@smartmemory/compose 0.1.7-beta → 0.1.9-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/README.md +32 -5
- package/bin/compose.js +294 -34
- package/bin/git-hooks/post-commit.template +2 -1
- package/bin/git-hooks/pre-push.template +2 -1
- package/dist/assets/{_baseUniq-D-avYfn5.js → _baseUniq-3jW4HAOf.js} +1 -1
- package/dist/assets/{arc-BC4dfQ-X.js → arc-DzzDimyd.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BZmFXnGI.js → architectureDiagram-Q4EWVU46-CtAgwORz.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DlfWSuux.js → blockDiagram-DXYQGD6D-Bryby0c_.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Y__uJrRx.js → c4Diagram-AHTNJAMY-C7N9RTJ8.js} +1 -1
- package/dist/assets/channel-DDkv7DUd.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-BfMePfTp.js → chunk-4BX2VUAB-wijkFgZY.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-BdlMSdEA.js → chunk-4TB4RGXK-zdSZGRS2.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-vrQHZTdv.js → chunk-55IACEB6-6zqzTZQQ.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-B8wioVlW.js → chunk-EDXVE4YY-frd1Vwf-.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Cd6Hrux2.js → chunk-FMBD7UC4-CdkRK5Hx.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CfrhdQXY.js → chunk-OYMX7WX6-C6bMB0cf.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-B9JQerOU.js → chunk-QZHKN3VN-4vsxN3jq.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-DFN9X99H.js → chunk-YZCP3GAM-DbNARKip.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-J6ZTeCbW.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-J6ZTeCbW.js +1 -0
- package/dist/assets/clone-5MVZ89iV.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BAn0ap_E.js → cose-bilkent-S5V4N54A-BpXeV7Vj.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-DyxnVq1g.js → dagre-KV5264BT-DQLu_W8r.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-XCrzqski.js → diagram-5BDNPKRD-skaOoe5A.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-MBCAXft_.js → diagram-G4DWMVQ6-DezlfFH4.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DbtB2yS6.js → diagram-MMDJMWI5-BUu-v-wT.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Bb5NzX61.js → diagram-TYMM5635-CziQ6LPs.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CpIeCOh2.js → erDiagram-SMLLAGMA-BsAyOVTI.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CHyoKnhW.js → flowDiagram-DWJPFMVM-CbYWJOLq.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-DErKteO_.js → ganttDiagram-T4ZO3ILL-CAwgDkLl.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-KFVAtj2F.js → gitGraphDiagram-UUTBAWPF-DK4RlkjO.js} +1 -1
- package/dist/assets/{graph-CRnO_ifT.js → graph-orv1XHGx.js} +1 -1
- package/dist/assets/{index-DkRKLuNr.js → index-Ceywghsu.js} +143 -143
- package/dist/assets/{infoDiagram-42DDH7IO-BZFnuSp5.js → infoDiagram-42DDH7IO-DQyA75sK.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-4Xe2Szde.js → ishikawaDiagram-UXIWVN3A-C-F_5q4k.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CZRByfS-.js → journeyDiagram-VCZTEJTY-Bj8UIvK-.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-B95sk6Fk.js → kanban-definition-6JOO6SKY-DZYr8Dp1.js} +1 -1
- package/dist/assets/{layout-BqNQzxWT.js → layout-CBaTKjpX.js} +1 -1
- package/dist/assets/{linear-CUh7qb64.js → linear-j1sI_SiN.js} +1 -1
- package/dist/assets/{min-wXgOS3ig.js → min-DtJISjld.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-DB6iaAbO.js → mindmap-definition-QFDTVHPH-Bulb64RS.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CHkZHrTW.js → pieDiagram-DEJITSTG-D11keQxr.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DoTEO8e3.js → quadrantDiagram-34T5L4WZ-BEcWQiEG.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Dn8peXYp.js → requirementDiagram-MS252O5E-Cbp23uDf.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-DRXs6Ipb.js → sankeyDiagram-XADWPNL6-Dae1hMc5.js} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-wBBYZ0aq.js → sequenceDiagram-FGHM5R23-C16abORi.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DPlBNGmf.js → stateDiagram-FHFEXIEX-CbEtfhbx.js} +1 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-CyY84hEA.js +1 -0
- package/dist/assets/{timeline-definition-GMOUNBTQ-CbbyTlHk.js → timeline-definition-GMOUNBTQ-BV7JTNMI.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-Bj4GaFfj.js → vennDiagram-DHZGUBPP-DBZiT48j.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-RtNzq8KU.js → wardley-RL74JXVD-Cc8uoiL3.js} +37 -37
- package/dist/assets/{wardleyDiagram-NUSXRM2D-CDfE3zSj.js → wardleyDiagram-NUSXRM2D-DEYcWGo5.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CZXHHYD5.js → xychartDiagram-5P7HB3ND-bFhLXv2b.js} +1 -1
- package/dist/index.html +1 -1
- package/lib/build.js +193 -19
- package/lib/completion-writer.js +7 -4
- package/lib/deps.js +17 -6
- package/lib/discover-workspaces.js +109 -0
- package/lib/feature-events.js +3 -0
- package/lib/feature-writer.js +34 -22
- package/lib/followup-writer.js +556 -0
- package/lib/mcp-enforcement.js +173 -0
- package/lib/migrate-roadmap.js +4 -1
- package/lib/project-paths.js +36 -0
- package/lib/resolve-workspace.js +166 -0
- package/lib/review-lenses.js +23 -8
- package/lib/review-normalize.js +42 -3
- package/lib/roadmap-drift.js +54 -0
- package/lib/roadmap-gen.js +297 -27
- package/lib/roadmap-preservers.js +353 -0
- package/lib/step-prompt.js +15 -0
- package/lib/triage.js +2 -1
- package/lib/version-check.js +110 -0
- package/package.json +1 -2
- package/server/compose-mcp-tools.js +44 -8
- package/server/compose-mcp.js +66 -1
- package/server/project-root.js +4 -0
- package/server/vision-routes.js +51 -2
- package/templates/ROADMAP.md +6 -0
- package/dist/assets/channel-LRG9kHqJ.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +0 -1
- package/dist/assets/clone-dRxgFrBv.js +0 -1
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-BW0ezXb4.js +0 -1
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* followup-writer.js — orchestrator for `propose_followup` MCP tool
|
|
3
|
+
* (COMP-MCP-FOLLOWUP, sub-ticket #8 of COMP-MCP-FEATURE-MGMT).
|
|
4
|
+
*
|
|
5
|
+
* Files a follow-up feature against a parent. Composes addRoadmapEntry +
|
|
6
|
+
* linkFeatures + scaffold via ArtifactManager, plus a "## Why" rationale
|
|
7
|
+
* block in the new design.md. Retry-safe via an inflight ledger; per-parent
|
|
8
|
+
* file lock prevents allocation races.
|
|
9
|
+
*
|
|
10
|
+
* See docs/features/COMP-MCP-FOLLOWUP/design.md and blueprint.md.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
existsSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
statSync,
|
|
21
|
+
} from 'fs';
|
|
22
|
+
import { join, resolve, dirname } from 'path';
|
|
23
|
+
import { createHash } from 'crypto';
|
|
24
|
+
|
|
25
|
+
import { readFeature, listFeatures } from './feature-json.js';
|
|
26
|
+
import { addRoadmapEntry, linkFeatures } from './feature-writer.js';
|
|
27
|
+
import { writeRoadmap } from './roadmap-gen.js';
|
|
28
|
+
import { appendEvent } from './feature-events.js';
|
|
29
|
+
import { checkOrInsert } from './idempotency.js';
|
|
30
|
+
import { FEATURE_CODE_RE_STRICT } from './feature-code.js';
|
|
31
|
+
import { loadFeaturesDir } from './project-paths.js';
|
|
32
|
+
|
|
33
|
+
const TERMINAL_STATUSES = new Set(['KILLED', 'SUPERSEDED']);
|
|
34
|
+
const VALID_STATUSES = new Set([
|
|
35
|
+
'PLANNED', 'IN_PROGRESS', 'PARTIAL', 'COMPLETE',
|
|
36
|
+
'BLOCKED', 'KILLED', 'PARKED', 'SUPERSEDED',
|
|
37
|
+
]);
|
|
38
|
+
const VALID_COMPLEXITIES = new Set(['S', 'M', 'L', 'XL']);
|
|
39
|
+
|
|
40
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
41
|
+
const LOCK_RETRY_MS = 25;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Errors
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function inputError(msg) {
|
|
48
|
+
const err = new Error(msg);
|
|
49
|
+
err.code = 'INVALID_INPUT';
|
|
50
|
+
return err;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parentNotFound(code) {
|
|
54
|
+
const err = new Error(`propose_followup: parent "${code}" not found`);
|
|
55
|
+
err.code = 'PARENT_NOT_FOUND';
|
|
56
|
+
return err;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parentTerminal(code, status) {
|
|
60
|
+
const err = new Error(
|
|
61
|
+
`propose_followup: parent "${code}" is in terminal status "${status}"; cannot file follow-ups`
|
|
62
|
+
);
|
|
63
|
+
err.code = 'PARENT_TERMINAL';
|
|
64
|
+
return err;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function followupBusy(parent_code) {
|
|
68
|
+
const err = new Error(
|
|
69
|
+
`propose_followup: per-parent lock for "${parent_code}" timed out after ${LOCK_TIMEOUT_MS}ms`
|
|
70
|
+
);
|
|
71
|
+
err.code = 'FOLLOWUP_BUSY';
|
|
72
|
+
return err;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function partialFollowup(stage, created_code, cause) {
|
|
76
|
+
const err = new Error(
|
|
77
|
+
`propose_followup: partial failure at stage "${stage}" for "${created_code}"; ` +
|
|
78
|
+
`recover by replaying with the same idempotency_key, or by completing manually.`
|
|
79
|
+
);
|
|
80
|
+
err.code = 'PARTIAL_FOLLOWUP';
|
|
81
|
+
err.stage = stage;
|
|
82
|
+
err.created_code = created_code;
|
|
83
|
+
if (cause) err.cause = cause;
|
|
84
|
+
return err;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Helpers
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function sha16(s) {
|
|
92
|
+
return createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fingerprint(args) {
|
|
96
|
+
const canonical = JSON.stringify({
|
|
97
|
+
parent_code: args.parent_code,
|
|
98
|
+
description: args.description,
|
|
99
|
+
rationale: args.rationale,
|
|
100
|
+
phase: args.phase ?? null,
|
|
101
|
+
status: args.status ?? 'PLANNED',
|
|
102
|
+
complexity: args.complexity ?? null,
|
|
103
|
+
});
|
|
104
|
+
return createHash('sha256').update(canonical).digest('hex');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ledgerDir(cwd) {
|
|
108
|
+
return join(cwd, '.compose', 'inflight-followups');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function ledgerPath(cwd, key, parent_code) {
|
|
112
|
+
// Namespace ledger filename by parent to prevent cross-parent collisions
|
|
113
|
+
// when the same idempotency_key is reused across different parents. The
|
|
114
|
+
// durable cache is also parent-namespaced (see cacheNamespacedKey), so
|
|
115
|
+
// the two layers stay consistent.
|
|
116
|
+
if (typeof parent_code !== 'string' || !parent_code) {
|
|
117
|
+
throw new Error('ledgerPath: parent_code is required');
|
|
118
|
+
}
|
|
119
|
+
return join(ledgerDir(cwd), `${sha16(`${parent_code}:${key}`)}.json`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function locksDir(cwd) {
|
|
123
|
+
return join(cwd, '.compose', 'locks');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function lockPath(cwd, parent_code) {
|
|
127
|
+
return join(locksDir(cwd), `followup-${sha16(parent_code)}.lock`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readLedger(cwd, key, parent_code) {
|
|
131
|
+
const p = ledgerPath(cwd, key, parent_code);
|
|
132
|
+
if (!existsSync(p)) return null;
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function writeLedger(cwd, key, parent_code, payload, mode) {
|
|
141
|
+
const p = ledgerPath(cwd, key, parent_code);
|
|
142
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
143
|
+
if (mode === 'wx' && existsSync(p)) {
|
|
144
|
+
const e = new Error(`ledger already exists: ${p}`);
|
|
145
|
+
e.code = 'LEDGER_EEXIST';
|
|
146
|
+
throw e;
|
|
147
|
+
}
|
|
148
|
+
writeFileSync(p, JSON.stringify(payload, null, 2), 'utf-8');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function deleteLedger(cwd, key, parent_code) {
|
|
152
|
+
const p = ledgerPath(cwd, key, parent_code);
|
|
153
|
+
try { unlinkSync(p); } catch { /* best-effort */ }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function acquireParentLock(cwd, parent_code) {
|
|
157
|
+
const path = lockPath(cwd, parent_code);
|
|
158
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
159
|
+
const start = Date.now();
|
|
160
|
+
// eslint-disable-next-line no-constant-condition
|
|
161
|
+
while (true) {
|
|
162
|
+
try {
|
|
163
|
+
mkdirSync(path);
|
|
164
|
+
return () => {
|
|
165
|
+
try { rmSync(path, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
166
|
+
};
|
|
167
|
+
} catch (err) {
|
|
168
|
+
if (err.code !== 'EEXIST') throw err;
|
|
169
|
+
// Stale-lock recovery
|
|
170
|
+
try {
|
|
171
|
+
const { mtimeMs } = statSync(path);
|
|
172
|
+
if (Date.now() - mtimeMs > LOCK_TIMEOUT_MS) {
|
|
173
|
+
rmSync(path, { recursive: true, force: true });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
} catch { /* stat raced; loop */ }
|
|
177
|
+
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
|
178
|
+
throw followupBusy(parent_code);
|
|
179
|
+
}
|
|
180
|
+
await new Promise(r => setTimeout(r, LOCK_RETRY_MS));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function nextNumberedCode(cwd, parent_code) {
|
|
186
|
+
const all = listFeatures(cwd, loadFeaturesDir(cwd));
|
|
187
|
+
const re = new RegExp(`^${parent_code.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}-(\\d+)$`);
|
|
188
|
+
let max = 0;
|
|
189
|
+
for (const f of all) {
|
|
190
|
+
const m = re.exec(f.code);
|
|
191
|
+
if (!m) continue;
|
|
192
|
+
const n = Number(m[1]);
|
|
193
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
194
|
+
}
|
|
195
|
+
return `${parent_code}-${max + 1}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// scaffoldDesignWithRationale — atomic scaffold + rationale block, with rollback
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
async function scaffoldDesignWithRationale(cwd, code, rationale) {
|
|
203
|
+
const { ArtifactManager } = await import('../server/artifact-manager.js');
|
|
204
|
+
const featureRoot = resolve(cwd, loadFeaturesDir(cwd));
|
|
205
|
+
mkdirSync(featureRoot, { recursive: true });
|
|
206
|
+
const manager = new ArtifactManager(featureRoot);
|
|
207
|
+
const scaffolded = manager.scaffold(code, { only: ['design.md'] });
|
|
208
|
+
|
|
209
|
+
const designPath = join(featureRoot, code, 'design.md');
|
|
210
|
+
let priorContent = null;
|
|
211
|
+
try {
|
|
212
|
+
priorContent = readFileSync(designPath, 'utf-8');
|
|
213
|
+
} catch (err) {
|
|
214
|
+
// The file should exist after scaffold; if not, propagate
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const lines = priorContent.split('\n');
|
|
220
|
+
const firstH1 = lines.findIndex(l => /^# /.test(l));
|
|
221
|
+
let insertIdx;
|
|
222
|
+
if (firstH1 === -1) {
|
|
223
|
+
insertIdx = 0;
|
|
224
|
+
} else {
|
|
225
|
+
insertIdx = firstH1 + 1;
|
|
226
|
+
while (insertIdx < lines.length && lines[insertIdx].trim() === '') insertIdx++;
|
|
227
|
+
}
|
|
228
|
+
// Idempotent: skip if a `## Why` block already exists at the insert point
|
|
229
|
+
const lookahead = lines.slice(insertIdx, insertIdx + 4).join('\n');
|
|
230
|
+
if (!/^## Why\b/m.test(lookahead)) {
|
|
231
|
+
const block = ['', '## Why', '', rationale.trim(), ''];
|
|
232
|
+
lines.splice(insertIdx, 0, ...block);
|
|
233
|
+
writeFileSync(designPath, lines.join('\n'), 'utf-8');
|
|
234
|
+
}
|
|
235
|
+
return scaffolded;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// Rollback: if we just created the file in this scaffold call, delete it;
|
|
238
|
+
// otherwise restore the prior content
|
|
239
|
+
try {
|
|
240
|
+
if (scaffolded.created.includes('design.md')) {
|
|
241
|
+
unlinkSync(designPath);
|
|
242
|
+
} else {
|
|
243
|
+
writeFileSync(designPath, priorContent, 'utf-8');
|
|
244
|
+
}
|
|
245
|
+
} catch { /* best-effort */ }
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Public API
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {string} cwd
|
|
256
|
+
* @param {object} args
|
|
257
|
+
* @param {string} args.parent_code
|
|
258
|
+
* @param {string} args.description
|
|
259
|
+
* @param {string} args.rationale
|
|
260
|
+
* @param {'S'|'M'|'L'|'XL'} [args.complexity]
|
|
261
|
+
* @param {string} [args.phase]
|
|
262
|
+
* @param {string} [args.status]
|
|
263
|
+
* @param {string} [args.idempotency_key]
|
|
264
|
+
* @returns {Promise<object>}
|
|
265
|
+
*/
|
|
266
|
+
export async function proposeFollowup(cwd, args = {}) {
|
|
267
|
+
// -------------------------------------------------------------------------
|
|
268
|
+
// Validation
|
|
269
|
+
// -------------------------------------------------------------------------
|
|
270
|
+
const { parent_code, description, rationale } = args;
|
|
271
|
+
|
|
272
|
+
if (typeof parent_code !== 'string' || !FEATURE_CODE_RE_STRICT.test(parent_code)) {
|
|
273
|
+
throw inputError(`propose_followup: invalid parent_code ${JSON.stringify(parent_code)}`);
|
|
274
|
+
}
|
|
275
|
+
if (typeof description !== 'string' || description.trim() === '') {
|
|
276
|
+
throw inputError('propose_followup: description must be a non-empty string');
|
|
277
|
+
}
|
|
278
|
+
if (typeof rationale !== 'string' || rationale.trim() === '') {
|
|
279
|
+
throw inputError('propose_followup: rationale must be a non-empty string');
|
|
280
|
+
}
|
|
281
|
+
if (args.complexity !== undefined && !VALID_COMPLEXITIES.has(args.complexity)) {
|
|
282
|
+
throw inputError(`propose_followup: invalid complexity ${JSON.stringify(args.complexity)}`);
|
|
283
|
+
}
|
|
284
|
+
if (args.status !== undefined && !VALID_STATUSES.has(args.status)) {
|
|
285
|
+
throw inputError(`propose_followup: invalid status ${JSON.stringify(args.status)}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const parent = readFeature(cwd, parent_code, loadFeaturesDir(cwd));
|
|
289
|
+
if (!parent) throw parentNotFound(parent_code);
|
|
290
|
+
if (TERMINAL_STATUSES.has(parent.status)) throw parentTerminal(parent_code, parent.status);
|
|
291
|
+
|
|
292
|
+
const phase = args.phase ?? parent.phase;
|
|
293
|
+
if (typeof phase !== 'string' || phase.trim() === '') {
|
|
294
|
+
throw inputError(
|
|
295
|
+
`propose_followup: phase is required (parent "${parent_code}" has no phase to inherit)`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
const status = args.status ?? 'PLANNED';
|
|
299
|
+
const requestFingerprint = fingerprint({ ...args, phase, status });
|
|
300
|
+
|
|
301
|
+
// -------------------------------------------------------------------------
|
|
302
|
+
// Cache hit (fast path) — only when idempotency_key provided
|
|
303
|
+
// -------------------------------------------------------------------------
|
|
304
|
+
const cacheNamespacedKey = args.idempotency_key
|
|
305
|
+
? `propose_followup:${parent_code}:${args.idempotency_key}`
|
|
306
|
+
: null;
|
|
307
|
+
|
|
308
|
+
// Drive the orchestration. If idempotency_key provided, wrap the whole
|
|
309
|
+
// thing in checkOrInsert which handles cache hits transparently. We rely
|
|
310
|
+
// on the cache layer to dedup full successes; partial state is handled by
|
|
311
|
+
// the inflight ledger inside the compute function.
|
|
312
|
+
const compute = () => orchestrate({
|
|
313
|
+
cwd,
|
|
314
|
+
parent_code,
|
|
315
|
+
parent,
|
|
316
|
+
args,
|
|
317
|
+
phase,
|
|
318
|
+
status,
|
|
319
|
+
requestFingerprint,
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
if (cacheNamespacedKey) {
|
|
323
|
+
const { result } = await checkOrInsert(cwd, cacheNamespacedKey, compute);
|
|
324
|
+
// Cache write succeeded (or hit) — now safe to delete the inflight
|
|
325
|
+
// ledger. Crash between checkOrInsert and this delete is harmless: the
|
|
326
|
+
// next same-key call will hit the cache and skip the ledger entirely.
|
|
327
|
+
deleteLedger(cwd, args.idempotency_key, parent_code);
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
return compute();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// orchestrate — the main flow, behind cache layer when idempotent
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
async function orchestrate({ cwd, parent_code, parent, args, phase, status, requestFingerprint }) {
|
|
338
|
+
const idempotency_key = args.idempotency_key;
|
|
339
|
+
|
|
340
|
+
// Resume from inflight ledger if present
|
|
341
|
+
let allocated_code;
|
|
342
|
+
let stage = 'pending';
|
|
343
|
+
let releaseLock = null;
|
|
344
|
+
|
|
345
|
+
if (idempotency_key) {
|
|
346
|
+
const ledger = readLedger(cwd, idempotency_key, parent_code);
|
|
347
|
+
if (ledger) {
|
|
348
|
+
if (ledger.idempotency_key !== idempotency_key
|
|
349
|
+
|| ledger.parent_code !== parent_code
|
|
350
|
+
|| ledger.request_fingerprint !== requestFingerprint) {
|
|
351
|
+
throw inputError(
|
|
352
|
+
'propose_followup: idempotency_key reused with different arguments'
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
allocated_code = ledger.allocated_code;
|
|
356
|
+
stage = ledger.stage;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
// -----------------------------------------------------------------------
|
|
362
|
+
// Stage: pending — allocate and call addRoadmapEntry
|
|
363
|
+
// -----------------------------------------------------------------------
|
|
364
|
+
if (stage === 'pending') {
|
|
365
|
+
releaseLock = await acquireParentLock(cwd, parent_code);
|
|
366
|
+
try {
|
|
367
|
+
if (!allocated_code) {
|
|
368
|
+
allocated_code = nextNumberedCode(cwd, parent_code);
|
|
369
|
+
}
|
|
370
|
+
if (idempotency_key) {
|
|
371
|
+
// Write ledger before mutating (wx if first time, overwrite on resume)
|
|
372
|
+
const ledgerPayload = {
|
|
373
|
+
idempotency_key,
|
|
374
|
+
parent_code,
|
|
375
|
+
allocated_code,
|
|
376
|
+
stage: 'pending',
|
|
377
|
+
request_fingerprint: requestFingerprint,
|
|
378
|
+
ts: new Date().toISOString(),
|
|
379
|
+
};
|
|
380
|
+
// Use a non-exclusive write — resume might reach here with a
|
|
381
|
+
// pre-existing ledger we already validated above.
|
|
382
|
+
writeLedger(cwd, idempotency_key, parent_code, ledgerPayload, 'overwrite');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
await addRoadmapEntry(cwd, {
|
|
387
|
+
code: allocated_code,
|
|
388
|
+
description: args.description,
|
|
389
|
+
phase,
|
|
390
|
+
complexity: args.complexity,
|
|
391
|
+
status,
|
|
392
|
+
parent: parent_code,
|
|
393
|
+
});
|
|
394
|
+
stage = 'roadmap_done';
|
|
395
|
+
if (idempotency_key) advanceLedger(cwd, idempotency_key, { allocated_code, parent_code, request_fingerprint: requestFingerprint, stage });
|
|
396
|
+
} catch (err) {
|
|
397
|
+
if (err && err.code === 'ROADMAP_PARTIAL_WRITE') {
|
|
398
|
+
stage = 'roadmap_committed_regen_failed';
|
|
399
|
+
if (idempotency_key) advanceLedger(cwd, idempotency_key, { allocated_code, parent_code, request_fingerprint: requestFingerprint, stage });
|
|
400
|
+
throw partialFollowup('roadmap_regen', allocated_code, err);
|
|
401
|
+
}
|
|
402
|
+
if (err && /already exists/.test(err.message || '')) {
|
|
403
|
+
// Resume duplicate: code was allocated in a prior attempt.
|
|
404
|
+
// Verify ownership: the existing feature's parent must match.
|
|
405
|
+
const existing = readFeature(cwd, allocated_code, loadFeaturesDir(cwd));
|
|
406
|
+
if (existing && existing.parent === parent_code) {
|
|
407
|
+
try {
|
|
408
|
+
writeRoadmap(cwd);
|
|
409
|
+
} catch (regenErr) {
|
|
410
|
+
// Surface as a partial — design requires regeneration to
|
|
411
|
+
// succeed before advancing past step 3.
|
|
412
|
+
stage = 'roadmap_committed_regen_failed';
|
|
413
|
+
if (idempotency_key) advanceLedger(cwd, idempotency_key, { allocated_code, parent_code, request_fingerprint: requestFingerprint, stage });
|
|
414
|
+
throw partialFollowup('roadmap_regen', allocated_code, regenErr);
|
|
415
|
+
}
|
|
416
|
+
stage = 'roadmap_done';
|
|
417
|
+
if (idempotency_key) advanceLedger(cwd, idempotency_key, { allocated_code, parent_code, request_fingerprint: requestFingerprint, stage });
|
|
418
|
+
} else {
|
|
419
|
+
// Foreign feature owns this code — bail out
|
|
420
|
+
if (idempotency_key) deleteLedger(cwd, idempotency_key, parent_code);
|
|
421
|
+
throw err;
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
// Unrelated error — clean up ledger and rethrow
|
|
425
|
+
if (idempotency_key) deleteLedger(cwd, idempotency_key, parent_code);
|
|
426
|
+
throw err;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} finally {
|
|
430
|
+
if (releaseLock) { releaseLock(); releaseLock = null; }
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// -----------------------------------------------------------------------
|
|
435
|
+
// Stage: roadmap_committed_regen_failed — regen ROADMAP, then proceed
|
|
436
|
+
// -----------------------------------------------------------------------
|
|
437
|
+
if (stage === 'roadmap_committed_regen_failed') {
|
|
438
|
+
try {
|
|
439
|
+
writeRoadmap(cwd);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
// Still failing — surface the partial again
|
|
442
|
+
throw partialFollowup('roadmap_regen', allocated_code, err);
|
|
443
|
+
}
|
|
444
|
+
stage = 'roadmap_done';
|
|
445
|
+
if (idempotency_key) advanceLedger(cwd, idempotency_key, { allocated_code, parent_code, request_fingerprint: requestFingerprint, stage });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// -----------------------------------------------------------------------
|
|
449
|
+
// Stage: roadmap_done | link_failed — call linkFeatures
|
|
450
|
+
// -----------------------------------------------------------------------
|
|
451
|
+
if (stage === 'roadmap_done' || stage === 'link_failed') {
|
|
452
|
+
try {
|
|
453
|
+
await linkFeatures(cwd, {
|
|
454
|
+
from_code: allocated_code,
|
|
455
|
+
to_code: parent_code,
|
|
456
|
+
kind: 'surfaced_by',
|
|
457
|
+
});
|
|
458
|
+
stage = 'link_done';
|
|
459
|
+
if (idempotency_key) advanceLedger(cwd, idempotency_key, { allocated_code, parent_code, request_fingerprint: requestFingerprint, stage });
|
|
460
|
+
} catch (err) {
|
|
461
|
+
stage = 'link_failed';
|
|
462
|
+
if (idempotency_key) advanceLedger(cwd, idempotency_key, { allocated_code, parent_code, request_fingerprint: requestFingerprint, stage });
|
|
463
|
+
throw partialFollowup('link', allocated_code, err);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// -----------------------------------------------------------------------
|
|
468
|
+
// Stage: link_done | scaffold_failed — scaffold + rationale
|
|
469
|
+
// -----------------------------------------------------------------------
|
|
470
|
+
let scaffolded;
|
|
471
|
+
if (stage === 'link_done' || stage === 'scaffold_failed') {
|
|
472
|
+
try {
|
|
473
|
+
scaffolded = await scaffoldDesignWithRationale(cwd, allocated_code, args.rationale);
|
|
474
|
+
stage = 'scaffold_done';
|
|
475
|
+
if (idempotency_key) advanceLedger(cwd, idempotency_key, { allocated_code, parent_code, request_fingerprint: requestFingerprint, stage });
|
|
476
|
+
} catch (err) {
|
|
477
|
+
stage = 'scaffold_failed';
|
|
478
|
+
if (idempotency_key) advanceLedger(cwd, idempotency_key, { allocated_code, parent_code, request_fingerprint: requestFingerprint, stage });
|
|
479
|
+
throw partialFollowup('scaffold', allocated_code, err);
|
|
480
|
+
}
|
|
481
|
+
} else if (stage === 'scaffold_done') {
|
|
482
|
+
// Resume after success — recompute scaffolded shape (idempotent re-scan)
|
|
483
|
+
const featureRoot = resolve(cwd, loadFeaturesDir(cwd));
|
|
484
|
+
const { ArtifactManager } = await import('../server/artifact-manager.js');
|
|
485
|
+
const manager = new ArtifactManager(featureRoot);
|
|
486
|
+
scaffolded = manager.scaffold(allocated_code, { only: ['design.md'] });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// -----------------------------------------------------------------------
|
|
490
|
+
// Audit + return
|
|
491
|
+
// -----------------------------------------------------------------------
|
|
492
|
+
try {
|
|
493
|
+
appendEvent(cwd, {
|
|
494
|
+
tool: 'propose_followup',
|
|
495
|
+
parent_code,
|
|
496
|
+
code: allocated_code,
|
|
497
|
+
rationale: args.rationale,
|
|
498
|
+
idempotency_key,
|
|
499
|
+
});
|
|
500
|
+
} catch (err) {
|
|
501
|
+
// eslint-disable-next-line no-console
|
|
502
|
+
console.warn(`[followup-writer] audit append failed: ${err.message}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const created = readFeature(cwd, allocated_code, loadFeaturesDir(cwd));
|
|
506
|
+
|
|
507
|
+
const result = {
|
|
508
|
+
code: allocated_code,
|
|
509
|
+
parent_code,
|
|
510
|
+
phase: created?.phase ?? phase,
|
|
511
|
+
position: created?.position,
|
|
512
|
+
roadmap_path: resolve(cwd, 'ROADMAP.md'),
|
|
513
|
+
scaffolded: scaffolded ?? { created: [], skipped: ['design.md'] },
|
|
514
|
+
link: { kind: 'surfaced_by', from_code: allocated_code, to_code: parent_code },
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Note: when idempotency_key is set, the inflight ledger is deleted by
|
|
518
|
+
// the caller (proposeFollowup) AFTER checkOrInsert persists the success
|
|
519
|
+
// result. That ordering keeps cache+ledger crash-safe: a process death
|
|
520
|
+
// between cache-write and ledger-delete is harmless (next replay hits
|
|
521
|
+
// the cache); a death before cache-write leaves the ledger so resume
|
|
522
|
+
// works.
|
|
523
|
+
if (!idempotency_key) {
|
|
524
|
+
// No-key path has no ledger to delete — nothing to do.
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return result;
|
|
528
|
+
} catch (err) {
|
|
529
|
+
if (releaseLock) { try { releaseLock(); } catch { /* */ } }
|
|
530
|
+
throw err;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function advanceLedger(cwd, key, { allocated_code, parent_code, request_fingerprint, stage }) {
|
|
535
|
+
writeLedger(cwd, key, parent_code, {
|
|
536
|
+
idempotency_key: key,
|
|
537
|
+
parent_code,
|
|
538
|
+
allocated_code,
|
|
539
|
+
stage,
|
|
540
|
+
request_fingerprint,
|
|
541
|
+
ts: new Date().toISOString(),
|
|
542
|
+
}, 'overwrite');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
// Test/diagnostic exports
|
|
547
|
+
// ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
export const _internals = {
|
|
550
|
+
sha16,
|
|
551
|
+
fingerprint,
|
|
552
|
+
ledgerPath,
|
|
553
|
+
lockPath,
|
|
554
|
+
nextNumberedCode,
|
|
555
|
+
scaffoldDesignWithRationale,
|
|
556
|
+
};
|