agentic-flow-kit 0.2.0 → 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 (70) hide show
  1. package/README.md +4 -2
  2. package/modules/gates/dotnet-ca/check-architecture.sh +14 -10
  3. package/modules/hosts/trae/commands/wf-build.md +14 -14
  4. package/modules/hosts/trae/commands/wf-deploy.md +13 -13
  5. package/modules/hosts/trae/commands/wf-design.md +13 -13
  6. package/modules/hosts/trae/commands/wf-maintain.md +13 -13
  7. package/modules/hosts/trae/commands/wf-new-task.md +13 -13
  8. package/modules/hosts/trae/commands/wf-plan.md +13 -13
  9. package/modules/hosts/trae/commands/wf-review.md +13 -13
  10. package/modules/hosts/trae/commands/wf-test.md +14 -14
  11. package/modules/hosts/trae/hooks/post-edit-check.cjs +10 -0
  12. package/modules/hosts/trae/hooks/pre-shell-check.cjs +14 -4
  13. package/modules/hosts/trae/hooks/resolve-shell.cjs +30 -21
  14. package/modules/hosts/trae/rules/beautify-input-chinese.md +6 -6
  15. package/modules/hosts/trae/rules/git-commit-message.md +10 -10
  16. package/package.json +10 -2
  17. package/src/add-gate.mjs +8 -1
  18. package/src/add-host.mjs +4 -0
  19. package/src/cli.mjs +8 -5
  20. package/src/doctor.mjs +9 -2
  21. package/src/init.mjs +96 -12
  22. package/src/init.test.mjs +56 -0
  23. package/src/profiles.mjs +8 -0
  24. package/src/render.mjs +8 -9
  25. package/src/run-tests.mjs +1 -0
  26. package/src/sync.mjs +20 -4
  27. package/src/sync.test.mjs +40 -1
  28. package/templates/AGENTS.md +3 -2
  29. package/templates/_agents/board/index.html +449 -382
  30. package/templates/_agents/board/marked.min.js +6 -6
  31. package/templates/_agents/cache/kb-index.json +1 -1
  32. package/templates/_agents/commands/build.md +1 -1
  33. package/templates/_agents/commands/design.md +1 -1
  34. package/templates/_agents/commands/maintain.md +1 -1
  35. package/templates/_agents/commands/new-task.md +73 -73
  36. package/templates/_agents/commands/plan.md +2 -2
  37. package/templates/_agents/commands/test.md +2 -1
  38. package/templates/_agents/hooks/check-pairing-incremental.sh +30 -29
  39. package/templates/_agents/hooks/commit-check.cjs +6 -1
  40. package/templates/_agents/scripts/agg-delegations.cjs +35 -18
  41. package/templates/_agents/scripts/agg-delegations.test.mjs +95 -0
  42. package/templates/_agents/scripts/check-loop.sh +6 -0
  43. package/templates/_agents/scripts/check-loop.test.sh +1042 -1042
  44. package/templates/_agents/scripts/commit-check-trigger.test.mjs +94 -0
  45. package/templates/_agents/scripts/ensure-board.ps1 +1 -1
  46. package/templates/_agents/scripts/gen-wiki-board.mjs +13 -3
  47. package/templates/_agents/scripts/gen-wiki-board.test.mjs +22 -0
  48. package/templates/_agents/scripts/gen-workflow-index.mjs +7 -2
  49. package/templates/_agents/scripts/gen-workflow-index.test.mjs +27 -0
  50. package/templates/_agents/scripts/gen-workflow-metrics.mjs +17 -3
  51. package/templates/_agents/scripts/gen-workflow-metrics.test.mjs +21 -0
  52. package/templates/_agents/scripts/kb-search.mjs +2 -1
  53. package/templates/_agents/scripts/rule-budget.sh +8 -2
  54. package/templates/_agents/scripts/verify-wiki-consistency.mjs +6 -1
  55. package/templates/_agents/scripts/verify.mjs +36 -0
  56. package/templates/_agents/scripts/verify.test.mjs +77 -0
  57. package/templates/_agents/scripts/workflow-board-server.mjs +245 -244
  58. package/templates/_githooks/commit-msg +4 -2
  59. package/templates/_githooks/pre-push +32 -32
  60. package/templates/wiki/INDEX.md +9 -2
  61. package/templates/wiki//344/272/247/345/223/201/351/234/200/346/261/202/README.md +3 -0
  62. package/templates/wiki//345/274/200/345/217/221/346/226/271/346/241/210/README.md +3 -0
  63. package/templates/wiki//346/225/260/346/215/256/347/273/264/346/212/244/README.md +3 -0
  64. package/templates/wiki//346/265/213/350/257/225/346/212/245/345/221/212/README.md +3 -0
  65. package/templates/wiki//347/224/250/346/210/267/345/212/237/350/203/275/README.md +3 -0
  66. package/templates/wiki//351/241/271/347/233/256/350/247/204/350/214/203/README.md +3 -0
  67. package/templates/wiki//351/241/271/347/233/256/350/256/241/345/210/222/README.md +3 -0
  68. package/templates/workflow/README.md +2 -2
  69. package/templates/workflow/intents/_TEMPLATE.md +40 -40
  70. package/templates/workflow/specs/_TEMPLATE.md +41 -41
