carthagent 0.0.0-stage → 1.0.7

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 (379) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -2
  3. package/bin/ctg.mjs +736 -0
  4. package/docs/budgets.md +72 -0
  5. package/docs/compaction.md +34 -0
  6. package/docs/context-efficiency-plan.md +24 -0
  7. package/docs/context-efficiency-review.md +27 -0
  8. package/docs/context-efficiency.md +49 -0
  9. package/docs/deepseek-jev-benchmark-plan.md +70 -0
  10. package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
  11. package/docs/deepseek-jev-benchmark-restart.md +9 -0
  12. package/docs/deepseek-jev-benchmark-results.md +77 -0
  13. package/docs/deepseek-jev-benchmark-running.md +60 -0
  14. package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
  15. package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
  16. package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
  17. package/docs/deepseek-jev-http400-diagnosis.md +45 -0
  18. package/docs/delivery-review-evidence.md +53 -0
  19. package/docs/delivery-timeout-repair-plan.md +51 -0
  20. package/docs/evaluation-readiness.md +31 -0
  21. package/docs/evaluation.md +70 -0
  22. package/docs/extending.md +354 -0
  23. package/docs/glm-pilot-execution.md +9 -0
  24. package/docs/glm-pilot-plan.md +15 -0
  25. package/docs/glm-pilot-results.md +71 -0
  26. package/docs/graph-context-optimization.md +15 -0
  27. package/docs/jev-repository-pilot-plan.md +25 -0
  28. package/docs/jev-repository-pilot-protocol.md +39 -0
  29. package/docs/jev-repository-pilot-restart.md +7 -0
  30. package/docs/jev-repository-pilot-results.md +79 -0
  31. package/docs/jev.md +32 -0
  32. package/docs/model-registry.md +25 -0
  33. package/docs/native-compatibility.md +117 -0
  34. package/docs/opencode-archive.md +9 -0
  35. package/docs/opencode-migration.md +115 -0
  36. package/docs/opencode-runtime.md +94 -0
  37. package/docs/opencode-workspace.md +31 -0
  38. package/docs/owned-runtime-workflow-registration.md +17 -0
  39. package/docs/plan-advisor-evaluation.md +38 -0
  40. package/docs/planning-startup-fix-plan.md +23 -0
  41. package/docs/planning-startup-fix-review.md +24 -0
  42. package/docs/planning.md +118 -0
  43. package/docs/production-merge-review.md +23 -0
  44. package/docs/quality.md +35 -0
  45. package/docs/repository-benchmark-design.md +111 -0
  46. package/docs/review-assurance.md +9 -0
  47. package/docs/review-experiment-results.md +59 -0
  48. package/docs/tested-planning-roadmap.md +58 -0
  49. package/docs/tested-planning.md +58 -0
  50. package/docs/tui-pickers.md +9 -0
  51. package/docs/webdriver.md +83 -0
  52. package/docs/workflow-modes.md +26 -0
  53. package/docs/workflow.d2 +17 -0
  54. package/docs/workflow.svg +149 -0
  55. package/docs/workspace-coordination.md +9 -0
  56. package/evaluate.mjs +249 -0
  57. package/extensions/codegraph.ts +36 -0
  58. package/extensions/delivery.ts +819 -0
  59. package/extensions/experiential.ts +21 -0
  60. package/extensions/subscriptions.ts +21 -0
  61. package/guidance/profiles/authenticated-web.json +24 -0
  62. package/guidance/profiles/data-visualization.json +25 -0
  63. package/guidance/profiles/database-change.json +24 -0
  64. package/guidance/profiles/external-api.json +24 -0
  65. package/guidance/profiles/transactional-data.json +25 -0
  66. package/guidance/profiles/web-application.json +26 -0
  67. package/lib/acceptance-review.mjs +4 -0
  68. package/lib/browser-bin.mjs +160 -0
  69. package/lib/browser-errors.mjs +54 -0
  70. package/lib/browser.mjs +219 -0
  71. package/lib/budget.mjs +37 -0
  72. package/lib/cdp.mjs +221 -0
  73. package/lib/cloud/client.mjs +275 -0
  74. package/lib/cloud/control-plane.mjs +350 -0
  75. package/lib/cloud/gateway.mjs +380 -0
  76. package/lib/cloud/http.mjs +135 -0
  77. package/lib/cloud/ledger.mjs +215 -0
  78. package/lib/codegraph-query.mjs +60 -0
  79. package/lib/codegraph.mjs +378 -0
  80. package/lib/context-archive.mjs +18 -0
  81. package/lib/delivery-context.mjs +132 -0
  82. package/lib/delivery-guidance.mjs +9 -0
  83. package/lib/delivery-review.mjs +94 -0
  84. package/lib/delivery.mjs +610 -0
  85. package/lib/engine.mjs +137 -0
  86. package/lib/guidance.mjs +71 -0
  87. package/lib/jev-advisor.mjs +47 -0
  88. package/lib/opencode/archive.mjs +122 -0
  89. package/lib/opencode/compatibility.mjs +19 -0
  90. package/lib/opencode/credentials.mjs +205 -0
  91. package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
  92. package/lib/opencode/evaluation.mjs +242 -0
  93. package/lib/opencode/file-lock.mjs +86 -0
  94. package/lib/opencode/headless.mjs +200 -0
  95. package/lib/opencode/review.mjs +213 -0
  96. package/lib/opencode/runtime.mjs +246 -0
  97. package/lib/opencode/service.mjs +249 -0
  98. package/lib/opencode/session-options.mjs +60 -0
  99. package/lib/opencode/workspace.mjs +133 -0
  100. package/lib/openrouter-jev.mjs +37 -0
  101. package/lib/outcome-contract.mjs +21 -0
  102. package/lib/plan-advisor.mjs +38 -0
  103. package/lib/planning-access.mjs +7 -0
  104. package/lib/planning-advice.mjs +28 -0
  105. package/lib/planning-discovery.mjs +14 -0
  106. package/lib/planning-guide.mjs +34 -0
  107. package/lib/planning-view.mjs +57 -0
  108. package/lib/planning.mjs +155 -0
  109. package/lib/probe-diagnostics.mjs +13 -0
  110. package/lib/providers/experiential.mjs +292 -0
  111. package/lib/providers/model-catalog.json +700 -0
  112. package/lib/providers/model-catalog.mjs +7 -0
  113. package/lib/providers/names.mjs +13 -0
  114. package/lib/providers/subscription-models.mjs +483 -0
  115. package/lib/reports.mjs +80 -0
  116. package/lib/review-state.mjs +49 -0
  117. package/lib/review.mjs +114 -0
  118. package/lib/scenarios.mjs +99 -0
  119. package/lib/ship-auth.mjs +44 -0
  120. package/lib/ship-jev.mjs +59 -0
  121. package/lib/ship-telemetry.mjs +183 -0
  122. package/lib/tui/ansi.mjs +255 -0
  123. package/lib/tui/app.mjs +2445 -0
  124. package/lib/tui/auth.mjs +249 -0
  125. package/lib/tui/carthage.mjs +109 -0
  126. package/lib/tui/clipboard.mjs +122 -0
  127. package/lib/tui/d2.mjs +431 -0
  128. package/lib/tui/feed.mjs +467 -0
  129. package/lib/tui/framing.mjs +80 -0
  130. package/lib/tui/glyphs.mjs +172 -0
  131. package/lib/tui/hash-worker.mjs +11 -0
  132. package/lib/tui/mock-engine.mjs +222 -0
  133. package/lib/tui/models.mjs +73 -0
  134. package/lib/tui/picker.mjs +18 -0
  135. package/lib/tui/theme.mjs +663 -0
  136. package/lib/tui/title.mjs +7 -0
  137. package/lib/update-channel.mjs +62 -0
  138. package/lib/update.mjs +230 -0
  139. package/lib/webdriver.mjs +82 -0
  140. package/lib/workflow.mjs +88 -0
  141. package/lib/workspace.mjs +39 -0
  142. package/metadata.json +8 -0
  143. package/package.json +94 -4
  144. package/plugins/carthagent/index.ts +3 -0
  145. package/plugins/carthagent/package.json +28 -0
  146. package/plugins/carthagent/src/archive-rpc.ts +46 -0
  147. package/plugins/carthagent/src/bootstrap.ts +38 -0
  148. package/plugins/carthagent/src/index.ts +62 -0
  149. package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
  150. package/plugins/carthagent/src/rpc.ts +76 -0
  151. package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
  152. package/plugins/carthagent/src/server/budgets.ts +21 -0
  153. package/plugins/carthagent/src/server/codegraph.ts +46 -0
  154. package/plugins/carthagent/src/server/delivery.ts +488 -0
  155. package/plugins/carthagent/src/server/device-auth.ts +91 -0
  156. package/plugins/carthagent/src/server/native-delivery.ts +26 -0
  157. package/plugins/carthagent/src/server/orchestration.ts +56 -0
  158. package/plugins/carthagent/src/server/policy.ts +24 -0
  159. package/plugins/carthagent/src/server/providers.ts +424 -0
  160. package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
  161. package/plugins/carthagent/src/server/session-controls.ts +82 -0
  162. package/plugins/carthagent/src/server/telemetry.ts +92 -0
  163. package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
  164. package/plugins/carthagent/src/session-rpc.ts +31 -0
  165. package/plugins/carthagent/src/testing.ts +85 -0
  166. package/plugins/carthagent/src/workspace-rpc.ts +133 -0
  167. package/plugins/carthagent/test-plugin/index.ts +4 -0
  168. package/plugins/carthagent/test-plugin/package.json +1 -0
  169. package/plugins/carthagent/tsconfig.json +19 -0
  170. package/plugins/carthagent/tui.ts +1 -0
  171. package/prompts/delivery.md +7 -0
  172. package/public/index.html +983 -0
  173. package/scenarios/cli/grade_cli.py +69 -0
  174. package/scenarios/cli/scenario.json +21 -0
  175. package/scenarios/game/baseline/minecraft.py +193 -0
  176. package/scenarios/game/context.md +1 -0
  177. package/scenarios/game/grade_minecraft_clone.py +603 -0
  178. package/scenarios/game/requirements.txt +2 -0
  179. package/scenarios/game/scenario.json +33 -0
  180. package/server.mjs +341 -0
  181. package/skills/game-development/SKILL.md +36 -0
  182. package/skills/game-development/assets/ursina_starter.py +98 -0
  183. package/skills/game-development/scripts/verify_ursina.py +80 -0
  184. package/skills/software-delivery/SKILL.md +50 -0
  185. package/skills/typescript-delivery/SKILL.md +69 -0
  186. package/tests/acceptance-review.test.mjs +13 -0
  187. package/tests/auth.test.mjs +234 -0
  188. package/tests/bounded-summary.test.mjs +68 -0
  189. package/tests/browser-bin.test.mjs +91 -0
  190. package/tests/browser.test.mjs +96 -0
  191. package/tests/budget-engine.test.mjs +110 -0
  192. package/tests/budget.test.mjs +23 -0
  193. package/tests/cli.test.mjs +245 -0
  194. package/tests/clipboard.test.mjs +79 -0
  195. package/tests/cloud-client.test.mjs +129 -0
  196. package/tests/cloud-control-plane.test.mjs +144 -0
  197. package/tests/cloud-gateway.test.mjs +147 -0
  198. package/tests/cloud-http.test.mjs +102 -0
  199. package/tests/cloud-ledger.test.mjs +75 -0
  200. package/tests/codegraph-query.test.mjs +66 -0
  201. package/tests/codegraph.test.mjs +142 -0
  202. package/tests/delivery-context.test.mjs +66 -0
  203. package/tests/delivery-review.test.mjs +139 -0
  204. package/tests/delivery.test.mjs +343 -0
  205. package/tests/engine-api.mjs +24 -0
  206. package/tests/experiential-extension.test.mjs +37 -0
  207. package/tests/experiential.test.mjs +300 -0
  208. package/tests/extension.test.mjs +921 -0
  209. package/tests/fixtures/archive-plugin/index.ts +10 -0
  210. package/tests/guidance.test.mjs +78 -0
  211. package/tests/model-registry.test.mjs +68 -0
  212. package/tests/models.test.mjs +70 -0
  213. package/tests/openai-idempotency.test.mjs +99 -0
  214. package/tests/opencode-archive-rpc.test.mjs +82 -0
  215. package/tests/opencode-delivery.test.mjs +231 -0
  216. package/tests/opencode-providers.test.mjs +414 -0
  217. package/tests/opencode-runtime.test.mjs +455 -0
  218. package/tests/opencode-session-controls.test.mjs +115 -0
  219. package/tests/opencode-session-options.test.mjs +26 -0
  220. package/tests/opencode-workspace.test.mjs +339 -0
  221. package/tests/outcome-contract.test.mjs +17 -0
  222. package/tests/picker.test.mjs +34 -0
  223. package/tests/plan-advisor.test.mjs +88 -0
  224. package/tests/plan-review-loop.test.mjs +53 -0
  225. package/tests/planning-advice.test.mjs +27 -0
  226. package/tests/planning-contract.test.mjs +65 -0
  227. package/tests/planning-startup.test.mjs +38 -0
  228. package/tests/planning-view.test.mjs +33 -0
  229. package/tests/planning.test.mjs +223 -0
  230. package/tests/probe-diagnostics.test.mjs +21 -0
  231. package/tests/pull-request-rpc.test.mjs +366 -0
  232. package/tests/review-state.test.mjs +32 -0
  233. package/tests/review.test.mjs +127 -0
  234. package/tests/server.test.mjs +252 -0
  235. package/tests/ship-jev.test.mjs +77 -0
  236. package/tests/ship-telemetry.test.mjs +333 -0
  237. package/tests/standard-workflow.test.mjs +28 -0
  238. package/tests/subscription-models.test.mjs +416 -0
  239. package/tests/subscriptions-extension.test.mjs +27 -0
  240. package/tests/terminal-title.test.mjs +13 -0
  241. package/tests/test_grade_minecraft_clone.py +199 -0
  242. package/tests/test_starter.py +78 -0
  243. package/tests/theme.test.mjs +175 -0
  244. package/tests/tui-copy-paste.test.mjs +54 -0
  245. package/tests/tui.test.mjs +808 -0
  246. package/tests/update-channels.test.mjs +129 -0
  247. package/tests/update.test.mjs +175 -0
  248. package/tests/webdriver.test.mjs +31 -0
  249. package/tests/workflow.test.mjs +47 -0
  250. package/tests/workspace-rpc.test.mjs +236 -0
  251. package/tests/workspace.test.mjs +24 -0
  252. package/tools/benchmark/advisor.test.mjs +7 -0
  253. package/tools/benchmark/advisor.ts +27 -0
  254. package/tools/benchmark/campaign.mjs +45 -0
  255. package/tools/benchmark/diagnose-request.mjs +40 -0
  256. package/tools/benchmark/eval-common.mjs +32 -0
  257. package/tools/benchmark/eval-ledger.mjs +22 -0
  258. package/tools/benchmark/eval-shop.mjs +26 -0
  259. package/tools/benchmark/eval-voxel.mjs +20 -0
  260. package/tools/benchmark/evaluate.mjs +24 -0
  261. package/tools/benchmark/evaluators.test.mjs +13 -0
  262. package/tools/benchmark/fixtures/shop/server.mjs +18 -0
  263. package/tools/benchmark/fixtures/shop/ui.html +8 -0
  264. package/tools/benchmark/freeze.mjs +30 -0
  265. package/tools/benchmark/gateway.mjs +91 -0
  266. package/tools/benchmark/gateway.test.mjs +38 -0
  267. package/tools/benchmark/generator.mjs +11 -0
  268. package/tools/benchmark/generator.test.mjs +26 -0
  269. package/tools/benchmark/guard.test.mjs +133 -0
  270. package/tools/benchmark/guard.ts +136 -0
  271. package/tools/benchmark/native-plugin/index.ts +24 -0
  272. package/tools/benchmark/planning-startup.test.mjs +47 -0
  273. package/tools/benchmark/preflight-live.mjs +31 -0
  274. package/tools/benchmark/preflight.mjs +22 -0
  275. package/tools/benchmark/replay-diagnostic.mjs +23 -0
  276. package/tools/benchmark/runner.mjs +141 -0
  277. package/tools/benchmark/runner.test.mjs +93 -0
  278. package/tools/benchmark/runtime.mjs +23 -0
  279. package/tools/benchmark/selftest.mjs +47 -0
  280. package/tools/benchmark/smoke-jev.mjs +5 -0
  281. package/tools/benchmark/tasks.mjs +20 -0
  282. package/tools/benchmark/transport.mjs +65 -0
  283. package/tools/benchmark/transport.test.mjs +44 -0
  284. package/tools/benchmark/upstream-error.mjs +18 -0
  285. package/tools/benchmark/upstream-error.test.mjs +10 -0
  286. package/tools/browser-check.mjs +109 -0
  287. package/tools/check-syntax.mjs +27 -0
  288. package/tools/evaluate-plan-advisor.mjs +20 -0
  289. package/tools/native-fixture.mjs +194 -0
  290. package/tools/native-pty-smoke.py +108 -0
  291. package/tools/native-report-plugin/index.ts +50 -0
  292. package/tools/native-report-plugin/package.json +7 -0
  293. package/tools/patch-compaction.mjs +24 -0
  294. package/tools/patch-model-catalog.mjs +12 -0
  295. package/tools/patch-print-shutdown.mjs +39 -0
  296. package/tools/repo-pilot/evaluator.cjs +32 -0
  297. package/tools/repo-pilot/reference.mjs +43 -0
  298. package/tools/repo-pilot/runner.mjs +85 -0
  299. package/tools/repo-pilot/sandbox-entry.mjs +12 -0
  300. package/tools/repo-pilot/tasks.mjs +20 -0
  301. package/tools/repo-pilot/type-evaluator.cjs +6 -0
  302. package/tools/test-native-controls.mjs +84 -0
  303. package/tools/test-native-delivery-host.mjs +307 -0
  304. package/tools/test-native-evaluation.mjs +46 -0
  305. package/tools/test-native-host.mjs +385 -0
  306. package/tools/test-native-outcomes.mjs +73 -0
  307. package/tools/test-native-package.mjs +70 -0
  308. package/tools/test-native-pty.mjs +20 -0
  309. package/tools/test-native-report.mjs +88 -0
  310. package/tools/test-native-swe-host.mjs +132 -0
  311. package/tools/test-native-swe.mjs +122 -0
  312. package/tools/test-picker-pty.py +45 -0
  313. package/tools/test-python.mjs +29 -0
  314. package/tools/test-webdriver.mjs +54 -0
  315. package/tools/verify-release-version.mjs +37 -0
  316. package/tools/webdriver-check.mjs +52 -0
  317. package/vendor/agent/bounded-summary.mjs +88 -0
  318. package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
  319. package/vendor/agent/chunks/anthropic.js +75 -0
  320. package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
  321. package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
  322. package/vendor/agent/chunks/chord-context.js +96 -0
  323. package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
  324. package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
  325. package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
  326. package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
  327. package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
  328. package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
  329. package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
  330. package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
  331. package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
  332. package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
  333. package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
  334. package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
  335. package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
  336. package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
  337. package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
  338. package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
  339. package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
  340. package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
  341. package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
  342. package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
  343. package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
  344. package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
  345. package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
  346. package/vendor/agent/chunks/github-copilot.js +2 -0
  347. package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
  348. package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
  349. package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
  350. package/vendor/agent/chunks/image-resize-worker.js +2 -0
  351. package/vendor/agent/chunks/kimi-coding.js +2 -0
  352. package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
  353. package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
  354. package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
  355. package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
  356. package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
  357. package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
  358. package/vendor/agent/chunks/openai-codex.js +75 -0
  359. package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
  360. package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
  361. package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
  362. package/vendor/agent/chunks/openrouter.js +75 -0
  363. package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
  364. package/vendor/agent/chunks/radius.js +75 -0
  365. package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
  366. package/vendor/agent/chunks/xai.js +2 -0
  367. package/vendor/agent/cli.js +3 -0
  368. package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
  369. package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
  370. package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
  371. package/vendor/agent/index.js +2 -0
  372. package/vendor/agent/modes/interactive/theme/dark.json +91 -0
  373. package/vendor/agent/modes/interactive/theme/light.json +90 -0
  374. package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
  375. package/vendor/agent/package.json +14 -0
  376. package/vendor/agent/rpc-entry.js +3 -0
  377. package/vendor/agent/theme/dark.json +91 -0
  378. package/vendor/agent/theme/light.json +90 -0
  379. package/vendor/agent/theme/theme-schema.json +356 -0
