diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index d3619de..cca7536 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,7 +9,7 @@ "source": { "source": "npm", "package": "@sdsrs/agentsmd", - "version": "4.25.4 || 5.0.0" + "version": "5.0.0 || 5.0.1" }, "policy": { "installation": "AVAILABLE", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 513759c..9aa660d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "agentsmd", - "version": "5.0.0", + "version": "5.0.1", "surfaceProtocolVersion": 1, "description": "CODEX-CODING-SPEC — global Codex workflow and evidence guidance, with native hooks for selected safety/report checks and rule-specific opportunity telemetry.", "author": { diff --git a/CHANGELOG.md b/CHANGELOG.md index d91240a..96915f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ Release history for **agentsmd** (the Codex coding-spec enforcement plugin). The spec's own rule-level history lives in `spec/AGENTS-CHANGELOG.md`. +## v5.0.1 — 2026-07-28 — registry install-readiness retry (patch) + +- Extended the tested post-publish registry helper so one retry attempt includes + both `npm pack` and an isolated `npm install --ignore-scripts` probe. A + successful tarball fetch no longer ends the retry loop while the install + packument can still return transient `ETARGET`. +- Failed install-readiness attempts remove only that attempt's new top-level + tarball and exact temporary install prefix before waiting. Pre-existing + tarballs, nested files, and unrelated destination contents remain untouched. +- Added direct and real CLI regressions for pack-success/install-lag recovery, + install-readiness exhaustion, probe disposal, and operation beneath + `bash -euo pipefail`. +- The immutable v5.0.0 package and GitHub release were published successfully; + their first release-workflow attempt exposed the multi-endpoint propagation + race after publication, and the idempotent failed-job rerun completed every + release gate. This successor patch closes that race in the reusable helper. + ## v5.0.0 — 2026-07-28 — single full profile and native Codex orchestration contract (major) ### Breaking profile simplification diff --git a/install.sh b/install.sh index 84575af..ad47e36 100644 --- a/install.sh +++ b/install.sh @@ -15,7 +15,7 @@ set -eu NAME="agentsmd" DEFAULT_REPO="sdsrss/agentsmd" # Synchronized by scripts/version-sync.js — must equal package.json version. -INSTALLER_VERSION="5.0.0" +INSTALLER_VERSION="5.0.1" DEFAULT_REF="v$INSTALLER_VERSION" ACTION="install" diff --git a/memory/reference_release-registry-propagation.md b/memory/reference_release-registry-propagation.md index 337f1ae..6157fee 100644 --- a/memory/reference_release-registry-propagation.md +++ b/memory/reference_release-registry-propagation.md @@ -1,4 +1,4 @@ -verified: 2026-07-27 | source: GitHub Actions release runs 30262890085 and 30264165884 +verified: 2026-07-28 | source: GitHub Actions release runs 30262890085, 30264165884, and 30334474903 # Release registry propagation retries @@ -9,7 +9,14 @@ forms exited the step before the retry body despite passing under local `bash -euo pipefail`. Put the npm subprocess and retry loop inside a tested Node helper. The helper -captures child exit statuses, removes only newly created top-level tarball -paths from its bounded destination between attempts, waits, and returns nonzero -to the workflow only after the configured attempts are exhausted. This keeps -shell `errexit` outside the retryable failure boundary. +must treat the tarball fetch and install metadata as one readiness attempt: +`npm pack` can succeed while the next `npm install name@version` still returns +transient `ETARGET` from a lagging packument endpoint. Run the install probe in +an exact temporary prefix with lifecycle scripts disabled. + +Capture both child exit statuses, remove only newly created top-level tarball +paths and the exact temporary install prefix between attempts, wait, and return +nonzero to the workflow only after the configured attempts are exhausted. This +keeps shell `errexit` outside the retryable failure boundary and does not +declare propagation complete before the workflow's install-based signature +audit can resolve the version. diff --git a/package.json b/package.json index ebd70f9..ebaafdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sdsrs/agentsmd", - "version": "5.0.0", + "version": "5.0.1", "description": "A global coding-discipline spec for Codex with native hooks, reversible global AGENTS.md installation, and rule-specific opportunity telemetry.", "homepage": "https://github.com/sdsrss/agentsmd#readme", "bugs": "https://github.com/sdsrss/agentsmd/issues", diff --git a/scripts/lib/registry-pack-retry.js b/scripts/lib/registry-pack-retry.js index a7fb854..efe3c4e 100644 --- a/scripts/lib/registry-pack-retry.js +++ b/scripts/lib/registry-pack-retry.js @@ -37,6 +37,20 @@ function cleanTarballs(destination, preserved = new Set()) { } } +function installProbeArgs(packageSpec, probeDirectory) { + return [ + 'install', + packageSpec, + '--prefix', + probeDirectory, + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + '--prefer-online', + ]; +} + async function packRegistryArtifact({ packageSpec, destination, @@ -58,9 +72,10 @@ async function packRegistryArtifact({ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; let lastStatus = null; + let lastStage = 'pack'; for (let attempt = 1; attempt <= attempts; attempt += 1) { realDirectoryEntries(destination); - const result = spawn(npm, [ + const packResult = spawn(npm, [ 'pack', packageSpec, '--pack-destination', @@ -68,21 +83,47 @@ async function packRegistryArtifact({ '--json', '--prefer-online', ], { stdio: 'inherit' }); - if (result.error) throw result.error; - if (result.status === 0) return { attempt, status: 0 }; + if (packResult.error) { + cleanTarballs(destination, preservedTarballs); + throw packResult.error; + } + + lastStage = 'pack'; + lastStatus = packResult.status; + if (packResult.status === 0) { + const probeDirectory = fs.mkdtempSync(path.join(destination, '.agentsmd-install-probe.')); + try { + const installResult = spawn( + npm, + installProbeArgs(packageSpec, probeDirectory), + { stdio: 'inherit' } + ); + if (installResult.error) { + cleanTarballs(destination, preservedTarballs); + throw installResult.error; + } + lastStage = 'install'; + lastStatus = installResult.status; + if (installResult.status === 0) return { attempt, status: 0 }; + } finally { + fs.rmSync(probeDirectory, { recursive: true, force: true }); + } + } - lastStatus = result.status; cleanTarballs(destination, preservedTarballs); if (attempt < attempts) { - log(`waiting for ${packageSpec} registry bytes (attempt ${attempt}/${attempts}, npm exit ${result.status})`); + const readiness = lastStage === 'pack' ? 'registry bytes' : 'registry install readiness'; + log(`waiting for ${packageSpec} ${readiness} (attempt ${attempt}/${attempts}, npm ${lastStage} exit ${lastStatus})`); await wait(delayMs); } } - throw new Error(`${packageSpec} registry bytes were unavailable after ${attempts} attempts (last npm exit ${lastStatus})`); + const readiness = lastStage === 'pack' ? 'registry bytes' : 'registry install readiness'; + throw new Error(`${packageSpec} ${readiness} was unavailable after ${attempts} attempts (last npm ${lastStage} exit ${lastStatus})`); } module.exports = { cleanTarballs, + installProbeArgs, packRegistryArtifact, positiveInteger, realDirectoryEntries, diff --git a/scripts/tests/release-registry-pack.test.js b/scripts/tests/release-registry-pack.test.js index 826bc2a..dc002cb 100644 --- a/scripts/tests/release-registry-pack.test.js +++ b/scripts/tests/release-registry-pack.test.js @@ -31,18 +31,31 @@ const fs = require('fs'); const path = require('path'); const stateFile = process.env.AGENTSMD_FAKE_NPM_STATE; const destination = process.env.AGENTSMD_FAKE_NPM_DESTINATION; -const succeedAfter = Number(process.env.AGENTSMD_FAKE_NPM_SUCCEED_AFTER); -const attempt = fs.existsSync(stateFile) ? Number(fs.readFileSync(stateFile, 'utf8')) + 1 : 1; -fs.writeFileSync(stateFile, String(attempt)); -fs.writeFileSync(path.join(destination, attempt >= succeedAfter ? 'final.tgz' : \`partial-\${attempt}.tgz\`), 'bytes'); -process.exit(attempt >= succeedAfter ? 0 : 1); +const packSucceedAfter = Number(process.env.AGENTSMD_FAKE_NPM_PACK_SUCCEED_AFTER); +const installSucceedAfter = Number(process.env.AGENTSMD_FAKE_NPM_INSTALL_SUCCEED_AFTER); +const state = fs.existsSync(stateFile) + ? JSON.parse(fs.readFileSync(stateFile, 'utf8')) + : { pack: 0, install: 0 }; +const command = process.argv[2]; +if (command === 'pack') { + state.pack += 1; + fs.writeFileSync(stateFile, JSON.stringify(state)); + fs.writeFileSync(path.join(destination, state.pack >= packSucceedAfter ? 'final.tgz' : \`partial-\${state.pack}.tgz\`), 'bytes'); + process.exit(state.pack >= packSucceedAfter ? 0 : 1); +} +if (command === 'install') { + state.install += 1; + fs.writeFileSync(stateFile, JSON.stringify(state)); + process.exit(state.install >= installSucceedAfter ? 0 : 1); +} +process.exit(2); `); fs.chmodSync(executable, 0o755); t.after(() => fs.rmSync(bin, { recursive: true, force: true })); return bin; } -function runCliProbe(t, { attempts, succeedAfter }) { +function runCliProbe(t, { attempts, packSucceedAfter, installSucceedAfter = 1 }) { const root = sandbox(t); const destination = path.join(root, 'destination'); const stateFile = path.join(root, 'attempts.txt'); @@ -69,13 +82,14 @@ function runCliProbe(t, { attempts, succeedAfter }) { ...process.env, AGENTSMD_FAKE_NPM_DESTINATION: destination, AGENTSMD_FAKE_NPM_STATE: stateFile, - AGENTSMD_FAKE_NPM_SUCCEED_AFTER: String(succeedAfter), + AGENTSMD_FAKE_NPM_PACK_SUCCEED_AFTER: String(packSucceedAfter), + AGENTSMD_FAKE_NPM_INSTALL_SUCCEED_AFTER: String(installSucceedAfter), PATH: `${bin}${path.delimiter}${process.env.PATH}`, }, }); return { ...result, - attempts: Number(fs.readFileSync(stateFile, 'utf8')), + state: JSON.parse(fs.readFileSync(stateFile, 'utf8')), files: fs.readdirSync(destination).sort(), }; } @@ -83,10 +97,11 @@ function runCliProbe(t, { attempts, succeedAfter }) { test('transient npm failures are captured inside Node and retried to success', async (t) => { const destination = sandbox(t); fs.writeFileSync(path.join(destination, 'keep.txt'), 'unrelated'); - const statuses = [1, 1, 0]; + const packStatuses = [1, 1, 0]; const waits = []; const logs = []; - let calls = 0; + let packCalls = 0; + let installCalls = 0; const result = await packRegistryArtifact({ packageSpec: '@sdsrs/agentsmd@9.9.9', destination, @@ -94,6 +109,21 @@ test('transient npm failures are captured inside Node and retried to success', a delayMs: 7, spawn(command, args, options) { assert.strictEqual(command, process.platform === 'win32' ? 'npm.cmd' : 'npm'); + assert.deepStrictEqual(options, { stdio: 'inherit' }); + if (args[0] === 'install') { + installCalls += 1; + assert.strictEqual(args[1], '@sdsrs/agentsmd@9.9.9'); + assert.strictEqual(args[2], '--prefix'); + assert.match(args[3], /\.agentsmd-install-probe\./); + assert.deepStrictEqual(args.slice(4), [ + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + '--prefer-online', + ]); + return { status: 0 }; + } assert.deepStrictEqual(args, [ 'pack', '@sdsrs/agentsmd@9.9.9', @@ -102,10 +132,9 @@ test('transient npm failures are captured inside Node and retried to success', a '--json', '--prefer-online', ]); - assert.deepStrictEqual(options, { stdio: 'inherit' }); - calls += 1; - fs.writeFileSync(path.join(destination, calls === 3 ? 'final.tgz' : `partial-${calls}.tgz`), 'bytes'); - return { status: statuses.shift() }; + packCalls += 1; + fs.writeFileSync(path.join(destination, packCalls === 3 ? 'final.tgz' : `partial-${packCalls}.tgz`), 'bytes'); + return { status: packStatuses.shift() }; }, wait(delay) { waits.push(delay); }, log(message) { logs.push(message); }, @@ -114,9 +143,41 @@ test('transient npm failures are captured inside Node and retried to success', a assert.deepStrictEqual(result, { attempt: 3, status: 0 }); assert.deepStrictEqual(waits, [7, 7]); assert.strictEqual(logs.length, 2); + assert.strictEqual(packCalls, 3); + assert.strictEqual(installCalls, 1); assert.deepStrictEqual(fs.readdirSync(destination).sort(), ['final.tgz', 'keep.txt']); }); +test('pack success is retried until npm install metadata is ready', async (t) => { + const destination = sandbox(t); + const calls = []; + let packAttempts = 0; + let installAttempts = 0; + const result = await packRegistryArtifact({ + packageSpec: '@sdsrs/agentsmd@9.9.9', + destination, + attempts: 3, + delayMs: 0, + spawn(command, args) { + calls.push(args[0]); + if (args[0] === 'pack') { + packAttempts += 1; + fs.writeFileSync(path.join(destination, `registry-${packAttempts}.tgz`), 'bytes'); + return { status: 0 }; + } + assert.strictEqual(args[0], 'install'); + installAttempts += 1; + return { status: installAttempts === 1 ? 1 : 0 }; + }, + wait() {}, + log() {}, + }); + + assert.deepStrictEqual(result, { attempt: 2, status: 0 }); + assert.deepStrictEqual(calls, ['pack', 'install', 'pack', 'install']); + assert.deepStrictEqual(fs.readdirSync(destination), ['registry-2.tgz']); +}); + test('failed attempts preserve pre-existing and nested tarballs', async (t) => { const destination = sandbox(t); const nested = path.join(destination, 'nested'); @@ -129,7 +190,8 @@ test('failed attempts preserve pre-existing and nested tarballs', async (t) => { destination, attempts: 2, delayMs: 0, - spawn() { + spawn(command, args) { + if (args[0] === 'install') return { status: 0 }; calls += 1; fs.writeFileSync(path.join(destination, calls === 2 ? 'final.tgz' : 'partial.tgz'), 'bytes'); return { status: calls === 2 ? 0 : 1 }; @@ -161,12 +223,42 @@ test('retry exhaustion cleans partial tarballs and reports the final child statu wait() {}, log() {}, }), - /unavailable after 2 attempts \(last npm exit 1\)/ + /unavailable after 2 attempts \(last npm pack exit 1\)/ ); assert.strictEqual(calls, 2); assert.deepStrictEqual(fs.readdirSync(destination), []); }); +test('install readiness exhaustion cleans every probe and new tarball', async (t) => { + const destination = sandbox(t); + fs.writeFileSync(path.join(destination, 'preserved.tgz'), 'pre-existing'); + let packCalls = 0; + let installCalls = 0; + await assert.rejects( + packRegistryArtifact({ + packageSpec: '@sdsrs/agentsmd@9.9.9', + destination, + attempts: 2, + delayMs: 0, + spawn(command, args) { + if (args[0] === 'install') { + installCalls += 1; + return { status: 1 }; + } + packCalls += 1; + fs.writeFileSync(path.join(destination, `attempt-${packCalls}.tgz`), 'bytes'); + return { status: 0 }; + }, + wait() {}, + log() {}, + }), + /registry install readiness was unavailable after 2 attempts \(last npm install exit 1\)/ + ); + assert.strictEqual(packCalls, 2); + assert.strictEqual(installCalls, 2); + assert.deepStrictEqual(fs.readdirSync(destination), ['preserved.tgz']); +}); + test('spawn errors fail immediately instead of masquerading as registry propagation', async (t) => { const destination = sandbox(t); let waits = 0; @@ -215,18 +307,31 @@ test('CLI usage errors remain distinct from retry exhaustion', async () => { }); test('real CLI retries successfully beneath bash errexit and pipefail', (t) => { - const result = runCliProbe(t, { attempts: 4, succeedAfter: 3 }); + const result = runCliProbe(t, { attempts: 4, packSucceedAfter: 3 }); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(result.state, { pack: 3, install: 1 }); + assert.deepStrictEqual(result.files, ['final.tgz']); + assert.match(result.stderr, /waiting for .* \(attempt 1\/4, npm pack exit 1\)/); + assert.match(result.stderr, /waiting for .* \(attempt 2\/4, npm pack exit 1\)/); +}); + +test('real CLI retries pack and install as one attempt beneath bash errexit', (t) => { + const result = runCliProbe(t, { + attempts: 4, + packSucceedAfter: 1, + installSucceedAfter: 3, + }); assert.strictEqual(result.status, 0, result.stderr); - assert.strictEqual(result.attempts, 3); + assert.deepStrictEqual(result.state, { pack: 3, install: 3 }); assert.deepStrictEqual(result.files, ['final.tgz']); - assert.match(result.stderr, /waiting for .* \(attempt 1\/4, npm exit 1\)/); - assert.match(result.stderr, /waiting for .* \(attempt 2\/4, npm exit 1\)/); + assert.match(result.stderr, /registry install readiness \(attempt 1\/4, npm install exit 1\)/); + assert.match(result.stderr, /registry install readiness \(attempt 2\/4, npm install exit 1\)/); }); test('real CLI reports exhaustion only after every attempt beneath bash errexit', (t) => { - const result = runCliProbe(t, { attempts: 2, succeedAfter: 99 }); + const result = runCliProbe(t, { attempts: 2, packSucceedAfter: 99 }); assert.strictEqual(result.status, 1); - assert.strictEqual(result.attempts, 2); + assert.deepStrictEqual(result.state, { pack: 2, install: 0 }); assert.deepStrictEqual(result.files, []); - assert.match(result.stderr, /unavailable after 2 attempts \(last npm exit 1\)/); + assert.match(result.stderr, /unavailable after 2 attempts \(last npm pack exit 1\)/); }); diff --git a/scripts/tests/spec-source.test.js b/scripts/tests/spec-source.test.js index b10e02b..83d58cf 100644 --- a/scripts/tests/spec-source.test.js +++ b/scripts/tests/spec-source.test.js @@ -22,7 +22,7 @@ t('canonical layout renders the committed full artifact byte-for-byte', () => { for (const [relative, content] of rendered) { assert.deepStrictEqual(content, fs.readFileSync(path.join(ROOT, relative)), relative); } - assert.strictEqual(sha256(rendered.get('spec/AGENTS.md')), '1d24fdec823596962933da6004e1fc957a74efdbc2653f33e4ac5b021ae4dc30'); + assert.strictEqual(sha256(rendered.get('spec/AGENTS.md')), '9749a8a8351a2f651743da546db74cd28405e09ce5eea7d13cbcf546464262c2'); }); t('spec:check is read-only and reports the full output in sync', () => { diff --git a/spec/AGENTS-CHANGELOG.md b/spec/AGENTS-CHANGELOG.md index 81fc6e3..8838c20 100644 --- a/spec/AGENTS-CHANGELOG.md +++ b/spec/AGENTS-CHANGELOG.md @@ -2,6 +2,13 @@ Single changelog for the pair `~/.codex/AGENTS.md` (core) + `~/.codex/AGENTS-extended.md` (extended). From v1.4.0 both files carry ONE shared version and move together. This file sits outside the Codex discovery chain and costs zero context; the agent never loads it unless explicitly asked. +## v5.0.1 (2026-07-28) — no spec changes (registry readiness patch) + +Core and extended rule text, manifest rules, and every `section_anchor` are +unchanged. The shared version moves with the package patch that makes the +post-publish registry retry wait for both tarball bytes and isolated npm install +readiness. `spec_version` → v5.0.1. Detail: repository `CHANGELOG.md`. + ## v5.0.0 (2026-07-28) — native subagent leadership in the single full core The full core's §4 now defines the runtime-independent portion of OMX's useful diff --git a/spec/AGENTS-extended.md b/spec/AGENTS-extended.md index 24fbe7f..83db384 100644 --- a/spec/AGENTS-extended.md +++ b/spec/AGENTS-extended.md @@ -1,4 +1,4 @@ -# CODEX-CODING-SPEC v5.0.0 — Extended +# CODEX-CODING-SPEC v5.0.1 — Extended Location: packaged with the active delivery surface (standalone: `$CODEX_HOME/AGENTS-extended.md`; plugin: inside the plugin bundle) — SessionStart announces the resolved path. NOT in the Codex discovery chain — costs zero `project_doc_max_bytes` budget; the agent reads it explicitly. Load triggers: defined ONCE in the core header (**Extended** line); core is the single source — this file does not restate them. How: read the whole file once at trigger, before ROUTE/plan; re-read on resume whenever the task file's `spec: … loaded` line is present but this file's content is not in context, and after any suspected compaction. Core spec always wins on conflict; §8 SAFETY and all three Iron Laws bind here unchanged — the only sanctioned modulation is core §6's EMERGENCY deferral of #1/#3. diff --git a/spec/AGENTS.md b/spec/AGENTS.md index cf881c9..f17c628 100644 --- a/spec/AGENTS.md +++ b/spec/AGENTS.md @@ -1,4 +1,4 @@ -# CODEX-CODING-SPEC v5.0.0 — Global +# CODEX-CODING-SPEC v5.0.1 — Global **Discovery**: Global uses `$CODEX_HOME/AGENTS.override.md` else `AGENTS.md`; project files load root→cwd with override precedence. The combined cap (32 KiB default) truncates silently; core reserves room for project rules. Closer layers may override defaults, NEVER §8 or §5-hard. **Extended**: standalone uses `~/.codex/AGENTS-extended.md`; plugin SessionStart announces its packaged path — MUST read on **L3** · **ship intent** (`push` shared / merge / PR / publish / release / deploy) · **Override mode** · **three-strike** · **§3 recurrence hit**. diff --git a/spec/hard-rules.json b/spec/hard-rules.json index ba1dc09..59cb8ed 100644 --- a/spec/hard-rules.json +++ b/spec/hard-rules.json @@ -1,6 +1,6 @@ { "_doc": "Machine-readable manifest of every HARD / MUST rule in spec/AGENTS*.md. It drives hook wiring, rule-specific opportunity/outcome review, and CI anchor drift checks. Telemetry never edits the spec automatically: zero or high hits require an eligible/evaluated denominator plus operator review. Update this manifest in the same edit as a HARD rule. enforcement: self | hook | external | both. bypassable: false = immutable — no inline token or exception may skip enforcement in hooks (structured per-repo exceptions for §8-secrets/§8-unknown-script are fingerprint+expiry lookups, not bypasses, and are telemetried as exception events); true requires bypass_token and telemetry on every use.", - "spec_version": "v5.0.0", + "spec_version": "v5.0.1", "spec_files": { "core": "spec/AGENTS.md", "extended": "spec/AGENTS-extended.md" diff --git a/spec/source/full/00-pre-auth.md b/spec/source/full/00-pre-auth.md index 64eabc0..9afad16 100644 --- a/spec/source/full/00-pre-auth.md +++ b/spec/source/full/00-pre-auth.md @@ -1,4 +1,4 @@ -# CODEX-CODING-SPEC v5.0.0 — Global +# CODEX-CODING-SPEC v5.0.1 — Global **Discovery**: Global uses `$CODEX_HOME/AGENTS.override.md` else `AGENTS.md`; project files load root→cwd with override precedence. The combined cap (32 KiB default) truncates silently; core reserves room for project rules. Closer layers may override defaults, NEVER §8 or §5-hard. **Extended**: standalone uses `~/.codex/AGENTS-extended.md`; plugin SessionStart announces its packaged path — MUST read on **L3** · **ship intent** (`push` shared / merge / PR / publish / release / deploy) · **Override mode** · **three-strike** · **§3 recurrence hit**.