@dzhechkov/skills-feature-adr 1.3.67 → 1.4.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.
package/.dz-manifest.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "files": [
6
6
  {
7
7
  "path": "CHANGELOG.md",
8
- "sha256": "e4f50d4f757f877b54c8d25dfc1321776545be4cb814d5f17ee1128ee2320982"
8
+ "sha256": "bbb43101a686369a8662f47fe7b7aa75073b614a188c2752cd81d9570620bb3d"
9
9
  },
10
10
  {
11
11
  "path": "LICENSE",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  {
15
15
  "path": "README.md",
16
- "sha256": "1d123dcb15ac44b9adfe8266cb72d2dc69d5086f54003540795296c36e06e1b2"
16
+ "sha256": "469873d967c4438afd0b25ec8b013347841e07328f508f6786f90cc6aa2f09b9"
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": "31de034ec7534b585ee99a2dac0105bc4e43dca0c5b419076e66c43e797509d3"
28
+ "sha256": "c1e4d257167fd2462e0c8e09f248fc1aef332aa34fb09dddd5940bdcc30ea34b"
29
29
  },
30
30
  {
31
31
  "path": "scripts/sync-templates.js",
@@ -321,7 +321,7 @@
321
321
  },
322
322
  {
323
323
  "path": "templates/.claude/workflows/feature-adr.js",
324
- "sha256": "d9cda8df501188c894d083c5c44c240c9ebe9ba7e2d418302c448d73b089e051"
324
+ "sha256": "1be239d2f02612bc33027767d9930eae8a2d6d5afd427d1485198bbdd821a3c7"
325
325
  },
326
326
  {
327
327
  "path": "templates/lib/memory-protocol.md",
@@ -349,5 +349,5 @@
349
349
  }
350
350
  ]
351
351
  },
352
- "signature": "ZnkOOhYGfaIEp1W5L/Gphs0uniT0FieL/yn5KPl6cZ3zQeXkZcqDjfOta9b6zmPEgztZAclspZ0yf82VnjeHBQ=="
352
+ "signature": "gixeRY/3+gjSSS3a+DfVwn40SVlD9Gr2sCh3H8afGWd1+Dzo8R8CmD61qG0cLeDdHn2NgzRWwvICHe+0HFlYCA=="
353
353
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.4.0] - 2026-08-20
4
+
5
+ ### Changed — the composite design checkpoint
6
+
7
+ - The Step 1–5 design fan is checkpointed **per sibling** instead of as one composite entry, so one
8
+ dead agent no longer discards three finished siblings. Each sibling's resume key carries its own
9
+ steering inputs, including its prompt text: fixing one step's instructions invalidates that step
10
+ and nothing else.
11
+ - An incomplete fan is **refused** at the Step-5/6 boundary (`phase: 'design-incomplete'`) instead of
12
+ being handed to the planner. Previously the completeness verdict was computed and only logged, and
13
+ Step 6 planned off a null design. The refusal captures training pairs and appends a cost-ledger row
14
+ on the way out, so a stopped run is neither silent nor invisible to cost analysis.
15
+ - Three named reasons with distinct repairs — `substage-missing`, `artifact-missing`,
16
+ `probe-not-established`. Under `resume:'force'` the artifact-missing repair says `resume:'never'`,
17
+ because force skips artifact probes and a plain re-invoke would loop forever.
18
+ - The artifact check no longer lists a directory: it runs `[ -f <exact path> ]` per required
19
+ artifact. A listing is a list of filenames, and a file whose NAME ends in a newline was measured
20
+ satisfying the requirement for the real file.
21
+ - The probe transcript is validated strictly rather than scanned. It is relayed by an agent, not read
22
+ from a pipe, and an agent that narrates the expected output emits the token byte-identically —
23
+ which produced a false pass on a missing artifact before this release. Inconclusive is never a pass.
24
+
25
+ ### Requires
26
+
27
+ - `@dzhechkov/harness-core >= 0.5.4`. The checkpoint schema is now `fa-ckpt-3`: every existing
28
+ `.fa-state/checkpoints.jsonl` reads as no checkpoint, so each in-flight feature re-runs
29
+ router+design+plan once.
30
+
31
+ ### Verification
32
+
33
+ Eight cross-family review rounds (Codex `gpt-5.6-sol` at xhigh, each pinned to a sha256 of the
34
+ reviewed files): D → D → D → B → C → C → D → **A**, the last with no new defects. Full ladder in
35
+ `features/feature-adr-hardening/composite-design-checkpoint.md`.
36
+
37
+
3
38
  ### Follow-up (QE LOW gaps closed before publish)
4
39
  - **Upstream deletions**: `update` now removes files the template dropped (manifest-tracked orphans only; user-created untracked files are never touched) — previously `diff.missing` was computed but ignored. Shown in the summary + `--dry-run` (`- DEL`). Tests: Case G/H.
