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
@@ -541,9 +541,21 @@ if (isMain) {
541
541
  // 内容绑定约束,与既有归档批次同口径)。失败一律 fail-open:归档是卫生动作,不得回滚已完成的
542
542
  // 确认(**归档失败不阻断关单**——与源仓语义逐字一致)。启用锚(照搬源仓,无 policy 锚):
543
543
  // ① 仓库已建立归档约定(workflow/archive/ 存在)——真实装户仓有,临时 fixture 无,避免测试夹具
544
- // 被静默搬走(也避免在未采纳归档的仓库里凭空造目录);② env 逃生门 CONFIRM_DOC_NO_ARCHIVE。
544
+ // 被静默搬走(也避免在未采纳归档的仓库里凭空造目录);② env 逃生门 CONFIRM_DOC_NO_ARCHIVE;
545
+ // ③ 特性分支默认不归档(2026-10-16 自源仓 13f67ef7 回灌):分支上先关单再合并时,diff 只剩
546
+ // archive/ 路径,泳道/配对增量门只认 workflow 活跃直路径,合法合并被拦。main/master/develop 与
547
+ // 游离 HEAD 维持关单即归档。CONFIRM_DOC_NO_ARCHIVE=1 仍强制关;CONFIRM_DOC_ARCHIVE=1 可在特性分支强制归档。
545
548
  const archiveRoot = path.join(root, 'workflow', 'archive');
546
- if (confirmed > 0 && !process.env.CONFIRM_DOC_NO_ARCHIVE && fs.existsSync(archiveRoot)) {
549
+ let featureHold = false;
550
+ if (confirmed > 0 && !process.env.CONFIRM_DOC_NO_ARCHIVE && process.env.CONFIRM_DOC_ARCHIVE !== '1') {
551
+ let branch = '';
552
+ try { branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); } catch { branch = ''; }
553
+ featureHold = !!branch && branch !== 'HEAD' && !['main', 'master', 'develop'].includes(branch);
554
+ if (featureHold && fs.existsSync(archiveRoot)) {
555
+ console.log(`📦 特性分支 ${branch} 不归档(合并前保持 workflow 活跃路径;main/develop 关单才归档)。CONFIRM_DOC_ARCHIVE=1 可强制`);
556
+ }
557
+ }
558
+ if (confirmed > 0 && !process.env.CONFIRM_DOC_NO_ARCHIVE && !featureHold && fs.existsSync(archiveRoot)) {
547
559
  try {
548
560
  const { analyze, buildDocList, loadLastTouch, parseStatus, TERMINAL, ACTIVE } = await import('./archive-sweep.mjs');
549
561
  // 台账终态锚:kit 读侧单源 = readLedgerRows(主台账+分片透明合并;源仓走
@@ -1056,5 +1056,48 @@ const writeTrust = (root, cfg) => {
1056
1056
  fs.rmSync(root2, { recursive: true, force: true });
1057
1057
  }
1058
1058
 
1059
+ // ---- S45c/S45d 特性分支默认不归档(2026-10-16 自源仓 13f67ef7 回灌)----
1060
+ // 分支上先关单再合并时 diff 只剩 archive/ 路径,泳道/配对增量门只认 workflow 活跃直路径;
1061
+ // main/master/develop 与游离 HEAD 维持关单即归档。CONFIRM_DOC_ARCHIVE=1 可在特性分支强制归档。
1062
+ {
1063
+ const mkRepoBranch = (branchName) => {
1064
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'confirm-branch-'));
1065
+ const git = (...a) => spawnSync(process.platform === 'win32' ? 'git.exe' : 'git', a, { cwd: root, encoding: 'utf8' });
1066
+ git('init', '-q');
1067
+ git('config', 'user.email', 't@t');
1068
+ git('config', 'user.name', 't');
1069
+ git('checkout', '-q', '-b', branchName);
1070
+ const ip = path.join(root, 'workflow', 'intents');
1071
+ fs.mkdirSync(ip, { recursive: true });
1072
+ fs.mkdirSync(path.join(root, 'workflow', 'archive'), { recursive: true });
1073
+ const fp = branchName === 'main' ? 'eeee5555eeee5555' : 'ffff6666ffff6666';
1074
+ fs.writeFileSync(path.join(ip, '2026-09-01-branch.md'), `---\n状态: approved\n级别: L1\n确认指纹: ${fp}\n---\n# I\n\n## 验收标准(可测试)\n- [x] 通过(证据:测试全绿)\n`, 'utf8');
1075
+ git('add', '-A');
1076
+ spawnSync(process.platform === 'win32' ? 'git.exe' : 'git', ['commit', '-q', '-m', 'c1'], { cwd: root, env: { ...process.env, GIT_AUTHOR_DATE: '2026-09-01T10:00:00+08:00', GIT_COMMITTER_DATE: '2026-09-01T10:00:00+08:00' } });
1077
+ return { root, ip };
1078
+ };
1079
+ // S45c:特性分支关单 → 不归档(保持 workflow 活跃直路径),但状态照常 done
1080
+ {
1081
+ const { root, ip } = mkRepoBranch('feature/x');
1082
+ const r = spawnSync(process.execPath, [CLI, 'workflow/intents/2026-09-01-branch.md', '--delegated', '可以'],
1083
+ { cwd: root, encoding: 'utf8', env: { ...process.env, CONFIRM_DOC_TEST_NOW: '2026-10-13' } });
1084
+ check('S45c 特性分支关单默认不归档(状态置 done、文档留活跃路径)',
1085
+ r.status === 0 && /状态: done/.test(fs.readFileSync(path.join(ip, '2026-09-01-branch.md'), 'utf8'))
1086
+ && !fs.existsSync(path.join(root, 'workflow', 'archive', 'intents')) && /不归档/.test(r.stdout),
1087
+ JSON.stringify({ status: r.status, stdout: String(r.stdout).slice(-260) }));
1088
+ fs.rmSync(root, { recursive: true, force: true });
1089
+ }
1090
+ // S45d:特性分支 + CONFIRM_DOC_ARCHIVE=1 → 强制归档
1091
+ {
1092
+ const { root } = mkRepoBranch('feature/y');
1093
+ const r = spawnSync(process.execPath, [CLI, 'workflow/intents/2026-09-01-branch.md', '--delegated', '可以'],
1094
+ { cwd: root, encoding: 'utf8', env: { ...process.env, CONFIRM_DOC_TEST_NOW: '2026-10-13', CONFIRM_DOC_ARCHIVE: '1' } });
1095
+ check('S45d 特性分支 CONFIRM_DOC_ARCHIVE=1 强制归档',
1096
+ r.status === 0 && /📦 归档/.test(r.stdout),
1097
+ JSON.stringify({ status: r.status, stdout: String(r.stdout).slice(-260) }));
1098
+ fs.rmSync(root, { recursive: true, force: true });
1099
+ }
1100
+ }
1101
+
1059
1102
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
1060
1103
  process.exit(fail ? 1 : 0);
@@ -14,6 +14,7 @@ import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { loadEnums } from './workflow-enums.mjs';
16
16
  import { readLedgerRows as loadLedgerRows } from './ledger-loader.mjs';
17
+ import { analyze, buildDocList, loadLastTouch, loadLedgerTs, parseStatus, ACTIVE } from './archive-sweep.mjs';
17
18
 
18
19
  const USAGE = `用法:node .agents/scripts/gen-workflow-metrics.mjs [--month YYYY-MM] [--dry-run] [--help]
19
20
 
@@ -217,6 +218,22 @@ console.log(` 常驻面合计:${kb(surf.total)}`);
217
218
  console.log(` 闭环漏斗:收口 ${funnel.closed}(完整链 ${funnel.full} = 一次通过 ${funnel.once} + 返工 ${funnel.rework};协议前 ${funnel.legacy})|中位周期 ${medianDays === null ? '—' : `${medianDays.toFixed(1)} 天`}|台账行 ${funnel.ledgerRows}`);
218
219
  for (const it of funnel.items) console.log(` ${String(it.doc).padEnd(58)} ${it.kind.padEnd(7)} ${it.days} 天`);
219
220
  if (funnel.items.length) console.log(' ℹ️ 机器口径可与 workflow/delegations.md 自做表的人工自报交叉对照(差异即信号,不对账)');
221
+ // 归档积压提醒(2026-10-15 减重手段⑥ 自源仓回灌,回路别等人喊):滚动归档已随确认门自动跑
222
+ // (confirm-doc archive-on-close),历史积压走 archive-sweep 手工批次——月度 stdout 报候选数使积压可见。
223
+ // 判定单源 = archive-sweep.analyze(21 天窗,禁第二套口径);analyze/loadLastTouch 抛错(如
224
+ // 浅克隆无 git 历史)fail-open 静默——观测提醒不挡快照。
225
+ try {
226
+ const allDocs = buildDocList(process.cwd());
227
+ const arch = analyze({
228
+ docs: allDocs,
229
+ ledgerTs: loadLedgerTs(process.cwd()),
230
+ lastTouch: loadLastTouch(process.cwd()),
231
+ activeTexts: allDocs.filter((d) => ACTIVE.has(parseStatus(d.text))).map((d) => d.text),
232
+ windowDays: 21,
233
+ });
234
+ const fams = new Set(arch.candidates.map((x) => x.file));
235
+ console.log(` 归档积压:候选 ${arch.candidates.length} 篇 / ${fams.size} 族(21 天窗)${arch.candidates.length ? '——node .agents/scripts/archive-sweep.mjs 查看,--execute 执行' : ''}`);
236
+ } catch { /* fail-open */ }
220
237
  console.log(` ${DRY ? '(--dry-run,未写盘)' : ''}`);
221
238
 
222
239
  if (DRY) { console.log(`\n将写入行:\n${row}\n${funnelRow}`); process.exit(0); }
@@ -226,8 +226,30 @@ export function renderShard(name, rows, eol = '\n') {
226
226
  return out.length ? `${out.join(eol)}${eol}` : '';
227
227
  }
228
228
 
229
+ // insertIntoSection(text, section, lines):把行插进已有分片的对应 ## 节(下一节标题之前)。
230
+ // 节不存在则整节追加到文末。无 section 的行不走这里(保持追加到文末 = 末表)。
231
+ function insertIntoSection(text, section, lines) {
232
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
233
+ const parts = text.split(/\r?\n/);
234
+ if (parts.length && parts[parts.length - 1] === '') parts.pop();
235
+ const start = parts.findIndex((l) => l.startsWith('## ') && l.includes(section));
236
+ if (start < 0) {
237
+ return `${parts.join(eol)}${eol}${eol}## ${section}${eol}${eol}${lines.join(eol)}${eol}`;
238
+ }
239
+ let end = parts.length;
240
+ for (let i = start + 1; i < parts.length; i++) {
241
+ if (parts[i].startsWith('## ')) { end = i; break; }
242
+ }
243
+ let at = end;
244
+ while (at > start && parts[at - 1].trim() === '') at--;
245
+ parts.splice(at, 0, ...lines);
246
+ return `${parts.join(eol)}${eol}`;
247
+ }
248
+
229
249
  // appendRows(root, name, rows, { month, section }):把数据行追加进指定月分片(缺省取行内日期月)。
