@dzhechkov/skills-feature-adr 1.5.15 → 1.5.16

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
@@ -13,7 +13,7 @@
13
13
  },
14
14
  {
15
15
  "path": "README.md",
16
- "sha256": "66f3b2cf7fdcf9efbf64c7d5cc36b34ffc82b151a9bc07e6fda5fc47d4fba72e"
16
+ "sha256": "a76e2b71dd2598d4d6789ee00ce99791d4e4927466f4800e5637c4bbbade859e"
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": "e74b1a22e0260a61a3ab37512366402c4e6231b85625028a86219ee3f5c21e12"
28
+ "sha256": "d9af637b1b430a77811424de145ff5a72eceff10b7a583413affa4899c877b8e"
29
29
  },
30
30
  {
31
31
  "path": "src/cli.js",
@@ -137,7 +137,7 @@
137
137
  },
138
138
  {
139
139
  "path": "templates/.claude/skills/feature-adr/modules/03.5-ideation-swarm.md",
140
- "sha256": "d48a8d6eebf9760cc8f1a4f19df0e6afc31fcc1284b5eacfb604ad8ced6a0b53"
140
+ "sha256": "b6e505083c1b8bc92243379b72ff4667b5b91ef65fd9c2ef7407129018b3e56b"
141
141
  },
142
142
  {
143
143
  "path": "templates/.claude/skills/feature-adr/modules/04-ddd.md",
@@ -157,7 +157,7 @@
157
157
  },
158
158
  {
159
159
  "path": "templates/.claude/skills/feature-adr/modules/08-qe.md",
160
- "sha256": "d7a967926feec9b0ec173ac7042ae3abd368644d8e1dba9a81a770794417fcb3"
160
+ "sha256": "4aa7ef1dea08b8d1995d968a317da5d36a4859ec299637bdc60170cf577c87af"
161
161
  },
162
162
  {
163
163
  "path": "templates/.claude/skills/feature-adr/modules/09-fleet-qe.md",
@@ -255,6 +255,10 @@
255
255
  "path": "templates/.claude/skills/feature-adr/scripts/check-plan-completeness.mjs",
256
256
  "sha256": "8b93949ce4f671d932c5389050db3a3e2750efcbed69681d69f392ccf4d2a168"
257
257
  },
258
+ {
259
+ "path": "templates/.claude/skills/feature-adr/scripts/check-review-convergence.mjs",
260
+ "sha256": "80f9691b8e94bf310461aeaaaa23af6e336d41d0962fdf137737639655cbdeb8"
261
+ },
258
262
  {
259
263
  "path": "templates/.claude/skills/feature-adr/scripts/markdown-masker.mjs",
260
264
  "sha256": "82c3c48c25f400f3a64d1caba3d47357f3db87460f0746f88706f901eb6ff689"
@@ -321,7 +325,7 @@
321
325
  },
322
326
  {
323
327
  "path": "templates/.claude/workflows/feature-adr.js",
324
- "sha256": "7928cc90575eaef4f4626849f4490631676bc4ce6eb80e8f94c99d9e215bbd77"
328
+ "sha256": "6f9e6205c8f78bef8086603bf0efe8099011a2cc273d786b8404bd77af565354"
325
329
  },
326
330
  {
327
331
  "path": "templates/lib/memory-protocol.md",
@@ -333,5 +337,5 @@
333
337
  }
334
338
  ]
335
339
  },
336
- "signature": "wSJpaUNRsFXAD5Ye7V5y8FPgcvrLDEq6peQpK/RadbFsD518KglZDn5GNzV4DJE6i+mkHbH9tIwr+HIYXKN4BA=="
340
+ "signature": "nV5vsSwCCTR5hUAMHmxpGPe3DoZoRQJsnAdqqm4gO0OPm1GHMWBx5AZhJEC1jzUugamIpkJEPtWCsD6d+e0oAg=="
337
341
  }
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @dzhechkov/skills-feature-adr
2
2
 
3
- Current package version: `1.5.15`. <!-- dz:version -->
3
+ Current package version: `1.5.16`. <!-- dz:version -->
4
4
 
5
5
  Site: https://aicoding.space · Source: https://github.com/djd1m/dz-harness/tree/main/packages/@dzhechkov/skills-feature-adr
6
6
 
@@ -1401,3 +1401,20 @@ watch v1 ETA is unavailable and global source inventory omitted. No stdin/raw mo
1401
1401
  store writes or automatic terminal/settings changes. Normal SQLite ephemeral WAL/SHM sidecars are
1402
1402
  permitted. One-shot Claude text/JSON/ETA remain unchanged. Codex native footer capability is not
1403
1403
  asserted; parity names manual `dz statusline --watch` access.
1404
+
1405
+
1406
+ Review convergence in `/feature-adr` now uses one installed Node gate in Plain and native Workflow.
1407
+ Step 3.5 closes before planning; Step 8 closes before completion and delivery, including S tier and
1408
+ resumed runs. The host measures current artifact bytes and preserves originating reviewer conditions;
1409
+ focused rework includes an explicit author delta/new-risk assessment and independent own-condition
1410
+ verification. Serious primary or precision findings remain visible at the round ceiling.
1411
+
1412
+ Missing or stale receipts, legacy resume, unavailable reviewers and read-only Codex mode A pause
1413
+ with host-driven repair instructions. Parallel native design can require focused review after sibling
1414
+ artifacts settle. Clean initial supported review closes without invented conditions or an extra review.
1415
+ This is a receipt consistency/freshness gate, not reviewer authentication or semantic proof; existing
1416
+ Confirmation, scope, family and budget gates still apply. No automatic repair loop or new CLI command.
1417
+
1418
+ Repeated prepare retains all pending paths until verified closure. Actual fallback families bind before dispatch while prior owners remain required. Same-slug QE repair uses current own-reviewer evidence bound to the complete historical checkpoint; original findings/grades remain visible, and failed Confirmation still blocks delivery.
1419
+
1420
+ Before fallback, host prepare retains independently written pending receipt findings under the original owner even if that reviewer returned null. Unreconciled pending evidence pauses dispatch for originating-reviewer repair; it cannot be replaced by a clean fallback to obtain closure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dzhechkov/skills-feature-adr",
3
- "version": "1.5.15",
3
+ "version": "1.5.16",
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": "66f3b2cf7fdcf9efbf64c7d5cc36b34ffc82b151a9bc07e6fda5fc47d4fba72e"
38
+ "content": "a76e2b71dd2598d4d6789ee00ce99791d4e4927466f4800e5637c4bbbade859e"
39
39
  }
40
40
  ]
41
41
  },
@@ -69,7 +69,7 @@
69
69
  },
70
70
  {
71
71
  "name": "dz:canonical-json-sha256-v2",
72
- "value": "e74b1a22e0260a61a3ab37512366402c4e6231b85625028a86219ee3f5c21e12"
72
+ "value": "d9af637b1b430a77811424de145ff5a72eceff10b7a583413affa4899c877b8e"
73
73
  }
74
74
  ]
75
75
  },
@@ -349,7 +349,7 @@
349
349
  "hashes": [
350
350
  {
351
351
  "alg": "SHA-256",
352
- "content": "d48a8d6eebf9760cc8f1a4f19df0e6afc31fcc1284b5eacfb604ad8ced6a0b53"
352
+ "content": "b6e505083c1b8bc92243379b72ff4667b5b91ef65fd9c2ef7407129018b3e56b"
353
353
  }
354
354
  ]
355
355
  },
@@ -399,7 +399,7 @@
399
399
  "hashes": [
400
400
  {
401
401
  "alg": "SHA-256",
402
- "content": "d7a967926feec9b0ec173ac7042ae3abd368644d8e1dba9a81a770794417fcb3"
402
+ "content": "4aa7ef1dea08b8d1995d968a317da5d36a4859ec299637bdc60170cf577c87af"
403
403
  }
404
404
  ]
405
405
  },
@@ -643,6 +643,16 @@
643
643
  }
644
644
  ]
645
645
  },
646
+ {
647
+ "type": "file",
648
+ "name": "templates/.claude/skills/feature-adr/scripts/check-review-convergence.mjs",
649
+ "hashes": [
650
+ {
651
+ "alg": "SHA-256",
652
+ "content": "80f9691b8e94bf310461aeaaaa23af6e336d41d0962fdf137737639655cbdeb8"
653
+ }
654
+ ]
655
+ },
646
656
  {
647
657
  "type": "file",
648
658
  "name": "templates/.claude/skills/feature-adr/scripts/markdown-masker.mjs",
@@ -809,7 +819,7 @@
809
819
  "hashes": [
810
820
  {
811
821
  "alg": "SHA-256",
812
- "content": "7928cc90575eaef4f4626849f4490631676bc4ce6eb80e8f94c99d9e215bbd77"
822
+ "content": "6f9e6205c8f78bef8086603bf0efe8099011a2cc273d786b8404bd77af565354"
813
823
  }
814
824
  ]
815
825
  },
@@ -27,6 +27,135 @@ sonnet (analytical quality assessment — multiple parallel agents)
27
27
 
28
28
  ## Protocol
29
29
 
