@sabaiway/agent-workflow-kit 2.1.0 → 3.1.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.
Files changed (107) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +7 -5
  3. package/SKILL.md +16 -8
  4. package/bin/install.mjs +11 -2
  5. package/bridges/antigravity-cli-bridge/SKILL.md +8 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +163 -0
  7. package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
  8. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +80 -15
  9. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +171 -22
  10. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +59 -41
  11. package/bridges/antigravity-cli-bridge/capability.json +3 -2
  12. package/bridges/codex-cli-bridge/SKILL.md +8 -1
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +67 -22
  14. package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +156 -0
  15. package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
  16. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +158 -27
  17. package/bridges/codex-cli-bridge/capability.json +3 -2
  18. package/capability.json +1 -1
  19. package/migrations/3.0.0-hardened-core-loop.md +44 -0
  20. package/migrations/README.md +1 -1
  21. package/package.json +2 -2
  22. package/references/hooks/gate-approve.mjs +1 -1
  23. package/references/modes/bootstrap.md +3 -3
  24. package/references/modes/commit-guard.md +19 -0
  25. package/references/modes/core-evidence.md +15 -0
  26. package/references/modes/coverage-check.md +12 -0
  27. package/references/modes/doc-parity.md +7 -6
  28. package/references/modes/gates.md +7 -6
  29. package/references/modes/grounding.md +1 -2
  30. package/references/modes/hook.md +1 -1
  31. package/references/modes/recommendations.md +1 -0
  32. package/references/modes/review-state.md +3 -3
  33. package/references/modes/upgrade.md +6 -6
  34. package/references/modes/velocity.md +2 -2
  35. package/references/modes/worktrees.md +120 -0
  36. package/references/scripts/archive-decisions.mjs +3 -3
  37. package/references/scripts/archive-decisions.test.mjs +5 -5
  38. package/references/scripts/check-docs-size-cli.test.mjs +41 -0
  39. package/references/scripts/check-docs-size.mjs +46 -29
  40. package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
  41. package/references/scripts/install-git-hooks.mjs +90 -18
  42. package/references/scripts/install-git-hooks.test.mjs +102 -0
  43. package/references/scripts/migrate-gates-branches.test.mjs +157 -0
  44. package/references/scripts/migrate-gates.mjs +395 -0
  45. package/references/scripts/migrate-gates.test.mjs +284 -0
  46. package/references/templates/agent_rules.md +2 -2
  47. package/tools/ack-write.mjs +1 -1
  48. package/tools/atomic-write.mjs +2 -2
  49. package/tools/autonomy-config.mjs +1 -1
  50. package/tools/autonomy-doctor.mjs +2 -2
  51. package/tools/autonomy-write.mjs +1 -1
  52. package/tools/bridge-settings-read.mjs +1 -1
  53. package/tools/bridge-settings.mjs +1 -1
  54. package/tools/changed-surface.mjs +16 -77
  55. package/tools/cheap-agents.mjs +2 -2
  56. package/tools/commands.mjs +26 -10
  57. package/tools/commit-guard.mjs +167 -0
  58. package/tools/core-evidence.mjs +914 -0
  59. package/tools/coverage-check.mjs +260 -0
  60. package/tools/delegation.mjs +1 -1
  61. package/tools/detect-backends.mjs +3 -3
  62. package/tools/doc-parity.mjs +11 -27
  63. package/tools/engine-source.mjs +1 -1
  64. package/tools/family-members.mjs +1 -1
  65. package/tools/family-registry.mjs +1 -1
  66. package/tools/fs-safe.mjs +1 -1
  67. package/tools/gate-hook.mjs +1 -1
  68. package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
  69. package/tools/grounding.mjs +19 -96
  70. package/tools/hide-footprint.mjs +1 -1
  71. package/tools/inject-methodology.mjs +72 -41
  72. package/tools/known-footprint.mjs +1 -1
  73. package/tools/labels.mjs +1 -1
  74. package/tools/lcov.mjs +6 -10
  75. package/tools/lens-region.mjs +1 -1
  76. package/tools/manifest/validate.mjs +19 -0
  77. package/tools/migrate-adr-store.mjs +5 -5
  78. package/tools/orchestration-config.mjs +1 -1
  79. package/tools/orchestration-write.mjs +1 -1
  80. package/tools/presentation.mjs +1 -1
  81. package/tools/procedures.mjs +3 -3
  82. package/tools/recipes.mjs +62 -11
  83. package/tools/recommendations.mjs +97 -14
  84. package/tools/release-scan.mjs +10 -3
  85. package/tools/renderers.mjs +1 -1
  86. package/tools/review-state.mjs +291 -343
  87. package/tools/run-gates.mjs +216 -92
  88. package/tools/sandbox-masks.mjs +14 -14
  89. package/tools/semver-lite.mjs +1 -1
  90. package/tools/set-autonomy.mjs +1 -1
  91. package/tools/set-recipe.mjs +2 -2
  92. package/tools/setup-backends.mjs +1 -1
  93. package/tools/surface.mjs +1 -1
  94. package/tools/uninstall.mjs +1 -1
  95. package/tools/velocity-profile.mjs +9 -9
  96. package/tools/view-model.mjs +1 -1
  97. package/tools/worktrees.mjs +2292 -0
  98. package/references/modes/fold-completeness.md +0 -30
  99. package/references/modes/review-ledger.md +0 -34
  100. package/references/templates/verification-profile.json +0 -10
  101. package/tools/fold-completeness-run.mjs +0 -1120
  102. package/tools/fold-completeness.mjs +0 -672
  103. package/tools/review-ledger-core.mjs +0 -428
  104. package/tools/review-ledger-write.mjs +0 -647
  105. package/tools/review-ledger.mjs +0 -630
  106. package/tools/sarif.mjs +0 -52
  107. package/tools/verification-profile.mjs +0 -219
