@smartmemory/compose 0.3.6-beta → 0.3.8

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 (269) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/.compose-deps.json +1 -13
  3. package/README.md +72 -5
  4. package/bin/compose.js +754 -347
  5. package/bin/git-hooks/pre-push.template +29 -0
  6. package/bin/judgment-import.js +7 -0
  7. package/bin/judgment-migrate.js +387 -0
  8. package/contracts/comp-obs-contract.schema.json +9 -3
  9. package/contracts/feature-json.schema.json +5 -0
  10. package/contracts/fluid-record.schema.json +209 -0
  11. package/contracts/judgment-record.schema.json +425 -4
  12. package/contracts/lifecycle-backfill.schema.json +322 -0
  13. package/dist/assets/App-Z4MU-H_F.js +916 -0
  14. package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
  15. package/dist/assets/arc-DY26UIVo.js +1 -0
  16. package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
  17. package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
  18. package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
  19. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
  20. package/dist/assets/channel-SnZzzh7k.js +1 -0
  21. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
  22. package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
  23. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
  24. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
  25. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
  26. package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
  27. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
  28. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
  29. package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
  30. package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
  31. package/dist/assets/clone-DgklGjHm.js +1 -0
  32. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
  33. package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
  34. package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
  41. package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
  42. package/dist/assets/graph-BNzKGvoy.js +1 -0
  43. package/dist/assets/graph-CI_1htl0.js +331 -0
  44. package/dist/assets/index-BEfrNBp8.js +123 -0
  45. package/dist/assets/index-yyrA5OZd.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
  47. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
  48. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
  49. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
  50. package/dist/assets/katex-DkKDou_j.js +257 -0
  51. package/dist/assets/layout-BI8cXFPI.js +1 -0
  52. package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
  53. package/dist/assets/min-vPHfnXcC.js +1 -0
  54. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
  55. package/dist/assets/mobile-B7m9EO9D.js +17 -0
  56. package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
  57. package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
  58. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
  59. package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
  60. package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
  61. package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
  63. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
  64. package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
  65. package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
  66. package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
  67. package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
  68. package/dist/index.html +3 -3
  69. package/lib/agent-string.js +7 -5
  70. package/lib/append-integrity.js +81 -0
  71. package/lib/backfill-evidence.js +109 -0
  72. package/lib/bug-escalation.js +39 -4
  73. package/lib/build-stream-schema.js +3 -1
  74. package/lib/build-stream-writer.js +25 -0
  75. package/lib/build.js +1624 -195
  76. package/lib/canon-guard.js +245 -0
  77. package/lib/canon-override.js +196 -0
  78. package/lib/canon-registry.js +291 -0
  79. package/lib/cli-commands.js +144 -0
  80. package/lib/codex-preflight.js +50 -15
  81. package/lib/colleague/context.js +215 -0
  82. package/lib/colleague/writeback.js +95 -0
  83. package/lib/completion-gate.js +1421 -0
  84. package/lib/completion-writer.js +47 -47
  85. package/lib/consumer-fanout.js +105 -11
  86. package/lib/coverage-gate.js +200 -0
  87. package/lib/dir-lock.js +170 -0
  88. package/lib/dispatch-ledger.js +301 -0
  89. package/lib/dispatch-metrics.js +236 -0
  90. package/lib/experiment-judge.js +6 -1
  91. package/lib/feature-json.js +1 -1
  92. package/lib/feature-reconciler.js +8 -0
  93. package/lib/feature-validator.js +64 -1
  94. package/lib/feature-writer.js +66 -2
  95. package/lib/fluid/factory.js +167 -0
  96. package/lib/fluid/ideabox-dates.js +73 -0
  97. package/lib/fluid/ideabox-migrate.js +154 -0
  98. package/lib/fluid/ideabox-ops.js +585 -0
  99. package/lib/fluid/ideabox-view.js +146 -0
  100. package/lib/fluid/import-ideabox.js +186 -0
  101. package/lib/fluid/local-provider.js +606 -0
  102. package/lib/fluid/provider.js +684 -0
  103. package/lib/fluid/record-shape.js +214 -0
  104. package/lib/fluid/record-store.js +328 -0
  105. package/lib/fluid/render-ideabox.js +261 -0
  106. package/lib/fluid/schema.js +40 -0
  107. package/lib/fluid/smartmemory-provider.js +1695 -0
  108. package/lib/gsd.js +63 -14
  109. package/lib/guard-cli.js +175 -0
  110. package/lib/guard-custody.js +141 -0
  111. package/lib/guard-descriptors.js +530 -0
  112. package/lib/guard-enrol.js +254 -0
  113. package/lib/health-score.js +1 -1
  114. package/lib/hooks-status.js +32 -3
  115. package/lib/ideabox-cli.js +315 -0
  116. package/lib/ideabox.js +121 -21
  117. package/lib/judgment/store/index.js +166 -0
  118. package/lib/judgment/store/records.js +184 -25
  119. package/lib/judgment/trace.js +380 -0
  120. package/lib/judgment-attest.js +259 -0
  121. package/lib/judgment-decision-write.js +277 -0
  122. package/lib/judgment-decisions.js +466 -0
  123. package/lib/judgment-gen.js +375 -22
  124. package/lib/judgment-verify.js +153 -0
  125. package/lib/judgment-writer.js +2842 -262
  126. package/lib/lane-gate.js +2 -0
  127. package/lib/lifecycle-modes.js +4 -4
  128. package/lib/lineage.js +400 -0
  129. package/lib/local-claude-connector.js +250 -54
  130. package/lib/maya-client.js +302 -0
  131. package/lib/maya-config.js +53 -0
  132. package/lib/maya-identity.js +283 -0
  133. package/lib/mcp-enforcement.js +21 -35
  134. package/lib/migrate-anon.js +5 -0
  135. package/lib/migrate-roadmap.js +15 -0
  136. package/lib/new.js +13 -1
  137. package/lib/pipeline-compat.js +104 -0
  138. package/lib/policy-catalog.js +295 -0
  139. package/lib/policy-check.js +0 -0
  140. package/lib/process-termination.js +98 -0
  141. package/lib/resolve-workspace.js +5 -1
  142. package/lib/result-normalizer.js +428 -153
  143. package/lib/review-normalize.js +4 -0
  144. package/lib/roadmap-errors.js +65 -0
  145. package/lib/roadmap-preservers.js +24 -4
  146. package/lib/roadmap-residue.js +299 -0
  147. package/lib/smartmemory-client.js +614 -78
  148. package/lib/smartmemory-config.js +54 -0
  149. package/lib/smartmemory-ingest.js +19 -2
  150. package/lib/step-prompt.js +7 -6
  151. package/lib/stratum-engine.js +53 -4
  152. package/lib/stratum-mcp-client.js +391 -31
  153. package/lib/test-bootstrap.js +31 -0
  154. package/lib/tool-inventory.js +122 -0
  155. package/lib/version-check.js +91 -19
  156. package/lib/vision-writer.js +88 -1
  157. package/package.json +7 -6
  158. package/pipelines/bug-fix.stratum.yaml +205 -211
  159. package/pipelines/build-quick.profiles.json +12 -0
  160. package/pipelines/build-quick.stratum.yaml +263 -350
  161. package/pipelines/content.stratum.yaml +81 -77
  162. package/pipelines/coverage-sweep.stratum.yaml +49 -30
  163. package/pipelines/plan.stratum.yaml +76 -86
  164. package/pipelines/refactor.stratum.yaml +125 -125
  165. package/pipelines/research.stratum.yaml +56 -58
  166. package/pipelines/review-fix.profiles.json +6 -0
  167. package/pipelines/review-fix.stratum.yaml +110 -83
  168. package/presets/team-feature.profiles.json +6 -0
  169. package/presets/team-feature.stratum.yaml +93 -66
  170. package/presets/team-research.profiles.json +6 -0
  171. package/presets/team-research.stratum.yaml +89 -80
  172. package/presets/team-review.profiles.json +8 -0
  173. package/presets/team-review.stratum.yaml +98 -80
  174. package/scripts/cost-census.mjs +70 -0
  175. package/scripts/guard-sign/compose-guard-sign.sh +62 -0
  176. package/server/agent-health.js +22 -0
  177. package/server/agent-hooks.js +14 -1
  178. package/server/agent-server.js +5 -248
  179. package/server/agent-spawn.js +3 -4
  180. package/server/agent-workspace.js +294 -0
  181. package/server/build-routes.js +6 -5
  182. package/server/build-stream-bridge.js +53 -0
  183. package/server/cc-session-watcher.js +4 -1
  184. package/server/coalescing-buffer.js +7 -1
  185. package/server/completion-projection.js +228 -0
  186. package/server/compose-mcp-tools.js +124 -24
  187. package/server/compose-mcp.js +91 -790
  188. package/server/decision-event-emit.js +41 -2
  189. package/server/decision-event-id.js +17 -0
  190. package/server/decision-events-snapshot.js +3 -0
  191. package/server/design-routes.js +14 -8
  192. package/server/feature-scan.js +76 -2
  193. package/server/file-watcher.js +170 -21
  194. package/server/ideabox-routes.js +166 -224
  195. package/server/index.js +70 -100
  196. package/server/lifecycle-guard.js +240 -10
  197. package/server/lifecycle-phase-history.js +276 -0
  198. package/server/maya-routes.js +507 -0
  199. package/server/mcp-tool-defs.js +940 -0
  200. package/server/mcp-tool-policy.js +35 -3
  201. package/server/model-tiers.js +22 -5
  202. package/server/pipeline-routes.js +21 -11
  203. package/server/project-root.js +58 -19
  204. package/server/remote-utils.js +3 -1
  205. package/server/schema-validator.js +7 -1
  206. package/server/session-manager.js +5 -6
  207. package/server/session-routes.js +3 -1
  208. package/server/stratum-client.js +57 -10
  209. package/server/stratum-sync.js +6 -3
  210. package/server/summarizer.js +3 -4
  211. package/server/supervisor.js +0 -1
  212. package/server/vision-routes.js +208 -98
  213. package/server/vision-server.js +86 -23
  214. package/server/vision-store.js +60 -6
  215. package/server/vision-utils.js +3 -4
  216. package/server/workspace-activity.js +18 -0
  217. package/server/workspace-middleware.js +2 -2
  218. package/server/workspace-runtime.js +243 -0
  219. package/server/worktree-gc.js +1 -0
  220. package/dist/assets/App-BG3ngu8H.js +0 -896
  221. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  222. package/dist/assets/arc-_v4hR_uD.js +0 -1
  223. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  224. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  225. package/dist/assets/channel-B3U5wFAT.js +0 -1
  226. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  227. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  228. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  229. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  230. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  231. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  232. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  233. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  234. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  235. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  236. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  237. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  238. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  239. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  240. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  241. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  242. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  243. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  244. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  245. package/dist/assets/graph-BXPQrYYB.js +0 -331
  246. package/dist/assets/graph-C9eacEi8.js +0 -1
  247. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  248. package/dist/assets/index-LIwREYgH.css +0 -1
  249. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  250. package/dist/assets/katex-C5jXJg4s.js +0 -257
  251. package/dist/assets/layout-DEXfKzaS.js +0 -1
  252. package/dist/assets/map-Czzmt4hB.js +0 -1
  253. package/dist/assets/mobile-CaoXUwAr.js +0 -17
  254. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  255. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  256. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  257. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  258. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  259. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  260. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  261. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  262. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  263. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  264. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  265. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  266. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  267. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
  268. package/lib/staleness.js +0 -87
  269. package/server/ideabox-cache.js +0 -77
