agentic-flow-kit 1.2.2 → 1.4.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 (36) hide show
  1. package/README.md +10 -4
  2. package/modules/hosts/claude/commands/wf-test.md +1 -1
  3. package/modules/hosts/codex/commands/wf-test.md +1 -1
  4. package/modules/hosts/cursor/commands/wf-test.md +1 -1
  5. package/modules/hosts/opencode/commands/wf-test.md +1 -1
  6. package/modules/hosts/trae/commands/wf-test.md +1 -1
  7. package/package.json +1 -1
  8. package/src/fresh-init.test.mjs +10 -0
  9. package/src/init.mjs +1 -1
  10. package/src/profiles.mjs +12 -6
  11. package/src/sync.mjs +30 -26
  12. package/src/sync.test.mjs +82 -0
  13. package/templates/AGENTS.md +1 -0
  14. package/templates/_agents/commands/test.md +1 -1
  15. package/templates/_agents/rule-budgets.txt +3 -1
  16. package/templates/_agents/scripts/agg-delegations.cjs +56 -17
  17. package/templates/_agents/scripts/agg-delegations.test.mjs +76 -1
  18. package/templates/_agents/scripts/agg-gate-stats.mjs +113 -0
  19. package/templates/_agents/scripts/agg-gate-stats.test.mjs +95 -0
  20. package/templates/_agents/scripts/check-hygiene.mjs +167 -0
  21. package/templates/_agents/scripts/check-hygiene.test.mjs +182 -0
  22. package/templates/_agents/scripts/check-ledger.mjs +60 -16
  23. package/templates/_agents/scripts/check-ledger.test.mjs +70 -0
  24. package/templates/_agents/scripts/check-loop-unit.test.mjs +95 -0
  25. package/templates/_agents/scripts/check-loop.mjs +1119 -1097
  26. package/templates/_agents/scripts/check-loop.test.mjs +200 -1
  27. package/templates/_agents/scripts/confirm-doc.mjs +27 -6
  28. package/templates/_agents/scripts/confirm-doc.test.mjs +35 -0
  29. package/templates/_agents/scripts/gate-seg.mjs +54 -0
  30. package/templates/_agents/scripts/gate-seg.test.mjs +112 -0
  31. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +249 -0
  32. package/templates/_agents/scripts/gen-workflow-dashboard.test.mjs +189 -0
  33. package/templates/_agents/scripts/policy.mjs +31 -3
  34. package/templates/_agents/scripts/verify.mjs +27 -2
  35. package/templates/_agents/scripts/verify.test.mjs +53 -0
  36. package/templates/_github/workflows/kit-ci.yml +28 -0
