agentic-flow-kit 1.5.0 → 1.7.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 (43) hide show
  1. package/modules/hosts/claude/commands/wf-build.md +7 -0
  2. package/modules/hosts/claude/commands/wf-design.md +6 -0
  3. package/modules/hosts/claude/commands/wf-new-task.md +2 -0
  4. package/modules/hosts/codex/commands/wf-build.md +7 -0
  5. package/modules/hosts/codex/commands/wf-design.md +6 -0
  6. package/modules/hosts/codex/commands/wf-new-task.md +2 -0
  7. package/modules/hosts/cursor/commands/wf-build.md +7 -0
  8. package/modules/hosts/cursor/commands/wf-design.md +6 -0
  9. package/modules/hosts/cursor/commands/wf-new-task.md +2 -0
  10. package/modules/hosts/opencode/commands/wf-build.md +7 -0
  11. package/modules/hosts/opencode/commands/wf-design.md +6 -0
  12. package/modules/hosts/opencode/commands/wf-new-task.md +2 -0
  13. package/modules/hosts/trae/commands/wf-build.md +7 -0
  14. package/modules/hosts/trae/commands/wf-design.md +6 -0
  15. package/modules/hosts/trae/commands/wf-new-task.md +2 -0
  16. package/package.json +1 -1
  17. package/src/run-tests.mjs +16 -9
  18. package/src/sync-hosts.mjs +100 -62
  19. package/src/sync-hosts.test.mjs +39 -0
  20. package/templates/AGENTS.md +2 -1
  21. package/templates/_agents/commands/build.md +7 -0
  22. package/templates/_agents/commands/design.md +6 -0
  23. package/templates/_agents/commands/new-task.md +2 -0
  24. package/templates/_agents/scripts/check-ledger-invariant.mjs +18 -0
  25. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +16 -0
  26. package/templates/_agents/scripts/check-loop.mjs +122 -28
  27. package/templates/_agents/scripts/check-loop.test.mjs +121 -0
  28. package/templates/_agents/scripts/check-stage-index.mjs +40 -0
  29. package/templates/_agents/scripts/check-stage-index.test.mjs +77 -0
  30. package/templates/_agents/scripts/confirm-doc.mjs +52 -3
  31. package/templates/_agents/scripts/confirm-doc.test.mjs +51 -1
  32. package/templates/_agents/scripts/edit-face-check.mjs +107 -0
  33. package/templates/_agents/scripts/edit-face-check.test.mjs +98 -0
  34. package/templates/_agents/scripts/fill-intent.mjs +32 -3
  35. package/templates/_agents/scripts/fill-intent.test.mjs +13 -0
  36. package/templates/_agents/scripts/gen-workflow-metrics.mjs +52 -1
  37. package/templates/_agents/scripts/policy.mjs +62 -0
  38. package/templates/_agents/scripts/verify-wiki-consistency.test.mjs +81 -0
  39. package/templates/_agents/scripts/verify.mjs +11 -3
  40. package/templates/_agents/scripts/verify.test.mjs +30 -3
  41. package/templates/_agents/scripts/wiki-search.test.mjs +58 -0
  42. package/templates/_agents/scripts-test-exempt.txt +2 -3
  43. package/templates/_github/workflows/kit-ci.yml +21 -1
@@ -38,8 +38,9 @@ import fs from 'node:fs';
38
38
  import path from 'node:path';
39
39
  import readline from 'node:readline';
40
40
  import { createHash, randomBytes } from 'node:crypto';
41
+ import { spawnSync } from 'node:child_process';
41
42
  import { confirmGateFor, doneGateFor, laneOfEntry, laneOfDoc } from './stage-gates.mjs';
42
- import { loadKitPolicy, hasFreshVerifyLine } from './policy.mjs';
43
+ import { loadKitPolicy, hasFreshVerifyLine, ledgerChainHash, scopeDigest } from './policy.mjs';
43
44
 
44
45
  // hasFreshVerify(root, doc):读 <root>/.agents/verifications.jsonl 判 24h 窗口绿行(判定单源
45
46
  // policy.hasFreshVerifyLine)。只读,台账缺失/不可读一律 false(=无凭证,advisory 层 fail-open)。
@@ -65,6 +66,33 @@ function docRelOf(root, doc) {
65
66
  }
66
67
  }
67
68
 
69
+ // gitDirtyFiles(root):机器实测的「工作树处于改动态的文件集合」(已跟踪改动 + 未跟踪新件,gitignore 项天然排除)。
70
+ // 解析 `git status --porcelain`(-z 不用,逐行解析足够;重命名行取 `->` 后段)。
71
+ // 非 git 仓库 / git 不可用 / 命令失败 → [](**静默降级为「无额外改动面」而非假装有**——scope 退化成
72
+ // 只含被确认文档本身,仍然自洽可验;不因探测失败而拒绝关单)。
73
+ function gitDirtyFiles(root) {
74
+ try {
75
+ const r = spawnSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8', timeout: 10_000 });
76
+ if (r.status !== 0 || !r.stdout) return [];
77
+ return r.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
78
+ const p = line.slice(3).trim();
79
+ const arrow = p.lastIndexOf(' -> ');
80
+ const rel = arrow >= 0 ? p.slice(arrow + 4) : p;
81
+ return rel.replace(/^"(.*)"$/, '$1');
82
+ }).filter(Boolean);
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ // machineScope(root, doc):落账用的改动面事实(2026-10-09 审查 A1 干净版)。
89
+ // **AI 无输入面**——files 全部由机器派生(被确认文档 + git 实测工作树改动集),不是调用方能填的参数。
90
+ // 这正是相对旧设计的核心改进:旧的 quote 是 AI 自填的自由文本(可复用、可伪造、机器不可校验);
91
+ // scope 是机器事实,AI 填不了假,对质时问的是「这 N 个文件」而非「一句说过的话」。
92
+ // 归一/摘要计算单源在 policy.scopeDigest(check-loop 检查 15 侧校验复用同一实现)。
93
+ export function machineScope(root, doc) {
94
+ return scopeDigest([docRelOf(root, doc), ...gitDirtyFiles(root)]);
95
+ }
68
96
  // computeFingerprint(text):CRLF 归一 → 剔指纹行 → sha256 hex(64 位)
