agentic-flow-kit 0.5.0 → 0.6.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 (82) hide show
  1. package/README.md +2 -0
  2. package/modules/gates/dotnet-ca/check-architecture.sh +23 -5
  3. package/modules/hosts/omp/agents/implementer.md +2 -0
  4. package/modules/hosts/omp/agents/independent-reviewer.md +2 -0
  5. package/modules/hosts/omp/agents/ui-verifier.md +2 -0
  6. package/modules/hosts/opencode/agents/implementer.md +2 -0
  7. package/modules/hosts/opencode/agents/independent-reviewer.md +2 -0
  8. package/modules/hosts/opencode/agents/ui-verifier.md +2 -0
  9. package/modules/hosts/opencode/commands/build.md +20 -1
  10. package/modules/hosts/opencode/commands/design.md +2 -0
  11. package/modules/hosts/opencode/commands/gate-checklist.md +77 -0
  12. package/modules/hosts/opencode/commands/maintain.md +2 -3
  13. package/modules/hosts/opencode/commands/plan.md +4 -1
  14. package/modules/hosts/opencode/commands/review.md +1 -0
  15. package/modules/hosts/opencode/commands/source-sync-check.md +89 -0
  16. package/modules/hosts/opencode/commands/sync-hosts.md +66 -0
  17. package/modules/hosts/opencode/commands/test.md +4 -2
  18. package/modules/hosts/trae/agents/implementer.md +2 -0
  19. package/modules/hosts/trae/agents/independent-reviewer.md +2 -0
  20. package/modules/hosts/trae/agents/ui-verifier.md +2 -0
  21. package/modules/hosts/trae/commands/wf-build.md +20 -1
  22. package/modules/hosts/trae/commands/wf-design.md +2 -0
  23. package/modules/hosts/trae/commands/wf-gate-checklist.md +78 -0
  24. package/modules/hosts/trae/commands/wf-maintain.md +2 -3
  25. package/modules/hosts/trae/commands/wf-plan.md +4 -1
  26. package/modules/hosts/trae/commands/wf-review.md +1 -0
  27. package/modules/hosts/trae/commands/wf-source-sync-check.md +90 -0
  28. package/modules/hosts/trae/commands/wf-sync-hosts.md +67 -0
  29. package/modules/hosts/trae/commands/wf-test.md +4 -2
  30. package/modules/hosts/trae/hooks/post-edit-check.cjs +3 -2
  31. package/modules/hosts/trae/hooks/pre-shell-check.cjs +40 -9
  32. package/modules/hosts/trae/hooks.json +1 -1
  33. package/modules/hosts/zcode/agents/implementer.md +2 -0
  34. package/modules/hosts/zcode/agents/independent-reviewer.md +2 -0
  35. package/modules/hosts/zcode/agents/ui-verifier.md +2 -0
  36. package/package.json +2 -2
  37. package/src/add-gate.mjs +5 -3
  38. package/src/doctor.mjs +121 -18
  39. package/src/gate-dotnet-ca.test.mjs +124 -0
  40. package/src/init.mjs +79 -17
  41. package/src/init.test.mjs +60 -1
  42. package/src/render.mjs +64 -4
  43. package/src/run-tests.mjs +3 -0
  44. package/src/sync-hosts.mjs +3 -2
  45. package/src/sync.mjs +64 -20
  46. package/src/sync.test.mjs +42 -0
  47. package/src/trae-hooks.test.mjs +115 -0
  48. package/templates/AGENTS.md +1 -0
  49. package/templates/_agents/board/index.html +32 -3
  50. package/templates/_agents/commands/build.md +5 -4
  51. package/templates/_agents/commands/design.md +1 -0
  52. package/templates/_agents/commands/maintain.md +1 -2
  53. package/templates/_agents/commands/plan.md +2 -0
  54. package/templates/_agents/commands/test.md +2 -1
  55. package/templates/_agents/hooks/check-pairing-incremental.sh +20 -7
  56. package/templates/_agents/roles/implementer.md +2 -0
  57. package/templates/_agents/roles/independent-reviewer.md +2 -0
  58. package/templates/_agents/roles/ui-verifier.md +2 -0
  59. package/templates/_agents/scripts/agg-delegations.cjs +39 -30
  60. package/templates/_agents/scripts/agg-delegations.test.mjs +32 -0
  61. package/templates/_agents/scripts/check-loop.mjs +262 -16
  62. package/templates/_agents/scripts/check-loop.test.mjs +621 -0
  63. package/templates/_agents/scripts/confirm-doc.mjs +228 -0
  64. package/templates/_agents/scripts/confirm-doc.test.mjs +321 -0
  65. package/templates/_agents/scripts/doctor.test.mjs +74 -1
  66. package/templates/_agents/scripts/ensure-board.mjs +29 -2
  67. package/templates/_agents/scripts/gate-checklist.mjs +2 -0
  68. package/templates/_agents/scripts/gate-checklist.test.mjs +3 -3
  69. package/templates/_agents/scripts/gen-wiki-board.mjs +38 -24
  70. package/templates/_agents/scripts/gen-wiki-board.test.mjs +18 -0
  71. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -3
  72. package/templates/_agents/scripts/kb-search.mjs +22 -8
  73. package/templates/_agents/scripts/kb-search.test.mjs +38 -8
  74. package/templates/_agents/scripts/source-sync-check.mjs +17 -3
  75. package/templates/_agents/scripts/source-sync-check.test.mjs +96 -8
  76. package/templates/_agents/scripts/verify-wiki-consistency.mjs +46 -23
  77. package/templates/_agents/scripts/workflow-board-server.mjs +64 -9
  78. package/templates/_agents/scripts/workflow-board-server.test.mjs +136 -0
  79. package/templates/_agents/scripts/workflow-enums.mjs +4 -2
  80. package/templates/_agents/workflow-enums.txt +3 -3
  81. package/templates/_agents/workflows/_TEMPLATE.md +1 -0
  82. package/templates/_githooks/pre-commit +16 -1
@@ -6,6 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import os from 'node:os';
9
+ import { spawnSync } from 'node:child_process';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import { sourceSyncCheck } from './source-sync-check.mjs';
11
12
 
@@ -15,7 +16,20 @@ function check(name, cond, detail = '') {
15
16
  else { failCount++; console.log('FAIL ' + name + (detail ? '——' + detail : '')); }
16
17
  }
17
18
 
