@opengsd/gsd-core 1.5.0-rc.5 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/LICENSE +1 -1
  3. package/agents/gsd-executor.md +22 -0
  4. package/bin/install.js +53 -99
  5. package/commands/gsd/autonomous.md +1 -1
  6. package/commands/gsd/execute-phase.md +1 -1
  7. package/commands/gsd/plan-phase.md +1 -1
  8. package/gemini-extension.json +1 -1
  9. package/gsd-core/bin/gsd-tools.cjs +36 -5
  10. package/gsd-core/bin/lib/decisions.cjs +19 -1
  11. package/gsd-core/bin/lib/phase-id.cjs +1 -1
  12. package/gsd-core/bin/lib/phase.cjs +41 -6
  13. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  14. package/gsd-core/bin/lib/prohibition-enforcement.cjs +201 -26
  15. package/gsd-core/bin/lib/roadmap-parser.cjs +25 -20
  16. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +4 -1
  17. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +24 -3
  18. package/gsd-core/bin/lib/state.cjs +64 -15
  19. package/gsd-core/bin/lib/teams-status.cjs +74 -0
  20. package/gsd-core/bin/lib/uat.cjs +56 -0
  21. package/gsd-core/bin/lib/worktree-safety.cjs +7 -1
  22. package/gsd-core/references/prohibition-probe.md +48 -19
  23. package/gsd-core/references/worktree-branch-check.md +11 -5
  24. package/gsd-core/workflows/docs-update.md +23 -31
  25. package/gsd-core/workflows/execute-phase.md +2 -1
  26. package/gsd-core/workflows/map-codebase.md +8 -10
  27. package/gsd-core/workflows/plan-phase.md +6 -0
  28. package/gsd-core/workflows/quick.md +23 -2
  29. package/gsd-core/workflows/spec-phase.md +11 -6
  30. package/gsd-core/workflows/verify-phase.md +3 -3
  31. package/hooks/dist/gsd-worktree-path-guard.js +34 -18
  32. package/hooks/gsd-worktree-path-guard.js +34 -18
  33. package/package.json +2 -2
  34. package/scripts/ci-prepare-test-scope.cjs +56 -14
  35. package/scripts/diff-touches-shipped-paths.cjs +5 -11
  36. package/scripts/run-tests.cjs +1 -0
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ /**
3
+ * Teams Status Module — issue #1355
4
+ *
5
+ * Read-only detector for claude-code's experimental agent-teams feature.
6
+ * Exposes a PURE core function (env injected, no process.env/disk inside) and
7
+ * a thin CLI wrapper that reuses resolveRuntime from runtime-slash.cjs.
8
+ *
9
+ * Exports:
10
+ * resolveTeamsStatus({ runtime, env }) → TeamsStatus
11
+ * cmdTeamsStatus(cwd, opts) — I/O entry point
12
+ *
13
+ * resolveTeamsStatus is PURE: env and runtime are injected, no process.env or
14
+ * disk access inside the function. Pass process.env explicitly at call sites.
15
+ *
16
+ * cmdTeamsStatus is the I/O handler. It reads process.env, resolves the
17
+ * runtime via resolveRuntime(cwd) from runtime-slash.cjs (GSD_RUNTIME →
18
+ * config.runtime → 'claude' precedence), then:
19
+ * - default: prints JSON.stringify(status) to stdout via io.output, exits 0.
20
+ * - --active: prints nothing, exits 0 if status.active, exit 1 otherwise.
21
+ *
22
+ * Strictly read-only — no config writes, no disk mutation.
23
+ *
24
+ * Dependencies:
25
+ * - ./io.cjs (output)
26
+ * - ./runtime-slash.cjs (resolveRuntime)
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.resolveTeamsStatus = resolveTeamsStatus;
30
+ exports.cmdTeamsStatus = cmdTeamsStatus;
31
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
32
+ const ioMod = require("./io.cjs");
33
+ const { output: coreOutput } = ioMod;
34
+ // ─── Pure core ────────────────────────────────────────────────────────────────
35
+ /**
36
+ * Resolve the agent-teams status from injected runtime and env.
37
+ *
38
+ * Strict truthiness: only '1' and 'true' (case-insensitive, trimmed) are on.
39
+ * '0', 'false', '', and unset are all off.
40
+ *
41
+ * @param opts.runtime The resolved runtime name (e.g. 'claude', 'codex')
42
+ * @param opts.env The environment map to read from (typically process.env)
43
+ */
44
+ function resolveTeamsStatus(opts) {
45
+ const raw = (opts.env['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'] ?? '').trim().toLowerCase();
46
+ const envOn = raw === '1' || raw === 'true'; // strict: never '0'/'false'/'' as on
47
+ const isClaude = opts.runtime === 'claude';
48
+ const source = !isClaude ? 'off: non-claude' : (envOn ? 'on: env' : 'off: flag absent');
49
+ return { active: envOn && isClaude, runtime: opts.runtime, env_present: envOn, source };
50
+ }
51
+ // ─── CLI command handler ──────────────────────────────────────────────────────
52
+ /**
53
+ * Command entry point: resolve runtime via resolveRuntime(cwd), read process.env,
54
+ * call resolveTeamsStatus, and emit the result.
55
+ *
56
+ * @param cwd Project root directory (used by resolveRuntime for config.json)
57
+ * @param opts Command options
58
+ * @param opts.active When true: print nothing, exit 0 if active, exit 1 otherwise
59
+ */
60
+ function cmdTeamsStatus(cwd, opts) {
61
+ // Resolve runtime via the canonical precedence:
62
+ // GSD_RUNTIME → config.runtime → 'claude'
63
+ // Reuses resolveRuntime from runtime-slash.cjs — no reimplementation.
64
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
65
+ const runtimeSlash = require('./runtime-slash.cjs');
66
+ const runtime = runtimeSlash.resolveRuntime(cwd);
67
+ const status = resolveTeamsStatus({ runtime, env: process.env });
68
+ if (opts.active) {
69
+ // --active mode: no output, exit code encodes the boolean
70
+ process.exit(status.active ? 0 : 1);
71
+ }
72
+ // Default: emit JSON to stdout via io.output, exit 0
73
+ coreOutput(status, false);
74
+ }
@@ -145,6 +145,13 @@ function parseCurrentTest(content) {
145
145
  || section.match(/^expected:\s*\|\n([\s\S]+)/m);
146
146
  const expectedInlineMatch = section.match(/^expected:\s*(.+)\s*$/m);
147
147
  if (!numberMatch || !nameMatch || (!expectedBlockMatch && !expectedInlineMatch)) {
148
+ if (!numberMatch && !nameMatch && !expectedBlockMatch && !expectedInlineMatch) {
149
+ const pendingTest = parseFirstPendingTest(content);
150
+ if (pendingTest) {
151
+ return pendingTest;
152
+ }
153
+ error('Current Test section is non-structured and no pending UAT test remains to resume');
154
+ }
148
155
  error('Current Test section is malformed');
149
156
  }
150
157
  let expected;
@@ -165,6 +172,55 @@ function parseCurrentTest(content) {
165
172
  expected: (0, security_cjs_1.sanitizeForDisplay)(expected),
166
173
  };
167
174
  }
