@sabaiway/agent-workflow-memory 2.3.0 → 3.0.1

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
@@ -2,7 +2,47 @@
2
2
 
3
3
  All notable changes to the memory substrate. Versions are this **package's** npm versions;
4
4
  they are distinct from the **deployment-lineage** stamp written into a project's
5
- `docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `2.0.0`).
5
+ `docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `3.0.0`).
6
+
7
+ ## 3.0.1 — bundled reference scripts refreshed (no behavior change)
8
+
9
+ PATCH rider on the kit 3.1.0 release — the publish workflow's changed-but-unbumped tooth caught
10
+ that two bundled reference scripts had moved since 3.0.0 without a version bump. No CLI surface,
11
+ output, or exit-code change:
12
+
13
+ - `references/scripts/check-docs-size.mjs` — reworked to an in-process CLI shape (pure argv
14
+ parser, injectable log, exported `runCli`) so suites drive it without a spawn; same flags, same
15
+ behavior; a colocated CLI test pins it.
16
+ - `references/scripts/archive-decisions.mjs` — comment-only cleanup (reviewer-round identity
17
+ references removed as part of the family-wide neutral-review-ID sweep).
18
+
19
+ ## 3.0.0 — strip-the-kit: the substrate follows the hardened computed core (AD-059)
20
+
21
+ > ### ⚠ BREAKING — retired loop surfaces + Node >= 22
22
+ >
23
+ > The verification-profile template is gone; the gates.json seed and the loop docs describe the
24
+ > `run-gates --final` / commit-guard loop; pre-commit hooks from `install-git-hooks.mjs` may gain
25
+ > the consented `--commit-guard <path>` line. Node floor **>= 22**. Deployment-lineage head is
26
+ > **3.0.0** — init/upgrade re-stamps and applies `migrations/3.0.0-hardened-core-loop.md`.
27
+
28
+ Shipped in lockstep with kit 3.0.0 / engine 2.0.0 (the lineage owner carries the consumer
29
+ migration surfaces):
30
+
31
+ - **`migrate-gates.mjs` (+ its deploy-payload spec, NEW):** the consented D8 legacy gates.json
32
+ migration — preview default, atomic apply; canonical legacy entries removed by their documented
33
+ single-invocation forms; the canonical `unit-tests` cmd gains the full lcov reporter flag set;
34
+ the canonical `coverage-check` gate lands LAST (realpath-anchored, move/collision semantics);
35
+ retired git-dir ledger stores cleaned; CUSTOMIZED entries reported with paste-ready recoveries;
36
+ symlinked-parent and control-byte STOPs, all fail-closed.
37
+ - **`install-git-hooks.mjs`:** the hooks path comes from `git rev-parse --git-path hooks` (a
38
+ linked worktree installs at ITS OWN hooks dir); the optional `--commit-guard <path>` arm writes
39
+ the resolved quoted guard line; an armed line survives flagless re-runs (strict single-line
40
+ carry-forward; `--no-commit-guard` is the one consented disable; duplicates fail closed). New
41
+ colocated specs incl. the in-place GIT_DIR-pinned execution suite.
42
+ - **`stamp-takeover.mjs`:** LINEAGE_HEAD 3.0.0; the takeover/migration selection mechanics are
43
+ unchanged — new deployments stamp 3.0.0, older ones pick up the 3.0.0 migration.
44
+ - **Templates/docs:** the gates seed + agent-rules/§4 consent line follow the D13 loop (staging
45
+ is reversible loop-work; COMMIT is the ask); SKILL sweeps for the three seeded configs.
6
46
 
7
47
  ## 2.3.0 — agent_rules template: a report-facts Communication bullet + the writer-batch lens re-render (REPORT-FACTS train, AD-054)
8
48
 
package/README.md CHANGED
@@ -45,7 +45,7 @@ Open your agent inside a project and run the skill:
45
45
 
46
46
  Two independent axes: this package's **npm version** vs the **deployment-lineage** stamp the
47
47
  substrate writes into a project. They are not the same number. `.memory-version` tracks the
48
- shared `agent-workflow` deployment lineage (head `2.0.0` today), **not** this package's
48
+ shared `agent-workflow` deployment lineage (head `3.0.0` today), **not** this package's
49
49
  version. A project bootstrapped by the kit's fallback carries a `.workflow-version`; when this
50
50
  substrate is later installed, `migrations/legacy-stamp-takeover.md` copies that value verbatim
51
51
  into `.memory-version` and never deletes the legacy stamp. See `migrations/README.md`.
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: '2.3.0'
6
+ version: '3.0.1'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
@@ -51,7 +51,6 @@ pointers are the **three empty slots** the composition root fills — never auth
51
51
  | Autonomy-policy read contract (`docs/ai/autonomy.json`) | **methodology engine** (not this skill) | the empty `workflow:autonomy` slot — filled by the composition root |