69
97
  export function computeFingerprint(text) {
70
98
  const norm = String(text).replace(/\r\n/g, '\n').split('\n')
@@ -120,10 +148,25 @@ export function applyTransition(text, targetStage, fingerprint16) {
120
148
  }
121
149
 
122
150
  // 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)。
123
154
  export function appendLedger(root, entry) {
124
155
  const p = path.join(root, '.agents', 'confirmations.jsonl');
125
156
  fs.mkdirSync(path.dirname(p), { recursive: true });
126
- fs.appendFileSync(p, JSON.stringify(entry) + '\n');
157
+ let prevHash = '';
158
+ try {
159
+ if (fs.existsSync(p)) {
160
+ const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/).filter((l) => l.trim());
161
+ if (lines.length) {
162
+ const last = JSON.parse(lines[lines.length - 1]);
163
+ prevHash = last && typeof last.hash === 'string' ? last.hash : '';
164
+ }
165
+ }
166
+ } catch { /* 末行不可解析 → prevHash ''(链从本行重启;断裂由 check-loop 15 出账) */ }
167
+ const { hash: _h, prevHash: _p, ...rest } = entry;
168
+ const row = { ...rest, prevHash, hash: ledgerChainHash(rest, prevHash) };
169
+ fs.appendFileSync(p, JSON.stringify(row) + '\n');
127
170
  return p;
128
171
  }
129
172
 
@@ -345,6 +388,9 @@ if (isMain) {
345
388
  }
346
389
  }
347
390
  const fp = computeFingerprint(text);
391
+ // 改动面事实(2026-10-09 审查 A1 干净版):机器派生,三形态(TTY / 委托代录 / AI 自治)一律记入——
392
+ // TTY 形态同样需要(事后对质问的是「放行时盘面上哪些文件在动」,与谁点的确认无关)。
393
+ const scope = machineScope(root, doc);
348
394
  // AI 自治放行分支(2026-09-30 ai-autonomy-trust):
349
395
  // ① 仅 L0/L1(读 frontmatter「级别」,缺级别按不可自治处理——fail-closed);
350
396
  // ② Level 1:仅 draft→approved;Level 2:draft→approved 与 approved→done;
@@ -363,7 +409,7 @@ if (isMain) {
363
409
  appendLedger(root, {
364
410
  ts: new Date().toISOString(), doc, stage: target, fingerprint: fp, prev: st,
365
411
  source: `ai-auto-trust-L${trustConfig.level}`, quote: `AI 自治放行 (Trust Level ${trustConfig.level})`,
366
- batch, seq, of: docs.length,
412
+ batch, seq, of: docs.length, scope,
367
413
  });
368
414
  console.log(`✓ ${doc} ${st} → ${target}(指纹 ${fp.slice(0, 16)},AI 自治已记账:source=ai-auto-trust-L${trustConfig.level})`);
369
415
  confirmed++;
@@ -386,6 +432,9 @@ if (isMain) {
386
432
  source: 'chat-delegated', quote: String(delegatedQuote),
387
433
  // 调用事实(batch-ledger-audit):seq=本次调用内件序(从 1),of=本次调用总份数
388
434
  batch, seq, of: docs.length,
435
+ // 改动面事实(A1 干净版):quote 降级为「人类可读摘要」,分辨力改由 scope(机器派生)+ fingerprint
436
+ // (文档内容)承担——「不复用同句」纪律据此退役(复用一句摘要不再削弱任何判据)。
437
+ scope,
389
438
  // 简洁审计标记(hybrid-governance-risk-lanes):L0/L1 协作道行带 brief(quote 仍入账供对质),
390
439
  // 检查 15 并录审计对全 brief 批次豁免「确认并录」告警
391
440
  ...(low ? { brief: true } : {}),
@@ -13,7 +13,8 @@ import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import { fileURLToPath } from 'node:url';
16
- import { computeFingerprint, nextStage, applyTransition, appendLedger, resolveTransition } from './confirm-doc.mjs';
16
+ import { computeFingerprint, nextStage, applyTransition, appendLedger, resolveTransition, machineScope } from './confirm-doc.mjs';
17
+ import { scopeDigest, scopeMatches } from './policy.mjs';
17
18
 
18
19
  let pass = 0;
19
20
  let fail = 0;
@@ -104,6 +105,23 @@ const readLedgerOf = (root) => {
104
105
  fs.rmSync(root, { recursive: true, force: true });
105
106
  }
106
107
 
108
+ // ---- S8b 哈希链(2026-10-09 engine-quality-round2 D):链字段写入 + 接续 + 重算自洽 ----
109
+ {
110
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'confirm-chain-'));
111
+ // 先写一行无 hash 的「存量」行(模拟 v1.5.0 前台账)——新行 prevHash 应为 ''(链重启)
112
+ fs.mkdirSync(path.join(root, '.agents'), { recursive: true });
113
+ fs.writeFileSync(path.join(root, '.agents', 'confirmations.jsonl'), JSON.stringify({ ts: 'T0', doc: 'workflow/plans/old.md', stage: 'done', fingerprint: 'e'.repeat(64), prev: 'approved' }) + '\n');
114
+ appendLedger(root, { ts: 'T1', doc: 'workflow/plans/a.md', stage: 'approved', fingerprint: 'f'.repeat(64), prev: 'draft' });
115
+ appendLedger(root, { ts: 'T2', doc: 'workflow/plans/a.md', stage: 'done', fingerprint: 'g'.repeat(64), prev: 'approved' });
116
+ const rows = fs.readFileSync(path.join(root, '.agents', 'confirmations.jsonl'), 'utf8').trim().split('\n').map((l) => JSON.parse(l));
117
+ check('S8b 存量行后首链行 prevHash 为空(链重启,历史零回填)', rows[0].hash === undefined && rows[1].prevHash === '');
118
+ check('S8b 第二链行 prevHash 接续第一链行 hash', rows[2].prevHash === rows[1].hash);
119
+ const { ledgerChainHash } = await import('./policy.mjs');
120
+ const recompute = (row) => { const { hash, prevHash, ...rest } = row; return ledgerChainHash(rest, prevHash) === hash; };
121
+ check('S8b 两链行 hash 重算自洽(CRLF/键序规范化单源)', recompute(rows[1]) && recompute(rows[2]));
122
+ fs.rmSync(root, { recursive: true, force: true });
123
+ }
124
+
107
125
  // ---- S9【核心】非 TTY spawn(AI 调用路径)必须被拒 ----
