@sabaiway/agent-workflow-memory 1.11.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 {
@@ -261,6 +270,31 @@ const formatIndexRow = (row) => {
261
270
  return `| ${link} | ${fm.type ?? '—'} | ${row.lineCount}/${fm.maxLines ?? '—'} | ${fm.lastUpdated ?? '—'} | ${fm.staleAfter ?? '—'} |`;
262
271
  };
263
272
 
273
+ // The one-file-per-ADR store (docs/ai/adr/) grows O(n) forever, so its rows would blow the index's
274
+ // own 80-line cap. It COLLAPSES to a single aggregate row (link → the navigator adr/log.md, record
275
+ // count + numeric id range) — while walkMarkdownFiles still finds + cap-checks every individual body
276
+ // (a body over its own cap still fails in the main flow; only the index RENDERING is collapsed).
277
+ const ADR_DIR_PREFIX = 'docs/ai/adr/';
278
+ const ADR_RECORD_RE = /\/AD-(\d{3,})-[^/]*\.md$/;
279
+ const ADR_NAV_PATH = 'docs/ai/adr/log.md';
280
+
281
+ // Only genuine records + the navigator collapse into the aggregate row; an UNEXPECTED file under
282
+ // adr/ (a stray README.md, AD-foo.md) renders as its OWN visible index row — never silently hidden
283
+ // by the collapse (it also fails archive-decisions' own store-integrity check).
284
+ const isCollapsibleAdr = (path) => path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(path) || path === ADR_NAV_PATH);
285
+
286
+ const formatAdrCollapseRow = (adrRows) => {
287
+ const recs = adrRows
288
+ .map((r) => {
289
+ const m = r.path.match(ADR_RECORD_RE);
290
+ return m ? { idStr: m[1], idNum: Number(m[1]) } : null;
291
+ })
292
+ .filter(Boolean)
293
+ .sort((a, b) => a.idNum - b.idNum); // NUMERIC id ordering (AD-200 before AD-1000), never lexical
294
+ const range = recs.length > 0 ? `AD-${recs[0].idStr} … AD-${recs[recs.length - 1].idStr}` : '—';
295
+ return `| [\`adr/\`](./adr/log.md) | adr | ${recs.length} records | ${range} | — |`;
296
+ };
297
+
264
298
  // Pure index renderer — given inspected rows + the date to stamp in the header,
265
299
  // returns the exact bytes `docs/ai/index.md` should contain. Shared by
266
300
  // `--write-index` (writes it) and `--check-index` (diffs against on-disk).
@@ -268,13 +302,18 @@ export const buildIndex = (rows, todayStr, meta = {}) => {
268
302
  const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
269
303
  const onDemandLinks = meta.onDemandLinks ?? [];
270
304
  const hierarchicalLinks = meta.hierarchicalLinks ?? [];
271
- const sorted = [...rows].sort((a, b) => a.path.localeCompare(b.path));
272
305
  const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
273
306
  const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
274
- const tableRows = sorted
275
- .filter((r) => r.path !== 'docs/ai/index.md')
276
- .map(formatIndexRow)
277
- .join('\n');
307
+ const nonAdr = [];
308
+ const adrRows = [];
309
+ for (const r of rows) {
310
+ if (r.path === 'docs/ai/index.md') continue;
311
+ (isCollapsibleAdr(r.path) ? adrRows : nonAdr).push(r);
312
+ }
313
+ const tableEntries = nonAdr.map((r) => ({ sortPath: r.path, md: formatIndexRow(r) }));
314
+ if (adrRows.length > 0) tableEntries.push({ sortPath: ADR_DIR_PREFIX, md: formatAdrCollapseRow(adrRows) });
315
+ tableEntries.sort((a, b) => a.sortPath.localeCompare(b.sortPath));
316
+ const tableRows = tableEntries.map((e) => e.md).join('\n');
278
317
  const onDemandSection =
279
318
  onDemandLinks.length > 0
280
319
  ? `\n\n## Skills (on-demand)\n\n${onDemandLinks.map((link) => `- ${link}`).join('\n')}`
@@ -301,24 +340,41 @@ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
301
340
  return { fresh: expected === onDiskText, expected };
302
341
  };
303
342
 
