agentic-flow-kit 0.5.0 → 0.7.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 (85) 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/profiles.mjs +19 -2
  43. package/src/render.mjs +64 -4
  44. package/src/run-tests.mjs +3 -0
  45. package/src/sync-hosts.mjs +3 -2
  46. package/src/sync.mjs +64 -20
  47. package/src/sync.test.mjs +42 -0
  48. package/src/trae-hooks.test.mjs +115 -0
  49. package/templates/AGENTS.md +1 -0
  50. package/templates/_agents/board/index.html +32 -3
  51. package/templates/_agents/commands/build.md +5 -4
  52. package/templates/_agents/commands/design.md +1 -0
  53. package/templates/_agents/commands/maintain.md +1 -2
  54. package/templates/_agents/commands/plan.md +2 -0
  55. package/templates/_agents/commands/test.md +2 -1
  56. package/templates/_agents/hooks/check-pairing-incremental.sh +20 -7
  57. package/templates/_agents/metric-claims.txt +81 -0
  58. package/templates/_agents/roles/implementer.md +2 -0
  59. package/templates/_agents/roles/independent-reviewer.md +2 -0
  60. package/templates/_agents/roles/ui-verifier.md +2 -0
  61. package/templates/_agents/scripts/agg-delegations.cjs +39 -30
  62. package/templates/_agents/scripts/agg-delegations.test.mjs +32 -0
  63. package/templates/_agents/scripts/check-loop.mjs +538 -16
  64. package/templates/_agents/scripts/check-loop.test.mjs +1116 -0
  65. package/templates/_agents/scripts/confirm-doc.mjs +228 -0
  66. package/templates/_agents/scripts/confirm-doc.test.mjs +321 -0
  67. package/templates/_agents/scripts/doctor.test.mjs +81 -3
  68. package/templates/_agents/scripts/ensure-board.mjs +29 -2
  69. package/templates/_agents/scripts/gate-checklist.mjs +3 -0
  70. package/templates/_agents/scripts/gate-checklist.test.mjs +3 -3
  71. package/templates/_agents/scripts/gen-wiki-board.mjs +38 -24
  72. package/templates/_agents/scripts/gen-wiki-board.test.mjs +18 -0
  73. package/templates/_agents/scripts/gen-workflow-metrics.mjs +31 -3
  74. package/templates/_agents/scripts/kb-search.mjs +22 -8
  75. package/templates/_agents/scripts/kb-search.test.mjs +38 -8
  76. package/templates/_agents/scripts/source-sync-check.mjs +30 -5
  77. package/templates/_agents/scripts/source-sync-check.test.mjs +133 -8
  78. package/templates/_agents/scripts/verify-wiki-consistency.mjs +46 -23
  79. package/templates/_agents/scripts/workflow-board-server.mjs +64 -9
  80. package/templates/_agents/scripts/workflow-board-server.test.mjs +136 -0
  81. package/templates/_agents/scripts/workflow-enums.mjs +4 -2
  82. package/templates/_agents/scripts/workflows-check.test.mjs +14 -3
  83. package/templates/_agents/workflow-enums.txt +3 -3
  84. package/templates/_agents/workflows/_TEMPLATE.md +1 -0
  85. package/templates/_githooks/pre-commit +16 -1
@@ -5,8 +5,10 @@
5
5
  // 排除装副本独有文件:kit.json / settings.json / hooks/commit-check.config.json(init 渲染产物,不属双源)
6
6
  // 用法:node .agents/scripts/source-sync-check.mjs --diff
7
7
  // node .agents/scripts/source-sync-check.mjs --json
8
+ // node .agents/scripts/source-sync-check.mjs --gate(门禁模式:缺失/漂移 exit 1,孤儿只报告不计失败
9
+ // ——pre-commit 双源一致性门禁用,2026-09-27 audit-gate-hardening;--diff/--json 仍恒 exit 0)
8
10
  // node .agents/scripts/source-sync-check.mjs --pkg-root <path> --target <path>
9
- // 零依赖;B-b 决策「只报告不修复」
11
+ // 零依赖;B-b 决策「只报告不修复」(--gate 只加 exit 码裁决,不改默认行为)
10
12
  import fs from 'node:fs';
11
13
  import path from 'node:path';
12
14
  import { createHash } from 'node:crypto';
