@smartmemory/compose 0.1.0 → 0.1.2-beta

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 (124) hide show
  1. package/.claude/skills/bug-fix/SKILL.md +143 -0
  2. package/.claude/skills/compose/SKILL.md +604 -0
  3. package/.compose-deps.json +89 -0
  4. package/README.md +14 -3
  5. package/bin/compose.js +473 -0
  6. package/contracts/comp-obs-contract.schema.json +362 -0
  7. package/contracts/cross-model-review-result.json +78 -0
  8. package/contracts/review-result.json +126 -0
  9. package/dist/assets/{_baseUniq-CQwX6VLz.js → _baseUniq-D-avYfn5.js} +1 -1
  10. package/dist/assets/{arc-SxJ2J1sh.js → arc-BC4dfQ-X.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-BykunY1F.js → architectureDiagram-Q4EWVU46-BZmFXnGI.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-ohAKBOUw.js → blockDiagram-DXYQGD6D-DlfWSuux.js} +1 -1
  13. package/dist/assets/{c4Diagram-AHTNJAMY-DBDC3ENB.js → c4Diagram-AHTNJAMY-Y__uJrRx.js} +1 -1
  14. package/dist/assets/channel-LRG9kHqJ.js +1 -0
  15. package/dist/assets/{chunk-4BX2VUAB-Cv93Z7uM.js → chunk-4BX2VUAB-BfMePfTp.js} +1 -1
  16. package/dist/assets/{chunk-4TB4RGXK-DE0WBDkj.js → chunk-4TB4RGXK-BdlMSdEA.js} +1 -1
  17. package/dist/assets/{chunk-55IACEB6-CE1EXenG.js → chunk-55IACEB6-vrQHZTdv.js} +1 -1
  18. package/dist/assets/{chunk-EDXVE4YY-DA7Ana6H.js → chunk-EDXVE4YY-B8wioVlW.js} +1 -1
  19. package/dist/assets/{chunk-FMBD7UC4-CTDIPA3p.js → chunk-FMBD7UC4-Cd6Hrux2.js} +1 -1
  20. package/dist/assets/{chunk-OYMX7WX6-uGBaPaTX.js → chunk-OYMX7WX6-CfrhdQXY.js} +1 -1
  21. package/dist/assets/{chunk-QZHKN3VN-CYlnXuUO.js → chunk-QZHKN3VN-B9JQerOU.js} +1 -1
  22. package/dist/assets/{chunk-YZCP3GAM-ojGkzcZK.js → chunk-YZCP3GAM-DFN9X99H.js} +1 -1
  23. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +1 -0
  24. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +1 -0
  25. package/dist/assets/clone-dRxgFrBv.js +1 -0
  26. package/dist/assets/{cose-bilkent-S5V4N54A-Bktn9hL-.js → cose-bilkent-S5V4N54A-BAn0ap_E.js} +1 -1
  27. package/dist/assets/{dagre-KV5264BT-DFaSzuRF.js → dagre-KV5264BT-DyxnVq1g.js} +1 -1
  28. package/dist/assets/{diagram-5BDNPKRD-DnfmDzEm.js → diagram-5BDNPKRD-XCrzqski.js} +1 -1
  29. package/dist/assets/{diagram-G4DWMVQ6-Bm8W9YnG.js → diagram-G4DWMVQ6-MBCAXft_.js} +1 -1
  30. package/dist/assets/{diagram-MMDJMWI5-B5-TSKvp.js → diagram-MMDJMWI5-DbtB2yS6.js} +1 -1
  31. package/dist/assets/{diagram-TYMM5635-ls4rqlky.js → diagram-TYMM5635-Bb5NzX61.js} +1 -1
  32. package/dist/assets/{erDiagram-SMLLAGMA-giG6WO-r.js → erDiagram-SMLLAGMA-CpIeCOh2.js} +1 -1
  33. package/dist/assets/{flowDiagram-DWJPFMVM-XvlUuz-7.js → flowDiagram-DWJPFMVM-CHyoKnhW.js} +1 -1
  34. package/dist/assets/{ganttDiagram-T4ZO3ILL-hLBV57oV.js → ganttDiagram-T4ZO3ILL-DErKteO_.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js → gitGraphDiagram-UUTBAWPF-KFVAtj2F.js} +1 -1
  36. package/dist/assets/{graph-D0Cfv00Y.js → graph-CRnO_ifT.js} +1 -1
  37. package/dist/assets/index-DKBsEUJ-.css +1 -0
  38. package/dist/assets/index-DkRKLuNr.js +1144 -0
  39. package/dist/assets/{infoDiagram-42DDH7IO-DbqRsOo3.js → infoDiagram-42DDH7IO-BZFnuSp5.js} +1 -1
  40. package/dist/assets/{ishikawaDiagram-UXIWVN3A-DnCdx7zb.js → ishikawaDiagram-UXIWVN3A-4Xe2Szde.js} +1 -1
  41. package/dist/assets/{journeyDiagram-VCZTEJTY-CfD7eNcP.js → journeyDiagram-VCZTEJTY-CZRByfS-.js} +1 -1
  42. package/dist/assets/{kanban-definition-6JOO6SKY-BYaO9-mK.js → kanban-definition-6JOO6SKY-B95sk6Fk.js} +1 -1
  43. package/dist/assets/{layout-Bj72wOEB.js → layout-BqNQzxWT.js} +1 -1
  44. package/dist/assets/{linear-BRFo114D.js → linear-CUh7qb64.js} +1 -1
  45. package/dist/assets/{min-GCHnKlJS.js → min-wXgOS3ig.js} +1 -1
  46. package/dist/assets/{mindmap-definition-QFDTVHPH-n0PMebY4.js → mindmap-definition-QFDTVHPH-DB6iaAbO.js} +1 -1
  47. package/dist/assets/{pieDiagram-DEJITSTG-pN4CljHF.js → pieDiagram-DEJITSTG-CHkZHrTW.js} +1 -1
  48. package/dist/assets/{quadrantDiagram-34T5L4WZ-DNoAy8-D.js → quadrantDiagram-34T5L4WZ-DoTEO8e3.js} +1 -1
  49. package/dist/assets/{requirementDiagram-MS252O5E-BhtY05PT.js → requirementDiagram-MS252O5E-Dn8peXYp.js} +1 -1
  50. package/dist/assets/{sankeyDiagram-XADWPNL6-B6AD-16A.js → sankeyDiagram-XADWPNL6-DRXs6Ipb.js} +1 -1
  51. package/dist/assets/{sequenceDiagram-FGHM5R23-DShHM-uk.js → sequenceDiagram-FGHM5R23-wBBYZ0aq.js} +1 -1
  52. package/dist/assets/{stateDiagram-FHFEXIEX-DMxn7HTo.js → stateDiagram-FHFEXIEX-DPlBNGmf.js} +1 -1
  53. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +1 -0
  54. package/dist/assets/{timeline-definition-GMOUNBTQ-Cdu6uq52.js → timeline-definition-GMOUNBTQ-CbbyTlHk.js} +1 -1
  55. package/dist/assets/{vennDiagram-DHZGUBPP-CpK29iRe.js → vennDiagram-DHZGUBPP-Bj4GaFfj.js} +1 -1
  56. package/dist/assets/{wardley-RL74JXVD-BQgSkdcO.js → wardley-RL74JXVD-RtNzq8KU.js} +55 -55
  57. package/dist/assets/{wardleyDiagram-NUSXRM2D-DJHYev6O.js → wardleyDiagram-NUSXRM2D-CDfE3zSj.js} +1 -1
  58. package/dist/assets/{xychartDiagram-5P7HB3ND-1d75pbaO.js → xychartDiagram-5P7HB3ND-CZXHHYD5.js} +1 -1
  59. package/dist/index.html +2 -2
  60. package/lib/budget-ledger.js +45 -0
  61. package/lib/bug-bisect.js +292 -0
  62. package/lib/bug-checkpoint.js +191 -0
  63. package/lib/bug-escalation.js +306 -0
  64. package/lib/bug-index-gen.js +136 -0
  65. package/lib/bug-ledger.js +126 -0
  66. package/lib/build-stream-schema.js +176 -0
  67. package/lib/build-stream-writer.js +3 -1
  68. package/lib/build.js +854 -284
  69. package/lib/connector-factory-shim.js +167 -0
  70. package/lib/constants.js +18 -0
  71. package/lib/debug-discipline.js +176 -27
  72. package/lib/deps.js +205 -0
  73. package/lib/health-score.js +4 -4
  74. package/lib/import.js +26 -13
  75. package/lib/inject-schema.js +21 -0
  76. package/lib/new.js +27 -53
  77. package/lib/result-normalizer.js +160 -144
  78. package/lib/review-lenses.js +5 -5
  79. package/lib/review-normalize.js +413 -0
  80. package/lib/review-prompt.js +163 -0
  81. package/lib/sections.js +325 -0
  82. package/lib/step-prompt.js +21 -1
  83. package/lib/step-validator.js +5 -3
  84. package/lib/stratum-mcp-client.js +172 -7
  85. package/package.json +14 -3
  86. package/pipelines/bug-fix.stratum.yaml +39 -1
  87. package/pipelines/build.stratum.yaml +28 -45
  88. package/pipelines/review-fix.stratum.yaml +1 -1
  89. package/presets/team-review.stratum.yaml +21 -14
  90. package/server/build-stream-bridge.js +28 -0
  91. package/server/cc-session-feature-resolver.js +111 -0
  92. package/server/cc-session-reader.js +327 -0
  93. package/server/cc-session-watcher.js +318 -0
  94. package/server/compose-mcp-tools.js +0 -125
  95. package/server/compose-mcp.js +2 -4
  96. package/server/contract-diff.js +192 -0
  97. package/server/decision-event-emit.js +175 -0
  98. package/server/decision-event-id.js +64 -0
  99. package/server/decision-events-snapshot.js +166 -0
  100. package/server/design-routes.js +92 -49
  101. package/server/drift-axes.js +365 -0
  102. package/server/drift-emit.js +121 -0
  103. package/server/gate-log-store.js +102 -0
  104. package/server/lifecycle-phase-history.js +44 -0
  105. package/server/open-loops-store.js +102 -0
  106. package/server/schema-validator.js +49 -0
  107. package/server/status-emit.js +27 -0
  108. package/server/status-snapshot.js +218 -0
  109. package/server/vision-routes.js +332 -4
  110. package/server/vision-server.js +104 -12
  111. package/server/vision-store.js +21 -0
  112. package/dist/assets/channel-DGElom1e.js +0 -1
  113. package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +0 -1
  114. package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +0 -1
  115. package/dist/assets/clone-DUJKJXd7.js +0 -1
  116. package/dist/assets/index-CUd6pFGF.css +0 -1
  117. package/dist/assets/index-DReRlzZI.js +0 -1144
  118. package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +0 -1
  119. package/server/connectors/agent-connector.js +0 -78
  120. package/server/connectors/claude-sdk-connector.js +0 -198
  121. package/server/connectors/codex-connector.js +0 -240
  122. package/server/connectors/connector-discovery.js +0 -18
  123. package/server/connectors/connector-runtime.js +0 -13
  124. package/server/connectors/opencode-connector.js +0 -200
