ctxr-cli 0.33.0 → 0.35.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 (378) hide show
  1. package/README.md +40 -6
  2. package/dist/commands/archive.d.ts +5 -4
  3. package/dist/commands/archive.d.ts.map +1 -1
  4. package/dist/commands/archive.js +7 -7
  5. package/dist/commands/archive.js.map +1 -1
  6. package/dist/commands/catalog-show.d.ts +8 -0
  7. package/dist/commands/catalog-show.d.ts.map +1 -1
  8. package/dist/commands/catalog-show.js +29 -2
  9. package/dist/commands/catalog-show.js.map +1 -1
  10. package/dist/commands/context-gather.d.ts.map +1 -1
  11. package/dist/commands/context-gather.js +3 -2
  12. package/dist/commands/context-gather.js.map +1 -1
  13. package/dist/commands/doctor.d.ts +5 -1
  14. package/dist/commands/doctor.d.ts.map +1 -1
  15. package/dist/commands/doctor.js +4 -0
  16. package/dist/commands/doctor.js.map +1 -1
  17. package/dist/commands/graph-build.d.ts +4 -1
  18. package/dist/commands/graph-build.d.ts.map +1 -1
  19. package/dist/commands/graph-build.js +14 -5
  20. package/dist/commands/graph-build.js.map +1 -1
  21. package/dist/commands/land.d.ts +27 -7
  22. package/dist/commands/land.d.ts.map +1 -1
  23. package/dist/commands/land.js +307 -32
  24. package/dist/commands/land.js.map +1 -1
  25. package/dist/commands/plugin-build.d.ts +33 -0
  26. package/dist/commands/plugin-build.d.ts.map +1 -0
  27. package/dist/commands/plugin-build.js +51 -0
  28. package/dist/commands/plugin-build.js.map +1 -0
  29. package/dist/commands/publish-check.d.ts.map +1 -1
  30. package/dist/commands/publish-check.js +35 -4
  31. package/dist/commands/publish-check.js.map +1 -1
  32. package/dist/commands/publish-gather.d.ts.map +1 -1
  33. package/dist/commands/publish-gather.js +2 -3
  34. package/dist/commands/publish-gather.js.map +1 -1
  35. package/dist/commands/review-list.d.ts +19 -5
  36. package/dist/commands/review-list.d.ts.map +1 -1
  37. package/dist/commands/review-list.js +55 -6
  38. package/dist/commands/review-list.js.map +1 -1
  39. package/dist/commands/rollup-gather.d.ts.map +1 -1
  40. package/dist/commands/rollup-gather.js +2 -3
  41. package/dist/commands/rollup-gather.js.map +1 -1
  42. package/dist/commands/second-opinion.d.ts +33 -0
  43. package/dist/commands/second-opinion.d.ts.map +1 -0
  44. package/dist/commands/second-opinion.js +56 -0
  45. package/dist/commands/second-opinion.js.map +1 -0
  46. package/dist/commands/serve.d.ts.map +1 -1
  47. package/dist/commands/serve.js +14 -2
  48. package/dist/commands/serve.js.map +1 -1
  49. package/dist/commands/session-discard.d.ts +18 -0
  50. package/dist/commands/session-discard.d.ts.map +1 -0
  51. package/dist/commands/session-discard.js +31 -0
  52. package/dist/commands/session-discard.js.map +1 -0
  53. package/dist/commands/session-list.d.ts +17 -6
  54. package/dist/commands/session-list.d.ts.map +1 -1
  55. package/dist/commands/session-list.js +31 -11
  56. package/dist/commands/session-list.js.map +1 -1
  57. package/dist/commands/session-rebase.d.ts +18 -0
  58. package/dist/commands/session-rebase.d.ts.map +1 -0
  59. package/dist/commands/session-rebase.js +43 -0
  60. package/dist/commands/session-rebase.js.map +1 -0
  61. package/dist/commands/session-start.d.ts +8 -0
  62. package/dist/commands/session-start.d.ts.map +1 -1
  63. package/dist/commands/session-start.js +52 -38
  64. package/dist/commands/session-start.js.map +1 -1
  65. package/dist/commands/session-status.d.ts +17 -0
  66. package/dist/commands/session-status.d.ts.map +1 -0
  67. package/dist/commands/session-status.js +38 -0
  68. package/dist/commands/session-status.js.map +1 -0
  69. package/dist/commands/session-submit.d.ts +23 -0
  70. package/dist/commands/session-submit.d.ts.map +1 -0
  71. package/dist/commands/session-submit.js +42 -0
  72. package/dist/commands/session-submit.js.map +1 -0
  73. package/dist/commands/shared-stores-status.d.ts +24 -0
  74. package/dist/commands/shared-stores-status.d.ts.map +1 -0
  75. package/dist/commands/shared-stores-status.js +51 -0
  76. package/dist/commands/shared-stores-status.js.map +1 -0
  77. package/dist/commands/shared-stores-sync.d.ts +27 -0
  78. package/dist/commands/shared-stores-sync.d.ts.map +1 -0
  79. package/dist/commands/shared-stores-sync.js +115 -0
  80. package/dist/commands/shared-stores-sync.js.map +1 -0
  81. package/dist/commands/verify.d.ts.map +1 -1
  82. package/dist/commands/verify.js +2 -4
  83. package/dist/commands/verify.js.map +1 -1
  84. package/dist/config/schema.d.ts +16 -0
  85. package/dist/config/schema.d.ts.map +1 -1
  86. package/dist/config/schema.js +32 -0
  87. package/dist/config/schema.js.map +1 -1
  88. package/dist/core/agents-doc.d.ts +10 -0
  89. package/dist/core/agents-doc.d.ts.map +1 -1
  90. package/dist/core/agents-doc.js +26 -1
  91. package/dist/core/agents-doc.js.map +1 -1
  92. package/dist/core/approval/decision.d.ts +39 -1
  93. package/dist/core/approval/decision.d.ts.map +1 -1
  94. package/dist/core/approval/decision.js +53 -6
  95. package/dist/core/approval/decision.js.map +1 -1
  96. package/dist/core/approval/session-group.d.ts +156 -0
  97. package/dist/core/approval/session-group.d.ts.map +1 -0
  98. package/dist/core/approval/session-group.js +359 -0
  99. package/dist/core/approval/session-group.js.map +1 -0
  100. package/dist/core/approval/session-marker.d.ts +79 -0
  101. package/dist/core/approval/session-marker.d.ts.map +1 -0
  102. package/dist/core/approval/session-marker.js +146 -0
  103. package/dist/core/approval/session-marker.js.map +1 -0
  104. package/dist/core/approval/store-forges.d.ts +15 -0
  105. package/dist/core/approval/store-forges.d.ts.map +1 -0
  106. package/dist/core/approval/store-forges.js +33 -0
  107. package/dist/core/approval/store-forges.js.map +1 -0
  108. package/dist/core/browse/approve.d.ts +8 -0
  109. package/dist/core/browse/approve.d.ts.map +1 -1
  110. package/dist/core/browse/approve.js +366 -48
  111. package/dist/core/browse/approve.js.map +1 -1
  112. package/dist/core/browse/link-resolver.d.ts +9 -4
  113. package/dist/core/browse/link-resolver.d.ts.map +1 -1
  114. package/dist/core/browse/link-resolver.js +12 -7
  115. package/dist/core/browse/link-resolver.js.map +1 -1
  116. package/dist/core/browse/review.d.ts +5 -0
  117. package/dist/core/browse/review.d.ts.map +1 -1
  118. package/dist/core/browse/review.js +8 -0
  119. package/dist/core/browse/review.js.map +1 -1
  120. package/dist/core/checks/integrity-checks.d.ts.map +1 -1
  121. package/dist/core/checks/integrity-checks.js +7 -1
  122. package/dist/core/checks/integrity-checks.js.map +1 -1
  123. package/dist/core/checks/manifest.d.ts.map +1 -1
  124. package/dist/core/checks/manifest.js +2 -0
  125. package/dist/core/checks/manifest.js.map +1 -1
  126. package/dist/core/checks/shared-stores-checks.d.ts +17 -0
  127. package/dist/core/checks/shared-stores-checks.d.ts.map +1 -0
  128. package/dist/core/checks/shared-stores-checks.js +84 -0
  129. package/dist/core/checks/shared-stores-checks.js.map +1 -0
  130. package/dist/core/errors.d.ts +17 -0
  131. package/dist/core/errors.d.ts.map +1 -1
  132. package/dist/core/errors.js +21 -0
  133. package/dist/core/errors.js.map +1 -1
  134. package/dist/core/forge/github.d.ts +14 -0
  135. package/dist/core/forge/github.d.ts.map +1 -1
  136. package/dist/core/forge/github.js +17 -0
  137. package/dist/core/forge/github.js.map +1 -1
  138. package/dist/core/fs/atomic.d.ts +3 -2
  139. package/dist/core/fs/atomic.d.ts.map +1 -1
  140. package/dist/core/fs/atomic.js +3 -2
  141. package/dist/core/fs/atomic.js.map +1 -1
  142. package/dist/core/git/fast-forward.d.ts +23 -0
  143. package/dist/core/git/fast-forward.d.ts.map +1 -0
  144. package/dist/core/git/fast-forward.js +38 -0
  145. package/dist/core/git/fast-forward.js.map +1 -0
  146. package/dist/core/git/repo.d.ts +11 -0
  147. package/dist/core/git/repo.d.ts.map +1 -1
  148. package/dist/core/git/repo.js +25 -0
  149. package/dist/core/git/repo.js.map +1 -1
  150. package/dist/core/graph/model.d.ts +27 -0
  151. package/dist/core/graph/model.d.ts.map +1 -1
  152. package/dist/core/graph/model.js +68 -2
  153. package/dist/core/graph/model.js.map +1 -1
  154. package/dist/core/graph/persist.d.ts.map +1 -1
  155. package/dist/core/graph/persist.js +3 -2
  156. package/dist/core/graph/persist.js.map +1 -1
  157. package/dist/core/mcp/cli-runner.d.ts +41 -0
  158. package/dist/core/mcp/cli-runner.d.ts.map +1 -0
  159. package/dist/core/mcp/cli-runner.js +81 -0
  160. package/dist/core/mcp/cli-runner.js.map +1 -0
  161. package/dist/core/mcp/jsonrpc.d.ts +13 -1
  162. package/dist/core/mcp/jsonrpc.d.ts.map +1 -1
  163. package/dist/core/mcp/jsonrpc.js +1 -1
  164. package/dist/core/mcp/jsonrpc.js.map +1 -1
  165. package/dist/core/mcp/member-sessions.d.ts +48 -0
  166. package/dist/core/mcp/member-sessions.d.ts.map +1 -0
  167. package/dist/core/mcp/member-sessions.js +85 -0
  168. package/dist/core/mcp/member-sessions.js.map +1 -0
  169. package/dist/core/mcp/ownership.d.ts +51 -0
  170. package/dist/core/mcp/ownership.d.ts.map +1 -0
  171. package/dist/core/mcp/ownership.js +101 -0
  172. package/dist/core/mcp/ownership.js.map +1 -0
  173. package/dist/core/mcp/paths.d.ts +48 -0
  174. package/dist/core/mcp/paths.d.ts.map +1 -0
  175. package/dist/core/mcp/paths.js +111 -0
  176. package/dist/core/mcp/paths.js.map +1 -0
  177. package/dist/core/mcp/sessions.d.ts +25 -50
  178. package/dist/core/mcp/sessions.d.ts.map +1 -1
  179. package/dist/core/mcp/sessions.js +31 -123
  180. package/dist/core/mcp/sessions.js.map +1 -1
  181. package/dist/core/mcp/tool-map.d.ts +94 -0
  182. package/dist/core/mcp/tool-map.d.ts.map +1 -0
  183. package/dist/core/mcp/tool-map.js +211 -0
  184. package/dist/core/mcp/tool-map.js.map +1 -0
  185. package/dist/core/mcp/tools/capture.d.ts +7 -2
  186. package/dist/core/mcp/tools/capture.d.ts.map +1 -1
  187. package/dist/core/mcp/tools/capture.js +69 -18
  188. package/dist/core/mcp/tools/capture.js.map +1 -1
  189. package/dist/core/mcp/tools/cli.d.ts +97 -0
  190. package/dist/core/mcp/tools/cli.d.ts.map +1 -0
  191. package/dist/core/mcp/tools/cli.js +271 -0
  192. package/dist/core/mcp/tools/cli.js.map +1 -0
  193. package/dist/core/mcp/tools/edit.d.ts +62 -28
  194. package/dist/core/mcp/tools/edit.d.ts.map +1 -1
  195. package/dist/core/mcp/tools/edit.js +182 -153
  196. package/dist/core/mcp/tools/edit.js.map +1 -1
  197. package/dist/core/mcp/tools/files.d.ts +37 -11
  198. package/dist/core/mcp/tools/files.d.ts.map +1 -1
  199. package/dist/core/mcp/tools/files.js +122 -100
  200. package/dist/core/mcp/tools/files.js.map +1 -1
  201. package/dist/core/mcp/tools/index.d.ts +12 -3
  202. package/dist/core/mcp/tools/index.d.ts.map +1 -1
  203. package/dist/core/mcp/tools/index.js +161 -58
  204. package/dist/core/mcp/tools/index.js.map +1 -1
  205. package/dist/core/mcp/tools/sessions.d.ts +145 -0
  206. package/dist/core/mcp/tools/sessions.d.ts.map +1 -0
  207. package/dist/core/mcp/tools/sessions.js +226 -0
  208. package/dist/core/mcp/tools/sessions.js.map +1 -0
  209. package/dist/core/notes/list.d.ts.map +1 -1
  210. package/dist/core/notes/list.js +5 -0
  211. package/dist/core/notes/list.js.map +1 -1
  212. package/dist/core/plugin/build.d.ts +70 -0
  213. package/dist/core/plugin/build.d.ts.map +1 -0
  214. package/dist/core/plugin/build.js +159 -0
  215. package/dist/core/plugin/build.js.map +1 -0
  216. package/dist/core/plugin/routes.d.ts +35 -0
  217. package/dist/core/plugin/routes.d.ts.map +1 -0
  218. package/dist/core/plugin/routes.js +77 -0
  219. package/dist/core/plugin/routes.js.map +1 -0
  220. package/dist/core/plugin/zip.d.ts +17 -0
  221. package/dist/core/plugin/zip.d.ts.map +1 -0
  222. package/dist/core/plugin/zip.js +75 -0
  223. package/dist/core/plugin/zip.js.map +1 -0
  224. package/dist/core/reconcile.d.ts.map +1 -1
  225. package/dist/core/reconcile.js +2 -1
  226. package/dist/core/reconcile.js.map +1 -1
  227. package/dist/core/retrieval/pass.d.ts.map +1 -1
  228. package/dist/core/retrieval/pass.js +5 -4
  229. package/dist/core/retrieval/pass.js.map +1 -1
  230. package/dist/core/rollup.d.ts.map +1 -1
  231. package/dist/core/rollup.js +2 -3
  232. package/dist/core/rollup.js.map +1 -1
  233. package/dist/core/root.d.ts.map +1 -1
  234. package/dist/core/root.js +33 -2
  235. package/dist/core/root.js.map +1 -1
  236. package/dist/core/second-opinion/environment.d.ts +21 -0
  237. package/dist/core/second-opinion/environment.d.ts.map +1 -0
  238. package/dist/core/second-opinion/environment.js +70 -0
  239. package/dist/core/second-opinion/environment.js.map +1 -0
  240. package/dist/core/second-opinion/judge.d.ts +29 -0
  241. package/dist/core/second-opinion/judge.d.ts.map +1 -0
  242. package/dist/core/second-opinion/judge.js +55 -0
  243. package/dist/core/second-opinion/judge.js.map +1 -0
  244. package/dist/core/second-opinion/launch.d.ts +16 -0
  245. package/dist/core/second-opinion/launch.d.ts.map +1 -0
  246. package/dist/core/second-opinion/launch.js +75 -0
  247. package/dist/core/second-opinion/launch.js.map +1 -0
  248. package/dist/core/second-opinion/lineup.d.ts +82 -0
  249. package/dist/core/second-opinion/lineup.d.ts.map +1 -0
  250. package/dist/core/second-opinion/lineup.js +113 -0
  251. package/dist/core/second-opinion/lineup.js.map +1 -0
  252. package/dist/core/second-opinion/modes.d.ts +98 -0
  253. package/dist/core/second-opinion/modes.d.ts.map +1 -0
  254. package/dist/core/second-opinion/modes.js +224 -0
  255. package/dist/core/second-opinion/modes.js.map +1 -0
  256. package/dist/core/second-opinion/prompts.d.ts +18 -0
  257. package/dist/core/second-opinion/prompts.d.ts.map +1 -0
  258. package/dist/core/second-opinion/prompts.js +65 -0
  259. package/dist/core/second-opinion/prompts.js.map +1 -0
  260. package/dist/core/session/commit.d.ts +29 -0
  261. package/dist/core/session/commit.d.ts.map +1 -0
  262. package/dist/core/session/commit.js +22 -0
  263. package/dist/core/session/commit.js.map +1 -0
  264. package/dist/core/session/discard.d.ts +24 -0
  265. package/dist/core/session/discard.d.ts.map +1 -0
  266. package/dist/core/session/discard.js +68 -0
  267. package/dist/core/session/discard.js.map +1 -0
  268. package/dist/core/session/list.d.ts +48 -0
  269. package/dist/core/session/list.d.ts.map +1 -0
  270. package/dist/core/session/list.js +55 -0
  271. package/dist/core/session/list.js.map +1 -0
  272. package/dist/core/session/rebase.d.ts +34 -0
  273. package/dist/core/session/rebase.d.ts.map +1 -0
  274. package/dist/core/session/rebase.js +87 -0
  275. package/dist/core/session/rebase.js.map +1 -0
  276. package/dist/core/session/remote.d.ts +80 -0
  277. package/dist/core/session/remote.d.ts.map +1 -0
  278. package/dist/core/session/remote.js +102 -0
  279. package/dist/core/session/remote.js.map +1 -0
  280. package/dist/core/session/resolve.d.ts +41 -0
  281. package/dist/core/session/resolve.d.ts.map +1 -0
  282. package/dist/core/session/resolve.js +109 -0
  283. package/dist/core/session/resolve.js.map +1 -0
  284. package/dist/core/session/start.d.ts +46 -0
  285. package/dist/core/session/start.d.ts.map +1 -0
  286. package/dist/core/session/start.js +33 -0
  287. package/dist/core/session/start.js.map +1 -0
  288. package/dist/core/session/status.d.ts +47 -0
  289. package/dist/core/session/status.d.ts.map +1 -0
  290. package/dist/core/session/status.js +55 -0
  291. package/dist/core/session/status.js.map +1 -0
  292. package/dist/core/session/submit.d.ts +67 -0
  293. package/dist/core/session/submit.d.ts.map +1 -0
  294. package/dist/core/session/submit.js +254 -0
  295. package/dist/core/session/submit.js.map +1 -0
  296. package/dist/core/shared-stores-location.d.ts +17 -0
  297. package/dist/core/shared-stores-location.d.ts.map +1 -0
  298. package/dist/core/shared-stores-location.js +22 -0
  299. package/dist/core/shared-stores-location.js.map +1 -0
  300. package/dist/core/shared-stores.d.ts +69 -0
  301. package/dist/core/shared-stores.d.ts.map +1 -0
  302. package/dist/core/shared-stores.js +100 -0
  303. package/dist/core/shared-stores.js.map +1 -0
  304. package/dist/core/skills.d.ts +20 -2
  305. package/dist/core/skills.d.ts.map +1 -1
  306. package/dist/core/skills.js +70 -13
  307. package/dist/core/skills.js.map +1 -1
  308. package/dist/run.d.ts +12 -0
  309. package/dist/run.d.ts.map +1 -1
  310. package/dist/run.js +194 -55
  311. package/dist/run.js.map +1 -1
  312. package/dist/version.d.ts +1 -1
  313. package/dist/version.js +1 -1
  314. package/package.json +1 -1
  315. package/templates/agents/shared-stores.md +11 -0
  316. package/templates/approve/copies-missing.md +1 -0
  317. package/templates/approve/elsewhere-heading.md +1 -0
  318. package/templates/approve/elsewhere-intro.md +1 -0
  319. package/templates/approve/homed-elsewhere.md +1 -0
  320. package/templates/approve/not-decider.md +1 -0
  321. package/templates/approve/part-heading.md +1 -0
  322. package/templates/approve/parts-changed.md +1 -0
  323. package/templates/approve/session-decision-note.md +1 -0
  324. package/templates/approve/session-intro.md +1 -0
  325. package/templates/approve/session-no-decision.md +1 -0
  326. package/templates/approve/store-unreachable.md +1 -0
  327. package/templates/land/resolver.md +2 -0
  328. package/templates/mcp/Delete.md +6 -0
  329. package/templates/mcp/Edit.md +6 -5
  330. package/templates/mcp/Glob.md +2 -2
  331. package/templates/mcp/Grep.md +2 -1
  332. package/templates/mcp/Move.md +7 -0
  333. package/templates/mcp/Read.md +4 -4
  334. package/templates/mcp/Write.md +4 -4
  335. package/templates/mcp/ctxr.md +7 -0
  336. package/templates/mcp/ctxr_write.md +8 -0
  337. package/templates/mcp/server-instructions.md +11 -3
  338. package/templates/mcp/session_discard.md +4 -0
  339. package/templates/mcp/session_list.md +7 -0
  340. package/templates/mcp/session_rebase.md +5 -0
  341. package/templates/mcp/session_start.md +7 -0
  342. package/templates/mcp/session_status.md +3 -0
  343. package/templates/mcp/session_submit.md +8 -0
  344. package/templates/plugin/README.md +13 -0
  345. package/templates/plugin/chatgpt-mcp.json +19 -0
  346. package/templates/plugin/chatgpt-plugin.json +13 -0
  347. package/templates/plugin/claude-mcp.json +8 -0
  348. package/templates/plugin/claude-plugin.json +6 -0
  349. package/templates/plugin/page.html +47 -0
  350. package/templates/second-opinion/poll-answer.md +7 -0
  351. package/templates/second-opinion/taste-panel.md +14 -0
  352. package/templates/session/pages/bare-route.md +2 -0
  353. package/templates/session/pages/block.md +8 -0
  354. package/templates/session/pages/condition-preview.md +1 -0
  355. package/templates/session/pages/condition.md +1 -0
  356. package/templates/session/pages/moved.md +1 -0
  357. package/templates/session/pages/stops-working.md +1 -0
  358. package/templates/session/pages/table-head-preview.md +2 -0
  359. package/templates/session/pages/table-head.md +2 -0
  360. package/templates/skills/ctxr-connection-finding.md +4 -1
  361. package/templates/skills/ctxr-land.md +26 -7
  362. package/templates/skills/ctxr-placement.md +10 -0
  363. package/templates/skills/ctxr-publish.md +2 -1
  364. package/templates/skills/ctxr-rollup.md +3 -0
  365. package/templates/skills/ctxr-second-opinion.md +52 -36
  366. package/templates/skills/ctxr-session-lifecycle.md +60 -33
  367. package/templates/skills/ctxr-submit.md +65 -56
  368. package/templates/skills/mcp/_preamble.md +44 -0
  369. package/dist/core/mcp/drafts.d.ts +0 -30
  370. package/dist/core/mcp/drafts.d.ts.map +0 -1
  371. package/dist/core/mcp/drafts.js +0 -52
  372. package/dist/core/mcp/drafts.js.map +0 -1
  373. package/templates/skills/ctxr-second-opinion/run.mjs +0 -542
  374. /package/templates/{skills/ctxr-second-opinion → second-opinion}/critic-contract.md +0 -0
  375. /package/templates/{skills/ctxr-second-opinion → second-opinion}/lenses.md +0 -0
  376. /package/templates/{skills/ctxr-second-opinion → second-opinion}/personas/architect.md +0 -0
  377. /package/templates/{skills/ctxr-second-opinion → second-opinion}/personas/pragmatist.md +0 -0
  378. /package/templates/{skills/ctxr-second-opinion → second-opinion}/personas/skeptic.md +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-forges.d.ts","sourceRoot":"","sources":["../../../src/core/approval/store-forges.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAExC,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAC3E,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAEnD;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAC,EAAE,QAAQ,CAAC,EAAE,WAAW,GAAG,QAAQ,CAmBtG"}
