agentic-flow-kit 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -102,6 +102,8 @@
102
102
  // .githooks/pre-push 对 remote=refs/heads/main 的推送传入;不占 1-20 编号,gate-checklist 登记表
103
103
  // 按号配对面不变,门体说明见加固门段注释)
104
104
  // 测试:node templates/_agents/scripts/check-loop.test.mjs(fixture 注入 CHECK_LOOP_ROOT)
105
+ // import 契约(2026-10-08 checkloop-importable):import { runCheckLoop, delegationResultRows, versionGreater, fmStatus, bindingSha256 }
106
+ // ——零副作用;runCheckLoop({root?, rev?, hardening?}) 返回 { exitCode, blockers, warnings }(输出打印照旧,静默断言自行重定向 stderr)
105
107
  import fs from 'node:fs';
106
108
  import os from 'node:os';
107
109
  import path from 'node:path';
@@ -140,1197 +142,1279 @@ function takeOpts() {
140
142
  }
141
143
  return opts;
142
144
  }
143
- const { rev: REV_ARG, hardening: HARDENING } = takeOpts();
144
- if (REV_ARG && process.env.CHECK_LOOP_ROOT) {
145
- console.error('check-loop: CHECK_LOOP_ROOT 与 --rev 不能同时使用');
146
- process.exit(1);
147
- }
148
- if (REV_ARG && /^0+$/.test(REV_ARG)) {
149
- console.error(`check-loop: --rev 不是提交 ${REV_ARG}`);
150
- process.exit(1);
151
- }
152
- let REV = null;
153
145
 