@@ -0,0 +1,120 @@
1
+ ### Mode: worktrees
2
+
3
+ Parallel feature worktrees (v1) — several features implemented simultaneously in DIFFERENT agent
4
+ sessions on one machine/repo, zero interference on working-tree files (the ONE exception: the
5
+ default `node_modules` symlink is a shared MUTABLE dependency cache — see below), unambiguous
6
+ ownership. One thin tool over git; every verification datum is recomputed live from git, never
7
+ read from stored metadata.
8
+ The ONE stored-metadata exception is the PREPARED OID recorded in the handoff: land and cleanup read it back only for recovery.
9
+
10
+ **Run** — `node ${CLAUDE_SKILL_DIR}/tools/worktrees.mjs <subcommand> …`:
11
+
12
+ Git ≥ 2.36 is required for NUL-terminated worktree porcelain; an older Git fails closed with its
13
+ own verbatim error through the existing Git-error surface.
14
+
15
+ - `provision <slug> --plan <path> [--as <name>.md] [--dir <path>] [--branch <name>] [--include <path>]... [--install] [--resume]`
16
+ — create a feature worktree (default: the visible sibling `<repoParent>/<repoName>--<slug>`,
17
+ branch `aw/<slug>`) and populate it: the registry-derived footprint copy-if-missing (a tracked
18
+ file is NEVER overwritten), EXACTLY ONE seeded feature plan, the `handoff-<slug>.md` stub
19
+ (written at provision — the tool's own record; `list` and `cleanup` read it), a
20
+ `node_modules` symlink when main has one and the link stays ignored — a SHARED MUTABLE cache:
21
+ writes through it hit MAIN's node_modules; for isolation RUN the printed isolated-install
22
+ command (`--install` only prints it), and on `--resume` an existing symlink is kept — run the
23
+ printed unlink-first recovery first. Absolute root-pinned gate commands are rebased on UNTRACKED copies only — and only
24
+ while their bytes still equal the MAIN source (or its rebased form); user-modified copies stay
25
+ byte-untouched. `--install` only PRINTS the install command — zero spawn, zero write.
26
+ `--resume <slug>` completes a half-done provision (identity fail-closed; handoff user sections,
27
+ the seeded plan, and edited copies are preserved byte-exact; the provision-record section is
28
+ refreshed atomically; copy-if-missing everywhere).
29
+ Resume identity also binds the EXISTING handoff: at most one `handoff-<slug>.md` may exist and
30
+ its recorded slug AND branch must match the live invocation — a second handoff, a name/record
31
+ mismatch, or a handoff that is not a regular file is a typed STOP before anything is written
32
+ (the writability probe itself runs only after these checks on a resume). The provision record
33
+ is read from exactly one REQUIRED `## Provision record` section (a decoy field elsewhere cannot
34
+ hijack identity); a missing or repeated section, or a duplicated single-valued field, is a typed
35
+ STOP, never last-wins.
36
+ - `list` — read-only: every worktree of this repo with slug (from the handoff file; none →
37
+ "unknown (foreign)"), path, branch, base OID (the worktree HEAD — under the v1 no-commit bar
38
+ that IS the provision base; a manual satellite commit moves it, and land derives its own base
39
+ live), dirty flag, handoff presence, opener suggestion. Honest read errors: only a genuinely
40
+ ABSENT docs/plans under a symlink-free, present worktree dir reads as `handoff: no`; a
41
+ symlinked docs/plans (or ancestor), a handoff-named entry that is not a regular file, a
42
+ vanished worktree dir, or any other read failure renders `handoff: (unreadable)` — never a
43
+ silent "no".
44
+ - `land <slug> --prepare` — stage the satellite's finished diff onto a CLEAN main; the commit is
45
+ NEVER run by the tool — it stays a dialogue ask at the primary session. Land takes the transient
46
+ common-git-dir lock, refuses a dirty main, graph divergence, visible `docs/ai` drift, excluded
47
+ staged paths, unstaged/untracked leftovers, an empty diff, or a red satellite review-state. The
48
+ complete satellite working-tree diff versus its base is inspected (staged and unstaged); every
49
+ unstaged/untracked path is listed and refused, so an accepted transfer cannot silently omit it.
50
+ The binary/no-ext-diff/no-textconv transfer excludes exactly `docs/ai` and `docs/plans`, applies
51
+ into the index, optionally runs the porcelain-visible sync adapter, then runs the main gate
52
+ matrix. It reports main HEAD, TRANSFER, PREPARED, and sync delta OIDs/data; the primary re-attests
53
+ that prepared tree and confirms main HEAD still equals the printed OID before the commit ask.
54
+ - `cleanup <slug> [--branch <name>] [--abandon]` — take the same transient lock and remove a LANDED
55
+ worktree fail-closed after live landed-verification against main HEAD. Verification uses exactly
56
+ the land exclusions (`docs/ai`, `docs/plans`), then checks untracked and ignored content before a
57
+ plain worktree remove, branch `-d`, and prune. Provision-derived ignored containers are ephemeral;
58
+ `node_modules` of any kind is provision-derivable because provision explicitly advises installs.
59
+ Foreign content stops cleanup. `--abandon` is the ONE destructive arm: it DESTROYS unlanded work,
60
+ requires the handoff identity, and is the only path where `--force` may appear.
61
+
62
+ **Honesty:** there is NO preview step on the writers — over-warned by design. The tool never
63
+ commits, never pushes, never runs a subscription CLI. Every content read and regular-file copy
64
+ goes through its one no-follow descriptor door (identity-bound source, exclusive destination,
65
+ descriptor mode update), and tripwire tests keep them the only paths.
66
+
67
+ **Settings:** the parent dir for new worktrees is the `docs/ai/worktrees.json` `{"parentDir": …}`
68
+ project setting (hand-editable strict JSON; absent file → the sibling default; malformed → a
69
+ typed STOP, never a guess). The file must be a REGULAR, non-symlink file reached through a
70
+ symlink-free path — anything else is a typed STOP (the advisor renders the same shape as a
71
+ stated skip), and the ancestor chain is verified even when the leaf is ABSENT (a symlinked
72
+ `docs/` or `docs/ai` never reads as plain absence). `--dir` overrides per invocation.
73
+
74
+ **Host-specific consent (zero-prompt only where the host honors it):** every sibling-dir mutation
75
+ runs a REAL create+delete writability probe first. An unwritable parent prints both the
76
+ settings-native line (`sandbox.filesystem.allowWrite` in `.claude/settings.json`) and the full
77
+ terminal command. On a settings-native host that honors the key, adding the parent can make later
78
+ provision/cleanup promptless. On a harness-managed host that ignores project settings, grant the
79
+ narrow parent through the host/session controls; if that is unavailable, use the printed terminal
80
+ command for each operation. The `recommendations` mode surfaces this lane but treats write access
81
+ as unverified without a trusted host-capability signal.
82
+
83
+ **Landing flow:** provision → work → handoff → land → re-attest → commit → cleanup. Satellite
84
+ commits are outside v1: graph divergence stops land and prints cherry-pick/rebase recovery. A gate
85
+ failure keeps the prepared main tree and names both recovery lanes. A second prepare is reset-only:
86
+ the STOP prints the current staged write-tree, compares it with the PREPARED OID recorded in the
87
+ handoff, distinguishes a converged re-run from foreign staged work, and lists removal commands for
88
+ untracked crash residue before the reset-and-re-run lane. A killed process may leave
89
+ `aw-prepare-lock`; after confirming no land/cleanup process owns it, remove that directory by hand.
90
+
91
+ The optional `scripts/sync-mirrors.mjs` adapter runs as a child. Its contract is porcelain-visible
92
+ output: tracked modifications/deletions or untracked-not-ignored creations. Ignored writes and
93
+ changes inside an already-untracked file are outside observation and therefore out of contract.
94
+ Cleanup reports EBUSY as likely lingering processes/open file descriptors (including a sandbox
95
+ mount); close them and retry, outside the sandbox when needed. Hidden satellite `docs/ai` state is
96
+ ephemeral by design; durable content belongs in the handoff before landing — the handoff carries a
97
+ free-form session-records digest slot (every section outside `## Provision record` is user-owned
98
+ and byte-preserved by the tool).
99
+
100
+ **Ownership:** MAIN owns MAIN-tree files, commits, pushes, releases, the gate matrix, every
101
+ docs/ai record, `docs/plans/queue.md`, and all shared git state — stash, hooks, repo config,
102
+ `.git/info/exclude`, and every ref except the satellite's configured branch. The SATELLITE owns
103
+ that one branch (`aw/<slug>` or the `--branch` override), its feature edits, its seeded plan, and
104
+ the user-owned handoff sections; `## Provision record` remains tool-owned. Satellite forbidden
105
+ verbs (the v1 docs-only bar): no `git commit`/`push`/`tag`/`git stash`/history rewrite — the ONE
106
+ legal rewrite is the tool-printed `git reset --hard` recovery of the satellite's OWN configured
107
+ branch (`aw/<slug>` or the `--branch` override) — no kit lifecycle writers
108
+ (`init`/`upgrade`/`setup`/`hide-footprint`/`install-git-hooks`/`sandbox-masks`/`ack-write`), no
109
+ queue.md writes, no version bumps or publishes, no edits to MAIN's files from the satellite
110
+ session — divergence and the landed verification enforce the observable half; the rest is the
111
+ stated contract. A symlinked `node_modules` under npm workspaces resolves
112
+ workspace self-links to MAIN-tree sources — use the printed isolated install when that matters.
113
+
114
+ **Other harnesses:** PROVEN — the host-installed codex/agy review wrappers run with a provisioned
115
+ worktree as their cwd; the footprint carries the root `AGENTS.md` required by the codex wrappers
116
+ and available to agy as its fallback project context. The host-level `bridge-settings.conf` under
117
+ `${XDG_CONFIG_HOME:-~/.config}/agent-workflow/` is per-host, shared by every worktree, and is not
118
+ copied into the worktree. ASSUMED until probed per harness — each target harness's project-local
119
+ settings and context pickup in a fresh worktree session; treat that as unverified until the target
120
+ harness demonstrates it.
@@ -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
+ });
@@ -1,12 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  // Idempotent installer for the project's git hooks.
3
3
  //
