@smartmemory/compose 0.1.7-beta → 0.1.8-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.
Files changed (79) hide show
  1. package/README.md +32 -5
  2. package/bin/compose.js +167 -5
  3. package/dist/assets/{_baseUniq-D-avYfn5.js → _baseUniq-3jW4HAOf.js} +1 -1
  4. package/dist/assets/{arc-BC4dfQ-X.js → arc-DzzDimyd.js} +1 -1
  5. package/dist/assets/{architectureDiagram-Q4EWVU46-BZmFXnGI.js → architectureDiagram-Q4EWVU46-CtAgwORz.js} +1 -1
  6. package/dist/assets/{blockDiagram-DXYQGD6D-DlfWSuux.js → blockDiagram-DXYQGD6D-Bryby0c_.js} +1 -1
  7. package/dist/assets/{c4Diagram-AHTNJAMY-Y__uJrRx.js → c4Diagram-AHTNJAMY-C7N9RTJ8.js} +1 -1
  8. package/dist/assets/channel-DDkv7DUd.js +1 -0
  9. package/dist/assets/{chunk-4BX2VUAB-BfMePfTp.js → chunk-4BX2VUAB-wijkFgZY.js} +1 -1
  10. package/dist/assets/{chunk-4TB4RGXK-BdlMSdEA.js → chunk-4TB4RGXK-zdSZGRS2.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-vrQHZTdv.js → chunk-55IACEB6-6zqzTZQQ.js} +1 -1
  12. package/dist/assets/{chunk-EDXVE4YY-B8wioVlW.js → chunk-EDXVE4YY-frd1Vwf-.js} +1 -1
  13. package/dist/assets/{chunk-FMBD7UC4-Cd6Hrux2.js → chunk-FMBD7UC4-CdkRK5Hx.js} +1 -1
  14. package/dist/assets/{chunk-OYMX7WX6-CfrhdQXY.js → chunk-OYMX7WX6-C6bMB0cf.js} +1 -1
  15. package/dist/assets/{chunk-QZHKN3VN-B9JQerOU.js → chunk-QZHKN3VN-4vsxN3jq.js} +1 -1
  16. package/dist/assets/{chunk-YZCP3GAM-DFN9X99H.js → chunk-YZCP3GAM-DbNARKip.js} +1 -1
  17. package/dist/assets/classDiagram-6PBFFD2Q-J6ZTeCbW.js +1 -0
  18. package/dist/assets/classDiagram-v2-HSJHXN6E-J6ZTeCbW.js +1 -0
  19. package/dist/assets/clone-5MVZ89iV.js +1 -0
  20. package/dist/assets/{cose-bilkent-S5V4N54A-BAn0ap_E.js → cose-bilkent-S5V4N54A-BpXeV7Vj.js} +1 -1
  21. package/dist/assets/{dagre-KV5264BT-DyxnVq1g.js → dagre-KV5264BT-DQLu_W8r.js} +1 -1
  22. package/dist/assets/{diagram-5BDNPKRD-XCrzqski.js → diagram-5BDNPKRD-skaOoe5A.js} +1 -1
  23. package/dist/assets/{diagram-G4DWMVQ6-MBCAXft_.js → diagram-G4DWMVQ6-DezlfFH4.js} +1 -1
  24. package/dist/assets/{diagram-MMDJMWI5-DbtB2yS6.js → diagram-MMDJMWI5-BUu-v-wT.js} +1 -1
  25. package/dist/assets/{diagram-TYMM5635-Bb5NzX61.js → diagram-TYMM5635-CziQ6LPs.js} +1 -1
  26. package/dist/assets/{erDiagram-SMLLAGMA-CpIeCOh2.js → erDiagram-SMLLAGMA-BsAyOVTI.js} +1 -1
  27. package/dist/assets/{flowDiagram-DWJPFMVM-CHyoKnhW.js → flowDiagram-DWJPFMVM-CbYWJOLq.js} +1 -1
  28. package/dist/assets/{ganttDiagram-T4ZO3ILL-DErKteO_.js → ganttDiagram-T4ZO3ILL-CAwgDkLl.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-UUTBAWPF-KFVAtj2F.js → gitGraphDiagram-UUTBAWPF-DK4RlkjO.js} +1 -1
  30. package/dist/assets/{graph-CRnO_ifT.js → graph-orv1XHGx.js} +1 -1
  31. package/dist/assets/{index-DkRKLuNr.js → index-Ceywghsu.js} +143 -143
  32. package/dist/assets/{infoDiagram-42DDH7IO-BZFnuSp5.js → infoDiagram-42DDH7IO-DQyA75sK.js} +1 -1
  33. package/dist/assets/{ishikawaDiagram-UXIWVN3A-4Xe2Szde.js → ishikawaDiagram-UXIWVN3A-C-F_5q4k.js} +1 -1
  34. package/dist/assets/{journeyDiagram-VCZTEJTY-CZRByfS-.js → journeyDiagram-VCZTEJTY-Bj8UIvK-.js} +1 -1
  35. package/dist/assets/{kanban-definition-6JOO6SKY-B95sk6Fk.js → kanban-definition-6JOO6SKY-DZYr8Dp1.js} +1 -1
  36. package/dist/assets/{layout-BqNQzxWT.js → layout-CBaTKjpX.js} +1 -1
  37. package/dist/assets/{linear-CUh7qb64.js → linear-j1sI_SiN.js} +1 -1
  38. package/dist/assets/{min-wXgOS3ig.js → min-DtJISjld.js} +1 -1
  39. package/dist/assets/{mindmap-definition-QFDTVHPH-DB6iaAbO.js → mindmap-definition-QFDTVHPH-Bulb64RS.js} +1 -1
  40. package/dist/assets/{pieDiagram-DEJITSTG-CHkZHrTW.js → pieDiagram-DEJITSTG-D11keQxr.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-34T5L4WZ-DoTEO8e3.js → quadrantDiagram-34T5L4WZ-BEcWQiEG.js} +1 -1
  42. package/dist/assets/{requirementDiagram-MS252O5E-Dn8peXYp.js → requirementDiagram-MS252O5E-Cbp23uDf.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-XADWPNL6-DRXs6Ipb.js → sankeyDiagram-XADWPNL6-Dae1hMc5.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-FGHM5R23-wBBYZ0aq.js → sequenceDiagram-FGHM5R23-C16abORi.js} +1 -1
  45. package/dist/assets/{stateDiagram-FHFEXIEX-DPlBNGmf.js → stateDiagram-FHFEXIEX-CbEtfhbx.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CyY84hEA.js +1 -0
  47. package/dist/assets/{timeline-definition-GMOUNBTQ-CbbyTlHk.js → timeline-definition-GMOUNBTQ-BV7JTNMI.js} +1 -1
  48. package/dist/assets/{vennDiagram-DHZGUBPP-Bj4GaFfj.js → vennDiagram-DHZGUBPP-DBZiT48j.js} +1 -1
  49. package/dist/assets/{wardley-RL74JXVD-RtNzq8KU.js → wardley-RL74JXVD-Cc8uoiL3.js} +37 -37
  50. package/dist/assets/{wardleyDiagram-NUSXRM2D-CDfE3zSj.js → wardleyDiagram-NUSXRM2D-DEYcWGo5.js} +1 -1
  51. package/dist/assets/{xychartDiagram-5P7HB3ND-CZXHHYD5.js → xychartDiagram-5P7HB3ND-bFhLXv2b.js} +1 -1
  52. package/dist/index.html +1 -1
  53. package/lib/build.js +193 -19
  54. package/lib/completion-writer.js +7 -4
  55. package/lib/deps.js +17 -6
  56. package/lib/feature-events.js +3 -0
  57. package/lib/feature-writer.js +34 -22
  58. package/lib/followup-writer.js +556 -0
  59. package/lib/mcp-enforcement.js +173 -0
  60. package/lib/migrate-roadmap.js +4 -1
  61. package/lib/project-paths.js +36 -0
  62. package/lib/review-lenses.js +23 -8
  63. package/lib/review-normalize.js +42 -3
  64. package/lib/roadmap-drift.js +54 -0
  65. package/lib/roadmap-gen.js +297 -27
  66. package/lib/roadmap-preservers.js +353 -0
  67. package/lib/step-prompt.js +15 -0
  68. package/lib/triage.js +2 -1
  69. package/lib/version-check.js +110 -0
  70. package/package.json +1 -1
  71. package/server/compose-mcp-tools.js +16 -2
  72. package/server/compose-mcp.js +24 -1
  73. package/server/vision-routes.js +51 -2
  74. package/templates/ROADMAP.md +6 -0
  75. package/dist/assets/channel-LRG9kHqJ.js +0 -1
  76. package/dist/assets/classDiagram-6PBFFD2Q-BC9a6pDE.js +0 -1
  77. package/dist/assets/classDiagram-v2-HSJHXN6E-BC9a6pDE.js +0 -1
  78. package/dist/assets/clone-dRxgFrBv.js +0 -1
  79. 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
+ };