@smartmemory/compose 0.1.3-beta → 0.1.5-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
@@ -576,13 +576,13 @@ if (cmd === 'new') {
576
576
  if (result.options.reviewAgent === 'Codex (automated review)') {
577
577
  const { pipelineSet } = await import('../lib/pipeline-cli.js')
578
578
  try {
579
- pipelineSet(cwd, 'review_gate', ['--mode', 'review'])
580
- } catch { /* gate may not exist in new.stratum.yaml */ }
579
+ pipelineSet(cwd, 'review_gate', ['--mode', 'review'], 'new.stratum.yaml')
580
+ } catch { /* kickoff spec missing or review_gate already absent */ }
581
581
  } else if (result.options.reviewAgent === 'Skip review') {
582
582
  const { pipelineDisable } = await import('../lib/pipeline-cli.js')
583
583
  try {
584
- pipelineDisable(cwd, ['review_gate'])
585
- } catch { /* ignore */ }
584
+ pipelineDisable(cwd, ['review_gate'], 'new.stratum.yaml')
585
+ } catch { /* kickoff spec missing or review_gate already absent */ }
586
586
  }
587
587
  } else if (hasAnswers && !autoMode) {
588
588
  // Load saved answers to enrich intent without prompting
package/lib/build.js CHANGED
@@ -27,7 +27,8 @@ import { CliProgress } from './cli-progress.js';
27
27
  import { BuildStreamWriter } from './build-stream-writer.js';
28
28
  import { resolveAgentConfig } from './agent-string.js';
29
29
  import { installFactoryShim } from './connector-factory-shim.js';
30
- import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers } from './sections.js';
30
+ import { emitSections as emitPlanSections, appendTrailers as appendSectionTrailers, analyzeRollup, writeRollup } from './sections.js';
31
+ import { SECTIONS_DIR } from './constants.js';
31
32
 
32
33
  import YAML from 'yaml';
33
34
  import { updateFeature, readFeature, writeFeature } from './feature-json.js';
