@sabaiway/agent-workflow-memory 2.3.0 → 3.0.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
@@ -2,7 +2,35 @@
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.0 — strip-the-kit: the substrate follows the hardened computed core (AD-059)
8
+
9
+ > ### ⚠ BREAKING — retired loop surfaces + Node >= 22
10
+ >
11
+ > The verification-profile template is gone; the gates.json seed and the loop docs describe the
12
+ > `run-gates --final` / commit-guard loop; pre-commit hooks from `install-git-hooks.mjs` may gain
13
+ > the consented `--commit-guard <path>` line. Node floor **>= 22**. Deployment-lineage head is
14
+ > **3.0.0** — init/upgrade re-stamps and applies `migrations/3.0.0-hardened-core-loop.md`.
15
+
16
+ Shipped in lockstep with kit 3.0.0 / engine 2.0.0 (the lineage owner carries the consumer
17
+ migration surfaces):
18
+
19
+ - **`migrate-gates.mjs` (+ its deploy-payload spec, NEW):** the consented D8 legacy gates.json
20
+ migration — preview default, atomic apply; canonical legacy entries removed by their documented
21
+ single-invocation forms; the canonical `unit-tests` cmd gains the full lcov reporter flag set;
22
+ the canonical `coverage-check` gate lands LAST (realpath-anchored, move/collision semantics);
23
+ retired git-dir ledger stores cleaned; CUSTOMIZED entries reported with paste-ready recoveries;
24
+ symlinked-parent and control-byte STOPs, all fail-closed.
25
+ - **`install-git-hooks.mjs`:** the hooks path comes from `git rev-parse --git-path hooks` (a
26
+ linked worktree installs at ITS OWN hooks dir); the optional `--commit-guard <path>` arm writes
27
+ the resolved quoted guard line; an armed line survives flagless re-runs (strict single-line
28
+ carry-forward; `--no-commit-guard` is the one consented disable; duplicates fail closed). New
29
+ colocated specs incl. the in-place GIT_DIR-pinned execution suite.
30
+ - **`stamp-takeover.mjs`:** LINEAGE_HEAD 3.0.0; the takeover/migration selection mechanics are
31
+ unchanged — new deployments stamp 3.0.0, older ones pick up the 3.0.0 migration.
32
+ - **Templates/docs:** the gates seed + agent-rules/§4 consent line follow the D13 loop (staging
33
+ is reversible loop-work; COMMIT is the ask); SKILL sweeps for the three seeded configs.
6
34
 
7
35
  ## 2.3.0 — agent_rules template: a report-facts Communication bullet + the writer-batch lens re-render (REPORT-FACTS train, AD-054)
