@sabaiway/agent-workflow-memory 8.3.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,56 @@ 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
+
41
+ ## 8.4.0 — the commit hook runs your own scripts/pre-commit.local first (AD-195)
42
+
43
+ **Your own commit checks now have a place of their own: `scripts/pre-commit.local`.** Every hook that
44
+ `references/scripts/install-git-hooks.mjs` writes carries, right after `set -e`, a comment line naming that file and
45
+ `if [ -f scripts/pre-commit.local ]; then bash scripts/pre-commit.local; fi`, so the file runs with bash before the
46
+ docs and scripts lines.
47
+
48
+ - A non-zero exit of the file stops the commit; with no such file the hook runs as before.
49
+ - The installer never writes, reads or ships the file, and no flag turns the line off.
50
+ - A hook this installer wrote gains the line the next time the installer runs (`prepare`, or by hand); a hook it
51
+ did not write is refused as before.
52
+ - `install-git-hooks-repo-exec.test.mjs` proves it through real commits: the file runs before the docs check, an exit
53
+ 1 stops the commit, and an absent file lets it through.
54
+
55
+ MINOR: two new lines in every written hook.
56
+
7
57
  ## 8.3.0 — the hook installer arms the project-rules check and the layout check (AD-193)
8
58
 
9
59
  **`references/scripts/install-git-hooks.mjs` gains `--rules-check <path>` and `--layout-check <path>`.** Each writes
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.3.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.3.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.3.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);
@@ -4,12 +4,13 @@
4
4
  // deploy-copy suite (install-git-hooks.test.mjs) keeps the consumer-shaped behavior pins. Every
5
5
  // branch of the installer runs from here: fresh install, the guard arm/carry/disable/conflict
6
6
  // lanes, the rules-check and layout-check arm/carry/duplicate/refusal lanes (win32 included), the
7
- // unmanaged refusal, the up-to-date short-circuit, and the not-a-git-checkout skip. Every child
7
+ // unmanaged refusal, the up-to-date short-circuit, the not-a-git-checkout skip, and the S71
8
+ // real-commit lane (real `git commit` through the written hook over stand-in scripts). Every child
8
9
  // runs with each inherited GIT_* variable stripped, so a hook's environment never leaks in.
9
10
 
10
11
  import { describe, it } from 'node:test';
11
12
  import assert from 'node:assert/strict';
12
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, realpathSync } from 'node:fs';
13
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, realpathSync, chmodSync, statSync } from 'node:fs';
13
14
  import { tmpdir } from 'node:os';
14
15
  import { join, dirname } from 'node:path';
15
16
  import { fileURLToPath } from 'node:url';
@@ -130,3 +131,55 @@ describe('install-git-hooks — the in-place installer under a GIT_DIR-pinned fi
130
131
  rmSync(nowhere, { recursive: true, force: true });
131
132
  });
132
133
  });