4
- // Installs `.git/hooks/pre-commit` running the docs cap-validator + index-freshness
4
+ // Installs the `pre-commit` hook running the docs cap-validator + index-freshness
5
5
  // gate + changelog/issues rotation-freshness checks + the `scripts/` test
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
9
  //
10
+ // The hooks location comes from git's OWN answer (`git rev-parse --git-path hooks`) — a linked
11
+ // worktree resolves ITS OWN hooks path (its `.git` is a FILE; a hardcoded `.git/hooks` would
12
+ // miss or mis-write).
13
+ //
14
+ // COMMIT GUARD (D10, optional): `--commit-guard <path>` names the composition root's read-only
15
+ // commit-guard tool explicitly — the hook then gains its `--check` line with the RESOLVED,
16
+ // quoted path written at install time (a portable contract: no runtime guessing; this substrate
17
+ // knows no sibling by name, so the path always arrives from the caller). The guard binds the
18
+ // latest final-run receipt to the exact commit tree. An armed guard line SURVIVES flagless
19
+ // re-runs (carried forward from the managed hook — exactly ONE strictly-parsed line; an
20
+ // ambiguous duplicate fails closed); `--no-commit-guard` is the one consented disable; passing
21
+ // both flags together is a usage error.
22
+ //
10
23
  // Package-manager-agnostic: the hook calls the scripts via `node` directly (no pnpm/npm