@@ -14,9 +14,11 @@
14
14
  * <cwd>/.compose/data/locks/feature-<feature_code>.lock
15
15
  * (Decision 10; mirrors acquireLock pattern from lib/idempotency.js:42).
16
16
  *
17
- * setFeatureStatus is lazy-imported inside the writer to avoid circular load.
18
- * (feature-writer.js must NOT import completion-writer.js that would create
19
- * a cycle. The lazy import here is intentional and must stay inside the function.)
17
+ * The completion gate is lazy-imported inside the writer to avoid circular load
18
+ * (gate → writer → gate). The lazy import is intentional and must stay inside
19
+ * the function. Since COMP-COMPLETION-GATE slice 3 this writer never flips
20
+ * status itself: the completing path (set_status ≠ false) delegates to the
21
+ * gate, and the gate calls back with set_status:false for the record write.
20
22
  *
21
23
  * No HTTP, no transport awareness.
22
24
  */
@@ -113,10 +115,16 @@ function notFoundError(code) {
113
115
  return e;
114
116
  }
115
117
 
116
- function statusFlipError(message, cause) {
117
- const e = new Error(message);
118
- e.code = 'STATUS_FLIP_AFTER_COMPLETION_RECORDED';
119
- if (cause) e.cause = cause;
118
+ function completionRefusedError(gated) {
119
+ const reasons = (gated.reasons || []).join('; ') || 'refused';
120
+ const e = new Error(
121
+ `completion-writer: completion of "${gated.featureCode}" refused at ${gated.refusedAt} — ${reasons}. ` +
122
+ `Nothing was written.`,
123
+ );
124
+ e.code = 'COMPLETION_REFUSED';
125
+ e.refusedAt = gated.refusedAt;
126
+ e.reasons = gated.reasons || [];
127
+ if (gated.error) e.cause = gated.error;
120
128
  return e;
121
129
  }
122
130
 
@@ -287,6 +295,34 @@ export async function recordCompletion(cwd, args) {
287
295
  // 1. Validate
288
296
  validate(args);
289
297
 
298
+ // COMP-COMPLETION-GATE slice 3 (AC-7, Decision 2): a completion that moves
299
+ // the feature goes THROUGH the gate — evidence, guarded transition, then the
300
+ // gate performs every write (record, status, ROADMAP, vision, events) in
301
+ // §2.3a order. This writer no longer flips status itself: `setFeatureStatus`
302
+ // refuses COMPLETE unconditionally (AC-9), and the only module authorized to
303
+ // raw-write COMPLETE is the gate. `set_status:false` is the record-only path
304
+ // the gate itself calls back into (step 1 of §2.3a) and that evidence-only
305
+ // callers use; it never touches status. No recursion: the gate always passes
306
+ // `set_status:false`.
307
+ if (args.set_status !== false) {
308
+ const { completionGate } = await import('./completion-gate.js');
309
+ const gated = await completionGate({
310
+ featureCode: args.feature_code,
311
+ ...(args.commit_sha === undefined || args.commit_sha === null ? {} : { commitSha: args.commit_sha }),
312
+ testsPass: args.tests_pass,
313
+ filesChanged: args.files_changed,
314
+ notes: args.notes,
315
+ force: args.force,
316
+ builtVia: args.built_via,
317
+ idempotencyKey: args.idempotency_key,
318
+ workspaceRoot: cwd,
319
+ ...(args.evidence_root ? { evidenceRoot: args.evidence_root } : {}),
320
+ ...(args.mode ? { mode: args.mode } : {}),
321
+ });
322
+ if (!gated.ok) throw completionRefusedError({ featureCode: args.feature_code, ...gated });
323
+ return gated.result;
324
+ }
325
+
290
326
  // 2. Normalize SHA (omitted/null ⇒ NULL_SHA sentinel — commit-less completion)
291
327
  const commit_sha = (args.commit_sha === undefined || args.commit_sha === null)
292
328
  ? NULL_SHA
@@ -366,46 +402,10 @@ export async function recordCompletion(cwd, args) {
366
402
  ...(args.built_via ? { built_via: args.built_via } : {}),
367
403
  });
368
404
 
369
- // 5i. Status flip (default on)
370
- const set_status = args.set_status !== false;
371
- let status_changed = null;
372
-
373
- if (set_status && feature.status !== 'COMPLETE') {
374
- const fromStatus = feature.status;
375
- // Terminal states (KILLED, SUPERSEDED) have no valid outgoing transitions.
376
- // We deliberately do NOT force for terminal states so that the transition
377
- // policy enforcement fires and produces the STATUS_FLIP_AFTER_COMPLETION_RECORDED
378
- // error (test #11 / Decision 4). For non-terminal states we pass force: true
379
- // so that intermediate-state features (e.g. PLANNED → COMPLETE) succeed without
380
- // requiring callers to manually walk through IN_PROGRESS first.
381
- const TERMINAL_STATES = new Set(['KILLED', 'SUPERSEDED']);
382
- const flipForce = !TERMINAL_STATES.has(fromStatus);
383
- try {
384
- // Lazy-import setFeatureStatus to avoid circular load
385
- // (completion-writer.js must not be statically imported by feature-writer.js)
386
- const { setFeatureStatus } = await import('./feature-writer.js');
387
- await setFeatureStatus(cwd, {
388
- code: feature_code,
389
- status: 'COMPLETE',
390
- commit_sha,
391
- reason: 'record_completion',
392
- force: flipForce,
393
- });
394
- status_changed = { from: fromStatus, to: 'COMPLETE' };
395
- } catch (flipErr) {
396
- // Both failure sub-cases (transition rejected AND ROADMAP_PARTIAL_WRITE) rethrow
397
- // as STATUS_FLIP_AFTER_COMPLETION_RECORDED. The completion record IS persisted (step h).
398
- throw statusFlipError(
399
- flipErr.code === 'ROADMAP_PARTIAL_WRITE'
400
- ? `completion-writer: completion recorded for "${feature_code}" but ROADMAP regen failed after status flip. ` +
401
- `This is the ROADMAP_PARTIAL_WRITE subcase (Decision 4). ` +
402
- `Recover with \`compose roadmap generate\`.`
403
- : `completion-writer: completion recorded for "${feature_code}" but status flip to COMPLETE failed. ` +
404
- `err.cause carries the underlying transition error.`,
405
- flipErr,
406
- );
407
- }
408
- }
405
+ // 5i. No status flip here. The gate owns it (§2.3a step 2) — see the
406
+ // delegation at the top of this function. This branch is record-only.
407
+ const set_status = false;
408
+ const status_changed = null;
409
409
 
410
410
  // 5j. Audit event (not appended for idempotent no-ops — they return early at 5e)
411
411
  const auditEvent = {
@@ -143,19 +143,80 @@ function cumulativeDiff(cwd, baseCommit) {
143
143
  });
144
144
  }