@@ -0,0 +1,33 @@
1
+ import { APP_REPOSITORY_VAR, credentialsFor, ghCredentials } from '../forge/credentials.js';
2
+ import { createGhForgeClient } from '../forge/github.js';
3
+ /**
4
+ * approve-sessions-across-stores (proposal Impact): every store a session
5
+ * touches is read and written with the same recording identity — the same
6
+ * credential source, addressed at the mount's own repository through its
7
+ * clone's `origin`. `CONTEXTURE_GITHUB_REPOSITORY` names the home store's
8
+ * repository only, so it is not passed on to a mount.
9
+ *
10
+ * When a command was handed a forge client (a test's), mounts get a plain
11
+ * `gh` client in their own clone, so they are still addressed by their origin.
12
+ */
13
+ export function storeForges(env, injected) {
14
+ const cache = new Map();
15
+ return (root, mount) => {
16
+ let client = cache.get(root);
17
+ if (client === undefined) {
18
+ client = (async () => {
19
+ if (injected !== undefined) {
20
+ const credentials = ghCredentials(env.env);
21
+ return { forge: createGhForgeClient(root, credentials), credentials };
22
+ }
23
+ const scoped = mount ? { ...env, env: { ...env.env, [APP_REPOSITORY_VAR]: undefined } } : env;
24
+ const credentials = await credentialsFor(scoped, root);
25
+ return { forge: createGhForgeClient(root, credentials), credentials };
26
+ })();
27
+ cache.set(root, client);
28
+ client.catch(() => cache.delete(root));
29
+ }
30
+ return client;
31
+ };
32
+ }
33
+ //# sourceMappingURL=store-forges.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store-forges.js","sourceRoot":"","sources":["../../../src/core/approval/store-forges.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,cAAc,EAAE,aAAa,EAAyB,MAAM,yBAAyB,CAAC;AACnH,OAAO,EAAE,mBAAmB,EAAoB,MAAM,oBAAoB,CAAC;AAG3E;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CAAC,GAAwC,EAAE,QAAsB;IAC1F,MAAM,KAAK,GAAG,IAAI,GAAG,EAA0E,CAAC;IAChG,OAAO,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;QACrB,IAAI,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC7B,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,MAAM,GAAG,CAAC,KAAK,IAAI,EAAE;gBACnB,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;oBAC3B,MAAM,WAAW,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;oBAC3C,OAAO,EAAE,KAAK,EAAE,mBAAmB,CAAC,IAAI,EAAE,WAAW,CAAC,EAAE,WAAW,EAAE,CAAC;gBACxE,CAAC;gBACD,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,GAAG,EAAE,GAAG,EAAE,EAAE,GAAG,GAAG,CAAC,GAAG,EAAE,CAAC,kBAAkB,CAAC,EAAE,SAAS,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC;gBAC9F,MAAM,WAAW,GAAG,MAAM,cAAc,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC;gBACvD,OAAO,EAAE,KAAK,EAAE,mBAAmB,CAAC,IAAI,EAAE,WAAW,CAAC,EAAE,WAAW,EAAE,CAAC;YACxE,CAAC,CAAC,EAAE,CAAC;YACL,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YACxB,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;QACzC,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC,CAAC;AACJ,CAAC"}
@@ -1,6 +1,7 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import type { ChangeApprovalConfig } from '../../config/schema.js';
3
3
  import type { IdentityVerifier } from '../approval/identity.js';
