Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 9 additions & 4 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
15 changes: 2 additions & 13 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions lib/refresh-manifest-hashes.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand Down
11 changes: 11 additions & 0 deletions setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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)" \
Expand Down Expand Up @@ -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 || "",
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions skill-sources/update-wizard/SKILL.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 9 additions & 12 deletions tests/test-adapter.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=$(
Expand All @@ -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
Expand Down
61 changes: 56 additions & 5 deletions tests/test-update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions update.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down