5
40
  - Removed a dead `unchanged` branch in the directory update path (unreachable — `diff.modified` guarantees bytes differ).
package/README.md CHANGED
@@ -106,6 +106,36 @@ ARCHITECTURE → IMPLEMENTATION → CODE → QE → FLEET QE
106
106
  # Full protocols + 6 extra skills, up to 7 fleet QE agents
107
107
  ```
108
108
 
109
+ ### The design fan resumes per sibling, and an incomplete design is REFUSED (v1.4.0)
110
+
111
+ Step 1–5 run as one parallel fan — requirements, ADR, QCSD, architecture. Until v1.4.0 the whole fan
112
+ was ONE checkpoint entry, so a single dead agent discarded three finished siblings and the next
113
+ invocation paid for all four again. Each sibling is now checkpointed on its own
114
+ (`design:requirements` / `design:adr` / `design:qcsd` / `design:architecture`), keyed on what steers
115
+ **it** — its own prompt text included, so correcting one step's instructions re-runs that step and
116
+ leaves the others alone.
117
+
118
+ The other half is what the pipeline is allowed to CONSUME. An incomplete fan no longer flows into
119
+ Step 6: the run stops at the Step-5/6 boundary and returns `phase: 'design-incomplete'` rather than
120
+ producing a plan with no ADR behind it. Three distinct reasons, each with its own repair:
121
+
122
+ | Reason | What happened | Repair printed |
123
+ |---|---|---|
124
+ | `substage-missing` | a sibling agent died (often a Claude limit) | re-invoke — the finished siblings resume free; or route that stage to Codex |
125
+ | `artifact-missing` | every sibling reported success, but a required file is not on disk | under `resume:'force'` it says use `resume:'never'` — force skips artifact probes, so re-invoking would loop |
126
+ | `probe-not-established` | the check could not be trusted at all | inconclusive is never a pass — refuse, then re-run |
127
+
128
+ The artifact check never lists a directory. It asks `[ -f <exact path> ]` per required artifact,
129
+ because a listing is a list of filenames and the data can impersonate the frame: measured, a file
130
+ whose NAME ends in a newline satisfied the requirement for the real file. And since the check is
131
+ relayed by an agent rather than read from a pipe, the whole transcript is validated — an agent that
132
+ merely *narrates* the expected output emits the token byte-identically, and that produced a false
133
+ pass before this release.
134
+
135
+ Requires `@dzhechkov/harness-core >= 0.5.4`. **One-time cost:** the checkpoint schema moved to
136
+ `fa-ckpt-3`, so every existing `.fa-state/checkpoints.jsonl` reads as no checkpoint and each
137
+ in-flight feature re-runs router+design+plan once.
138
+
109
139
  ### Durable checkpoints + resume (v1.3.64)
110
140
 
111
141
  Every expensive stage (router / design / plan / code / qe / fleet) checkpoints its result into
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/skills-feature-adr",
3
- "version": "1.3.67",
3
+ "version": "1.4.0",
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
@@ -15,7 +15,7 @@
15
15
  "hashes": [
16
16
  {
17
17
  "alg": "SHA-256",
18
- "content": "e4f50d4f757f877b54c8d25dfc1321776545be4cb814d5f17ee1128ee2320982"
18
+ "content": "bbb43101a686369a8662f47fe7b7aa75073b614a188c2752cd81d9570620bb3d"
19
19
  }
20
20
  ]
21
21
  },
@@ -35,7 +35,7 @@
35
35
  "hashes": [
36
36
  {
37
37
  "alg": "SHA-256",
38
- "content": "1d123dcb15ac44b9adfe8266cb72d2dc69d5086f54003540795296c36e06e1b2"
38
+ "content": "469873d967c4438afd0b25ec8b013347841e07328f508f6786f90cc6aa2f09b9"
39
39
  }
40
40
  ]
41
41
  },
@@ -65,7 +65,7 @@
65
65
  "hashes": [
66
66
  {
67
67
  "alg": "SHA-256",
68
- "content": "31de034ec7534b585ee99a2dac0105bc4e43dca0c5b419076e66c43e797509d3"
68
+ "content": "c1e4d257167fd2462e0c8e09f248fc1aef332aa34fb09dddd5940bdcc30ea34b"
69
69
  }
70
70
  ]
71
71
  },
@@ -805,7 +805,7 @@
805
805
  "hashes": [
806
806
  {
807
807
  "alg": "SHA-256",
808
- "content": "d9cda8df501188c894d083c5c44c240c9ebe9ba7e2d418302c448d73b089e051"
808
+ "content": "1be239d2f02612bc33027767d9930eae8a2d6d5afd427d1485198bbdd821a3c7"
809
809
  }
810
810
  ]
811
811
  },
@@ -142,7 +142,7 @@ const CKPT_FILE = FDIR + '/.fa-state/checkpoints.jsonl'
142
142
  // M10 Stage-A, feature loop-designer). This region is now a GENERATED BLOB (regen-diff-gated by
143
143
  // loop-blobs-regen.test.ts): edit the canonical TS FIRST, run node scripts/gen-loop-blobs.mjs,
144
144
  // then re-splice. The value-pinned wiring tests in feature-adr-checkpoints.test.ts stay the net.
145
- // ── BEGIN BLOB checkpoints@1.1.0 sha256:aa730483f52a9f6263751138d4514fe9a6a3f4f191897c86d1e035f3da890574 src=packages/@dzhechkov/harness-core/src/feature-adr-checkpoints.ts ──
145
+ // ── BEGIN BLOB checkpoints@1.1.0 sha256:d4dc6c956be3dd279b225bc5061ec4255babc44ffb5ca58646c9b6ea04a0c8c2 src=packages/@dzhechkov/harness-core/src/feature-adr-checkpoints.ts ──
146
146
  const CHECKPOINT_STAGES = ['router', 'design', 'plan', 'code', 'qe', 'fleet'];
147
147
  const STAGE_ARTIFACTS = {
148
148
  router: null,
@@ -153,7 +153,7 @@ const STAGE_ARTIFACTS = {
153
153
  fleet: '09_fleet_qe_assessment.md',
154
154
  };
155
155
  const CHECKPOINT_MAX_RESULT_CHARS = 12000;
156
- const CKPT_SCHEMA_VERSION = 'fa-ckpt-2';
156
+ const CKPT_SCHEMA_VERSION = 'fa-ckpt-3';
157
157
  function fnv1a(str) {
158
158
  let h = 0x811c9dc5;
159
159
  for (let i = 0; i < str.length; i++) {
@@ -171,6 +171,33 @@ function checkpointInputHash(stage, parts) {
171
171
  function resumeMode(raw) {
172
172
  return raw === 'never' ? 'never' : raw === 'force' ? 'force' : 'auto';
173
173
  }
174
+ const DESIGN_SUBSTAGES = ['requirements', 'adr', 'qcsd', 'architecture'];
175
+ function designStageKey(sub) {
176
+ return 'design:' + sub;
177
+ }
178
+ function decideDesignFanResume(opts) {
179
+ const missingSubstages = [];
180
+ opts.required.forEach((sub, i) => {
181
+ const r = opts.results[i];
182
+ if (r === null || r === undefined)
183
+ missingSubstages.push(sub);
184
+ });
185
+ const missingArtifacts = [];
186
+ let probeMissing = false;
187
+ if (opts.artifacts.length > 0) {
188
+ if (opts.postRunListing === null)
189
+ probeMissing = true;
190
+ else
191
+ for (const rel of opts.artifacts)
192
+ if (!opts.postRunListing.has(rel))
193
+ missingArtifacts.push(rel);
194
+ }
195
+ const reason = missingSubstages.length > 0 ? 'substage-missing'
196
+ : probeMissing ? 'probe-not-established'
197
+ : missingArtifacts.length > 0 ? 'artifact-missing'
198
+ : 'ok';
199
+ return { complete: reason === 'ok', missingSubstages, missingArtifacts, reason };
200
+ }
174
201
  function decideCheckpointResume(opts) {
175
202
  if (opts.mode === 'never')
176
203
  return { resume: false, reason: 'mode-never' };
@@ -250,10 +277,41 @@ function checkpointAppendCmd(fdirAbs, line) {
250
277
  const file = shellQuote(fdirAbs + '/.fa-state/checkpoints.jsonl');
251
278
  return 'mkdir -p ' + dir + " && printf '%s\\n' " + shellQuote(line) + ' >> ' + file;
252
279
  }
280
+ function parseArtifactProbe(opts) {
281
+ if (opts.stdout === null || opts.stdout === undefined)
282
+ return null;
283
+ const known = new Map();
284
+ for (const rel of opts.required)
285
+ known.set('HAVE:' + rel, rel);
286
+ const found = new Set();
287
+ let sentinels = 0;
288
+ for (const raw of String(opts.stdout).split('\n')) {
289
+ const line = raw.trim();
290
+ if (line === '')
291
+ continue;
292
+ if (line === opts.sentinel) {
293
+ sentinels++;
294
+ continue;
295
+ }
296
+ if (sentinels > 0)
297
+ return null;
298
+ const rel = known.get(line);
299
+ if (rel === undefined)
300
+ return null;
301
+ found.add(rel);
302
+ }
303
+ if (sentinels !== 1)
304
+ return null;
305
+ return found;
306
+ }
253
307
  // ── END BLOB checkpoints@1.1.0 ──
254
308
  // glue (workflow-owned, hand-reviewed): legacy mirror names → the blob exports. The hash
255
309
  // composition is UNCHANGED (same CKPT_SCHEMA_VERSION salt + JSON-tuple), so recorded
256
310
  // checkpoints from pre-wrap runs still resume.
311
+ // Run-specific, not a fixed literal: a fixed one is a string an ARTIFACT FILE could be named, and a
312
+ // listing is exactly a list of filenames. Derived from the slug + dir so it stays stable across a
313
+ // resume (the workflow sandbox has no randomness, and a random one would break nothing here anyway).
314
+ const FAN_LS_SENTINEL = '<<<DZ-FAN-LS-END:' + fnv1a64(SLUG + '\0' + FDIR) + '>>>'
257
315
  const CKPT_LS_SENTINEL = CHECKPOINT_LS_SENTINEL
258
316
  const CKPT_MAX = CHECKPOINT_MAX_RESULT_CHARS
259
317
  const STAGE_ARTIFACTS_CKPT = STAGE_ARTIFACTS
@@ -1465,19 +1523,19 @@ modelsUsed.ddd = modelLabel(archOpts)
1465
1523
  // which aligns with design[i] in the parallel result — one pair per design sub-stage.
1466
1524
  const designPairMeta = []
1467
1525
  const reqPromptText = 'Step 1 (Requirements)' + (isLplus ? ' + Step 2 (Research)' : '') + ' of /feature-adr for "' + DESC + '" (tier ' + tier + ', slug ' + SLUG + '). Code: ' + CODE_HINT + '. APPLY these Step-0 recalled LEARNED PATTERNS (fold the applicable ones into requirements/constraints - the loop paying off): ' + LEARNED + '. Write ' + FDIR + '/01_requirements.md (functional + non-functional requirements, acceptance criteria, constraints, and an "Applied learned patterns" note).' + reqExtra + ' Return wrote[] + a 1-line summary.' + PS_GUIDANCE('design') + WRITE_DISCIPLINE
1468
- designThunks.push(() => designStage(reqPromptText, reqOpts, FDIR + '/01_requirements.md', 'requirements'))
1526
+ designThunks.push(() => subCheckpoint('requirements', reqPromptText, reqOpts, ['01_requirements.md'].concat(isLplus ? ['02_research.md'] : []), () => designStage(reqPromptText, reqOpts, FDIR + '/01_requirements.md', 'requirements')))
1469
1527
  // the requirements prompt is where the Step-0 recalled lessons are INJECTED — recorded per pair
1470
1528
  designPairMeta.push({ role: 'design:requirements', prompt: reqPromptText, model: modelLabel(reqOpts), lessons: (LEARNED && LEARNED !== 'none recalled') ? [String(LEARNED)] : [] })
1471
1529
  if (isMplus) {
1472
1530
  const adrPromptText = 'Step 3 (ADR + shift-left testability) of /feature-adr for "' + DESC + '" (' + SLUG + '). READ the actual code (' + CODE_HINT + ') to ground it. ' + ADR_TEMPLATE_GUIDE + ' Write ' + FDIR + '/03_adr/001-' + SLUG + '.md as a MADR-structured ADR that PASSES the Step-8 ADR fitness checklist (do NOT emit the legacy shape). Emit ALL of these sections, in order: a decision-shaped # Title (present-tense imperative verb — the auto-filename tracks the feature slug, so the IMPERATIVE signal lives in the title); ## Status (proposed/accepted/rejected/deprecated/superseded + a reversibility/revisit clause); ## Context (neutral, problem-first, BEFORE the Decision); ## Decision Drivers (ranked/weighted D1, D2, …); ## Considered Options (frame the CHOSEN approach as one option ALONGSIDE the rejected ones, each with symmetric Pros:/Cons:); ## Decision (concrete/testable — exact names, versions, paths, commands); ## Rationale (map each point to a driver Dn + why the losers lost); ## Consequences (Positive + Negative/Accepted Downsides + Follow-up ADRs + After-action Review with owner + date); a REQUIRED ## Confirmation stanza with Method:, Monitoring:, Success metric:, Owner:, Load-bearing property:, and Required automated check: `<test file>` NAMING the load-bearing property that MUST have a Step-8 test (the recurring lesson: the key safety property is often the untested one); and a ## Links traceability block (requirements, driving use case, related ADRs). Add a one-line provenance note (model-generated, edited for clarity) and, for a long ADR, a top-of-file table of contents. Do NOT use an "Alternatives considered" or "Testability/shift-left" heading in place of Considered Options / Confirmation. When creating ADDITIONAL ADRs, name them 03_adr/NNN-{decision-slug}.md with a lowercase-kebab, present-tense imperative, dateless, ticketless slug. Return wrote[] + summary.' + WRITE_DISCIPLINE
1473
- designThunks.push(() => designStage(adrPromptText, adrOpts, FDIR + '/03_adr/001-' + SLUG + '.md', 'adr'))
1531
+ designThunks.push(() => subCheckpoint('adr', adrPromptText, adrOpts, ['03_adr/001-' + SLUG + '.md'], () => designStage(adrPromptText, adrOpts, FDIR + '/03_adr/001-' + SLUG + '.md', 'adr')))
1474
1532
  designPairMeta.push({ role: 'design:adr', prompt: adrPromptText, model: modelLabel(adrOpts), lessons: [] })
1475
1533
  const qcsdPromptText = 'Step 3.5 (QCSD ideation swarm - HTSM quality criteria + SFDIPOT risk) of /feature-adr for "' + DESC + '" (' + SLUG + '). Assess quality criteria + product-factors risk. Write ' + FDIR + '/03.5_ideation_report.md with a GO/CONDITIONAL/NO-GO verdict + top quality risks for QE. On a CONDITIONAL verdict, write each condition as an amendment row in a `## Amendments` section. ' + AMENDMENT_RULE + ' Return wrote[] + summary.' + WRITE_DISCIPLINE