175
+ function parseFirstPendingTest(content) {
176
+ const testsMatch = content.match(/##\s*Tests\s*\n([\s\S]*?)(?=\n##\s|$)/i);
177
+ if (!testsMatch) {
178
+ return null;
179
+ }
180
+ const testsSection = testsMatch[1];
181
+ const headingPattern = /^###\s*(\d+)\.\s*([^\n]+)\s*$/gm;
182
+ const headings = [];
183
+ let headingMatch;
184
+ while ((headingMatch = headingPattern.exec(testsSection)) !== null) {
185
+ headings.push({
186
+ index: headingMatch.index,
187
+ number: parseInt(headingMatch[1], 10),
188
+ name: headingMatch[2].trim(),
189
+ });
190
+ }
191
+ for (let i = 0; i < headings.length; i += 1) {
192
+ const current = headings[i];
193
+ const next = headings[i + 1];
194
+ const block = testsSection.slice(current.index, next ? next.index : undefined);
195
+ if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) {
196
+ continue;
197
+ }
198
+ const expected = parseExpectedFromTestBlock(block);
199
+ if (!expected) {
200
+ error(`Pending UAT test ${current.number} is missing an expected field`);
201
+ }
202
+ return {
203
+ complete: false,
204
+ number: current.number,
205
+ name: (0, security_cjs_1.sanitizeForDisplay)(current.name),
206
+ expected: (0, security_cjs_1.sanitizeForDisplay)(expected),
207
+ };
208
+ }
209
+ return null;
210
+ }
211
+ function parseExpectedFromTestBlock(block) {
212
+ const expectedBlockMatch = block.match(/^expected:\s*\|\n([\s\S]*?)(?=^\w[\w-]*:\s)/m)
213
+ || block.match(/^expected:\s*\|\n([\s\S]+)/m);
214
+ if (expectedBlockMatch) {
215
+ return expectedBlockMatch[1]
216
+ .split('\n')
217
+ .map((line) => line.replace(/^ {2}/, ''))
218
+ .join('\n')
219
+ .trim();
220
+ }
221
+ const expectedInlineMatch = block.match(/^expected:\s*(.+)\s*$/m);
222
+ return expectedInlineMatch ? expectedInlineMatch[1].trim() : null;
223
+ }
168
224
  // ─── buildCheckpoint ──────────────────────────────────────────────────────────
169
225
  function buildCheckpoint(currentTest) {
170
226
  return [
@@ -304,11 +304,14 @@ function normalizeCleanupManifestEntry(entry) {
304
304
  return null;
305
305
  if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch))
306
306
  return null;
307
+ const rawAllowedBases = Array.isArray(e.allowed_bases) ? e.allowed_bases : [];
308
+ const allowedBases = Array.from(new Set([expectedBase, ...rawAllowedBases.filter((base) => typeof base === 'string' && base.length > 0)]));
307
309
  return {
308
310
  agent_id: typeof e.agent_id === 'string' ? e.agent_id : null,
309
311
  worktree_path: worktreePath,
310
312
  branch,
311
313
  expected_base: expectedBase,
314
+ allowed_bases: allowedBases,
312
315
  };
313
316
  }
314
317
  function normalizeCleanupManifest(manifest) {
@@ -529,7 +532,10 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
529
532
  break;
530
533
  }
531
534
  const mergeBase = execGit(['merge-base', 'HEAD', entry.branch], { cwd: plan.repoRoot });