30
+ ## Review convergence gate (Plain and native Workflow)
31
+
32
+ Review follows **initial review → host rework → focused verification → closure**, or explicit owner
33
+ escalation. A grade/GO label does not establish closure. Run the same installed Node gate before
34
+ planning (phase `ideation`) and before Step-8 completion/delivery (phase `qe`), including resume.
35
+
36
+ The host prepares a measured artifact snapshot BEFORE independent review. Resolve the executable
37
+ from this installed skill's `scripts/check-review-convergence.mjs`; do not use a scratch replacement.
38
+ For example, from the execution project, using the actual originating role/family:
39
+
40
+ ```bash
41
+ node "$FEATURE_ADR_SKILL/scripts/check-review-convergence.mjs" prepare \
42
+ --repo "$EXECUTION_PROJECT" --feature "features/$FEATURE_SLUG" --phase ideation \
43
+ --reviewers '[{"id":"qcsd-quality","family":"owner-exception"},{"id":"qcsd-risk","family":"owner-exception"},{"id":"qcsd-testability","family":"owner-exception"}]'
44
+ node "$FEATURE_ADR_SKILL/scripts/check-review-convergence.mjs" evaluate \
45
+ --repo "$EXECUTION_PROJECT" --feature "features/$FEATURE_SLUG" --phase ideation
46
+ ```
47
+
48
+ For QE use `--phase qe` and the actual `qe-primary` reviewer, plus `qe-precision` when configured.
49
+ `family` is `codex`, `claude`, or an explicitly authorized `owner-exception`. Existing family/routing
50
+ policy remains binding; this receipt does not authorize an exception. Only the host calls `prepare`.
51
+ It retains originating reviewer roles and prior condition meanings in the existing feature checkpoint
52
+ `.fa-state/review-convergence-<phase>-host.json`; neither the latest report nor an empty request array
53
+ can replace that lineage. Do not edit/delete host lineage to obtain a clean bootstrap. A new nonce is
54
+ issued when measured bytes/path membership change; repeated preparation cannot remove conditions. Pending changed paths accumulate across every prepare, including path additions/deletions, until an established closure; failed/refused evaluations never clear them.
55
+
56
+ The snapshot measures requirements, every direct ADR, architecture, and other available design
57
+ inputs. QE also measures the plan, ideation report, every declared target, and host-discovered Git
58
+ changes/additions/deletions against `.fa-state/base-ref` (HEAD when absent). Receipt/checkpoint files,
59
+ QE report and generated `architecture/map.json` are outputs excluded to avoid circular hashes.
60
+ Explicit planned deletions carry a null digest. Traversal, symlinks and non-files refuse. The host
61
+ must include relevant unchanged dependencies in declared targets; hashing cannot discover semantic
62
+ dependencies. Parallel Workflow design may change after ideation review: that stale review pauses
63
+ before planning and needs focused review of the settled snapshot.
64
+
65
+ All three QCSD core roles remain originating reviewers. The coordinator does not manufacture their
66
+ receipts from grades or report prose. The JSON example below shows one entry; include all three
67
+ actual roles in `reviews`, with their actual families. Each independent reviewer writes its OWN
68
+ entry, preserving other reviewer entries and the actual
69
+ author handoff, in `.fa-state/review-convergence-<phase>-review.json`. A genuinely clean initial
70
+ review needs no invented condition or extra review. Its complete receipt shape is:
71
+
72
+ ```json
73
+ {
74
+ "schema": "fa-review-convergence-1", "phase": "ideation",
75
+ "nonce": "<copy host snapshot nonce>", "revision": "<copy host snapshot revision>",
76
+ "author": null,
77
+ "reviews": [{
78
+ "reviewer": "qcsd-quality", "family": "owner-exception", "independent": true, "verdict": "clean",
79
+ "conditions": [], "verifications": [],
80
+ "newRisks": {"assessment": "none identified after complete scope review", "evidence": "<actual review evidence>", "conditions": []},
81
+ "authorAssessmentChecked": false, "deltaVerification": null
82
+ }]
83
+ }
84
+ ```
85
+
86
+ `verdict` is `clean`, `conditional`, or `no-go`. Conditions have closed fields
87
+ `{id, owner, severity, classification, requirement, scope, evidence}`: stable unique ID, originating
88
+ role ID, BLOCKER/CRITICAL/HIGH/WARNING/SUGGESTION, structural/wording, exact acceptance meaning,
89
+ nonempty repo-relative scope paths, and original evidence. Carry every prior own condition unchanged
90
+ on every pass. Reviewer verifications have closed fields
91
+ `{id, revision, classification, evidence, implementationVerified}`. Only the originating independent
92
+ role can verify its own condition at the current revision; author/lead/scribe assertions cannot.
93
+
94
+ Rework requires the actual author object
95
+ `{addressed, changedScope, classification, delta, evidence, newRisks}`. Addressed IDs name prior
96
+ conditions; changedScope includes ALL host-measured changes and affected dependencies. Delta/evidence
97
+ explain the actual fix. Both author and reviewer `newRisks` require explicit assessment/evidence and a
98
+ conditions array, including an explicit none-identified assessment when empty. The reviewer must set
99
+ `authorAssessmentChecked:true` and supply independent
100
+ `deltaVerification:{classification,evidence,implementationVerified}`. Structural changes affect
101
+ behavior, contracts, safeguards, architecture or acceptance meaning and require affected implementation
102
+ and test verification. Wording changes affect expression only; the independent reviewer checks that
103
+ classification, so an author's wording label cannot weaken structural verification.
104
+
105
+ Focused scope is outstanding conditions plus actual changes and affected dependencies. New serious
106
+ risks always expand that scope. Retain ALL conditions and serious primary/precision findings until
107
+ own-reviewer verification; a token/round ceiling escalates unresolved risk, never approves it.
108
+
109
+ Gate exits: 0 with `prepared` (prepare only) or `closed` (evaluate only); 1 `unresolved`; 3
110
+ `not-established`. Inspect the structured reasons/unresolved IDs. Missing executable/report, malformed
111
+ or contradictory output, foreign/duplicate/omitted conditions, stale phase/nonce/manifest or unavailable
112
+ reviewer refuses. Native Workflow strictly checks stdout AND exit before planning and before
113
+ completion/delivery/success tags. Supported native/Mode-B/fallback/precision reviewers receive this
114
+ contract; the read-only Codex mode-A route cannot accept it and pauses for a supported independent
115
+ review. A scribe never creates reviewer evidence from grade. Historical resume without this contract
116
+ needs fresh evidence; resume rechecks bytes and every configured route, including unwitnessed precision.
117
+
118
+ Host prepare first reads the existing reviewer receipt, including valid partial own entries written
119
+ before an agent/transport failure. It preserves independently written conditions under their original
120
+ role/family before binding any actual fallback. This imports findings, never closure or author/scribe
121
+ claims. A stale receipt cannot originate new host conditions; known prior meanings remain immutable.
122
+ `pending-reviewer-receipt-needs-origin-review` pauses before native fallback dispatch when pending
123
+ evidence cannot be reconciled. Keep the original receipt and host lineage. Have its originating
124
+ reviewer repair malformed/foreign fields or refresh unknown stale own findings at the unchanged
125
+ host identity/current snapshot. Repeat prepare for the selected actual base-role families, complete
126
+ the returned full reviewer roster with actual own verification, then evaluate and resume the same
127
+ slug. No receipt/lineage deletion or relabeling is a recovery action. The existing QE checkpoint
128
+ validator rejects a paused non-QE result, so same-slug resume re-enters only that reviewer stage;
129
+ unrelated coder checkpoints remain eligible for reuse.
130
+
131
+ Bind the actual fallback family with host prepare BEFORE reviewer dispatch. An unused role may change family; once review evidence originated, its role/family and prior conditions remain required, and the new family receives a distinct `<base-id>:<family>` role. Reviewers read the returned full roster and never relabel old ownership.
132
+
133
+ On refusal, the host settles artifacts, calls prepare with the selected actual base-role families, supplies
134
+ the author handoff, requests focused own-condition verification, then evaluates and resumes the same
135
+ slug. This is host-driven repair, not an automatic repair loop. Existing Confirmation, amendment,
136
+ scope, testing, independent-family and budget gates remain conjunctive. This gate establishes receipt
137
+ consistency and current artifact binding, not semantic truth or authenticated reviewer identity.
138
+
139
+ For a same-slug QE resume blocked by historical serious findings, read-only mode A, or an
140
+ unwitnessed precision append, preserve the complete original checkpoint, findings and grades.
141
+ Prepare returns `checkpointDigest`: SHA-256 of `JSON.stringify` of the latest `qe` result in
142
+ `.fa-state/checkpoints.jsonl`. Each actual originating reviewer supplies optional closed-field
143
+ `checkpointVerification:{checkpointDigest,evidence,supported:true,route,findings,reportDigest}`
144
+ in its own review entry, in addition to the ordinary current-revision independent verification.
145
+ `route` is native/mode-b/fallback and evidence describes the actual supported focused review;
146
+ mode A, author/scribe flags, or the old grade cannot establish this proof. `findings` maps EVERY
147
+ original serious gap with `{digest,conditionId}`, where digest is SHA-256(JSON.stringify(gap))
148
+ and conditionId names that reviewer's preserved serious condition with its current structural
149
+ implementation verification. Both primary and precision origins must verify their own findings.
150
+ Use `reportDigest:null` normally; an unwitnessed precision append requires that originating
151
+ precision reviewer to verify the actual complete `08_qe_report.md` and supply its SHA-256.
152
+ The report must contain Primary QE pass, Precision QE pass, and Combined Step-8 grade sections.
153
+ Then evaluate with the same phase/repo/feature and re-invoke the same slug. Only a closed gate
154
+ with complete checkpoint binding can reconcile historical refusals on an actual resumed QE stage;
155
+ live findings, changed checkpoints/artifacts and a failed Confirmation gate still block delivery.
156
+ This procedure runs no coder or automatic repair loop, and retains the historical result for audit.
157
+
158
+
30
159
  ### 1. Flag Detection (MANDATORY)
31
160
 
32
161
  Scan feature requirements + ADR decisions and SET these flags:
@@ -23,6 +23,132 @@ sonnet (analytical evaluation)
23
23
 
24
24
  ## Protocol
25
25
 
