dsh-notes-board 0.0.0-stage → 0.3.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +163 -2
  4. package/client.js +1253 -0
  5. package/cordis.patch.yml +6 -0
  6. package/icon.svg +16 -0
  7. package/index.js +28 -0
  8. package/locale/en.json +6 -0
  9. package/locale/zh.json +6 -0
  10. package/package.json +79 -4
  11. package/scripts/build-client.mjs +175 -0
  12. package/scripts/test-board.mjs +202 -0
  13. package/src/core/notes.js +486 -0
  14. package/src/core/tools.js +221 -0
  15. package/src/host/board.js +184 -0
  16. package/src/host/notes.js +250 -0
  17. package/src/host/skills.js +48 -0
  18. package/src/panel.js +294 -0
  19. package/vendor/board.html +1779 -0
  20. package/vendor/write-notes-like-deepseek/README.md +323 -0
  21. package/vendor/write-notes-like-deepseek/package.json +21 -0
  22. package/vendor/write-notes-like-deepseek/scripts/export-assets.sh +40 -0
  23. package/vendor/write-notes-like-deepseek/scripts/import-dsh-notes.ts +125 -0
  24. package/vendor/write-notes-like-deepseek/scripts/test-skill-install.mjs +63 -0
  25. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/SKILL.md +195 -0
  26. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/assets/agent-notes-board.html +1779 -0
  27. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/archiving.md +109 -0
  28. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/classification.md +33 -0
  29. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/note-format.md +69 -0
  30. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/prose-checklist.md +51 -0
  31. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/quality-gate.md +58 -0
  32. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/simplification-checklist.md +26 -0
  33. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/verification.md +34 -0
  34. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/references/when-to-write.md +83 -0
  35. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/agent-note-tree.ts +75 -0
  36. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/archive-agent-note.ts +167 -0
  37. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/build-board.ts +235 -0
  38. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/check-note-anchors.ts +103 -0
  39. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/verify-agent-note-format.ts +158 -0
  40. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/verify-agent-note-tree.ts +56 -0
  41. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/scripts/verify-archived-agent-notes.ts +127 -0
  42. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/implemented.md +32 -0
  43. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/proposed.md +38 -0
  44. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/rejected.md +27 -0
  45. package/vendor/write-notes-like-deepseek/skills/write-notes-like-deepseek/templates/verify-notes.yml +38 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * CLI tool to archive an implemented Agent Note with frozen manifest sealing.
