@smartmemory/compose 0.1.7-beta → 0.1.9-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/README.md +32 -5
- package/bin/compose.js +294 -34
- package/bin/git-hooks/post-commit.template +2 -1
- package/bin/git-hooks/pre-push.template +2 -1
- package/dist/assets/{_baseUniq-D-avYfn5.js → _baseUniq-3jW4HAOf.js} +1 -1
- package/dist/assets/{arc-BC4dfQ-X.js → arc-DzzDimyd.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BZmFXnGI.js → architectureDiagram-Q4EWVU46-CtAgwORz.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DlfWSuux.js → blockDiagram-DXYQGD6D-Bryby0c_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Y__uJrRx.js → c4Diagram-AHTNJAMY-C7N9RTJ8.js} +1 -1
- package/dist/assets/channel-DDkv7DUd.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BfMePfTp.js → chunk-4BX2VUAB-wijkFgZY.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-BdlMSdEA.js → chunk-4TB4RGXK-zdSZGRS2.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-vrQHZTdv.js → chunk-55IACEB6-6zqzTZQQ.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-B8wioVlW.js → chunk-EDXVE4YY-frd1Vwf-.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Cd6Hrux2.js → chunk-FMBD7UC4-CdkRK5Hx.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CfrhdQXY.js → chunk-OYMX7WX6-C6bMB0cf.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-B9JQerOU.js → chunk-QZHKN3VN-4vsxN3jq.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-DFN9X99H.js → chunk-YZCP3GAM-DbNARKip.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-J6ZTeCbW.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-J6ZTeCbW.js +1 -0
- package/dist/assets/clone-5MVZ89iV.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BAn0ap_E.js → cose-bilkent-S5V4N54A-BpXeV7Vj.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DyxnVq1g.js → dagre-KV5264BT-DQLu_W8r.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-XCrzqski.js → diagram-5BDNPKRD-skaOoe5A.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-MBCAXft_.js → diagram-G4DWMVQ6-DezlfFH4.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DbtB2yS6.js → diagram-MMDJMWI5-BUu-v-wT.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Bb5NzX61.js → diagram-TYMM5635-CziQ6LPs.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CpIeCOh2.js → erDiagram-SMLLAGMA-BsAyOVTI.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CHyoKnhW.js → flowDiagram-DWJPFMVM-CbYWJOLq.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-DErKteO_.js → ganttDiagram-T4ZO3ILL-CAwgDkLl.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-KFVAtj2F.js → gitGraphDiagram-UUTBAWPF-DK4RlkjO.js} +1 -1
- package/dist/assets/{graph-CRnO_ifT.js → graph-orv1XHGx.js} +1 -1
- package/dist/assets/{index-DkRKLuNr.js → index-Ceywghsu.js} +143 -143
- package/dist/assets/{infoDiagram-42DDH7IO-BZFnuSp5.js → infoDiagram-42DDH7IO-DQyA75sK.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-4Xe2Szde.js → ishikawaDiagram-UXIWVN3A-C-F_5q4k.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CZRByfS-.js → journeyDiagram-VCZTEJTY-Bj8UIvK-.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-B95sk6Fk.js → kanban-definition-6JOO6SKY-DZYr8Dp1.js} +1 -1
- package/dist/assets/{layout-BqNQzxWT.js → layout-CBaTKjpX.js} +1 -1
- package/dist/assets/{linear-CUh7qb64.js → linear-j1sI_SiN.js} +1 -1
- package/dist/assets/{min-wXgOS3ig.js → min-DtJISjld.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-DB6iaAbO.js → mindmap-definition-QFDTVHPH-Bulb64RS.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CHkZHrTW.js → pieDiagram-DEJITSTG-D11keQxr.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DoTEO8e3.js → quadrantDiagram-34T5L4WZ-BEcWQiEG.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Dn8peXYp.js → requirementDiagram-MS252O5E-Cbp23uDf.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DRXs6Ipb.js → sankeyDiagram-XADWPNL6-Dae1hMc5.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-wBBYZ0aq.js → sequenceDiagram-FGHM5R23-C16abORi.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DPlBNGmf.js → stateDiagram-FHFEXIEX-CbEtfhbx.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-CyY84hEA.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-CbbyTlHk.js → timeline-definition-GMOUNBTQ-BV7JTNMI.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-Bj4GaFfj.js → vennDiagram-DHZGUBPP-DBZiT48j.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-RtNzq8KU.js → wardley-RL74JXVD-Cc8uoiL3.js} +37 -37
- package/dist/assets/{wardleyDiagram-NUSXRM2D-CDfE3zSj.js → wardleyDiagram-NUSXRM2D-DEYcWGo5.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CZXHHYD5.js → xychartDiagram-5P7HB3ND-bFhLXv2b.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/build.js +193 -19
- package/lib/completion-writer.js +7 -4
- package/lib/deps.js +17 -6
- package/lib/discover-workspaces.js +109 -0
- package/lib/feature-events.js +3 -0
- package/lib/feature-writer.js +34 -22
- package/lib/followup-writer.js +556 -0
- package/lib/mcp-enforcement.js +173 -0
- package/lib/migrate-roadmap.js +4 -1
- package/lib/project-paths.js +36 -0
- package/lib/resolve-workspace.js +166 -0
- package/lib/review-lenses.js +23 -8
- package/lib/review-normalize.js +42 -3
- package/lib/roadmap-drift.js +54 -0
- package/lib/roadmap-gen.js +297 -27
- package/lib/roadmap-preservers.js +353 -0
- package/lib/step-prompt.js +15 -0
- package/lib/triage.js +2 -1
- package/lib/version-check.js +110 -0
- package/package.json +1 -2
- package/server/compose-mcp-tools.js +44 -8
- package/server/compose-mcp.js +66 -1
- package/server/project-root.js +4 -0
- package/server/vision-routes.js +51 -2
- package/templates/ROADMAP.md +6 -0
- package/dist/assets/channel-LRG9kHqJ.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +0 -1
- package/dist/assets/clone-dRxgFrBv.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +0 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-enforcement.js — helpers for COMP-MCP-MIGRATION-1 build-time
|
|
3
|
+
* enforcement of typed MCP writers against `ROADMAP.md`, `CHANGELOG.md`,
|
|
4
|
+
* and `feature.json` files.
|
|
5
|
+
*
|
|
6
|
+
* Mode parsing: `enforcement.mcpForFeatureMgmt` in `.compose/data/settings.json`
|
|
7
|
+
* true → 'block' (prompt + scan rejects unauthorized edits)
|
|
8
|
+
* 'log' → 'log' (prompt + scan emits decision events but proceeds)
|
|
9
|
+
* anything else → 'off' (no prompt, no scan)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
|
|
15
|
+
const GUARDED_FILES = new Set(['ROADMAP.md', 'CHANGELOG.md']);
|
|
16
|
+
|
|
17
|
+
const TOOLS_FOR_ROADMAP = ['add_roadmap_entry', 'set_feature_status', 'propose_followup'];
|
|
18
|
+
const TOOLS_FOR_CHANGELOG = ['add_changelog_entry'];
|
|
19
|
+
const TOOLS_FOR_FEATURE_JSON = [
|
|
20
|
+
'add_roadmap_entry',
|
|
21
|
+
'set_feature_status',
|
|
22
|
+
'link_artifact',
|
|
23
|
+
'link_features',
|
|
24
|
+
'record_completion',
|
|
25
|
+
'propose_followup',
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read `enforcement.mcpForFeatureMgmt` and normalize to 'block' | 'log' | 'off'.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} dataDir - The .compose/data directory containing settings.json.
|
|
32
|
+
* @returns {'block'|'log'|'off'}
|
|
33
|
+
*/
|
|
34
|
+
export function readEnforcementMode(dataDir) {
|
|
35
|
+
const settingsPath = join(dataDir, 'settings.json');
|
|
36
|
+
if (!existsSync(settingsPath)) return 'off';
|
|
37
|
+
try {
|
|
38
|
+
const s = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
39
|
+
const v = s?.enforcement?.mcpForFeatureMgmt;
|
|
40
|
+
if (v === true) return 'block';
|
|
41
|
+
if (v === 'log') return 'log';
|
|
42
|
+
return 'off';
|
|
43
|
+
} catch {
|
|
44
|
+
return 'off';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Filter a list of dirty repo-relative file paths down to the ones under
|
|
50
|
+
* MCP-enforcement governance.
|
|
51
|
+
*
|
|
52
|
+
* @param {string[]} dirtyFiles
|
|
53
|
+
* @param {string} featuresDir - Resolved features dir (e.g. 'docs/features').
|
|
54
|
+
* @returns {string[]}
|
|
55
|
+
*/
|
|
56
|
+
export function filterGuarded(dirtyFiles, featuresDir) {
|
|
57
|
+
return dirtyFiles.filter(p => isGuardedPath(p, featuresDir));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} path
|
|
62
|
+
* @param {string} featuresDir
|
|
63
|
+
*/
|
|
64
|
+
export function isGuardedPath(path, featuresDir) {
|
|
65
|
+
if (typeof path !== 'string') return false;
|
|
66
|
+
if (GUARDED_FILES.has(path)) return true;
|
|
67
|
+
// <featuresDir>/<CODE>/feature.json
|
|
68
|
+
const prefix = featuresDir.replace(/\/$/, '') + '/';
|
|
69
|
+
if (!path.startsWith(prefix)) return false;
|
|
70
|
+
return path.endsWith('/feature.json');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Return the typed MCP tool names that could legitimately produce the given
|
|
75
|
+
* guarded path. The pre-stage scan requires at least one event from this set
|
|
76
|
+
* to be present (with matching build_id) for the path to pass.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} path
|
|
79
|
+
* @param {string} featuresDir
|
|
80
|
+
* @returns {string[]}
|
|
81
|
+
*/
|
|
82
|
+
export function expectedToolsForPath(path, featuresDir) {
|
|
83
|
+
if (path === 'ROADMAP.md') return [...TOOLS_FOR_ROADMAP];
|
|
84
|
+
if (path === 'CHANGELOG.md') return [...TOOLS_FOR_CHANGELOG];
|
|
85
|
+
const prefix = featuresDir.replace(/\/$/, '') + '/';
|
|
86
|
+
if (path.startsWith(prefix) && path.endsWith('/feature.json')) {
|
|
87
|
+
return [...TOOLS_FOR_FEATURE_JSON];
|
|
88
|
+
}
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract the feature code from a feature.json path under featuresDir, or
|
|
94
|
+
* null if the path doesn't fit that shape.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} path
|
|
97
|
+
* @param {string} featuresDir
|
|
98
|
+
* @returns {string|null}
|
|
99
|
+
*/
|
|
100
|
+
export function featureCodeFromPath(path, featuresDir) {
|
|
101
|
+
const prefix = featuresDir.replace(/\/$/, '') + '/';
|
|
102
|
+
if (!path.startsWith(prefix) || !path.endsWith('/feature.json')) return null;
|
|
103
|
+
const middle = path.slice(prefix.length, -'/feature.json'.length);
|
|
104
|
+
if (!middle || middle.includes('/')) return null;
|
|
105
|
+
return middle;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run the pre-stage scan: for every guarded path in dirtyFiles, verify at
|
|
110
|
+
* least one matching audit event with the current build_id exists in the
|
|
111
|
+
* provided event window. For feature.json paths, the event must also be
|
|
112
|
+
* scoped to the same feature code (so an event for feature A can't bless a
|
|
113
|
+
* dirty edit to feature B's feature.json).
|
|
114
|
+
*
|
|
115
|
+
* @param {object} args
|
|
116
|
+
* @param {string[]} args.dirtyFiles
|
|
117
|
+
* @param {string} args.featuresDir
|
|
118
|
+
* @param {string} args.buildId - current build's UUID
|
|
119
|
+
* @param {Array<object>} args.events - events from feature-events.jsonl filtered to the build window
|
|
120
|
+
* @returns {{violations: Array<{path: string, expected: string[]}>}}
|
|
121
|
+
*/
|
|
122
|
+
export function scanGuarded({ dirtyFiles, featuresDir, buildId, events }) {
|
|
123
|
+
const guarded = filterGuarded(dirtyFiles, featuresDir);
|
|
124
|
+
const eventsForBuild = events.filter(e => e.build_id === buildId);
|
|
125
|
+
const violations = [];
|
|
126
|
+
for (const path of guarded) {
|
|
127
|
+
const expected = expectedToolsForPath(path, featuresDir);
|
|
128
|
+
if (expected.length === 0) continue; // unknown guarded shape — skip
|
|
129
|
+
|
|
130
|
+
// For feature.json paths, require code-level correlation so a typed
|
|
131
|
+
// event for feature A can't bless a manual edit to feature B's
|
|
132
|
+
// feature.json. ROADMAP.md and CHANGELOG.md are project-scoped, so
|
|
133
|
+
// tool-name-only matching is sufficient.
|
|
134
|
+
const requiredCode = featureCodeFromPath(path, featuresDir);
|
|
135
|
+
const matched = eventsForBuild.some(e => {
|
|
136
|
+
if (!expected.includes(e.tool)) return false;
|
|
137
|
+
if (requiredCode === null) return true;
|
|
138
|
+
// Writers all stamp `code` with the feature being mutated. propose_followup
|
|
139
|
+
// stamps the new code (which is also the feature.json being scaffolded),
|
|
140
|
+
// and link_features stamps the from_code (the source feature).
|
|
141
|
+
return e.code === requiredCode;
|
|
142
|
+
});
|
|
143
|
+
if (!matched) violations.push({ path, expected });
|
|
144
|
+
}
|
|
145
|
+
return { violations };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Construct the typed error thrown by the build runner when block-mode enforcement fires.
|
|
150
|
+
*
|
|
151
|
+
* @param {Array<{path: string, expected: string[]}>} violations
|
|
152
|
+
*/
|
|
153
|
+
export function enforcementError(violations) {
|
|
154
|
+
const lines = violations.map(v =>
|
|
155
|
+
` ${v.path} — required typed tool from: ${v.expected.join(', ')}`
|
|
156
|
+
).join('\n');
|
|
157
|
+
const err = new Error(
|
|
158
|
+
`MCP enforcement violation (enforcement.mcpForFeatureMgmt: true). ` +
|
|
159
|
+
`The following dirty paths have no matching typed-tool event in this build:\n${lines}\n` +
|
|
160
|
+
`Either re-run the failing edits via the typed MCP tools, or set ` +
|
|
161
|
+
`enforcement.mcpForFeatureMgmt to false / 'log' to bypass.`
|
|
162
|
+
);
|
|
163
|
+
err.code = 'MCP_ENFORCEMENT_VIOLATION';
|
|
164
|
+
err.violations = violations;
|
|
165
|
+
return err;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export const _internals = {
|
|
169
|
+
GUARDED_FILES,
|
|
170
|
+
TOOLS_FOR_ROADMAP,
|
|
171
|
+
TOOLS_FOR_CHANGELOG,
|
|
172
|
+
TOOLS_FOR_FEATURE_JSON,
|
|
173
|
+
};
|
package/lib/migrate-roadmap.js
CHANGED
|
@@ -9,6 +9,7 @@ import { readFileSync, existsSync } from 'fs';
|
|
|
9
9
|
import { join } from 'path';
|
|
10
10
|
import { parseRoadmap } from './roadmap-parser.js';
|
|
11
11
|
import { readFeature, writeFeature } from './feature-json.js';
|
|
12
|
+
import { loadFeaturesDir } from './project-paths.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Migrate ROADMAP.md entries to feature.json files.
|
|
@@ -21,7 +22,9 @@ import { readFeature, writeFeature } from './feature-json.js';
|
|
|
21
22
|
* @returns {{ created: string[], skipped: string[], updated: string[] }}
|
|
22
23
|
*/
|
|
23
24
|
export function migrateRoadmap(cwd, opts = {}) {
|
|
24
|
-
|
|
25
|
+
// COMP-MCP-MIGRATION-2-1: honor `paths.features` override so backfill
|
|
26
|
+
// writes under the configured root rather than the hardcoded default.
|
|
27
|
+
const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
|
|
25
28
|
const roadmapPath = join(cwd, 'ROADMAP.md');
|
|
26
29
|
|
|
27
30
|
if (!existsSync(roadmapPath)) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-paths.js — read .compose/compose.json `paths.features` override
|
|
3
|
+
* for lib-side writers.
|
|
4
|
+
*
|
|
5
|
+
* Server-side code uses `server/project-root.js`'s cached `loadProjectConfig`,
|
|
6
|
+
* but lib code may run outside the server process (CLI, tests, MCP stdio)
|
|
7
|
+
* and shouldn't share that cache. This is a tiny per-call read; the file is
|
|
8
|
+
* a few hundred bytes.
|
|
9
|
+
*
|
|
10
|
+
* Introduced by COMP-MCP-MIGRATION-2.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, readFileSync } from 'fs';
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_FEATURES_DIR = 'docs/features';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the project's features directory, respecting `.compose/compose.json`'s
|
|
19
|
+
* `paths.features` override. Returns the relative path (joined onto cwd by callers).
|
|
20
|
+
*
|
|
21
|
+
* @param {string} cwd
|
|
22
|
+
* @returns {string} Relative features dir, e.g. 'docs/features' or 'specs/features'.
|
|
23
|
+
*/
|
|
24
|
+
export function loadFeaturesDir(cwd) {
|
|
25
|
+
const cfgPath = join(cwd, '.compose', 'compose.json');
|
|
26
|
+
if (!existsSync(cfgPath)) return DEFAULT_FEATURES_DIR;
|
|
27
|
+
try {
|
|
28
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
29
|
+
const rel = cfg?.paths?.features;
|
|
30
|
+
return (typeof rel === 'string' && rel.length > 0) ? rel : DEFAULT_FEATURES_DIR;
|
|
31
|
+
} catch {
|
|
32
|
+
return DEFAULT_FEATURES_DIR;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const _internals = { DEFAULT_FEATURES_DIR };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolve-workspace.js — single resolver chain for compose workspaces.
|
|
3
|
+
*
|
|
4
|
+
* Precedence:
|
|
5
|
+
* 1. explicit hint.workspaceId (cheap upward walk first; falls back to discovery)
|
|
6
|
+
* 2. COMPOSE_TARGET env (absolute path bypasses discovery; id routes through it)
|
|
7
|
+
* 3. hint.getBinding() (MCP binding)
|
|
8
|
+
* 4. discovery (auto-pick when exactly one candidate; throws otherwise)
|
|
9
|
+
*
|
|
10
|
+
* Throws structured errors with `.code`: WorkspaceUnknown, WorkspaceAmbiguous,
|
|
11
|
+
* WorkspaceIdCollision, WorkspaceUnset. The CLI's dieOnWorkspaceError consumes them.
|
|
12
|
+
*
|
|
13
|
+
* Design intent: explicit-flag path uses findWorkspaceById (cheap upward walk)
|
|
14
|
+
* BEFORE invoking discoverWorkspaces — this lets users escape WorkspaceDiscoveryTooBroad
|
|
15
|
+
* by passing --workspace=<ancestor-id>. A descendant id still routes through discovery.
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import { discoverWorkspaces, deriveId } from './discover-workspaces.js';
|
|
20
|
+
|
|
21
|
+
export class WorkspaceUnknown extends Error {
|
|
22
|
+
constructor(id) {
|
|
23
|
+
super(`Unknown workspaceId: ${id}`);
|
|
24
|
+
this.code = 'WorkspaceUnknown';
|
|
25
|
+
this.id = id;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class WorkspaceAmbiguous extends Error {
|
|
30
|
+
constructor(candidates) {
|
|
31
|
+
super('Multiple workspaces match cwd');
|
|
32
|
+
this.code = 'WorkspaceAmbiguous';
|
|
33
|
+
this.candidates = candidates;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class WorkspaceIdCollision extends Error {
|
|
38
|
+
constructor(id, roots) {
|
|
39
|
+
super(`workspaceId "${id}" used by multiple roots`);
|
|
40
|
+
this.code = 'WorkspaceIdCollision';
|
|
41
|
+
this.id = id;
|
|
42
|
+
this.roots = roots;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class WorkspaceUnset extends Error {
|
|
47
|
+
constructor() {
|
|
48
|
+
super('No workspace resolved');
|
|
49
|
+
this.code = 'WorkspaceUnset';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve a workspace from hints + env + cwd.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} hint
|
|
57
|
+
* @param {string} [hint.cwd] — defaults to process.cwd()
|
|
58
|
+
* @param {string} [hint.workspaceId] — explicit --workspace=<id>
|
|
59
|
+
* @param {() => string|null} [hint.getBinding] — MCP binding accessor
|
|
60
|
+
* @returns {{id: string, root: string, configPath: string, source: string}}
|
|
61
|
+
*/
|
|
62
|
+
export function resolveWorkspace(hint = {}) {
|
|
63
|
+
const cwd = hint.cwd ?? process.cwd();
|
|
64
|
+
|
|
65
|
+
// 1. Explicit flag — authoritative. Cheap upward walk first; fall back to
|
|
66
|
+
// discovery (which may throw TooBroad for pathological trees).
|
|
67
|
+
if (hint.workspaceId) {
|
|
68
|
+
const found = findWorkspaceById(cwd, hint.workspaceId);
|
|
69
|
+
if (found) return { ...found, source: 'explicit-flag' };
|
|
70
|
+
const { candidates } = discoverWorkspaces(cwd);
|
|
71
|
+
return resolveByIdScopedCollisionCheck(hint.workspaceId, candidates, 'explicit-flag');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. COMPOSE_TARGET — absolute path is authoritative without discovery.
|
|
75
|
+
if (process.env.COMPOSE_TARGET) {
|
|
76
|
+
const t = process.env.COMPOSE_TARGET;
|
|
77
|
+
if (path.isAbsolute(t)) {
|
|
78
|
+
if (!fs.existsSync(t)) {
|
|
79
|
+
const e = new Error(`COMPOSE_TARGET=${t} does not exist`);
|
|
80
|
+
e.code = 'WorkspaceUnknown';
|
|
81
|
+
e.id = t;
|
|
82
|
+
throw e;
|
|
83
|
+
}
|
|
84
|
+
return { ...deriveId({ root: t }), source: 'env' };
|
|
85
|
+
}
|
|
86
|
+
const { candidates } = discoverWorkspaces(cwd);
|
|
87
|
+
return resolveByIdScopedCollisionCheck(t, candidates, 'env');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 3. MCP binding — scoped collision check on the bound id.
|
|
91
|
+
if (hint.getBinding) {
|
|
92
|
+
const id = hint.getBinding();
|
|
93
|
+
if (id) {
|
|
94
|
+
const { candidates } = discoverWorkspaces(cwd);
|
|
95
|
+
return resolveByIdScopedCollisionCheck(id, candidates, 'mcp-binding');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 4. Discovery — collisions matter because we're auto-picking.
|
|
100
|
+
const { candidates } = discoverWorkspaces(cwd);
|
|
101
|
+
detectCollisions(candidates);
|
|
102
|
+
if (candidates.length === 0) throw new WorkspaceUnset();
|
|
103
|
+
if (candidates.length === 1) return { ...candidates[0], source: 'discovery' };
|
|
104
|
+
throw new WorkspaceAmbiguous(candidates.map(({ id, root }) => ({ id, root })));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Cheap upward-only lookup: walk ancestors from startDir, return the first
|
|
109
|
+
* `.compose/` directory whose derived id matches targetId. Lets users bypass
|
|
110
|
+
* descendant-cap entirely via `--workspace=<ancestor-id>`.
|
|
111
|
+
*/
|
|
112
|
+
function findWorkspaceById(startDir, targetId) {
|
|
113
|
+
let dir = path.resolve(startDir);
|
|
114
|
+
const { root } = path.parse(dir);
|
|
115
|
+
while (true) {
|
|
116
|
+
if (fs.existsSync(path.join(dir, '.compose'))) {
|
|
117
|
+
const candidate = deriveId({ root: dir });
|
|
118
|
+
if (candidate.id === targetId) return candidate;
|
|
119
|
+
}
|
|
120
|
+
if (dir === root) return null;
|
|
121
|
+
const parent = path.dirname(dir);
|
|
122
|
+
if (parent === dir) return null;
|
|
123
|
+
dir = parent;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolveByIdScopedCollisionCheck(id, candidates, source) {
|
|
128
|
+
const matching = candidates.filter((c) => c.id === id);
|
|
129
|
+
if (matching.length === 0) throw new WorkspaceUnknown(id);
|
|
130
|
+
if (matching.length > 1) {
|
|
131
|
+
throw new WorkspaceIdCollision(id, matching.map((m) => m.root));
|
|
132
|
+
}
|
|
133
|
+
return { ...matching[0], source };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function detectCollisions(candidates) {
|
|
137
|
+
const byId = new Map();
|
|
138
|
+
for (const c of candidates) {
|
|
139
|
+
if (!byId.has(c.id)) byId.set(c.id, []);
|
|
140
|
+
byId.get(c.id).push(c.root);
|
|
141
|
+
}
|
|
142
|
+
for (const [id, roots] of byId) {
|
|
143
|
+
if (roots.length > 1) throw new WorkspaceIdCollision(id, roots);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Pull --workspace=<id> or --workspace <id> out of args, mutating in place.
|
|
149
|
+
* Returns the id, or null if absent.
|
|
150
|
+
*/
|
|
151
|
+
export function getWorkspaceFlag(args) {
|
|
152
|
+
for (let i = 0; i < args.length; i++) {
|
|
153
|
+
const a = args[i];
|
|
154
|
+
if (a === '--workspace' && i + 1 < args.length) {
|
|
155
|
+
const id = args[i + 1];
|
|
156
|
+
args.splice(i, 2);
|
|
157
|
+
return id;
|
|
158
|
+
}
|
|
159
|
+
if (typeof a === 'string' && a.startsWith('--workspace=')) {
|
|
160
|
+
const id = a.slice('--workspace='.length);
|
|
161
|
+
args.splice(i, 1);
|
|
162
|
+
return id;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
package/lib/review-lenses.js
CHANGED
|
@@ -112,27 +112,42 @@ const FRAMEWORK_PATTERNS = [
|
|
|
112
112
|
// ---------------------------------------------------------------------------
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
* Classify a diff by
|
|
115
|
+
* Classify a diff by changed-file count, optionally promoted by line count.
|
|
116
|
+
*
|
|
117
|
+
* Rules (the larger of the two classifications wins):
|
|
118
|
+
* File count: ≤2 → small, ≤8 → medium, ≥9 → large
|
|
119
|
+
* Line count: <50 → small, <200 → medium, ≥200 → large
|
|
120
|
+
*
|
|
121
|
+
* The line-count gate (STRAT-REV-FU-1) catches single-file mega-refactors that
|
|
122
|
+
* the original file-count-only rule under-classified. The original design called
|
|
123
|
+
* for >200 lines as the trigger — this restores parity while keeping the cheap,
|
|
124
|
+
* already-shipped file-count gate as the primary signal.
|
|
116
125
|
*
|
|
117
126
|
* @param {string[]} filesChanged - list of changed file paths
|
|
127
|
+
* @param {number|null} [lineCount] - optional total changed line count from `git diff --shortstat`
|
|
118
128
|
* @returns {'small'|'medium'|'large'}
|
|
119
129
|
*/
|
|
120
|
-
export function classifyDiffSize(filesChanged) {
|
|
130
|
+
export function classifyDiffSize(filesChanged, lineCount = null) {
|
|
121
131
|
const count = Array.isArray(filesChanged) ? filesChanged.length : 0;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
132
|
+
const fileClass = count <= 2 ? 'small' : count <= 8 ? 'medium' : 'large';
|
|
133
|
+
|
|
134
|
+
if (typeof lineCount !== 'number' || lineCount < 0) return fileClass;
|
|
135
|
+
|
|
136
|
+
const lineClass = lineCount < 50 ? 'small' : lineCount < 200 ? 'medium' : 'large';
|
|
137
|
+
const rank = { small: 0, medium: 1, large: 2 };
|
|
138
|
+
return rank[fileClass] >= rank[lineClass] ? fileClass : lineClass;
|
|
125
139
|
}
|
|
126
140
|
|
|
127
141
|
/**
|
|
128
142
|
* Whether cross-model (Codex) review should run for this diff.
|
|
129
|
-
*
|
|
143
|
+
* Triggers for large diffs (≥9 files OR ≥200 changed lines).
|
|
130
144
|
*
|
|
131
145
|
* @param {string[]} filesChanged - list of changed file paths
|
|
146
|
+
* @param {number|null} [lineCount] - optional total changed line count
|
|
132
147
|
* @returns {boolean}
|
|
133
148
|
*/
|
|
134
|
-
export function shouldRunCrossModel(filesChanged) {
|
|
135
|
-
return classifyDiffSize(filesChanged) === 'large';
|
|
149
|
+
export function shouldRunCrossModel(filesChanged, lineCount = null) {
|
|
150
|
+
return classifyDiffSize(filesChanged, lineCount) === 'large';
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
// ---------------------------------------------------------------------------
|
package/lib/review-normalize.js
CHANGED
|
@@ -282,9 +282,12 @@ export async function normalizeCrossModelResult(rawText, {
|
|
|
282
282
|
&& Array.isArray(parsed.claude_only)
|
|
283
283
|
&& Array.isArray(parsed.codex_only);
|
|
284
284
|
if (!hasAllArrays) {
|
|
285
|
+
// STRAT-REV-FU-3: defensively promote fallback confidence so caller-supplied findings
|
|
286
|
+
// can't silently drop below the gate filter (regression: codexAsFallback shipped at
|
|
287
|
+
// confidence=6 with gate=7, dropping all fallback findings on synthesis parse failure).
|
|
285
288
|
consensusRaw = [];
|
|
286
|
-
claudeOnlyRaw = claudeFindingsFallback;
|
|
287
|
-
codexOnlyRaw = codexFindingsFallback;
|
|
289
|
+
claudeOnlyRaw = claudeFindingsFallback.map(f => promoteFallbackConfidence(f, confidenceGate));
|
|
290
|
+
codexOnlyRaw = codexFindingsFallback.map(f => promoteFallbackConfidence(f, confidenceGate));
|
|
288
291
|
} else {
|
|
289
292
|
consensusRaw = parsed.consensus;
|
|
290
293
|
claudeOnlyRaw = parsed.claude_only;
|
|
@@ -306,11 +309,12 @@ export async function normalizeCrossModelResult(rawText, {
|
|
|
306
309
|
};
|
|
307
310
|
};
|
|
308
311
|
|
|
309
|
-
const consensus = consensusRaw.map(normalizeFinding).filter(f => f.confidence >= f.applied_gate);
|
|
312
|
+
const consensus = consensusRaw.map(normalizeFinding).filter(f => f.confidence >= f.applied_gate).map(promoteConsensusFinding);
|
|
310
313
|
const claude_only = claudeOnlyRaw.map(normalizeFinding).filter(f => f.confidence >= f.applied_gate);
|
|
311
314
|
const codex_only = codexOnlyRaw.map(normalizeFinding).filter(f => f.confidence >= f.applied_gate);
|
|
312
315
|
|
|
313
316
|
// Step 4: Merge all findings into top-level findings array
|
|
317
|
+
// (consensus stamp + boost flow through unchanged so cockpit can highlight high-conviction issues)
|
|
314
318
|
const findings = [...consensus, ...claude_only, ...codex_only];
|
|
315
319
|
|
|
316
320
|
// Step 5: Compute clean across all three arrays
|
|
@@ -397,6 +401,41 @@ function buildCrossModelRepairPrompt(badText) {
|
|
|
397
401
|
);
|
|
398
402
|
}
|
|
399
403
|
|
|
404
|
+
/**
|
|
405
|
+
* STRAT-REV-FU-2: promote a consensus finding — stamp consensus:true and boost
|
|
406
|
+
* confidence by 2 (capped at 10). Two independent models agreeing is materially
|
|
407
|
+
* stronger evidence than either alone, so the cockpit treats these as high-conviction.
|
|
408
|
+
*
|
|
409
|
+
* @param {object} f
|
|
410
|
+
* @returns {object}
|
|
411
|
+
*/
|
|
412
|
+
const CONSENSUS_BOOST = 2;
|
|
413
|
+
const MAX_CONFIDENCE = 10;
|
|
414
|
+
function promoteConsensusFinding(f) {
|
|
415
|
+
return {
|
|
416
|
+
...f,
|
|
417
|
+
consensus: true,
|
|
418
|
+
confidence: Math.min(MAX_CONFIDENCE, f.confidence + CONSENSUS_BOOST),
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* STRAT-REV-FU-3: clone a fallback finding and promote its confidence to applied_gate
|
|
424
|
+
* if it's under-stamped. The fallback path is the only place we trust the caller's
|
|
425
|
+
* intent over the model's confidence — these findings come from prior model output that
|
|
426
|
+
* already passed its own gate, so suppressing them via gate-filter is silent data loss.
|
|
427
|
+
*
|
|
428
|
+
* @param {object} f
|
|
429
|
+
* @param {number} confidenceGate
|
|
430
|
+
* @returns {object}
|
|
431
|
+
*/
|
|
432
|
+
function promoteFallbackConfidence(f, confidenceGate) {
|
|
433
|
+
const gate = typeof f.applied_gate === 'number' ? f.applied_gate : confidenceGate;
|
|
434
|
+
const conf = typeof f.confidence === 'number' ? f.confidence : 5;
|
|
435
|
+
if (conf >= gate) return f;
|
|
436
|
+
return { ...f, confidence: gate };
|
|
437
|
+
}
|
|
438
|
+
|
|
400
439
|
/**
|
|
401
440
|
* Normalize severity strings to canonical values.
|
|
402
441
|
* Accepts: must-fix, must_fix, MUST-FIX, should-fix, should_fix, nit, etc.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drift detection for ROADMAP.md typed-writer regen.
|
|
3
|
+
*
|
|
4
|
+
* COMP-MCP-MIGRATION-2-1-1 T2 (Option A).
|
|
5
|
+
*
|
|
6
|
+
* When a phase heading carries a curated status override (e.g. `PARTIAL
|
|
7
|
+
* (1a–1d COMPLETE, 2 PLANNED)`) that diverges from the rollup-computed
|
|
8
|
+
* status from feature.json, the writer keeps the override (per Decision 2)
|
|
9
|
+
* and emits a `roadmap_drift` event so the divergence is visible.
|
|
10
|
+
*
|
|
11
|
+
* Dedupe is at read time inside emitDrift() — appendEvent() does not
|
|
12
|
+
* enforce idempotency_key, so we read recent events and short-circuit
|
|
13
|
+
* if the same drift triple was already recorded.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { appendEvent, readEvents } from './feature-events.js';
|
|
17
|
+
|
|
18
|
+
const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000; // 24h
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Emit a roadmap drift event with read-side dedupe.
|
|
22
|
+
*
|
|
23
|
+
* Always writes a stderr warning. Only writes a new event if the same
|
|
24
|
+
* (phaseId, override, computed) triple hasn't been recorded in the last
|
|
25
|
+
* 24h.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} cwd
|
|
28
|
+
* @param {{phaseId: string, override: string, computed: string}} info
|
|
29
|
+
*/
|
|
30
|
+
export function emitDrift(cwd, { phaseId, override, computed }) {
|
|
31
|
+
process.stderr.write(
|
|
32
|
+
`WARN: phase "${phaseId}" override "${override}" diverges from rollup "${computed}". Edit ROADMAP.md to acknowledge.\n`
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const recent = readEvents(cwd, { since: Date.now() - DEDUPE_WINDOW_MS });
|
|
36
|
+
for (const ev of recent) {
|
|
37
|
+
if (
|
|
38
|
+
ev.tool === 'roadmap_drift' &&
|
|
39
|
+
ev.code === phaseId &&
|
|
40
|
+
ev.from === computed &&
|
|
41
|
+
ev.to === override
|
|
42
|
+
) {
|
|
43
|
+
return; // already recorded within window
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
appendEvent(cwd, {
|
|
48
|
+
tool: 'roadmap_drift',
|
|
49
|
+
code: phaseId,
|
|
50
|
+
from: computed,
|
|
51
|
+
to: override,
|
|
52
|
+
reason: 'override-vs-rollup-divergence',
|
|
53
|
+
});
|
|
54
|
+
}
|