26
+ ## Review convergence gate (Plain and native Workflow)
27
+
28
+ Review follows **initial review → host rework → focused verification → closure**, or explicit owner
29
+ escalation. A grade/GO label does not establish closure. Run the same installed Node gate before
30
+ planning (phase `ideation`) and before Step-8 completion/delivery (phase `qe`), including resume.
31
+
32
+ The host prepares a measured artifact snapshot BEFORE independent review. Resolve the executable
33
+ from this installed skill's `scripts/check-review-convergence.mjs`; do not use a scratch replacement.
34
+ For example, from the execution project, using the actual originating role/family:
35
+
36
+ ```bash
37
+ node "$FEATURE_ADR_SKILL/scripts/check-review-convergence.mjs" prepare \
38
+ --repo "$EXECUTION_PROJECT" --feature "features/$FEATURE_SLUG" --phase ideation \
39
+ --reviewers '[{"id":"qcsd","family":"owner-exception"}]'
40
+ node "$FEATURE_ADR_SKILL/scripts/check-review-convergence.mjs" evaluate \
41
+ --repo "$EXECUTION_PROJECT" --feature "features/$FEATURE_SLUG" --phase ideation
42
+ ```
43
+
44
+ The ideation example above is illustrative; QCSD uses its three actual core role entries. For QE use `--phase qe` and the actual `qe-primary` reviewer, plus `qe-precision` when configured.
45
+ `family` is `codex`, `claude`, or an explicitly authorized `owner-exception`. Existing family/routing
46
+ policy remains binding; this receipt does not authorize an exception. Only the host calls `prepare`.
47
+ It retains originating reviewer roles and prior condition meanings in the existing feature checkpoint
48
+ `.fa-state/review-convergence-<phase>-host.json`; neither the latest report nor an empty request array
49
+ can replace that lineage. Do not edit/delete host lineage to obtain a clean bootstrap. A new nonce is
50
+ issued when measured bytes/path membership change; repeated preparation cannot remove conditions. Pending changed paths accumulate across every prepare, including path additions/deletions, until an established closure; failed/refused evaluations never clear them.
51
+
52
+ The snapshot measures requirements, every direct ADR, architecture, and other available design
53
+ inputs. QE also measures the plan, ideation report, every declared target, and host-discovered Git
54
+ changes/additions/deletions against `.fa-state/base-ref` (HEAD when absent). Receipt/checkpoint files,
55
+ QE report and generated `architecture/map.json` are outputs excluded to avoid circular hashes.
56
+ Explicit planned deletions carry a null digest. Traversal, symlinks and non-files refuse. The host
57
+ must include relevant unchanged dependencies in declared targets; hashing cannot discover semantic
58
+ dependencies. Parallel Workflow design may change after ideation review: that stale review pauses
59
+ before planning and needs focused review of the settled snapshot.
60
+
61
+ Each independent reviewer writes its OWN entry, preserving other reviewer entries and the actual
62
+ author handoff, in `.fa-state/review-convergence-<phase>-review.json`. A genuinely clean initial
63
+ review needs no invented condition or extra review. Its complete receipt shape is:
64
+
65
+ ```json
66
+ {
67
+ "schema": "fa-review-convergence-1", "phase": "ideation",
68
+ "nonce": "<copy host snapshot nonce>", "revision": "<copy host snapshot revision>",
69
+ "author": null,
70
+ "reviews": [{
71
+ "reviewer": "qcsd", "family": "owner-exception", "independent": true, "verdict": "clean",
72
+ "conditions": [], "verifications": [],
73
+ "newRisks": {"assessment": "none identified after complete scope review", "evidence": "<actual review evidence>", "conditions": []},
74
+ "authorAssessmentChecked": false, "deltaVerification": null
75
+ }]
76
+ }
77
+ ```
78
+
79
+ `verdict` is `clean`, `conditional`, or `no-go`. Conditions have closed fields
80
+ `{id, owner, severity, classification, requirement, scope, evidence}`: stable unique ID, originating
81
+ role ID, BLOCKER/CRITICAL/HIGH/WARNING/SUGGESTION, structural/wording, exact acceptance meaning,
82
+ nonempty repo-relative scope paths, and original evidence. Carry every prior own condition unchanged
83
+ on every pass. Reviewer verifications have closed fields
84
+ `{id, revision, classification, evidence, implementationVerified}`. Only the originating independent
85
+ role can verify its own condition at the current revision; author/lead/scribe assertions cannot.
86
+
87
+ Rework requires the actual author object
88
+ `{addressed, changedScope, classification, delta, evidence, newRisks}`. Addressed IDs name prior
89
+ conditions; changedScope includes ALL host-measured changes and affected dependencies. Delta/evidence
90
+ explain the actual fix. Both author and reviewer `newRisks` require explicit assessment/evidence and a
91
+ conditions array, including an explicit none-identified assessment when empty. The reviewer must set
92
+ `authorAssessmentChecked:true` and supply independent
93
+ `deltaVerification:{classification,evidence,implementationVerified}`. Structural changes affect
94
+ behavior, contracts, safeguards, architecture or acceptance meaning and require affected implementation
95
+ and test verification. Wording changes affect expression only; the independent reviewer checks that
96
+ classification, so an author's wording label cannot weaken structural verification.
97
+
98
+ Focused scope is outstanding conditions plus actual changes and affected dependencies. New serious
99
+ risks always expand that scope. Retain ALL conditions and serious primary/precision findings until
100
+ own-reviewer verification; a token/round ceiling escalates unresolved risk, never approves it.
101
+
102
+ Gate exits: 0 with `prepared` (prepare only) or `closed` (evaluate only); 1 `unresolved`; 3
103
+ `not-established`. Inspect the structured reasons/unresolved IDs. Missing executable/report, malformed
104
+ or contradictory output, foreign/duplicate/omitted conditions, stale phase/nonce/manifest or unavailable
105
+ reviewer refuses. Native Workflow strictly checks stdout AND exit before planning and before
106
+ completion/delivery/success tags. Supported native/Mode-B/fallback/precision reviewers receive this
107
+ contract; the read-only Codex mode-A route cannot accept it and pauses for a supported independent
108
+ review. A scribe never creates reviewer evidence from grade. Historical resume without this contract
109
+ needs fresh evidence; resume rechecks bytes and every configured route, including unwitnessed precision.
110
+
111
+ Host prepare first reads the existing reviewer receipt, including valid partial own entries written
112
+ before an agent/transport failure. It preserves independently written conditions under their original
113
+ role/family before binding any actual fallback. This imports findings, never closure or author/scribe
114
+ claims. A stale receipt cannot originate new host conditions; known prior meanings remain immutable.
115
+ `pending-reviewer-receipt-needs-origin-review` pauses before native fallback dispatch when pending
116
+ evidence cannot be reconciled. Keep the original receipt and host lineage. Have its originating
117
+ reviewer repair malformed/foreign fields or refresh unknown stale own findings at the unchanged
118
+ host identity/current snapshot. Repeat prepare for the selected actual base-role families, complete
119
+ the returned full reviewer roster with actual own verification, then evaluate and resume the same
120
+ slug. No receipt/lineage deletion or relabeling is a recovery action. The existing QE checkpoint
121
+ validator rejects a paused non-QE result, so same-slug resume re-enters only that reviewer stage;
122
+ unrelated coder checkpoints remain eligible for reuse.
123
+
124
+ Bind the actual fallback family with host prepare BEFORE reviewer dispatch. An unused role may change family; once review evidence originated, its role/family and prior conditions remain required, and the new family receives a distinct `<base-id>:<family>` role. Reviewers read the returned full roster and never relabel old ownership.
125
+
126
+ On refusal, the host settles artifacts, calls prepare with the selected actual base-role families, supplies
127
+ the author handoff, requests focused own-condition verification, then evaluates and resumes the same
128
+ slug. This is host-driven repair, not an automatic repair loop. Existing Confirmation, amendment,
129
+ scope, testing, independent-family and budget gates remain conjunctive. This gate establishes receipt
130
+ consistency and current artifact binding, not semantic truth or authenticated reviewer identity.
131
+
132
+ For a same-slug QE resume blocked by historical serious findings, read-only mode A, or an
133
+ unwitnessed precision append, preserve the complete original checkpoint, findings and grades.
134
+ Prepare returns `checkpointDigest`: SHA-256 of `JSON.stringify` of the latest `qe` result in
135
+ `.fa-state/checkpoints.jsonl`. Each actual originating reviewer supplies optional closed-field
136
+ `checkpointVerification:{checkpointDigest,evidence,supported:true,route,findings,reportDigest}`
137
+ in its own review entry, in addition to the ordinary current-revision independent verification.
138
+ `route` is native/mode-b/fallback and evidence describes the actual supported focused review;
139
+ mode A, author/scribe flags, or the old grade cannot establish this proof. `findings` maps EVERY
140
+ original serious gap with `{digest,conditionId}`, where digest is SHA-256(JSON.stringify(gap))
141
+ and conditionId names that reviewer's preserved serious condition with its current structural
142
+ implementation verification. Both primary and precision origins must verify their own findings.
143
+ Use `reportDigest:null` normally; an unwitnessed precision append requires that originating
144
+ precision reviewer to verify the actual complete `08_qe_report.md` and supply its SHA-256.
145
+ The report must contain Primary QE pass, Precision QE pass, and Combined Step-8 grade sections.
146
+ Then evaluate with the same phase/repo/feature and re-invoke the same slug. Only a closed gate
147
+ with complete checkpoint binding can reconcile historical refusals on an actual resumed QE stage;
148
+ live findings, changed checkpoints/artifacts and a failed Confirmation gate still block delivery.
149
+ This procedure runs no coder or automatic repair loop, and retains the historical result for audit.
150
+
151
+
26
152
  ### Bridge identity for plain Step 8
27
153
 
28
154
  When the plain host invokes the existing Claude review bridge, take round number, pipeline run and
@@ -56,7 +182,7 @@ a live Claude model roundtrip or secure every native Codex QE receipt.
56
182
  - [ ] Type checker passes (if configured)
