@bevel-software/platform-core-backend 0.1.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 (720) hide show
  1. package/LICENSE +202 -0
  2. package/dist/assets.d.ts +5 -0
  3. package/dist/assets.d.ts.map +1 -0
  4. package/dist/assets.js +23 -0
  5. package/dist/assets.js.map +1 -0
  6. package/dist/core/core-ports.d.ts +83 -0
  7. package/dist/core/core-ports.d.ts.map +1 -0
  8. package/dist/core/core-ports.js +12 -0
  9. package/dist/core/core-ports.js.map +1 -0
  10. package/dist/core/create-core-server.d.ts +62 -0
  11. package/dist/core/create-core-server.d.ts.map +1 -0
  12. package/dist/core/create-core-server.js +204 -0
  13. package/dist/core/create-core-server.js.map +1 -0
  14. package/dist/core/create-core-services.d.ts +95 -0
  15. package/dist/core/create-core-services.d.ts.map +1 -0
  16. package/dist/core/create-core-services.js +352 -0
  17. package/dist/core/create-core-services.js.map +1 -0
  18. package/dist/core-config.d.ts +119 -0
  19. package/dist/core-config.d.ts.map +1 -0
  20. package/dist/core-config.js +240 -0
  21. package/dist/core-config.js.map +1 -0
  22. package/dist/index.d.ts +36 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +36 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/modules/access/access-control.interface.d.ts +348 -0
  27. package/dist/modules/access/access-control.interface.d.ts.map +1 -0
  28. package/dist/modules/access/access-control.interface.js +2 -0
  29. package/dist/modules/access/access-control.interface.js.map +1 -0
  30. package/dist/modules/access/access-control.service.d.ts +323 -0
  31. package/dist/modules/access/access-control.service.d.ts.map +1 -0
  32. package/dist/modules/access/access-control.service.js +1431 -0
  33. package/dist/modules/access/access-control.service.js.map +1 -0
  34. package/dist/modules/access/access-errors.d.ts +34 -0
  35. package/dist/modules/access/access-errors.d.ts.map +1 -0
  36. package/dist/modules/access/access-errors.js +40 -0
  37. package/dist/modules/access/access-errors.js.map +1 -0
  38. package/dist/modules/access/access-mutation.service.d.ts +176 -0
  39. package/dist/modules/access/access-mutation.service.d.ts.map +1 -0
  40. package/dist/modules/access/access-mutation.service.js +307 -0
  41. package/dist/modules/access/access-mutation.service.js.map +1 -0
  42. package/dist/modules/access/access-splice.d.ts +98 -0
  43. package/dist/modules/access/access-splice.d.ts.map +1 -0
  44. package/dist/modules/access/access-splice.js +346 -0
  45. package/dist/modules/access/access-splice.js.map +1 -0
  46. package/dist/modules/access/access.routes.d.ts +10 -0
  47. package/dist/modules/access/access.routes.d.ts.map +1 -0
  48. package/dist/modules/access/access.routes.js +757 -0
  49. package/dist/modules/access/access.routes.js.map +1 -0
  50. package/dist/modules/access/creator-access.d.ts +113 -0
  51. package/dist/modules/access/creator-access.d.ts.map +1 -0
  52. package/dist/modules/access/creator-access.js +212 -0
  53. package/dist/modules/access/creator-access.js.map +1 -0
  54. package/dist/modules/access/kb-read-filter.d.ts +41 -0
  55. package/dist/modules/access/kb-read-filter.d.ts.map +1 -0
  56. package/dist/modules/access/kb-read-filter.js +60 -0
  57. package/dist/modules/access/kb-read-filter.js.map +1 -0
  58. package/dist/modules/access/roles-admin.service.d.ts +240 -0
  59. package/dist/modules/access/roles-admin.service.d.ts.map +1 -0
  60. package/dist/modules/access/roles-admin.service.js +630 -0
  61. package/dist/modules/access/roles-admin.service.js.map +1 -0
  62. package/dist/modules/access/roles-edit.d.ts +91 -0
  63. package/dist/modules/access/roles-edit.d.ts.map +1 -0
  64. package/dist/modules/access/roles-edit.js +232 -0
  65. package/dist/modules/access/roles-edit.js.map +1 -0
  66. package/dist/modules/access/roles-yaml-guard.d.ts +56 -0
  67. package/dist/modules/access/roles-yaml-guard.d.ts.map +1 -0
  68. package/dist/modules/access/roles-yaml-guard.js +79 -0
  69. package/dist/modules/access/roles-yaml-guard.js.map +1 -0
  70. package/dist/modules/admin/admin-access.routes.d.ts +18 -0
  71. package/dist/modules/admin/admin-access.routes.d.ts.map +1 -0
  72. package/dist/modules/admin/admin-access.routes.js +26 -0
  73. package/dist/modules/admin/admin-access.routes.js.map +1 -0
  74. package/dist/modules/admin/admin-access.service.d.ts +24 -0
  75. package/dist/modules/admin/admin-access.service.d.ts.map +1 -0
  76. package/dist/modules/admin/admin-access.service.js +41 -0
  77. package/dist/modules/admin/admin-access.service.js.map +1 -0
  78. package/dist/modules/admin/admin.interface.d.ts +11 -0
  79. package/dist/modules/admin/admin.interface.d.ts.map +1 -0
  80. package/dist/modules/admin/admin.interface.js +2 -0
  81. package/dist/modules/admin/admin.interface.js.map +1 -0
  82. package/dist/modules/auth/account-erasure.service.d.ts +80 -0
  83. package/dist/modules/auth/account-erasure.service.d.ts.map +1 -0
  84. package/dist/modules/auth/account-erasure.service.js +91 -0
  85. package/dist/modules/auth/account-erasure.service.js.map +1 -0
  86. package/dist/modules/auth/auth.middleware.d.ts +23 -0
  87. package/dist/modules/auth/auth.middleware.d.ts.map +1 -0
  88. package/dist/modules/auth/auth.middleware.js +68 -0
  89. package/dist/modules/auth/auth.middleware.js.map +1 -0
  90. package/dist/modules/auth/auth.routes.d.ts +25 -0
  91. package/dist/modules/auth/auth.routes.d.ts.map +1 -0
  92. package/dist/modules/auth/auth.routes.js +72 -0
  93. package/dist/modules/auth/auth.routes.js.map +1 -0
  94. package/dist/modules/auth/auth.service.d.ts +76 -0
  95. package/dist/modules/auth/auth.service.d.ts.map +1 -0
  96. package/dist/modules/auth/auth.service.js +168 -0
  97. package/dist/modules/auth/auth.service.js.map +1 -0
  98. package/dist/modules/code-mode/code-mode-names.d.ts +16 -0
  99. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -0
  100. package/dist/modules/code-mode/code-mode-names.js +30 -0
  101. package/dist/modules/code-mode/code-mode-names.js.map +1 -0
  102. package/dist/modules/code-mode/code-mode.tool.d.ts +13 -0
  103. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -0
  104. package/dist/modules/code-mode/code-mode.tool.js +112 -0
  105. package/dist/modules/code-mode/code-mode.tool.js.map +1 -0
  106. package/dist/modules/code-mode/index.d.ts +3 -0
  107. package/dist/modules/code-mode/index.d.ts.map +1 -0
  108. package/dist/modules/code-mode/index.js +3 -0
  109. package/dist/modules/code-mode/index.js.map +1 -0
  110. package/dist/modules/database/connection.d.ts +6 -0
  111. package/dist/modules/database/connection.d.ts.map +1 -0
  112. package/dist/modules/database/connection.js +12 -0
  113. package/dist/modules/database/connection.js.map +1 -0
  114. package/dist/modules/database/core-schema.d.ts +2273 -0
  115. package/dist/modules/database/core-schema.d.ts.map +1 -0
  116. package/dist/modules/database/core-schema.js +413 -0
  117. package/dist/modules/database/core-schema.js.map +1 -0
  118. package/dist/modules/database/migrate.d.ts +10 -0
  119. package/dist/modules/database/migrate.d.ts.map +1 -0
  120. package/dist/modules/database/migrate.js +40 -0
  121. package/dist/modules/database/migrate.js.map +1 -0
  122. package/dist/modules/database/schema.d.ts +12 -0
  123. package/dist/modules/database/schema.d.ts.map +1 -0
  124. package/dist/modules/database/schema.js +12 -0
  125. package/dist/modules/database/schema.js.map +1 -0
  126. package/dist/modules/diff/diff-paths.d.ts +2 -0
  127. package/dist/modules/diff/diff-paths.d.ts.map +1 -0
  128. package/dist/modules/diff/diff-paths.js +9 -0
  129. package/dist/modules/diff/diff-paths.js.map +1 -0
  130. package/dist/modules/diff/diff.config.d.ts +8 -0
  131. package/dist/modules/diff/diff.config.d.ts.map +1 -0
  132. package/dist/modules/diff/diff.config.js +13 -0
  133. package/dist/modules/diff/diff.config.js.map +1 -0
  134. package/dist/modules/diff/diff.interface.d.ts +73 -0
  135. package/dist/modules/diff/diff.interface.d.ts.map +1 -0
  136. package/dist/modules/diff/diff.interface.js +2 -0
  137. package/dist/modules/diff/diff.interface.js.map +1 -0
  138. package/dist/modules/diff/diff.routes.d.ts +35 -0
  139. package/dist/modules/diff/diff.routes.d.ts.map +1 -0
  140. package/dist/modules/diff/diff.routes.js +228 -0
  141. package/dist/modules/diff/diff.routes.js.map +1 -0
  142. package/dist/modules/diff/diff.service.d.ts +51 -0
  143. package/dist/modules/diff/diff.service.d.ts.map +1 -0
  144. package/dist/modules/diff/diff.service.js +489 -0
  145. package/dist/modules/diff/diff.service.js.map +1 -0
  146. package/dist/modules/diff/line-diff.d.ts +16 -0
  147. package/dist/modules/diff/line-diff.d.ts.map +1 -0
  148. package/dist/modules/diff/line-diff.js +67 -0
  149. package/dist/modules/diff/line-diff.js.map +1 -0
  150. package/dist/modules/mcp/mcp-auth.middleware.d.ts +27 -0
  151. package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -0
  152. package/dist/modules/mcp/mcp-auth.middleware.js +116 -0
  153. package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -0
  154. package/dist/modules/mcp/mcp-session-store.d.ts +74 -0
  155. package/dist/modules/mcp/mcp-session-store.d.ts.map +1 -0
  156. package/dist/modules/mcp/mcp-session-store.js +131 -0
  157. package/dist/modules/mcp/mcp-session-store.js.map +1 -0
  158. package/dist/modules/mcp/mcp.routes.d.ts +24 -0
  159. package/dist/modules/mcp/mcp.routes.d.ts.map +1 -0
  160. package/dist/modules/mcp/mcp.routes.js +229 -0
  161. package/dist/modules/mcp/mcp.routes.js.map +1 -0
  162. package/dist/modules/mcp/mcp.service.d.ts +198 -0
  163. package/dist/modules/mcp/mcp.service.d.ts.map +1 -0
  164. package/dist/modules/mcp/mcp.service.js +840 -0
  165. package/dist/modules/mcp/mcp.service.js.map +1 -0
  166. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts +76 -0
  167. package/dist/modules/mcp/oauth/bevel-oauth-provider.d.ts.map +1 -0
  168. package/dist/modules/mcp/oauth/bevel-oauth-provider.js +291 -0
  169. package/dist/modules/mcp/oauth/bevel-oauth-provider.js.map +1 -0
  170. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts +18 -0
  171. package/dist/modules/mcp/oauth/oauth-consent.routes.d.ts.map +1 -0
  172. package/dist/modules/mcp/oauth/oauth-consent.routes.js +52 -0
  173. package/dist/modules/mcp/oauth/oauth-consent.routes.js.map +1 -0
  174. package/dist/modules/mcp/oauth/oauth-state.d.ts +32 -0
  175. package/dist/modules/mcp/oauth/oauth-state.d.ts.map +1 -0
  176. package/dist/modules/mcp/oauth/oauth-state.js +34 -0
  177. package/dist/modules/mcp/oauth/oauth-state.js.map +1 -0
  178. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts +92 -0
  179. package/dist/modules/secrets-vault/db-secrets-vault.service.d.ts.map +1 -0
  180. package/dist/modules/secrets-vault/db-secrets-vault.service.js +728 -0
  181. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -0
  182. package/dist/modules/secrets-vault/index.d.ts +7 -0
  183. package/dist/modules/secrets-vault/index.d.ts.map +1 -0
  184. package/dist/modules/secrets-vault/index.js +6 -0
  185. package/dist/modules/secrets-vault/index.js.map +1 -0
  186. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts +63 -0
  187. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.d.ts.map +1 -0
  188. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js +269 -0
  189. package/dist/modules/secrets-vault/mcp-oauth-discovery.service.js.map +1 -0
  190. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts +46 -0
  191. package/dist/modules/secrets-vault/secrets-variable-loader.d.ts.map +1 -0
  192. package/dist/modules/secrets-vault/secrets-variable-loader.js +87 -0
  193. package/dist/modules/secrets-vault/secrets-variable-loader.js.map +1 -0
  194. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts +210 -0
  195. package/dist/modules/secrets-vault/secrets-vault.contract.d.ts.map +1 -0
  196. package/dist/modules/secrets-vault/secrets-vault.contract.js +90 -0
  197. package/dist/modules/secrets-vault/secrets-vault.contract.js.map +1 -0
  198. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +30 -0
  199. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -0
  200. package/dist/modules/secrets-vault/secrets-vault.routes.js +490 -0
  201. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -0
  202. package/dist/modules/skills/index.d.ts +5 -0
  203. package/dist/modules/skills/index.d.ts.map +1 -0
  204. package/dist/modules/skills/index.js +4 -0
  205. package/dist/modules/skills/index.js.map +1 -0
  206. package/dist/modules/skills/skills.contract.d.ts +58 -0
  207. package/dist/modules/skills/skills.contract.d.ts.map +1 -0
  208. package/dist/modules/skills/skills.contract.js +10 -0
  209. package/dist/modules/skills/skills.contract.js.map +1 -0
  210. package/dist/modules/skills/skills.routes.d.ts +15 -0
  211. package/dist/modules/skills/skills.routes.d.ts.map +1 -0
  212. package/dist/modules/skills/skills.routes.js +34 -0
  213. package/dist/modules/skills/skills.routes.js.map +1 -0
  214. package/dist/modules/skills/skills.service.d.ts +21 -0
  215. package/dist/modules/skills/skills.service.d.ts.map +1 -0
  216. package/dist/modules/skills/skills.service.js +202 -0
  217. package/dist/modules/skills/skills.service.js.map +1 -0
  218. package/dist/modules/skills/skills.tools.d.ts +15 -0
  219. package/dist/modules/skills/skills.tools.d.ts.map +1 -0
  220. package/dist/modules/skills/skills.tools.js +96 -0
  221. package/dist/modules/skills/skills.tools.js.map +1 -0
  222. package/dist/modules/tool-auth/external-api-key.errors.d.ts +14 -0
  223. package/dist/modules/tool-auth/external-api-key.errors.d.ts.map +1 -0
  224. package/dist/modules/tool-auth/external-api-key.errors.js +23 -0
  225. package/dist/modules/tool-auth/external-api-key.errors.js.map +1 -0
  226. package/dist/modules/tool-auth/external-api-key.interface.d.ts +90 -0
  227. package/dist/modules/tool-auth/external-api-key.interface.d.ts.map +1 -0
  228. package/dist/modules/tool-auth/external-api-key.interface.js +2 -0
  229. package/dist/modules/tool-auth/external-api-key.interface.js.map +1 -0
  230. package/dist/modules/tool-auth/external-api-key.service.d.ts +25 -0
  231. package/dist/modules/tool-auth/external-api-key.service.d.ts.map +1 -0
  232. package/dist/modules/tool-auth/external-api-key.service.js +164 -0
  233. package/dist/modules/tool-auth/external-api-key.service.js.map +1 -0
  234. package/dist/modules/tool-auth/internal-token.service.d.ts +62 -0
  235. package/dist/modules/tool-auth/internal-token.service.d.ts.map +1 -0
  236. package/dist/modules/tool-auth/internal-token.service.js +85 -0
  237. package/dist/modules/tool-auth/internal-token.service.js.map +1 -0
  238. package/dist/modules/tool-auth/llm-usage-meter.d.ts +16 -0
  239. package/dist/modules/tool-auth/llm-usage-meter.d.ts.map +1 -0
  240. package/dist/modules/tool-auth/llm-usage-meter.js +6 -0
  241. package/dist/modules/tool-auth/llm-usage-meter.js.map +1 -0
  242. package/dist/modules/tool-auth/tool-auth.middleware.d.ts +101 -0
  243. package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -0
  244. package/dist/modules/tool-auth/tool-auth.middleware.js +166 -0
  245. package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -0
  246. package/dist/modules/tool-helpers/index.d.ts +19 -0
  247. package/dist/modules/tool-helpers/index.d.ts.map +1 -0
  248. package/dist/modules/tool-helpers/index.js +18 -0
  249. package/dist/modules/tool-helpers/index.js.map +1 -0
  250. package/dist/modules/tool-helpers/tool-context.d.ts +35 -0
  251. package/dist/modules/tool-helpers/tool-context.d.ts.map +1 -0
  252. package/dist/modules/tool-helpers/tool-context.js +66 -0
  253. package/dist/modules/tool-helpers/tool-context.js.map +1 -0
  254. package/dist/modules/tool-helpers/tool-def.d.ts +33 -0
  255. package/dist/modules/tool-helpers/tool-def.d.ts.map +1 -0
  256. package/dist/modules/tool-helpers/tool-def.js +57 -0
  257. package/dist/modules/tool-helpers/tool-def.js.map +1 -0
  258. package/dist/modules/tool-helpers/tool-handler.d.ts +19 -0
  259. package/dist/modules/tool-helpers/tool-handler.d.ts.map +1 -0
  260. package/dist/modules/tool-helpers/tool-handler.js +84 -0
  261. package/dist/modules/tool-helpers/tool-handler.js.map +1 -0
  262. package/dist/modules/tool-helpers/tool.contract.d.ts +64 -0
  263. package/dist/modules/tool-helpers/tool.contract.d.ts.map +1 -0
  264. package/dist/modules/tool-helpers/tool.contract.js +24 -0
  265. package/dist/modules/tool-helpers/tool.contract.js.map +1 -0
  266. package/dist/modules/tool-helpers/validate-token.d.ts +31 -0
  267. package/dist/modules/tool-helpers/validate-token.d.ts.map +1 -0
  268. package/dist/modules/tool-helpers/validate-token.js +20 -0
  269. package/dist/modules/tool-helpers/validate-token.js.map +1 -0
  270. package/dist/modules/tool-manuals/index.d.ts +5 -0
  271. package/dist/modules/tool-manuals/index.d.ts.map +1 -0
  272. package/dist/modules/tool-manuals/index.js +5 -0
  273. package/dist/modules/tool-manuals/index.js.map +1 -0
  274. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +182 -0
  275. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -0
  276. package/dist/modules/tool-manuals/tool-manuals.contract.js +19 -0
  277. package/dist/modules/tool-manuals/tool-manuals.contract.js.map +1 -0
  278. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +24 -0
  279. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -0
  280. package/dist/modules/tool-manuals/tool-manuals.routes.js +102 -0
  281. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -0
  282. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +108 -0
  283. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -0
  284. package/dist/modules/tool-manuals/tool-manuals.service.js +710 -0
  285. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -0
  286. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts +35 -0
  287. package/dist/modules/tool-manuals/tool-manuals.tools.d.ts.map +1 -0
  288. package/dist/modules/tool-manuals/tool-manuals.tools.js +157 -0
  289. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -0
  290. package/dist/modules/tool-registry/manual.routes.d.ts +6 -0
  291. package/dist/modules/tool-registry/manual.routes.d.ts.map +1 -0
  292. package/dist/modules/tool-registry/manual.routes.js +76 -0
  293. package/dist/modules/tool-registry/manual.routes.js.map +1 -0
  294. package/dist/modules/tool-registry/tool-registry.d.ts +23 -0
  295. package/dist/modules/tool-registry/tool-registry.d.ts.map +1 -0
  296. package/dist/modules/tool-registry/tool-registry.js +46 -0
  297. package/dist/modules/tool-registry/tool-registry.js.map +1 -0
  298. package/dist/modules/tool-registry/tool.contract.d.ts +48 -0
  299. package/dist/modules/tool-registry/tool.contract.d.ts.map +1 -0
  300. package/dist/modules/tool-registry/tool.contract.js +2 -0
  301. package/dist/modules/tool-registry/tool.contract.js.map +1 -0
  302. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts +14 -0
  303. package/dist/modules/workflow/agent-tools/workflow.tools.d.ts.map +1 -0
  304. package/dist/modules/workflow/agent-tools/workflow.tools.js +437 -0
  305. package/dist/modules/workflow/agent-tools/workflow.tools.js.map +1 -0
  306. package/dist/modules/workflow/event-bus.d.ts +107 -0
  307. package/dist/modules/workflow/event-bus.d.ts.map +1 -0
  308. package/dist/modules/workflow/event-bus.js +224 -0
  309. package/dist/modules/workflow/event-bus.js.map +1 -0
  310. package/dist/modules/workflow/events.routes.d.ts +80 -0
  311. package/dist/modules/workflow/events.routes.d.ts.map +1 -0
  312. package/dist/modules/workflow/events.routes.js +216 -0
  313. package/dist/modules/workflow/events.routes.js.map +1 -0
  314. package/dist/modules/workflow/file-change-notifier.d.ts +38 -0
  315. package/dist/modules/workflow/file-change-notifier.d.ts.map +1 -0
  316. package/dist/modules/workflow/file-change-notifier.js +22 -0
  317. package/dist/modules/workflow/file-change-notifier.js.map +1 -0
  318. package/dist/modules/workflow/file-lock.service.d.ts +57 -0
  319. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -0
  320. package/dist/modules/workflow/file-lock.service.js +202 -0
  321. package/dist/modules/workflow/file-lock.service.js.map +1 -0
  322. package/dist/modules/workflow/git/branch-name.d.ts +10 -0
  323. package/dist/modules/workflow/git/branch-name.d.ts.map +1 -0
  324. package/dist/modules/workflow/git/branch-name.js +76 -0
  325. package/dist/modules/workflow/git/branch-name.js.map +1 -0
  326. package/dist/modules/workflow/git/clone-config.d.ts +61 -0
  327. package/dist/modules/workflow/git/clone-config.d.ts.map +1 -0
  328. package/dist/modules/workflow/git/clone-config.js +69 -0
  329. package/dist/modules/workflow/git/clone-config.js.map +1 -0
  330. package/dist/modules/workflow/git/git-version.d.ts +34 -0
  331. package/dist/modules/workflow/git/git-version.d.ts.map +1 -0
  332. package/dist/modules/workflow/git/git-version.js +63 -0
  333. package/dist/modules/workflow/git/git-version.js.map +1 -0
  334. package/dist/modules/workflow/git/git.service.d.ts +427 -0
  335. package/dist/modules/workflow/git/git.service.d.ts.map +1 -0
  336. package/dist/modules/workflow/git/git.service.js +1987 -0
  337. package/dist/modules/workflow/git/git.service.js.map +1 -0
  338. package/dist/modules/workflow/git/mutex.d.ts +11 -0
  339. package/dist/modules/workflow/git/mutex.d.ts.map +1 -0
  340. package/dist/modules/workflow/git/mutex.js +23 -0
  341. package/dist/modules/workflow/git/mutex.js.map +1 -0
  342. package/dist/modules/workflow/git/pull-request.service.d.ts +124 -0
  343. package/dist/modules/workflow/git/pull-request.service.d.ts.map +1 -0
  344. package/dist/modules/workflow/git/pull-request.service.js +328 -0
  345. package/dist/modules/workflow/git/pull-request.service.js.map +1 -0
  346. package/dist/modules/workflow/locking-filesystem.d.ts +133 -0
  347. package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -0
  348. package/dist/modules/workflow/locking-filesystem.js +400 -0
  349. package/dist/modules/workflow/locking-filesystem.js.map +1 -0
  350. package/dist/modules/workflow/pending-commits.service.d.ts +221 -0
  351. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -0
  352. package/dist/modules/workflow/pending-commits.service.js +364 -0
  353. package/dist/modules/workflow/pending-commits.service.js.map +1 -0
  354. package/dist/modules/workflow/pending-commits.worker.d.ts +148 -0
  355. package/dist/modules/workflow/pending-commits.worker.d.ts.map +1 -0
  356. package/dist/modules/workflow/pending-commits.worker.js +245 -0
  357. package/dist/modules/workflow/pending-commits.worker.js.map +1 -0
  358. package/dist/modules/workflow/read-only-filesystem.d.ts +24 -0
  359. package/dist/modules/workflow/read-only-filesystem.d.ts.map +1 -0
  360. package/dist/modules/workflow/read-only-filesystem.js +39 -0
  361. package/dist/modules/workflow/read-only-filesystem.js.map +1 -0
  362. package/dist/modules/workflow/recovery-bot.d.ts +31 -0
  363. package/dist/modules/workflow/recovery-bot.d.ts.map +1 -0
  364. package/dist/modules/workflow/recovery-bot.js +64 -0
  365. package/dist/modules/workflow/recovery-bot.js.map +1 -0
  366. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts +137 -0
  367. package/dist/modules/workflow/review-workflow/review-workflow.interface.d.ts.map +1 -0
  368. package/dist/modules/workflow/review-workflow/review-workflow.interface.js +2 -0
  369. package/dist/modules/workflow/review-workflow/review-workflow.interface.js.map +1 -0
  370. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts +26 -0
  371. package/dist/modules/workflow/review-workflow/review-workflow.service.d.ts.map +1 -0
  372. package/dist/modules/workflow/review-workflow/review-workflow.service.js +636 -0
  373. package/dist/modules/workflow/review-workflow/review-workflow.service.js.map +1 -0
  374. package/dist/modules/workflow/sanitize-error.d.ts +29 -0
  375. package/dist/modules/workflow/sanitize-error.d.ts.map +1 -0
  376. package/dist/modules/workflow/sanitize-error.js +60 -0
  377. package/dist/modules/workflow/sanitize-error.js.map +1 -0
  378. package/dist/modules/workflow/session-ontology.policy.d.ts +52 -0
  379. package/dist/modules/workflow/session-ontology.policy.d.ts.map +1 -0
  380. package/dist/modules/workflow/session-ontology.policy.js +62 -0
  381. package/dist/modules/workflow/session-ontology.policy.js.map +1 -0
  382. package/dist/modules/workflow/session-ontology.service.d.ts +100 -0
  383. package/dist/modules/workflow/session-ontology.service.d.ts.map +1 -0
  384. package/dist/modules/workflow/session-ontology.service.js +143 -0
  385. package/dist/modules/workflow/session-ontology.service.js.map +1 -0
  386. package/dist/modules/workflow/workflow-hooks.d.ts +87 -0
  387. package/dist/modules/workflow/workflow-hooks.d.ts.map +1 -0
  388. package/dist/modules/workflow/workflow-hooks.js +35 -0
  389. package/dist/modules/workflow/workflow-hooks.js.map +1 -0
  390. package/dist/modules/workflow/workflow.errors.d.ts +171 -0
  391. package/dist/modules/workflow/workflow.errors.d.ts.map +1 -0
  392. package/dist/modules/workflow/workflow.errors.js +260 -0
  393. package/dist/modules/workflow/workflow.errors.js.map +1 -0
  394. package/dist/modules/workflow/workflow.routes.d.ts +26 -0
  395. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -0
  396. package/dist/modules/workflow/workflow.routes.js +804 -0
  397. package/dist/modules/workflow/workflow.routes.js.map +1 -0
  398. package/dist/modules/workflow/workflow.service.d.ts +385 -0
  399. package/dist/modules/workflow/workflow.service.d.ts.map +1 -0
  400. package/dist/modules/workflow/workflow.service.js +1155 -0
  401. package/dist/modules/workflow/workflow.service.js.map +1 -0
  402. package/dist/modules/workspace/bevel-ignore.d.ts +25 -0
  403. package/dist/modules/workspace/bevel-ignore.d.ts.map +1 -0
  404. package/dist/modules/workspace/bevel-ignore.js +57 -0
  405. package/dist/modules/workspace/bevel-ignore.js.map +1 -0
  406. package/dist/modules/workspace/kb-seed.interface.d.ts +36 -0
  407. package/dist/modules/workspace/kb-seed.interface.d.ts.map +1 -0
  408. package/dist/modules/workspace/kb-seed.interface.js +2 -0
  409. package/dist/modules/workspace/kb-seed.interface.js.map +1 -0
  410. package/dist/modules/workspace/kb-seed.service.d.ts +70 -0
  411. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -0
  412. package/dist/modules/workspace/kb-seed.service.js +325 -0
  413. package/dist/modules/workspace/kb-seed.service.js.map +1 -0
  414. package/dist/modules/workspace/routine-write-policy.d.ts +49 -0
  415. package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -0
  416. package/dist/modules/workspace/routine-write-policy.js +46 -0
  417. package/dist/modules/workspace/routine-write-policy.js.map +1 -0
  418. package/dist/modules/workspace/session-ontology.gate.d.ts +112 -0
  419. package/dist/modules/workspace/session-ontology.gate.d.ts.map +1 -0
  420. package/dist/modules/workspace/session-ontology.gate.js +161 -0
  421. package/dist/modules/workspace/session-ontology.gate.js.map +1 -0
  422. package/dist/modules/workspace/session-sink.d.ts +25 -0
  423. package/dist/modules/workspace/session-sink.d.ts.map +1 -0
  424. package/dist/modules/workspace/session-sink.js +8 -0
  425. package/dist/modules/workspace/session-sink.js.map +1 -0
  426. package/dist/modules/workspace/spill-store.d.ts +24 -0
  427. package/dist/modules/workspace/spill-store.d.ts.map +1 -0
  428. package/dist/modules/workspace/spill-store.js +87 -0
  429. package/dist/modules/workspace/spill-store.js.map +1 -0
  430. package/dist/modules/workspace/workspace.routes.d.ts +25 -0
  431. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -0
  432. package/dist/modules/workspace/workspace.routes.js +1046 -0
  433. package/dist/modules/workspace/workspace.routes.js.map +1 -0
  434. package/dist/modules/workspace/workspace.service.d.ts +339 -0
  435. package/dist/modules/workspace/workspace.service.d.ts.map +1 -0
  436. package/dist/modules/workspace/workspace.service.js +1221 -0
  437. package/dist/modules/workspace/workspace.service.js.map +1 -0
  438. package/dist/modules/workspace/workspace.tools.d.ts +19 -0
  439. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -0
  440. package/dist/modules/workspace/workspace.tools.js +767 -0
  441. package/dist/modules/workspace/workspace.tools.js.map +1 -0
  442. package/dist/shared/frontmatter-id.d.ts +35 -0
  443. package/dist/shared/frontmatter-id.d.ts.map +1 -0
  444. package/dist/shared/frontmatter-id.js +68 -0
  445. package/dist/shared/frontmatter-id.js.map +1 -0
  446. package/dist/shared/fs-walk.d.ts +9 -0
  447. package/dist/shared/fs-walk.d.ts.map +1 -0
  448. package/dist/shared/fs-walk.js +34 -0
  449. package/dist/shared/fs-walk.js.map +1 -0
  450. package/dist/shared/hash-email.d.ts +11 -0
  451. package/dist/shared/hash-email.d.ts.map +1 -0
  452. package/dist/shared/hash-email.js +14 -0
  453. package/dist/shared/hash-email.js.map +1 -0
  454. package/dist/shared/kb-layout.d.ts +38 -0
  455. package/dist/shared/kb-layout.d.ts.map +1 -0
  456. package/dist/shared/kb-layout.js +102 -0
  457. package/dist/shared/kb-layout.js.map +1 -0
  458. package/dist/shared/kb-layout.test.d.ts +2 -0
  459. package/dist/shared/kb-layout.test.d.ts.map +1 -0
  460. package/dist/shared/kb-layout.test.js +67 -0
  461. package/dist/shared/kb-layout.test.js.map +1 -0
  462. package/dist/shared/ssrf.d.ts +20 -0
  463. package/dist/shared/ssrf.d.ts.map +1 -0
  464. package/dist/shared/ssrf.js +89 -0
  465. package/dist/shared/ssrf.js.map +1 -0
  466. package/dist/shared/token-crypto.d.ts +16 -0
  467. package/dist/shared/token-crypto.d.ts.map +1 -0
  468. package/dist/shared/token-crypto.js +49 -0
  469. package/dist/shared/token-crypto.js.map +1 -0
  470. package/dist/shared/ttl-cache.d.ts +16 -0
  471. package/dist/shared/ttl-cache.d.ts.map +1 -0
  472. package/dist/shared/ttl-cache.js +27 -0
  473. package/dist/shared/ttl-cache.js.map +1 -0
  474. package/dist/shared/utcp-namespace.d.ts +46 -0
  475. package/dist/shared/utcp-namespace.d.ts.map +1 -0
  476. package/dist/shared/utcp-namespace.js +82 -0
  477. package/dist/shared/utcp-namespace.js.map +1 -0
  478. package/dist/version.d.ts +17 -0
  479. package/dist/version.d.ts.map +1 -0
  480. package/dist/version.js +19 -0
  481. package/dist/version.js.map +1 -0
  482. package/kb-template/.bevelignore +21 -0
  483. package/kb-template/Agents/.gitkeep +0 -0
  484. package/kb-template/Agents/README.md +38 -0
  485. package/kb-template/CLAUDE.md +419 -0
  486. package/kb-template/Data/.gitkeep +0 -0
  487. package/kb-template/Data/README.md +39 -0
  488. package/kb-template/KnowledgeBase/.gitkeep +0 -0
  489. package/kb-template/KnowledgeBase/README.md +37 -0
  490. package/kb-template/Pipelines/.gitkeep +0 -0
  491. package/kb-template/Pipelines/README.md +38 -0
  492. package/kb-template/Skills/.gitkeep +0 -0
  493. package/kb-template/Skills/README.md +32 -0
  494. package/kb-template/Tools/.gitkeep +0 -0
  495. package/kb-template/Tools/README.md +42 -0
  496. package/kb-template/access.md +36 -0
  497. package/migrations/0000_core_init.sql +243 -0
  498. package/migrations/meta/0000_snapshot.json +1480 -0
  499. package/migrations/meta/_journal.json +13 -0
  500. package/package.json +80 -0
  501. package/src/assets.ts +25 -0
  502. package/src/core/core-ports.ts +91 -0
  503. package/src/core/create-core-server.ts +338 -0
  504. package/src/core/create-core-services.ts +501 -0
  505. package/src/core-config.ts +257 -0
  506. package/src/index.ts +69 -0
  507. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -0
  508. package/src/modules/access/__tests__/access-control.read-batch.test.ts +132 -0
  509. package/src/modules/access/__tests__/access-control.service.test.ts +1011 -0
  510. package/src/modules/access/__tests__/access-mutation.service.test.ts +439 -0
  511. package/src/modules/access/__tests__/access-splice.test.ts +247 -0
  512. package/src/modules/access/__tests__/access.routes.revoke.test.ts +407 -0
  513. package/src/modules/access/__tests__/creator-access.test.ts +217 -0
  514. package/src/modules/access/__tests__/grant-sources.test.ts +204 -0
  515. package/src/modules/access/__tests__/kb-read-filter.test.ts +70 -0
  516. package/src/modules/access/__tests__/roles-admin.service.test.ts +469 -0
  517. package/src/modules/access/__tests__/roles-edit.test.ts +138 -0
  518. package/src/modules/access/__tests__/roles-yaml-guard.test.ts +70 -0
  519. package/src/modules/access/__tests__/roles.routes.test.ts +234 -0
  520. package/src/modules/access/access-control.interface.ts +383 -0
  521. package/src/modules/access/access-control.service.ts +1794 -0
  522. package/src/modules/access/access-errors.ts +60 -0
  523. package/src/modules/access/access-mutation.service.ts +369 -0
  524. package/src/modules/access/access-splice.ts +413 -0
  525. package/src/modules/access/access.routes.ts +894 -0
  526. package/src/modules/access/creator-access.ts +270 -0
  527. package/src/modules/access/kb-read-filter.ts +76 -0
  528. package/src/modules/access/roles-admin.service.ts +741 -0
  529. package/src/modules/access/roles-edit.ts +248 -0
  530. package/src/modules/access/roles-yaml-guard.ts +89 -0
  531. package/src/modules/admin/admin-access.routes.ts +29 -0
  532. package/src/modules/admin/admin-access.service.ts +38 -0
  533. package/src/modules/admin/admin.interface.ts +10 -0
  534. package/src/modules/auth/account-erasure.service.ts +183 -0
  535. package/src/modules/auth/auth.middleware.ts +78 -0
  536. package/src/modules/auth/auth.routes.ts +97 -0
  537. package/src/modules/auth/auth.service.ts +199 -0
  538. package/src/modules/code-mode/code-mode-names.ts +38 -0
  539. package/src/modules/code-mode/code-mode.tool.ts +117 -0
  540. package/src/modules/code-mode/index.ts +6 -0
  541. package/src/modules/database/connection.ts +15 -0
  542. package/src/modules/database/core-schema.ts +431 -0
  543. package/src/modules/database/migrate.ts +43 -0
  544. package/src/modules/database/schema.ts +11 -0
  545. package/src/modules/diff/__tests__/diff.routes.read-gate.test.ts +87 -0
  546. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -0
  547. package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +184 -0
  548. package/src/modules/diff/__tests__/diff.service.test.ts +202 -0
  549. package/src/modules/diff/diff-paths.ts +9 -0
  550. package/src/modules/diff/diff.config.ts +12 -0
  551. package/src/modules/diff/diff.interface.ts +78 -0
  552. package/src/modules/diff/diff.routes.ts +277 -0
  553. package/src/modules/diff/diff.service.ts +508 -0
  554. package/src/modules/diff/line-diff.ts +79 -0
  555. package/src/modules/mcp/__tests__/bevel-oauth-provider.test.ts +287 -0
  556. package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +249 -0
  557. package/src/modules/mcp/__tests__/mcp-proxy-helpers.test.ts +253 -0
  558. package/src/modules/mcp/__tests__/mcp-session-store.test.ts +151 -0
  559. package/src/modules/mcp/__tests__/mcp.e2e.test.ts +150 -0
  560. package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +92 -0
  561. package/src/modules/mcp/__tests__/mcp.service.test.ts +414 -0
  562. package/src/modules/mcp/mcp-auth.middleware.ts +126 -0
  563. package/src/modules/mcp/mcp-session-store.ts +162 -0
  564. package/src/modules/mcp/mcp.routes.ts +263 -0
  565. package/src/modules/mcp/mcp.service.ts +973 -0
  566. package/src/modules/mcp/oauth/bevel-oauth-provider.ts +401 -0
  567. package/src/modules/mcp/oauth/oauth-consent.routes.ts +60 -0
  568. package/src/modules/mcp/oauth/oauth-state.ts +61 -0
  569. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +277 -0
  570. package/src/modules/secrets-vault/__tests__/db-secrets-vault.oauth.test.ts +206 -0
  571. package/src/modules/secrets-vault/__tests__/mcp-oauth-discovery.service.test.ts +178 -0
  572. package/src/modules/secrets-vault/__tests__/scopes-covered.test.ts +101 -0
  573. package/src/modules/secrets-vault/__tests__/secrets-variable-loader.test.ts +54 -0
  574. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -0
  575. package/src/modules/secrets-vault/db-secrets-vault.service.ts +792 -0
  576. package/src/modules/secrets-vault/index.ts +25 -0
  577. package/src/modules/secrets-vault/mcp-oauth-discovery.service.ts +289 -0
  578. package/src/modules/secrets-vault/secrets-variable-loader.ts +104 -0
  579. package/src/modules/secrets-vault/secrets-vault.contract.ts +270 -0
  580. package/src/modules/secrets-vault/secrets-vault.routes.ts +526 -0
  581. package/src/modules/skills/__tests__/skills.service.test.ts +176 -0
  582. package/src/modules/skills/index.ts +10 -0
  583. package/src/modules/skills/skills.contract.ts +54 -0
  584. package/src/modules/skills/skills.routes.ts +38 -0
  585. package/src/modules/skills/skills.service.ts +245 -0
  586. package/src/modules/skills/skills.tools.ts +121 -0
  587. package/src/modules/tool-auth/__tests__/external-api-key.service.test.ts +358 -0
  588. package/src/modules/tool-auth/__tests__/internal-token.service.test.ts +79 -0
  589. package/src/modules/tool-auth/__tests__/manual-auth.middleware.test.ts +88 -0
  590. package/src/modules/tool-auth/external-api-key.errors.ts +25 -0
  591. package/src/modules/tool-auth/external-api-key.interface.ts +99 -0
  592. package/src/modules/tool-auth/external-api-key.service.ts +209 -0
  593. package/src/modules/tool-auth/internal-token.service.ts +124 -0
  594. package/src/modules/tool-auth/llm-usage-meter.ts +19 -0
  595. package/src/modules/tool-auth/tool-auth.middleware.ts +220 -0
  596. package/src/modules/tool-helpers/__tests__/phase0-loopback-spike.test.ts +129 -0
  597. package/src/modules/tool-helpers/__tests__/phase4-tools.test.ts +95 -0
  598. package/src/modules/tool-helpers/__tests__/tools-surface.test.ts +146 -0
  599. package/src/modules/tool-helpers/__tests__/validate-token.test.ts +75 -0
  600. package/src/modules/tool-helpers/index.ts +18 -0
  601. package/src/modules/tool-helpers/tool-context.ts +98 -0
  602. package/src/modules/tool-helpers/tool-def.ts +73 -0
  603. package/src/modules/tool-helpers/tool-handler.ts +91 -0
  604. package/src/modules/tool-helpers/tool.contract.ts +85 -0
  605. package/src/modules/tool-helpers/validate-token.ts +50 -0
  606. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +200 -0
  607. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +499 -0
  608. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +156 -0
  609. package/src/modules/tool-manuals/index.ts +12 -0
  610. package/src/modules/tool-manuals/tool-manuals.contract.ts +183 -0
  611. package/src/modules/tool-manuals/tool-manuals.routes.ts +110 -0
  612. package/src/modules/tool-manuals/tool-manuals.service.ts +781 -0
  613. package/src/modules/tool-manuals/tool-manuals.tools.ts +202 -0
  614. package/src/modules/tool-registry/__tests__/tool-registry.test.ts +64 -0
  615. package/src/modules/tool-registry/manual.routes.ts +87 -0
  616. package/src/modules/tool-registry/tool-registry.ts +55 -0
  617. package/src/modules/tool-registry/tool.contract.ts +52 -0
  618. package/src/modules/workflow/__tests__/event-bus.test.ts +322 -0
  619. package/src/modules/workflow/__tests__/file-change-notifier.test.ts +51 -0
  620. package/src/modules/workflow/__tests__/format-affected-owners.test.ts +67 -0
  621. package/src/modules/workflow/__tests__/locking-filesystem.test.ts +445 -0
  622. package/src/modules/workflow/__tests__/pending-commits.canonical.test.ts +36 -0
  623. package/src/modules/workflow/__tests__/pending-commits.worker.test.ts +243 -0
  624. package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +308 -0
  625. package/src/modules/workflow/__tests__/sanitize-error.test.ts +67 -0
  626. package/src/modules/workflow/__tests__/session-ontology.policy.test.ts +62 -0
  627. package/src/modules/workflow/__tests__/session-ontology.service.test.ts +200 -0
  628. package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +232 -0
  629. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +380 -0
  630. package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +340 -0
  631. package/src/modules/workflow/agent-tools/__tests__/workflow.tools.test.ts +192 -0
  632. package/src/modules/workflow/agent-tools/workflow.tools.ts +502 -0
  633. package/src/modules/workflow/event-bus.ts +262 -0
  634. package/src/modules/workflow/events.routes.ts +266 -0
  635. package/src/modules/workflow/file-change-notifier.ts +54 -0
  636. package/src/modules/workflow/file-lock.service.ts +269 -0
  637. package/src/modules/workflow/git/__tests__/branch-name.test.ts +86 -0
  638. package/src/modules/workflow/git/__tests__/branchAuthor.test.ts +80 -0
  639. package/src/modules/workflow/git/__tests__/clone-config.test.ts +15 -0
  640. package/src/modules/workflow/git/__tests__/git-test-helpers.ts +47 -0
  641. package/src/modules/workflow/git/__tests__/git-version.failure.test.ts +24 -0
  642. package/src/modules/workflow/git/__tests__/git-version.test.ts +49 -0
  643. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +389 -0
  644. package/src/modules/workflow/git/__tests__/git.service.changedFilesForPr.test.ts +124 -0
  645. package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +105 -0
  646. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +211 -0
  647. package/src/modules/workflow/git/__tests__/git.service.createBranch.test.ts +154 -0
  648. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +320 -0
  649. package/src/modules/workflow/git/__tests__/git.service.diffFileAtCommit.test.ts +202 -0
  650. package/src/modules/workflow/git/__tests__/git.service.diffFileBetweenBranches.test.ts +453 -0
  651. package/src/modules/workflow/git/__tests__/git.service.list-branches.test.ts +198 -0
  652. package/src/modules/workflow/git/__tests__/git.service.listTrackedFiles.test.ts +125 -0
  653. package/src/modules/workflow/git/__tests__/git.service.logForFile.test.ts +219 -0
  654. package/src/modules/workflow/git/__tests__/git.service.mergeChangeRequest.test.ts +145 -0
  655. package/src/modules/workflow/git/__tests__/git.service.pendingChanges.test.ts +240 -0
  656. package/src/modules/workflow/git/__tests__/git.service.postMergePull.test.ts +272 -0
  657. package/src/modules/workflow/git/__tests__/git.service.pull.test.ts +232 -0
  658. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +101 -0
  659. package/src/modules/workflow/git/__tests__/git.service.status.test.ts +127 -0
  660. package/src/modules/workflow/git/__tests__/mutex.test.ts +78 -0
  661. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +225 -0
  662. package/src/modules/workflow/git/__tests__/pull-request.service.viewer-can-cancel.test.ts +101 -0
  663. package/src/modules/workflow/git/branch-name.ts +70 -0
  664. package/src/modules/workflow/git/clone-config.ts +71 -0
  665. package/src/modules/workflow/git/git-version.ts +70 -0
  666. package/src/modules/workflow/git/git.service.ts +2248 -0
  667. package/src/modules/workflow/git/mutex.ts +22 -0
  668. package/src/modules/workflow/git/pull-request.service.ts +447 -0
  669. package/src/modules/workflow/locking-filesystem.ts +489 -0
  670. package/src/modules/workflow/pending-commits.service.ts +444 -0
  671. package/src/modules/workflow/pending-commits.worker.ts +361 -0
  672. package/src/modules/workflow/read-only-filesystem.ts +54 -0
  673. package/src/modules/workflow/recovery-bot.ts +73 -0
  674. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +560 -0
  675. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +176 -0
  676. package/src/modules/workflow/review-workflow/__tests__/merge-gate.test.ts +258 -0
  677. package/src/modules/workflow/review-workflow/review-workflow.interface.ts +199 -0
  678. package/src/modules/workflow/review-workflow/review-workflow.service.ts +806 -0
  679. package/src/modules/workflow/sanitize-error.ts +62 -0
  680. package/src/modules/workflow/session-ontology.policy.ts +70 -0
  681. package/src/modules/workflow/session-ontology.service.ts +178 -0
  682. package/src/modules/workflow/workflow-hooks.ts +101 -0
  683. package/src/modules/workflow/workflow.errors.ts +285 -0
  684. package/src/modules/workflow/workflow.routes.ts +858 -0
  685. package/src/modules/workflow/workflow.service.ts +1555 -0
  686. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +213 -0
  687. package/src/modules/workspace/__tests__/routine-write-policy.test.ts +68 -0
  688. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -0
  689. package/src/modules/workspace/__tests__/spill-store.test.ts +43 -0
  690. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +233 -0
  691. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +176 -0
  692. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +303 -0
  693. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +276 -0
  694. package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +120 -0
  695. package/src/modules/workspace/__tests__/workspace.service.test.ts +565 -0
  696. package/src/modules/workspace/__tests__/workspace.tools.test.ts +504 -0
  697. package/src/modules/workspace/bevel-ignore.ts +62 -0
  698. package/src/modules/workspace/kb-seed.interface.ts +36 -0
  699. package/src/modules/workspace/kb-seed.service.ts +361 -0
  700. package/src/modules/workspace/routine-write-policy.ts +79 -0
  701. package/src/modules/workspace/session-ontology.gate.ts +189 -0
  702. package/src/modules/workspace/session-sink.ts +25 -0
  703. package/src/modules/workspace/spill-store.ts +87 -0
  704. package/src/modules/workspace/workspace.routes.ts +1114 -0
  705. package/src/modules/workspace/workspace.service.ts +1343 -0
  706. package/src/modules/workspace/workspace.tools.ts +885 -0
  707. package/src/shared/__tests__/frontmatter-id.test.ts +88 -0
  708. package/src/shared/__tests__/fs-walk.test.ts +42 -0
  709. package/src/shared/__tests__/ssrf.test.ts +53 -0
  710. package/src/shared/__tests__/utcp-namespace.test.ts +19 -0
  711. package/src/shared/frontmatter-id.ts +72 -0
  712. package/src/shared/fs-walk.ts +30 -0
  713. package/src/shared/hash-email.ts +14 -0
  714. package/src/shared/kb-layout.test.ts +88 -0
  715. package/src/shared/kb-layout.ts +101 -0
  716. package/src/shared/ssrf.ts +78 -0
  717. package/src/shared/token-crypto.ts +54 -0
  718. package/src/shared/ttl-cache.ts +27 -0
  719. package/src/shared/utcp-namespace.ts +88 -0
  720. package/src/version.ts +19 -0