@@ -0,0 +1,366 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync, realpathSync, lstatSync, unlinkSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { basename, join } from 'node:path';
7
+ import { createRequire } from 'node:module';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const jiti = require('jiti').createJiti(import.meta.url);
12
+ const { registerPullRequests } = await jiti.import('../plugins/carthagent/src/server/pull-request-rpc.ts');
13
+ const head = 'a'.repeat(40);
14
+ function git(cwd, ...args) { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
15
+ function fixture(t, sessionID = 'session-1') {
16
+ const root = mkdtempSync(join(tmpdir(), 'pr-rpc-')); t.after(() => rmSync(root, { recursive: true, force: true }));
17
+ const project = join(root, 'project'); const worktree = join(root, 'task'); mkdirSync(project); mkdirSync(worktree);
18
+ git(project, 'init', '-q'); git(worktree, 'init', '-q'); git(worktree, 'remote', 'add', 'origin', 'https://github.com/acme/repo.git');
19
+ writeFileSync(join(worktree, 'tracked'), 'base'); git(worktree, 'add', 'tracked'); git(worktree, '-c', 'user.name=test', '-c', 'user.email=test@example.com', 'commit', '-qm', 'base');
20
+ const actualHead = git(worktree, 'rev-parse', 'HEAD');
21
+ const taskDir = join(project, '.harness'); mkdirSync(taskDir);
22
+ writeFileSync(join(taskDir, 'opencode-tasks.json'), JSON.stringify({ version: 1, tasks: [{ id: 'task-1', task: 'test', sessionID, worktree, status: 'active' }], operations: [] }));
23
+ const taskSession = { id: sessionID, projectID: 'project-id', location: { directory: worktree } };
24
+ const handlers = {};
25
+ const ctx = {
26
+ location: { directory: project, project: { id: 'project-id', canonical: project } },
27
+ session: { async get({ sessionID: id }) { if (id !== sessionID) throw Error('missing session'); return taskSession; } },
28
+ rpc: { async register(_definition, methods) { Object.assign(handlers, methods); return { dispose() {} }; } },
29
+ };
30
+ const calls = [];
31
+ const run = async (command, args, cwd) => {
32
+ calls.push([command, args]);
33
+ if (command === 'git') return args[0] === 'remote' ? 'https://github.com/acme/repo.git' : actualHead;
34
+ const endpoint = args.at(-1);
35
+ const pull = endpoint.match(/\/pulls\/(\d+)$/);
36
+ if (pull) return JSON.stringify({ number: Number(pull[1]), html_url: `https://github.com/acme/repo/pull/${pull[1]}`, state: 'open', draft: true, head: { sha: head, ref: 'feature', repo: { full_name: 'acme/repo' } }, base: { sha: 'b'.repeat(40), ref: 'main', repo: { full_name: 'acme/repo' } } });
37
+ if (endpoint.includes('/check-runs')) return JSON.stringify([{ total_count: 1, check_runs: [{ status: 'completed', conclusion: 'success' }] }]);
38
+ if (endpoint.includes('/status')) return JSON.stringify([{ total_count: 1, statuses: [{ state: 'success' }] }]);
39
+ if (endpoint.includes('/reviews')) return JSON.stringify([[{ state: 'APPROVED', submitted_at: '2026-01-01T00:00:00Z', commit_id: head, user: { login: 'reviewer' } }]]);
40
+ throw Error(`unexpected endpoint ${endpoint}`);
41
+ };
42
+ return { project, worktree, handlers, ctx, run, calls };
43
+ }
44
+ const call = () => ({
45
+ signal: new AbortController().signal,
46
+ error(type, message, data) {
47
+ const error = new Error(message);
48
+ error.type = type;
49
+ error.data = data;
50
+ throw error;
51
+ },
52
+ });
53
+ const callWithSignal = signal => ({ ...call(), signal });
54
+
55
+ test('explicit associate persists authoritative GitHub metadata and list stays offline', async t => {
56
+ const f = fixture(t); const dispose = await registerPullRequests(f.ctx, { run: f.run }); t.after(dispose);
57
+ const associated = await f.handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call());
58
+ assert.equal(associated.pullRequest.draft, true);
59
+ assert.equal(associated.pullRequest.checks, 'passed');
60
+ assert.equal(associated.pullRequest.review, 'approved-unverified');
61
+ assert.equal(associated.pullRequest.source, 'stale-source');
62
+ assert.equal(associated.pullRequest.head, head);
63
+ assert.equal(associated.pullRequest.headRef, 'feature');
64
+ assert.equal(associated.pullRequest.base, 'b'.repeat(40));
65
+ assert.equal(associated.pullRequest.baseRef, 'main');
66
+ const before = f.calls.length;
67
+ const listed = await f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call());
68
+ assert.equal(listed.pullRequests[0].number, 7);
69
+ assert.equal(f.calls.length, before, 'cached render/list does not call GitHub');
70
+ writeFileSync(join(f.worktree, 'tracked'), 'changed');
71
+ const stale = await f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call());
72
+ assert.equal(stale.pullRequests[0].source, 'stale-source', 'local edits invalidate the cached source snapshot');
73
+ const cache = JSON.parse(readFileSync(join(f.worktree, '.harness', 'pull-requests.json'), 'utf8'));
74
+ assert.equal(cache.pullRequests[0].taskID, 'task-1');
75
+ writeFileSync(join(f.worktree, '.harness', 'pull-requests.json'), JSON.stringify({ version: 1, pullRequests: [{ ...cache.pullRequests[0], taskID: 'other-task' }] }));
76
+ await assert.rejects(f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call()), error => error.data?.code === 'not_owned');
77
+ });
78
+
79
+ test('same-HEAD tracked and untracked local edits keep PR source stale', async t => {
80
+ for (const kind of ['tracked', 'untracked']) await t.test(kind, async t => {
81
+ const f = fixture(t); const handlers = {};
82
+ const localHead = git(f.worktree, 'rev-parse', 'HEAD');
83
+ const run = async (command, args, cwd, signal) => {
84
+ if (command === 'git') return f.run(command, args, cwd, signal);
85
+ const endpoint = args.at(-1);
86
+ const pull = endpoint.match(/\/pulls\/(\d+)$/);
87
+ if (pull) return JSON.stringify({ number: Number(pull[1]), html_url: `https://github.com/acme/repo/pull/${pull[1]}`, state: 'open', draft: false, head: { sha: localHead, ref: 'main', repo: { full_name: 'acme/repo' } }, base: { sha: 'b'.repeat(40), ref: 'main', repo: { full_name: 'acme/repo' } } });
88
+ if (endpoint.includes('/check-runs')) return JSON.stringify([{ total_count: 0, check_runs: [] }]);
89
+ if (endpoint.includes('/status')) return JSON.stringify([{ total_count: 0, statuses: [] }]);
90
+ if (endpoint.includes('/reviews')) return JSON.stringify([[]]);
91
+ throw Error(`unexpected endpoint ${endpoint}`);
92
+ };
93
+ const dispose = await registerPullRequests({ ...f.ctx, rpc: { async register(_definition, methods) { Object.assign(handlers, methods); return { dispose() {} }; } } }, { run });
94
+ try {
95
+ if (kind === 'tracked') writeFileSync(join(f.worktree, 'tracked'), 'modified without committing');
96
+ else writeFileSync(join(f.worktree, 'untracked-local-change'), 'not committed');
97
+ const result = await handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call());
98
+ assert.equal(result.pullRequest.head, localHead, 'fixture PR head exactly equals local HEAD');
99
+ assert.equal(result.pullRequest.source, 'stale-source');
100
+ const listed = await handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call());
101
+ assert.equal(listed.pullRequests[0].source, 'stale-source');
102
+ } finally { await dispose(); }
103
+ });
104
+ });
105
+
106
+ test('rejects malformed GitHub metadata, inaccessible GitHub, and unassociated refreshes', async t => {
107
+ const f = fixture(t); const dispose = await registerPullRequests(f.ctx, { run: async (command, args, cwd) => {
108
+ if (command === 'git') return f.run(command, args, cwd);
109
+ return '{malformed';
110
+ } }); t.after(dispose);
111
+ const { PullRequests } = await jiti.import('../plugins/carthagent/src/pull-request-rpc.ts');
112
+ for (const method of ['list', 'associate', 'refresh']) assert.ok(PullRequests.methods[method].errors.pull_request_error, `${method} declares diagnosable errors`);
113
+ await assert.rejects(f.handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call()), error => error.data?.code === 'github_invalid');
114
+ await assert.rejects(f.handlers.refresh({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call()), error => error.data?.code === 'not_associated');
115
+ const inaccessibleHandlers = {};
116
+ const inaccessible = await registerPullRequests({ ...f.ctx, rpc: { async register(_definition, methods) { Object.assign(inaccessibleHandlers, methods); return { dispose() {} }; } } }, { run: async (command, args, cwd) => command === 'git' ? f.run(command, args, cwd) : Promise.reject(Error('gh unavailable')) });
117
+ try { await assert.rejects(inaccessibleHandlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call()), error => error.data?.code === 'github_unavailable'); }
118
+ finally { await inaccessible(); }
119
+ });
120
+
121
+ test('rejects cross-task, foreign-session, and unavailable session ownership', async t => {
122
+ const f = fixture(t); const dispose = await registerPullRequests(f.ctx, { run: f.run }); t.after(dispose);
123
+ await assert.rejects(f.handlers.list({ sessionID: 'session-1', taskID: 'other-task' }, call()), error => error.data?.code === 'not_owned');
124
+ await assert.rejects(f.handlers.list({ sessionID: 'foreign-session', taskID: 'task-1' }, call()), error => error.data?.code === 'not_owned');
125
+ const sessionGet = f.ctx.session.get;
126
+ f.ctx.session.get = async () => { throw Error('session unavailable'); };
127
+ await assert.rejects(f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call()), error => error.data?.code === 'internal');
128
+ f.ctx.session.get = sessionGet;
129
+ });
130
+
131
+ test('canonicalizes aliased ancestors while rejecting a symlinked task worktree leaf', async t => {
132
+ const f = fixture(t);
133
+ const { canonicalDirectory } = await jiti.import('../plugins/carthagent/src/server/pull-request-rpc.ts');
134
+ const parent = join(f.worktree, '..');
135
+ const parentAlias = join(parent, 'root-alias'); symlinkSync(parent, parentAlias);
136
+ assert.equal(canonicalDirectory(join(parentAlias, basename(f.worktree))), realpathSync(f.worktree), 'a symlinked ancestor is resolved to its real path');
137
+ const aliasRoot = join(f.project, 'alias'); symlinkSync(f.worktree, aliasRoot);
138
+ const statePath = join(f.project, '.harness', 'opencode-tasks.json');
139
+ const state = JSON.parse(readFileSync(statePath, 'utf8'));
140
+ state.tasks[0].worktree = join(parentAlias, basename(f.worktree));
141
+ writeFileSync(statePath, JSON.stringify(state));
142
+ f.ctx.session.get = async () => ({ id: 'session-1', projectID: 'project-id', location: { directory: f.worktree } });
143
+ const dispose = await registerPullRequests(f.ctx, { run: f.run });
144
+ try {
145
+ assert.deepEqual((await f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call())).pullRequests, []);
146
+ await assert.rejects(Promise.resolve().then(() => canonicalDirectory(aliasRoot)), error => error.code === 'not_owned');
147
+ } finally { await dispose(); }
148
+ assert.equal(lstatSync(aliasRoot).isSymbolicLink(), true);
149
+ assert.equal(realpathSync(f.worktree), canonicalDirectory(f.worktree));
150
+ });
151
+
152
+ test('head-bound reviews keep current changes requests blocking and incomplete checks partial', async t => {
153
+ const f = fixture(t); const handlers = {};
154
+ const run = async (command, args, cwd) => {
155
+ if (command === 'git') return f.run(command, args, cwd);
156
+ const endpoint = args.at(-1);
157
+ if (endpoint.endsWith('/pulls/7')) return f.run(command, args, cwd);
158
+ if (endpoint.includes('/check-runs')) return JSON.stringify([{ total_count: 1, check_runs: [] }]);
159
+ if (endpoint.includes('/status')) return JSON.stringify([{ total_count: 1, statuses: [{ state: 'success' }] }]);
160
+ if (endpoint.includes('/reviews')) return JSON.stringify([[{ state: 'APPROVED', submitted_at: '2026-01-01', commit_id: 'c'.repeat(40), user: { login: 'reviewer-a' } }, { state: 'APPROVED', submitted_at: '2026-01-02', commit_id: head, user: { login: 'reviewer-a' } }, { state: 'CHANGES_REQUESTED', submitted_at: '2026-01-03', commit_id: head, user: { login: 'reviewer-b' } }]]);
161
+ throw Error(`unexpected endpoint ${endpoint}`);
162
+ };
163
+ const dispose = await registerPullRequests({ ...f.ctx, rpc: { async register(_definition, methods) { Object.assign(handlers, methods); return { dispose() {} }; } } }, { run });
164
+ try {
165
+ const result = await handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call());
166
+ assert.equal(result.pullRequest.review, 'changes-requested');
167
+ assert.equal(result.pullRequest.checks, 'partial');
168
+ } finally { await dispose(); }
169
+ });
170
+
171
+ test('rejects a symlinked PR cache instead of reading outside the task worktree', async t => {
172
+ const f = fixture(t); const dispose = await registerPullRequests(f.ctx, { run: f.run }); t.after(dispose);
173
+ const harness = join(f.worktree, '.harness'); mkdirSync(harness);
174
+ const outside = join(f.project, 'foreign-pr-cache.json');
175
+ writeFileSync(outside, JSON.stringify({ version: 1, pullRequests: [] }));
176
+ symlinkSync(outside, join(harness, 'pull-requests.json'));
177
+ await assert.rejects(f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call()), error => error.data?.code === 'cache_invalid');
178
+ });
179
+
180
+ test('descriptor-bound cache read rejects a symlink replacement target', async t => {
181
+ const f = fixture(t);
182
+ const { readBoundedFile } = await jiti.import('../plugins/carthagent/src/server/pull-request-rpc.ts');
183
+ const harness = join(f.worktree, '.harness'); mkdirSync(harness, { recursive: true });
184
+ const cache = join(harness, 'pull-requests.json');
185
+ writeFileSync(cache, 'safe cache bytes');
186
+ const result = readBoundedFile(cache, 100);
187
+ assert.equal(result.toString(), 'safe cache bytes');
188
+ const external = join(f.project, 'foreign-cache.json');
189
+ writeFileSync(external, 'foreign bytes');
190
+ const replaced = join(harness, 'replacement.json');
191
+ symlinkSync(external, replaced);
192
+ assert.throws(() => readBoundedFile(cache, 100, () => {
193
+ const moved = join(harness, 'moved-cache.json');
194
+ execFileSync('mv', [cache, moved]);
195
+ symlinkSync(external, cache);
196
+ }), /target changed/);
197
+ });
198
+
199
+ test('propagates request cancellation into GitHub reads and stops between endpoints', async t => {
200
+ const f = fixture(t); const handlers = {}; const controller = new AbortController(); let ghSignal;
201
+ const run = async (command, args, cwd, signal) => {
202
+ if (command === 'git') return f.run(command, args, cwd);
203
+ ghSignal = signal;
204
+ controller.abort(new Error('cancelled by caller'));
205
+ return f.run(command, args, cwd);
206
+ };
207
+ const dispose = await registerPullRequests({ ...f.ctx, rpc: { async register(_definition, methods) { Object.assign(handlers, methods); return { dispose() {} }; } } }, { run });
208
+ try {
209
+ await assert.rejects(handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, callWithSignal(controller.signal)), error => error.data?.code === 'canceled');
210
+ assert.equal(ghSignal, controller.signal);
211
+ } finally { await dispose(); }
212
+ });
213
+
214
+ test('concurrent associations merge under the cross-process cache lock', async t => {
215
+ const f = fixture(t); let pending = 0; let release;
216
+ const bothPRs = new Promise(resolve => { release = resolve; });
217
+ const run = async (command, args, cwd, signal) => {
218
+ if (command === 'git') return f.run(command, args, cwd, signal);
219
+ const endpoint = args.at(-1);
220
+ const pull = endpoint.match(/\/pulls\/(\d+)$/);
221
+ if (pull) {
222
+ pending++;
223
+ if (pending === 2) release();
224
+ await bothPRs;
225
+ return JSON.stringify({ number: Number(pull[1]), html_url: `https://github.com/acme/repo/pull/${pull[1]}`, state: 'open', draft: false, head: { sha: head, ref: 'feature', repo: { full_name: 'acme/repo' } }, base: { sha: 'b'.repeat(40), ref: 'main', repo: { full_name: 'acme/repo' } } });
226
+ }
227
+ return f.run(command, args, cwd, signal);
228
+ };
229
+ const dispose = await registerPullRequests({ ...f.ctx, rpc: { async register(_definition, methods) { Object.assign(f.handlers, methods); return { dispose() {} }; } } }, { run });
230
+ try {
231
+ await Promise.all([7, 8].map(number => f.handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number }, call())));
232
+ const listed = await f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call());
233
+ assert.deepEqual(listed.pullRequests.map(pr => pr.number).sort(), [7, 8]);
234
+ } finally { await dispose(); }
235
+ });
236
+
237
+ test('canceled lock wait does not mutate the cache', async t => {
238
+ const f = fixture(t); const dispose = await registerPullRequests(f.ctx, { run: f.run }); t.after(dispose);
239
+ const harness = join(f.worktree, '.harness'); mkdirSync(harness);
240
+ writeFileSync(join(harness, 'pull-requests.lock'), 'held by another process');
241
+ const controller = new AbortController();
242
+ const pending = f.handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, callWithSignal(controller.signal));
243
+ setTimeout(() => controller.abort(new Error('lock wait canceled')), 50);
244
+ await assert.rejects(pending, error => error.data?.code === 'canceled');
245
+ assert.equal(readFileSync(join(harness, 'pull-requests.lock'), 'utf8'), 'held by another process');
246
+ assert.equal(readFileSync(join(f.project, '.harness', 'opencode-tasks.json'), 'utf8').includes('task-1'), true);
247
+ assert.equal((await f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call())).pullRequests.length, 0);
248
+ });
249
+
250
+ test('recovers an expired dead-owner lock but does not steal a live-owner lock', async t => {
251
+ const f = fixture(t); const dispose = await registerPullRequests(f.ctx, { run: f.run }); t.after(dispose);
252
+ const harness = join(f.worktree, '.harness'); mkdirSync(harness);
253
+ const lock = join(harness, 'pull-requests.lock');
254
+ writeFileSync(lock, JSON.stringify({ pid: 2_000_000_000, token: 'abandoned', createdAt: '2000-01-01T00:00:00.000Z' }));
255
+ const old = new Date(Date.now() - 60_000); utimesSync(lock, old, old);
256
+ const associated = await f.handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call());
257
+ assert.equal(associated.pullRequest.number, 7, 'a dead stale owner is recoverable');
258
+
259
+ const livePID = process.pid;
260
+ writeFileSync(lock, JSON.stringify({ pid: livePID, token: 'live-owner', createdAt: '2000-01-01T00:00:00.000Z' }));
261
+ utimesSync(lock, old, old);
262
+ const controller = new AbortController();
263
+ const waiting = f.handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 8 }, callWithSignal(controller.signal));
264
+ setTimeout(() => controller.abort(new Error('live lock remains owned')), 60);
265
+ await assert.rejects(waiting, error => error.data?.code === 'canceled');
266
+ assert.equal(JSON.parse(readFileSync(lock, 'utf8')).token, 'live-owner', 'a live owner is never displaced');
267
+ assert.deepEqual((await f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call())).pullRequests.map(pr => pr.number), [7]);
268
+ });
269
+
270
+ test('crashes during empty/partial lock and recovery publication leave no blocking incomplete owner', async t => {
271
+ for (const target of ['lock', 'recovery']) for (const stage of ['empty', 'partial']) await t.test(`${target} ${stage}`, async t => {
272
+ const f = fixture(t); const dispose = await registerPullRequests(f.ctx, { run: f.run }); t.after(dispose);
273
+ const harness = join(f.worktree, '.harness'); mkdirSync(harness);
274
+ const lock = join(harness, 'pull-requests.lock');
275
+ if (target === 'recovery') {
276
+ writeFileSync(lock, JSON.stringify({ pid: 2_000_000_000, token: 'dead-owner', createdAt: '2000-01-01T00:00:00.000Z' }));
277
+ const old = new Date(Date.now() - 60_000); utimesSync(lock, old, old);
278
+ }
279
+ const destination = target === 'lock' ? lock : `${lock}.recovering`;
280
+ const modulePath = fileURLToPath(new URL('../plugins/carthagent/src/server/pull-request-rpc.ts', import.meta.url));
281
+ const testPath = fileURLToPath(import.meta.url);
282
+ const script = `const jiti=require('jiti').createJiti(${JSON.stringify(testPath)}); jiti.import(${JSON.stringify(modulePath)}).then(({publishExclusive})=>publishExclusive(process.argv[1],{pid:process.pid,token:'crash-token',createdAt:new Date().toISOString()},stage=>{if(stage===process.argv[2])process.exit(73)}));`;
283
+ const child = spawnSync(process.execPath, ['--input-type=commonjs', '-e', script, destination, stage], { encoding: 'utf8' });
284
+ assert.equal(child.status, 73, child.stderr);
285
+ assert.equal((await f.handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call())).pullRequest.number, 7);
286
+ });
287
+ });
288
+
289
+ test('concurrent stale-lock reclaimers serialize and never unlink a successor lock', async t => {
290
+ const f = fixture(t); const { recoverDeadOwner } = await jiti.import('../plugins/carthagent/src/server/pull-request-rpc.ts');
291
+ const harness = join(f.worktree, '.harness'); mkdirSync(harness);
292
+ const lock = join(harness, 'pull-requests.lock');
293
+ const old = new Date(Date.now() - 60_000);
294
+ writeFileSync(lock, JSON.stringify({ pid: 2_000_000_000, token: 'dead-owner', createdAt: '2000-01-01T00:00:00.000Z' })); utimesSync(lock, old, old);
295
+ let successor = false;
296
+ const reclaimed = recoverDeadOwner(lock, () => {
297
+ unlinkSync(lock);
298
+ writeFileSync(lock, JSON.stringify({ pid: process.pid, token: 'successor-token', createdAt: new Date().toISOString() }));
299
+ successor = true;
300
+ });
301
+ assert.equal(successor, true);
302
+ assert.equal(reclaimed, false, 'reclaim detects that its observed lock has been replaced');
303
+ assert.equal(JSON.parse(readFileSync(lock, 'utf8')).token, 'successor-token');
304
+ assert.equal(recoverDeadOwner(lock), false, 'a live successor cannot be reclaimed');
305
+ assert.equal(JSON.parse(readFileSync(lock, 'utf8')).token, 'successor-token');
306
+ });
307
+
308
+ test('rejects oversized cache bytes and association counts', async t => {
309
+ const f = fixture(t); const dispose = await registerPullRequests(f.ctx, { run: f.run }); t.after(dispose);
310
+ const harness = join(f.worktree, '.harness'); mkdirSync(harness);
311
+ const cache = join(harness, 'pull-requests.json');
312
+ writeFileSync(cache, ' '.repeat(256 * 1024 + 1));
313
+ await assert.rejects(f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call()), error => error.data?.code === 'cache_limit');
314
+ const invalidRecords = Array.from({ length: 101 }, (_, i) => ({ taskID: 'task-1', number: i + 1 }));
315
+ writeFileSync(cache, JSON.stringify({ version: 1, pullRequests: invalidRecords }));
316
+ await assert.rejects(f.handlers.list({ sessionID: 'session-1', taskID: 'task-1' }, call()), error => error.data?.code === 'cache_limit');
317
+ });
318
+
319
+ test('allows GitHub fork heads but binds the PR base repository to task origin', async t => {
320
+ const f = fixture(t); const handlers = {};
321
+ const run = async (command, args, cwd, signal) => {
322
+ if (command === 'git') return f.run(command, args, cwd, signal);
323
+ const endpoint = args.at(-1);
324
+ if (endpoint.endsWith('/pulls/7')) {
325
+ const pr = JSON.parse(await f.run(command, args, cwd, signal));
326
+ pr.head.repo.full_name = 'contributor/repo';
327
+ return JSON.stringify(pr);
328
+ }
329
+ return f.run(command, args, cwd, signal);
330
+ };
331
+ const dispose = await registerPullRequests({ ...f.ctx, rpc: { async register(_definition, methods) { Object.assign(handlers, methods); return { dispose() {} }; } } }, { run });
332
+ try {
333
+ const result = await handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call());
334
+ assert.equal(result.pullRequest.headRepository, 'contributor/repo');
335
+ } finally { await dispose(); }
336
+ });
337
+
338
+ test('refresh is explicit, updates source metadata, and refuses wrong-repository PRs', async t => {
339
+ const f = fixture(t); const dispose = await registerPullRequests(f.ctx, { run: f.run }); t.after(dispose);
340
+ await f.handlers.associate({ sessionID: 'session-1', taskID: 'task-1', number: 7 }, call());
341
+ let refreshed = 0;
342
+ const run = async (command, args, cwd) => {
343
+ if (command === 'git') return f.run(command, args, cwd);
344
+ refreshed++;
345
+ const value = JSON.parse(await f.run(command, args, cwd));
346
+ if (args.at(-1).endsWith('/pulls/7')) value.head.sha = git(f.worktree, 'rev-parse', 'HEAD');
347
+ return JSON.stringify(value);
348
+ };
349
+ const updateHandlers = {};
350
+ const update = await registerPullRequests({ ...f.ctx, rpc: { async register(_definition, methods) { Object.assign(updateHandlers, methods); return { dispose() {} }; } } }, { run });
351
+ try {
352
+ const result = await updateHandlers.refresh({ sessionID: 'session-1', taskID: 'task-1' }, call());
353
+ assert.equal(result.pullRequest.source, 'current');
354
+ assert.equal(refreshed, 4);
355
+ } finally { await update(); }
356
+ const wrong = async (command, args, cwd) => {
357
+ if (command === 'git') return f.run(command, args, cwd);
358
+ const value = JSON.parse(await f.run(command, args, cwd));
359
+ if (args.at(-1).endsWith('/pulls/7')) value.base.repo.full_name = 'other/repo';
360
+ return JSON.stringify(value);
361
+ };
362
+ const badHandlers = {};
363
+ const bad = await registerPullRequests({ ...f.ctx, rpc: { async register(_definition, methods) { Object.assign(badHandlers, methods); return { dispose() {} }; } } }, { run: wrong });
364
+ try { await assert.rejects(badHandlers.refresh({ sessionID: 'session-1', taskID: 'task-1' }, call()), error => error.data?.code === 'github_invalid'); }
365
+ finally { await bad(); }
366
+ });
@@ -0,0 +1,32 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { beginReview, parseFindings } from '../lib/review-state.mjs';
7
+
8
+ test('review lock, persisted rounds, approval and escalation fail closed', t => {
9
+ const cwd = mkdtempSync(join(tmpdir(), 'review café '));
10
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
11
+ const first = beginReview(cwd, 'pr', 'source', 'head');
12
+ assert.throws(() => beginReview(cwd, 'pr', 'source', 'head'), /running/);
13
+ first.finish('approved', {}); first.release();
14
+ assert.throws(() => beginReview(cwd, 'pr', 'source', 'head'), /already approved/);
15
+ for (let i = 0; i < 2; i++) {
16
+ const next = beginReview(cwd, 'pr', `changed${i}`, 'head');
17
+ next.finish('changes-requested', {}); next.release();
18
+ }
19
+ assert.throws(() => beginReview(cwd, 'pr', 'new', 'newhead'), /round limit/);
20
+ const interrupted = beginReview(cwd, 'other', 'source', 'head');
21
+ interrupted.release();
22
+ assert.throws(() => beginReview(cwd, 'other', 'source', 'head'), /Interrupted/);
23
+ });
24
+
25
+ test('structured findings reject absent, stale, malformed and contradictory data', () => {
26
+ const data = {version:1,snapshot:'source',head:'head',findings:[]};
27
+ const output = value => `REVIEW_JSON: ${JSON.stringify(value)}\nVERDICT: APPROVE`;
28
+ assert.deepEqual(parseFindings(output(data), 'source', 'head', 'APPROVE'), data);
29
+ for (const bad of [null, {}, {...data,head:'stale'}, {...data,findings:[{}]}]) assert.throws(() => parseFindings(output(bad), 'source', 'head', 'APPROVE'));
30
+ assert.throws(() => parseFindings(output(data), 'source', 'head', 'CHANGES-REQUESTED'), /contradicts/);
31
+ assert.throws(() => parseFindings('VERDICT: APPROVE', 'source', 'head', 'APPROVE'), /Missing/);
32
+ });
@@ -0,0 +1,127 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ REVIEW_MODES, normalizeReviewMode, resolveReviewMode,
8
+ loadCarthagentConfig, saveCarthagentConfig, resolveStickyDefaults, shouldOfferReview,
9
+ reviewKickoff, reviewerPrompt, parseVerdict,
10
+ } from '../lib/review.mjs';
11
+
12
+ function configFixture(t) {
13
+ const dir = mkdtempSync(join(tmpdir(), 'carthagent review config '));
14
+ t.after(() => rmSync(dir, { recursive: true, force: true }));
15
+ return join(dir, 'config.json');
16
+ }
17
+
18
+ test('review modes normalize strictly to ask|yes|no', () => {
19
+ assert.deepEqual(REVIEW_MODES, ['ask', 'yes', 'no']);
20
+ for (const [input, expected] of [['ask', 'ask'], [' YES ', 'yes'], ['No', 'no'], ['', null], ['always', null], [undefined, null], [42, null]]) {
21
+ assert.equal(normalizeReviewMode(input), expected, JSON.stringify(input));
22
+ }
23
+ });
24
+
25
+ test('config persists atomically and merges patches', t => {
26
+ const path = configFixture(t);
27
+ assert.deepEqual(loadCarthagentConfig(path), {});
28
+ saveCarthagentConfig({ review: 'yes' }, path);
29
+ saveCarthagentConfig({ other: 1 }, path);
30
+ const config = loadCarthagentConfig(path);
31
+ assert.equal(config.review, 'yes');
32
+ assert.equal(config.other, 1);
33
+ writeFileSync(path, 'not json');
34
+ assert.deepEqual(loadCarthagentConfig(path), {});
35
+ });
36
+
37
+ test('resolveReviewMode precedence: explicit flag > config > ask default', t => {
38
+ const path = configFixture(t);
39
+ assert.equal(resolveReviewMode(undefined, loadCarthagentConfig(path)), 'ask');
40
+ saveCarthagentConfig({ review: 'no' }, path);
41
+ assert.equal(resolveReviewMode(undefined, loadCarthagentConfig(path)), 'no');
42
+ assert.equal(resolveReviewMode('yes', loadCarthagentConfig(path)), 'yes');
43
+ assert.equal(resolveReviewMode('garbage', loadCarthagentConfig(path)), 'no');
44
+ });
45
+
46
+ test('resolveStickyDefaults: flag > config > undefined (built-in default)', t => {
47
+ const path = configFixture(t);
48
+ const cfg = loadCarthagentConfig(path);
49
+ // nothing set → falls through to the engine's own defaults
50
+ assert.deepEqual(resolveStickyDefaults({}, cfg), { theme: undefined, model: undefined });
51
+ assert.deepEqual(resolveStickyDefaults({ theme: 'ember', model: 'sonnet' }, cfg), { theme: 'ember', model: 'sonnet' });
52
+ // config fills in only the keys the flag leaves empty
53
+ saveCarthagentConfig({ theme: 'nord', model: 'anthropic/opus' }, path);
54
+ const cfg2 = loadCarthagentConfig(path);
55
+ assert.deepEqual(resolveStickyDefaults({}, cfg2), { theme: 'nord', model: 'anthropic/opus' });
56
+ assert.deepEqual(resolveStickyDefaults({ theme: 'mono' }, cfg2), { theme: 'mono', model: 'anthropic/opus' });
57
+ assert.deepEqual(resolveStickyDefaults({ model: 'sonnet' }, cfg2), { theme: 'nord', model: 'sonnet' });
58
+ // a flag wins over a conflicting config value
59
+ assert.equal(resolveStickyDefaults({ theme: 'ember' }, cfg2).theme, 'ember');
60
+ });
61
+
62
+ test('sticky theme/model round-trip through the config file', t => {
63
+ const path = configFixture(t);
64
+ // simulate the TUI persisting a theme, then a model, then loading on next launch
65
+ saveCarthagentConfig({ theme: 'tokyonight' }, path);
66
+ saveCarthagentConfig({ model: 'openrouter/inkling' }, path);
67
+ const cfg = loadCarthagentConfig(path);
68
+ assert.equal(cfg.theme, 'tokyonight');
69
+ assert.equal(cfg.model, 'openrouter/inkling');
70
+ assert.equal(resolveStickyDefaults({}, cfg).theme, 'tokyonight');
71
+ assert.equal(resolveStickyDefaults({}, cfg).model, 'openrouter/inkling');
72
+ // a stale review setting coexists with the new keys
73
+ saveCarthagentConfig({ review: 'yes' }, path);
74
+ const merged = loadCarthagentConfig(path);
75
+ assert.equal(merged.review, 'yes');
76
+ assert.equal(merged.theme, 'tokyonight');
77
+ });
78
+
79
+ test('shouldOfferReview gates on mode, evidence of change and prior offers', () => {
80
+ const offered = new Set();
81
+ const base = { mode: 'ask', verified: true, writes: 0, loopActive: false, offeredRuns: offered, runKey: 's@1' };
82
+ assert.equal(shouldOfferReview(base), true);
83
+ assert.equal(shouldOfferReview({ ...base, mode: 'yes' }), false, 'yes mode is auto-kicked, never prompted');
84
+ assert.equal(shouldOfferReview({ ...base, mode: 'no' }), false);
85
+ assert.equal(shouldOfferReview({ ...base, verified: false, writes: 2 }), true, 'file edits qualify');
86
+ assert.equal(shouldOfferReview({ ...base, verified: false, writes: 0 }), false, 'read-only runs do not qualify');
87
+ assert.equal(shouldOfferReview({ ...base, loopActive: true }), false);
88
+ offered.add('s@1');
89
+ assert.equal(shouldOfferReview(base), false, 'no duplicate offers for the same run');
90
+ });
91
+
92
+ test('kickoff instructs the PR → fresh review → fixes loop with a round cap', () => {
93
+ const text = reviewKickoff('/abs/bin/ctg.mjs');
94
+ assert.match(text, /gh pr create/);
95
+ assert.match(text, /node "\/abs\/bin\/ctg\.mjs" review <pr-number-or-url>/);
96
+ assert.match(text, /VERDICT: APPROVE/);
97
+ assert.match(text, /VERDICT: CHANGES-REQUESTED/);
98
+ assert.match(text, /3 review rounds/);
99
+ assert.match(text, /do not simulate a review/i);
100
+ });
101
+
102
+ test('reviewer prompt is read-only, fresh-context and ends in a verdict', () => {
103
+ const text = reviewerPrompt({ prRef: '14', cwd: '/repo', meta: { title: 'Add feature', headRefName: 'feat', baseRefName: 'main' } });
104
+ assert.match(text, /fresh-context code reviewer/);
105
+ assert.match(text, /"14"/);
106
+ assert.match(text, /"\/repo"/);
107
+ assert.match(text, /PR title: Add feature/);
108
+ assert.match(text, /feat → main/);
109
+ assert.match(text, /Do not modify files, commit, push/);
110
+ assert.match(text, /VERDICT: APPROVE/);
111
+ assert.match(text, /VERDICT: CHANGES-REQUESTED/);
112
+ });
113
+
114
+ test('parseVerdict reads the final verdict line only', () => {
115
+ assert.equal(parseVerdict('analysis...\nVERDICT: APPROVE\n'), 'APPROVE');
116
+ assert.equal(parseVerdict('findings...\nVERDICT: CHANGES-REQUESTED\n'), 'CHANGES-REQUESTED');
117
+ assert.equal(parseVerdict('verdict: changes-requested'), 'CHANGES-REQUESTED');
118
+ assert.equal(parseVerdict('no verdict here'), null);
119
+ assert.equal(parseVerdict(''), null);
120
+ assert.equal(parseVerdict('quoted VERDICT: APPROVE'), null);
121
+ assert.equal(parseVerdict('VERDICT: APPROVE\nActually incomplete'), null);
122
+ assert.equal(parseVerdict('VERDICT: APPROVE-but-not-really'), null);
123
+ // The LAST verdict wins — the prompt's format example or mid-analysis
124
+ // mentions must not shadow the reviewer's actual final line.
125
+ assert.equal(parseVerdict('finish with: VERDICT: APPROVE\n...\nVERDICT: CHANGES-REQUESTED\n'), 'CHANGES-REQUESTED');
126
+ assert.equal(parseVerdict('VERDICT: CHANGES-REQUESTED ... wait, actually fine\nVERDICT: APPROVE\n'), 'APPROVE');
127
+ });