@sabaiway/agent-workflow-memory 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ All notable changes to the memory substrate. Versions are this **package's** npm
4
4
  they are distinct from the **deployment-lineage** stamp written into a project's
5
5
  `docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `1.3.0`).
6
6
 
7
+ ## 1.12.0 — Verification-profile template + the docs-index-on-rotation regen (BUGFREE-3, AD-049)
8
+
9
+ A **minor** release (deployment-lineage head stays `1.3.0` — no migration; co-released with the
10
+ workflow kit 1.40.0). The memory substrate gains the optional verification-profile config and closes
11
+ the docs-index-goes-stale-on-rotation cost (economics item (h)).
12
+
13
+ - **Verification-profile template** — a new seeded `references/templates/verification-profile.json`
14
+ (`schema:1`; the kit reads it, kit 1.40.0) is created on bootstrap and ensured-if-missing on
15
+ upgrade (the `gates.json` / `orchestration.json` twin); `SKILL.md`'s bootstrap/ensure prose now
16
+ names it. An **absent profile reproduces today's V8 + node:test behaviour exactly** — it only ADDS
17
+ an opt-in default a consumer edits for its own language/runner.
18
+ - **(h) a rotation regenerates `docs/ai/index.md`** — `references/scripts/archive-decisions.mjs`
19
+ regenerates the docs index after a successful rotation write (moves OR a normalize-only rewrite) by
20
+ reusing the now **root-parameterized** `check-docs-size.mjs --write-index --report` (the `--report`
21
+ isolates the index-write outcome from the docs-cap-check, so a benign over-cap sibling never reads
22
+ as a regeneration failure), with a loud instruct on absence/failure. An ADR rotation no longer
23
+ leaves the index stale to trip the `--check-index` gate mid-release-matrix.
24
+ - **§2.2 minimal-comments** — the `agent_rules` template states comments as minimal / only-vital,
25
+ a BASELINE a consumer project may tighten (e.g. comments forbidden entirely).
26
+
27
+ ## 1.11.1 — One batched setup prompt (the F11 ask reword; AD-042)
28
+
29
+ A **patch** release (prose reword only; deployment-lineage head stays `1.3.0` — no migration).
30
+ The three bootstrap setup questions (visibility / conversational language / attribution) are now
31
+ asked as **ONE structured multi-question prompt where supported** (`AskUserQuestion`, up to 4
32
+ questions per call), each answer recorded individually, nothing written until ALL are answered —
33
+ first contact interrupts once, not three times (`SKILL.md` bootstrap preamble + steps 2–4). The
34
+ upgrade path batches its two migration asks the same way ONLY when both `AGENTS.md` blocks are
35
+ missing (a pre-1.1.0 deployment), collecting them in step 4 BEFORE the migrations apply and never
36
+ re-asking a collected answer (a migration's own "Ask the user" step stays the standalone
37
+ fallback). The `references/contracts.md` ask paragraph is reworded byte-identical with the kit's
38
+ copy — pinned cross-package by the kit's new `ask-contract` test.
39
+
7
40
  ## 1.11.0 — The template lens block becomes a render of the engine canon (AD-041)
8
41
 
9
42
  A **feature** release (template text only; deployment-lineage head stays `1.3.0` — no migration).
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: '1.11.0'
6
+ version: '1.12.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-memory
@@ -49,6 +49,7 @@ What this substrate owns vs what it only points at. The methodology + orchestrat
49
49
  | Orchestration recipes (Solo / Reviewed / Council / Delegated) | **methodology engine** (not this skill) | the empty `workflow:orchestration` slot — filled by the composition root |
50
50
  | 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) |
51
51
  | 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) |
52
+ | 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) |
52
53
 
53
54
  ---
54
55
 
@@ -68,24 +69,26 @@ bootstrapping over a live system, but the user makes the final call.
68
69
  > Use that as the copy/read source; the working directory is the **target project**.
69
70
 
70
71
  > The three setup questions (steps 2–4) are decisions only the user can make and are hard to
71
- > reverse after a commit. Ask each as a **structured multiple-choice prompt where supported**
72
- > (`AskUserQuestion` in Claude Code, recommended option first), otherwise in prose — and
73
- > **wait for the answer before writing anything**.
72
+ > reverse after a commit. Ask them as **ONE structured multi-question prompt where supported**
73
+ > (`AskUserQuestion` in Claude Code, up to 4 questions per call, recommended option first),
74
+ > otherwise in prose; **record each answer individually** and **write nothing until ALL are
75
+ > answered**.
74
76
 
75
77
  1. **Recon (read-only).** Before writing anything: `package.json` / `pyproject.toml` / `go.mod`
76
78
  / `Cargo.toml` → stack, package manager, scripts; `ls -la` root → README, existing
77
79
  `AGENTS.md`/`CLAUDE.md`, CI / linter configs; `git log --oneline -30` + `git status`; `src/`
78
80
  2–3 levels deep; tests + linter rules. Record stack, package manager, daily commands, layers.
79
- 2. **Choose visibility — ASK explicitly and wait.** `visible` (committed canonical,
81
+ 2. **Choose visibility — ask the batched prompt NOW (all three questions, per the preamble
82
+ above) and wait until every answer is in.** `visible` (committed — canonical,
80
83
  recommended) or `hidden` (in-tree, git-ignored via the **project-local** `.git/info/exclude` —
81
84
  never the machine-global excludes). See
82
85
  [Visibility contract](references/contracts.md#visibility-contract).
83
- 3. **Choose conversational language — ASK explicitly and wait.** Which language the agent
86
+ 3. **Choose conversational language — answered in the step-2 batch.** Which language the agent
84
87
  *talks to them* in. Offer the language they're already writing in as the default. Carry it
85
88
  into the `{{COMM_LANGUAGE}}` slot of the *Communication language* block (step 5). See
86
89
  [Communication contract](references/contracts.md#communication-contract). Dialogue only —
87
90
  never the files.
88
- 4. **Choose agent attribution — ASK explicitly and wait.** May the agent attribute work to
91
+ 4. **Choose agent attribution — answered in the step-2 batch.** May the agent attribute work to
89
92
  itself / AI — `Co-Authored-By` trailers, "Generated with …" footers, AI/agent/model mentions?
90
93
  **Default `off`.** Carry it into `{{AGENT_ATTRIBUTION}}` (step 5). **If `off` and the project
91
94
  uses Claude Code**, also set `"includeCoAuthoredBy": false` in the project's
@@ -97,11 +100,14 @@ bootstrapping over a live system, but the user makes the final call.
97
100
  (`ln -s AGENTS.md CLAUDE.md`). **Leave BOTH pointer slots (`workflow:methodology` +
98
101
  `workflow:orchestration`) exactly as shipped — empty.** Filling them is the composition root's job.
99
102
  6. **Deploy `docs/ai/`.** Create the files + `pages/` from
100
- `${CLAUDE_SKILL_DIR}/references/templates/` (every non-`AGENTS.md` template, including the two
103
+ `${CLAUDE_SKILL_DIR}/references/templates/` (every non-`AGENTS.md` template, including the three
101
104
  seeded, **user-editable** strict-JSON configs: `docs/ai/orchestration.json` — the per-project
102
- recipe defaults the composition root's `procedures` advisor reads — and `docs/ai/gates.json` —
105
+ recipe defaults the composition root's `procedures` advisor reads — `docs/ai/gates.json` —
103
106
  the project's gate declaration, an empty list to fill with its own verification commands,
104
- consumed by the composition root's gate runner). Keep each `.md` file's frontmatter.
107
+ consumed by the composition root's gate runner and `docs/ai/verification-profile.json` the
108
+ OPTIONAL fold-completeness verification profile (the language-independence contract; the seeded
109
+ default reproduces the composition root's default V8 + node:test behaviour, delete it for exactly
110
+ that)). Keep each `.md` file's frontmatter.
105
111
  7. **Fill templates** per the table below.
106
112
  8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs`