4
+ import { type ForgeFor } from '../approval/session-group.js';
4
5
  import type { ForgeCredentials } from '../forge/credentials.js';
5
6
  import type { ForgeClient } from '../forge/github.js';
6
7
  import type { GitRunner } from '../git/exec.js';
@@ -24,6 +25,13 @@ export interface ApprovalServer {
24
25
  verifier: IdentityVerifier;
25
26
  git: GitRunner;
26
27
  now(): Date;
28
+ /**
29
+ * approve-sessions-across-stores D8: the forge client and credential for a mounted store's clone, with the same
30
+ * recording identity. Omitted, mounts are read with `gh`'s own credential in their clone.
31
+ */
32
+ forgeFor?: ForgeFor;
33
+ /** The process environment, for an authenticated fetch of a mounted part's head and for a mount's forge calls. */
34
+ env?: Readonly<Record<string, string | undefined>>;
27
35
  }
28
36
  /** What a route needs from the browsing surface once the requester is known: the shell, and note links. */
29
37
  export interface PageKit {
@@ -1 +1 @@
1
- {"version":3,"file":"approve.d.ts","sourceRoot":"","sources":["../../../src/core/browse/approve.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAInE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAIhE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,oBAAoB,CAAC;AAEnE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAA8B,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAO5E;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,cAAc,CAAC;AAOhD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,oGAAoG;IACpG,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,CAAC;IACnB,qHAAqH;IACrH,WAAW,EAAE,gBAAgB,CAAC;IAC9B,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,GAAG,EAAE,SAAS,CAAC;IACf,GAAG,IAAI,IAAI,CAAC;CACb;AAED,2GAA2G;AAC3G,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACzD,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACzC,WAAW,EAAE,YAAY,CAAC;CAC3B;AAED,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,mGAAmG;IACnG,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAQD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEzD;AAgRD;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,cAAc,GAAG,IAAI,EAC7B,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAC/B,WAAW,GAAE,MAAM,GAAG,IAAW,GAChC,OAAO,CAAC,KAAK,CAAC,CAkBhB"}
1
+ {"version":3,"file":"approve.d.ts","sourceRoot":"","sources":["../../../src/core/browse/approve.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAInE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAGhE,OAAO,EAQL,KAAK,QAAQ,EAOd,MAAM,8BAA8B,CAAC;AAOtC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,KAAK,EAAE,WAAW,EAAe,MAAM,oBAAoB,CAAC;AAEnE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AACvD,OAAO,EAA8B,KAAK,YAAY,EAAE,MAAM,aAAa,CAAC;AAO5E;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,cAAc,CAAC;AAOhD,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,oBAAoB,CAAC;IAC/B,oGAAoG;IACpG,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,WAAW,CAAC;IACnB,qHAAqH;IACrH,WAAW,EAAE,gBAAgB,CAAC;IAC9B,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,GAAG,EAAE,SAAS,CAAC;IACf,GAAG,IAAI,IAAI,CAAC;IACZ;;;OAGG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,kHAAkH;IAClH,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;CACpD;AAED,2GAA2G;AAC3G,MAAM,WAAW,OAAO;IACtB,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACzD,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACzC,WAAW,EAAE,YAAY,CAAC;CAC3B;AAED,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,mGAAmG;IACnG,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAQD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEvD;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEzD;AAqmBD;;;;;;;GAOG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,cAAc,GAAG,IAAI,EAC7B,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,EAC/B,WAAW,GAAE,MAAM,GAAG,IAAW,GAChC,OAAO,CAAC,KAAK,CAAC,CAkBhB"}
@@ -1,16 +1,22 @@
1
1
  import path from 'node:path';
2
2
  import { pageUrlsFor } from '../../commands/publish-urls.js';
3
- import { decisionBody, isDecider } from '../approval/decision.js';
3
+ import { decisionBody, isDecider, recordPartOf } from '../approval/decision.js';
4
4
  import { readFileDiffs, renderFileDiffs } from '../approval/file-diff.js';
5
5
  import { readLandingRecord } from '../approval/landing-record.js';
6
6
  import { baseRef, forgeRead, reviewStateOf, sessionPulls, sessionWorktrees } from '../approval/sessions.js';
7
+ import { decidersOnForge, groupSessions, listOpen, readAsSessionPart, sessionState, storeLabel, storeReach, } from '../approval/session-group.js';
8
+ import { parseSessionMarker, sessionRouteName } from '../approval/session-marker.js';
9
+ import { storeForges } from '../approval/store-forges.js';
10
+ import { runRemoteGit, ghCredentials } from '../forge/credentials.js';
11
+ import { mainWorktreePath } from '../git/worktree.js';
12
+ import { randomUUID } from 'node:crypto';
7
13
  import { ForgeUnreachableError } from '../errors.js';
8
14
  import { ForgeRequestError } from '../forge/github.js';
9
15
  import { storePrefix } from '../graph/tree.js';
10
16
  import { packagedTemplate } from '../templates.js';
11
17
  import { escapeHtml, renderNoteBody } from './render.js';
12
18
  import { configStamp, createMemo } from './memo.js';
13
- import { renderReview } from './review.js';
19
+ import { renderReviewAt } from './review.js';
14
20
  import { resolveRange } from '../graph/diff.js';
15
21
  /**
16
22
  * change-approval spec: the approval index, one session's approval page, and
@@ -43,6 +49,7 @@ const STATE_LABEL = {
43
49
  'changes-requested': 'changes requested',
44
50
  'needs-reapproval': 'needs a new decision',
45
51
  blocked: 'blocked',
52
+ retrying: 'partly landed, the rest lands on the next run',
46
53
  };
47
54
  async function readForm(req) {
48
55
  const chunks = [];
@@ -87,19 +94,78 @@ async function locate(store, server, name) {
87
94
  async function indexPage(store, server, kit) {
88
95
  const html = [`<h1>${escapeHtml(copy('index-heading'))}</h1>`];
89
96
  try {
90
- const pulls = await sessionPulls(server.git, store.root, store.config, server.forge, server.approval.recording_login, () => openPulls(server));
91
- if (pulls.length === 0) {
97
+ // approve-sessions-across-stores: the store and its mounts, read once; a pull request without a session marker keeps today's row.
98
+ const view = await readSessionView(store, server, false);
99
+ const homeListing = view?.listings.find((l) => l.store === view.reach.home);
100
+ if (homeListing?.error)
101
+ throw new ForgeUnreachableError(homeListing.error);
102
+ const homePulls = homeListing === undefined ? () => openPulls(server) : async () => homeListing.pulls;
103
+ const pulls = (await sessionPulls(server.git, store.root, store.config, server.forge, server.approval.recording_login, homePulls)).filter((p) => !readAsSessionPart(p.pull, view?.reach.home.slug ?? null, store.config));
104
+ const rows = pulls.map((p) => `<tr><td><a href="${approveRoute(p.name)}">${escapeHtml(p.name)}</a></td><td>#${p.pull.number}</td>` +
105
+ `<td><code>${escapeHtml(p.pull.headSha.slice(0, 7))}</code></td><td>${escapeHtml(STATE_LABEL[p.review.state])}</td></tr>`);
106
+ const elsewhere = [];
107
+ if (view !== null) {
108
+ for (const listing of view.listings) {
109
+ if (listing.error === null || listing.store === view.reach.home)
110
+ continue;
111
+ html.push(`<p class="ctxr-approve-notice">${escapeHtml(copy('store-unreachable').replace('__STORE__', storeLabel(listing.store)))}</p>`);
112
+ }
113
+ for (const group of view.groups) {
114
+ const state = await forgeRead(() => sessionState({ git: server.git, reach: view.reach, identity: group.identity, homedHere: group.homedHere, open: group.parts, recordingLogin: server.approval.recording_login }));
115
+ const single = group.parts.length === 1 && group.parts[0].store === view.reach.home;
116
+ const partCell = group.parts.map((p) => escapeHtml(single ? `#${p.pull.number}` : `${partStore(view, p.store)} #${p.pull.number}`)).join('<br>');
117
+ const headCell = group.parts.map((p) => `<code>${escapeHtml(p.pull.headSha.slice(0, 7))}</code>`).join('<br>');
118
+ if (group.homedHere) {
119
+ const name = sessionRouteName(group.identity);
120
+ const undecidable = unreadableParts(view, state).length > 0;
121
+ rows.push(`<tr><td><a href="${approveRoute(name)}">${escapeHtml(name)}</a></td><td>${partCell}</td><td>${headCell}</td>` +
122
+ `<td>${escapeHtml(STATE_LABEL[state.state])}${undecidable ? ` (${escapeHtml(copy('session-no-decision'))})` : ''}</td></tr>`);
123
+ }
124
+ else {
125
+ elsewhere.push(`<tr><td>${escapeHtml(sessionRouteName(group.identity))}</td><td>${escapeHtml(group.identity.home)}</td><td>${partCell}</td><td>${headCell}</td><td>${escapeHtml(STATE_LABEL[state.state])}</td></tr>`);
126
+ }
127
+ }
128
+ // write-lifecycle spec: a mount's pull request with no session marker is a session of its own, homed in that
129
+ // mount. It is shown here as homed elsewhere and decided only on the mount's own page.
130
+ for (const listing of view.listings) {
131
+ if (listing.store === view.reach.home || listing.pulls === null)
132
+ continue;
133
+ const mount = listing.store;
134
+ const login = mount.config.change_approval?.recording_login ?? null;
135
+ const base = await baseRef(server.git, mount.root, mount.config.git.default_branch);
136
+ for (const pull of listing.pulls) {
137
+ if (parseSessionMarker(pull.body) !== null)
138
+ continue;
139
+ let label = STATE_LABEL.awaiting;
140
+ if (login !== null) {
141
+ try {
142
+ label = STATE_LABEL[(await forgeRead(() => reviewStateOf(server.git, mount.root, base, mount.forge, login, pull))).state];
143
+ }
144
+ catch (err) {
145
+ if (!(err instanceof ForgeUnreachableError))
146
+ throw err;
147
+ label = '—';
148
+ }
149
+ }
150
+ elsewhere.push(`<tr><td>${escapeHtml(pull.headRef)}</td><td>${escapeHtml(mount.slug)}</td><td>${escapeHtml(`${partStore(view, mount)} #${pull.number}`)}</td>` +
151
+ `<td><code>${escapeHtml(pull.headSha.slice(0, 7))}</code></td><td>${escapeHtml(label)}</td></tr>`);
152
+ }
153
+ }
154
+ }
155
+ if (rows.length === 0) {
92
156
  html.push(`<p class="ctxr-empty">${escapeHtml(copy('index-empty'))}</p>`);
93
157
  }
94
158
  else {
95
159
  html.push(`<p>${escapeHtml(copy('index-intro'))}</p>`);
96
160
  html.push('<table class="ctxr-approve-index"><thead><tr><th>Change</th><th>Pull request</th><th>Commit</th><th>State</th></tr></thead><tbody>');
97
- for (const p of pulls) {
98
- html.push(`<tr><td><a href="${approveRoute(p.name)}">${escapeHtml(p.name)}</a></td><td>#${p.pull.number}</td>` +
99
- `<td><code>${escapeHtml(p.pull.headSha.slice(0, 7))}</code></td><td>${escapeHtml(STATE_LABEL[p.review.state])}</td></tr>`);
100
- }
161
+ html.push(...rows);
101
162
  html.push('</tbody></table>');
102
163
  }
164
+ if (elsewhere.length > 0) {
165
+ html.push(`<section class="ctxr-approve-elsewhere"><h2>${escapeHtml(copy('elsewhere-heading'))}</h2><p>${escapeHtml(copy('elsewhere-intro'))}</p>`);
166
+ html.push('<table class="ctxr-approve-index"><thead><tr><th>Session</th><th>Home</th><th>Pull request</th><th>Commit</th><th>State</th></tr></thead><tbody>');
167
+ html.push(...elsewhere, '</tbody></table></section>');
168
+ }
103
169
  }
104
170
  catch (err) {
105
171
  if (!(err instanceof ForgeUnreachableError))
@@ -108,6 +174,56 @@ async function indexPage(store, server, kit) {
108
174
  }
109
175
  return { status: 200, contentType: 'text/html; charset=utf-8', body: await kit.page('approve', { html: html.join('\n'), hasDiagram: false }) };
110
176
  }
177
+ /**
178
+ * One pull request's change as the approval page shows it: the author's account, the knowledge changes, the
179
+ * changed pages and the line diff — the same for a single pull request and for each part of a session.
180
+ */
181
+ async function changeSections(server, kit, view) {
182
+ const { pull } = view;
183
+ const head = pull.headSha;
184
+ const base = await baseRef(server.git, view.cwd, view.config.git.default_branch);
185
+ const prefix = await storePrefix(server.git, view.prefixRoot);
186
+ const worktreeStoreRoot = path.join(view.cwd, prefix);
187
+ const mergeBase = (await resolveRange(server.git, view.cwd, base, head)).base;
188
+ const range = `${mergeBase}..${head}`;
189
+ const [facts, parts] = await Promise.all([
190
+ renderReviewAt(server.git, worktreeStoreRoot, view.config, prefix, null, view.linkFor, kit.resolveLink, head),
191
+ changeParts.get(`${configStamp(view.config)}\0${view.memoName}\0${prefix}\0${mergeBase}\0${head}`, async () => {
192
+ const [log, pages, files] = await Promise.all([
193
+ server.git.run(['log', '--reverse', '--no-color', '--format=%H%x1f%B%x1e', range], { cwd: view.cwd }),
194
+ pageUrlsFor({ root: worktreeStoreRoot, config: view.config }, [range]),
195
+ readFileDiffs(server.git, worktreeStoreRoot, mergeBase, head, view.config.derived.paths),
196
+ ]);
197
+ const commits = log.stdout
198
+ .split('\x1e')
199
+ .map((entry) => entry.trim())
200
+ .filter(Boolean)
201
+ .map((entry) => {
202
+ const [sha, message] = entry.split('\x1f');
203
+ return { sha, message: message.trim() };
204
+ });
205
+ return { commits, pages, files };
206
+ }),
207
+ ]);
208
+ const html = [];
209
+ let hasDiagram = false;
210
+ // The author's account: labelled as such, and kept apart from everything computed below.
211
+ const authorBody = renderNoteBody(pull.body, kit.resolveLink);
212
+ hasDiagram ||= authorBody.hasDiagram;
213
+ const commits = parts.commits.map((c) => `<li><code>${escapeHtml(c.sha.slice(0, 7))}</code><pre class="ctxr-commit-message">${escapeHtml(c.message)}</pre></li>`);
214
+ html.push(`<section class="ctxr-author-account"><h2>${escapeHtml(copy('author-heading'))}</h2>`, `<p class="ctxr-summary">${escapeHtml(copy('author-note'))}</p>`, `<h3>${escapeHtml(pull.title)}</h3>`, authorBody.html, `<h3>${escapeHtml(copy('commits-heading'))}</h3><ol class="ctxr-commits">${commits.join('\n')}</ol>`, '</section>');
215
+ hasDiagram ||= facts.hasDiagram;
216
+ html.push(`<section class="ctxr-derived"><h2>${escapeHtml(copy('facts-heading'))}</h2>`, facts.html, '</section>');
217
+ const pages = parts.pages;
218
+ if (pages.length > 0) {
219
+ const link = (address) => address === null ? '—' : `<a href="${escapeHtml(address.url ?? address.route)}">${escapeHtml(address.url ?? address.route)}</a>`;
220
+ const rows = pages.map((p) => `<tr><td>${escapeHtml(p.status)}</td><td>${escapeHtml(p.lifespan)}</td><td><code>${escapeHtml(p.path)}</code></td>` +
221
+ `<td>${p.status === 'removed' ? '—' : link(p.preview)}</td><td>${link(p.published)}</td></tr>`);
222
+ html.push(`<section class="ctxr-pages"><h2>${escapeHtml(copy('pages-heading'))}</h2><p class="ctxr-summary">${escapeHtml(copy('pages-note'))}</p>`, `<table><thead><tr><th>Status</th><th>Lifespan</th><th>Page</th><th>Preview</th><th>Published</th></tr></thead><tbody>${rows.join('')}</tbody></table></section>`);
223
+ }
224
+ html.push(`<section class="ctxr-changed-files"><h2>${escapeHtml(copy('diff-heading'))}</h2>`, renderFileDiffs(parts.files, { binary: copy('diff-binary'), noFiles: copy('diff-empty') }), '</section>');
225
+ return { html, hasDiagram };
226
+ }
111
227
  async function renderChangePage(store, server, kit, name, notice = {}) {
112
228
  const title = `approve — ${name}`;
113
229
  const shell = async (status, parts, hasDiagram = false) => ({
@@ -118,6 +234,15 @@ async function renderChangePage(store, server, kit, name, notice = {}) {
118
234
  const html = [`<h1>${escapeHtml(name)}</h1>`];
119
235
  if (notice.message !== undefined)
120
236
  html.push(`<p class="ctxr-approve-notice">${escapeHtml(notice.message)}</p>`);
237
+ // approve-sessions-across-stores: a session with a marker, homed here, has a page of its own.
238
+ const view = await readSessionView(store, server, false).catch((err) => {
239
+ if (err instanceof ForgeUnreachableError)
240
+ return null;
241
+ throw err;
242
+ });
243
+ const group = view === null ? undefined : findSession(view, name);
244
+ if (view !== null && group !== undefined)
245
+ return renderSessionPage(server, kit, view, group, html, title, notice);
121
246
  let located;
122
247
  try {
123
248
  located = await locate(store, server, name);
@@ -150,50 +275,15 @@ async function renderChangePage(store, server, kit, name, notice = {}) {
150
275
  if (present.exitCode !== 0) {
151
276
  return shell(notice.status ?? 200, [...html, `<p class="ctxr-approve-notice">${escapeHtml(copy('head-missing')).replace('__HEAD__', () => `<code>${escapeHtml(head)}</code>`)}</p>`]);
152
277
  }
153
- const base = await baseRef(server.git, located.worktreePath, store.config.git.default_branch);
154
- const prefix = await storePrefix(server.git, store.root);
155
- const worktreeStoreRoot = path.join(located.worktreePath, prefix);
156
- const mergeBase = (await resolveRange(server.git, located.worktreePath, base, head)).base;
157
- const range = `${mergeBase}..${head}`;
158
278
  // The decision state is read fresh every time; everything the commits fix is computed once per pair.
159
- const [state, facts, parts] = await Promise.all([
279
+ const base = await baseRef(server.git, located.worktreePath, store.config.git.default_branch);
280
+ const [state, sections] = await Promise.all([
160
281
  forgeRead(() => reviewStateOf(server.git, located.worktreePath, base, server.forge, server.approval.recording_login, pull)),
161
- renderReview(server.git, store, name, null, kit.linkFor, kit.resolveLink, head),
162
- changeParts.get(`${configStamp(store.config)}\0${name}\0${prefix}\0${mergeBase}\0${head}`, async () => {
163
- const [log, pages, files] = await Promise.all([
164
- server.git.run(['log', '--reverse', '--no-color', '--format=%H%x1f%B%x1e', range], { cwd: located.worktreePath }),
165
- pageUrlsFor({ root: worktreeStoreRoot, config: store.config }, [range]),
166
- readFileDiffs(server.git, worktreeStoreRoot, mergeBase, head, store.config.derived.paths),
167
- ]);
168
- const commits = log.stdout
169
- .split('\x1e')
170
- .map((entry) => entry.trim())
171
- .filter(Boolean)
172
- .map((entry) => {
173
- const [sha, message] = entry.split('\x1f');
174
- return { sha, message: message.trim() };
175
- });
176
- return { commits, pages, files };
177
- }),
282
+ changeSections(server, kit, { cwd: located.worktreePath, prefixRoot: store.root, config: store.config, memoName: name, pull, linkFor: kit.linkFor }),
178
283
  ]);
179
- let hasDiagram = false;
284
+ let hasDiagram = sections.hasDiagram;
180
285
  html.push(`<p>Pull request <a href="${escapeHtml(pull.url)}">#${pull.number}</a>, commit <code>${escapeHtml(head)}</code>. ${escapeHtml(copy('state-line').replace('__STATE__', STATE_LABEL[state.state]))}</p>`);
181
- // The author's account: labelled as such, and kept apart from everything computed below.
182
- const authorBody = renderNoteBody(pull.body, kit.resolveLink);
183
- hasDiagram ||= authorBody.hasDiagram;
184
- const commits = parts.commits.map((c) => `<li><code>${escapeHtml(c.sha.slice(0, 7))}</code><pre class="ctxr-commit-message">${escapeHtml(c.message)}</pre></li>`);
185
- html.push(`<section class="ctxr-author-account"><h2>${escapeHtml(copy('author-heading'))}</h2>`, `<p class="ctxr-summary">${escapeHtml(copy('author-note'))}</p>`, `<h3>${escapeHtml(pull.title)}</h3>`, authorBody.html, `<h3>${escapeHtml(copy('commits-heading'))}</h3><ol class="ctxr-commits">${commits.join('\n')}</ol>`, '</section>');
186
- hasDiagram ||= facts.hasDiagram;
187
- html.push(`<section class="ctxr-derived"><h2>${escapeHtml(copy('facts-heading'))}</h2>`, facts.html, '</section>');
188
- const pages = parts.pages;
189
- if (pages.length > 0) {
190
- const link = (address) => address === null ? '—' : `<a href="${escapeHtml(address.url ?? address.route)}">${escapeHtml(address.url ?? address.route)}</a>`;
191
- const rows = pages.map((p) => `<tr><td>${escapeHtml(p.status)}</td><td>${escapeHtml(p.lifespan)}</td><td><code>${escapeHtml(p.path)}</code></td>` +
192
- `<td>${p.status === 'removed' ? '—' : link(p.preview)}</td><td>${link(p.published)}</td></tr>`);
193
- html.push(`<section class="ctxr-pages"><h2>${escapeHtml(copy('pages-heading'))}</h2><p class="ctxr-summary">${escapeHtml(copy('pages-note'))}</p>`, `<table><thead><tr><th>Status</th><th>Lifespan</th><th>Page</th><th>Preview</th><th>Published</th></tr></thead><tbody>${rows.join('')}</tbody></table></section>`);
194
- }
195
- const files = parts.files;
196
- html.push(`<section class="ctxr-changed-files"><h2>${escapeHtml(copy('diff-heading'))}</h2>`, renderFileDiffs(files, { binary: copy('diff-binary'), noFiles: copy('diff-empty') }), '</section>');
286
+ html.push(...sections.html);
197
287
  const action = `${approveRoute(name)}decision`;
198
288
  const note = escapeHtml(copy('decision-note')).replace('__HEAD__', () => `<code>${escapeHtml(head.slice(0, 7))}</code>`);
199
289
  html.push(`<section class="ctxr-decision"><h2>${escapeHtml(copy('decision-heading'))}</h2><p>${note}</p>`, `<form method="post" action="${action}"><input type="hidden" name="decision" value="approve"><input type="hidden" name="head" value="${head}"><button type="submit">${escapeHtml(copy('approve-button'))}</button></form>`, `<form method="post" action="${action}"><input type="hidden" name="decision" value="request-changes"><input type="hidden" name="head" value="${head}">` +
@@ -209,6 +299,8 @@ async function decide(store, server, makeKit, req, name, email) {
209
299
  const form = await readForm(req);
210
300
  if (form === null)
211
301
  return text(413, 'decision too large');
302
+ if (form.getAll('part').length > 0)
303
+ return decideSession(store, server, makeKit, form, name, email);
212
304
  const decision = form.get('decision');
213
305
  const head = form.get('head') ?? '';
214
306
  const comment = (form.get('comment') ?? '').trim();
@@ -255,6 +347,232 @@ async function decide(store, server, makeKit, req, name, email) {
255
347
  landingRequested: kind === 'approve',
256
348
  };
257
349
  }
350
+ const forgeForCache = new WeakMap();
351
+ function forgeForOf(server) {
352
+ if (server.forgeFor !== undefined)
353
+ return server.forgeFor;
354
+ let forgeFor = forgeForCache.get(server);
355
+ if (forgeFor === undefined) {
356
+ const env = server.env ?? {};
357
+ forgeFor = storeForges({ env, git: server.git, now: () => server.now() }, server.forge);
358
+ forgeForCache.set(server, forgeFor);
359
+ }
360
+ return forgeFor;
361
+ }
362
+ /**
363
+ * Null when the store has no `origin` to name it by — no session can be homed in it then. `fresh` skips the
364
+ * page-view cache of the store's own open pull requests, as a decision must.
365
+ */
366
+ async function readSessionView(store, server, fresh) {
367
+ const canonical = await mainWorktreePath(server.git, store.root);
368
+ const reach = await storeReach(server.git, { root: store.root, canonical, config: store.config, forge: server.forge, credentials: server.credentials }, forgeForOf(server));
369
+ if (reach === null)
370
+ return null;
371
+ const listings = await listOpen(reach, fresh ? undefined : () => openPulls(server));
372
+ return { reach, listings, groups: groupSessions(reach, listings) };
373
+ }
374
+ /** The session homed here whose page is `name`: the home session's branch as a worktree directory name. */
375
+ function findSession(view, name) {
376
+ return view.groups.find((g) => g.homedHere && sessionRouteName(g.identity) === name);
377
+ }
378
+ function partStore(view, store) {
379
+ return store === view.reach.home ? view.reach.home.slug : (store.mountName ?? store.slug);
380
+ }
381
+ /** The session's parts, named or open, on a store whose pull requests could not be read: no decision is offered then. */
382
+ function unreadableParts(view, state) {
383
+ const unreadable = view.listings.filter((l) => l.error !== null).map((l) => l.store.slug);
384
+ return state.parts.filter((p) => unreadable.includes(p.storeSlug)).map((p) => `${p.storeSlug}#${p.number}`);
385
+ }
386
+ /**
387
+ * change-approval spec ("The approval page shows every part of a session"): the
388
+ * checkout a part's change is read in. The home store's session worktree when
389
+ * it is here; otherwise the store's own clone, after fetching the part's head
390
+ * into a ref namespace of this server's own (`refs/ctxr-approve/<number>`),
391
+ * which leaves the clone's branches and working tree untouched.
392
+ */
393
+ async function partCheckout(server, view, store, pull) {
394
+ if (store === view.reach.home) {
395
+ const worktree = (await sessionWorktrees(server.git, store.root, store.config)).find((w) => w.branch === pull.headRef);
396
+ if (worktree !== undefined) {
397
+ const there = await server.git.run(['cat-file', '-e', `${pull.headSha}^{commit}`], { cwd: worktree.path, allowFailure: true });
398
+ if (there.exitCode === 0)
399
+ return worktree.path;
400
+ }
401
+ }
402
+ const present = async () => (await server.git.run(['cat-file', '-e', `${pull.headSha}^{commit}`], { cwd: store.root, allowFailure: true })).exitCode === 0;
403
+ if (await present())
404
+ return store.root;
405
+ const refspec = `+refs/heads/${pull.headRef}:refs/ctxr-approve/${pull.number}`;
406
+ const env = server.env ?? {};
407
+ await runRemoteGit(server.git, store.credentials ?? ghCredentials(env), env, ['fetch', '--quiet', '--no-tags', 'origin', refspec], { cwd: store.root, allowFailure: true });
408
+ return (await present()) ? store.root : null;
409
+ }
410
+ async function renderSessionPage(server, kit, view, group, html, title, notice) {
411
+ let state = null;
412
+ try {
413
+ state = await forgeRead(() => sessionState({ git: server.git, reach: view.reach, identity: group.identity, homedHere: true, open: group.parts, recordingLogin: server.approval.recording_login }));
414
+ }
415
+ catch (err) {
416
+ if (!(err instanceof ForgeUnreachableError))
417
+ throw err;
418
+ }
419
+ let hasDiagram = false;
420
+ html.push(`<p>${escapeHtml(copy('session-intro').replace('__COUNT__', String(group.parts.length)))}</p>`);
421
+ if (state !== null)
422
+ html.push(`<p>${escapeHtml(copy('state-line').replace('__STATE__', STATE_LABEL[state.state]))}</p>`);
423
+ else
424
+ html.push(`<p class="ctxr-approve-notice">${escapeHtml(copy('forge-unreachable'))}</p>`);
425
+ for (const listing of view.listings) {
426
+ if (listing.error !== null)
427
+ html.push(`<p class="ctxr-approve-notice">${escapeHtml(copy('store-unreachable').replace('__STORE__', storeLabel(listing.store)))}</p>`);
428
+ }
429
+ let decidable = state !== null && unreadableParts(view, state).length === 0;
430
+ const fields = [];
431
+ // Landing order: the parts in mounted stores first, then this store's.
432
+ for (const part of group.parts) {
433
+ const { store, pull } = part;
434
+ const pullLink = `<a href="${escapeHtml(pull.url)}">#${pull.number}</a>`;
435
+ const heading = escapeHtml(copy('part-heading'))
436
+ .replace('__STORE__', () => escapeHtml(partStore(view, store)))
437
+ .replace('__PULL__', () => pullLink);
438
+ html.push(`<section class="ctxr-approve-part"><h2>${heading}</h2>`, `<p>Commit <code>${escapeHtml(pull.headSha)}</code>.</p>`);
439
+ const cwd = await partCheckout(server, view, store, pull);
440
+ if (cwd === null) {
441
+ decidable = false;
442
+ html.push(`<p class="ctxr-approve-notice">${escapeHtml(copy('head-missing')).replace('__HEAD__', () => `<code>${escapeHtml(pull.headSha)}</code>`)}</p>`, '</section>');
443
+ continue;
444
+ }
445
+ const location = store.mountName === null ? null : `shared/${store.mountName}`;
446
+ const sections = await changeSections(server, kit, {
447
+ cwd,
448
+ prefixRoot: store.root,
449
+ config: store.config,
450
+ memoName: `${store.slug}#${pull.number}`,
451
+ pull,
452
+ linkFor: location === null ? kit.linkFor : (notePath) => kit.linkFor(`${location}/${notePath}`),
453
+ });
454
+ hasDiagram ||= sections.hasDiagram;
455
+ html.push(...sections.html, '</section>');
456
+ fields.push(`<input type="hidden" name="part" value="${escapeHtml(`${store.slug}#${pull.number}@${pull.headSha}`)}">`);
457
+ }
458
+ if (!decidable) {
459
+ html.push(`<section class="ctxr-decision"><h2>${escapeHtml(copy('decision-heading'))}</h2><p class="ctxr-approve-notice">${escapeHtml(copy('session-no-decision'))}</p></section>`);
460
+ }
461
+ else {
462
+ const action = `${approveRoute(sessionRouteName(group.identity))}decision`;
463
+ const hidden = fields.join('');
464
+ html.push(`<section class="ctxr-decision"><h2>${escapeHtml(copy('decision-heading'))}</h2><p>${escapeHtml(copy('session-decision-note'))}</p>`, `<form method="post" action="${action}"><input type="hidden" name="decision" value="approve">${hidden}<button type="submit">${escapeHtml(copy('approve-button'))}</button></form>`, `<form method="post" action="${action}"><input type="hidden" name="decision" value="request-changes">${hidden}` +
465
+ `<label for="ctxr-comment">${escapeHtml(copy('comment-label'))}</label><textarea id="ctxr-comment" name="comment" rows="5" required></textarea>` +
466
+ `<button type="submit">${escapeHtml(copy('request-changes-button'))}</button></form>`, '</section>');
467
+ }
468
+ return { status: notice.status ?? 200, contentType: 'text/html; charset=utf-8', body: await kit.page(title, { html: html.join('\n'), hasDiagram }) };
469
+ }
470
+ const PART_FIELD = /^([^/#@\s]+\/[^/#@\s]+)#(\d+)@([0-9a-f]{40})$/;
471
+ /**
472
+ * change-approval spec ("A session is decided only on its home store's page,
473
+ * for every part it names"): the parts posted must be exactly the session's
474
+ * open parts at the heads the page showed, every store they are on must be
475
+ * readable, and the decider must be a decider of every one of those stores.
476
+ * Anything else records nothing. An accepted decision gets one id; its record
477
+ * review goes on the record part first, then a copy on every other part. A
478
+ * copy that fails leaves the decision standing, and the reply says which.
479
+ */
480
+ async function decideSession(store, server, makeKit, form, name, email) {
481
+ const decision = form.get('decision');
482
+ const comment = (form.get('comment') ?? '').trim();
483
+ const posted = [];
484
+ for (const value of form.getAll('part')) {
485
+ const m = PART_FIELD.exec(value);
486
+ if (m === null)
487
+ return text(400, 'malformed decision');
488
+ posted.push({ store: m[1], number: Number.parseInt(m[2], 10), head: m[3] });
489
+ }
490
+ if (decision !== 'approve' && decision !== 'request-changes')
491
+ return text(400, 'malformed decision');
492
+ if (comment.length > MAX_COMMENT_CHARS)
493
+ return text(413, 'comment too long');
494
+ const view = await readSessionView(store, server, true);
495
+ if (view === null)
496
+ return text(404, 'not found');
497
+ const page = async (notice) => renderChangePage(store, server, await makeKit(), name, notice);
498
+ const elsewhere = view.groups.find((g) => !g.homedHere && sessionRouteName(g.identity) === name);
499
+ if (elsewhere !== undefined)
500
+ return text(409, copy('homed-elsewhere').replace('__HOME__', elsewhere.identity.home));
501
+ const group = findSession(view, name);
502
+ if (group === undefined)
503
+ return text(404, 'not found');
504
+ const postedStores = new Set(posted.map((p) => p.store));
505
+ const unreadable = view.listings.find((l) => l.error !== null && (postedStores.has(l.store.slug) || l.store === view.reach.home));
506
+ if (unreadable !== undefined)
507
+ return page({ status: 502, message: copy('store-unreachable').replace('__STORE__', storeLabel(unreadable.store)) });
508
+ const current = group.parts.map((p) => `${p.store.slug}#${p.pull.number}@${p.pull.headSha}`).sort();
509
+ const sent = posted.map((p) => `${p.store}#${p.number}@${p.head}`).sort();
510
+ if (current.length !== sent.length || current.some((c, i) => c !== sent[i]))
511
+ return page({ status: 409, message: copy('parts-changed') });
512
+ if (decision === 'request-changes' && comment === '')
513
+ return page({ status: 400, message: copy('comment-required') });
514
+ // D5: every store the session changes must count this person among its deciders, read from the forge now.
515
+ for (const part of group.parts) {
516
+ if (part.store === view.reach.home)
517
+ continue;
518
+ let deciders;
519
+ try {
520
+ deciders = await decidersOnForge(server.git, part.store);
521
+ }
522
+ catch (err) {
523
+ if (!(err instanceof ForgeRequestError))
524
+ throw err;
525
+ return page({ status: 502, message: copy('record-failed') });
526
+ }
527
+ if (!isDecider(email, deciders))
528
+ return page({ status: 403, message: copy('not-decider').replace('__STORE__', storeLabel(part.store)) });
529
+ }
530
+ const kind = decision;
531
+ const at = server.now().toISOString();
532
+ const parts = group.parts.map((p) => ({ store: p.store.slug, number: p.pull.number, head: p.pull.headSha }));
533
+ const session = { id: randomUUID(), session: group.identity, parts };
534
+ const record = recordPartOf(session);
535
+ const post = async (ref) => {
536
+ const part = group.parts.find((p) => p.store.slug === ref.store && p.pull.number === ref.number);
537
+ await part.store.forge.createReview(ref.number, {
538
+ event: kind === 'approve' ? 'APPROVE' : 'REQUEST_CHANGES',
539
+ commitId: ref.head,
540
+ body: decisionBody({ decision: kind, head: ref.head, by: email, at, carriedFrom: null, comment: kind === 'approve' ? null : comment, session }),
541
+ });
542
+ };
543
+ try {
544
+ await post(record);
545
+ }
546
+ catch (err) {
547
+ if (!(err instanceof ForgeRequestError))
548
+ throw err;
549
+ return page({ status: 502, message: copy('record-failed') });
550
+ }
551
+ const missing = [];
552
+ for (const ref of parts) {
553
+ if (ref === record)
554
+ continue;
555
+ try {
556
+ await post(ref);
557
+ }
558
+ catch (err) {
559
+ if (!(err instanceof ForgeRequestError))
560
+ throw err;
561
+ missing.push(`${ref.store}#${ref.number}`);
562
+ }
563
+ }
564
+ if (missing.length > 0) {
565
+ const reply = await page({ status: 200, message: copy('copies-missing').replace('__PARTS__', missing.join(', ')) });
566
+ return { ...reply, landingRequested: kind === 'approve' };
567
+ }
568
+ return {
569
+ status: 303,
570
+ contentType: 'text/plain; charset=utf-8',
571
+ body: 'recorded\n',
572
+ headers: { Location: approveRoute(name) },
573
+ landingRequested: kind === 'approve',
574
+ };
575
+ }
258
576
  /**
259
577
  * Answers every request under the approval prefix. `server` is null when the
260
578
  * store declares no change approval, and then every such path is `404` —