@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 +31 -3
- package/lib/feature-events.js +114 -0
- package/lib/feature-writer.js +265 -0
- package/lib/idempotency.js +138 -0
- package/lib/roadmap-parser.js +3 -1
- package/lib/sections.js +188 -0
- package/package.json +1 -1
- package/server/compose-mcp-tools.js +20 -0
- package/server/compose-mcp.js +57 -0
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
|
-
|
|
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
|
+
}
|
package/lib/roadmap-parser.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* from the markdown table format used by Compose roadmaps.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
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
|
+
"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) => {
|
package/server/compose-mcp.js
CHANGED
|
@@ -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 {
|