134
+
135
+ // The commit lane also drops NODE_TEST_CONTEXT: an inherited one makes the hook's nested `node --test` skip every file.
136
+ const IDENTITY = { GIT_AUTHOR_NAME: 'p', GIT_AUTHOR_EMAIL: 'p@e', GIT_COMMITTER_NAME: 'p', GIT_COMMITTER_EMAIL: 'p@e' };
137
+ const bareAt = (home, extra = {}) => { const { NODE_TEST_CONTEXT: _, ...env } = envAt(home, { ...IDENTITY, ...extra }); return env; };
138
+ const passes = (r) => (assert.equal(r.status, 0, r.stderr), r);
139
+
140
+ // Passing stand-ins for every script the hook runs; check-docs-size.mjs, run right after the local line, records
141
+ // whether the marker existed. The marker and the record live in a sibling dir, outside the work tree.
142
+ const commitRepo = (root, side) => {
143
+ const [marker, record, local] = [join(side, 'marker'), join(side, 'record'), join(root, 'scripts', 'pre-commit.local')];
144
+ const git = (...args) => spawnChecked('git', args, { cwd: root, env: bareAt(root) });
145
+ mkdirSync(join(root, 'scripts'));
146
+ writeFileSync(join(root, 'scripts', 'check-docs-size.mjs'), `import { existsSync, writeFileSync } from 'node:fs';
147
+ if (!process.argv.includes('--check-index')) writeFileSync(${JSON.stringify(record)}, String(existsSync(${JSON.stringify(marker)})));\n`);
148
+ for (const name of ['archive-changelog', 'archive-issues', 'archive-decisions']) writeFileSync(join(root, 'scripts', `${name}.mjs`), 'process.exit(0);\n');
149
+ writeFileSync(join(root, 'scripts', 'stand-in.test.mjs'), "import { test } from 'node:test';\ntest('a passing stand-in', () => {});\n");
150
+ for (const args of [['init', '-q'], ['add', '-A'], ['commit', '-qm', 'base']]) passes(git(...args));
151
+ passes(spawnChecked(process.execPath, [INSTALLER], { env: bareAt(root, { GIT_DIR: join(root, '.git') }) }));
152
+ const head = () => git('rev-parse', 'HEAD').stdout.trim();
153
+ const commit = (body) => {
154
+ if (body !== undefined) { writeFileSync(local, body); chmodSync(local, 0o644); assert.equal(statSync(local).mode & 0o111, 0, 'the local file is not executable'); }
155
+ writeFileSync(join(root, 'change.txt'), 'change\n');
156
+ passes(git('add', 'change.txt'));
157
+ return git('commit', '-qm', 'change');
158
+ };
159
+ return { marker, record, head, commit };
160
+ };
161
+ const inCommitRepo = (cell) => () => {
162
+ const dirs = ['hooks-commit-', 'hooks-commit-side-'].map((prefix) => realpathSync(mkdtempSync(join(tmpdir(), prefix))));
163
+ try { cell(commitRepo(...dirs)); } finally { for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); }
164
+ };
165
+
166
+ describe('spec:init-project/S71 the local hook file through real commits over stand-ins', () => {
167
+ it('a local file that writes a marker runs before the docs check, and the commit exits 0', { timeout: 120_000 }, inCommitRepo((fx) => {
168
+ passes(fx.commit(`printf made > "${fx.marker}"\n`));
169
+ assert.ok(existsSync(fx.marker), 'the local file ran: its marker appeared');
170
+ assert.equal(readFileSync(fx.record, 'utf8'), 'true', 'the marker existed when check-docs-size.mjs ran');
171
+ }));
172
+
173
+ it('a local file that exits 1 stops the commit: non-zero, no new commit', { timeout: 120_000 }, inCommitRepo((fx) => {
174
+ const before = fx.head();
175
+ assert.notEqual(fx.commit('exit 1\n').status, 0, 'a local file exiting 1 stops the commit');
176
+ assert.equal(fx.head(), before, 'no new commit');
177
+ }));
178
+
179
+ it('with no local file the commit exits 0', { timeout: 120_000 }, inCommitRepo((fx) => {
180
+ const before = fx.head();
181
+ passes(fx.commit());
182
+ assert.notEqual(fx.head(), before, 'the commit landed');
183
+ assert.equal(readFileSync(fx.record, 'utf8'), 'false', 'the hook ran its stand-ins, with no marker');
184
+ }));
185
+ });
@@ -6,6 +6,7 @@
6
6
  // suite, so docs files cannot drift over their declared `maxLines`, the auto-generated
7
7
  // `index.md` navigator cannot silently fall out of sync, stale archive entries never
8
8
  // reach a commit, and regressions to the scripts themselves are caught at commit time.
9
+ // First after `set -e` it runs the project's own `scripts/pre-commit.local` with bash when present.
9
10
  //
10
11
  // The hooks location comes from git's OWN answer (`git rev-parse --git-path hooks`) — a linked
11
12
  // worktree resolves ITS OWN hooks path (its `.git` is a FILE; a hardcoded `.git/hooks` would
@@ -161,6 +162,8 @@ ${MAGIC_MARKER}
161
162
  # declared maxLines, the auto-generated index.md cannot silently go stale, stale
162
163
  # archive entries never reach a commit, and regressions to the scripts are caught.
163
164
  set -e
165
+ # Your own commit checks go in scripts/pre-commit.local; it runs first, with bash, when present.
166
+ if [ -f scripts/pre-commit.local ]; then bash scripts/pre-commit.local; fi
164
167
  node scripts/check-docs-size.mjs
165
168
  node scripts/check-docs-size.mjs --check-index
166
169
  node scripts/archive-changelog.mjs --check
@@ -59,7 +59,7 @@ const markerFor = (name) => `# ${name}:install-git-hooks.mjs`;
59
59
  const readHook = (root) => readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
60
60
  const stub = (root, rel) => (mkdirSync(dirname(join(root, rel)), { recursive: true }), writeFileSync(join(root, rel), '// a stand-in tool\n'), join(root, rel));
61
61
 
62
- // The hook the 4.0.0-8.2.1 installer body writes with no guard: the six docs and scripts lines.
62
+ // The hook the 8.3.0 installer body writes with no guard: the six docs and scripts lines.
63
63
  const plainHook = (root) => `#!/usr/bin/env bash
64
64
  ${markerFor(basename(root))}
65
65
  # Auto-installed by scripts/install-git-hooks.mjs (run it from "prepare" or by hand).
@@ -75,6 +75,16 @@ node scripts/archive-issues.mjs --check
75
75
  node scripts/archive-decisions.mjs --check
76
76
  node --test scripts/*.test.mjs
77
77
  `;
