agentic-flow-kit 1.2.1 → 1.3.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 (31) hide show
  1. package/modules/hosts/claude/commands/wf-sync-hosts.md +7 -3
  2. package/modules/hosts/claude/commands/wf-test.md +1 -1
  3. package/modules/hosts/codex/commands/wf-sync-hosts.md +7 -3
  4. package/modules/hosts/codex/commands/wf-test.md +1 -1
  5. package/modules/hosts/cursor/commands/wf-sync-hosts.md +7 -3
  6. package/modules/hosts/cursor/commands/wf-test.md +1 -1
  7. package/modules/hosts/opencode/commands/wf-sync-hosts.md +7 -3
  8. package/modules/hosts/opencode/commands/wf-test.md +1 -1
  9. package/modules/hosts/trae/commands/wf-sync-hosts.md +7 -3
  10. package/modules/hosts/trae/commands/wf-test.md +1 -1
  11. package/package.json +1 -1
  12. package/src/cli.mjs +3 -1
  13. package/src/fresh-init.test.mjs +10 -0
  14. package/src/init.mjs +1 -1
  15. package/src/profiles.mjs +12 -6
  16. package/src/sync-hosts.mjs +42 -19
  17. package/src/sync-hosts.test.mjs +55 -7
  18. package/src/sync.mjs +30 -26
  19. package/src/sync.test.mjs +82 -0
  20. package/templates/AGENTS.md +1 -0
  21. package/templates/_agents/commands/sync-hosts.md +7 -3
  22. package/templates/_agents/rule-budgets.txt +3 -1
  23. package/templates/_agents/scripts/agg-delegations.cjs +15 -5
  24. package/templates/_agents/scripts/check-ledger.mjs +60 -16
  25. package/templates/_agents/scripts/check-ledger.test.mjs +70 -0
  26. package/templates/_agents/scripts/check-loop-unit.test.mjs +95 -0
  27. package/templates/_agents/scripts/check-loop.mjs +1134 -1084
  28. package/templates/_agents/scripts/check-loop.test.mjs +86 -1
  29. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +182 -0
  30. package/templates/_agents/scripts/gen-workflow-dashboard.test.mjs +126 -0
  31. package/templates/_github/workflows/kit-ci.yml +28 -0
package/src/sync.test.mjs CHANGED
@@ -366,5 +366,87 @@ else if (cmd === 'add-gate') addGate(rest, root);
366
366
  check('S15⑥ .agents/ 前缀翻译 → _agents 模板参与感知出账', (r7.stdout + r7.stderr).includes('有演进且盘面未跟随') && (r7.stdout + r7.stderr).includes('.agents/rule-budgets.txt'));
367
367
  }
368
368
 
