agentic-flow-kit 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +2 -0
  2. package/modules/gates/dotnet-ca/check-architecture.sh +23 -5
  3. package/modules/hosts/omp/agents/implementer.md +2 -0
  4. package/modules/hosts/omp/agents/independent-reviewer.md +2 -0
  5. package/modules/hosts/omp/agents/ui-verifier.md +2 -0
  6. package/modules/hosts/opencode/agents/implementer.md +2 -0
  7. package/modules/hosts/opencode/agents/independent-reviewer.md +2 -0
  8. package/modules/hosts/opencode/agents/ui-verifier.md +2 -0
  9. package/modules/hosts/opencode/commands/build.md +20 -1
  10. package/modules/hosts/opencode/commands/design.md +2 -0
  11. package/modules/hosts/opencode/commands/gate-checklist.md +77 -0
  12. package/modules/hosts/opencode/commands/maintain.md +2 -3
  13. package/modules/hosts/opencode/commands/plan.md +4 -1
  14. package/modules/hosts/opencode/commands/review.md +1 -0
  15. package/modules/hosts/opencode/commands/source-sync-check.md +89 -0
  16. package/modules/hosts/opencode/commands/sync-hosts.md +66 -0
  17. package/modules/hosts/opencode/commands/test.md +4 -2
  18. package/modules/hosts/trae/agents/implementer.md +2 -0
  19. package/modules/hosts/trae/agents/independent-reviewer.md +2 -0
  20. package/modules/hosts/trae/agents/ui-verifier.md +2 -0
  21. package/modules/hosts/trae/commands/wf-build.md +20 -1
  22. package/modules/hosts/trae/commands/wf-design.md +2 -0
  23. package/modules/hosts/trae/commands/wf-gate-checklist.md +78 -0
  24. package/modules/hosts/trae/commands/wf-maintain.md +2 -3
  25. package/modules/hosts/trae/commands/wf-plan.md +4 -1
  26. package/modules/hosts/trae/commands/wf-review.md +1 -0
  27. package/modules/hosts/trae/commands/wf-source-sync-check.md +90 -0
  28. package/modules/hosts/trae/commands/wf-sync-hosts.md +67 -0
  29. package/modules/hosts/trae/commands/wf-test.md +4 -2
  30. package/modules/hosts/trae/hooks/post-edit-check.cjs +3 -2
  31. package/modules/hosts/trae/hooks/pre-shell-check.cjs +40 -9
  32. package/modules/hosts/trae/hooks.json +1 -1
  33. package/modules/hosts/zcode/agents/implementer.md +2 -0
  34. package/modules/hosts/zcode/agents/independent-reviewer.md +2 -0
  35. package/modules/hosts/zcode/agents/ui-verifier.md +2 -0
  36. package/package.json +2 -2
  37. package/src/add-gate.mjs +5 -3
  38. package/src/doctor.mjs +121 -18
  39. package/src/gate-dotnet-ca.test.mjs +124 -0
  40. package/src/init.mjs +79 -17
  41. package/src/init.test.mjs +60 -1
  42. package/src/render.mjs +64 -4
  43. package/src/run-tests.mjs +3 -0
  44. package/src/sync-hosts.mjs +3 -2
  45. package/src/sync.mjs +64 -20
  46. package/src/sync.test.mjs +42 -0
  47. package/src/trae-hooks.test.mjs +115 -0
  48. package/templates/AGENTS.md +1 -0
  49. package/templates/_agents/board/index.html +32 -3
  50. package/templates/_agents/commands/build.md +5 -4
  51. package/templates/_agents/commands/design.md +1 -0
  52. package/templates/_agents/commands/maintain.md +1 -2
  53. package/templates/_agents/commands/plan.md +2 -0
  54. package/templates/_agents/commands/test.md +2 -1
  55. package/templates/_agents/hooks/check-pairing-incremental.sh +20 -7
  56. package/templates/_agents/roles/implementer.md +2 -0
  57. package/templates/_agents/roles/independent-reviewer.md +2 -0
  58. package/templates/_agents/roles/ui-verifier.md +2 -0
  59. package/templates/_agents/scripts/agg-delegations.cjs +39 -30
  60. package/templates/_agents/scripts/agg-delegations.test.mjs +32 -0
  61. package/templates/_agents/scripts/check-loop.mjs +262 -16
  62. package/templates/_agents/scripts/check-loop.test.mjs +621 -0
  63. package/templates/_agents/scripts/confirm-doc.mjs +228 -0
  64. package/templates/_agents/scripts/confirm-doc.test.mjs +321 -0
  65. package/templates/_agents/scripts/doctor.test.mjs +74 -1
  66. package/templates/_agents/scripts/ensure-board.mjs +29 -2
  67. package/templates/_agents/scripts/gate-checklist.mjs +2 -0
  68. package/templates/_agents/scripts/gate-checklist.test.mjs +3 -3
  69. package/templates/_agents/scripts/gen-wiki-board.mjs +38 -24
  70. package/templates/_agents/scripts/gen-wiki-board.test.mjs +18 -0
  71. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -3
  72. package/templates/_agents/scripts/kb-search.mjs +22 -8
  73. package/templates/_agents/scripts/kb-search.test.mjs +38 -8
  74. package/templates/_agents/scripts/source-sync-check.mjs +17 -3
  75. package/templates/_agents/scripts/source-sync-check.test.mjs +96 -8
  76. package/templates/_agents/scripts/verify-wiki-consistency.mjs +46 -23
  77. package/templates/_agents/scripts/workflow-board-server.mjs +64 -9
  78. package/templates/_agents/scripts/workflow-board-server.test.mjs +136 -0
  79. package/templates/_agents/scripts/workflow-enums.mjs +4 -2
  80. package/templates/_agents/workflow-enums.txt +3 -3
  81. package/templates/_agents/workflows/_TEMPLATE.md +1 -0
  82. package/templates/_githooks/pre-commit +16 -1
@@ -10,6 +10,7 @@ import os from 'node:os';
10
10
  import path from 'node:path';
11
11
  import { spawnSync } from 'node:child_process';
12
12
  import { fileURLToPath } from 'node:url';
13
+ import { computeFingerprint } from './confirm-doc.mjs';
13
14
 
14
15
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
15
16
  const CHECK_LOOP = path.join(SCRIPT_DIR, 'check-loop.mjs');
