@smartmemory/compose 0.1.34-beta → 0.1.36-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 (71) hide show
  1. package/README.md +42 -0
  2. package/bin/compose.js +16 -0
  3. package/dist/assets/{App-DMCO9aNs.js → App-DO9nGI18.js} +8 -8
  4. package/dist/assets/{arc-DsXb95RZ.js → arc-DrI_lR89.js} +1 -1
  5. package/dist/assets/{architectureDiagram-3BPJPVTR-BaBYippI.js → architectureDiagram-3BPJPVTR-DyS14rP-.js} +1 -1
  6. package/dist/assets/{blockDiagram-GPEHLZMM-HwB_eL3_.js → blockDiagram-GPEHLZMM-elTUtlnM.js} +1 -1
  7. package/dist/assets/{c4Diagram-AAUBKEIU-CPSXghc8.js → c4Diagram-AAUBKEIU-CJ_8nsiM.js} +1 -1
  8. package/dist/assets/channel-Cda02Ntm.js +1 -0
  9. package/dist/assets/{chunk-2J33WTMH-CCN3bc9J.js → chunk-2J33WTMH-BllJC9rQ.js} +1 -1
  10. package/dist/assets/{chunk-4BX2VUAB-DuqFxpoV.js → chunk-4BX2VUAB-f3EEUpDr.js} +1 -1
  11. package/dist/assets/{chunk-55IACEB6-DT20mkDV.js → chunk-55IACEB6-CgbUlP7I.js} +1 -1
  12. package/dist/assets/{chunk-727SXJPM-ByMH6Qvp.js → chunk-727SXJPM-D9zRfRSY.js} +1 -1
  13. package/dist/assets/{chunk-AQP2D5EJ-CLgYtOHw.js → chunk-AQP2D5EJ-Bjp1xaJ8.js} +1 -1
  14. package/dist/assets/{chunk-FMBD7UC4-BXWmTsAA.js → chunk-FMBD7UC4-Dyvhvbkr.js} +1 -1
  15. package/dist/assets/{chunk-ND2GUHAM-C2WgVbpE.js → chunk-ND2GUHAM-D4dMBzz5.js} +1 -1
  16. package/dist/assets/{chunk-QZHKN3VN-DFuQRJeh.js → chunk-QZHKN3VN-CVhvkQt1.js} +1 -1
  17. package/dist/assets/classDiagram-4FO5ZUOK-BykFns4A.js +1 -0
  18. package/dist/assets/classDiagram-v2-Q7XG4LA2-BykFns4A.js +1 -0
  19. package/dist/assets/{cose-bilkent-S5V4N54A-CdyvK5N2.js → cose-bilkent-S5V4N54A-CylcCFl3.js} +1 -1
  20. package/dist/assets/{dagre-BM42HDAG-Drkta_n5.js → dagre-BM42HDAG-Dd1Ny0g8.js} +1 -1
  21. package/dist/assets/{diagram-2AECGRRQ-BRiBkuu5.js → diagram-2AECGRRQ-tVs4SxwR.js} +1 -1
  22. package/dist/assets/{diagram-5GNKFQAL-IrSBDK26.js → diagram-5GNKFQAL-UC1fUNjx.js} +1 -1
  23. package/dist/assets/{diagram-KO2AKTUF-BUktYepH.js → diagram-KO2AKTUF-Cxhb85Xt.js} +1 -1
  24. package/dist/assets/{diagram-LMA3HP47-B5erGOiF.js → diagram-LMA3HP47-DJMx1YAp.js} +1 -1
  25. package/dist/assets/{diagram-OG6HWLK6-5KoSfwod.js → diagram-OG6HWLK6-k_cRur_6.js} +1 -1
  26. package/dist/assets/{erDiagram-TEJ5UH35-CXSf-i6t.js → erDiagram-TEJ5UH35-ClfbMYdQ.js} +1 -1
  27. package/dist/assets/{flowDiagram-I6XJVG4X-DiwEgd9q.js → flowDiagram-I6XJVG4X-B9KvjWpm.js} +1 -1
  28. package/dist/assets/{ganttDiagram-6RSMTGT7-zQ94YEl2.js → ganttDiagram-6RSMTGT7-CCqvcQcy.js} +1 -1
  29. package/dist/assets/{gitGraphDiagram-PVQCEYII-CWNWantF.js → gitGraphDiagram-PVQCEYII-DN9gJgDY.js} +1 -1
  30. package/dist/assets/{graph-DPbJeZyN.js → graph-uO5hwVZK.js} +1 -1
  31. package/dist/assets/{index-CyFM4bTc.js → index-CtjbBZuo.js} +3 -3
  32. package/dist/assets/index-Dh2rRpBR.css +1 -0
  33. package/dist/assets/{infoDiagram-5YYISTIA-BcnrgEm6.js → infoDiagram-5YYISTIA-BZjN-ULc.js} +1 -1
  34. package/dist/assets/{ishikawaDiagram-YF4QCWOH-BRzURsJQ.js → ishikawaDiagram-YF4QCWOH-Ck1kwRqz.js} +1 -1
  35. package/dist/assets/{journeyDiagram-JHISSGLW-CdwMwMPo.js → journeyDiagram-JHISSGLW-CHKyH4p5.js} +1 -1
  36. package/dist/assets/{kanban-definition-UN3LZRKU-Difj4Zd-.js → kanban-definition-UN3LZRKU-Bq4CuVJj.js} +1 -1
  37. package/dist/assets/{linear-CKwgBFBW.js → linear-h6UanLnU.js} +1 -1
  38. package/dist/assets/{mindmap-definition-RKZ34NQL-C2aCD1L4.js → mindmap-definition-RKZ34NQL-IQcATrSw.js} +1 -1
  39. package/dist/assets/mobile-BwduHUEq.js +17 -0
  40. package/dist/assets/{pieDiagram-4H26LBE5-C9qGrfV0.js → pieDiagram-4H26LBE5-B7cw_oKH.js} +1 -1
  41. package/dist/assets/{quadrantDiagram-W4KKPZXB-COA0Z2JV.js → quadrantDiagram-W4KKPZXB-CqiNInwZ.js} +1 -1
  42. package/dist/assets/{requirementDiagram-4Y6WPE33-BJCU8yFE.js → requirementDiagram-4Y6WPE33-CynhQVI1.js} +1 -1
  43. package/dist/assets/{sankeyDiagram-5OEKKPKP-vYzK7FJ6.js → sankeyDiagram-5OEKKPKP-aFS3y10_.js} +1 -1
  44. package/dist/assets/{sequenceDiagram-3UESZ5HK-Bkh_RWpN.js → sequenceDiagram-3UESZ5HK-CTvZcil0.js} +1 -1
  45. package/dist/assets/{stateDiagram-AJRCARHV-BlUsbYTW.js → stateDiagram-AJRCARHV-DYXgroJe.js} +1 -1
  46. package/dist/assets/stateDiagram-v2-BHNVJYJU-BVQEXVG-.js +1 -0
  47. package/dist/assets/{timeline-definition-PNZ67QCA-DuYEZwxg.js → timeline-definition-PNZ67QCA-BtN3u7JC.js} +1 -1
  48. package/dist/assets/{vennDiagram-CIIHVFJN-D9a3Q3Ni.js → vennDiagram-CIIHVFJN-DsssmbTq.js} +1 -1
  49. package/dist/assets/{wardley-L42UT6IY-h2fQnc_J.js → wardley-L42UT6IY-BXD_QFvV.js} +1 -1
  50. package/dist/assets/{wardleyDiagram-YWT4CUSO-C-_dzSY5.js → wardleyDiagram-YWT4CUSO-CgZmwLSB.js} +1 -1
  51. package/dist/assets/{xychartDiagram-2RQKCTM6-CKxMIB7j.js → xychartDiagram-2RQKCTM6-BmiRpk6f.js} +1 -1
  52. package/dist/index.html +3 -3
  53. package/lib/build.js +60 -13
  54. package/lib/changelog-writer.js +111 -83
  55. package/lib/completion-writer.js +26 -9
  56. package/lib/feature-writer.js +62 -38
  57. package/lib/roadmap-gen.js +41 -14
  58. package/lib/tracker/cli.js +31 -0
  59. package/lib/tracker/factory.js +93 -0
  60. package/lib/tracker/github-api.js +115 -0
  61. package/lib/tracker/github-provider.js +641 -0
  62. package/lib/tracker/local-provider.js +202 -0
  63. package/lib/tracker/provider.js +40 -0
  64. package/lib/tracker/sync-engine.js +131 -0
  65. package/package.json +3 -2
  66. package/dist/assets/channel-TOlxWxU-.js +0 -1
  67. package/dist/assets/classDiagram-4FO5ZUOK-DZsvwI1V.js +0 -1
  68. package/dist/assets/classDiagram-v2-Q7XG4LA2-DZsvwI1V.js +0 -1
  69. package/dist/assets/index-CHkeTiSt.css +0 -1
  70. package/dist/assets/mobile-CsuriFuT.js +0 -17
  71. package/dist/assets/stateDiagram-v2-BHNVJYJU-_AUWPuja.js +0 -1
