@sabaiway/agent-workflow-memory 1.11.1 → 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,26 @@ 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
+
7
27
  ## 1.11.1 — One batched setup prompt (the F11 ask reword; AD-042)
8
28
 
9
29
  A **patch** release (prose reword 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.1'
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
 
@@ -99,11 +100,14 @@ bootstrapping over a live system, but the user makes the final call.
99
100
  (`ln -s AGENTS.md CLAUDE.md`). **Leave BOTH pointer slots (`workflow:methodology` +
100
101
  `workflow:orchestration`) exactly as shipped — empty.** Filling them is the composition root's job.
101
102
  6. **Deploy `docs/ai/`.** Create the files + `pages/` from
102
- `${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
103
104
  seeded, **user-editable** strict-JSON configs: `docs/ai/orchestration.json` — the per-project
104
- 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` —
105
106
  the project's gate declaration, an empty list to fill with its own verification commands,
106
- 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.
107
111
  7. **Fill templates** per the table below.
108
112
  8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs`
109
113
  (+ `*.test.mjs`) into the project's `scripts/`. **No Node runtime** → skip this + the hook;
@@ -171,13 +175,16 @@ Fill strategy:
171
175
  **untracked AND not ignored** → **AMBIGUOUS** → **ASK** the user before writing. This visibility check
172
176
  runs on **every** in-range upgrade, even at head — it is not gated by the stamp delta, but it is gated
173
177
  **behind** the never-downgrade STOP above. **Also stamp-independent (same gate, before the equal-head
174
- short-circuit): ensure BOTH seeded `.json` configs** — for `docs/ai/orchestration.json` AND
175
- `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
176
180
  `${CLAUDE_SKILL_DIR}/references/templates/` template if missing**, **preserve it byte-for-byte
177
181
  if it already exists** (a user may have edited it; never clobber it). The shipped
178
182
  `orchestration.json` template's `_README` already frames that config as agent-writable (set it
179
183
  with the `set-recipe` writer) and still hand-editable; `gates.json` is the project's own gate
180
- 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
181
188
  orchestration `_README` note in place on an existing file is the **composition root's** job on
182
189
  its own reconcile — this substrate is standalone and only seeds-or-preserves; it owns no
183
190
  cross-package refresh helper. The gates declaration gets no note-refresh at all — authored
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.1",
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.1",
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",
@@ -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
+ }