52
52
  | Per-project recipe **CONFIG** (which recipe each activity/slot uses) | **memory** seeds an *editable default* | `docs/ai/orchestration.json` (agent-writable via the composition root's `set-recipe` writer, or hand-edited; the recipe **canon** + the slot **vocabulary** live in the engine / composition root, never here) |
53
53
  | Per-project **gate declaration** (which verification commands must be green) | **memory** seeds an *editable default* | `docs/ai/gates.json` (hand-editable; an empty list as shipped — the project declares its own commands; the **runner** lives in the composition root, never here) |
54
- | Per-project **verification profile** (optional; the fold-completeness language-independence contract) | **memory** seeds an *editable default* | `docs/ai/verification-profile.json` (hand-editable; the seeded default reproduces the composition root's default V8 + node:test behaviour — delete it for exactly that; declares coverage source / single-test format / optional SARIF path; the **runner** lives in the composition root, never here) |
55
54
  | Per-project **autonomy policy** (red-lines + per-activity autonomy level) | **memory** seeds an *editable default* | `docs/ai/autonomy.json` (hand-editable, or agent-writable via the composition root's `set-autonomy` writer; the seed is SPARSE — the onboarding note only, defaults-equivalent, so deploying it never changes behavior; the policy **canon** + the render live in the engine / composition root, never here) |
56
55
 
57
56
  ---
@@ -107,15 +106,13 @@ bootstrapping over a live system, but the user makes the final call.
107
106
  `${CLAUDE_SKILL_DIR}/references/templates/` (every non-`AGENTS.md`, non-`adr-record.md` template —
108
107
  the latter is a skill-home authoring reference, never deployed). This deploys the HOT ADR window
109
108
  `docs/ai/decisions.md` **and** the seed navigator `docs/ai/adr/log.md` (its `adr/` subdir copies
110
- in wholesale), plus the four seeded, **user-editable** strict-JSON configs:
109
+ in wholesale), plus the three seeded, **user-editable** strict-JSON configs:
111
110
  `docs/ai/orchestration.json` — the per-project recipe defaults the composition root's `procedures`
112
111
  advisor reads — `docs/ai/gates.json` — the project's gate declaration, an empty list to fill with
113
- its own verification commands, consumed by the composition root's gate runner —
114
- `docs/ai/verification-profile.json` — the OPTIONAL fold-completeness verification profile (the
115
- language-independence contract; the seeded default reproduces the composition root's default V8 +
116
- node:test behaviour, delete it for exactly that) — and `docs/ai/autonomy.json` — the per-project
117
- autonomy policy, seeded SPARSE (the onboarding note only, defaults-equivalent — behavior changes
118
- only when the user declares levels). Keep each `.md` file's frontmatter.
112
+ its own verification commands, consumed by the composition root's gate runner — and
113
+ `docs/ai/autonomy.json` — the per-project autonomy policy, seeded SPARSE (the onboarding note
114
+ only, defaults-equivalent behavior changes only when the user declares levels). Keep each
115
+ `.md` file's frontmatter.
119
116
  7. **Fill templates** per the table below.
120
117
  8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs`
121
118
  (+ `*.test.mjs`) into the project's `scripts/`. **No Node runtime** → skip this + the hook;
@@ -128,7 +125,7 @@ bootstrapping over a live system, but the user makes the final call.
128
125
  append-only (never duplicate an existing line), then **verify `git status` shows them ignored**.
129
126
  Never the machine-global excludes; never edit `package.json`.
130
127
  10. **Stamp the deployment lineage.** Write the **deployment-lineage head** into
131
- `docs/ai/.memory-version` (one semver line). The lineage head is **`2.0.0`** (the
128
+ `docs/ai/.memory-version` (one semver line). The lineage head is **`3.0.0`** (the
132
129
  `LINEAGE_HEAD` constant in `scripts/stamp-takeover.mjs`) — the shared `agent-workflow`
133
130
  lineage, a **separate axis** from this package's npm version (the two may even coincide by
134
131
  accident; see *Stamp = lineage head, not package version*). Use the atomic writer in
@@ -167,7 +164,7 @@ Fill strategy:
167
164
  `scripts/stamp-takeover.mjs` decides the action per state; the Markdown migration is the
168
165
  no-Node manual fallback. If **no** stamp exists at all, offer a conservative re-bootstrap.
169
166
  2. **Never-downgrade gate FIRST, then the stamp-independent hidden-mode reconcile (D14).** Compare the
170
- stamp to the **deployment-lineage head** (`LINEAGE_HEAD`, `2.0.0`). **Greater than the head, or
167
+ stamp to the **deployment-lineage head** (`LINEAGE_HEAD`, `3.0.0`). **Greater than the head, or
171
168
  unparseable → STOP and report immediately, before ANY write** (never downgrade or guess, and never
172
169
  touch `.git/info/exclude`). This STOP is one of the only two places the number is shown (see
173
170
  *Version disclosure* below): tell the user **the `docs/ai` structure version** their deployment
@@ -184,23 +181,21 @@ Fill strategy:
184
181
  **untracked AND not ignored** → **AMBIGUOUS** → **ASK** the user before writing. This visibility check
185
182
  runs on **every** in-range upgrade, even at head — it is not gated by the stamp delta, but it is gated
186
183
  **behind** the never-downgrade STOP above. **Also stamp-independent (same gate, before the equal-head
187
- short-circuit): ensure the FOUR seeded `.json` configs** — for `docs/ai/orchestration.json`,
188
- `docs/ai/gates.json`, `docs/ai/verification-profile.json`, AND `docs/ai/autonomy.json`,
184
+ short-circuit): ensure the THREE seeded `.json` configs** — for `docs/ai/orchestration.json`,
185
+ `docs/ai/gates.json`, AND `docs/ai/autonomy.json`,
189
186
  **create the file from its
190
187
  `${CLAUDE_SKILL_DIR}/references/templates/` template if missing**, **preserve it byte-for-byte
191
188
  if it already exists** (a user may have edited it; never clobber it). The shipped
192
189
  `orchestration.json` template's `_README` already frames that config as agent-writable (set it
193
190
  with the `set-recipe` writer) and still hand-editable; `gates.json` is the project's own gate
194
191
  declaration (what to verify — consumed by the composition root's gate runner);
195
- `verification-profile.json` is the OPTIONAL fold-completeness verification profile — the seeded
196
- default reproduces the composition root's default V8 + node:test behaviour, so a project deletes
197
- it to fall back to exactly that, or edits it for another language/runner; `autonomy.json` is the
192
+ `autonomy.json` is the
198
193
  per-project autonomy policy, seeded SPARSE (the onboarding note only, defaults-equivalent —
199
194
  agent-writable via the composition root's `set-autonomy` writer, hand-editing supported). (Refreshing the
200
195
  orchestration `_README` note in place on an existing file is the **composition root's** job on
201
196
  its own reconcile — this substrate is standalone and only seeds-or-preserves; it owns no
202
197
  cross-package refresh helper. The gates declaration gets no note-refresh at all — authored
203
- content, seed-or-preserve only.) This is why an equal-head (`2.0.0`) deployment still gains the
198
+ content, seed-or-preserve only.) This is why an equal-head (`3.0.0`) deployment still gains the
204
199
  config seeds **without a lineage-head bump or a migration file** (the
205
200
  stamp-independent-reconcile precedent — like the pointer slots + the hidden-mode footprint).
206
201
  **Same gate, also stamp-independent: ensure the ADR-store enforcement pair** — copy
@@ -287,7 +282,7 @@ memory substrate has none, so it relies on the STOP + the explicit ask — never
287
282
  - **Source vs target directory.** Templates/scripts are read from the skill's own dir; the
288
283
  **working directory is the target project** — never write substrate files back into the skill.
289
284
  - **Stamp = lineage head, not package version.** `.memory-version` carries the **deployment-lineage
290
- head** (`2.0.0`, the shared `agent-workflow` lineage) — a **separate axis** from this package's npm
285
+ head** (`3.0.0`, the shared `agent-workflow` lineage) — a **separate axis** from this package's npm
291
286
  version (the two may even coincide by accident). They move independently.
292
287
  - **All three pointer slots ship empty and stay the user's.** Never author methodology,
293
288
  orchestration or autonomy text into them; on upgrade, preserve their content byte-for-byte. The
package/bin/install.mjs CHANGED
@@ -13,7 +13,7 @@
13
13
  // That is distinct from `/agent-workflow-memory upgrade`, which migrates a
14
14
  // *project's* docs/ai deployment — see README "Use".
15
15
  //
16
- // No telemetry, no phone-home. Dependency-free, Node >= 18.
16
+ // No telemetry, no phone-home. Dependency-free, Node >= 22.
17
17
 
18
18
  import { readFile, mkdir, readdir, copyFile, lstat, readlink, symlink } from 'node:fs/promises';
19
19
  import { existsSync, lstatSync, realpathSync } from 'node:fs';
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": "2.3.0",
6
+ "version": "3.0.1",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
@@ -0,0 +1,29 @@
1
+ # Migration 3.0.0-hardened-core-loop
2
+
3
+ **From:** versions < 3.0.0 **To:** 3.0.0
4
+
5
+ ## Why
6
+
7
+ 3.0.0 aligns the substrate with the hardened computed review loop: the verification-profile
8
+ template is retired, the gates seed and the loop docs describe the `run-gates --final` /
9
+ commit-guard loop, and the hook installer gains the consented `--commit-guard <path>` arm.
10
+
11
+ ## Steps
12
+
13
+ 1. **Node floor:** ensure Node **>= 22**.
14
+ 2. **Composition-root-present deployment — one handoff, never a loop:** invoked FROM the
15
+ composition root's upgrade (the delegated path), do nothing here — control returns to the
16
+ OUTER composition-root upgrade, which owns the gates/bridge/guard steps of its OWN `3.0.0`
17
+ migration. Running the memory upgrade STANDALONE with a composition root installed: hand off
18
+ ONCE to the installed composition root's upgrade and do not re-enter.
19
+ 3. **Memory-only deployment:** nothing to migrate by hand — the upgrade's seed-or-preserve pass
20
+ applies (templates and loop docs arrive for NEW files; existing user-authored files are
21
+ PRESERVED, never rewritten); the composition-root-owned gates/bridge/guard operations simply
22
+ do not run without one.
23
+ 4. **Re-stamp:** the upgrade re-stamps `docs/ai/.memory-version` to 3.0.0 (the memory axis).
24
+
25
+ ## Verification
26
+
27
+ `docs/ai/.memory-version` reads `3.0.0`. Memory-only branch: user-authored `docs/ai` files are
28
+ byte-unchanged. Composition-root-present branch: that root's own migration verification applies
29
+ (it owns the consented `docs/ai/gates.json` change — byte-preservation is NOT claimed there).
@@ -13,7 +13,7 @@ releases add files/templates, which `upgrade` reconciles without a migration.
13
13
  `.memory-version` verbatim — see that migration.
14
14
  2. Select every migration whose `<version>` is **strictly newer** than the stamp.
15
15
  3. Apply them in **ascending semver order**.
16
- 4. Re-stamp `docs/ai/.memory-version` to the deployment-lineage head (`2.0.0` today — the shared
16
+ 4. Re-stamp `docs/ai/.memory-version` to the deployment-lineage head (`3.0.0` today — the shared
17
17
  lineage sequence, **not** this package's npm version). The legacy `.workflow-version`, if present,
18
18
  is never deleted.
19
19
 
@@ -13,13 +13,13 @@ any other migration.
13
13
 
14
14
  The deployment stamp moved from `.workflow-version` (owned by the kit) to `.memory-version`
15
15
  (owned by this substrate). They track the **same** shared deployment-lineage sequence — never
16
- their package versions. The current lineage head is **`2.0.0`** (`LINEAGE_HEAD`). This migration
16
+ their package versions. The current lineage head is **`3.0.0`** (`LINEAGE_HEAD`). This migration
17
17
  adopts an existing legacy stamp into the new file **without** changing the lineage value and
18
18
  **without** deleting the legacy stamp (both may coexist; each tool migrates from its own stamp).
19
19
 
20
20
  ## State table (idempotent)
21
21
 
22
- Look at `docs/ai/` and act by row. `V` is the legacy stamp's value; `head` = `2.0.0`.
22
+ Look at `docs/ai/` and act by row. `V` is the legacy stamp's value; `head` = `3.0.0`.
23
23
 
24
24
  | `docs/ai/` state | Action |
25
25
  |---|---|
@@ -42,7 +42,7 @@ Look at `docs/ai/` and act by row. `V` is the legacy stamp's value; `head` = `2.
42
42
  done
43
43
  ```
44
44
  2. **Validate** each value that exists: it must be `MAJOR.MINOR.PATCH`, non-empty, and **not** greater
45
- than `2.0.0`. If any present stamp is empty, unparseable, or in the future → **STOP and report**;
45
+ than `3.0.0`. If any present stamp is empty, unparseable, or in the future → **STOP and report**;
46
46
  do nothing else.
47
47
  3. **Apply** the matching row above. For the "only `.workflow-version=V`" row, copy verbatim and
48
48
  write **atomically** — create an exclusive randomized temp in `docs/ai/` (so a planted temp-name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "2.3.0",
3
+ "version": "3.0.1",
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",
@@ -47,7 +47,7 @@
47
47
  "!scripts/*.test.mjs"
48
48
  ],
49
49
  "engines": {
50
- "node": ">=18"
50
+ "node": ">=22"
51
51
  },
52
52
  "publishConfig": {
53
53
  "access": "public"
@@ -46,7 +46,7 @@
46
46
  // destructive --migrate --apply writes a durable snapshot to the git dir (uncommittable, the
47
47
  // review-receipts precedent) BEFORE any delete, with a stated out-of-tree fallback off git.
48
48
  //
49
- // Dependency-free, Node >= 18. Deployed into a consumer's scripts/ like its siblings.
49
+ // Dependency-free, Node >= 22. Deployed into a consumer's scripts/ like its siblings.
50
50
 
51
51
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
52
52
  import { dirname, resolve, join } from 'node:path';
@@ -649,7 +649,7 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
649
649
  verifyConservation(oldItems, newItems);
650
650
  // Integrity over the FULL final store — existing records ∪ freshly-exploded — not just the new
651
651
  // writes: a pre-existing adr/ record whose id also stays RETAINED in HOT would otherwise leave the
652
- // ADR in two places (codex R4). Same full-store pattern as runRotate.
652
+ // ADR in two places. Same full-store pattern as runRotate.
653
653
  const finalStoreById = new Map(existingStore.map((e) => [e.id, { id: e.id, idNum: e.idNum, fileName: e.fileName }]));
654
654
  for (const r of records) finalStoreById.set(r.id, { id: r.id, idNum: r.idNum, fileName: r.fileName });
655
655
  assertStoreIntegrity(retained, [...finalStoreById.values()]);
@@ -783,7 +783,7 @@ const runRotate = (root, flags, today, deps, log, logError) => {
783
783
  }
784
784
 
785
785
  const records = explode(toExplode, today);
786
- // Crash-resume (fold-induced, codex R2): a record from a prior crashed rotate may already be on
786
+ // Crash-resume (fold-induced): a record from a prior crashed rotate may already be on
787
787
  // disk. A byte-identical one is done (deduped, not a duplicate-id error); a divergent-body one is
788
788
  // corrupt → FAIL. The FINAL store = existing ∪ freshly-exploded, deduped by id.
789
789
  const existingById = new Map(existingStore.map((e) => [e.id, e]));
@@ -341,7 +341,7 @@ describe('1.3 --migrate --apply — records + snapshot + retire monoliths + HOT
341
341
  assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'the resumed tree passes --check');
342
342
  });
343
343
 
344
- it('refuses when a pre-existing adr/ record duplicates an ADR that stays in HOT (never two places — codex R4)', () => {
344
+ it('refuses when a pre-existing adr/ record duplicates an ADR that stays in HOT (never two places — review-adr-archive-r04-major-01)', () => {
345
345
  const root = makeRoot();
346
346
  seedLegacy(root, { hot: ['005', '006'], warm: ['003'], cold: ['001'] });
347
347
  mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
@@ -452,7 +452,7 @@ describe('1.4 --check', () => {
452
452
  assert.match(errText, /duplicate ADR id AD-002/);
453
453
  });
454
454
 
455
- it('a decisions.md with NO maxLines cap fails loud (never operates against an unknown budget — codex R4)', () => {
455
+ it('a decisions.md with NO maxLines cap fails loud (never operates against an unknown budget — review-adr-archive-r04-major-02)', () => {
456
456
  const root = makeRoot();
457
457
  mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
458
458
  const noCapFm = '---\ntype: reference\nlastUpdated: 2026-01-01\nscope: permanent\nstaleAfter: never\nowner: none\n---\n';
@@ -514,7 +514,7 @@ describe('1.4 --check', () => {
514
514
  assert.match(errText, /two records for AD-001/);
515
515
  });
516
516
 
517
- it('a NESTED subdirectory in adr/ fails loud (the store is a flat directory — codex R2)', () => {
517
+ it('a NESTED subdirectory in adr/ fails loud (the store is a flat directory — review-adr-archive-r02-major-01)', () => {
518
518
  const root = makeRoot();
519
519
  seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
520
520
  mkdirSync(join(root, ADR_DIR_REL, 'nested'), { recursive: true });
@@ -561,7 +561,7 @@ describe('1.4 default rotate — explode the oldest beyond cap + regenerate the
561
561
  assert.equal(regenCalls.length, 0, 'a no-op never regenerates the index');
562
562
  });
563
563
 
564
- it('is crash-resumable: a byte-identical record from a crashed prior rotate is deduped, not a fatal duplicate (codex R2)', () => {
564
+ it('is crash-resumable: a byte-identical record from a crashed prior rotate is deduped, not a fatal duplicate (review-adr-archive-r02-major-02)', () => {
565
565
  const root = makeRoot();
566
566
  mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
567
567
  const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
@@ -579,7 +579,7 @@ describe('1.4 default rotate — explode the oldest beyond cap + regenerate the
579
579
  assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
580
580
  });
581
581
 
582
- it('a no-op rotate still REFUSES a corrupt store (partition) — never a silent green no-op (codex R2)', () => {
582
+ it('a no-op rotate still REFUSES a corrupt store (partition) — never a silent green no-op (review-adr-archive-r02-major-03)', () => {
583
583
  const root = makeRoot();
584
584
  seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] }); // under cap
585
585
  const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('010')]), 'x').entries, '2026-07-09')[0];
@@ -0,0 +1,41 @@
1
+ // check-docs-size-cli.test.mjs — runCli branch pins the subprocess smokes cannot reach
2
+ // in-process (Phase-5 coverage fill; the main spec file is parity-frozen, so these ride a
3
+ // colocated file): the unknown-argument refusal and the written-empty-index guard.
4
+ import { describe, it } from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { runCli } from './check-docs-size.mjs';
10
+
11
+ const cli = async (argv) => {
12
+ const { code, stdout, stderr } = await runCli(argv);
13
+ return { code, stdout, stderr };
14
+ };
15
+
16
+ describe('check-docs-size runCli — refusal branches', () => {
17
+ it('an unknown argument exits 2 naming it', async () => {
18
+ const { code, stderr } = await cli(['--bogus']);
19
+ assert.equal(code, 2);
20
+ assert.match(stderr, /Unknown argument: --bogus/);
21
+ });
22
+
23
+ it('--write-index landing on a sink path (index stat size 0) is the loud written-empty refusal', async () => {
24
+ const root = mkdtempSync(join(tmpdir(), 'cds-cli-'));
25
+ try {
26
+ mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
27
+ writeFileSync(
28
+ join(root, 'docs', 'ai', 'a.md'),
29
+ '---\ntype: state\nlastUpdated: 2026-07-18\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# a\n',
30
+ );
31
+ // The index path is a symlink into /dev/null: the write lands, the stat reads size 0 —
32
+ // the guard must refuse loudly instead of reporting a written index.
33
+ symlinkSync('/dev/null', join(root, 'docs', 'ai', 'index.md'));
34
+ const { code, stderr } = await cli(['--write-index', `--root=${root}`]);
35
+ assert.equal(code, 2);
36
+ assert.match(stderr, /index\.md was written empty/);
37
+ } finally {
38
+ rmSync(root, { recursive: true, force: true });
39
+ }
40
+ });
41
+ });
@@ -103,25 +103,19 @@ export const discoverMeta = async (root = ROOT) => {
103
103
  return { projectName, hierarchicalLinks, onDemandLinks };
104
104
  };
105
105
 
106
+ // Pure argv parser (no I/O, no exit): `help` / `error` ride out as data for runCli to render.
106
107
  const parseArgs = (argv) => {
107
108
  const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
108
109
  const opts = { today: null, root: null };
109
- for (const arg of argv.slice(2)) {
110
+ for (const arg of argv) {
110
111
  if (arg === '--report') flags.report = true;
111
112
  else if (arg === '--write-index') flags.writeIndex = true;
112
113
  else if (arg === '--check-index') flags.checkIndex = true;
113
114
  else if (arg === '--quiet') flags.quiet = true;
114
115
  else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
115
116
  else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
116
- else if (arg === '--help' || arg === '-h') {
117
- console.log(
118
- 'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]',
119
- );
120
- process.exit(0);
121
- } else {
122
- console.error(`Unknown argument: ${arg}`);
123
- process.exit(2);
124
- }
117
+ else if (arg === '--help' || arg === '-h') return { flags, opts, help: true };
118
+ else return { flags, opts, error: `Unknown argument: ${arg}` };
125
119
  }
126
120
  return { flags, opts };
127
121
  };
@@ -218,7 +212,7 @@ const formatRow = (row) => {
218
212
  return { status, sizeCell, ...row };
219
213
  };
220
214
 
221
- const printReport = (rows, quiet) => {
215
+ const printReport = (rows, quiet, log = console.log) => {
222
216
  const widths = {
223
217
  status: 2,
224
218
  path: Math.max(4, ...rows.map((r) => r.path.length)),
@@ -228,15 +222,15 @@ const printReport = (rows, quiet) => {
228
222
  };
229
223
  const printable = quiet ? rows.filter((r) => r.errors.length || r.warnings.length) : rows;
230
224
  if (printable.length > 0) {
231
- console.log(
225
+ log(
232
226
  `${'S'.padEnd(widths.status)} ${'PATH'.padEnd(widths.path)} ${'SIZE/MAX'.padEnd(widths.size)} ${'TYPE'.padEnd(widths.type)} ${'UPDATED'.padEnd(widths.updated)}`,
233
227
  );
234
228
  for (const row of printable) {
235
- console.log(
229
+ log(
236
230
  `${row.status.padEnd(widths.status)} ${row.path.padEnd(widths.path)} ${row.sizeCell.padEnd(widths.size)} ${(row.frontmatter?.type ?? '').padEnd(widths.type)} ${(row.frontmatter?.lastUpdated ?? '').padEnd(widths.updated)}`,
237
231
  );
238
- for (const err of row.errors) console.log(` - ERROR ${err}`);
239
- for (const warn of row.warnings) console.log(` - WARN ${warn}`);
232
+ for (const err of row.errors) log(` - ERROR ${err}`);
233
+ for (const warn of row.warnings) log(` - WARN ${warn}`);
240
234
  }
241
235
  }
242
236
  };
@@ -361,9 +355,29 @@ export const regenerateIndex = async (root, todayStr = null) => {
361
355
  return { indexPath, files: rows.length };
362
356
  };
363
357
 
364
- const main = async () => {
365
- const { flags, opts } = parseArgs(process.argv);
366
- const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : ROOT);
358
+ // The return-code entry point (no process.argv / process.exit / console inside): argv[]
359
+ // { code, stdout, stderr }. The thin shell at the bottom is the only process-coupled code.
360
+ export const runCli = async (argv, deps = {}) => {
361
+ const stdoutLines = [];
362
+ const stderrLines = [];
363
+ const log = (line) => stdoutLines.push(line);
364
+ const logError = (line) => stderrLines.push(line);
365
+ const result = (code) => ({
366
+ code,
367
+ stdout: stdoutLines.length > 0 ? `${stdoutLines.join('\n')}\n` : '',
368
+ stderr: stderrLines.length > 0 ? `${stderrLines.join('\n')}\n` : '',
369
+ });
370
+
371
+ const { flags, opts, help, error } = parseArgs(argv);
372
+ if (help) {
373
+ log('Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]');
374
+ return result(0);
375
+ }
376
+ if (error) {
377
+ logError(error);
378
+ return result(2);
379
+ }
380
+ const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : (deps.root ?? ROOT));
367
381
  const today = computeToday(opts.today);
368
382
  const files = (await walkMarkdownFiles(docsDir)).sort();
369
383
  const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
@@ -373,11 +387,11 @@ const main = async () => {
373
387
 
374
388
  if (flags.writeIndex) {
375
389
  await writeIndex(rows, today, meta, indexPath);
376
- console.log(`Wrote ${relative(root, indexPath)}`);
390
+ log(`Wrote ${relative(root, indexPath)}`);
377
391
  const after = await stat(indexPath);
378
392
  if (after.size === 0) {
379
- console.error('index.md was written empty');
380
- process.exit(2);
393
+ logError('index.md was written empty');
394
+ return result(2);
381
395
  }
382
396
  }
383
397
 
@@ -385,28 +399,31 @@ const main = async () => {
385
399
  const onDisk = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : null;
386
400
  const { fresh } = checkIndexFreshness(rows, onDisk, meta);
387
401
  if (!fresh) {
388
- console.error(
402
+ logError(
389
403
  `[check-docs-size] FAIL: ${relative(root, indexPath)} is stale (out of sync with source frontmatter). Regenerate the index (--write-index) and commit the regenerated file.`,
390
404
  );
391
- process.exit(1);
405
+ return result(1);
392
406
  }
393
- console.log(
407
+ log(
394
408
  `[check-docs-size] OK — ${relative(root, indexPath)} is in sync with source frontmatter.`,
395
409
  );
396
- return;
410
+ return result(0);
397
411
  }
398
412
 
399
- printReport(rows, flags.quiet);
413
+ printReport(rows, flags.quiet, log);
400
414
  const errorCount = rows.reduce((n, r) => n + r.errors.length, 0);
401
415
  const warnCount = rows.reduce((n, r) => n + r.warnings.length, 0);
402
- console.log(
416
+ log(
403
417
  `\n${rows.length} files inspected — ${errorCount} error(s), ${warnCount} warning(s)`,
404
418
  );
405
419
 
406
- if (errorCount > 0 && !flags.report) process.exit(1);
420
+ return result(errorCount > 0 && !flags.report ? 1 : 0);
407
421
  };
408
422
 
409
423
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
410
424
  if (isDirectRun) {
411
- await main();
425
+ const { code, stdout, stderr } = await runCli(process.argv.slice(2));
426
+ if (stdout) process.stdout.write(stdout);
427
+ if (stderr) process.stderr.write(stderr);
428
+ process.exitCode = code;
412
429
  }
@@ -0,0 +1,82 @@
1
+ // install-git-hooks-repo-exec.test.mjs — the SIBLING installer executed IN PLACE (no deploy
2
+ // copy): GIT_DIR pins every git answer to a fixture repo, so the run exercises this tree's own
3
+ // file — the D3(d) changed-line check reads real executions of the shipped bytes, while the
4
+ // deploy-copy suite (install-git-hooks.test.mjs) keeps the consumer-shaped behavior pins. Every
5
+ // branch of the installer runs from here: fresh install, the guard arm/carry/disable/conflict
6
+ // lanes, the unmanaged refusal, the up-to-date short-circuit, and the not-a-git-checkout skip.
7
+
8
+ import { describe, it } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join, dirname } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { spawnSync } from 'node:child_process';
15
+
16
+ const HERE = dirname(fileURLToPath(import.meta.url));
17
+ const INSTALLER = join(HERE, 'install-git-hooks.mjs');
18
+
19
+ const mkRepo = () => {
20
+ const root = mkdtempSync(join(tmpdir(), 'hooks-repo-exec-'));
21
+ spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
22
+ writeFileSync(join(root, 'guard.mjs'), '// a stand-in commit-guard tool\n');
23
+ return { root, gitDir: join(root, '.git') };
24
+ };
25
+
26
+ // The installer anchors its git queries at its OWN parent dir — GIT_DIR overrides that anchor to
27
+ // the fixture repo, so running the in-place file NEVER touches this tree's hooks.
28
+ const runAt = (gitDir, args = []) =>
29
+ spawnSync('node', [INSTALLER, ...args], { encoding: 'utf8', env: { ...process.env, GIT_DIR: gitDir } });
30
+
31
+ const hookOf = ({ gitDir }) => readFileSync(join(gitDir, 'hooks', 'pre-commit'), 'utf8');
32
+
33
+ describe('install-git-hooks — the in-place installer under a GIT_DIR-pinned fixture', () => {
34
+ it('installs the managed hook, is idempotent, and refuses an UNMANAGED hook', () => {
35
+ const fx = mkRepo();
36
+ assert.equal(runAt(fx.gitDir).status, 0);
37
+ assert.match(hookOf(fx), /:install-git-hooks\.mjs/, 'the managed marker is written');
38
+ const again = runAt(fx.gitDir);
39
+ assert.equal(again.status, 0);
40
+ assert.match(again.stdout, /already up to date/);
41
+ writeFileSync(join(fx.gitDir, 'hooks', 'pre-commit'), '#!/bin/sh\nexit 0\n');
42
+ const refused = runAt(fx.gitDir);
43
+ assert.equal(refused.status, 1, 'an unmanaged hook is never overwritten');
44
+ assert.match(refused.stderr, /Refusing to overwrite/);
45
+ rmSync(fx.root, { recursive: true, force: true });
46
+ });
47
+
48
+ it('the guard lanes: arm (absolute path) → carry on flagless re-run → consented disable', () => {
49
+ const fx = mkRepo();
50
+ assert.equal(runAt(fx.gitDir, ['--commit-guard', join(fx.root, 'guard.mjs')]).status, 0);
51
+ assert.match(hookOf(fx), /guard\.mjs" --check/);
52
+ assert.equal(runAt(fx.gitDir).status, 0, 'the flagless re-run keeps the armed line');
53
+ assert.match(hookOf(fx), /guard\.mjs" --check/);
54
+ assert.equal(runAt(fx.gitDir, ['--no-commit-guard']).status, 0);
55
+ assert.doesNotMatch(hookOf(fx), /" --check$/m);
56
+ rmSync(fx.root, { recursive: true, force: true });
57
+ });
58
+
59
+ it('the guard refusals: both flags (usage), a bare flag (usage), a missing path, a duplicated line', () => {
60
+ const fx = mkRepo();
61
+ assert.equal(runAt(fx.gitDir, ['--commit-guard', join(fx.root, 'guard.mjs'), '--no-commit-guard']).status, 2);
62
+ assert.equal(runAt(fx.gitDir, ['--commit-guard']).status, 2, '--commit-guard without a path is a usage error');
63
+ assert.equal(runAt(fx.gitDir, ['--commit-guard', join(fx.root, 'no-such.mjs')]).status, 1, 'a nonexistent guard path refuses');
64
+ assert.equal(runAt(fx.gitDir, ['--commit-guard', join(fx.root, 'guard.mjs')]).status, 0);
65
+ const armed = hookOf(fx);
66
+ const guardLine = armed.split('\n').find((l) => /" --check$/.test(l));
67
+ writeFileSync(join(fx.gitDir, 'hooks', 'pre-commit'), `${armed}${guardLine}\n`);
68
+ const dup = runAt(fx.gitDir);
69
+ assert.equal(dup.status, 1, 'two guard lines are ambiguous — fail closed');
70
+ assert.match(dup.stderr, /commit-guard --check lines/);
71
+ rmSync(fx.root, { recursive: true, force: true });
72
+ });
73
+
74
+ it('outside a git checkout the installer SKIPS loudly and writes nothing', () => {
75
+ const nowhere = mkdtempSync(join(tmpdir(), 'hooks-repo-exec-nogit-'));
76
+ const r = runAt(join(nowhere, 'absent-git-dir'));
77
+ assert.equal(r.status, 0);
78
+ assert.match(r.stdout, /skipping \(not a git checkout\)/);
79
+ assert.equal(existsSync(join(nowhere, 'absent-git-dir')), false, 'nothing was created');
80
+ rmSync(nowhere, { recursive: true, force: true });
81
+ });
82
+ });