@@ -0,0 +1,1987 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { AccessDeniedError } from '../../access/access-errors.js';
6
+ import { WorkspaceMutex } from './mutex.js';
7
+ import { cloneTrackingConfigArgs, SAFE_IMPLICIT_FETCH_ARGS } from './clone-config.js';
8
+ import { assertValidBranchName, assertValidRelativePath, isBranchAuthoredBy, isProtectedBranch, PROTECTED_BRANCHES, } from './branch-name.js';
9
+ import { BranchAuthorshipError, WorkflowValidationError, ProtectedBranchError, } from '../workflow.errors.js';
10
+ const execFileAsync = promisify(execFile);
11
+ function redact(msg) {
12
+ const token = process.env.GITHUB_TOKEN;
13
+ return token ? msg.replaceAll(token, '***') : msg;
14
+ }
15
+ /**
16
+ * Fallback committer identity. Every workflow commit overrides the author via
17
+ * `--author=…` so the real human shows up in `git log`; this is just the
18
+ * committer git records need. Mirrors the value `WorkspaceService` stamps on
19
+ * each clone — set again where an operation must not depend on that ambient
20
+ * config (e.g. the merge below).
21
+ */
22
+ const BOT_NAME = 'Bevel Workflow';
23
+ const BOT_EMAIL = 'bevel-workflow@bevel.software';
24
+ /**
25
+ * Git parses `--author="Name <email>"` by scanning for the last `<` and `>` and
26
+ * taking everything before the `<` as the name. Stray angle brackets, newlines, or
27
+ * carriage returns in either field break that parser or let a caller forge a
28
+ * different author line. Reject rather than try to escape.
29
+ */
30
+ /**
31
+ * Parse the NUL-delimited output of `git status --porcelain=v1 -z` into a
32
+ * deduplicated list of paths the next commit would include. For a rename /
33
+ * copy entry the record spans two NUL-separated fields (new-path, old-path);
34
+ * we keep the new path and discard the old one.
35
+ */
36
+ export function parsePorcelainZ(stdout) {
37
+ const tokens = stdout.split('\0').filter((t) => t.length > 0);
38
+ const paths = new Set();
39
+ let i = 0;
40
+ while (i < tokens.length) {
41
+ const token = tokens[i];
42
+ // Every entry is "XY <path>" — 2 status chars, a space, then the path.
43
+ if (token.length < 4) {
44
+ i++;
45
+ continue;
46
+ }
47
+ const x = token[0];
48
+ const y = token[1];
49
+ paths.add(token.slice(3));
50
+ // Rename or copy in either the index or worktree position carries an
51
+ // extra NUL-separated old-path field we must skip.
52
+ i += (x === 'R' || x === 'C' || y === 'R' || y === 'C') ? 2 : 1;
53
+ }
54
+ return Array.from(paths);
55
+ }
56
+ /**
57
+ * Default commit subject used when a caller (typically `LockingFilesystem`'s
58
+ * auto-commit on lock release) doesn't supply one. Must stay within the 200-
59
+ * char subject limit enforced just below — deep KB paths can themselves exceed
60
+ * 200 chars, so a naive `Update <path>` would trip the validator and the
61
+ * release would propagate the error back to the agent's tool call. Fall back
62
+ * to the basename, then hard-truncate as a last resort.
63
+ */
64
+ function buildDefaultCommitSummary(repoRelativePath) {
65
+ const full = `Update ${repoRelativePath}`;
66
+ if (full.length <= 200)
67
+ return full;
68
+ const basename = repoRelativePath.split('/').pop() ?? repoRelativePath;
69
+ const basenameSummary = `Update ${basename}`;
70
+ if (basenameSummary.length <= 200)
71
+ return basenameSummary;
72
+ return basenameSummary.slice(0, 199) + '…';
73
+ }
74
+ function assertValidAuthor(user) {
75
+ if (!user.name || !user.email) {
76
+ throw new WorkflowValidationError('commit author name and email are required');
77
+ }
78
+ if (/[\r\n<>]/.test(user.name)) {
79
+ throw new WorkflowValidationError('commit author name contains invalid characters');
80
+ }
81
+ if (/[\r\n<>\s]/.test(user.email) || !/^[^@\s]+@[^@\s]+$/.test(user.email)) {
82
+ throw new WorkflowValidationError('commit author email is not a valid address');
83
+ }
84
+ }
85
+ /**
86
+ * Cheap binary heuristic. Git's own `diff` treats a file as binary if it sees a
87
+ * NUL byte in the first ~8KB, and the same rule works here: text files don't
88
+ * contain NULs, binary formats almost always do early in the file.
89
+ */
90
+ function looksBinary(buf) {
91
+ const sample = buf.subarray(0, Math.min(buf.length, 8192));
92
+ return sample.indexOf(0) !== -1;
93
+ }
94
+ /**
95
+ * Matches `git diff`'s output for adding a new binary file — the UI just
96
+ * echoes the patch text, so this header renders as a neutral "can't preview"
97
+ * notice without needing special-case rendering in DiffView.
98
+ */
99
+ function synthesizeBinaryAddDiff(relativePath) {
100
+ return [
101
+ `diff --git a/${relativePath} b/${relativePath}`,
102
+ 'new file mode 100644',
103
+ `Binary files /dev/null and b/${relativePath} differ`,
104
+ '',
105
+ ].join('\n');
106
+ }
107
+ /**
108
+ * Produce a unified diff resembling `git diff --no-index /dev/null <path>` output
109
+ * so the UI can render an untracked file as "all added" using the same DiffView
110
+ * it uses for committed changes. No mode detection — new files get 100644.
111
+ */
112
+ function synthesizeNewFileDiff(relativePath, contents) {
113
+ const header = [
114
+ `diff --git a/${relativePath} b/${relativePath}`,
115
+ 'new file mode 100644',
116
+ '--- /dev/null',
117
+ `+++ b/${relativePath}`,
118
+ ].join('\n');
119
+ if (contents === '')
120
+ return `${header}\n`;
121
+ const endsWithNewline = contents.endsWith('\n');
122
+ // Drop the trailing empty string `split` introduces when the file ends with a newline
123
+ // so the line count matches what git itself would report in the hunk header.
124
+ const body = endsWithNewline ? contents.slice(0, -1) : contents;
125
+ const lines = body.split('\n');
126
+ const hunk = `@@ -0,0 +1,${lines.length} @@`;
127
+ const bodyText = lines.map((l) => `+${l}`).join('\n');
128
+ const trailer = endsWithNewline ? '' : '\n\';
129
+ return `${header}\n${hunk}\n${bodyText}${trailer}\n`;
130
+ }
131
+ /** Mirror of {@link synthesizeBinaryAddDiff} for the removed direction. */
132
+ function synthesizeBinaryRemoveDiff(relativePath) {
133
+ return [
134
+ `diff --git a/${relativePath} b/${relativePath}`,
135
+ 'deleted file mode 100644',
136
+ `Binary files a/${relativePath} and /dev/null differ`,
137
+ '',
138
+ ].join('\n');
139
+ }
140
+ /** Mirror of {@link synthesizeNewFileDiff} for when the working-tree file sits on the "from" side. */
141
+ function synthesizeDeletedFileDiff(relativePath, contents) {
142
+ const header = [
143
+ `diff --git a/${relativePath} b/${relativePath}`,
144
+ 'deleted file mode 100644',
145
+ `--- a/${relativePath}`,
146
+ '+++ /dev/null',
147
+ ].join('\n');
148
+ if (contents === '')
149
+ return `${header}\n`;
150
+ const endsWithNewline = contents.endsWith('\n');
151
+ const body = endsWithNewline ? contents.slice(0, -1) : contents;
152
+ const lines = body.split('\n');
153
+ const hunk = `@@ -1,${lines.length} +0,0 @@`;
154
+ const bodyText = lines.map((l) => `-${l}`).join('\n');
155
+ const trailer = endsWithNewline ? '' : '\n\';
156
+ return `${header}\n${hunk}\n${bodyText}${trailer}\n`;
157
+ }
158
+ /**
159
+ * Synthesize a diff for an untracked working-tree file when the current branch
160
+ * is one side of a branch-vs-branch comparison. `direction` says which side the
161
+ * working tree sits on: 'added' = working tree is the "to" side (all `+`),
162
+ * 'removed' = working tree is the "from" side (all `-`).
163
+ */
164
+ async function synthesizeUntrackedSideDiff(cwd, relativePath, direction) {
165
+ try {
166
+ const buf = await fs.readFile(path.join(cwd, relativePath));
167
+ if (looksBinary(buf)) {
168
+ return direction === 'added'
169
+ ? synthesizeBinaryAddDiff(relativePath)
170
+ : synthesizeBinaryRemoveDiff(relativePath);
171
+ }
172
+ const text = buf.toString('utf8');
173
+ return direction === 'added'
174
+ ? synthesizeNewFileDiff(relativePath, text)
175
+ : synthesizeDeletedFileDiff(relativePath, text);
176
+ }
177
+ catch (err) {
178
+ if (err.code === 'ENOENT')
179
+ return '';
180
+ throw err;
181
+ }
182
+ }
183
+ export class GitService {
184
+ workspaceService;
185
+ hooks;
186
+ kbDirName;
187
+ mutex;
188
+ accessControl;
189
+ // Per-workspace fetch state, kept OUTSIDE the workspace mutex on purpose.
190
+ // `git fetch` only writes `refs/remotes/origin/*` and packs new objects —
191
+ // it doesn't touch HEAD, the index, or the working tree, so it's safe to
192
+ // run alongside checkout/status/commit. Holding the mutex during a network
193
+ // round-trip used to park every other git op behind a slow origin (the
194
+ // worst case for branch-switching UX).
195
+ fetchLocks = new Map();
196
+ lastImplicitFetchAt = new Map();
197
+ static IMPLICIT_FETCH_TTL_MS = 5_000;
198
+ // Cap on concurrent `aheadBehindForBranch` calls inside `listBranches`. Each
199
+ // call serializes up to 3 `git rev-list` spawns, so 8 workers ≈ ≤24
200
+ // concurrent processes worst case — fast for typical (<8) feature-branch
201
+ // counts, calm under large branch lists.
202
+ static MAX_AHEAD_BEHIND_CONCURRENCY = 8;
203
+ constructor(workspaceService,
204
+ /**
205
+ * Workflow lifecycle hooks. This service only consults the ADVISORY
206
+ * `commitValidation` hooks (registered by the enterprise kb module in the
207
+ * composition root — the constructor-injected `IKbValidator` this
208
+ * replaced). No hooks registered = no commit-time validation, which is
209
+ * safe because validation is advisory and never blocks a commit.
210
+ */
211
+ hooks, kbDirName, mutex = new WorkspaceMutex(), accessControl = null) {
212
+ this.workspaceService = workspaceService;
213
+ this.hooks = hooks;
214
+ this.kbDirName = kbDirName;
215
+ this.mutex = mutex;
216
+ this.accessControl = accessControl;
217
+ }
218
+ /**
219
+ * "Does the pending-commits queue hold rows for this workspace?" — used by
220
+ * `statusInternal` to tell an expected dirty tree (queued saves still
221
+ * draining) from a genuinely orphaned one. Injected as a narrow probe via
222
+ * setter (not constructor) because the queue service is constructed after
223
+ * this one in the composition root; `null` (e.g. in tests) keeps the old
224
+ * always-warn behavior.
225
+ */
226
+ pendingCommitsProbe = null;
227
+ setPendingCommitsProbe(probe) {
228
+ this.pendingCommitsProbe = probe;
229
+ }
230
+ /**
231
+ * Mark a workspace as freshly fetched. Called by `WorkspaceService` right
232
+ * after a branch's clone is created — `git clone` already downloaded every
233
+ * ref, so the first `listBranches` can skip the redundant implicit
234
+ * `git fetch` (see `fetchOriginIfStale`).
235
+ */
236
+ noteWorkspaceFetched(workspaceId) {
237
+ this.lastImplicitFetchAt.set(workspaceId, Date.now());
238
+ }
239
+ /**
240
+ * Run the registered ADVISORY commit-validation hooks at a commit site.
241
+ * Preserves the semantics of the injected validator this replaced: a
242
+ * `mustFix` report is logged (via `formatWarning`) but never blocks, and a
243
+ * hook that throws is caught and logged — we'd rather land the commit than
244
+ * refuse the save because a validator crashed. Each hook gets its own
245
+ * try/catch so one crashing hook can't silence another.
246
+ */
247
+ async runCommitValidationHooks(ctx, formatWarning) {
248
+ for (const hook of this.hooks.commitValidationHooks()) {
249
+ try {
250
+ const report = await hook(ctx);
251
+ if (report && report.mustFix.length > 0) {
252
+ console.warn(formatWarning(report));
253
+ }
254
+ }
255
+ catch (validatorErr) {
256
+ // Validator failure is non-fatal — advisory only.
257
+ console.warn('[git] validator crashed (advisory only, ignoring):', validatorErr instanceof Error ? validatorErr.message : validatorErr);
258
+ }
259
+ }
260
+ }
261
+ /**
262
+ * Resolve write permission for a list of repo-relative paths against the
263
+ * access tree at a specific ref. Throws `AccessDeniedError` for the first
264
+ * path the caller lacks `write` on, with the eligible-roles + users
265
+ * payload attached so the frontend can render a useful refusal message.
266
+ *
267
+ * Always uses an at-ref lookup (never the working tree) so a user can't
268
+ * grant themselves access by editing `roles.yaml` / `access.md` locally.
269
+ * Callers pass:
270
+ * - `HEAD` for commit / revert gates — the pre-operation state is the
271
+ * authoritative source for whether the user can write these paths.
272
+ * - `origin/<branch>` for push gates — the published state is the
273
+ * authoritative source; local rebases or commit-tree manipulation
274
+ * can't slip changes past this check.
275
+ *
276
+ * Bootstrap: if the ref doesn't carry usable config (no `roles.yaml` /
277
+ * malformed / first commit creating the system), `canWriteBatchAtRef`
278
+ * returns null and we treat that as "no rules in force yet" → allow.
279
+ * Once config exists at the ref, gating kicks in.
280
+ *
281
+ * `accessControl` is optional on the constructor so existing tests that
282
+ * don't exercise these paths can keep their stub WorkspaceService — when
283
+ * it's null we skip the check entirely (legacy behaviour, only the
284
+ * branch-name guards remain). Production wires it up via composition root.
285
+ */
286
+ async assertCanWriteAtRef(workspaceId, ref, userEmail, paths) {
287
+ if (!this.accessControl || paths.length === 0)
288
+ return;
289
+ const result = await this.accessControl.canWriteBatchAtRef(workspaceId, ref, userEmail, paths);
290
+ if (!result)
291
+ return; // bootstrap: no config at ref → default-allow
292
+ for (const p of paths) {
293
+ if (!result.get(p)) {
294
+ const eligible = await this.accessControl.eligibleWritersAtRef(workspaceId, ref, p);
295
+ throw new AccessDeniedError({
296
+ path: p,
297
+ eligibleRoles: eligible?.roles ?? [],
298
+ eligibleUsers: eligible?.users ?? [],
299
+ });
300
+ }
301
+ }
302
+ }
303
+ async status(workspaceId) {
304
+ return this.mutex.run(workspaceId, () => this.statusInternal(workspaceId));
305
+ }
306
+ async listBranches(workspaceId, opts = {}) {
307
+ const cwd = await this.repoDir(workspaceId);
308
+ // Run the fetch BEFORE entering the mutex so a slow origin can't block
309
+ // local-only git ops (checkout/status) on this workspace. The fetch has
310
+ // its own per-workspace lock + TTL, so concurrent listBranches callers
311
+ // share a single in-flight fetch instead of stacking up. `freshFetch`
312
+ // bypasses the TTL — used for user-initiated refreshes (opening the branch
313
+ // selector) so a draft another workspace just deleted is pruned right away
314
+ // instead of lingering up to one TTL window.
315
+ await this.fetchOriginIfStale(cwd, workspaceId, opts.freshFetch);
316
+ return this.mutex.run(workspaceId, async () => {
317
+ // Union local heads with origin's remote-tracking refs: a fresh per-user
318
+ // clone only has one local head (HEAD's default), so without this we'd hide
319
+ // target-company-state and every draft pushed by another user.
320
+ const { stdout } = await this.git(cwd, [
321
+ 'for-each-ref',
322
+ '--format=%(refname:short)%09%(refname)',
323
+ 'refs/heads',
324
+ 'refs/remotes/origin',
325
+ ]);
326
+ const buckets = new Map();
327
+ for (const line of stdout.split('\n')) {
328
+ const trimmed = line.trim();
329
+ if (!trimmed)
330
+ continue;
331
+ const tab = trimmed.indexOf('\t');
332
+ if (tab < 0)
333
+ continue;
334
+ const rawShort = trimmed.slice(0, tab);
335
+ const ref = trimmed.slice(tab + 1);
336
+ if (!rawShort || !ref)
337
+ continue;
338
+ // Skip symbolic refs up-front. Different git versions report
339
+ // `refs/remotes/origin/HEAD` as either `origin` (older) or `origin/HEAD`
340
+ // (modern) for `refname:short`, and a literal `HEAD` ref can also
341
+ // appear under `refs/heads/` in pathological repos.
342
+ if (rawShort === 'HEAD' || rawShort === 'origin' || rawShort === 'origin/HEAD')
343
+ continue;
344
+ const isLocal = ref.startsWith('refs/heads/');
345
+ const shortName = isLocal ? rawShort : rawShort.replace(/^origin\//, '');
346
+ if (!shortName)
347
+ continue;
348
+ // Final guard: the strip above turns `origin/HEAD` into `HEAD`. Even
349
+ // if a future git version lands us here, don't bucket it as a branch.
350
+ if (shortName === 'HEAD')
351
+ continue;
352
+ const existing = buckets.get(shortName);
353
+ if (!existing) {
354
+ buckets.set(shortName, {
355
+ shortName,
356
+ ref,
357
+ hasLocal: isLocal,
358
+ hasRemote: !isLocal,
359
+ });
360
+ continue;
361
+ }
362
+ // Prefer the local entry's ref when both exist — ahead/behind against the
363
+ // configured upstream is more meaningful than against origin directly.
364
+ if (isLocal && !existing.hasLocal) {
365
+ existing.ref = ref;
366
+ existing.hasLocal = true;
367
+ }
368
+ else if (!isLocal) {
369
+ existing.hasRemote = true;
370
+ }
371
+ }
372
+ const rows = Array.from(buckets.values()).sort((a, b) => {
373
+ if (a.hasLocal !== b.hasLocal)
374
+ return a.hasLocal ? -1 : 1;
375
+ return a.shortName.localeCompare(b.shortName);
376
+ });
377
+ // Compute ahead/behind for every branch with bounded concurrency — each
378
+ // branch is 1–3 `git rev-list` spawns, and the unbounded `Promise.all`
379
+ // we used before could put N×3 processes in flight on repos with many
380
+ // branches. Workers pull indices off a shared counter and write into a
381
+ // pre-sized array, so input order (and the sort above) is preserved.
382
+ const infos = new Array(rows.length);
383
+ let nextIndex = 0;
384
+ const workerCount = Math.min(GitService.MAX_AHEAD_BEHIND_CONCURRENCY, rows.length);
385
+ await Promise.all(Array.from({ length: workerCount }, async () => {
386
+ while (true) {
387
+ const i = nextIndex++;
388
+ if (i >= rows.length)
389
+ return;
390
+ const row = rows[i];
391
+ const { ahead, behind } = await this.aheadBehindForBranch(cwd, row.shortName, row.ref, row.hasLocal);
392
+ infos[i] = {
393
+ name: row.shortName,
394
+ isProtected: isProtectedBranch(row.shortName),
395
+ ahead,
396
+ behind,
397
+ hasRemote: row.hasRemote,
398
+ };
399
+ }
400
+ }));
401
+ return infos;
402
+ });
403
+ }
404
+ /**
405
+ * Run `git fetch --prune origin` outside the workspace mutex, with a short
406
+ * TTL so back-to-back callers (mount + dropdown-open + 30s poll) coalesce
407
+ * onto a single in-flight fetch instead of each waiting their turn behind
408
+ * the network round-trip. Failures (offline, bad auth, no remote) are
409
+ * logged and swallowed so the branch list still serves stale local data
410
+ * instead of returning 500.
411
+ *
412
+ * Safe to keep outside the mutex: fetch only writes remote-tracking refs
413
+ * and the object store, neither of which checkout/status/commit touch.
414
+ */
415
+ async fetchOriginIfStale(cwd, workspaceId, force = false) {
416
+ const last = this.lastImplicitFetchAt.get(workspaceId) ?? 0;
417
+ if (!force && Date.now() - last < GitService.IMPLICIT_FETCH_TTL_MS)
418
+ return;
419
+ const inFlight = this.fetchLocks.get(workspaceId);
420
+ if (inFlight) {
421
+ // Another caller is already fetching for this workspace — share its
422
+ // result rather than firing a redundant network round-trip. Awaiting
423
+ // the same promise also means subsequent listBranches calls see the
424
+ // newly fetched refs.
425
+ await inFlight;
426
+ return;
427
+ }
428
+ const runFetch = async () => {
429
+ try {
430
+ // http.lowSpeedLimit/Time abort the fetch if the transfer stalls below
431
+ // 1KB/s for 10 seconds — prevents a hung origin from parking us
432
+ // forever and starving the TTL refresh.
433
+ await this.git(cwd, [
434
+ '-c', 'http.lowSpeedLimit=1000',
435
+ '-c', 'http.lowSpeedTime=10',
436
+ // This driver runs OUTSIDE the workspace mutex (see the caller), so
437
+ // it may only run the safe implicit-fetch shape — see
438
+ // `SAFE_IMPLICIT_FETCH_ARGS` in `clone-config.ts` for the rationale.
439
+ ...SAFE_IMPLICIT_FETCH_ARGS,
440
+ ]);
441
+ this.lastImplicitFetchAt.set(workspaceId, Date.now());
442
+ }
443
+ catch (err) {
444
+ const msg = err instanceof Error ? err.message : String(err);
445
+ console.warn(`[git] implicit fetch failed for workspace ${workspaceId}: ${msg}`);
446
+ // Stamp the TTL on failure too so a hard-down origin doesn't make
447
+ // every subsequent listBranches retry the (still-failing) fetch and
448
+ // pile up 10s timeouts. Stale local refs are better than spinning.
449
+ this.lastImplicitFetchAt.set(workspaceId, Date.now());
450
+ }
451
+ };
452
+ const p = runFetch();
453
+ this.fetchLocks.set(workspaceId, p);
454
+ try {
455
+ await p;
456
+ }
457
+ finally {
458
+ // Drop the lock once the fetch settles so the next caller past the TTL
459
+ // can spawn a fresh one. Compare-and-delete in case a future race
460
+ // somehow swapped a different promise in.
461
+ if (this.fetchLocks.get(workspaceId) === p) {
462
+ this.fetchLocks.delete(workspaceId);
463
+ }
464
+ }
465
+ }
466
+ async createBranch(workspaceId, name, fromBase) {
467
+ assertValidBranchName(name);
468
+ if (isProtectedBranch(name)) {
469
+ throw new ProtectedBranchError(name, 'creating a protected branch');
470
+ }
471
+ if (fromBase)
472
+ assertValidBranchName(fromBase);
473
+ return this.mutex.run(workspaceId, async () => {
474
+ const cwd = await this.repoDir(workspaceId);
475
+ // With no fromBase, git branches from HEAD — i.e. whichever branch the
476
+ // user has currently checked out.
477
+ const args = fromBase ? ['branch', name, fromBase] : ['branch', name];
478
+ await this.git(cwd, args);
479
+ // Publish to origin immediately. The per-branch workspace bootstrap
480
+ // (`WorkspaceService.getOrCreateForBranch`) shells out to
481
+ // `git clone -b <name>` against origin — without this push the next
482
+ // navigation to the new draft's URL fails with
483
+ // "Remote branch <name> not found in upstream origin". Roll the local
484
+ // ref back on failure so a retry isn't blocked by "branch already exists".
485
+ try {
486
+ await this.git(cwd, ['push', '-u', 'origin', name]);
487
+ }
488
+ catch (err) {
489
+ await this.git(cwd, ['branch', '-D', name]).catch(() => undefined);
490
+ throw err;
491
+ }
492
+ const { ahead, behind } = await this.aheadBehindForBranch(cwd, name, `refs/heads/${name}`, true);
493
+ // Drop the now-vestigial local head. Under the per-branch workspace model
494
+ // this clone's identity is its OWN branch; the new draft lives in its own
495
+ // workspace (cloned from origin on first navigation), so this clone never
496
+ // checks `name` out again. Leaving a local `refs/heads/<name>` here means
497
+ // that when the draft is later deleted from a DIFFERENT workspace (which
498
+ // removes it from origin and from that clone), this clone's local head
499
+ // survives `fetch --prune` — `prune` only drops remote-tracking refs — and
500
+ // the draft resurfaces in the picker as a phantom orphan that "won't
501
+ // delete". Removing it now keeps the draft as a pure remote-tracking ref,
502
+ // which prune cleans up like any other. The push already published it to
503
+ // origin, so the BranchInfo below still reports `hasRemote: true`.
504
+ await this.git(cwd, ['branch', '-D', name]).catch(() => undefined);
505
+ // `isCurrent` removed from BranchInfo with the per-branch workspace
506
+ // migration — the workspace's identity IS the current branch, so
507
+ // consumers derive it from the workspaceId. No need to call
508
+ // `currentBranch(cwd)` here anymore either.
509
+ return {
510
+ name,
511
+ isProtected: false,
512
+ ahead,
513
+ behind,
514
+ hasRemote: true,
515
+ };
516
+ });
517
+ }
518
+ // `switchBranch` removed. Under the per-branch workspace model
519
+ // (`workspaces/<encodeURIComponent(branch)>/`) the active branch IS the
520
+ // workspace's identity — there is no "check out a different branch on this
521
+ // workspace's clone" operation. Switching branches in the UI navigates to
522
+ // a different workspace; the destination workspace's clone is already on
523
+ // its branch by construction (`WorkspaceService.getOrCreateForBranch`).
524
+ //
525
+ // Two consequences:
526
+ // - The legacy `DirtyWorkingTreeError` gate this method used to throw
527
+ // can no longer surface as a user-facing 409.
528
+ // - The diff-backup reseed that used to fire here is unnecessary: a
529
+ // fresh per-branch workspace gets its backup seeded by the workspace
530
+ // bootstrap path, not by a branch switch on an existing one.
531
+ async deleteBranch(workspaceId, name, user, opts = {}) {
532
+ assertValidBranchName(name);
533
+ if (isProtectedBranch(name)) {
534
+ throw new ProtectedBranchError(name, 'deleting a protected branch');
535
+ }
536
+ return this.mutex.run(workspaceId, async () => {
537
+ const cwd = await this.repoDir(workspaceId);
538
+ const current = await this.currentBranch(cwd);
539
+ if (current === name) {
540
+ throw new WorkflowValidationError(`Cannot delete "${name}" while it's the checked-out branch.`);
541
+ }
542
+ if (opts.onlyIfNoRemote) {
543
+ // Legacy orphan-cleanup path (PR merged + remote head pruned). Skip
544
+ // the authorship check — callers prune any orphan they encounter,
545
+ // not just their own — but keep the "refuse if origin still has the
546
+ // ref" safety: a present remote means the branch is still live.
547
+ if (await this.refExists(cwd, `refs/remotes/origin/${name}`)) {
548
+ throw new WorkflowValidationError(`Branch "${name}" still exists on origin — refusing to auto-prune.`);
549
+ }
550
+ }
551
+ else {
552
+ // Authored-or-admin delete path. The branch's author (per the
553
+ // `<email-localpart>/...` naming convention — see `isBranchAuthoredBy`)
554
+ // can always delete their own draft. Admins (per the workspace's
555
+ // `roles.yaml`) can additionally clean up anyone's branch, including
556
+ // unprefixed CLI branches like `fix/...` that have no recognisable
557
+ // author. We detect admin via `canWrite(_, _, 'roles.yaml')` because
558
+ // that predicate is the existing source of truth for `Admin`-role
559
+ // membership inside the access model — no separate `isAdmin()` plumbing
560
+ // needed.
561
+ const isAuthor = isBranchAuthoredBy(name, user.email);
562
+ const isAdmin = this.accessControl
563
+ ? await this.accessControl.canWrite(workspaceId, user.email, 'roles.yaml')
564
+ : false;
565
+ if (!isAuthor && !isAdmin) {
566
+ throw new BranchAuthorshipError(name);
567
+ }
568
+ }
569
+ // Local delete. `-D` force-deletes even if the branch isn't "fully
570
+ // merged" from git's POV — squash-merged PRs leave a local branch whose
571
+ // commits don't appear on origin/<base> verbatim, but the changes are
572
+ // in fact merged. Force is the right default for our workflow.
573
+ //
574
+ // Skip when no local ref exists — the picker can surface branches that
575
+ // exist only as `refs/remotes/origin/<name>` (the user hasn't worked on
576
+ // them locally), and "discard from origin" must still work for those.
577
+ if (await this.refExists(cwd, `refs/heads/${name}`)) {
578
+ await this.git(cwd, ['branch', '-D', name]);
579
+ }
580
+ // Remote delete. Skip in `onlyIfNoRemote` mode (the contract is
581
+ // local-only cleanup) and skip when there's no remote ref to delete.
582
+ // A push failure here surfaces to the caller — the local ref is
583
+ // already gone, and we don't try to resurrect it because rolling back
584
+ // a "merged-into-the-deleted-branch" local commit would be even more
585
+ // confusing than the half-finished state.
586
+ if (!opts.onlyIfNoRemote
587
+ && await this.refExists(cwd, `refs/remotes/origin/${name}`)) {
588
+ await this.git(cwd, ['push', 'origin', '--delete', name]);
589
+ }
590
+ });
591
+ }
592
+ /**
593
+ * True iff the given ref (e.g. `refs/heads/foo`, `refs/remotes/origin/foo`)
594
+ * exists in this workspace's clone. Wraps `git show-ref --verify --quiet`
595
+ * — exits 0 when the ref is present, 1 with no stderr when it's missing.
596
+ * Any other exit code / stderr means a real failure and is rethrown so
597
+ * "ref not found" can't silently mask filesystem / repo corruption.
598
+ *
599
+ * `this.git()` wraps execFile failures and stores the original exit code on
600
+ * `wrapped.exitCode` (not `.code`) — check the wrapped property name, or
601
+ * the "ref not found" path silently becomes "rethrow every show-ref error".
602
+ */
603
+ async refExists(cwd, ref) {
604
+ try {
605
+ await this.git(cwd, ['show-ref', '--verify', '--quiet', ref]);
606
+ return true;
607
+ }
608
+ catch (err) {
609
+ const exitCode = err?.exitCode;
610
+ const stderr = err?.stderr ?? '';
611
+ const refNotFound = exitCode === 1 && stderr.trim() === '';
612
+ if (refNotFound)
613
+ return false;
614
+ throw err;
615
+ }
616
+ }
617
+ // `forkCurrentToDraft` removed: under the per-branch workspace model, every
618
+ // branch IS its own workspace by construction. The "stuck on protected
619
+ // branch with dirty edits" escape hatch this method implemented can no
620
+ // longer occur — save=share auto-commits each write, and the user can
621
+ // never end up with uncommitted work that needs to be carried onto a new
622
+ // draft. Creating a draft is just `createBranch` + URL navigation to the
623
+ // new branch's workspace.
624
+ // `discardChanges` removed: under save=share, the working tree is never
625
+ // dirty by design — every write goes through `LockingFilesystem`, which
626
+ // auto-commits on lock release. There's nothing to discard. The method
627
+ // was only meaningful when users could accumulate uncommitted edits
628
+ // between explicit save actions.
629
+ async commit(workspaceId, user, req) {
630
+ const subject = req.summary?.trim();
631
+ if (!subject)
632
+ throw new WorkflowValidationError('commit summary is required');
633
+ if (subject.length > 200) {
634
+ throw new WorkflowValidationError('commit summary must be ≤ 200 characters');
635
+ }
636
+ if (subject.includes('\n')) {
637
+ throw new WorkflowValidationError('commit summary must be a single line');
638
+ }
639
+ assertValidAuthor(user);
640
+ return this.mutex.run(workspaceId, async () => {
641
+ const cwd = await this.repoDir(workspaceId);
642
+ const branch = await this.currentBranch(cwd);
643
+ // One-change-per-file invariant from PLAN.md: each commit must touch
644
+ // exactly one path. Empty folders are represented via `.gitkeep`, so
645
+ // "creating a folder" is also one file. We compute the touched set
646
+ // unconditionally (the protected-branch gate below reuses it) and
647
+ // refuse anything other than exactly one path before any access
648
+ // check runs — keeps the error message stable across permission
649
+ // outcomes.
650
+ const { stdout: porcelain } = await this.git(cwd, [
651
+ 'status', '--porcelain=v1', '-z',
652
+ ]);
653
+ const touched = parsePorcelainZ(porcelain);
654
+ if (touched.length === 0) {
655
+ throw new WorkflowValidationError('No pending changes to commit', {
656
+ kind: 'no-pending-changes',
657
+ });
658
+ }
659
+ if (touched.length > 1) {
660
+ throw new WorkflowValidationError(`Each change must affect exactly one file. ${touched.length} files are currently pending: ${touched.join(', ')}. Commit them one at a time.`, { kind: 'too-many-files-dirty', paths: touched });
661
+ }
662
+ // Gate by access-control rules — but ONLY on protected branches.
663
+ // Feature/draft branches are free-for-all: anyone can commit anything
664
+ // because nothing canonical changes until a CR merges, and the CR's
665
+ // approval gate (`origin/<base>` access tree) is the real security
666
+ // boundary. Without this guard, the standard "fork to draft then
667
+ // propose" workflow would 403 for non-admins — they can't write
668
+ // protected, can't write the draft they forked from it either.
669
+ if (isProtectedBranch(branch)) {
670
+ // Gate against the access tree as it exists at HEAD — the pre-commit
671
+ // state. Reading from the working tree would let the user broaden
672
+ // their own access by editing access.md alongside the change they're
673
+ // trying to push through.
674
+ await this.assertCanWriteAtRef(workspaceId, 'HEAD', user.email, touched);
675
+ }
676
+ // Validation is **advisory**, not a gate. We still run the registered
677
+ // commit-validation hooks (the report surfaces in logs + can drive UI
678
+ // warnings later), but a `mustFix` result no longer blocks the commit —
679
+ // the user's right to save their work shouldn't depend on the rest of
680
+ // the KB being clean.
681
+ await this.runCommitValidationHooks({ workspaceId, branch, paths: touched }, (report) => `[git] commit on workspace=${workspaceId} branch=${branch} landing with ${report.mustFix.length} unresolved KB validator issue(s); the editor surfaces them but commit is no longer blocked.`);
682
+ await this.git(cwd, ['add', '-A']);
683
+ const args = [
684
+ 'commit',
685
+ `--author=${user.name} <${user.email}>`,
686
+ '-m',
687
+ subject,
688
+ ];
689
+ if (req.description && req.description.trim()) {
690
+ args.push('-m', req.description.trim());
691
+ }
692
+ await this.git(cwd, args);
693
+ const { stdout } = await this.git(cwd, [
694
+ 'log',
695
+ '-1',
696
+ '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
697
+ ]);
698
+ const [sha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
699
+ // The commit may have changed roles.yaml or access.md — drop cached state.
700
+ this.accessControl?.invalidate(workspaceId);
701
+ return {
702
+ sha: sha?.trim() ?? '',
703
+ authorName: authorName ?? '',
704
+ authorEmail: authorEmail ?? '',
705
+ subject: subj ?? subject,
706
+ committedAt: committedAt?.trim() ?? new Date().toISOString(),
707
+ };
708
+ });
709
+ }
710
+ /**
711
+ * Commit a single path — bypasses the one-file-per-change guard in
712
+ * `commit()` because the caller has already scoped the change to one
713
+ * path (typically the lock-release flow, where the lock itself
714
+ * guarantees the file is the only one whose edits this user owns).
715
+ *
716
+ * `git add <path>` then `git commit` — other dirty paths in the working
717
+ * tree are left alone. Validation + protected-branch access gating still
718
+ * run, since `path` could itself be `roles.yaml` / `access.md`.
719
+ *
720
+ * Returns `null` when the path has nothing to commit (no diff against
721
+ * HEAD, not untracked) — keeps lock-release idempotent on "I opened the
722
+ * editor but didn't actually change anything" flows.
723
+ */
724
+ /**
725
+ * Reset the file's working-tree state to HEAD — used when a write
726
+ * partially completed but the surrounding flow can't commit it
727
+ * (validator was advisory so this is rare, but op-throws + push-
728
+ * blocked-by-access-control still hit this path). Tracked files
729
+ * `git checkout HEAD --` reverts to the committed content; untracked
730
+ * files (a half-written new file) are deleted outright. Either way
731
+ * the working tree returns to a clean state so the next acquirer
732
+ * doesn't inherit half-finished bytes.
733
+ *
734
+ * Idempotent: a path that's already clean stays clean. Missing paths
735
+ * are silently ignored.
736
+ */
737
+ async discardPath(workspaceId, relativePath) {
738
+ assertValidRelativePath(relativePath);
739
+ const repoRelativePath = this.stripRepoPrefix(relativePath);
740
+ return this.mutex.run(workspaceId, async () => {
741
+ const cwd = await this.repoDir(workspaceId);
742
+ // Tracked-at-HEAD? `ls-files --error-unmatch` returns 0 if so.
743
+ let trackedAtHead = false;
744
+ try {
745
+ await this.git(cwd, ['ls-files', '--error-unmatch', '--', repoRelativePath]);
746
+ trackedAtHead = true;
747
+ }
748
+ catch {
749
+ trackedAtHead = false;
750
+ }
751
+ if (trackedAtHead) {
752
+ await this.git(cwd, ['checkout', 'HEAD', '--', repoRelativePath]).catch((err) => {
753
+ console.warn(`[git] discardPath checkout failed for "${repoRelativePath}":`, err instanceof Error ? err.message : err);
754
+ });
755
+ }
756
+ else {
757
+ // Untracked new file — remove from working tree.
758
+ const fileAbs = path.join(cwd, repoRelativePath);
759
+ await fs.rm(fileAbs, { force: true }).catch((err) => {
760
+ console.warn(`[git] discardPath rm failed for "${repoRelativePath}":`, err instanceof Error ? err.message : err);
761
+ });
762
+ }
763
+ });
764
+ }
765
+ async commitFile(workspaceId, user, relativePath, summary, skipValidator) {
766
+ assertValidRelativePath(relativePath);
767
+ assertValidAuthor(user);
768
+ const repoRelativePath = this.stripRepoPrefix(relativePath);
769
+ return this.mutex.run(workspaceId, async () => {
770
+ const cwd = await this.repoDir(workspaceId);
771
+ const branch = await this.currentBranch(cwd);
772
+ // Path-scoped status — is this specific file dirty? `--porcelain` on
773
+ // a single path returns empty when there's nothing to commit.
774
+ const { stdout: scoped } = await this.git(cwd, [
775
+ 'status', '--porcelain=v1', '--', repoRelativePath,
776
+ ]);
777
+ if (!scoped.trim())
778
+ return null;
779
+ // **No access check at commit time.** Per the "disk is the source of
780
+ // truth" rule, by the time content is on disk it must not be rejected.
781
+ // The permission check that used to live here now runs at lock
782
+ // acquisition (see `WorkflowService.acquireLock`'s
783
+ // `assertCanWriteAtPath`), so a user can't even start editing a file
784
+ // they lack write permission on — they never reach this commit. The
785
+ // legacy gate could destroy a user's edits when access changed
786
+ // mid-session; lock-time gating eliminates that destructive window.
787
+ //
788
+ // `GitService.commit` (the multi-file variant called by the agent's
789
+ // `commit_change` tool) still gates at commit time because it's used
790
+ // for files dirtied OUTSIDE the LockingFilesystem (e.g. agent
791
+ // manual-merge resolution), where no acquireLock ran.
792
+ // Validation is advisory; see `commit()` above for full rationale.
793
+ // `skipValidator` is set by callers that commit content which was
794
+ // already validated when it was first written — notably the reject
795
+ // flow, which re-commits a pre-agent baseline. Running the validator
796
+ // there is pure latency (it parses the whole KB and regenerates
797
+ // `diagram.md`) for a result that's neither enforced nor surfaced.
798
+ if (!skipValidator) {
799
+ await this.runCommitValidationHooks({ workspaceId, branch, paths: [repoRelativePath] }, (report) => `[git] commitFile on workspace=${workspaceId} branch=${branch} path=${repoRelativePath} landing with ${report.mustFix.length} unresolved KB validator issue(s); commit is no longer blocked.`);
800
+ }
801
+ await this.git(cwd, ['add', '--', repoRelativePath]);
802
+ const subject = summary?.trim() || buildDefaultCommitSummary(repoRelativePath);
803
+ if (subject.length > 200) {
804
+ throw new WorkflowValidationError('commit summary must be ≤ 200 characters');
805
+ }
806
+ if (subject.includes('\n')) {
807
+ throw new WorkflowValidationError('commit summary must be a single line');
808
+ }
809
+ await this.git(cwd, [
810
+ 'commit',
811
+ `--author=${user.name} <${user.email}>`,
812
+ '-m', subject,
813
+ ]);
814
+ const { stdout } = await this.git(cwd, [
815
+ 'log',
816
+ '-1',
817
+ '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
818
+ ]);
819
+ const [sha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
820
+ this.accessControl?.invalidate(workspaceId);
821
+ return {
822
+ sha: sha?.trim() ?? '',
823
+ authorName: authorName ?? '',
824
+ authorEmail: authorEmail ?? '',
825
+ subject: subj ?? subject,
826
+ committedAt: committedAt?.trim() ?? new Date().toISOString(),
827
+ };
828
+ });
829
+ }
830
+ /**
831
+ * Atomic multi-file commit — commits whatever is currently dirty in the
832
+ * working tree as ONE commit attributed to `user`, then returns its
833
+ * attribution (or null when the tree is clean — idempotent re-apply). The
834
+ * multi-file sibling of `commit()`: same `git add -A` + commit, but WITHOUT
835
+ * the one-file-per-change guard — the caller has assembled + written + validated
836
+ * a batch (bulk node upload; the role-rename rewrite via `LockingFilesystem`).
837
+ *
838
+ * Deliberately does NOT write file content itself — disk writes are the
839
+ * caller's job. This is the git layer: it only stages + commits what is already
840
+ * on disk (the working tree is expected otherwise-clean under save=share, so
841
+ * `git add -A` stages exactly the caller's batch). Caller pushes separately
842
+ * (mirrors `commitFile` + `push`).
843
+ */
844
+ async commitChanges(workspaceId, user, summary) {
845
+ assertValidAuthor(user);
846
+ const subject = summary?.trim();
847
+ if (!subject)
848
+ throw new WorkflowValidationError('commit summary is required');
849
+ if (subject.length > 200)
850
+ throw new WorkflowValidationError('commit summary must be ≤ 200 characters');
851
+ if (subject.includes('\n'))
852
+ throw new WorkflowValidationError('commit summary must be a single line');
853
+ return this.mutex.run(workspaceId, async () => {
854
+ const cwd = await this.repoDir(workspaceId);
855
+ const branch = await this.currentBranch(cwd);
856
+ // Nothing dirty → no-op (idempotent). Compute touched BEFORE `git add` so
857
+ // the protected-branch gate sees the same path set `commit()` would.
858
+ const { stdout: porcelain } = await this.git(cwd, ['status', '--porcelain=v1', '-z']);
859
+ const touched = parsePorcelainZ(porcelain);
860
+ if (touched.length === 0)
861
+ return null;
862
+ // Protected-branch access gate (defence in depth — callers already gate at
863
+ // lock-acquire / upload-mint, but this primitive is generic). Gate every
864
+ // touched path against the access tree at HEAD, like `commit()`.
865
+ if (isProtectedBranch(branch)) {
866
+ await this.assertCanWriteAtRef(workspaceId, 'HEAD', user.email, touched);
867
+ }
868
+ await this.git(cwd, ['add', '-A']);
869
+ await this.git(cwd, ['commit', `--author=${user.name} <${user.email}>`, '-m', subject]);
870
+ const { stdout } = await this.git(cwd, [
871
+ 'log', '-1', '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
872
+ ]);
873
+ const [sha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
874
+ this.accessControl?.invalidate(workspaceId);
875
+ return {
876
+ sha: sha?.trim() ?? '',
877
+ authorName: authorName ?? '',
878
+ authorEmail: authorEmail ?? '',
879
+ subject: subj ?? subject,
880
+ committedAt: committedAt?.trim() ?? new Date().toISOString(),
881
+ };
882
+ });
883
+ }
884
+ async push(workspaceId, user) {
885
+ return this.mutex.run(workspaceId, async () => {
886
+ const cwd = await this.repoDir(workspaceId);
887
+ const branch = await this.currentBranch(cwd);
888
+ // Access gate fires only when pushing to a protected branch. Pushing
889
+ // a feature/draft branch to its same-named remote can't change
890
+ // canonical state — the PR merge gate (`origin/<base>` access tree)
891
+ // is the real security boundary. Skipping the check here lets non-
892
+ // admins push their drafts freely so they can propose changes via
893
+ // PR. Gate against `origin/<branch>` — the published state — for
894
+ // protected pushes; that's defence-in-depth against local rebases or
895
+ // `commit-tree` shenanigans that might bypass the commit-time gate.
896
+ if (isProtectedBranch(branch)) {
897
+ const touched = await this.unpushedTouchedPaths(cwd);
898
+ await this.assertCanWriteAtRef(workspaceId, `origin/${branch}`, user.email, touched);
899
+ }
900
+ await this.git(cwd, ['push', '-u', 'origin', branch]);
901
+ });
902
+ }
903
+ // `forcePush` method removed. The workflow layer no longer auto-force-pushes
904
+ // as a recovery — when the cooperative `pull --rebase` + retry can't
905
+ // reconcile, we throw `PushNeedsAgentResolutionError` and hand off to the
906
+ // agent. The agent has git + gh CLI access and can choose to run a raw
907
+ // `git push --force-with-lease` itself when it has determined that's the
908
+ // correct call (i.e. it semantically merged + force-pushed, or determined
909
+ // the displacing commits were spurious). Removing the wrapper method
910
+ // prevents accidental reintroduction as an "easy fallback" — force-push
911
+ // belongs in the agent's tool surface (raw CLI), not the system's
912
+ // automatic-recovery path.
913
+ /**
914
+ * Attempt a local merge of `origin/<targetBranch>` into the currently-
915
+ * checked-out branch. Used by the workflow module to auto-merge target →
916
+ * source when a change request is opened or refreshed (PLAN §1).
917
+ *
918
+ * Two outcomes:
919
+ * - `{ kind: 'clean', alreadyUpToDate }` — merge succeeded, possibly as a
920
+ * no-op (`alreadyUpToDate: true`) or with a new merge commit attributed
921
+ * to `user` with a descriptive subject + body so reviewers can see why
922
+ * the merge landed without inspecting the diff.
923
+ * - `{ kind: 'conflicts', paths }` — `git merge` reported conflicts. The
924
+ * merge is aborted to leave the working tree clean before returning
925
+ * the conflicting paths so the caller can route to the agent for
926
+ * resolution.
927
+ *
928
+ * Requires the source branch to already be checked out and the working
929
+ * tree clean — both refused with structured errors before any git op runs.
930
+ */
931
+ async mergeFromOrigin(workspaceId, sourceBranch, targetBranch, user) {
932
+ assertValidBranchName(sourceBranch);
933
+ assertValidBranchName(targetBranch);
934
+ assertValidAuthor(user);
935
+ return this.mutex.run(workspaceId, async () => {
936
+ const cwd = await this.repoDir(workspaceId);
937
+ // Under the per-branch workspace model, the workspace's identity is
938
+ // the source branch — the legacy "current branch must equal sourceBranch"
939
+ // guard was an artifact of the single-workspace-many-branches model and
940
+ // is no longer needed. The legacy dirty-tree refusal is also gone:
941
+ // save=share guarantees the tree is clean before any auto-merge runs,
942
+ // and the only way it could be dirty now is an upstream bug, which
943
+ // `statusInternal` already logs server-side.
944
+ // Refresh the target ref so we merge against the latest published
945
+ // state. Failing the fetch here is non-recoverable for this
946
+ // operation: a stale `origin/<target>` would let us silently miss
947
+ // conflicts the next push would surface, and the whole point of
948
+ // `mergeFromOrigin` is to surface those conflicts now. Propagate
949
+ // the error so the auto-merge aborts cleanly instead of producing
950
+ // a merge based on a stale ref. Explicit destination refspec for the
951
+ // same reason as `mergeChangeRequest`: a bare `fetch origin <branch>`
952
+ // updates `origin/<branch>` only as far as `remote.origin.fetch` allows,
953
+ // and a drifted refspec would hand this merge the stale ref it is
954
+ // explicitly trying to avoid.
955
+ await this.git(cwd, [
956
+ 'fetch', '--no-write-fetch-head', 'origin',
957
+ `+refs/heads/${targetBranch}:refs/remotes/origin/${targetBranch}`,
958
+ ]);
959
+ // `--no-commit --no-ff` so we control the commit (need to override
960
+ // author) and so we never sneak a fast-forward past the merge-commit
961
+ // explanation gate.
962
+ let mergeStdout = '';
963
+ try {
964
+ const r = await this.git(cwd, [
965
+ 'merge', '--no-commit', '--no-ff', `origin/${targetBranch}`,
966
+ ]);
967
+ mergeStdout = r.stdout;
968
+ }
969
+ catch (err) {
970
+ // Conflict or other failure. Disambiguate via the shared porcelain parse.
971
+ const conflictPaths = await this.conflictedPaths(cwd);
972
+ // Always attempt to abort so a partial merge doesn't poison the tree.
973
+ try {
974
+ await this.git(cwd, ['merge', '--abort']);
975
+ }
976
+ catch {
977
+ // Already aborted (no MERGE_HEAD) — fine.
978
+ }
979
+ if (conflictPaths.length === 0)
980
+ throw err;
981
+ return { kind: 'conflicts', paths: conflictPaths };
982
+ }
983
+ // No conflicts. Detect "already up to date" — git prints that exact
984
+ // phrase and stages nothing. Otherwise the merge is staged and waiting
985
+ // for the commit we now author as `user`.
986
+ if (/Already up to date/i.test(mergeStdout)) {
987
+ return { kind: 'clean', alreadyUpToDate: true };
988
+ }
989
+ const subject = `Refresh ${sourceBranch} from ${targetBranch}`;
990
+ const body = `Workflow auto-merge: incorporates the latest ${targetBranch} into ${sourceBranch} ` +
991
+ `as part of opening or refreshing a change request. ` +
992
+ `Conflicts (if any) would have been surfaced to the caller before this commit landed; ` +
993
+ `none were detected, so the merge applied cleanly.`;
994
+ await this.git(cwd, [
995
+ 'commit',
996
+ `--author=${user.name} <${user.email}>`,
997
+ '-m', subject,
998
+ '-m', body,
999
+ ]);
1000
+ this.accessControl?.invalidate(workspaceId);
1001
+ return { kind: 'clean', alreadyUpToDate: false };
1002
+ });
1003
+ }
1004
+ /**
1005
+ * Merge a change request locally and publish it to the target branch — the
1006
+ * provider-agnostic replacement for `gh pr merge`. Runs in the *base* branch's
1007
+ * workspace: reset to the published base tip, merge the source's published
1008
+ * tip as a `--no-ff` commit authored by the human triggerer, and push.
1009
+ *
1010
+ * Serialized per base workspace via the mutex, with a bounded retry: if the
1011
+ * push is rejected because the base advanced (a concurrent merge), we reset to
1012
+ * the new base and re-merge on top of it. Conflicts are deterministic — they
1013
+ * return immediately (no retry) so the caller can route into resolution.
1014
+ *
1015
+ * Returns the merge commit SHA, or the conflicting paths.
1016
+ */
1017
+ async mergeChangeRequest(baseWorkspaceId, sourceBranch, targetBranch, commit, user) {
1018
+ assertValidBranchName(sourceBranch);
1019
+ assertValidBranchName(targetBranch);
1020
+ assertValidAuthor(user);
1021
+ const MAX_ATTEMPTS = 3;
1022
+ return this.mutex.run(baseWorkspaceId, async () => {
1023
+ const cwd = await this.repoDir(baseWorkspaceId);
1024
+ // `git merge`/`git commit` need a committer identity. Prod clones are
1025
+ // stamped by WorkspaceService, but stamp here too so the merge never
1026
+ // depends on that ambient config (it would fail "Committer identity
1027
+ // unknown" on a clone that lacks it).
1028
+ await this.git(cwd, ['config', 'user.name', BOT_NAME]);
1029
+ await this.git(cwd, ['config', 'user.email', BOT_EMAIL]);
1030
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
1031
+ // Explicit destination refspecs, because the two `origin/<branch>`
1032
+ // reads below MUST see the tips this fetch just retrieved. A bare
1033
+ // `fetch origin <branch>` only updates `refs/remotes/origin/<branch>`
1034
+ // *opportunistically* — via whatever `remote.origin.fetch` happens to
1035
+ // say — and this PR exists because that refspec drifts. Narrowed (or
1036
+ // dropped) it leaves `origin/<source>` stale, so the merge below lands
1037
+ // an old source tip on the base and every push attempt burns against
1038
+ // the same stale ref. Naming the destinations removes the dependency
1039
+ // on ambient config; both branches are `assertValidBranchName`-checked
1040
+ // at the top of this method.
1041
+ //
1042
+ // `--no-write-fetch-head`: a multi-ref fetch writes one for-merge
1043
+ // FETCH_HEAD entry PER ref — the exact shape that kills a concurrent
1044
+ // `git pull` in this clone (see `pull`). Only the `origin/<branch>`
1045
+ // refs below are read.
1046
+ await this.git(cwd, [
1047
+ 'fetch', '--no-write-fetch-head', 'origin',
1048
+ `+refs/heads/${targetBranch}:refs/remotes/origin/${targetBranch}`,
1049
+ `+refs/heads/${sourceBranch}:refs/remotes/origin/${sourceBranch}`,
1050
+ ]);
1051
+ // Match the published base tip exactly. Protected base branches never
1052
+ // carry local commits (save=share + no direct commits), so this only
1053
+ // discards a prior attempt's state, never real work.
1054
+ await this.git(cwd, ['checkout', targetBranch]);
1055
+ await this.git(cwd, ['reset', '--hard', `origin/${targetBranch}`]);
1056
+ // `--no-commit --no-ff` so we author the merge commit as the human and
1057
+ // never fast-forward past the merge record.
1058
+ try {
1059
+ await this.git(cwd, ['merge', '--no-commit', '--no-ff', `origin/${sourceBranch}`]);
1060
+ }
1061
+ catch (err) {
1062
+ const paths = await this.conflictedPaths(cwd);
1063
+ await this.git(cwd, ['merge', '--abort']).catch(() => undefined);
1064
+ if (paths.length > 0)
1065
+ return { kind: 'conflicts', paths };
1066
+ // Not a conflict — surface the underlying git error so the real cause
1067
+ // (e.g. missing identity, unrelated histories) is diagnosable.
1068
+ throw new Error(`git merge failed without detectable conflicts: ${redact(err instanceof Error ? err.message : String(err))}`);
1069
+ }
1070
+ // Nothing staged ⇒ base already contains source (empty CR). The base tip
1071
+ // is the "merged" state; report it without an empty commit.
1072
+ const { stdout: staged } = await this.git(cwd, ['diff', '--cached', '--name-only']);
1073
+ if (staged.trim() === '') {
1074
+ const { stdout: sha } = await this.git(cwd, ['rev-parse', 'HEAD']);
1075
+ await this.git(cwd, ['merge', '--abort']).catch(() => undefined);
1076
+ return { kind: 'merged', sha: sha.trim() };
1077
+ }
1078
+ await this.git(cwd, [
1079
+ 'commit',
1080
+ `--author=${user.name} <${user.email}>`,
1081
+ '-m', commit.subject,
1082
+ '-m', commit.body,
1083
+ ]);
1084
+ const { stdout: sha } = await this.git(cwd, ['rev-parse', 'HEAD']);
1085
+ try {
1086
+ await this.git(cwd, ['push', 'origin', `HEAD:refs/heads/${targetBranch}`]);
1087
+ this.accessControl?.invalidate(baseWorkspaceId);
1088
+ return { kind: 'merged', sha: sha.trim() };
1089
+ }
1090
+ catch (err) {
1091
+ const msg = err instanceof Error ? err.message : String(err);
1092
+ // Base moved under us — reset to the new tip and re-merge next loop.
1093
+ if (/non-fast-forward|\[rejected\]|fetch first/i.test(msg) && attempt < MAX_ATTEMPTS) {
1094
+ continue;
1095
+ }
1096
+ throw err;
1097
+ }
1098
+ }
1099
+ throw new Error(`merge push to "${targetBranch}" kept being rejected after ${MAX_ATTEMPTS} attempts (base moving concurrently)`);
1100
+ });
1101
+ }
1102
+ /** Conflicted paths from a half-done merge, parsed from porcelain status. */
1103
+ async conflictedPaths(cwd) {
1104
+ const { stdout } = await this.git(cwd, ['status', '--porcelain=v1']);
1105
+ const paths = [];
1106
+ for (const line of stdout.split('\n')) {
1107
+ if (/^(UU|AA|DD|AU|UA|DU|UD) /.test(line))
1108
+ paths.push(line.slice(3));
1109
+ }
1110
+ return paths;
1111
+ }
1112
+ /**
1113
+ * Paths changed in commits that exist locally but not on the upstream ref —
1114
+ * i.e. exactly what the next `push` would publish. Falls back to "every
1115
+ * commit not reachable from any remote ref" when there's no upstream yet
1116
+ * (first push).
1117
+ */
1118
+ async unpushedTouchedPaths(cwd) {
1119
+ let range;
1120
+ try {
1121
+ await this.git(cwd, ['rev-parse', '--verify', '--quiet', '@{u}']);
1122
+ range = ['@{u}..HEAD'];
1123
+ }
1124
+ catch {
1125
+ range = ['HEAD', '--not', '--remotes'];
1126
+ }
1127
+ const { stdout } = await this.git(cwd, [
1128
+ 'log', '--name-only', '--pretty=format:', ...range,
1129
+ ]);
1130
+ const set = new Set();
1131
+ for (const line of stdout.split('\n')) {
1132
+ const trimmed = line.trim();
1133
+ if (trimmed)
1134
+ set.add(trimmed);
1135
+ }
1136
+ return [...set];
1137
+ }
1138
+ async fetch(workspaceId) {
1139
+ return this.mutex.run(workspaceId, async () => {
1140
+ const cwd = await this.repoDir(workspaceId);
1141
+ // `--no-write-fetch-head`: callers want the remote-tracking refs, not
1142
+ // FETCH_HEAD, and leaving that shared file alone keeps this fetch from
1143
+ // steering a concurrent refresh in the same clone (see `pull`).
1144
+ await this.git(cwd, ['fetch', '--no-write-fetch-head', 'origin']);
1145
+ });
1146
+ }
1147
+ /**
1148
+ * Collapse the clone's drifted tracking config back to one fetch refspec and
1149
+ * one upstream ref, then refresh `refs/remotes/origin/<branch>` through an
1150
+ * explicit refspec — never touching the shared `FETCH_HEAD`. Returns the ref
1151
+ * the caller should operate on.
1152
+ *
1153
+ * Both halves matter, and neither is sufficient alone:
1154
+ *
1155
+ * - The self-heal. A multi-valued `branch.<name>.merge` makes every bare
1156
+ * fetch in this clone write more than one *for-merge* FETCH_HEAD entry,
1157
+ * which is the ammunition behind the failure below; a duplicated
1158
+ * `remote.origin.fetch` refspec is the same class of drift (see
1159
+ * `clone-config.ts`). `--replace-all` collapses either back to the single
1160
+ * value the clone was created with. Best effort — the fetch names its refs
1161
+ * explicitly, so a failure here is harmless.
1162
+ *
1163
+ * - Never reading or writing `FETCH_HEAD`. `git pull` is a two-step
1164
+ * operation — fetch, then read the rebase target out of `.git/FETCH_HEAD`
1165
+ * — and `FETCH_HEAD` is a single mutable file shared by every git process
1166
+ * in the clone. This service is not the only writer: `fetchOriginIfStale`
1167
+ * (outside the workspace mutex, see `listBranches`) and
1168
+ * `WorkspaceService.ensureRemotesFetched` (outside it entirely, driven by
1169
+ * the CR-list poll) both run bare `git fetch --prune origin`, and a bare
1170
+ * fetch writes one for-merge entry per `branch.<name>.merge` value. Let one
1171
+ * of those land in the window between the pull's own fetch and its read, on
1172
+ * a clone whose merge config has drifted to two values, and pull sees two
1173
+ * merge heads and dies with "Cannot rebase onto multiple branches" — the
1174
+ * reported failure. (Measured: ~40% of refreshes with one concurrent fetch
1175
+ * loop. The drifted config alone never breaks it — an explicit
1176
+ * `pull --rebase origin <branch>` survives every drift shape on its own.)
1177
+ *
1178
+ * So: fetch with an explicit destination refspec, which updates
1179
+ * `refs/remotes/origin/<branch>` regardless of any `remote.origin.fetch`
1180
+ * drift, and let the caller name that ref. `--no-write-fetch-head` (git
1181
+ * 2.29+, asserted at boot by `git-version.ts`) keeps this refresh from
1182
+ * perturbing anyone else's `FETCH_HEAD` in turn. Nothing here reads shared
1183
+ * mutable state, so no concurrent git process can steer or break it.
1184
+ *
1185
+ * Shared by the two refresh paths (`pull`, `resetToRemote`) so they can't
1186
+ * drift apart — which is precisely the failure class this guards against.
1187
+ */
1188
+ async refreshRemoteBranchRef(cwd, branch) {
1189
+ for (const args of cloneTrackingConfigArgs(branch)) {
1190
+ await this.git(cwd, args).catch(() => undefined);
1191
+ }
1192
+ await this.git(cwd, [
1193
+ 'fetch', '--no-write-fetch-head', 'origin',
1194
+ `+refs/heads/${branch}:refs/remotes/origin/${branch}`,
1195
+ ]);
1196
+ return `refs/remotes/origin/${branch}`;
1197
+ }
1198
+ async pull(workspaceId) {
1199
+ return this.mutex.run(workspaceId, async () => {
1200
+ const cwd = await this.repoDir(workspaceId);
1201
+ const branch = await this.currentBranch(cwd);
1202
+ try {
1203
+ // Refresh WITHOUT `git pull`, and without touching `FETCH_HEAD` — see
1204
+ // `refreshRemoteBranchRef` for why both halves are load-bearing.
1205
+ //
1206
+ // `--autostash` is load-bearing for the cooperative push recovery.
1207
+ // Under save=share the working tree is *supposed* to stay clean, but
1208
+ // in practice it can carry modified tracked artifacts (e.g. the
1209
+ // validator regenerates a dashboard HTML on every commit) that the
1210
+ // single-file commit didn't pick up. A rebase without it aborts
1211
+ // outright on those — "cannot rebase: You have unstaged
1212
+ // changes" — which is exactly the failure that strands a diverged
1213
+ // branch in an unrecoverable loop (the retry push stays non-fast-
1214
+ // forward forever). Autostash stashes those modifications, rebases
1215
+ // the local commits onto origin, then reapplies them, so the retry
1216
+ // push can fast-forward. Untracked files don't block rebase and are
1217
+ // left untouched.
1218
+ const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
1219
+ await this.git(cwd, ['rebase', '--autostash', remoteRef]);
1220
+ }
1221
+ catch (err) {
1222
+ // A failed rebase leaves the repo in a "REBASE_HEAD" / detached-apply state,
1223
+ // which makes every subsequent git command behave unexpectedly. Return the
1224
+ // working tree to a clean state before surfacing the original error.
1225
+ await this.git(cwd, ['rebase', '--abort']).catch(() => undefined);
1226
+ throw err;
1227
+ }
1228
+ this.accessControl?.invalidate(workspaceId);
1229
+ });
1230
+ }
1231
+ /**
1232
+ * Hard-reset the workspace's checked-out branch to `origin/<branch>`, fetching
1233
+ * first. This is the break-glass primitive behind roles.yaml recovery: it
1234
+ * makes the local clone EXACTLY match origin, discarding any local divergence
1235
+ * (e.g. a half-finished recovery commit from a prior failed push, or commits
1236
+ * the clone was behind on). The caller then writes its fix on top of the
1237
+ * current origin tip, so the follow-up push fast-forwards instead of being
1238
+ * rejected non-fast-forward.
1239
+ *
1240
+ * Destructive by design and intentionally NOT gated on the protected-branch
1241
+ * rule — it only ever moves the LOCAL ref to match origin (no remote mutation),
1242
+ * and recovery must work precisely on the protected default branch. Under
1243
+ * save=share there is no precious unpushed local work to lose (every save
1244
+ * auto-pushes); the one exception is a failed recovery commit, which the
1245
+ * caller re-creates.
1246
+ */
1247
+ async resetToRemote(workspaceId, branch) {
1248
+ assertValidBranchName(branch);
1249
+ return this.mutex.run(workspaceId, async () => {
1250
+ const cwd = await this.repoDir(workspaceId);
1251
+ // Same self-heal + FETCH_HEAD-free refresh as `pull`, then reset to the
1252
+ // remote-tracking ref it returns — never `FETCH_HEAD`. The stakes here
1253
+ // are higher than a failed refresh: this is a hard reset, so a raced
1254
+ // FETCH_HEAD would silently reset the workspace to whatever branch the
1255
+ // other fetch happened to write. Deliberately no rebase — a divergent
1256
+ // local commit can never conflict.
1257
+ const remoteRef = await this.refreshRemoteBranchRef(cwd, branch);
1258
+ await this.git(cwd, ['reset', '--hard', remoteRef]);
1259
+ this.accessControl?.invalidate(workspaceId);
1260
+ });
1261
+ }
1262
+ async resolveForkBase(workspaceId, branch) {
1263
+ assertValidBranchName(branch);
1264
+ return this.mutex.run(workspaceId, async () => {
1265
+ const cwd = await this.repoDir(workspaceId);
1266
+ let best = null;
1267
+ for (const p of PROTECTED_BRANCHES) {
1268
+ if (p === branch)
1269
+ continue;
1270
+ const res = await this.tryAheadBehind(cwd, branch, `origin/${p}`);
1271
+ if (res && (best === null || res.ahead < best.ahead)) {
1272
+ best = { base: p, ahead: res.ahead };
1273
+ }
1274
+ }
1275
+ return best?.base ?? null;
1276
+ });
1277
+ }
1278
+ /**
1279
+ * Count commits on `branch` that aren't on `baseBranch`. Identical to the
1280
+ * "ahead" half of `tryAheadBehind` but exposed as a public method because
1281
+ * background callers (the routine runner) need to gate "open a CR or
1282
+ * clean the branch up?" on it. Returns 0 when either ref can't be
1283
+ * resolved — a missing branch can't possibly be ahead of anything, and
1284
+ * misreporting as `> 0` would block legitimate cleanup.
1285
+ */
1286
+ async countCommitsAhead(workspaceId, branch, baseBranch) {
1287
+ assertValidBranchName(branch);
1288
+ assertValidBranchName(baseBranch);
1289
+ if (branch === baseBranch)
1290
+ return 0;
1291
+ return this.mutex.run(workspaceId, async () => {
1292
+ const cwd = await this.repoDir(workspaceId);
1293
+ // Compare against the *remote* base — the routine flow pushes commits
1294
+ // to origin as they're made, and matching here against the same ref the
1295
+ // CR creator uses (`origin/<base>`) keeps the two readings consistent.
1296
+ const res = await this.tryAheadBehind(cwd, branch, `origin/${baseBranch}`);
1297
+ return res?.ahead ?? 0;
1298
+ });
1299
+ }
1300
+ /**
1301
+ * Whether `head` and `origin/<base>` have any common ancestor. False when
1302
+ * `head` is an orphan branch or was created from an unrelated history —
1303
+ * GitHub refuses PRs in that state with a `no history in common` error,
1304
+ * so PR creation pre-flights this and hands off to the agent on false.
1305
+ */
1306
+ async haveSharedHistory(workspaceId, base, head) {
1307
+ assertValidBranchName(base);
1308
+ assertValidBranchName(head);
1309
+ return this.mutex.run(workspaceId, async () => {
1310
+ const cwd = await this.repoDir(workspaceId);
1311
+ try {
1312
+ const { stdout } = await this.git(cwd, [
1313
+ 'merge-base',
1314
+ `origin/${base}`,
1315
+ head,
1316
+ ]);
1317
+ return stdout.trim().length > 0;
1318
+ }
1319
+ catch (err) {
1320
+ // git merge-base exits 1 specifically for "no common ancestor" — the
1321
+ // very condition this method tests for. Any other failure (exit 128
1322
+ // for invalid args / corrupt repo, ENOENT for missing git binary,
1323
+ // etc.) is an infra problem that must bubble up rather than silently
1324
+ // masquerade as "no shared history".
1325
+ const exitCode = err.exitCode;
1326
+ if (exitCode === 1)
1327
+ return false;
1328
+ throw err;
1329
+ }
1330
+ });
1331
+ }
1332
+ async diffStat(workspaceId, base) {
1333
+ return this.mutex.run(workspaceId, async () => {
1334
+ const cwd = await this.repoDir(workspaceId);
1335
+ if (base)
1336
+ assertValidBranchName(base.replace(/^origin\//, ''));
1337
+ const against = base ?? '@{upstream}';
1338
+ try {
1339
+ // `git diff <ref>` (no dots) compares <ref> to the working tree, so
1340
+ // this counts uncommitted + committed changes that would land if the
1341
+ // caller committed and pushed right now — that's what the share
1342
+ // dialog previews. For PR creation the working tree is already clean
1343
+ // (the push just ran), so the answer matches the old three-dot form.
1344
+ const { stdout } = await this.git(cwd, ['diff', '--name-only', against]);
1345
+ return stdout.split('\n').map((l) => l.trim()).filter(Boolean);
1346
+ }
1347
+ catch {
1348
+ // No upstream / unknown base — nothing to diff against.
1349
+ return [];
1350
+ }
1351
+ });
1352
+ }
1353
+ async pendingChanges(workspaceId) {
1354
+ return this.mutex.run(workspaceId, async () => {
1355
+ const cwd = await this.repoDir(workspaceId);
1356
+ const { stdout } = await this.git(cwd, ['status', '--porcelain=v1', '-z']);
1357
+ return parsePorcelainZ(stdout);
1358
+ });
1359
+ }
1360
+ /**
1361
+ * Repo-relative paths of every VCS-tracked file (NUL-delimited, so paths with
1362
+ * spaces or bracketed prefixes survive intact). Untracked / ignored files are
1363
+ * excluded — the caller gets exactly the working tree's committed contents.
1364
+ */
1365
+ async listTrackedFiles(workspaceId) {
1366
+ return this.mutex.run(workspaceId, async () => {
1367
+ const cwd = await this.repoDir(workspaceId);
1368
+ const { stdout } = await this.git(cwd, ['ls-files', '-z']);
1369
+ return stdout.split('\0').filter(Boolean);
1370
+ });
1371
+ }
1372
+ async revertCommit(workspaceId, user, sha) {
1373
+ if (!/^[a-f0-9]{7,40}$/i.test(sha)) {
1374
+ throw new WorkflowValidationError('invalid commit sha');
1375
+ }
1376
+ assertValidAuthor(user);
1377
+ return this.mutex.run(workspaceId, async () => {
1378
+ const cwd = await this.repoDir(workspaceId);
1379
+ const branch = await this.currentBranch(cwd);
1380
+ // Revert creates a new commit reversing `sha`. Same model as commit:
1381
+ // gate only on protected branches; reverts on feature/draft branches
1382
+ // are free (they produce a commit that would still need to merge via
1383
+ // PR to affect canonical state).
1384
+ if (isProtectedBranch(branch)) {
1385
+ const { stdout: diffOut } = await this.git(cwd, [
1386
+ 'diff-tree', '--no-commit-id', '--name-only', '-r', sha,
1387
+ ]);
1388
+ const touched = diffOut.split('\n').map((s) => s.trim()).filter(Boolean);
1389
+ await this.assertCanWriteAtRef(workspaceId, 'HEAD', user.email, touched);
1390
+ }
1391
+ await this.git(cwd, [
1392
+ 'revert',
1393
+ '--no-edit',
1394
+ `--author=${user.name} <${user.email}>`,
1395
+ sha,
1396
+ ]);
1397
+ const { stdout } = await this.git(cwd, [
1398
+ 'log',
1399
+ '-1',
1400
+ '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
1401
+ ]);
1402
+ const [headSha, authorName, authorEmail, subj, committedAt] = stdout.split('\x00');
1403
+ // A revert may have touched roles.yaml or access.md — drop cached
1404
+ // access state so the next gate read reflects the rolled-back tree.
1405
+ // Mirrors the commit / pull paths.
1406
+ this.accessControl?.invalidate(workspaceId);
1407
+ return {
1408
+ sha: headSha?.trim() ?? '',
1409
+ authorName: authorName ?? '',
1410
+ authorEmail: authorEmail ?? '',
1411
+ subject: subj ?? '',
1412
+ committedAt: committedAt?.trim() ?? new Date().toISOString(),
1413
+ };
1414
+ });
1415
+ }
1416
+ async logForFile(workspaceId, relativePath, limit = 20) {
1417
+ assertValidRelativePath(relativePath);
1418
+ const max = Math.max(1, Math.min(limit, 100));
1419
+ const repoRelativePath = this.stripRepoPrefix(relativePath);
1420
+ return this.mutex.run(workspaceId, async () => {
1421
+ const cwd = await this.repoDir(workspaceId);
1422
+ const { stdout } = await this.git(cwd, [
1423
+ 'log',
1424
+ `--max-count=${max}`,
1425
+ '--pretty=format:%H%x00%an%x00%ae%x00%s%x00%aI',
1426
+ '--',
1427
+ repoRelativePath,
1428
+ ]);
1429
+ return stdout
1430
+ .split('\n')
1431
+ .map((line) => line.trim())
1432
+ .filter(Boolean)
1433
+ .map((line) => {
1434
+ const [sha, authorName, authorEmail, subject, committedAt] = line.split('\x00');
1435
+ return {
1436
+ sha: sha ?? '',
1437
+ authorName: authorName ?? '',
1438
+ authorEmail: authorEmail ?? '',
1439
+ subject: subject ?? '',
1440
+ committedAt: committedAt ?? '',
1441
+ };
1442
+ });
1443
+ });
1444
+ }
1445
+ async diffFileAtCommit(workspaceId, relativePath, sha) {
1446
+ assertValidRelativePath(relativePath);
1447
+ if (!/^[a-f0-9]{7,40}$/i.test(sha)) {
1448
+ throw new WorkflowValidationError('invalid commit sha');
1449
+ }
1450
+ const repoRelativePath = this.stripRepoPrefix(relativePath);
1451
+ return this.mutex.run(workspaceId, async () => {
1452
+ const cwd = await this.repoDir(workspaceId);
1453
+ try {
1454
+ const { stdout } = await this.git(cwd, [
1455
+ 'show',
1456
+ sha,
1457
+ '--',
1458
+ repoRelativePath,
1459
+ ]);
1460
+ return stdout;
1461
+ }
1462
+ catch (err) {
1463
+ const msg = err instanceof Error ? err.message : String(err);
1464
+ throw new Error(`Failed to execute git show for "${relativePath}" at "${sha}": ${msg}`, { cause: err });
1465
+ }
1466
+ });
1467
+ }
1468
+ async diffFileBetweenBranches(workspaceId, relativePath, fromBranch, toBranch) {
1469
+ assertValidRelativePath(relativePath);
1470
+ assertValidBranchName(fromBranch);
1471
+ assertValidBranchName(toBranch);
1472
+ if (fromBranch === toBranch) {
1473
+ throw new WorkflowValidationError('from and to branches must differ');
1474
+ }
1475
+ const repoRelativePath = this.stripRepoPrefix(relativePath);
1476
+ return this.mutex.run(workspaceId, async () => {
1477
+ const cwd = await this.repoDir(workspaceId);
1478
+ const fromRef = await this.resolveBranchRef(cwd, fromBranch);
1479
+ const toRef = await this.resolveBranchRef(cwd, toBranch);
1480
+ // When one side is the currently-checked-out branch, diff against the
1481
+ // working tree instead of the branch's HEAD commit. Otherwise saves
1482
+ // that haven't been committed yet (which is the normal state in the
1483
+ // share-changes flow) are invisible — the user just edited the file
1484
+ // and would see "identical" if we always compared committed states.
1485
+ const current = await this.currentBranch(cwd);
1486
+ const isToCurrent = toBranch === current;
1487
+ const isFromCurrent = fromBranch === current;
1488
+ // `git diff <ref> -- <path>` ignores untracked working-tree files, so a
1489
+ // brand-new file (created on disk but never committed) renders as
1490
+ // identical between branches. When the current branch is one side and
1491
+ // the path is untracked locally, synthesize an added/removed diff —
1492
+ // same pattern diffFileWorking uses for HEAD-vs-working-tree.
1493
+ if (isToCurrent !== isFromCurrent &&
1494
+ (await this.isPathUntracked(cwd, repoRelativePath))) {
1495
+ return synthesizeUntrackedSideDiff(cwd, repoRelativePath, isToCurrent ? 'added' : 'removed');
1496
+ }
1497
+ const args = ['diff', '--no-color'];
1498
+ if (isToCurrent && !isFromCurrent) {
1499
+ // `git diff <ref>` compares ref → working tree, with the working
1500
+ // tree's contents shown as `+`. That's exactly the +/- direction
1501
+ // we want when "to" is the current branch.
1502
+ args.push(fromRef);
1503
+ }
1504
+ else if (isFromCurrent && !isToCurrent) {
1505
+ // Reverse so the +/- direction matches "from current → to ref":
1506
+ // ref's contents show as `+`, working tree shows as `-`.
1507
+ args.push('-R', toRef);
1508
+ }
1509
+ else {
1510
+ // Neither side is the current branch — pure commit-vs-commit.
1511
+ // Two-dot, not three-dot: three-dot would be merge-base semantics
1512
+ // (PR-style) which would hide changes on the from-side since the
1513
+ // fork point — wrong for "compare these two versions right now".
1514
+ args.push(fromRef, toRef);
1515
+ }
1516
+ args.push('--', repoRelativePath);
1517
+ try {
1518
+ const { stdout } = await this.git(cwd, args);
1519
+ return stdout;
1520
+ }
1521
+ catch (err) {
1522
+ const msg = err instanceof Error ? err.message : String(err);
1523
+ throw new Error(`Failed to diff "${relativePath}" between "${fromBranch}" and "${toBranch}": ${msg}`, { cause: err });
1524
+ }
1525
+ });
1526
+ }
1527
+ /**
1528
+ * Head/base commit SHAs for a change request, computed locally — no provider
1529
+ * API. `head` is the source branch tip, `base` the target branch tip, both
1530
+ * resolved on `origin/*` after a fetch so a force-push is reflected. These are
1531
+ * what approvals pin against and what the diff range is built from.
1532
+ */
1533
+ async resolvePrShas(workspaceId, baseBranch, headBranch) {
1534
+ assertValidBranchName(baseBranch);
1535
+ assertValidBranchName(headBranch);
1536
+ // Fetch (a network round-trip) BEFORE taking the mutex so a slow origin can't
1537
+ // block unrelated local git ops on this workspace; the lock guards only the
1538
+ // local ref resolution + rev-parse below.
1539
+ const cwd = await this.repoDir(workspaceId);
1540
+ await this.fetchPrRefs(cwd, baseBranch, headBranch);
1541
+ return this.mutex.run(workspaceId, async () => {
1542
+ const baseRef = await this.resolveBranchRef(cwd, baseBranch);
1543
+ const headRef = await this.resolveBranchRef(cwd, headBranch);
1544
+ const [{ stdout: baseSha }, { stdout: headSha }] = await Promise.all([
1545
+ this.git(cwd, ['rev-parse', baseRef]),
1546
+ this.git(cwd, ['rev-parse', headRef]),
1547
+ ]);
1548
+ return { baseSha: baseSha.trim(), headSha: headSha.trim() };
1549
+ });
1550
+ }
1551
+ /**
1552
+ * The changed-file list for a change request, computed locally via three-dot
1553
+ * (merge-base) diff — the same semantics a GitHub PR shows. Replaces the
1554
+ * `gh api …/pulls/:n/files` call so any git host works.
1555
+ *
1556
+ * `--name-status` + `--numstat` (both rename-aware via `-M`) drive the file
1557
+ * list, statuses, and +/- counts; per-file patches are generated for up to
1558
+ * `patchCap` files (default 400) — beyond that `patch` is left undefined,
1559
+ * exactly like the old API's binary/oversized files (the UI renders those with
1560
+ * no inline diff). Patch generation is skipped for binary files.
1561
+ */
1562
+ async changedFilesForPr(workspaceId, baseBranch, headBranch, opts = {}) {
1563
+ assertValidBranchName(baseBranch);
1564
+ assertValidBranchName(headBranch);
1565
+ const patchCap = opts.patchCap ?? 400;
1566
+ // Fetch outside the mutex (network round-trip) so origin latency can't hold
1567
+ // the workspace lock; the lock guards only the local diff work below.
1568
+ const cwd = await this.repoDir(workspaceId);
1569
+ await this.fetchPrRefs(cwd, baseBranch, headBranch);
1570
+ return this.mutex.run(workspaceId, async () => {
1571
+ const baseRef = await this.resolveBranchRef(cwd, baseBranch);
1572
+ const headRef = await this.resolveBranchRef(cwd, headBranch);
1573
+ const range = `${baseRef}...${headRef}`; // three-dot = changes on head since merge-base
1574
+ const [{ stdout: nameStatusOut }, { stdout: numstatOut }] = await Promise.all([
1575
+ this.git(cwd, ['diff', '-M', '-z', '--name-status', range]),
1576
+ this.git(cwd, ['diff', '-M', '-z', '--numstat', range]),
1577
+ ]);
1578
+ const statuses = parseNameStatusZ(nameStatusOut);
1579
+ const counts = parseNumstatZ(numstatOut);
1580
+ // `--name-status` and `--numstat` enumerate the same files in the same
1581
+ // order (same `-M` over the same range), so we zip by index. If the two
1582
+ // ever disagree in length, the index alignment is unsafe — fall back to
1583
+ // zeroed counts (statuses/paths stay correct) rather than pin the wrong
1584
+ // +/- to a file.
1585
+ const aligned = counts.length === statuses.length;
1586
+ if (!aligned) {
1587
+ console.warn(`[cr] diff name-status/numstat length mismatch (${statuses.length} vs ${counts.length}) ` +
1588
+ `for ${range} — reporting file list without +/- counts`);
1589
+ }
1590
+ const files = statuses.map((s, i) => {
1591
+ const c = (aligned ? counts[i] : undefined) ?? { additions: 0, deletions: 0, isBinary: false };
1592
+ return {
1593
+ path: s.path,
1594
+ previousPath: s.previousPath,
1595
+ status: s.status,
1596
+ additions: c.additions,
1597
+ deletions: c.deletions,
1598
+ patch: undefined,
1599
+ isBinary: c.isBinary,
1600
+ sha: '',
1601
+ rawUrl: '',
1602
+ };
1603
+ });
1604
+ // Generate per-file patches for the first `patchCap` non-binary files.
1605
+ let generated = 0;
1606
+ for (const f of files) {
1607
+ if (generated >= patchCap)
1608
+ break;
1609
+ if (f.isBinary)
1610
+ continue;
1611
+ f.patch = await this.filePatchForPr(cwd, range, f);
1612
+ generated += 1;
1613
+ }
1614
+ return files;
1615
+ });
1616
+ }
1617
+ /**
1618
+ * Just the repo-relative paths changed by a change request (three-dot), no
1619
+ * patches — the cheap version used to build CR-list summaries and owner
1620
+ * routing. `changedFilesForPr` is the full version with statuses + diffs.
1621
+ */
1622
+ async changedPathsForPr(workspaceId, baseBranch, headBranch) {
1623
+ assertValidBranchName(baseBranch);
1624
+ assertValidBranchName(headBranch);
1625
+ // Fetch outside the mutex (network round-trip) so origin latency can't hold
1626
+ // the workspace lock; the lock guards only the local diff below.
1627
+ const cwd = await this.repoDir(workspaceId);
1628
+ await this.fetchPrRefs(cwd, baseBranch, headBranch);
1629
+ return this.mutex.run(workspaceId, async () => {
1630
+ const baseRef = await this.resolveBranchRef(cwd, baseBranch);
1631
+ const headRef = await this.resolveBranchRef(cwd, headBranch);
1632
+ const { stdout } = await this.git(cwd, [
1633
+ 'diff', '-M', '--name-only', `${baseRef}...${headRef}`,
1634
+ ]);
1635
+ return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
1636
+ });
1637
+ }
1638
+ /** Fetch the two branches a CR spans so origin refs reflect the latest push. */
1639
+ async fetchPrRefs(cwd, baseBranch, headBranch) {
1640
+ // Best-effort: a branch may be local-only (rare) or origin briefly
1641
+ // unreachable; `resolveBranchRef` still falls back to whatever is local.
1642
+ // `--no-write-fetch-head` — this runs on the CR-detail path (polled around
1643
+ // every merge) and only the `origin/<branch>` refs are read; writing two
1644
+ // for-merge FETCH_HEAD entries here can break a concurrent refresh in the
1645
+ // same clone (see `pull`).
1646
+ //
1647
+ // Explicit destination refspecs for the same reason as `mergeChangeRequest`:
1648
+ // "reflect the latest push" is exactly what a drifted `remote.origin.fetch`
1649
+ // silently stops delivering, and the CR diff would then be computed against
1650
+ // a stale head. Every caller `assertValidBranchName`s both branches.
1651
+ await this.git(cwd, [
1652
+ 'fetch', '--no-write-fetch-head', 'origin',
1653
+ `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`,
1654
+ `+refs/heads/${headBranch}:refs/remotes/origin/${headBranch}`,
1655
+ ]).catch(() => undefined);
1656
+ }
1657
+ /** Unified-diff patch for one file across the CR range (rename-aware). */
1658
+ async filePatchForPr(cwd, range, file) {
1659
+ // For a rename, pass both endpoints so `-M` renders it as a rename patch;
1660
+ // otherwise the single (destination, or base-side for deletes) path.
1661
+ const pathspec = file.previousPath && file.previousPath !== file.path
1662
+ ? ['--', file.previousPath, file.path]
1663
+ : ['--', file.path];
1664
+ try {
1665
+ const { stdout } = await this.git(cwd, ['diff', '-M', '--no-color', range, ...pathspec]);
1666
+ return stdout.length > 0 ? stdout : undefined;
1667
+ }
1668
+ catch {
1669
+ // A patch failure is cosmetic — the file still shows in the list.
1670
+ return undefined;
1671
+ }
1672
+ }
1673
+ // `workingStatus` + `diffFileWorking` removed: both reported the dirty
1674
+ // state of a workspace's working tree, which under save=share is always
1675
+ // empty. The frontend used these to render a "pending uncommitted edits"
1676
+ // panel and per-file diff — neither has a meaningful state to show
1677
+ // anymore. `diffFileBetweenBranches` + `diffFileAtCommit` still cover
1678
+ // the meaningful diff cases (cross-branch comparisons and history).
1679
+ /**
1680
+ * Strip the `knowledge-base/` prefix from a workspace-relative path so
1681
+ * it lines up with where git actually runs (`cwd = repoDir`, i.e. inside
1682
+ * `knowledge-base/`). The frontend stores `openFilePath` as a
1683
+ * workspace-relative path including that prefix because the file viewer
1684
+ * reads/writes through `WorkspaceService.readFile`, which resolves from
1685
+ * the workspace root. Git pathspecs, on the other hand, are resolved from
1686
+ * cwd — so without this normalization `git diff -- knowledge-base/X`
1687
+ * silently looks for `knowledge-base/knowledge-base/X` and returns
1688
+ * nothing, which the UI renders as "identical".
1689
+ *
1690
+ * No-op for paths already repo-relative (the agent tools and tests pass
1691
+ * those directly).
1692
+ */
1693
+ stripRepoPrefix(relativePath) {
1694
+ return relativePath.startsWith(`${this.kbDirName}/`)
1695
+ ? relativePath.slice(this.kbDirName.length + 1)
1696
+ : relativePath;
1697
+ }
1698
+ /**
1699
+ * Resolve a branch name to a concrete ref the local clone knows about.
1700
+ * Tries the local head first, falls back to the matching remote-tracking
1701
+ * ref. The workspace clone fetches all remotes on creation, so protected
1702
+ * branches are reachable through `origin/` even when the user has never
1703
+ * personally checked them out.
1704
+ */
1705
+ async resolveBranchRef(cwd, branch) {
1706
+ for (const ref of [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`]) {
1707
+ try {
1708
+ await this.git(cwd, ['rev-parse', '--verify', '--quiet', ref]);
1709
+ return ref;
1710
+ }
1711
+ catch {
1712
+ // Try the next candidate.
1713
+ }
1714
+ }
1715
+ throw new WorkflowValidationError(`unknown branch: ${branch}`);
1716
+ }
1717
+ // ── internals ──────────────────────────────────────────────────────────
1718
+ async statusInternal(workspaceId) {
1719
+ const cwd = await this.repoDir(workspaceId);
1720
+ const branch = await this.currentBranch(cwd);
1721
+ // Defensive sanity check. Under save=share a dirty working tree has two
1722
+ // very different meanings:
1723
+ // - Rows exist in `pending_commits` for this workspace → EXPECTED. The
1724
+ // saves are on disk and their commits are still draining through the
1725
+ // background worker (a bulk write can queue hundreds of rows, taking
1726
+ // minutes). Not a bug — log quietly for observability.
1727
+ // - No queued rows → files are dirty with nothing scheduled to commit
1728
+ // them: an upstream bug (lock-release crashed mid-write, an external
1729
+ // `git` invocation left state behind, etc.). Warn loudly.
1730
+ // Either way it's never surfaced to the user: the structured status no
1731
+ // longer carries an isDirty bit, which removes the only path by which a
1732
+ // transient working-tree blip used to leak into a user-facing 409.
1733
+ const porcelain = await this.git(cwd, ['status', '--porcelain=v1']);
1734
+ if (porcelain.stdout.trim().length > 0) {
1735
+ let queueExplainsIt = false;
1736
+ if (this.pendingCommitsProbe) {
1737
+ try {
1738
+ queueExplainsIt = await this.pendingCommitsProbe(workspaceId);
1739
+ }
1740
+ catch {
1741
+ // Probe failure (DB blip) must not break status — fall through to
1742
+ // the loud warning, which is the safe-side default.
1743
+ }
1744
+ }
1745
+ const dirtyList = porcelain.stdout.trim().replace(/\s+/g, ' ');
1746
+ if (queueExplainsIt) {
1747
+ console.log(`[git] workspace=${workspaceId} branch=${branch} working tree is dirty while ` +
1748
+ `pending commits drain (expected — the background worker is catching up). ` +
1749
+ `Files: ${dirtyList}`);
1750
+ }
1751
+ else {
1752
+ console.warn(`[git] workspace=${workspaceId} branch=${branch} has a non-clean working ` +
1753
+ `tree under save=share with NO queued pending commits — this should never ` +
1754
+ `happen and likely indicates a missed lock-release commit. Files: ${dirtyList}`);
1755
+ }
1756
+ }
1757
+ let hasUpstream = true;
1758
+ let unmergedFromUpstream = false;
1759
+ try {
1760
+ // We don't expose `unpushedCommits` anymore — under save=share every
1761
+ // commit is auto-pushed on lock release, so ahead is always 0. We only
1762
+ // care about "is origin ahead of us?" for the auto-pull driver.
1763
+ const behind = await this.git(cwd, ['rev-list', '--count', 'HEAD..@{u}']);
1764
+ unmergedFromUpstream = (parseInt(behind.stdout.trim(), 10) || 0) > 0;
1765
+ }
1766
+ catch {
1767
+ // Branch has never been published — nothing upstream to compare against.
1768
+ // Surfaces via `hasUpstream` so the UI can flag a never-shared draft.
1769
+ hasUpstream = false;
1770
+ }
1771
+ return { branch, hasUpstream, unmergedFromUpstream };
1772
+ }
1773
+ async currentBranch(cwd) {
1774
+ const { stdout } = await this.git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
1775
+ return stdout.trim();
1776
+ }
1777
+ /**
1778
+ * Ahead/behind of `ref` (a full ref like `refs/heads/foo` or
1779
+ * `refs/remotes/origin/foo`). When the branch exists locally (`hasLocal`),
1780
+ * prefer its configured upstream; otherwise — or when no upstream is set —
1781
+ * fall back to the nearest protected branch on origin, picking whichever
1782
+ * minimises "ahead" as a proxy for the fork point.
1783
+ */
1784
+ async aheadBehindForBranch(cwd, shortName, ref, hasLocal) {
1785
+ if (hasLocal) {
1786
+ const upstream = await this.tryAheadBehind(cwd, ref, `${shortName}@{upstream}`);
1787
+ if (upstream)
1788
+ return upstream;
1789
+ }
1790
+ let best = null;
1791
+ for (const p of PROTECTED_BRANCHES) {
1792
+ if (p === shortName)
1793
+ continue;
1794
+ const res = await this.tryAheadBehind(cwd, ref, `refs/remotes/origin/${p}`);
1795
+ if (res && (best === null || res.ahead < best.ahead))
1796
+ best = res;
1797
+ }
1798
+ return best ?? { ahead: null, behind: null };
1799
+ }
1800
+ async tryAheadBehind(cwd, branch, base) {
1801
+ try {
1802
+ const { stdout } = await this.git(cwd, [
1803
+ 'rev-list',
1804
+ '--left-right',
1805
+ '--count',
1806
+ `${base}...${branch}`,
1807
+ ]);
1808
+ const [behind, ahead] = stdout.trim().split(/\s+/).map((n) => parseInt(n, 10) || 0);
1809
+ return { ahead, behind };
1810
+ }
1811
+ catch {
1812
+ return null;
1813
+ }
1814
+ }
1815
+ /**
1816
+ * Read a repo-relative file's contents at an arbitrary git ref (a branch,
1817
+ * `origin/<branch>`, or a sha) WITHOUT checking it out. Returns `null` when
1818
+ * the path does not exist at that ref (mirrors the access resolver's
1819
+ * `showAtRef`) — the caller distinguishes "absent" from "present but empty".
1820
+ * Read-only: `git show <ref>:<path>` never mutates the working tree.
1821
+ */
1822
+ async readFileAtRef(workspaceId, ref, repoRelativePath) {
1823
+ const cwd = await this.repoDir(workspaceId);
1824
+ try {
1825
+ const { stdout } = await this.git(cwd, ['show', `${ref}:${repoRelativePath}`]);
1826
+ return stdout;
1827
+ }
1828
+ catch (err) {
1829
+ // ONLY "the path doesn't exist at this ref" maps to null. `git show
1830
+ // <ref>:<path>` reports that as `fatal: path '<p>' does not exist in
1831
+ // '<ref>'` (or `... exists on disk, but not in '<ref>'`). Any other
1832
+ // failure — an unresolvable ref, a corrupt object, a repo/IO error —
1833
+ // must PROPAGATE, not be misread as "absent": callers like
1834
+ // `preserveBaseRolesYaml` compare base-vs-head content and rely on the
1835
+ // absent/present distinction being truthful, so a swallowed error
1836
+ // (both refs → null → "identical") would fail OPEN. Fail closed instead.
1837
+ const stderr = err.stderr ?? (err instanceof Error ? err.message : String(err));
1838
+ if (/does not exist in|exists on disk, but not in/.test(stderr))
1839
+ return null;
1840
+ throw err;
1841
+ }
1842
+ }
1843
+ async repoDir(workspaceId) {
1844
+ return path.join(await this.workspaceService.getWorkspacePath(workspaceId), this.kbDirName);
1845
+ }
1846
+ async isPathUntracked(cwd, relativePath) {
1847
+ const { stdout } = await this.git(cwd, [
1848
+ 'ls-files',
1849
+ '--others',
1850
+ '--exclude-standard',
1851
+ '--',
1852
+ relativePath,
1853
+ ]);
1854
+ return stdout.trim().length > 0;
1855
+ }
1856
+ async git(cwd, args) {
1857
+ try {
1858
+ const { stdout, stderr } = await execFileAsync('git', args, {
1859
+ cwd,
1860
+ // `GIT_LITERAL_PATHSPECS=1` makes git treat every pathspec literally
1861
+ // instead of interpreting `[`, `]`, `*`, `?`, `!`, or `:(magic)` as
1862
+ // glob / magic syntax. KB files routinely arrive with bracketed
1863
+ // prefixes like `[Approved] foo.docx` or `[Updated 2025] bar.md`; the
1864
+ // upload pipeline (`writeFileBinary` + `releaseLock` + `commitFile`)
1865
+ // passes the relative path straight through to `git add` / `git
1866
+ // checkout -- <path>`, which would otherwise glob and either match the
1867
+ // wrong file or no file at all. Setting it once here covers every git
1868
+ // subprocess this service spawns.
1869
+ //
1870
+ // `LC_ALL=C` / `LANG=C` force git's human-readable output (including
1871
+ // stderr) to stable, English, locale-independent text. Callers that
1872
+ // classify errors by message — e.g. `readFileAtRef` distinguishing a
1873
+ // "path does not exist in <ref>" absence from a hard failure — would
1874
+ // otherwise misread a translated message on a non-English host and, for
1875
+ // the fail-closed roles.yaml preservation path, fail OPEN.
1876
+ env: { ...process.env, GIT_LITERAL_PATHSPECS: '1', LC_ALL: 'C', LANG: 'C' },
1877
+ maxBuffer: 32 * 1024 * 1024,
1878
+ });
1879
+ return { stdout: stdout.toString(), stderr: stderr.toString() };
1880
+ }
1881
+ catch (err) {
1882
+ const msg = err instanceof Error ? err.message : String(err);
1883
+ // Skip past any leading `-c key=val` pairs so the error names the actual
1884
+ // git subcommand that failed (e.g. "git fetch failed:" not "git -c failed:").
1885
+ let i = 0;
1886
+ while (i < args.length && args[i] === '-c')
1887
+ i += 2;
1888
+ const subcommand = args[i] ?? args[0];
1889
+ const wrapped = new Error(`git ${subcommand} failed: ${redact(msg)}`);
1890
+ // Preserve the underlying exit code / stderr so callers can distinguish
1891
+ // expected non-zero exits (e.g. merge-base exit 1 = no common ancestor)
1892
+ // from infra failures (ENOENT, exit 128) without parsing message strings.
1893
+ const original = err;
1894
+ if (typeof original.code === 'number')
1895
+ wrapped.exitCode = original.code;
1896
+ if (typeof original.stderr === 'string')
1897
+ wrapped.stderr = redact(original.stderr);
1898
+ throw wrapped;
1899
+ }
1900
+ }
1901
+ }
1902
+ /** Map a git status letter (`git diff --name-status`) to the DTO's `PrFileStatus`. */
1903
+ function mapGitStatus(letter) {
1904
+ switch (letter[0]) {
1905
+ case 'A': return 'added';
1906
+ case 'D': return 'removed';
1907
+ case 'R': return 'renamed';
1908
+ case 'C': return 'copied';
1909
+ case 'T': return 'changed'; // type change (e.g. file ↔ symlink)
1910
+ case 'M':
1911
+ default: return 'modified';
1912
+ }
1913
+ }
1914
+ /**
1915
+ * Parse `git diff -M -z --name-status` output. NUL-separated tokens: each record
1916
+ * is `<statusLetter>\0<path>`, or for a rename/copy `<Rxxx|Cxxx>\0<old>\0<new>`.
1917
+ */
1918
+ export function parseNameStatusZ(out) {
1919
+ const tokens = out.split('\0');
1920
+ const entries = [];
1921
+ let i = 0;
1922
+ while (i < tokens.length) {
1923
+ const raw = tokens[i];
1924
+ if (!raw) {
1925
+ i += 1;
1926
+ continue;
1927
+ } // trailing empty token after the final NUL
1928
+ const letter = raw[0];
1929
+ if (letter === 'R' || letter === 'C') {
1930
+ const previousPath = tokens[i + 1];
1931
+ const path = tokens[i + 2];
1932
+ if (path === undefined)
1933
+ break; // malformed tail — stop rather than throw
1934
+ entries.push({ status: mapGitStatus(letter), path, previousPath });
1935
+ i += 3;
1936
+ }
1937
+ else {
1938
+ const path = tokens[i + 1];
1939
+ if (path === undefined)
1940
+ break;
1941
+ entries.push({ status: mapGitStatus(letter), path });
1942
+ i += 2;
1943
+ }
1944
+ }
1945
+ return entries;
1946
+ }
1947
+ /**
1948
+ * Parse `git diff -M -z --numstat` output, in the SAME file order as
1949
+ * `--name-status` (both driven by `-M` over the same range). Each record is
1950
+ * `<add>\t<del>\t<path>` for a normal change, or `<add>\t<del>\t\0<old>\0<new>`
1951
+ * for a rename (empty path segment before the two NUL-separated names). Binary
1952
+ * files report `-` for both counts.
1953
+ */
1954
+ export function parseNumstatZ(out) {
1955
+ const tokens = out.split('\0');
1956
+ const entries = [];
1957
+ let i = 0;
1958
+ while (i < tokens.length) {
1959
+ const raw = tokens[i];
1960
+ if (!raw) {
1961
+ i += 1;
1962
+ continue;
1963
+ }
1964
+ const m = raw.match(/^(-|\d+)\t(-|\d+)\t(.*)$/s);
1965
+ if (!m) {
1966
+ i += 1;
1967
+ continue;
1968
+ }
1969
+ const isBinary = m[1] === '-' && m[2] === '-';
1970
+ const entry = {
1971
+ additions: m[1] === '-' ? 0 : Number(m[1]),
1972
+ deletions: m[2] === '-' ? 0 : Number(m[2]),
1973
+ isBinary,
1974
+ };
1975
+ if (m[3] === '') {
1976
+ // Rename: the two names follow as separate NUL-terminated tokens.
1977
+ i += 3;
1978
+ }
1979
+ else {
1980
+ i += 1;
1981
+ }
1982
+ entries.push(entry);
1983
+ }
1984
+ return entries;
1985
+ }
1986
+ export { PROTECTED_BRANCHES };
1987
+ //# sourceMappingURL=git.service.js.map