@smartmemory/compose 0.2.39-beta → 0.2.41-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/bin/compose.js CHANGED
@@ -329,14 +329,16 @@ async function runDoctor(flags = []) {
329
329
  // compose init — project-local setup
330
330
  // ---------------------------------------------------------------------------
331
331
 
332
- async function runInit(flags) {
332
+ async function runInit(flags, cwdOverride) {
333
333
  const noStratum = flags.includes('--no-stratum')
334
334
  const noLifecycle = flags.includes('--no-lifecycle')
335
335
  // init creates the workspace — never go through resolveCwdWithWorkspace (which
336
336
  // requires one to exist). Strip --workspace if present to avoid leaving it in
337
337
  // the shared args array for downstream subcommands.
338
338
  getWorkspaceFlag(args)
339
- const cwd = process.cwd()
339
+ // cwdOverride lets callers (e.g. runUpdate) target the resolved workspace root
340
+ // instead of process.cwd(), which differs when run from a subdirectory.
341
+ const cwd = cwdOverride || process.cwd()
340
342
 
341
343
  // 1. Create .compose/ directory
342
344
  const composeDir = join(cwd, '.compose')
@@ -373,8 +375,10 @@ async function runInit(flags) {
373
375
  // 4. Write .compose/compose.json (merge with existing if present)
374
376
  const configPath = join(composeDir, 'compose.json')
375
377
  let existing = {}
378
+ let existingConfigCorrupt = false
376
379
  if (existsSync(configPath)) {
377
- try { existing = JSON.parse(readFileSync(configPath, 'utf-8')) } catch {}
380
+ try { existing = JSON.parse(readFileSync(configPath, 'utf-8')) }
381
+ catch { existingConfigCorrupt = true }
378
382
  }
379
383
 
380
384
  const agentsConfig = {}
@@ -388,6 +392,10 @@ async function runInit(flags) {
388
392
  }
389
393
 
390
394
  const config = {
395
+ // Preserve unknown top-level keys (tracker, roadmap, stateVersion, …) — the
396
+ // explicit keys below override the merged sub-objects. Without this spread,
397
+ // init/upgrade silently drops e.g. `roadmap.narrative` and `tracker` config.
398
+ ...existing,
391
399
  version: 2,
392
400
  capabilities: {
393
401
  ...(existing.capabilities || {}),
@@ -418,6 +426,23 @@ async function runInit(flags) {
418
426
  // 5. Create .compose/data/
419
427
  mkdirSync(join(composeDir, 'data'), { recursive: true })
420
428
 
429
+ // 5a. Run pending feature.json state migrations (COMP-MIGRATE-ON-UPGRADE).
430
+ // Uses init's own resolved cwd; never aborts init on a migration fault. Skip
431
+ // when the prior compose.json was corrupt — init just normalized it to a
432
+ // default, so its tracker/paths can't be trusted to scope the migration.
433
+ if (existingConfigCorrupt) {
434
+ console.warn('state migration skipped: prior compose.json was unreadable (normalized to default; re-run `compose migrate-state` after verifying config)')
435
+ } else {
436
+ try {
437
+ const { runStateMigrations, summarizeMigrationReport } = await import('../lib/state-migrations.js')
438
+ const rep = runStateMigrations(cwd, {})
439
+ const line = summarizeMigrationReport(rep)
440
+ if (line && !rep.noop) console.log(line)
441
+ } catch (err) {
442
+ console.warn(`state migration skipped: ${err.message}`)
443
+ }
444
+ }
445
+
421
446
  // 5b. Scaffold docs/context/ with ambient context templates
422
447
  const contextDir = join(cwd, config.paths.context)
423
448
  mkdirSync(contextDir, { recursive: true })
@@ -669,7 +694,11 @@ async function runUpdate(flags) {
669
694
  if (existsSync(join(cwd, '.compose', 'compose.json'))) {
670
695
  console.log('')
671
696
  console.log(`Refreshing project at ${cwd}...`)
672
- await runInit([])
697
+ // Thread the RESOLVED workspace root into runInit (it otherwise uses
698
+ // process.cwd(), which differs when upgrading from a subdirectory). This
699
+ // makes all of init's refresh — including its state-migration step — target
700
+ // the right workspace (COMP-MIGRATE-ON-UPGRADE finding 6).
701
+ await runInit([], cwd)
673
702
  }
674
703
 
675
704
  console.log('')
@@ -704,6 +733,25 @@ if (cmd === 'update' || cmd === 'upgrade') {
704
733
  process.exit(0)
705
734
  }
706
735
 
736
+ if (cmd === 'migrate-state') {
737
+ // COMP-MIGRATE-ON-UPGRADE — run pending feature.json state migrations explicitly.
738
+ // Distinct from `compose roadmap migrate` (ROADMAP→feature.json backfill).
739
+ const { root: cwd } = resolveCwdWithWorkspace(args)
740
+ const dryRun = args.includes('--dry-run')
741
+ const { runStateMigrations, summarizeMigrationReport } = await import('../lib/state-migrations.js')
742
+ const rep = runStateMigrations(cwd, { dryRun })
743
+ if (rep.skipped) {
744
+ console.log(`migrate-state: skipped (${rep.skipped})`)
745
+ process.exit(0)
746
+ }
747
+ console.log(summarizeMigrationReport(rep))
748
+ for (const m of rep.perMigration) {
749
+ if (m.touched.length) console.log(` [${m.id}] v${m.version}: ${m.touched.join(', ')}`)
750
+ }
751
+ for (const e of rep.parseErrors) console.error(` unparseable: ${e.path} — ${e.message}`)
752
+ process.exit(0)
753
+ }
754
+
707
755
  if (cmd === 'install') {
708
756
  // Backwards-compat: run both init + setup
709
757
  await runInit(args)
@@ -0,0 +1,140 @@
1
+ /**
2
+ * get-roadmap.js — COMP-MCP-ROADMAP-READ.
3
+ *
4
+ * Read-only roadmap reader for the compose MCP `get_roadmap` tool. Renders the
5
+ * roadmap from canon (feature.json) WITHOUT writing any file, parses rows via the
6
+ * shared parseRoadmap, and reports a staleness flag vs the on-disk ROADMAP.md.
7
+ *
8
+ * Invariants:
9
+ * - Never mutates the filesystem (no writeRoadmap / renderRoadmap).
10
+ * - Narrative-owned workspaces read ROADMAP.md verbatim (no console.warn path),
11
+ * and are never reported stale (the file IS the canon).
12
+ * - Row parsing reuses parseRoadmap — no second hand-rolled regex.
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+
17
+ import { generateRoadmap } from './roadmap-gen.js';
18
+ import { isNarrativeOwned } from './roadmap-config.js';
19
+ import { parseRoadmap, parseStatusToken } from './roadmap-parser.js';
20
+
21
+ // Whole `**Last updated:** <date>` line — date-only and per-day, so it is
22
+ // stripped (not literal-matched) before drift comparison.
23
+ const LAST_UPDATED_RE = /^\*\*Last updated:\*\*.*$/m;
24
+
25
+ const ACTIVE_STATUSES = new Set(['IN_PROGRESS', 'PARTIAL']);
26
+
27
+ // Normalized status token -> summary bucket.
28
+ const BUCKET = {
29
+ COMPLETE: 'complete',
30
+ IN_PROGRESS: 'active',
31
+ PARTIAL: 'active',
32
+ PLANNED: 'planned',
33
+ BLOCKED: 'blocked',
34
+ PARKED: 'parked',
35
+ SUPERSEDED: 'superseded',
36
+ };
37
+
38
+ function emptySummary() {
39
+ return { complete: 0, active: 0, planned: 0, blocked: 0, parked: 0, superseded: 0 };
40
+ }
41
+
42
+ function stripVolatile(text) {
43
+ return text.replace(LAST_UPDATED_RE, '').trimEnd();
44
+ }
45
+
46
+ /**
47
+ * @param {string} root - Project root.
48
+ * @param {object} [opts]
49
+ * @param {string} [opts.status] - Comma-list status filter applied to active/blocked + rows.
50
+ * @param {string} [opts.phase] - Exact phaseId filter applied to active/blocked + rows.
51
+ * @param {'summary'|'markdown'} [opts.format='summary'] - Omit or include raw markdown.
52
+ * @param {boolean} [opts.check_drift=true] - Compare render vs on-disk ROADMAP.md.
53
+ * @param {number} [opts.limit=50] - Cap on the general `rows` list (emitted only when
54
+ * a status/phase filter or an explicit limit is supplied — keeps the default call token-safe).
55
+ */
56
+ export function getRoadmap(root, opts = {}) {
57
+ const { status, phase, format = 'summary', check_drift = true, limit } = opts ?? {};
58
+
59
+ const roadmapPath = join(root, 'ROADMAP.md');
60
+ const onDisk = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
61
+
62
+ // Branch on narrative ownership ourselves: read the file directly for narrative
63
+ // workspaces (avoids generateRoadmap's console.warn and is a true no-op read).
64
+ const narrative = isNarrativeOwned(root);
65
+ const markdown = narrative ? onDisk : generateRoadmap(root, {});
66
+ const source = narrative ? 'narrative' : 'rendered';
67
+
68
+ const rows = parseRoadmap(markdown);
69
+
70
+ // Summary counts over ALL rows (anonymous included).
71
+ const summary = emptySummary();
72
+ for (const r of rows) {
73
+ const bucket = BUCKET[parseStatusToken(r.status)];
74
+ if (bucket) summary[bucket]++;
75
+ }
76
+
77
+ // active/blocked lists exclude anonymous rows. parseRoadmap rewrites
78
+ // codeless rows to `_anon_${position}` (roadmap-parser.js), so filter on that
79
+ // prefix — not the raw '—' glyph, which never reaches us.
80
+ const named = rows.filter((r) => r.code && !r.code.startsWith('_anon_'));
81
+
82
+ const matchFilter = (r) => {
83
+ if (status) {
84
+ const wanted = status.split(',').map((s) => s.trim().toUpperCase());
85
+ if (!wanted.includes(parseStatusToken(r.status))) return false;
86
+ }
87
+ if (phase && r.phaseId !== phase) return false;
88
+ return true;
89
+ };
90
+ const pick = (r) => ({
91
+ code: r.code,
92
+ description: r.description,
93
+ status: parseStatusToken(r.status),
94
+ phaseId: r.phaseId,
95
+ });
96
+
97
+ const active = named
98
+ .filter((r) => ACTIVE_STATUSES.has(parseStatusToken(r.status)))
99
+ .filter(matchFilter)
100
+ .map(pick);
101
+ const blocked = named
102
+ .filter((r) => parseStatusToken(r.status) === 'BLOCKED')
103
+ .filter(matchFilter)
104
+ .map(pick);
105
+
106
+ const out = { source, path: roadmapPath, summary, active, blocked };
107
+
108
+ // General filtered rows list — emitted only when the caller signals intent via a
109
+ // status/phase filter or an explicit limit. This is what lets `/roadmap next` read
110
+ // the PLANNED list structured instead of re-parsing the markdown. The no-arg summary
111
+ // call stays token-safe (no rows key). Anonymous rows are excluded, same as the lists.
112
+ const wantsRows = status != null || phase != null || limit != null;
113
+ if (wantsRows) {
114
+ // Resolve the row cap with predictable semantics for malformed input: a finite
115
+ // number is floored and clamped to >= 0 (so limit:-1 → 0 rows, limit:1.5 → 1),
116
+ // never silently widened. Anything non-finite (no limit, just a status/phase
117
+ // filter) falls back to the default 50.
118
+ const cap = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 50;
119
+ const matched = named.filter(matchFilter).map(pick);
120
+ out.rowsTotal = matched.length;
121
+ out.rows = matched.slice(0, cap);
122
+ out.rowsTruncated = matched.length > cap;
123
+ }
124
+
125
+ if (check_drift) {
126
+ if (narrative) {
127
+ out.stale = false; // the content IS the file
128
+ } else {
129
+ const drifted = stripVolatile(markdown) !== stripVolatile(onDisk);
130
+ out.stale = drifted;
131
+ if (drifted) {
132
+ out.drift = 'ROADMAP.md differs from the feature.json render (run validate_project --fix to reconcile)';
133
+ }
134
+ }
135
+ }
136
+
137
+ if (format === 'markdown') out.markdown = markdown;
138
+
139
+ return out;
140
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * COMP-MIGRATE-ON-UPGRADE — versioned, eager feature.json state migration.
3
+ *
4
+ * `compose upgrade` refreshes code but historically ran no state migration:
5
+ * vision-state is migrated lazily on every load, but feature.json cold data
6
+ * (e.g. legacy `complexity: null` that fails the schema oneOf) is never touched
7
+ * unless the file happens to be rewritten. This runner walks every feature.json
8
+ * eagerly, applies an ordered registry of pure transforms, and records progress
9
+ * in a durable, dedicated state file.
10
+ *
11
+ * Design: docs/features/COMP-MIGRATE-ON-UPGRADE/design.md (rev 2).
12
+ *
13
+ * Invariants:
14
+ * - migrateFeature transforms are PURE and TOTAL: no I/O, never throw on
15
+ * valid-JSON input. (A throw means a migration-code bug → fail-fast.)
16
+ * - The runner owns all I/O, atomicity (temp+rename), and reporting.
17
+ * - The stamp lives in .compose/data/migration-state.json — NOT compose.json
18
+ * (runInit rewrites compose.json from a fixed shape and would drop it; and a
19
+ * torn write to tracker config would break workspace resolution).
20
+ * - Convergent: corrupt/unparseable feature.json is REPORTED, not a permanent
21
+ * block — the stamp still advances, so one bad file can't re-run the whole
22
+ * stack forever.
23
+ * - local-provider only; narrative-safe (never regenerates ROADMAP.md).
24
+ */
25
+ import {
26
+ readFileSync, writeFileSync, existsSync, mkdirSync,
27
+ renameSync, unlinkSync, readdirSync,
28
+ } from 'fs';
29
+ import { join } from 'path';
30
+ import { loadFeaturesDir } from './project-paths.js';
31
+ import { writeFeature } from './feature-json.js';
32
+
33
+ /**
34
+ * Ordered, append-only migration registry. Each `migrateFeature` is pure/total.
35
+ * @type {{version:number, id:string, describe:string,
36
+ * migrateFeature:(f:object)=>{changed:boolean, feature:object}}[]}
37
+ */
38
+ // Legacy free-text complexity → schema enum (S/M/L/XL), preserving ordinal intent.
39
+ const COMPLEXITY_SYNONYMS = {
40
+ xs: 'S', s: 'S', small: 'S', low: 'S', trivial: 'S',
41
+ m: 'M', medium: 'M', med: 'M', moderate: 'M',
42
+ l: 'L', large: 'L', high: 'L',
43
+ xl: 'XL', 'extra-large': 'XL', 'extra large': 'XL', 'very-high': 'XL', 'very high': 'XL',
44
+ };
45
+ const VALID_COMPLEXITY = new Set(['S', 'M', 'L', 'XL']);
46
+
47
+ export const MIGRATIONS = [
48
+ {
49
+ version: 1,
50
+ id: 'normalize-complexity',
51
+ describe: 'Normalize legacy free-text complexity to the S/M/L/XL enum; drop null/unmappable (fails the string|number oneOf)',
52
+ migrateFeature(f) {
53
+ // Total on any parseable JSON value: a non-object (scalar/array/null)
54
+ // feature.json is malformed data, not a migration target — never throw.
55
+ if (!f || typeof f !== 'object' || Array.isArray(f)) return { changed: false, feature: f };
56
+ if (!('complexity' in f)) return { changed: false, feature: f };
57
+ const c = f.complexity;
58
+ // Already schema-valid: a number or an enum member — leave untouched.
59
+ if (typeof c === 'number') return { changed: false, feature: f };
60
+ if (typeof c === 'string' && VALID_COMPLEXITY.has(c)) return { changed: false, feature: f };
61
+ // Mappable legacy free-text → enum.
62
+ if (typeof c === 'string') {
63
+ const mapped = COMPLEXITY_SYNONYMS[c.trim().toLowerCase()];
64
+ if (mapped) return { changed: true, feature: { ...f, complexity: mapped } };
65
+ }
66
+ // null or unmappable (object/garbage string) → drop the optional key.
67
+ const { complexity, ...rest } = f;
68
+ return { changed: true, feature: rest };
69
+ },
70
+ },
71
+ ];
72
+
73
+ function stateFilePath(cwd) {
74
+ return join(cwd, '.compose', 'data', 'migration-state.json');
75
+ }
76
+
77
+ /** Read the durable stamp. Absent or corrupt ⇒ {stateVersion:0, applied:[]}. */
78
+ export function readMigrationState(cwd) {
79
+ const p = stateFilePath(cwd);
80
+ if (!existsSync(p)) return { stateVersion: 0, applied: [] };
81
+ try {
82
+ const parsed = JSON.parse(readFileSync(p, 'utf-8'));
83
+ return {
84
+ stateVersion: Number.isInteger(parsed?.stateVersion) ? parsed.stateVersion : 0,
85
+ applied: Array.isArray(parsed?.applied) ? parsed.applied : [],
86
+ };
87
+ } catch {
88
+ // A corrupt state file is treated as version 0; re-running is idempotent.
89
+ return { stateVersion: 0, applied: [] };
90
+ }
91
+ }
92
+
93
+ function writeMigrationStateAtomic(cwd, state) {
94
+ const dir = join(cwd, '.compose', 'data');
95
+ mkdirSync(dir, { recursive: true });
96
+ const p = join(dir, 'migration-state.json');
97
+ const tmp = `${p}.tmp.${process.pid}`;
98
+ try {
99
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
100
+ renameSync(tmp, p);
101
+ } catch (err) {
102
+ try { unlinkSync(tmp); } catch { /* tmp may not exist */ }
103
+ throw err;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Tracker classification from `.compose/compose.json`:
109
+ * 'local' — tracker absent/null or provider==='local'
110
+ * 'non-local' — an explicit non-local provider (e.g. github)
111
+ * 'unreadable' — config present but not parseable (mirrors factory.js failing
112
+ * fast rather than silently assuming local)
113
+ */
114
+ function trackerKind(cwd) {
115
+ let cfg;
116
+ try {
117
+ cfg = JSON.parse(readFileSync(join(cwd, '.compose', 'compose.json'), 'utf-8'));
118
+ } catch {
119
+ return 'unreadable';
120
+ }
121
+ const t = cfg.tracker;
122
+ if (t === undefined || t === null || !t.provider || t.provider === 'local') return 'local';
123
+ return 'non-local';
124
+ }
125
+
126
+ /**
127
+ * Run pending feature.json state migrations for the workspace at `cwd`.
128
+ *
129
+ * @param {string} cwd - workspace root
130
+ * @param {{dryRun?: boolean}} [opts]
131
+ * @returns {object} report — one of:
132
+ * {skipped:'no-workspace'|'non-local-tracker'}
133
+ * {from, to, dryRun, noop:true, perMigration:[], parseErrors:[]}
134
+ * {from, to, dryRun, perMigration:[{id,version,touched:[]}], parseErrors:[{path,message}]}
135
+ * @throws if a migrateFeature transform throws (migration-code bug) — aborts
136
+ * WITHOUT advancing the stamp.
137
+ */
138
+ export function runStateMigrations(cwd, opts = {}) {
139
+ const dryRun = !!opts.dryRun;
140
+
141
+ // Guard: never write stray state into a non-workspace cwd (e.g. when runUpdate
142
+ // → runInit lands on a process.cwd() that isn't the resolved workspace root).
143
+ if (!existsSync(join(cwd, '.compose', 'compose.json'))) {
144
+ return { skipped: 'no-workspace' };
145
+ }
146
+ const kind = trackerKind(cwd);
147
+ if (kind === 'non-local') return { skipped: 'non-local-tracker' };
148
+ if (kind === 'unreadable') return { skipped: 'unreadable-config' };
149
+
150
+ const { stateVersion: from, applied } = readMigrationState(cwd);
151
+ const pending = MIGRATIONS
152
+ .filter((m) => m.version > from)
153
+ .sort((a, b) => a.version - b.version);
154
+ if (pending.length === 0) {
155
+ return { from, to: from, dryRun, noop: true, perMigration: [], parseErrors: [] };
156
+ }
157
+
158
+ const featuresDir = loadFeaturesDir(cwd); // honors paths.features override
159
+ const featuresRoot = join(cwd, featuresDir);
160
+ const perMigration = pending.map((m) => ({ id: m.id, version: m.version, touched: [] }));
161
+ const parseErrors = [];
162
+
163
+ // Own directory walk — do NOT use listFeatures(): it silently skips unreadable
164
+ // files, which would hide exactly the cold-data corruption we must surface.
165
+ let dirs = [];
166
+ try {
167
+ dirs = readdirSync(featuresRoot, { withFileTypes: true })
168
+ .filter((e) => e.isDirectory())
169
+ .map((e) => e.name);
170
+ } catch {
171
+ dirs = []; // features dir may not exist yet
172
+ }
173
+
174
+ for (const code of dirs) {
175
+ const fpath = join(featuresRoot, code, 'feature.json');
176
+ if (!existsSync(fpath)) continue;
177
+ let feature;
178
+ try {
179
+ feature = JSON.parse(readFileSync(fpath, 'utf-8'));
180
+ } catch (err) {
181
+ parseErrors.push({ path: fpath, message: err.message });
182
+ continue;
183
+ }
184
+ let changedAny = false;
185
+ pending.forEach((m, i) => {
186
+ const res = m.migrateFeature(feature); // pure/total; a throw = migration-code bug → fail-fast
187
+ if (res.changed) {
188
+ feature = res.feature;
189
+ changedAny = true;
190
+ perMigration[i].touched.push(feature.code || code);
191
+ }
192
+ });
193
+ if (changedAny && !dryRun) {
194
+ writeFeature(cwd, feature, featuresDir, { validate: false });
195
+ }
196
+ }
197
+
198
+ const to = pending[pending.length - 1].version;
199
+ if (!dryRun) {
200
+ writeMigrationStateAtomic(cwd, {
201
+ stateVersion: to,
202
+ applied: applied.concat(pending.map((m) => ({ version: m.version, id: m.id }))),
203
+ });
204
+ }
205
+ return { from, to, dryRun, perMigration, parseErrors };
206
+ }
207
+
208
+ /** One-line human summary for the upgrade/init narration. */
209
+ export function summarizeMigrationReport(report) {
210
+ if (!report || report.skipped) return null;
211
+ if (report.noop) return `state up to date (v${report.to})`;
212
+ const touched = report.perMigration.reduce((n, m) => n + m.touched.length, 0);
213
+ const errs = report.parseErrors.length;
214
+ const dry = report.dryRun ? ' (dry-run)' : '';
215
+ return `migrated ${touched} feature.json across ${report.perMigration.length} migration(s) `
216
+ + `→ stateVersion ${report.to}${errs ? `, ${errs} unparseable (reported)` : ''}${dry}`;
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.2.39-beta",
3
+ "version": "0.2.41-beta",
4
4
  "description": "Structured AI dev pipeline — goal-to-product orchestration with gates, iteration loops, and feature lifecycle management.",
5
5
  "author": "SmartMemory",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@ import path from 'node:path';
11
11
  import { ArtifactManager, ARTIFACT_SCHEMAS } from './artifact-manager.js';
12
12
  import { getTargetRoot, getDataDir, resolveProjectPath, switchProject, setCurrentWorkspaceId, loadProjectConfig } from './project-root.js';
13
13
  import { resolveProfile, isToolAllowed } from './mcp-tool-policy.js';
14
+ import { getRoadmap } from '../lib/get-roadmap.js';
14
15
 
15
16
  /**
16
17
  * COMP-MCP-ENFORCE Slice 3 — kill the `force` escape hatch at the MCP tool
@@ -152,6 +153,12 @@ export function loadSessions() {
152
153
  // Tool implementations
153
154
  // ---------------------------------------------------------------------------
154
155
 
156
+ // COMP-MCP-ROADMAP-READ — read-only roadmap reader. Thin wrapper over
157
+ // lib/get-roadmap.js; never mutates the filesystem.
158
+ export function toolGetRoadmap(args = {}) {
159
+ return getRoadmap(getTargetRoot(), args ?? {});
160
+ }
161
+
155
162
  export function toolGetVisionItems({ phase, status, type, keyword, limit = 30 }) {
156
163
  const { items } = loadVisionState();
157
164
 
@@ -28,6 +28,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
28
28
  import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
29
29
  import {
30
30
  toolGetVisionItems,
31
+ toolGetRoadmap,
31
32
  toolGetItemDetail,
32
33
  toolGetPhasesSummary,
33
34
  toolGetBlockedItems,
@@ -358,6 +359,20 @@ const TOOLS = [
358
359
  },
359
360
  },
360
361
  },
362
+ {
363
+ name: 'get_roadmap',
364
+ description: 'Read the current roadmap rendered from canon (feature.json) WITHOUT writing. Returns a status summary, the active/blocked convenience lists, and a staleness flag vs on-disk ROADMAP.md. Pass a status/phase filter or a limit to also get a general `rows` list (e.g. {status:"PLANNED", limit:10} for "what to work on next") — structured rows so callers never re-parse the markdown. Narrative-owned workspaces return the hand-authored file verbatim. Read-only — prefer this over reading ROADMAP.md directly.',
365
+ inputSchema: {
366
+ type: 'object',
367
+ properties: {
368
+ status: { type: 'string', description: 'Filter active/blocked + rows by status (comma-separated): PLANNED, IN_PROGRESS, PARTIAL, BLOCKED, COMPLETE, …' },
369
+ phase: { type: 'string', description: 'Filter active/blocked + rows to a single phase (matched against phaseId)' },
370
+ format: { type: 'string', description: '"summary" (default — counts + lists, token-safe) or "markdown" (full rendered text)' },
371
+ check_drift: { type: 'boolean', description: 'Compare the render against on-disk ROADMAP.md and set stale/drift (default true)' },
372
+ limit: { type: 'integer', minimum: 0, description: 'Cap on the general `rows` list (default 50). Supplying status/phase/limit emits rows[]/rowsTotal/rowsTruncated; without any of them rows is omitted (token-safe summary). A finite value is floored and clamped to ≥ 0.' },
373
+ },
374
+ },
375
+ },
361
376
  {
362
377
  name: 'validate_feature',
363
378
  description: 'Cross-check a single feature against ROADMAP, vision-state, feature.json, folder contents, linked artifacts, and cross-references. Returns structured findings with severity (error/warning/info). FEATURE_NOT_FOUND emitted as a finding (not thrown) when the code matches strict regex but exists in no source.',
@@ -733,6 +748,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
733
748
  case 'add_roadmap_entry': result = await toolAddRoadmapEntry(args); break;
734
749
  case 'set_feature_status': result = await toolSetFeatureStatus(args); break;
735
750
  case 'roadmap_diff': result = await toolRoadmapDiff(args); break;
751
+ case 'get_roadmap': result = toolGetRoadmap(args); break;
736
752
  case 'link_artifact': result = await toolLinkArtifact(args); break;
737
753
  case 'link_features': result = await toolLinkFeatures(args); break;
738
754
  case 'get_feature_artifacts': result = await toolGetFeatureArtifacts(args); break;
@@ -34,7 +34,7 @@ const REVIEWER_ALLOW = [
34
34
  'get_vision_items', 'get_item_detail', 'get_phase_summary', 'get_blocked_items',
35
35
  'get_current_session', 'get_feature_lifecycle', 'get_feature_artifacts', 'get_feature_links',
36
36
  'get_pending_gates', 'get_changelog_entries', 'get_journal_entries', 'get_completions',
37
- 'validate_feature', 'validate_project', 'roadmap_diff', 'roadmap_graph_check', 'assess_feature_artifacts',
37
+ 'validate_feature', 'validate_project', 'roadmap_diff', 'get_roadmap', 'roadmap_graph_check', 'assess_feature_artifacts',
38
38
  'set_workspace', 'get_workspace', 'bind_session',
39
39
  ];
40
40