From e091eb584b8208a8c3309ce9a3fb027ddab3d187 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Sun, 20 Sep 2026 21:40:27 -0500 Subject: [PATCH 1/8] feat(installer): migrate legacy runtimes during upgrades Prepare the 1.3.0 minor release. Ask for explicit migration consent in the wizard, preflight legacy projects before writes, and continue into the update after migration. Preserve local edits and selected runtimes on later updates. --- UPDATE.md | 25 +++++++ package-lock.json | 4 +- package.json | 2 +- scripts/install.mjs | 26 ++++++- scripts/install.py | 21 +++--- scripts/runtime_store.py | 5 +- scripts/wizard.mjs | 10 ++- tests/test_runtime_lifecycle.py | 126 ++++++++++++++++++++++++++++++++ tests/wizard.test.mjs | 18 +++++ 9 files changed, 218 insertions(+), 19 deletions(-) diff --git a/UPDATE.md b/UPDATE.md index c80dabe0..4b40cf28 100644 --- a/UPDATE.md +++ b/UPDATE.md @@ -98,6 +98,31 @@ node scripts/install.mjs --update --claude --cursor `--update` only refreshes hosts that **already have** GSD Path installed. +### Legacy project runtime + +The interactive npm installer detects the old `.gsd-path/runtime/` layout when +you choose to update project wiring. Choose **Migrate and continue upgrade** to +migrate first, then update skills and wiring. Cancelling leaves the installation +unchanged. Migration preserves locally modified files by stopping for you to +resolve them; it never stages or commits changes. + +For unattended upgrades, migration requires explicit consent: + +```bash +npx @opengsd/gsd-path@latest --update --runtime-migrate --project "/absolute/project" --dry-run +npx @opengsd/gsd-path@latest --update --runtime-migrate --project "/absolute/project" +``` + +The combined dry run previews migration only and writes nothing. The real command +migrates, then validates and applies the update. These are separate operations: +if the update fails, the completed migration remains as an unstaged Git diff. +Review it with `git status --short` and `git diff` in the project. + +Migration is needed once per project. For later updates, omit `--runtime-migrate`. +Normal updates keep the selected runtime; use `--runtime-upgrade --project PATH` +to explicitly change it. Without migration consent, legacy project updates stop +before writing files and print the exact migration command. + ### After updating 1. Restart your agent session (hosts reload skills on session start). diff --git a/package-lock.json b/package-lock.json index aef492ae..b1889398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opengsd/gsd-path", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opengsd/gsd-path", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "bin": { "gsd-path": "scripts/install.mjs" diff --git a/package.json b/package.json index 76780585..a63e434d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opengsd/gsd-path", - "version": "1.2.0", + "version": "1.3.0", "description": "Disk-backed AI agent pipeline. Docs: DOCS.md (start here), QUICK.md, FULL.md, UPDATE.md", "license": "MIT", "repository": { diff --git a/scripts/install.mjs b/scripts/install.mjs index e7eefe39..c52058a4 100755 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -1655,7 +1655,7 @@ function usage() { " --hooks-init add guards to an existing project without changing its contracts\n" + " --runtime-restore restore the exact declared runtime from --source-root\n" + " --runtime-upgrade explicitly select the supplied package runtime\n" + - " --runtime-migrate migrate a tracked legacy runtime for review\n" + + " --runtime-migrate migrate a tracked legacy runtime for review; add --update to continue upgrading\n" + " --hooks-refresh validate the selected runtime; keep its version\n" + " --hooks-refresh-full refresh native settings/git hooks; target flags create missing configs\n" + " --dry-run preview without writing\n" + @@ -1684,6 +1684,8 @@ export async function main(argv, env = process.env) { colored: !process.env.NO_COLOR, version: packageVersion(), targets: TARGETS, + legacyRuntime: lexists(path.join(process.cwd(), ".gsd-path", "runtime")) && + !lexists(path.join(process.cwd(), ".gsd-path", "runtime.json")), installed: (target, local) => hasManagedInstall(local ? localRoot(target, process.cwd()) : defaultRoot(target, env)), }); @@ -1695,7 +1697,12 @@ export async function main(argv, env = process.env) { : path.resolve(SCRIPT_DIRECTORY, ".."); const project = values.project !== undefined ? absolutePath(values.project) : null; - if (["runtime-restore", "runtime-upgrade", "runtime-migrate"].some(name => values[name])) { + const migrateAndUpdate = values["runtime-migrate"] && values.update; + if (migrateAndUpdate && (project === null || values["runtime-restore"] || values["runtime-upgrade"] || values.doctor || values["hooks-init"] || values["hooks-refresh"] || values["hooks-refresh-full"])) { + ui.error("--runtime-migrate --update requires --project and cannot be combined with another runtime, doctor, or hook operation"); + return 2; + } + if (!migrateAndUpdate && ["runtime-restore", "runtime-upgrade", "runtime-migrate"].some(name => values[name])) { const interpreter = requiredPythonRuntime("project runtime"); const result = spawnSync(interpreter, ["-B", path.join(SCRIPT_DIRECTORY, "install.py"), ...argv.filter(arg => arg !== "--no-color")], { stdio: "inherit", env }); @@ -1800,6 +1807,21 @@ export async function main(argv, env = process.env) { } else { plans = selected.map((target) => targetPlan(target, rootFor(target))); } + if (migrateAndUpdate) { + const interpreter = requiredPythonRuntime("project runtime"); + const migration = spawnSync(interpreter, ["-B", path.join(SCRIPT_DIRECTORY, "install.py"), + "--runtime-migrate", "--project", project, "--source-root", sourceRoot, + ...(values["dry-run"] ? ["--dry-run"] : [])], { stdio: "inherit", env }); + if (migration.error || migration.status !== 0) { + if (migration.error) ui.error(migration.error.message); + return migration.status ?? 1; + } + if (values["dry-run"]) { + ui.result("Migration preview only; nothing was written. Run without --dry-run to migrate, then validate and apply the update."); + return 0; + } + ui.result("Migration completed. Review the unstaged Git diff. Updating skills and wiring next; an update failure will retain the completed migration."); + } const spin = ui.spinner("Preparing"); try { const results = await install(sourceRootForInstall, plans, { diff --git a/scripts/install.py b/scripts/install.py index b6e41ded..82097bf9 100644 --- a/scripts/install.py +++ b/scripts/install.py @@ -2641,17 +2641,16 @@ def install( if project is not None: _validate_directory_destination(project, "project path") - if dry_run: - _validate_project( - source_root, - project, - selected, - hooks, - [*mutation_roots, *planned_backups], - interpreter, - hooks_dir, - update, - ) + _validate_project( + source_root, + project, + selected, + hooks, + [*mutation_roots, *planned_backups], + interpreter, + hooks_dir, + update, + ) results = [] with tempfile.TemporaryDirectory(prefix="gsd-path-install-") as temporary: diff --git a/scripts/runtime_store.py b/scripts/runtime_store.py index dc1f351f..58cbed5b 100644 --- a/scripts/runtime_store.py +++ b/scripts/runtime_store.py @@ -6,6 +6,7 @@ import os from pathlib import Path import shutil +import shlex import tempfile from contextlib import contextmanager @@ -135,7 +136,9 @@ def prepare(source, project, *, dry_run=False): status_runtime.validate_runtime(pin) return pin if os.path.lexists(project / ".gsd-path/runtime"): - raise ValueError("legacy project runtime requires --runtime-migrate --project PATH; migration produces a reviewable Git diff") + raise ValueError("legacy project runtime requires explicit migration before updating; run: " + f"npx @opengsd/gsd-path@latest --runtime-migrate --project {shlex.quote(str(project.resolve()))}; " + "then retry the update. Migration produces a reviewable Git diff") return publish(source, dry_run=dry_run) diff --git a/scripts/wizard.mjs b/scripts/wizard.mjs index 9554e76f..6bf6a9ad 100644 --- a/scripts/wizard.mjs +++ b/scripts/wizard.mjs @@ -125,7 +125,7 @@ const confirm = (io, theme, title, yes = "Yes", no = "No") => // Pure: runs the question flow and returns install.mjs argv (or null if cancelled). // `installed(target, local)` reports whether a managed install already exists for that host in that scope. -export async function wizard({ input, output, colored = true, version = "", cwd = process.cwd(), installed = () => false, targets }) { +export async function wizard({ input, output, colored = true, version = "", cwd = process.cwd(), installed = () => false, legacyRuntime = false, targets }) { const theme = makeTheme(colored); const keys = keyReader(input); const io = { keys, output }; @@ -147,8 +147,13 @@ export async function wizard({ input, output, colored = true, version = "", cwd hostItems.some((item) => item.checked && hosts.includes(item.value)) && (await confirm(io, theme, "Existing installs found. What do you want to do?", "Update in place", "Fresh install")); - const project = await confirm(io, theme, "Write AGENTS.md + WORKFLOW.md contracts into this repo?", `Yes — ${cwd}`, "Not now"); + const project = await confirm(io, theme, update ? "Refresh this project's wiring (keep its contracts and selected runtime)?" : "Write AGENTS.md + WORKFLOW.md contracts into this repo?", `Yes — ${cwd}`, "Not now"); const hooks = project && (await confirm(io, theme, "Install guard hooks (archive immutability, ship-commit purity)?")); + const migrate = project && update && legacyRuntime; + if (migrate) { + output.write(`\n This project uses the old runtime layout. Migration moves managed runtime files\n outside the repo and leaves an unstaged Git diff for review. Local edits are preserved.\n`); + if (!(await confirm(io, theme, "Migrate this project before updating?", "Migrate and continue upgrade", "Cancel"))) return null; + } const argv = []; if (hosts.length === targets.length) argv.push("--all"); @@ -157,6 +162,7 @@ export async function wizard({ input, output, colored = true, version = "", cwd if (local) argv.push("--local"); if (project) argv.push("--project", cwd); if (hooks) argv.push("--hooks"); + if (migrate) argv.push("--runtime-migrate"); output.write(`\n ${theme.dim("Equivalent command:")}\n ${theme.accent("$")} gsd-path ${argv.join(" ")}\n`); const go = await select(io, theme, "Ready?", [ diff --git a/tests/test_runtime_lifecycle.py b/tests/test_runtime_lifecycle.py index eac3b14d..0e69d293 100644 --- a/tests/test_runtime_lifecycle.py +++ b/tests/test_runtime_lifecycle.py @@ -247,6 +247,132 @@ def test_modified_legacy_runtime_is_preserved(self): self.assertEqual(changed.read_bytes(), before) self.assertFalse((runtime.parent / "runtime.json").exists()) + def legacy_update_fixture(self): + runtime = self.repo / ".gsd-path/runtime" + runtime.mkdir(parents=True) + for name in install.PROJECT_RUNTIME_SCRIPTS: + shutil.copy2(SOURCE / "scripts" / name, runtime / name) + shutil.copy2(SOURCE / "scripts/status_runtime.py", runtime.parent / "status_runtime.py") + self.git("add", ".") + self.git("commit", "-m", "legacy runtime") + return runtime + + def test_legacy_update_preflight_never_acquires_locks(self): + self.legacy_update_fixture() + with mock.patch.object(install, "_acquire_install_locks", side_effect=AssertionError("write attempted")): + with self.assertRaises(install.InstallerError) as error: + install.install(SOURCE, [], project=self.repo, update=True, migrate_legacy=False) + self.assertIn(str(self.repo), str(error.exception)) + self.assertIn("--runtime-migrate", str(error.exception)) + self.assertNotIn("rolled back", str(error.exception)) + self.assertEqual(self.git("status", "--porcelain"), "") + + def test_node_migrate_and_update_then_repeat_preserves_pin(self): + runtime = self.legacy_update_fixture() + skills = self.home / ".claude/skills" + install.install(SOURCE, [install.TargetPlan("claude", skills)], migrate_legacy=False) + args = ["node", str(SOURCE / "scripts/install.mjs"), "--update", "--claude", + "--claude-root", str(skills), "--project", str(self.repo), "--hooks"] + version = skills / "gsd-path/VERSION" + version.write_text("0.0.1\n") + refused = subprocess.run(args, capture_output=True, text=True) + self.assertNotEqual(refused.returncode, 0) + self.assertIn(str(self.repo), refused.stdout + refused.stderr) + self.assertNotIn("rolled back", refused.stdout + refused.stderr) + self.assertEqual(version.read_text(), "0.0.1\n") + edited = runtime / "pipeline_state.py" + original = edited.read_bytes() + edited.write_bytes(original + b"\n# user edit\n") + blocked = subprocess.run([*args, "--runtime-migrate"], capture_output=True, text=True) + self.assertNotEqual(blocked.returncode, 0) + self.assertIn("locally modified", blocked.stdout + blocked.stderr) + self.assertEqual(edited.read_bytes(), original + b"\n# user edit\n") + self.assertEqual(version.read_text(), "0.0.1\n") + edited.write_bytes(original) + preview = subprocess.run([*args, "--runtime-migrate", "--dry-run"], capture_output=True, text=True) + self.assertEqual(preview.returncode, 0, preview.stderr) + self.assertEqual(self.git("status", "--porcelain"), "") + self.assertTrue(runtime.exists()) + self.assertEqual(version.read_text(), "0.0.1\n") + self.assertFalse((self.home / ".gsd-path/runtimes").exists()) + result = subprocess.run([*args, "--runtime-migrate"], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Updated.", result.stdout) + self.assertEqual(version.read_text().strip(), json.loads((SOURCE / "package.json").read_text())["version"]) + self.assertFalse(runtime.exists()) + self.assertTrue((skills.parent / "disabled-gsd-skills").exists()) + self.assertTrue((self.repo / ".claude/settings.json").exists()) + guard = subprocess.run([sys.executable, "-B", str(self.repo / ".gsd-path/git_guard.py"), "pre-commit"], + cwd=self.repo, capture_output=True, text=True) + self.assertEqual(guard.returncode, 0, guard.stderr) + pin = self.pin() + repeated = subprocess.run(args, capture_output=True, text=True) + self.assertEqual(repeated.returncode, 0, repeated.stderr) + self.assertEqual(self.pin(), pin) + self.assertFalse(runtime.exists()) + self.assertEqual(self.git("diff", "--cached", "--name-only"), "") + + def test_node_update_failure_retains_completed_migration(self): + runtime = self.legacy_update_fixture() + skills = self.home / ".claude/skills" + install.install(SOURCE, [install.TargetPlan("claude", skills)], migrate_legacy=False) + (self.repo / ".claude").write_text("user file prevents project wiring\n") + result = subprocess.run(["node", str(SOURCE / "scripts/install.mjs"), "--update", "--runtime-migrate", + "--claude", "--claude-root", str(skills), "--project", str(self.repo)], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Migration completed", result.stdout) + self.assertIn("unsafe Claude project directory", result.stdout + result.stderr) + self.assertFalse(runtime.exists()) + self.assertTrue(self.pin()["digest"]) + self.assertEqual(self.git("diff", "--cached", "--name-only"), "") + self.assertFalse((skills.parent / "disabled-gsd-skills").exists()) + + def test_node_migration_update_rejects_missing_project_and_conflicting_modes(self): + self.legacy_update_fixture() + cases = [[], *(["--project", str(self.repo), mode] for mode in + ("--runtime-upgrade", "--runtime-restore", "--doctor", "--hooks-init", + "--hooks-refresh", "--hooks-refresh-full"))] + for extra in cases: + with self.subTest(extra=extra): + result = subprocess.run(["node", str(SOURCE / "scripts/install.mjs"), "--update", + "--runtime-migrate", *extra], capture_output=True, text=True) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn("requires --project", result.stdout + result.stderr) + self.assertEqual(self.git("status", "--porcelain"), "") + self.assertFalse((self.home / ".gsd-path/runtimes").exists()) + + def test_node_interactive_upgrade_detects_legacy_runtime(self): + runtime = self.legacy_update_fixture() + skills = self.home / ".claude/skills" + install.install(SOURCE, [install.TargetPlan("claude", skills)], migrate_legacy=False) + # Simulate terminal capabilities; drive the real main(), wizard, and installer. + script = f""" +import {{ main }} from {json.dumps((SOURCE / 'scripts/install.mjs').as_uri())}; +Object.defineProperty(process.stdin, 'isTTY', {{value: true}}); +Object.defineProperty(process.stdout, 'isTTY', {{value: true}}); +process.stdin.setRawMode = () => {{}}; +process.exitCode = await main([]); +""" + environment = {key: value for key, value in os.environ.items() if key not in { + "CLAUDE_CONFIG_DIR", "GROK_HOME", "OPENCODE_CONFIG_DIR", "OPENCODE_CONFIG", + "XDG_CONFIG_HOME", "COPILOT_HOME", "QWEN_HOME", "KIRO_HOME", "KIMI_CODE_HOME", "CODEX_HOME"}} + declined = subprocess.run(["node", "--input-type=module", "-e", script], cwd=self.repo, + env=environment, input="\r\r\r\r\x1b[B\r\x1b[B\r", capture_output=True, text=True) + self.assertEqual(declined.returncode, 0, declined.stdout + declined.stderr) + self.assertTrue(runtime.exists()) + self.assertEqual(self.git("status", "--porcelain"), "") + self.assertFalse((skills.parent / "disabled-gsd-skills").exists()) + self.assertFalse((self.home / ".gsd-path/runtimes").exists()) + # global, selected Claude, update, project, no hooks, consent, install + result = subprocess.run(["node", "--input-type=module", "-e", script], cwd=self.repo, + env=environment, input="\r\r\r\r\x1b[B\r\r\r", capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("Migrate and continue upgrade", result.stdout) + self.assertIn("Updated.", result.stdout) + self.assertFalse(runtime.exists()) + self.assertTrue((skills.parent / "disabled-gsd-skills").exists()) + def test_corrupt_runtime_can_be_explicitly_restored(self): self.provision() runtime = self.home / ".gsd-path/runtimes" / self.pin()["digest"] diff --git a/tests/wizard.test.mjs b/tests/wizard.test.mjs index ba2b9d36..b7ed6ec1 100644 --- a/tests/wizard.test.mjs +++ b/tests/wizard.test.mjs @@ -48,6 +48,24 @@ test("wizard cancels on q", async () => { assert.match(text, /Cancelled/); }); +test("legacy update asks for migration and carries explicit consent", async () => { + const { argv, text } = await run( + ["enter", "enter", "enter", "enter", "down", "enter", "enter", "enter"], + { installed: (target) => target === "codex", legacyRuntime: true } + ); + assert.deepEqual(argv, ["--codex", "--update", "--project", "/repo", "--runtime-migrate"]); + assert.match(text, /Migrate and continue upgrade/); + assert.match(text, /Git diff/); +}); + +test("declining legacy migration cancels before installation", async () => { + const { argv } = await run( + ["enter", "enter", "enter", "enter", "down", "enter", "down", "enter", "enter"], + { installed: (target) => target === "codex", legacyRuntime: true } + ); + assert.equal(argv, null); +}); + test("banner carries the OpenGSD wordmark", () => { assert.match(banner(makeTheme(false), "1.0.0"), /██/); assert.match(banner(makeTheme(false), "1.0.0"), /installer v1\.0\.0/); From af013b32016c00e4db4e6b58f799bc3ba75aaaf7 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Sun, 20 Sep 2026 21:49:28 -0500 Subject: [PATCH 2/8] no-mistakes(document): Clarify legacy upgrade guidance and verify syntax --- DOCS.md | 4 ++++ QUICK.md | 7 ++----- UPDATE.md | 7 ++++--- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/DOCS.md b/DOCS.md index 4711dfe6..59db64da 100644 --- a/DOCS.md +++ b/DOCS.md @@ -405,6 +405,10 @@ the declaration and stable wiring. It does not stage or commit. Resolve local runtime edits or unknown files first. Interrupted migration is recovered by the same explicit command; its journal lives outside the checkout. +To migrate and then update skills and project wiring in one invocation, follow +[Legacy project runtime](UPDATE.md#legacy-project-runtime) for wizard consent, +unattended commands, dry-run scope, and recovery after an update failure. + An ignore rule alone cannot migrate tracked runtime files. This runtime lifecycle covers runtime code and launch wiring; project-local skill copies, retained skill backups, and mixed user/host settings remain separately owned installation output. diff --git a/QUICK.md b/QUICK.md index f2b225ef..de1f709f 100644 --- a/QUICK.md +++ b/QUICK.md @@ -67,11 +67,8 @@ node scripts/install.mjs --all --project "$(pwd)" Installs the [project contracts](DOCS.md#installing) (+ `.claude/CLAUDE.md` if Claude is selected). If those managed files already exist, a plain install **refuses and installs -nothing** — see [project runtime versions](DOCS.md#project-runtime-versions) -for legacy migration, then use `--update --project PATH` to refresh skills and -hook wiring while keeping your contracts and selected runtime. Merge contract -changes by hand -([UPDATE.md](UPDATE.md)). +nothing** — use [the update guide](UPDATE.md) for existing installations, +including [legacy runtime migration during upgrades](UPDATE.md#legacy-project-runtime). **Optional** archive/git guards: diff --git a/UPDATE.md b/UPDATE.md index 4b40cf28..977291d2 100644 --- a/UPDATE.md +++ b/UPDATE.md @@ -119,9 +119,10 @@ if the update fails, the completed migration remains as an unstaged Git diff. Review it with `git status --short` and `git diff` in the project. Migration is needed once per project. For later updates, omit `--runtime-migrate`. -Normal updates keep the selected runtime; use `--runtime-upgrade --project PATH` -to explicitly change it. Without migration consent, legacy project updates stop -before writing files and print the exact migration command. +For runtime selection after migration, see +[Project runtime versions](DOCS.md#project-runtime-versions). +Without migration consent, legacy project updates stop before writing files and +print the exact migration command. ### After updating From 4f0554c8b34ad659493a03dd6db8fad716590a8c Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 21 Sep 2026 05:24:36 -0500 Subject: [PATCH 3/8] fix: clarify owner gates and accept serial task release evidence --- scripts/check_trust_evidence.py | 11 +++++++++-- skills/gsd-path-define/SKILL.md | 4 +++- skills/gsd-path-inspect/SKILL.md | 22 +++++++++++++++++----- skills/gsd-path/DEFINE.md | 4 +++- skills/gsd-path/INSPECT.md | 22 +++++++++++++++++----- skills/gsd-path/SKILL.md | 10 +++++++--- skills/path/DEFINE.md | 4 +++- skills/path/INSPECT.md | 22 +++++++++++++++++----- skills/path/SKILL.md | 10 +++++++--- tests/test_trust_evidence.py | 31 +++++++++++++++++++++++++++++-- 10 files changed, 112 insertions(+), 28 deletions(-) diff --git a/scripts/check_trust_evidence.py b/scripts/check_trust_evidence.py index 1e47da7a..0e3b31f8 100644 --- a/scripts/check_trust_evidence.py +++ b/scripts/check_trust_evidence.py @@ -622,7 +622,8 @@ def _validate_git_bundle( _git(fixture, "check-ref-format", "--branch", task_branch) except EvidenceError: raise EvidenceError(f"{bundle}: task_branch has an invalid name") from None - if _git_ref_exists(fixture, f"refs/heads/{task_branch}"): + serial_primary = landing.get("isolation_mode") == "serial" and task_branch == bound_branch + if not serial_primary and _git_ref_exists(fixture, f"refs/heads/{task_branch}"): raise EvidenceError(f"{bundle}: task branch was not retired") _validate_run_artifacts( fixture, @@ -720,7 +721,13 @@ def _validate_evidence_details( raise EvidenceError( f"{path}: primary worktree must be on the bound branch at ship HEAD" ) - if any( + serial_primary = ( + landing.get("isolation_mode") == "serial" + and task_ref == f"refs/heads/{integration['bound_branch']}" + ) + if serial_primary and task_worktree.casefold() != primary_worktree.casefold(): + raise EvidenceError(f"{path}: serial task must use the primary worktree") + if not serial_primary and any( record["worktree"].casefold() == task_worktree.casefold() or record.get("branch") == task_ref for record in records diff --git a/skills/gsd-path-define/SKILL.md b/skills/gsd-path-define/SKILL.md index 89551ac8..355b4c5b 100644 --- a/skills/gsd-path-define/SKILL.md +++ b/skills/gsd-path-define/SKILL.md @@ -281,7 +281,9 @@ runtimes without config support). Include that preference in the draft. Retain `Review panel:` as reviewed: the CHARTER copy, the explicit user choice, or the configured future preference. Quick lane keeps the panel off. -After approval, finalize `.project/intent/INTENT.md` and run +Stop after presenting the intent draft. Resume only when the owner replies +with approval of that draft; the initial request is scope, not this approval. +After that reply, finalize `.project/intent/INTENT.md` and run `pipeline_state.py transition` with expected `define/active`, the exact current milestone/branch/archive values, event `milestone intent approved`, `--set-phase define --set-status done --set-milestone `, and diff --git a/skills/gsd-path-inspect/SKILL.md b/skills/gsd-path-inspect/SKILL.md index 3fb75689..7e742f75 100644 --- a/skills/gsd-path-inspect/SKILL.md +++ b/skills/gsd-path-inspect/SKILL.md @@ -51,6 +51,12 @@ same milestone; a later milestone's `inspect/active` is a new scan. ## Process +Choose one inspection flow below. Once `prepare-inspect` succeeds, its +`finish-inspect` command owns gates, collection, retirement, and completion. +Keep both sidecars intact until that command returns success. + +### Initial inspection + For an initial `inspect/active` inspection on the `.project` track, with neither output artifact present and a clean Git product at the recorded HEAD, run the bundled `python3 prepare-inspect --repo @@ -70,11 +76,17 @@ template as in step 3. After that review passes, run the bundled --mapper-reviewed`. This runs the docs gate, checks the audit baseline, collects and retires both sidecars, checks pending discussion, and records `inspect/done` through the canonical transition. Do not repeat those operations. -Present step 4's ground truth and use step 5's caller handoff. A failure uses -step 3's failure contract and the returned step evidence; do not blindly rerun -the completion command or repeat already proven steps. -Prior evidence, lookahead, or a dirty/non-Git product uses steps 1–2 below. -Preparation never changes phase state; neither command dispatches agents. +On success, go directly to step 4's ground truth and step 5's caller handoff. +On failure, print the helper stderr, run the bundled `pipeline_diagnose.py +diagnose --repo `, report the failed artifact, and stop. Keep +state and sidecars at the failed checkpoint; do not substitute a manual state +transition or switch to the re-inspection flow. Preparation never changes phase +state; neither command dispatches agents. + +### Re-inspection and lookahead + +Use steps 1–3 only when prior evidence, lookahead, or a dirty/non-Git product +prevents initial preparation. A successful `prepare-inspect` excludes this flow. 1. Before creating or changing `.project/` Markdown, freeze the helper's exact stdout from `python3 --repo diff --git a/skills/gsd-path/DEFINE.md b/skills/gsd-path/DEFINE.md index 89551ac8..355b4c5b 100644 --- a/skills/gsd-path/DEFINE.md +++ b/skills/gsd-path/DEFINE.md @@ -281,7 +281,9 @@ runtimes without config support). Include that preference in the draft. Retain `Review panel:` as reviewed: the CHARTER copy, the explicit user choice, or the configured future preference. Quick lane keeps the panel off. -After approval, finalize `.project/intent/INTENT.md` and run +Stop after presenting the intent draft. Resume only when the owner replies +with approval of that draft; the initial request is scope, not this approval. +After that reply, finalize `.project/intent/INTENT.md` and run `pipeline_state.py transition` with expected `define/active`, the exact current milestone/branch/archive values, event `milestone intent approved`, `--set-phase define --set-status done --set-milestone `, and diff --git a/skills/gsd-path/INSPECT.md b/skills/gsd-path/INSPECT.md index 3fb75689..7e742f75 100644 --- a/skills/gsd-path/INSPECT.md +++ b/skills/gsd-path/INSPECT.md @@ -51,6 +51,12 @@ same milestone; a later milestone's `inspect/active` is a new scan. ## Process +Choose one inspection flow below. Once `prepare-inspect` succeeds, its +`finish-inspect` command owns gates, collection, retirement, and completion. +Keep both sidecars intact until that command returns success. + +### Initial inspection + For an initial `inspect/active` inspection on the `.project` track, with neither output artifact present and a clean Git product at the recorded HEAD, run the bundled `python3 prepare-inspect --repo @@ -70,11 +76,17 @@ template as in step 3. After that review passes, run the bundled --mapper-reviewed`. This runs the docs gate, checks the audit baseline, collects and retires both sidecars, checks pending discussion, and records `inspect/done` through the canonical transition. Do not repeat those operations. -Present step 4's ground truth and use step 5's caller handoff. A failure uses -step 3's failure contract and the returned step evidence; do not blindly rerun -the completion command or repeat already proven steps. -Prior evidence, lookahead, or a dirty/non-Git product uses steps 1–2 below. -Preparation never changes phase state; neither command dispatches agents. +On success, go directly to step 4's ground truth and step 5's caller handoff. +On failure, print the helper stderr, run the bundled `pipeline_diagnose.py +diagnose --repo `, report the failed artifact, and stop. Keep +state and sidecars at the failed checkpoint; do not substitute a manual state +transition or switch to the re-inspection flow. Preparation never changes phase +state; neither command dispatches agents. + +### Re-inspection and lookahead + +Use steps 1–3 only when prior evidence, lookahead, or a dirty/non-Git product +prevents initial preparation. A successful `prepare-inspect` excludes this flow. 1. Before creating or changing `.project/` Markdown, freeze the helper's exact stdout from `python3 --repo diff --git a/skills/gsd-path/SKILL.md b/skills/gsd-path/SKILL.md index baee4d4f..1fc1a488 100644 --- a/skills/gsd-path/SKILL.md +++ b/skills/gsd-path/SKILL.md @@ -323,9 +323,13 @@ report. Ignore any failure and never block or retry — the check is advisory and must not delay routing. Auto-advance after a non-interactive phase completes unless blocked or waiting -on `NEEDS-USER`. Planning owns the single build-approval gate; never ask a -second time. One user-driven exception to normal routing: at a program milestone -boundary — `inspect/active` or `define/active` with no approved INTENT.md for +on `NEEDS-USER`. At an owner approval gate, first write and link the artifact, +then stop for the owner's reply. A feature request, evaluation scenario, or +instruction to complete the workflow supplies scope; it does not approve an +artifact written later. Record approval only from the owner's reply to that +review surface. Planning owns the single build-approval gate; after that +approval, enter build without asking again. One user-driven exception to normal +routing: at a program milestone boundary — `inspect/active` or `define/active` with no approved INTENT.md for the next milestone — a user request to re-scope the remaining `pending` entries routes to the bundled [roadmap contract](ROADMAP.md) in re-slice mode. diff --git a/skills/path/DEFINE.md b/skills/path/DEFINE.md index 89551ac8..355b4c5b 100644 --- a/skills/path/DEFINE.md +++ b/skills/path/DEFINE.md @@ -281,7 +281,9 @@ runtimes without config support). Include that preference in the draft. Retain `Review panel:` as reviewed: the CHARTER copy, the explicit user choice, or the configured future preference. Quick lane keeps the panel off. -After approval, finalize `.project/intent/INTENT.md` and run +Stop after presenting the intent draft. Resume only when the owner replies +with approval of that draft; the initial request is scope, not this approval. +After that reply, finalize `.project/intent/INTENT.md` and run `pipeline_state.py transition` with expected `define/active`, the exact current milestone/branch/archive values, event `milestone intent approved`, `--set-phase define --set-status done --set-milestone `, and diff --git a/skills/path/INSPECT.md b/skills/path/INSPECT.md index 3fb75689..7e742f75 100644 --- a/skills/path/INSPECT.md +++ b/skills/path/INSPECT.md @@ -51,6 +51,12 @@ same milestone; a later milestone's `inspect/active` is a new scan. ## Process +Choose one inspection flow below. Once `prepare-inspect` succeeds, its +`finish-inspect` command owns gates, collection, retirement, and completion. +Keep both sidecars intact until that command returns success. + +### Initial inspection + For an initial `inspect/active` inspection on the `.project` track, with neither output artifact present and a clean Git product at the recorded HEAD, run the bundled `python3 prepare-inspect --repo @@ -70,11 +76,17 @@ template as in step 3. After that review passes, run the bundled --mapper-reviewed`. This runs the docs gate, checks the audit baseline, collects and retires both sidecars, checks pending discussion, and records `inspect/done` through the canonical transition. Do not repeat those operations. -Present step 4's ground truth and use step 5's caller handoff. A failure uses -step 3's failure contract and the returned step evidence; do not blindly rerun -the completion command or repeat already proven steps. -Prior evidence, lookahead, or a dirty/non-Git product uses steps 1–2 below. -Preparation never changes phase state; neither command dispatches agents. +On success, go directly to step 4's ground truth and step 5's caller handoff. +On failure, print the helper stderr, run the bundled `pipeline_diagnose.py +diagnose --repo `, report the failed artifact, and stop. Keep +state and sidecars at the failed checkpoint; do not substitute a manual state +transition or switch to the re-inspection flow. Preparation never changes phase +state; neither command dispatches agents. + +### Re-inspection and lookahead + +Use steps 1–3 only when prior evidence, lookahead, or a dirty/non-Git product +prevents initial preparation. A successful `prepare-inspect` excludes this flow. 1. Before creating or changing `.project/` Markdown, freeze the helper's exact stdout from `python3 --repo diff --git a/skills/path/SKILL.md b/skills/path/SKILL.md index b2e3a0ac..2945ca6c 100644 --- a/skills/path/SKILL.md +++ b/skills/path/SKILL.md @@ -323,9 +323,13 @@ report. Ignore any failure and never block or retry — the check is advisory and must not delay routing. Auto-advance after a non-interactive phase completes unless blocked or waiting -on `NEEDS-USER`. Planning owns the single build-approval gate; never ask a -second time. One user-driven exception to normal routing: at a program milestone -boundary — `inspect/active` or `define/active` with no approved INTENT.md for +on `NEEDS-USER`. At an owner approval gate, first write and link the artifact, +then stop for the owner's reply. A feature request, evaluation scenario, or +instruction to complete the workflow supplies scope; it does not approve an +artifact written later. Record approval only from the owner's reply to that +review surface. Planning owns the single build-approval gate; after that +approval, enter build without asking again. One user-driven exception to normal +routing: at a program milestone boundary — `inspect/active` or `define/active` with no approved INTENT.md for the next milestone — a user request to re-scope the remaining `pending` entries routes to the bundled [roadmap contract](ROADMAP.md) in re-slice mode. diff --git a/tests/test_trust_evidence.py b/tests/test_trust_evidence.py index e51c9dbf..daf3d849 100644 --- a/tests/test_trust_evidence.py +++ b/tests/test_trust_evidence.py @@ -20,6 +20,7 @@ def setUp(self): self.fixture_states = {} self.fixture_manifest_overrides = {} self.keep_fixture_branches = set() + self.serial_hosts = set() self.unrelated_integration_hosts = set() self.blocked_final_hosts = set() self.shared_history_groups = {} @@ -143,11 +144,13 @@ def git(*arguments): f"advance {evidence_host} default", ) task_branches = { - evidence_host: f"task/{evidence_host}-milestone" + evidence_host: ("gsd-path/M001" if evidence_host in self.serial_hosts + else f"task/{evidence_host}-milestone") for evidence_host in fixture_hosts } task_worktrees = { - evidence_host: repository.parent / f"{evidence_host}-task-worktree" + evidence_host: (repository if evidence_host in self.serial_hosts + else repository.parent / f"{evidence_host}-task-worktree") for evidence_host in fixture_hosts } run_ids = { @@ -426,6 +429,7 @@ def step_evidence(self, host, step): "task_branch": fixture["task_branch"], "task_worktree": fixture["task_worktree"], "landing_commit": fixture["landing"], + "isolation_mode": "serial" if host in self.serial_hosts else "sidecar", }, "task-verify": { "verify_artifact": fixture["artifacts"]["verify"], @@ -1085,6 +1089,29 @@ def test_rejects_unstructured_worktree_output(self): ): check_trust_evidence.validate_repository(self.repo) + def test_accepts_serial_task_on_primary_bound_branch(self): + self.serial_hosts.add("alpha") + self.receipt("alpha") + self.receipt("beta") + self.commit_receipts() + check_trust_evidence.validate_repository(self.repo) + + def test_serial_task_requires_primary_worktree_and_bound_branch(self): + self.serial_hosts.add("alpha") + self.receipt("alpha") + self.receipt("beta") + landing = self.artifact("alpha", "task-landing") + original = json.loads(landing.read_text()) + for key, value in (("task_worktree", "/different/repo"), + ("isolation_mode", "sidecar")): + with self.subTest(key=key): + landing.write_text(json.dumps({**original, key: value}) + "\n") + self.commit_receipts() + with self.assertRaisesRegex(check_trust_evidence.EvidenceError, + "serial task must use the primary worktree" if key == "task_worktree" + else "task worktree was not retired"): + check_trust_evidence.validate_repository(self.repo) + def test_rejects_registered_task_worktree(self): self.receipt("alpha") self.receipt("beta") From 4b0b7574588f517b9be0c435ea1f3ea833ed0c19 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 21 Sep 2026 05:53:12 -0500 Subject: [PATCH 4/8] no-mistakes(test): Reject unretired verification worktrees and branches in release evidence --- scripts/check_trust_evidence.py | 10 +++++++++ tests/test_trust_evidence.py | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/scripts/check_trust_evidence.py b/scripts/check_trust_evidence.py index 0e3b31f8..29a4b468 100644 --- a/scripts/check_trust_evidence.py +++ b/scripts/check_trust_evidence.py @@ -21,6 +21,7 @@ ) from isolation import ( IsolationError, + VERIFY_BRANCH_PREFIX, task_frontmatter, verify_landed_task_files, ) @@ -36,6 +37,7 @@ ) from scripts.isolation import ( IsolationError, + VERIFY_BRANCH_PREFIX, task_frontmatter, verify_landed_task_files, ) @@ -625,6 +627,9 @@ def _validate_git_bundle( serial_primary = landing.get("isolation_mode") == "serial" and task_branch == bound_branch if not serial_primary and _git_ref_exists(fixture, f"refs/heads/{task_branch}"): raise EvidenceError(f"{bundle}: task branch was not retired") + if _git(fixture, "for-each-ref", "--format=%(refname)", + f"refs/heads/{VERIFY_BRANCH_PREFIX}"): + raise EvidenceError(f"{bundle}: verification branch was not retired") _validate_run_artifacts( fixture, bundle, @@ -733,6 +738,11 @@ def _validate_evidence_details( for record in records ): raise EvidenceError(f"{path}: task worktree was not retired") + if any( + record.get("branch", "").startswith(f"refs/heads/{VERIFY_BRANCH_PREFIX}") + for record in records + ): + raise EvidenceError(f"{path}: verification worktree was not retired") if any( record["worktree"].casefold() == integration_worktree.casefold() for record in records diff --git a/tests/test_trust_evidence.py b/tests/test_trust_evidence.py index daf3d849..3504f2db 100644 --- a/tests/test_trust_evidence.py +++ b/tests/test_trust_evidence.py @@ -1096,6 +1096,45 @@ def test_accepts_serial_task_on_primary_bound_branch(self): self.commit_receipts() check_trust_evidence.validate_repository(self.repo) + def test_serial_rejects_registered_verification_worktree(self): + self.serial_hosts.add("alpha") + self.receipt("alpha") + self.receipt("beta") + fixture = self.fixtures["alpha"] + worktrees = self.artifact("alpha", "worktrees") + evidence = json.loads(worktrees.read_text(encoding="utf-8")) + evidence["output"] += ( + f"\nworktree {fixture['primary_worktree']}-verify\n" + f"HEAD {fixture['landing']}\n" + "branch refs/heads/gsd-path-verify/task-t001-verify\n" + ) + worktrees.write_text(json.dumps(evidence) + "\n", encoding="utf-8") + self.commit_receipts() + with self.assertRaisesRegex( + check_trust_evidence.EvidenceError, "verification worktree was not retired" + ): + check_trust_evidence.validate_repository(self.repo) + + def test_serial_rejects_unretired_verification_branch(self): + self.serial_hosts.add("alpha") + self.receipt("alpha") + self.receipt("beta") + fixture = self.fixtures["alpha"] + bundle = self.artifact("alpha", "fixture").with_suffix(".bundle") + for arguments in ( + ("branch", "gsd-path-verify/task-t001-verify", fixture["landing"]), + ("bundle", "create", str(bundle), "--all"), + ): + subprocess.run( + ["git", *arguments], cwd=fixture["primary_worktree"], + check=True, capture_output=True, text=True, + ) + self.commit_receipts() + with self.assertRaisesRegex( + check_trust_evidence.EvidenceError, "verification branch was not retired" + ): + check_trust_evidence.validate_repository(self.repo) + def test_serial_task_requires_primary_worktree_and_bound_branch(self): self.serial_hosts.add("alpha") self.receipt("alpha") From 2038f62a49f69176404a6d1a8438d83758a29418 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 21 Sep 2026 06:09:40 -0500 Subject: [PATCH 5/8] no-mistakes(test): Clarify reviewer ownership; live verification remains pending --- platforms/copilot/dispatch.md | 11 +++++++++++ platforms/cursor/dispatch.md | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/platforms/copilot/dispatch.md b/platforms/copilot/dispatch.md index 9495cd9c..a17c75d5 100644 --- a/platforms/copilot/dispatch.md +++ b/platforms/copilot/dispatch.md @@ -59,3 +59,14 @@ The parent owns lifecycle: wait for terminal completion, enforce timeout and cancellation, transfer staged outputs from disposable roots, clean up every child and temporary root before the phase gate, and never let a child delegate another GSD Path child. A timeout or cancellation is a blocked result. + +The reviewer owns the review artifact, including corrections after completion. +The parent must not edit staged or collected reviews, including `Surface`, +`Check`, `Observed`, verdicts, or `Reviewed HEAD`, to make validation pass. +If validation rejects a review, preserve the rejected artifact and return the +exact validation error to the reviewer through the continuation path above, +with the complete brief, original recorded review base, template, and output +path. Only the reviewer may correct its output from the recorded evidence; +missing evidence remains blocked, never invented. Validate the returned output +before collection. If the reviewer cannot complete the correction, report the +block; parent lifecycle ownership never grants review-authoring authority. diff --git a/platforms/cursor/dispatch.md b/platforms/cursor/dispatch.md index 769535e0..9d74e53b 100644 --- a/platforms/cursor/dispatch.md +++ b/platforms/cursor/dispatch.md @@ -56,3 +56,14 @@ The parent owns lifecycle: wait for terminal completion, enforce timeout and cancellation, transfer staged outputs from disposable roots, clean up every child and temporary root before the phase gate, and never let a child delegate another GSD Path child. A timeout or cancellation is a blocked result. + +The reviewer owns the review artifact, including corrections after completion. +The parent must not edit staged or collected reviews, including `Surface`, +`Check`, `Observed`, verdicts, or `Reviewed HEAD`, to make validation pass. +If validation rejects a review, preserve the rejected artifact and return the +exact validation error to the reviewer through the continuation path above, +with the complete brief, original recorded review base, template, and output +path. Only the reviewer may correct its output from the recorded evidence; +missing evidence remains blocked, never invented. Validate the returned output +before collection. If the reviewer cannot complete the correction, report the +block; parent lifecycle ownership never grants review-authoring authority. From d6f9fe9b4af9064bd3c744370fe487d0fd799558 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 21 Sep 2026 06:31:41 -0500 Subject: [PATCH 6/8] fix: preserve closed review cycles in host recovery --- .../LIVE-EVIDENCE-TEMPLATE.md | 4 +-- platforms/copilot/dispatch.md | 30 ++++++++++++------- platforms/cursor/dispatch.md | 30 ++++++++++++------- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/docs/trust-validation/LIVE-EVIDENCE-TEMPLATE.md b/docs/trust-validation/LIVE-EVIDENCE-TEMPLATE.md index 0a086c47..22709199 100644 --- a/docs/trust-validation/LIVE-EVIDENCE-TEMPLATE.md +++ b/docs/trust-validation/LIVE-EVIDENCE-TEMPLATE.md @@ -64,12 +64,12 @@ step-specific fields below: | `install` | `host_version`, `install_root`, `candidate`, `package_version`, `exit_code: 0` | | `router` | `state_artifact`, `state_phase: "shipped"` | | `child-spawn` | manifest-declared `child_api`, matching `command`, structured child output that binds `child_id` and completed status, `child_id`, `child_status: "completed"` | -| `task-landing` | `fixture_bundle`, `run_manifest`, `fixture_base_commit`, `task_branch`, `task_worktree`, `landing_commit` | +| `task-landing` | `fixture_bundle`, `run_manifest`, `fixture_base_commit`, `task_branch`, `task_worktree`, `landing_commit`; for a serial task on the primary, `isolation_mode: serial`, `task_branch` equal to the bound branch, and `task_worktree` equal to the primary worktree | | `task-verify` | `verify_artifact`, `verify_exit_code: 0` | | `reviews` | `wave_review_artifact`, `final_review_artifact` | | `archive` | `archive_path`, `validation_exit_code: 0` | | `integration` | `fixture_bundle`, `run_manifest`, `ship_commit`, `bound_branch`, `default_branch`, `pre_integration_default_commit`, `integration_commit`, `milestone_tag` | -| `worktrees` | `primary_worktree`, retired `integration_worktree`, and normalized `git worktree list --porcelain` in `output`; the primary must remain on the bound branch at the ship commit, while task and integration worktrees must be absent | +| `worktrees` | `primary_worktree`, retired `integration_worktree`, and normalized `git worktree list --porcelain` in `output`; the primary must remain on the bound branch at the ship commit, while separate task, verification, and integration worktrees must be absent. A serial task's primary worktree remains; verification branches must still be retired | | `guards` | `guard_artifact` plus manifest `declared_tier`, `native_guard`, and `git_hooks` results | `fixture_bundle` is one tracked `git bundle` inside the host evidence directory. diff --git a/platforms/copilot/dispatch.md b/platforms/copilot/dispatch.md index a17c75d5..cb37d98c 100644 --- a/platforms/copilot/dispatch.md +++ b/platforms/copilot/dispatch.md @@ -60,13 +60,23 @@ cancellation, transfer staged outputs from disposable roots, clean up every child and temporary root before the phase gate, and never let a child delegate another GSD Path child. A timeout or cancellation is a blocked result. -The reviewer owns the review artifact, including corrections after completion. -The parent must not edit staged or collected reviews, including `Surface`, -`Check`, `Observed`, verdicts, or `Reviewed HEAD`, to make validation pass. -If validation rejects a review, preserve the rejected artifact and return the -exact validation error to the reviewer through the continuation path above, -with the complete brief, original recorded review base, template, and output -path. Only the reviewer may correct its output from the recorded evidence; -missing evidence remains blocked, never invented. Validate the returned output -before collection. If the reviewer cannot complete the correction, report the -block; parent lifecycle ownership never grants review-authoring authority. +The dispatched reviewer owns its review artifact. The parent must not edit +staged or collected reviews, including `Surface`, `Check`, `Observed`, verdicts, +or `Reviewed HEAD`, to make validation pass. For final-scope reviews, supply +PLAN.md's Surface contract and require the reviewer to use its exact Surface +names, with Check and Observed evidence for each applicable criterion. + +Only an unaccepted staged artifact in the still-open review cycle may be +corrected. If validation rejects that artifact, preserve it and return the exact +validation error to the reviewer through the continuation path above, with the +complete brief, original recorded review base, template, and output path. Only +the reviewer may correct it from recorded evidence; missing evidence remains +blocked, never invented. Validate the returned output before collection. If the +reviewer cannot complete the correction, report the block. + +A completed or collected review cycle is closed. Neither parent nor reviewer +may rewrite, backfill, split, rename, or reconstruct its artifact, even through +continuation. Report a missing or non-canonical earlier artifact to the user; +the only repair is a new review cycle at the current HEAD, counting toward the +configured cycle cap. Parent lifecycle ownership never grants review-authoring +authority. diff --git a/platforms/cursor/dispatch.md b/platforms/cursor/dispatch.md index 9d74e53b..af77ac4d 100644 --- a/platforms/cursor/dispatch.md +++ b/platforms/cursor/dispatch.md @@ -57,13 +57,23 @@ cancellation, transfer staged outputs from disposable roots, clean up every child and temporary root before the phase gate, and never let a child delegate another GSD Path child. A timeout or cancellation is a blocked result. -The reviewer owns the review artifact, including corrections after completion. -The parent must not edit staged or collected reviews, including `Surface`, -`Check`, `Observed`, verdicts, or `Reviewed HEAD`, to make validation pass. -If validation rejects a review, preserve the rejected artifact and return the -exact validation error to the reviewer through the continuation path above, -with the complete brief, original recorded review base, template, and output -path. Only the reviewer may correct its output from the recorded evidence; -missing evidence remains blocked, never invented. Validate the returned output -before collection. If the reviewer cannot complete the correction, report the -block; parent lifecycle ownership never grants review-authoring authority. +The dispatched reviewer owns its review artifact. The parent must not edit +staged or collected reviews, including `Surface`, `Check`, `Observed`, verdicts, +or `Reviewed HEAD`, to make validation pass. For final-scope reviews, supply +PLAN.md's Surface contract and require the reviewer to use its exact Surface +names, with Check and Observed evidence for each applicable criterion. + +Only an unaccepted staged artifact in the still-open review cycle may be +corrected. If validation rejects that artifact, preserve it and return the exact +validation error to the reviewer through the continuation path above, with the +complete brief, original recorded review base, template, and output path. Only +the reviewer may correct it from recorded evidence; missing evidence remains +blocked, never invented. Validate the returned output before collection. If the +reviewer cannot complete the correction, report the block. + +A completed or collected review cycle is closed. Neither parent nor reviewer +may rewrite, backfill, split, rename, or reconstruct its artifact, even through +continuation. Report a missing or non-canonical earlier artifact to the user; +the only repair is a new review cycle at the current HEAD, counting toward the +configured cycle cap. Parent lifecycle ownership never grants review-authoring +authority. From 3389ff07d938188081fe546a659803daab605ddb Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 21 Sep 2026 07:05:39 -0500 Subject: [PATCH 7/8] fix(release): validate installer changes without live host reruns --- RELEASE.md | 7 ++ .../LIVE-EVIDENCE-TEMPLATE.md | 4 +- .../trust-validation/TRUST-VALIDATION-SPEC.md | 13 ++- platforms/copilot/dispatch.md | 21 ---- platforms/cursor/dispatch.md | 21 ---- scripts/check_trust_evidence.py | 28 ++---- skills/gsd-path-define/SKILL.md | 4 +- skills/gsd-path-inspect/SKILL.md | 22 +---- skills/gsd-path/DEFINE.md | 4 +- skills/gsd-path/INSPECT.md | 22 +---- skills/gsd-path/SKILL.md | 10 +- skills/path/DEFINE.md | 4 +- skills/path/INSPECT.md | 22 +---- skills/path/SKILL.md | 10 +- tests/test_trust_evidence.py | 98 ++++++------------- 15 files changed, 83 insertions(+), 207 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 5cb2a023..c2436558 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -36,6 +36,13 @@ publication workflow never increments or pushes a package version. A version-onl change does not invalidate live evidence; package identity, dependencies, and other package changes do. +Installer-only releases reuse valid workflow receipts. The offline gate tests +installation, migration, rollback, runtime pinning, and installed guards; it +does not require eight agent milestones to test an installer change. Changes +to the installed skills, host adapters, or workflow runtime still select live +checks through the release policy. Always inspect the plan below before +starting any live evaluations. + 1. From a clean checkout, inspect which hosts need new evidence: ```bash diff --git a/docs/trust-validation/LIVE-EVIDENCE-TEMPLATE.md b/docs/trust-validation/LIVE-EVIDENCE-TEMPLATE.md index 22709199..0a086c47 100644 --- a/docs/trust-validation/LIVE-EVIDENCE-TEMPLATE.md +++ b/docs/trust-validation/LIVE-EVIDENCE-TEMPLATE.md @@ -64,12 +64,12 @@ step-specific fields below: | `install` | `host_version`, `install_root`, `candidate`, `package_version`, `exit_code: 0` | | `router` | `state_artifact`, `state_phase: "shipped"` | | `child-spawn` | manifest-declared `child_api`, matching `command`, structured child output that binds `child_id` and completed status, `child_id`, `child_status: "completed"` | -| `task-landing` | `fixture_bundle`, `run_manifest`, `fixture_base_commit`, `task_branch`, `task_worktree`, `landing_commit`; for a serial task on the primary, `isolation_mode: serial`, `task_branch` equal to the bound branch, and `task_worktree` equal to the primary worktree | +| `task-landing` | `fixture_bundle`, `run_manifest`, `fixture_base_commit`, `task_branch`, `task_worktree`, `landing_commit` | | `task-verify` | `verify_artifact`, `verify_exit_code: 0` | | `reviews` | `wave_review_artifact`, `final_review_artifact` | | `archive` | `archive_path`, `validation_exit_code: 0` | | `integration` | `fixture_bundle`, `run_manifest`, `ship_commit`, `bound_branch`, `default_branch`, `pre_integration_default_commit`, `integration_commit`, `milestone_tag` | -| `worktrees` | `primary_worktree`, retired `integration_worktree`, and normalized `git worktree list --porcelain` in `output`; the primary must remain on the bound branch at the ship commit, while separate task, verification, and integration worktrees must be absent. A serial task's primary worktree remains; verification branches must still be retired | +| `worktrees` | `primary_worktree`, retired `integration_worktree`, and normalized `git worktree list --porcelain` in `output`; the primary must remain on the bound branch at the ship commit, while task and integration worktrees must be absent | | `guards` | `guard_artifact` plus manifest `declared_tier`, `native_guard`, and `git_hooks` results | `fixture_bundle` is one tracked `git bundle` inside the host evidence directory. diff --git a/docs/trust-validation/TRUST-VALIDATION-SPEC.md b/docs/trust-validation/TRUST-VALIDATION-SPEC.md index 72ab8e73..f610d732 100644 --- a/docs/trust-validation/TRUST-VALIDATION-SPEC.md +++ b/docs/trust-validation/TRUST-VALIDATION-SPEC.md @@ -16,8 +16,9 @@ Every release is trusted only when: `evidence/releases//`. Each host must identify a distinct run, landing commit, ship commit, and integration commit. 3. `npm run verify:release` validates those receipts and compares each tested - candidate with HEAD. Reuse is allowed only when that host's runtime and - integration inputs are unchanged. Missing, invalid, or stale evidence + candidate with HEAD. Reuse is allowed when that host's workflow inputs are + unchanged under the classification below. Installer tooling is verified by + offline tests instead of repeating agent workflows. Missing, invalid, or stale evidence requires a fresh run. A current-version receipt takes precedence; a failed current receipt cannot be hidden by an older pass. @@ -44,6 +45,7 @@ validated receipt. No tag alone exempts a host with missing evidence. | Changed files | Required live checks | |---|---| | `platforms//` | That host if included in release evaluations; multiple host changes combine | +| `scripts/install.mjs`, `scripts/install.py`, `scripts/wizard.mjs`, `scripts/runtime_store.py` | None; offline installer lifecycle, migration, rollback, runtime pinning, and installed-guard tests apply | | Shared adapters, skills, runtime scripts, `AGENTS.md`, `WORKFLOW.md`, or unclassified paths | Every release evaluation host | | `docs/`, other root Markdown, `tests/`, `.github/`, `daemon/` | None | | `scripts/bump_version.mjs`, `scripts/update_release_docs.mjs`, `scripts/prepare_release_evidence.sh`, `scripts/check_trust_evidence.py` | None; automated verification still applies | @@ -53,6 +55,13 @@ Added and deleted paths count, including both sides of renames. No old receipt is relabeled as current evidence. Required receipts retain all existing candidate, native-child, task, review, archive, integration, and guard checks. +Installer tooling runs during installation, update, and migration; a complete +agent milestone does not replace tests of those operations. This category does +not exempt the files it installs: changes to skills, host adapters, guard +implementations, runtime payloads, the host manifest, or package contents still +use their own live-check classification. Mixed changes combine requirements. +`npm run verify` remains required and includes the installer and guard suites. + Release 1.2.0 includes shared pipeline changes, so all eight evaluation hosts need evidence that covers those changes. Subsequent documentation, version-only, and release-policy changes can reuse it. Never rewrite a receipt's candidate diff --git a/platforms/copilot/dispatch.md b/platforms/copilot/dispatch.md index cb37d98c..9495cd9c 100644 --- a/platforms/copilot/dispatch.md +++ b/platforms/copilot/dispatch.md @@ -59,24 +59,3 @@ The parent owns lifecycle: wait for terminal completion, enforce timeout and cancellation, transfer staged outputs from disposable roots, clean up every child and temporary root before the phase gate, and never let a child delegate another GSD Path child. A timeout or cancellation is a blocked result. - -The dispatched reviewer owns its review artifact. The parent must not edit -staged or collected reviews, including `Surface`, `Check`, `Observed`, verdicts, -or `Reviewed HEAD`, to make validation pass. For final-scope reviews, supply -PLAN.md's Surface contract and require the reviewer to use its exact Surface -names, with Check and Observed evidence for each applicable criterion. - -Only an unaccepted staged artifact in the still-open review cycle may be -corrected. If validation rejects that artifact, preserve it and return the exact -validation error to the reviewer through the continuation path above, with the -complete brief, original recorded review base, template, and output path. Only -the reviewer may correct it from recorded evidence; missing evidence remains -blocked, never invented. Validate the returned output before collection. If the -reviewer cannot complete the correction, report the block. - -A completed or collected review cycle is closed. Neither parent nor reviewer -may rewrite, backfill, split, rename, or reconstruct its artifact, even through -continuation. Report a missing or non-canonical earlier artifact to the user; -the only repair is a new review cycle at the current HEAD, counting toward the -configured cycle cap. Parent lifecycle ownership never grants review-authoring -authority. diff --git a/platforms/cursor/dispatch.md b/platforms/cursor/dispatch.md index af77ac4d..769535e0 100644 --- a/platforms/cursor/dispatch.md +++ b/platforms/cursor/dispatch.md @@ -56,24 +56,3 @@ The parent owns lifecycle: wait for terminal completion, enforce timeout and cancellation, transfer staged outputs from disposable roots, clean up every child and temporary root before the phase gate, and never let a child delegate another GSD Path child. A timeout or cancellation is a blocked result. - -The dispatched reviewer owns its review artifact. The parent must not edit -staged or collected reviews, including `Surface`, `Check`, `Observed`, verdicts, -or `Reviewed HEAD`, to make validation pass. For final-scope reviews, supply -PLAN.md's Surface contract and require the reviewer to use its exact Surface -names, with Check and Observed evidence for each applicable criterion. - -Only an unaccepted staged artifact in the still-open review cycle may be -corrected. If validation rejects that artifact, preserve it and return the exact -validation error to the reviewer through the continuation path above, with the -complete brief, original recorded review base, template, and output path. Only -the reviewer may correct it from recorded evidence; missing evidence remains -blocked, never invented. Validate the returned output before collection. If the -reviewer cannot complete the correction, report the block. - -A completed or collected review cycle is closed. Neither parent nor reviewer -may rewrite, backfill, split, rename, or reconstruct its artifact, even through -continuation. Report a missing or non-canonical earlier artifact to the user; -the only repair is a new review cycle at the current HEAD, counting toward the -configured cycle cap. Parent lifecycle ownership never grants review-authoring -authority. diff --git a/scripts/check_trust_evidence.py b/scripts/check_trust_evidence.py index 29a4b468..aaac08bd 100644 --- a/scripts/check_trust_evidence.py +++ b/scripts/check_trust_evidence.py @@ -21,7 +21,6 @@ ) from isolation import ( IsolationError, - VERIFY_BRANCH_PREFIX, task_frontmatter, verify_landed_task_files, ) @@ -37,7 +36,6 @@ ) from scripts.isolation import ( IsolationError, - VERIFY_BRANCH_PREFIX, task_frontmatter, verify_landed_task_files, ) @@ -624,12 +622,8 @@ def _validate_git_bundle( _git(fixture, "check-ref-format", "--branch", task_branch) except EvidenceError: raise EvidenceError(f"{bundle}: task_branch has an invalid name") from None - serial_primary = landing.get("isolation_mode") == "serial" and task_branch == bound_branch - if not serial_primary and _git_ref_exists(fixture, f"refs/heads/{task_branch}"): + if _git_ref_exists(fixture, f"refs/heads/{task_branch}"): raise EvidenceError(f"{bundle}: task branch was not retired") - if _git(fixture, "for-each-ref", "--format=%(refname)", - f"refs/heads/{VERIFY_BRANCH_PREFIX}"): - raise EvidenceError(f"{bundle}: verification branch was not retired") _validate_run_artifacts( fixture, bundle, @@ -726,23 +720,12 @@ def _validate_evidence_details( raise EvidenceError( f"{path}: primary worktree must be on the bound branch at ship HEAD" ) - serial_primary = ( - landing.get("isolation_mode") == "serial" - and task_ref == f"refs/heads/{integration['bound_branch']}" - ) - if serial_primary and task_worktree.casefold() != primary_worktree.casefold(): - raise EvidenceError(f"{path}: serial task must use the primary worktree") - if not serial_primary and any( + if any( record["worktree"].casefold() == task_worktree.casefold() or record.get("branch") == task_ref for record in records ): raise EvidenceError(f"{path}: task worktree was not retired") - if any( - record.get("branch", "").startswith(f"refs/heads/{VERIFY_BRANCH_PREFIX}") - for record in records - ): - raise EvidenceError(f"{path}: verification worktree was not retired") if any( record["worktree"].casefold() == integration_worktree.casefold() for record in records @@ -891,6 +874,12 @@ def changed_host_scope(repo: Path, baseline: str, hosts: Sequence[str]) -> Mappi "scripts/bump_version.mjs", "scripts/update_release_docs.mjs", "scripts/prepare_release_evidence.sh", "scripts/check_trust_evidence.py", } + # Installer execution is covered by offline lifecycle and installed-guard + # tests. Installed skills, adapters and runtime payloads still need live proof. + installer_tools = { + "scripts/install.mjs", "scripts/install.py", + "scripts/wizard.mjs", "scripts/runtime_store.py", + } for path in filter(None, paths): if path in {"package.json", "package-lock.json"}: # Only version fields are exempt; dependency and packaging changes are shared. @@ -909,6 +898,7 @@ def changed_host_scope(repo: Path, baseline: str, hosts: Sequence[str]) -> Mappi continue elif (path.startswith(("docs/", "tests/", ".github/", "daemon/")) or path in release_tools + or path in installer_tools or ("/" not in path and path.endswith(".md") and path not in {"AGENTS.md", "WORKFLOW.md"})): continue diff --git a/skills/gsd-path-define/SKILL.md b/skills/gsd-path-define/SKILL.md index 355b4c5b..89551ac8 100644 --- a/skills/gsd-path-define/SKILL.md +++ b/skills/gsd-path-define/SKILL.md @@ -281,9 +281,7 @@ runtimes without config support). Include that preference in the draft. Retain `Review panel:` as reviewed: the CHARTER copy, the explicit user choice, or the configured future preference. Quick lane keeps the panel off. -Stop after presenting the intent draft. Resume only when the owner replies -with approval of that draft; the initial request is scope, not this approval. -After that reply, finalize `.project/intent/INTENT.md` and run +After approval, finalize `.project/intent/INTENT.md` and run `pipeline_state.py transition` with expected `define/active`, the exact current milestone/branch/archive values, event `milestone intent approved`, `--set-phase define --set-status done --set-milestone `, and diff --git a/skills/gsd-path-inspect/SKILL.md b/skills/gsd-path-inspect/SKILL.md index 7e742f75..3fb75689 100644 --- a/skills/gsd-path-inspect/SKILL.md +++ b/skills/gsd-path-inspect/SKILL.md @@ -51,12 +51,6 @@ same milestone; a later milestone's `inspect/active` is a new scan. ## Process -Choose one inspection flow below. Once `prepare-inspect` succeeds, its -`finish-inspect` command owns gates, collection, retirement, and completion. -Keep both sidecars intact until that command returns success. - -### Initial inspection - For an initial `inspect/active` inspection on the `.project` track, with neither output artifact present and a clean Git product at the recorded HEAD, run the bundled `python3 prepare-inspect --repo @@ -76,17 +70,11 @@ template as in step 3. After that review passes, run the bundled --mapper-reviewed`. This runs the docs gate, checks the audit baseline, collects and retires both sidecars, checks pending discussion, and records `inspect/done` through the canonical transition. Do not repeat those operations. -On success, go directly to step 4's ground truth and step 5's caller handoff. -On failure, print the helper stderr, run the bundled `pipeline_diagnose.py -diagnose --repo `, report the failed artifact, and stop. Keep -state and sidecars at the failed checkpoint; do not substitute a manual state -transition or switch to the re-inspection flow. Preparation never changes phase -state; neither command dispatches agents. - -### Re-inspection and lookahead - -Use steps 1–3 only when prior evidence, lookahead, or a dirty/non-Git product -prevents initial preparation. A successful `prepare-inspect` excludes this flow. +Present step 4's ground truth and use step 5's caller handoff. A failure uses +step 3's failure contract and the returned step evidence; do not blindly rerun +the completion command or repeat already proven steps. +Prior evidence, lookahead, or a dirty/non-Git product uses steps 1–2 below. +Preparation never changes phase state; neither command dispatches agents. 1. Before creating or changing `.project/` Markdown, freeze the helper's exact stdout from `python3 --repo diff --git a/skills/gsd-path/DEFINE.md b/skills/gsd-path/DEFINE.md index 355b4c5b..89551ac8 100644 --- a/skills/gsd-path/DEFINE.md +++ b/skills/gsd-path/DEFINE.md @@ -281,9 +281,7 @@ runtimes without config support). Include that preference in the draft. Retain `Review panel:` as reviewed: the CHARTER copy, the explicit user choice, or the configured future preference. Quick lane keeps the panel off. -Stop after presenting the intent draft. Resume only when the owner replies -with approval of that draft; the initial request is scope, not this approval. -After that reply, finalize `.project/intent/INTENT.md` and run +After approval, finalize `.project/intent/INTENT.md` and run `pipeline_state.py transition` with expected `define/active`, the exact current milestone/branch/archive values, event `milestone intent approved`, `--set-phase define --set-status done --set-milestone `, and diff --git a/skills/gsd-path/INSPECT.md b/skills/gsd-path/INSPECT.md index 7e742f75..3fb75689 100644 --- a/skills/gsd-path/INSPECT.md +++ b/skills/gsd-path/INSPECT.md @@ -51,12 +51,6 @@ same milestone; a later milestone's `inspect/active` is a new scan. ## Process -Choose one inspection flow below. Once `prepare-inspect` succeeds, its -`finish-inspect` command owns gates, collection, retirement, and completion. -Keep both sidecars intact until that command returns success. - -### Initial inspection - For an initial `inspect/active` inspection on the `.project` track, with neither output artifact present and a clean Git product at the recorded HEAD, run the bundled `python3 prepare-inspect --repo @@ -76,17 +70,11 @@ template as in step 3. After that review passes, run the bundled --mapper-reviewed`. This runs the docs gate, checks the audit baseline, collects and retires both sidecars, checks pending discussion, and records `inspect/done` through the canonical transition. Do not repeat those operations. -On success, go directly to step 4's ground truth and step 5's caller handoff. -On failure, print the helper stderr, run the bundled `pipeline_diagnose.py -diagnose --repo `, report the failed artifact, and stop. Keep -state and sidecars at the failed checkpoint; do not substitute a manual state -transition or switch to the re-inspection flow. Preparation never changes phase -state; neither command dispatches agents. - -### Re-inspection and lookahead - -Use steps 1–3 only when prior evidence, lookahead, or a dirty/non-Git product -prevents initial preparation. A successful `prepare-inspect` excludes this flow. +Present step 4's ground truth and use step 5's caller handoff. A failure uses +step 3's failure contract and the returned step evidence; do not blindly rerun +the completion command or repeat already proven steps. +Prior evidence, lookahead, or a dirty/non-Git product uses steps 1–2 below. +Preparation never changes phase state; neither command dispatches agents. 1. Before creating or changing `.project/` Markdown, freeze the helper's exact stdout from `python3 --repo diff --git a/skills/gsd-path/SKILL.md b/skills/gsd-path/SKILL.md index 1fc1a488..baee4d4f 100644 --- a/skills/gsd-path/SKILL.md +++ b/skills/gsd-path/SKILL.md @@ -323,13 +323,9 @@ report. Ignore any failure and never block or retry — the check is advisory and must not delay routing. Auto-advance after a non-interactive phase completes unless blocked or waiting -on `NEEDS-USER`. At an owner approval gate, first write and link the artifact, -then stop for the owner's reply. A feature request, evaluation scenario, or -instruction to complete the workflow supplies scope; it does not approve an -artifact written later. Record approval only from the owner's reply to that -review surface. Planning owns the single build-approval gate; after that -approval, enter build without asking again. One user-driven exception to normal -routing: at a program milestone boundary — `inspect/active` or `define/active` with no approved INTENT.md for +on `NEEDS-USER`. Planning owns the single build-approval gate; never ask a +second time. One user-driven exception to normal routing: at a program milestone +boundary — `inspect/active` or `define/active` with no approved INTENT.md for the next milestone — a user request to re-scope the remaining `pending` entries routes to the bundled [roadmap contract](ROADMAP.md) in re-slice mode. diff --git a/skills/path/DEFINE.md b/skills/path/DEFINE.md index 355b4c5b..89551ac8 100644 --- a/skills/path/DEFINE.md +++ b/skills/path/DEFINE.md @@ -281,9 +281,7 @@ runtimes without config support). Include that preference in the draft. Retain `Review panel:` as reviewed: the CHARTER copy, the explicit user choice, or the configured future preference. Quick lane keeps the panel off. -Stop after presenting the intent draft. Resume only when the owner replies -with approval of that draft; the initial request is scope, not this approval. -After that reply, finalize `.project/intent/INTENT.md` and run +After approval, finalize `.project/intent/INTENT.md` and run `pipeline_state.py transition` with expected `define/active`, the exact current milestone/branch/archive values, event `milestone intent approved`, `--set-phase define --set-status done --set-milestone `, and diff --git a/skills/path/INSPECT.md b/skills/path/INSPECT.md index 7e742f75..3fb75689 100644 --- a/skills/path/INSPECT.md +++ b/skills/path/INSPECT.md @@ -51,12 +51,6 @@ same milestone; a later milestone's `inspect/active` is a new scan. ## Process -Choose one inspection flow below. Once `prepare-inspect` succeeds, its -`finish-inspect` command owns gates, collection, retirement, and completion. -Keep both sidecars intact until that command returns success. - -### Initial inspection - For an initial `inspect/active` inspection on the `.project` track, with neither output artifact present and a clean Git product at the recorded HEAD, run the bundled `python3 prepare-inspect --repo @@ -76,17 +70,11 @@ template as in step 3. After that review passes, run the bundled --mapper-reviewed`. This runs the docs gate, checks the audit baseline, collects and retires both sidecars, checks pending discussion, and records `inspect/done` through the canonical transition. Do not repeat those operations. -On success, go directly to step 4's ground truth and step 5's caller handoff. -On failure, print the helper stderr, run the bundled `pipeline_diagnose.py -diagnose --repo `, report the failed artifact, and stop. Keep -state and sidecars at the failed checkpoint; do not substitute a manual state -transition or switch to the re-inspection flow. Preparation never changes phase -state; neither command dispatches agents. - -### Re-inspection and lookahead - -Use steps 1–3 only when prior evidence, lookahead, or a dirty/non-Git product -prevents initial preparation. A successful `prepare-inspect` excludes this flow. +Present step 4's ground truth and use step 5's caller handoff. A failure uses +step 3's failure contract and the returned step evidence; do not blindly rerun +the completion command or repeat already proven steps. +Prior evidence, lookahead, or a dirty/non-Git product uses steps 1–2 below. +Preparation never changes phase state; neither command dispatches agents. 1. Before creating or changing `.project/` Markdown, freeze the helper's exact stdout from `python3 --repo diff --git a/skills/path/SKILL.md b/skills/path/SKILL.md index 2945ca6c..b2e3a0ac 100644 --- a/skills/path/SKILL.md +++ b/skills/path/SKILL.md @@ -323,13 +323,9 @@ report. Ignore any failure and never block or retry — the check is advisory and must not delay routing. Auto-advance after a non-interactive phase completes unless blocked or waiting -on `NEEDS-USER`. At an owner approval gate, first write and link the artifact, -then stop for the owner's reply. A feature request, evaluation scenario, or -instruction to complete the workflow supplies scope; it does not approve an -artifact written later. Record approval only from the owner's reply to that -review surface. Planning owns the single build-approval gate; after that -approval, enter build without asking again. One user-driven exception to normal -routing: at a program milestone boundary — `inspect/active` or `define/active` with no approved INTENT.md for +on `NEEDS-USER`. Planning owns the single build-approval gate; never ask a +second time. One user-driven exception to normal routing: at a program milestone +boundary — `inspect/active` or `define/active` with no approved INTENT.md for the next milestone — a user request to re-scope the remaining `pending` entries routes to the bundled [roadmap contract](ROADMAP.md) in re-slice mode. diff --git a/tests/test_trust_evidence.py b/tests/test_trust_evidence.py index 3504f2db..3b5d0735 100644 --- a/tests/test_trust_evidence.py +++ b/tests/test_trust_evidence.py @@ -20,7 +20,6 @@ def setUp(self): self.fixture_states = {} self.fixture_manifest_overrides = {} self.keep_fixture_branches = set() - self.serial_hosts = set() self.unrelated_integration_hosts = set() self.blocked_final_hosts = set() self.shared_history_groups = {} @@ -144,13 +143,11 @@ def git(*arguments): f"advance {evidence_host} default", ) task_branches = { - evidence_host: ("gsd-path/M001" if evidence_host in self.serial_hosts - else f"task/{evidence_host}-milestone") + evidence_host: f"task/{evidence_host}-milestone" for evidence_host in fixture_hosts } task_worktrees = { - evidence_host: (repository if evidence_host in self.serial_hosts - else repository.parent / f"{evidence_host}-task-worktree") + evidence_host: repository.parent / f"{evidence_host}-task-worktree" for evidence_host in fixture_hosts } run_ids = { @@ -429,7 +426,6 @@ def step_evidence(self, host, step): "task_branch": fixture["task_branch"], "task_worktree": fixture["task_worktree"], "landing_commit": fixture["landing"], - "isolation_mode": "serial" if host in self.serial_hosts else "sidecar", }, "task-verify": { "verify_artifact": fixture["artifacts"]["verify"], @@ -578,6 +574,34 @@ def test_plan_requires_only_host_with_stale_receipt(self): self.release_change("skills/gsd-path/SKILL.md") self.assertEqual(["alpha", "beta"], check_trust_evidence.validate_repository(self.repo, plan=True)["required_runs"]) + def test_installer_changes_reuse_valid_workflow_receipts(self): + self.receipt("alpha") + self.receipt("beta") + self.commit_receipts() + original = self.candidate + for path in ("scripts/install.mjs", "scripts/install.py", + "scripts/wizard.mjs", "scripts/runtime_store.py"): + with self.subTest(path=path): + self.release_change(path) + result = check_trust_evidence.validate_repository(self.repo, plan=True) + self.assertEqual([], result["required_runs"]) + self.assertEqual(original, result["receipts"]["alpha"]["candidate"]) + self.release_change("platforms/alpha/dispatch.md") + self.assertEqual(["alpha"], check_trust_evidence.validate_repository( + self.repo, plan=True)["required_runs"]) + self.release_change("scripts/pipeline_state.py") + self.assertEqual(["alpha", "beta"], check_trust_evidence.validate_repository( + self.repo, plan=True)["required_runs"]) + + def test_installer_change_does_not_excuse_invalid_or_missing_receipts(self): + self.receipt("alpha", child_spawn="unverifiable") + self.commit_receipts() + self.release_change("scripts/install.mjs") + result = check_trust_evidence.validate_repository(self.repo, plan=True) + self.assertEqual(["alpha", "beta"], result["required_runs"]) + self.assertIn("child_spawn", result["reasons"]["alpha"]) + self.assertEqual("missing host evidence", result["reasons"]["beta"]) + def test_plan_cannot_reuse_corrupt_historical_receipt(self): self.receipt("alpha", child_spawn="unverifiable") self.receipt("beta") @@ -1089,68 +1113,6 @@ def test_rejects_unstructured_worktree_output(self): ): check_trust_evidence.validate_repository(self.repo) - def test_accepts_serial_task_on_primary_bound_branch(self): - self.serial_hosts.add("alpha") - self.receipt("alpha") - self.receipt("beta") - self.commit_receipts() - check_trust_evidence.validate_repository(self.repo) - - def test_serial_rejects_registered_verification_worktree(self): - self.serial_hosts.add("alpha") - self.receipt("alpha") - self.receipt("beta") - fixture = self.fixtures["alpha"] - worktrees = self.artifact("alpha", "worktrees") - evidence = json.loads(worktrees.read_text(encoding="utf-8")) - evidence["output"] += ( - f"\nworktree {fixture['primary_worktree']}-verify\n" - f"HEAD {fixture['landing']}\n" - "branch refs/heads/gsd-path-verify/task-t001-verify\n" - ) - worktrees.write_text(json.dumps(evidence) + "\n", encoding="utf-8") - self.commit_receipts() - with self.assertRaisesRegex( - check_trust_evidence.EvidenceError, "verification worktree was not retired" - ): - check_trust_evidence.validate_repository(self.repo) - - def test_serial_rejects_unretired_verification_branch(self): - self.serial_hosts.add("alpha") - self.receipt("alpha") - self.receipt("beta") - fixture = self.fixtures["alpha"] - bundle = self.artifact("alpha", "fixture").with_suffix(".bundle") - for arguments in ( - ("branch", "gsd-path-verify/task-t001-verify", fixture["landing"]), - ("bundle", "create", str(bundle), "--all"), - ): - subprocess.run( - ["git", *arguments], cwd=fixture["primary_worktree"], - check=True, capture_output=True, text=True, - ) - self.commit_receipts() - with self.assertRaisesRegex( - check_trust_evidence.EvidenceError, "verification branch was not retired" - ): - check_trust_evidence.validate_repository(self.repo) - - def test_serial_task_requires_primary_worktree_and_bound_branch(self): - self.serial_hosts.add("alpha") - self.receipt("alpha") - self.receipt("beta") - landing = self.artifact("alpha", "task-landing") - original = json.loads(landing.read_text()) - for key, value in (("task_worktree", "/different/repo"), - ("isolation_mode", "sidecar")): - with self.subTest(key=key): - landing.write_text(json.dumps({**original, key: value}) + "\n") - self.commit_receipts() - with self.assertRaisesRegex(check_trust_evidence.EvidenceError, - "serial task must use the primary worktree" if key == "task_worktree" - else "task worktree was not retired"): - check_trust_evidence.validate_repository(self.repo) - def test_rejects_registered_task_worktree(self): self.receipt("alpha") self.receipt("beta") From cd7d44ff817c8293bea97c8de31bb3bf9d7585f7 Mon Sep 17 00:00:00 2001 From: Jeremy McSpadden Date: Mon, 21 Sep 2026 07:16:22 -0500 Subject: [PATCH 8/8] no-mistakes(document): Clarify receipt reuse and consolidate release policy guidance --- RELEASE.md | 9 +++------ docs/trust-validation/TRUST-VALIDATION-SPEC.md | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index c2436558..e9fb44a5 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -36,12 +36,9 @@ publication workflow never increments or pushes a package version. A version-onl change does not invalidate live evidence; package identity, dependencies, and other package changes do. -Installer-only releases reuse valid workflow receipts. The offline gate tests -installation, migration, rollback, runtime pinning, and installed guards; it -does not require eight agent milestones to test an installer change. Changes -to the installed skills, host adapters, or workflow runtime still select live -checks through the release policy. Always inspect the plan below before -starting any live evaluations. +For installer-only releases and mixed changes, follow the +[live-check scope](docs/trust-validation/TRUST-VALIDATION-SPEC.md#live-check-scope). +Inspect the plan below before starting any live evaluations. 1. From a clean checkout, inspect which hosts need new evidence: diff --git a/docs/trust-validation/TRUST-VALIDATION-SPEC.md b/docs/trust-validation/TRUST-VALIDATION-SPEC.md index f610d732..fe1bb784 100644 --- a/docs/trust-validation/TRUST-VALIDATION-SPEC.md +++ b/docs/trust-validation/TRUST-VALIDATION-SPEC.md @@ -87,7 +87,7 @@ partial or unverifiable blocks release. It never becomes an implicit pass. | Unit and integration tests | Install, state routing, handoff validation, isolation, recovery, guards, archive, integration | `npm run verify` | | Lightweight live smoke | Real host invocation and a bounded artifact | `.github/workflows/dogfood.yml` | | Full live milestone | Real child dispatch, build, review, archive, merge, and tag on one host | release receipt | -| Release reconciliation | Every host has validated evidence covering its unchanged runtime inputs | `npm run verify:release` | +| Release reconciliation | Every evaluation host has validated evidence reusable under [Live-check scope](#live-check-scope) | `npm run verify:release` | Simulated full-cycle tests are strong evidence for the deterministic disk and Git contract. They do not replace real child-agent execution.