devflow-kit 2.2.0 → 2.4.0
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/CHANGELOG.md +45 -0
- package/dist/cli/commands/attribution-prompts.js +144 -0
- package/dist/cli/commands/compliance-prompts.js +7 -11
- package/dist/cli/commands/init-seed.js +45 -4
- package/dist/cli/commands/init.js +49 -6
- package/dist/cli/commands/prompt-io.js +38 -0
- package/dist/cli/commands/proxy.js +91 -31
- package/dist/cli/commands/uninstall.js +22 -12
- package/dist/commands/resolve.md +29 -16
- package/dist/core/flags.js +145 -4
- package/dist/core/proxy-log.js +4 -2
- package/dist/core/proxy-state.js +91 -37
- package/dist/targets/claude-code/post-install.js +106 -29
- package/package.json +2 -2
- package/src/assets/agents/learning.md +63 -13
- package/src/assets/agents/triage.md +19 -1
- package/src/assets/commands/resolve.mds +29 -16
- package/src/assets/scripts/hooks/background-memory-update +180 -37
- package/src/assets/scripts/hooks/ensure-proxy +64 -5
- package/src/assets/scripts/hooks/is-hex-sha +14 -0
- package/src/assets/scripts/hooks/json-helper.cjs +223 -38
- package/src/assets/scripts/hooks/lib/decisions-format.cjs +264 -43
- package/src/assets/scripts/hooks/lib/mkdir-lock.cjs +1 -1
- package/src/assets/scripts/hooks/lib/render-decisions.cjs +59 -28
- package/src/assets/scripts/hooks/pre-compact-memory +40 -22
- package/src/assets/scripts/hooks/session-start-memory +19 -20
- package/src/assets/skills/git/SKILL.md +1 -5
- package/src/assets/skills/git/references/patterns.md +0 -6
- package/src/targets/claude-code/templates/settings.json +0 -4
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// backup-construct Build pre-compact backup JSON from --arg pairs
|
|
27
27
|
// assign-anchor <type> <obs_id> Claim next ADR/PF number, render both .md files
|
|
28
28
|
// retire-anchor <anchor_id> <status> Flip ledger row status, re-render both .md files
|
|
29
|
+
// refresh-anchor <anchor_id> Re-project log obs onto ledger row, re-render
|
|
29
30
|
// rotate-observations [<log>] [<arch>] Archive observing rows older than 30 days
|
|
30
31
|
|
|
31
32
|
'use strict';
|
|
@@ -291,6 +292,51 @@ function parseArgs(argList) {
|
|
|
291
292
|
return { ...result, ...jsonArgs };
|
|
292
293
|
}
|
|
293
294
|
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
// Lock helpers — shared by the three decisions ledger ops (assign-anchor,
|
|
297
|
+
// retire-anchor, refresh-anchor). rotate-observations uses a DIFFERENT lock
|
|
298
|
+
// (.observations.lock) and keeps its own scaffold (avoids over-generalising).
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
/** Acquire-timeout for .decisions.lock (ms). Named to avoid magic numbers (COMP-4). */
|
|
302
|
+
const LOCK_ACQUIRE_TIMEOUT_MS = 30000;
|
|
303
|
+
/** Stale-break threshold for .decisions.lock (ms). Named to avoid magic numbers (COMP-4). */
|
|
304
|
+
const LOCK_STALE_MS = 60000;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Run fn() under .decisions.lock.
|
|
308
|
+
*
|
|
309
|
+
* Never call process.exit() inside fn — throw instead (PF-014): the throw propagates
|
|
310
|
+
* through the try/finally so releaseLock always runs. process.exit is reserved for
|
|
311
|
+
* the acquire-failure path where no lock is held and no cleanup is needed.
|
|
312
|
+
*
|
|
313
|
+
* PF-013: parent directory of the lock dir is created before acquireMkdirLock is
|
|
314
|
+
* called so a fresh-project cold-path does not throw ENOENT inside the lock lib.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} opName - operation name for error messages
|
|
317
|
+
* @param {string} projectRoot - project root (cwd)
|
|
318
|
+
* @param {() => unknown} fn - body to execute under the lock
|
|
319
|
+
*/
|
|
320
|
+
function withDecisionsLock(opName, projectRoot, fn) {
|
|
321
|
+
const lockDir = getDecisionsLockDir(projectRoot);
|
|
322
|
+
// PF-013: ensure parent directory exists before acquiring lock
|
|
323
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
324
|
+
if (!acquireMkdirLock(lockDir, LOCK_ACQUIRE_TIMEOUT_MS, LOCK_STALE_MS)) {
|
|
325
|
+
process.stderr.write(`${opName}: timeout acquiring lock at ${lockDir}\n`);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
try { return fn(); } finally { releaseLock(lockDir); }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Serialize ledger rows to a JSONL string with trailing newline.
|
|
333
|
+
* Extracted to avoid repeating the same expression at four sites (COMP-4).
|
|
334
|
+
*
|
|
335
|
+
* @param {object[]} rows
|
|
336
|
+
* @returns {string}
|
|
337
|
+
*/
|
|
338
|
+
const serializeLedger = rows => rows.map(r => JSON.stringify(r)).join('\n') + '\n';
|
|
339
|
+
|
|
294
340
|
if (require.main === module) {
|
|
295
341
|
try {
|
|
296
342
|
switch (op) {
|
|
@@ -511,16 +557,8 @@ try {
|
|
|
511
557
|
const aaProjectRoot = process.cwd();
|
|
512
558
|
const aaLedgerPath = getDecisionsLedgerPath(aaProjectRoot);
|
|
513
559
|
const aaLogPath = getDecisionsLogPath(aaProjectRoot);
|
|
514
|
-
const aaLockDir = getDecisionsLockDir(aaProjectRoot);
|
|
515
|
-
|
|
516
|
-
fs.mkdirSync(path.dirname(aaLockDir), { recursive: true });
|
|
517
|
-
|
|
518
|
-
if (!acquireMkdirLock(aaLockDir, 30000, 60000)) {
|
|
519
|
-
process.stderr.write(`assign-anchor: timeout acquiring lock at ${aaLockDir}\n`);
|
|
520
|
-
process.exit(1);
|
|
521
|
-
}
|
|
522
560
|
|
|
523
|
-
|
|
561
|
+
withDecisionsLock('assign-anchor', aaProjectRoot, () => {
|
|
524
562
|
// Read existing ledger (absent = empty)
|
|
525
563
|
const aaLedgerRows = parseLedger(aaLedgerPath);
|
|
526
564
|
|
|
@@ -531,7 +569,6 @@ try {
|
|
|
531
569
|
let aaLogEntries = parseLedger(aaLogPath);
|
|
532
570
|
const aaObsIdx = aaLogEntries.findIndex(e => e.id === assignObsId);
|
|
533
571
|
if (aaObsIdx === -1) {
|
|
534
|
-
// throw instead of process.exit so the finally block releases the lock
|
|
535
572
|
throw new Error(`assign-anchor: obs_id '${assignObsId}' not found in ${aaLogPath}`);
|
|
536
573
|
}
|
|
537
574
|
const aaObs = aaLogEntries[aaObsIdx];
|
|
@@ -545,7 +582,6 @@ try {
|
|
|
545
582
|
// never fire in normal operation — it guards against double-assign
|
|
546
583
|
// bugs (e.g. assign called twice for the same obs_id in a crash loop).
|
|
547
584
|
if (aaLedgerRows.some(r => r.anchor_id === aaAnchorId)) {
|
|
548
|
-
// throw instead of process.exit so the finally block releases the lock
|
|
549
585
|
throw new Error(
|
|
550
586
|
`assign-anchor: anchor_id '${aaAnchorId}' already present in ledger — ` +
|
|
551
587
|
`possible double-assign; refusing to overwrite committed entry`
|
|
@@ -557,7 +593,6 @@ try {
|
|
|
557
593
|
// (the old anchor would remain in the ledger AND the new one would
|
|
558
594
|
// be added), corrupting the committed source of truth.
|
|
559
595
|
if (aaObs.anchor_id) {
|
|
560
|
-
// throw instead of process.exit so the finally block releases the lock
|
|
561
596
|
throw new Error(
|
|
562
597
|
`assign-anchor: obs_id '${assignObsId}' is already anchored as '${aaObs.anchor_id}'; ` +
|
|
563
598
|
`use retire-anchor to change its status instead`
|
|
@@ -570,13 +605,15 @@ try {
|
|
|
570
605
|
// that must stay in the log only. applies ADR-008.
|
|
571
606
|
const aaDate = new Date().toISOString().slice(0, 10);
|
|
572
607
|
const aaActiveStatus = assignType === 'decision' ? 'Accepted' : 'Active';
|
|
573
|
-
// Date
|
|
574
|
-
//
|
|
575
|
-
|
|
608
|
+
// Date stamped on ALL entry types (decisions + pitfalls). Prefer the
|
|
609
|
+
// date from the observation (content authority per ADR-022); fall back
|
|
610
|
+
// to today. Both types carry a date so refresh-anchor can re-project
|
|
611
|
+
// them correctly (pattern refreshes too — consumers match anchor headings, never titles, per ADR-022).
|
|
612
|
+
const aaEntryDate = aaObs.date || aaDate;
|
|
576
613
|
const aaLedgerRow = toLedgerRow(aaObs, {
|
|
577
614
|
anchorId: aaAnchorId,
|
|
578
615
|
status: aaActiveStatus,
|
|
579
|
-
date:
|
|
616
|
+
date: aaEntryDate,
|
|
580
617
|
});
|
|
581
618
|
|
|
582
619
|
// Append anchored row to ledger (atomic temp+rename).
|
|
@@ -588,11 +625,14 @@ try {
|
|
|
588
625
|
// .md files. The render is kept as the FINAL write under the lock so
|
|
589
626
|
// the window is as narrow as possible.
|
|
590
627
|
const aaNewLedgerRows = [...aaLedgerRows, aaLedgerRow];
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
//
|
|
595
|
-
|
|
628
|
+
writeFileAtomic(aaLedgerPath, serializeLedger(aaNewLedgerRows));
|
|
629
|
+
|
|
630
|
+
// Mark log row as created and stamp anchor_id so guard (b) fires on
|
|
631
|
+
// any subsequent assign-anchor call for the same obs_id. Without this
|
|
632
|
+
// write-back the guard is dead: aaObs.anchor_id would be undefined on
|
|
633
|
+
// a re-read and a second assign would silently mint a duplicate number.
|
|
634
|
+
// applies ADR-022 (log is content authority; anchor_id written back to arm guard).
|
|
635
|
+
aaLogEntries[aaObsIdx] = Object.assign({}, aaObs, { status: 'created', anchor_id: aaAnchorId });
|
|
596
636
|
writeJsonlAtomic(aaLogPath, aaLogEntries);
|
|
597
637
|
|
|
598
638
|
// Register usage entry
|
|
@@ -604,9 +644,7 @@ try {
|
|
|
604
644
|
|
|
605
645
|
// Print assigned anchor id to stdout
|
|
606
646
|
process.stdout.write(aaAnchorId + '\n');
|
|
607
|
-
}
|
|
608
|
-
releaseLock(aaLockDir);
|
|
609
|
-
}
|
|
647
|
+
});
|
|
610
648
|
break;
|
|
611
649
|
}
|
|
612
650
|
|
|
@@ -635,33 +673,180 @@ try {
|
|
|
635
673
|
|
|
636
674
|
const raProjectRoot = process.cwd();
|
|
637
675
|
const raLedgerPath = getDecisionsLedgerPath(raProjectRoot);
|
|
638
|
-
const raLockDir = getDecisionsLockDir(raProjectRoot);
|
|
639
|
-
|
|
640
|
-
fs.mkdirSync(path.dirname(raLockDir), { recursive: true });
|
|
641
|
-
|
|
642
|
-
if (!acquireMkdirLock(raLockDir, 30000, 60000)) {
|
|
643
|
-
process.stderr.write(`retire-anchor: timeout acquiring lock at ${raLockDir}\n`);
|
|
644
|
-
process.exit(1);
|
|
645
|
-
}
|
|
646
676
|
|
|
647
|
-
|
|
677
|
+
withDecisionsLock('retire-anchor', raProjectRoot, () => {
|
|
648
678
|
const raRows = parseLedger(raLedgerPath);
|
|
649
679
|
const raIdx = raRows.findIndex(r => r.anchor_id === retireAnchorId);
|
|
650
680
|
if (raIdx === -1) {
|
|
651
|
-
// throw instead of process.exit so the finally block releases the lock
|
|
652
681
|
throw new Error(`retire-anchor: anchor_id '${retireAnchorId}' not found in ledger`);
|
|
653
682
|
}
|
|
654
683
|
|
|
655
684
|
// Idempotent: if already set to same status, still write (no-op equivalent)
|
|
656
685
|
raRows[raIdx] = Object.assign({}, raRows[raIdx], { decisions_status: retireStatus });
|
|
657
|
-
|
|
658
|
-
writeFileAtomic(raLedgerPath, raLedgerContent);
|
|
686
|
+
writeFileAtomic(raLedgerPath, serializeLedger(raRows));
|
|
659
687
|
|
|
660
688
|
// Re-render both .md (lock-free — we already hold .decisions.lock)
|
|
661
689
|
renderAndWriteAll(raProjectRoot, raRows);
|
|
662
|
-
|
|
663
|
-
|
|
690
|
+
|
|
691
|
+
// Echo anchor_id to stdout matching the other three ops (CON-P1).
|
|
692
|
+
process.stdout.write(retireAnchorId + '\n');
|
|
693
|
+
});
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// -------------------------------------------------------------------------
|
|
698
|
+
// refresh-anchor <anchor_id> [<anchor_id>...]
|
|
699
|
+
// ADR-022: Re-project log observations onto committed ledger rows and
|
|
700
|
+
// re-render all three files (decisions.md, pitfalls.md, index.md). Each write
|
|
701
|
+
// is atomic; the sequence is not transactional — a crash between writes self-heals
|
|
702
|
+
// on the next ledger op. Variadic — accepts 1..N anchor ids and performs
|
|
703
|
+
// ONE lock acquisition, ONE ledger parse, ONE log parse, and ONE render
|
|
704
|
+
// (PERF-1: collapses N agent turns into 1, N re-renders into 1).
|
|
705
|
+
//
|
|
706
|
+
// All-or-nothing semantics: every anchor is validated before any write;
|
|
707
|
+
// a throw on any anchor leaves the ledger and .md files untouched.
|
|
708
|
+
//
|
|
709
|
+
// Algorithm:
|
|
710
|
+
// 1. Read ledger and log ONCE (outside the per-anchor loop).
|
|
711
|
+
// 2. For each anchor: locate ledger row, run precondition checks, run
|
|
712
|
+
// REG-1 details divergence guard (ADR-022: consumers match anchor headings not
|
|
713
|
+
// titles so pattern replacement is sanctioned; only details containment is enforced),
|
|
714
|
+
// re-project via toLedgerRow (which carries PF-023 sink validation for pattern/raw_body/type).
|
|
715
|
+
// 3. Assert row count unchanged (REL-6 — bounds parseLedger silent-drop exposure).
|
|
716
|
+
// 4. Write ledger once, render once, echo all ids to stdout (one per line).
|
|
717
|
+
//
|
|
718
|
+
// Locking discipline: holds ONLY .decisions.lock.
|
|
719
|
+
// -------------------------------------------------------------------------
|
|
720
|
+
case 'refresh-anchor': {
|
|
721
|
+
const refreshAnchorIds = args.filter(Boolean);
|
|
722
|
+
|
|
723
|
+
if (refreshAnchorIds.length === 0) {
|
|
724
|
+
process.stderr.write('refresh-anchor: usage: refresh-anchor <anchor_id> [<anchor_id>...]\n');
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const rfProjectRoot = process.cwd();
|
|
729
|
+
const rfLedgerPath = getDecisionsLedgerPath(rfProjectRoot);
|
|
730
|
+
const rfLogPath = getDecisionsLogPath(rfProjectRoot);
|
|
731
|
+
|
|
732
|
+
// SEC-S3: refuse when no ledger exists at the resolved project root. A refresh
|
|
733
|
+
// is only valid for a project with a committed ledger — invoked from the wrong
|
|
734
|
+
// cwd withDecisionsLock would otherwise silently materialise a stray
|
|
735
|
+
// .devflow/learning/ tree before throwing 'not found in ledger'.
|
|
736
|
+
if (!fs.existsSync(rfLedgerPath)) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
`refresh-anchor: no decisions-ledger.jsonl found at '${rfLedgerPath}' — ` +
|
|
739
|
+
`cannot refresh an entry where no ledger exists`
|
|
740
|
+
);
|
|
664
741
|
}
|
|
742
|
+
|
|
743
|
+
withDecisionsLock('refresh-anchor', rfProjectRoot, () => {
|
|
744
|
+
// (1) Read ledger and log ONCE — shared across all anchor ids (PERF-1).
|
|
745
|
+
const rfLedgerRows = parseLedger(rfLedgerPath);
|
|
746
|
+
const rfExpectedRowCount = rfLedgerRows.length;
|
|
747
|
+
const rfLogEntries = parseLedger(rfLogPath);
|
|
748
|
+
|
|
749
|
+
// (2) Validate and re-project each anchor — all-or-nothing: any throw
|
|
750
|
+
// propagates out of withDecisionsLock's fn() before any write occurs.
|
|
751
|
+
for (const anchorId of refreshAnchorIds) {
|
|
752
|
+
// Locate the existing ledger row by anchor_id (stable, canonical key).
|
|
753
|
+
// Miss → throw (PF-014: throw, not process.exit, inside a lock scope).
|
|
754
|
+
const rfLedgerIdx = rfLedgerRows.findIndex(r => r.anchor_id === anchorId);
|
|
755
|
+
if (rfLedgerIdx === -1) {
|
|
756
|
+
throw new Error(
|
|
757
|
+
`refresh-anchor: anchor_id '${anchorId}' not found in ledger — ` +
|
|
758
|
+
`cannot refresh a row that was never committed`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const rfExistingRow = rfLedgerRows[rfLedgerIdx];
|
|
763
|
+
|
|
764
|
+
// Precondition assertions — checked under the lock (assert-preconditions
|
|
765
|
+
// per reliability rule). Mirrors assign-anchor's pattern.
|
|
766
|
+
// (a) Ledger row must have an id — undefined===undefined would bind the wrong log row.
|
|
767
|
+
if (!rfExistingRow.id) {
|
|
768
|
+
throw new Error(
|
|
769
|
+
`refresh-anchor: ledger row '${anchorId}' has no id — ` +
|
|
770
|
+
`cannot resolve its log observation`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
// (b) Ledger row must have decisions_status — toLedgerRow passes it through;
|
|
774
|
+
// absent would cause JSON.stringify to drop the key from the projected row.
|
|
775
|
+
if (!rfExistingRow.decisions_status) {
|
|
776
|
+
throw new Error(
|
|
777
|
+
`refresh-anchor: ledger row '${anchorId}' has no decisions_status — ` +
|
|
778
|
+
`refusing to project a row that would drop it`
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Locate the log obs by the LEDGER ROW's id field (content authority, ADR-022).
|
|
783
|
+
// Matching on id (not anchor_id) covers pre-existing obs written before
|
|
784
|
+
// assign-anchor added anchor_id write-back to the log (avoids PF-041).
|
|
785
|
+
const rfObs = rfLogEntries.find(r => r.id === rfExistingRow.id);
|
|
786
|
+
if (!rfObs) {
|
|
787
|
+
throw new Error(
|
|
788
|
+
`refresh-anchor: log obs with id '${rfExistingRow.id}' ` +
|
|
789
|
+
`(for anchor ${anchorId}) not found in log`
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// (c) Type must match the committed anchor — re-projecting across types would move
|
|
794
|
+
// a PF-NNN into decisions.md (or vice versa) and corrupt the rendered corpus.
|
|
795
|
+
// This check also satisfies toLedgerRow's expectType guard (PF-023 sink);
|
|
796
|
+
// both fire with their respective messages — this one fires first.
|
|
797
|
+
if (rfObs.type !== rfExistingRow.type) {
|
|
798
|
+
throw new Error(
|
|
799
|
+
`refresh-anchor: log obs '${rfObs.id}' type '${rfObs.type}' does not match committed anchor ` +
|
|
800
|
+
`${anchorId} type '${rfExistingRow.type}' — refusing to re-project across entry types`
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// REG-1 (avoids PF-044): divergence guard — refuse to silently overwrite
|
|
805
|
+
// ledger-only curation content. Applies to DETAILS only: pattern replacement
|
|
806
|
+
// is sanctioned (ADR-022 — consumers match '## (ADR|PF)-NNN:' anchors, never
|
|
807
|
+
// titles, so a sharpened log pattern may update the rendered heading).
|
|
808
|
+
// raw_body is handled by isSafeRawBody inside toLedgerRow (PF-023 sink).
|
|
809
|
+
const rfNormWS = (/** @type {unknown} */ s) =>
|
|
810
|
+
typeof s === 'string' ? s.replace(/\s+/g, ' ').trim() : '';
|
|
811
|
+
const rfLedgerDetails = rfNormWS(rfExistingRow.details);
|
|
812
|
+
const rfLogDetails = rfNormWS(rfObs.details);
|
|
813
|
+
if (rfLedgerDetails && !rfLogDetails.includes(rfLedgerDetails)) {
|
|
814
|
+
throw new Error(
|
|
815
|
+
`refresh-anchor: ledger row '${anchorId}' carries content absent from log obs ` +
|
|
816
|
+
`'${rfExistingRow.id}' (details: ledger ${rfLedgerDetails.length}B / log ${rfLogDetails.length}B). ` +
|
|
817
|
+
`Reconcile the log row first — re-projecting would discard curated content (avoids PF-044).`
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Re-project via toLedgerRow (strict canonical projection — ADR-022).
|
|
822
|
+
// Preserve decisions_status and date from the ledger (ledger-owned fields).
|
|
823
|
+
// expectType passed for PF-023 sink validation (redundant with the check above,
|
|
824
|
+
// but ensures the guard holds even if future callers bypass the outer check).
|
|
825
|
+
rfLedgerRows[rfLedgerIdx] = toLedgerRow(rfObs, {
|
|
826
|
+
anchorId,
|
|
827
|
+
status: rfExistingRow.decisions_status,
|
|
828
|
+
date: rfExistingRow.date,
|
|
829
|
+
expectType: rfExistingRow.type,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// (3) REL-6: assert row count unchanged — bounds parseLedger silent-drop
|
|
834
|
+
// exposure. A whole-file rewrite that shrank the corpus is always a bug.
|
|
835
|
+
if (rfLedgerRows.length !== rfExpectedRowCount) {
|
|
836
|
+
throw new Error(
|
|
837
|
+
`refresh-anchor: ledger row count changed during re-projection ` +
|
|
838
|
+
`(${rfExpectedRowCount} → ${rfLedgerRows.length}) — refusing to write a lossy rewrite`
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// (4) Write once and render once (PERF-1 — N anchors, one I/O round-trip).
|
|
843
|
+
writeFileAtomic(rfLedgerPath, serializeLedger(rfLedgerRows));
|
|
844
|
+
renderAndWriteAll(rfProjectRoot, rfLedgerRows);
|
|
845
|
+
|
|
846
|
+
// Echo all refreshed ids to stdout — one per line, mirrors assign-anchor's
|
|
847
|
+
// contract; callers can confirm which rows were refreshed without parsing stderr.
|
|
848
|
+
process.stdout.write(refreshAnchorIds.join('\n') + '\n');
|
|
849
|
+
});
|
|
665
850
|
break;
|
|
666
851
|
}
|
|
667
852
|
|