@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.
- package/CHANGELOG.md +72 -1
- package/README.md +2 -2
- package/SKILL.md +42 -21
- package/bin/install.mjs +32 -0
- package/capability.json +1 -1
- package/migrations/README.md +1 -1
- package/migrations/legacy-stamp-takeover.md +3 -3
- package/package.json +1 -1
- package/references/scripts/archive-decisions.mjs +705 -283
- package/references/scripts/archive-decisions.test.mjs +666 -209
- package/references/scripts/check-docs-size.mjs +85 -29
- package/references/scripts/check-docs-size.test.mjs +128 -1
- package/references/templates/adr/log.md +25 -0
- package/references/templates/adr-record.md +72 -0
- package/references/templates/agent_rules.md +1 -0
- package/references/templates/decisions.md +7 -18
- package/references/templates/verification-profile.json +10 -0
- package/scripts/stamp-takeover.mjs +2 -2
|
@@ -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(
|
|
67
|
+
export const discoverMeta = async (root = ROOT) => {
|
|
68
|
+
let projectName = basename(root);
|
|
61
69
|
try {
|
|
62
|
-
const pkg = JSON.parse(await readFile(resolve(
|
|
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(
|
|
68
|
-
const claudeFiles = await walkForName(
|
|
69
|
-
const rootAgents = resolve(
|
|
70
|
-
const rootClaude = resolve(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
.
|
|
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(
|
|
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(
|
|
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(
|
|
321
|
-
const after = await stat(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
>
|
|
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 +
|
|
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 +
|
|
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 (
|
|
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 = '
|
|
25
|
+
export const LINEAGE_HEAD = '2.0.0';
|
|
26
26
|
|
|
27
27
|
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
|
28
28
|
|