107
113
  (+ `*.test.mjs`) into the project's `scripts/`. **No Node runtime** → skip this + the hook;
@@ -169,13 +175,16 @@ Fill strategy:
169
175
  **untracked AND not ignored** → **AMBIGUOUS** → **ASK** the user before writing. This visibility check
170
176
  runs on **every** in-range upgrade, even at head — it is not gated by the stamp delta, but it is gated
171
177
  **behind** the never-downgrade STOP above. **Also stamp-independent (same gate, before the equal-head
172
- short-circuit): ensure BOTH seeded `.json` configs** — for `docs/ai/orchestration.json` AND
173
- `docs/ai/gates.json`, **create the file from its
178
+ short-circuit): ensure the THREE seeded `.json` configs** — for `docs/ai/orchestration.json`,
179
+ `docs/ai/gates.json`, AND `docs/ai/verification-profile.json`, **create the file from its
174
180
  `${CLAUDE_SKILL_DIR}/references/templates/` template if missing**, **preserve it byte-for-byte
175
181
  if it already exists** (a user may have edited it; never clobber it). The shipped
176
182
  `orchestration.json` template's `_README` already frames that config as agent-writable (set it
177
183
  with the `set-recipe` writer) and still hand-editable; `gates.json` is the project's own gate
178
- declaration (what to verify — consumed by the composition root's gate runner). (Refreshing the
184
+ declaration (what to verify — consumed by the composition root's gate runner);
185
+ `verification-profile.json` is the OPTIONAL fold-completeness verification profile — the seeded
186
+ default reproduces the composition root's default V8 + node:test behaviour, so a project deletes
187
+ it to fall back to exactly that, or edits it for another language/runner. (Refreshing the
179
188
  orchestration `_README` note in place on an existing file is the **composition root's** job on
180
189
  its own reconcile — this substrate is standalone and only seeds-or-preserves; it owns no
181
190
  cross-package refresh helper. The gates declaration gets no note-refresh at all — authored
@@ -196,13 +205,20 @@ Fill strategy:
196
205
  structure number, stamp filename, or internal versioning vocabulary on this happy-path exit — the
197
206
  number is inert here and belongs to *Version disclosure* (below).
198
207
  3. Show the relevant `${CLAUDE_SKILL_DIR}/CHANGELOG.md` context (entries newer than the stamp).
199
- 4. Apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**, only those
200
- newer than the stamp. Migrations are **idempotent**.
208
+ 4. **Collect the migration answers FIRST, then apply.** If `AGENTS.md` is missing BOTH the
209
+ *Communication language* and *Attribution* blocks — i.e. both blocks are missing (a pre-1.1.0
210
+ deployment) — ask the two questions as ONE structured multi-question prompt; record each
211
+ answer individually, write nothing until ALL are answered, and carry the answers into the
212
+ migrations below: a migration whose answer was already collected never re-asks (its own
213
+ "Ask the user" step is the standalone fallback); a single missing block keeps its single ask
214
+ (step 5). Then apply `${CLAUDE_SKILL_DIR}/migrations/<version>-<slug>.md` in **semver order**,
215
+ only those newer than the stamp. Migrations are **idempotent**.
201
216
  5. Reconcile drift: add any substrate files/scripts the project is missing; **never clobber
202
217
  project-authored content** (their `decisions.md`, `known_issues.md`, page specs stay). For a
203
218
  pre-1.1.0 deployment with no *Communication language* block, ask + insert
204
219
  (`migrations/1.1.0-communication-language.md`); pre-1.2.0 with no *Attribution* block, ask +
205
- insert defaulting to `off` (`migrations/1.2.0-agent-attribution.md`).
220
+ insert defaulting to `off` (`migrations/1.2.0-agent-attribution.md`). (An answer already
221
+ collected by the step-4 batched prompt is carried in — never re-asked here.)
206
222
  6. **Preserve BOTH pointer slots.** If `AGENTS.md` has the `workflow:methodology` and/or
207
223
  `workflow:orchestration` markers, **never regenerate the file wholesale** — extract any bytes
208
224
  between each pair and reinsert them unchanged. If a pair is absent (a legacy `AGENTS.md`),
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": "1.11.0",
6
+ "version": "1.12.0",
7
7
  "provides": ["context"],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-memory",
3
- "version": "1.11.0",
3
+ "version": "1.12.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",
@@ -5,8 +5,9 @@ language**, and **agent attribution** — each have a contract below. `SKILL.md`
5
5
  main procedure stays lean; load this file when you need the full rule for a contract (e.g. while
6
6
  filling the matching `AGENTS.md` block, or when an `upgrade` migration touches it).
7
7
 
8
- Ask each as a **structured multiple-choice prompt where your agent supports it** (`AskUserQuestion`
9
- in Claude Code), otherwise in prose and always wait for the answer before writing.
8
+ Ask the three as **ONE structured multi-question prompt where your agent supports it**
9
+ (`AskUserQuestion` in Claude Code, up to 4 questions per call), otherwise in prose; record each
10
+ answer individually — and write nothing until ALL are answered.
10
11
 
11
12
  ---
12
13
 
@@ -10,7 +10,8 @@
10
10
  // oldest (lowest AD id, top of file) first; an entry's lines move verbatim.
11
11
  //
12
12
  // Modes:
13
- // (default) rotate, mutate files in place (only when something is over cap)
13
+ // (default) rotate, mutate files in place (only when something is over cap), then regenerate
14
+ // docs/ai/index.md so the rotation never leaves the index stale (item (h))
14
15
  // --dry-run print the planned move-set, change nothing
15
16
  // --check report per-tier lines/cap; exit 1 if any tier is over its cap
16
17
  // --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs)
@@ -41,10 +42,40 @@
41
42
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
42
43
  import { dirname, resolve } from 'node:path';
43
44
  import { fileURLToPath, pathToFileURL } from 'node:url';
45
+ import { spawnSync } from 'node:child_process';
44
46
 
45
47
  const __dirname = dirname(fileURLToPath(import.meta.url));
46
48
  const DEFAULT_ROOT = resolve(__dirname, '..');
47
49
 
50
+ // (h) — after a rotation write, docs/ai/index.md would silently go stale (a moved ADR, or just the
51
+ // bumped lastUpdated), tripping the SEPARATE `--check-index` gate mid-release-matrix. So the
52
+ // rotation regenerates the index by REUSING the ONE generator in the sibling check-docs-size.mjs
53
+ // (spawned with --write-index --root=<root>). The subprocess bridges that script's ASYNC generator,
54
+ // so this runCli stays SYNCHRONOUS (spawnSync) — the existing sync callers/tests never ripple. It
55
+ // DEGRADES LOUDLY to an instruct (never a silent failure) when the sibling is absent or the
56
+ // regeneration fails; the --check-index gate still catches a stale index. Injectable for tests.
57
+ const CHECK_DOCS_SIBLING = resolve(__dirname, 'check-docs-size.mjs');
58
+ const INDEX_INSTRUCT = 'run `node scripts/check-docs-size.mjs --write-index` to refresh docs/ai/index.md';
59
+ // Exported + its filesystem edges injectable (deps) so BOTH degrade branches are unit-testable.
60
+ export const defaultRegenerateIndex = (root, today, deps = {}) => {
61
+ const exists = deps.existsSync ?? existsSync;
62
+ const spawn = deps.spawnSync ?? spawnSync;
63
+ const sibling = deps.sibling ?? CHECK_DOCS_SIBLING;
64
+ if (!exists(sibling)) {
65
+ return { ok: false, detail: `the index generator is not beside this script — ${INDEX_INSTRUCT}` };
66
+ }
67
+ // `--report` ISOLATES the index-WRITE outcome from the docs-cap-CHECK outcome: check-docs-size
68
+ // --write-index still WRITES the index (and still exits 2 on an empty write / rejects a genuine
69
+ // throw), but --report suppresses its exit-1 on an unrelated over-cap co-located doc — otherwise a
70
+ // benign over-cap sibling would read as an index-regeneration FAILURE (a cry-wolf on this very
71
+ // loud-degrade channel).
72
+ const r = spawn(process.execPath, [sibling, '--write-index', '--report', `--root=${root}`, `--today=${today}`], { encoding: 'utf8' });
73
+ if (r.error || r.status !== 0) {
74
+ return { ok: false, detail: `index regeneration failed (${(r.error && r.error.message) || `exit ${r.status}`}) — ${INDEX_INSTRUCT}` };
75
+ }
76
+ return { ok: true, detail: (r.stdout || '').trim() };
77
+ };
78
+
48
79
  export const HOT_REL = 'docs/ai/decisions.md';
49
80
  export const WARM_REL = 'docs/ai/history/decisions-archive.md';
50
81
  export const COLD_REL = 'docs/ai/history/decisions-archive-early.md';
@@ -295,7 +326,7 @@ const parseArgs = (argv) => {
295
326
  };
296
327
 
297
328
  export const runCli = (argv, deps = {}) => {
298
- const { root = DEFAULT_ROOT, log = console.log, logError = console.error } = deps;
329
+ const { root = DEFAULT_ROOT, log = console.log, logError = console.error, regenerateIndex = defaultRegenerateIndex } = deps;
299
330
  try {
300
331
  const { flags, today: todayOpt } = parseArgs(argv);
301
332
  if (flags.help) {
@@ -406,6 +437,10 @@ export const runCli = (argv, deps = {}) => {
406
437
  mkdirSync(dirname(tier.path), { recursive: true });
407
438
  writeFileSync(tier.path, renderTier(updated, entries), 'utf8');
408
439
  }
440
+ // (h) — the write loop completed (a full successful rotation OR a normalize-only rewrite): the
441
+ // docs index is now stale, so regenerate it here (never on --check / --dry-run / the
442
+ // nothing-to-rotate no-op / a pre-write refusal — those return before this point).
443
+ const regen = regenerateIndex(root, today);
409
444
  log('[archive-decisions] rotated:');
410
445
  log(` HOT→WARM: ${summary.hotToWarm.join(', ') || '(none)'}`);
411
446
  log(` WARM→COLD: ${summary.warmToCold.join(', ') || '(none)'}`);
@@ -413,6 +448,8 @@ export const runCli = (argv, deps = {}) => {
413
448
  log(` normalize-only rewrite (over cap on raw lines, no entry moves): ${summary.normalizeOnly.join(', ')}`);
414
449
  }
415
450
  log(` now: HOT ${summary.after.hot} · WARM ${summary.after.warm} · COLD ${summary.after.cold}`);
451
+ if (regen.ok) log(' regenerated docs/ai/index.md (the rotation kept the index fresh)');
452
+ else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
416
453
  return 0;
417
454
  } catch (err) {
418
455
  logError(`[archive-decisions] ${err.message}`);
@@ -14,6 +14,7 @@ import {
14
14
  planRotation,
15
15
  updateRangeTokens,
16
16
  runCli,
17
+ defaultRegenerateIndex,
17
18
  } from './archive-decisions.mjs';
18
19
 
19
20
  // Hermetic by design: this test ships as deploy payload and runs inside CONSUMER repos via the
@@ -67,10 +68,14 @@ const seedProject = (root, { hotIds, warmIds, coldIds, hotCapDelta = 100, warmCa
67
68
  };
68
69
  };
69
70
 
71
+ // The existing rotation tests are about ROTATION, not the index-regen hook (item (h)) — inject a
72
+ // no-op regenerator so they stay hermetic + fast (no real check-docs-size subprocess). The hook's
73
+ // own firing/degrade behavior is pinned by the dedicated (h) describe below; the real end-to-end
74
+ // spawn is isolated to one integration test there.
70
75
  const run = (argv, root) => {
71
76
  const out = [];
72
77
  const err = [];
73
- const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l) });
78
+ const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l), regenerateIndex: () => ({ ok: true, detail: '' }) });
74
79
  return { code, out, err, text: out.join('\n'), errText: err.join('\n') };
75
80
  };
