agentic-flow-kit 0.4.0 → 0.6.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 (117) hide show
  1. package/README.md +3 -0
  2. package/modules/gates/dotnet-ca/check-architecture.sh +23 -5
  3. package/modules/hosts/omp/agents/implementer.md +47 -6
  4. package/modules/hosts/omp/agents/independent-reviewer.md +44 -5
  5. package/modules/hosts/omp/agents/ui-verifier.md +43 -6
  6. package/modules/hosts/opencode/agents/implementer.md +47 -1
  7. package/modules/hosts/opencode/agents/independent-reviewer.md +44 -1
  8. package/modules/hosts/opencode/agents/ui-verifier.md +43 -1
  9. package/modules/hosts/opencode/commands/build.md +74 -7
  10. package/modules/hosts/opencode/commands/deploy.md +51 -6
  11. package/modules/hosts/opencode/commands/design.md +36 -6
  12. package/modules/hosts/opencode/commands/gate-checklist.md +77 -0
  13. package/modules/hosts/opencode/commands/maintain.md +70 -6
  14. package/modules/hosts/opencode/commands/new-task.md +59 -6
  15. package/modules/hosts/opencode/commands/plan.md +35 -6
  16. package/modules/hosts/opencode/commands/review.md +41 -6
  17. package/modules/hosts/opencode/commands/source-sync-check.md +89 -0
  18. package/modules/hosts/opencode/commands/sync-hosts.md +66 -0
  19. package/modules/hosts/opencode/commands/test.md +58 -8
  20. package/modules/hosts/trae/agents/implementer.md +46 -2
  21. package/modules/hosts/trae/agents/independent-reviewer.md +44 -1
  22. package/modules/hosts/trae/agents/ui-verifier.md +41 -3
  23. package/modules/hosts/trae/commands/wf-build.md +74 -7
  24. package/modules/hosts/trae/commands/wf-deploy.md +51 -6
  25. package/modules/hosts/trae/commands/wf-design.md +36 -6
  26. package/modules/hosts/trae/commands/wf-gate-checklist.md +78 -0
  27. package/modules/hosts/trae/commands/wf-maintain.md +70 -6
  28. package/modules/hosts/trae/commands/wf-new-task.md +59 -6
  29. package/modules/hosts/trae/commands/wf-plan.md +35 -6
  30. package/modules/hosts/trae/commands/wf-review.md +41 -6
  31. package/modules/hosts/trae/commands/wf-source-sync-check.md +90 -0
  32. package/modules/hosts/trae/commands/wf-sync-hosts.md +67 -0
  33. package/modules/hosts/trae/commands/wf-test.md +58 -7
  34. package/modules/hosts/trae/hooks/post-edit-check.cjs +3 -2
  35. package/modules/hosts/trae/hooks/pre-shell-check.cjs +40 -9
  36. package/modules/hosts/trae/hooks.json +1 -1
  37. package/modules/hosts/zcode/agents/implementer.md +47 -6
  38. package/modules/hosts/zcode/agents/independent-reviewer.md +44 -5
  39. package/modules/hosts/zcode/agents/ui-verifier.md +43 -5
  40. package/package.json +6 -3
  41. package/src/add-gate.mjs +5 -3
  42. package/src/cli.mjs +15 -2
  43. package/src/doctor.mjs +245 -15
  44. package/src/gate-dotnet-ca.test.mjs +124 -0
  45. package/src/init.mjs +79 -17
  46. package/src/init.test.mjs +84 -2
  47. package/src/pack.test.mjs +24 -0
  48. package/src/render.mjs +64 -4
  49. package/src/run-tests.mjs +7 -11
  50. package/src/sync-hosts.mjs +194 -0
  51. package/src/sync-hosts.test.mjs +170 -0
  52. package/src/sync.mjs +65 -20
  53. package/src/sync.test.mjs +52 -0
  54. package/src/trae-hooks.test.mjs +115 -0
  55. package/templates/AGENTS.md +2 -1
  56. package/templates/_agents/board/index.html +32 -3
  57. package/templates/_agents/commands/build.md +22 -1
  58. package/templates/_agents/commands/design.md +2 -0
  59. package/templates/_agents/commands/gate-checklist.md +91 -0
  60. package/templates/_agents/commands/maintain.md +2 -3
  61. package/templates/_agents/commands/plan.md +4 -1
  62. package/templates/_agents/commands/review.md +1 -0
  63. package/templates/_agents/commands/source-sync-check.md +103 -0
  64. package/templates/_agents/commands/sync-hosts.md +79 -0
  65. package/templates/_agents/commands/test.md +4 -2
  66. package/templates/_agents/hooks/check-pairing-incremental.sh +20 -7
  67. package/templates/_agents/notes/runtime-env.md +1 -1
  68. package/templates/_agents/roles/implementer.md +2 -0
  69. package/templates/_agents/roles/independent-reviewer.md +2 -0
  70. package/templates/_agents/roles/ui-verifier.md +2 -0
  71. package/templates/_agents/rule-budgets.txt +1 -1
  72. package/templates/_agents/scripts/agg-delegations.cjs +39 -30
  73. package/templates/_agents/scripts/agg-delegations.test.mjs +32 -0
  74. package/templates/_agents/scripts/check-loop.mjs +763 -0
  75. package/templates/_agents/scripts/check-loop.sh +5 -808
  76. package/templates/_agents/scripts/check-loop.test.mjs +1102 -0
  77. package/templates/_agents/scripts/confirm-doc.mjs +228 -0
  78. package/templates/_agents/scripts/confirm-doc.test.mjs +321 -0
  79. package/templates/_agents/scripts/doctor.test.mjs +371 -0
  80. package/templates/_agents/scripts/ensure-board.mjs +30 -3
  81. package/templates/_agents/scripts/fill-intent.mjs +59 -0
  82. package/templates/_agents/scripts/fill-intent.test.mjs +73 -0
  83. package/templates/_agents/scripts/fill-plan.mjs +60 -0
  84. package/templates/_agents/scripts/fill-plan.test.mjs +67 -0
  85. package/templates/_agents/scripts/fill-spec.mjs +52 -0
  86. package/templates/_agents/scripts/fill-spec.test.mjs +61 -0
  87. package/templates/_agents/scripts/gate-checklist.mjs +194 -0
  88. package/templates/_agents/scripts/gate-checklist.test.mjs +204 -0
  89. package/templates/_agents/scripts/gen-wiki-board.mjs +38 -24
  90. package/templates/_agents/scripts/gen-wiki-board.test.mjs +18 -0
  91. package/templates/_agents/scripts/gen-workflow-index.mjs +4 -2
  92. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -3
  93. package/templates/_agents/scripts/kb-search.mjs +25 -9
  94. package/templates/_agents/scripts/kb-search.test.mjs +38 -8
  95. package/templates/_agents/scripts/source-sync-check.mjs +145 -0
  96. package/templates/_agents/scripts/source-sync-check.test.mjs +244 -0
  97. package/templates/_agents/scripts/verify-wiki-consistency.mjs +46 -23
  98. package/templates/_agents/scripts/verify.mjs +8 -10
  99. package/templates/_agents/scripts/verify.test.mjs +13 -1
  100. package/templates/_agents/scripts/wf-journal.mjs +158 -0
  101. package/templates/_agents/scripts/wf-journal.test.mjs +150 -0
  102. package/templates/_agents/scripts/workflow-board-server.mjs +73 -17
  103. package/templates/_agents/scripts/workflow-board-server.test.mjs +136 -0
  104. package/templates/_agents/scripts/workflow-enums.mjs +70 -0
  105. package/templates/_agents/scripts/workflow-enums.test.mjs +110 -0
  106. package/templates/_agents/scripts/workflows-check.mjs +260 -0
  107. package/templates/_agents/scripts/workflows-check.test.mjs +267 -0
  108. package/templates/_agents/workflow-enums.txt +19 -0
  109. package/templates/_agents/workflows/_TEMPLATE.md +75 -0
  110. package/templates/_agents/workflows/pipeline-closing.md +29 -0
  111. package/templates/_agents/workflows/source-sync-repair.md +30 -0
  112. package/templates/_agents/workflows/steps//347/244/272/344/276/213-/351/203/250/347/275/262/351/252/214/350/257/201.md +10 -0
  113. package/templates/_agents/workflows//347/244/272/344/276/213-/345/271/266/350/241/214/345/256/236/347/216/260/350/257/204/345/256/241.md +16 -0
  114. package/templates/_githooks/pre-commit +16 -1
  115. package/templates/workflow/README.md +1 -1
  116. package/templates/_agents/cache/kb-index.json +0 -1
  117. package/templates/_agents/scripts/check-loop.test.sh +0 -1042