3
+ * Usage: npx tsx <skill-dir>/scripts/archive-agent-note.ts .agents/notes/implemented/<class>/<filename>.md [--superseded-by <new-note-path>]
4
+ * --superseded-by writes a relative link into the NEW note, never into the archived file.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
8
+ import { basename, dirname, join, relative, resolve } from "node:path";
9
+ import { agentNoteRoot, AGENT_NOTE_CLASSES, walkAgentNoteTree } from "./agent-note-tree.ts";
10
+
11
+ const args = process.argv.slice(2);
12
+ const targetArg = args[0];
13
+ const supersededByArgIdx = args.indexOf("--superseded-by");
14
+ const supersededByArg = supersededByArgIdx !== -1 ? args[supersededByArgIdx + 1] : undefined;
15
+ if (supersededByArgIdx !== -1 && !supersededByArg) {
16
+ console.error("Error: --superseded-by requires a note path");
17
+ process.exit(1);
18
+ }
19
+ if (!targetArg) {
20
+ console.error("Usage: npx tsx <skill-dir>/scripts/archive-agent-note.ts <path-to-note> [--superseded-by <new-note-path>]");
21
+ process.exit(1);
22
+ }
23
+
24
+ let successorPath: string | undefined;
25
+ let successorRel: string | undefined;
26
+ if (supersededByArg) {
27
+ successorPath = resolve(process.cwd(), supersededByArg);
28
+ if (!existsSync(successorPath)) {
29
+ console.error(`Error: --superseded-by target not found at ${successorPath}`);
30
+ process.exit(1);
31
+ }
32
+ }
33
+
34
+ const targetPath = resolve(process.cwd(), targetArg);
35
+ if (!existsSync(targetPath)) {
36
+ console.error(`Error: target note not found at ${targetPath}`);
37
+ process.exit(1);
38
+ }
39
+
40
+ const relToRoot = relative(agentNoteRoot, targetPath).replace(/\\/g, "/");
41
+ const segs = relToRoot.split("/");
42
+
43
+ if (segs[0] !== "implemented" || segs.length !== 3) {
44
+ console.error(`Error: only notes in .agents/notes/implemented/<class>/ can be archived (got: ${relToRoot})`);
45
+ process.exit(1);
46
+ }
47
+
48
+ const [, cls, filename] = segs;
49
+ if (!AGENT_NOTE_CLASSES.includes(cls as any)) {
50
+ console.error(`Error: unknown class "${cls}"`);
51
+ process.exit(1);
52
+ }
53
+
54
+ // 1. Read and update content with Archived line
55
+ const raw = readFileSync(targetPath, "utf8");
56
+ const lines = raw.split("\n");
57
+ const statusIdx = lines.findIndex((l) => l === "Status: implemented");
58
+ if (statusIdx === -1) {
59
+ console.error("Error: note must contain `Status: implemented` to be archived");
60
+ process.exit(1);
61
+ }
62
+
63
+ // Local calendar date, not UTC — toISOString() would roll the archived stamp
64
+ // back one day for evening runs east of the prime meridian.
65
+ const nowLocal = new Date();
66
+ const pad2 = (n: number) => String(n).padStart(2, "0");
67
+ const today = `${nowLocal.getFullYear()}-${pad2(nowLocal.getMonth() + 1)}-${pad2(nowLocal.getDate())}`;
68
+ // DSH contract: `Archived:` sits immediately below `Status:` (L3/L4).
69
+ if (!lines.some((l) => l.startsWith("Archived:"))) {
70
+ lines.splice(statusIdx + 1, 0, `Archived: ${today}`);
71
+ }
72
+
73
+ if (successorPath) {
74
+ const rel = relative(agentNoteRoot, successorPath).replace(/\\/g, "/");
75
+ successorRel = rel;
76
+ const successorSegs = rel.split("/");
77
+ if (successorSegs.length !== 3 || !["proposed", "implemented", "rejected"].includes(successorSegs[0] ?? "")) {
78
+ console.error(`Error: --superseded-by target must be an active note in {lifecycle}/{class}/ (got: ${rel})`);
79
+ process.exit(1);
80
+ }
81
+ }
82
+
83
+ const oldTitle = (lines[0] ?? "").replace(/^# Agent Note[::] ?/, "").trim() || basename(filename, ".md");
84
+ const updatedContent = lines.join("\n");
85
+
86
+ // 2. Determine archived destination
87
+ const archivedDir = join(agentNoteRoot, "archived", cls);
88
+ mkdirSync(archivedDir, { recursive: true });
89
+ const archivedPath = join(archivedDir, filename);
90
+
91
+ if (existsSync(archivedPath)) {
92
+ console.error(`Error: target archived note already exists at ${archivedPath}`);
93
+ console.error("Refusing to overwrite existing archived note. Please inspect and resolve name collision manually.");
94
+ process.exit(1);
95
+ }
96
+
97
+ writeFileSync(targetPath, updatedContent, "utf8");
98
+ renameSync(targetPath, archivedPath);
99
+ console.log(`Moved: ${relToRoot} -> archived/${cls}/${filename}`);
100
+
101
+ // 3. Update archived/manifest.json with SHA-256 seal
102
+ const manifestPath = join(agentNoteRoot, "archived", "manifest.json");
103
+ interface Manifest {
104
+ version: 1;
105
+ files: Record<string, string>;
106
+ }
107
+ let manifest: Manifest = { version: 1, files: {} };
108
+ if (existsSync(manifestPath)) {
109
+ try {
110
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
111
+ } catch (e) {
112
+ console.warn("Warning: existing manifest.json was invalid, creating fresh");
113
+ }
114
+ }
115
+
116
+ const newArchivedRel = `archived/${cls}/${filename}`;
117
+ const sha256 = `sha256:${createHash("sha256").update(readFileSync(archivedPath)).digest("hex")}`;
118
+ manifest.files[newArchivedRel] = sha256;
119
+
120
+ // Deterministic sort keys
121
+ const sortedFiles = Object.fromEntries(
122
+ Object.entries(manifest.files).sort(([a], [b]) => a.localeCompare(b))
123
+ );
124
+ writeFileSync(manifestPath, JSON.stringify({ version: 1, files: sortedFiles }, null, 2) + "\n", "utf8");
125
+ console.log(`Sealed in archived/manifest.json with hash ${sha256.slice(0, 16)}...`);
126
+
127
+ // 4. Scan inbound links in active notes (precise markdown-link resolution)
128
+ const { notes } = walkAgentNoteTree();
129
+ const inboundFound: string[] = [];
130
+ const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
131
+ for (const note of notes) {
132
+ const noteFullPath = resolve(agentNoteRoot, note.rel);
133
+ const content = readFileSync(noteFullPath, "utf8");
134
+ let match: RegExpExecArray | null;
135
+ LINK_REGEX.lastIndex = 0;
136
+ while ((match = LINK_REGEX.exec(content)) !== null) {
137
+ const rawTarget = match[2]?.trim();
138
+ if (!rawTarget || rawTarget.startsWith("http://") || rawTarget.startsWith("https://") || rawTarget.startsWith("#") || rawTarget.startsWith("mailto:")) continue;
139
+ const fileTarget = rawTarget.split("#")[0];
140
+ if (!fileTarget) continue;
141
+ // resolve the link relative to the linking note, then compare with the archived note's original location
142
+ const resolvedTarget = resolve(dirname(noteFullPath), fileTarget);
143
+ if (resolvedTarget === targetPath) {
144
+ inboundFound.push(note.rel);
145
+ break;
146
+ }
147
+ }
148
+ }
149
+
150
+ if (inboundFound.length > 0) {
151
+ console.log("\n[Notice] The following active notes link to the archived note:");
152
+ for (const rel of inboundFound) {
153
+ console.log(` - ${rel}`);
154
+ }
155
+ console.log("Please review and update their relative markdown links if necessary.");
156
+ } else {
157
+ console.log("\nNo active notes link to this archived note.");
158
+ }
159
+
160
+ if (successorPath && successorRel) {
161
+ const relLink = relative(dirname(successorPath), archivedPath).replace(/\\/g, "/");
162
+ const newRaw = readFileSync(successorPath, "utf8");
163
+ if (!newRaw.includes(`archived/${cls}/${filename}`) && !newRaw.includes(relLink)) {
164
+ writeFileSync(successorPath, `${newRaw.replace(/\s*$/, "")}\n\n[历史快照:${oldTitle}](${relLink})\n`, "utf8");
165
+ console.log(`Linked from ${successorRel}: [历史快照:${oldTitle}](${relLink})`);
166
+ }
167
+ }
@@ -0,0 +1,235 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs';
4
+ import { join, resolve, relative, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ const templatePath = resolve(__dirname, '../assets/agent-notes-board.html');
11
+ const args = process.argv.slice(2);
12
+
13
+ if (!existsSync(templatePath)) {
14
+ console.error(`❌ Template not found at: ${templatePath}`);
15
+ process.exit(1);
16
+ }
17
+
18
+ // 检查参数
19
+ const isInitMode = args.includes('--init');
20
+ const isForce = args.includes('--force') || args.includes('-f');
21
+ const isMetadataOnly = args.includes('--metadata-only');
22
+ const cleanArgs = args.filter((a: string) => !a.startsWith('--') && !a.startsWith('-'));
23
+
24
+ function checkTargetSafety(targetPath: string) {
25
+ if (existsSync(targetPath) && !isForce) {
26
+ try {
27
+ const existing = readFileSync(targetPath, 'utf8');
28
+ const isBoard = existing.includes('generator" content="agent-notes-board"') || existing.includes('id="brand-project-title"');
29
+ if (!isBoard) {
30
+ console.error(`❌ 错误:目标文件已存在 (${targetPath}) 且并非看板生成文件!`);
31
+ console.error(`💡 为防止意外覆盖已有项目页面(如 Vite/React/Vue 项目的 index.html),请指定其他输出路径或传入 --force 确认覆盖。`);
32
+ process.exit(1);
33
+ }
34
+ } catch {}
35
+ }
36
+ }
37
+
38
+ if (isInitMode) {
39
+ const targetPath = cleanArgs[0] ? resolve(cleanArgs[0]) : resolve(process.cwd(), 'board.html');
40
+ const projectName = cleanArgs[1] || '工程决策看板';
41
+
42
+ checkTargetSafety(targetPath);
43
+
44
+ let template = readFileSync(templatePath, 'utf8');
45
+ template = template.replace('id="brand-project-title">工程决策看板<', () => `id="brand-project-title">${projectName}<`);
46
+
47
+ writeFileSync(targetPath, template, 'utf8');
48
+ console.log(`✅ [日常开发模式] 轻量看板已生成 (仅 ~69KB): ${targetPath}`);
49
+ console.log(`💡 用浏览器打开后,点击右上角「连接本地目录」选择 .agents/notes,后续新建/修改笔记切回浏览器即可自动热刷新!`);
50
+ process.exit(0);
51
+ }
52
+
53
+ // 打包模式(生成内联完整数据的单文件,如 demo.html)
54
+ const notesDir = cleanArgs[0] ? resolve(cleanArgs[0]) : resolve(process.cwd(), '.agents/notes');
55
+ const outputPath = cleanArgs[1] ? resolve(cleanArgs[1]) : resolve(process.cwd(), 'demo.html');
56
+ const projectName = cleanArgs[2] || '工程决策看板';
57
+
58
+ checkTargetSafety(outputPath);
59
+
60
+ const LIFECYCLES = ['implemented', 'proposed', 'rejected', 'archived'];
61
+
62
+ if (!existsSync(notesDir)) {
63
+ console.error(`❌ Notes directory not found at: ${notesDir}`);
64
+ process.exit(1);
65
+ }
66
+
67
+ /** Blank out fenced code-block lines so `## ` headings and [](.md) links inside them are never parsed as note structure. */
68
+ function stripFencedBlocks(raw: string): string {
69
+ let inFence = false;
70
+ return raw
71
+ .replace(/\r\n?/g, "\n")
72
+ .split("\n")
73
+ .map((l) => {
74
+ if (/^\s{0,3}```/.test(l)) {
75
+ inFence = !inFence;
76
+ return "";
77
+ }
78
+ return inFence ? "" : l;
79
+ })
80
+ .join("\n");
81
+ }
82
+
83
+ function parseNoteContent(raw: string, relPath: string, slugToId: Map<string, string>) {
84
+ const parseable = stripFencedBlocks(raw);
85
+ const slug = relPath.split('/').pop()!.replace(/\.md$/, '');
86
+ const parts = relPath.split('/');
87
+ const lifecycle = parts[0];
88
+ const cls = parts[1] || 'architecture';
89
+
90
+ let title = slug;
91
+ let status = lifecycle;
92
+ let date = '';
93
+
94
+ const dMatch = /^(\d{4}-\d{2}-\d{2})/.exec(slug);
95
+ if (dMatch) date = dMatch[1];
96
+
97
+ const h1Match = /^# Agent Note[^::]*[::]\s*(.*)$/m.exec(parseable);
98
+ if (h1Match) title = h1Match[1].trim();
99
+
100
+ function extractSection(secNamePattern: string) {
101
+ const re = new RegExp(`^## (?:${secNamePattern})\\s*\\n+([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm');
102
+ const m = re.exec(parseable);
103
+ return m ? m[1].trim() : '';
104
+ }
105
+
106
+ const problem = extractSection('Problem|问题');
107
+ const decision = extractSection('Decision|Proposal|决策|提案');
108
+ const alternatives = extractSection('Alternatives considered|曾考虑的替代方案|曾考虑的备选');
109
+ const consequences = extractSection('Consequences|后果');
110
+
111
+ const links: string[] = [];
112
+ for (const m of parseable.matchAll(/\]\(([^)]+\.md)\)/g)) {
113
+ let targetHref = m[1].split('#')[0].trim();
114
+ if (targetHref.includes('://')) continue;
115
+ targetHref = targetHref.replace(/\.zh\.md$/, '.md');
116
+ const segs = `${lifecycle}/${cls}/${targetHref}`.split('/');
117
+ const resolved: string[] = [];
118
+ for (const s of segs) {
119
+ if (!s || s === '.') continue;
120
+ if (s === '..') resolved.pop();
121
+ else resolved.push(s);
122
+ }
123
+ const cleanTarget = resolved.join('/');
124
+
125
+ let finalTarget = '';
126
+ if (slugToId.has(cleanTarget)) {
127
+ finalTarget = slugToId.get(cleanTarget)!;
128
+ } else {
129
+ const targetSlug = targetHref.split('/').pop()!.replace(/\.md$/, '');
130
+ if (slugToId.has(targetSlug)) {
131
+ finalTarget = slugToId.get(targetSlug)!;
132
+ }
133
+ }
134
+ if (finalTarget && finalTarget !== relPath) {
135
+ links.push(finalTarget);
136
+ }
137
+ }
138
+
139
+ return {
140
+ id: relPath,
141
+ slug,
142
+ lifecycle,
143
+ cls,
144
+ date,
145
+ title,
146
+ status,
147
+ problem,
148
+ decision,
149
+ alternatives,
150
+ consequences,
151
+ outLinks: [...new Set(links)],
152
+ rawBody: raw,
153
+ };
154
+ }
155
+
156
+ function walk(dir: string, baseDir: string = dir): any[] {
157
+ const noteFiles = new Map<string, string>(); // relPath -> fullPath
158
+
159
+ function scan(currentDir: string) {
160
+ const items = readdirSync(currentDir, { withFileTypes: true });
161
+ for (const entry of items) {
162
+ if (entry.name.startsWith('.')) continue;
163
+ const fullPath = join(currentDir, entry.name);
164
+
165
+ if (entry.isDirectory()) {
166
+ scan(fullPath);
167
+ } else if (entry.isFile() && entry.name.endsWith('.md')) {
168
+ const isZh = entry.name.endsWith('.zh.md');
169
+ const relPath = relative(baseDir, fullPath).replace(/\\/g, '/');
170
+ const cleanRel = isZh ? relPath.replace(/\.zh\.md$/, '.md') : relPath;
171
+ const parts = cleanRel.split('/');
172
+
173
+ if (parts.length >= 3 && LIFECYCLES.includes(parts[0])) {
174
+ if (!noteFiles.has(cleanRel) || isZh) {
175
+ noteFiles.set(cleanRel, fullPath);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ scan(dir);
183
+
184
+ const slugToId = new Map<string, string>();
185
+ for (const relPath of noteFiles.keys()) {
186
+ const slug = relPath.split('/').pop()!.replace(/\.md$/, '');
187
+ slugToId.set(slug, relPath);
188
+ slugToId.set(relPath, relPath);
189
+ }
190
+
191
+ const results: any[] = [];
192
+ for (const [relPath, fullPath] of noteFiles.entries()) {
193
+ try {
194
+ const raw = readFileSync(fullPath, 'utf8');
195
+ results.push(parseNoteContent(raw, relPath, slugToId));
196
+ } catch (err) {
197
+ console.warn(`⚠️ Error reading ${relPath}:`, err);
198
+ }
199
+ }
200
+
201
+ return results;
202
+ }
203
+
204
+ console.log(`🔍 [打包模式] 正在扫描笔记目录: ${notesDir}`);
205
+ const notes = walk(notesDir);
206
+ console.log(`✅ 解析完成,共发现 ${notes.length} 篇有效 Agent Notes。`);
207
+
208
+ if (isMetadataOnly) {
209
+ console.log('🔒 [安全脱敏] 启用 --metadata-only 模式,已剥离所有笔记的具体正文和详细论证,仅保留决策元数据与关联拓扑。');
210
+ for (const n of notes) {
211
+ n.problem = '[正文已脱敏]';
212
+ n.decision = '[正文已脱敏]';
213
+ n.alternatives = '';
214
+ n.consequences = '';
215
+ n.rawBody = `# Agent Note: ${n.title}\n\nStatus: ${n.status}\n\n<!-- content redacted for public demo -->`;
216
+ }
217
+ } else {
218
+ console.log('⚠️ [安全提示] 正在生成包含完整正文的数据包。如需对外公开发布且避免泄露内部决策细节,请添加 --metadata-only 参数。');
219
+ }
220
+
221
+ let template = readFileSync(templatePath, 'utf8');
222
+
223
+ // 注入项目名
224
+ template = template.replace('id="brand-project-title">工程决策看板<', () => `id="brand-project-title">${projectName}<`);
225
+
226
+ // 注入数据
227
+ const jsonSafe = JSON.stringify(notes).replace(/</g, '\\u003c');
228
+ template = template.replace(
229
+ 'window.__INLINE_DATA__ = null;',
230
+ () => `window.__INLINE_DATA__ = ${jsonSafe};`
231
+ );
232
+
233
+ writeFileSync(outputPath, template, 'utf8');
234
+ console.log(`🎉 [打包完成] 看板已生成: ${outputPath}`);
235
+ console.log(`💡 该文件内置了 ${notes.length} 篇笔记数据,可脱机分发、离线演示或部署至 GitHub Pages。`);
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Soft report: physical reverse anchors (`// Note: ... 见 .agents/notes/...`).
3
+ * A. anchors in source code pointing at notes that do not exist (dangling)
4
+ * B. implemented notes with zero inbound code anchors (suspected missing anchor)
5
+ * Exit code is always 0 — this is an advisory report, not a gate.
6
+ * Env: AGENT_NOTE_CODE_ROOT (default: cwd) — source root to scan.
7
+ * Run: npx tsx <skill-dir>/scripts/check-note-anchors.ts
8
+ */
9
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { agentNoteRoot, walkAgentNoteTree } from "./agent-note-tree.ts";
12
+
13
+ const codeRoot = resolve(process.env.AGENT_NOTE_CODE_ROOT || process.cwd());
14
+ const CODE_EXT = new Set([
15
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
16
+ ".py", ".go", ".rs", ".java", ".kt", ".c", ".cc", ".cpp", ".h", ".hpp",
17
+ ".cs", ".rb", ".swift", ".vue", ".svelte", ".php",
18
+ ]);
19
+ const SKIP_DIR = new Set([
20
+ "node_modules", ".git", "dist", "build", "out", "coverage", ".next",
21
+ ".cache", ".agents", "vendor", "target",
22
+ ]);
23
+ const ANCHOR_RE = /(?:\/\/|#|\/\*|\*\s*|<!--\s*)\s*Note[::]/;
24
+ const PATH_RE = /\.agents\/notes\/([^\s'"`)>]+?\.md)/;
25
+
26
+ const anchorHits = new Map<string, string[]>(); // note rel (from notes root, lowercased basename fallback) -> [code rel:line]
27
+ const dangling: string[] = [];
28
+ const notePathRefs: string[] = [];
29
+
30
+ function walk(dir: string, depth: number) {
31
+ if (depth > 12) return;
32
+ let entries;
33
+ try {
34
+ entries = readdirSync(dir, { withFileTypes: true });
35
+ } catch {
36
+ return;
37
+ }
38
+ for (const entry of entries) {
39
+ if (entry.name.startsWith(".") && entry.name !== ".agents") continue;
40
+ const full = join(dir, entry.name);
41
+ if (entry.isDirectory()) {
42
+ if (!SKIP_DIR.has(entry.name)) walk(full, depth + 1);
43
+ continue;
44
+ }
45
+ const dot = entry.name.lastIndexOf(".");
46
+ if (dot === -1 || !CODE_EXT.has(entry.name.slice(dot))) continue;
47
+ const rel = full.slice(codeRoot.length + 1);
48
+ const lines = readFileSync(full, "utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n");
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const l = lines[i];
51
+ if (!ANCHOR_RE.test(l)) continue;
52
+ const m = PATH_RE.exec(l);
53
+ if (!m) {
54
+ notePathRefs.push(`${rel}:${i + 1} — Note anchor without a .agents/notes path`);
55
+ continue;
56
+ }
57
+ const noteRel = m[1].split("#")[0].replace(/^\.\//, "");
58
+ const key = noteRel.replace(/\\/g, "/");
59
+ const list = anchorHits.get(key) ?? [];
60
+ list.push(`${rel}:${i + 1}`);
61
+ anchorHits.set(key, list);
62
+ if (!existsSync(join(agentNoteRoot, key))) {
63
+ dangling.push(`${rel}:${i + 1} — .agents/notes/${key} does not exist`);
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ walk(codeRoot, 0);
70
+
71
+ // implemented notes with zero inbound anchors
72
+ const { notes } = walkAgentNoteTree();
73
+ const implemented = notes.filter((n) => n.lifecycle === "implemented");
74
+ // basename -> note count: a slug shared by several notes is ambiguous and
75
+ // must not let one note's anchor count for another.
76
+ const slugCount = new Map<string, number>();
77
+ for (const n of notes) {
78
+ const slug = n.rel.split("/").pop()!;
79
+ slugCount.set(slug, (slugCount.get(slug) ?? 0) + 1);
80
+ }
81
+ const unanchored: string[] = [];
82
+ for (const n of implemented) {
83
+ const slug = n.rel.split("/").pop()!;
84
+ const direct = anchorHits.get(n.rel) ?? anchorHits.get(n.rel.replace(/\\/g, "/"));
85
+ const bySlug = [...anchorHits.keys()].filter((k) => k === slug || k.endsWith("/" + slug));
86
+ if (!direct && (bySlug.length === 0 || (slugCount.get(slug) ?? 0) > 1)) unanchored.push(n.rel);
87
+ }
88
+
89
+ console.log(`scanned: ${codeRoot}`);
90
+ console.log(`anchors found: ${[...anchorHits.values()].reduce((a, b) => a + b.length, 0)} across ${anchorHits.size} note(s)`);
91
+ if (notePathRefs.length) {
92
+ console.log(`\n[anchors without note path] ${notePathRefs.length}`);
93
+ for (const r of notePathRefs.slice(0, 20)) console.log(` - ${r}`);
94
+ }
95
+ if (dangling.length) {
96
+ console.log(`\n[dangling anchors] ${dangling.length}`);
97
+ for (const d of dangling.slice(0, 20)) console.log(` - ${d}`);
98
+ } else {
99
+ console.log("\n[dangling anchors] none");
100
+ }
101
+ console.log(`\n[implemented notes without any code anchor] ${unanchored.length} of ${implemented.length}`);
102
+ for (const u of unanchored.slice(0, 20)) console.log(` - ${u}`);
103
+ if (unanchored.length > 20) console.log(` ... and ${unanchored.length - 20} more`);
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Enforce Agent Note headers, lifecycle-specific sections, alternatives.
3
+ * Implemented notes may not carry proposal-era H2s; present tense in the
4
+ * body is a prose rule, not a lexical scan.
5
+ * Run: npx tsx <skill-dir>/scripts/verify-agent-note-format.ts
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { agentNoteRoot, walkAgentNoteTree } from "./agent-note-tree.ts";
10
+
11
+ const STATUS: Record<string, RegExp> = {
12
+ proposed: /^Status: proposed$|^状态[::] ?(?:proposed|已提议)$/,
13
+ implemented: /^Status: implemented$|^状态[::] ?(?:implemented|已实现)$/,
14
+ rejected: /^Status: rejected — .+$|^状态[::] ?(?:rejected|已否决) — .+$/,
15
+ };
16
+
17
+ const PROBLEM_FIRST = ["## Problem", "## 问题"];
18
+
19
+ const REQUIRED: Record<string, string[][]> = {
20
+ proposed: [
21
+ ["## Proposal", "## 提议", "## 方案", "## 提案"],
22
+ ["## Acceptance criteria", "## 验收标准", "## 验收条件", "## 接受标准"],
23
+ ["## Risks", "## 风险"],
24
+ ],
25
+ implemented: [
26
+ ["## Decision", "## 决定", "## 决策"],
27
+ ["## Consequences", "## 后果", "## 影响", "## 结果", "## 结果与代价", "## 影响与验证", "## 后果与边界"],
28
+ ],
29
+ rejected: [
30
+ ["## Proposal", "## 提议", "## 方案", "## 提案"],
31
+ ],
32
+ };
33
+
34
+ const BANNED_IMPLEMENTED = new Set([
35
+ "## proposal", "## plan", "## migration plan", "## acceptance criteria",
36
+ "## 提议", "## 方案", "## 提案",
37
+ "## 计划", "## 规划", "## 迁移计划",
38
+ "## 验收标准", "## 验收条件", "## 接受标准",
39
+ ]);
40
+
41
+ const ALTERNATIVES_RE = /^## (?:Alternatives considered|.{0,8}?(?:替代方案|备选(?:方案)?|其他做法))$/;
42
+ const FORMAT_ADOPTED = "2026-07-05";
43
+ const GRANDFATHER = "<!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) -->";
44
+
45
+ /** Strip trailing parenthetical: `## Decision(说明)` → `## Decision`. */
46
+ function headingBase(h: string): string {
47
+ return h.replace(/[((].*$/, "").trimEnd();
48
+ }
49
+
50
+ interface MaskedSource {
51
+ lines: string[];
52
+ /** line indexes inside fenced code blocks */
53
+ fenced: boolean[];
54
+ /** line indexes inside HTML comments */
55
+ commented: boolean[];
56
+ }
57
+
58
+ /** Normalize BOM/CRLF, then mask fenced code and HTML comment regions. */
59
+ function maskSource(raw: string): MaskedSource {
60
+ const normalized = raw.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
61
+ const lines = normalized.split("\n");
62
+ const fenced = new Array<boolean>(lines.length).fill(false);
63
+ const commented = new Array<boolean>(lines.length).fill(false);
64
+ let inFence = false;
65
+ let inComment = false;
66
+ for (let i = 0; i < lines.length; i++) {
67
+ const l = lines[i];
68
+ fenced[i] = inFence;
69
+ commented[i] = inComment;
70
+ // A fence opens/closes only on its own line (≤3 leading spaces); fence
71
+ // mentions inside inline code or prose never toggle state.
72
+ if (!inComment && /^\s{0,3}```/.test(l)) {
73
+ fenced[i] = true;
74
+ inFence = !inFence;
75
+ } else if (inFence) {
76
+ fenced[i] = true;
77
+ }
78
+ // Fence interiors are opaque: a `<!--` in a HTML/JSX sample must not
79
+ // leak the comment mask past the closing fence.
80
+ if (inFence) continue;
81
+ const openIdx = inComment ? -1 : l.indexOf("<!--");
82
+ const closeIdx = l.indexOf("-->");
83
+ if (inComment) {
84
+ commented[i] = true;
85
+ if (closeIdx !== -1) inComment = false;
86
+ } else if (openIdx !== -1) {
87
+ commented[i] = true;
88
+ if (closeIdx === -1 || closeIdx < openIdx) inComment = true;
89
+ }
90
+ }
91
+ return { lines, fenced, commented };
92
+ }
93
+
94
+ function isProseLine(src: MaskedSource, i: number): boolean {
95
+ if (src.fenced[i] || src.commented[i]) return false;
96
+ return !/^\s*>/.test(src.lines[i]);
97
+ }
98
+
99
+ const { notes, errors } = walkAgentNoteTree();
100
+
101
+ for (const note of notes) {
102
+ const fail = (msg: string) => { errors.push(`format: ${note.rel} — ${msg}`); };
103
+ const src = maskSource(readFileSync(resolve(agentNoteRoot, note.rel), "utf8"));
104
+ const { lines } = src;
105
+ const proseIdx: number[] = [];
106
+ for (let i = 0; i < lines.length; i++) if (isProseLine(src, i)) proseIdx.push(i);
107
+ const prose = proseIdx.map((i) => lines[i]);
108
+
109
+ // Halfwidth or fullwidth colon (IME often inserts :)
110
+ if (!/^# Agent Note[::] ?\S/.test(lines[0] ?? "")) fail("line 1 must be `# Agent Note: <title>`");
111
+ if (lines[1] !== "") fail("line 2 must be blank");
112
+
113
+ const re = STATUS[note.lifecycle];
114
+ if (re && !re.test(lines[2] ?? "")) fail(`line 3 must match ${note.lifecycle} status grammar (${String(re)})`);
115
+ if (lines[3] !== "") fail("line 4 must be blank");
116
+
117
+ // single Status line (fences, quotes and comments immune). Match strict
118
+ // status-shaped lines only — body prose like "Status: 200 means OK" must
119
+ // not be miscounted as a duplicate status header.
120
+ const looksLikeStatusLine = (l: string) => {
121
+ const t = l.trim();
122
+ return Object.values(STATUS).some((re) => re.test(t));
123
+ };
124
+ const statusCount = prose.filter(looksLikeStatusLine).length;
125
+ if (statusCount !== 1) fail("Status: line must appear exactly once");
126
+
127
+ const h2s = prose.filter((l: string) => l.startsWith("## ")).map((l: string) => l.trimEnd());
128
+ const bases = h2s.map(headingBase);
129
+ if (!PROBLEM_FIRST.includes(headingBase(h2s[0] ?? ""))) {
130
+ fail(`first section must be ## Problem or ## 问题 (got ${JSON.stringify(h2s[0] ?? "<none>")})`);
131
+ }
132
+
133
+ for (const group of REQUIRED[note.lifecycle] ?? []) {
134
+ if (!group.some((h: string) => bases.includes(h))) fail(`missing one of ${JSON.stringify(group)}`);
135
+ }
136
+
137
+ if (note.lifecycle === "implemented") {
138
+ for (const h of bases.filter((x: string) => BANNED_IMPLEMENTED.has(x.toLowerCase()))) {
139
+ fail(`banned in implemented: ${h}`);
140
+ }
141
+ }
142
+
143
+ const hasSection = bases.some((h: string) => ALTERNATIVES_RE.test(h));
144
+ const rawText = readFileSync(resolve(agentNoteRoot, note.rel), "utf8");
145
+ const hasGrandfather = rawText.includes(GRANDFATHER);
146
+ if (hasSection && hasGrandfather) fail("carries both ## Alternatives considered and grandfather comment — drop the comment");
147
+ if (!hasSection && !hasGrandfather) {
148
+ fail("missing ## Alternatives considered / ## 备选方案");
149
+ }
150
+ if (hasGrandfather && note.date >= FORMAT_ADOPTED) fail(`grandfather comment only valid before ${FORMAT_ADOPTED}`);
151
+ }
152
+
153
+ if (errors.length) {
154
+ for (const e of errors) console.error(e);
155
+ process.exit(1);
156
+ }
157
+
158
+ console.log(`ok: ${notes.length} note(s) verified`);