145
145
 
146
+ /**
147
+ * Apply one lane's cumulative diff to a temporary index.
148
+ *
149
+ * `--3way`: every lane diffs against the SAME baseline, so once lane 1 has
150
+ * landed, lane 2's hunks still carry baseline context. A plain `git apply`
151
+ * rejects any hunk whose context line was touched by an earlier lane — even
152
+ * when the edits themselves do not overlap (two lanes appending tests to the
153
+ * same file, 2026-08-30: `patch failed: test/version-check.test.js:5`, four
154
+ * paid revise rounds). Three-way falls back to merging against the recorded
155
+ * preimage blobs (`index` lines in the diff), so only a genuine overlap fails.
156
+ * A genuine conflict still throws: `git apply` exits non-zero and the temporary
157
+ * index (with its conflict entries) is discarded by withTemporaryIndex.
158
+ */
159
+ function applyDiffToIndex(cwd, diff, env) {
160
+ git(cwd, ['apply', '--cached', '--3way', '--binary', '-'], {
161
+ env,
162
+ input: diff,
163
+ timeout: 60_000,
164
+ });
165
+ }
166
+
167
+ /** Working tree + one lane diff, merged in a temporary index; returns the tree id. */
168
+ function mergeDiffIntoWorkingTree(cwd, diff) {
169
+ return withTemporaryIndex(cwd, (env) => {
170
+ git(cwd, ['read-tree', 'HEAD'], { env });
171
+ git(cwd, ['add', '-A'], { env });
172
+ applyDiffToIndex(cwd, diff, env);
173
+ return git(cwd, ['write-tree'], { env });
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Move the working tree from one snapshot tree to another WITHOUT `git clean`.
179
+ *
180
+ * A two-tree `read-tree -m -u` writes exactly the paths that differ between
181
+ * the trees and leaves untracked and ignored content alone. restoreWorkingTree
182
+ * (baseline restore) cleans first, which is right for a full reset but wrong
183
+ * mid-merge: a lane that edits .gitignore makes previously ignored files
184
+ * visible, and a clean under the new rules would delete files no snapshot ever
185
+ * captured (Codex review of e98ea87, 2026-08-30). The real index is untouched.
186
+ */
187
+ function checkoutTreeDelta(cwd, fromTree, toTree) {
188
+ withTemporaryIndex(cwd, (env) => {
189
+ git(cwd, ['read-tree', fromTree], { env });
190
+ // A freshly read index has no stat data, so every entry looks modified and
191
+ // the two-tree merge refuses ("not uptodate"). Refresh against the working
192
+ // tree, which matches fromTree by the witness check that precedes this.
193
+ git(cwd, ['update-index', '-q', '--refresh'], { env });
194
+ git(cwd, ['read-tree', '-m', '-u', fromTree, toTree], { env });
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Rollback variant of checkoutTreeDelta for a working tree that may be
200
+ * PARTIALLY written (a forward checkout that threw mid-way). `--reset -u`
201
+ * rewrites every path listed in fromTree or toTree to its toTree state without
202
+ * demanding an up-to-date worktree, and — unlike restoreWorkingTree — never
203
+ * runs `git clean`, so untracked and ignored content is untouched (Codex r3).
204
+ * fromTree must be a SUPERSET of anything the forward pass may have written.
205
+ */
206
+ function rollbackTreeDelta(cwd, fromTree, toTree) {
207
+ withTemporaryIndex(cwd, (env) => {
208
+ git(cwd, ['read-tree', fromTree], { env });
209
+ git(cwd, ['read-tree', '--reset', '-u', toTree], { env });
210
+ });
211
+ }
212
+
146
213
  function computeWitnessChain(cwd, orderedEntries) {
147
214
  return withTemporaryIndex(cwd, (env) => {
148
215
  git(cwd, ['read-tree', 'HEAD'], { env });
149
216
  git(cwd, ['add', '-A'], { env });
150
217
  const chain = [git(cwd, ['write-tree'], { env })];
151
218
  for (const entry of orderedEntries) {
152
- if (entry.diff.length > 0) {
153
- git(cwd, ['apply', '--cached', '--binary', '-'], {
154
- env,
155
- input: entry.diff,
156
- timeout: 60_000,
157
- });
158
- }
219
+ if (entry.diff.length > 0) applyDiffToIndex(cwd, entry.diff, env);
159
220
  chain.push(git(cwd, ['write-tree'], { env }));
160
221
  }
161
222
  return chain;
@@ -808,7 +869,20 @@ export class ConsumerFanoutArtifacts {
808
869
  // evidence but owe no diff (a pure-none fanout yields zero ordered diffs).
809
870
  const worktreeAccepted = accepted.filter((entry) => entry.isolation !== 'none');
810
871
  const baselineTree = snapshotWorkingTree(this.targetCwd);
811
- const orderedDiffs = worktreeAccepted.map((entry) => ({
872
+ // A worker that changed NOTHING owes an empty diff, not a missing one, and it
873
+ // must not join the merge: applying nothing leaves the tree identical, so its
874
+ // witness entry repeats its predecessor and the uniqueness check below fires
875
+ // — blocking a merge whose only fault is that one worker had nothing to do.
876
+ // That is the normal outcome whenever a task is fanned out more ways than it
877
+ // divides (observed: 2 workers on a 2-edit single-file doc change, one
878
+ // no-op). computeWitnessChain already skips `apply` for a zero-length diff;
879
+ // it just pushed a tree anyway, so the intent was there and the chain wasn't.
880
+ //
881
+ // Filtered AFTER the completeness accounting below reads `worktreeAccepted`,
882
+ // so a NULL diff (never captured — a real fault) still fails there. Empty and
883
+ // absent are different things and stay that way.
884
+ const mergeParticipants = worktreeAccepted.filter((entry) => (entry.diff?.length ?? 0) > 0);
885
+ const orderedDiffs = mergeParticipants.map((entry) => ({
812
886
  dispatchToken: entry.dispatchToken,
813
887
  scopedId: entry.scopedId,
814
888
  itemIndex: entry.itemIndex,
@@ -849,7 +923,7 @@ export class ConsumerFanoutArtifacts {
849
923
 
850
924
  let witnessChain;
851
925
  try {
852
- witnessChain = computeWitnessChain(this.targetCwd, worktreeAccepted);
926
+ witnessChain = computeWitnessChain(this.targetCwd, mergeParticipants);
853
927
  } catch (error) {
854
928
  recordBlocked(
855
929
  'MERGE_WITNESS_PRECOMPUTE_FAILED',
@@ -983,6 +1057,8 @@ export class ConsumerFanoutArtifacts {
983
1057
 
984
1058
  for (let orderedIndex = prefix; orderedIndex < orderedDiffs.length; orderedIndex += 1) {
985
1059
  const ordered = orderedDiffs[orderedIndex];
1060
+ // Tree the working tree's TRACKED content currently equals (by witness).
1061
+ let trackedTree = witnessChain[orderedIndex];
986
1062
  try {
987
1063
  if (typeof this.hooks.insideDiffApply === 'function') {
988
1064
  await this.hooks.insideDiffApply({
@@ -1003,7 +1079,17 @@ export class ConsumerFanoutArtifacts {
1003
1079
  // not a lockfile in this slice.
1004
1080
  const live = this.#currentTransaction(gateToken);
1005
1081
  if (live) this.#assertMergeNotDecided(live, gateToken);
1006
- git(this.targetCwd, ['apply', '--binary', '-'], { input: ordered.diff, timeout: 60_000 });
1082
+ // Same merge algorithm as the witness precompute (temporary index,
1083
+ // three-way), then the merged tree is checked out. Applying straight to
1084
+ // the working tree cannot use --3way (it requires the real index to match).
1085
+ const mergedTree = mergeDiffIntoWorkingTree(this.targetCwd, ordered.diff);
1086
+ // Mark the merged tree as written BEFORE the checkout starts: if the
1087
+ // checkout throws part-way, the rollback still knows every path it may
1088
+ // have touched. A rollback must transition from that tree, never from a
1089
+ // fresh snapshot (which would reclassify newly un-ignored files as
1090
+ // tracked and delete them on the way back to baseline — Codex r2/r3).
1091
+ trackedTree = mergedTree;
1092
+ checkoutTreeDelta(this.targetCwd, witnessChain[orderedIndex], mergedTree);
1007
1093
  const landedTree = snapshotWorkingTree(this.targetCwd);
1008
1094
  const expectedTree = witnessChain[orderedIndex + 1];
1009
1095
  if (landedTree !== expectedTree) {
@@ -1020,7 +1106,15 @@ export class ConsumerFanoutArtifacts {
1020
1106
  // as-is (no baseline restore / block) rather than mislabeling it as an
1021
1107
  // apply failure over a round another party already owns.
1022
1108
  if (error instanceof ConsumerMergeDecisionError) throw error;
1023
- restoreWorkingTree(this.targetCwd, baselineTree);
1109
+ // Roll back by tree delta from the last tree we may have WRITTEN, never
1110
+ // by clean and never from a fresh snapshot: the failing lane may have
1111
+ // just un-ignored files that no snapshot holds. Reset-style so a
1112
+ // partially written checkout rolls back too.
1113
+ rollbackTreeDelta(this.targetCwd, trackedTree, baselineTree);
1114
+ const restored = snapshotWorkingTree(this.targetCwd);
1115
+ if (restored !== baselineTree) {
1116
+ throw new Error(`consumer merge rollback verification failed: expected ${baselineTree}, got ${restored}`);
1117
+ }
1024
1118
  this.#mutate(() => {
1025
1119
  const tx = this.journal.mergeTransactions.find((e) => e.gateToken === gateToken);
1026
1120
  // Never overwrite a concurrent decision, even on the error path.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * coverage-gate.js — COMP-COVERAGE-GATE slice 2.
3
+ *
4
+ * The check half of the feature. Slice 1 made the mutating surface DECLARED
5
+ * (`effect`/`writes` on every tool definition, derived by lib/tool-inventory.js);
6
+ * this module cross-checks that declaration against the two lists that are
7
+ * supposed to govern it — lib/canon-registry.js entries and
8
+ * server/mcp-tool-policy.js profile policy — and reports what neither accounts
9
+ * for.
10
+ *
11
+ * Pure, no I/O. Callers supply the three inputs (shape template:
12
+ * server/mcp-tool-policy.js).
13
+ *
14
+ * WHAT THIS ANSWERS. "Could this tool ever be enforced?" — not "was this call
15
+ * allowed?". It is a static check over declarations and adds NO enforcement
16
+ * point (design Decision 3); the runtime points (`hook`, `ship`, the MCP
17
+ * CallTool dispatch) are untouched, and the blast radius is validate_project's
18
+ * output.
19
+ *
20
+ * ADVISORY, WITH ONE HARD TIER (design Decision 2). Only MISSING_EFFECT is an
21
+ * error: it is unambiguous and one-line-fixable. C2–C4 encode conventions that
22
+ * can have legitimate exceptions, and a gate that blocks on a judgment call
23
+ * gets disabled.
24
+ *
25
+ * EVERY FINDING CARRIES A REMEDIATION, NOT A VERDICT (design Decision 4) — a
26
+ * specific instruction naming the file and the list to edit. That is the one
27
+ * PolicyGuide (arXiv:2608.19861 §3.3) result that transfers at zero cost:
28
+ * returning the required NEXT ACTION changes agent behavior where a bare
29
+ * allow/deny does not.
30
+ *
31
+ * HONEST LIMIT. This checks declarations, not behavior. A tool declaring
32
+ * `writes: []` while writing feature.json through a helper passes every check
33
+ * here. Closing that needs runtime write-path attribution — the `ship` point's
34
+ * correlation job, not this one.
35
+ */
36
+
37
+ /**
38
+ * C4 exceptions — mutating tools an implementer-profile session is INTENDED to
39
+ * call, so their absence from IMPLEMENTER_DENY is a decision, not an omission.
40
+ *
41
+ * This list lives next to the check rather than in a doc on purpose: a doc
42
+ * nobody loads is how the three lists drifted in the first place (design, Open
43
+ * Questions). Adding a tool here is a recorded ruling and needs a reason.
44
+ *
45
+ * The bar: does an implementer doing ordinary feature work need it? Recording
46
+ * its own artifacts, notes and iteration state — yes. Granting itself
47
+ * authority, or writing the decision record — no.
48
+ */
49
+ export const C4_EXCEPTIONS = {
50
+ scaffold_feature: 'implementer creates the feature folder templates it then fills in',
51
+ link_artifact: 'implementer records the artifacts it produced',
52
+ link_features: 'implementer records dependencies it discovered while building',
53
+ write_journal_entry: 'implementer progress notes — non-canon, append-only',
54
+ write_checkpoint: 'implementer checkpoints its own run — non-canon',
55
+ start_iteration_loop: 'the iteration loop is driven BY the implementer',
56
+ report_iteration_result: 'the iteration loop is driven BY the implementer',
57
+ abort_iteration_loop: 'the implementer must be able to stop its own loop',
58
+ add_changelog_entry: 'CHANGELOG entry ships in the same commit as the code (documentation standard)',
59
+
60
+ // The eight judgment writers. NOT an omission — COMP-JUDGMENT-WRITER
61
+ // design.md:137 rules that the write tools "stay implementer/orchestrator-only",
62
+ // with the provenance argument aimed squarely at REVIEWERS (a reviewer writing
63
+ // canon directly would be a second unattributed author). C4 raised all eight
64
+ // on its first run because the ruling lived only in prose; recording it here
65
+ // is the closure. Reversing it is a deliberate revisit of that design, not a
66
+ // coverage fix — and `test/judgment-writer-mcp.test.js` asserts the current
67
+ // behavior directly, so a reversal has one obvious place to start.
68
+ judgment_position_create: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
69
+ judgment_position_amend: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
70
+ judgment_joint_add: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
71
+ judgment_transition: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
72
+ judgment_ledger_append: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
73
+ judgment_person_write: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
74
+ judgment_situation_write: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
75
+ judgment_goal_write: 'COMP-JUDGMENT-WRITER design.md:137 — judgment writes are implementer/orchestrator-only by design',
76
+ };
77
+
78
+ /** Findings are reported in this order — most actionable first. */
79
+ const CODE_RANK = ['MISSING_EFFECT', 'UNGATED_MUTATION', 'ORPHAN_REGISTRY_TOOL', 'UNCOVERED_WRITE'];
80
+
81
+ /**
82
+ * @param {object} args
83
+ * @param {{read:string[], mutating:string[], setup:string[],
84
+ * undeclared:Array<{name:string,reason:string}>,
85
+ * writesByTool:Record<string,string[]>}} args.inventory
86
+ * — lib/tool-inventory.js loadToolInventory() result.
87
+ * @param {Array<{id:string, display?:string, tools:string[]}>} args.registry
88
+ * — lib/canon-registry.js canonEntries().
89
+ * @param {{PROFILE_POLICY:object, PHASE_REFINEMENT?:object}} args.policy
90
+ * — server/mcp-tool-policy.js.
91
+ * @returns {{ findings: Array<{code:string, tool:string, path?:string,
92
+ * severity:'error'|'warning'|'info', remediation:string}> }}
93
+ */
94
+ export function checkAuthorizationCoverage({ inventory, registry, policy } = {}) {
95
+ const findings = [];
96
+
97
+ const inv = inventory || {};
98
+ const undeclared = Array.isArray(inv.undeclared) ? inv.undeclared : [];
99
+ const mutating = Array.isArray(inv.mutating) ? inv.mutating : [];
100
+ const writesByTool = inv.writesByTool || {};
101
+ const entries = Array.isArray(registry) ? registry : [];
102
+
103
+ // Every name the inventory has SEEN, declared or not — an undeclared tool
104
+ // still exists, so it must not also be reported as an orphan (C3).
105
+ const known = new Set([
106
+ ...(inv.read || []), ...mutating, ...(inv.setup || []),
107
+ ...undeclared.map((u) => u.name),
108
+ ]);
109
+
110
+ // ── C1 MISSING_EFFECT (hard) ───────────────────────────────────────────────
111
+ // Fail-closed on the declaration. This is the only check that can fail a
112
+ // validate run, because the fix is a single field with no judgment in it.
113
+ for (const { name, reason } of undeclared) {
114
+ findings.push({
115
+ code: 'MISSING_EFFECT',
116
+ tool: name,
117
+ severity: 'error',
118
+ remediation: `add \`effect: 'read' | 'mutating' | 'setup'\` to the '${name}' definition in server/mcp-tool-defs.js (${reason}); a mutating tool also needs \`writes: []\``,
119
+ });
120
+ }
121
+
122
+ // ── C4 UNGATED_MUTATION (the headline) ─────────────────────────────────────
123
+ // PROFILE_POLICY is the only one of the three lists that is actually
124
+ // ENFORCED — at the server/compose-mcp.js CallTool dispatch. A mutating tool
125
+ // named by none of its lists is callable by an implementer-profile session
126
+ // and nothing anywhere records whether that was intended.
127
+ const profilePolicy = (policy && policy.PROFILE_POLICY) || {};
128
+ const denyTools = _toolSet(profilePolicy.implementer);
129
+ const allowTools = _toolSet(profilePolicy.reviewer);
130
+ const refinementTools = new Set();
131
+ for (const set of Object.values((policy && policy.PHASE_REFINEMENT) || {})) {
132
+ for (const t of _iter(set)) refinementTools.add(t);
133
+ }
134
+
135
+ for (const tool of mutating) {
136
+ if (Object.prototype.hasOwnProperty.call(C4_EXCEPTIONS, tool)) continue;
137
+ if (denyTools.has(tool) || refinementTools.has(tool) || allowTools.has(tool)) continue;
138
+ findings.push({
139
+ code: 'UNGATED_MUTATION',
140
+ tool,
141
+ severity: 'warning',
142
+ remediation: `'${tool}' mutates state but is named by no profile list — add it to IMPLEMENTER_DENY in server/mcp-tool-policy.js, or record why an implementer may call it in C4_EXCEPTIONS (lib/coverage-gate.js)`,
143
+ });
144
+ }
145
+
146
+ // ── C3 ORPHAN_REGISTRY_TOOL ────────────────────────────────────────────────
147
+ // Reverse drift: the registry names a tool that no longer exists. Catches
148
+ // renames, which are otherwise silent — a renamed tool leaves its old name
149
+ // in a deny message that will never match anything again.
150
+ for (const entry of entries) {
151
+ for (const tool of entry.tools || []) {
152
+ if (known.has(tool)) continue;
153
+ findings.push({
154
+ code: 'ORPHAN_REGISTRY_TOOL',
155
+ tool,
156
+ path: entry.id,
157
+ severity: 'warning',
158
+ remediation: `canon entry '${entry.id}' names tool '${tool}', which is not in the tool inventory — remove it from lib/canon-registry.js, or restore the tool definition in server/mcp-tool-defs.js if it was renamed`,
159
+ });
160
+ }
161
+ }
162
+
163
+ // ── C2 UNCOVERED_WRITE (minor) ─────────────────────────────────────────────
164
+ // `entry.tools` has exactly ONE consumer — lib/canon-guard.js joins it into
165
+ // the deny message ("use one of these tools instead"). It is never an
166
+ // allow/deny input. So this keeps the REMEDIATION MESSAGE honest; it does not
167
+ // close a hole. Ranked last for that reason.
168
+ const entryById = new Map(entries.map((e) => [e.id, e]));
169
+ for (const tool of mutating) {
170
+ for (const canonId of writesByTool[tool] || []) {
171
+ const entry = entryById.get(canonId);
172
+ if (!entry) continue; // an unknown canon id is tool-inventory's `undeclared` job
173
+ if ((entry.tools || []).includes(tool)) continue;
174
+ findings.push({
175
+ code: 'UNCOVERED_WRITE',
176
+ tool,
177
+ path: canonId,
178
+ severity: 'info',
179
+ remediation: `'${tool}' declares it writes '${entry.display || canonId}' but is absent from that entry's tool list — add it in lib/canon-registry.js so a denied raw edit is offered it as an alternative`,
180
+ });
181
+ }
182
+ }
183
+
184
+ findings.sort((a, b) => {
185
+ const r = CODE_RANK.indexOf(a.code) - CODE_RANK.indexOf(b.code);
186
+ return r !== 0 ? r : a.tool.localeCompare(b.tool);
187
+ });
188
+ return { findings };
189
+ }
190
+
191
+ /** PROFILE_POLICY entries carry `tools` as a Set; tolerate an array or absence. */
192
+ function _toolSet(profile) {
193
+ return new Set(_iter(profile && profile.tools));
194
+ }
195
+
196
+ function _iter(v) {
197
+ if (v instanceof Set) return v;
198
+ if (Array.isArray(v)) return v;
199
+ return [];
200
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * lib/dir-lock.js — the one advisory lock, extracted.
3
+ *
4
+ * COMP-PLAN-IDEA-UNIFY S3b-1 (D17).
5
+ *
6
+ * `mkdir` is the primitive: it is atomic on every filesystem this runs on, and
7
+ * it fails with EEXIST rather than clobbering. Everything below exists to make
8
+ * that primitive survive the cases a bare `mkdirSync` does not.
9
+ *
10
+ * There were six independent copy-pasted versions of this in `lib/` when this
11
+ * module was written, and they did NOT agree — the weakest set its stale
12
+ * threshold equal to its acquire timeout, which makes ordinary contention
13
+ * indistinguishable from a crashed holder, so a busy lock gets stolen from a
14
+ * live owner. This is the hardened shape (from `lib/judgment-writer.js`),
15
+ * lifted so there is one implementation to reason about instead of six.
16
+ *
17
+ * The four things that are not obvious:
18
+ *
19
+ * 1. **An owner token inside the dir.** Without it, release cannot tell "my
20
+ * lock" from "the lock that replaced mine after it was declared stale", so
21
+ * a slow holder deletes the new owner's lock on the way out. This is the
22
+ * ABA case and it is the reason release reads before it removes.
23
+ *
24
+ * 2. **A partial acquisition is undone.** If the dir is created but the token
25
+ * write fails, every later release declines to remove a lock it cannot
26
+ * prove is its own — stranding it until the stale window expires. So that
27
+ * window is closed by hand rather than waited out.
28
+ *
29
+ * 3. **A heartbeat, and why the threshold is high.** The holder touches the
30
+ * dir's mtime on a timer so a long-but-live operation is not mistaken for a
31
+ * dead one. The timer runs on the event loop, so a SYNCHRONOUS block longer
32
+ * than the stale threshold defeats it. 20s is set well above any sync
33
+ * section this codebase produces (small-file fs I/O; long work is async).
34
+ *
35
+ * 4. **Stale reclaim is by mtime, not by pid.** A pid check cannot see across
36
+ * containers or a reused pid, and this lock guards files that two clones
37
+ * can reach.
38
+ *
39
+ * The lock is advisory: it coordinates writers that agree to use it. Nothing
40
+ * stops a writer that does not, which is why tool-owned canon also has the
41
+ * guard layer.
42
+ */
43
+
44
+ import {
45
+ mkdirSync,
46
+ readFileSync,
47
+ rmSync,
48
+ statSync,
49
+ utimesSync,
50
+ writeFileSync,
51
+ } from 'node:fs';
52
+ import { dirname, join } from 'node:path';
53
+
54
+ /** A holder is presumed dead once its mtime is this old. See note 3. */
55
+ const LOCK_STALE_MS = 20000;
56
+ /** How long to keep trying before giving up on a live holder. */
57
+ const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
58
+ /** Keeps a live holder from being declared stale. */
59
+ const LOCK_HEARTBEAT_MS = 1000;
60
+ /** Poll interval while waiting on a holder. */
61
+ const LOCK_RETRY_MS = 25;
62
+
63
+ /** The holder's token, or null if it cannot be read (racing, or never written). */
64
+ function readOwner(ownerFile) {
65
+ try { return readFileSync(ownerFile, 'utf8'); } catch { return null; }
66
+ }
67
+
68
+ export class DirLockTimeout extends Error {
69
+ constructor(path, ms) {
70
+ super(`dir-lock: timed out after ${ms}ms waiting for ${path}`);
71
+ this.name = 'DirLockTimeout';
72
+ this.code = 'DIR_LOCK_TIMEOUT';
73
+ this.path = path;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Acquire the lock at `path`, returning a release function.
79
+ *
80
+ * @param {string} path directory to create as the lock
81
+ * @returns {Promise<() => void>} release — idempotent, and a no-op if the lock
82
+ * is no longer provably ours
83
+ */
84
+ export async function acquireDirLock(path, { timeoutMs = LOCK_ACQUIRE_TIMEOUT_MS } = {}) {
85
+ const ownerFile = join(path, 'owner');
86
+ mkdirSync(dirname(path), { recursive: true });
87
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
88
+ const start = Date.now();
89
+
90
+ for (;;) {
91
+ try {
92
+ mkdirSync(path);
93
+ try {
94
+ writeFileSync(ownerFile, token);
95
+ } catch (err) {
96
+ // Note 2: undo our own partial acquisition rather than strand the lock.
97
+ rmSync(path, { recursive: true, force: true });
98
+ throw err;
99
+ }
100
+ const heartbeat = setInterval(() => {
101
+ try {
102
+ utimesSync(path, new Date(), new Date());
103
+ } catch { /* stolen or gone — release will no-op */ }
104
+ }, LOCK_HEARTBEAT_MS);
105
+ heartbeat.unref?.();
106
+
107
+ let released = false;
108
+ return () => {
109
+ if (released) return;
110
+ released = true;
111
+ clearInterval(heartbeat);
112
+ try {
113
+ // Note 1: only remove a lock still provably ours.
114
+ if (readFileSync(ownerFile, 'utf8') === token) {
115
+ rmSync(path, { recursive: true, force: true });
116
+ }
117
+ } catch { /* not ours anymore — leave it */ }
118
+ };
119
+ } catch (err) {
120
+ if (err.code !== 'EEXIST') throw err;
121
+ try {
122
+ const { mtimeMs } = statSync(path);
123
+ if (Date.now() - mtimeMs > LOCK_STALE_MS) {
124
+ // Reclaim is compare-and-delete, not blind delete. Two contenders can
125
+ // both stat the same stale lock and both decide to remove it; if the
126
+ // first then acquires a fresh one, a blind `rmSync` from the second
127
+ // deletes the NEW owner's lock and both proceed into the critical
128
+ // section. That is the ABA case on the acquire side — the release side
129
+ // already guarded against it, and this side did not.
130
+ //
131
+ // Re-reading the owner token and removing only if it still matches
132
+ // what we saw when we judged it stale closes the window: the winner
133
+ // rewrote the token, so the loser's compare fails and it retries.
134
+ const staleOwner = readOwner(ownerFile);
135
+ const { mtimeMs: recheck } = statSync(path);
136
+ if (recheck === mtimeMs && readOwner(ownerFile) === staleOwner) {
137
+ rmSync(path, { recursive: true, force: true });
138
+ }
139
+ continue;
140
+ }
141
+ } catch { /* stat raced the holder's release; loop and retry */ }
142
+ if (Date.now() - start > timeoutMs) {
143
+ throw new DirLockTimeout(path, timeoutMs);
144
+ }
145
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Run `fn` holding the lock at `path`. The lock is always released, including
152
+ * when `fn` throws.
153
+ *
154
+ * Not reentrant: calling this for the same path from inside `fn` deadlocks
155
+ * until the acquire timeout. Callers compose by locking once at the outermost
156
+ * mutating boundary.
157
+ *
158
+ * @template T
159
+ * @param {string} path
160
+ * @param {() => Promise<T>|T} fn
161
+ * @returns {Promise<T>}
162
+ */
163
+ export async function withDirLock(path, fn) {
164
+ const release = await acquireDirLock(path);
165
+ try {
166
+ return await fn();
167
+ } finally {
168
+ release();
169
+ }
170
+ }