@bevel-software/platform-core-backend 0.4.1 → 0.5.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 (304) hide show
  1. package/dist/core/core-ports.d.ts +15 -0
  2. package/dist/core/core-ports.d.ts.map +1 -1
  3. package/dist/core/core-ports.js.map +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +40 -6
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +17 -1
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +153 -29
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/core-config.d.ts +70 -21
  12. package/dist/core-config.d.ts.map +1 -1
  13. package/dist/core-config.js +134 -58
  14. package/dist/core-config.js.map +1 -1
  15. package/dist/modules/access/access-control.interface.d.ts +6 -0
  16. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  17. package/dist/modules/access/access-control.service.d.ts +74 -5
  18. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  19. package/dist/modules/access/access-control.service.js +116 -15
  20. package/dist/modules/access/access-control.service.js.map +1 -1
  21. package/dist/modules/access/access-declarations.d.ts +93 -0
  22. package/dist/modules/access/access-declarations.d.ts.map +1 -0
  23. package/dist/modules/access/access-declarations.js +168 -0
  24. package/dist/modules/access/access-declarations.js.map +1 -0
  25. package/dist/modules/access/access-mutation.service.js +4 -4
  26. package/dist/modules/access/access-mutation.service.js.map +1 -1
  27. package/dist/modules/access/access-splice.d.ts +16 -1
  28. package/dist/modules/access/access-splice.d.ts.map +1 -1
  29. package/dist/modules/access/access-splice.js +40 -21
  30. package/dist/modules/access/access-splice.js.map +1 -1
  31. package/dist/modules/access/access.routes.d.ts.map +1 -1
  32. package/dist/modules/access/access.routes.js +51 -0
  33. package/dist/modules/access/access.routes.js.map +1 -1
  34. package/dist/modules/access/creator-access.d.ts +8 -0
  35. package/dist/modules/access/creator-access.d.ts.map +1 -1
  36. package/dist/modules/access/creator-access.js +21 -2
  37. package/dist/modules/access/creator-access.js.map +1 -1
  38. package/dist/modules/admin/admin-access.service.d.ts +1 -1
  39. package/dist/modules/admin/admin-access.service.d.ts.map +1 -1
  40. package/dist/modules/admin/admin-access.service.js +3 -2
  41. package/dist/modules/admin/admin-access.service.js.map +1 -1
  42. package/dist/modules/auth/auth.routes.d.ts.map +1 -1
  43. package/dist/modules/auth/auth.routes.js +50 -1
  44. package/dist/modules/auth/auth.routes.js.map +1 -1
  45. package/dist/modules/auth/auth.service.d.ts +39 -10
  46. package/dist/modules/auth/auth.service.d.ts.map +1 -1
  47. package/dist/modules/auth/auth.service.js +38 -16
  48. package/dist/modules/auth/auth.service.js.map +1 -1
  49. package/dist/modules/database/core-schema.d.ts +124 -0
  50. package/dist/modules/database/core-schema.d.ts.map +1 -1
  51. package/dist/modules/database/core-schema.js +45 -0
  52. package/dist/modules/database/core-schema.js.map +1 -1
  53. package/dist/modules/groups/group-provision.service.d.ts +130 -0
  54. package/dist/modules/groups/group-provision.service.d.ts.map +1 -0
  55. package/dist/modules/groups/group-provision.service.js +288 -0
  56. package/dist/modules/groups/group-provision.service.js.map +1 -0
  57. package/dist/modules/groups/groups.contract.d.ts +106 -0
  58. package/dist/modules/groups/groups.contract.d.ts.map +1 -0
  59. package/dist/modules/groups/groups.contract.js +36 -0
  60. package/dist/modules/groups/groups.contract.js.map +1 -0
  61. package/dist/modules/groups/groups.routes.d.ts +42 -0
  62. package/dist/modules/groups/groups.routes.d.ts.map +1 -0
  63. package/dist/modules/groups/groups.routes.js +379 -0
  64. package/dist/modules/groups/groups.routes.js.map +1 -0
  65. package/dist/modules/groups/groups.service.d.ts +60 -0
  66. package/dist/modules/groups/groups.service.d.ts.map +1 -0
  67. package/dist/modules/groups/groups.service.js +172 -0
  68. package/dist/modules/groups/groups.service.js.map +1 -0
  69. package/dist/modules/groups/index.d.ts +7 -0
  70. package/dist/modules/groups/index.d.ts.map +1 -0
  71. package/dist/modules/groups/index.js +6 -0
  72. package/dist/modules/groups/index.js.map +1 -0
  73. package/dist/modules/groups/join-proposals.d.ts +53 -0
  74. package/dist/modules/groups/join-proposals.d.ts.map +1 -0
  75. package/dist/modules/groups/join-proposals.js +67 -0
  76. package/dist/modules/groups/join-proposals.js.map +1 -0
  77. package/dist/modules/groups/join-requests.service.d.ts +81 -0
  78. package/dist/modules/groups/join-requests.service.d.ts.map +1 -0
  79. package/dist/modules/groups/join-requests.service.js +135 -0
  80. package/dist/modules/groups/join-requests.service.js.map +1 -0
  81. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +20 -0
  82. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  83. package/dist/modules/secrets-vault/secrets-vault.routes.js +59 -11
  84. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  85. package/dist/modules/settings/deployment-settings.service.d.ts +129 -0
  86. package/dist/modules/settings/deployment-settings.service.d.ts.map +1 -0
  87. package/dist/modules/settings/deployment-settings.service.js +368 -0
  88. package/dist/modules/settings/deployment-settings.service.js.map +1 -0
  89. package/dist/modules/settings/setup.routes.d.ts +49 -0
  90. package/dist/modules/settings/setup.routes.d.ts.map +1 -0
  91. package/dist/modules/settings/setup.routes.js +292 -0
  92. package/dist/modules/settings/setup.routes.js.map +1 -0
  93. package/dist/modules/skills/index.d.ts +2 -1
  94. package/dist/modules/skills/index.d.ts.map +1 -1
  95. package/dist/modules/skills/index.js +1 -0
  96. package/dist/modules/skills/index.js.map +1 -1
  97. package/dist/modules/skills/pending-skills.service.d.ts +40 -0
  98. package/dist/modules/skills/pending-skills.service.d.ts.map +1 -0
  99. package/dist/modules/skills/pending-skills.service.js +147 -0
  100. package/dist/modules/skills/pending-skills.service.js.map +1 -0
  101. package/dist/modules/skills/skills.contract.d.ts +32 -3
  102. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  103. package/dist/modules/skills/skills.contract.js +1 -1
  104. package/dist/modules/skills/skills.routes.d.ts +3 -2
  105. package/dist/modules/skills/skills.routes.d.ts.map +1 -1
  106. package/dist/modules/skills/skills.routes.js +18 -1
  107. package/dist/modules/skills/skills.routes.js.map +1 -1
  108. package/dist/modules/skills/skills.service.d.ts +22 -0
  109. package/dist/modules/skills/skills.service.d.ts.map +1 -1
  110. package/dist/modules/skills/skills.service.js +24 -12
  111. package/dist/modules/skills/skills.service.js.map +1 -1
  112. package/dist/modules/tool-manuals/index.d.ts +1 -1
  113. package/dist/modules/tool-manuals/index.d.ts.map +1 -1
  114. package/dist/modules/tool-manuals/index.js.map +1 -1
  115. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +47 -2
  116. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  117. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  118. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +4 -0
  119. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  120. package/dist/modules/tool-manuals/tool-manuals.routes.js +23 -0
  121. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  122. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +3 -1
  123. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  124. package/dist/modules/tool-manuals/tool-manuals.service.js +84 -17
  125. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  126. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -1
  127. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -2
  128. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  129. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -1
  130. package/dist/modules/workflow/agent-tools/workflow.tools.js +4 -2
  131. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -1
  132. package/dist/modules/workflow/event-bus.d.ts +11 -0
  133. package/dist/modules/workflow/event-bus.d.ts.map +1 -1
  134. package/dist/modules/workflow/event-bus.js +22 -0
  135. package/dist/modules/workflow/event-bus.js.map +1 -1
  136. package/dist/modules/workflow/file-lock.service.d.ts +9 -0
  137. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  138. package/dist/modules/workflow/file-lock.service.js +16 -0
  139. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  140. package/dist/modules/workflow/git/branch-name.d.ts +1 -1
  141. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -1
  142. package/dist/modules/workflow/git/branch-name.js +1 -1
  143. package/dist/modules/workflow/git/branch-name.js.map +1 -1
  144. package/dist/modules/workflow/git/git.service.d.ts +31 -2
  145. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  146. package/dist/modules/workflow/git/git.service.js +144 -52
  147. package/dist/modules/workflow/git/git.service.js.map +1 -1
  148. package/dist/modules/workflow/git/pull-request.service.d.ts +12 -6
  149. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -1
  150. package/dist/modules/workflow/git/pull-request.service.js +19 -9
  151. package/dist/modules/workflow/git/pull-request.service.js.map +1 -1
  152. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -1
  153. package/dist/modules/workflow/review-workflow/review-workflow.service.js +12 -3
  154. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -1
  155. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  156. package/dist/modules/workflow/workflow.routes.js +95 -14
  157. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  158. package/dist/modules/workflow/workflow.service.d.ts +92 -3
  159. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  160. package/dist/modules/workflow/workflow.service.js +381 -108
  161. package/dist/modules/workflow/workflow.service.js.map +1 -1
  162. package/dist/modules/workspace/bevel-ignore.d.ts +5 -0
  163. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -1
  164. package/dist/modules/workspace/bevel-ignore.js +5 -1
  165. package/dist/modules/workspace/bevel-ignore.js.map +1 -1
  166. package/dist/modules/workspace/kb-seed.interface.d.ts +1 -1
  167. package/dist/modules/workspace/kb-seed.service.d.ts +60 -5
  168. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  169. package/dist/modules/workspace/kb-seed.service.js +228 -51
  170. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  171. package/dist/modules/workspace/workspace.routes.d.ts +2 -1
  172. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  173. package/dist/modules/workspace/workspace.routes.js +51 -18
  174. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  175. package/dist/modules/workspace/workspace.service.d.ts +16 -4
  176. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  177. package/dist/modules/workspace/workspace.service.js +36 -9
  178. package/dist/modules/workspace/workspace.service.js.map +1 -1
  179. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  180. package/dist/modules/workspace/workspace.tools.js +8 -3
  181. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  182. package/dist/shared/kb-layout.test.js +2 -2
  183. package/dist/test-setup.d.ts +2 -0
  184. package/dist/test-setup.d.ts.map +1 -0
  185. package/dist/test-setup.js +18 -0
  186. package/dist/test-setup.js.map +1 -0
  187. package/kb-template/.bevelignore +6 -2
  188. package/kb-template/AGENTS.md +272 -0
  189. package/kb-template/KnowledgeBase/How to get started.md +65 -0
  190. package/kb-template/access.md +36 -36
  191. package/migrations/0002_user_onboarding_done.sql +1 -0
  192. package/migrations/0003_deployment_settings.sql +9 -0
  193. package/migrations/meta/0001_snapshot.json +1486 -0
  194. package/migrations/meta/0002_snapshot.json +1493 -0
  195. package/migrations/meta/0003_snapshot.json +1552 -0
  196. package/migrations/meta/_journal.json +15 -1
  197. package/package.json +3 -3
  198. package/src/__tests__/branch-model.test.ts +79 -0
  199. package/src/__tests__/core-config.admin.test.ts +114 -0
  200. package/src/__tests__/database-url.test.ts +70 -0
  201. package/src/core/core-ports.ts +15 -0
  202. package/src/core/create-core-server.ts +55 -6
  203. package/src/core/create-core-services.ts +201 -36
  204. package/src/core-config.ts +139 -59
  205. package/src/modules/access/__tests__/access-declarations.test.ts +195 -0
  206. package/src/modules/access/__tests__/access-md-format.test.ts +191 -0
  207. package/src/modules/access/__tests__/access-splice.test.ts +21 -0
  208. package/src/modules/access/__tests__/access.routes.overrides.test.ts +214 -0
  209. package/src/modules/access/access-control.interface.ts +11 -0
  210. package/src/modules/access/access-control.service.ts +113 -15
  211. package/src/modules/access/access-declarations.ts +251 -0
  212. package/src/modules/access/access-mutation.service.ts +4 -4
  213. package/src/modules/access/access-splice.ts +64 -21
  214. package/src/modules/access/access.routes.ts +61 -0
  215. package/src/modules/access/creator-access.ts +22 -2
  216. package/src/modules/admin/admin-access.service.ts +4 -3
  217. package/src/modules/auth/__tests__/auth.service.test.ts +57 -0
  218. package/src/modules/auth/__tests__/onboarding-done.route.test.ts +147 -0
  219. package/src/modules/auth/auth.routes.ts +50 -1
  220. package/src/modules/auth/auth.service.ts +55 -17
  221. package/src/modules/database/core-schema.ts +46 -0
  222. package/src/modules/groups/__tests__/group-index.service.test.ts +225 -0
  223. package/src/modules/groups/__tests__/group-provision.service.test.ts +247 -0
  224. package/src/modules/groups/__tests__/groups.routes.test.ts +493 -0
  225. package/src/modules/groups/__tests__/join-proposals.test.ts +101 -0
  226. package/src/modules/groups/__tests__/join-requests.service.test.ts +175 -0
  227. package/src/modules/groups/group-provision.service.ts +328 -0
  228. package/src/modules/groups/groups.contract.ts +107 -0
  229. package/src/modules/groups/groups.routes.ts +433 -0
  230. package/src/modules/groups/groups.service.ts +193 -0
  231. package/src/modules/groups/index.ts +17 -0
  232. package/src/modules/groups/join-proposals.ts +114 -0
  233. package/src/modules/groups/join-requests.service.ts +185 -0
  234. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +283 -0
  235. package/src/modules/secrets-vault/secrets-vault.routes.ts +68 -12
  236. package/src/modules/settings/__tests__/deployment-settings.service.test.ts +256 -0
  237. package/src/modules/settings/__tests__/setup.routes.test.ts +201 -0
  238. package/src/modules/settings/deployment-settings.service.ts +419 -0
  239. package/src/modules/settings/setup.routes.ts +317 -0
  240. package/src/modules/skills/__tests__/pending-skills.service.test.ts +240 -0
  241. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  242. package/src/modules/skills/index.ts +3 -0
  243. package/src/modules/skills/pending-skills.service.ts +168 -0
  244. package/src/modules/skills/skills.contract.ts +34 -3
  245. package/src/modules/skills/skills.routes.ts +23 -2
  246. package/src/modules/skills/skills.service.ts +24 -16
  247. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +262 -0
  248. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  249. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +61 -23
  250. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -156
  251. package/src/modules/tool-manuals/index.ts +2 -0
  252. package/src/modules/tool-manuals/tool-manuals.contract.ts +50 -2
  253. package/src/modules/tool-manuals/tool-manuals.routes.ts +21 -0
  254. package/src/modules/tool-manuals/tool-manuals.service.ts +86 -17
  255. package/src/modules/tool-manuals/tool-manuals.tools.ts +206 -202
  256. package/src/modules/workflow/__tests__/event-bus.test.ts +35 -0
  257. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +251 -5
  258. package/src/modules/workflow/agent-tools/workflow.tools.ts +4 -2
  259. package/src/modules/workflow/event-bus.ts +25 -0
  260. package/src/modules/workflow/file-lock.service.ts +17 -0
  261. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +9 -49
  262. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +101 -0
  263. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +219 -211
  264. package/src/modules/workflow/git/__tests__/git.service.fileContentsAtCommit.test.ts +132 -0
  265. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +4 -2
  266. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +275 -272
  267. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +5 -0
  268. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +44 -0
  269. package/src/modules/workflow/git/branch-name.ts +5 -1
  270. package/src/modules/workflow/git/git.service.ts +159 -62
  271. package/src/modules/workflow/git/pull-request.service.ts +22 -11
  272. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +4 -1
  273. package/src/modules/workflow/review-workflow/review-workflow.service.ts +12 -3
  274. package/src/modules/workflow/workflow.routes.ts +91 -13
  275. package/src/modules/workflow/workflow.service.ts +353 -13
  276. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +255 -24
  277. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -233
  278. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -176
  279. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -303
  280. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -276
  281. package/src/modules/workspace/__tests__/workspace.tools.test.ts +14 -6
  282. package/src/modules/workspace/bevel-ignore.ts +66 -62
  283. package/src/modules/workspace/kb-seed.interface.ts +1 -1
  284. package/src/modules/workspace/kb-seed.service.ts +247 -53
  285. package/src/modules/workspace/workspace.routes.ts +54 -18
  286. package/src/modules/workspace/workspace.service.ts +45 -7
  287. package/src/modules/workspace/workspace.tools.ts +922 -917
  288. package/src/shared/__tests__/frontmatter-id.test.ts +53 -0
  289. package/src/shared/__tests__/join-request.test.ts +96 -0
  290. package/src/shared/__tests__/kb-layout.group.test.ts +45 -0
  291. package/src/shared/kb-layout.test.ts +2 -2
  292. package/src/test-setup.ts +18 -0
  293. package/kb-template/Agents/README.md +0 -38
  294. package/kb-template/CLAUDE.md +0 -419
  295. package/kb-template/Data/.gitkeep +0 -0
  296. package/kb-template/Data/README.md +0 -39
  297. package/kb-template/KnowledgeBase/README.md +0 -37
  298. package/kb-template/Pipelines/.gitkeep +0 -0
  299. package/kb-template/Pipelines/README.md +0 -38
  300. package/kb-template/Skills/.gitkeep +0 -0
  301. package/kb-template/Skills/README.md +0 -32
  302. package/kb-template/Tools/.gitkeep +0 -0
  303. package/kb-template/Tools/README.md +0 -42
  304. /package/kb-template/{Agents → Groups}/.gitkeep +0 -0