@@ -20,14 +20,19 @@
20
20
  * CLI, or future REST routes.
21
21
  */
22
22
 
23
- import {
24
- readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync,
25
- } from 'node:fs';
26
- import { join, dirname } from 'node:path';
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
27
25
 
28
- import { appendEvent, normalizeSince } from './feature-events.js';
26
+ import { normalizeSince } from './feature-events.js';
29
27
  import { checkOrInsert } from './idempotency.js';
30
28
 
29
+ // providerFor is imported lazily to avoid the load-time cycle:
30
+ // factory.js → local-provider.js → changelog-writer.js
31
+ async function getProvider(cwd) {
32
+ const { providerFor } = await import('./tracker/factory.js');
33
+ return providerFor(cwd);
34
+ }
35
+
31
36
  // ---------------------------------------------------------------------------
32
37
  // Validation helpers
33
38
  // ---------------------------------------------------------------------------
@@ -304,9 +309,13 @@ function readChangelogText(cwd) {
304
309
  return readFileSync(p, 'utf-8');
305
310
  }
306
311
 
307
- function safeAppendEvent(cwd, event) {
312
+ // Routes through provider.appendEvent so GitHubProvider can post
313
+ // <!--compose-event--> comments. LocalFileProvider delegates to
314
+ // feature-events.js#appendEvent producing byte-identical output.
315
+ async function safeAppendEvent(cwd, event) {
308
316
  try {
309
- appendEvent(cwd, event);
317
+ const provider = await getProvider(cwd);
318
+ await provider.appendEvent(event.code, event);
310
319
  } catch (err) {
311
320
  // eslint-disable-next-line no-console
312
321
  console.warn(`[changelog-writer] audit append failed for ${event.tool} ${event.code ?? ''}: ${err.message}`);
@@ -320,17 +329,86 @@ function maybeIdempotent(args, fn) {
320
329
  return Promise.resolve().then(fn);
321
330
  }
322
331
 
323
- function atomicWrite(cwd, content) {
324
- const p = changelogPath(cwd);
325
- const tmp = p + '.tmp';
326
- mkdirSync(dirname(p), { recursive: true });
327
- try {
328
- writeFileSync(tmp, content);
329
- renameSync(tmp, p);
330
- } catch (err) {
331
- try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* ignore */ }
332
- throw err;
332
+ /**
333
+ * spliceChangelog(currentText, entry) — pure string-in / string-out transform.
334
+ *
335
+ * Parses `currentText`, applies the mutation described by `entry` (same shape
336
+ * as the `args` to addChangelogEntry), and returns the new file content.
337
+ *
338
+ * Exported so future providers (e.g. GitHubProvider) can fetch a remote blob,
339
+ * pass it here, then PUT the result back — without touching the local FS.
340
+ *
341
+ * Throws formatError if the file exists but lacks the `# Changelog` header.
342
+ * Returns { content, insertedAtLine, surface, action } — callers write content.
343
+ */
344
+ export function spliceChangelog(currentText, entry) {
345
+ const text = currentText;
346
+ if (text.length > 0 && !H1_RE.test(text.split('\n')[0] ?? '')) {
347
+ throw formatError('first line must be "# Changelog" (line 1)');
348
+ }
349
+
350
+ const parsed = parseChangelog(text);
351
+
352
+ // Find existing entry across all matching surfaces.
353
+ const matchingSurfaces = parsed.surfaces.filter(s => s.label === entry.date_or_version);
354
+ let existingSurface = null;
355
+ let existingEntry = null;
356
+ for (const s of matchingSurfaces) {
357
+ const e = s.entries.find(en => en.code === entry.code);
358
+ if (e) { existingSurface = s; existingEntry = e; break; }
359
+ }
360
+
361
+ const rendered = renderEntry({
362
+ code: entry.code,
363
+ summary: entry.summary,
364
+ body: entry.body,
365
+ sections: entry.sections,
366
+ });
367
+
368
+ let action;
369
+ let chosenSurfaceLabel = entry.date_or_version;
370
+ let chosenSurfaceStartLine = -1;
371
+
372
+ if (existingEntry && !entry.force) {
373
+ // Storage-level idempotent no-op — caller decides what to do with this.
374
+ return {
375
+ content: null,
376
+ insertedAtLine: existingEntry.startLine,
377
+ surface: existingSurface.label,
378
+ idempotent: true,
379
+ };
333
380
  }
381
+
382
+ if (existingEntry && entry.force) {
383
+ action = { kind: 'replace', entry: existingEntry, code: entry.code };
384
+ chosenSurfaceLabel = existingSurface.label;
385
+ chosenSurfaceStartLine = existingSurface.startLine;
386
+ } else if (matchingSurfaces.length > 0) {
387
+ const surface = matchingSurfaces[0];
388
+ action = { kind: 'append-to-surface', surface, code: entry.code };
389
+ chosenSurfaceLabel = surface.label;
390
+ chosenSurfaceStartLine = surface.startLine;
391
+ } else if (text.length === 0) {
392
+ action = { kind: 'new-file', code: entry.code };
393
+ chosenSurfaceStartLine = 3;
394
+ } else {
395
+ action = { kind: 'new-surface', code: entry.code };
396
+ }
397
+
398
+ const { content, insertedAtLine } = buildNextContent(text, parsed, action, rendered, entry.date_or_version);
399
+
400
+ // For new-surface, recompute chosenSurfaceStartLine from output.
401
+ if (action.kind === 'new-surface' || action.kind === 'new-file') {
402
+ const outLines = content.split('\n');
403
+ for (let i = 0; i < outLines.length; i++) {
404
+ if (outLines[i] === `## ${entry.date_or_version}`) {
405
+ chosenSurfaceStartLine = i + 1;
406
+ break;
407
+ }
408
+ }
409
+ }
410
+
411
+ return { content, insertedAtLine, surface: chosenSurfaceLabel, idempotent: false, action, chosenSurfaceStartLine };
334
412
  }
335
413
 
336
414
  /**
@@ -507,91 +585,41 @@ export async function addChangelogEntry(cwd, args) {
507
585
  validateSummary(args.summary);
508
586
  validateSections(args.sections);
509
587
 
510
- return maybeIdempotent({ ...args, cwd }, () => {
511
- const text = readChangelogText(cwd);
512
- if (text.length > 0 && !H1_RE.test(text.split('\n')[0] ?? '')) {
513
- throw formatError('first line must be "# Changelog" (line 1)');
514
- }
588
+ return maybeIdempotent({ ...args, cwd }, async () => {
589
+ // Route through provider low-level primitives (getChangelog / putChangelog).
590
+ // Do NOT call provider.appendChangelog that's LocalFileProvider→addChangelogEntry = recursion.
591
+ const provider = await getProvider(cwd);
592
+ const text = await provider.getChangelog();
515
593
 
516
- const parsed = parseChangelog(text);
517
-
518
- // Find existing entry across all matching surfaces.
519
- const matchingSurfaces = parsed.surfaces.filter(s => s.label === args.date_or_version);
520
- let existingSurface = null;
521
- let existingEntry = null;
522
- for (const s of matchingSurfaces) {
523
- const e = s.entries.find(en => en.code === args.code);
524
- if (e) { existingSurface = s; existingEntry = e; break; }
525
- }
594
+ const spliced = spliceChangelog(text, args);
526
595
 
527
- const rendered = renderEntry({
528
- code: args.code,
529
- summary: args.summary,
530
- body: args.body,
531
- sections: args.sections,
532
- });
533
-
534
- let action;
535
- let chosenSurfaceLabel = args.date_or_version;
536
- let chosenSurfaceStartLine = -1;
537
-
538
- if (existingEntry && !args.force) {
596
+ if (spliced.idempotent) {
539
597
  // Storage-level idempotent no-op: no file write, no audit event
540
598
  // (Decision 2 of design). Caller-supplied idempotency_key replays are
541
599
  // handled separately by the maybeIdempotent wrapper above.
542
600
  return {
543
- inserted_at: existingEntry.startLine,
601
+ inserted_at: spliced.insertedAtLine,
544
602
  idempotent: true,
545
- surface: existingSurface.label,
603
+ surface: spliced.surface,
546
604
  };
547
605
  }
548
606
 
549
- if (existingEntry && args.force) {
550
- action = { kind: 'replace', entry: existingEntry, code: args.code };
551
- chosenSurfaceLabel = existingSurface.label;
552
- chosenSurfaceStartLine = existingSurface.startLine;
553
- } else if (matchingSurfaces.length > 0) {
554
- // No existing entry; insert into first (topmost) matching surface.
555
- const surface = matchingSurfaces[0];
556
- action = { kind: 'append-to-surface', surface, code: args.code };
557
- chosenSurfaceLabel = surface.label;
558
- chosenSurfaceStartLine = surface.startLine;
559
- } else if (text.length === 0) {
560
- action = { kind: 'new-file', code: args.code };
561
- chosenSurfaceStartLine = 3; // line of new surface heading after H1 + blank
562
- } else {
563
- action = { kind: 'new-surface', code: args.code };
564
- }
565
-
566
- const { content, insertedAtLine } = buildNextContent(text, parsed, action, rendered, args.date_or_version);
567
-
568
- // For new-surface, recompute chosenSurfaceStartLine from output.
569
- if (action.kind === 'new-surface' || action.kind === 'new-file') {
570
- const outLines = content.split('\n');
571
- for (let i = 0; i < outLines.length; i++) {
572
- if (outLines[i] === `## ${args.date_or_version}`) {
573
- chosenSurfaceStartLine = i + 1;
574
- break;
575
- }
576
- }
577
- }
578
-
579
- atomicWrite(cwd, content);
607
+ await provider.putChangelog(spliced.content);
580
608
 
581
609
  const event = {
582
610
  tool: 'add_changelog_entry',
583
611
  code: args.code,
584
- surface_label: chosenSurfaceLabel,
585
- surface_start_line: chosenSurfaceStartLine,
612
+ surface_label: spliced.surface,
613
+ surface_start_line: spliced.chosenSurfaceStartLine,
586
614
  };
587
615
  if (args.idempotency_key) event.idempotency_key = args.idempotency_key;
588
- if (action.kind === 'replace') event.force = true;
589
- safeAppendEvent(cwd, event);
616
+ if (spliced.action?.kind === 'replace') event.force = true;
617
+ await safeAppendEvent(cwd, event);
590
618
 
591
619
  return {
592
- inserted_at: insertedAtLine,
620
+ inserted_at: spliced.insertedAtLine,
593
621
  idempotent: false,
594
- surface: chosenSurfaceLabel,
622
+ surface: spliced.surface,
595
623
  };
596
624
  });
597
625
  }
@@ -24,12 +24,19 @@
24
24
  import { mkdirSync, rmSync, statSync } from 'fs';
25
25
  import { join, dirname, posix } from 'path';
26
26
 
27
- import { readFeature, updateFeature, listFeatures } from './feature-json.js';
27
+ import { readFeature, listFeatures } from './feature-json.js';
28
28
  import { loadFeaturesDir } from './project-paths.js';
29
- import { appendEvent, normalizeSince } from './feature-events.js';
29
+ import { normalizeSince } from './feature-events.js';
30
30
  import { checkOrInsert } from './idempotency.js';
31
31
  import { FEATURE_CODE_RE_STRICT as FEATURE_CODE_RE } from './feature-code.js';
32
32
 
33
+ // providerFor is imported lazily to avoid the load-time cycle:
34
+ // factory.js → local-provider.js → completion-writer.js
35
+ async function getProvider(cwd) {
36
+ const { providerFor } = await import('./tracker/factory.js');
37
+ return providerFor(cwd);
38
+ }
39
+
33
40
  // ---------------------------------------------------------------------------
34
41
  // Constants + regexes
35
42
  // ---------------------------------------------------------------------------
@@ -206,11 +213,17 @@ function maybeIdempotent(args, fn) {
206
213
  // ---------------------------------------------------------------------------
207
214
  // safeAppendEvent — best-effort; failed append must NOT roll back a committed
208
215
  // mutation (per sibling-writer convention).
216
+ //
217
+ // Routes through provider.appendEvent so GitHubProvider can post
218
+ // <!--compose-event--> comments. LocalFileProvider delegates to
219
+ // feature-events.js#appendEvent producing byte-identical output.
209
220
  // ---------------------------------------------------------------------------
210
221
 
211
- function safeAppendEvent(cwd, event) {
222
+ async function safeAppendEvent(cwd, event) {
212
223
  try {
213
- appendEvent(cwd, event);
224
+ const { providerFor } = await import('./tracker/factory.js');
225
+ const provider = await providerFor(cwd);
226
+ await provider.appendEvent(event.code, event);
214
227
  } catch (err) {
215
228
  // eslint-disable-next-line no-console
216
229
  console.warn(
@@ -252,14 +265,16 @@ export async function recordCompletion(cwd, args) {
252
265
  // 3. Compute completion_id
253
266
  const completion_id = `${feature_code}:${commit_sha}`;
254
267
 
255
- const featuresDir = loadFeaturesDir(cwd);
256
268
  // 4. Wrap in maybeIdempotent for caller-key path
257
269
  return maybeIdempotent({ ...args, cwd }, async () => {
270
+ // Acquire provider once per operation (resolves factory, no per-call overhead)
271
+ const provider = await getProvider(cwd);
272
+
258
273
  // 5a. Acquire per-feature advisory lock (Decision 10)
259
274
  const release = await acquireFeatureLock(cwd, feature_code);
260
275
  try {
261
- // 5b. Read feature
262
- const feature = readFeature(cwd, feature_code, featuresDir);
276
+ // 5b. Read feature via provider (low-level primitive — no recursion risk)
277
+ const feature = await provider.getFeature(feature_code);
263
278
  if (!feature) throw notFoundError(feature_code);
264
279
 
265
280
  // 5c. Snapshot completions array
@@ -303,7 +318,9 @@ export async function recordCompletion(cwd, args) {
303
318
  }
304
319
 
305
320
  // 5h. Persist completion record BEFORE status flip (so flip failure doesn't lose the record)
306
- updateFeature(cwd, feature_code, { completions }, featuresDir);
321
+ // Use persistFeatureRaw (policy-free, no status-delta guard) completion-writer
322
+ // owns its own ordering/locking and only mutates the completions array, not status.
323
+ await provider.persistFeatureRaw(feature_code, { ...feature, completions });
307
324
 
308
325
  // 5i. Status flip (default on)
309
326
  const set_status = args.set_status !== false;
@@ -357,7 +374,7 @@ export async function recordCompletion(cwd, args) {
357
374
  };
358
375
  if (args.force) auditEvent.force = args.force;
359
376
  if (args.idempotency_key) auditEvent.idempotency_key = args.idempotency_key;
360
- safeAppendEvent(cwd, auditEvent);
377
+ await safeAppendEvent(cwd, auditEvent);
361
378
 
362
379
  // 5l. Return
363
380
  return {
@@ -19,13 +19,18 @@
19
19
  import { existsSync, realpathSync, statSync } from 'fs';
20
20
  import { resolve, normalize, sep, basename, dirname } from 'path';
21
21
 
22
- import { readFeature, writeFeature, listFeatures, updateFeature } from './feature-json.js';
23
- const _listFeatures = listFeatures;
24
- import { writeRoadmap } from './roadmap-gen.js';
25
- import { appendEvent, readEvents } from './feature-events.js';
22
+ import { readEvents } from './feature-events.js';
26
23
  import { checkOrInsert } from './idempotency.js';
27
24
  import { loadFeaturesDir } from './project-paths.js';
28
25
 
26
+ // providerFor is imported lazily (inside each function) to break the
27
+ // module-load-time cycle: factory.js → local-provider.js → feature-writer.js.
28
+ // Dynamic import resolves at call time, after all modules have loaded.
29
+ async function getProvider(cwd) {
30
+ const { providerFor } = await import('./tracker/factory.js');
31
+ return providerFor(cwd);
32
+ }
33
+
29
34
  // ---------------------------------------------------------------------------
30
35
  // Status / transition policy
31
36
  // ---------------------------------------------------------------------------
@@ -99,9 +104,10 @@ export async function addRoadmapEntry(cwd, args) {
99
104
  throw new Error(`feature-writer: invalid status "${status}"`);
100
105
  }
101
106
 
102
- const featuresDir = loadFeaturesDir(cwd);
103
- return maybeIdempotent({ ...args, cwd }, () => {
104
- const existing = readFeature(cwd, args.code, featuresDir);
107
+ return maybeIdempotent({ ...args, cwd }, async () => {
108
+ const provider = await getProvider(cwd);
109
+
110
+ const existing = await provider.getFeature(args.code);
105
111
  if (existing) {
106
112
  throw new Error(`feature-writer: feature "${args.code}" already exists`);
107
113
  }
@@ -118,14 +124,18 @@ export async function addRoadmapEntry(cwd, args) {
118
124
  if (args.complexity) feature.complexity = args.complexity;
119
125
  feature.position = args.position !== undefined
120
126
  ? args.position
121
- : nextPositionInPhase(cwd, args.phase, featuresDir);
127
+ : await nextPositionInPhase(provider, args.phase);
122
128
  if (args.parent) feature.parent = args.parent;
123
129
  if (args.tags && args.tags.length) feature.tags = args.tags;
124
130
 
125
- writeFeature(cwd, feature, featuresDir);
131
+ // Use createFeature (not putFeature) for the initial write of a brand-new
132
+ // feature: the not-found check above has already confirmed it doesn't exist,
133
+ // and createFeature carries the correct semantics for remote providers
134
+ // (e.g. GitHubProvider creates a new issue rather than patching an existing one).
135
+ await provider.createFeature(args.code, feature);
126
136
  let roadmapPath;
127
137
  try {
128
- roadmapPath = writeRoadmap(cwd, { featuresDir });
138
+ roadmapPath = await provider.renderRoadmap();
129
139
  } catch (err) {
130
140
  throw partialWriteError(
131
141
  `add_roadmap_entry: feature.json for "${args.code}" was written but ROADMAP.md regeneration failed. ` +
@@ -134,7 +144,7 @@ export async function addRoadmapEntry(cwd, args) {
134
144
  );
135
145
  }
136
146
 
137
- safeAppendEvent(cwd, {
147
+ await safeAppendEvent(cwd, {
138
148
  tool: 'add_roadmap_entry',
139
149
  code: args.code,
140
150
  to: status,
@@ -153,8 +163,9 @@ export async function addRoadmapEntry(cwd, args) {
153
163
 
154
164
  // Default position for a new feature: max existing position in the same
155
165
  // phase, plus 1. Falls back to 1 when the phase is empty.
156
- function nextPositionInPhase(cwd, phase, featuresDir) {
157
- const peers = _listFeatures(cwd, featuresDir).filter(f => f.phase === phase);
166
+ async function nextPositionInPhase(provider, phase) {
167
+ const all = await provider.listFeatures();
168
+ const peers = all.filter(f => f.phase === phase);
158
169
  if (peers.length === 0) return 1;
159
170
  const maxPos = peers.reduce((m, f) => {
160
171
  const p = typeof f.position === 'number' ? f.position : 0;
@@ -178,9 +189,14 @@ function partialWriteError(message, cause) {
178
189
  // Audit-log writes are best-effort: a failed append must NOT roll back a
179
190
  // committed mutation (per design Decision 2 and docs/mcp.md). Log a warning
180
191
  // and continue.
181
- function safeAppendEvent(cwd, event) {
192
+ //
193
+ // Routes through provider.appendEvent so GitHubProvider can post
194
+ // <!--compose-event--> comments + mirror Projects v2. LocalFileProvider
195
+ // delegates to feature-events.js#appendEvent producing byte-identical output.
196
+ async function safeAppendEvent(cwd, event) {
182
197
  try {
183
- appendEvent(cwd, event);
198
+ const provider = await getProvider(cwd);
199
+ await provider.appendEvent(event.code, event);
184
200
  } catch (err) {
185
201
  // eslint-disable-next-line no-console
186
202
  console.warn(`[feature-writer] audit append failed for ${event.tool} ${event.code ?? ''}: ${err.message}`);
@@ -207,9 +223,10 @@ export async function setFeatureStatus(cwd, args) {
207
223
  throw new Error(`feature-writer: invalid status "${args.status}" — must be one of ${[...STATUSES].join(', ')}`);
208
224
  }
209
225
 
210
- const featuresDir = loadFeaturesDir(cwd);
211
- return maybeIdempotent({ ...args, cwd }, () => {
212
- const feature = readFeature(cwd, args.code, featuresDir);
226
+ return maybeIdempotent({ ...args, cwd }, async () => {
227
+ const provider = await getProvider(cwd);
228
+
229
+ const feature = await provider.getFeature(args.code);
213
230
  if (!feature) {
214
231
  throw new Error(`feature-writer: feature "${args.code}" not found`);
215
232
  }
@@ -230,11 +247,14 @@ export async function setFeatureStatus(cwd, args) {
230
247
  );
231
248
  }
232
249
 
233
- const updates = { status: to };
234
- if (args.commit_sha) updates.commit_sha = args.commit_sha;
235
- updateFeature(cwd, args.code, updates, featuresDir);
250
+ // Build the updated feature object. We use persistFeatureRaw (not putFeature)
251
+ // because putFeature rejects status deltas by contract. Transition policy
252
+ // enforcement has already happened above — this is the raw persistence step.
253
+ const updated = { ...feature, status: to };
254
+ if (args.commit_sha) updated.commit_sha = args.commit_sha;
255
+ await provider.persistFeatureRaw(args.code, updated);
236
256
  try {
237
- writeRoadmap(cwd, { featuresDir });
257
+ await provider.renderRoadmap();
238
258
  } catch (err) {
239
259
  throw partialWriteError(
240
260
  `set_feature_status: feature.json for "${args.code}" was updated (${from} → ${to}) but ROADMAP.md regeneration failed. ` +
@@ -253,7 +273,7 @@ export async function setFeatureStatus(cwd, args) {
253
273
  if (args.reason) event.reason = args.reason;
254
274
  if (args.commit_sha) event.commit_sha = args.commit_sha;
255
275
  if (args.force && !allowed.includes(to)) event.forced = true;
256
- safeAppendEvent(cwd, event);
276
+ await safeAppendEvent(cwd, event);
257
277
 
258
278
  return { code: args.code, from, to, ts: new Date().toISOString() };
259
279
  });
@@ -382,8 +402,10 @@ export async function linkArtifact(cwd, args) {
382
402
  const featuresDir = loadFeaturesDir(cwd);
383
403
  rejectCanonicalArtifact(featuresDir, args.feature_code, normalizedPath);
384
404
 
385
- return maybeIdempotent({ ...args, cwd }, () => {
386
- const feature = readFeature(cwd, args.feature_code, featuresDir);
405
+ return maybeIdempotent({ ...args, cwd }, async () => {
406
+ const provider = await getProvider(cwd);
407
+
408
+ const feature = await provider.getFeature(args.feature_code);
387
409
  if (!feature) {
388
410
  throw new Error(`feature-writer: feature "${args.feature_code}" not found`);
389
411
  }
@@ -408,9 +430,9 @@ export async function linkArtifact(cwd, args) {
408
430
  if (matchIdx !== -1) artifacts[matchIdx] = entry;
409
431
  else artifacts.push(entry);
410
432
 
411
- updateFeature(cwd, args.feature_code, { artifacts }, featuresDir);
433
+ await provider.putFeature(args.feature_code, { ...feature, artifacts });
412
434
 
413
- safeAppendEvent(cwd, {
435
+ await safeAppendEvent(cwd, {
414
436
  tool: 'link_artifact',
415
437
  code: args.feature_code,
416
438
  artifact_type: args.artifact_type,
@@ -452,9 +474,10 @@ export async function linkFeatures(cwd, args) {
452
474
  );
453
475
  }
454
476
 
455
- const featuresDir = loadFeaturesDir(cwd);
456
- return maybeIdempotent({ ...args, cwd }, () => {
457
- const feature = readFeature(cwd, args.from_code, featuresDir);
477
+ return maybeIdempotent({ ...args, cwd }, async () => {
478
+ const provider = await getProvider(cwd);
479
+
480
+ const feature = await provider.getFeature(args.from_code);
458
481
  if (!feature) {
459
482
  throw new Error(`feature-writer: feature "${args.from_code}" not found`);
460
483
  }
@@ -474,9 +497,9 @@ export async function linkFeatures(cwd, args) {
474
497
  if (matchIdx !== -1) links[matchIdx] = entry;
475
498
  else links.push(entry);
476
499
 
477
- updateFeature(cwd, args.from_code, { links }, featuresDir);
500
+ await provider.putFeature(args.from_code, { ...feature, links });
478
501
 
479
- safeAppendEvent(cwd, {
502
+ await safeAppendEvent(cwd, {
480
503
  tool: 'link_features',
481
504
  code: args.from_code,
482
505
  to_code: args.to_code,
@@ -508,8 +531,8 @@ export async function linkFeatures(cwd, args) {
508
531
  */
509
532
  export async function getFeatureArtifacts(cwd, args) {
510
533
  validateCode(args.feature_code);
511
- const featuresDir = loadFeaturesDir(cwd);
512
- const feature = readFeature(cwd, args.feature_code, featuresDir);
534
+ const provider = await getProvider(cwd);
535
+ const feature = await provider.getFeature(args.feature_code);
513
536
  if (!feature) {
514
537
  throw new Error(`feature-writer: feature "${args.feature_code}" not found`);
515
538
  }
@@ -525,6 +548,7 @@ export async function getFeatureArtifacts(cwd, args) {
525
548
  let canonical = null;
526
549
  try {
527
550
  const { ArtifactManager } = await import('../server/artifact-manager.js');
551
+ const featuresDir = loadFeaturesDir(cwd);
528
552
  const featureRoot = resolve(realCwd, featuresDir);
529
553
  if (existsSync(featureRoot)) {
530
554
  const manager = new ArtifactManager(featureRoot);
@@ -550,7 +574,7 @@ export async function getFeatureArtifacts(cwd, args) {
550
574
  * @param {'outgoing'|'incoming'|'both'} [args.direction='both']
551
575
  * @param {string} [args.kind]
552
576
  */
553
- export function getFeatureLinks(cwd, args) {
577
+ export async function getFeatureLinks(cwd, args) {
554
578
  validateCode(args.feature_code);
555
579
  const direction = args.direction ?? 'both';
556
580
  if (!['outgoing', 'incoming', 'both'].includes(direction)) {
@@ -560,11 +584,11 @@ export function getFeatureLinks(cwd, args) {
560
584
  }
561
585
  const kind = args.kind;
562
586
 
563
- const featuresDir = loadFeaturesDir(cwd);
587
+ const provider = await getProvider(cwd);
564
588
  const out = { feature_code: args.feature_code };
565
589
 
566
590
  if (direction === 'outgoing' || direction === 'both') {
567
- const feature = readFeature(cwd, args.feature_code, featuresDir);
591
+ const feature = await provider.getFeature(args.feature_code);
568
592
  if (!feature) {
569
593
  throw new Error(`feature-writer: feature "${args.feature_code}" not found`);
570
594
  }
@@ -574,7 +598,7 @@ export function getFeatureLinks(cwd, args) {
574
598
  }
575
599
 
576
600
  if (direction === 'incoming' || direction === 'both') {
577
- const all = _listFeatures(cwd, featuresDir);
601
+ const all = await provider.listFeatures();
578
602
  const incoming = [];
579
603
  for (const f of all) {
580
604
  if (f.code === args.feature_code) continue;
@@ -46,22 +46,23 @@ function phaseStatus(features) {
46
46
  }
47
47
 
48
48
  /**
49
- * Generate ROADMAP.md content from feature.json files.
49
+ * Pure transform: merge a features array into a base ROADMAP.md text string
50
+ * and return the resulting text. No filesystem access.
50
51
  *
51
- * @param {string} cwd - Project root
52
+ * @param {string} baseText - The existing ROADMAP.md content (empty string for a fresh file)
53
+ * @param {Array} features - Feature objects (as returned by listFeatures)
52
54
  * @param {object} [opts]
53
- * @param {string} [opts.featuresDir] - Relative path to features dir
54
- * @param {string} [opts.projectName] - Project name for header
55
- * @param {string} [opts.projectDescription] - Project description for header
56
- * @returns {string} - Generated ROADMAP.md content
55
+ * @param {string} [opts.projectName] - Project name for default preamble
56
+ * @param {string} [opts.projectDescription] - Project description for default preamble
57
+ * @param {string} [opts.cwd] - Used only for drift emission (optional; defaults to '')
58
+ * @param {string} [opts.featuresDir] - Passed through to buildKeyDocs (optional)
59
+ * @returns {string} - Merged ROADMAP.md content
57
60
  */
58
- export function generateRoadmap(cwd, opts = {}) {
59
- const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
60
- const features = listFeatures(cwd, featuresDir);
61
+ export function generateRoadmapFromBase(baseText, features, opts = {}) {
62
+ const cwd = opts.cwd ?? '';
63
+ const featuresDir = opts.featuresDir ?? 'docs/features';
61
64
 
62
- // Read existing ROADMAP.md once: preamble + curated content for splice-back.
63
- const roadmapPath = join(cwd, 'ROADMAP.md');
64
- const existingText = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
65
+ const existingText = baseText ?? '';
65
66
  const preamble = readPreamble(cwd, opts, existingText);
66
67
  const overrides = readPhaseOverrides(existingText);
67
68
  const anonRows = readAnonymousRows(existingText);
@@ -141,7 +142,7 @@ export function generateRoadmap(cwd, opts = {}) {
141
142
  if (override) {
142
143
  const overrideToken = parseStatusToken(override);
143
144
  if (overrideToken && overrideToken !== rollupStatus) {
144
- emitDrift(cwd, { phaseId: phase, override, computed: rollupStatus });
145
+ if (cwd) emitDrift(cwd, { phaseId: phase, override, computed: rollupStatus });
145
146
  }
146
147
  // Override always wins. We can't reliably distinguish curated overrides
147
148
  // from previously-auto-generated rollups without explicit marking, so
@@ -189,6 +190,27 @@ export function generateRoadmap(cwd, opts = {}) {
189
190
  return sections.join('\n\n---\n\n') + '\n';
190
191
  }
191
192
 
193
+ /**
194
+ * Generate ROADMAP.md content from feature.json files.
195
+ *
196
+ * @param {string} cwd - Project root
197
+ * @param {object} [opts]
198
+ * @param {string} [opts.featuresDir] - Relative path to features dir
199
+ * @param {string} [opts.projectName] - Project name for header
200
+ * @param {string} [opts.projectDescription] - Project description for header
201
+ * @returns {string} - Generated ROADMAP.md content
202
+ */
203
+ export function generateRoadmap(cwd, opts = {}) {
204
+ const featuresDir = opts.featuresDir ?? loadFeaturesDir(cwd);
205
+ const features = listFeatures(cwd, featuresDir);
206
+
207
+ // Read existing ROADMAP.md once: preamble + curated content for splice-back.
208
+ const roadmapPath = join(cwd, 'ROADMAP.md');
209
+ const existingText = existsSync(roadmapPath) ? readFileSync(roadmapPath, 'utf-8') : '';
210
+
211
+ return generateRoadmapFromBase(existingText, features, { ...opts, cwd, featuresDir });
212
+ }
213
+
192
214
  /**
193
215
  * Read the preamble (everything before the first ## Phase/Feature section)
194
216
  * from an existing ROADMAP.md, or generate a default one.
@@ -206,7 +228,12 @@ function readPreamble(cwd, opts, existingText) {
206
228
  if (firstHeadingIdx === -1 || idx < firstHeadingIdx) firstHeadingIdx = idx;
207
229
  }
208
230
  }
209
- if (firstHeadingIdx > 0) {
231
+ if (firstHeadingIdx === -1) {
232
+ // No phase headings found — the entire file is a preamble (e.g. remote file
233
+ // contains only a header/intro with no generated sections yet). Preserve it.
234
+ const stripped = existingText.trimEnd().replace(/\n---\s*$/, '').trimEnd();
235
+ if (stripped.length > 0) return stripped;
236
+ } else if (firstHeadingIdx > 0) {
210
237
  // Walk back over a possible `---\n\n` separator immediately before the heading
211
238
  // so it doesn't get duplicated against the join("\n\n---\n\n") below.
212
239
  let cutIdx = firstHeadingIdx;