@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,301 @@
1
+ /**
2
+ * dispatch-ledger.js — closed, append-only telemetry events for agent dispatch.
3
+ *
4
+ * Ledger rows are addressed by an explicit project root so callers running in a
5
+ * worktree cannot accidentally split telemetry away from the build project.
6
+ */
7
+
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { dirname, join, resolve } from 'node:path';
11
+
12
+ import { normalizeSince } from './feature-events.js';
13
+
14
+ export const DISPATCH_LEDGER_RELATIVE_PATH = '.compose/data/dispatch-ledger.jsonl';
15
+
16
+ /**
17
+ * Resolve the project root a dispatch connector should address its ledger by.
18
+ *
19
+ * Two paths write telemetry into the live compose checkout during a test run:
20
+ * an unattributed dispatch with no project_cwd falls back to process.cwd(), and
21
+ * a test that explicitly roots a build at process.cwd() threads that same live
22
+ * path through. Under NODE_TEST_CONTEXT, any ledger write addressed at the
23
+ * process cwd (the live checkout) is redirected to a hermetic tmpdir so
24
+ * full-suite runs never accrete fixture rows in the real .compose/data ledger.
25
+ * A test that threads its own tmpdir project root is addressed there, unchanged.
26
+ * Production paths never set NODE_TEST_CONTEXT, so they are unaffected.
27
+ *
28
+ * @param {string} [explicitCwd] the caller's project_cwd, when known
29
+ * @returns {string}
30
+ */
31
+ export function resolveDispatchLedgerCwd(explicitCwd) {
32
+ const cwd = isString(explicitCwd) ? explicitCwd : process.cwd();
33
+ if (process.env.NODE_TEST_CONTEXT && resolve(cwd) === resolve(process.cwd())) {
34
+ return join(tmpdir(), 'compose-test-worktrees', 'dispatch-ledger');
35
+ }
36
+ return cwd;
37
+ }
38
+
39
+ export const DISPATCH_SITES = Object.freeze([
40
+ 'build-step', 'consumer', 'review', 'review-repair', 'gsd', 'gate-qa',
41
+ 'escalation', 'preflight', 'judge', 'design-chat', 'import', 'validation',
42
+ 'new-project', 'unattributed',
43
+ ]);
44
+ export const DISPATCH_OUTCOMES = Object.freeze(['ok', 'error', 'blocked']);
45
+ export const SETTLEMENT_FAILURE_CLASSES = Object.freeze([
46
+ 'ownership', 'vocabulary', 'normalization', 'agent', 'ensure-retry',
47
+ ]);
48
+ export const ESTIMATE_LANES = Object.freeze(['trivial', 'standard', 'complex']);
49
+ export const ESTIMATE_SOURCES = Object.freeze(['fresh', 'cached', 'escalated']);
50
+ export const TRIAGE_CONFIDENCES = Object.freeze(['high', 'medium', 'low']);
51
+ export const BUILD_TERMINAL_STATUSES = Object.freeze(['complete', 'failed', 'aborted']);
52
+ export const BUILD_FILES_SOURCES = Object.freeze(['ship', 'accumulated']);
53
+
54
+ const EVENT_FIELDS = {
55
+ dispatch: {
56
+ required: ['dispatch_id', 'site', 'agent', 'outcome'],
57
+ optional: [
58
+ 'build_id', 'feature_code', 'step_id', 'attempt', 'model',
59
+ 'effort_intended', 'effort_executed', 'tokens_in', 'tokens_out',
60
+ 'tokens_total', 'usd', 'duration_ms', 'note',
61
+ ],
62
+ },
63
+ settlement: {
64
+ required: ['dispatch_id', 'accepted'],
65
+ optional: ['build_id', 'step_id', 'failure_class'],
66
+ },
67
+ 'triage-estimate': {
68
+ required: ['build_id', 'feature_code', 'tier', 'lane', 'profile', 'estimate_source'],
69
+ optional: ['confidence'],
70
+ },
71
+ 'build-actuals': {
72
+ required: [
73
+ 'build_id', 'feature_code', 'terminal_status', 'files_changed_count',
74
+ 'files_source', 'review_iterations', 'escalations', 'tokens_total', 'usd',
75
+ ],
76
+ optional: ['test_count', 'pass_rate'],
77
+ },
78
+ };
79
+
80
+ const countersByPath = new Map();
81
+
82
+ function ledgerPath(projectCwd) {
83
+ return resolve(projectCwd, DISPATCH_LEDGER_RELATIVE_PATH);
84
+ }
85
+
86
+ function isPlainObject(value) {
87
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
88
+ }
89
+
90
+ function isString(value) {
91
+ return typeof value === 'string' && value.length > 0;
92
+ }
93
+
94
+ function isFiniteNumber(value) {
95
+ return typeof value === 'number' && Number.isFinite(value);
96
+ }
97
+
98
+ function isNullableFiniteNumber(value) {
99
+ return value === null || isFiniteNumber(value);
100
+ }
101
+
102
+ function isNullableString(value) {
103
+ return value === null || isString(value);
104
+ }
105
+
106
+ function isEnum(value, values) {
107
+ return values.includes(value);
108
+ }
109
+
110
+ function invalid(message) {
111
+ throw new Error(`dispatch-ledger.appendEvent: ${message}`);
112
+ }
113
+
114
+ function validateEvent(event, { allowExtra = false } = {}) {
115
+ if (!isPlainObject(event)) invalid('event must be an object');
116
+ if (!isString(event.kind)) invalid('kind is required');
117
+ const shape = EVENT_FIELDS[event.kind];
118
+ if (!shape) invalid(`unknown kind "${event.kind}"`);
119
+
120
+ const allowed = new Set(['kind', ...shape.required, ...shape.optional]);
121
+ if (!allowExtra) {
122
+ for (const field of Object.keys(event)) {
123
+ if (!allowed.has(field)) invalid(`unknown field "${field}" for ${event.kind}`);
124
+ }
125
+ }
126
+ for (const field of shape.required) {
127
+ if (!Object.hasOwn(event, field) || event[field] === undefined) {
128
+ invalid(`required field "${field}" is missing for ${event.kind}`);
129
+ }
130
+ }
131
+
132
+ switch (event.kind) {
133
+ case 'dispatch':
134
+ requireString(event, 'dispatch_id');
135
+ requireEnum(event, 'site', DISPATCH_SITES);
136
+ requireString(event, 'agent');
137
+ requireEnum(event, 'outcome', DISPATCH_OUTCOMES);
138
+ optionalString(event, 'build_id');
139
+ optionalString(event, 'feature_code');
140
+ optionalString(event, 'step_id');
141
+ optionalNumber(event, 'attempt');
142
+ optionalNullableString(event, 'model');
143
+ optionalNullableString(event, 'effort_intended');
144
+ optionalNullableString(event, 'effort_executed');
145
+ for (const field of ['tokens_in', 'tokens_out', 'tokens_total', 'usd', 'duration_ms']) {
146
+ optionalNullableNumber(event, field);
147
+ }
148
+ optionalString(event, 'note');
149
+ break;
150
+ case 'settlement':
151
+ requireString(event, 'dispatch_id');
152
+ if (typeof event.accepted !== 'boolean') invalid('accepted must be a boolean');
153
+ optionalString(event, 'build_id');
154
+ optionalString(event, 'step_id');
155
+ optionalEnum(event, 'failure_class', SETTLEMENT_FAILURE_CLASSES);
156
+ break;
157
+ case 'triage-estimate':
158
+ requireString(event, 'build_id');
159
+ requireString(event, 'feature_code');
160
+ if (!Number.isInteger(event.tier) || event.tier < 0 || event.tier > 4) {
161
+ invalid('tier must be an integer 0-4');
162
+ }
163
+ requireEnum(event, 'lane', ESTIMATE_LANES);
164
+ if (!isPlainObject(event.profile)) invalid('profile must be a non-array object');
165
+ requireEnum(event, 'estimate_source', ESTIMATE_SOURCES);
166
+ if (Object.hasOwn(event, 'confidence') && event.confidence !== null && !isEnum(event.confidence, TRIAGE_CONFIDENCES)) {
167
+ invalid(`confidence must be one of ${TRIAGE_CONFIDENCES.join(', ')} or null`);
168
+ }
169
+ break;
170
+ case 'build-actuals':
171
+ requireString(event, 'build_id');
172
+ requireString(event, 'feature_code');
173
+ requireEnum(event, 'terminal_status', BUILD_TERMINAL_STATUSES);
174
+ requireNumber(event, 'files_changed_count');
175
+ requireEnum(event, 'files_source', BUILD_FILES_SOURCES);
176
+ requireNumber(event, 'review_iterations');
177
+ requireNumber(event, 'escalations');
178
+ requireNumber(event, 'tokens_total');
179
+ requireNumber(event, 'usd');
180
+ optionalNullableNumber(event, 'test_count');
181
+ optionalNullableNumber(event, 'pass_rate');
182
+ break;
183
+ }
184
+ }
185
+
186
+ function requireString(event, field) {
187
+ if (!isString(event[field])) invalid(`${field} must be a non-empty string`);
188
+ }
189
+
190
+ function requireNumber(event, field) {
191
+ if (!isFiniteNumber(event[field])) invalid(`${field} must be a number`);
192
+ }
193
+
194
+ function requireEnum(event, field, values) {
195
+ if (!isEnum(event[field], values)) invalid(`${field} must be one of ${values.join(', ')}`);
196
+ }
197
+
198
+ function optionalString(event, field) {
199
+ if (Object.hasOwn(event, field) && !isString(event[field])) invalid(`${field} must be a non-empty string`);
200
+ }
201
+
202
+ function optionalNullableString(event, field) {
203
+ if (Object.hasOwn(event, field) && !isNullableString(event[field])) invalid(`${field} must be a string or null`);
204
+ }
205
+
206
+ function optionalNumber(event, field) {
207
+ if (Object.hasOwn(event, field) && !isFiniteNumber(event[field])) invalid(`${field} must be a number`);
208
+ }
209
+
210
+ function optionalNullableNumber(event, field) {
211
+ if (Object.hasOwn(event, field) && !isNullableFiniteNumber(event[field])) invalid(`${field} must be a number or null`);
212
+ }
213
+
214
+ function optionalEnum(event, field, values) {
215
+ if (Object.hasOwn(event, field) && !isEnum(event[field], values)) {
216
+ invalid(`${field} must be one of ${values.join(', ')}`);
217
+ }
218
+ }
219
+
220
+ function isValidStoredRow(row) {
221
+ if (!isPlainObject(row) || row.v !== 1 || !isIsoTimestamp(row.ts) || !Number.isInteger(row._seq) || row._seq < 0) {
222
+ return false;
223
+ }
224
+ try {
225
+ validateEvent(row, { allowExtra: true });
226
+ return true;
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ function isIsoTimestamp(value) {
233
+ return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) && !Number.isNaN(Date.parse(value));
234
+ }
235
+
236
+ function seedCounter(path) {
237
+ if (countersByPath.has(path)) return countersByPath.get(path);
238
+ let highest = 0;
239
+ if (existsSync(path)) {
240
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
241
+ if (!line.trim()) continue;
242
+ try {
243
+ const row = JSON.parse(line);
244
+ if (isValidStoredRow(row)) highest = Math.max(highest, row._seq);
245
+ } catch {
246
+ // A torn JSONL row must not prevent later rows from being read.
247
+ }
248
+ }
249
+ }
250
+ countersByPath.set(path, highest);
251
+ return highest;
252
+ }
253
+
254
+ /**
255
+ * Append one closed dispatch-ledger event, stamping its immutable envelope.
256
+ *
257
+ * @param {string} projectCwd
258
+ * @param {object} event caller-supplied kind and closed payload only
259
+ * @returns {object} the row written to disk
260
+ */
261
+ export function appendEvent(projectCwd, event) {
262
+ validateEvent(event);
263
+ const path = ledgerPath(projectCwd);
264
+ const nextSeq = seedCounter(path) + 1;
265
+ countersByPath.set(path, nextSeq);
266
+ const row = { v: 1, ts: new Date().toISOString(), _seq: nextSeq, ...event };
267
+ mkdirSync(dirname(path), { recursive: true });
268
+ appendFileSync(path, JSON.stringify(row) + '\n');
269
+ return row;
270
+ }
271
+
272
+ /**
273
+ * Tolerantly read valid, known ledger rows. Future fields are preserved.
274
+ *
275
+ * @param {string} projectCwd
276
+ * @param {object} [opts]
277
+ * @param {string} [opts.kind]
278
+ * @param {string|number|Date} [opts.since]
279
+ * @param {string} [opts.feature]
280
+ * @returns {Array<object>}
281
+ */
282
+ export function readEvents(projectCwd, { kind, since, feature } = {}) {
283
+ const path = ledgerPath(projectCwd);
284
+ if (!existsSync(path)) return [];
285
+ const sinceMs = normalizeSince(since);
286
+ const rows = [];
287
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
288
+ if (!line.trim()) continue;
289
+ try {
290
+ const row = JSON.parse(line);
291
+ if (!isValidStoredRow(row)) continue;
292
+ if (kind && row.kind !== kind) continue;
293
+ if (feature && row.feature_code !== feature) continue;
294
+ if (sinceMs !== null && Date.parse(row.ts) < sinceMs) continue;
295
+ rows.push(row);
296
+ } catch {
297
+ // Malformed rows are expected in an append-only JSONL file after a tear.
298
+ }
299
+ }
300
+ return rows;
301
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * dispatch-metrics.js — read-only aggregation over the dispatch ledger.
3
+ *
4
+ * This module deliberately keeps the ledger's append-only observations intact:
5
+ * settlements and terminal actuals are reduced at read time by their `_seq`.
6
+ */
7
+
8
+ import { readEvents } from './dispatch-ledger.js';
9
+
10
+ const RETRY_NOTE = 'known undercount: child flows';
11
+ const GSD_NOTE = 'not instrumented';
12
+
13
+ function isNumber(value) {
14
+ return typeof value === 'number' && Number.isFinite(value);
15
+ }
16
+
17
+ function upperMiddle(values) {
18
+ const sorted = values.filter(isNumber).sort((a, b) => a - b);
19
+ return sorted.length ? sorted[Math.floor(sorted.length / 2)] : null;
20
+ }
21
+
22
+ function rate(numerator, denominator) {
23
+ return denominator > 0 ? numerator / denominator : null;
24
+ }
25
+
26
+ function highestBy(rows, key) {
27
+ const result = new Map();
28
+ for (const row of rows) {
29
+ const current = result.get(row[key]);
30
+ if (!current || row._seq > current._seq) result.set(row[key], row);
31
+ }
32
+ return result;
33
+ }
34
+
35
+ function compareNullable(a, b) {
36
+ if (a === b) return 0;
37
+ if (a === null || a === undefined) return -1;
38
+ if (b === null || b === undefined) return 1;
39
+ return String(a).localeCompare(String(b));
40
+ }
41
+
42
+ function collectRowStats(dispatches, settlements, { gsd = false } = {}) {
43
+ const tokenValues = dispatches.map((row) => row.tokens_total).filter(isNumber);
44
+ const durationValues = dispatches.map((row) => row.duration_ms).filter(isNumber);
45
+ const settled = dispatches
46
+ .map((row) => settlements.get(row.dispatch_id))
47
+ .filter(Boolean);
48
+ const accepted = settled.filter((row) => row.accepted).length;
49
+ const retryEligible = dispatches.filter((row) => isNumber(row.attempt));
50
+ const retries = retryEligible.filter((row) => row.attempt > 1).length;
51
+
52
+ return {
53
+ token_sample_count: tokenValues.length,
54
+ null_usage_count: dispatches.filter((row) => !isNumber(row.tokens_total)).length,
55
+ median_tokens: upperMiddle(tokenValues),
56
+ duration_sample_count: durationValues.length,
57
+ median_duration_ms: upperMiddle(durationValues),
58
+ completion: {
59
+ ok: dispatches.filter((row) => row.outcome === 'ok').length,
60
+ total: dispatches.length,
61
+ rate: rate(dispatches.filter((row) => row.outcome === 'ok').length, dispatches.length),
62
+ },
63
+ acceptance: {
64
+ accepted,
65
+ settled: settled.length,
66
+ rate: rate(accepted, settled.length),
67
+ note: gsd ? GSD_NOTE : null,
68
+ },
69
+ retry: {
70
+ retries,
71
+ eligible: retryEligible.length,
72
+ rate: rate(retries, retryEligible.length),
73
+ note: RETRY_NOTE,
74
+ },
75
+ usd_total: dispatches.reduce((total, row) => total + (isNumber(row.usd) ? row.usd : 0), 0),
76
+ };
77
+ }
78
+
79
+ function realizedLane(actual) {
80
+ if (actual.escalations >= 1 || actual.review_iterations >= 3 || actual.files_changed_count >= 6) return 'complex';
81
+ if (actual.files_changed_count <= 2 && actual.review_iterations <= 1 && actual.escalations === 0) return 'trivial';
82
+ return 'standard';
83
+ }
84
+
85
+ function acrrSummary(rows) {
86
+ const total = rows.length;
87
+ const matched = rows.filter((row) => row.matched).length;
88
+ return { matched, total, rate: rate(matched, total) };
89
+ }
90
+
91
+ /**
92
+ * Aggregate dispatch, settlement, estimate, and terminal actual rows without
93
+ * mutating the ledger.
94
+ *
95
+ * @param {string} projectCwd
96
+ * @param {{ since?: string|number|Date, feature?: string }} [opts]
97
+ * @returns {object}
98
+ */
99
+ export function collectDispatchMetrics(projectCwd, { since, feature } = {}) {
100
+ // Settlements have no feature code. Read one since-filtered population first,
101
+ // then join them to the feature-filtered dispatches below.
102
+ const allRows = readEvents(projectCwd, { since });
103
+ const inFeature = (row) => !feature || row.feature_code === feature;
104
+ const dispatches = allRows.filter((row) => row.kind === 'dispatch' && inFeature(row));
105
+ const settlements = highestBy(allRows.filter((row) => row.kind === 'settlement'), 'dispatch_id');
106
+
107
+ const modelBuckets = new Map();
108
+ for (const row of dispatches) {
109
+ const executed = row.effort_executed ?? null;
110
+ const intendedWhenUnknown = executed === null ? (row.effort_intended ?? null) : null;
111
+ const values = [row.model ?? null, executed, intendedWhenUnknown];
112
+ const key = JSON.stringify(values);
113
+ if (!modelBuckets.has(key)) modelBuckets.set(key, { values, rows: [] });
114
+ modelBuckets.get(key).rows.push(row);
115
+ }
116
+ const model_effort = [...modelBuckets.values()]
117
+ .sort((a, b) => compareNullable(a.values[0], b.values[0]) || compareNullable(a.values[1], b.values[1]) || compareNullable(a.values[2], b.values[2]))
118
+ .map(({ values: [model, effort_executed, effort_intended_when_unknown], rows }) => {
119
+ const stats = collectRowStats(rows, settlements, { gsd: rows.every((row) => row.site === 'gsd') });
120
+ return {
121
+ model, effort_executed, effort_intended_when_unknown, dispatch_count: rows.length,
122
+ ...stats,
123
+ };
124
+ });
125
+
126
+ const siteBuckets = new Map();
127
+ for (const row of dispatches) {
128
+ if (!siteBuckets.has(row.site)) siteBuckets.set(row.site, []);
129
+ siteBuckets.get(row.site).push(row);
130
+ }
131
+ const sites = [...siteBuckets.entries()]
132
+ .sort(([a], [b]) => a.localeCompare(b))
133
+ .map(([site, rows]) => ({ site, dispatch_count: rows.length, ...collectRowStats(rows, settlements, { gsd: site === 'gsd' }) }));
134
+
135
+ const estimates = allRows.filter((row) => row.kind === 'triage-estimate' && inFeature(row));
136
+ const actualRows = allRows.filter((row) => row.kind === 'build-actuals' && inFeature(row));
137
+ const actuals = highestBy(actualRows, 'build_id');
138
+ const estimateBuildIds = new Set(estimates.map((row) => row.build_id));
139
+ const acrrRows = estimates
140
+ .map((estimate) => {
141
+ const actual = actuals.get(estimate.build_id) ?? null;
142
+ const eligible = actual?.terminal_status === 'complete';
143
+ const lane = eligible ? realizedLane(actual) : null;
144
+ return {
145
+ build_id: estimate.build_id,
146
+ feature_code: estimate.feature_code,
147
+ estimate_source: estimate.estimate_source,
148
+ confidence: estimate.confidence ?? null,
149
+ estimated_lane: estimate.lane,
150
+ terminal_status: actual?.terminal_status ?? null,
151
+ realized_lane: lane,
152
+ matched: eligible ? estimate.lane === lane : false,
153
+ files_changed_count: actual?.files_changed_count ?? null,
154
+ files_source: actual?.files_source ?? null,
155
+ review_iterations: actual?.review_iterations ?? null,
156
+ escalations: actual?.escalations ?? null,
157
+ };
158
+ })
159
+ .sort((a, b) => a.build_id.localeCompare(b.build_id));
160
+ const eligibleRows = acrrRows.filter((row) => row.terminal_status === 'complete' && row.estimate_source !== 'escalated');
161
+ const escalatedRows = acrrRows.filter((row) => row.terminal_status === 'complete' && row.estimate_source === 'escalated');
162
+ const failed_attempts = actualRows.filter((row) => row.terminal_status === 'failed').length;
163
+ const aborted_attempts = actualRows.filter((row) => row.terminal_status === 'aborted').length;
164
+
165
+ return {
166
+ v: 1,
167
+ filters: { since: since ?? null, feature: feature ?? null },
168
+ coverage: {
169
+ dispatch_count: dispatches.length,
170
+ unattributed_count: dispatches.filter((row) => row.site === 'unattributed').length,
171
+ null_usage_count: dispatches.filter((row) => !isNumber(row.tokens_total)).length,
172
+ },
173
+ model_effort,
174
+ sites,
175
+ acrr: {
176
+ eligible: acrrSummary(eligibleRows),
177
+ escalated: acrrSummary(escalatedRows),
178
+ attrition_count: failed_attempts + aborted_attempts,
179
+ failed_attempts,
180
+ aborted_attempts,
181
+ pending_count: acrrRows.filter((row) => row.terminal_status === null).length,
182
+ unpaired_count: [...actuals.keys()].filter((buildId) => !estimateBuildIds.has(buildId)).length,
183
+ rows: acrrRows,
184
+ },
185
+ known_limitations: { retry_rate: RETRY_NOTE, gsd_acceptance: GSD_NOTE },
186
+ };
187
+ }
188
+
189
+ function formatRate(value) {
190
+ return value === null ? 'n/a' : `${(value * 100).toFixed(1)}%`;
191
+ }
192
+
193
+ function formatMedian(value) {
194
+ return value === null ? 'n/a' : String(value);
195
+ }
196
+
197
+ function formatAcceptance(acceptance) {
198
+ return acceptance.note === GSD_NOTE ? 'n/a (not instrumented)' : formatRate(acceptance.rate);
199
+ }
200
+
201
+ /** Render a compact, stable human report for `compose metrics`. */
202
+ export function renderDispatchMetrics(report) {
203
+ const lines = [
204
+ 'Model × executed effort',
205
+ 'model | executed | intended when unknown | dispatches | median tokens | median ms | completion | acceptance | retry | usd',
206
+ ];
207
+ for (const row of report.model_effort) {
208
+ lines.push([
209
+ row.model === null ? '(model: unknown)' : row.model,
210
+ row.effort_executed ?? 'unknown',
211
+ row.effort_intended_when_unknown ?? '—',
212
+ row.dispatch_count,
213
+ formatMedian(row.median_tokens),
214
+ formatMedian(row.median_duration_ms),
215
+ formatRate(row.completion.rate),
216
+ formatAcceptance(row.acceptance),
217
+ formatRate(row.retry.rate),
218
+ row.usd_total,
219
+ ].join(' | '));
220
+ }
221
+ lines.push('', 'Sites', 'site | dispatches | median tokens | median ms | completion | acceptance | retry | usd');
222
+ for (const row of report.sites) {
223
+ lines.push([
224
+ row.site, row.dispatch_count, formatMedian(row.median_tokens), formatMedian(row.median_duration_ms),
225
+ formatRate(row.completion.rate), formatAcceptance(row.acceptance), formatRate(row.retry.rate), row.usd_total,
226
+ ].join(' | '));
227
+ }
228
+ lines.push('', 'ACRR');
229
+ lines.push(`eligible: ${report.acrr.eligible.matched}/${report.acrr.eligible.total} (${formatRate(report.acrr.eligible.rate)})`);
230
+ lines.push(`escalated: ${report.acrr.escalated.matched}/${report.acrr.escalated.total} (${formatRate(report.acrr.escalated.rate)})`);
231
+ lines.push(`attrition: ${report.acrr.attrition_count} (failed attempts: ${report.acrr.failed_attempts}, aborted attempts: ${report.acrr.aborted_attempts}, pending: ${report.acrr.pending_count}, unpaired: ${report.acrr.unpaired_count})`);
232
+ lines.push('', 'Known limitations');
233
+ lines.push(`retry rate: ${report.known_limitations.retry_rate}`);
234
+ lines.push(`GSD acceptance: ${report.known_limitations.gsd_acceptance}`);
235
+ return `${lines.join('\n')}\n`;
236
+ }
@@ -121,6 +121,7 @@ export async function judge({ diff, goal, judgeModel, stratum, cwd }) {
121
121
  try {
122
122
  const prompt = buildJudgePrompt(diff, goal);
123
123
  const { provider, modelID, thinking, effort } = resolveAgentConfig(judgeModel);
124
+ const telemetry = { site: 'judge', project_cwd: cwd };
124
125
 
125
126
  let text;
126
127
  if (typeof stratum.agentRun === 'function') {
@@ -130,11 +131,15 @@ export async function judge({ diff, goal, judgeModel, stratum, cwd }) {
130
131
  thinking: thinking ?? undefined,
131
132
  effort: effort ?? undefined,
132
133
  cwd: cwd ?? undefined,
134
+ telemetry,
133
135
  });
134
136
  text = result?.text ?? '';
135
137
  } else {
136
138
  // Fallback: runAgentText (no model pinning — test harnesses may use this).
137
- text = await stratum.runAgentText(provider, prompt, { cwd: cwd ?? undefined });
139
+ text = await stratum.runAgentText(provider, prompt, {
140
+ cwd: cwd ?? undefined,
141
+ telemetry,
142
+ });
138
143
  }
139
144
 
140
145
  return extractJudgeResult(text);
@@ -68,6 +68,7 @@ const IMPACTS = new Set(['low', 'medium', 'high']);
68
68
  // front estimator, the doc-reading refinement, or an escalation bump.
69
69
  const LANES = new Set(['trivial', 'standard', 'complex']);
70
70
  const ESTIMATE_SOURCES = new Set(['front', 'refined', 'escalated']);
71
+ const TRIAGE_CONFIDENCES = new Set(['high', 'medium', 'low']);
71
72
 
72
73
  // ---------------------------------------------------------------------------
73
74
  // validateFeatureFields — shared field-shape guard
@@ -90,6 +91,7 @@ const ESTIMATE_SOURCES = new Set(['front', 'refined', 'escalated']);
90
91
  * @param {number} [fields.triageTier] integer 0-4
91
92
  * @param {string} [fields.lane] one of trivial|standard|complex
92
93
  * @param {string} [fields.estimateSource] one of front|refined|escalated
94
+ * @param {string} [fields.triageConfidence] one of high|medium|low
93
95
  */
94
96
  export function validateFeatureFields(fields) {
95
97
  if (fields.complexity !== undefined && !COMPLEXITIES.has(fields.complexity)) {
@@ -107,6 +109,11 @@ export function validateFeatureFields(fields) {
107
109
  `feature-writer: invalid estimateSource "${fields.estimateSource}" — must be one of ${[...ESTIMATE_SOURCES].join(', ')}`
108
110
  );
109
111
  }
112
+ if (fields.triageConfidence !== undefined && !TRIAGE_CONFIDENCES.has(fields.triageConfidence)) {
113
+ throw new Error(
114
+ `feature-writer: invalid triageConfidence "${fields.triageConfidence}" — must be one of ${[...TRIAGE_CONFIDENCES].join(', ')}`
115
+ );
116
+ }
110
117
  }
111
118
 
112
119
  // ---------------------------------------------------------------------------
@@ -164,6 +171,7 @@ function maybeIdempotent(args, fn) {
164
171
  * @param {string} [args.lane] COMP-TRIAGE-5: trivial | standard | complex
165
172
  * @param {number} [args.triageTier] COMP-TRIAGE-5: integer 0-4
166
173
  * @param {string} [args.estimateSource] COMP-TRIAGE-5: front | refined | escalated
174
+ * @param {string} [args.triageConfidence] COMP-TRIAGE-6: high | medium | low
167
175
  * @param {boolean} [args.force]
168
176
  * @param {string} [args.idempotency_key]
169
177
  */
@@ -235,6 +243,7 @@ export async function addRoadmapEntry(cwd, args) {
235
243
  if (args.lane !== undefined) feature.lane = args.lane;
236
244
  if (args.triageTier !== undefined) feature.triageTier = args.triageTier;
237
245
  if (args.estimateSource !== undefined) feature.estimateSource = args.estimateSource;
246
+ if (args.triageConfidence !== undefined) feature.triageConfidence = args.triageConfidence;
238
247
 
239
248
  let roundtrip = null;
240
249
  if (isLocalProvider(provider)) {
package/lib/gsd.js CHANGED
@@ -518,7 +518,7 @@ async function runOneStep(response, ctx) {
518
518
  // by definition a GSD task, so timing.json + diffs/<id>.diff are written
519
519
  // for the milestone report. The build consumer path omits it (no marker),
520
520
  // so build-mode fanout stays byte-identical.
521
- context: { cwd, featureCode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage) => recordTsAgentUsage(ctx, usage), taskResultPath },
521
+ context: { cwd, projectCwd: cwd, featureCode, gsd: true, gsdTaskId: consumerItem?.id, filesChanged: ctx.filesChanged, onUsage: (usage) => recordTsAgentUsage(ctx, usage), taskResultPath },
522
522
  // runAndNormalize narrates via progress.debug/warn/info/toolUse — gsd
523
523
  // has no cockpit, so a COMPLETE no-op (not just stepStart/stepDone) is
524
524
  // required or the item throws "progress.debug is not a function".
@@ -565,7 +565,16 @@ async function runOneStep(response, ctx) {
565
565
 
566
566
  // D2(b): dispatch via agentRun (not runAgentText) so the TS complete
567
567
  // envelope's usage is captured and debited into the cumulative ledger.
568
- const runOut = await stratum.agentRun(step.agent ?? 'claude', step.do ?? '', { cwd });
568
+ const runOut = await stratum.agentRun(step.agent ?? 'claude', step.do ?? '', {
569
+ cwd,
570
+ telemetry: {
571
+ site: 'gsd',
572
+ project_cwd: cwd,
573
+ feature_code: featureCode,
574
+ step_id: step.id,
575
+ ...(typeof step.attempt === 'number' ? { attempt: step.attempt } : {}),
576
+ },
577
+ });
569
578
  const text = runOut?.text ?? '';
570
579
  recordTsAgentUsage(ctx, runOut?.usage);
571
580
  // V1: also report usage in the step_done envelope so the ENGINE debits its