agentic-flow-kit 0.5.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 (82) hide show
  1. package/README.md +2 -0
  2. package/modules/gates/dotnet-ca/check-architecture.sh +23 -5
  3. package/modules/hosts/omp/agents/implementer.md +2 -0
  4. package/modules/hosts/omp/agents/independent-reviewer.md +2 -0
  5. package/modules/hosts/omp/agents/ui-verifier.md +2 -0
  6. package/modules/hosts/opencode/agents/implementer.md +2 -0
  7. package/modules/hosts/opencode/agents/independent-reviewer.md +2 -0
  8. package/modules/hosts/opencode/agents/ui-verifier.md +2 -0
  9. package/modules/hosts/opencode/commands/build.md +20 -1
  10. package/modules/hosts/opencode/commands/design.md +2 -0
  11. package/modules/hosts/opencode/commands/gate-checklist.md +77 -0
  12. package/modules/hosts/opencode/commands/maintain.md +2 -3
  13. package/modules/hosts/opencode/commands/plan.md +4 -1
  14. package/modules/hosts/opencode/commands/review.md +1 -0
  15. package/modules/hosts/opencode/commands/source-sync-check.md +89 -0
  16. package/modules/hosts/opencode/commands/sync-hosts.md +66 -0
  17. package/modules/hosts/opencode/commands/test.md +4 -2
  18. package/modules/hosts/trae/agents/implementer.md +2 -0
  19. package/modules/hosts/trae/agents/independent-reviewer.md +2 -0
  20. package/modules/hosts/trae/agents/ui-verifier.md +2 -0
  21. package/modules/hosts/trae/commands/wf-build.md +20 -1
  22. package/modules/hosts/trae/commands/wf-design.md +2 -0
  23. package/modules/hosts/trae/commands/wf-gate-checklist.md +78 -0
  24. package/modules/hosts/trae/commands/wf-maintain.md +2 -3
  25. package/modules/hosts/trae/commands/wf-plan.md +4 -1
  26. package/modules/hosts/trae/commands/wf-review.md +1 -0
  27. package/modules/hosts/trae/commands/wf-source-sync-check.md +90 -0
  28. package/modules/hosts/trae/commands/wf-sync-hosts.md +67 -0
  29. package/modules/hosts/trae/commands/wf-test.md +4 -2
  30. package/modules/hosts/trae/hooks/post-edit-check.cjs +3 -2
  31. package/modules/hosts/trae/hooks/pre-shell-check.cjs +40 -9
  32. package/modules/hosts/trae/hooks.json +1 -1
  33. package/modules/hosts/zcode/agents/implementer.md +2 -0
  34. package/modules/hosts/zcode/agents/independent-reviewer.md +2 -0
  35. package/modules/hosts/zcode/agents/ui-verifier.md +2 -0
  36. package/package.json +2 -2
  37. package/src/add-gate.mjs +5 -3
  38. package/src/doctor.mjs +121 -18
  39. package/src/gate-dotnet-ca.test.mjs +124 -0
  40. package/src/init.mjs +79 -17
  41. package/src/init.test.mjs +60 -1
  42. package/src/render.mjs +64 -4
  43. package/src/run-tests.mjs +3 -0
  44. package/src/sync-hosts.mjs +3 -2
  45. package/src/sync.mjs +64 -20
  46. package/src/sync.test.mjs +42 -0
  47. package/src/trae-hooks.test.mjs +115 -0
  48. package/templates/AGENTS.md +1 -0
  49. package/templates/_agents/board/index.html +32 -3
  50. package/templates/_agents/commands/build.md +5 -4
  51. package/templates/_agents/commands/design.md +1 -0
  52. package/templates/_agents/commands/maintain.md +1 -2
  53. package/templates/_agents/commands/plan.md +2 -0
  54. package/templates/_agents/commands/test.md +2 -1
  55. package/templates/_agents/hooks/check-pairing-incremental.sh +20 -7
  56. package/templates/_agents/roles/implementer.md +2 -0
  57. package/templates/_agents/roles/independent-reviewer.md +2 -0
  58. package/templates/_agents/roles/ui-verifier.md +2 -0
  59. package/templates/_agents/scripts/agg-delegations.cjs +39 -30
  60. package/templates/_agents/scripts/agg-delegations.test.mjs +32 -0
  61. package/templates/_agents/scripts/check-loop.mjs +262 -16
  62. package/templates/_agents/scripts/check-loop.test.mjs +621 -0
  63. package/templates/_agents/scripts/confirm-doc.mjs +228 -0
  64. package/templates/_agents/scripts/confirm-doc.test.mjs +321 -0
  65. package/templates/_agents/scripts/doctor.test.mjs +74 -1
  66. package/templates/_agents/scripts/ensure-board.mjs +29 -2
  67. package/templates/_agents/scripts/gate-checklist.mjs +2 -0
  68. package/templates/_agents/scripts/gate-checklist.test.mjs +3 -3
  69. package/templates/_agents/scripts/gen-wiki-board.mjs +38 -24
  70. package/templates/_agents/scripts/gen-wiki-board.test.mjs +18 -0
  71. package/templates/_agents/scripts/gen-workflow-metrics.mjs +12 -3
  72. package/templates/_agents/scripts/kb-search.mjs +22 -8
  73. package/templates/_agents/scripts/kb-search.test.mjs +38 -8
  74. package/templates/_agents/scripts/source-sync-check.mjs +17 -3
  75. package/templates/_agents/scripts/source-sync-check.test.mjs +96 -8
  76. package/templates/_agents/scripts/verify-wiki-consistency.mjs +46 -23
  77. package/templates/_agents/scripts/workflow-board-server.mjs +64 -9
  78. package/templates/_agents/scripts/workflow-board-server.test.mjs +136 -0
  79. package/templates/_agents/scripts/workflow-enums.mjs +4 -2
  80. package/templates/_agents/workflow-enums.txt +3 -3
  81. package/templates/_agents/workflows/_TEMPLATE.md +1 -0
  82. package/templates/_githooks/pre-commit +16 -1