@@ -937,6 +938,7 @@ export async function runBuild(featureCode, opts = {}) {
937
938
  // COMP-PLAN-SECTIONS T7: append "What Was Built" trailers to all
938
939
  // section files after a successful ship. No-op if sections/ doesn't
939
940
  // exist. Wrapped so trailer-append failure never fails the ship.
941
+ let postShipAnalysis = null;
940
942
  try {
941
943
  if (shipResult.commit) {
942
944
  const trailerResult = appendSectionTrailers({
@@ -945,18 +947,44 @@ export async function runBuild(featureCode, opts = {}) {
945
947
  filesChanged: shipResult.filesChanged ?? [],
946
948
  cwd: agentCwd,
947
949
  });
950
+ // COMP-PLAN-SECTIONS-REPORT T4: read-only analyzer feeds the
951
+ // trailer event with `unattributed` and primes writeRollup.
952
+ const sectionsDir = join(featureDir, SECTIONS_DIR);
953
+ postShipAnalysis = analyzeRollup({
954
+ sectionsDir,
955
+ filesChanged: shipResult.filesChanged ?? [],
956
+ });
948
957
  if (trailerResult.trailed?.length > 0) {
949
- streamWriter.write({
958
+ const payload = {
950
959
  type: 'build_sections_trailed',
951
960
  featureCode,
952
961
  count: trailerResult.trailed.length,
953
962
  sections: trailerResult.trailed,
954
- });
963
+ };
964
+ if (postShipAnalysis && Array.isArray(postShipAnalysis.unattributed)) {
965
+ payload.unattributed = postShipAnalysis.unattributed;
966
+ }
967
+ streamWriter.write(payload);
955
968
  }
956
969
  }
957
970
  } catch (err) {
958
971
  try { streamWriter.write({ type: 'build_error', message: `sections trailer append failed: ${err.message}`, stepId: 'ship' }); } catch { /* ignore */ }
959
972
  }
973
+ // COMP-PLAN-SECTIONS-REPORT T4: roll-up write isolated in its own
974
+ // try/catch — failure must not suppress the trailer-success event.
975
+ try {
976
+ if (shipResult.commit && postShipAnalysis) {
977
+ const today = new Date().toISOString().slice(0, 10);
978
+ writeRollup({
979
+ featureDir,
980
+ analysis: postShipAnalysis,
981
+ commit: shipResult.commit,
982
+ date: today,
983
+ });
984
+ }
985
+ } catch (err) {
986
+ try { streamWriter.write({ type: 'build_error', message: `sections rollup write failed: ${err.message}`, stepId: 'ship' }); } catch { /* ignore */ }
987
+ }
960
988
  // COMP-HEALTH: collect plan_completion signal from ship result (if present)
961
989
  if (shipResult.planCompletionPct != null || shipResult.plan_completion_pct != null) {
962
990
  buildSignals.plan_completion = {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * feature-events.js — append-only audit log for feature-management mutations.
3
+ *
4
+ * One JSONL row per mutation. Filed by COMP-MCP-FEATURE-MGMT writers
5
+ * (add_roadmap_entry, set_feature_status, etc.) and read by `roadmap_diff`
6
+ * plus future `validate_feature`.
7
+ *
8
+ * File: <cwd>/.compose/data/feature-events.jsonl
9
+ *
10
+ * Event row shape (additive — extra fields are allowed and preserved by
11
+ * readers, so individual writers can attach context-specific metadata):
12
+ * {
13
+ * ts: ISO string,
14
+ * tool: 'add_roadmap_entry' | 'set_feature_status' | ...,
15
+ * code?: string,
16
+ * from?: string, // status transitions
17
+ * to?: string,
18
+ * reason?: string,
19
+ * actor: string, // process.env.COMPOSE_ACTOR || 'mcp:agent'
20
+ * idempotency_key?: string,
21
+ * ...
22
+ * }
23
+ */
24
+
25
+ import { readFileSync, appendFileSync, existsSync, mkdirSync } from 'fs';
26
+ import { join, dirname } from 'path';
27
+
28
+ function eventsFile(cwd) {
29
+ return join(cwd, '.compose', 'data', 'feature-events.jsonl');
30
+ }
31
+
32
+ function actor() {
33
+ return process.env.COMPOSE_ACTOR || 'mcp:agent';
34
+ }
35
+
36
+ /**
37
+ * Append an event to the audit log. Caller supplies tool + payload; ts and
38
+ * actor are stamped here.
39
+ *
40
+ * @param {string} cwd
41
+ * @param {object} event - must include `tool`; other fields are passed through
42
+ * @returns {object} the row that was written (with ts + actor stamped)
43
+ */
44
+ export function appendEvent(cwd, event) {
45
+ if (!event || typeof event.tool !== 'string' || !event.tool) {
46
+ throw new Error('feature-events.appendEvent: event.tool is required');
47
+ }
48
+
49
+ const path = eventsFile(cwd);
50
+ mkdirSync(dirname(path), { recursive: true });
51
+
52
+ const row = {
53
+ ts: new Date().toISOString(),
54
+ actor: actor(),
55
+ ...event,
56
+ };
57
+ appendFileSync(path, JSON.stringify(row) + '\n');
58
+ return row;
59
+ }
60
+
61
+ /**
62
+ * Read events from the audit log, optionally filtered.
63
+ *
64
+ * @param {string} cwd
65
+ * @param {object} [opts]
66
+ * @param {string|number|Date} [opts.since] - ISO date, ms since epoch, Date,
67
+ * or shorthand '24h' / '7d'. Default: read all.
68
+ * @param {string} [opts.code] - filter to events with `code === <value>`
69
+ * @param {string} [opts.tool] - filter to events with `tool === <value>`
70
+ * @returns {Array<object>}
71
+ */
72
+ export function readEvents(cwd, opts = {}) {
73
+ const path = eventsFile(cwd);
74
+ if (!existsSync(path)) return [];
75
+
76
+ const sinceMs = normalizeSince(opts.since);
77
+ const text = readFileSync(path, 'utf-8');
78
+ const out = [];
79
+ for (const line of text.split('\n')) {
80
+ if (!line.trim()) continue;
81
+ let row;
82
+ try { row = JSON.parse(line); } catch { continue; }
83
+ if (sinceMs !== null) {
84
+ const ts = Date.parse(row.ts);
85
+ if (Number.isNaN(ts) || ts < sinceMs) continue;
86
+ }
87
+ if (opts.code && row.code !== opts.code) continue;
88
+ if (opts.tool && row.tool !== opts.tool) continue;
89
+ out.push(row);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Normalize a since value to milliseconds-since-epoch, or return null for
96
+ * "no filter".
97
+ */
98
+ export function normalizeSince(since) {
99
+ if (since === undefined || since === null) return null;
100
+ if (since instanceof Date) return since.getTime();
101
+ if (typeof since === 'number') return since;
102
+ if (typeof since !== 'string') return null;
103
+
104
+ // Shorthand: "24h" | "7d" | "30m"
105
+ const m = since.match(/^(\d+)([hdm])$/);
106
+ if (m) {
107
+ const n = parseInt(m[1], 10);
108
+ const mult = m[2] === 'h' ? 3600_000 : m[2] === 'd' ? 86_400_000 : 60_000;
109
+ return Date.now() - n * mult;
110
+ }
111
+
112
+ const parsed = Date.parse(since);
113
+ return Number.isNaN(parsed) ? null : parsed;
114
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * feature-writer.js — typed writers for ROADMAP / feature.json mutations.
3
+ *
4
+ * First sub-ticket of COMP-MCP-FEATURE-MGMT (COMP-MCP-ROADMAP-WRITER).
5
+ *
6
+ * Three operations:
7
+ * addRoadmapEntry(cwd, args) — register a new feature, regenerate ROADMAP
8
+ * setFeatureStatus(cwd, args) — flip status with transition policy enforcement
9
+ * roadmapDiff(cwd, args) — read the audit log for a window
10
+ *
11
+ * All writes go through feature.json (canonical) + writeRoadmap()
12
+ * (regenerates ROADMAP.md). Mutations append to the feature-events.jsonl
13
+ * audit log. Idempotency keys protect against retries.
14
+ *
15
+ * No HTTP, no transport awareness — pure data + IO so the same writers can
16
+ * be called from MCP tools, the CLI, or future REST routes.
17
+ */
18
+
19
+ import { readFeature, writeFeature, listFeatures, updateFeature } from './feature-json.js';
20
+ // eslint-disable-next-line no-unused-vars
21
+ const _listFeatures = listFeatures;
22
+ import { writeRoadmap } from './roadmap-gen.js';
23
+ import { appendEvent, readEvents } from './feature-events.js';
24
+ import { checkOrInsert } from './idempotency.js';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Status / transition policy
28
+ // ---------------------------------------------------------------------------
29
+
30
+ const STATUSES = new Set([
31
+ 'PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE',
32
+ 'BLOCKED', 'KILLED', 'PARKED', 'SUPERSEDED',
33
+ ]);
34
+
35
+ // COMPLETE -> SUPERSEDED is force-only (per design Decision 6); not in the
36
+ // normal transitions list. Force flag bypasses the policy and is recorded in
37
+ // audit.
38
+ const TRANSITIONS = {
39
+ PLANNED: ['IN_PROGRESS', 'KILLED', 'PARKED'],
40
+ IN_PROGRESS: ['PARTIAL', 'COMPLETE', 'BLOCKED', 'KILLED', 'PARKED'],
41
+ PARTIAL: ['IN_PROGRESS', 'COMPLETE', 'KILLED'],
42
+ COMPLETE: [],
43
+ BLOCKED: ['IN_PROGRESS', 'KILLED', 'PARKED'],
44
+ PARKED: ['PLANNED', 'KILLED'],
45
+ KILLED: [],
46
+ SUPERSEDED: [],
47
+ };
48
+
49
+ const COMPLEXITIES = new Set(['S', 'M', 'L', 'XL']);
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Helpers
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const FEATURE_CODE_RE = /^[A-Z][A-Z0-9-]*[A-Z0-9]$/;
56
+
57
+ function validateCode(code) {
58
+ if (typeof code !== 'string' || !FEATURE_CODE_RE.test(code)) {
59
+ throw new Error(`feature-writer: invalid feature code "${code}" — must match ${FEATURE_CODE_RE}`);
60
+ }
61
+ }
62
+
63
+ function maybeIdempotent(args, fn) {
64
+ if (args.idempotency_key) {
65
+ return checkOrInsert(args.cwd, args.idempotency_key, fn).then(({ result }) => result);
66
+ }
67
+ return Promise.resolve().then(fn);
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // addRoadmapEntry
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * @param {string} cwd
76
+ * @param {object} args
77
+ * @param {string} args.code
78
+ * @param {string} args.description
79
+ * @param {string} args.phase
80
+ * @param {string} [args.complexity]
81
+ * @param {string} [args.status]
82
+ * @param {number} [args.position]
83
+ * @param {string} [args.parent]
84
+ * @param {string[]} [args.tags]
85
+ * @param {string} [args.idempotency_key]
86
+ */
87
+ export async function addRoadmapEntry(cwd, args) {
88
+ validateCode(args.code);
89
+ if (!args.description) throw new Error('feature-writer: description is required');
90
+ if (!args.phase) throw new Error('feature-writer: phase is required');
91
+ if (args.complexity && !COMPLEXITIES.has(args.complexity)) {
92
+ throw new Error(`feature-writer: invalid complexity "${args.complexity}"`);
93
+ }
94
+ const status = args.status ?? 'PLANNED';
95
+ if (!STATUSES.has(status)) {
96
+ throw new Error(`feature-writer: invalid status "${status}"`);
97
+ }
98
+
99
+ return maybeIdempotent({ ...args, cwd }, () => {
100
+ const existing = readFeature(cwd, args.code);
101
+ if (existing) {
102
+ throw new Error(`feature-writer: feature "${args.code}" already exists`);
103
+ }
104
+
105
+ const today = new Date().toISOString().slice(0, 10);
106
+ const feature = {
107
+ code: args.code,
108
+ description: args.description,
109
+ status,
110
+ phase: args.phase,
111
+ created: today,
112
+ updated: today,
113
+ };
114
+ if (args.complexity) feature.complexity = args.complexity;
115
+ feature.position = args.position !== undefined
116
+ ? args.position
117
+ : nextPositionInPhase(cwd, args.phase);
118
+ if (args.parent) feature.parent = args.parent;
119
+ if (args.tags && args.tags.length) feature.tags = args.tags;
120
+
121
+ writeFeature(cwd, feature);
122
+ const roadmapPath = writeRoadmap(cwd);
123
+
124
+ safeAppendEvent(cwd, {
125
+ tool: 'add_roadmap_entry',
126
+ code: args.code,
127
+ to: status,
128
+ phase: args.phase,
129
+ idempotency_key: args.idempotency_key,
130
+ });
131
+
132
+ return {
133
+ code: args.code,
134
+ phase: args.phase,
135
+ position: feature.position,
136
+ roadmap_path: roadmapPath,
137
+ };
138
+ });
139
+ }
140
+
141
+ // Default position for a new feature: max existing position in the same
142
+ // phase, plus 1. Falls back to 1 when the phase is empty.
143
+ function nextPositionInPhase(cwd, phase) {
144
+ const peers = _listFeatures(cwd).filter(f => f.phase === phase);
145
+ if (peers.length === 0) return 1;
146
+ const maxPos = peers.reduce((m, f) => {
147
+ const p = typeof f.position === 'number' ? f.position : 0;
148
+ return p > m ? p : m;
149
+ }, 0);
150
+ return maxPos + 1;
151
+ }
152
+
153
+ // Audit-log writes are best-effort: a failed append must NOT roll back a
154
+ // committed mutation (per design Decision 2 and docs/mcp.md). Log a warning
155
+ // and continue.
156
+ function safeAppendEvent(cwd, event) {
157
+ try {
158
+ appendEvent(cwd, event);
159
+ } catch (err) {
160
+ // eslint-disable-next-line no-console
161
+ console.warn(`[feature-writer] audit append failed for ${event.tool} ${event.code ?? ''}: ${err.message}`);
162
+ }
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // setFeatureStatus
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * @param {string} cwd
171
+ * @param {object} args
172
+ * @param {string} args.code
173
+ * @param {string} args.status
174
+ * @param {string} [args.reason]
175
+ * @param {string} [args.commit_sha]
176
+ * @param {boolean} [args.force]
177
+ * @param {string} [args.idempotency_key]
178
+ */
179
+ export async function setFeatureStatus(cwd, args) {
180
+ validateCode(args.code);
181
+ if (!STATUSES.has(args.status)) {
182
+ throw new Error(`feature-writer: invalid status "${args.status}" — must be one of ${[...STATUSES].join(', ')}`);
183
+ }
184
+
185
+ return maybeIdempotent({ ...args, cwd }, () => {
186
+ const feature = readFeature(cwd, args.code);
187
+ if (!feature) {
188
+ throw new Error(`feature-writer: feature "${args.code}" not found`);
189
+ }
190
+
191
+ const from = feature.status;
192
+ const to = args.status;
193
+
194
+ if (from === to) {
195
+ return { code: args.code, from, to, ts: new Date().toISOString(), noop: true };
196
+ }
197
+
198
+ const allowed = TRANSITIONS[from] ?? [];
199
+ if (!allowed.includes(to) && !args.force) {
200
+ throw new Error(
201
+ `feature-writer: invalid transition for ${args.code}: ${from} → ${to}. ` +
202
+ `Allowed from ${from}: [${allowed.join(', ') || 'none'}]. ` +
203
+ `Pass force: true to override.`
204
+ );
205
+ }
206
+
207
+ const updates = { status: to };
208
+ if (args.commit_sha) updates.commit_sha = args.commit_sha;
209
+ updateFeature(cwd, args.code, updates);
210
+ writeRoadmap(cwd);
211
+
212
+ const event = {
213
+ tool: 'set_feature_status',
214
+ code: args.code,
215
+ from,
216
+ to,
217
+ idempotency_key: args.idempotency_key,
218
+ };
219
+ if (args.reason) event.reason = args.reason;
220
+ if (args.commit_sha) event.commit_sha = args.commit_sha;
221
+ if (args.force && !allowed.includes(to)) event.forced = true;
222
+ safeAppendEvent(cwd, event);
223
+
224
+ return { code: args.code, from, to, ts: new Date().toISOString() };
225
+ });
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // roadmapDiff
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /**
233
+ * @param {string} cwd
234
+ * @param {object} [args]
235
+ * @param {string|number|Date} [args.since='24h']
236
+ * @param {string} [args.feature_code]
237
+ * @param {string} [args.tool]
238
+ */
239
+ export function roadmapDiff(cwd, args = {}) {
240
+ const since = args.since ?? '24h';
241
+ const events = readEvents(cwd, {
242
+ since,
243
+ code: args.feature_code,
244
+ tool: args.tool,
245
+ });
246
+
247
+ const added = [];
248
+ const status_changed = [];
249
+ for (const e of events) {
250
+ if (e.tool === 'add_roadmap_entry' && e.code) {
251
+ added.push(e.code);
252
+ }
253
+ if (e.tool === 'set_feature_status' && e.code && e.from !== e.to) {
254
+ status_changed.push({ code: e.code, from: e.from, to: e.to });
255
+ }
256
+ }
257
+
258
+ return { events, added, status_changed };
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Exports for tests / introspection
263
+ // ---------------------------------------------------------------------------
264
+
265
+ export const _internals = { TRANSITIONS, STATUSES, COMPLEXITIES };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * idempotency.js — caller-provided idempotency keys with persistent cache.
3
+ *
4
+ * Used by the feature-management writers (COMP-MCP-FEATURE-MGMT) so that the
5
+ * same logical operation invoked twice with the same key returns the first
6
+ * result without re-mutating state.
7
+ *
8
+ * Cache file: <cwd>/.compose/data/idempotency-keys.jsonl
9
+ * Lock file: <cwd>/.compose/data/idempotency-keys.lock (advisory mkdir lock)
10
+ *
11
+ * Each cache row is JSON: { key, result, ts }.
12
+ * Cap at MAX_ENTRIES (default 1000). When the cap is exceeded, the oldest
13
+ * entries are dropped on the next write. No background sweep — drop happens
14
+ * inline so we never block on large rewrites unnecessarily.
15
+ */
16
+
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'fs';
18
+ import { join, dirname } from 'path';
19
+
20
+ const MAX_ENTRIES = 1000;
21
+ const LOCK_TIMEOUT_MS = 5000;
22
+ const LOCK_RETRY_MS = 25;
23
+
24
+ function cacheFile(cwd) {
25
+ return join(cwd, '.compose', 'data', 'idempotency-keys.jsonl');
26
+ }
27
+
28
+ function lockFile(cwd) {
29
+ return join(cwd, '.compose', 'data', 'idempotency-keys.lock');
30
+ }
31
+
32
+ function ensureDir(file) {
33
+ mkdirSync(dirname(file), { recursive: true });
34
+ }
35
+
36
+ /**
37
+ * Acquire an advisory lock by creating a directory (atomic on POSIX). Caller
38
+ * is responsible for releasing via the returned function. Stale locks older
39
+ * than LOCK_TIMEOUT_MS are forcibly cleared so a crashed prior holder can't
40
+ * deadlock the next caller.
41
+ */
42
+ async function acquireLock(cwd) {
43
+ const path = lockFile(cwd);
44
+ ensureDir(path);
45
+
46
+ const start = Date.now();
47
+ // eslint-disable-next-line no-constant-condition
48
+ while (true) {
49
+ try {
50
+ mkdirSync(path);
51
+ return () => {
52
+ try { rmSync(path, { recursive: true, force: true }); } catch { /* best-effort */ }
53
+ };
54
+ } catch (err) {
55
+ if (err.code !== 'EEXIST') throw err;
56
+ // Stale lock recovery: if older than timeout, clear and retry.
57
+ try {
58
+ const { mtimeMs } = (await import('fs')).statSync(path);
59
+ if (Date.now() - mtimeMs > LOCK_TIMEOUT_MS) {
60
+ rmSync(path, { recursive: true, force: true });
61
+ continue;
62
+ }
63
+ } catch { /* stat raced; loop and retry */ }
64
+
65
+ if (Date.now() - start > LOCK_TIMEOUT_MS) {
66
+ throw new Error(`idempotency lock timeout after ${LOCK_TIMEOUT_MS}ms: ${path}`);
67
+ }
68
+ await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
69
+ }
70
+ }
71
+ }
72
+
73
+ function readEntries(cwd) {
74
+ const path = cacheFile(cwd);
75
+ if (!existsSync(path)) return [];
76
+ const text = readFileSync(path, 'utf-8');
77
+ const out = [];
78
+ for (const line of text.split('\n')) {
79
+ if (!line.trim()) continue;
80
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function writeEntries(cwd, entries) {
86
+ const path = cacheFile(cwd);
87
+ ensureDir(path);
88
+ const trimmed = entries.length > MAX_ENTRIES
89
+ ? entries.slice(entries.length - MAX_ENTRIES)
90
+ : entries;
91
+ const body = trimmed.map(e => JSON.stringify(e)).join('\n') + (trimmed.length ? '\n' : '');
92
+ writeFileSync(path, body);
93
+ }
94
+
95
+ /**
96
+ * Run computeFn() at most once for a given key. If the key has been seen
97
+ * before, return the cached result without invoking computeFn. Caller must
98
+ * supply a stable key derived from the operation's intent.
99
+ *
100
+ * Intentionally async because the lock acquisition is async; computeFn may
101
+ * be sync or async.
102
+ *
103
+ * @param {string} cwd
104
+ * @param {string} key
105
+ * @param {() => any | Promise<any>} computeFn
106
+ * @returns {Promise<{ result: any, cached: boolean }>}
107
+ */
108
+ export async function checkOrInsert(cwd, key, computeFn) {
109
+ if (typeof key !== 'string' || key.length === 0) {
110
+ throw new Error('idempotency: key must be a non-empty string');
111
+ }
112
+
113
+ const release = await acquireLock(cwd);
114
+ try {
115
+ const entries = readEntries(cwd);
116
+ const hit = entries.find(e => e.key === key);
117
+ if (hit) {
118
+ return { result: hit.result, cached: true };
119
+ }
120
+
121
+ const result = await computeFn();
122
+ entries.push({ key, result, ts: new Date().toISOString() });
123
+ writeEntries(cwd, entries);
124
+ return { result, cached: false };
125
+ } finally {
126
+ release();
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Test/diagnostic helper: clear the cache. Not exposed via MCP.
132
+ */
133
+ export function _resetIdempotency(cwd) {
134
+ const path = cacheFile(cwd);
135
+ if (existsSync(path)) rmSync(path);
136
+ const lock = lockFile(cwd);
137
+ if (existsSync(lock)) rmSync(lock, { recursive: true, force: true });
138
+ }
@@ -20,12 +20,13 @@ import { parse, stringify } from 'yaml'
20
20
  // Helpers
21
21
  // ---------------------------------------------------------------------------
22
22
 
23
- function loadSpec(cwd) {
24
- const specPath = join(cwd, 'pipelines', 'build.stratum.yaml')
23
+ function loadSpec(cwd, specName = 'build.stratum.yaml') {
24
+ const specPath = join(cwd, 'pipelines', specName)
25
25
  if (!existsSync(specPath)) {
26
26
  throw new Error(`No pipeline found at ${specPath}. Run 'compose init' first.`)
27
27
  }
28
- return { specPath, spec: parse(readFileSync(specPath, 'utf-8')) }
28
+ const flowName = specName.replace(/\.stratum\.yaml$/, '')
29
+ return { specPath, spec: parse(readFileSync(specPath, 'utf-8')), flowName }
29
30
  }
30
31
 
31
32
  function saveSpec(specPath, spec) {
@@ -54,12 +55,12 @@ const LEVEL_COLORS = {
54
55
  }
55
56
  const RESET = '\x1b[0m'
56
57
 
57
- export function pipelineShow(cwd) {
58
- const { spec } = loadSpec(cwd)
59
- const mainFlow = spec.flows?.build
60
- if (!mainFlow) throw new Error('No "build" flow found in pipeline spec.')
58
+ export function pipelineShow(cwd, specName = 'build.stratum.yaml') {
59
+ const { spec, flowName } = loadSpec(cwd, specName)
60
+ const mainFlow = spec.flows?.[flowName]
61
+ if (!mainFlow) throw new Error(`No "${flowName}" flow found in pipeline spec.`)
61
62
 
62
- console.log(`\n Pipeline: build (${mainFlow.steps.length} steps)\n`)
63
+ console.log(`\n Pipeline: ${flowName} (${mainFlow.steps.length} steps)\n`)
63
64
 
64
65
  for (const step of mainFlow.steps) {
65
66
  const isGate = !!step.function
@@ -133,10 +134,10 @@ function gateTimeout(spec, funcName) {
133
134
  // set
134
135
  // ---------------------------------------------------------------------------
135
136
 
136
- export function pipelineSet(cwd, stepId, flags) {
137
- const { specPath, spec } = loadSpec(cwd)
138
- const mainFlow = spec.flows?.build
139
- if (!mainFlow) throw new Error('No "build" flow found.')
137
+ export function pipelineSet(cwd, stepId, flags, specName = 'build.stratum.yaml') {
138
+ const { specPath, spec, flowName } = loadSpec(cwd, specName)
139
+ const mainFlow = spec.flows?.[flowName]
140
+ if (!mainFlow) throw new Error(`No "${flowName}" flow found.`)
140
141
 
141
142
  const { step, idx } = findStep(mainFlow.steps, stepId)
142
143
 
@@ -305,10 +306,10 @@ function convertToAgent(spec, mainFlow, step, stepId) {
305
306
  // add
306
307
  // ---------------------------------------------------------------------------
307
308
 
308
- export function pipelineAdd(cwd, flags) {
309
- const { specPath, spec } = loadSpec(cwd)
310
- const mainFlow = spec.flows?.build
311
- if (!mainFlow) throw new Error('No "build" flow found.')
309
+ export function pipelineAdd(cwd, flags, specName = 'build.stratum.yaml') {
310
+ const { specPath, spec, flowName } = loadSpec(cwd, specName)
311
+ const mainFlow = spec.flows?.[flowName]
312
+ if (!mainFlow) throw new Error(`No "${flowName}" flow found.`)
312
313
 
313
314
  const id = flagVal(flags, '--id')
314
315
  const after = flagVal(flags, '--after')
@@ -353,10 +354,10 @@ export function pipelineAdd(cwd, flags) {
353
354
  // remove
354
355
  // ---------------------------------------------------------------------------
355
356
 
356
- export function pipelineRemove(cwd, stepId) {
357
- const { specPath, spec } = loadSpec(cwd)
358
- const mainFlow = spec.flows?.build
359
- if (!mainFlow) throw new Error('No "build" flow found.')
357
+ export function pipelineRemove(cwd, stepId, specName = 'build.stratum.yaml') {
358
+ const { specPath, spec, flowName } = loadSpec(cwd, specName)
359
+ const mainFlow = spec.flows?.[flowName]
360
+ if (!mainFlow) throw new Error(`No "${flowName}" flow found.`)
360
361
 
361
362
  const { step, idx } = findStep(mainFlow.steps, stepId)
362
363
 
@@ -388,10 +389,10 @@ export function pipelineRemove(cwd, stepId) {
388
389
  // enable / disable
389
390
  // ---------------------------------------------------------------------------
390
391
 
391
- export function pipelineEnable(cwd, stepIds) {
392
- const { specPath, spec } = loadSpec(cwd)
393
- const mainFlow = spec.flows?.build
394
- if (!mainFlow) throw new Error('No "build" flow found.')
392
+ export function pipelineEnable(cwd, stepIds, specName = 'build.stratum.yaml') {
393
+ const { specPath, spec, flowName } = loadSpec(cwd, specName)
394
+ const mainFlow = spec.flows?.[flowName]
395
+ if (!mainFlow) throw new Error(`No "${flowName}" flow found.`)
395
396
 
396
397
  for (const stepId of stepIds) {
397
398
  const { step } = findStep(mainFlow.steps, stepId)
@@ -403,10 +404,10 @@ export function pipelineEnable(cwd, stepIds) {
403
404
  saveSpec(specPath, spec)
404
405
  }
405
406
 
406
- export function pipelineDisable(cwd, stepIds) {
407
- const { specPath, spec } = loadSpec(cwd)
408
- const mainFlow = spec.flows?.build
409
- if (!mainFlow) throw new Error('No "build" flow found.')
407
+ export function pipelineDisable(cwd, stepIds, specName = 'build.stratum.yaml') {
408
+ const { specPath, spec, flowName } = loadSpec(cwd, specName)
409
+ const mainFlow = spec.flows?.[flowName]
410
+ if (!mainFlow) throw new Error(`No "${flowName}" flow found.`)
410
411
 
411
412
  for (const stepId of stepIds) {
412
413
  const { step } = findStep(mainFlow.steps, stepId)
@@ -5,7 +5,9 @@
5
5
  * from the markdown table format used by Compose roadmaps.
6
6
  */
7
7
 
8
- const SKIP_STATUSES = new Set(['COMPLETE', 'SUPERSEDED', 'PARKED']);
8
+ // Statuses that exclude a feature from the buildable list. KILLED is
9
+ // terminal; BLOCKED isn't buildable until unblocked.
10
+ const SKIP_STATUSES = new Set(['COMPLETE', 'SUPERSEDED', 'PARKED', 'KILLED', 'BLOCKED']);
9
11
 
10
12
  const PHASE_HEADING_RE = /^##\s+(.+?)(?:\s+—\s+(.+))?$/;
11
13
  const MILESTONE_HEADING_RE = /^###\s+(.+?)(?:\s*:\s*(.+))?$/;
package/lib/sections.js CHANGED
@@ -323,3 +323,191 @@ export function appendTrailers({ featureDir, commit, filesChanged, cwd, diffStat
323
323
 
324
324
  return result;
325
325
  }
326
+
327
+ // ---------- COMP-PLAN-SECTIONS-REPORT: roll-up ----------
328
+
329
+ const SECTION_FILE_RE = /^section-(\d+)-.+\.md$/;
330
+ const ROLLUP_HEADING_RE = /^## Section Roll-up\b/m;
331
+ const ROLLUP_NEXT_HEADING_RE = /^## /m;
332
+
333
+ function parseSectionTitle(content, filename) {
334
+ // Expect H1 like: `# Section NN — <title>`. Fallback to filename slug.
335
+ const m = content.match(/^#\s+Section\s+\d+\s*[—\-:.]\s*(.+?)\s*$/m);
336
+ if (m && m[1]) return m[1].trim();
337
+ // Filename slug fallback: strip section-NN- prefix and .md suffix.
338
+ const fm = filename.match(/^section-\d+-(.+)\.md$/);
339
+ return fm ? fm[1] : filename;
340
+ }
341
+
342
+ function todayIso() {
343
+ const d = new Date();
344
+ const y = d.getFullYear();
345
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
346
+ const da = String(d.getDate()).padStart(2, '0');
347
+ return `${y}-${mo}-${da}`;
348
+ }
349
+
350
+ /**
351
+ * analyzeRollup({ sectionsDir, filesChanged }) — read-only analyzer.
352
+ *
353
+ * Returns null when sectionsDir is absent OR contains no `section-NN-*.md`
354
+ * files. Otherwise returns:
355
+ * {
356
+ * sections: [{ filename, title, declared, changed, missing }],
357
+ * unattributed: string[],
358
+ * sectionCount: number,
359
+ * sectionsWithChanges: number, // declared.length > 0 && changed.length === declared.length
360
+ * sectionsAllUnchanged: number, // declared.length > 0 && changed.length === 0
361
+ * }
362
+ */
363
+ export function analyzeRollup({ sectionsDir, filesChanged } = {}) {
364
+ if (!sectionsDir || !fs.existsSync(sectionsDir)) return null;
365
+
366
+ const entries = fs
367
+ .readdirSync(sectionsDir)
368
+ .filter(f => SECTION_FILE_RE.test(f))
369
+ .sort();
370
+ if (entries.length === 0) return null;
371
+
372
+ const changedSet = new Set(Array.isArray(filesChanged) ? filesChanged : []);
373
+ const declaredUnion = new Set();
374
+ const sections = [];
375
+ let sectionsWithChanges = 0;
376
+ let sectionsAllUnchanged = 0;
377
+
378
+ for (const filename of entries) {
379
+ const fullPath = path.join(sectionsDir, filename);
380
+ const content = fs.readFileSync(fullPath, 'utf8');
381
+ const declared = readDeclaredFiles(content);
382
+ for (const f of declared) declaredUnion.add(f);
383
+ const changed = declared.filter(f => changedSet.has(f));
384
+ const missing = declared.filter(f => !changedSet.has(f));
385
+ const title = parseSectionTitle(content, filename);
386
+ if (declared.length > 0 && changed.length === declared.length) sectionsWithChanges++;
387
+ if (declared.length > 0 && changed.length === 0) sectionsAllUnchanged++;
388
+ sections.push({ filename, title, declared, changed, missing });
389
+ }
390
+
391
+ const unattributed = [];
392
+ for (const f of changedSet) {
393
+ if (!declaredUnion.has(f)) unattributed.push(f);
394
+ }
395
+
396
+ return {
397
+ sections,
398
+ unattributed,
399
+ sectionCount: sections.length,
400
+ sectionsWithChanges,
401
+ sectionsAllUnchanged,
402
+ };
403
+ }
404
+
405
+ /**
406
+ * renderRollupBlock({ analysis, commit, date }) — pure markdown renderer.
407
+ * No I/O. Returns the full `## Section Roll-up` block with trailing newline.
408
+ */
409
+ export function renderRollupBlock({ analysis, commit, date } = {}) {
410
+ const shortSha = commit && typeof commit === 'string' && commit.length > 0
411
+ ? `\`${commit.slice(0, 7)}\``
412
+ : '(commit unavailable)';
413
+ const dateStr = date && typeof date === 'string' && date ? date : todayIso();
414
+
415
+ const sections = analysis?.sections ?? [];
416
+ const unattributed = analysis?.unattributed ?? [];
417
+ const sectionCount = analysis?.sectionCount ?? 0;
418
+ const sectionsWithChanges = analysis?.sectionsWithChanges ?? 0;
419
+ const sectionsAllUnchanged = analysis?.sectionsAllUnchanged ?? 0;
420
+
421
+ // Section NN derived from filename prefix.
422
+ const indexLines = sections.map(s => {
423
+ const numMatch = s.filename.match(/^section-(\d+)-/);
424
+ const nn = numMatch ? numMatch[1] : '??';
425
+ const changedCount = s.changed?.length ?? 0;
426
+ const declaredCount = s.declared?.length ?? 0;
427
+ return `- [Section ${nn} — ${s.title}](sections/${s.filename}) — \`${changedCount}/${declaredCount}\` files changed`;
428
+ });
429
+
430
+ const unattribLines = unattributed.length
431
+ ? unattributed.map(f => `- \`${f}\``)
432
+ : ['None'];
433
+
434
+ const lines = [
435
+ `## Section Roll-up`,
436
+ ``,
437
+ `**Commit:** ${shortSha}`,
438
+ `**Date:** ${dateStr}`,
439
+ `**Sections:** ${sectionCount} total — ${sectionsWithChanges} with changes / ${sectionsAllUnchanged} with no declared changes`,
440
+ ``,
441
+ `### Index`,
442
+ ``,
443
+ ...indexLines,
444
+ ``,
445
+ `### Unattributed files this commit`,
446
+ ``,
447
+ ...unattribLines,
448
+ ``,
449
+ `### Deviations summary`,
450
+ ``,
451
+ `- **Sections with all declared files changed:** ${sectionsWithChanges}`,
452
+ `- **Sections with declared files that did NOT change:** ${sectionsAllUnchanged}`,
453
+ `- **Files changed but undeclared:** ${unattributed.length}`,
454
+ ``,
455
+ ];
456
+ return lines.join('\n');
457
+ }
458
+
459
+ /**
460
+ * writeRollup({ featureDir, analysis, commit, date }) — atomic same-directory
461
+ * temp+rename writer for `<featureDir>/report.md`.
462
+ *
463
+ * Returns null when analysis is null OR sectionCount === 0.
464
+ * Otherwise replaces an existing `## Section Roll-up` block in place
465
+ * (boundary: heading → next `^## ` heading or EOF) or appends if absent.
466
+ * Returns { written: true, path }.
467
+ */
468
+ export function writeRollup({ featureDir, analysis, commit, date } = {}) {
469
+ if (!featureDir) return null;
470
+ if (!analysis || analysis.sectionCount === 0) return null;
471
+
472
+ const block = renderRollupBlock({ analysis, commit, date });
473
+ const reportPath = path.join(featureDir, 'report.md');
474
+ const tmpPath = path.join(featureDir, 'report.md.tmp');
475
+
476
+ let existing = '';
477
+ if (fs.existsSync(reportPath)) {
478
+ existing = fs.readFileSync(reportPath, 'utf8');
479
+ }
480
+
481
+ let next;
482
+ const headingMatch = existing.match(ROLLUP_HEADING_RE);
483
+ if (headingMatch && typeof headingMatch.index === 'number') {
484
+ const start = headingMatch.index;
485
+ const after = existing.slice(start + headingMatch[0].length);
486
+ const nextMatch = after.match(ROLLUP_NEXT_HEADING_RE);
487
+ let endRel;
488
+ if (nextMatch && typeof nextMatch.index === 'number') {
489
+ endRel = start + headingMatch[0].length + nextMatch.index;
490
+ } else {
491
+ endRel = existing.length;
492
+ }
493
+ const before = existing.slice(0, start);
494
+ const tail = existing.slice(endRel);
495
+ next = before + block + (tail.startsWith('\n') || !tail ? tail : '\n' + tail);
496
+ } else if (existing.length === 0) {
497
+ next = block;
498
+ } else {
499
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
500
+ next = existing + sep + block;
501
+ }
502
+
503
+ fs.mkdirSync(featureDir, { recursive: true });
504
+ try {
505
+ fs.writeFileSync(tmpPath, next);
506
+ fs.renameSync(tmpPath, reportPath);
507
+ } catch (err) {
508
+ try { if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath); } catch { /* ignore */ }
509
+ throw err;
510
+ }
511
+
512
+ return { written: true, path: reportPath };
513
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smartmemory/compose",
3
- "version": "0.1.3-beta",
3
+ "version": "0.1.5-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",
@@ -0,0 +1,182 @@
1
+ version: "0.2"
2
+
3
+ workflow:
4
+ name: new
5
+ description: "Product kickoff — research, brainstorm, and scaffold a new project from intent"
6
+ input:
7
+ projectName:
8
+ type: string
9
+ required: true
10
+ intent:
11
+ type: string
12
+ required: true
13
+
14
+ contracts:
15
+ ResearchResult:
16
+ priorArt: {type: array}
17
+ patterns: {type: array}
18
+ risks: {type: array}
19
+ summary: {type: string}
20
+
21
+ BrainstormResult:
22
+ features: {type: array}
23
+ userStories: {type: array}
24
+ archOptions: {type: array}
25
+ summary: {type: string}
26
+
27
+ RoadmapResult:
28
+ phases: {type: array}
29
+ features: {type: array}
30
+ summary: {type: string}
31
+ artifact: {type: string}
32
+
33
+ ScaffoldResult:
34
+ created: {type: array}
35
+ summary: {type: string}
36
+
37
+ functions:
38
+ review_gate:
39
+ mode: gate
40
+ timeout: 7200
41
+
42
+ roadmap_gate:
43
+ mode: gate
44
+ timeout: 3600
45
+
46
+ flows:
47
+ new:
48
+ input:
49
+ projectName: {type: string}
50
+ intent: {type: string}
51
+ output: ScaffoldResult
52
+ steps:
53
+ # Phase: Research prior art
54
+ - id: research
55
+ agent: claude
56
+ intent: >
57
+ Research prior art for this product idea. Search the web for:
58
+ 1. Existing tools that solve the same or similar problems
59
+ 2. Common architectural patterns used in this domain
60
+ 3. Known pitfalls and risks
61
+
62
+ Write your findings to docs/discovery/research.md.
63
+ If docs/discovery/research.md already exists, read it and build on it
64
+ rather than starting from scratch.
65
+ inputs:
66
+ intent: "$.input.intent"
67
+ output_contract: ResearchResult
68
+ ensure:
69
+ - "file_exists('docs/discovery/research.md')"
70
+ validate:
71
+ artifact: docs/discovery/research.md
72
+ criteria:
73
+ - "Contains at least 2 existing tools or prior art entries"
74
+ - "Mentions architectural patterns or common approaches"
75
+ - "Lists risks or pitfalls"
76
+ retries: 3
77
+
78
+ # Phase: Brainstorm features
79
+ - id: brainstorm
80
+ agent: claude
81
+ intent: >
82
+ Given the product intent (and any available research findings),
83
+ brainstorm the product. Generate:
84
+ 1. A feature list — discrete capabilities the product should have,
85
+ each with a short code (e.g. LOG-1, LOG-2) and description.
86
+ Order features by dependency: foundational first, advanced later.
87
+ 2. User stories — "As a <user>, I want <goal>, so that <benefit>"
88
+ 3. Architecture options — 2-3 high-level approaches with trade-offs
89
+
90
+ If docs/discovery/research.md exists, read it first for prior-art
91
+ context. Otherwise proceed from the product intent alone — research
92
+ is an optional input, not a hard requirement (the questionnaire can
93
+ skip it).
94
+
95
+ Write the full brainstorm to docs/discovery/brainstorm.md.
96
+ If docs/discovery/brainstorm.md already exists, read it and refine it
97
+ rather than starting from scratch.
98
+ inputs:
99
+ intent: "$.input.intent"
100
+ research: "$.steps.research.output.summary" # may be null when research is skipped
101
+ output_contract: BrainstormResult
102
+ ensure:
103
+ - "file_exists('docs/discovery/brainstorm.md')"
104
+ validate:
105
+ artifact: docs/discovery/brainstorm.md
106
+ criteria:
107
+ - "Contains at least 3 features with short codes (e.g. LOG-1, LOG-2)"
108
+ - "Contains user stories in 'As a... I want... so that...' format"
109
+ - "Contains at least 2 architecture options with trade-offs"
110
+ retries: 3
111
+ depends_on: [research]
112
+
113
+ # Gate: Human reviews brainstorm
114
+ - id: review_gate
115
+ function: review_gate
116
+ on_approve: roadmap
117
+ on_revise: brainstorm
118
+ on_kill: null
119
+ depends_on: [brainstorm]
120
+
121
+ # Phase: Structure the roadmap
122
+ - id: roadmap
123
+ agent: claude
124
+ intent: >
125
+ Take the approved brainstorm and structure it into a phased ROADMAP.
126
+
127
+ First read docs/discovery/brainstorm.md for the full brainstorm.
128
+ If ROADMAP.md already exists, read it and update it rather than overwriting.
129
+
130
+ Write ROADMAP.md in the project root with:
131
+ 1. Project name and description
132
+ 2. Roadmap conventions (status values, numbering rules)
133
+ 3. Phases grouping features by dependency order
134
+ 4. Each feature as a numbered row with its code, description, and PLANNED status
135
+
136
+ Follow this table format for each phase:
137
+ | # | Feature | Item | Status |
138
+ |---|---------|------|--------|
139
+ | 1 | CODE-1 | Description | PLANNED |
140
+ inputs:
141
+ intent: "$.input.intent"
142
+ brainstorm: "$.steps.brainstorm.output.summary"
143
+ output_contract: RoadmapResult
144
+ ensure:
145
+ - "file_exists('ROADMAP.md')"
146
+ validate:
147
+ artifact: ROADMAP.md
148
+ criteria:
149
+ - "Contains a markdown table with feature codes and status columns"
150
+ - "Features are organized into phases"
151
+ - "All features have PLANNED status"
152
+ retries: 3
153
+ depends_on: [review_gate]
154
+
155
+ # Gate: Human approves the roadmap
156
+ - id: roadmap_gate
157
+ function: roadmap_gate
158
+ on_approve: scaffold
159
+ on_revise: roadmap
160
+ on_kill: null
161
+ depends_on: [roadmap]
162
+
163
+ # Phase: Scaffold feature folders
164
+ - id: scaffold
165
+ agent: claude
166
+ intent: >
167
+ Read the approved ROADMAP.md and create a feature folder for each
168
+ feature listed. For each feature:
169
+ 1. Create docs/features/<CODE>/design.md with a seed design doc containing:
170
+ - Title: "<CODE>: <description>"
171
+ - Status: PLANNED
172
+ - Created date
173
+ - Intent section with the feature description
174
+ - Notes section explaining this is a seed for compose build
175
+ inputs:
176
+ intent: "$.input.intent"
177
+ roadmap: "$.steps.roadmap.output.summary"
178
+ output_contract: ScaffoldResult
179
+ ensure:
180
+ - "len(result.created) > 0"
181
+ retries: 2
182
+ depends_on: [roadmap_gate]
@@ -196,6 +196,26 @@ export async function toolGetCurrentSession({ featureCode } = {}) {
196
196
  };
197
197
  }
198
198
 
199
+ // ---------------------------------------------------------------------------
200
+ // Roadmap writers — COMP-MCP-ROADMAP-WRITER
201
+ // Pure file-based mutations via lib/feature-writer.js. No HTTP delegation.
202
+ // ---------------------------------------------------------------------------
203
+
204
+ export async function toolAddRoadmapEntry(args) {
205
+ const { addRoadmapEntry } = await import('../lib/feature-writer.js');
206
+ return addRoadmapEntry(getTargetRoot(), args);
207
+ }
208
+
209
+ export async function toolSetFeatureStatus(args) {
210
+ const { setFeatureStatus } = await import('../lib/feature-writer.js');
211
+ return setFeatureStatus(getTargetRoot(), args);
212
+ }
213
+
214
+ export async function toolRoadmapDiff(args) {
215
+ const { roadmapDiff } = await import('../lib/feature-writer.js');
216
+ return roadmapDiff(getTargetRoot(), args);
217
+ }
218
+
199
219
  export async function toolBindSession({ featureCode }) {
200
220
  const postData = JSON.stringify({ featureCode });
201
221
  return new Promise((resolve, reject) => {
@@ -43,6 +43,9 @@ import {
43
43
  toolIterationStart,
44
44
  toolIterationReport,
45
45
  toolIterationAbort,
46
+ toolAddRoadmapEntry,
47
+ toolSetFeatureStatus,
48
+ toolRoadmapDiff,
46
49
  } from './compose-mcp-tools.js';
47
50
 
48
51
  // ---------------------------------------------------------------------------
@@ -258,6 +261,57 @@ const TOOLS = [
258
261
  },
259
262
  // `agent_run` tool removed 2026-04-18 (STRAT-DEDUP-AGENTRUN v1); LLM-facing
260
263
  // dispatch goes through `mcp__stratum__stratum_agent_run`.
264
+
265
+ // -------------------------------------------------------------------------
266
+ // Roadmap writers — COMP-MCP-ROADMAP-WRITER
267
+ // -------------------------------------------------------------------------
268
+ {
269
+ name: 'add_roadmap_entry',
270
+ description: 'Register a new feature in the project. Writes feature.json and regenerates ROADMAP.md (audit-log append is best-effort). Use this instead of editing ROADMAP.md by hand.',
271
+ inputSchema: {
272
+ type: 'object',
273
+ required: ['code', 'description', 'phase'],
274
+ properties: {
275
+ code: { type: 'string', description: 'Unique feature code (e.g. "COMP-FOO-1"). Must be uppercase A-Z, digits, dashes; cannot start or end with a dash.' },
276
+ description: { type: 'string', description: 'One-line description for the ROADMAP cell' },
277
+ phase: { type: 'string', description: 'Phase heading (e.g. "Phase 6: MCP Writers"). Required.' },
278
+ complexity: { type: 'string', enum: ['S', 'M', 'L', 'XL'] },
279
+ status: { type: 'string', enum: ['PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE', 'BLOCKED', 'KILLED', 'PARKED', 'SUPERSEDED'], description: 'Initial status (default PLANNED)' },
280
+ position: { type: 'number', description: 'Sort order within phase' },
281
+ parent: { type: 'string', description: 'Parent feature code, for cross-references' },
282
+ tags: { type: 'array', items: { type: 'string' } },
283
+ idempotency_key: { type: 'string', description: 'Optional caller-provided key. Same key replays return the cached result without re-mutating.' },
284
+ },
285
+ },
286
+ },
287
+ {
288
+ name: 'set_feature_status',
289
+ description: 'Flip a feature status. Updates feature.json and regenerates ROADMAP.md. Enforces a transition policy (use force: true to bypass). Appends an audit event (best-effort).',
290
+ inputSchema: {
291
+ type: 'object',
292
+ required: ['code', 'status'],
293
+ properties: {
294
+ code: { type: 'string' },
295
+ status: { type: 'string', enum: ['PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE', 'BLOCKED', 'KILLED', 'PARKED', 'SUPERSEDED'] },
296
+ reason: { type: 'string', description: 'Free-form reason persisted in the audit event' },
297
+ commit_sha: { type: 'string', description: 'Optional commit binding' },
298
+ force: { type: 'boolean', description: 'Bypass the transition policy. Recorded in audit.' },
299
+ idempotency_key: { type: 'string' },
300
+ },
301
+ },
302
+ },
303
+ {
304
+ name: 'roadmap_diff',
305
+ description: 'Read the feature-management audit log for a window. Returns events plus derived added[] and status_changed[] arrays.',
306
+ inputSchema: {
307
+ type: 'object',
308
+ properties: {
309
+ since: { type: 'string', description: 'Window: shorthand like "24h"/"7d"/"30m", or an ISO date. Default 24h.' },
310
+ feature_code: { type: 'string' },
311
+ tool: { type: 'string', description: 'Filter to one tool name, e.g. "set_feature_status"' },
312
+ },
313
+ },
314
+ },
261
315
  ];
262
316
 
263
317
  // ---------------------------------------------------------------------------
@@ -295,6 +349,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
295
349
  case 'scaffold_feature': result = toolScaffoldFeature(args); break;
296
350
  case 'approve_gate': result = await toolApproveGate(args); break;
297
351
  case 'get_pending_gates': result = toolGetPendingGates(args); break;
352
+ case 'add_roadmap_entry': result = await toolAddRoadmapEntry(args); break;
353
+ case 'set_feature_status': result = await toolSetFeatureStatus(args); break;
354
+ case 'roadmap_diff': result = await toolRoadmapDiff(args); break;
298
355
  // agent_run removed — STRAT-DEDUP-AGENTRUN v1. Use mcp__stratum__stratum_agent_run.
299
356
  default:
300
357
  return {