@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.
- package/.claude/skills/bug-fix/SKILL.md +143 -0
- package/.claude/skills/compose/SKILL.md +604 -0
- package/.compose-deps.json +89 -0
- package/README.md +14 -3
- package/bin/compose.js +473 -0
- package/contracts/comp-obs-contract.schema.json +362 -0
- package/contracts/cross-model-review-result.json +78 -0
- package/contracts/review-result.json +126 -0
- package/dist/assets/{_baseUniq-CQwX6VLz.js → _baseUniq-D-avYfn5.js} +1 -1
- package/dist/assets/{arc-SxJ2J1sh.js → arc-BC4dfQ-X.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BykunY1F.js → architectureDiagram-Q4EWVU46-BZmFXnGI.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-ohAKBOUw.js → blockDiagram-DXYQGD6D-DlfWSuux.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DBDC3ENB.js → c4Diagram-AHTNJAMY-Y__uJrRx.js} +1 -1
- package/dist/assets/channel-LRG9kHqJ.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-Cv93Z7uM.js → chunk-4BX2VUAB-BfMePfTp.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-DE0WBDkj.js → chunk-4TB4RGXK-BdlMSdEA.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-CE1EXenG.js → chunk-55IACEB6-vrQHZTdv.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DA7Ana6H.js → chunk-EDXVE4YY-B8wioVlW.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-CTDIPA3p.js → chunk-FMBD7UC4-Cd6Hrux2.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-uGBaPaTX.js → chunk-OYMX7WX6-CfrhdQXY.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CYlnXuUO.js → chunk-QZHKN3VN-B9JQerOU.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-ojGkzcZK.js → chunk-YZCP3GAM-DFN9X99H.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +1 -0
- package/dist/assets/clone-dRxgFrBv.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-Bktn9hL-.js → cose-bilkent-S5V4N54A-BAn0ap_E.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DFaSzuRF.js → dagre-KV5264BT-DyxnVq1g.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DnfmDzEm.js → diagram-5BDNPKRD-XCrzqski.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-Bm8W9YnG.js → diagram-G4DWMVQ6-MBCAXft_.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-B5-TSKvp.js → diagram-MMDJMWI5-DbtB2yS6.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-ls4rqlky.js → diagram-TYMM5635-Bb5NzX61.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-giG6WO-r.js → erDiagram-SMLLAGMA-CpIeCOh2.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-XvlUuz-7.js → flowDiagram-DWJPFMVM-CHyoKnhW.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-hLBV57oV.js → ganttDiagram-T4ZO3ILL-DErKteO_.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js → gitGraphDiagram-UUTBAWPF-KFVAtj2F.js} +1 -1
- package/dist/assets/{graph-D0Cfv00Y.js → graph-CRnO_ifT.js} +1 -1
- package/dist/assets/index-DKBsEUJ-.css +1 -0
- package/dist/assets/index-DkRKLuNr.js +1144 -0
- package/dist/assets/{infoDiagram-42DDH7IO-DbqRsOo3.js → infoDiagram-42DDH7IO-BZFnuSp5.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-DnCdx7zb.js → ishikawaDiagram-UXIWVN3A-4Xe2Szde.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CfD7eNcP.js → journeyDiagram-VCZTEJTY-CZRByfS-.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-BYaO9-mK.js → kanban-definition-6JOO6SKY-B95sk6Fk.js} +1 -1
- package/dist/assets/{layout-Bj72wOEB.js → layout-BqNQzxWT.js} +1 -1
- package/dist/assets/{linear-BRFo114D.js → linear-CUh7qb64.js} +1 -1
- package/dist/assets/{min-GCHnKlJS.js → min-wXgOS3ig.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-n0PMebY4.js → mindmap-definition-QFDTVHPH-DB6iaAbO.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-pN4CljHF.js → pieDiagram-DEJITSTG-CHkZHrTW.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DNoAy8-D.js → quadrantDiagram-34T5L4WZ-DoTEO8e3.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-BhtY05PT.js → requirementDiagram-MS252O5E-Dn8peXYp.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-B6AD-16A.js → sankeyDiagram-XADWPNL6-DRXs6Ipb.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-DShHM-uk.js → sequenceDiagram-FGHM5R23-wBBYZ0aq.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DMxn7HTo.js → stateDiagram-FHFEXIEX-DPlBNGmf.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cdu6uq52.js → timeline-definition-GMOUNBTQ-CbbyTlHk.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-CpK29iRe.js → vennDiagram-DHZGUBPP-Bj4GaFfj.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BQgSkdcO.js → wardley-RL74JXVD-RtNzq8KU.js} +55 -55
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DJHYev6O.js → wardleyDiagram-NUSXRM2D-CDfE3zSj.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-1d75pbaO.js → xychartDiagram-5P7HB3ND-CZXHHYD5.js} +1 -1
- package/dist/index.html +2 -2
- package/lib/budget-ledger.js +45 -0
- package/lib/bug-bisect.js +292 -0
- package/lib/bug-checkpoint.js +191 -0
- package/lib/bug-escalation.js +306 -0
- package/lib/bug-index-gen.js +136 -0
- package/lib/bug-ledger.js +126 -0
- package/lib/build-stream-schema.js +176 -0
- package/lib/build-stream-writer.js +3 -1
- package/lib/build.js +854 -284
- package/lib/connector-factory-shim.js +167 -0
- package/lib/constants.js +18 -0
- package/lib/debug-discipline.js +176 -27
- package/lib/deps.js +205 -0
- package/lib/health-score.js +4 -4
- package/lib/import.js +26 -13
- package/lib/inject-schema.js +21 -0
- package/lib/new.js +27 -53
- package/lib/result-normalizer.js +160 -144
- package/lib/review-lenses.js +5 -5
- package/lib/review-normalize.js +413 -0
- package/lib/review-prompt.js +163 -0
- package/lib/sections.js +325 -0
- package/lib/step-prompt.js +21 -1
- package/lib/step-validator.js +5 -3
- package/lib/stratum-mcp-client.js +172 -7
- package/package.json +14 -3
- package/pipelines/bug-fix.stratum.yaml +39 -1
- package/pipelines/build.stratum.yaml +28 -45
- package/pipelines/review-fix.stratum.yaml +1 -1
- package/presets/team-review.stratum.yaml +21 -14
- package/server/build-stream-bridge.js +28 -0
- package/server/cc-session-feature-resolver.js +111 -0
- package/server/cc-session-reader.js +327 -0
- package/server/cc-session-watcher.js +318 -0
- package/server/compose-mcp-tools.js +0 -125
- package/server/compose-mcp.js +2 -4
- package/server/contract-diff.js +192 -0
- package/server/decision-event-emit.js +175 -0
- package/server/decision-event-id.js +64 -0
- package/server/decision-events-snapshot.js +166 -0
- package/server/design-routes.js +92 -49
- package/server/drift-axes.js +365 -0
- package/server/drift-emit.js +121 -0
- package/server/gate-log-store.js +102 -0
- package/server/lifecycle-phase-history.js +44 -0
- package/server/open-loops-store.js +102 -0
- package/server/schema-validator.js +49 -0
- package/server/status-emit.js +27 -0
- package/server/status-snapshot.js +218 -0
- package/server/vision-routes.js +332 -4
- package/server/vision-server.js +104 -12
- package/server/vision-store.js +21 -0
- package/dist/assets/channel-DGElom1e.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +0 -1
- package/dist/assets/clone-DUJKJXd7.js +0 -1
- package/dist/assets/index-CUd6pFGF.css +0 -1
- package/dist/assets/index-DReRlzZI.js +0 -1144
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +0 -1
- package/server/connectors/agent-connector.js +0 -78
- package/server/connectors/claude-sdk-connector.js +0 -198
- package/server/connectors/codex-connector.js +0 -240
- package/server/connectors/connector-discovery.js +0 -18
- package/server/connectors/connector-runtime.js +0 -13
- 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
|
+
}
|