agentic-flow-kit 1.7.0 → 1.7.16

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 (162) hide show
  1. package/README.md +81 -9
  2. package/modules/gates/README.md +1 -0
  3. package/modules/gates/test-protect/README.md +23 -0
  4. package/modules/gates/test-protect/check-test-protect.mjs +64 -0
  5. package/modules/gates/test-protect/local-pre-commit.line +1 -0
  6. package/modules/hosts/README.md +57 -0
  7. package/modules/hosts/claude/commands/wf-build.md +5 -24
  8. package/modules/hosts/claude/commands/wf-deploy.md +1 -0
  9. package/modules/hosts/claude/commands/wf-design.md +7 -6
  10. package/modules/hosts/claude/commands/wf-gate-checklist.md +1 -3
  11. package/modules/hosts/claude/commands/wf-new-task.md +42 -21
  12. package/modules/hosts/claude/commands/wf-plan.md +1 -1
  13. package/modules/hosts/claude/commands/wf-review.md +1 -1
  14. package/modules/hosts/claude/commands/wf-sync-hosts.md +8 -16
  15. package/modules/hosts/claude/commands/wf-test.md +5 -4
  16. package/modules/hosts/codex/commands/wf-build.md +5 -24
  17. package/modules/hosts/codex/commands/wf-deploy.md +1 -0
  18. package/modules/hosts/codex/commands/wf-design.md +7 -6
  19. package/modules/hosts/codex/commands/wf-gate-checklist.md +1 -3
  20. package/modules/hosts/codex/commands/wf-new-task.md +42 -21
  21. package/modules/hosts/codex/commands/wf-plan.md +1 -1
  22. package/modules/hosts/codex/commands/wf-review.md +1 -1
  23. package/modules/hosts/codex/commands/wf-sync-hosts.md +8 -16
  24. package/modules/hosts/codex/commands/wf-test.md +5 -4
  25. package/modules/hosts/cursor/commands/wf-build.md +5 -24
  26. package/modules/hosts/cursor/commands/wf-deploy.md +1 -0
  27. package/modules/hosts/cursor/commands/wf-design.md +7 -6
  28. package/modules/hosts/cursor/commands/wf-gate-checklist.md +1 -3
  29. package/modules/hosts/cursor/commands/wf-new-task.md +42 -21
  30. package/modules/hosts/cursor/commands/wf-plan.md +1 -1
  31. package/modules/hosts/cursor/commands/wf-review.md +1 -1
  32. package/modules/hosts/cursor/commands/wf-sync-hosts.md +8 -16
  33. package/modules/hosts/cursor/commands/wf-test.md +5 -4
  34. package/modules/hosts/opencode/commands/wf-build.md +5 -24
  35. package/modules/hosts/opencode/commands/wf-deploy.md +1 -0
  36. package/modules/hosts/opencode/commands/wf-design.md +7 -6
  37. package/modules/hosts/opencode/commands/wf-gate-checklist.md +1 -3
  38. package/modules/hosts/opencode/commands/wf-new-task.md +42 -21
  39. package/modules/hosts/opencode/commands/wf-plan.md +1 -1
  40. package/modules/hosts/opencode/commands/wf-review.md +1 -1
  41. package/modules/hosts/opencode/commands/wf-sync-hosts.md +8 -16
  42. package/modules/hosts/opencode/commands/wf-test.md +5 -4
  43. package/modules/hosts/trae/commands/wf-build.md +5 -24
  44. package/modules/hosts/trae/commands/wf-deploy.md +1 -0
  45. package/modules/hosts/trae/commands/wf-design.md +7 -6
  46. package/modules/hosts/trae/commands/wf-gate-checklist.md +1 -3
  47. package/modules/hosts/trae/commands/wf-new-task.md +42 -21
  48. package/modules/hosts/trae/commands/wf-plan.md +1 -1
  49. package/modules/hosts/trae/commands/wf-review.md +1 -1
  50. package/modules/hosts/trae/commands/wf-sync-hosts.md +8 -16
  51. package/modules/hosts/trae/commands/wf-test.md +5 -4
  52. package/package.json +2 -2
  53. package/src/add-gate.mjs +25 -1
  54. package/src/add-gate.test.mjs +103 -0
  55. package/src/cli.mjs +59 -5
  56. package/src/doctor.mjs +136 -12
  57. package/src/executor.mjs +245 -0
  58. package/src/executor.test.mjs +292 -0
  59. package/src/gates.test.mjs +17 -0
  60. package/src/init.mjs +14 -1
  61. package/src/init.test.mjs +19 -0
  62. package/src/instance-ref.test.mjs +62 -0
  63. package/src/jscpd-drift.test.mjs +144 -0
  64. package/src/profiles.mjs +3 -0
  65. package/src/render.mjs +6 -1
  66. package/src/rule-budget.test.mjs +103 -0
  67. package/src/sync-hosts.mjs +41 -45
  68. package/src/sync-hosts.test.mjs +67 -56
  69. package/src/trae-hooks.test.mjs +15 -3
  70. package/src/validate.mjs +72 -0
  71. package/src/validate.test.mjs +95 -0
  72. package/templates/.gitattributes +8 -0
  73. package/templates/AGENTS.md +2 -1
  74. package/templates/_agents/commands/build.md +5 -24
  75. package/templates/_agents/commands/deploy.md +1 -0
  76. package/templates/_agents/commands/design.md +7 -6
  77. package/templates/_agents/commands/gate-checklist.md +1 -3
  78. package/templates/_agents/commands/new-task.md +42 -21
  79. package/templates/_agents/commands/plan.md +1 -1
  80. package/templates/_agents/commands/review.md +1 -1
  81. package/templates/_agents/commands/sync-hosts.md +8 -16
  82. package/templates/_agents/commands/test.md +5 -4
  83. package/templates/_agents/control-surfaces.txt +53 -0
  84. package/templates/_agents/engine-lock.json +246 -0
  85. package/templates/_agents/lane-surfaces.txt +1 -1
  86. package/templates/_agents/level-hints.txt +30 -0
  87. package/templates/_agents/scripts/agg-delegations.cjs +51 -6
  88. package/templates/_agents/scripts/agg-delegations.test.mjs +84 -4
  89. package/templates/_agents/scripts/check-engine-integrity.mjs +217 -0
  90. package/templates/_agents/scripts/check-engine-integrity.test.mjs +215 -0
  91. package/templates/_agents/scripts/check-lane-surface.mjs +78 -24
  92. package/templates/_agents/scripts/check-lane-surface.test.mjs +66 -0
  93. package/templates/_agents/scripts/check-ledger-invariant.mjs +112 -14
  94. package/templates/_agents/scripts/check-ledger-invariant.test.mjs +5 -1
  95. package/templates/_agents/scripts/check-loop-rev.test.mjs +6 -4
  96. package/templates/_agents/scripts/check-loop.mjs +255 -45
  97. package/templates/_agents/scripts/check-loop.test.mjs +330 -15
  98. package/templates/_agents/scripts/check-metric-claims.mjs +4 -2
  99. package/templates/_agents/scripts/compact-ledgers.mjs +97 -0
  100. package/templates/_agents/scripts/compact-ledgers.test.mjs +162 -0
  101. package/templates/_agents/scripts/confirm-doc.mjs +54 -12
  102. package/templates/_agents/scripts/confirm-doc.test.mjs +59 -0
  103. package/templates/_agents/scripts/doctor.test.mjs +294 -1
  104. package/templates/_agents/scripts/draft-sediment.mjs +178 -0
  105. package/templates/_agents/scripts/draft-sediment.test.mjs +272 -0
  106. package/templates/_agents/scripts/edit-face-check.test.mjs +5 -4
  107. package/templates/_agents/scripts/fill-intent.mjs +2 -2
  108. package/templates/_agents/scripts/fixture-scripts.mjs +153 -0
  109. package/templates/_agents/scripts/fixture-scripts.test.mjs +107 -0
  110. package/templates/_agents/scripts/gate-checklist.mjs +6 -3
  111. package/templates/_agents/scripts/gate-checklist.test.mjs +2 -5
  112. package/templates/_agents/scripts/gen-wiki-board.mjs +61 -6
  113. package/templates/_agents/scripts/gen-wiki-board.test.mjs +74 -1
  114. package/templates/_agents/scripts/gen-workflow-dashboard.mjs +10 -13
  115. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -30
  116. package/templates/_agents/scripts/git-merge-jsonl.mjs +63 -0
  117. package/templates/_agents/scripts/git-merge-jsonl.test.mjs +125 -0
  118. package/templates/_agents/scripts/kb-search.mjs +87 -12
  119. package/templates/_agents/scripts/kb-search.test.mjs +83 -0
  120. package/templates/_agents/scripts/ledger-doc-chain.test.mjs +166 -0
  121. package/templates/_agents/scripts/ledger-loader.mjs +108 -0
  122. package/templates/_agents/scripts/ledger-loader.test.mjs +131 -0
  123. package/templates/_agents/scripts/ledger-prune.mjs +96 -0
  124. package/templates/_agents/scripts/ledger-prune.test.mjs +120 -0
  125. package/templates/_agents/scripts/ledger-ts-monotonic.test.mjs +135 -0
  126. package/templates/_agents/scripts/level-criteria.mjs +232 -0
  127. package/templates/_agents/scripts/level-criteria.test.mjs +279 -0
  128. package/templates/_agents/scripts/pipeline-run.mjs +5 -3
  129. package/templates/_agents/scripts/policy.mjs +47 -0
  130. package/templates/_agents/scripts/solidify-task.mjs +1 -1
  131. package/templates/_agents/scripts/stage-gates.mjs +52 -10
  132. package/templates/_agents/scripts/stage-gates.test.mjs +105 -0
  133. package/templates/_agents/scripts/verify-wiki-consistency.mjs +34 -0
  134. package/templates/_agents/scripts/verify-wiki-consistency.test.mjs +44 -2
  135. package/templates/_agents/scripts/verify.mjs +1 -1
  136. package/templates/_agents/scripts/wf-journal.test.mjs +1 -1
  137. package/templates/_agents/scripts/wiki-search.test.mjs +5 -6
  138. package/templates/_agents/scripts/workflows-check.mjs +1 -1
  139. package/templates/_agents/scripts-test-exempt.txt +3 -1
  140. package/templates/_agents/skills/wiki/SKILL.md +5 -3
  141. package/templates/_agents/workflows/pipeline-closing.md +4 -5
  142. package/templates/_githooks/pre-commit +3 -34
  143. package/templates/_githooks/pre-push +3 -3
  144. package/templates/wiki/INDEX.md +44 -1
  145. package/templates/wiki//347/237/245/350/257/206/346/262/211/346/267/200/346/200/273/350/247/210.html +22 -1
  146. package/templates/workflow/README.md +11 -0
  147. package/templates/workflow/delegations.md +5 -4
  148. package/templates/workflow/incidents/_TEMPLATE.md +2 -0
  149. package/templates/workflow/intents/_TEMPLATE.md +3 -0
  150. package/templates/workflow/plans/_TEMPLATE.md +4 -0
  151. package/templates/workflow/specs/_TEMPLATE.md +23 -0
  152. package/modules/hosts/claude/commands/wf-source-sync-check.md +0 -91
  153. package/modules/hosts/codex/commands/wf-source-sync-check.md +0 -91
  154. package/modules/hosts/cursor/commands/wf-source-sync-check.md +0 -91
  155. package/modules/hosts/opencode/commands/wf-source-sync-check.md +0 -91
  156. package/modules/hosts/trae/commands/wf-source-sync-check.md +0 -92
  157. package/templates/_agents/commands/source-sync-check.md +0 -105
  158. package/templates/_agents/scripts/check-ledger.mjs +0 -109
  159. package/templates/_agents/scripts/check-ledger.test.mjs +0 -197
  160. package/templates/_agents/scripts/source-sync-check.mjs +0 -156
  161. package/templates/_agents/scripts/source-sync-check.test.mjs +0 -281
  162. package/templates/_agents/workflows/source-sync-repair.md +0 -30