11
24
  // assumption). Re-running is safe — the script detects a previously installed hook via
12
25
  // the MAGIC_MARKER comment and rewrites only that file.
@@ -17,12 +30,11 @@ import { writeFile, readFile, mkdir, chmod } from 'node:fs/promises';
17
30
  import { existsSync, readFileSync } from 'node:fs';
18
31
  import { dirname, resolve, basename } from 'node:path';
19
32
  import { fileURLToPath } from 'node:url';
33
+ import { spawnSync } from 'node:child_process';
20
34
 
21
35
  const __filename = fileURLToPath(import.meta.url);
22
36
  const __dirname = dirname(__filename);
23
37
  const ROOT = resolve(__dirname, '..');
24
- const HOOKS_DIR = resolve(ROOT, '.git/hooks');
25
- const PRE_COMMIT_PATH = resolve(HOOKS_DIR, 'pre-commit');
26
38
 
27
39
  const readProjectName = () => {
28
40
  try {
@@ -36,7 +48,58 @@ const readProjectName = () => {
36
48
 
37
49
  const MAGIC_MARKER = `# ${readProjectName()}:install-git-hooks.mjs`;
38
50
 
39
- const PRE_COMMIT_CONTENT = `#!/usr/bin/env bash
51
+ // git's own hooks location (worktree-correct), or null outside a git checkout.
52
+ const gitHooksDir = () => {
53
+ const r = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: ROOT, encoding: 'utf8', windowsHide: true });
54
+ if (r.error || r.status !== 0) return null;
55
+ return resolve(ROOT, r.stdout.replace(/\r?\n$/, ''));
56
+ };
57
+
58
+ // The exact shape this installer writes — the strict carry-forward parse anchors on it
59
+ // (quoted path + a bare ` --check` tail; nothing else in the managed hook matches).
60
+ const GUARD_LINE_RE = /^node "([^"]+)" --check$/;
61
+
62
+ const ARGV = process.argv.slice(2);
63
+
64
+ // The optional commit-guard line: an explicit --commit-guard <path> arms it (resolved at install
65
+ // time); --no-commit-guard is the one consented disable; both together are a usage error —
66
+ // never an order-dependent pick.
67
+ const resolveGuardFlags = (argv) => {
68
+ const at = argv.indexOf('--commit-guard');
69
+ const noGuard = argv.includes('--no-commit-guard');
70
+ if (at !== -1 && noGuard) {
71
+ console.error('[install-git-hooks] --commit-guard and --no-commit-guard contradict each other — pass exactly one');
72
+ process.exit(2);
73
+ }
74
+ if (noGuard) return { mode: 'disable' };
75
+ if (at === -1) return { mode: 'carry' };
76
+ const given = argv[at + 1];
77
+ if (!given) {
78
+ console.error('[install-git-hooks] --commit-guard needs a path');
79
+ process.exit(2);
80
+ }
81
+ const abs = resolve(ROOT, given);
82
+ if (!existsSync(abs)) {
83
+ console.error(`[install-git-hooks] --commit-guard ${abs} does not exist`);
84
+ process.exit(1);
85
+ }
86
+ return { mode: 'arm', path: abs };
87
+ };
88
+
89
+ // Flagless re-runs carry the armed guard forward from the MANAGED hook — strictly parsed:
90
+ // exactly one guard-shaped line keeps its path; none keeps the hook plain; more than one is
91
+ // ambiguous and fails CLOSED (an installer must never guess which line was the real guard).
92
+ const carriedGuardPath = (existingHook) => {
93
+ if (existingHook === null) return null;
94
+ const matches = existingHook.split('\n').map((l) => GUARD_LINE_RE.exec(l)).filter(Boolean);
95
+ if (matches.length > 1) {
96
+ console.error(`[install-git-hooks] the managed pre-commit hook carries ${matches.length} commit-guard --check lines — ambiguous; remove the duplicates by hand and re-run (refusing to guess)`);
97
+ process.exit(1);
98
+ }
99
+ return matches.length === 1 ? matches[0][1] : null;
100
+ };
101
+
102
+ const preCommitContent = (guardPath) => `#!/usr/bin/env bash
40
103
  ${MAGIC_MARKER}
41
104
  # Auto-installed by scripts/install-git-hooks.mjs (run it from "prepare" or by hand).
42
105
  # Runs the docs cap-validator + index-freshness gate + rotation-freshness checks
@@ -50,32 +113,41 @@ node scripts/archive-changelog.mjs --check
50
113
  node scripts/archive-issues.mjs --check
51
114
  node scripts/archive-decisions.mjs --check
52
115
  node --test scripts/*.test.mjs
53
- `;
116
+ ${guardPath ? `node "${guardPath}" --check\n` : ''}`;
54
117
 
55
118
  const main = async () => {
56
- if (!existsSync(resolve(ROOT, '.git'))) {
57
- console.log('[install-git-hooks] .git directory not found — skipping (not a git checkout).');
119
+ const flags = resolveGuardFlags(ARGV);
120
+ const hooksDir = gitHooksDir();
121
+ if (hooksDir === null) {
122
+ console.log('[install-git-hooks] git cannot resolve a hooks path here — skipping (not a git checkout).');
58
123
  return;
59
124
  }
60
- await mkdir(HOOKS_DIR, { recursive: true });
125
+ const preCommitPath = resolve(hooksDir, 'pre-commit');
61
126
 
62
- if (existsSync(PRE_COMMIT_PATH)) {
63
- const existing = await readFile(PRE_COMMIT_PATH, 'utf8');
64
- if (existing.includes(MAGIC_MARKER) && existing === PRE_COMMIT_CONTENT) {
65
- console.log('[install-git-hooks] pre-commit already up to date.');
66
- return;
67
- }
127
+ let existing = null;
128
+ if (existsSync(preCommitPath)) {
129
+ existing = await readFile(preCommitPath, 'utf8');
68
130
  if (!existing.includes(MAGIC_MARKER)) {
69
131
  console.warn(
70
- '[install-git-hooks] WARNING: .git/hooks/pre-commit exists and was not installed by this script. Refusing to overwrite — remove or merge it manually.',
132
+ `[install-git-hooks] WARNING: ${preCommitPath} exists and was not installed by this script. Refusing to overwrite — remove or merge it manually.`,
71
133
  );
72
134
  process.exit(1);
73
135
  }
74
136
  }
75
137
 
76
- await writeFile(PRE_COMMIT_PATH, PRE_COMMIT_CONTENT, 'utf8');
77
- await chmod(PRE_COMMIT_PATH, 0o755);
78
- console.log('[install-git-hooks] installed .git/hooks/pre-commit (docs caps + index freshness + archive checks + scripts/ tests).');
138
+ const guardPath = flags.mode === 'arm' ? flags.path : flags.mode === 'disable' ? null : carriedGuardPath(existing);
139
+ const content = preCommitContent(guardPath);
140
+ if (existing === content) {
141
+ console.log('[install-git-hooks] pre-commit already up to date.');
142
+ return;
143
+ }
144
+
145
+ await mkdir(hooksDir, { recursive: true });
146
+ await writeFile(preCommitPath, content, 'utf8');
147
+ await chmod(preCommitPath, 0o755);
148
+ console.log(
149
+ `[install-git-hooks] installed ${preCommitPath} (docs caps + index freshness + archive checks + scripts/ tests${guardPath ? ' + commit guard' : ''}).`,
150
+ );
79
151
  };
80
152
 
81
153
  main().catch((err) => {