package/src/init.mjs CHANGED
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import readline from 'node:readline';
6
6
  import { execSync, spawnSync } from 'node:child_process';
7
- import { renderTree, renderContent, sha256 } from './render.mjs';
7
+ import { renderTree, renderContent, sha256, scriptTrusted, listTree } from './render.mjs';
8
8
  import { HOSTS, STACKS, STACK_ALIASES, settingsJson, commitCheckConfig, pickStackVars, isOwned } from './profiles.mjs';
9
9
  import { doctor } from './doctor.mjs';
10
10
 
@@ -112,7 +112,12 @@ async function interactive() {
112
112
  console.log(dim(' ⚠️ 没认出——序号或名称再来一次'));
113
113
  }
114
114
  console.log(`\n${cyan('── 3/3 · workflow 看板端口')}`);
115
- const boardPort = (await p.ask(dim(' 直接回车 = 8933'))) || '8933';
115
+ let boardPort = '';
116
+ for (;;) {
117
+ boardPort = (await p.ask(dim(' 直接回车 = 8933'))) || '8933';
118
+ if (/^(0|[1-9]\d*)$/.test(String(boardPort)) && Number(boardPort) >= 1 && Number(boardPort) <= 65535) break;
119
+ console.log(dim(' ⚠️ 端口须为 1-65535 整数——再来一次'));
120
+ }
116
121
 
117
122
  console.log(`\n ${bold(`宿主 ${hosts.join('、')}`)}${dim(' | ')}${bold(`技术栈 ${stack}`)}${dim(' | ')}${bold(`端口 ${boardPort}`)}`);
118
123
  const yes = await p.ask(dim(' 确认安装到当前目录?(y/n,回车取消)'));
