@dzhechkov/skills-feature-adr 1.5.4 → 1.5.6

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.
package/.dz-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "manifest": {
3
- "version": 1,
3
+ "version": 3,
4
4
  "pack": "skills-feature-adr",
5
5
  "files": [
6
6
  {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  {
15
15
  "path": "README.md",
16
- "sha256": "eb5998619673f928f02b6b462278acfc4871387e1f6495ab0b613a965611cda7"
16
+ "sha256": "2dbd3c18b3b927adb1b3b220ccc1df298db8bf0b7f8de67c6796b91fc4290d8b"
17
17
  },
18
18
  {
19
19
  "path": "bin/cli.js",
@@ -25,7 +25,7 @@
25
25
  },
26
26
  {
27
27
  "path": "package.json",
28
- "sha256": "8e082526415c4ce4427c51dff222c248e29ef5fbdcd17f0d957c28d621738513"
28
+ "sha256": "38c97a3eea3e5007d479ce50207068f65baee4be049797ae8d72f2b8c9deeca0"
29
29
  },
30
30
  {
31
31
  "path": "src/cli.js",
@@ -149,7 +149,7 @@
149
149
  },
150
150
  {
151
151
  "path": "templates/.claude/skills/feature-adr/modules/06-implementation-plan.md",
152
- "sha256": "b3efdfa1d44f4c51ff46551b5827066955c1f0bfee212d371ac1e2fb1c99980f"
152
+ "sha256": "c624e0ade0314ca07913ba64e092d4655f86201856e8188c3e616b2320226c1e"
153
153
  },
154
154
  {
155
155
  "path": "templates/.claude/skills/feature-adr/modules/07-code.md",
@@ -157,7 +157,7 @@
157
157
  },
158
158
  {
159
159
  "path": "templates/.claude/skills/feature-adr/modules/08-qe.md",
160
- "sha256": "bd5b5809142c12fa1378de446b68027a0c342a7a91ad17e4fb3395ca8f6a8508"
160
+ "sha256": "ea8fb546acb7f09816d2fc9f9189a86f2400b8cec91c9ba4b82b68a8e32920bb"
161
161
  },
162
162
  {
163
163
  "path": "templates/.claude/skills/feature-adr/modules/09-fleet-qe.md",
@@ -249,7 +249,7 @@
249
249
  },
250
250
  {
251
251
  "path": "templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs",
252
- "sha256": "eddf116620050e80b88497ace026fb8e0a12986f94f216181480c7daa0e08be4"
252
+ "sha256": "630eb9047258ad0333082f407cd34522e4c278650f22ef6dba1ef897ec5cce71"
253
253
  },
254
254
  {
255
255
  "path": "templates/.claude/skills/frontend-design/LICENSE.txt",
@@ -313,7 +313,7 @@
313
313
  },
314
314
  {
315
315
  "path": "templates/.claude/workflows/feature-adr.js",
316
- "sha256": "a6ff055d50f4eb5790dab36deee188e880eec1362188ec16a5504710294d87c3"
316
+ "sha256": "587b36287ce9e9b85b1dfc41fc50d95297f5ac6be32e1e702e0b89b5df0a7892"
317
317
  },
318
318
  {
319
319
  "path": "templates/lib/memory-protocol.md",
@@ -325,5 +325,5 @@
325
325
  }
326
326
  ]
327
327
  },
328
- "signature": "4u6AmuuLsokLePBMiy6Gg9ETVwszE2NVE2fArT62nFcJSWPEd06QhKu4eb2/awzNjH4bhY3HXbacMZsS7B7gBA=="
328
+ "signature": "m7+klENS8u5kS4x5SFp5QmxI/t+ymAD1rm1aVKRTtdH1Ti44wrNA+dE8FS1BTmiIzJ4KFEZKWsHOb68cq3IaAg=="
329
329
  }
package/README.md CHANGED
@@ -169,7 +169,7 @@ refused BY NAME: `path traversal ('..' segment)`, `degenerate path segment`, `em
169
169
  `illegal character '<c>'`, `empty stem after the leading dot`. The old substring traversal test also
170
170
  rejected the ordinary filename `foo..bar.ts`; it is a segment test now.
171
171
 
172
- ### The Step-8 amendment gate runs a command instead of judging (v1.5.4)
172
+ ### The Step-8 amendment gate runs a command instead of judging (v1.5.6)
173
173
 
174
174
  Every `AM-N` row must resolve to a test found INSIDE the file the row names, and the check is
175
175
  `dz amendment-check --slug <slug> --json` rather than a paragraph asking the reviewer to confirm it.
@@ -1133,7 +1133,7 @@ pre-code probe that returns nothing no longer becomes an all-null baseline that
1133
1133
  "every target changed".
1134
1134
 
1135
1135
 
1136
- `1.5.4` — **the workflow stops crashing on the way into Step 7.** `1.5.4` shipped a workflow that
1136
+ `1.5.3` — **the workflow stops crashing on the way into Step 7.** `1.5.2` shipped a workflow that
1137
1137
  CALLED three helpers it never defined — `changeSetProbeCmd`, `parseHashProbe`, `changedFromHashes`
1138
1138
  (5 call sites, 0 definitions). `QE_SCOPE` defaults to `uncommitted`, so the guarded branch was true
1139
1139
  by default and every ultracode run that reached the coding step died with a `ReferenceError` while
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/skills-feature-adr",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "Adaptive Feature Development skill pack for Claude Code — 11-step pipeline with Complexity Router (S/M/L/XL), ADR-driven architecture, 15 agentic-qe skills, multi-agent fleet QE. Supports --full-qe, --full-qe-extended, --with-learning, and --knowledge-extractor modes.",
5
5
  "bin": {
6
6
  "skills-feature-adr": "./bin/cli.js"
package/sbom.json CHANGED
@@ -35,7 +35,7 @@
35
35
  "hashes": [
36
36
  {
37
37
  "alg": "SHA-256",
38
- "content": "eb5998619673f928f02b6b462278acfc4871387e1f6495ab0b613a965611cda7"
38
+ "content": "2dbd3c18b3b927adb1b3b220ccc1df298db8bf0b7f8de67c6796b91fc4290d8b"
39
39
  }
40
40
  ]
41
41
  },
@@ -62,10 +62,14 @@
62
62
  {
63
63
  "type": "file",
64
64
  "name": "package.json",
65
- "hashes": [
65
+ "properties": [
66
66
  {
67
- "alg": "SHA-256",
68
- "content": "8e082526415c4ce4427c51dff222c248e29ef5fbdcd17f0d957c28d621738513"
67
+ "name": "dz:digest-basis",
68
+ "value": "package-json-ordered-conditions-v2"
69
+ },
70
+ {
71
+ "name": "dz:canonical-json-sha256-v2",
72
+ "value": "38c97a3eea3e5007d479ce50207068f65baee4be049797ae8d72f2b8c9deeca0"
69
73
  }
70
74
  ]
71
75
  },
@@ -375,7 +379,7 @@
375
379
  "hashes": [
376
380
  {
377
381
  "alg": "SHA-256",
378
- "content": "b3efdfa1d44f4c51ff46551b5827066955c1f0bfee212d371ac1e2fb1c99980f"
382
+ "content": "c624e0ade0314ca07913ba64e092d4655f86201856e8188c3e616b2320226c1e"
379
383
  }
380
384
  ]
381
385
  },
@@ -395,7 +399,7 @@
395
399
  "hashes": [
396
400
  {
397
401
  "alg": "SHA-256",
398
- "content": "bd5b5809142c12fa1378de446b68027a0c342a7a91ad17e4fb3395ca8f6a8508"
402
+ "content": "ea8fb546acb7f09816d2fc9f9189a86f2400b8cec91c9ba4b82b68a8e32920bb"
399
403
  }
400
404
  ]
401
405
  },
@@ -625,7 +629,7 @@
625
629
  "hashes": [
626
630
  {
627
631
  "alg": "SHA-256",
628
- "content": "eddf116620050e80b88497ace026fb8e0a12986f94f216181480c7daa0e08be4"
632
+ "content": "630eb9047258ad0333082f407cd34522e4c278650f22ef6dba1ef897ec5cce71"
629
633
  }
630
634
  ]
631
635
  },
@@ -785,7 +789,7 @@
785
789
  "hashes": [
786
790
  {
787
791
  "alg": "SHA-256",
788
- "content": "a6ff055d50f4eb5790dab36deee188e880eec1362188ec16a5504710294d87c3"
792
+ "content": "587b36287ce9e9b85b1dfc41fc50d95297f5ac6be32e1e702e0b89b5df0a7892"
789
793
  }
790
794
  ]
791
795
  },
@@ -130,8 +130,15 @@ Before finalizing the plan, validate completeness:
130
130
  1. Cross-reference every `{REQUIREMENT}` (FR-N) → at least one TASK covers it
131
131
  2. Cross-reference every `{ADR_DECISION}` → at least one TASK implements it
132
132
  3. Cross-reference every critical risk from `{QUALITY_RISKS}` → mitigation in some TASK
133
- 4. If gaps found add missing TASKs and re-order the DAG
134
- 5. **Iterate until no gaps remain** (max 3 iterations, then flag for user)
133
+ 4. Name every acid token `A<n>` from `00_complexity_assessment.md` VERBATIM in the plan (C4), each
134
+ bound to the TASK that owns it and the TEST that proves the refusal — the gate reads them with a
135
+ word-boundary regex, so a paraphrase, a range (`A1-A7`) or a renamed token does not count
136
+ 5. Carry an `Inputs read:` line naming at least `03_adr` and `05_architecture` (C5). Put it at the
137
+ TOP of the plan or immediately BEFORE the trailing `EXPECTED_CODE_TARGETS:` block — never after
138
+ it. The plan must END with that targets block, so "end with Inputs read" would contradict the
139
+ layout the coder reads (cross-family QE, gpt-5.6-sol)
140
+ 6. If gaps found → add missing TASKs and re-order the DAG
141
+ 7. **Iterate until no gaps remain** (max 3 iterations, then flag for user)
135
142
 
136
143
  This gap-check loop ensures the implementation plan is complete before coding begins.
137
144
 
@@ -190,6 +197,14 @@ Create `features/<slug>/06_implementation_plan.md` with:
190
197
  the NEXT `AM-N` begins. Multi-line amendments are fine; what is NOT fine is putting a marker after
191
198
  the following amendment has already started, because it then belongs to that one. A bare range
192
199
  like `AM-1..AM-4` never opens a row.
200
+ TWO machines read this row with ONE grammar and different depths: the K2 C6 gate asks whether the
201
+ row is well-formed; `dz amendment-check` asks whether the named test RESOLVES to a real title in a
202
+ real file. So the marker carries ALL THREE parts — an arrow (`→` or `->`), the test id in
203
+ backticks, and the file as `` in `path` ``:
204
+ `AM-N (source): <change>. Confirmation: <property> → test \`test_name\` in \`path/to/file\` (fails if reverted).`
205
+ A marker WITHOUT the file used to pass the plan gate and then fail Step 8 with `no-file-named` —
206
+ that is the exact defect this shape removes. The alternative complete form is a retraction:
207
+ `superseded by AM-N`, which both machines accept.
193
208
  A safeguard amendment's named test must prove it TRIGGERS on a real input, not merely that its code