@@ -1,7 +1,8 @@
1
1
  // wiki 索引生成器:磁盘 + INDEX 速览表「用途」列 → 自动重生成 INDEX 速览计数/映射表 + 看板 DATA
2
2
  // 2026-09-13 引入:消除三层手工双写(短板①)。人工只维护两样:磁盘文件本身 + 速览表用途列。
3
- // 用法:node .agents/scripts/gen-wiki-board.mjs [--dry-run] [--help]
3
+ // 用法:node .agents/scripts/gen-wiki-board.mjs [--dry-run] [--check] [--help]
4
4
  // (收录/删除/移动 wiki 文件后必跑,随后跑 verify-wiki-consistency.mjs)
5
+ // --check 不写盘;INDEX/看板与磁盘不一致时 exit 1 并打印差异(供 doctor 漂移告警)
5
6
  // 测试:node .agents/scripts/gen-wiki-board.test.mjs(fixture 回归,须全绿)
6
7
  // 生成范围(锚点内整段重写,锚点外字节原样保留):
7
8
  // INDEX.md 速览表数据行 / 「合计」行数字 / 「## 文件 → 主题 → 归属目录 映射表」到「## 命名规则」之间
@@ -11,7 +12,7 @@ import fs from 'node:fs';
11
12
  import path from 'node:path';
