@smartmemory/compose 0.3.7 → 0.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.
Files changed (217) hide show
  1. package/.claude/skills/compose/SKILL.md +12 -3
  2. package/.compose-deps.json +51 -25
  3. package/README.md +79 -7
  4. package/bin/compose.js +495 -360
  5. package/bin/judgment-migrate.js +387 -0
  6. package/contracts/comp-obs-contract.schema.json +9 -3
  7. package/contracts/fluid-record.schema.json +209 -0
  8. package/contracts/lifecycle-backfill.schema.json +322 -0
  9. package/dist/assets/App-Z4MU-H_F.js +916 -0
  10. package/dist/assets/{_baseUniq-Bo837sRJ.js → _baseUniq-ClWoCPFl.js} +1 -1
  11. package/dist/assets/{arc-BafGpyqE.js → arc-DY26UIVo.js} +1 -1
  12. package/dist/assets/{architectureDiagram-Q4EWVU46-BOBfUsqL.js → architectureDiagram-Q4EWVU46-6Ggq4DqJ.js} +1 -1
  13. package/dist/assets/{blockDiagram-DXYQGD6D-Dwodev1a.js → blockDiagram-DXYQGD6D-CH3Ked0l.js} +1 -1
  14. package/dist/assets/{browser-1ntj1-x_.js → browser-BWkrenen.js} +1 -1
  15. package/dist/assets/{c4Diagram-AHTNJAMY-CU_bhYag.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  16. package/dist/assets/channel-SnZzzh7k.js +1 -0
  17. package/dist/assets/{chunk-4BX2VUAB-p8WsDwnO.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  18. package/dist/assets/{chunk-4TB4RGXK-B8h7-eR0.js → chunk-4TB4RGXK-JytR14a9.js} +1 -1
  19. package/dist/assets/{chunk-55IACEB6-DxeEr98s.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  20. package/dist/assets/{chunk-EDXVE4YY-BYt8F151.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  21. package/dist/assets/{chunk-FMBD7UC4-DGSOVeie.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  22. package/dist/assets/{chunk-OYMX7WX6-B-QdgYR2.js → chunk-OYMX7WX6-BySQzVxc.js} +1 -1
  23. package/dist/assets/{chunk-QZHKN3VN-Du5UAZLs.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  24. package/dist/assets/{chunk-YZCP3GAM-C8JbNBSk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  25. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  26. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  27. package/dist/assets/clone-DgklGjHm.js +1 -0
  28. package/dist/assets/{cose-bilkent-S5V4N54A-O1ESaqge.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  29. package/dist/assets/{dagre-KV5264BT-CPTmFPHw.js → dagre-KV5264BT-Cp3F5KTn.js} +1 -1
  30. package/dist/assets/{diagram-5BDNPKRD-B3PNrWs5.js → diagram-5BDNPKRD-DiR6_2q_.js} +1 -1
  31. package/dist/assets/{diagram-G4DWMVQ6-Cscfr6vc.js → diagram-G4DWMVQ6-w0i-p5HX.js} +1 -1
  32. package/dist/assets/{diagram-MMDJMWI5-CSfqZ-TM.js → diagram-MMDJMWI5-tIHhwUv3.js} +1 -1
  33. package/dist/assets/{diagram-TYMM5635-Cg4aYS7W.js → diagram-TYMM5635-BAeY3B19.js} +1 -1
  34. package/dist/assets/{erDiagram-SMLLAGMA-_ZqwG5pl.js → erDiagram-SMLLAGMA-Ckx_Knko.js} +1 -1
  35. package/dist/assets/{flowDiagram-DWJPFMVM-C83boxFT.js → flowDiagram-DWJPFMVM-DeoNka6J.js} +1 -1
  36. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWnIjuEi.js → ganttDiagram-T4ZO3ILL-BmGnFbEg.js} +1 -1
  37. package/dist/assets/{gitGraphDiagram-UUTBAWPF-DrMdxZfH.js → gitGraphDiagram-UUTBAWPF-Dk48IHsx.js} +1 -1
  38. package/dist/assets/{graph-RE4I7Ty7.js → graph-BNzKGvoy.js} +1 -1
  39. package/dist/assets/{graph-Bi99_6Yf.js → graph-CI_1htl0.js} +1 -1
  40. package/dist/assets/{index-Rm2RE-c0.js → index-BEfrNBp8.js} +3 -3
  41. package/dist/assets/index-yyrA5OZd.css +1 -0
  42. package/dist/assets/{infoDiagram-42DDH7IO-BLmP4Epr.js → infoDiagram-42DDH7IO-BRf827i0.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-yuWWshKN.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-BOfhaJov.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-Bbolde15.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +1 -1
  46. package/dist/assets/{layout-BSf33zm8.js → layout-BI8cXFPI.js} +1 -1
  47. package/dist/assets/{linear-AvSTWMqx.js → linear-a0glcDiw.js} +1 -1
  48. package/dist/assets/{min-QBM8H4xN.js → min-vPHfnXcC.js} +1 -1
  49. package/dist/assets/{mindmap-definition-QFDTVHPH-BuvgtqIc.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +1 -1
  50. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  51. package/dist/assets/{pieDiagram-DEJITSTG-DIzF16vh.js → pieDiagram-DEJITSTG-Cno-gETh.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-D-mbUIjS.js → quadrantDiagram-34T5L4WZ-BUQM1Hfm.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-CEs4kCLd.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-DFsnCr9n.js → sankeyDiagram-XADWPNL6-Crynd3_b.js} +1 -1
  55. package/dist/assets/{sequenceDiagram-FGHM5R23-BEJYdTjQ.js → sequenceDiagram-FGHM5R23-D9fZdCM8.js} +1 -1
  56. package/dist/assets/{stateDiagram-FHFEXIEX-BBXs57uY.js → stateDiagram-FHFEXIEX-CW9qVec8.js} +1 -1
  57. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  58. package/dist/assets/{timeline-definition-GMOUNBTQ-BGvLoVAY.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +1 -1
  59. package/dist/assets/{vennDiagram-DHZGUBPP-9LaBTMe0.js → vennDiagram-DHZGUBPP-BfytJcWk.js} +1 -1
  60. package/dist/assets/{wardley-RL74JXVD-P4MEqMTP.js → wardley-RL74JXVD-DLj-IjyB.js} +1 -1
  61. package/dist/assets/{wardleyDiagram-NUSXRM2D-o-tmxnlC.js → wardleyDiagram-NUSXRM2D-Ds0Ue68c.js} +1 -1
  62. package/dist/assets/{xychartDiagram-5P7HB3ND-Dpn7V6qk.js → xychartDiagram-5P7HB3ND-vjWDXFL6.js} +1 -1
  63. package/dist/index.html +3 -3
  64. package/lib/agent-string.js +7 -5
  65. package/lib/append-integrity.js +81 -0
  66. package/lib/backfill-evidence.js +109 -0
  67. package/lib/bug-escalation.js +9 -0
  68. package/lib/build-stream-schema.js +3 -1
  69. package/lib/build-stream-writer.js +25 -0
  70. package/lib/build.js +874 -170
  71. package/lib/canon-guard.js +28 -6
  72. package/lib/canon-override.js +196 -0
  73. package/lib/canon-registry.js +104 -0
  74. package/lib/cli-commands.js +144 -0
  75. package/lib/codex-preflight.js +26 -13
  76. package/lib/colleague/context.js +215 -0
  77. package/lib/colleague/writeback.js +95 -0
  78. package/lib/completion-gate.js +1421 -0
  79. package/lib/completion-writer.js +47 -47
  80. package/lib/consumer-fanout.js +105 -11
  81. package/lib/coverage-gate.js +200 -0
  82. package/lib/deps.js +164 -7
  83. package/lib/dir-lock.js +170 -0
  84. package/lib/dispatch-ledger.js +3 -3
  85. package/lib/feature-json.js +1 -1
  86. package/lib/feature-reconciler.js +8 -0
  87. package/lib/feature-validator.js +64 -1
  88. package/lib/feature-writer.js +57 -2
  89. package/lib/fluid/factory.js +167 -0
  90. package/lib/fluid/ideabox-dates.js +73 -0
  91. package/lib/fluid/ideabox-migrate.js +154 -0
  92. package/lib/fluid/ideabox-ops.js +585 -0
  93. package/lib/fluid/ideabox-view.js +146 -0
  94. package/lib/fluid/import-ideabox.js +186 -0
  95. package/lib/fluid/local-provider.js +606 -0
  96. package/lib/fluid/provider.js +684 -0
  97. package/lib/fluid/record-shape.js +214 -0
  98. package/lib/fluid/record-store.js +328 -0
  99. package/lib/fluid/render-ideabox.js +261 -0
  100. package/lib/fluid/schema.js +40 -0
  101. package/lib/fluid/smartmemory-provider.js +1695 -0
  102. package/lib/gsd.js +63 -23
  103. package/lib/guard-cli.js +175 -0
  104. package/lib/guard-custody.js +141 -0
  105. package/lib/guard-descriptors.js +530 -0
  106. package/lib/guard-enrol.js +254 -0
  107. package/lib/health-score.js +1 -1
  108. package/lib/ideabox-cli.js +315 -0
  109. package/lib/ideabox.js +121 -21
  110. package/lib/judgment/store/index.js +9 -1
  111. package/lib/judgment/store/records.js +1 -1
  112. package/lib/judgment/trace.js +380 -0
  113. package/lib/judgment-decision-write.js +277 -0
  114. package/lib/judgment-decisions.js +466 -0
  115. package/lib/judgment-gen.js +5 -1
  116. package/lib/judgment-writer.js +56 -2
  117. package/lib/lifecycle-modes.js +4 -4
  118. package/lib/lineage.js +400 -0
  119. package/lib/local-claude-connector.js +52 -1
  120. package/lib/maya-client.js +302 -0
  121. package/lib/maya-config.js +53 -0
  122. package/lib/maya-identity.js +283 -0
  123. package/lib/migrate-anon.js +5 -0
  124. package/lib/migrate-roadmap.js +15 -0
  125. package/lib/new.js +13 -1
  126. package/lib/pipeline-compat.js +104 -0
  127. package/lib/policy-catalog.js +295 -0
  128. package/lib/policy-check.js +0 -0
  129. package/lib/process-termination.js +98 -0
  130. package/lib/resolve-workspace.js +5 -1
  131. package/lib/result-normalizer.js +396 -199
  132. package/lib/roadmap-errors.js +65 -0
  133. package/lib/roadmap-preservers.js +24 -4
  134. package/lib/roadmap-residue.js +299 -0
  135. package/lib/smartmemory-client.js +614 -78
  136. package/lib/smartmemory-config.js +54 -0
  137. package/lib/smartmemory-ingest.js +19 -2
  138. package/lib/step-prompt.js +7 -6
  139. package/lib/stratum-engine.js +53 -4
  140. package/lib/stratum-mcp-client.js +271 -36
  141. package/lib/test-bootstrap.js +31 -0
  142. package/lib/tool-inventory.js +122 -0
  143. package/lib/version-check.js +91 -19
  144. package/lib/vision-writer.js +88 -1
  145. package/package.json +7 -6
  146. package/pipelines/bug-fix.stratum.yaml +205 -211
  147. package/pipelines/build-quick.profiles.json +12 -0
  148. package/pipelines/build-quick.stratum.yaml +263 -350
  149. package/pipelines/content.stratum.yaml +81 -77
  150. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  151. package/pipelines/plan.stratum.yaml +76 -86
  152. package/pipelines/refactor.stratum.yaml +125 -125
  153. package/pipelines/research.stratum.yaml +56 -58
  154. package/pipelines/review-fix.profiles.json +6 -0
  155. package/pipelines/review-fix.stratum.yaml +110 -83
  156. package/presets/team-feature.profiles.json +6 -0
  157. package/presets/team-feature.stratum.yaml +93 -66
  158. package/presets/team-research.profiles.json +6 -0
  159. package/presets/team-research.stratum.yaml +89 -80
  160. package/presets/team-review.profiles.json +8 -0
  161. package/presets/team-review.stratum.yaml +98 -80
  162. package/scripts/cost-census.mjs +70 -0
  163. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  164. package/server/agent-health.js +22 -0
  165. package/server/agent-hooks.js +14 -1
  166. package/server/agent-server.js +5 -248
  167. package/server/agent-spawn.js +3 -4
  168. package/server/agent-workspace.js +294 -0
  169. package/server/build-routes.js +6 -5
  170. package/server/build-stream-bridge.js +53 -0
  171. package/server/cc-session-watcher.js +4 -1
  172. package/server/coalescing-buffer.js +7 -1
  173. package/server/completion-projection.js +228 -0
  174. package/server/compose-mcp-tools.js +109 -23
  175. package/server/compose-mcp.js +88 -882
  176. package/server/decision-event-emit.js +41 -2
  177. package/server/decision-event-id.js +17 -0
  178. package/server/decision-events-snapshot.js +3 -0
  179. package/server/design-routes.js +14 -8
  180. package/server/feature-scan.js +76 -2
  181. package/server/file-watcher.js +170 -21
  182. package/server/ideabox-routes.js +166 -224
  183. package/server/index.js +70 -100
  184. package/server/lifecycle-guard.js +240 -10
  185. package/server/lifecycle-phase-history.js +276 -0
  186. package/server/maya-routes.js +507 -0
  187. package/server/mcp-tool-defs.js +940 -0
  188. package/server/mcp-tool-policy.js +34 -2
  189. package/server/model-tiers.js +22 -5
  190. package/server/pipeline-routes.js +21 -11
  191. package/server/project-root.js +58 -19
  192. package/server/remote-utils.js +3 -1
  193. package/server/schema-validator.js +7 -1
  194. package/server/session-manager.js +5 -6
  195. package/server/session-routes.js +3 -1
  196. package/server/stratum-client.js +57 -10
  197. package/server/stratum-sync.js +6 -3
  198. package/server/summarizer.js +3 -4
  199. package/server/supervisor.js +0 -1
  200. package/server/vision-routes.js +208 -98
  201. package/server/vision-server.js +86 -23
  202. package/server/vision-store.js +60 -6
  203. package/server/vision-utils.js +3 -4
  204. package/server/workspace-activity.js +18 -0
  205. package/server/workspace-middleware.js +2 -2
  206. package/server/workspace-runtime.js +243 -0
  207. package/server/worktree-gc.js +1 -0
  208. package/dist/assets/App-PkZzHeMj.js +0 -894
  209. package/dist/assets/channel-qVK_qn4E.js +0 -1
  210. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +0 -1
  211. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +0 -1
  212. package/dist/assets/clone-Pu3RyLUh.js +0 -1
  213. package/dist/assets/index-LIwREYgH.css +0 -1
  214. package/dist/assets/mobile-BnXEOE3U.js +0 -17
  215. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +0 -1
  216. package/lib/staleness.js +0 -87
  217. package/server/ideabox-cache.js +0 -77
@@ -0,0 +1,1421 @@
1
+ /**
2
+ * lib/completion-gate.js — COMP-COMPLETION-GATE (slices 1–3).
3
+ *
4
+ * The one door a feature passes through to become COMPLETE.
5
+ *
6
+ * Context: the lifecycle guard has never guarded a real feature. 321 managed
7
+ * features, 31 registered guard resources, zero overlap — every one of the 31
8
+ * is a leaked test fixture. The reason is not that registration is impossible
9
+ * (`guardedTransition` registers lazily); it is that the writers which actually
10
+ * complete features never call a guarded transition at all. See
11
+ * docs/features/COMP-COMPLETION-GATE/design.md §1.
12
+ *
13
+ * SLICE 3 (design.md §2.3a, Decision 8/11): after the guard applies, THIS
14
+ * module performs every write a completion consists of, in this order —
15
+ * completion record → status COMPLETE → ROADMAP regen → vision projection →
16
+ * events. Steps 1–2 are the durable truth and abort the completion if they
17
+ * fail (the write-ahead intent stays, so a retry recovers). Steps 3–5 are
18
+ * projections: re-drivable, so a failure is COLLECTED and reported as
19
+ * `{ok:true, partial:true, failures:[…]}`, never swallowed — silent best-effort
20
+ * is what produced the drift this feature exists to end.
21
+ *
22
+ * `setFeatureStatus` refuses COMPLETE unconditionally (AC-9), so the status
23
+ * write here goes through `persistFeatureRaw` — the policy-free primitive whose
24
+ * contract is now "callers must have passed the gate" (Decision 7; enforced by
25
+ * the allowlist test, test/completion-write-allowlist.test.js). No marker lets
26
+ * another module through: anything this file exported to identify itself could
27
+ * be imported by the callers it exists to refuse.
28
+ *
29
+ * The vision projection is the self-verifying seam of §2.3b
30
+ * (`VisionWriter.completeItem` → `server/completion-projection.js`): it
31
+ * re-reads feature.json and the guard ledger and refuses unless the completion
32
+ * is ALREADY recorded there, so it grants no authority of its own.
33
+ *
34
+ * Three things here are not obvious:
35
+ *
36
+ * 1. **Late registration, always.** No feature is registered today, and headless
37
+ * builds advance phases without the guard, so a mid-lifecycle guard state
38
+ * never exists. The gate registers at the mode's completable phase and takes
39
+ * ONE `→ complete` edge. Walking the full graph instead would refuse 91% of
40
+ * features (only 30 of 321 have design+blueprint+plan), and a gate that
41
+ * refuses nine of ten legitimate completions gets forced — which is how
42
+ * coverage reached zero in the first place. The ledger stamp records the
43
+ * weaker basis so it is never mistaken for lifecycle enforcement.
44
+ *
45
+ * 2. **Recovery is a write-ahead intent, not a derived id.** A derived id
46
+ * (`feature:sha`) identifies the RETRY, not the transition that reached the
47
+ * ledger: commit A applies, the process dies before the record is written, a
48
+ * retry on commit B finds no record for B and would be waved through as
49
+ * "recovery" against a ledger entry that attested A. The intent record is
50
+ * written before the transition and carries an `operation_id` that is also
51
+ * sent as a guard artifact — artifacts feed the payload digest, so two
52
+ * attempts are distinguishable even when both have no commit at all.
53
+ *
54
+ * 3. **The test command runs OUTSIDE the lock.** `spawnSync` blocks the event
55
+ * loop, and a blocked event loop cannot fire the lock's heartbeat timer, so
56
+ * a long test run inside the lock would get the lock declared stale and
57
+ * stolen from a live owner.
58
+ */
59
+
60
+ import { createHash, randomUUID } from 'node:crypto';
61
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
62
+ import path from 'node:path';
63
+
64
+ import { acquireDirLock } from './dir-lock.js';
65
+ import { deriveConfidence, resolveEvidenceRef } from './backfill-evidence.js';
66
+ import {
67
+ completablePhaseOf, genesisOf, getMode, terminalOf, transitionsOf,
68
+ } from './lifecycle-modes.js';
69
+ import {
70
+ applyBackfillUpgrade,
71
+ ensureGuard,
72
+ guardedTransition,
73
+ guardErrorMessage,
74
+ guardErrorType,
75
+ guardTestCommand,
76
+ isGuardError,
77
+ resourceId,
78
+ verifyCompletionEvidence,
79
+ verifyCompletionEvidenceAsync,
80
+ } from '../server/lifecycle-guard.js';
81
+ import { custodyBackend } from './guard-custody.js';
82
+ import { insertBackfilledPhases, occurrenceKey } from '../server/lifecycle-phase-history.js';
83
+
84
+ /** Statuses with no legal outgoing transition — completing one is a policy violation. */
85
+ const TERMINAL_STATUSES = new Set(['KILLED', 'SUPERSEDED']);
86
+
87
+ /** The sentinel `completion-writer` stamps for a commit-less (non-git) completion. */
88
+ const NULL_SHA = '0'.repeat(40);
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Config
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Read `capabilities.guard` from the SERVED workspace root — not the process
96
+ * global. A gate invoked over the CLI with `--workspace` must judge the guard by
97
+ * the tree it is actually completing in.
98
+ */
99
+ export function guardEnabled(workspaceRoot) {
100
+ try {
101
+ const cfg = JSON.parse(
102
+ readFileSync(path.join(workspaceRoot, '.compose', 'compose.json'), 'utf-8'),
103
+ );
104
+ return cfg?.capabilities?.guard === true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Write-ahead intent (design.md §2.4a)
112
+ // ---------------------------------------------------------------------------
113
+
114
+ function intentPath(workspaceRoot, featureCode) {
115
+ return path.join(
116
+ workspaceRoot, '.compose', 'data', 'completion-intents', `${featureCode}.json`,
117
+ );
118
+ }
119
+
120
+ export function readIntent(workspaceRoot, featureCode) {
121
+ const p = intentPath(workspaceRoot, featureCode);
122
+ try {
123
+ return JSON.parse(readFileSync(p, 'utf-8'));
124
+ } catch {
125
+ // Missing is the common case; malformed is treated the same way — an intent
126
+ // we cannot read cannot authorize a recovery, and §2.4a's "no intent + guard
127
+ // complete" row already fails closed.
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function writeIntent(workspaceRoot, featureCode, record) {
133
+ const p = intentPath(workspaceRoot, featureCode);
134
+ mkdirSync(path.dirname(p), { recursive: true });
135
+ const tmp = `${p}.tmp`;
136
+ writeFileSync(tmp, JSON.stringify(record, null, 2));
137
+ // Rename so a crash mid-write never leaves a half-parsed intent behind.
138
+ writeFileSync(p, readFileSync(tmp));
139
+ rmSync(tmp, { force: true });
140
+ }
141
+
142
+ function clearIntent(workspaceRoot, featureCode) {
143
+ try { rmSync(intentPath(workspaceRoot, featureCode), { force: true }); } catch { /* best-effort */ }
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Guard state
148
+ // ---------------------------------------------------------------------------
149
+
150
+ let _history = null;
151
+ /** @internal test seam */
152
+ export function _testOnly_setHistoryClient(fn) { _history = fn; }
153
+ /** @internal test seam */
154
+ export function _testOnly_resetHistoryClient() { _history = null; }
155
+
156
+ /**
157
+ * The guard's current state for this resource, or null when it has never been
158
+ * registered. Distinguishes "not found" (legacy/unregistered — the normal case)
159
+ * from every other failure, which must fail CLOSED: an unreachable guard that
160
+ * silently reads as "unregistered" would be a bypass wearing the legacy rule as
161
+ * a disguise.
162
+ *
163
+ * @returns {Promise<{state: string|null, error?: object}>}
164
+ */
165
+ export async function currentGuardState(rid) {
166
+ const client = _history || (await import('../server/stratum-client.js')).guardHistory;
167
+ let res;
168
+ try {
169
+ res = await client(rid);
170
+ } catch (e) {
171
+ return { state: null, error: { code: 'GUARD_UNREACHABLE', message: e.message } };
172
+ }
173
+ if (res && (res.error || res.status === 'error')) {
174
+ const err = res.error || res;
175
+ // `error_type` is the field the REAL client returns (server/stratum-client.js
176
+ // parses stratum's canonical `{status:'error', error_type, message}`); `code`
177
+ // and `kind` are the shapes injected by _testOnly_setHistoryClient. Reading
178
+ // only the latter two made the not-found branch below DEAD against the real
179
+ // producer: every unregistered feature refused with "guard unreachable",
180
+ // which is every feature that never ran a lifecycle. Found 2026-08-24 trying
181
+ // to complete COMP-COVERAGE-GATE; see the real-shape test in
182
+ // test/completion-gate.test.js.
183
+ const code = String(err.code || err.kind || err.error_type || '');
184
+ // Never registered — the expected state for every feature today. The
185
+ // message fallback applies ONLY when no code was returned at all: a SPAWN
186
+ // failure whose message happens to say "stratum-mcp: command not found"
187
+ // must not read as a legacy feature (found by the slice-3 fail-closed test).
188
+ if (/not_found|NOT_FOUND|GuardNotFound/.test(code)
189
+ || (!code && /not found|no guard registered/i.test(err.message || ''))) {
190
+ return { state: null };
191
+ }
192
+ return { state: null, error: err };
193
+ }
194
+ return { state: res?.current_state ?? null };
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // The gate
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /**
202
+ * Verify a feature may complete, record it through the guard, and write it.
203
+ *
204
+ * FAIL-CLOSED throughout: evidence that does not verify, a guard that refuses,
205
+ * and a guard that cannot be reached all return `{ok:false}` with nothing
206
+ * written. Guard *disabled* is different from guard *unreachable* — the first
207
+ * still runs the evidence check (it is compose-local and needs no stratum), the
208
+ * second refuses.
209
+ *
210
+ * @param {object} a
211
+ * @param {string} a.featureCode
212
+ * @param {string} [a.commitSha] omitted ⇒ commit-less (non-git) completion
213
+ * @param {boolean} [a.testsPass] explicit attestation; ignored when a testCommand attests
214
+ * @param {string[]} [a.filesChanged]
215
+ * @param {string} [a.notes]
216
+ * @param {boolean} [a.force]
217
+ * @param {string} [a.builtVia] preserved — the build-quick validator exemption reads it
218
+ * @param {string} [a.idempotencyKey]
219
+ * @param {string} a.workspaceRoot provider, capabilities, guard resourceId, feature.json
220
+ * @param {string} [a.evidenceRoot] git + tests; defaults to workspaceRoot (single-repo case)
221
+ * @param {string} [a.mode]
222
+ * @param {'complete'|'evidence-only'} [a.intent]
223
+ * @param {string} [a.visionItemId] the item to project; resolved via findFeatureItem when absent
224
+ * @param {Function} [a.visionProjector] transport override for §2.3a step 4 — the server passes an
225
+ * in-process projector against its live store; default is
226
+ * VisionWriter.completeItem (REST when a server is up, direct
227
+ * file write otherwise)
228
+ * @returns {Promise<object>}
229
+ */
230
+ export async function completionGate({
231
+ featureCode,
232
+ commitSha,
233
+ testsPass,
234
+ filesChanged = [],
235
+ notes,
236
+ force,
237
+ builtVia,
238
+ idempotencyKey,
239
+ workspaceRoot,
240
+ evidenceRoot,
241
+ mode = 'build',
242
+ intent = 'complete',
243
+ visionItemId,
244
+ visionProjector,
245
+ // --- COMP-LIFECYCLE-BACKFILL (intent:'backfill' only) --------------------
246
+ reason,
247
+ occurrences,
248
+ item,
249
+ store,
250
+ actor,
251
+ }) {
252
+ if (!featureCode) throw new Error('completion-gate: featureCode is required');
253
+ if (!workspaceRoot) throw new Error('completion-gate: workspaceRoot is required');
254
+
255
+ // Backfill is a PARAMETERISATION of this gate, not a sibling door (§5). It
256
+ // shares the lock, the intent file, the write sequence and the refusal
257
+ // vocabulary; what differs is that its evidence is historical, so it runs the
258
+ // ASYNC evidence runner INSIDE the lock (§5.2) and merges by valid time.
259
+ if (intent === 'backfill') {
260
+ return backfillGate({
261
+ featureCode, commitSha, testsPass, filesChanged, notes, reason, occurrences,
262
+ workspaceRoot, evidenceRoot, mode, item, store, visionItemId, visionProjector,
263
+ actor: actor || 'agent:rest',
264
+ });
265
+ }
266
+
267
+ // Two roots. They are equal in the single-repo case, but a cross-repo build
268
+ // runs git and tests in the agent's tree while feature metadata lives in the
269
+ // project tree — verifying the wrong repo's HEAD is silent and wrong.
270
+ const evRoot = evidenceRoot || workspaceRoot;
271
+ const guarded = guardEnabled(workspaceRoot);
272
+ const reasons = [];
273
+
274
+ // --- 1. Evidence, BEFORE the lock (note 3 in the header) -----------------
275
+ //
276
+ // Scoped to `capabilities.guard`, matching the contract `assertCompletionEvidence`
277
+ // already established (server/compose-mcp-tools.js:46). An earlier draft of this
278
+ // design (AC-5) had the evidence check run even with the guard off, on the
279
+ // reasoning that a fabricated SHA is worthless either way. That is true, but
280
+ // `capabilities.guard: false` is a deliberate opt-OUT, and enforcing evidence
281
+ // against a project that opted out is a breaking change for every such project —
282
+ // including non-git workspaces, where the check can never pass at all. Respecting
283
+ // the flag keeps slice 1 non-breaking; strengthening it is its own decision, not
284
+ // a side effect of adding the gate.
285
+ // Guard off: pass the caller's claim through untouched so `recordCompletion`'s
286
+ // own validation still applies (it requires a strict boolean). Coercing an
287
+ // omitted value to `false` here would quietly rewrite the record.
288
+ let attestedTestsPass = testsPass;
289
+ if (guarded) {
290
+ const ev = await verifyCompletionEvidence({
291
+ commitSha,
292
+ cwd: evRoot,
293
+ testCommand: guardTestCommand(workspaceRoot),
294
+ testsPassClaim: testsPass,
295
+ });
296
+ if (!ev.ok) {
297
+ return { ok: false, guarded, refusedAt: 'evidence', reasons: ev.reasons };
298
+ }
299
+ // A configured test command that exited 0 outranks any caller claim; without
300
+ // one, only an explicit `true` counts. There is no silent default.
301
+ attestedTestsPass = ev.testsAttested ? true : testsPass === true;
302
+ }
303
+
304
+ // Evidence-only callers (record_completion with set_status:false) are not
305
+ // completing anything, so they must not drive the guard to a terminal state.
306
+ if (intent === 'evidence-only') {
307
+ return { ok: true, guarded, evidenceOnly: true, attestedTestsPass, reasons: [] };
308
+ }
309
+
310
+ const rid = resourceId(featureCode, workspaceRoot, mode);
311
+ const lockDir = path.join(workspaceRoot, '.compose', 'data', 'locks', `completion-${featureCode}`);
312
+ const release = await acquireDirLock(lockDir);
313
+
314
+ try {
315
+ // --- 2. Preflight ------------------------------------------------------
316
+ // Lazy import: feature-writer ↔ completion-writer ↔ this module form a cycle
317
+ // at load time, and completion-writer already dodges it the same way.
318
+ const { getProvider } = await import('./feature-writer.js');
319
+ const provider = await getProvider(workspaceRoot);
320
+ const feature = await provider.getFeature(featureCode);
321
+ if (!feature) {
322
+ return { ok: false, guarded, refusedAt: 'preflight', reasons: [`feature "${featureCode}" not found`] };
323
+ }
324
+ // Terminal-status legality. The gate writes through a policy-free path, so
325
+ // the policy `setFeatureStatus` enforces (KILLED/SUPERSEDED are terminal)
326
+ // has to be re-asserted here or a killed feature could be completed.
327
+ if (TERMINAL_STATUSES.has(feature.status)) {
328
+ return {
329
+ ok: false, guarded, refusedAt: 'preflight',
330
+ reasons: [`feature "${featureCode}" is ${feature.status} — a terminal status has no legal completion`],
331
+ };
332
+ }
333
+
334
+ // --- 3. Guard state + recovery decision (§2.4a) ------------------------
335
+ const priorIntent = readIntent(workspaceRoot, featureCode);
336
+ let guardState = null;
337
+ if (guarded) {
338
+ const g = await currentGuardState(rid);
339
+ if (g.error) {
340
+ // Configured but unreachable ⇒ refuse. Never degrade to the disabled path.
341
+ return { ok: false, guarded, refusedAt: 'guard', reasons: ['guard unreachable'], error: g.error };
342
+ }
343
+ guardState = g.state;
344
+ }
345
+ const guardComplete = guardState === 'complete';
346
+
347
+ let operationId = randomUUID();
348
+ let recovering = false;
349
+
350
+ if (guardComplete) {
351
+ if (!priorIntent) {
352
+ // The ledger says complete and nothing records why. Not ours to repair.
353
+ return {
354
+ ok: false, guarded, refusedAt: 'recovery',
355
+ reasons: [
356
+ `guard for "${featureCode}" is already complete with no completion intent on record — ` +
357
+ `a prior completion cannot be automatically resumed; operator action required`,
358
+ ],
359
+ };
360
+ }
361
+ recovering = true;
362
+ operationId = priorIntent.operation_id;
363
+ // Same operation ⇒ re-drive the writes. Different ⇒ the ledger attested
364
+ // something else and this is a second completion, not a retry.
365
+ const sameCommit = (priorIntent.commit_sha || null) === (commitSha || null);
366
+ if (!sameCommit) {
367
+ return {
368
+ ok: false, guarded, refusedAt: 'recovery',
369
+ reasons: [
370
+ `guard for "${featureCode}" already completed operation ${priorIntent.operation_id} ` +
371
+ `against commit ${priorIntent.commit_sha || '(none)'}, but this attempt carries ` +
372
+ `${commitSha || '(none)'} — refusing to complete twice against different evidence`,
373
+ ],
374
+ };
375
+ }
376
+ } else if (priorIntent) {
377
+ // Intent with no applied transition: a crash BEFORE the guard. Stale.
378
+ clearIntent(workspaceRoot, featureCode);
379
+ }
380
+
381
+ // --- 4. Write-ahead intent --------------------------------------------
382
+ if (!recovering) {
383
+ writeIntent(workspaceRoot, featureCode, {
384
+ operation_id: operationId,
385
+ feature_code: featureCode,
386
+ commit_sha: commitSha || null,
387
+ tests_attested: attestedTestsPass,
388
+ started_at: new Date().toISOString(),
389
+ });
390
+ }
391
+
392
+ // --- 5. The guarded transition ----------------------------------------
393
+ let ledgerRef;
394
+ if (guarded && !recovering) {
395
+ const completable = completablePhaseOf(mode);
396
+ const reg = await ensureGuard(featureCode, completable, workspaceRoot, mode);
397
+ if (reg && (reg.error || reg.status === 'error')) {
398
+ clearIntent(workspaceRoot, featureCode);
399
+ return { ok: false, guarded, refusedAt: 'guard', reasons: ['guard registration failed'], error: reg.error || reg };
400
+ }
401
+
402
+ // The stamp records what this actually is. Late registration attests the
403
+ // evidence present at completion, never lifecycle history — nothing here
404
+ // knows whether a design doc existed when the work was done.
405
+ // Stratum >= 0.4.0 validates resolved_by strictly as "agent" | "human"
406
+ // (guard/transition.ts) — a tagged resolver like "agent:late-registration"
407
+ // is refused with evidence_parse_error, which is how every completion
408
+ // failed silently after the upgrade. The tags therefore ride in
409
+ // `artifacts`, where they still land in the ledger's payload digest.
410
+ const tags = ['late-registration'];
411
+ if (!commitSha || commitSha === NULL_SHA) tags.push('no-repo-exemption');
412
+ const resolvedBy = 'agent';
413
+
414
+ const g = await guardedTransition({
415
+ featureCode,
416
+ from: completable,
417
+ to: 'complete',
418
+ workspaceRoot,
419
+ commitSha,
420
+ resolvedBy,
421
+ mode,
422
+ // operation_id rides in the artifacts so it lands in the payload digest —
423
+ // without it two commit-less completions are indistinguishable in the ledger.
424
+ artifacts: { operation_id: operationId, resolver_tags: tags.join('+') },
425
+ });
426
+ if (!g.applied) {
427
+ clearIntent(workspaceRoot, featureCode);
428
+ return {
429
+ ok: false, guarded, refusedAt: 'guard',
430
+ reasons: [
431
+ (g.refused ? 'completion refused by guard' : 'guard transition failed')
432
+ + (g.error?.message ? `: ${g.error.message}` : ''),
433
+ ],
434
+ verdict: g.verdict, error: g.error,
435
+ };
436
+ }
437
+ ledgerRef = g.ledgerRef;
438
+ }
439
+
440
+ // --- 6. The write sequence (§2.3a) — the gate is the single COMPLETE writer
441
+ const { recordCompletion } = await import('./completion-writer.js');
442
+ const { isLocalProvider, roundtripGuard, safeAppendEvent } = await import('./feature-writer.js');
443
+ const failures = [];
444
+
445
+ // 6.1 completion record. `set_status:false` is the record-only path; the
446
+ // writer's completing path is the one that calls THIS function.
447
+ let rec;
448
+ try {
449
+ rec = await recordCompletion(workspaceRoot, {
450
+ feature_code: featureCode,
451
+ ...(commitSha ? { commit_sha: commitSha } : {}),
452
+ tests_pass: attestedTestsPass,
453
+ files_changed: filesChanged,
454
+ ...(notes ? { notes } : {}),
455
+ ...(force ? { force } : {}),
456
+ ...(builtVia ? { built_via: builtVia } : {}),
457
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
458
+ set_status: false,
459
+ });
460
+ } catch (e) {
461
+ // The guard has applied and nothing durable is written: keep the intent so
462
+ // a retry on the same evidence recovers (§2.4a) instead of completing twice.
463
+ return {
464
+ ok: false, guarded, refusedAt: 'write', operationId, ledgerRef, error: e,
465
+ reasons: [`completion record could not be written: ${e.message}`],
466
+ };
467
+ }
468
+
469
+ // 6.2 status → COMPLETE, raw — through the single authorized writer.
470
+ //
471
+ // The re-read and the preparation stay OUTSIDE the catch, exactly where they
472
+ // were before the shared helper existed: a rejected read or a `null` feature
473
+ // is a broken precondition, not a failed write, and it must keep throwing.
474
+ const fresh = await provider.getFeature(featureCode);
475
+ const prepared = prepareCompleteStatus(fresh, commitSha);
476
+ let statusChanged = null;
477
+ if (prepared) {
478
+ try {
479
+ await persistCompleteStatus({
480
+ provider, workspaceRoot, featureCode, updated: prepared.updated,
481
+ });
482
+ } catch (e) {
483
+ return {
484
+ ok: false, guarded, refusedAt: 'write', operationId, ledgerRef, error: e,
485
+ reasons: [`completion recorded but status could not be set: ${e.message}`],
486
+ result: rec,
487
+ };
488
+ }
489
+ statusChanged = { from: prepared.from, to: 'COMPLETE' };
490
+ }
491
+
492
+ // 6.3 ROADMAP regen — projection; collect on failure.
493
+ try {
494
+ await provider.renderRoadmap();
495
+ } catch (e) {
496
+ failures.push({ step: 'roadmap', message: e.message, recover: 'compose roadmap generate' });
497
+ }
498
+
499
+ // 6.4 vision projection — projection; collect on failure.
500
+ let visionProjection = null;
501
+ try {
502
+ const project = visionProjector || defaultVisionProjector;
503
+ visionProjection = await project({
504
+ workspaceRoot, featureCode, visionItemId, commitSha, ledgerRef, mode,
505
+ });
506
+ } catch (e) {
507
+ failures.push({ step: 'vision', message: e.message, recover: 'compose validate --fix' });
508
+ }
509
+
510
+ // 6.5 events — never fails a completion (safeAppendEvent swallows and warns).
511
+ if (statusChanged) {
512
+ await safeAppendEvent(workspaceRoot, {
513
+ tool: 'set_feature_status',
514
+ code: featureCode,
515
+ from: statusChanged.from,
516
+ to: 'COMPLETE',
517
+ reason: 'completion_gate',
518
+ via: 'completion_gate',
519
+ operation_id: operationId,
520
+ ...(commitSha ? { commit_sha: commitSha } : {}),
521
+ ...(ledgerRef ? { ledger_ref: ledgerRef } : {}),
522
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
523
+ });
524
+ }
525
+
526
+ clearIntent(workspaceRoot, featureCode);
527
+ return {
528
+ ok: true, guarded, recovered: recovering, operationId, ledgerRef,
529
+ attestedTestsPass, reasons,
530
+ partial: failures.length > 0, failures, visionProjection,
531
+ // The writer-shaped result every existing caller (MCP tool, CLI, cockpit)
532
+ // returns as-is, so it must carry the whole partial story — not just the
533
+ // ROADMAP half (Codex r1 #3): a vision projection that failed is a
534
+ // completion the cockpit does not show.
535
+ result: {
536
+ ...rec,
537
+ status_changed: statusChanged,
538
+ status_flip_partial: failures.length > 0,
539
+ partial: failures.length > 0,
540
+ failures,
541
+ },
542
+ };
543
+ } finally {
544
+ release();
545
+ }
546
+ }
547
+
548
+ // ---------------------------------------------------------------------------
549
+ // Default vision projector (§2.3a step 4) — VisionWriter.completeItem, which is
550
+ // REST when a server is up and a direct verified file write otherwise.
551
+ // ---------------------------------------------------------------------------
552
+
553
+ async function defaultVisionProjector({
554
+ workspaceRoot, featureCode, visionItemId, commitSha, ledgerRef, mode, store, guarded,
555
+ }) {
556
+ const { VisionWriter } = await import('./vision-writer.js');
557
+ const writer = new VisionWriter(path.join(workspaceRoot, '.compose', 'data'));
558
+ const itemId = visionItemId || (await writer.findFeatureItem(featureCode))?.id;
559
+ // No item is not a failure: paths 1–2 (record_completion) have never carried
560
+ // an item id, and a feature with no cockpit item has nothing to project.
561
+ if (!itemId) return { skipped: true, reason: 'no vision item for feature' };
562
+
563
+ // A caller that owns a LIVE store projects through it (Codex r1 #2/#4).
564
+ //
565
+ // Two defects made this necessary, and both were invisible from the outside.
566
+ // First, with no server up `VisionWriter` falls through to a DIRECT write
567
+ // against a separately loaded disk snapshot; the caller's in-memory item never
568
+ // learns it was completed, and finalization then serializes that stale item
569
+ // straight over the successful projection — the operation clears its intent
570
+ // with the item still reading `in_progress` and carrying no stamp. Second, the
571
+ // writer verifies against LIVE config, so the operation's persisted guard flag
572
+ // never reached the verifier at all; only a hand-wired test callback made
573
+ // §5.10a look wired. Both are fixed by projecting through the store the caller
574
+ // already handed us, with the flag threaded to the projector AND the verifier.
575
+ if (store) {
576
+ const { applyVerifiedProjection } = await import('../server/completion-projection.js');
577
+ return applyVerifiedProjection(store, {
578
+ itemId, featureCode, cwd: workspaceRoot,
579
+ // `?? true` keeps every caller that sends no flag byte-identical.
580
+ consultGuard: guarded ?? true,
581
+ guardEnabledOverride: guarded,
582
+ evidence: { commitSha, ledgerRef, source: 'completion-gate' },
583
+ });
584
+ }
585
+ return writer.completeItem(itemId, { featureCode, cwd: workspaceRoot, commitSha, ledgerRef, mode });
586
+ }
587
+
588
+ /**
589
+ * §2.3a step 2 / §5.10 step 6.2 — the ONE authorized COMPLETE status write, split
590
+ * into PREPARE and PERSIST because the two sit on opposite sides of an error
591
+ * boundary the callers must keep.
592
+ *
593
+ * Both doors share these rather than carrying two copies of the same five lines.
594
+ * That is not tidiness: the AC-19 allowlist scan asserts there is exactly ONE
595
+ * `status: 'COMPLETE'` callsite in the repo and that it is the gate, so a second
596
+ * copy would either fail that test or force it to be weakened into a per-FILE
597
+ * check.
598
+ *
599
+ * The SPLIT is load-bearing (Codex r2 #2). The live path's re-read and status
600
+ * preparation have always sat OUTSIDE the persistence `try`: a provider read
601
+ * that rejects, or a `null` feature where one was just written, is a broken
602
+ * precondition and throws — `/lifecycle/complete` answers 400 and
603
+ * `record_completion` propagates the original error. Folding the re-read into
604
+ * the helper quietly moved both under the catch and turned them into
605
+ * `refusedAt:'write'` (422, and `COMPLETION_GATE_REFUSED` in place of the real
606
+ * exception). `prepareCompleteStatus` is therefore pure and callers invoke it
607
+ * before entering their own `try`; only `persistCompleteStatus` may throw a
608
+ * write failure.
609
+ */
610
+ function prepareCompleteStatus(fresh, commitSha) {
611
+ if (fresh.status === 'COMPLETE') return null;
612
+ const updated = { ...fresh, status: 'COMPLETE' };
613
+ if (commitSha) updated.commit_sha = commitSha;
614
+ return { updated, from: fresh.status };
615
+ }
616
+
617
+ async function persistCompleteStatus({ provider, workspaceRoot, featureCode, updated }) {
618
+ const { isLocalProvider, roundtripGuard } = await import('./feature-writer.js');
619
+ if (isLocalProvider(provider)) {
620
+ // The same prose-loss fixed-point check `setFeatureStatus` runs. It is a
621
+ // preflight against the ROADMAP, not a write; a refusal here aborts the
622
+ // status flip with the record present and the intent kept — recoverable.
623
+ await roundtripGuard(workspaceRoot, provider,
624
+ (feats) => feats.map((f) => (f.code === featureCode ? updated : f)),
625
+ { force: false, label: 'completion_gate' });
626
+ }
627
+ await provider.persistFeatureRaw(featureCode, updated);
628
+ }
629
+
630
+ // ---------------------------------------------------------------------------
631
+
632
+ // ===========================================================================
633
+ // COMP-LIFECYCLE-BACKFILL — completionGate({intent:'backfill'}) (blueprint §5)
634
+ //
635
+ // The shape of this function is the design. Five rules explain every ordering
636
+ // decision in it, and each one exists because getting it wrong is silent:
637
+ //
638
+ // 1. NOTHING DURABLE IS WRITTEN UNTIL THE HISTORY VALIDATES (BP-4/R2B-8).
639
+ // Registration creates a guard resource and a ledger on disk. Registering
640
+ // first and validating second leaves a permanent registration behind for a
641
+ // request that was then refused. So: decide the initial state, validate
642
+ // evidence and history, and only then register, upgrade and transition.
643
+ //
644
+ // 2. THE INTENT IS THE COMPLETE WRITE PLAN, NOT A BREADCRUMB (BP-3/R2B-3).
645
+ // A resumed attempt recomputes NOTHING. Every value §5.10 writes comes out
646
+ // of `writeContext`, and `writeContext` is either built fresh (§5.7) or
647
+ // restored wholesale from the intent (§5.4a). If a value is not in
648
+ // `writeContext`, the write sequence cannot see it — which is the only
649
+ // structural way to stop a resume from writing different bytes than the
650
+ // crash intended.
651
+ //
652
+ // 3. TIMESTAMPS ARE MINTED ONCE (BP-6). `startedAt` and every timestamp inside
653
+ // the occurrences are stamped on the first attempt and persisted. A resume
654
+ // stamps only `finalizedAt`, because that one really is now.
655
+ //
656
+ // 4. RECOVERY GOES STRAIGHT TO THE TRANSPORT (R2B-2), carrying the PERSISTED
657
+ // policy checksum (R3-2/R4-1). `guardedTransition` calls `ensureGuard`,
658
+ // which fails closed when the stored policy has moved — fatal on exactly
659
+ // the recovery it would otherwise have completed. And the checksum must be
660
+ // sent, or a crash BEFORE the transition would later apply under whatever
661
+ // policy is current, moving the guard to a terminal state the plan was
662
+ // never validated against.
663
+ //
664
+ // 5. EVERY RAW-TRANSPORT RESULT GOES THROUGH isGuardError (R4-2). The raw
665
+ // verbs return stratum's canonical `{status:'error', error_type, message}`
666
+ // unchanged; a check on `.error` alone misses every stratum-side refusal,
667
+ // `policy_checksum_mismatch` included, and falls through to the success
668
+ // branch.
669
+ // ===========================================================================
670
+
671
+ const BACKFILL_TERMINAL = 'complete_backfilled';
672
+ const HEX64 = /^[0-9a-f]{64}$/;
673
+
674
+ /** Every `writeContext` key §5.10 may read. A missing one is a corrupt intent. */
675
+ const WRITE_CONTEXT_FIELDS = [
676
+ 'operationId', 'requestDigest', 'featureCode', 'mode', 'reason', 'commitSha',
677
+ 'filesChanged', 'notes', 'attested', 'startedAt', 'occurrences', 'terminalOcc',
678
+ 'history', 'writtenKeys', 'skippedKeys', 'guardInitial', 'upgrade', 'guarded',
679
+ 'policyChecksum', 'envelope',
680
+ ];
681
+
682
+ const refusal = (refusedAt, reasons, extra = {}) => ({
683
+ ok: false, refusedAt, reasons: Array.isArray(reasons) ? reasons : [reasons], ...extra,
684
+ });
685
+
686
+ const deepClone = (x) => JSON.parse(JSON.stringify(x));
687
+
688
+ /** Stable key order, so the digest is a function of the CLAIM and not of typing order. */
689
+ function canonicalJson(value) {
690
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
691
+ if (value && typeof value === 'object') {
692
+ return `{${Object.keys(value).sort()
693
+ .map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
694
+ }
695
+ return JSON.stringify(value ?? null);
696
+ }
697
+
698
+ /**
699
+ * §5.1 — canonicalise the request and digest it BEFORE anything else.
700
+ *
701
+ * `notes` is excluded: prose that does not change what is claimed. `observedTime`
702
+ * is excluded because it is server-derived — it is instead part of the
703
+ * per-occurrence claim inside `insertBackfilledPhases`.
704
+ */
705
+ export function backfillRequestDigest({
706
+ featureCode, commitSha, testsPass, mode, filesChanged, reason, occurrences,
707
+ }) {
708
+ const canonical = {
709
+ feature_code: featureCode,
710
+ commit_sha: commitSha ?? null,
711
+ tests_pass: testsPass ?? null,
712
+ mode,
713
+ files_changed: [...new Set(filesChanged ?? [])].sort(),
714
+ reason: (reason ?? '').trim(),
715
+ occurrences: [...(occurrences ?? [])]
716
+ .map((o) => ({ phase: o.phase, kind: o.evidence?.kind ?? null, ref: o.evidence?.ref ?? null }))
717
+ .sort((a, b) => `${a.phase}\u001f${a.ref}`.localeCompare(`${b.phase}\u001f${b.ref}`)),
718
+ };
719
+ return createHash('sha256').update(canonicalJson(canonical)).digest('hex');
720
+ }
721
+
722
+ function inGraph(graph, phase) {
723
+ if (Object.hasOwn(graph, phase)) return true;
724
+ return Object.values(graph).some((tos) => (tos || []).includes(phase));
725
+ }
726
+
727
+ /** The raw guard transport. Imported lazily so the module graph stays acyclic. */
728
+ async function rawGuard() {
729
+ return import('../server/stratum-client.js');
730
+ }
731
+
732
+ // ---------------------------------------------------------------------------
733
+
734
+ async function backfillGate({
735
+ featureCode, commitSha, testsPass, filesChanged = [], notes, reason, occurrences = [],
736
+ workspaceRoot, evidenceRoot, mode = 'build', item, store, visionItemId, visionProjector, actor,
737
+ }) {
738
+ const evRoot = evidenceRoot || workspaceRoot;
739
+ const guarded = guardEnabled(workspaceRoot);
740
+
741
+ // --- §5.1 request shape, then the digest, BEFORE anything else -----------
742
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
743
+ return refusal('request', 'a backfill requires a non-empty reason', { guarded });
744
+ }
745
+ if (typeof commitSha !== 'string' || commitSha.trim().length === 0) {
746
+ return refusal('request', 'a backfill requires commit_sha', { guarded });
747
+ }
748
+ const requestDigest = backfillRequestDigest({
749
+ featureCode, commitSha, testsPass, mode, filesChanged, reason, occurrences,
750
+ });
751
+
752
+ const rid = resourceId(featureCode, workspaceRoot, mode);
753
+ const lockDir = path.join(workspaceRoot, '.compose', 'data', 'locks', `completion-${featureCode}`);
754
+ const release = await acquireDirLock(lockDir);
755
+
756
+ try {
757
+ // --- §5.3 mode-aware preflight ---------------------------------------
758
+ const tracksJson = getMode(mode).runner.tracksFeatureJson;
759
+ const { getProvider } = await import('./feature-writer.js');
760
+ if (tracksJson) {
761
+ const provider = await getProvider(workspaceRoot);
762
+ const feature = await provider.getFeature(featureCode);
763
+ if (!feature) {
764
+ return refusal('preflight', `feature "${featureCode}" not found`, { guarded });
765
+ }
766
+ if (TERMINAL_STATUSES.has(feature.status)) {
767
+ return refusal('preflight',
768
+ `feature "${featureCode}" is ${feature.status} — a terminal status has no legal completion`,
769
+ { guarded });
770
+ }
771
+ }
772
+ if (!item || !item.lifecycle) {
773
+ return refusal('preflight',
774
+ `no vision item with a lifecycle for "${featureCode}" — run scaffold_feature first`,
775
+ { guarded });
776
+ }
777
+
778
+ // --- §5.3 lookup order: finalized -> pending/intent -> new ------------
779
+ const records = item.lifecycle.backfills ?? [];
780
+ const finalized = records.find((r) => r.request_digest === requestDigest && r.state === 'finalized');
781
+ if (finalized) {
782
+ return {
783
+ ok: true, guarded, backfill: finalized, status: 'finalized',
784
+ operationId: finalized.operation_id, ledgerRef: finalized.guardRef ?? null, reasons: [],
785
+ replayed: true,
786
+ };
787
+ }
788
+
789
+ const priorIntent = readIntent(workspaceRoot, featureCode);
790
+ const pending = records.find((r) => r.request_digest === requestDigest && r.state === 'pending');
791
+
792
+ // BP-3: the INTENT is the only recovery source. A pending record is a marker.
793
+ if (pending && (!priorIntent || priorIntent.request_digest !== requestDigest)) {
794
+ return refusal('recovery',
795
+ 'a pending backfill record exists for this request with no matching intent — '
796
+ + 'the operation cannot be resumed automatically; operator action required', { guarded });
797
+ }
798
+ if (priorIntent && priorIntent.intent === 'backfill' && priorIntent.request_digest !== requestDigest) {
799
+ return refusal('recovery', 'a different backfill operation is in flight for this feature', { guarded });
800
+ }
801
+ if (priorIntent && priorIntent.intent !== 'backfill') {
802
+ clearIntent(workspaceRoot, featureCode); // a stale live-completion intent
803
+ }
804
+ const recovering = Boolean(priorIntent && priorIntent.intent === 'backfill'
805
+ && priorIntent.request_digest === requestDigest);
806
+
807
+ let writeContext;
808
+ let skipHistoryWrite = false;
809
+
810
+ if (recovering) {
811
+ // ================= §5.4a RECOVERY ==================================
812
+ const op = priorIntent;
813
+ writeContext = {
814
+ operationId: op.operation_id, requestDigest: op.request_digest,
815
+ featureCode: op.feature_code, mode: op.mode, reason: op.reason,
816
+ commitSha: op.commit_sha, filesChanged: op.files_changed,
817
+ notes: op.notes, attested: op.tests_attested, startedAt: op.started_at,
818
+ occurrences: op.occurrences, terminalOcc: op.terminal_occurrence,
819
+ history: op.write_plan?.history,
820
+ writtenKeys: op.write_plan?.written, skippedKeys: op.write_plan?.skipped,
821
+ guardInitial: op.guard_initial, upgrade: op.upgrade,
822
+ guarded: op.guarded, policyChecksum: op.policy_checksum,
823
+ envelope: op.envelope, ledgerRef: null,
824
+ };
825
+ // R3-4: a missing field is a CORRUPT intent, not a defaultable zero. The
826
+ // four nullable ones are checked with `hasOwn` on the intent itself, so
827
+ // "the operation had none" stays distinguishable from "the intent omitted it".
828
+ for (const field of WRITE_CONTEXT_FIELDS) {
829
+ if (writeContext[field] === undefined) {
830
+ return refusal('recovery',
831
+ `the persisted intent is missing ${field} — clear it and re-run`, { guarded });
832
+ }
833
+ }
834
+ for (const key of ['notes', 'guard_initial', 'upgrade', 'policy_checksum']) {
835
+ if (!Object.hasOwn(op, key)) {
836
+ return refusal('recovery',
837
+ `the persisted intent is missing ${key} — clear it and re-run`, { guarded });
838
+ }
839
+ }
840
+
841
+ // --- History divergence on resume (R2B-3, R3-5) ---------------------
842
+ const replay = insertBackfilledPhases(item, [...writeContext.occurrences, writeContext.terminalOcc]);
843
+ if (!replay.ok) return refusal('history', replay.reasons, { guarded });
844
+ if (canonicalJson(replay.history) === canonicalJson(writeContext.history)) {
845
+ // Nothing moved — keep the plan exactly as persisted.
846
+ } else if (replay.written.length === 0
847
+ && [...writeContext.occurrences, writeContext.terminalOcc]
848
+ .every((o) => replay.skipped.includes(occurrenceKey(o)))) {
849
+ // The history already reached disk, terminal occurrence included — which
850
+ // §4.1 step 3a recognises by operation_id rather than by claim (R3-5).
851
+ writeContext.history = item.lifecycle.phaseHistory;
852
+ skipHistoryWrite = true;
853
+ } else {
854
+ return refusal('history',
855
+ "the item's phase history changed while a backfill was in flight", { guarded });
856
+ }
857
+
858
+ if (writeContext.guarded) {
859
+ const resolved = await replayGuardTransition(rid, writeContext);
860
+ if (!resolved.ok) return { ...resolved, guarded: writeContext.guarded };
861
+ writeContext.ledgerRef = resolved.ledgerRef;
862
+ }
863
+ } else {
864
+ // ================= §5.4b BOOTSTRAP =================================
865
+ const operationId = randomUUID();
866
+ const now = new Date().toISOString(); // minted ONCE (BP-6)
867
+
868
+ let guardInitial = null;
869
+ let fromState = null;
870
+ let policyChecksum = null;
871
+ let upgrade = null;
872
+ let needsRegistration = false;
873
+ let proposedInitial = null;
874
+
875
+ if (guarded) {
876
+ const g = await currentGuardState(rid);
877
+ if (g.error) return refusal('guard', 'guard unreachable', { guarded, error: g.error });
878
+ if (g.state === null) {
879
+ // R2B-8: COMPUTE the initial, do NOT register yet.
880
+ const lp = item.lifecycle.currentPhase;
881
+ proposedInitial = inGraph(transitionsOf(mode), lp) ? lp : genesisOf(mode);
882
+ needsRegistration = true;
883
+ fromState = proposedInitial;
884
+ guardInitial = { registered: proposedInitial, lifecycle_phase: lp };
885
+ } else {
886
+ fromState = g.state;
887
+ }
888
+ } else {
889
+ // Unguarded: the lifecycle IS the source of truth, and legality is local.
890
+ fromState = item.lifecycle.currentPhase;
891
+ }
892
+ if (terminalOf(mode).includes(fromState)) {
893
+ return refusal('guard', `${featureCode} is already terminal at "${fromState}"`, { guarded });
894
+ }
895
+
896
+ // --- §5.5 materialise and VALIDATE before any guard mutation -------
897
+ const ev = await verifyCompletionEvidenceAsync({
898
+ commitSha, cwd: evRoot, testCommand: guardTestCommand(workspaceRoot), testsPassClaim: testsPass,
899
+ });
900
+ if (!ev.ok) return refusal('evidence', ev.reasons, { guarded });
901
+ const attested = ev.testsAttested ? true : testsPass === true;
902
+
903
+ const fwd = transitionsOf(mode);
904
+ for (const o of occurrences) {
905
+ if (!inGraph(fwd, o.phase)) {
906
+ return refusal('request', `"${o.phase}" is not a phase of mode ${mode}`, { guarded });
907
+ }
908
+ }
909
+
910
+ let materialised;
911
+ try {
912
+ materialised = occurrences.map((o) => {
913
+ const resolved = resolveEvidenceRef(evRoot, o.evidence);
914
+ return {
915
+ phase: o.phase, step: o.phase, to: o.phase, from: null,
916
+ enteredAt: resolved.observedTime, timestamp: resolved.observedTime, exitedAt: null,
917
+ outcome: 'backfilled', recordedAt: now, origin: 'backfill',
918
+ confidence: deriveConfidence(resolved.kind), episode: 1, evidence: resolved,
919
+ };
920
+ });
921
+ } catch (e) {
922
+ // A path that escapes the repo, a symlink that leaves it, a missing file:
923
+ // the occurrence cannot be placed in the history at all.
924
+ return refusal('history', e.message, { guarded });
925
+ }
926
+
927
+ const terminalOcc = {
928
+ phase: BACKFILL_TERMINAL, step: BACKFILL_TERMINAL, to: BACKFILL_TERMINAL, from: fromState,
929
+ enteredAt: now, timestamp: now, exitedAt: null,
930
+ outcome: 'backfilled', recordedAt: now, origin: 'live',
931
+ confidence: 1.0, episode: 2, operation_id: operationId,
932
+ evidence: {
933
+ kind: 'commit', ref: commitSha, verifiedAt: now,
934
+ observedTime: now, observedEpochMs: Date.parse(now),
935
+ },
936
+ };
937
+ // The terminal occurrence IS live — it happened now. The terminal STATE is
938
+ // the provenance signal (Decision 11), not this field.
939
+
940
+ const probe = insertBackfilledPhases(item, [...materialised, terminalOcc]);
941
+ if (!probe.ok) return refusal('history', probe.reasons, { guarded });
942
+ if (probe.history[probe.history.length - 1]?.phase !== BACKFILL_TERMINAL) {
943
+ return refusal('history',
944
+ 'backfilled evidence is dated after the completion being recorded', { guarded });
945
+ }
946
+
947
+ // --- §5.6 register, legacy compatibility, lazy upgrade (guarded only)
948
+ if (guarded) {
949
+ const reg = await ensureGuard(featureCode, needsRegistration ? proposedInitial : fromState,
950
+ workspaceRoot, mode);
951
+ if (isGuardError(reg)) {
952
+ return refusal('guard', ['guard registration failed'], { guarded, error: reg.error || reg });
953
+ }
954
+ if (needsRegistration) {
955
+ const g = await currentGuardState(rid);
956
+ if (g.error || g.state === null) {
957
+ return refusal('guard', ['guard registration could not be confirmed'],
958
+ { guarded, error: g.error });
959
+ }
960
+ if (g.state !== fromState) {
961
+ return refusal('guard', `registration seeded "${g.state}", not "${fromState}"`, { guarded });
962
+ }
963
+ }
964
+ if (reg.status === 'legacy') {
965
+ const u = await applyBackfillUpgrade({ featureCode, workspaceRoot, mode });
966
+ if (!u.ok) {
967
+ const manual = u.error?.code === 'upgrade_descriptor_unavailable' && custodyBackend() === 'none';
968
+ return refusal('upgrade', [
969
+ ...u.reasons,
970
+ ...(u.error?.hint ? [u.error.hint] : []),
971
+ ...(manual ? ['regenerate with `compose guard descriptors`, have the operator re-sign it, and commit both files'] : []),
972
+ ], { guarded, error: u.error });
973
+ }
974
+ upgrade = { descriptor_id: u.descriptorId ?? null, status: u.status, ledger_ref: u.ledgerRef ?? null };
975
+ }
976
+ // BP-1: read the checksum AFTER any upgrade and BEFORE the transition.
977
+ const { guardPolicy } = await rawGuard();
978
+ const pol = await guardPolicy(rid);
979
+ if (isGuardError(pol)) {
980
+ return refusal('guard', [`guard policy unreadable: ${guardErrorMessage(pol)}`], { guarded });
981
+ }
982
+ policyChecksum = pol.checksum ?? null;
983
+ if (!HEX64.test(String(policyChecksum))) {
984
+ return refusal('guard', ['guard policy returned no usable checksum'], { guarded });
985
+ }
986
+ }
987
+
988
+ // --- §5.7 write-ahead intent: the COMPLETE validated write plan ----
989
+ writeContext = {
990
+ operationId, requestDigest, featureCode, mode, reason,
991
+ commitSha: commitSha ?? null, filesChanged, notes: notes ?? null,
992
+ attested, startedAt: now,
993
+ occurrences: materialised, terminalOcc,
994
+ history: probe.history, writtenKeys: probe.written, skippedKeys: probe.skipped,
995
+ guardInitial, upgrade, guarded, policyChecksum,
996
+ envelope: {
997
+ from: fromState, to: BACKFILL_TERMINAL,
998
+ artifacts: {
999
+ operation_id: operationId,
1000
+ request_digest: requestDigest,
1001
+ resolver_tags: 'late-registration+backfill',
1002
+ ...(commitSha ? { commit_sha: commitSha } : {}),
1003
+ },
1004
+ modified_files: [], resolved_by: 'agent',
1005
+ idempotency_key: operationId,
1006
+ ...(guarded ? { expected_policy_checksum: policyChecksum } : {}),
1007
+ },
1008
+ ledgerRef: null,
1009
+ };
1010
+
1011
+ writeIntent(workspaceRoot, featureCode, {
1012
+ operation_id: writeContext.operationId, feature_code: writeContext.featureCode,
1013
+ mode: writeContext.mode, intent: 'backfill',
1014
+ request_digest: writeContext.requestDigest, reason: writeContext.reason,
1015
+ commit_sha: writeContext.commitSha, files_changed: writeContext.filesChanged,
1016
+ notes: writeContext.notes,
1017
+ tests_attested: writeContext.attested, started_at: writeContext.startedAt,
1018
+ guarded: writeContext.guarded,
1019
+ occurrences: writeContext.occurrences,
1020
+ terminal_occurrence: writeContext.terminalOcc,
1021
+ write_plan: {
1022
+ history: writeContext.history,
1023
+ written: writeContext.writtenKeys,
1024
+ skipped: writeContext.skippedKeys,
1025
+ },
1026
+ guard_initial: writeContext.guardInitial,
1027
+ upgrade: writeContext.upgrade,
1028
+ policy_checksum: writeContext.policyChecksum,
1029
+ envelope: writeContext.envelope,
1030
+ });
1031
+
1032
+ // --- §5.8 the fresh guarded transition -----------------------------
1033
+ if (guarded) {
1034
+ const applied = await freshGuardTransition(rid, writeContext, workspaceRoot, featureCode);
1035
+ if (!applied.ok) return { ...applied, guarded };
1036
+ writeContext.ledgerRef = applied.ledgerRef;
1037
+ }
1038
+ }
1039
+
1040
+ // --- §5.10 the write sequence ---------------------------------------
1041
+ //
1042
+ // `return await`, NOT `return`. `try { return p } finally { release() }`
1043
+ // runs the finally as soon as the expression is EVALUATED, so returning the
1044
+ // bare promise released the lock at the write sequence's first suspension
1045
+ // and let the completion record, the status flip, the audit event and
1046
+ // finalization all run unlocked — with a concurrent retry free to enter the
1047
+ // same pending operation while they were still in flight (Codex r1 #1).
1048
+ return await backfillWriteSequence({
1049
+ wc: writeContext, item, store, workspaceRoot, visionItemId, visionProjector,
1050
+ actor, recovering, skipHistoryWrite, getProvider,
1051
+ });
1052
+ } finally {
1053
+ release();
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * §5.8 — the FRESH transition.
1059
+ *
1060
+ * DEVIATION from the blueprint's pseudocode, deliberately: this uses the RAW
1061
+ * `guardTransition` transport rather than `guardedTransition`. §5.6 has already
1062
+ * run `ensureGuard`, so the wrapper's only remaining contribution is a second
1063
+ * (cached) registration call plus collapsing `replayed` into `applied:true` —
1064
+ * and that collapsing is exactly what R2B-6 forbids, because a replay returns a
1065
+ * HISTORICAL verdict and says nothing about what happened afterwards. Surfacing
1066
+ * `status` verbatim would need a change to `server/lifecycle-guard.js`, which
1067
+ * this slice may not touch; going straight to the transport gets the same
1068
+ * guarantee with no cross-slice edit.
1069
+ */
1070
+ async function freshGuardTransition(rid, wc, workspaceRoot, featureCode) {
1071
+ const { guardTransition } = await rawGuard();
1072
+ const env = wc.envelope;
1073
+ let g;
1074
+ try {
1075
+ g = await guardTransition({
1076
+ resourceId: rid, fromState: env.from, toState: env.to,
1077
+ artifacts: env.artifacts, modifiedFiles: env.modified_files,
1078
+ idempotencyKey: env.idempotency_key, resolvedBy: env.resolved_by,
1079
+ expectedPolicyChecksum: env.expected_policy_checksum,
1080
+ });
1081
+ } catch (e) {
1082
+ return refusal('guard', [`guard transition failed: ${e.message}`]);
1083
+ }
1084
+
1085
+ if (guardErrorType(g) === 'policy_checksum_mismatch') {
1086
+ // R2B-7: refused ATOMICALLY under stratum's resource lock; nothing written.
1087
+ // This is the ONE refusal that is retryable with no operator action.
1088
+ clearIntent(workspaceRoot, featureCode);
1089
+ return refusal('guard',
1090
+ `the guard policy for ${featureCode} changed while this backfill was in flight `
1091
+ + `(expected ${env.expected_policy_checksum}) — nothing was written; retry`);
1092
+ }
1093
+ if (isGuardError(g)) {
1094
+ return refusal('guard', [`guard transition failed: ${guardErrorMessage(g)}`], { error: g });
1095
+ }
1096
+ if (g.status === 'applied') return { ok: true, ledgerRef: g.ledger_ref ?? null };
1097
+ if (g.status === 'replayed') {
1098
+ // §5.9b: a fresh attempt that meets an existing entry is a recovery in
1099
+ // disguise, and is resolved read-only like any other.
1100
+ return verifyGuardLedger(rid, wc);
1101
+ }
1102
+ return refusal('guard', [g.status === 'refused' ? 'refused by guard' : 'guard transition failed'],
1103
+ { verdict: g.verdict });
1104
+ }
1105
+
1106
+ /**
1107
+ * §5.9a — the RECOVERY replay. Raw transport, no `ensureGuard`, no policy
1108
+ * re-derivation, and ALWAYS carrying the persisted checksum (R3-2/R4-1).
1109
+ */
1110
+ async function replayGuardTransition(rid, wc) {
1111
+ const env = wc.envelope;
1112
+ if (wc.policyChecksum === null || !HEX64.test(String(wc.policyChecksum))) {
1113
+ return refusal('recovery', 'intent has no policy checksum; clear it or re-run');
1114
+ }
1115
+ if ((env.expected_policy_checksum ?? null) !== wc.policyChecksum) {
1116
+ return refusal('recovery', 'intent envelope checksum disagrees with intent policy_checksum');
1117
+ }
1118
+ const { guardTransition } = await rawGuard();
1119
+ let g;
1120
+ try {
1121
+ g = await guardTransition({
1122
+ resourceId: rid, fromState: env.from, toState: env.to,
1123
+ artifacts: env.artifacts, modifiedFiles: env.modified_files,
1124
+ idempotencyKey: env.idempotency_key, resolvedBy: env.resolved_by,
1125
+ expectedPolicyChecksum: wc.policyChecksum,
1126
+ });
1127
+ } catch (e) {
1128
+ return refusal('recovery', `guard transition failed: ${e.message}`);
1129
+ }
1130
+ if (guardErrorType(g) === 'policy_checksum_mismatch') {
1131
+ return verifyGuardLedger(rid, wc); // READ-ONLY. Nothing was written.
1132
+ }
1133
+ if (isGuardError(g)) {
1134
+ return refusal('recovery', `guard transition failed: ${guardErrorMessage(g)}`);
1135
+ }
1136
+ if (g.status === 'applied') return { ok: true, ledgerRef: g.ledger_ref ?? null };
1137
+ // `replayed` is NOT `applied` (R2B-6): the ledger is read on EVERY recovery
1138
+ // success, because a kill, an override or a migrate could have moved the
1139
+ // resource on after the historical verdict this replay is echoing.
1140
+ return verifyGuardLedger(rid, wc);
1141
+ }
1142
+
1143
+ /**
1144
+ * §5.9c — read-only verification. No transition is issued: everything is decided
1145
+ * from `guard digest` (a pure function) and ONE `guard history` read, so all
1146
+ * three conditions describe one consistent moment.
1147
+ */
1148
+ async function verifyGuardLedger(rid, wc) {
1149
+ if (wc.policyChecksum === null) return refusal('recovery', 'no policy checksum on record');
1150
+ const { guardDigest, guardHistory, guardPolicy } = await rawGuard();
1151
+ const env = wc.envelope;
1152
+
1153
+ const d = await guardDigest({
1154
+ fromState: env.from, toState: env.to, artifacts: env.artifacts,
1155
+ modifiedFiles: env.modified_files, resolvedBy: env.resolved_by,
1156
+ policyChecksum: wc.policyChecksum,
1157
+ });
1158
+ if (isGuardError(d)) {
1159
+ return refusal('recovery', `payload digest could not be computed: ${guardErrorMessage(d)}`);
1160
+ }
1161
+
1162
+ const h = await guardHistory(rid);
1163
+ if (isGuardError(h)) {
1164
+ return refusal('recovery', `guard history unreadable: ${guardErrorMessage(h)}`);
1165
+ }
1166
+ const ledger = h.ledger ?? [];
1167
+
1168
+ // (1) THE ENTRY EXISTS AND IS OURS. Matching on id + to_state + outcome alone
1169
+ // is R2-1, which BP-1 rejected — the payload digest is what makes it ours.
1170
+ const index = ledger.findIndex((e) => e.kind === 'transition'
1171
+ && e.idempotency_key === wc.operationId
1172
+ && e.outcome === 'applied'
1173
+ && e.to_state === BACKFILL_TERMINAL
1174
+ && e.payload_digest === d.payload_digest);
1175
+ if (index === -1) {
1176
+ // R3-2: the operation NEVER applied. This is where the design deliberately
1177
+ // stops — the intent stays, the guard is untouched, and a HUMAN decides.
1178
+ const livePolicy = await guardPolicy(rid);
1179
+ const liveChecksum = isGuardError(livePolicy) ? '(unreadable)' : (livePolicy.checksum ?? '(none)');
1180
+ if (liveChecksum !== wc.policyChecksum) {
1181
+ return refusal('recovery',
1182
+ `this backfill never reached the guard, and the policy for ${wc.featureCode} has changed `
1183
+ + `since the intent was written (recorded ${wc.policyChecksum}, now ${liveChecksum}). `
1184
+ + 'Nothing has been written. Either clear the intent at '
1185
+ + `.compose/data/completion-intents/${wc.featureCode}.json and re-run the backfill under `
1186
+ + 'the current policy, or restore the policy the intent was written against.');
1187
+ }
1188
+ return refusal('recovery',
1189
+ 'no applied ledger entry under this operation id matches the persisted envelope');
1190
+ }
1191
+ const entry = ledger[index];
1192
+
1193
+ // (2) THE RESOURCE IS STILL WHERE THAT ENTRY LEFT IT.
1194
+ if (h.current_state !== BACKFILL_TERMINAL) {
1195
+ return refusal('recovery',
1196
+ `the guard has moved to "${h.current_state}" since this operation applied`);
1197
+ }
1198
+
1199
+ // (3) NOTHING MUTATED IT AFTERWARDS. The ledger is append-only and ordered, so
1200
+ // this is a suffix scan. R3-6: the kinds are the ones stratum ACTUALLY
1201
+ // writes — there is no 'override' kind and no 'migrate' kind, and the
1202
+ // first draft scanned for both, so an override slipped past silently.
1203
+ for (const e of ledger.slice(index + 1)) {
1204
+ if (e.kind === 'transition' || e.kind === 'deviation') {
1205
+ return refusal('recovery',
1206
+ `the guard was mutated after this operation applied (a ${e.kind} entry `
1207
+ + `${e.from_state} -> ${e.to_state})`);
1208
+ }
1209
+ if (e.kind === 'graph_version') {
1210
+ // State-preserving BY CONSTRUCTION — verify rather than assume, so a
1211
+ // future stratum that moved state in a policy entry breaks loudly.
1212
+ if (e.from_state !== e.to_state) {
1213
+ return refusal('recovery', 'a policy entry moved the guard state after this operation applied');
1214
+ }
1215
+ continue;
1216
+ }
1217
+ return refusal('recovery', `unrecognised ledger entry kind "${e.kind}" after this operation`);
1218
+ }
1219
+
1220
+ return { ok: true, ledgerRef: entry.entry_digest ?? null };
1221
+ }
1222
+
1223
+ /**
1224
+ * §5.10 — the write sequence. EVERY value comes from `wc`: no probe, no `now`,
1225
+ * no request, no live `guardEnabled`. Steps 6.0–6.2 are durable truth and abort
1226
+ * with the intent KEPT; 6.3–6.4 are re-drivable projections and are collected.
1227
+ */
1228
+ async function backfillWriteSequence({
1229
+ wc, item, store, workspaceRoot, visionItemId, visionProjector, actor, recovering,
1230
+ skipHistoryWrite, getProvider,
1231
+ }) {
1232
+ const tracksJson = getMode(wc.mode).runner.tracksFeatureJson;
1233
+ const failures = [];
1234
+
1235
+ // --- 6.0 history + PENDING marker, ONE store write -----------------------
1236
+ const snapshot = deepClone(item.lifecycle);
1237
+ if (!skipHistoryWrite) item.lifecycle.phaseHistory = wc.history;
1238
+ item.lifecycle.currentPhase = BACKFILL_TERMINAL;
1239
+ item.lifecycle.completedAt = wc.terminalOcc.enteredAt; // stable (BP-6)
1240
+ if (!Array.isArray(item.lifecycle.backfills)) item.lifecycle.backfills = [];
1241
+ const record = {
1242
+ operation_id: wc.operationId, request_digest: wc.requestDigest, state: 'pending',
1243
+ reason: wc.reason,
1244
+ recordedAt: wc.startedAt, // R3-4: the OPERATION's instant, not the retry's
1245
+ finalizedAt: null,
1246
+ completionEvidence: {
1247
+ commit_sha: wc.commitSha, tests_attested: wc.attested, verified_at: wc.startedAt,
1248
+ },
1249
+ guardRef: wc.ledgerRef ?? null, guard_initial: wc.guardInitial, upgrade: wc.upgrade,
1250
+ actor,
1251
+ occurrenceKeys: [...wc.writtenKeys, ...wc.skippedKeys],
1252
+ };
1253
+ const at = item.lifecycle.backfills.findIndex((r) => r.operation_id === wc.operationId);
1254
+ if (at === -1) item.lifecycle.backfills.push(record);
1255
+ else item.lifecycle.backfills[at] = { ...item.lifecycle.backfills[at], ...record };
1256
+ const liveRecord = item.lifecycle.backfills.find((r) => r.operation_id === wc.operationId);
1257
+
1258
+ store.updateLifecycle(item.id, item.lifecycle);
1259
+ if (store.lastSaveOk === false) {
1260
+ // BP-5: roll the in-memory item back so memory and disk agree.
1261
+ item.lifecycle = snapshot;
1262
+ store.items.set(item.id, item);
1263
+ return refusal('write', 'vision-state could not be persisted',
1264
+ { guarded: wc.guarded, operationId: wc.operationId, ledgerRef: wc.ledgerRef });
1265
+ }
1266
+
1267
+ const provider = await getProvider(workspaceRoot);
1268
+
1269
+ // --- 6.1 completion record — BP-11: only for modes that track feature.json
1270
+ let rec = null;
1271
+ if (tracksJson) {
1272
+ const { recordCompletion } = await import('./completion-writer.js');
1273
+ try {
1274
+ rec = await recordCompletion(workspaceRoot, {
1275
+ feature_code: wc.featureCode,
1276
+ ...(wc.commitSha ? { commit_sha: wc.commitSha } : {}),
1277
+ tests_pass: wc.attested,
1278
+ files_changed: wc.filesChanged,
1279
+ ...(wc.notes ? { notes: wc.notes } : {}),
1280
+ idempotency_key: wc.operationId,
1281
+ set_status: false,
1282
+ });
1283
+ } catch (e) {
1284
+ // Durable write failed: the intent is KEPT so a retry resumes.
1285
+ return refusal('write', `completion record could not be written: ${e.message}`,
1286
+ { guarded: wc.guarded, operationId: wc.operationId, ledgerRef: wc.ledgerRef, error: e });
1287
+ }
1288
+ }
1289
+
1290
+ // --- 6.2 status -> COMPLETE, raw ----------------------------------------
1291
+ let statusChanged = null;
1292
+ if (tracksJson) {
1293
+ // The backfill door keeps the re-read INSIDE the catch, deliberately and
1294
+ // unlike the live path above. A backfill holds a write-ahead intent, and
1295
+ // §5.11 says a durable-write failure refuses at `write` and KEEPS it so a
1296
+ // retry resumes — a thrown read here would escape the gate with the intent
1297
+ // stranded and no `refusedAt` for the caller to act on.
1298
+ try {
1299
+ const fresh = await provider.getFeature(wc.featureCode);
1300
+ const prepared = prepareCompleteStatus(fresh, wc.commitSha);
1301
+ if (prepared) {
1302
+ await persistCompleteStatus({
1303
+ provider, workspaceRoot, featureCode: wc.featureCode, updated: prepared.updated,
1304
+ });
1305
+ statusChanged = { from: prepared.from, to: 'COMPLETE' };
1306
+ }
1307
+ } catch (e) {
1308
+ return refusal('write', `completion recorded but status could not be set: ${e.message}`,
1309
+ { guarded: wc.guarded, operationId: wc.operationId, ledgerRef: wc.ledgerRef, error: e, result: rec });
1310
+ }
1311
+ }
1312
+
1313
+ // --- 6.3 ROADMAP regen — projection; collected --------------------------
1314
+ if (tracksJson) {
1315
+ try {
1316
+ await provider.renderRoadmap();
1317
+ } catch (e) {
1318
+ failures.push({ step: 'roadmap', message: e.message, recover: 'compose roadmap generate' });
1319
+ }
1320
+ }
1321
+
1322
+ // --- 6.4 vision projection ----------------------------------------------
1323
+ let visionProjection = null;
1324
+ if (tracksJson) {
1325
+ try {
1326
+ const project = visionProjector || defaultVisionProjector;
1327
+ visionProjection = await project({
1328
+ workspaceRoot, featureCode: wc.featureCode, visionItemId: visionItemId ?? item.id,
1329
+ commitSha: wc.commitSha, ledgerRef: wc.ledgerRef, mode: wc.mode,
1330
+ // R2B-4/R3-7: the EFFECTIVE flag for THIS operation, not live config.
1331
+ guarded: wc.guarded,
1332
+ // The LIVE store, so the projection writes the same item finalization
1333
+ // is about to serialize (Codex r1 #2).
1334
+ store,
1335
+ });
1336
+ // `applyVerifiedProjection` REPORTS a refused or unpersisted projection as
1337
+ // `{ok:false}`; it does not throw. Handling only exceptions finalized the
1338
+ // operation and cleared its intent with the item still incomplete
1339
+ // (Codex r1 #3). A projector that returns nothing at all is the same
1340
+ // failure wearing a quieter disguise.
1341
+ if (!visionProjection || visionProjection.ok === false) {
1342
+ failures.push({
1343
+ step: 'vision',
1344
+ message: (visionProjection?.reasons ?? ['the vision projection reported no result']).join('; '),
1345
+ recover: 'compose validate --fix',
1346
+ });
1347
+ }
1348
+ } catch (e) {
1349
+ failures.push({ step: 'vision', message: e.message, recover: 'compose validate --fix' });
1350
+ }
1351
+ } else {
1352
+ // R2B-9: modes without feature.json have no projector, and updateLifecycle
1353
+ // does NOT touch item.status — so without this the item finalizes still
1354
+ // reading its old status while its lifecycle says complete_backfilled. This
1355
+ // is DURABLE, not a projection: a failure is rolled back and collected.
1356
+ const priorStatus = item.status;
1357
+ store.updateItem(item.id, { status: 'complete' });
1358
+ if (store.lastSaveOk === false) {
1359
+ store.updateItem(item.id, { status: priorStatus });
1360
+ failures.push({
1361
+ step: 'item-status',
1362
+ message: 'vision item status could not be persisted',
1363
+ recover: 'retry the backfill',
1364
+ });
1365
+ }
1366
+ }
1367
+
1368
+ // --- 6.5 audit — BP-5: NON-SWALLOWING, emitted regardless of statusChanged
1369
+ let auditOk = false;
1370
+ try {
1371
+ await provider.appendEvent(wc.featureCode, {
1372
+ tool: 'backfill_completion', code: wc.featureCode,
1373
+ from: statusChanged?.from ?? null, to: 'COMPLETE',
1374
+ reason: 'backfill', via: 'completion_gate',
1375
+ operation_id: wc.operationId, backfill_request_digest: wc.requestDigest,
1376
+ ...(wc.commitSha ? { commit_sha: wc.commitSha } : {}),
1377
+ ...(wc.ledgerRef ? { ledger_ref: wc.ledgerRef } : {}),
1378
+ });
1379
+ auditOk = true;
1380
+ } catch (e) {
1381
+ failures.push({ step: 'audit', message: e.message, recover: 'retry the backfill' });
1382
+ }
1383
+
1384
+ const common = {
1385
+ ok: true, guarded: wc.guarded, operationId: wc.operationId,
1386
+ ledgerRef: wc.ledgerRef ?? null, recovered: recovering,
1387
+ attestedTestsPass: wc.attested, visionProjection,
1388
+ written: wc.writtenKeys, skipped: wc.skippedKeys,
1389
+ result: rec ? { ...rec, status_changed: statusChanged } : null,
1390
+ };
1391
+
1392
+ // --- 6.6 finalize — ONLY with an empty failure set AND a confirmed audit --
1393
+ if (failures.length === 0 && auditOk) {
1394
+ const snapshot2 = deepClone(item.lifecycle);
1395
+ liveRecord.state = 'finalized';
1396
+ // The ONLY timestamp in this section minted at write time, deliberately: it
1397
+ // records when the operation FINISHED, which on a resume really is now.
1398
+ liveRecord.finalizedAt = new Date().toISOString();
1399
+ store.updateLifecycle(item.id, item.lifecycle);
1400
+ if (store.lastSaveOk === false) {
1401
+ item.lifecycle = snapshot2;
1402
+ store.items.set(item.id, item);
1403
+ // Return the RESTORED record, not the detached one this block already
1404
+ // mutated to `finalized` (Codex r1 #6). The caller was being handed a
1405
+ // record that says finalized while disk and memory both say pending.
1406
+ const restored = (item.lifecycle.backfills ?? [])
1407
+ .find((r) => r.operation_id === wc.operationId) ?? null;
1408
+ return {
1409
+ ...common,
1410
+ partial: true,
1411
+ failures: [{ step: 'finalize', message: 'vision-state could not be persisted', recover: 'retry the backfill' }],
1412
+ backfill: restored, status: 'pending', reasons: [],
1413
+ };
1414
+ }
1415
+ clearIntent(workspaceRoot, wc.featureCode); // LAST
1416
+ return { ...common, partial: false, failures: [], backfill: liveRecord, status: 'finalized', reasons: [] };
1417
+ }
1418
+
1419
+ // The record stays `pending`, the intent stays. A retry resumes.
1420
+ return { ...common, partial: true, failures, backfill: liveRecord, status: 'pending', reasons: [] };
1421
+ }