@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.
- package/.claude/skills/compose/SKILL.md +42 -88
- package/.compose-deps.json +1 -13
- package/README.md +72 -5
- package/bin/compose.js +754 -347
- package/bin/git-hooks/pre-push.template +29 -0
- package/bin/judgment-import.js +7 -0
- package/bin/judgment-migrate.js +387 -0
- package/contracts/comp-obs-contract.schema.json +9 -3
- package/contracts/feature-json.schema.json +5 -0
- package/contracts/fluid-record.schema.json +209 -0
- package/contracts/judgment-record.schema.json +425 -4
- package/contracts/lifecycle-backfill.schema.json +322 -0
- package/dist/assets/App-Z4MU-H_F.js +916 -0
- package/dist/assets/_baseUniq-ClWoCPFl.js +1 -0
- package/dist/assets/arc-DY26UIVo.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-6Ggq4DqJ.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-CH3Ked0l.js +132 -0
- package/dist/assets/{browser-BSM23If2.js → browser-BWkrenen.js} +6 -6
- package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-Bk8dYilu.js} +1 -1
- package/dist/assets/channel-SnZzzh7k.js +1 -0
- package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-BMR0XaAQ.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-JytR14a9.js +206 -0
- package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-B4Q97BCP.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-R_qarkSf.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-C9s7KR9m.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-BySQzVxc.js +231 -0
- package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-DdpSYZsW.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-iE_tzriw.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CBu92dSH.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CBu92dSH.js +1 -0
- package/dist/assets/clone-DgklGjHm.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-BdlU6ZX_.js} +1 -1
- package/dist/assets/dagre-KV5264BT-Cp3F5KTn.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-DiR6_2q_.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-w0i-p5HX.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-tIHhwUv3.js +43 -0
- package/dist/assets/diagram-TYMM5635-BAeY3B19.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-Ckx_Knko.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-DeoNka6J.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-BmGnFbEg.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-Dk48IHsx.js +106 -0
- package/dist/assets/graph-BNzKGvoy.js +1 -0
- package/dist/assets/graph-CI_1htl0.js +331 -0
- package/dist/assets/index-BEfrNBp8.js +123 -0
- package/dist/assets/index-yyrA5OZd.css +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-BRf827i0.js +2 -0
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-0kCZaeCM.js} +5 -5
- package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-rvU7ayRt.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-DpQwX1C5.js} +7 -7
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-BI8cXFPI.js +1 -0
- package/dist/assets/{linear-Ck7gpa5N.js → linear-a0glcDiw.js} +1 -1
- package/dist/assets/min-vPHfnXcC.js +1 -0
- package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-D14eF-7C.js} +7 -7
- package/dist/assets/mobile-B7m9EO9D.js +17 -0
- package/dist/assets/pieDiagram-DEJITSTG-Cno-gETh.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-BUQM1Hfm.js +7 -0
- package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-pOXlN2-q.js} +3 -3
- package/dist/assets/sankeyDiagram-XADWPNL6-Crynd3_b.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-D9fZdCM8.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-CW9qVec8.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DkVLzHbY.js +1 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BcHzhm_8.js} +3 -3
- package/dist/assets/vennDiagram-DHZGUBPP-BfytJcWk.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DLj-IjyB.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-Ds0Ue68c.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-vjWDXFL6.js +7 -0
- package/dist/index.html +3 -3
- package/lib/agent-string.js +7 -5
- package/lib/append-integrity.js +81 -0
- package/lib/backfill-evidence.js +109 -0
- package/lib/bug-escalation.js +39 -4
- package/lib/build-stream-schema.js +3 -1
- package/lib/build-stream-writer.js +25 -0
- package/lib/build.js +1624 -195
- package/lib/canon-guard.js +245 -0
- package/lib/canon-override.js +196 -0
- package/lib/canon-registry.js +291 -0
- package/lib/cli-commands.js +144 -0
- package/lib/codex-preflight.js +50 -15
- package/lib/colleague/context.js +215 -0
- package/lib/colleague/writeback.js +95 -0
- package/lib/completion-gate.js +1421 -0
- package/lib/completion-writer.js +47 -47
- package/lib/consumer-fanout.js +105 -11
- package/lib/coverage-gate.js +200 -0
- package/lib/dir-lock.js +170 -0
- package/lib/dispatch-ledger.js +301 -0
- package/lib/dispatch-metrics.js +236 -0
- package/lib/experiment-judge.js +6 -1
- package/lib/feature-json.js +1 -1
- package/lib/feature-reconciler.js +8 -0
- package/lib/feature-validator.js +64 -1
- package/lib/feature-writer.js +66 -2
- package/lib/fluid/factory.js +167 -0
- package/lib/fluid/ideabox-dates.js +73 -0
- package/lib/fluid/ideabox-migrate.js +154 -0
- package/lib/fluid/ideabox-ops.js +585 -0
- package/lib/fluid/ideabox-view.js +146 -0
- package/lib/fluid/import-ideabox.js +186 -0
- package/lib/fluid/local-provider.js +606 -0
- package/lib/fluid/provider.js +684 -0
- package/lib/fluid/record-shape.js +214 -0
- package/lib/fluid/record-store.js +328 -0
- package/lib/fluid/render-ideabox.js +261 -0
- package/lib/fluid/schema.js +40 -0
- package/lib/fluid/smartmemory-provider.js +1695 -0
- package/lib/gsd.js +63 -14
- package/lib/guard-cli.js +175 -0
- package/lib/guard-custody.js +141 -0
- package/lib/guard-descriptors.js +530 -0
- package/lib/guard-enrol.js +254 -0
- package/lib/health-score.js +1 -1
- package/lib/hooks-status.js +32 -3
- package/lib/ideabox-cli.js +315 -0
- package/lib/ideabox.js +121 -21
- package/lib/judgment/store/index.js +166 -0
- package/lib/judgment/store/records.js +184 -25
- package/lib/judgment/trace.js +380 -0
- package/lib/judgment-attest.js +259 -0
- package/lib/judgment-decision-write.js +277 -0
- package/lib/judgment-decisions.js +466 -0
- package/lib/judgment-gen.js +375 -22
- package/lib/judgment-verify.js +153 -0
- package/lib/judgment-writer.js +2842 -262
- package/lib/lane-gate.js +2 -0
- package/lib/lifecycle-modes.js +4 -4
- package/lib/lineage.js +400 -0
- package/lib/local-claude-connector.js +250 -54
- package/lib/maya-client.js +302 -0
- package/lib/maya-config.js +53 -0
- package/lib/maya-identity.js +283 -0
- package/lib/mcp-enforcement.js +21 -35
- package/lib/migrate-anon.js +5 -0
- package/lib/migrate-roadmap.js +15 -0
- package/lib/new.js +13 -1
- package/lib/pipeline-compat.js +104 -0
- package/lib/policy-catalog.js +295 -0
- package/lib/policy-check.js +0 -0
- package/lib/process-termination.js +98 -0
- package/lib/resolve-workspace.js +5 -1
- package/lib/result-normalizer.js +428 -153
- package/lib/review-normalize.js +4 -0
- package/lib/roadmap-errors.js +65 -0
- package/lib/roadmap-preservers.js +24 -4
- package/lib/roadmap-residue.js +299 -0
- package/lib/smartmemory-client.js +614 -78
- package/lib/smartmemory-config.js +54 -0
- package/lib/smartmemory-ingest.js +19 -2
- package/lib/step-prompt.js +7 -6
- package/lib/stratum-engine.js +53 -4
- package/lib/stratum-mcp-client.js +391 -31
- package/lib/test-bootstrap.js +31 -0
- package/lib/tool-inventory.js +122 -0
- package/lib/version-check.js +91 -19
- package/lib/vision-writer.js +88 -1
- package/package.json +7 -6
- package/pipelines/bug-fix.stratum.yaml +205 -211
- package/pipelines/build-quick.profiles.json +12 -0
- package/pipelines/build-quick.stratum.yaml +263 -350
- package/pipelines/content.stratum.yaml +81 -77
- package/pipelines/coverage-sweep.stratum.yaml +49 -30
- package/pipelines/plan.stratum.yaml +76 -86
- package/pipelines/refactor.stratum.yaml +125 -125
- package/pipelines/research.stratum.yaml +56 -58
- package/pipelines/review-fix.profiles.json +6 -0
- package/pipelines/review-fix.stratum.yaml +110 -83
- package/presets/team-feature.profiles.json +6 -0
- package/presets/team-feature.stratum.yaml +93 -66
- package/presets/team-research.profiles.json +6 -0
- package/presets/team-research.stratum.yaml +89 -80
- package/presets/team-review.profiles.json +8 -0
- package/presets/team-review.stratum.yaml +98 -80
- package/scripts/cost-census.mjs +70 -0
- package/scripts/guard-sign/compose-guard-sign.sh +62 -0
- package/server/agent-health.js +22 -0
- package/server/agent-hooks.js +14 -1
- package/server/agent-server.js +5 -248
- package/server/agent-spawn.js +3 -4
- package/server/agent-workspace.js +294 -0
- package/server/build-routes.js +6 -5
- package/server/build-stream-bridge.js +53 -0
- package/server/cc-session-watcher.js +4 -1
- package/server/coalescing-buffer.js +7 -1
- package/server/completion-projection.js +228 -0
- package/server/compose-mcp-tools.js +124 -24
- package/server/compose-mcp.js +91 -790
- package/server/decision-event-emit.js +41 -2
- package/server/decision-event-id.js +17 -0
- package/server/decision-events-snapshot.js +3 -0
- package/server/design-routes.js +14 -8
- package/server/feature-scan.js +76 -2
- package/server/file-watcher.js +170 -21
- package/server/ideabox-routes.js +166 -224
- package/server/index.js +70 -100
- package/server/lifecycle-guard.js +240 -10
- package/server/lifecycle-phase-history.js +276 -0
- package/server/maya-routes.js +507 -0
- package/server/mcp-tool-defs.js +940 -0
- package/server/mcp-tool-policy.js +35 -3
- package/server/model-tiers.js +22 -5
- package/server/pipeline-routes.js +21 -11
- package/server/project-root.js +58 -19
- package/server/remote-utils.js +3 -1
- package/server/schema-validator.js +7 -1
- package/server/session-manager.js +5 -6
- package/server/session-routes.js +3 -1
- package/server/stratum-client.js +57 -10
- package/server/stratum-sync.js +6 -3
- package/server/summarizer.js +3 -4
- package/server/supervisor.js +0 -1
- package/server/vision-routes.js +208 -98
- package/server/vision-server.js +86 -23
- package/server/vision-store.js +60 -6
- package/server/vision-utils.js +3 -4
- package/server/workspace-activity.js +18 -0
- package/server/workspace-middleware.js +2 -2
- package/server/workspace-runtime.js +243 -0
- package/server/worktree-gc.js +1 -0
- package/dist/assets/App-BG3ngu8H.js +0 -896
- package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
- package/dist/assets/arc-_v4hR_uD.js +0 -1
- package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
- package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
- package/dist/assets/channel-B3U5wFAT.js +0 -1
- package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
- package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
- package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
- package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
- package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
- package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
- package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
- package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
- package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
- package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
- package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
- package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
- package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
- package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
- package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
- package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
- package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
- package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
- package/dist/assets/graph-BXPQrYYB.js +0 -331
- package/dist/assets/graph-C9eacEi8.js +0 -1
- package/dist/assets/index-3ZH5eMcZ.js +0 -119
- package/dist/assets/index-LIwREYgH.css +0 -1
- package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DEXfKzaS.js +0 -1
- package/dist/assets/map-Czzmt4hB.js +0 -1
- package/dist/assets/mobile-CaoXUwAr.js +0 -17
- package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
- package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
- package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
- package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
- package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
- package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
- package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
- package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
- package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
- package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
- package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
- package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
- package/lib/staleness.js +0 -87
- package/server/ideabox-cache.js +0 -77
package/lib/completion-writer.js
CHANGED
|
@@ -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
|
-
*
|
|
18
|
-
* (
|
|
19
|
-
*
|
|
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
|
|
117
|
-
const
|
|
118
|
-
e
|
|
119
|
-
|
|
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.
|
|
370
|
-
|
|
371
|
-
|
|
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 = {
|
package/lib/consumer-fanout.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/dir-lock.js
ADDED
|
@@ -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
|
+
}
|