304
- const writeIndex = async (rows, today, meta) => {
343
+ const writeIndex = async (rows, today, meta, indexPath = INDEX_PATH) => {
305
344
  const body = buildIndex(rows, today.toISOString().slice(0, 10), meta);
306
- await writeFile(INDEX_PATH, body, 'utf8');
345
+ await writeFile(indexPath, body, 'utf8');
346
+ };
347
+
348
+ // regenerateIndex(root, todayStr) — the ONE reused generator, root-parameterized (item (h)). It runs
349
+ // the SAME walk → inspect → discoverMeta → writeIndex pipeline as `--write-index`, against `root`
350
+ // (default this deployment). The ADR-rotation hook reaches it via the CLI (`--write-index --root=…`);
351
+ // hermetic tests call it directly. `todayStr` is 'YYYY-MM-DD' (null → today). Returns the written
352
+ // index path + row count. No second index implementation exists.
353
+ export const regenerateIndex = async (root, todayStr = null) => {
354
+ const { docsDir, indexPath } = pathsFor(root);
355
+ const today = computeToday(todayStr);
356
+ const files = (await walkMarkdownFiles(docsDir)).sort();
357
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
358
+ const rows = inspected.map(formatRow);
359
+ const meta = await discoverMeta(root);
360
+ await writeIndex(rows, today, meta, indexPath);
361
+ return { indexPath, files: rows.length };
307
362
  };
308
363
 
309
364
  const main = async () => {
310
365
  const { flags, opts } = parseArgs(process.argv);
366
+ const { root, docsDir, indexPath } = pathsFor(opts.root ? resolve(opts.root) : ROOT);
311
367
  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)));
368
+ const files = (await walkMarkdownFiles(docsDir)).sort();
369
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
314
370
  const rows = inspected.map(formatRow);
315
371
 
316
- const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta() : null;
372
+ const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta(root) : null;
317
373
 