@@ -338,25 +338,33 @@ describe('workspace file primitives', () => {
338
338
  expect(res.stdout.trim()).toBe('kb-seed');
339
339
  });
340
340
 
341
- it('execute_command does not leak GIT_DIR into non-git commands', async () => {
341
+ // POSIX-only probes: `$GIT_DIR` expansion and `>/dev/null` are `sh`
342
+ // semantics, and `shell: true` on Windows runs cmd.exe, where the probe
343
+ // itself (not the scoping under test) is meaningless. CI runs Linux.
344
+ it.skipIf(process.platform === 'win32')('execute_command does not leak GIT_DIR into non-git commands', async () => {
342
345
  const base = await start();
343
346
  // The KB-clone GIT_DIR/GIT_WORK_TREE override is scoped to bare `git …`
344
347
  // only; a non-git command (e.g. npm/pip that shells git internally) must
345
348
  // NOT inherit it, or the nested git child would target the KB clone.
346
- const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'echo "GIT_DIR=[$GIT_DIR]"' })).json()) as { stdout: string; stderr: string; exitCode: number };
349
+ // Probe via `node -e` rather than shell expansion — the override lives in
350
+ // the spawn env, and `$GIT_DIR` syntax doesn't expand under cmd.exe.
351
+ const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: `node -e "console.log('GIT_DIR=['+(process.env.GIT_DIR||'')+']')"` })).json()) as { stdout: string; stderr: string; exitCode: number };
347
352
  expect(res.exitCode).toBe(0);