154
- // ---- 根目录与枚举单源 ----
155
- let ROOT;
156
- if (process.env.CHECK_LOOP_ROOT) {
157
- ROOT = path.resolve(process.env.CHECK_LOOP_ROOT);
158
- if (!fs.existsSync(ROOT)) {
159
- console.error('check-loop: CHECK_LOOP_ROOT 不可访问:' + process.env.CHECK_LOOP_ROOT);
160
- process.exit(1);
146
+ // ---- runCheckLoop:门禁主流程可 import 化(2026-10-08 checkloop-importable;papercuts 2026-10-04 isMain 行点名单独立项)----
147
+ // import 本模块零副作用(isMain 主守卫,board-kb-p1 先例);opts = { root?, rev?, hardening? }——root 显式参数
148
+ // 优先于 CHECK_LOOP_ROOT env 优先于 git toplevel(CLI 缺省行为逐字节不变);返回 { exitCode, blockers, warnings },
149
+ // 输出打印照旧在本函数内(稳定输出契约:banner 后空行、条目 `- ` 前缀、HARD-BLOCK/WARN 两段式——消费者 .githooks/pre-push)。
150
+ // opts.rev 的 worktree 清理挂 process.on('exit')——进程内多次调用累计监听器(测试场景 ≤3 次,可接受)。
151
+ export function runCheckLoop(opts = {}) {
152
+ const REV_ARG = opts.rev ?? null;
153
+ const HARDENING = !!opts.hardening;
154
+ if (REV_ARG && (opts.root || process.env.CHECK_LOOP_ROOT)) {
155
+ console.error('check-loop: --rev 不能同时使用 root 参数 / CHECK_LOOP_ROOT');
156
+ return { exitCode: 1, blockers: [], warnings: [] };
161
157
  }
162
- } else {
163
- const top = gitOut(['rev-parse', '--show-toplevel']);
164
- if (top === null) {
165
- if (REV_ARG) {
166
- console.error(`check-loop: --rev 剥不到提交 ${REV_ARG}`);
167
- process.exit(1);
168
- }
169
- console.error('check-loop: 不在 git 仓库内,跳过扫描');
170
- process.exit(0);
158
+ if (REV_ARG && /^0+$/.test(REV_ARG)) {
159
+ console.error(`check-loop: --rev 不是提交 ${REV_ARG}`);
160
+ return { exitCode: 1, blockers: [], warnings: [] };
171
161
  }
172
- ROOT = top.trim();
173
- if (REV_ARG) {
174
- const peeled = gitOut(['rev-parse', '--verify', `${REV_ARG}^{commit}`]);
175
- if (!peeled) {
176
- console.error(`check-loop: --rev 剥不到提交 ${REV_ARG}`);
177
- process.exit(1);
162
+ let REV = null;
163
+
164
+ // ---- 根目录与枚举单源 ----
165
+ let ROOT;
166
+ if (opts.root) {
167
+ ROOT = path.resolve(opts.root);
168
+ if (!fs.existsSync(ROOT)) {
169
+ console.error('check-loop: root 不可访问:' + opts.root);
170
+ return { exitCode: 1, blockers: [], warnings: [] };
178
171
  }
179
- REV = peeled.trim();
180
- const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-rev-'));
181
- const wt = path.join(parent, 'wt');
182
- const main = ROOT;
183
- const add = spawnSync(GIT, ['worktree', 'add', '--detach', '--quiet', wt, REV], {
184
- cwd: main, encoding: 'utf8',
185
- });
186
- if (add.status !== 0) {
187
- fs.rmSync(parent, { recursive: true, force: true });
188
- console.error(`check-loop: 导出提交失败 ${REV_ARG}\n${add.stderr || add.stdout || ''}`);
189
- process.exit(1);
172
+ } else if (process.env.CHECK_LOOP_ROOT) {
173
+ ROOT = path.resolve(process.env.CHECK_LOOP_ROOT);
174
+ if (!fs.existsSync(ROOT)) {
175
+ console.error('check-loop: CHECK_LOOP_ROOT 不可访问:' + process.env.CHECK_LOOP_ROOT);
176
+ return { exitCode: 1, blockers: [], warnings: [] };
190
177
  }
191
- process.on('exit', () => {
192
- spawnSync(GIT, ['worktree', 'remove', '--force', wt], { cwd: main });
193
- fs.rmSync(parent, { recursive: true, force: true });
194
- });
195
- ROOT = wt;
196
- }
197
- }
198
- // 枚举单源(<root>/.agents/workflow-enums.txt——fixture 契约:缺文件/缺键 fail-loud)
199
- let ENUMS;
200
- try {
201
- ENUMS = loadEnums(path.join(ROOT, '.agents', 'workflow-enums.txt'));
202
- } catch (e) {
203
- const msg = String(e.message || '');
204
- if (msg.includes('不可读')) {
205
- console.error(`check-loop: 缺 ${path.join('.agents', 'workflow-enums.txt')}(枚举单源)——跑 flow-kit sync 恢复后重试`);
206
178
  } else {
207
- console.error('check-loop: 枚举单源 ' + msg);
208
- }
209
- process.exit(1);
210
- }
211
- const inSet = (v, arr) => arr.includes(v);
212
- const stOkDoc = (st) => inSet(st, ENUMS['doc.status.confirmed']);
213
-
214
- // git 调用(参数数组形式不走 shell——Windows cmd 不认单引号;git.exe 显式解析沿 doctor 先例)
215
- // 基础设施异常与业务失败分流(2026-10-05-gitout-fail-open):spawn 异常响亮出账(进程级去重恰一条,
216
- // 沿检查 16 装户「载入失败响亮出账」先例)+ 瞬时类错误码单次重试;「非 git 仓」(status≠0)仍是
217
- // 合法装户语义、静默走降级。intentional-simple: 重试后仍异常继续走原降级/拦截语义(连续瞬时异常
218
- // 残余面小且有账可查);升级路径 = 探测门(:720/:796)改三态拆分
219
- let gitInfraWarned = false;
220
- function warnGitInfra(args, code) {
221
- if (gitInfraWarned) return;
222
- gitInfraWarned = true;
223
- console.error(`check-loop: git 探测异常 ${args[0]}(${code})——按非 git 仓降级,检查面可能收窄`);
224
- }
225
- const GIT_TRANSIENT = new Set(['EAGAIN', 'EPERM', 'EMFILE', 'EINTR']);
226
- function spawnGit(args) {
227
- const opts = { cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
228
- let r = spawnSync(GIT, args, opts);
229
- if (r.error) {
230
- // 出账在重试终败后(与 linesOf 对称——瞬时重试恢复成功不打扰);非瞬时类直接出账
231
- if (GIT_TRANSIENT.has(r.error.code)) r = spawnSync(GIT, args, opts);
232
- if (r.error) warnGitInfra(args, r.error.code || r.error.message);
233
- }
234
- return r;
235
- }
236
- function gitOut(args, { ok = () => true } = {}) {
237
- const r = spawnGit(args);
238
- if (r.error || r.status !== 0 || !ok(r)) return null;
239
- return r.stdout || '';
240
- }
241
-
242
- // ---- frontmatter 受限子集读取(fm_get 语义:首行 --- 进入、下一 --- 闭合、键在行首、首个命中、值两侧去空白)----
243
- // fs 读取同走 fail-loud(2026-10-05-gitout-fail-open 实现期诊断追加,与 spawnGit 同一故障类):
244
- // readFileSync 瞬时异常(宿主资源压力 EMFILE/共享冲突类)重试一次,仍失败响亮出账(进程级去重)
245
- // 后按 null 降级——调用方按空文档跳过该件,但异常本身不再静默
246
- let fsReadWarned = new Set();
247
- const fileLinesCache = new Map();
248
- function linesOf(file) {
249
- if (!fileLinesCache.has(file)) {
250
- let lines = null;
251
- try {
252
- lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
253
- } catch (e) {
254
- try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } catch { lines = null; }
255
- if (!lines && !fsReadWarned.has(file)) {
256
- fsReadWarned.add(file);
257
- console.error(`check-loop: 文档读取异常 ${path.basename(file)}(${e.code || e.message})——按空文档降级,检查面可能收窄`);
179
+ const top = gitOut(['rev-parse', '--show-toplevel']);
180
+ if (top === null) {
181
+ if (REV_ARG) {
182
+ console.error(`check-loop: --rev 剥不到提交 ${REV_ARG}`);
183
+ return { exitCode: 1, blockers: [], warnings: [] };
258
184
  }
185
+ console.error('check-loop: 不在 git 仓库内,跳过扫描');
186
+ return { exitCode: 0, blockers: [], warnings: [] };
259
187
  }
260
- fileLinesCache.set(file, lines);
261
- }
262
- return fileLinesCache.get(file);
263
- }
264
- function fmGet(file, key) {
265
- const lines = linesOf(file);
266
- if (!lines) return '';
267
- const isDelim = (l) => /^---\s*$/.test(l);
268
- if (!isDelim(lines[0] || '')) return '';
269
- for (let i = 1; i < lines.length; i++) {
270
- if (isDelim(lines[i])) return '';
271
- if (lines[i].startsWith(key + ':')) return lines[i].slice(key.length + 1).trim();
272
- }
273
- return '';
274
- }
275
-
276
- const WF = 'workflow';
277
- const DOC_DIRS = ['intents', 'specs', 'plans', 'incidents'];
278
- const blockers = [];
279
- const warnings = [];
280
- // audit:false(init 新装)只保留 blockers。缺省与 audit:true 保持全量警告。
281
- // 硬规则:配对 / 验收勾验的阻断 / 确认留痕 / 发版草稿。卫生项走 warnings。
282
- const kitPolicy = loadKitPolicy(ROOT);
283
- if (!auditEnabled(kitPolicy)) {
284
- warnings.push = () => warnings.length;
285
- }
286
-
287
- // ---- 已提交(HEAD)过滤:仓库模式只扫 tracked(fixture 模式恒真;ls-tree 失败退化全扫)----
288
- let trackedSet = null; // null = 不过滤
289
- if (!process.env.CHECK_LOOP_ROOT) {
290
- const out = gitOut(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'workflow']);
291
- if (out !== null) trackedSet = new Set(out.split('\n').map((l) => l.trim()).filter(Boolean));
292
- }
293
- const isTracked = (abs) => trackedSet === null || trackedSet.has(path.relative(ROOT, abs).split(path.sep).join('/'));
294
- const docFiles = (sub) => {
295
- const dir = path.join(ROOT, WF, sub);
296
- if (!fs.existsSync(dir)) return [];
297
- return fs.readdirSync(dir)
298
- .filter((f) => f.endsWith('.md') && /^\d/.test(f))
299
- .map((f) => path.join(dir, f))
300
- .filter((f) => isTracked(f))
301
- .sort();
302
- };
303
-
304
- // --- 1. intent/spec/plan 同名配对 + 状态确认 + L3 确认三件 + 风险泳道一致性 [hard-block] ---
305
- for (const intent of docFiles('intents')) {
306
- const base = path.basename(intent);
307
- const st = fmGet(intent, '状态');
308
- const lvl = fmGet(intent, '级别');
309
- const risk = fmGet(intent, 'risk_level'); // 风险泳道字段(fill-intent 落,=级别;2026-09-30 hybrid-governance-risk-lanes)
310
- if (!stOkDoc(st)) {
311
- const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
312
- if (lvl === 'L3') blockers.push(`- [状态未确认] L3 intent 必须为 approved/done/superseded/cancelled:${msg}`);
313
- else warnings.push(`- [WARN 状态未确认] intent 必须为 approved/done/superseded/cancelled:${msg}`);
314
- }
315
- if (lvl === 'L2' || lvl === 'L3') {
316
- if (!fs.existsSync(path.join(ROOT, WF, 'specs', base))) {
317
- blockers.push(`- [配对断裂] intent 缺 spec:${base}(应在 ${WF}/specs/ 下同名)`);
188
+ ROOT = top.trim();
189
+ if (REV_ARG) {
190
+ const peeled = gitOut(['rev-parse', '--verify', `${REV_ARG}^{commit}`]);
191
+ if (!peeled) {
192
+ console.error(`check-loop: --rev 剥不到提交 ${REV_ARG}`);
193
+ return { exitCode: 1, blockers: [], warnings: [] };
194
+ }
195
+ REV = peeled.trim();
196
+ const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-rev-'));
197
+ const wt = path.join(parent, 'wt');
198
+ const main = ROOT;
199
+ const add = spawnSync(GIT, ['worktree', 'add', '--detach', '--quiet', wt, REV], {
200
+ cwd: main, encoding: 'utf8',
201
+ });
202
+ if (add.status !== 0) {
203
+ fs.rmSync(parent, { recursive: true, force: true });
204
+ console.error(`check-loop: 导出提交失败 ${REV_ARG}\n${add.stderr || add.stdout || ''}`);
205
+ return { exitCode: 1, blockers: [], warnings: [] };
206
+ }
207
+ process.on('exit', () => {
208
+ spawnSync(GIT, ['worktree', 'remove', '--force', wt], { cwd: main });
209
+ fs.rmSync(parent, { recursive: true, force: true });
210
+ });
211
+ ROOT = wt;
318
212
  }
319
213
  }
320
- // 协作道(L0)豁免 plan 配对——异步审计兜底(hybrid-governance-risk-lanes);L1 仍须 plan,L2/L3 须 spec+plan
321
- if (lvl !== 'L0' && !fs.existsSync(path.join(ROOT, WF, 'plans', base))) {
322
- blockers.push(`- [配对断裂] intent 缺 plan:${base}(应在 ${WF}/plans/ 下同名)`);
323
- }
324
- // 风险泳道一致性(2026-10-02 caliber-convergence 起单源 laneOfEntry·方案 C 分歧双严):非 high
325
- // (low 或 suspect——级别/risk_level 缺失、非法、分歧)勾选触达红线 = 自认触及规则/契约或数据/运行时
326
- // 结构面——就高不就低,协作道不受理 → hard 判低,须对齐两字段至 L2/L3 并补同名 spec
327
- if (laneOfEntry(lvl, risk, kitPolicy.riskLevelSince, fmGet(intent, '日期')) !== 'high') {
328
- const redlineTouched = (linesOf(intent) || [])
329
- .some((l) => /^- \[x\]/.test(l) && /(\$\\rightarrow\$|→)\s*级别|级别至少\s*L[23]/.test(l));
330
- if (redlineTouched) {
331
- blockers.push(`- [红线判低] ${base} 级别/风险泳道为 ${[lvl, risk].filter(Boolean).join('/')} 但触达红线已勾选(规则/契约或 schema/结构面)——协作道不受理 STOP 级改动:就高不就低,把「级别」与「risk_level」两字段对齐到 L2/L3(fill-intent 双写口径)并补同名 spec`);
214
+ // 枚举单源(<root>/.agents/workflow-enums.txt——fixture 契约:缺文件/缺键 fail-loud)
215
+ let ENUMS;
216
+ try {
217
+ ENUMS = loadEnums(path.join(ROOT, '.agents', 'workflow-enums.txt'));
218
+ } catch (e) {
219
+ const msg = String(e.message || '');
220
+ if (msg.includes('不可读')) {
221
+ console.error(`check-loop: 缺 ${path.join('.agents', 'workflow-enums.txt')}(枚举单源)——跑 flow-kit sync 恢复后重试`);
222
+ } else {
223
+ console.error('check-loop: 枚举单源 ' + msg);
332
224
  }
225
+ return { exitCode: 1, blockers: [], warnings: [] };
333
226
  }
334
- }
227
+ const inSet = (v, arr) => arr.includes(v);
228
+ const stOkDoc = (st) => inSet(st, ENUMS['doc.status.confirmed']);
335
229
 
336
- for (const spec of docFiles('specs')) {
337
- const base = path.basename(spec);
338
- const st = fmGet(spec, '状态');
339
- const lvl = fmGet(spec, '级别');
340
- const hasEntry = fs.existsSync(path.join(ROOT, WF, 'intents', base)) || fs.existsSync(path.join(ROOT, WF, 'incidents', base));
341
- if (!hasEntry) blockers.push(`- [配对断裂] spec 缺 intent/incident:${base}(应在 ${WF}/intents/ 或 ${WF}/incidents/ 下同名)`);
342
- if (!stOkDoc(st)) {
343
- const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
344
- if (lvl === 'L3') blockers.push(`- [状态未确认] L3 spec 必须为 approved/done/superseded/cancelled:${msg}`);
345
- else warnings.push(`- [WARN 状态未确认] spec 必须为 approved/done/superseded/cancelled:${msg}`);
230
+ // git 调用(参数数组形式不走 shell——Windows cmd 不认单引号;git.exe 显式解析沿 doctor 先例)
231
+ // 基础设施异常与业务失败分流(2026-10-05-gitout-fail-open):spawn 异常响亮出账(进程级去重恰一条,
232
+ // 沿检查 16 装户「载入失败响亮出账」先例)+ 瞬时类错误码单次重试;「非 git 仓」(status≠0)仍是
233
+ // 合法装户语义、静默走降级。intentional-simple: 重试后仍异常继续走原降级/拦截语义(连续瞬时异常
234
+ // 残余面小且有账可查);升级路径 = 探测门(:720/:796)改三态拆分
235
+ let gitInfraWarned = false;
236
+ function warnGitInfra(args, code) {
237
+ if (gitInfraWarned) return;
238
+ gitInfraWarned = true;
239
+ console.error(`check-loop: git 探测异常 ${args[0]}(${code})——按非 git 仓降级,检查面可能收窄`);
346
240
  }
347
- // L3 确认三件:确认结果/确认时间读 frontmatter;独立复核按正文行锚定(放弃件豁免 = 枚举单源 abandoned 集)
348
- if (lvl === 'L3' && !inSet(st, ENUMS['doc.status.abandoned'])) {
349
- if (fmGet(spec, '确认结果') !== 'approved') blockers.push(`- [L3 确认缺失] ${base} 确认结果必须为 approved`);
350
- if (!fmGet(spec, '确认时间')) blockers.push(`- [L3 确认缺失] ${base} 必须记录确认时间`);
351
- if (!(linesOf(spec) || []).some((l) => /^- 独立复核:[ \t]*[^<\s]/.test(l))) {
352
- blockers.push(`- [L3 复核缺失] ${base} 必须记录新会话独立复核结论`);
241
+ const GIT_TRANSIENT = new Set(['EAGAIN', 'EPERM', 'EMFILE', 'EINTR']);
242
+ function spawnGit(args) {
243
+ const opts = { cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
244
+ let r = spawnSync(GIT, args, opts);
245
+ if (r.error) {
246
+ // 出账在重试终败后(与 linesOf 对称——瞬时重试恢复成功不打扰);非瞬时类直接出账
247
+ if (GIT_TRANSIENT.has(r.error.code)) r = spawnSync(GIT, args, opts);
248
+ if (r.error) warnGitInfra(args, r.error.code || r.error.message);
353
249
  }
250
+ return r;
354
251
  }
355
- }
356
-
357
- for (const plan of docFiles('plans')) {
358
- const base = path.basename(plan);
359
- const st = fmGet(plan, '状态');
360
- const lvl = fmGet(plan, '级别');
361
- const hasEntry = fs.existsSync(path.join(ROOT, WF, 'intents', base)) || fs.existsSync(path.join(ROOT, WF, 'incidents', base));
362
- if (!hasEntry) blockers.push(`- [配对断裂] plan 缺 intent/incident:${base}(应在 ${WF}/intents/ 或 ${WF}/incidents/ 下同名)`);
363
- if (!stOkDoc(st)) {
364
- const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
365
- if (lvl === 'L3') blockers.push(`- [状态未确认] L3 plan 必须为 approved/done/superseded/cancelled:${msg}`);
366
- else warnings.push(`- [WARN 状态未确认] plan 必须为 approved/done/superseded/cancelled:${msg}`);
252
+ function gitOut(args, { ok = () => true } = {}) {
253
+ const r = spawnGit(args);
254
+ if (r.error || r.status !== 0 || !ok(r)) return null;
255
+ return r.stdout || '';
367
256
  }
368
- }
369
257
 
370
- // --- 2. 模板字段占位符残留 [warning](排除运行时文件名格式与协议样板,口径沿 sh 版)---
371
- {
372
- const phRe = /YYYY-MM-DD|<主题>|<日期 主题>|L0 \/ L1 \/ L2 \/ L3|draft \/ approved \/ done|open \/ fixed \/ closed/;
373
- const boilerRe = /\.\.\/specs\/[A-Za-z0-9-]*\.md|写明如何满足|防复发验证|_YYYY-MM-DD\.|format\('YYYY-MM-DD'\)|value-format="YYYY-MM-DD"|确认结果:approved(YYYY-MM-DD 用户对话内确认);done(YYYY-MM-DD 关单,随入口文档置终态)/;
374
- // plan 确认节样板豁免(2026-10-03 plan-confirm-boiler):plan 模板「确认与复核」节自带
375
- // 「确认结果:approved(YYYY-MM-DD 用户对话内确认);done(YYYY-MM-DD 关单,随入口文档置终态)」
376
- // 样板句,未回填不等于漏填占位(确认由 confirm-doc 机器兜底)——精确豁免整句,真实占位(如
377
- // `日期: YYYY-MM-DD`)照拦。
378
- // 命名约定豁免:`<主题>` 与 `.md` 同行 = 在描述文件命名规则(如 `.agents/workflows/<主题>.md`、
379
- // 「复制本模板为 YYYY-MM-DD-<主题>.md」),非未填占位符;真未填的占位(标题 `# INTENT — <主题>`、
380
- // `日期: YYYY-MM-DD`)不含 .md,仍照拦(2026-09-25-wf-runtime incident 记录的误报口径)
381
- const isNamingConv = (line) => line.includes('<主题>') && /\.md/.test(line);
382
- // 样例引用豁免(2026-10-02 caliber-convergence 假阳性消除,先例=检查 18 行尾归一):命中位于行内代码
383
- // (反引号包裹)或围栏代码块内 = 引用样例而非未填占位符,不报;正文裸占位符照报——真占位符均为
384
- // 裸文本(模板正文无反引号包裹),检测面不缩。
385
- // 日期显示格式豁免(2026-10-06 check2-datetime-literal-exempt):`YYYY-MM-DD HH:mm(:ss)` 是功能
386
- // 自实现的显示格式描述(文档叙述「匹配时间列显示为 YYYY-MM-DD HH:mm」属常态——cancel-export
387
- // intent/plan 实证 2 条 advisory、2026-10-06 loop-audit intent 现场复现),非未填占位——行内剔除
388
- // 后再判;真占位(`日期: YYYY-MM-DD`,无时间后缀)不含该形态仍拦。只豁免实证形态,变体(斜杠日期
389
- // /十二小时制等)出现假阳性再扩。
390
- const stripSamples = (lines) => {
391
- let inFence = false;
392
- return lines.map((line) => {
393
- if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return ''; }
394
- if (inFence) return '';
395
- return line.replace(/`[^`]*`/g, '').replace(/YYYY-MM-DD\s+HH:mm(:ss)?/g, '');
396
- });
397
- };
398
- const groups = new Map(); // file -> [ "行号:内容" ](首现序)
399
- for (const sub of DOC_DIRS) {
400
- for (const f of docFiles(sub)) {
401
- const lines = linesOf(f) || [];
402
- const hits = [];
403
- stripSamples(lines).forEach((line, i) => {
404
- if (phRe.test(line) && !boilerRe.test(line) && !isNamingConv(line)) hits.push(`${i + 1}:${lines[i]}`);
405
- });
406
- if (hits.length) groups.set(f, hits);
258
+ // ---- frontmatter 受限子集读取(fm_get 语义:首行 --- 进入、下一 --- 闭合、键在行首、首个命中、值两侧去空白)----
259
+ // fs 读取同走 fail-loud(2026-10-05-gitout-fail-open 实现期诊断追加,与 spawnGit 同一故障类):
260
+ // readFileSync 瞬时异常(宿主资源压力 EMFILE/共享冲突类)重试一次,仍失败响亮出账(进程级去重)
261
+ // 后按 null 降级——调用方按空文档跳过该件,但异常本身不再静默
262
+ let fsReadWarned = new Set();
263
+ const fileLinesCache = new Map();
264
+ function linesOf(file) {
265
+ if (!fileLinesCache.has(file)) {
266
+ let lines = null;
267
+ try {
268
+ lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
269
+ } catch (e) {
270
+ try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } catch { lines = null; }
271
+ if (!lines && !fsReadWarned.has(file)) {
272
+ fsReadWarned.add(file);
273
+ console.error(`check-loop: 文档读取异常 ${path.basename(file)}(${e.code || e.message})——按空文档降级,检查面可能收窄`);
274
+ }
275
+ }
276
+ fileLinesCache.set(file, lines);
407
277
  }
278
+ return fileLinesCache.get(file);
408
279
  }
409
- for (const [f, hits] of groups) {
410
- warnings.push(`- [WARN 模板未填] ${path.relative(ROOT, f).split(path.sep).join('/')} 含模板占位符:\n${hits.join('\n')}`);
280
+ function fmGet(file, key) {
281
+ const lines = linesOf(file);
282
+ if (!lines) return '';
283
+ const isDelim = (l) => /^---\s*$/.test(l);
284
+ if (!isDelim(lines[0] || '')) return '';
285
+ for (let i = 1; i < lines.length; i++) {
286
+ if (isDelim(lines[i])) return '';
287
+ if (lines[i].startsWith(key + ':')) return lines[i].slice(key.length + 1).trim();
288
+ }
289
+ return '';
411
290
  }
412
- }
413
291
 
414
- // --- 3. incidents 复盘三件套 + 状态严格枚举 + 新 intent 回路(回路断档 = hard)---
415
- for (const inc of docFiles('incidents')) {
416
- const name = path.basename(inc);
417
- const lines = linesOf(inc) || [];
418
- const incLvl = fmGet(inc, '级别');
419
- const incSt = fmGet(inc, '状态');
292
+ const WF = 'workflow';
293
+ const DOC_DIRS = ['intents', 'specs', 'plans', 'incidents'];
294
+ const blockers = [];
295
+ const warnings = [];
296
+ // audit:false(init 新装)只保留 blockers。缺省与 audit:true 保持全量警告。
297
+ // 硬规则:配对 / 验收勾验的阻断 / 确认留痕 / 发版草稿。卫生项走 warnings。
298
+ const kitPolicy = loadKitPolicy(ROOT);
299
+ if (!auditEnabled(kitPolicy)) {
300
+ warnings.push = () => warnings.length;
301
+ }
420
302
 
421
- if (!inSet(incSt, ENUMS['incident.status.all'])) {
422
- warnings.push(`- [WARN 状态非法] incident 状态必须为 open/fixed/closed:${name}(frontmatter 状态键当前值:『${incSt || '缺失'}』)`);
303
+ // ---- 已提交(HEAD)过滤:仓库模式只扫 tracked(fixture 模式恒真;ls-tree 失败退化全扫)----
304
+ let trackedSet = null; // null = 不过滤
305
+ if (!process.env.CHECK_LOOP_ROOT) {
306
+ const out = gitOut(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'workflow']);
307
+ if (out !== null) trackedSet = new Set(out.split('\n').map((l) => l.trim()).filter(Boolean));
423
308
  }
309
+ const isTracked = (abs) => trackedSet === null || trackedSet.has(path.relative(ROOT, abs).split(path.sep).join('/'));
310
+ const docFiles = (sub) => {
311
+ const dir = path.join(ROOT, WF, sub);
312
+ if (!fs.existsSync(dir)) return [];
313
+ return fs.readdirSync(dir)
314
+ .filter((f) => f.endsWith('.md') && /^\d/.test(f))
315
+ .map((f) => path.join(dir, f))
316
+ .filter((f) => isTracked(f))
317
+ .sort();
318
+ };
424
319
 
425
- if (fmGet(inc, '流程') !== 'legacy') {
426
- if (!inSet(incLvl, ENUMS['level.all'])) {
427
- warnings.push(`- [WARN 级别缺失] ${name} 必须填写 L0/L1/L2/L3(配对检查已跳过——补级别后重跑本脚本校验配对)`);
428
- } else {
429
- if (['L1', 'L2', 'L3'].includes(incLvl) && !fs.existsSync(path.join(ROOT, WF, 'plans', name))) {
430
- blockers.push(`- [配对断裂] incident 缺 plan:${name}(应在 ${WF}/plans/ 下同名)`);
320
+ // --- 1. intent/spec/plan 同名配对 + 状态确认 + L3 确认三件 + 风险泳道一致性 [hard-block] ---
321
+ for (const intent of docFiles('intents')) {
322
+ const base = path.basename(intent);
323
+ const st = fmGet(intent, '状态');
324
+ const lvl = fmGet(intent, '级别');
325
+ const risk = fmGet(intent, 'risk_level'); // 风险泳道字段(fill-intent 落,=级别;2026-09-30 hybrid-governance-risk-lanes)
326
+ if (!stOkDoc(st)) {
327
+ const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
328
+ if (lvl === 'L3') blockers.push(`- [状态未确认] L3 intent 必须为 approved/done/superseded/cancelled:${msg}`);
329
+ else warnings.push(`- [WARN 状态未确认] intent 必须为 approved/done/superseded/cancelled:${msg}`);
330
+ }
331
+ if (lvl === 'L2' || lvl === 'L3') {
332
+ if (!fs.existsSync(path.join(ROOT, WF, 'specs', base))) {
333
+ blockers.push(`- [配对断裂] intent 缺 spec:${base}(应在 ${WF}/specs/ 下同名)`);
431
334
  }
432
- if (['L2', 'L3'].includes(incLvl) && !fs.existsSync(path.join(ROOT, WF, 'specs', name))) {
433
- blockers.push(`- [配对断裂] incident 缺 spec:${name}(应在 ${WF}/specs/ 下同名)`);
335
+ }
336
+ // 协作道(L0)豁免 plan 配对——异步审计兜底(hybrid-governance-risk-lanes);L1 仍须 plan,L2/L3 须 spec+plan
337
+ if (lvl !== 'L0' && !fs.existsSync(path.join(ROOT, WF, 'plans', base))) {
338
+ blockers.push(`- [配对断裂] intent 缺 plan:${base}(应在 ${WF}/plans/ 下同名)`);
339
+ }
340
+ // 风险泳道一致性(2026-10-02 caliber-convergence 起单源 laneOfEntry·方案 C 分歧双严):非 high
341
+ // (low 或 suspect——级别/risk_level 缺失、非法、分歧)勾选触达红线 = 自认触及规则/契约或数据/运行时
342
+ // 结构面——就高不就低,协作道不受理 → hard 判低,须对齐两字段至 L2/L3 并补同名 spec
343
+ if (laneOfEntry(lvl, risk, kitPolicy.riskLevelSince, fmGet(intent, '日期')) !== 'high') {
344
+ const redlineTouched = (linesOf(intent) || [])
345
+ .some((l) => /^- \[x\]/.test(l) && /(\$\\rightarrow\$|→)\s*级别|级别至少\s*L[23]/.test(l));
346
+ if (redlineTouched) {
347
+ blockers.push(`- [红线判低] ${base} 级别/风险泳道为 ${[lvl, risk].filter(Boolean).join('/')} 但触达红线已勾选(规则/契约或 schema/结构面)——协作道不受理 STOP 级改动:就高不就低,把「级别」与「risk_level」两字段对齐到 L2/L3(fill-intent 双写口径)并补同名 spec`);
434
348
  }
435
349
  }
436
350
  }
437
351
 
438
- // 三件套:正文行锚定 /^1\. /^2\. /^3\. (sh awk 同款)
439
- for (const n of [1, 2, 3]) {
440
- if (!lines.some((l) => l.startsWith(`${n}. `))) {
441
- warnings.push(`- [WARN 三件套不全] ${name} 缺复盘三件套之 ${n}`);
352
+ for (const spec of docFiles('specs')) {
353
+ const base = path.basename(spec);
354
+ const st = fmGet(spec, '状态');
355
+ const lvl = fmGet(spec, '级别');
356
+ const hasEntry = fs.existsSync(path.join(ROOT, WF, 'intents', base)) || fs.existsSync(path.join(ROOT, WF, 'incidents', base));
357
+ if (!hasEntry) blockers.push(`- [配对断裂] spec 缺 intent/incident:${base}(应在 ${WF}/intents/ 或 ${WF}/incidents/ 下同名)`);
358
+ if (!stOkDoc(st)) {
359
+ const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
360
+ if (lvl === 'L3') blockers.push(`- [状态未确认] L3 spec 必须为 approved/done/superseded/cancelled:${msg}`);
361
+ else warnings.push(`- [WARN 状态未确认] spec 必须为 approved/done/superseded/cancelled:${msg}`);
362
+ }
363
+ // L3 确认三件:确认结果/确认时间读 frontmatter;独立复核按正文行锚定(放弃件豁免 = 枚举单源 abandoned 集)
364
+ if (lvl === 'L3' && !inSet(st, ENUMS['doc.status.abandoned'])) {
365
+ if (fmGet(spec, '确认结果') !== 'approved') blockers.push(`- [L3 确认缺失] ${base} 确认结果必须为 approved`);
366
+ if (!fmGet(spec, '确认时间')) blockers.push(`- [L3 确认缺失] ${base} 必须记录确认时间`);
367
+ if (!(linesOf(spec) || []).some((l) => /^- 独立复核:[ \t]*[^<\s]/.test(l))) {
368
+ blockers.push(`- [L3 复核缺失] ${base} 必须记录新会话独立复核结论`);
369
+ }
442
370
  }
443
371
  }
444
- const hasParent = lines.some((l) => l.includes('是否需要新 intent'));
445
- if (!hasParent) {
446
- warnings.push(`- [WARN 三件套不全] ${name} 缺「是否需要新 intent」子项`);
447
- continue;
448
- }
449
- const yesLine = lines.find((l) => l.includes('是 → '));
450
- if (yesLine) {
451
- const m = yesLine.match(/intents\/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[^ )]*\.md/);
452
- const ref = m ? m[0] : null;
453
- if (!ref) blockers.push(`- [回路断档] ${name} 选「是」但未指明 intent 文件路径`);
454
- else if (!fs.existsSync(path.join(ROOT, WF, ref))) blockers.push(`- [回路断档] ${name} 引用的 intent 不存在:${ref}`);
455
- }
456
- }
457
372
 
458
- // --- 4. 引用有效性 [warning](.agents/ 路径须存在;skills 特例允许用户级 ~/.agents/skills)---
459
- {
460
- // 引用可带 {a,b,c} 花括号展开(仓库通写法 `fill-{intent,spec,plan}.mjs`)或 `*` 通配(`fill-*.mjs`)——
461
- // 花括号展开逐路查存在,任一路存在即有效;`*` 按目录枚举 glob 匹配,命中即有效;全不中才算断档
462
- // (2026-09-26 审查修复:正则字符类不含 {} *,曾把 `fill-{intent,spec,plan}.mjs` / `fill-*.mjs`
463
- // 截断成 `fill-` 误报引用断档)
464
- const refRe = /\.agents\/(commands|hooks|scripts|skills|roles)\/[A-Za-z0-9_][A-Za-z0-9_./{},,*-]*/g;
465
- const expandRef = (ref) => {
466
- const m = ref.match(/^([^{]*)\{([^}]*)\}(.*)$/);
467
- if (!m) return [ref];
468
- return m[2].split(/[,,]/).map((v) => m[1] + v.trim() + m[3]);
469
- };
470
- // globExists:`*` 通配引用按目录枚举匹配(仅文件名层,`*` 不跨 /;目录不存在 = false)
471
- const globExists = (ref) => {
472
- const re = new RegExp('^' + path.basename(ref).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/\\\\]*') + '$');
473
- try { return fs.readdirSync(path.dirname(path.join(ROOT, ref))).some((f) => re.test(f)); } catch { return false; }
474
- };
475
- const refExists = (r) => (r.includes('*') ? globExists(r) : fs.existsSync(path.join(ROOT, r)));
476
- const HOME_DIR = process.env.HOME || process.env.USERPROFILE || '';
477
- const files = [];
478
- if (fs.existsSync(path.join(ROOT, 'AGENTS.md'))) files.push('AGENTS.md');
479
- for (const e of readdirOrNull(ROOT) || []) {
480
- const p = path.join(ROOT, e, 'AGENTS.md');
481
- if (isDirectoryOrNull(path.join(ROOT, e)) && fs.existsSync(p)) files.push(path.join(e, 'AGENTS.md'));
482
- }
483
- for (const f of readdirOrNull(path.join(ROOT, WF)) || []) {
484
- if (f.endsWith('.md')) files.push(`${WF}/${f}`); // workflow 根级 *.md(README 等)
485
- }
486
- for (const sub of DOC_DIRS) {
487
- const dir = path.join(ROOT, WF, sub);
488
- // 仅扫活跃态(draft/approved/open)——终态件的引用是历史叙述(提及已改名/已废文件属常态),
489
- // 扫了只会产生不可消除的 advisory 噪声,真漏点被淹没(2026-09-27 audit-gate-hardening)
490
- const activeSet = sub === 'incidents' ? ENUMS['incident.status.active'] : ENUMS['doc.status.active'];
491
- for (const f of readdirOrNull(dir) || []) {
492
- if (!f.endsWith('.md') || !(f === '_TEMPLATE.md' || /^\d/.test(f))) continue;
493
- if (f !== '_TEMPLATE.md' && !inSet(fmGet(path.join(dir, f), '状态'), activeSet)) continue;
494
- files.push(`${WF}/${sub}/${f}`);
373
+ for (const plan of docFiles('plans')) {
374
+ const base = path.basename(plan);
375
+ const st = fmGet(plan, '状态');
376
+ const lvl = fmGet(plan, '级别');
377
+ const hasEntry = fs.existsSync(path.join(ROOT, WF, 'intents', base)) || fs.existsSync(path.join(ROOT, WF, 'incidents', base));
378
+ if (!hasEntry) blockers.push(`- [配对断裂] plan 缺 intent/incident:${base}(应在 ${WF}/intents/ 或 ${WF}/incidents/ 下同名)`);
379
+ if (!stOkDoc(st)) {
380
+ const msg = `${base}(frontmatter 状态键当前值:『${st || '缺失'}』)`;
381
+ if (lvl === 'L3') blockers.push(`- [状态未确认] L3 plan 必须为 approved/done/superseded/cancelled:${msg}`);
382
+ else warnings.push(`- [WARN 状态未确认] plan 必须为 approved/done/superseded/cancelled:${msg}`);
495
383
  }
496
384
  }
497
- const cmdDir = path.join(ROOT, '.agents', 'commands');
498
- for (const f of readdirOrNull(cmdDir) || []) {
499
- if (f.endsWith('.md')) files.push(`.agents/commands/${f}`);
500
- }
501
- for (const rel of files) {
502
- const text = linesOf(path.join(ROOT, rel));
503
- if (!text) continue;
504
- const seen = new Set();
505
- for (const line of text) {
506
- for (const m of line.matchAll(refRe)) {
507
- const ref = m[0];
508
- if (seen.has(ref)) continue;
509
- seen.add(ref);
510
- const refs = expandRef(ref);
511
- if (refs.some(refExists)) continue;
512
- if (refs.some((r) => r.startsWith('.agents/skills/') && !r.includes('*') && fs.existsSync(path.join(HOME_DIR, r)))) continue;
513
- warnings.push(`- [WARN 引用断档] ${rel.split(path.sep).join('/')} 引用不存在的文件:${ref}`);
385
+
386
+ // --- 2. 模板字段占位符残留 [warning](排除运行时文件名格式与协议样板,口径沿 sh 版)---
387
+ {
388
+ const phRe = /YYYY-MM-DD|<主题>|<日期 主题>|L0 \/ L1 \/ L2 \/ L3|draft \/ approved \/ done|open \/ fixed \/ closed/;
389
+ const boilerRe = /\.\.\/specs\/[A-Za-z0-9-]*\.md|写明如何满足|防复发验证|_YYYY-MM-DD\.|format\('YYYY-MM-DD'\)|value-format="YYYY-MM-DD"|确认结果:approved(YYYY-MM-DD 用户对话内确认);done(YYYY-MM-DD 关单,随入口文档置终态)/;
390
+ // plan 确认节样板豁免(2026-10-03 plan-confirm-boiler):plan 模板「确认与复核」节自带
391
+ // 「确认结果:approved(YYYY-MM-DD 用户对话内确认);done(YYYY-MM-DD 关单,随入口文档置终态)」
392
+ // 样板句,未回填不等于漏填占位(确认由 confirm-doc 机器兜底)——精确豁免整句,真实占位(如
393
+ // `日期: YYYY-MM-DD`)照拦。
394
+ // 命名约定豁免:`<主题>` 与 `.md` 同行 = 在描述文件命名规则(如 `.agents/workflows/<主题>.md`、
395
+ // 「复制本模板为 YYYY-MM-DD-<主题>.md」),非未填占位符;真未填的占位(标题 `# INTENT — <主题>`、
396
+ // `日期: YYYY-MM-DD`)不含 .md,仍照拦(2026-09-25-wf-runtime incident 记录的误报口径)
397
+ const isNamingConv = (line) => line.includes('<主题>') && /\.md/.test(line);
398
+ // 样例引用豁免(2026-10-02 caliber-convergence 假阳性消除,先例=检查 18 行尾归一):命中位于行内代码
399
+ // (反引号包裹)或围栏代码块内 = 引用样例而非未填占位符,不报;正文裸占位符照报——真占位符均为
400
+ // 裸文本(模板正文无反引号包裹),检测面不缩。
401
+ // 日期显示格式豁免(2026-10-06 check2-datetime-literal-exempt):`YYYY-MM-DD HH:mm(:ss)` 是功能
402
+ // 自实现的显示格式描述(文档叙述「匹配时间列显示为 YYYY-MM-DD HH:mm」属常态——cancel-export
403
+ // intent/plan 实证 2 条 advisory、2026-10-06 loop-audit intent 现场复现),非未填占位——行内剔除
404
+ // 后再判;真占位(`日期: YYYY-MM-DD`,无时间后缀)不含该形态仍拦。只豁免实证形态,变体(斜杠日期
405
+ // /十二小时制等)出现假阳性再扩。
406
+ const stripSamples = (lines) => {
407
+ let inFence = false;
408
+ return lines.map((line) => {
409
+ if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; return ''; }
410
+ if (inFence) return '';
411
+ return line.replace(/`[^`]*`/g, '').replace(/YYYY-MM-DD\s+HH:mm(:ss)?/g, '');
412
+ });
413
+ };
414
+ const groups = new Map(); // file -> [ "行号:内容" ](首现序)
415
+ for (const sub of DOC_DIRS) {
416
+ for (const f of docFiles(sub)) {
417
+ const lines = linesOf(f) || [];
418
+ const hits = [];
419
+ stripSamples(lines).forEach((line, i) => {
420
+ if (phRe.test(line) && !boilerRe.test(line) && !isNamingConv(line)) hits.push(`${i + 1}:${lines[i]}`);
421
+ });
422
+ if (hits.length) groups.set(f, hits);
514
423
  }
515
424
  }
425
+ for (const [f, hits] of groups) {
426
+ warnings.push(`- [WARN 模板未填] ${path.relative(ROOT, f).split(path.sep).join('/')} 含模板占位符:\n${hits.join('\n')}`);
427
+ }
516
428
  }
517
- }
518
429
 
519
- // --- 5. 子智能体角色契约 + 宿主 Adapter 一致性 + 旧委派残留 + 钉死模型 [warning] ---
520
- {
521
- const cmdFiles = () => (readdirOrNull(path.join(ROOT, '.agents', 'commands')) || [])
522
- .filter((f) => f.endsWith('.md')).map((f) => `.agents/commands/${f}`);
523
- // existsSync 守卫(2026-10-06-check19-entry-enoent 同类):AGENTS.md 等可合法缺失(调用方按空数组
524
- // 判定本就预期缺失态),fail-loud fs 读下按缺省跳过、不再响亮出账;其余调用方路径另有上游守卫
525
- const textOf = (rel) => {
526
- const p = path.join(ROOT, rel);
527
- return fs.existsSync(p) ? (linesOf(p) || []) : [];
528
- };
529
- for (const role of ['implementer', 'independent-reviewer', 'ui-verifier']) {
530
- const rolePath = `.agents/roles/${role}.md`;
531
- if (!fs.existsSync(path.join(ROOT, rolePath))) {
532
- warnings.push(`- [WARN 角色缺失] 公共角色契约不存在:${rolePath}`);
533
- continue;
430
+ // --- 3. incidents 复盘三件套 + 状态严格枚举 + 新 intent 回路(回路断档 = hard)---
431
+ for (const inc of docFiles('incidents')) {
432
+ const name = path.basename(inc);
433
+ const lines = linesOf(inc) || [];
434
+ const incLvl = fmGet(inc, '级别');
435
+ const incSt = fmGet(inc, '状态');
436
+
437
+ if (!inSet(incSt, ENUMS['incident.status.all'])) {
438
+ warnings.push(`- [WARN 状态非法] incident 状态必须为 open/fixed/closed:${name}(frontmatter 状态键当前值:『${incSt || '缺失'}』)`);
534
439
  }
535
- if (fs.existsSync(path.join(ROOT, '.opencode', 'agents'))) {
536
- const p = `.opencode/agents/${role}.md`;
537
- if (!fs.existsSync(path.join(ROOT, p))) warnings.push(`- [WARN Adapter 缺失] OpenCode 缺 ${role}:${p}`);
538
- else {
539
- if (!/^mode:\s*subagent\s*$/m.test(textOf(p).join('\n'))) warnings.push(`- [WARN Adapter 无效] OpenCode ${role} 未声明 mode:subagent:${p}`);
540
- if (!textOf(p).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] OpenCode ${role} 未引用公共角色:${rolePath}`);
440
+
441
+ if (fmGet(inc, '流程') !== 'legacy') {
442
+ if (!inSet(incLvl, ENUMS['level.all'])) {
443
+ warnings.push(`- [WARN 级别缺失] ${name} 必须填写 L0/L1/L2/L3(配对检查已跳过——补级别后重跑本脚本校验配对)`);
444
+ } else {
445
+ if (['L1', 'L2', 'L3'].includes(incLvl) && !fs.existsSync(path.join(ROOT, WF, 'plans', name))) {
446
+ blockers.push(`- [配对断裂] incident 缺 plan:${name}(应在 ${WF}/plans/ 下同名)`);
447
+ }
448
+ if (['L2', 'L3'].includes(incLvl) && !fs.existsSync(path.join(ROOT, WF, 'specs', name))) {
449
+ blockers.push(`- [配对断裂] incident 缺 spec:${name}(应在 ${WF}/specs/ 下同名)`);
450
+ }
541
451
  }
542
452
  }
543
- if (fs.existsSync(path.join(ROOT, '.trae', 'agents'))) {
544
- const p = `.trae/agents/${role}.md`;
545
- if (!fs.existsSync(path.join(ROOT, p))) warnings.push(`- [WARN Adapter 缺失] Trae 缺 ${role}:${p}`);
546
- else {
547
- if (!new RegExp(`^name:\\s*${role}\\s*$`, 'm').test(textOf(p).join('\n'))) warnings.push(`- [WARN Adapter 无效] Trae ${role} 的 name 与文件名不一致:${p}`);
548
- if (!textOf(p).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] Trae ${role} 未引用公共角色:${rolePath}`);
453
+
454
+ // 三件套:正文行锚定 /^1\. /^2\. /^3\. (sh awk 同款)
455
+ for (const n of [1, 2, 3]) {
456
+ if (!lines.some((l) => l.startsWith(`${n}. `))) {
457
+ warnings.push(`- [WARN 三件套不全] ${name} 缺复盘三件套之 ${n}`);
549
458
  }
550
459
  }
551
- const zc = `.zcode/agents/${role}.md`;
552
- if (fs.existsSync(path.join(ROOT, zc))) { // .zcode/agents/ 现为入库件(2026-10-05 起宿主一律入库);缺失只说明宿主未装,不告警,存在时校验一致性
553
- if (!new RegExp(`^name:\\s*["']?${role}["']?\\s*$`, 'm').test(textOf(zc).join('\n'))) warnings.push(`- [WARN Adapter 无效] ZCode ${role} 的 name 与文件名不一致:${zc}`);
554
- if (!textOf(zc).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] ZCode ${role} 未引用公共角色:${rolePath}`);
460
+ const hasParent = lines.some((l) => l.includes('是否需要新 intent'));
461
+ if (!hasParent) {
462
+ warnings.push(`- [WARN 三件套不全] ${name} 缺「是否需要新 intent」子项`);
463
+ continue;
555
464
  }
556
- const cited = ['AGENTS.md', ...cmdFiles()].some((f) => textOf(f).some((l) => l.includes(rolePath)));
557
- if (!cited) warnings.push(`- [WARN 命令断线] 工作流未引用公共角色:${rolePath}`);
558
- }
559
- // 旧委派残留 / 钉死模型(grep -nH 行格式 file:line:content)
560
- const legacyRe = /(^subagent:|subagent_type=|general_purpose_task|browser_use)/;
561
- const legacyHits = [];
562
- for (const f of ['AGENTS.md', ...cmdFiles()]) {
563
- textOf(f).forEach((l, i) => { if (legacyRe.test(l)) legacyHits.push(`${f}:${i + 1}:${l}`); });
564
- }
565
- if (legacyHits.length) warnings.push(`- [WARN 旧委派残留] 主规则或阶段命令仍含宿主特定/旧委派声明:\n${legacyHits.join('\n')}`);
566
- const modelHits = [];
567
- for (const h of ['.opencode', '.trae', '.zcode']) {
568
- for (const f of readdirOrNull(path.join(ROOT, h, 'agents')) || []) {
569
- if (!f.endsWith('.md')) continue;
570
- const rel = `${h}/agents/${f}`;
571
- textOf(rel).forEach((l, i) => { if (/^model:/.test(l) && !/^model:\s*inherit/.test(l)) modelHits.push(`${rel}:${i + 1}:${l}`); });
465
+ const yesLine = lines.find((l) => l.includes('是 → '));
466
+ if (yesLine) {
467
+ const m = yesLine.match(/intents\/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-[^ )]*\.md/);
468
+ const ref = m ? m[0] : null;
469
+ if (!ref) blockers.push(`- [回路断档] ${name} 选「是」但未指明 intent 文件路径`);
470
+ else if (!fs.existsSync(path.join(ROOT, WF, ref))) blockers.push(`- [回路断档] ${name} 引用的 intent 不存在:${ref}`);
572
471
  }
573
472
  }
574
- if (modelHits.length) warnings.push(`- [WARN 宿主耦合] 薄 Adapter 不应固定模型:\n${modelHits.join('\n')}`);
575
- }
576
473
 
577
- // --- 6. 阶段索引同步 [warning] ---
578
- for (const cmd of ['plan', 'design', 'build', 'test', 'deploy', 'maintain', 'review']) {
579
- for (const doc of ['AGENTS.md', '.agents/commands/new-task.md']) {
580
- // existsSync 守卫(2026-10-06-check19-entry-enoent 同类):两文件可合法缺失(下方 text 判空本就
581
- // 预期缺失态),fail-loud fs 读下按空跳过、不再响亮出账
582
- const docPath = path.join(ROOT, doc);
583
- const text = fs.existsSync(docPath) ? linesOf(docPath) : null;
584
- if (text && !text.some((l) => l.includes(`.agents/commands/${cmd}.md`))) {
585
- warnings.push(`- [WARN 阶段索引漂移] ${doc} 缺 ${cmd} 指令索引(两处阶段表须同步维护)`);
474
+ // --- 4. 引用有效性 [warning](.agents/ 路径须存在;skills 特例允许用户级 ~/.agents/skills)---
475
+ {
476
+ // 引用可带 {a,b,c} 花括号展开(仓库通写法 `fill-{intent,spec,plan}.mjs`)或 `*` 通配(`fill-*.mjs`)——
477
+ // 花括号展开逐路查存在,任一路存在即有效;`*` 按目录枚举 glob 匹配,命中即有效;全不中才算断档
478
+ // (2026-09-26 审查修复:正则字符类不含 {} *,曾把 `fill-{intent,spec,plan}.mjs` / `fill-*.mjs`
479
+ // 截断成 `fill-` 误报引用断档)
480
+ // 全角逗号不入字符类(2026-10-08 papercuts-cleanup-batch 修 4):中文文档路径引用后紧跟「,」时
481
+ // 曾被并入引用串 → 引用断档误报(p0-gate-noise-batch 实证 2 条 + 本登记行自中);花括号保留({{VAR}} 引用形态)
482
+ const refRe = /\.agents\/(commands|hooks|scripts|skills|roles)\/[A-Za-z0-9_][A-Za-z0-9_./{},*-]*/g;
483
+ const expandRef = (ref) => {
484
+ const m = ref.match(/^([^{]*)\{([^}]*)\}(.*)$/);
485
+ if (!m) return [ref];
486
+ return m[2].split(/[,,]/).map((v) => m[1] + v.trim() + m[3]);
487
+ };
488
+ // globExists:`*` 通配引用按目录枚举匹配(仅文件名层,`*` 不跨 /;目录不存在 = false)
489
+ const globExists = (ref) => {
490
+ const re = new RegExp('^' + path.basename(ref).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/\\\\]*') + '$');
491
+ try { return fs.readdirSync(path.dirname(path.join(ROOT, ref))).some((f) => re.test(f)); } catch { return false; }
492
+ };
493
+ const refExists = (r) => (r.includes('*') ? globExists(r) : fs.existsSync(path.join(ROOT, r)));
494
+ const HOME_DIR = process.env.HOME || process.env.USERPROFILE || '';
495
+ const files = [];
496
+ if (fs.existsSync(path.join(ROOT, 'AGENTS.md'))) files.push('AGENTS.md');
497
+ for (const e of readdirOrNull(ROOT) || []) {
498
+ const p = path.join(ROOT, e, 'AGENTS.md');
499
+ if (isDirectoryOrNull(path.join(ROOT, e)) && fs.existsSync(p)) files.push(path.join(e, 'AGENTS.md'));
500
+ }
501
+ for (const f of readdirOrNull(path.join(ROOT, WF)) || []) {
502
+ if (f.endsWith('.md')) files.push(`${WF}/${f}`); // workflow 根级 *.md(README 等)
503
+ }
504
+ for (const sub of DOC_DIRS) {
505
+ const dir = path.join(ROOT, WF, sub);
506
+ // 仅扫活跃态(draft/approved/open)——终态件的引用是历史叙述(提及已改名/已废文件属常态),
507
+ // 扫了只会产生不可消除的 advisory 噪声,真漏点被淹没(2026-09-27 audit-gate-hardening)
508
+ const activeSet = sub === 'incidents' ? ENUMS['incident.status.active'] : ENUMS['doc.status.active'];
509
+ for (const f of readdirOrNull(dir) || []) {
510
+ if (!f.endsWith('.md') || !(f === '_TEMPLATE.md' || /^\d/.test(f))) continue;
511
+ if (f !== '_TEMPLATE.md' && !inSet(fmGet(path.join(dir, f), '状态'), activeSet)) continue;
512
+ files.push(`${WF}/${sub}/${f}`);
513
+ }
514
+ }
515
+ const cmdDir = path.join(ROOT, '.agents', 'commands');
516
+ for (const f of readdirOrNull(cmdDir) || []) {
517
+ if (f.endsWith('.md')) files.push(`.agents/commands/${f}`);
518
+ }
519
+ for (const rel of files) {
520
+ const text = linesOf(path.join(ROOT, rel));
521
+ if (!text) continue;
522
+ const seen = new Set();
523
+ for (const line of text) {
524
+ for (const m of line.matchAll(refRe)) {
525
+ const ref = m[0];
526
+ if (seen.has(ref)) continue;
527
+ seen.add(ref);
528
+ const refs = expandRef(ref);
529
+ if (refs.some(refExists)) continue;
530
+ if (refs.some((r) => r.startsWith('.agents/skills/') && !r.includes('*') && fs.existsSync(path.join(HOME_DIR, r)))) continue;
531
+ warnings.push(`- [WARN 引用断档] ${rel.split(path.sep).join('/')} 引用不存在的文件:${ref}`);
532
+ }
533
+ }
586
534
  }
587
535
  }
588
- }
589
536
 
590
- // --- 共享:文件「首次加入 git」的日期索引(2026-09-28 check8-git-anchor)---
591
- // 用途:检查 8 的生效日锚从**可手填的文件名前缀**(项目命名规范本身即 `YYYY-MM-DD-<主题>`,作者
592
- // 日常手写该前缀,「写早」零成本、顺手即发生——一条 hard 门可被平凡绕过)改为**首次加入 git 的日期**。
593
- // **诚实边界(复核 P1 更正)**:该锚取 `git log --diff-filter=A --format=@%aI`,即 **author date
594
- // (作者自报时间戳)**,**不是** commit 时刻——`git commit --date=<过去>` 一条参数即可伪造,故
595
- // 「锚不可手填」的说法**不成立**。本改动的确切收益是:关掉「改文件名(日常必写、零成本)」这条通道,
596
- // 把伪造成本抬到「主动加参数伪造时间戳」——后者与「伪造台账」同属**本地信任边界内不可机器防**的范畴
597
- // (事后对质靠 git 历史与台账)。此处如实声明,不宣称「通道已关闭」。
598
- // 口径:git 按时间新→旧遍历,同一文件首遇即最早那次加入(与检查 10 的 addpath 同款判据)。
599
- // 非 git / 命令失败 → null,调用方按既有「非 git 跳过」语义退化(不 fail-loud)。
600
- // 注:检查 10 需要的是「文件→加入 commit」+「commit→文件清单」两份映射(判断同提交是否触及迁移 SQL),
601
- // 与本索引「文件→日期」不同构,故**不合并**——避免把两处判据耦死。
602
- const addedDates = (() => {
603
- if (gitOut(['rev-parse', '--git-dir']) === null) return null;
604
- const log = gitOut(['log', '--diff-filter=A', '--format=@%aI', '--name-only']);
605
- if (log === null) return null;
606
- const m = new Map();
607
- let cur = null;
608
- for (const line of log.split('\n')) {
609
- if (line.startsWith('@')) { cur = line.slice(1); continue; }
610
- if (line.trim() && cur && !m.has(line.trim())) m.set(line.trim(), cur); // 新→旧序首遇 = 最早
537
+ // --- 5. 子智能体角色契约 + 宿主 Adapter 一致性 + 旧委派残留 + 钉死模型 [warning] ---
538
+ {
539
+ const cmdFiles = () => (readdirOrNull(path.join(ROOT, '.agents', 'commands')) || [])
540
+ .filter((f) => f.endsWith('.md')).map((f) => `.agents/commands/${f}`);
541
+ // existsSync 守卫(2026-10-06-check19-entry-enoent 同类):AGENTS.md 等可合法缺失(调用方按空数组
542
+ // 判定本就预期缺失态),fail-loud fs 读下按缺省跳过、不再响亮出账;其余调用方路径另有上游守卫
543
+ const textOf = (rel) => {
544
+ const p = path.join(ROOT, rel);
545
+ return fs.existsSync(p) ? (linesOf(p) || []) : [];
546
+ };
547
+ for (const role of ['implementer', 'independent-reviewer', 'ui-verifier']) {
548
+ const rolePath = `.agents/roles/${role}.md`;
549
+ if (!fs.existsSync(path.join(ROOT, rolePath))) {
550
+ warnings.push(`- [WARN 角色缺失] 公共角色契约不存在:${rolePath}`);
551
+ continue;
552
+ }
553
+ if (fs.existsSync(path.join(ROOT, '.opencode', 'agents'))) {
554
+ const p = `.opencode/agents/${role}.md`;
555
+ if (!fs.existsSync(path.join(ROOT, p))) warnings.push(`- [WARN Adapter 缺失] OpenCode 缺 ${role}:${p}`);
556
+ else {
557
+ if (!/^mode:\s*subagent\s*$/m.test(textOf(p).join('\n'))) warnings.push(`- [WARN Adapter 无效] OpenCode ${role} 未声明 mode:subagent:${p}`);
558
+ if (!textOf(p).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] OpenCode ${role} 未引用公共角色:${rolePath}`);
559
+ }
560
+ }
561
+ if (fs.existsSync(path.join(ROOT, '.trae', 'agents'))) {
562
+ const p = `.trae/agents/${role}.md`;
563
+ if (!fs.existsSync(path.join(ROOT, p))) warnings.push(`- [WARN Adapter 缺失] Trae 缺 ${role}:${p}`);
564
+ else {
565
+ if (!new RegExp(`^name:\\s*${role}\\s*$`, 'm').test(textOf(p).join('\n'))) warnings.push(`- [WARN Adapter 无效] Trae ${role} 的 name 与文件名不一致:${p}`);
566
+ if (!textOf(p).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] Trae ${role} 未引用公共角色:${rolePath}`);
567
+ }
568
+ }
569
+ const zc = `.zcode/agents/${role}.md`;
570
+ if (fs.existsSync(path.join(ROOT, zc))) { // .zcode/agents/ 现为入库件(2026-10-05 起宿主一律入库);缺失只说明宿主未装,不告警,存在时校验一致性
571
+ if (!new RegExp(`^name:\\s*["']?${role}["']?\\s*$`, 'm').test(textOf(zc).join('\n'))) warnings.push(`- [WARN Adapter 无效] ZCode ${role} 的 name 与文件名不一致:${zc}`);
572
+ if (!textOf(zc).some((l) => l.includes(rolePath))) warnings.push(`- [WARN Adapter 断线] ZCode ${role} 未引用公共角色:${rolePath}`);
573
+ }
574
+ const cited = ['AGENTS.md', ...cmdFiles()].some((f) => textOf(f).some((l) => l.includes(rolePath)));
575
+ if (!cited) warnings.push(`- [WARN 命令断线] 工作流未引用公共角色:${rolePath}`);
576
+ }
577
+ // 旧委派残留 / 钉死模型(grep -nH 行格式 file:line:content)
578
+ const legacyRe = /(^subagent:|subagent_type=|general_purpose_task|browser_use)/;
579
+ const legacyHits = [];
580
+ for (const f of ['AGENTS.md', ...cmdFiles()]) {
581
+ textOf(f).forEach((l, i) => { if (legacyRe.test(l)) legacyHits.push(`${f}:${i + 1}:${l}`); });
582
+ }
583
+ if (legacyHits.length) warnings.push(`- [WARN 旧委派残留] 主规则或阶段命令仍含宿主特定/旧委派声明:\n${legacyHits.join('\n')}`);
584
+ const modelHits = [];
585
+ for (const h of ['.opencode', '.trae', '.zcode']) {
586
+ for (const f of readdirOrNull(path.join(ROOT, h, 'agents')) || []) {
587
+ if (!f.endsWith('.md')) continue;
588
+ const rel = `${h}/agents/${f}`;
589
+ textOf(rel).forEach((l, i) => { if (/^model:/.test(l) && !/^model:\s*inherit/.test(l)) modelHits.push(`${rel}:${i + 1}:${l}`); });
590
+ }
591
+ }
592
+ if (modelHits.length) warnings.push(`- [WARN 宿主耦合] 薄 Adapter 不应固定模型:\n${modelHits.join('\n')}`);
611
593
  }
612
- return m;
613
- })();
614
- // 某文档「首次加入日期」(YYYY-MM-DD);不可判定(非 git / 查不到)返回 ''
615
- const addedDateOf = (absDoc) => {
616
- if (!addedDates) return '';
617
- const rel = path.relative(ROOT, absDoc).split(path.sep).join('/');
618
- const iso = addedDates.get(rel);
619
- return iso ? iso.slice(0, 10) : '';
620
- };
621
-
622
- // 豁免/放行分支出账(fail-loud 口径——「裁决消失」不可归因的教训:豁免路径零痕迹;
623
- // 2026-10-05-check8-digit-sha-misfire):按 type 进程级去重,每类至多一条;stderr 不改 stdout 协议
624
- const evidenceExemptWarned = new Set();
625
- function warnEvidenceExempt(type, base) {
626
- if (evidenceExemptWarned.has(type)) return;
627
- evidenceExemptWarned.add(type);
628
- console.error(`check-loop: 证据豁免 ${type} ${base}——该文档的验收证据未做真相核验`);
629
- }
630
594
 
631
- // 证据真相校验:检查证据字符串是否包含合法的 commit SHA,且该 commit 触及了 plan 声明的文件
632
- // 测试绿声明对账(2026-10-07 verify-evidence):ctx = { verifyRelevant, hasFresh } 时,无 SHA 且命中
633
- // 关键词的证据走凭证对账——有 24h 绿行 → verify 型豁免出账;无 → verify-missing(聚合段出 warning,
634
- // 灰度第一档非 blocker)。ctx 由调用方按「intent 加入 git ≥ verifySince(v5 锚)」组装;锚不可判定
635
- // → ctx null → 维持 text 豁免(无判定依据不产出告警)。关键词表最小集起步(check2-datetime 先例:
636
- // 只豁免实证形态,变体实证再扩)。
637
- const VERIFY_GREEN_RE = /测试通过|测试全绿|测试绿|全绿/;
638
- function verifyEvidenceTruth(evidenceStr, planBase, root, ctx) {
639
- const shaMatch = evidenceStr.match(/\b([a-f0-9]{7,40})\b/i);
640
- if (!shaMatch) {
641
- if (ctx && ctx.verifyRelevant && VERIFY_GREEN_RE.test(evidenceStr)) {
642
- if (ctx.hasFresh) {
643
- warnEvidenceExempt('verify', planBase);
644
- return { ok: true, type: 'verify' };
595
+ // --- 6. 阶段索引同步 [warning] ---
596
+ for (const cmd of ['plan', 'design', 'build', 'test', 'deploy', 'maintain', 'review']) {
597
+ for (const doc of ['AGENTS.md', '.agents/commands/new-task.md']) {
598
+ // existsSync 守卫(2026-10-06-check19-entry-enoent 同类):两文件可合法缺失(下方 text 判空本就
599
+ // 预期缺失态),fail-loud fs 读下按空跳过、不再响亮出账
600
+ const docPath = path.join(ROOT, doc);
601
+ const text = fs.existsSync(docPath) ? linesOf(docPath) : null;
602
+ if (text && !text.some((l) => l.includes(`.agents/commands/${cmd}.md`))) {
603
+ warnings.push(`- [WARN 阶段索引漂移] ${doc} 缺 ${cmd} 指令索引(两处阶段表须同步维护)`);
645
604
  }
646
- return { ok: true, type: 'verify-missing' };
647
605
  }
648
- warnEvidenceExempt('text', planBase);
649
- return { ok: true, type: 'text' };
650
606
  }
651
607
 
652
- const sha = shaMatch[1];
653
- // 直连走 spawnGit(与 gitOut 同一 loud+retry 通道,注释互引):spawn 异常不再被静默算作业务失败;
654
- // 重试后仍异常按下方 status!==0 判定(fail-closed 保持——本地核验不了就按伪造拦)
655
- const revParse = spawnGit(['rev-parse', sha]);
656
- if (revParse.status !== 0) {
657
- // 纯数字串且解析失败 → 时间戳/ID 维持文本豁免(2026-10-05-check8-digit-sha-misfire:形态守卫
658
- // 曾把全数字短 SHA≈4.4%/夹具 误分类为文本致核验静默跳过——改由 rev-parse 实证分流)。
659
- // 纪律:文本启发式须可被实证兜底,禁以字符形态抢先分类。
660
- if (/^\d+$/.test(sha)) {
608
+ // --- 共享:文件「首次加入 git」的日期索引(2026-09-28 check8-git-anchor)---
609
+ // 用途:检查 8 的生效日锚从**可手填的文件名前缀**(项目命名规范本身即 `YYYY-MM-DD-<主题>`,作者
610
+ // 日常手写该前缀,「写早」零成本、顺手即发生——一条 hard 门可被平凡绕过)改为**首次加入 git 的日期**。
611
+ // **诚实边界(复核 P1 更正)**:该锚取 `git log --diff-filter=A --format=@%aI`,即 **author date
612
+ // (作者自报时间戳)**,**不是** commit 时刻——`git commit --date=<过去>` 一条参数即可伪造,故
613
+ // 「锚不可手填」的说法**不成立**。本改动的确切收益是:关掉「改文件名(日常必写、零成本)」这条通道,
614
+ // 把伪造成本抬到「主动加参数伪造时间戳」——后者与「伪造台账」同属**本地信任边界内不可机器防**的范畴
615
+ // (事后对质靠 git 历史与台账)。此处如实声明,不宣称「通道已关闭」。
616
+ // 口径:git 按时间新→旧遍历,同一文件首遇即最早那次加入(与检查 10 的 addpath 同款判据)。
617
+ // 非 git / 命令失败 → null,调用方按既有「非 git 跳过」语义退化(不 fail-loud)。
618
+ // 注:检查 10 需要的是「文件→加入 commit」+「commit→文件清单」两份映射(判断同提交是否触及迁移 SQL),
619
+ // 与本索引「文件→日期」不同构,故**不合并**——避免把两处判据耦死。
620
+ const addedDates = (() => {
621
+ if (gitOut(['rev-parse', '--git-dir']) === null) return null;
622
+ const log = gitOut(['log', '--diff-filter=A', '--format=@%aI', '--name-only']);
623
+ if (log === null) return null;
624
+ const m = new Map();
625
+ let cur = null;
626
+ for (const line of log.split('\n')) {
627
+ if (line.startsWith('@')) { cur = line.slice(1); continue; }
628
+ if (line.trim() && cur && !m.has(line.trim())) m.set(line.trim(), cur); // 新→旧序首遇 = 最早
629
+ }
630
+ return m;
631
+ })();
632
+ // 某文档「首次加入日期」(YYYY-MM-DD);不可判定(非 git / 查不到)返回 ''
633
+ const addedDateOf = (absDoc) => {
634
+ if (!addedDates) return '';
635
+ const rel = path.relative(ROOT, absDoc).split(path.sep).join('/');
636
+ const iso = addedDates.get(rel);
637
+ return iso ? iso.slice(0, 10) : '';
638
+ };
639
+
640
+ // 豁免/放行分支出账(fail-loud 口径——「裁决消失」不可归因的教训:豁免路径零痕迹;
641
+ // 2026-10-05-check8-digit-sha-misfire):按 type 进程级去重,每类至多一条;stderr 不改 stdout 协议
642
+ const evidenceExemptWarned = new Set();
643
+ function warnEvidenceExempt(type, base) {
644
+ if (evidenceExemptWarned.has(type)) return;
645
+ evidenceExemptWarned.add(type);
646
+ console.error(`check-loop: 证据豁免 ${type} ${base}——该文档的验收证据未做真相核验`);
647
+ }
648
+
649
+ // 证据真相校验:检查证据字符串是否包含合法的 commit SHA,且该 commit 触及了 plan 声明的文件
650
+ // 测试绿声明对账(2026-10-07 verify-evidence):ctx = { verifyRelevant, hasFresh } 时,无 SHA 且命中
651
+ // 关键词的证据走凭证对账——有 24h 绿行 → verify 型豁免出账;无 → verify-missing(聚合段出 warning,
652
+ // 灰度第一档非 blocker)。ctx 由调用方按「intent 加入 git ≥ verifySince(v5 锚)」组装;锚不可判定
653
+ // → ctx null → 维持 text 豁免(无判定依据不产出告警)。关键词表最小集起步(check2-datetime 先例:
654
+ // 只豁免实证形态,变体实证再扩)。
655
+ const VERIFY_GREEN_RE = /测试通过|测试全绿|测试绿|全绿/;
656
+ function verifyEvidenceTruth(evidenceStr, planBase, root, ctx) {
657
+ const shaMatch = evidenceStr.match(/\b([a-f0-9]{7,40})\b/i);
658
+ if (!shaMatch) {
659
+ if (ctx && ctx.verifyRelevant && VERIFY_GREEN_RE.test(evidenceStr)) {
660
+ if (ctx.hasFresh) {
661
+ warnEvidenceExempt('verify', planBase);
662
+ return { ok: true, type: 'verify' };
663
+ }
664
+ return { ok: true, type: 'verify-missing' };
665
+ }
661
666
  warnEvidenceExempt('text', planBase);
662
667
  return { ok: true, type: 'text' };
663
668
  }
664
- // 区分本地伪造与外部仓库引用:
665
- // 剔除 SHA 与泛指词后仍有其他内容(如项目名、路径描述)→ 视为外部仓库引用,不硬拦(本地无法核验他仓哈希);
666
- // 剔除后为空(如纯「commit a1b2c3d」「提交 a1b2c3d」)→ 视为本地引用伪造,硬拦。
667
- const residue = evidenceStr.replace(sha, '')
668
- .replace(/证据[::]|commit|提交|[()()。—-]/gi, '')
669
- .trim();
670
- if (residue.length > 0) {
671
- warnEvidenceExempt('external', planBase);
672
- return { ok: true, type: 'external' };
669
+
670
+ const sha = shaMatch[1];
671
+ // 直连走 spawnGit(与 gitOut 同一 loud+retry 通道,注释互引):spawn 异常不再被静默算作业务失败;
672
+ // 重试后仍异常按下方 status!==0 判定(fail-closed 保持——本地核验不了就按伪造拦)
673
+ const revParse = spawnGit(['rev-parse', sha]);
674
+ if (revParse.status !== 0) {
675
+ // git 通道坏死(spawn 异常,非 git 的正常回答)→ 禁用下方两类豁免:「实证分流」依赖 rev-parse
676
+ // 可用,通道失效时纯数字/外部引用都无法实证,按伪造拦(fail-closed,与 :672 注释一致)。
677
+ // v1.3.0 Release CI 实证:fixture 短 sha 恰全数字(≈4%/run)时,纯数字 text 豁免曾把本场景
678
+ // fail-closed 漏成 exit 0(ubuntu 腿);win32 sha 含字母走 forged 故本地恒绿——平台概率差异。
679
+ if (revParse.error) {
680
+ return { ok: false, type: 'forged', msg: `提交 ${sha} 不存在` };
681
+ }
682
+ // 纯数字串且解析失败 → 时间戳/ID 维持文本豁免(2026-10-05-check8-digit-sha-misfire:形态守卫
683
+ // 曾把全数字短 SHA≈4.4%/夹具 误分类为文本致核验静默跳过——改由 rev-parse 实证分流)。
684
+ // 纪律:文本启发式须可被实证兜底,禁以字符形态抢先分类。
685
+ if (/^\d+$/.test(sha)) {
686
+ warnEvidenceExempt('text', planBase);
687
+ return { ok: true, type: 'text' };
688
+ }
689
+ // 区分本地伪造与外部仓库引用:
690
+ // 剔除 SHA 与泛指词后仍有其他内容(如项目名、路径描述)→ 视为外部仓库引用,不硬拦(本地无法核验他仓哈希);
691
+ // 剔除后为空(如纯「commit a1b2c3d」「提交 a1b2c3d」)→ 视为本地引用伪造,硬拦。
692
+ const residue = evidenceStr.replace(sha, '')
693
+ .replace(/证据[::]|commit|提交|[()()。—-]/gi, '')
694
+ .trim();
695
+ if (residue.length > 0) {
696
+ warnEvidenceExempt('external', planBase);
697
+ return { ok: true, type: 'external' };
698
+ }
699
+ return { ok: false, type: 'forged', msg: `提交 ${sha} 不存在` };
673
700
  }
674
- return { ok: false, type: 'forged', msg: `提交 ${sha} 不存在` };
675
- }
676
701
 
677
- // 解析成功(含纯数字短 SHA 命中提交/ref 的情形)→ 真相核验
678
- const planPath = path.join(ROOT, WF, 'plans', planBase + '.md');
679
- if (!fs.existsSync(planPath)) {
680
- warnEvidenceExempt('no-plan', planBase);
681
- return { ok: true, type: 'no-plan' }; // 缺 plan 走 Check 1 拦截
682
- }
702
+ // 解析成功(含纯数字短 SHA 命中提交/ref 的情形)→ 真相核验
703
+ const planPath = path.join(ROOT, WF, 'plans', planBase + '.md');
704
+ if (!fs.existsSync(planPath)) {
705
+ warnEvidenceExempt('no-plan', planBase);
706
+ return { ok: true, type: 'no-plan' }; // 缺 plan 走 Check 1 拦截
707
+ }
683
708
 
684
- const planLines = linesOf(planPath) || [];
685
- const declaredFiles = [];
686
- // 扫描「改动面」「任务拆解」或「改动方案」(L1 Quick-Plan 生成器 fill-plan 输出)节
687
- let inDeclaredSec = false;
688
- for (const line of planLines) {
689
- if (/^##\s+(改动面|任务拆解|改动方案)/.test(line)) { inDeclaredSec = true; continue; }
690
- if (inDeclaredSec && /^##\s+/.test(line)) { inDeclaredSec = false; continue; }
691
- if (inDeclaredSec) {
692
- const fileMatch = line.match(/([a-zA-Z0-9._\/-]+\.[a-zA-Z0-9]+)/);
693
- if (fileMatch) declaredFiles.push(fileMatch[1]);
709
+ const planLines = linesOf(planPath) || [];
710
+ const declaredFiles = [];
711
+ // 扫描「改动面」「任务拆解」或「改动方案」(L1 Quick-Plan 生成器 fill-plan 输出)节
712
+ let inDeclaredSec = false;
713
+ for (const line of planLines) {
714
+ if (/^##\s+(改动面|任务拆解|改动方案)/.test(line)) { inDeclaredSec = true; continue; }
715
+ if (inDeclaredSec && /^##\s+/.test(line)) { inDeclaredSec = false; continue; }
716
+ if (inDeclaredSec) {
717
+ const fileMatch = line.match(/([a-zA-Z0-9._\/-]+\.[a-zA-Z0-9]+)/);
718
+ if (fileMatch) declaredFiles.push(fileMatch[1]);
719
+ }
694
720
  }
695
- }
696
721
 
697
- const show = spawnGit(['show', '--name-only', sha]);
698
- if (show.status !== 0) return { ok: false, type: 'error', msg: `无法读取提交 ${sha} 的文件列表` };
722
+ const show = spawnGit(['show', '--name-only', sha]);
723
+ if (show.status !== 0) return { ok: false, type: 'error', msg: `无法读取提交 ${sha} 的文件列表` };
699
724
 
700
- const changedFiles = show.stdout.split(/\r?\n/).filter(Boolean);
701
- const hasIntersection = changedFiles.some(f => declaredFiles.some(df => f.includes(df) || df.includes(f)));
725
+ const changedFiles = show.stdout.split(/\r?\n/).filter(Boolean);
726
+ const hasIntersection = changedFiles.some(f => declaredFiles.some(df => f.includes(df) || df.includes(f)));
702
727
 
703
- if (!hasIntersection) {
704
- // 过程证据(2026-10-03 check-evidence-process):执行器驱动的提交可作验收证据——提交信息带
705
- // 「pipeline-run <runId>」标记(docsCommit 四种格式共有段,runId 严格形态 日期8-时间4-主题-尾;
706
- // 与 run 事件流同源,git 历史留痕可对质)。只救 irrelevant:forged(SHA 不存在)在前已判,其余判据不动。
707
- // 记录型标志(2026-10-06-v114-backflow-batch):docs 系 Conventional Commit(冒烟报告 / 留痕 / 关单台账)
708
- // 不触 plan 声明面属预期——是否降级豁免由检查 8 聚合段判(须同文档存在可过检实现证据),此处只出标志。
709
- const subj = spawnGit(['log', '-1', '--format=%s', sha]);
710
- const recordCommit = subj.status === 0 && /^docs[(:]/.test(subj.stdout.trim());
711
- if (subj.status === 0 && /pipeline-run [0-9]{8}-[0-9]{4}-[A-Za-z0-9-]+/.test(subj.stdout)) {
712
- warnEvidenceExempt('process', planBase);
713
- return { ok: true, type: 'process' };
728
+ if (!hasIntersection) {
729
+ // 过程证据(2026-10-03 check-evidence-process):执行器驱动的提交可作验收证据——提交信息带
730
+ // 「pipeline-run <runId>」标记(docsCommit 四种格式共有段,runId 严格形态 日期8-时间4-主题-尾;
731
+ // 与 run 事件流同源,git 历史留痕可对质)。只救 irrelevant:forged(SHA 不存在)在前已判,其余判据不动。
732
+ // 记录型标志(2026-10-06-v114-backflow-batch):docs 系 Conventional Commit(冒烟报告 / 留痕 / 关单台账)
733
+ // 不触 plan 声明面属预期——是否降级豁免由检查 8 聚合段判(须同文档存在可过检实现证据),此处只出标志。
734
+ const subj = spawnGit(['log', '-1', '--format=%s', sha]);
735
+ const recordCommit = subj.status === 0 && /^docs[(:]/.test(subj.stdout.trim());
736
+ if (subj.status === 0 && /pipeline-run [0-9]{8}-[0-9]{4}-[A-Za-z0-9-]+/.test(subj.stdout)) {
737
+ warnEvidenceExempt('process', planBase);
738
+ return { ok: true, type: 'process' };
739
+ }
740
+ return { ok: false, type: 'irrelevant', msg: `提交 ${sha} 未触及 plan 声明的任何文件`, recordCommit };
714
741
  }
715
- return { ok: false, type: 'irrelevant', msg: `提交 ${sha} 未触及 plan 声明的任何文件`, recordCommit };
716
- }
717
742
 
718
- return { ok: true, type: 'sha' };
719
- }
743
+ return { ok: true, type: 'sha' };
744
+ }
720
745
 
721
- // --- 8. intent 验收标准对账(done 须逐条勾验并补证据;新建 hard,存量聚合 warning)---
722
- // 生效日锚 2026-09-28 改「git 首次加入日期」(此前取文件名前 10 字符——命名规范强制的字段、
723
- // 写早零成本,见 incidents/2026-09-28-check8-git-anchor)。非 git → 不可判定 → 走存量口径(不误报 hard)。
724
- {
725
- const accCutoff = '2026-09-12';
726
- let legacyUnaccounted = 0;
727
- // 测试绿凭证状态(2026-10-07 verify-evidence):台账读一次循环外缓存;policy 缺 verifySince
728
- //(v1-v4)→ ctxBase null → 对账整体跳过(向后兼容)
729
- const verifySinceOf = kitPolicy.verifySince;
730
- const verifyFresh = (() => {
731
- try {
732
- const vp = path.join(ROOT, '.agents', 'verifications.jsonl');
733
- return fs.existsSync(vp) ? hasFreshVerifyLine(fs.readFileSync(vp, 'utf8').split(/\r?\n/)) : false;
734
- } catch { return false; }
735
- })();
736
- for (const intent of docFiles('intents')) {
737
- if (fmGet(intent, '状态') !== 'done') continue;
738
- const base = path.basename(intent);
739
- const lines = linesOf(intent) || [];
740
- // 节扫描:hs=有验收节;insec 至下一个二级标题;uc=未勾项;ne=勾选缺证据;ex=存量豁免声明
741
- // 证据可写在 [x] 行的续行(仓库通写法「(证据:…)」另起一行)——pendX 记「待证据项」,
742
- // 遇证据行(全角「:」/半角「:」皆认)清零;遇下一条目 / 下一小节 / 节末仍无证据 → ne
743
- let hs = false, uc = false, ne = false;
744
- let insec = false, ex = false;
745
- let pendX = false; // 上一 [x] 项尚无证据,证据可能在紧随的续行
746
- let currentEvidence = ''; // 当前条目的证据内容,用于语义校验
747
- const evidenceItems = []; // 逐条证据(2026-10-06-v114-backflow-batch:两遍裁决——先文档级判实现证据在场,再逐条落判)
748
- const evRe = /证据[::]/;
749
- const closeItem = () => {
750
- if (pendX) {
751
- ne = true;
752
- pendX = false;
753
- }
754
- if (currentEvidence && !ex) {
755
- evidenceItems.push(currentEvidence);
746
+ // --- 8. intent 验收标准对账(done 须逐条勾验并补证据;新建 hard,存量聚合 warning)---
747
+ // 生效日锚 2026-09-28 改「git 首次加入日期」(此前取文件名前 10 字符——命名规范强制的字段、
748
+ // 写早零成本,见 incidents/2026-09-28-check8-git-anchor)。非 git → 不可判定 → 走存量口径(不误报 hard)。
749
+ {
750
+ const accCutoff = '2026-09-12';
751
+ let legacyUnaccounted = 0;
752
+ // 测试绿凭证状态(2026-10-07 verify-evidence):台账读一次循环外缓存;policy 缺 verifySince
753
+ //(v1-v4)→ ctxBase null → 对账整体跳过(向后兼容)
754
+ const verifySinceOf = kitPolicy.verifySince;
755
+ const verifyFresh = (() => {
756
+ try {
757
+ const vp = path.join(ROOT, '.agents', 'verifications.jsonl');
758
+ return fs.existsSync(vp) ? hasFreshVerifyLine(fs.readFileSync(vp, 'utf8').split(/\r?\n/)) : false;
759
+ } catch { return false; }
760
+ })();
761
+ for (const intent of docFiles('intents')) {
762
+ if (fmGet(intent, '状态') !== 'done') continue;
763
+ const base = path.basename(intent);
764
+ const lines = linesOf(intent) || [];
765
+ // 节扫描:hs=有验收节;insec 至下一个二级标题;uc=未勾项;ne=勾选缺证据;ex=存量豁免声明
766
+ // 证据可写在 [x] 行的续行(仓库通写法「(证据:…)」另起一行)——pendX 记「待证据项」,
767
+ // 遇证据行(全角「:」/半角「:」皆认)清零;遇下一条目 / 下一小节 / 节末仍无证据 → ne
768
+ let hs = false, uc = false, ne = false;
769
+ let insec = false, ex = false;
770
+ let pendX = false; // 上一 [x] 项尚无证据,证据可能在紧随的续行
771
+ let currentEvidence = ''; // 当前条目的证据内容,用于语义校验
772
+ const evidenceItems = []; // 逐条证据(2026-10-06-v114-backflow-batch:两遍裁决——先文档级判实现证据在场,再逐条落判)
773
+ const evRe = /证据[::]/;
774
+ const closeItem = () => {
775
+ if (pendX) {
776
+ ne = true;
777
+ pendX = false;
778
+ }
779
+ if (currentEvidence && !ex) {
780
+ evidenceItems.push(currentEvidence);
781
+ }
782
+ currentEvidence = '';
783
+ };
784
+ for (const line of lines) {
785
+ if (/存量对账豁免(/.test(line)) ex = true;
786
+ if (/^\s*##\s+[^#]*验收标准/.test(line)) { hs = true; insec = true; closeItem(); continue; }
787
+ if (insec && /^\s*##\s/.test(line)) { insec = false; closeItem(); continue; }
788
+ if (!insec) continue;
789
+ if (/^\s*- \[ \]/.test(line)) { closeItem(); uc = true; continue; }
790
+ if (/^\s*- \[x\]/.test(line)) {
791
+ closeItem();
792
+ pendX = !evRe.test(line);
793
+ if (evRe.test(line)) currentEvidence = line.split(/证据[::]/)[1].trim();
794
+ continue;
795
+ }
796
+ if (pendX && evRe.test(line)) {
797
+ pendX = false;
798
+ currentEvidence = line.split(/证据[::]/)[1].trim();
799
+ } else if (currentEvidence && line.trim() && !/^\s*- /.test(line)) {
800
+ // 简单累加续行证据
801
+ currentEvidence += ' ' + line.trim();
802
+ }
756
803
  }
757
- currentEvidence = '';
758
- };
759
- for (const line of lines) {
760
- if (/存量对账豁免(/.test(line)) ex = true;
761
- if (/^\s*##\s+[^#]*验收标准/.test(line)) { hs = true; insec = true; closeItem(); continue; }
762
- if (insec && /^\s*##\s/.test(line)) { insec = false; closeItem(); continue; }
763
- if (!insec) continue;
764
- if (/^\s*- \[ \]/.test(line)) { closeItem(); uc = true; continue; }
765
- if (/^\s*- \[x\]/.test(line)) {
766
- closeItem();
767
- pendX = !evRe.test(line);
768
- if (evRe.test(line)) currentEvidence = line.split(/证据[::]/)[1].trim();
769
- continue;
804
+ closeItem(); // 节末(或全文末)仍无证据 → 计缺证据
805
+ // 两遍裁决(2026-10-06-v114-backflow-batch 记录型提交豁免):文档级先判「存在可过检实现证据」
806
+ // (sha / process 型任一),记录型提交(subject=docs 系)证据仅在实现证据在场时降级豁免告警——
807
+ // 冒烟报告 / approved 留痕 / 关单提交不改 plan 声明面属预期;仅引记录型而无实现证据 → 维持 hard-block
808
+ //(豁免通道不得独立成立,防「只引 docs 提交洗白代码改动」)。
809
+ if (evidenceItems.length) {
810
+ const planBase = base.replace(/.md$/, '');
811
+ // verify 对账 ctx:intent 首次加入 git ≥ verifySince(v5 锚)才参与;不可判定 → null 走 text 豁免
812
+ const iadd = addedDateOf(intent);
813
+ const vctx = typeof verifySinceOf === 'string' && iadd !== '' && iadd >= verifySinceOf
814
+ ? { verifyRelevant: true, hasFresh: verifyFresh } : null;
815
+ const results = evidenceItems.map((ev) => ({ t: verifyEvidenceTruth(ev, planBase, ROOT, vctx) }));
816
+ const hasImplEvidence = results.some((r) => r.t.ok && (r.t.type === 'sha' || r.t.type === 'process'));
817
+ for (const r of results) {
818
+ if (r.t.ok && r.t.type === 'verify-missing') {
819
+ warnings.push(`- [WARN 测试绿缺凭证] ${base} 验收证据声明「测试绿」但 24h 内无 verify 凭证(verifications.jsonl)——跑 node .agents/scripts/verify.mjs 后重跑(advisory,灰度第一档)`);
820
+ continue;
821
+ }
822
+ if (r.t.ok) continue;
823
+ if (r.t.type === 'irrelevant' && r.t.recordCommit && hasImplEvidence) {
824
+ warnEvidenceExempt('record', planBase);
825
+ continue;
826
+ }
827
+ blockers.push(`- [证据${r.t.type === 'forged' ? '伪造' : '无关'}] ${base} 验收证据校验失败: ${r.t.msg}`);
828
+ }
770
829
  }
771
- if (pendX && evRe.test(line)) {
772
- pendX = false;
773
- currentEvidence = line.split(/证据[::]/)[1].trim();
774
- } else if (currentEvidence && line.trim() && !/^\s*- /.test(line)) {
775
- // 简单累加续行证据
776
- currentEvidence += ' ' + line.trim();
830
+ // 生效日锚 = git 首次加入日期(2026-09-28 改;此前取文件名前 10 字符)。**准确收益**:关掉
831
+ // 「改文件名日期(日常必写、零成本)即整段跳过 hard 门」这条通道;锚取 author date(%aI),
832
+ // 刻意伪造(`git commit --date=`)仍可绕过——与伪造台账同属本地信任边界内,不宣称"通道已关闭"。
833
+ // 非 git / 查不到 → '' → isNew=false → 走存量口径(与既有语义一致:不可判定时不误报 hard)。
834
+ const adddate = addedDateOf(intent);
835
+ const isNew = adddate !== '' && adddate >= accCutoff;
836
+ if (!hs) {
837
+ if (isNew) blockers.push(`- [验收未对账] done intent 缺「## 验收标准」节(勾验无从核对):${base}`);
838
+ else if (!ex) legacyUnaccounted++;
839
+ continue;
777
840
  }
778
- }
779
- closeItem(); // 节末(或全文末)仍无证据 → 计缺证据
780
- // 两遍裁决(2026-10-06-v114-backflow-batch 记录型提交豁免):文档级先判「存在可过检实现证据」
781
- // (sha / process 型任一),记录型提交(subject=docs 系)证据仅在实现证据在场时降级豁免告警——
782
- // 冒烟报告 / approved 留痕 / 关单提交不改 plan 声明面属预期;仅引记录型而无实现证据 → 维持 hard-block
783
- //(豁免通道不得独立成立,防「只引 docs 提交洗白代码改动」)。
784
- if (evidenceItems.length) {
785
- const planBase = base.replace(/.md$/, '');
786
- // verify 对账 ctx:intent 首次加入 git ≥ verifySince(v5 锚)才参与;不可判定 → null 走 text 豁免
787
- const iadd = addedDateOf(intent);
788
- const vctx = typeof verifySinceOf === 'string' && iadd !== '' && iadd >= verifySinceOf
789
- ? { verifyRelevant: true, hasFresh: verifyFresh } : null;
790
- const results = evidenceItems.map((ev) => ({ t: verifyEvidenceTruth(ev, planBase, ROOT, vctx) }));
791
- const hasImplEvidence = results.some((r) => r.t.ok && (r.t.type === 'sha' || r.t.type === 'process'));
792
- for (const r of results) {
793
- if (r.t.ok && r.t.type === 'verify-missing') {
794
- warnings.push(`- [WARN 测试绿缺凭证] ${base} 验收证据声明「测试绿」但 24h 内无 verify 凭证(verifications.jsonl)——跑 node .agents/scripts/verify.mjs 后重跑(advisory,灰度第一档)`);
795
- continue;
841
+ if (!uc && !ne) continue;
842
+ if (isNew) {
843
+ if (uc) blockers.push(`- [验收未对账] done intent 验收标准有未勾验项:${base}(逐条勾验并补证据:commit/用例/冒烟输出)`);
844
+ if (ne) blockers.push(`- [验收缺证据] ${base} 验收标准勾选项缺「证据:」标注——证据是闭环准确性的唯一机器事实`);
845
+ } else if (!ex) {
846
+ legacyUnaccounted++;
796
847
  }
797
- if (r.t.ok) continue;
798
- if (r.t.type === 'irrelevant' && r.t.recordCommit && hasImplEvidence) {
799
- warnEvidenceExempt('record', planBase);
800
- continue;
801
- }
802
- blockers.push(`- [证据${r.t.type === 'forged' ? '伪造' : '无关'}] ${base} 验收证据校验失败: ${r.t.msg}`);
803
- }
804
848
  }
805
- // 生效日锚 = git 首次加入日期(2026-09-28 改;此前取文件名前 10 字符)。**准确收益**:关掉
806
- // 「改文件名日期(日常必写、零成本)即整段跳过 hard 门」这条通道;锚取 author date(%aI),
807
- // 刻意伪造(`git commit --date=`)仍可绕过——与伪造台账同属本地信任边界内,不宣称"通道已关闭"。
808
- // 非 git / 查不到 → '' → isNew=false → 走存量口径(与既有语义一致:不可判定时不误报 hard)。
809
- const adddate = addedDateOf(intent);
810
- const isNew = adddate !== '' && adddate >= accCutoff;
811
- if (!hs) {
812
- if (isNew) blockers.push(`- [验收未对账] done intent 缺「## 验收标准」节(勾验无从核对):${base}`);
813
- else if (!ex) legacyUnaccounted++;
814
- continue;
849
+ if (legacyUnaccounted > 0) {
850
+ warnings.push(`- [WARN 验收对账存量] ${legacyUnaccounted} 个存量 done intent 验收标准未对账(生效日锚之前加入仓库,豁免 hard,不回填)`);
815
851
  }
816
- if (!uc && !ne) continue;
817
- if (isNew) {
818
- if (uc) blockers.push(`- [验收未对账] done intent 验收标准有未勾验项:${base}(逐条勾验并补证据:commit/用例/冒烟输出)`);
819
- if (ne) blockers.push(`- [验收缺证据] ${base} 验收标准勾选项缺「证据:」标注——证据是闭环准确性的唯一机器事实`);
820
- } else if (!ex) {
821
- legacyUnaccounted++;
822
- }
823
852
  }
824
- if (legacyUnaccounted > 0) {
825
- warnings.push(`- [WARN 验收对账存量] ${legacyUnaccounted} 个存量 done intent 验收标准未对账(生效日锚之前加入仓库,豁免 hard,不回填)`);
826
- }
827
- }
828
853
 
829
- // --- 9. 文件名英文 kebab-case [warning] ---
830
- for (const sub of DOC_DIRS) {
831
- const dir = path.join(ROOT, WF, sub);
832
- for (const f of readdirOrNull(dir) || []) {
833
- if (!f.endsWith('.md') || f === '_TEMPLATE.md') continue;
834
- const abs = path.join(dir, f);
835
- if (!isTracked(abs)) continue;
836
- if (!/^[\x20-\x7e]*$/.test(f)) {
837
- warnings.push(`- [WARN 文件名非英文] 文件名含非 ASCII 字符,应为英文 kebab-case:${WF}/${sub}/${f}`);
854
+ // --- 9. 文件名英文 kebab-case [warning] ---
855
+ for (const sub of DOC_DIRS) {
856
+ const dir = path.join(ROOT, WF, sub);
857
+ for (const f of readdirOrNull(dir) || []) {
858
+ if (!f.endsWith('.md') || f === '_TEMPLATE.md') continue;
859
+ const abs = path.join(dir, f);
860
+ if (!isTracked(abs)) continue;
861
+ if (!/^[\x20-\x7e]*$/.test(f)) {
862
+ warnings.push(`- [WARN 文件名非英文] 文件名含非 ASCII 字符,应为英文 kebab-case:${WF}/${sub}/${f}`);
863
+ }
838
864
  }
839
865
  }
840
- }
841
866
 
842
- // --- 10. 级别 vs 迁移文件一致性 [warning](启发式:入口文档加入提交触及迁移 SQL/Migrations → 疑似判低;非 git 跳过)---
843
- if (gitOut(['rev-parse', '--git-dir']) !== null) {
844
- const log = gitOut(['log', '--diff-filter=A', '--format=@%H', '--name-only']);
845
- if (log !== null) {
846
- const addpath = new Map(); // 文件 → 首次加入 commit(新→旧序首遇)
847
- const cfiles = new Map(); // commit → 文件清单
848
- let cur = null;
849
- for (const line of log.split('\n')) {
850
- if (line.startsWith('@')) { cur = line.slice(1); cfiles.set(cur, []); continue; }
851
- if (line.trim() && cur && !addpath.has(line.trim())) {
852
- addpath.set(line.trim(), cur);
853
- cfiles.get(cur).push(line.trim());
867
+ // --- 10. 级别 vs 迁移文件一致性 [warning](启发式:入口文档加入提交触及迁移 SQL/Migrations → 疑似判低;非 git 跳过)---
868
+ if (gitOut(['rev-parse', '--git-dir']) !== null) {
869
+ const log = gitOut(['log', '--diff-filter=A', '--format=@%H', '--name-only']);
870
+ if (log !== null) {
871
+ const addpath = new Map(); // 文件 → 首次加入 commit(新→旧序首遇)
872
+ const cfiles = new Map(); // commit → 文件清单
873
+ let cur = null;
874
+ for (const line of log.split('\n')) {
875
+ if (line.startsWith('@')) { cur = line.slice(1); cfiles.set(cur, []); continue; }
876
+ if (line.trim() && cur && !addpath.has(line.trim())) {
877
+ addpath.set(line.trim(), cur);
878
+ cfiles.get(cur).push(line.trim());
879
+ }
854
880
  }
855
- }
856
- const docs = [...docFiles('intents'), ...docFiles('incidents')];
857
- for (const doc of docs) {
858
- const rel = path.relative(ROOT, doc).split(path.sep).join('/');
859
- const lvl = fmGet(doc, '级别');
860
- if (lvl !== 'L1' && lvl !== 'L2') continue;
861
- const c = addpath.get(rel);
862
- if (!c) continue;
863
- const hits = (cfiles.get(c) || []).filter((f) => /(^|\/)docs\/scripts\/.*\.sql$/.test(f) || /(^|\/)Migrations\//.test(f));
864
- if (hits.length) {
865
- warnings.push(`- [WARN 级别疑似判低] ${path.basename(doc)} 级别 ${lvl} 但其加入提交触及迁移文件(混合改动应就高不就低,请复核级别):\n${hits.join('\n')}`);
881
+ const docs = [...docFiles('intents'), ...docFiles('incidents')];
882
+ for (const doc of docs) {
883
+ const rel = path.relative(ROOT, doc).split(path.sep).join('/');
884
+ const lvl = fmGet(doc, '级别');
885
+ if (lvl !== 'L1' && lvl !== 'L2') continue;
886
+ const c = addpath.get(rel);
887
+ if (!c) continue;
888
+ const hits = (cfiles.get(c) || []).filter((f) => /(^|\/)docs\/scripts\/.*\.sql$/.test(f) || /(^|\/)Migrations\//.test(f));
889
+ if (hits.length) {
890
+ warnings.push(`- [WARN 级别疑似判低] ${path.basename(doc)} 级别 ${lvl} 但其加入提交触及迁移文件(混合改动应就高不就低,请复核级别):\n${hits.join('\n')}`);
891
+ }
866
892
  }
867
893
  }
868
894
  }
869
- }
870
895
 
871
- // --- 11. workflow/INDEX.md 漂移 [warning](调生成器 --check,口径单一不复刻渲染)---
872
- {
873
- const gen = path.join(ROOT, '.agents', 'scripts', 'gen-workflow-index.mjs');
874
- if (fs.existsSync(gen)) {
875
- const r = spawnSync(process.execPath, [gen, '--check'], { cwd: ROOT, encoding: 'utf8' });
876
- if (r.status !== 0) {
877
- const first = `${r.stdout || ''}${r.stderr || ''}`.split('\n')[0];
878
- warnings.push(`- [WARN 索引漂移] workflow/INDEX.md 与磁盘不一致——跑 node .agents/scripts/gen-workflow-index.mjs 重新生成(${first})`);
896
+ // --- 11. workflow/INDEX.md 漂移 [warning](调生成器 --check,口径单一不复刻渲染)---
897
+ {
898
+ const gen = path.join(ROOT, '.agents', 'scripts', 'gen-workflow-index.mjs');
899
+ if (fs.existsSync(gen)) {
900
+ const r = spawnSync(process.execPath, [gen, '--check'], { cwd: ROOT, encoding: 'utf8' });
901
+ if (r.status !== 0) {
902
+ const first = `${r.stdout || ''}${r.stderr || ''}`.split('\n')[0];
903
+ warnings.push(`- [WARN 索引漂移] workflow/INDEX.md 与磁盘不一致——跑 node .agents/scripts/gen-workflow-index.mjs 重新生成(${first})`);
904
+ }
879
905
  }
880
906
  }
881
- }
882
907
 
883
- // --- 12. frontmatter「模块:」合法性 [warning](词表单源;缺字段只对 2026-09-22 起新建提示)---
884
- {
885
- const modsFile = path.join(ROOT, '.agents', 'workflow-modules.txt');
886
- if (fs.existsSync(modsFile)) {
887
- const mods = linesOf(modsFile).filter((l) => l.trim() && !l.startsWith('#')).map((l) => l.trim());
888
- const vocab = new Set(mods);
889
- for (const sub of ['intents', 'specs', 'plans', 'incidents']) {
890
- for (const doc of docFiles(sub)) {
891
- const base = path.basename(doc);
892
- const mod = fmGet(doc, '模块');
893
- let d = fmGet(doc, '日期') || fmGet(doc, '发现');
894
- if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
895
- if (mod) {
896
- if (!vocab.has(mod)) warnings.push(`- [WARN 模块元数据] ${base}「模块: ${mod}」不在词表(见 .agents/workflow-modules.txt)`);
897
- } else if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= kitPolicy.moduleSince) {
898
- warnings.push(`- [WARN 模块元数据] ${base} 缺「模块:」字段(${kitPolicy.moduleSince} 起新建文档必填)`);
908
+ // --- 12. frontmatter「模块:」合法性 [warning](词表单源;缺字段只对 2026-09-22 起新建提示)---
909
+ {
910
+ const modsFile = path.join(ROOT, '.agents', 'workflow-modules.txt');
911
+ if (fs.existsSync(modsFile)) {
912
+ const mods = linesOf(modsFile).filter((l) => l.trim() && !l.startsWith('#')).map((l) => l.trim());
913
+ const vocab = new Set(mods);
914
+ for (const sub of ['intents', 'specs', 'plans', 'incidents']) {
915
+ for (const doc of docFiles(sub)) {
916
+ const base = path.basename(doc);
917
+ const mod = fmGet(doc, '模块');
918
+ let d = fmGet(doc, '日期') || fmGet(doc, '发现');
919
+ if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
920
+ if (mod) {
921
+ if (!vocab.has(mod)) warnings.push(`- [WARN 模块元数据] ${base}「模块: ${mod}」不在词表(见 .agents/workflow-modules.txt)`);
922
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= kitPolicy.moduleSince) {
923
+ warnings.push(`- [WARN 模块元数据] ${base} 缺「模块:」字段(${kitPolicy.moduleSince} 起新建文档必填)`);
924
+ }
899
925
  }
900
926
  }
901
927
  }
902
928
  }
903
- }
904
929
 
905
- // --- 13. 常驻面体积预算 [warning](判定单源 rule-budget.sh;无 sh 环境静默跳过——advisory,pre-commit 硬拦兜底)---
906
- {
907
- const rb = path.join(ROOT, '.agents', 'scripts', 'rule-budget.sh');
908
- const budgets = path.join(ROOT, '.agents', 'rule-budgets.txt');
909
- if (fs.existsSync(rb) && fs.existsSync(budgets)) {
910
- const r = spawnSync('sh', [rb, '--all'], { cwd: ROOT, encoding: 'utf8' });
911
- if (!r.error && r.status !== 0) {
912
- const head = `${r.stdout || ''}${r.stderr || ''}`.split('\n').slice(0, 5).map((l) => ' ' + l).join('\n');
913
- warnings.push(`- [WARN 常驻面超限] 常驻面体积超预算(先删除或下沉被取代条目再增——一进一出):\n${head}`);
914
- }
915
- }
916
- }
917
-
918
- // --- 14. 新 done 的 spec/plan 须在 git 历史里出现过 `状态: approved` [warning](恒 advisory;非 git 跳过)---
919
- if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '--verify', 'HEAD']) !== null) {
920
- for (const sub of ['specs', 'plans']) {
921
- for (const doc of docFiles(sub)) {
922
- if (fmGet(doc, '状态') !== 'done') continue;
923
- const base = path.basename(doc);
924
- let d = fmGet(doc, '日期') || fmGet(doc, '发现');
925
- if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
926
- if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || d < kitPolicy.check14Since) continue; // policy.mjs check14Since
927
- const lines = linesOf(doc) || [];
928
- if (lines.some((l) => l.includes('存量确认态豁免('))) continue;
929
- const rel = path.relative(ROOT, doc).split(path.sep).join('/');
930
- // 命中判据单源(stage-gates.approvedTraceHit,2026-09-30 confirm-gate-approved-history):
931
- // 返回 ''=从未出现 / <sha>=命中 / null=git 调用失败(不报,沿本段 fail-open 口径)
932
- const hit = approvedTraceHit(ROOT, rel);
933
- if (hit !== null && hit === '') {
934
- warnings.push(`- [WARN 确认态缺失] ${base} 状态已 done 但 git 历史中从未出现行首「状态: approved」——确认环节未留痕(draft 直跳 done)`);
930
+ // --- 13. 常驻面体积预算 [warning](判定单源 rule-budget.sh;无 sh 环境静默跳过——advisory,pre-commit 硬拦兜底)---
931
+ {
932
+ const rb = path.join(ROOT, '.agents', 'scripts', 'rule-budget.sh');
933
+ const budgets = path.join(ROOT, '.agents', 'rule-budgets.txt');
934
+ if (fs.existsSync(rb) && fs.existsSync(budgets)) {
935
+ const r = spawnSync('sh', [rb, '--all'], { cwd: ROOT, encoding: 'utf8' });
936
+ if (!r.error && r.status !== 0) {
937
+ const head = `${r.stdout || ''}${r.stderr || ''}`.split('\n').slice(0, 5).map((l) => ' ' + l).join('\n');
938
+ warnings.push(`- [WARN 常驻面超限] 常驻面体积超预算(先删除或下沉被取代条目再增——一进一出):\n${head}`);
935
939
  }
936
940
  }
937
941
  }
938
- }
939
942
 
940
- // --- 15. 确认指纹对账 [hard-block](2026-09-26 confirm-gate-machine;2026-09-27 confirm-gate-delegated 两形态;
941
- // 生效日锚 2026-09-28 改台账 ts——此前锚取自文档自报「日期/发现」,新档只要把日期写早即整段跳过判定,
942
- // incident 2026-09-28-confirm-gate-effective-date-anchor 实证)---
943
- // 确认两形态(confirm-doc.mjs):TTY 亲手键入「可以」/ --delegated 对话委托代录(用户对话内明确放行后
944
- // AI 代录,台账行如实记 source=chat-delegated + quote 原话,永不伪装 TTY)。intents/specs/plans 凡
945
- // approved/done 须有 confirm-doc 产生的 frontmatter 确认指纹 + 台账(.agents/confirmations.jsonl)
946
- // 配对行——配对判据 doc/stage/fingerprint 三键,与 source 无关。
947
- // **生效日锚 = 台账 ts(2026-09-28 起)**:受管准入 = 该 doc 在台账中有合法跳转行(stage ∈ approved/done/
948
- // fixed/closed/superseded/cancelled,append-only 取末次);无台账行 = 存量豁免(不追溯)。自报日期
949
- // (日期/发现/文件名)不再参与门禁判定——它可被作者手填,把「门是否生效」的决定权交给被约束对象本身
950
- // 是自举漏洞;台账行是「确认确实发生过」的唯一机器事实。docs 与 incidents 共用同一锚(原 incidents
951
- // 独立生效日 2026-09-28 常量退役:台账起算日 = docs 生效日,两档等价)。
952
- // 台账坏行容忍跳过(审计件,jsonl 追加式);frontmatter 存 16 位、台账存 64 位,按前 16 位配对。
953
- {
954
- const EFFECTIVE = kitPolicy.confirmDocsEffective;
955
- const ledgerPath = path.join(ROOT, '.agents', 'confirmations.jsonl');
956
- const ledger = [];
957
- if (fs.existsSync(ledgerPath)) {
958
- for (const line of linesOf(ledgerPath) || []) {
959
- if (!line.trim()) continue;
960
- try { ledger.push(JSON.parse(line)); } catch { /* 坏行跳过 */ }
961
- }
962
- }
963
- // incidents 侧覆盖(2026-09-27 gate-coverage):fixed/closed 为已确认态(open = 起草态不加门——
964
- // maintain.md 创建即对话确认的既有口径)
965
- // 放弃态覆盖(2026-09-27 closing-coverage):superseded/cancelled 入配对集——confirm-doc --to 是唯一
966
- // 合法产生通道(cancelled 自 draft/approved/open/fixed;superseded 自 approved/done/fixed/closed),
967
- // 此前手改 frontmatter 即出账的口子收死
968
- const CONFIRMED_BY_SUB = {
969
- intents: ['approved', 'done', 'superseded', 'cancelled'],
970
- specs: ['approved', 'done', 'superseded', 'cancelled'],
971
- plans: ['approved', 'done', 'superseded', 'cancelled'],
972
- incidents: ['fixed', 'closed', 'superseded', 'cancelled'],
973
- };
974
- // 合法跳转 stage 集(台账行「确认确实发生过」的判据;revert-draft / revert-open 类回退注记行不算)
975
- const VALID_STAGES = new Set(['approved', 'done', 'fixed', 'closed', 'superseded', 'cancelled']);
976
- for (const [sub, confirmedSet] of Object.entries(CONFIRMED_BY_SUB)) {
977
- for (const doc of docFiles(sub)) {
978
- const st = fmGet(doc, '状态');
979
- if (!confirmedSet.includes(st)) continue;
980
- const base = path.basename(doc);
981
- const rel = path.relative(ROOT, doc).split(path.sep).join('/');
982
- // 受管准入 = 两条件取或(2026-09-28 改锚):
983
- // ① 台账中有该 doc 的合法跳转行——「确认确实发生过」的机器事实;它独立于自报日期成立,
984
- // 故堵死「确认过却把日期写早以逃掉对账」这条攻破路径(本 incident 的核心缺陷);
985
- // ② 自报日期 ≥ 生效日——覆盖「新档完全没跑 confirm-doc」的漏网面(此类无台账行,
986
- // 仅靠 ① 会被误判为存量豁免而静默放行)。
987
- // 两条件皆不满足 = 存量豁免(生效日前既有、从未走确认门,不追溯)。
988
- const docEntries = ledger.filter((e) => e && e.doc === rel && VALID_STAGES.has(e.stage));
989
- const eff = sub === 'incidents' ? kitPolicy.confirmIncidentsEffective : EFFECTIVE;
990
- let d = fmGet(doc, '日期') || fmGet(doc, '发现');
991
- if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
992
- const dateManaged = /^\d{4}-\d{2}-\d{2}$/.test(d) && d >= eff;
993
- if (!docEntries.length && !dateManaged) continue; // 存量豁免
994
- const fp = fmGet(doc, '确认指纹');
995
- const ok = !!fp && docEntries.some((e) => e.stage === st
996
- && typeof e.fingerprint === 'string' && e.fingerprint.startsWith(fp));
997
- if (!ok) {
998
- blockers.push(`- [确认未对账] ${base} 状态 ${st} 无用户确认记录——AI 不得代确认,用户在终端跑 node .agents/scripts/confirm-doc.mjs ${rel} 后重试`);
943
+ // --- 14. 新 done 的 spec/plan 须有确认留痕:git 历史「状态: approved」 或 confirmations.jsonl 台账 approved 行 [warning](恒 advisory;非 git 跳过)---
944
+ // 2026-10-08 papercuts-cleanup-batch 修 3:两跳确认同批提交时 git 历史不出现行首「状态: approved」曾误报,
945
+ // 而台账已证明两跳均走(2026-09-28-adopter-derivers 实证)——判据改 OR 并集(git 判据保留不弱化,
946
+ // 台账为第二通道;确认事件以台账为准)。台账伪造面由检查 15 指纹 hard 门把关,本修仅消 advisory 误报。
947
+ if (gitOut(['rev-parse', '--git-dir']) !== null && gitOut(['rev-parse', '-q', '--verify', 'HEAD']) !== null) {
948
+ // 台账 approved 文档集合:单次读取(坏行容忍跳过,与检查 15 同口径);缺失视为空集(git 判据仍守门)
949
+ const ledgerApproved = new Set();
950
+ try {
951
+ const lp = path.join(ROOT, '.agents', 'confirmations.jsonl');
952
+ if (fs.existsSync(lp)) {
953
+ for (const line of fs.readFileSync(lp, 'utf8').split(/\r?\n/)) {
954
+ if (!line.trim()) continue;
955
+ try {
956
+ const e = JSON.parse(line);
957
+ if (e && typeof e.doc === 'string' && e.stage === 'approved') ledgerApproved.add(e.doc);
958
+ } catch { /* 坏行容忍跳过 */ }
959
+ }
999
960
  }
1000
- // 内容绑定(2026-09-27 audit-gate-hardening;生效锚 2026-09-27 gate-hardening-p2-batch 自文档自报日期
1001
- // 改为台账 done 行 ts):done 文档当前内容须与台账 done 行确认时的内容一致——按 prev 复原跳转前文本
1002
- // 重算比对,防「确认后篡改」(改验收标准/正文均触发)。复原口径 = confirm-doc computeFingerprint 的
1003
- // 逆推:CRLF 归一 → frontmatter 首个「状态:」行保分隔符换值为台账 prev(双空格等非规范分隔符不误伤,
1004
- // 与前向按原行字面计算对称)→ 剔「确认指纹:」行。两条降级路径:台账行缺 prev(schema 演进前存量,
1005
- // 无以复原)→ warning 不拦;entry.ts 早于 2026-09-28(UTC 字符串比较——该次确认在旧关单顺序时代完成)→
1006
- // 豁免绑定(配对判定已过,3 份失配存量即此列)。关单编辑顺序新约定不变:confirm-doc 是最后一次写入,
1007
- // 此后修订走 superseded 或新 intent。
1008
- if (ok && ['done', 'closed', 'superseded', 'cancelled'].includes(st)) {
1009
- const doneEntries = ledger.filter((e) => e && e.doc === rel && e.stage === st && typeof e.fingerprint === 'string');
1010
- const entry = doneEntries[doneEntries.length - 1]; // append-only 台账,末次生效(重确认场景)
1011
- if (!entry || !entry.prev) {
1012
- warnings.push(`- [WARN 绑定降级] ${base} 台账 stage=${st} 行缺 prev 字段(schema 演进前行),内容绑定跳过——仅配对判定`);
1013
- } else if (!(typeof entry.ts === 'string' && entry.ts >= kitPolicy.bindingTs)) {
1014
- // 该 done 确认发生在生效锚前(旧关单顺序时代)——豁免内容绑定,配对判定照常
1015
- } else if (bindingSha256(linesOf(doc) || [], entry.prev) !== entry.fingerprint) {
1016
- blockers.push(`- [确认内容漂移] ${base} ${st} 后内容与确认台账不符——已关单文档不得直接改(关单编辑先于关单确认);确需修订走 superseded 或新 intent 引用`);
961
+ } catch { /* 台账不可读视为无 approved 行 */ }
962
+ for (const sub of ['specs', 'plans']) {
963
+ for (const doc of docFiles(sub)) {
964
+ if (fmGet(doc, '状态') !== 'done') continue;
965
+ const base = path.basename(doc);
966
+ let d = fmGet(doc, '日期') || fmGet(doc, '发现');
967
+ if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
968
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || d < kitPolicy.check14Since) continue; // policy.mjs check14Since
969
+ const lines = linesOf(doc) || [];
970
+ if (lines.some((l) => l.includes('存量确认态豁免('))) continue; // 保留:服务对象随修 3 自然消失,退役条件=连续两版本周期零命中
971
+ const rel = path.relative(ROOT, doc).split(path.sep).join('/');
972
+ // 命中判据单源(stage-gates.approvedTraceHit,2026-09-30 confirm-gate-approved-history):
973
+ // 返回 ''=从未出现 / <sha>=命中 / null=git 调用失败(不报,沿本段 fail-open 口径)
974
+ const hit = approvedTraceHit(ROOT, rel);
975
+ if (hit !== null && hit === '' && !ledgerApproved.has(rel)) {
976
+ warnings.push(`- [WARN 确认态缺失] ${base} 状态已 done 但 git 历史中从未出现行首「状态: approved」——确认环节未留痕(draft 直跳 done)`);
1017
977
  }
1018
978
  }
1019
979
  }
1020
980
  }
1021
- // 并录批次审计(2026-09-28 batch-ledger-audit 改判据;原 2026-09-27 confirm-gate-one-per-call 引入):
1022
- // **读调用事实,不猜时间戳模式**。判据 = 台账行按 `batch` 分组,组内 `of > 1` → warning「确认并录」。
1023
- // batch/seq/of 由 confirm-doc 在**写入时**记录(那才是「本次调用落了几份」确定已知的时刻);
1024
- // 旧判据「同 quote + 相邻 ts 差 < 2s」是拿两个间接信号反推该事实,已实证四类失效并**整段退役**:
1025
- // ① 假阳性:合规逐件调用复用同句(用户两次都说「可以」)→ 必然误报;
1026
- // ② 假阴性:并录时给每份换不同 quote → 零告警(判据的实际效果是引导伪装者改 quote);
1027
- // ③ 噪声不可消退:不按日期门豁免,退役前本仓 22 条 advisory 中 14 条为该告警,且文案自认「不可区分」;
1028
- // ④ 字段职责冲突:`quote` 同时当「对质凭据」与「并录指纹」——现归还单一职责,只记用户原话。
1029
- // 无 `batch` 字段的历史行(schema 演进前,本仓全部 67 行均无该字段)→ **静默跳过**,不降级出账、不回溯:
1030
- // 按 audit-gate-hardening P3 教训(「扫了只产生不可消除的 advisory 噪声,真漏点被淹没」),无判定依据
1031
- // 的行不该产出告警。VALID_STAGES 复用外层声明(复核 P2-3 更正:此前本块内另有一份同名声明遮蔽外层)。
981
+
982
+ // --- 15. 确认指纹对账 [hard-block](2026-09-26 confirm-gate-machine;2026-09-27 confirm-gate-delegated 两形态;
983
+ // 生效日锚 2026-09-28 改台账 ts——此前锚取自文档自报「日期/发现」,新档只要把日期写早即整段跳过判定,
984
+ // incident 2026-09-28-confirm-gate-effective-date-anchor 实证)---
985
+ // 确认两形态(confirm-doc.mjs):TTY 亲手键入「可以」/ --delegated 对话委托代录(用户对话内明确放行后
986
+ // AI 代录,台账行如实记 source=chat-delegated + quote 原话,永不伪装 TTY)。intents/specs/plans 凡
987
+ // approved/done 须有 confirm-doc 产生的 frontmatter 确认指纹 + 台账(.agents/confirmations.jsonl)
988
+ // 配对行——配对判据 doc/stage/fingerprint 三键,与 source 无关。
989
+ // **生效日锚 = 台账 ts(2026-09-28 起)**:受管准入 = 该 doc 在台账中有合法跳转行(stage ∈ approved/done/
990
+ // fixed/closed/superseded/cancelled,append-only 取末次);无台账行 = 存量豁免(不追溯)。自报日期
991
+ // (日期/发现/文件名)不再参与门禁判定——它可被作者手填,把「门是否生效」的决定权交给被约束对象本身
992
+ // 是自举漏洞;台账行是「确认确实发生过」的唯一机器事实。docs 与 incidents 共用同一锚(原 incidents
993
+ // 独立生效日 2026-09-28 常量退役:台账起算日 = docs 生效日,两档等价)。
994
+ // 台账坏行容忍跳过(审计件,jsonl 追加式);frontmatter 存 16 位、台账存 64 位,按前 16 位配对。
1032
995
  {
1033
- const byBatch = new Map(); // batch → 该批的台账行(仅计合法跳转 stage)
1034
- for (const e of ledger) {
1035
- if (!e || !VALID_STAGES.has(e.stage)) continue;
1036
- if (typeof e.batch !== 'string' || !e.batch) continue; // 历史行无 batch → 无判定依据,跳过
1037
- if (!byBatch.has(e.batch)) byBatch.set(e.batch, []);
1038
- byBatch.get(e.batch).push(e);
996
+ const EFFECTIVE = kitPolicy.confirmDocsEffective;
997
+ const ledgerPath = path.join(ROOT, '.agents', 'confirmations.jsonl');
998
+ const ledger = [];
999
+ if (fs.existsSync(ledgerPath)) {
1000
+ for (const line of linesOf(ledgerPath) || []) {
1001
+ if (!line.trim()) continue;
1002
+ try { ledger.push(JSON.parse(line)); } catch { /* 坏行跳过 */ }
1003
+ }
1004
+ }
1005
+ // incidents 侧覆盖(2026-09-27 gate-coverage):fixed/closed 为已确认态(open = 起草态不加门——
1006
+ // maintain.md 创建即对话确认的既有口径)
1007
+ // 放弃态覆盖(2026-09-27 closing-coverage):superseded/cancelled 入配对集——confirm-doc --to 是唯一
1008
+ // 合法产生通道(cancelled 自 draft/approved/open/fixed;superseded 自 approved/done/fixed/closed),
1009
+ // 此前手改 frontmatter 即出账的口子收死
1010
+ const CONFIRMED_BY_SUB = {
1011
+ intents: ['approved', 'done', 'superseded', 'cancelled'],
1012
+ specs: ['approved', 'done', 'superseded', 'cancelled'],
1013
+ plans: ['approved', 'done', 'superseded', 'cancelled'],
1014
+ incidents: ['fixed', 'closed', 'superseded', 'cancelled'],
1015
+ };
1016
+ // 合法跳转 stage 集(台账行「确认确实发生过」的判据;revert-draft / revert-open 类回退注记行不算)
1017
+ const VALID_STAGES = new Set(['approved', 'done', 'fixed', 'closed', 'superseded', 'cancelled']);
1018
+ for (const [sub, confirmedSet] of Object.entries(CONFIRMED_BY_SUB)) {
1019
+ for (const doc of docFiles(sub)) {
1020
+ const st = fmGet(doc, '状态');
1021
+ if (!confirmedSet.includes(st)) continue;
1022
+ const base = path.basename(doc);
1023
+ const rel = path.relative(ROOT, doc).split(path.sep).join('/');
1024
+ // 受管准入 = 两条件取或(2026-09-28 改锚):
1025
+ // ① 台账中有该 doc 的合法跳转行——「确认确实发生过」的机器事实;它独立于自报日期成立,
1026
+ // 故堵死「确认过却把日期写早以逃掉对账」这条攻破路径(本 incident 的核心缺陷);
1027
+ // ② 自报日期 ≥ 生效日——覆盖「新档完全没跑 confirm-doc」的漏网面(此类无台账行,
1028
+ // 仅靠 ① 会被误判为存量豁免而静默放行)。
1029
+ // 两条件皆不满足 = 存量豁免(生效日前既有、从未走确认门,不追溯)。
1030
+ const docEntries = ledger.filter((e) => e && e.doc === rel && VALID_STAGES.has(e.stage));
1031
+ const eff = sub === 'incidents' ? kitPolicy.confirmIncidentsEffective : EFFECTIVE;
1032
+ let d = fmGet(doc, '日期') || fmGet(doc, '发现');
1033
+ if (!d) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
1034
+ const dateManaged = /^\d{4}-\d{2}-\d{2}$/.test(d) && d >= eff;
1035
+ if (!docEntries.length && !dateManaged) continue; // 存量豁免
1036
+ const fp = fmGet(doc, '确认指纹');
1037
+ const ok = !!fp && docEntries.some((e) => e.stage === st
1038
+ && typeof e.fingerprint === 'string' && e.fingerprint.startsWith(fp));
1039
+ if (!ok) {
1040
+ blockers.push(`- [确认未对账] ${base} 状态 ${st} 无用户确认记录——AI 不得代确认,用户在终端跑 node .agents/scripts/confirm-doc.mjs ${rel} 后重试`);
1041
+ }
1042
+ // 内容绑定(2026-09-27 audit-gate-hardening;生效锚 2026-09-27 gate-hardening-p2-batch 自文档自报日期
1043
+ // 改为台账 done 行 ts):done 文档当前内容须与台账 done 行确认时的内容一致——按 prev 复原跳转前文本
1044
+ // 重算比对,防「确认后篡改」(改验收标准/正文均触发)。复原口径 = confirm-doc computeFingerprint 的
1045
+ // 逆推:CRLF 归一 → frontmatter 首个「状态:」行保分隔符换值为台账 prev(双空格等非规范分隔符不误伤,
1046
+ // 与前向按原行字面计算对称)→ 剔「确认指纹:」行。两条降级路径:台账行缺 prev(schema 演进前存量,
1047
+ // 无以复原)→ warning 不拦;entry.ts 早于 2026-09-28(UTC 字符串比较——该次确认在旧关单顺序时代完成)→
1048
+ // 豁免绑定(配对判定已过,3 份失配存量即此列)。关单编辑顺序新约定不变:confirm-doc 是最后一次写入,
1049
+ // 此后修订走 superseded 或新 intent。
1050
+ if (ok && ['done', 'closed', 'superseded', 'cancelled'].includes(st)) {
1051
+ const doneEntries = ledger.filter((e) => e && e.doc === rel && e.stage === st && typeof e.fingerprint === 'string');
1052
+ const entry = doneEntries[doneEntries.length - 1]; // append-only 台账,末次生效(重确认场景)
1053
+ if (!entry || !entry.prev) {
1054
+ warnings.push(`- [WARN 绑定降级] ${base} 台账 stage=${st} 行缺 prev 字段(schema 演进前行),内容绑定跳过——仅配对判定`);
1055
+ } else if (!(typeof entry.ts === 'string' && entry.ts >= kitPolicy.bindingTs)) {
1056
+ // 该 done 确认发生在生效锚前(旧关单顺序时代)——豁免内容绑定,配对判定照常
1057
+ } else if (bindingSha256(linesOf(doc) || [], entry.prev) !== entry.fingerprint) {
1058
+ blockers.push(`- [确认内容漂移] ${base} ${st} 后内容与确认台账不符——已关单文档不得直接改(关单编辑先于关单确认);确需修订走 superseded 或新 intent 引用`);
1059
+ }
1060
+ }
1061
+ }
1039
1062
  }
1040
- for (const [b, rows] of byBatch) {
1041
- // 判据取组内**任一行** of>1(复核 P2-3 更正:原取 rows[0].of 使结论依赖行序——同批首行 of=1、
1042
- // 次行 of=2 会漏报。confirm-doc 对同批所有行写同一个 of=docs.length,故正常批次组内一致;
1043
- // 改用 some 消除该顺序脆弱性,并对 craft 出来的不一致批次按「最大值」报,方向偏严不偏松)
1044
- if (!rows.some((r) => typeof r.of === 'number' && r.of > 1)) continue;
1045
- // of > 1 = 一次调用落多份态。delegated 形态下这正是「并录」(入口已拒多份,此处抓历史/绕行);
1046
- // TTY 形态天然逐份过目(用户亲手键入),of>1 不构成违规——故只对 delegated 行报。
1047
- if (!rows.some((r) => r.source === 'chat-delegated')) continue;
1048
- // 协作道批量豁免(2026-09-30 hybrid-governance-risk-lanes):confirm-doc --batch 仅受理 L0/L1
1049
- // (逐份读级别,L2/L3 逐份拒绝),台账行带 brief:true——全行带标记的批次是 sanctioned 泳道行为
1050
- // (异步审计兜底),不出「确认并录」;缺标记(旧版脚本/手造台账)照旧告警,判据偏严不偏松。
1051
- if (rows.every((r) => r.brief === true)) continue;
1052
- const of = Math.max(...rows.map((r) => (typeof r.of === 'number' ? r.of : 0)));
1053
- const docs = rows.map((r) => String(r.doc).replace(/^workflow\//, '')).join('、');
1054
- 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`);
1063
+ // 并录批次审计(2026-09-28 batch-ledger-audit 改判据;原 2026-09-27 confirm-gate-one-per-call 引入):
1064
+ // **读调用事实,不猜时间戳模式**。判据 = 台账行按 `batch` 分组,组内 `of > 1` → warning「确认并录」。
1065
+ // batch/seq/of 由 confirm-doc 在**写入时**记录(那才是「本次调用落了几份」确定已知的时刻);
1066
+ // 旧判据「同 quote + 相邻 ts 差 < 2s」是拿两个间接信号反推该事实,已实证四类失效并**整段退役**:
1067
+ // ① 假阳性:合规逐件调用复用同句(用户两次都说「可以」)→ 必然误报;
1068
+ // ② 假阴性:并录时给每份换不同 quote → 零告警(判据的实际效果是引导伪装者改 quote);
1069
+ // ③ 噪声不可消退:不按日期门豁免,退役前本仓 22 条 advisory 中 14 条为该告警,且文案自认「不可区分」;
1070
+ // ④ 字段职责冲突:`quote` 同时当「对质凭据」与「并录指纹」——现归还单一职责,只记用户原话。
1071
+ // 无 `batch` 字段的历史行(schema 演进前,本仓全部 67 行均无该字段)→ **静默跳过**,不降级出账、不回溯:
1072
+ // 按 audit-gate-hardening P3 教训(「扫了只产生不可消除的 advisory 噪声,真漏点被淹没」),无判定依据
1073
+ // 的行不该产出告警。VALID_STAGES 复用外层声明(复核 P2-3 更正:此前本块内另有一份同名声明遮蔽外层)。
1074
+ {
1075
+ const byBatch = new Map(); // batch → 该批的台账行(仅计合法跳转 stage)
1076
+ for (const e of ledger) {
1077
+ if (!e || !VALID_STAGES.has(e.stage)) continue;
1078
+ if (typeof e.batch !== 'string' || !e.batch) continue; // 历史行无 batch → 无判定依据,跳过
1079
+ if (!byBatch.has(e.batch)) byBatch.set(e.batch, []);
1080
+ byBatch.get(e.batch).push(e);
1081
+ }
1082
+ for (const [b, rows] of byBatch) {
1083
+ // 判据取组内**任一行** of>1(复核 P2-3 更正:原取 rows[0].of 使结论依赖行序——同批首行 of=1、
1084
+ // 次行 of=2 会漏报。confirm-doc 对同批所有行写同一个 of=docs.length,故正常批次组内一致;
1085
+ // 改用 some 消除该顺序脆弱性,并对 craft 出来的不一致批次按「最大值」报,方向偏严不偏松)
1086
+ if (!rows.some((r) => typeof r.of === 'number' && r.of > 1)) continue;
1087
+ // of > 1 = 一次调用落多份态。delegated 形态下这正是「并录」(入口已拒多份,此处抓历史/绕行);
1088
+ // TTY 形态天然逐份过目(用户亲手键入),of>1 不构成违规——故只对 delegated 行报。
1089
+ if (!rows.some((r) => r.source === 'chat-delegated')) continue;
1090
+ // 协作道批量豁免(2026-09-30 hybrid-governance-risk-lanes):confirm-doc --batch 仅受理 L0/L1
1091
+ // (逐份读级别,L2/L3 逐份拒绝),台账行带 brief:true——全行带标记的批次是 sanctioned 泳道行为
1092
+ // (异步审计兜底),不出「确认并录」;缺标记(旧版脚本/手造台账)照旧告警,判据偏严不偏松。
1093
+ if (rows.every((r) => r.brief === true)) continue;
1094
+ const of = Math.max(...rows.map((r) => (typeof r.of === 'number' ? r.of : 0)));
1095
+ const docs = rows.map((r) => String(r.doc).replace(/^workflow\//, '')).join('、');
1096
+ 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`);
1097
+ }
1055
1098
  }
1056
1099
  }
1057
- }
1058
1100
 
1059
- // --- 16. 量化断言指标签名对账 [warning](2026-09-28 claim-exceeds-fix;登记表单源 .agents/metric-claims.txt)---
1060
- // 判据要点:活跃态文档(draft/approved/open)中的 `{{指标名}}`(小写点分)签名须替换为实时值,留签名=未回填=
1061
- // warning;只查显式签名、不全文扫数字(历史叙述假阳性恒 0);未登记签名 / 取数器缺失 → fail-loud 出账;
1062
- // 登记表缺失 → 静默跳过(未启用该检查的装户零噪声);装户自有指标走 .agents/metric-derivers.cjs(CJS 契约,
1063
- // 内置优先)。实现自 2026-09-28 check16-inline-debt 起迁出至 check-metric-claims.mjs(原内联块,判据语义/
1064
- // 消息文案/exit 语义零变化,迁出经全量输出 diff 基线验证)。ctx 显式携带本检查引用的外层符号
1065
- // (ROOT/ENUMS/docFiles/fmGet/inSet/isTracked/linesOf/readdirOrNull/warnings,以代码实际引用集为准);
1066
- // 调用位置须保持在检查 15 之后、输出段之前——warnings 按插入序输出,位置变化会改变输出行序。
1067
- runCheck16({ ROOT, ENUMS, docFiles, fmGet, inSet, isTracked, linesOf, readdirOrNull, warnings });
1101
+ // --- 16. 量化断言指标签名对账 [warning](2026-09-28 claim-exceeds-fix;登记表单源 .agents/metric-claims.txt)---
1102
+ // 判据要点:活跃态文档(draft/approved/open)中的 `{{指标名}}`(小写点分)签名须替换为实时值,留签名=未回填=
1103
+ // warning;只查显式签名、不全文扫数字(历史叙述假阳性恒 0);未登记签名 / 取数器缺失 → fail-loud 出账;
1104
+ // 登记表缺失 → 静默跳过(未启用该检查的装户零噪声);装户自有指标走 .agents/metric-derivers.cjs(CJS 契约,
1105
+ // 内置优先)。实现自 2026-09-28 check16-inline-debt 起迁出至 check-metric-claims.mjs(原内联块,判据语义/
1106
+ // 消息文案/exit 语义零变化,迁出经全量输出 diff 基线验证)。ctx 显式携带本检查引用的外层符号
1107
+ // (ROOT/ENUMS/docFiles/fmGet/inSet/isTracked/linesOf/readdirOrNull/warnings,以代码实际引用集为准);
1108
+ // 调用位置须保持在检查 15 之后、输出段之前——warnings 按插入序输出,位置变化会改变输出行序。
1109
+ runCheck16({ ROOT, ENUMS, docFiles, fmGet, inSet, isTracked, linesOf, readdirOrNull, warnings });
1068
1110
 
1069
- // --- 17. 发版提交树上仍未收口的 intent/spec/plan [hard-block] ---
1070
- // 最近一次 package.json version 发生变化的提交里,当时状态已是 draft 或 approved 的
1071
- // intent/spec/plan,被扫描的树上仍是 draft 或 approved 则阻断。
1072
- // 当时已是 draft:不看版本锚。当时已是 approved:只在发版版本大于
1073
- // policy.check17UnclosedAfter 时阻断。版本 1 没有该键,因此不启用后两格。
1074
- // 版本按主、次、修订三段整数比较。沿 package.json 的全部历史查找,不设次数上限。
1075
- // 该提交之后新建的文件不在范围内。open 的 incident 不在此列。
1076
- {
1077
- const log = gitOut(['log', '--format=%H', '--', 'package.json']);
1078
- if (log) {
1079
- let release = null;
1080
- let releaseVer = '';
1081
- for (const sha of log.split('\n').map((s) => s.trim()).filter(Boolean)) {
1082
- const curTxt = gitOut(['show', `${sha}:package.json`]);
1083
- if (!curTxt) continue;
1084
- let cur = null;
1085
- try { cur = JSON.parse(curTxt).version; } catch { continue; }
1086
- const prevTxt = gitOut(['show', `${sha}^:package.json`]);
1087
- let prev = null;
1088
- if (prevTxt) { try { prev = JSON.parse(prevTxt).version; } catch { prev = null; } }
1089
- if (cur && cur !== prev) { release = sha; releaseVer = cur; break; }
1090
- }
1091
- const tree = release && gitOut(['ls-tree', '-r', '--name-only', release, '--', `${WF}/intents`, `${WF}/specs`, `${WF}/plans`]);
1092
- if (release && tree) {
1093
- for (const rel of tree.split('\n').map((s) => s.trim()).filter(Boolean)) {
1094
- if (!rel.endsWith('.md') || rel.endsWith('_TEMPLATE.md')) continue;
1095
- const thenTxt = gitOut(['show', `${release}:${rel}`]);
1096
- if (!thenTxt) continue;
1097
- const thenSt = fmStatus(thenTxt);
1098
- const nowSt = fmGet(path.join(ROOT, rel), '状态');
1099
- if ((thenSt !== 'draft' && thenSt !== 'approved') || (nowSt !== 'draft' && nowSt !== 'approved')) continue;
1100
- if (thenSt === 'approved' && !versionGreater(releaseVer, kitPolicy.check17UnclosedAfter)) continue;
1101
- const here = REV ? `提交 ${REV.slice(0, 7)}` : '工作区';
1102
- blockers.push(`- [发版草稿] ${rel} 在 version=${releaseVer} 的提交 ${release.slice(0, 7)} 上已是 ${thenSt},${here}仍是 ${nowSt}。发版提交树上未收口的 intent/spec/plan 须先离开 draft 与 approved。覆盖面只含该提交当时已在树上的 intent/spec/plan;其后新建的文件不在此列`);
1111
+ // --- 17. 发版提交树上仍未收口的 intent/spec/plan [hard-block] ---
1112
+ // 最近一次 package.json version 发生变化的提交里,当时状态已是 draft 或 approved 的
1113
+ // intent/spec/plan,被扫描的树上仍是 draft 或 approved 则阻断。
1114
+ // 当时已是 draft:不看版本锚。当时已是 approved:只在发版版本大于
1115
+ // policy.check17UnclosedAfter 时阻断。版本 1 没有该键,因此不启用后两格。
1116
+ // 版本按主、次、修订三段整数比较。沿 package.json 的全部历史查找,不设次数上限。
1117
+ // 该提交之后新建的文件不在范围内。open 的 incident 不在此列。
1118
+ {
1119
+ const log = gitOut(['log', '--format=%H', '--', 'package.json']);
1120
+ if (log) {
1121
+ let release = null;
1122
+ let releaseVer = '';
1123
+ for (const sha of log.split('\n').map((s) => s.trim()).filter(Boolean)) {
1124
+ const curTxt = gitOut(['show', `${sha}:package.json`]);
1125
+ if (!curTxt) continue;
1126
+ let cur = null;
1127
+ try { cur = JSON.parse(curTxt).version; } catch { continue; }
1128
+ const prevTxt = gitOut(['show', `${sha}^:package.json`]);
1129
+ let prev = null;
1130
+ if (prevTxt) { try { prev = JSON.parse(prevTxt).version; } catch { prev = null; } }
1131
+ if (cur && cur !== prev) { release = sha; releaseVer = cur; break; }
1132
+ }
1133
+ const tree = release && gitOut(['ls-tree', '-r', '--name-only', release, '--', `${WF}/intents`, `${WF}/specs`, `${WF}/plans`]);
1134
+ if (release && tree) {
1135
+ for (const rel of tree.split('\n').map((s) => s.trim()).filter(Boolean)) {
1136
+ if (!rel.endsWith('.md') || rel.endsWith('_TEMPLATE.md')) continue;
1137
+ const thenTxt = gitOut(['show', `${release}:${rel}`]);
1138
+ if (!thenTxt) continue;
1139
+ const thenSt = fmStatus(thenTxt);
1140
+ const nowSt = fmGet(path.join(ROOT, rel), '状态');
1141
+ if ((thenSt !== 'draft' && thenSt !== 'approved') || (nowSt !== 'draft' && nowSt !== 'approved')) continue;
1142
+ if (thenSt === 'approved' && !versionGreater(releaseVer, kitPolicy.check17UnclosedAfter)) continue;
1143
+ const here = REV ? `提交 ${REV.slice(0, 7)}` : '工作区';
1144
+ blockers.push(`- [发版草稿] ${rel} 在 version=${releaseVer} 的提交 ${release.slice(0, 7)} 上已是 ${thenSt},${here}仍是 ${nowSt}。发版提交树上未收口的 intent/spec/plan 须先离开 draft 与 approved。覆盖面只含该提交当时已在树上的 intent/spec/plan;其后新建的文件不在此列`);
1145
+ }
1103
1146
  }
1104
1147
  }
1105
1148
  }
1106
- }
1107
1149
 
1108
- // --- 18. 委派台账对账 [warning] ---
1109
- // L2/L3 的 intent、spec、plan 状态为 done,或同级别 incident 状态为 fixed 或 closed 时,
1110
- // 「委派结果」表与「自做任务结果」表均没有日期不早于文档日期、且含该文件名(带/不带 .md 均匹配)的一行,则警告。
1111
- // 缺级别、缺日期、以及其他状态不警告。audit:false 时 warnings.push 已被换成空函数。
1112
- // 两表解析口径与 agg-delegations.cjs splitTables 保持一致,一边改另一边须跟。
1113
- {
1114
- const rows = delegationResultRows(path.join(ROOT, WF, 'delegations.md'));
1115
- for (const sub of ['intents', 'specs', 'plans', 'incidents']) {
1116
- for (const abs of docFiles(sub)) {
1117
- const lvl = fmGet(abs, '级别');
1118
- if (lvl !== 'L2' && lvl !== 'L3') continue;
1119
- const st = fmGet(abs, '状态');
1120
- const inScope = sub === 'incidents' ? (st === 'fixed' || st === 'closed') : st === 'done';
1121
- if (!inScope) continue;
1122
- const date = sub === 'incidents' ? (fmGet(abs, '发现') || fmGet(abs, '日期')) : fmGet(abs, '日期');
1123
- if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
1124
- // delegationSince 生效日豁免(policy v4,2026-10-06 loop-audit-remediation):锚前文档为存量回溯件
1125
- // ——台账记法约定(delegations.md 头部「任务一句话含文件名」)自生效日才立,存量不回填;缺键
1126
- // (v1-v3)→ 维持原全量对账行为(向后兼容,检查面不放松)
1127
- if (typeof kitPolicy.delegationSince === 'string' && date < kitPolicy.delegationSince) continue;
1128
- const base = path.basename(abs);
1129
- const stripped = base.replace(/\.md$/, '');
1130
- if (rows.some((r) => r.date >= date && (r.line.includes(base) || r.line.includes(stripped)))) continue;
1131
- const rel = path.relative(ROOT, abs).split(path.sep).join('/');
1132
- warnings.push(`- [WARN 委派台账] ${rel} 在 ${date} 及之后的委派结果/自做任务结果表中没有该文件名`);
1150
+ // --- 18. 委派台账对账 [warning] ---
1151
+ // L2/L3 的 intent、spec、plan 状态为 done,或同级别 incident 状态为 fixed 或 closed 时,
1152
+ // 「委派结果」表与「自做任务结果」表均没有日期不早于文档日期、且含该文件名(带/不带 .md 均匹配)的一行,则警告。
1153
+ // 缺级别、缺日期、以及其他状态不警告。audit:false 时 warnings.push 已被换成空函数。
1154
+ // 两表解析口径与 agg-delegations.cjs splitTables 保持一致,一边改另一边须跟。
1155
+ {
1156
+ const rows = delegationResultRows(path.join(ROOT, WF, 'delegations.md'));
1157
+ for (const sub of ['intents', 'specs', 'plans', 'incidents']) {
1158
+ for (const abs of docFiles(sub)) {
1159
+ const lvl = fmGet(abs, '级别');
1160
+ if (lvl !== 'L2' && lvl !== 'L3') continue;
1161
+ const st = fmGet(abs, '状态');
1162
+ const inScope = sub === 'incidents' ? (st === 'fixed' || st === 'closed') : st === 'done';
1163
+ if (!inScope) continue;
1164
+ const date = sub === 'incidents' ? (fmGet(abs, '发现') || fmGet(abs, '日期')) : fmGet(abs, '日期');
1165
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
1166
+ // delegationSince 生效日豁免(policy v4,2026-10-06 loop-audit-remediation):锚前文档为存量回溯件
1167
+ // ——台账记法约定(delegations.md 头部「任务一句话含文件名」)自生效日才立,存量不回填;缺键
1168
+ // (v1-v3)→ 维持原全量对账行为(向后兼容,检查面不放松)
1169
+ if (typeof kitPolicy.delegationSince === 'string' && date < kitPolicy.delegationSince) continue;
1170
+ const base = path.basename(abs);
1171
+ const stripped = base.replace(/\.md$/, '');
1172
+ if (rows.some((r) => r.date >= date && (r.line.includes(base) || r.line.includes(stripped)))) continue;
1173
+ const rel = path.relative(ROOT, abs).split(path.sep).join('/');
1174
+ warnings.push(`- [WARN 委派台账] ${rel} 在 ${date} 及之后的委派结果/自做任务结果表中没有该文件名`);
1175
+ }
1133
1176
  }
1134
1177
  }
1135
- }
1136
1178
 
1137
- // --- 19. 逐阶段审计 [warning](2026-09-30 stage-gate-machine;口径与 stage-gates.mjs / fill-* / confirm-doc 前置门互引) ---
1138
- // 判据 A(在途扫描):同名 spec/plan 为 draft 且其日期 ≥ stageGateSince 时——入口未确认(intent:状态非
1139
- // approved/done、或缺台账行且非存量;incident:「时间线」小节缺行首「用户确认」条目)→ warning;
1140
- // 入口缺合法「级别」→ warning(与起草门 fail-closed 同口径,2026-09-30 复核 P2-7);
1141
- // plan 另判(入口级别 L2/L3 且 spec 未确认或缺失)→ warning。
1142
- // 判据 B(台账顺序):同主题 {intents,specs,plans} 的 approved 行(每 doc 取最早)时间须非降序;仅对
1143
- // 组内最早 ts 日期 ≥ stageGateSince 的主题判定;含更早 ts 的主题整组跳过(历史豁免)。
1144
- // 协作道豁免(2026-09-30 hybrid-governance-risk-lanes):入口 intent 为 draft 且级别 L0/L1 → 判据 A 静默
1145
- // (「先动手后确认」是泳道语义;approved/done 无台账行仍照报);判据 B 对入口 L0/L1 的主题豁免顺序
1146
- // 倒置判定(入口文件缺失不豁免)。
1147
- // stageGateSince 缺键(policy v1)→ 本检查整体跳过;全部 warning、不 hard-block。
1148
- {
1149
- const since = kitPolicy.stageGateSince;
1150
- if (typeof since === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(since)) {
1151
- const ledgerRows = [];
1152
- {
1153
- const lp = path.join(ROOT, '.agents', 'confirmations.jsonl');
1154
- if (fs.existsSync(lp)) {
1155
- for (const line of linesOf(lp) || []) {
1156
- if (!line.trim()) continue;
1157
- try { ledgerRows.push(JSON.parse(line)); } catch { /* 坏行跳过(同检查 15 口径) */ }
1179
+ // --- 19. 逐阶段审计 [warning](2026-09-30 stage-gate-machine;口径与 stage-gates.mjs / fill-* / confirm-doc 前置门互引) ---
1180
+ // 判据 A(在途扫描):同名 spec/plan 为 draft 且其日期 ≥ stageGateSince 时——入口未确认(intent:状态非
1181
+ // approved/done、或缺台账行且非存量;incident:「时间线」小节缺行首「用户确认」条目)→ warning;
1182
+ // 入口缺合法「级别」→ warning(与起草门 fail-closed 同口径,2026-09-30 复核 P2-7);
1183
+ // plan 另判(入口级别 L2/L3 且 spec 未确认或缺失)→ warning。
1184
+ // 判据 B(台账顺序):同主题 {intents,specs,plans} 的 approved 行(每 doc 取最早)时间须非降序;仅对
1185
+ // 组内最早 ts 日期 ≥ stageGateSince 的主题判定;含更早 ts 的主题整组跳过(历史豁免)。
1186
+ // 协作道豁免(2026-09-30 hybrid-governance-risk-lanes):入口 intent 为 draft 且级别 L0/L1 → 判据 A 静默
1187
+ // (「先动手后确认」是泳道语义;approved/done 无台账行仍照报);判据 B 对入口 L0/L1 的主题豁免顺序
1188
+ // 倒置判定(入口文件缺失不豁免)。
1189
+ // stageGateSince 缺键(policy v1)→ 本检查整体跳过;全部 warning、不 hard-block。
1190
+ {
1191
+ const since = kitPolicy.stageGateSince;
1192
+ if (typeof since === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(since)) {
1193
+ const ledgerRows = [];
1194
+ {
1195
+ const lp = path.join(ROOT, '.agents', 'confirmations.jsonl');
1196
+ if (fs.existsSync(lp)) {
1197
+ for (const line of linesOf(lp) || []) {
1198
+ if (!line.trim()) continue;
1199
+ try { ledgerRows.push(JSON.parse(line)); } catch { /* 坏行跳过(同检查 15 口径) */ }
1200
+ }
1158
1201
  }
1159
1202
  }
1160
- }
1161
- const hasRow = (rel) => ledgerRows.some((e) => e && e.doc === rel && (e.stage === 'approved' || e.stage === 'done'));
1162
- const docConfirmed = (rel, abs) => {
1163
- const st = fmGet(abs, '状态');
1164
- if (st !== 'approved' && st !== 'done') return { ok: false, legacy: false, why: `状态「${st || '缺失'}」` };
1165
- if (hasRow(rel)) return { ok: true, level: fmGet(abs, '级别'), legacy: false };
1166
- const d = fmGet(abs, '日期') || fmGet(abs, '发现');
1167
- const legacy = (linesOf(abs) || []).some((l) => /^流程: legacy/.test(l))
1168
- || (/^\d{4}-\d{2}-\d{2}$/.test(d) && d < kitPolicy.confirmDocsEffective);
1169
- if (legacy) return { ok: true, level: fmGet(abs, '级别'), legacy: true };
1170
- return { ok: false, legacy: false, why: '无确认台账行' };
1171
- };
1172
- // incident 留痕判定:**直接复用 stage-gates.mjs 的 MARK_RE**(第七轮复核 N3:此前此处内联复制
1173
- // 一份同口径字面量,两处靠注释人工同步、无测试钉住——改为单源复用,从结构上消除漂移可能)。
1174
- // 判据同源:「## 时间线」小节内、行首列表条目含「用户确认」(词边界见 MARK_RE)。
1175
- const incidentMarked19 = (lines) => {
1176
- let inTimeline = false;
1177
- for (const l of lines || []) {
1178
- if (/^##\s/.test(l)) { inTimeline = /^##\s*时间线/.test(l); continue; }
1179
- if (inTimeline && MARK_RE.test(l)) return true;
1180
- }
1181
- return false;
1182
- };
1183
- const entryConfirmed19 = (base) => {
1184
- const irel = `${WF}/intents/${base}.md`;
1185
- const iabs = path.join(ROOT, irel);
1186
- if (fs.existsSync(iabs)) {
1187
- const st19 = fmGet(iabs, '状态');
1188
- const lvl19 = fmGet(iabs, '级别');
1189
- const r = docConfirmed(irel, iabs);
1190
- const body = (linesOf(iabs) || []).join('\n');
1191
- const d19 = fmGet(iabs, '日期') || fmGet(iabs, '发现');
1192
- const storLegacy = /^流程: legacy/m.test(body) || (/^\d{4}-\d{2}-\d{2}$/.test(d19) && d19 < kitPolicy.confirmDocsEffective);
1193
- return { ...r, level: lvl19, risk: fmGet(iabs, 'risk_level'), date: d19, entryDraft: st19 === 'draft', storageLegacy: storLegacy };
1194
- }
1195
- const crel = `${WF}/incidents/${base}.md`;
1196
- const cabs = path.join(ROOT, crel);
1197
- if (fs.existsSync(cabs)) {
1198
- const st = fmGet(cabs, '状态');
1199
- if ((st === 'open' || st === 'fixed' || st === 'closed') && incidentMarked19(linesOf(cabs))) {
1200
- return { ok: true, level: fmGet(cabs, '级别'), legacy: false, storageLegacy: false };
1203
+ const hasRow = (rel) => ledgerRows.some((e) => e && e.doc === rel && (e.stage === 'approved' || e.stage === 'done'));
1204
+ const docConfirmed = (rel, abs) => {
1205
+ const st = fmGet(abs, '状态');
1206
+ if (st !== 'approved' && st !== 'done') return { ok: false, legacy: false, why: `状态「${st || '缺失'}」` };
1207
+ if (hasRow(rel)) return { ok: true, level: fmGet(abs, '级别'), legacy: false };
1208
+ const d = fmGet(abs, '日期') || fmGet(abs, '发现');
1209
+ const legacy = (linesOf(abs) || []).some((l) => /^流程: legacy/.test(l))
1210
+ || (/^\d{4}-\d{2}-\d{2}$/.test(d) && d < kitPolicy.confirmDocsEffective);
1211
+ if (legacy) return { ok: true, level: fmGet(abs, '级别'), legacy: true };
1212
+ return { ok: false, legacy: false, why: '无确认台账行' };
1213
+ };
1214
+ // incident 留痕判定:**直接复用 stage-gates.mjs 的 MARK_RE**(第七轮复核 N3:此前此处内联复制
1215
+ // 一份同口径字面量,两处靠注释人工同步、无测试钉住——改为单源复用,从结构上消除漂移可能)。
1216
+ // 判据同源:「## 时间线」小节内、行首列表条目含「用户确认」(词边界见 MARK_RE)。
1217
+ const incidentMarked19 = (lines) => {
1218
+ let inTimeline = false;
1219
+ for (const l of lines || []) {
1220
+ if (/^##\s/.test(l)) { inTimeline = /^##\s*时间线/.test(l); continue; }
1221
+ if (inTimeline && MARK_RE.test(l)) return true;
1201
1222
  }
1202
- return { ok: false, legacy: false, storageLegacy: false, why: 'incident 未见过目留痕(时间线条目缺失)' };
1203
- }
1204
- return { ok: false, legacy: false, storageLegacy: false, why: '无同名入口' };
1205
- };
1206
- for (const sub of ['specs', 'plans']) {
1207
- for (const abs of docFiles(sub)) {
1208
- if (fmGet(abs, '状态') !== 'draft') continue;
1209
- const base = path.basename(abs).replace(/\.md$/, '');
1210
- // 日期:frontmatter 优先,缺则回落文件名前缀(plan 无「日期」字段;沿检查 15 口径)
1211
- let d = fmGet(abs, '日期');
1212
- if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
1213
- if (!(/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= since)) continue;
1214
- const rel = path.relative(ROOT, abs).split(path.sep).join('/');
1215
- const ent = entryConfirmed19(base);
1216
- if (!ent.ok) {
1217
- // 协作道异步审计豁免(2026-09-30 hybrid-governance-risk-lanes;2026-10-02 caliber-convergence
1218
- // 起单源 laneOfEntry·方案 C):入口 intent 仍为 draft 且泳道 low(两字段一致 L0/L1)→「先动手后
1219
- // 确认」是泳道语义,不告警;suspect(缺失/非法/分歧)不豁免;approved/done 却无台账行仍照报
1220
- // (防手改状态冒充确认,与交叉一致性口径同);incident 入口不豁免(过目留痕本就轻量)。
1221
- if (ent.entryDraft && laneOfEntry(ent.level, ent.risk, kitPolicy.riskLevelSince, ent.date) === 'low') continue;
1222
- warnings.push(`- [WARN 逐阶段] ${rel} 起草先于入口确认(${ent.why})`); continue;
1223
+ return false;
1224
+ };
1225
+ const entryConfirmed19 = (base) => {
1226
+ const irel = `${WF}/intents/${base}.md`;
1227
+ const iabs = path.join(ROOT, irel);
1228
+ if (fs.existsSync(iabs)) {
1229
+ const st19 = fmGet(iabs, '状态');
1230
+ const lvl19 = fmGet(iabs, '级别');
1231
+ const r = docConfirmed(irel, iabs);
1232
+ const body = (linesOf(iabs) || []).join('\n');
1233
+ const d19 = fmGet(iabs, '日期') || fmGet(iabs, '发现');
1234
+ const storLegacy = /^流程: legacy/m.test(body) || (/^\d{4}-\d{2}-\d{2}$/.test(d19) && d19 < kitPolicy.confirmDocsEffective);
1235
+ return { ...r, level: lvl19, risk: fmGet(iabs, 'risk_level'), date: d19, entryDraft: st19 === 'draft', storageLegacy: storLegacy };
1223
1236
  }
1224
- // 库存量形态(标记 / 日期早于生效日)短路 level 校验与 spec 档(与起草门同口径,N1)
1225
- const storLegacy = ent.storageLegacy === true;
1226
- if (!storLegacy && !/^L[0-3]$/.test(ent.level)) { warnings.push(`- [WARN 逐阶段] ${rel} 入口缺合法「级别」字段(无法判定前置深度;与起草门 fail-closed 同口径,库存量形态除外)`); continue; }
1227
- if (sub === 'plans' && !storLegacy && (ent.level === 'L2' || ent.level === 'L3')) {
1228
- const srel = `${WF}/specs/${base}.md`;
1229
- const sabs = path.join(ROOT, srel);
1230
- const sp = fs.existsSync(sabs) ? docConfirmed(srel, sabs) : { ok: false, why: '缺同名 spec' };
1231
- if (!sp.ok) warnings.push(`- [WARN 逐阶段] ${rel} 起草先于 spec 确认(${sp.why})`);
1237
+ const crel = `${WF}/incidents/${base}.md`;
1238
+ const cabs = path.join(ROOT, crel);
1239
+ if (fs.existsSync(cabs)) {
1240
+ const st = fmGet(cabs, '状态');
1241
+ if ((st === 'open' || st === 'fixed' || st === 'closed') && incidentMarked19(linesOf(cabs))) {
1242
+ return { ok: true, level: fmGet(cabs, '级别'), legacy: false, storageLegacy: false };
1243
+ }
1244
+ return { ok: false, legacy: false, storageLegacy: false, why: 'incident 未见过目留痕(时间线条目缺失)' };
1245
+ }
1246
+ return { ok: false, legacy: false, storageLegacy: false, why: '无同名入口' };
1247
+ };
1248
+ for (const sub of ['specs', 'plans']) {
1249
+ for (const abs of docFiles(sub)) {
1250
+ if (fmGet(abs, '状态') !== 'draft') continue;
1251
+ const base = path.basename(abs).replace(/\.md$/, '');
1252
+ // 日期:frontmatter 优先,缺则回落文件名前缀(plan 无「日期」字段;沿检查 15 口径)
1253
+ let d = fmGet(abs, '日期');
1254
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) d = /^\d{4}-\d{2}-\d{2}$/.test(base.slice(0, 10)) ? base.slice(0, 10) : '';
1255
+ if (!(/^\d{4}-\d{2}-\d{2}$/.test(d) && d >= since)) continue;
1256
+ const rel = path.relative(ROOT, abs).split(path.sep).join('/');
1257
+ const ent = entryConfirmed19(base);
1258
+ if (!ent.ok) {
1259
+ // 协作道异步审计豁免(2026-09-30 hybrid-governance-risk-lanes;2026-10-02 caliber-convergence
1260
+ // 起单源 laneOfEntry·方案 C):入口 intent 仍为 draft 且泳道 low(两字段一致 L0/L1)→「先动手后
1261
+ // 确认」是泳道语义,不告警;suspect(缺失/非法/分歧)不豁免;approved/done 却无台账行仍照报
1262
+ // (防手改状态冒充确认,与交叉一致性口径同);incident 入口不豁免(过目留痕本就轻量)。
1263
+ if (ent.entryDraft && laneOfEntry(ent.level, ent.risk, kitPolicy.riskLevelSince, ent.date) === 'low') continue;
1264
+ warnings.push(`- [WARN 逐阶段] ${rel} 起草先于入口确认(${ent.why})`); continue;
1265
+ }
1266
+ // 库存量形态(标记 / 日期早于生效日)短路 level 校验与 spec 档(与起草门同口径,N1)
1267
+ const storLegacy = ent.storageLegacy === true;
1268
+ if (!storLegacy && !/^L[0-3]$/.test(ent.level)) { warnings.push(`- [WARN 逐阶段] ${rel} 入口缺合法「级别」字段(无法判定前置深度;与起草门 fail-closed 同口径,库存量形态除外)`); continue; }
1269
+ if (sub === 'plans' && !storLegacy && (ent.level === 'L2' || ent.level === 'L3')) {
1270
+ const srel = `${WF}/specs/${base}.md`;
1271
+ const sabs = path.join(ROOT, srel);
1272
+ const sp = fs.existsSync(sabs) ? docConfirmed(srel, sabs) : { ok: false, why: '缺同名 spec' };
1273
+ if (!sp.ok) warnings.push(`- [WARN 逐阶段] ${rel} 起草先于 spec 确认(${sp.why})`);
1274
+ }
1232
1275
  }
1233
1276
  }
1234
- }
1235
- const firstApproved = new Map(); // base -> { intents?, specs?, plans? } 最早 approved ts
1236
- for (const e of ledgerRows) {
1237
- if (!e || e.stage !== 'approved' || typeof e.doc !== 'string') continue;
1238
- const m = /^workflow\/(intents|specs|plans)\/(.+)\.md$/.exec(e.doc);
1239
- if (!m) continue;
1240
- const rec = firstApproved.get(m[2]) || {};
1241
- if (!rec[m[1]] && typeof e.ts === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(e.ts)) rec[m[1]] = e.ts;
1242
- firstApproved.set(m[2], rec);
1243
- }
1244
- for (const [base, rec] of firstApproved) {
1245
- const seq = ['intents', 'specs', 'plans'].filter((k) => rec[k]);
1246
- if (seq.length < 2) continue;
1247
- // 协作道豁免(2026-09-30 hybrid-governance-risk-lanes):入口 L0/L1 的审批顺序倒置(如 plan 先于
1248
- // intent approved——「动手后确认」)是异步审计泳道语义,非流程违规;入口文件缺失(纯台账历史行 /
1249
- // incident 侧)不豁免,维持既有判据。
1250
- // existsSync 守卫(2026-10-06-check19-entry-enoent):incident 闭环主题无同名 intent,「读空判级」
1251
- // 惯用法(缺文件→级别空→不豁免,判定面不变)在 fail-loud fs 读(2026-10-05-gitout-fail-open)下
1252
- // 每次推送响亮出账 N 条 ENOENT 噪声——守卫跳过读取,语义与上注释一致;同款先例 entryConfirmed19
1253
- // 与下方 spec 读取。
1254
- const iabsB = path.join(ROOT, WF, 'intents', `${base}.md`);
1255
- const ilvlB = fs.existsSync(iabsB) ? fmGet(iabsB, '级别') : '';
1256
- if (ilvlB === 'L0' || ilvlB === 'L1') continue;
1257
- const earliest = seq.map((k) => rec[k]).sort()[0];
1258
- if (!(earliest.slice(0, 10) >= since)) continue;
1259
- let ordered = true;
1260
- for (let i = 1; i < seq.length; i++) if (rec[seq[i]] < rec[seq[i - 1]]) ordered = false;
1261
- if (!ordered) warnings.push(`- [WARN 逐阶段] 台账审批顺序倒置:${base}(${seq.map((k) => `${k}=${rec[k].slice(0, 19)}`).join(' / ')})`);
1277
+ const firstApproved = new Map(); // base -> { intents?, specs?, plans? } 最早 approved ts
1278
+ for (const e of ledgerRows) {
1279
+ if (!e || e.stage !== 'approved' || typeof e.doc !== 'string') continue;
1280
+ const m = /^workflow\/(intents|specs|plans)\/(.+)\.md$/.exec(e.doc);
1281
+ if (!m) continue;
1282
+ const rec = firstApproved.get(m[2]) || {};
1283
+ if (!rec[m[1]] && typeof e.ts === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(e.ts)) rec[m[1]] = e.ts;
1284
+ firstApproved.set(m[2], rec);
1285
+ }
1286
+ for (const [base, rec] of firstApproved) {
1287
+ const seq = ['intents', 'specs', 'plans'].filter((k) => rec[k]);
1288
+ if (seq.length < 2) continue;
1289
+ // 协作道豁免(2026-09-30 hybrid-governance-risk-lanes):入口 L0/L1 的审批顺序倒置(如 plan 先于
1290
+ // intent approved——「动手后确认」)是异步审计泳道语义,非流程违规;入口文件缺失(纯台账历史行 /
1291
+ // incident 侧)不豁免,维持既有判据。
1292
+ // existsSync 守卫(2026-10-06-check19-entry-enoent):incident 闭环主题无同名 intent,「读空判级」
1293
+ // 惯用法(缺文件→级别空→不豁免,判定面不变)在 fail-loud fs 读(2026-10-05-gitout-fail-open)下
1294
+ // 每次推送响亮出账 N 条 ENOENT 噪声——守卫跳过读取,语义与上注释一致;同款先例 entryConfirmed19
1295
+ // 与下方 spec 读取。
1296
+ const iabsB = path.join(ROOT, WF, 'intents', `${base}.md`);
1297
+ const ilvlB = fs.existsSync(iabsB) ? fmGet(iabsB, '级别') : '';
1298
+ if (ilvlB === 'L0' || ilvlB === 'L1') continue;
1299
+ const earliest = seq.map((k) => rec[k]).sort()[0];
1300
+ if (!(earliest.slice(0, 10) >= since)) continue;
1301
+ let ordered = true;
1302
+ for (let i = 1; i < seq.length; i++) if (rec[seq[i]] < rec[seq[i - 1]]) ordered = false;
1303
+ if (!ordered) warnings.push(`- [WARN 逐阶段] 台账审批顺序倒置:${base}(${seq.map((k) => `${k}=${rec[k].slice(0, 19)}`).join(' / ')})`);
1304
+ }
1262
1305
  }
1263
1306
  }
1264
- }
1265
1307
 
1266
- // --- 加固门(仅 --hardening 启用;2026-09-30 hybrid-governance-explore-hardening)[hard-block] ---
1267
- // 探索泳道(experiment/* 分支 + intent frontmatter「阶段: exploring」标记;「状态: exploring」亦认——
1268
- // 非法状态本就会在检查 5 出账)允许「先动手后确认」快速 PoC;成果转正(合入 main)前必须完成加固:
1269
- // ① intent 状态离开 draft → approved/done(探索作废走显式放弃态 superseded/cancelled——留档即出列,
1270
- // 不加固:放弃态无代码应随行);
1271
- // ② 同名 plan 在场且状态收口(∈ doc.status.confirmed;L0 亦不豁免——本门是转正门槛,不是配对门);
1272
- // ③ 泳道 L2/L3(级别优先、缺失/非法回落 risk_level,与 stage-gates 起草门同款取法)另须同名 spec
1273
- // 在场且收口。
1274
- // 触发面:.githooks/pre-push 对 remote=refs/heads/main 的推送以 --hardening 运行本脚本(hard 失败即阻断
1275
- // push);手跑 `node check-loop.mjs --hardening` 可转正前预检。诚实边界:分支名不进 commit,扫描树无法
1276
- // 机械判定「曾在 experiment/* 上」——标记靠泳道纪律写入(intent 模板 / workflow README 口径),漏标记的
1277
- // 探索件不被本门覆盖;本门拦的是「已标记未收口」。
1278
- if (HARDENING) {
1279
- for (const intent of docFiles('intents')) {
1280
- if (fmGet(intent, '阶段') !== 'exploring' && fmGet(intent, '状态') !== 'exploring') continue;
1281
- const base = path.basename(intent);
1282
- const st = fmGet(intent, '状态');
1283
- if (st === 'superseded' || st === 'cancelled') continue; // 放弃态:探索作废留档,不加固
1284
- const lvl = fmGet(intent, '级别');
1285
- // 泳道单源(2026-10-02 caliber-convergence 方案 C):suspect(缺失/非法/分歧)按 high——L2/L3 另须 spec
1286
- const lane = laneOfEntry(lvl, fmGet(intent, 'risk_level'), kitPolicy.riskLevelSince, fmGet(intent, '日期'));
1287
- if (st !== 'approved' && st !== 'done') {
1288
- blockers.push(`- [加固未过] exploring 任务入 main 须先收口:${base} intent 状态仍为『${st || '缺失'}』——先确认至 approved/done 并补齐同名 spec+plan,或显式放弃(superseded/cancelled)`);
1289
- continue;
1290
- }
1291
- const planAbs = path.join(ROOT, WF, 'plans', base);
1292
- const planSt = fs.existsSync(planAbs) ? fmGet(planAbs, '状态') : '';
1293
- if (!fs.existsSync(planAbs)) {
1294
- blockers.push(`- [加固未过] exploring 任务入 main 缺同名 plan:${base}(应在 ${WF}/plans/ 下同名)`);
1295
- } else if (!stOkDoc(planSt)) {
1296
- blockers.push(`- [加固未过] exploring 任务入 main 的 plan 未收口:${base}(plan 状态『${planSt || '缺失'}』须为 approved/done)`);
1308
+ // --- 加固门(仅 --hardening 启用;2026-09-30 hybrid-governance-explore-hardening)[hard-block] ---
1309
+ // 探索泳道(experiment/* 分支 + intent frontmatter「阶段: exploring」标记;「状态: exploring」亦认——
1310
+ // 非法状态本就会在检查 5 出账)允许「先动手后确认」快速 PoC;成果转正(合入 main)前必须完成加固:
1311
+ // ① intent 状态离开 draft → approved/done(探索作废走显式放弃态 superseded/cancelled——留档即出列,
1312
+ // 不加固:放弃态无代码应随行);
1313
+ // ② 同名 plan 在场且状态收口(∈ doc.status.confirmed;L0 亦不豁免——本门是转正门槛,不是配对门);
1314
+ // ③ 泳道 L2/L3(级别优先、缺失/非法回落 risk_level,与 stage-gates 起草门同款取法)另须同名 spec
1315
+ // 在场且收口。
1316
+ // 触发面:.githooks/pre-push 对 remote=refs/heads/main 的推送以 --hardening 运行本脚本(hard 失败即阻断
1317
+ // push);手跑 `node check-loop.mjs --hardening` 可转正前预检。诚实边界:分支名不进 commit,扫描树无法
1318
+ // 机械判定「曾在 experiment/* 上」——标记靠泳道纪律写入(intent 模板 / workflow README 口径),漏标记的
1319
+ // 探索件不被本门覆盖;本门拦的是「已标记未收口」。
1320
+ if (HARDENING) {
1321
+ for (const intent of docFiles('intents')) {
1322
+ if (fmGet(intent, '阶段') !== 'exploring' && fmGet(intent, '状态') !== 'exploring') continue;
1323
+ const base = path.basename(intent);
1324
+ const st = fmGet(intent, '状态');
1325
+ if (st === 'superseded' || st === 'cancelled') continue; // 放弃态:探索作废留档,不加固
1326
+ const lvl = fmGet(intent, '级别');
1327
+ // 泳道单源(2026-10-02 caliber-convergence 方案 C):suspect(缺失/非法/分歧)按 high——L2/L3 另须 spec
1328
+ const lane = laneOfEntry(lvl, fmGet(intent, 'risk_level'), kitPolicy.riskLevelSince, fmGet(intent, '日期'));
1329
+ if (st !== 'approved' && st !== 'done') {
1330
+ blockers.push(`- [加固未过] exploring 任务入 main 须先收口:${base} intent 状态仍为『${st || '缺失'}』——先确认至 approved/done 并补齐同名 spec+plan,或显式放弃(superseded/cancelled)`);
1331
+ continue;
1332
+ }
1333
+ const planAbs = path.join(ROOT, WF, 'plans', base);
1334
+ const planSt = fs.existsSync(planAbs) ? fmGet(planAbs, '状态') : '';
1335
+ if (!fs.existsSync(planAbs)) {
1336
+ blockers.push(`- [加固未过] exploring 任务入 main 缺同名 plan:${base}(应在 ${WF}/plans/ 下同名)`);
1337
+ } else if (!stOkDoc(planSt)) {
1338
+ blockers.push(`- [加固未过] exploring 任务入 main 的 plan 未收口:${base}(plan 状态『${planSt || '缺失'}』须为 approved/done)`);
1339
+ }
1340
+ // 泳道单源(2026-10-02 caliber-convergence 方案 C;复核 P1-1 修正):suspect(缺失/非法/分歧)与
1341
+ // high 同样另须同名 spec——仅 low(两字段一致 L0/L1)免 spec,只增不松。
1342
+ if (lane !== 'low') {
1343
+ const specAbs = path.join(ROOT, WF, 'specs', base);
1344
+ const specSt = fs.existsSync(specAbs) ? fmGet(specAbs, '状态') : '';
1345
+ if (!fs.existsSync(specAbs)) {
1346
+ blockers.push(`- [加固未过] exploring 任务入 main 缺同名 spec:${base}(级别 ${lvl}/risk ${fmGet(intent, 'risk_level') || '缺失'},应在 ${WF}/specs/ 下同名)`);
1347
+ } else if (!stOkDoc(specSt)) {
1348
+ blockers.push(`- [加固未过] exploring 任务入 main 的 spec 未收口:${base}(spec 状态『${specSt || '缺失'}』须为 approved/done)`);
1349
+ }
1350
+ }
1297
1351
  }
1298
- // 泳道单源(2026-10-02 caliber-convergence 方案 C;复核 P1-1 修正):suspect(缺失/非法/分歧)与
1299
- // high 同样另须同名 spec——仅 low(两字段一致 L0/L1)免 spec,只增不松。
1300
- if (lane !== 'low') {
1301
- const specAbs = path.join(ROOT, WF, 'specs', base);
1302
- const specSt = fs.existsSync(specAbs) ? fmGet(specAbs, '状态') : '';
1303
- if (!fs.existsSync(specAbs)) {
1304
- blockers.push(`- [加固未过] exploring 任务入 main 缺同名 spec:${base}(级别 ${lvl}/risk ${fmGet(intent, 'risk_level') || '缺失'},应在 ${WF}/specs/ 下同名)`);
1305
- } else if (!stOkDoc(specSt)) {
1306
- blockers.push(`- [加固未过] exploring 任务入 main 的 spec 未收口:${base}(spec 状态『${specSt || '缺失'}』须为 approved/done)`);
1352
+ }
1353
+
1354
+ // --- 20. 引擎脚本测试覆盖 [warning](2026-10-01 gate-script-test-coverage;仅包源环境) ---
1355
+ // 包源开发纪律:templates/_agents/scripts/ 下每个非 *.test.mjs 引擎脚本须有同名兄弟 .test.mjs,
1356
+ // 或在 .agents/scripts-test-exempt.txt 登记豁免(managed 下发)。装户(无 templates/)整体跳过。
1357
+ {
1358
+ const scriptsDir = path.join(ROOT, 'templates', '_agents', 'scripts');
1359
+ if (fs.existsSync(scriptsDir)) {
1360
+ const exemptFile = path.join(ROOT, '.agents', 'scripts-test-exempt.txt');
1361
+ const exempt = fs.existsSync(exemptFile)
1362
+ ? fs.readFileSync(exemptFile, 'utf8').split(/\r?\n/)
1363
+ .map((l) => l.trim())
1364
+ .filter((l) => l && !l.startsWith('#'))
1365
+ .map((l) => l.split('|')[0].trim())
1366
+ .filter(Boolean)
1367
+ : [];
1368
+ const orphans = fs.readdirSync(scriptsDir)
1369
+ .filter((f) => f.endsWith('.mjs') && !f.endsWith('.test.mjs') && !exempt.includes(f))
1370
+ .filter((f) => !fs.existsSync(path.join(scriptsDir, f.replace(/\.mjs$/, '.test.mjs'))));
1371
+ for (const f of orphans.sort()) {
1372
+ warnings.push(`- [WARN 脚本测试缺失] ${f} 缺同名 .test.mjs——新增引擎脚本默认必须带测试;库件/工具可在 .agents/scripts-test-exempt.txt 登记豁免`);
1307
1373
  }
1308
1374
  }
1309
1375
  }
1376
+
1377
+ // --- 输出(banner 沿 sh 版字样与格式 = 稳定输出契约:banner 后空行、条目 '- ' 前缀)---
1378
+ if (blockers.length) {
1379
+ process.stderr.write(`闭环骨架断档(check-loop.sh)— HARD-BLOCK:\n\n${blockers.join('\n')}\n\n`);
1380
+ if (warnings.length) process.stderr.write(`WARN(advisory,不阻断):\n\n${warnings.join('\n')}\n`);
1381
+ return { exitCode: 1, blockers, warnings };
1382
+ }
1383
+ if (warnings.length) {
1384
+ process.stderr.write(`check-loop.sh WARN(advisory,不阻断):\n\n${warnings.join('\n')}\n`);
1385
+ }
1386
+ return { exitCode: 0, blockers, warnings };
1310
1387
  }
1311
1388
 
1312
- function delegationResultRows(file) {
1389
+ // ---- CLI 入口(isMain 守卫)——takeOpts 的用法错误仍 process.exit(1)(CLI 专属路径)----
1390
+ const isMain = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('check-loop.mjs');
1391
+ if (isMain) process.exit(runCheckLoop(takeOpts()).exitCode);
1392
+
1393
+ export function delegationResultRows(file) {
1313
1394
  let text = '';
1314
1395
  try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
1315
1396
  const lines = text.split(/\r?\n/);
1316
- const targetSec = (line) => /^##\s+(委派结果|自做任务结果)\s*$/.test(line);
1317
- let inSec = false;
1397
+ // 两表识别与 agg-delegations.cjs splitTables 表头签名同口径(一边改另一边须跟,2026-10-08 papercuts-cleanup-batch 修 2):
1398
+ // 委派表头=含「被委派方」列;自做表头=首列「日期」且含「任务一句话」列——节标题只服务人类阅读,
1399
+ // 表头才是数据边界(节标题漂移不再静默停解析);「##」节边界仍作收集终止符(表不跨节)。
1400
+ let inTable = false;
1318
1401
  const rows = [];
1319
1402
  for (const line of lines) {
1320
- if (/^##\s+/.test(line)) {
1321
- inSec = targetSec(line);
1322
- continue;
1323
- }
1324
- if (!inSec || !/^\s*\|/.test(line)) continue;
1403
+ if (/^##\s+/.test(line)) { inTable = false; continue; }
1404
+ if (!/^\s*\|/.test(line)) continue;
1325
1405
  const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
1326
1406
  if (!cells.length || cells.every((c) => /^[-: ]*$/.test(c))) continue;
1327
- if (!/^\d{4}-\d{2}-\d{2}$/.test(cells[0])) continue;
1407
+ if (cells.some((c) => c.startsWith('被委派方')) || (cells[0] === '日期' && cells.some((c) => c.startsWith('任务一句话')))) {
1408
+ inTable = true;
1409
+ continue;
1410
+ }
1411
+ if (!inTable || !/^\d{4}-\d{2}-\d{2}$/.test(cells[0])) continue;
1328
1412
  rows.push({ date: cells[0], line });
1329
1413
  }
1330
1414
  return rows;
1331
1415
  }
1332
1416
 
1333
- function versionGreater(a, b) {
1417
+ export function versionGreater(a, b) {
1334
1418
  const parse = (v) => {
1335
1419
  const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v ?? ''));
1336
1420
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
@@ -1344,7 +1428,7 @@ function versionGreater(a, b) {
1344
1428
  return false;
1345
1429
  }
1346
1430
 
1347
- function fmStatus(text) {
1431
+ export function fmStatus(text) {
1348
1432
  const lines = String(text || '').split(/\r?\n/);
1349
1433
  if (!/^---\s*$/.test(lines[0] || '')) return '';
1350
1434
  for (let i = 1; i < lines.length; i++) {
@@ -1354,45 +1438,11 @@ function fmStatus(text) {
1354
1438
  return '';
1355
1439
  }
1356
1440
 
1357
- // --- 20. 引擎脚本测试覆盖 [warning](2026-10-01 gate-script-test-coverage;仅包源环境) ---
1358
- // 包源开发纪律:templates/_agents/scripts/ 下每个非 *.test.mjs 引擎脚本须有同名兄弟 .test.mjs,
1359
- // 或在 .agents/scripts-test-exempt.txt 登记豁免(managed 下发)。装户(无 templates/)整体跳过。
1360
- {
1361
- const scriptsDir = path.join(ROOT, 'templates', '_agents', 'scripts');
1362
- if (fs.existsSync(scriptsDir)) {
1363
- const exemptFile = path.join(ROOT, '.agents', 'scripts-test-exempt.txt');
1364
- const exempt = fs.existsSync(exemptFile)
1365
- ? fs.readFileSync(exemptFile, 'utf8').split(/\r?\n/)
1366
- .map((l) => l.trim())
1367
- .filter((l) => l && !l.startsWith('#'))
1368
- .map((l) => l.split('|')[0].trim())
1369
- .filter(Boolean)
1370
- : [];
1371
- const orphans = fs.readdirSync(scriptsDir)
1372
- .filter((f) => f.endsWith('.mjs') && !f.endsWith('.test.mjs') && !exempt.includes(f))
1373
- .filter((f) => !fs.existsSync(path.join(scriptsDir, f.replace(/\.mjs$/, '.test.mjs'))));
1374
- for (const f of orphans.sort()) {
1375
- warnings.push(`- [WARN 脚本测试缺失] ${f} 缺同名 .test.mjs——新增引擎脚本默认必须带测试;库件/工具可在 .agents/scripts-test-exempt.txt 登记豁免`);
1376
- }
1377
- }
1378
- }
1379
-
1380
- // --- 输出(banner 沿 sh 版字样与格式 = 稳定输出契约:banner 后空行、条目 '- ' 前缀)---
1381
- if (blockers.length) {
1382
- process.stderr.write(`闭环骨架断档(check-loop.sh)— HARD-BLOCK:\n\n${blockers.join('\n')}\n\n`);
1383
- if (warnings.length) process.stderr.write(`WARN(advisory,不阻断):\n\n${warnings.join('\n')}\n`);
1384
- process.exit(1);
1385
- }
1386
- if (warnings.length) {
1387
- process.stderr.write(`check-loop.sh WARN(advisory,不阻断):\n\n${warnings.join('\n')}\n`);
1388
- }
1389
- process.exit(0);
1390
-
1391
1441
  // bindingSha256:done 内容绑定的复原重算(检查 15 专用)——confirm-doc.mjs computeFingerprint 的逆推:
1392
1442
  // lines 已 CRLF 归一(split(/\r?\n/)),frontmatter 区内首个「状态:」行保分隔符换值为 prev(`状态: done`
1393
1443
  // 双空格 → `状态: approved`——与前向按原行字面计算对称,非规范分隔符不误伤;行尾空白等更奇异格式仍会
1394
1444
  // 失配,触发前提本身已违反「确认落态唯一入口」约定,接受),全文剔「确认指纹:」行后 join('\n') 再 sha256。
1395
- function bindingSha256(lines, prevStatus) {
1445
+ export function bindingSha256(lines, prevStatus) {
1396
1446
  const isDelim = (l) => /^---\s*$/.test(l);
1397
1447
  const out = [];
1398
1448
  let i = 0;