@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 +41 -1
- package/README.md +1 -1
- package/SKILL.md +13 -18
- package/bin/install.mjs +1 -1
- package/capability.json +1 -1
- package/migrations/3.0.0-hardened-core-loop.md +29 -0
- package/migrations/README.md +1 -1
- package/migrations/legacy-stamp-takeover.md +3 -3
- package/package.json +2 -2
- package/references/scripts/archive-decisions.mjs +3 -3
- package/references/scripts/archive-decisions.test.mjs +5 -5
- package/references/scripts/check-docs-size-cli.test.mjs +41 -0
- package/references/scripts/check-docs-size.mjs +46 -29
- package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
- package/references/scripts/install-git-hooks.mjs +90 -18
- package/references/scripts/install-git-hooks.test.mjs +102 -0
- package/references/scripts/migrate-gates-branches.test.mjs +157 -0
- package/references/scripts/migrate-gates.mjs +395 -0
- package/references/scripts/migrate-gates.test.mjs +284 -0
- package/references/templates/agent_rules.md +2 -2
- package/scripts/stamp-takeover.mjs +3 -3
- package/references/templates/verification-profile.json +0 -10
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 `
|
|
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 `
|
|
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: '
|
|
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
|
|
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/
|
|
115
|
-
|
|
116
|
-
|
|
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 **`
|
|
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`, `
|
|
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
|
|
188
|
-
`docs/ai/gates.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
|
-
`
|
|
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 (`
|
|
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** (`
|
|
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 >=
|
|
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
|
@@ -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).
|
package/migrations/README.md
CHANGED
|
@@ -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 (`
|
|
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 **`
|
|
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` = `
|
|
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 `
|
|
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": "
|
|
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": ">=
|
|
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 >=
|
|
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
|
|
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
|
|
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 —
|
|
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 —
|
|
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 —
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
239
|
-
for (const warn of row.warnings)
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
390
|
+
log(`Wrote ${relative(root, indexPath)}`);
|
|
377
391
|
const after = await stat(indexPath);
|
|
378
392
|
if (after.size === 0) {
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
405
|
+
return result(1);
|
|
392
406
|
}
|
|
393
|
-
|
|
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
|
-
|
|
416
|
+
log(
|
|
403
417
|
`\n${rows.length} files inspected — ${errorCount} error(s), ${warnCount} warning(s)`,
|
|
404
418
|
);
|
|
405
419
|
|
|
406
|
-
|
|
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
|
|
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
|
+
});
|