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