@@ -16,11 +18,20 @@ function fail(msg) { console.error('❌ ' + msg); process.exit(1); }
16
18
  const VALID_EXTS = new Set(['.md', '.mjs', '.json', '.txt']);
17
19
  // 装副本独有文件(init 渲染产物,不属双源结构)
18
20
  const RENDER_OUTPUT_FILES = new Set(['kit.json', 'settings.json', 'hooks/commit-check.config.json']);
19
- const TARGET_EXCLUDE = RENDER_OUTPUT_FILES; // 向后兼容别名
21
+ // **项目自持、按设计就该与包源骨架不同**的架子文件(2026-09-28 追加):
22
+ // `notes/runtime-env.md` 自述「记录本机/本项目的运行时差异……**本文件是骨架:项目按实际填写,
23
+ // 不随 flow-kit 升级覆盖**」——即包源只发骨架、装户填实况,**两处本就应当不同**。
24
+ // 但本工具此前按 `.md` 全量比对,导致装户一旦按文件自述填写,`--gate` 即 exit 1(CI 红):
25
+ // 实测(本仓自装)填写后 --gate 报 `漂移 1: notes/runtime-env.md`,回滚即恢复 0。
26
+ // 这属「门禁范围与文件自述冲突」,非真漂移——故按渲染产物同类处理(排除)。
27
+ // 判据:该文件是**项目内容**而非**引擎件**;引擎件(.mjs 脚本等)不在排除内,仍受双源门禁约束。
28
+ const PROJECT_OWNED_FILES = new Set(['notes/runtime-env.md']);
29
+ const TARGET_EXCLUDE = new Set([...RENDER_OUTPUT_FILES, ...PROJECT_OWNED_FILES]); // 向后兼容别名(原为 RENDER_OUTPUT_FILES)
20
30
 
