@sabaiway/agent-workflow-memory 8.4.0 → 8.5.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 CHANGED
@@ -4,6 +4,40 @@ All notable changes to the memory substrate. Versions are this **package's** npm
4
4
  they are distinct from the **deployment-lineage** stamp written into a project's
5
5
  `docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `4.0.0`).
6
6
 
7
+ ## 8.5.0 — the decisions script brings your ADR store current in one command, and writes nothing when it already is (AD-200)
8
+
9
+ **`references/scripts/archive-decisions.mjs` gains `--ensure-current`.** One command judges the ADR store before it
10
+ writes anything, then brings it current:
11
+
12
+ - A retired monolith (`docs/ai/history/decisions-archive.md` or `decisions-archive-early.md`) beside
13
+ `docs/ai/decisions.md` is migrated, after the
14
+ `--migrate` preflight in full and a snapshot in the git dir (else in the OS temp dir, when that lies outside the
15
+ project).
16
+ - A `docs/ai/decisions.md` over its `maxLines` is rotated: its oldest entries become `adr/` records and the
17
+ `docs/ai` links to them follow.
18
+ - The navigator `docs/ai/adr/log.md` is written only when it differs from its regeneration, so a current store never
19
+ gains a date-only diff.
20
+ - Every refusal is decided before the first write, among them: a malformed heading, two records for one id, a
21
+ monolith with no `decisions.md` beside it, a dead ADR reference the moves would not cure, a HOT window whose newest
22
+ entry alone exceeds its cap. A project with no ADR files is
23
+ skipped, unless a `docs/ai` page links into one.
24
+ - Exit 0 ends on the line `already-current` or `brought-current: <actions>` (`migrated`, `rotated`,
25
+ `navigator written`); 1 means no project file changed; 3 means one changed and the run then failed: after an
26
+ interrupted write a re-run completes the move, and after a failed closing check the message names the check's lines
27
+ and says the store was written and does not pass; 2 is a usage error, `--ensure-current` beside another mode flag included.
28
+
29
+ **Every write to a project file replaces it whole, in every mode.** A record, a rewritten link, `decisions.md` and the navigator
30
+ are each written to a temporary file beside the target, whose name never ends in `.md`, then renamed into place: an
31
+ interrupted run leaves each file with its old bytes or its new ones, and the next run completes the move. A record
32
+ under another slug for its id moves to its canonical name by one rename. A snapshot location that fails partway has
33
+ its partial directory removed before the next is tried, and an OS temp dir inside the project root is no longer used
34
+ for the snapshot.
35
+
36
+ MINOR: one new mode. Every other mode keeps its output and exit codes, with these exceptions: the usage line names the
37
+ new mode; `--migrate --apply` no longer writes its snapshot to an OS temp dir inside the project root, so with no usable
38
+ git dir it then exits 1, and its snapshot refusal names each location that failed; a failed write may name its
39
+ temporary file.
40
+
7
41
  ## 8.4.0 — the commit hook runs your own scripts/pre-commit.local first (AD-195)
8
42
 
9
43
  **Your own commit checks now have a place of their own: `scripts/pre-commit.local`.** Every hook that
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-memory
3
3
  description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '8.4.0'
6
+ version: '8.5.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-memory",
5
5
  "kind": "memory-substrate",
6
- "version": "8.4.0",
6
+ "version": "8.5.0",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "8.4.0",
3
+ "version": "8.5.0",
4
4
  "description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -44,8 +44,18 @@
44
44
  // earn a go-ahead without risking a partial write.
45
45
  // --dry-run print the planned rotation move-set + inbound-rewrite set (file:line, old
46
46
  // target → new target), change nothing.
47
+ // --ensure-current bring the store current, alone: judge it before any write by the functions above
48
+ // (migrate a monolith, rotate a HOT window over its cap, write the navigator only
49
+ // when it differs, refuse a dead reference the moves would not cure), write, and
50
+ // close with an in-process --check. Exit 0 (the last stdout line `already-current`
51
+ // or `brought-current: <actions>`), 1 (no work-tree file changed), 3 (a file
52
+ // changed, then the run failed), 2 (usage).
47
53
  // --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs).
48
54
  //
55
+ // Every project-file write replaces its file whole: a temporary file beside it (named after it, never ending
56
+ // in .md), then one rename. The fs seams for tests: deps.writeFileSync (each file written), deps.renameSync
57
+ // (each rename), deps.rmSync (each removal), deps.fsyncSync (each sync) — node:fs when absent.
58
+ //
49
59
  // Reference-scan boundary + stated limitations: the inbound-link scan covers docs/ai/** ONLY (links
50
60
  // in README / agent entry points are out of scope); the ADR corpus surfaces themselves are NEVER
51
61
  // rewritten — a rewrite-form link to a moved id inside decisions.md, an adr/ record or a monolith
@@ -75,11 +85,11 @@
75
85
  //
76
86
  // Dependency-free, Node >= 22. Deployed into a consumer's scripts/ like its siblings.
77
87
 
78
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, realpathSync } from 'node:fs';
79
- import { dirname, resolve, join, posix } from 'node:path';
88
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, realpathSync, openSync, fsyncSync, closeSync } from 'node:fs';
89
+ import { dirname, resolve, join, posix, relative, isAbsolute } from 'node:path';
80
90
  import { fileURLToPath } from 'node:url';
81
91
  import { spawnSync } from 'node:child_process';
82
- import { createHash } from 'node:crypto';
92
+ import { createHash, randomBytes } from 'node:crypto';
83
93
  import { tmpdir } from 'node:os';
84
94
  import { tokenizeMarkdown } from './markdown-blocks.mjs';
85
95
 
@@ -520,7 +530,67 @@ export const verifyRewriteConservation = (plan) => {
520
530
  const summarizeRewrites = (plans) =>
521
531
  plans.flatMap((plan) => plan.rewrites.map((rw) => `${plan.rel}:${rw.line} ${rw.old} → ${rw.new}`));
522
532
 
523
- export const writeInboundRewrites = (root, plans) => {
533
+ // ── whole-file writes (a temporary file beside the target, then one rename) ─────────────
534
+
535
+ // The fs seams: every file the rotator writes goes through deps.writeFileSync, every rename through
536
+ // deps.renameSync, every removal through deps.rmSync, every sync through deps.fsyncSync. onChange fires once a
537
+ // project file has changed.
538
+ const fsIo = (deps = {}, onChange = () => {}) => {
539
+ const rename = deps.renameSync ?? renameSync;
540
+ const remove = deps.rmSync ?? rmSync;
541
+ return {
542
+ write: deps.writeFileSync ?? writeFileSync,
543
+ fsync: deps.fsyncSync ?? fsyncSync,
544
+ remove,
545
+ rename: (from, to) => {
546
+ rename(from, to);
547
+ onChange();
548
+ },
549
+ removeFile: (path) => {
550
+ remove(path);
551
+ onChange();
552
+ },
553
+ };
554
+ };
555
+
556
+ const fsyncPath = (io, path, flags = 'r') => {
557
+ const fd = openSync(path, flags);
558
+ try {
559
+ io.fsync(fd);
560
+ } finally {
561
+ closeSync(fd);
562
+ }
563
+ };
564
+ const DIRECTORY_FSYNC_UNSUPPORTED = ['EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM', 'EISDIR'];
565
+
566
+ // An interruption leaves the target whole — its old bytes or its new ones: the temporary file, named per run
567
+ // so no earlier run's leftover meets the exclusive create, is created exclusively, synced, then renamed over
568
+ // the target. A failed write removes it, never a name another writer holds; its name never ends in .md, so no
569
+ // reader of the store takes a leftover for a document.
570
+ const writeWhole = (io, path, content) => {
571
+ const temp = `${path}.${process.pid}-${randomBytes(4).toString('hex')}.tmp`;
572
+ try {
573
+ io.write(temp, content, { encoding: 'utf8', flag: 'wx' });
574
+ fsyncPath(io, temp, 'r+'); // Windows flushes a file only through a handle open for writing
575
+ io.rename(temp, path);
576
+ } catch (err) {
577
+ if (err?.code !== 'EEXIST') {
578
+ try {
579
+ io.remove(temp, { force: true });
580
+ } catch (cleanupErr) {
581
+ err.message += ` (its temporary file ${temp} was left: ${cleanupErr.message})`;
582
+ }
583
+ }
584
+ throw err;
585
+ }
586
+ try {
587
+ fsyncPath(io, dirname(path));
588
+ } catch (err) {
589
+ if (!DIRECTORY_FSYNC_UNSUPPORTED.includes(err?.code)) throw err; // the rename is complete either way
590
+ }
591
+ };
592
+
593
+ export const writeInboundRewrites = (root, plans, io = fsIo()) => {
524
594
  // Two passes (the plan is computed pre-write, D4): verify the LIVE bytes of EVERY planned file
525
595
  // first, then write — a drift anywhere refuses with NOTHING written in the rewrite phase.
526
596
  for (const plan of plans) {
@@ -529,36 +599,49 @@ export const writeInboundRewrites = (root, plans) => {
529
599
  }
530
600
  }
531
601
  for (const plan of plans) {
532
- writeFileSync(resolve(root, plan.rel), `${plan.frontmatter}${plan.afterLines.join('\n')}`, 'utf8');
602
+ writeWhole(io, resolve(root, plan.rel), `${plan.frontmatter}${plan.afterLines.join('\n')}`);
533
603
  }
534
604
  };
535
605
 
606
+ // The unfenced lines of one markdown file, each with its file line and whether it sits in the preamble
607
+ // (before the first `## ` heading).
608
+ const liveLines = (rel, text) => {
609
+ const { frontLines, lines, fencedLines, headings } = tokenizeMarkdown(text, rel);
610
+ const first = headings.find((h) => h.level === 2 && h.text.startsWith('## '));
611
+ const blocksFrom = first ? first.index : lines.length;
612
+ return lines.flatMap((line, index) => (fencedLines.has(index) ? [] : [{ fileLine: frontLines + index + 1, line, inPreamble: index < blocksFrom }]));
613
+ };
614
+
536
615
  // CHECK scope (read-only, wider): docs/ai/** including decisions.md (whole file) and the adr/