@@ -150,6 +155,10 @@ export async function init(args, pkgRoot) {
150
155
 
151
156
  opt.stack = normalizeStack(opt.stack);
152
157
  if (!STACKS[opt.stack]) fail(`未知技术栈:${opt.stack}(可选 dotnet | node(ts/js) | python | go | none——对应编译检查与自检验命令初值)`);
158
+ // 端口校验(2026-09-27 p2-batch1):任意字符串曾直进 vars 渲染进 AGENTS.md(AI 每会话必读面)——1-65535 整数 fail-fast
159
+ if (!/^(0|[1-9]\d*)$/.test(String(opt.boardPort)) || Number(opt.boardPort) < 1 || Number(opt.boardPort) > 65535) {
160
+ fail(`--board-port 须为 1-65535 整数(现「${opt.boardPort}」)`);
161
+ }
153
162
  const hosts = opt.hosts.split(',').map((s) => s.trim()).filter(Boolean);
154
163
  for (const h of hosts) {
155
164
  if (!HOSTS[h]) fail(`未知宿主:${h}(可选 ${Object.keys(HOSTS).join(' | ')})`);
@@ -167,12 +176,23 @@ export async function init(args, pkgRoot) {
167
176
  console.log(`${bold(cyan('▶ flow-kit init'))}${dim(' → ')}${target}`);
168
177
  console.log(` 宿主:${hosts.join(', ')} | 技术栈:${opt.stack} | 看板端口:${opt.boardPort}`);
169
178
 
170
- // 1) 模板树(保守:已存在文件跳过,--force 覆盖)
171
- const t = renderTree(path.join(pkgRoot, 'templates'), target, vars, { force: opt.force });
179
+ // 1) 模板树(保守:已存在文件跳过,--force 覆盖;p2-batch1:--force 下已存在 owned 仍跳过——
180
+ // 「项目自持」两态模型在重装路径的对齐,实测曾静默丢用户自定义「项目适配区」内容。
181
+ // 保护集 = 包源模板树内 isOwned 为真的 rel,现场枚举单源不另抄清单)
182
+ const protectSet = opt.force
183
+ ? new Set(listTree(path.join(pkgRoot, 'templates')).filter((rel) => isOwned(rel)))
184
+ : null;
185
+ const t = renderTree(path.join(pkgRoot, 'templates'), target, vars, { force: opt.force, protectSet });
186
+ for (const rel of t.protectedSkipped || []) {
187
+ console.log(` 跳过(owned 项目自持,--force 不覆盖):${rel}——如需重置请手动删除后重跑`);
188
+ }
172
189
 
173
- // 1.5) AGENTS.md 特例:已存在且无骨架标记 → 文末追加补齐(原内容保留);带标记 → 保守跳过
190
+ // 1.5) AGENTS.md 特例:已存在且无骨架标记 → 文末追加补齐(原内容保留);带标记 → 保守跳过。
191
+ // 门槛含 protectedSkipped(p2-batch1 复核 P1-2):--force 下 owned 保护跳过也属「已存在不覆盖」,
192
+ // 无骨架时同样要追加(追加本就是非覆盖语义,与保护不冲突)——否则自带 AGENTS.md 的装户经
193
+ // --force 重装永远拿不到工作流骨架且无感知
174
194
  let agentsMergedSha = null;
175
- if (t.skipped.includes('AGENTS.md')) {
195
+ if (t.skipped.includes('AGENTS.md') || (t.protectedSkipped || []).includes('AGENTS.md')) {
176
196
  const agentsAbs = path.join(target, 'AGENTS.md');
177
197
  const existing = fs.readFileSync(agentsAbs, 'utf8');
178
198
  if (hasAgentsSkeleton(existing)) {
@@ -181,7 +201,8 @@ export async function init(args, pkgRoot) {
181
201
  } else {
182
202
  const incoming = renderContent(fs.readFileSync(path.join(pkgRoot, 'templates', 'AGENTS.md'), 'utf8'), vars);
183
203
  fs.writeFileSync(agentsAbs, mergeAgents(existing, incoming));
184
- agentsMergedSha = sha256(fs.readFileSync(agentsAbs));
204
+ // LF 归一记账(init-p1-batch P1-4):用户 CRLF 原文 + LF 追加的混合文件与 doctor §6.6 / sync ownedSha 同口径
205
+ agentsMergedSha = sha256(Buffer.from(fs.readFileSync(agentsAbs, 'utf8').replace(/\r\n/g, '\n'), 'utf8'));
185
206
  t.skipped = t.skipped.filter((r) => r !== 'AGENTS.md');
186
207
  console.log(' AGENTS.md 已存在但无工作流骨架——文末追加补齐(原内容保留,「项目适配区」照常自填)');
187
208
  }
@@ -192,6 +213,16 @@ export async function init(args, pkgRoot) {
192
213
  for (const h of hosts) {
193
214
  hostResults[h] = renderTree(path.join(pkgRoot, 'modules', 'hosts', h), path.join(target, HOSTS[h].dir), vars, { force: opt.force });
194
215
  }
216
+ // 换 --hosts 重装:旧宿主目录脱账提示(kit.json 只记新宿主,孤儿对 doctor §4.5 不可见——审查 P2-1 附带)
217
+ if (fs.existsSync(kitPath)) {
218
+ try {
219
+ const oldHosts = (JSON.parse(fs.readFileSync(kitPath, 'utf8')).options?.hosts) || [];
220
+ const removed = oldHosts.filter((h) => HOSTS[h] && !hosts.includes(h));
221
+ for (const h of removed) {
222
+ console.log(` ⚠️ 旧宿主 ${HOSTS[h].dir}/ 不在新 --hosts 清单——其文件已脱账(成静默孤儿),如弃用请手动删除目录`);
223
+ }
224
+ } catch { /* 旧台账不可读(--force 重装修复场景),跳过提示 */ }
225
+ }
195
226
 
196
227
  // 3) 生成的 owned 基线配置(引擎按固定路径读取;存在则跳过,永不覆盖;技术栈命令由项目在文件内自填)
197
228
  const generated = {
@@ -231,13 +262,49 @@ export async function init(args, pkgRoot) {
231
262
  console.log(' ⚠️ 目标不是 git 仓库——钩子未挂载;git init 后手动执行:git config core.hooksPath .githooks');
232
263
  }
233
264
 
234
- // 6) kit.json:managed(模板/适配层,升级可覆盖)与 owned(基线配置+起步文档,升级不动)台账
265
+ // 6) 生成器首跑(幂等):workflow/INDEX.md 活跃层索引 + wiki 速览计数与看板 DATA
266
+ // 先于 kit.json 记账(init-p1-batch P1-2:生成器会重写 owned 起步文档,先跑后记账则 doctor §6.6 对账即绿,
267
+ // fresh init 不再必现 owned FAIL / exit 1);执行前过供应链防线(P1-3)——目标侧脚本与包源渲染值
268
+ // sha 一致才执行(目标目录预置脚本不可信,「已存在保守跳过」语义下预置件会被原样保留)
269
+ const guardRun = (rel) => {
270
+ const g = scriptTrusted({ pkgRoot, target, rel, vars });
271
+ if (!g.ok) {
272
+ console.log(` ⚠️ 跳过执行 ${rel}——${g.note}(供应链防线:只执行与包源渲染值一致的目标侧脚本)`);
273
+ return false;
274
+ }
275
+ return true;
276
+ };
277
+ if (guardRun('.agents/scripts/gen-workflow-index.mjs')) {
278
+ const genIndex = runNode(target, '.agents/scripts/gen-workflow-index.mjs');
279
+ console.log(genIndex.ok ? ' 已生成 workflow/INDEX.md(活跃层索引)' : ` ⚠️ gen-workflow-index 失败:${genIndex.out.split('\n')[0]}`);
280
+ }
281
+ if (guardRun('.agents/scripts/gen-wiki-board.mjs')) {
282
+ const genBoard = runNode(target, '.agents/scripts/gen-wiki-board.mjs');
283
+ console.log(genBoard.ok ? ' 已生成 wiki 速览计数与知识沉淀总览 DATA' : ` ⚠️ gen-wiki-board 失败:${genBoard.out.split('\n')[0]}`);
284
+ }
285
+
286
+ // 7) kit.json:managed(模板/适配层,升级可覆盖)与 owned(基线配置+起步文档,升级不动)台账
287
+ // owned 的生成器目标按生成后盘面重记(P1-2);记账一律 LF 归一 sha(P1-4——render 写盘已归一,
288
+ // 生成器重写目标与用户合并文件两侧同口径,跨 checkout 字节稳定)
289
+ const shaText = (p) => sha256(Buffer.from(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8'));
290
+ const GEN_TARGETS = new Set(['workflow/INDEX.md', 'wiki/INDEX.md', 'wiki/知识沉淀总览.html']); // 新增生成器目标须同步本清单
235
291
  const managed = [
236
292
  ...t.written.filter((f) => !isOwned(f.rel)),
237
293
  // 宿主文件渲染时相对宿主根,入台账须还原为项目根相对路径
238
294
  ...hosts.flatMap((h) => hostResults[h].written.map((f) => ({ ...f, rel: `${HOSTS[h].dir}/${f.rel}` }))),
239
295
  ];
240
- const owned = [...t.written.filter((f) => isOwned(f.rel)), ...ownedGenerated];
296
+ const owned = [...t.written.filter((f) => isOwned(f.rel)), ...ownedGenerated]
297
+ .map((f) => (GEN_TARGETS.has(f.rel) && fs.existsSync(path.join(target, f.rel))
298
+ ? { ...f, sha256: shaText(path.join(target, f.rel)) }
299
+ : f));
300
+ // protectedSkipped 件按盘面 LF 归一 sha 补记(p2-batch1 复核 P2-3):--force 重装受保护 owned
301
+ // 不补记会让 doctor §6.6 的漂移可见性静默消失(台账缺口对体检不可见)
302
+ for (const rel of t.protectedSkipped || []) {
303
+ if (!isOwned(rel) || !fs.existsSync(path.join(target, rel))) continue;
304
+ if (rel === 'AGENTS.md' && agentsMergedSha) continue; // 追加特例已记(agentsMergedSha),避免台账重复条目(复核收口 P2)
305
+ if (owned.some((f) => f.rel === rel)) continue;
306
+ owned.push({ rel, sha256: shaText(path.join(target, rel)) });
307
+ }
241
308
  if (agentsMergedSha) owned.push({ rel: 'AGENTS.md', sha256: agentsMergedSha });
242
309
  const pkg = JSON.parse(fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8'));
243
310
  fs.mkdirSync(path.dirname(kitPath), { recursive: true });
@@ -251,14 +318,9 @@ export async function init(args, pkgRoot) {
251
318
  }, null, 2)}\n`);
252
319
  console.log(` kit.json 台账:managed ${managed.length} 份 | owned ${owned.length} 份(版本 ${pkg.version})`);
253
320
 
254
- // 7) 生成器首跑(幂等):workflow/INDEX.md 活跃层索引 + wiki 速览计数与看板 DATA
255
- const genIndex = runNode(target, '.agents/scripts/gen-workflow-index.mjs');
256
- console.log(genIndex.ok ? ' 已生成 workflow/INDEX.md(活跃层索引)' : ` ⚠️ gen-workflow-index 失败:${genIndex.out.split('\n')[0]}`);
257
- const genBoard = runNode(target, '.agents/scripts/gen-wiki-board.mjs');
258
- console.log(genBoard.ok ? ' 已生成 wiki 速览计数与知识沉淀总览 DATA' : ` ⚠️ gen-wiki-board 失败:${genBoard.out.split('\n')[0]}`);
259
-
260
- // 8) 自检
261
- console.log('▶ flow-kit doctor');
321
+ // 8) 自检(kit.json 已按生成后盘面记账——doctor §6.6 应全绿,fresh init 以 exit 0 收场)
322
+ // 输出语义(p2-batch1 P2-4):doctor 打印在异步端口回调里、晚于本横幅——脚本化消费方按退出码判定
323
+ console.log('▶ flow-kit doctor(安装产物已就位;体检 FAIL 时本命令 exit 1,产物无损——按 FAIL 项处置)');
262
324
  doctor(['--dir', target], pkgRoot);
263
325
 
264
326
  if (t.skipped.length) console.log(` 跳过(已存在,未覆盖):${t.skipped.join('、')}`);
package/src/init.test.mjs CHANGED
@@ -1,9 +1,16 @@
1
1
  #!/usr/bin/env node
2
- // init.mjs 交互解析纯函数测试(normalizeStack / parseChoices)——不触真实安装与 stdin
2
+ // init.mjs 交互解析纯函数测试(normalizeStack / parseChoices)+ 装户面断言——解析层不触真实安装与 stdin;
3
+ // 装户面节对真实 templates/ 树跑 renderTree(init/sync 同一装户路径),断言编排 runner 与 workflows 目录在装户清单内。
3
4
  // 断言:①ts/js 别名归一 node;②序号/名称/混输/全角逗号/空白分隔解析正确(去重保序);
4
- // ③非法输入返回 null(就地重问信号);④空输入回默认(EOF/直接回车安全回退)。
5
+ // ③非法输入返回 null(就地重问信号);④空输入回默认(EOF/直接回车安全回退);⑥装户面含 runner/workflows。
5
6
  // 用法:node src/init.test.mjs
6
7
  import { normalizeStack, parseChoices, hasAgentsSkeleton, mergeAgents } from './init.mjs';
8
+ import { renderTree, scriptTrusted, listTree } from './render.mjs';
9
+ import { sha256 } from './render.mjs';
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
7
14
 
8
15
  let pass = 0;
9
16
  let fail = 0;
@@ -48,6 +55,81 @@ check('合并:原内容尾部空白折叠不产生连续空行',
48
55
  mergeAgents('# A\n内容\n\n\n', '<!-- m -->\n# B') === '# A\n内容\n\n<!-- m -->\n# B\n');
49
56
  check('合并:原内容为空时骨架即全文', mergeAgents('', '<!-- m -->\n# B') === '<!-- m -->\n# B\n');
50
57
 
58
+ // ---- ⑥ 装户面:真实模板树含编排机制件(init/sync 同走 renderTree,结构保证装户必有) ----
59
+ {
60
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-surface-'));
61
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
62
+ const t = renderTree(path.join(pkgRoot, 'templates'), tmp, { BOARD_PORT: '8933' }, { force: true });
63
+ const rels = new Set(t.written.map((w) => w.rel));
64
+ check('装户面含机制文档与编排脚本示例(_TEMPLATE + 示例 .md)',
65
+ rels.has('.agents/workflows/_TEMPLATE.md') && rels.has('.agents/workflows/示例-并行实现评审.md'),
66
+ [...rels].filter((r) => r.includes('workflows')).join('、'));
67
+ check('装户面含 steps 扩展点示例', rels.has('.agents/workflows/steps/示例-部署验证.md'),
68
+ [...rels].filter((r) => r.includes('steps')).join('、'));
69
+ check('装户面无 orchestrate 命令残留(编排机制为目录约定,2026-09-25-wf-runtime)',
70
+ ![...rels].some((r) => r.includes('orchestrate')),
71
+ [...rels].filter((r) => r.includes('orchestrate')).join('、'));
72
+ fs.rmSync(tmp, { recursive: true, force: true });
73
+ }
74
+
75
+ // ---- ⑦ 钩子执行位 + 供应链防线 + 记账归一(2026-09-27 init-p1-batch 复核条件①)----
76
+ {
77
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'init-p1-'));
78
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
79
+ const vars = { BOARD_PORT: '8933' };
80
+ const t = renderTree(path.join(pkgRoot, 'templates'), tmp, vars, { force: true });
81
+ // P1-1:renderTree 落盘执行位——win32 下 statSync().mode 无 0o111 语义(恒 0666 系),
82
+ // 断言「writeFileSync 带 mode 参数不炸 + POSIX 语义表达」;真值在 POSIX CI 腿验证
83
+ const hookStat = fs.statSync(path.join(tmp, '.githooks', 'pre-commit'));
84
+ const hookHasExecBit = (hookStat.mode & 0o111) !== 0;
85
+ check('钩子落盘执行位:writeFileSync mode=0755 不炸且 POSIX 语义下可执行位为真(win32 平台分支容忍)',
86
+ hookHasExecBit || process.platform === 'win32',
87
+ `mode=${(hookStat.mode & 0o777).toString(8)} platform=${process.platform}`);
88
+ // P1-3:scriptTrusted——一致放行 / 预置拒绝 / 包源无模板拒绝
89
+ const ok1 = scriptTrusted({ pkgRoot, target: tmp, rel: '.agents/scripts/gen-workflow-index.mjs', vars });
90
+ check('scriptTrusted:与包源渲染值一致 → ok', ok1.ok, ok1.note);
91
+ fs.writeFileSync(path.join(tmp, '.agents/scripts/gen-workflow-index.mjs'), 'require("fs").writeFileSync("MARKER","pwned")\n');
92
+ const bad1 = scriptTrusted({ pkgRoot, target: tmp, rel: '.agents/scripts/gen-workflow-index.mjs', vars });
93
+ check('scriptTrusted:预置/被改动 → 拒绝且 note 可见', !bad1.ok && bad1.note.includes('不符'), bad1.note);
94
+ const bad2 = scriptTrusted({ pkgRoot, target: tmp, rel: '.agents/scripts/not-in-pkg.mjs', vars });
95
+ check('scriptTrusted:包源无模板 → fail-closed 拒绝', !bad2.ok, bad2.note);
96
+ // P1-4:owned 记账 LF 归一——CRLF 盘面与 LF 内容同 sha(与 doctor §6.6 比较口径一致)
97
+ const crlfFile = path.join(tmp, 'crlf-owned.txt');
98
+ fs.writeFileSync(crlfFile, '行甲\r\n行乙\r\n');
99
+ const normSha = sha256(Buffer.from('行甲\n行乙\n', 'utf8'));
100
+ const rawSha = sha256(Buffer.from('行甲\r\n行乙\r\n', 'utf8'));
101
+ const readNorm = sha256(Buffer.from(fs.readFileSync(crlfFile, 'utf8').replace(/\r\n/g, '\n'), 'utf8'));
102
+ check('记账口径:LF 归一 sha == LF 内容 sha != 原始字节 sha(CRLF 盘面跨 checkout 稳定)',
103
+ readNorm === normSha && readNorm !== rawSha);
104
+ fs.rmSync(tmp, { recursive: true, force: true });
105
+ }
106
+
107
+ // ---- ⑧ --force owned 保护 + 端口校验(2026-09-27 p2-batch1)----
108
+ {
109
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'init-protect-'));
110
+ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
111
+ const vars = { BOARD_PORT: '8933' };
112
+ // 预置「用户已自定义」的 owned 文件(内容 != 模板渲染值)
113
+ const agPath = path.join(tmp, 'AGENTS.md');
114
+ fs.writeFileSync(agPath, '# 我的项目\n\n自定义适配区命令行,p2b1-marker-keep。\n');
115
+ const lpPath = path.join(tmp, '.agents', 'hooks', 'local-pre-commit');
116
+ fs.mkdirSync(path.dirname(lpPath), { recursive: true });
117
+ fs.writeFileSync(lpPath, '#!/bin/sh\nset -e\nsh .agents/hooks/check-architecture.sh # add-gate 接线行 p2b1-marker-keep\n');
118
+ const t = renderTree(path.join(pkgRoot, 'templates'), tmp, vars, { force: true, protectSet: new Set([...listTree(path.join(pkgRoot, 'templates')).filter((rel) => rel === 'AGENTS.md' || rel === '.agents/hooks/local-pre-commit')]) });
119
+ check('⑧--force owned 保护:自定义 AGENTS.md 不被覆盖(marker 保留 + protectedSkipped 记录)',
120
+ fs.readFileSync(agPath, 'utf8').includes('p2b1-marker-keep') && (t.protectedSkipped || []).includes('AGENTS.md'),
121
+ JSON.stringify({ ps: t.protectedSkipped }));
122
+ check('⑧--force owned 保护:local-pre-commit 接线行保留',
123
+ fs.readFileSync(lpPath, 'utf8').includes('p2b1-marker-keep') && (t.protectedSkipped || []).includes('.agents/hooks/local-pre-commit'),
124
+ JSON.stringify({ ps: t.protectedSkipped }));
125
+ // managed 文件不受保护(--force 照常覆盖)
126
+ const cmd = path.join(tmp, '.agents', 'commands', 'plan.md');
127
+ check('⑧managed 文件 --force 照常覆盖(不在 protectedSkipped)',
128
+ fs.existsSync(cmd) && !(t.protectedSkipped || []).includes('.agents/commands/plan.md'),
129
+ JSON.stringify({ ps: t.protectedSkipped }));
130
+ fs.rmSync(tmp, { recursive: true, force: true });
131
+ }
132
+
51
133
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
52
134
  if (fail) {
53
135
  console.log('\n用法:node src/init.test.mjs');
@@ -0,0 +1,24 @@
1
+ // pack.test.mjs — npm 打包防线断言:templates/_agents/cache(运行时生成、gitignore)不得进发布包。
2
+ // 0.4.0 实发包含测试残留 kb-index.json(incident 2026-09-24-npm-pack-cache-leak)——files 白名单列出
3
+ // templates 后 npm-packlist 不再按根 .gitignore 排除目录内文件,须显式负向排除;prepack 兜底物理清除。
4
+ // 此处断言配置层两道防线在位(npm 10/11 双版本 pack 行为已在 incident 实测验证)。
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
11
+
12
+ let pass = 0, fail = 0;
13
+ function check(name, cond, detail = '') {
14
+ if (cond) { pass++; console.log(`PASS ${name}`); }
15
+ else { fail++; console.log(`FAIL ${name}${detail ? `——${detail}` : ''}`); }
16
+ }
17
+
18
+ const files = pkg.files || [];
19
+ const prepack = pkg.scripts?.prepack || '';
20
+ check('P1 files 负向排除 cache 目录与内容', files.includes('!templates/_agents/cache') && files.includes('!templates/_agents/cache/**'), JSON.stringify(files));
21
+ check('P2 prepack 兜底清除 cache 目录', /templates[\\/]_agents[\\/]cache/.test(prepack) && /rmSync/.test(prepack), prepack || 'scripts.prepack 缺失');
22
+
23
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
24
+ process.exit(fail ? 1 : 0);
package/src/render.mjs CHANGED
@@ -22,9 +22,13 @@ export function renderContent(text, vars) {
22
22
  return String(text || '').replace(PH_RE, (m, key) => (Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : m));
23
23
  }
24
24
 
25
- export function renderTree(srcRoot, targetRoot, vars, { force = false } = {}) {
25
+ // renderTree:模板树渲染。opts.protectOwned=true 时(init --force 路径,2026-09-27 p2-batch1),
26
+ // rel 命中 protectSet 且目标已存在 → 跳过覆盖并计入 protected 清单(owned「永不触碰」两态模型
27
+ // 在 --force 路径的对齐;此前 force 覆盖一切同名文件曾实测静默丢用户自定义内容)
28
+ export function renderTree(srcRoot, targetRoot, vars, { force = false, protectSet = null } = {}) {
26
29
  const written = []; // { rel, sha256 }
27
30
  const skipped = []; // rel(已存在,保守跳过)
31
+ const protectedSkipped = []; // rel(--force 下 owned 保护跳过)
28
32
  const warnings = []; // 未知占位符等
29
33
  const dirs = [];
30
34
 
@@ -45,10 +49,17 @@ export function renderTree(srcRoot, targetRoot, vars, { force = false } = {}) {
45
49
  skipped.push(rel);
46
50
  continue;
47
51
  }
52
+ if (protectSet && protectSet.has(rel) && fs.existsSync(target)) {
53
+ protectedSkipped.push(rel);
54
+ continue;
55
+ }
48
56
  const raw = fs.readFileSync(abs);
49
57
  let out = raw;
50
58
  if (raw.length < 512 * 1024 && !raw.includes(0)) {
51
- const replaced = renderContent(raw.toString('utf8'), vars);
59
+ // 文本面 LF 归一(2026-09-27 closing-coverage):模板盘面 CRLF(autocrlf 检出 / 编辑器写入)与
60
+ // LF 克隆渲染出同一字节、同一 sha——fresh sha、装户落盘、kit.json 台账三方跨环境稳定;
61
+ // sync/doctor/source-sync-check 的读取侧归一(shaText)与本处写盘归一同口径闭合
62
+ const replaced = renderContent(raw.toString('utf8').replace(/\r\n/g, '\n'), vars);
52
63
  if (replaced.includes('{{')) {
53
64
  for (const m of replaced.matchAll(/\{\{([A-Z][A-Z0-9_]*)\}\}/g)) {
54
65
  if (!Object.prototype.hasOwnProperty.call(vars, m[1])) warnings.push(`${rel}: 未知占位符 {{${m[1]}}}(原样保留)`);
@@ -56,12 +67,61 @@ export function renderTree(srcRoot, targetRoot, vars, { force = false } = {}) {
56
67
  }
57
68
  out = Buffer.from(replaced, 'utf8');
58
69
  }
59
- fs.writeFileSync(target, out);
70
+ fs.writeFileSync(target, out, { mode: rel.startsWith('.githooks/') ? 0o755 : 0o644 }); // 钩子带执行位(init-p1-batch P1-1:POSIX 装户 git 对不可执行钩子静默跳过)
60
71
  written.push({ rel: rel.split(path.sep).join('/'), sha256: sha256(out) });
61
72
  }
62
73
  })(srcRoot);
63
74
 
64
- return { written, skipped, warnings, dirs };
75
+ return { written, skipped, protectedSkipped, warnings, dirs };
76
+ }
77
+
78
+ // renderPkgText:目标侧脚本执行防线的比对基准(2026-09-27 init-p1-batch P1-3)——读包源模板
79
+ // (templates/_agents/<rel 去掉 .agents/ 前缀>)→ LF 归一 → {{VAR}} 渲染(脚本面无占位符时即原文)。
80
+ // 返回 null = 包源无该模板或非文本面(无从校验,调用方按不可信处理)。
81
+ export function renderPkgText(pkgRoot, rel, vars) {
82
+ if (!rel.startsWith('.agents/')) return null;
83
+ const tmpl = path.join(pkgRoot, 'templates', '_agents', rel.slice('.agents/'.length));
84
+ try {
85
+ const raw = fs.readFileSync(tmpl);
86
+ if (raw.length >= 512 * 1024 || raw.includes(0)) return null;
87
+ return renderContent(raw.toString('utf8').replace(/\r\n/g, '\n'), vars);
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // scriptTrusted:目标侧脚本是否与包源渲染值一致(LF 归一双侧 sha 比对,与 closing-coverage 六处口径同族)。
94
+ // 返回 {ok, note}——ok=false 时 note 给出原因(预置/被改动/包源无模板/不可读),调用方跳过执行并显式提示。
95
+ export function scriptTrusted({ pkgRoot, target, rel, vars }) {
96
+ const expected = renderPkgText(pkgRoot, rel, vars);
97
+ if (expected === null) return { ok: false, note: `包源无 ${rel} 模板,无法校验` };
98
+ const expSha = sha256(Buffer.from(expected, 'utf8'));
99
+ let actual;
100
+ try {
101
+ actual = fs.readFileSync(path.join(target, rel), 'utf8').replace(/\r\n/g, '\n');
102
+ } catch {
103
+ return { ok: false, note: `${rel} 不可读` };
104
+ }
105
+ if (sha256(Buffer.from(actual, 'utf8')) === expSha) return { ok: true, note: '' };
106
+ return { ok: false, note: `目标侧 ${rel} 与包源渲染值不符(预置或被改动)` };
107
+ }
108
+
109
+ // listTree(srcRoot):只枚举模板树的目标相对路径(rel),不落盘、不算 sha——供 doctor 台账覆盖率检查复用。
110
+ // 与 renderTree 完全同一套 relOf 映射(顶层 _ 前缀目录转点目录)与 .agents/cache 排除,避免两处映射漂移。
111
+ export function listTree(srcRoot) {
112
+ const out = [];
113
+ (function walk(dir) {
114
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
115
+ const abs = path.join(dir, entry.name);
116
+ if (entry.isDirectory()) {
117
+ if (relOf(abs, srcRoot) === '.agents/cache') continue;
118
+ walk(abs);
119
+ continue;
120
+ }
121
+ out.push(relOf(abs, srcRoot).split(path.sep).join('/'));
122
+ }
123
+ })(srcRoot);
124
+ return out;
65
125
  }
66
126
 
67
127
  export { sha256 };
package/src/run-tests.mjs CHANGED
@@ -1,5 +1,6 @@
1
- // run-tests.mjs — 全套测试入口(npm test):CLI 单测(sync/add-host/add-gate)+ 引擎 *.test.mjs + check-loop bash 套件。
2
- // 顺序执行、任一非零整体失败(exit 1);无 sh 环境(极简 Windows)跳过 bash 套件并显著提示。
1
+ // run-tests.mjs — 全套测试入口(npm test):CLI 单测(sync/add-host/add-gate)+ 引擎 *.test.mjs 套件。
2
+ // 顺序执行、任一非零整体失败(exit 1)。check-loop 套件自 2026-09-26 check-loop-node 起为 node 实现
3
+ // (check-loop.test.mjs 由下方 glob 自动发现——旧 bash 特判与「无 sh 跳过」提示随迁移退役)。
3
4
  import { spawnSync } from 'node:child_process';
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
@@ -10,6 +11,10 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
11
  const nodeSuites = [
11
12
  'src/sync.test.mjs',
12
13
  'src/init.test.mjs',
14
+ 'src/pack.test.mjs',
15
+ // 宿主门禁模块回归(2026-09-27 host-gates-p1:dotnet-ca BRE 豁免/fail-closed + trae 强推 token 化)
16
+ 'src/gate-dotnet-ca.test.mjs',
17
+ 'src/trae-hooks.test.mjs',
13
18
  ...fs.readdirSync(path.join(ROOT, 'templates/_agents/scripts'))
14
19
  .filter((f) => f.endsWith('.test.mjs'))
15
20
  .sort()
@@ -23,14 +28,5 @@ for (const rel of nodeSuites) {
23
28
  if (r.status !== 0) failed++;
24
29
  }
25
30
 
26
- const shSuite = 'templates/_agents/scripts/check-loop.test.sh';
27
- if (spawnSync('sh', ['-c', 'true']).status === 0) {
28
- console.log(`\n===== ${shSuite}(bash 套件) =====`);
29
- const r = spawnSync('sh', [path.join(ROOT, shSuite)], { cwd: ROOT, stdio: 'inherit' });
30
- if (r.status !== 0) failed++;
31
- } else {
32
- console.warn(`\n⚠️ 环境无 sh——跳过 ${shSuite}(装 Git Bash / 在 WSL 里补跑,勿静默当作通过)`);
33
- }
34
-
35
31
  console.log(`\n${failed ? `❌ ${failed} 个套件失败` : '✅ 全部套件通过'}`);
36
32
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,194 @@
1
+ // flow-kit sync-hosts:跨宿主适配层正文段漂移检查 + 单向同步(B-b 方案,2026-09-25)
2
+ // 目的:改权威源(templates/_agents/{commands,roles}/*.md)正文要同步到 N 份薄适配
3
+ // (modules/hosts/<h>/{agents,commands}/*.md)的对应正文段;薄适配 frontmatter 保留宿主特化字段
4
+ // (trae commands 加 name: wf-X、opencode/zcode/omp 各自原描述),不在同步范围。
5
+ // B-b 语义:sha 比对与 apply 只动正文段,frontmatter 双方各自维护。
6
+ // 算法:diffHosts 抽为 pure function(authorityRoot + adaptersRoot 两个根);
7
+ // sync-hosts CLI 在 pkgRoot 跑(包源视角);doctor §7.x 在 target 跑(装户视角)——同源逻辑。
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { HOSTS } from './profiles.mjs';
11
+ import { sha256 } from './render.mjs';
12
+
13
+ function fail(msg) {
14
+ console.error(`❌ ${msg}`);
15
+ process.exit(1);
16
+ }
17
+
18
+ // 剥离 frontmatter 返回 { fm, body };无 frontmatter 时 fm = '',body = 整文件
19
+ // frontmatter 形如 "---\n...\n---\n",位于文件首;其后(含分隔空行)为正文段。
20
+ function splitFm(text) {
21
+ const m = String(text || '').match(/^---\r?\n([\s\S]*?\r?\n)---\r?\n?([\s\S]*)$/);
22
+ if (!m) return { fm: '', body: String(text || '') };
23
+ // fm 还原为带分隔横线的形式;body 保留首部空行
24
+ return { fm: `---\n${m[1]}---\n`, body: m[2] };
25
+ }
26
+
27
+ // bodySha:剥离 frontmatter 后的正文段 sha256(缺失返 null);正文 LF 归一(2026-09-27 closing-coverage:
28
+ // CRLF/LF 盘面同值——权威源与薄适配行尾不一致不再误报漂移,与 doctor §6.7 同口径)
29
+ function bodySha(p) {
30
+ try {
31
+ const { body } = splitFm(fs.readFileSync(p, 'utf8'));
32
+ return sha256(Buffer.from(body.replace(/\r\n/g, '\n'), 'utf8'));
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ // listMd:列目录下 .md 文件(同步当前层;不递归)
39
+ function listMd(dir) {
40
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return [];
41
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.md'));
42
+ }
43
+
44
+ // pairsFor:权威源文件 → 薄适配 rel 列表(与 sync-hosts 早版同映射;trae commands 加 wf- 前缀)
45
+ function pairsFor(authRel) {
46
+ const out = [];
47
+ const norm = authRel.split(path.sep).join('/');
48
+ if (norm.startsWith('commands/') && norm.endsWith('.md')) {
49
+ const name = norm.slice('commands/'.length, -'.md'.length);
50
+ out.push({ adapterRel: `opencode/commands/${name}.md` });
51
+ out.push({ adapterRel: `trae/commands/wf-${name}.md` });
52
+ } else if (norm.startsWith('roles/') && norm.endsWith('.md')) {
53
+ const name = norm.slice('roles/'.length, -'.md'.length);
54
+ for (const h of Object.keys(HOSTS)) out.push({ adapterRel: `${h}/agents/${name}.md` });
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // diffHosts:权威源正文段 vs 薄适配正文段的漂移分析(B-b 语义;pure function)。
60
+ // authorityRoot:权威源根(含 commands/ + roles/)
61
+ // adaptersRoot:薄适配根(含 <h>/{agents,commands}/)
62
+ // 返回:{ drift, inSync, authorityMissing, adapterOrphans, note }
63
+ // drift = 正文段 sha 不一致(权威源改 / 薄适配手改 都计入;不区分方向)
64
+ // inSync = 正文段 sha 对齐的文件对数
65
+ // authorityMissing = 权威源声明但薄适配文件不存在(apply 不自动创建——薄适配须经 add-host 接管)
66
+ // adapterOrphans = 薄适配存在但权威源无对应文件(apply 不删——可能是宿主特化或历史残留)
67
+ export function diffHosts({ authorityRoot, adaptersRoot }) {
68
+ if (!fs.existsSync(authorityRoot)) return { drift: [], inSync: 0, authorityMissing: [], adapterOrphans: [], note: `权威源根不存在:${authorityRoot}` };
69
+
70
+ const drift = [];
71
+ let inSync = 0;
72
+ const adapterSeen = new Set();
73
+ const authorityMissing = [];
74
+
75
+ // 扫权威源 commands/ + roles/
76
+ for (const sub of ['commands', 'roles']) {
77
+ const subAbs = path.join(authorityRoot, sub);
78
+ for (const f of listMd(subAbs)) {
79
+ const authRel = `${sub}/${f}`;
80
+ const authAbs = path.join(authorityRoot, authRel);
81
+ const authSha = bodySha(authAbs);
82
+ const pairs = pairsFor(authRel);
83
+ if (pairs.length === 0) continue;
84
+ for (const { adapterRel } of pairs) {
85
+ adapterSeen.add(adapterRel);
86
+ const adapterAbs = path.join(adaptersRoot, adapterRel);
87
+ const adapterSha = bodySha(adapterAbs);
88
+ if (adapterSha === null) {
89
+ authorityMissing.push({ authRel, adapterRel });
90
+ continue;
91
+ }
92
+ if (authSha === adapterSha) { inSync++; continue; }
93
+ drift.push({ authRel, adapterRel, authSha, adapterSha });
94
+ }
95
+ }
96
+ }
97
+
98
+ // 扫薄适配找孤儿(权威源无对应文件)
99
+ const adapterOrphans = [];
100
+ for (const h of Object.keys(HOSTS)) {
101
+ for (const sub of ['agents', 'commands']) {
102
+ const subAbs = path.join(adaptersRoot, h, sub);
103
+ for (const f of listMd(subAbs)) {
104
+ const adapterRel = `${h}/${sub}/${f}`;
105
+ if (adapterSeen.has(adapterRel)) continue;
106
+ let authGuess;
107
+ if (h === 'trae' && sub === 'commands' && f.startsWith('wf-')) {
108
+ authGuess = `commands/${f.slice('wf-'.length)}`;
109
+ } else if (sub === 'agents') {
110
+ authGuess = `roles/${f}`;
111
+ } else {
112
+ authGuess = `commands/${f}`;
113
+ }
114
+ adapterOrphans.push({ adapterRel, authGuess });
115
+ }
116
+ }
117
+ }
118
+
119
+ return { drift, inSync, authorityMissing, adapterOrphans };
120
+ }
121
+
122
+ // printDiff:人类可读 diff 报告(B-b 语义:正文段比对 + frontmatter 双方不动)
123
+ function printDiff(result) {
124
+ const { drift, inSync, authorityMissing, adapterOrphans, note } = result;
125
+ if (note) console.log(`ℹ️ ${note}\n`);
126
+ console.log(` 正文对齐:${inSync} 对`);
127
+ if (authorityMissing.length) {
128
+ console.log(` 权威源声明但薄适配缺失(${authorityMissing.length},apply 不自动创建——薄适配须经 add-host 接管):`);
129
+ for (const { authRel, adapterRel } of authorityMissing) console.log(` - ${authRel} → ${adapterRel}`);
130
+ }
131
+ if (drift.length) {
132
+ console.log(` 正文段漂移(${drift.length},权威源与薄适配正文 sha 不一致——可能是权威源改了或薄适配正文手改了;run with --apply to sync 正文段,frontmatter 不动):`);
133
+ for (const { authRel, adapterRel } of drift) console.log(` - ${authRel} → ${adapterRel}`);
134
+ } else if (!authorityMissing.length) {
135
+ console.log(' 无漂移 ✅');
136
+ }
137
+ if (adapterOrphans.length) {
138
+ console.log(` ⚠️ 孤儿薄适配(${adapterOrphans.length},权威源无对应文件,可能是历史残留或宿主特化;apply 不删):`);
139
+ for (const { adapterRel, authGuess } of adapterOrphans) console.log(` - ${adapterRel}(推测权威源:${authGuess})`);
140
+ }
141
+ }
142
+
143
+ // applyForward:把权威源正文段同步到薄适配(保留薄适配原 frontmatter)
144
+ // 返回:{ synced: [{adapterRel}] }
145
+ function applyForward({ authorityRoot, adaptersRoot }, drift) {
146
+ const synced = [];
147
+ for (const { authRel, adapterRel } of drift) {
148
+ const authAbs = path.join(authorityRoot, authRel);
149
+ const adapterAbs = path.join(adaptersRoot, adapterRel);
150
+ const authText = fs.readFileSync(authAbs, 'utf8');
151
+ const adapterText = fs.readFileSync(adapterAbs, 'utf8');
152
+ const { body } = splitFm(authText);
153
+ const { fm } = splitFm(adapterText);
154
+ // 拼接:薄适配 frontmatter + 权威源正文段
155
+ fs.mkdirSync(path.dirname(adapterAbs), { recursive: true });
156
+ fs.writeFileSync(adapterAbs, `${fm}${body}`);
157
+ synced.push(adapterRel);
158
+ }
159
+ return { synced };
160
+ }
161
+
162
+ export function syncHosts(args, pkgRoot) {
163
+ let apply = false;
164
+ let json = false;
165
+ for (const a of args) {
166
+ if (a === '--diff') apply = false;
167
+ else if (a === '--apply') apply = true;
168
+ else if (a === '--json') json = true;
169
+ else fail(`未知选项:${a}(flow-kit sync-hosts --help)`);
170
+ }
171
+ if (!pkgRoot) fail('sync-hosts 缺少 pkgRoot(开发模式入口)');
172
+
173
+ const authorityRoot = path.join(pkgRoot, 'templates', '_agents');
174
+ const adaptersRoot = path.join(pkgRoot, 'modules', 'hosts');
175
+ const result = diffHosts({ authorityRoot, adaptersRoot });
176
+
177
+ if (apply && result.drift.length > 0) {
178
+ const { synced } = applyForward({ authorityRoot, adaptersRoot }, result.drift);
179
+ console.log(`▶ flow-kit sync-hosts --apply → ${pkgRoot}`);
180
+ console.log(` 按权威源正文覆盖薄适配正文段 ${synced.length} 份(薄适配 frontmatter 不动):`);
181
+ for (const rel of synced) console.log(` - ${rel}`);
182
+ const after = diffHosts({ authorityRoot, adaptersRoot });
183
+ console.log(` 同步后正文对齐:${after.inSync} 对;权威源缺失:${after.authorityMissing.length};正文漂移:${after.drift.length}`);
184
+ return after.drift.length === 0 && after.authorityMissing.length === 0 ? 0 : 1;
185
+ }
186
+
187
+ if (json) {
188
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
189
+ } else {
190
+ console.log(`▶ flow-kit sync-hosts --diff → ${pkgRoot}`);
191
+ printDiff(result);
192
+ }
193
+ return 0;
194
+ }