@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
package/lib/lane-gate.js CHANGED
@@ -87,6 +87,7 @@ export async function applyFrontTriage({ featureCode, request, provider, cachedF
87
87
  triageTier: tier,
88
88
  lane,
89
89
  estimateSource,
90
+ triageConfidence: front.confidence,
90
91
  profile: buildProfile,
91
92
  triageTimestamp: new Date().toISOString(),
92
93
  };
@@ -109,6 +110,7 @@ export async function applyFrontTriage({ featureCode, request, provider, cachedF
109
110
  buildProfile,
110
111
  tier,
111
112
  lane,
113
+ confidence: front.confidence,
112
114
  tierLabel: fields.complexity,
113
115
  rationale: front.rationale,
114
116
  cachedFeature: updated,
@@ -17,8 +17,11 @@
17
17
  * and reports usage. The SDK `query` is injectable for tests.
18
18
  */
19
19
 
20
+ import { randomUUID } from 'node:crypto';
20
21
  import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
21
22
 
23
+ import { appendEvent, resolveDispatchLedgerCwd } from './dispatch-ledger.js';
24
+
22
25
  const SENSITIVE_ENV_VARS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'CLAUDE_API_KEY', 'CLAUDECODE'];
23
26
 
24
27
  function isRecord(value) {
@@ -29,6 +32,77 @@ function nonneg(value) {
29
32
  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
30
33
  }
31
34
 
35
+ function reportedNumber(value) {
36
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
37
+ }
38
+
39
+ function reportedString(value) {
40
+ return typeof value === 'string' && value.length > 0 ? value : null;
41
+ }
42
+
43
+ function isBlockedDispatch(value) {
44
+ const candidates = [value?.status, value?.outcome, value?.code, value?.subtype];
45
+ return candidates.some((candidate) => {
46
+ const normalized = typeof candidate === 'string' ? candidate.toLowerCase() : '';
47
+ return normalized === 'blocked'
48
+ || normalized === 'budget_exhausted'
49
+ || normalized === 'budget-exhausted';
50
+ });
51
+ }
52
+
53
+ function attachDispatchId(value, dispatchId) {
54
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) return;
55
+ try {
56
+ Object.defineProperty(value, 'dispatchId', {
57
+ configurable: true,
58
+ enumerable: false,
59
+ value: dispatchId,
60
+ });
61
+ } catch {
62
+ // Capture metadata must never replace the SDK's original behavior.
63
+ }
64
+ }
65
+
66
+ function recordDispatch(dispatchId, context, capture, outcome, elapsedMs, effortExecuted) {
67
+ try {
68
+ const event = {
69
+ kind: 'dispatch',
70
+ dispatch_id: dispatchId,
71
+ site: reportedString(context.site) ?? 'unattributed',
72
+ agent: 'claude',
73
+ outcome,
74
+ model: reportedString(capture.model),
75
+ effort_intended: reportedString(context.effort_intended),
76
+ // Executed effort is only recorded once a model run is confirmed — the SDK
77
+ // emitted a model identity (init or result). A transport/startup failure
78
+ // before any model spoke leaves effort_executed null, so the executed-effort
79
+ // curve is not polluted by tiers that were requested but never actually ran
80
+ // (mirrors the codex route, whose effort_executed comes from run telemetry).
81
+ effort_executed: capture.model ? reportedString(effortExecuted) : null,
82
+ tokens_in: reportedNumber(capture.inputTokens),
83
+ tokens_out: reportedNumber(capture.outputTokens),
84
+ tokens_total: capture.inputTokens !== null || capture.outputTokens !== null
85
+ ? (reportedNumber(capture.inputTokens) ?? 0) + (reportedNumber(capture.outputTokens) ?? 0)
86
+ : null,
87
+ usd: reportedNumber(capture.costUsd),
88
+ duration_ms: reportedNumber(capture.durationMs) ?? reportedNumber(elapsedMs),
89
+ };
90
+ for (const [field, value] of [
91
+ ['build_id', context.build_id],
92
+ ['feature_code', context.feature_code],
93
+ ['step_id', context.step_id],
94
+ ]) {
95
+ if (reportedString(value) !== null) event[field] = value;
96
+ }
97
+ if (typeof context.attempt === 'number' && Number.isFinite(context.attempt)) {
98
+ event.attempt = context.attempt;
99
+ }
100
+ appendEvent(resolveDispatchLedgerCwd(context.project_cwd), event);
101
+ } catch {
102
+ // Dispatch capture is fail-open by contract.
103
+ }
104
+ }
105
+
32
106
  /**
33
107
  * Run a controlled claude agent locally.
34
108
  *
@@ -39,17 +113,26 @@ function nonneg(value) {
39
113
  * @param {string[]} [opts.allowedTools] enforced tool allowlist (read-only review)
40
114
  * @param {string[]} [opts.disallowedTools]
41
115
  * @param {object} [opts.thinking]
116
+ * @param {string} [opts.effort] reasoning-effort tier (low|medium|high|xhigh|max)
42
117
  * @param {AbortController} [opts.abortController] abort → interrupt the run
43
118
  * @param {(ev:{tool:string,input:object})=>void} [opts.onToolUse] per tool_use block
44
119
  * @param {NodeJS.ProcessEnv} [opts.env]
45
120
  * @param {Function} [opts.query] SDK `query` seam for tests
121
+ * @param {object} [opts.telemetry] Compose-only dispatch context
46
122
  * @returns {Promise<{text:string, usage:object, telemetry:object}>}
47
123
  */
48
124
  export async function runLocalClaudeAgent(prompt, opts = {}) {
49
125
  const query = opts.query ?? sdkQuery;
126
+ const { telemetry } = opts;
127
+ const telemetryContext = telemetry && typeof telemetry === 'object'
128
+ ? telemetry
129
+ : {};
50
130
  const env = { ...(opts.env ?? process.env) };
51
131
  for (const key of SENSITIVE_ENV_VARS) delete env[key];
52
132
 
133
+ // Only forward a non-empty effort so routes without a configured tier keep the
134
+ // SDK's default behavior; the applied value is what we record as effort_executed.
135
+ const appliedEffort = reportedString(opts.effort);
53
136
  const sdkOptions = {
54
137
  cwd: opts.cwd ?? process.cwd(),
55
138
  model: opts.model ?? process.env.CLAUDE_MODEL ?? 'claude-sonnet-4-6',
@@ -57,6 +140,7 @@ export async function runLocalClaudeAgent(prompt, opts = {}) {
57
140
  env,
58
141
  ...(opts.abortController ? { abortController: opts.abortController } : {}),
59
142
  ...(opts.thinking !== undefined ? { thinking: opts.thinking } : {}),
143
+ ...(appliedEffort !== null ? { effort: appliedEffort } : {}),
60
144
  };
61
145
  // A read-only profile passes an explicit allowlist; without one, the agent
62
146
  // gets the full claude_code preset (unrestricted).
@@ -83,67 +167,128 @@ export async function runLocalClaudeAgent(prompt, opts = {}) {
83
167
  let inputTokens = 0;
84
168
  let outputTokens = 0;
85
169
  let costUsd = 0;
170
+ const capture = {
171
+ model: null,
172
+ inputTokens: null,
173
+ outputTokens: null,
174
+ costUsd: null,
175
+ durationMs: null,
176
+ };
177
+ let resultError = null;
178
+ let resultFailureOutcome = null;
179
+ const dispatchId = randomUUID();
180
+ const dispatchStartedAt = Date.now();
86
181
 
87
- for await (const raw of query({ prompt, options: sdkOptions })) {
88
- if (!isRecord(raw)) continue;
89
- if (raw.type === 'system' && raw.subtype === 'init' && typeof raw.model === 'string') {
90
- resolvedModel = raw.model;
91
- }
92
- if (raw.type === 'assistant' && isRecord(raw.message) && Array.isArray(raw.message.content)) {
93
- for (const block of raw.message.content) {
94
- if (!isRecord(block)) continue;
95
- if (block.type === 'text' && typeof block.text === 'string') assistantText += block.text;
96
- if (block.type === 'tool_use' && typeof block.name === 'string' && typeof opts.onToolUse === 'function') {
97
- opts.onToolUse({ tool: block.name, input: isRecord(block.input) ? block.input : {} });
182
+ try {
183
+ for await (const raw of query({ prompt, options: sdkOptions })) {
184
+ if (!isRecord(raw)) continue;
185
+ if (raw.type === 'system' && raw.subtype === 'init' && typeof raw.model === 'string') {
186
+ resolvedModel = raw.model;
187
+ capture.model = raw.model;
188
+ }
189
+ if (raw.type === 'assistant' && isRecord(raw.message) && Array.isArray(raw.message.content)) {
190
+ for (const block of raw.message.content) {
191
+ if (!isRecord(block)) continue;
192
+ if (block.type === 'text' && typeof block.text === 'string') assistantText += block.text;
193
+ if (block.type === 'tool_use' && typeof block.name === 'string' && typeof opts.onToolUse === 'function') {
194
+ opts.onToolUse({ tool: block.name, input: isRecord(block.input) ? block.input : {} });
195
+ }
98
196
  }
99
197
  }
198
+ if (raw.type !== 'result') continue;
199
+ durationMs = nonneg(raw.duration_ms);
200
+ capture.durationMs = reportedNumber(raw.duration_ms);
201
+ capture.costUsd = reportedNumber(raw.total_cost_usd);
202
+ if (reportedString(raw.model) !== null) capture.model = raw.model;
203
+ if (isRecord(raw.usage)) {
204
+ capture.inputTokens = reportedNumber(raw.usage.input_tokens);
205
+ capture.outputTokens = reportedNumber(raw.usage.output_tokens);
206
+ capture.model = reportedString(raw.usage.model) ?? capture.model;
207
+ }
208
+ if (raw.subtype !== 'success') {
209
+ // F3: a failed run still consumed billable tokens/cost. Capture them from
210
+ // the error result (SDKResultError carries usage + total_cost_usd) and
211
+ // attach to the thrown Error — same usage shape as the success return — so
212
+ // the consumer failure path can debit the engine/GSD ledgers. Without this,
213
+ // repeated failures evade budget exhaustion.
214
+ const failCost = nonneg(raw.total_cost_usd);
215
+ const failIn = isRecord(raw.usage) ? nonneg(raw.usage.input_tokens) : 0;
216
+ const failOut = isRecord(raw.usage) ? nonneg(raw.usage.output_tokens) : 0;
217
+ const errors = Array.isArray(raw.errors) ? raw.errors.filter((v) => typeof v === 'string') : [];
218
+ const err = new Error(errors.join('; ') || `claude query failed: ${String(raw.subtype)}`);
219
+ err.usage = {
220
+ input_tokens: failIn,
221
+ output_tokens: failOut,
222
+ tokens: failIn + failOut,
223
+ cost_usd: failCost,
224
+ usd: failCost,
225
+ duration_ms: durationMs,
226
+ ms: durationMs,
227
+ model: resolvedModel,
228
+ };
229
+ err.costUsd = failCost;
230
+ resultError = err;
231
+ resultFailureOutcome = isBlockedDispatch(raw) ? 'blocked' : 'error';
232
+ throw err;
233
+ }
234
+ if (typeof raw.result === 'string') finalText = raw.result;
235
+ costUsd = nonneg(raw.total_cost_usd);
236
+ if (isRecord(raw.usage)) {
237
+ inputTokens = nonneg(raw.usage.input_tokens);
238
+ outputTokens = nonneg(raw.usage.output_tokens);
239
+ }
100
240
  }
101
- if (raw.type !== 'result') continue;
102
- durationMs = nonneg(raw.duration_ms);
103
- if (raw.subtype !== 'success') {
104
- // F3: a failed run still consumed billable tokens/cost. Capture them from
105
- // the error result (SDKResultError carries usage + total_cost_usd) and
106
- // attach to the thrown Error — same usage shape as the success return — so
107
- // the consumer failure path can debit the engine/GSD ledgers. Without this,
108
- // repeated failures evade budget exhaustion.
109
- const failCost = nonneg(raw.total_cost_usd);
110
- const failIn = isRecord(raw.usage) ? nonneg(raw.usage.input_tokens) : 0;
111
- const failOut = isRecord(raw.usage) ? nonneg(raw.usage.output_tokens) : 0;
112
- const errors = Array.isArray(raw.errors) ? raw.errors.filter((v) => typeof v === 'string') : [];
113
- const err = new Error(errors.join('; ') || `claude query failed: ${String(raw.subtype)}`);
114
- err.usage = {
115
- input_tokens: failIn,
116
- output_tokens: failOut,
117
- tokens: failIn + failOut,
118
- cost_usd: failCost,
119
- usd: failCost,
241
+
242
+ const result = {
243
+ text: finalText ?? assistantText,
244
+ usage: {
245
+ input_tokens: inputTokens,
246
+ output_tokens: outputTokens,
247
+ tokens: inputTokens + outputTokens,
248
+ cost_usd: costUsd,
249
+ usd: costUsd,
120
250
  duration_ms: durationMs,
121
251
  ms: durationMs,
122
252
  model: resolvedModel,
123
- };
124
- err.costUsd = failCost;
125
- throw err;
126
- }
127
- if (typeof raw.result === 'string') finalText = raw.result;
128
- costUsd = nonneg(raw.total_cost_usd);
129
- if (isRecord(raw.usage)) {
130
- inputTokens = nonneg(raw.usage.input_tokens);
131
- outputTokens = nonneg(raw.usage.output_tokens);
253
+ },
254
+ telemetry: { durationMs, model: resolvedModel },
255
+ };
256
+ recordDispatch(
257
+ dispatchId,
258
+ telemetryContext,
259
+ capture,
260
+ 'ok',
261
+ Date.now() - dispatchStartedAt,
262
+ appliedEffort,
263
+ );
264
+ attachDispatchId(result, dispatchId);
265
+ return result;
266
+ } catch (error) {
267
+ if (error !== resultError && error && typeof error === 'object') {
268
+ const errorUsage = isRecord(error.usage) ? error.usage : {};
269
+ const errorTelemetry = isRecord(error.telemetry) ? error.telemetry : {};
270
+ capture.model = reportedString(errorTelemetry.model)
271
+ ?? reportedString(errorUsage.model)
272
+ ?? capture.model;
273
+ capture.inputTokens = reportedNumber(errorUsage.input_tokens) ?? capture.inputTokens;
274
+ capture.outputTokens = reportedNumber(errorUsage.output_tokens) ?? capture.outputTokens;
275
+ capture.costUsd = reportedNumber(errorUsage.cost_usd)
276
+ ?? reportedNumber(errorUsage.usd)
277
+ ?? capture.costUsd;
278
+ capture.durationMs = reportedNumber(errorUsage.duration_ms)
279
+ ?? reportedNumber(errorUsage.ms)
280
+ ?? reportedNumber(errorTelemetry.durationMs)
281
+ ?? capture.durationMs;
132
282
  }
283
+ recordDispatch(
284
+ dispatchId,
285
+ telemetryContext,
286
+ capture,
287
+ resultFailureOutcome ?? (isBlockedDispatch(error) ? 'blocked' : 'error'),
288
+ Date.now() - dispatchStartedAt,
289
+ appliedEffort,
290
+ );
291
+ attachDispatchId(error, dispatchId);
292
+ throw error;
133
293
  }
134
-
135
- return {
136
- text: finalText ?? assistantText,
137
- usage: {
138
- input_tokens: inputTokens,
139
- output_tokens: outputTokens,
140
- tokens: inputTokens + outputTokens,
141
- cost_usd: costUsd,
142
- usd: costUsd,
143
- duration_ms: durationMs,
144
- ms: durationMs,
145
- model: resolvedModel,
146
- },
147
- telemetry: { durationMs, model: resolvedModel },
148
- };
149
294
  }
@@ -11,19 +11,20 @@
11
11
 
12
12
  import { existsSync, readFileSync } from 'fs';
13
13
  import { join } from 'path';
14
+ import {
15
+ isGuarded as registryIsGuarded,
16
+ toolsForPath as registryToolsForPath,
17
+ featureCodeForPath as registryFeatureCodeForPath,
18
+ } from './canon-registry.js';
14
19
 
15
- const GUARDED_FILES = new Set(['ROADMAP.md', 'CHANGELOG.md']);
16
-
17
- const TOOLS_FOR_ROADMAP = ['add_roadmap_entry', 'set_feature_status', 'propose_followup'];
18
- const TOOLS_FOR_CHANGELOG = ['add_changelog_entry'];
19
- const TOOLS_FOR_FEATURE_JSON = [
20
- 'add_roadmap_entry',
21
- 'set_feature_status',
22
- 'link_artifact',
23
- 'link_features',
24
- 'record_completion',
25
- 'propose_followup',
26
- ];
20
+ // COMP-CANON-GUARD S1: the guarded-path/tool declarations that used to live here
21
+ // as literal sets now live in lib/canon-registry.js — the single source of truth
22
+ // shared with the write-time hook. This module is the 'ship' enforcement point;
23
+ // it consumes ONLY the registry's ship subset, so its behavior is unchanged
24
+ // (docs/judgment/** is a hook-only path and is invisible here, exactly as before
25
+ // this refactor, when it was not declared at all). The contract test
26
+ // (test/canon-registry-contract.test.js) pins that equivalence.
27
+ const ENFORCEMENT_POINT = 'ship';
27
28
 
28
29
  /**
29
30
  * Read `enforcement.mcpForFeatureMgmt` and normalize to 'block' | 'log' | 'off'.
@@ -63,11 +64,7 @@ export function filterGuarded(dirtyFiles, featuresDir) {
63
64
  */
64
65
  export function isGuardedPath(path, featuresDir) {
65
66
  if (typeof path !== 'string') return false;
66
- if (GUARDED_FILES.has(path)) return true;
67
- // <featuresDir>/<CODE>/feature.json
68
- const prefix = featuresDir.replace(/\/$/, '') + '/';
69
- if (!path.startsWith(prefix)) return false;
70
- return path.endsWith('/feature.json');
67
+ return registryIsGuarded(path, { featuresDir, point: ENFORCEMENT_POINT });
71
68
  }
72
69
 
73
70
  /**
@@ -80,13 +77,7 @@ export function isGuardedPath(path, featuresDir) {
80
77
  * @returns {string[]}
81
78
  */
82
79
  export function expectedToolsForPath(path, featuresDir) {
83
- if (path === 'ROADMAP.md') return [...TOOLS_FOR_ROADMAP];
84
- if (path === 'CHANGELOG.md') return [...TOOLS_FOR_CHANGELOG];
85
- const prefix = featuresDir.replace(/\/$/, '') + '/';
86
- if (path.startsWith(prefix) && path.endsWith('/feature.json')) {
87
- return [...TOOLS_FOR_FEATURE_JSON];
88
- }
89
- return [];
80
+ return registryToolsForPath(path, { featuresDir, point: ENFORCEMENT_POINT });
90
81
  }
91
82
 
92
83
  /**
@@ -98,11 +89,7 @@ export function expectedToolsForPath(path, featuresDir) {
98
89
  * @returns {string|null}
99
90
  */
100
91
  export function featureCodeFromPath(path, featuresDir) {
101
- const prefix = featuresDir.replace(/\/$/, '') + '/';
102
- if (!path.startsWith(prefix) || !path.endsWith('/feature.json')) return null;
103
- const middle = path.slice(prefix.length, -'/feature.json'.length);
104
- if (!middle || middle.includes('/')) return null;
105
- return middle;
92
+ return registryFeatureCodeForPath(path, { featuresDir });
106
93
  }
107
94
 
108
95
  /**
@@ -165,9 +152,8 @@ export function enforcementError(violations) {
165
152
  return err;
166
153
  }
167
154
 
168
- export const _internals = {
169
- GUARDED_FILES,
170
- TOOLS_FOR_ROADMAP,
171
- TOOLS_FOR_CHANGELOG,
172
- TOOLS_FOR_FEATURE_JSON,
173
- };
155
+ // NOTE: the `_internals` export (guarded-file set + tool sets) was removed in
156
+ // COMP-CANON-GUARD S1. Those declarations now live in lib/canon-registry.js —
157
+ // the single source of truth — with no in-repo consumer of the old shim. Import
158
+ // from canon-registry.js if you need the raw sets, so there is exactly ONE
159
+ // declaration, not a copy here that can drift.
@@ -224,6 +224,21 @@ export class AgentAbortedError extends Error {
224
224
  }
225
225
  }
226
226
 
227
+ function copyDispatchId(source, target) {
228
+ try {
229
+ if (!source || !target || typeof source.dispatchId !== 'string') return target;
230
+ Object.defineProperty(target, 'dispatchId', {
231
+ configurable: true,
232
+ enumerable: false,
233
+ value: source.dispatchId,
234
+ });
235
+ } catch {
236
+ // Error replacement must preserve the original control flow even if a
237
+ // third-party target is unexpectedly frozen.
238
+ }
239
+ return target;
240
+ }
241
+
227
242
  /**
228
243
  * STRAT-DEDUP-AGENTRUN-V3: `runAndNormalize` is now a thin wrapper around the
229
244
  * Python connector tier exposed through `stratum_agent_run`. Events arrive as
@@ -258,6 +273,18 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
258
273
  // overrides the bare literal for capability resolution; the provider is
259
274
  // unchanged. Absent → bare literal (no restrictions), preserving old behavior.
260
275
  const cfg = resolveAgentConfig(opts.profile || agentType);
276
+ const callerTelemetry = opts.telemetry && typeof opts.telemetry === 'object'
277
+ ? opts.telemetry
278
+ : {};
279
+ const primaryTelemetry = {};
280
+ for (const field of ['project_cwd', 'site', 'build_id', 'feature_code']) {
281
+ if (callerTelemetry[field] !== undefined) primaryTelemetry[field] = callerTelemetry[field];
282
+ }
283
+ primaryTelemetry.step_id = stepId;
284
+ if (typeof stepDispatch.attempt === 'number' && Number.isFinite(stepDispatch.attempt)) {
285
+ primaryTelemetry.attempt = stepDispatch.attempt;
286
+ }
287
+ primaryTelemetry.effort_intended = cfg.effort ?? null;
261
288
  // A read-only profile (no Edit/Write/Bash) also maps to a read-only sandbox so
262
289
  // the restriction binds at the engine's connector, not just at this invocation.
263
290
  const readOnlyProfile = Array.isArray(cfg.disallowedTools)
@@ -430,6 +457,8 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
430
457
  };
431
458
 
432
459
  let runResult;
460
+ let primaryDispatchId = null;
461
+ let repairDispatchId = null;
433
462
  try {
434
463
  if (useLocalClaude) {
435
464
  // Test seam: an installed factory shim exposes an SDK-shaped query adapter
@@ -443,8 +472,10 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
443
472
  allowedTools: cfg.allowedTools ?? undefined,
444
473
  disallowedTools: cfg.disallowedTools ?? undefined,
445
474
  thinking: cfg.thinking ?? undefined,
475
+ effort: cfg.effort ?? undefined,
446
476
  abortController,
447
477
  onToolUse: localOnToolUse,
478
+ telemetry: primaryTelemetry,
448
479
  ...(localQuery ? { query: localQuery } : {}),
449
480
  });
450
481
  } else {
@@ -457,8 +488,12 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
457
488
  sandboxMode,
458
489
  cwd: opts.cwd ?? undefined,
459
490
  correlationId,
491
+ telemetry: primaryTelemetry,
460
492
  });
461
493
  }
494
+ primaryDispatchId = typeof runResult?.dispatchId === 'string'
495
+ ? runResult.dispatchId
496
+ : null;
462
497
  } catch (err) {
463
498
  // F3/G3: preserve any billable usage the failed run reported (the local
464
499
  // connector attaches it on a non-success result / usage-bearing rejection) so
@@ -469,17 +504,19 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
469
504
  if (timedOut) {
470
505
  const e = new AgentTimeoutError(stepId, Date.now() - startTime);
471
506
  if (errUsage) e.usage = errUsage;
472
- throw e;
507
+ throw copyDispatchId(err, e);
508
+ }
509
+ if (userInterruptAction) {
510
+ throw copyDispatchId(err, new UserInterruptError(stepId, userInterruptAction));
473
511
  }
474
- if (userInterruptAction) throw new UserInterruptError(stepId, userInterruptAction);
475
512
  if (abortReason) {
476
513
  const e = new AgentAbortedError(stepId, abortReason);
477
514
  if (errUsage) e.usage = errUsage;
478
- throw e;
515
+ throw copyDispatchId(err, e);
479
516
  }
480
517
  const agentError = new AgentError(err?.message ?? 'Agent run failed');
481
518
  if (errUsage) agentError.usage = errUsage;
482
- throw agentError;
519
+ throw copyDispatchId(err, agentError);
483
520
  } finally {
484
521
  if (timeoutHandle) clearTimeout(timeoutHandle);
485
522
  if (onInterrupt && progress?.removeListener) progress.removeListener('interrupt', onInterrupt);
@@ -498,13 +535,15 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
498
535
  if (timedOut) {
499
536
  const e = new AgentTimeoutError(stepId, Date.now() - startTime);
500
537
  if (lateUsage) e.usage = lateUsage;
501
- throw e;
538
+ throw copyDispatchId(runResult, e);
539
+ }
540
+ if (userInterruptAction) {
541
+ throw copyDispatchId(runResult, new UserInterruptError(stepId, userInterruptAction));
502
542
  }
503
- if (userInterruptAction) throw new UserInterruptError(stepId, userInterruptAction);
504
543
  if (abortReason) {
505
544
  const e = new AgentAbortedError(stepId, abortReason);
506
545
  if (lateUsage) e.usage = lateUsage;
507
- throw e;
546
+ throw copyDispatchId(runResult, e);
508
547
  }
509
548
 
510
549
  // D2(b): the TS agent_run path returns a synchronous `complete` envelope with
@@ -540,13 +579,35 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
540
579
  if (opts.reviewMode === true) {
541
580
  const reviewAgentType = agentType; // already resolved from stepDispatch.agent at line 178
542
581
  const reviewModelId = usageTotals.model ?? cfg.modelID ?? null;
582
+ // The repair dispatch is only CREDITED (dispatchIds.repair) when its output
583
+ // actually replaced the primary parse — a failed or unparseable repair still
584
+ // bills its usage but must not absorb the step's settlement.
585
+ let repairUsed = false;
586
+ const foldRepairUsage = (usage) => {
587
+ if (!usage || typeof usage !== 'object') return;
588
+ if (typeof usage.tokens === 'number') usageTotals.output_tokens += usage.tokens;
589
+ if (typeof usage.usd === 'number') usageTotals.cost_usd += usage.usd;
590
+ };
543
591
  const repairFn = stratum
544
592
  ? async (repairPrompt) => {
545
- const repairResult = await stratum.agentRun(reviewAgentType, repairPrompt, {
546
- modelID: cfg.modelID ?? undefined,
547
- cwd: opts.cwd ?? undefined,
548
- });
549
- return repairResult?.text ?? '';
593
+ try {
594
+ const repairResult = await stratum.agentRun(reviewAgentType, repairPrompt, {
595
+ modelID: cfg.modelID ?? undefined,
596
+ cwd: opts.cwd ?? undefined,
597
+ telemetry: { ...primaryTelemetry, site: 'review-repair' },
598
+ });
599
+ repairDispatchId = typeof repairResult?.dispatchId === 'string'
600
+ ? repairResult.dispatchId
601
+ : null;
602
+ foldRepairUsage(repairResult?.usage);
603
+ return repairResult?.text ?? '';
604
+ } catch (error) {
605
+ repairDispatchId = typeof error?.dispatchId === 'string'
606
+ ? error.dispatchId
607
+ : null;
608
+ foldRepairUsage(error?.usage);
609
+ throw error;
610
+ }
550
611
  }
551
612
  : undefined;
552
613
  const reviewResult = await normalizeReviewResult(text, {
@@ -555,12 +616,23 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
555
616
  confidenceGate: opts.confidenceGate ?? 7,
556
617
  lens: opts.lens ?? 'general',
557
618
  repairFn,
619
+ onRepairUsed: () => { repairUsed = true; },
558
620
  });
559
- return { text, result: reviewResult, usage: usageTotals };
621
+ return {
622
+ text,
623
+ result: reviewResult,
624
+ usage: usageTotals,
625
+ dispatchIds: { primary: primaryDispatchId, repair: repairUsed ? repairDispatchId : null },
626
+ };
560
627
  }
561
628
 
562
629
  if (!hasStructuredOutput) {
563
- return { text, result: null, usage: usageTotals };
630
+ return {
631
+ text,
632
+ result: null,
633
+ usage: usageTotals,
634
+ dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
635
+ };
564
636
  }
565
637
 
566
638
  const result = extractJson(text);
@@ -577,8 +649,14 @@ export async function runAndNormalize(_connectorIgnored, prompt, stepDispatch, o
577
649
  result: { summary: normalizationFailure },
578
650
  usage: usageTotals,
579
651
  normalizationFailure,
652
+ dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
580
653
  };
581
654
  }
582
655
 
583
- return { text, result, usage: usageTotals };
656
+ return {
657
+ text,
658
+ result,
659
+ usage: usageTotals,
660
+ dispatchIds: { primary: primaryDispatchId, repair: repairDispatchId },
661
+ };
584
662
  }
@@ -144,6 +144,7 @@ export async function normalizeReviewResult(rawText, {
144
144
  confidenceGate = 7,
145
145
  lens = 'general',
146
146
  repairFn,
147
+ onRepairUsed,
147
148
  } = {}) {
148
149
  // Step 1: Try direct JSON parse
149
150
  let parsed = parseReviewJson(rawText);
@@ -158,6 +159,9 @@ export async function normalizeReviewResult(rawText, {
158
159
 
159
160
  if (repairText) {
160
161
  parsed = parseReviewJson(repairText);
162
+ // Only a parseable repair actually replaces the primary output — the
163
+ // caller uses this to decide which dispatch gets credited.
164
+ if (parsed && typeof onRepairUsed === 'function') onRepairUsed();
161
165
  }
162
166
  }
163
167