532
- if (!gitResultOk(mergeBase) || mergeBase.stdout.trim() !== entry.expected_base) {
535
+ const allowedBases = Array.isArray(entry.allowed_bases) && entry.allowed_bases.length > 0
536
+ ? entry.allowed_bases
537
+ : [entry.expected_base];
538
+ if (!gitResultOk(mergeBase) || !allowedBases.includes(mergeBase.stdout.trim())) {
533
539
  result.status = 'blocked';
534
540
  result.reason = 'base_mismatch';
535
541
  result.stderr = mergeBase?.stderr || '';
@@ -112,15 +112,38 @@ lifecycle is identical to the edge-probe, the verification tiers differ):
112
112
 
113
113
  At verify time these tiers are routed differently (ADR-550 D4):
114
114
  - A **test**-tier prohibition is enforced + hard-gates via the deterministic
115
- `check prohibition-enforcement` sub-command (#1259, ADR-550 D5d): it locates the wired
115
+ `check prohibition-enforcement` sub-command (#1259 + #1279, ADR-550 D5d): it locates the wired
116
116
  mechanical check (a `node --test` negative test OR a lint/AST rule run as
117
- `eslint --format json` and filtered by `ruleId`), requires the caller-attested `failFirst`
118
- marker, runs it for a genuine **non-vacuous** pass, and emits the
119
- `dispositionForProhibition()` verdict. A passing wired check disposes **green** (satisfiable
120
- → can reach `passed`); a missing, non-attested, or genuinely-non-passing check **hard-gates**
121
- (flagged, never green → `gaps_found`) in BOTH interactive and autonomous modes — never a
122
- silent pass. (`failFirst` is caller-attested, not yet machine-proven against a violation
123
- fixture — a tracked follow-up; see ADR-550 D5d.)
117
+ `eslint --format json` and filtered by `ruleId`), **machine-proves it is fail-first** against a
118
+ known violation, runs it for a genuine **non-vacuous** pass, and emits the
119
+ `dispositionForProhibition()` verdict. A passing, fail-first-proven wired check disposes **green**
120
+ (satisfiable → can reach `passed`); a missing, un-provable, or genuinely-non-passing check
121
+ **hard-gates** (flagged, never green → `gaps_found`) in BOTH interactive and autonomous modes —
122
+ never a silent pass.
123
+
124
+ **Machine-proven fail-first (#1279).** `failFirst` is now **machine-proven, not caller-attested**:
125
+ before a clean pass greens, the producer independently runs the wired check against a KNOWN
126
+ VIOLATION and confirms it goes RED (any other outcome — passes-on-violation, can't-prove, throws,
127
+ times out, no violation source — hard-gates). The violation is sourced from a descriptor field:
128
+ - **`violationFixture`** — an author-supplied path to a KNOWN-BAD subject. For `lint-rule`: a file
129
+ whose content violates `rule`; the prover lints it and requires the rule id to appear in the
130
+ JSON report (the rule must have teeth). For `node-test`: a subject the negative test exercises,
131
+ expected to drive it RED.
132
+ - **`GSD_PROHIB_SUBJECT`** — the node-test subject-injection convention: the producer spawns the
133
+ negative test with `GSD_PROHIB_SUBJECT=<violationFixture>` in the child env; the test reads that
134
+ env var to locate its subject-under-test and is expected to go red against the violating subject.
135
+ - **lint-fixture authoring gotcha** — the violating fixture must actually trigger the rule. For the
136
+ `local/no-source-grep` dogfood anchor specifically, use the `path.join('lib','foo.cjs')` form
137
+ (a standalone quoted dir token); a single string literal like `'src/x.cjs'` does NOT trigger the
138
+ rule, so a mis-authored fixture makes the prover report "not proven" and hard-gate a legitimately
139
+ wired check. (`no-source-grep` has no filename guard — any `.cjs` with the pattern fires.)
140
+
141
+ > **PROPOSED, renamable conventions (zero live consumers).** Both `GSD_PROHIB_SUBJECT` and
142
+ > `violationFixture` are net-new surface with **no live in-tree consumer yet** — there is no
143
+ > in-tree `node --test` prohibition; node-test fail-first proof is exercised only by SYNTHETIC
144
+ > temp fixtures in the tests, and the real dogfood remains the LINT-rule `local/no-source-grep`.
145
+ > They are therefore **open to maintainer adjustment (rename, or replacing the env var with an
146
+ > argv) at PR review with zero migration cost.** See the ADR-550 2026-06-15 addendum (#1279).
124
147
  - A **judgment**-tier prohibition routes to a never-silent / never-hard-halt soft gate
125
148
  (autonomous emits an `unverified-prohibition — human review recommended` flag).
126
149
 
@@ -128,33 +151,39 @@ Splitting these axes keeps the lifecycle enum free of a verification fact and le
128
151
  prohibition adapter declare `test | judgment` without forking the shared lifecycle enum that
129
152
  the edge-probe's `explicit | backstop` also uses.
130
153
 
131
- ## Optional wired-check descriptor (deterministic locate, #1278)
154
+ ## Optional wired-check descriptor (deterministic locate + machine-proof, #1278 + #1346)
132
155
 
133
156
  A `resolved`/`test`-tier prohibition MAY carry an **optional `check` descriptor** that names
134
157
  the wired mechanical check, so verify-phase locates it deterministically instead of inventing
135
158
  `{kind, target, rule}` each run. The descriptor is captured at spec-phase (soft / optional —
136
159
  the author wires it when the negative test or lint rule already exists) and is represented as
137
- **three flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}`
160
+ **four flat scalar keys** on the `must_haves.prohibitions` item — never a nested `check: {}`
138
161
  object:
139
162
 
140
163
  - `check_kind` — `node-test` | `lint-rule` (which producer mechanism runs the check).
141
164
  - `check_target` — the test file (`node-test`) or the file the rule runs against (`lint-rule`).
142
165
  - `check_rule` — the `ruleId` to filter on, **lint-rule only** (absent for `node-test`).
166
+ - `check_violation_fixture` — path to a KNOWN-BAD subject the #1279 prover runs the check against to
167
+ machine-prove fail-first (rides BOTH kinds; for `node-test` it is injected via `GSD_PROHIB_SUBJECT`).
143
168
 
144
169
  The flat-scalar shape is load-bearing: the shared `parseMustHavesBlock` is a flat parser and a
145
170
  nested object would flatten/mangle the round-trip (ADR-550 2026-06-15 addendum; #644 "no parser
146
171
  rewrite" precedent). `projectProhibitions` emits these keys **only for a well-formed descriptor**
147
- (valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path), and
148
- verify-phase reads them back via `descriptorFromProjection` into the `CheckDescriptor` handed to
149
- `check prohibition-enforcement`. A wired, passing test then closes the gap with **zero manual
150
- descriptor authoring**.
172
+ (valid `check_kind` + non-empty `check_target`; `check_rule` only on the lint-rule path;
173
+ `check_violation_fixture` only when non-empty), and verify-phase reads them back via
174
+ `descriptorFromProjection` into the `CheckDescriptor` handed to `check prohibition-enforcement`. This
175
+ closes **both** the locate (#1278) and the machine-proof-fixture (#1346) halves with **zero manual
176
+ descriptor authoring**: a prohibition authored with all four scalars greens end-to-end through the
177
+ projection alone.
151
178
 
152
179
  **Fail-closed + backward-compat.** A partial descriptor (`lint-rule` missing `check_rule`), an
153
- unknown `check_kind`, or an **absent** descriptor on a test-tier prohibition falls through to the
154
- producer's existing fail-closed locate — never a silent green. A prohibition with no descriptor
155
- parses and disposes byte-identically to today. `failFirst` is **not** sourced from the
156
- descriptor — it stays a verify-time caller attestation (machine-proven fail-first is tracked in
157
- #1279; the `dispositionForProhibition` policy is unchanged).
180
+ unknown `check_kind`, an **absent** descriptor, OR a descriptor with **no `check_violation_fixture`**
181
+ falls through to the producer's fail-closed paths (`located: false`, or located-but-unprovable) —
182
+ never a silent green. A prohibition with no descriptor parses and disposes byte-identically to today.
183
+ `failFirst` is **not** sourced from the descriptor and is **demoted** (machine-proven fail-first
184
+ DELIVERED in #1279 — no path greens on attestation alone, FF-08); the `dispositionForProhibition`
185
+ policy is unchanged. Residual (tracked **#1346**): the node-test proof confirms the fixture exists and
186
+ the check goes RED, but cannot generically prove the red was *caused by* the subject's content.
158
187
 
159
188
  ## Output schema
160
189
 
@@ -6,9 +6,13 @@ block — do not inline a copy elsewhere. History of coordinated edits: #2924, #
6
6
 
7
7
  **Contract for orchestrators:** before dispatch, capture `EXPECTED_BASE=$(git rev-parse HEAD)`,
8
8
  then embed the block below into the sub-agent prompt verbatim, substituting `{EXPECTED_BASE}`
9
- with that captured SHA. The sub-agent only *verifies* and fails closed; the orchestrator
10
- (the worktree lifecycle owner) performs any base recovery — the sub-agent never rewrites a
11
- worktree it did not create (#48).
9
+ with that captured SHA. Orchestrators that intentionally create a docs-only pre-dispatch
10
+ plan commit may also substitute `{EXPECTED_BASE_ALTERNATE}` with that commit's immediate
11
+ parent so runtimes that fork from either side of the docs-only commit pass the same
12
+ fail-closed guard (#1265). Otherwise substitute `{EXPECTED_BASE_ALTERNATE}` with an empty
13
+ string. The sub-agent only *verifies* and fails closed; the orchestrator (the worktree
14
+ lifecycle owner) performs any base recovery — the sub-agent never rewrites a worktree it
15
+ did not create (#48).
12
16
 
13
17
  <worktree_branch_check>
14
18
  FIRST ACTION: HEAD assertion MUST run before anything else, and this block is
@@ -30,8 +34,10 @@ if ! echo "$ACTUAL_BRANCH" | grep -Eq '^worktree-agent-[A-Za-z0-9._/-]+$'; then
30
34
  echo "FATAL: worktree HEAD '$ACTUAL_BRANCH' is not in the worktree-agent-* namespace; refusing to commit (#2924)." >&2
31
35
  exit 42
32
36
  fi
33
- if [ "$(git rev-parse HEAD)" != "{EXPECTED_BASE}" ]; then
34
- echo "FATAL: worktree base mismatch — HEAD is $(git rev-parse HEAD), expected {EXPECTED_BASE}. Orchestrator owns recovery; sub-agent refuses to rewrite the worktree (#48)." >&2
37
+ ACTUAL_BASE=$(git rev-parse HEAD)
38
+ EXPECTED_BASE_ALTERNATE="{EXPECTED_BASE_ALTERNATE}"
39
+ if [ "$ACTUAL_BASE" != "{EXPECTED_BASE}" ] && { [ -z "$EXPECTED_BASE_ALTERNATE" ] || [ "$ACTUAL_BASE" != "$EXPECTED_BASE_ALTERNATE" ]; }; then
40
+ echo "FATAL: worktree base mismatch — HEAD is $ACTUAL_BASE, expected {EXPECTED_BASE}${EXPECTED_BASE_ALTERNATE:+ or $EXPECTED_BASE_ALTERNATE}. Orchestrator owns recovery; sub-agent refuses to rewrite the worktree (#48)." >&2
35
41
  exit 42
36
42
  fi
37
43
  ```
@@ -457,27 +457,23 @@ Continue to collect_wave_1.
457
457
  <step name="collect_wave_1">
458
458
  **Read the work manifest first:** `Read .planning/tmp/docs-work-manifest.json` — update `status` to `"completed"` or `"failed"` for each Wave 1 item after collection. Write the updated manifest back to disk.
459
459
 
460
- Wait for all 3 Wave 1 agents to complete using the TaskOutput tool.
460
+ Wait for all 3 Wave 1 background agents to finish, then read each agent's output file to collect confirmations.
461
461
 
462
- Call TaskOutput for all 3 agents in parallel (single message with 3 TaskOutput calls):
462
+ Each `Agent(...)` call above with `run_in_background=true` returns an `async_launched` result that carries an `outputFile` path (and `canReadOutputFile: true`). Each agent's completion arrives as a message in this conversation when it finishes — do NOT issue a separate blocking call to wait. Once all 3 agents have reported completion, read their output files in parallel (single message with 3 Read calls):
463
463
 
464
464
  ```
465
- TaskOutput tool:
466
- task_id: "{task_id from README agent result}"
467
- block: true
468
- timeout: 300000
465
+ Read tool:
466
+ file_path: "{outputFile from README agent result}"
469
467
 
470
- TaskOutput tool:
471
- task_id: "{task_id from ARCHITECTURE agent result}"
472
- block: true
473
- timeout: 300000
468
+ Read tool:
469
+ file_path: "{outputFile from ARCHITECTURE agent result}"
474
470
 
475
- TaskOutput tool:
476
- task_id: "{task_id from CONFIGURATION agent result}"
477
- block: true
478
- timeout: 300000
471
+ Read tool:
472
+ file_path: "{outputFile from CONFIGURATION agent result}"
479
473
  ```
480
474
 
475
+ > Allow up to 5 minutes (300000 ms) for the slowest agent to finish before treating it as failed.
476
+
481
477
  **Expected confirmation format from each agent:**
482
478
  ```
483
479
  ## Doc Generation Complete
@@ -676,29 +672,25 @@ Continue to collect_wave_2.
676
672
  <step name="collect_wave_2">
677
673
  **Read the work manifest first:** `Read .planning/tmp/docs-work-manifest.json` — update `status` to `"completed"` or `"failed"` for each Wave 2 item after collection. Write the updated manifest back to disk.
678
674
 
679
- Wait for all Wave 2 agents to complete using the TaskOutput tool.
675
+ Wait for all Wave 2 background agents to finish, then read each agent's output file to collect confirmations.
680
676
 
681
- Call TaskOutput for all Wave 2 agents in parallel (single message with N TaskOutput calls — one per spawned Wave 2 agent):
677
+ Each `Agent(...)` call above with `run_in_background=true` returns an `async_launched` result that carries an `outputFile` path (and `canReadOutputFile: true`). Each agent's completion arrives as a message in this conversation when it finishes — do NOT issue a separate blocking call to wait. Once all Wave 2 agents have reported completion, read their output files in parallel (single message with N Read calls — one per spawned Wave 2 agent):
682
678
 
683
679
  ```
684
- TaskOutput tool:
685
- task_id: "{task_id from GETTING-STARTED agent result}"
686
- block: true
687
- timeout: 300000
680
+ Read tool:
681
+ file_path: "{outputFile from GETTING-STARTED agent result}"
688
682
 
689
- TaskOutput tool:
690
- task_id: "{task_id from DEVELOPMENT agent result}"
691
- block: true
692
- timeout: 300000
683
+ Read tool:
684
+ file_path: "{outputFile from DEVELOPMENT agent result}"
693
685
 
694
- TaskOutput tool:
695
- task_id: "{task_id from TESTING agent result}"
696
- block: true
697
- timeout: 300000
686
+ Read tool:
687
+ file_path: "{outputFile from TESTING agent result}"
698
688
 
699
- # Add one TaskOutput call per conditional agent spawned (API, DEPLOYMENT, CONTRIBUTING)
689
+ # Add one Read call per conditional agent spawned (API, DEPLOYMENT, CONTRIBUTING)
700
690
  ```
701
691
 
692
+ > Allow up to 5 minutes (300000 ms) for the slowest agent to finish before treating it as failed.
693
+
702
694
  **After collection, verify all Wave 2 files exist on disk** using the `resolved_path` from each manifest entry:
703
695
  ```bash
704
696
  ls -la {resolved_path for each wave 2 item} 2>/dev/null
@@ -752,9 +744,9 @@ Write {package_dir}/README.md directly. Return confirmation only — do not retu
752
744
  )
753
745
  ```
754
746
 
755
- > **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling all per-package Agent() calls above with `run_in_background=true`, do NOT generate any package READMEs independently while the subagents are active. Wait for all agents to complete via TaskOutput before proceeding. This prevents duplicate work and wasted context.
747
+ > **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling all per-package Agent() calls above with `run_in_background=true`, do NOT generate any package READMEs independently while the subagents are active. Wait for all agents to complete before proceeding. This prevents duplicate work and wasted context.
756
748
 
757
- Collect confirmations via TaskOutput for all package agents. Note failures in the final report.
749
+ Collect confirmations by reading each package agent's `outputFile` once it reports completion — each `run_in_background=true` Agent call returns an `async_launched` result carrying an `outputFile` path (with `canReadOutputFile: true`). Note failures in the final report.
758
750
 
759
751
  **Fallback when Task tool is unavailable:** Generate per-package READMEs sequentially inline after the `sequential_generation` step. For each package directory with a `package.json`, construct the equivalent `doc_assignment` block and generate the README following gsd-doc-writer instructions.
760
752
 
@@ -635,6 +635,7 @@ increases monotonically across waves. `{status}` is `complete` (success),
635
635
  this commit — the orchestrator force-removes the worktree after you return, and
636
636
  any uncommitted SUMMARY.md will be permanently lost (#2070).
637
637
  REQUIRED ORDER: Write SUMMARY.md → commit → only then any narration. No text between Write and commit (truncation risk; #2070 rescue is not primary defense).
638
+
638
639
  </parallel_execution>
639
640
 
640
641
  <execution_context>
@@ -682,7 +683,7 @@ increases monotonically across waves. `{status}` is `complete` (success),
682
683
  )
683
684
  ```
684
685
 
685
- Immediately after each worktree `Agent()` spawn returns metadata, atomically append `{agent_id, worktree_path, branch, expected_base}` to `WAVE_WORKTREE_MANIFEST`. If any field is missing, stop and ask for recovery instead of scanning all agent worktrees.
686
+ After each `Agent()` returns, parse executor-returned worktree metadata (`<worktree_metadata>`) before harness metadata, then atomically append `{agent_id, worktree_path, branch, expected_base}` to `WAVE_WORKTREE_MANIFEST`. Missing: stop and ask for recovery instead of scanning worktrees.
686
687
 
687
688
  > **Worktree recovery policy (#48 + #1292):** See `execute-phase/steps/worktree-recovery-policy.md` — FAIL-CLOSED rule for base/HEAD-namespace mismatches AND isolated-run fail-safe recovery.
688
689
 
@@ -255,21 +255,19 @@ Continue to collect_confirmations.
255
255
  </step>
256
256
 
257
257
  <step name="collect_confirmations">
258
- Wait for all 4 agents to complete using TaskOutput tool.
258
+ Wait for all 4 background agents to finish, then read each agent's output file to collect confirmations.
259
259
 
260
- **For each agent task_id returned by the Agent tool calls above:**
260
+ Each `Agent(...)` call above with `run_in_background=true` returns an `async_launched` result that carries an `outputFile` path (and `canReadOutputFile: true`). The 4 agents run concurrently and each one's completion arrives as a message in this conversation when it finishes — do NOT issue a separate blocking call to wait for them.
261
+
262
+ **Once all 4 agents have reported completion, read each agent's output file (single message with 4 Read calls):**
261
263
  ```
262
- TaskOutput tool:
263
- task_id: "{task_id from Agent result}"
264
- block: true
265
- timeout: {subagent_timeout from init context, default 300000}
264
+ Read tool:
265
+ file_path: "{outputFile from that agent's async_launched result}"
266
266
  ```
267
267
 
268
- > The timeout is configurable via `workflow.subagent_timeout` in `.planning/config.json` (milliseconds). Default: 300000 (5 minutes). Increase for large codebases or slower models.
269
-
270
- Call TaskOutput for all 4 agents in parallel (single message with 4 TaskOutput calls).
268
+ > Allow up to `workflow.subagent_timeout` for the slowest agent to finish before treating it as failed. The timeout is configurable via `workflow.subagent_timeout` in `.planning/config.json` (milliseconds). Default: 300000 (5 minutes). Increase for large codebases or slower models.
271
269
 
272
- Once all TaskOutput calls return, read each agent's output file to collect confirmations.
270
+ Each output file contains that agent's completion confirmation. Parse the confirmation marker (see below) from the file contents.
273
271
 
274
272
  **Expected confirmation format from each agent:**
275
273
  ```
@@ -499,6 +499,12 @@ Display banner:
499
499
 
500
500
  ### Spawn gsd-phase-researcher
501
501
 
502
+ ```bash
503
+ if gsd_run query teams-status --active >/dev/null 2>&1; then
504
+ echo "⚠️ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS detected. GSD's multi-agent orchestration is not validated under claude-code agent-teams and may stall (a subagent's completion can fail to route to the orchestrator). Recommend disabling agent-teams for GSD workflows. See https://github.com/open-gsd/gsd-core/issues/1355" >&2
505
+ fi
506
+ ```
507
+
502
508
  ```bash
503
509
  PHASE_DESC=$(gsd_run query roadmap.get-phase "${PHASE}" --pick section)
504
510
  if [ -z "${PLAN_PRE_HOOKS_JSON:-}" ]; then
@@ -632,7 +632,10 @@ When `USE_WORKTREES !== "false"`, commit PLAN.md to the current branch **before*
632
632
  Skip this step entirely if `USE_WORKTREES === "false"` (non-worktree mode: PLAN.md is committed in Step 8 as usual).
633
633
 
634
634
  ```bash
635
+ QUICK_PLAN_PARENT=""
636
+ QUICK_PLAN_COMMIT=""
635
637
  if [ "${USE_WORKTREES}" != "false" ]; then
638
+ QUICK_PLAN_PARENT=$(git rev-parse HEAD)
636
639
  COMMIT_DOCS=$(gsd_run query config-get commit_docs 2>/dev/null || echo "true")
637
640
  if [ "$COMMIT_DOCS" != "false" ]; then
638
641
  git add "${QUICK_DIR}/${quick_id}-PLAN.md"
@@ -650,8 +653,12 @@ if [ "${USE_WORKTREES}" != "false" ]; then
650
653
  git commit -m "docs(${quick_id}): pre-dispatch plan for ${DESCRIPTION}" -- "${QUICK_DIR}/${quick_id}-PLAN.md" \
651
654
  || { echo "ERROR: pre-dispatch PLAN.md commit failed — likely a pre-commit hook failure. Fix the hook output above (or set workflow.worktree_skip_hooks=true to bypass) and re-run." >&2; exit 1; }
652
655
  fi
656
+ QUICK_PLAN_COMMIT=$(git rev-parse HEAD)
653
657
  fi
654
658
  fi
659
+ if [ -z "$QUICK_PLAN_COMMIT" ]; then
660
+ QUICK_PLAN_COMMIT=$(git rev-parse HEAD)
661
+ fi
655
662
  fi
656
663
  ```
657
664
 
@@ -678,8 +685,22 @@ Execute quick task ${quick_id}.
678
685
 
679
686
  ${USE_WORKTREES !== "false" ? `
680
687
  <worktree_branch_check>
681
- ORCHESTRATOR build-time embed (NOT a sub-agent runtime step): before this dispatch, read \`gsd-core/references/worktree-branch-check.md\`, substitute \`{EXPECTED_BASE}\` with the base SHA captured above (${EXPECTED_BASE}), and replace this note with that fragment's \`<worktree_branch_check>\` block so the dispatched prompt carries the runnable guard verbatim — do not pass this instruction through in its place.
688
+ ORCHESTRATOR build-time embed (NOT a sub-agent runtime step): before this dispatch, read \`gsd-core/references/worktree-branch-check.md\`, substitute \`{EXPECTED_BASE}\` with the base SHA captured above (${EXPECTED_BASE}), substitute \`{EXPECTED_BASE_ALTERNATE}\` with \`${QUICK_PLAN_PARENT}\` when it differs from \`${EXPECTED_BASE}\` (otherwise empty), and replace this note with that fragment's \`<worktree_branch_check>\` block so the dispatched prompt carries the runnable guard verbatim — do not pass this instruction through in its place.
682
689
  </worktree_branch_check>
690
+
691
+ FIRST ACTION after the worktree branch check: ensure the quick PLAN.md exists at a worktree-rooted relative path before any Read/Edit/Write path can be primed. If \`${QUICK_DIR}/${quick_id}-PLAN.md\` is absent, materialize it from the shared git object store:
692
+
693
+ \`\`\`bash
694
+ QUICK_PLAN_COMMIT="${QUICK_PLAN_COMMIT}"
695
+ QUICK_PLAN_PATH="${QUICK_DIR}/${quick_id}-PLAN.md"
696
+ if [ ! -f "$QUICK_PLAN_PATH" ]; then
697
+ mkdir -p "$(dirname "$QUICK_PLAN_PATH")"
698
+ git show "${QUICK_PLAN_COMMIT}:${QUICK_PLAN_PATH}" > "$QUICK_PLAN_PATH" || {
699
+ echo "FATAL: unable to materialize quick plan from ${QUICK_PLAN_COMMIT}:${QUICK_PLAN_PATH}; refusing to continue." >&2
700
+ exit 42
701
+ }
702
+ fi
703
+ \`\`\`
683
704
  ` : ''}
684
705
 
685
706
  <files_to_read>
@@ -743,7 +764,7 @@ SUMMARY.md and stop — the user must rerun with worktrees disabled.
743
764
 
744
765
  > **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available.
745
766
 
746
- If the executor ran with `isolation="worktree"`, append its returned `{agent_id, worktree_path, branch, expected_base}` metadata to `QUICK_WORKTREE_MANIFEST` before cleanup. If any field is unavailable, stop and ask for recovery; do not discover global worktrees.
767
+ If the executor ran with `isolation="worktree"`, append its returned `{agent_id, worktree_path, branch, expected_base, allowed_bases}` metadata to `QUICK_WORKTREE_MANIFEST` before cleanup. Set `expected_base` to `${EXPECTED_BASE}` and `allowed_bases` to `["${EXPECTED_BASE}", "${QUICK_PLAN_PARENT}"]` with duplicates removed. If any required field is unavailable, stop and ask for recovery; do not discover global worktrees.
747
768
 
748
769
  After executor returns:
749
770
  1. **Worktree cleanup:** If the executor ran with `isolation="worktree"`, merge the worktree branch back and clean up:
@@ -365,11 +365,16 @@ For each Requirement gathered so far, run the two-stage recall→precision pass:
365
365
  - `check_target` — the negative-test file path (for `node-test`), or the path to lint
366
366
  (for `lint-rule`).
367
367
  - `check_rule` — the eslint rule id (e.g. `local/no-source-grep`); `lint-rule` only.
368
+ - `check_violation_fixture` (#1346) — path to a KNOWN-BAD subject the wired check is run
369
+ against to **machine-prove fail-first**; rides BOTH kinds. Capture it to let the item green
370
+ end-to-end with zero hand-authoring at verify time; for `node-test` the negative test should
371
+ read its subject from the `GSD_PROHIB_SUBJECT` env var so the prover can inject this fixture.
368
372
  This is a **SOFT capture (CHK-04): a `test`-tier prohibition WITHOUT a descriptor is still
369
373
  allowed** — if the author cannot yet name the wired check, leave the descriptor empty and
370
374
  proceed. It is NOT a hard authoring block; the item simply stays fail-closed/flagged
371
- downstream (an absent/partial descriptor → `descriptorFromProjection` null/under-specified
372
- → producer fail-closed locate, never green). Do NOT capture `failFirst` here — it is a
375
+ downstream (an absent/partial descriptor — or one with no `check_violation_fixture` —
376
+ → `descriptorFromProjection` null/under-specified/fixture-less → producer fail-closed
377
+ locate-or-unprovable, never green). Do NOT capture `failFirst` here — it is a
373
378
  verify-time caller attestation, not a spec-authored field (#1279).
374
379
  - **Dismiss (reason)** → mark `dismissed` with a REQUIRED non-empty reason (PROB-05). The
375
380
  reason string is the audit trail; silence is not a valid dismissal.
@@ -390,8 +395,8 @@ For each Requirement gathered so far, run the two-stage recall→precision pass:
390
395
  written (test or judgment tier); otherwise leave `unresolved`. **`--auto` NEVER auto-dismisses
391
396
  a prohibition** — a wrong dismissal is the exact silent failure this probe eliminates (PROB-06,
392
397
  the load-bearing safety property). On a `test`-tier auto-resolution, capture the `check_kind` /
393
- `check_target` / `check_rule` descriptor **only when a wired check is unambiguous**; otherwise
394
- leave it empty — `--auto` NEVER fabricates a check path (a wrong locate is re-validated and
398
+ `check_target` / `check_rule` / `check_violation_fixture` descriptor **only when a wired check is unambiguous**; otherwise
399
+ leave it empty — `--auto` NEVER fabricates a check path or fixture (a wrong locate is re-validated and
395
400
  fails closed at the producer, but a fabricated path is still noise to avoid). Log:
396
401
  `[auto] prohibitions: R resolved, U unresolved`.
397
402
 
@@ -402,8 +407,8 @@ runs identically for non-Claude / text-mode hosts.
402
407
  Populate the `## Prohibitions` section of SPEC.md from the resolved prohibitions (each
403
408
  `resolved`/`test` row is a checkable negative acceptance criterion; `resolved`/`judgment`
404
409
  rows route to judgment review; `⚠ UNRESOLVED` rows are flagged as assumptions). A
405
- `resolved`/`test` row ALSO carries its captured `check_kind` / `check_target` / `check_rule`
406
- descriptor when present (so the projection feeds `verify-phase`'s deterministic locate, #1278);
410
+ `resolved`/`test` row ALSO carries its captured `check_kind` / `check_target` / `check_rule` /
411
+ `check_violation_fixture` descriptor when present (so the projection feeds `verify-phase`'s deterministic locate + machine-proof, #1278 + #1346);
407
412
  a `test` row with no captured descriptor is still valid — it stays fail-closed/flagged
408
413
  downstream rather than blocking authoring.
409
414
 
@@ -70,17 +70,17 @@ Aggregate all must_haves across plans for phase-level verification.
70
70
  **Prohibitions (`must_haves.prohibitions`, ADR-550 D3 — the must-NOT sibling block):** When a plan carries `must_haves.prohibitions`, extract each `{ statement, status, verification }` item and route it by `verification` tier in verdict assembly (ADR-550 D4, "B-with-guard", 2026-06-12 maintainer decision). These are NEGATIVE checks (the must-NOT must NOT have happened), distinct from positive `truths`:
71
71
 
72
72
  - **judgment-tier → mode-dependent soft-gate.** Interactive verify defers each item to the end-of-phase human checkpoint (`human_verify_mode: end-of-phase`). Autonomous verify records a NON-AUTHORITATIVE LLM-judge verdict + a prominent `unverified-prohibition — human review recommended` flag (autonomous completion reads "complete with N flagged prohibitions"). NEVER a silent pass; NEVER a hard halt of an AFK run.
73
- - **test-tier → ENFORCED via `check prohibition-enforcement` (green on pass, hard-gate on miss/fail).** Accept the `verification: test` value (the SPEC↔must_haves.prohibitions projection contract holds — no forced schema change later). For each test-tier item, the verifier builds `request.check` **DETERMINISTICALLY from the projected descriptor** — it does NOT invent `{ kind, target, rule }`. Read the flat scalar keys `check_kind` / `check_target` / `check_rule` off the `must_haves.prohibitions` item and reconstruct the `CheckDescriptor` via the `descriptorFromProjection` adapter in `prohibition-enforcement` (`descriptorFromProjection(projectedItem)` → `{ kind: check_kind, target: check_target, rule?: check_rule }`). Then attest `failFirst: true` in the request — `failFirst` is the ONE field NOT sourced from the projection; it stays a verify-time caller attestation (#1279 machine-proves it against a violation fixture). Invoke the producer (CLI surface unchanged):
73
+ - **test-tier → ENFORCED via `check prohibition-enforcement` (green on pass, hard-gate on miss/fail).** Accept the `verification: test` value (the SPEC↔must_haves.prohibitions projection contract holds — no forced schema change later). For each test-tier item, the verifier builds `request.check` **DETERMINISTICALLY from the projected descriptor** — it does NOT invent `{ kind, target, rule }`. Read the flat scalar keys `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` off the `must_haves.prohibitions` item and reconstruct the `CheckDescriptor` via the `descriptorFromProjection` adapter in `prohibition-enforcement` (`descriptorFromProjection(projectedItem)` → `{ kind: check_kind, target: check_target, rule?: check_rule, violationFixture?: check_violation_fixture }`). The `violationFixture` (a path to a KNOWN-BAD subject) is the field that gates **green** and it is **now projected** (`check_violation_fixture`, #1346) — so a prohibition authored with all four scalars greens through the projection alone, **zero hand-authoring at verify time**. Do NOT rely on `failFirst`: it is DEMOTED (#1279) and greens nothing on its own; an item with no projected fixture hard-gates fail-closed. Invoke the producer (CLI surface unchanged):
74
74
 
75
75
  ```bash
76
76
  gsd_run check prohibition-enforcement <request.json>
77
77
  ```
78
78
 
79
- where `<request.json>` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, failFirst: true }`, with `kind`/`target`/`rule` now sourced from the projected `check_*` scalars (not author/verifier invention) and `failFirst` caller-attested. For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). The producer LOCATES the wired check, requires the caller-attested `failFirst` marker, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259, ADR-550 D5d). Route the result by its typed fields:
79
+ where `<request.json>` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, violationFixture, failFirst? }`, with `kind`/`target`/`rule`/`violationFixture` now sourced from the projected `check_*` scalars (not author/verifier invention — #1278 + #1346). For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). `violationFixture` (from `check_violation_fixture`) is the path to a KNOWN-BAD subject the producer runs the check against to **machine-prove fail-first** (for `node-test`, injected via the `GSD_PROHIB_SUBJECT` env convention — #1279); `failFirst` is a DEMOTED, non-authoritative hint kept only for backward route-JSON shape (no path greens on it alone — FF-08). The producer LOCATES the wired check from the projection, **machine-proves it is fail-first** by running it against the violation and confirming it goes RED, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259 + #1278 + #1279, ADR-550 D5d). Fail-first is **machine-proven, not caller-attested** — absent a provable violation the producer fails closed, never falling back to attestation. Route the result by its typed fields:
80
80
  - **`status: 'green'`, `flagged: false`** (a genuinely-passing wired negative test / lint rule, `located: true`, non-empty `evidence`) → the item is satisfiable → it can reach **passed**.
81
81
  - **missing, non-attested, or genuinely-non-passing check** (`located: false` OR `status: 'unverified'`, `flagged: true`) → **hard-gate**: disposes flagged-unverified, NEVER green, routing to `gaps_found` in BOTH interactive and autonomous modes (a failing mechanical check blocks even AFK; ADR-550 D4 / D3). The deterministic fail-closed default backing every miss/fail is `dispositionForProhibition()` in probe-core (`status: 'unverified'`, `flagged: true` on empty `enforcementEvidence`).
82
82
 
83
- > **Descriptor source — deterministic locate (#1278, DELIVERED).** The `check` descriptor's `{ kind, target, rule }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So a wired passing test closes the gap with **zero manual descriptor authoring** — the verifier no longer invents the locate (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor — or a PARTIAL one (e.g. a `lint-rule` missing `check_rule`) — makes `descriptorFromProjection` return `null` / an under-specified descriptor, which falls through to the producer's existing fail-closed LOCATE (`located: false`) → flagged-unverified, NEVER green, in BOTH modes. The only field still attested at verify time (not projected) is `failFirst`; machine-proving it against a violation fixture is the remaining **tracked follow-up: #1279**.
83
+ > **Descriptor source — deterministic locate + machine-proof compose (#1278 + #1346, DELIVERED).** The `check` descriptor's `{ kind, target, rule, violationFixture }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So both halves close with **zero manual descriptor authoring** — the verifier neither invents the locate (#1278) nor hand-supplies the violation fixture (#1346): a prohibition authored with all four scalars machine-proves fail-first and greens end-to-end through the projection alone (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor, a PARTIAL one (e.g. a `lint-rule` missing `check_rule`), OR a descriptor with **no `check_violation_fixture`** makes `descriptorFromProjection` return `null` / an under-specified or fixture-less descriptor, which falls through to the producer's fail-closed paths (`located: false`, or located-but-unprovable) → flagged-unverified, NEVER green, in BOTH modes. `failFirst` is demoted and greens nothing on its own (#1279, FF-08). Residual (tracked **#1346**): the node-test proof confirms the fixture exists and the check goes RED, but cannot generically prove the red was *caused by* the subject's content vs the env merely being set.
84
84
 
85
85
  **Option B: Use Success Criteria from ROADMAP.md**
86
86