agentic-flow-kit 1.7.15 → 1.7.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +41 -3
  2. package/modules/hosts/claude/commands/wf-design.md +5 -4
  3. package/modules/hosts/claude/commands/wf-new-task.md +42 -21
  4. package/modules/hosts/claude/commands/wf-review.md +1 -1
  5. package/modules/hosts/claude/commands/wf-sync-hosts.md +7 -13
  6. package/modules/hosts/claude/commands/wf-test.md +1 -1
  7. package/modules/hosts/codex/commands/wf-design.md +5 -4
  8. package/modules/hosts/codex/commands/wf-new-task.md +42 -21
  9. package/modules/hosts/codex/commands/wf-review.md +1 -1
  10. package/modules/hosts/codex/commands/wf-sync-hosts.md +7 -13
  11. package/modules/hosts/codex/commands/wf-test.md +1 -1
  12. package/modules/hosts/cursor/commands/wf-design.md +5 -4
  13. package/modules/hosts/cursor/commands/wf-new-task.md +42 -21
  14. package/modules/hosts/cursor/commands/wf-review.md +1 -1
  15. package/modules/hosts/cursor/commands/wf-sync-hosts.md +7 -13
  16. package/modules/hosts/cursor/commands/wf-test.md +1 -1
  17. package/modules/hosts/opencode/commands/wf-design.md +5 -4
  18. package/modules/hosts/opencode/commands/wf-new-task.md +42 -21
  19. package/modules/hosts/opencode/commands/wf-review.md +1 -1
  20. package/modules/hosts/opencode/commands/wf-sync-hosts.md +7 -13
  21. package/modules/hosts/opencode/commands/wf-test.md +1 -1
  22. package/modules/hosts/trae/commands/wf-design.md +5 -4
  23. package/modules/hosts/trae/commands/wf-new-task.md +42 -21
  24. package/modules/hosts/trae/commands/wf-review.md +1 -1
  25. package/modules/hosts/trae/commands/wf-sync-hosts.md +7 -13
  26. package/modules/hosts/trae/commands/wf-test.md +1 -1
  27. package/package.json +1 -1
  28. package/src/cli.mjs +43 -4
  29. package/src/doctor.mjs +37 -1
  30. package/src/executor.mjs +245 -0
  31. package/src/executor.test.mjs +292 -0
  32. package/src/init.mjs +7 -0
  33. package/src/profiles.mjs +3 -0
  34. package/src/render.mjs +3 -3
  35. package/src/sync-hosts.mjs +37 -43
  36. package/src/sync-hosts.test.mjs +67 -56
  37. package/templates/.gitattributes +8 -0
  38. package/templates/AGENTS.md +1 -1
  39. package/templates/_agents/commands/design.md +5 -4
  40. package/templates/_agents/commands/new-task.md +42 -21
  41. package/templates/_agents/commands/review.md +1 -1
  42. package/templates/_agents/commands/sync-hosts.md +7 -13
  43. package/templates/_agents/commands/test.md +1 -1
  44. package/templates/_agents/control-surfaces.txt +1 -1
  45. package/templates/_agents/engine-lock.json +246 -0
  46. package/templates/_agents/lane-surfaces.txt +1 -1
  47. package/templates/_agents/level-hints.txt +30 -0
  48. package/templates/_agents/scripts/agg-delegations.test.mjs +5 -3
  49. package/templates/_agents/scripts/check-engine-integrity.mjs +55 -126
  50. package/templates/_agents/scripts/check-engine-integrity.test.mjs +33 -45
  51. package/templates/_agents/scripts/check-lane-surface.mjs +72 -23
  52. package/templates/_agents/scripts/check-lane-surface.test.mjs +66 -0
  53. package/templates/_agents/scripts/check-ledger-invariant.mjs +112 -14
  54. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +5 -1
  55. package/templates/_agents/scripts/check-loop-rev.test.mjs +6 -4
  56. package/templates/_agents/scripts/check-loop.mjs +49 -41
  57. package/templates/_agents/scripts/check-loop.test.mjs +18 -13
  58. package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
  59. package/templates/_agents/scripts/compact-ledgers.mjs +97 -0
  60. package/templates/_agents/scripts/compact-ledgers.test.mjs +162 -0
  61. package/templates/_agents/scripts/confirm-doc.mjs +40 -11
  62. package/templates/_agents/scripts/doctor.test.mjs +44 -1
  63. package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
  64. package/templates/_agents/scripts/fill-intent.mjs +2 -2
  65. package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
  66. package/templates/_agents/scripts/fixture-scripts.test.mjs +107 -0
  67. package/templates/_agents/scripts/gate-checklist.mjs +4 -9
  68. package/templates/_agents/scripts/gate-checklist.test.mjs +2 -11
  69. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +8 -11
  70. package/templates/_agents/scripts/gen-workflow-metrics.mjs +9 -33
  71. package/templates/_agents/scripts/git-merge-jsonl.mjs +63 -0
  72. package/templates/_agents/scripts/git-merge-jsonl.test.mjs +125 -0
  73. package/templates/_agents/scripts/ledger-doc-chain.test.mjs +166 -0
  74. package/templates/_agents/scripts/ledger-loader.mjs +108 -0
  75. package/templates/_agents/scripts/ledger-loader.test.mjs +131 -0
  76. package/templates/_agents/scripts/ledger-prune.mjs +96 -0
  77. package/templates/_agents/scripts/ledger-prune.test.mjs +120 -0
  78. package/templates/_agents/scripts/ledger-ts-monotonic.test.mjs +135 -0
  79. package/templates/_agents/scripts/level-criteria.mjs +232 -0
  80. package/templates/_agents/scripts/level-criteria.test.mjs +279 -0
  81. package/templates/_agents/scripts/pipeline-run.mjs +4 -2
  82. package/templates/_agents/scripts/policy.mjs +47 -0
  83. package/templates/_agents/scripts/stage-gates.mjs +4 -9
  84. package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
  85. package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
  86. package/templates/_agents/scripts/workflows-check.mjs +1 -1
  87. package/templates/_agents/workflows/pipeline-closing.md +4 -5
  88. package/templates/_githooks/pre-commit +3 -34
  89. package/templates/_githooks/pre-push +3 -3
  90. package/templates/workflow/README.md +11 -0
  91. package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -89
  92. package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -89
  93. package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -89
  94. package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -89
  95. package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -90
  96. package/templates/_agents/commands/source-sync-check.md +0 -103
  97. package/templates/_agents/scripts/check-ledger.mjs +0 -109
  98. package/templates/_agents/scripts/check-ledger.test.mjs +0 -199
  99. package/templates/_agents/scripts/source-sync-check.mjs +0 -156
  100. package/templates/_agents/scripts/source-sync-check.test.mjs +0 -286
  101. package/templates/_agents/workflows/source-sync-repair.md +0 -30
