agentic-flow-kit 0.2.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/bin/flow-kit.mjs +4 -0
  4. package/modules/gates/dotnet-ca/README.md +24 -0
  5. package/modules/gates/dotnet-ca/check-architecture.sh +128 -0
  6. package/modules/gates/dotnet-ca/local-pre-commit.line +1 -0
  7. package/modules/hosts/omp/agents/implementer.md +12 -0
  8. package/modules/hosts/omp/agents/independent-reviewer.md +11 -0
  9. package/modules/hosts/omp/agents/ui-verifier.md +12 -0
  10. package/modules/hosts/opencode/agents/implementer.md +14 -0
  11. package/modules/hosts/opencode/agents/independent-reviewer.md +10 -0
  12. package/modules/hosts/opencode/agents/ui-verifier.md +10 -0
  13. package/modules/hosts/opencode/commands/build.md +13 -0
  14. package/modules/hosts/opencode/commands/deploy.md +12 -0
  15. package/modules/hosts/opencode/commands/design.md +12 -0
  16. package/modules/hosts/opencode/commands/maintain.md +12 -0
  17. package/modules/hosts/opencode/commands/new-task.md +12 -0
  18. package/modules/hosts/opencode/commands/plan.md +12 -0
  19. package/modules/hosts/opencode/commands/review.md +12 -0
  20. package/modules/hosts/opencode/commands/test.md +14 -0
  21. package/modules/hosts/trae/agents/implementer.md +9 -0
  22. package/modules/hosts/trae/agents/independent-reviewer.md +8 -0
  23. package/modules/hosts/trae/agents/ui-verifier.md +12 -0
  24. package/modules/hosts/trae/commands/wf-build.md +14 -0
  25. package/modules/hosts/trae/commands/wf-deploy.md +13 -0
  26. package/modules/hosts/trae/commands/wf-design.md +13 -0
  27. package/modules/hosts/trae/commands/wf-maintain.md +13 -0
  28. package/modules/hosts/trae/commands/wf-new-task.md +13 -0
  29. package/modules/hosts/trae/commands/wf-plan.md +13 -0
  30. package/modules/hosts/trae/commands/wf-review.md +13 -0
  31. package/modules/hosts/trae/commands/wf-test.md +14 -0
  32. package/modules/hosts/trae/hooks/post-edit-check.cjs +87 -0
  33. package/modules/hosts/trae/hooks/pre-shell-check.cjs +151 -0
  34. package/modules/hosts/trae/hooks/resolve-shell.cjs +21 -0
  35. package/modules/hosts/trae/hooks.json +29 -0
  36. package/modules/hosts/trae/rules/beautify-input-chinese.md +6 -0
  37. package/modules/hosts/trae/rules/git-commit-message.md +10 -0
  38. package/modules/hosts/trae/rules/project-workflow.md +14 -0
  39. package/modules/hosts/zcode/agents/implementer.md +11 -0
  40. package/modules/hosts/zcode/agents/independent-reviewer.md +10 -0
  41. package/modules/hosts/zcode/agents/ui-verifier.md +10 -0
  42. package/package.json +34 -0
  43. package/src/add-gate.mjs +83 -0
  44. package/src/add-host.mjs +63 -0
  45. package/src/cli.mjs +81 -0
  46. package/src/doctor.mjs +150 -0
  47. package/src/init.mjs +190 -0
  48. package/src/profiles.mjs +101 -0
  49. package/src/render.mjs +68 -0
  50. package/src/run-tests.mjs +35 -0
  51. package/src/sync.mjs +141 -0
  52. package/src/sync.test.mjs +207 -0
  53. package/templates/.gitattributes +6 -0
  54. package/templates/AGENTS.md +52 -0
  55. package/templates/_agents/board/index.html +382 -0
  56. package/templates/_agents/board/marked.min.js +6 -0
  57. package/templates/_agents/cache/kb-index.json +1 -0
  58. package/templates/_agents/commands/build.md +74 -0
  59. package/templates/_agents/commands/deploy.md +65 -0
  60. package/templates/_agents/commands/design.md +54 -0
  61. package/templates/_agents/commands/maintain.md +91 -0
  62. package/templates/_agents/commands/new-task.md +73 -0
  63. package/templates/_agents/commands/plan.md +46 -0
  64. package/templates/_agents/commands/review.md +59 -0
  65. package/templates/_agents/commands/test.md +80 -0
  66. package/templates/_agents/hooks/check-pairing-incremental.sh +29 -0
  67. package/templates/_agents/hooks/check-wiki-ledger.sh +18 -0
  68. package/templates/_agents/hooks/commit-check.cjs +140 -0
  69. package/templates/_agents/hooks/local-pre-commit +6 -0
  70. package/templates/_agents/notes/runtime-env.md +18 -0
  71. package/templates/_agents/roles/implementer.md +46 -0
  72. package/templates/_agents/roles/independent-reviewer.md +43 -0
  73. package/templates/_agents/roles/ui-verifier.md +42 -0
  74. package/templates/_agents/rule-budgets.txt +8 -0
  75. package/templates/_agents/scripts/agg-delegations.cjs +175 -0
  76. package/templates/_agents/scripts/check-loop.sh +803 -0
  77. package/templates/_agents/scripts/check-loop.test.sh +1042 -0
  78. package/templates/_agents/scripts/ensure-board.ps1 +48 -0
  79. package/templates/_agents/scripts/gen-wiki-board.mjs +197 -0
  80. package/templates/_agents/scripts/gen-wiki-board.test.mjs +136 -0
  81. package/templates/_agents/scripts/gen-workflow-index.mjs +190 -0
  82. package/templates/_agents/scripts/gen-workflow-index.test.mjs +113 -0
  83. package/templates/_agents/scripts/gen-workflow-metrics.mjs +172 -0
  84. package/templates/_agents/scripts/gen-workflow-metrics.test.mjs +101 -0
  85. package/templates/_agents/scripts/kb-cache-evict.mjs +50 -0
  86. package/templates/_agents/scripts/kb-cache-evict.test.mjs +104 -0
  87. package/templates/_agents/scripts/kb-search.mjs +307 -0
  88. package/templates/_agents/scripts/kb-search.test.mjs +153 -0
  89. package/templates/_agents/scripts/rule-budget.sh +68 -0
  90. package/templates/_agents/scripts/verify-wiki-consistency.mjs +192 -0
  91. package/templates/_agents/scripts/wiki-search.mjs +19 -0
  92. package/templates/_agents/scripts/workflow-board-server.mjs +244 -0
  93. package/templates/_agents/skills/verify-ui/SKILL.md +24 -0
  94. package/templates/_agents/skills/wiki/SKILL.md +104 -0
  95. package/templates/_agents/workflow-modules.txt +11 -0
  96. package/templates/_githooks/commit-msg +15 -0
  97. package/templates/_githooks/post-commit +10 -0
  98. package/templates/_githooks/pre-commit +58 -0
  99. package/templates/_githooks/pre-merge-commit +24 -0
  100. package/templates/_githooks/pre-push +32 -0
  101. package/templates/wiki/INDEX.md +23 -0
  102. package/templates/wiki/drafts-archive/README.md +3 -0
  103. package/templates/wiki//347/237/245/350/257/206/346/262/211/346/267/200/346/200/273/350/247/210.html +56 -0
  104. package/templates/workflow/README.md +52 -0
  105. package/templates/workflow/delegations.md +28 -0
  106. package/templates/workflow/incidents/_TEMPLATE.md +42 -0
  107. package/templates/workflow/intents/_TEMPLATE.md +40 -0
  108. package/templates/workflow/papercuts.md +7 -0
  109. package/templates/workflow/plans/_TEMPLATE.md +38 -0
  110. package/templates/workflow/regression-checklist.md +22 -0
  111. package/templates/workflow/specs/_TEMPLATE.md +41 -0
