@smartmemory/compose 0.1.0
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/LICENSE +21 -0
- package/README.md +1014 -0
- package/bin/compose.js +1515 -0
- package/dist/assets/_baseUniq-CQwX6VLz.js +1 -0
- package/dist/assets/arc-SxJ2J1sh.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-BykunY1F.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-ohAKBOUw.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-DBDC3ENB.js +10 -0
- package/dist/assets/channel-DGElom1e.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Cv93Z7uM.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-DE0WBDkj.js +206 -0
- package/dist/assets/chunk-55IACEB6-CE1EXenG.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-DA7Ana6H.js +1 -0
- package/dist/assets/chunk-FMBD7UC4-CTDIPA3p.js +15 -0
- package/dist/assets/chunk-OYMX7WX6-uGBaPaTX.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-CYlnXuUO.js +1 -0
- package/dist/assets/chunk-YZCP3GAM-ojGkzcZK.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-KqWP9wWZ.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-KqWP9wWZ.js +1 -0
- package/dist/assets/clone-DUJKJXd7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-Bktn9hL-.js +1 -0
- package/dist/assets/dagre-KV5264BT-DFaSzuRF.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-DnfmDzEm.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-Bm8W9YnG.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-B5-TSKvp.js +43 -0
- package/dist/assets/diagram-TYMM5635-ls4rqlky.js +24 -0
- package/dist/assets/erDiagram-SMLLAGMA-giG6WO-r.js +85 -0
- package/dist/assets/flowDiagram-DWJPFMVM-XvlUuz-7.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-hLBV57oV.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-BHu3s_Gn.js +106 -0
- package/dist/assets/graph-D0Cfv00Y.js +1 -0
- package/dist/assets/index-CUd6pFGF.css +1 -0
- package/dist/assets/index-DReRlzZI.js +1144 -0
- package/dist/assets/infoDiagram-42DDH7IO-DbqRsOo3.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-DnCdx7zb.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-CfD7eNcP.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-BYaO9-mK.js +89 -0
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-Bj72wOEB.js +1 -0
- package/dist/assets/linear-BRFo114D.js +1 -0
- package/dist/assets/min-GCHnKlJS.js +1 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-n0PMebY4.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-pN4CljHF.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-DNoAy8-D.js +7 -0
- package/dist/assets/requirementDiagram-MS252O5E-BhtY05PT.js +84 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-B6AD-16A.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DShHM-uk.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-DMxn7HTo.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-o6PnCs4e.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-Cdu6uq52.js +120 -0
- package/dist/assets/vennDiagram-DHZGUBPP-CpK29iRe.js +34 -0
- package/dist/assets/wardley-RL74JXVD-BQgSkdcO.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-DJHYev6O.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-1d75pbaO.js +7 -0
- package/dist/index.html +30 -0
- package/lib/agent-chains.js +65 -0
- package/lib/agent-string.js +86 -0
- package/lib/budget-ledger.js +86 -0
- package/lib/build-all.js +162 -0
- package/lib/build-dag.js +120 -0
- package/lib/build-stream-writer.js +190 -0
- package/lib/build.js +2997 -0
- package/lib/capability-checker.js +53 -0
- package/lib/cert-inject.js +38 -0
- package/lib/cli-progress.js +483 -0
- package/lib/constants.js +69 -0
- package/lib/cross-layer-audit.js +84 -0
- package/lib/debug-discipline.js +173 -0
- package/lib/feature-json.js +106 -0
- package/lib/gate-prompt.js +291 -0
- package/lib/gate-tiers.js +194 -0
- package/lib/health-history.js +119 -0
- package/lib/health-score.js +227 -0
- package/lib/ideabox.js +570 -0
- package/lib/import.js +244 -0
- package/lib/migrate-roadmap.js +94 -0
- package/lib/model-pricing.js +67 -0
- package/lib/new.js +413 -0
- package/lib/pipeline-cli.js +489 -0
- package/lib/plan-parser.js +103 -0
- package/lib/qa-scoping.js +474 -0
- package/lib/questionnaire.js +200 -0
- package/lib/resolve-port.js +7 -0
- package/lib/result-normalizer.js +349 -0
- package/lib/review-lenses.js +166 -0
- package/lib/roadmap-gen.js +210 -0
- package/lib/roadmap-parser.js +176 -0
- package/lib/server-probe.js +23 -0
- package/lib/staleness.js +87 -0
- package/lib/step-prompt.js +260 -0
- package/lib/step-validator.js +49 -0
- package/lib/stratum-mcp-client.js +365 -0
- package/lib/team-flag.js +46 -0
- package/lib/test-bootstrap.js +401 -0
- package/lib/triage.js +274 -0
- package/lib/vision-writer.js +391 -0
- package/package.json +111 -0
- package/pipelines/bug-fix.stratum.yaml +230 -0
- package/pipelines/build.stratum.yaml +498 -0
- package/pipelines/content.stratum.yaml +112 -0
- package/pipelines/coverage-sweep.stratum.yaml +52 -0
- package/pipelines/refactor.stratum.yaml +169 -0
- package/pipelines/research.stratum.yaml +88 -0
- package/pipelines/review-fix.stratum.yaml +109 -0
- package/presets/team-feature.stratum.yaml +105 -0
- package/presets/team-research.stratum.yaml +108 -0
- package/presets/team-review.stratum.yaml +106 -0
- package/scripts/agent-activity-hook.sh +31 -0
- package/scripts/agent-error-hook.sh +28 -0
- package/scripts/analyze-orphans.mjs +50 -0
- package/scripts/find-orphans.mjs +26 -0
- package/scripts/fix-phases.mjs +49 -0
- package/scripts/generate-stratum-spec.mjs +137 -0
- package/scripts/import-roadmap.mjs +116 -0
- package/scripts/phase-audit.mjs +33 -0
- package/scripts/run-pipeline.mjs +314 -0
- package/scripts/session-end-hook.sh +18 -0
- package/scripts/session-start-hook.sh +38 -0
- package/scripts/vision-hook.sh +104 -0
- package/scripts/vision-track.mjs +554 -0
- package/scripts/wire-all-orphans.mjs +108 -0
- package/scripts/wire-orphans.mjs +164 -0
- package/server/activity-routes.js +123 -0
- package/server/agent-health.js +197 -0
- package/server/agent-hooks.js +102 -0
- package/server/agent-mcp.js +10 -0
- package/server/agent-registry.js +95 -0
- package/server/agent-server.js +290 -0
- package/server/agent-spawn.js +251 -0
- package/server/agent-templates.js +77 -0
- package/server/artifact-manager.js +247 -0
- package/server/artifact-templates/architecture.md +28 -0
- package/server/artifact-templates/blueprint.md +21 -0
- package/server/artifact-templates/design.md +36 -0
- package/server/artifact-templates/plan.md +25 -0
- package/server/artifact-templates/prd.md +43 -0
- package/server/artifact-templates/report.md +40 -0
- package/server/block-tracker.js +90 -0
- package/server/build-stream-bridge.js +502 -0
- package/server/coalescing-buffer.js +46 -0
- package/server/compose-mcp-tools.js +479 -0
- package/server/compose-mcp.js +324 -0
- package/server/connectors/agent-connector.js +78 -0
- package/server/connectors/claude-sdk-connector.js +198 -0
- package/server/connectors/codex-connector.js +240 -0
- package/server/connectors/connector-discovery.js +18 -0
- package/server/connectors/connector-runtime.js +13 -0
- package/server/connectors/opencode-connector.js +200 -0
- package/server/design-routes.js +540 -0
- package/server/design-session.js +161 -0
- package/server/feature-scan.js +593 -0
- package/server/file-watcher.js +284 -0
- package/server/find-root.js +29 -0
- package/server/graph-export.js +343 -0
- package/server/ideabox-cache.js +77 -0
- package/server/ideabox-routes.js +294 -0
- package/server/index.js +156 -0
- package/server/model-tiers.js +49 -0
- package/server/pipeline-routes.js +288 -0
- package/server/policy-evaluator.js +36 -0
- package/server/project-root.js +122 -0
- package/server/security.js +23 -0
- package/server/session-manager.js +403 -0
- package/server/session-routes.js +190 -0
- package/server/session-store.js +107 -0
- package/server/settings-routes.js +35 -0
- package/server/settings-store.js +234 -0
- package/server/stratum-api.js +102 -0
- package/server/stratum-client.js +192 -0
- package/server/stratum-sync.js +193 -0
- package/server/summarizer.js +139 -0
- package/server/supervisor.js +196 -0
- package/server/vision-routes.js +668 -0
- package/server/vision-server.js +393 -0
- package/server/vision-store.js +360 -0
- package/server/vision-utils.js +179 -0
- package/server/worktree-gc.js +137 -0
- package/templates/ROADMAP.md +46 -0
package/lib/triage.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* triage.js — Pre-flight feature triage.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes the feature folder contents and assigns a complexity tier.
|
|
5
|
+
* Populates the build profile (needs_prd, needs_architecture, needs_verification,
|
|
6
|
+
* needs_report) in feature.json so subsequent builds can toggle skip_if on
|
|
7
|
+
* pipeline steps without requiring manual intervention.
|
|
8
|
+
*
|
|
9
|
+
* No LLM calls — pure file analysis and heuristics.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Tier definitions
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
//
|
|
19
|
+
// Tier 0: Config-only — dotfiles, package.json tweaks, no design docs
|
|
20
|
+
// → skip prd, architecture, verification, report
|
|
21
|
+
// Tier 1: Single-concern — 1-2 files in plan, no security/core paths
|
|
22
|
+
// → skip prd, architecture, report (keep verification)
|
|
23
|
+
// Tier 2: Standard feature — multiple files, design doc present
|
|
24
|
+
// → skip prd, architecture (default — what most features need)
|
|
25
|
+
// Tier 3: Cross-component / security-sensitive
|
|
26
|
+
// → enable architecture, skip prd
|
|
27
|
+
// Tier 4: Architecture change / shared core code
|
|
28
|
+
// → enable prd and architecture
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const SECURITY_PATTERNS = [
|
|
32
|
+
/\bauth\b/i,
|
|
33
|
+
/\bcrypto\b/i,
|
|
34
|
+
/\bsession\b/i,
|
|
35
|
+
/\bmiddleware\b/i,
|
|
36
|
+
/\btoken\b/i,
|
|
37
|
+
/\bpermission\b/i,
|
|
38
|
+
/\bcredential\b/i,
|
|
39
|
+
/\bjwt\b/i,
|
|
40
|
+
/\boauth\b/i,
|
|
41
|
+
/\bpassword\b/i,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const CORE_PATTERNS = [
|
|
45
|
+
/\blib\//,
|
|
46
|
+
/\bserver\/index\b/,
|
|
47
|
+
/connector.*base/i,
|
|
48
|
+
/\bbase.*connector/i,
|
|
49
|
+
/\bcore\//,
|
|
50
|
+
/\bshared\//,
|
|
51
|
+
/stratum-mcp/i,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Extract file paths mentioned in a markdown string.
|
|
56
|
+
* Matches backtick-quoted paths that look like file paths (contain a dot or slash).
|
|
57
|
+
*
|
|
58
|
+
* @param {string} content
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
function extractFilePaths(content) {
|
|
62
|
+
const matches = [];
|
|
63
|
+
// Match backtick-quoted strings that look like paths
|
|
64
|
+
const backtickRe = /`([^`]+)`/g;
|
|
65
|
+
let m;
|
|
66
|
+
while ((m = backtickRe.exec(content)) !== null) {
|
|
67
|
+
const val = m[1];
|
|
68
|
+
if (val.includes('/') || (val.includes('.') && !val.includes(' '))) {
|
|
69
|
+
matches.push(val);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return matches;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Count markdown checkbox items in content.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} content
|
|
79
|
+
* @returns {number}
|
|
80
|
+
*/
|
|
81
|
+
function countTasks(content) {
|
|
82
|
+
const re = /^\s*-\s*\[[ xX]\]/gm;
|
|
83
|
+
return (content.match(re) ?? []).length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check whether any path in a list matches the given patterns.
|
|
88
|
+
*
|
|
89
|
+
* @param {string[]} paths
|
|
90
|
+
* @param {RegExp[]} patterns
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
function anyMatch(paths, patterns) {
|
|
94
|
+
return paths.some(p => patterns.some(re => re.test(p)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Derive tier and profile from signal values.
|
|
99
|
+
*
|
|
100
|
+
* @param {{ fileCount: number, securityPaths: boolean, corePaths: boolean, taskCount: number, hasDesignDoc: boolean }} signals
|
|
101
|
+
* @returns {{ tier: number, profile: object, rationale: string }}
|
|
102
|
+
*/
|
|
103
|
+
function deriveProfile(signals) {
|
|
104
|
+
const { fileCount, securityPaths, corePaths, taskCount, hasDesignDoc } = signals;
|
|
105
|
+
|
|
106
|
+
// Tier 4: core/shared code changes → needs full design review
|
|
107
|
+
if (corePaths) {
|
|
108
|
+
return {
|
|
109
|
+
tier: 4,
|
|
110
|
+
profile: {
|
|
111
|
+
needs_prd: true,
|
|
112
|
+
needs_architecture: true,
|
|
113
|
+
needs_verification: true,
|
|
114
|
+
needs_report: true,
|
|
115
|
+
},
|
|
116
|
+
rationale: 'Touches core/shared code — full design review required',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Tier 3: security-sensitive → architecture required
|
|
121
|
+
if (securityPaths) {
|
|
122
|
+
return {
|
|
123
|
+
tier: 3,
|
|
124
|
+
profile: {
|
|
125
|
+
needs_prd: false,
|
|
126
|
+
needs_architecture: true,
|
|
127
|
+
needs_verification: true,
|
|
128
|
+
needs_report: false,
|
|
129
|
+
},
|
|
130
|
+
rationale: 'References security-sensitive paths — architecture review required',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Tier 0: config-only — no design docs, at most 1 file path, very few tasks
|
|
135
|
+
if (!hasDesignDoc && fileCount <= 1 && taskCount <= 5) {
|
|
136
|
+
return {
|
|
137
|
+
tier: 0,
|
|
138
|
+
profile: {
|
|
139
|
+
needs_prd: false,
|
|
140
|
+
needs_architecture: false,
|
|
141
|
+
needs_verification: false,
|
|
142
|
+
needs_report: false,
|
|
143
|
+
},
|
|
144
|
+
rationale: 'Config-only change — minimal scope, no design docs',
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Tier 1: single-concern — few files, no special paths
|
|
149
|
+
if (fileCount <= 2 && taskCount <= 10) {
|
|
150
|
+
return {
|
|
151
|
+
tier: 1,
|
|
152
|
+
profile: {
|
|
153
|
+
needs_prd: false,
|
|
154
|
+
needs_architecture: false,
|
|
155
|
+
needs_verification: true,
|
|
156
|
+
needs_report: false,
|
|
157
|
+
},
|
|
158
|
+
rationale: 'Single-concern change — verification sufficient',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Tier 2: standard feature (default)
|
|
163
|
+
return {
|
|
164
|
+
tier: 2,
|
|
165
|
+
profile: {
|
|
166
|
+
needs_prd: false,
|
|
167
|
+
needs_architecture: false,
|
|
168
|
+
needs_verification: true,
|
|
169
|
+
needs_report: false,
|
|
170
|
+
},
|
|
171
|
+
rationale: 'Standard feature — default build profile',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Public API
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Run triage on a feature folder.
|
|
181
|
+
*
|
|
182
|
+
* @param {string} featureCode - Feature code (e.g. 'FEAT-1')
|
|
183
|
+
* @param {{ cwd: string }} opts
|
|
184
|
+
* @returns {Promise<{ tier: number, profile: object, rationale: string, signals: object }>}
|
|
185
|
+
*/
|
|
186
|
+
export async function runTriage(featureCode, opts = {}) {
|
|
187
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
188
|
+
const featureDir = join(cwd, 'docs', 'features', featureCode);
|
|
189
|
+
|
|
190
|
+
// Collect content from key files
|
|
191
|
+
const candidateFiles = ['plan.md', 'blueprint.md', 'design.md', 'prd.md', 'architecture.md'];
|
|
192
|
+
let combinedContent = '';
|
|
193
|
+
let hasDesignDoc = false;
|
|
194
|
+
|
|
195
|
+
for (const fname of candidateFiles) {
|
|
196
|
+
const fpath = join(featureDir, fname);
|
|
197
|
+
if (existsSync(fpath)) {
|
|
198
|
+
if (['design.md', 'prd.md', 'architecture.md'].includes(fname)) {
|
|
199
|
+
hasDesignDoc = true;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
combinedContent += readFileSync(fpath, 'utf-8') + '\n';
|
|
203
|
+
} catch { /* skip unreadable */ }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const filePaths = extractFilePaths(combinedContent);
|
|
208
|
+
const taskCount = countTasks(combinedContent);
|
|
209
|
+
const securityPaths = anyMatch(filePaths, SECURITY_PATTERNS);
|
|
210
|
+
const corePaths = anyMatch(filePaths, CORE_PATTERNS);
|
|
211
|
+
|
|
212
|
+
// Deduplicate file paths for count
|
|
213
|
+
const uniquePaths = new Set(filePaths);
|
|
214
|
+
const fileCount = uniquePaths.size;
|
|
215
|
+
|
|
216
|
+
const signals = { fileCount, securityPaths, corePaths, taskCount, hasDesignDoc };
|
|
217
|
+
const { tier, profile, rationale } = deriveProfile(signals);
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
tier,
|
|
221
|
+
profile,
|
|
222
|
+
rationale,
|
|
223
|
+
signals: { fileCount, securityPaths, corePaths, taskCount },
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check whether cached triage results are stale.
|
|
229
|
+
*
|
|
230
|
+
* Returns true if:
|
|
231
|
+
* - feature.json has no triageTimestamp
|
|
232
|
+
* - any file in the feature folder has an mtime newer than triageTimestamp
|
|
233
|
+
*
|
|
234
|
+
* @param {string} cwd - Project root
|
|
235
|
+
* @param {string} featureCode - Feature code
|
|
236
|
+
* @param {string} [featuresDir] - Relative path to features dir (default: docs/features)
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
export function isTriageStale(cwd, featureCode, featuresDir = 'docs/features') {
|
|
240
|
+
const featureDir = join(cwd, featuresDir, featureCode);
|
|
241
|
+
const featureJsonPath = join(featureDir, 'feature.json');
|
|
242
|
+
|
|
243
|
+
if (!existsSync(featureJsonPath)) return true;
|
|
244
|
+
|
|
245
|
+
let feature;
|
|
246
|
+
try {
|
|
247
|
+
feature = JSON.parse(readFileSync(featureJsonPath, 'utf-8'));
|
|
248
|
+
} catch {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!feature.triageTimestamp) return true;
|
|
253
|
+
|
|
254
|
+
const triageTime = new Date(feature.triageTimestamp).getTime();
|
|
255
|
+
if (isNaN(triageTime)) return true;
|
|
256
|
+
|
|
257
|
+
// Check all files in the feature folder
|
|
258
|
+
if (!existsSync(featureDir)) return true;
|
|
259
|
+
try {
|
|
260
|
+
const entries = readdirSync(featureDir, { withFileTypes: true });
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
if (!entry.isFile()) continue;
|
|
263
|
+
const filePath = join(featureDir, entry.name);
|
|
264
|
+
try {
|
|
265
|
+
const stat = statSync(filePath);
|
|
266
|
+
if (stat.mtimeMs > triageTime) return true;
|
|
267
|
+
} catch { /* skip */ }
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VisionWriter — Dual-dispatch read-modify-write for vision-state.json
|
|
3
|
+
*
|
|
4
|
+
* Routes mutations through REST when the Compose server is running,
|
|
5
|
+
* falls back to direct atomic file writes when it's down.
|
|
6
|
+
*
|
|
7
|
+
* Provides step-to-item mapping, feature item lookup (using lifecycle.featureCode),
|
|
8
|
+
* gate management with outcome normalization, and migration of legacy formats.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import { resolvePort } from './resolve-port.js';
|
|
15
|
+
import { probeServer } from './server-probe.js';
|
|
16
|
+
|
|
17
|
+
const EMPTY_STATE = () => ({ items: [], connections: [], gates: [] });
|
|
18
|
+
|
|
19
|
+
/** Canonical outcome normalization — maps legacy past-tense to imperative */
|
|
20
|
+
function normalizeOutcome(outcome) {
|
|
21
|
+
const map = { approved: 'approve', killed: 'kill', revised: 'revise' };
|
|
22
|
+
return map[outcome] || outcome;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class ServerUnreachableError extends Error {
|
|
26
|
+
constructor(message = 'Server is unreachable') {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'ServerUnreachableError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class VisionWriter {
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} dataDir Path to the data directory (e.g. `.compose/data/`)
|
|
35
|
+
* @param {object} [opts]
|
|
36
|
+
* @param {number} [opts.port] Server port (default: resolvePort())
|
|
37
|
+
*/
|
|
38
|
+
constructor(dataDir, opts = {}) {
|
|
39
|
+
this.filePath = path.join(dataDir, 'vision-state.json');
|
|
40
|
+
this._dataDir = dataDir;
|
|
41
|
+
this._port = opts.port ?? resolvePort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Internal helpers
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read and parse vision-state.json, applying migration if needed.
|
|
50
|
+
* Returns empty state if the file doesn't exist or contains invalid JSON.
|
|
51
|
+
*/
|
|
52
|
+
_load() {
|
|
53
|
+
try {
|
|
54
|
+
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
parsed.items = parsed.items || [];
|
|
57
|
+
parsed.connections = parsed.connections || [];
|
|
58
|
+
parsed.gates = parsed.gates || [];
|
|
59
|
+
|
|
60
|
+
// Migration: legacy featureCode → lifecycle.featureCode
|
|
61
|
+
let migrated = false;
|
|
62
|
+
for (const item of parsed.items) {
|
|
63
|
+
if (item.featureCode && item.featureCode.startsWith('feature:') && !item.lifecycle?.featureCode) {
|
|
64
|
+
const bare = item.featureCode.replace(/^feature:/, '');
|
|
65
|
+
item.lifecycle = item.lifecycle || {};
|
|
66
|
+
item.lifecycle.featureCode = bare;
|
|
67
|
+
delete item.featureCode;
|
|
68
|
+
migrated = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Dedup gates by ID (keep latest)
|
|
73
|
+
const gateMap = new Map();
|
|
74
|
+
for (const gate of parsed.gates) gateMap.set(gate.id, gate);
|
|
75
|
+
if (gateMap.size < parsed.gates.length) { migrated = true; }
|
|
76
|
+
parsed.gates = Array.from(gateMap.values());
|
|
77
|
+
|
|
78
|
+
// Migration: normalize legacy gate outcomes
|
|
79
|
+
for (const gate of parsed.gates) {
|
|
80
|
+
if (gate.outcome) {
|
|
81
|
+
const normalized = normalizeOutcome(gate.outcome);
|
|
82
|
+
if (normalized !== gate.outcome) {
|
|
83
|
+
gate.outcome = normalized;
|
|
84
|
+
migrated = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (migrated) {
|
|
90
|
+
this._atomicWrite(parsed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return parsed;
|
|
94
|
+
} catch {
|
|
95
|
+
return EMPTY_STATE();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Atomically write state by writing to a temp file then renaming.
|
|
101
|
+
* @param {object} state
|
|
102
|
+
*/
|
|
103
|
+
_atomicWrite(state) {
|
|
104
|
+
const dir = path.dirname(this.filePath);
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
const tmp = path.join(dir, `vision-state.json.tmp.${crypto.randomUUID()}`);
|
|
107
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8');
|
|
108
|
+
fs.renameSync(tmp, this.filePath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Check if the Compose server is reachable */
|
|
112
|
+
async _serverAvailable() {
|
|
113
|
+
return probeServer(this._port);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Base URL for REST calls */
|
|
117
|
+
get _baseUrl() {
|
|
118
|
+
return `http://localhost:${this._port}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fetch helper with timeout */
|
|
122
|
+
async _fetch(urlPath, opts = {}) {
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetch(`${this._baseUrl}${urlPath}`, {
|
|
127
|
+
...opts,
|
|
128
|
+
signal: controller.signal,
|
|
129
|
+
headers: { 'Content-Type': 'application/json', ...opts.headers },
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
const body = await res.text().catch(() => '');
|
|
133
|
+
throw new Error(`REST ${opts.method || 'GET'} ${urlPath} failed: ${res.status} ${body}`);
|
|
134
|
+
}
|
|
135
|
+
return res.json();
|
|
136
|
+
} finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// REST variants
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
async _restFindFeatureItem(featureCode) {
|
|
146
|
+
const state = await this._fetch('/api/vision/items');
|
|
147
|
+
const items = state.items || [];
|
|
148
|
+
return items.find(item => item.lifecycle?.featureCode === featureCode) || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async _restEnsureFeatureItem(featureCode, title) {
|
|
152
|
+
const existing = await this._restFindFeatureItem(featureCode);
|
|
153
|
+
if (existing) {
|
|
154
|
+
// Partial repair: item exists but no lifecycle — start lifecycle
|
|
155
|
+
if (!existing.lifecycle?.featureCode) {
|
|
156
|
+
await this._fetch(`/api/vision/items/${existing.id}/lifecycle/start`, {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
body: JSON.stringify({ featureCode }),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return existing.id;
|
|
162
|
+
}
|
|
163
|
+
const item = await this._fetch('/api/vision/items', {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
type: 'feature',
|
|
167
|
+
title: title || featureCode,
|
|
168
|
+
description: '',
|
|
169
|
+
status: 'planned',
|
|
170
|
+
phase: 'planning',
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
await this._fetch(`/api/vision/items/${item.id}/lifecycle/start`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
body: JSON.stringify({ featureCode }),
|
|
176
|
+
});
|
|
177
|
+
return item.id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async _restUpdateItemStatus(itemId, status) {
|
|
181
|
+
await this._fetch(`/api/vision/items/${itemId}`, {
|
|
182
|
+
method: 'PATCH',
|
|
183
|
+
body: JSON.stringify({ status }),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async _restUpdateItemPhase(itemId, stepId) {
|
|
188
|
+
await this._fetch(`/api/vision/items/${itemId}/lifecycle/advance`, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
body: JSON.stringify({ targetPhase: stepId }),
|
|
191
|
+
}).catch(() => {
|
|
192
|
+
// Lifecycle advance may reject for invalid transitions — fall through
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async _restCreateGate(flowId, stepId, itemId, opts = {}) {
|
|
197
|
+
const round = opts.round ?? 1;
|
|
198
|
+
const gate = await this._fetch('/api/vision/gates', {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
flowId, stepId, round, itemId,
|
|
202
|
+
fromPhase: opts.fromPhase || null,
|
|
203
|
+
toPhase: opts.toPhase || null,
|
|
204
|
+
artifact: opts.artifact || null,
|
|
205
|
+
options: opts.options || null,
|
|
206
|
+
summary: opts.summary || null,
|
|
207
|
+
comment: opts.comment || null,
|
|
208
|
+
policyMode: opts.policyMode || undefined,
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
|
+
return gate.id;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async _restGetGate(gateId) {
|
|
215
|
+
try {
|
|
216
|
+
return await this._fetch(`/api/vision/gates/${encodeURIComponent(gateId)}`);
|
|
217
|
+
} catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async _restResolveGate(gateId, outcome, comment, resolvedBy) {
|
|
223
|
+
await this._fetch(`/api/vision/gates/${encodeURIComponent(gateId)}/resolve`, {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
body: JSON.stringify({ outcome: normalizeOutcome(outcome), comment: comment || null, resolvedBy: resolvedBy || undefined }),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Direct (file-based) variants
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
_directFindFeatureItem(featureCode) {
|
|
234
|
+
const state = this._load();
|
|
235
|
+
return state.items.find(item => item.lifecycle?.featureCode === featureCode) || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
_directEnsureFeatureItem(featureCode, title) {
|
|
239
|
+
const existing = this._directFindFeatureItem(featureCode);
|
|
240
|
+
if (existing) return existing.id;
|
|
241
|
+
|
|
242
|
+
const state = this._load();
|
|
243
|
+
// Derive group from featureCode (same logic as vision-store.js deriveGroup)
|
|
244
|
+
const groupMatch = (title || featureCode).match(/^([A-Z]+-[A-Z]+|[A-Z]+)(?=-\d)/);
|
|
245
|
+
const group = groupMatch ? groupMatch[1] : featureCode;
|
|
246
|
+
const item = {
|
|
247
|
+
id: crypto.randomUUID(),
|
|
248
|
+
type: 'feature',
|
|
249
|
+
title: title || featureCode,
|
|
250
|
+
description: '',
|
|
251
|
+
status: 'planned',
|
|
252
|
+
phase: 'planning',
|
|
253
|
+
group,
|
|
254
|
+
lifecycle: { featureCode, currentPhase: 'explore_design' },
|
|
255
|
+
slug: featureCode.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
|
256
|
+
confidence: null,
|
|
257
|
+
createdAt: new Date().toISOString(),
|
|
258
|
+
};
|
|
259
|
+
state.items.push(item);
|
|
260
|
+
this._atomicWrite(state);
|
|
261
|
+
return item.id;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_directUpdateItemStatus(itemId, status) {
|
|
265
|
+
const state = this._load();
|
|
266
|
+
const item = state.items.find(i => i.id === itemId);
|
|
267
|
+
if (item) {
|
|
268
|
+
item.status = status;
|
|
269
|
+
this._atomicWrite(state);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
_directUpdateItemPhase(itemId, stepId) {
|
|
274
|
+
const state = this._load();
|
|
275
|
+
const item = state.items.find(i => i.id === itemId);
|
|
276
|
+
if (item) {
|
|
277
|
+
item.lifecycle = item.lifecycle || {};
|
|
278
|
+
item.lifecycle.currentPhase = stepId;
|
|
279
|
+
this._atomicWrite(state);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_directCreateGate(flowId, stepId, itemId, opts = {}) {
|
|
284
|
+
const state = this._load();
|
|
285
|
+
state.gates = state.gates || [];
|
|
286
|
+
const round = opts.round ?? 1;
|
|
287
|
+
const gate = {
|
|
288
|
+
id: `${flowId}:${stepId}:${round}`,
|
|
289
|
+
flowId,
|
|
290
|
+
stepId,
|
|
291
|
+
round,
|
|
292
|
+
itemId,
|
|
293
|
+
fromPhase: opts.fromPhase || null,
|
|
294
|
+
toPhase: opts.toPhase || null,
|
|
295
|
+
artifact: opts.artifact || null,
|
|
296
|
+
options: opts.options || null,
|
|
297
|
+
summary: opts.summary || null,
|
|
298
|
+
comment: opts.comment || null,
|
|
299
|
+
policyMode: opts.policyMode ?? 'gate',
|
|
300
|
+
status: 'pending',
|
|
301
|
+
createdAt: new Date().toISOString(),
|
|
302
|
+
};
|
|
303
|
+
// Dedupe by full gate ID or by pending itemId+stepId to prevent duplicates
|
|
304
|
+
const existingIdx = state.gates.findIndex(g =>
|
|
305
|
+
g.id === gate.id ||
|
|
306
|
+
(g.itemId === gate.itemId && g.stepId === gate.stepId && g.status === 'pending')
|
|
307
|
+
);
|
|
308
|
+
if (existingIdx !== -1) {
|
|
309
|
+
return state.gates[existingIdx].id;
|
|
310
|
+
}
|
|
311
|
+
state.gates.push(gate);
|
|
312
|
+
this._atomicWrite(state);
|
|
313
|
+
return gate.id;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
_directGetGate(gateId) {
|
|
317
|
+
const state = this._load();
|
|
318
|
+
return (state.gates || []).find(g => g.id === gateId) || null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_directResolveGate(gateId, outcome, comment, resolvedBy) {
|
|
322
|
+
const state = this._load();
|
|
323
|
+
const gate = (state.gates || []).find(g => g.id === gateId);
|
|
324
|
+
if (gate) {
|
|
325
|
+
gate.status = 'resolved';
|
|
326
|
+
gate.outcome = normalizeOutcome(outcome);
|
|
327
|
+
gate.comment = comment || null;
|
|
328
|
+
gate.resolvedBy = resolvedBy ?? 'human';
|
|
329
|
+
gate.resolvedAt = new Date().toISOString();
|
|
330
|
+
this._atomicWrite(state);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Public async methods — dual dispatch
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
async findFeatureItem(featureCode) {
|
|
339
|
+
if (await this._serverAvailable()) {
|
|
340
|
+
return this._restFindFeatureItem(featureCode);
|
|
341
|
+
}
|
|
342
|
+
return this._directFindFeatureItem(featureCode);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async ensureFeatureItem(featureCode, title) {
|
|
346
|
+
if (await this._serverAvailable()) {
|
|
347
|
+
return this._restEnsureFeatureItem(featureCode, title);
|
|
348
|
+
}
|
|
349
|
+
return this._directEnsureFeatureItem(featureCode, title);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async updateItemStatus(itemId, status) {
|
|
353
|
+
if (await this._serverAvailable()) {
|
|
354
|
+
return this._restUpdateItemStatus(itemId, status);
|
|
355
|
+
}
|
|
356
|
+
return this._directUpdateItemStatus(itemId, status);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async updateItemPhase(itemId, stepId) {
|
|
360
|
+
if (await this._serverAvailable()) {
|
|
361
|
+
return this._restUpdateItemPhase(itemId, stepId);
|
|
362
|
+
}
|
|
363
|
+
return this._directUpdateItemPhase(itemId, stepId);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async createGate(flowId, stepId, itemId, opts = {}) {
|
|
367
|
+
if (await this._serverAvailable()) {
|
|
368
|
+
return this._restCreateGate(flowId, stepId, itemId, opts);
|
|
369
|
+
}
|
|
370
|
+
return this._directCreateGate(flowId, stepId, itemId, opts);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async getGate(gateId, opts = {}) {
|
|
374
|
+
if (opts.requireServer) {
|
|
375
|
+
const up = await this._serverAvailable();
|
|
376
|
+
if (!up) throw new ServerUnreachableError();
|
|
377
|
+
return this._restGetGate(gateId);
|
|
378
|
+
}
|
|
379
|
+
if (await this._serverAvailable()) {
|
|
380
|
+
return this._restGetGate(gateId);
|
|
381
|
+
}
|
|
382
|
+
return this._directGetGate(gateId);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async resolveGate(gateId, outcome, comment, resolvedBy) {
|
|
386
|
+
if (await this._serverAvailable()) {
|
|
387
|
+
return this._restResolveGate(gateId, outcome, comment, resolvedBy);
|
|
388
|
+
}
|
|
389
|
+
return this._directResolveGate(gateId, outcome, comment, resolvedBy);
|
|
390
|
+
}
|
|
391
|
+
}
|