12
13
  import { execSync } from 'node:child_process';
13
14
 
14
- const USAGE = `用法:node .agents/scripts/gen-wiki-board.mjs [--dry-run] [--help]
15
+ const USAGE = `用法:node .agents/scripts/gen-wiki-board.mjs [--dry-run] [--check] [--help]
15
16
 
16
17
  事实源:磁盘 wiki/<主题>/ 一层文件 + INDEX.md 速览表「用途」列(人工列)。重生成:
17
18
  - wiki/INDEX.md:速览表数据行与文件计数、「合计」行数字、映射表节
@@ -21,6 +22,7 @@ const USAGE = `用法:node .agents/scripts/gen-wiki-board.mjs [--dry-run] [--h
21
22
 
22
23
  选项:
23
24
  --dry-run 只打印将生成内容的摘要与逐行差异,不写盘
25
+ --check 不写盘;INDEX/看板与磁盘不一致时 exit 1 并打印差异(供 doctor 漂移告警)
24
26
  --help 打印本说明`;
25
27
 
26
28
  const args = process.argv.slice(2);
@@ -28,12 +30,13 @@ if (args.includes('--help') || args.includes('-h')) {
28
30
  console.log(USAGE);
29
31
  process.exit(0);
30
32
  }
31
- const unknown = args.filter((a) => a !== '--dry-run');
33
+ const unknown = args.filter((a) => a !== '--dry-run' && a !== '--check');
32
34
  if (unknown.length) {
33
35
  console.error(`❌ 未知参数:${unknown.join(' ')}\n\n${USAGE}`);
34
36
  process.exit(1);
35
37
  }