230
250
  // rows 可为字符串数组或 { line, section } 数组(多表台账须带 section 才能归对表)。
251
+ // 分片已存在时:带 section 的行插入该节(不落到文末自做表);不带 section 的行仍追加到文末
252
+ // (2026-10-16 自源仓 13f67ef7 回灌——修「合并丢共有行」时新增行误落末表)。
231
253
  // 返回实际写入的分片路径列表。写失败 fail-loud(不静默吞)。
232
254
  export function appendRows(root, name, rows, { month, section = '' } = {}) {
233
255
  const written = [];
@@ -247,9 +269,21 @@ export function appendRows(root, name, rows, { month, section = '' } = {}) {
247
269
  const existingRows = new Set(existing.split(/\r?\n/).filter((l) => l.trim()));
248
270
  const fresh = rs.filter((r) => !existingRows.has(r.line));
249
271
  if (!fresh.length) { written.push(p); continue; } // 已在分片 → 幂等跳过
250
- const next = existing
251
- ? `${existing.replace(/\s*$/, '')}\n${fresh.map((r) => r.line).join('\n')}\n`
252
- : renderShard(name, fresh, '\n');
272
+ let next;
273
+ if (!existing) next = renderShard(name, fresh, '\n');
274
+ else {
275
+ const placed = fresh.filter((r) => r.section);
276
+ const tail = fresh.filter((r) => !r.section);
277
+ let text = existing;
278
+ const bySec = new Map();
279
+ for (const r of placed) {
280
+ if (!bySec.has(r.section)) bySec.set(r.section, []);
281
+ bySec.get(r.section).push(r.line);
282
+ }
283
+ for (const [sec, lines] of bySec) text = insertIntoSection(text, sec, lines);
284
+ if (tail.length) text = `${text.replace(/\s*$/, '')}\n${tail.map((r) => r.line).join('\n')}\n`;
285
+ next = text.endsWith('\n') ? text : `${text}\n`;
286
+ }
253
287
  fs.writeFileSync(p, next, 'utf8');
254
288
  written.push(p);
255
289
  }
@@ -296,11 +330,14 @@ function main() {
296
330
  if (!row) { console.error('--append 须带 --row "<数据行>"'); process.exit(2); }
297
331
  const mIdx = argv.indexOf('--month');
298
332
  const month = mIdx >= 0 ? argv[mIdx + 1] : undefined;
299
- const written = appendRows(root, name, [row], { month });
333
+ const sIdx = argv.indexOf('--section');
334
+ const section = sIdx >= 0 ? argv[sIdx + 1] : '';
335
+ if (sIdx >= 0 && !section) { console.error('--section 须带节标题(如 委派结果)'); process.exit(2); }
336
+ const written = appendRows(root, name, [row], { month, section });
300
337
  console.log(`✓ 追加 1 行 → ${written.map((p) => path.relative(root, p).split(path.sep).join('/')).join(', ')}`);
301
338
  return;
302
339
  }
303
- console.error('用法:node .agents/scripts/ledger-shards.mjs --check | --split <ledger> [--dry-run] | --append <ledger> --row "<行>" [--month YYYY-MM]');
340
+ console.error('用法:node .agents/scripts/ledger-shards.mjs --check | --split <ledger> [--dry-run] | --append <ledger> --row "<行>" [--month YYYY-MM] [--section <节标题>]');
304
341
  process.exit(2);
305
342
  }