369
+ // ============ 场景 16:装户 GitHub CI 远端门模板(2026-10-07 adopter-ci-github)============
370
+ // 包源 templates/_github/workflows/kit-ci.yml → 装户 .github/workflows/kit-ci.yml(owned):
371
+ // ① isOwned / srcTemplatePath 契约(含 .agents 存量不回归、无模板反例);
372
+ // ② 存量装户盘上无该件 → newOwned 提示且不自动落盘;③ 已装 + 源演进 → 感知出账 + 锚刷新 + 再跑静默
373
+ {
374
+ const sha = (s) => sha256(Buffer.from(s, 'utf8'));
375
+ const CI_REL = '.github/workflows/kit-ci.yml';
376
+ const CI_TPL = 'ci v2\n';
377
+ const { isOwned, srcTemplatePath } = await import(pathToFileURL(path.join(SRC_ROOT, 'profiles.mjs')).href);
378
+
379
+ // ① 契约断言:.github/ 归 owned;点前缀翻译 .github ↔ _github 命中、.agents 严格不回归、无模板 null
380
+ check('S16① isOwned:.github/ 前缀归 owned + 存量正例不回归(spec 验收 1)',
381
+ isOwned(CI_REL) === true && isOwned('.github/other.yml') === true
382
+ && isOwned('AGENTS.md') === true && isOwned('.agents/notes/x.md') === true && isOwned('workflow/plans/a.md') === true
383
+ && isOwned('.agents/scripts/check-loop.mjs') === false);
384
+ const fxC = mkFixture();
385
+ W(path.join(fxC, 'templates/_github/workflows/kit-ci.yml'), CI_TPL);
386
+ const norm = (p) => p.split(path.sep).join('/');
387
+ check('S16① srcTemplatePath 通用点前缀翻译:.github → _github 命中 / .agents 严格不回归',
388
+ norm(srcTemplatePath(fxC, CI_REL)).endsWith('templates/_github/workflows/kit-ci.yml')
389
+ && norm(srcTemplatePath(fxC, '.agents/scripts/keep.txt')).endsWith('templates/_agents/scripts/keep.txt'),
390
+ JSON.stringify({ ci: srcTemplatePath(fxC, CI_REL), agents: srcTemplatePath(fxC, '.agents/scripts/keep.txt') }));
391
+ check('S16① srcTemplatePath 无对应模板 → null(.gitattributes 反例,与旧实现等价)',
392
+ srcTemplatePath(fxC, '.gitattributes') === null);
393
+
394
+ // ② 存量装户(盘上无该件)→ newOwned 提示且不自动落盘
395
+ const fx1 = mkFixture();
396
+ W(path.join(fx1, 'templates/_github/workflows/kit-ci.yml'), CI_TPL);
397
+ const t1 = mkTarget(fx1);
398
+ const r1 = runCmd('sync', fx1, t1);
399
+ const out1 = r1.stdout + r1.stderr;
400
+ check('S16② 存量装户无该件 → newOwned 提示含文件名且不自动落盘',
401
+ out1.includes('新增 owned 起步文档') && out1.includes(CI_REL) && !fs.existsSync(path.join(t1, CI_REL)), out1);
402
+
403
+ // ③ 装户已装(带旧锚)+ 源演进 → 感知出账;锚刷新后(custom-synced)再跑静默
404
+ const fx2 = mkFixture();
405
+ W(path.join(fx2, 'templates/_github/workflows/kit-ci.yml'), CI_TPL);
406
+ const t2 = mkTarget(fx2);
407
+ W(path.join(t2, CI_REL), 'ci CUSTOM\n');
408
+ W(path.join(t2, '.agents/kit.json'), `${JSON.stringify({
409
+ kit: 'agentic-flow-kit', version: '1.0.0', createdAt: '2026-09-23T00:00:00Z',
410
+ options: { hosts: ['zcode'], stack: 'none', boardPort: '777' },
411
+ managed: [{ rel: '.agents/scripts/keep.txt', sha256: sha('keep v2\n') }],
412
+ owned: [{ rel: CI_REL, sha256: sha('ci CUSTOM\n'), srcSha256: sha('ci v1\n') }],
413
+ }, null, 2)}\n`);
414
+ const r2 = runCmd('sync', fx2, t2);
415
+ const out2 = r2.stdout + r2.stderr;
416
+ check('S16③ 源演进未跟随 → 感知出账含 .github rel', out2.includes('有演进且盘面未跟随') && out2.includes(CI_REL), out2.slice(0, 300));
417
+ check('S16③ 装户定制不被覆盖(盘面仍 CUSTOM)', R(path.join(t2, CI_REL)) === 'ci CUSTOM\n', out2);
418
+ const kit2 = JSON.parse(R(path.join(t2, '.agents/kit.json')));
419
+ check('S16③ 锚刷新为当前包源 sha', kit2.owned.find((f) => f.rel === CI_REL).srcSha256 === sha(CI_TPL), JSON.stringify(kit2.owned));
420
+ const r2b = runCmd('sync', fx2, t2);
421
+ check('S16③ 锚已刷(custom-synced)→ 再跑静默', !(r2b.stdout + r2b.stderr).includes('有演进且盘面未跟随'));
422
+ }
423
+
424
+ // ============ 场景 17:sync 一次自洽——生成器重写 owned 目标后按终态记账(2026-10-08 papercuts-cleanup-batch 修 1)============
425
+ // 修前:记账段(owned 自愈/感知/台账重写)先于生成器执行 → 生成器重写 wiki 类 owned 目标后台账停旧值
426
+ // → doctor §6.6 owned 漂移 FAIL 一次,需二次 sync 自愈。修后:生成器先跑,记账按生成后盘面收口。
427
+ {
428
+ const sha = (s) => sha256(Buffer.from(s, 'utf8'));
429
+ const GEN = "import fs from 'node:fs';\nfs.writeFileSync('wiki/INDEX.md', 'generated v2\\n');\n";
430
+ const fx = mkFixture();
431
+ W(path.join(fx, 'templates/_agents/scripts/gen-wiki-board.mjs'), GEN);
432
+ const t = mkTarget(fx);
433
+ W(path.join(t, '.agents/scripts/gen-wiki-board.mjs'), GEN); // 目标侧与包源渲染一致(genGuard/scriptTrusted 放行)
434
+ W(path.join(t, 'wiki/INDEX.md'), 'generated v1\n'); // 盘面停旧版——生成器将重写它
435
+ const k0 = JSON.parse(R(path.join(t, '.agents/kit.json')));
436
+ k0.managed.push({ rel: '.agents/scripts/gen-wiki-board.mjs', sha256: sha(GEN) });
437
+ k0.owned.push({ rel: 'wiki/INDEX.md', sha256: sha('generated v1\n') });
438
+ W(path.join(t, '.agents/kit.json'), `${JSON.stringify(k0, null, 2)}\n`);
439
+ const r = runCmd('sync', fx, t);
440
+ const out = r.stdout + r.stderr;
441
+ check('S17 一次 sync:生成器重写后 owned 台账按终态记账(wiki/INDEX.md → v2 sha)',
442
+ JSON.parse(R(path.join(t, '.agents/kit.json'))).owned.find((f) => f.rel === 'wiki/INDEX.md').sha256 === sha('generated v2\n'),
443
+ JSON.stringify(JSON.parse(R(path.join(t, '.agents/kit.json'))).owned) + out.slice(0, 300));
444
+ const { checkOwnedDrift } = await import(pathToFileURL(path.join(SRC_ROOT, 'doctor.mjs')).href);
445
+ const d = checkOwnedDrift(t);
446
+ check('S17 一次 sync 后 doctor owned 零漂移(修前必漂移需二次 sync)', d.drift === 0 && d.gone.length === 0, JSON.stringify(d));
447
+ const r2 = runCmd('sync', fx, t);
448
+ check('S17 二次 sync 幂等(无 owned 刷新报告)', !(r2.stdout + r2.stderr).includes('owned 台账哈希按盘面刷新'), r2.stdout + r2.stderr);
449
+ }
450
+
369
451
  console.log(`\n合计: PASS ${pass} / FAIL ${failCount}`);