1476
- designThunks.push(() => designStage(qcsdPromptText, qcsdOpts, FDIR + '/03.5_ideation_report.md', 'qcsd'))
1534
+ designThunks.push(() => subCheckpoint('qcsd', qcsdPromptText, qcsdOpts, ['03.5_ideation_report.md'], () => designStage(qcsdPromptText, qcsdOpts, FDIR + '/03.5_ideation_report.md', 'qcsd')))
1477
1535
  designPairMeta.push({ role: 'design:qcsd', prompt: qcsdPromptText, model: modelLabel(qcsdOpts), lessons: [] })
1478
1536
  const archExtra = isLplus ? ' Also ' + FDIR + '/04_domain_model.md (DDD).' : ''
1479
1537
  const archPromptText = (isLplus ? 'Step 4 (DDD) + ' : '') + 'Step 5 (Architecture) of /feature-adr for "' + DESC + '" (' + SLUG + '). READ the code. Write ' + FDIR + '/05_architecture.md (components, data flow, integration points, the emit/merge/wiring shape).' + archExtra + ' Return wrote[] + summary.' + WRITE_DISCIPLINE
1480
- designThunks.push(() => designStage(archPromptText, archOpts, FDIR + '/05_architecture.md', 'architecture'))
1538
+ designThunks.push(() => subCheckpoint('architecture', archPromptText, archOpts, ['05_architecture.md'].concat(isLplus ? ['04_domain_model.md'] : []), () => designStage(archPromptText, archOpts, FDIR + '/05_architecture.md', 'architecture')))
1481
1539
  designPairMeta.push({ role: 'design:architecture', prompt: archPromptText, model: modelLabel(archOpts), lessons: [] })
