@smartmemory/compose 0.3.6-beta → 0.3.7

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 (134) hide show
  1. package/.claude/skills/compose/SKILL.md +42 -88
  2. package/bin/compose.js +288 -0
  3. package/bin/git-hooks/pre-push.template +29 -0
  4. package/bin/judgment-import.js +7 -0
  5. package/contracts/feature-json.schema.json +5 -0
  6. package/contracts/judgment-record.schema.json +425 -4
  7. package/dist/assets/App-PkZzHeMj.js +894 -0
  8. package/dist/assets/_baseUniq-Bo837sRJ.js +1 -0
  9. package/dist/assets/arc-BafGpyqE.js +1 -0
  10. package/dist/assets/architectureDiagram-Q4EWVU46-BOBfUsqL.js +36 -0
  11. package/dist/assets/blockDiagram-DXYQGD6D-Dwodev1a.js +132 -0
  12. package/dist/assets/{browser-BSM23If2.js → browser-1ntj1-x_.js} +6 -6
  13. package/dist/assets/{c4Diagram-LMCZKHZV-DZf45Fbz.js → c4Diagram-AHTNJAMY-CU_bhYag.js} +1 -1
  14. package/dist/assets/channel-qVK_qn4E.js +1 -0
  15. package/dist/assets/{chunk-JWPE2WC7-_7ujgd_Q.js → chunk-4BX2VUAB-p8WsDwnO.js} +1 -1
  16. package/dist/assets/chunk-4TB4RGXK-B8h7-eR0.js +206 -0
  17. package/dist/assets/{chunk-XXDRQBXY-DfdVhbmA.js → chunk-55IACEB6-DxeEr98s.js} +1 -1
  18. package/dist/assets/{chunk-VR4S4FIN-Dt9NZ67m.js → chunk-EDXVE4YY-BYt8F151.js} +1 -1
  19. package/dist/assets/{chunk-5VM5RSS4-BY4_PV5H.js → chunk-FMBD7UC4-DGSOVeie.js} +1 -1
  20. package/dist/assets/chunk-OYMX7WX6-B-QdgYR2.js +231 -0
  21. package/dist/assets/{chunk-2Q5K7J3B-Dn1spZYu.js → chunk-QZHKN3VN-Du5UAZLs.js} +1 -1
  22. package/dist/assets/{chunk-32BRIVSS-pURGrJDk.js → chunk-YZCP3GAM-C8JbNBSk.js} +1 -1
  23. package/dist/assets/classDiagram-6PBFFD2Q-B8UcfC1q.js +1 -0
  24. package/dist/assets/classDiagram-v2-HSJHXN6E-B8UcfC1q.js +1 -0
  25. package/dist/assets/clone-Pu3RyLUh.js +1 -0
  26. package/dist/assets/{cose-bilkent-JH36ORCC-BieYif4o.js → cose-bilkent-S5V4N54A-O1ESaqge.js} +1 -1
  27. package/dist/assets/dagre-KV5264BT-CPTmFPHw.js +4 -0
  28. package/dist/assets/diagram-5BDNPKRD-B3PNrWs5.js +10 -0
  29. package/dist/assets/diagram-G4DWMVQ6-Cscfr6vc.js +24 -0
  30. package/dist/assets/diagram-MMDJMWI5-CSfqZ-TM.js +43 -0
  31. package/dist/assets/diagram-TYMM5635-Cg4aYS7W.js +24 -0
  32. package/dist/assets/erDiagram-SMLLAGMA-_ZqwG5pl.js +85 -0
  33. package/dist/assets/flowDiagram-DWJPFMVM-C83boxFT.js +162 -0
  34. package/dist/assets/ganttDiagram-T4ZO3ILL-CWnIjuEi.js +292 -0
  35. package/dist/assets/gitGraphDiagram-UUTBAWPF-DrMdxZfH.js +106 -0
  36. package/dist/assets/graph-Bi99_6Yf.js +331 -0
  37. package/dist/assets/graph-RE4I7Ty7.js +1 -0
  38. package/dist/assets/index-Rm2RE-c0.js +123 -0
  39. package/dist/assets/infoDiagram-42DDH7IO-BLmP4Epr.js +2 -0
  40. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CzEB9fQS.js → ishikawaDiagram-UXIWVN3A-yuWWshKN.js} +5 -5
  41. package/dist/assets/{journeyDiagram-5HDEW3XC-Bz8TCdz2.js → journeyDiagram-VCZTEJTY-BOfhaJov.js} +1 -1
  42. package/dist/assets/{kanban-definition-HUTT4EX6-tozrMoV_.js → kanban-definition-6JOO6SKY-Bbolde15.js} +7 -7
  43. package/dist/assets/katex-DkKDou_j.js +257 -0
  44. package/dist/assets/layout-BSf33zm8.js +1 -0
  45. package/dist/assets/{linear-Ck7gpa5N.js → linear-AvSTWMqx.js} +1 -1
  46. package/dist/assets/min-QBM8H4xN.js +1 -0
  47. package/dist/assets/{mindmap-definition-LN4V7U3C-DTcHO0DJ.js → mindmap-definition-QFDTVHPH-BuvgtqIc.js} +7 -7
  48. package/dist/assets/{mobile-CaoXUwAr.js → mobile-BnXEOE3U.js} +2 -2
  49. package/dist/assets/pieDiagram-DEJITSTG-DIzF16vh.js +30 -0
  50. package/dist/assets/quadrantDiagram-34T5L4WZ-D-mbUIjS.js +7 -0
  51. package/dist/assets/{requirementDiagram-TGXJPOKE-bnI2zJeT.js → requirementDiagram-MS252O5E-CEs4kCLd.js} +3 -3
  52. package/dist/assets/sankeyDiagram-XADWPNL6-DFsnCr9n.js +10 -0
  53. package/dist/assets/sequenceDiagram-FGHM5R23-BEJYdTjQ.js +157 -0
  54. package/dist/assets/stateDiagram-FHFEXIEX-BBXs57uY.js +1 -0
  55. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BqKuX4rj.js +1 -0
  56. package/dist/assets/{timeline-definition-FHXFAJF6-D267GQFF.js → timeline-definition-GMOUNBTQ-BGvLoVAY.js} +3 -3
  57. package/dist/assets/vennDiagram-DHZGUBPP-9LaBTMe0.js +34 -0
  58. package/dist/assets/wardley-RL74JXVD-P4MEqMTP.js +162 -0
  59. package/dist/assets/wardleyDiagram-NUSXRM2D-o-tmxnlC.js +20 -0
  60. package/dist/assets/xychartDiagram-5P7HB3ND-Dpn7V6qk.js +7 -0
  61. package/dist/index.html +2 -2
  62. package/lib/bug-escalation.js +30 -4
  63. package/lib/build.js +777 -52
  64. package/lib/canon-guard.js +223 -0
  65. package/lib/canon-registry.js +187 -0
  66. package/lib/codex-preflight.js +26 -4
  67. package/lib/dispatch-ledger.js +301 -0
  68. package/lib/dispatch-metrics.js +236 -0
  69. package/lib/experiment-judge.js +6 -1
  70. package/lib/feature-writer.js +9 -0
  71. package/lib/gsd.js +11 -2
  72. package/lib/hooks-status.js +32 -3
  73. package/lib/judgment/store/index.js +158 -0
  74. package/lib/judgment/store/records.js +183 -24
  75. package/lib/judgment-attest.js +259 -0
  76. package/lib/judgment-gen.js +370 -21
  77. package/lib/judgment-verify.js +153 -0
  78. package/lib/judgment-writer.js +2803 -277
  79. package/lib/lane-gate.js +2 -0
  80. package/lib/local-claude-connector.js +199 -54
  81. package/lib/mcp-enforcement.js +21 -35
  82. package/lib/result-normalizer.js +93 -15
  83. package/lib/review-normalize.js +4 -0
  84. package/lib/stratum-mcp-client.js +131 -6
  85. package/package.json +2 -2
  86. package/server/compose-mcp-tools.js +15 -1
  87. package/server/compose-mcp.js +96 -1
  88. package/server/mcp-tool-policy.js +1 -1
  89. package/dist/assets/App-BG3ngu8H.js +0 -896
  90. package/dist/assets/abnfDiagram-VRR7QNED-CjB_sD3D.js +0 -1
  91. package/dist/assets/arc-_v4hR_uD.js +0 -1
  92. package/dist/assets/architectureDiagram-ZJ3FMSHR-DreJmzXQ.js +0 -36
  93. package/dist/assets/blockDiagram-677ZJIJ3-BG9-c0O1.js +0 -132
  94. package/dist/assets/channel-B3U5wFAT.js +0 -1
  95. package/dist/assets/chunk-EX3LRPZG-DdELs1qP.js +0 -231
  96. package/dist/assets/chunk-MOJQB5TN-D-ky35G-.js +0 -88
  97. package/dist/assets/chunk-RYQCIY6F-Dag_kVlO.js +0 -1
  98. package/dist/assets/chunk-V7JOEXUC-BtewURat.js +0 -206
  99. package/dist/assets/classDiagram-OUVF2IWQ-B6fCN-ht.js +0 -1
  100. package/dist/assets/classDiagram-v2-EOCWNBFH-B6fCN-ht.js +0 -1
  101. package/dist/assets/cynefin-VYW2F7L2-CT2BA6KE.js +0 -178
  102. package/dist/assets/cynefinDiagram-TSTJHNR4-Bh6exbyg.js +0 -62
  103. package/dist/assets/dagre-VKFMJZFB-aXMLSmQL.js +0 -4
  104. package/dist/assets/diagram-FQU43EPY-Dr7JAOuQ.js +0 -3
  105. package/dist/assets/diagram-G47NLZAW-DUvA3FQK.js +0 -24
  106. package/dist/assets/diagram-NH7WQ7WH-BQUARqcu.js +0 -24
  107. package/dist/assets/diagram-OA4YK3LP-dDUc1zHi.js +0 -30
  108. package/dist/assets/diagram-WEI45ONY-B2h5Qlb1.js +0 -41
  109. package/dist/assets/ebnfDiagram-CCIWWBDH-DThRGupB.js +0 -1
  110. package/dist/assets/erDiagram-Q63AITRT-BUCsprO2.js +0 -85
  111. package/dist/assets/flowDiagram-23GEKE2U-DXtNNi6r.js +0 -156
  112. package/dist/assets/ganttDiagram-NO4QXBWP-D4zbBHh_.js +0 -292
  113. package/dist/assets/gitGraphDiagram-IHSO6WYX-DpoQws0W.js +0 -106
  114. package/dist/assets/graph-BXPQrYYB.js +0 -331
  115. package/dist/assets/graph-C9eacEi8.js +0 -1
  116. package/dist/assets/index-3ZH5eMcZ.js +0 -119
  117. package/dist/assets/infoDiagram-FWYZ7A6U-Bbas2GAo.js +0 -2
  118. package/dist/assets/katex-C5jXJg4s.js +0 -257
  119. package/dist/assets/layout-DEXfKzaS.js +0 -1
  120. package/dist/assets/map-Czzmt4hB.js +0 -1
  121. package/dist/assets/pegDiagram-2B236MQR-CHiINrNy.js +0 -1
  122. package/dist/assets/pieDiagram-ENE6RG2P-CfS4YFlR.js +0 -39
  123. package/dist/assets/quadrantDiagram-ABIIQ3AL-CadesS9w.js +0 -7
  124. package/dist/assets/railroadDiagram-RFXS5EU6-CgWEspBN.js +0 -1
  125. package/dist/assets/sankeyDiagram-HTMAVEWB-YWKFgOGw.js +0 -40
  126. package/dist/assets/sequenceDiagram-DBY2YBRQ-BvkNOyF9.js +0 -162
  127. package/dist/assets/sizeCapture-X5ZJPWSS-DlFPA2yO.js +0 -1
  128. package/dist/assets/stateDiagram-2N3HPSRC-h8NIx0kQ.js +0 -1
  129. package/dist/assets/stateDiagram-v2-6OUMAXLB-DjPgZtJ9.js +0 -1
  130. package/dist/assets/swimlanes-5IMT3BWC-CT5n22kG.js +0 -2
  131. package/dist/assets/swimlanesDiagram-G3AALYLV-Dn318Bhq.js +0 -8
  132. package/dist/assets/vennDiagram-L72KCM5P-Dj-wWLYG.js +0 -34
  133. package/dist/assets/wardleyDiagram-EHGQE667-BxCeYxkG.js +0 -78
  134. package/dist/assets/xychartDiagram-FW5EYKEG-DMFqWn7z.js +0 -7