348
353
  expect(res.stdout.trim()).toBe('GIT_DIR=[]');
349
354
  });
350
355
 
351
- it('execute_command does not leak GIT_DIR into a chained step after git', async () => {
356
+ it.skipIf(process.platform === 'win32')('execute_command does not leak GIT_DIR into a chained step after git', async () => {
352
357
  const base = await start();
353
358
  // Runs under `shell: true`, so a command that merely STARTS with git but
354
359
  // chains another step must not export the KB git env to the whole shell —
355
360
  // otherwise `git … && npm ci` would leak the KB repo context into the npm/pip
356
- // git subprocess. `git --version` needs no repo, so exit stays 0.
357
- const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: 'git --version >/dev/null && echo "leak=[$GIT_DIR]"' })).json()) as { stdout: string; stderr: string; exitCode: number };
361
+ // git subprocess. `git --version` needs no repo, so exit stays 0. The
362
+ // version line lands on stdout (`>/dev/null` isn't portable to cmd.exe),
363
+ // so the leak probe is asserted on the LAST line.
364
+ const res = (await (await post(`${base}/api/agent/tools/execute_command`, { branch: 'main', command: `git --version && node -e "console.log('leak=['+(process.env.GIT_DIR||'')+']')"` })).json()) as { stdout: string; stderr: string; exitCode: number };
358
365
  expect(res.exitCode).toBe(0);