306
343
 
@@ -247,6 +247,23 @@ const wf = (root, ...p) => path.join(root, 'workflow', ...p);
247
247
  fs.rmSync(root, { recursive: true, force: true });
248
248
  }
249
249
 
250
+ // ---- S20 appendRows --section(2026-10-16 自源仓 13f67ef7 回灌):已有双表时委派行插入委派节,
251
+ // 不带 section 仍落文末(修「合并丢共有行」时新增行误落末表)----
252
+ {
253
+ const root = mkroot();
254
+ appendRows(root, 'delegations', ['| 2026-10-15 | model | 委派甲 | 一次通过 | 备注 | |'], { section: '委派结果' });
255
+ appendRows(root, 'delegations', ['| 2026-10-15 | 自做甲 | 一次通过 | 备注 | |'], { section: '自做任务结果' });
256
+ appendRows(root, 'delegations', ['| 2026-10-15 | model | 委派乙 | 一次通过 | 备注 | |'], { section: '委派结果' });
257
+ appendRows(root, 'delegations', ['| 2026-10-15 | 自做乙 | 一次通过 | 备注 | |']);
258
+ const shard = fs.readFileSync(shardPathFor(root, 'delegations', '2026-10'), 'utf8');
259
+ const iSelf = shard.indexOf('## 自做任务结果');
260
+ const iDelegRow = shard.indexOf('委派乙');
261
+ const iSelfRow = shard.indexOf('自做乙');
262
+ check('S20 后追加的委派行仍在自做节之前', iDelegRow >= 0 && iDelegRow < iSelf, shard);
263
+ check('S20b 不带 section 的行落在文末', iSelf >= 0 && iSelf < iSelfRow, shard);
264
+ fs.rmSync(root, { recursive: true, force: true });
265
+ }
266
+
250
267
  // ---- S18 appendRows 幂等:同行走两次只落一次 ----