194
209
  path exists. Step 8 verifies every named test exists and is non-vacuous (`dz discrimination-check`).
195
210
 
@@ -246,6 +246,25 @@ ITERATION {N}:
246
246
  Max iterations: 3. After 3 iterations, flag remaining gaps for user decision.
247
247
  ```
248
248
 
249
+ **Do NOT count the iterations yourself — ASK.** Before starting another rework round, run:
250
+
251
+ ```bash
252
+ dz qe-rounds --slug <slug>
253
+ ```
254
+
255
+ `0` under the ceiling, another round is within budget · `1` **STOP** — the ceiling is reached, the
256
+ remaining gaps go to the owner · `2` NOT ESTABLISHED, which is never "zero rounds".
257
+
258
+ It reads what `dz qe-bridge` already wrote and records nothing of its own, so it answers correctly
259
+ across restarts and for runs already past.
260
+
261
+ **Why this is a command and not a sentence you remember.** Until 2026-08-27 this rule existed ONLY as
262
+ the line above: `grep -rl 'ReviewScope|scopeId|findingId|lineage'` over harness-core and harness-cli
263
+ returned nothing, so there was no counter anywhere. Every restart of the agent forgot the sentence.
264
+ MEASURED cost on real data: one slug reached **38** graded rounds and 4 attempts with no verdict,
265
+ against a ceiling of 3. A rule at layer 4 of the cost-of-detection ladder fails silently — nobody
266
+ complains that the ceiling was missed, it is simply never reached.
267
+
249
268
  This loop implements the "did we miss something?" recursive check from the original pipeline spec.
250
269
 
251
270
  ### 6. Acceptance Criteria Validation
@@ -301,15 +301,38 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
301
301
  // Line-scan, not regex-over-document: \Z is Python, and $-lookahead under /m matches every
302
302
  // line end — a section with no FOLLOWING heading silently failed to parse (caught by the
303
303
  // fixture the moment the tests ran).
304
- const planLines = plan.split('\n');
304
+ // Fenced blocks are BLANKED line-for-line, preserving numbering and offsets: a fenced
305
+ // `## Amendments` could become the section heading, and a fenced example row could either open a
306
+ // phantom amendment or hand a real testless one someone else's marker. Third fence-blindness
307
+ // found in a checker today, so it is closed here by construction rather than by care.
308
+ const rawLines = plan.split('\n');
309
+ const planLines = [];
310
+ {
311
+ let fence = null;
312
+ for (const line of rawLines) {
313
+ const open = /^ {0,3}(```+|~~~+)/.exec(line);
314
+ if (fence === null && open) { fence = open[1][0]; planLines.push(''); continue; }
315
+ if (fence !== null) {
316
+ planLines.push('');
317
+ if (new RegExp('^ {0,3}' + fence + '{3,}\\s*$').test(line)) fence = null;
318
+ continue;
319
+ }
320
+ planLines.push(line);
321
+ }
322
+ }
305
323
  let sectionStart = -1, sectionEnd = -1, cursor = 0;
306
324
  for (const pl of planLines) {
307
- if (sectionStart < 0 && /^##\s*Amendments\s*$/.test(pl)) sectionStart = cursor + pl.length + 1;
308
- else if (sectionStart >= 0 && sectionEnd < 0 && /^##\s/.test(pl)) sectionEnd = cursor;
325
+ // The SAME heading shape amendment-trace.ts accepts: up to three leading spaces, two to four
326
+ // hashes, and trailing text allowed. C6 required exactly `##` with nothing after, so the two
327
+ // tools disagreed about where the section even IS — the divergence this feature exists to end.
328
+ if (sectionStart < 0 && /^ {0,3}#{2,4}\s+Amendments\b/.test(pl)) sectionStart = cursor + pl.length + 1;
329
+ else if (sectionStart >= 0 && sectionEnd < 0 && /^ {0,3}#{1,4}\s/.test(pl)) sectionEnd = cursor;
309
330
  cursor += pl.length + 1;
310
331
  }
311
332
  if (sectionStart >= 0 && sectionEnd < 0) sectionEnd = plan.length;
312
- const amSection = sectionStart >= 0 ? plan.slice(sectionStart, sectionEnd) : '';
333
+ // Sliced from the MASKED text so the offsets computed above line up with what is scanned.
334
+ const maskedPlan = planLines.join('\n');
335
+ const amSection = sectionStart >= 0 ? maskedPlan.slice(sectionStart, sectionEnd) : '';
313
336
  // Only a DEFINITION-shaped line counts as a stray: a list item opening with the AM token.
314
337
  // A mid-prose REFERENCE («per 01_requirements.md (AM-1..AM-6)», «(AM-1: the clause stays
315
338
  // dropped)») cites an amendment defined in ANOTHER artifact and is legitimate — the first cut
@@ -317,7 +340,11 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
317
340
  {
318
341
  let cursor2 = 0;
319
342
  for (const pl of planLines) {
320
- const isDef = /^\s*(?:[-*|]\s*)\*{0,2}AM-(?:CP-)?\d+\b/.test(pl);
343
+ // The SAME row shape as the in-section rule, or the two disagree about what a row is: a
344
+ // bullet-less row was invisible here while being a row there (so it evaded this check), and
345
+ // this side lacked the range guard, so «AM-1..AM-4 are covered elsewhere» was falsely
346
+ // reported as a definition. One shape, one meaning.
347
+ const isDef = /^\s*(?:[-*|]\s*)?\*{0,2}AM-(?:CP-)?\d+\*{0,2}\b(?!\s*\.)/.test(pl);
321
348
  const inSection = sectionStart >= 0 && cursor2 >= sectionStart && cursor2 < sectionEnd;
322
349
  if (isDef && !inSection) {
323
350
  const tok = (/AM-(?:CP-)?\d+/.exec(pl) || ['AM-?'])[0];
@@ -346,16 +373,39 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
346
373
  // check entirely (MEASURED). The `(?!\s*\.)` guard is what refuses a wrapped `AM-1..AM-4;`
347
374
  // range preamble, which used to open a phantom amendment and double-count AM-1 — it costs 0 rows.
348
375
  const DEF = /^(?:[-*|]\s*)?\*{0,2}(AM-(?:CP-)?\d+)\*{0,2}\b(?!\s*\.)/;
349
- // `tests?` the two-id form `-> tests \`a\` and \`b\`` is corpus-canonical and is what
350
- // amendment-trace.ts parses; matching only the singular turned two currently-green rows red.
351
- const MARK = /\u2192\s*tests?\b|->\s*tests?\b/;
376
+ // The marker must carry what the LATER gate needs to resolve: an arrow, a backticked test id,
377
+ // and a file as `in \`path\``. Until 2026-08-25 this accepted a bare `-> test`, so a plan cleared
378
+ // K2 and then failed Step-8 `dz amendment-check` with `no-file-named` — the two tools read one
379
+ // row under different contracts (field report doc-26). Same grammar, different depth: this asks
380
+ // whether the row is WELL-FORMED, amendment-check asks whether it RESOLVES.
381
+ // The two-id form `-> tests \`a\` and \`b\`` is corpus-canonical; matching only the singular
382
+ // turned two green rows red when this was last touched.
383
+ const MARK = /(?:\u2192|->)\s*tests?\s+`[^`]+`(?:\s*(?:and|и)\s*`[^`]+`)?[\s\S]{0,40}?\bin\s+`[^`]+`/;
352
384
  const defs = [];
353
385
  secLines.forEach((l, i) => { const m = DEF.exec(l.trim()); if (m) defs.push({ i, id: m[1] }); });
354
386
  for (let k = 0; k < defs.length; k++) {
355
387
  const to = k + 1 < defs.length ? defs[k + 1].i : secLines.length;
356
388
  const block = secLines.slice(defs[k].i, to).join('\n');
357
389
  const hasTest = MARK.test(block);
358
- const superseded = /superseded by AM-(?:CP-)?\d+/i.test(block);
390
+ // A MENTION is not a CLAIM — the third instance of that class found today. An unanchored
391
+ // substring test passes on "NOT superseded by AM-9" and on a sentence quoting the form.
392
+ // Anchoring on POSITION was tried and rejected: real rows put prose between the id and the
393
+ // retraction, so a position rule refuses legitimate corpus forms. The rule is therefore about
394
+ // NEGATION and QUOTATION, the two ways a mention differs from a claim — plus the successor
395
+ // must EXIST among the amendments defined here, since a retraction pointing at AM-999999 is
396
+ // not a retraction but a way through the gate.
397
+ let superseded = false;
398
+ for (const bl of block.split('\n')) {
399
+ const re = /superseded by (AM-(?:CP-)?\d+)\b/ig;
400
+ for (let m = re.exec(bl); m !== null; m = re.exec(bl)) {
401
+ const before = bl.slice(0, m.index);
402
+ if (/\b(?:not|never|no|isn't|is not|rather than|instead of)\s*$/i.test(before)) continue;
403
+ const quotes = (before.match(/[`"«]/g) || []).length;
404
+ if (quotes % 2 === 1) continue;
405
+ if (m[1] && defs.some((d) => d.id.toLowerCase() === m[1].toLowerCase())) { superseded = true; break; }
406
+ }
407
+ if (superseded) break;
408
+ }
359
409
  if (!hasTest && !superseded) failures.push(`C6: ${defs[k].id} carries neither \`\u2192 test <name>\` nor \`superseded by AM-N\` — an amendment without a confirmation is a wish, and a retracted one must say its successor`);
360
410
  }
361
411
  }
@@ -368,7 +418,11 @@ else for (const t of acidTokens) if (!new RegExp(`\\b${t.replace(/[.*+?^${}()|[\
368
418
  // with both sides named and never blocks.
369
419
  {
370
420
  const adrAll = adrFiles.map((f) => { try { return readFileSync(join(adrDir, f), 'utf-8'); } catch { return ''; } }).join('\n');
371
- const cmds = (t) => new Set([...t.matchAll(/`dz ([a-z][a-z0-9-]+)`/g)].map((m) => m[1]));
421
+ // The tail is OPTIONAL and stops at the first closing backtick (`[^`]*`, never `.*`): a command
422
+ // cited WITH its real arguments must be visible on BOTH sides, or a genuine ADR<->plan divergence
423
+ // hides whenever both files write the natural form (ADR-002, backlog 420e5b79). The CAPTURE stays
424
+ // the bare name, so the comparison remains a set of command NAMES and flag differences never warn.
425
+ const cmds = (t) => new Set([...t.matchAll(/`dz ([a-z][a-z0-9-]+)(?:\s[^`]*)?`/g)].map((m) => m[1]));
372
426
  const inAdr = cmds(adrAll); const inPlan = cmds(plan);
373
427
  const onlyAdr = [...inAdr].filter((c) => !inPlan.has(c));
374
428
  const onlyPlan = [...inPlan].filter((c) => !inAdr.has(c));
@@ -505,9 +505,31 @@ function captureFailureRecord(stage, mode, reason, detail) {
505
505
  }
506
506
  function tpFamily(spec) { return /codex|gpt|openai/i.test(String(spec == null ? '' : spec)) ? 'codex' : 'claude' }
507
507
  function tpText(v) { if (typeof v === 'string') return v; if (v === null || v === undefined) return ''; try { const s = JSON.stringify(v); return typeof s === 'string' ? s : String(v) } catch (e) { return String(v) } }
508
+ // Operator-profile redaction (mirror of harness-core redactProfileBlock — ADR-001 Decision 5 /
509
+ // CF-6 of operator-profile). The PERSIST seam in `dz feature-adr-record` redacts independently;
510
+ // this mirror exists so the truncation fnv1a64 hashes below are hashes of the REDACTED text and
511
+ // never fingerprint personal data, exactly like the core builder. Unterminated block fails
512
+ // CLOSED: everything from the start marker to the end of the text is dropped.
513
+ const TP_PROFILE_START = '<!-- dz:profile:start -->'
514
+ const TP_PROFILE_END = '<!-- dz:profile:end -->'
515
+ const TP_PROFILE_REDACTED = '[dz:profile REDACTED]'
516
+ function tpRedact(text) {
517
+ if (typeof text !== 'string' || text === '') return typeof text === 'string' ? text : ''
518
+ let out = ''
519
+ let rest = text
520
+ for (;;) {
521
+ const start = rest.indexOf(TP_PROFILE_START)
522
+ if (start === -1) return out + rest
523
+ out += rest.slice(0, start) + TP_PROFILE_REDACTED
524
+ const end = rest.indexOf(TP_PROFILE_END, start + TP_PROFILE_START.length)
525
+ if (end === -1) return out
526
+ rest = rest.slice(end + TP_PROFILE_END.length)
527
+ }
528
+ }
508
529
  function buildTrainingPair(slug, stage, ts, inputRaw, outputRaw, evaluation, provenance, captureMode, resumed) {
509
- let input = tpText(inputRaw)
510
- let output = tpText(outputRaw)
530
+ // Redaction FIRST, before the oversize guard — same order as the core builder.
531
+ let input = tpRedact(tpText(inputRaw))
532
+ let output = tpRedact(tpText(outputRaw))
511
533
  let truncated = null
512
534
  if (input.length + output.length > TP_MAX_IO) {
513
535
  truncated = { inputChars: input.length, outputChars: output.length, inputHash: fnv1a64(input), outputHash: fnv1a64(output) }
@@ -921,9 +943,23 @@ function codexExecPlan(stage, promptChars, probedId, scoped) {
921
943
  // A model id is user input (args.codexModel) and lands in a shell command the agent runs. Cross-model
922
944
  // review (codex exec, 2026-07-10) found it interpolated unquoted. Plain ids only, quoted anyway.
923
945
  function isSafeCodexId(id) { return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(String(id)) }
924
- function codexProbeCommand(id) {
946
+ const TIMEOUT_BINS = { timeout: true, gtimeout: true }
947
+
948
+ // Which binary bounds a dispatched run. timeout(1) is GNU coreutils and is NOT on macOS; brew's
949
+ // coreutils installs it as gtimeout. An ALLOWLIST, because this value goes into a shell command.
950
+ // MEASURED 2026-08-25: with neither present the dispatch exits 127 and cross-family QE — a NAMED
951
+ // safety property — silently did not happen for a whole run.
952
+ // Deliberately NOT the perl alarm+exec form: MEASURED, it exits 142 while GNU timeout exits 124,
953
+ // and classifyCodexQeOutcome keys timeout on exit === 124. A remedy that breaks the classifier is
954
+ // worse than the defect it fixes.
955
+ function timeoutBinOrDefault(bin) {
956
+ const b = typeof bin === 'string' ? bin : ''
957
+ return TIMEOUT_BINS[b] === true ? b : 'timeout'
958
+ }
959
+
960
+ function codexProbeCommand(id, timeoutBin) {
925
961
  if (!isSafeCodexId(id)) return null
926
- return "timeout 60 codex exec -m '" + id + "' 'Reply with exactly: OK' < /dev/null"
962
+ return timeoutBinOrDefault(timeoutBin) + " 60 codex exec -m '" + id + "' 'Reply with exactly: OK' < /dev/null"
927
963
  }
928
964
 
929
965
  // A verdict must NAME its grade. "Looks good" is not a review — cross-model review caught this too.
@@ -956,7 +992,7 @@ const CODEX_REVIEW_TIMEOUT_SECONDS = 600
956
992
  const CODEX_REVIEW_DEFAULT_EFFORT = 'high'
957
993
  const CODEX_TIMEOUT = 'CODEX_TIMEOUT'
958
994
  const CODEX_QE_SIGNAL_PREFIX = 'CODEX-QE-SIGNAL'
959
- const CODEX_QE_DECLINE_KINDS = ['timeout', 'no-verdict', 'tool-error', 'unusable-output', 'unavailable', 'over-ceiling']
995
+ const CODEX_QE_DECLINE_KINDS = ['timeout', 'no-verdict', 'tool-error', 'unusable-output', 'unavailable', 'over-ceiling', 'wrong-tree']
960
996
  const SCOPED_QE_MAX_FILES = 3
961
997
  const SCOPED_QE_MAX_QUESTIONS = 4
962
998
  const SCOPED_QE_MAX_PATH_CHARS = 200
@@ -974,6 +1010,23 @@ function isSafeCodexRef(ref) {
974
1010
  // --uncommitted alike. Either mistake presents as a review that silently did not happen, so both are
975
1011
  // refusals in the builder, not comments. carriesPrompt is kept as a field: it is the one boolean a
976
1012
  // future CLI would flip.
1013
+ function codexSq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'" }
1014
+ function codexCd(repo) { return repo === '' ? '' : 'cd ' + codexSq(repo) + ' && ' }
1015
+ // Field report 27: BOTH codex dispatches ran with no working directory, so they read the SESSION cwd.
1016
+ // On a run against an external checkout that is a different tree — and the review still exits 0 with a
1017
+ // Grade the pipeline records as a verdict, while crossFamilyQe.happened stays true. codex exec takes
1018
+ // -C; codex review has no such flag (measured on codex-cli 0.149.1) and needs a cd prefix.
1019
+ function codexExecCommand(input) {
1020
+ const o = input || {}
1021
+ const modelId = String(o.modelId === undefined || o.modelId === null ? '' : o.modelId)
1022
+ if (!isSafeCodexId(modelId)) return null
1023
+ const raw = Number(o.timeoutSeconds)
1024
+ const seconds = (raw === raw && raw !== Infinity && raw > 0) ? Math.floor(raw) : CODEX_EXEC_TIMEOUT_SECONDS
1025
+ const repo = String(o.repo === undefined || o.repo === null ? '' : o.repo)
1026
+ const cd = repo === '' ? '' : ' -C ' + codexSq(repo)
1027
+ return timeoutBinOrDefault(o.timeoutBin) + ' ' + seconds + ' codex exec' + cd + ' -m ' + codexSq(modelId) + ' ' + codexSq(String(o.prompt === undefined || o.prompt === null ? '' : o.prompt)) + ' < /dev/null'
1028
+ }
1029
+
977
1030
  function codexReviewCommand(input) {
978
1031
  const o = input || {}
979
1032
  const scope = (o.scope === undefined || o.scope === null || o.scope === '') ? 'uncommitted' : String(o.scope)
@@ -988,7 +1041,8 @@ function codexReviewCommand(input) {
988
1041
  if (scope !== 'uncommitted' && !isSafeCodexRef(ref)) return { cmd: null, carriesPrompt: false, scope: scope, reason: 'unsafe id or ref' }
989
1042
  const raw = Number(o.timeoutSeconds)
990
1043
  const seconds = (raw === raw && raw !== Infinity && raw > 0) ? Math.floor(raw) : CODEX_REVIEW_TIMEOUT_SECONDS
991
- let cmd = 'timeout ' + seconds + " codex review -c model='" + modelId + "' -c model_reasoning_effort='" + effort + "'"
1044
+ const repo = String(o.repo === undefined || o.repo === null ? '' : o.repo)
1045
+ let cmd = codexCd(repo) + timeoutBinOrDefault(o.timeoutBin) + ' ' + seconds + " codex review -c model='" + modelId + "' -c model_reasoning_effort='" + effort + "'"
992
1046
  if (scope === 'commit') cmd += " --commit '" + ref + "'"
993
1047
  else if (scope === 'base') cmd += " --base '" + ref + "'"
994
1048
  else cmd += ' --uncommitted'
@@ -1097,6 +1151,37 @@ function gradeFromReviewFindings(findings) {
1097
1151
  // called it 'unusable-output' and told the operator to fix a tool that works. And exit 0 is a
1098
1152
  // SUCCESSFUL review even when it finds blockers (MEASURED probe 0.2), so tool-error is exit not in
1099
1153
  // {0,124} only. signalExpected defaults TRUE (fail closed): a swallowed sentinel is a tool-error.
1154
+ // Field report 27. A review dispatched into the wrong working directory does not error: the declared
1155
+ // paths simply are not there, the model says so in prose, and the command still exits 0 — often with
1156
+ // a Grade line, which the pipeline then records as a verdict about code nobody read. Narrow on
1157
+ // purpose: a review of a file-handling module may legitimately DISCUSS "No such file or directory",
1158
+ // and a mention is not a claim — so one LINE must carry both the phrase and a declared path.
1159
+ function codexReviewMissedItsFiles(body, declaredFiles) {
1160
+ const text = String(body === undefined || body === null ? '' : body)
1161
+ const files = Array.isArray(declaredFiles) ? declaredFiles.filter((f) => typeof f === 'string' && f !== '') : []
1162
+ if (text === '' || files.length === 0) return false
1163
+ // Only quotes, whitespace and a colon may sit between the path and the failure. Prose may not —
1164
+ // and that single restriction is what separates "the tool could not open this path" from "this
1165
+ // finding is ABOUT this path": a review finding always names its file, so anything looser marks
1166
+ // every file-handling review as wrong-tree. (Codex, gpt-5.6-sol, on the first version of this
1167
+ // function: the finding line "- [P2] Do not swallow file not found - src/io.ts:42" plus a stated
1168
+ // grade C was classified wrong-tree, discarding a valid cross-family verdict and falling back to
1169
+ // same-family QE — the guard against a false-clean review destroying a true one.)
1170
+ const GAP = '["\'\u2018\u2019\u201c\u201d\u0060(\\[\\s:,]{0,4}'
1171
+ const NOT_FOUND = 'no such file or directory|file not found|not found|does not exist|is not present|cannot be found'
1172
+ const VERB = '(?:cannot|can\'t|could not|couldn\'t|unable to|failed to|error(?: while)?)\\s+(?:open|read|find|access|stat|locate|load)'
1173
+ for (const f of files) {
1174
+ const q = f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
1175
+ const shapes = [
1176
+ q + GAP + '(?:' + NOT_FOUND + ')', // src/io.ts: No such file or directory
1177
+ '(?:' + NOT_FOUND + ')' + GAP + q, // No such file or directory: src/io.ts
1178
+ VERB + GAP + q, // cannot open 'src/io.ts'
1179
+ ]
1180
+ for (const shape of shapes) if (new RegExp(shape, 'i').test(text)) return true
1181
+ }
1182
+ return false
1183
+ }
1184
+
1100
1185
  function classifyCodexQeOutcome(input) {
1101
1186
  const o = input || {}
1102
1187
  const body = String(o.body === undefined || o.body === null ? '' : o.body)
@@ -1117,6 +1202,9 @@ function classifyCodexQeOutcome(input) {
1117
1202
  } else if (exit !== 0) {
1118
1203
  return { kind: 'tool-error' }
1119
1204
  }
1205
+ // BEFORE the grade rule: a wrong-tree review usually DOES state a grade, and that clean letter
1206
+ // about unread code is the most dangerous thing this pipeline can emit.
1207
+ if (codexReviewMissedItsFiles(body, o.declaredFiles)) return { kind: 'wrong-tree' }
1120
1208
  if (grade !== null) return { kind: 'verdict' }
1121
1209
  return { kind: 'no-verdict' }
1122
1210
  }
@@ -1137,11 +1225,14 @@ function codexQeDeclineReason(kind, detail) {
1137
1225
  const exit = (d.exit === undefined || d.exit === null) ? '?' : String(d.exit)
1138
1226
  const chars = (d.chars === undefined || d.chars === null) ? '?' : String(d.chars)
1139
1227
  const extra = (d.detail === undefined || d.detail === null || String(d.detail) === '') ? 'no detail' : String(d.detail)
1228
+ if (canonical === 'wrong-tree') return 'codex reported that the declared file(s) do not exist — the review ran in the WRONG working directory and its verdict is about a tree nobody asked for; ' + files + ' file(s) declared, exit ' + exit + ' (' + extra + ')'
1140
1229
  if (canonical === 'timeout') return 'codex review timed out after ' + elapsed + 's on scope ' + ref + ' (' + files + ' files) — NARROW the scope (this is reconnaissance cost, not thinking time)'
1141
1230
  if (canonical === 'no-verdict') return 'codex answered in ' + elapsed + 's but named no grade — not a verdict'
1142
1231
  if (canonical === 'tool-error') return 'codex review exited ' + exit + ' — FIX the invocation (' + extra + ')'
1143
1232
  if (canonical === 'unusable-output') return 'codex exec unusable — ' + ((d.reason === undefined || d.reason === null || String(d.reason) === '') ? 'codex exec returned no text' : String(d.reason))
1144
- if (canonical === 'unavailable') return 'codex not used ' + ((d.reason === undefined || d.reason === null || String(d.reason) === '') ? 'codex exec reported it could not run' : String(d.reason))
1233
+ // The detail is the ONE field carrying the shell error, and this branch used to drop it while
1234
+ // tool-error right above rendered it — the asymmetry that made the field report unfixable blind.
1235
+ if (canonical === 'unavailable') return 'codex not used — ' + ((d.reason === undefined || d.reason === null || String(d.reason) === '') ? 'codex exec reported it could not run' : String(d.reason)) + (extra === 'no detail' ? '' : ' (' + extra + ')')
1145
1236
  if (canonical === 'over-ceiling') return 'prompt is ' + chars + ' chars / unscoped — refused before dispatch'
1146
1237
  throw new Error('codexQeDeclineReason: unknown kind ' + k)
1147
1238
  }
@@ -1157,12 +1248,12 @@ function codexDeclineReason(a, b) {
1157
1248
  // this input may never have been wrapped (a saved fixture, a report on disk), so a missing sentinel
1158
1249
  // means "no machine signal exists", not "the tool failed". The PIPELINE must not use that leniency —
1159
1250
  // the dispatch below calls classifyCodexQeOutcome directly with signalExpected true.
1160
- function parseCodexReviewResult(text) {
1251
+ function parseCodexReviewResult(text, declaredFiles) {
1161
1252
  const sig = parseCodexReviewSignal(text)
1162
1253
  const findings = parseCodexReviewFindings(sig.body)
1163
1254
  const stated = parseCodexGrade(sig.body)
1164
1255
  const grade = (stated !== null) ? stated : gradeFromReviewFindings(findings)
1165
- const outcome = classifyCodexQeOutcome({ exit: sig.exit, body: sig.body, grade: grade, findings: findings, signalExpected: sig.signalPresent })
1256
+ const outcome = classifyCodexQeOutcome({ exit: sig.exit, body: sig.body, grade: grade, findings: findings, signalExpected: sig.signalPresent, declaredFiles: declaredFiles })
1166
1257
  const kind = (outcome.kind === 'unusable-output') ? 'empty' : outcome.kind
1167
1258
  const ok = kind === 'verdict'
1168
1259
  const reason = ok ? null : codexQeDeclineReason(kind, { elapsedSeconds: sig.elapsedSeconds, exit: sig.exit, chars: sig.body.length })
@@ -1186,12 +1277,35 @@ async function safeCodexAgent(prompt, opts) {
1186
1277
 
1187
1278
  // CX-1: the allowlist says a name is spellable; only a probe says it answers. One probe per run.
1188
1279
  let _probedCodexId
1280
+ // Which timeout binary this machine actually has. ONE cached shell probe, run before either
1281
+ // dispatch mode spends a model call.
1282
+ //
1283
+ // The model probe below is NOT a substitute: it is judged by the agent's prose ("reply with its
1284
+ // stdout only"), so on the machine in field report doc-24 it passed while timeout(1) was absent —
1285
+ // the agent evidently retried without the missing prefix — and only the strict "run this VERBATIM"
1286
+ // dispatch surfaced the 127. A shell fact must be checked by a shell, once, deterministically.
1287
+ let _timeoutBin
1288
+ async function probeTimeoutBin() {
1289
+ if (_timeoutBin !== undefined) return _timeoutBin
1290
+ const cmd = 'command -v timeout >/dev/null 2>&1 && echo timeout || { command -v gtimeout >/dev/null 2>&1 && echo gtimeout || echo NONE; }'
1291
+ const out = await agent('Run EXACTLY this via Bash and reply with its stdout only: ' + cmd, { label: 'probe:timeout-bin', phase: 'Route', model: 'haiku', effort: 'low' })
1292
+ const t = String(out === null || out === undefined ? '' : out).trim()
1293
+ _timeoutBin = TIMEOUT_BINS[t] === true ? t : null
1294
+ if (_timeoutBin === null) log('codex: NEITHER timeout(1) NOR gtimeout is on PATH — every codex dispatch would exit 127; on macOS: brew install coreutils')
1295
+ else if (_timeoutBin !== 'timeout') log('codex: bounding runs with ' + _timeoutBin + ' (timeout(1) is absent — darwin without coreutils on PATH)')
1296
+ return _timeoutBin
1297
+ }
1298
+
1189
1299
  async function probeCodexId() {
1190
1300
  if (_probedCodexId !== undefined) return _probedCodexId
1191
1301
  const raw = (CODEX_MODEL && CODEX_MODEL !== 'auto') ? [CODEX_MODEL, 'gpt-5.6-sol', 'gpt-5.5'] : ['gpt-5.6-sol', 'gpt-5.5']
1192
1302
  const ids = raw.filter(isSafeCodexId)
1193
1303
  for (const id of ids) {
1194
- const cmd = codexProbeCommand(id)
1304
+ // The probe is built with the SAME binary the real dispatch will use, so a machine that cannot
1305
+ // bound a run fails here rather than mid-QE.
1306
+ const tbin = await probeTimeoutBin()
1307
+ if (tbin === null) { _probedCodexId = null; return null }
1308
+ const cmd = codexProbeCommand(id, tbin)
1195
1309
  if (!cmd) { log('codex: refusing unsafe model id ' + id); continue }
1196
1310
  const out = await agent('Run EXACTLY this via Bash and reply with its stdout only: ' + cmd + ' — if it fails or times out reply with exactly ' + CODEX_UNAVAILABLE, { label: 'probe:' + id, phase: 'Route', model: 'haiku', effort: 'low' })
1197
1311
  if (out && /\bOK\b/.test(String(out)) && String(out).indexOf(CODEX_UNAVAILABLE) === -1) {
@@ -1249,7 +1363,7 @@ async function runCodexQeCommand(stage, cmd, phaseName, label, probed, mode, sco
1249
1363
  // the closing line, may read it.
1250
1364
  const stated = (allowStatedGrade === true) ? parseCodexGrade(sig.body) : null
1251
1365
  const grade = (stated !== null) ? stated : gradeFromReviewFindings(findings)
1252
- const outcome = classifyCodexQeOutcome({ exit: sig.exit, body: sig.body, grade: grade, findings: findings, signalExpected: true })
1366
+ const outcome = classifyCodexQeOutcome({ exit: sig.exit, body: sig.body, grade: grade, findings: findings, signalExpected: true, declaredFiles: files })
1253
1367
  if (outcome.kind !== 'verdict') {
1254
1368
  return noteCodexDecline(stage, outcome.kind, { elapsedSeconds: sig.elapsedSeconds, exit: sig.exit, ref: scopeRef, files: files, chars: cmd.length, reason: parseCodexExecResult(sig.body).reason, detail: sig.body.slice(0, 160) })
1255
1369
  }
@@ -1263,7 +1377,7 @@ async function codexReviewAgent(stage, scope, scopeRef, phaseName) {
1263
1377
  lastCodexDecline = null
1264
1378
  const probed = await probeCodexId()
1265
1379
  if (!probed) return noteCodexDecline(stage, 'unavailable', { reason: 'no codex model id answered the probe' })
1266
- const built = codexReviewCommand({ scope: scope, ref: scopeRef, modelId: probed, reasoning: 'high', timeoutSeconds: CODEX_REVIEW_TIMEOUT_SECONDS })
1380
+ const built = codexReviewCommand({ scope: scope, ref: scopeRef, modelId: probed, reasoning: 'high', timeoutSeconds: CODEX_REVIEW_TIMEOUT_SECONDS, timeoutBin: await probeTimeoutBin(), repo: REPO })
1267
1381
  if (built.cmd === null) return noteCodexDecline(stage, 'tool-error', { exit: 2, detail: built.reason })
1268
1382
  return await runCodexQeCommand(stage, built.cmd, phaseName, stage + ':codex-review', probed, 'A', built.scope + (scopeRef ? ' ' + scopeRef : ''), [], false)
1269
1383
  }
@@ -1275,7 +1389,11 @@ async function codexExecAgent(stage, prompt, phaseName, scoped, files) {
1275
1389
  const probed = await probeCodexId()
1276
1390
  const plan = codexExecPlan(stage, prompt.length, probed, scoped)
1277
1391
  if (plan.mode !== 'exec') return noteCodexDecline(stage, 'unavailable', { reason: plan.reason })
1278
- const inner = 'timeout ' + CODEX_EXEC_TIMEOUT_SECONDS + ' codex exec -m ' + JSON.stringify(probed) + ' ' + JSON.stringify(prompt) + ' < /dev/null'
1392
+ // Was JSON.stringify(...) DOUBLE quotes, in which the shell still expands a command substitution, and the
1393
+ // prompt carries the user's own feature description. Single-quoted through codexSq closes that
1394
+ // as a side effect of pinning the working directory.
1395
+ const inner = codexExecCommand({ modelId: probed, prompt: prompt, timeoutBin: await probeTimeoutBin(), timeoutSeconds: CODEX_EXEC_TIMEOUT_SECONDS, repo: REPO })
1396
+ if (inner === null) return noteCodexDecline(stage, 'unavailable', { reason: 'unsafe codex id ' + String(probed) })
1279
1397
  return await runCodexQeCommand(stage, inner, phaseName, stage + ':codex-exec', probed, 'B', 'declared-targets(' + (files || []).length + ' declared, <=' + SCOPED_QE_MAX_FILES + ' reviewed)', files || [], true)
1280
1398
  }
1281
1399
 
@@ -1978,7 +2096,7 @@ const DISCRIMINATION_GATE = '\u00a742 TEST-DISCRIMINATION GATE (run right after
1978
2096
  // amendment is a mini-ADR: it carries a one-line Confirmation naming the test that falsifies it. Machine-checkable
1979
2097
  // shape (a linter can assert the `→ test ` token); Step-8 verifies existence + non-vacuity via the SAME
1980
2098
  // dz discrimination-check that guards the ADR property (cost-of-detection ladder: judgment → step gate).
1981
- const AMENDMENT_RULE = 'AMENDMENT CONFIRMATION DISCIPLINE (every amendment is a mini-ADR): whenever a correction/amendment is folded in (a QCSD CONDITIONAL condition, a challenge-panel confirmed finding, or a user checkpoint steer), record it in a `## Amendments` section as a fixed-shape row: `AM-N (source): <change>. Confirmation: <property> → test `test_name` (fails if reverted).` — naming the test that would FAIL if the amendment were reverted/broken. The row is MACHINE-READ by the K2 C6 gate: the marker (or a superseded-by-AM-N note) must sit inside the OWN block of that amendment — anywhere between its AM-N line and the line where the NEXT AM-N begins. Multi-line amendments are fine; what is NOT fine is placing a marker after the following amendment has already started, because it then belongs to that one. A bare range such as AM-1..AM-4 never opens a row. For a SAFEGUARD amendment (a warning/guard/fallback), the named test must prove the safeguard actually TRIGGERS on a real input — not merely that its code path exists (a structurally-dead safeguard passes an existence test and never fires in production).'
2099
+ const AMENDMENT_RULE = 'AMENDMENT CONFIRMATION DISCIPLINE (every amendment is a mini-ADR): whenever a correction/amendment is folded in (a QCSD CONDITIONAL condition, a challenge-panel confirmed finding, or a user checkpoint steer), record it in a `## Amendments` section as a fixed-shape row: `AM-N (source): <change>. Confirmation: <property> → test `test_name` (fails if reverted).` — naming the test that would FAIL if the amendment were reverted/broken. The row is MACHINE-READ by the K2 C6 gate: the marker (or a superseded-by-AM-N note) must sit inside the OWN block of that amendment — anywhere between its AM-N line and the line where the NEXT AM-N begins. Multi-line amendments are fine; what is NOT fine is placing a marker after the following amendment has already started, because it then belongs to that one. A bare range such as AM-1..AM-4 never opens a row. The row is read by TWO machines with the SAME grammar and different depths: the K2 C6 gate asks whether the row is well-formed, and dz amendment-check asks whether the named test RESOLVES. So the marker must carry ALL THREE parts: an arrow, the test id in backticks, and the file — AM-N (source): <change>. Confirmation: <property> -> test <backticked name> in <backticked path> (fails if reverted). A marker without the file passes the plan gate and then FAILS Step 8, which is the exact defect this shape removes. The alternative form is a retraction: superseded by AM-N, which both machines accept as a complete answer. For a SAFEGUARD amendment (a warning/guard/fallback), the named test must prove the safeguard actually TRIGGERS on a real input — not merely that its code path exists (a structurally-dead safeguard passes an existence test and never fires in production).'
1982
2100
  const AMENDMENT_GATE = 'AMENDMENT GATE (P2): do NOT judge this yourself — RUN the check and report what it says. Via Bash run EXACTLY `' + DZ + ' amendment-check --slug ' + SLUG + ' --json` (add `--feature-dir ' + FDIR + '` if the slug does not resolve from your CWD). Parse the JSON and report `amendments: {outcome, counts, reasons}` in your return object. outcome `pass` or `skip` clears the gate; `fail` is a HIGH gap and every reason must be quoted verbatim into the QE report; `not-established` means the check could not be run or the grammar matched nothing — that is NEVER a pass, report it as inconclusive with the tool error. Empty stdout, a crash, or a missing `dz` is `not-established`, not a clean gate. This check proves each amendment RESOLVES to a real test; it does NOT prove the test discriminates — vacuity stays with the discrimination gate above. ' +
1983
2101
  'IO-ON-PURE-PATH + FIXTURE-SWAP HUNT (P5): in the test diff, hunt for replacements of broken/unbound fixtures with healthy ones — the old fixture was probably a NEGATIVE CONTROL proving a path was I/O-free; each such swap requires a compensating negative resource-down test. If the code diff adds I/O (DB/network/file) to a previously-pure path — especially startup/lifespan/health — require a negative resource-down test (broken/unbound resource → the path degrades per its declared contract: fail-open for advisory, explicit fail-fast for load-bearing). Missing → HIGH gap.'
1984
2102
 
@@ -2075,9 +2193,35 @@ await agent('Run EXACTLY this one shell command via your Bash tool and report it
2075
2193
  // R2 polymorphic-feature-adr (ADR-001): probe the project skill manifest ONCE at Step 0. Returns only
2076
2194
  // {hasManifest, report} (small, reliable). NO manifest ⇒ PS_GUIDANCE returns '' for every stage, so the
2077
2195
  // stage prompts are byte-identical to today (FR-7 load-bearing). Fail-open: a probe error ⇒ generic run.
2196
+ // Field report doc-25b: BOTH the probe below and PS_GUIDANCE hardcoded `cd REPO`, so on a run whose
2197
+ // REPO is an external checkout the manifest installed in the WORKSPACE was unreachable — the run
2198
+ // recorded an honest `polymorphism:null` and every project lens silently went missing. Symmetric with
2199
+ // the doc-21 fix for the K2 gate: probe the target repo FIRST (a repo's own conventions are
2200
+ // authoritative for it) and fall back to the workspace ONLY when the target has no manifest and WS is
2201
+ // a genuinely different root. The choice is made in the SHELL by `grep -q`, never by the agent's
2202
+ // judgment. ONE builder feeds both call sites, so they cannot drift apart again.
2203
+ // Checkpoint note: POLY.hasManifest and fnv1a64(POLY.report) are checkpoint-hash inputs, so a run that
2204
+ // NOW finds a manifest it used to miss legitimately re-spends design/code/qe on resume — that is the
2205
+ // stale-input promise working, not a regression.
2206
+ // MIRROR of harness-core `projectSkillsOneRoot` / `projectSkillsProbeCommand` (the workflow sandbox
2207
+ // has no imports). Behaviour is pinned against the export by a drift test that extracts BOTH copies.
2208
+ function psSq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'" }
2209
+ function projectSkillsOneRoot(dzBin, root) { return 'cd ' + psSq(root) + ' && ' + dzBin + ' project-skills --project ' + psSq(root) + ' --stages-json' }
2210
+ function projectSkillsProbeCommand(dzBin, repo, workspace) {
2211
+ const primary = projectSkillsOneRoot(dzBin, repo)
2212
+ if (workspace === null || workspace === undefined || workspace === repo) return primary
2213
+ return 'o=$(' + primary + ' 2>/dev/null); echo "$o" | grep -q \'"hasManifest":true\' || o=$(' + projectSkillsOneRoot(dzBin, workspace) + ' 2>/dev/null); echo "$o"'
2214
+ }
2215
+ // WS is populated ONLY by args.workspace or a relative args.repo, so the reporter's own invocation
2216
+ // (absolute args.repo, no args.workspace) leaves it null and the fallback would never arm — the exact
2217
+ // run doc-28 describes. BRAIN is the canonical pattern store, defaulting to REPO but pinned by
2218
+ // args.brain to the workspace precisely when the coder works in a target checkout, so it is the
2219
+ // second-best evidence of where the workspace is. Falling back to it costs nothing when it equals
2220
+ // REPO (the builder collapses to the single-root form).
2221
+ const PS_CMD = projectSkillsProbeCommand(DZ, REPO, WS !== null ? WS : (BRAIN !== REPO ? BRAIN : null))
2078
2222
  let POLY = { hasManifest: false, report: '' }
2079
2223
  try {
2080
- const psProbe = await agent('Run EXACTLY this one shell command via your Bash tool: cd ' + REPO + ' && ' + DZ + ' project-skills --stages-json. It prints one JSON line {hasManifest, design, code, qe, report}. Return ONLY {hasManifest, report} from it (drop the big design/code/qe strings).', { label: 'project-skills:step0', phase: 'Router', effort: 'low', schema: PROJECT_SKILLS })
2224
+ const psProbe = await agent('Run EXACTLY this one shell command via your Bash tool: ' + PS_CMD + '. It prints one JSON line {hasManifest, design, code, qe, report}. Return ONLY {hasManifest, report} from it (drop the big design/code/qe strings).', { label: 'project-skills:step0', phase: 'Router', effort: 'low', schema: PROJECT_SKILLS })
2081
2225
  if (psProbe && typeof psProbe === 'object') POLY = psProbe
2082
2226
  } catch (e) { /* fail-open — generic run */ }
2083
2227
  if (POLY.hasManifest) log('Project skills: manifest active — folding project guidance into design/code/qe')
@@ -2085,7 +2229,7 @@ if (POLY.hasManifest) log('Project skills: manifest active — folding project g
2085
2229
  // agent fetches its OWN stage's guidance from `dz project-skills` (exact file content, no model
2086
2230
  // transcription). Empty string when no manifest ⇒ `prompt + PS_GUIDANCE(...)` is a no-op (byte-identical).
2087
2231
  const PS_GUIDANCE = (stage) => POLY.hasManifest
2088
- ? '\n\nPROJECT-SPECIFIC GUIDANCE (polymorphic feature-adr): this project ships a skill manifest. Via your Bash tool run EXACTLY `cd ' + REPO + ' && ' + DZ + ' project-skills --stages-json`, parse the JSON, and treat its `' + stage + '` field as AUTHORITATIVE project guidance for THIS step (product vision, conventions, checklists) — honor it as a hard constraint. If the command errors or hasManifest is false, ignore this paragraph.'
2232
+ ? '\n\nPROJECT-SPECIFIC GUIDANCE (polymorphic feature-adr): this project ships a skill manifest. Via your Bash tool run EXACTLY `' + PS_CMD + '`, parse the JSON, and treat its `' + stage + '` field as AUTHORITATIVE project guidance for THIS step (product vision, conventions, checklists) — honor it as a hard constraint. If the command errors or hasManifest is false, ignore this paragraph.'
2089
2233
  : ''
2090
2234
 
2091
2235
  // Steps 1-5: Design (tier-gated thunks built explicitly - no inline ternary-null)
@@ -2285,7 +2429,7 @@ if (!fanVerdict.complete) {
2285
2429
  // unavailable/errors — the pipeline never blocks on Codex.
2286
2430
  phase('Plan')
2287
2431
  await usageProbe('Plan')
2288
- const planPrompt = 'Step 6 (SPARC-GOAP implementation plan) of /feature-adr for "' + DESC + '" (' + SLUG + ', tier ' + tier + '). Given the requirements + ADR + architecture in ' + FDIR + ', decompose into milestones + concrete tasks with success metrics. Write ' + FDIR + '/06_implementation_plan.md. END the plan with a trailing `EXPECTED_CODE_TARGETS:` block listing, one per line as `- <repo-relative path>`, EVERY production/test/config/doc file Step 7 is expected to create or modify. This block is machine-read by the Step-7.5 landing barrier: only paths it ESTABLISHES can ever count as landed, so an absent or unpollable block makes the barrier verdict INCONCLUSIVE. List only real targets outside features/, .dz/, .agentic-qe/ and roam/. If any corrections from Step 3.5 (a CONDITIONAL verdict) or other sources are folded into this plan, carry them in a `## Amendments` section. ' + AMENDMENT_RULE + ' Return wrote[] + summary.' + ABSOLUTE_PATH_NOTE + WRITE_DISCIPLINE
2432
+ const planPrompt = 'Step 6 (SPARC-GOAP implementation plan) of /feature-adr for "' + DESC + '" (' + SLUG + ', tier ' + tier + '). Given the requirements + ADR + architecture in ' + FDIR + ', decompose into milestones + concrete tasks with success metrics. Write ' + FDIR + '/06_implementation_plan.md. END the plan with a trailing `EXPECTED_CODE_TARGETS:` block listing, one per line as `- <repo-relative path>`, EVERY production/test/config/doc file Step 7 is expected to create or modify. This block is machine-read by the Step-7.5 landing barrier: only paths it ESTABLISHES can ever count as landed, so an absent or unpollable block makes the barrier verdict INCONCLUSIVE. List only real targets outside features/, .dz/, .agentic-qe/ and roam/. The K2 plan-completeness gate blocks Step 7 until the plan satisfies these too, so write them in as you author, not afterwards: (C1) every ADR under 03_adr/ is cited as `ADR-<n>` by the task that implements it; (C2) every test path named in an ADR Confirmation stanza appears verbatim in the plan, bound to the task that writes it; (C4) every acid token `A<n>` from 00_complexity_assessment.md is named verbatim, bound to its owning task and to the test that proves the refusal. If any corrections from Step 3.5 (a CONDITIONAL verdict) or other sources are folded into this plan, carry them in a `## Amendments` section. ' + AMENDMENT_RULE + ' Return wrote[] + summary.' + ABSOLUTE_PATH_NOTE + WRITE_DISCIPLINE
2289
2433
  // Resolve the plan model. args.models.plan wins; else the planner:'codex' knob (via routingRequested +
2290
2434
  // DEFAULT_MODELS/coder-fold) or the DEFAULT_MODELS.plan ('sonnet') under routing; else {} (BC).
2291
2435
  const planModel = resolveStageModel('plan')
@@ -2613,6 +2757,16 @@ if (codeStage && codeStage.modelUsed) modelsUsed.code = codeStage.modelUsed + (r
2613
2757
  // landedNote comes FROM the checkpointed composite (the barrier ran inside the code stage — QE #3):
2614
2758
  // on resume the ORIGINAL barrier signal is restored verbatim with a resumed marker prepended.
2615
2759
  let landedNote = codeStage ? String(codeStage.landedNote || '') : ''
2760
+ // These two are re-read from codeStage for the SAME reason `code`, `codexCodeText` and `landedNote`
2761
+ // above are: the Step-7 body is an arrow function, so its `let`s die at its closing brace. Both were
2762
+ // referenced BARE at top level — `expectedTargets` by the Step-8 writer-quiescence probe and
2763
+ // `landingStatus` by the promise-tag block — which is a runtime ReferenceError, not a stale value.
2764
+ // The wrapper landed 2026-08-19 (wave1-instrument-repair) and the quiescence probe 2026-08-24
2765
+ // (qe-writer-quiescence) referenced the name across it. Found 2026-08-25 by the scope-aware half of
2766
+ // workflow-free-identifiers.test.ts on its first run; the flat half could not see it, because both
2767
+ // names ARE declared — three hundred lines away, inside a scope that had already closed.
2768
+ const expectedTargets = codeStage && Array.isArray(codeStage.expectedTargets) ? codeStage.expectedTargets : []
2769
+ const landingStatus = codeStage ? codeStage.landingStatus : null
2616
2770
  if (resumedStages.indexOf('code') !== -1 && landedNote !== '') {
2617
2771
  landedNote = '\n\n[RESUMED from checkpoint — the landing barrier below ran in the ORIGINAL run; the change-manifest artifact was re-verified present by the resume probe]' + landedNote
2618
2772
  }