package/src/doctor.mjs CHANGED
@@ -4,6 +4,11 @@ import path from 'node:path';
4
4
  import { execSync, spawnSync } from 'node:child_process';
5
5
  import net from 'node:net';
6
6
  import { createHash } from 'node:crypto';
7
+ import { listTree, scriptTrusted } from './render.mjs';
8
+ import { isOwned, HOSTS, pickStackVars } from './profiles.mjs';
9
+
10
+ // 宿主目录映射(与 src/profiles.mjs#HOSTS 同源:装副本目录名前缀带点)
11
+ const HOST_DIR = Object.fromEntries(Object.entries(HOSTS).map(([k, v]) => [k, v.dir]));
7
12
 
8
13
  function sh(cmd, cwd) {
9
14
  try {
@@ -55,6 +60,7 @@ export function doctor(args, pkgRoot) {
55
60
  '.agents/settings.json',
56
61
  '.agents/workflows/_TEMPLATE.md',
57
62
  '.agents/scripts/check-loop.mjs',
63
+ '.agents/scripts/confirm-doc.mjs',
58
64
  '.githooks/pre-commit',
59
65
  '.githooks/pre-push',
60
66
  '.githooks/commit-msg',
@@ -75,7 +81,20 @@ export function doctor(args, pkgRoot) {
75
81
  const isRepo = sh('git rev-parse --git-dir', target).ok;
76
82
  if (isRepo) {
77
83
  const hp = sh('git config core.hooksPath', target);
78
- if (hp.ok && hp.out.trim() === '.githooks') add('PASS', 'core.hooksPath=.githooks(钩子已挂载)');
84
+ if (hp.ok && hp.out.trim() === '.githooks') {
85
+ add('PASS', 'core.hooksPath=.githooks(钩子已挂载)');
86
+ // 执行位检查(init-p1-batch P1-1):POSIX 上 git 对不可执行钩子静默跳过、全部门禁失效且原不可发现;
87
+ // win32 无执行位语义(mode 恒 0666 系),跳过本检查
88
+ if (process.platform !== 'win32') {
89
+ try {
90
+ const hookDir = path.join(target, '.githooks');
91
+ const noExec = fs.readdirSync(hookDir)
92
+ .filter((f) => fs.statSync(path.join(hookDir, f)).isFile())
93
+ .filter((f) => !(fs.statSync(path.join(hookDir, f)).mode & 0o111));
94
+ if (noExec.length) add('FAIL', `钩子缺执行位(POSIX 上 git 静默跳过不可执行钩子,门禁全部失效):${noExec.join('、')}——执行 chmod +x .githooks/*`);
95
+ } catch { /* .githooks 目录缺失由 §2 布局检查覆盖 */ }
96
+ }
97
+ }
79
98
  else add('FAIL', `core.hooksPath 当前为「${(hp.ok ? hp.out : '').trim() || '未设置'}」——执行 git config core.hooksPath .githooks`);
80
99
  } else {
81
100
  add('WARN', '不在 git 仓库内——钩子未挂载(git init 后执行 git config core.hooksPath .githooks)');
@@ -94,7 +113,7 @@ export function doctor(args, pkgRoot) {
94
113
  for (const f of kit.managed || []) {
95
114
  const p = path.join(target, f.rel);
96
115
  if (!fs.existsSync(p)) { gone.push(f.rel); continue; }
97
- const h = createHash('sha256').update(fs.readFileSync(p)).digest('hex');
116
+ const h = createHash('sha256').update(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8').digest('hex'); // LF 归一(closing-coverage:跨 checkout 稳定,同 sync shaText 口径)
98
117
  if (h === f.sha256) ok++; else modified++;
99
118
  }
100
119
  add('PASS', `kit.json v${kit.version}:managed ${ok + modified} 份校验通过`);
@@ -105,6 +124,35 @@ export function doctor(args, pkgRoot) {
105
124
  }
106
125
  }
107
126
 
127
+ // 4.5 台账覆盖率(2026-09-26 managed-ledger-adopt):§4 只遍历台账条目做双向校验,
128
+ // 从不反向问「盘上还有谁没登记」——15 份 managed 类文件曾因此长期游离台账外,sync 只报不修。
129
+ // 本项由 pkgRoot 包源枚举应有 managed 清单(排除 isOwned)与台账取差集,补上「盘上有、台账无」一侧。
130
+ // 首次引入用 WARN:doctor 的 pkgRoot 是包安装目录,装户可能落后于包版本(包内新增 managed 件
131
+ // 在装户 sync 前必然未登记),直接 FAIL 会让存量装户升级包后一跑 doctor 即挂。
132
+ // 沿用本仓库三次先例(doctor-owned-drift / workflows-check / adapter 薄适配漂移均先 WARN)。
133
+ {
134
+ const covRes = checkLedgerCoverage(target, pkgRoot);
135
+ if (covRes.skipped) {
136
+ add('PASS', `台账覆盖率检查跳过(${covRes.note || '无包源模板'})`);
137
+ } else if (covRes.missing.length === 0) {
138
+ add('PASS', `台账覆盖率完整(${covRes.total} 份 managed 全在册)`);
139
+ } else {
140
+ const shown = covRes.missing.slice(0, 5).join('、');
141
+ add('WARN', `台账覆盖率缺口 ${covRes.missing.length} 份——盘上 managed 类文件未登记进 kit.json,包源改动不会同步到装副本;跑 node bin/flow-kit.mjs sync 收养(内容等于新版才登记)${covRes.missing.length > 5 ? `:${shown} …` : `:${shown}`}`);
142
+ }
143
+ }
144
+
145
+ // scriptGuard(init-p1-batch P1-3 供应链防线):spawn 目标侧脚本前校验其与包源渲染值(LF 归一)一致——
146
+ // 失配说明预置/被改动,不可信:调用方记 WARN 跳过(fail-visible,不执行也不下通过结论)。
147
+ // 无 pkgRoot(理论路径)不设防;vars 从 kit.json options 重建(与 sync 同源构造)。
148
+ const scriptGuard = (rel) => {
149
+ if (!pkgRoot) return { ok: true, note: '' };
150
+ let opts = null;
151
+ try { opts = JSON.parse(fs.readFileSync(kitPath, 'utf8')).options; } catch { /* 无/坏台账→用默认 vars */ }
152
+ const vars = { BOARD_PORT: String(opts?.boardPort || '8933'), ...pickStackVars(opts?.stack) };
153
+ return scriptTrusted({ pkgRoot, target, rel, vars });
154
+ };
155
+
108
156
  // 5. 占位符残留
109
157
  const phFiles = ['AGENTS.md', 'workflow/README.md', '.agents/notes/runtime-env.md']
110
158
  .concat(fs.existsSync(path.join(target, '.agents/commands')) ? fs.readdirSync(path.join(target, '.agents/commands')).filter((f) => f.endsWith('.md')).map((f) => `.agents/commands/${f}`) : []);
@@ -119,15 +167,26 @@ export function doctor(args, pkgRoot) {
119
167
  else add('WARN', `占位符残留 ${phHits.length} 处(init 变量缺失或需手改):${phHits.slice(0, 5).join(';')}${phHits.length > 5 ? ' …' : ''}`);
120
168
 
121
169
  // 6. workflow 索引漂移
122
- const idx = spawnSync(process.execPath, ['.agents/scripts/gen-workflow-index.mjs', '--check'], { cwd: target, encoding: 'utf8' });
123
- if (idx.status === 0) add('PASS', 'workflow/INDEX.md 无漂移');
124
- else add('WARN', `workflow/INDEX.md 漂移——跑 node .agents/scripts/gen-workflow-index.mjs 重生成`);
170
+ const idxGuard = scriptGuard('.agents/scripts/gen-workflow-index.mjs');
171
+ if (!idxGuard.ok) {
172
+ add('WARN', `workflow/INDEX.md 漂移检查跳过——${idxGuard.note}(供应链防线)`);
173
+ } else {
174
+ const idx = spawnSync(process.execPath, ['.agents/scripts/gen-workflow-index.mjs', '--check'], { cwd: target, encoding: 'utf8' });
175
+ if (idx.status === 0) add('PASS', 'workflow/INDEX.md 无漂移');
176
+ else add('WARN', `workflow/INDEX.md 漂移——跑 node .agents/scripts/gen-workflow-index.mjs 重生成`);
177
+ }
125
178
 
126
179
  // 6.5 delegations 台账结构(量化层非门禁:结构漂移曾静默吞掉全部记录,2026-09-24)
180
+ // 存在性先行(复核 P2-2):旧装户无此脚本 = 既有「静默跳过」语义,不产生防线 WARN 噪音
127
181
  if (fs.existsSync(path.join(target, '.agents/scripts/agg-delegations.cjs'))) {
128
- const agg = spawnSync(process.execPath, ['.agents/scripts/agg-delegations.cjs'], { cwd: target, encoding: 'utf8' });
129
- if (agg.status === 0) add('PASS', 'delegations 台账结构有效(agg 可解析)');
130
- else add('WARN', `delegations 台账结构漂移——${String(agg.stderr || agg.stdout || '').split('\n')[0]}`);
182
+ const aggGuard = scriptGuard('.agents/scripts/agg-delegations.cjs');
183
+ if (!aggGuard.ok) {
184
+ add('WARN', `delegations 台账结构检查跳过——${aggGuard.note}(供应链防线)`);
185
+ } else {
186
+ const agg = spawnSync(process.execPath, ['.agents/scripts/agg-delegations.cjs'], { cwd: target, encoding: 'utf8' });
187
+ if (agg.status === 0) add('PASS', 'delegations 台账结构有效(agg 可解析)');
188
+ else add('WARN', `delegations 台账结构漂移——${String(agg.stderr || agg.stdout || '').split('\n')[0]}`);
189
+ }
131
190
  }
132
191
 
133
192
  // 6.6 owned 漂移校验(kit.owned 列表盘面 sha 不一致;engine 双源纪律对 owned 走「项目自持 + 哈希记账不约束」,
@@ -165,7 +224,16 @@ export function doctor(args, pkgRoot) {
165
224
  // 2026-09-25 workflows-linter 把 _TEMPLATE.md「解析校验先行」从 prose 变机器门。首次引入 WARN
166
225
  // (沿用 wf-runtime 复盘「先 WARN 升级 FAIL」渐进路径),装户吃过警告后可升 FAIL。
167
226
  // 旧版装户 sync 前 .agents/scripts/workflows-check.mjs 不存在 → 自然跳过不误报)
168
- if (fs.existsSync(path.join(target, '.agents/scripts/workflows-check.mjs')) && fs.existsSync(path.join(target, '.agents/workflows'))) {
227
+ // 6.8 workflows 编排脚本 lint(stages 表解析校验:role/step/after 引用、无环、三形态互斥、数值枚举;
228
+ // 2026-09-25 workflows-linter 把 _TEMPLATE.md「解析校验先行」从 prose 变机器门。首次引入 WARN
229
+ // (沿用 wf-runtime 复盘「先 WARN 升级 FAIL」渐进路径),装户吃过警告后可升 FAIL。
230
+ // 旧版装户 sync 前 .agents/scripts/workflows-check.mjs 不存在 → 既有「静默跳过」语义保留(存在性先行,
231
+ // 复核 P2-2),防线只拦「存在但与包源不符」)
232
+ const wfExists = fs.existsSync(path.join(target, '.agents/scripts/workflows-check.mjs')) && fs.existsSync(path.join(target, '.agents/workflows'));
233
+ const wfGuard = wfExists ? scriptGuard('.agents/scripts/workflows-check.mjs') : { ok: true, note: '' };
234
+ if (wfExists && !wfGuard.ok) {
235
+ add('WARN', `workflows 编排脚本 lint 跳过——${wfGuard.note}(供应链防线)`);
236
+ } else if (wfExists) {
169
237
  const wf = spawnSync(process.execPath, ['.agents/scripts/workflows-check.mjs'], { cwd: target, encoding: 'utf8' });
170
238
  if (wf.status === 0) {
171
239
  const warnN = (String(wf.stdout || '').match(/- ⚠️ W\d/g) || []).length;
@@ -179,12 +247,17 @@ export function doctor(args, pkgRoot) {
179
247
  // 7. check-loop——2026-09-26 check-loop-node 起 node 实现直跑(sh 版曾需先探 sh 可用性:Windows
180
248
  // PowerShell 常无 sh,ENOENT 曾被吞进 hard-block 分支报成空原因假警报,incident 2026-09-24-doctor-sh-enoent;
181
249
  // 迁移后无 sh 依赖,探针退役。check-loop.sh 为兼容 shim,pre-push 钩子路径照常)
182
- const cl = spawnSync(process.execPath, ['.agents/scripts/check-loop.mjs'], { cwd: target, encoding: 'utf8' });
183
- if (cl.status === 0) {
184
- const warnTxt = String(cl.stderr || '').trim();
185
- add('PASS', `check-loop 干净${warnTxt ? `(${warnTxt.split('\n').filter((l) => l.includes('WARN')).length} 条 advisory 警告)` : ''}`);
250
+ const clGuard = scriptGuard('.agents/scripts/check-loop.mjs');
251
+ if (!clGuard.ok) {
252
+ add('WARN', `check-loop 跳过——${clGuard.note}(供应链防线:目标侧脚本与包源渲染值不符,不执行不下结论)`);
186
253
  } else {
187
- add('FAIL', `check-loop 有 hard-block:\n${String(cl.stderr || '').split('\n').slice(0, 8).join('\n')}`);
254
+ const cl = spawnSync(process.execPath, ['.agents/scripts/check-loop.mjs'], { cwd: target, encoding: 'utf8' });
255
+ if (cl.status === 0) {
256
+ const warnTxt = String(cl.stderr || '').trim();
257
+ add('PASS', `check-loop 干净${warnTxt ? `(${warnTxt.split('\n').filter((l) => l.includes('WARN')).length} 条 advisory 警告)` : ''}`);
258
+ } else {
259
+ add('FAIL', `check-loop 有 hard-block:\n${String(cl.stderr || '').split('\n').slice(0, 8).join('\n')}`);
260
+ }
188
261
  }
189
262
 
190
263
  // 8. 看板端口(信息级)
@@ -207,6 +280,34 @@ function print(results) {
207
280
  console.log(`\ndoctor:${results.length - fails - warns} PASS | ${warns} WARN | ${fails} FAIL`);
208
281
  }
209
282
 
283
+ export function checkLedgerCoverage(target, pkgRoot) {
284
+ const kitPath = path.join(target, '.agents', 'kit.json');
285
+ if (!fs.existsSync(kitPath)) return { missing: [], total: 0, skipped: true, note: 'kit.json 不存在' };
286
+ let kit;
287
+ try {
288
+ kit = JSON.parse(fs.readFileSync(kitPath, 'utf8'));
289
+ } catch (e) {
290
+ return { missing: [], total: 0, skipped: true, note: `kit.json 解析失败:${e.message}` };
291
+ }
292
+ if (!Array.isArray(kit.managed)) return { missing: [], total: 0, skipped: true, note: 'kit.json 无 managed 条目' };
293
+ // 无包源 = 装户环境:盘上没有 templates/,无法枚举应有清单,跳过
294
+ const tmplRoot = path.join(pkgRoot || '', 'templates');
295
+ if (!pkgRoot || !fs.existsSync(tmplRoot)) return { missing: [], total: 0, skipped: true, note: '无包源模板(templates/ 不存在)' };
296
+
297
+ // 应有清单 = 模板树 + 台账宿主层,排除 owned(与 src/sync.mjs 的 fresh 计算口径一致)
298
+ const expected = new Set(listTree(tmplRoot));
299
+ const hosts = Array.isArray(kit.options?.hosts) ? kit.options.hosts : [];
300
+ for (const h of hosts) {
301
+ if (!HOST_DIR[h]) continue;
302
+ const hostRoot = path.join(pkgRoot, 'modules', 'hosts', h);
303
+ if (!fs.existsSync(hostRoot)) continue;
304
+ for (const rel of listTree(hostRoot)) expected.add(`${HOST_DIR[h]}/${rel}`);
305
+ }
306
+ const ledger = new Set(kit.managed.map((f) => f.rel));
307
+ const missing = [...expected].filter((rel) => !ledger.has(rel) && !isOwned(rel)).sort();
308
+ return { missing, total: ledger.size, skipped: false };
309
+ }
310
+
210
311
  // owned 漂移校验(独立 export 供 doctor 主流程 + 单元测试共用;2026-09-25 wf-runtime 复盘)
211
312
  // 返回:{ drift, gone, total, skipped, error? }
212
313
  // - skipped=true:kit.json 不存在或解析失败/无 owned 字段
@@ -229,7 +330,9 @@ export function checkOwnedDrift(target) {
229
330
  for (const f of owned) {
230
331
  const p = path.join(target, f.rel);
231
332
  if (!fs.existsSync(p)) { gone.push(f.rel); continue; }
232
- const h = createHash('sha256').update(fs.readFileSync(p)).digest('hex');
333
+ // sha 按 LF 归一口径(同 rule-budgets「索引/HEAD 字节」约定):盘面 CRLF(autocrlf 检出)与克隆检出 LF
334
+ // 须同值,否则跨环境必漂移(CI 克隆 §6.6 必红——2026-09-27 gate-coverage 复核 P1-1 根因;owned 均为文本件)
335
+ const h = createHash('sha256').update(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8').digest('hex');
233
336
  if (h !== f.sha256) drift++;
234
337
  }
235
338
  return { drift, gone, total: owned.length, skipped: false };
@@ -251,15 +354,15 @@ export function checkAdapterDrift(target) {
251
354
  if (fs.existsSync(path.join(target, 'templates', '_agents')) && fs.existsSync(path.join(target, 'modules', 'hosts'))) {
252
355
  return { drift: 0, total: 0, skipped: true, note: '包源环境(templates/ + modules/ 同时存在)——§7.x 仅在装户环境有意义' };
253
356
  }
254
- // 4 宿主目录映射(与 src/profiles.mjs#HOSTS 同源:装副本目录名前缀带点)
255
- const HOST_DIR = { zcode: '.zcode', omp: '.omp', opencode: '.opencode', trae: '.trae' };
357
+ // 4 宿主目录映射(与顶层 HOST_DIR 同源)
256
358
  const splitFm = (text) => {
257
359
  const m = String(text || '').match(/^---\r?\n([\s\S]*?\r?\n)---\r?\n?([\s\S]*)$/);
258
360
  if (!m) return { fm: '', body: String(text || '') };
259
361
  return { fm: `---\n${m[1]}---\n`, body: m[2] };
260
362
  };
261
363
  const bodySha = (p) => {
262
- try { return createHash('sha256').update(splitFm(fs.readFileSync(p, 'utf8')).body).digest('hex'); } catch { return null; }
364
+ // 正文段 LF 归一后哈希(closing-coverage:与 sync-hosts bodySha 同口径,CRLF/LF 盘面不误报漂移)
365
+ try { return createHash('sha256').update(splitFm(fs.readFileSync(p, 'utf8')).body.replace(/\r\n/g, '\n'), 'utf8').digest('hex'); } catch { return null; }
263
366
  };
264
367
  let total = 0;
265
368
  let drift = 0;
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ // gate-dotnet-ca.test.mjs — dotnet-ca 门禁模块判定回归(2026-09-27 host-gates-p1,incident 三件套「防复发验证」落点)
3
+ // 方法:fixture 临时 git 仓构造 CONFIG 指向的最小目录树,复制包源 check-architecture.sh 并按场景改 CONFIG,
4
+ // spawn sh 跑门禁断言 exit 码与输出。三场景对位三个 P1:
5
+ // ①豁免清单多值生效(P1-B1:旧 tr ':' '|' 造 "A|B" BRE 字面量永不命中)
6
+ // ②CONTROLLERS_DIR 缺失 fail-closed(P1-B3:旧 2>/dev/null 静默放行)
7
+ // ③csproj 缺失 fail-closed(随 B3:旧 glob 展开失败静默放行)
8
+ // 用法:node src/gate-dotnet-ca.test.mjs(npm test 随跑)
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { spawnSync } from 'node:child_process';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ let pass = 0;
16
+ let fail = 0;
17
+ const check = (desc, cond, detail = '') => {
18
+ if (cond) { pass++; console.log(`PASS ${desc}`); }
19
+ else { fail++; console.log(`FAIL ${desc}${detail ? `\n${detail}` : ''}`); }
20
+ };
21
+
22
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const GATE_SRC = path.join(PKG_ROOT, 'modules', 'gates', 'dotnet-ca', 'check-architecture.sh');
24
+
25
+ // mkfixture:最小 dotnet 目录树(Application/API/Domain/Infrastructure/Controllers)+ 可覆盖 CONFIG
26
+ function mkfixture(configOverride = {}) {
27
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-ca-test-'));
28
+ const dirs = {
29
+ APP_DIR: 'backend/src/App.Application',
30
+ API_DIR: 'backend/src/App.API',
31
+ DBCTX_FILE: 'backend/src/App.Infrastructure/Data/AppDbContext.cs',
32
+ DOMAIN_ENTITIES_DIR: 'backend/src/App.Domain/Entities',
33
+ CONTROLLERS_DIR: 'backend/src/App.API/Controllers',
34
+ };
35
+ for (const rel of Object.values(dirs)) fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); // 文件型键取父目录;目录型键随后显式建
36
+ fs.mkdirSync(path.join(root, dirs.APP_DIR), { recursive: true });
37
+ fs.mkdirSync(path.join(root, dirs.API_DIR), { recursive: true });
38
+ fs.mkdirSync(path.join(root, dirs.DOMAIN_ENTITIES_DIR), { recursive: true });
39
+ fs.mkdirSync(path.join(root, dirs.CONTROLLERS_DIR), { recursive: true });
40
+ fs.writeFileSync(path.join(root, dirs.DBCTX_FILE), 'class AppDbContext { }\n');
41
+ const cfg = {
42
+ ...Object.fromEntries(Object.entries(dirs).map(([k, v]) => [k, v])),
43
+ DBCTX_CLASS: 'AppDbContext',
44
+ INFRA_USING: 'using App.Infrastructure',
45
+ EXEMPT_CTRL: 'ExemptedA:ExemptedB',
46
+ SHARED_TYPES: '',
47
+ ...configOverride,
48
+ };
49
+ // 包源脚本 CONFIG 段替换:按「^KEY=" 任意到行尾」整行重写(值含 / 且行尾带注释——不用值内正则);
50
+ // EXEMPT_CTRL/SHARED_TYPES 行尾有注释,重写时保留注释语义(测试值自带说明则省略)
51
+ let script = fs.readFileSync(GATE_SRC, 'utf8');
52
+ for (const [k, v] of Object.entries(cfg)) {
53
+ script = script.replace(new RegExp(`^${k}=.*$`, 'm'), `${k}="${v}"`);
54
+ }
55
+ const scriptPath = path.join(root, 'check-architecture.sh');
56
+ fs.writeFileSync(scriptPath, script);
57
+ // fixture 需为 git 仓(脚本首行 git rev-parse --show-toplevel)
58
+ spawnSync('git', ['init', '-q', '.'], { cwd: root });
59
+ return { root, scriptPath, dirs };
60
+ }
61
+ const runGate = (root, scriptPath) => spawnSync('sh', [scriptPath], { cwd: root, encoding: 'utf8' });
62
+ // ctrlWrite:往 fixture 的 Controllers 目录写文件(目录可能尚未创建)
63
+ const ctrlWrite = (root, dirs, name, content) => {
64
+ fs.mkdirSync(path.join(root, dirs.CONTROLLERS_DIR), { recursive: true });
65
+ fs.writeFileSync(path.join(root, dirs.CONTROLLERS_DIR, name), content);
66
+ };
67
+ // addCsproj:补干净 csproj(fail-closed 后红线 1 先查 csproj——不写则走不到目标断言)
68
+ const addCsproj = (root, dirs) => fs.writeFileSync(path.join(root, dirs.APP_DIR, 'App.Application.csproj'), '<Project Sdk="Microsoft.NET.Sdk" />\n');
69
+
70
+ // ---- ① 豁免清单多值(P1-B1)----
71
+ {
72
+ const { root, scriptPath, dirs } = mkfixture();
73
+ addCsproj(root, dirs);
74
+ // API 层两处 DbContext 引用:一个在豁免 Controller 文件、一个在普通 Controller——豁免只应放过前者
75
+ ctrlWrite(root, dirs, 'ExemptedAController.cs', '// AppDbContext 直接引用(豁免)\n');
76
+ ctrlWrite(root, dirs, 'OrdersController.cs', '// AppDbContext 直接引用(应拦)\n');
77
+ const r = runGate(root, scriptPath);
78
+ check('①多值豁免生效:ExemptedA 文件行不出现、Orders 行出现(违例 exit 1)',
79
+ r.status === 1 && !r.stderr.includes('ExemptedAController') && r.stderr.includes('OrdersController'),
80
+ `exit=${r.status}\n${r.stdout}\n${r.stderr}`);
81
+ fs.rmSync(root, { recursive: true, force: true });
82
+ }
83
+ // ①附:干净树(豁免命中全部 DbContext 引用)→ 红线 3 不拦(整体 exit 0 需其余红线干净)
84
+ {
85
+ const { root, scriptPath, dirs } = mkfixture();
86
+ addCsproj(root, dirs);
87
+ ctrlWrite(root, dirs, 'ExemptedAController.cs', '// AppDbContext\n');
88
+ ctrlWrite(root, dirs, 'ExemptedBController.cs', '// AppDbContext\n');
89
+ const r = runGate(root, scriptPath);
90
+ check('①两个豁免 Controller 全命中 → 红线 3 零输出 exit 0', r.status === 0 && !r.stderr, `exit=${r.status}\n${r.stderr}`);
91
+ fs.rmSync(root, { recursive: true, force: true });
92
+ }
93
+
94
+ // ---- ② CONTROLLERS_DIR 缺失 fail-closed(P1-B3)----
95
+ {
96
+ const { root, scriptPath, dirs } = mkfixture({ CONTROLLERS_DIR: 'backend/src/App.API/NotThere' });
97
+ addCsproj(root, dirs);
98
+ const r = runGate(root, scriptPath);
99
+ check('②Controllers 目录缺失 → fail-closed exit 1 点名目标(旧代码静默放行)',
100
+ r.status === 1 && r.stderr.includes('NotThere') && r.stderr.includes('fail-closed'),
101
+ `exit=${r.status}\n${r.stderr}`);
102
+ fs.rmSync(root, { recursive: true, force: true });
103
+ }
104
+
105
+ // ---- ③ csproj 缺失 fail-closed(随 B3)----
106
+ {
107
+ const { root, scriptPath } = mkfixture();
108
+ // Application 目录存在但无 .csproj(glob 展开失败形态)
109
+ const r = runGate(root, scriptPath);
110
+ check('③Application 无 csproj → fail-closed exit 1(旧代码静默放行)',
111
+ r.status === 1 && r.stderr.includes('csproj') && r.stderr.includes('fail-closed'),
112
+ `exit=${r.status}\n${r.stderr}`);
113
+ fs.rmSync(root, { recursive: true, force: true });
114
+ }
115
+ // ③附:有 csproj 且干净 → 过红线 1
116
+ {
117
+ const { root, scriptPath, dirs } = mkfixture();
118
+ fs.writeFileSync(path.join(root, dirs.APP_DIR, 'App.Application.csproj'), '<Project Sdk="Microsoft.NET.Sdk" />\n');
119
+ const r = runGate(root, scriptPath);
120
+ check('③干净 csproj 存在 → 整体 exit 0', r.status === 0, `exit=${r.status}\n${r.stderr}`); fs.rmSync(root, { recursive: true, force: true });
121
+ }
122
+
123
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
124
+ process.exit(fail ? 1 : 0);
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
@@ -5,7 +5,8 @@
5
5
  // ③非法输入返回 null(就地重问信号);④空输入回默认(EOF/直接回车安全回退);⑥装户面含 runner/workflows。
6
6
  // 用法:node src/init.test.mjs
7
7
  import { normalizeStack, parseChoices, hasAgentsSkeleton, mergeAgents } from './init.mjs';
8
- import { renderTree } from './render.mjs';
8
+ import { renderTree, scriptTrusted, listTree } from './render.mjs';
9
+ import { sha256 } from './render.mjs';
9
10
  import fs from 'node:fs';
10
11
  import os from 'node:os';
11
12
  import path from 'node:path';
@@ -71,6 +72,64 @@ check('合并:原内容为空时骨架即全文', mergeAgents('', '<!-- m -->\
71
72
  fs.rmSync(tmp, { recursive: true, force: true });
72
73
  }
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
+
74
133
  console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
75
134
  if (fail) {
76
135
  console.log('\n用法:node src/init.test.mjs');