36
38
  const DRY = args.includes('--dry-run');
39
+ const CHECK = args.includes('--check');
37
40
 
38
41
  const WIKI = 'wiki';
39
42
  const INDEX_P = path.join(WIKI, 'INDEX.md');
@@ -87,6 +90,34 @@ for (const d of fs.readdirSync(WIKI)) {
87
90
  diskTopics.set(d, entries);
88
91
  }
89
92
 
93
+ // ---- 草稿超龄提醒(P1-2):drafts-archive/<YYYY-MM-DD-主题>/ 落盘超龄未归类 → stdout WARN ----
94
+ // advisory 档:只提醒不阻断(与 B1 沉淀追踪同档),不改归档统计口径与任何生成物,exit code 不受影响。
95
+ // 目录名不带日期前缀(人工建的目录)→ 静默跳过,不 WARN 也不报错。
96
+ const DRAFT_AGE_DAYS = 14; // 超龄档位(天):与 B1 沉淀追踪同档 advisory,只提醒不阻断
97
+ const TODAY = (() => {
98
+ // FLOW_KIT_TODAY=YYYY-MM-DD 仅测试/演练用:覆盖「今天」便于构造超龄/未超龄用例;未设置或格式不合法时用系统当前日期
99
+ const env = process.env.FLOW_KIT_TODAY;
100
+ if (env && /^\d{4}-\d{2}-\d{2}$/.test(env)) return env;
101
+ const now = new Date();
102
+ const pad = (n) => String(n).padStart(2, '0');
103
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
104
+ })();
105
+ const utcMs = (s) => { // 'YYYY-MM-DD' → 当日 UTC 零点毫秒(与 TODAY 同构换算,天数差恒为精确整数)
106
+ const [y, m, d] = s.split('-').map(Number);
107
+ return Date.UTC(y, m - 1, d);
108
+ };
109
+ // rel:相对 drafts-archive 的 '/' 分隔路径(如 '2026-09-01-主题/主题.md',与上方 walk 的 rel 拼法一致)
110
+ const warnIfDraftAged = (rel) => {
111
+ if (!rel.includes('/')) return; // 直接躺在 drafts-archive 根(无日期目录包裹)→ 静默跳过
112
+ const dirName = rel.split('/')[0];
113
+ const m = dirName.match(/^(\d{4}-\d{2}-\d{2})/);
114
+ if (!m) return; // 目录名不带日期前缀(人工建的目录)→ 静默跳过
115
+ const days = (utcMs(TODAY) - utcMs(m[1])) / 86400000;
116
+ if (!(days > DRAFT_AGE_DAYS)) return; // 未超龄(恰 14 天不提醒,超过才提醒);NaN 同样落此静默跳过
117
+ const topic = dirName.slice('YYYY-MM-DD-'.length) || dirName; // 目录名 = 日期-主题;无主题段则退回目录名
118
+ console.log(`[WARN 草稿超龄] drafts-archive/${rel} 落于 ${m[1]} 未归类——草稿是中间态,归类到 wiki/${topic}/ 后跑 gen-wiki-board + verify-wiki-consistency`);
119
+ };
120
+
90
121
  const archiveCount = (() => {
91
122
  let n = 0;
92
123
  const walk = (dir, rel = '') => {
@@ -95,7 +126,10 @@ const archiveCount = (() => {
95
126
  // 集合键带 drafts-archive/ 前缀(扫描面扩全 wiki/ 后),walk rel 须拼前缀比对(复核 P2-b)
96
127
  const r = rel ? `drafts-archive/${rel}/${e}` : `drafts-archive/${e}`;
97
128
  if (fs.statSync(p).isDirectory()) walk(p, rel ? `${rel}/${e}` : e);
98
- else if (!gitIgnoredArchive.has(r)) n++;
129
+ else if (!gitIgnoredArchive.has(r)) {
130
+ n++;
131
+ warnIfDraftAged(rel ? `${rel}/${e}` : e); // 超龄提醒挂在计数控分支:口径一致(ignored 件不提醒),不影响计数
132
+ }
99
133
  }
100
134
  };
101
135
  if (fs.existsSync(path.join(WIKI, 'drafts-archive'))) walk(path.join(WIKI, 'drafts-archive'));
@@ -185,7 +219,28 @@ if (boardOut === boardText && !/const DATA = \{[\s\S]*?\n\};/.test(boardText)) {
185
219
  process.exit(1);
186
220
  }
187
221
 
188
- // ---- 写盘(dry-run 只预览差异)----
222
+ // ---- 写盘(dry-run 只预览差异;--check 只比对不写盘)----
223
+ // 行尾归一比较(与 gen-workflow-index.mjs 同款:fresh clone 检出 CRLF、生成段恒 LF——
224
+ // 直接字符串相等会在克隆环境假阳性「漂移」;仅内容差异才算漂移)
225
+ const normEol = (s) => String(s).replace(/\r\n/g, '\n');
226
+ const indexOk = normEol(out) === normEol(indexText);
227
+ const boardOk = normEol(boardOut) === normEol(boardText);
228
+ const summary = `文件 ${totalFiles} 份 / 主题 ${diskTopics.size} 个 / 归档 ${archiveCount} 份`;
229
+ if (CHECK) {
230
+ if (indexOk && boardOk) {
231
+ console.log(`✅ wiki/INDEX.md 与看板均与磁盘一致(${summary})`);
232
+ process.exit(0);
233
+ }
234
+ if (!indexOk) {
235
+ console.error(`❌ wiki/INDEX.md 与磁盘不一致(${summary})——跑 node .agents/scripts/gen-wiki-board.mjs 重新生成`);
236
+ previewDiff('wiki/INDEX.md', indexText, out);
237
+ }
238
+ if (!boardOk) {
239
+ console.error(`❌ wiki/知识沉淀总览.html 与磁盘不一致(${summary})——跑 node .agents/scripts/gen-wiki-board.mjs 重新生成`);
240
+ previewDiff('wiki/知识沉淀总览.html', boardText, boardOut);
241
+ }
242
+ process.exit(1);
243
+ }
189
244
  if (DRY) {
190
245
  console.log('🔎 --dry-run:未写盘,差异预览如下');
191
246
  previewDiff('wiki/INDEX.md', indexText, out);
@@ -196,7 +251,7 @@ if (DRY) {
196
251
  }
197
252
 
198
253
  const tail = DRY ? '将重写' : '已重写';
199
- console.log(`${DRY ? '🔎 [dry-run] 将生成' : '✅ 已生成'}:文件 ${totalFiles} 份 / 主题 ${diskTopics.size} 个 / 归档 ${archiveCount} 份;速览表 ${overviewRows.length} 行、映射表 ${ordered.length} 节、看板 DATA ${tail}`);
254
+ console.log(`${DRY ? '🔎 [dry-run] 将生成' : '✅ 已生成'}:${summary};速览表 ${overviewRows.length} 行、映射表 ${ordered.length} 节、看板 DATA ${tail}`);
200
255
  console.log(' 人工仅维护:磁盘文件 + 速览表「用途」列(新主题为 <待补> 的请补描述后重跑本脚本)');
201
256
 
202
257
  // dry-run 差异预览:首尾对齐后取中段变更块(逐行),各方向最多显示 10 行
@@ -85,7 +85,8 @@ const mkfix = (eol) => {
85
85
  return root;
86
86
  };
87
87
 
88
- const runGen = (root) => spawnSync(process.execPath, [GEN], { cwd: root, encoding: 'utf8' });
88
+ // extraEnv:注入被测脚本环境变量(场景 8/9 用 FLOW_KIT_TODAY 固定「今天」,默认继承父进程环境不变)
89
+ const runGen = (root, extraArgs = [], extraEnv = {}) => spawnSync(process.execPath, [GEN, ...extraArgs], { cwd: root, encoding: 'utf8', env: { ...process.env, ...extraEnv } });
89
90
 
90
91
  // ---- 场景 1:CRLF INDEX ——「用途」列重生成后保持 + 行尾保持(缺陷回归)----
91
92
  {
@@ -168,6 +169,78 @@ const runGen = (root) => spawnSync(process.execPath, [GEN], { cwd: root, encodin
168
169
  fs.rmSync(root, { recursive: true, force: true });
169
170
  }
170
171
 
172
+ // ---- 场景 7:--check 漂移判定(2026-10-10 wiki-index-drift:供 doctor §6 镜像 workflow INDEX 检查)----
173
+ // 不写盘;一致 exit 0,任一生成区漂移 exit 1 + 差异预览。行尾归一(CRLF clone 不假阳性)
174
+ {
175
+ const root = mkfix('\r\n');
176
+ // 先跑一次正规生成,把 INDEX/看板对齐到磁盘事实
177
+ const r0 = runGen(root);
178
+ check('场景 7:前置生成 exit 0', r0.status === 0, `exit=${r0.status}\n${r0.stdout}${r0.stderr}`);
179
+ // 对齐后 --check 须一致(CRLF INDEX 尤其不能因行尾假阳性)
180
+ const r1 = runGen(root, ['--check']);
181
+ check('场景 7a --check 对齐态 → exit 0 且提示一致',
182
+ r1.status === 0 && (r1.stdout || '').includes('一致'), `exit=${r1.status}\n${r1.stdout}${r1.stderr}`);
183
+ check('场景 7a --check 不写盘(INDEX 内容字节不变)',
184
+ fs.readFileSync(path.join(root, 'wiki', 'INDEX.md'), 'utf8').includes(USAGE_COL), '');
185
+ // 磁盘新增文件 → 生成区与磁盘不一致 → exit 1
186
+ fs.writeFileSync(path.join(root, 'wiki', '主题A', 'b.md'), '# b\n', 'utf8');
187
+ const r2 = runGen(root, ['--check']);
188
+ check('场景 7b --check 漂移(磁盘新增文件未重生成)→ exit 1',
189
+ r2.status === 1 && (r2.stderr || '').includes('不一致'), `exit=${r2.status}\n${r2.stdout}${r2.stderr}`);
190
+ check('场景 7b --check 不写盘(漂移时仍不覆盖磁盘文件)',
191
+ !fs.readFileSync(path.join(root, 'wiki', 'INDEX.md'), 'utf8').includes('| b.md | 主题A |'), '');
192
+ // 修复后 --check 恢复 exit 0
193
+ const r3 = runGen(root);
194
+ const r4 = runGen(root, ['--check']);
195
+ check('场景 7c 重生成后 --check 恢复 exit 0', r3.status === 0 && r4.status === 0, `exit3=${r3.status} exit4=${r4.status}\n${r4.stdout}${r4.stderr}`);
196
+ // 看板 DATA 被手改 → exit 1(与 INDEX 漂移同档)
197
+ const board = path.join(root, 'wiki', '知识沉淀总览.html');
198
+ fs.writeFileSync(board, fs.readFileSync(board, 'utf8').replace('"total": 2', '"total": 99'), 'utf8');
199
+ const r5 = runGen(root, ['--check']);
200
+ check('场景 7d --check 看板 DATA 漂移 → exit 1',
201
+ r5.status === 1 && (r5.stderr || '').includes('知识沉淀总览.html'), `exit=${r5.status}\n${r5.stdout}${r5.stderr}`);
202
+ fs.rmSync(root, { recursive: true, force: true });
203
+ }
204
+
205
+ // ---- 场景 8:草稿超龄 WARN(P1-2:drafts-archive/<YYYY-MM-DD-主题>/ 落盘超 14 天未归类 → stdout 提醒)----
206
+ // advisory 档只提醒不阻断:exit 0、INDEX/看板 DATA 不因 WARN 有任何变化;FLOW_KIT_TODAY 固定「今天」保证时间可控
207
+ {
208
+ const root = mkfix('\n');
209
+ const DRAFT_DIR = '2026-09-01-登录优化';
210
+ const DRAFT_DATE = '2026-09-01';
211
+ fs.mkdirSync(path.join(root, 'wiki', 'drafts-archive', DRAFT_DIR));
212
+ fs.writeFileSync(path.join(root, 'wiki', 'drafts-archive', DRAFT_DIR, '登录优化.md'), '# 登录优化草稿\n', 'utf8');
213
+ const board = path.join(root, 'wiki', '知识沉淀总览.html');
214
+ const warnLine = `[WARN 草稿超龄] drafts-archive/${DRAFT_DIR}/登录优化.md 落于 ${DRAFT_DATE} 未归类——草稿是中间态,归类到 wiki/登录优化/ 后跑 gen-wiki-board + verify-wiki-consistency`;
215
+
216
+ // a) 「今天」= 草稿日期 + 15 天(> 14 天档)→ WARN 整行点名该草稿
217
+ const ra = runGen(root, [], { FLOW_KIT_TODAY: '2026-09-16' });
218
+ check('场景 8a:超龄 15 天 → exit 0(advisory 不阻断)', ra.status === 0, `exit=${ra.status}\n${ra.stdout}${ra.stderr}`);
219
+ check('场景 8a:stdout 含超龄 WARN 且整行点名草稿', (ra.stdout || '').includes(warnLine), ra.stdout);
220
+ check('场景 8a:生成物不受影响(看板归档计数仍为文件数 1)', /"archive": 1/.test(fs.readFileSync(board, 'utf8')), '');
221
+
222
+ // b) 「今天」= 同日 / 恰 14 天 → 未超龄,无 WARN(超过才提醒)
223
+ const rb = runGen(root, [], { FLOW_KIT_TODAY: DRAFT_DATE });
224
+ check('场景 8b:同日 → 无超龄 WARN', rb.status === 0 && !(rb.stdout || '').includes('[WARN 草稿超龄]'), rb.stdout);
225
+ const rb14 = runGen(root, [], { FLOW_KIT_TODAY: '2026-09-15' });
226
+ check('场景 8b:恰 14 天 → 无超龄 WARN(边界)', rb14.status === 0 && !(rb14.stdout || '').includes('[WARN 草稿超龄]'), rb14.stdout);
227
+ fs.rmSync(root, { recursive: true, force: true });
228
+ }
229
+
230
+ // ---- 场景 9:目录名无日期前缀(人工建的目录)→ 静默跳过:不 WARN、不报错、生成物不受影响(P1-2)----
231
+ {
232
+ const root = mkfix('\n');
233
+ fs.mkdirSync(path.join(root, 'wiki', 'drafts-archive', '临时素材'));
234
+ fs.writeFileSync(path.join(root, 'wiki', 'drafts-archive', '临时素材', 'x.md'), '# x\n', 'utf8');
235
+ // 「今天」取远期(2027-01-01):若误把无日期目录判成超龄必触发 WARN,可当场暴露缺陷
236
+ const r = runGen(root, [], { FLOW_KIT_TODAY: '2027-01-01' });
237
+ const board = fs.readFileSync(path.join(root, 'wiki', '知识沉淀总览.html'), 'utf8');
238
+ check('场景 9:无日期前缀目录 → exit 0(不报错)', r.status === 0, `exit=${r.status}\n${r.stdout}${r.stderr}`);
239
+ check('场景 9:无超龄 WARN(静默跳过)', !(r.stdout || '').includes('[WARN 草稿超龄]'), r.stdout);
240
+ check('场景 9:看板输出不受影响(归档计数仍为文件数 1)', /"archive": 1/.test(board), '');
241
+ fs.rmSync(root, { recursive: true, force: true });
242
+ }
243
+
171
244
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
172
245
  if (fail) {
173
246
  console.log(`\n${USAGE}`);
@@ -19,6 +19,7 @@ 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';
22
23
 
23
24
  const require = createRequire(import.meta.url);
24
25
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -82,17 +83,13 @@ function collectDoneIntents(root) {
82
83
  // collectDoneMap:台账 earliest done 行(坏行容忍,doc 正斜杠根相对——沿检查 14/15 口径)
83
84
  function collectDoneMap(root) {
84
85
  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 { /* 坏行容忍跳过 */ }
86
+ // 读法收敛 ledger-loader(2026-10-12 ledger-loader-p1):earliest done 行取自时间戳比较,
87
+ // 与行序无关,故并入台账视图不改变本函数语义(分片行同样参与「最早」判定)
88
+ for (const e of readLedgerRows(root)) {
89
+ if (e && e.stage === 'done' && typeof e.doc === 'string' && e.doc.startsWith('workflow/intents/')) {
90
+ const prev = doneMap.get(e.doc);
91
+ if (prev === undefined || e.ts < prev) doneMap.set(e.doc, e.ts);
92
+ }
96
93
  }
97
94
  return doneMap;
98
95
  }
@@ -119,7 +116,7 @@ export function qualityRows(root) {
119
116
  const avg = Number.isFinite(m.avgRework) ? m.avgRework.toFixed(2) : '—';
120
117
  // 门禁噪声返工单列(2026-10-08 selfmeasure-and-modularize):设计返工与噪声返工分列可诊断——
121
118
  // 「平均返工」把两者平均在一起时看不出该改门禁还是改设计。
122
- out.push(`| ${ym} | ${m.total} | ${pct(m.passRate)} | ${m.reworkSum} | ${m.reworkNoiseSum} | ${avg} | ${pct(m.fallbackRate)} | ${verdict} |`);
119
+ out.push(`| ${ym} | ${m.total} | ${pct(m.passRate)} | ${m.reworkSum} | ${m.reworkNoiseSum} | ${avg} | ${pct(m.fallbackRate)} | ${agg.fmtAccept(m)} | ${verdict} |`);
123
120
  }
124
121
  return { rows: out, verdictMap, months };
125
122
  }
@@ -202,7 +199,7 @@ function main() {
202
199
  '',
203
200
  '## 质量 / 返工(月度,判据复用 agg-delegations)',
204
201
  '',
205
- '| 月份 | 有效任务 | 一次通过率 | 设计返工 | 门禁噪声返工 | 平均返工 | 主兜底占比 | 扩容门判定 |',
202
+ '| 月份 | 有效任务 | 一次通过率 | 设计返工 | 门禁噪声返工 | 平均返工 | 主兜底占比 | 产出接受率 | 扩容门判定 |',
206
203
  '|------|----------|------------|---------|-------------|--------|------------|------------|',
207
204
  ...(q.rows.length ? q.rows : ['| — | — | — | — | — | — | — | 台账暂无数据 |']),
208
205
  '',
@@ -13,6 +13,7 @@ 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,28 +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
- const resolveEnumsPath = () => {
57
- let dir = path.dirname(fileURLToPath(import.meta.url));
58
- for (let i = 0; i < 6; i++) {
59
- const cand = path.join(dir, '.agents', 'workflow-enums.txt');
60
- if (fs.existsSync(cand)) return cand;
61
- const parent = path.dirname(dir);
62
- if (parent === dir) break; // 到根
63
- dir = parent;
64
- }
65
- return path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '.agents', 'workflow-enums.txt');
66
- };
67
- 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'));
68
51
  const ACTIVE_STATUS = [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']];
69
52
  const BEGIN = '<!-- GENERATED:BEGIN — gen-workflow-metrics.mjs 整段重写,手工说明写在本行之前 -->';
70
53
  const END = '<!-- GENERATED:END -->';
@@ -168,14 +151,13 @@ const idxText = fs.existsSync(path.join(WORKFLOW, 'INDEX.md')) ? fs.readFileSync
168
151
  const idxBytes = Buffer.byteLength(idxText, 'utf8');
169
152
  const idxActive = Number((idxText.match(/^## 活跃((\d+))/m) || [])[1] ?? NaN);
170
153
 
171
- // ---- 闭环漏斗(2026-10-02 ledger-funnel-metrics;口径定义式单源 = workflow/specs/2026-10-02-ledger-funnel-metrics.md)----
154
+ // ---- 闭环漏斗(2026-10-02 ledger-funnel-metrics 立法)----
172
155
  // 只读台账一手字段(doc/stage/ts/source),坏行跳过(与检查 15/19 同容错);观测面不挂门禁、不新增文档断言(检查 16 零接触)。
173
- 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
+ // 属口径变更,须随分片功能同批生效,不得混入单源化本步。
174
159
  function readLedgerRows() {
175
- if (!fs.existsSync(LEDGER_P)) return [];
176
- return fs.readFileSync(LEDGER_P, 'utf8').split(/\r?\n/)
177
- .map((l) => { try { return JSON.parse(l); } catch { return null; } })
178
- .filter((e) => e && typeof e === 'object');
160
+ return loadLedgerRows();
179
161
  }
180
162
 
181
163
  function funnelFromLedger(entries, month) {
@@ -249,9 +231,9 @@ const TABLE_HEAD = [
249
231
  '| 月份 | 文档(活跃/终态) | 文档字节 | 模块已填/总 | 常驻面字节(预算峰值) | INDEX(活跃行) |',
250
232
  '|---|---|---|---|---|---|',
251
233
  ];
252
- // 漏斗表头(第二表;口径定义式单源 = workflow/specs/2026-10-02-ledger-funnel-metrics.md)
234
+ // 漏斗表头(第二表)
253
235
  const FUNNEL_HEAD = [
254
- '## 闭环漏斗(机器口径,源:.agents/confirmations.jsonl——定义式见 specs/2026-10-02-ledger-funnel-metrics.md)',
236
+ '## 闭环漏斗(机器口径,源:.agents/confirmations.jsonl)',
255
237
  '',
256
238
  '| 月份 | 收口 | 完整链 | 协议前 | 一次通过 | 返工件 | 中位周期(天) | 台账行数 |',
257
239
  '|---|---|---|---|---|---|---|---|',
@@ -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);