@@ -25,6 +25,8 @@
25
25
  // node .agents/scripts/confirm-doc.mjs <doc...> --delegated "<用户对话原话>"(委托代录)
26
26
  // node .agents/scripts/confirm-doc.mjs <doc...> --delegated "<原话>" --batch(协作道批量:L0/L1 多份一次代录)
27
27
  // node .agents/scripts/confirm-doc.mjs <doc...> --to superseded|cancelled(放弃态:取代/取消,留指纹与台账)
28
+ // node .agents/scripts/confirm-doc.mjs <doc...> --shard(分片写入:多 worktree 并行时各写各的
29
+ // .agents/confirmations.d/<doc>.jsonl,避免同文件并发覆盖;里程碑跑 compact-ledgers.mjs 归并)
28
30
  // 多文档一次传入:TTY 模式天然逐份过目键入;--delegated 默认一次仅一份(2026-09-27
29
31
  // confirm-gate-one-per-call:多份并录曾系统性塌掉 build.md「逐件确认」三道门),L0/L1 协作道可
30
32
  // `--batch` 多份一次代录(2026-09-30 hybrid-governance-risk-lanes):逐份读「级别」,L2/L3 逐份拒绝
@@ -39,8 +41,9 @@ import path from 'node:path';
39
41
  import readline from 'node:readline';
40
42
  import { createHash, randomBytes } from 'node:crypto';
41
43
  import { spawnSync } from 'node:child_process';
44
+ import { readLedgerEntries } from './ledger-loader.mjs';
42
45
  import { confirmGateFor, clarifyGateFor, doneGateFor, laneOfEntry, laneOfDoc } from './stage-gates.mjs';
43
- import { loadKitPolicy, hasFreshVerifyLine, ledgerChainHash, scopeDigest } from './policy.mjs';
46
+ import { loadKitPolicy, hasFreshVerifyLine, ledgerChainHash, ledgerDocChainActive, ledgerDocChainPrev, scopeDigest } from './policy.mjs';
44
47
 
45
48
  // hasFreshVerify(root, doc):读 <root>/.agents/verifications.jsonl 判 24h 窗口绿行(判定单源
46
49
  // policy.hasFreshVerifyLine)。只读,台账缺失/不可读一律 false(=无凭证,advisory 层 fail-open)。
@@ -148,22 +151,38 @@ export function applyTransition(text, targetStage, fingerprint16) {
148
151
  }
149
152
 
150
153
  // appendLedger(root, entry):追加一行 JSON(目录不存在自动建;只 append 不重写)