8
36
 
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.0'
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.0",
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.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",
@@ -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';
@@ -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) => {
@@ -0,0 +1,102 @@
1
+ // install-git-hooks.test.mjs — spec for the pre-commit installer (strip-the-kit round-1 folds):
2
+ // the hooks path comes from git plumbing (a linked worktree resolves ITS OWN hooks path, never a
3
+ // hardcoded `.git/hooks`), and an armed commit-guard line SURVIVES a flagless re-run (exactly one
4
+ // strictly-parsed canonical guard line carries forward; --no-commit-guard is the only consented
5
+ // disable; both flags together are a usage error; a duplicated guard line fails closed).
6
+
7
+ import { describe, it } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, existsSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { join, dirname, resolve } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url));
16
+ const INSTALLER_SRC = join(HERE, 'install-git-hooks.mjs');
17
+
18
+ // The installer anchors ROOT at <its dir>/.. — deploy it into a fixture's scripts/ like a consumer.
19
+ const mkProject = () => {
20
+ const root = mkdtempSync(join(tmpdir(), 'install-hooks-'));
21
+ const g = (...a) => spawnSync('git', a, { cwd: root, encoding: 'utf8' });
22
+ g('init', '-q');
23
+ g('config', 'user.email', 'p@e');
24
+ g('config', 'user.name', 'p');
25
+ mkdirSync(join(root, 'scripts'), { recursive: true });
26
+ cpSync(INSTALLER_SRC, join(root, 'scripts', 'install-git-hooks.mjs'));
27
+ writeFileSync(join(root, 'guard.mjs'), '// a stand-in commit-guard tool\n');
28
+ writeFileSync(join(root, 'base.txt'), 'base\n');
29
+ g('add', '-A');
30
+ g('commit', '-qm', 'base');
31
+ return { root, g };
32
+ };
33
+
34
+ const runInstaller = (root, args = []) =>
35
+ spawnSync('node', [join(root, 'scripts', 'install-git-hooks.mjs'), ...args], { cwd: root, encoding: 'utf8' });
36
+
37
+ const hooksPathOf = (root) => {
38
+ const r = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: root, encoding: 'utf8' });
39
+ return resolve(root, r.stdout.trim());
40
+ };
41
+
42
+ describe('install-git-hooks — the hooks path comes from git plumbing (C7)', () => {
43
+ it('a normal repo installs at the git-path hooks location', () => {
44
+ const { root } = mkProject();
45
+ const r = runInstaller(root);
46
+ assert.equal(r.status, 0, r.stderr);
47
+ assert.ok(existsSync(join(hooksPathOf(root), 'pre-commit')), 'the hook lands where git says hooks live');
48
+ rmSync(root, { recursive: true, force: true });
49
+ });
50
+
51
+ it('a LINKED WORKTREE installs at ITS OWN git-path hooks (never a hardcoded <worktree>/.git/hooks)', () => {
52
+ const { root, g } = mkProject();
53
+ const wt = join(root, '..', `wt-${Date.now() % 1e6}`);
54
+ g('worktree', 'add', '-q', wt);
55
+ mkdirSync(join(wt, 'scripts'), { recursive: true });
56
+ cpSync(INSTALLER_SRC, join(wt, 'scripts', 'install-git-hooks.mjs'));
57
+ const r = runInstaller(wt);
58
+ assert.equal(r.status, 0, r.stderr);
59
+ const expected = join(hooksPathOf(wt), 'pre-commit');
60
+ assert.ok(existsSync(expected), `the worktree hook lands at git's own answer (${expected})`);
61
+ assert.ok(!existsSync(join(wt, '.git', 'hooks', 'pre-commit')), 'never a literal <worktree>/.git/hooks (a worktree .git is a FILE)');
62
+ rmSync(wt, { recursive: true, force: true });
63
+ rmSync(root, { recursive: true, force: true });
64
+ });
65
+ });
66
+
67
+ describe('install-git-hooks — the commit-guard arm persists (C8)', () => {
68
+ it('an armed guard SURVIVES a later flagless re-run (carried forward from the managed hook)', () => {
69
+ const { root } = mkProject();
70
+ assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
71
+ const armed = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
72
+ assert.match(armed, /guard\.mjs" --check/, 'the guard line is written armed');
73
+ assert.equal(runInstaller(root).status, 0, 'the flagless canonical re-run succeeds');
74
+ const after = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
75
+ assert.match(after, /guard\.mjs" --check/, 'the armed guard line survives a flagless re-run');
76
+ rmSync(root, { recursive: true, force: true });
77
+ });
78
+
79
+ it('--no-commit-guard is the ONLY consented disable; combining both flags is a usage error', () => {
80
+ const { root } = mkProject();
81
+ assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
82
+ assert.equal(runInstaller(root, ['--no-commit-guard']).status, 0);
83
+ const after = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
84
+ assert.doesNotMatch(after, /" --check$/m, 'the guard line is removed on the explicit disable');
85
+ const both = runInstaller(root, ['--commit-guard', 'guard.mjs', '--no-commit-guard']);
86
+ assert.equal(both.status, 2, 'both flags together are a usage error, never an order-dependent pick');
87
+ rmSync(root, { recursive: true, force: true });
88
+ });
89
+
90
+ it('a DUPLICATED/malformed guard line in the managed hook fails CLOSED on a flagless re-run', () => {
91
+ const { root } = mkProject();
92
+ assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
93
+ const hookPath = join(hooksPathOf(root), 'pre-commit');
94
+ const armed = readFileSync(hookPath, 'utf8');
95
+ const guardLine = armed.split('\n').find((l) => /" --check$/.test(l));
96
+ writeFileSync(hookPath, `${armed}${guardLine}\n`); // duplicate the guard line
97
+ const r = runInstaller(root);
98
+ assert.equal(r.status, 1, 'two guard lines are ambiguous — fail closed, never guess');
99
+ assert.match(r.stderr, /guard/i);
100
+ rmSync(root, { recursive: true, force: true });
101
+ });
102
+ });