@@ -0,0 +1,182 @@
1
+ // check-hygiene.test.mjs — 卫生类检查模块测试(2026-10-08 selfmeasure-and-modularize,L2)
2
+ // 被测:templates/_agents/scripts/check-hygiene.mjs 的 runCheckHygiene(检查 2 / 9 / 11 / 12 / 13)。
3
+ // 端到端行为回归网仍是 check-loop.test.mjs(219 断言,spawn 真 check-loop)——本套件补的是**模块层直测**:
4
+ // ctx 缺项降级、装户跳过分支、生成物双告警、以及「模块内顺序 = 原 2→9→11→12→13」这条输出契约。
5
+ // fixture:mkdtemp 临时根(workflow/{intents,specs,plans,incidents} + .agents/),**不触真实仓库 workflow/**。
6
+ // 用法:node templates/_agents/scripts/check-hygiene.test.mjs
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+
13
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
14
+ const { runCheckHygiene } = await import(pathToFileURL(path.join(SCRIPT_DIR, 'check-hygiene.mjs')).href);
15
+
16
+ let pass = 0, fail = 0;
17
+ const check = (name, cond, detail = '') => {
18
+ if (cond) { pass++; console.log(`PASS ${name}`); }
19
+ else { fail++; console.log(`FAIL ${name}${detail ? `——${detail}` : ''}`); }
20
+ };
21
+
22
+ // ctx 组装:复刻 check-loop.mjs 的 helper 口径(tracked 恒真 = fixture 模式、frontmatter 受限子集),
23
+ // 以免测试用自己的一套语义掩盖模块与主文件的漂移。
24
+ function makeCtx(root, overrides = {}) {
25
+ const WF = 'workflow';
26
+ const DOC_DIRS = ['intents', 'specs', 'plans', 'incidents'];
27
+ const fileLinesCache = new Map();
28
+ const linesOf = (file) => {
29
+ if (!fileLinesCache.has(file)) {
30
+ let lines = null;
31
+ try { lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); } catch { lines = null; }
32
+ fileLinesCache.set(file, lines);
33
+ }
34
+ return fileLinesCache.get(file);
35
+ };
36
+ const fmGet = (file, key) => {
37
+ const lines = linesOf(file);
38
+ if (!lines) return '';
39
+ const isDelim = (l) => /^---\s*$/.test(l);
40
+ if (!isDelim(lines[0] || '')) return '';
41
+ for (let i = 1; i < lines.length; i++) {
42
+ if (isDelim(lines[i])) return '';
43
+ if (lines[i].startsWith(key + ':')) return lines[i].slice(key.length + 1).trim();
44
+ }
45
+ return '';
46
+ };
47
+ const isTracked = () => true; // fixture 模式:check-loop 中 CHECK_LOOP_ROOT 注入时恒真
48
+ const docFiles = (sub) => {
49
+ const dir = path.join(root, WF, sub);
50
+ if (!fs.existsSync(dir)) return [];
51
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.md') && /^\d/.test(f))
52
+ .map((f) => path.join(dir, f)).filter(isTracked).sort();
53
+ };
54
+ const readdirOrNull = (dir) => { try { return fs.readdirSync(dir); } catch { return null; } };
55
+ const kitPolicy = { moduleSince: '2026-09-22', ...(overrides.kitPolicy || {}) };
56
+ return { root, WF, DOC_DIRS, docFiles, linesOf, fmGet, isTracked, readdirOrNull, kitPolicy, ...overrides };
57
+ }
58
+
59
+ const W = (root, rel, content) => {
60
+ fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true });
61
+ fs.writeFileSync(path.join(root, rel), content, 'utf8');
62
+ };
63
+ const DOC = (fm, body = '# T\n') => `---\n${fm}\n---\n${body}`;
64
+
65
+ // ---- 检查 2:占位符残留 ----
66
+ {
67
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg2-'));
68
+ W(root, 'workflow/intents/2026-10-01-ph.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline', '# I\n日期: YYYY-MM-DD\n'));
69
+ W(root, 'workflow/intents/2026-10-01-ok.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline', '# I\n没有占位符\n'));
70
+ W(root, 'workflow/intents/2026-10-01-fence.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline', '# I\n```\nYYYY-MM-DD\n```\n'));
71
+ const r = runCheckHygiene(makeCtx(root));
72
+ const ph = r.filter((w) => w.includes('[WARN 模板未填]'));
73
+ check('检查 2:裸占位符出账 + 围栏代码块内豁免(仅 1 件)',
74
+ ph.length === 1 && ph[0].includes('2026-10-01-ph.md'), JSON.stringify(r));
75
+ fs.rmSync(root, { recursive: true, force: true });
76
+ }
77
+
78
+ // ---- 检查 9:文件名 kebab(检查 9 走 readdirOrNull,不走 docFiles 的 /^\d/ 过滤)----
79
+ {
80
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg9-'));
81
+ W(root, 'workflow/intents/2026-10-01-中文名.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline'));
82
+ const r = runCheckHygiene(makeCtx(root));
83
+ check('检查 9:非 ASCII 文件名出账', r.some((w) => w.includes('[WARN 文件名非英文]') && w.includes('中文名.md')), JSON.stringify(r));
84
+ fs.rmSync(root, { recursive: true, force: true });
85
+ }
86
+
87
+ // ---- 检查 11:双生成物 + 装户跳过 ----
88
+ {
89
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg11-'));
90
+ // 装户形态:无生成器脚本 → 检查 11 整体跳过(不报)
91
+ let r = runCheckHygiene(makeCtx(root));
92
+ check('检查 11:生成器脚本不存在(旧装副本)→ 跳过不报',
93
+ !r.some((w) => w.includes('漂移]')), JSON.stringify(r));
94
+ // 放一个恒红的假生成器(--check 必 exit 1),覆盖双告警
95
+ W(root, '.agents/scripts/gen-workflow-index.mjs', 'process.exit(1);\n');
96
+ W(root, '.agents/scripts/gen-workflow-dashboard.mjs', 'process.exit(1);\n');
97
+ W(root, 'workflow/INDEX.md', '# stale\n');
98
+ W(root, 'workflow/DASHBOARD.md', '# stale\n'); // 双告警场景须盘面在位——否则命中装户跳过分支
99
+ r = runCheckHygiene(makeCtx(root));
100
+ check('检查 11:INDEX + DASHBOARD 各自独立出账且指名修复命令',
101
+ r.some((w) => w.includes('[WARN 索引漂移]') && w.includes('gen-workflow-index.mjs'))
102
+ && r.some((w) => w.includes('[WARN 仪表盘漂移]') && w.includes('gen-workflow-dashboard.mjs')), JSON.stringify(r));
103
+ // DASHBOARD.md 不存在 → 只剩 INDEX 一条(装户未生成过看板不得常红)
104
+ fs.rmSync(path.join(root, 'workflow/DASHBOARD.md'), { force: true });
105
+ r = runCheckHygiene(makeCtx(root));
106
+ check('检查 11:DASHBOARD.md 缺失 → 跳过(装户未生成过看板不常红)',
107
+ !r.some((w) => w.includes('仪表盘漂移]')) && r.some((w) => w.includes('索引漂移]')), JSON.stringify(r));
108
+ fs.rmSync(root, { recursive: true, force: true });
109
+ }
110
+
111
+ // ---- 检查 12:模块枚举 + 词表缺失降级 ----
112
+ {
113
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg12-'));
114
+ W(root, '.agents/workflow-modules.txt', '# 词表\npipeline\nwiki\n');
115
+ W(root, 'workflow/intents/2026-10-01-bad.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: 不存在的模块'));
116
+ W(root, 'workflow/intents/2026-10-01-missing.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01'));
117
+ W(root, 'workflow/intents/2026-09-01-old.md', DOC('状态: done\n级别: L1\n日期: 2026-09-01')); // 早于 moduleSince → 存量豁免
118
+ let r = runCheckHygiene(makeCtx(root));
119
+ check('检查 12:非法模块出账 + 新建缺字段出账 + 存量豁免',
120
+ r.some((w) => w.includes('不在词表')) && r.some((w) => w.includes('missing.md 缺「模块:」字段'))
121
+ && !r.some((w) => w.includes('old.md')), JSON.stringify(r));
122
+ fs.rmSync(path.join(root, '.agents/workflow-modules.txt'));
123
+ r = runCheckHygiene(makeCtx(root));
124
+ check('检查 12:词表文件缺失 → 静默跳过(装户无感)', !r.some((w) => w.includes('模块元数据')), JSON.stringify(r));
125
+ fs.rmSync(root, { recursive: true, force: true });
126
+ }
127
+
128
+ // ---- 输出顺序契约:块内须保持 2 → 9 → 11 → 12 → 13 ----
129
+ {
130
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hyg-order-'));
131
+ W(root, 'workflow/intents/2026-10-01-ph.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: 非法模块', '# I\nYYYY-MM-DD\n'));
132
+ W(root, 'workflow/intents/2026-10-01-中文名.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline'));
133
+ W(root, '.agents/scripts/gen-workflow-index.mjs', 'process.exit(1);\n');
134
+ W(root, '.agents/scripts/gen-workflow-dashboard.mjs', 'process.exit(1);\n');
135
+ W(root, '.agents/workflow-modules.txt', '# 词表\npipeline\n'); // 词表须在位,否则检查 12 整段跳过
136
+ W(root, 'workflow/INDEX.md', '# stale\n');
137
+ W(root, 'workflow/DASHBOARD.md', '# stale\n');
138
+ const r = runCheckHygiene(makeCtx(root));
139
+ const tags = r.map((w) => (w.includes('模板未填') ? '2' : w.includes('文件名非英文') ? '9'
140
+ : w.includes('索引漂移') ? '11a' : w.includes('仪表盘漂移') ? '11b' : w.includes('模块元数据') ? '12' : '13'));
141
+ check('输出顺序:块内 2 → 9 → 11 → 12 保持(行序属稳定输出契约)',
142
+ tags.join(',') === '2,9,11a,11b,12', tags.join(','));
143
+ fs.rmSync(root, { recursive: true, force: true });
144
+ }
145
+
146
+ // ---- 检查 20 覆盖:本模块须被 npm test 的 glob 发现 ----
147
+ {
148
+ const found = fs.existsSync(path.join(SCRIPT_DIR, 'check-hygiene.test.mjs'));
149
+ check('检查 20:本套件与被测模块同名兄弟(否则检查 20 出账)', found);
150
+ const r = spawnSync(process.execPath, ['--check', path.join(SCRIPT_DIR, 'check-hygiene.mjs')], { encoding: 'utf8' });
151
+ check('被测模块语法有效', r.status === 0, r.stderr || '');
152
+ }
153
+
154
+ // ---- 门禁 ROI 段归属(2026-10-08 gate-roi-metrics,实仓抓到的错账根因)----
155
+ // 症状:本模块的 warnings 是**局部数组**(check-loop 事后才 spread 进全局),若段增量记在全收集器上,
156
+ // 本模块 5 段恒记 0 命中、且全部增量被误记到模块之后的第一个段(检查 3)。修复 = 局部收集器 + appendSegs。
157
+ // 本组断言在**模块层**钉死归属:检查 2 与检查 9 各产 1 条,必须各归各的段,不得漂到别的段上。
158
+ {
159
+ const { makeCollector, finishSegs } = await import(pathToFileURL(path.join(SCRIPT_DIR, 'gate-seg.mjs')).href);
160
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-hygroi-'));
161
+ W(root, 'workflow/intents/2026-10-01-ph.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline', '# I\n日期: YYYY-MM-DD\n'));
162
+ W(root, 'workflow/intents/2026-10-01-中文名.md', DOC('状态: done\n级别: L1\n日期: 2026-10-01\n模块: pipeline'));
163
+ // 全局收集器挂在**空数组**上:若模块错误地用全局数组做 Δ 源,两条警告一条都不会被算进去。
164
+ const globalStats = makeCollector([], []);
165
+ const r = runCheckHygiene(makeCtx(root, { gateStats: globalStats }));
166
+ const segs = globalStats.segs;
167
+ const byId = new Map(segs.map((s) => [s.id, s.warns]));
168
+ const hits = r.length;
169
+ check('段归属:本模块 5 段全部入账(2/9/11/12/13)',
170
+ segs.map((s) => s.id).join(',') === '2,9,11,12,13', JSON.stringify(segs.map((s) => s.id)));
171
+ check('段归属:各段增量之和 == 本模块实际产出条数(无漏记无重复记)',
172
+ segs.reduce((a, s) => a + s.warns, 0) === hits && hits === 2, `segs=${JSON.stringify(segs)} hits=${hits}`);
173
+ check('段归属:检查 2 / 检查 9 各归 1 条(**不都堆到末段**——本次实抓的错账形态)',
174
+ byId.get('2') === 1 && byId.get('9') === 1 && byId.get('11') === 0 && byId.get('12') === 0 && byId.get('13') === 0,
175
+ JSON.stringify(segs));
176
+ check('段归属:并入后全局游标清空(check-loop 随后的 markGate 从零起算)', globalStats.current === null);
177
+ check('段归属:finishSegs(全局) 幂等,不产生额外伪段', finishSegs(globalStats).length === segs.length);
178
+ fs.rmSync(root, { recursive: true, force: true });
179
+ }
180
+
181
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
182
+ process.exit(fail ? 1 : 0);
@@ -4,49 +4,93 @@
4
4
  // 随常规提交静默落 main,三次真实发生(0272a1c 预入 / ff3c721 还原窗口 / 537df18 闭合),本脚本在
