agentic-flow-kit 0.2.0 → 0.2.1

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 (56) hide show
  1. package/README.md +3 -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 +4 -3
  20. package/src/doctor.mjs +9 -2
  21. package/src/render.mjs +2 -0
  22. package/src/sync.mjs +20 -4
  23. package/src/sync.test.mjs +40 -1
  24. package/templates/AGENTS.md +2 -2
  25. package/templates/_agents/board/index.html +382 -382
  26. package/templates/_agents/board/marked.min.js +6 -6
  27. package/templates/_agents/cache/kb-index.json +1 -1
  28. package/templates/_agents/commands/build.md +1 -1
  29. package/templates/_agents/commands/design.md +1 -1
  30. package/templates/_agents/commands/maintain.md +1 -1
  31. package/templates/_agents/commands/new-task.md +73 -73
  32. package/templates/_agents/commands/plan.md +2 -2
  33. package/templates/_agents/commands/test.md +1 -1
  34. package/templates/_agents/hooks/check-pairing-incremental.sh +30 -29
  35. package/templates/_agents/hooks/commit-check.cjs +6 -1
  36. package/templates/_agents/scripts/agg-delegations.cjs +35 -18
  37. package/templates/_agents/scripts/agg-delegations.test.mjs +95 -0
  38. package/templates/_agents/scripts/check-loop.sh +6 -0
  39. package/templates/_agents/scripts/check-loop.test.sh +1042 -1042
  40. package/templates/_agents/scripts/commit-check-trigger.test.mjs +94 -0
  41. package/templates/_agents/scripts/ensure-board.ps1 +1 -1
  42. package/templates/_agents/scripts/gen-wiki-board.mjs +13 -3
  43. package/templates/_agents/scripts/gen-wiki-board.test.mjs +22 -0
  44. package/templates/_agents/scripts/gen-workflow-index.mjs +7 -2
  45. package/templates/_agents/scripts/gen-workflow-index.test.mjs +27 -0
  46. package/templates/_agents/scripts/gen-workflow-metrics.mjs +17 -3
  47. package/templates/_agents/scripts/gen-workflow-metrics.test.mjs +21 -0
  48. package/templates/_agents/scripts/kb-search.mjs +2 -1
  49. package/templates/_agents/scripts/rule-budget.sh +8 -2
  50. package/templates/_agents/scripts/verify-wiki-consistency.mjs +6 -1
  51. package/templates/_agents/scripts/workflow-board-server.mjs +245 -244
  52. package/templates/_githooks/commit-msg +4 -2
  53. package/templates/_githooks/pre-push +32 -32
  54. package/templates/workflow/README.md +2 -2
  55. package/templates/workflow/intents/_TEMPLATE.md +40 -40
  56. 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
@@ -24,9 +24,9 @@
24
24
  ## 使用方式
25
25
 
26
26
  - **发起新任务**:执行 `.agents/commands/new-task.md` 定义的流程(6 阶段总入口)
27
- - **看板**:任务入口幂等拉起 .agents/scripts/ensure-board.ps1(探活 / 旧代码自动重启 / 全新启动弹浏览器),服务只起不关,实时只读 http://127.0.0.1:{{BOARD_PORT}};**预警层,非门禁;告警口径对齐 check-loop 硬断档**。端口被占用时先探活 `/api/board`——响应不含 `cards` 即判为他人进程,打印 pid / 进程名并退出,不 kill
27
+ - **看板(可选,默认不拉起)**:需要时手动跑 .agents/scripts/ensure-board.ps1(幂等:探活 / 旧代码自动重启 / 全新启动弹浏览器),实时只读 http://127.0.0.1:{{BOARD_PORT}};**预警层,非门禁;告警口径对齐 check-loop 硬断档**。端口被占用时先探活 `/api/board`——响应不含 `cards` 即判为他人进程,打印 pid / 进程名并退出,不 kill
28
28
  - **检索**:活跃流程读 `INDEX.md`(生成物,`node .agents/scripts/gen-workflow-index.mjs` 重生成、`--check` 校验漂移);跨语料检索 `node .agents/scripts/kb-search.mjs "<词>"`(workflow 按节级定位 + wiki 全文,`--type/--module/--status/-n` 过滤;`--status all` 显式全量)
29
- - **验证**:`.agents/commands/test.md`(静态门 + 实测;项目自有验证脚本/门禁如有,见 `.agents/hooks/local-pre-commit` 与 test.md 项目适配区)
29
+ - **验证**:`.agents/commands/test.md`(静态门 + 实测;项目自有验证脚本/门禁如有,见 `.agents/hooks/local-pre-commit` 与根 `AGENTS.md`「项目适配区」)
30
30
  - **评审**:`.agents/commands/review.md`(按 P0 / P1 / P2 分级:机器兜底 + AI 自查出清单,用户决策定性与合入)
31
31
  - **上线**:`.agents/commands/deploy.md`(上线前必跑清单)
32
32
  - **量化**:委派/自做结果记 `delegations.md`,聚合跑 `node .agents/scripts/agg-delegations.cjs`(**并发扩容门槛见该文件——数字达标前不扩并发**);语料与常驻面体积的**月度快照**跑 `node .agents/scripts/gen-workflow-metrics.mjs`(每月一行落 `metrics.md`,同月重跑即更新;明细看 stdout)