@@ -1,244 +1,245 @@
1
- #!/usr/bin/env node
2
- // workflow 实时看板服务:本地只读 http + SSE 文件监听,可视化 AI 执行流程(intents/incidents/plans/specs)。
3
- // 用法:node .agents/scripts/workflow-board-server.mjs [--port 8933]
4
- // 零依赖(node:http/node:fs/node:path/node:url/node:child_process);只读 workflow/ 与 git,仅绑 127.0.0.1。
5
- // API:GET /(看板页) /marked.min.js(vendor) /api/board /api/doc?file=<相对路径> /api/history?file=<相对路径> /api/events(SSE)
6
- // 实时边界:文档内容与状态随落盘实时(fs.watch→SSE 推送);git 历史仅含已提交记录(git 语义)。
7
- import http from 'node:http';
8
- import fs from 'node:fs';
9
- import path from 'node:path';
10
- import { fileURLToPath } from 'node:url';
11
- import { execFile } from 'node:child_process';
12
-
13
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
14
- const WORKFLOW = path.join(ROOT, 'workflow');
15
- const BOARD_DIR = path.join(ROOT, '.agents', 'board');
16
- const DOC_TYPES = ['intents', 'incidents', 'plans', 'specs'];
17
-
18
- const portArg = process.argv.indexOf('--port');
19
- const PORT = portArg > 0 ? Number(process.argv[portArg + 1]) || 8933 : 8933;
20
-
21
- // ---- front matter + 标题解析(对照 _TEMPLATE.md 受限子集:每行 `键: 值`)----
22
- function parseDoc(text) {
23
- const meta = {};
24
- const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
25
- if (m) {
26
- for (const line of m[1].split(/\r?\n/)) {
27
- const kv = line.match(/^(\S+):\s*(.*)$/);
28
- if (kv) meta[kv[1].trim()] = kv[2].trim();
29
- }
30
- }
31
- const h1 = text.match(/^# (.+)$/m);
32
- let title = h1 ? h1[1] : '';
33
- const dash = title.match(/\S+\s*[—–-]+\s*(.+)$/); // 去掉 "# INTENT — " 前缀,留主题
34
- if (dash) title = dash[1];
35
- return { meta, title };
36
- }
37
-
38
- // ---- 验收标准 checkbox 统计:仅「## 验收标准」节内(至下一个 ## 标题),无该节返回 null ----
39
- function parseAcceptance(text) {
40
- const h = text.match(/^## 验收标准.*$/m);
41
- if (!h) return null;
42
- const rest = text.slice(h.index + h[0].length);
43
- const next = rest.match(/^## /m);
44
- const section = next ? rest.slice(0, next.index) : rest;
45
- const total = (section.match(/^\s*[-*] \[[ xX]\]/gm) || []).length;
46
- if (!total) return null;
47
- const done = (section.match(/^\s*[-*] \[[xX]\]/gm) || []).length;
48
- return { done, total };
49
- }
50
-
51
- // ---- 配对断裂检测:按 slug 聚合同族(同名 intent/incident/plan/spec),异常卡附 alerts ----
52
- // 规则(口径对齐 check-loop.sh 的硬断档 + 状态枚举,看板为预警层、不阻断):
53
- // 孤儿 spec / 孤儿 plan(无同名 intent·incident 入口);入口 done 但 plan 未终态(看板启发式);
54
- // 入口缺 plan(intent 一律要求;incident 需非 legacy 且级别 L1/L2/L3);L2/L3 入口缺同名 spec;
55
- // spec L3 确认三件缺失(确认结果 / 确认时间 / 正文独立复核行);
56
- // 状态不在枚举内或缺失(intent·spec·plan: approved/done/superseded/cancelled;incident: open/fixed/closed)
57
- const PLAN_TERMINAL = ['done', 'superseded', 'cancelled'];
58
- const STATUS_ENUM = {
59
- intents: ['approved', 'done', 'superseded', 'cancelled'],
60
- specs: ['approved', 'done', 'superseded', 'cancelled'],
61
- plans: ['approved', 'done', 'superseded', 'cancelled'],
62
- incidents: ['open', 'fixed', 'closed'],
63
- };
64
- const NO_STATUS = '(未填)';
65
- const L3_REVIEW = /^- 独立复核:[ \t]*[^<\s]/m; // 正文独立复核行须有实质内容(对照 check-loop 锚定)
66
-
67
- function detectAlerts(cards) {
68
- const bySlug = new Map();
69
- for (const c of cards) {
70
- if (!bySlug.has(c.slug)) bySlug.set(c.slug, []);
71
- bySlug.get(c.slug).push(c);
72
- }
73
- for (const c of cards) {
74
- const fam = bySlug.get(c.slug) || [];
75
- const types = new Set(fam.map((x) => x.type));
76
- const alerts = [];
77
- const isEntry = c.type === 'intents' || c.type === 'incidents';
78
- // legacy 复盘件免配对类告警(口径同 check-loop);孤儿 spec / 孤儿 plan 两条仍按 spec·plan 自身判定
79
- const legacyIncident = c.type === 'incidents' && c.flow === 'legacy';
80
- if (!STATUS_ENUM[c.type].includes(c.status))
81
- alerts.push(`状态不在枚举内:须为 ${STATUS_ENUM[c.type].join('/')}(现 ${c.status === NO_STATUS ? '缺失' : c.status})`);
82
- if (c.type === 'specs' && !types.has('intents') && !types.has('incidents'))
83
- alerts.push('孤儿 spec:无同名 intent/incident 入口');
84
- if (c.type === 'plans' && !types.has('intents') && !types.has('incidents'))
85
- alerts.push('孤儿 plan:无同名 intent/incident 入口');
86
- if (c.type === 'plans') {
87
- const entry = fam.find((x) => x.type === 'intents' || x.type === 'incidents');
88
- if (entry && entry.status === 'done' && !PLAN_TERMINAL.includes(c.status))
89
- alerts.push(`入口已 done,本 plan 未终态(现 ${c.status})`);
90
- }
91
- if (isEntry && !legacyIncident && !types.has('plans') && (c.type === 'intents' || ['L1', 'L2', 'L3'].includes(c.level)))
92
- alerts.push('入口缺 plan:无同名 plan');
93
- if (isEntry && !legacyIncident && ['L2', 'L3'].includes(c.level) && !types.has('specs'))
94
- alerts.push(`${c.level} 入口缺同名 spec`);
95
- if (c.type === 'specs' && c.level === 'L3' && !['superseded', 'cancelled'].includes(c.status)) {
96
- if (c.confirm !== 'approved') alerts.push(`L3 确认缺失:确认结果须为 approved(现 ${c.confirm || '缺失'})`);
97
- if (!c.confirmTime) alerts.push('L3 确认缺失:须记录确认时间');
98
- if (!c.hasReview) alerts.push('L3 复核缺失:须记录新会话独立复核结论');
99
- }
100
- if (alerts.length) c.alerts = alerts;
101
- }
102
- }
103
-
104
- // ---- 文档作者:单次 git log 批量取「文件 → 最早提交者」(--reverse 首次出现即创建者)----
105
- function gitAuthors() {
106
- return new Promise((resolve) => {
107
- const GIT = process.platform === 'win32' ? 'git.exe' : 'git';
108
- execFile(GIT, ['-c', 'core.quotepath=off', 'log', '--reverse', '--pretty=format:%x00%an', '--name-only', '--', 'workflow'], { cwd: ROOT, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
109
- if (err) { console.error('git log 取作者失败(卡片 author 置空):', err.message); return resolve(new Map()); }
110
- const map = new Map();
111
- let author = '';
112
- for (const line of stdout.toString('utf8').split(/\r?\n/)) {
113
- if (line.startsWith('\0')) author = line.slice(1);
114
- else if (line.trim() && !map.has(line.trim())) map.set(line.trim(), author);
115
- }
116
- resolve(map);
117
- });
118
- });
119
- }
120
-
121
- // ---- 看板全量数据:每次请求现扫磁盘(数据量小,无缓存即无失效 bug)----
122
- async function scanBoard() {
123
- const authors = await gitAuthors();
124
- const cards = [];
125
- const counts = {};
126
- for (const type of DOC_TYPES) {
127
- const dir = path.join(WORKFLOW, type);
128
- const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !f.startsWith('_TEMPLATE'));
129
- counts[type] = files.length;
130
- for (const f of files) {
131
- let text = '';
132
- try { text = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; } // watch 触发瞬间可能正被写,跳过等下次推送
133
- const { meta, title } = parseDoc(text);
134
- const accept = parseAcceptance(text);
135
- const name = f.slice(0, -3);
136
- cards.push({
137
- type,
138
- file: `${type}/${f}`,
139
- name,
140
- slug: name.replace(/^\d{4}-\d{2}-\d{2}-/, ''),
141
- date: meta['日期'] || name.slice(0, 10),
142
- status: meta['状态'] || NO_STATUS,
143
- level: meta['级别'] || '',
144
- title: title || name,
145
- author: authors.get(`workflow/${type}/${f}`) || '',
146
- ...(accept ? { accept } : {}),
147
- // 告警规则用到的 frontmatter / 正文锚点(对照 check-loop:legacy 豁免、L3 确认三件)
148
- ...(meta['流程'] ? { flow: meta['流程'] } : {}),
149
- ...(meta['确认结果'] ? { confirm: meta['确认结果'] } : {}),
150
- ...(meta['确认时间'] ? { confirmTime: meta['确认时间'] } : {}),
151
- ...(L3_REVIEW.test(text) ? { hasReview: true } : {}),
152
- });
153
- }
154
- }
155
- detectAlerts(cards);
156
- cards.sort((a, b) => b.date.localeCompare(a.date) || a.type.localeCompare(b.type));
157
- return { root: ROOT, counts, cards };
158
- }
159
-
160
- // ---- 路径白名单:resolve 后必须仍在 workflow/ 内 ----
161
- function safeDocPath(rel) {
162
- const abs = path.resolve(WORKFLOW, rel);
163
- if (!abs.startsWith(WORKFLOW + path.sep) || !abs.endsWith('.md')) return null;
164
- return abs;
165
- }
166
-
167
- function gitLog(rel) {
168
- return new Promise((resolve) => {
169
- const GIT = process.platform === 'win32' ? 'git.exe' : 'git'; // Windows spawn 不补 .exe 扩展名
170
- execFile(GIT, ['log', '--follow', '--date=short', '--pretty=%H|%ad|%s', '--', rel.split('/').join(path.sep)], { cwd: WORKFLOW, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
171
- if (err) { console.error('git log 失败:', rel, err.message); return resolve([]); }
172
- resolve(stdout.toString('utf8').split(/\r?\n/).filter(Boolean).map((l) => {
173
- const [hash, date, ...rest] = l.split('|');
174
- return { hash, date, subject: rest.join('|') };
175
- }));
176
- });
177
- });
178
- }
179
-
180
- // ---- SSE 广播:fs.watch 防抖 500ms(AI 写盘常连发多事件,合并为一次推送)----
181
- const sseClients = new Set();
182
- let debounceTimer = null;
183
- function broadcast(event) {
184
- for (const res of sseClients) { if (!res.destroyed) res.write(`event: ${event}\ndata: {}\n\n`); }
185
- }
186
- try {
187
- const watcher = fs.watch(WORKFLOW, { recursive: true }, () => {
188
- clearTimeout(debounceTimer);
189
- debounceTimer = setTimeout(() => broadcast('changed'), 500);
190
- });
191
- // watch 中途失效(目录被动/杀软锁):显式告知前端降级轮询,避免假实时
192
- watcher.on('error', (e) => { console.error('fs.watch 失效,已通知前端降级轮询:', e.message); broadcast('watchdead'); });
193
- } catch (e) {
194
- console.error('fs.watch 初始化失败(前端将走 60s 兜底轮询):', e.message);
195
- }
196
- // 25s 心跳:防半开连接;顺带清理已断开的 client
197
- setInterval(() => { for (const res of sseClients) { if (res.destroyed) sseClients.delete(res); else res.write(': ping\n\n'); } }, 25000).unref();
198
-
199
- const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
200
-
201
- function sendJson(res, code, obj) {
202
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
203
- res.end(JSON.stringify(obj));
204
- }
205
-
206
- const server = http.createServer(async (req, res) => {
207
- const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
208
- try {
209
- if (url.pathname === '/api/events') {
210
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
211
- res.write('retry: 3000\n\n');
212
- sseClients.add(res);
213
- req.on('close', () => sseClients.delete(res));
214
- return;
215
- }
216
- if (url.pathname === '/api/board') return sendJson(res, 200, await scanBoard());
217
-
218
- if (url.pathname === '/api/doc' || url.pathname === '/api/history') {
219
- const rel = url.searchParams.get('file') || '';
220
- const abs = safeDocPath(rel);
221
- if (!abs) return sendJson(res, 403, { error: '路径不在 workflow/ 白名单内' });
222
- if (url.pathname === '/api/doc') {
223
- try { return sendJson(res, 200, { text: fs.readFileSync(abs, 'utf8') }); }
224
- catch { return sendJson(res, 404, { error: '文件不存在' }); }
225
- }
226
- return sendJson(res, 200, { commits: await gitLog(rel) });
227
- }
228
-
229
- // 静态文件:/ 与 /marked.min.js,同样防穿越
230
- const rel = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
231
- const abs = path.resolve(BOARD_DIR, rel);
232
- if (!abs.startsWith(BOARD_DIR + path.sep) || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
233
- res.writeHead(404); return res.end('not found');
234
- }
235
- res.writeHead(200, { 'Content-Type': MIME[path.extname(abs)] || 'application/octet-stream' });
236
- fs.createReadStream(abs).pipe(res);
237
- } catch (e) {
238
- sendJson(res, 500, { error: e.message });
239
- }
240
- });
241
-
242
- server.listen(PORT, '127.0.0.1', () => {
243
- console.log(`workflow 看板: http://127.0.0.1:${PORT} (Ctrl+C 停止;只读 workflow/,不写任何文件)`);
244
- });
1
+ #!/usr/bin/env node
2
+ // workflow 实时看板服务:本地只读 http + SSE 文件监听,可视化 AI 执行流程(intents/incidents/plans/specs)。
3
+ // 用法:node .agents/scripts/workflow-board-server.mjs [--port 8933]
4
+ // 零依赖(node:http/node:fs/node:path/node:url/node:child_process);只读 workflow/ 与 git,仅绑 127.0.0.1。
5
+ // API:GET /(看板页) /marked.min.js(vendor) /api/board /api/doc?file=<相对路径> /api/history?file=<相对路径> /api/events(SSE)
6
+ // 实时边界:文档内容与状态随落盘实时(fs.watch→SSE 推送);git 历史仅含已提交记录(git 语义)。
7
+ import http from 'node:http';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { execFile } from 'node:child_process';
12
+
13
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
14
+ const WORKFLOW = path.join(ROOT, 'workflow');
15
+ const BOARD_DIR = path.join(ROOT, '.agents', 'board');
16
+ const DOC_TYPES = ['intents', 'incidents', 'plans', 'specs'];
17
+
18
+ const portArg = process.argv.indexOf('--port');
19
+ const PORT = portArg > 0 ? Number(process.argv[portArg + 1]) || 8933 : 8933;
20
+
21
+ // ---- front matter + 标题解析(对照 _TEMPLATE.md 受限子集:每行 `键: 值`)----
22
+ function parseDoc(text) {
23
+ const meta = {};
24
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
25
+ if (m) {
26
+ for (const line of m[1].split(/\r?\n/)) {
27
+ const kv = line.match(/^(\S+):\s*(.*)$/);
28
+ if (kv) meta[kv[1].trim()] = kv[2].trim();
29
+ }
30
+ }
31
+ const h1 = text.match(/^# (.+)$/m);
32
+ let title = h1 ? h1[1] : '';
33
+ // 去掉 "# INTENT — " 前缀留主题:em/en dash(任意间距)或带空格连字符——裸连字符属 kebab-case,不截断(2026-09-24)
34
+ const dash = title.match(/\S+\s*(?:[—–]+|\s-\s)\s*(.+)$/);
35
+ if (dash) title = dash[1];
36
+ return { meta, title };
37
+ }
38
+
39
+ // ---- 验收标准 checkbox 统计:仅「## 验收标准」节内(至下一个 ## 标题),无该节返回 null ----
40
+ function parseAcceptance(text) {
41
+ const h = text.match(/^## 验收标准.*$/m);
42
+ if (!h) return null;
43
+ const rest = text.slice(h.index + h[0].length);
44
+ const next = rest.match(/^## /m);
45
+ const section = next ? rest.slice(0, next.index) : rest;
46
+ const total = (section.match(/^\s*[-*] \[[ xX]\]/gm) || []).length;
47
+ if (!total) return null;
48
+ const done = (section.match(/^\s*[-*] \[[xX]\]/gm) || []).length;
49
+ return { done, total };
50
+ }
51
+
52
+ // ---- 配对断裂检测:按 slug 聚合同族(同名 intent/incident/plan/spec),异常卡附 alerts ----
53
+ // 规则(口径对齐 check-loop.sh 的硬断档 + 状态枚举,看板为预警层、不阻断):
54
+ // 孤儿 spec / 孤儿 plan(无同名 intent·incident 入口);入口 done 但 plan 未终态(看板启发式);
55
+ // 入口缺 plan(intent 一律要求;incident 需非 legacy 且级别 L1/L2/L3);L2/L3 入口缺同名 spec;
56
+ // spec L3 确认三件缺失(确认结果 / 确认时间 / 正文独立复核行);
57
+ // 状态不在枚举内或缺失(intent·spec·plan: approved/done/superseded/cancelled;incident: open/fixed/closed)
58
+ const PLAN_TERMINAL = ['done', 'superseded', 'cancelled'];
59
+ const STATUS_ENUM = {
60
+ intents: ['approved', 'done', 'superseded', 'cancelled'],
61
+ specs: ['approved', 'done', 'superseded', 'cancelled'],
62
+ plans: ['approved', 'done', 'superseded', 'cancelled'],
63
+ incidents: ['open', 'fixed', 'closed'],
64
+ };
65
+ const NO_STATUS = '(未填)';
66
+ const L3_REVIEW = /^- 独立复核:[ \t]*[^<\s]/m; // 正文独立复核行须有实质内容(对照 check-loop 锚定)
67
+
68
+ function detectAlerts(cards) {
69
+ const bySlug = new Map();
70
+ for (const c of cards) {
71
+ if (!bySlug.has(c.slug)) bySlug.set(c.slug, []);
72
+ bySlug.get(c.slug).push(c);
73
+ }
74
+ for (const c of cards) {
75
+ const fam = bySlug.get(c.slug) || [];
76
+ const types = new Set(fam.map((x) => x.type));
77
+ const alerts = [];
78
+ const isEntry = c.type === 'intents' || c.type === 'incidents';
79
+ // legacy 复盘件免配对类告警(口径同 check-loop);孤儿 spec / 孤儿 plan 两条仍按 spec·plan 自身判定
80
+ const legacyIncident = c.type === 'incidents' && c.flow === 'legacy';
81
+ if (!STATUS_ENUM[c.type].includes(c.status))
82
+ alerts.push(`状态不在枚举内:须为 ${STATUS_ENUM[c.type].join('/')}(现 ${c.status === NO_STATUS ? '缺失' : c.status})`);
83
+ if (c.type === 'specs' && !types.has('intents') && !types.has('incidents'))
84
+ alerts.push('孤儿 spec:无同名 intent/incident 入口');
85
+ if (c.type === 'plans' && !types.has('intents') && !types.has('incidents'))
86
+ alerts.push('孤儿 plan:无同名 intent/incident 入口');
87
+ if (c.type === 'plans') {
88
+ const entry = fam.find((x) => x.type === 'intents' || x.type === 'incidents');
89
+ if (entry && entry.status === 'done' && !PLAN_TERMINAL.includes(c.status))
90
+ alerts.push(`入口已 done,本 plan 未终态(现 ${c.status})`);
91
+ }
92
+ if (isEntry && !legacyIncident && !types.has('plans') && (c.type === 'intents' || ['L1', 'L2', 'L3'].includes(c.level)))
93
+ alerts.push('入口缺 plan:无同名 plan');
94
+ if (isEntry && !legacyIncident && ['L2', 'L3'].includes(c.level) && !types.has('specs'))
95
+ alerts.push(`${c.level} 入口缺同名 spec`);
96
+ if (c.type === 'specs' && c.level === 'L3' && !['superseded', 'cancelled'].includes(c.status)) {
97
+ if (c.confirm !== 'approved') alerts.push(`L3 确认缺失:确认结果须为 approved(现 ${c.confirm || '缺失'})`);
98
+ if (!c.confirmTime) alerts.push('L3 确认缺失:须记录确认时间');
99
+ if (!c.hasReview) alerts.push('L3 复核缺失:须记录新会话独立复核结论');
100
+ }
101
+ if (alerts.length) c.alerts = alerts;
102
+ }
103
+ }
104
+
105
+ // ---- 文档作者:单次 git log 批量取「文件 → 最早提交者」(--reverse 首次出现即创建者)----
106
+ function gitAuthors() {
107
+ return new Promise((resolve) => {
108
+ const GIT = process.platform === 'win32' ? 'git.exe' : 'git';
109
+ execFile(GIT, ['-c', 'core.quotepath=off', 'log', '--reverse', '--pretty=format:%x00%an', '--name-only', '--', 'workflow'], { cwd: ROOT, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
110
+ if (err) { console.error('git log 取作者失败(卡片 author 置空):', err.message); return resolve(new Map()); }
111
+ const map = new Map();
112
+ let author = '';
113
+ for (const line of stdout.toString('utf8').split(/\r?\n/)) {
114
+ if (line.startsWith('\0')) author = line.slice(1);
115
+ else if (line.trim() && !map.has(line.trim())) map.set(line.trim(), author);
116
+ }
117
+ resolve(map);
118
+ });
119
+ });
120
+ }
121
+
122
+ // ---- 看板全量数据:每次请求现扫磁盘(数据量小,无缓存即无失效 bug)----
123
+ async function scanBoard() {
124
+ const authors = await gitAuthors();
125
+ const cards = [];
126
+ const counts = {};
127
+ for (const type of DOC_TYPES) {
128
+ const dir = path.join(WORKFLOW, type);
129
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !f.startsWith('_TEMPLATE'));
130
+ counts[type] = files.length;
131
+ for (const f of files) {
132
+ let text = '';
133
+ try { text = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; } // watch 触发瞬间可能正被写,跳过等下次推送
134
+ const { meta, title } = parseDoc(text);
135
+ const accept = parseAcceptance(text);
136
+ const name = f.slice(0, -3);
137
+ cards.push({
138
+ type,
139
+ file: `${type}/${f}`,
140
+ name,
141
+ slug: name.replace(/^\d{4}-\d{2}-\d{2}-/, ''),
142
+ date: meta['日期'] || name.slice(0, 10),
143
+ status: meta['状态'] || NO_STATUS,
144
+ level: meta['级别'] || '',
145
+ title: title || name,
146
+ author: authors.get(`workflow/${type}/${f}`) || '',
147
+ ...(accept ? { accept } : {}),
148
+ // 告警规则用到的 frontmatter / 正文锚点(对照 check-loop:legacy 豁免、L3 确认三件)
149
+ ...(meta['流程'] ? { flow: meta['流程'] } : {}),
150
+ ...(meta['确认结果'] ? { confirm: meta['确认结果'] } : {}),
151
+ ...(meta['确认时间'] ? { confirmTime: meta['确认时间'] } : {}),
152
+ ...(L3_REVIEW.test(text) ? { hasReview: true } : {}),
153
+ });
154
+ }
155
+ }
156
+ detectAlerts(cards);
157
+ cards.sort((a, b) => b.date.localeCompare(a.date) || a.type.localeCompare(b.type));
158
+ return { root: ROOT, counts, cards };
159
+ }
160
+
161
+ // ---- 路径白名单:resolve 后必须仍在 workflow/ 内 ----
162
+ function safeDocPath(rel) {
163
+ const abs = path.resolve(WORKFLOW, rel);
164
+ if (!abs.startsWith(WORKFLOW + path.sep) || !abs.endsWith('.md')) return null;
165
+ return abs;
166
+ }
167
+
168
+ function gitLog(rel) {
169
+ return new Promise((resolve) => {
170
+ const GIT = process.platform === 'win32' ? 'git.exe' : 'git'; // Windows spawn 不补 .exe 扩展名
171
+ execFile(GIT, ['log', '--follow', '--date=short', '--pretty=%H|%ad|%s', '--', rel.split('/').join(path.sep)], { cwd: WORKFLOW, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
172
+ if (err) { console.error('git log 失败:', rel, err.message); return resolve([]); }
173
+ resolve(stdout.toString('utf8').split(/\r?\n/).filter(Boolean).map((l) => {
174
+ const [hash, date, ...rest] = l.split('|');
175
+ return { hash, date, subject: rest.join('|') };
176
+ }));
177
+ });
178
+ });
179
+ }
180
+
181
+ // ---- SSE 广播:fs.watch 防抖 500ms(AI 写盘常连发多事件,合并为一次推送)----
182
+ const sseClients = new Set();
183
+ let debounceTimer = null;
184
+ function broadcast(event) {
185
+ for (const res of sseClients) { if (!res.destroyed) res.write(`event: ${event}\ndata: {}\n\n`); }
186
+ }
187
+ try {
188
+ const watcher = fs.watch(WORKFLOW, { recursive: true }, () => {
189
+ clearTimeout(debounceTimer);
190
+ debounceTimer = setTimeout(() => broadcast('changed'), 500);
191
+ });
192
+ // watch 中途失效(目录被动/杀软锁):显式告知前端降级轮询,避免假实时
193
+ watcher.on('error', (e) => { console.error('fs.watch 失效,已通知前端降级轮询:', e.message); broadcast('watchdead'); });
194
+ } catch (e) {
195
+ console.error('fs.watch 初始化失败(前端将走 60s 兜底轮询):', e.message);
196
+ }
197
+ // 25s 心跳:防半开连接;顺带清理已断开的 client
198
+ setInterval(() => { for (const res of sseClients) { if (res.destroyed) sseClients.delete(res); else res.write(': ping\n\n'); } }, 25000).unref();
199
+
200
+ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
201
+
202
+ function sendJson(res, code, obj) {
203
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
204
+ res.end(JSON.stringify(obj));
205
+ }
206
+
207
+ const server = http.createServer(async (req, res) => {
208
+ const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
209
+ try {
210
+ if (url.pathname === '/api/events') {
211
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
212
+ res.write('retry: 3000\n\n');
213
+ sseClients.add(res);
214
+ req.on('close', () => sseClients.delete(res));
215
+ return;
216
+ }
217
+ if (url.pathname === '/api/board') return sendJson(res, 200, await scanBoard());
218
+
219
+ if (url.pathname === '/api/doc' || url.pathname === '/api/history') {
220
+ const rel = url.searchParams.get('file') || '';
221
+ const abs = safeDocPath(rel);
222
+ if (!abs) return sendJson(res, 403, { error: '路径不在 workflow/ 白名单内' });
223
+ if (url.pathname === '/api/doc') {
224
+ try { return sendJson(res, 200, { text: fs.readFileSync(abs, 'utf8') }); }
225
+ catch { return sendJson(res, 404, { error: '文件不存在' }); }
226
+ }
227
+ return sendJson(res, 200, { commits: await gitLog(rel) });
228
+ }
229
+
230
+ // 静态文件:/ 与 /marked.min.js,同样防穿越
231
+ const rel = url.pathname === '/' ? 'index.html' : url.pathname.slice(1);
232
+ const abs = path.resolve(BOARD_DIR, rel);
233
+ if (!abs.startsWith(BOARD_DIR + path.sep) || !fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
234
+ res.writeHead(404); return res.end('not found');
235
+ }
236
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(abs)] || 'application/octet-stream' });
237
+ fs.createReadStream(abs).pipe(res);
238
+ } catch (e) {
239
+ sendJson(res, 500, { error: e.message });
240
+ }
241
+ });
242
+
243
+ server.listen(PORT, '127.0.0.1', () => {
244
+ console.log(`workflow 看板: http://127.0.0.1:${PORT} (Ctrl+C 停止;只读 workflow/,不写任何文件)`);
245
+ });
@@ -1,11 +1,13 @@
1
1
  #!/bin/sh
2
2
  # Git commit-msg hook:提交类型枚举校验(自 pre-commit 迁入,2026-09-18 papercut 升级修复)
3
3
  # AGENTS.md「git提交」:Conventional Commits 中文,feat/fix/docs/style/refactor/perf
4
- # merge/revert 提交豁免(含自定义小写 merge:/revert: 前缀);$1 为 git 传入的本次消息文件(真实待提交内容)
4
+ # 豁免仅限真实 merge/revert 形态:git 默认消息(Merge …/Revert …)与自定义小写 merge:/revert: 前缀。
5
+ # 2026-09-24 收紧:不再匹配任意 merge/revert 开头(如 "merged xxx"),空首行不再豁免(fail-closed)。
6
+ # $1 为 git 传入的本次消息文件(真实待提交内容)
5
7
 
6
8
  msg=$(head -1 "$1" 2>/dev/null)
7
9
  case "$msg" in
8
- [Mm]erge*|[Rr]evert*|"") ;;
10
+ "Merge "*|"merge:"*|"Revert "*|"revert:"*) ;;
9
11
  *)