370
452
  process.exit(failCount ? 1 : 0);
@@ -24,6 +24,7 @@
24
24
  ### 门禁与提交
25
25
 
26
26
  - `.githooks/`(经 `core.hooksPath` 挂载,本地 clone 后执行 `git config core.hooksPath .githooks`):pre-commit(闭环配对 / 泳道完整性 / wiki 台账 / 规则面预算 / 双源一致性 / managed 台账快检 / 敏感信息扫描与条件构建)、pre-push(闭环断档扫描)、commit-msg、post-commit、pre-merge-commit。
27
+ - **CI 远端门(2026-10-07 起)**:`.github/workflows/kit-ci.yml`(owned)在 GitHub runner 复跑 `node .agents/scripts/verify.mjs`(云端兜底本地钩子);非 GitHub 装户可删,等价门 = 同入口命令 + 服务端分支策略(人工一次性配置,机器不校验)。
27
28
  - **`git commit` / `git merge` / `git push` 三处一律禁 `--no-verify`**——被拦说明产出不合规,按提示修完原路重试。
28
29
  - 项目专属门禁挂 `.agents/hooks/local-pre-commit`。装户五条硬规则(配对、验收、确认留痕、敏感信息、双源台账)与 `audit` 档见 `workflow/README.md`「硬规则」。
29
30
  - 提交遵循 Conventional Commits 中文(feat / fix / docs / style / refactor / perf)。L1+ 三段式闭环链:`docs(*)` approved 留痕 → 代码 → 关单 `docs(*)`,相邻提交即同一闭环(2026-10-04 修订)。