5
5
  // 提交时刻补齐第三边。sha 口径:LF 归一 utf8(同 doctor §4 / render.mjs shaText / source-sync-check
6
6
  // 读取侧,跨 checkout 稳定;装户自包含约束决定独立实现,三处口径改动须同批)。
7
+ // 入库态口径(2026-10-08 papercuts-cleanup-batch 修 5,独立复核 P1 修正取值链):快检原比「工作树」,
8
+ // 而分批提交时提交树(= HEAD + 暂存合成,删除同样被应用)里的 kit.json sha 可能指向「工作区已改、
9
+ // 未提交」内容 → clone/CI 跑 doctor 必红(e8445c5 实证 6 项不符)。无参运行自适应:**暂存区非空即
10
+ // 入库态**(不判「提交集含 kit.json」——diff --cached 只列与 HEAD 有差异的文件,kit.json 无变化暂存
11
+ // 时不出现,但其合成提交树同样可能预 landing:只 add 件不 add 台账是姊妹漏洞)。入库态取值:
12
+ // 台账清单 = 暂存版 kit.json(`git show :.agents/kit.json`);暂存无而 HEAD 有 = 退出性提交(kit.json
13
+ // 被暂存删除)放行;managed 内容 = `git show :rel` 暂存版,**暂存无即 gone**(提交树=index——
14
+ // 未暂存改动的文件 index 仍在,`:rel` 必命中;`:rel` 缺失唯一可达路径是暂存删除,HEAD 兜底会把它
15
+ // 误读为「未触碰」放行 = 提交门绕过,复核 P1 实证)。
16
+ // 暂存区空(amend 等罕见态)→ 维持工作树口径零变化。
7
17
  // 边界:仅包源环境由 .githooks/pre-commit 触发(装户 managed 手改走 doctor §4 WARN 口径,不硬拦);