318
374
  if (flags.writeIndex) {
319
- await writeIndex(rows, today, meta);
320
- console.log(`Wrote ${relative(ROOT, INDEX_PATH)}`);
321
- const after = await stat(INDEX_PATH);
375
+ await writeIndex(rows, today, meta, indexPath);
376
+ console.log(`Wrote ${relative(root, indexPath)}`);
377
+ const after = await stat(indexPath);
322
378
  if (after.size === 0) {
323
379
  console.error('index.md was written empty');
324
380
  process.exit(2);
@@ -326,16 +382,16 @@ const main = async () => {
326
382
  }
327
383
 
328
384
  if (flags.checkIndex) {
329
- const onDisk = existsSync(INDEX_PATH) ? await readFile(INDEX_PATH, 'utf8') : null;
385
+ const onDisk = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : null;
330
386
  const { fresh } = checkIndexFreshness(rows, onDisk, meta);
331
387
  if (!fresh) {
332
388
  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.`,
389
+ `[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
390
  );
335
391
  process.exit(1);
336
392
  }
337
393
  console.log(
338
- `[check-docs-size] OK — ${relative(ROOT, INDEX_PATH)} is in sync with source frontmatter.`,
394
+ `[check-docs-size] OK — ${relative(root, indexPath)} is in sync with source frontmatter.`,
339
395
  );
340
396
  return;
341
397
  }
@@ -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', () => {
@@ -160,6 +164,61 @@ const makeRow = (path, overrides = {}) => ({
160
164
  ...overrides,
161
165
  });
162
166
 
167
+ // The one-file-per-ADR store collapses to a SINGLE aggregate index row so the index stays under its
168
+ // own 80-line cap no matter how many records accumulate — while every body is still cap-checked.
169
+ describe('buildIndex — docs/ai/adr/ directory collapse (Decision 11)', () => {
170
+ it('200 synthetic AD-*.md records collapse to ONE aggregate row; the index stays ≤ 80 lines', () => {
171
+ const adr = Array.from({ length: 200 }, (_, i) => makeRow(`docs/ai/adr/AD-${String(i + 1).padStart(3, '0')}-record-${i}.md`, { frontmatter: { type: 'adr', maxLines: '400', lastUpdated: '2026-07-09', staleAfter: 'never' } }));
172
+ const rows = [makeRow('docs/ai/handover.md'), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }), ...adr];
173
+ const out = buildIndex(rows, '2026-07-09');
174
+ const adrRowLines = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
175
+ expect(adrRowLines.length).toBe(1); // exactly one row references the whole adr/ tree
176
+ expect(out).not.toMatch(/AD-001-record-0\.md/); // individual records are NOT listed
177
+ expect(out.split('\n').length <= 80).toBe(true);
178
+ });
179
+
180
+ it('a stray adr/ markdown file renders as its OWN visible row (never collapsed/hidden)', () => {
181
+ const rows = [
182
+ makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
183
+ makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
184
+ makeRow('docs/ai/adr/notes.md', { frontmatter: { type: 'reference', maxLines: '100' } }),
185
+ ];
186
+ const out = buildIndex(rows, '2026-07-09');
187
+ expect(out).toMatch(/adr\/notes\.md/); // the stray is a visible row, not swallowed by the collapse
188
+ const aggregateRows = out.split('\n').filter((l) => l.includes('](./adr/log.md)'));
189
+ expect(aggregateRows.length).toBe(1); // exactly one aggregate row for the real record(s)
190
+ });
191
+
192
+ it('the aggregate row shows the record count and a NUMERIC id range (AD-200 … AD-1000)', () => {
193
+ const rows = [
194
+ makeRow('docs/ai/adr/AD-200-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
195
+ makeRow('docs/ai/adr/AD-1000-b.md', { frontmatter: { type: 'adr', maxLines: '400' } }),
196
+ makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } }),
197
+ ];
198
+ const out = buildIndex(rows, '2026-07-09');
199
+ expect(out).toMatch(/\[`adr\/`\]\(\.\/adr\/log\.md\) \| adr \| 2 records \| AD-200 … AD-1000/);
200
+ });
201
+
202
+ it('adding a record drifts the collapse row → checkIndexFreshness flags it stale', () => {
203
+ const base = [makeRow('docs/ai/adr/AD-001-a.md', { frontmatter: { type: 'adr', maxLines: '400' } }), makeRow('docs/ai/adr/log.md', { frontmatter: { type: 'reference', maxLines: '200' } })];
204
+ const onDisk = buildIndex(base, '2026-07-09');
205
+ const grown = [...base, makeRow('docs/ai/adr/AD-002-b.md', { frontmatter: { type: 'adr', maxLines: '400' } })];
206
+ expect(checkIndexFreshness(grown, onDisk).fresh).toBe(false);
207
+ });
208
+
209
+ it('a single adr record OVER its own cap still fails inspectFile (the collapse never hides a fat body)', async () => {
210
+ const dir = await mkdtemp(join(tmpdir(), 'adr-cap-'));
211
+ try {
212
+ const path = join(dir, 'AD-001-huge.md');
213
+ await writeFile(path, `---\ntype: adr\nlastUpdated: 2026-07-09\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: 5\n---\n\n## AD-001 — Huge\n${'x\n'.repeat(20)}`);
214
+ const result = await inspectFile(path, computeToday('2026-07-09'));
215
+ expect(result.errors.some((e) => /lines > maxLines/.test(e))).toBe(true);
216
+ } finally {
217
+ await rm(dir, { recursive: true, force: true });
218
+ }
219
+ });
220
+ });
221
+
163
222
  describe('buildIndex', () => {
164
223
  it('is deterministic, sorts rows by path, and excludes index.md itself', () => {
165
224
  const rows = [
@@ -202,3 +261,71 @@ describe('checkIndexFreshness', () => {
202
261
  expect(checkIndexFreshness(rows, onDisk).fresh).toBe(true);
203
262
  });
204
263
  });
264
+
265
+ // ── (h) — root-parameterization: the ADR-rotation hook regenerates ANOTHER root's index ────
266
+ // The generator's module ROOT is the CLI default only; --root / regenerateIndex(root) target an
267
+ // arbitrary tree so the rotation hook (and hermetic tests) never touch the real repo.
268
+ describe('root parameterization (item (h))', () => {
269
+ let root;
270
+ beforeEach(async () => {
271
+ root = await mkdtemp(join(tmpdir(), 'check-docs-root-'));
272
+ await mkdir(join(root, 'docs', 'ai'), { recursive: true });
273
+ });
274
+ afterEach(async () => {
275
+ await rm(root, { recursive: true, force: true });
276
+ });
277
+
278
+ const seedDoc = (name, extra = '') =>
279
+ 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}`);
280
+
281
+ it('inspectFile computes the file path RELATIVE to the passed root (not the module ROOT)', async () => {
282
+ await seedDoc('handover.md');
283
+ const result = await inspectFile(join(root, 'docs', 'ai', 'handover.md'), computeToday('2026-07-08'), root);
284
+ expect(result.path).toBe('docs/ai/handover.md');
285
+ });
286
+
287
+ it('regenerateIndex(root, today) writes THAT root\'s docs/ai/index.md from its frontmatter', async () => {
288
+ await seedDoc('a.md');
289
+ await seedDoc('b.md');
290
+ const res = await regenerateIndex(root, '2026-07-08');
291
+ expect(res.indexPath).toBe(join(root, 'docs', 'ai', 'index.md'));
292
+ expect(existsSync(res.indexPath)).toBe(true);
293
+ const index = await readFile(res.indexPath, 'utf8');
294
+ expect(index).toMatch(/lastUpdated: 2026-07-08/); // header date is the argument
295
+ expect(index).toMatch(/a\.md/);
296
+ expect(index).toMatch(/b\.md/);
297
+ expect(index).not.toMatch(/\[`index\.md`\]/); // the index never lists itself
298
+ });
299
+
300
+ it('a re-run with unchanged sources is byte-identical (deterministic, --check-index safe)', async () => {
301
+ await seedDoc('a.md');
302
+ const first = await regenerateIndex(root, '2026-07-08');
303
+ const bytesA = await readFile(first.indexPath, 'utf8');
304
+ await regenerateIndex(root, '2026-07-08');
305
+ const bytesB = await readFile(first.indexPath, 'utf8');
306
+ expect(bytesB).toBe(bytesA);
307
+ });
308
+
309
+ // The CLI entry (main) over --root — a subprocess smoke so the --help usage + the --check-index
310
+ // fresh/stale branches (root-parameterized) are exercised end-to-end.
311
+ const SCRIPT = fileURLToPath(new URL('./check-docs-size.mjs', import.meta.url));
312
+ const runCli = (args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8' });
313
+
314
+ it('--help prints the usage (naming --root) and exits 0', () => {
315
+ const r = runCli(['--help']);
316
+ expect(r.status).toBe(0);
317
+ expect(r.stdout).toMatch(/--root=/);
318
+ });
319
+
320
+ it('--check-index --root: a fresh index is OK (exit 0); a drifted one is stale (exit 1)', async () => {
321
+ await seedDoc('a.md');
322
+ expect(runCli(['--write-index', `--root=${root}`]).status).toBe(0);
323
+ const fresh = runCli(['--check-index', `--root=${root}`]);
324
+ expect(fresh.status).toBe(0);
325
+ expect(fresh.stdout).toMatch(/in sync/);
326
+ await seedDoc('b.md'); // a new source row drifts the on-disk index
327
+ const stale = runCli(['--check-index', `--root=${root}`]);
328
+ expect(stale.status).toBe(1);
329
+ expect(stale.stderr).toMatch(/stale/);
330
+ });
331
+ });
@@ -0,0 +1,25 @@
1
+ ---
2
+ type: reference
3
+ lastUpdated: {{DATE}}
4
+ scope: permanent
5
+ staleAfter: never
6
+ owner: none
7
+ maxLines: 200
8
+ ---
9
+
10
+ # ADR Navigator — active set (AD-001 … AD-001)
11
+
12
+ > **Auto-generated** (`archive-decisions.mjs --write-navigator`). The governing heads only —
13
+ > an ADR superseded/amended by another drops OUT (still reachable by filename, grep, or the
14
+ > `[[AD-NNN]]` chain). The HOT window lives in [`../decisions.md`](../decisions.md); every
15
+ > archived record is one file in this directory. This is a navigator, never a full ledger.
16
+
17
+ ## Governing (1) — accepted & not superseded
18
+
19
+ | ADR | Title | Record |
20
+ |-----|-------|--------|
21
+ | AD-001 | Adopt AI-agent memory system (`docs/ai/`) | [`../decisions.md`](../decisions.md) |
22
+
23
+ ## Recent (1)
24
+
25
+ - **AD-001** — governing — `decisions.md (HOT)`
@@ -0,0 +1,72 @@
1
+ # ADR record format — authoring & lifecycle reference
2
+
3
+ > How an Architecture Decision Record is authored, archived, and retrieved in this project's
4
+ > **one-file-per-ADR** store. Read this when you author a new ADR or change an existing one's
5
+ > lifecycle. This file is a reference — it is **not** deployed into `docs/ai/` (it never becomes a
6
+ > record itself).
7
+
8
+ ## Where ADRs live
9
+
10
+ - **HOT window — `docs/ai/decisions.md`.** The active ADR window. You **author here**, newest at the
11
+ bottom. It is self-bounding under its frontmatter `maxLines`.
12
+ - **Store — `docs/ai/adr/AD-NNN-slug.md`.** One immutable record per archived ADR (full frontmatter +
13
+ the verbatim `## AD-NNN — title` block). You **never hand-write** these — `archive-decisions.mjs`
14
+ produces them by exploding the oldest HOT entries beyond the cap.
15
+ - **Navigator — `docs/ai/adr/log.md`.** The one generated map: the currently-governing heads
16
+ (accepted ∧ not superseded) + a recent window. Superseded ADRs drop OUT (still reachable by
17
+ filename, grep, or the `[[AD-NNN]]` chain). It is a navigator, never a full ledger.
18
+
19
+ ## Authoring a new ADR (in the HOT window)
20
+
21
+ Append a block at the **bottom** of `docs/ai/decisions.md`:
22
+
23
+ ```
24
+ ## AD-NNN — <concise title>
25
+
26
+ **Date:** YYYY-MM-DD
27
+ **Status:** Accepted
28
+
29
+ **Context.** Why this decision is needed; the forces at play.
30
+ **Decision.** What was chosen (imperative, unambiguous).
31
+ **Consequences.** ➕ benefits · ➖ costs. Note any ADR this supersedes.
32
+ ```
33
+
34
+ - **ID grammar: `AD-\d{3,}`.** Three digits minimum; `AD-1000+` is valid. Allocate the next integer
35
+ after the highest id across HOT ∪ `adr/`. Ordering is always **numeric**, never lexical.
36
+ - **The `## AD-NNN — <title>` heading is strict.** Use the em dash ` — `. A non-canonical `## ` heading
37
+ is a loud parse failure — the store never silently glues an entry to the previous body.
38
+ - **`slug` is cosmetic and frozen at creation** (`slugify(title)`); the `AD-NNN` prefix is the key. A
39
+ retitle never renames the file.
40
+ - The block is preserved **verbatim** when it is archived, so write it as you want it to persist.
41
+
42
+ ## Lifecycle (the only mutable part after acceptance)
43
+
44
+ The **body is immutable** once accepted — only lifecycle changes. Express supersession in the body of
45
+ the **new** ADR; governance is then computed automatically (no predecessor-file edit is required):
46
+
47
+ | Body form (in the newer ADR) | Effect |
48
+ |----------------------------------|------------------------------------------------------------------|
49
+ | `Supersedes [[AD-NNN]]` | retires AD-NNN (when the citing ADR is `accepted`) |
50
+ | `Superseded by [[AD-NNN]]` | marks this ADR retired by AD-NNN |
51
+ | `Amended by [[AD-NNN]]` | marks this ADR amended (also drops it from the governing heads) |
52
+
53
+ - **`status`** is the leading word of `**Status:**`, lowercased (`accepted` / `superseded` /
54
+ `amended` / `deprecated`). A **missing** `**Status:**` line defaults to `accepted` (the in-force
55
+ default). Only `accepted` ADRs govern.
56
+ - When an ADR is archived, its record frontmatter backfills `status`, `date`, `supersedes`, and
57
+ `supersededBy` from these body forms — you do not maintain the frontmatter by hand.
58
+
59
+ ## Retrieval (never through an O(n) artifact)
60
+
61
+ - **by id** → the deterministic filename `docs/ai/adr/AD-NNN-*.md` (glob).
62
+ - **by topic** → `grep` the flat `docs/ai/adr/` tree.
63
+ - **by lifecycle** → the two-way `supersedes` / `supersededBy` frontmatter + the `[[AD-NNN]]` chain;
64
+ `ls docs/ai/adr/` IS the log.
65
+
66
+ ## Commands
67
+
68
+ - `node scripts/archive-decisions.mjs` — rotate: explode the oldest HOT entries beyond the cap into
69
+ `adr/` records, then regenerate the navigator + `docs/ai/index.md`.
70
+ - `node scripts/archive-decisions.mjs --write-navigator` — regenerate `docs/ai/adr/log.md` (and the
71
+ index) after authoring an ADR or editing a supersession, so `--check` stays green.
72
+ - `node scripts/archive-decisions.mjs --check` — verify HOT cap + store integrity + navigator freshness.
@@ -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.
@@ -9,7 +9,11 @@ maxLines: 500
9
9
 
10
10
  # Architecture Decision Records (ADRs)
11
11
 
12
- > Every significant choice that has long-term consequences. Newest at the bottom. Link related ADRs with `[[AD-XXX]]`.
12
+ > The **HOT window** of Architecture Decision Records every significant choice with long-term
13
+ > consequences, newest at the bottom. Link related ADRs with `[[AD-XXX]]`; retrieval is by the
14
+ > `AD-NNN` id (filename), grep over the flat store, or the `[[AD-NNN]]` supersession chain.
15
+
16
+ > **Archive:** older ADRs are stored one immutable file per record under [`adr/`](./adr/) — see the active-set navigator [`adr/log.md`](./adr/log.md). `archive-decisions.mjs` explodes the oldest entries beyond this window automatically; no cap is ever raised and there is no monolithic ledger.
13
17
 
14
18
  ## AD-001 — Adopt AI-agent memory system (`docs/ai/`)
15
19
 
@@ -18,27 +22,12 @@ maxLines: 500
18
22
 
19
23
  **Context.** Multi-session AI work loses context between runs. Without a structured handover, each new session re-reads code, re-discovers decisions, and repeats past mistakes.
20
24
 
21
- **Decision.** Adopt a Memory Map in `AGENTS.md` (entry point — the cross-agent standard; tool aliases like `CLAUDE.md` symlink to it) + structured files under `docs/ai/`. Define three protocols (Start / During / Complete). Enforce frontmatter caps + index freshness + 3-tier archive via a pre-commit hook. Deployed via the `agent-workflow-memory` substrate (standalone, or as part of the agent-workflow family).
25
+ **Decision.** Adopt a Memory Map in `AGENTS.md` (entry point — the cross-agent standard; tool aliases like `CLAUDE.md` symlink to it) + structured files under `docs/ai/`. Define three protocols (Start / During / Complete). Enforce frontmatter caps + index freshness + a one-file-per-ADR archive via a pre-commit hook. Deployed via the `agent-workflow-memory` substrate (standalone, or as part of the agent-workflow family).
22
26
 
23
- **Rationale.** Single entry + structured spec files = constant boot-up cost regardless of project size. ADRs prevent litigating the same decision twice. `pages/<page>.md` keeps behaviour canonical (docs > assumptions). Caps + archive keep files scannable as history grows.
27
+ **Rationale.** Single entry + structured spec files = constant boot-up cost regardless of project size. ADRs prevent litigating the same decision twice. `pages/<page>.md` keeps behaviour canonical (docs > assumptions). Caps + one-file-per-ADR archival keep every record scannable as the decision history grows without bound.
24
28
 
25
29
  **Consequences.**
26
30
  - ➕ Faster session start, less drift between agents.
27
31
  - ➕ ADRs as institutional memory; honest `known_issues.md`.
28
32
  - ➖ Discipline cost: docs updated alongside code.
29
33
  - ➖ A set of markdown files + scripts to maintain.
30
-
31
- ---
32
-
33
- ## AD-002 — {{Next decision}}
34
-
35
- **Date:** {{DATE}}
36
- **Status:** Proposed / Accepted / Superseded
37
-
38
- **Context.** {{...}}
39
- **Decision.** {{...}}
40
- **Consequences.** {{...}}
41
-
42
- ---
43
-
44
- > When this file nears ~90% of its cap, move the oldest Accepted/Superseded ADRs to `decisions-archive.md` (keep the IDs + a one-line pointer here) and record the split. Bumping the cap instead of splitting is a conscious exception, justified in an ADR.
@@ -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
+ }
@@ -6,7 +6,7 @@
6
6
  // The deployment lineage is a SINGLE shared sequence; its current head is LINEAGE_HEAD.
7
7
  // Both `.memory-version` and the kit-fallback `.workflow-version` track THAT sequence —
8
8
  // never their package versions. So this substrate's package may be 1.0.0 while the stamp
9
- // it writes is the lineage head (1.3.0 today).
9
+ // it writes is the lineage head (2.0.0 today).
10
10
  //
11
11
  // `decideTakeover` is a PURE function (stamp state in → action out) so the state machine is
12
12
  // unit-testable per row. `applyTakeover` is the thin fs wrapper; stamp writes are ATOMIC
@@ -22,7 +22,7 @@ import { pathToFileURL } from 'node:url';
22
22
 
23
23
  // The shared agent-workflow deployment-lineage head. Bumped only when a project-migration
24
24
  // changes the deployed docs/ai structure — NOT on a packaging-only release.
25
- export const LINEAGE_HEAD = '1.3.0';
25
+ export const LINEAGE_HEAD = '2.0.0';
26
26
 
27
27
  const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
28
28