251
268
  {
252
269
  const root = mkroot();
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ // mechanism-retire-watch.mjs — 机制退役催办自动化(2026-10-14 源仓 L5 自举接线 1b/1c;2026-10-16 回灌 kit)
3
+ // 用途:读机制台账「退役阈值/最近触发」+ judgments 聚合,阈值触达自动产动作项——让机制的「死」与「生」同样强制。
4
+ // 自动化的是**感知/排序**(谁触达了阈值),处置仍人放行(改状态/删代码走人 + 一进一出)。
5
+ // 口径单源:.agents/mechanism-registry.md 列序(机制|门禁位置|触发统计口径|退役阈值|最近触发|状态|处置期限)
6
+ // 与 .agents/judgments.jsonl(check-loop 每次真实运行落账,per-rule WARN/BLOCK 计数)。
7
+ // 判定(确定性,无 LLM):
8
+ // ① 现役行 + 阈值形态「连续 N 月 = 0」+ 口径列含「规则名」→ 以有数据的末月为终点取 N 个日历月,缺月不算零命中,全 0 即触达 → 动作项。
9
+ // ② 待退役行 + 处置期限为日期且已过 → 动作项(逾期)。
10
+ // ③ 其余阈值形态(不退役主干 / 使用率型 / 数据源型)→ 非自动可判,仅计数,交人工月判。
11
+ // 缺月不算零命中(2026-10-15 源仓修正):漏跑数月后陈旧月份不得凑成「连续」——日历窗口内有缺月即
12
+ // 交人工判,不产触达动作(与 agg-judgments --retire-candidates 的「数据月」口径互补:彼按有数据月,
13
+ // 此按日历连续)。
14
+ // 退出码恒 0(催办报告不该阻断调度器;测量失败打 [ERROR] 行)。
15
+ // 用法:node .agents/scripts/mechanism-retire-watch.mjs [--json] [--root <仓库根>] [--now <ISO 日期>]
16
+ // 测试:node .agents/scripts/mechanism-retire-watch.test.mjs(纯函数 evaluate 进程内断言)
17
+ // kit 适配(源仓→kit):台账路径 `.agents/config/mechanism-registry.md` → kit 平铺
18
+ // `.agents/mechanism-registry.md`(同 check-registry.mjs 检查 22 口径)。
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath, pathToFileURL } from 'node:url';
22
+ import { aggFromRows } from './agg-judgments.mjs';
23
+
24
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
25
+ const argv = process.argv.slice(2);
26
+ const argOf = (k) => { const i = argv.indexOf(k); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : ''; };
27
+ const asJson = argv.includes('--json');
28
+ const ROOT = path.resolve(argOf('--root') || path.join(SCRIPT_DIR, '..', '..'));
29
+ const NOW = argOf('--now') ? new Date(argOf('--now')) : new Date();
30
+
31
+ const REGISTRY_REL = path.join('.agents', 'mechanism-registry.md');
32
+ const LEDGER_REL = path.join('.agents', 'judgments.jsonl');
33
+
34
+ // parseRegistry(text):7 列管道表 → 行对象数组。列序固定(check-loop §22 按此解析)。
35
+ export function parseRegistry(text) {
36
+ const rows = [];
37
+ for (const l of String(text).split(/\r?\n/)) {
38
+ if (!/^\|/.test(l)) continue;
39
+ if (/^\|[\s:|-]+\|?\s*$/.test(l)) continue; // 分隔行
40
+ const cells = l.split('|').slice(1, -1).map((c) => c.trim());
41
+ if (cells.length !== 7) continue;
42
+ if (cells[0] === '机制') continue; // 表头
43
+ const [name, gate, criterion, threshold, lastTrigger, status, deadline] = cells;
44
+ rows.push({ name, gate, criterion, threshold, lastTrigger, status, deadline });
45
+ }
46
+ return rows;
47
+ }
48
+
49
+ // ruleNamesOf(criterion):从「触发统计口径」列抽「」内规则名(/ 分隔)。无「」= 无法自动映射。
50
+ export function ruleNamesOf(criterion) {
51
+ const names = [];
52
+ for (const m of String(criterion).matchAll(/「([^」]+)」/g)) {
53
+ for (const n of m[1].split('/')) {
54
+ const t = n.trim();
55
+ if (t) names.push(t);
56
+ }
57
+ }
58
+ return names;
59
+ }
60
+
61
+ // zeroMonthsOf(threshold):阈值形态「连续 N 月 = 0」→ N;否则 null(非自动可判)。
62
+ export function zeroMonthsOf(threshold) {
63
+ const m = String(threshold).match(/连续\s*(\d+)\s*月\s*=\s*0/);
64
+ return m ? Number(m[1]) : null;
65
+ }
66
+
67
+ // localDay(d):本地日历日 YYYY-MM-DD。期限比较不用 UTC 零点,避免北京时 08:00 后把当天判逾期。
68
+ export function localDay(d) {
69
+ const dt = d instanceof Date ? d : new Date(d);
70
+ const m = String(dt.getMonth() + 1).padStart(2, '0');
71
+ const day = String(dt.getDate()).padStart(2, '0');
72
+ return `${dt.getFullYear()}-${m}-${day}`;
73
+ }
74
+
75
+ // calendarWindow(endYm, n):以有数据的末月为终点,向前取 n 个日历月(含终点)。
76
+ export function calendarWindow(endYm, n) {
77
+ const [y, m] = String(endYm).split('-').map(Number);
78
+ const out = [];
79
+ for (let i = n - 1; i >= 0; i--) {
80
+ const d = new Date(Date.UTC(y, m - 1 - i, 1));
81
+ out.push(`${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ // evaluate(registryRows, judgmentRows, now):纯函数——产动作项。测试进程内断言。
87
+ export function evaluate(registryRows, judgmentRows, now = new Date()) {
88
+ const { byMonth } = aggFromRows(judgmentRows || [], null);
89
+ const months = Object.keys(byMonth).sort(); // 升序
90
+ const actions = [];
91
+ const notAuto = [];
92
+
93
+ for (const r of registryRows) {
94
+ if (r.status === '已退役') continue;
95
+
96
+ if (r.status === '待退役') {
97
+ const d = String(r.deadline).match(/\d{4}-\d{2}-\d{2}/);
98
+ if (d && localDay(now) > d[0]) {
99
+ actions.push({ kind: 'overdue', name: r.name, detail: `待退役处置期限 ${d[0]} 已过(今 ${localDay(now)})——按台账维护纪律「退役动作」当日删代码+测试+更新本表` });
100
+ } else if (!d) {
101
+ actions.push({ kind: 'no-deadline', name: r.name, detail: `待退役但处置期限非日期(${r.deadline || '空'})——补时间表或改回现役` });
102
+ }
103
+ continue;
104
+ }
105
+
106
+ // 现役
107
+ const n = zeroMonthsOf(r.threshold);
108
+ const rules = ruleNamesOf(r.criterion);
109
+ if (n === null) { notAuto.push({ name: r.name, reason: '阈值形态非「连续 N 月 = 0」' }); continue; }
110
+ if (!rules.length) { notAuto.push({ name: r.name, reason: '口径列未列规则名(无法映射 judgments)' }); continue; }
111
+ if (months.length < n) { notAuto.push({ name: r.name, reason: `judgments 仅 ${months.length} 月 < 阈值 ${n} 月(数据不足)` }); continue; }
112
+
113
+ // 日历连续:缺月不算零命中(漏跑数月后陈旧月份不得凑成「连续」)。
114
+ const window = calendarWindow(months[months.length - 1], n);
115
+ const missing = window.filter((ym) => !byMonth[ym]);
116
+ if (missing.length) {
117
+ notAuto.push({ name: r.name, reason: `日历窗口 ${window.join(' / ')} 缺月 ${missing.join(' / ')}(断档不算零命中)` });
118
+ continue;
119
+ }
120
+ const hits = window.map((ym) => {
121
+ const cell = byMonth[ym].rules;
122
+ let c = 0;
123
+ for (const rule of rules) {
124
+ const e = cell[rule];
125
+ if (e) c += (e.WARN || 0) + (e.BLOCK || 0);
126
+ }
127
+ return { ym, count: c };
128
+ });
129
+ const total = hits.reduce((s, h) => s + h.count, 0);
130
+ if (total === 0) {
131
+ actions.push({ kind: 'threshold', name: r.name, detail: `连续 ${n} 月零触发(${window.join(' / ')};规则 ${rules.join('、')})——阈值触达,评估退役(改状态「待退役」并填处置期限)` });
132
+ }
133
+ }
134
+ return { actions, notAuto, months, monthCount: months.length };
135
+ }
136
+
137
+ function readJudgments(root) {
138
+ const p = path.join(root, LEDGER_REL);
139
+ if (!fs.existsSync(p)) return { rows: [], missing: true };
140
+ const rows = [];
141
+ for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) {
142
+ if (!line.trim()) continue;
143
+ try { rows.push(JSON.parse(line)); } catch { /* 坏行跳过,不中断 */ }
144
+ }
145
+ return { rows, missing: false };
146
+ }
147
+
148
+ function main() {
149
+ const regPath = path.join(ROOT, REGISTRY_REL);
150
+ if (!fs.existsSync(regPath)) {
151
+ console.error(`[ERROR] mechanism-retire-watch: 台账不存在 ${REGISTRY_REL}`);
152
+ if (asJson) console.log(JSON.stringify({ error: 'registry-missing', actions: [] }, null, 2));
153
+ process.exit(0);
154
+ }
155
+ const registryRows = parseRegistry(fs.readFileSync(regPath, 'utf8'));
156
+ const { rows: judgmentRows, missing } = readJudgments(ROOT);
157
+ const { actions, notAuto, monthCount } = evaluate(registryRows, judgmentRows, NOW);
158
+
159
+ if (asJson) {
160
+ console.log(JSON.stringify({ registryRows: registryRows.length, judgmentMonths: monthCount, judgmentsMissing: missing, actions, notAuto }, null, 2));
161
+ return;
162
+ }
163
+
164
+ console.log(`# 机制退役催办(${NOW.toISOString()})`);
165
+ console.log('');
166
+ console.log(`台账 ${REGISTRY_REL}|机制行 ${registryRows.length}|judgments 月数 ${monthCount}${missing ? '(台账缺失——尚无运行记录)' : ''}`);
167
+ console.log('');
168
+ if (!actions.length) {
169
+ console.log('✅ 无阈值触达 / 无逾期——本轮无动作项。');
170
+ } else {
171
+ console.log(`## 动作项 ${actions.length} 条`);
172
+ for (const a of actions) console.log(`- [${a.kind}] ${a.name}:${a.detail}`);
173
+ }
174
+ if (notAuto.length) {
175
+ console.log('');
176
+ console.log(`## 非自动可判(${notAuto.length} 条,交人工月判)`);
177
+ for (const x of notAuto) console.log(`- ${x.name}:${x.reason}`);
178
+ }
179
+ console.log('');
180
+ console.log('> 处置仍人放行:改状态/删代码走人 + 一进一出(台账维护纪律)。本报告不自动改文件。');
181
+ }
182
+
183
+ if (import.meta.url === pathToFileURL(process.argv[1] || '').href) main();
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // mechanism-retire-watch.test.mjs — 1b 机制退役催办纯函数套件(2026-10-14 L5 自举接线 1b)
3
+ // parseRegistry / ruleNamesOf / zeroMonthsOf / evaluate 进程内断言。
4
+ import { parseRegistry, ruleNamesOf, zeroMonthsOf, evaluate } from './mechanism-retire-watch.mjs';
5
+
6
+ let pass = 0;
7
+ let fail = 0;
8
+ const check = (desc, cond, detail = '') => {
9
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
10
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
11
+ };
12
+
13
+ // ---- S1 parseRegistry ----
14
+ {
15
+ const text = [
16
+ '| 机制 | 门禁位置 | 触发统计口径 | 退役阈值 | 最近触发 | 状态 | 处置期限 |',
17
+ '|---|---|---|---|---|---|---|',
18
+ '| 甲门 | check-loop §1 | WARN「甲规则」计数(月) | 连续 6 月 = 0 → 评估退役 | 2026-10 在用 | 现役 | — |',
19
+ '| 乙门 | check-loop §2 | hard 计数 | 不退役(主干) | 2026-10 | 现役 | — |',
20
+ '| 丙门 | check-loop §3 | WARN 计数 | 连续 6 月 = 0 | 2026-10 | 待退役 | 2026-01-01 |',
21
+ '| 坏行 | 少列 |',
22
+ ].join('\n');
23
+ const rows = parseRegistry(text);
24
+ check('S1 只取 7 列数据行(表头/分隔/坏行剔除)', rows.length === 3, String(rows.length));
25
+ check('S1 列序正确', rows[0].name === '甲门' && rows[0].status === '现役' && rows[0].deadline === '—', JSON.stringify(rows[0]));
26
+ check('S1 待退役行期限解析', rows[2].status === '待退役' && rows[2].deadline === '2026-01-01');
27
+ }
28
+
29
+ // ---- S2 ruleNamesOf ----
30
+ check('S2 单规则名', ruleNamesOf('WARN「模板未填」计数(月)').join(',') === '模板未填');
31
+ check('S2 斜杠分隔多规则', ruleNamesOf('WARN「预判类型不符/预判级别不符/预判字段不可解析」计数').join(',') === '预判类型不符,预判级别不符,预判字段不可解析');
32
+ check('S2 无「」→ 空', ruleNamesOf('hard/WARN 计数(月)').length === 0);
33
+
34
+ // ---- S3 zeroMonthsOf ----
35
+ check('S3 连续 6 月 = 0', zeroMonthsOf('连续 6 月 = 0 → 评估退役') === 6);
36
+ check('S3 带前缀仍解析', zeroMonthsOf('WARN 命中连续 6 月 = 0 且新增预判字段停滞 → 评估退役') === 6);
37
+ check('S3 非零阈值形态 → null', zeroMonthsOf('不退役(防伪主干)') === null);
38
+ check('S3 使用率型 → null', zeroMonthsOf('<10% 且无新增 → 退役双协议') === null);
39
+
40
+ // ---- S4 evaluate:阈值触达 ----
41
+ {
42
+ const reg = [{ name: '甲门', criterion: 'WARN「甲规则」计数', threshold: '连续 2 月 = 0 → 评估退役', status: '现役', deadline: '—' }];
43
+ const j = [
44
+ { ts: '2026-08-01T00:00:00Z', rules: [{ rule: '甲规则', level: 'WARN', count: 0 }] },
45
+ { ts: '2026-09-01T00:00:00Z', rules: [] },
46
+ ];
47
+ const r = evaluate(reg, j, new Date('2026-10-14'));
48
+ check('S4 连续 2 月零触发 → 动作项', r.actions.length === 1 && r.actions[0].kind === 'threshold', JSON.stringify(r.actions));
49
+ check('S4 动作项含窗口与规则名', /2026-08 \/ 2026-09/.test(r.actions[0].detail) && /甲规则/.test(r.actions[0].detail), r.actions[0].detail);
50
+ }
51
+
52
+ // ---- S5 evaluate:窗口内有触发 → 不产动作项 ----
53
+ {
54
+ const reg = [{ name: '甲门', criterion: 'WARN「甲规则」计数', threshold: '连续 2 月 = 0', status: '现役', deadline: '—' }];
55
+ const j = [
56
+ { ts: '2026-08-01T00:00:00Z', rules: [{ rule: '甲规则', level: 'WARN', count: 1 }] },
57
+ { ts: '2026-09-01T00:00:00Z', rules: [] },
58
+ ];
59
+ const r = evaluate(reg, j, new Date('2026-10-14'));
60
+ check('S5 窗口内命中 → 无动作项', r.actions.length === 0, JSON.stringify(r.actions));
61
+ }
62
+
63
+ // ---- S6 evaluate:数据不足 / 无规则名 → notAuto ----
64
+ {
65
+ const reg = [
66
+ { name: '甲门', criterion: 'WARN「甲规则」计数', threshold: '连续 6 月 = 0', status: '现役', deadline: '—' },
67
+ { name: '乙门', criterion: 'hard 计数', threshold: '连续 6 月 = 0', status: '现役', deadline: '—' },
68
+ { name: '丙门', criterion: 'WARN「丙规则」计数', threshold: '不退役(主干)', status: '现役', deadline: '—' },
69
+ ];
70
+ const j = [{ ts: '2026-09-01T00:00:00Z', rules: [] }];
71
+ const r = evaluate(reg, j, new Date('2026-10-14'));
72
+ check('S6 数据不足 → notAuto', r.notAuto.some((x) => x.name === '甲门' && /数据不足/.test(x.reason)), JSON.stringify(r.notAuto));
73
+ check('S6 无规则名 → notAuto', r.notAuto.some((x) => x.name === '乙门' && /未列规则名/.test(x.reason)));
74
+ check('S6 非零阈值形态 → notAuto', r.notAuto.some((x) => x.name === '丙门' && /非「连续/.test(x.reason)));
75
+ check('S6 无动作项', r.actions.length === 0);
76
+ }
77
+
78
+ // ---- S7 evaluate:待退役逾期 / 缺期限 ----
79
+ {
80
+ const reg = [
81
+ { name: '甲门', criterion: '', threshold: '', status: '待退役', deadline: '2026-01-01' },
82
+ { name: '乙门', criterion: '', threshold: '', status: '待退役', deadline: '下批次' },
83
+ { name: '丙门', criterion: '', threshold: '', status: '已退役', deadline: '—' },
84
+ ];
85
+ const r = evaluate(reg, [], new Date('2026-10-14'));
86
+ check('S7 逾期 → overdue', r.actions.some((a) => a.kind === 'overdue' && a.name === '甲门'), JSON.stringify(r.actions));
87
+ check('S7 非日期期限 → no-deadline', r.actions.some((a) => a.kind === 'no-deadline' && a.name === '乙门'));
88
+ check('S7 已退役行跳过', !r.actions.some((a) => a.name === '丙门'));
89
+ }
90
+ // ---- S7b:期限当天不逾期(本地日,不当天 08:00 后提前催)----
91
+ {
92
+ const reg = [{ name: '甲门', criterion: '', threshold: '', status: '待退役', deadline: '2026-10-14' }];
93
+ const now = new Date(2026, 9, 14, 9, 0, 0);
94
+ const r = evaluate(reg, [], now);
95
+ check('S7b 期限当天本地 09:00 不逾期', r.actions.length === 0, JSON.stringify(r.actions));
96
+ }
97
+ // ---- S4b:有数据月不连续 → 缺月不算零命中 ----
98
+ {
99
+ const reg = [{ name: '甲门', criterion: 'WARN「甲规则」计数', threshold: '连续 2 月 = 0 → 评估退役', status: '现役', deadline: '—' }];
100
+ const j = [
101
+ { ts: '2026-08-01T00:00:00Z', rules: [] },
102
+ { ts: '2026-10-01T00:00:00Z', rules: [] },
103
+ ];
104
+ const r = evaluate(reg, j, new Date('2026-10-14T00:00:00Z'));
105
+ check('S4b 8 月与 10 月之间缺 9 月 → 不产退役动作', r.actions.length === 0, JSON.stringify(r.actions));
106
+ check('S4b 缺月记入 notAuto', r.notAuto.some((x) => x.name === '甲门' && /缺月/.test(x.reason) && /2026-09/.test(x.reason)), JSON.stringify(r.notAuto));
107
+ }
108
+
109
+ // ---- S8 空入参 ----
110
+ check('S8 空台账 → 无动作项', evaluate([], [], new Date()).actions.length === 0);
111
+ check('S8 null judgments 不 throw', evaluate([], null, new Date()).actions.length === 0);
112
+
113
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
114
+ process.exit(fail ? 1 : 0);
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // pre-push.test.mjs — 加固门目标分支判定 fixture 套件(2026-10-01 v09-review-defects)
3
- // 真仓端到端:临时仓库挂载真实 pre-push,.agents/scripts/check-loop.sh 以桩脚本记录 argv。
2
+ // pre-push.test.mjs — 加固门目标分支判定 + 构建门 fixture 套件
3
+ // (2026-10-01 v09-review-defects 建;2026-10-16 随 pre-push node 化更新:桩改 .agents/scripts/check-loop.mjs
4
+ // 直调,补 commit-check 构建门桩与 --range 断言——自源仓 2ff869a9 回灌面)
5
+ // 真仓端到端:临时仓库挂载真实 pre-push,check-loop.mjs 与 commit-check.cjs 以桩记录 argv。
4
6
  // 断言三态 refspec:
5
- // main→main → argv 含 --hardening(加固门)
6
- // experiment/x→main → argv 含 --hardening(修复回归:直推漏口)
7
- // experiment/y→experiment/y → argv 不含 --hardening(advisory)
7
+ // main→main → argv 含 --hardening(加固门);构建门 --range 新分支首推=全量零 sha
8
+ // experiment/x→main → argv 含 --hardening(修复回归:直推漏口);构建门 old 为远端实 sha
9
+ // experiment/y→experiment/y → argv 不含 --hardening(advisory);构建门仍按范围跑(advisory 不阻断)
8
10
  import fs from 'node:fs';
9
11
  import os from 'node:os';
10
12
  import path from 'node:path';
@@ -34,11 +36,15 @@ const setup = () => {
34
36
  const remote = path.join(root, 'remote.git');
35
37
  fs.mkdirSync(path.join(root, '.githooks'), { recursive: true });
36
38
  fs.mkdirSync(path.join(root, '.agents', 'scripts'), { recursive: true });
37
- // 挂载真实 pre-push(装户同款),stub 替换 check-loop 记录 argv
39
+ fs.mkdirSync(path.join(root, '.agents', 'hooks'), { recursive: true });
40
+ // 挂载真实 pre-push(装户同款),stub 替换 check-loop 与 commit-check 记录 argv
38
41
  fs.copyFileSync(PRE_PUSH, path.join(root, '.githooks', 'pre-push'));
39
- // Linux:git 静默跳过不可执行钩子(推送照常成功、桩无记录)——挂载后必须设执行位;桩经 sh 调用无须 +x
42
+ // Linux:git 静默跳过不可执行钩子(推送照常成功、桩无记录)——挂载后必须设执行位;桩经 node 调用无须 +x
40
43
  fs.chmodSync(path.join(root, '.githooks', 'pre-push'), 0o755);
41
- fs.writeFileSync(path.join(root, '.agents', 'scripts', 'check-loop.sh'), '#!/bin/sh\necho "$@" >> .stub-args\nexit 0\n');
44
+ fs.writeFileSync(path.join(root, '.agents', 'scripts', 'check-loop.mjs'),
45
+ 'import fs from "node:fs";\nfs.appendFileSync(".stub-args", process.argv.slice(2).join(" ") + "\\n");\n');
46
+ fs.writeFileSync(path.join(root, '.agents', 'hooks', 'commit-check.cjs'),
47
+ 'const fs = require("node:fs");\nfs.appendFileSync(".stub-args-cc", process.argv.slice(2).join(" ") + "\\n");\n');
42
48
  fs.writeFileSync(path.join(root, 'a.txt'), 'a\n');
43
49
  git(root, ['init', '-q']);
44
50
  git(root, ['config', 'user.email', 't@t']);
@@ -54,15 +60,21 @@ const stubArgs = (root) => {
54
60
  const p = path.join(root, '.stub-args');
55
61
  return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').split(/\n/).filter(Boolean) : [];
56
62
  };
63
+ const ccArgs = (root) => {
64
+ const p = path.join(root, '.stub-args-cc');
65
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf8').split(/\n/).filter(Boolean) : [];
66
+ };
57
67
 
58
68
  const root = setup();
59
69
  const w = (f, c) => fs.writeFileSync(path.join(root, f), c);
60
70
 
61
- // 1) main → main:应含 --hardening
71
+ // 1) main → main:应含 --hardening;构建门首推 = 全量零 sha
62
72
  git(root, ['checkout', '-q', '-b', 'main']);
63
73
  let r = run(root, ['push', 'origin', 'HEAD:main']);
64
74
  check('main→main 推送成功', r.status === 0, `${r.stdout}${r.stderr}`);
65
75
  check('main→main argv 含 --hardening', (stubArgs(root)[0] || '').includes('--hardening'), stubArgs(root).join('|'));
76
+ check('main→main 构建门 --range 新分支首推=全量零 sha(2026-10-16 构建门后移)',
77
+ (ccArgs(root)[0] || '').startsWith('--range 0000000000000000000000000000000000000000 '), ccArgs(root).join('|'));
66
78
 
67
79
  // 2) experiment/x → main(回归漏口):本地分支在 experiment,目标 main → 必须含 --hardening
68
80
  git(root, ['checkout', '-q', '-b', 'experiment/x']);
@@ -72,8 +84,10 @@ git(root, ['commit', '-q', '-m', 'x']);
72
84
  r = run(root, ['push', 'origin', 'HEAD:main']);
73
85
  check('experiment/x→main 推送成功', r.status === 0, `${r.stdout}${r.stderr}`);
74
86
  check('experiment/x→main argv 含 --hardening(直推漏口修复)', (stubArgs(root)[1] || '').includes('--hardening'), stubArgs(root).join('|'));
87
+ check('experiment/x→main 构建门 --range old 为远端实 sha(非零)',
88
+ /^--range [0-9a-f]{40} /.test(ccArgs(root)[1] || ''), ccArgs(root).join('|'));
75
89
 
76
- // 3) experiment/y → experiment/y:目标在探索泳道 → 不含 --hardening
90
+ // 3) experiment/y → experiment/y:目标在探索泳道 → 不含 --hardening;构建门仍按范围跑(advisory)
77
91
  git(root, ['fetch', '-q', 'origin']);
78
92
  git(root, ['branch', 'experiment/y', 'origin/main']);
79
93
  git(root, ['checkout', '-q', 'experiment/y']);
@@ -84,6 +98,8 @@ r = run(root, ['push', 'origin', 'HEAD:experiment/y']);
84
98
  check('experiment→experiment 推送成功', r.status === 0, `${r.stdout}${r.stderr}`);
85
99
  const line3 = stubArgs(root)[2] || '';
86
100
  check('experiment→experiment argv 不含 --hardening', !line3.includes('--hardening'), stubArgs(root).join('|'));
101
+ check('experiment→experiment 构建门仍按范围跑(advisory 不阻断)',
102
+ /^--range [0-9a-f]{40} /.test(ccArgs(root)[2] || ''), ccArgs(root).join('|'));
87
103
 
88
104
  console.log(`\n${fail ? `❌ ${fail} failed` : '✅ all passed'}(${pass} passed)`);
89
105
  process.exit(fail ? 1 : 0);
@@ -8,6 +8,10 @@
8
8
  // 「提交后树」= 索引(index)内容:staged 版优先于 HEAD;无 git(fixture)回退工作区文件。
9
9
  // 字节口径 = 索引 / HEAD 内容(git cat-file,LF 归一)。禁用工作区 wc -c 口径(CRLF 膨胀系统性偏大,
10
10
  // 2026-09-21 实测差 ~500B 一度误判超限)——本脚本所有字节数都走 cat-file,仅无 git 时才落工作区。
11
+ // 条目数预算(2026-10-15 减重手段② 自源仓回灌):上限字段形如 `<N>#count` 时按「匹配文件条数」执法
12
+ // 而非字节——规则面一进一出(新增一条须先退役一条,退役判定 agg-judgments --retire-candidates)。
13
+ // 旧格式行(纯数字)不受影响;`#count` 行对旧读者(未升级的脚本副本)因上限非纯数字被跳过——
14
+ // fail-open,不误拦。
11
15
  import { spawnSync } from 'node:child_process';
12
16
  import fs from 'node:fs';
13
17
  import path from 'node:path';
@@ -60,13 +64,22 @@ const expand = (pat) => {
60
64
 
61
65
  let viol = 0;
62
66
  const over = (what, bytes, lim) => { console.log(`常驻面超限:${what} ${bytes}B > 上限 ${lim}B`); viol = 1; };
67
+ const overCount = (pat, n, lim) => { console.log(`常驻面超限(条目数):${pat} ${n} 项 > 上限 ${lim} 项——一进一出:新增须先退役一条(退役判定 agg-judgments --retire-candidates)`); viol = 1; };
63
68
 
64
69
  for (const line of fs.readFileSync(BUDGETS, 'utf8').split(/\r?\n/)) {
65
70
  const sp = line.lastIndexOf(' ');
66
71
  if (sp < 0) continue;
67
72
  const pat = line.slice(0, sp), lim = line.slice(sp + 1);
68
73
  if (pat === '' || pat.startsWith('#')) continue;
69
- if (!/^\d+$/.test(lim)) continue;
74
+ // 条目数预算(2026-10-15 减重手段②):`<N>#count` = 匹配文件数上限(一进一出的执法面)
75
+ const cnt = /^(\d+)#count$/.exec(lim);
76
+ if (!cnt && !/^\d+$/.test(lim)) continue;
77
+ if (cnt) {
78
+ if (mode === '--staged' && !staged.some((sf) => sf.startsWith(pat))) continue;
79
+ const n = expand(pat).length;
80
+ if (n > Number(cnt[1])) overCount(pat, n, cnt[1]);
81
+ continue;
82
+ }
70
83
  if (pat.endsWith('/')) {
71
84
  // 目录合计:--staged 下仅当暂存触及该目录内文件才查(字面前缀匹配,同 sh 版 2026-09-24 口径)
72
85
  if (mode === '--staged' && !staged.some((sf) => sf.startsWith(pat))) continue;