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
@@ -0,0 +1,170 @@
1
+ // sync-hosts.test.mjs — flow-kit sync-hosts B-b 语义四场景(2026-09-25)
2
+ // 方法:迷你 fixture 包根(权威源 templates/_agents + 薄适配 modules/hosts/<h>/{agents,commands}/
3
+ // 与权威源正文段一致,frontmatter 各异)+ 子进程 driver 调真实 sync-hosts(隔离 process.exit)
4
+ // 判据:①全对齐 → inSync=28, drift 空 ②权威源改 → drift 列 ③apply → 正文一致,frontmatter 各自保留
5
+ // ④薄适配 frontmatter 手改 → 不进 drift ⑤薄适配正文手改 → drift 列,apply 覆盖
6
+ // ⑥薄适配缺失 → authorityMissing 列;apply 不自动创建
7
+ // 用法:node src/sync-hosts.test.mjs
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import os from 'node:os';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ const SRC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)));
15
+
16
+ let pass = 0, failCount = 0;
17
+ function check(name, cond, detail = '') {
18
+ if (cond) { pass++; console.log(`PASS ${name}`); }
19
+ else { failCount++; console.log(`FAIL ${name}${detail ? `——${detail}` : ''}`); }
20
+ }
21
+ const W = (p, content) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content); };
22
+ const R = (p) => fs.readFileSync(p, 'utf8');
23
+
24
+ // ---- fixture 包根:权威源 + 4 宿主薄适配(正文段与权威源一致,frontmatter 各异) ----
25
+ function mkFixture() {
26
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fk-synchosts-'));
27
+ // 权威源(2 commands + 1 role,简化测试体量;权威源也带 frontmatter 保证 body 段含前导空行,与薄适配对齐)
28
+ W(path.join(root, 'templates/_agents/commands/build.md'),
29
+ `---\ndescription: Build 阶段\nstage: Build\n---\n\n# Build · v1\n\n权威源正文 build v1\n`);
30
+ W(path.join(root, 'templates/_agents/commands/test.md'),
31
+ `---\ndescription: Test 阶段\nstage: Test\n---\n\n# Test · v1\n\n权威源正文 test v1\n`);
32
+ W(path.join(root, 'templates/_agents/roles/implementer.md'),
33
+ `---\ndescription: Implementer 角色契约\n---\n\n# Implementer · v1\n\n权威源正文 implementer v1\n`);
34
+ // 薄适配:commands 权威源 → opencode + trae(trae 加 wf- 前缀 + name)
35
+ W(path.join(root, 'modules/hosts/opencode/commands/build.md'),
36
+ `---\ndescription: Build · opencode 风格\n---\n\n# Build · v1\n\n权威源正文 build v1\n`);
37
+ W(path.join(root, 'modules/hosts/trae/commands/wf-build.md'),
38
+ `---\nname: wf-build\ndescription: Build · trae 风格\n---\n\n# Build · v1\n\n权威源正文 build v1\n`);
39
+ W(path.join(root, 'modules/hosts/opencode/commands/test.md'),
40
+ `---\ndescription: Test · opencode 风格\n---\n\n# Test · v1\n\n权威源正文 test v1\n`);
41
+ W(path.join(root, 'modules/hosts/trae/commands/wf-test.md'),
42
+ `---\nname: wf-test\ndescription: Test · trae 风格\n---\n\n# Test · v1\n\n权威源正文 test v1\n`);
43
+ // roles 权威源 → 4 宿主 agents(omp/zcode 中文风格 + trae name)
44
+ W(path.join(root, 'modules/hosts/zcode/agents/implementer.md'),
45
+ `---\nname: implementer\ndescription: 在已批准工作包内实现\n---\n\n# Implementer · v1\n\n权威源正文 implementer v1\n`);
46
+ W(path.join(root, 'modules/hosts/omp/agents/implementer.md'),
47
+ `---\nname: implementer\ndescription: omp 本机风格\n---\n\n# Implementer · v1\n\n权威源正文 implementer v1\n`);
48
+ W(path.join(root, 'modules/hosts/opencode/agents/implementer.md'),
49
+ `---\nname: implementer\ndescription: opencode 英文风格\n---\n\n# Implementer · v1\n\n权威源正文 implementer v1\n`);
50
+ W(path.join(root, 'modules/hosts/trae/agents/implementer.md'),
51
+ `---\nname: implementer\ndescription: trae 英文风格\n---\n\n# Implementer · v1\n\n权威源正文 implementer v1\n`);
52
+ // 孤儿薄适配:权威源无对应文件
53
+ W(path.join(root, 'modules/hosts/opencode/commands/orphan.md'), '# orphan\n');
54
+ return root;
55
+ }
56
+
57
+ // ---- 子进程 driver:调真实 sync-hosts ----
58
+ function runSyncHosts(fixtureRoot, extra = []) {
59
+ const driver = path.join(fixtureRoot, 'driver.mjs');
60
+ W(driver, `import { syncHosts } from ${JSON.stringify(pathToFileURL(path.join(SRC_ROOT, 'sync-hosts.mjs')).href)};
61
+ const root = ${JSON.stringify(fixtureRoot)};
62
+ syncHosts(process.argv.slice(2), root);
63
+ `);
64
+ return spawnSync(process.execPath, [driver, ...extra], { encoding: 'utf8' });
65
+ }
66
+
67
+ // ============ 场景 1:全对齐(正文段与权威源一致,frontmatter 各异)→ drift 空 ============
68
+ {
69
+ const fx = mkFixture();
70
+ const r = runSyncHosts(fx, ['--diff']);
71
+ const out = r.stdout + r.stderr;
72
+ check('S1 全对齐:正文对齐 8 对(2 commands×2 适配 + 1 role×4 宿主)', /正文对齐:\s*8\s*对/.test(out), out);
73
+ check('S1 全对齐:报告无漂移', /无漂移/.test(out), out);
74
+ check('S1 全对齐:exit 0', r.status === 0, `status=${r.status}`);
75
+ }
76
+
77
+ // ============ 场景 2:权威源改了正文,薄适配未跟 → drift 列 4 处(build→opencode+trae, test→opencode+trae)============
78
+ {
79
+ const fx = mkFixture();
80
+ W(path.join(fx, 'templates/_agents/commands/build.md'),
81
+ `---\ndescription: Build 阶段\nstage: Build\n---\n\n# Build · v2\n\n权威源正文 build v2\n`);
82
+ W(path.join(fx, 'templates/_agents/commands/test.md'),
83
+ `---\ndescription: Test 阶段\nstage: Test\n---\n\n# Test · v2\n\n权威源正文 test v2\n`);
84
+ const r = runSyncHosts(fx, ['--diff']);
85
+ const out = r.stdout + r.stderr;
86
+ check('S2 权威源改了 → 正文漂移 4(build×2 + test×2)', /正文段漂移(4/.test(out), out);
87
+ check('S2 列出 opencode/commands/build.md', out.includes('commands/build.md → opencode/commands/build.md'), out);
88
+ check('S2 列出 trae/commands/wf-build.md(trae wf- 前缀映射)', out.includes('commands/build.md → trae/commands/wf-build.md'), out);
89
+ check('S2 正文对齐 = 4(1 role × 4 宿主 implementer 未改)', /正文对齐:\s*4\s*对/.test(out), out);
90
+ check('S2 exit 0(diff 模式不阻断)', r.status === 0);
91
+ }
92
+
93
+ // ============ 场景 3:apply 后正文段 sha 一致,frontmatter 各自保留 ============
94
+ {
95
+ const fx = mkFixture();
96
+ W(path.join(fx, 'templates/_agents/commands/build.md'),
97
+ `---\ndescription: Build 阶段\nstage: Build\n---\n\n# Build · v2\n\n权威源正文 build v2\n`);
98
+ const r = runSyncHosts(fx, ['--apply']);
99
+ const out = r.stdout + r.stderr;
100
+ check('S3 apply:报告同步 2 份(opencode + trae)', /按权威源正文覆盖薄适配正文段\s*2\s*份/.test(out), out);
101
+ check('S3 apply:opencode 正文已同步到 v2', R(path.join(fx, 'modules/hosts/opencode/commands/build.md')).includes('权威源正文 build v2'));
102
+ check('S3 apply:trae 正文已同步到 v2', R(path.join(fx, 'modules/hosts/trae/commands/wf-build.md')).includes('权威源正文 build v2'));
103
+ check('S3 apply:opencode frontmatter 保留(仍无 name 字段)', !R(path.join(fx, 'modules/hosts/opencode/commands/build.md')).match(/^name:/m));
104
+ check('S3 apply:trae frontmatter 保留(仍含 name: wf-build)', R(path.join(fx, 'modules/hosts/trae/commands/wf-build.md')).match(/^name:\s*wf-build/m) !== null);
105
+ check('S3 apply:权威源不动', R(path.join(fx, 'templates/_agents/commands/build.md')).includes('权威源正文 build v2'));
106
+ check('S3 apply:再次 diff 应显示对齐 8', true); // 二次扫描由 sync-hosts 内部做
107
+ }
108
+
109
+ // ============ 场景 4:薄适配 frontmatter 手改 → 不进 drift(正文段比对不受影响)============
110
+ {
111
+ const fx = mkFixture();
112
+ // trae frontmatter description 改成"自定义"——正文段未动
113
+ W(path.join(fx, 'modules/hosts/trae/commands/wf-build.md'),
114
+ `---\nname: wf-build\ndescription: trae 自定义描述\n---\n\n# Build · v1\n\n权威源正文 build v1\n`);
115
+ const r = runSyncHosts(fx, ['--diff']);
116
+ const out = r.stdout + r.stderr;
117
+ check('S4 frontmatter 差异不计正文段漂移', /正文对齐:\s*8\s*对/.test(out) && /无漂移/.test(out), out);
118
+ }
119
+
120
+ // ============ 场景 5:薄适配正文手改 → drift 列出,apply 覆盖(不污染权威源)============
121
+ {
122
+ const fx = mkFixture();
123
+ W(path.join(fx, 'modules/hosts/zcode/agents/implementer.md'),
124
+ `---\nname: implementer\ndescription: zcode 风格\n---\n\n# Implementer · v1\n\n薄适配手改 implementer v1\n`);
125
+ const r = runSyncHosts(fx, ['--diff']);
126
+ const out = r.stdout + r.stderr;
127
+ check('S5 薄适配正文手改 → 正文段漂移 1', /正文段漂移(1/.test(out), out);
128
+ const r2 = runSyncHosts(fx, ['--apply']);
129
+ check('S5 apply 后:薄适配正文回到权威源(含「权威源正文 implementer v1」)', /权威源正文 implementer v1/.test(R(path.join(fx, 'modules/hosts/zcode/agents/implementer.md'))));
130
+ check('S5 apply 不污染权威源(权威源仍 v1)', !R(path.join(fx, 'templates/_agents/roles/implementer.md')).includes('薄适配手改'));
131
+ // apply 后再 diff 一次,diff 应显示"无漂移"
132
+ const r3 = runSyncHosts(fx, ['--diff']);
133
+ check('S5 apply 后再 diff 应无漂移', /无漂移/.test(r3.stdout + r3.stderr), r3.stdout + r3.stderr);
134
+ }
135
+
136
+ // ============ 场景 6:薄适配文件不存在 → authorityMissing 列出,apply 不创建 ============
137
+ {
138
+ const fx = mkFixture();
139
+ fs.rmSync(path.join(fx, 'modules/hosts/opencode/commands/build.md'));
140
+ const r = runSyncHosts(fx, ['--diff']);
141
+ const out = r.stdout + r.stderr;
142
+ check('S6 薄适配缺失 → authorityMissing 列', /权威源声明但薄适配缺失(/.test(out) && out.includes('opencode/commands/build.md'), out);
143
+ const r2 = runSyncHosts(fx, ['--apply']);
144
+ check('S6 apply 不自动创建薄适配(仍缺失)', !fs.existsSync(path.join(fx, 'modules/hosts/opencode/commands/build.md')));
145
+ }
146
+
147
+ // ============ 场景 7:--json 机器可读输出 ============
148
+ {
149
+ const fx = mkFixture();
150
+ const r = runSyncHosts(fx, ['--json']);
151
+ const out = r.stdout + r.stderr;
152
+ let parsed = null;
153
+ try { parsed = JSON.parse(out); } catch {}
154
+ check('S7 --json 输出可解析', parsed && typeof parsed === 'object' && Array.isArray(parsed.drift));
155
+ check('S7 --json 含 inSync = 8', parsed && parsed.inSync === 8, parsed ? JSON.stringify(parsed) : 'null');
156
+ }
157
+
158
+ // ============ 场景 8:孤儿薄适配(权威源无对应文件)→ adapterOrphans 列,apply 不删 ============
159
+ {
160
+ const fx = mkFixture();
161
+ // orphan.md 已在 mkFixture 写入
162
+ const r = runSyncHosts(fx, ['--diff']);
163
+ const out = r.stdout + r.stderr;
164
+ check('S8 孤儿薄适配 → adapterOrphans 列', /孤儿薄适配(/.test(out) && out.includes('opencode/commands/orphan.md'), out);
165
+ const r2 = runSyncHosts(fx, ['--apply']);
166
+ check('S8 apply 不删孤儿薄适配', fs.existsSync(path.join(fx, 'modules/hosts/opencode/commands/orphan.md')));
167
+ }
168
+
169
+ console.log(`\n合计: PASS ${pass} / FAIL ${failCount}`);
170
+ process.exit(failCount ? 1 : 0);
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,25 +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
- fs.copyFileSync(freshFile.abs, disk);
93
+ fs.mkdirSync(path.dirname(disk), { recursive: true }); // 父目录可能整目录缺失(如 localOnly 宿主目录被清),copyfile 不建目录
94
+ copyManaged(freshFile, disk, rel);
78
95
  restored.push(rel);
79
96
  managedNew.push({ rel, sha256: freshFile.sha });
80
97
  continue;
81
98
  }
82
99
  if (diskSha === ledgerSha) {
83
- if (freshFile.sha === ledgerSha) { unchanged++; managedNew.push({ rel, sha256: ledgerSha }); }
84
- 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 }); }
85
105
  continue;
86
106
  }