10
12
  if ! echo "$msg" | grep -qE "^(feat|fix|docs|style|refactor|perf)(\(|:|()"; then
11
13
  echo "commit-msg: 提交类型须为 feat/fix/docs/style/refactor/perf(AGENTS.md「git提交」),当前首行:$msg" >&2
@@ -1,32 +1,32 @@
1
- #!/bin/sh
2
- # Git 原生 pre-push hook(经 core.hooksPath 挂载,git push 时触发)
3
- # 跑 .agents/scripts/check-loop.sh:扫 workflow/ 闭环骨架断档
4
- # - 硬阻断(exit 1)= 配对断裂 / 回路断档 → push 立即阻断
5
- # - 警告(advisory)= 状态未确认 / 模板占位残留 / 引用漂移 等卫生项 → 仅打印提示
6
- #
7
- # 机器门:本地钩子(唯一本地机器门)——CI 配置为本地未入库件(.gitignore 排除),不构成跨环境保证。
8
- # 不要用 `git push --no-verify` 绕开——发现 hard-block 应先修 workflow 文档再推。
9
-
10
- cd "$(git rev-parse --show-toplevel)" || exit 1
11
-
12
- # 读 stdin(pre-push 协议要求消费 stdin:本地 ref + 远端 ref + sha x2 每行)
13
- while read -r _ _ _ _; do :; done
14
-
15
- SCRIPT=".agents/scripts/check-loop.sh"
16
- if [ ! -f "$SCRIPT" ]; then
17
- echo "pre-push: 未找到 $SCRIPT,跳过闭环扫描" >&2
18
- exit 0
19
- fi
20
-
21
- # Windows 下 git 自带 bash 能直接执行 sh 脚本;其它环境 sh 兼容
22
- if command -v sh >/dev/null 2>&1; then
23
- sh "$SCRIPT"
24
- else
25
- bash "$SCRIPT"
26
- fi
27
- rc=$?
28
- if [ $rc -ne 0 ]; then
29
- echo "pre-push: 闭环骨架断档(hard-block)未通过,push 已阻断。修 workflow 文档后重推——不要用 --no-verify 绕过(CI 服务端会再次兜底拦截)" >&2
30
- exit $rc
31
- fi
32
- exit 0
1
+ #!/bin/sh
2
+ # Git 原生 pre-push hook(经 core.hooksPath 挂载,git push 时触发)
3
+ # 跑 .agents/scripts/check-loop.sh:扫 workflow/ 闭环骨架断档
4
+ # - 硬阻断(exit 1)= 配对断裂 / 回路断档 → push 立即阻断
5
+ # - 警告(advisory)= 状态未确认 / 模板占位残留 / 引用漂移 等卫生项 → 仅打印提示
6
+ #
7
+ # 机器门:本地钩子(唯一本地机器门)——CI 配置为本地未入库件(.gitignore 排除),不构成跨环境保证。
8
+ # 不要用 `git push --no-verify` 绕开——发现 hard-block 应先修 workflow 文档再推。
9
+
10
+ cd "$(git rev-parse --show-toplevel)" || exit 1
11
+
12
+ # 读 stdin(pre-push 协议要求消费 stdin:本地 ref + 远端 ref + sha x2 每行)
13
+ while read -r _ _ _ _; do :; done
14
+
15
+ SCRIPT=".agents/scripts/check-loop.sh"
16
+ if [ ! -f "$SCRIPT" ]; then
17
+ echo "pre-push: 未找到 $SCRIPT,跳过闭环扫描" >&2
18
+ exit 0
19
+ fi
20
+
21
+ # Windows 下 git 自带 bash 能直接执行 sh 脚本;其它环境 sh 兼容
22
+ if command -v sh >/dev/null 2>&1; then
23
+ sh "$SCRIPT"
24
+ else
25
+ bash "$SCRIPT"
26
+ fi
27
+ rc=$?
28
+ if [ $rc -ne 0 ]; then
29
+ echo "pre-push: 闭环骨架断档(hard-block)未通过,push 已阻断。修 workflow 文档后重推——不要用 --no-verify 绕过(本钩子是唯一机器门,无服务端兜底,见头部说明)" >&2
30
+ exit $rc
31
+ fi
32
+ exit 0
@@ -9,8 +9,15 @@
9
9
 