57
183
  ```
58
184
 
59
- For S-tier: if all pass, proceed to checkpoint. Done.
185
+ For S-tier: passing smoke tests still requires the review-convergence contract above before checkpoint/completion; a smoke pass alone is not closure.
60
186
 
61
187
  ### 2. Test Generation (M+)
62
188
 
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ // Host checkpoint lineage, not a reviewer-supplied prior array, owns historical conditions.
3
+ // This checks consistency/freshness; it does not authenticate models or judge their evidence.
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from 'node:fs'
6
+ import { dirname, relative, resolve, sep } from 'node:path'
7
+ import { execFileSync } from 'node:child_process'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ export const SCHEMA = 'fa-review-convergence-1'
11
+ const phases = ['ideation', 'qe']
12
+ const severities = ['BLOCKER', 'CRITICAL', 'HIGH', 'WARNING', 'SUGGESTION']
13
+ const kinds = ['structural', 'wording']
14
+ const digest = value => createHash('sha256').update(value).digest('hex')
15
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
16
+ const text = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 65536
17
+ const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
18
+ const array = (value, limit = 512) => Array.isArray(value) && value.length <= limit
19
+ function dictionary(value, keys, required = keys) {
20
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => keys.includes(key)) && required.every(key => Object.hasOwn(value, key))
21
+ }
22
+ function strings(value, nonempty = false) { return array(value) && (!nonempty || value.length > 0) && value.every(text) && new Set(value).size === value.length }
23
+ function condition(c, owners) {
24
+ return dictionary(c, ['id', 'owner', 'severity', 'classification', 'requirement', 'scope', 'evidence']) && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}$/.test(c.id) && owners.includes(c.owner) && severities.includes(c.severity) && kinds.includes(c.classification) && text(c.requirement) && strings(c.scope, true) && text(c.evidence)
25
+ }
26
+ function risks(r, owners) { return dictionary(r, ['assessment', 'evidence', 'conditions']) && text(r.assessment) && text(r.evidence) && array(r.conditions) && r.conditions.every(c => condition(c, owners)) }
27
+ function checkpointProof(p) {
28
+ return dictionary(p, ['checkpointDigest', 'evidence', 'supported', 'route', 'findings', 'reportDigest']) && hash(p.checkpointDigest) && text(p.evidence) && p.supported === true && ['native', 'mode-b', 'fallback'].includes(p.route) && array(p.findings) && p.findings.every(v => dictionary(v, ['digest', 'conditionId']) && hash(v.digest) && text(v.conditionId)) && (p.reportDigest === null || hash(p.reportDigest))
29
+ }
30
+ function reviewerFamily(stage) { return stage.qeReviewerUsed === 'codex-fallback' ? 'codex' : stage.qeReviewerUsed }
31
+ function reviewerEntry(r, roster) {
32
+ const owners = roster.map(p => p.id)
33
+ return !(!dictionary(r, ['reviewer', 'family', 'independent', 'verdict', 'conditions', 'verifications', 'newRisks', 'authorAssessmentChecked', 'deltaVerification', 'checkpointVerification'], ['reviewer', 'family', 'independent', 'verdict', 'conditions', 'verifications', 'newRisks', 'authorAssessmentChecked', 'deltaVerification']) || !owners.includes(r.reviewer) || roster.find(p => p.id === r.reviewer)?.family !== r.family || r.independent !== true || !['clean', 'conditional', 'no-go'].includes(r.verdict) || !array(r.conditions) || !r.conditions.every(c => condition(c, owners) && c.owner === r.reviewer) || !array(r.verifications) || !risks(r.newRisks, owners) || !r.newRisks.conditions.every(c => c.owner === r.reviewer) || typeof r.authorAssessmentChecked !== 'boolean')
34
+ }
35
+ function result(phase, verdict, revision, reasons, unresolved = []) { return { schema: SCHEMA, phase, verdict, revision, unresolved, reasons } }
36
+
37
+ export function evaluateReviewConvergence(host, receipt, currentManifest) {
38
+ const phase = host?.phase
39
+ const refuse = reason => result(phase || 'qe', 'not-established', host?.snapshot?.revision || null, [reason], (host?.conditions || []).map(c => c.id))
40
+ if (!dictionary(host, ['schema', 'phase', 'reviewers', 'snapshot', 'conditions', 'reviewSeen', 'rework', 'changedPaths']) || host.schema !== SCHEMA || !phases.includes(phase) || !array(host.reviewers, 16) || host.reviewers.length === 0) return refuse('host-lineage-invalid')
41
+ const owners = host.reviewers.map(r => r.id)
42
+ if (new Set(owners).size !== owners.length || !host.reviewers.every(r => dictionary(r, ['id', 'family']) && text(r.id) && ['codex', 'claude', 'owner-exception'].includes(r.family))) return refuse('host-reviewers-invalid')
43
+ if (!dictionary(host.snapshot, ['nonce', 'revision', 'manifest']) || !text(host.snapshot.nonce) || !hash(host.snapshot.revision) || !array(host.conditions) || !host.conditions.every(c => condition(c, owners)) || typeof host.reviewSeen !== 'boolean' || typeof host.rework !== 'boolean' || !strings(host.changedPaths)) return refuse('host-snapshot-invalid')
44
+ if (!same(host.snapshot.manifest, currentManifest) || digest(JSON.stringify(currentManifest)) !== host.snapshot.revision) return refuse('artifact-manifest-changed')
45
+ if (!dictionary(receipt, ['schema', 'phase', 'nonce', 'revision', 'reviews', 'author']) || receipt.schema !== SCHEMA || receipt.phase !== phase || receipt.nonce !== host.snapshot.nonce || receipt.revision !== host.snapshot.revision) return refuse('receipt-phase-or-revision-invalid')
46
+ if (!array(receipt.reviews, 16) || receipt.reviews.length !== owners.length || new Set(receipt.reviews.map(r => r?.reviewer)).size !== owners.length) return refuse('reviewer-set-invalid')
47
+ const all = new Map(host.conditions.map(c => [c.id, c]))
48
+ const verified = new Set()
49
+ const reasons = []
50
+ const needsAuthor = host.rework || (host.reviewSeen && host.conditions.length > 0)
51
+ if (needsAuthor && !receipt.author) return refuse('author-delta-missing')
52
+ if (receipt.author !== null) {
53
+ const a = receipt.author
54
+ if (!dictionary(a, ['addressed', 'changedScope', 'classification', 'delta', 'evidence', 'newRisks']) || !strings(a.addressed) || !strings(a.changedScope, true) || !kinds.includes(a.classification) || !text(a.delta) || !text(a.evidence) || !risks(a.newRisks, owners)) return refuse('author-delta-invalid')
55
+ if (host.changedPaths.some(path => !a.changedScope.includes(path))) return refuse('author-delta-omits-measured-changes')
56
+ if (a.addressed.some(id => !all.has(id))) return refuse('author-addressed-unknown-condition')
57
+ for (const c of a.newRisks.conditions) {
58
+ if (all.has(c.id) && !same(all.get(c.id), c)) return refuse('author-risk-condition-redefined')
59
+ all.set(c.id, c)
60
+ }
61
+ }
62
+ for (const r of receipt.reviews) {
63
+ if (!reviewerEntry(r, host.reviewers)) return refuse('reviewer-contract-invalid')
64
+ if (Object.hasOwn(r, 'checkpointVerification') && !checkpointProof(r.checkpointVerification)) return refuse('checkpoint-verification-contract-invalid')
65
+ if (receipt.author && !r.authorAssessmentChecked) return refuse('author-risk-assessment-not-reviewed')
66
+ if (receipt.author) {
67
+ const d = r.deltaVerification
68
+ if (!dictionary(d, ['classification', 'evidence', 'implementationVerified']) || !kinds.includes(d.classification) || !text(d.evidence) || typeof d.implementationVerified !== 'boolean') return refuse('independent-delta-classification-missing')
69
+ if ((receipt.author.classification === 'structural' || d.classification === 'structural') && (!d.implementationVerified || d.classification !== 'structural')) reasons.push('structural-delta-not-verified:' + r.reviewer)
70
+ } else if (r.deltaVerification !== null) return refuse('unexpected-delta-verification')
71
+ const own = r.conditions.concat(r.newRisks.conditions)
72
+ if (new Set(own.map(c => c.id)).size !== own.length) return refuse('duplicate-condition')
73
+ for (const c of host.conditions.filter(c => c.owner === r.reviewer)) if (!own.some(n => same(n, c))) return refuse('prior-condition-omitted-or-redefined:' + c.id)
74
+ for (const c of own) {
75
+ if (c.scope.some(path => !currentManifest.some(p => p.path === path))) return refuse('condition-scope-unmeasured:' + c.id)
76
+ if (all.has(c.id) && !same(all.get(c.id), c)) return refuse('condition-redefined:' + c.id)
77
+ all.set(c.id, c)
78
+ }
79
+ if (r.verdict === 'clean' && own.length > 0 && r.verifications.length === 0) return refuse('clean-verdict-contradicts-conditions')
80
+ if (r.verdict === 'conditional' && own.length === 0) return refuse('conditional-without-conditions')
81
+ if (r.verdict === 'no-go') reasons.push('reviewer-no-go:' + r.reviewer)
82
+ const seen = new Set()
83
+ for (const v of r.verifications) {
84
+ if (!dictionary(v, ['id', 'revision', 'classification', 'evidence', 'implementationVerified']) || seen.has(v.id) || !all.has(v.id) || all.get(v.id).owner !== r.reviewer || v.revision !== host.snapshot.revision || !kinds.includes(v.classification) || !text(v.evidence) || typeof v.implementationVerified !== 'boolean') return refuse('verification-invalid-or-foreign')
85
+ seen.add(v.id)
86
+ // A semantic change cannot become a weaker check by the author's label.
87
+ const structural = all.get(v.id).classification === 'structural' || receipt.author?.classification === 'structural' || v.classification === 'structural'
88
+ if (structural && (!v.implementationVerified || v.classification !== 'structural')) reasons.push('structural-verification-missing:' + v.id)
89
+ else verified.add(v.id)
90
+ }
91
+ }
92
+ const unresolved = [...all.keys()].filter(id => !verified.has(id))
93
+ for (const id of unresolved) reasons.push('unresolved:' + id)
94
+ if (receipt.author?.newRisks.conditions.some(c => !receipt.reviews.some(r => r.conditions.concat(r.newRisks.conditions).some(n => same(n, c))))) reasons.push('author-risk-not-adopted-by-reviewer')
95
+ return result(phase, reasons.length ? 'unresolved' : 'closed', host.snapshot.revision, reasons, unresolved)
96
+ }
97
+
98
+ function safePath(root, rel, allowMissing = true) {
99
+ if (!text(rel) || rel.startsWith('/') || rel.includes('\\') || rel.split('/').some(p => !p || p === '.' || p === '..') || /[\x00-\x1f]/.test(rel)) throw Error('unsafe-artifact-path')
100
+ let path = root
101
+ for (const part of rel.split('/')) {
102
+ path = resolve(path, part)
103
+ if (!existsSync(path)) { if (allowMissing) return resolve(root, rel); throw Error('artifact-missing:' + rel) }
104
+ if (lstatSync(path).isSymbolicLink()) throw Error('symlink-artifact:' + rel)
105
+ }
106
+ return path
107
+ }
108
+ function readJson(path) { return JSON.parse(readFileSync(path, 'utf8')) }
109
+ function writeJson(path, value) {
110
+ if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) throw Error('unsafe-checkpoint')
111
+ const temporary = path + '.' + randomUUID() + '.tmp'
112
+ writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx' })
113
+ renameSync(temporary, path)
114
+ }
115
+ function pathsFor(repo, feature, phase) {
116
+ const prefix = relative(repo, feature).split(sep).join('/')
117
+ if (!prefix || prefix.startsWith('../')) throw Error('feature-must-be-inside-repo')
118
+ safePath(repo, prefix, false)
119
+ const targets = ['00_complexity_assessment.md', '01_requirements.md', '02_research.md', '04_domain_model.md', '05_architecture.md']
120
+ if (phase === 'qe') targets.push('06_implementation_plan.md', '03.5_ideation_report.md')
121
+ const adr = safePath(repo, prefix + '/03_adr')
122
+ if (existsSync(adr) && !lstatSync(adr).isDirectory()) throw Error('adr-not-directory')
123
+ const decisions = existsSync(adr) ? readdirSync(adr).filter(p => /^[0-9]{3}-.+\.md$/.test(p)).sort() : []
124
+ if (phase === 'ideation' && !decisions.length) throw Error('adr-missing')
125
+ const paths = targets.filter(p => existsSync(resolve(feature, p))).concat(decisions.map(p => '03_adr/' + p)).map(p => prefix + '/' + p)
126
+ for (const required of phase === 'ideation' ? ['01_requirements.md', '05_architecture.md'] : ['01_requirements.md', '06_implementation_plan.md']) if (!paths.includes(prefix + '/' + required)) throw Error('design-input-missing:' + required)
127
+ if (phase === 'qe') {
128
+ const plan = readFileSync(safePath(repo, prefix + '/06_implementation_plan.md', false), 'utf8')
129
+ const block = plan.split(/^EXPECTED_CODE_TARGETS:\s*$/m)
130
+ if (block.length !== 2) throw Error('target-block-missing-or-duplicate')
131
+ const planned = block[1].split('\n').filter(line => line.trim()).map(line => {
132
+ const match = /^- (.+)$/.exec(line)
133
+ if (!match) throw Error('target-block-malformed')
134
+ return match[1]
135
+ })
136
+ if (!planned.length) throw Error('target-block-empty')
137
+ if (planned.some(p => /^(features|\.dz|\.agentic-qe|roam)\//.test(p))) throw Error('circular-review-target')
138
+ paths.push(...planned)
139
+ // Additions/deletions are discovered by the host, not the author delta request.
140
+ const baseFile = resolve(feature, '.fa-state/base-ref')
141
+ const base = existsSync(baseFile) ? readFileSync(baseFile, 'utf8').trim() : 'HEAD'
142
+ if (!/^[A-Za-z0-9_./:-]+$/.test(base) || base.startsWith('-')) throw Error('base-ref-invalid')
143
+ for (const command of [['diff', '--name-only', '-z', base, '--'], ['ls-files', '--others', '--exclude-standard', '-z']]) {
144
+ const names = execFileSync('git', command, { cwd: repo, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }).split('\0').filter(Boolean)
145
+ paths.push(...names.filter(p => !p.startsWith('features/') && !p.startsWith('.dz/') && !p.startsWith('.agentic-qe/') && !p.startsWith('roam/') && p !== 'architecture/map.json'))
146
+ }
147
+ }
148
+ return [...new Set(paths)].sort()
149
+ }
150
+ export function measureManifest(repo, feature, phase) {
151
+ return pathsFor(repo, feature, phase).map(path => {
152
+ const absolute = safePath(repo, path)
153
+ if (!existsSync(absolute)) return { path, digest: null }
154
+ if (!lstatSync(absolute).isFile()) throw Error('non-file-artifact:' + path)
155
+ return { path, digest: digest(readFileSync(absolute)) }
156
+ })
157
+ }
158
+ function checkpointPaths(repo, feature, phase) {
159
+ const stateRel = relative(repo, feature).split(sep).join('/') + '/.fa-state'
160
+ const state = safePath(repo, stateRel)
161
+ mkdirSync(state, { recursive: true })
162
+ return { host: safePath(repo, stateRel + '/review-convergence-' + phase + '-host.json'), review: safePath(repo, stateRel + '/review-convergence-' + phase + '-review.json') }
163
+ }
164
+ function qeCheckpoint(repo, feature) {
165
+ const rel = relative(repo, feature).split(sep).join('/') + '/.fa-state/checkpoints.jsonl'
166
+ const file = safePath(repo, rel)
167
+ if (!existsSync(file)) return null
168
+ let checkpoint = null
169
+ for (const line of readFileSync(file, 'utf8').split('\n').filter(Boolean)) {
170
+ const item = JSON.parse(line)
171
+ if (item.stage === 'qe') checkpoint = item.result
172
+ }
173
+ return checkpoint ? { checkpointDigest: digest(JSON.stringify(checkpoint)), checkpoint } : null
174
+ }
175
+ function seriousFindings(source) {
176
+ return (array(source?.gaps) ? source.gaps : []).filter(gap => [gap.sev, gap.severity, gap.priority].some(v => /(?:^|[^A-Z0-9])(BLOCKER|CRITICAL|HIGH|P0|P1)(?:$|[^A-Z0-9])/.test(String(v || '').toUpperCase())))
177
+ }
178
+ function reconcileCheckpoint(host, receipt, measured, repo, feature) {
179
+ if (!measured) return null
180
+ const stage = measured.checkpoint
181
+ for (const [base, source, family, missing] of [
182
+ ['qe-primary', stage.qe, reviewerFamily(stage), !stage.qe || Boolean(stage.qe?.convergenceUnavailable)],
183
+ ['qe-precision', stage.qe2, 'claude', host.reviewers.some(r => r.id.startsWith('qe-precision')) && (!stage.qe2 || !stage.qe2.reportWritten)],
184
+ ]) {
185
+ const findings = seriousFindings(source)
186
+ if (!missing && !findings.length) continue
187
+ const origin = host.reviewers.find(r => (r.id === base || r.id === base + ':' + family) && r.family === family)
188
+ const review = receipt.reviews.find(r => r.reviewer === origin?.id)
189
+ const proof = review?.checkpointVerification
190
+ if (!origin || !checkpointProof(proof) || proof.checkpointDigest !== measured.checkpointDigest || !text(proof.evidence) || proof.supported !== true || !['native', 'mode-b', 'fallback'].includes(proof.route) || !array(proof.findings) || proof.findings.length !== findings.length || !(proof.reportDigest === null || hash(proof.reportDigest))) return null
191
+ const mapped = new Set()
192
+ for (const gap of findings) {
193
+ const item = proof.findings.find(v => v.digest === digest(JSON.stringify(gap)))
194
+ if (!dictionary(item, ['digest', 'conditionId']) || mapped.has(item.conditionId) || !host.conditions.some(c => c.id === item.conditionId && c.owner === origin.id && ['BLOCKER', 'CRITICAL', 'HIGH'].includes(c.severity)) || !review.verifications.some(v => v.id === item.conditionId && v.revision === host.snapshot.revision && v.implementationVerified === true && v.classification === 'structural')) return null
195
+ mapped.add(item.conditionId)
196
+ }
197
+ if (base === 'qe-precision' && missing) {
198
+ const file = safePath(repo, relative(repo, feature).split(sep).join('/') + '/08_qe_report.md', false)
199
+ const report = readFileSync(file)
200
+ if (digest(report) !== proof.reportDigest || !/## Primary QE pass/.test(report.toString()) || !/## Precision QE pass/.test(report.toString()) || !/Combined Step-8 grade:/.test(report.toString())) return null
201
+ }
202
+ }
203
+ return { ...measured, revision: host.snapshot.revision }
204
+ }
205
+ function retainPendingReceipt(host, path) {
206
+ if (!host || !existsSync(path)) return host
207
+ let receipt
208
+ try { receipt = readJson(path) } catch (_) { throw Error('pending-reviewer-receipt-needs-origin-review') }
209
+ if (!dictionary(receipt, ['schema', 'phase', 'nonce', 'revision', 'reviews', 'author']) || receipt.schema !== SCHEMA || receipt.phase !== host.phase || !text(receipt.nonce) || !hash(receipt.revision) || !array(receipt.reviews, 16) || !receipt.reviews.length || new Set(receipt.reviews.map(r => r?.reviewer)).size !== receipt.reviews.length) throw Error('pending-reviewer-receipt-needs-origin-review')
210
+ const owners = host.reviewers.map(r => r.id), preserved = new Map(host.conditions.map(c => [c.id, c]))
211
+ const fresh = receipt.nonce === host.snapshot.nonce && receipt.revision === host.snapshot.revision
212
+ let problem = false, witnessed = false
213
+ for (const r of receipt.reviews) {
214
+ if (!reviewerEntry(r, host.reviewers) || Object.hasOwn(r, 'checkpointVerification') && !checkpointProof(r.checkpointVerification)) { problem = true; continue }
215
+ const own = r.conditions.concat(r.newRisks.conditions)
216
+ if (new Set(own.map(c => c.id)).size !== own.length) { problem = true; continue }
217
+ witnessed = true
218
+ for (const c of own) {
219
+ // Stale evidence may preserve a known condition; it cannot originate new host authority.
220
+ if (preserved.has(c.id)) { if (!same(preserved.get(c.id), c)) problem = true }
221
+ else if (!fresh || c.scope.some(path => !host.snapshot.manifest.some(p => p.path === path))) problem = true
222
+ else if (condition(c, owners) && c.owner === r.reviewer) preserved.set(c.id, c)
223
+ }
224
+ }
225
+ const next = { ...host, conditions: [...preserved.values()], reviewSeen: host.reviewSeen || witnessed }
226
+ // This imports original independent findings only, never closure, author claims or a grade.
227
+ return { next, problem }
228
+ }
229
+ function bindReviewers(host, requested, measured) {
230
+ if (!host) return requested
231
+ const bound = host.reviewers.map(r => ({ ...r }))
232
+ for (const actual of requested) {
233
+ const prior = bound.find(r => r.id === actual.id)
234
+ if (!prior) throw Error('host-lineage-conflict')
235
+ if (prior.family === actual.family) continue
236
+ const originated = host.reviewSeen || host.conditions.some(c => c.owner === prior.id) || measured && (prior.id === 'qe-primary' && reviewerFamily(measured.checkpoint) === prior.family || prior.id === 'qe-precision' && measured.checkpoint.qe2)
237
+ if (!originated) prior.family = actual.family
238
+ else if (!bound.some(r => r.id === actual.id + ':' + actual.family)) bound.push({ id: actual.id + ':' + actual.family, family: actual.family })
239
+ }
240
+ if (host.reviewers.some(r => !r.id.includes(':') && !requested.some(n => n.id === r.id)) || bound.length > 16) throw Error('host-lineage-conflict')
241
+ return bound
242
+ }
243
+ export function runGate({ action, repo, feature, phase, reviewers }) {
244
+ if (!phases.includes(phase) || !['prepare', 'evaluate'].includes(action)) throw Error('invalid-command')
245
+ repo = realpathSync(repo); feature = resolve(repo, feature)
246
+ const current = measureManifest(repo, feature, phase)
247
+ const paths = checkpointPaths(repo, feature, phase)
248
+ let host = existsSync(paths.host) ? readJson(paths.host) : null
249
+ const measuredCheckpoint = phase === 'qe' ? qeCheckpoint(repo, feature) : null
250
+ if (action === 'prepare') {
251
+ if (!array(reviewers, 16) || !reviewers.length || !reviewers.every(r => dictionary(r, ['id', 'family']) && text(r.id) && ['codex', 'claude', 'owner-exception'].includes(r.family)) || new Set(reviewers.map(r => r.id)).size !== reviewers.length) throw Error('reviewers-invalid')
252
+ if (host && (host.phase !== phase || host.schema !== SCHEMA || !array(host.conditions) || typeof host.reviewSeen !== 'boolean')) throw Error('host-lineage-conflict')
253
+ if (host && existsSync(paths.review)) {
254
+ const pending = retainPendingReceipt(host, paths.review)
255
+ host = pending.next; writeJson(paths.host, host)
256
+ if (pending.problem) throw Error('pending-reviewer-receipt-needs-origin-review')
257
+ }
258
+ reviewers = bindReviewers(host, reviewers, measuredCheckpoint)
259
+ const revision = digest(JSON.stringify(current))
260
+ const snapshot = host?.snapshot?.revision === revision ? host.snapshot : { nonce: randomUUID(), revision, manifest: current }
261
+ const priorManifest = new Map((host?.snapshot?.manifest || []).map(p => [p.path, p.digest]))
262
+ const newManifest = new Map(current.map(p => [p.path, p.digest]))
263
+ const changedPaths = host?.snapshot?.revision === revision ? host.changedPaths : host ? [...new Set([...(host.changedPaths || []), ...[...new Set([...priorManifest.keys(), ...newManifest.keys()])].filter(path => priorManifest.get(path) !== newManifest.get(path))])].sort() : []
264
+ const next = { schema: SCHEMA, phase, reviewers, snapshot, conditions: host?.conditions || [], reviewSeen: host?.reviewSeen || false, rework: Boolean(host && (host.rework || host.reviewSeen && (host.snapshot.revision !== revision || host.conditions.length > 0))), changedPaths }
265
+ writeJson(paths.host, next)
266
+ return { ...result(phase, 'prepared', revision, []), nonce: snapshot.nonce, manifest: current, conditions: next.conditions, reviewers, checkpointDigest: measuredCheckpoint?.checkpointDigest || null }
267
+ }
268
+ if (!host) return result(phase, 'not-established', null, ['host-lineage-missing'])
269
+ if (!existsSync(paths.review)) return result(phase, 'not-established', host.snapshot.revision, ['reviewer-receipt-missing'], host.conditions.map(c => c.id))
270
+ const receipt = readJson(paths.review)
271
+ let verdict = evaluateReviewConvergence(host, receipt, current)
272
+ let closure = null
273
+ if (phase === 'qe' && verdict.verdict === 'closed' && measuredCheckpoint) {
274
+ closure = reconcileCheckpoint(host, receipt, measuredCheckpoint, repo, feature)
275
+ if (!closure) verdict = result(phase, 'not-established', host.snapshot.revision, ['historical-checkpoint-own-verification-missing'])
276
+ }
277
+ // Preserve independently owned findings even on refusal. Never import author/scribe claims.
278
+ // Invalid ownership/meaning refuses without overwriting the prior host authority.
279
+ const owners = host.reviewers.map(r => r.id)
280
+ const preserved = new Map(host.conditions.map(c => [c.id, c]))
281
+ if (receipt?.schema === SCHEMA && receipt.phase === phase && array(receipt.reviews)) {
282
+ for (const r of receipt.reviews) if (owners.includes(r?.reviewer) && host.reviewers.find(p => p.id === r.reviewer)?.family === r.family && r.independent === true) {
283
+ for (const c of [...(array(r.conditions) ? r.conditions : []), ...(array(r.newRisks?.conditions) ? r.newRisks.conditions : [])]) {
284
+ if (condition(c, owners) && c.owner === r.reviewer && !preserved.has(c.id)) preserved.set(c.id, c)
285
+ }
286
+ }
287
+ }
288
+ if (receipt?.phase === phase && receipt.nonce === host.snapshot.nonce && receipt.revision === host.snapshot.revision && risks(receipt.author?.newRisks, owners)) {
289
+ for (const c of receipt.author.newRisks.conditions) if (!preserved.has(c.id)) preserved.set(c.id, c)
290
+ }
291
+ writeJson(paths.host, { ...host, conditions: [...preserved.values()], reviewSeen: true, changedPaths: verdict.verdict === 'closed' ? [] : host.changedPaths })
292
+ if (closure && verdict.verdict === 'closed') return { ...verdict, checkpointClosure: closure }
293
+ return verdict
294
+ }
295
+
296
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
297
+ let phase = 'qe'
298
+ try {
299
+ const args = process.argv.slice(2)
300
+ const action = args.shift()
301
+ const options = {}
302
+ while (args.length) {
303
+ const key = args.shift()
304
+ if (!['--repo', '--feature', '--phase', '--reviewers'].includes(key) || !args.length || Object.hasOwn(options, key)) throw Error('invalid-arguments')
305
+ options[key] = args.shift()
306
+ }
307
+ phase = options['--phase']
308
+ if (!options['--repo'] || !options['--feature'] || !phases.includes(phase)) throw Error('invalid-arguments')
309
+ const output = runGate({ action, repo: options['--repo'], feature: options['--feature'], phase, reviewers: options['--reviewers'] ? JSON.parse(options['--reviewers']) : undefined })
310
+ process.stdout.write(JSON.stringify(output) + '\n')
311
+ process.exitCode = ['closed', 'prepared'].includes(output.verdict) ? 0 : output.verdict === 'unresolved' ? 1 : 3
312
+ } catch (error) {
313
+ process.stdout.write(JSON.stringify(result(phases.includes(phase) ? phase : 'qe', 'not-established', null, [String(error.message)])) + '\n')
314
+ process.exitCode = 3
315
+ }
316
+ }
@@ -3597,6 +3597,67 @@ function parseCoderContextEnvelope(raw, tier) {
3597
3597
  return value
3598
3598
  }
3599
3599
 
3600
+ // Review convergence host contract BEGIN. No Node imports in the Workflow sandbox.
3601
+ const REVIEW_CONVERGENCE_SCHEMA = 'fa-review-convergence-1'
3602
+ function reviewConvergenceCommand(action, reviewPhase, reviewers) {
3603
+ const script = '.claude/skills/feature-adr/scripts/check-review-convergence.mjs'
3604
+ const q = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"
3605
+ const flags = ' ' + q(action) + ' --repo ' + q(REPO) + ' --feature ' + q(FDIR) + ' --phase ' + q(reviewPhase) + (action === 'prepare' ? ' --reviewers ' + q(JSON.stringify(reviewers)) : '')
3606
+ return [
3607
+ 'RC_WORKSPACE=' + (WS ? q(WS) : '$(pwd -P)') + "; RC_SCRIPT=''",
3608
+ 'for c in "$RC_WORKSPACE/' + script + '" ' + q(REPO + '/' + script) + '; do [ -f "$c" ] && { RC_SCRIPT="$c"; break; }; done',
3609
+ 'if [ -n "$RC_SCRIPT" ]; then node "$RC_SCRIPT"' + flags + '; RC_STATUS=$?; else printf \'%s\\n\' ' + q(JSON.stringify({ schema: REVIEW_CONVERGENCE_SCHEMA, phase: reviewPhase, verdict: 'not-established', revision: null, unresolved: [], reasons: ['installed-helper-missing'] })) + '; RC_STATUS=3; fi',
3610
+ 'printf \'RC_EXIT=%s\\n\' "$RC_STATUS"',
3611
+ ].join('\n')
3612
+ }
3613
+ function parseReviewConvergenceRelay(raw, reviewPhase, action) {
3614
+ const failed = (reason) => ({ schema: REVIEW_CONVERGENCE_SCHEMA, phase: reviewPhase, verdict: 'not-established', revision: null, unresolved: [], reasons: [reason] })
3615
+ if (typeof raw !== 'string' || raw.length > 2 * 1024 * 1024) return failed('relay-invalid')
3616
+ const match = /^([^\r\n]+)\r?\nRC_EXIT=(0|1|3)\r?\n?$/.exec(raw)
3617
+ if (!match) return failed('relay-not-exact-stdout')
3618
+ let v
3619
+ try { v = JSON.parse(match[1]) } catch (_) { return failed('relay-invalid-json') }
3620
+ const keys = action === 'prepare' && v && v.verdict === 'prepared' ? ['schema', 'phase', 'verdict', 'revision', 'unresolved', 'reasons', 'nonce', 'manifest', 'conditions', 'reviewers', 'checkpointDigest'] : ['schema', 'phase', 'verdict', 'revision', 'unresolved', 'reasons']
3621
+ if (action === 'evaluate' && v && Object.prototype.hasOwnProperty.call(v, 'checkpointClosure')) keys.push('checkpointClosure')
3622
+ if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).length !== keys.length || Object.keys(v).some(k => keys.indexOf(k) === -1) || v.schema !== REVIEW_CONVERGENCE_SCHEMA || v.phase !== reviewPhase || !Array.isArray(v.unresolved) || !v.unresolved.every(s => typeof s === 'string') || !Array.isArray(v.reasons) || !v.reasons.every(s => typeof s === 'string') || !(v.revision === null || typeof v.revision === 'string' && /^[a-f0-9]{64}$/.test(v.revision))) return failed('relay-invalid-envelope')
3623
+ const expected = v.verdict === 'closed' || v.verdict === 'prepared' ? '0' : v.verdict === 'unresolved' ? '1' : v.verdict === 'not-established' ? '3' : null
3624
+ if (expected === null || match[2] !== expected || action === 'evaluate' && v.verdict === 'prepared' || action === 'prepare' && v.verdict === 'closed') return failed('relay-verdict-exit-conflict')
3625
+ if ((v.verdict === 'closed' || v.verdict === 'prepared') && (!v.revision || v.reasons.length || v.unresolved.length)) return failed('relay-contradictory-success')
3626
+ if (v.verdict === 'prepared' && (typeof v.nonce !== 'string' || !v.nonce || !Array.isArray(v.manifest) || !Array.isArray(v.conditions) || !Array.isArray(v.reviewers) || !(v.checkpointDigest === null || typeof v.checkpointDigest === 'string' && /^[a-f0-9]{64}$/.test(v.checkpointDigest)))) return failed('relay-invalid-snapshot')
3627
+ if (v.checkpointClosure && (v.verdict !== 'closed' || Object.keys(v.checkpointClosure).sort().join(',') !== 'checkpoint,checkpointDigest,revision' || v.checkpointClosure.revision !== v.revision || typeof v.checkpointClosure.checkpointDigest !== 'string' || !/^[a-f0-9]{64}$/.test(v.checkpointClosure.checkpointDigest) || !v.checkpointClosure.checkpoint || typeof v.checkpointClosure.checkpoint !== 'object')) return failed('relay-invalid-checkpoint-closure')
3628
+ return v
3629
+ }
3630
+ async function reviewConvergenceGate(action, reviewPhase, reviewers) {
3631
+ const command = reviewConvergenceCommand(action, reviewPhase, reviewers)
3632
+ const raw = await dispatchAgent(newRung(), 'Run EXACTLY this via Bash. Return ONLY its stdout, including RC_EXIT, with no fences, narration, or synthesized result:\n' + command, { label: 'review-convergence:' + reviewPhase + ':' + action, phase: reviewPhase === 'ideation' ? 'Design' : 'QE', effort: 'low' })
3633
+ return parseReviewConvergenceRelay(raw, reviewPhase, action)
3634
+ }
3635
+ function reviewConvergencePrompt(reviewPhase, reviewerId, family, prepared) {
3636
+ if (prepared && Array.isArray(prepared.reviewers)) {
3637
+ const actual = prepared.reviewers.find(r => r.id === reviewerId && r.family === family) || prepared.reviewers.find(r => r.id === reviewerId + ':' + family && r.family === family)
3638
+ if (actual) reviewerId = actual.id
3639
+ }
3640
+ const identity = reviewerId === 'qcsd' ? 'Coordinate the actual qcsd-quality, qcsd-risk, qcsd-testability independent reviewers; each must write and verify only its own entry. The coordinator cannot manufacture their receipts from grades or report prose. Use the host role for each base id with actual family ' + family + ' (a retained other-family owner remains a separate required reviewer)' : 'Your originating independent role is ' + reviewerId + ', actual family ' + family
3641
+ return ' REVIEW CONVERGENCE: read ' + FDIR + '/.fa-state/review-convergence-' + reviewPhase + '-host.json and the installed feature-adr module receipt contract. ' + identity + '. Preserve every prior own condition with identical meaning/ID and every serious finding. Review the complete measured manifest at its revision/nonce; if any required input is missing or moving, report not-established rather than inventing approval. Write your own structured review entry into ' + FDIR + '/.fa-state/review-convergence-' + reviewPhase + '-review.json, preserving other reviewer entries and the actual author handoff. Never infer closure from grade. Each entry must contain reviewer, family, independent:true, verdict, conditions, verifications, explicit newRisks assessment/evidence/conditions, authorAssessmentChecked, and deltaVerification (null on initial review; independent classification/evidence/implementationVerified on rework). If your agent/transport fails after writing, your existing own entry remains original evidence: the host imports it before any family transition. Only verify your own conditions at the current revision; structural changes require implementation/test verification. The host gate will refuse missing/stale evidence. If a host snapshot is unavailable, preserve provisional findings in the ordinary report and name the repair. Do not call prepare or edit host lineage.'
3642
+ }
3643
+ function reviewConvergenceRefusal(reviewPhase, verdict, evidence) {
3644
+ return { phase: 'review-convergence-' + reviewPhase, outcome: 'unverified', slug: SLUG, artifactsDir: FDIR, convergence: verdict, reviewerEvidence: evidence || null, note: 'Review closure is ' + verdict.verdict + ': ' + verdict.reasons.join(', ') + '. Host-driven repair: settle current artifacts; invoke the installed check-review-convergence.mjs prepare --repo <repo> --feature <feature-dir> --phase ' + reviewPhase + ' --reviewers <the selected actual base-role families JSON; host retains originated owner roles>; provide an author delta/addressed IDs/evidence/explicit new-risk assessment; have each originating independent reviewer verify its own conditions and actual structural/wording delta at the host revision/nonce, writing the reviewer receipt named in the Step ' + (reviewPhase === 'ideation' ? '3.5' : '8') + ' module; run evaluate with the same repo/feature/phase; re-invoke the same slug. Prior conditions stay in the host checkpoint. pending-reviewer-receipt-needs-origin-review pauses before fallback dispatch: keep the original receipt, have its originating reviewer repair malformed/foreign fields or refresh unknown stale findings at the unchanged host identity/current snapshot, then repeat prepare for the selected actual family. Valid partial own entries are retained without claiming closure; complete the full returned roster and own verifications before evaluate. Do not delete or replace original evidence to bootstrap. For same-slug QE resume, read prepare checkpointDigest and the complete latest qe result in .fa-state/checkpoints.jsonl. Each originating reviewer must additionally write checkpointVerification with that digest, supported:true, route native/mode-b/fallback, independent evidence, findings mapping every original serious gap SHA-256(JSON.stringify(gap)) to its own preserved conditionId, and reportDigest (null except a failed precision append requires SHA-256 of the current complete 08_qe_report.md). Own current structural verifications are required for every mapped finding. The gate binds the complete old checkpoint and current manifest; resume retains historical gaps/grade. A scribe or author cannot produce this reviewer proof. Outstanding serious risks at the round ceiling require owner escalation. No automatic repair loop ran.' }
3645
+ }
3646
+ function currentSeriousReviewFindings(stage) {
3647
+ const serious = []
3648
+ for (const source of [stage && stage.qe, stage && stage.qe2]) {
3649
+ for (const gap of source && Array.isArray(source.gaps) ? source.gaps : []) {
3650
+ const seriousSeverity = [gap.sev, gap.severity, gap.priority].some(value => /(?:^|[^A-Z0-9])(BLOCKER|CRITICAL|HIGH|P0|P1)(?:$|[^A-Z0-9])/.test(String(value || '').toUpperCase()))
3651
+ if (seriousSeverity) serious.push(gap)
3652
+ }
3653
+ }
3654
+ return serious
3655
+ }
3656
+ function historicalReviewClosure(verdict, stage) {
3657
+ return resumedStages.indexOf('qe') !== -1 && verdict.checkpointClosure && verdict.checkpointClosure.revision === verdict.revision && JSON.stringify(verdict.checkpointClosure.checkpoint) === JSON.stringify(stage)
3658
+ }
3659
+ // Review convergence host contract END.
3660
+
3600
3661
  // ── K2 plan-completeness gate (feature fa-plan-gate-wiring) ────────────────────────────────────
3601
3662
  // The coder (Step-7) must NOT start on an incomplete plan. Mirrors of the pure halves in
3602
3663
  // harness-core/src/feature-adr-routing.ts (planCompletenessGateCmd / parsePlanGateVerdict) —
@@ -3938,6 +3999,12 @@ function setDesignProvenance(baseLabel, label) {
3938
3999
  }
3939
4000
 
3940
4001
  async function designStage(promptText, opts, artifactPath, baseLabel) {
4002
+ const convergenceBasePrompt = promptText
4003
+ if (baseLabel === 'qcsd') {
4004
+ const initialConvergence = await reviewConvergenceGate('prepare', 'ideation', ['qcsd-quality', 'qcsd-risk', 'qcsd-testability'].map(id => ({ id: id, family: tpFamily(modelLabel(opts)) })))
4005
+ if (initialConvergence.verdict !== 'prepared') return reviewConvergenceRefusal('ideation', initialConvergence, null)
4006
+ promptText += reviewConvergencePrompt('ideation', 'qcsd', tpFamily(modelLabel(opts)))
4007
+ }
3941
4008
  if (!needsLandedBarrier(opts)) { return await dispatchAgent(newRung(), promptText, opts) }
3942
4009
  const codexOpts = {}
3943
4010
  for (const k in opts) if (k !== 'schema') codexOpts[k] = opts[k]
@@ -3964,8 +4031,14 @@ async function designStage(promptText, opts, artifactPath, baseLabel) {
3964
4031
  // separate sentence, unchanged), but the LINE's reason now comes from the rung's three-valued
3965
4032
  // outcome so no branch here can silently re-flatten it back to "the previous rung ran".
3966
4033
  const designRung = designProbeFailed ? { state: 'probe-failed', reason: null } : designRungHolder
4034
+ // Review convergence design fallback boundary BEGIN.
4035
+ if (baseLabel === 'qcsd') {
4036
+ const fallbackConvergence = await reviewConvergenceGate('prepare', 'ideation', ['qcsd-quality', 'qcsd-risk', 'qcsd-testability'].map(id => ({ id: id, family: 'claude' })))
4037
+ if (fallbackConvergence.verdict !== 'prepared') return reviewConvergenceRefusal('ideation', fallbackConvergence, null)
4038
+ }
3967
4039
  setDesignProvenance(baseLabel, modelLabel(fallbackOpts) + codexFallbackProvenance(designRung, 'codex not-landed', false))
3968
- const fb = await dispatchAgent(newRung(), promptText, mergeOpts({ label: stageLabel((baseLabel || 'design') + ':claude-fb', fallbackOpts), phase: 'Design', schema: ARTIFACT }, fallbackOpts), mergeOpts(fallbackOpts, { _stage: opts._stage, _reason: 'fallback-rung' }))
4040
+ const fb = await dispatchAgent(newRung(), convergenceBasePrompt + (baseLabel === 'qcsd' ? reviewConvergencePrompt('ideation', 'qcsd', 'claude') : ''), mergeOpts({ label: stageLabel((baseLabel || 'design') + ':claude-fb', fallbackOpts), phase: 'Design', schema: ARTIFACT }, fallbackOpts), mergeOpts(fallbackOpts, { _stage: opts._stage, _reason: 'fallback-rung' }))
4041
+ // Review convergence design fallback boundary END.
3969
4042
  // d926ee89: the fallback used to keep CODEX provenance — modelsUsed, the checkpoint label and the
3970
4043
  // training-pair family all still said codex after Claude wrote the artifact. The WRITER is the
3971
4044
  // provenance; overwrite it here, at the one place that knows the fallback fired.
@@ -4670,6 +4743,14 @@ if (registryOutcome !== 'unverified') registryOutcome = designIncompleteOutcome
4670
4743
  return { tier: tier, phase: 'design-incomplete', outcome: designIncompleteOutcome, slug: SLUG, artifactsDir: FDIR, missingSubstages: fanVerdict.missingSubstages, missingArtifacts: fanVerdict.missingArtifacts, reason: fanVerdict.reason, modelsUsed: modelsUsed, dispatchOutcomes: dispatchOutcomes, gates: designIncompleteGates, resumedStages: resumedStages, checkpointing: CHECKPOINTS_ON ? RESUME_MODE : 'off', trainingPairs: CAPTURE_PAIRS ? TP_DIR : 'off', captureFailures: captureFailures, recordFailures: recordFailures, decisionRecallFailures: decisionRecallFailures, 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\'.' }
4671
4744
  }
4672
4745
 
4746
+ // Review convergence ideation boundary BEGIN.
4747
+ let ideationConvergence = null
4748
+ if (isMplus) {
4749
+ ideationConvergence = await reviewConvergenceGate('evaluate', 'ideation')
4750
+ if (ideationConvergence.verdict !== 'closed') return reviewConvergenceRefusal('ideation', ideationConvergence, design)
4751
+ }
4752
+ // Review convergence ideation boundary END.
4753
+
4673
4754
  // Step 6: Plan — optionally routed to Codex's top model (opt-in via args.planner='codex').
4674
4755
  // The user opts in at pre-flight ('use the top Codex model for planning?'); we route the Plan step to
4675
4756
  // the codex:codex-rescue runtime and GRACEFULLY FALL BACK to the default (Claude) planner if Codex is
@@ -5438,6 +5519,9 @@ const qe2Spec = qePrecisionPassSpec(PRIMARY, BUDGET_MODE, tier)
5438
5519
  // isolated-scope one, or vice versa) instead of re-QEing under the new setting.
5439
5520
  const qeHash = ckptHash('qe', [fnv1a64(JSON.stringify(codeStage === undefined ? null : codeStage)), tier, DESC, QE_REVIEWER, MODELS.qe === undefined ? null : MODELS.qe, CODEX_MODEL, coderUsed, PRIMARY, BUDGET_MODE, qe2Spec, POLY.hasManifest, fnv1a64(String(POLY.report || '')), usageOverride, QE_SCOPE, QE_SCOPE_REF, confirmationFileGate, QE_ISOLATED_SCOPE])
5440
5521
  let crossFamilyQeReport = null
5522
+ const qeConvergenceReviewers = [{ id: 'qe-primary', family: tpFamily(modelLabel(qeModel)) }]
5523
+ if (qe2Spec) qeConvergenceReviewers.push({ id: 'qe-precision', family: 'claude' })
5524
+ let qeConvergencePrepared = await reviewConvergenceGate('prepare', 'qe', qeConvergenceReviewers)
5441
5525
  const qeStage = await withCheckpoint('qe', 'QE', qeHash, async () => {
5442
5526
  let qe = null
5443
5527
  let qeReviewerUsed = 'claude'
@@ -5456,13 +5540,17 @@ if (!qeIsCodex) {
5456
5540
  // entry that does not say which family reviewed, and it correctly rejected the first shape of this
5457
5541
  // fix. The SAME verdict object then serves the success branch, so there is still one call here.
5458
5542
  const cfCl = crossFamilyQe({ requestedSpec: modelLabel(qeModel), actualLabel: modelLabel(qeClaudeOpts), coderFamily: tpFamily(coderUsed), reviewerFamily: 'claude', declineReason: null })
5543
+ qeConvergencePrepared = await reviewConvergenceGate('prepare', 'qe', [{ id: 'qe-primary', family: 'claude' }].concat(qe2Spec ? [{ id: 'qe-precision', family: 'claude' }] : []))
5544
+ if (qeConvergencePrepared.verdict !== 'prepared') return reviewConvergenceRefusal('qe', qeConvergencePrepared, qe)
5459
5545
  modelsUsed.qe = cfCl.label
5460
- qe = await dispatchAgent(newRung(), qePrompt, qeClaudeOpts)
5546
+ qe = await dispatchAgent(newRung(), qePrompt + reviewConvergencePrompt('qe', 'qe-primary', 'claude', qeConvergencePrepared), qeClaudeOpts)
5461
5547
  if (qe) { qeReviewerUsed = 'claude'; crossFamilyQeReport = cfCl.report }
5462
5548
  else modelsUsed.qe = cfCl.label + ' (no deliverable)'
5463
5549
  }
5464
5550
  if (qe === null && !qeIsCodex) reactiveBelt('QE')
5465
5551
  if (qe === null && (qeIsCodex || QE_REVIEWER === 'codex-fallback')) {
5552
+ qeConvergencePrepared = await reviewConvergenceGate('prepare', 'qe', [{ id: 'qe-primary', family: 'codex' }].concat(qe2Spec ? [{ id: 'qe-precision', family: 'claude' }] : []))
5553
+ if (qeConvergencePrepared.verdict !== 'prepared') return reviewConvergenceRefusal('qe', qeConvergencePrepared, qe)
5466
5554
  if (QE_REVIEWER === 'codex-fallback' && !qeIsCodex) log('QE: Claude unavailable (limit?) — falling back to Codex ' + CODEX_MODEL)
5467
5555
  // R5-2: reaching this branch with !qeIsCodex means the cross-family rule had routed QE to Claude
5468
5556
  // and that FIRST rung returned null. This dispatch is therefore a fallback AND — with a codex coder
@@ -5663,7 +5751,7 @@ if (qe === null && (qeIsCodex || QE_REVIEWER === 'codex-fallback')) {
5663
5751
  const modeBRungHolder = newRung()
5664
5752
  qeLastRungHolder = modeBRungHolder
5665
5753
  const modeBLabelOpts = mergeOpts(qeCodexLabelOpts, { _reason: 'fallback-rung' })
5666
- codexQe = await codexExecAgent('qe', modeBPrompt + CODEX_HINT + codexEffortHint(modeBLabelOpts), 'QE', true, modeBFiles, modeBLabelOpts, modeBRungHolder)
5754
+ codexQe = await codexExecAgent('qe', modeBPrompt + reviewConvergencePrompt('qe', 'qe-primary', 'codex', qeConvergencePrepared) + CODEX_HINT + codexEffortHint(modeBLabelOpts), 'QE', true, modeBFiles, modeBLabelOpts, modeBRungHolder)
5667
5755
  if (codexQe === null) lastCodexDecline = 'mode A: ' + String(modeADecline) + ' | mode B: ' + String(lastCodexDecline)
5668
5756
  }
5669
5757
  }
@@ -5672,6 +5760,7 @@ if (qe === null && (qeIsCodex || QE_REVIEWER === 'codex-fallback')) {
5672
5760
  // STATED by the reviewer or DERIVED from its findings, because mode A cannot be asked for one.
5673
5761
  qe = enforceConfirmationFileGate({ grade: codexQe.grade, gaps: codexQe.findings, codeTestsAdequate: null, docTestsPresent: null, summary: String(codexQe.text).slice(0, 1500), gradeSource: codexQe.gradeSource, qeScope: { mode: codexQe.mode, ref: codexQe.scopeRef, files: codexQe.files } }, confirmationFileGate)
5674
5762
  qeReviewerUsed = qeIsCodex ? 'codex' : 'codex-fallback'
5763
+ if (codexQe.mode === 'A') qe = mergeOpts(qe, { convergenceUnavailable: 'read-only Codex mode A cannot accept the reviewer contract; host-driven supported review required' })
5675
5764
  log('QE: cross-family review by codex, mode ' + codexQe.mode + ' (scope ' + codexQe.scopeRef + ', grade ' + codexQe.grade + ' ' + codexQe.gradeSource + ', ' + codexQe.elapsedSeconds + 's)')
5676
5765
  // ARTIFACT SCRIBE. The old dispatch handed Codex the whole Step-8 prompt, so the reviewer itself
5677
5766
  // was asked to write 08_qe_report.md and close the teach loop. Neither mode can be asked that
@@ -5735,8 +5824,10 @@ if (qe === null && qeIsCodex) {
5735
5824
  // belt that returned null left the preceding Codex label standing and omitted the Claude dispatch
5736
5825
  // that had just been announced. The same verdict object still serves the success branch.
5737
5826
  const cfBelt = crossFamilyQe({ requestedSpec: modelLabel(qeModel), actualLabel: modelLabel(qeBeltOpts), coderFamily: tpFamily(coderUsed), reviewerFamily: 'claude', declineReason: lastCodexDecline })
5827
+ qeConvergencePrepared = await reviewConvergenceGate('prepare', 'qe', [{ id: 'qe-primary', family: 'claude' }].concat(qe2Spec ? [{ id: 'qe-precision', family: 'claude' }] : []))
5828
+ if (qeConvergencePrepared.verdict !== 'prepared') return reviewConvergenceRefusal('qe', qeConvergencePrepared, qe)
5738
5829
  modelsUsed.qe = cfBelt.label
5739
- qe = await dispatchAgent(newRung(), qePrompt, qeBeltOpts)
5830
+ qe = await dispatchAgent(newRung(), qePrompt + reviewConvergencePrompt('qe', 'qe-primary', 'claude', qeConvergencePrepared), qeBeltOpts)
5740
5831
  if (qe) { qeReviewerUsed = 'claude'; crossFamilyQeReport = cfBelt.report }
5741
5832
  else modelsUsed.qe = cfBelt.label + ' (no deliverable)'
5742
5833
  }
@@ -5745,6 +5836,8 @@ qe = enforceConfirmationFileGate(qe, confirmationFileGate)
5745
5836
  // independent precision pass. It is advisory but real — never a table-only half-wire — and its
5746
5837
  // provenance stays separate in both the return object and 08_qe_report.md.
5747
5838
  let qe2 = null
5839
+ // Record the primary's conditions before the precision reviewer can append its receipt.
5840
+ await reviewConvergenceGate('evaluate', 'qe')
5748
5841
  if (qe !== null && qe2Spec !== null) {
5749
5842
  const qe2Model = specToOpts(qe2Spec)
5750
5843
  const primaryGrade = String(qe.grade || '').trim().toUpperCase()
@@ -5764,7 +5857,7 @@ if (qe !== null && qe2Spec !== null) {
5764
5857
  // R13-2: claimed at DISPATCH. The run completes on the primary verdict even when this reviewer
5765
5858
  // returns null, so a success-only write let the report silently omit a reviewer it had announced.
5766
5859
  modelsUsed.qe2 = modelLabel(qe2Model)
5767
- qe2 = await dispatchAgent(newRung(), qe2Prompt, qe2Opts)
5860
+ qe2 = await dispatchAgent(newRung(), qe2Prompt + reviewConvergencePrompt('qe', 'qe-precision', 'claude'), qe2Opts)
5768
5861
  if (!qe2) modelsUsed.qe2 = modelLabel(qe2Model) + ' (no deliverable)'
5769
5862
  if (qe2) {
5770
5863
  const qe2After = await qe2ReportState('qe:precision-after')
@@ -5791,6 +5884,20 @@ return { qe: qe, qeReviewerUsed: qeReviewerUsed, modelUsed: modelsUsed.qe, qe2:
5791
5884
  }, { validate: function (r) { return !!(r && typeof r === 'object' && r.qe && typeof r.qe === 'object' && typeof r.qeReviewerUsed === 'string' && (r.bridge == null || (typeof r.bridge === 'object' && !Array.isArray(r.bridge)))) } })
5792
5885
  qe = qeStage ? qeStage.qe : null
5793
5886
  let qeReviewerUsed = qeStage ? qeStage.qeReviewerUsed : 'claude'
5887
+ // Review convergence QE boundary BEGIN. Runs for live and resumed checkpoints.
5888
+ let qeConvergence = null
5889
+ {
5890
+ qeConvergence = await reviewConvergenceGate('evaluate', 'qe')
5891
+ const serious = currentSeriousReviewFindings(qeStage)
5892
+ const historicalClosure = historicalReviewClosure(qeConvergence, qeStage)
5893
+ const confirmationClosed = confirmationFileGate && (confirmationFileGate.verdict === 'pass' || confirmationFileGate.verdict === 'skipped')
5894
+ const routeMissing = !qeStage || !qe || qe.convergenceUnavailable || (qe2Spec && (!qeStage.qe2 || !qeStage.qe2.reportWritten))
5895
+ if (qeConvergencePrepared.verdict !== 'prepared' || qeConvergence.verdict !== 'closed' || !confirmationClosed || (!historicalClosure && (routeMissing || serious.length > 0))) {
5896
+ const reasons = qeConvergence.reasons.concat(routeMissing ? [String(qe && qe.convergenceUnavailable || 'reviewer-route-or-precision-evidence-not-established')] : [], serious.length ? ['current-serious-review-findings-remain'] : [], qeConvergencePrepared.verdict !== 'prepared' ? qeConvergencePrepared.reasons : [], !confirmationClosed ? ['confirmation-file-gate-not-closed'] : [])
5897
+ return reviewConvergenceRefusal('qe', Object.assign({}, qeConvergence, { verdict: serious.length ? 'unresolved' : 'not-established', reasons: reasons }), qeStage)
5898
+ }
5899
+ }
5900
+ // Review convergence QE boundary END.
5794
5901
  if (qeStage && qeStage.modelUsed) modelsUsed.qe = qeStage.modelUsed + (resumedStages.indexOf('qe') !== -1 ? ' (resumed)' : '')
5795
5902
  if (qeStage && qeStage.qe2ModelUsed) modelsUsed.qe2 = qeStage.qe2ModelUsed + (resumedStages.indexOf('qe') !== -1 ? ' (resumed)' : '')
5796
5903
  if (qeStage && qeStage.bridge == null && resumedStages.indexOf('qe') !== -1) {
@@ -6183,6 +6290,15 @@ function runOutcomeOf(input) {
6183
6290
  // A crashed run cannot classify itself; an external consumer assigns that outcome later.
6184
6291
  return 'unclassified'
6185
6292
  }
6293
+ // Review convergence completion boundary BEGIN.
6294
+ {
6295
+ const terminalConvergence = await reviewConvergenceGate('evaluate', 'qe')
6296
+ const terminalHistorical = historicalReviewClosure(terminalConvergence, qeStage)
6297
+ const terminalRouteMissing = !qeStage || !qe || qe.convergenceUnavailable || (qe2Spec && (!qeStage.qe2 || !qeStage.qe2.reportWritten))
6298
+ const terminalConfirmation = confirmationFileGate && (confirmationFileGate.verdict === 'pass' || confirmationFileGate.verdict === 'skipped')
6299
+ if (terminalConvergence.verdict !== 'closed' || !terminalConfirmation || (!terminalHistorical && (terminalRouteMissing || currentSeriousReviewFindings(qeStage).length > 0))) return reviewConvergenceRefusal('qe', Object.assign({}, terminalConvergence, { verdict: 'not-established', reasons: terminalConvergence.reasons.concat(!terminalConfirmation ? ['confirmation-file-gate-not-closed'] : [], !terminalHistorical && terminalRouteMissing ? ['terminal-reviewer-route-not-established'] : [], !terminalHistorical && currentSeriousReviewFindings(qeStage).length > 0 ? ['terminal-serious-findings-remain'] : []) }), qeStage)
6300
+ }
6301
+ // Review convergence completion boundary END.
6186
6302
  const designEvidence = Array.isArray(design) && design.filter(Boolean).length > 0
6187
6303
  const implementedEvidence = code !== null && code !== undefined && (needsCodeLandedBarrier(coderUsed) ? landingStatus === 'landed' : true)
6188
6304
  const tags = [