@@ -85,6 +86,19 @@ const gitCommitAll = (root, msg) => {
85
86
  spawnSync(G, ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', msg], { cwd: root });
86
87
  };
87
88
  const rmfix = (root) => fs.rmSync(root, { recursive: true, force: true });
89
+ // writeLedger:fixture 台账辅助(检查 15 配对用;行 schema 同 confirm-doc.mjs appendLedger)
90
+ const writeLedger = (root, entries) => {
91
+ fs.mkdirSync(path.join(root, '.agents'), { recursive: true });
92
+ fs.writeFileSync(path.join(root, '.agents', 'confirmations.jsonl'),
93
+ entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
94
+ };
95
+ // mkConfirmedDoc:落一份「带真实指纹 + 台账配对」的文档(复刻 confirm-doc 落态形态)
96
+ const mkConfirmedDoc = (root, rel, fmBody) => {
97
+ const noFp = `---\n${fmBody}\n---\n# DOC\n`;
98
+ const fp = computeFingerprint(noFp);
99
+ fs.writeFileSync(path.join(root, rel), `---\n${fmBody}\n确认指纹: ${fp.slice(0, 16)}\n---\n# DOC\n`);
100
+ return { rel: rel.replace(/\\/g, '/'), fp };
101
+ };
88
102
 
89
103
  // ---- 场景 1:全合法闭环(intent+plan,L1,done 全勾验带证据)→ exit 0 ----
90
104
  {
@@ -133,10 +147,14 @@ const rmfix = (root) => fs.rmSync(root, { recursive: true, force: true });
133
147
  }
134
148
 
135
149
  // ---- 场景 6:新建 done intent 验收未勾验 → hard;带附注不影响判定(papercut #4 回归)----
150
+ // 换锚(2026-09-28 check8-git-anchor)后「新建」由 **git 首次加入日期**判定(文件名字段不再作锚),
151
+ // 故本场景须为真 git 仓——否则不可判定、走存量口径而不再 hard。**意图与断言强度均不变**。
136
152
  {
137
153
  const T = mkfix();
154
+ gitInit(T);
138
155
  w(T, 'workflow/intents/2026-09-13-e.md', INTENT('e', '状态: done\n级别: L1\n日期: 2026-09-13\n备注: 回溯补档,原工作 2026-09-12 完成', '\n## 验收标准(可测试)\n- [ ] 用例通过\n'));
139
156
  w(T, 'workflow/plans/2026-09-13-e.md', PLAN('e', '状态: done\n级别: L1'));
157
+ gitCommitAll(T, 'add');
140
158
  expectHard('新建 done 未勾验(附注在备注键) → hard 验收未对账', T, '验收未对账');
141
159
  rmfix(T);
142
160
  }
@@ -268,8 +286,10 @@ const rmfix = (root) => fs.rmSync(root, { recursive: true, force: true });
268
286
  // ---- 场景 18:新建 done intent 缺「## 验收标准」节 → hard(2026-09-16 补口回归)----
269
287
  {
270
288
  const T = mkfix();
289
+ gitInit(T);
271
290
  w(T, 'workflow/intents/2026-09-13-r.md', INTENT('r', '状态: done\n级别: L1\n日期: 2026-09-13', '\n## 目标\n关单前未写验收标准节。\n'));
272
291
  w(T, 'workflow/plans/2026-09-13-r.md', PLAN('r', '状态: done\n级别: L1'));
292
+ gitCommitAll(T, 'add');
273
293
  expectHard('新建 done 缺验收标准节 → hard 验收未对账', T, '缺「## 验收标准」节');
274
294
  rmfix(T);
275
295
  }
@@ -277,8 +297,10 @@ const rmfix = (root) => fs.rmSync(root, { recursive: true, force: true });
277
297
  // ---- 场景 19:新建 done 勾选项缩进写法 → hard(口径放宽后仍拦)----
278
298
  {
279
299
  const T = mkfix();
300
+ gitInit(T);
280
301
  w(T, 'workflow/intents/2026-09-13-s.md', INTENT('s', '状态: done\n级别: L1\n日期: 2026-09-13', '\n## 验收标准(可测试)\n - [ ] 缩进未勾项(此前后静默通过)\n'));
281
302
  w(T, 'workflow/plans/2026-09-13-s.md', PLAN('s', '状态: done\n级别: L1'));
303
+ gitCommitAll(T, 'add');
282
304
  expectHard('新建 done 勾选项缩进 → hard 验收未对账', T, '验收未对账');
283
305
  rmfix(T);
284
306
  }
@@ -477,5 +499,604 @@ const rmfix = (root) => fs.rmSync(root, { recursive: true, force: true });
477
499
  rmfix(T);
478
500
  }
479
501
 
502
+ // ---- 场景 37/38/39/40:检查 15 确认指纹对账——无指纹拦 / 配对齐过 / 指纹不符拦 / 生效日前豁免 ----
503
+ {
504
+ const T = mkfix();
505
+ w(T, 'workflow/intents/2026-09-27-cg.md', INTENT('cg', '状态: approved\n级别: L1\n日期: 2026-09-27'));
506
+ w(T, 'workflow/plans/2026-09-27-cg.md', PLAN('cg', '状态: draft\n级别: L1'));
507
+ expectHard('检查15:生效日起新档 approved 无指纹无台账 → hard 确认未对账', T, '确认未对账');
508
+ rmfix(T);
509
+ }
510
+ {
511
+ const T = mkfix();
512
+ const i1 = mkConfirmedDoc(T, 'workflow/intents/2026-09-27-ok15.md', '状态: approved\n级别: L1\n日期: 2026-09-27');
513
+ const p1 = mkConfirmedDoc(T, 'workflow/plans/2026-09-27-ok15.md', '状态: approved\n级别: L1');
514
+ writeLedger(T, [
515
+ { ts: 'T', doc: i1.rel, stage: 'approved', fingerprint: i1.fp, prev: 'draft' },
516
+ { ts: 'T', doc: p1.rel, stage: 'approved', fingerprint: p1.fp, prev: 'draft' },
517
+ ]);
518
+ expectOk('检查15:指纹+台账配对齐 → exit 0', T);
519
+ rmfix(T);
520
+ }
521
+ {
522
+ const T = mkfix();
523
+ const i1 = mkConfirmedDoc(T, 'workflow/intents/2026-09-27-bad15.md', '状态: approved\n级别: L1\n日期: 2026-09-27');
524
+ w(T, 'workflow/plans/2026-09-27-bad15.md', PLAN('bad15', '状态: draft\n级别: L1'));
525
+ writeLedger(T, [{ ts: 'T', doc: i1.rel, stage: 'approved', fingerprint: 'e'.repeat(64), prev: 'draft' }]); // 指纹不符
526
+ expectHard('检查15:指纹与台账不配对 → hard 确认未对账', T, '确认未对账');
527
+ rmfix(T);
528
+ }
529
+ {
530
+ const T = mkfix();
531
+ // 豁免理由(2026-09-28 锚改台账 ts 后):**台账无行** ⇒ 存量未受管,与文档自报日期无关。
532
+ // 本场景构造的正是「日期早 + 无台账」——换锚前后结论一致,但理由已从「日期早于生效日」变为「无台账行」。
533
+ w(T, 'workflow/intents/2026-09-26-old15.md', INTENT('old15', '状态: approved\n级别: L1\n日期: 2026-09-26'));
534
+ w(T, 'workflow/plans/2026-09-26-old15.md', PLAN('old15', '状态: draft\n级别: L1'));
535
+ expectOk('检查15:存量 approved 无台账行 → 豁免 exit 0', T);
536
+ rmfix(T);
537
+ }
538
+
539
+ // ---- 场景 40:检查 8——证据写在 [x] 续行(仓库通写法「(证据:…)」另起一行)→ 不报缺证据 ----
540
+ {
541
+ const T = mkfix();
542
+ w(T, 'workflow/intents/2026-09-12-ev1.md', INTENT('ev1', '状态: done\n级别: L1\n日期: 2026-09-12',
543
+ '\n## 验收标准(可测试)\n- [x] 用例通过\n(证据:commit fixture;套件全绿)\n- [x] 文档补齐\n(证据:AGENTS.md 第 1 行)\n'));
544
+ w(T, 'workflow/plans/2026-09-12-ev1.md', PLAN('ev1', '状态: done\n级别: L1'));
545
+ const r = run(T);
546
+ check('证据在续行(全角冒号)→ 不报验收缺证据',
547
+ r.status === 0 && !outOf(r).includes('验收缺证据'), `exit=${r.status}\n${outOf(r)}`);
548
+ rmfix(T);
549
+ }
550
+ // ---- 场景 41:检查 8——[x] 无证据且续行也无 → 报验收缺证据(回归:不可把豁免放大成漏检)----
551
+ // 换锚后须真 git 仓:否则不可判定 → 走存量口径,「验收缺证据」这条 warning 也不再产出。
552
+ {
553
+ const T = mkfix();
554
+ gitInit(T);
555
+ w(T, 'workflow/intents/2026-09-12-ev2.md', INTENT('ev2', '状态: done\n级别: L1\n日期: 2026-09-12',
556
+ '\n## 验收标准(可测试)\n- [x] 用例通过\n- [x] 文档补齐\n(说明:无证据行)\n'));
557
+ w(T, 'workflow/plans/2026-09-12-ev2.md', PLAN('ev2', '状态: done\n级别: L1'));
558
+ gitCommitAll(T, 'add');
559
+ const r = run(T);
560
+ check('[x] 续行仍无证据 → 报验收缺证据',
561
+ r.status === 0 && outOf(r).includes('验收缺证据'), `exit=${r.status}\n${outOf(r)}`);
562
+ rmfix(T);
563
+ }
564
+ // ---- 场景 42:检查 4——fill-{intent,spec,plan}.mjs 花括号展开,三路皆存在 → 不报引用断档 ----
565
+ {
566
+ const T = mkfix();
567
+ w(T, 'AGENTS.md', '# AGENTS\n\n起草先跑 `node .agents/scripts/fill-{intent,spec,plan}.mjs` 拿结构化草稿。\n');
568
+ fs.mkdirSync(path.join(T, '.agents', 'scripts'), { recursive: true });
569
+ for (const n of ['fill-intent', 'fill-spec', 'fill-plan']) w(T, `.agents/scripts/${n}.mjs`, '// stub\n');
570
+ w(T, 'workflow/intents/2026-09-12-br1.md', INTENT('br1', '状态: done\n级别: L1\n日期: 2026-09-12', '\n## 验收标准(可测试)\n- [x] 用例通过(证据:fixture)\n'));
571
+ w(T, 'workflow/plans/2026-09-12-br1.md', PLAN('br1', '状态: done\n级别: L1'));
572
+ const r = run(T);
573
+ check('花括号展开三路存在 → 不报引用断档',
574
+ r.status === 0 && !outOf(r).includes('引用断档'), `exit=${r.status}\n${outOf(r)}`);
575
+ rmfix(T);
576
+ }
577
+ // ---- 场景 43:检查 4——花括号展开三路皆不存在 → 仍报引用断档(带完整展开式文案)----
578
+ {
579
+ const T = mkfix();
580
+ w(T, 'AGENTS.md', '# AGENTS\n\n起草先跑 `node .agents/scripts/fill-{alpha,beta,gamma}.mjs` 拿结构化草稿。\n');
581
+ w(T, 'workflow/intents/2026-09-12-br2.md', INTENT('br2', '状态: done\n级别: L1\n日期: 2026-09-12', '\n## 验收标准(可测试)\n- [x] 用例通过(证据:fixture)\n'));
582
+ w(T, 'workflow/plans/2026-09-12-br2.md', PLAN('br2', '状态: done\n级别: L1'));
583
+ const r = run(T);
584
+ check('花括号展开三路皆无 → 仍报引用断档',
585
+ r.status === 0 && /引用断档.*fill-\{alpha,beta,gamma\}\.mjs/.test(outOf(r)), `exit=${r.status}\n${outOf(r)}`);
586
+ rmfix(T);
587
+ }
588
+ // ---- 场景 44:检查 2——<主题> 与 .md 同行 = 命名约定描述 → 不报模板未填 ----
589
+ {
590
+ const T = mkfix();
591
+ w(T, 'workflow/intents/2026-09-01-nm1.md', INTENT('nm1', '状态: done\n级别: L1\n日期: 2026-09-01',
592
+ '\n编排脚本(`.agents/workflows/<主题>.md`):frontmatter + stages 表。\n复制本模板为 YYYY-MM-DD-<主题>.md 后填写。\n'));
593
+ w(T, 'workflow/plans/2026-09-01-nm1.md', PLAN('nm1', '状态: done\n级别: L1'));
594
+ const r = run(T);
595
+ check('<主题>+.md 命名约定行 → 不报模板未填',
596
+ r.status === 0 && !outOf(r).includes('模板未填'), `exit=${r.status}\n${outOf(r)}`);
597
+ rmfix(T);
598
+ }
599
+ // ---- 场景 45:检查 2——真未填占位(标题 <主题>,无 .md)→ 仍报模板未填 ----
600
+ {
601
+ const T = mkfix();
602
+ w(T, 'workflow/intents/2026-09-01-nm2.md', INTENT('<主题>', '状态: done\n级别: L1\n日期: 2026-09-01', '\n正文无占位。\n'));
603
+ w(T, 'workflow/plans/2026-09-01-nm2.md', PLAN('nm2', '状态: done\n级别: L1'));
604
+ const r = run(T);
605
+ check('标题 <主题>(无 .md)→ 仍报模板未填',
606
+ r.status === 0 && outOf(r).includes('模板未填'), `exit=${r.status}\n${outOf(r)}`);
607
+ rmfix(T);
608
+ }
609
+ // ---- 场景 46:检查 6——薄适配正文含 .agents/roles/<role>.md 引用 → 不报 Adapter 断线 ----
610
+ {
611
+ const T = mkfix();
612
+ w(T, 'workflow/intents/2026-09-12-ad1.md', INTENT('ad1', '状态: done\n级别: L1\n日期: 2026-09-12', '\n## 验收标准(可测试)\n- [x] 用例通过(证据:fixture)\n'));
613
+ w(T, 'workflow/plans/2026-09-12-ad1.md', PLAN('ad1', '状态: done\n级别: L1'));
614
+ fs.mkdirSync(path.join(T, '.agents', 'roles'), { recursive: true });
615
+ fs.mkdirSync(path.join(T, '.zcode', 'agents'), { recursive: true });
616
+ w(T, '.agents/roles/implementer.md', '# role implementer\n');
617
+ w(T, '.zcode/agents/implementer.md', '---\nname: implementer\n---\n> 契约见 `.agents/roles/implementer.md`。\n');
618
+ const r = run(T);
619
+ check('薄适配含角色引用 → 不报 Adapter 断线',
620
+ r.status === 0 && !outOf(r).includes('Adapter 断线'), `exit=${r.status}\n${outOf(r)}`);
621
+ rmfix(T);
622
+ }
623
+ // ---- 场景 47:检查 4——fill-*.mjs 星号通配,目录内有匹配文件 → 不报引用断档;无匹配 → 仍报 ----
624
+ {
625
+ const T = mkfix();
626
+ w(T, 'AGENTS.md', '# AGENTS\n\n起草先跑 `node .agents/scripts/fill-*.mjs`;孤儿引用 `.agents/scripts/ghost-*.mjs`。\n');
627
+ fs.mkdirSync(path.join(T, '.agents', 'scripts'), { recursive: true });
628
+ for (const n of ['fill-intent', 'fill-spec', 'fill-plan']) w(T, `.agents/scripts/${n}.mjs`, '// stub\n');
629
+ w(T, 'workflow/intents/2026-09-12-gb1.md', INTENT('gb1', '状态: done\n级别: L1\n日期: 2026-09-12', '\n## 验收标准(可测试)\n- [x] 用例通过(证据:fixture)\n'));
630
+ w(T, 'workflow/plans/2026-09-12-gb1.md', PLAN('gb1', '状态: done\n级别: L1'));
631
+ const r = run(T);
632
+ check('星号通配有匹配 → 不报;无匹配 → 仍报引用断档',
633
+ r.status === 0 && !/引用断档.*fill-\*/.test(outOf(r)) && /引用断档.*ghost-\*\.mjs/.test(outOf(r)), `exit=${r.status}\n${outOf(r)}`);
634
+ rmfix(T);
635
+ }
636
+ // ---- 场景 48:检查 15——委托代录台账行(source/quote 额外字段)照常配对放行(两形态口径,2026-09-27 confirm-gate-delegated)----
637
+ {
638
+ const T = mkfix();
639
+ const i1 = mkConfirmedDoc(T, 'workflow/intents/2026-09-27-del15.md', '状态: approved\n级别: L1\n日期: 2026-09-27');
640
+ const p1 = mkConfirmedDoc(T, 'workflow/plans/2026-09-27-del15.md', '状态: approved\n级别: L1');
641
+ writeLedger(T, [
642
+ { ts: 'T', doc: i1.rel, stage: 'approved', fingerprint: i1.fp, prev: 'draft', source: 'chat-delegated', quote: '2选2' },
643
+ { ts: 'T', doc: p1.rel, stage: 'approved', fingerprint: p1.fp, prev: 'draft', source: 'tty' },
644
+ ]);
645
+ expectOk('检查15:委托代录台账行(source+quote)照常配对 → exit 0', T);
646
+ rmfix(T);
647
+ }
648
+
649
+ // ---- 场景 49-54:检查 15 done 内容绑定(2026-09-27 audit-gate-hardening;同日 p2-batch 改锚台账 ts + 保分隔符复原)----
650
+ // done 形态复刻真实时序:approved 内容(含 approved 指纹)→ done 指纹在其上计算 → 落 done 文档 + 台账 done 行
651
+ // opt.ts:台账 done 行 ts(默认 2026-09-28T02:00Z——生效锚后);opt.sep:状态行键值分隔符(默认单空格,
652
+ // ' ' 复刻两跳间手工改成双空格的非规范格式)
653
+ {
654
+ const mkDoneFixture = (T, date, tamper, opt = {}) => {
655
+ const sep = opt.sep || ' ';
656
+ const ts = opt.ts || '2026-09-28T02:00:00.000Z';
657
+ const body = `\n## 验收标准(可测试)\n- [x] 用例通过(证据:fixture)\n`;
658
+ const pre = `---\n状态:${sep}approved\n级别: L1\n日期: ${date}\n确认指纹: ${'a'.repeat(16)}\n---\n# INTENT — bind\n${body}`;
659
+ const fpDone = computeFingerprint(pre);
660
+ // tamper 只污染 done 落盘文本(不进指纹底稿)——复刻「done 确认后篡改正文」
661
+ const doneText = `---\n状态:${sep}done\n级别: L1\n日期: ${date}\n确认指纹: ${fpDone.slice(0, 16)}\n---\n# INTENT — bind\n${body}${tamper || ''}`;
662
+ w(T, 'workflow/intents/' + date + '-bind.md', doneText);
663
+ w(T, 'workflow/plans/' + date + '-bind.md', PLAN('bind', '状态: draft\n级别: L1'));
664
+ return { rel: 'workflow/intents/' + date + '-bind.md', fp: fpDone, ts };
665
+ };
666
+ {
667
+ const T = mkfix();
668
+ const d1 = mkDoneFixture(T, '2026-09-28');
669
+ writeLedger(T, [{ ts: d1.ts, doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'tty' }]);
670
+ expectOk('检查15:done 内容与台账一致(复原重算=台账指纹)→ exit 0', T);
671
+ rmfix(T);
672
+ }
673
+ {
674
+ const T = mkfix();
675
+ const d1 = mkDoneFixture(T, '2026-09-28', '确认后被篡改的正文行\n');
676
+ writeLedger(T, [{ ts: d1.ts, doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'tty' }]);
677
+ expectHard('检查15:done 后正文被篡改 → hard 确认内容漂移', T, '确认内容漂移');
678
+ rmfix(T);
679
+ }
680
+ {
681
+ const T = mkfix();
682
+ // 生效锚 = 台账 ts:文档日期晚、但确认发生在锚前(旧关单顺序时代)→ 豁免绑定
683
+ const d1 = mkDoneFixture(T, '2026-09-28', '锚前确认的文档即便日后改了也不绑\n', { ts: '2026-09-27T10:00:00.000Z' });
684
+ writeLedger(T, [{ ts: d1.ts, doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'tty' }]);
685
+ expectOk('检查15:台账 ts 早于生效锚(2026-09-28)→ 豁免内容绑定', T);
686
+ rmfix(T);
687
+ }
688
+ {
689
+ const T = mkfix();
690
+ // 锚后确认 + 文档日期早(旧日期文档晚关单)+ 篡改 → 绑定照拦(p2-batch 改锚的核心收益)
691
+ const d1 = mkDoneFixture(T, '2026-09-27', '确认后被篡改的正文行\n');
692
+ writeLedger(T, [{ ts: d1.ts, doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'tty' }]);
693
+ expectHard('检查15:文档日期早但台账 ts 锚后 + 篡改 → hard 确认内容漂移', T, '确认内容漂移');
694
+ rmfix(T);
695
+ }
696
+ {
697
+ const T = mkfix();
698
+ const d1 = mkDoneFixture(T, '2026-09-28');
699
+ writeLedger(T, [{ ts: d1.ts, doc: d1.rel, stage: 'done', fingerprint: d1.fp }]); // 无 prev(schema 演进前行)
700
+ const r = run(T);
701
+ check('检查15:台账 done 行缺 prev → 降级 WARN 绑定跳过(exit 0 不拦)',
702
+ r.status === 0 && outOf(r).includes('绑定降级'), `exit=${r.status}\n${outOf(r)}`);
703
+ rmfix(T);
704
+ }
705
+ {
706
+ const T = mkfix();
707
+ // 两跳间状态行被手工改成双空格(非规范分隔符)——保分隔符复原不误伤(p2-batch P2-1)
708
+ const d1 = mkDoneFixture(T, '2026-09-28', '', { sep: ' ' });
709
+ writeLedger(T, [{ ts: d1.ts, doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'tty' }]);
710
+ expectOk('检查15:状态行双空格(非规范分隔符)绑定仍过——保分隔符复原不误伤', T);
711
+ rmfix(T);
712
+ }
713
+ }
714
+
715
+ // ---- 场景 55-56:检查 4 引用扫描收窄活跃态(2026-09-27 audit-gate-hardening)----
716
+ {
717
+ const T = mkfix();
718
+ // 日期取生效日前(2026-09-26):superseded 现入 15 配对集(closing-coverage),无台账行 ⇒ 免配对
719
+ // (2026-09-28 锚改台账 ts 后,豁免理由 = 无台账行),场景焦点保持在「终态退出引用扫描」本身
720
+ w(T, 'workflow/intents/2026-09-26-refterm.md', INTENT('refterm', '状态: superseded\n级别: L1\n日期: 2026-09-26', '\n引用 .agents/scripts/ghost-gone.mjs\n'));
721
+ w(T, 'workflow/plans/2026-09-26-refterm.md', PLAN('refterm', '状态: superseded\n级别: L1'));
722
+ const r = run(T);
723
+ check('检查4:终态文档引用断链不再报(历史叙述退出扫描)',
724
+ r.status === 0 && !outOf(r).includes('引用断档'), `exit=${r.status}\n${outOf(r)}`);
725
+ rmfix(T);
726
+ }
727
+ {
728
+ const T = mkfix();
729
+ w(T, 'workflow/intents/2026-09-26-reflive.md', INTENT('reflive', '状态: approved\n级别: L1\n日期: 2026-09-26', '\n引用 .agents/scripts/ghost-gone.mjs\n'));
730
+ w(T, 'workflow/plans/2026-09-26-reflive.md', PLAN('reflive', '状态: draft\n级别: L1'));
731
+ const r = run(T);
732
+ check('检查4:活跃文档引用断链仍报(收窄只放终态)',
733
+ r.status === 0 && outOf(r).includes('引用断档') && outOf(r).includes('ghost-gone.mjs'), `exit=${r.status}\n${outOf(r)}`);
734
+ rmfix(T);
735
+ }
736
+
737
+ // ---- 场景 57-60:检查 15 incidents 确认门(2026-09-27 gate-coverage,配对自 2026-09-28 起)----
738
+ // fixed/closed 须指纹+台账配对;closed 与 done 同口径内容绑定(ts 锚 + 保分隔符);open 起草态不加门
739
+ {
740
+ const mkIncFixture = (T, st, tamper, opt = {}) => {
741
+ const ts = opt.ts || '2026-09-28T02:00:00.000Z';
742
+ const body = 三件套(false);
743
+ const pre = `---\n状态: fixed\n级别: L1\n发现: 2026-09-28\n确认指纹: ${'a'.repeat(16)}\n---\n# INCIDENT — inc\n${body}`;
744
+ const fpClosed = computeFingerprint(pre);
745
+ // tamper 只污染 closed 落盘文本(不进指纹底稿)——复刻「closed 确认后篡改正文」
746
+ const closedText = `---\n状态: ${st}\n级别: L1\n发现: 2026-09-28\n确认指纹: ${fpClosed.slice(0, 16)}\n---\n# INCIDENT — inc\n${body}${tamper || ''}`;
747
+ w(T, 'workflow/incidents/2026-09-28-inc.md', closedText);
748
+ w(T, 'workflow/plans/2026-09-28-inc.md', PLAN('inc', '状态: draft\n级别: L1'));
749
+ return { rel: 'workflow/incidents/2026-09-28-inc.md', fp: fpClosed, ts };
750
+ };
751
+ {
752
+ // incident closed(生效日起)无指纹无台账 → hard 确认未对账
753
+ const T = mkfix();
754
+ w(T, 'workflow/incidents/2026-09-28-inc15.md', `---\n状态: closed\n级别: L1\n发现: 2026-09-28\n---\n# INCIDENT — inc15\n${三件套(false)}`);
755
+ w(T, 'workflow/plans/2026-09-28-inc15.md', PLAN('inc15', '状态: draft\n级别: L1'));
756
+ expectHard('检查15:incident closed(生效日起)无指纹无台账 → hard 确认未对账', T, '确认未对账');
757
+ rmfix(T);
758
+ }
759
+ {
760
+ const T = mkfix();
761
+ const i1 = mkIncFixture(T, 'closed');
762
+ writeLedger(T, [{ ts: i1.ts, doc: i1.rel, stage: 'closed', fingerprint: i1.fp, prev: 'fixed', source: 'tty' }]);
763
+ expectOk('检查15:incident closed 配对+内容绑定一致 → exit 0', T);
764
+ rmfix(T);
765
+ }
766
+ {
767
+ const T = mkfix();
768
+ const i1 = mkIncFixture(T, 'closed', 'closed 确认后被篡改的正文行\n');
769
+ writeLedger(T, [{ ts: i1.ts, doc: i1.rel, stage: 'closed', fingerprint: i1.fp, prev: 'fixed', source: 'tty' }]);
770
+ expectHard('检查15:incident closed 后正文被篡改 → hard 确认内容漂移', T, '确认内容漂移');
771
+ rmfix(T);
772
+ }
773
+ {
774
+ // open 起草态不加门(无指纹无台账 → 不报确认未对账)
775
+ const T = mkfix();
776
+ w(T, 'workflow/incidents/2026-09-28-incopen.md', `---\n状态: open\n级别: L1\n发现: 2026-09-28\n---\n# INCIDENT — incopen\n${三件套(false)}`);
777
+ w(T, 'workflow/plans/2026-09-28-incopen.md', PLAN('incopen', '状态: draft\n级别: L1'));
778
+ const r = run(T);
779
+ check('检查15:incident open(起草态)不进确认门 → 不报确认未对账',
780
+ r.status === 0 && !outOf(r).includes('确认未对账'), `exit=${r.status}\n${outOf(r)}`);
781
+ rmfix(T);
782
+ }
783
+ }
784
+
785
+ // ---- 场景 61-63:检查 15 放弃态覆盖(2026-09-27 closing-coverage:superseded/cancelled 配对 + 四终态绑定)----
786
+ {
787
+ const mkAbandonFixture = (T, st, tamper) => {
788
+ const ts = '2026-09-28T02:00:00.000Z';
789
+ const body = '\n正文行\n';
790
+ const pre = `---\n状态: approved\n级别: L1\n日期: 2026-09-28\n确认指纹: ${'a'.repeat(16)}\n---\n# INTENT — ab\n${body}`;
791
+ const fp = computeFingerprint(pre);
792
+ const after = `---\n状态: ${st}\n级别: L1\n日期: 2026-09-28\n确认指纹: ${fp.slice(0, 16)}\n---\n# INTENT — ab\n${body}${tamper || ''}`;
793
+ w(T, 'workflow/intents/2026-09-28-ab.md', after);
794
+ w(T, 'workflow/plans/2026-09-28-ab.md', PLAN('ab', '状态: draft\n级别: L1'));
795
+ return { rel: 'workflow/intents/2026-09-28-ab.md', fp, ts };
796
+ };
797
+ {
798
+ // superseded(生效日起)无指纹无台账 → hard 确认未对账(手改出账的口子收死)
799
+ const T = mkfix();
800
+ w(T, 'workflow/intents/2026-09-28-ab1.md', `---\n状态: superseded\n级别: L1\n日期: 2026-09-28\n---\n# INTENT — ab1\n`);
801
+ w(T, 'workflow/plans/2026-09-28-ab1.md', PLAN('ab1', '状态: draft\n级别: L1'));
802
+ expectHard('检查15:superseded(生效日起)无指纹无台账 → hard 确认未对账', T, '确认未对账');
803
+ rmfix(T);
804
+ }
805
+ {
806
+ // superseded + 配对 + 终态绑定:篡改 → hard 确认内容漂移
807
+ const T = mkfix();
808
+ const a1 = mkAbandonFixture(T, 'superseded', 'superseded 确认后被篡改的正文行\n');
809
+ writeLedger(T, [{ ts: a1.ts, doc: a1.rel, stage: 'superseded', fingerprint: a1.fp, prev: 'approved', source: 'tty' }]);
810
+ expectHard('检查15:superseded 后正文被篡改 → hard 确认内容漂移(四终态绑定)', T, '确认内容漂移');
811
+ rmfix(T);
812
+ }
813
+ {
814
+ // cancelled + 配对 + 绑定一致 → 过
815
+ const T = mkfix();
816
+ const a1 = mkAbandonFixture(T, 'cancelled');
817
+ writeLedger(T, [{ ts: a1.ts, doc: a1.rel, stage: 'cancelled', fingerprint: a1.fp, prev: 'approved', source: 'chat-delegated', quote: '不做了' }]);
818
+ expectOk('检查15:cancelled 配对+绑定一致 → exit 0', T);
819
+ rmfix(T);
820
+ }
821
+ }
822
+
823
+ // ---- 场景 64-66:检查 15 并录批次审计(2026-09-28 batch-ledger-audit 改判据:读 batch/of 事实)----
824
+ // 判据演进:原「同 quote + 相邻 ts 差 < 2s」反推调用次数(已被实证假阳性+假阴性,整段退役);
825
+ // 现「台账行按 batch 分组、组内 of>1 且含 delegated 行 → warning」。本块场景随之改判定依据,
826
+ // **断言强度不放宽、场景不删**(同一意图换了机制):
827
+ // · 原「同 quote 双份 2s 内」→ 现「同 batch、of=2、delegated」→ 报(机制换、意图同:一次调用落两份)
828
+ // · 原「异 quote / >2s 不聚组」→ 现「不同 batch(或 of=1)→ 不报」;且**新增反向场景**:同 batch
829
+ // 但换 quote、或人为延迟跨 2s,仍须被拦——这正是旧判据的盲区(旧判据下会漏报)
830
+ // · 原「revert-draft 注记行不参与」→ 现由 VALID_STAGES 过滤承接(同意图)
831
+ {
832
+ const mk2LedgerDocs = (T) => {
833
+ // 两份全配对绑定的 done 文档(复用 mkDoneFixture 的构造口径)
834
+ const mkOne = (slug, date) => {
835
+ const body = `\n## 验收标准(可测试)\n- [x] 用例通过(证据:fixture)\n`;
836
+ const pre = `---\n状态: approved\n级别: L1\n日期: ${date}\n确认指纹: ${'a'.repeat(16)}\n---\n# INTENT — ${slug}\n${body}`;
837
+ const fp = computeFingerprint(pre);
838
+ w(T, `workflow/intents/${date}-${slug}.md`, `---\n状态: done\n级别: L1\n日期: ${date}\n确认指纹: ${fp.slice(0, 16)}\n---\n# INTENT — ${slug}\n${body}`);
839
+ w(T, `workflow/plans/${date}-${slug}.md`, PLAN(slug, '状态: draft\n级别: L1'));
840
+ return { rel: `workflow/intents/${date}-${slug}.md`, fp };
841
+ };
842
+ return [mkOne('ab1', '2026-09-28'), mkOne('ab2', '2026-09-28')];
843
+ };
844
+ {
845
+ // 同一批(batch 同、of=2)delegated → warning 可见(exit 0 不阻断)——原「同 quote 双份」的新机制等价物
846
+ const T = mkfix();
847
+ const [d1, d2] = mk2LedgerDocs(T);
848
+ writeLedger(T, [
849
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'chat-delegated', quote: '两份一起', batch: 'b1', seq: 1, of: 2 },
850
+ { ts: '2026-09-28T02:00:00.100Z', doc: d2.rel, stage: 'done', fingerprint: d2.fp, prev: 'approved', source: 'chat-delegated', quote: '两份一起', batch: 'b1', seq: 2, of: 2 },
851
+ ]);
852
+ const r = run(T);
853
+ check('检查15:同 batch 且 of=2(delegated)→ WARN 确认并录(exit 0 审计可见性)',
854
+ r.status === 0 && outOf(r).includes('确认并录') && outOf(r).includes('2 份'),
855
+ `exit=${r.status}\n${outOf(r)}`);
856
+ rmfix(T);
857
+ }
858
+ {
859
+ // 【核心回归·旧判据盲区】同一批但**换 quote**("甲"/"乙")且 ts 相差 5s:
860
+ // 旧判据(同 quote + 差 < 2s)→ 两个信号都不命中 → **零告警**(漏报真实并录)
861
+ // 新判据读 batch/of(与 quote、ts 无关)→ 仍须报。此场景在旧判据下必然变红。
862
+ const T = mkfix();
863
+ const [d1, d2] = mk2LedgerDocs(T);
864
+ writeLedger(T, [
865
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'chat-delegated', quote: '甲', batch: 'b2', seq: 1, of: 2 },
866
+ { ts: '2026-09-28T02:00:05.000Z', doc: d2.rel, stage: 'done', fingerprint: d2.fp, prev: 'approved', source: 'chat-delegated', quote: '乙', batch: 'b2', seq: 2, of: 2 },
867
+ ]);
868
+ const r = run(T);
869
+ check('检查15:同 batch 但换 quote + 隔 5s → 仍报并录(旧判据盲区,本单核心回归)',
870
+ r.status === 0 && outOf(r).includes('确认并录') && outOf(r).includes('2 份'),
871
+ `exit=${r.status}\n${outOf(r)}`);
872
+ rmfix(T);
873
+ }
874
+ {
875
+ // of=1(逐件调用)复用**同一句** quote 且 ts 极近 → **不报**。
876
+ // 这是旧判据的假阳性面:用户两次都说「可以」+ 快速连跑,旧判据必然误报;新判据读 of=1 正确放过。
877
+ const T = mkfix();
878
+ const [d1, d2] = mk2LedgerDocs(T);
879
+ writeLedger(T, [
880
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'chat-delegated', quote: '可以', batch: 'b3', seq: 1, of: 1 },
881
+ { ts: '2026-09-28T02:00:00.050Z', doc: d2.rel, stage: 'done', fingerprint: d2.fp, prev: 'approved', source: 'chat-delegated', quote: '可以', batch: 'b4', seq: 1, of: 1 },
882
+ ]);
883
+ const r = run(T);
884
+ check('检查15:两次逐件调用(of=1)复用同一句 quote 且 ts 极近 → 不报(修旧判据假阳性)',
885
+ r.status === 0 && !outOf(r).includes('确认并录'),
886
+ `exit=${r.status}\n${outOf(r)}`);
887
+ rmfix(T);
888
+ }
889
+ {
890
+ // TTY 形态 of>1:用户亲手逐份过目键入(天然逐件),不构成违规 → 不报
891
+ const T = mkfix();
892
+ const [d1, d2] = mk2LedgerDocs(T);
893
+ writeLedger(T, [
894
+ { ts: '2026-09-28T03:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'tty', batch: 't1', seq: 1, of: 2 },
895
+ { ts: '2026-09-28T03:00:01.000Z', doc: d2.rel, stage: 'done', fingerprint: d2.fp, prev: 'approved', source: 'tty', batch: 't1', seq: 2, of: 2 },
896
+ ]);
897
+ const r = run(T);
898
+ check('检查15:TTY 形态 of=2(亲手逐份过目)→ 不报并录',
899
+ r.status === 0 && !outOf(r).includes('确认并录'),
900
+ `exit=${r.status}\n${outOf(r)}`);
901
+ rmfix(T);
902
+ }
903
+ {
904
+ // 无 batch 字段的历史行(schema 演进前)→ 静默跳过,不报、不降级出账
905
+ // (沿 audit-gate-hardening P3:无判定依据的行不产出不可消除噪声)
906
+ const T = mkfix();
907
+ const [d1, d2] = mk2LedgerDocs(T);
908
+ writeLedger(T, [
909
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'chat-delegated', quote: '两份一起' },
910
+ { ts: '2026-09-28T02:00:01.500Z', doc: d2.rel, stage: 'done', fingerprint: d2.fp, prev: 'approved', source: 'chat-delegated', quote: '两份一起' },
911
+ ]);
912
+ const r = run(T);
913
+ check('检查15:无 batch 的历史行 → 静默跳过(不报、不降级出账)',
914
+ r.status === 0 && !outOf(r).includes('确认并录'),
915
+ `exit=${r.status}\n${outOf(r)}`);
916
+ rmfix(T);
917
+ }
918
+ {
919
+ // 不同 batch 各自 of=1;revert-draft 注记行(stage 非法)不参与 → 一条都不报
920
+ const T = mkfix();
921
+ const [d1, d2] = mk2LedgerDocs(T);
922
+ writeLedger(T, [
923
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'chat-delegated', quote: 'x', batch: 'c1', seq: 1, of: 1 },
924
+ { ts: '2026-09-28T02:00:00.400Z', doc: d2.rel, stage: 'done', fingerprint: d2.fp, prev: 'approved', source: 'chat-delegated', quote: 'x', batch: 'c2', seq: 1, of: 1 },
925
+ { ts: '2026-09-28T02:00:00.900Z', doc: 'workflow/plans/2026-09-28-ghost.md', stage: 'revert-draft', fingerprint: 'n/a', prev: 'approved', source: 'chat-delegated', quote: 'x', batch: 'c3', seq: 1, of: 3 },
926
+ ]);
927
+ const r = run(T);
928
+ check('检查15:异 batch 各自 of=1 + revert-draft 注记行 → 不报并录(注记行由 VALID_STAGES 过滤)',
929
+ r.status === 0 && !outOf(r).includes('确认并录') && !outOf(r).includes('ghost'),
930
+ `exit=${r.status}\n${outOf(r)}`);
931
+ rmfix(T);
932
+ }
933
+ {
934
+ // 【复核 P2-3】组内 `of` 不一致(人为构造)时判据不得因**行序**而改变结论:
935
+ // 原实现取 rows[0].of —— 首行 of=1、次行 of=2 会漏报;现取组内 some(of>1),两个方向都报。
936
+ // 正常批次由 confirm-doc 写同一个 of=docs.length,组内恒一致;本用例专门钉住顺序无关性。
937
+ const T = mkfix();
938
+ const [d1, d2] = mk2LedgerDocs(T);
939
+ writeLedger(T, [
940
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'chat-delegated', quote: '甲', batch: 'd1', seq: 1, of: 1 },
941
+ { ts: '2026-09-28T02:00:01.000Z', doc: d2.rel, stage: 'done', fingerprint: d2.fp, prev: 'approved', source: 'chat-delegated', quote: '乙', batch: 'd1', seq: 2, of: 2 },
942
+ ]);
943
+ const r = run(T);
944
+ check('检查15:组内 of 不一致(首行 of=1 / 次行 of=2)→ 仍报(判据顺序无关,复核 P2-3)',
945
+ r.status === 0 && outOf(r).includes('确认并录'),
946
+ `exit=${r.status}\n${outOf(r)}`);
947
+ rmfix(T);
948
+ }
949
+ {
950
+ // 反向:首行 of=2 / 次行 of=1 —— 与上条同批、仅行序相反,结论必须一致(都报)
951
+ const T = mkfix();
952
+ const [d1, d2] = mk2LedgerDocs(T);
953
+ writeLedger(T, [
954
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'chat-delegated', quote: '甲', batch: 'd2', seq: 1, of: 2 },
955
+ { ts: '2026-09-28T02:00:01.000Z', doc: d2.rel, stage: 'done', fingerprint: d2.fp, prev: 'approved', source: 'chat-delegated', quote: '乙', batch: 'd2', seq: 2, of: 1 },
956
+ ]);
957
+ const r2 = run(T);
958
+ check('检查15:组内 of 不一致(首行 of=2 / 次行 of=1)→ 仍报(与上条对称,顺序无关)',
959
+ r2.status === 0 && outOf(r2).includes('确认并录'),
960
+ `exit=${r2.status}\n${outOf(r2)}`);
961
+ rmfix(T);
962
+ }
963
+ {
964
+ // 【复核 P2-3 收口】「可报组与 revert-draft 注记行并存 → 恰报 1 次、注记行不参与」
965
+ // 上一版场景把两行改成异 batch of=1 后,fixture 里已无可报组,只验证了「无可报组时零报」——
966
+ // 本用例恢复旧版被覆盖的组合(旧断言为 `length===1 && includes('2 份')`,此处以新机制等价重建)。
967
+ const T = mkfix();
968
+ const [d1, d2] = mk2LedgerDocs(T);
969
+ writeLedger(T, [
970
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'chat-delegated', quote: '两份一起', batch: 'p1', seq: 1, of: 2 },
971
+ { ts: '2026-09-28T02:00:00.400Z', doc: d2.rel, stage: 'done', fingerprint: d2.fp, prev: 'approved', source: 'chat-delegated', quote: '两份一起', batch: 'p1', seq: 2, of: 2 },
972
+ // 注记行:与上批**同 batch id 但 stage 非法**——若 VALID_STAGES 过滤失效,它会被并入该批
973
+ { ts: '2026-09-28T02:00:00.900Z', doc: 'workflow/plans/2026-09-28-ghost.md', stage: 'revert-draft', fingerprint: 'n/a', prev: 'approved', source: 'chat-delegated', quote: '两份一起', batch: 'p1', seq: 3, of: 3 },
974
+ ]);
975
+ const r = run(T);
976
+ check('检查15:可报组与 revert-draft 注记行并存 → 恰报 1 次且注记行不参与(复核 P2-4 补回)',
977
+ r.status === 0 && (outOf(r).match(/确认并录/g) || []).length === 1
978
+ && outOf(r).includes('2 份') && !outOf(r).includes('ghost'),
979
+ `exit=${r.status}\n${outOf(r)}`);
980
+ rmfix(T);
981
+ }
982
+ }
983
+
984
+ // ---- 场景 67-71:检查 15 受管准入两条件取或(2026-09-28 incident confirm-gate-effective-date-anchor)----
985
+ // 换锚前:受管准入仅「自报日期 ≥ 生效日」——新档把日期写早即整段跳过判定(确认门静默不开)。
986
+ // 换锚后:受管 = ①台账有合法跳转行 ∨ ②自报日期 ≥ 生效日;两者皆不满足才豁免(存量)。
987
+ // ①独立于自报日期成立 → 堵死「确认过却写早日期逃掉对账」;②保留 → 兜住「新档完全没跑 confirm-doc」。
988
+ // 本块自包含构造(不复用他块作用域内的 mkDoneFixture,避免跨块引用)。
989
+ {
990
+ const mkAnchorFixture = (T, date, tamper, opt = {}) => {
991
+ const ts = opt.ts || '2026-09-28T02:00:00.000Z';
992
+ const body = `\n## 验收标准(可测试)\n- [x] 用例通过(证据:fixture)\n`;
993
+ const pre = `---\n状态: approved\n级别: L1\n日期: ${date}\n确认指纹: ${'a'.repeat(16)}\n---\n# INTENT — anchor\n${body}`;
994
+ const fp = computeFingerprint(pre);
995
+ // tamper 只污染 done 落盘文本(不进指纹底稿)——复刻「done 确认后篡改正文」
996
+ w(T, `workflow/intents/${date}-anchor.md`,
997
+ `---\n状态: done\n级别: L1\n日期: ${date}\n确认指纹: ${fp.slice(0, 16)}\n---\n# INTENT — anchor\n${body}${tamper || ''}`);
998
+ w(T, `workflow/plans/${date}-anchor.md`, PLAN('anchor', '状态: draft\n级别: L1'));
999
+ return { rel: `workflow/intents/${date}-anchor.md`, fp, ts };
1000
+ };
1001
+
1002
+ {
1003
+ // 核心回归(本 incident 的触发条件):自报日期早于生效日,但台账 ts 在生效日之后 → 仍须 hard 拦。
1004
+ // 换锚前此场景被 `d < eff` 跳过判定(静默放行);换锚后按台账事实纳入受管。
1005
+ const T = mkfix();
1006
+ const d1 = mkAnchorFixture(T, '2026-09-20'); // 自报日期 2026-09-20(早于任何生效日)
1007
+ writeLedger(T, [{ ts: d1.ts, doc: d1.rel, stage: 'done', fingerprint: 'e'.repeat(64), prev: 'approved', source: 'tty' }]); // 指纹不符
1008
+ expectHard('检查15:自报日期早但台账 ts 锚后 + 指纹不符 → hard 确认未对账(换锚前被静默跳过)', T, '确认未对账');
1009
+ rmfix(T);
1010
+ }
1011
+ {
1012
+ // 存量豁免须**两条件皆不满足**:日期早(2026-09-26 < 生效日)且台账无行 → 免配对。
1013
+ // 注意「日期新但无台账」**不**豁免(那是新档漏走确认门,见上一条核心回归的反面)——
1014
+ // 两条件取或的语义即:任一条命中即受管,故豁免档必须两条都不命中。
1015
+ const T = mkfix();
1016
+ w(T, 'workflow/intents/2026-09-26-noledge.md', INTENT('noledge', '状态: approved\n级别: L1\n日期: 2026-09-26'));
1017
+ w(T, 'workflow/plans/2026-09-26-noledge.md', PLAN('noledge', '状态: draft\n级别: L1'));
1018
+ expectOk('检查15:日期早于生效日且台账无行 → 存量豁免(两条件皆不满足)', T);
1019
+ rmfix(T);
1020
+ }
1021
+ {
1022
+ // 条件①的**独立判别力**(复核 P2-2 更正):日期早(条件②不命中,本会被日期条件豁免)
1023
+ // + 台账有行但**指纹不匹配** → 条件①独立成立 ⇒ 受管 ⇒ 配对失败 ⇒ hard 拦。
1024
+ // 断言必须用 expectHard 而非 expectOk:旧单条件下该 doc 因日期早被整段跳过(exit 0),
1025
+ // 用 expectOk 会「新旧门都通过」而无法证伪条件①被移除(上一版即此缺陷,属无效测试)。
1026
+ // 现版本在忠实变异(还原旧单条件)下会变红——与核心回归场景同向锁定条件①的独立成立。
1027
+ const T = mkfix();
1028
+ const d1 = mkAnchorFixture(T, '2026-09-20');
1029
+ writeLedger(T, [{ ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: 'e'.repeat(64), prev: 'approved', source: 'tty' }]); // 指纹不符
1030
+ expectHard('检查15:日期早但台账有行(指纹不匹配)→ 受管并 hard 拦(条件①独立于日期成立)', T, '确认未对账');
1031
+ rmfix(T);
1032
+ }
1033
+ {
1034
+ // 重确认:同一 doc+stage 台账多行 → 取末次 ts 判锚(append-only 末次生效;不能因首行早于锚而误豁免)
1035
+ const T = mkfix();
1036
+ const d1 = mkAnchorFixture(T, '2026-09-28', '确认后被篡改的正文行\n');
1037
+ writeLedger(T, [
1038
+ { ts: '2026-09-27T10:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'tty' }, // 早期行(锚前)
1039
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'done', fingerprint: d1.fp, prev: 'approved', source: 'tty' }, // 末次行(锚后)
1040
+ ]);
1041
+ expectHard('检查15:同 doc+stage 台账多行取末次 ts → 篡改仍 hard 确认内容漂移(重确认场景)', T, '确认内容漂移');
1042
+ rmfix(T);
1043
+ }
1044
+ {
1045
+ // 非合法跳转行(revert-open / revert-draft 类回退注记)**不构成本 incident 的核心命题**——
1046
+ // 为把该属性与日期条件隔离,此处取日期早于生效日(条件②不命中),使受管准入**只**取决于台账条件:
1047
+ // · 若注记行被判为「合法跳转行」→ 台账条件命中 → 受管 → 无指纹 → hard 拦(期望:不拦,故失败)
1048
+ // · 期望:注记行被 VALID_STAGES 过滤 → 台账条件不命中 → 两条件皆不满足 → 存量豁免
1049
+ // 注记行不是「确认确实发生过」的证据,不能被当成台账行把文档拖进受管面
1050
+ // (否则回退操作反而制造 hard 拦——本 incident 自己就吃过这个亏,见 incident 备注「落态注记」)。
1051
+ const T = mkfix();
1052
+ const d1 = mkAnchorFixture(T, '2026-09-26');
1053
+ writeLedger(T, [
1054
+ { ts: '2026-09-28T02:00:00.000Z', doc: d1.rel, stage: 'revert-open', fingerprint: 'n/a', prev: 'approved', source: 'chat-delegated', quote: '回退注记' },
1055
+ ]);
1056
+ expectOk('检查15:仅 revert-open 注记行(非合法跳转 stage)→ 不构成台账条件,日期早 → 存量豁免 exit 0', T);
1057
+ rmfix(T);
1058
+ }
1059
+ }
1060
+
1061
+ // ---- 场景 72-74:检查 8 生效日锚改 git 首次加入日期(2026-09-28 check8-git-anchor)----
1062
+ // 换锚前锚取**文件名前 10 字符**——项目命名规范本身即 YYYY-MM-DD-<主题>,作者日常手写该前缀,
1063
+ // 「写早」无需任何额外动作、顺手即发生 → 一条 hard 门可被平凡绕过(有未勾验项却 exit 0)。
1064
+ // 换锚后锚取 git 首次加入日期(不可手填)→ 该通道关闭。
1065
+ {
1066
+ {
1067
+ // 【核心回归】文件名日期写早(2026-09-01 < accCutoff 2026-09-12),但**实际是新建档**
1068
+ // (git 首次加入在生效日之后)→ 仍须 hard 拦。换锚前此场景被 `filedate >= accCutoff` 跳过(静默放行)。
1069
+ const T = mkfix();
1070
+ gitInit(T);
1071
+ w(T, 'workflow/intents/2026-09-01-early.md', INTENT('early', '状态: done\n级别: L1\n日期: 2026-09-01', '\n## 验收标准\n- [ ] 未勾项\n'));
1072
+ w(T, 'workflow/plans/2026-09-01-early.md', PLAN('early', '状态: done\n级别: L1'));
1073
+ gitCommitAll(T, 'add'); // 加入时间 = 今天(≥ accCutoff)→ git 事实:新建档
1074
+ expectHard('检查8:文件名日期写早但 git 首次加入够新 + 未勾验 → hard 拦(换锚前被静默跳过)', T, '验收未对账');
1075
+ rmfix(T);
1076
+ }
1077
+ {
1078
+ // 【退化语义】非 git 仓(fixture 常态)→ 不可判定 → 走存量口径,**不误报 hard**。
1079
+ // 钉住「换锚不引入新的误拦」:不可判定时宁可放过(advisory 层),不放 hard。
1080
+ const T = mkfix();
1081
+ w(T, 'workflow/intents/2026-09-15-nogit.md', INTENT('nogit', '状态: done\n级别: L1\n日期: 2026-09-15', '\n## 验收标准\n- [ ] 未勾项\n'));
1082
+ w(T, 'workflow/plans/2026-09-15-nogit.md', PLAN('nogit', '状态: done\n级别: L1'));
1083
+ const r = run(T);
1084
+ check('检查8:非 git 仓 → 不可判定走存量口径(不误报 hard)',
1085
+ r.status === 0 && !outOf(r).includes('验收未对账'), `exit=${r.status}\n${outOf(r)}`);
1086
+ rmfix(T);
1087
+ }
1088
+ {
1089
+ // 【对照】同一内容、仅把**文件名**改为够新(09-15)→ 两锚下都应 hard 拦。
1090
+ // 与核心回归并置,说明「拦不拦由 git 事实决定,不由文件名字段决定」。
1091
+ const T = mkfix();
1092
+ gitInit(T);
1093
+ w(T, 'workflow/intents/2026-09-15-newer.md', INTENT('newer', '状态: done\n级别: L1\n日期: 2026-09-15', '\n## 验收标准\n- [ ] 未勾项\n'));
1094
+ w(T, 'workflow/plans/2026-09-15-newer.md', PLAN('newer', '状态: done\n级别: L1'));
1095
+ gitCommitAll(T, 'add');
1096
+ expectHard('检查8:文件名日期够新 + git 加入够新 + 未勾验 → hard 拦(对照)', T, '验收未对账');
1097
+ rmfix(T);
1098
+ }
1099
+ }
1100
+
480
1101
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
481
1102
  process.exit(fail ? 1 : 0);