10
10
  | 主题 | 文件数 | 用途 |
11
11
  |---|---|---|
12
-
13
- 合计:**0 份**知识文档,**0 个主题**
12
+ | 产品需求 | 1 | 需求文档与变更记录(PRD、需求说明、评审结论) |
13
+ | 用户功能 | 1 | 功能清单与操作说明(功能视角;与产品需求的分工:那里记"为什么做",这里记"怎么用") |
14
+ | 开发方案 | 1 | 技术方案与设计文档(架构、接口、关键取舍) |
15
+ | 项目规范 | 1 | 编码 / 流程 / 协作规范(只放现行有效版本) |
16
+ | 项目计划 | 1 | 计划、里程碑与会议纪要(按日期命名) |
17
+ | 测试报告 | 1 | 测试报告与验收记录(按 日期-主题 命名) |
18
+ | 数据维护 | 1 | schema 变更与数据修复台账(子目录按批次组织,批内 README 记账,不参与文件级登记) |
19
+
20
+ 合计:**7 份**知识文档,**7 个主题**
14
21
 
15
22
  ## 文件 → 主题 → 归属目录 映射表
16
23
 
@@ -0,0 +1,3 @@
1
+ # 产品需求 — 需求文档与变更记录
2
+
3
+ PRD、需求说明、评审结论归此;按 `<主题>-<日期>` 或成套子目录命名。原始聊天/草稿先归 `../drafts-archive/`,整理后入位。