@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.
- package/CHANGELOG.md +32 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/SKILL.md +121 -0
- package/bin/install.mjs +139 -0
- package/launchers/README.md +33 -0
- package/launchers/install-launchers.sh +94 -0
- package/launchers/windsurf-workflow.md +30 -0
- package/migrations/README.md +41 -0
- package/package.json +46 -0
- package/references/planning.md +105 -0
- package/references/scripts/_expect-shim.mjs +41 -0
- package/references/scripts/archive-changelog.mjs +441 -0
- package/references/scripts/archive-changelog.test.mjs +212 -0
- package/references/scripts/archive-issues.mjs +179 -0
- package/references/scripts/archive-issues.test.mjs +95 -0
- package/references/scripts/check-docs-size.mjs +353 -0
- package/references/scripts/check-docs-size.test.mjs +180 -0
- package/references/scripts/install-git-hooks.mjs +83 -0
- package/references/templates/AGENTS.md +78 -0
- package/references/templates/active_plan.md +31 -0
- package/references/templates/agent_rules.md +85 -0
- package/references/templates/architecture.md +49 -0
- package/references/templates/changelog.md +24 -0
- package/references/templates/current_state.md +36 -0
- package/references/templates/decisions.md +44 -0
- package/references/templates/env_commands.md +41 -0
- package/references/templates/handover.md +37 -0
- package/references/templates/known_issues.md +33 -0
- package/references/templates/pages/PAGE_TEMPLATE.md +53 -0
- package/references/templates/pages/index.md +23 -0
- package/references/templates/pages/shared-patterns.md +30 -0
- package/references/templates/tech_reference.md +34 -0
- 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
|
+
}
|