1482
1540
  }
1483
1541
  // design checkpoint: ONE composite entry for the whole parallel design fan (requirements/ADR/QCSD/
@@ -1490,10 +1548,145 @@ const designHash = ckptHash('design', [tier, DESC, CODE_HINT, fnv1a64(String(LEA
1490
1548
  const designArtifacts = ['01_requirements.md']
1491
1549
  if (isMplus) designArtifacts.push('03_adr/001-' + SLUG + '.md', '03.5_ideation_report.md', '05_architecture.md')
1492
1550
  if (isLplus) designArtifacts.push('02_research.md', '04_domain_model.md')
1493
- const design = await withCheckpoint('design', 'Design', designHash, async () => parallel(designThunks), { artifacts: designArtifacts })
1551
+ /**
1552
+ * PER-SIBLING checkpoint (SP-2, no-amplification). Before 2026-08-20 the whole fan shared ONE
1553
+ * all-or-nothing entry, so a single dead agent discarded three finished siblings — and because every
1554
+ * downstream hash is content-addressed on the design RESULT, re-running them produced different
1555
+ * summaries and a completed plan then read `stale-input` too. One death re-spent the entire run,
1556
+ * every time, which is why the field report saw 18 attempts rather than 6.
1557
+ *
1558
+ * `withCheckpoint` needs no change for this: it already does its own lookup, hash, artifact probe
1559
+ * and append per call. Nesting is the whole fix.
1560
+ */
1561
+ function subCheckpoint(sub, prompt, opts, artifacts, thunk) {
1562
+ const key = 'design:' + sub
1563
+ // SP-3 (independence). The hash carries THIS sibling's own steering inputs — including its PROMPT
1564
+ // TEXT (owner decision 2026-08-20). Two consequences, both intended:
1565
+ // • a fix to one step's instructions invalidates exactly that step and nothing else, so a feature
1566
+ // still IN FLIGHT picks the corrected prompt up on its next invocation. A feature that already
1567
+ // finished is never re-invoked, so its written artifacts are untouched — which is the line the
1568
+ // owner drew: correct what has not run, leave alone what has.
1569
+ // • changing the ADR model no longer invalidates requirements. The old fan hash lumped every
1570
+ // model spec together, so one dial moved four stages.
1571
+ // The hash carries what actually steers THIS sibling, and nothing else. Review round 1 measured
1572
+ // both directions of wrongness in the first version:
1573
+ // OVER-invalidating — FIXED, by DELETION. CODEX_MODEL was folded in unconditionally, so changing
1574
+ // args.codexModel invalidated a sibling explicitly pinned to `sonnet`. Round 1 narrowed it to
1575
+ // Codex siblings; round 2 showed that was still wrong — a sibling pinned to
1576
+ // 'codex:gpt-5.6-sol:high' resolves its own id, yet still moved when the global dial moved. It
1577
+ // is gone entirely, because `modelLabel(opts)` — already in this hash — renders
1578
+ // 'codex:<resolvedId>:<reasoning>', and specToOpts resolves a bare 'codex' spec THROUGH
1579
+ // CODEX_MODEL. So the effective model is captured in every case, pinned or inherited, and a
1580
+ // second copy of it could only ever add false invalidation.
1581
+ // UNDER-invalidating — NAMED, NOT FIXED, because it cannot be fixed here. Editing the CONTENT of
1582
+ // a project-skills guidance file without changing which files exist leaves this hash unchanged,
1583
+ // so a stale sibling resumes. The reason is structural: the Step-0 probe deliberately returns
1584
+ // only {hasManifest, report} and DROPS the guidance text ("drop the big design/code/qe
1585
+ // strings"), so the workflow never holds the content to hash. My first fix claimed the prompt
1586
+ // covered it via PS_GUIDANCE — MEASURED FALSE: of the four sibling prompts only `requirements`
1587
+ // embeds PS_GUIDANCE; adr, qcsd and architecture do not. POLY.report therefore stays in the
1588
+ // hash as the best available proxy — it names the source files, so adding or removing one does
1589
+ // invalidate. Closing this properly means carrying a content digest out of the probe, which is
1590
+ // a change to the probe, not to this line.
1591
+ const h = ckptHash(key, [tier, DESC, CODE_HINT, fnv1a64(String(prompt)), modelLabel(opts), POLY.hasManifest, fnv1a64(String(POLY.report || '')), usageOverride, sub === 'requirements' ? fnv1a64(String(LEARNED)) : null])
1592
+ return withCheckpoint(key, 'Design', h, thunk, { artifacts: artifacts })
1593
+ }
1594
+
1595
+ const designSubsRequired = ['requirements'].concat(isMplus ? ['adr', 'qcsd', 'architecture'] : [])
1596
+ const designFan = await parallel(designThunks)
1597
+ // SP-1 (completeness) — what may be CONSUMED is a different question from what may be WRITTEN. The
1598
+ // old code answered the second by crippling the first. Codex QE #2 (a one-file probe accepting a
1599
+ // design with no ADR) is preserved here, on the read side where it belongs.
1600
+ // LIVE results, never the start-of-run snapshot. Cross-family review round 1 (grade D) caught the
1601
+ // snapshot version: a stale non-null entry from a PREVIOUS run survives in it even when this run's
1602
+ // retry returned null, so an incomplete design would be declared complete — reopening the very hole
1603
+ // the old all-or-nothing gate existed to close. A sibling that returns non-null wrote its artifact;
1604
+ // a sibling that died returns null. Nothing needs re-probing to know that.
1605
+ // The artifact half needs a listing taken AFTER the fan, and nothing else will do. Round 2 caught the
1606
+ // version fed CKPT_LISTING (taken once at run start, so on a fresh slug it cannot contain what the fan
1607
+ // is about to write — every fresh M+ run read as incomplete). Round 3 caught the version with no probe
1608
+ // at all: an L-tier requirements sibling that writes 01_requirements.md, skips 02_research.md and
1609
+ // returns non-null was accepted, and Step 6 planned with no research behind it. A non-null result is
1610
+ // the agent's own word about its own work. One effort-low `find` is what turns it into evidence.
1611
+ // The probe must PROVE it ran, and it must not be forgeable by the very thing it inspects.
1612
+ // Three rounds of review taught the shape:
1613
+ // • round 4 — `|| true` around a suppressed listing made an unreadable FDIR indistinguishable from
1614
+ // an empty one, so the refusal named the wrong cause and printed the wrong repair;
1615
+ // • round 5 — `find | sed; echo SENT` emitted the sentinel even when find itself failed, because sed
1616
+ // masks the pipeline status;
1617
+ // • round 6 — and the deeper problem: a LISTING is a list of filenames, so the data can impersonate
1618
+ // the frame. A file named "01_requirements.md\n" prints as a line reading `01_requirements.md`
1619
+ // plus a blank one, and satisfied a requirement for the real file. No amount of sentinel hardening
1620
+ // fixes that, because the forgery is inside the payload.
1621
+ // So the probe no longer prints filenames AT ALL. It asks `[ -f <exact rel> ]` once per required
1622
+ // artifact and echoes a fixed token we already know the text of. A filename can no longer produce a
1623
+ // line, so it can no longer forge one — and `-f` on an exact path cannot be satisfied by a name that
1624
+ // merely renders like it. MEASURED red/green below in a real shell against a newline-bearing filename.
1625
+ let fanChecks = ''
1626
+ for (const rel of designArtifacts) fanChecks += '[ -f ' + shq(rel) + ' ] && echo ' + shq('HAVE:' + rel) + '; '
1627
+ const fanLsCmd = 'cd ' + shq(FDIR) + ' 2>/dev/null && { ' + fanChecks + 'echo ' + shq(FAN_LS_SENTINEL) + '; } || true'
1628
+ const fanLsOut = await agent('Run EXACTLY this via Bash and return its stdout VERBATIM (it may be empty) with NO code fences and NO commentary: ' + fanLsCmd, { label: 'design:artifact-probe', phase: 'Design', effort: 'low' })
1629
+ // The transcript is validated STRICTLY, not scanned. Round 7 measured the difference: an agent that
1630
+ // narrates ("Expected output when present: HAVE:01_requirements.md … Actual stdout: …") emits a line
1631
+ // byte-identical to the real token, and a parser that merely LOOKED for the token passed a design whose
1632
+ // artifact did not exist. parseArtifactProbe accepts only a subset of the known tokens followed by
1633
+ // exactly one sentinel and nothing else; anything unexpected returns null — inconclusive, never a pass.
1634
+ // It cannot stop an agent that deliberately emits the exact expected transcript; that residual is the
1635
+ // same trust the checkpoint reader and the Step-7.5 landing barrier already place in a relaying agent.
1636
+ const fanListing = parseArtifactProbe({ stdout: fanLsOut, sentinel: FAN_LS_SENTINEL, required: designArtifacts })
1637
+ if (fanListing === null) log('design artifact probe NOT ESTABLISHED — the transcript was not the command\'s own output (no completion sentinel, a second one, or an unexpected line). This is inconclusive, not clean.')
1638
+ const fanVerdict = decideDesignFanResume({ results: designFan, required: designSubsRequired, artifacts: designArtifacts, postRunListing: fanListing })
1639
+ const design = designFan
1494
1640
  // training pairs: one per design sub-stage (designPairMeta[i] ↔ design[i]); a null sub-result emits
1495
1641
  // no pair (a dead agent produced no output). Design has no per-stage QE grade — grade:null honestly.
1496
- if (design) await capturePairs('design', 'Design', designPairMeta.map(function (m, i) { return { input: m.prompt, output: design[i], evaluation: { grade: null, gradedBy: null, lessonsInjected: m.lessons }, provenance: { model: m.model, family: tpFamily(m.model), role: m.role } } }))
1642
+ // Pair capture has to answer TWO questions per-sibling that the whole-stage guard inside capturePairs
1643
+ // cannot: which siblings produced output THIS run, and whether the stage as a whole is a resume.
1644
+ // • The guard tests resumedStages for the exact name 'design'; per-sibling checkpointing records
1645
+ // 'design:requirements' instead, so every repair run read as wholly-live and re-appended the
1646
+ // resumed siblings' pairs (round 3).
1647
+ // • Filtering the resumed siblings out unconditionally then killed the BACKFILL path — the recovery
1648
+ // that exists for a run whose pairs were never written (round 4). Backfill is file-absence-guarded,
1649
+ // so it only ever recovers a wholly-missing file, and that is exactly the all-resumed case.
1650
+ // So: all four resumed ⇒ the stage IS a resume, hand capturePairs every record under a guard stage it
1651
+ // will read as resumed, and let the absence guard decide. Otherwise ⇒ emit only what ran this run.
1652
+ const designPairRecords = designPairMeta.map(function (m, i) { return { input: m.prompt, output: design ? design[i] : null, evaluation: { grade: null, gradedBy: null, lessonsInjected: m.lessons }, provenance: { model: m.model, family: tpFamily(m.model), role: m.role } } })
1653
+ const designAllResumed = designSubsRequired.length > 0 && designSubsRequired.every(function (sub) { return resumedStages.indexOf('design:' + sub) !== -1 })
1654
+ // NAMED, not fixed: a MIXED repair run whose earlier pair file was lost entirely does not recover the
1655
+ // resumed siblings' pairs — backfill would skip anyway once the file exists, and re-appending them in
1656
+ // capture mode is the duplication round 3 caught. Recovering that case needs per-record dedup inside
1657
+ // capturePairs, which is a change to the capture layer, not to this call.
1658
+ if (design && designAllResumed) await capturePairs('design', 'Design', designPairRecords, 'design:' + designSubsRequired[0])
1659
+ else if (design) await capturePairs('design', 'Design', designPairRecords.map(function (r, i) { return (resumedStages.indexOf('design:' + designSubsRequired[i]) !== -1) ? null : r }))
1660
+
1661
+ // SP-1 ENFORCED. Round 2 was right that the previous line only LOGGED: the comment above promised a
1662
+ // read-side gate and the body handed a fan containing nulls straight to Step 6, which then planned
1663
+ // off a missing ADR — the exact outcome Codex QE #2 named. Refusing here is cheap precisely because
1664
+ // this feature made the fan resumable: every sibling that DID finish is checkpointed, so the repair
1665
+ // re-invoke re-spends only the missing ones. (Pairs are captured first: what ran deserves its record.)
1666
+ if (!fanVerdict.complete) {
1667
+ const missing = fanVerdict.missingSubstages.join(', ')
1668
+ const missingArt = fanVerdict.missingArtifacts.join(', ')
1669
+ const what = fanVerdict.reason === 'substage-missing' ? 'sub-stage(s) [' + missing + '] returned nothing (a dead or limit-exhausted agent)'
1670
+ : fanVerdict.reason === 'probe-not-established' ? 'the post-run artifact probe could not be read, so completeness is NOT ESTABLISHED (never a pass)'
1671
+ : 'the design artifact(s) [' + missingArt + '] are absent although every sub-stage reported success — an agent said it wrote a file it did not write'
1672
+ // Honest about the repair: "resume for free" is only true when resume is actually available. With
1673
+ // checkpoints off or resume:'never' the whole fan re-runs, and saying otherwise would misprice the retry.
1674
+ // resume:'force' deliberately trusts the input hash and SKIPS the artifact probe, so a sibling that
1675
+ // reported success without writing its file resumes forever and this gate refuses forever. Telling
1676
+ // that operator to "just re-invoke" would be a loop, not a repair (round 5).
1677
+ const repair = (CHECKPOINTS_ON && RESUME_MODE === 'force' && fanVerdict.reason === 'artifact-missing')
1678
+ ? "HOW TO REPAIR: this run used resume:'force', which skips the artifact probe when resuming — so the sibling that reported success without writing [" + missingArt + "] would resume again and hit this same refusal. Re-invoke with args.resume='never' (or delete features/" + SLUG + "/.fa-state/) to make it actually re-run."
1679
+ : (CHECKPOINTS_ON && RESUME_MODE !== 'never')
1680
+ ? 'HOW TO REPAIR: re-invoke with the SAME slug — the sub-stages that finished are individually checkpointed and resume for free, so only the failing one re-runs.'
1681
+ : 'HOW TO REPAIR: re-invoke with the SAME slug. NOTE: this run had ' + (CHECKPOINTS_ON ? "resume:'never', so its finished siblings ARE recorded — drop that argument and the retry re-runs only the failing one" : 'checkpoints disabled, so this run recorded NOTHING — dropping args.checkpoints:false does not make THIS retry cheap (the whole fan re-runs and is re-spent once more); it only makes the run AFTER it cheap') + '.'
1682
+ log('design fan INCOMPLETE (' + fanVerdict.reason + ') — ' + what + '. REFUSING to plan off a partial design.')
1683
+ // A run that stops here still SPENT its router + design phases, so it belongs in the cost ledger —
1684
+ // the same reason the K2 plan-gate refusal logs one. An unlogged refusal makes design stops
1685
+ // invisible to cost analysis, which is how a stage that keeps dying stays cheap-looking.
1686
+ // (coderUsed/qe are the outer bindings, both still null here, so the row reports null honestly.)
1687
+ await appendRunCostRow('design-gate', 'Design')
1688
+ return { tier: tier, phase: 'design-incomplete', slug: SLUG, artifactsDir: FDIR, missingSubstages: fanVerdict.missingSubstages, missingArtifacts: fanVerdict.missingArtifacts, reason: fanVerdict.reason, modelsUsed: modelsUsed, gates: { design: fanVerdict.reason === 'probe-not-established' ? 'not-established' : 'incomplete', plan: 'not-run', planCompleteness: 'not-run', challengePanel: 'not-run', code: 'not-run', qe: 'not-run' }, resumedStages: resumedStages, checkpointing: CHECKPOINTS_ON ? RESUME_MODE : 'off', trainingPairs: CAPTURE_PAIRS ? TP_DIR : 'off', captureFailures: captureFailures, usageEvents: usageEvents, usageThreshold: USAGE_THRESHOLD, polymorphism: POLY.hasManifest ? POLY.report : null, note: 'REFUSED at the Step-5/6 boundary: ' + what + ', so the design is incomplete and Step 6 was NOT dispatched. Planning off a partial design produces a plan with no ADR behind it. ' + repair + ' If a sibling died on a Claude limit, add usage-adaptive routing or route that stage to Codex first (args.models). To rebuild the whole design from scratch instead, re-invoke with args.resume=\'never\'.' }
1689
+ }
1497
1690
 
1498
1691
  // Step 6: Plan — optionally routed to Codex's top model (opt-in via args.planner='codex').
1499
1692
  // The user opts in at pre-flight ('use the top Codex model for planning?'); we route the Plan step to