@sabaiway/agent-workflow-kit 1.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +216 -0
  4. package/SKILL.md +121 -0
  5. package/bin/install.mjs +139 -0
  6. package/launchers/README.md +33 -0
  7. package/launchers/install-launchers.sh +94 -0
  8. package/launchers/windsurf-workflow.md +30 -0
  9. package/migrations/README.md +41 -0
  10. package/package.json +46 -0
  11. package/references/planning.md +105 -0
  12. package/references/scripts/_expect-shim.mjs +41 -0
  13. package/references/scripts/archive-changelog.mjs +441 -0
  14. package/references/scripts/archive-changelog.test.mjs +212 -0
  15. package/references/scripts/archive-issues.mjs +179 -0
  16. package/references/scripts/archive-issues.test.mjs +95 -0
  17. package/references/scripts/check-docs-size.mjs +353 -0
  18. package/references/scripts/check-docs-size.test.mjs +180 -0
  19. package/references/scripts/install-git-hooks.mjs +83 -0
  20. package/references/templates/AGENTS.md +78 -0
  21. package/references/templates/active_plan.md +31 -0
  22. package/references/templates/agent_rules.md +85 -0
  23. package/references/templates/architecture.md +49 -0
  24. package/references/templates/changelog.md +24 -0
  25. package/references/templates/current_state.md +36 -0
  26. package/references/templates/decisions.md +44 -0
  27. package/references/templates/env_commands.md +41 -0
  28. package/references/templates/handover.md +37 -0
  29. package/references/templates/known_issues.md +33 -0
  30. package/references/templates/pages/PAGE_TEMPLATE.md +53 -0
  31. package/references/templates/pages/index.md +23 -0
  32. package/references/templates/pages/shared-patterns.md +30 -0
  33. package/references/templates/tech_reference.md +34 -0
  34. package/references/templates/technical_specification.md +37 -0
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ // Rotate FIXED issues from docs/ai/known_issues.md → docs/ai/history/issues-resolved.md.
3
+ //
4
+ // Rule: an issue is archivable when
5
+ // - its heading is wrapped in ~~strikethrough~~ AND
6
+ // - its body contains `**Status:** ✅ FIXED (YYYY.MM.DD)` with a date older than CUTOFF_DAYS.
7
+ // Issues marked FIXED without an explicit date are left untouched (conservative — agent
8
+ // can re-evaluate and archive manually).
9
+ //
10
+ // Modes:
11
+ // (default) append matching issues to history/issues-resolved.md, remove from known_issues.md
12
+ // --dry-run print plan, no file changes
13
+ // --check exit 1 if known_issues.md still has archivable issues
14
+ //
15
+ // CLI:
16
+ // --cutoff-days=N (default 14)
17
+ // --today=YYYY-MM-DD (default UTC today)
18
+
19
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { dirname, resolve, relative, basename } from 'node:path';
22
+ import { fileURLToPath, pathToFileURL } from 'node:url';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const ROOT = resolve(__dirname, '..');
26
+
27
+ const readProjectName = () => {
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8'));
30
+ if (pkg.name) return pkg.name;
31
+ } catch {
32
+ /* no package.json — fall back to repo dir basename */
33
+ }
34
+ return basename(ROOT);
35
+ };
36
+ const PROJECT_NAME = readProjectName();
37
+
38
+ const KNOWN_ISSUES_PATH = resolve(ROOT, 'docs/ai/known_issues.md');
39
+ const HISTORY_DIR = resolve(ROOT, 'docs/ai/history');
40
+ const RESOLVED_PATH = resolve(HISTORY_DIR, 'issues-resolved.md');
41
+
42
+ const DEFAULT_CUTOFF_DAYS = 14;
43
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
44
+
45
+ const ISSUE_HEADING_RE = /^### (.+?)$/;
46
+ const STRIKETHROUGH_RE = /^~~(.+)~~$/;
47
+ const FIXED_WITH_DATE_RE = /\*\*Status:\*\*\s*✅\s*FIXED\s*\((\d{4})\.(\d{2})\.(\d{2})\)/;
48
+
49
+ const parseArgs = (argv) => {
50
+ const flags = { dryRun: false, check: false };
51
+ const opts = { cutoffDays: DEFAULT_CUTOFF_DAYS, today: null };
52
+ for (const arg of argv.slice(2)) {
53
+ if (arg === '--dry-run') flags.dryRun = true;
54
+ else if (arg === '--check') flags.check = true;
55
+ else if (arg.startsWith('--cutoff-days=')) opts.cutoffDays = Number(arg.slice('--cutoff-days='.length));
56
+ else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
57
+ else if (arg === '--help' || arg === '-h') {
58
+ console.log('Usage: archive-issues.mjs [--dry-run|--check] [--cutoff-days=N] [--today=YYYY-MM-DD]');
59
+ process.exit(0);
60
+ } else {
61
+ console.error(`Unknown argument: ${arg}`);
62
+ process.exit(2);
63
+ }
64
+ }
65
+ return { flags, opts };
66
+ };
67
+
68
+ export const parseKnownIssues = (text) => {
69
+ const fmMatch = text.match(/^(---\n[\s\S]*?\n---\n)/);
70
+ const frontmatter = fmMatch ? fmMatch[1] : '';
71
+ const body = text.slice(frontmatter.length);
72
+ const lines = body.split('\n');
73
+
74
+ const sections = [];
75
+ let current = { heading: null, lines: [] };
76
+ for (const line of lines) {
77
+ if (/^### /.test(line)) {
78
+ if (current.heading !== null || current.lines.length > 0) sections.push(current);
79
+ current = { heading: line, lines: [line] };
80
+ } else {
81
+ current.lines.push(line);
82
+ }
83
+ }
84
+ if (current.heading !== null || current.lines.length > 0) sections.push(current);
85
+ return { frontmatter, sections };
86
+ };
87
+
88
+ export const classifySection = (section, cutoffDate) => {
89
+ if (section.heading === null) return { kind: 'preamble' };
90
+ const headingMatch = ISSUE_HEADING_RE.exec(section.heading);
91
+ if (!headingMatch) return { kind: 'other' };
92
+ const title = headingMatch[1];
93
+ const stricken = STRIKETHROUGH_RE.exec(title);
94
+ if (!stricken) return { kind: 'open' };
95
+
96
+ const blockText = section.lines.join('\n');
97
+ const dateMatch = FIXED_WITH_DATE_RE.exec(blockText);
98
+ if (!dateMatch) return { kind: 'fixed-undated' };
99
+
100
+ const fixedDate = new Date(`${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}T00:00:00Z`);
101
+ return fixedDate < cutoffDate ? { kind: 'archivable', fixedDate } : { kind: 'fixed-recent', fixedDate };
102
+ };
103
+
104
+ export const buildResolvedFile = (existing, newSections, todayStr) => {
105
+ const header = existing
106
+ ? existing
107
+ : `---\ntype: history\nlastUpdated: ${todayStr}\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: 3500\n---\n\n# Resolved Issues — ${PROJECT_NAME}\n\n> Append-only archive of issues closed > 14 days ago. Sourced from \`../known_issues.md\`.\n\n---\n`;
108
+ const newBlocks = newSections.map((s) => s.lines.join('\n').replace(/\n+$/, '')).join('\n\n---\n\n');
109
+ if (!newBlocks) return header;
110
+ return `${header}\n${newBlocks}\n`;
111
+ };
112
+
113
+ const main = async () => {
114
+ const { flags, opts } = parseArgs(process.argv);
115
+ const today = opts.today
116
+ ? new Date(`${opts.today}T00:00:00Z`)
117
+ : new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
118
+ const cutoffDate = new Date(today.getTime() - (opts.cutoffDays - 1) * MS_PER_DAY);
119
+ const todayStr = today.toISOString().slice(0, 10);
120
+
121
+ const text = await readFile(KNOWN_ISSUES_PATH, 'utf8');
122
+ const { frontmatter, sections } = parseKnownIssues(text);
123
+
124
+ const classified = sections.map((s) => ({ section: s, ...classifySection(s, cutoffDate) }));
125
+ const archivable = classified.filter((c) => c.kind === 'archivable');
126
+
127
+ if (flags.check) {
128
+ if (archivable.length > 0) {
129
+ console.error(`[archive-issues] FAIL: ${archivable.length} archivable issues found in known_issues.md.`);
130
+ for (const c of archivable) console.error(` - ${c.section.heading.trim()}`);
131
+ console.error('Run the issues archive script (without --check) to rotate.');
132
+ process.exit(1);
133
+ }
134
+ console.log('[archive-issues] OK — no FIXED issues older than 14 days in known_issues.md.');
135
+ process.exit(0);
136
+ }
137
+
138
+ if (flags.dryRun) {
139
+ console.log('[archive-issues] DRY-RUN — no files will be changed.');
140
+ console.log(` cutoffDate: ${cutoffDate.toISOString().slice(0, 10)}`);
141
+ console.log(` total sections: ${sections.length}`);
142
+ console.log(` archivable: ${archivable.length}`);
143
+ for (const c of archivable) console.log(` - ${c.section.heading.trim()}`);
144
+ return;
145
+ }
146
+
147
+ if (archivable.length === 0) {
148
+ console.log('[archive-issues] nothing to archive.');
149
+ return;
150
+ }
151
+
152
+ await mkdir(HISTORY_DIR, { recursive: true });
153
+ const existing = existsSync(RESOLVED_PATH) ? await readFile(RESOLVED_PATH, 'utf8') : '';
154
+ const updatedResolved = buildResolvedFile(existing, archivable.map((c) => c.section), todayStr);
155
+ await writeFile(RESOLVED_PATH, updatedResolved, 'utf8');
156
+
157
+ const keptSections = classified.filter((c) => c.kind !== 'archivable').map((c) => c.section);
158
+ // Rebuild known_issues.md
159
+ const rebuilt = [
160
+ frontmatter.trim(),
161
+ '',
162
+ ...keptSections.map((s) => s.lines.join('\n').replace(/\n+$/, '')),
163
+ '',
164
+ ]
165
+ .join('\n')
166
+ .replace(/\n{3,}/g, '\n\n')
167
+ .trim() + '\n';
168
+ await writeFile(KNOWN_ISSUES_PATH, rebuilt, 'utf8');
169
+
170
+ console.log(`[archive-issues] archived ${archivable.length} issue(s) to ${relative(ROOT, RESOLVED_PATH)}`);
171
+ };
172
+
173
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
174
+ if (isDirectRun) {
175
+ main().catch((err) => {
176
+ console.error(err);
177
+ process.exit(1);
178
+ });
179
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, it } from 'node:test';
2
+ import { expect } from './_expect-shim.mjs';
3
+ import {
4
+ parseKnownIssues,
5
+ classifySection,
6
+ buildResolvedFile,
7
+ } from './archive-issues.mjs';
8
+
9
+ const FM = '---\ntype: reference\nlastUpdated: 2026-05-24\nmaxLines: 240\n---\n';
10
+
11
+ describe('parseKnownIssues', () => {
12
+ it('extracts frontmatter and each ### section', () => {
13
+ const text = `${FM}\n# Known Issues\n\n## High\n\n### Issue-001: foo\n\nbody one.\n\n### ~~Issue-002: bar~~\n\nbody two.\n`;
14
+ const parsed = parseKnownIssues(text);
15
+ expect(parsed.frontmatter).toBe(FM);
16
+ const issueSections = parsed.sections.filter((s) => s.heading !== null);
17
+ expect(issueSections).toHaveLength(2);
18
+ expect(issueSections[0].heading).toBe('### Issue-001: foo');
19
+ });
20
+
21
+ it('treats body before any ### as a preamble section', () => {
22
+ const text = `${FM}\n# Header\n\npreamble text\n\n### Issue-001: foo\n\nbody.\n`;
23
+ const parsed = parseKnownIssues(text);
24
+ expect(parsed.sections[0].heading).toBeNull();
25
+ expect(parsed.sections[0].lines.join('\n')).toContain('preamble text');
26
+ });
27
+ });
28
+
29
+ describe('classifySection', () => {
30
+ const cutoff = new Date('2026-05-20T00:00:00Z'); // 14 days before today=2026-05-24 ... actually let's use real cutoff math
31
+
32
+ it('returns preamble when heading is null', () => {
33
+ expect(classifySection({ heading: null, lines: [] }, cutoff).kind).toBe('preamble');
34
+ });
35
+
36
+ it('returns open when issue heading is not strikethrough', () => {
37
+ const section = {
38
+ heading: '### Issue-013: example open issue',
39
+ lines: ['### Issue-013: example open issue', '', '**Status:** Accepted'],
40
+ };
41
+ expect(classifySection(section, cutoff).kind).toBe('open');
42
+ });
43
+
44
+ it('returns archivable when strikethrough AND FIXED date older than cutoff', () => {
45
+ const section = {
46
+ heading: '### ~~Issue-001: example fixed feature~~',
47
+ lines: ['### ~~Issue-001: example fixed feature~~', '', '**Status:** ✅ FIXED (2026.04.10)'],
48
+ };
49
+ const result = classifySection(section, cutoff);
50
+ expect(result.kind).toBe('archivable');
51
+ expect(result.fixedDate.toISOString().slice(0, 10)).toBe('2026-04-10');
52
+ });
53
+
54
+ it('returns fixed-recent when strikethrough AND FIXED date newer than cutoff', () => {
55
+ const section = {
56
+ heading: '### ~~Issue-015: example recently-fixed item~~',
57
+ lines: ['### ~~Issue-015: example recently-fixed item~~', '', '**Status:** ✅ FIXED (2026.05.23)'],
58
+ };
59
+ expect(classifySection(section, cutoff).kind).toBe('fixed-recent');
60
+ });
61
+
62
+ it('returns fixed-undated when strikethrough has no FIXED date', () => {
63
+ const section = {
64
+ heading: '### ~~Issue-002: example undated-fixed item~~',
65
+ lines: ['### ~~Issue-002: example undated-fixed item~~', '', '**Status:** ✅ FIXED'],
66
+ };
67
+ expect(classifySection(section, cutoff).kind).toBe('fixed-undated');
68
+ });
69
+ });
70
+
71
+ describe('buildResolvedFile', () => {
72
+ it('writes new file with header + frontmatter when existing is empty', () => {
73
+ const result = buildResolvedFile(
74
+ '',
75
+ [{ heading: '### ~~Issue-001~~', lines: ['### ~~Issue-001~~', '', '**Status:** ✅ FIXED (2026.01.01)'] }],
76
+ '2026-05-24',
77
+ );
78
+ expect(result).toMatch(/^---\n/);
79
+ expect(result).toMatch(/maxLines: 3500/);
80
+ expect(result).toMatch(/# Resolved Issues/);
81
+ expect(result).toMatch(/### ~~Issue-001~~/);
82
+ });
83
+
84
+ it('appends new sections to existing content without re-emitting the header', () => {
85
+ const existing = '---\ntype: history\nlastUpdated: 2026-04-01\nmaxLines: 3500\n---\n\n# Resolved Issues\n\n### ~~Issue-000~~\n\nold body.\n';
86
+ const result = buildResolvedFile(
87
+ existing,
88
+ [{ heading: '### ~~Issue-099~~', lines: ['### ~~Issue-099~~', '', 'new body.'] }],
89
+ '2026-05-24',
90
+ );
91
+ expect(result.split('# Resolved Issues').length).toBe(2); // header appears exactly once
92
+ expect(result).toMatch(/### ~~Issue-099~~/);
93
+ expect(result).toMatch(/### ~~Issue-000~~/);
94
+ });
95
+ });
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ // Cap-validator for docs/ai/**/*.md.
3
+ //
4
+ // Reads YAML frontmatter from each file and verifies:
5
+ // - line count ≤ maxLines (blocking error)
6
+ // - lastUpdated within staleAfter window (e.g. 7d, 30d) (non-blocking warning)
7
+ //
8
+ // Modes:
9
+ // (default) run validation, print report, exit 1 if any error
10
+ // --report run validation, print full table, do not exit non-zero
11
+ // --write-index run validation AND regenerate docs/ai/index.md from frontmatter
12
+ // --check-index verify docs/ai/index.md is in sync with source frontmatter;
13
+ // exit 1 (and print how to fix) if stale. Catches the silent
14
+ // drift `--write-index` is supposed to prevent.
15
+ //
16
+ // CLI overrides:
17
+ // --today=YYYY-MM-DD (default today UTC) — useful for tests / reproducible runs
18
+ // --quiet print only failures (and final summary)
19
+
20
+ import { readFile, writeFile, readdir, stat } from 'node:fs/promises';
21
+ import { existsSync } from 'node:fs';
22
+ import { dirname, resolve, relative, join, basename } from 'node:path';
23
+ import { fileURLToPath, pathToFileURL } from 'node:url';
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = dirname(__filename);
27
+ const ROOT = resolve(__dirname, '..');
28
+ const DOCS_DIR = resolve(ROOT, 'docs/ai');
29
+ const INDEX_PATH = resolve(DOCS_DIR, 'index.md');
30
+
31
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
32
+
33
+ // Project-name + footer links for the index are auto-discovered (no hardcoding):
34
+ // project name ← package.json "name" (fallback: repo dir basename)
35
+ // hierarchical ← every AGENTS.md / CLAUDE.md below the repo root
36
+ // on-demand ← .agents/skills/*-{patterns,commands}/SKILL.md
37
+ const DEFAULT_PROJECT_NAME = 'this project';
38
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'dist-ssr', 'coverage', 'build', '.next']);
39
+
40
+ const walkForName = async (dir, name, acc = [], depth = 0) => {
41
+ if (depth > 6) return acc;
42
+ let entries;
43
+ try {
44
+ entries = await readdir(dir, { withFileTypes: true });
45
+ } catch {
46
+ return acc;
47
+ }
48
+ for (const entry of entries) {
49
+ if (entry.isDirectory()) {
50
+ if (SKIP_DIRS.has(entry.name)) continue;
51
+ await walkForName(join(dir, entry.name), name, acc, depth + 1);
52
+ } else if (entry.isFile() && entry.name === name) {
53
+ acc.push(join(dir, entry.name));
54
+ }
55
+ }
56
+ return acc;
57
+ };
58
+
59
+ export const discoverMeta = async () => {
60
+ let projectName = basename(ROOT);
61
+ try {
62
+ const pkg = JSON.parse(await readFile(resolve(ROOT, 'package.json'), 'utf8'));
63
+ if (pkg.name) projectName = pkg.name;
64
+ } catch {
65
+ /* no package.json — keep dir basename */
66
+ }
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');
71
+ // A subdir typically holds AGENTS.md plus a CLAUDE.md symlink to it — list each
72
+ // dir once (prefer AGENTS.md, drop its sibling CLAUDE.md alias).
73
+ const agentsDirs = new Set(agentsFiles.map((file) => dirname(resolve(file))));
74
+ const nestedFiles = [
75
+ ...agentsFiles.filter((file) => resolve(file) !== rootAgents),
76
+ ...claudeFiles.filter(
77
+ (file) => resolve(file) !== rootClaude && !agentsDirs.has(dirname(resolve(file))),
78
+ ),
79
+ ];
80
+ const hierarchicalLinks = nestedFiles
81
+ .map((file) => relative(ROOT, file))
82
+ .sort()
83
+ .map((rel) => `[\`${rel}\`](../../${rel})`);
84
+ let onDemandLinks = [];
85
+ try {
86
+ const skillDirs = await readdir(resolve(ROOT, '.agents/skills'), { withFileTypes: true });
87
+ onDemandLinks = skillDirs
88
+ .filter((dirent) => dirent.isDirectory() && /-(patterns|commands)$/.test(dirent.name))
89
+ .map((dirent) => dirent.name)
90
+ .sort()
91
+ .map((name) => `[\`${name}\`](../../.agents/skills/${name}/SKILL.md)`);
92
+ } catch {
93
+ /* no .agents/skills — omit the section */
94
+ }
95
+ return { projectName, hierarchicalLinks, onDemandLinks };
96
+ };
97
+
98
+ const parseArgs = (argv) => {
99
+ const flags = { report: false, writeIndex: false, checkIndex: false, quiet: false };
100
+ const opts = { today: null };
101
+ for (const arg of argv.slice(2)) {
102
+ if (arg === '--report') flags.report = true;
103
+ else if (arg === '--write-index') flags.writeIndex = true;
104
+ else if (arg === '--check-index') flags.checkIndex = true;
105
+ else if (arg === '--quiet') flags.quiet = true;
106
+ else if (arg.startsWith('--today=')) opts.today = arg.slice('--today='.length);
107
+ else if (arg === '--help' || arg === '-h') {
108
+ console.log(
109
+ 'Usage: check-docs-size.mjs [--report|--write-index|--check-index] [--today=YYYY-MM-DD] [--quiet]',
110
+ );
111
+ process.exit(0);
112
+ } else {
113
+ console.error(`Unknown argument: ${arg}`);
114
+ process.exit(2);
115
+ }
116
+ }
117
+ return { flags, opts };
118
+ };
119
+
120
+ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
121
+
122
+ export const parseFrontmatter = (text) => {
123
+ const match = text.match(FRONTMATTER_RE);
124
+ if (!match) return null;
125
+ const body = match[1];
126
+ const fields = {};
127
+ for (const line of body.split('\n')) {
128
+ const m = line.match(/^([a-zA-Z][a-zA-Z0-9_]*):\s*(.*)$/);
129
+ if (!m) continue;
130
+ fields[m[1]] = m[2].trim();
131
+ }
132
+ return fields;
133
+ };
134
+
135
+ export const parseStaleAfter = (value) => {
136
+ if (!value || value === 'never') return null;
137
+ const m = value.match(/^(\d+)d$/);
138
+ if (!m) return null;
139
+ return Number(m[1]);
140
+ };
141
+
142
+ const walkMarkdownFiles = async (dir) => {
143
+ const entries = await readdir(dir, { withFileTypes: true });
144
+ const files = [];
145
+ for (const entry of entries) {
146
+ const full = join(dir, entry.name);
147
+ if (entry.isDirectory()) {
148
+ files.push(...(await walkMarkdownFiles(full)));
149
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
150
+ files.push(full);
151
+ }
152
+ }
153
+ return files;
154
+ };
155
+
156
+ export const computeToday = (todayStr) =>
157
+ todayStr
158
+ ? new Date(`${todayStr}T00:00:00Z`)
159
+ : new Date(new Date().toISOString().slice(0, 10) + 'T00:00:00Z');
160
+
161
+ export const inspectFile = async (filePath, today) => {
162
+ const text = await readFile(filePath, 'utf8');
163
+ const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
164
+ const fm = parseFrontmatter(text);
165
+ const rel = relative(ROOT, filePath);
166
+
167
+ if (!fm) {
168
+ return {
169
+ path: rel,
170
+ lineCount,
171
+ frontmatter: null,
172
+ errors: [`missing YAML frontmatter`],
173
+ warnings: [],
174
+ };
175
+ }
176
+
177
+ const errors = [];
178
+ const warnings = [];
179
+
180
+ const maxLines = fm.maxLines ? Number(fm.maxLines) : null;
181
+ if (maxLines === null || Number.isNaN(maxLines)) {
182
+ errors.push(`frontmatter missing maxLines`);
183
+ } else if (lineCount > maxLines) {
184
+ errors.push(`${lineCount} lines > maxLines ${maxLines}`);
185
+ }
186
+
187
+ const staleDays = parseStaleAfter(fm.staleAfter);
188
+ if (staleDays !== null && fm.lastUpdated) {
189
+ const updated = new Date(`${fm.lastUpdated}T00:00:00Z`);
190
+ if (!Number.isNaN(updated.getTime())) {
191
+ const ageDays = Math.floor((today.getTime() - updated.getTime()) / MS_PER_DAY);
192
+ if (ageDays > staleDays) {
193
+ warnings.push(`lastUpdated ${fm.lastUpdated} is ${ageDays}d old (staleAfter ${staleDays}d)`);
194
+ }
195
+ }
196
+ }
197
+
198
+ return { path: rel, lineCount, frontmatter: fm, errors, warnings };
199
+ };
200
+
201
+ const formatRow = (row) => {
202
+ const sizeCell = row.frontmatter?.maxLines
203
+ ? `${row.lineCount}/${row.frontmatter.maxLines}`
204
+ : `${row.lineCount}/?`;
205
+ const status = row.errors.length > 0 ? 'X' : row.warnings.length > 0 ? '!' : 'OK';
206
+ return { status, sizeCell, ...row };
207
+ };
208
+
209
+ const printReport = (rows, quiet) => {
210
+ const widths = {
211
+ status: 2,
212
+ path: Math.max(4, ...rows.map((r) => r.path.length)),
213
+ size: Math.max(9, ...rows.map((r) => r.sizeCell.length)),
214
+ type: Math.max(4, ...rows.map((r) => (r.frontmatter?.type ?? '').length)),
215
+ updated: 12,
216
+ };
217
+ const printable = quiet ? rows.filter((r) => r.errors.length || r.warnings.length) : rows;
218
+ if (printable.length > 0) {
219
+ console.log(
220
+ `${'S'.padEnd(widths.status)} ${'PATH'.padEnd(widths.path)} ${'SIZE/MAX'.padEnd(widths.size)} ${'TYPE'.padEnd(widths.type)} ${'UPDATED'.padEnd(widths.updated)}`,
221
+ );
222
+ for (const row of printable) {
223
+ console.log(
224
+ `${row.status.padEnd(widths.status)} ${row.path.padEnd(widths.path)} ${row.sizeCell.padEnd(widths.size)} ${(row.frontmatter?.type ?? '').padEnd(widths.type)} ${(row.frontmatter?.lastUpdated ?? '').padEnd(widths.updated)}`,
225
+ );
226
+ for (const err of row.errors) console.log(` - ERROR ${err}`);
227
+ for (const warn of row.warnings) console.log(` - WARN ${warn}`);
228
+ }
229
+ }
230
+ };
231
+
232
+ const INDEX_HEADER = `---
233
+ type: reference
234
+ lastUpdated: __TODAY__
235
+ scope: permanent
236
+ staleAfter: 30d
237
+ owner: none
238
+ maxLines: 80
239
+ ---
240
+
241
+ # Memory Map — __PROJECT__ \`docs/ai/\`
242
+
243
+ > **Auto-generated** — edit the source files' frontmatter, not this file. Regenerate after changes.
244
+ > Layered context architecture:
245
+ > **Always-loaded** — root \`AGENTS.md\` + this index.
246
+ > **On-demand** — read a specific \`docs/ai/\` file when its "Read When" applies.
247
+ > **Hierarchical** — subdirectory \`AGENTS.md\` files load when working in that folder.
248
+ > **Archive** — \`history/recent.md\` (WARM) + \`history/condensed-index.md\` + per-month files.
249
+
250
+ ## Files
251
+
252
+ `;
253
+
254
+ const formatIndexRow = (row) => {
255
+ const fm = row.frontmatter ?? {};
256
+ const name = row.path.replace(/^docs\/ai\//, '');
257
+ const link = `[\`${name}\`](./${name})`;
258
+ return `| ${link} | ${fm.type ?? '—'} | ${row.lineCount}/${fm.maxLines ?? '—'} | ${fm.lastUpdated ?? '—'} | ${fm.staleAfter ?? '—'} |`;
259
+ };
260
+
261
+ // Pure index renderer — given inspected rows + the date to stamp in the header,
262
+ // returns the exact bytes `docs/ai/index.md` should contain. Shared by
263
+ // `--write-index` (writes it) and `--check-index` (diffs against on-disk).
264
+ export const buildIndex = (rows, todayStr, meta = {}) => {
265
+ const projectName = meta.projectName ?? DEFAULT_PROJECT_NAME;
266
+ const onDemandLinks = meta.onDemandLinks ?? [];
267
+ const hierarchicalLinks = meta.hierarchicalLinks ?? [];
268
+ const sorted = [...rows].sort((a, b) => a.path.localeCompare(b.path));
269
+ const header = INDEX_HEADER.replace('__TODAY__', todayStr).replace('__PROJECT__', projectName);
270
+ const tableHeader = `| File | Type | Lines/Max | Updated | Stale after |\n|------|------|-----------|---------|-------------|`;
271
+ const tableRows = sorted
272
+ .filter((r) => r.path !== 'docs/ai/index.md')
273
+ .map(formatIndexRow)
274
+ .join('\n');
275
+ const onDemandSection =
276
+ onDemandLinks.length > 0
277
+ ? `\n\n## Skills (on-demand)\n\n${onDemandLinks.map((link) => `- ${link}`).join('\n')}`
278
+ : '';
279
+ const hierarchicalSection =
280
+ hierarchicalLinks.length > 0
281
+ ? `\n\n## Subdirectory \`AGENTS.md\` (hierarchical)\n\n${hierarchicalLinks.map((link) => `- ${link}`).join('\n')}`
282
+ : '';
283
+ return `${header}${tableHeader}\n${tableRows}${onDemandSection}${hierarchicalSection}\n`;
284
+ };
285
+
286
+ // Decides whether an on-disk index is in sync with the source frontmatter.
287
+ // The index is regenerated in memory using the on-disk index's OWN `lastUpdated`
288
+ // for the header, so a mere day-rollover (no content change) is NOT flagged —
289
+ // only genuine drift in the file table (added/removed files, changed
290
+ // type/cap/lastUpdated/staleAfter, or a changed line count) makes it stale.
291
+ export const checkIndexFreshness = (rows, onDiskText, meta = {}) => {
292
+ if (onDiskText === null || onDiskText === undefined || onDiskText === '') {
293
+ return { fresh: false, expected: buildIndex(rows, 'unknown', meta) };
294
+ }
295
+ const fm = parseFrontmatter(onDiskText);
296
+ const headerDate = fm?.lastUpdated ?? 'unknown';
297
+ const expected = buildIndex(rows, headerDate, meta);
298
+ return { fresh: expected === onDiskText, expected };
299
+ };
300
+
301
+ const writeIndex = async (rows, today, meta) => {
302
+ const body = buildIndex(rows, today.toISOString().slice(0, 10), meta);
303
+ await writeFile(INDEX_PATH, body, 'utf8');
304
+ };
305
+
306
+ const main = async () => {
307
+ const { flags, opts } = parseArgs(process.argv);
308
+ const today = computeToday(opts.today);
309
+ const files = (await walkMarkdownFiles(DOCS_DIR)).sort();
310
+ const inspected = await Promise.all(files.map((f) => inspectFile(f, today)));
311
+ const rows = inspected.map(formatRow);
312
+
313
+ const meta = flags.writeIndex || flags.checkIndex ? await discoverMeta() : null;
314
+
315
+ if (flags.writeIndex) {
316
+ await writeIndex(rows, today, meta);
317
+ console.log(`Wrote ${relative(ROOT, INDEX_PATH)}`);
318
+ const after = await stat(INDEX_PATH);
319
+ if (after.size === 0) {
320
+ console.error('index.md was written empty');
321
+ process.exit(2);
322
+ }
323
+ }
324
+
325
+ if (flags.checkIndex) {
326
+ const onDisk = existsSync(INDEX_PATH) ? await readFile(INDEX_PATH, 'utf8') : null;
327
+ const { fresh } = checkIndexFreshness(rows, onDisk, meta);
328
+ if (!fresh) {
329
+ console.error(
330
+ `[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.`,
331
+ );
332
+ process.exit(1);
333
+ }
334
+ console.log(
335
+ `[check-docs-size] OK — ${relative(ROOT, INDEX_PATH)} is in sync with source frontmatter.`,
336
+ );
337
+ return;
338
+ }
339
+
340
+ printReport(rows, flags.quiet);
341
+ const errorCount = rows.reduce((n, r) => n + r.errors.length, 0);
342
+ const warnCount = rows.reduce((n, r) => n + r.warnings.length, 0);
343
+ console.log(
344
+ `\n${rows.length} files inspected — ${errorCount} error(s), ${warnCount} warning(s)`,
345
+ );
346
+
347
+ if (errorCount > 0 && !flags.report) process.exit(1);
348
+ };
349
+
350
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
351
+ if (isDirectRun) {
352
+ await main();
353
+ }