76
81
 
@@ -363,3 +368,115 @@ describe('created tiers (a consumer with no history files yet)', () => {
363
368
  assert.match(errText, /Unknown argument/);
364
369
  });
365
370
  });
371
+
372
+ // ── (h) — a rotation regenerates docs/ai/index.md (BUGFREE-3 / AD-049) ─────────────────
373
+ describe('(h) index regeneration after rotation', () => {
374
+ const spyRun = (argv, root, regen) => {
375
+ const out = [];
376
+ const err = [];
377
+ const code = runCli(argv, { root, log: (l) => out.push(l), logError: (l) => err.push(l), regenerateIndex: regen });
378
+ return { code, out, err, text: out.join('\n'), errText: err.join('\n') };
379
+ };
380
+
381
+ it('calls the regenerator once with (root, today) AFTER a successful rotation write', () => {
382
+ const root = makeRoot();
383
+ seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
384
+ const calls = [];
385
+ const { code } = spyRun(['--today=2026-01-02'], root, (r, t) => { calls.push([r, t]); return { ok: true, detail: '' }; });
386
+ assert.equal(code, 0);
387
+ assert.deepEqual(calls, [[root, '2026-01-02']], 'exactly one regen, with the rotation root + today');
388
+ });
389
+
390
+ it('does NOT regenerate on a no-op, --check, --dry-run, or a pre-write refusal', () => {
391
+ let called = 0;
392
+ const spy = () => { called += 1; return { ok: true, detail: '' }; };
393
+ // no-op (under cap)
394
+ const r1 = makeRoot(); seedProject(r1, { hotIds: ['005'], warmIds: ['003'], coldIds: ['001'] });
395
+ spyRun([], r1, spy);
396
+ // --check
397
+ spyRun(['--check'], r1, spy);
398
+ // --dry-run over a real rotation plan
399
+ const r2 = makeRoot(); seedProject(r2, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
400
+ spyRun(['--dry-run'], r2, spy);
401
+ // a pre-write refusal (COLD exhausted)
402
+ const r3 = makeRoot(); seedProject(r3, { hotIds: ['005', '006', '007'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1, warmCapDelta: 3, coldCapDelta: 3 });
403
+ spyRun(['--today=2026-01-02'], r3, spy);
404
+ assert.equal(called, 0, 'the hook fires only AFTER a real write — never on no-op/check/dry-run/refusal');
405
+ });
406
+
407
+ it('a NORMALIZE-ONLY rewrite (stampLastUpdated bumps lastUpdated, zero moves) still triggers regeneration', () => {
408
+ const root = makeRoot();
409
+ mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
410
+ const blocks = [entryBlock('001'), entryBlock('002'), entryBlock('003')];
411
+ const body = blocks.join('\n\n---\n\n'); // the template separator shape: raw > cap, normalized fits
412
+ const probe = `${fm(999)}\n# ADRs\n\n${body}\n`;
413
+ writeFileSync(join(root, HOT_REL), `${fm(lineCountOf(probe) - 2)}\n# ADRs\n\n${body}\n`);
414
+ let called = 0;
415
+ const { code, text } = spyRun(['--today=2026-01-02'], root, () => { called += 1; return { ok: true, detail: '' }; });
416
+ assert.equal(code, 0, text);
417
+ assert.match(text, /normalize-only rewrite/);
418
+ assert.equal(called, 1, 'a normalize-only rewrite still leaves the index stale → regenerate');
419
+ });
420
+
421
+ it('degrades LOUDLY to an instruct when regeneration fails — the rotation still succeeds (exit 0)', () => {
422
+ const root = makeRoot();
423
+ seedProject(root, { hotIds: ['005', '006'], warmIds: [], coldIds: [], hotCapDelta: -1 });
424
+ const { code, errText } = spyRun(['--today=2026-01-02'], root, () => ({ ok: false, detail: 'the index generator is not beside this script — run `node scripts/check-docs-size.mjs --write-index` to refresh docs/ai/index.md' }));
425
+ assert.equal(code, 0, 'the rotation itself succeeded — regen is best-effort');
426
+ assert.match(errText, /NOT regenerated/);
427
+ assert.match(errText, /check-docs-size\.mjs --write-index/, 'the loud instruct names the recovery command');
428
+ });
429
+
430
+ it('the REAL default regenerator writes docs/ai/index.md end-to-end (no injection)', () => {
431
+ const root = makeRoot();
432
+ seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
433
+ const out = [];
434
+ const code = runCli(['--today=2026-01-02'], { root, log: (l) => out.push(l), logError: (l) => out.push(l) });
435
+ assert.equal(code, 0, out.join('\n'));
436
+ assert.ok(existsSync(join(root, 'docs', 'ai', 'index.md')), 'the index was regenerated by the sibling check-docs-size.mjs');
437
+ const idx = readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
438
+ assert.match(idx, /decisions\.md/, 'the regenerated index lists the rotated ADR file');
439
+ assert.match(out.join('\n'), /regenerated docs\/ai\/index\.md/, 'the success log line fires');
440
+ });
441
+
442
+ // The index-write outcome must be ISOLATED from the docs-cap-CHECK outcome: check-docs-size
443
+ // --write-index exits 1 on ANY over-cap co-located doc even after writing the index correctly, so
444
+ // a benign over-cap sibling must NEVER read as an index-regeneration failure (cry-wolf on the very
445
+ // loud-degrade channel item (h) is built around). The regen spawns --write-index --report.
446
+ it('the REAL regenerator succeeds even when an UNRELATED co-located doc is over its cap (no cry-wolf)', () => {
447
+ const root = makeRoot();
448
+ seedProject(root, { hotIds: ['005', '006', '007', '008'], warmIds: ['003', '004'], coldIds: ['001', '002'], hotCapDelta: -1 });
449
+ // An unrelated doc over its OWN maxLines cap: --write-index writes the index fine, but without
450
+ // --report it would exit 1 on this file → a false "NOT regenerated" instruct.
451
+ writeFileSync(join(root, 'docs', 'ai', 'handover.md'), `---\ntype: state\nlastUpdated: 2026-01-02\nscope: session\nstaleAfter: 7d\nowner: none\nmaxLines: 3\n---\n\n# H\nl1\nl2\nl3\nl4\nl5\n`);
452
+ const out = [];
453
+ const err = [];
454
+ const code = runCli(['--today=2026-01-02'], { root, log: (l) => out.push(l), logError: (l) => err.push(l) });
455
+ assert.equal(code, 0);
456
+ assert.match(out.join('\n'), /regenerated docs\/ai\/index\.md/, 'a benign over-cap sibling must NOT read as a regen failure');
457
+ assert.ok(!err.join('\n').includes('NOT regenerated'), 'no false cry-wolf on the loud-degrade channel');
458
+ assert.ok(existsSync(join(root, 'docs', 'ai', 'index.md')), 'the index was actually written');
459
+ });
460
+
461
+ describe('defaultRegenerateIndex — the loud-degrade branches', () => {
462
+ it('an absent index generator sibling → a loud instruct (ok:false)', () => {
463
+ const r = defaultRegenerateIndex('/tmp/anyroot', '2026-01-02', { sibling: '/nonexistent/check-docs-size.mjs' });
464
+ assert.equal(r.ok, false);
465
+ assert.match(r.detail, /not beside this script/);
466
+ assert.match(r.detail, /check-docs-size\.mjs --write-index/);
467
+ });
468
+
469
+ it('a regeneration subprocess that exits nonzero → a loud instruct (ok:false)', () => {
470
+ const r = defaultRegenerateIndex('/tmp/anyroot', '2026-01-02', { existsSync: () => true, spawnSync: () => ({ status: 1 }) });
471
+ assert.equal(r.ok, false);
472
+ assert.match(r.detail, /index regeneration failed/);
473
+ assert.match(r.detail, /check-docs-size\.mjs --write-index/);
474
+ });
475
+
476
+ it('a successful subprocess (status 0) → ok:true with the trimmed stdout', () => {
477
+ const r = defaultRegenerateIndex('/tmp/anyroot', '2026-01-02', { existsSync: () => true, spawnSync: () => ({ status: 0, stdout: 'Wrote docs/ai/index.md\n' }) });
478
+ assert.equal(r.ok, true);
479
+ assert.match(r.detail, /Wrote docs\/ai\/index\.md/);
480
+ });
481
+ });
482
+ });
@@ -15,6 +15,8 @@
15
15
  //
16
16
  // CLI overrides:
17
17
  // --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
18
+ // --root=<dir> run against another project root (default this deployment) — the ADR-rotation
19
+ // hook passes it so a rotation regenerates the right project's index
18
20
  // --quiet print only failures (and final summary)
19
21
 
20
22
  import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
@@ -28,6 +30,12 @@ const ROOT = resolve(__dirname, '..');
28
30
  const DOCS_DIR = resolve(ROOT, 'docs/ai');
29
31
  const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
30
32
 
33
+ // Root-parameterized (BUGFREE-3 / AD-049, item (h)): the module ROOT constants are the CLI DEFAULT
34
+ // (this deployment's own root); `--root=<dir>` and the exported `regenerateIndex(root, today)`
35
+ // override them so the ADR-rotation hook (archive-decisions.mjs) and hermetic tests can regenerate
36
+ // an arbitrary root's index without ever touching the real repo tree.
37
+ const pathsFor = (root) => ({ root, docsDir: resolve(root, 'docs/ai'), indexPath: resolve(root, 'docs/ai/index.md') });
38
+
31
39
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
32
40
 
33
41
  // Project-name + footer links for the index are auto-discovered (no hardcoding):
@@ -56,18 +64,18 @@ const walkForName = async (dir, name, acc = [], depth = 0) => {
56
64
  return acc;
57
65
  };
58
66
 
59
- export const discoverMeta = async () => {
60
- let projectName = basename(ROOT);
67
+ export const discoverMeta = async (root = ROOT) => {
68
+ let projectName = basename(root);
61
69
  try {
62
- const pkg = JSON.parse(await readFile(resolve(ROOT, 'package.json'), 'utf8'));
70
+ const pkg = JSON.parse(await readFile(resolve(root, 'package.json'), 'utf8'));
63
71
  if (pkg.name) projectName = pkg.name;
64
72
  } catch {
65
73
  /* no package.json — keep dir basename */
66
74
  }
67
- const agentsFiles = await walkForName(ROOT, 'AGENTS.md');
68
- const claudeFiles = await walkForName(ROOT, 'CLAUDE.md');
69
- const rootAgents = resolve(ROOT, 'AGENTS.md');
70
- const rootClaude = resolve(ROOT, 'CLAUDE.md');
75
+ const agentsFiles = await walkForName(root, 'AGENTS.md');
76
+ const claudeFiles = await walkForName(root, 'CLAUDE.md');
77
+ const rootAgents = resolve(root, 'AGENTS.md');
78
+ const rootClaude = resolve(root, 'CLAUDE.md');
71
79
  // A subdir typically holds AGENTS.md plus a CLAUDE.md symlink to it — list each
72
80
  // dir once (prefer AGENTS.md, drop its sibling CLAUDE.md alias).
73
81
  const agentsDirs = new Set(agentsFiles.map((file) => dirname(resolve(file))));
@@ -78,12 +86,12 @@ export const discoverMeta = async () => {
78
86
  ),
79
87
  ];
80
88
  const hierarchicalLinks = nestedFiles
81
- .map((file) => relative(ROOT, file))
89
+ .map((file) => relative(root, file))
82
90
  .sort()
83
91
  .map((rel) => `[\`${rel}\`](../../${rel})`);
84
92
  let onDemandLinks = [];
85
93
  try {
86
- const skillDirs = await readdir(resolve(ROOT, '.agents/skills'), { withFileTypes: true });
94
+ const skillDirs = await readdir(resolve(root, '.agents/skills'), { withFileTypes: true });
87
95
  onDemandLinks = skillDirs
88
96
  .filter((dirent) => dirent.isDirectory() && /-(patterns|commands)$/.test(dirent.name))
89
97
  .map((dirent) => dirent.name)
@@ -97,16 +105,17 @@ export const discoverMeta = async () => {
97
105
 
98
106
  const parseArgs = (argv) => {
99
107
  const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
100
- const opts = { today: null };
108
+ const opts = { today: null, root: null };
101
109
  for (const arg of argv.slice(2)) {
102
110
  if (arg === '--report') flags.report = true;
103
111
  else if (arg === '--write-index') flags.writeIndex = true;
104
112
  else if (arg === '--check-index') flags.checkIndex = true;
105
113
  else if (arg === '--quiet') flags.quiet = true;
106
114
  else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
115
+ else if (arg.startsWith('--root=')) opts.root = arg.slice('--root='.length);
107
116
  else if (arg === '--help' || arg === '-h') {
108
117
  console.log(
109
- 'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--quiet]',
118
+ 'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--root=<dir>] [--quiet]',
110
119
  );
111
120
  process.exit(0);
112
121
  } else {
@@ -161,11 +170,11 @@ export const computeToday = (todayStr) =>
161
170
  ? new Date(`${todayStr}T00:00:00Z`)
162
171
  : new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
163
172
 
164
- export const inspectFile = async (filePath, today) => {
173
+ export const inspectFile = async (filePath, today, root = ROOT) => {
165
174
  const text = await readFile(filePath, 'utf8');
166
175
  const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
167
176
  const fm = parseFrontmatter(text);
168
- const rel = relative(ROOT, filePath);
177
+ const rel = relative(root, filePath);
169
178
 
170
179
  if (!fm) {
171
180
  return {
@@ -301,24 +310,41 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
301
310
  return { fresh: expected === onDiskText, expected };
302
311
  };
303
312
 
304
- const writeIndex = async (rows, today, meta) => {
313
+ const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
305
314
  const body = buildIndex(rows, today.toISOString().slice(0, 10), meta);
306
- await writeFile(INDEX_PATH, body, 'utf8');
315
+ await writeFile(indexPath, body, 'utf8');
316
+ };
317
+
318
+ // regenerateIndex(root, todayStr) — the ONE reused generator, root-parameterized (item (h)). It runs
319
+ // the SAME walk → inspect → discoverMeta → writeIndex pipeline as `--write-index`, against `root`
320
+ // (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
321
+ // hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
322
+ // index path + row count. No second index implementation exists.
323
+ export const regenerateIndex = async (root, todayStr = null) => {
324
+ const { docsDir, indexPath } = pathsFor(root);
325
+ const today = computeToday(todayStr);
326
+ const files = (await walkMarkdownFiles(docsDir)).sort();
327
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
328
+ const rows = inspected.map(formatRow);
329
+ const meta = await discoverMeta(root);
330
+ await writeIndex(rows, today, meta, indexPath);
331
+ return { indexPath, files: rows.length };
307
332
  };
308
333
 
309
334
  const main = async () => {
310
335
  const { flags, opts } = parseArgs(process.argv);
336
+ const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : ROOT);
311
337
  const today = computeToday(opts.today);
312
- const files = (await walkMarkdownFiles(DOCS_DIR)).sort();
313
- const inspected = await Promise.all(files.map((f) => inspectFile(f, today)));
338
+ const files = (await walkMarkdownFiles(docsDir)).sort();
339
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
314
340
  const rows = inspected.map(formatRow);
315
341
 
316
- const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta() : null;
342
+ const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta(root) : null;
317
343
 
318
344
  if (flags.writeIndex) {
319
- await writeIndex(rows, today, meta);
320
- console.log(`Wrote ${relative(ROOT, INDEX_PATH)}`);
321
- const after = await stat(INDEX_PATH);
345
+ await writeIndex(rows, today, meta, indexPath);
346
+ console.log(`Wrote ${relative(root, indexPath)}`);
347
+ const after = await stat(indexPath);
322
348
  if (after.size === 0) {
323
349
  console.error('index.md was written empty');
324
350
  process.exit(2);
@@ -326,16 +352,16 @@ const main = async () => {
326
352
  }
327
353
 
328
354
  if (flags.checkIndex) {
329
- const onDisk = existsSync(INDEX_PATH) ? await readFile(INDEX_PATH, 'utf8') : null;
355
+ const onDisk = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : null;
330
356
  const { fresh } = checkIndexFreshness(rows, onDisk, meta);
331
357
  if (!fresh) {
332
358
  console.error(
333
- `[check-docs-size] FAIL: ${relative(ROOT, INDEX_PATH)} is stale (out of sync with source frontmatter). Regenerate the index (--write-index) and commit the regenerated file.`,
359
+ `[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.`,
334
360
  );
335
361
  process.exit(1);
336
362
  }
337
363
  console.log(
338
- `[check-docs-size] OK — ${relative(ROOT, INDEX_PATH)} is in sync with source frontmatter.`,
364
+ `[check-docs-size] OK — ${relative(root, indexPath)} is in sync with source frontmatter.`,
339
365
  );
340
366
  return;
341
367
  }
@@ -1,8 +1,11 @@
1
1
  import { describe, it, beforeEach, afterEach } from 'node:test';
2
2
  import { expect } from './_expect-shim.mjs';
3
- import { mkdtemp, writeFile, rm } from 'node:fs/promises';
3
+ import { mkdtemp, writeFile, mkdir, readFile, rm } from 'node:fs/promises';
4
+ import { existsSync } from 'node:fs';
5
+ import { spawnSync } from 'node:child_process';
4
6
  import { tmpdir } from 'node:os';
5
7
  import { join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
6
9
  import {
7
10
  parseFrontmatter,
8
11
  parseStaleAfter,
@@ -11,6 +14,7 @@ import {
11
14
  buildIndex,
12
15
  checkIndexFreshness,
13
16
  walkMarkdownFiles,
17
+ regenerateIndex,
14
18
  } from './check-docs-size.mjs';
15
19
 
16
20
  describe('parseFrontmatter', () => {
@@ -202,3 +206,71 @@ describe('checkIndexFreshness', () => {
202
206
  expect(checkIndexFreshness(rows, onDisk).fresh).toBe(true);
203
207
  });
204
208
  });
209
+
210
+ // ── (h) — root-parameterization: the ADR-rotation hook regenerates ANOTHER root's index ────
211
+ // The generator's module ROOT is the CLI default only; --root / regenerateIndex(root) target an
212
+ // arbitrary tree so the rotation hook (and hermetic tests) never touch the real repo.
213
+ describe('root parameterization (item (h))', () => {
214
+ let root;
215
+ beforeEach(async () => {
216
+ root = await mkdtemp(join(tmpdir(), 'check-docs-root-'));
217
+ await mkdir(join(root, 'docs', 'ai'), { recursive: true });
218
+ });
219
+ afterEach(async () => {
220
+ await rm(root, { recursive: true, force: true });
221
+ });
222
+
223
+ const seedDoc = (name, extra = '') =>
224
+ writeFile(join(root, 'docs', 'ai', name), `---\ntype: reference\nlastUpdated: 2026-07-08\nscope: permanent\nstaleAfter: 30d\nowner: none\nmaxLines: 100\n---\n\n# ${name}\n${extra}`);
225
+
226
+ it('inspectFile computes the file path RELATIVE to the passed root (not the module ROOT)', async () => {
227
+ await seedDoc('handover.md');
228
+ const result = await inspectFile(join(root, 'docs', 'ai', 'handover.md'), computeToday('2026-07-08'), root);
229
+ expect(result.path).toBe('docs/ai/handover.md');
230
+ });
231
+
232
+ it('regenerateIndex(root, today) writes THAT root\'s docs/ai/index.md from its frontmatter', async () => {
233
+ await seedDoc('a.md');
234
+ await seedDoc('b.md');
235
+ const res = await regenerateIndex(root, '2026-07-08');
236
+ expect(res.indexPath).toBe(join(root, 'docs', 'ai', 'index.md'));
237
+ expect(existsSync(res.indexPath)).toBe(true);
238
+ const index = await readFile(res.indexPath, 'utf8');
239
+ expect(index).toMatch(/lastUpdated: 2026-07-08/); // header date is the argument
240
+ expect(index).toMatch(/a\.md/);
241
+ expect(index).toMatch(/b\.md/);
242
+ expect(index).not.toMatch(/\[`index\.md`\]/); // the index never lists itself
243
+ });
244
+
245
+ it('a re-run with unchanged sources is byte-identical (deterministic, --check-index safe)', async () => {
246
+ await seedDoc('a.md');
247
+ const first = await regenerateIndex(root, '2026-07-08');
248
+ const bytesA = await readFile(first.indexPath, 'utf8');
249
+ await regenerateIndex(root, '2026-07-08');
250
+ const bytesB = await readFile(first.indexPath, 'utf8');
251
+ expect(bytesB).toBe(bytesA);
252
+ });
253
+
254
+ // The CLI entry (main) over --root — a subprocess smoke so the --help usage + the --check-index
255
+ // fresh/stale branches (root-parameterized) are exercised end-to-end.
256
+ const SCRIPT = fileURLToPath(new URL('./check-docs-size.mjs', import.meta.url));
257
+ const runCli = (args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8' });
258
+
259
+ it('--help prints the usage (naming --root) and exits 0', () => {
260
+ const r = runCli(['--help']);
261
+ expect(r.status).toBe(0);
262
+ expect(r.stdout).toMatch(/--root=/);
263
+ });
264
+
265
+ it('--check-index --root: a fresh index is OK (exit 0); a drifted one is stale (exit 1)', async () => {
266
+ await seedDoc('a.md');
267
+ expect(runCli(['--write-index', `--root=${root}`]).status).toBe(0);
268
+ const fresh = runCli(['--check-index', `--root=${root}`]);
269
+ expect(fresh.status).toBe(0);
270
+ expect(fresh.stdout).toMatch(/in sync/);
271
+ await seedDoc('b.md'); // a new source row drifts the on-disk index
272
+ const stale = runCli(['--check-index', `--root=${root}`]);
273
+ expect(stale.status).toBe(1);
274
+ expect(stale.stderr).toMatch(/stale/);
275
+ });
276
+ });
@@ -55,6 +55,7 @@ Before proposing changes or committing, review against:
55
55
  ### 2.2. Clean Code
56
56
  - **No magic literals** — extract string/numeric constants to named consts at module level.
57
57
  - **DRY** — no duplicated logic.
58
+ - **Minimal comments (a BASELINE this project may tighten)** — if this project sets a stricter rule (e.g. comments forbidden entirely), that stricter rule ALWAYS wins; this is only a floor. Otherwise comment only where vitally necessary (a non-obvious invariant, a fail-closed rationale, a subtle edge). Make the code self-explaining first — clear variable/function names and compact-but-unambiguous test descriptions replace most comments; never restate what the code already says.
58
59
 
59
60
  ### 2.3. Strict Compliance
60
61
  - Only `const` (no `let`); no classes — pure functions, closures, modules.
@@ -0,0 +1,10 @@
1
+ {
2
+ "_README": "Optional per-project VERIFICATION PROFILE for the fold-completeness gate (the language-independence contract). DELETE this file to reproduce the exact default behaviour (V8 line coverage + node:test TAP on stdout) — an absent profile is fully supported. Present, it GENERALIZES three inputs so a consumer on another language/runner can drive the same gate: (1) coverage.kind is \"v8\" (default) or \"lcov\" — with lcov, set coverage.lcovPath to where your suite leaves an LCOV file; (2) singleTest.argv is the shell-free command template for probing ONE test (placeholders {file} and {pattern} are required; a file-based resultFormat also requires {resultPath}), and singleTest.resultFormat is \"tap-stdout\" (default), \"tap-file\", or \"junit-xml\"; (3) findings.sarifPath (optional) points at a SARIF file for advisory-only findings (never gate-blocking). The suite COMMAND is NOT declared here — it stays your docs/ai/gates.json unit-tests gate (so the fold run and the gate share command-identity). Every DECLARED path (coverage.lcovPath, findings.sarifPath) MUST be gitignored or outside the repo (a symlink is refused): an in-tree, non-ignored file the suite writes would move the review fingerprint. Env knobs still override (AW_FOLD_SUITE_CMD / AW_FOLD_BOUND_CMD / AW_FOLD_RESULTS). Strict JSON — no comments.",
3
+ "schema": 1,
4
+ "coverage": { "kind": "v8" },
5
+ "singleTest": {
6
+ "argv": ["node", "--test", "--test-reporter", "tap", "--test-name-pattern={pattern}", "{file}"],
7
+ "resultFormat": "tap-stdout"
8
+ },
9
+ "findings": {}
10
+ }