18
- const SRC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
19
+ // SRC_ROOT:自测试文件位置向上探测包源(templates/_agents)——包源路径(templates/_agents/scripts/)与
20
+ // 装副本路径(.agents/scripts/)直跑都解析到仓库根;无包源(装户环境)返回 null,S5/S9 baseline 记 SKIP
21
+ // 不算失败(2026-09-27 gate-hardening-p2-batch:旧 `../../..` 硬编码层级从装副本直跑必 FAIL)
22
+ const findSrcRoot = () => {
23
+ let d = path.dirname(fileURLToPath(import.meta.url));
24
+ for (let i = 0; i < 5; i++) {
25
+ if (fs.existsSync(path.join(d, 'templates', '_agents'))) return d;
26
+ d = path.dirname(d);
27
+ }
28
+ return null;
29
+ };
30
+ const SRC_ROOT = findSrcRoot();
31
+ const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'source-sync-check.mjs');
32
+ const runGate = (args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8' });
19
33
 
20
34
  // ---- fixture:mini 包源 + 装副本,3 类差异各造 1 个 ----
21
35
  function mkFixture() {
@@ -87,11 +101,11 @@ function mkFixture() {
87
101
  check('S4 一致文件 cmdA.md 不在漂移', !r.drift.some((d) => d.rel === 'commands/cmdA.md'));
88
102
  }
89
103
 
90
- // ---- 场景 5:实际仓库扫描 baseline(包源 ≥ 30 份 / 装副本 ≥ 30 份)----
91
- {
92
- const pkgRoot = SRC_ROOT;
93
- const target = SRC_ROOT;
94
- const r = sourceSyncCheck({ pkgRoot, target });
104
+ // ---- 场景 5:实际仓库扫描 baseline(包源 ≥ 30 份 / 装副本 ≥ 30 份;装户环境无包源 → SKIP)----
105
+ if (!SRC_ROOT) {
106
+ check('S5 baseline SKIP(未探测到包源 templates/_agents——装户环境直跑,不算失败)', true);
107
+ } else {
108
+ const r = sourceSyncCheck({ pkgRoot: SRC_ROOT, target: SRC_ROOT });
95
109
  check('S5 实际仓库 包源 ≥ 30 份', r.pkgCount >= 30, '实际 ' + r.pkgCount);
96
110
  check('S5 实际仓库 装副本 ≥ 30 份', r.tgtCount >= 30, '实际 ' + r.tgtCount);
97
111
  // 装副本 kit.json / settings.json 不应在孤儿
@@ -138,8 +152,10 @@ function mkFixture() {
138
152
  r.orphan.length === 1 && r.orphan[0].rel === 'hooks/real-orphan.json', JSON.stringify(r.orphan));
139
153
  }
140
154
 
141
- // ---- 场景 9:实际仓库 hooks/commit-check.config.json 不在孤儿 ----
142
- {
155
+ // ---- 场景 9:实际仓库 hooks/commit-check.config.json 不在孤儿(装户环境随 S5 一并 SKIP)----
156
+ if (!SRC_ROOT) {
157
+ check('S9 baseline SKIP(装户环境,随 S5)', true);
158
+ } else {
143
159
  const r = sourceSyncCheck({ pkgRoot: SRC_ROOT, target: SRC_ROOT });
144
160
  check('S9 实际仓库 hooks/commit-check.config.json 不在孤儿',
145
161
  !r.orphan.some((o) => o.rel === 'hooks/commit-check.config.json'), JSON.stringify(r.orphan));
@@ -152,5 +168,77 @@ function mkFixture() {
152
168
  check('S7 空目录 fixture 不崩(0/0)', r.pkgCount === 0 && r.tgtCount === 0);
153
169
  }
154
170
 
171
+ // ---- 场景 10-12:--gate 门禁模式(2026-09-27 audit-gate-hardening,pre-commit 双源门禁用)----
172
+ {
173
+ // S10 差异 fixture(缺失 1 / 漂移 1 / 孤儿 1)→ --gate exit 1(缺失/漂移阻断)
174
+ const { pkgRoot, target } = mkFixture();
175
+ const r = runGate(['--gate', '--pkg-root', pkgRoot, '--target', target]);
176
+ check('S10 --gate 差异 fixture(缺失+漂移)→ exit 1', r.status === 1, `exit=${r.status}\n${r.stdout || ''}${r.stderr || ''}`);
177
+ }
178
+ {
179
+ // S11 孤儿-only fixture → --gate exit 0(孤儿只报告不计失败——装户自持内容不入机器门)
180
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-ssc-gate-orphan-'));
181
+ const pkgRoot = path.join(root, 'pkg');
182
+ const target = path.join(root, 'target');
183
+ const pkgBase = path.join(pkgRoot, 'templates', '_agents');
184
+ const tgtBase = path.join(target, '.agents');
185
+ fs.mkdirSync(path.join(pkgBase, 'commands'), { recursive: true });
186
+ fs.mkdirSync(path.join(tgtBase, 'commands'), { recursive: true });
187
+ fs.writeFileSync(path.join(pkgBase, 'commands', 'same.md'), '# v1\n');
188
+ fs.writeFileSync(path.join(tgtBase, 'commands', 'same.md'), '# v1\n');
189
+ fs.writeFileSync(path.join(tgtBase, 'local-only.md'), '# 装户自持\n');
190
+ const r = runGate(['--gate', '--pkg-root', pkgRoot, '--target', target]);
191
+ check('S11 --gate 孤儿-only → exit 0(孤儿不计失败)', r.status === 0, `exit=${r.status}\n${r.stdout || ''}${r.stderr || ''}`);
192
+ }
193
+ {
194
+ // S12 干净 fixture --gate → exit 0;差异 fixture --diff → 仍 exit 0(B-b「只报告不修复」默认语义不变)
195
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-ssc-gate-clean-'));
196
+ const pkgRoot = path.join(root, 'pkg');
197
+ const target = path.join(root, 'target');
198
+ const pkgBase = path.join(pkgRoot, 'templates', '_agents');
199
+ const tgtBase = path.join(target, '.agents');
200
+ fs.mkdirSync(path.join(pkgBase, 'commands'), { recursive: true });
201
+ fs.mkdirSync(path.join(tgtBase, 'commands'), { recursive: true });
202
+ fs.writeFileSync(path.join(pkgBase, 'commands', 'same.md'), '# v1\n');
203
+ fs.writeFileSync(path.join(tgtBase, 'commands', 'same.md'), '# v1\n');
204
+ const rClean = runGate(['--gate', '--pkg-root', pkgRoot, '--target', target]);
205
+ check('S12 --gate 干净 fixture → exit 0', rClean.status === 0, `exit=${rClean.status}`);
206
+ const { pkgRoot: dp, target: dt } = mkFixture();
207
+ const rDiff = runGate(['--diff', '--pkg-root', dp, '--target', dt]);
208
+ check('S12 --diff 差异 fixture 仍 exit 0(B-b 默认语义不变)', rDiff.status === 0, `exit=${rDiff.status}`);
209
+ }
210
+
211
+ // ---- 场景 13-14:sha LF 归一(2026-09-27 closing-coverage)——CRLF 等价不误报 / 内容差异照常抓 ----
212
+ {
213
+ // S13 同内容不同行尾(包源 LF / 装副本 CRLF)→ 不报漂移
214
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-ssc-crlf-'));
215
+ const pkgRoot = path.join(root, 'pkg');
216
+ const target = path.join(root, 'target');
217
+ const pkgBase = path.join(pkgRoot, 'templates', '_agents', 'commands');
218
+ const tgtBase = path.join(target, '.agents', 'commands');
219
+ fs.mkdirSync(pkgBase, { recursive: true });
220
+ fs.mkdirSync(tgtBase, { recursive: true });
221
+ fs.writeFileSync(path.join(pkgBase, 'a.md'), '# 标题\n\n正文行\n- 项目\n');
222
+ fs.writeFileSync(path.join(tgtBase, 'a.md'), '# 标题\r\n\r\n正文行\r\n- 项目\r\n');
223
+ fs.writeFileSync(path.join(pkgBase, 'b.md'), '# B\n');
224
+ fs.writeFileSync(path.join(tgtBase, 'b.md'), '# B\n');
225
+ const r = sourceSyncCheck({ pkgRoot, target });
226
+ check('S13 同内容不同行尾(LF vs CRLF)→ 0 漂移(LF 归一口径)', r.drift.length === 0, JSON.stringify(r.drift));
227
+ }
228
+ {
229
+ // S14 内容真实差异(即使行尾也不同)→ 照常报漂移(归一不掩盖内容差异)
230
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-ssc-crlf2-'));
231
+ const pkgRoot = path.join(root, 'pkg');
232
+ const target = path.join(root, 'target');
233
+ const pkgBase = path.join(pkgRoot, 'templates', '_agents', 'commands');
234
+ const tgtBase = path.join(target, '.agents', 'commands');
235
+ fs.mkdirSync(pkgBase, { recursive: true });
236
+ fs.mkdirSync(tgtBase, { recursive: true });
237
+ fs.writeFileSync(path.join(pkgBase, 'a.md'), '# v1\n内容甲\n');
238
+ fs.writeFileSync(path.join(tgtBase, 'a.md'), '# v1\r\n内容乙\r\n');
239
+ const r = sourceSyncCheck({ pkgRoot, target });
240
+ check('S14 内容差异(甲 vs 乙)→ 报漂移(归一只认 CRLF 不吞内容)', r.drift.length === 1 && r.drift[0].rel === 'commands/a.md', JSON.stringify(r.drift));
241
+ }
242
+
155
243
  console.log('\n合计: PASS ' + pass + ' / FAIL ' + failCount);
156
244
  process.exit(failCount ? 1 : 0);
@@ -31,6 +31,23 @@ const walkTree = (dir, rel = '') => {
31
31
 
32
32
  // ---- 磁盘枚举(登记口径与 gen-wiki-board.mjs 一致)----
33
33
  const isKnowledgeDoc = (f) => /\.(md|html)$/i.test(f) && !/\.visual-check\./i.test(f);
34
+ // gitignore 豁免(F4,incident 2026-09-23-code-review-f3-f4-closeout):drafts-archive 含
35
+ // .gitignore 忽略的本机文件(如 *.xlsx),计入会使生成物计数在 fresh clone 必然漂移;
36
+ // 口径 = 排除忽略文件(git 不可用/非仓库时退化为全量计数,fixture 环境不受影响)
37
+ const gitIgnoredArchive = (() => {
38
+ try {
39
+ const out = execSync('git -c core.quotepath=false status --ignored=matching --porcelain -uall -- wiki',
40
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
41
+ const prefix = 'wiki/';
42
+ return new Set(out.split(/\r?\n/)
43
+ .filter((l) => l.startsWith('!! '))
44
+ .map((l) => l.slice(3).trim().replace(/\/$/, ''))
45
+ .filter((p) => p.startsWith(prefix))
46
+ .map((p) => p.slice(prefix.length)));
47
+ } catch {
48
+ return new Set();
49
+ }
50
+ })();
34
51
  const diskFiles = new Set(); // "主题/文件名"(子目录文件名带 子目录/ 前缀)
35
52
  const diskTopics = new Set();
36
53
  const activeTree = { files: [], dirs: [] }; // 递归视图(断链守卫 / 台账登记断言用)
@@ -39,15 +56,18 @@ for (const d of fs.readdirSync(WIKI)) {
39
56
  if (!fs.statSync(p).isDirectory()) continue;
40
57
  if (d === 'drafts-archive') continue;
41
58
  diskTopics.add(d);
59
+ // gitignore 豁免消费(p2-batch2 复核 P1-3):gen 侧排除 ignored 本机件,verify 须同口径——
60
+ // 否则「gen 恒写 N、verify 恒数 N+1」门禁死锁且自愈指引(重跑 gen)无效
61
+ const isIgnored = (rel) => { const k = rel.replace(/\/$/, ''); return gitIgnoredArchive.has(`${d}/${k}`) || gitIgnoredArchive.has(`${d}/${k}/`); }; // 尾斜杠归一(复核 P2-a)
42
62
  for (const f of fs.readdirSync(p)) {
43
- if (fs.statSync(path.join(p, f)).isFile()) diskFiles.add(`${d}/${f}`);
63
+ if (fs.statSync(path.join(p, f)).isFile() && !isIgnored(f)) diskFiles.add(`${d}/${f}`);
44
64
  }
45
65
  if (d !== '数据维护') {
46
66
  for (const sub of fs.readdirSync(p)) {
47
67
  const sp = path.join(p, sub);
48
68
  if (!fs.statSync(sp).isDirectory()) continue;
49
69
  for (const f of fs.readdirSync(sp)) {
50
- if (fs.statSync(path.join(sp, f)).isFile() && isKnowledgeDoc(f)) diskFiles.add(`${d}/${sub}/${f}`);
70
+ if (fs.statSync(path.join(sp, f)).isFile() && isKnowledgeDoc(f) && !isIgnored(`${sub}/${f}`)) diskFiles.add(`${d}/${sub}/${f}`);
51
71
  }
52
72
  }
53
73
  }
@@ -56,27 +76,11 @@ for (const d of fs.readdirSync(WIKI)) {
56
76
  activeTree.dirs.push(...sub.dirs);
57
77
  }
58
78
 
59
- // gitignore 豁免(F4,incident 2026-09-23-code-review-f3-f4-closeout):drafts-archive 含
60
- // .gitignore 忽略的本机文件(如 *.xlsx),计入会使生成物计数在 fresh clone 必然漂移;
61
- // 口径 = 排除忽略文件(git 不可用/非仓库时退化为全量计数,fixture 环境不受影响)
62
- const gitIgnoredArchive = (() => {
63
- try {
64
- const out = execSync('git -c core.quotepath=false status --ignored=matching --porcelain -uall -- wiki/drafts-archive',
65
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
66
- const prefix = 'wiki/drafts-archive/';
67
- return new Set(out.split(/\r?\n/)
68
- .filter((l) => l.startsWith('!! '))
69
- .map((l) => l.slice(3).trim().replace(/\/$/, ''))
70
- .filter((p) => p.startsWith(prefix))
71
- .map((p) => p.slice(prefix.length)));
72
- } catch {
73
- return new Set();
74
- }
75
- })();
79
+
76
80
  // drafts-archive 缺失:ENOENT 崩栈改明示失败(本脚本是门禁,协议目录被删应 exit 1 而非崩栈,2026-09-24)
77
81
  const ARCHIVE_DIR = path.join(WIKI, 'drafts-archive');
78
82
  let archiveCount = 0;
79
- if (fs.existsSync(ARCHIVE_DIR)) archiveCount = walkTree(ARCHIVE_DIR).files.filter((r) => !gitIgnoredArchive.has(r)).length;
83
+ if (fs.existsSync(ARCHIVE_DIR)) archiveCount = walkTree(ARCHIVE_DIR).files.filter((r) => !gitIgnoredArchive.has(`drafts-archive/${r}`)).length; // 集合键带 drafts-archive/ 前缀(复核 P2-b)
80
84
  else problems.push('wiki/drafts-archive/ 目录缺失(wiki 协议结构,模板自带)——归档一致性无从校验');
81
85
 
82
86
  // ---- 看板 DATA 解析 ----
@@ -86,7 +90,25 @@ if (!m) {
86
90
  console.error('❌ 看板 DATA 解析失败:知识沉淀总览.html 中找不到 `const DATA = {...}`');
87
91
  process.exit(1);
88
92
  }
89
- const DATA = eval('(' + m[1] + ')');
93
+ // JSON.parse(p2-batch2):DATA 由 gen-wiki-board 经 JSON.stringify 生成、必为合法 JSON——
94
+ // eval 换 JSON.parse 消除门禁脚本对 repo 内文件执行任意 JS 的面;解析失败即 fail-loud 门禁失败
95
+ let DATA;
96
+ try {
97
+ DATA = JSON.parse(m[1]);
98
+ } catch (e) {
99
+ console.error(`❌ 看板 DATA 不是合法 JSON(应跑 gen-wiki-board.mjs 重生成):${e.message}`);
100
+ process.exit(1);
101
+ }
102
+ // 合计行比对(p2-batch2):合计行有生成(fail-loud 锚点)无验证——手改/陈旧此前不可见
103
+ {
104
+ const totalM = fs.readFileSync(path.join(WIKI, 'INDEX.md'), 'utf8').match(/合计:\*\*(\d+) 份\*\*知识文档,\*\*(\d+) 个主题\*\*/);
105
+ if (totalM) {
106
+ if (Number(totalM[1]) !== diskFiles.size || Number(totalM[2]) !== diskTopics.size) {
107
+ console.error(`❌ INDEX 合计行与磁盘不一致:合计 ${totalM[1]} 份/${totalM[2]} 主题 vs 磁盘 ${diskFiles.size} 份/${diskTopics.size} 主题——跑 gen-wiki-board.mjs 重生成`);
108
+ process.exit(1);
109
+ }
110
+ }
111
+ }
90
112
  const boardFiles = new Set();
91
113
  for (const t of DATA.topics) for (const f of t.files) boardFiles.add(`${t.name}/${f.file}`);
92
114
  const boardTopics = new Set(DATA.topics.map((t) => t.name));
@@ -97,8 +119,9 @@ const indexTopicCounts = new Map(); // 速览表 topic → 文件数
97
119
  for (const line of fs.readFileSync(path.join(WIKI, 'INDEX.md'), 'utf8').split(/\r?\n/)) {
98
120
  const mm = line.match(/^\| ([^|]+) \| ([^|]+) \| wiki\/([^/]+)\/[^|]*\|$/);
99
121
  if (mm) indexFiles.add(`${mm[3].trim()}/${mm[1].trim()}`);
100
- const ov = line.match(/^\| ([^|]+) \| (\d+) \|[^|]*\|$/);
101
- if (ov && ov[1].trim() !== '主题') indexTopicCounts.set(ov[1].trim(), Number(ov[2]));
122
+ // 速览行用途列容忍转义管道(p2-batch2 复核 P2-4:gen 侧转义写入后本解析须同款,否则该主题计数核对静默跳过)
123
+ const ov = line.match(/^\| ([^|]+) \| (\d+) \|((?:\\.|[^|])*)\|$/);
124
+ if (ov && ov[1].trim() !== '主题') indexTopicCounts.set(ov[1].trim().replace(/\\\|/g, '|'), Number(ov[2]));
102
125
  }
103
126
 
104
127
  const diff = (a, b) => [...a].filter((x) => !b.has(x));
@@ -8,7 +8,7 @@ import http from 'node:http';
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { execFile } from 'node:child_process';
11
+ import { execFile, spawnSync } from 'node:child_process';
12
12
  import { ENUMS } from './workflow-enums.mjs';
13
13
 
14
14
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -38,9 +38,10 @@ function parseDoc(text) {
38
38
  return { meta, title };
39
39
  }
40
40
 
41
- // ---- 验收标准 checkbox 统计:仅「## 验收标准」节内(至下一个 ## 标题),无该节返回 null ----
41
+ // ---- 验收标准 checkbox 统计:仅「验收标准」节内(至下一个 ## 标题),无该节返回 null ----
42
+ // 节匹配对齐 check-loop 检查 8 口径(^\s*##\s[^#]*验收标准,2026-09-27 board-kb-p1——「## 三、验收标准」类标题此前识别不到)
42
43
  function parseAcceptance(text) {
43
- const h = text.match(/^## 验收标准.*$/m);
44
+ const h = text.match(/^\s*##\s[^#]*验收标准.*$/m);
44
45
  if (!h) return null;
45
46
  const rest = text.slice(h.index + h[0].length);
46
47
  const next = rest.match(/^## /m);
@@ -50,6 +51,7 @@ function parseAcceptance(text) {
50
51
  const done = (section.match(/^\s*[-*] \[[xX]\]/gm) || []).length;
51
52
  return { done, total };
52
53
  }
54
+ export { parseAcceptance };
53
55
 
54
56
  // ---- 配对断裂检测:按 slug 聚合同族(同名 intent/incident/plan/spec),异常卡附 alerts ----
55
57
  // 规则(口径对齐 check-loop.sh 的硬断档 + 状态枚举,看板为预警层、不阻断;枚举读单源 workflow-enums.txt):
@@ -157,7 +159,12 @@ async function scanBoard() {
157
159
  }
158
160
  detectAlerts(cards);
159
161
  cards.sort((a, b) => b.date.localeCompare(a.date) || a.type.localeCompare(b.type));
160
- return { root: ROOT, pid: process.pid, startedAt: STARTED_AT, counts, cards };
162
+ const loop = loopHardBlocks(); // hard-block 全局告警(单源 check-loop;null = 校验不可用)
163
+ return {
164
+ root: ROOT, pid: process.pid, startedAt: STARTED_AT, counts, cards,
165
+ loopHardBlocks: loop.blocks, loopNote: loop.note || undefined,
166
+ activeStatuses: [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']], // 枚举单源贯通前端(p2-batch2)
167
+ };
161
168
  }
162
169
 
163
170
  // ---- 路径白名单:resolve 后必须仍在 workflow/ 内 ----
@@ -202,13 +209,54 @@ setInterval(() => { for (const res of sseClients) { if (res.destroyed) sseClient
202
209
  const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8' };
203
210
 
204
211
  function sendJson(res, code, obj) {
205
- res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
212
+ // X-Frame-Options(p2-batch2):本地只读服务拒绝被外站 iframe 嵌套(配合 Host 校验收敛攻击面)
213
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'X-Frame-Options': 'DENY' });
206
214
  res.end(JSON.stringify(obj));
207
215
  }
208
216
 
217
+ // ---- hard-block 告警单源(2026-09-27 board-kb-p1)----
218
+ // 看板不再自建与 check-loop 平行的 hard 规则(此前四类缺失:回路断档/验收未对账/确认未对账/确认内容漂移,
219
+ // test.md「双跑断言」曾必失败)——scanBoard 时 spawn check-loop.mjs,解析其 stderr hard-block 段为全局告警数组:
220
+ // check-loop 未来新增 hard 项看板自动跟随,口径永不分叉。输出 banner 为稳定契约(check-loop 头注释明示
221
+ // doctor/pre-push 按输出消费),本解析由双跑断言测试兜底格式漂移。
222
+ export function parseLoopHardBlocks(stderrText) {
223
+ const text = String(stderrText || '');
224
+ const start = text.indexOf('HARD-BLOCK:');
225
+ if (start === -1) return [];
226
+ let seg = text.slice(start);
227
+ const warnIdx = seg.indexOf('WARN');
228
+ if (warnIdx > 0) seg = seg.slice(0, warnIdx);
229
+ return seg.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.startsWith('- ['));
230
+ }
231
+
232
+ function loopHardBlocks() {
233
+ // TTL 缓存(复核 P2-2 rider):check-loop 独跑实测 ~10.5s 且 spawnSync 阻塞事件循环——搜索击键/SSE
234
+ // 连发场景下每请求全跑不可用。60s 内复用上次结果(loopHardBlocks 是预警层非门禁,陈旧 60s 可接受;
235
+ // 前端 loopNote 均如实展示)。进程级缓存,无失效盲区风险(比照 kb 两元组口径弱化声明:预警层容忍)。
236
+ const NOW = Date.now();
237
+ if (loopCache.at && NOW - loopCache.at < 60_000) return loopCache.v;
238
+ const v = (() => {
239
+ try {
240
+ const r = spawnSync(process.execPath, [path.join(ROOT, '.agents', 'scripts', 'check-loop.mjs')], { cwd: ROOT, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 });
241
+ if (r.error) return { blocks: null, note: `check-loop 不可执行:${r.error.message}` };
242
+ return { blocks: parseLoopHardBlocks(r.stderr || ''), note: '' };
243
+ } catch (e) {
244
+ return { blocks: null, note: e.message };
245
+ }
246
+ })();
247
+ loopCache.at = NOW; loopCache.v = v;
248
+ return v;
249
+ }
250
+ const loopCache = { at: 0, v: null };
251
+
209
252
  const server = http.createServer(async (req, res) => {
210
- const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
211
253
  try {
254
+ // Host 校验(DNS rebinding 缓解,2026-09-27 board-kb-p1):本地只读服务只认本机 Host
255
+ const host = String(req.headers.host || '');
256
+ if (host !== `127.0.0.1:${PORT}` && host !== `localhost:${PORT}`) {
257
+ return sendJson(res, 403, { error: `Host 不受信任(仅接受 127.0.0.1:${PORT} / localhost:${PORT})` });
258
+ }
259
+ const url = new URL(req.url, `http://127.0.0.1:${PORT}`); // 畸形请求行抛错 → 400(P1-3:原在 try 外,单包可杀进程)
212
260
  if (url.pathname === '/api/events') {
213
261
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
214
262
  res.write('retry: 3000\n\n');
@@ -238,6 +286,9 @@ const server = http.createServer(async (req, res) => {
238
286
  res.writeHead(200, { 'Content-Type': MIME[path.extname(abs)] || 'application/octet-stream' });
239
287
  fs.createReadStream(abs).pipe(res);
240
288
  } catch (e) {
289
+ if (e && e.code === 'ERR_INVALID_URL') {
290
+ return sendJson(res, 400, { error: `请求行/URL 非法:${e.message}` }); // 畸形请求不杀进程(P1-3);只认 URL 错误码——其余 TypeError 是服务端缺陷,如实 500(复核 P2-1)
291
+ }
241
292
  sendJson(res, 500, { error: e.message });
242
293
  }
243
294
  });
@@ -248,6 +299,10 @@ server.on('error', (e) => {
248
299
  process.exit(1);
249
300
  });
250
301
 
251
- server.listen(PORT, '127.0.0.1', () => {
252
- console.log(`workflow 看板: http://127.0.0.1:${PORT} (Ctrl+C 停止;只读 workflow/,不写任何文件)`);
253
- });
302
+ // isMain 守卫(board-kb-p1):parseLoopHardBlocks 可被测试 import(import 不 listen)
303
+ const isMain = process.argv[1] && process.argv[1].endsWith('workflow-board-server.mjs');
304
+ if (isMain) {
305
+ server.listen(PORT, '127.0.0.1', () => {
306
+ console.log(`workflow 看板: http://127.0.0.1:${PORT} (Ctrl+C 停止;只读 workflow/,不写任何文件)`);
307
+ });
308
+ }
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // workflow-board-server 的纯函数测试(2026-09-27 board-kb-p1)
3
+ // 核心 = 「双跑断言」的机器化:test.md 要求「涉及看板告警规则时,用同批 workflow fixture 双跑 check-loop
4
+ // 与看板 /api/board,断言 hard-block 集合被看板告警覆盖(防口径分叉)」——此前是纯手工步骤且必失败
5
+ // (四类 hard-block 看板无对应项)。告警单源化后看板直接消费 check-loop 输出,本测试把双跑断言固化为:
6
+ // check-loop fixture 输出 → parseLoopHardBlocks → 断言四类 hard-block 全覆盖。
7
+ // 断言:①parseLoopHardBlocks 合成样本四类齐;②真 fixture spawn check-loop → 解析覆盖四类(双跑断言);
8
+ // ③空/无 hard-block → 空数组;④parseAcceptance 节匹配与 check-loop 检查 8 同口径。
9
+ // 用法:node .agents/scripts/workflow-board-server.test.mjs(import 不 listen——server 有 isMain 守卫)
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { parseLoopHardBlocks, parseAcceptance } from './workflow-board-server.mjs';
16
+ import { computeFingerprint } from './confirm-doc.mjs';
17
+
18
+ let pass = 0;
19
+ let fail = 0;
20
+ const check = (desc, cond, detail = '') => {
21
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
22
+ else { fail++; console.log(`FAIL ${desc}${detail ? `\n${detail}` : ''}`); }
23
+ };
24
+
25
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
26
+ const CHECK_LOOP = path.join(SCRIPT_DIR, 'check-loop.mjs');
27
+
28
+ // ---- ①合成样本:四类 hard-block 各一 → 全解析 ----
29
+ {
30
+ const sample = [
31
+ '闭环骨架断档(check-loop.sh)— HARD-BLOCK:',
32
+ '',
33
+ '- [配对断裂] intent 缺 plan:x.md',
34
+ '- [回路断档] inc 选「是」但未指明 intent 文件路径',
35
+ '- [验收未对账] done intent 缺「## 验收标准」节(勾验无从核对):x.md',
36
+ '- [确认未对账] x.md 状态 done 无用户确认记录',
37
+ '- [确认内容漂移] x.md done 后内容与确认台账不符',
38
+ '',
39
+ 'WARN(advisory,不阻断):',
40
+ '',
41
+ '- [WARN 状态未确认] plan 必须...',
42
+ ].join('\n');
43
+ const parsed = parseLoopHardBlocks(sample);
44
+ check('①合成样本解析 5 行 hard-block(WARN 段不混入)', parsed.length === 5, JSON.stringify(parsed));
45
+ check('①四类目标 hard-block 全覆盖(回路断档/验收未对账/确认未对账/确认内容漂移)',
46
+ ['回路断档', '验收未对账', '确认未对账', '确认内容漂移'].every((k) => parsed.some((l) => l.includes(k))),
47
+ JSON.stringify(parsed));
48
+ }
49
+
50
+ // ---- ②双跑(真 fixture):spawn check-loop → parseLoopHardBlocks 覆盖四类 ----
51
+ // **检查 8 换锚后的前置条件(2026-09-28 check8-git-anchor)**:检查 8 的生效日锚已从文件名前缀
52
+ // 改为「文件首次加入 git 的日期」,故「验收未对账」这条 hard 的产出**需要 git 首次加入日期可判定**;
53
+ // 非 git 仓会走存量口径、不再产出该 hard(有意语义,见 spec)。因此本场景需为**真 git 仓**——
54
+ // 这不是放宽断言,而是补上使原断言(四类 hard-block 全覆盖)成立的前置;**断言强度与文案均未变**。
55
+ {
56
+ const T = fs.mkdtempSync(path.join(os.tmpdir(), 'board-loop-test-'));
57
+ for (const s of ['intents', 'specs', 'plans', 'incidents']) fs.mkdirSync(path.join(T, 'workflow', s), { recursive: true });
58
+ fs.mkdirSync(path.join(T, '.agents'), { recursive: true });
59
+ fs.writeFileSync(path.join(T, '.agents', 'workflow-enums.txt'), [
60
+ 'doc.status.all=draft approved done superseded cancelled',
61
+ 'doc.status.confirmed=approved done superseded cancelled',
62
+ 'doc.status.active=draft approved',
63
+ 'doc.status.terminal=done superseded cancelled',
64
+ 'doc.status.abandoned=superseded cancelled',
65
+ 'incident.status.all=open fixed closed',
66
+ 'incident.status.active=open',
67
+ 'level.all=L0 L1 L2 L3',
68
+ ].join('\n') + '\n');
69
+ const w = (rel, c) => fs.writeFileSync(path.join(T, rel), c);
70
+ // 回路断档:incident 三件套选「是」但引用 intent 不存在
71
+ w('workflow/incidents/2026-09-27-loop.md', '---\n状态: open\n级别: L1\n发现: 2026-09-27\n模块: pipeline\n---\n# INCIDENT — loop\n\n## 复盘三件套\n\n1. 结构性修复\n - 是否需要新 intent:\n - 是 → ../intents/2026-09-12-ghost.md\n2. 防复发验证\n3. 规范条目\n');
72
+ // 验收未对账:done intent 无验收标准节(锚 = git 首次加入日期 → 见上)
73
+ w('workflow/intents/2026-09-27-acc.md', '---\n状态: done\n级别: L1\n日期: 2026-09-27\n模块: pipeline\n---\n# INTENT — acc\n');
74
+ // 确认未对账:done 无指纹无台账
75
+ w('workflow/plans/2026-09-27-cg.md', '---\n状态: done\n级别: L1\n日期: 2026-09-27\n---\n# PLAN — cg\n');
76
+ // 确认内容漂移:done + 台账 prev + 落盘后篡改
77
+ {
78
+ const body = '\n## 验收标准(可测试)\n- [x] 用例通过(证据:fixture)\n';
79
+ const pre = `---\n状态: approved\n级别: L1\n日期: 2026-09-28\n确认指纹: ${'a'.repeat(16)}\n---\n# INTENT — drift\n${body}`;
80
+ const fp = computeFingerprint(pre);
81
+ w('workflow/intents/2026-09-28-drift.md', `---\n状态: done\n级别: L1\n日期: 2026-09-28\n确认指纹: ${fp.slice(0, 16)}\n---\n# INTENT — drift\n${body}确认后篡改行\n`);
82
+ fs.writeFileSync(path.join(T, '.agents', 'confirmations.jsonl'),
83
+ JSON.stringify({ ts: '2026-09-28T02:00:00.000Z', doc: 'workflow/intents/2026-09-28-drift.md', stage: 'done', fingerprint: fp, prev: 'approved', source: 'tty' }) + '\n');
84
+ }
85
+ // 配对齐 plan(避免无关阻断干扰可读性——配对断裂也会在 hard 段,不影响四类断言)
86
+ w('workflow/plans/2026-09-27-loop.md', '---\n状态: draft\n级别: L1\n---\n# PLAN — loop\n');
87
+ w('workflow/plans/2026-09-27-acc.md', '---\n状态: draft\n级别: L1\n---\n# PLAN — acc\n');
88
+
89
+ // 真 git 仓(使检查 8 的 git 锚可判定)——本地提交,不入任何远端
90
+ const G = process.platform === 'win32' ? 'git.exe' : 'git';
91
+ spawnSync(G, ['init', '-q'], { cwd: T });
92
+ spawnSync(G, ['add', '-A'], { cwd: T });
93
+ spawnSync(G, ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture'], { cwd: T });
94
+
95
+ const r = spawnSync(process.execPath, [CHECK_LOOP], { cwd: T, encoding: 'utf8', env: { ...process.env, CHECK_LOOP_ROOT: T } });
96
+ const parsed = parseLoopHardBlocks(r.stderr || '');
97
+ check('②双跑:check-loop fixture exit 1(确有 hard-block)', r.status === 1, `exit=${r.status}\n${r.stderr}`);
98
+ check('②双跑断言:四类 hard-block 均被看板告警解析覆盖',
99
+ ['回路断档', '验收未对账', '确认未对账', '确认内容漂移'].every((k) => parsed.some((l) => l.includes(k))),
100
+ JSON.stringify(parsed));
101
+ fs.rmSync(T, { recursive: true, force: true });
102
+ }
103
+
104
+ // ---- ③空/无 hard-block → 空数组 ----
105
+ {
106
+ check('③纯 WARN 输出 → 空数组', parseLoopHardBlocks('check-loop.sh WARN(advisory,不阻断):\n\n- [WARN x]\n').length === 0);
107
+ check('③空输入 → 空数组', parseLoopHardBlocks('').length === 0);
108
+ check('③exit 0 场景(仅 WARN banner)→ 空数组', parseLoopHardBlocks('check-loop.sh WARN(advisory,不阻断):\n\n- [WARN 引用断档] x\n').length === 0);
109
+ }
110
+
111
+ // ---- ④parseAcceptance 与 check-loop 检查 8 节匹配同口径 ----
112
+ {
113
+ const std = '---\n状态: done\n---\n# I\n\n## 验收标准(可测试)\n- [x] 甲(证据:e)\n- [ ] 乙\n';
114
+ const numbered = '---\n状态: done\n---\n# I\n\n## 三、验收标准\n- [x] 甲(证据:e)\n- [ ] 乙\n';
115
+ check('④标准标题解析 1/2', parseAcceptance(std)?.done === 1 && parseAcceptance(std)?.total === 2, JSON.stringify(parseAcceptance(std)));
116
+ check('④带序号标题(## 三、验收标准)此前识别不到、现在同口径', parseAcceptance(numbered)?.total === 2, JSON.stringify(parseAcceptance(numbered)));
117
+ check('④无验收节 → null', parseAcceptance('---\n状态: done\n---\n# I\n') === null);
118
+ }
119
+
120
+ // ---- ⑤XSS 剥离口径(p2-batch2;复核 P2-3 备注)----
121
+ // 注意:strip 与 board/index.html loadDoc 的剥离正则是【同款拷贝、非同源】——改 loadDoc 正则时本场景
122
+ // 须同步(防回归能力受限为已知取舍:抽共享模块需前端打包链路,暂不做)。
123
+ // activeStatuses 载荷断言未入本套件(server 侧 scanBoard 非纯函数不可直调)——
124
+ // 以 /api/board 手工探活留证(复核已实测枚举贯通 + X-Frame-Options DENY)。
125
+ {
126
+ const strip = (s) => s.replace(/<[^>]*>/g, '');
127
+ const evil = '正常文字 <img src=x onerror=alert(1)> 更多 <script>alert(2)</script> 结束';
128
+ check('⑤剥标签:img/script 标签清零,正文文字保留',
129
+ !/<(img|script)/.test(strip(evil)) && strip(evil).includes('正常文字') && strip(evil).includes('结束'),
130
+ strip(evil));
131
+ const md = '## 标题\n\n- 列表 **粗体** `code`\n\n[链接](intents/x.md)';
132
+ check('⑤markdown 语法不受剥标签影响', strip(md) === md);
133
+ }
134
+
135
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
136
+ process.exit(fail ? 1 : 0);
@@ -1,6 +1,8 @@
1
1
  // workflow-enums 读取器——.agents/workflow-enums.txt 单源(2026-09-25 enum-single-source)
2
- // 消费方:workflow-board-server.mjs / kb-search.mjs / gen-workflow-index.mjs / fill-{intent,plan,spec}.mjs
3
- // (check-loop.sh 为 POSIX sh,自行 sed 读取同文件,口径同本读取器的键)。
2
+ // 消费方:check-loop.mjs / workflow-board-server.mjs / kb-search.mjs / gen-workflow-index.mjs /
3
+ // fill-{intent,plan,spec}.mjs(均经本读取器)。(2026-09-26 check-loop sh→node 迁移后,
4
+ // 原先的 sh sed 消费方已退役——全部消费方统一经本读取器,无跨语言读取口径;S11 测试仍守
5
+ // 「空格分隔 + # 注释」格式对简单工具保持可解析)
4
6
  // fail-loud 无 fallback:kit 自带该文件,缺失/坏数据属安装破损——直接抛错崩得可见,
5
7
  // doctor §2 布局门同时把关文件存在性。禁止在任何消费方内再抄一份枚举字面量。
6
8
  import fs from 'node:fs';
@@ -1,7 +1,7 @@
1
1
  # workflow 文档枚举单源(2026-09-25 enum-single-source)
2
- # 本文件会被 core.autocrlf 检出为 CRLF——读取方显式去 \r(check-loop enum_val / node parseEnums),禁止依赖行尾格式(rule-budgets.txt 同约定)。
3
- # 消费方:check-loop.sh / workflow-board-server.mjs / kb-search.mjs / gen-workflow-index.mjs /
4
- # fill-{intent,plan,spec}.mjs(经 scripts/workflow-enums.mjs 读取器)——改枚举改这里,别改消费方。
2
+ # 本文件会被 core.autocrlf 检出为 CRLF——读取方显式去 \r(node parseEnums),禁止依赖行尾格式(rule-budgets.txt 同约定)。
3
+ # 消费方:check-loop.mjs / workflow-board-server.mjs / kb-search.mjs / gen-workflow-index.mjs /
4
+ # fill-{intent,plan,spec}.mjs(均经 scripts/workflow-enums.mjs 读取器)——改枚举改这里,别改消费方。
5
5
  # 格式:key=value value value(空格分隔;# 注释行忽略;键重复 / 值重复 / 缺键 = 消费方 fail-loud)
6
6
  # 语义:doc = intent/spec/plan 三件;incident = 事故件。
7
7
  # all 全生命周期状态
@@ -70,5 +70,6 @@ params: env(目标环境);timeout-min(可选,默认 10)
70
70
  - 子智能体不跨确认门、不 commit / push(派单红线行:只改授权文件;缺输入或需偏离 → `BLOCKER:` 停下)。确认门一律用 human 形态行表达,不得派子智能体代行。
71
71
  - 收敛复核:派单返回后核对 diff 与授权文件范围(与 build.md 委派约定同口径);BLOCKER 停住待处置。
72
72
  - **run journal 三条**:① 每个 stage 每次尝试出结果即 `wf-journal.mjs add` 记一行(含重试与 human 门结论);② 中止后重跑先 `status`——已 pass 不重跑,从就绪层续;③ 收尾可把本 run 的 status 汇总粘 `workflow/delegations.md` 留痕(聚合脚本不读 journal)。
73
+ - **journal 是自报态,非验证态**:run journal 由执行者(宿主 AI)自己记——concurrency / retry / 断点续跑语义的保证取决于记账纪律,编排层无运行时派发器强制(wf-run 已废,执行按本机制文档语义);中止续跑时对「已 pass」结论保留人工核对权(2026-09-27 audit-gate-hardening 边界声明)。
73
74
  - 留痕:每个派单 / step stage 完成后向 `workflow/delegations.md` 委派结果表追加一行(`一次通过` / `返工×N` / `返工待修`,口径与 agg-delegations.cjs 对齐)。
74
75
  - 解析校验先行(机器门):`node .agents/scripts/workflows-check.mjs`——role ∈ `.agents/roles/`、step ∈ `steps/`、after 引用存在且无环、四形态恰填一项、retries/concurrency 数值合法;任一不过即拒,不执行。
@@ -1,6 +1,6 @@
1
1
  #!/bin/sh
2
2
  # Git 原生 pre-commit hook(经 core.hooksPath 挂载,git 执行、不依赖 agent 客户端)— agentic-flow-kit 通用编排
3
- # 通用门禁序:闭环配对(增量) → wiki 台账(增量) → 常驻面体积预算(增量) → 项目门禁(local-pre-commit) → 敏感信息扫描+条件构建(commit-check.cjs)
3
+ # 通用门禁序:闭环配对(增量) → wiki 台账(增量) → 常驻面体积预算(增量) → 双源一致性(增量) → 项目门禁(local-pre-commit) → 敏感信息扫描+条件构建(commit-check.cjs)
4
4
  # 项目专属门禁(架构红线/业务守卫等)放 .agents/hooks/local-pre-commit——存在即在本钩子内执行,返回非零即阻断提交
5
5
  # 合并提交场景由 .githooks/pre-merge-commit 原样转发到本脚本(合并时 git 调的是 pre-merge-commit,
6
6
  # 不是 pre-commit)——门禁清单只此一份,避免两处分叉
@@ -31,6 +31,21 @@ if [ -f .agents/scripts/rule-budget.sh ] && [ -f .agents/rule-budgets.txt ]; the
31
31
  run_gate "常驻面体积预算" .agents/scripts/rule-budget.sh --staged
32
32
  fi
33
33
 
34
+ # 双源一致性(增量,2026-09-27 audit-gate-hardening):包源 templates/_agents/ 与装副本 .agents/ 须同提交成对。
35
+ # 暂存触及任一侧时跑 source-sync-check --gate(缺失/漂移 exit 1,孤儿只报告)——拦「包源改了装副本没跟」
36
+ # (doctor §4 台账 sha 对此静默:盘面=台账即 PASS;该故障模式曾两次真实发生,见 incidents/2026-09-25-wf-runtime)。
37
+ # 比较工作树而非暂存区(口径沿 check-pairing-incremental 先例):两侧都已改好、分两笔提交时不拦——最终一致即可。
38
+ # 已知边界(复核 P2-2 声明,2026-09-27 gate-hardening-p2-batch):包源删除文件不触发缺失/漂移——装副本残件
39
+ # 退化为「孤儿」只报告不计失败,删包源时须手动同步清理装副本(sync 对 removed 同样仅报告不删盘)。
40
+ # 装户环境(无 templates/_agents/ 包源)自然跳过。
41
+ if [ -d templates/_agents ] && [ -f .agents/scripts/source-sync-check.mjs ] \
42
+ && git -c core.quotepath=off diff --cached --name-only | grep -qE '^(templates/_agents/|\.agents/)'; then
43
+ if ! node .agents/scripts/source-sync-check.mjs --gate; then
44
+ echo "pre-commit: 双源一致性(增量)未通过,提交已阻止——单边改动先跑 node bin/flow-kit.mjs sync 成对后原路重试" >&2
45
+ exit 1
46
+ fi
47
+ fi
48
+
34
49
  # 项目门禁挂载点:.agents/hooks/local-pre-commit(项目自建门禁/门禁模块接线处,存在即执行)
35
50
  if [ -f .agents/hooks/local-pre-commit ]; then
36
51
  run_gate "项目门禁(local-pre-commit)" .agents/hooks/local-pre-commit