8
18
  // managed 删除即拦——sync 对 removed 只报不删,删件须手动同步装副本并重跑 sync 刷台账。
9
- // 用法:node .agents/scripts/check-ledger.mjs(cwd = 仓库根;非零出口即阻断提交)
10
- // 单元测试:import { ledgerDrift } from './check-ledger.mjs'
19
+ // 用法:node .agents/scripts/check-ledger.mjs(cwd = 仓库根;非零出口即阻断提交;无参自适应两口径)
20
+ // 单元测试:import { ledgerDrift } from './check-ledger.mjs'({staged:true} 显式入库态)
11
21
  import fs from 'node:fs';
12
22
  import path from 'node:path';
13
23
  import { createHash } from 'node:crypto';
24
+ import { spawnSync } from 'node:child_process';
14
25
 
15
- export function ledgerDrift(target = process.cwd()) {
26
+ const gitText = (target, args) => {
27
+ const r = spawnSync('git', args, { cwd: target, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
28
+ return r.status === 0 ? r.stdout : null;
29
+ };
30
+
31
+ export function ledgerDrift(target = process.cwd(), { staged = false } = {}) {
16
32
  const kitPath = path.join(target, '.agents', 'kit.json');
17
33
  if (!fs.existsSync(kitPath)) return { skipped: true, note: '无 .agents/kit.json(非 flow-kit 安装)' };
18
- let kit;
19
- try {
20
- kit = JSON.parse(fs.readFileSync(kitPath, 'utf8'));
21
- } catch (e) {
22
- return { skipped: false, fatal: `kit.json 解析失败:${e.message}`, modified: [], gone: [], total: 0, version: '?' };
34
+ // staged 模式:台账清单取暂存版 kit.json(提交树=index 合成语义);暂存无而 HEAD 有 = 退出性提交
35
+ // (kit.json 被暂存删除)放行;两者皆无(新仓无 kit.json)同样放行
36
+ let kit = null;
37
+ if (staged) {
38
+ const stagedKit = gitText(target, ['show', ':.agents/kit.json']);
39
+ const headKit = stagedKit === null ? gitText(target, ['show', 'HEAD:.agents/kit.json']) : null;
40
+ if (stagedKit === null) {
41
+ return { skipped: true, note: headKit !== null ? '提交树无 .agents/kit.json(退出 flow-kit 的提交),跳过' : '提交树无 .agents/kit.json(新仓),跳过', mode: 'staged' };
42
+ }
43
+ try {
44
+ kit = JSON.parse(stagedKit);
45
+ } catch (e) {
46
+ return { skipped: false, fatal: `kit.json(暂存版)解析失败:${e.message}`, modified: [], gone: [], total: 0, version: '?', mode: 'staged' };
47
+ }
48
+ }
49
+ if (!kit) {
50
+ try {
51
+ kit = JSON.parse(fs.readFileSync(kitPath, 'utf8'));
52
+ } catch (e) {
53
+ return { skipped: false, fatal: `kit.json 解析失败:${e.message}`, modified: [], gone: [], total: 0, version: '?', mode: staged ? 'staged' : 'worktree' };
54
+ }
23
55
  }
24
56
  if (!Array.isArray(kit.managed)) {
25
57
  // fail-loud(独立复核 P2-3,2026-09-28):JSON 合法但 managed 缺失/非数组时静默「全对齐」= 假放,
26
58
  // 同 doctor §4 的 Array.isArray 显式判口径,台账结构坏了必须响
27
- return { skipped: false, fatal: 'kit.json managed 缺失或非数组', modified: [], gone: [], total: 0, version: kit.version };
59
+ return { skipped: false, fatal: 'kit.json managed 缺失或非数组', modified: [], gone: [], total: 0, version: kit.version, mode: staged ? 'staged' : 'worktree' };
28
60
  }
29
61
  const modified = [];
30
62
  const gone = [];
31
63
  const managed = kit.managed;
32
64
  for (const f of managed) {
33
- const p = path.join(target, f.rel);
34
- if (!fs.existsSync(p)) { gone.push(f.rel); continue; }
35
- const h = createHash('sha256').update(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8').digest('hex'); // LF 归一,同 doctor §4
65
+ let content = null;
66
+ if (staged) {
67
+ // 入库态:提交树 = index(暂存版);`:rel` 缺失唯一可达路径是暂存删除 → gone 拦(复核 P1:
68
+ // HEAD 兜底会把暂存删除误读为「未触碰」放行——提交树已无该件而台账仍跟踪,clone/CI 必红)
69
+ content = gitText(target, ['show', `:${f.rel}`]);
70
+ if (content === null) { gone.push(f.rel); continue; }
71
+ } else {
72
+ const p = path.join(target, f.rel);
73
+ if (!fs.existsSync(p)) { gone.push(f.rel); continue; }
74
+ content = fs.readFileSync(p, 'utf8');
75
+ }
76
+ const h = createHash('sha256').update(content.replace(/\r\n/g, '\n'), 'utf8').digest('hex'); // LF 归一,同 doctor §4
36
77
  if (h !== f.sha256) modified.push(f.rel);
37
78
  }
38
- return { skipped: false, fatal: '', modified, gone, total: managed.length, version: kit.version };
79
+ return { skipped: false, fatal: '', modified, gone, total: managed.length, version: kit.version, mode: staged ? 'staged' : 'worktree' };
39
80
  }
40
81
 
41
82
  const isMain = process.argv[1] && process.argv[1].endsWith('check-ledger.mjs');
42
83
  if (isMain) {
43
- const r = ledgerDrift();
84
+ // 无参自适应:暂存区非空(有提交发生)→ 入库态口径;暂存区空(amend 等罕见态/非 git 仓)→ 工作树口径
85
+ const cached = gitText(process.cwd(), ['diff', '--cached', '--name-only']) || '';
86
+ const staged = cached.split(/\r?\n/).some((l) => l.trim() !== '');
87
+ const r = ledgerDrift(process.cwd(), { staged });
44
88
  console.log('▶ flow-kit check-ledger');
45
89
  if (r.skipped) {
46
90
  console.log(' ' + r.note + ',跳过 ✅');
47
91
  process.exit(0);
48
92
  }
49
- console.log(' kit.json v' + r.version + ':managed ' + r.total + ' 份,台账 ↔ 盘面(工作树,LF 归一)');
93
+ console.log(' kit.json v' + r.version + ':managed ' + r.total + ' 份,台账 ↔ ' + (r.mode === 'staged' ? '提交树入库态(暂存版=index,暂存删除即拦,LF 归一)' : '盘面(工作树,LF 归一)'));
50
94
  if (r.fatal) console.error('❌ ' + r.fatal);
51
95
  if (r.modified.length) {
52
96
  console.error(' 台账漂移(盘面 ≠ 台账 sha)' + r.modified.length + ':');
@@ -57,7 +101,7 @@ if (isMain) {
57
101
  for (const rel of r.gone) console.error(' - ' + rel);
58
102
  }
59
103
  if (r.fatal || r.modified.length || r.gone.length) {
60
- console.error(' 提交被拦:kit.json 与盘面不一致(预 landing / 手改未随动)——跑 node bin/flow-kit.mjs sync 成对后原路重试');
104
+ console.error(' 提交被拦:kit.json 与' + (r.mode === 'staged' ? '提交树(暂存面)' : '盘面') + '不一致(预 landing / 手改未随动)——跑 node bin/flow-kit.mjs sync 成对后原路重试');
61
105
  process.exit(1);
62
106
  }
63
107
  console.log(' 全对齐 ✅');
@@ -123,5 +123,75 @@ const runCli = (cwd) => spawnSync(process.execPath, [SCRIPT], { encoding: 'utf8'
123
123
  }
124
124
  }
125
125
 
126
+ // ---- S10 入库态口径(2026-10-08 papercuts-cleanup-batch 修 5):git 仓 fixture,分批提交场景 ----
127
+ // 核心场景(papercuts 2026-09-30 行):sync 刷新 kit.json 后只 add 台账不 add 件 → 提交树内 kit.json
128
+ // 预 landing → 修前工作树口径「全对齐」放行(假绿),clone/CI doctor 必红;修后入库态模式拦。
129
+ {
130
+ const GIT = process.platform === 'win32' ? 'git.exe' : 'git';
131
+ const g = (cwd, ...args) => spawnSync(GIT, args, { cwd, encoding: 'utf8' });
132
+ const W2 = (root, rel, content) => {
133
+ fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true });
134
+ fs.writeFileSync(path.join(root, rel), content);
135
+ };
136
+ const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-cl-git-'));
137
+ g(repo, 'init', '-q');
138
+ g(repo, 'config', 'user.email', 't@t');
139
+ g(repo, 'config', 'user.name', 't');
140
+ g(repo, 'config', 'core.autocrlf', 'false');
141
+ const kitOf = (aSha) => JSON.stringify({
142
+ kit: 't', version: '0.0.0-test',
143
+ managed: [{ rel: '.agents/scripts/a.mjs', sha256: aSha }],
144
+ }) + '\n';
145
+ W2(repo, '.agents/scripts/a.mjs', 'A v1\n');
146
+ W2(repo, '.agents/kit.json', kitOf(lfSha('A v1\n')));
147
+ g(repo, 'add', '-A');
148
+ g(repo, 'commit', '-q', '-m', 'v1');
149
+ // sync 模拟:件与台账同刷 v2(工作区自洽,均未提交)
150
+ W2(repo, '.agents/scripts/a.mjs', 'A v2\n');
151
+ W2(repo, '.agents/kit.json', kitOf(lfSha('A v2\n')));
152
+
153
+ // S10① 只 add kit.json(分批提交)→ 暂存台账(v2) vs 入库件(HEAD v1) → 拦
154
+ g(repo, 'add', '.agents/kit.json');
155
+ const r1 = ledgerDrift(repo, { staged: true });
156
+ check('S10① 入库态:台账(v2 暂存) vs 件(HEAD v1) → 拦(修前工作树口径假绿放行)',
157
+ r1.mode === 'staged' && r1.modified.includes('.agents/scripts/a.mjs'), JSON.stringify(r1));
158
+ // S10② 件与台账同 add → 入库态一致 → 过
159
+ g(repo, 'add', '.agents/scripts/a.mjs');
160
+ const r2 = ledgerDrift(repo, { staged: true });
161
+ check('S10② 入库态:件与台账同 add → 一致过', r2.mode === 'staged' && !r2.modified.length && !r2.gone.length, JSON.stringify(r2));
162
+ g(repo, 'commit', '-q', '-m', 'v2 both');
163
+ // S10③ 工作树删件未暂存 → index 仍有该件(`:rel` 命中 HEAD 内容)→ 不误报 gone(复核 P1 修后语义:
164
+ // HEAD 兜底已移除,判定路径 = `:rel` 命中而非兜底)
165
+ fs.rmSync(path.join(repo, '.agents/scripts/a.mjs'));
166
+ const r3 = ledgerDrift(repo, { staged: true });
167
+ check('S10③ 入库态:工作树删件未暂存 → index 仍在,不误报', !r3.gone.length && !r3.modified.length, JSON.stringify(r3));
168
+ // S10④ 暂存删除(git rm)→ 提交树已无该件而台账仍跟踪 → gone 拦(复核 P1:HEAD 兜底曾把它误读
169
+ // 为「未触碰」放行 = 提交门绕过;修后与模块头「managed 删除即拦」政策一致)
170
+ g(repo, 'rm', '-q', '--cached', '.agents/scripts/a.mjs');
171
+ const r4 = ledgerDrift(repo, { staged: true });
172
+ check('S10④ 入库态:暂存删除 → gone 拦(提交树=index,删除被应用)', r4.gone.includes('.agents/scripts/a.mjs'), JSON.stringify(r4));
173
+ // S10④b 退出性提交:kit.json 被暂存删除 → 放行(skip,语义与 managed 删除拦截区分)
174
+ g(repo, 'rm', '-q', '--cached', '.agents/kit.json');
175
+ const r4b = ledgerDrift(repo, { staged: true });
176
+ check('S10④b 入库态:kit.json 暂存删除(退出性提交)→ skip 放行', r4b.skipped === true, JSON.stringify(r4b));
177
+ g(repo, 'reset', '-q', '--hard', 'HEAD'); // 复原(防脏)
178
+
179
+ // S10⑤ isMain 无参自适应(子进程):暂存区空 → 「工作树」;暂存区非空 → 自动「入库态」
180
+ g(repo, 'reset', '-q', '--hard', 'HEAD'); // 清掉 ④ 的暂存删除,回到干净 v2 both
181
+ const s1 = runCli(repo); // 暂存区空 → 工作树口径
182
+ check('S10⑤ 暂存区空 → 工作树口径', s1.status === 0 && (s1.stdout || '').includes('工作树'), `out=${s1.stdout}`);
183
+ W2(repo, 'unrelated.txt', 'x\n');
184
+ g(repo, 'add', 'unrelated.txt'); // 暂存非空(无关文件即可)→ 自动入库态;kit.json 未暂存走 HEAD 兜底
185
+ const s2 = runCli(repo);
186
+ check('S10⑤ 暂存区非空 → 自动切入库态且一致 exit 0',
187
+ s2.status === 0 && (s2.stdout || '').includes('入库态'), `status=${s2.status} out=${s2.stdout}`);
188
+ // S10⑥ 姊妹漏洞(完备口径价值场景):只 add 件不 add 台账 → 提交树 = HEAD 台账(v1? v2) + 暂存件 → 错位拦
189
+ W2(repo, '.agents/scripts/a.mjs', 'A v3\n');
190
+ g(repo, 'add', '.agents/scripts/a.mjs');
191
+ const s3 = runCli(repo);
192
+ check('S10⑥ 只 add 件不 add 台账 → 入库态错位拦 exit 1', s3.status === 1 && (s3.stderr || '').includes('.agents/scripts/a.mjs'), `status=${s3.status} err=${s3.stderr}`);
193
+ fs.rmSync(repo, { recursive: true, force: true });
194
+ }
195
+
126
196
  console.log(`\n合计: PASS ${pass} / FAIL ${failCount}`);
127
197
  process.exit(failCount ? 1 : 0);
@@ -0,0 +1,95 @@
1
+ // check-loop-unit.test.mjs — check-loop 零件直测(2026-10-08 checkloop-importable)
2
+ // papercuts 2026-10-04 isMain 行的兑现:check-loop.mjs 自 isMain 主守卫 + runCheckLoop 接缝后可 import——
3
+ // 本套件全部进程内直测(不 spawn),与 check-loop.test.mjs(端到端 spawn,行为回归网)互补。
4
+ // 覆盖:①runCheckLoop({root}) 三态(干净 exit 0 / 构造 blocker exit 1 且数组带内容 / rev+root 互斥);
5
+ // ②四纯零件:delegationResultRows 表头签名(节标题漂移仍解析)、versionGreater 边界、
6
+ // fmStatus 形态、bindingSha256 prev 复原与「确认指纹」剔除对称。
7
+ // 用法:node templates/_agents/scripts/check-loop-unit.test.mjs
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { createHash } from 'node:crypto';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
15
+ const { runCheckLoop, delegationResultRows, versionGreater, fmStatus, bindingSha256 } =
16
+ await import(pathToFileURL(path.join(SCRIPT_DIR, 'check-loop.mjs')).href);
17
+
18
+ let pass = 0, fail = 0;
19
+ const check = (name, cond, detail = '') => {
20
+ if (cond) { pass++; console.log(`PASS ${name}`); }
21
+ else { fail++; console.log(`FAIL ${name}${detail ? `——${detail}` : ''}`); }
22
+ };
23
+ const W = (root, rel, content) => { fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true }); fs.writeFileSync(path.join(root, rel), content); };
24
+
25
+ // ---- ① runCheckLoop 进程内直测 ----
26
+ {
27
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cl-unit-'));
28
+ W(root, '.agents/workflow-enums.txt', fs.readFileSync(path.join(SCRIPT_DIR, '..', 'workflow-enums.txt'), 'utf8'));
29
+ let r = runCheckLoop({ root });
30
+ check('run:干净 fixture → exit 0(blockers/warnings 均数组)',
31
+ r.exitCode === 0 && Array.isArray(r.blockers) && Array.isArray(r.warnings) && r.blockers.length === 0,
32
+ `exit=${r.exitCode} b=${r.blockers.length}`);
33
+ // 构造 blocker:L2 done intent 缺 spec 配对(配对断裂 hard)
34
+ W(root, 'workflow/intents/2026-10-08-x.md', '---\n状态: done\n级别: L2\n日期: 2026-10-08\n模块: pipeline\n---\n# x\n');
35
+ r = runCheckLoop({ root });
36
+ check('run:构造 L2 缺配对 → exit 1 且 blockers 数组带内容',
37
+ r.exitCode === 1 && r.blockers.length > 0 && r.blockers[0].includes('配对断裂'),
38
+ `exit=${r.exitCode} b=${JSON.stringify(r.blockers).slice(0, 80)}`);
39
+ // rev+root 互斥(root 参数通道)
40
+ const r2 = runCheckLoop({ root, rev: 'abc1234' });
41
+ check('run:rev+root 互斥 → exit 1(报错走 stderr,返回空数组判决)', r2.exitCode === 1, `exit=${r2.exitCode}`);
42
+ // root 不可访问 → exit 1(不抛异常)
43
+ const r3 = runCheckLoop({ root: path.join(root, '不存在目录') });
44
+ check('run:root 不可访问 → exit 1(同款报错语义)', r3.exitCode === 1, `exit=${r3.exitCode}`);
45
+ fs.rmSync(root, { recursive: true, force: true });
46
+ }
47
+
48
+ // ---- ② delegationResultRows:表头签名识别(零件级直测)----
49
+ {
50
+ const f = path.join(os.tmpdir(), `cl-unit-${Date.now()}-delegations.md`);
51
+ // 节标题漂移(「委派结果表」≠精确节名)而表头不动 → 仍解析(papercuts-cleanup-batch 修 2 语义)
52
+ fs.writeFileSync(f, '## 委派结果表\n\n| 日期 | 被委派方 | 任务一句话 | 结果 | 备注 |\n|---|---|---|---|---|\n| 2026-10-01 | x | y | 一次通过 | z |\n\n## 自做台账\n\n| 日期 | 任务一句话 | 结果 | 备注 |\n|---|---|---|---|\n| 2026-10-02 | p | 返工×2 | q |\n');
53
+ const rows = delegationResultRows(f);
54
+ check('零件 delegationResultRows:节标题漂移仍按表头签名解析(委派 1 + 自做 1)',
55
+ rows.length === 2 && rows[0].date === '2026-10-01' && rows[1].date === '2026-10-02' && rows[1].line.includes('返工×2'),
56
+ JSON.stringify(rows));
57
+ check('零件 delegationResultRows:文件不存在 → 空数组不抛', delegationResultRows(path.join(os.tmpdir(), 'cl-unit-nope.md')).length === 0);
58
+ fs.rmSync(f, { force: true });
59
+ }
60
+
61
+ // ---- ③ versionGreater 边界 ----
62
+ {
63
+ check('零件 versionGreater:1.2.3>1.2.2 / 1.10.0>1.9.9 / 相等 false / 非法 false',
64
+ versionGreater('1.2.3', '1.2.2') === true
65
+ && versionGreater('1.10.0', '1.9.9') === true
66
+ && versionGreater('1.2.3', '1.2.3') === false
67
+ && versionGreater('abc', '1.0.0') === false);
68
+ }
69
+
70
+ // ---- ④ fmStatus 形态 ----
71
+ {
72
+ check('零件 fmStatus:标准/双空格/CRLF/无 frontmatter',
73
+ fmStatus('---\n状态: done\n级别: L1\n---\n# x') === 'done'
74
+ && fmStatus('---\r\n状态: approved\r\n---\r\n# x') === 'approved'
75
+ && fmStatus('# 无 frontmatter') === '');
76
+ }
77
+
78
+ // ---- ⑤ bindingSha256:prev 复原与「确认指纹」剔除对称 ----
79
+ {
80
+ // 前向:approved 落态全文算指纹(confirm-doc computeFingerprint 口径:CRLF 归一→剔指纹行→sha256)
81
+ const fpOf = (text) => createHash('sha256')
82
+ .update(text.replace(/\r\n/g, '\n').split('\n').filter((l) => !/^确认指纹:/.test(l)).join('\n'), 'utf8').digest('hex');
83
+ const approved = '---\n状态: approved\n级别: L2\n日期: 2026-10-08\n模块: pipeline\n确认指纹: ab12cd34ef56ab12\n---\n# T\n\n## 验收标准(可测试)\n\n- [x] 用例(证据:a1b2c3d)\n';
84
+ const done = approved.replace('状态: approved', '状态: done');
85
+ // 逆推:done 行集(lines 已 CRLF 归一)+ prev='approved' → 应还原出与 approved 落态一致的指纹
86
+ const reversed = bindingSha256(done.replace(/\r\n/g, '\n').split('\n'), 'approved');
87
+ check('零件 bindingSha256:done 行集经 prev 复原重算 == approved 落态指纹(内容绑定对称性)',
88
+ reversed === fpOf(approved), `${reversed.slice(0, 12)} vs ${fpOf(approved).slice(0, 12)}`);
89
+ // prev 不同 → 指纹不同(判定不恒真)
90
+ check('零件 bindingSha256:prev 不同 → 复原指纹不同(判定不恒真)',
91
+ bindingSha256(done.replace(/\r\n/g, '\n').split('\n'), 'draft') !== reversed);
92
+ }
93
+
94
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
95
+ process.exit(fail ? 1 : 0);