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
@@ -44,20 +44,40 @@ const BOARD_P = path.join(WIKI, '知识沉淀总览.html');
44
44
  // 主题目录顶层:全量文件登记(现状不变);
45
45
  // 直属子目录:仅登记 md/html 知识文档(排除 *.visual-check.* 工具产物;.json/.png 不计入);
46
46
  // 数据维护主题例外——其子目录(schema变更/<批次>/ 等)走 README.md 台账,不参与登记。
47
+ // gitignore 豁免(F4,incident 2026-09-23-code-review-f3-f4-closeout;p2-batch2 扩面到全 wiki/——
48
+ // 活跃主题目录内的本机文件(如 *.xlsx)同样会造成生成物计数在 fresh clone 漂移):
49
+ // 排除 .gitignore 忽略的本机文件,git 不可用时退化为全量计数
50
+ const gitIgnoredArchive = (() => {
51
+ try {
52
+ const out = execSync('git -c core.quotepath=false status --ignored=matching --porcelain -uall -- wiki',
53
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
54
+ const prefix = 'wiki/';
55
+ return new Set(out.split(/\r?\n/)
56
+ .filter((l) => l.startsWith('!! '))
57
+ .map((l) => l.slice(3).trim().replace(/\/$/, ''))
58
+ .filter((p) => p.startsWith(prefix))
59
+ .map((p) => p.slice(prefix.length)));
60
+ } catch {
61
+ return new Set();
62
+ }
63
+ })();
47
64
  const isKnowledgeDoc = (f) => /\.(md|html)$/i.test(f) && !/\.visual-check\./i.test(f);
48
65
  const diskTopics = new Map(); // name -> [{ name: '文件名或 子目录/文件名', dir: 'wiki/主题[/子目录]/' }]
49
66
  for (const d of fs.readdirSync(WIKI)) {
50
67
  const p = path.join(WIKI, d);
51
68
  if (!fs.statSync(p).isDirectory() || d === 'drafts-archive') continue;
69
+ // gitignore 豁免消费(p2-batch2 扩面落点):gitIgnoredArchive 集合现为全 wiki/ 的忽略件相对路径——
70
+ // 活跃区 entries 构建处按「主题/文件」与「主题/子目录/文件」过滤(复核 P1-2:扩面集合此前无消费点=死代码)
71
+ const isIgnored = (rel) => { const k = rel.endsWith('/') ? rel.slice(0, -1) : rel; return gitIgnoredArchive.has(`${d}/${k}`) || gitIgnoredArchive.has(`${d}/${k}/`); }; // 尾斜杠归一(复核 P2-a;endsWith 替代正则——此文件转义层过于嘈杂)
52
72
  const entries = fs.readdirSync(p)
53
- .filter((f) => fs.statSync(path.join(p, f)).isFile())
73
+ .filter((f) => fs.statSync(path.join(p, f)).isFile() && !isIgnored(f))
54
74
  .map((f) => ({ name: f, dir: `wiki/${d}/` }));
55
75
  if (d !== '数据维护') {
56
76
  for (const sub of fs.readdirSync(p)) {
57
77
  const sp = path.join(p, sub);
58
- if (!fs.statSync(sp).isDirectory()) continue;
78
+ if (!fs.statSync(sp).isDirectory() || isIgnored(`${sub}/`)) continue;
59
79
  for (const f of fs.readdirSync(sp)) {
60
- if (fs.statSync(path.join(sp, f)).isFile() && isKnowledgeDoc(f)) {
80
+ if (fs.statSync(path.join(sp, f)).isFile() && isKnowledgeDoc(f) && !isIgnored(`${sub}/${f}`)) {
61
81
  entries.push({ name: `${sub}/${f}`, dir: `wiki/${d}/${sub}/` });
62
82
  }
63
83
  }
@@ -66,29 +86,15 @@ for (const d of fs.readdirSync(WIKI)) {
66
86
  entries.sort((a, b) => a.name.localeCompare(b.name, 'zh'));
67
87
  diskTopics.set(d, entries);
68
88
  }
69
- // gitignore 豁免(F4,incident 2026-09-23-code-review-f3-f4-closeout):与 verify-wiki-consistency.mjs 同口径——
70
- // 排除 .gitignore 忽略的本机文件(如 *.xlsx),防止生成物计数在 fresh clone 漂移;git 不可用时退化为全量计数
71
- const gitIgnoredArchive = (() => {
72
- try {
73
- const out = execSync('git -c core.quotepath=false status --ignored=matching --porcelain -uall -- wiki/drafts-archive',
74
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
75
- const prefix = 'wiki/drafts-archive/';
76
- return new Set(out.split(/\r?\n/)
77
- .filter((l) => l.startsWith('!! '))
78
- .map((l) => l.slice(3).trim().replace(/\/$/, ''))
79
- .filter((p) => p.startsWith(prefix))
80
- .map((p) => p.slice(prefix.length)));
81
- } catch {
82
- return new Set();
83
- }
84
- })();
89
+
85
90
  const archiveCount = (() => {
86
91
  let n = 0;
87
92
  const walk = (dir, rel = '') => {
88
93
  for (const e of fs.readdirSync(dir)) {
89
94
  const p = path.join(dir, e);
90
- const r = rel ? `${rel}/${e}` : e;
91
- if (fs.statSync(p).isDirectory()) walk(p, r);
95
+ // 集合键带 drafts-archive/ 前缀(扫描面扩全 wiki/ 后),walk rel 须拼前缀比对(复核 P2-b)
96
+ const r = rel ? `drafts-archive/${rel}/${e}` : `drafts-archive/${e}`;
97
+ if (fs.statSync(p).isDirectory()) walk(p, rel ? `${rel}/${e}` : e);
92
98
  else if (!gitIgnoredArchive.has(r)) n++;
93
99
  }
94
100
  };
@@ -100,11 +106,19 @@ const archiveCount = (() => {
100
106
  // ---- 读 INDEX 现速览表:主题顺序 + 用途列(人工,不覆盖)----
101
107
  // 切行须容忍 CRLF:按 '\n' 切会在行尾留 '\r',速览表行锚定正则(\|$)整表失配 →
102
108
  // 「用途」列(唯一人工维护位)被静默重置为 <待补>(papercut 2026-09-17;fixture 见 gen-wiki-board.test.mjs 场景 1)
109
+ // 用途列管道转义(p2-batch2):生成侧对单元格 `|` → `\|`(行内码 \`|\`),解析侧容忍转义——
110
+ // 否则含管道的用途描述会切坏表格行、下次生成该行解析失配走「新主题追加」把人工文本静默重置
111
+ const BS = String.fromCharCode(92); // 反斜杠字符(转义写法在此文件里反复出错,用 charCode 表达)
112
+ const PIPE_CH = '|';
113
+ const escCell = (s) => String(s).split(PIPE_CH).join(BS + PIPE_CH);
114
+ const unescCell = (s) => String(s).split(BS + PIPE_CH).join(PIPE_CH);
103
115
  const indexText = fs.readFileSync(INDEX_P, 'utf8');
104
116
  const overview = []; // {name, usage}
117
+ // 解析正则(p2-batch2):用途列允许转义管道 \|--`(?:\\.|[^|])*` 吃掉「转义字符+任一字符」或普通字符,
118
+ // 裸管道才是列分隔。unescCell 还原人工原文。
105
119
  for (const line of indexText.split(/\r?\n/)) {
106
- const m = line.match(/^\| ([^|]+) \| (\d+) \|([^|]*)\|$/);
107
- if (m && m[1].trim() !== '主题') overview.push({ name: m[1].trim(), usage: m[3].trim() });
120
+ const m = line.match(/^\| ([^|]+) \| (\d+) \|((?:\\.|[^|])*)\|$/);
121
+ if (m && m[1].trim() !== '主题') overview.push({ name: m[1].trim(), usage: unescCell(m[3].trim()) });
108
122
  }
109
123
  // 主题顺序 = 速览表现顺序在前,磁盘新主题按中文排序追加
110
124
  const ordered = [...overview.filter((o) => diskTopics.has(o.name))];
@@ -116,7 +130,7 @@ const totalFiles = [...diskTopics.values()].reduce((n, f) => n + f.length, 0);
116
130
 
117
131
  // ---- 生成 INDEX 三段 ----
118
132
  const eol = indexText.includes('\r\n') ? '\r\n' : '\n';
119
- const overviewRows = ordered.map((o) => `| ${o.name} | ${diskTopics.get(o.name).length} | ${o.usage} |`);
133
+ const overviewRows = ordered.map((o) => `| ${o.name} | ${diskTopics.get(o.name).length} | ${escCell(o.usage)} |`);
120
134
  const mappingSection = ordered.map((o) => {
121
135
  const rows = diskTopics.get(o.name).map((e) => `| ${e.name} | ${o.name} | ${e.dir} |`);
122
136
  return [`### ${o.name}(${diskTopics.get(o.name).length})`, '', '| 文件名 | 主题 | 归属目录 |', '|---|---|---|', ...rows].join(eol);
@@ -150,6 +150,24 @@ const runGen = (root) => spawnSync(process.execPath, [GEN], { cwd: root, encodin
150
150
  fs.rmSync(root, { recursive: true, force: true });
151
151
  }
152
152
 
153
+ // ---- 场景 6:用途列含管道符 → 转义保 round-trip(p2-batch2:含管道的用途描述曾切坏表格行、
154
+ // 下次生成把人工文本静默重置为 <待补>)----
155
+ {
156
+ const root = mkfix('\n');
157
+ const BS = String.fromCharCode(92); // 反斜杠字符(JS 字符串 '\|' 中 \| 就是裸管道无转义语义——须显式拼)
158
+ const USAGE_WITH_PIPE = '用于 A' + BS + '|B 两类(含竖线)'; // 转义形态输入(反斜杠+管道)
159
+ const idx = path.join(root, 'wiki', 'INDEX.md');
160
+ fs.writeFileSync(idx, fs.readFileSync(idx, 'utf8').replace(USAGE_COL, USAGE_WITH_PIPE), 'utf8');
161
+ const r1 = runGen(root);
162
+ const out1 = fs.readFileSync(idx, 'utf8');
163
+ check('场景 6:含转义管道用途生成 exit 0 且原样保留', r1.status === 0 && out1.includes(USAGE_WITH_PIPE), `exit=${r1.status}\n${out1.split('\n').slice(0, 8).join('\n')}`);
164
+ const r2 = runGen(root);
165
+ const out2 = fs.readFileSync(idx, 'utf8');
166
+ check('场景 6:二次生成用途文本保持(round-trip 不重置为 <待补>)',
167
+ r2.status === 0 && out2.includes(USAGE_WITH_PIPE) && !out2.includes('<待补'), out2.split('\n').slice(0, 8).join('\n'));
168
+ fs.rmSync(root, { recursive: true, force: true });
169
+ }
170
+
153
171
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
154
172
  if (fail) {
155
173
  console.log(`\n${USAGE}`);
@@ -10,6 +10,7 @@
10
10
  // 生成区:INDEX.md 的 GENERATED:BEGIN/END 锚点注释之间整段重写,锚点外内容原样保留。
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
+ import { ENUMS } from './workflow-enums.mjs';
13
14
 
14
15
  const USAGE = `用法:node .agents/scripts/gen-workflow-index.mjs [--dry-run] [--check] [--help]
15
16
 
@@ -32,14 +33,15 @@ const WORKFLOW = 'workflow';
32
33
  const INDEX_P = path.join(WORKFLOW, 'INDEX.md');
33
34
  const DOC_TYPES = ['intents', 'specs', 'plans', 'incidents'];
34
35
  const TYPE_LABEL = { intents: 'INTENT', specs: 'SPEC', plans: 'PLAN', incidents: 'INCIDENT' };
35
- const ACTIVE_STATUS = ['draft', 'approved', 'open']; // 其余(done/fixed/closed/superseded/cancelled)折叠进档案计数
36
+ // 活跃口径 = 单源 doc.status.active ∪ incident.status.active;其余状态折叠进档案计数
37
+ const ACTIVE_STATUS = [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']];
36
38
 
37
39
  const BEGIN = '<!-- GENERATED:BEGIN — gen-workflow-index.mjs 整段重写,手工说明写在本行之前 -->';
38
40
  const END = '<!-- GENERATED:END -->';
39
41
  const HEADER = `# workflow 索引 — 活跃层与档案(生成物,勿手改)
40
42
 
41
43
  > 生成器 \`node .agents/scripts/gen-workflow-index.mjs\`(状态变更 / 新建文档后重跑;\`--check\` 校验漂移,check-loop 会告警)。
42
- > 活跃 = draft / approved / open(在跑);其余状态折叠进档案计数,检索用 \`node .agents/scripts/kb-search.mjs "<词>"\`(默认含全部状态、活跃优先排序)。
44
+ > 活跃 = ${ACTIVE_STATUS.join(' / ')}(在跑);其余状态折叠进档案计数,检索用 \`node .agents/scripts/kb-search.mjs "<词>"\`(默认含全部状态、活跃优先排序)。
43
45
  > 模块词表见 \`.agents/workflow-modules.txt\`。
44
46
  `;
45
47
 
@@ -4,11 +4,15 @@
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 用
8
+ // 索引 LF 归一字节——趋势观测可对比,但与门禁判定数值存在系统性差异,勿互相替用。
7
9
  // 不做 --check / 不挂门禁:快照是历史记录——任何文档改动都会让「当前值」漂移,拿它做漂移校验只会常红;
8
10
  // 规则面的机器约束由 pre-commit 的 rule-budget.sh 负责,本脚本只负责「看见趋势」。
9
11
  // 测试:node .agents/scripts/gen-workflow-metrics.test.mjs(fixture 回归,须全绿)
10
12
  import fs from 'node:fs';
11
13
  import path from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { loadEnums } from './workflow-enums.mjs';
12
16
 
13
17
  const USAGE = `用法:node .agents/scripts/gen-workflow-metrics.mjs [--month YYYY-MM] [--dry-run] [--help]
14
18
 
@@ -39,7 +43,10 @@ const WORKFLOW = 'workflow';
39
43
  const METRICS_P = path.join(WORKFLOW, 'metrics.md');
40
44
  const BUDGETS = path.join('.agents', 'rule-budgets.txt');
41
45
  const DOC_TYPES = ['intents', 'specs', 'plans', 'incidents'];
42
- const ACTIVE_STATUS = ['draft', 'approved', 'open'];
46
+ // 活跃口径单源(board-kb-p1:枚举字面量硬编码退役——workflow-enums.txt 是唯一源);
47
+ // 枚举路径锚定脚本位置(fixture 测试 cwd 在临时目录,按 cwd 解析会 ENOENT)
48
+ const ENUMS = loadEnums(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '.agents', 'workflow-enums.txt'));
49
+ const ACTIVE_STATUS = [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']];
43
50
  const BEGIN = '<!-- GENERATED:BEGIN — gen-workflow-metrics.mjs 整段重写,手工说明写在本行之前 -->';
44
51
  const END = '<!-- GENERATED:END -->';
45
52
 
@@ -51,6 +58,7 @@ function scanDocs() {
51
58
  let files = 0;
52
59
  let bytes = 0;
53
60
  let active = 0;
61
+ let invalid = 0;
54
62
  let withModule = 0;
55
63
  for (const type of DOC_TYPES) {
56
64
  let n = 0;
@@ -72,6 +80,7 @@ function scanDocs() {
72
80
  }
73
81
  }
74
82
  if (ACTIVE_STATUS.includes(status)) active++;
83
+ else if (!status || ![...ENUMS['doc.status.all'], ...ENUMS['incident.status.all']].includes(status)) invalid++; // 状态缺失/非法单列(board-kb-p1:不再吞进终态桶)
75
84
  if (mod) withModule++;
76
85
  n++;
77
86
  b += fs.statSync(p).size;
@@ -80,7 +89,7 @@ function scanDocs() {
80
89
  files += n;
81
90
  bytes += b;
82
91
  }
83
- return { perType, files, bytes, active, terminal: files - active, withModule };
92
+ return { perType, files, bytes, active, terminal: files - active - invalid, invalid, withModule };
84
93
  }
85
94
 
86
95
  // ---- 常驻面:按预算表逐条实测(<glob|dir/> <上限>)----
@@ -146,7 +155,7 @@ const row = `| ${month} | ${docs.files}(活跃 ${docs.active} / 终态 ${docs.
146
155
 
147
156
  // ---- 明细(stdout,不落表):类型分布 + 逐条预算占用 ----
148
157
  console.log(`📅 ${month} 快照`);
149
- console.log(` 文档:${docs.files} 篇 / ${kb(docs.bytes)}(活跃 ${docs.active} / 终态 ${docs.terminal});模块已填 ${docs.withModule}/${docs.files}`);
158
+ console.log(` 文档:${docs.files} 篇 / ${kb(docs.bytes)}(活跃 ${docs.active} / 终态 ${docs.terminal}${docs.invalid ? ` / 状态缺失或非法 ${docs.invalid}` : ''});模块已填 ${docs.withModule}/${docs.files}`);
150
159
  for (const [t, v] of Object.entries(docs.perType)) console.log(` ${t.padEnd(10)} ${String(v.n).padStart(3)} 篇 / ${kb(v.b)}`);
151
160
  console.log(` 索引:workflow/INDEX.md ${kb(idxBytes)}(活跃 ${Number.isNaN(idxActive) ? '—' : idxActive} 行)`);
152
161
  for (const e of surf.entries) console.log(` ${e.label.padEnd(44)} ${String(e.max).padStart(6)} / ${String(e.lim).padStart(6)} B(${e.pct.toFixed(1)}%)`);
@@ -16,6 +16,7 @@ import fs from 'node:fs';
16
16
  import path from 'node:path';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import { ENUMS } from './workflow-enums.mjs';
19
20
 
20
21
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
21
22
  const MODULES_FILE = path.join(SCRIPT_DIR, '..', 'workflow-modules.txt');
@@ -30,6 +31,7 @@ const USAGE = `用法: node .agents/scripts/kb-search.mjs [选项] <词1> [词2
30
31
  -n <N> 每语料展示文件数上限,默认 10;0 = 不限
31
32
  --include-generated wiki:纳入默认跳过的 json/csv、*.visual-check.* 与 >180KB bulk 文件(走纯扫,不吃缓存)
32
33
  --no-cache 禁用结构缓存,全量扫(与缓存热路径输出逐字节一致)
34
+ --cache <文件> 结构缓存文件路径覆盖(默认 .agents/cache/kb-index.json;测试注入用)
33
35
  --rebuild 忽略现有缓存重建后回写
34
36
  --help 打印本说明
35
37
 
@@ -46,6 +48,7 @@ let limit = 10;
46
48
  let limitRaw = '10';
47
49
  let includeGenerated = false;
48
50
  let noCache = false;
51
+ let cacheFileOverride = ''; // --cache <文件>(board-kb-p1):测试注入式缓存路径,不再直跑清真实开发缓存
49
52
  let rebuild = false;
50
53
  const words = [];
51
54
  for (let i = 0; i < args.length; i++) {
@@ -58,11 +61,16 @@ for (let i = 0; i < args.length; i++) {
58
61
  else if (a === '-n') { limitRaw = args[++i] ?? ''; limit = Number(limitRaw); }
59
62
  else if (a === '--include-generated') includeGenerated = true;
60
63
  else if (a === '--no-cache') noCache = true;
64
+ else if (a === '--cache') cacheFileOverride = args[++i] ?? '';
61
65
  else if (a === '--rebuild') rebuild = true;
62
66
  else if (a === '--help' || a === '-h') { console.log(USAGE); process.exit(0); }
63
67
  else words.push(a);
64
68
  }
65
69
  if (!words.length) { console.error(USAGE); process.exit(1); }
70
+ // 空 query 校验(board-kb-p1):空字符串词 includes('') 恒真 → 全量命中噪声(实测 109 文件命中)——参数错误
71
+ const qWords = words.map((w) => w.trim()).filter(Boolean);
72
+ if (!qWords.length) { console.error(`检索词不能为空(纯空白/空串)\n\n${USAGE}`); process.exit(1); }
73
+ words.length = 0; words.push(...qWords); // 原位替换,下游 searchWorkflow/searchWiki 传参不变
66
74
  if (!Number.isFinite(limit) || limit < 0) { console.error(`-n 须为非负整数(现 ${limitRaw})\n\n${USAGE}`); process.exit(1); }
67
75
  if (!scope) scope = topicFilter ? 'wiki' : 'all';
68
76
  if (!['workflow', 'wiki', 'all'].includes(scope)) { console.error(`--scope 须为 workflow|wiki|all(现 ${scope})\n\n${USAGE}`); process.exit(1); }
@@ -78,7 +86,8 @@ if (moduleFilter && !MODULES.includes(moduleFilter)) {
78
86
  process.exit(1);
79
87
  }
80
88
 
81
- const ACTIVE_STATUS = ['draft', 'approved', 'open']; // 与 workflow/INDEX.md 活跃口径一致(fixed 归档案,默认也可命中)
89
+ // 活跃口径 = 单源 doc.status.active ∪ incident.status.active(与 workflow/INDEX.md 活跃层一致,fixed 归档案默认也可命中)
90
+ const ACTIVE_STATUS = [...ENUMS['doc.status.active'], ...ENUMS['incident.status.active']];
82
91
  const WF_SECTIONS = ['背景', '目标', '根因', '复盘三件套', '验收标准', '影响面'];
83
92
  const WIKI_EXCLUDE = new Set(['INDEX.md', '知识沉淀总览.html']);
84
93
  const WIKI_MAX_KB = 180;
@@ -135,12 +144,23 @@ function matchWords(lines, words) {
135
144
  // ---- 结构缓存:可检索子集预提取,[mtimeMs,size] 两元组逐文件失效,指纹变即整份作废 ----
136
145
  // 键 = <检索根 cwd>::<相对路径>(多语料根共存一文件,fixture 临时目录天然隔离);读写任何异常静默回退全扫。
137
146
  const CACHE_DIR = path.join(SCRIPT_DIR, '..', 'cache');
138
- const CACHE_FILE = path.join(CACHE_DIR, 'kb-index.json');
147
+ const CACHE_FILE = cacheFileOverride || path.join(CACHE_DIR, 'kb-index.json');
139
148
  const ROOT_NS = `${path.resolve('.')}::`;
149
+ // WIKI_EXTS:wiki 语料扩展面(含 --include-generated 分支)——上移至 CFG_FP 之前(board-kb-p1 起纳入指纹派生)
150
+ const WIKI_EXTS = includeGenerated ? /\.(md|sql|html|csv|json|txt)$/i : /\.(md|sql|html|txt)$/i;
151
+
152
+ // 行序列化/复活(缓存 schema 的定义者——board-kb-p1 起纳入 CFG_FP 指纹:schema 演进即整份缓存作废)
153
+ const serializeLines = (lines, kind) => lines.map((x) => (kind === 'wf' ? [x.ln, x.section, x.line] : [x.ln, x.line]));
154
+ const reviveLines = (lines, kind) => lines.map((a) => (kind === 'wf' ? { ln: a[0], section: a[1], line: a[2] } : { ln: a[0], line: a[1] }));
155
+
156
+ // linesShapeOk:缓存条目 lines 形状校验(board-kb-p1)——JSON 合法但 schema 坏(lines 非数组 / 元素非数组)
157
+ // 一律视同 miss 走 build 重建(头注释「任何读写异常静默回退」契约恢复;此前直接 .map 抛 TypeError exit 1)
158
+ const linesShapeOk = (lines) => Array.isArray(lines) && (lines.length === 0 || lines.every(Array.isArray));
159
+
140
160
  const CFG_FP = createHash('sha256').update(JSON.stringify({
141
161
  v: 1, WF_SECTIONS, ACTIVE_STATUS, WIKI_MAX_KB, excl: [...WIKI_EXCLUDE],
142
- exts: ['md', 'sql', 'html', 'txt', 'gen:csv|json'],
143
- fns: [parseDoc.toString(), workflowLines.toString(), matchWords.toString()],
162
+ exts: WIKI_EXTS.source, // schema 派生(board-kb-p1:手抄摘要曾致扩展面演进不触发指纹失效;RegExp.source 跨 includeGenerated 分支稳定)
163
+ fns: [parseDoc.toString(), workflowLines.toString(), matchWords.toString(), serializeLines.toString(), reviveLines.toString()],
144
164
  })).digest('hex').slice(0, 16);
145
165
  const cacheUsable = !noCache && !includeGenerated;
146
166
  let cache = null;
@@ -153,9 +173,6 @@ if (cacheUsable) {
153
173
  if (rebuild) cache = {};
154
174
  }
155
175
 
156
- const serializeLines = (lines, kind) => lines.map((x) => (kind === 'wf' ? [x.ln, x.section, x.line] : [x.ln, x.line]));
157
- const reviveLines = (lines, kind) => lines.map((a) => (kind === 'wf' ? { ln: a[0], section: a[1], line: a[2] } : { ln: a[0], line: a[1] }));
158
-
159
176
  // 取一个源文件的缓存条目;miss 时 build()(读全文提取可检索子集)并登记回写。返回 {st, meta?, title?, lines}
160
177
  function cachedEntry(relPath, kind, build) {
161
178
  const abs = path.resolve(relPath);
@@ -168,7 +185,7 @@ function cachedEntry(relPath, kind, build) {
168
185
  const st = fs.statSync(abs);
169
186
  const sig = [st.mtimeMs, st.size];
170
187
  const e = cache[key];
171
- if (e && e.kind === kind && Array.isArray(e.st) && e.st[0] === sig[0] && e.st[1] === sig[1]) {
188
+ if (e && e.kind === kind && Array.isArray(e.st) && e.st[0] === sig[0] && e.st[1] === sig[1] && linesShapeOk(e.lines)) {
172
189
  touched.add(key);
173
190
  return { st: e.st, meta: e.meta, title: e.title, lines: reviveLines(e.lines, kind) };
174
191
  }
@@ -234,7 +251,6 @@ function searchWorkflow(words) {
234
251
  }
235
252
 
236
253
  // ---- wiki 检索(口径同 2026-09-21 降噪版 wiki-search)----
237
- const WIKI_EXTS = includeGenerated ? /\.(md|sql|html|csv|json|txt)$/i : /\.(md|sql|html|txt)$/i;
238
254
  const walkWiki = (dir, rel) => {
239
255
  const out = [];
240
256
  for (const e of fs.readdirSync(dir)) {
@@ -14,8 +14,10 @@ import { fileURLToPath } from 'node:url';
14
14
 
15
15
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
16
16
  const KB = path.join(SCRIPT_DIR, 'kb-search.mjs');
17
- const CACHE_FILE = path.join(SCRIPT_DIR, '..', 'cache', 'kb-index.json');
17
+ // 注入式缓存(board-kb-p1):--cache 指向 fixture 内路径——测试不再触碰真实 .agents/cache/kb-index.json
18
+ // (旧 CACHE_FILE 直跑会清空真实开发缓存并遗留孤儿条目,审查 P2-2)
18
19
  const USAGE = '用法:node .agents/scripts/kb-search.test.mjs';
20
+ const rootCache = (root) => path.join(root, 'kb-cache.json');
19
21
 
20
22
  let pass = 0;
21
23
  let fail = 0;
@@ -46,7 +48,7 @@ const mkfix = () => {
46
48
  return root;
47
49
  };
48
50
 
49
- const run = (root, args) => spawnSync(process.execPath, [KB, ...args], { cwd: root, encoding: 'utf8' });
51
+ const run = (root, args) => spawnSync(process.execPath, [KB, '--cache', rootCache(root), ...args], { cwd: root, encoding: 'utf8' });
50
52
  const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l.replace('📄 ', ''));
51
53
 
52
54
  // ---- 场景 1:主路径——AND 语义 / 节限定 / 输出字段 / 活跃优先 ----
@@ -97,7 +99,7 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
97
99
  const cold = run(root, ['编码模板']); // 该临时根 ns 无条目 → cold
98
100
  const warm = run(root, ['编码模板']); // 全命中缓存
99
101
  const nocache = run(root, ['--no-cache', '编码模板']);
100
- check('场景 4:cold/warm exit 0 且 warm 确有缓存回写', cold.status === 0 && warm.status === 0 && fs.existsSync(CACHE_FILE), `cold=${cold.status} warm=${warm.status}`);
102
+ check('场景 4:cold/warm exit 0 且 warm 确有缓存回写', cold.status === 0 && warm.status === 0 && fs.existsSync(rootCache(root)), `cold=${cold.status} warm=${warm.status}`);
101
103
  check('场景 4:cold ≡ warm(逐字节)', cold.stdout === warm.stdout, `cold:\n${cold.stdout}\nwarm:\n${warm.stdout}`);
102
104
  check('场景 4:cold ≡ --no-cache(逐字节)', cold.stdout === nocache.stdout, `cold:\n${cold.stdout}\nnocache:\n${nocache.stdout}`);
103
105
  check('场景 4:双侧均有命中(workflow 3 + wiki 1)', files(cold.stdout).length === 3 && /wiki\/测试主题/.test(cold.stdout), cold.stdout);
@@ -121,14 +123,14 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
121
123
  {
122
124
  const root = mkfix();
123
125
  run(root, ['编码模板']); // 建缓存
124
- const j = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
126
+ const j = JSON.parse(fs.readFileSync(rootCache(root), 'utf8'));
125
127
  const fpBefore = j.fp;
126
128
  j.fp = 'ffffffffffffffff';
127
- fs.writeFileSync(CACHE_FILE, JSON.stringify(j), 'utf8');
129
+ fs.writeFileSync(rootCache(root), JSON.stringify(j), 'utf8');
128
130
  const after = run(root, ['编码模板']);
129
131
  const scan = run(root, ['--no-cache', '编码模板']);
130
132
  check('场景 6:指纹不符 → 输出仍与纯扫逐字节一致', after.status === 0 && after.stdout === scan.stdout, `after:\n${after.stdout}\nscan:\n${scan.stdout}`);
131
- const j2 = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
133
+ const j2 = JSON.parse(fs.readFileSync(rootCache(root), 'utf8'));
132
134
  check('场景 6:回写恢复正确指纹', j2.fp === fpBefore && j2.fp !== 'ffffffffffffffff', `fp=${j2.fp}`);
133
135
  fs.rmSync(root, { recursive: true, force: true });
134
136
  }
@@ -136,8 +138,8 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
136
138
  // ---- 场景 7:缓存损坏 → 静默回退全扫(不报错、输出与 --no-cache 一致) ----
137
139
  {
138
140
  const root = mkfix();
139
- fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
140
- fs.writeFileSync(CACHE_FILE, 'not-json{{{', 'utf8');
141
+ fs.mkdirSync(path.dirname(rootCache(root)), { recursive: true });
142
+ fs.writeFileSync(rootCache(root), 'not-json{{{', 'utf8');
141
143
  const r = run(root, ['编码模板']);
142
144
  const scan = run(root, ['--no-cache', '编码模板']);
143
145
  check('场景 7:损坏缓存 exit 0(stderr 无泄漏)', r.status === 0 && !r.stderr, `exit=${r.status}\n${r.stderr}`);
@@ -145,6 +147,34 @@ const files = (out) => (out.match(/^📄 (workflow\/\S+)/gm) || []).map((l) => l
145
147
  fs.rmSync(root, { recursive: true, force: true });
146
148
  }
147
149
 
150
+ // ---- 场景 8:结构损坏(JSON 合法但 lines 非数组 / 元素非数组)→ 静默回退(board-kb-p1 P1-1)----
151
+ // 此前 reviveLines 直接 .map 抛 TypeError exit 1,违背「任何读写异常静默回退」声明契约
152
+ {
153
+ for (const [tag, lines] of [['lines-字符串', 'corrupted'], ['lines-元素非数组', ['not-array']]]) {
154
+ const root = mkfix();
155
+ run(root, ['编码模板']); // 建缓存
156
+ const j = JSON.parse(fs.readFileSync(rootCache(root), 'utf8'));
157
+ const key = Object.keys(j.files).find((k) => k.endsWith('2026-09-01-aaa.md'));
158
+ j.files[key].lines = lines;
159
+ fs.writeFileSync(rootCache(root), JSON.stringify(j), 'utf8');
160
+ const r = run(root, ['编码模板']);
161
+ const scan = run(root, ['--no-cache', '编码模板']);
162
+ check(`场景 8:结构损坏(${tag})exit 0 静默回退`, r.status === 0 && !r.stderr, `exit=${r.status}\n${r.stderr}`);
163
+ check(`场景 8:结构损坏(${tag})输出与纯扫一致`, r.stdout === scan.stdout, `r:\n${r.stdout}\nscan:\n${scan.stdout}`);
164
+ fs.rmSync(root, { recursive: true, force: true });
165
+ }
166
+ }
167
+
168
+ // ---- 场景 9:空 query(空串 / 纯空白)→ 参数错误 exit 1(board-kb-p1:includes('') 恒真曾全量命中)----
169
+ {
170
+ const root = mkfix();
171
+ const r1 = run(root, ['']);
172
+ const r2 = run(root, [' ', ' ']);
173
+ check('场景 9:空串 query exit 1', r1.status === 1 && /检索词不能为空/.test(r1.stderr), `exit=${r1.status}\n${r1.stderr}`);
174
+ check('场景 9:纯空白 query exit 1', r2.status === 1 && /检索词不能为空/.test(r2.stderr), `exit=${r2.status}\n${r2.stderr}`);
175
+ fs.rmSync(root, { recursive: true, force: true });
176
+ }
177
+
148
178
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
149
179
  if (fail) {
150
180
  console.log(`\n${USAGE}`);
@@ -0,0 +1,145 @@
1
+ // flow-kit source-sync-check:装户面同步一致性检查(包源 templates/_agents/ vs 装副本 .agents/)
2
+ // 目的:包源改了 templates/_agents/ 但 .agents/ 装副本没刷新——这种"真空地带"sync.mjs 不扫、doctor.mjs §6.6 不扫
3
+ // 本工具扫 templates/_agents/ 下所有 .md / .mjs / .json / .txt(排除 cache/)vs .agents/ 对应路径
4
+ // 输出三类差异:缺失(包源有装副本无)/ 孤儿(包源无装副本有)/ 漂移(两者都有 sha 不一致)
5
+ // 排除装副本独有文件:kit.json / settings.json / hooks/commit-check.config.json(init 渲染产物,不属双源)
6
+ // 用法:node .agents/scripts/source-sync-check.mjs --diff
7
+ // node .agents/scripts/source-sync-check.mjs --json
8
+ // node .agents/scripts/source-sync-check.mjs --gate(门禁模式:缺失/漂移 exit 1,孤儿只报告不计失败
9
+ // ——pre-commit 双源一致性门禁用,2026-09-27 audit-gate-hardening;--diff/--json 仍恒 exit 0)
10
+ // node .agents/scripts/source-sync-check.mjs --pkg-root <path> --target <path>
11
+ // 零依赖;B-b 决策「只报告不修复」(--gate 只加 exit 码裁决,不改默认行为)
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { createHash } from 'node:crypto';
15
+
16
+ function fail(msg) { console.error('❌ ' + msg); process.exit(1); }
17
+
18
+ const VALID_EXTS = new Set(['.md', '.mjs', '.json', '.txt']);
19
+ // 装副本独有文件(init 渲染产物,不属双源结构)
20
+ const RENDER_OUTPUT_FILES = new Set(['kit.json', 'settings.json', 'hooks/commit-check.config.json']);
21
+ const TARGET_EXCLUDE = RENDER_OUTPUT_FILES; // 向后兼容别名
22
+
23
+ function sha256(p) {
24
+ try {
25
+ // LF 归一(2026-09-27 closing-coverage):CRLF 盘面与 LF 克隆同值,行尾不一致不误报缺失/漂移/孤儿外差异
26
+ return createHash('sha256').update(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8').digest('hex');
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ // walkMd:递归扫目录,列出所有 validExt 文件(排除 cache/)
33
+ function walkMd(root) {
34
+ const out = [];
35
+ function walk(dir, relBase) {
36
+ if (!fs.existsSync(dir)) return;
37
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
38
+ const rel = relBase ? relBase + '/' + e.name : e.name;
39
+ const abs = path.join(dir, e.name);
40
+ // 排除 cache/ 目录(与 sync.mjs 既有约定一致)
41
+ if (rel === 'cache' || rel.startsWith('cache/')) continue;
42
+ if (e.isDirectory()) walk(abs, rel);
43
+ else if (VALID_EXTS.has(path.extname(e.name))) out.push(rel);
44
+ }
45
+ }
46
+ walk(root, '');
47
+ return out;
48
+ }
49
+
50
+ export function sourceSyncCheck({ pkgRoot, target }) {
51
+ const authorityRoot = path.join(pkgRoot, 'templates', '_agents');
52
+ const adapterRoot = path.join(target, '.agents');
53
+ const pkgFiles = walkMd(authorityRoot);
54
+ const tgtFiles = walkMd(adapterRoot).filter((f) => !TARGET_EXCLUDE.has(f));
55
+ const pkgSet = new Set(pkgFiles);
56
+ const tgtSet = new Set(tgtFiles);
57
+ const missing = []; // 包源有 / 装副本无
58
+ const orphan = []; // 包源无 / 装副本有
59
+ const drift = []; // 两者都有但 sha 不一致
60
+ for (const f of pkgFiles) {
61
+ if (!tgtSet.has(f)) { missing.push({ rel: f }); continue; }
62
+ const pkgSha = sha256(path.join(authorityRoot, f));
63
+ const tgtSha = sha256(path.join(adapterRoot, f));
64
+ if (pkgSha !== null && tgtSha !== null && pkgSha !== tgtSha) {
65
+ drift.push({ rel: f, pkgSha, tgtSha });
66
+ }
67
+ }
68
+ for (const f of tgtFiles) {
69
+ if (!pkgSet.has(f)) orphan.push({ rel: f });
70
+ }
71
+ return {
72
+ pkgRoot, target,
73
+ pkgCount: pkgFiles.length,
74
+ tgtCount: tgtFiles.length,
75
+ missing: missing.sort((a, b) => a.rel.localeCompare(b.rel)),
76
+ orphan: orphan.sort((a, b) => a.rel.localeCompare(b.rel)),
77
+ drift: drift.sort((a, b) => a.rel.localeCompare(b.rel)),
78
+ };
79
+ }
80
+
81
+ function printDiff(result) {
82
+ console.log('▶ flow-kit source-sync-check --diff');
83
+ console.log(' 包源 (templates/_agents/) ' + result.pkgCount + ' 份 | 装副本 (.agents/) ' + result.tgtCount + ' 份');
84
+ if (!result.missing.length && !result.orphan.length && !result.drift.length) {
85
+ console.log(' 无差异 ✅');
86
+ return;
87
+ }
88
+ if (result.missing.length) {
89
+ console.log(' 缺失(包源有 / 装副本无)' + result.missing.length + ':');
90
+ for (const m of result.missing) console.log(' - ' + m.rel);
91
+ }
92
+ if (result.orphan.length) {
93
+ console.log(' 孤儿(包源无 / 装副本有)' + result.orphan.length + ':');
94
+ for (const o of result.orphan) console.log(' - ' + o.rel);
95
+ }
96
+ if (result.drift.length) {
97
+ console.log(' 漂移(包源装副本都有但 sha 不一致)' + result.drift.length + ':');
98
+ for (const d of result.drift) console.log(' - ' + d.rel + ' pkg=' + d.pkgSha.slice(0, 8) + '… tgt=' + d.tgtSha.slice(0, 8) + '…');
99
+ }
100
+ }
101
+
102
+ const isMain = process.argv[1] && process.argv[1].endsWith('source-sync-check.mjs');
103
+ if (isMain) {
104
+ let jsonMode = false, diffMode = false, gateMode = false;
105
+ let pkgRoot = null, target = null;
106
+ for (let i = 0; i < process.argv.length - 2; i++) {
107
+ const a = process.argv[2 + i];
108
+ if (a === '--json') jsonMode = true;
109
+ else if (a === '--diff') diffMode = true;
110
+ else if (a === '--gate') gateMode = true;
111
+ else if (a === '--pkg-root') pkgRoot = process.argv[2 + i + 1];
112
+ else if (a === '--target') target = process.argv[2 + i + 1];
113
+ }
114
+ if (!pkgRoot) pkgRoot = process.cwd();
115
+ if (!target) target = process.cwd();
116
+ const authorityRoot = path.join(pkgRoot, 'templates', '_agents');
117
+ const adapterRoot = path.join(target, '.agents');
118
+ if (!fs.existsSync(authorityRoot)) fail('找不到包源:' + authorityRoot);
119
+ if (!fs.existsSync(adapterRoot)) fail('找不到装副本:' + adapterRoot);
120
+ const result = sourceSyncCheck({ pkgRoot, target });
121
+ if (jsonMode) {
122
+ process.stdout.write(JSON.stringify({
123
+ pkgCount: result.pkgCount,
124
+ tgtCount: result.tgtCount,
125
+ missing: result.missing.map((m) => m.rel),
126
+ orphan: result.orphan.map((o) => o.rel),
127
+ drift: result.drift.map((d) => ({ rel: d.rel, pkgSha: d.pkgSha.slice(0, 8), tgtSha: d.tgtSha.slice(0, 8) })),
128
+ }, null, 2) + '\n');
129
+ } else {
130
+ printDiff(result);
131
+ }
132
+ if (gateMode) {
133
+ // 门禁模式(pre-commit 双源一致性用):缺失/漂移非零阻断——正是「包源改了装副本没跟」的故障模式
134
+ // (doctor §4 台账 sha 对此静默:盘面=台账即 PASS;§4.5 只抓未登记)。孤儿只报告不计失败:
135
+ // 孤儿 = 装副本独有(装户自持内容如本地 skills),删留由人拍板,不入机器门。
136
+ if (result.missing.length || result.drift.length) {
137
+ console.error('❌ 双源不一致(缺失 ' + result.missing.length + ' / 漂移 ' + result.drift.length + ')——包源与装副本须同提交成对(双源纪律);跑 node bin/flow-kit.mjs sync 后原路重试(孤儿只报告不计失败)');
138
+ process.exit(1);
139
+ }
140
+ process.exit(0);
141
+ }
142
+ // diff/json 模式总 exit 0;差异存在不视作错误(人类阅读用;B-b 决策「只报告不修复」)
143
+ process.exit(0);
144
+ }
145
+ export default sourceSyncCheck;