agentic-flow-kit 1.8.1 → 1.8.2

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 (40) hide show
  1. package/modules/hosts/claude/commands/wf-new-task.md +1 -0
  2. package/modules/hosts/codex/commands/wf-new-task.md +1 -0
  3. package/modules/hosts/cursor/commands/wf-new-task.md +1 -0
  4. package/modules/hosts/opencode/commands/wf-new-task.md +1 -0
  5. package/modules/hosts/trae/commands/wf-new-task.md +1 -0
  6. package/package.json +1 -1
  7. package/templates/_agents/commands/new-task.md +1 -0
  8. package/templates/_agents/engine-lock.json +27 -19
  9. package/templates/_agents/hooks/commit-check.cjs +57 -30
  10. package/templates/_agents/notes/engine-internals.md +17 -0
  11. package/templates/_agents/rule-budgets.txt +7 -1
  12. package/templates/_agents/scripts/agg-judgments.mjs +77 -1
  13. package/templates/_agents/scripts/agg-judgments.test.mjs +49 -1
  14. package/templates/_agents/scripts/archive-sweep.mjs +3 -1
  15. package/templates/_agents/scripts/check-convergence.mjs +8 -3
  16. package/templates/_agents/scripts/check-lane-surface.mjs +9 -2
  17. package/templates/_agents/scripts/check-lane-surface.test.mjs +16 -2
  18. package/templates/_agents/scripts/check-ledger-invariant.mjs +30 -4
  19. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +28 -0
  20. package/templates/_agents/scripts/check-loop-rev.test.mjs +8 -14
  21. package/templates/_agents/scripts/check-loop.mjs +9 -0
  22. package/templates/_agents/scripts/closeout-l1.mjs +217 -0
  23. package/templates/_agents/scripts/closeout-l1.test.mjs +105 -0
  24. package/templates/_agents/scripts/commit-check-trigger.test.mjs +49 -0
  25. package/templates/_agents/scripts/confirm-doc.mjs +14 -2
  26. package/templates/_agents/scripts/confirm-doc.test.mjs +43 -0
  27. package/templates/_agents/scripts/gen-workflow-metrics.mjs +17 -0
  28. package/templates/_agents/scripts/ledger-shards.mjs +42 -5
  29. package/templates/_agents/scripts/ledger-shards.test.mjs +17 -0
  30. package/templates/_agents/scripts/mechanism-retire-watch.mjs +183 -0
  31. package/templates/_agents/scripts/mechanism-retire-watch.test.mjs +114 -0
  32. package/templates/_agents/scripts/pre-push.test.mjs +26 -10
  33. package/templates/_agents/scripts/rule-budget.mjs +14 -1
  34. package/templates/_agents/scripts/rule-budget.test.mjs +118 -0
  35. package/templates/_agents/scripts/verify.mjs +32 -8
  36. package/templates/_agents/scripts/verify.test.mjs +29 -0
  37. package/templates/_agents/scripts-test-exempt.txt +2 -1
  38. package/templates/_githooks/pre-commit +9 -4
  39. package/templates/_githooks/pre-merge-commit +4 -3
  40. package/templates/_githooks/pre-push +129 -75
@@ -22,7 +22,7 @@
22
22
  // B 跳过(沿 pairing 门先例);配置行正则语法错误 → fail-closed(防静默空洞)。
23
23
  // 已知边界:commit-time 门禁,CI / pre-push 复跑面无对应全量检查(push 时刻无暂存 diff 一手事实,
24
24
  // 树级反推违背判据原则)——沿增量门禁 pre-commit-only 先例,声明于 spec 风险评估 R2。
25
- // 用法:node check-lane-surface.mjs [--root <dir>] [--branch <名>] [--staged-file <清单文件>]
25
+ // 用法:node check-lane-surface.mjs [--merge] [--root <dir>] [--branch <名>] [--staged-file <清单文件>]
26
26
  // (--root/--branch/--staged-file 为测试注入位;缺省自 git 发现。staged-file 每行一个仓库相对路径)
27
27
  // 退出码:0 放行(含 advisory);1 阻断(stderr 出 [标签] 消息与修法)
28
28
  import fs from 'node:fs';
@@ -52,6 +52,10 @@ const STAGED = stagedArg
52
52
  : gitOut(ROOT, ['diff', '--cached', '--name-only', '--diff-filter=ACMR']).split(/\r?\n/).filter(Boolean);
53
53
 
54
54
  const isExperiment = /^experiment\//.test(BRANCH);
55
+ // 合并进行中(2026-10-15 自源仓 13f67ef7 回灌):pre-merge-commit 显式传 --merge(git merge 自动提交
56
+ // 窗口 MERGE_HEAD 已不可靠,只认显式旗标);冲突解决后的手动 commit 由 MERGE_HEAD 探测兜底(此时
57
+ // 合并仍在进行、MERGE_HEAD 确定存在)。合并提交不引入新首发改动,各笔已在自身提交时刻过门 → 检查 A 跳过。
58
+ const FORCE_MERGE = argv.includes('--merge');
55
59
  // 泳道协议锚(单源 policy.mjs):无 kit.json 的测试夹具回落 v1(无键=双字段协议从未生效,单字段判)
56
60
  const RISK_LEVEL_SINCE = loadKitPolicy(ROOT).riskLevelSince;
57
61
 