@@ -0,0 +1,327 @@
1
+ /**
2
+ * CC JSONL session reader — COMP-OBS-BRANCH T1.
3
+ *
4
+ * Walks a single `~/.claude/projects/<slug>/<session-id>.jsonl`, parses records,
5
+ * builds a parent-pointer tree over non-sidechain records, identifies leaves,
6
+ * classifies per-branch state, and derives the BranchOutcome metrics required by
7
+ * the shared Wave 6 contract (docs/features/COMP-OBS-CONTRACT/schema.json).
8
+ *
9
+ * Producer-side only. The `feature_code` field is injected later by the watcher
10
+ * (T5) using the CC-session feature resolver (T2); records emitted here omit it.
11
+ */
12
+
13
+ import { readFileSync } from 'node:fs';
14
+ import { basename } from 'node:path';
15
+ import { createHash } from 'node:crypto';
16
+
17
+ const WRITE_TOOL_NAMES = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit']);
18
+ const TEST_RUNNER_RE = /(pytest|jest|vitest|mocha|node\s+--test|npm\s+(run\s+)?test|go\s+test|cargo\s+test)/i;
19
+ const PASS_FAIL_RE = /(\d+)\s+passed(?:[^\n]*?\s(\d+)\s+failed)?(?:[^\n]*?\s(\d+)\s+skipped)?/i;
20
+
21
+ function sha1(s) { return createHash('sha1').update(s).digest('hex'); }
22
+
23
+ function parseJsonlSafe(text) {
24
+ const out = [];
25
+ const lines = text.split('\n');
26
+ let truncated = false;
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const line = lines[i];
29
+ if (line.length === 0) continue;
30
+ try {
31
+ out.push(JSON.parse(line));
32
+ } catch {
33
+ // Any unparseable line (middle or trailing) marks the session as truncated
34
+ // so the downstream classifier surfaces `unknown` rather than silently dropping
35
+ // potentially load-bearing records (e.g. a result/error line).
36
+ truncated = true;
37
+ }
38
+ }
39
+ return { records: out, truncated };
40
+ }
41
+
42
+ function contentArray(msg) {
43
+ const c = msg?.content;
44
+ if (!c) return [];
45
+ return Array.isArray(c) ? c : [];
46
+ }
47
+
48
+ function hasIsErrorToolResult(rec) {
49
+ return rec?.type === 'user' &&
50
+ contentArray(rec.message).some(item => item?.type === 'tool_result' && item?.is_error === true);
51
+ }
52
+
53
+ function classifyLeafState(leaf, childrenByParent) {
54
+ if (!leaf) return 'unknown';
55
+ if (hasIsErrorToolResult(leaf)) return 'failed';
56
+ if (leaf.type === 'user') {
57
+ const hasToolResult = contentArray(leaf.message).some(it => it?.type === 'tool_result');
58
+ if (hasToolResult) return 'complete';
59
+ }
60
+ if (leaf.type === 'assistant') {
61
+ const stopReason = leaf.message?.stop_reason;
62
+ if (stopReason === 'end_turn') return 'complete';
63
+ const hasUse = contentArray(leaf.message).some(it => it?.type === 'tool_use');
64
+ if (hasUse) {
65
+ const kids = childrenByParent.get(leaf.uuid) || [];
66
+ if (kids.length === 0) return 'running';
67
+ }
68
+ }
69
+ return 'unknown';
70
+ }
71
+
72
+ function walkPath(leafUuid, byUuid) {
73
+ const path = [];
74
+ let cur = byUuid.get(leafUuid);
75
+ while (cur) {
76
+ path.push(cur);
77
+ if (!cur.parentUuid) break;
78
+ cur = byUuid.get(cur.parentUuid);
79
+ }
80
+ return path.reverse();
81
+ }
82
+
83
+ function findForkUuid(path, forkPointUuids) {
84
+ for (let i = path.length - 1; i >= 0; i--) {
85
+ if (forkPointUuids.has(path[i].uuid)) return path[i].uuid;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ function firstPostForkIndex(path, forkUuid) {
91
+ if (!forkUuid) return 0;
92
+ const idx = path.findIndex(r => r.uuid === forkUuid);
93
+ return idx >= 0 ? idx + 1 : 0;
94
+ }
95
+
96
+ function extractMetricsForPath(path, fullRecords) {
97
+ let turnCount = 0;
98
+ const fileStats = new Map();
99
+ let testsPassed = 0, testsFailed = 0, testsSkipped = 0;
100
+ const runIds = new Set();
101
+ let tokensIn = 0, tokensOut = 0, cacheReadIn = 0, cacheCreation = 0;
102
+ let finalArtifact = null;
103
+ let lastWriteIdx = -1;
104
+
105
+ const childrenByParent = new Map();
106
+ for (const r of fullRecords) {
107
+ if (!r?.uuid) continue;
108
+ const p = r.parentUuid || null;
109
+ if (!childrenByParent.has(p)) childrenByParent.set(p, []);
110
+ childrenByParent.get(p).push(r);
111
+ }
112
+
113
+ let lastUserTurnIdx = -1;
114
+ for (let i = 0; i < path.length; i++) {
115
+ const r = path[i];
116
+ if (r.type === 'user') {
117
+ turnCount++;
118
+ lastUserTurnIdx = i;
119
+ }
120
+ if (r.type === 'assistant') {
121
+ const usage = r.message?.usage || {};
122
+ tokensIn += (usage.input_tokens || 0);
123
+ tokensOut += (usage.output_tokens || 0);
124
+ cacheReadIn += (usage.cache_read_input_tokens || 0);
125
+ cacheCreation += (usage.cache_creation_input_tokens || 0);
126
+ for (const item of contentArray(r.message)) {
127
+ if (item?.type === 'tool_use') {
128
+ if (WRITE_TOOL_NAMES.has(item.name)) {
129
+ const filePath = item.input?.file_path;
130
+ if (filePath) {
131
+ if (!fileStats.has(filePath)) fileStats.set(filePath, new Set());
132
+ fileStats.get(filePath).add(lastUserTurnIdx);
133
+ lastWriteIdx = i;
134
+ }
135
+ }
136
+ if (item.name === 'Bash' && r.requestId) {
137
+ const cmd = item.input?.command || '';
138
+ if (TEST_RUNNER_RE.test(cmd)) runIds.add(r.requestId);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ if (r.type === 'user') {
144
+ for (const item of contentArray(r.message)) {
145
+ if (item?.type === 'tool_result') {
146
+ const content = item.content;
147
+ const stdout = typeof content === 'string'
148
+ ? content
149
+ : Array.isArray(content)
150
+ ? content.map(c => c?.text || '').join('\n')
151
+ : '';
152
+ const m = stdout.match(PASS_FAIL_RE);
153
+ if (m) {
154
+ testsPassed += parseInt(m[1] || '0', 10);
155
+ testsFailed += parseInt(m[2] || '0', 10);
156
+ testsSkipped += parseInt(m[3] || '0', 10);
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ if (lastWriteIdx >= 0) {
164
+ const writeRec = path[lastWriteIdx];
165
+ for (const item of contentArray(writeRec.message)) {
166
+ if (item?.type === 'tool_use' && WRITE_TOOL_NAMES.has(item.name)) {
167
+ const fp = item.input?.file_path;
168
+ if (!fp) continue;
169
+ if (fp.includes('docs/features/')) {
170
+ const kind = inferArtifactKind(fp);
171
+ finalArtifact = { path: fp, kind, snapshot: null };
172
+ break;
173
+ } else if (!finalArtifact) {
174
+ finalArtifact = { path: fp, kind: inferArtifactKind(fp), snapshot: null };
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ const files = [];
181
+ for (const [path, turnSet] of fileStats.entries()) {
182
+ files.push({ path, turns_modified: Math.max(1, turnSet.size) });
183
+ }
184
+ return {
185
+ turnCount,
186
+ files,
187
+ tests: {
188
+ passed: testsPassed,
189
+ failed: testsFailed,
190
+ skipped: testsSkipped,
191
+ run_ids: [...runIds],
192
+ },
193
+ cost: {
194
+ tokens_in: tokensIn,
195
+ tokens_out: tokensOut,
196
+ cache_read_input_tokens: cacheReadIn,
197
+ cache_creation_input_tokens: cacheCreation,
198
+ },
199
+ finalArtifact,
200
+ };
201
+ }
202
+
203
+ function inferArtifactKind(filePath) {
204
+ const name = basename(filePath).toLowerCase();
205
+ if (name === 'design.md') return 'design';
206
+ if (name === 'plan.md') return 'plan';
207
+ if (name === 'prd.md') return 'prd';
208
+ if (name === 'blueprint.md') return 'blueprint';
209
+ if (name === 'report.md') return 'report';
210
+ if (name.endsWith('.diff') || name.endsWith('.patch')) return 'diff';
211
+ if (name.endsWith('.md')) return 'other';
212
+ return 'other';
213
+ }
214
+
215
+ function costUsd(tokensIn, tokensOut) {
216
+ const inRate = Number(process.env.CC_USD_PER_1K_INPUT || 0);
217
+ const outRate = Number(process.env.CC_USD_PER_1K_OUTPUT || 0);
218
+ if (!inRate && !outRate) return 0;
219
+ return (tokensIn / 1000) * inRate + (tokensOut / 1000) * outRate;
220
+ }
221
+
222
+ export async function readCCSession(jsonlPath) {
223
+ const cc_session_id = basename(jsonlPath).replace(/\.jsonl$/, '');
224
+ const raw = readFileSync(jsonlPath, 'utf8');
225
+ const { records, truncated } = parseJsonlSafe(raw);
226
+
227
+ const byUuid = new Map();
228
+ for (const r of records) {
229
+ if (r?.uuid && r.isSidechain !== true) byUuid.set(r.uuid, r);
230
+ }
231
+
232
+ const childrenByParent = new Map();
233
+ for (const r of byUuid.values()) {
234
+ const p = r.parentUuid || null;
235
+ if (!childrenByParent.has(p)) childrenByParent.set(p, []);
236
+ childrenByParent.get(p).push(r);
237
+ }
238
+
239
+ const forkPointUuids = new Set();
240
+ for (const [parent, kids] of childrenByParent.entries()) {
241
+ if (parent == null) continue;
242
+ const userKids = kids.filter(k => k.type === 'user');
243
+ if (userKids.length >= 2) forkPointUuids.add(parent);
244
+ }
245
+
246
+ const leaves = [];
247
+ for (const r of byUuid.values()) {
248
+ const kids = childrenByParent.get(r.uuid) || [];
249
+ if (kids.length === 0) leaves.push(r);
250
+ }
251
+
252
+ const branches = [];
253
+ const forkMap = new Map();
254
+ for (const leaf of leaves) {
255
+ const path = walkPath(leaf.uuid, byUuid);
256
+ if (path.length === 0) continue;
257
+
258
+ let state = classifyLeafState(leaf, childrenByParent);
259
+
260
+ const forkUuid = findForkUuid(path, forkPointUuids);
261
+ const startIdx = firstPostForkIndex(path, forkUuid);
262
+ const branchPath = path.slice(startIdx);
263
+
264
+ // If any line in the file was unparseable, we may have lost records that
265
+ // would have classified a `running` leaf as `complete` (a missing tool_result).
266
+ // `failed` and `complete` both have positive identifications on the leaf itself
267
+ // (is_error / end_turn) so they remain trustworthy; `running` depends on the
268
+ // absence of a child record and becomes unreliable under truncation.
269
+ if (truncated && state === 'running') state = 'unknown';
270
+
271
+ const terminal = state === 'complete' || state === 'failed';
272
+ const startedAtRec = branchPath[0] || path[0];
273
+ const started_at = startedAtRec?.timestamp || null;
274
+ const ended_at = terminal ? (leaf.timestamp || null) : null;
275
+
276
+ let metrics = null;
277
+ if (terminal) {
278
+ metrics = extractMetricsForPath(branchPath, [...byUuid.values()]);
279
+ }
280
+
281
+ const branch_id = sha1(cc_session_id + ':' + leaf.uuid);
282
+
283
+ const outcome = {
284
+ branch_id,
285
+ cc_session_id,
286
+ fork_uuid: forkUuid,
287
+ leaf_uuid: leaf.uuid,
288
+ parent_branch_id: null,
289
+ state,
290
+ started_at,
291
+ ended_at,
292
+ turn_count: terminal ? metrics.turnCount : null,
293
+ files_touched: terminal ? metrics.files : null,
294
+ tests: terminal ? metrics.tests : null,
295
+ cost: terminal
296
+ ? {
297
+ tokens_in: metrics.cost.tokens_in,
298
+ tokens_out: metrics.cost.tokens_out,
299
+ usd: costUsd(metrics.cost.tokens_in, metrics.cost.tokens_out),
300
+ wall_clock_ms: started_at && ended_at ? Math.max(0, Date.parse(ended_at) - Date.parse(started_at)) : null,
301
+ }
302
+ : null,
303
+ final_artifact: terminal ? metrics.finalArtifact : null,
304
+ drift_axes_snapshot: null,
305
+ open_loops_produced: [],
306
+ };
307
+
308
+ if (forkUuid) {
309
+ if (!forkMap.has(forkUuid)) forkMap.set(forkUuid, []);
310
+ forkMap.get(forkUuid).push(leaf.uuid);
311
+ }
312
+
313
+ branches.push(outcome);
314
+ }
315
+
316
+ const fork_points = [];
317
+ for (const [parent_uuid, child_leaf_uuids] of forkMap.entries()) {
318
+ fork_points.push({ parent_uuid, child_leaf_uuids });
319
+ }
320
+
321
+ return {
322
+ cc_session_id,
323
+ branches,
324
+ fork_points,
325
+ truncated,
326
+ };
327
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * CC-session watcher — COMP-OBS-BRANCH T5.
3
+ *
4
+ * Orchestrator that combines:
5
+ * - cc-session-reader.js (T1) — parses one JSONL file
6
+ * - cc-session-feature-resolver.js (T2) — resolves cc_session_id → feature_code
7
+ * - decision-event-id.js (T6) — deterministic DecisionEvent ids
8
+ *
9
+ * Maintains a per-feature × per-session accumulator so a feature with multiple
10
+ * CC sessions never has branches clobbered on POST. On each scan, computes the
11
+ * union BranchLineage per feature, emits DecisionEvents for NEW forks only (via
12
+ * persisted emitted_event_ids), and POSTs the updated lineage.
13
+ *
14
+ * `projectsRoot` is injectable — tests use tmp dirs; production points at
15
+ * `~/.claude/projects`.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { readCCSession } from './cc-session-reader.js';
21
+ import { CCSessionFeatureResolver } from './cc-session-feature-resolver.js';
22
+ import { branchDecisionEventId, shouldEmit } from './decision-event-id.js';
23
+
24
+ const DEFAULT_DEBOUNCE_MS = 100;
25
+
26
+ function listJsonlFiles(projectsRoot) {
27
+ const out = [];
28
+ let entries;
29
+ try { entries = fs.readdirSync(projectsRoot, { withFileTypes: true }); }
30
+ catch { return out; }
31
+ for (const ent of entries) {
32
+ if (!ent.isDirectory()) {
33
+ if (ent.isFile() && ent.name.endsWith('.jsonl')) {
34
+ out.push(path.join(projectsRoot, ent.name));
35
+ }
36
+ continue;
37
+ }
38
+ const subdir = path.join(projectsRoot, ent.name);
39
+ let files;
40
+ try { files = fs.readdirSync(subdir); } catch { continue; }
41
+ for (const f of files) {
42
+ if (f.endsWith('.jsonl')) out.push(path.join(subdir, f));
43
+ }
44
+ }
45
+ return out;
46
+ }
47
+
48
+ function uniqById(arr, key) {
49
+ const seen = new Map();
50
+ for (const x of arr) {
51
+ if (!seen.has(x[key])) seen.set(x[key], x);
52
+ }
53
+ return [...seen.values()];
54
+ }
55
+
56
+ export class CCSessionWatcher {
57
+ constructor({
58
+ projectsRoot,
59
+ sessionsFile,
60
+ featureRoot,
61
+ findItemIdByFeatureCode,
62
+ postBranchLineage,
63
+ broadcastMessage = () => {},
64
+ now = () => new Date().toISOString(),
65
+ // COMP-OBS-STATUS: optional deps for post-lineage status broadcast
66
+ emitStatusSnapshot = null,
67
+ getState = null,
68
+ // COMP-OBS-DRIFT: optional deps for post-lineage drift broadcast
69
+ emitDriftAxes = null,
70
+ projectRoot = null,
71
+ }) {
72
+ if (!projectsRoot) throw new Error('projectsRoot required');
73
+ if (!sessionsFile) throw new Error('sessionsFile required');
74
+ if (!featureRoot) throw new Error('featureRoot required');
75
+ if (typeof findItemIdByFeatureCode !== 'function') throw new Error('findItemIdByFeatureCode required');
76
+ if (typeof postBranchLineage !== 'function') throw new Error('postBranchLineage required');
77
+
78
+ this.projectsRoot = projectsRoot;
79
+ this.resolver = new CCSessionFeatureResolver({ sessionsFile, featureRoot });
80
+ this.findItemIdByFeatureCode = findItemIdByFeatureCode;
81
+ this.postBranchLineage = postBranchLineage;
82
+ this.broadcastMessage = broadcastMessage;
83
+ this.now = now;
84
+ // COMP-OBS-STATUS: optional snapshot emitter (defaults to no-op if not injected)
85
+ this._emitStatusSnapshot = emitStatusSnapshot;
86
+ this._getState = getState;
87
+ // COMP-OBS-DRIFT: optional drift emitter
88
+ this._emitDriftAxes = emitDriftAxes;
89
+ this._projectRoot = projectRoot;
90
+
91
+ // featureCode → (cc_session_id → BranchOutcome[])
92
+ this._accum = new Map();
93
+ // featureCode → Set<emitted_event_id>
94
+ this._emitted = new Map();
95
+ this._watcher = null;
96
+ this._debounce = new Map();
97
+ }
98
+
99
+ _accumFor(fc) {
100
+ if (!this._accum.has(fc)) this._accum.set(fc, new Map());
101
+ return this._accum.get(fc);
102
+ }
103
+ _emittedFor(fc) {
104
+ if (!this._emitted.has(fc)) this._emitted.set(fc, new Set());
105
+ return this._emitted.get(fc);
106
+ }
107
+
108
+ /** Seed emitted_event_ids from persisted lineage (e.g. from prior lineage.emitted_event_ids on server). */
109
+ seedEmittedEventIds(featureCode, ids) {
110
+ const s = this._emittedFor(featureCode);
111
+ for (const id of ids || []) s.add(id);
112
+ }
113
+
114
+ async _scanOne(jsonlPath) {
115
+ const cc_session_id = path.basename(jsonlPath).replace(/\.jsonl$/, '');
116
+ const fc = this.resolver.resolve(cc_session_id);
117
+ if (!fc) return null;
118
+
119
+ let result;
120
+ try { result = await readCCSession(jsonlPath); }
121
+ catch (err) {
122
+ console.warn(`[cc-watcher] read failed for ${jsonlPath}: ${err.message}`);
123
+ return null;
124
+ }
125
+
126
+ const featureScope = `docs/features/${fc}/`;
127
+ const enriched = result.branches.map(b => {
128
+ // Reader-side `final_artifact` is chosen before `feature_code` is known, so
129
+ // a branch that touched multiple feature folders may have picked another
130
+ // feature's artifact. Re-filter against the resolved feature_code — if the
131
+ // picked path isn't scoped to this feature, drop the artifact pointer. Lazy
132
+ // re-selection from the branch path isn't possible here (the reader drops
133
+ // intermediate state), so the safe behavior is to return null rather than
134
+ // point at the wrong feature.
135
+ let fa = b.final_artifact;
136
+ if (fa && fa.path && !fa.path.includes(featureScope)) fa = null;
137
+ return { ...b, feature_code: fc, final_artifact: fa };
138
+ });
139
+ this._accumFor(fc).set(cc_session_id, enriched);
140
+ return { featureCode: fc, cc_session_id, result };
141
+ }
142
+
143
+ _aggregate(featureCode) {
144
+ const sessionMap = this._accumFor(featureCode);
145
+ const all = [];
146
+ for (const branches of sessionMap.values()) all.push(...branches);
147
+ const branches = uniqById(all, 'branch_id');
148
+ const in_progress_siblings = branches.filter(b => b.state === 'running').map(b => b.branch_id);
149
+ return {
150
+ feature_code: featureCode,
151
+ branches,
152
+ in_progress_siblings,
153
+ emitted_event_ids: [...this._emittedFor(featureCode)],
154
+ last_scan_at: this.now(),
155
+ };
156
+ }
157
+
158
+ /** Build the DecisionEvent payload for a fork — pure, no side effects. */
159
+ _buildForkEvent(featureCode, fork) {
160
+ const eventId = branchDecisionEventId(featureCode, fork.branch_id);
161
+ return {
162
+ eventId,
163
+ message: {
164
+ type: 'decisionEvent',
165
+ event: {
166
+ id: eventId,
167
+ feature_code: featureCode,
168
+ timestamp: this.now(),
169
+ kind: 'branch',
170
+ title: `New branch ${fork.branch_id.slice(0, 8)}…`,
171
+ metadata: {
172
+ branch_id: fork.branch_id,
173
+ fork_uuid: fork.fork_uuid || null,
174
+ sibling_branch_ids: fork.sibling_branch_ids || [],
175
+ },
176
+ },
177
+ },
178
+ };
179
+ }
180
+
181
+ async _flush(featureCodes) {
182
+ for (const fc of featureCodes) {
183
+ const itemId = this.findItemIdByFeatureCode(fc);
184
+ if (!itemId) continue;
185
+
186
+ const lineage = this._aggregate(fc);
187
+
188
+ const byFork = new Map();
189
+ for (const b of lineage.branches) {
190
+ if (!b.fork_uuid) continue;
191
+ if (!byFork.has(b.fork_uuid)) byFork.set(b.fork_uuid, []);
192
+ byFork.get(b.fork_uuid).push(b.branch_id);
193
+ }
194
+ const forks = [];
195
+ for (const b of lineage.branches) {
196
+ if (!b.fork_uuid) continue;
197
+ forks.push({
198
+ branch_id: b.branch_id,
199
+ fork_uuid: b.fork_uuid,
200
+ sibling_branch_ids: byFork.get(b.fork_uuid) || [],
201
+ });
202
+ }
203
+
204
+ // Build DecisionEvents for NEW forks only (not yet in emitted set).
205
+ // Do NOT broadcast yet — we persist the updated emitted_event_ids to the
206
+ // lineage POST first, so a failure doesn't leak an unpersisted event id.
207
+ const emitted = this._emittedFor(fc);
208
+ const newEvents = [];
209
+ for (const fork of forks) {
210
+ const { eventId, message } = this._buildForkEvent(fc, fork);
211
+ if (!shouldEmit(eventId, emitted)) continue;
212
+ newEvents.push({ eventId, message });
213
+ }
214
+
215
+ // Stage the new event ids in the lineage payload; keep a rollback list.
216
+ const stagedIds = newEvents.map(e => e.eventId);
217
+ lineage.emitted_event_ids = [
218
+ ...emitted,
219
+ ...stagedIds,
220
+ ];
221
+
222
+ try {
223
+ await this.postBranchLineage(itemId, lineage);
224
+ } catch (err) {
225
+ console.warn(`[cc-watcher] POST lineage failed for ${fc} (${itemId}): ${err.message}`);
226
+ // Persistence failed → do NOT broadcast. emitted set is NOT updated,
227
+ // so a later successful POST will replay and persist these events.
228
+ continue;
229
+ }
230
+
231
+ // POST succeeded → commit event ids to the in-memory dedupe set, then broadcast.
232
+ for (const { eventId, message } of newEvents) {
233
+ emitted.add(eventId);
234
+ this.broadcastMessage(message);
235
+ }
236
+
237
+ // COMP-OBS-DRIFT: emit drift axes before STATUS so STATUS reads fresh drift_axes
238
+ if (this._emitDriftAxes && this._getState && this._projectRoot) {
239
+ try {
240
+ const state = this._getState();
241
+ const itemId = this.findItemIdByFeatureCode(fc);
242
+ const it = itemId && state.items?.get ? state.items.get(itemId) : null;
243
+ if (it) this._emitDriftAxes(this.broadcastMessage, state, it, this._projectRoot, this.now());
244
+ } catch (err) {
245
+ console.warn(`[cc-watcher] emitDriftAxes failed for ${fc}: ${err.message}`);
246
+ }
247
+ }
248
+ // COMP-OBS-STATUS: emit status snapshot after branch lineage update
249
+ if (this._emitStatusSnapshot && this._getState) {
250
+ try {
251
+ this._emitStatusSnapshot(this.broadcastMessage, this._getState(), fc, this.now());
252
+ } catch (err) {
253
+ console.warn(`[cc-watcher] emitStatusSnapshot failed for ${fc}: ${err.message}`);
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ async fullScan() {
260
+ const files = listJsonlFiles(this.projectsRoot);
261
+ const touched = new Set();
262
+ for (const f of files) {
263
+ const scanned = await this._scanOne(f);
264
+ if (scanned) touched.add(scanned.featureCode);
265
+ }
266
+ await this._flush(touched);
267
+ return { files_scanned: files.length, features_touched: [...touched] };
268
+ }
269
+
270
+ async _onFileChange(jsonlPath) {
271
+ const scanned = await this._scanOne(jsonlPath);
272
+ if (scanned) await this._flush([scanned.featureCode]);
273
+ }
274
+
275
+ start() {
276
+ if (this._watcher) return;
277
+ if (!fs.existsSync(this.projectsRoot)) {
278
+ fs.mkdirSync(this.projectsRoot, { recursive: true });
279
+ }
280
+ try {
281
+ this._watcher = fs.watch(this.projectsRoot, { recursive: true }, (_evt, filename) => {
282
+ if (!filename || !filename.endsWith('.jsonl')) return;
283
+ const full = path.join(this.projectsRoot, filename);
284
+ const last = this._debounce.get(full) || 0;
285
+ const now = Date.now();
286
+ if (now - last < DEFAULT_DEBOUNCE_MS) return;
287
+ this._debounce.set(full, now);
288
+ this._onFileChange(full).catch(err => {
289
+ console.warn(`[cc-watcher] change handler failed: ${err.message}`);
290
+ });
291
+ });
292
+ } catch (err) {
293
+ console.warn(`[cc-watcher] fs.watch unavailable, falling back to polling: ${err.message}`);
294
+ this._startPolling();
295
+ }
296
+ }
297
+
298
+ _startPolling(intervalMs = 2000) {
299
+ this._pollTimer = setInterval(async () => {
300
+ const files = listJsonlFiles(this.projectsRoot);
301
+ for (const f of files) {
302
+ const stat = fs.statSync(f, { throwIfNoEntry: false });
303
+ if (!stat) continue;
304
+ const key = f;
305
+ const last = this._debounce.get(key) || 0;
306
+ if (stat.mtimeMs > last) {
307
+ this._debounce.set(key, stat.mtimeMs);
308
+ await this._onFileChange(f);
309
+ }
310
+ }
311
+ }, intervalMs);
312
+ }
313
+
314
+ stop() {
315
+ if (this._watcher) { try { this._watcher.close(); } catch {} this._watcher = null; }
316
+ if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
317
+ }
318
+ }