@smartmemory/compose 0.1.4-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/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
+ }
@@ -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.4-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",
@@ -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 {