@@ -0,0 +1,223 @@
1
+ /**
2
+ * canon-guard.js — COMP-CANON-GUARD S4 (pure logic for the write-time hook).
3
+ *
4
+ * Two responsibilities, both pure (no I/O — the runtime wrapper
5
+ * .claude/hooks/canon-guard.mjs and bin/compose.js do the I/O):
6
+ *
7
+ * 1. decideCanonGuard — the PreToolUse decision. Deny a raw Write/Edit to a
8
+ * path the registry marks hook-enforced (docs/judgment/**), naming the
9
+ * tool that owns it. Allow everything else. FAIL OPEN on any malformed
10
+ * input — a guard that wedges the session is worse than one that misses.
11
+ *
12
+ * 2. installGuardHook / uninstallGuardHook / guardHookStatus — idempotent
13
+ * transforms over a parsed .claude/settings.json object that register the
14
+ * hook under hooks.PreToolUse without disturbing existing hooks.
15
+ *
16
+ * The hook is the 'hook' enforcement point of the shared canon-registry. It is
17
+ * Claude-runtime-scoped: Codex-dispatched edits and Bash (sed/heredoc) bypass
18
+ * it — the runtime-neutral backstop is S5/S6 (see design.md honest limits).
19
+ */
20
+ import { relative, resolve, isAbsolute, dirname, basename, join } from 'node:path';
21
+ import { existsSync, realpathSync } from 'node:fs';
22
+ import { matchEntry } from './canon-registry.js';
23
+
24
+ /** Tools whose file writes the hook intercepts. */
25
+ export const GUARDED_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit']);
26
+
27
+ /** How the hook registers in .claude/settings.json. */
28
+ export const HOOK_MATCHER = 'Write|Edit|NotebookEdit';
29
+ export const HOOK_COMMAND = 'node "${CLAUDE_PROJECT_DIR}/.claude/hooks/canon-guard.mjs"';
30
+
31
+ /**
32
+ * A command is ours iff it executes a file whose leaf name is exactly
33
+ * canon-guard.mjs. This is stricter than a loose substring: it matches our
34
+ * command and a drifted path (node old/canon-guard.mjs → still ours, so status
35
+ * reports 'stale'), but NOT a different script (canon-guard-v2.mjs) and not an
36
+ * unrelated command that merely names the file inside a larger word.
37
+ */
38
+ const OUR_SCRIPT_RE = /(?:^|[\s"'/\\=])canon-guard\.mjs(?:$|[\s"';:&|)(<>])/;
39
+
40
+ /** True if a hook entry is our canon-guard hook (by executed-script leaf name). */
41
+ function isOurHookEntry(h) {
42
+ return !!h && h.type === 'command' && typeof h.command === 'string' && OUR_SCRIPT_RE.test(h.command);
43
+ }
44
+
45
+ /**
46
+ * Strip the macOS data-volume firmlink prefix. /System/Volumes/Data mirrors /,
47
+ * so /System/Volumes/Data/Users/x IS /Users/x — but realpath does NOT collapse
48
+ * firmlinks (unlike symlinks), so this must be done by hand.
49
+ */
50
+ function stripFirmlink(x) {
51
+ return x.replace(/^\/System\/Volumes\/Data(?=\/)/, '') || x;
52
+ }
53
+
54
+ /**
55
+ * Canonicalize a path to defeat filesystem aliasing before lexical matching:
56
+ * realpath collapses symlinks and case folding; stripFirmlink handles the macOS
57
+ * /System/Volumes/Data firmlink realpath leaves alone. The target of a Write may
58
+ * not exist yet, so realpath the longest existing ancestor and re-append the
59
+ * not-yet-created tail. Best-effort — falls back to a lexical resolve on any
60
+ * error (the caller fails open regardless).
61
+ *
62
+ * NOT alias-proof against every vector (bind mounts, hardlinks) — those are the
63
+ * same runtime-scoped bucket as the Bash bypass, closed by S5/S6 on the tree.
64
+ */
65
+ export function realpathCanonicalize(p) {
66
+ try {
67
+ // Do NOT resolve(p) up front: resolve() collapses `..` LEXICALLY before any
68
+ // symlink resolves, so `symlink/../real` mis-normalizes (Codex S4 r2 finding
69
+ // 1). realpath resolves `..` and symlinks together but needs an existing
70
+ // path — so walk up the RAW path to the longest existing prefix, realpath
71
+ // THAT, and re-append the not-yet-created tail.
72
+ let cur = p;
73
+ const tail = [];
74
+ while (cur && !existsSync(cur)) {
75
+ const parent = dirname(cur);
76
+ if (parent === cur) { cur = ''; break; } // reached root, nothing existed
77
+ tail.unshift(basename(cur));
78
+ cur = parent;
79
+ }
80
+ if (!cur) return stripFirmlink(resolve(p)); // nothing existed → lexical fallback
81
+ let base;
82
+ try { base = realpathSync.native(cur); } catch { base = resolve(cur); }
83
+ const full = tail.length ? join(base, ...tail) : base;
84
+ return stripFirmlink(full);
85
+ } catch {
86
+ return resolve(p);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Decide whether to deny a PreToolUse tool call.
92
+ *
93
+ * @param {object} args
94
+ * @param {string} [args.toolName]
95
+ * @param {object} [args.toolInput] - the tool's arguments (file_path / notebook_path)
96
+ * @param {string} [args.cwd] - session cwd (for resolving a relative file_path)
97
+ * @param {string} [args.projectRoot] - repo root the registry patterns are relative to
98
+ * @param {string} [args.featuresDir='docs/features']
99
+ * @param {(p:string)=>string} [args.canonicalize] - map a path to its real, alias-free form.
100
+ * The runtime wrapper passes realpathCanonicalize; pure tests inject a stub or omit it.
101
+ * @returns {{deny: boolean, reason?: string, path?: string}}
102
+ */
103
+ export function decideCanonGuard({ toolName, toolInput, cwd, projectRoot, featuresDir = 'docs/features', canonicalize } = {}) {
104
+ try {
105
+ if (!GUARDED_TOOLS.has(toolName)) return { deny: false };
106
+ const raw = toolInput && (toolInput.file_path ?? toolInput.notebook_path);
107
+ if (typeof raw !== 'string' || !raw) return { deny: false };
108
+ if (typeof projectRoot !== 'string' || !projectRoot) return { deny: false };
109
+
110
+ // Canonicalize BOTH root and target with the same function so an aliased
111
+ // target (firmlink/symlink/case) can't slip a real canonical write past a
112
+ // lexical relative() (Codex S4 finding 1). Default = identity (lexical).
113
+ const canon = typeof canonicalize === 'function' ? canonicalize : (x) => x;
114
+ const abs = isAbsolute(raw) ? raw : resolve(cwd || projectRoot, raw);
115
+ const rel = relative(canon(projectRoot), canon(abs));
116
+ // Outside the repo (empty, parent-relative, or still absolute) → not our canon.
117
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return { deny: false };
118
+ // Normalize Windows separators defensively so registry patterns (forward-slash) match.
119
+ const relPosix = rel.split(/[\\/]/).join('/');
120
+
121
+ const entry = matchEntry(relPosix, { featuresDir, point: 'hook' });
122
+ if (!entry) return { deny: false };
123
+
124
+ const tools = entry.tools.join(', ');
125
+ return {
126
+ deny: true,
127
+ path: relPosix,
128
+ reason:
129
+ `${relPosix} is tool-owned canon (COMP-CANON-GUARD). A direct ${toolName} is blocked — ` +
130
+ `write it through one of: ${tools}. These tools stamp provenance and regenerate the ` +
131
+ `docs/judgment/** projections from records; a hand-edit is unattributed and overwritten on the next regen. ` +
132
+ `To override deliberately, use the canon override path (not yet available this slice — remove the path from ` +
133
+ `the registry hook set if you truly must hand-edit).`,
134
+ };
135
+ } catch {
136
+ return { deny: false }; // fail open — never wedge the session
137
+ }
138
+ }
139
+
140
+ // ── settings.json registration ───────────────────────────────────────────────
141
+
142
+ function clone(obj) {
143
+ return obj == null ? {} : structuredClone(obj);
144
+ }
145
+
146
+ /** Our canonical PreToolUse group. */
147
+ function ourGroup() {
148
+ return { matcher: HOOK_MATCHER, hooks: [{ type: 'command', command: HOOK_COMMAND }] };
149
+ }
150
+
151
+ /**
152
+ * Remove OUR hook ENTRIES from each group, preserving sibling hooks that happen
153
+ * to share the group, and dropping only groups left empty. Operates at the hook
154
+ * level, not the group level (Codex S4 finding 2 — a group-level "any child is
155
+ * ours → delete the group" wiped unrelated sibling hooks).
156
+ *
157
+ * @returns {Array<object>} the pruned groups
158
+ */
159
+ function pruneOurHooks(groups) {
160
+ const out = [];
161
+ for (const g of groups) {
162
+ if (!Array.isArray(g.hooks)) { out.push(g); continue; }
163
+ const kept = g.hooks.filter((h) => !isOurHookEntry(h));
164
+ if (kept.length === 0) continue; // group had only our hook → drop it
165
+ if (kept.length === g.hooks.length) { out.push(g); continue; } // nothing of ours here
166
+ out.push({ ...g, hooks: kept }); // keep siblings, our entry removed
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * Ensure our hook is registered with the current matcher + command in its own
173
+ * dedicated group, preserving every other hook. Idempotent.
174
+ *
175
+ * @returns {{settings: object, changed: boolean}}
176
+ */
177
+ export function installGuardHook(settings) {
178
+ const s = clone(settings);
179
+ s.hooks = s.hooks ?? {};
180
+ const before = Array.isArray(s.hooks.PreToolUse) ? JSON.stringify(s.hooks.PreToolUse) : null;
181
+ const existing = Array.isArray(s.hooks.PreToolUse) ? s.hooks.PreToolUse.map((g) => structuredClone(g)) : [];
182
+
183
+ // Strip any prior copy of our hook (from anywhere, incl. mixed groups), then
184
+ // append one clean dedicated group. Siblings in mixed groups are preserved.
185
+ const next = pruneOurHooks(existing);
186
+ next.push(ourGroup());
187
+ s.hooks.PreToolUse = next;
188
+
189
+ const changed = before !== JSON.stringify(next);
190
+ return { settings: s, changed };
191
+ }
192
+
193
+ /**
194
+ * Remove our hook registration, preserving sibling hooks, pruning emptied
195
+ * groups and an emptied PreToolUse array. Idempotent.
196
+ *
197
+ * @returns {{settings: object, changed: boolean}}
198
+ */
199
+ export function uninstallGuardHook(settings) {
200
+ const s = clone(settings);
201
+ if (!s.hooks || !Array.isArray(s.hooks.PreToolUse)) return { settings: s, changed: false };
202
+ const before = JSON.stringify(s.hooks.PreToolUse);
203
+ const next = pruneOurHooks(s.hooks.PreToolUse.map((g) => structuredClone(g)));
204
+ if (next.length === 0) delete s.hooks.PreToolUse;
205
+ else s.hooks.PreToolUse = next;
206
+ const after = s.hooks.PreToolUse ? JSON.stringify(s.hooks.PreToolUse) : null;
207
+ return { settings: s, changed: before !== after };
208
+ }
209
+
210
+ /**
211
+ * Report the guard hook's registration state.
212
+ * @returns {{state: 'installed'|'stale'|'absent'}}
213
+ */
214
+ export function guardHookStatus(settings) {
215
+ const groups = settings?.hooks?.PreToolUse;
216
+ if (!Array.isArray(groups)) return { state: 'absent' };
217
+ const ours = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.some(isOurHookEntry));
218
+ if (ours.length === 0) return { state: 'absent' };
219
+ const current = ours.some(
220
+ (g) => g.matcher === HOOK_MATCHER && g.hooks.length === 1 && g.hooks[0].command === HOOK_COMMAND,
221
+ );
222
+ return { state: current && ours.length === 1 ? 'installed' : 'stale' };
223
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * canon-registry.js — COMP-CANON-GUARD S1.
3
+ *
4
+ * The single declaration of what is canon: path pattern → writer → typed tools
5
+ * → enforcement points. Pure, no I/O (shape template: server/mcp-tool-policy.js).
6
+ *
7
+ * Consumed by BOTH:
8
+ * - lib/mcp-enforcement.js (the ship-time scan — the 'ship' point)
9
+ * - .claude/hooks/canon-guard.mjs (the write-time PreToolUse hook — 'hook')
10
+ *
11
+ * The load-bearing invariant (design Decision 1, corrected in blueprint-s1-s4):
12
+ * ONE registry does NOT imply ONE coverage. Each entry declares `enforcedBy`,
13
+ * and every enforcement point consumes only the subset that names it. This is
14
+ * what keeps the shared registry from locking out paths a point cannot yet
15
+ * legally guard:
16
+ * - docs/judgment/** is 100% tool-covered (S3 shipped the 8 judgment_* tools
17
+ * + regen), so the write-time hook can guard it with no lockout → ['hook'].
18
+ * - ROADMAP.md / feature.json have legal mutations NO tool covers yet
19
+ * (open a preserved section; edit a feature description — Decision 2), so
20
+ * the always-deny hook would lock them out. They stay ['ship'] (their
21
+ * existing build-event correlation) until update_feature_fields /
22
+ * open_preserved_section + the override land.
23
+ *
24
+ * Adding a path here turns on real enforcement — register a path for 'hook'
25
+ * ONLY once every legal mutation of it has a tool or an override.
26
+ */
27
+
28
+ // ── Tool sets ────────────────────────────────────────────────────────────────
29
+ // Moved verbatim from the pre-refactor mcp-enforcement.js literal sets. The
30
+ // contract test pins these against the legacy values.
31
+ const TOOLS_FOR_ROADMAP = ['add_roadmap_entry', 'set_feature_status', 'propose_followup'];
32
+ const TOOLS_FOR_CHANGELOG = ['add_changelog_entry'];
33
+ const TOOLS_FOR_FEATURE_JSON = [
34
+ 'add_roadmap_entry',
35
+ 'set_feature_status',
36
+ 'link_artifact',
37
+ 'link_features',
38
+ 'record_completion',
39
+ 'propose_followup',
40
+ ];
41
+
42
+ /**
43
+ * The eight judgment WRITE tools (COMP-JUDGMENT-WRITER, shipped @751cc96a).
44
+ * get_judgment_state is read-only and is deliberately NOT here. Every mutation
45
+ * of docs/judgment/** — records under records/ and the generated projections
46
+ * (REGISTER/LEDGER/OBJECTIVE/SITUATION/index.md, people/*.md, positions/*.md) —
47
+ * goes through one of these, which regenerate the projections atomically.
48
+ */
49
+ export const JUDGMENT_WRITE_TOOLS = [
50
+ 'judgment_position_create',
51
+ 'judgment_position_amend',
52
+ 'judgment_joint_add',
53
+ 'judgment_transition',
54
+ 'judgment_ledger_append',
55
+ 'judgment_person_write',
56
+ 'judgment_situation_write',
57
+ 'judgment_goal_write',
58
+ ];
59
+
60
+ // ── Matchers ─────────────────────────────────────────────────────────────────
61
+ // A matcher is (path, { featuresDir }) => boolean. featuresDir is relative and
62
+ // project-configurable (loadFeaturesDir), so the feature.json matcher is
63
+ // parameterized; fixed-name and docs/judgment matchers ignore it.
64
+
65
+ function matchExact(name) {
66
+ return (path) => path === name;
67
+ }
68
+
69
+ /** startsWith(<featuresDir>/) && endsWith(/feature.json) — mirrors the legacy
70
+ * isGuardedPath exactly (does NOT require a single-segment middle; that
71
+ * constraint lives only in code-correlation, below). */
72
+ function matchFeatureJson(path, featuresDir) {
73
+ if (typeof path !== 'string' || !featuresDir) return false;
74
+ const prefix = featuresDir.replace(/\/$/, '') + '/';
75
+ if (!path.startsWith(prefix)) return false;
76
+ return path.endsWith('/feature.json');
77
+ }
78
+
79
+ /** Anything under docs/judgment/ (records or projections). Fixed root — matches
80
+ * lib/judgment-gen.js, which hardcodes docs/judgment/. */
81
+ function matchJudgment(path) {
82
+ return typeof path === 'string' && path.startsWith('docs/judgment/');
83
+ }
84
+
85
+ // ── The registry ─────────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * @typedef {object} CanonEntry
89
+ * @property {string} id — stable identifier
90
+ * @property {string} writer — the module that legitimately produces this path
91
+ * @property {string[]} tools — typed tools authorised to write it
92
+ * @property {Array<'ship'|'hook'|'pre-commit'>} enforcedBy — points that guard it
93
+ * @property {(path:string, featuresDir:string)=>boolean} matches
94
+ */
95
+
96
+ /** @type {CanonEntry[]} */
97
+ const REGISTRY = [
98
+ {
99
+ id: 'roadmap',
100
+ writer: 'lib/roadmap-gen.js',
101
+ tools: TOOLS_FOR_ROADMAP,
102
+ enforcedBy: ['ship'],
103
+ matches: matchExact('ROADMAP.md'),
104
+ },
105
+ {
106
+ id: 'changelog',
107
+ writer: 'lib/changelog-writer.js',
108
+ tools: TOOLS_FOR_CHANGELOG,
109
+ enforcedBy: ['ship'],
110
+ matches: matchExact('CHANGELOG.md'),
111
+ },
112
+ {
113
+ id: 'feature-json',
114
+ writer: 'lib/feature-writer.js',
115
+ tools: TOOLS_FOR_FEATURE_JSON,
116
+ enforcedBy: ['ship'],
117
+ matches: (path, featuresDir) => matchFeatureJson(path, featuresDir),
118
+ },
119
+ {
120
+ id: 'judgment',
121
+ writer: 'lib/judgment-writer.js',
122
+ tools: JUDGMENT_WRITE_TOOLS,
123
+ enforcedBy: ['hook'],
124
+ matches: (path) => matchJudgment(path),
125
+ },
126
+ ];
127
+
128
+ // ── Public API ───────────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Resolve the registry entry guarding `path` at enforcement `point`, or null.
132
+ * Only entries whose `enforcedBy` includes the point are considered — this is
133
+ * the per-point-subset invariant.
134
+ *
135
+ * @param {string} path
136
+ * @param {{ featuresDir?: string, point: 'ship'|'hook'|'pre-commit' }} opts
137
+ * @returns {CanonEntry|null}
138
+ */
139
+ export function matchEntry(path, { featuresDir, point }) {
140
+ for (const entry of REGISTRY) {
141
+ if (!entry.enforcedBy.includes(point)) continue;
142
+ if (entry.matches(path, featuresDir)) return entry;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /** True if `path` is guarded at `point`. */
148
+ export function isGuarded(path, opts) {
149
+ return matchEntry(path, opts) !== null;
150
+ }
151
+
152
+ /** The typed tools authorised to write `path` at `point`, or [] if unguarded. */
153
+ export function toolsForPath(path, opts) {
154
+ const entry = matchEntry(path, opts);
155
+ return entry ? [...entry.tools] : [];
156
+ }
157
+
158
+ /**
159
+ * The feature code for a feature.json path (single-segment middle), else null.
160
+ * Used for ship-scan code correlation so an event for feature A cannot bless a
161
+ * dirty edit to feature B's feature.json.
162
+ *
163
+ * @param {string} path
164
+ * @param {{ featuresDir: string }} opts
165
+ * @returns {string|null}
166
+ */
167
+ export function featureCodeForPath(path, { featuresDir }) {
168
+ if (typeof path !== 'string' || !featuresDir) return null;
169
+ const prefix = featuresDir.replace(/\/$/, '') + '/';
170
+ if (!path.startsWith(prefix) || !path.endsWith('/feature.json')) return null;
171
+ const middle = path.slice(prefix.length, -'/feature.json'.length);
172
+ if (!middle || middle.includes('/')) return null;
173
+ return middle;
174
+ }
175
+
176
+ /** The entry ids guarded at `point` (for the contract test + introspection). */
177
+ export function guardedPatternIdsFor(point) {
178
+ return REGISTRY.filter((e) => e.enforcedBy.includes(point)).map((e) => e.id);
179
+ }
180
+
181
+ export const _internals = {
182
+ REGISTRY,
183
+ TOOLS_FOR_ROADMAP,
184
+ TOOLS_FOR_CHANGELOG,
185
+ TOOLS_FOR_FEATURE_JSON,
186
+ JUDGMENT_WRITE_TOOLS,
187
+ };
@@ -21,10 +21,12 @@
21
21
  import { execSync } from 'node:child_process';
22
22
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
- import { homedir } from 'node:os';
24
+ import { homedir, tmpdir } from 'node:os';
25
25
 
26
26
  export const PROBE_SENTINEL = 'COMPOSE_CODEX_PROBE_OK';
27
- const WORKTREE_BASE = join(homedir(), '.stratum', 'worktrees');
27
+ const WORKTREE_BASE = process.env.NODE_TEST_CONTEXT
28
+ ? join(tmpdir(), 'compose-test-worktrees', 'codex-preflight')
29
+ : join(homedir(), '.stratum', 'worktrees');
28
30
  const PROBE_CACHE = 'codex-worktree-probe.json';
29
31
  // Bound the probe's agent run so a wedged Codex CLI can't hang the whole build
30
32
  // before the flow even starts. On timeout we reject → the finally cleanup runs.
@@ -63,13 +65,25 @@ function isGitRepo(cwd) {
63
65
  *
64
66
  * @param {object} args
65
67
  * @param {string} args.cwd repo working directory
68
+ * @param {string} args.projectCwd project root used for dispatch telemetry
69
+ * @param {string} args.buildId current Compose build identifier
70
+ * @param {string} args.featureCode current feature code
66
71
  * @param {object} args.stratum stratum client (uses runAgentText('codex', ...))
67
72
  * @param {string} args.dataDir .compose/data dir for the per-repo cache
68
73
  * @param {string} args.ts caller-supplied timestamp string (unique worktree name)
69
74
  * @param {boolean} [args.force] ignore the cache and re-probe
70
75
  * @returns {Promise<{ok: boolean, reason: string, cached?: boolean, skipped?: boolean}>}
71
76
  */
72
- export async function preflightCodexWorktreeProbe({ cwd, stratum, dataDir, ts, force = false }) {
77
+ export async function preflightCodexWorktreeProbe({
78
+ cwd,
79
+ projectCwd,
80
+ buildId,
81
+ featureCode,
82
+ stratum,
83
+ dataDir,
84
+ ts,
85
+ force = false,
86
+ }) {
73
87
  if (process.env.COMPOSE_SKIP_CODEX_PROBE) {
74
88
  return { ok: true, skipped: true, reason: 'COMPOSE_SKIP_CODEX_PROBE set — probe skipped' };
75
89
  }
@@ -110,7 +124,15 @@ export async function preflightCodexWorktreeProbe({ cwd, stratum, dataDir, ts, f
110
124
  // Bound the agent run — a hang here would otherwise stall the build and skip
111
125
  // the finally cleanup. On timeout the race rejects and we fall to the catch.
112
126
  await Promise.race([
113
- stratum.runAgentText('codex', prompt, { cwd: wtPath }),
127
+ stratum.runAgentText('codex', prompt, {
128
+ cwd: wtPath,
129
+ telemetry: {
130
+ site: 'preflight',
131
+ project_cwd: projectCwd,
132
+ build_id: buildId,
133
+ feature_code: featureCode,
134
+ },
135
+ }),
114
136
  new Promise((_, reject) =>
115
137
  setTimeout(() => reject(new Error(`probe timed out after ${PROBE_AGENT_TIMEOUT_MS}ms`)), PROBE_AGENT_TIMEOUT_MS).unref?.()
116
138
  ),