@@ -175,7 +179,10 @@ const hits = patterns.length ? STAGED.filter((p) => patterns.some((re) => re.tes
175
179
  // 本次提交触达到的**最低应然档位**:lane-surfaces 命中隐含 L2(沿原口径),hints 命中按登记档取。
176
180
  // 混档取高——与 level-criteria maxLevel 同一口径(就高不就低在提交面同样成立)。
177
181
  const requiredLevel = maxLevel(hits.length ? 'L2' : '', hintLevel);
178
- if (requiredLevel) {
182
+ const merging = FORCE_MERGE || gitOut(ROOT, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).trim() !== '';
183
+ if (requiredLevel && merging) {
184
+ advisories.push(`[触达面判低](advisory)合并进行中(${FORCE_MERGE ? '--merge 显式标记' : 'MERGE_HEAD 在场'})——检查 A 跳过:合并提交不引入新首发改动,各笔已在自身提交时刻过门`);
185
+ } else if (requiredLevel) {
179
186
  const active = collectActive();
180
187
  const requiredRank = rankOf(requiredLevel) ?? 1;
181
188
  // 入口是否够格:级别 rank ≥ 应然档。**suspect(级别/risk_level 缺失、非法或分歧)一律不够格**——
@@ -31,10 +31,10 @@ const w = (root, rel, content) => fs.writeFileSync(path.join(root, rel), content
31
31
  // eol='\r\n' 时整文件按 CRLF 写(平台边界:frontmatter 读取容忍性)
32
32
  const intent = (name, fm, eol = '\n') => `---${eol}${fm}${eol}---${eol}# INTENT — ${name}${eol}`;
33
33
  const incident = (fm) => `---\n${fm}\n---\n# INCIDENT\n`;
34
- const run = (root, { branch = 'main', staged = [] } = {}) => {
34
+ const run = (root, { branch = 'main', staged = [], args = [] } = {}) => {
35
35
  const listFile = path.join(root, 'staged.txt');
36
36
  fs.writeFileSync(listFile, staged.join('\n') + '\n');
37
- const r = spawnSync(process.execPath, [SCRIPT, '--root', root, '--branch', branch, '--staged-file', listFile], { encoding: 'utf8' });
37
+ const r = spawnSync(process.execPath, [SCRIPT, '--root', root, '--branch', branch, '--staged-file', listFile, ...args], { encoding: 'utf8' });
38
38
  return { code: r.status, out: `${r.stdout || ''}${r.stderr || ''}` };
39
39
  };
40
40
 
@@ -77,6 +77,20 @@ const FM_LOW_NEW = '状态: draft\n级别: L1\n日期: 2026-10-01\n模块: ui';
77
77
  const r = run(d, { branch: 'experiment/poc', staged: ['templates/t.md'] });
78
78
  check('S2c experiment 分支检查 A → advisory 放行', r.code === 0 && r.out.includes('advisory·experiment'), r.out);
79
79
  }
80
+ // --- S2d/S2e:合并进行中 → 检查 A 跳过(2026-10-16 自源仓 13f67ef7 回灌)---
81
+ // git merge 自动提交窗口 MERGE_HEAD 已不可靠 → pre-merge-commit 显式传 --merge(fixture 无 MERGE_HEAD,
82
+ // 恰验证 --merge 独生效);对照:同夹具不带 --merge 时命中面+仅活跃 L1 照拦。
83
+ {
84
+ const d = mkfix();
85
+ base(d);
86
+ const r = run(d, { staged: ['templates/_agents/scripts/x.mjs'], args: ['--merge'] });
87
+ check('S2d --merge 显式标记 → 检查 A 跳过且明示来源',
88
+ r.code === 0 && r.out.includes('合并进行中(--merge 显式标记)') && !r.out.includes('[触达面判低] 暂存命中高风险面'),
89
+ r.out);
90
+ const r2 = run(d, { staged: ['templates/_agents/scripts/x.mjs'] });
91
+ check('S2e 无 --merge 同夹具照拦(对照:跳过仅由旗标/MERGE_HEAD 触发)',
92
+ r2.code === 1 && r2.out.includes('[触达面判低]'), r2.out);
93
+ }
80
94
  // --- S3:命中 + 无活跃入口 → advisory 放行(done 不算活跃)---
81
95
  {
82
96
  const d = mkfix();
@@ -221,10 +221,12 @@ const parentShas = (sha) => {
221
221
 
222
222
  // 合并并集对账:cur 行集须恰为各父行集之并(无丢失、无凭空新增)。返回 null = 认可;否则返回原因。
223
223
  // 父提交无此路径(单侧新增)跳过该父;行比对按整行字符串(与 linesOf/前缀判据同粒度)。
224
- const mergeUnionProblem = (sha, rel, curBuf) => {
224
+ // 2026-10-16 自源仓回灌:签名自 sha 改收 parents 数组——--staged 合并态无提交 sha,由调用方传
225
+ // [HEAD, MERGE_HEAD…];历史全扫(传 parentShas(sha))与 --staged 共用本判据(不新开口子)。
226
+ const mergeUnionProblem = (parents, rel, curBuf) => {
225
227
  const curLines = new Set(linesOf(curBuf));
226
228
  const parentLines = new Set();
227
- for (const p of parentShas(sha)) {
229
+ for (const p of parents) {
228
230
  const b = tryBlob(`${p}:${rel}`);
229
231
  if (b === null) continue;
230
232
  for (const l of linesOf(b)) parentLines.add(l);
@@ -309,7 +311,7 @@ function scanHistory(rel, { allowShardDelete }) {
309
311
  if (parents.length >= 2) {
310
312
  // 多父提交无条件走并集对账(前缀通过不得短路——ours-resolution 合并树与第一父逐字节同 blob,
311
313
  // 嵌套判法对静默丢行全盲)
312
- const unionFail = mergeUnionProblem(sha, rel, cur);
314
+ const unionFail = mergeUnionProblem(parentShas(sha), rel, cur);
313
315
  if (!unionFail) {
314
316
  console.error(`[info] ${sha.slice(0, 10)}:合并并集无丢失——union 合并语义认可,续扫`);
315
317
  continue;
@@ -363,6 +365,18 @@ function ledgerPaths() {
363
365
  }
364
366
 
365
367
  // ---- --staged:暂存台账面 ⊇ HEAD 前缀,或携带 manifest 的特许修剪;分片删除须已并入暂存主台账 ----
368
+ // 合并态口径(2026-10-16 自源仓 13f67ef7 回灌 staged-merge-union):MERGE_HEAD 在场时暂存内容是**多父
369
+ // 合并结果**,单看 HEAD 一父不够——合法合并形态下暂存 = 父母并集(− manifest removes),严格前缀等式
370
+ // 必然不等而误拦。此时在严格等格外**再试父母并集集合对账**——与历史全扫同一函数 mergeUnionProblem /
371
+ // mergePruneProblem、同一口径,不新开口子:并集须无丢失、无凭空行(既禁丢行也禁凭空行)。非合并态不启用。
372
+ const mergeParents = (() => {
373
+ const r = spawnSync('git', ['rev-parse', 'MERGE_HEAD'], { cwd: ROOT, encoding: 'utf8' }); // octopus 可多行
374
+ if (r.status !== 0) return null; // 非合并态;spawnSync 吞 stderr,不外泄 fatal 噪声
375
+ const shas = String(r.stdout || '').split(/\s+/).filter(Boolean);
376
+ if (!shas.length) return null;
377
+ const h = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' });
378
+ return [String(h.stdout || '').trim(), ...shas];
379
+ })();
366
380
  if (staged) {
367
381
  const stagedHit = gitText(['-c', 'core.quotepath=off', 'diff', '--cached', '--name-only']).split(/\r?\n/).filter(Boolean);
368
382
  const touched = ledgerPaths().filter((rel) => stagedHit.includes(rel));
@@ -388,9 +402,21 @@ if (staged) {
388
402
  console.log(`台账不变量(--staged)— ${rel} 特许修剪通过:移除 ${m.removed} 行(${m.docs.length} 家族)——凭据 ${PRUNE_REL} 随笔入库,被删行可在 git 历史对质`);
389
403
  continue;
390
404
  }
405
+ // 合并态(2026-10-16 回灌):严格前缀不适用 → 先纯并集对账,再落 manifest 修剪集合对账
406
+ // (与历史全扫的判定顺序一致)。非合并态两者皆拒。
407
+ const unionFail = mergeParents ? mergeUnionProblem(mergeParents, rel, cur) : '非合并态';
408
+ if (!unionFail) {
409
+ console.log(`台账不变量(--staged)— ${rel} 合并并集无丢失认可(${mergeParents.length} 父)——暂存行集恰为父母行集之并(无丢失、无凭空行),union 语义与历史全扫同口径`);
410
+ continue;
411
+ }
412
+ const setErr = mergeParents ? mergePruneProblem(mergeParents, rel, cur, loadManifestFromRef(`:${PRUNE_REL}`, ROOT)) : '非合并态';
413
+ if (!setErr) {
414
+ console.log(`台账不变量(--staged)— ${rel} 合并携带特许修剪通过:父母并集集合对账认可(${mergeParents.length} 父)——凭据 ${PRUNE_REL} 随笔入库,被删行可在 git 历史对质`);
415
+ continue;
416
+ }
391
417
  let off = 0;
392
418
  while (off < Math.min(head.length, cur.length) && head[off] === cur[off]) off++;
393
- problems.push(`- [台账重写] ${rel} 暂存内容不是 HEAD 的前缀扩展(首异偏移 ${off},HEAD ${head.length}B → 暂存 ${cur.length}B)${m && m.err ? `;特许修剪对账失败:${m.err}` : ''}`);
419
+ problems.push(`- [台账重写] ${rel} 暂存内容不是 HEAD 的前缀扩展(首异偏移 ${off},HEAD ${head.length}B → 暂存 ${cur.length}B)${m && m.err ? `;特许修剪对账失败:${m.err}` : ''}${mergeParents ? `;合并并集集合对账亦失败:${setErr}` : ''}${mergeParents ? `;合并纯并集对账失败:${unionFail}` : ''}`);
394
420
  }
395
421
  continue;
396
422
  }
@@ -591,6 +591,34 @@ const mkMergeRepo = () => {
591
591
  fs.rmSync(d, { recursive: true, force: true });
592
592
  }
593
593
 
594
+ // M12(2026-10-16 自源仓 13f67ef7 回灌补夹具):合并态 --staged 删掉两父母共有行(不在 manifest
595
+ // removes 里)→ 仍拦。共享行被删是 union 语义下的真丢失——manifest 只申报了分支侧行,共有行无凭据消失。
596
+ const mkMergePruneRepo = () => {
597
+ const d = mkrepo();
598
+ const BR = G(d, ['symbolic-ref', '--short', 'HEAD']).stdout.trim();
599
+ const r2 = row({ ts: '2026-10-02T01:00:00.000Z', stage: 'done', doc: 'workflow/intents/gone.md' });
600
+ const comp = row({ ts: '2026-10-13T00:00:00.000Z', stage: 'done', note: '补偿行:doc 仍在' });
601
+ append(d, r2); commitAll(d, 'main r2');
602
+ G(d, ['checkout', '-qb', 'br']);
603
+ fs.writeFileSync(ledPath(d), [row(), comp].join('\n') + '\n');
604
+ writeManifest(d, { ts: '2026-10-13T00:00:00.000Z', authority: 't', reason: 'branch prune', removes: [sha256Of(r2)], docs: ['gone'] });
605
+ commitAll(d, 'br prune + compensation');
606
+ G(d, ['checkout', '-q', BR]);
607
+ fs.writeFileSync(path.join(d, 'other.txt'), 'x');
608
+ commitAll(d, 'main unrelated change');
609
+ G(d, ['merge', '--no-commit', 'br']);
610
+ return { d, r2, comp };
611
+ };
612
+ {
613
+ const { d, comp } = mkMergePruneRepo();
614
+ fs.writeFileSync(ledPath(d), comp + '\n'); // 双父共有行 row() 被删,comp 也不在 removes 内
615
+ G(d, ['add', '-A']);
616
+ const r = run(d, ['--staged']);
617
+ check('M12 合并态 --staged 删双父共有行 → exit 1',
618
+ r.code === 1 && /合并仍丢失 manifest 外/.test(r.out), r.out);
619
+ fs.rmSync(d, { recursive: true, force: true });
620
+ }
621
+
594
622
  // ---- 历史基线(2026-10-13 ledger-history-baseline):owner 声明豁免前基线时代 ----
595
623
  // B1:前基线改写无凭据——无基线全史扫描拦;LEDGER_HISTORY_BASELINE 指到 HEAD 后豁免过
596
624
  // (kit 缺省无基线 = 全量扫描,与源实现「缺省基线指向自身仓提交、外来仓退化为全扫」同语义)
@@ -35,14 +35,6 @@ const check = (name, cond, detail = '') => {
35
35
  else { fail++; console.log(`FAIL ${name}${detail ? `\n ${String(detail).slice(0, 800)}` : ''}`); }
36
36
  };
37
37
 
38
- function shBin() {
39
- for (const bin of ['sh', 'bash']) {
40
- const r = spawnSync(bin, ['-c', 'echo ok'], { encoding: 'utf8' });
41
- if (!r.error && r.status === 0) return bin;
42
- }
43
- return null;
44
- }
45
-
46
38
  function gitRun(cwd, args) {
47
39
  const r = spawnSync(git, args, { cwd, encoding: 'utf8' });
48
40
  if (r.status !== 0) throw new Error(`${args.join(' ')}\n${r.stderr || r.stdout}`);
@@ -145,17 +137,19 @@ try {
145
137
  console.log('SKIP doctor 契约核对(装户位置无包源 src/)');
146
138
  }
147
139
 
148
- const sh = shBin();
149
- check('找到 sh 以跑 pre-push', !!sh);
150
- if (sh && !HOOK) console.log('SKIP pre-push 相关场景(未找到 _githooks / .githooks 下的 pre-push)');
151
- if (sh && HOOK) {
140
+ // pre-push 已 node 化(2026-10-16 自源仓回灌):直调 node 跑钩子,桩改 check-loop.mjs / commit-check.cjs
141
+ if (!HOOK) console.log('SKIP pre-push 相关场景(未找到 _githooks / .githooks 下的 pre-push)');
142
+ if (HOOK) {
152
143
  fs.mkdirSync(path.join(root, '.agents', 'scripts'), { recursive: true });
144
+ fs.mkdirSync(path.join(root, '.agents', 'hooks'), { recursive: true });
153
145
  const node = process.execPath.replace(/\\/g, '/');
154
146
  const loopSh = LOOP.replace(/\\/g, '/');
155
- fs.writeFileSync(path.join(root, '.agents', 'scripts', 'check-loop.sh'), `#!/bin/sh\nexec "${node}" "${loopSh}" "$@"\n`);
147
+ fs.writeFileSync(path.join(root, '.agents', 'scripts', 'check-loop.mjs'), `import { spawnSync } from 'node:child_process';\nconst r = spawnSync(${JSON.stringify(node)}, [${JSON.stringify(loopSh)}, ...process.argv.slice(2)], { stdio: 'inherit' });\nprocess.exit(r.status ?? 1);\n`);
148
+ // 构建门桩:零退出占位——本套件测 --rev 扫描面,不测构建触发
149
+ fs.writeFileSync(path.join(root, '.agents', 'hooks', 'commit-check.cjs'), 'process.exit(0);\n');
156
150
  const hookPath = path.join(root, 'pre-push');
157
151
  fs.copyFileSync(HOOK, hookPath);
158
- const push = (input) => spawnSync(sh, [hookPath], { cwd: root, input, encoding: 'utf8', timeout: 120000 });
152
+ const push = (input) => spawnSync(process.execPath, [hookPath], { cwd: root, input, encoding: 'utf8', timeout: 120000 });
159
153
  const onlyDelete = push(`refs/heads/old ${Z} refs/heads/old ${'b'.repeat(40)}\n`);
160
154
  check('stdin 只有删除行:不阻断', onlyDelete.status === 0, outOf(onlyDelete));
161
155
  const mixed = push(
@@ -92,6 +92,8 @@
92
92
  // quote 字段职责冲突(对质凭据 vs 并录指纹,现归还单一职责)。无 batch 的历史行静默跳过
93
93
  // (无判定依据的行不产出不可消除噪声——沿 audit-gate-hardening P3 教训)
94
94
  // 17. 发版提交树上仍未收口的 intent/spec/plan [hard-block]
95
+ // (2026-10-16 快径:当前树 package.json 无 version 字段 → 整段跳过——无 version 则版本变化提交
96
+ // 永不存在恒不触发,省全历史 git 扫描;源仓「registry 登记恒不触发」同款口径)
95
97
  // 18. 委派台账对账 [warning](policy v4 加 delegationSince 生效日豁免:日期早于锚的 L2/L3 存量豁免——
96
98
  // delegations.md 记法自始不含文件名、无豁免时 113 条不可消退;缺键 v1-v3 维持原全量对账)
97
99
  // 19. 逐阶段审计(起草先于入口确认 / 台账审批顺序倒置) [warning]
@@ -1422,7 +1424,13 @@ export function runCheckLoop(opts = {}) {
1422
1424
  // policy.check17UnclosedAfter 时阻断。版本 1 没有该键,因此不启用后两格。
1423
1425
  // 版本按主、次、修订三段整数比较。沿 package.json 的全部历史查找,不设次数上限。
1424
1426
  // 该提交之后新建的文件不在范围内。open 的 incident 不在此列。
1427
+ // 快径(2026-10-16 减重 自源仓回灌):当前树 package.json 无 version 字段(装户非 node 仓 / 快照树)
1428
+ // → 整段跳过——无 version 则「version 变化的提交」永不存在,本段恒不触发,省去全历史 git log+show
1429
+ // 扫描。适配点:源仓 s17 为独立模块函数用 return 快返;kit 单体函数内改 if 包裹,保后续段照常执行。
1425
1430
  {
1431
+ let curVer = null;
1432
+ try { curVer = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; } catch { curVer = null; }
1433
+ if (curVer) {
1426
1434
  const log = gitOut(['log', '--format=%H', '--', 'package.json']);
1427
1435
  if (log) {
1428
1436
  let release = null;
@@ -1452,6 +1460,7 @@ export function runCheckLoop(opts = {}) {
1452
1460
  }
1453
1461
  }
1454
1462
  }
1463
+ }
1455
1464
  }
1456
1465
  } // laneOn(17) 守卫闭合
1457
1466
 
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ // closeout-l1 — L1 关单一把编排(2026-10-15 减重手段④ 自源仓回灌:记录性动作一把生成,判断留给人)
3
+ // 把 test.md §6 的 L1 关单链(预检 → confirm-doc 代录 → INDEX 重生成 → 提交清单)收进一条命令。
4
+ // 用法:node .agents/scripts/closeout-l1.mjs <workflow/plans/xxx.md> # 预检模式:只查差距,不落任何状态
5
+ // node .agents/scripts/closeout-l1.mjs <plan> --delegated "<用户对话原话>" # 执行模式:预检过 → confirm-doc 代录 → 重生成 INDEX
6
+ // (可加 --root <仓库根>;缺省 git toplevel,无 git 用 cwd)
7
+ // 边界(判据单源,本工具不复制强判断):
8
+ // - 证据格式 / approved 历史 / 测试绿硬门 = confirm-doc(stage-gates/policy 单源),本工具预检只是
9
+ // 提前暴露差距的 advisory 快筛;执行模式原样转发 confirm-doc,其拒绝即本命令拒绝。
10
+ // - --delegated 调用纪律:AI 须先取得用户对话内明确放行(AGENTS.md 确认门条款),原话入账供对质。
11
+ // - L2/L3 不受理(spec 收敛对账 + 独立复核是防御道本体,不做捷径);incidents 不受理(两跳制)。
12
+ // kit 适配(源仓→kit 逐条):① 源仓引用 test.md §5/§5.5,kit 关单链在 test.md §6;② verify 预检
13
+ // 镜像 kit done 前置口径(policy v6+ 按 verifyDocSince 绑定本单绿行,不带 --doc 的绿行不给背书);
14
+ // ③ 分片路径修复:源仓 main() 拼 `<基名.md>.jsonl`,而 confirm-doc/ledger-loader 写入口径是
15
+ // `<基名>.jsonl`(basename 去 .md)——源仓提交清单会把分片误报成主台账(其测试只盖纯函数未暴露),
16
+ // kit 抽 shardOf 纯函数修正并补断言。
17
+ // 测试:node .agents/scripts/closeout-l1.test.mjs(parseArgs / fmOf / acceptanceOf / preflight / shardOf 纯函数进程内断言)
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { hasFreshVerifyLine, loadKitPolicy } from './policy.mjs';
23
+
24
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
25
+
26
+ // parseArgs(argv):{ plan, delegated, root } | { error }
27
+ export function parseArgs(argv = []) {
28
+ const out = { plan: null, delegated: null, root: null, error: null };
29
+ for (let i = 0; i < argv.length; i++) {
30
+ const a = argv[i];
31
+ if (a === '--delegated') {
32
+ out.delegated = argv[++i];
33
+ if (!out.delegated) out.error = '--delegated 须带「<用户对话原话>」参数';
34
+ } else if (a.startsWith('--delegated=')) {
35
+ out.delegated = a.slice('--delegated='.length);
36
+ } else if (a === '--root') {
37
+ out.root = argv[++i];
38
+ if (!out.root) out.error = '--root 须带仓库根路径';
39
+ } else if (!a.startsWith('--')) {
40
+ if (out.plan) out.error = `只受理一份 plan(现已有 ${out.plan})`;
41
+ else out.plan = a;
42
+ } else {
43
+ out.error = `未知参数 ${a}`;
44
+ }
45
+ }
46
+ if (!out.error && !out.plan) out.error = '用法:node .agents/scripts/closeout-l1.mjs <workflow/plans/xxx.md> [--delegated "<用户对话原话>"] [--root <仓库根>]';
47
+ return out;
48
+ }
49
+
50
+ // frontmatter 平铺解析(key: value;本工具只消费 状态/级别 两个键)
51
+ export function fmOf(text) {
52
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
53
+ if (!/^---\s*$/.test(lines[0] || '')) return {};
54
+ const fm = {};
55
+ for (let i = 1; i < lines.length; i++) {
56
+ if (/^---\s*$/.test(lines[i])) break;
57
+ const m = /^([^\s:]+):\s*(.*)$/.exec(lines[i]);
58
+ if (m) fm[m[1]] = m[2].trim();
59
+ }
60
+ return fm;
61
+ }
62
+
63
+ // acceptanceOf(text):定位「## …验收标准…」节,统计勾选框条目。
64
+ // 返回 { found, total, unchecked: [条目文本] };节内无任何勾选框条目 → found=true, total=0(判为异常)。
65
+ export function acceptanceOf(text) {
66
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
67
+ let start = -1;
68
+ for (let i = 0; i < lines.length; i++) {
69
+ if (/^##\s/.test(lines[i]) && lines[i].includes('验收标准')) { start = i; break; }
70
+ if (i > 0 && /^##\s/.test(lines[i]) && start >= 0) break; // 上一节结束仍无(不可达,防御)
71
+ }
72
+ if (start < 0) return { found: false, total: 0, unchecked: [] };
73
+ const unchecked = [];
74
+ let total = 0;
75
+ for (let i = start + 1; i < lines.length; i++) {
76
+ if (/^##\s/.test(lines[i])) break; // 节结束
77
+ const m = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(lines[i]);
78
+ if (!m) continue;
79
+ total++;
80
+ if (m[1] === ' ') unchecked.push(m[2].trim());
81
+ }
82
+ return { found: true, total, unchecked };
83
+ }
84
+
85
+ // preflight({ rel, text, freshVerify }):L1 关单前置快筛(advisory;强门在 confirm-doc)。
86
+ // 返回 { ok, problems: [], level, status, total, checked }。
87
+ export function preflight({ rel, text, freshVerify }) {
88
+ const problems = [];
89
+ if (!/^workflow\/plans\/[^/]+\.md$/.test(rel) || rel.includes('_TEMPLATE')) {
90
+ problems.push(`路径须为 workflow/plans/ 直下文档(现「${rel}」)——L2/L3 intent/spec 与 incidents 不在本工具受理面`);
91
+ }
92
+ const fm = fmOf(text);
93
+ const level = fm['级别'] || '';
94
+ const status = fm['状态'] || '';
95
+ if (level !== 'L1') {
96
+ problems.push(`级别「${level || '缺失'}」≠ L1——L2/L3 关单走 .agents/commands/test.md 全流程(验收判据对账 + 独立复核),本工具只服务协作道 L1`);
97
+ }
98
+ if (status === 'done' || status === 'superseded' || status === 'cancelled') {
99
+ problems.push(`状态已为终态「${status}」——本 plan 已收口,无需重复关单`);
100
+ } else if (status !== 'approved') {
101
+ problems.push(`状态「${status || '缺失'}」≠ approved——draft 须先过起草确认(confirm-doc approved),关单只受理 approved → done`);
102
+ }
103
+ const acc = acceptanceOf(text);
104
+ if (!acc.found) {
105
+ problems.push('未找到「## 验收标准」节——L1 plan 须有可勾验的验收标准(模板口径)');
106
+ } else if (acc.total === 0) {
107
+ problems.push('「验收标准」节内无勾选框条目(- [ ] / - [x])——验收标准须逐条可勾验');
108
+ } else if (acc.unchecked.length) {
109
+ problems.push(`验收标准 ${acc.unchecked.length}/${acc.total} 条未勾验(勾验与补证据是判断动作,留在关单前完成):`);
110
+ for (const u of acc.unchecked) problems.push(` - [ ] ${u}`);
111
+ }
112
+ if (!freshVerify) {
113
+ problems.push('24h 内无本单绑定的 verify 绿行(.agents/verifications.jsonl)——先跑 node .agents/scripts/verify.mjs --doc <本单路径>(全绿自动落绿凭证与 verify-evidence 摘要;v6+ 只认带本单 --doc 的绿行)');
114
+ }
115
+ return { ok: problems.length === 0, problems, level, status, total: acc.total, checked: acc.total - acc.unchecked.length };
116
+ }
117
+
118
+ // shardOf(rel):该 plan 的确认台账分片路径(posix,仓库根相对)。写入口径单源 = ledger-loader
119
+ // writeConfirmation(basename 去 .md)——源仓首版此处拼了 `<基名.md>.jsonl`,提交清单会漏列分片。
120
+ export function shardOf(rel) {
121
+ return path.join('.agents', 'confirmations.d', `${path.basename(rel, '.md')}.jsonl`);
122
+ }
123
+
124
+ function gitRoot() {
125
+ const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
126
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : process.cwd();
127
+ }
128
+
129
+ // freshVerifyOf(root, rel):镜像 kit confirm-doc done 前置的绿行口径(判定单源 policy.hasFreshVerifyLine):
130
+ // policy v5+(verifySince)才有 24h 绿行门;v6+(verifyDocSince)按本 doc 精确绑定。预检与强门同口径,
131
+ // 差距快筛才不失真。只读,台账缺失/不可读一律 false(advisory 层 fail-open,同 confirm-doc)。
132
+ function freshVerifyOf(root, rel) {
133
+ try {
134
+ const p = path.join(root, '.agents', 'verifications.jsonl');
135
+ if (!fs.existsSync(p)) return false;
136
+ const pol = loadKitPolicy(root);
137
+ if (typeof pol.verifySince !== 'string') return true; // v1-v4 无绿行门 → 预检不产差距
138
+ const bound = typeof pol.verifyDocSince === 'string' ? rel : null;
139
+ return hasFreshVerifyLine(fs.readFileSync(p, 'utf8').split(/\r?\n/), Date.now(), bound);
140
+ } catch {
141
+ return false;
142
+ }
143
+ }
144
+
145
+ function findArchived(root, rel) {
146
+ const base = path.basename(rel);
147
+ for (const monthDir of fs.existsSync(path.join(root, 'workflow', 'archive'))
148
+ ? fs.readdirSync(path.join(root, 'workflow', 'archive')) : []) {
149
+ const cand = path.join('workflow', 'archive', monthDir, 'plans', base);
150
+ if (fs.existsSync(path.join(root, cand))) return cand;
151
+ }
152
+ return null;
153
+ }
154
+
155
+ function main() {
156
+ const args = parseArgs(process.argv.slice(2));
157
+ if (args.error) {
158
+ console.error(`closeout-l1: ${args.error}`);
159
+ process.exit(2);
160
+ }
161
+ const root = path.resolve(args.root || gitRoot());
162
+ const rel = path.relative(root, path.resolve(args.plan)).replace(/\\/g, '/');
163
+ const abs = path.join(root, rel);
164
+ if (!fs.existsSync(abs)) {
165
+ console.error(`closeout-l1: 文件不存在 ${rel}(相对仓库根 ${root})`);
166
+ process.exit(2);
167
+ }
168
+ const text = fs.readFileSync(abs, 'utf8');
169
+ const pf = preflight({ rel, text, freshVerify: freshVerifyOf(root, rel) });
170
+
171
+ console.log(`L1 关单预检|${rel}`);
172
+ console.log(` 级别 ${pf.level || '—'}|状态 ${pf.status || '—'}|验收 ${pf.checked}/${pf.total || 0} 勾验|verify 绿行 ${pf.problems.some((p) => p.includes('verify')) ? '✗' : '✓(24h)'}`);
173
+ if (!pf.ok) {
174
+ console.error(`\n预检未过(${pf.problems.length} 项差距,逐条处理后再来):`);
175
+ for (const p of pf.problems) console.error(` ✗ ${p}`);
176
+ process.exit(2);
177
+ }
178
+ console.log(' 预检通过。');
179
+
180
+ if (!args.delegated) {
181
+ console.log('\n下一步(执行关单,须先取得用户对话内明确放行):');
182
+ console.log(` node .agents/scripts/closeout-l1.mjs ${rel} --delegated "<用户确认原话>"`);
183
+ return;
184
+ }
185
+
186
+ // 执行模式:confirm-doc 原样转发(全部强门在彼处),随后重生成 INDEX。
187
+ const cd = spawnSync(process.execPath, [
188
+ path.join(SCRIPT_DIR, 'confirm-doc.mjs'), rel, '--delegated', args.delegated, '--root', root,
189
+ ], { stdio: 'inherit' });
190
+ if (cd.status !== 0) {
191
+ console.error(`closeout-l1: confirm-doc 拒绝(exit ${cd.status})——按上方提示修复后重试;本命令不做任何绕过。`);
192
+ process.exit(cd.status === null ? 2 : cd.status);
193
+ }
194
+ const gi = spawnSync(process.execPath, [path.join(SCRIPT_DIR, 'gen-workflow-index.mjs')], { cwd: root, encoding: 'utf8' });
195
+ if (gi.status !== 0) {
196
+ console.error('closeout-l1: ⚠️ INDEX 重生成失败(确认已落账不可回滚;提交前须手动跑 node .agents/scripts/gen-workflow-index.mjs 修齐,否则索引漂移门会拦提交)');
197
+ }
198
+
199
+ // 汇总:关单后的落盘面与提交清单(归档 mv 时给出新路径)。
200
+ const base = path.basename(rel);
201
+ const archivedTo = fs.existsSync(abs) ? null : findArchived(root, rel);
202
+ const shard = shardOf(rel);
203
+ const adds = [
204
+ archivedTo || rel,
205
+ fs.existsSync(path.join(root, shard)) ? shard : '.agents/confirmations.jsonl',
206
+ 'workflow/INDEX.md',
207
+ ];
208
+ console.log(`\n关单完成:approved → done(台账已记 chat-delegated,原话入账供对质)`);
209
+ if (archivedTo) console.log(`📦 已随确认滚动归档:${rel} → ${archivedTo}(家族原子,git mv 已暂存)`);
210
+ console.log(`提交清单(git add):
211
+ ${adds.join('\n ')}`);
212
+ console.log(`建议提交说明:docs(workflow): 关单 ${base.replace(/\.md$/, '')}(L1 done)——用户对话内确认代录${archivedTo ? `,归档至 ${path.dirname(archivedTo)}/` : ''}`);
213
+ console.log('提醒:本会话自做任务另记 workflow/delegations.md 并跑 agg-delegations.mjs(test.md §6);verify-evidence.md 有改动时随同提交。');
214
+ }
215
+
216
+ const isMain = process.argv[1] && process.argv[1].endsWith('closeout-l1.mjs');
217
+ if (isMain) main();
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ // closeout-l1.test.mjs — L1 关单编排纯函数套件(2026-10-15 减重手段④ 自源仓回灌随附)
3
+ // parseArgs / fmOf / acceptanceOf / preflight / shardOf 进程内断言:快筛差距逐项暴露、
4
+ // L2/L3 与终态件不受理、勾验缺一即拦、verify 凭证缺失即拦、分片路径与写入口径同形。
5
+ // 运行:node templates/_agents/scripts/closeout-l1.test.mjs
6
+ import { parseArgs, fmOf, acceptanceOf, preflight, shardOf } from './closeout-l1.mjs';
7
+
8
+ let pass = 0;
9
+ let fail = 0;
10
+ const check = (desc, cond, detail = '') => {
11
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
12
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
13
+ };
14
+
15
+ const PLAN_OK = `---
16
+ 状态: approved
17
+ 级别: L1
18
+ 模块: purchase
19
+ ---
20
+ # PLAN — demo
21
+
22
+ ## 改动方案
23
+
24
+ - 改 A
25
+
26
+ ## 验收标准(可测试)
27
+
28
+ - [x] 判据一(证据:UI 实测 A1 PASS)
29
+ - [x] 判据二(证据:UT 用例名 demo_case)
30
+ `;
31
+
32
+ // ---- A1 parseArgs ----
33
+ {
34
+ const a = parseArgs(['workflow/plans/x.md', '--delegated', '可以关单', '--root', 'E:/r']);
35
+ check('A1 三参齐解析', a.plan === 'workflow/plans/x.md' && a.delegated === '可以关单' && a.root === 'E:/r' && !a.error, JSON.stringify(a));
36
+ check('A1 缺 plan → error', !!parseArgs(['--delegated', 'q']).error);
37
+ check('A1 --delegated 缺值 → error', !!parseArgs(['workflow/plans/x.md', '--delegated']).error);
38
+ check('A1 未知参数 → error', !!parseArgs(['workflow/plans/x.md', '--wow']).error);
39
+ check('A1 两份 plan → error', !!parseArgs(['a.md', 'b.md']).error);
40
+ check('A1 --delegated= 等号形态', parseArgs(['x.md', '--delegated=原话']).delegated === '原话');
41
+ }
42
+
43
+ // ---- A2 fmOf / acceptanceOf ----
44
+ {
45
+ const fm = fmOf(PLAN_OK);
46
+ check('A2 frontmatter 状态/级别', fm['状态'] === 'approved' && fm['级别'] === 'L1' && fm['模块'] === 'purchase', JSON.stringify(fm));
47
+ const acc = acceptanceOf(PLAN_OK);
48
+ check('A2 验收节 2 条全勾', acc.found === true && acc.total === 2 && acc.unchecked.length === 0, JSON.stringify(acc));
49
+ check('A2 无验收节 → found=false', acceptanceOf('# 无节文档\n\n- [ ] 孤儿勾选不算').found === false);
50
+ const mixed = acceptanceOf('## 验收标准\n\n- [x] 甲\n- [ ] 乙待办\n- [X] 丙大写勾\n\n## 下一节\n\n- [ ] 节外不算\n');
51
+ check('A2 未勾只数节内(乙),大写 X 认勾、节外不计', mixed.total === 3 && mixed.unchecked.length === 1 && mixed.unchecked[0] === '乙待办', JSON.stringify(mixed));
52
+ check('A2 节在无勾选框条目 → total=0', acceptanceOf('## 验收标准\n\n纯文字段\n').total === 0);
53
+ }
54
+
55
+ // ---- A3 preflight 快乐路径 ----
56
+ {
57
+ const pf = preflight({ rel: 'workflow/plans/2026-10-15-demo.md', text: PLAN_OK, freshVerify: true });
58
+ check('A3 L1+approved+全勾+有凭证 → ok', pf.ok === true && pf.problems.length === 0 && pf.checked === 2, JSON.stringify(pf));
59
+ }
60
+
61
+ // ---- A4 级别/状态不受理 ----
62
+ {
63
+ const l2 = preflight({ rel: 'workflow/plans/x.md', text: PLAN_OK.replace('级别: L1', '级别: L2'), freshVerify: true });
64
+ check('A4 L2 → 拒并指回 test.md', !l2.ok && l2.problems.some((p) => p.includes('L2/L3') && p.includes('test.md')), JSON.stringify(l2.problems));
65
+ const draft = preflight({ rel: 'workflow/plans/x.md', text: PLAN_OK.replace('状态: approved', '状态: draft'), freshVerify: true });
66
+ check('A4 draft → 拒(须先过起草确认)', !draft.ok && draft.problems.some((p) => p.includes('draft')), JSON.stringify(draft.problems));
67
+ const done = preflight({ rel: 'workflow/plans/x.md', text: PLAN_OK.replace('状态: approved', '状态: done'), freshVerify: true });
68
+ check('A4 done → 拒(已收口)', !done.ok && done.problems.some((p) => p.includes('终态')), JSON.stringify(done.problems));
69
+ check('A4 级别缺失也拒(fail-closed)', !preflight({ rel: 'workflow/plans/x.md', text: PLAN_OK.replace('级别: L1\n', ''), freshVerify: true }).ok);
70
+ }
71
+
72
+ // ---- A5 路径受理面 ----
73
+ {
74
+ check('A5 intents 路径不受理', !preflight({ rel: 'workflow/intents/x.md', text: PLAN_OK, freshVerify: true }).ok);
75
+ check('A5 incidents 路径不受理', !preflight({ rel: 'workflow/incidents/x.md', text: PLAN_OK, freshVerify: true }).ok);
76
+ check('A5 模板不受理', !preflight({ rel: 'workflow/plans/_TEMPLATE.md', text: PLAN_OK, freshVerify: true }).ok);
77
+ check('A5 子目录路径不受理', !preflight({ rel: 'workflow/plans/sub/x.md', text: PLAN_OK, freshVerify: true }).ok);
78
+ }
79
+
80
+ // ---- A6 勾验与凭证差距 ----
81
+ {
82
+ const unchecked = preflight({
83
+ rel: 'workflow/plans/x.md',
84
+ text: PLAN_OK.replace('- [x] 判据二(证据:UT 用例名 demo_case)', '- [ ] 判据二'),
85
+ freshVerify: true,
86
+ });
87
+ check('A6 未勾验逐条列出(计数行 + 条目行分项)', !unchecked.ok
88
+ && unchecked.problems.some((p) => p.includes('1/2 条未勾验'))
89
+ && unchecked.problems.some((p) => p.includes('判据二')), JSON.stringify(unchecked.problems));
90
+ const noVerify = preflight({ rel: 'workflow/plans/x.md', text: PLAN_OK, freshVerify: false });
91
+ check('A6 verify 凭证缺失 → 拦并指 verify.mjs --doc', !noVerify.ok && noVerify.problems.some((p) => p.includes('verify.mjs') && p.includes('--doc')), JSON.stringify(noVerify.problems));
92
+ const noAcc = preflight({ rel: 'workflow/plans/x.md', text: PLAN_OK.split('## 验收标准')[0], freshVerify: true });
93
+ check('A6 无验收节 → 拦', !noAcc.ok && noAcc.problems.some((p) => p.includes('验收标准')));
94
+ }
95
+
96
+ // ---- A7 shardOf:与 ledger-loader writeConfirmation 写入口径同形(<基名去.md>.jsonl)----
97
+ // 源仓首版 main() 拼 `<基名.md>.jsonl` 与写入口径错位——提交清单会把分片误报成主台账。本断言钉死修正。
98
+ {
99
+ const s = shardOf('workflow/plans/2026-10-15-demo.md');
100
+ check('A7 分片路径 = confirmations.d/<基名>.jsonl(去 .md)', s.replace(/\\/g, '/') === '.agents/confirmations.d/2026-10-15-demo.jsonl', s);
101
+ check('A7 分片路径不含 .md.jsonl 双后缀', !s.replace(/\\/g, '/').includes('.md.jsonl'), s);
102
+ }
103
+
104
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
105
+ process.exit(fail ? 1 : 0);
@@ -149,6 +149,55 @@ for (const [label, writePkg] of unsatisfied) {
149
149
  r.status === 0 && !ran(f.marks, 'build') && r.out.includes('pkg:scripts.build') && !r.out.includes('无匹配的源码变更'), r.out);
150
150
  }
151
151
 
152
+ // 场景 10:--no-build 跳过全部构建(2026-10-16 构建门后移 pre-push)——ext 命中也不跑
153
+ f = mkfix();
154
+ stage(f.root, 'src/a.xyz', 'x');
155
+ r = runHook(f.root, ['--no-build']);
156
+ check('S10 --no-build 命中 ext 也不构建(后移 pre-push)',
157
+ r.status === 0 && !ran(f.marks, 'extbuild') && r.out.includes('--no-build'), r.out);
158
+
159
+ // 场景 11:--range 推送范围模式——范围内 .xyz 触发 extbuild;敏感扫描跳过有提示
160
+ f = mkfix();
161
+ fs.writeFileSync(path.join(f.root, 'old.txt'), 'old\n');
162
+ git(f.root, ['add', 'old.txt']);
163
+ git(f.root, ['commit', '-q', '-m', 'old']);
164
+ stage(f.root, 'src/ranged.xyz', 'x');
165
+ const shaOld = git(f.root, ['rev-parse', 'HEAD']).stdout.trim();
166
+ git(f.root, ['commit', '-q', '-m', 'ranged']);
167
+ const shaNew = git(f.root, ['rev-parse', 'HEAD']).stdout.trim();
168
+ r = runHook(f.root, ['--range', shaOld, shaNew]);
169
+ check('S11 --range 范围内 .xyz 触发 extbuild 且提示跳过敏感扫描',
170
+ r.status === 0 && ran(f.marks, 'extbuild') && r.out.includes('SKIP: --range 模式跳过敏感扫描'), r.out);
171
+
172
+ // 场景 11b:--range 范围只含非触发文件 → 零构建
173
+ f = mkfix();
174
+ fs.writeFileSync(path.join(f.root, 'a.txt'), 'a\n');
175
+ git(f.root, ['add', 'a.txt']);
176
+ git(f.root, ['commit', '-q', '-m', 'a']);
177
+ const s1 = git(f.root, ['rev-parse', 'HEAD']).stdout.trim();
178
+ fs.writeFileSync(path.join(f.root, 'b.txt'), 'b\n');
179
+ git(f.root, ['add', 'b.txt']);
180
+ git(f.root, ['commit', '-q', '-m', 'b']);
181
+ const s2 = git(f.root, ['rev-parse', 'HEAD']).stdout.trim();
182
+ r = runHook(f.root, ['--range', s1, s2]);
183
+ check('S11b --range 范围无触发文件 → 零构建',
184
+ r.status === 0 && !ran(f.marks, 'extbuild') && !ran(f.marks, 'prefixbuild'), r.out);
185
+
186
+ // 场景 12:--range 全零 old sha(新分支首推)→ 全量构建语义(等价 --full 构建面)
187
+ f = mkfix();
188
+ fs.writeFileSync(path.join(f.root, 'seed.txt'), 'seed\n');
189
+ git(f.root, ['add', 'seed.txt']);
190
+ git(f.root, ['commit', '-q', '-m', 'seed']);
191
+ const shaHead = git(f.root, ['rev-parse', 'HEAD']).stdout.trim();
192
+ r = runHook(f.root, ['--range', '0'.repeat(40), shaHead]);
193
+ check('S12 --range 全零 old = 全量构建语义',
194
+ r.status === 0 && ran(f.marks, 'extbuild') && ran(f.marks, 'prefixbuild'), r.out);
195
+
196
+ // 场景 13:--range sha 非法 → BLOCK(信任边界:sha 来自 git 管道仍校验)
197
+ f = mkfix();
198
+ r = runHook(f.root, ['--range', 'not-a-sha', 'HEAD']);
199
+ check('S13 --range 非 sha 参数 → BLOCK exit 1', r.status === 1 && r.out.includes('BLOCK'), r.out);
200
+
152
201
  for (const d of fs.readdirSync(os.tmpdir()).filter((x) => x.startsWith('cc-trigger-test-'))) {
153
202
  fs.rmSync(path.join(os.tmpdir(), d), { recursive: true, force: true });
154
203
  }