@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.
- package/README.md +42 -0
- package/bin/compose.js +16 -0
- package/dist/assets/{App-DMCO9aNs.js → App-DO9nGI18.js} +8 -8
- package/dist/assets/{arc-DsXb95RZ.js → arc-DrI_lR89.js} +1 -1
- package/dist/assets/{architectureDiagram-3BPJPVTR-BaBYippI.js → architectureDiagram-3BPJPVTR-DyS14rP-.js} +1 -1
- package/dist/assets/{blockDiagram-GPEHLZMM-HwB_eL3_.js → blockDiagram-GPEHLZMM-elTUtlnM.js} +1 -1
- package/dist/assets/{c4Diagram-AAUBKEIU-CPSXghc8.js → c4Diagram-AAUBKEIU-CJ_8nsiM.js} +1 -1
- package/dist/assets/channel-Cda02Ntm.js +1 -0
- package/dist/assets/{chunk-2J33WTMH-CCN3bc9J.js → chunk-2J33WTMH-BllJC9rQ.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-DuqFxpoV.js → chunk-4BX2VUAB-f3EEUpDr.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DT20mkDV.js → chunk-55IACEB6-CgbUlP7I.js} +1 -1
- package/dist/assets/{chunk-727SXJPM-ByMH6Qvp.js → chunk-727SXJPM-D9zRfRSY.js} +1 -1
- package/dist/assets/{chunk-AQP2D5EJ-CLgYtOHw.js → chunk-AQP2D5EJ-Bjp1xaJ8.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BXWmTsAA.js → chunk-FMBD7UC4-Dyvhvbkr.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-C2WgVbpE.js → chunk-ND2GUHAM-D4dMBzz5.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DFuQRJeh.js → chunk-QZHKN3VN-CVhvkQt1.js} +1 -1
- package/dist/assets/classDiagram-4FO5ZUOK-BykFns4A.js +1 -0
- package/dist/assets/classDiagram-v2-Q7XG4LA2-BykFns4A.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CdyvK5N2.js → cose-bilkent-S5V4N54A-CylcCFl3.js} +1 -1
- package/dist/assets/{dagre-BM42HDAG-Drkta_n5.js → dagre-BM42HDAG-Dd1Ny0g8.js} +1 -1
- package/dist/assets/{diagram-2AECGRRQ-BRiBkuu5.js → diagram-2AECGRRQ-tVs4SxwR.js} +1 -1
- package/dist/assets/{diagram-5GNKFQAL-IrSBDK26.js → diagram-5GNKFQAL-UC1fUNjx.js} +1 -1
- package/dist/assets/{diagram-KO2AKTUF-BUktYepH.js → diagram-KO2AKTUF-Cxhb85Xt.js} +1 -1
- package/dist/assets/{diagram-LMA3HP47-B5erGOiF.js → diagram-LMA3HP47-DJMx1YAp.js} +1 -1
- package/dist/assets/{diagram-OG6HWLK6-5KoSfwod.js → diagram-OG6HWLK6-k_cRur_6.js} +1 -1
- package/dist/assets/{erDiagram-TEJ5UH35-CXSf-i6t.js → erDiagram-TEJ5UH35-ClfbMYdQ.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-DiwEgd9q.js → flowDiagram-I6XJVG4X-B9KvjWpm.js} +1 -1
- package/dist/assets/{ganttDiagram-6RSMTGT7-zQ94YEl2.js → ganttDiagram-6RSMTGT7-CCqvcQcy.js} +1 -1
- package/dist/assets/{gitGraphDiagram-PVQCEYII-CWNWantF.js → gitGraphDiagram-PVQCEYII-DN9gJgDY.js} +1 -1
- package/dist/assets/{graph-DPbJeZyN.js → graph-uO5hwVZK.js} +1 -1
- package/dist/assets/{index-CyFM4bTc.js → index-CtjbBZuo.js} +3 -3
- package/dist/assets/index-Dh2rRpBR.css +1 -0
- package/dist/assets/{infoDiagram-5YYISTIA-BcnrgEm6.js → infoDiagram-5YYISTIA-BZjN-ULc.js} +1 -1
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-BRzURsJQ.js → ishikawaDiagram-YF4QCWOH-Ck1kwRqz.js} +1 -1
- package/dist/assets/{journeyDiagram-JHISSGLW-CdwMwMPo.js → journeyDiagram-JHISSGLW-CHKyH4p5.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-Difj4Zd-.js → kanban-definition-UN3LZRKU-Bq4CuVJj.js} +1 -1
- package/dist/assets/{linear-CKwgBFBW.js → linear-h6UanLnU.js} +1 -1
- package/dist/assets/{mindmap-definition-RKZ34NQL-C2aCD1L4.js → mindmap-definition-RKZ34NQL-IQcATrSw.js} +1 -1
- package/dist/assets/mobile-BwduHUEq.js +17 -0
- package/dist/assets/{pieDiagram-4H26LBE5-C9qGrfV0.js → pieDiagram-4H26LBE5-B7cw_oKH.js} +1 -1
- package/dist/assets/{quadrantDiagram-W4KKPZXB-COA0Z2JV.js → quadrantDiagram-W4KKPZXB-CqiNInwZ.js} +1 -1
- package/dist/assets/{requirementDiagram-4Y6WPE33-BJCU8yFE.js → requirementDiagram-4Y6WPE33-CynhQVI1.js} +1 -1
- package/dist/assets/{sankeyDiagram-5OEKKPKP-vYzK7FJ6.js → sankeyDiagram-5OEKKPKP-aFS3y10_.js} +1 -1
- package/dist/assets/{sequenceDiagram-3UESZ5HK-Bkh_RWpN.js → sequenceDiagram-3UESZ5HK-CTvZcil0.js} +1 -1
- package/dist/assets/{stateDiagram-AJRCARHV-BlUsbYTW.js → stateDiagram-AJRCARHV-DYXgroJe.js} +1 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-BVQEXVG-.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-DuYEZwxg.js → timeline-definition-PNZ67QCA-BtN3u7JC.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-D9a3Q3Ni.js → vennDiagram-CIIHVFJN-DsssmbTq.js} +1 -1
- package/dist/assets/{wardley-L42UT6IY-h2fQnc_J.js → wardley-L42UT6IY-BXD_QFvV.js} +1 -1
- package/dist/assets/{wardleyDiagram-YWT4CUSO-C-_dzSY5.js → wardleyDiagram-YWT4CUSO-CgZmwLSB.js} +1 -1
- package/dist/assets/{xychartDiagram-2RQKCTM6-CKxMIB7j.js → xychartDiagram-2RQKCTM6-BmiRpk6f.js} +1 -1
- package/dist/index.html +3 -3
- package/lib/build.js +60 -13
- package/lib/changelog-writer.js +111 -83
- package/lib/completion-writer.js +26 -9
- package/lib/feature-writer.js +62 -38
- package/lib/roadmap-gen.js +41 -14
- package/lib/tracker/cli.js +31 -0
- package/lib/tracker/factory.js +93 -0
- package/lib/tracker/github-api.js +115 -0
- package/lib/tracker/github-provider.js +641 -0
- package/lib/tracker/local-provider.js +202 -0
- package/lib/tracker/provider.js +40 -0
- package/lib/tracker/sync-engine.js +131 -0
- package/package.json +3 -2
- package/dist/assets/channel-TOlxWxU-.js +0 -1
- package/dist/assets/classDiagram-4FO5ZUOK-DZsvwI1V.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-DZsvwI1V.js +0 -1
- package/dist/assets/index-CHkeTiSt.css +0 -1
- package/dist/assets/mobile-CsuriFuT.js +0 -17
- package/dist/assets/stateDiagram-v2-BHNVJYJU-_AUWPuja.js +0 -1
package/lib/changelog-writer.js
CHANGED
|
@@ -20,14 +20,19 @@
|
|
|
20
20
|
* CLI, or future REST routes.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
24
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
601
|
+
inserted_at: spliced.insertedAtLine,
|
|
544
602
|
idempotent: true,
|
|
545
|
-
surface:
|
|
603
|
+
surface: spliced.surface,
|
|
546
604
|
};
|
|
547
605
|
}
|
|
548
606
|
|
|
549
|
-
|
|
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:
|
|
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
|
|
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:
|
|
622
|
+
surface: spliced.surface,
|
|
595
623
|
};
|
|
596
624
|
});
|
|
597
625
|
}
|
package/lib/completion-writer.js
CHANGED
|
@@ -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,
|
|
27
|
+
import { readFeature, listFeatures } from './feature-json.js';
|
|
28
28
|
import { loadFeaturesDir } from './project-paths.js';
|
|
29
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
package/lib/feature-writer.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
157
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
|
512
|
-
const feature =
|
|
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
|
|
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 =
|
|
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 =
|
|
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;
|
package/lib/roadmap-gen.js
CHANGED
|
@@ -46,22 +46,23 @@ function phaseStatus(features) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
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}
|
|
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.
|
|
54
|
-
* @param {string} [opts.
|
|
55
|
-
* @param {string} [opts.
|
|
56
|
-
* @
|
|
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
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
+
export function generateRoadmapFromBase(baseText, features, opts = {}) {
|
|
62
|
+
const cwd = opts.cwd ?? '';
|
|
63
|
+
const featuresDir = opts.featuresDir ?? 'docs/features';
|
|
61
64
|
|
|
62
|
-
|
|
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
|
|
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;
|