@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.
Files changed (84) hide show
  1. package/README.md +32 -5
  2. package/bin/compose.js +294 -34
  3. package/bin/git-hooks/post-commit.template +2 -1
  4. package/bin/git-hooks/pre-push.template +2 -1
  5. package/dist/assets/{_baseUniq-D-avYfn5.js → _baseUniq-3jW4HAOf.js} +1 -1
  6. package/dist/assets/{arc-BC4dfQ-X.js → arc-DzzDimyd.js} +1 -1
  7. package/dist/assets/{architectureDiagram-Q4EWVU46-BZmFXnGI.js → architectureDiagram-Q4EWVU46-CtAgwORz.js} +1 -1
  8. package/dist/assets/{blockDiagram-DXYQGD6D-DlfWSuux.js → blockDiagram-DXYQGD6D-Bryby0c_.js} +1 -1
  9. package/dist/assets/{c4Diagram-AHTNJAMY-Y__uJrRx.js → c4Diagram-AHTNJAMY-C7N9RTJ8.js} +1 -1
  10. package/dist/assets/channel-DDkv7DUd.js +1 -0
  11. package/dist/assets/{chunk-4BX2VUAB-BfMePfTp.js → chunk-4BX2VUAB-wijkFgZY.js} +1 -1
  12. package/dist/assets/{chunk-4TB4RGXK-BdlMSdEA.js → chunk-4TB4RGXK-zdSZGRS2.js} +1 -1
  13. package/dist/assets/{chunk-55IACEB6-vrQHZTdv.js → chunk-55IACEB6-6zqzTZQQ.js} +1 -1
  14. package/dist/assets/{chunk-EDXVE4YY-B8wioVlW.js → chunk-EDXVE4YY-frd1Vwf-.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-Cd6Hrux2.js → chunk-FMBD7UC4-CdkRK5Hx.js} +1 -1
  16. package/dist/assets/{chunk-OYMX7WX6-CfrhdQXY.js → chunk-OYMX7WX6-C6bMB0cf.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-B9JQerOU.js → chunk-QZHKN3VN-4vsxN3jq.js} +1 -1
  18. package/dist/assets/{chunk-YZCP3GAM-DFN9X99H.js → chunk-YZCP3GAM-DbNARKip.js} +1 -1
  19. package/dist/assets/classDiagram-6PBFFD2Q-J6ZTeCbW.js +1 -0
  20. package/dist/assets/classDiagram-v2-HSJHXN6E-J6ZTeCbW.js +1 -0
  21. package/dist/assets/clone-5MVZ89iV.js +1 -0
  22. package/dist/assets/{cose-bilkent-S5V4N54A-BAn0ap_E.js → cose-bilkent-S5V4N54A-BpXeV7Vj.js} +1 -1
  23. package/dist/assets/{dagre-KV5264BT-DyxnVq1g.js → dagre-KV5264BT-DQLu_W8r.js} +1 -1
  24. package/dist/assets/{diagram-5BDNPKRD-XCrzqski.js → diagram-5BDNPKRD-skaOoe5A.js} +1 -1
  25. package/dist/assets/{diagram-G4DWMVQ6-MBCAXft_.js → diagram-G4DWMVQ6-DezlfFH4.js} +1 -1
  26. package/dist/assets/{diagram-MMDJMWI5-DbtB2yS6.js → diagram-MMDJMWI5-BUu-v-wT.js} +1 -1
  27. package/dist/assets/{diagram-TYMM5635-Bb5NzX61.js → diagram-TYMM5635-CziQ6LPs.js} +1 -1
  28. package/dist/assets/{erDiagram-SMLLAGMA-CpIeCOh2.js → erDiagram-SMLLAGMA-BsAyOVTI.js} +1 -1
  29. package/dist/assets/{flowDiagram-DWJPFMVM-CHyoKnhW.js → flowDiagram-DWJPFMVM-CbYWJOLq.js} +1 -1
  30. package/dist/assets/{ganttDiagram-T4ZO3ILL-DErKteO_.js → ganttDiagram-T4ZO3ILL-CAwgDkLl.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-UUTBAWPF-KFVAtj2F.js → gitGraphDiagram-UUTBAWPF-DK4RlkjO.js} +1 -1
  32. package/dist/assets/{graph-CRnO_ifT.js → graph-orv1XHGx.js} +1 -1
  33. package/dist/assets/{index-DkRKLuNr.js → index-Ceywghsu.js} +143 -143
  34. package/dist/assets/{infoDiagram-42DDH7IO-BZFnuSp5.js → infoDiagram-42DDH7IO-DQyA75sK.js} +1 -1
  35. package/dist/assets/{ishikawaDiagram-UXIWVN3A-4Xe2Szde.js → ishikawaDiagram-UXIWVN3A-C-F_5q4k.js} +1 -1
  36. package/dist/assets/{journeyDiagram-VCZTEJTY-CZRByfS-.js → journeyDiagram-VCZTEJTY-Bj8UIvK-.js} +1 -1
  37. package/dist/assets/{kanban-definition-6JOO6SKY-B95sk6Fk.js → kanban-definition-6JOO6SKY-DZYr8Dp1.js} +1 -1
  38. package/dist/assets/{layout-BqNQzxWT.js → layout-CBaTKjpX.js} +1 -1
  39. package/dist/assets/{linear-CUh7qb64.js → linear-j1sI_SiN.js} +1 -1
  40. package/dist/assets/{min-wXgOS3ig.js → min-DtJISjld.js} +1 -1
  41. package/dist/assets/{mindmap-definition-QFDTVHPH-DB6iaAbO.js → mindmap-definition-QFDTVHPH-Bulb64RS.js} +1 -1
  42. package/dist/assets/{pieDiagram-DEJITSTG-CHkZHrTW.js → pieDiagram-DEJITSTG-D11keQxr.js} +1 -1
  43. package/dist/assets/{quadrantDiagram-34T5L4WZ-DoTEO8e3.js → quadrantDiagram-34T5L4WZ-BEcWQiEG.js} +1 -1
  44. package/dist/assets/{requirementDiagram-MS252O5E-Dn8peXYp.js → requirementDiagram-MS252O5E-Cbp23uDf.js} +1 -1
  45. package/dist/assets/{sankeyDiagram-XADWPNL6-DRXs6Ipb.js → sankeyDiagram-XADWPNL6-Dae1hMc5.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-FGHM5R23-wBBYZ0aq.js → sequenceDiagram-FGHM5R23-C16abORi.js} +1 -1
  47. package/dist/assets/{stateDiagram-FHFEXIEX-DPlBNGmf.js → stateDiagram-FHFEXIEX-CbEtfhbx.js} +1 -1
  48. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CyY84hEA.js +1 -0
  49. package/dist/assets/{timeline-definition-GMOUNBTQ-CbbyTlHk.js → timeline-definition-GMOUNBTQ-BV7JTNMI.js} +1 -1
  50. package/dist/assets/{vennDiagram-DHZGUBPP-Bj4GaFfj.js → vennDiagram-DHZGUBPP-DBZiT48j.js} +1 -1
  51. package/dist/assets/{wardley-RL74JXVD-RtNzq8KU.js → wardley-RL74JXVD-Cc8uoiL3.js} +37 -37
  52. package/dist/assets/{wardleyDiagram-NUSXRM2D-CDfE3zSj.js → wardleyDiagram-NUSXRM2D-DEYcWGo5.js} +1 -1
  53. package/dist/assets/{xychartDiagram-5P7HB3ND-CZXHHYD5.js → xychartDiagram-5P7HB3ND-bFhLXv2b.js} +1 -1
  54. package/dist/index.html +1 -1
  55. package/lib/build.js +193 -19
  56. package/lib/completion-writer.js +7 -4
  57. package/lib/deps.js +17 -6
  58. package/lib/discover-workspaces.js +109 -0
  59. package/lib/feature-events.js +3 -0
  60. package/lib/feature-writer.js +34 -22
  61. package/lib/followup-writer.js +556 -0
  62. package/lib/mcp-enforcement.js +173 -0
  63. package/lib/migrate-roadmap.js +4 -1
  64. package/lib/project-paths.js +36 -0
  65. package/lib/resolve-workspace.js +166 -0
  66. package/lib/review-lenses.js +23 -8
  67. package/lib/review-normalize.js +42 -3
  68. package/lib/roadmap-drift.js +54 -0
  69. package/lib/roadmap-gen.js +297 -27
  70. package/lib/roadmap-preservers.js +353 -0
  71. package/lib/step-prompt.js +15 -0
  72. package/lib/triage.js +2 -1
  73. package/lib/version-check.js +110 -0
  74. package/package.json +1 -2
  75. package/server/compose-mcp-tools.js +44 -8
  76. package/server/compose-mcp.js +66 -1
  77. package/server/project-root.js +4 -0
  78. package/server/vision-routes.js +51 -2
  79. package/templates/ROADMAP.md +6 -0
  80. package/dist/assets/channel-LRG9kHqJ.js +0 -1
  81. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +0 -1
  82. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +0 -1
  83. package/dist/assets/clone-dRxgFrBv.js +0 -1
  84. 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
+ };
@@ -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
- const featuresDir = opts.featuresDir ?? 'docs/features';
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
+ }
@@ -112,27 +112,42 @@ const FRAMEWORK_PATTERNS = [
112
112
  // ---------------------------------------------------------------------------
113
113
 
114
114
  /**
115
- * Classify a diff by number of changed files.
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
- if (count <= 2) return 'small';
123
- if (count <= 8) return 'medium';
124
- return 'large';
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
- * Only triggers for large diffs (≥9 files).
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
  // ---------------------------------------------------------------------------
@@ -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
+ }