@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 +4 -4
- 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/pipeline-cli.js +29 -28
- package/lib/roadmap-parser.js +3 -1
- package/lib/sections.js +188 -0
- package/package.json +1 -1
- package/pipelines/new.stratum.yaml +182 -0
- package/server/compose-mcp-tools.js +20 -0
- package/server/compose-mcp.js +57 -0
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 { /*
|
|
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 { /*
|
|
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
|
-
|
|
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/pipeline-cli.js
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
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?.
|
|
60
|
-
if (!mainFlow) throw new Error(
|
|
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:
|
|
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?.
|
|
139
|
-
if (!mainFlow) throw new Error(
|
|
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?.
|
|
311
|
-
if (!mainFlow) throw new Error(
|
|
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?.
|
|
359
|
-
if (!mainFlow) throw new Error(
|
|
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?.
|
|
394
|
-
if (!mainFlow) throw new Error(
|
|
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?.
|
|
409
|
-
if (!mainFlow) throw new Error(
|
|
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)
|
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",
|
|
@@ -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) => {
|
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 {
|