agentic-flow-kit 1.7.0 → 1.7.15

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 (110) hide show
  1. package/README.md +40 -6
  2. package/modules/gates/README.md +1 -0
  3. package/modules/gates/test-protect/README.md +23 -0
  4. package/modules/gates/test-protect/check-test-protect.mjs +64 -0
  5. package/modules/gates/test-protect/local-pre-commit.line +1 -0
  6. package/modules/hosts/README.md +57 -0
  7. package/modules/hosts/claude/commands/wf-build.md +5 -24
  8. package/modules/hosts/claude/commands/wf-deploy.md +1 -0
  9. package/modules/hosts/claude/commands/wf-design.md +2 -2
  10. package/modules/hosts/claude/commands/wf-gate-checklist.md +1 -3
  11. package/modules/hosts/claude/commands/wf-plan.md +1 -1
  12. package/modules/hosts/claude/commands/wf-source-sync-check.md +1 -3
  13. package/modules/hosts/claude/commands/wf-sync-hosts.md +1 -3
  14. package/modules/hosts/claude/commands/wf-test.md +5 -4
  15. package/modules/hosts/codex/commands/wf-build.md +5 -24
  16. package/modules/hosts/codex/commands/wf-deploy.md +1 -0
  17. package/modules/hosts/codex/commands/wf-design.md +2 -2
  18. package/modules/hosts/codex/commands/wf-gate-checklist.md +1 -3
  19. package/modules/hosts/codex/commands/wf-plan.md +1 -1
  20. package/modules/hosts/codex/commands/wf-source-sync-check.md +1 -3
  21. package/modules/hosts/codex/commands/wf-sync-hosts.md +1 -3
  22. package/modules/hosts/codex/commands/wf-test.md +5 -4
  23. package/modules/hosts/cursor/commands/wf-build.md +5 -24
  24. package/modules/hosts/cursor/commands/wf-deploy.md +1 -0
  25. package/modules/hosts/cursor/commands/wf-design.md +2 -2
  26. package/modules/hosts/cursor/commands/wf-gate-checklist.md +1 -3
  27. package/modules/hosts/cursor/commands/wf-plan.md +1 -1
  28. package/modules/hosts/cursor/commands/wf-source-sync-check.md +1 -3
  29. package/modules/hosts/cursor/commands/wf-sync-hosts.md +1 -3
  30. package/modules/hosts/cursor/commands/wf-test.md +5 -4
  31. package/modules/hosts/opencode/commands/wf-build.md +5 -24
  32. package/modules/hosts/opencode/commands/wf-deploy.md +1 -0
  33. package/modules/hosts/opencode/commands/wf-design.md +2 -2
  34. package/modules/hosts/opencode/commands/wf-gate-checklist.md +1 -3
  35. package/modules/hosts/opencode/commands/wf-plan.md +1 -1
  36. package/modules/hosts/opencode/commands/wf-source-sync-check.md +1 -3
  37. package/modules/hosts/opencode/commands/wf-sync-hosts.md +1 -3
  38. package/modules/hosts/opencode/commands/wf-test.md +5 -4
  39. package/modules/hosts/trae/commands/wf-build.md +5 -24
  40. package/modules/hosts/trae/commands/wf-deploy.md +1 -0
  41. package/modules/hosts/trae/commands/wf-design.md +2 -2
  42. package/modules/hosts/trae/commands/wf-gate-checklist.md +1 -3
  43. package/modules/hosts/trae/commands/wf-plan.md +1 -1
  44. package/modules/hosts/trae/commands/wf-source-sync-check.md +1 -3
  45. package/modules/hosts/trae/commands/wf-sync-hosts.md +1 -3
  46. package/modules/hosts/trae/commands/wf-test.md +5 -4
  47. package/package.json +2 -2
  48. package/src/add-gate.mjs +25 -1
  49. package/src/add-gate.test.mjs +103 -0
  50. package/src/cli.mjs +17 -2
  51. package/src/doctor.mjs +99 -11
  52. package/src/gates.test.mjs +17 -0
  53. package/src/init.mjs +7 -1
  54. package/src/init.test.mjs +19 -0
  55. package/src/instance-ref.test.mjs +62 -0
  56. package/src/jscpd-drift.test.mjs +144 -0
  57. package/src/render.mjs +5 -0
  58. package/src/rule-budget.test.mjs +103 -0
  59. package/src/sync-hosts.mjs +4 -2
  60. package/src/trae-hooks.test.mjs +15 -3
  61. package/src/validate.mjs +72 -0
  62. package/src/validate.test.mjs +95 -0
  63. package/templates/AGENTS.md +1 -0
  64. package/templates/_agents/commands/build.md +5 -24
  65. package/templates/_agents/commands/deploy.md +1 -0
  66. package/templates/_agents/commands/design.md +2 -2
  67. package/templates/_agents/commands/gate-checklist.md +1 -3
  68. package/templates/_agents/commands/plan.md +1 -1
  69. package/templates/_agents/commands/source-sync-check.md +1 -3
  70. package/templates/_agents/commands/sync-hosts.md +1 -3
  71. package/templates/_agents/commands/test.md +5 -4
  72. package/templates/_agents/control-surfaces.txt +53 -0
  73. package/templates/_agents/scripts/agg-delegations.cjs +51 -6
  74. package/templates/_agents/scripts/agg-delegations.test.mjs +79 -1
  75. package/templates/_agents/scripts/check-engine-integrity.mjs +288 -0
  76. package/templates/_agents/scripts/check-engine-integrity.test.mjs +227 -0
  77. package/templates/_agents/scripts/check-lane-surface.mjs +7 -2
  78. package/templates/_agents/scripts/check-ledger.test.mjs +2 -0
  79. package/templates/_agents/scripts/check-loop.mjs +206 -4
  80. package/templates/_agents/scripts/check-loop.test.mjs +312 -2
  81. package/templates/_agents/scripts/confirm-doc.mjs +14 -1
  82. package/templates/_agents/scripts/confirm-doc.test.mjs +59 -0
  83. package/templates/_agents/scripts/doctor.test.mjs +251 -1
  84. package/templates/_agents/scripts/draft-sediment.mjs +178 -0
  85. package/templates/_agents/scripts/draft-sediment.test.mjs +272 -0
  86. package/templates/_agents/scripts/gate-checklist.mjs +10 -2
  87. package/templates/_agents/scripts/gate-checklist.test.mjs +7 -1
  88. package/templates/_agents/scripts/gen-wiki-board.mjs +61 -6
  89. package/templates/_agents/scripts/gen-wiki-board.test.mjs +74 -1
  90. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +2 -2
  91. package/templates/_agents/scripts/gen-workflow-metrics.mjs +11 -5
  92. package/templates/_agents/scripts/kb-search.mjs +87 -12
  93. package/templates/_agents/scripts/kb-search.test.mjs +83 -0
  94. package/templates/_agents/scripts/pipeline-run.mjs +1 -1
  95. package/templates/_agents/scripts/solidify-task.mjs +1 -1
  96. package/templates/_agents/scripts/source-sync-check.test.mjs +9 -4
  97. package/templates/_agents/scripts/stage-gates.mjs +48 -1
  98. package/templates/_agents/scripts/stage-gates.test.mjs +105 -0
  99. package/templates/_agents/scripts/verify-wiki-consistency.mjs +34 -0
  100. package/templates/_agents/scripts/verify-wiki-consistency.test.mjs +44 -2
  101. package/templates/_agents/scripts/verify.mjs +1 -1
  102. package/templates/_agents/scripts-test-exempt.txt +3 -1
  103. package/templates/_agents/skills/wiki/SKILL.md +5 -3
  104. package/templates/wiki/INDEX.md +44 -1
  105. package/templates/wiki//347/237/245/350/257/206/346/262/211/346/267/200/346/200/273/350/247/210.html +22 -1
  106. package/templates/workflow/delegations.md +5 -4
  107. package/templates/workflow/incidents/_TEMPLATE.md +2 -0
  108. package/templates/workflow/intents/_TEMPLATE.md +3 -0
  109. package/templates/workflow/plans/_TEMPLATE.md +4 -0
  110. package/templates/workflow/specs/_TEMPLATE.md +23 -0
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ // check-engine-integrity.test.mjs — 门禁本体完整性门 fixture 套件(2026-10-09 P0-A guard-the-guards)
3
+ // 方法:构造临时根(templates/_agents/scripts + .githooks + workflow/intents),spawn 脚本断言
4
+ // exit code 与 [标签];跨提交锚用真 git 仓(init→commit→改弱→--update)端到端验证——
5
+ // 那一条是本门存在的理由:本地层自洽(盘面↔基线同刷)时,唯祖先提交参照物仍能出账。
6
+ // 用法:node templates/_agents/scripts/check-engine-integrity.test.mjs(npm test 随跑)
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
14
+ const SCRIPT = path.join(SCRIPT_DIR, 'check-engine-integrity.mjs');
15
+
16
+ let pass = 0;
17
+ let fail = 0;
18
+ const check = (desc, cond, detail = '') => {
19
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
20
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
21
+ };
22
+
23
+ const w = (root, rel, content) => {
24
+ const abs = path.join(root, rel);
25
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
26
+ fs.writeFileSync(abs, content);
27
+ };
28
+ const run = (root, args = []) => {
29
+ const r = spawnSync(process.execPath, [SCRIPT, '--root', root, ...args], { encoding: 'utf8' });
30
+ return { code: r.status, out: `${r.stdout || ''}${r.stderr || ''}` };
31
+ };
32
+ const git = (root, args) => spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
33
+ const haveGit = (() => { const r = spawnSync('git', ['--version'], { encoding: 'utf8' }); return r.status === 0; })();
34
+
35
+ // 通用 fixture:一份门禁脚本(2 个 blocker + 1 个 exit(1) = 3 硬拦单位)、一个钩子(1 个 exit 1)、AGENTS.md
36
+ const mkfix = ({ withEntry = false, entryNames = 'gate.mjs' } = {}) => {
37
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-test-'));
38
+ w(d, 'templates/_agents/scripts/gate.mjs',
39
+ '// 门禁样例\nblockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n');
40
+ w(d, '.githooks/pre-commit', '#!/bin/sh\nif ! node check.mjs; then\n echo blocked >&2\n exit 1\nfi\nexit 0\n');
41
+ w(d, 'AGENTS.md', '# 协议\n- 规则一\n- 规则二\n');
42
+ if (withEntry) {
43
+ w(d, 'workflow/intents/2026-10-09-ctrl.md',
44
+ `---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:templates/_agents/scripts/${entryNames}\n`);
45
+ }
46
+ return d;
47
+ };
48
+
49
+ // --- T1 基线缺失 = 未启用(旧装副本零改动,exit 0)---
50
+ {
51
+ const r = run(mkfix());
52
+ check('T1 无基线 → exit 0 且报「未启用」', r.code === 0 && r.out.includes('未启用'), r.out);
53
+ }
54
+
55
+ // --- T2 --update 生成基线后自身一致 ---
56
+ {
57
+ const d = mkfix();
58
+ const u = run(d, ['--update']);
59
+ // fixture 无 .agents/ → 基线落包源径(resolveLock 布局回落,见 T9/T10)
60
+ const lockPath = path.join(d, 'templates', '_agents', 'engine-lock.json');
61
+ let lock = null;
62
+ try { lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { /* 断言兜 */ }
63
+ const r = run(d);
64
+ check('T2a --update → exit 0 且基线入账受检面', u.code === 0 && !!lock && !!lock.files['templates/_agents/scripts/gate.mjs'], u.out);
65
+ check('T2b 基线记 hard=3(2 blocker + 1 exit(1))', !!lock && lock.files['templates/_agents/scripts/gate.mjs'].hard === 3, JSON.stringify(lock && lock.files['templates/_agents/scripts/gate.mjs']));
66
+ check('T2c 钩子入面 hard=1(exit 1 计数)', !!lock && lock.files['.githooks/pre-commit'].hard === 1, JSON.stringify(lock && lock.files['.githooks/pre-commit']));
67
+ check('T2d 盘面未变 → exit 0 且报「与基线一致」', r.code === 0 && r.out.includes('盘面与基线一致'), r.out);
68
+ }
69
+
70
+ // --- T3 硬拦单位数下降 → 无条件拦(有入口也不放行)---
71
+ {
72
+ const d = mkfix({ withEntry: true });
73
+ run(d, ['--update']);
74
+ w(d, 'templates/_agents/scripts/gate.mjs', '// 门禁样例(退役一条 blocker)\nblockers.push(`A`);\nif (bad) process.exit(1);\n');
75
+ const r = run(d);
76
+ check('T3 删 blocker.push → [硬拦面收缩] 且点名文件与减量',
77
+ r.code === 1 && r.out.includes('[硬拦面收缩]') && r.out.includes('gate.mjs') && r.out.includes('3 → 2'), r.out);
78
+ check('T3b 有活跃 L2 入口仍拦(收缩无豁免出口)', r.code === 1 && r.out.includes('[硬拦面收缩]'), r.out);
79
+ }
80
+
81
+ // --- T4 非收缩改动:无入口拦、有入口放行 ---
82
+ {
83
+ const d = mkfix();
84
+ run(d, ['--update']);
85
+ w(d, 'templates/_agents/scripts/gate.mjs', '// 门禁样例(改写文案)\n// 说明行\ntoast(`hint`);\nblockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n');
86
+ const noEntry = run(d);
87
+ check('T4a 内容漂移 + 无入口 → [控制面改动无入口]',
88
+ noEntry.code === 1 && noEntry.out.includes('[控制面改动无入口]') && noEntry.out.includes('gate.mjs'), noEntry.out);
89
+ w(d, 'workflow/intents/2026-10-09-ctrl.md',
90
+ '---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:templates/_agents/scripts/gate.mjs\n');
91
+ const withEntry = run(d);
92
+ check('T4b 同一改动 + 活跃 L2 入口点名该件 → 放行', withEntry.code === 0, withEntry.out);
93
+ }
94
+ // --- T4c 入口存在但级别为 L1(协作道)→ 不算点名 ---
95
+ {
96
+ const d = mkfix({ withEntry: false });
97
+ run(d, ['--update']);
98
+ w(d, 'templates/_agents/scripts/gate.mjs', 'blockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n// 追加注释不改硬拦面\n');
99
+ w(d, 'workflow/intents/2026-10-09-low.md', '---\n状态: approved\n级别: L1\n日期: 2026-10-09\n模块: gate\n---\n改动面:gate.mjs\n');
100
+ const r = run(d);
101
+ check('T4c L1 入口点名不豁免(控制面须防御道)', r.code === 1 && r.out.includes('[控制面改动无入口]'), r.out);
102
+ }
103
+ // --- T4d 入口非活跃(done 已关单)→ 不算点名 ---
104
+ {
105
+ const d = mkfix({ withEntry: false });
106
+ run(d, ['--update']);
107
+ w(d, 'templates/_agents/scripts/gate.mjs', 'blockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n// 追加注释\n');
108
+ w(d, 'workflow/intents/2026-10-09-done.md', '---\n状态: done\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n改动面:gate.mjs\n');
109
+ const r = run(d);
110
+ check('T4d done 入口不点名(活跃口径:draft/approved)', r.code === 1 && r.out.includes('[控制面改动无入口]'), r.out);
111
+ }
112
+
113
+ // --- T5 门禁件消失 / 新增件未入基线 ---
114
+ {
115
+ const d = mkfix();
116
+ run(d, ['--update']);
117
+ fs.rmSync(path.join(d, 'templates/_agents/scripts/gate.mjs'));
118
+ let r = run(d);
119
+ check('T5a 删除门禁件 → [门禁件消失]', r.code === 1 && r.out.includes('[门禁件消失]'), r.out);
120
+ }
121
+ {
122
+ const d = mkfix();
123
+ run(d, ['--update']);
124
+ w(d, 'templates/_agents/scripts/newgate.mjs', 'blockers.push(`X`);\n');
125
+ const r = run(d);
126
+ check('T5b 新受检件未入账 → [门禁件未入基线],--update 后放行',
127
+ r.code === 1 && r.out.includes('[门禁件未入基线]') && r.out.includes('newgate.mjs'), r.out);
128
+ run(d, ['--update']);
129
+ check('T5c --update 把新件入账后 exit 0', run(d).code === 0);
130
+ }
131
+
132
+ // --- T6 外部副本锚 ---
133
+ {
134
+ const d = mkfix();
135
+ run(d, ['--update']);
136
+ const bad = run(d, ['--against', path.join(d, 'nope')]);
137
+ check('T6a --against 目录不存在 → 用法错误 exit 2', bad.code === 2 && bad.out.includes('目录不存在'), bad.out);
138
+ const ext = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-ext-'));
139
+ w(ext, 'templates/_agents/scripts/gate.mjs', '// 外部已知良好副本(与盘面不同)\nblockers.push(`A`);\n');
140
+ const r = run(d, ['--against', ext]);
141
+ check('T6b 与外部副本逐字失配 → [外部锚失配]', r.code === 1 && r.out.includes('[外部锚失配]'), r.out);
142
+ }
143
+
144
+ // --- T7 跨提交锚(真 git 仓端到端:本门存在的理由)---
145
+ if (haveGit) {
146
+ const d = mkfix();
147
+ git(d, ['init', '-q']);
148
+ git(d, ['config', 'user.email', 't@example.com']);
149
+ git(d, ['config', 'user.name', 'fixture']);
150
+ run(d, ['--update']);
151
+ git(d, ['add', '-A']);
152
+ git(d, ['commit', '-q', '-m', 'chore: 基线入账']);
153
+ // 改弱门禁 + 同批刷基线:本地层完全自洽(这正是 CI 自指的形态)
154
+ w(d, 'templates/_agents/scripts/gate.mjs', '// 退役一条判据\nblockers.push(`A`);\nif (bad) process.exit(1);\n');
155
+ run(d, ['--update']);
156
+ const local = run(d);
157
+ check('T7a 改弱 + 刷基线 → 本地层自洽放行(缺陷场景复现)', local.code === 0, local.out);
158
+ const anchored = run(d, ['--against-ref', 'HEAD']);
159
+ check('T7b 跨提交锚(祖先提交基线)出账 [硬拦面收缩(跨提交锚)]',
160
+ anchored.code === 1 && anchored.out.includes('[硬拦面收缩(跨提交锚)]') && /3(见 HEAD)→ 盘面 2/.test(anchored.out), anchored.out);
161
+ // 合法退役出口:活跃 L2 入口点名该件
162
+ w(d, 'workflow/intents/2026-10-09-retire.md',
163
+ '---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:templates/_agents/scripts/gate.mjs(退役 B 判据)\n');
164
+ git(d, ['add', '-A']);
165
+ git(d, ['commit', '-q', '-m', 'docs(gate): L2 入口留痕']);
166
+ const declared = run(d, ['--against-ref', 'HEAD~1']);
167
+ check('T7c 声明过的收缩(L2 入口点名)→ 跨提交锚放行', declared.code === 0, declared.out);
168
+ // 锚缺失(首个入账提交之前)→ 跳过不误报
169
+ const missing = run(d, ['--against-ref', 'HEAD~9']);
170
+ check('T7d ref 不可解析 → 锚缺失跳过(exit 0 不假阳性)', missing.code === 0 && missing.out.includes('跨提交锚缺失'), missing.out);
171
+ // 严格档反例(T7e):正文顺手提及 ≠ 声明改动面——存量无关 L2 文档不得替改弱行为背书
172
+ w(d, 'workflow/intents/2026-10-09-retire.md',
173
+ '---\n状态: superseded\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:templates/_agents/scripts/gate.mjs\n');
174
+ w(d, 'workflow/intents/2026-10-09-prose.md',
175
+ '---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n本批沿用 gate.mjs 与 check-loop.mjs 的既有判据,未改动它们。\n');
176
+ git(d, ['add', '-A']);
177
+ git(d, ['commit', '-q', '-m', 'docs(gate): prose 提及(非声明改动面)']);
178
+ const prose = run(d, ['--against-ref', 'HEAD~2']);
179
+ check('T7e 正文提及但无 §改动面 声明 → 跨提交锚仍拦(严格档:模糊提及不豁免收缩)',
180
+ prose.code === 1 && prose.out.includes('[硬拦面收缩(跨提交锚)]'), prose.out);
181
+ } else {
182
+ console.log('SKIP T7 跨提交锚(未找到 git)');
183
+ }
184
+
185
+ // --- T8 --quiet 静默面(钩子里调用不刷屏)---
186
+ {
187
+ const d = mkfix();
188
+ run(d, ['--update']);
189
+ const r = run(d, ['--quiet']);
190
+ check('T8 --quiet 通过时不打印 ✅ 行', r.code === 0 && !r.out.includes('✅'), r.out);
191
+ }
192
+
193
+ // --- T9 装户布局:.agents/ 在 → 基线落装户径,不新建 templates/_agents/engine-lock.json ---
194
+ {
195
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-test-'));
196
+ w(d, '.agents/scripts/gate.mjs', 'blockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n');
197
+ w(d, 'AGENTS.md', '# 协议\n- 规则一\n');
198
+ const u = run(d, ['--update']);
199
+ let lock = null;
200
+ try { lock = JSON.parse(fs.readFileSync(path.join(d, '.agents', 'engine-lock.json'), 'utf8')); } catch { /* 断言兜 */ }
201
+ check('T9a .agents/ 在 → --update 落 .agents/engine-lock.json(装户径行为不变)',
202
+ u.code === 0 && !!lock && !!lock.files['.agents/scripts/gate.mjs'], u.out);
203
+ check('T9b 装户布局不新建 templates/_agents/engine-lock.json',
204
+ !fs.existsSync(path.join(d, 'templates', '_agents', 'engine-lock.json')), '');
205
+ fs.rmSync(d, { recursive: true, force: true });
206
+ }
207
+
208
+ // --- T10 包源布局:无 .agents/ → 基线落包源径,不得重建装户目录(装户端内容清理后回归锁)---
209
+ {
210
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-test-'));
211
+ w(d, 'templates/_agents/scripts/gate.mjs', 'blockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n');
212
+ w(d, 'templates/AGENTS.md', '# 协议\n- 规则一\n');
213
+ const u = run(d, ['--update']);
214
+ let lock = null;
215
+ try { lock = JSON.parse(fs.readFileSync(path.join(d, 'templates', '_agents', 'engine-lock.json'), 'utf8')); } catch { /* 断言兜 */ }
216
+ check('T10a 无 .agents/ → --update 落 templates/_agents/engine-lock.json 且入账受检面',
217
+ u.code === 0 && !!lock && !!lock.files['templates/_agents/scripts/gate.mjs'], u.out);
218
+ check('T10b --update 不重建 .agents/ 目录(旧实现按装户径兜底会重建已清理的装户目录)',
219
+ !fs.existsSync(path.join(d, '.agents')), '');
220
+ const r = run(d);
221
+ check('T10c 基线启用 → 「盘面与基线一致」且未启用提示点包源径',
222
+ r.code === 0 && r.out.includes('盘面与基线一致'), r.out);
223
+ fs.rmSync(d, { recursive: true, force: true });
224
+ }
225
+
226
+ console.log(`\ncheck-engine-integrity: ${pass} PASS / ${fail} FAIL`);
227
+ process.exit(fail ? 1 : 0);
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // check-lane-surface.mjs — 泳道完整性门禁(pre-commit 增量;2026-10-01 drift-hardening)
3
+ // 布局前提:本门读 .agents/lane-surfaces.txt,豁免出口是 workflow/intents 的活跃 L2/L3 入口——
4
+ // 二者都是**装户侧**件。纯包源仓(装户端内容清理后)两处皆无:清单缺失 = 检查 A/C 按既定
5
+ // 「装户无感」口径跳过,本门整体惰性。此时把清单解析改双路径**只会让门跑起来而不会可用**——
6
+ // 无 L2 入口机器时,任何控制面改动都走「无活跃入口」分支(A 出 advisory / C 出 BLOCK),
7
+ // 纯包源仓的控制面保护改由 npm test(47 套件覆盖门禁逻辑)+ 评审 + CHANGELOG 承担。
3
8
  // 检查 A「触达面判低」:暂存 diff 命中项目 L2 触达面(.agents/lane-surfaces.txt,ERE 逐行;
4
9
  // # 注释与空行忽略;文件缺失或清单为空 = 检查跳过,装户无感)时——
5
10
  // 存在活跃 L2/L3 入口 → 放行(归因歧义为已知边界:机器不猜「哪个入口在干这批改动」);
@@ -10,7 +15,7 @@
10
15
  // 检查 B「探索标记缺失」:experiment/* 分支暂存的 workflow/intents/*.md(排除 _TEMPLATE)
11
16
  // frontmatter 无「阶段: exploring」且无「状态: exploring」(口径同 check-loop 加固门)→ hard 拦。
12
17
  // 标记是加固门覆盖率的前提且补写零成本——advisory 免的是纪律负担,不免标记本身。
13
- // 判据原则(incidents/2026-09-28-batch-ledger-audit 教训):只用一手事实——暂存文件清单、
18
+ // 判据原则(2026-09-28 台账批量代录事故教训):只用一手事实——暂存文件清单、
14
19
  // 当前分支名、工作区 frontmatter 字段;不用时间戳 / 字符串相等反推过程事实。
15
20
  // 边界:git 调用一律 -c core.quotepath=off(非 ASCII 路径教训,同 check-pairing-incremental.sh);
16
21
  // frontmatter 读取容忍 CRLF(显式按 \r?\n 切分);detached HEAD 分支不可判 → A 按 strict、
@@ -171,7 +176,7 @@ if (isExperiment) {
171
176
  for (const a of advisories) console.error(`泳道完整性(增量)— advisory:\n${a}\n`);
172
177
  if (blocks.length) {
173
178
  for (const b of blocks) console.error(`泳道完整性(增量)— BLOCK:\n${b}\n`);
174
- console.error('pre-commit: 泳道完整性未通过,提交已阻止(spec: workflow/specs/2026-10-01-drift-hardening.md)');
179
+ console.error('pre-commit: 泳道完整性未通过,提交已阻止(判据见 .agents/commands/new-task.md §级别判断)');
175
180
  process.exit(1);
176
181
  }
177
182
  process.exit(0);
@@ -117,6 +117,8 @@ const runCli = (cwd) => spawnSync(process.execPath, [SCRIPT], { encoding: 'utf8'
117
117
  }
118
118
  if (!srcRoot || !fs.existsSync(path.join(srcRoot, 'templates', '_agents'))) {
119
119
  console.log('SKIP S9(装户环境无包源,不计失败)');
120
+ } else if (!fs.existsSync(path.join(srcRoot, '.agents', 'kit.json'))) {
121
+ console.log('SKIP S9(纯包源仓无 .agents/ 装副本——装户端内容清理后形态,不计失败)');
120
122
  } else {
121
123
  const r = ledgerDrift(srcRoot);
122
124
  check('S9 真实仓 baseline → 台账↔盘面全对齐', !r.skipped && r.modified.length === 0 && r.gone.length === 0, JSON.stringify({ modified: r.modified, gone: r.gone }));
@@ -24,6 +24,12 @@
24
24
  // 6. 子智能体角色契约 + OpenCode/Trae/ZCode Adapter 一致性(含旧委派残留/钉死模型) [warning]
25
25
  // 7. 阶段索引同步(AGENTS.md 与 new-task.md 须双向索引全部阶段指令) [warning]
26
26
  // 8. intent 验收标准对账(新建:done 未勾验/缺节=hard,勾选缺证据=warning;存量聚合 warning,含「存量对账豁免」声明者出账)
27
+ // + 场景覆盖子判据(2026-10-10 scenario-coverage,灰度 warning):同名 spec 有「验收场景」节时,
28
+ // done intent 验收标准须逐场景 ID 引用(场景:S1…),缺引用=warning;spec 无该节则不适用(零变化)
29
+ // + THEN 断言子判据(2026-10-11 then-assertion,灰度 warning):声明场景缺「- THEN」行=空壳场景,
30
+ // 聚合一条「场景无断言」warning(THEN 是机器对账锚;节不存在=零变化)
31
+ // + 引用断链子判据(2026-10-11 dangling-ref,灰度 warning):验收标准引用 spec 未声明的场景 ID
32
+ // (含 spec 无场景节)=「场景引用断链」warning——场景未覆盖的反向对账,共用同一次解析
27
33
  // **生效日锚=git 首次加入日期(2026-09-28 改;此前取文件名前 10 字符——命名规范强制的字段、
28
34
  // 写早零成本,一条 hard 门可被平凡绕过;incident 2026-09-28-check8-git-anchor 实证)**:
29
35
  // 非 git / 查不到加入记录 → 不可判定 → 走存量口径(不误报 hard)
@@ -31,6 +37,8 @@
31
37
  // 9. 文件名英文 kebab-case(非 ASCII 文件名=warning,2026-09-11 规则)
32
38
  // 10. 级别 vs 迁移文件一致性(L1/L2 入口文档加入提交触及迁移 SQL/Migrations=疑似判低,warning)
33
39
  // 11. 生成物漂移:workflow/INDEX.md(2026-09-21 检索层) + workflow/DASHBOARD.md(2026-10-08 扩覆盖面)=warning;
40
+ // wiki 侧(wiki/INDEX.md + 看板 DATA)由 doctor §6b 经 gen-wiki-board --check 校验,不在此重复
41
+ // (2026-10-10:wiki 生成区此前零机器校验,现与 workflow 侧对称为 doctor 单路);
34
42
  // 12. frontmatter「模块:」合法性(枚举非法 / 2026-09-22 起新建缺字段=warning;词表单源 .agents/workflow-modules.txt)
35
43
  // 13. 常驻面体积预算(超限=warning;判定单源 rule-budget.sh——经 sh 调用,
36
44
  // 无 sh 环境静默跳过:advisory 级且 pre-commit 侧在 git 钩子 sh 环境照常硬拦)
@@ -156,7 +164,7 @@ function takeOpts() {
156
164
  return opts;
157
165
  }
158
166
 
159
- // ---- runCheckLoop:门禁主流程可 import 化(2026-10-08 checkloop-importable;papercuts 2026-10-04 isMain 行点名单独立项)----
167
+ // ---- runCheckLoop:门禁主流程可 import 化(2026-10-08 checkloop-importable;2026-10-04 isMain 行点名单独立项)----
160
168
  // import 本模块零副作用(isMain 主守卫,board-kb-p1 先例);opts = { root?, rev?, hardening? }——root 显式参数
161
169
  // 优先于 CHECK_LOOP_ROOT env 优先于 git toplevel(CLI 缺省行为逐字节不变);返回 { exitCode, blockers, warnings },
162
170
  // 输出打印照旧在本函数内(稳定输出契约:banner 后空行、条目 `- ` 前缀、HARD-BLOCK/WARN 两段式——消费者 .githooks/pre-push)。
@@ -168,7 +176,7 @@ function takeOpts() {
168
176
 
169
177
  // writeGateStats:门禁 ROI 落盘(2026-10-08 gate-roi-metrics)——append 一行到
170
178
  // <root>/.agents/cache/gate-stats.jsonl(该目录已 gitignore,属运行时数据、不入 git、不进 kit.json
171
- // managed 台账——刻意避开 papercuts 2026-10-05 探针临时件被 sync 收编的事故面)。
179
+ // managed 台账——刻意避开 2026-10-05 探针临时件被 sync 收编的事故面)。
172
180
  // IO 失败只出账不阻断(沿 verify.mjs 凭证落账同款容错——度量失败不该让门禁变成红灯)。
173
181
  export function writeGateStats(root, segs) {
174
182
  try {
@@ -325,6 +333,18 @@ export function runCheckLoop(opts = {}) {
325
333
  return '';
326
334
  }
327
335
 
336
+ // 落点空壳告警(2026-10-11 landing-reality,沉淀追踪第三级,incident/intent 两侧共用):
337
+ // 「沉淀: <路径>」存在 ≠ 有效——文件存在但 sedimentLandingIsShell 为真(无来源 frontmatter
338
+ // 且非占位正文不足 3 行)→ WARN 落点空壳(advisory,灰度第一档)。调用方须先出过「沉淀断档」
339
+ // 分支(文件不存在),故此处 readFileSync 异常 = 真读取故障,按空壳出账并在文案注明读取失败。
340
+ const warnSedimentShell = (single, sed) => {
341
+ let text = null;
342
+ try { text = fs.readFileSync(path.resolve(ROOT, sed), 'utf8'); } catch { text = null; }
343
+ if (!sedimentLandingIsShell(text)) return;
344
+ const readFail = text === null ? ';落点文件读取失败' : '';
345
+ warnings.push(`- [WARN 落点空壳] ${single} frontmatter「沉淀: ${sed}」指向的文件存在但无有效内容(无来源 frontmatter 且非占位正文不足 3 行${readFail})——沉淀须真实整理而非空壳占位(advisory,灰度第一档)`);
346
+ };
347
+
328
348
  const WF = 'workflow';
329
349
  const DOC_DIRS = ['intents', 'specs', 'plans', 'incidents'];
330
350
  const blockers = [];
@@ -459,9 +479,25 @@ export function runCheckLoop(opts = {}) {
459
479
  }
460
480
 
461
481
  // 三件套:正文行锚定 /^1\. /^2\. /^3\. (sh awk 同款)
482
+ let trioOk = true;
462
483
  for (const n of [1, 2, 3]) {
463
484
  if (!lines.some((l) => l.startsWith(`${n}. `))) {
464
485
  warnings.push(`- [WARN 三件套不全] ${name} 缺复盘三件套之 ${n}`);
486
+ trioOk = false;
487
+ }
488
+ }
489
+ // 沉淀追踪(incident 侧,与检查 8 intent 侧对称):closed 且三件套齐全 = 最高价值沉淀源。
490
+ // frontmatter「沉淀: <相对仓根路径>」登记去向;缺登记 → WARN 未沉淀(advisory,灰度第一档);
491
+ // 登记了但文件不存在 → WARN 沉淀断档;存在但空壳(无来源且非占位正文不足 3 行)→ WARN 落点空壳。
492
+ // 同「沉淀: 无」显式豁免口径。
493
+ if (incSt === 'closed' && trioOk) {
494
+ const sed = fmGet(inc, '沉淀');
495
+ if (!sed) {
496
+ warnings.push(`- [WARN 未沉淀] ${name} 复盘三件套齐全(closed)但未登记沉淀去向——整理到 wiki 后在 frontmatter 记「沉淀: wiki/<主题>/<文件>」,或显式写「沉淀: 无」(advisory,灰度第一档;沉淀是人工判断,机器只提醒不强制)`);
497
+ } else if (sed !== '无' && !fs.existsSync(path.resolve(ROOT, sed))) {
498
+ warnings.push(`- [WARN 沉淀断档] ${name} frontmatter「沉淀: ${sed}」指向的文件不存在——沉淀文件移动/改名后须同步更新本字段(advisory,灰度第一档)`);
499
+ } else if (sed !== '无') {
500
+ warnSedimentShell(name, sed); // 落点真实性(P1-3):存在但空壳 → WARN 落点空壳
465
501
  }
466
502
  }
467
503
  const hasParent = lines.some((l) => l.includes('是否需要新 intent'));
@@ -742,7 +778,7 @@ export function runCheckLoop(opts = {}) {
742
778
  // --- 8. intent 验收标准对账(done 须逐条勾验并补证据;新建 hard,存量聚合 warning)---
743
779
  markGate('8', '验收标准对账');
744
780
  // 生效日锚 2026-09-28 改「git 首次加入日期」(此前取文件名前 10 字符——命名规范强制的字段、
745
- // 写早零成本,见 incidents/2026-09-28-check8-git-anchor)。非 git → 不可判定 → 走存量口径(不误报 hard)。
781
+ // 写早零成本)。非 git → 不可判定 → 走存量口径(不误报 hard)。
746
782
  {
747
783
  const accCutoff = '2026-09-12';
748
784
  let legacyUnaccounted = 0;
@@ -877,6 +913,65 @@ export function runCheckLoop(opts = {}) {
877
913
  else if (!ex) legacyUnaccounted++;
878
914
  continue;
879
915
  }
916
+ // 场景覆盖子判据(2026-10-10 scenario-coverage,灰度第一档 = warning):
917
+ // 同名 spec 写了「## 验收场景」节(GIVEN/WHEN/THEN,每条带 ID:S1、S2…)时,
918
+ // done intent 的验收标准须**逐场景 ID 引用**(`场景:S1`)。缺引用只出 warning——
919
+ // 有意不进 hard:新判据先灰度观察存量装户告警噪音,沿检查 8 自身 / 检查 15 / 检查 18
920
+ // 「先 WARN、装户吃过警告后升 FAIL」的既有路径;spec 无该节 = 原判据零变化。
921
+ // ID 提取单源 = 本块(spec 侧),引用匹配只认验收标准节内的 `场景:S<n>` 行。
922
+ // 引用断链子判据(2026-10-11 dangling-ref,P2-1,灰度第一档 = warning):
923
+ // 上判据的**反向对账**——验收标准引用了 spec 未声明的场景 ID = 悬空引用(改 ID 漏改 / 复制粘贴
924
+ // 残留的机器信号);spec 无场景节但引用了 S<n> 同样算断链(引用了不存在的场景)。与「场景未覆盖」
925
+ // 共用同一次 ID 解析与引用遍历(covered 一份两用),不重复解析。
926
+ // THEN 断言子判据(2026-10-11 then-assertion,P2-2,灰度第一档 = warning):
927
+ // 声明了场景却无 THEN 断言行 = 空壳场景——THEN 是可观测结果的机器对账锚(GIVEN/WHEN 可省),
928
+ // 聚合一条不逐场景刷屏;验收场景节本身可选,节不存在 → 零变化。
929
+ const specPath = path.join(ROOT, WF, 'specs', base);
930
+ if (fs.existsSync(specPath)) {
931
+ const sids = scenarioIdsOf(specPath);
932
+ // 引用遍历单次(覆盖/断链两方向共用):不锁进 sids.length 分支——spec 无场景节时引用同样悬空
933
+ const covered = new Set();
934
+ for (const line of lines) {
935
+ const m = line.match(/场景[::]\s*S(\d+)/);
936
+ if (m) covered.add(`S${m[1]}`);
937
+ }
938
+ if (sids.length) {
939
+ const missing = sids.filter((id) => !covered.has(id));
940
+ if (missing.length) {
941
+ warnings.push(`- [WARN 场景未覆盖] ${base} 同名 spec 有验收场景但验收标准未逐场景引用:缺 ${missing.join('、')}——在「## 验收标准」按「- [x] 场景:S<n> <判据>(证据:…)」补齐(advisory,灰度第一档;不写场景节的 spec 不适用)`);
942
+ }
943
+ // THEN 对账需 spec 全文(scenarioIdsOf 只回 ID);读取失败 = 不可判定 → 跳过不误报(fail-open)
944
+ let thenMap = null;
945
+ try { thenMap = extractThenByScenario(fs.readFileSync(specPath, 'utf8')); } catch { /* 极窄竞态,不判空壳 */ }
946
+ if (thenMap) {
947
+ const noThen = sids.filter((id) => !(thenMap.get(id) || []).length);
948
+ if (noThen.length) {
949
+ warnings.push(`- [WARN 场景无断言] ${base} 同名 spec 场景缺 THEN 断言:${noThen.join('、')}——每场景至少一行「- THEN <可观测结果>」(GIVEN/WHEN 可省;advisory,灰度第一档)`);
950
+ }
951
+ }
952
+ }
953
+ const dangling = [...covered].filter((id) => !sids.includes(id));
954
+ if (dangling.length) {
955
+ warnings.push(`- [WARN 场景引用断链] ${base} 验收标准引用了 spec 未声明的场景 ID:${dangling.join('、')}——修正 ID 或在 spec「## 验收场景」补该场景(advisory,灰度第一档)`);
956
+ }
957
+ }
958
+ // 沉淀追踪子判据(2026-10-10 sediment-tracking,灰度第一档 = warning):
959
+ // intent 高价值节(影响面 / 触达红线)是 wiki 沉淀的主源——frontmatter「沉淀: <相对仓根路径>」
960
+ // 登记去向(如 wiki/项目规范/xxx.md),「沉淀: 无」= 显式声明不沉淀。缺登记且内容高价值
961
+ // → WARN 未沉淀(advisory:沉淀是人工判断,机器只提醒不强制,不进 hard);登记了但文件
962
+ // 不存在 → WARN 沉淀断档(路径漂移);存在但空壳(无来源且非占位正文不足 3 行)→ WARN 落点
963
+ // 空壳(2026-10-11 landing-reality,P1-3 落点真实性)。高价值判据 = 两节之一有非占位正文内容。
964
+ // incident 侧对称(检查 3 内):closed 且复盘三件套齐全 = 最高价值沉淀源,同款提醒。
965
+ // 有意不加 policyVersion 锚:字段缺失 = 未登记,无字段语义零变化(沿场景覆盖子判据同款口径)。
966
+ const sed = fmGet(intent, '沉淀');
967
+ const highValue = sectionHasBody(lines, '影响面') || sectionHasBody(lines, '触达红线');
968
+ if (highValue && !sed) {
969
+ warnings.push(`- [WARN 未沉淀] ${base} 含高价值沉淀内容(影响面/触达红线)但未登记沉淀去向——整理到 wiki 后在 frontmatter 记「沉淀: wiki/<主题>/<文件>」,或显式写「沉淀: 无」(advisory,灰度第一档;沉淀是人工判断,机器只提醒不强制)`);
970
+ } else if (sed && sed !== '无' && !fs.existsSync(path.resolve(ROOT, sed))) {
971
+ warnings.push(`- [WARN 沉淀断档] ${base} frontmatter「沉淀: ${sed}」指向的文件不存在——沉淀文件移动/改名后须同步更新本字段(advisory,灰度第一档)`);
972
+ } else if (sed && sed !== '无') {
973
+ warnSedimentShell(base, sed); // 落点真实性(P1-3):存在但空壳 → WARN 落点空壳
974
+ }
880
975
  if (!uc && !ne) continue;
881
976
  if (isNew) {
882
977
  if (uc) blockers.push(`- [验收未对账] done intent 验收标准有未勾验项:${base}(逐条勾验并补证据:commit/用例/冒烟输出)`);
@@ -1137,7 +1232,7 @@ export function runCheckLoop(opts = {}) {
1137
1232
  if (rows.every((r) => r.brief === true)) continue;
1138
1233
  const of = Math.max(...rows.map((r) => (typeof r.of === 'number' ? r.of : 0)));
1139
1234
  const docs = rows.map((r) => String(r.doc).replace(/^workflow\//, '')).join('、');
1140
- warnings.push(`- [WARN 确认并录] 单次调用落账 ${rows.length} 份(batch ${b},of=${of}):${docs}——同一次 --delegated 调用放行多份,塌掉「逐件确认」门(build.md);口径见 workflow/papercuts.md 2026-09-28 与 incidents/2026-09-28-batch-ledger-audit.md`);
1235
+ warnings.push(`- [WARN 确认并录] 单次调用落账 ${rows.length} 份(batch ${b},of=${of}):${docs}——同一次 --delegated 调用放行多份,塌掉「逐件确认」门(build.md;口径见根 AGENTS.md「门禁与提交」确认门节)`);
1141
1236
  }
1142
1237
  }
1143
1238
  }
@@ -1556,3 +1651,110 @@ function readdirOrNull(dir) {
1556
1651
  function isDirectoryOrNull(p) {
1557
1652
  try { return fs.statSync(p).isDirectory(); } catch { return false; }
1558
1653
  }
1654
+
1655
+ // 场景 ID 提取(2026-10-10 scenario-coverage,检查 8 场景覆盖子判据的 spec 侧单源):
1656
+ // 认「## 验收场景」节内的场景声明——三级标题 `### S1 <名>` 或列表项 `- S1 <名>`,
1657
+ // 提取 S<n> 形 ID(按出现序去重)。节外不认(防「## 功能行为」里偶然出现的 S1 干扰)。
1658
+ // 无该节 / 无场景声明 → 空数组(调用方按「不适用」跳过,原判据零变化)。
1659
+ // 独立 export 供单元测试直测(纯函数:只吃文件路径、只读)。
1660
+ export function scenarioIdsOf(specPath) {
1661
+ let lines;
1662
+ try { lines = fs.readFileSync(specPath, 'utf8').split(/\r?\n/); } catch { return []; }
1663
+ const ids = [];
1664
+ let inSec = false;
1665
+ for (const line of lines) {
1666
+ if (/^\s*##\s+[^#]*验收场景/.test(line)) { inSec = true; continue; }
1667
+ if (inSec && /^\s*##\s/.test(line)) break; // 下一二级标题 = 节末
1668
+ if (!inSec) continue;
1669
+ const m = line.match(/^\s*(?:###|-)\s*\**S(\d+)\b/i);
1670
+ if (m && !ids.includes(`S${m[1]}`)) ids.push(`S${m[1]}`);
1671
+ }
1672
+ return ids;
1673
+ }
1674
+
1675
+ // THEN 断言抽取(2026-10-11 then-assertion,检查 8「场景无断言」子判据的 spec 侧单源):
1676
+ // 输入 spec 全文,定位「## 验收场景」节(节定位正则与 scenarioIdsOf 同款——口径单源,改写法时两处同步),
1677
+ // 按场景块分组(`### S1 <名>` 三级标题或 `- S1 <名>` 列表项,声明正则同 scenarioIdsOf),抽块内 THEN 断言行。
1678
+ // 行匹配口径:剥缩进后以 `- THEN` 开头(模板规定形式);`THEN:` / `THEN `(无连字符)也认——
1679
+ // 防「写了断言但少了 `- ` 前缀」的写法漂移把断言误判为空;只认行首 THEN,GIVEN/WHEN 行中段的
1680
+ // "THEN" 字样不误抽。返回 Map<S-ID, string[]>(声明即建键,无 THEN = 空数组——空壳判定交给调用方);
1681
+ // 节不存在 / 非字符串输入 → 空 Map(验收场景节本身可选,调用方零变化)。
1682
+ // 独立 export 供单元测试直测(纯函数:只吃文本、无 IO)。
1683
+ export function extractThenByScenario(text) {
1684
+ const byScen = new Map();
1685
+ if (typeof text !== 'string') return byScen; // 读取失败(调用方传 null)→ 空 Map,调用方按不可判定跳过
1686
+ const lines = text.split(/\r?\n/);
1687
+ let cur = null; // 当前场景块 ID(null = 尚未进入任何场景块,节头说明行不算断言)
1688
+ let inSec = false;
1689
+ for (const line of lines) {
1690
+ if (/^\s*##\s+[^#]*验收场景/.test(line)) { inSec = true; continue; }
1691
+ if (inSec && /^\s*##\s/.test(line)) break; // 下一二级标题 = 节末
1692
+ if (!inSec) continue;
1693
+ const decl = line.match(/^\s*(?:###|-)\s*\**S(\d+)\b/i);
1694
+ if (decl) {
1695
+ const id = `S${decl[1]}`;
1696
+ if (!byScen.has(id)) byScen.set(id, []);
1697
+ cur = id;
1698
+ continue;
1699
+ }
1700
+ if (cur === null) continue;
1701
+ const s = line.trim();
1702
+ if (/^-\s*THEN\b/i.test(s) || /^THEN\b/i.test(s)) byScen.get(cur).push(s);
1703
+ }
1704
+ return byScen;
1705
+ }
1706
+
1707
+ // 节体非空判定(2026-10-10 sediment-tracking,检查 8 沉淀追踪子判据的「高价值」信号):
1708
+ // 认 `## <标题前缀>` 节内**标题之后**存在非空非占位行(占位 = `<…>` 单行包揽 / HTML 注释 / 引用)。
1709
+ // 只有光标题(如模板未填的 `## 影响面`)= 未沉淀价值,不出告警——防模板未填段被误判为高价值。
1710
+ // 前缀匹配(startsWith):兼容「## 影响面」「## 影响面与边界」等变体写法。
1711
+ // 独立 export 供单元测试直测(纯函数:只吃行数组、无 IO)。
1712
+ export function sectionHasBody(lines, headingPrefix) {
1713
+ let inSec = false;
1714
+ for (const line of lines) {
1715
+ if (/^\s*##\s/.test(line)) {
1716
+ const t = line.replace(/^\s*##\s*/, '').trim();
1717
+ inSec = t.startsWith(headingPrefix);
1718
+ continue;
1719
+ }
1720
+ if (!inSec) continue;
1721
+ const s = line.trim();
1722
+ if (!s) continue;
1723
+ if (/^<.*>$/.test(s) || s.startsWith('<!--') || s.startsWith('>')) continue; // 占位 / 注释 / 引用
1724
+ return true;
1725
+ }
1726
+ return false;
1727
+ }
1728
+
1729
+ // 沉淀落点空壳判定(2026-10-11 landing-reality,「存在 ≠ 有效」的落点真实性一环):
1730
+ // 落点文件可能是只有标题没有内容的空壳。满足任一即**非**空壳(有效沉淀):
1731
+ // ① frontmatter 含「来源:」键(人工已登记来源 = 真实整理的机器信号;frontmatter 口径同 fmGet 受限子集);
1732
+ // ② frontmatter / 标题行 / 占位行剔除后的非空正文 ≥ 3 行(占位口径逐字对齐 sectionHasBody:
1733
+ // `<…>` 单行包揽 / HTML 注释 / 引用块)——标题行不算正文,防「只有标题」凑行数绕过。
1734
+ // text = 落点文件全文;非字符串(调用方读取失败传 null)按空壳处理,调用方在告警文案注明读取失败。
1735
+ // 独立 export 供单元测试直测(纯函数:只吃文本、无 IO)。
1736
+ export function sedimentLandingIsShell(text) {
1737
+ if (typeof text !== 'string') return true; // 读取失败 → 按空壳
1738
+ const lines = text.split(/\r?\n/);
1739
+ const isDelim = (l) => /^---\s*$/.test(l); // 同 fmGet:首行 --- 进入、下一 --- 闭合
1740
+ let inFm = isDelim(lines[0] || '');
1741
+ if (inFm) {
1742
+ for (let i = 1; i < lines.length; i++) {
1743
+ if (isDelim(lines[i])) break; // 下一 --- = frontmatter 闭合
1744
+ if (lines[i].startsWith('来源:')) return false; // ① 有来源登记 = 有效
1745
+ }
1746
+ }
1747
+ let body = 0;
1748
+ for (let i = inFm ? 1 : 0; i < lines.length; i++) {
1749
+ if (inFm) {
1750
+ if (isDelim(lines[i])) inFm = false; // frontmatter 闭合,其后才开始数正文
1751
+ continue;
1752
+ }
1753
+ const s = lines[i].trim();
1754
+ if (!s) continue;
1755
+ if (s.startsWith('#')) continue; // 标题行 = 结构不算正文(空壳 = 只有标题没有内容)
1756
+ if (/^<.*>$/.test(s) || s.startsWith('<!--') || s.startsWith('>')) continue; // 占位 / 注释 / 引用(同 sectionHasBody)
1757
+ if (++body >= 3) return false; // ② 非占位正文 ≥ 3 行 = 有效
1758
+ }
1759
+ return true;
1760
+ }