359
- expect(res.stdout.trim()).toBe('leak=[]');
366
+ const lines = res.stdout.trim().split(/\r?\n/);
367
+ expect(lines[lines.length - 1].trim()).toBe('leak=[]');
360
368
  });
361
369
 
362
370
  it('read scope refuses write tools (403) but allows reads', async () => {
@@ -1,62 +1,66 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import ignore, { type Ignore } from 'ignore';
4
-
5
- const IGNORE_FILENAME = '.bevelignore';
6
-
7
- /** One .bevelignore file's rules, scoped to the directory it lives in. */
8
- interface IgnoreLayer {
9
- /** Absolute path of the directory where the .bevelignore lives. */
10
- readonly root: string;
11
- readonly matcher: Ignore;
12
- }
13
-
14
- /**
15
- * A hierarchical stack of `.bevelignore` rule sets.
16
- *
17
- * Each `.bevelignore` file applies to paths beneath the directory it lives in,
18
- * using standard gitignore syntax (negations, `**`, directory-only patterns, anchoring).
19
- * Deeper files combine with — and can override — rules from shallower files,
20
- * mirroring how git layers `.gitignore` files.
21
- */
22
- export class BevelIgnoreStack {
23
- private constructor(private readonly layers: readonly IgnoreLayer[]) {}
24
-
25
- static empty(): BevelIgnoreStack {
26
- return new BevelIgnoreStack([]);
27
- }
28
-
29
- /**
30
- * Return a new stack that additionally includes a `.bevelignore` from `dir`
31
- * (if present). If no `.bevelignore` exists in `dir`, returns this stack unchanged.
32
- */
33
- async extendedWith(dir: string): Promise<BevelIgnoreStack> {
34
- const file = path.join(dir, IGNORE_FILENAME);
35
- let contents: string;
36
- try {
37
- contents = await fs.readFile(file, 'utf-8');
38
- } catch {
39
- return this;
40
- }
41
- const matcher = ignore().add(contents);
42
- return new BevelIgnoreStack([...this.layers, { root: dir, matcher }]);
43
- }
44
-
45
- /**
46
- * Whether the given entry should be hidden from the file tree.
47
- * @param absolutePath absolute path of the entry
48
- * @param isDirectory needed because gitignore patterns ending in `/` match directories only
49
- */
50
- isIgnored(absolutePath: string, isDirectory: boolean): boolean {
51
- for (const layer of this.layers) {
52
- const rel = path.relative(layer.root, absolutePath).replace(/\\/g, '/');
53
- // Skip entries that aren't under this layer's root (shouldn't happen in normal
54
- // recursion, but guards against absolute-path quirks on Windows).
55
- if (!rel || rel.startsWith('..')) continue;
56
- // Append trailing slash for directories so `foo/`-style rules match correctly.
57
- const probe = isDirectory ? `${rel}/` : rel;
58
- if (layer.matcher.ignores(probe)) return true;
59
- }
60
- return false;
61
- }
62
- }
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import ignore, { type Ignore } from 'ignore';
4
+
5
+ /**
6
+ * The ignore file's own name. Exported because the file-tree filter hides it
7
+ * from non-admins, and both places must agree on the spelling.
8
+ */
9
+ export const IGNORE_FILENAME = '.bevelignore';
10
+
11
+ /** One .bevelignore file's rules, scoped to the directory it lives in. */
12
+ interface IgnoreLayer {
13
+ /** Absolute path of the directory where the .bevelignore lives. */
14
+ readonly root: string;
15
+ readonly matcher: Ignore;
16
+ }
17
+
18
+ /**
19
+ * A hierarchical stack of `.bevelignore` rule sets.
20
+ *
21
+ * Each `.bevelignore` file applies to paths beneath the directory it lives in,
22
+ * using standard gitignore syntax (negations, `**`, directory-only patterns, anchoring).
23
+ * Deeper files combine with — and can override — rules from shallower files,
24
+ * mirroring how git layers `.gitignore` files.
25
+ */
26
+ export class BevelIgnoreStack {
27
+ private constructor(private readonly layers: readonly IgnoreLayer[]) {}
28
+
29
+ static empty(): BevelIgnoreStack {
30
+ return new BevelIgnoreStack([]);
31
+ }
32
+
33
+ /**
34
+ * Return a new stack that additionally includes a `.bevelignore` from `dir`
35
+ * (if present). If no `.bevelignore` exists in `dir`, returns this stack unchanged.
36
+ */
37
+ async extendedWith(dir: string): Promise<BevelIgnoreStack> {
38
+ const file = path.join(dir, IGNORE_FILENAME);
39
+ let contents: string;
40
+ try {
41
+ contents = await fs.readFile(file, 'utf-8');
42
+ } catch {
43
+ return this;
44
+ }
45
+ const matcher = ignore().add(contents);
46
+ return new BevelIgnoreStack([...this.layers, { root: dir, matcher }]);
47
+ }
48
+
49
+ /**
50
+ * Whether the given entry should be hidden from the file tree.
51
+ * @param absolutePath absolute path of the entry
52
+ * @param isDirectory needed because gitignore patterns ending in `/` match directories only
53
+ */
54
+ isIgnored(absolutePath: string, isDirectory: boolean): boolean {
55
+ for (const layer of this.layers) {
56
+ const rel = path.relative(layer.root, absolutePath).replace(/\\/g, '/');
57
+ // Skip entries that aren't under this layer's root (shouldn't happen in normal
58
+ // recursion, but guards against absolute-path quirks on Windows).
59
+ if (!rel || rel.startsWith('..')) continue;
60
+ // Append trailing slash for directories so `foo/`-style rules match correctly.
61
+ const probe = isDirectory ? `${rel}/` : rel;
62
+ if (layer.matcher.ignores(probe)) return true;
63
+ }
64
+ return false;
65
+ }
66
+ }
@@ -15,7 +15,7 @@ export interface IKbSeedService {
15
15
  * work runs at most once per process.
16
16
  *
17
17
  * - **Empty remote** → full seed: all template files (incl. the sample
18
- * ontology) committed to every protected branch. Requires `SEED_ADMIN_EMAILS`
18
+ * ontology) committed to every protected branch. Uses `ADMIN_EMAIL`
19
19
  * (a repo with no Admin is unusable), otherwise throws.
20
20
  * - **Existing remote** → create only the protected branches that don't exist
21
21
  * yet, off an existing one. No file changes here — per-branch base files are
@@ -3,14 +3,8 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
- import {
7
- AGENTS_DIR,
8
- DATA_DIR,
9
- KNOWLEDGE_BASE_DIR,
10
- PIPELINES_DIR,
11
- SKILLS_DIR,
12
- TOOLS_DIR,
13
- } from '@bevel-software/platform-shared';
6
+ import { KNOWLEDGE_BASE_DIR, GROUPS_DIR } from '@bevel-software/platform-shared';
7
+ import { IGNORE_FILENAME } from './bevel-ignore.js';
14
8
  import type { IKbSeedService } from './kb-seed.interface.js';
15
9
 
16
10
  const execFileAsync = promisify(execFile);
@@ -29,24 +23,57 @@ const BOT_EMAIL = 'bevel-workflow@bevel.software';
29
23
  *
30
24
  * Two kinds:
31
25
  * - {@link REQUIRED_FILES}: repo-root files added when the file is missing.
32
- * - {@link REQUIRED_DIRS}: well-known root dirs the app expects to exist; when a
33
- * dir is entirely absent it's created by adding its `<dir>/.gitkeep`. Keyed on
34
- * the *directory's* existence, not the `.gitkeep` file — so a branch that
35
- * already has content under `KnowledgeBase/` never gets a pointless placeholder.
26
+ * - {@link CORE_REQUIRED_DIRS} (plus any `extraDirs`): well-known root dirs the
27
+ * app expects to exist; when a dir is entirely absent it's created by adding
28
+ * its `<dir>/.gitkeep`. Keyed on the *directory's* existence, not the
29
+ * `.gitkeep` file — so a branch that already has content under
30
+ * `KnowledgeBase/` never gets a pointless placeholder.
36
31
  *
37
32
  * `roles.yaml` is in neither, and is not part of the template at all: it is
38
- * generated from `SEED_ADMIN_EMAILS` (see {@link renderRolesYaml}) and written
33
+ * generated from `ADMIN_EMAIL` (see {@link renderRolesYaml}) and written
39
34
  * directly, so a repo can't be seeded with a stale hard-coded Admin list.
40
35
  */
41
- const REQUIRED_FILES: readonly string[] = ['access.md', 'CLAUDE.md', '.bevelignore', '.gitignore'];
42
- const REQUIRED_DIRS: readonly string[] = [
43
- KNOWLEDGE_BASE_DIR,
44
- DATA_DIR,
45
- AGENTS_DIR,
46
- PIPELINES_DIR,
47
- SKILLS_DIR,
48
- TOOLS_DIR,
49
- ];
36
+ const REQUIRED_FILES: readonly string[] = ['access.md', 'AGENTS.md', '.bevelignore', '.gitignore'];
37
+
38
+ /**
39
+ * The two roots CORE gives a knowledge base: the ontologies, and the groups
40
+ * that hold skills and tools.
41
+ *
42
+ * `Data/`, `Agents/` and `Pipelines/` are deliberately absent. They scaffold
43
+ * the agentic execution layer, which is not part of this platform — a core
44
+ * deployment that created them would be handing every operator three empty
45
+ * folders it has no feature to fill. A distribution that DOES own that layer
46
+ * passes them as `extraDirs` (and ships a template carrying their READMEs);
47
+ * the names stay reserved in `kb-layout.ts` either way, so a KB that has them
48
+ * still renders them as roots rather than folding them into Knowledge.
49
+ */
50
+ export const CORE_REQUIRED_DIRS: readonly string[] = [KNOWLEDGE_BASE_DIR, GROUPS_DIR];
51
+
52
+ /**
53
+ * A reserved root must be ONE path segment — `Data`, not `Data/x`, `../x` or
54
+ * `/x`. The name is joined onto the repo root, so anything else writes outside
55
+ * the repo being seeded.
56
+ *
57
+ * Deliberately NOT a check against the reserved-root set in `kb-layout.ts`:
58
+ * `Data`, `Agents` and `Pipelines` are all in that set, and they are precisely
59
+ * what a distribution passes here. Being reserved is what makes a name worth
60
+ * claiming — the file tree renders it as its own root instead of folding it
61
+ * into Knowledge — so rejecting reserved names would reject the only real use.
62
+ */
63
+ function assertRootSegment(dir: string): void {
64
+ if (
65
+ !dir ||
66
+ dir === '.' ||
67
+ dir === '..' ||
68
+ dir.includes('/') ||
69
+ dir.includes('\\') ||
70
+ path.isAbsolute(dir)
71
+ ) {
72
+ throw new Error(
73
+ `Reserved KB root must be a single path segment (no separators, no ".."); got "${dir}"`,
74
+ );
75
+ }
76
+ }
50
77
 
51
78
  /** Redact the token from any string that might surface in a log or error. */
52
79
  function redact(s: string): string {
@@ -62,7 +89,7 @@ function renderRolesYaml(adminEmails: readonly string[]): string {
62
89
  '# Role names are case- and whitespace-insensitive. The `Admin` role is special:\n' +
63
90
  '# only Admins may edit this file, and at least one Admin must always exist.\n' +
64
91
  '#\n' +
65
- '# Generated by the Bevel platform at KB-seed time from SEED_ADMIN_EMAILS.\n' +
92
+ '# Generated by the Bevel platform at KB-seed time from ADMIN_EMAIL.\n' +
66
93
  'roles:\n' +
67
94
  ' Admin:\n' +
68
95
  `${lines}\n`
@@ -96,15 +123,56 @@ export class KbSeedService implements IKbSeedService {
96
123
  * base when creating a missing protected branch.
97
124
  * @param seedAdminEmails Admins written into the generated `roles.yaml`.
98
125
  * @param gitUsername Basic-auth username for git-over-HTTPS (provider-specific).
126
+ * @param extraDirs Additional root folders this distribution reserves,
127
+ * on top of {@link CORE_REQUIRED_DIRS}. Their
128
+ * `.gitkeep` is written directly rather than copied,
129
+ * so a distribution can claim a root without also
130
+ * shipping a template entry for it.
99
131
  */
100
132
  constructor(
101
- private readonly kbRepoUrl: string,
133
+ kbRepoUrl: string | (() => string),
102
134
  private readonly kbTemplateDir: string,
103
- private readonly protectedBranches: readonly string[],
104
- private readonly defaultBranch: string,
135
+ protectedBranches: readonly string[] | (() => readonly string[]),
136
+ defaultBranch: string | (() => string),
105
137
  private readonly seedAdminEmails: readonly string[],
106
- private readonly gitUsername: string = 'x-access-token',
107
- ) {}
138
+ gitUsername: string | (() => string) = 'x-access-token',
139
+ extraDirs: readonly string[] = [],
140
+ ) {
141
+ // A getter is read per-operation, so a remote supplied through the setup
142
+ // screen is seeded against without restarting; a string is still accepted.
143
+ this.kbRepoUrl = typeof kbRepoUrl === 'function' ? kbRepoUrl : () => kbRepoUrl;
144
+ // Read when seeding, not at construction: the branch model can be supplied
145
+ // through the setup screen, which happens after this object exists.
146
+ this.protectedBranches =
147
+ typeof protectedBranches === 'function' ? protectedBranches : () => protectedBranches;
148
+ this.defaultBranch = typeof defaultBranch === 'function' ? defaultBranch : () => defaultBranch;
149
+ this.gitUsername = typeof gitUsername === 'function' ? gitUsername : () => gitUsername;
150
+ // NOT a getter, unlike its neighbours above: the reserved roots are named
151
+ // by the composition root in code, not collected on the setup screen, so
152
+ // there is nothing to re-read. Validated once, here — every entry is joined
153
+ // onto the repo root and onto `<dir>/.gitkeep`, so a separator or a `..`
154
+ // would write outside the repo being seeded, and a bad value should fail at
155
+ // boot beside the rest of the wiring rather than part-way through seeding
156
+ // somebody's knowledge base. Same contract as `KB_DIR_NAME` in `CoreConfig`.
157
+ for (const dir of extraDirs) {
158
+ assertRootSegment(dir);
159
+ // A root named after a required FILE is a typo with a silent outcome:
160
+ // the file is laid down first in both seed paths, so `ensureRequiredDirs`
161
+ // finds the path taken and skips it, and the directory the caller asked
162
+ // for never appears with nothing said about why.
163
+ if (REQUIRED_FILES.includes(dir)) {
164
+ throw new Error(`Reserved KB root "${dir}" collides with a required file of the same name`);
165
+ }
166
+ }
167
+ this.requiredDirs = [...CORE_REQUIRED_DIRS, ...extraDirs];
168
+ }
169
+
170
+ private readonly kbRepoUrl: () => string;
171
+ private readonly protectedBranches: () => readonly string[];
172
+ private readonly defaultBranch: () => string;
173
+ private readonly gitUsername: () => string;
174
+ /** Root folders this deployment guarantees — core's two plus any extras. */
175
+ private readonly requiredDirs: readonly string[];
108
176
 
109
177
  ensureRemoteSeeded(): Promise<void> {
110
178
  // Cache the promise, not just a boolean, so concurrent first-callers share
@@ -125,9 +193,11 @@ export class KbSeedService implements IKbSeedService {
125
193
  if (heads.size === 0) {
126
194
  if (this.seedAdminEmails.length === 0) {
127
195
  throw new Error(
128
- `KB remote ${redact(this.kbRepoUrl)} is empty and cannot be seeded: ` +
129
- 'set SEED_ADMIN_EMAILS to the initial Admin email(s). A seeded KB with ' +
130
- 'no Admin is unusable (access resolution requires at least one Admin).',
196
+ `KB remote ${redact(this.kbRepoUrl())} is empty and cannot be seeded: ` +
197
+ 'no initial Admin was supplied. A seeded KB with no Admin is unusable ' +
198
+ '(access resolution requires at least one Admin). Unreachable in ' +
199
+ 'normal operation — ADMIN_EMAIL is required at boot — so this guards ' +
200
+ 'a caller that constructed the seeder with an empty list.',
131
201
  );
132
202
  }
133
203
  await this.seedEmptyRemote();
@@ -138,7 +208,7 @@ export class KbSeedService implements IKbSeedService {
138
208
  // later `clone -b <branch>` succeeds. This creates the branch pointer only;
139
209
  // per-branch file top-up happens in `topUpWorkspace` when the branch loads.
140
210
  const base = this.pickBase(heads);
141
- for (const branch of this.protectedBranches) {
211
+ for (const branch of this.protectedBranches()) {
142
212
  if (!heads.has(branch)) {
143
213
  await this.createBranchOnRemote(branch, base);
144
214
  }
@@ -155,21 +225,42 @@ export class KbSeedService implements IKbSeedService {
155
225
  * stays consistent with origin, and the top-up is retried on a future clone.
156
226
  */
157
227
  async topUpWorkspace(repoDir: string, branch: string): Promise<void> {
228
+ // Scaffolding lands on PROTECTED branches only. A draft or suggestions
229
+ // branch is somebody's change-in-waiting, and a seeder commit there
230
+ // surfaces as noise in their change request's diff against the default
231
+ // branch (a stray scaffolding file riding along in a skill proposal was
232
+ // exactly this). Whatever the protected branches are missing, they get
233
+ // when THEY load — and drafts fork from them.
234
+ if (!this.protectedBranches().includes(branch)) return;
158
235
  try {
159
236
  const added: string[] = [];
160
237
  for (const rel of REQUIRED_FILES) {
161
238
  if (!(await this.exists(path.join(repoDir, rel)))) {
162
239
  await this.copyTemplateFile(rel, repoDir);
163
240
  added.push(rel);
241
+ // Adding AGENTS.md to a knowledge base seeded before the rename
242
+ // leaves it VISIBLE: that repo's `.bevelignore` lists CLAUDE.md and
243
+ // knows nothing of the new name, so the conventions doc starts
244
+ // showing up in the file tree and the agent view. We created the
245
+ // mismatch by adding the file, so we close it here.
246
+ if (rel === 'AGENTS.md') added.push(...(await this.mergeIgnorePattern(repoDir, rel)));
164
247
  }
165
248
  }
166
- for (const rootDir of REQUIRED_DIRS) {
167
- if (!(await this.exists(path.join(repoDir, rootDir)))) {
168
- const keep = `${rootDir}/.gitkeep`;
169
- await this.copyTemplateFile(keep, repoDir);
170
- added.push(keep);
171
- }
249
+ // AGENTS.md is MANAGED, not merely seeded: the platform owns its
250
+ // content, and a stale copy is replaced with the packaged template's on
251
+ // every top-up (each fresh clone of a protected branch — in practice,
252
+ // every server restart). The file's own header says so, which is what
253
+ // makes overwriting edits a stated contract instead of a surprise.
254
+ let agentsRefreshed = false;
255
+ if (
256
+ !added.includes('AGENTS.md') &&
257
+ (await this.templateDiffers(repoDir, 'AGENTS.md'))
258
+ ) {
259
+ await this.copyTemplateFile('AGENTS.md', repoDir);
260
+ added.push('AGENTS.md');
261
+ agentsRefreshed = true;
172
262
  }
263
+ added.push(...(await this.ensureRequiredDirs(repoDir)));
173
264
  if (!(await this.exists(path.join(repoDir, 'roles.yaml')))) {
174
265
  if (this.seedAdminEmails.length > 0) {
175
266
  await fs.writeFile(
@@ -180,9 +271,9 @@ export class KbSeedService implements IKbSeedService {
180
271
  added.push('roles.yaml');
181
272
  } else {
182
273
  console.warn(
183
- `[kb-seed] Branch "${branch}" is missing roles.yaml but SEED_ADMIN_EMAILS ` +
184
- 'is unset — leaving it absent. Access resolution will fail until an ' +
185
- 'Admin roles.yaml exists; set SEED_ADMIN_EMAILS or add roles.yaml manually.',
274
+ `[kb-seed] Branch "${branch}" is missing roles.yaml and ADMIN_EMAIL is ` +
275
+ 'unset — leaving it absent. Access resolution will fail until an ' +
276
+ 'Admin roles.yaml exists; set ADMIN_EMAIL or add roles.yaml manually.',
186
277
  );
187
278
  }
188
279
  }
@@ -194,7 +285,11 @@ export class KbSeedService implements IKbSeedService {
194
285
  // values, so this is a harmless no-op there).
195
286
  await this.stampIdentity(repoDir);
196
287
  await this.git(repoDir, ['add', '--', ...added]);
197
- await this.git(repoDir, ['commit', '-m', `Add missing KB scaffolding: ${added.join(', ')}`]);
288
+ const message =
289
+ agentsRefreshed && added.length === 1
290
+ ? 'Update AGENTS.md to the current platform template'
291
+ : `Add missing KB scaffolding: ${added.join(', ')}`;
292
+ await this.git(repoDir, ['commit', '-m', message]);
198
293
  try {
199
294
  await this.git(repoDir, ['push', 'origin', `HEAD:refs/heads/${branch}`]);
200
295
  console.log(`[kb-seed] Topped up "${branch}" with: ${added.join(', ')}`);
@@ -220,8 +315,8 @@ export class KbSeedService implements IKbSeedService {
220
315
 
221
316
  /** Prefer the default branch as a base, else the first protected branch present, else any head. */
222
317
  private pickBase(heads: Set<string>): string {
223
- if (heads.has(this.defaultBranch)) return this.defaultBranch;
224
- const protectedPresent = this.protectedBranches.find((b) => heads.has(b));
318
+ if (heads.has(this.defaultBranch())) return this.defaultBranch();
319
+ const protectedPresent = this.protectedBranches().find((b) => heads.has(b));
225
320
  if (protectedPresent) return protectedPresent;
226
321
  // Deterministic pick so repeated runs behave identically.
227
322
  return [...heads].sort()[0];
@@ -236,7 +331,7 @@ export class KbSeedService implements IKbSeedService {
236
331
  // Username is provider-specific; the token is always the Basic-auth password.
237
332
  args.push(
238
333
  '-c',
239
- `credential.helper=!f() { echo "username=${this.gitUsername}"; echo "password=$GITHUB_TOKEN"; }; f`,
334
+ `credential.helper=!f() { echo "username=${this.gitUsername()}"; echo "password=$GITHUB_TOKEN"; }; f`,
240
335
  );
241
336
  }
242
337
  return args;
@@ -260,7 +355,7 @@ export class KbSeedService implements IKbSeedService {
260
355
 
261
356
  /** Set of branch names on the remote (empty ⇒ uninitialised repo). */
262
357
  private async lsRemoteHeads(): Promise<Set<string>> {
263
- const out = await this.git(null, [...this.credArgs(), 'ls-remote', '--heads', this.kbRepoUrl]);
358
+ const out = await this.git(null, [...this.credArgs(), 'ls-remote', '--heads', this.kbRepoUrl()]);
264
359
  const heads = new Set<string>();
265
360
  for (const line of out.split('\n')) {
266
361
  const m = line.match(/\srefs\/heads\/(.+)$/);
@@ -302,6 +397,20 @@ export class KbSeedService implements IKbSeedService {
302
397
  }
303
398
 
304
399
  /** Copy one template file (by repo-relative path) into `dest`, creating parents. */
400
+ /**
401
+ * Whether the repo's copy of `relPath` differs from the template's, modulo
402
+ * line endings — a CRLF checkout of identical content must read as "same",
403
+ * or the managed-file refresh would commit churn on every boot forever.
404
+ */
405
+ private async templateDiffers(repoDir: string, relPath: string): Promise<boolean> {
406
+ const norm = (text: string) => text.replace(/\r\n?/g, '\n');
407
+ const [current, template] = await Promise.all([
408
+ fs.readFile(path.join(repoDir, relPath), 'utf8'),
409
+ fs.readFile(path.join(this.kbTemplateDir, relPath), 'utf8'),
410
+ ]);
411
+ return norm(current) !== norm(template);
412
+ }
413
+
305
414
  private async copyTemplateFile(relPath: string, dest: string): Promise<void> {
306
415
  const from = path.join(this.kbTemplateDir, relPath);
307
416
  const to = path.join(dest, relPath);
@@ -309,11 +418,96 @@ export class KbSeedService implements IKbSeedService {
309
418
  await fs.copyFile(from, to);
310
419
  }
311
420
 
421
+ /**
422
+ * Ensure `.bevelignore` carries `pattern`, appending it when absent. Returns
423
+ * the paths changed, for the commit.
424
+ *
425
+ * APPENDS — never rewrites. The file is the operator's, and every rule
426
+ * already in it is theirs to keep; this adds one line under a comment saying
427
+ * where it came from. Absent file, or a file that already lists the pattern,
428
+ * is a no-op, so running it on every clone changes nothing after the first.
429
+ *
430
+ * Matched line-wise rather than by substring: a rule for `Groups/AGENTS.md`
431
+ * is not a rule for the root `AGENTS.md`, and treating it as one would leave
432
+ * the mismatch this exists to close.
433
+ */
434
+ private async mergeIgnorePattern(repoDir: string, pattern: string): Promise<string[]> {
435
+ const ignorePath = path.join(repoDir, IGNORE_FILENAME);
436
+ let current: string;
437
+ try {
438
+ current = await fs.readFile(ignorePath, 'utf8');
439
+ } catch {
440
+ return []; // No ignore file — the template's copy arrives with the pattern in it.
441
+ }
442
+ const lines = current.split('\n').map((l) => l.trim());
443
+ if (lines.includes(pattern)) return [];
444
+ const separator = current.endsWith('\n') ? '' : '\n';
445
+ await fs.appendFile(
446
+ ignorePath,
447
+ `${separator}\n# Added by the platform: the conventions doc is not node content.\n${pattern}\n`,
448
+ 'utf8',
449
+ );
450
+ return [IGNORE_FILENAME];
451
+ }
452
+
453
+ /**
454
+ * Create any reserved root folder this repo is missing, as an empty
455
+ * `<dir>/.gitkeep`. Returns the paths added, for the commit message.
456
+ *
457
+ * WRITTEN, not copied from the template. A `.gitkeep` is empty by definition,
458
+ * and requiring a template entry per root would mean a distribution could not
459
+ * reserve one without forking the packaged template.
460
+ *
461
+ * Keyed on the DIRECTORY's existence, not the `.gitkeep` file — a branch that
462
+ * already has content under `KnowledgeBase/` never gets a pointless
463
+ * placeholder alongside it.
464
+ */
465
+ private async ensureRequiredDirs(repoDir: string): Promise<string[]> {
466
+ const added: string[] = [];
467
+ for (const rootDir of this.requiredDirs) {
468
+ const abs = path.join(repoDir, rootDir);
469
+ // `lstat`, not `exists`: `fs.access` answers "is there something here?",
470
+ // which is true of a FILE named `Groups` — and the old skip-if-present
471
+ // check then did nothing and reported success, leaving a knowledge base
472
+ // permanently missing a root it claims to guarantee. `lstat` rather than
473
+ // `stat` so a SYMLINK is rejected too: a link named `Groups` is not a KB
474
+ // layout, and one pointing outside the repo would make every later write
475
+ // into it land somewhere nobody asked for.
476
+ const found = await this.lstatOrNull(abs);
477
+ if (found) {
478
+ if (found.isDirectory()) continue;
479
+ throw new Error(
480
+ `KB root "${rootDir}" exists but is not a directory ` +
481
+ `(${found.isSymbolicLink() ? 'symlink' : 'file'}). Remove or rename it — ` +
482
+ 'the platform requires this name to be a folder.',
483
+ );
484
+ }
485
+ await fs.mkdir(abs, { recursive: true });
486
+ await fs.writeFile(path.join(abs, '.gitkeep'), '', 'utf8');
487
+ added.push(`${rootDir}/.gitkeep`);
488
+ }
489
+ return added;
490
+ }
491
+
492
+ /** `lstat` without the throw — null when nothing is at `p`. */
493
+ private async lstatOrNull(p: string): Promise<import('node:fs').Stats | null> {
494
+ try {
495
+ return await fs.lstat(p);
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+
312
501
  /** `git init` a temp repo, lay down the full template + generated roles.yaml, commit. */
313
502
  private async buildSeedCommit(dir: string): Promise<void> {
314
- await this.git(dir, ['init', '-b', this.defaultBranch]);
503
+ await this.git(dir, ['init', '-b', this.defaultBranch()]);
315
504
  await this.stampIdentity(dir);
316
505
  await this.copyTemplateTree(dir);
506
+ // Reserved roots the template does not carry. Without this the seed commit
507
+ // would hold only what the template has, and a distribution's own roots
508
+ // would appear a step later, when the first clone gets topped up — the same
509
+ // folders, arriving in a second commit for no reason.
510
+ await this.ensureRequiredDirs(dir);
317
511
  await fs.writeFile(path.join(dir, 'roles.yaml'), renderRolesYaml(this.seedAdminEmails), 'utf8');
318
512
  await this.git(dir, ['add', '-A']);
319
513
  await this.git(dir, ['commit', '-m', 'Seed knowledge base from Bevel template']);
@@ -325,17 +519,17 @@ export class KbSeedService implements IKbSeedService {
325
519
  await this.buildSeedCommit(dir);
326
520
  // Point every protected branch at the seed commit. The init branch is
327
521
  // already `defaultBranch`; create the rest as refs to HEAD.
328
- for (const branch of this.protectedBranches) {
329
- if (branch !== this.defaultBranch) {
522
+ for (const branch of this.protectedBranches()) {
523
+ if (branch !== this.defaultBranch()) {
330
524
  await this.git(dir, ['branch', branch]);
331
525
  }
332
526
  }
333
- await this.git(dir, ['remote', 'add', 'origin', this.kbRepoUrl]);
527
+ await this.git(dir, ['remote', 'add', 'origin', this.kbRepoUrl()]);
334
528
  // Push only the protected branches — never the stray init branch if it
335
529
  // isn't itself protected.
336
- await this.git(dir, [...this.credArgs(), 'push', '-u', 'origin', ...this.protectedBranches]);
530
+ await this.git(dir, [...this.credArgs(), 'push', '-u', 'origin', ...this.protectedBranches()]);
337
531
  console.log(
338
- `[kb-seed] Seeded empty KB remote with branches: ${this.protectedBranches.join(', ')}`,
532
+ `[kb-seed] Seeded empty KB remote with branches: ${this.protectedBranches().join(', ')}`,
339
533
  );
340
534
  });
341
535
  }
@@ -343,7 +537,7 @@ export class KbSeedService implements IKbSeedService {
343
537
  /** Create a missing protected branch on the remote, pointed at `base`'s tip. */
344
538
  private async createBranchOnRemote(branch: string, base: string): Promise<void> {
345
539
  await this.withTempDir(async (dir) => {
346
- await this.git(dir, [...this.credArgs(), 'clone', '--depth', '1', '-b', base, this.kbRepoUrl, dir]);
540
+ await this.git(dir, [...this.credArgs(), 'clone', '--depth', '1', '-b', base, this.kbRepoUrl(), dir]);
347
541
  // Push base's fetched tip up under the new branch name.
348
542
  await this.git(dir, [...this.credArgs(), 'push', 'origin', `HEAD:refs/heads/${branch}`]);
349
543
  console.log(`[kb-seed] Created missing protected branch "${branch}" from "${base}"`);