21
31
  function sha256(p) {
22
32
  try {
23
- return createHash('sha256').update(fs.readFileSync(p)).digest('hex');
33
+ // LF 归一(2026-09-27 closing-coverage):CRLF 盘面与 LF 克隆同值,行尾不一致不误报缺失/漂移/孤儿外差异
34
+ return createHash('sha256').update(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8').digest('hex');
24
35
  } catch {
25
36
  return null;
26
37
  }
@@ -47,7 +58,10 @@ function walkMd(root) {
47
58
  export function sourceSyncCheck({ pkgRoot, target }) {
48
59
  const authorityRoot = path.join(pkgRoot, 'templates', '_agents');
49
60
  const adapterRoot = path.join(target, '.agents');
50
- const pkgFiles = walkMd(authorityRoot);
61
+ // **两侧都要按 TARGET_EXCLUDE 过滤**(2026-09-28 修正):只过滤装副本一侧会把排除件
62
+ // 变成「包源有 / 装副本无」的**缺失**(实测:加排除后 --gate 从「漂移 1」变成「缺失 1」,
63
+ // 仍 exit 1)。排除的语义是「不参与双源比对」,故包源侧同样滤掉。
64
+ const pkgFiles = walkMd(authorityRoot).filter((f) => !TARGET_EXCLUDE.has(f));
51
65
  const tgtFiles = walkMd(adapterRoot).filter((f) => !TARGET_EXCLUDE.has(f));
52
66
  const pkgSet = new Set(pkgFiles);
53
67
  const tgtSet = new Set(tgtFiles);
@@ -98,12 +112,13 @@ function printDiff(result) {
98
112
 
99
113
  const isMain = process.argv[1] && process.argv[1].endsWith('source-sync-check.mjs');
100
114
  if (isMain) {
101
- let jsonMode = false, diffMode = false;
115
+ let jsonMode = false, diffMode = false, gateMode = false;
102
116
  let pkgRoot = null, target = null;
103
117
  for (let i = 0; i < process.argv.length - 2; i++) {
104
118
  const a = process.argv[2 + i];
105
119
  if (a === '--json') jsonMode = true;
106
120
  else if (a === '--diff') diffMode = true;
121
+ else if (a === '--gate') gateMode = true;
107
122
  else if (a === '--pkg-root') pkgRoot = process.argv[2 + i + 1];
108
123
  else if (a === '--target') target = process.argv[2 + i + 1];
109
124
  }
@@ -125,6 +140,16 @@ if (isMain) {
125
140
  } else {
126
141
  printDiff(result);
127
142
  }
143
+ if (gateMode) {
144
+ // 门禁模式(pre-commit 双源一致性用):缺失/漂移非零阻断——正是「包源改了装副本没跟」的故障模式
145
+ // (doctor §4 台账 sha 对此静默:盘面=台账即 PASS;§4.5 只抓未登记)。孤儿只报告不计失败:
146
+ // 孤儿 = 装副本独有(装户自持内容如本地 skills),删留由人拍板,不入机器门。
147
+ if (result.missing.length || result.drift.length) {
148
+ console.error('❌ 双源不一致(缺失 ' + result.missing.length + ' / 漂移 ' + result.drift.length + ')——包源与装副本须同提交成对(双源纪律);跑 node bin/flow-kit.mjs sync 后原路重试(孤儿只报告不计失败)');
149
+ process.exit(1);
150
+ }
151
+ process.exit(0);
152
+ }
128
153
  // diff/json 模式总 exit 0;差异存在不视作错误(人类阅读用;B-b 决策「只报告不修复」)
129
154
  process.exit(0);
130
155
  }
@@ -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,114 @@ 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
+
243
+ // ---- 场景 15-16:项目自持架子文件排除(2026-09-28 runtime-env 双源冲突)----
244
+ {
245
+ // S15 notes/runtime-env.md **两侧都被排除**——包源只发骨架、装户填实况,两处本就该不同。
246
+ // 回归:首版只过滤装副本一侧 → 排除件变成「包源有/装副本无」的**缺失**,--gate 仍 exit 1
247
+ // (实测:漂移 1 → 缺失 1)。故断言 missing/drift 皆为空。
248
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-ssc-owned-'));
249
+ const pkgRoot = path.join(root, 'pkg');
250
+ const target = path.join(root, 'target');
251
+ const pkgBase = path.join(pkgRoot, 'templates', '_agents', 'notes');
252
+ const tgtBase = path.join(target, '.agents', 'notes');
253
+ fs.mkdirSync(pkgBase, { recursive: true });
254
+ fs.mkdirSync(tgtBase, { recursive: true });
255
+ fs.writeFileSync(path.join(pkgBase, 'runtime-env.md'), '# 骨架\n> 项目按实际填写\n');
256
+ fs.writeFileSync(path.join(tgtBase, 'runtime-env.md'), '# 实况\n> 本机 sh 路径已接\n');
257
+ const r = sourceSyncCheck({ pkgRoot, target });
258
+ check('S15 项目自持 runtime-env.md 两侧内容不同 → 既不报缺失也不报漂移(双侧排除)',
259
+ r.missing.length === 0 && r.drift.length === 0,
260
+ JSON.stringify({ missing: r.missing, drift: r.drift }));
261
+ }
262
+ {
263
+ // S16 排除**只针对该文件**——同目录其他 .md 仍受双源门禁约束(防排除范围被误放大)
264
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-ssc-owned2-'));
265
+ const pkgRoot = path.join(root, 'pkg');
266
+ const target = path.join(root, 'target');
267
+ const pkgBase = path.join(pkgRoot, 'templates', '_agents', 'notes');
268
+ const tgtBase = path.join(target, '.agents', 'notes');
269
+ fs.mkdirSync(pkgBase, { recursive: true });
270
+ fs.mkdirSync(tgtBase, { recursive: true });
271
+ fs.writeFileSync(path.join(pkgBase, 'runtime-env.md'), '# 骨架\n');
272
+ fs.writeFileSync(path.join(tgtBase, 'runtime-env.md'), '# 实况\n');
273
+ fs.writeFileSync(path.join(pkgBase, 'other.md'), '# v1\n');
274
+ fs.writeFileSync(path.join(tgtBase, 'other.md'), '# v2\n');
275
+ const r = sourceSyncCheck({ pkgRoot, target });
276
+ check('S16 排除不放大——同目录 other.md 内容不同仍报漂移',
277
+ r.drift.length === 1 && r.drift[0].rel === 'notes/other.md', JSON.stringify(r.drift));
278
+ }
279
+
155
280
  console.log('\n合计: PASS ' + pass + ' / FAIL ' + failCount);
156
281
  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';