agentic-flow-kit 1.7.15 → 1.7.16

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 (101) hide show
  1. package/README.md +41 -3
  2. package/modules/hosts/claude/commands/wf-design.md +5 -4
  3. package/modules/hosts/claude/commands/wf-new-task.md +42 -21
  4. package/modules/hosts/claude/commands/wf-review.md +1 -1
  5. package/modules/hosts/claude/commands/wf-sync-hosts.md +7 -13
  6. package/modules/hosts/claude/commands/wf-test.md +1 -1
  7. package/modules/hosts/codex/commands/wf-design.md +5 -4
  8. package/modules/hosts/codex/commands/wf-new-task.md +42 -21
  9. package/modules/hosts/codex/commands/wf-review.md +1 -1
  10. package/modules/hosts/codex/commands/wf-sync-hosts.md +7 -13
  11. package/modules/hosts/codex/commands/wf-test.md +1 -1
  12. package/modules/hosts/cursor/commands/wf-design.md +5 -4
  13. package/modules/hosts/cursor/commands/wf-new-task.md +42 -21
  14. package/modules/hosts/cursor/commands/wf-review.md +1 -1
  15. package/modules/hosts/cursor/commands/wf-sync-hosts.md +7 -13
  16. package/modules/hosts/cursor/commands/wf-test.md +1 -1
  17. package/modules/hosts/opencode/commands/wf-design.md +5 -4
  18. package/modules/hosts/opencode/commands/wf-new-task.md +42 -21
  19. package/modules/hosts/opencode/commands/wf-review.md +1 -1
  20. package/modules/hosts/opencode/commands/wf-sync-hosts.md +7 -13
  21. package/modules/hosts/opencode/commands/wf-test.md +1 -1
  22. package/modules/hosts/trae/commands/wf-design.md +5 -4
  23. package/modules/hosts/trae/commands/wf-new-task.md +42 -21
  24. package/modules/hosts/trae/commands/wf-review.md +1 -1
  25. package/modules/hosts/trae/commands/wf-sync-hosts.md +7 -13
  26. package/modules/hosts/trae/commands/wf-test.md +1 -1
  27. package/package.json +1 -1
  28. package/src/cli.mjs +43 -4
  29. package/src/doctor.mjs +37 -1
  30. package/src/executor.mjs +245 -0
  31. package/src/executor.test.mjs +292 -0
  32. package/src/init.mjs +7 -0
  33. package/src/profiles.mjs +3 -0
  34. package/src/render.mjs +3 -3
  35. package/src/sync-hosts.mjs +37 -43
  36. package/src/sync-hosts.test.mjs +67 -56
  37. package/templates/.gitattributes +8 -0
  38. package/templates/AGENTS.md +1 -1
  39. package/templates/_agents/commands/design.md +5 -4
  40. package/templates/_agents/commands/new-task.md +42 -21
  41. package/templates/_agents/commands/review.md +1 -1
  42. package/templates/_agents/commands/sync-hosts.md +7 -13
  43. package/templates/_agents/commands/test.md +1 -1
  44. package/templates/_agents/control-surfaces.txt +1 -1
  45. package/templates/_agents/engine-lock.json +246 -0
  46. package/templates/_agents/lane-surfaces.txt +1 -1
  47. package/templates/_agents/level-hints.txt +30 -0
  48. package/templates/_agents/scripts/agg-delegations.test.mjs +5 -3
  49. package/templates/_agents/scripts/check-engine-integrity.mjs +55 -126
  50. package/templates/_agents/scripts/check-engine-integrity.test.mjs +33 -45
  51. package/templates/_agents/scripts/check-lane-surface.mjs +72 -23
  52. package/templates/_agents/scripts/check-lane-surface.test.mjs +66 -0
  53. package/templates/_agents/scripts/check-ledger-invariant.mjs +112 -14
  54. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +5 -1
  55. package/templates/_agents/scripts/check-loop-rev.test.mjs +6 -4
  56. package/templates/_agents/scripts/check-loop.mjs +49 -41
  57. package/templates/_agents/scripts/check-loop.test.mjs +18 -13
  58. package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
  59. package/templates/_agents/scripts/compact-ledgers.mjs +97 -0
  60. package/templates/_agents/scripts/compact-ledgers.test.mjs +162 -0
  61. package/templates/_agents/scripts/confirm-doc.mjs +40 -11
  62. package/templates/_agents/scripts/doctor.test.mjs +44 -1
  63. package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
  64. package/templates/_agents/scripts/fill-intent.mjs +2 -2
  65. package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
  66. package/templates/_agents/scripts/fixture-scripts.test.mjs +107 -0
  67. package/templates/_agents/scripts/gate-checklist.mjs +4 -9
  68. package/templates/_agents/scripts/gate-checklist.test.mjs +2 -11
  69. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +8 -11
  70. package/templates/_agents/scripts/gen-workflow-metrics.mjs +9 -33
  71. package/templates/_agents/scripts/git-merge-jsonl.mjs +63 -0
  72. package/templates/_agents/scripts/git-merge-jsonl.test.mjs +125 -0
  73. package/templates/_agents/scripts/ledger-doc-chain.test.mjs +166 -0
  74. package/templates/_agents/scripts/ledger-loader.mjs +108 -0
  75. package/templates/_agents/scripts/ledger-loader.test.mjs +131 -0
  76. package/templates/_agents/scripts/ledger-prune.mjs +96 -0
  77. package/templates/_agents/scripts/ledger-prune.test.mjs +120 -0
  78. package/templates/_agents/scripts/ledger-ts-monotonic.test.mjs +135 -0
  79. package/templates/_agents/scripts/level-criteria.mjs +232 -0
  80. package/templates/_agents/scripts/level-criteria.test.mjs +279 -0
  81. package/templates/_agents/scripts/pipeline-run.mjs +4 -2
  82. package/templates/_agents/scripts/policy.mjs +47 -0
  83. package/templates/_agents/scripts/stage-gates.mjs +4 -9
  84. package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
  85. package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
  86. package/templates/_agents/scripts/workflows-check.mjs +1 -1
  87. package/templates/_agents/workflows/pipeline-closing.md +4 -5
  88. package/templates/_githooks/pre-commit +3 -34
  89. package/templates/_githooks/pre-push +3 -3
  90. package/templates/workflow/README.md +11 -0
  91. package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -89
  92. package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -89
  93. package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -89
  94. package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -89
  95. package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -90
  96. package/templates/_agents/commands/source-sync-check.md +0 -103
  97. package/templates/_agents/scripts/check-ledger.mjs +0 -109
  98. package/templates/_agents/scripts/check-ledger.test.mjs +0 -199
  99. package/templates/_agents/scripts/source-sync-check.mjs +0 -156
  100. package/templates/_agents/scripts/source-sync-check.test.mjs +0 -286
  101. package/templates/_agents/workflows/source-sync-repair.md +0 -30
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env node
2
+ // level-criteria.mjs — 风险级别判定的**机器单源**(2026-10-13 flow-kit-route)
3
+ //
4
+ // 为什么有它:风险级别(L0-L3)此前只以**散文**形态存在于 `new-task.md`「级别判断」节——
5
+ // L2 四类触达面、L3 结构面、负例、自查句全都写给人读。后果有二:
6
+ // ① 定级是纯自由心证,AI 每次判级都可能不同,门禁无从校验「判低」是否守约;
7
+ // ② 想让 CLI 接管路由(`flow-kit route`),就没有可执行的判据可调,只能复读 md。
8
+ // 本文件把「**能被机器判定的部分**」从散文里抽出来做成单源,md 改为引用本文件而非承载判据。
9
+ //
10
+ // 分工(诚实声明——不是所有判据都能机器判定,别指望本文件替代判断力):
11
+ // 机器可判:档位闭集与就高不就低、**路径级最低档提示**(level-hints.txt)、混档取高、自查句与负例的**输出**。
12
+ // 机器不可判:「一处定义、多处消费」是**语义**性质——同一条路径改了可能只是修文案、也可能是改编码权威。
13
+ // 因此本文件对语义面**只给判据清单与自查句,不产出断言**。缺 hints 配置时按 L1 起步并提示补配置,
14
+ // 绝不假装判得出。
15
+ //
16
+ // 路径提示清单(`.agents/level-hints.txt`,装户 owned):`L3 <ERE>` / `L2 <ERE>` 每行一条,
17
+ // 命中即该改动**至少**是此档。缺文件或清单为空 = 纯语义定级(本文件全程静默,不阻断)。
18
+ // 语义参照 `lane-surfaces.txt`(那是「提交面」的触达判据,本文件是「意图面」的档位提示,两者不同档)。
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ export const LEVELS = ['L0', 'L1', 'L2', 'L3'];
23
+ export const HINTS_REL = '.agents/level-hints.txt';
24
+
25
+ // 档位语义(new-task.md 引用本段,不复述——单源纪律)
26
+ export const LEVEL_MEANING = {
27
+ L0: '文档/样式微调(无行为影响)——直接 commit,豁免 intent',
28
+ L1: '实现级改动(未命中 L2/L3)——快车道:轻量 intent/plan + 实现',
29
+ L2: '规则/契约(一处改、多处依赖)——入口文档 + spec + plan 三件套',
30
+ L3: '数据与运行时结构(schema / 迁移 SQL / DI 链 / 认证与中间件管线)——同 L2 + spec 新会话独立复核',
31
+ };
32
+
33
+ // L2 四类触达面(闭集,机器不判定语义,只作为判据清单输出给人与 AI)
34
+ export const L2_SURFACES = [
35
+ '编码权威:一处生成、多处消费的编码/命名/取号规则(具体清单按项目 AGENTS.md)',
36
+ '共享契约:跨模块共享的基类/DTO 形状、跨模块公共接口签名',
37
+ '既有接口语义:既有参数或端点的行为变化(失效变生效、默认值或钳制口径改变)',
38
+ '全局横切口径:错误响应出口、分页与校验口径、聚合投影键',
39
+ ];
40
+
41
+ // L3 结构面
42
+ export const L3_SURFACES = [
43
+ 'schema / 数据模型定义',
44
+ '迁移 SQL(schema 变更、数据修复)',
45
+ 'DI 链:注册、构造器依赖、组合根',
46
+ '认证与中间件管线',
47
+ ];
48
+
49
+ // 落 L1 的负例(判低红线:命中即不得定 L2/L3)
50
+ export const L1_NEGATIVES = [
51
+ '纯页面表现 / 文案 / 布局 / 交互实现',
52
+ '纯新增端点或参数,且既有行为不变',
53
+ '单模块内部重构,且无对外契约变化',
54
+ ];
55
+
56
+ // 自查句(人/AI 定级时的判定式,机器只输出不裁决)
57
+ export const SELF_CHECK = '能否列出 ≥2 个受影响的消费方(模块 / 端点 / 页面)?能 → 至少 L2。';
58
+
59
+ export function rankOf(level) {
60
+ const i = LEVELS.indexOf(String(level || '').toUpperCase());
61
+ return i < 0 ? null : i;
62
+ }
63
+
64
+ export function maxLevel(a, b) {
65
+ const ra = rankOf(a), rb = rankOf(b);
66
+ if (ra === null) return rb === null ? '' : String(b).toUpperCase();
67
+ if (rb === null) return String(a).toUpperCase();
68
+ return LEVELS[Math.max(ra, rb)];
69
+ }
70
+
71
+ // parseLevelHints:读 hints 清单。行格式 `L3 <ERE>`,ERE 为 grep -E 兼容正则,匹配仓库相对路径(/ 分隔)。
72
+ // 非法档位 / 语法错误的行 → fail-loud 抛错(防静默空洞:写错配置比没配置更危险——没配置会提示,写错会误判)。
73
+ // 三条收紧(2026-10-13 executor-review,全部来自实测复现,不是假想):
74
+ // ① **只收 L2 / L3**。本清单语义是「哪些路径一碰就至少是 L2/L3」,L0/L1 行不构成任何门槛——
75
+ // 而此前接受 L0 会被提交面判低门当真:`L0 ^docs/` 会让纯文档提交被硬拦,修法还让人
76
+ // 「升级至 L0 及以上」(L0 即最低档,荒谬)。写这种行的人本意多半是「这里是 L0 区」,
77
+ // 那正是不该登记的面——故按写错配置处理,fail-loud 点名。
78
+ // ② **行内 `#` 注释 fail-loud**。ERE 取整行剩余部分(`(\S.*)$`),所以 `L3 ^a\.sql$ # 注释`
79
+ // 会把注释编进正则:若路径含 `$` 锚点,该条**永不命中**且无任何报错——路径下限与提交面兜底
80
+ // 同时静默失效,而 `--check` 还报「N 条有效」。这正是随包模板原先示例的写法(评审实测)。
81
+ // 注释请单独成行;确需匹配路径里的 `#` 写 `\#`。
82
+ // ③ 档位大写归一:只接受 `L2` / `L3`(原正则 `L[0-3]` 已含大小写外的写法,见下)。
83
+ export function parseLevelHints(text) {
84
+ const out = [];
85
+ const errs = [];
86
+ String(text || '').split(/\r?\n/).forEach((raw, i) => {
87
+ const line = raw.trim();
88
+ if (!line || line.startsWith('#')) return;
89
+ const m = line.match(/^(L[0-3])\s+(\S.*)$/i);
90
+ if (!m) { errs.push(`第 ${i + 1} 行:格式须为 \`L2 <ERE>\` 或 \`L3 <ERE>\`(实际「${line.slice(0, 40)}」)`); return; }
91
+ const level = m[1].toUpperCase();
92
+ const re = m[2];
93
+ if (level !== 'L2' && level !== 'L3') {
94
+ errs.push(`第 ${i + 1} 行:档位只能是 L2 或 L3(实际 ${level})——本清单只登记「一碰就至少是中/高风险面」的路径下限;`
95
+ + `L0/L1 不构成门槛,登记了只会让提交面判低门对无关改动出手。若本意是「这里是低风险区」,那就不该登记`);
96
+ return;
97
+ }
98
+ // 行内注释检测:空白后的未转义 #。ERE 里的 `#` 若被转义(\#)或前面不是空白(形如 `^src/#old/`)则放行。
99
+ const inline = /(^|\s)#/.exec(re);
100
+ if (inline) {
101
+ errs.push(`第 ${i + 1} 行:ERE 与行内注释不得同一行(实际「${line.slice(0, 40)}」)——注释会被编进正则,`
102
+ + `若含 $ 锚点则该条永不命中且无任何报错;请把注释放到上一行单独成行(确需匹配路径里的 # 请写 \\#)`);
103
+ return;
104
+ }
105
+ try {
106
+ out.push({ level, re, rx: new RegExp(re), source: line });
107
+ } catch (e) {
108
+ errs.push(`第 ${i + 1} 行:正则语法错误(${e.message})`);
109
+ }
110
+ });
111
+ if (errs.length) {
112
+ const e = new Error(`${HINTS_REL} 配置有误:\n - ${errs.join('\n - ')}`);
113
+ e.code = 'LEVEL_HINTS_INVALID';
114
+ throw e;
115
+ }
116
+ return out;
117
+ }
118
+
119
+ export function loadLevelHints(root = process.cwd()) {
120
+ const p = path.join(root, ...HINTS_REL.split('/'));
121
+ if (!fs.existsSync(p)) return { hints: [], missing: true };
122
+ return { hints: parseLevelHints(fs.readFileSync(p, 'utf8')), missing: false };
123
+ }
124
+
125
+ // hintLevelFor:路径命中的**最低档**提示(多条命中取最高)。无命中返回 ''。
126
+ export function hintLevelFor(hints, files) {
127
+ let hit = '';
128
+ const hitRe = [];
129
+ for (const f of files || []) {
130
+ const norm = String(f).replace(/\\/g, '/').replace(/^\.\//, '');
131
+ for (const h of hints) {
132
+ if (h.rx.test(norm)) { hit = maxLevel(hit, h.level); hitRe.push(`${h.level}←${norm}`); }
133
+ }
134
+ }
135
+ return { level: hit, matched: [...new Set(hitRe)] };
136
+ }
137
+
138
+ // assessLevel:定级判定面。输入 = 改动文件 + 调用方给的建议档;输出 = 机器可判的部分。
139
+ // 语义面(是否「一处定义多处消费」)**不由本函数裁决**——它只保证:hints 提示档是下限、混档取高。
140
+ export function assessLevel(root, { files = [], proposed = '' } = {}) {
141
+ const { hints, missing } = loadLevelHints(root);
142
+ const { level: hintLevel, matched } = hintLevelFor(hints, files);
143
+ const finalLevel = maxLevel(hintLevel, proposed);
144
+ return {
145
+ level: finalLevel,
146
+ hintLevel,
147
+ matched,
148
+ proposed: proposed ? String(proposed).toUpperCase() : '',
149
+ hintsConfigured: !missing && hints.length > 0,
150
+ // 调用方给的建议档若低于 hint 下限 → 提示;不硬拦(判级纪律仍由 lane-surface 触达面门在提交面兜)
151
+ underHint: !!(hintLevel && rankOf(proposed) !== null && rankOf(proposed) < rankOf(hintLevel)),
152
+ };
153
+ }
154
+
155
+ // routeAdvice:给人/AI 的**判据面**文本(route 命令直接输出这份)。
156
+ // 2026-10-13 executor-review:原先这里还兼报「hints 未配置」「建议档低于下限」两条**状态**信息,
157
+ // 而 route 的降级块(executor.mjs degradations)讲的是同一件事 → 文本模式把同一事实打了两遍。
158
+ // 口径:**判据归本函数、状态归降级块**,单一出口。故 ctx(原 hintsConfigured / underHint /
159
+ // hintLevel / matched)保留在签名里仅为兼容既有调用方,本函数不再读取——故参数名带下划线前缀。
160
+ export function routeAdvice(level, _ctx = {}) {
161
+ const L = String(level || 'L1').toUpperCase();
162
+ const lines = [];
163
+ lines.push(`建议级别:${L} —— ${LEVEL_MEANING[L] || '(未知档)'}`);
164
+ if (L === 'L2' || L === 'L3') {
165
+ lines.push('');
166
+ lines.push(`【L2 触达面 · 闭集,逐条判】`);
167
+ for (const s of L2_SURFACES) lines.push(` · ${s}`);
168
+ }
169
+ if (L === 'L3') {
170
+ lines.push('');
171
+ lines.push(`【L3 结构面】`);
172
+ for (const s of L3_SURFACES) lines.push(` · ${s}`);
173
+ }
174
+ if (L === 'L1' || L === 'L0') {
175
+ lines.push('');
176
+ lines.push(`【负例 · 命中即不得升 L2/L3】`);
177
+ for (const s of L1_NEGATIVES) lines.push(` · ${s}`);
178
+ }
179
+ lines.push('');
180
+ lines.push(`【自查句】${SELF_CHECK}`);
181
+ lines.push('提示:级别是**意图面**的语义判定(「一处定义、多处消费」),机器只兜「就高不就低」与路径下限。');
182
+ return lines.join('\n');
183
+ }
184
+
185
+ // ---- 体检入口(2026-10-13 hint-doctor)------------------------------------
186
+ // 为什么需要:非法 hints 此前只在**跑 flow-kit route 时**才抛 LEVEL_HINTS_INVALID,而 route 是
187
+ // 可选调用 → 写错的清单能静默存活很久,直到某天有人碰巧路由一次才炸。写错配置比没配置危险
188
+ // (没配置会提示补,写错会误判),故给 doctor 一条**主动体检**通道。
189
+ // 口径纪律:doctor 不自己复刻解析器(复刻=判据双源漂移),改为 spawn 本文件执行本节——
190
+ // 解析逻辑永远只有 parseLevelHints 一份。
191
+ // 用法:node level-criteria.mjs --check <清单路径>
192
+ // 退出码:0 通过(含文件缺失/空清单=未启用);1 配置有误(stderr 出逐行原因)。无参数 → 退出 2。
193
+ export function checkHintsFile(file) {
194
+ if (!fs.existsSync(file)) return { configured: false, entries: 0, note: '文件不存在(默认空=纯语义定级)' };
195
+ const text = fs.readFileSync(file, 'utf8');
196
+ let hints;
197
+ try {
198
+ hints = parseLevelHints(text);
199
+ } catch (e) {
200
+ return { configured: true, ok: false, entries: 0, error: e.message, code: e.code || 'LEVEL_HINTS_INVALID' };
201
+ }
202
+ return {
203
+ configured: true,
204
+ ok: true,
205
+ entries: hints.length,
206
+ levels: [...new Set(hints.map((h) => h.level))].sort(),
207
+ };
208
+ }
209
+
210
+ // CLI 自运行守卫:仅当本文件被直接执行时进入体检模式(被 import 时不跑——库用法不受影响)
211
+ if (process.argv[1] && path.resolve(process.argv[1]).endsWith(path.join('scripts', 'level-criteria.mjs'))) {
212
+ const argv = process.argv.slice(2);
213
+ const at = argv.indexOf('--check');
214
+ if (at >= 0) {
215
+ const file = argv[at + 1];
216
+ if (!file || argv[at + 1].startsWith('--')) {
217
+ console.error('用法:node level-criteria.mjs --check <level-hints.txt 路径>');
218
+ process.exit(2);
219
+ }
220
+ const r = checkHintsFile(path.resolve(file));
221
+ if (r.ok === false) {
222
+ console.error(`[level-hints 配置有误] ${file}\n${String(r.error).split('\n').map((l) => ` ${l}`).join('\n')}`);
223
+ console.error(' 修法:按 `<档位> <ERE>` 格式修正(档位 ∈ L0-L3,ERE 为 grep -E 兼容正则)——'
224
+ + '写错配置比没配置危险:没配置会提示补配置,写错会误判档位');
225
+ process.exit(1);
226
+ }
227
+ console.log(r.configured
228
+ ? `level-hints:${r.entries} 条有效(${(r.levels || []).join('/') || '—'})`
229
+ : `level-hints:未配置(${r.note})`);
230
+ process.exit(0);
231
+ }
232
+ }
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+ // level-criteria.test.mjs — 级别判据机器单源套件(2026-10-13 flow-kit-route)
3
+ // 覆盖:档位闭集 / rankOf·maxLevel 边界 / hints 解析(注释·空行·CRLF·分隔)/ 非法 hints fail-loud /
4
+ // 路径下限与多命中取高 / assessLevel 就高不就低与 underHint / hints 缺失·为空两态 /
5
+ // routeAdvice 分档输出面(负例·触达面·结构面·未配置提示)。
6
+ import fs from 'node:fs';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+
11
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
12
+ const C = await import(pathToFileURL(path.join(SCRIPT_DIR, 'level-criteria.mjs')).href);
13
+
14
+ let pass = 0, fail = 0;
15
+ const check = (name, ok, detail = '') => {
16
+ if (ok) { pass++; console.log(` ok ${name}`); }
17
+ else { fail++; console.error(`FAIL ${name}${detail ? ' — ' + detail : ''}`); }
18
+ };
19
+
20
+ const mkRoot = () => fs.mkdtempSync(path.join(os.tmpdir(), 'level-criteria-'));
21
+ const wHints = (root, text) => {
22
+ const p = path.join(root, ...C.HINTS_REL.split('/'));
23
+ fs.mkdirSync(path.dirname(p), { recursive: true });
24
+ fs.writeFileSync(p, text, 'utf8');
25
+ };
26
+ const throwsCode = (fn, code) => {
27
+ try { fn(); return ''; } catch (e) { return e.code === code ? 'ok' : `code=${e.code}`; }
28
+ };
29
+
30
+ // ============ 场景 1:档位闭集与排序原语 ============
31
+ {
32
+ check('S1 档位闭集为 L0-L3', JSON.stringify(C.LEVELS) === JSON.stringify(['L0', 'L1', 'L2', 'L3']));
33
+ check('S1 rankOf 大小写不敏感', C.rankOf('l2') === 2 && C.rankOf('L3') === 3 && C.rankOf('L0') === 0);
34
+ check('S1 rankOf 非法档返回 null', C.rankOf('L9') === null && C.rankOf('') === null && C.rankOf(undefined) === null);
35
+ check('S1 maxLevel 取高', C.maxLevel('L1', 'L3') === 'L3' && C.maxLevel('L3', 'L1') === 'L3');
36
+ check('S1 maxLevel 同档幂等', C.maxLevel('L2', 'L2') === 'L2');
37
+ check('S1 maxLevel 单边非法时取合法侧', C.maxLevel('L9', 'L2') === 'L2' && C.maxLevel('L1', 'L9') === 'L1');
38
+ check('S1 maxLevel 双边非法返回空串(不假装判得出)', C.maxLevel('L9', 'X') === '');
39
+ check('S1 maxLevel 小写输入归一为大写', C.maxLevel('l0', 'l2') === 'L2');
40
+ }
41
+
42
+ // ============ 场景 2:hints 解析 —— 注释/空行/CRLF/档位前缀 ============
43
+ {
44
+ const txt = [
45
+ '# 注释行',
46
+ '',
47
+ ' ',
48
+ 'L2 ^src/contracts/',
49
+ 'L3 ^src/db/migrations/.*\\.sql$',
50
+ '\tL2 \uFEFFsrc/gen/', // 前导空白 + BOM 残留(Windows 编辑器常见)
51
+ ].join('\r\n');
52
+ const hints = C.parseLevelHints(txt);
53
+ check('S2 解析出 3 条(注释/空行不计)', hints.length === 3, `实得 ${hints.length}`);
54
+ check('S2 首条档位与正则正确', hints[0].level === 'L2' && hints[0].re === '^src/contracts/');
55
+ check('S2 CRLF 不污染正则', hints[1].rx.test('src/db/migrations/001_init.sql'));
56
+ check('S2 空白/BOM 前缀被 trim 掉', hints[2].rx.test('src/gen/x.ts'), `re=${hints[2].re}`);
57
+ check('S2 无命中项不抛', C.parseLevelHints('# 全是注释\n\n').length === 0);
58
+ }
59
+
60
+ // ============ 场景 3:非法 hints fail-loud(写错配置比没配置危险) ============
61
+ {
62
+ check('S3 非法档位行报 LEVEL_HINTS_INVALID', throwsCode(() => C.parseLevelHints('L9 ^src/'), 'LEVEL_HINTS_INVALID') === 'ok');
63
+ check('S3 缺正发行报 LEVEL_HINTS_INVALID', throwsCode(() => C.parseLevelHints('L2'), 'LEVEL_HINTS_INVALID') === 'ok');
64
+ check('S3 正则语法错报 LEVEL_HINTS_INVALID', throwsCode(() => C.parseLevelHints('L2 ^src/(['), 'LEVEL_HINTS_INVALID') === 'ok');
65
+ let msg = '';
66
+ try { C.parseLevelHints('L9 ^src/\nL2 ^ok/'); } catch (e) { msg = e.message; }
67
+ check('S3 报文含清单路径与行号定位', msg.includes(C.HINTS_REL) && msg.includes('第 1 行'), msg);
68
+ let msg2 = '';
69
+ try { C.parseLevelHints('L2 ^ok/\n\nL2 ^bad/([\nL9 x'); } catch (e) { msg2 = e.message; }
70
+ check('S3 一次聚合全部坏行行号', msg2.includes('第 3 行') && msg2.includes('第 4 行'), msg2);
71
+ check('S3 合法行不报错(只聚合坏行)', !msg2.includes('第 2 行'), msg2);
72
+
73
+ // 2026-10-13 executor-review 两条实测 Major 的回归锁:
74
+ // ① 行内 `#` 注释会被编进 ERE → 含 $ 锚点时该条永不命中且**无任何报错**,
75
+ // 路径下限与提交面兜底同时静默失效(原随包模板示例就是这个写法)。
76
+ let c1 = '';
77
+ try { C.parseLevelHints('L3 ^src/db/migrations/.*\\.sql$ # 迁移 SQL'); } catch (e) { c1 = e.message; }
78
+ check('S3 行内 # 注释 → fail-loud(否则该条永不命中且静默)',
79
+ c1.includes('LEVEL_HINTS_INVALID') || c1.includes('行内注释'), c1);
80
+ check('S3 行内注释报文点名该行并给出正确写法', c1.includes('第 1 行') && c1.includes('单独成行'), c1);
81
+ check('S3 转义 \\# 视为路径字符而非注释', C.parseLevelHints('L2 ^src/\\#old/').length === 1);
82
+ check('S3 路径里的 #(前非空白)不算注释', C.parseLevelHints('L2 ^src/#old/').length === 1);
83
+
84
+ // ② L0/L1 档被原实现接受,而提交面判低门会当真:`L0 ^docs/` 让纯文档提交被硬拦,
85
+ // 修法还让人「升级至 L0 及以上」(L0 即最低档)。只收 L2/L3。
86
+ let c2 = '';
87
+ try { C.parseLevelHints('L0 ^docs/'); } catch (e) { c2 = e.message; }
88
+ check('S3 L0 档 → fail-loud(本清单只登记 L2/L3 下限)', c2.includes('只能是 L2 或 L3'), c2);
89
+ check('S3 L1 档同样被拒', throwsCode(() => C.parseLevelHints('L1 ^src/'), 'LEVEL_HINTS_INVALID') === 'ok');
90
+ check('S3 拒绝低档时说明理由(登记低档只会让提交面门出手)', c2.includes('噪声') || c2.includes('不构成门槛'), c2);
91
+ }
92
+
93
+ // ============ 场景 4:路径下限与多命中取高 ============
94
+ {
95
+ const hints = C.parseLevelHints(['L2 ^src/contracts/', 'L3 ^src/db/migrations/.*\\.sql$', 'L2 ^docs/api\\.md$'].join('\n'));
96
+ check('S4 单命中取该档', C.hintLevelFor(hints, ['src/contracts/types.ts']).level === 'L2');
97
+ check('S4 多文件多档取最高', C.hintLevelFor(hints, ['src/contracts/a.ts', 'src/db/migrations/002_x.sql']).level === 'L3');
98
+ check('S4 未命中返回空串', C.hintLevelFor(hints, ['src/ui/button.tsx']).level === '');
99
+ check('S4 反斜杠路径归一为 /', C.hintLevelFor(hints, ['src\\contracts\\a.ts']).level === 'L2');
100
+ check('S4 ./ 前缀剥离', C.hintLevelFor(hints, ['./src/contracts/a.ts']).level === 'L2');
101
+ // ERE 按「仓库相对路径」语义写,`^` 锚定即不命中绝对路径 —— 这是契约不是漏洞:
102
+ // 命中绝对路径说明清单写成了无锚 ERE,容易误伤同名目录。
103
+ check('S4 `^` 锚定规则不命中绝对路径(清单契约=仓库相对路径)',
104
+ C.hintLevelFor(hints, ['C:/repo/src/db/migrations/003_y.sql']).level === '');
105
+ check('S4 无锚 ERE 命中子串(清单作者自选口径)',
106
+ C.hintLevelFor(C.parseLevelHints('L3 migrations/.*\\.sql$'), ['C:/repo/src/db/migrations/003_y.sql']).level === 'L3');
107
+ const m = C.hintLevelFor(hints, ['src/contracts/a.ts', 'src/db/migrations/002_x.sql']);
108
+ check('S4 matched 给出命中证据', m.matched.length === 2 && m.matched.some((s) => s.includes('src/contracts/a.ts')), JSON.stringify(m.matched));
109
+ check('S4 matched 去重', new Set(m.matched).size === m.matched.length);
110
+ check('S4 空输入不抛', C.hintLevelFor(hints, []).level === '' && C.hintLevelFor([], ['a']).level === '');
111
+ }
112
+
113
+ // ============ 场景 5:assessLevel —— 就高不就低 / underHint / 两态配置 ============
114
+ {
115
+ const r = mkRoot();
116
+ wHints(r, 'L3 ^src/db/migrations/.*\\.sql$\n');
117
+ const noProposed = C.assessLevel(r, { files: ['src/db/migrations/001_init.sql'] });
118
+ check('S5 无建议档时按 hint 抬档', noProposed.level === 'L3' && noProposed.hintLevel === 'L3');
119
+ check('S5 无建议档不算 underHint(无档可低)', noProposed.underHint === false);
120
+ check('S5 hintsConfigured=true', noProposed.hintsConfigured === true);
121
+
122
+ const up = C.assessLevel(r, { files: ['src/db/migrations/001_init.sql'], proposed: 'L3' });
123
+ check('S5 建议档≥下限 → 不提示', up.underHint === false && up.level === 'L3');
124
+
125
+ const down = C.assessLevel(r, { files: ['src/db/migrations/001_init.sql'], proposed: 'L1' });
126
+ check('S5 建议档低于下限 → underHint=true', down.underHint === true);
127
+ check('S5 就高不就低:仍取 L3(建议档只作提示不压档)', down.level === 'L3');
128
+ check('S5 proposed 归一为大写回显', down.proposed === 'L1');
129
+
130
+ const noHit = C.assessLevel(r, { files: ['src/ui/button.tsx'], proposed: 'L2' });
131
+ check('S5 无命中时尊重建议档', noHit.level === 'L2' && noHit.hintLevel === '' && noHit.underHint === false);
132
+ }
133
+
134
+ // ============ 场景 6:hints 缺失 vs 为空 —— 两态都算「未配置」但不崩 ============
135
+ {
136
+ const missing = C.assessLevel(mkRoot(), { files: ['src/a.ts'] });
137
+ check('S6 文件缺失 → hintsConfigured=false', missing.hintsConfigured === false);
138
+ check('S6 文件缺失 → 按 L1 起步(不假装判得出)', missing.level === '' && missing.hintLevel === '');
139
+
140
+ const empty = mkRoot();
141
+ wHints(empty, '# 只有注释\n');
142
+ const e = C.assessLevel(empty, { files: ['src/a.ts'], proposed: 'L2' });
143
+ check('S6 清单存在但为空 → hintsConfigured=false', e.hintsConfigured === false);
144
+ check('S6 空清单不压建议档', e.level === 'L2');
145
+ }
146
+
147
+ // ============ 场景 7:routeAdvice 分档输出面 ============
148
+ {
149
+ const l1 = C.routeAdvice('L1', { hintsConfigured: true });
150
+ check('S7 L1 输出负例清单', C.L1_NEGATIVES.every((s) => l1.includes(s)));
151
+ check('S7 L1 不输出 L2/L3 触达面', !l1.includes('共享契约') && !l1.includes('迁移 SQL'));
152
+ check('S7 一律输出自查句', l1.includes(C.SELF_CHECK));
153
+ check('S7 一律声明机器边界(语义面不由机器裁决)', l1.includes('语义'));
154
+
155
+ const l2 = C.routeAdvice('L2', { hintsConfigured: true });
156
+ check('S7 L2 输出四类触达面', C.L2_SURFACES.every((s) => l2.includes(s)));
157
+ check('S7 L2 不输出 L3 结构面', !l2.includes('DI 链:注册'));
158
+ check('S7 L2 不输出负例面', !l2.includes('【负例'));
159
+
160
+ const l3 = C.routeAdvice('L3', { hintsConfigured: true });
161
+ check('S7 L3 同时输出 L2 触达面与 L3 结构面', C.L2_SURFACES.every((s) => l3.includes(s)) && C.L3_SURFACES.every((s) => l3.includes(s)));
162
+
163
+ const l0 = C.routeAdvice('L0', { hintsConfigured: true });
164
+ check('S7 L0 归一为语义行(无下溢)', l0.includes('建议级别:L0'));
165
+
166
+ // 2026-10-13 executor-review:hints 未配置 / 低于下限两条**状态**信息已移出 routeAdvice,
167
+ // 改由 route 的降级块(executor.mjs degradations)单一输出——原实现把同一事实在文本面打两遍。
168
+ // 判据面留在本函数,状态面归降级块;此处锁死「不再重复」。
169
+ const un = C.routeAdvice('L1', { hintsConfigured: false });
170
+ check('S7 routeAdvice 不再输出 hints 状态(降级块唯一出口)',
171
+ !un.includes('未配置') && !un.includes('机器面给不出档位提示'), un);
172
+ const under = C.routeAdvice('L3', { hintsConfigured: true, underHint: true, proposed: 'L1', hintLevel: 'L3', matched: ['L3←src/db/migrations/001_init.sql'] });
173
+ check('S7 routeAdvice 不再输出 underHint 状态(降级块唯一出口)',
174
+ !under.includes('低于路径提示下限') && !under.includes('src/db/migrations/001_init.sql'), under);
175
+ check('S7 但判据面照常输出(自查句 + 机器边界声明)',
176
+ under.includes(C.SELF_CHECK) && under.includes('语义'), under);
177
+
178
+ check('S7 未知档位标注未知而非假装', C.routeAdvice('L9', {}).includes('(未知档)'));
179
+ check('S7 空档位回落 L1', C.routeAdvice('', {}).includes('建议级别:L1'));
180
+ }
181
+
182
+ // ============ 场景 8:单源纪律 —— md 不再承载判据散文 ============
183
+ {
184
+ const cmdPath = path.join(SCRIPT_DIR, '..', 'commands', 'new-task.md');
185
+ const md = fs.readFileSync(cmdPath, 'utf8');
186
+ check('S8 new-task.md 指向判据单源', md.includes('level-criteria.mjs'));
187
+ check('S8 new-task.md 登记 level-hints.txt', md.includes('.agents/level-hints.txt'));
188
+ check('S8 new-task.md 不再内嵌 L2 四类触达面散文', !md.includes('聚合投影键'));
189
+ check('S8 new-task.md 不再内嵌判低负例散文', !md.includes('单模块内部重构且无对外契约变化'));
190
+ check('S8 new-task.md 不再内嵌自查句原文', !md.includes('能否列出 ≥2 个受影响的消费方'));
191
+
192
+ // 过期指针回归:§级别判断 已改为「引用单源」,别处再指向它就是指向一个不再承载判据的章节。
193
+ // (改写该节时同步发现 design.md 与 fill-intent.mjs 两处指针,测试锁死免得日后再长出来。)
194
+ const design = fs.readFileSync(path.join(SCRIPT_DIR, '..', 'commands', 'design.md'), 'utf8');
195
+ const fillIntent = fs.readFileSync(path.join(SCRIPT_DIR, 'fill-intent.mjs'), 'utf8');
196
+ const cmdsDir = path.join(SCRIPT_DIR, '..', 'commands');
197
+ const cmdHits = fs.readdirSync(cmdsDir)
198
+ .filter((f) => f.endsWith('.md'))
199
+ .filter((f) => fs.readFileSync(path.join(cmdsDir, f), 'utf8').match(/§\s*级别判断|new-task\.md[^))]*级别判断/));
200
+ check('S8 无 md 指令指向已改写的 §级别判断', cmdHits.length === 0, cmdHits.join(','));
201
+ check('S8 design.md 指向判据单源', design.includes('level-criteria.mjs'));
202
+ check('S8 fill-intent 不再指向 §级别判断', !fillIntent.includes('§级别判断'));
203
+
204
+ // 面扩到 md 之外:首轮改写漏掉的三处(pipeline-run 工单 / check-lane-surface 门禁报文 /
205
+ // lane-surfaces.txt 头注释)都在 .mjs 与 .txt 里,只扫 commands/*.mjs 会漏。扫描整个装户面。
206
+ const AGENTS_DIR = path.join(SCRIPT_DIR, '..');
207
+ const stale = [];
208
+ const walk = (dir) => {
209
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
210
+ if (e.name === 'scripts' && dir !== AGENTS_DIR) continue; // 避免重复扫
211
+ const abs = path.join(dir, e.name);
212
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'cache') walk(abs); continue; }
213
+ if (!/\.(mjs|txt|md|json)$/.test(e.name)) continue;
214
+ if (e.name.endsWith('.test.mjs')) continue; // 含本文件(顶层 await 环境下不能用 __filename)
215
+ const t = fs.readFileSync(abs, 'utf8');
216
+ if (/new-task\.md[^\n]{0,40}§\s*级别判断/.test(t)) stale.push(path.relative(AGENTS_DIR, abs).replace(/\\/g, '/'));
217
+ }
218
+ };
219
+ walk(AGENTS_DIR);
220
+ check('S8 装户面无指向 §级别判断的过期指针', stale.length === 0, stale.join(','));
221
+
222
+ // 判据**散文本身**的回归(2026-10-13 executor-review):原护栏只锁 new-task.md 里 3 个字面串,
223
+ // 于是判据换了措辞就能在别处重新长回来——实测 design.md 与 fill-intent.mjs 各存了一份
224
+ // (后者把 L2 列成五项,单源是四项)。故改为**按单源词条扫判级面**:单源与本测试之外,
225
+ // 凡列举/复述判据闭集的文件即判红。
226
+ // 扫描面 = 会指导判级的两类件(命令 md + 起草工具脚本);不含 level-hints.txt——那是路径
227
+ // 示例文件,其注释里提到「迁移 SQL」「DI 链」是路径举例,不是判据复述。
228
+ const CRITERIA_TERMS = ['编码权威', '共享契约', '既有接口语义', '全局横切口径', 'DI 链', '认证与中间件管线'];
229
+ const ALLOW = new Set(['level-criteria.mjs', 'level-criteria.test.mjs']);
230
+ const proseHits = [];
231
+ const scanProse = (dir) => {
232
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
233
+ const abs = path.join(dir, e.name);
234
+ const rel = path.relative(AGENTS_DIR, abs).replace(/\\/g, '/');
235
+ if (e.isDirectory()) { if (e.name !== 'node_modules' && e.name !== 'cache') scanProse(abs); continue; }
236
+ if (!/\.(mjs|md)$/.test(e.name)) continue;
237
+ if (e.name.endsWith('.test.mjs') || ALLOW.has(e.name)) continue;
238
+ // 只扫判级面:命令 md + 起草工具(fill-*)。其余脚本引用单源属正常。
239
+ if (!/^commands\//.test(rel) && !/^scripts\/fill-/.test(rel)) continue;
240
+ const t = fs.readFileSync(abs, 'utf8');
241
+ const hit = CRITERIA_TERMS.filter((w) => t.includes(w));
242
+ if (hit.length) proseHits.push(`${rel} → ${hit.join('/')}`);
243
+ }
244
+ };
245
+ scanProse(AGENTS_DIR);
246
+ check('S8 判级面不复述判据闭集(词条只在单源一处)', proseHits.length === 0, proseHits.join(' | '));
247
+ check('S8 design.md 只留流程映射 + 指向单源',
248
+ design.includes('L2_SURFACES') && design.includes('L3_SURFACES') && proseHits.every((h) => !h.startsWith('commands/design.md')),
249
+ proseHits.filter((h) => h.startsWith('commands/design.md')).join('|'));
250
+ check('S8 fill-intent 触达红线改为对照单源(不再自带五项/四项清单)',
251
+ fillIntent.includes('L2_SURFACES') && fillIntent.includes('L3_SURFACES'), '');
252
+
253
+ // B3:route 曾是「可绕旁路」——装户面唯一提及只是 §4 里一行 bash 提示,无任何必跑语义,
254
+ // 而机器又无法发现它没被调用(定级早于改动面确定,无可比对的记录面,见 ARCHITECTURE §5)。
255
+ // 故把接线强度锁在**流程文本**上:必跑标记 + 不给 --files 拿不到下限 + 跳过须写理由 + 兜底门。
256
+ const nt = fs.readFileSync(path.join(SCRIPT_DIR, '..', 'commands', 'new-task.md'), 'utf8');
257
+ check('S8 new-task.md 定级第 0 步标注必跑 route(非可选提示)', /必跑/.test(nt) && /flow-kit route --files/.test(nt));
258
+ check('S8 new-task.md 说明不给 --files 就拿不到路径下限', nt.includes('不给 `--files` 就拿不到路径下限'));
259
+ check('S8 new-task.md 要求跳过时写明理由', nt.includes('写明理由'));
260
+ check('S8 new-task.md 点名提交面兜底门', nt.includes('check-lane-surface'));
261
+ check('S8 new-task.md 声明机器无法发现跳过(诚实边界)', nt.includes('无法**发现'));
262
+ }
263
+
264
+ // ============ 场景 9:装户模板面 —— level-hints.txt 随模板下发且默认为空 ============
265
+ {
266
+ const tpl = path.join(SCRIPT_DIR, '..', 'level-hints.txt');
267
+ const ok = fs.existsSync(tpl);
268
+ check('S9 模板带 level-hints.txt', ok);
269
+ if (ok) {
270
+ const txt = fs.readFileSync(tpl, 'utf8');
271
+ check('S9 默认无有效条目(内容项目私有)', C.parseLevelHints(txt).length === 0);
272
+ check('S9 说明格式为 `<档位> <ERE>`', txt.includes('`<档位> <ERE>`'), txt.slice(0, 200));
273
+ check('S9 含被注释掉的 L2/L3 示例行', /#\s*L3\s+\^/.test(txt) && /#\s*L2\s+\^/.test(txt));
274
+ check('S9 说明默认留空与项目私有立场', txt.includes('默认留空') && txt.includes('项目私有'));
275
+ }
276
+ }
277
+
278
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
279
+ process.exit(fail ? 1 : 0);
@@ -271,7 +271,9 @@ function changedFiles(ctx, run) {
271
271
  }
272
272
 
273
273
  // 脚本自身管理的运行态路径——confirm/gate/gen-index 副产物,不属宿主越权面(沙箱演练实证,2026-10-02)
274
- const RUNTIME_OWNED = ['.agents/cache/', '.agents/confirmations.jsonl', 'workflow/INDEX.md'];
274
+ // confirmations.d/ 亦为运行态副产物(2026-10-12 ledger-shard-write 的 `--shard` 写入面)——
275
+ // 不加会让 --shard 的确认行被沙箱越权检查误判为「宿主改了台账」。
276
+ const RUNTIME_OWNED = ['.agents/cache/', '.agents/confirmations.jsonl', '.agents/confirmations.d/', 'workflow/INDEX.md'];
275
277
 
276
278
  function checkAuthorized(ctx, run, filesCsv) {
277
279
  const listed = String(filesCsv || '').split(/[,,]/).map((s) => s.trim().replace(/\\/g, '/')).filter(Boolean);
@@ -338,7 +340,7 @@ function advance(ctx, run, opt) {
338
340
  issueWO(ctx, run, wo(run, {
339
341
  kind: 'triage', title: '判级与分类', goal: '读需求与 new-task.md 判级闭集,输出 kind/level/module/topic',
340
342
  instructions: [
341
- '1. 读 .agents/commands/new-task.md §级别判断(L2 触达面闭集 / L1 默认档 / L3 结构面)',
343
+ '1. 读 .agents/scripts/level-criteria.mjs 判据单源(L2_SURFACES 触达面闭集 / L1 默认档 / L3_SURFACES 结构面;new-task.md 只承载流程表,判据一律看单源;机器面可先跑 `flow-kit route --kind require` 取路径下限提示)',
342
344
  `2. 对需求定级:「${run.requirement}」${run.hint ? `(预判参考:${run.hint})` : ''}`,
343
345
  '3. 回填:next --triage "kind=require|fix|verify|review|deploy level=L0-L3 module=<词表> topic=<英文kebab>"',
344
346
  ],
@@ -119,6 +119,34 @@ export const POLICIES = {
119
119
  // 消费方:check-loop 检查 15 的 scope 子项(台账行 ts ≥ 本值才判)。
120
120
  scopeSince: '2026-10-09T11:24:43Z',
121
121
  },
122
+ // 版本 8 = 版本 7 的全部键 + ledgerDocChainSince(台账哈希链的**链域**从「文件物理序」改为
123
+ // 「按 doc 的逻辑序」,2026-10-12 ledger-doc-chain)。
124
+ // 为什么改:原链 prevHash = 物理上一行的 hash,隐含「台账是单写者顺序追加」的前提。三种并发形态
125
+ // 全部破坏它——① 两进程并发 append 读到同一末行 → 同 prevHash 分叉;② git merge 并集(见
126
+ // git-merge-jsonl.mjs)后 B 侧行被追加到 A 尾部之后 → A 侧链被劈开;③ 分片归并 compact 把分片行
127
+ // 插进主台账中部 → 断链。且这三者都是**正常协作**,不是篡改——让正常协作长期撞 hard 门,门禁就会被
128
+ // 绕过(--no-verify),治理失效。
129
+ // 为什么按 doc 分域:台账行的天然主键是 doc(每行 = 某份文档的一次确认)。**同一 doc 内的先后有语义,
130
+ // doc 之间的先后没有语义**。把链的域对齐语义域是对既有判据的修正,不是削弱——就地改行、删行、
131
+ // 插行三类篡改在 doc 链下一律仍被抓住(见 check-loop 检查 15)。
132
+ // 本版本只加一个开关键:**缺键(v1-v7)时写入侧与校验侧都走旧物理链口径**,存量台账零变化、零新增告警。
133
+ // 粒度取「时刻」而非日期(同 scopeSince / verifyDocSince 的教训):本日已有一批旧口径行写入,
134
+ // 日期粒度会把它们纳入新受审范围、产出不可消退告警。
135
+ // 消费方:confirm-doc appendLedger(写入侧取前驱)+ check-loop 检查 15(校验侧按行分域)。
136
+ 8: {
137
+ moduleSince: '2026-09-22',
138
+ check14Since: '2026-09-26',
139
+ confirmDocsEffective: '2026-09-27',
140
+ confirmIncidentsEffective: '2026-09-28',
141
+ bindingTs: '2026-09-28',
142
+ check17UnclosedAfter: '0.8.0',
143
+ stageGateSince: '2026-09-29',
144
+ riskLevelSince: '2026-10-01',
145
+ verifySince: '2026-10-07',
146
+ verifyDocSince: '2026-10-08T09:37:35Z',
147
+ scopeSince: '2026-10-09T11:24:43Z',
148
+ ledgerDocChainSince: '2026-10-12T07:20:00Z',
149
+ },
122
150
  };
123
151
 
124
152
  export function loadKitPolicy(root) {
@@ -180,6 +208,25 @@ export function ledgerChainHash(row, prevHash) {
180
208
  return createHash('sha256').update((prevHash || '') + JSON.stringify(row)).digest('hex');
181
209
  }
182
210
 
211
+ // ---- 台账 doc 链前驱(2026-10-12 ledger-doc-chain)----
212
+ // 单源:confirm-doc appendLedger(写入侧取前驱)与 check-loop 检查 15(校验侧判接续)共用同一语义。
213
+ // ledgerDocChainActive(policy):锚存在且为合法 ISO 时刻 → 新口径生效;缺键(v1-v7)→ 旧物理链。
214
+ // ledgerDocChainPrev(rows, doc):rows 为物理序的已解析台账行,返回该 doc **最后一个带 hash 行**的
215
+ // hash,无则 ''(链段重启)。取不到带 hash 的行时返回 '',与旧口径「末行无 hash → ''」同向。
216
+ // 为什么不做去重/合并:去重是**口径变更**(会改漏斗计数),属分片功能同批生效,不在本步。
217
+ export function ledgerDocChainActive(policy) {
218
+ const s = policy && policy.ledgerDocChainSince;
219
+ return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(s);
220
+ }
221
+
222
+ export function ledgerDocChainPrev(rows, doc) {
223
+ for (let i = (rows && rows.length ? rows.length : 0) - 1; i >= 0; i--) {
224
+ const r = rows[i];
225
+ if (r && r.doc === doc && typeof r.hash === 'string') return r.hash;
226
+ }
227
+ return '';
228
+ }
229
+
183
230
  // ---- 台账 scope(机器派生改动面,2026-10-09 审查 A1 干净版)----
184
231
  // scopeDigest(files) = {files, sha256}:files 归一(反斜杠→posix)、去重、排序;sha256 = files.join('\n') 的摘要。
185
232
  // **单源**(confirm-doc 落账侧构造与 check-loop 检查 15 校验侧重算共用——防两处归一/排序口径漂移,
@@ -25,6 +25,7 @@ import fs from 'node:fs';
25
25
  import path from 'node:path';
26
26
  import { spawnSync } from 'node:child_process';
27
27
  import { loadKitPolicy } from './policy.mjs';
28
+ import { readLedgerRows } from './ledger-loader.mjs';
28
29
 
29
30
  export const ENTRY_STATUSES = ['approved', 'done'];
30
31
  export const INCIDENT_STATUSES = ['open', 'fixed', 'closed'];
@@ -48,16 +49,10 @@ function readBody(abs) {
48
49
  }
49
50
 
50
51
  // readLedger:confirmations.jsonl 追加式台账(坏行容忍跳过;与 check-loop 检查 15 同容错口径)
52
+ // 读法收敛到 ledger-loader 单源(2026-10-12 ledger-loader-p1):本函数此前自持 readFileSync+parse
53
+ // 复刻,与其余 5 处各写各的——分片台账落地时逐处加分支必然漏改。签名与返回值不变。
51
54
  export function readLedger(root) {
52
- const p = path.join(root, '.agents', 'confirmations.jsonl');
53
- let text = '';
54
- try { text = fs.readFileSync(p, 'utf8'); } catch { return []; }
55
- const out = [];
56
- for (const line of text.split('\n')) {
57
- if (!line.trim()) continue;
58
- try { out.push(JSON.parse(line)); } catch { /* 坏行跳过 */ }
59
- }
60
- return out;
55
+ return readLedgerRows(root);
61
56
  }
62
57
 
63
58
  // judgeDoc:状态枚举 + 机器事实源判定(供入口 / spec 复用)
@@ -139,7 +139,7 @@ const STAGES = parseStages(`| id | after | role | step | task | files | accept |
139
139
  const j = path.join(os.tmpdir(), `wfj-real-${Date.now()}.jsonl`);
140
140
  const r = spawnSync(process.execPath, [JOURNAL_CLI, 'status', '--wf', 'pipeline-closing', '--journal', j], { encoding: 'utf8', cwd: ROOT });
141
141
  check('S9 真实 pipeline-closing:confirm 为 human 形态且无 journal 时 author 首层就绪',
142
- !!confirm && confirm.human && !confirm.role && stages.length >= 10
142
+ !!confirm && confirm.human && !confirm.role && stages.length >= 9
143
143
  && r.status === 0 && /就绪(第 0 层.*author/.test(r.stdout),
144
144
  JSON.stringify({ confirm, out: r.stdout }));
145
145
  fs.rmSync(j, { force: true });