@@ -20,7 +20,10 @@ next: .agents/commands/build.md(漂移属 build 改权威源未同步导致的
20
20
  # Sync-Hosts · 跨宿主适配层正文段同步
21
21
 
22
22
  > 改权威源(`templates/_agents/{commands,roles}/*.md`)后薄适配(`modules/hosts/<h>/{agents,commands}/*.md`)要同步——薄适配正文 = 权威源正文,frontmatter 各自保留宿主特化(commands 层文件名 = 宿主命令名,opencode / trae 同用 `wf-` 前缀,单源见 `src/profiles.mjs#HOSTS.commandPrefix`;trae 另有 `name: wf-X`,zcode/omp 各自原描述)。
23
- > 装户视角:`flow-kit doctor` §7.x 跑同名检查(仅装户,包源环境跳过——包源下永远是 drift)。
23
+ > 装户视角:`flow-kit doctor` §7.x 跑同名检查(仅装户,包源环境跳过——包源下永远是 drift);**修漂移用同一条命令**
24
+ > `flow-kit sync-hosts --apply`——2026-10-07 起按**目标根**自动判布局(含 `templates/_agents` + `modules/hosts` → 包源:
25
+ > 比 `templates/_agents` ↔ `modules/hosts/<宿主键>/`;含 `.agents` → 装户:比 `.agents` ↔ 项目根下 `<宿主点目录>/`),
26
+ > 目标根取 `--dir <目录>` > 当前目录。
24
27
 
25
28
  ## 何时跑
26
29
 
@@ -34,8 +37,9 @@ next: .agents/commands/build.md(漂移属 build 改权威源未同步导致的
34
37
  ### 默认 `--diff`(仅报告)
35
38
 
36
39
  ```
37
- node bin/flow-kit.mjs sync-hosts --diff
38
- node bin/flow-kit.mjs sync-hosts --json # 机器可读
40
+ node bin/flow-kit.mjs sync-hosts --diff # 包源仓(在包源根跑)
41
+ node bin/flow-kit.mjs sync-hosts --json # 机器可读
42
+ flow-kit sync-hosts --diff # 装户仓(在装户根跑;或用 --dir <项目根> 指定)
39
43
  ```
40
44
 
41
45
  输出三类:
@@ -5,6 +5,8 @@
5
5
  # 口径:预算比较用「索引 / HEAD 字节」(git cat-file,LF 归一);禁用工作区 wc -c 核对(CRLF 膨胀)。
6
6
  # 2026-10-06 backflow-loop-audit-remediation:6 份命令文档顶部加「装户边界」标注(build.md 实测 8386B
7
7
  # 超 8192 被拦)——单篇上限 8192→8704(与装户 Shipyard.Material 同值,512B 步进);目录合计未超不动。
8
- AGENTS.md 7680
8
+ # 2026-10-07 adopter-ci-github:AGENTS.md 门禁节补 CI 远端门一行(299B,实测 7969B 超 7680 被拦)——
9
+ # 新门入册属合法增长,同 512B 步进上调 7680→8192。
10
+ AGENTS.md 8192
9
11
  .agents/commands/*.md 8704
10
12
  .agents/commands/ 65536
@@ -41,6 +41,9 @@ function parseResult(raw) {
41
41
 
42
42
  // splitTables:按表头签名识别两张结果表——节标题只服务人类阅读,表头才是数据边界
43
43
  // (2026-09-24 修复:曾按 `## 委派结果` 节标题定位,节结构漂移时已有记录被静默读成「台账为空」)
44
+ // ⚠️ 互引(2026-10-08 papercuts-cleanup-batch 修 2):check-loop.mjs delegationResultRows 已对齐本签名
45
+ // 口径(委派表头=含「被委派方」列 / 自做表头=首列「日期」且含「任务一句话」列)——一边改另一边须跟;
46
+ // 差异保留:本函数按「连续 | 行」断表(聚合需分行归属),check-loop 按「## 节」终止收集(对账只需并集)
44
47
  function splitTables(text) {
45
48
  const tables = { delegated: [], self: [] };
46
49
  let cur = null;
@@ -54,9 +57,15 @@ function splitTables(text) {
54
57
  return tables;
55
58
  }
56
59
 
57
- function readLedger() {
58
- if (!fs.existsSync(LEDGER)) fail(`找不到 ${LEDGER}`);
59
- const text = fs.readFileSync(LEDGER, 'utf8');
60
+ // readLedger:root 显式参数 + soft 模式(2026-10-08 workflow-dashboard 使能导出,判据单源复用——
61
+ // 仪表盘 require 本函数取行,不重写表解析;缺省 root/无 soft 行为与原签名完全一致,main() 零变化)
62
+ function readLedger(root = ROOT, opts = {}) {
63
+ const ledger = path.join(root, 'workflow', 'delegations.md');
64
+ if (!fs.existsSync(ledger)) {
65
+ if (opts.soft) return [];
66
+ fail(`找不到 ${ledger}`);
67
+ }
68
+ const text = fs.readFileSync(ledger, 'utf8');
60
69
  const tables = splitTables(text);
61
70
  const delegated = parseTableRows(tables.delegated.join('\n')).map((c) => ({
62
71
  scope: '委派',
@@ -73,7 +82,8 @@ function readLedger() {
73
82
  const rows = delegated.concat(selfDone);
74
83
  // 零数据行时区分「台账真空」与「结构漂移」:有日期开头的数据行却识别不到表 → fail-loud,不再静默「台账为空」
75
84
  if (!rows.length && /^\s*\|\s*\d{4}-\d{2}-\d{2}\s*\|/m.test(text)) {
76
- fail(`台账结构漂移:${LEDGER} 存在日期开头的数据行但未识别到结果表(委派表表头须含「被委派方」列,自做表表头须为「日期 | 任务一句话 | 结果 | 备注」)`);
85
+ if (opts.soft) return [];
86
+ fail(`台账结构漂移:${ledger} 存在日期开头的数据行但未识别到结果表(委派表表头须含「被委派方」列,自做表表头须为「日期 | 任务一句话 | 结果 | 备注」)`);
77
87
  }
78
88
  return rows;
79
89
  }
@@ -197,5 +207,5 @@ function main() {
197
207
  if (require.main === module) {
198
208
  main();
199
209
  } else {
200
- module.exports = { parseResult, metrics, gateMonth, expansionVerdict, GATE };
210
+ module.exports = { parseResult, metrics, gateMonth, expansionVerdict, GATE, readLedger };
201
211
  }
@@ -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);