151
- // 哈希链(2026-10-09 engine-quality-round2 D):每行追加 prevHash(末行 hash,末行无 hash/空文件 → '')
152
- // 与 hash(ledgerChainHash 单源计算,check-loop 15 同函数验链)——「就地改/删中间行」必留断链痕迹;
153
- // 历史行零回填(无 hash 行后链重启),整文件重写不可机器防(边界声明见 policy.ledgerChainHash)。
154
- export function appendLedger(root, entry) {
155
- const p = path.join(root, '.agents', 'confirmations.jsonl');
154
+ // 哈希链(2026-10-09 engine-quality-round2 D):每行追加 prevHash 与 hash(ledgerChainHash 单源计算,
155
+ // check-loop 15 同函数验链)——「就地改/删中间行」必留断链痕迹;历史行零回填(无 hash 行后链重启),
156
+ // 整文件重写不可机器防(边界声明见 policy.ledgerChainHash)。
157
+ // 链域(2026-10-12 ledger-doc-chain):policy.ledgerDocChainSince 存在且合法 → prevHash 取**同行 doc 的
158
+ // 最后一个带 hash 行**(ledgerDocChainPrev),不再取物理末行。理由见 POLICIES v8 注释:物理链隐含
159
+ // 「单写者顺序追加」前提,而并发 append / git 并集合并 / 分片归并三种**正常协作**都会打断它。
160
+ // 缺键(v1-v7)→ 走旧物理链口径,存量零变化。
161
+ // shard(2026-10-12 ledger-shard-write):多 worktree 并行时两个进程同时 append 同一台账文件会互相覆盖
162
+ // (各自读到同一末行 → 同 prevHash → 后写者覆盖前写者)。--shard 把本行写入分片
163
+ // `.agents/confirmations.d/<doc基名>.jsonl`,各 worktree 各写各的、互不冲突;里程碑跑 compact-ledgers.mjs
164
+ // 归并回主台账。**默认仍写主台账**——分片是有意的并发开关,不设则零影响。
165
+ export function appendLedger(root, entry, { shard = false } = {}) {
166
+ const p = shard && entry.doc
167
+ ? path.join(root, '.agents', 'confirmations.d', `${path.basename(entry.doc, '.md')}.jsonl`)
168
+ : path.join(root, '.agents', 'confirmations.jsonl');
156
169
  fs.mkdirSync(path.dirname(p), { recursive: true });
157
170
  let prevHash = '';
158
171
  try {
159
- if (fs.existsSync(p)) {
172
+ if (ledgerDocChainActive(loadKitPolicy(root))) {
173
+ // doc 链取前驱走**台账视图**(主台账 + 分片)而非单个文件:--shard 写入时,该 doc 的历史行
174
+ // 很可能在主台账里,只读分片文件会从 '' 重启 → 链断裂。视图取法天然跨来源衔接。
175
+ const rows = readLedgerEntries(root).map((e) => e.row);
176
+ prevHash = ledgerDocChainPrev(rows, entry.doc);
177
+ } else if (fs.existsSync(p)) {
178
+ // 旧物理链口径:取**本次写入目标文件**的末行 hash(v1-v7 存量行为,逐字不变)
160
179
  const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/).filter((l) => l.trim());
161
180
  if (lines.length) {
162
181
  const last = JSON.parse(lines[lines.length - 1]);
163
182
  prevHash = last && typeof last.hash === 'string' ? last.hash : '';
164
183
  }
165
184
  }
166
- } catch { /* 末行不可解析 → prevHash ''(链从本行重启;断裂由 check-loop 15 出账) */ }
185
+ } catch { /* 不可解析 → prevHash ''(链从本行重启;断裂由 check-loop 15 出账) */ }
167
186
  const { hash: _h, prevHash: _p, ...rest } = entry;
168
187
  const row = { ...rest, prevHash, hash: ledgerChainHash(rest, prevHash) };
169
188
  fs.appendFileSync(p, JSON.stringify(row) + '\n');
@@ -190,6 +209,7 @@ if (isMain) {
190
209
  let toTarget = null; // null = 默认前向跳转;superseded|cancelled = 放弃态显式目标
191
210
  let autoMode = false; // AI 自治模式(信任等级授权,2026-09-30 ai-autonomy-trust)
192
211
  let batchMode = false; // 协作道批量代录(--batch,2026-09-30 hybrid-governance-risk-lanes:仅 --delegated + L0/L1)
212
+ let shardWrite = false; // 分片写入(--shard,2026-10-12 ledger-shard-write):多 worktree 并行时各写各的分片,避免互相覆盖
193
213
  const docs = [];
194
214
  for (let i = 0; i < argv.length; i++) {
195
215
  if (argv[i] === '--root') root = path.resolve(argv[++i]);
@@ -197,12 +217,21 @@ if (isMain) {
197
217
  else if (argv[i] === '--to') toTarget = argv[++i] ?? '';
198
218
  else if (argv[i] === '--auto') autoMode = true;
199
219
  else if (argv[i] === '--batch') batchMode = true;
220
+ else if (argv[i] === '--shard') shardWrite = true;
200
221
  else docs.push(argv[i].replace(/\\/g, '/'));
201
222
  }
202
223
  if (toTarget !== null && !ABANDON_TARGETS.includes(toTarget)) {
203
224
  console.error(`用法:--to 仅接受 superseded|cancelled(现「${toTarget}」)——cancelled 自 draft/approved/open/fixed;superseded 自 approved/done/fixed/closed(未确认态只能 cancelled;终态间仅允许 done/closed→superseded)`);
204
225
  process.exit(1);
205
226
  }
227
+ // --shard 与 doc 链口径绑定(2026-10-12 ledger-shard-write):分片与主台账分属不同文件,
228
+ // 只有 doc 链能跨来源接续;旧物理链口径下分片行的 prevHash 会与主台账末行失配 → 提交即断链。
229
+ // 故缺锚时**参数期直接拒**(友好报错),而不是让用户写完台账再被门禁拦。
230
+ if (shardWrite && !ledgerDocChainActive(loadKitPolicy(root))) {
231
+ console.error('❌ --shard 需要台账 doc 链口径(policy.ledgerDocChainSince),当前 policyVersion 无此键。\n 原因:分片与主台账是不同文件,只有 doc 链能跨来源接续;旧物理链口径下分片行会与主台账断链。\n 修法:在 .agents/kit.json 设 policyVersion: 8(或更高的支持版本)后重试。');
232
+ process.exit(1);
233
+ }
234
+
206
235
  const delegated = delegatedQuote !== null;
207
236
  if (delegated && autoMode) {
208
237
  console.error('❌ --auto 与 --delegated 互斥:自治模式是用户预先授权的机器放行,委托代录是单次对话确认');
@@ -420,7 +449,7 @@ if (isMain) {
420
449
  }
421
450
  fs.writeFileSync(abs, applyTransition(text, target, fp.slice(0, 16)));
422
451
  appendLedger(root, {
423
- ts: new Date().toISOString(), doc, stage: target, fingerprint: fp, prev: st,
452
+ shard: shardWrite, ts: new Date().toISOString(), doc, stage: target, fingerprint: fp, prev: st,
424
453
  source: `ai-auto-trust-L${trustConfig.level}`, quote: `AI 自治放行 (Trust Level ${trustConfig.level})`,
425
454
  batch, seq, of: docs.length, scope,
426
455
  });
@@ -441,7 +470,7 @@ if (isMain) {
441
470
  }
442
471
  fs.writeFileSync(abs, applyTransition(text, target, fp.slice(0, 16)));
443
472
  appendLedger(root, {
444
- ts: new Date().toISOString(), doc, stage: target, fingerprint: fp, prev: st,
473
+ shard: shardWrite, ts: new Date().toISOString(), doc, stage: target, fingerprint: fp, prev: st,
445
474
  source: 'chat-delegated', quote: String(delegatedQuote),
446
475
  // 调用事实(batch-ledger-audit):seq=本次调用内件序(从 1),of=本次调用总份数
447
476
  batch, seq, of: docs.length,
@@ -471,7 +500,7 @@ if (isMain) {
471
500
  }
472
501
  fs.writeFileSync(abs, applyTransition(text, target, fp.slice(0, 16)));
473
502
  appendLedger(root, {
474
- ts: new Date().toISOString(), doc, stage: target, fingerprint: fp, prev: st,
503
+ shard: shardWrite, ts: new Date().toISOString(), doc, stage: target, fingerprint: fp, prev: st,
475
504
  source: 'tty',
476
505
  batch, seq, of: docs.length, // 调用事实(形态无关:TTY 多文档天然逐件过目,of>1 不构成违规)
477
506
  });
@@ -32,7 +32,7 @@ if (!fs.existsSync(SRC)) {
32
32
  console.error(`doctor.test.mjs:SKIP——未找到 ${SRC}(装户环境无包源 src/;本套件仅在包源仓库/dogfooding 跑 npm test 时有意义)`);
33
33
  process.exit(0);
34
34
  }
35
- const { checkOwnedDrift, checkAdapterDrift, checkLedgerCoverage, checkDelegationSnapshotFreshness, checkGateAnchors, checkTemplateDrift, gateTrustOf, GATE_TRUST_LEVELS, GATE_TRUST_DEFAULT } = await import(pathToFileURL(SRC).href);
35
+ const { checkOwnedDrift, checkAdapterDrift, checkLedgerCoverage, checkDelegationSnapshotFreshness, checkGateAnchors, checkTemplateDrift, checkLevelHints, gateTrustOf, GATE_TRUST_LEVELS, GATE_TRUST_DEFAULT } = await import(pathToFileURL(SRC).href);
36
36
 
37
37
  let pass = 0;
38
38
  let fail = 0;
@@ -723,5 +723,48 @@ const GATE_BODY = '// gate v1\n';
723
723
  }
724
724
  }
725
725
 
726
+ // --- 场景 37-40:checkLevelHints(2026-10-13 hint-doctor)---
727
+ // level-hints 配置此前**只**在 flow-kit route 时才校验,而 route 是可选调用 → 写错的清单能静默存活。
728
+ // 本组直测三态:旧装户无判据单源 → skipped;配置合法(含默认空)→ ok;非法档位/ERE → ok=false。
729
+ {
730
+ const mkHintsRoot = (hintsText) => {
731
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-hints-'));
732
+ fs.mkdirSync(path.join(root, '.agents', 'scripts'), { recursive: true });
733
+ fs.copyFileSync(path.join(ROOT, 'templates/_agents/scripts/level-criteria.mjs'),
734
+ path.join(root, '.agents/scripts/level-criteria.mjs'));
735
+ if (hintsText !== null) fs.writeFileSync(path.join(root, '.agents/level-hints.txt'), hintsText);
736
+ return root;
737
+ };
738
+
739
+ {
740
+ const r = mkHintsRoot('L2 ^src/contracts/\nL3 ^src/db/migrations/.*\\.sql$\n');
741
+ const out = checkLevelHints(r);
742
+ check('场景 37:合法 hints → ok 且报条数与档位',
743
+ out.skipped === false && out.ok === true && /2 条有效/.test(out.note) && /L2/.test(out.note) && /L3/.test(out.note),
744
+ JSON.stringify(out));
745
+ fs.rmSync(r, { recursive: true, force: true });
746
+ }
747
+ {
748
+ const r = mkHintsRoot(null); // 缺文件=默认空=纯语义定级
749
+ const out = checkLevelHints(r);
750
+ check('场景 38:清单缺失(默认空)→ ok 且明示未配置', out.ok === true && out.skipped === false && out.note.length > 0, JSON.stringify(out));
751
+ fs.rmSync(r, { recursive: true, force: true });
752
+ }
753
+ {
754
+ const r = mkHintsRoot('L3 ^src/db/[bad(\n'); // 非法 ERE
755
+ const out = checkLevelHints(r);
756
+ check('场景 39:非法 ERE → ok=false 且带清单路径与行号',
757
+ out.ok === false && out.skipped === false && /level-hints/.test(out.detail) && /第 \d+ 行/.test(out.detail), JSON.stringify(out));
758
+ fs.rmSync(r, { recursive: true, force: true });
759
+ }
760
+ {
761
+ const r = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-hints-old-')); // 无判据单源=旧装户
762
+ fs.mkdirSync(path.join(r, '.agents'), { recursive: true });
763
+ const out = checkLevelHints(r);
764
+ check('场景 40:无 level-criteria.mjs(1.7.15 前旧装户)→ skipped 不产出告警', out.skipped === true, JSON.stringify(out));
765
+ fs.rmSync(r, { recursive: true, force: true });
766
+ }
767
+ }
768
+
726
769
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
727
770
  process.exit(fail ? 1 : 0);
@@ -36,16 +36,17 @@ check('globToRe:目录前缀', globToRe('.agents/cache/').test('.agents/cache/
36
36
  }
37
37
 
38
38
  // ---- ② 端到端 fixture ----
39
- const SCRIPTS = ['check-loop.mjs', 'policy.mjs', 'stage-gates.mjs', 'check-metric-claims.mjs', 'check-hygiene.mjs', 'check-stage-index.mjs', 'gate-seg.mjs', 'workflow-enums.mjs', 'edit-face-check.mjs'];
39
+ // 夹具闭包(2026-10-12 fixture-closure-rootfix):此前手写 SCRIPTS 枚举,漏拷 ledger-loader / ledger-prune
40
+ // 就让 check-loop import 失败 → 门禁静默不跑 → **假绿**。改为按入口自动求传递闭包,新增依赖自动跟随。
41
+ const { copyScriptClosure, copyAgentsFiles } = await import('./fixture-scripts.mjs');
40
42
  // 引用完整性哑文件(检查 4/6/7 的存在性判据——避免 fixture 结构性 WARN 淹没断言面)
41
43
  const REF_COMMANDS = ['new-task', 'plan', 'design', 'build', 'test', 'deploy', 'maintain', 'review'];
42
44
  const REF_ROLES = ['implementer', 'independent-reviewer', 'ui-verifier'];
43
45
  function mkFixture({ breakStageIndex, overBudget }) {
44
46
  const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'efc-'));
45
47
  fs.mkdirSync(path.join(fx, '.agents', 'scripts'), { recursive: true });
46
- for (const f of SCRIPTS) fs.copyFileSync(path.join(SRC, f), path.join(fx, '.agents', 'scripts', f));
47
- for (const f of ['kit.json', 'workflow-modules.txt', 'workflow-enums.txt'])
48
- if (fs.existsSync(path.join(SRC, '..', f))) fs.copyFileSync(path.join(SRC, '..', f), path.join(fx, '.agents', f));
48
+ copyScriptClosure(path.join(fx, '.agents', 'scripts'), ['edit-face-check.mjs', 'check-loop.mjs']);
49
+ copyAgentsFiles(path.join(fx, '.agents'), ['kit.json', 'workflow-modules.txt', 'workflow-enums.txt']);
49
50
  fs.mkdirSync(path.join(fx, 'workflow', 'intents'), { recursive: true });
50
51
  for (const d of ['specs', 'plans', 'incidents']) fs.mkdirSync(path.join(fx, 'workflow', d), { recursive: true });
51
52
  for (const c of REF_COMMANDS) { fs.mkdirSync(path.join(fx, '.agents', 'commands'), { recursive: true }); fs.writeFileSync(path.join(fx, '.agents', 'commands', `${c}.md`), `# ${c}\n`); }
@@ -33,8 +33,8 @@ const SECTIONS = [
33
33
  { title: '目标', hint: '- <可验证的目标>' },
34
34
  { title: '非目标(L1 微改动无实质内容可删本节,不硬填)', hint: '- <明确不做的,防止范围蔓延>' },
35
35
  { title: '约束(L1 微改动无实质内容可删本节,不硬填)', hint: '- <复用什么、不许动什么>' },
36
- { title: '影响面', hint: '- 模块:<词表之一,见 .agents/workflow-modules.txt>\n- 数据库:无 / <表名>(涉及 schema 变更 → L3;无 schema 的运行时 / 管线结构面亦为 L3,判据见 .agents/commands/new-task.md §级别判断)\n- 前端页面:<路由或组件,无则删本行>' },
37
- { title: '触达红线(对照 AGENTS.md,勾选仅标记触及范围;具体如何满足在同名 spec 或 Quick-Plan 中说明)', hint: '- [ ] <按本项目 AGENTS.md 红线逐行补;无则删本行>\n- [ ] 规则 / 契约变更(编码权威 / 共享契约 / 接口签名 / 既有参数语义 / 全局口径)→ 级别至少 L2\n- [ ] schema / 迁移 SQL / DI 链 / 认证与中间件管线 → 级别 L3\n\n> 触及任一红线即为 L2 / L3,必须立 ../specs/YYYY-MM-DD-<主题>.md 写明如何满足,方可起草 plan。' },
36
+ { title: '影响面', hint: '- 模块:<词表之一,见 .agents/workflow-modules.txt>\n- 数据库:无 / <表名>(涉及 schema 变更 → L3;无 schema 的运行时 / 管线结构面亦为 L3,判据单源见 .agents/scripts/level-criteria.mjs 的 L3_SURFACES)\n- 前端页面:<路由或组件,无则删本行>' },
37
+ { title: '触达红线(对照 AGENTS.md,勾选仅标记触及范围;具体如何满足在同名 spec 或 Quick-Plan 中说明)', hint: '- [ ] <按本项目 AGENTS.md 红线逐行补;无则删本行>\n- [ ] 触及判据单源 `.agents/scripts/level-criteria.mjs` 的 `L2_SURFACES` 触达面闭集 → 级别至少 L2\n- [ ] 触及同源的 `L3_SURFACES` 结构面闭集 → 级别 L3\n\n> 两条闭集只在该单源一处定义,**逐条对照单源勾选,不要凭记忆复述**(此处曾各写一份且条目不一致,判低即错判)。\n> 触及任一红线即为 L2 / L3,必须立 ../specs/YYYY-MM-DD-<主题>.md 写明如何满足,方可起草 plan。' },
38
38
  { title: '验收标准(可测试)', hint: '- [ ] <逐条可测试;写不出可测试判据 = 还没想清楚>\n\n> **闭环对账**:关单在 test 阶段(不依赖 deploy)。intent 置 done 前逐条勾验,每条补证据——`- [x] <判据>(证据:<commit SHA / 测试用例名 / 冒烟脚本输出>)`。\n> done 状态仍有未勾项会被 check-loop 拦截(2026-09-12 起新建 intent 为 hard-block,存量 intent 仅 warning 提示);勾选但缺「证据:」为 hard-block。' },
39
39
  ];
40
40
 
@@ -0,0 +1,153 @@
1
+ // fixture-scripts.mjs — 测试夹具脚本闭包工具(2026-10-12 fixture-closure-rootfix)
2
+ //
3
+ // 为什么有它:夹具此前用**手工枚举**把被测脚本及其依赖拷进临时根(如 `const SCRIPTS = ['check-loop.mjs',
4
+ // 'policy.mjs', ...]`)。那份枚举必须与被测脚本真实的依赖闭包一致,但两者互不校验——给脚本加一行
5
+ // `import './x.mjs'`,枚举不会自动跟着变,而滞后的表现是**静默绿**:ESM resolve 失败 → 被测脚本根本没跑
6
+ // → 断言面为空 → 套件判绿。2026-10-12 那批台账改动就因此在 `check-loop.test` / `edit-face-check.test` /
7
+ // `ledger-prune.test` 三处先后假绿(各自补拷才转绿)。补齐是止血,消灭枚举才是根治。
8
+ //
9
+ // 本模块给两样东西,各自独立成立:
10
+ // ① scriptClosure / copyScriptClosure —— 自动求依赖传递闭包并整闭包拷贝,维护面归零(新增依赖自动跟随);
11
+ // ② assertFixtureScripts —— 拷完在**目标目录**独立复扫一遍,任一相对依赖解析不到就抛错报全量缺失清单。
12
+ // 这是纵深防御:即使将来有人又手写枚举(或绕过本 helper 直接拷),漏拷会在建夹具那一刻响亮炸掉,
13
+ // 而不是几十个场景之后以「门禁没跑」的形式悄悄绿掉。
14
+ //
15
+ // 依赖发现口径(刻意取宽):**静态 import/export from**、**动态 import()**、以及**裸文件名字面量**
16
+ // (形如 path.join(here, 'kb-search.mjs') 的 spawn 依赖——wiki-search.mjs 就只靠这一条挂住 kb-search;
17
+ // check-hygiene 靠它挂住 rule-budget.sh 与两个生成器)。裸文件名只在**解析到同目录真实文件**时才计入,
18
+ // 故 `'../hooks/commit-check.cjs'` 这类跨目录字面量天然被排除(scripts/ 下不存在同名文件)。
19
+ // 宁可多拷(多一个文件不会让测试变假绿),不可漏拷(漏拷就是假绿)。
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ export const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
25
+
26
+ const SCRIPT_EXT = /\.(?:mjs|cjs|js|sh)$/;
27
+ // 裸文件名 / ./x / ../x 三种形态;含目录分隔的更长路径字面量(如 'templates/_agents/scripts/gate.mjs')不匹配。
28
+ // 点开头的隐藏名不匹配:check-loop.mjs 里有 `.replace(/\.mjs$/, '.test.mjs')`,那个 '.test.mjs'
29
+ // 是**后缀字面量**不是文件名,按名解析必然扑空(对每个测试夹具都误报「漏拷」→ 整套件红)。
30
+ const BARE_NAME = /^(?:\.{1,2}\/)?[A-Za-z0-9_][A-Za-z0-9_.-]*$/;
31
+ // 引号字面量(单/双引号皆可),逐行扫即可——模板字面量里的文件名由动态拼接给出,不在此列
32
+ const QUOTED = /'([^'\n]*)'|"([^"\n]*)"/g;
33
+
34
+ // stripComments:抹掉注释,保留字符串字面量内容。
35
+ // 刻意**不用正则**:正则版把 `confirmations.d/*.jsonl` 这类注释里的 `/*` 当块注释起点,跨注释一路吃到下一个
36
+ // 真 `*/`,把后面的 import 整段吃掉(check-ledger-invariant 实测:12141 字符被砍到 4198,43 行的
37
+ // import 直接消失 → 闭包漏依赖 → 回到假绿)。故走一个 40 行的逐字符状态机,只认三种真形态。
38
+ // 字符串内容**不抹**(我们要的就是字面量里的文件名),故正则里的 ".*?/*.jsonl" 这类串不被误当注释。
39
+ function stripComments(src) {
40
+ let out = '';
41
+ let i = 0;
42
+ const n = src.length;
43
+ while (i < n) {
44
+ const c = src[i];
45
+ const d = src[i + 1];
46
+ if (c === '/' && d === '/') { while (i < n && src[i] !== '\n') i++; continue; }
47
+ if (c === '/' && d === '*') {
48
+ i += 2;
49
+ while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
50
+ i += 2;
51
+ continue;
52
+ }
53
+ if (c === '"' || c === "'" || c === '`') {
54
+ const quote = c;
55
+ out += c;
56
+ i++;
57
+ while (i < n) {
58
+ if (src[i] === '\\') { out += src[i] + (src[i + 1] || ''); i += 2; continue; }
59
+ out += src[i];
60
+ if (src[i] === quote) { i++; break; }
61
+ i++;
62
+ }
63
+ continue;
64
+ }
65
+ out += c;
66
+ i++;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // depsOf:某脚本源码里引用的、可能落在同目录的脚本文件名(去重、保持出现序)。
72
+ // 纯文本解析——不查盘面,故「源码引用了但盘面没有的名字」也会被列出,由调用方按盘面存在性收窄。
73
+ export function depsOf(sourceText) {
74
+ const out = [];
75
+ const push = (v) => { if (v && !out.includes(v)) out.push(v); };
76
+ const text = stripComments(sourceText);
77
+ // import/export ... from '...' 与 import('...')
78
+ for (const m of text.matchAll(/(?:\bfrom\s*|\bimport\s*\(\s*)(['"])([^'"]+)\1/g)) push(m[2]);
79
+ // 其余裸文件名字面量(spawn / path.join 依赖)
80
+ for (const m of text.matchAll(QUOTED)) push(m[1] || m[2]);
81
+ return out.filter((v) => SCRIPT_EXT.test(v) && BARE_NAME.test(v));
82
+ }
83
+
84
+ // scriptClosure:从入口出发递归求同目录依赖传递闭包(BFS,稳定序;不存在的引用不炸——由 assert 层兜)
85
+ export function scriptClosure(entries, { from = SCRIPT_DIR } = {}) {
86
+ const queue = [...new Set(entries)].map((e) => path.basename(e));
87
+ const seen = new Set();
88
+ const order = [];
89
+ while (queue.length) {
90
+ const name = queue.shift();
91
+ if (seen.has(name)) continue;
92
+ seen.add(name);
93
+ order.push(name);
94
+ const abs = path.join(from, name);
95
+ if (!fs.existsSync(abs)) continue;
96
+ for (const dep of depsOf(fs.readFileSync(abs, 'utf8'))) {
97
+ const base = path.basename(dep);
98
+ if (!seen.has(base)) queue.push(base);
99
+ }
100
+ }
101
+ return order;
102
+ }
103
+
104
+ // copyScriptClosure:把入口的整个依赖闭包拷进 destScriptsDir,返回实际拷入的文件名。
105
+ // opts.extra —— 额外要拷的同级非脚本件(如 rule-budget.sh 之外的 .txt 词表由 copyAgentsFiles 负责)。
106
+ export function copyScriptClosure(destScriptsDir, entries, opts = {}) {
107
+ const from = opts.from || SCRIPT_DIR;
108
+ fs.mkdirSync(destScriptsDir, { recursive: true });
109
+ const closure = scriptClosure(entries, { from });
110
+ const copied = [];
111
+ for (const name of closure) {
112
+ const src = path.join(from, name);
113
+ if (!fs.existsSync(src) || !fs.statSync(src).isFile()) continue;
114
+ const dest = path.join(destScriptsDir, name);
115
+ fs.copyFileSync(src, dest);
116
+ copied.push(name);
117
+ }
118
+ // 纵深防御:在**目标目录**独立复扫,不复用上面的闭包结果(这样才抓得到拷贝环节本身的错漏)
119
+ assertFixtureScripts(destScriptsDir);
120
+ return copied;
121
+ }
122
+
123
+ // copyAgentsFiles:把包源 `.agents/` 面的词表/配置(workflow-enums.txt 等)拷进夹具的 .agents/ 根
124
+ export function copyAgentsFiles(destAgentsDir, names, { from = path.join(SCRIPT_DIR, '..') } = {}) {
125
+ fs.mkdirSync(destAgentsDir, { recursive: true });
126
+ const copied = [];
127
+ for (const name of names) {
128
+ const src = path.join(from, name);
129
+ if (!fs.existsSync(src)) continue;
130
+ fs.copyFileSync(src, path.join(destAgentsDir, name));
131
+ copied.push(name);
132
+ }
133
+ return copied;
134
+ }
135
+
136
+ // assertFixtureScripts:扫目录内全部脚本,任一相对/裸文件名依赖解析不到 → 抛错(报全量缺失,不报第一个)
137
+ // 用法:夹具建好后立即调;漏拷在当场炸,而不是在「门禁静默不跑」之后绿掉。
138
+ export function assertFixtureScripts(dir) {
139
+ const missing = [];
140
+ if (!fs.existsSync(dir)) throw new Error(`assertFixtureScripts: 目录不存在 ${dir}`);
141
+ for (const f of fs.readdirSync(dir).filter((f) => SCRIPT_EXT.test(f)).sort()) {
142
+ const src = fs.readFileSync(path.join(dir, f), 'utf8');
143
+ for (const dep of depsOf(src)) {
144
+ if (dep.includes('/') && dep.startsWith('..')) continue; // 跨目录引用不在本夹具职责内
145
+ if (!fs.existsSync(path.join(dir, dep))) missing.push(`${f} → ${dep}`);
146
+ }
147
+ }
148
+ if (missing.length) {
149
+ throw new Error(`夹具依赖缺失(漏拷 → 被测脚本 import 失败 → 门禁静默不跑 = 假绿):\n ${missing.join('\n ')}\n`
150
+ + '修法:改用 copyScriptClosure(入口脚本) 让闭包自动跟随,不要手写枚举。');
151
+ }
152
+ return true;
153
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // fixture-scripts.test.mjs — 夹具闭包工具自身套件(2026-10-12 fixture-closure-rootfix)
3
+ // 命题:本工具**只能比手工枚举多拷,绝不能少拷**——多拷的文件不会让测试假绿,少拷的文件会让
4
+ // 被测脚本 import 失败、门禁静默不跑、套件假绿(2026-10-12 台账那批三处假绿的根因)。
5
+ // 覆盖面:多层传递依赖 / node: 内建跳过 / 注释与字符串字面量不误收 / 后缀字面量('.test.mjs')不误报 /
6
+ // 缺失即炸且报全量清单 / spawn 依赖(裸文件名字面量)也被收进闭包。
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { SCRIPT_DIR, depsOf, scriptClosure, copyScriptClosure, assertFixtureScripts, copyAgentsFiles } from './fixture-scripts.mjs';
11
+
12
+ let pass = 0, fail = 0;
13
+ const check = (name, ok, detail = '') => {
14
+ if (ok) { pass++; console.log(` ok ${name}`); }
15
+ else { fail++; console.error(`FAIL ${name}${detail ? ' — ' + detail : ''}`); }
16
+ };
17
+ const tmpRoot = (p) => fs.mkdtempSync(path.join(os.tmpdir(), `fxsc-${p}-`));
18
+ // 造一个合成 scripts 目录(不触真实盘面——本工具的依赖发现是纯文本解析,故合成目录可独立验证)
19
+ const synth = (files) => {
20
+ const dir = tmpRoot('synth');
21
+ for (const [name, body] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), body, 'utf8');
22
+ return dir;
23
+ };
24
+
25
+ // ---- S1:多层传递依赖闭包 ----
26
+ {
27
+ const src = synth({
28
+ 'a.mjs': `import './b.mjs';\nimport { x } from './sub/c.mjs';\n`,
29
+ 'b.mjs': `import './c.mjs';\n`,
30
+ 'c.mjs': `export const x = 1;\n`,
31
+ 'd.mjs': `export const y = 2;\n`,
32
+ });
33
+ const cl = scriptClosure(['a.mjs'], { from: src });
34
+ check('S1 三层传递依赖全部收进闭包', ['a.mjs', 'b.mjs', 'c.mjs'].every((f) => cl.includes(f)) && !cl.includes('d.mjs'), cl.join(','));
35
+ }
36
+
37
+ // ---- S2:node: 内建与跨目录字面量不误收 ----
38
+ {
39
+ const src = synth({
40
+ 'a.mjs': `import fs from 'node:fs';\nimport { x } from './b.mjs';\nconst p = path.join(h, 'templates/_agents/scripts/gate.mjs');\n`,
41
+ 'b.mjs': 'export const x=1;\n',
42
+ });
43
+ const cl = scriptClosure(['a.mjs'], { from: src });
44
+ check('S2 node: 内建与长路径字面量被排除', cl.length === 2, cl.join(','));
45
+ }
46
+
47
+ // ---- S3:注释与字符串里的脚本名不算依赖 ----
48
+ {
49
+ const deps = depsOf(`// 拷 './gone.mjs' 进来\n/* from './gone2.mjs' */\nimport './real.mjs';\n`);
50
+ check('S3 注释里的文件名不算依赖', deps.length === 1 && deps[0] === './real.mjs', JSON.stringify(deps));
51
+ }
52
+
53
+ // ---- S4:后缀字面量('.test.mjs')不误报——防「每个夹具都误报漏拷」 ----
54
+ // 这条是真实回归:check-loop.mjs 里就有 `.replace(/\.mjs$/, '.test.mjs')`,按名解析必然扑空。
55
+ {
56
+ const deps = depsOf(`f.replace(/\\.mjs$/, '.test.mjs')\nimport './real.mjs';\n`);
57
+ check('S4 后缀字面量 .test.mjs 不计入依赖', !deps.some((d) => d.includes('test')), JSON.stringify(deps));
58
+ }
59
+
60
+ // ---- S5:spawn 依赖(裸文件名字面量)也进闭包——wiki-search 只靠 path.join(here,'kb-search.mjs') 挂住依赖 ----
61
+ {
62
+ const src = synth({
63
+ 'wiki-search.mjs': `spawnSync(process.execPath, [path.join(here, 'kb-search.mjs')]);\n`,
64
+ 'kb-search.mjs': `import './workflow-enums.mjs';\n`,
65
+ 'workflow-enums.mjs': 'export const a=1;\n',
66
+ });
67
+ const cl = scriptClosure(['wiki-search.mjs'], { from: src });
68
+ check('S5 spawn 裸文件名依赖传递收进闭包', cl.includes('kb-search.mjs') && cl.includes('workflow-enums.mjs'), cl.join(','));
69
+ }
70
+
71
+ // ---- S6:缺失即炸且报全量清单(非报第一个就停) ----
72
+ {
73
+ const dir = tmpRoot('missing');
74
+ fs.writeFileSync(path.join(dir, 'a.mjs'), `import './m1.mjs';\nimport './m2.mjs';\n`, 'utf8');
75
+ let msg = '';
76
+ try { assertFixtureScripts(dir); } catch (e) { msg = String(e.message); }
77
+ check('S6 缺依赖 → 抛错', !!msg, '未抛错');
78
+ check('S6 报全量缺失清单(m1+m2 都在)', msg.includes('m1.mjs') && msg.includes('m2.mjs'), msg);
79
+ check('S6 报文点明假绿与修法', msg.includes('假绿') && msg.includes('copyScriptClosure'), msg.slice(0, 120));
80
+ }
81
+
82
+ // ---- S7:copyScriptClosure 对真实盘面跑通(check-loop 闭包 + 拷后自检不炸) ----
83
+ {
84
+ const dest = path.join(tmpRoot('copy'), 'scripts');
85
+ const copied = copyScriptClosure(dest, ['check-loop.mjs']);
86
+ check('S7 check-loop 闭包含新台账依赖', ['check-loop.mjs', 'ledger-loader.mjs', 'ledger-prune.mjs'].every((f) => copied.includes(f)), copied.join(','));
87
+ check('S7 拷贝后 assertFixtureScripts 不炸(闭包自洽)', assertFixtureScripts(dest) === true);
88
+ // 真跑一次被测脚本,确认夹具不是「文件齐了但跑不起来」
89
+ const fx = path.dirname(dest);
90
+ fs.mkdirSync(path.join(fx, '.agents'), { recursive: true });
91
+ fs.copyFileSync(SCRIPT_DIR.replace(/scripts$/, 'workflow-enums.txt'), path.join(fx, '.agents', 'workflow-enums.txt'));
92
+ const { spawnSync } = await import('node:child_process');
93
+ const r = spawnSync(process.execPath, [path.join(dest, 'check-loop.mjs')], {
94
+ cwd: fx, encoding: 'utf8', env: { ...process.env, CHECK_LOOP_ROOT: fx },
95
+ });
96
+ check('S7 夹具内 check-loop 真能跑起来(非 ENOENT 崩)', !/Cannot find module|ERR_MODULE_NOT_FOUND/.test(`${r.stdout || ''}${r.stderr || ''}`), `${r.stdout || ''}${r.stderr || ''}`.slice(0, 200));
97
+ }
98
+
99
+ // ---- S8:copyAgentsFiles 拷词表,缺失的静默跳过 ----
100
+ {
101
+ const dest = tmpRoot('agents');
102
+ const copied = copyAgentsFiles(dest, ['workflow-enums.txt', 'definitely-absent.txt']);
103
+ check('S8 存在词表拷入、缺席跳过', copied.length === 1 && copied[0] === 'workflow-enums.txt', copied.join(','));
104
+ }
105
+
106
+ console.log(`\n[fixture-scripts] PASS ${pass} / FAIL ${fail}`);
107
+ process.exit(fail ? 1 : 0);
@@ -67,6 +67,7 @@ const PAIRS = [
67
67
  { doctor: '6.7', cl: null, note: '跨宿主薄适配——装户体检独有' },
68
68
  { doctor: '6.8', cl: null, note: 'workflows 编排脚本 lint——装户体检独有' },
69
69
  { doctor: '6.9', cl: null, note: '模板下发感知(2026-10-06 template-downstream)——装户体检独有(只读回显 sync 出账同源判据)' },
70
+ { doctor: '6.10', cl: null, note: 'level-hints 配置体检(2026-10-13 hint-doctor)——装户体检独有;提交面判低由 check-lane-surface(pre-commit 增量面)承担,不在 check-loop 项内' },
70
71
  { doctor: '8', cl: null, note: '看板端口——信息级独有' },
71
72
  // 经 §7 覆盖:doctor §7 整体运行 check-loop(#2/#11 已直接配对,不重复登记)
72
73
  { doctor: '7', cl: '1', note: '经 §7 整体运行覆盖' },
@@ -176,17 +177,11 @@ if (isMain) {
176
177
  const pkgRoot = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : null;
177
178
  const base = pkgRoot || process.cwd();
178
179
  const doctorPath = path.join(base, 'src', 'doctor.mjs');
179
- // check-loop 解析序:装副本 .agents/(装户径)→ 包源脚本同级(纯包源径,装户端内容清理后形态)
180
+ // check-loop 与本脚本同目录:包源 templates/_agents/scripts/,装户副本 .agents/scripts/。
180
181
  const here = path.dirname(fileURLToPath(import.meta.url));
181
- const clPathMjs = path.join(base, '.agents', 'scripts', 'check-loop.mjs');
182
- const clCandidates = [
183
- clPathMjs,
184
- path.join(base, '.agents', 'scripts', 'check-loop.sh'),
185
- path.join(here, 'check-loop.mjs'),
186
- ];
187
- const clPath = clCandidates.find((p) => fs.existsSync(p));
182
+ const clPath = path.join(here, 'check-loop.mjs');
188
183
  if (!fs.existsSync(doctorPath)) fail('找不到 doctor.mjs:' + doctorPath);
189
- if (!clPath) fail('找不到 check-loop(.mjs / .sh 均无):' + clPathMjs);
184
+ if (!fs.existsSync(clPath)) fail('找不到 check-loop.mjs:' + clPath);
190
185
  const doctorSrc = fs.readFileSync(doctorPath, 'utf8');
191
186
  const checkLoopSrc = fs.readFileSync(clPath, 'utf8');
192
187
  const result = gateChecklist({ doctorSrc, checkLoopSrc });
@@ -152,17 +152,8 @@ const y = 2;
152
152
  }
153
153
 
154
154
  const realDoctor = () => fs.readFileSync(path.join(SRC_ROOT, 'src', 'doctor.mjs'), 'utf8');
155
- // check-loop 解析面与 CLI 同口径:.mjs 优先(2026-09-26 迁移后主位),fallback .sh(旧装户)
156
- // 装户端内容清理后(2026-10-09):纯包源仓无 .agents/ 装副本——check-loop.mjs 在包源 scripts/ 下,
157
- // 即本测试同级;.agents/ 优先(装户径),包源径兜底(纯包源径),两者皆无 → hasReal 已经把守。
158
- const realCl = () => {
159
- const mjs = path.join(SRC_ROOT, '.agents', 'scripts', 'check-loop.mjs');
160
- if (fs.existsSync(mjs)) return fs.readFileSync(mjs, 'utf8');
161
- const sh = path.join(SRC_ROOT, '.agents', 'scripts', 'check-loop.sh');
162
- if (fs.existsSync(sh)) return fs.readFileSync(sh, 'utf8');
163
- const pkgMjs = path.join(SCRIPT_DIR, 'check-loop.mjs');
164
- return fs.readFileSync(pkgMjs, 'utf8');
165
- };
155
+ // check-loop 与 gate-checklist.mjs 同目录(包源 scripts/,拷进装户后是 .agents/scripts/)。
156
+ const realCl = () => fs.readFileSync(path.join(SCRIPT_DIR, 'check-loop.mjs'), 'utf8');
166
157
  const hasReal = fs.existsSync(path.join(SRC_ROOT, 'src', 'doctor.mjs'));
167
158
 
168
159
  // ---- S10 真实仓库 baseline:PAIRS 全量登记 → 0 断档 / 0 未登记 ----
@@ -19,6 +19,7 @@ import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { createRequire } from 'node:module';
22
+ import { readLedgerRows } from './ledger-loader.mjs';
22
23
 
23
24
  const require = createRequire(import.meta.url);
24
25
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -82,17 +83,13 @@ function collectDoneIntents(root) {
82
83
  // collectDoneMap:台账 earliest done 行(坏行容忍,doc 正斜杠根相对——沿检查 14/15 口径)
83
84
  function collectDoneMap(root) {
84
85
  const doneMap = new Map();
85
- const ledgerPath = path.join(root, '.agents', 'confirmations.jsonl');
86
- if (!fs.existsSync(ledgerPath)) return doneMap;
87
- for (const line of fs.readFileSync(ledgerPath, 'utf8').split(/\r?\n/)) {
88
- if (!line.trim()) continue;
89
- try {
90
- const e = JSON.parse(line);
91
- if (e && e.stage === 'done' && typeof e.doc === 'string' && e.doc.startsWith('workflow/intents/')) {
92
- const prev = doneMap.get(e.doc);
93
- if (prev === undefined || e.ts < prev) doneMap.set(e.doc, e.ts);
94
- }
95
- } catch { /* 坏行容忍跳过 */ }
86
+ // 读法收敛 ledger-loader(2026-10-12 ledger-loader-p1):earliest done 行取自时间戳比较,
87
+ // 与行序无关,故并入台账视图不改变本函数语义(分片行同样参与「最早」判定)
88
+ for (const e of readLedgerRows(root)) {
89
+ if (e && e.stage === 'done' && typeof e.doc === 'string' && e.doc.startsWith('workflow/intents/')) {
90
+ const prev = doneMap.get(e.doc);
91
+ if (prev === undefined || e.ts < prev) doneMap.set(e.doc, e.ts);
92
+ }
96
93
  }
97
94
  return doneMap;
98
95
  }