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.
@@ -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
- try {
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 set on decisions only (byte-compat asymmetry — formatDecisionBody
574
- // emits "- **Date**: …"; pitfall rows have no date field)
575
- const aaDecisionDate = assignType === 'decision' ? (aaObs.date || aaDate) : undefined;
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: aaDecisionDate,
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
- const aaLedgerContent = aaNewLedgerRows.map(r => JSON.stringify(r)).join('\n') + '\n';
592
- writeFileAtomic(aaLedgerPath, aaLedgerContent);
593
-
594
- // Mark log row as created
595
- aaLogEntries[aaObsIdx] = Object.assign({}, aaObs, { status: 'created' });
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
- } finally {
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
- try {
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
- const raLedgerContent = raRows.map(r => JSON.stringify(r)).join('\n') + '\n';
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
- } finally {
663
- releaseLock(raLockDir);
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