@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 +34 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/archive-decisions.mjs +316 -112
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.
|
|
6
|
+
version: '8.5.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-memory
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-memory",
|
|
3
|
-
"version": "8.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
543
|
-
const {
|
|
544
|
-
|
|
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
|
|
553
|
-
if (!
|
|
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
|
-
//
|
|
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
|
|
771
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
|
812
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
if (
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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);
|