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
@@ -37,8 +37,8 @@ const mkfix = ({ withEntry = false, entryNames = 'gate.mjs' } = {}) => {
37
37
  const d = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-test-'));
38
38
  w(d, 'templates/_agents/scripts/gate.mjs',
39
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');
40
+ w(d, 'templates/_githooks/pre-commit', '#!/bin/sh\nif ! node check.mjs; then\n echo blocked >&2\n exit 1\nfi\nexit 0\n');
41
+ w(d, 'templates/AGENTS.md', '# 协议\n- 规则一\n- 规则二\n');
42
42
  if (withEntry) {
43
43
  w(d, 'workflow/intents/2026-10-09-ctrl.md',
44
44
  `---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:templates/_agents/scripts/${entryNames}\n`);
@@ -56,14 +56,13 @@ const mkfix = ({ withEntry = false, entryNames = 'gate.mjs' } = {}) => {
56
56
  {
57
57
  const d = mkfix();
58
58
  const u = run(d, ['--update']);
59
- // fixture 无 .agents/ → 基线落包源径(resolveLock 布局回落,见 T9/T10)
60
59
  const lockPath = path.join(d, 'templates', '_agents', 'engine-lock.json');
61
60
  let lock = null;
62
61
  try { lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { /* 断言兜 */ }
63
62
  const r = run(d);
64
63
  check('T2a --update → exit 0 且基线入账受检面', u.code === 0 && !!lock && !!lock.files['templates/_agents/scripts/gate.mjs'], u.out);
65
64
  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']));
65
+ check('T2c 钩子入面 hard=1(exit 1 计数)', !!lock && lock.files['templates/_githooks/pre-commit'].hard === 1, JSON.stringify(lock && lock.files['templates/_githooks/pre-commit']));
67
66
  check('T2d 盘面未变 → exit 0 且报「与基线一致」', r.code === 0 && r.out.includes('盘面与基线一致'), r.out);
68
67
  }
69
68
 
@@ -78,36 +77,31 @@ const mkfix = ({ withEntry = false, entryNames = 'gate.mjs' } = {}) => {
78
77
  check('T3b 有活跃 L2 入口仍拦(收缩无豁免出口)', r.code === 1 && r.out.includes('[硬拦面收缩]'), r.out);
79
78
  }
80
79
 
81
- // --- T4 非收缩改动:无入口拦、有入口放行 ---
80
+ // --- T4 非收缩改动:内容漂移即拦(2026-10-13 移除入口退路后无豁免出口)---
82
81
  {
83
82
  const d = mkfix();
84
83
  run(d, ['--update']);
85
84
  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);
85
+ const drift = run(d);
86
+ check('T4a 内容漂移(硬拦面未缩)→ [控制面改动未入基线]',
87
+ drift.code === 1 && drift.out.includes('[控制面改动未入基线]') && drift.out.includes('gate.mjs'), drift.out);
88
+ check('T4a 修法指向 --update(不再宣传不可达的「立 L2 入口」)',
89
+ drift.out.includes('--update') && !drift.out.includes('立 L2 入口'), drift.out);
90
+ check('T4a --update 入账后放行', run(d, ['--update']).code === 0 && run(d).code === 0);
102
91
  }
103
- // --- T4d 入口非活跃(done 已关单)→ 不算点名 ---
92
+ // --- T4e 入口面即便存在也不豁免(锁死新口径)---
93
+ // 1.7.15 卸掉同仓双身份后,包源仓没有 workflow/intents —— 旧「立入口即放行」退路在本仓不可达。
94
+ // 删掉该能力后,即使有人在包源仓手工造出入口文档点名该件,**也不得**成为放行口:
95
+ // 否则一个 mkdir 就把门重新打开,等于用未验证的路径恢复未验证的能力。
104
96
  {
105
- const d = mkfix({ withEntry: false });
97
+ const d = mkfix();
106
98
  run(d, ['--update']);
107
99
  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');
100
+ w(d, 'workflow/intents/2026-10-09-ctrl.md',
101
+ '---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:gate.mjs\n');
109
102
  const r = run(d);
110
- check('T4d done 入口不点名(活跃口径:draft/approved)', r.code === 1 && r.out.includes('[控制面改动无入口]'), r.out);
103
+ check('T4e 活跃 L2 入口点名该件 → 仍拦(本门无声明式豁免出口)',
104
+ r.code === 1 && r.out.includes('[控制面改动未入基线]'), r.out);
111
105
  }
112
106
 
113
107
  // --- T5 门禁件消失 / 新增件未入基线 ---
@@ -158,26 +152,20 @@ if (haveGit) {
158
152
  const anchored = run(d, ['--against-ref', 'HEAD']);
159
153
  check('T7b 跨提交锚(祖先提交基线)出账 [硬拦面收缩(跨提交锚)]',
160
154
  anchored.code === 1 && anchored.out.includes('[硬拦面收缩(跨提交锚)]') && /3(见 HEAD)→ 盘面 2/.test(anchored.out), anchored.out);
161
- // 合法退役出口:活跃 L2 入口点名该件
155
+ // T7c/T7e(2026-10-13 移除入口退路后删除):原为「活跃 L2 入口点名 → 锚层放行」与「严格档:
156
+ // 模糊提及不豁免」两条断言。二者均依赖 workflow/intents 入口面,而该面在本仓结构性不可达
157
+ // (1.7.15 卸掉同仓双身份),故能力与测试一并移除——锚层的收缩判定现在**无豁免出口**。
158
+ // T7c 替代断言:造出入口文档点名该件,锚层仍须拦(锁死「无豁免」不随时间被悄悄恢复)
162
159
  w(d, 'workflow/intents/2026-10-09-retire.md',
163
160
  '---\n状态: approved\n级别: L2\n日期: 2026-10-09\n模块: gate\n---\n# INTENT\n改动面:templates/_agents/scripts/gate.mjs(退役 B 判据)\n');
164
161
  git(d, ['add', '-A']);
165
- git(d, ['commit', '-q', '-m', 'docs(gate): L2 入口留痕']);
162
+ git(d, ['commit', '-q', '-m', 'docs(gate): 造入口文档(应不构成放行口)']);
166
163
  const declared = run(d, ['--against-ref', 'HEAD~1']);
167
- check('T7c 声明过的收缩(L2 入口点名)→ 跨提交锚放行', declared.code === 0, declared.out);
164
+ check('T7c 入口文档点名该件 → 锚层仍拦(本门无声明式豁免出口)',
165
+ declared.code === 1 && declared.out.includes('[硬拦面收缩(跨提交锚)]'), declared.out);
168
166
  // 锚缺失(首个入账提交之前)→ 跳过不误报
169
167
  const missing = run(d, ['--against-ref', 'HEAD~9']);
170
168
  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
169
  } else {
182
170
  console.log('SKIP T7 跨提交锚(未找到 git)');
183
171
  }
@@ -190,18 +178,18 @@ if (haveGit) {
190
178
  check('T8 --quiet 通过时不打印 ✅ 行', r.code === 0 && !r.out.includes('✅'), r.out);
191
179
  }
192
180
 
193
- // --- T9 装户布局:.agents/ 在 → 基线落装户径,不新建 templates/_agents/engine-lock.json ---
181
+ // --- T9 非包源根:拒绝,不写基线、不建 templates/_agents ---
194
182
  {
195
183
  const d = fs.mkdtempSync(path.join(os.tmpdir(), 'engine-integrity-test-'));
196
184
  w(d, '.agents/scripts/gate.mjs', 'blockers.push(`A`);\nblockers.push(`B`);\nif (bad) process.exit(1);\n');
197
185
  w(d, 'AGENTS.md', '# 协议\n- 规则一\n');
198
186
  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')), '');
187
+ const plain = run(d);
188
+ check('T9a 非包源根 --update 拒绝', u.code !== 0 && /只在包源仓运行/.test(u.out), u.out);
189
+ check('T9b 非包源根默认模式同样拒绝', plain.code !== 0 && /只在包源仓运行/.test(plain.out), plain.out);
190
+ check('T9c 不写 .agents/engine-lock.json,不建 templates/_agents',
191
+ !fs.existsSync(path.join(d, '.agents', 'engine-lock.json'))
192
+ && !fs.existsSync(path.join(d, 'templates', '_agents')), '');
205
193
  fs.rmSync(d, { recursive: true, force: true });
206
194
  }
207
195
 
@@ -30,6 +30,7 @@ import path from 'node:path';
30
30
  import { spawnSync } from 'node:child_process';
31
31
  import { laneOfEntry, laneOfDoc } from './stage-gates.mjs';
32
32
  import { loadKitPolicy } from './policy.mjs';
33
+ import { loadLevelHints, hintLevelFor, rankOf, maxLevel } from './level-criteria.mjs';
33
34
 
34
35
  const argv = process.argv.slice(2);
35
36
  const argOf = (k) => {
@@ -126,32 +127,80 @@ const collectActive = () => {
126
127
  const blocks = [];
127
128
  const advisories = [];
128
129
 
130
+ // --- 检查 A0:level-hints 路径下限对账(2026-10-13 hints-backstop)----------------
131
+ // 背景:此前「就高不就低」只在 flow-kit route 输出 advisory,而提交面判低门只认 lane-surfaces.txt
132
+ // → 两份清单各管一头、**无机器对账**:项目在 hints 里登记了「改这些面至少 L2/L3」,提交面却可能
133
+ // 靠一个 L1 活跃入口放行(判低有机可乘)。本段把 hints 接进提交面:
134
+ // ① hints 命中 → 要求存在**级别 ≥ 命中档**的活跃入口(不只「有个 high 入口」),否则 hard 拦;
135
+ // ② hints 已登记高风险面、lane-surfaces.txt 却缺失/为空 → advisory 提示「意图面登记了、提交面无兜底」。
136
+ // fail-closed:hints 语法错 → hard 拦(写错配置比没配置危险,见 level-criteria parseLevelHints)。
137
+ const hintsState = (() => {
138
+ try {
139
+ const { hints } = loadLevelHints(ROOT);
140
+ return { hints, invalid: null };
141
+ } catch (e) {
142
+ return { hints: [], invalid: e.message };
143
+ }
144
+ })();
145
+
146
+ if (hintsState.invalid) {
147
+ blocks.push([
148
+ '[level-hints 配置错误] .agents/level-hints.txt 非法(档位只收 L2/L3;ERE 不得带行内注释)——配置面坏了整条兜底不可信:',
149
+ ...String(hintsState.invalid).split('\n').map((l) => ` ${l}`),
150
+ ' 修法:修正清单后重试(fail-closed,防静默空洞——写错配置比没配置危险:没配置会提示,写错会误判档位)',
151
+ ].join('\n'));
152
+ }
153
+
154
+ // 命中判定复用判据单源的 hintLevelFor(2026-10-13 executor-review):此前本文件手写了一遍
155
+ // 「路径归一化 + 多条命中取高档」,与 level-criteria.mjs:104 的实现同形但独立演进——
156
+ // 双源漂移是本仓硬规则(doctor 段就写着「不复刻解析器」),一旦归一化口径调整,
157
+ // route 会判 L1 而本门拦 L3,且没有任何测试能发现。
158
+ const hintHits = hintsState.hints.length
159
+ ? STAGED
160
+ .map((p) => ({ p, lv: hintLevelFor(hintsState.hints, [p]).level }))
161
+ .filter((x) => x.lv)
162
+ : [];
163
+ const hintLevel = hintHits.reduce((acc, x) => maxLevel(acc, x.lv), '');
164
+
129
165
  // --- 检查 A:触达面判低 ---
130
166
  const patterns = loadPatterns();
167
+
168
+ if (hintLevel && !patterns.length) {
169
+ advisories.push(
170
+ `[level-hints 无提交面兜底] .agents/level-hints.txt 登记了 ${hintHits.length} 个命中(${hintHits.slice(0, 5).map((x) => `${x.p}→${x.lv}`).join(' ')})——`
171
+ + '但 .agents/lane-surfaces.txt 缺失或为空,触达面判低检查整体跳过:意图面登记了高风险面,提交面却无兜底',
172
+ );
173
+ }
131
174
  const hits = patterns.length ? STAGED.filter((p) => patterns.some((re) => re.test(p))) : [];
132
- if (hits.length) {
175
+ // 本次提交触达到的**最低应然档位**:lane-surfaces 命中隐含 L2(沿原口径),hints 命中按登记档取。
176
+ // 混档取高——与 level-criteria maxLevel 同一口径(就高不就低在提交面同样成立)。
177
+ const requiredLevel = maxLevel(hits.length ? 'L2' : '', hintLevel);
178
+ if (requiredLevel) {
133
179
  const active = collectActive();
134
- const hasHigh = active.some((e) => e.lane === 'high');
135
- if (!hasHigh) {
136
- const low = active.filter((e) => e.lane !== 'high'); // low + suspect 均拦(suspect 不作豁免依据)
137
- if (low.length) {
138
- const newest = [...low].sort((a, b) => (b.date || '').localeCompare(a.date || '') || b.rel.localeCompare(a.rel))[0];
139
- const fixVerb = newest.lane === 'suspect'
140
- ? '① 对齐该入口「级别」与「risk_level」两字段至一致的 L2/L3(fill-intent 双写口径;现两值缺失/非法/分歧)并补同名 spec(.agents/commands/design.md)'
141
- : '① 升级该入口「级别」与「risk_level」两字段至 L2/L3 并补同名 spec(.agents/commands/design.md)';
142
- const text = [
143
- `[触达面判低] 暂存命中 L2 触达面,但活跃入口无泳道 high(存在 low/suspect):`,
144
- ` 命中: ${hits.join(' ')}`,
145
- ` 关联入口: ${newest.rel}(级别 ${newest.level || '缺失'}/risk_level ${newest.risk || '缺失'}→ 泳道 ${newest.lane}${newest.date ? `,${newest.date}` : ''};活跃非 high 共 ${low.length} 件)`,
146
- ` 修法(三选一): ${fixVerb}`,
147
- ` ② 该入口已完成则关单(done)或显式放弃(superseded/cancelled)——不留活跃残留`,
148
- ` ③ 面清单误配则修 .agents/lane-surfaces.txt`,
149
- ].join('\n');
150
- if (isExperiment) advisories.push(`(advisory·experiment 泳道,不阻断;转正由 pre-push 加固门兜底)\n${text}`);
151
- else blocks.push(text);
152
- } else {
153
- advisories.push(`[触达面判低](advisory)暂存命中 L2 触达面(${hits.length} 个文件),但当前无任何活跃入口——若非 docs / bootstrap 豁免类改动(.agents/commands/new-task.md §intent 豁免),请先立入口文档`);
154
- }
180
+ const requiredRank = rankOf(requiredLevel) ?? 1;
181
+ // 入口是否够格:级别 rank ≥ 应然档。**suspect(级别/risk_level 缺失、非法或分歧)一律不够格**——
182
+ // 沿用原口径「suspect 不作豁免依据」:分歧(L2/L1)这类看似够档的入口,机器无法确认它真罩着这批改动。
183
+ const enough = (e) => e.lane !== 'suspect' && (rankOf(e.level) ?? -1) >= requiredRank;
184
+ const insufficient = active.filter((e) => !enough(e));
185
+ if (insufficient.length) {
186
+ const newest = [...insufficient].sort((a, b) => (b.date || '').localeCompare(a.date || '') || b.rel.localeCompare(a.rel))[0];
187
+ const hitList = [...hits.map((p) => `${p}(触达面)`), ...hintHits.map((x) => `${x.p}(hints→${x.lv})`)];
188
+ const fixVerb = newest.lane === 'suspect'
189
+ ? `① 对齐该入口「级别」与「risk_level」两字段至一致的 ${requiredLevel}(fill-intent 双写口径;现两值缺失/非法/分歧)并补同名 spec(.agents/commands/design.md)`
190
+ : `① 升级该入口「级别」与「risk_level」两字段至 ${requiredLevel} 及以上并补同名 spec(.agents/commands/design.md)`;
191
+ const text = [
192
+ `[触达面判低] 暂存命中高风险面,但活跃入口无 ${requiredLevel} 及以上(存在更低档/low/suspect):`,
193
+ ` 命中: ${hitList.join(' ')}`,
194
+ ` 关联入口: ${newest.rel}(级别 ${newest.level || '缺失'}/risk_level ${newest.risk || '缺失'}→ 泳道 ${newest.lane}${newest.date ? `,${newest.date}` : ''};活跃不足共 ${insufficient.length} 件)`,
195
+ ...(hintLevel ? [' 判据:.agents/level-hints.txt 登记的路径下限——就高不就低在提交面同样成立(此前只 advisory,无门)'] : []),
196
+ ` 修法(三选一): ${fixVerb}`,
197
+ ` ② 该入口已完成则关单(done)或显式放弃(superseded/cancelled)——不留活跃残留`,
198
+ ` ③ 清单误配则修 .agents/lane-surfaces.txt${hintLevel ? ' 或 .agents/level-hints.txt' : ''}`,
199
+ ].join('\n');
200
+ if (isExperiment) advisories.push(`(advisory·experiment 泳道,不阻断;转正由 pre-push 加固门兜底)\n${text}`);
201
+ else blocks.push(text);
202
+ } else if (!active.length) {
203
+ advisories.push(`[触达面判低](advisory)暂存命中高风险面(${hits.length + hintHits.length} 个文件,应然 ${requiredLevel}),但当前无任何活跃入口——若非 docs / bootstrap 豁免类改动(.agents/commands/new-task.md §intent 豁免),请先立入口文档`);
155
204
  }
156
205
  }
157
206
 
@@ -176,7 +225,7 @@ if (isExperiment) {
176
225
  for (const a of advisories) console.error(`泳道完整性(增量)— advisory:\n${a}\n`);
177
226
  if (blocks.length) {
178
227
  for (const b of blocks) console.error(`泳道完整性(增量)— BLOCK:\n${b}\n`);
179
- console.error('pre-commit: 泳道完整性未通过,提交已阻止(判据见 .agents/commands/new-task.md §级别判断)');
228
+ console.error('pre-commit: 泳道完整性未通过,提交已阻止(判据单源见 .agents/scripts/level-criteria.mjs)');
180
229
  process.exit(1);
181
230
  }
182
231
  process.exit(0);
@@ -200,6 +200,72 @@ const FM_L0_NEW = '状态: draft\n级别: L0\n日期: 2026-10-01\n模块: ui';
200
200
  const r = run(d, { staged: ['ok/a.ts'] });
201
201
  check('配置含非法 ERE → fail-closed 阻断并指出坏行', r.code === 1 && r.out.includes('[面清单语法错误]') && r.out.includes('[invalid('), r.out);
202
202
  }
203
+ // --- S-hints(2026-10-13 hints-backstop):level-hints 接入提交面判低 ---
204
+ // 此前「就高不就低」只在 flow-kit route 输出 advisory,提交面只认 lane-surfaces → 两份清单无机器对账。
205
+ {
206
+ // ① hints 命中 L3,但活跃入口只有 L2 → 判低必须拦(此前「有个 high 入口」就放行)
207
+ const d = mkfix();
208
+ base(d, { level: 'L2' });
209
+ w(d, '.agents/level-hints.txt', 'L3 ^src/db/\n');
210
+ const r = run(d, { staged: ['src/db/schema.sql'] });
211
+ check('S-hints hints 命中 L3 + 活跃仅 L2 → 阻断(要求入口 ≥ L3,不只「有个 high」)',
212
+ r.code === 1 && r.out.includes('[触达面判低]') && r.out.includes('src/db/schema.sql')
213
+ && r.out.includes('L3') && r.out.includes('level-hints.txt'), r.out);
214
+
215
+ // ② 升到 L3 后放行
216
+ const d2 = mkfix();
217
+ base(d2, { level: 'L3' });
218
+ w(d2, '.agents/level-hints.txt', 'L3 ^src/db/\n');
219
+ const r2 = run(d2, { staged: ['src/db/schema.sql'] });
220
+ check('S-hints 入口升到 L3 → 放行', r2.code === 0 && !r2.out.includes('[触达面判低]'), r2.out);
221
+
222
+ // ③ 无 hints(默认空)→ 不产生任何新阻断面(旧装户零影响)
223
+ const d3 = mkfix();
224
+ base(d3, { level: 'L1' });
225
+ const r3 = run(d3, { staged: ['src/db/schema.sql'] });
226
+ check('S-hints 未配 hints → 不触发新判定(默认空=纯语义定级)', !r3.out.includes('[触达面判低]'), r3.out);
227
+
228
+ // ④ hints 命中但无任何活跃入口 → advisory(与检查 A 同口径,豁免 docs/bootstrap 类)
229
+ const d4 = mkfix();
230
+ w(d4, '.agents/lane-surfaces.txt', '^templates/\n');
231
+ w(d4, '.agents/level-hints.txt', 'L2 ^src/db/\n');
232
+ const r4 = run(d4, { staged: ['src/db/x.sql'] });
233
+ check('S-hints 命中但无活跃入口 → advisory 不阻断',
234
+ r4.code === 0 && r4.out.includes('触达面判低') && r4.out.includes('advisory'), r4.out);
235
+
236
+ // ⑤ hints 语法错 → fail-closed(写错配置比没配置危险)
237
+ const d5 = mkfix();
238
+ base(d5, { level: 'L2' });
239
+ w(d5, '.agents/level-hints.txt', 'L3 [bad(\n');
240
+ const r5 = run(d5, { staged: ['ok/a.ts'] });
241
+ check('S-hints 非法 hints → fail-closed 阻断并带路径行号',
242
+ r5.code === 1 && r5.out.includes('[level-hints 配置错误]') && r5.out.includes('level-hints.txt'), r5.out);
243
+
244
+ // ⑥ 两份清单交叉对账:hints 登记了高风险面、lane-surfaces 为空 → advisory 点名「提交面无兜底」
245
+ const d6 = mkfix();
246
+ w(d6, '.agents/level-hints.txt', 'L2 ^src/db/\n');
247
+ const r6 = run(d6, { staged: ['src/db/x.sql'] });
248
+ check('S-hints hints 已登记但触达面清单空 → advisory 点名无兜底',
249
+ r6.code === 0 && r6.out.includes('level-hints 无提交面兜底'), r6.out);
250
+
251
+ // ⑦ L0 档 hints → 配置错误 fail-closed(2026-10-13 executor-review 实测 Major)
252
+ // 原实现接受 `L0 ^docs/`,提交面判低门当真 → 纯文档提交被硬拦,修法还让人「升级至 L0 及以上」
253
+ const d7 = mkfix();
254
+ base(d7, { level: 'L1' });
255
+ w(d7, '.agents/level-hints.txt', 'L0 ^docs/\n');
256
+ const r7 = run(d7, { staged: ['docs/readme.md'] });
257
+ check('S-hints L0 档 hints → fail-closed 阻断(不拿低档当门槛)',
258
+ r7.code === 1 && r7.out.includes('[level-hints 配置错误]') && r7.out.includes('L2 或 L3'), r7.out);
259
+
260
+ // ⑧ 路径归一化口径与判据单源一致(复用 hintLevelFor,不再各自复刻)
261
+ // 同一文件同时命中 hints 与触达面 → 判据里点名两处来源;Windows 反斜杠写法也要能命中
262
+ const d8 = mkfix();
263
+ base(d8, { level: 'L3' });
264
+ w(d8, '.agents/level-hints.txt', 'L3 ^src/db/\n');
265
+ const r8 = run(d8, { staged: ['src\\db\\a.sql'] });
266
+ check('S-hints 反斜杠路径经单源归一后仍命中(复用 hintLevelFor)',
267
+ r8.code === 0 && !r8.out.includes('[触达面判低]'), r8.out);
268
+ }
203
269
  // --- git 真仓端到端:走生产发现路径(quotepath=off + diff --cached)---
204
270
  {
205
271
  const d = mkfix();
@@ -3,6 +3,14 @@
3
3
  // 目的:把「台账本地可写」的伪造面从事后对质级升为机器可检出级(能力评审审计维度扣分项)。
4
4
  // 台账不可变原则:confirmations.jsonl 历史行禁删改——修正只能走追加补偿行(README 硬规则 6)。
5
5
  //
6
+ // **特许修剪通道(2026-10-12 ledger-prune-manifest)**:append-only 之上唯一的合法删行出口。场景是
7
+ // 仓库所有者明令删除某批 workflow 文档——台账对应行不删则行级④持续报红,退路只剩手改台账
8
+ // (即把使用者逼向 `--no-verify`)。判据与凭据格式见 ledger-prune.mjs:暂存集须同带
9
+ // `.agents/ledger-prune-manifest.json`(被删行 sha256 + 授权 + 理由),逐行对账「新 = 旧 − manifest
10
+ // 所列行」且每哈希恰命中一行、doc ∈ 声明家族,才认领为合法修剪;无 manifest 的删/改/重排**仍全拦**。
11
+ // 诚实边界:manifest 是可写文件,刻意伪造即可让任意删行合法化——与「台账本地可写」同级(见 README
12
+ // 确认门诚实边界)。本通道不宣称防伪,只把「顺手手改」抬到「提交一份可被 git 历史对质的凭据」。
13
+ //
6
14
  // 模式:
7
15
  // 历史全扫(缺省,CI 机器门):遍历台账全部提交历史(时间正序),每次变更必须以旧 blob 为
8
16
  // **逐字节前缀**(允许纯追加;禁止删行/改行/重排);再对最终树做行级校验(五项,见下)。
@@ -10,11 +18,14 @@
10
18
  // --staged(pre-commit 前置):暂存 blob 必须以 HEAD blob 为逐字节前缀——提交时刻即拦重写。
11
19
  //
12
20
  // 行级校验(终树,五项):
13
- // ① ts 为 ISO 字符串且全表非降序(追加时序单调)
21
+ // ① ts 为 ISO 字符串,且**同一 doc 内**非降序(跨 doc 物理序乱序不判——并集合并/分片归并的正常形态,
22
+ // 2026-10-12 修正;同 doc 内倒挂仍 hard 拦,跨 doc 插行/改行由检查 15 哈希链兜)
14
23
  // ② fingerprint 为 64 位 hex
15
24
  // ③ source=chat-delegated 行必有非空 quote(原话对质面)
16
25
  // ④ doc 匹配 workflow/(intents|specs|plans|incidents)/[^/]+.md 且当前树存在
17
26
  // ⑤ stage ∈ 合法跳转终态集(approved/done/fixed/closed/superseded/cancelled)
27
+ // 取数面 = ledger-loader 视图(主台账 + 分片 confirmations.d/*.jsonl);视图序为主台账在前,
28
+ // 故既有 ledger-line-exempt.json 的行号不因引入分片而漂移。
18
29
  //
19
30
  // 行级豁免(2026-10-09 ledger-line-exempt,CI 红事故处置):.agents/ledger-line-exempt.json 登记
20
31
  // 已定性的污染行({line, doc, reason}——line = 校验序号即非空行序)——行级五项对豁免行整体跳过、
@@ -29,6 +40,8 @@
29
40
  import { execFileSync } from 'node:child_process';
30
41
  import fs from 'node:fs';
31
42
  import path from 'node:path';
43
+ import { matchPrune, loadManifestFromRef, PRUNE_REL } from './ledger-prune.mjs';
44
+ import { readLedgerRows, SHARD_DIR_REL } from './ledger-loader.mjs';
32
45
 
33
46
  const argv = process.argv.slice(2);
34
47
  const argOf = (k) => {
@@ -62,11 +75,22 @@ if (staged) {
62
75
  process.exit(0); // 对象读取异常(如极端并发)交由 CI 全扫兜底
63
76
  }
64
77
  if (!cur.subarray(0, head.length).equals(head)) {
78
+ // 特许修剪(2026-10-12 ledger-prune-manifest):仓库所有者明令删某批 workflow 文档时,台账对应行
79
+ // 须随之删除,否则行级④「doc 在当前树存在」持续报红、退路只剩手改台账(踩 --no-verify 红线)。
80
+ // 出口 = **逐行对账 + 凭据入库**:暂存集须同时携带 .agents/ledger-prune-manifest.json,判据见
81
+ // ledger-prune.mjs matchPrune(四条缺一不可)。无 manifest 的删/改/重排仍全拦——append-only 红线
82
+ // 对其他一切场景不变。被删行哈希随 manifest 入库,事后可对质。
83
+ const m = matchPrune(head.toString('utf8'), cur.toString('utf8'),
84
+ loadManifestFromRef(`:${PRUNE_REL}`, ROOT));
85
+ if (m && !m.err) {
86
+ console.log(`台账不变量(--staged)— 特许修剪通过:移除 ${m.removed} 行(${m.docs.length} 家族)——凭据 ${PRUNE_REL} 随笔入库,被删行可在 git 历史对质`);
87
+ process.exit(0);
88
+ }
65
89
  let off = 0;
66
90
  while (off < Math.min(head.length, cur.length) && head[off] === cur[off]) off++;
67
- problems.push(`- [台账重写] 暂存内容不是 HEAD 的前缀扩展(首异偏移 ${off},HEAD ${head.length}B → 暂存 ${cur.length}B)`);
91
+ problems.push(`- [台账重写] 暂存内容不是 HEAD 的前缀扩展(首异偏移 ${off},HEAD ${head.length}B → 暂存 ${cur.length}B)${m && m.err ? `;特许修剪对账失败:${m.err}` : ''}`);
68
92
  console.error('台账不可变(--staged)— BLOCK:\n' + problems.join('\n'));
69
- console.error(' 修法:台账历史行禁删改——误写走追加补偿行(node .agents/scripts/confirm-doc.mjs ...);确需撤销历史提交请 git revert 后重新追加');
93
+ console.error(` 修法:台账历史行禁删改——误写走追加补偿行(node .agents/scripts/confirm-doc.mjs ...);确需撤销历史提交请 git revert 后重新追加。\n 所有者明令删除某批文档须同笔暂存 ${PRUNE_REL}(逐行 sha256 + 授权 + 理由),门禁逐行对账后才放行。`);
70
94
  process.exit(1);
71
95
  }
72
96
  process.exit(0);
@@ -82,6 +106,15 @@ try {
82
106
  }
83
107
  if (!shas.length) process.exit(0);
84
108
 
109
+ // 分片路径清单(2026-10-12 ledger-shard-write):**扫 git 全历史里出现过的分片**,不只看工作树现有的
110
+ // ——compact 会把分片删掉,只看工作树就永远扫不到「已删分片的历史」。用 pathspec 枚举全史分片路径。
111
+ let shardRelPaths = [];
112
+ try {
113
+ shardRelPaths = gitText(['log', '--all', '--format=', '--name-only', '--', `${SHARD_DIR_REL}/`])
114
+ .split(/\r?\n/).map((l) => l.trim()).filter((l) => l.startsWith(`${SHARD_DIR_REL}/`) && l.endsWith('.jsonl'));
115
+ shardRelPaths = [...new Set(shardRelPaths)].sort();
116
+ } catch { /* 非仓库/无分片历史 → 空清单,分片段整段跳过 */ }
117
+
85
118
  let prev = null;
86
119
  let prevSha = null;
87
120
  for (const sha of shas) {
@@ -94,20 +127,68 @@ for (const sha of shas) {
94
127
  continue;
95
128
  }
96
129
  if (prev !== null && !cur.subarray(0, prev.length).equals(prev)) {
97
- let off = 0;
98
- while (off < Math.min(prev.length, cur.length) && prev[off] === cur[off]) off++;
99
- const removed = prev.length > cur.length;
100
- problems.push(`- [前缀破坏] ${sha.slice(0, 10)}(前承 ${prevSha.slice(0, 10)}):历史内容被${removed ? `删减 ${prev.length - cur.length}B` : `改写(首异偏移 ${off},旧 ${prev.length}B → 新 ${cur.length}B)`}`);
130
+ // 特许修剪:前缀破坏时,若该提交树上携带 manifest 且逐行对账通过 = 认领为合法修剪,
131
+ // loud 记录后**以新 blob 为基线续扫**(对质凭据 = 该提交的 manifest + 被删行内容在 git 历史里)。
132
+ const m = matchPrune(prev.toString('utf8'), cur.toString('utf8'), loadManifestFromRef(`${sha}:${PRUNE_REL}`, ROOT));
133
+ if (m && !m.err) {
134
+ console.error(`[info] ${sha.slice(0, 10)}:特许修剪 ${m.removed} 行(${m.docs.length} 家族)——manifest 对账通过,凭据 ${PRUNE_REL} 入库可对质`);
135
+ } else {
136
+ let off = 0;
137
+ while (off < Math.min(prev.length, cur.length) && prev[off] === cur[off]) off++;
138
+ const removed = prev.length > cur.length;
139
+ problems.push(`- [前缀破坏] ${sha.slice(0, 10)}(前承 ${prevSha.slice(0, 10)}):历史内容被${removed ? `删减 ${prev.length - cur.length}B` : `改写(首异偏移 ${off},旧 ${prev.length}B → 新 ${cur.length}B)`}${m && m.err ? `;特许修剪对账失败:${m.err}` : ''}`);
140
+ }
101
141
  }
102
142
  prev = cur;
103
143
  prevSha = sha;
104
144
  }
105
145
 
146
+ // ---- 分片历史扫描(2026-10-12 ledger-shard-write)----
147
+ // 分片 `.agents/confirmations.d/*.jsonl` 此前**不受本门约束**——`--shard` 写入一旦可用,分片就成了
148
+ // 前缀不变量的缺口(可改可删而不被历史扫到)。故逐分片路径做同样的前缀不变量扫描。
149
+ // **删除豁免(与主台账相反)**:compact-ledgers.mjs 归并后**删除分片是合法形态**,不能照抄主台账那条
150
+ // 「删文件即 hard fail」。豁免判据:该分片被删时的内容,其**每一行都已出现在同提交的主台账里**
151
+ // (并集归并的必然结果)→ 认领为 compact 归并;否则(行未并入 = 丢行)仍 hard fail。
152
+ for (const shard of shardRelPaths) {
153
+ let sShas;
154
+ try {
155
+ sShas = gitText(['log', '--format=%H', '--reverse', '--', shard]).split(/\s+/).filter(Boolean);
156
+ } catch { continue; }
157
+ let sPrev = null;
158
+ for (const sha of sShas) {
159
+ let sCur = null;
160
+ try { sCur = blobOf(`${sha}:${shard}`); } catch { sCur = null; }
161
+ if (sCur === null) {
162
+ // 分片在本次提交消失 → 只在「行已全部并入主台账」时认领 compact 归并
163
+ if (sPrev !== null) {
164
+ const shardLines = sPrev.toString('utf8').replace(/\r\n/g, '\n').split('\n').filter((l) => l.trim());
165
+ let mainNow = '';
166
+ try { mainNow = blobOf(`${sha}:${REL}`).toString('utf8'); } catch { /* 主台账亦不在 = 整体删除,另行报 */ }
167
+ const mainLines = new Set(mainNow.replace(/\r\n/g, '\n').split('\n').filter((l) => l.trim()));
168
+ const missing = shardLines.filter((l) => !mainLines.has(l));
169
+ if (missing.length === 0) {
170
+ console.error(`[info] ${sha.slice(0, 10)}:分片 ${shard} 删除(${shardLines.length} 行已全部并入主台账——compact 归并形态,放行)`);
171
+ } else {
172
+ problems.push(`- [分片删除丢行] ${sha.slice(0, 10)}:${shard} 被删除,但其中 ${missing.length} 行未出现在主台账(compact 归并须先并入再删)`);
173
+ }
174
+ }
175
+ sPrev = null;
176
+ continue;
177
+ }
178
+ if (sPrev !== null && !sCur.subarray(0, sPrev.length).equals(sPrev)) {
179
+ let off = 0;
180
+ while (off < Math.min(sPrev.length, sCur.length) && sPrev[off] === sCur[off]) off++;
181
+ problems.push(`- [分片前缀破坏] ${sha.slice(0, 10)}:${shard} 历史内容被改写(首异偏移 ${off},旧 ${sPrev.length}B → 新 ${sCur.length}B)——分片同受 append-only 前缀不变量约束`);
182
+ }
183
+ sPrev = sCur;
184
+ }
185
+ }
186
+
106
187
  // ---- 行级校验(终树)----
107
- let rows = [];
108
- try {
109
- rows = fs.readFileSync(FILE, 'utf8').split(/\r?\n/).filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } });
110
- } catch { /* 上面 existsSync 已兜 */ }
188
+ // 取数走 ledger-loader 视图(2026-10-12 ledger-loader-p1 / ledger-doc-chain):主台账 + 分片
189
+ // `.agents/confirmations.d/*.jsonl`。**视图序 = 主台账行在前、分片行在后**——故既有
190
+ // `.agents/ledger-line-exempt.json` 的行号(基于主台账全量)不因引入分片而漂移。
191
+ const rows = readLedgerRows(ROOT);
111
192
  const EXEMPT_P = path.join(ROOT, '.agents', 'ledger-line-exempt.json');
112
193
  const exemptLines = new Set();
113
194
  try {
@@ -118,15 +199,29 @@ try {
118
199
  }
119
200
  } catch { /* 豁免表损坏 → 视同无豁免(fail-closed 于校验面) */ }
120
201
  let exemptCount = 0;
121
- let prevTs = '';
202
+ // ① ts 单调口径(2026-10-12 ledger-doc-chain 配套修正):**按 doc 分组内单调**,跨 doc 物理序乱序不判。
203
+ // 为什么必须改:git 并集合并(见 git-merge-jsonl.mjs)与分片归并都会把「别的 doc 的新行」追加到本文件
204
+ // 尾部,而那些行的 ts 可能早于本文件末行的 ts → **必然**触发原「全表非降序」判据。这是正常协作的形态,
205
+ // 不是篡改;让门禁因正常协作报红,只会逼出 `--no-verify`。
206
+ // 仍保留的强判据:**同一 doc 内的 ts 倒挂照报**(该 doc 的确认时序倒挂是真问题),另加哈希链
207
+ // (检查 15)覆盖跨 doc 的插行/改行。跨 doc 乱序仅计数出账、不阻断(可观测性保留)。
208
+ const prevTsByDoc = new Map();
209
+ let crossDocOutOfOrder = 0;
210
+ let prevTsAny = '';
122
211
  rows.forEach((e, idx) => {
123
212
  const at = `第 ${idx + 1} 行`;
124
213
  if (exemptLines.has(idx + 1)) { exemptCount++; return; } // 事故行豁免(登记文件入库可对质)
125
214
  if (!e || typeof e !== 'object') { problems.push(`- [行级] ${at}:非 JSON 对象行`); return; }
126
215
  if (typeof e.ts !== 'string' || Number.isNaN(Date.parse(e.ts))) problems.push(`- [行级] ${at}:ts 非 ISO 字符串`);
127
216
  else {
128
- if (prevTs && e.ts < prevTs) problems.push(`- [行级] ${at}:ts ${e.ts} 早于前行 ${prevTs}(追加时序非单调)`);
129
- prevTs = e.ts;
217
+ const docKey = typeof e.doc === 'string' ? e.doc : '(doc 缺失)';
218
+ const prevSameDoc = prevTsByDoc.get(docKey);
219
+ if (prevSameDoc && e.ts < prevSameDoc) {
220
+ problems.push(`- [行级] ${at}:ts ${e.ts} 早于同 doc 前行 ${prevSameDoc}(${docKey} 追加时序倒挂)`);
221
+ }
222
+ prevTsByDoc.set(docKey, e.ts);
223
+ if (prevTsAny && e.ts < prevTsAny) crossDocOutOfOrder++;
224
+ prevTsAny = e.ts;
130
225
  }
131
226
  const isRevertNote = typeof e.stage === 'string' && e.stage.startsWith('revert-');
132
227
  if (isRevertNote) {
@@ -144,6 +239,9 @@ rows.forEach((e, idx) => {
144
239
  });
145
240
 
146
241
  if (exemptCount) console.log(`ℹ️ 台账豁免行 ${exemptCount} 条(定性见 .agents/ledger-line-exempt.json)`);
242
+ if (crossDocOutOfOrder) {
243
+ console.log(`ℹ️ 台账跨 doc 物理序 ts 乱序 ${crossDocOutOfOrder} 处(并集合并 / 分片归并的正常形态——不阻断;同 doc 内时序倒挂仍 hard 拦,插行/改行由检查 15 哈希链兜)`);
244
+ }
147
245
  if (problems.length) {
148
246
  console.error(`台账不可变(历史全扫)— HARD-BLOCK:\n\n${problems.join('\n')}\n`);
149
247
  console.error(' 修法:台账历史行禁删改(append-only 前缀不变量)——误写只能追加补偿行;确需撤销历史提交请 git revert 后重新追加。篡改属审计事件,保留现场联系仓库所有者。');
@@ -92,7 +92,11 @@ const append = (d, line) => fs.appendFileSync(ledPath(d), line + '\n');
92
92
  append(d, row({ ts: '2026-10-01T00:00:00.000Z', stage: 'done' })); // 早于首行
93
93
  commitAll(d, 'append out-of-order');
94
94
  const r = run(d);
95
- check('S5 ts 非单调 → 行级 exit 1', r.code === 1 && r.out.includes('非单调'), r.out);
95
+ // S5:ts 乱序追加 → 行级拦。措辞随 2026-10-12 口径修正由「非单调」改为「追加时序倒挂」
96
+ // (判据从「全表非降序」收窄为「同 doc 内非降序」,跨 doc 乱序是并集合并的正常形态)。
97
+ // 本例两行同 doc(demo.md),故仍在新口径下 hard 拦。
98
+ check('S5 ts 非单调(同 doc 内倒挂)→ 行级 exit 1',
99
+ r.code === 1 && r.out.includes('追加时序倒挂'), r.out);
96
100
  }
97
101
  // S6:伪指纹(非 64-hex)→ 行级拦
98
102
  {
@@ -24,6 +24,9 @@ const HOOK = [
24
24
  ].find((p) => fs.existsSync(p)) || null;
25
25
  const git = process.platform === 'win32' ? 'git.exe' : 'git';
26
26
  const Z = '0'.repeat(40);
27
+ // 夹具闭包(2026-10-12 fixture-closure-rootfix):本文件此前三处手写依赖枚举(tree 场景最险——漏拷
28
+ // ledger-loader 会让看板漂移门恒绿而非报错)。改为按入口自动求传递闭包,新增依赖自动跟随。
29
+ const { copyScriptClosure, copyAgentsFiles } = await import('./fixture-scripts.mjs');
27
30
 
28
31
  let pass = 0;
29
32
  let fail = 0;
@@ -215,10 +218,9 @@ try {
215
218
  gitRun(tree, ['config', 'user.email', 't@example.com']);
216
219
  gitRun(tree, ['config', 'user.name', 't']);
217
220
  fs.mkdirSync(path.join(tree, '.agents', 'scripts'), { recursive: true });
218
- fs.copyFileSync(ENUMS, path.join(tree, '.agents', 'workflow-enums.txt'));
219
- fs.copyFileSync(path.join(HERE, 'gen-workflow-index.mjs'), path.join(tree, '.agents', 'scripts', 'gen-workflow-index.mjs'));
220
- fs.copyFileSync(path.join(HERE, 'workflow-enums.mjs'), path.join(tree, '.agents', 'scripts', 'workflow-enums.mjs'));
221
- fs.copyFileSync(path.join(HERE, 'rule-budget.sh'), path.join(tree, '.agents', 'scripts', 'rule-budget.sh'));
221
+ copyAgentsFiles(path.join(tree, '.agents'), ['workflow-enums.txt']);
222
+ // 索引生成器 + 预算判定件的整闭包(含 workflow-enums.mjs 与日后新增依赖)
223
+ copyScriptClosure(path.join(tree, '.agents', 'scripts'), ['gen-workflow-index.mjs', 'rule-budget.sh']);
222
224
  fs.writeFileSync(path.join(tree, '.agents', 'rule-budgets.txt'), 'big.txt 10\n');
223
225
  fs.writeFileSync(path.join(tree, 'big.txt'), 'ok\n');
224
226
  for (const sub of ['intents', 'specs', 'plans', 'incidents']) {