537
616
  // records, excluding only the generated navigator. (a) a `decisions.md#ad-NNN…` id outside the
538
617
  // CURRENT HOT window is dead (an archived id is stale, not resolving); (b) an `adr/AD-NNN-slug.md`
539
- // link must name an existing record FILE (exact filename, not id-presence).
540
- const collectReferenceViolations = (root, hotIds, archivedIds) => {
618
+ // link must name an existing record FILE (exact filename, not id-presence). Neither rule reads the
619
+ // linking file's own path, so a block is judged the same before and after it moves.
620
+ const scanReferences = (docs, hotIds, archivedIds, recordExists) => {
541
621
  const violations = [];
542
- for (const rel of walkDocsMarkdown(root).filter((r) => r !== NAV_REL)) {
543
- const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
544
- for (let index = 0; index < lines.length; index += 1) {
545
- if (fencedLines.has(index)) continue;
546
- const fileLine = frontLines + index + 1;
547
- for (const m of lines[index].matchAll(HOT_LINK_RE)) {
622
+ for (const [rel, live] of docs) {
623
+ for (const { fileLine, line } of live) {
624
+ for (const m of line.matchAll(HOT_LINK_RE)) {
548
625
  if (hotIds.has(m[2])) continue;
549
626
  const why = archivedIds.has(m[2]) ? `AD-${m[2]} is archived — repoint the link at ${ADR_DIR_REL}/` : `AD-${m[2]} is not in the current HOT window`;
550
627
  violations.push(`${rel}:${fileLine}: dead ADR anchor "${m[0]}" — ${why}`);
551
628
  }
552
- for (const m of lines[index].matchAll(RECORD_LINK_RE)) {
553
- if (!existsSync(resolve(root, ADR_DIR_REL, m[1]))) {
554
- violations.push(`${rel}:${fileLine}: dead ADR record link "${m[0]}" — no record file ${ADR_DIR_REL}/${m[1]}`);
555
- }
629
+ for (const m of line.matchAll(RECORD_LINK_RE)) {
630
+ if (!recordExists(m[1])) violations.push(`${rel}:${fileLine}: dead ADR record link "${m[0]}" — no record file ${ADR_DIR_REL}/${m[1]}`);
556
631
  }
557
632
  }
558
633
  }
559
634
  return violations;
560
635
  };
561
636
 
637
+ const collectReferenceViolations = (root, hotIds, archivedIds) =>
638
+ scanReferences(
639
+ walkDocsMarkdown(root).filter((r) => r !== NAV_REL).map((rel) => [rel, liveLines(rel, readFileSync(resolve(root, rel), 'utf8'))]),
640
+ hotIds,
641
+ archivedIds,
642
+ (name) => existsSync(resolve(root, ADR_DIR_REL, name)),
643
+ );
644
+
562
645
  // ── tier / store IO ─────────────────────────────────────────────────────────────────────
563
646
 
564
647
  export const lineCountOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
@@ -646,8 +729,9 @@ const NEW_ARCHIVE_LINE = (oldestId) =>
646
729
  // A preamble that never mentioned the archive is left untouched (a consumer's own wording preserved).
647
730
  // Assumes the archive prose is one logical blockquote line (the shipped shape); a hand-wrapped
648
731
  // continuation line carrying NEITHER marker is left in place (a stated, benign residual).
732
+ const isArchiveLine = (line) => /decisions-archive/.test(line) || /\*\*Archive:\*\*/.test(line);
733
+
649
734
  export const rewriteHotPreamble = (preamble, oldestId) => {
650
- const isArchiveLine = (line) => /decisions-archive/.test(line) || /\*\*Archive:\*\*/.test(line);
651
735
  const out = [];
652
736
  let replaced = false;
653
737
  for (const line of preamble.split('\n')) {
@@ -759,29 +843,48 @@ const resolveGitDir = (root, spawn) => {
759
843
  return r && r.status === 0 && r.stdout ? r.stdout.trim() : null;
760
844
  };
761
845
 
846
+ const isInside = (root, path) => {
847
+ const rel = relative(resolve(root), resolve(path));
848
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
849
+ };
850
+
762
851
  // Snapshot the docs the migration is about to destroy (decisions.md + both monoliths) into the git
763
- // dir (uncommittable — never in the work tree, never stageable). Off git → a stated tmpdir fallback;
764
- // fails LOUD if neither base is writable. Returns { dir, base } for the caller to report.
852
+ // dir (uncommittable — never in the work tree, never stageable). Off git → a stated tmpdir fallback,
853
+ // refused when it lies inside the project root; a base whose write fails partway has its partial
854
+ // directory removed before the next is tried; fails LOUD naming both when neither base is writable.
855
+ // Returns { dir, base } for the caller to report.
765
856
  export const writeSnapshot = (root, files, deps = {}) => {
766
857
  const spawn = deps.spawnSync ?? spawnSync;
767
858
  const stamp = deps.stamp ?? `${new Date().toISOString().replace(/[:.]/g, '-')}`;
768
859
  const fallbackBase = deps.snapshotFallbackBase ?? tmpdir();
860
+ const io = fsIo(deps);
769
861
  const gitDir = resolveGitDir(root, spawn);
770
- const bases = gitDir ? [gitDir, fallbackBase] : [fallbackBase];
771
- let lastErr = null;
862
+ const failures = gitDir ? [] : ['no git dir'];
863
+ const bases = gitDir ? [gitDir] : [];
864
+ if (isInside(root, fallbackBase)) failures.push(`the temp dir ${fallbackBase} lies inside the project root`);
865
+ else bases.push(fallbackBase);
772
866
  for (const base of bases) {
773
867
  const dir = resolve(base, `${SNAPSHOT_PREFIX}-${stamp}`);
868
+ let made = false;
774
869
  try {
775
870
  mkdirSync(dir, { recursive: true });
871
+ made = true;
776
872
  for (const { rel, content } of files) {
777
- writeFileSync(resolve(dir, rel.replace(/\//g, '__')), content, 'utf8');
873
+ io.write(resolve(dir, rel.replace(/\//g, '__')), content, 'utf8');
778
874
  }
779
875
  return { dir, base, viaGitDir: base === gitDir };
780
876
  } catch (err) {
781
- lastErr = err;
877
+ failures.push(`${dir}: ${err.message}`);
878
+ if (made) {
879
+ try {
880
+ io.remove(dir, { recursive: true, force: true });
881
+ } catch (rmErr) {
882
+ failures.push(`its partial directory was left: ${rmErr.message}`);
883
+ }
884
+ }
782
885
  }
783
886
  }
784
- throw fail(1, `refusing to migrate: no writable snapshot location (git dir + fallback both failed: ${lastErr && lastErr.message}) — a durable pre-delete snapshot is mandatory`);
887
+ throw fail(1, `refusing to migrate: no writable snapshot location (git dir + fallback both failed: ${failures.join('; ')}) — a durable pre-delete snapshot is mandatory`);
785
888
  };
786
889
 
787
890
  // ── index regeneration (item (h) — reuse the ONE generator in check-docs-size.mjs) ──────
@@ -805,12 +908,11 @@ export const defaultRegenerateIndex = (root, today, deps = {}) => {
805
908
 
806
909
  // ── shared store-write helpers ──────────────────────────────────────────────────────────
807
910
 
808
- const writeRecords = (root, records) => {
911
+ const writeRecords = (root, records, io) => {
809
912
  const dir = resolve(root, ADR_DIR_REL);
810
913
  mkdirSync(dir, { recursive: true });
811
- // Index the existing filenames by id ONCE (O(store), not O(records × store)) so a stale same-id
812
- // file under a DIVERGENT slug (a retitled or prior-run remnant) can be pruned — exactly one file
813
- // per id survives, the canonical slug wins.
914
+ // Index the existing filenames by id ONCE (O(store), not O(records × store)) so a same-id file
915
+ // under a DIVERGENT slug (a retitled or prior-run remnant) moves to its canonical name.
814
916
  const existingById = new Map();
815
917
  for (const name of readdirSync(dir)) {
816
918
  const m = name.match(RECORD_FILE_RE);
@@ -822,40 +924,53 @@ const writeRecords = (root, records) => {
822
924
  let written = 0;
823
925
  let skipped = 0;
824
926
  for (const record of records) {
825
- for (const name of existingById.get(record.id) ?? []) {
826
- if (name !== record.fileName) rmSync(resolve(dir, name));
827
- }
828
927
  const path = resolve(dir, record.fileName);
928
+ // ONE rename, never a copy: no instant holds zero or two files for the id (the store loader
929
+ // refuses two); the body is then written whole, so either failure leaves one record a re-run completes.
930
+ const other = (existingById.get(record.id) ?? []).find((name) => name !== record.fileName);
931
+ if (other) io.rename(resolve(dir, other), path);
829
932
  const body = renderRecord(record);
830
933
  if (existsSync(path) && readFileSync(path, 'utf8') === body) {
831
934
  skipped += 1; // crash-resumable: a byte-identical record is left as is
832
935
  continue;
833
936
  }
834
- writeFileSync(path, body, 'utf8');
937
+ writeWhole(io, path, body);
835
938
  written += 1;
836
939
  }
837
940
  return { written, skipped };
838
941
  };
839
942
 
840
- const writeNavigatorFile = (root, corpus, today) => {
943
+ const writeNavigatorFile = (root, corpus, today, io) => {
841
944
  const dir = resolve(root, ADR_DIR_REL);
842
945
  mkdirSync(dir, { recursive: true });
843
- writeFileSync(resolve(root, NAV_REL), buildNavigator(corpus, today), 'utf8');
946
+ writeWhole(io, resolve(root, NAV_REL), buildNavigator(corpus, today));
844
947
  };
845
948
 
846
- const writeHot = (root, hot, retained, today) => {
949
+ const writeHot = (root, hot, retained, today, io) => {
847
950
  const oldestId = retained.length > 0 ? retained[0].id : hot.entries.length > 0 ? hot.entries[0].id : '000';
848
951
  const preamble = rewriteHotPreamble(hot.preamble, oldestId);
849
952
  const frontmatter = stampLastUpdated(hot.frontmatter, today);
850
- writeFileSync(hot.path, renderTier(frontmatter, preamble, retained), 'utf8');
953
+ writeWhole(io, hot.path, renderTier(frontmatter, preamble, retained));
954
+ };
955
+
956
+ // Pinned write order: records → inbound rewrites → navigator → HOT rewrite → monolith removal, so every
957
+ // interrupted state re-runs to completion.
958
+ const applyMoves = (root, plan, today, io, withNavigator = true) => {
959
+ writeRecords(root, plan.records, io);
960
+ writeInboundRewrites(root, plan.rewritePlans, io);
961
+ if (withNavigator) writeNavigatorFile(root, [...plan.retained, ...loadAdrStore(root)], today, io);
962
+ writeHot(root, plan.hot, plan.retained, today, io);
963
+ for (const rel of plan.present) io.removeFile(resolve(root, rel));
851
964
  };
852
965
 
853
966
  // ── CLI ─────────────────────────────────────────────────────────────────────────────────
854
967
 
855
- const USAGE = 'Usage: archive-decisions.mjs [--check|--migrate [--apply]|--write-navigator|--dry-run] [--today=YYYY-MM-DD]';
968
+ const USAGE = 'Usage: archive-decisions.mjs [--check|--migrate [--apply]|--write-navigator|--dry-run|--ensure-current] [--today=YYYY-MM-DD]';
969
+
970
+ const MODE_FLAGS = [['--check', 'check'], ['--migrate', 'migrate'], ['--apply', 'apply'], ['--write-navigator', 'writeNavigator'], ['--dry-run', 'dryRun']];
856
971
 
857
972
  const parseArgs = (argv) => {
858
- const flags = { check: false, migrate: false, apply: false, writeNavigator: false, dryRun: false, help: false };
973
+ const flags = { check: false, migrate: false, apply: false, writeNavigator: false, dryRun: false, ensureCurrent: false, help: false };
859
974
  let today = null;
860
975
  for (const arg of argv) {
861
976
  if (arg === '--check') flags.check = true;
@@ -863,6 +978,7 @@ const parseArgs = (argv) => {
863
978
  else if (arg === '--apply') flags.apply = true;
864
979
  else if (arg === '--write-navigator') flags.writeNavigator = true;
865
980
  else if (arg === '--dry-run') flags.dryRun = true;
981
+ else if (arg === '--ensure-current') flags.ensureCurrent = true;
866
982
  else if (arg === '--help' || arg === '-h') flags.help = true;
867
983
  else if (arg.startsWith('--today=')) today = arg.slice('--today='.length);
868
984
  else throw fail(2, `Unknown argument: ${arg}\n${USAGE}`);
@@ -871,6 +987,10 @@ const parseArgs = (argv) => {
871
987
  if (flags.migrate && flags.apply && flags.dryRun) {
872
988
  throw fail(2, `--migrate --apply --dry-run is contradictory — plain --migrate IS the dry run; drop --apply to preview or --dry-run to apply\n${USAGE}`);
873
989
  }
990
+ const beside = MODE_FLAGS.filter(([, key]) => flags[key]).map(([flag]) => flag);
991
+ if (flags.ensureCurrent && beside.length > 0) {
992
+ throw fail(2, `--ensure-current ${beside.join(' ')} is a usage error — --ensure-current is one mode, run alone\n${USAGE}`);
993
+ }
874
994
  return { flags, today };
875
995
  };
876
996
 
@@ -895,9 +1015,22 @@ const assertStoreIntegrity = (hotEntries, adrEntries) => {
895
1015
  }
896
1016
  };
897
1017
 
1018
+ // A HOT window whose newest entry alone exceeds its cap cannot be brought under it by moving entries.
1019
+ const assertHotReducible = (hot, retained) => {
1020
+ const rewrittenPreamble = rewriteHotPreamble(hot.preamble, retained.length > 0 ? retained[0].id : '000');
1021
+ if (hot.cap !== null && lineCountOf(renderTier(hot.frontmatter, rewrittenPreamble, retained)) > hot.cap) {
1022
+ throw fail(1, `${HOT_REL} is over its cap but cannot be reduced (the newest entry alone exceeds ${hot.cap} lines) — trim it or raise the cap (a maintainer decision; this script only moves entries).`);
1023
+ }
1024
+ };
1025
+
1026
+ const snapshotFilesOf = ({ hot, warm, cold }) => [
1027
+ { rel: HOT_REL, content: hot.raw },
1028
+ ...(warm.exists ? [{ rel: WARM_REL, content: warm.raw }] : []),
1029
+ ...(cold.exists ? [{ rel: COLD_REL, content: cold.raw }] : []),
1030
+ ];
1031
+
898
1032
  const runMigrate = (root, flags, today, deps, log, logError) => {
899
1033
  const present = monolithsPresent(root);
900
- const hasHot = existsSync(resolve(root, HOT_REL));
901
1034
  if (present.length === 0) {
902
1035
  if (existsSync(resolve(root, ADR_DIR_REL))) {
903
1036
  log('[archive-decisions] already migrated — no legacy monolith present; nothing to do.');
@@ -906,7 +1039,44 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
906
1039
  log('[archive-decisions] nothing to migrate — no legacy decisions-archive monolith found.');
907
1040
  return 0;
908
1041
  }
909
- if (!hasHot) throw fail(1, `${HOT_REL} not found but a legacy monolith is present — a broken tree; restore ${HOT_REL} before migrating.`);
1042
+ const plan = planMigration(root, today);
1043
+ const { retained, records, rewritePlans, oldItems } = plan;
1044
+ const summary = {
1045
+ records: records.map((r) => r.fileName),
1046
+ retainedHot: retained.map((e) => `AD-${e.id}`),
1047
+ monolithsRetired: present,
1048
+ inboundRewrites: summarizeRewrites(rewritePlans),
1049
+ conservation: `${oldItems.length} corpus blocks → ${retained.length} retained-HOT + ${records.length} records (conserved)`,
1050
+ };
1051
+
1052
+ if (!flags.apply) {
1053
+ log('[archive-decisions] --migrate DRY-RUN — no files will be changed.');
1054
+ log(JSON.stringify(summary, null, 2));
1055
+ return 0;
1056
+ }
1057
+
1058
+ const snapshot = writeSnapshot(root, snapshotFilesOf(plan), deps);
1059
+ // Only after conservation passed AND the snapshot exists does applyMoves remove the monoliths.
1060
+ applyMoves(root, plan, today, fsIo(deps));
1061
+
1062
+ const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
1063
+ log('[archive-decisions] migrated the 3-tier cascade → one-file-per-ADR store:');
1064
+ log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'})`);
1065
+ log(` records written: ${records.length} under ${ADR_DIR_REL}/`);
1066
+ log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${rewritePlans.length} file(s)`);
1067
+ log(` retained HOT: ${summary.retainedHot.join(', ') || '(none)'}`);
1068
+ log(` retired monoliths: ${present.join(', ')}`);
1069
+ log(` navigator: ${NAV_REL}`);
1070
+ if (regen.ok) log(' regenerated docs/ai/index.md');
1071
+ else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
1072
+ return 0;
1073
+ };
1074
+
1075
+ // The migration's whole preflight — the corpus union, conservation, store integrity and every link
1076
+ // check — with no write: --migrate and --ensure-current both run it.
1077
+ const planMigration = (root, today) => {
1078
+ const present = monolithsPresent(root);
1079
+ if (!existsSync(resolve(root, HOT_REL))) throw fail(1, `${HOT_REL} not found but a legacy monolith is present — a broken tree; restore ${HOT_REL} before migrating.`);
910
1080
 
911
1081
  const hot = loadHot(root);
912
1082
  const warm = loadMonolith(root, WARM_REL);
@@ -973,48 +1143,7 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
973
1143
  ]);
974
1144
  const rewritePlans = planInboundRewrites(root, movedById, true);
975
1145
  for (const plan of rewritePlans) verifyRewriteConservation(plan);
976
-
977
- const summary = {
978
- records: records.map((r) => r.fileName),
979
- retainedHot: retained.map((e) => `AD-${e.id}`),
980
- monolithsRetired: present,
981
- inboundRewrites: summarizeRewrites(rewritePlans),
982
- conservation: `${oldItems.length} corpus blocks → ${retained.length} retained-HOT + ${records.length} records (conserved)`,
983
- };
984
-
985
- if (!flags.apply) {
986
- log('[archive-decisions] --migrate DRY-RUN — no files will be changed.');
987
- log(JSON.stringify(summary, null, 2));
988
- return 0;
989
- }
990
-
991
- const snapshotFiles = [
992
- { rel: HOT_REL, content: hot.raw },
993
- ...(warm.exists ? [{ rel: WARM_REL, content: warm.raw }] : []),
994
- ...(cold.exists ? [{ rel: COLD_REL, content: cold.raw }] : []),
995
- ];
996
- const snapshot = writeSnapshot(root, snapshotFiles, deps);
997
-
998
- // Pinned write order: records → inbound rewrites → HOT rewrite / monolith removal.
999
- writeRecords(root, records);
1000
- writeInboundRewrites(root, rewritePlans);
1001
- const corpus = [...retained, ...loadAdrStore(root)];
1002
- writeNavigatorFile(root, corpus, today);
1003
- writeHot(root, hot, retained, today);
1004
- // Only NOW — conservation passed AND the snapshot exists — remove the monoliths.
1005
- for (const rel of present) rmSync(resolve(root, rel));
1006
-
1007
- const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
1008
- log('[archive-decisions] migrated the 3-tier cascade → one-file-per-ADR store:');
1009
- log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'})`);
1010
- log(` records written: ${records.length} under ${ADR_DIR_REL}/`);
1011
- log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${rewritePlans.length} file(s)`);
1012
- log(` retained HOT: ${summary.retainedHot.join(', ') || '(none)'}`);
1013
- log(` retired monoliths: ${present.join(', ')}`);
1014
- log(` navigator: ${NAV_REL}`);
1015
- if (regen.ok) log(' regenerated docs/ai/index.md');
1016
- else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
1017
- return 0;
1146
+ return { present, hot, warm, cold, existingStore, retained, toExplode, records, rewritePlans, oldItems };
1018
1147
  };
1019
1148
 
1020
1149
  const runWriteNavigator = (root, flags, today, deps, log, logError) => {
@@ -1036,7 +1165,7 @@ const runWriteNavigator = (root, flags, today, deps, log, logError) => {
1036
1165
  log(`[archive-decisions] --write-navigator DRY-RUN — no files will be changed; ${corpus.length} ADR(s) validated.`);
1037
1166
  return 0;
1038
1167
  }
1039
- writeNavigatorFile(root, corpus, today);
1168
+ writeNavigatorFile(root, corpus, today, fsIo(deps));
1040
1169
  const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
1041
1170
  log(`[archive-decisions] wrote ${NAV_REL} (${corpus.length} ADRs in the corpus).`);
1042
1171
  if (regen.ok) log(' regenerated docs/ai/index.md');
@@ -1112,18 +1241,44 @@ const runRotate = (root, flags, today, deps, log, logError) => {
1112
1241
  logError(`[archive-decisions] FAIL: legacy monolith present (${present.join(', ')}) — the ADR store is half-migrated; run \`node scripts/archive-decisions.mjs --migrate --apply\` first (a default rotate never half-explodes).`);
1113
1242
  return 1;
1114
1243
  }
1244
+ const plan = planRotation(root, today);
1245
+ if (plan.idle) {
1246
+ log(`[archive-decisions] nothing to rotate — HOT ${plan.hot.rawLines}/${plan.hot.cap}, ${plan.existingStore.length} archived record(s).`);
1247
+ return 0;
1248
+ }
1249
+ const summary = {
1250
+ explode: plan.records.map((r) => r.fileName),
1251
+ retainedHot: plan.retained.map((e) => `AD-${e.id}`),
1252
+ inboundRewrites: summarizeRewrites(plan.rewritePlans),
1253
+ };
1254
+ if (flags.dryRun) {
1255
+ log('[archive-decisions] DRY-RUN — no files will be changed.');
1256
+ log(JSON.stringify(summary, null, 2));
1257
+ return 0;
1258
+ }
1259
+
1260
+ applyMoves(root, plan, today, fsIo(deps));
1261
+ const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
1262
+ log('[archive-decisions] rotated:');
1263
+ log(` exploded to adr/: ${summary.explode.join(', ') || '(none)'}`);
1264
+ log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${plan.rewritePlans.length} file(s)`);
1265
+ log(` retained HOT: ${summary.retainedHot.join(', ')}`);
1266
+ if (regen.ok) log(' regenerated docs/ai/index.md');
1267
+ else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
1268
+ return 0;
1269
+ };
1270
+
1271
+ // The rotate's whole preflight with no write. `idle` when nothing moves: the default rotate idles while
1272
+ // no entry overflows and HOT is within its cap; --ensure-current (withinCap false) whenever HOT is within it.
1273
+ const planRotation = (root, today, rotateWithinCap = true) => {
1115
1274
  const hot = loadHot(root);
1116
1275
  const existingStore = loadAdrStore(root);
1117
1276
  const { retained, toExplode } = boundHot(hot.entries, hot.frontmatter, hot.preamble, hot.cap ?? Infinity);
1118
- if (toExplode.length === 0 && hot.rawLines <= (hot.cap ?? Infinity)) {
1277
+ if (hot.rawLines <= (hot.cap ?? Infinity) && (toExplode.length === 0 || !rotateWithinCap)) {
1119
1278
  assertStoreIntegrity(hot.entries, existingStore); // a no-op still refuses a corrupt store (dup / partition)
1120
- log(`[archive-decisions] nothing to rotate — HOT ${hot.rawLines}/${hot.cap}, ${existingStore.length} archived record(s).`);
1121
- return 0;
1122
- }
1123
- const rewrittenPreamble = rewriteHotPreamble(hot.preamble, retained.length > 0 ? retained[0].id : '000');
1124
- if (hot.cap !== null && lineCountOf(renderTier(hot.frontmatter, rewrittenPreamble, retained)) > hot.cap) {
1125
- throw fail(1, `${HOT_REL} is over its cap but cannot be reduced (the newest entry alone exceeds ${hot.cap} lines) — trim it or raise the cap (a maintainer decision; this script only moves entries).`);
1279
+ return { idle: true, present: [], hot, existingStore, retained: hot.entries, toExplode: [], records: [], rewritePlans: [] };
1126
1280
  }
1281
+ assertHotReducible(hot, retained);
1127
1282
 
1128
1283
  const records = explode(toExplode, today);
1129
1284
  // Crash-resume (fold-induced): a record from a prior crashed rotate may already be on
@@ -1145,31 +1300,79 @@ const runRotate = (root, flags, today, deps, log, logError) => {
1145
1300
  assertMovingBlocksFreeOfRelativeAdrLinks(root, new Set(movedById.keys()), new Set(retained.map((e) => e.id)));
1146
1301
  const rewritePlans = planInboundRewrites(root, movedById, false);
1147
1302
  for (const plan of rewritePlans) verifyRewriteConservation(plan);
1303
+ return { idle: false, present: [], hot, existingStore, retained, toExplode, records, rewritePlans };
1304
+ };
1148
1305
 
1149
- const summary = {
1150
- explode: records.map((r) => r.fileName),
1151
- retainedHot: retained.map((e) => `AD-${e.id}`),
1152
- inboundRewrites: summarizeRewrites(rewritePlans),
1153
- };
1154
- if (flags.dryRun) {
1155
- log('[archive-decisions] DRY-RUN — no files will be changed.');
1156
- log(JSON.stringify(summary, null, 2));
1306
+ // --ensure-current: every decision is judged before the first write, by the plans above and the check's
1307
+ // own collector over the store the planned moves leave; then the writes, the index, and a closing --check.
1308
+ const runEnsure = (root, today, deps, log, logError) => {
1309
+ const present = monolithsPresent(root);
1310
+ const hasHot = existsSync(resolve(root, HOT_REL));
1311
+ if (present.length === 0 && !hasHot && !existsSync(resolve(root, ADR_DIR_REL))) {
1312
+ const orphaned = collectReferenceViolations(root, new Set(), new Set());
1313
+ if (orphaned.length > 0) throw fail(1, `no ADR substrate, yet ADR references point into one:\n ${orphaned.join('\n ')}`);
1314
+ log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to bring current.`);
1315
+ log('already-current');
1157
1316
  return 0;
1158
1317
  }
1318
+ let plan;
1319
+ if (present.length > 0) {
1320
+ plan = planMigration(root, today);
1321
+ assertHotReducible(plan.hot, plan.retained);
1322
+ } else if (hasHot) plan = planRotation(root, today, false);
1323
+ else plan = { idle: true, present: [], hot: null, existingStore: loadAdrStore(root), retained: [], toExplode: [], records: [], rewritePlans: [] };
1324
+
1325
+ // The store the planned moves leave: each moved id under its canonical record name, every other
1326
+ // record name of a moved id pruned, the planned rewrites applied, the monoliths' preambles gone.
1327
+ const moved = new Map(plan.records.map((r, i) => [r.id, { ...plan.toExplode[i], fileName: r.fileName }]));
1328
+ const store = [...plan.existingStore.filter((e) => !moved.has(e.id)), ...moved.values()];
1329
+ const corpus = [...plan.retained, ...store];
1330
+ const recordNames = new Set([...plan.existingStore.filter((e) => !moved.has(e.id)).map((e) => e.fileName), ...plan.records.map((r) => r.fileName)]);
1331
+ const rewrites = new Map(plan.rewritePlans.map((p) => [p.rel, p]));
1332
+ const docs = walkDocsMarkdown(root).filter((rel) => rel !== NAV_REL).map((rel) => {
1333
+ const rw = rewrites.get(rel);
1334
+ const live = liveLines(rel, rw ? `${rw.frontmatter}${rw.afterLines.join('\n')}` : readFileSync(resolve(root, rel), 'utf8'));
1335
+ if (plan.present.includes(rel)) return [rel, live.filter((l) => !l.inPreamble)];
1336
+ if (rel === HOT_REL && !plan.idle) return [rel, live.filter((l) => !(l.inPreamble && isArchiveLine(l.line)))];
1337
+ return [rel, live];
1338
+ });
1339
+ const dead = scanReferences(docs, new Set(plan.retained.map((e) => e.id)), new Set(store.map((e) => e.id)), (name) => recordNames.has(name));
1340
+ if (dead.length > 0) throw fail(1, `refusing before any write — ADR references the planned moves would not cure:\n ${dead.join('\n ')}`);
1159
1341
 
1160
- // Pinned write order: records → inbound rewrites → HOT rewrite (crash-resume idempotency).
1161
- writeRecords(root, records);
1162
- writeInboundRewrites(root, rewritePlans);
1163
- const corpus = [...retained, ...loadAdrStore(root)];
1164
- writeNavigatorFile(root, corpus, today);
1165
- writeHot(root, hot, retained, today);
1166
- const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
1167
- log('[archive-decisions] rotated:');
1168
- log(` exploded to adr/: ${summary.explode.join(', ') || '(none)'}`);
1169
- log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${rewritePlans.length} file(s)`);
1170
- log(` retained HOT: ${summary.retainedHot.join(', ')}`);
1171
- if (regen.ok) log(' regenerated docs/ai/index.md');
1172
- else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
1342
+ const navPath = resolve(root, NAV_REL);
1343
+ const navOnDisk = existsSync(navPath) ? readFileSync(navPath, 'utf8') : null;
1344
+ const navStale = navOnDisk === null || buildNavigator(corpus, navLastUpdated(navOnDisk)) !== navOnDisk;
1345
+ const actions = [present.length > 0 && 'migrated', present.length === 0 && !plan.idle && 'rotated', navStale && 'navigator written'].filter(Boolean);
1346
+ if (actions.length === 0) {
1347
+ log('already-current');
1348
+ return 0;
1349
+ }
1350
+
1351
+ let changed = false;
1352
+ let written = false;
1353
+ let snapshot = null;
1354
+ try {
1355
+ if (present.length > 0) snapshot = writeSnapshot(root, snapshotFilesOf(plan), deps);
1356
+ const io = fsIo(deps, () => {
1357
+ changed = true;
1358
+ });
1359
+ if (plan.idle) writeNavigatorFile(root, corpus, today, io);
1360
+ else applyMoves(root, plan, today, io, navStale);
1361
+ written = true;
1362
+ const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
1363
+ if (!regen.ok) logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
1364
+ const checkLines = [];
1365
+ if (runCheck(root, today, () => {}, (line) => checkLines.push(line)) !== 0) throw new Error(checkLines.join('\n '));
1366
+ } catch (err) {
1367
+ if (written) logError(`[archive-decisions] the store was written and does not pass --check:\n ${err.message}`);
1368
+ else logError(`[archive-decisions] ${err.message}`);
1369
+ if (snapshot) logError(`[archive-decisions] snapshot: ${snapshot.dir}`);
1370
+ if (!changed) return 1;
1371
+ if (!written) logError('[archive-decisions] a project file changed before the failure — a re-run completes the move.');
1372
+ return 3;
1373
+ }
1374
+ if (snapshot) log(`snapshot: ${snapshot.dir}`);
1375
+ log(`brought-current: ${actions.join(', ')}`);
1173
1376
  return 0;
1174
1377
  };
1175
1378
 
@@ -1183,6 +1386,7 @@ export const runCli = (argv, deps = {}) => {
1183
1386
  }
1184
1387
  const today = todayOpt ?? new Date().toISOString().slice(0, 10);
1185
1388
 
1389
+ if (flags.ensureCurrent) return runEnsure(root, today, deps, log, logError);
1186
1390
  if (flags.migrate) return runMigrate(root, flags, today, deps, log, logError);
1187
1391
  if (flags.writeNavigator) return runWriteNavigator(root, flags, today, deps, log, logError);
1188
1392
  if (flags.check) return runCheck(root, today, log, logError);