@@ -0,0 +1,48 @@
1
+ # ensure-board.ps1 — 任务入口幂等拉起 workflow 看板(只起不关:Windows 重启即天然回收,无需 stop/status)
2
+ # 行为:探活(端口占用者须响应 /api/board 才认定为看板,否则不动手、报占用者并 exit 1)
3
+ # → 前端或服务端任一代码早于进程 = 旧代码自动 kill 重启 → 全新启动成功后弹浏览器(重启/已运行不弹,SSE 自动重连)
4
+ # 用法:powershell -NoProfile -File .agents/scripts/ensure-board.ps1 [-Port 8933] [-NoOpen]
5
+ param(
6
+ [int]$Port = 8933,
7
+ [switch]$NoOpen
8
+ )
9
+ $ErrorActionPreference = 'Stop'
10
+ $agentsDir = Split-Path -Parent $PSScriptRoot
11
+ $server = Join-Path $PSScriptRoot 'workflow-board-server.mjs'
12
+ $boardHtml = Join-Path $agentsDir 'board\index.html'
13
+ $codeFiles = @($boardHtml, $server) # 前端 + 服务端:任一更新都算「旧代码」
14
+ $url = "http://127.0.0.1:$Port"
15
+
16
+ $proc = $null
17
+ $conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
18
+ if ($conn) { $proc = Get-Process -Id $conn.OwningProcess -ErrorAction SilentlyContinue }
19
+
20
+ $action = 'fresh start'
21
+ if ($proc) {
22
+ # 身份校验:只有能返回带 cards 字段 JSON 的才是看板,否则绝不 kill(可能误杀用户自己的服务)
23
+ $isBoard = $false
24
+ try {
25
+ $body = (Invoke-WebRequest -Uri "$url/api/board" -TimeoutSec 2).Content
26
+ $isBoard = $null -ne ($body | ConvertFrom-Json).PSObject.Properties['cards']
27
+ } catch { $isBoard = $false }
28
+ if (-not $isBoard) {
29
+ Write-Output "board: 端口 $Port 被非看板进程占用(pid $($proc.Id) $($proc.ProcessName)),未做任何改动;请确认该进程后自行处理"
30
+ exit 1
31
+ }
32
+ $newest = ($codeFiles | ForEach-Object { (Get-Item $_).LastWriteTime } | Sort-Object -Descending)[0]
33
+ $stale = $newest -gt $proc.StartTime.AddSeconds(2)
34
+ if (-not $stale) {
35
+ Write-Output "board: already up-to-date at $url (pid $($proc.Id))"
36
+ exit 0
37
+ }
38
+ Stop-Process -Id $proc.Id -Force
39
+ Start-Sleep -Milliseconds 500
40
+ $action = 'restarted (stale code detected)'
41
+ }
42
+
43
+ Start-Process -FilePath 'node' -ArgumentList "`"$server`" --port $Port" -WindowStyle Hidden
44
+ $deadline = (Get-Date).AddSeconds(8)
45
+ do { Start-Sleep -Milliseconds 300; $up = [bool](Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue) } while (-not $up -and (Get-Date) -lt $deadline)
46
+ if (-not $up) { Write-Output "board: FAILED to start on $Port"; exit 1 }
47
+ Write-Output "board: $action -> $url"
48
+ if (-not $NoOpen -and $action -eq 'fresh start') { Start-Process $url }
@@ -0,0 +1,197 @@
1
+ // wiki 索引生成器:磁盘 + INDEX 速览表「用途」列 → 自动重生成 INDEX 速览计数/映射表 + 看板 DATA
2
+ // 2026-09-13 引入:消除三层手工双写(短板①)。人工只维护两样:磁盘文件本身 + 速览表用途列。
3
+ // 用法:node .agents/scripts/gen-wiki-board.mjs [--dry-run] [--help]
4
+ // (收录/删除/移动 wiki 文件后必跑,随后跑 verify-wiki-consistency.mjs)
5
+ // 测试:node .agents/scripts/gen-wiki-board.test.mjs(fixture 回归,须全绿)
6
+ // 生成范围(锚点内整段重写,锚点外字节原样保留):
7
+ // INDEX.md 速览表数据行 / 「合计」行数字 / 「## 文件 → 主题 → 归属目录 映射表」到「## 命名规则」之间
8
+ // 看板 知识沉淀总览.html 的 `const DATA = {...};` 整块
9
+ // 注意:映射表节内的手工注记会被清除——背景说明请写进速览表「用途」列(人工列,生成器不覆盖)。
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { execSync } from 'node:child_process';
13
+
14
+ const USAGE = `用法:node .agents/scripts/gen-wiki-board.mjs [--dry-run] [--help]
15
+
16
+ 事实源:磁盘 wiki/<主题>/ 一层文件 + INDEX.md 速览表「用途」列(人工列)。重生成:
17
+ - wiki/INDEX.md:速览表数据行与文件计数、「合计」行数字、映射表节
18
+ (「## 文件 → 主题 → 归属目录 映射表」到「## 命名规则」之间整段)
19
+ - wiki/知识沉淀总览.html:const DATA = {...} 整块(summary 统计 + topics 文件清单 + dir 链接)
20
+ 两处生成区手改会被覆盖;锚点外内容原样保留。人工只维护磁盘文件 + 速览表「用途」列。
21
+
22
+ 选项:
23
+ --dry-run 只打印将生成内容的摘要与逐行差异,不写盘
24
+ --help 打印本说明`;
25
+
26
+ const args = process.argv.slice(2);
27
+ if (args.includes('--help') || args.includes('-h')) {
28
+ console.log(USAGE);
29
+ process.exit(0);
30
+ }
31
+ const unknown = args.filter((a) => a !== '--dry-run');
32
+ if (unknown.length) {
33
+ console.error(`❌ 未知参数:${unknown.join(' ')}\n\n${USAGE}`);
34
+ process.exit(1);
35
+ }
36
+ const DRY = args.includes('--dry-run');
37
+
38
+ const WIKI = 'wiki';
39
+ const INDEX_P = path.join(WIKI, 'INDEX.md');
40
+ const BOARD_P = path.join(WIKI, '知识沉淀总览.html');
41
+
42
+ // ---- 磁盘枚举(事实源:文件在哪个主题目录就属于哪个主题)----
43
+ // 登记口径(2026-09-17 起,与 verify-wiki-consistency.mjs 保持一致):
44
+ // 主题目录顶层:全量文件登记(现状不变);
45
+ // 直属子目录:仅登记 md/html 知识文档(排除 *.visual-check.* 工具产物;.json/.png 不计入);
46
+ // 数据维护主题例外——其子目录(schema变更/<批次>/ 等)走 README.md 台账,不参与登记。
47
+ const isKnowledgeDoc = (f) => /\.(md|html)$/i.test(f) && !/\.visual-check\./i.test(f);
48
+ const diskTopics = new Map(); // name -> [{ name: '文件名或 子目录/文件名', dir: 'wiki/主题[/子目录]/' }]
49
+ for (const d of fs.readdirSync(WIKI)) {
50
+ const p = path.join(WIKI, d);
51
+ if (!fs.statSync(p).isDirectory() || d === 'drafts-archive') continue;
52
+ const entries = fs.readdirSync(p)
53
+ .filter((f) => fs.statSync(path.join(p, f)).isFile())
54
+ .map((f) => ({ name: f, dir: `wiki/${d}/` }));
55
+ if (d !== '数据维护') {
56
+ for (const sub of fs.readdirSync(p)) {
57
+ const sp = path.join(p, sub);
58
+ if (!fs.statSync(sp).isDirectory()) continue;
59
+ for (const f of fs.readdirSync(sp)) {
60
+ if (fs.statSync(path.join(sp, f)).isFile() && isKnowledgeDoc(f)) {
61
+ entries.push({ name: `${sub}/${f}`, dir: `wiki/${d}/${sub}/` });
62
+ }
63
+ }
64
+ }
65
+ }
66
+ entries.sort((a, b) => a.name.localeCompare(b.name, 'zh'));
67
+ diskTopics.set(d, entries);
68
+ }
69
+ // gitignore 豁免(F4,incident 2026-09-23-code-review-f3-f4-closeout):与 verify-wiki-consistency.mjs 同口径——
70
+ // 排除 .gitignore 忽略的本机文件(如 *.xlsx),防止生成物计数在 fresh clone 漂移;git 不可用时退化为全量计数
71
+ const gitIgnoredArchive = (() => {
72
+ try {
73
+ const out = execSync('git -c core.quotepath=false status --ignored=matching --porcelain -uall -- wiki/drafts-archive',
74
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
75
+ const prefix = 'wiki/drafts-archive/';
76
+ return new Set(out.split(/\r?\n/)
77
+ .filter((l) => l.startsWith('!! '))
78
+ .map((l) => l.slice(3).trim().replace(/\/$/, ''))
79
+ .filter((p) => p.startsWith(prefix))
80
+ .map((p) => p.slice(prefix.length)));
81
+ } catch {
82
+ return new Set();
83
+ }
84
+ })();
85
+ const archiveCount = (() => {
86
+ let n = 0;
87
+ const walk = (dir, rel = '') => {
88
+ for (const e of fs.readdirSync(dir)) {
89
+ const p = path.join(dir, e);
90
+ const r = rel ? `${rel}/${e}` : e;
91
+ if (fs.statSync(p).isDirectory()) walk(p, r);
92
+ else if (!gitIgnoredArchive.has(r)) n++;
93
+ }
94
+ };
95
+ walk(path.join(WIKI, 'drafts-archive'));
96
+ return n;
97
+ })();
98
+
99
+ // ---- 读 INDEX 现速览表:主题顺序 + 用途列(人工,不覆盖)----
100
+ // 切行须容忍 CRLF:按 '\n' 切会在行尾留 '\r',速览表行锚定正则(\|$)整表失配 →
101
+ // 「用途」列(唯一人工维护位)被静默重置为 <待补>(papercut 2026-09-17;fixture 见 gen-wiki-board.test.mjs 场景 1)
102
+ const indexText = fs.readFileSync(INDEX_P, 'utf8');
103
+ const overview = []; // {name, usage}
104
+ for (const line of indexText.split(/\r?\n/)) {
105
+ const m = line.match(/^\| ([^|]+) \| (\d+) \|([^|]*)\|$/);
106
+ if (m && m[1].trim() !== '主题') overview.push({ name: m[1].trim(), usage: m[3].trim() });
107
+ }
108
+ // 主题顺序 = 速览表现顺序在前,磁盘新主题按中文排序追加
109
+ const ordered = [...overview.filter((o) => diskTopics.has(o.name))];
110
+ for (const t of [...diskTopics.keys()].sort((a, b) => a.localeCompare(b, 'zh'))) {
111
+ if (!ordered.some((o) => o.name === t)) ordered.push({ name: t, usage: '<待补:新主题用途描述>' });
112
+ }
113
+
114
+ const totalFiles = [...diskTopics.values()].reduce((n, f) => n + f.length, 0);
115
+
116
+ // ---- 生成 INDEX 三段 ----
117
+ const eol = indexText.includes('\r\n') ? '\r\n' : '\n';
118
+ const overviewRows = ordered.map((o) => `| ${o.name} | ${diskTopics.get(o.name).length} | ${o.usage} |`);
119
+ const mappingSection = ordered.map((o) => {
120
+ const rows = diskTopics.get(o.name).map((e) => `| ${e.name} | ${o.name} | ${e.dir} |`);
121
+ return [`### ${o.name}(${diskTopics.get(o.name).length})`, '', '| 文件名 | 主题 | 归属目录 |', '|---|---|---|', ...rows].join(eol);
122
+ }).join(eol + eol);
123
+
124
+ let out = indexText;
125
+ // 速览表数据行(表头「| 主题 |」+ 分隔行之后的连续数据行整组替换)
126
+ {
127
+ const lines = out.split(/\r?\n/);
128
+ const headIdx = lines.findIndex((l) => /^\| 主题 \|/.test(l));
129
+ if (headIdx < 0 || !/^[\|:\-\s]+$/.test(lines[headIdx + 1] || '')) {
130
+ console.error('❌ INDEX 速览表表头未找到,未写盘');
131
+ process.exit(1);
132
+ }
133
+ let lastData = headIdx + 1;
134
+ while (lastData + 1 < lines.length && lines[lastData + 1].startsWith('|')) lastData++;
135
+ const fixedRows = [...overviewRows, '| drafts-archive | — | 原文档备份(按 日期-主题 分目录) |'];
136
+ out = [...lines.slice(0, headIdx), '| 主题 | 文件数 | 用途 |', '|---|---|---|', ...fixedRows, ...lines.slice(lastData + 1)].join(eol);
137
+ }
138
+ // 合计行数字
139
+ out = out.replace(/合计:\*\*\d+ 份\*\*知识文档,\*\*\d+ 个主题\*\*/, `合计:**${totalFiles} 份**知识文档,**${diskTopics.size} 个主题**`);
140
+ // 映射表区(节标题之间整段重写)
141
+ {
142
+ const lines = out.split(/\r?\n/);
143
+ const s = lines.findIndex((l) => l.startsWith('## 文件 → 主题 → 归属目录 映射表'));
144
+ const e = lines.findIndex((l) => l.startsWith('## 命名规则'));
145
+ if (s >= 0 && e > s) {
146
+ out = [...lines.slice(0, s + 1), '', mappingSection, '', '---', '', ...lines.slice(e)].join(eol);
147
+ }
148
+ }
149
+
150
+ // ---- 生成看板 DATA ----
151
+ const boardText = fs.readFileSync(BOARD_P, 'utf8');
152
+ const topicsJs = ordered.map((o) => {
153
+ // dir 相对看板自身(wiki/)——旧值带 wiki/ 前缀会被解析为 wiki/wiki/... 死链
154
+ const files = diskTopics.get(o.name).map((e) => ` { "file": ${JSON.stringify(e.name)}, "topic": ${JSON.stringify(o.name)}, "dir": ${JSON.stringify(`./${e.dir.slice('wiki/'.length)}`)} }`);
155
+ return ` { "name": ${JSON.stringify(o.name)}, "files": [\n${files.join(',\n')}\n ]}`;
156
+ }).join(',\n');
157
+ const dataJs = `const DATA = {\n "summary": { "total": ${totalFiles}, "topics": ${diskTopics.size}, "files": ${totalFiles}, "archive": ${archiveCount} },\n "topics": [\n${topicsJs}\n ]\n};`;
158
+ const boardOut = boardText.replace(/const DATA = \{[\s\S]*?\n\};/, dataJs);
159
+ if (boardOut === boardText && !/const DATA = \{[\s\S]*?\n\};/.test(boardText)) {
160
+ console.error('❌ 看板中未找到 const DATA = {...} 块,未写盘');
161
+ process.exit(1);
162
+ }
163
+
164
+ // ---- 写盘(dry-run 只预览差异)----
165
+ if (DRY) {
166
+ console.log('🔎 --dry-run:未写盘,差异预览如下');
167
+ previewDiff('wiki/INDEX.md', indexText, out);
168
+ previewDiff('wiki/知识沉淀总览.html', boardText, boardOut);
169
+ } else {
170
+ fs.writeFileSync(INDEX_P, out, 'utf8');
171
+ fs.writeFileSync(BOARD_P, boardOut, 'utf8');
172
+ }
173
+
174
+ const tail = DRY ? '将重写' : '已重写';
175
+ console.log(`${DRY ? '🔎 [dry-run] 将生成' : '✅ 已生成'}:文件 ${totalFiles} 份 / 主题 ${diskTopics.size} 个 / 归档 ${archiveCount} 份;速览表 ${overviewRows.length} 行、映射表 ${ordered.length} 节、看板 DATA ${tail}`);
176
+ console.log(' 人工仅维护:磁盘文件 + 速览表「用途」列(新主题为 <待补> 的请补描述后重跑本脚本)');
177
+
178
+ // dry-run 差异预览:首尾对齐后取中段变更块(逐行),各方向最多显示 10 行
179
+ function previewDiff(label, before, after) {
180
+ if (before === after) {
181
+ console.log(`• ${label}:无变化`);
182
+ return;
183
+ }
184
+ const b = before.split(/\r?\n/);
185
+ const a = after.split(/\r?\n/);
186
+ let head = 0;
187
+ while (head < b.length && head < a.length && b[head] === a[head]) head++;
188
+ let tailN = 0;
189
+ while (tailN < b.length - head && tailN < a.length - head && b[b.length - 1 - tailN] === a[a.length - 1 - tailN]) tailN++;
190
+ const removed = b.slice(head, b.length - tailN);
191
+ const added = a.slice(head, a.length - tailN);
192
+ console.log(`• ${label}:第 ${head + 1} 行起变更,删 ${removed.length} 行 / 增 ${added.length} 行`);
193
+ for (const l of removed.slice(0, 10)) console.log(` - ${l.slice(0, 120)}`);
194
+ if (removed.length > 10) console.log(` - …(其余 ${removed.length - 10} 行略)`);
195
+ for (const l of added.slice(0, 10)) console.log(` + ${l.slice(0, 120)}`);
196
+ if (added.length > 10) console.log(` + …(其余 ${added.length - 10} 行略)`);
197
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // gen-wiki-board.mjs 的 fixture 驱动测试(2026-09-21 随 CRLF 解析缺陷修复引入)
3
+ // 现场构造临时 wiki 根,以「换 cwd」方式运行被测脚本(脚本按相对路径 'wiki/' 读盘,无需注入口)。
4
+ // 断言:①CRLF 下速览表「用途」列(唯一人工维护位)重生成后保持——papercut 2026-09-17 缺陷回归;
5
+ // ②行尾保持(CRLF 不被改写成 LF);③新增 wiki 文件后速览计数 / 合计行 / 看板 DATA 同步。
6
+ // 用法:node .agents/scripts/gen-wiki-board.test.mjs(在仓库任意目录执行均可)
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
14
+ const GEN = path.join(SCRIPT_DIR, 'gen-wiki-board.mjs');
15
+ const USAGE = '用法:node .agents/scripts/gen-wiki-board.test.mjs';
16
+
17
+ const USAGE_COL = '主题A 用途说明(人工列)';
18
+
19
+ let pass = 0;
20
+ let fail = 0;
21
+ const check = (desc, cond, detail = '') => {
22
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
23
+ else { fail++; console.log(`FAIL ${desc}${detail ? `\n${detail}` : ''}`); }
24
+ };
25
+
26
+ // 行尾统计:验证 CRLF fixture 不被改写成 LF
27
+ const eolStats = (p) => {
28
+ const b = fs.readFileSync(p);
29
+ let crlf = 0;
30
+ let lf = 0;
31
+ for (let i = 0; i < b.length; i++) {
32
+ if (b[i] === 10) { if (i > 0 && b[i - 1] === 13) crlf++; else lf++; }
33
+ }
34
+ return { crlf, lf };
35
+ };
36
+
37
+ // mkfix:建临时 wiki 根(INDEX 按 eol 拼装;看板 DATA 块固定 LF——与仓库现状一致)
38
+ const mkfix = (eol) => {
39
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gen-wiki-board-test-'));
40
+ fs.mkdirSync(path.join(root, 'wiki', '主题A'), { recursive: true });
41
+ fs.mkdirSync(path.join(root, 'wiki', 'drafts-archive'), { recursive: true });
42
+ fs.writeFileSync(path.join(root, 'wiki', '主题A', 'a.md'), '# a\n', 'utf8');
43
+ fs.writeFileSync(path.join(root, 'wiki', 'INDEX.md'), [
44
+ '# wiki 索引(fixture)',
45
+ '',
46
+ '| 主题 | 文件数 | 用途 |',
47
+ '|---|---|---|',
48
+ `| 主题A | 1 | ${USAGE_COL} |`,
49
+ '| drafts-archive | — | 原文档备份(按 日期-主题 分目录) |',
50
+ '',
51
+ '合计:**1 份**知识文档,**1 个主题**',
52
+ '',
53
+ '---',
54
+ '',
55
+ '## 文件 → 主题 → 归属目录 映射表',
56
+ '',
57
+ '### 主题A(1)',
58
+ '',
59
+ '| 文件名 | 主题 | 归属目录 |',
60
+ '|---|---|---|',
61
+ '| a.md | 主题A | wiki/主题A/ |',
62
+ '',
63
+ '---',
64
+ '',
65
+ '## 命名规则',
66
+ '',
67
+ '- fixture',
68
+ '',
69
+ ].join(eol), 'utf8');
70
+ fs.writeFileSync(path.join(root, 'wiki', '知识沉淀总览.html'), [
71
+ '<!doctype html><html><body>',
72
+ '<script>',
73
+ 'const DATA = {',
74
+ ' "summary": { "total": 1, "topics": 1, "files": 1, "archive": 0 },',
75
+ ' "topics": [',
76
+ ' { "name": "主题A", "files": [',
77
+ ' { "file": "a.md", "topic": "主题A", "dir": "./主题A/" }',
78
+ ' ]}',
79
+ ' ]',
80
+ '};',
81
+ '</script>',
82
+ '</body></html>',
83
+ '',
84
+ ].join('\n'), 'utf8');
85
+ return root;
86
+ };
87
+
88
+ const runGen = (root) => spawnSync(process.execPath, [GEN], { cwd: root, encoding: 'utf8' });
89
+
90
+ // ---- 场景 1:CRLF INDEX ——「用途」列重生成后保持 + 行尾保持(缺陷回归)----
91
+ {
92
+ const root = mkfix('\r\n');
93
+ const r = runGen(root);
94
+ const out = fs.readFileSync(path.join(root, 'wiki', 'INDEX.md'), 'utf8');
95
+ const { crlf, lf } = eolStats(path.join(root, 'wiki', 'INDEX.md'));
96
+ check('场景 1:CRLF INDEX 生成器 exit 0', r.status === 0, `exit=${r.status}\n${r.stdout}${r.stderr}`);
97
+ check(`场景 1:「用途」列保持(CRLF 致整表解析为空即丢——papercut 2026-09-17)`, out.includes(USAGE_COL), out.split('\n').slice(0, 10).join('\n'));
98
+ check('场景 1:未退化为 <待补>', !out.includes('<待补'), out.split('\n').slice(0, 10).join('\n'));
99
+ check('场景 1:行尾保持 CRLF(未被改写为 LF)', crlf > 0 && lf === 0, `crlf=${crlf} bareLF=${lf}`);
100
+ fs.rmSync(root, { recursive: true, force: true });
101
+ }
102
+
103
+ // ---- 场景 2:LF INDEX 对照 —— 用途保持(证明解析不依赖行尾)----
104
+ {
105
+ const root = mkfix('\n');
106
+ const r = runGen(root);
107
+ const out = fs.readFileSync(path.join(root, 'wiki', 'INDEX.md'), 'utf8');
108
+ const { crlf, lf } = eolStats(path.join(root, 'wiki', 'INDEX.md'));
109
+ check('场景 2:LF INDEX 生成器 exit 0', r.status === 0, `exit=${r.status}\n${r.stdout}${r.stderr}`);
110
+ check('场景 2:「用途」列保持', out.includes(USAGE_COL), out.split('\n').slice(0, 10).join('\n'));
111
+ check('场景 2:行尾保持 LF', lf > 0 && crlf === 0, `crlf=${crlf} bareLF=${lf}`);
112
+ fs.rmSync(root, { recursive: true, force: true });
113
+ }
114
+
115
+ // ---- 场景 3:新增 wiki 文件 —— 速览计数 / 合计行 / 看板 DATA 同步 ----
116
+ {
117
+ const root = mkfix('\r\n');
118
+ fs.writeFileSync(path.join(root, 'wiki', '主题A', 'b.md'), '# b\n', 'utf8');
119
+ const r = runGen(root);
120
+ const index = fs.readFileSync(path.join(root, 'wiki', 'INDEX.md'), 'utf8');
121
+ const board = fs.readFileSync(path.join(root, 'wiki', '知识沉淀总览.html'), 'utf8');
122
+ check('场景 3:新增文件后生成器 exit 0', r.status === 0, `exit=${r.status}\n${r.stdout}${r.stderr}`);
123
+ check('场景 3:速览表计数同步为 2', /\| 主题A \| 2 \|/.test(index), index.split('\n').slice(0, 8).join('\n'));
124
+ check('场景 3:合计行同步为 2 份', index.includes('合计:**2 份**知识文档,**1 个主题**'), index.split('\n').slice(0, 12).join('\n'));
125
+ check('场景 3:看板 DATA 同步 total=2', /"summary": \{ "total": 2/.test(board), '');
126
+ check('场景 3:「用途」列保持', index.includes(USAGE_COL), '');
127
+ check('场景 3:映射表节收录新文件', index.includes('| b.md | 主题A | wiki/主题A/ |'), '');
128
+ fs.rmSync(root, { recursive: true, force: true });
129
+ }
130
+
131
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
132
+ if (fail) {
133
+ console.log(`\n${USAGE}`);
134
+ process.exit(1);
135
+ }
136
+ process.exit(0);
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env node
2
+ // workflow 活跃层索引生成器(2026-09-21,intent 2026-09-21-workflow-doc-retrieval)
3
+ // 用法:node .agents/scripts/gen-workflow-index.mjs [--dry-run] [--check] [--help]
4
+ // --dry-run 只打印差异预览,不写盘
5
+ // --check 不写盘;与磁盘不一致时 exit 1 并打印差异(供 check-loop 漂移告警)
6
+ // 测试:node .agents/scripts/gen-workflow-index.test.mjs(fixture 回归,须全绿)
7
+ // 口径:扫磁盘(对照 gen-wiki-board.mjs 与看板);排除 _TEMPLATE.md。活跃 = draft / approved / open
8
+ // 逐行列出(在跑的工作);其余状态折叠进「档案计数」——fixed 未 closed 的 incident 与 approved
9
+ // 孤儿 plan 属「待收口」,看板会告警;查档案用 kb-search(默认含全部状态)。
10
+ // 生成区:INDEX.md 的 GENERATED:BEGIN/END 锚点注释之间整段重写,锚点外内容原样保留。
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ const USAGE = `用法:node .agents/scripts/gen-workflow-index.mjs [--dry-run] [--check] [--help]
15
+
16
+ 事实源:workflow/{intents,specs,plans,incidents}/*.md 的 frontmatter(状态/级别/日期/发现/模块)
17
+ + H1 标题 + 「## 验收标准」勾验统计;排除 _TEMPLATE.md。
18
+ 生成物:workflow/INDEX.md —— 活跃层逐行(类型|级别|状态|日期|模块|标题|验收)+ 档案计数(类型×模块)。
19
+ 选项:
20
+ --dry-run 只打印将生成内容的摘要与差异,不写盘
21
+ --check 不写盘;INDEX 与磁盘不一致时 exit 1(供 check-loop 漂移告警)
22
+ --help 打印本说明`;
23
+
24
+ const args = process.argv.slice(2);
25
+ if (args.includes('--help') || args.includes('-h')) { console.log(USAGE); process.exit(0); }
26
+ const unknown = args.filter((a) => !['--dry-run', '--check'].includes(a));
27
+ if (unknown.length) { console.error(`❌ 未知参数:${unknown.join(' ')}\n\n${USAGE}`); process.exit(1); }
28
+ const DRY = args.includes('--dry-run');
29
+ const CHECK = args.includes('--check');
30
+
31
+ const WORKFLOW = 'workflow';
32
+ const INDEX_P = path.join(WORKFLOW, 'INDEX.md');
33
+ const DOC_TYPES = ['intents', 'specs', 'plans', 'incidents'];
34
+ const TYPE_LABEL = { intents: 'INTENT', specs: 'SPEC', plans: 'PLAN', incidents: 'INCIDENT' };
35
+ const ACTIVE_STATUS = ['draft', 'approved', 'open']; // 其余(done/fixed/closed/superseded/cancelled)折叠进档案计数
36
+
37
+ const BEGIN = '<!-- GENERATED:BEGIN — gen-workflow-index.mjs 整段重写,手工说明写在本行之前 -->';
38
+ const END = '<!-- GENERATED:END -->';
39
+ const HEADER = `# workflow 索引 — 活跃层与档案(生成物,勿手改)
40
+
41
+ > 生成器 \`node .agents/scripts/gen-workflow-index.mjs\`(状态变更 / 新建文档后重跑;\`--check\` 校验漂移,check-loop 会告警)。
42
+ > 活跃 = draft / approved / open(在跑);其余状态折叠进档案计数,检索用 \`node .agents/scripts/kb-search.mjs "<词>"\`(默认含全部状态、活跃优先排序)。
43
+ > 模块词表见 \`.agents/workflow-modules.txt\`。
44
+ `;
45
+
46
+ // ---- frontmatter(受限子集:每行 `键: 值`)+ H1 标题(去「INTENT — 」前缀),对照 workflow/README.md 文档协议 ----
47
+ function parseDoc(text) {
48
+ const meta = {};
49
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
50
+ if (m) {
51
+ for (const line of m[1].split(/\r?\n/)) {
52
+ const kv = line.match(/^(\S+):\s*(.*)$/);
53
+ if (kv) meta[kv[1].trim()] = kv[2].trim();
54
+ }
55
+ }
56
+ const h1 = text.match(/^# (.+)$/m);
57
+ let title = h1 ? h1[1] : '';
58
+ const dash = title.match(/\S+\s*[—–-]+\s*(.+)$/);
59
+ if (dash) title = dash[1];
60
+ return { meta, title };
61
+ }
62
+
63
+ // ---- 验收勾验统计:仅「## 验收标准」节内(至下一个 ## 标题),无该节返回 null(对照看板口径)----
64
+ function parseAcceptance(text) {
65
+ const h = text.match(/^## 验收标准.*$/m);
66
+ if (!h) return null;
67
+ const rest = text.slice(h.index + h[0].length);
68
+ const next = rest.match(/^## /m);
69
+ const section = next ? rest.slice(0, next.index) : rest;
70
+ const total = (section.match(/^\s*[-*] \[[ xX]\]/gm) || []).length;
71
+ if (!total) return null;
72
+ const done = (section.match(/^\s*[-*] \[[xX]\]/gm) || []).length;
73
+ return { done, total };
74
+ }
75
+
76
+ const esc = (s) => String(s).replace(/\|/g, '\\|'); // 表格单元格内管道转义
77
+
78
+ function scan() {
79
+ const docs = [];
80
+ for (const type of DOC_TYPES) {
81
+ const dir = path.join(WORKFLOW, type);
82
+ for (const f of fs.readdirSync(dir)) {
83
+ if (!f.endsWith('.md') || f === '_TEMPLATE.md') continue;
84
+ const name = f.slice(0, -3);
85
+ const text = fs.readFileSync(path.join(dir, f), 'utf8');
86
+ const { meta, title } = parseDoc(text);
87
+ docs.push({
88
+ type,
89
+ file: `${type}/${f}`,
90
+ status: meta['状态'] || '(未填)',
91
+ level: meta['级别'] || '—',
92
+ date: meta['日期'] || meta['发现'] || name.slice(0, 10),
93
+ module: meta['模块'] || '—',
94
+ title: title || name,
95
+ accept: parseAcceptance(text),
96
+ });
97
+ }
98
+ }
99
+ return docs;
100
+ }
101
+
102
+ function render(docs) {
103
+ const active = docs.filter((d) => ACTIVE_STATUS.includes(d.status));
104
+ active.sort((a, b) => b.date.localeCompare(a.date) || a.type.localeCompare(b.type) || a.file.localeCompare(b.file));
105
+ const arch = docs.filter((d) => !ACTIVE_STATUS.includes(d.status));
106
+
107
+ const rows = active.map((d) =>
108
+ `| ${TYPE_LABEL[d.type]} | ${esc(d.level)} | ${esc(d.status)} | ${esc(d.date)} | ${esc(d.module)} | ${esc(d.title)} | ${d.accept ? `☑${d.accept.done}/${d.accept.total}` : '—'} |`);
109
+
110
+ const counts = new Map(); // `${type}|${module}` → 数量
111
+ for (const d of arch) {
112
+ const k = `${d.type}|${d.module}`;
113
+ counts.set(k, (counts.get(k) || 0) + 1);
114
+ }
115
+ const archRows = [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([k, n]) => {
116
+ const [type, module] = k.split('|');
117
+ return `| ${TYPE_LABEL[type]} | ${esc(module)} | ${n} |`;
118
+ });
119
+ const byStatus = new Map();
120
+ for (const d of arch) byStatus.set(d.status, (byStatus.get(d.status) || 0) + 1);
121
+ const statusLine = [...byStatus.entries()].sort((a, b) => b[1] - a[1]).map(([s, n]) => `${s} ${n}`).join(' · ');
122
+
123
+ return [
124
+ `## 活跃(${active.length})`,
125
+ '',
126
+ '| 类型 | 级别 | 状态 | 日期 | 模块 | 标题 | 验收 |',
127
+ '|---|---|---|---|---|---|---|',
128
+ ...rows,
129
+ '',
130
+ `## 档案计数(${arch.length},不进表)`,
131
+ '',
132
+ '| 类型 | 模块 | 数量 |',
133
+ '|---|---|---|',
134
+ ...archRows,
135
+ '',
136
+ `终态构成:${statusLine};查档案用 \`node .agents/scripts/kb-search.mjs "<词>" --status all\`。`,
137
+ ].join('\n');
138
+ }
139
+
140
+ // ---- 组装:锚点内整段重写,锚点外(含首次创建时的 HEADER)原样保留 ----
141
+ const docs = scan();
142
+ const existing = fs.existsSync(INDEX_P) ? fs.readFileSync(INDEX_P, 'utf8') : '';
143
+ const inner = `${BEGIN}\n\n${render(docs)}\n\n${END}`;
144
+ let out;
145
+ if (existing.includes(BEGIN) && existing.includes(END)) {
146
+ out = existing.slice(0, existing.indexOf(BEGIN)) + inner + existing.slice(existing.indexOf(END) + END.length);
147
+ } else {
148
+ out = (existing ? existing.replace(/\s*$/, '') + '\n\n' : HEADER) + inner + '\n';
149
+ }
150
+
151
+ const nActive = docs.filter((d) => ACTIVE_STATUS.includes(d.status)).length;
152
+ const nArch = docs.length - nActive;
153
+ const summary = `活跃 ${nActive} 行 / 档案 ${nArch} 篇 / ${Buffer.byteLength(out, 'utf8')} B`;
154
+
155
+ // 差异预览:首尾对齐后取中段变更块(逐行),各方向最多 10 行
156
+ function previewDiff(before, after) {
157
+ if (before === after) return;
158
+ const b = before.split(/\r?\n/);
159
+ const a = after.split(/\r?\n/);
160
+ let head = 0;
161
+ while (head < b.length && head < a.length && b[head] === a[head]) head++;
162
+ let tailN = 0;
163
+ while (tailN < b.length - head && tailN < a.length - head && b[b.length - 1 - tailN] === a[a.length - 1 - tailN]) tailN++;
164
+ const removed = b.slice(head, b.length - tailN);
165
+ const added = a.slice(head, a.length - tailN);
166
+ console.log(` 第 ${head + 1} 行起变更:删 ${removed.length} 行 / 增 ${added.length} 行`);
167
+ for (const l of removed.slice(0, 10)) console.log(` - ${l.slice(0, 140)}`);
168
+ if (removed.length > 10) console.log(` - …(其余 ${removed.length - 10} 行略)`);
169
+ for (const l of added.slice(0, 10)) console.log(` + ${l.slice(0, 140)}`);
170
+ if (added.length > 10) console.log(` + …(其余 ${added.length - 10} 行略)`);
171
+ }
172
+
173
+ if (CHECK) {
174
+ if (out === existing) {
175
+ console.log(`✅ workflow/INDEX.md 与磁盘一致(${summary})`);
176
+ process.exit(0);
177
+ }
178
+ console.error(`❌ workflow/INDEX.md 与磁盘不一致(${summary})——跑 node .agents/scripts/gen-workflow-index.mjs 重新生成`);
179
+ previewDiff(existing, out);
180
+ process.exit(1);
181
+ }
182
+
183
+ if (DRY) {
184
+ console.log(`🔎 --dry-run:未写盘(${summary})`);
185
+ if (out === existing) console.log('• workflow/INDEX.md:无变化');
186
+ else previewDiff(existing, out);
187
+ } else {
188
+ fs.writeFileSync(INDEX_P, out, 'utf8');
189
+ console.log(`✅ 已生成 workflow/INDEX.md:${summary}`);
190
+ }