108
126
  {
109
127
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'confirm-tty-'));
@@ -771,5 +789,37 @@ const writeTrust = (root, cfg) => {
771
789
  r43.status === 0 && /无测试绿凭证/.test(r43.stderr), String(r43.stderr).slice(0, 200));
772
790
  }
773
791
 
792
+ // ---- S41 台账 scope:机器派生改动面(2026-10-09 审查 A1 干净版)----
793
+ // 背景:委托代录的授权凭据原是 `--delegated` 的用户原话——自由文本机器不可校验、AI 可零成本复用。
794
+ // 干净版把分辨力换成机器派生的 scope{files,sha256}:写入侧机器派生(AI 无输入面),校验侧重算比对。
795
+ // ① 归一与摘要单源(反斜杠 / 前导 ./ / 重复项 / 顺序无关 → 同一摘要);② scopeMatches 抓「改了 files
796
+ // 没改摘要」;③ 委托落账行带 scope 且含被确认文档;④ 非 git 夹具降级不崩(scope 退化为仅含 doc)。
797
+ {
798
+ const d = scopeDigest(['b.md', 'a.md', './a.md', 'x\\y.md']);
799
+ check('S41① scopeDigest 归一:反斜杠→posix、去重、排序无关,摘要自洽',
800
+ d.files.join(',') === 'a.md,b.md,x/y.md' && d.sha256 === scopeDigest(['x/y.md', 'a.md', 'b.md']).sha256
801
+ && scopeMatches({ scope: d }), JSON.stringify(d));
802
+ check('S41② scopeMatches:files 被改而摘要未同步 → 判否',
803
+ !scopeMatches({ scope: { files: [...d.files, 'zz.md'], sha256: d.sha256 } })
804
+ && !scopeMatches({}) && !scopeMatches({ scope: { files: d.files } }));
805
+
806
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'confirm-scope-'));
807
+ const docP = path.join(root, 'workflow', 'intents'); // 用 intents:无逐阶段前置门,本用例只验 scope 落账
808
+ fs.mkdirSync(docP, { recursive: true });
809
+ fs.writeFileSync(path.join(docP, '2026-10-09-s.md'), '---\n状态: draft\n---\n# I\n');
810
+ const r = spawnSync(process.execPath, [CLI, 'workflow/intents/2026-10-09-s.md', '--delegated', '放行'], { cwd: root, encoding: 'utf8' });
811
+ const led = readLedgerOf(root);
812
+ const j = led[led.length - 1];
813
+ check('S41③ 委托落账行带机器派生 scope:含被确认文档 + 摘要自洽',
814
+ r.status === 0 && j && j.scope && Array.isArray(j.scope.files)
815
+ && j.scope.files.includes('workflow/intents/2026-10-09-s.md') && scopeMatches(j),
816
+ JSON.stringify({ status: r.status, stderr: r.stderr, scope: j && j.scope }));
817
+ check('S41④ 非 git 夹具降级:machineScope 不崩且仍含 doc(静默退化为无额外改动面,不拒绝关单)',
818
+ (() => { const s = machineScope(root, 'workflow/intents/2026-10-09-s.md');
819
+ return Array.isArray(s.files) && s.files.includes('workflow/intents/2026-10-09-s.md') && scopeMatches({ scope: s }); })(),
820
+ JSON.stringify(machineScope(root, 'workflow/intents/2026-10-09-s.md')));
821
+ fs.rmSync(root, { recursive: true, force: true });
822
+ }
823
+
774
824
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
775
825
  process.exit(fail ? 1 : 0);
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // edit-face-check — 常驻面编辑时快检(2026-10-09 engine-quality-round2 B2)
3
+ // 动机:常驻面(AGENTS.md 等)的机器约束此前只在 git 钩子层生效——check-loop 仓库模式只扫 HEAD,
4
+ // 工作树编辑期零反馈(实测:常驻面重构砍掉阶段索引,到提交才被检查 7 报警)。本脚本把判定左移到编辑时。
5
+ // 判定零复刻铁律:闭环判定**单源**复用 check-loop——以 CHECK_LOOP_ROOT=<root>(全扫模式,扫工作树
6
+ // 非 HEAD)子进程消费其裁决,本脚本不实现任何门判定;唯一自算是常驻面字节预算 advisory(工作树
7
+ // LF 字节 vs rule-budgets.txt 上限——提交门口径走 git index/HEAD(rule-budget.sh),两者判据不同面,
8
+ // 此处为编辑时 advisory,冲突时以提交门为准)。
9
+ // exit 语义:check-loop hard(exit≠0)→ 2;预算 advisory 超限 → 1;其余 → 0。
10
+ // WARN **不进 exit**(打印明细供 AI/人对照)——绝对 WARN 计数会让带存量 advisory 的仓库恒非零
11
+ // (本仓即有用户拍板保留的存量 WARN),工具就不可用了;编辑破坏的表现(如阶段索引漂移 WARN)
12
+ // 在输出里一眼可见,由编辑者处置。
13
+ // 用法:node .agents/scripts/edit-face-check.mjs [--root <仓库根>](缺省 cwd;测试注入 EDIT_FACE_CHECK_SPAWN)
14
+ // 测试:node templates/_agents/scripts/edit-face-check.test.mjs
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ // LF 归一字节数(编辑时口径——工作区文件可能 CRLF,预算表口径注释声明「比较用 LF 归一」)
21
+ export function lfBytes(text) {
22
+ return Buffer.byteLength(String(text).replace(/\r\n/g, '\n'), 'utf8');
23
+ }
24
+
25
+ // globToRe:rule-budgets.txt 行首路径 → 正则(支持 `*` 段内通配与结尾 `/` 目录前缀;其余字面)
26
+ export function globToRe(pattern) {
27
+ const dir = pattern.endsWith('/');
28
+ let src = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
29
+ if (dir) src += '.*';
30
+ return new RegExp(`^${src}${dir ? '' : '$'}`);
31
+ }
32
+
33
+ // budgetAdvisory:常驻面预算 advisory(编辑时口径)。files: Map<rel, 工作树文本>;budgetsText: 表原文。
34
+ // 返回超限行 [{ rel, limit, actual }];表缺失/行非法 → 空数组(advisory 层 fail-open)。
35
+ export function budgetAdvisory(files, budgetsText) {
36
+ const out = [];
37
+ if (!budgetsText) return out;
38
+ for (const line of budgetsText.split(/\r?\n/)) {
39
+ const t = line.replace(/\r$/, '').trim();
40
+ if (!t || t.startsWith('#')) continue;
41
+ const m = t.match(/^(\S+)\s+(\d+)$/);
42
+ if (!m) continue;
43
+ const [, pattern, limitStr] = m;
44
+ const limit = Number(limitStr);
45
+ const re = globToRe(pattern);
46
+ for (const [rel, text] of files) {
47
+ if (!re.test(rel)) continue;
48
+ const actual = lfBytes(text);
49
+ if (actual > limit) out.push({ rel, limit, actual });
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // runCheck:工作树面 check-loop(CHECK_LOOP_ROOT 全扫)。返回 { code, output, ms }。
56
+ function runCheck(root, scriptDir) {
57
+ const t0 = Date.now();
58
+ const r = spawnSync(process.execPath, [path.join(scriptDir, 'check-loop.mjs')], {
59
+ cwd: root, encoding: 'utf8', windowsHide: true,
60
+ env: { ...process.env, CHECK_LOOP_ROOT: root },
61
+ });
62
+ return { code: r.status ?? 1, output: `${r.stdout || ''}${r.stderr || ''}`, ms: Date.now() - t0 };
63
+ }
64
+
65
+ export function main(root = process.cwd(), scriptDir = path.dirname(fileURLToPath(import.meta.url))) {
66
+ // ① 闭环判定(单源 check-loop,工作树全扫)
67
+ const ck = runCheck(root, scriptDir);
68
+ const warnCount = (ck.output.match(/\[WARN /g) || []).length;
69
+ // ② 常驻面预算 advisory(AGENTS.md + 预算表内文件的工作树字节)
70
+ let over = [];
71
+ try {
72
+ const budgetsPath = path.join(root, '.agents', 'rule-budgets.txt');
73
+ const budgetsText = fs.existsSync(budgetsPath) ? fs.readFileSync(budgetsPath, 'utf8') : null;
74
+ const files = new Map();
75
+ const agentsPath = path.join(root, 'AGENTS.md');
76
+ if (fs.existsSync(agentsPath)) files.set('AGENTS.md', fs.readFileSync(agentsPath, 'utf8'));
77
+ over = budgetAdvisory(files, budgetsText);
78
+ } catch { /* advisory 层 fail-open:预算读不到不阻断编辑时反馈 */ }
79
+
80
+ console.log(`▶ edit-face-check → ${root}(工作树面)`);
81
+ console.log(` check-loop:${ck.code === 0 ? '通过' : `exit ${ck.code}`},WARN ${warnCount} 条(${ck.ms}ms)——与本仓存量 advisory 对照看新增`);
82
+ // WARN 明细透传(编辑破坏的判读面——如删阶段索引会在此点名「阶段索引漂移」)
83
+ for (const line of ck.output.split(/\r?\n/)) {
84
+ if (/\[WARN /.test(line)) console.log(' ' + line.trim().slice(0, 200));
85
+ }
86
+ for (const { rel, limit, actual } of over) {
87
+ console.log(` ⚠️ 预算 advisory:${rel} 工作树 ${actual}B > 上限 ${limit}B(编辑时口径;提交门以 rule-budget.sh 为准)`);
88
+ }
89
+ if (ck.code !== 0) {
90
+ console.error('❌ 闭环判定 hard 未过——先修复再提交(判定单源 check-loop,详见上方输出)');
91
+ return 2;
92
+ }
93
+ if (over.length > 0) {
94
+ console.error('⚠️ 常驻面预算 advisory 超限——建议编辑期就瘦,别留到提交门');
95
+ return 1;
96
+ }
97
+ console.log('✅ 编辑时快检完成(hard/预算两道均过;WARN 明细请对照处置)');
98
+ return 0;
99
+ }
100
+
101
+ const isMain = process.argv[1] && process.argv[1].endsWith('edit-face-check.mjs');
102
+ if (isMain) {
103
+ let root = process.cwd();
104
+ const i = process.argv.indexOf('--root');
105
+ if (i > 0 && process.argv[i + 1]) root = path.resolve(process.argv[i + 1]);
106
+ process.exit(main(root));
107
+ }
@@ -0,0 +1,98 @@
1
+ // edit-face-check.test.mjs — 编辑时快检(2026-10-09 engine-quality-round2 B2)
2
+ // 三面:纯函数(lfBytes/globToRe/budgetAdvisory)+ 端到端全绿面 + 破坏面(删阶段索引→WARN→exit 1、
3
+ // 预算超限→exit 1)。判定零复刻:端到端经 CHECK_LOOP_ROOT 消费真 check-loop,fixture 复制脚本集。
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+
10
+ const SRC = path.dirname(fileURLToPath(import.meta.url));
11
+ let pass = 0, fail = 0;
12
+ const check = (name, cond, detail = '') => {
13
+ if (cond) { pass++; console.log(`PASS ${name}`); }
14
+ else { fail++; console.log(`FAIL ${name}${detail ? `——${detail}` : ''}`); }
15
+ };
16
+
17
+ const mod = await import(pathToFileURL(path.join(SRC, 'edit-face-check.mjs')).href);
18
+ const { lfBytes, globToRe, budgetAdvisory } = mod;
19
+
20
+ // ---- ① 纯函数 ----
21
+ check('lfBytes:CRLF 归一计 UTF-8 字节', lfBytes('a\r\nb中文') === Buffer.byteLength('a\nb中文', 'utf8'));
22
+ check('globToRe:精确路径', globToRe('AGENTS.md').test('AGENTS.md') && !globToRe('AGENTS.md').test('x/AGENTS.md'));
23
+ check('globToRe:段内通配', globToRe('.agents/commands/*.md').test('.agents/commands/plan.md') && !globToRe('.agents/commands/*.md').test('.agents/commands/x/plan.md'));
24
+ check('globToRe:目录前缀', globToRe('.agents/cache/').test('.agents/cache/a/b.json'));
25
+
26
+ {
27
+ const files = new Map([
28
+ ['AGENTS.md', 'x'.repeat(120)],
29
+ ['.agents/commands/plan.md', 'y'.repeat(50)],
30
+ ]);
31
+ const budgets = '# 注释行\nAGENTS.md 100\n.agents/commands/*.md 200\n非法行\n';
32
+ const r = budgetAdvisory(files, budgets);
33
+ check('budgetAdvisory:超限单文件报、未超限不报、非法行跳过',
34
+ r.length === 1 && r[0].rel === 'AGENTS.md' && r[0].limit === 100 && r[0].actual === 120, JSON.stringify(r));
35
+ check('budgetAdvisory:表缺失 fail-open', budgetAdvisory(files, null).length === 0);
36
+ }
37
+
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'];
40
+ // 引用完整性哑文件(检查 4/6/7 的存在性判据——避免 fixture 结构性 WARN 淹没断言面)
41
+ const REF_COMMANDS = ['new-task', 'plan', 'design', 'build', 'test', 'deploy', 'maintain', 'review'];
42
+ const REF_ROLES = ['implementer', 'independent-reviewer', 'ui-verifier'];
43
+ function mkFixture({ breakStageIndex, overBudget }) {
44
+ const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'efc-'));
45
+ 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));
49
+ fs.mkdirSync(path.join(fx, 'workflow', 'intents'), { recursive: true });
50
+ for (const d of ['specs', 'plans', 'incidents']) fs.mkdirSync(path.join(fx, 'workflow', d), { recursive: true });
51
+ 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`); }
52
+ for (const rname of REF_ROLES) { fs.mkdirSync(path.join(fx, '.agents', 'roles'), { recursive: true }); fs.writeFileSync(path.join(fx, '.agents', 'roles', `${rname}.md`), `# ${rname}\n`); }
53
+ // 常驻面 AGENTS.md:阶段索引齐全(含 review)/ 破坏面删索引(破坏只在 AGENTS.md 侧——new-task
54
+ // 哑文件写全索引,钉检查 7 的双向语义差异)
55
+ const routes = '.agents/commands/plan.md → .agents/commands/design.md → .agents/commands/build.md → .agents/commands/test.md → .agents/commands/deploy.md → .agents/commands/maintain.md';
56
+ const reviewLine = breakStageIndex ? '' : '\n**横切**:.agents/commands/review.md\n';
57
+ const body = `# AGENTS\n\n## 阶段路由\n\n**入口**:.agents/commands/new-task.md\n**指令**:${breakStageIndex ? '(已删)' : routes}${reviewLine}\n`;
58
+ fs.writeFileSync(path.join(fx, 'AGENTS.md'), overBudget ? body + 'x'.repeat(200) : body);
59
+ fs.writeFileSync(path.join(fx, '.agents', 'commands', 'new-task.md'),
60
+ `# new-task\n\n| Plan | ${routes} | Review |\n`);
61
+ fs.writeFileSync(path.join(fx, '.agents', 'rule-budgets.txt'), `AGENTS.md ${overBudget ? 100 : 100000}\n`);
62
+ return fx;
63
+ }
64
+
65
+ {
66
+ // 全绿面:空 workflow + 引用齐全 + 预算内 → hard/预算两道过(exit 0)。
67
+ // 注:不要求零 WARN——check-loop 全扫对极简 fixture 有结构性 advisory(引用/角色形态),明细
68
+ // 承载给编辑者对照;exit 语义只钉 hard 与预算两道(绝对 WARN 计数会让存量仓库恒非零)。
69
+ const fx = mkFixture({});
70
+ const driver = path.join(fx, 'driver.mjs');
71
+ fs.writeFileSync(driver, `import { main } from ${JSON.stringify('file:///' + path.join(fx, '.agents', 'scripts', 'edit-face-check.mjs').replace(/\\/g, '/'))};\nprocess.exit(main(${JSON.stringify(fx)}, ${JSON.stringify(path.join(fx, '.agents', 'scripts'))}));\n`);
72
+ const r = spawnSync(process.execPath, [driver], { encoding: 'utf8' });
73
+ check('E2E 全绿面:exit 0(hard/预算两道过)且 AGENTS.md 侧无索引漂移', r.status === 0 && !/AGENTS\.md 缺 .* 指令索引/.test(r.stdout || ''), `status=${r.status}\n${(r.stdout || '') + (r.stderr || '')}`.slice(0, 600));
74
+ }
75
+
76
+ {
77
+ // 破坏面:删阶段索引 → 检查 7 WARN 点名(明细承载编辑破坏——破坏面 vs 全绿面的差异就在输出;
78
+ // hard 不误触:阶段索引是 warning 级,exit≠2)
79
+ const fx = mkFixture({ breakStageIndex: true });
80
+ const driver = path.join(fx, 'driver.mjs');
81
+ fs.writeFileSync(driver, `import { main } from ${JSON.stringify('file:///' + path.join(fx, '.agents', 'scripts', 'edit-face-check.mjs').replace(/\\/g, '/'))};\nprocess.exit(main(${JSON.stringify(fx)}, ${JSON.stringify(path.join(fx, '.agents', 'scripts'))}));\n`);
82
+ const r = spawnSync(process.execPath, [driver], { encoding: 'utf8' });
83
+ const out = (r.stdout || '') + (r.stderr || '');
84
+ check('E2E 破坏面(删阶段索引):WARN 点名 AGENTS.md 侧索引漂移且 hard 不误触', r.status !== 2 && /AGENTS\.md 缺 plan 指令索引/.test(out), `status=${r.status}\n${out.slice(0, 600)}`);
85
+ }
86
+
87
+ {
88
+ // 预算超限面:AGENTS.md 超上限 → exit 1(advisory 面单独可达——workflow 全绿)
89
+ const fx = mkFixture({ overBudget: true });
90
+ const driver = path.join(fx, 'driver.mjs');
91
+ fs.writeFileSync(driver, `import { main } from ${JSON.stringify('file:///' + path.join(fx, '.agents', 'scripts', 'edit-face-check.mjs').replace(/\\/g, '/'))};\nprocess.exit(main(${JSON.stringify(fx)}, ${JSON.stringify(path.join(fx, '.agents', 'scripts'))}));\n`);
92
+ const r = spawnSync(process.execPath, [driver], { encoding: 'utf8' });
93
+ const out = (r.stdout || '') + (r.stderr || '');
94
+ check('E2E 预算超限面:exit 1 且 advisory 点名', r.status === 1 && /预算 advisory/.test(out), `status=${r.status}\n${out.slice(0, 500)}`);
95
+ }
96
+
97
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
98
+ process.exit(fail ? 1 : 0);
@@ -5,6 +5,8 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { ENUMS } from './workflow-enums.mjs';
8
+ import { spawnSync } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
8
10
 
9
11
  const TODAY = new Date().toISOString().slice(0, 10);
10
12
  const LEVELS = ENUMS['level.all']; // 级别词表单源(.agents/workflow-enums.txt)
@@ -36,12 +38,24 @@ const SECTIONS = [
36
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。' },
37
39
  ];
38
40
 
39
- export function renderIntent({ topic, module, level, notes, date = TODAY }) {
41
+ // renderIntent:骨架渲染(纯函数)。kbHits(2026-10-09 engine-quality-round3 W2):kb-search 命中行
42
+ // 数组(📄 行原文)——非空时替换「历史教训/防复发」节的检索占位行为实际命中列表(历史坑强制注入,
43
+ // 手动路由与 pipeline-run 工单同权);空/缺省保留占位(fail-open 口径)。子进程调用在 main,本函数保持无 IO。
44
+ export function renderIntent({ topic, module, level, notes, date = TODAY, kbHits = [], kbFailNote = '' }) {
40
45
  if (!LEVELS.includes(level)) throw new Error('level 不在枚举 ' + LEVELS.join('|') + ':' + level);
41
46
  const fm = '---\n状态: draft\n级别: ' + level + '\nrisk_level: ' + level + '\n日期: ' + date + '\n模块: ' + module + '\n备注: ' + (notes || '<可选:附注自由文本,check-loop 不解析>') + '\n---\n';
42
47
  let body = '# INTENT — ' + topic + '\n\n<!-- 复制本模板为 YYYY-MM-DD-<主题>.md 后填写;plans/ 下同名文件与本文件配对 -->\n<!-- frontmatter 受限子集(2026-09-13):每行 键: 值;状态∈draft/approved/done/superseded/cancelled(严格枚举,附注写备注键);级别∈L0/L1/L2/L3;risk_level=级别(混合治理风险泳道选道:L0/L1 协作道异步审计 / L2/L3 防御道同步确认);check-loop 只扫 frontmatter 取机器字段,正文不再写状态/级别行 -->\n\n';
43
48
  for (const { title, hint } of SECTIONS) {
44
- body += '## ' + title + '\n\n' + hint + '\n\n';
49
+ let section = hint;
50
+ if (title === '历史教训/防复发') {
51
+ const placeholder = '- 检索结果:`node .agents/scripts/kb-search.mjs "<关键词>" --type incidents` 命中结果';
52
+ if (kbHits.length) {
53
+ const injected = '- 检索结果(自动注入 ' + kbHits.length + ' 条,同域历史——起草前先读其根因与防复发):\n' + kbHits.map((h) => ' - ' + h).join('\n');
54
+ if (section.includes(placeholder)) section = section.replace(placeholder, injected);
55
+ }
56
+ else if (kbFailNote) section = section.replace('- 检索结果:', '- 检索结果' + kbFailNote + '\n- 检索结果:'); // 复核 P2-2:不可用时留手工命令注释行
57
+ }
58
+ body += '## ' + title + '\n\n' + section + '\n\n';
45
59
  }
46
60
  body += '## 确认与复核\n\n- 确认日期:\n- 确认人:用户(对话内明确放行即确认)\n- 确认范围:\n- 复核:L1 不要求独立复核\n';
47
61
  return { frontmatter: fm, body: fm + body, sections: SECTIONS.map((s) => s.title) };
@@ -52,7 +66,22 @@ if (isMain) {
52
66
  const a = parseArgs(process.argv.slice(2));
53
67
  if (!a.topic || !a.module || !a.output) fail('必填:--topic / --module / --output;选填:--level(默认 L1)/ --notes');
54
68
  if (!LEVELS.includes(a.level)) fail('level 必须在 ' + LEVELS.join('|') + ':' + a.level);
55
- const { body } = renderIntent(a);
69
+ // 历史坑强制注入(2026-10-09 engine-quality-round3 W2):同目录 kb-search 子进程检索同域历史
70
+ // (关键词 = topic ascii 段 + 模块名;--type incidents,plans——修复先例与计划同查)。fail-open:
71
+ // 非零/零命中/3s 超时 → kbHits 空 → 保留占位(与手动路由「AI 自觉检索」不同,此处机器保证尝试)。
72
+ let kbFailNote = '';
73
+ let kbHits = [];
74
+ try {
75
+ const words = (String(a.topic).toLowerCase().match(/[a-z0-9]{2,}/g) || []).slice(0, 2);
76
+ const kw = [...new Set([...words, String(a.module || '')].filter(Boolean))]; // 复核 P1-1:模块名入关键词(spec S2 口径)
77
+ const r = spawnSync(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'kb-search.mjs'),
78
+ ...kw, '--scope', 'workflow', '--type', 'incidents,plans', '-n', '4'], { encoding: 'utf8', timeout: 3000, windowsHide: true });
79
+ if (r.status === 0 && r.stdout) {
80
+ kbHits = r.stdout.split(/\r?\n/).filter((l) => l.startsWith('📄')).slice(0, 4);
81
+ if (!kbHits.length) kbFailNote = '(自动检索不可用或零命中,手工跑:node .agents/scripts/kb-search.mjs "<关键词>" --type incidents)';
82
+ }
83
+ } catch { /* fail-open:检索不可用保留占位 */ }
84
+ const { body } = renderIntent({ ...a, kbHits, kbFailNote });
56
85
  fs.mkdirSync(path.dirname(a.output), { recursive: true });
57
86
  fs.writeFileSync(a.output, body);
58
87
  console.log('✅ 已生成 intent 草稿:' + a.output + '(状态 draft / 级别 ' + a.level + ' / 模块 ' + a.module + ')');
@@ -78,5 +78,18 @@ function check(name, cond, detail = '') {
78
78
  }
79
79
  }
80
80
 
81
+
82
+ // ---- 历史坑注入 kbHits(2026-10-09 engine-quality-round3 W2)----
83
+ {
84
+ const hits = ["📄 workflow/incidents/2026-10-01-aaa.md — closed · L2 · pipeline · 3 处命中 / 1KB"];
85
+ const { body } = renderIntent({ topic: 't', module: 'pipeline', level: 'L2', kbHits: hits });
86
+ check('kb 注入:命中行替换占位(含自动注入计数与 📄 原文)',
87
+ body.includes('检索结果(自动注入 1 条') && body.includes("📄 workflow/incidents/2026-10-01-aaa.md — closed · L2 · pipeline · 3 处命中 / 1KB") && !body.includes("- 检索结果:`node .agents/scripts/kb-search.mjs \"<关键词>\" --type incidents` 命中结果"), body.slice(0, 200));
88
+ }
89
+ {
90
+ const { body } = renderIntent({ topic: 't', module: 'pipeline', level: 'L2', kbHits: [] });
91
+ check('kb 零命中:保留占位(fail-open 口径)', body.includes("- 检索结果:`node .agents/scripts/kb-search.mjs \"<关键词>\" --type incidents` 命中结果"), body.slice(0, 200));
92
+ }
93
+
81
94
  console.log('\n合计: PASS ' + pass + ' / FAIL ' + failCount);
82
95
  process.exit(failCount ? 1 : 0);
@@ -280,5 +280,56 @@ if (existing.includes(BEGIN) && existing.includes(END)) {
280
280
  } else {
281
281
  out = (existing ? existing.replace(/\s*$/, '') + '\n\n' : HEADER) + inner + '\n';
282
282
  }
283
- fs.writeFileSync(METRICS_P, out, 'utf8');
283
+
284
+ // ---- 趋势序列 + 确认负担(2026-10-09 engine-quality-round3 W4)----
285
+ // ① 确认负担:本月 confirmations.jsonl 行数(stage=void 除外)与上月比(口径:每次 confirm-doc 调用
286
+ // 落一行——「确认门调用次数」即确认负担的机器事实;void 为作废标记不计)。
287
+ // ② 趋势序列:.agents/cache/metrics-history.jsonl 每日一行幂等(同日重跑覆盖)——metrics.md「趋势」
288
+ // 行对比昨日 docsTotal/confirmCalls;history 损坏重建(fail-open)。
289
+ const HISTORY_P = path.join('.agents', 'cache', 'metrics-history.jsonl');
290
+ const voidExcluded = ledgerRows.filter((e) => e.stage !== 'void');
291
+ const monthOf = (e) => (typeof e.ts === 'string' ? e.ts.slice(0, 7) : '');
292
+ const thisMonth = month.slice(0, 7); // month 变量 = 生成锚月(YYYY-MM)
293
+ const prevMonthDate = new Date(thisMonth + '-01T00:00:00Z');
294
+ prevMonthDate.setUTCMonth(prevMonthDate.getUTCMonth() - 1);
295
+ const prevMonth = prevMonthDate.toISOString().slice(0, 7);
296
+ const confirmThis = voidExcluded.filter((e) => monthOf(e) === thisMonth).length;
297
+ const confirmPrev = voidExcluded.filter((e) => monthOf(e) === prevMonth).length;
298
+ const confirmDelta = confirmPrev > 0 ? Math.round(((confirmThis - confirmPrev) / confirmPrev) * 100) : null;
299
+
300
+ // docsTotal:metrics 主表本月行的文档数列(第一数值列)——从 row 提取,避免重复统计逻辑
301
+ const docsTotal = docs.files; // 直取作用域变量(正则提取曾因「287(活跃…)」复合列失配——round3 实测修正)
302
+
303
+ // history 读写(fail-open:损坏重建)
304
+ let history = [];
305
+ try {
306
+ if (fs.existsSync(HISTORY_P)) {
307
+ history = fs.readFileSync(HISTORY_P, 'utf8').split(/\r?\n/).filter(Boolean)
308
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
309
+ }
310
+ } catch { history = []; }
311
+ const today = new Date().toISOString().slice(0, 10);
312
+ history = history.filter((h) => h && h.date !== today); // 同日幂等:覆盖当日行
313
+ history.push({ date: today, docsTotal, passRate: funnel.closed ? Math.round((funnel.once / funnel.closed) * 100) + '%' : '', confirmCalls: confirmThis, savedAt: new Date().toISOString() });
314
+ try {
315
+ fs.mkdirSync(path.dirname(HISTORY_P), { recursive: true });
316
+ const tmpH = HISTORY_P + '.tmp';
317
+ fs.writeFileSync(tmpH, history.map((h) => JSON.stringify(h)).join('\n') + '\n');
318
+ fs.renameSync(tmpH, HISTORY_P);
319
+ } catch { /* history 写失败不影响 metrics.md 主产物 */ }
320
+
321
+ // 趋势行(对比昨日快照)
322
+ const yesterday = history.filter((h) => h.date !== today).slice(-1)[0];
323
+ const trendParts = [];
324
+ if (yesterday) {
325
+ trendParts.push('docs ' + docsTotal + '(昨日 ' + (yesterday.docsTotal ?? '?') + ')');
326
+ trendParts.push('确认调用 ' + confirmThis + '(昨日 ' + (yesterday.confirmCalls ?? '?') + ')');
327
+ } else {
328
+ trendParts.push('无前值(history 首日)');
329
+ }
330
+ const trendLine = '> 趋势(环比昨日):' + trendParts.join(';') + ';确认调用本月 ' + confirmThis + ' 次(上月 ' + confirmPrev + ' 次' + (confirmDelta === null ? '' : ',' + (confirmDelta >= 0 ? '+' : '') + confirmDelta + '%') + ';void 不计)';
331
+
332
+ // 注入「闭环漏斗」节尾(inner 的 FUNNEL 段之后、END 之前)
333
+
334
+ fs.writeFileSync(METRICS_P, out.replace(END, trendLine + '\n\n' + END), 'utf8');
284
335
  console.log(`✅ 已更新 workflow/metrics.md(${month} 行;共 ${rows.length} 个月)`);
@@ -3,6 +3,7 @@
3
3
  // kit.json 缺 policyVersion、或版本不在表内:回退版本 1(不启用版本 2 的 approved 锚)。
4
4
  // audit 不在本表:缺省视为全量检查(已装仓库与无 kit 的测试夹具);init 新装显式写 false。
5
5
  import fs from 'node:fs';
6
+ import { createHash } from 'node:crypto';
6
7
  import path from 'node:path';
7
8
 
8
9
  export const POLICIES = {
@@ -94,6 +95,30 @@ export const POLICIES = {
94
95
  // 消费方:检查 8 的 verifyDocInWindow(done ts ≥ 本值才启用精确匹配)。
95
96
  verifyDocSince: '2026-10-08T09:37:35Z',
96
97
  },
98
+ // 版本 7 = 版本 6 的全部键 + scopeSince(台账「机器派生改动面」的生效时刻,2026-10-09 审查 A1 干净版)。
99
+ // 背景:委托代录的授权凭据是 `--delegated` 的**用户原话**——机器无法校验自由文本,AI 可无成本复用同一句
100
+ // (本仓实测「可以」133 次、「确认」60 次),且台账 406 行里仅 6 行为 tty。整条授权链的源头不可核对,
101
+ // 下游的哈希链/指纹对账/内容绑定全建得很好却是建在空地基上。
102
+ // 干净版解法:**改动面改由机器派生,AI 无输入面**——confirm-doc 落账时自动记
103
+ // `scope:{files:[…], sha256}`(被确认文档 + `git status --porcelain` 实测的未提交改动集,归一去重排序后
104
+ // 取 sha256)。AI 无法伪造这个字段,因为它根本不参与输入;事后对质问的是「这 N 个文件」,而不是一句可复用的原话。
105
+ // 本版本只加一个开关键:**缺键(v1-v6)时该子检查整体跳过**,存量 406 行零新增告警;抄全键仍是铁律。
106
+ 7: {
107
+ moduleSince: '2026-09-22',
108
+ check14Since: '2026-09-26',
109
+ confirmDocsEffective: '2026-09-27',
110
+ confirmIncidentsEffective: '2026-09-28',
111
+ bindingTs: '2026-09-28',
112
+ check17UnclosedAfter: '0.8.0',
113
+ stageGateSince: '2026-09-29',
114
+ riskLevelSince: '2026-10-01',
115
+ verifySince: '2026-10-07',
116
+ verifyDocSince: '2026-10-08T09:37:35Z',
117
+ // 生效「时刻」(ISO 8601,UTC)而非日期:本日 04:29Z 已有一批旧口径台账行写入,日期粒度会把它们
118
+ // 纳入受审范围产出不可消退告警——沿 v6 verifyDocSince 升级为时刻的同款教训。
119
+ // 消费方:check-loop 检查 15 的 scope 子项(台账行 ts ≥ 本值才判)。
120
+ scopeSince: '2026-10-09T11:24:43Z',
121
+ },
97
122
  };
98
123
 
99
124
  export function loadKitPolicy(root) {
@@ -142,3 +167,40 @@ export function hasFreshVerifyLine(lines, now = Date.now(), doc = null) {
142
167
  }
143
168
  return false;
144
169
  }
170
+
171
+ // ---- 台账哈希链(2026-10-09 engine-quality-round2 D)----
172
+ // 单源(confirm-doc appendLedger 写入与 check-loop 检查 15 验链共用——判定零复刻):
173
+ // ledgerChainHash(row, prevHash) = sha256(prevHash + JSON.stringify(row))。
174
+ // row = 台账行**去掉 prevHash/hash 两字段**后的对象(键序 = 写入序,JSON.parse 保序 → 重算一致);
175
+ // prevHash = 上一行的 hash(上一行无 hash/文件首行 → ''——历史行零回填,链从首个带 hash 行起算)。
176
+ // 边界(诚实声明):整文件重写可重建完整链,不可机器防——兜底 = 台账文件自身的 git 历史
177
+ // (check-ledger-invariant 已扫 append-only 历史);本链堵的是「就地改/删中间行」这一高频伪造面,
178
+ // 把主动伪造从静默可行抬到必留断链痕迹。
179
+ export function ledgerChainHash(row, prevHash) {
180
+ return createHash('sha256').update((prevHash || '') + JSON.stringify(row)).digest('hex');
181
+ }
182
+
183
+ // ---- 台账 scope(机器派生改动面,2026-10-09 审查 A1 干净版)----
184
+ // scopeDigest(files) = {files, sha256}:files 归一(反斜杠→posix)、去重、排序;sha256 = files.join('\n') 的摘要。
185
+ // **单源**(confirm-doc 落账侧构造与 check-loop 检查 15 校验侧重算共用——防两处归一/排序口径漂移,
186
+ // N3 教训同款):任一侧改了口径而另一侧没改,台账会整体判失效。
187
+ // 边界(诚实声明):scope 是**机器实测**的「确认时刻工作树处于改动态的文件集合」,不是「本次任务应改什么」
188
+ // 的语义判定——它能证明「你放行时盘面上就是这些文件在动」,不能证明「这些改动就是你要的」。
189
+ // 语义正确性仍由指纹(文档内容)+ commit diff(事后对质)承担。
190
+ export function scopeDigest(files) {
191
+ const norm = [...new Set((Array.isArray(files) ? files : [])
192
+ .map((f) => String(f).replace(/\\/g, '/').replace(/^\.\//, ''))
193
+ .filter(Boolean))]
194
+ .sort();
195
+ return { files: norm, sha256: createHash('sha256').update(norm.join('\n'), 'utf8').digest('hex') };
196
+ }
197
+
198
+ // scopeMatches(row):台账行自带的 scope 是否与按其 files 重算的结果逐字一致。
199
+ // 用于检出「行里改了 files 却没同步 sha256」的手改形态(哈希链能兜住就地改,但整文件重写可重建链——
200
+ // 同一信任边界内的第二道,见 ledgerChainHash 边界声明)。
201
+ export function scopeMatches(row) {
202
+ if (!row || typeof row !== 'object' || !row.scope) return false;
203
+ const s = row.scope;
204
+ if (!Array.isArray(s.files) || typeof s.sha256 !== 'string') return false;
205
+ return scopeDigest(s.files).sha256 === s.sha256;
206
+ }