@smartmemory/compose 0.1.1-beta → 0.1.3-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 +47 -983
  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,365 @@
1
+ /**
2
+ * drift-axes.js — COMP-OBS-DRIFT axis computation.
3
+ *
4
+ * `computeDriftAxes(item, projectRoot, now)` returns DriftAxis[3] conforming to
5
+ * the COMP-OBS-CONTRACT schema v0.2.4. All three axes always present; individual
6
+ * axes fall back to `threshold: null` (disabled) when source is unavailable.
7
+ *
8
+ * Thresholds (Decision 2):
9
+ * path_drift 0.30 (30% of touched files outside the plan)
10
+ * contract_drift 0.20 (20% field churn since plan anchor)
11
+ * review_debt_drift 0.40 (40% unresolved STRAT-REV findings)
12
+ *
13
+ * Git invocations are synchronous / shell-out (mirrors BRANCH's inline pattern).
14
+ * No shared git-utils.js exists in the shipped tree. All errors are caught; the
15
+ * axis returns threshold:null rather than throwing.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { execSync } from 'node:child_process';
21
+ import { diffContracts } from './contract-diff.js';
22
+
23
+ // ── Threshold constants (Decision 2) ─────────────────────────────────────────
24
+
25
+ const THRESHOLD_PATH_DRIFT = 0.30;
26
+ const THRESHOLD_CONTRACT_DRIFT = 0.20;
27
+ const THRESHOLD_REVIEW_DEBT_DRIFT = 0.40;
28
+
29
+ // ── Git helpers ───────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Resolve an ISO timestamp to the nearest commit before that point.
33
+ * Returns null on any error (no git, no repo, timestamp before first commit).
34
+ *
35
+ * @param {string} projectRoot
36
+ * @param {string} isoTimestamp
37
+ * @returns {string|null} commit sha1
38
+ */
39
+ function resolveAnchorCommit(projectRoot, isoTimestamp) {
40
+ try {
41
+ const sha = execSync(
42
+ `git rev-list -1 --before="${isoTimestamp}" HEAD`,
43
+ { cwd: projectRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
44
+ ).trim();
45
+ return sha || null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Return the UNION of three git file sets (committed changes since anchor,
53
+ * uncommitted modifications, untracked new files). Deduplicates paths.
54
+ *
55
+ * @param {string} projectRoot
56
+ * @param {string} anchorCommit
57
+ * @returns {Set<string>} relative paths
58
+ */
59
+ function touchedFilesSince(projectRoot, anchorCommit) {
60
+ const paths = new Set();
61
+ const run = (cmd) => {
62
+ try {
63
+ const out = execSync(cmd, { cwd: projectRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
64
+ for (const line of out.split('\n')) {
65
+ const p = line.trim();
66
+ if (p) paths.add(p);
67
+ }
68
+ } catch {
69
+ // silent — partial results preferred over complete failure
70
+ }
71
+ };
72
+
73
+ run(`git diff --name-only ${anchorCommit}..HEAD`);
74
+ run('git diff --name-only HEAD');
75
+ run('git ls-files --others --exclude-standard');
76
+ return paths;
77
+ }
78
+
79
+ /**
80
+ * Extract backtick-quoted paths from a text file (plan.md / blueprint.md).
81
+ * Returns empty Set on missing/unparseable file.
82
+ *
83
+ * @param {string} filePath — absolute path
84
+ * @returns {Set<string>}
85
+ */
86
+ function extractDeclaredPaths(filePath) {
87
+ const paths = new Set();
88
+ try {
89
+ const content = fs.readFileSync(filePath, 'utf8');
90
+ // Match backtick-quoted strings that look like file paths (contain / or .)
91
+ const regex = /`([^`]+)`/g;
92
+ let m;
93
+ while ((m = regex.exec(content)) !== null) {
94
+ const candidate = m[1].trim();
95
+ // Include if it looks like a file path (has / or extension)
96
+ if (candidate.includes('/') || /\.\w+$/.test(candidate)) {
97
+ paths.add(candidate);
98
+ }
99
+ }
100
+ } catch {
101
+ // Missing file — return empty set; caller handles threshold:null
102
+ }
103
+ return paths;
104
+ }
105
+
106
+ // ── Anchor resolution ─────────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Find the MOST RECENT phaseHistory entry where to === 'plan'.
110
+ * Returns { timestamp } or null if no such entry exists.
111
+ *
112
+ * Design notes:
113
+ * - We intentionally do NOT require outcome === 'approved' because the shipped
114
+ * lifecycle writer frequently stores outcome: null on plain advances.
115
+ * - We use the LAST plan entry, not the first, because the lifecycle allows
116
+ * returning from verification back to plan (a replan). Drift should be
117
+ * measured against the freshest plan baseline, not a stale one — otherwise
118
+ * stale alerts persist across replans.
119
+ *
120
+ * @param {object} item
121
+ * @returns {{ timestamp: string }|null}
122
+ */
123
+ function findPlanAnchor(item) {
124
+ const history = item?.lifecycle?.phaseHistory;
125
+ if (!Array.isArray(history)) return null;
126
+ let lastPlan = null;
127
+ for (const entry of history) {
128
+ if (entry.to === 'plan') lastPlan = entry;
129
+ }
130
+ return lastPlan ? { timestamp: lastPlan.timestamp } : null;
131
+ }
132
+
133
+ // ── Axis computation ──────────────────────────────────────────────────────────
134
+
135
+ function buildAxis(axis_id, name, numerator, denominator, threshold, computed_at, explanation) {
136
+ const ratio = denominator > 0 ? numerator / denominator : 0;
137
+ const breached = threshold != null && ratio >= threshold;
138
+ return {
139
+ axis_id,
140
+ name,
141
+ numerator,
142
+ denominator,
143
+ ratio,
144
+ threshold,
145
+ breached,
146
+ computed_at,
147
+ explanation,
148
+ breach_started_at: null,
149
+ breach_event_id: null,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Compute path_drift.
155
+ *
156
+ * @param {object} item
157
+ * @param {string} projectRoot
158
+ * @param {string} featurePath — absolute path to docs/features/<FC>/
159
+ * @param {string} now — ISO timestamp
160
+ * @returns {DriftAxis}
161
+ */
162
+ function computePathDrift(item, projectRoot, featurePath, now) {
163
+ const axis_id = 'path_drift';
164
+ const name = 'Path drift';
165
+ const explanation = 'Ratio of files touched since plan-approval that are not declared in plan.md or blueprint.md.';
166
+
167
+ const anchor = findPlanAnchor(item);
168
+ if (!anchor) {
169
+ return buildAxis(axis_id, name, 0, 0, null, now, explanation);
170
+ }
171
+
172
+ const anchorCommit = resolveAnchorCommit(projectRoot, anchor.timestamp);
173
+ if (!anchorCommit) {
174
+ return buildAxis(axis_id, name, 0, 0, null, now, explanation);
175
+ }
176
+
177
+ const touched = touchedFilesSince(projectRoot, anchorCommit);
178
+ if (touched.size === 0) {
179
+ return buildAxis(axis_id, name, 0, 0, THRESHOLD_PATH_DRIFT, now, explanation);
180
+ }
181
+
182
+ // Collect declared paths from plan.md and blueprint.md
183
+ const declared = new Set();
184
+ for (const fname of ['plan.md', 'blueprint.md']) {
185
+ const filePath = path.join(featurePath, fname);
186
+ for (const p of extractDeclaredPaths(filePath)) {
187
+ declared.add(p);
188
+ }
189
+ }
190
+
191
+ // Count touched files NOT in declared set
192
+ let undeclared = 0;
193
+ for (const t of touched) {
194
+ // Normalize: strip leading ./ if present
195
+ const norm = t.replace(/^\.\//, '');
196
+ // Check whether any declared path matches as a suffix (last segment or subpath)
197
+ const isDeclared = [...declared].some(d => {
198
+ const dn = d.replace(/^\.\//, '');
199
+ return norm === dn || norm.endsWith('/' + dn) || norm.endsWith(dn);
200
+ });
201
+ if (!isDeclared) undeclared++;
202
+ }
203
+
204
+ return buildAxis(axis_id, name, undeclared, touched.size, THRESHOLD_PATH_DRIFT, now, explanation);
205
+ }
206
+
207
+ /**
208
+ * Compute contract_drift.
209
+ *
210
+ * @param {object} item
211
+ * @param {string} projectRoot
212
+ * @param {string} featurePath — absolute path to docs/features/<FC>/
213
+ * @param {string} now
214
+ * @returns {DriftAxis}
215
+ */
216
+ function computeContractDrift(item, projectRoot, featurePath, now) {
217
+ const axis_id = 'contract_drift';
218
+ const name = 'Contract drift';
219
+ const explanation = 'Ratio of JSON schema fields added, removed, or retyped since plan-approval vs. total current fields.';
220
+
221
+ const anchor = findPlanAnchor(item);
222
+ if (!anchor) {
223
+ return buildAxis(axis_id, name, 0, 0, null, now, explanation);
224
+ }
225
+
226
+ const anchorCommit = resolveAnchorCommit(projectRoot, anchor.timestamp);
227
+ if (!anchorCommit) {
228
+ return buildAxis(axis_id, name, 0, 0, null, now, explanation);
229
+ }
230
+
231
+ // Locate JSON schema files in featurePath
232
+ let headPaths = [];
233
+ try {
234
+ headPaths = fs.readdirSync(featurePath)
235
+ .filter(f => f.endsWith('.json'))
236
+ .map(f => path.join(featurePath, f));
237
+ } catch {
238
+ // Feature folder missing — axis disabled
239
+ return buildAxis(axis_id, name, 0, 0, null, now, explanation);
240
+ }
241
+
242
+ if (headPaths.length === 0) {
243
+ return buildAxis(axis_id, name, 0, 0, null, now, explanation);
244
+ }
245
+
246
+ const { added, removed, retyped, total } = diffContracts(anchorCommit, headPaths, projectRoot);
247
+ const numerator = added + removed + retyped;
248
+
249
+ if (total === 0) {
250
+ return buildAxis(axis_id, name, numerator, total, THRESHOLD_CONTRACT_DRIFT, now, explanation);
251
+ }
252
+
253
+ return buildAxis(axis_id, name, numerator, total, THRESHOLD_CONTRACT_DRIFT, now, explanation);
254
+ }
255
+
256
+ /**
257
+ * Compute review_debt_drift.
258
+ *
259
+ * Reads docs/features/<FC>/review*.json (or strat-rev/*.json as fallback).
260
+ * Missing / unparseable files → threshold: null (not ratio: 0 — to avoid
261
+ * marking unreviewed features as "clean").
262
+ *
263
+ * @param {string} featurePath
264
+ * @param {string} now
265
+ * @returns {DriftAxis}
266
+ */
267
+ function computeReviewDebtDrift(featurePath, now) {
268
+ const axis_id = 'review_debt_drift';
269
+ const name = 'Review debt drift';
270
+ const explanation = 'Ratio of unresolved STRAT-REV findings to total findings for this feature.';
271
+
272
+ const RESOLVED_STATUSES = new Set(['resolved', 'closed', 'fixed']);
273
+
274
+ // Scan for review JSON files
275
+ let reviewFiles = [];
276
+ try {
277
+ reviewFiles = fs.readdirSync(featurePath)
278
+ .filter(f => f.startsWith('review') && f.endsWith('.json'))
279
+ .map(f => path.join(featurePath, f));
280
+ } catch {
281
+ return buildAxis(axis_id, name, 0, 0, null, now, explanation);
282
+ }
283
+
284
+ // Fallback: strat-rev/ subfolder
285
+ if (reviewFiles.length === 0) {
286
+ try {
287
+ const stratRevDir = path.join(featurePath, 'strat-rev');
288
+ const subFiles = fs.readdirSync(stratRevDir)
289
+ .filter(f => f.endsWith('.json'))
290
+ .map(f => path.join(stratRevDir, f));
291
+ reviewFiles = subFiles;
292
+ } catch {
293
+ // No strat-rev/ either
294
+ }
295
+ }
296
+
297
+ if (reviewFiles.length === 0) {
298
+ // No review files — axis disabled (not "clean")
299
+ return buildAxis(axis_id, name, 0, 0, null, now, explanation);
300
+ }
301
+
302
+ let totalFindings = 0;
303
+ let unresolvedFindings = 0;
304
+ let parsedAny = false;
305
+
306
+ for (const filePath of reviewFiles) {
307
+ try {
308
+ const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
309
+ if (!content || typeof content !== 'object') continue;
310
+ const findings = Array.isArray(content.findings) ? content.findings : [];
311
+ // Mark as parsed at file level — an empty findings array IS a valid review
312
+ parsedAny = true;
313
+ for (const f of findings) {
314
+ totalFindings++;
315
+ if (!RESOLVED_STATUSES.has(f.status)) {
316
+ unresolvedFindings++;
317
+ }
318
+ }
319
+ } catch {
320
+ // Skip unparseable files
321
+ }
322
+ }
323
+
324
+ if (!parsedAny) {
325
+ // All files were unparseable — axis disabled
326
+ return buildAxis(axis_id, name, 0, 0, null, now, explanation);
327
+ }
328
+
329
+ return buildAxis(axis_id, name, unresolvedFindings, totalFindings, THRESHOLD_REVIEW_DEBT_DRIFT, now, explanation);
330
+ }
331
+
332
+ // ── Public API ────────────────────────────────────────────────────────────────
333
+
334
+ /**
335
+ * Compute all three drift axes for the given item.
336
+ *
337
+ * @param {object} item — vision item with item.lifecycle.featureCode and phaseHistory
338
+ * @param {string} projectRoot — absolute path to the project root (used for git)
339
+ * @param {string} now — ISO timestamp for computed_at
340
+ * @returns {DriftAxis[3]} always returns exactly 3 axes (path / contract / review_debt)
341
+ */
342
+ export function computeDriftAxes(item, projectRoot, now) {
343
+ const featureCode = item?.lifecycle?.featureCode;
344
+ if (!featureCode) {
345
+ // No feature code — all axes disabled
346
+ const ts = now || new Date().toISOString();
347
+ return [
348
+ buildAxis('path_drift', 'Path drift', 0, 0, null, ts, 'No feature code on item.'),
349
+ buildAxis('contract_drift', 'Contract drift', 0, 0, null, ts, 'No feature code on item.'),
350
+ buildAxis('review_debt_drift', 'Review debt drift', 0, 0, null, ts, 'No feature code on item.'),
351
+ ];
352
+ }
353
+
354
+ const ts = now || new Date().toISOString();
355
+
356
+ // Resolve the docs/features/<FC> directory
357
+ // projectRoot/docs/features/<FC>
358
+ const featurePath = path.join(projectRoot, 'docs', 'features', featureCode);
359
+
360
+ const pathAxis = computePathDrift(item, projectRoot, featurePath, ts);
361
+ const contractAxis = computeContractDrift(item, projectRoot, featurePath, ts);
362
+ const reviewAxis = computeReviewDebtDrift(featurePath, ts);
363
+
364
+ return [pathAxis, contractAxis, reviewAxis];
365
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * drift-emit.js — COMP-OBS-DRIFT broadcast dispatcher.
3
+ *
4
+ * Single choke point: emitDriftAxes(broadcastMessage, store, item, projectRoot, now)
5
+ *
6
+ * 1. Compute new axes via computeDriftAxes.
7
+ * 2. Read prior persisted axes from item.lifecycle.lifecycle_ext.drift_axes[].
8
+ * 3. For axes that were ALREADY breached: copy breach_started_at + breach_event_id forward.
9
+ * 4. For axes that are NEWLY breached: assign fresh breach_started_at + breach_event_id.
10
+ * 5. For axes that are NEWLY UNBREACHED: clear both fields to null.
11
+ * 6. Persist merged axes via store.updateLifecycleExt.
12
+ * 7. Broadcast { type: 'driftAxesUpdate', itemId, drift_axes }.
13
+ * 8. Emit DecisionEvent[kind=drift_threshold] for each newly-breached axis (rising edge only).
14
+ *
15
+ * v1 always populates breach_started_at and breach_event_id (even when null) on
16
+ * every DriftAxis so consumers have a consistent shape.
17
+ *
18
+ * Falling-edge → no DecisionEvent. Steady-state breached → no new DecisionEvent.
19
+ */
20
+
21
+ import { computeDriftAxes } from './drift-axes.js';
22
+ import { driftThresholdDecisionEventId } from './decision-event-id.js';
23
+ import { emitDecisionEvent, buildDriftThresholdEvent } from './decision-event-emit.js';
24
+
25
+ /**
26
+ * Compute, persist, and broadcast drift axes for a single item.
27
+ * Emits rising-edge DecisionEvents when an axis newly breaches its threshold.
28
+ *
29
+ * @param {function} broadcastMessage — fn(msg) WS broadcast
30
+ * @param {object} store — VisionStore (must expose updateLifecycleExt)
31
+ * @param {object} item — vision item (pre-loaded; must have lifecycle.featureCode)
32
+ * @param {string} projectRoot — absolute path of the project root (for git)
33
+ * @param {string} [now] — ISO timestamp (defaults to Date.now())
34
+ * @returns {DriftAxis[]} the merged axes that were persisted
35
+ */
36
+ export function emitDriftAxes(broadcastMessage, store, item, projectRoot, now) {
37
+ if (!item?.lifecycle?.featureCode) return [];
38
+
39
+ const ts = now || new Date().toISOString();
40
+ const featureCode = item.lifecycle.featureCode;
41
+
42
+ // 1. Compute fresh axes
43
+ let newAxes;
44
+ try {
45
+ newAxes = computeDriftAxes(item, projectRoot, ts);
46
+ } catch (err) {
47
+ // Computation failure must not crash the event loop — log and skip
48
+ console.warn(`[drift-emit] computeDriftAxes failed for ${featureCode}: ${err.message}`);
49
+ return [];
50
+ }
51
+
52
+ // 2. Read prior persisted axes (indexed by axis_id for O(1) lookup)
53
+ const prior = item.lifecycle.lifecycle_ext?.drift_axes ?? [];
54
+ const priorByAxisId = new Map(prior.map(a => [a.axis_id, a]));
55
+
56
+ // 3–5. Merge breach-edge metadata; collect newly-breached axes for event emission
57
+ const newlyBreached = [];
58
+
59
+ const mergedAxes = newAxes.map(axis => {
60
+ const priorAxis = priorByAxisId.get(axis.axis_id);
61
+ const wasBreached = priorAxis?.breached === true;
62
+ const isBreached = axis.breached === true;
63
+
64
+ let breach_started_at = null;
65
+ let breach_event_id = null;
66
+
67
+ if (isBreached) {
68
+ if (wasBreached && priorAxis.breach_started_at && priorAxis.breach_event_id) {
69
+ // Steady breach — preserve the original breach-edge metadata
70
+ breach_started_at = priorAxis.breach_started_at;
71
+ breach_event_id = priorAxis.breach_event_id;
72
+ } else if (!wasBreached) {
73
+ // Rising edge — assign fresh breach-edge metadata
74
+ breach_started_at = ts;
75
+ breach_event_id = driftThresholdDecisionEventId(featureCode, axis.axis_id, ts);
76
+ newlyBreached.push({ ...axis, breach_started_at, breach_event_id });
77
+ } else {
78
+ // wasBreached but prior metadata is missing (edge case: upgraded from old schema)
79
+ breach_started_at = priorAxis.breach_started_at || ts;
80
+ breach_event_id = priorAxis.breach_event_id || driftThresholdDecisionEventId(featureCode, axis.axis_id, breach_started_at);
81
+ }
82
+ }
83
+ // Falling edge or never breached: both stay null (already null from buildAxis)
84
+
85
+ return { ...axis, breach_started_at, breach_event_id };
86
+ });
87
+
88
+ // 6. Persist merged axes
89
+ try {
90
+ store.updateLifecycleExt(item.id, 'drift_axes', mergedAxes);
91
+ } catch (err) {
92
+ console.warn(`[drift-emit] updateLifecycleExt failed for ${featureCode}: ${err.message}`);
93
+ return mergedAxes;
94
+ }
95
+
96
+ // 7. Broadcast driftAxesUpdate
97
+ try {
98
+ broadcastMessage({ type: 'driftAxesUpdate', itemId: item.id, drift_axes: mergedAxes });
99
+ } catch (err) {
100
+ console.warn(`[drift-emit] broadcast failed for ${featureCode}: ${err.message}`);
101
+ }
102
+
103
+ // 8. Emit DecisionEvent for each newly-breached axis
104
+ for (const axis of newlyBreached) {
105
+ try {
106
+ const event = buildDriftThresholdEvent({
107
+ featureCode,
108
+ axisId: axis.axis_id,
109
+ ratio: axis.ratio,
110
+ threshold: axis.threshold,
111
+ breachStartedAt: axis.breach_started_at,
112
+ breachEventId: axis.breach_event_id,
113
+ });
114
+ emitDecisionEvent(broadcastMessage, event);
115
+ } catch (err) {
116
+ console.warn(`[drift-emit] DecisionEvent emit failed for ${featureCode}/${axis.axis_id}: ${err.message}`);
117
+ }
118
+ }
119
+
120
+ return mergedAxes;
121
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * gate-log-store.js — COMP-OBS-GATELOG persistence layer.
3
+ *
4
+ * Persists GateLogEntry records to <projectDataDir>/gate-log.jsonl (append-only).
5
+ * Project-scoped, NOT COMPOSE_HOME-scoped — gate decisions belong to the
6
+ * project they were made in, otherwise gate_load_24h and `compose gates report`
7
+ * would bleed across repos.
8
+ * Never rewrites entries in place; idempotent on duplicate id.
9
+ *
10
+ * Storage: one JSON object per line (JSONL). Tolerates malformed lines (skips + warns).
11
+ *
12
+ * Decision 1a outcome map: route outcome → schema enum
13
+ * approve → approve
14
+ * revise → interrupt
15
+ * kill → deny
16
+ *
17
+ * Decision 1b featureless gates: callers are responsible for skipping when
18
+ * gate.itemId is null or item lacks lifecycle.featureCode; this module does
19
+ * not enforce that — it trusts the caller (vision-routes.js).
20
+ */
21
+
22
+ import { appendFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { getDataDir } from './project-root.js';
25
+
26
+ // Gate log is project-scoped (mirrors sessions.json, active-build.json etc).
27
+ // COMPOSE_GATE_LOG env var overrides the path — read dynamically so tests can inject it.
28
+ function getGateLogPath() {
29
+ return process.env.COMPOSE_GATE_LOG || join(getDataDir(), 'gate-log.jsonl');
30
+ }
31
+
32
+ /** Translate route outcome vocabulary → schema GateLogEntry.decision enum. */
33
+ export function mapResolveOutcomeToSchema(outcome) {
34
+ if (outcome === 'approve') return 'approve';
35
+ if (outcome === 'revise') return 'interrupt';
36
+ if (outcome === 'kill') return 'deny';
37
+ // Passthrough for already-normalized values (shouldn't happen in practice)
38
+ return outcome;
39
+ }
40
+
41
+ /**
42
+ * Append one GateLogEntry to disk.
43
+ * Idempotent: if an entry with the same `id` already exists, the write is skipped.
44
+ * @param {object} entry — a GateLogEntry object (must have .id)
45
+ */
46
+ export function appendGateLogEntry(entry) {
47
+ const filePath = getGateLogPath();
48
+ const dataDir = join(filePath, '..');
49
+ mkdirSync(dataDir, { recursive: true });
50
+
51
+ // Idempotency check: scan existing entries for this id.
52
+ // Volume is bounded (gate resolution is rare) so a linear scan is fine.
53
+ if (existsSync(filePath)) {
54
+ const raw = readFileSync(filePath, 'utf8');
55
+ for (const line of raw.split('\n')) {
56
+ const trimmed = line.trim();
57
+ if (!trimmed) continue;
58
+ try {
59
+ const obj = JSON.parse(trimmed);
60
+ if (obj.id === entry.id) return; // already written
61
+ } catch {
62
+ // malformed line — skip
63
+ }
64
+ }
65
+ }
66
+
67
+ appendFileSync(filePath, JSON.stringify(entry) + '\n', 'utf8');
68
+ }
69
+
70
+ /**
71
+ * Read GateLogEntry records from disk with optional filters.
72
+ *
73
+ * @param {{ since?: number, featureCode?: string, logPath?: string }} opts
74
+ * - since: optional epoch ms — only entries with timestamp >= since are returned
75
+ * - featureCode: optional string — filter to a specific feature
76
+ * - logPath: optional path override for tests
77
+ * @returns {GateLogEntry[]}
78
+ */
79
+ export function readGateLog({ since, featureCode, logPath } = {}) {
80
+ const filePath = logPath || getGateLogPath();
81
+ if (!existsSync(filePath)) return [];
82
+
83
+ const raw = readFileSync(filePath, 'utf8');
84
+ const entries = [];
85
+
86
+ for (const line of raw.split('\n')) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed) continue;
89
+ let obj;
90
+ try {
91
+ obj = JSON.parse(trimmed);
92
+ } catch {
93
+ console.warn('[gate-log-store] malformed line skipped:', trimmed.slice(0, 80));
94
+ continue;
95
+ }
96
+ if (since !== undefined && Date.parse(obj.timestamp) < since) continue;
97
+ if (featureCode !== undefined && obj.feature_code !== featureCode) continue;
98
+ entries.push(obj);
99
+ }
100
+
101
+ return entries;
102
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * lifecycle-phase-history.js — Populate lifecycle.phaseHistory[].
3
+ *
4
+ * COMP-OBS-TIMELINE: plugs project_lifecycle_phasehistory_gap (memory note).
5
+ * This module is the SOLE WRITER for lifecycle.phaseHistory[].
6
+ *
7
+ * Entries carry BOTH the legacy shape (`phase`, `step`, `enteredAt`, `exitedAt`,
8
+ * `outcome`) consumed by `ItemDetailPanel.jsx`, `ContextPipelineDots.jsx`, and
9
+ * `session-routes.js`, AND the new shape (`from`, `to`, `outcome`, `timestamp`)
10
+ * consumed by `decision-events-snapshot.js`. Legacy `enteredAt` is the same
11
+ * instant as the new `timestamp`. The previous entry's `exitedAt` is closed out
12
+ * to the new entry's `enteredAt` (legacy semantic: a phase exits when its
13
+ * successor begins).
14
+ */
15
+
16
+ /**
17
+ * Append one phase transition entry to item.lifecycle.phaseHistory and close
18
+ * out the prior entry's `exitedAt`.
19
+ *
20
+ * @param {object} item — vision store item (mutated in place)
21
+ * @param {{ from: string|null, to: string, outcome: string|null, timestamp: string }} params
22
+ */
23
+ export function appendPhaseHistory(item, { from, to, outcome, timestamp }) {
24
+ if (!Array.isArray(item.lifecycle.phaseHistory)) {
25
+ item.lifecycle.phaseHistory = [];
26
+ }
27
+ const history = item.lifecycle.phaseHistory;
28
+ const prior = history[history.length - 1];
29
+ if (prior && prior.exitedAt == null) {
30
+ prior.exitedAt = timestamp;
31
+ }
32
+ history.push({
33
+ // Legacy shape (preserves existing readers in ItemDetailPanel, ContextPipelineDots, session-routes)
34
+ phase: to,
35
+ step: to,
36
+ enteredAt: timestamp,
37
+ exitedAt: null,
38
+ // New shape (consumed by decision-events-snapshot.js)
39
+ from: from ?? null,
40
+ to,
41
+ outcome: outcome ?? null,
42
+ timestamp,
43
+ });
44
+ }