From 525af5f840bcd421a687c2898007de8fac233c4d Mon Sep 17 00:00:00 2001 From: Stefan Ayala Date: Sun, 16 Aug 2026 07:33:02 -0700 Subject: [PATCH] fix: preserve specialized AGENTS during update --- README.md | 2 + install.ps1 | 13 ++-- install.sh | 15 +---- lib/refresh-manifest-hashes.cjs | 19 +++++- setup.sh | 11 ++++ skill-sources/update-wizard/SKILL.template.md | 2 + tests/test-adapter.sh | 21 +++---- tests/test-update.sh | 61 +++++++++++++++++-- update.sh | 21 +++++-- 9 files changed, 124 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 187c5da..2bba84f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Package upgrade vs repo repair: - Package upgrade: run `npx codex-sdlc-wizard@latest update` to consume the newest published package. - Repo repair/sync inside Codex: run `$update-wizard` to inspect and repair local SDLC artifacts using the skill/package already loaded in the active Codex session. +The deterministic updater preserves a specialized `AGENTS.md` unless its bytes match a separately recorded generator-owned baseline. It never treats a stale managed-file hash as ownership proof. For customized repositories, use `$update-wizard` to inspect and explicitly merge only the necessary policy delta while preserving the repository contract. + After either path changes skills, hooks, hook config, or helper scripts, restart/reopen Codex so the active session reloads them. Useful follow-ups after install: diff --git a/install.ps1 b/install.ps1 index ca579c3..f20c970 100644 --- a/install.ps1 +++ b/install.ps1 @@ -90,12 +90,17 @@ function Test-AgentsManifestMatch { try { $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json - $expected = $manifest.managed_files.$Path - if (-not $expected) { + $expectedProperty = $manifest.generated_files.PSObject.Properties[$Path] + if (-not $expectedProperty -or -not $expectedProperty.Value) { return $false } - $actual = "sha256:" + (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() - return $actual -eq $expected + $expected = [string]$expectedProperty.Value + $hashHelper = Join-Path $PSScriptRoot "lib\managed-file-hash.cjs" + $actual = & node $hashHelper hash $Path + if ($LASTEXITCODE -ne 0) { + return $false + } + return $actual.Trim() -eq $expected } catch { return $false } diff --git a/install.sh b/install.sh index c2d7abe..7006e44 100755 --- a/install.sh +++ b/install.sh @@ -251,19 +251,8 @@ install_agents_baseline() { "$source"); then echo "AGENTS.md already matches the wizard baseline - keeping it" elif [ -f ".codex-sdlc/manifest.json" ] && \ - AGENTS_TARGET="$target" node - <<'NODE' -const crypto = require("crypto"); -const fs = require("fs"); - -try { - const manifest = JSON.parse(fs.readFileSync(".codex-sdlc/manifest.json", "utf8")); - const expected = manifest.managed_files?.[process.env.AGENTS_TARGET]; - const actual = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(process.env.AGENTS_TARGET)).digest("hex")}`; - process.exit(expected === actual ? 0 : 1); -} catch { - process.exit(1); -} -NODE + [ "$(json_get_file ".codex-sdlc/manifest.json" 'data.generated_files?.["AGENTS.md"] || ""')" = \ + "$(node "$SCRIPT_DIR/lib/managed-file-hash.cjs" hash "$target")" ] then echo "AGENTS.md is wizard-managed - keeping it; profile guidance will be refreshed if needed" else diff --git a/lib/refresh-manifest-hashes.cjs b/lib/refresh-manifest-hashes.cjs index 010fd6a..578abd3 100644 --- a/lib/refresh-manifest-hashes.cjs +++ b/lib/refresh-manifest-hashes.cjs @@ -34,14 +34,14 @@ function writeTextAtomically(filePath, value) { function refreshProfileGuidance(manifest, touchedFiles, selectedProfile, baselineReasoning) { const agentsPath = "AGENTS.md"; const previousProfile = manifest.model_profile?.selected_profile; - const trackedHash = manifest.managed_files?.[agentsPath]; + const generatedHash = manifest.generated_files?.[agentsPath]; if ( typeof previousProfile !== "string" || previousProfile === "" || previousProfile === selectedProfile || - typeof trackedHash !== "string" || + typeof generatedHash !== "string" || !fs.existsSync(agentsPath) || - !inspectManagedFile(agentsPath, trackedHash).matches_expected + !inspectManagedFile(agentsPath, generatedHash).matches_expected ) { return false; } @@ -146,6 +146,10 @@ function main() { if (!managed || typeof managed !== "object" || Array.isArray(managed)) { throw new Error(`${manifestPath}.managed_files must contain a JSON object`); } + const generated = manifest.generated_files; + if (generated !== undefined && (!generated || typeof generated !== "object" || Array.isArray(generated))) { + throw new Error(`${manifestPath}.generated_files must contain an object when present`); + } const touchedFileSet = new Set(touchedFiles); let changed = synchronizeModelProfile(manifest, touchedFileSet); @@ -162,6 +166,15 @@ function main() { managed[filePath] = currentHash; changed = true; } + if ( + filePath === "AGENTS.md" && + generated && + Object.prototype.hasOwnProperty.call(generated, filePath) && + generated[filePath] !== currentHash + ) { + generated[filePath] = currentHash; + changed = true; + } } if (changed) writeAtomically(manifestPath, manifest); } diff --git a/setup.sh b/setup.sh index d93d1de..b0d0199 100644 --- a/setup.sh +++ b/setup.sh @@ -1198,6 +1198,11 @@ compute_hash() { } MANIFEST=".codex-sdlc/manifest.json" +PRIOR_AGENTS_GENERATED_HASH="$(json_get_file "$MANIFEST" 'data.generated_files?.["AGENTS.md"] || ""')" +AGENTS_GENERATED_HASH="$PRIOR_AGENTS_GENERATED_HASH" +if [ "$SETUP_GENERATED_AGENTS" = "true" ]; then + AGENTS_GENERATED_HASH="$(compute_hash AGENTS.md)" +fi ADAPTER_VERSION="$ADAPTER_VERSION" \ INSTALLED_AT="$INSTALLED_AT_VALUE" \ @@ -1267,6 +1272,7 @@ REASONING_BASELINE_SELECTED="$REASONING_BASELINE" \ REASONING_ESCALATION_SELECTED="$REASONING_ESCALATION" \ REASONING_RISK_SIGNALS_SELECTED="$REASONING_RISK_SIGNALS" \ AGENTS_HASH="$(compute_hash AGENTS.md)" \ +AGENTS_GENERATED_HASH="$AGENTS_GENERATED_HASH" \ SDLC_HASH="$(compute_hash SDLC.md)" \ TESTING_HASH="$(compute_hash TESTING.md)" \ ARCH_HASH="$(compute_hash ARCHITECTURE.md)" \ @@ -1367,6 +1373,7 @@ const manifest = { escalation_reasoning: process.env.REASONING_ESCALATION_SELECTED || "xhigh", repo_risk_signals: process.env.REASONING_RISK_SIGNALS_SELECTED || "none detected during setup" }, + generated_files: {}, managed_files: { "AGENTS.md": process.env.AGENTS_HASH || "", "SDLC.md": process.env.SDLC_HASH || "", @@ -1391,6 +1398,10 @@ const manifest = { } }; +if (process.env.AGENTS_GENERATED_HASH) { + manifest.generated_files["AGENTS.md"] = process.env.AGENTS_GENERATED_HASH; +} + if (process.env.MANAGE_GOALS === "true" && process.env.GOALS_HASH) { manifest.managed_files["GOALS.md"] = process.env.GOALS_HASH; } diff --git a/skill-sources/update-wizard/SKILL.template.md b/skill-sources/update-wizard/SKILL.template.md index 628a1c4..3a5e101 100644 --- a/skill-sources/update-wizard/SKILL.template.md +++ b/skill-sources/update-wizard/SKILL.template.md @@ -11,6 +11,8 @@ You are a guided update assistant. Your job is to show what changed, detect drif Do not blindly overwrite files. +The deterministic updater may refresh `AGENTS.md` only when the physical file still matches the separately recorded generator-owned baseline hash. A stale `managed_files` hash is not proof of generator ownership. If provenance is missing or the physical file differs, preserve `AGENTS.md` byte-for-byte. In the AI-guided path, inspect the diff and offer a narrow semantic merge of only the required model-policy lines; never replace specialized repository rules with a generic generated contract. + Version boundary: `$update-wizard` updates repo artifacts using the wizard version already installed in the active Codex skill/session. It does not self-update the npm package. To consume the newest published package first, tell the user to run `npx codex-sdlc-wizard@latest update` from the repo, then restart or resume Codex so refreshed skills/hooks/config load. ## Package upgrade preflight diff --git a/tests/test-adapter.sh b/tests/test-adapter.sh index a7ec248..c33fd4f 100755 --- a/tests/test-adapter.sh +++ b/tests/test-adapter.sh @@ -3519,9 +3519,12 @@ test_installers_share_agents_ownership_messages() { grep -Fq 'AGENTS.md is user-owned or customized - preserving it' "$installer" || valid=false done grep -Fq ') -ceq $content)' "$REPO_DIR/install.ps1" || valid=false + grep -Fq '$manifest.generated_files.PSObject.Properties[$Path]' "$REPO_DIR/install.ps1" || valid=false + grep -Fq 'lib\managed-file-hash.cjs' "$REPO_DIR/install.ps1" || valid=false + ! grep -Fq '$manifest.managed_files.$Path' "$REPO_DIR/install.ps1" || valid=false if [ "$valid" = "true" ]; then - pass "shell and PowerShell installers share explicit AGENTS.md ownership messages" + pass "shell and PowerShell installers share AGENTS.md ownership semantics" else fail "shell and PowerShell installers do not share AGENTS.md ownership semantics" fi @@ -4021,18 +4024,12 @@ test_install_refreshes_unmodified_agents_for_profile_switch() { bash "$REPO_DIR/setup.sh" --yes --model-profile mixed >/dev/null 2>&1 ) || valid=false - cat > "$tmpdir/AGENTS.md" <<'EOF' -# SDLC Enforcement - -- Selected profile: `mixed` -- Baseline reasoning: `medium` -EOF - MANIFEST_PATH="$tmpdir/.codex-sdlc/manifest.json" AGENTS_PATH="$tmpdir/AGENTS.md" node <<'NODE' -const crypto = require("crypto"); + grep -Fq -- '- Selected profile: mixed' "$tmpdir/AGENTS.md" || valid=false + MANIFEST_PATH="$tmpdir/.codex-sdlc/manifest.json" node <<'NODE' || valid=false const fs = require("fs"); const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_PATH, "utf8")); -manifest.managed_files["AGENTS.md"] = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(process.env.AGENTS_PATH)).digest("hex")}`; -fs.writeFileSync(process.env.MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`); +if (!manifest.generated_files?.["AGENTS.md"]) process.exit(1); +if (manifest.generated_files["AGENTS.md"] !== manifest.managed_files["AGENTS.md"]) process.exit(1); NODE install_output=$( @@ -4042,7 +4039,7 @@ NODE ) || valid=false output=$(cd "$tmpdir" && bash "$REPO_DIR/check.sh" 2>/dev/null) - grep -Fq -- '- Selected profile: `maximum`' "$tmpdir/AGENTS.md" || valid=false + grep -Fq -- '- Selected profile: maximum' "$tmpdir/AGENTS.md" || valid=false grep -Fq -- '- Baseline reasoning: `high`' "$tmpdir/AGENTS.md" || valid=false ! grep -Fq -- '- Selected profile: mixed' "$tmpdir/AGENTS.md" || valid=false echo "$install_output" | grep -Fq 'AGENTS.md is wizard-managed - keeping it; profile guidance will be refreshed if needed' || valid=false diff --git a/tests/test-update.sh b/tests/test-update.sh index afe1305..c1ddf8c 100644 --- a/tests/test-update.sh +++ b/tests/test-update.sh @@ -1467,7 +1467,53 @@ EOF fi } -# ---- Test 16: matching legacy metadata migrates without losing explicit profile choice ---- +# ---- Test 16: stale manifest ownership cannot authorize replacing specialized AGENTS ---- +test_update_preserves_specialized_agents_when_generator_provenance_is_missing() { + local ws output agents_before valid=true + ws=$(mktemp -d "$MKTEMP_DIR/update-test.XXXXXX") + echo '{"name":"test-app","scripts":{"test":"jest"}}' > "$ws/package.json" + mkdir -p "$ws/src" + + run_setup_local_args "$ws" --model-profile mixed + cat > "$ws/AGENTS.md" <<'EOF' +# Specialized Anticheat contract + +- Selected profile: mixed +- Baseline reasoning: `xhigh` +- Preserve medical/legal review, source-quality gates, and account boundaries. +EOF + agents_before=$(cat "$ws/AGENTS.md") + MANIFEST_PATH="$ws/.codex-sdlc/manifest.json" \ + AGENTS_PATH="$ws/AGENTS.md" \ + node <<'NODE' +const crypto = require("crypto"); +const fs = require("fs"); + +const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_PATH, "utf8")); +manifest.managed_files["AGENTS.md"] = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(process.env.AGENTS_PATH)).digest("hex")}`; +manifest.model_profile.policy_schema_version = 1; +delete manifest.generated_files; +fs.writeFileSync(process.env.MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`); +NODE + + output=$(run_update "$ws") || valid=false + + [ "$(cat "$ws/AGENTS.md")" = "$agents_before" ] || valid=false + grep -Fq 'medical/legal review' "$ws/AGENTS.md" || valid=false + echo "$output" | grep -Fq 'AGENTS.md: match -> skip (preserve customization)' || valid=false + json_text_equals "$(cat "$ws/.codex-sdlc/manifest.json")" 'data.model_profile.policy_schema_version' "3" || valid=false + + rm -rf "$ws" + + if [ "$valid" = "true" ]; then + pass "update preserves specialized AGENTS when generator provenance is missing" + else + echo "$output" >&2 + fail "update trusted stale AGENTS manifest ownership without generator provenance" + fi +} + +# ---- Test 17: matching legacy metadata migrates without losing explicit profile choice ---- test_update_refreshes_matching_legacy_model_profile_metadata() { local ws output check_output valid=true ws=$(mktemp -d "$MKTEMP_DIR/update-test.XXXXXX") @@ -1516,6 +1562,7 @@ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); const hash = crypto.createHash("sha256").update(fs.readFileSync(profilePath)).digest("hex"); manifest.managed_files[".codex-sdlc/model-profile.json"] = `sha256:${hash}`; manifest.managed_files["AGENTS.md"] = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(agentsPath)).digest("hex")}`; +manifest.generated_files["AGENTS.md"] = manifest.managed_files["AGENTS.md"]; manifest.model_profile.selected_profile = "mixed"; fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); NODE @@ -1528,6 +1575,7 @@ NODE json_text_equals "$(cat "$ws/.codex-sdlc/model-profile.json")" 'data.profiles.mixed.main_model' "gpt-5.6-terra" || valid=false json_text_equals "$(cat "$ws/.codex-sdlc/model-profile.json")" 'data.profiles.mixed.review_model' "gpt-5.6-sol" || valid=false json_text_equals "$(cat "$ws/.codex-sdlc/model-profile.json")" 'data.profiles.mixed.review_effort_source' "explicit command override" || valid=false + json_text_equals "$(cat "$ws/.codex-sdlc/manifest.json")" 'data.generated_files["AGENTS.md"] === data.managed_files["AGENTS.md"]' "true" || valid=false grep -Fq 'Selected profile: mixed' "$ws/AGENTS.md" || valid=false grep -Fq 'Baseline reasoning: `medium`' "$ws/AGENTS.md" || valid=false grep -Fq 'legacy-mini' "$ws/AGENTS.md" && valid=false @@ -1546,7 +1594,7 @@ NODE fi } -# ---- Test 17: missing managed metadata refreshes matching generated model policy ---- +# ---- Test 18: missing managed metadata refreshes matching generated model policy ---- test_update_refreshes_generated_policy_when_profile_metadata_is_missing() { local ws output valid=true ws=$(mktemp -d "$MKTEMP_DIR/update-test.XXXXXX") @@ -1571,6 +1619,7 @@ const manifestPath = process.env.MANIFEST_PATH; const agentsPath = process.env.AGENTS_PATH; const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); manifest.managed_files["AGENTS.md"] = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(agentsPath)).digest("hex")}`; +manifest.generated_files["AGENTS.md"] = manifest.managed_files["AGENTS.md"]; manifest.model_profile.selected_profile = "mixed"; fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); NODE @@ -1595,7 +1644,7 @@ NODE fi } -# ---- Test 18: legacy model migration refreshes unchanged policy surfaces only ---- +# ---- Test 19: legacy model migration refreshes unchanged policy surfaces only ---- test_update_refreshes_legacy_policy_surfaces_without_overwriting_customizations() { local ws output check_output valid=true ws=$(mktemp -d "$MKTEMP_DIR/update-test.XXXXXX") @@ -1657,6 +1706,7 @@ const hash = (file) => `sha256:${crypto.createHash("sha256").update(fs.readFileS const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_PATH, "utf8")); manifest.managed_files[".codex-sdlc/model-profile.json"] = hash(process.env.PROFILE_PATH); manifest.managed_files["AGENTS.md"] = hash(process.env.AGENTS_PATH); +manifest.generated_files["AGENTS.md"] = manifest.managed_files["AGENTS.md"]; manifest.managed_files["SDLC-LOOP.md"] = hash(process.env.LOOP_PATH); manifest.managed_files["START-SDLC.md"] = hash(process.env.START_PATH); delete manifest.managed_files[".agents/skills/sdlc/SKILL.md"]; @@ -1702,7 +1752,7 @@ NODE fi } -# ---- Test 19: customized legacy metadata still migrates unchanged policy surfaces ---- +# ---- Test 20: customized legacy metadata still migrates unchanged policy surfaces ---- test_update_migrates_legacy_policy_around_customized_profile_metadata() { local ws output second_output profile_before check_output valid=true ws=$(mktemp -d "$MKTEMP_DIR/update-test.XXXXXX") @@ -1762,7 +1812,7 @@ EOF fi } -# ---- Test 20: customized policy surfaces still record one-time migration completion ---- +# ---- Test 21: customized policy surfaces still record one-time migration completion ---- test_update_records_schema_only_migration_when_all_policy_surfaces_are_customized() { local ws output second_output profile_before agents_before loop_before start_before skill_before valid=true ws=$(mktemp -d "$MKTEMP_DIR/update-test.XXXXXX") @@ -2039,6 +2089,7 @@ test_update_refreshes_playwright_mcp_policy_for_old_manifest test_update_refreshes_changed_playwright_mcp_policy test_update_repairs_missing_goals_doc_when_manifest_tracks_it test_update_rejects_unsupported_codex_version_before_mutation +test_update_preserves_specialized_agents_when_generator_provenance_is_missing test_update_refreshes_matching_legacy_model_profile_metadata test_update_refreshes_generated_policy_when_profile_metadata_is_missing test_update_refreshes_legacy_policy_surfaces_without_overwriting_customizations diff --git a/update.sh b/update.sh index 6c8dcc7..4619fe9 100644 --- a/update.sh +++ b/update.sh @@ -197,6 +197,14 @@ file_sha256() { node "$SCRIPT_DIR/lib/managed-file-hash.cjs" hash "$file_path" } +agents_matches_generated_baseline() { + local generated_hash + generated_hash="$(json_get_file ".codex-sdlc/manifest.json" 'data.generated_files?.["AGENTS.md"] || ""')" + [ -n "$generated_hash" ] || return 1 + [ -f "AGENTS.md" ] || return 1 + [ "$(file_sha256 "AGENTS.md")" = "$generated_hash" ] +} + package_source_for_managed_file() { local relative_path="$1" @@ -627,10 +635,15 @@ for line in "${STATUS_LINES[@]}"; do RUN_REGENERATE=true queue_static_repair "$relative_path" elif [ "$relative_path" = "AGENTS.md" ] && [ "$MODEL_PROFILE_MIGRATION" = "true" ]; then - action="refresh generated model policy" - CHANGES_PENDING=true - RUN_REGENERATE=true - queue_regenerate_existing_doc "$relative_path" + if agents_matches_generated_baseline; then + action="refresh generated model policy" + CHANGES_PENDING=true + RUN_REGENERATE=true + queue_regenerate_existing_doc "$relative_path" + else + action="skip (preserve customization)" + SKIPPED_CUSTOMIZED_PATHS+=("$relative_path") + fi elif [ "$MODEL_PROFILE_MIGRATION" = "true" ] && is_model_policy_static_surface "$relative_path"; then action="refresh model policy" CHANGES_PENDING=true