87
107
  // 本地已改
88
108
  if (freshFile.sha === diskSha) { unchanged++; managedNew.push({ rel, sha256: diskSha }); continue; } // 改动恰好等于新版
89
- 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 }); }
90
110
  else { skipped.push(rel); managedNew.push({ rel, sha256: ledgerSha }); } // 台账保持包侧基线:持续报告「本地已改」,直到 --force 或本地对齐新版
91
111
  }
92
112
 
93
- // 台账外的新文件:managed 类安装;owned 类只对「盘上缺失」的起步文档报告(已装的不动不报)
113
+ // 台账外的新文件:managed 类安装 / 收养;owned 类只对「盘上缺失」的起步文档报告(已装的不动不报)
94
114
  for (const [rel, freshFile] of fresh) {
95
115
  if (ledger.has(rel)) continue;
96
116
  if (isOwned(rel)) {
@@ -98,9 +118,18 @@ export function sync(args, pkgRoot) {
98
118
  continue;
99
119
  }
100
120
  const disk = path.join(target, rel);
101
- 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
+ }
102
131
  fs.mkdirSync(path.dirname(disk), { recursive: true });
103
- fs.copyFileSync(freshFile.abs, disk);
132
+ copyManaged(freshFile, disk, rel);
104
133
  added.push(rel);
105
134
  managedNew.push({ rel, sha256: freshFile.sha });
106
135
  }