78
+ const LOCAL_LINE = 'if [ -f scripts/pre-commit.local ]; then bash scripts/pre-commit.local; fi';
79
+ // The plain hook every write carries: the 8.3.0 hook with, right after set -e, one `# ` comment naming
80
+ // scripts/pre-commit.local (its wording is free, so it is read from the written hook) and the exact local line.
81
+ const localHook = (root) => {
82
+ const lines = readHook(root).split('\n');
83
+ const comment = lines[lines.indexOf('set -e') + 1];
84
+ assert.ok(comment?.startsWith('# ') && comment.includes('scripts/pre-commit.local'), `right after set -e a # comment names scripts/pre-commit.local, got ${JSON.stringify(comment)}`);
85
+ assert.deepEqual(lines.filter((l) => l.includes('scripts/pre-commit.local')), [comment, LOCAL_LINE], 'the comment and the local line are the only lines naming scripts/pre-commit.local');
86
+ return plainHook(root).replace('set -e\n', `set -e\n${comment}\n${LOCAL_LINE}\n`);
87
+ };
78
88
  const line = (path, role) => `node "${role ? shown(path) : path}" --check${role ? ` # ${role}` : ''}\n`;
79
89
 
80
90
  describe('install-git-hooks — beside no package.json, or a malformed one, the marker names the directory basename', () => {
@@ -148,8 +158,9 @@ describe('spec:init-project/S58 install-git-hooks — the rules-check and layout
148
158
  const [rules, layout, guard, guard2] = ['tools/rules-cli.mjs', 'tools/source-size-check.mjs', 'guard.mjs', 'g2/guard.mjs'].map((rel) => stub(root, rel));
149
159
  installs(root, ['--commit-guard', 'guard.mjs']);
150
160
  installs(root, ['--layout-check', 'tools/source-size-check.mjs']);
161
+ assert.equal(readHook(root), localHook(root) + line(layout, 'layout-check') + line(guard), 'the --layout-check run carried the guard line');
151
162
  installs(root, ['--rules-check', rules]);
152
- const armed = plainHook(root) + line(rules, 'rules-check') + line(layout, 'layout-check');
163
+ const armed = localHook(root) + line(rules, 'rules-check') + line(layout, 'layout-check');
153
164
  assert.equal(readHook(root), armed + line(guard), 'each arming run carried the other armed lines');
154
165
  assert.match(installs(root).stdout, /already up to date/, 'a flagless re-run keeps all three');
155
166
  assert.equal(readHook(root), armed + line(guard));
@@ -163,7 +174,7 @@ describe('spec:init-project/S58 install-git-hooks — the rules-check and layout
163
174
  const [legacy, rules] = ['legacy/rules-cli.mjs', 'tools/rules-cli.mjs'].map((rel) => stub(root, rel));
164
175
  installs(root, ['--commit-guard', legacy]);
165
176
  installs(root, ['--rules-check', rules]);
166
- const expected = plainHook(root) + line(rules, 'rules-check') + line(legacy);
177
+ const expected = localHook(root) + line(rules, 'rules-check') + line(legacy);
167
178
  assert.equal(readHook(root), expected);
168
179
  installs(root);
169
180
  assert.equal(readHook(root), expected, 'the flagless re-run keeps the legacy line as the guard');
@@ -183,11 +194,11 @@ describe('spec:init-project/S58 install-git-hooks — the rules-check and layout
183
194
  }
184
195
  }));
185
196
 
186
- it('over a hook with no rules or layout line, a flagless and a --commit-guard run write the 4.0.0-8.2.1 bytes', inProject(({ root }) => {
197
+ it('over a hook with no rules or layout line, a flagless and a --commit-guard run write the plain hook with the comment line and the local line right after set -e', inProject(({ root }) => {
187
198
  installs(root);
188
- assert.equal(readHook(root), plainHook(root));
199
+ assert.equal(readHook(root), localHook(root));
189
200
  installs(root, ['--commit-guard', 'guard.mjs']);
190
- assert.equal(readHook(root), plainHook(root) + line(join(root, 'guard.mjs')));
201
+ assert.equal(readHook(root), localHook(root) + line(join(root, 'guard.mjs')));
191
202
  }));
192
203
 
193
204
  it('a wrong file name, a shell-active path or a missing value exits 2, a missing file exits 1, each writing nothing', inProject(({ root }) => {
@@ -220,11 +231,11 @@ describe('spec:init-project/S58 install-git-hooks — the rules-check and layout
220
231
  const armedAt = stub(root, 'a/b/rules-cli.mjs');
221
232
  if (process.platform !== 'win32') { // there the backslash is refused, writing nothing
222
233
  const posix = runInstaller(root, ['--rules-check', given]);
223
- assert.deepEqual([posix.status, /a backslash, CR or LF/.test(posix.stderr), readHook(root)], [2, true, plainHook(root)], posix.stderr);
234
+ assert.deepEqual([posix.status, /a backslash, CR or LF/.test(posix.stderr), readHook(root)], [2, true, localHook(root)], posix.stderr);
224
235
  }
225
236
  installs(root, ['--rules-check', given], WIN32);
226
- assert.equal(readHook(root), plainHook(root) + line(armedAt, 'rules-check'));
237
+ assert.equal(readHook(root), localHook(root) + line(armedAt, 'rules-check'));
227
238
  assert.match(installs(root).stdout, /already up to date/, 'the flagless re-run recognises the win32-armed line');
228
- assert.equal(readHook(root), plainHook(root) + line(armedAt, 'rules-check'));
239
+ assert.equal(readHook(root), localHook(root) + line(armedAt, 'rules-check'));
229
240
  }));
230
241
  });