agentic-flow-kit 1.7.15 → 1.8.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 (206) hide show
  1. package/README.md +50 -8
  2. package/modules/hosts/claude/commands/wf-build.md +1 -1
  3. package/modules/hosts/claude/commands/wf-design.md +5 -4
  4. package/modules/hosts/claude/commands/wf-maintain.md +1 -1
  5. package/modules/hosts/claude/commands/wf-new-task.md +47 -25
  6. package/modules/hosts/claude/commands/wf-plan.md +1 -1
  7. package/modules/hosts/claude/commands/wf-review.md +2 -2
  8. package/modules/hosts/claude/commands/wf-sync-hosts.md +7 -13
  9. package/modules/hosts/claude/commands/wf-test.md +2 -2
  10. package/modules/hosts/codex/commands/wf-build.md +1 -1
  11. package/modules/hosts/codex/commands/wf-design.md +5 -4
  12. package/modules/hosts/codex/commands/wf-maintain.md +1 -1
  13. package/modules/hosts/codex/commands/wf-new-task.md +47 -25
  14. package/modules/hosts/codex/commands/wf-plan.md +1 -1
  15. package/modules/hosts/codex/commands/wf-review.md +2 -2
  16. package/modules/hosts/codex/commands/wf-sync-hosts.md +7 -13
  17. package/modules/hosts/codex/commands/wf-test.md +2 -2
  18. package/modules/hosts/cursor/commands/wf-build.md +1 -1
  19. package/modules/hosts/cursor/commands/wf-design.md +5 -4
  20. package/modules/hosts/cursor/commands/wf-maintain.md +1 -1
  21. package/modules/hosts/cursor/commands/wf-new-task.md +47 -25
  22. package/modules/hosts/cursor/commands/wf-plan.md +1 -1
  23. package/modules/hosts/cursor/commands/wf-review.md +2 -2
  24. package/modules/hosts/cursor/commands/wf-sync-hosts.md +7 -13
  25. package/modules/hosts/cursor/commands/wf-test.md +2 -2
  26. package/modules/hosts/opencode/commands/wf-build.md +1 -1
  27. package/modules/hosts/opencode/commands/wf-design.md +5 -4
  28. package/modules/hosts/opencode/commands/wf-maintain.md +1 -1
  29. package/modules/hosts/opencode/commands/wf-new-task.md +47 -25
  30. package/modules/hosts/opencode/commands/wf-plan.md +1 -1
  31. package/modules/hosts/opencode/commands/wf-review.md +2 -2
  32. package/modules/hosts/opencode/commands/wf-sync-hosts.md +7 -13
  33. package/modules/hosts/opencode/commands/wf-test.md +2 -2
  34. package/modules/hosts/trae/commands/wf-build.md +1 -1
  35. package/modules/hosts/trae/commands/wf-design.md +5 -4
  36. package/modules/hosts/trae/commands/wf-maintain.md +1 -1
  37. package/modules/hosts/trae/commands/wf-new-task.md +47 -25
  38. package/modules/hosts/trae/commands/wf-plan.md +1 -1
  39. package/modules/hosts/trae/commands/wf-review.md +2 -2
  40. package/modules/hosts/trae/commands/wf-sync-hosts.md +7 -13
  41. package/modules/hosts/trae/commands/wf-test.md +2 -2
  42. package/package.json +1 -1
  43. package/src/cli.mjs +47 -4
  44. package/src/doctor.mjs +48 -3
  45. package/src/executor.mjs +262 -0
  46. package/src/executor.test.mjs +295 -0
  47. package/src/fresh-init.test.mjs +1 -1
  48. package/src/init.mjs +27 -3
  49. package/src/profiles.mjs +10 -3
  50. package/src/render.mjs +3 -3
  51. package/src/rule-budget.test.mjs +2 -2
  52. package/src/stack-profile.test.mjs +6 -0
  53. package/src/sync-hosts.mjs +37 -43
  54. package/src/sync-hosts.test.mjs +67 -56
  55. package/src/validate.mjs +6 -1
  56. package/src/validate.test.mjs +1 -1
  57. package/templates/.gitattributes +12 -1
  58. package/templates/AGENTS.md +3 -3
  59. package/templates/_agents/commands/build.md +1 -1
  60. package/templates/_agents/commands/design.md +5 -4
  61. package/templates/_agents/commands/maintain.md +1 -1
  62. package/templates/_agents/commands/new-task.md +47 -25
  63. package/templates/_agents/commands/plan.md +1 -1
  64. package/templates/_agents/commands/review.md +2 -2
  65. package/templates/_agents/commands/sync-hosts.md +7 -13
  66. package/templates/_agents/commands/test.md +2 -2
  67. package/templates/_agents/control-surfaces.txt +1 -1
  68. package/templates/_agents/engine-lock.json +334 -0
  69. package/templates/_agents/hooks/check-index-incremental.mjs +48 -0
  70. package/templates/_agents/hooks/check-pairing-incremental.mjs +66 -0
  71. package/templates/_agents/hooks/check-wiki-ledger.mjs +30 -0
  72. package/templates/_agents/lane-surfaces.txt +5 -2
  73. package/templates/_agents/level-hints.txt +30 -0
  74. package/templates/_agents/loop/papercut-debt/DESIGN.md +112 -0
  75. package/templates/_agents/mechanism-registry.md +27 -0
  76. package/templates/_agents/rule-budgets.txt +2 -2
  77. package/templates/_agents/scripts/agg-delegations.cjs +21 -3
  78. package/templates/_agents/scripts/agg-delegations.test.mjs +5 -3
  79. package/templates/_agents/scripts/agg-gate-stats.mjs +3 -3
  80. package/templates/_agents/scripts/agg-judgments.mjs +80 -0
  81. package/templates/_agents/scripts/agg-judgments.test.mjs +45 -0
  82. package/templates/_agents/scripts/archive-sweep.mjs +233 -0
  83. package/templates/_agents/scripts/archive-sweep.test.mjs +129 -0
  84. package/templates/_agents/scripts/check-attribution.mjs +37 -0
  85. package/templates/_agents/scripts/check-convergence.mjs +119 -0
  86. package/templates/_agents/scripts/check-engine-integrity.mjs +59 -127
  87. package/templates/_agents/scripts/check-engine-integrity.test.mjs +33 -45
  88. package/templates/_agents/scripts/check-funnel.mjs +56 -0
  89. package/templates/_agents/scripts/check-hygiene.mjs +5 -5
  90. package/templates/_agents/scripts/check-index-incremental.test.mjs +73 -0
  91. package/templates/_agents/scripts/check-lane-surface.mjs +74 -25
  92. package/templates/_agents/scripts/check-lane-surface.test.mjs +71 -5
  93. package/templates/_agents/scripts/check-ledger-invariant.mjs +427 -78
  94. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +438 -4
  95. package/templates/_agents/scripts/check-loop-rev.test.mjs +13 -8
  96. package/templates/_agents/scripts/check-loop-unit.test.mjs +8 -0
  97. package/templates/_agents/scripts/check-loop.mjs +264 -75
  98. package/templates/_agents/scripts/check-loop.test.mjs +587 -61
  99. package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
  100. package/templates/_agents/scripts/check-prejudgment.mjs +59 -0
  101. package/templates/_agents/scripts/check-registry.mjs +45 -0
  102. package/templates/_agents/scripts/check-scope.mjs +96 -0
  103. package/templates/_agents/scripts/check-scope.test.mjs +96 -0
  104. package/templates/_agents/scripts/check-scripts-syntax.mjs +61 -0
  105. package/templates/_agents/scripts/check-scripts-syntax.test.mjs +72 -0
  106. package/templates/_agents/scripts/check-spec-verif.mjs +47 -0
  107. package/templates/_agents/scripts/compact-ledgers.mjs +97 -0
  108. package/templates/_agents/scripts/compact-ledgers.test.mjs +162 -0
  109. package/templates/_agents/scripts/confirm-doc.mjs +136 -31
  110. package/templates/_agents/scripts/confirm-doc.test.mjs +180 -4
  111. package/templates/_agents/scripts/doctor.test.mjs +44 -1
  112. package/templates/_agents/scripts/edit-face-check.mjs +5 -3
  113. package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
  114. package/templates/_agents/scripts/fill-intent.mjs +7 -5
  115. package/templates/_agents/scripts/fill-intent.test.mjs +19 -1
  116. package/templates/_agents/scripts/fill-plan.mjs +10 -6
  117. package/templates/_agents/scripts/fill-plan.test.mjs +19 -3
  118. package/templates/_agents/scripts/fill-spec.mjs +6 -3
  119. package/templates/_agents/scripts/fill-spec.test.mjs +12 -4
  120. package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
  121. package/templates/_agents/scripts/fixture-scripts.test.mjs +112 -0
  122. package/templates/_agents/scripts/gate-checklist.mjs +10 -9
  123. package/templates/_agents/scripts/gate-checklist.test.mjs +2 -11
  124. package/templates/_agents/scripts/gate-seg.mjs +1 -1
  125. package/templates/_agents/scripts/gen-effective-policy.mjs +107 -0
  126. package/templates/_agents/scripts/gen-effective-policy.test.mjs +100 -0
  127. package/templates/_agents/scripts/gen-verify-evidence.mjs +107 -0
  128. package/templates/_agents/scripts/gen-verify-evidence.test.mjs +84 -0
  129. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +16 -14
  130. package/templates/_agents/scripts/gen-workflow-dashboard.test.mjs +2 -1
  131. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -36
  132. package/templates/_agents/scripts/git-core.mjs +45 -0
  133. package/templates/_agents/scripts/git-merge-jsonl.mjs +63 -0
  134. package/templates/_agents/scripts/git-merge-jsonl.test.mjs +125 -0
  135. package/templates/_agents/scripts/judgment-ledger.mjs +65 -0
  136. package/templates/_agents/scripts/judgment-ledger.test.mjs +129 -0
  137. package/templates/_agents/scripts/kb-search.mjs +19 -4
  138. package/templates/_agents/scripts/kb-search.test.mjs +33 -0
  139. package/templates/_agents/scripts/lane-surface-audit.mjs +143 -0
  140. package/templates/_agents/scripts/lane-surface-audit.test.mjs +93 -0
  141. package/templates/_agents/scripts/ledger-doc-chain.test.mjs +166 -0
  142. package/templates/_agents/scripts/ledger-loader.mjs +108 -0
  143. package/templates/_agents/scripts/ledger-loader.test.mjs +131 -0
  144. package/templates/_agents/scripts/ledger-prune.mjs +96 -0
  145. package/templates/_agents/scripts/ledger-prune.test.mjs +120 -0
  146. package/templates/_agents/scripts/ledger-shards.mjs +307 -0
  147. package/templates/_agents/scripts/ledger-shards.test.mjs +339 -0
  148. package/templates/_agents/scripts/ledger-ts-monotonic.test.mjs +135 -0
  149. package/templates/_agents/scripts/level-criteria.mjs +231 -0
  150. package/templates/_agents/scripts/level-criteria.test.mjs +281 -0
  151. package/templates/_agents/scripts/papercut-debt-controller.mjs +141 -0
  152. package/templates/_agents/scripts/papercut-debt-controller.test.mjs +127 -0
  153. package/templates/_agents/scripts/papercut-debt-sensor.mjs +170 -0
  154. package/templates/_agents/scripts/papercut-debt-sensor.test.mjs +146 -0
  155. package/templates/_agents/scripts/pipeline-run.mjs +10 -24
  156. package/templates/_agents/scripts/pipeline-run.test.mjs +22 -42
  157. package/templates/_agents/scripts/policy.mjs +75 -0
  158. package/templates/_agents/scripts/resolve-merge-conflicts.mjs +110 -0
  159. package/templates/_agents/scripts/resolve-merge-conflicts.test.mjs +87 -0
  160. package/templates/_agents/scripts/rewrite-file.mjs +133 -0
  161. package/templates/_agents/scripts/rewrite-file.test.mjs +148 -0
  162. package/templates/_agents/scripts/rule-budget.mjs +83 -0
  163. package/templates/_agents/scripts/run-tests.mjs +99 -0
  164. package/templates/_agents/scripts/run-tests.test.mjs +105 -0
  165. package/templates/_agents/scripts/solidify-task.test.mjs +1 -1
  166. package/templates/_agents/scripts/stage-gates.mjs +38 -21
  167. package/templates/_agents/scripts/trust-mode.mjs +4 -4
  168. package/templates/_agents/scripts/trust-mode.test.mjs +1 -1
  169. package/templates/_agents/scripts/verify.mjs +135 -58
  170. package/templates/_agents/scripts/verify.test.mjs +156 -47
  171. package/templates/_agents/scripts/wf-journal.mjs +8 -4
  172. package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
  173. package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
  174. package/templates/_agents/scripts/workflow-board-server.test.mjs +2 -2
  175. package/templates/_agents/scripts/workflow-enums.test.mjs +14 -5
  176. package/templates/_agents/scripts/workflows-check.mjs +1 -1
  177. package/templates/_agents/scripts-test-exempt.txt +1 -0
  178. package/templates/_agents/skills/papercut-fix/SKILL.md +60 -0
  179. package/templates/_agents/skills/papercut-fix/references/response-template.md +81 -0
  180. package/templates/_agents/workflow-enums.txt +8 -1
  181. package/templates/_agents/workflows/pipeline-closing.md +4 -5
  182. package/templates/_githooks/pre-commit +109 -120
  183. package/templates/_githooks/pre-merge-commit +27 -23
  184. package/templates/_githooks/pre-push +7 -6
  185. package/templates/_github/workflows/kit-ci.yml +4 -3
  186. package/templates/workflow/README.md +23 -5
  187. package/templates/workflow/incidents/_TEMPLATE.md +4 -1
  188. package/templates/workflow/intents/_TEMPLATE.md +4 -2
  189. package/templates/workflow/papercuts.md +2 -0
  190. package/templates/workflow/plans/_TEMPLATE.md +10 -5
  191. package/templates/workflow/regression-checklist.md +1 -0
  192. package/templates/workflow/specs/_TEMPLATE.md +17 -1
  193. package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -89
  194. package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -89
  195. package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -89
  196. package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -89
  197. package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -90
  198. package/templates/_agents/commands/source-sync-check.md +0 -103
  199. package/templates/_agents/hooks/check-pairing-incremental.sh +0 -43
  200. package/templates/_agents/hooks/check-wiki-ledger.sh +0 -18
  201. package/templates/_agents/scripts/check-ledger.mjs +0 -109
  202. package/templates/_agents/scripts/check-ledger.test.mjs +0 -199
  203. package/templates/_agents/scripts/rule-budget.sh +0 -74
  204. package/templates/_agents/scripts/source-sync-check.mjs +0 -156
  205. package/templates/_agents/scripts/source-sync-check.test.mjs +0 -286
  206. package/templates/_agents/workflows/source-sync-repair.md +0 -30
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ // gen-verify-evidence.mjs — 验证绿凭证摘要入库生成器(2026-10-13 自 Shipyard.Material 参考实现回灌,
3
+ // 源仓沿革留痕在包源仓 git 历史与 CHANGELOG——装户径不引实例路径)
4
+ // 背景:verifications.jsonl 是 gitignore 的本地台账(24h 窗口、跨机不可见)——历史提交「当时验证过什么」
5
+ // 的机器事实只活在本地。本脚本把全绿行按 headShort 去重(同 SHA 取最新 ts)渲染进仓内
6
+ // workflow/verify-evidence.md,使绿凭证成为随库持久、跨机可对质的生成物(全量台账仍留本地)。
7
+ // 语义:一行 = 「**验证时刻的工作树**(其基线提交 = 短 SHA,工作树可含未提交改动)过 verify 全量门禁
8
+ // 全绿(门数 / 套件计数)」;非证据链签名——本地台账可伪造的诚实边界不变(check-loop 检查 15 头注释
9
+ // 口径),入库摘要只是把「曾有凭证」从易失升级为难失。
10
+ // 归因提醒:短 SHA 只是**基线提交**,不等于被测内容。关单应「先提交、后跑门」,否则绿行 SHA 指向
11
+ // 未含本次改动的上一提交——同一 SHA 可能既有绿行(脏工作区跑)又有红记录。
12
+ // 追加保全:已入仓的行不因本地台账缺失而被抹掉——台账 per-worktree 且 gitignore,整段重写会让
13
+ // 跨机/换 worktree 的历史凭证消失。合并口径 = 仓内既有行 ∪ 本地台账绿行,同 headShort 取更新 ts
14
+ // (平局取本地台账,保持「重跑刷新凭证」语义)。代价(已接受):仓内错行不会被重生成自动纠正——
15
+ // 须人工改正;若本地台账仍持同一 SHA,重生成会按台账重写该行(须连同台账处理)。
16
+ // 时机:verify.mjs 全绿尾部自动重生成(事件原子化——绿事实与仓内摘要同刻生成,消除双真相分叉)
17
+ // + 关单时随三件套同笔提交;CI 侧重生成于 CI 工作区、不回流本仓,无副作用;--check 只供本地自检。
18
+ // 台账口径:kit 绿行形状 = {ts, exitCode:0, suite:'npm test', gates?, headShort?, passed?, failed?,
19
+ // doc?, runId?}(verify.mjs 落账单源)——本脚本只认 exitCode===0 且 suite==='npm test' 且带 headShort
20
+ // 的行(无 SHA 的旧 schema 行不渲染);passed/failed 语义 = 套件计数(非断言),doc = --doc 绑定单。
21
+ // 用法:node .agents/scripts/gen-verify-evidence.mjs [--root <dir>] [--check]
22
+ // 测试:node .agents/scripts/gen-verify-evidence.test.mjs(fixture 注入 --root)
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
28
+ const argv = process.argv.slice(2);
29
+ const argOf = (k) => {
30
+ const i = argv.indexOf(k);
31
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : '';
32
+ };
33
+ const ROOT = argOf('--root') || path.resolve(SCRIPT_DIR, '..', '..');
34
+ const LEDGER = path.join(ROOT, '.agents', 'verifications.jsonl');
35
+ const OUT = path.join(ROOT, 'workflow', 'verify-evidence.md');
36
+
37
+ // 六列数据行:短SHA / 时间 / 门数 / 套件计数 / 绑定单 / runId
38
+ const ROW_RE = /^\|\s*`([0-9a-f]{7,40})`\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*$/;
39
+
40
+ /** 按本地台账条目渲染数据行 */
41
+ function renderRow(e) {
42
+ const cnt = Number.isFinite(e.passed) ? `PASS ${e.passed} / FAIL ${e.failed ?? 0}` : '—';
43
+ return `| \`${e.headShort}\` | ${e.ts} | ${e.gates ?? '—'} | ${cnt} | ${e.doc || '—'} | ${e.runId || '—'} |`;
44
+ }
45
+
46
+ /**
47
+ * 解析本文件既有数据行(追加保全用)。
48
+ * 只认形态完整的数据行;说明行 / 表头 / 注释一律忽略。读不到文件 = 无既有行。
49
+ */
50
+ function parseCommittedRows() {
51
+ const out = [];
52
+ let disk = '';
53
+ try { disk = fs.readFileSync(OUT, 'utf8'); } catch { return out; }
54
+ for (const raw of disk.split(/\r?\n/)) {
55
+ const m = raw.trim().match(ROW_RE);
56
+ if (m) out.push({ headShort: m[1], ts: m[2].trim(), line: raw.trim() });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function render() {
62
+ const rows = [];
63
+ try {
64
+ for (const line of fs.readFileSync(LEDGER, 'utf8').split(/\r?\n/)) {
65
+ if (!line.trim()) continue;
66
+ try {
67
+ const e = JSON.parse(line);
68
+ if (e.exitCode === 0 && e.suite === 'npm test' && e.headShort) rows.push(e);
69
+ } catch { /* 坏行跳过——台账是 append-only 事实流,不因单行格式失败中断 */ }
70
+ }
71
+ } catch { /* 无台账 = 空表(新装户/新 clone 首跑前) */ }
72
+ // 行统一形态 { headShort, ts, line }。先放**仓内既有行**(追加保全),再叠加本地台账绿行。
73
+ const bySha = new Map();
74
+ for (const r of parseCommittedRows()) bySha.set(r.headShort, r);
75
+ // 同 headShort 取更新 ts(重跑刷新凭证,不堆行);平局取本地台账
76
+ for (const e of rows) {
77
+ const prev = bySha.get(e.headShort);
78
+ if (!prev || String(e.ts) >= String(prev.ts)) bySha.set(e.headShort, { headShort: e.headShort, ts: String(e.ts), line: renderRow(e) });
79
+ }
80
+ const ordered = [...bySha.values()].sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
81
+ const lines = [];
82
+ lines.push('# 验证绿凭证摘要(生成物,勿手改)');
83
+ lines.push('');
84
+ lines.push('> 生成器 `node .agents/scripts/gen-verify-evidence.mjs`(verify.mjs 全绿尾部自动重生成;源 .agents/verifications.jsonl 为本地台账)。');
85
+ lines.push('> 一行 = 验证时刻**工作树**过 verify 全量门禁全绿(短 SHA 为该时刻的**基线提交**,工作树可能含未提交改动——关单宜「先提交、后跑门」);`套件计数` 为测试门编排器的套件 PASS/FAIL(无汇总行时缺省);`绑定单` 为 --doc 绑定的工作流文档(不带的行不给任何单背书)。非签名链——诚实边界同 check-loop 检查 15。');
86
+ lines.push('> 行来源 = 本地台账绿行 ∪ 本文件既有行(**追加保全**:跨机 / 换 worktree 不失历史;代价:错行不会被重生成自动纠正,须人工改正后重跑)。');
87
+ lines.push('');
88
+ lines.push('| 提交(短SHA) | 时间(UTC) | 门数 | 套件计数 | 绑定单 | runId |');
89
+ lines.push('|---|---|---|---|---|---|');
90
+ for (const e of ordered) lines.push(e.line);
91
+ lines.push('');
92
+ lines.push(`<!-- GENERATED:END — 共 ${ordered.length} 行;gen-verify-evidence.mjs 生成(既有行追加保全,勿手改) -->`);
93
+ return lines.join('\n') + '\n';
94
+ }
95
+
96
+ const text = render();
97
+ if (argv.includes('--check')) {
98
+ let disk = '';
99
+ try { disk = fs.readFileSync(OUT, 'utf8'); } catch { /* 缺文件走漂移分支 */ }
100
+ if (disk === text) { console.log('[verify-evidence] ✅ 与磁盘一致'); process.exit(0); }
101
+ console.error('[verify-evidence] 漂移:本地台账有新绿行未入摘要——关单时跑 node .agents/scripts/gen-verify-evidence.mjs 并随同提交');
102
+ process.exit(1);
103
+ }
104
+ fs.mkdirSync(path.dirname(OUT), { recursive: true });
105
+ fs.writeFileSync(OUT, text);
106
+ // 数据行计数:直接数六列数据行,不做总行数算术(生成物自描述)
107
+ console.log(`✅ 已生成 ${path.relative(process.cwd(), OUT)}(绿凭证 ${text.split(/\r?\n/).filter((l) => ROW_RE.test(l.trim())).length} 行)`);
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ // gen-verify-evidence.test.mjs — 验证凭证摘要生成器 fixture 套件(2026-10-13 自 Shipyard.Material
3
+ // 参考实现回灌时随件移植;台账口径改 kit 绿行形状 suite:'npm test' + 绑定单 doc 列)
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
11
+ const SCRIPT = path.join(SCRIPT_DIR, 'gen-verify-evidence.mjs');
12
+
13
+ let pass = 0;
14
+ let fail = 0;
15
+ const check = (desc, cond, detail = '') => {
16
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
17
+ else { fail++; console.log(`FAIL ${desc}${detail ? '\n ' + detail : ''}`); }
18
+ };
19
+ const mkfix = (rows) => {
20
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-evidence-test-'));
21
+ fs.mkdirSync(path.join(d, '.agents'), { recursive: true });
22
+ if (rows) fs.writeFileSync(path.join(d, '.agents', 'verifications.jsonl'), rows.map((r) => JSON.stringify(r)).join('\n') + '\n');
23
+ return d;
24
+ };
25
+ const run = (root, ...args) => {
26
+ const r = spawnSync(process.execPath, [SCRIPT, '--root', root, ...args], { encoding: 'utf8' });
27
+ return { code: r.status, out: `${r.stdout || ''}${r.stderr || ''}` };
28
+ };
29
+ // kit 绿行形状(verify.mjs 落账单源):suite 'npm test'、passed/failed = 套件计数、doc = --doc 绑定单
30
+ const GREEN = (headShort, ts, extra = {}) => ({ ts, exitCode: 0, suite: 'npm test', gates: 7, passed: 45, failed: 0, headShort, ...extra });
31
+
32
+ // 场景 1:绿行渲染 + 同 SHA 去重取最新 + 失败行/无 SHA 行/坏行排除
33
+ {
34
+ const d = mkfix([
35
+ GREEN('abc1234', '2026-10-09T08:34:08Z'),
36
+ GREEN('abc1234', '2026-10-09T09:14:44Z'), // 同 SHA 更新 ts → 只留此行
37
+ GREEN('def5678', '2026-10-09T10:00:00Z', { runId: 'PIPE-1', doc: 'workflow/intents/<日期>-<主题>.md' }),
38
+ { ts: '2026-10-09T11:00:00Z', exitCode: 1, suite: 'check-loop', note: 'exit 1' }, // 失败行
39
+ { ts: '2026-10-09T12:00:00Z', exitCode: 0, suite: 'npm test', gates: 7 }, // 无 headShort(非 git 环境/旧 schema)
40
+ '{broken json', // 坏行
41
+ ]);
42
+ const r = run(d);
43
+ const doc = fs.readFileSync(path.join(d, 'workflow', 'verify-evidence.md'), 'utf8');
44
+ check('场景 1:只渲染绿行且同 SHA 取最新(abc1234 只出现一次且为 09:14)',
45
+ r.code === 0 && (doc.match(/\| `abc1234`/g) || []).length === 1 && doc.includes('2026-10-09T09:14:44Z') && doc.includes('`def5678`') && doc.includes('PIPE-1') && !doc.includes('2026-10-09T11:00:00Z') && !doc.includes('broken'),
46
+ doc);
47
+ check('场景 1b:门数、套件计数与绑定单列渲染', doc.includes('| 7 | PASS 45 / FAIL 0 |') && doc.includes('workflow/intents/<日期>-<主题>.md'), doc);
48
+ }
49
+ // 场景 2:无台账 → 空表骨架(新 clone 首跑)
50
+ {
51
+ const d = mkfix(null);
52
+ const r = run(d);
53
+ const doc = fs.readFileSync(path.join(d, 'workflow', 'verify-evidence.md'), 'utf8');
54
+ check('场景 2:无台账生成空表骨架', r.code === 0 && doc.includes('# 验证绿凭证摘要') && !doc.includes('`abc'), doc);
55
+ }
56
+ // 场景 3:--check 一致/漂移/再生成恢复
57
+ {
58
+ const d = mkfix([GREEN('aaa1111', '2026-10-09T08:00:00Z')]);
59
+ run(d);
60
+ check('场景 3a:生成后 --check 一致', run(d, '--check').code === 0);
61
+ fs.writeFileSync(path.join(d, '.agents', 'verifications.jsonl'), JSON.stringify(GREEN('bbb2222', '2026-10-09T09:00:00Z')) + '\n');
62
+ const r2 = run(d, '--check');
63
+ check('场景 3b:台账新增绿行 → --check 漂移 exit 1', r2.code === 1 && r2.out.includes('关单时跑'), r2.out);
64
+ run(d);
65
+ check('场景 3c:再生成后恢复一致', run(d, '--check').code === 0);
66
+ }
67
+
68
+ // 场景 4:追加保全——本地台账缺某 SHA 时,仓内既有行不被抹掉(跨机/换 worktree 不失历史)
69
+ {
70
+ const d = mkfix([GREEN('aaa1111', '2026-10-09T08:00:00Z'), GREEN('ccc3333', '2026-10-09T09:00:00Z')]);
71
+ run(d);
72
+ const before = fs.readFileSync(path.join(d, 'workflow', 'verify-evidence.md'), 'utf8');
73
+ // 换 worktree / 换机:本地台账被覆盖为只含 ddd4444
74
+ fs.writeFileSync(path.join(d, '.agents', 'verifications.jsonl'), JSON.stringify(GREEN('ddd4444', '2026-10-09T10:00:00Z')) + '\n');
75
+ const r = run(d);
76
+ const doc = fs.readFileSync(path.join(d, 'workflow', 'verify-evidence.md'), 'utf8');
77
+ check('场景 4:台账换机后仓内既有行保全(aaa1111/ccc3333 仍在,ddd4444 补入)',
78
+ r.code === 0 && doc.includes('`aaa1111`') && doc.includes('`ccc3333`') && doc.includes('`ddd4444`') && before.includes('`aaa1111`'),
79
+ doc);
80
+ check('场景 4b:保全后 --check 一致(幂等)', run(d, '--check').code === 0);
81
+ }
82
+
83
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
84
+ process.exit(fail ? 1 : 0);
@@ -19,6 +19,10 @@ import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { createRequire } from 'node:module';
22
+ import { readLedgerRows } from './ledger-loader.mjs';
23
+ // 委派台账分片感知读(2026-10-13 ledger-shard-ledgers 回灌):合并文本(主台账 ∪ delegations.d/*.md)
24
+ // 由本件经 readLedgerText 取(读侧单源),经 opts.text 注入 agg.readLedger——.cjs 库模式无法自装载 .mjs。
25
+ import { readLedgerText } from './ledger-shards.mjs';
22
26
 
23
27
  const require = createRequire(import.meta.url);
24
28
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -82,24 +86,22 @@ function collectDoneIntents(root) {
82
86
  // collectDoneMap:台账 earliest done 行(坏行容忍,doc 正斜杠根相对——沿检查 14/15 口径)
83
87
  function collectDoneMap(root) {
84
88
  const doneMap = new Map();
85
- const ledgerPath = path.join(root, '.agents', 'confirmations.jsonl');
86
- if (!fs.existsSync(ledgerPath)) return doneMap;
87
- for (const line of fs.readFileSync(ledgerPath, 'utf8').split(/\r?\n/)) {
88
- if (!line.trim()) continue;
89
- try {
90
- const e = JSON.parse(line);
91
- if (e && e.stage === 'done' && typeof e.doc === 'string' && e.doc.startsWith('workflow/intents/')) {
92
- const prev = doneMap.get(e.doc);
93
- if (prev === undefined || e.ts < prev) doneMap.set(e.doc, e.ts);
94
- }
95
- } catch { /* 坏行容忍跳过 */ }
89
+ // 读法收敛 ledger-loader(2026-10-12 ledger-loader-p1):earliest done 行取自时间戳比较,
90
+ // 与行序无关,故并入台账视图不改变本函数语义(分片行同样参与「最早」判定)
91
+ for (const e of readLedgerRows(root)) {
92
+ if (e && e.stage === 'done' && typeof e.doc === 'string' && e.doc.startsWith('workflow/intents/')) {
93
+ const prev = doneMap.get(e.doc);
94
+ if (prev === undefined || e.ts < prev) doneMap.set(e.doc, e.ts);
95
+ }
96
96
  }
97
97
  return doneMap;
98
98
  }
99
99
 
100
100
  // qualityRows:月度质量行(判据全部复用 agg——本函数只做分组与格式化)
101
101
  export function qualityRows(root) {
102
- const rows = agg.readLedger(root, { soft: true });
102
+ // 分片感知(2026-10-13 ledger-shard-ledgers):text = 主台账 ∪ delegations.d/*.md;空文本(无台账
103
+ // 装户)→ rows [],soft 优雅降级语义不变。
104
+ const rows = agg.readLedger(root, { soft: true, text: readLedgerText(root, 'delegations') });
103
105
  const byMonth = {};
104
106
  for (const r of rows) (byMonth[r.date.slice(0, 7)] ||= []).push(r);
105
107
  const months = Object.keys(byMonth).sort();
@@ -133,9 +135,9 @@ export function qualityRows(root) {
133
135
  // 故本节恒为静态文案:**任何时候生成本文件,字节恒等**(测试 ⑤ 的两条 --check exit 0 钉的就是这条)。
134
136
  export function gateRoiLines() {
135
137
  return [
136
- '- 采集:`node .agents/scripts/check-loop.mjs --gate-stats`(数据落 `.agents/cache/gate-stats.jsonl`,该目录已 gitignore;不带 flag 时零写入、输出逐字节不变)',
138
+ '- 采集:`node .agents/scripts/check-loop.mjs --all --gate-stats`(--all 全段成本面——2026-10-13 分道后裸跑只记硬门段;数据落 `.agents/cache/gate-stats.jsonl`,该目录已 gitignore;不带 flag 时零写入、输出逐字节不变)',
137
139
  '- 看数值:`node .agents/scripts/agg-gate-stats.mjs [--days 30]`——输出成本排序 + 两类决策依据:噪声率 top(该收窄或该修)、死检查(该拆或该降级);无采集数据时该命令自己提示「未采集」并给出采集命令',
138
- '- 口径:耗时**含子进程时间**(检查 11 调两个生成器 `--check`、检查 13 调 `sh rule-budget.sh`)——门禁真实成本,不做剔除;样本 <5 次时只作线索,不作决策依据',
140
+ '- 口径:耗时**含子进程时间**(检查 11 调两个生成器 `--check`、检查 13 调 `rule-budget.mjs`)——门禁真实成本,不做剔除;样本 <5 次时只作线索,不作决策依据',
139
141
  '- 本节为何不铺数值:本文件被检查 11 漂移门覆盖,运行数据每跑一次门禁即变一次,铺进来会让每次采集都产一条漂移 WARN(不可消除噪声)',
140
142
  ];
141
143
  }
@@ -163,8 +163,9 @@ const INTENT = (fm) => `---\n${fm}\n---\n# INTENT — x\n`;
163
163
 
164
164
  // 无采集态(先于造 cache 生成):节恒为静态文案,故与有无数据无关
165
165
  const rl = gateRoiLines();
166
+ // 采集命令随 2026-10-13 分道带 --all(全段成本面;裸 --gate-stats 只记硬门段)
166
167
  check('门禁ROI:节内容给出采集命令 + 看数值命令 + 无数据时 CLI 自带「未采集」提示(优雅降级不落空节)',
167
- rl.join('\n').includes('check-loop.mjs --gate-stats') && rl.join('\n').includes('agg-gate-stats.mjs') && rl.join('\n').includes('未采集'), rl.join('\n'));
168
+ rl.join('\n').includes('check-loop.mjs --all --gate-stats') && rl.join('\n').includes('agg-gate-stats.mjs') && rl.join('\n').includes('未采集'), rl.join('\n'));
168
169
  check('门禁ROI:节内容不含任何耗时/次数运行数值(防检查 11 每次采集报漂移的设计核心)',
169
170
  !/\d+\s*(ms|s)\b/.test(rl.join('\n')) && !/运行\s*\d/.test(rl.join('\n')), rl.join('\n'));
170
171
  run();
@@ -4,15 +4,16 @@
4
4
  // 目的:把「语料膨胀 + 常驻面体积」变成可观测趋势——每月(或按需)跑一次,往 workflow/metrics.md 追加/更新一行。
5
5
  // 口径:文档 = 磁盘(对照 gen-workflow-index / 看板,排除 _TEMPLATE.md);常驻面 = `.agents/rule-budgets.txt`
6
6
  // 各条实测(单源,含预算上限),括号内给「预算占用峰值项」。
7
- // 字节口径声明(p2-batch2):本脚本用工作区 statSync 字节(CRLF 检出态会偏高),rule-budget.sh 用
7
+ // 字节口径声明(p2-batch2):本脚本用工作区 statSync 字节(CRLF 检出态会偏高),rule-budget.mjs 用
8
8
  // 索引 LF 归一字节——趋势观测可对比,但与门禁判定数值存在系统性差异,勿互相替用。
9
9
  // 不做 --check / 不挂门禁:快照是历史记录——任何文档改动都会让「当前值」漂移,拿它做漂移校验只会常红;
10
- // 规则面的机器约束由 pre-commit 的 rule-budget.sh 负责,本脚本只负责「看见趋势」。
10
+ // 规则面的机器约束由 pre-commit 的 rule-budget.mjs 负责,本脚本只负责「看见趋势」。
11
11
  // 测试:node .agents/scripts/gen-workflow-metrics.test.mjs(fixture 回归,须全绿)
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { loadEnums } from './workflow-enums.mjs';
16
+ import { readLedgerRows as loadLedgerRows } from './ledger-loader.mjs';
16
17
 
17
18
  const USAGE = `用法:node .agents/scripts/gen-workflow-metrics.mjs [--month YYYY-MM] [--dry-run] [--help]
18
19
 
@@ -43,34 +44,10 @@ const WORKFLOW = 'workflow';
43
44
  const METRICS_P = path.join(WORKFLOW, 'metrics.md');
44
45
  const BUDGETS = path.join('.agents', 'rule-budgets.txt');
45
46
  const DOC_TYPES = ['intents', 'specs', 'plans', 'incidents'];
46
- // 活跃口径单源(board-kb-p1:枚举字面量硬编码退役——workflow-enums.txt 是唯一源);
47
- // 枚举路径锚定脚本位置(fixture 测试 cwd 在临时目录,按 cwd 解析会 ENOENT)。
48
- // **不能用固定上溯级数**(2026-09-28 修正):本脚本在两处运行、深度不同——
49
- // 包源 `templates/_agents/scripts/` → 上溯 3 级到仓库根
50
- // 装户 `.agents/scripts/` → 上溯 2 级到安装根
51
- // 首版硬编码 3 级,故**在每个装户安装里都解析不到枚举文件**(报错路径 `<install>/../.agents/…`)。
52
- // 实测暴露路径:CI 新增「shipped 套件」步骤后,`.agents/scripts/gen-workflow-metrics.test.mjs`
53
- // 场景 1 即 exit 1(workflow-enums 单源文件不可读),而包源侧同字节跑出全绿。
54
- // 改为**逐级上溯探测**:找到含 `.agents/workflow-enums.txt` 的那一级即用,找不到则回退标准位置让
55
- // loadEnums fail-loud(保持「缺失即响亮」的既有纪律,不静默兜底)。
56
- // 包源仓布局补丁(装户端内容清理后):纯包源仓无 `.agents/`,枚举单源在 `templates/_agents/` 下——
57
- // 上一级目录名是 `_agents` 而非 `.agents`。探测同步认这两种布局,回退仍走 fail-loud。
58
- const resolveEnumsPath = () => {
59
- const scriptDir = path.dirname(fileURLToPath(import.meta.url));
60
- let dir = scriptDir;
61
- for (let i = 0; i < 6; i++) {
62
- const cand = path.join(dir, '.agents', 'workflow-enums.txt');
63
- if (fs.existsSync(cand)) return cand;
64
- const parent = path.dirname(dir);
65
- if (parent === dir) break; // 到根
66
- dir = parent;
67
- }
68
- // 包源径:脚本在 templates/_agents/scripts/,枚举在 templates/_agents/workflow-enums.txt
69
- const pkgSrc = path.join(scriptDir, '..', 'workflow-enums.txt');
70
- if (fs.existsSync(pkgSrc)) return pkgSrc;
71
- return path.join(scriptDir, '..', '..', '.agents', 'workflow-enums.txt');
72
- };
73
- const ENUMS = loadEnums(resolveEnumsPath());
47
+ // 活跃口径单源(workflow-enums.txt)。与脚本同层的上一级:
48
+ // 包源 templates/_agents/workflow-enums.txt,装户副本 .agents/workflow-enums.txt。
49
+ // 拷贝后相对位置不变,不按仓库根猜测另一套目录。
50
+ const ENUMS = loadEnums(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'workflow-enums.txt'));
74
51
  const ACTIVE_STATUS = [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']];
75
52
  const BEGIN = '<!-- GENERATED:BEGIN — gen-workflow-metrics.mjs 整段重写,手工说明写在本行之前 -->';
76
53
  const END = '<!-- GENERATED:END -->';
@@ -138,7 +115,7 @@ function scanSurface() {
138
115
  max = sum;
139
116
  label = `${pat}(合计)`;
140
117
  } else if (pat.includes('*')) {
141
- // 词表约定 glob = 基名内单个 `*`(<前缀>*<后缀>),目录段不支持通配——与 rule-budget.sh 的 shell glob 同口径。
118
+ // 词表约定 glob = 基名内单个 `*`(<前缀>*<后缀>),目录段不支持通配——与 rule-budget.mjs 的 glob 展开同口径。
142
119
  // 多 `*` / 目录段含 `*` 出词表:警告跳过,不做「只按后缀 endsWith」的放宽式匹配(2026-09-24 收口:
143
120
  // 曾按首个 * 取后缀,`pl*.md` 会误吃目录内全部 .md、`a*b` 会误吃一切 b 结尾文件)
144
121
  const stars = pat.split('*').length - 1;
@@ -176,12 +153,11 @@ const idxActive = Number((idxText.match(/^## 活跃((\d+))/m) || [])[1] ?? N
176
153
 
177
154
  // ---- 闭环漏斗(2026-10-02 ledger-funnel-metrics 立法)----
178
155
  // 只读台账一手字段(doc/stage/ts/source),坏行跳过(与检查 15/19 同容错);观测面不挂门禁、不新增文档断言(检查 16 零接触)。
179
- const LEDGER_P = path.join('.agents', 'confirmations.jsonl');
156
+ // 台账读取收敛 ledger-loader 单源(2026-10-12 ledger-loader-p1)——口径不变(只读一手字段 doc/stage/ts/source、
157
+ // 坏行跳过,与检查 15/19 同容错)。**本步刻意保留「全量行不去重」**:去重会改漏斗 ledgerRows 计数口径,
158
+ // 属口径变更,须随分片功能同批生效,不得混入单源化本步。
180
159
  function readLedgerRows() {
181
- if (!fs.existsSync(LEDGER_P)) return [];
182
- return fs.readFileSync(LEDGER_P, 'utf8').split(/\r?\n/)
183
- .map((l) => { try { return JSON.parse(l); } catch { return null; } })
184
- .filter((e) => e && typeof e === 'object');
160
+ return loadLedgerRows();
185
161
  }
186
162
 
187
163
  function funnelFromLedger(entries, month) {
@@ -0,0 +1,45 @@
1
+ // git-core.mjs — 引擎 git 调用单源(2026-10-10 收敛系列 5:合并 check-loop-kit spawnGit/gitOut 与
2
+ // stage-gates gitRun 的「同口径两份实现」(该债由 stage-gates 头注登记、本单偿清))。
3
+ // 语义(取两侧并集,全部调用方共享):
4
+ // - 参数数组直 spawn(不走 shell——Windows cmd 不认单引号);git.exe 显式解析(Windows spawn 不补扩展名)
5
+ // - CHECK_LOOP_GIT 环境变量注入钩子(测试替身:指向不存在路径可稳定触发 spawn 异常)
6
+ // - 瞬时类错误(EAGAIN/EPERM/EMFILE/EINTR)单次重试;重试终败响亮出账(进程级去重恰一条)——
7
+ // 「非 git 仓」(status≠0、无 error)仍是合法装户语义、静默降级
8
+ // - 只读查询记忆化:同参(含 cwd)复用结果;带 error 的基础设施失败不缓存(保留重试语义)
9
+ // - maxBuffer 32MB(pickaxe/name-only 大输出)
10
+ import { spawnSync } from 'node:child_process';
11
+
12
+ export const GIT = process.env.CHECK_LOOP_GIT || (process.platform === 'win32' ? 'git.exe' : 'git');
13
+
14
+ const GIT_TRANSIENT = new Set(['EAGAIN', 'EPERM', 'EMFILE', 'EINTR']);
15
+ const GIT_RESULT_CACHE = new Map(); // 键 = cwd + NUL + args.join(NUL)(多仓安全:confirm-doc 等 root 参数调用方)
16
+ let infraWarned = false;
17
+ function warnInfra(args, code) {
18
+ if (infraWarned) return;
19
+ infraWarned = true;
20
+ console.error(`git-core: git 探测异常 ${args[0]}(${code})——按非 git 仓降级,检查面可能收窄`);
21
+ }
22
+
23
+ export function spawnGit(args, cwd) {
24
+ const cacheKey = cwd + '\u0000' + args.join('\u0000');
25
+ if (GIT_RESULT_CACHE.has(cacheKey)) return GIT_RESULT_CACHE.get(cacheKey);
26
+ const opts = { cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
27
+ let r = spawnSync(GIT, args, opts);
28
+ if (r.error) {
29
+ if (GIT_TRANSIENT.has(r.error.code)) r = spawnSync(GIT, args, opts);
30
+ if (r.error) warnInfra(args, r.error.code || r.error.message);
31
+ }
32
+ if (!r.error) GIT_RESULT_CACHE.set(cacheKey, r);
33
+ return r;
34
+ }
35
+
36
+ export function gitOut(args, cwd, { ok = () => true } = {}) {
37
+ const r = spawnGit(args, cwd);
38
+ if (r.error || r.status !== 0 || !ok(r)) return null;
39
+ return r.stdout || '';
40
+ }
41
+
42
+ // --rev 模式 ROOT 切到临时 worktree 后调:缓存键含 cwd,本不必整体作废;保留接口防御未来键形变化
43
+ export function clearGitCache() {
44
+ GIT_RESULT_CACHE.clear();
45
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ // git-merge-jsonl.mjs — Git 自定义合并驱动:JSONL 追加式台账并集去重(2026-10-12 ledger-merge-driver)
3
+ // 解决什么:确认台账 `.agents/confirmations.jsonl` 是「尾部追加 + 多人/多 worktree 并行」形态。
4
+ // 两个分支各自追加行 → 同一文件末尾分叉 → git 默认三方合并把它当文本冲突,人工解且极易解错
5
+ // (常见错解:整取一侧,**静默丢掉另一侧的确认行**——门禁事后无从发现,人不可信面直接扩大)。
6
+ // 本驱动把合并降级为确定性的**并集去重**:两侧行全保留,无冲突可解。
7
+ //
8
+ // 接线(两步,缺任一步则退化为文本冲突——不静默丢数据,但要人工解):
9
+ // ① git config merge.jsonl-union.driver "node .agents/scripts/git-merge-jsonl.mjs %O %A %B"
10
+ // ② 目标仓 .gitattributes 含:.agents/*.jsonl merge=jsonl-union
11
+ // flow-kit init 会自动做 ①;②由 templates/.gitattributes 下发(**owned**——存量装户 sync 永不覆盖,
12
+ // 需自行补一行,见 README 台账并发口径)。
13
+ //
14
+ // 调用协议(由 .gitattributes 关联 merge=jsonl-union 触发):
15
+ // %O 祖先版本路径 | %A 当前分支版本(合并结果必须覆写回此文件)| %B 对方分支版本
16
+ //
17
+ // **为什么 union 去重不会破坏台账哈希链**(ledgerChainHash 的前提,勿破坏):
18
+ // 去重按**整行文本**精确比对,不解析、不改写行内任何字段 → 合并后每行文本与合并前逐字相同
19
+ // → JSON.parse 保持键序 → ledgerChainHash(row) 重算结果不变。哈希链的**行内自洽**因此恒成立。
20
+ // (跨合并边界的**链首尾衔接**是另一回事,由链域改造承接,见 check-loop 检查 15。)
21
+ import fs from 'node:fs';
22
+
23
+ // mergeJsonlLines:并集去重。保持 current(%A)既有行的原始顺序,其余 other(%B)新行追加末尾。
24
+ // 全部行按整行文本精确去重;CRLF 归一为 LF(台账判据与 check-ledger-invariant 的 blob 口径对齐)。
25
+ export function mergeJsonlLines(currentContent, otherContent) {
26
+ const norm = (str) => (str || '').replace(/\r\n/g, '\n').split('\n').filter((l) => l.trim().length > 0);
27
+ const currentLines = norm(currentContent);
28
+ const otherLines = norm(otherContent);
29
+
30
+ const seen = new Set(currentLines);
31
+ const result = [...currentLines];
32
+ for (const line of otherLines) {
33
+ if (!seen.has(line)) { seen.add(line); result.push(line); }
34
+ }
35
+ return result.length > 0 ? result.join('\n') + '\n' : '';
36
+ }
37
+
38
+ // CLI 执行入口(isMain 守卫沿本仓惯例:被 import 时零副作用,供 compact-ledgers 复用本函数)
39
+ const isMain = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('git-merge-jsonl.mjs');
40
+ if (isMain) {
41
+ // %O(祖先版本)本驱动用不到——并集去重只需 %A ∪ %B;保留位置占位以对齐 git 的 %O %A %B 调用协议
42
+ const [, , _ancestorPath, currentPath, otherPath] = process.argv;
43
+
44
+ if (!currentPath || !otherPath) {
45
+ console.error('用法:node git-merge-jsonl.mjs %O %A %B(由 .gitattributes 的 merge=jsonl-union 触发,不供手工调用)');
46
+ process.exit(1);
47
+ }
48
+
49
+ // 原子覆写:直接 writeFileSync 会先截断原文件,进程中途崩溃将留下半写文件——git 合并中途失败
50
+ // 正是最需要「要么旧要么新」的场合。改为同目录临时文件 + rename。
51
+ const tmpPath = `${currentPath}.tmp`;
52
+ try {
53
+ const currentContent = fs.existsSync(currentPath) ? fs.readFileSync(currentPath, 'utf8') : '';
54
+ const otherContent = fs.existsSync(otherPath) ? fs.readFileSync(otherPath, 'utf8') : '';
55
+ fs.writeFileSync(tmpPath, mergeJsonlLines(currentContent, otherContent), 'utf8');
56
+ fs.renameSync(tmpPath, currentPath);
57
+ process.exit(0);
58
+ } catch (err) {
59
+ try { fs.unlinkSync(tmpPath); } catch { /* tmp 可能尚未创建 */ }
60
+ console.error(`[git-merge-jsonl] 合并失败: ${err.message}——已保留原文件,git 将报为冲突(人工解,勿整取一侧丢行)`);
61
+ process.exit(1);
62
+ }
63
+ }
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env node
2
+ // git-merge-jsonl.test.mjs — 台账并集合并驱动 fixture 套件(2026-10-12 ledger-merge-driver)
3
+ // 覆盖:并集去重 / %A 行序保持 / %B 新行追加 / 空侧 / CRLF 归一 / 幂等 / CLI 原子覆写与失败码,
4
+ // 以及**行内哈希重算不变**(union 不破坏台账哈希链的前提,见驱动头部注释)。
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
12
+ const SCRIPT = path.join(SCRIPT_DIR, 'git-merge-jsonl.mjs');
13
+ const { mergeJsonlLines } = await import(pathToFileURL(SCRIPT).href);
14
+ const { ledgerChainHash } = await import(pathToFileURL(path.join(SCRIPT_DIR, 'policy.mjs')).href);
15
+
16
+ let pass = 0, fail = 0;
17
+ const check = (name, ok, detail = '') => {
18
+ if (ok) { pass++; console.log(` ok ${name}`); }
19
+ else { fail++; console.error(`FAIL ${name}${detail ? ' — ' + detail : ''}`); }
20
+ };
21
+
22
+ const L = (...xs) => xs.filter(Boolean).join('\n') + '\n';
23
+ const r1 = L('{"ts":"1","doc":"a","stage":"approved"}', '{"ts":"2","doc":"a","stage":"done"}');
24
+ const r2 = L('{"ts":"3","doc":"b","stage":"approved"}');
25
+ const shared = L('{"ts":"9","doc":"c","stage":"approved"}');
26
+
27
+ // ============ 场景 1:%A 独有 → 全保留,%A 顺序不变 ============
28
+ {
29
+ const out = mergeJsonlLines(r1, '');
30
+ check('S1 %B 空时结果 == %A(字节级)', out === r1, JSON.stringify(out));
31
+ }
32
+
33
+ // ============ 场景 2:%B 有 %A 没有的新行 → 追加末尾 ============
34
+ {
35
+ const out = mergeJsonlLines(r1, r2);
36
+ check('S2 %B 新行追加到末尾', out === r1 + r2, JSON.stringify(out));
37
+ }
38
+
39
+ // ============ 场景 3:共有行只留一份(去重) ============
40
+ {
41
+ const out = mergeJsonlLines(r1 + shared, shared + r2);
42
+ const lines = out.trim().split('\n');
43
+ const cnt = lines.filter((l) => l === shared.trim()).length;
44
+ check('S3 共有行只保留一份', cnt === 1, `实得 ${cnt}`);
45
+ check('S3 %A 既有行顺序保持在前', lines[0].includes('"ts":"1"') && lines[1].includes('"ts":"2"'), JSON.stringify(lines));
46
+ check('S3 行数 = %A 行数 + %B 新增行数', lines.length === 4, `实得 ${lines.length}`);
47
+ }
48
+
49
+ // ============ 场景 4:双空 → 空串 ============
50
+ {
51
+ check('S4 双空返回空串', mergeJsonlLines('', '') === '');
52
+ check('S4 null 视为空', mergeJsonlLines(null, null) === '');
53
+ }
54
+
55
+ // ============ 场景 5:CRLF 归一为 LF(与 check-ledger-invariant 的 blob 口径对齐) ============
56
+ {
57
+ const out = mergeJsonlLines('{"ts":"1"}\r\n', '{"ts":"2"}\r\n');
58
+ check('S5 CRLF 归一为 LF', !out.includes('\r') && out === L('{"ts":"1"}', '{"ts":"2"}'), JSON.stringify(out));
59
+ check('S5 空行被剔除', mergeJsonlLines('{"ts":"1"}\n\n\n', '') === L('{"ts":"1"}'));
60
+ }
61
+
62
+ // ============ 场景 6:幂等——同输入重复合并结果不变(compact 重跑安全的前提) ============
63
+ {
64
+ const once = mergeJsonlLines(r1, r2);
65
+ check('S6 重复合并幂等', mergeJsonlLines(once, r2) === once && mergeJsonlLines(r1, once) === once);
66
+ }
67
+
68
+ // ============ 场景 7【关键互证】:union 保留行文本 → 行内哈希重算不变 ============
69
+ // 背景:台账哈希链 ledgerChainHash(row) = sha256(prevHash + JSON.stringify(row)),依赖 JSON.stringify
70
+ // 的键序 = 写入序。union 按整行文本去重、不解析不改写 → 行文本逐字不变 → 键序不变 → 重算不变。
71
+ // 这条一旦被破坏(例如将来有人改成「解析后重排键再序列化」),台账链会整片失效且极难排查。
72
+ {
73
+ const mkRow = (prevHash, fields) => {
74
+ const row = { ...fields, prevHash };
75
+ return { ...row, hash: ledgerChainHash(fields, prevHash) };
76
+ };
77
+ const chained = [
78
+ JSON.stringify(mkRow('', { ts: '2026-10-01T00:00:00Z', doc: 'workflow/intents/a.md', stage: 'approved', fingerprint: 'a'.repeat(64), scope: { files: ['x.md'], sha256: 's' } })),
79
+ JSON.stringify(mkRow('PREVHASH', { ts: '2026-10-02T00:00:00Z', doc: 'workflow/intents/a.md', stage: 'done', fingerprint: 'a'.repeat(64) })),
80
+ ].join('\n') + '\n';
81
+ const mine = chained;
82
+ const theirs = L('{"ts":"2026-10-03T00:00:00Z","doc":"workflow/intents/b.md","stage":"approved","fingerprint":"b","extra":1}');
83
+ const merged = mergeJsonlLines(mine, theirs);
84
+ const rows = merged.trim().split('\n').map((l) => JSON.parse(l));
85
+ const selfOk = rows.every((r) => {
86
+ if (typeof r.hash !== 'string') return true; // 无链字段行(存量/注记)不参与
87
+ const { hash, prevHash, ...rest } = r;
88
+ return ledgerChainHash(rest, prevHash ?? '') === hash;
89
+ });
90
+ check('S7 合并后每行行内哈希仍可重算一致', selfOk, JSON.stringify(rows));
91
+ check('S7 合并后行文本与合并前逐字相同(去重不改写字段)',
92
+ merged.trim().split('\n')[0] === mine.trim().split('\n')[0]);
93
+ }
94
+
95
+ // ============ 场景 8:CLI 真跑 —— 覆写 %A、exit 0、无残留 .tmp ============
96
+ {
97
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-merge-'));
98
+ const A = path.join(d, 'A'), B = path.join(d, 'B'), O = path.join(d, 'O');
99
+ fs.writeFileSync(A, r1, 'utf8');
100
+ fs.writeFileSync(B, r1 + r2, 'utf8');
101
+ fs.writeFileSync(O, L(), 'utf8');
102
+ const r = spawnSync(process.execPath, [SCRIPT, O, A, B], { encoding: 'utf8' });
103
+ check('S8 CLI exit 0', r.status === 0, `exit=${r.status} ${r.stderr || ''}`);
104
+ check('S8 CLI 结果写入 %A', fs.readFileSync(A, 'utf8') === r1 + r2, JSON.stringify(fs.readFileSync(A, 'utf8')));
105
+ check('S8 无残留 .tmp', !fs.existsSync(`${A}.tmp`));
106
+ // 缺参数 → 用法提示 + exit 1(driver 由 git 触发,参数缺失属接线错误,应响亮)
107
+ const bad = spawnSync(process.execPath, [SCRIPT, O], { encoding: 'utf8' });
108
+ check('S8 缺参数 exit 1 且出用法', bad.status === 1 && bad.stderr.includes('用法'), `exit=${bad.status}`);
109
+ fs.rmSync(d, { recursive: true, force: true });
110
+ }
111
+
112
+ // ============ 场景 9:%A 不存在(对侧新增文件)→ 以 %B 内容落盘 ============
113
+ {
114
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-merge2-'));
115
+ const A = path.join(d, 'A'), B = path.join(d, 'B'), O = path.join(d, 'O');
116
+ fs.writeFileSync(B, r2, 'utf8');
117
+ fs.writeFileSync(O, L(), 'utf8');
118
+ const r = spawnSync(process.execPath, [SCRIPT, O, A, B], { encoding: 'utf8' });
119
+ check('S9 %A 缺失时以 %B 落盘且 exit 0', r.status === 0 && fs.readFileSync(A, 'utf8') === r2,
120
+ `exit=${r.status} ${r.stderr || ''}`);
121
+ fs.rmSync(d, { recursive: true, force: true });
122
+ }
123
+
124
+ console.log(`\n[git-merge-jsonl.test] PASS ${pass} / FAIL ${fail}`);
125
+ process.exit(fail ? 1 : 0);