@@ -109,24 +138,27 @@ export function sync(args, pkgRoot) {
109
138
  list('覆盖更新', updated);
110
139
  list('恢复缺失', restored);
111
140
  list('新增安装', added);
141
+ list('收养登记', adopted);
112
142
  list('本地已改,跳过', skipped);
113
143
  if (skipped.some((s) => !s.includes('未入台账'))) console.log(' ↑ --force 覆盖本地改动;git diff 自查差异;台账保持包侧基线,后续 sync 持续报告直至处理');
114
144
  list('包内已移除(文件保留在盘上,可手动删除)', removed);
115
145
  list('新增 owned 起步文档(项目自持,未自动安装)', newOwned);
116
- 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) {
117
147
  console.log(` 已是最新(${unchanged} 份 managed 无变化)`);
118
148
  } else {
119
149
  console.log(` 无变化 ${unchanged} 份`);
120
150
  }
121
151
 
122
152
  // owned 台账按盘面自愈:owned 归项目所有,哈希只是记账不是约束——手改后无须手工刷 kit.json
123
- // (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'));
124
156
  let ownedRefreshed = 0;
125
157
  if (Array.isArray(kit.owned)) {
126
158
  kit.owned = kit.owned.map((f) => {
127
159
  const p = path.join(target, f.rel);
128
160
  if (!fs.existsSync(p)) return f;
129
- const disk = sha256(fs.readFileSync(p));
161
+ const disk = ownedSha(p);
130
162
  if (disk === f.sha256) return f;
131
163
  ownedRefreshed++;
132
164
  return { ...f, sha256: disk };
@@ -139,14 +171,27 @@ export function sync(args, pkgRoot) {
139
171
  kit.version = pkg.version;
140
172
  fs.writeFileSync(kitPath, `${JSON.stringify(kit, null, 2)}\n`);
141
173
 
142
- // 生成器目标走锚点重写(不参与 sha 比对)
143
- const genIndex = runNode(target, '.agents/scripts/gen-workflow-index.mjs');
144
- if (genIndex.ok || fs.existsSync(path.join(target, '.agents/scripts/gen-workflow-index.mjs'))) {
145
- 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
+ }
146
189
  }
147
- const genBoard = runNode(target, '.agents/scripts/gen-wiki-board.mjs');
148
- if (genBoard.ok || fs.existsSync(path.join(target, '.agents/scripts/gen-wiki-board.mjs'))) {
149
- 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
+ }
150
195
  }
151
196
  } finally {
152
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';
@@ -242,5 +243,56 @@ else if (cmd === 'add-gate') addGate(rest, root);
242
243
  check('S12 有刷新报告', out.includes('owned 台账哈希按盘面刷新 1 份'), out);
243
244
  }
244
245
 
246
+ // ============ 场景 13:managed 连同父目录整目录缺失 → 恢复并重建父目录(恢复分支 mkdir,2026-09-25) ============
247
+ {
248
+ const fx = mkFixture(), t = mkTarget(fx);
249
+ fs.rmSync(path.join(t, '.zcode'), { recursive: true, force: true });
250
+ const r = runCmd('sync', fx, t);
251
+ const out = r.stdout + r.stderr;
252
+ check('S13 整目录缺失的 managed 被恢复(父目录重建)', R(path.join(t, '.zcode/h.txt')) === 'host v2 777\n', out);
253
+ check('S13 恢复有报告标记', out.includes('恢复缺失'), out);
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
+
245
297
  console.log(`\n合计: PASS ${pass} / FAIL ${failCount}`);
246
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);
@@ -19,7 +19,7 @@
19
19
 
20
20
  ### 子智能体
21
21
 
22
- 公共角色契约在 `.agents/roles/`(`implementer` / `independent-reviewer` / `ui-verifier`),各宿主经薄适配层(`.opencode/agents/`、`.trae/agents/`、`.zcode/agents/` 等)注册,宿主不支持子智能体时按命令 frontmatter 的 `fallback` 执行。子智能体不跨用户确认门、不替用户批准、不自行提交。
22
+ 公共角色契约在 `.agents/roles/`(`implementer` / `independent-reviewer` / `ui-verifier`),各宿主经薄适配层(`.opencode/agents/`、`.trae/agents/`、`.zcode/agents/` 等)注册,宿主不支持子智能体时按命令 frontmatter 的 `fallback` 执行。子智能体不跨用户确认门、不替用户批准、不自行提交。多工作包编排(自动加载):跑编排 / plan 执行多工作包时,读 `.agents/workflows/` 编排脚本按机制文档执行——stages 表(依赖分层 / 并行 / 重试 / gate)+ `steps/` 自定义步骤扩展点,见 `.agents/workflows/_TEMPLATE.md`。
23
23
 
24
24
  ### 门禁与提交
25
25
 
@@ -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');