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/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
@@ -12,6 +12,9 @@ const nodeSuites = [
12
12
  'src/sync.test.mjs',
13
13
  'src/init.test.mjs',
14
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',
15
18
  ...fs.readdirSync(path.join(ROOT, 'templates/_agents/scripts'))
16
19
  .filter((f) => f.endsWith('.test.mjs'))
17
20
  .sort()
@@ -24,11 +24,12 @@ function splitFm(text) {
24
24
  return { fm: `---\n${m[1]}---\n`, body: m[2] };
25
25
  }
26
26
 
27
- // bodySha:剥离 frontmatter 后的正文段 sha256(缺失返 null)
27
+ // bodySha:剥离 frontmatter 后的正文段 sha256(缺失返 null);正文 LF 归一(2026-09-27 closing-coverage:
28
+ // CRLF/LF 盘面同值——权威源与薄适配行尾不一致不再误报漂移,与 doctor §6.7 同口径)
28
29
  function bodySha(p) {
29
30
  try {
30
31
  const { body } = splitFm(fs.readFileSync(p, 'utf8'));
31
- return sha256(Buffer.from(body, 'utf8'));
32
+ return sha256(Buffer.from(body.replace(/\r\n/g, '\n'), 'utf8'));
32
33
  } catch {
33
34
  return null;
34
35
  }
package/src/sync.mjs CHANGED
@@ -3,12 +3,15 @@
3
3
  // 每次持续报告直至 --force 或本地对齐新版——防跳过一次后下次升级被静默覆盖,2026-09-24 语义修正);
4
4
  // 生成器目标(INDEX.md / wiki 看板)→ 不比对,收尾重跑生成器走锚点重写。
5
5
  // 附带:包内新增 managed 文件 → 安装;包内已删 → 仅报告不删盘、出台账;managed 缺失 → 恢复。
6
+ // 台账外文件收养(2026-09-26 managed-ledger-adopt):盘上存在但台账无该 rel 的 managed 类文件,
7
+ // 若内容恰好等于新版渲染 sha(diskSha === fresh.sha)则收养登记——判据与台账内「改动恰好等于新版」
8
+ // 分支同源,不存在本地独有信息可被破坏;内容不等(本地真改动)仍保守跳过且台账不登记(持续报告)。
6
9
  // owned 文件永不触碰(两态模型,见 profiles.isOwned)。
7
10
  import fs from 'node:fs';
8
11
  import path from 'node:path';
9
12
  import os from 'node:os';
10
13
  import { spawnSync } from 'node:child_process';
11
- import { renderTree, sha256 } from './render.mjs';
14
+ import { renderTree, sha256, scriptTrusted } from './render.mjs';
12
15
  import { HOSTS, pickStackVars, isOwned } from './profiles.mjs';
13
16
  import { doctor } from './doctor.mjs';
14
17
 
@@ -22,6 +25,19 @@ function runNode(target, script, args = []) {
22
25
  return { ok: r.status === 0, out: `${r.stdout || ''}${r.stderr || ''}` };
23
26
  }
24
27
 
28
+ // shaText:文本件 LF 归一哈希(managed 记账与比对统一口径,2026-09-27 closing-coverage——跨 checkout
29
+ // 字节稳定:CRLF 盘面(autocrlf 检出/Edit 工具写入)与 LF 克隆同值;renderTree 写盘本为 LF,
30
+ // fresh sha 天然归一,故 ledger 记 fresh sha 后两侧自洽)
31
+ const shaText = (p) => sha256(Buffer.from(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8'));
32
+
33
+ // 钩子文件落盘补执行位(init-p1-batch P1-1):copyFileSync 不携带 mode,POSIX 装户 git 对不可执行钩子
34
+ // 静默跳过;FAT/网络盘 chmod 不支持时降级不中断安装
35
+ const safeChmod = (p) => { try { fs.chmodSync(p, 0o755); } catch { /* 降级 */ } };
36
+ const copyManaged = (freshFile, disk, rel) => {
37
+ fs.copyFileSync(freshFile.abs, disk);
38
+ if (rel.startsWith('.githooks/')) safeChmod(disk);
39
+ };
40
+
25
41
  export function sync(args, pkgRoot) {
26
42
  let target = process.cwd();
27
43
  let force = false;
@@ -60,7 +76,7 @@ export function sync(args, pkgRoot) {
60
76
  }
61
77
 
62
78
  const ledger = new Map((kit.managed || []).map((f) => [f.rel, f.sha256]));
63
- const updated = [], skipped = [], restored = [], added = [], removed = [], newOwned = [];
79
+ const updated = [], skipped = [], restored = [], added = [], removed = [], newOwned = [], adopted = [];
64
80
  let unchanged = 0;
65
81
  const managedNew = [];
66
82
 
@@ -72,26 +88,29 @@ export function sync(args, pkgRoot) {
72
88
  removed.push(rel);
73
89
  continue; // 包内已删:文件留在盘上,出台账(下次不再跟踪)
74
90
  }
75
- const diskSha = fs.existsSync(disk) ? sha256(fs.readFileSync(disk)) : null;
91
+ const diskSha = fs.existsSync(disk) ? shaText(disk) : null;
76
92
  if (diskSha === null) {
77
93
  fs.mkdirSync(path.dirname(disk), { recursive: true }); // 父目录可能整目录缺失(如 localOnly 宿主目录被清),copyfile 不建目录
78
- fs.copyFileSync(freshFile.abs, disk);
94
+ copyManaged(freshFile, disk, rel);
79
95
  restored.push(rel);
80
96
  managedNew.push({ rel, sha256: freshFile.sha });
81
97
  continue;
82
98
  }
83
99
  if (diskSha === ledgerSha) {
84
- if (freshFile.sha === ledgerSha) { unchanged++; managedNew.push({ rel, sha256: ledgerSha }); }
85
- else { fs.copyFileSync(freshFile.abs, disk); updated.push(rel); managedNew.push({ rel, sha256: freshFile.sha }); }
100
+ if (freshFile.sha === ledgerSha) {
101
+ unchanged++; managedNew.push({ rel, sha256: ledgerSha });
102
+ if (rel.startsWith('.githooks/')) safeChmod(disk); // 存量装户 0644 死钩子升级修复(复核 P2-3)
103
+ }
104
+ else { copyManaged(freshFile, disk, rel); updated.push(rel); managedNew.push({ rel, sha256: freshFile.sha }); }
86
105
  continue;
87
106
  }
88
107
  // 本地已改
89
108
  if (freshFile.sha === diskSha) { unchanged++; managedNew.push({ rel, sha256: diskSha }); continue; } // 改动恰好等于新版
90
- if (force) { fs.copyFileSync(freshFile.abs, disk); updated.push(`${rel}(--force 覆盖本地改动)`); managedNew.push({ rel, sha256: freshFile.sha }); }
109
+ if (force) { copyManaged(freshFile, disk, rel); updated.push(`${rel}(--force 覆盖本地改动)`); managedNew.push({ rel, sha256: freshFile.sha }); }
91
110
  else { skipped.push(rel); managedNew.push({ rel, sha256: ledgerSha }); } // 台账保持包侧基线:持续报告「本地已改」,直到 --force 或本地对齐新版
92
111
  }
93
112
 
94
- // 台账外的新文件:managed 类安装;owned 类只对「盘上缺失」的起步文档报告(已装的不动不报)
113
+ // 台账外的新文件:managed 类安装 / 收养;owned 类只对「盘上缺失」的起步文档报告(已装的不动不报)
95
114
  for (const [rel, freshFile] of fresh) {
96
115
  if (ledger.has(rel)) continue;
97
116
  if (isOwned(rel)) {
@@ -99,9 +118,18 @@ export function sync(args, pkgRoot) {
99
118
  continue;
100
119
  }
101
120
  const disk = path.join(target, rel);
102
- if (fs.existsSync(disk) && !force) { skipped.push(`${rel}(已存在未入台账)`); continue; }
121
+ if (fs.existsSync(disk)) {
122
+ // 盘上有、台账无:内容恰好等于新版 → 收养登记(与台账内「改动恰好等于新版」同判据);
123
+ // 内容不等 = 本地真改动 → 默认保守跳过且不登记(下次仍报「未入台账」,不会被静默覆盖);
124
+ // --force 下按既有语义覆盖并登记(与台账内文件的 --force 行为一致)
125
+ const diskSha = shaText(disk);
126
+ if (diskSha === freshFile.sha) { adopted.push(rel); managedNew.push({ rel, sha256: diskSha }); }
127
+ else if (force) { copyManaged(freshFile, disk, rel); added.push(rel); managedNew.push({ rel, sha256: freshFile.sha }); }
128
+ else skipped.push(`${rel}(已存在未入台账)`);
129
+ continue;
130
+ }
103
131
  fs.mkdirSync(path.dirname(disk), { recursive: true });
104
- fs.copyFileSync(freshFile.abs, disk);
132
+ copyManaged(freshFile, disk, rel);
105
133
  added.push(rel);
106
134
  managedNew.push({ rel, sha256: freshFile.sha });
107
135
  }
@@ -110,24 +138,27 @@ export function sync(args, pkgRoot) {
110
138
  list('覆盖更新', updated);
111
139
  list('恢复缺失', restored);
112
140
  list('新增安装', added);
141
+ list('收养登记', adopted);
113
142
  list('本地已改,跳过', skipped);
114
143
  if (skipped.some((s) => !s.includes('未入台账'))) console.log(' ↑ --force 覆盖本地改动;git diff 自查差异;台账保持包侧基线,后续 sync 持续报告直至处理');
115
144
  list('包内已移除(文件保留在盘上,可手动删除)', removed);
116
145
  list('新增 owned 起步文档(项目自持,未自动安装)', newOwned);
117
- if (!updated.length && !restored.length && !added.length && !removed.length && !skipped.length && !newOwned.length) {
146
+ if (!updated.length && !restored.length && !added.length && !adopted.length && !removed.length && !skipped.length && !newOwned.length) {
118
147
  console.log(` 已是最新(${unchanged} 份 managed 无变化)`);
119
148
  } else {
120
149
  console.log(` 无变化 ${unchanged} 份`);
121
150
  }
122
151
 
123
152
  // owned 台账按盘面自愈:owned 归项目所有,哈希只是记账不是约束——手改后无须手工刷 kit.json
124
- // (Shipyard 回流策略,2026-09-24:曾在消费仓被迫手工刷新 owned 哈希,根因收敛到此处)
153
+ // (Shipyard 回流策略,2026-09-24:曾在消费仓被迫手工刷新 owned 哈希,根因收敛到此处)。
154
+ // sha 按 LF 归一记(与 doctor checkOwnedDrift 同口径,2026-09-27 gate-coverage:跨 checkout 字节稳定)
155
+ const ownedSha = (p) => sha256(Buffer.from(fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n'), 'utf8'));
125
156
  let ownedRefreshed = 0;
126
157
  if (Array.isArray(kit.owned)) {
127
158
  kit.owned = kit.owned.map((f) => {
128
159
  const p = path.join(target, f.rel);
129
160
  if (!fs.existsSync(p)) return f;
130
- const disk = sha256(fs.readFileSync(p));
161
+ const disk = ownedSha(p);
131
162
  if (disk === f.sha256) return f;
132
163
  ownedRefreshed++;
133
164
  return { ...f, sha256: disk };
@@ -140,14 +171,27 @@ export function sync(args, pkgRoot) {
140
171
  kit.version = pkg.version;
141
172
  fs.writeFileSync(kitPath, `${JSON.stringify(kit, null, 2)}\n`);
142
173
 
143
- // 生成器目标走锚点重写(不参与 sha 比对)
144
- const genIndex = runNode(target, '.agents/scripts/gen-workflow-index.mjs');
145
- if (genIndex.ok || fs.existsSync(path.join(target, '.agents/scripts/gen-workflow-index.mjs'))) {
146
- console.log(genIndex.ok ? ' 已重生成 workflow/INDEX.md(锚点内重写)' : ` ⚠️ gen-workflow-index 失败:${genIndex.out.split('\n')[0]}`);
174
+ // 生成器目标走锚点重写(不参与 sha 比对)——执行前过供应链防线(init-p1-batch P1-3):
175
+ // 目标侧脚本与包源渲染值 sha 一致才执行(预置/被改动的脚本不可信,跳过并显式提示)
176
+ const genGuard = (rel) => {
177
+ const g = scriptTrusted({ pkgRoot, target, rel, vars });
178
+ if (!g.ok) {
179
+ console.log(` ⚠️ 跳过执行 ${rel}——${g.note}(供应链防线:只执行与包源渲染值一致的目标侧脚本)`);
180
+ return false;
181
+ }
182
+ return true;
183
+ };
184
+ if (genGuard('.agents/scripts/gen-workflow-index.mjs')) {
185
+ const genIndex = runNode(target, '.agents/scripts/gen-workflow-index.mjs');
186
+ if (genIndex.ok || fs.existsSync(path.join(target, '.agents/scripts/gen-workflow-index.mjs'))) {
187
+ console.log(genIndex.ok ? ' 已重生成 workflow/INDEX.md(锚点内重写)' : ` ⚠️ gen-workflow-index 失败:${genIndex.out.split('\n')[0]}`);
188
+ }
147
189
  }
148
- const genBoard = runNode(target, '.agents/scripts/gen-wiki-board.mjs');
149
- if (genBoard.ok || fs.existsSync(path.join(target, '.agents/scripts/gen-wiki-board.mjs'))) {
150
- console.log(genBoard.ok ? ' 已重生成 wiki 速览与看板 DATA(锚点内重写)' : ` ⚠️ gen-wiki-board 失败:${genBoard.out.split('\n')[0]}`);
190
+ if (genGuard('.agents/scripts/gen-wiki-board.mjs')) {
191
+ const genBoard = runNode(target, '.agents/scripts/gen-wiki-board.mjs');
192
+ if (genBoard.ok || fs.existsSync(path.join(target, '.agents/scripts/gen-wiki-board.mjs'))) {
193
+ console.log(genBoard.ok ? ' 已重生成 wiki 速览与看板 DATA(锚点内重写)' : ` ⚠️ gen-wiki-board 失败:${genBoard.out.split('\n')[0]}`);
194
+ }
151
195
  }
152
196
  } finally {
153
197
  fs.rmSync(tmp, { recursive: true, force: true });
package/src/sync.test.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // sync.test.mjs — flow-kit sync 三态升级 + add-host / add-gate 行为验证。
2
2
  // 方法:迷你 fixture 包根(小模板树 + 假宿主 zcode + 假门禁 fakeg)+ 手工模拟 v1 安装态(kit.json 台账 + 盘面),
3
3
  // 子进程 driver 调真实 sync/add-host/add-gact(隔离 process.exit 与 doctor 尾部退出),断言盘面 + 台账 + stdout 标记。
4
+ // 场景 14:台账外文件收养(2026-09-26 managed-ledger-adopt)——内容==新版才登记,本地真改动仍跳过。
4
5
  import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import os from 'node:os';
@@ -252,5 +253,46 @@ else if (cmd === 'add-gate') addGate(rest, root);
252
253
  check('S13 恢复有报告标记', out.includes('恢复缺失'), out);
253
254
  }
254
255
 
256
+ // ============ 场景 14:台账外文件收养(2026-09-26 managed-ledger-adopt) ============
257
+ // 盘上有 managed 类文件但台账无该条目(旧版手动双写绕过 init/sync 登记)。收养判据与台账内
258
+ // 「改动恰好等于新版」同源:diskSha === 新版渲染 sha 才登记;内容不等(本地真改动)仍保守跳过。
259
+ {
260
+ // ①盘上有、台账无、内容 == 新版 → 收养登记
261
+ const fx = mkFixture(), t = mkTarget(fx);
262
+ W(path.join(t, '.agents/scripts/chg.txt'), 'chg v2 port 777\n'); // == 新版渲染
263
+ // 把它从台账里剔除,伪造「盘上有但未登记」
264
+ const k0 = JSON.parse(R(path.join(t, '.agents/kit.json')));
265
+ k0.managed = k0.managed.filter((f) => f.rel !== '.agents/scripts/chg.txt');
266
+ W(path.join(t, '.agents/kit.json'), `${JSON.stringify(k0, null, 2)}\n`);
267
+ const r = runCmd('sync', fx, t);
268
+ const out = r.stdout + r.stderr;
269
+ check('S14① 内容==新版 → 收养登记(报告标记)', out.includes('收养登记') && out.includes('chg.txt'), out);
270
+ const kit1 = JSON.parse(R(path.join(t, '.agents/kit.json')));
271
+ check('S14① 收养后入台账且 sha=盘面', (() => {
272
+ const e = kit1.managed.find((f) => f.rel === '.agents/scripts/chg.txt');
273
+ return e && e.sha256 === sha256(Buffer.from('chg v2 port 777\n'));
274
+ })(), JSON.stringify(kit1.managed));
275
+ check('S14① 收养不改盘面(内容原样)', R(path.join(t, '.agents/scripts/chg.txt')) === 'chg v2 port 777\n');
276
+
277
+ // ②盘上有、台账无、内容 != 新版 → 不收养、不覆盖、台账不登记
278
+ const fx2 = mkFixture(), t2 = mkTarget(fx2);
279
+ W(path.join(t2, '.agents/scripts/chg.txt'), 'chg LOCAL EDIT\n'); // 本地真改动
280
+ const k1 = JSON.parse(R(path.join(t2, '.agents/kit.json')));
281
+ k1.managed = k1.managed.filter((f) => f.rel !== '.agents/scripts/chg.txt');
282
+ W(path.join(t2, '.agents/kit.json'), `${JSON.stringify(k1, null, 2)}\n`);
283
+ const r2 = runCmd('sync', fx2, t2);
284
+ const out2 = r2.stdout + r2.stderr;
285
+ check('S14② 内容!=新版 → 不收养(不报收养登记)', !out2.includes('收养登记'), out2);
286
+ check('S14② 本地改动幸存未被覆盖', R(path.join(t2, '.agents/scripts/chg.txt')) === 'chg LOCAL EDIT\n', out2);
287
+ const kit2 = JSON.parse(R(path.join(t2, '.agents/kit.json')));
288
+ check('S14② 台账不登记(持续报告,防下次静默覆盖)', !kit2.managed.some((f) => f.rel === '.agents/scripts/chg.txt'));
289
+ check('S14② 报「已存在未入台账」跳过', out2.includes('已存在未入台账'), out2);
290
+
291
+ // ③盘上无、台账无 → 新增安装(既有行为不回归)
292
+ const fx3 = mkFixture(), t3 = mkTarget(fx3);
293
+ const r3 = runCmd('sync', fx3, t3);
294
+ check('S14③ 盘上无 → 新增安装(既有行为不回归)', (r3.stdout + r3.stderr).includes('新增安装') && R(path.join(t3, '.agents/commands/new.txt')) === 'new in v2\n');
295
+ }
296
+
255
297
  console.log(`\n合计: PASS ${pass} / FAIL ${failCount}`);
256
298
  process.exit(failCount ? 1 : 0);
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // trae-hooks.test.mjs — trae 宿主 hook 判定回归(2026-09-27 host-gates-p1,incident 三件套「防复发验证」落点)
3
+ // 方法:stdin JSON 注入 spawn pre-shell-check.cjs(RunCommand 形态),断言 permissionDecision。
4
+ // 场景对位:P1-B2 强推 token 化(换序 / +refspec / --force-with-lease 放行 / -f)+ 既有 deny/ask 回归 +
5
+ // 非 push 命令带 --force 不误拦(如 dotnet build --force)。
6
+ // 注:127 未装分支(P2-B4)需 .githooks 脚本缺失形态,经 isCommit 路径覆盖——单独场景构造 cwd 指向
7
+ // 无 .githooks 的临时目录跑 git commit,断言「门禁未执行」而非「未通过」。
8
+ // 用法:node src/trae-hooks.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 HOOK = path.join(PKG_ROOT, 'modules', 'hosts', 'trae', 'hooks', 'pre-shell-check.cjs');
24
+
25
+ const runHook = (command, cwd) => {
26
+ const payload = JSON.stringify({
27
+ tool_name: 'RunCommand',
28
+ tool_input: { command },
29
+ cwd: cwd || process.cwd(),
30
+ });
31
+ const r = spawnSync(process.execPath, [HOOK], { input: payload, encoding: 'utf8', timeout: 30000 });
32
+ let decision = 'none';
33
+ let reason = '';
34
+ try {
35
+ const j = JSON.parse((r.stdout || '').trim().split('\n').pop() || '{}');
36
+ decision = j?.hookSpecificOutput?.permissionDecision || 'none';
37
+ reason = j?.hookSpecificOutput?.permissionDecisionReason || '';
38
+ } catch { /* 无输出 = 放行 */ }
39
+ return { decision, reason, raw: r.stdout || '', status: r.status };
40
+ };
41
+
42
+ // ---- ① 强推 token 化(P1-B2)----
43
+ {
44
+ const cases = [
45
+ ['git push --force', 'deny', '标准序'],
46
+ ['git push origin main --force', 'deny', '换序(旧代码漏拦形态)'],
47
+ ['git push origin +main', 'deny', '+refspec 变体'],
48
+ ['git push -f', 'deny', '短旗标'],
49
+ ['git -c x=y push origin main --force', 'deny', '全局参前缀'],
50
+ ['git push -vf origin main', 'deny', '组合短旗标 -vf(复核 P2-1)'],
51
+ ['git push origin main && dotnet build --force', 'none', '跨 && 的 --force 属他命令不误并(复核 P2-3)'],
52
+ ['git push origin main --force-with-lease', 'none', '--force-with-lease 精确 token 不误拦'],
53
+ ['git push origin main', 'none', '普通 push 放行'],
54
+ ['dotnet build --force', 'none', '非 push 命令带 --force 不误拦'],
55
+ ];
56
+ for (const [cmd, expect, label] of cases) {
57
+ const r = runHook(cmd);
58
+ check(`①${label}(${cmd})→ ${expect === 'none' ? '放行' : expect}`,
59
+ r.decision === expect,
60
+ `decision=${r.decision} reason=${r.reason}`);
61
+ }
62
+ }
63
+ // ①附:换序强推的 deny 理由点名红线
64
+ {
65
+ const r = runHook('git push origin main --force');
66
+ check('①换序强推 deny 理由点名禁强制推送', r.decision === 'deny' && /强制推送/.test(r.reason), r.reason);
67
+ }
68
+
69
+ // ---- ② 既有 deny/ask 回归(token 边界口径不动)----
70
+ {
71
+ const cases = [
72
+ ['git reset --hard HEAD~1', 'deny'],
73
+ ['drop database MyDb', 'deny'],
74
+ // 'dotnet ef' 已从 deny 清单移除(p2-batch2:对齐 settings.json 权威清单——settings.json 无此项)
75
+ ['dotnet ef database update', 'none'],
76
+ ['git checkout main', 'ask'],
77
+ ['rm -rf tmp/', 'ask'],
78
+ ['git status', 'none'],
79
+ ['git commit -m "fix: 修复"', 'none'], // commit 走 .githooks 链(本仓有钩子且全绿);deny 门本身不应拦 commit
80
+ ];
81
+ for (const [cmd, expect] of cases) {
82
+ const r = runHook(cmd, PKG_ROOT); // cwd=本仓:git commit 场景能找到 .githooks
83
+ check(`②${cmd} → ${expect === 'none' ? '不因 deny/ask 门拦' : expect}`,
84
+ r.decision === expect,
85
+ `decision=${r.decision} reason=${r.reason}`);
86
+ }
87
+ }
88
+
89
+ // ---- ②附 消息字样误触(p2-batch2):带引号多词消息内含禁词 → 剥引号整段后不再误 deny/ask ----
90
+ {
91
+ const cases = [
92
+ ['git commit -m "fix: 修 git push --force 误拦"', 'none', 'deny 词在引号消息内'],
93
+ ['git commit -m "docs: 补 git clean 说明"', 'none', 'ask 词在引号消息内'],
94
+ ['git commit -m "真正要拦的消息"', 'none', '普通消息照常'],
95
+ ];
96
+ for (const [cmd, expect, label] of cases) {
97
+ const r = runHook(cmd, PKG_ROOT);
98
+ check(`②附 ${label} → ${expect === 'none' ? '不误拦' : expect}`,
99
+ r.decision === expect,
100
+ `decision=${r.decision} reason=${r.reason}`);
101
+ }
102
+ }
103
+
104
+ // ---- ③ 127 未装区分(P2-B4):无 .githooks 的 cwd 跑 git commit → 「门禁未执行」----
105
+ {
106
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'trae-hook-test-'));
107
+ const r = runHook('git commit -m "x"', empty);
108
+ check('③.githooks 缺失 → deny 且理由为「门禁未执行/不可达」(非违例误报)',
109
+ r.decision === 'deny' && /门禁未执行|不可达/.test(r.reason),
110
+ `decision=${r.decision} reason=${r.reason}`);
111
+ fs.rmSync(empty, { recursive: true, force: true });
112
+ }
113
+
114
+ console.log(`\n合计: PASS ${pass} / FAIL ${fail}`);
115
+ process.exit(fail ? 1 : 0);
@@ -27,6 +27,7 @@
27
27
  - **`git commit` / `git merge` / `git push` 三处一律禁 `--no-verify`**——被拦说明产出不合规,按提示修完原路重试。
28
28
  - 项目专属门禁挂 `.agents/hooks/local-pre-commit`。
29
29
  - 提交遵循 Conventional Commits 中文(feat / fix / docs / style / refactor / perf);L1+ 入口文档 / spec / plan 随代码同一提交;确认(approved)后立即 `docs(*)` 单独提交留痕。
30
+ - **确认门(2026-09-27 起)**:intent/spec/plan 的 approved/done 与 incident 的 fixed/closed(2026-09-28 起,open→fixed / fixed→closed 两跳、无单跳)唯一入口 = `node .agents/scripts/confirm-doc.mjs <path>`,放弃态走显式 `--to superseded|cancelled`(cancelled 自 draft/approved/open/fixed;superseded 自 approved/done/fixed/closed——四终态同样内容绑定),两形态:① 用户终端亲手运行键入「可以」(TTY,AI 会话内被拒);② **对话委托代录**——用户在对话内明确确认后,AI 跑 `confirm-doc.mjs <path> --delegated "<用户原话>"` 逐件代录(一次一份,「逐件确认」口径——多份并录被拒),台账如实记 `source: chat-delegated` + 原话供对质,永不伪装 TTY 行(check-loop 15 指纹+台账对账拦截,与 source 无关)。
30
31
 
31
32
  ### 检索、看板与量化
32
33
 
@@ -202,7 +202,8 @@ let Q = ''; // 搜索过滤关键字
202
202
  const $ = (s) => document.querySelector(s);
203
203
  const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
204
204
  // —— 精细化徽标:验收进度 / 配对断裂 ⚠ / 进行中老化 ——
205
- const ACTIVE_STATUSES = ['draft', 'approved', 'open'];
205
+ // 活跃集默认值仅在 /api/board 首包前兜底;首包后由载荷 activeStatuses 覆盖(枚举单源贯通,p2-batch2)
206
+ let ACTIVE_STATUSES = ['draft', 'approved', 'open'];
206
207
  const ageClass = (c) => {
207
208
  if (!ACTIVE_STATUSES.includes(c.status)) return '';
208
209
  const d = new Date(c.date);
@@ -273,6 +274,7 @@ async function refreshBoard() {
273
274
  }
274
275
  CARDS = data.cards;
275
276
  ROOT = data.root || ROOT;
277
+ if (Array.isArray(data.activeStatuses) && data.activeStatuses.length) ACTIVE_STATUSES = data.activeStatuses;
276
278
  CURRENT = CARDS.find((c) => c.file === CURRENT?.file) ?? CURRENT; // 重定位抽屉卡片,避免刷新后徽标/状态陈旧
277
279
  for (const t of Object.keys(TYPE_LABEL)) $(`#c-${t}`).textContent = `${TYPE_LABEL[t]} ${data.counts[t] ?? 0}`;
278
280
  const groups = Object.fromEntries(COLS.map((c) => [c.key, []]));
@@ -289,6 +291,21 @@ async function refreshBoard() {
289
291
  const ca = $('#c-alerts');
290
292
  ca.style.display = nAlerts ? '' : 'none';
291
293
  if (nAlerts) { ca.textContent = `⚠ 断档 ${nAlerts}`; ca.style.color = 'var(--bad)'; }
294
+ // hard-block 全局告警条(单源 check-loop,2026-09-27 board-kb-p1):check-loop hard 项自动跟随
295
+ let loopBar = $('#loop-alerts');
296
+ if (!loopBar) {
297
+ loopBar = document.createElement('div');
298
+ loopBar.id = 'loop-alerts';
299
+ loopBar.style.cssText = 'display:none;background:var(--bad);color:#fff;padding:6px 14px;font-size:12px;line-height:1.7;white-space:pre-wrap;';
300
+ document.body.insertBefore(loopBar, document.body.firstChild);
301
+ }
302
+ if (data.loopHardBlocks === null) {
303
+ loopBar.style.display = ''; loopBar.textContent = `⚠ 闭环校验不可用(${data.loopNote || '未知原因'})——hard-block 面暂不可见`;
304
+ } else if (data.loopHardBlocks.length) {
305
+ loopBar.style.display = ''; loopBar.textContent = `⛔ 闭环 hard-block ${data.loopHardBlocks.length} 项(单源 check-loop):\n` + data.loopHardBlocks.join('\n');
306
+ } else {
307
+ loopBar.style.display = 'none';
308
+ }
292
309
  const cardHtml = (fam, extra = '') => {
293
310
  const h = fam.head;
294
311
  return `<div class="card ${ageClass(h)}${EXPANDED.has(fam.slug) || q ? ' open' : ''}" data-slug="${esc(fam.slug)}" style="border-left-color:${TYPE_COLOR[h.type]}">` +
@@ -336,10 +353,16 @@ async function loadDoc() {
336
353
  if (error) { $('#d-body').innerHTML = `<div class="none">${esc(error)}</div>`; return; }
337
354
  // 裸 .md 相对路径引用 → 可点链接(workflow 文档互引多为纯文本路径而非 md 链接语法;
338
355
  // 已有 ](path) 链接语法因前缀为 ] 不会被重复包裹)
339
- const linked = text.replace(
356
+ let linked = text.replace(
340
357
  /(^|[\s((【>])((?:\.\.\/|\.\/)?(?:intents|incidents|plans|specs)\/[A-Za-z0-9_\-./]+\.md)/g,
341
358
  '$1[$2]($2)'
342
359
  );
360
+ // XSS 防线(2026-09-27 p2-batch2):vendored marked v12 默认放行内嵌 HTML,文档由 AI 起草
361
+ // (外部内容可经 prompt 注入落盘)——渲染前剥全部裸 HTML 标签,markdown 语法不受影响
362
+ // (已知误伤:代码块内联 HTML 与散文 `<...>` 跨度会被剥,只读预览可接受——源文件不损)。
363
+ // 残余面:markdown 链接 href(javascript:/data:)不经本剥离,靠 onDocLink 对 #d-body a 一律
364
+ // preventDefault + 非 http(s) 转 vscode://file 中和——改 onDocLink 拦截链路前先读本注释。
365
+ linked = linked.replace(/<[^>]*>/g, '');
343
366
  $('#d-body').innerHTML = `<div class="md">${marked.parse(linked)}</div>`;
344
367
  }
345
368
 
@@ -438,7 +461,13 @@ sse.onerror = () => { $('#sse').classList.remove('on'); $('#sse span:last-child'
438
461
  setInterval(() => refreshBoard(), 60000);
439
462
 
440
463
  // 搜索过滤:本地过滤标题/slug/文件,SSE 刷新时保持关键字
441
- $('#q').addEventListener('input', (e) => { Q = e.target.value; refreshBoard(); });
464
+ // 击键去抖 400ms(复核 P2-2 rider):/api/board 服务端有 loop TTL 缓存,去抖进一步避免连发请求排队
465
+ let qTimer = null;
466
+ $('#q').addEventListener('input', (e) => {
467
+ Q = e.target.value;
468
+ clearTimeout(qTimer);
469
+ qTimer = setTimeout(refreshBoard, 400);
470
+ });
442
471
 
443
472
  // 首次入场:列错峰上浮一次(.boot 稍后摘除,SSE / 过滤触发的重渲染不再重播)
444
473
  $('#board').classList.add('boot');
@@ -31,7 +31,8 @@ next: .agents/commands/test.md
31
31
  - L2/L3 按 4 节填写:任务拆解(每个任务有判据) / 风险评估 / 执行顺序 / 遗留项
32
32
  - L1 默认极简两节:改动面(逐条文件+做什么) / 验证方式(一行静态门+按需 UI 实测);仅多文件多步骤时再加任务拆解/执行顺序
33
33
  - 仍须与入口文档同名落盘
34
- 3. 停下,输出 plan 草稿全文给用户过目,一句"可以"即确认。**逐件确认**:入口文档 / spec / plan 各自的确认点不得并作一次,plan 草稿须全文过目(不接受摘要代替)
34
+ 3. 停下,输出 plan 草稿全文给用户过目,一句"可以"即确认。**逐件确认**:入口文档 / spec / plan 各自的确认点不得并作一次,plan 草稿须全文过目(不接受摘要代替)。**逐件配逐件原话**:`--delegated` 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力(事后对质看不出放行的是哪一件),且相邻同 quote 连跑触发 check-loop 15 并录告警(见 workflow/papercuts.md 2026-09-28)
35
+ **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力,且相邻同 quote 连跑会触发并录告警))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——关单编辑(勾验/回填)先于 done 确认、confirm-doc 是最后一次写入,此后修订走 superseded 或新 intent。
35
36
  4. 确认后 → plan 状态 draft → approved,并在 plan 内补「确认与复核」节(确认结果 + 日期)。确认环节须在文档留痕:`approved` 是确认的机器可见态,`done` 只在关单出现,禁从 `draft` 直跳 `done`;确认后立即 `docs(workflow)` 单独提交留痕(根 `AGENTS.md` 提交约定;check-loop 检查 14 口径)
36
37
 
37
38
  ### 进计划模式列改动
@@ -65,9 +66,7 @@ next: .agents/commands/test.md
65
66
 
66
67
  ## 改权威源后必跑(薄适配同步防漏)
67
68
 
68
- - 改 `templates/_agents/commands/*.md` 或 `templates/_agents/roles/*.md` 正文后 → 跑 `node bin/flow-kit.mjs sync-hosts --diff` 看正文段漂移 → 用户拍板 → `node bin/flow-kit.mjs sync-hosts --apply` 单向同步(薄适配正文 = 权威源正文,frontmatter 不动);详见 `.agents/commands/sync-hosts.md`
69
- - 改 frontmatter(薄适配特化字段如 trae `name: wf-X`)不需同步——apply 只动正文段
70
- - 改 trae 钩子 / 规则 / 包源其他文件不在本工具范围
69
+ - 改 `templates/_agents/{commands,roles}/*.md` 正文后跑 `node bin/flow-kit.mjs sync-hosts --diff` → 拍板 → `--apply` 单向同步(只动正文段,frontmatter 不动);口径与边界详见 `.agents/commands/sync-hosts.md`
71
70
 
72
71
  ## 改 doctor / check-loop 后必跑(三处口径对账)
73
72
 
@@ -79,6 +78,8 @@ next: .agents/commands/test.md
79
78
 
80
79
  - 改 `templates/_agents/*` 包源后 → 跑 `node .agents/scripts/source-sync-check.mjs --diff` 看装副本 `.agents/` 是否同步(缺失 / 孤儿 / 漂移三类差异)
81
80
  - 漂移说明包源改了装副本未跟;缺失说明新增文件 init 未执行;孤儿说明装副本独有(可能是装户配置,不删)
81
+ - **新增 managed 类文件须经 init/sync 登记,禁手动双写绕过台账**——手工同时写 `templates/_agents/` 与 `.agents/` 会绕过台账登记,使该文件永远不被 sync 升级(`doctor` 台账覆盖率检查会拦;见 incidents/2026-09-26-managed-ledger-adopt 复盘)
82
+ - 门禁边界(pre-commit 双源一致性):比较工作树非暂存区——两侧改好、分两笔提交不拦(最终一致即可);包源删除文件退化为「孤儿」只报告不拦,删包源须手动清理装副本残件(复核 P2-2 声明,2026-09-27 gate-hardening-p2-batch)
82
83
  - B-b 决策「只报告不修复」;详见 `.agents/commands/source-sync-check.md`
83
84
 
84
85
  ## 子代理调用约定
@@ -51,5 +51,6 @@ next: .agents/commands/build.md
51
51
  ## 确认后
52
52
 
53
53
  - spec 状态 draft → approved
54
+ **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力,且相邻同 quote 连跑会触发并录告警))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——关单编辑(勾验/回填)先于 done 确认、confirm-doc 是最后一次写入,此后修订走 superseded 或新 intent。
54
55
  - 立即 `docs(workflow)` 单独提交留痕(根 `AGENTS.md` 提交约定;check-loop 检查 14 口径)
55
56
  - 进 `next: .agents/commands/build.md`
@@ -86,6 +86,5 @@ next: .agents/commands/plan.md(若复盘需要新 intent)
86
86
 
87
87
  ## 确认后
88
88
 
89
- - incident 状态 → fixed(三件套全落地)
90
- - 防复发用例通过后 → closed
89
+ - incident 状态 → fixed(三件套全落地);防复发用例通过后 → closed——两跳均走确认门 `node .agents/scripts/confirm-doc.mjs <path>`(open→fixed / fixed→closed,无单跳;2026-09-28 起 check-loop 15 对账,AI 不得直改状态)
91
90
  - 若需新 intent → 进 `next: .agents/commands/plan.md`
@@ -29,6 +29,8 @@ next: L1 → .agents/commands/build.md ;L2/L3 → .agents/commands/design.md
29
29
  3. 写不出可测试判据 = 还没想清楚 → 反问澄清,不要硬填
30
30
  4. 起草后停下,输出草稿全文给用户过目,一句"可以"即确认
31
31
 
32
+ **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力,且相邻同 quote 连跑会触发并录告警))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——关单编辑(勾验/回填)先于 done 确认、confirm-doc 是最后一次写入,此后修订走 superseded 或新 intent。
33
+
32
34
  ## 技能辅助(可选,宿主级 skills)
33
35
 
34
36
  - 需求模糊、判据写不出时:用 `grilling`/`grill-me` 拷问需求,答完回填 6 节
@@ -75,7 +75,8 @@ node bin/flow-kit.mjs doctor # §6.5 delegations + §6.6 o
75
75
  - **关单**(日常闭环点,不依赖是否上 prod):
76
76
  - 关单前一键过门:`node .agents/scripts/verify.mjs`(npm test + check-loop 固定编排,任一失败非零退出,非绿不关单)
77
77
  - 新需求:逐条勾验入口 intent「验收标准」,每条补「证据:」,intent → done;同名 spec(若有)仍 approved → done
78
- - 修复:防复发验证已落地,incident → closed
78
+ **确认落态唯一入口**(2026-09-27 起):用户在终端跑 `node .agents/scripts/confirm-doc.mjs <path>` 键入「可以」(draft→approved / approved→done;对话内明确确认后 AI 可 `--delegated "<原话>"` 代录,台账如实记 source/quote——**逐件调用 + 逐件原话**(一次一份,build.md「逐件确认」口径;多份并录被 confirm-doc 拒绝。--delegated 每次传当次实际放行的措辞,不复用同句——复用同句使台账 quote 失去分辨力,且相邻同 quote 连跑会触发并录告警))。check-loop 15 如实口径:缺记录/指纹不配对 → hard-block 拦截;伪造台账本地不可机器防,留痕供事后对质;done 内容绑定(2026-09-28 起)——**关单编辑(勾验/回填确认结果)先于 done 确认、confirm-doc 是最后一次写入**,此后修订走 superseded 或新 intent。
79
+ - 修复:防复发验证已落地,incident → closed(经确认门 confirm-doc:open→fixed / fixed→closed 两跳、无单跳;2026-09-28 起 check-loop 15 对账——关单编辑先于 closed 确认,口径同 done 内容绑定)
79
80
  - **同族收尾**:入口置终态时,同名 plan 一并置 `done`(spec 见上条),不留 `approved` 孤儿(口径同看板「入口已 done,本 plan 未终态」告警)
80
81
  - 主智能体自做的 L1+ 新需求在 `workflow/delegations.md`「自做任务结果表」记一行(修复类不重复记)
81
82
  - L1:本阶段结束,不进 deploy