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,129 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+ import { once } from 'node:events';
5
+ import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { spawn } from 'node:child_process';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { fetchLatestRelease, latestReleaseCached, normalizeRelease } from '../lib/update.mjs';
11
+ import { parseUpdateOptions, readUpdateChannel, writeUpdateChannel } from '../lib/update-channel.mjs';
12
+
13
+ const release = (version, extra = {}) => ({ tag_name: `v${version}`, draft: false, prerelease: version.includes('-'), ...extra });
14
+ async function fixture(t, pages = [[]], stable = release('1.0.7')) {
15
+ const dir = await mkdtemp(join(tmpdir(), 'ctg-update-channels-'));
16
+ const env = { CARTHAGENT_UPDATE_CHANNEL_FILE: join(dir, 'channel.json') };
17
+ const requests = [];
18
+ const server = createServer((req, res) => {
19
+ const url = new URL(req.url, 'http://localhost');
20
+ requests.push({ url, headers: req.headers });
21
+ const body = url.pathname.endsWith('/releases/latest') ? stable : pages[Number(url.searchParams.get('page') || 1) - 1] ?? [];
22
+ res.writeHead(200, { 'content-type': 'application/json' });
23
+ res.end(JSON.stringify(body));
24
+ });
25
+ server.listen(0, '127.0.0.1');
26
+ await once(server, 'listening');
27
+ t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await rm(dir, { recursive: true, force: true }); });
28
+ const base = `http://127.0.0.1:${server.address().port}`;
29
+ const fetchImpl = (url, options) => fetch(`${base}${new URL(url).pathname}${new URL(url).search}`, options);
30
+ return { dir, env, requests, base, options: { env, agentDir: join(dir, 'agent'), cloudUrl: base, fetchImpl } };
31
+ }
32
+
33
+ test('channel flags are explicit, validated, and do not reinterpret arbitrary update arguments', () => {
34
+ assert.deepEqual(parseUpdateOptions([]), { checkOnly: false, force: false, channel: null });
35
+ assert.deepEqual(parseUpdateOptions(['--channel', 'experimental', '--check']), { checkOnly: true, force: false, channel: 'experimental' });
36
+ assert.equal(parseUpdateOptions(['--channel=beta', '--force']).channel, 'beta');
37
+ for (const args of [['--channel'], ['--channel', 'nightly'], ['--channel', 'beta', '--channel', 'stable'], ['--tag', 'main']]) assert.throws(() => parseUpdateOptions(args));
38
+ });
39
+
40
+ test('channel preference defaults stable, is versioned and isolated, and rejects corrupt opt-in', async t => {
41
+ const f = await fixture(t);
42
+ assert.equal(readUpdateChannel(f.env), 'stable');
43
+ writeUpdateChannel('experimental', f.env);
44
+ assert.equal(readUpdateChannel(f.env), 'experimental');
45
+ assert.deepEqual(JSON.parse(await readFile(f.env.CARTHAGENT_UPDATE_CHANNEL_FILE, 'utf8')), { version: 1, channel: 'experimental' });
46
+ assert.throws(() => writeUpdateChannel('main', f.env));
47
+ await writeFile(f.env.CARTHAGENT_UPDATE_CHANNEL_FILE, '{broken');
48
+ assert.throws(() => readUpdateChannel(f.env), /channel/i);
49
+ });
50
+
51
+ test('stable refuses prereleases even when misadvertised by the control plane or latest API', async t => {
52
+ const f = await fixture(t, [], release('9.0.0-beta.1'));
53
+ await assert.rejects(fetchLatestRelease(f.options), /stable/i);
54
+ assert.equal(f.requests.length, 2);
55
+ assert.equal(normalizeRelease({ version: '1.0.7', tag: 'main' }), null);
56
+ });
57
+
58
+ test('beta selects the greatest eligible semver, ignores drafts, and excludes experimental tags', async t => {
59
+ const f = await fixture(t, [[release('2.0.0-beta.2'), release('2.0.0-beta.10'), release('3.0.0-experimental.1'), release('99.0.0-beta.1', { draft: true }), release('1.0.7'), release('2.0.0-rc.1')]]);
60
+ const result = await fetchLatestRelease({ ...f.options, channel: 'beta' });
61
+ assert.equal(result.release.version, '2.0.0-rc.1');
62
+ assert.equal(result.release.installSpec, 'github:B-icy/Carthagent#v2.0.0-rc.1');
63
+ assert.equal(f.requests.length, 1);
64
+ assert.equal(f.requests[0].headers['x-carthagent-install-id'], undefined);
65
+ });
66
+
67
+ test('experimental opts into all published prereleases and follows saved preference', async t => {
68
+ const f = await fixture(t, [[release('1.1.0-preview.1'), release('1.0.7'), release('1.1.0-beta.2'), release('8.0.0-alpha.1', { draft: true })]]);
69
+ writeUpdateChannel('experimental', f.env);
70
+ const result = await fetchLatestRelease(f.options);
71
+ assert.equal(result.release.version, '1.1.0-preview.1');
72
+ assert.equal(result.source, 'github');
73
+ });
74
+
75
+ test('release selection paginates instead of trusting GitHub creation order', async t => {
76
+ const f = await fixture(t, [Array.from({ length: 100 }, (_, i) => release(`1.0.${i}`)), [release('2.0.0-beta.5')]]);
77
+ assert.equal((await fetchLatestRelease({ ...f.options, channel: 'beta' })).release.version, '2.0.0-beta.5');
78
+ assert.equal(f.requests.length, 2);
79
+ });
80
+
81
+ test('published stable versions supersede beta versions without accepting unknown beta labels', async t => {
82
+ const f = await fixture(t, [[release('2.0.0-beta.2'), release('2.0.0'), release('3.0.0-nightly.1'), release('3.0.0-beta-unsafe.1')]]);
83
+ assert.equal((await fetchLatestRelease({ ...f.options, channel: 'beta' })).release.version, '2.0.0');
84
+ });
85
+
86
+ test('channel caches never leak a cached experimental release into stable', async t => {
87
+ const f = await fixture(t, [[release('2.0.0-preview.1')]]);
88
+ const cachePath = join(f.dir, 'cache.json');
89
+ const first = await latestReleaseCached({ ...f.options, cachePath, channel: 'experimental' });
90
+ assert.equal(first.release.version, '2.0.0-preview.1');
91
+ const stable = await latestReleaseCached({ ...f.options, cachePath, channel: 'stable' });
92
+ assert.equal(stable.release.version, '1.0.7');
93
+ await writeFile(cachePath, JSON.stringify({ checkedAt: Date.now(), channel: 'experimental', release: first.release }));
94
+ const offline = await latestReleaseCached({ ...f.options, cachePath, channel: 'stable', fetchImpl: async () => { throw Error('offline'); } });
95
+ assert.equal(offline.release, null);
96
+ });
97
+
98
+ test('CLI --check previews channel rollback without persisting or installing', async t => {
99
+ const f = await fixture(t, [], release('0.0.1'));
100
+ writeUpdateChannel('experimental', f.env);
101
+ const child = spawn(process.execPath, ['bin/ctg.mjs', 'update', '--channel', 'stable', '--check'], {
102
+ cwd: new URL('..', import.meta.url), env: { ...process.env, ...f.env, CARTHAGENT_CLOUD_URL: f.base, CARTHAGENT_CODING_AGENT_DIR: join(f.dir, 'agent') }, stdio: ['ignore', 'pipe', 'pipe'],
103
+ });
104
+ let output = ''; child.stdout.on('data', data => output += data); child.stderr.on('data', data => output += data);
105
+ const [code] = await once(child, 'close');
106
+ assert.equal(code, 0, output);
107
+ assert.match(output, /0\.0\.1.*available/);
108
+ assert.match(output, /stable/);
109
+ assert.equal(readUpdateChannel(f.env), 'experimental');
110
+ });
111
+
112
+
113
+ test('failed real clone update does not persist a requested channel switch', async t => {
114
+ const f = await fixture(t, [], release('999.0.0'));
115
+ const copy = join(f.dir, 'broken-clone');
116
+ await mkdir(copy);
117
+ for (const name of ['lib', 'bin', 'package.json']) await cp(new URL(`../${name}`, import.meta.url), join(copy, name), { recursive: true });
118
+ await symlink(fileURLToPath(new URL('../node_modules', import.meta.url)), join(copy, 'node_modules'), 'junction');
119
+ await mkdir(join(copy, '.git'));
120
+ writeUpdateChannel('experimental', f.env);
121
+ const child = spawn(process.execPath, [join(copy, 'bin/ctg.mjs'), 'update', '--channel', 'stable'], {
122
+ cwd: copy, env: { ...process.env, ...f.env, CARTHAGENT_CLOUD_URL: f.base, CARTHAGENT_CODING_AGENT_DIR: join(f.dir, 'agent') }, stdio: ['ignore', 'pipe', 'pipe'],
123
+ });
124
+ let output = ''; child.stdout.on('data', data => output += data); child.stderr.on('data', data => output += data);
125
+ const [code] = await once(child, 'close');
126
+ assert.equal(code, 1, output);
127
+ assert.match(output, /Cannot inspect the clone/);
128
+ assert.equal(readUpdateChannel(f.env), 'experimental');
129
+ });
@@ -0,0 +1,175 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, join } from 'node:path';
6
+ import {
7
+ cleanStaleGlobalInstall,
8
+ compareVersions,
9
+ detectInstallMethod,
10
+ fetchLatestRelease,
11
+ latestReleaseCached,
12
+ normalizeRelease,
13
+ staleGlobalInstallPaths,
14
+ updateCheckEnabled,
15
+ } from '../lib/update.mjs';
16
+
17
+ const manifest = { version: '0.3.0', tag: 'v0.3.0', installSpec: 'github:B-icy/Carthagent#v0.3.0', minNode: '22.19.0' };
18
+
19
+ function withTempDir(fn) {
20
+ const dir = mkdtempSync(join(tmpdir(), 'ctg-update-'));
21
+ return Promise.resolve().then(() => fn(dir)).finally(() => rmSync(dir, { recursive: true, force: true }));
22
+ }
23
+
24
+ // ---- compareVersions
25
+
26
+ test('compareVersions orders numeric triples', () => {
27
+ assert.equal(compareVersions('0.2.0', '0.3.0') < 0, true);
28
+ assert.equal(compareVersions('1.0.0', '0.9.9') > 0, true);
29
+ assert.equal(compareVersions('0.3.0', '0.3.0'), 0);
30
+ });
31
+
32
+ test('compareVersions strips a leading v and ignores build metadata', () => {
33
+ assert.equal(compareVersions('v0.3.0', '0.3.0+build7'), 0);
34
+ });
35
+
36
+ test('compareVersions ranks a release above its prereleases', () => {
37
+ assert.equal(compareVersions('0.3.0-rc.1', '0.3.0') < 0, true);
38
+ assert.equal(compareVersions('0.3.0-beta', '0.3.0-rc.1') < 0, true);
39
+ assert.equal(compareVersions('0.3.0-alpha.2', '0.3.0-alpha.10') < 0, true);
40
+ });
41
+
42
+ test('compareVersions rejects non-semver input', () => {
43
+ assert.throws(() => compareVersions('latest', '0.3.0'), /cannot compare/);
44
+ });
45
+
46
+ // ---- normalizeRelease
47
+
48
+ test('normalizeRelease accepts the control-plane manifest shape', () => {
49
+ const rel = normalizeRelease({ ...manifest, publishedAt: '2026-01-01T00:00:00Z' });
50
+ assert.equal(rel.version, '0.3.0');
51
+ assert.equal(rel.tag, 'v0.3.0');
52
+ assert.equal(rel.installSpec, 'github:B-icy/Carthagent#v0.3.0');
53
+ assert.equal(rel.publishedAt, '2026-01-01T00:00:00Z');
54
+ });
55
+
56
+ test('normalizeRelease maps a GitHub release payload', () => {
57
+ const rel = normalizeRelease({
58
+ tag_name: 'v0.4.0',
59
+ published_at: '2026-02-01T00:00:00Z',
60
+ name: 'Carthagent 0.4.0',
61
+ });
62
+ assert.equal(rel.version, '0.4.0');
63
+ assert.equal(rel.tag, 'v0.4.0');
64
+ assert.equal(rel.installSpec, 'github:B-icy/Carthagent#v0.4.0');
65
+ assert.equal(rel.note, 'Carthagent 0.4.0');
66
+ });
67
+
68
+ test('normalizeRelease fills defaults and returns null on unusable payloads', () => {
69
+ assert.equal(normalizeRelease(null), null);
70
+ assert.equal(normalizeRelease({ tag_name: 'release-2' }), null);
71
+ const filled = normalizeRelease({ version: '0.5.0' });
72
+ assert.equal(filled.tag, 'v0.5.0');
73
+ assert.equal(filled.installSpec, 'github:B-icy/Carthagent#v0.5.0');
74
+ });
75
+
76
+ test('normalizeRelease rejects install specs that do not install the release tag', () => {
77
+ // The manifest doubles as an install instruction: a tampered spec must
78
+ // never reach `npm install -g`.
79
+ assert.equal(normalizeRelease({ ...manifest, installSpec: 'github:attacker/evil#v9.9.9' }), null);
80
+ assert.equal(normalizeRelease({ ...manifest, installSpec: 'github:B-icy/Carthagent#v0.2.0' }), null);
81
+ assert.equal(normalizeRelease({ ...manifest, installSpec: 'carthagent@latest' }), null);
82
+ assert.equal(normalizeRelease(manifest).installSpec, 'github:B-icy/Carthagent#v0.3.0');
83
+ });
84
+
85
+ test('fetchLatestRelease falls back to GitHub when the manifest fails validation', async () => {
86
+ // A control-plane 200 whose installSpec is rejected must not dead-end the
87
+ // update — the GitHub release still resolves.
88
+ const urls = [];
89
+ const fetchImpl = async url => {
90
+ urls.push(url);
91
+ if (url.includes('/v1/cli/releases/latest')) {
92
+ return { status: 200, json: async () => ({ ...manifest, installSpec: 'carthagent@latest' }) };
93
+ }
94
+ return { status: 200, json: async () => ({ tag_name: 'v0.3.1', published_at: '2026-03-01T00:00:00Z' }) };
95
+ };
96
+ const { release, source } = await fetchLatestRelease({ cloudUrl: 'https://cloud.test', fetchImpl });
97
+ assert.equal(source, 'github');
98
+ assert.equal(release.version, '0.3.1');
99
+ assert.equal(release.installSpec, 'github:B-icy/Carthagent#v0.3.1');
100
+ assert.equal(urls.length, 2);
101
+ });
102
+
103
+ // ---- latestReleaseCached
104
+
105
+ test('latestReleaseCached fetches once, then serves the cache', () => withTempDir(async dir => {
106
+ const cachePath = join(dir, 'update-check.json');
107
+ let calls = 0;
108
+ const fetchImpl = async () => { calls++; return { status: 200, json: async () => manifest }; };
109
+ // Pinned clock: a slow runner must not outrun the cache TTL mid-test.
110
+ const t0 = Date.now();
111
+ const first = await latestReleaseCached({ cachePath, intervalMs: 1000, now: t0, fetchImpl });
112
+ assert.equal(first.release.version, '0.3.0');
113
+ assert.equal(first.fresh, false);
114
+ assert.equal(calls, 1);
115
+ const second = await latestReleaseCached({ cachePath, intervalMs: 1000, now: t0 + 500, fetchImpl });
116
+ assert.equal(second.release.version, '0.3.0');
117
+ assert.equal(second.fresh, true);
118
+ assert.equal(calls, 1);
119
+ }));
120
+
121
+ test('latestReleaseCached returns stale data when refresh fails', () => withTempDir(async dir => {
122
+ const cachePath = join(dir, 'update-check.json');
123
+ writeFileSync(cachePath, JSON.stringify({ checkedAt: Date.now() - 60_000, release: manifest }));
124
+ const result = await latestReleaseCached({ cachePath, intervalMs: 1000, fetchImpl: async () => { throw new Error('offline'); } });
125
+ assert.equal(result.release.version, '0.3.0');
126
+ assert.equal(result.fresh, false);
127
+ }));
128
+
129
+ test('latestReleaseCached swallows a cold-cache failure', () => withTempDir(async dir => {
130
+ const result = await latestReleaseCached({ cachePath: join(dir, 'update-check.json'), intervalMs: 1000, fetchImpl: async () => { throw new Error('offline'); } });
131
+ assert.equal(result.release, null);
132
+ }));
133
+
134
+ // ---- updateCheckEnabled / detectInstallMethod
135
+
136
+ test('updateCheckEnabled honours opt-out env vars', () => {
137
+ assert.equal(updateCheckEnabled({}), true);
138
+ assert.equal(updateCheckEnabled({ CARTHAGENT_NO_UPDATE_CHECK: '1' }), false);
139
+ assert.equal(updateCheckEnabled({ CARTHAGENT_UPDATE_CHECK: '0' }), false);
140
+ assert.equal(updateCheckEnabled({ CARTHAGENT_UPDATE_CHECK: 'false' }), false);
141
+ assert.equal(updateCheckEnabled({ CARTHAGENT_UPDATE_CHECK: '1' }), true);
142
+ });
143
+
144
+ test('detectInstallMethod distinguishes clones from packed installs', () => withTempDir(dir => {
145
+ assert.equal(detectInstallMethod(dir), 'npm');
146
+ mkdirSync(join(dir, '.git'));
147
+ assert.equal(detectInstallMethod(dir), 'clone');
148
+ }));
149
+
150
+ // ---- staleGlobalInstallPaths / cleanStaleGlobalInstall
151
+
152
+ test('staleGlobalInstallPaths flags non-directory carthagent and stash leftovers', () => withTempDir(dir => {
153
+ writeFileSync(join(dir, 'carthagent'), 'stray file');
154
+ mkdirSync(join(dir, '.carthagent-WHcGBFBt'));
155
+ mkdirSync(join(dir, 'other-pkg'));
156
+ const stale = staleGlobalInstallPaths(dir).map(p => basename(p));
157
+ assert.deepEqual(stale.sort(), ['.carthagent-WHcGBFBt', 'carthagent']);
158
+ }));
159
+
160
+ test('staleGlobalInstallPaths leaves a real carthagent install alone', () => withTempDir(dir => {
161
+ mkdirSync(join(dir, 'carthagent'));
162
+ assert.deepEqual(staleGlobalInstallPaths(dir), []);
163
+ }));
164
+
165
+ test('staleGlobalInstallPaths ignores a missing npm root', () => {
166
+ assert.deepEqual(staleGlobalInstallPaths(join(tmpdir(), 'ctg-no-such-dir')), []);
167
+ });
168
+
169
+ test('cleanStaleGlobalInstall removes flagged entries and reports them', () => withTempDir(dir => {
170
+ writeFileSync(join(dir, 'carthagent'), 'stray file');
171
+ writeFileSync(join(dir, '.carthagent-abandoned'), 'stash leftover');
172
+ const removed = cleanStaleGlobalInstall(dir);
173
+ assert.equal(removed.length, 2);
174
+ assert.deepEqual(staleGlobalInstallPaths(dir), []);
175
+ }));
@@ -0,0 +1,31 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const tool = fileURLToPath(new URL('../tools/webdriver-check.mjs', import.meta.url));
7
+ test('WebDriver CLI rejects unsupported options and unavailable driver without fallback', () => {
8
+ for (const args of [['--console-clean'], ['--endpoint', 'http://127.0.0.1:1', '--assert', '#app'], ['--endpoint', 'http://127.0.0.1:1', '--assert', '#app', '--wait-ms', 'NaN'], ['--endpoint', 'http://127.0.0.1:1', '--assert', '#app', '--url', 'file:///tmp/test'], ['--endpoint', 'http://127.0.0.1:1', '--assert', '#app', '--url', 'http://localhost', '--root', '.']]) {
9
+ const result = spawnSync(process.execPath, [tool, ...args], { encoding: 'utf8', timeout: 10000 });
10
+ assert.equal(result.status, 1, result.stderr);
11
+ const failure = JSON.parse(result.stderr);
12
+ assert.equal(failure.pass, false);
13
+ assert.ok(['driver', 'configuration'].includes(failure.category));
14
+ }
15
+ });
16
+
17
+ test('missing BiDi support fails closed before navigation and deletes session', async () => {
18
+ const { createServer } = await import('node:http');
19
+ const { webdriverCheck } = await import('../lib/webdriver.mjs');
20
+ const requests = [];
21
+ const server = createServer((req, res) => {
22
+ requests.push([req.method, req.url]);
23
+ res.setHeader('content-type', 'application/json');
24
+ res.end(JSON.stringify({ value: req.method === 'POST' ? { sessionId: 'test', capabilities: {} } : null }));
25
+ });
26
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
27
+ try {
28
+ await assert.rejects(webdriverCheck({ endpoint: `http://127.0.0.1:${server.address().port}`, url: 'http://localhost' }), error => error.category === 'capture');
29
+ assert.deepEqual(requests, [['POST', '/session'], ['DELETE', '/session/test']]);
30
+ } finally { await new Promise(r => server.close(r)); }
31
+ });
@@ -0,0 +1,47 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { validateWorkflow, workflowGraph, workflowD2, architectureGraph } from '../lib/workflow.mjs';
4
+ import { planD2WithProgress } from '../lib/delivery.mjs';
5
+ import { parseD2 } from '../lib/tui/d2.mjs';
6
+
7
+ function plan() {
8
+ const node = (id, step, dependsOn = []) => ({ id, step, dependsOn, title: `Task ${id}`, type: 'work', requirements: ['Works'], components: ['policy'], checks: ['unit'], inputs: [], artifacts: [], effects: ['write'] });
9
+ return { goal: 'Workflow', steps: [{ id: 'api', title: 'API' }, { id: 'ui', title: 'UI' }], acceptance: [{ requirement: 'Works', checks: ['unit'] }], checks: [{ id: 'unit' }], design: { components: [{ id: 'policy', responsibility: 'Policy', layer: 'core', dependencies: [] }], ports: [] }, workflow: { nodes: [node('server', 'api'), node('client', 'ui', ['server'])], recovery: [{ from: 'client', to: 'server', when: 'Contract mismatch', maxAttempts: 2, stop: 'Report blocked' }] } };
10
+ }
11
+ test('agent workflow preserves branches/recovery and mandatory gate overlay', () => {
12
+ const p = plan();
13
+ assert.deepEqual(validateWorkflow(p), []);
14
+ const graph = workflowGraph(p);
15
+ assert.ok(graph.authored);
16
+ assert.equal(graph.nodes.filter(n => n.type === 'gate').length, 4);
17
+ assert.ok(graph.edges.some(e => e.kind === 'recovery' && e.maxAttempts === 2));
18
+ const source = planD2WithProgress(p, { step0: 'done' });
19
+ assert.match(source, /Task server \[done\]/);
20
+ assert.equal(parseD2(source).nodes.length, 6);
21
+ assert.equal(architectureGraph(p).nodes[0].layer, 'core');
22
+ });
23
+ test('invalid workflow references, cycles, effects, steps and unbounded recovery fail', () => {
24
+ for (const mutate of [
25
+ p => p.workflow.nodes[0].dependsOn.push('client'),
26
+ p => p.workflow.nodes[0].dependsOn.push('missing'),
27
+ p => p.workflow.nodes[0].id = 'gate_design',
28
+ p => p.workflow.nodes[0].effects.push('deploy'),
29
+ p => p.workflow.nodes.pop(),
30
+ p => p.workflow.recovery[0].maxAttempts = 0,
31
+ p => p.workflow.nodes[0].components.push('missing'),
32
+ ]) {
33
+ const p = plan(); mutate(p);
34
+ assert.ok(validateWorkflow(p).length);
35
+ assert.throws(() => workflowGraph(p));
36
+ }
37
+ });
38
+ test('labels are data, not injectable D2 commands; legacy projection is available', () => {
39
+ const p = plan();
40
+ p.workflow.nodes[0].title = 'quoted "title"\ngate_design -> hacked';
41
+ const graph = parseD2(workflowD2(p));
42
+ assert.equal(graph.nodes.length, 6);
43
+ assert.equal(graph.nodes.find(n => n.id === 'server').label, p.workflow.nodes[0].title + ' [pending]');
44
+ delete p.workflow;
45
+ assert.equal(workflowGraph(p).authored, false);
46
+ assert.match(planD2WithProgress(p), /Verify checks/);
47
+ });
@@ -0,0 +1,236 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { createRequire } from 'node:module';
8
+ import { createReport } from '../lib/reports.mjs';
9
+ import { checkDigest, fingerprint } from '../lib/delivery.mjs';
10
+ import { selectReport } from '../lib/workspace.mjs';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { createJiti } = require('jiti');
14
+ const jiti = createJiti(import.meta.url);
15
+ const { registerWorkspaceRpc } = await jiti.import('../plugins/carthagent/src/server/workspace-rpc.ts');
16
+
17
+ function git(cwd, ...args) { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); }
18
+
19
+ test('native workspace RPC snapshots journaled sessions, enforces location ownership, and aborts requests', async t => {
20
+ const base = mkdtempSync(join(tmpdir(), 'workspace-rpc-')); t.after(() => rmSync(base, { recursive: true, force: true }));
21
+ const root = join(base, 'project'); mkdirSync(root); git(root, 'init', '-q');
22
+ const other = join(base, 'other'); mkdirSync(other);
23
+ const sessionMap = new Map([
24
+ ['root-session', { id: 'root-session', projectID: 'project-id', location: { directory: root }, title: 'Untracked UI session', outcome: 'succeeded', time: { updated: Date.now() } }],
25
+ ['task-session', { id: 'task-session', projectID: 'project-id', location: { directory: join(base, 'worktree'), workspaceID: 'workspace' }, title: 'Task session', outcome: 'failed', time: { updated: Date.now() } }],
26
+ ['foreign-session', { id: 'foreign-session', projectID: 'other-project', location: { directory: other }, title: 'Not ours', time: { updated: Date.now() } }],
27
+ ]);
28
+ const registered = {};
29
+ const eventQueue = [];
30
+ let wake;
31
+ const events = {
32
+ push(event) { eventQueue.push(event); wake?.(); },
33
+ async *[Symbol.asyncIterator]({ signal } = {}) {
34
+ while (true) {
35
+ if (signal?.aborted) return;
36
+ if (!eventQueue.length) await new Promise(resolve => {
37
+ wake = resolve;
38
+ signal?.addEventListener('abort', resolve, { once: true });
39
+ });
40
+ wake = undefined;
41
+ while (eventQueue.length) yield eventQueue.shift();
42
+ }
43
+ },
44
+ };
45
+ const emitted = [];
46
+ const ctx = {
47
+ location: { directory: root, project: { id: 'project-id', canonical: root } },
48
+ session: {
49
+ async get({ sessionID }, options) { options?.signal?.throwIfAborted(); const found = sessionMap.get(sessionID); if (!found) throw Error('missing'); return found; },
50
+ async create(input, options) { options?.signal?.throwIfAborted(); const id = `created-${sessionMap.size}`; sessionMap.set(id, { id, projectID: 'project-id', location: input.location, title: input.title, time: { updated: Date.now() } }); return { id }; },
51
+ },
52
+ worktree: { async create({ projectID, name }, options) { options?.signal?.throwIfAborted(); assert.equal(projectID, 'project-id'); assert.ok(name); return { directory: join(base, 'new-worktree') }; } },
53
+ rpc: { async register(_definition, handlers) { Object.assign(registered, handlers); return { events: { async emit(name, payload) { emitted.push({ name, payload }); } }, dispose() {} }; } },
54
+ event: { subscribe: ({ signal }) => ({ [Symbol.asyncIterator]: () => events[Symbol.asyncIterator]({ signal }) }) },
55
+ };
56
+ const taskDir = join(base, 'worktree'); mkdirSync(taskDir);
57
+ const statePath = join(root, '.harness/opencode-tasks.json'); mkdirSync(join(root, '.harness'));
58
+ writeFileSync(statePath, JSON.stringify({ version: 1, tasks: [{ id: 'task-1', task: 'Tracked task', sessionID: 'task-session', worktree: taskDir, status: 'active' }], operations: [] }));
59
+ const dispose = await registerWorkspaceRpc(ctx);
60
+ t.after(dispose);
61
+
62
+ const data = await registered.snapshot({}, { signal: new AbortController().signal });
63
+ assert.equal(data.sessions.length, 1, 'only task sessions can be inventoried by the plugin Context');
64
+ assert.deepEqual(data.tasks.map(task => task.id), ['task-1']);
65
+ assert.equal(data.tasks[0].attention, true, 'native failed outcome is surfaced');
66
+ assert.equal(Object.hasOwn(data.tasks[0], 'branch'), false, 'undefined optional branch is omitted from RPC output');
67
+ assert.equal(data.sessions[0].id, 'task-session');
68
+
69
+ await assert.rejects(registered.open({ sessionID: 'foreign-session' }, { signal: new AbortController().signal }), /another workspace location/);
70
+ assert.deepEqual(await registered.open({ sessionID: 'root-session' }, { signal: new AbortController().signal }), { opened: true, directory: root });
71
+ assert.deepEqual(await registered.createWorktree({ projectID: 'project-id', name: 'task' }, { signal: new AbortController().signal }), { directory: join(base, 'new-worktree') });
72
+ await assert.rejects(registered.createWorktree({ projectID: 'other-project', name: 'task' }, { signal: new AbortController().signal }), /outside this OpenCode workspace/);
73
+ const controller = new AbortController(); controller.abort(new Error('cancelled'));
74
+ await assert.rejects(registered.snapshot({}, { signal: controller.signal }), /cancelled/);
75
+ await new Promise(resolve => setTimeout(resolve, 20));
76
+ events.push({ id: 'event-1', created: Date.now(), type: 'session.execution.started', data: { sessionID: 'task-session' }, durable: { aggregateID: 'task-session', seq: 2, version: 1 } });
77
+ const deadline = Date.now() + 1000;
78
+ while (!emitted.length && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
79
+ assert.equal(emitted[0]?.name, 'updated');
80
+ assert.deepEqual(Object.keys(emitted[0]?.payload ?? {}), ['revision']);
81
+ const inFlight = await registered.snapshot({}, { signal: new AbortController().signal });
82
+ assert.equal(inFlight.sessions[0].status, 'running');
83
+ });
84
+
85
+ test('registration failures reject setup and cannot leave an active unregistered bridge', async t => {
86
+ const root = mkdtempSync(join(tmpdir(), 'workspace-rpc-register-')); t.after(() => rmSync(root, { recursive: true, force: true }));
87
+ git(root, 'init', '-q');
88
+ let subscribed = false;
89
+ const ctx = {
90
+ location: { directory: root, project: { id: 'project-id', canonical: root } }, session: {}, worktree: {},
91
+ rpc: { async register() { throw new Error('registration rejected'); } },
92
+ event: { subscribe: () => { subscribed = true; return { async *[Symbol.asyncIterator]() {} }; } },
93
+ };
94
+ await assert.rejects(registerWorkspaceRpc(ctx), /registration rejected/);
95
+ assert.equal(subscribed, false, 'event stream should not start when RPC registration fails');
96
+ });
97
+
98
+ test('durable task creation propagates cancellation into native worktree creation', async t => {
99
+ const base = mkdtempSync(join(tmpdir(), 'workspace-rpc-cancel-')); t.after(() => rmSync(base, { recursive: true, force: true }));
100
+ const root = join(base, 'project'); mkdirSync(root); git(root, 'init', '-q');
101
+ const registered = {};
102
+ const controller = new AbortController();
103
+ const ctx = {
104
+ location: { directory: root, project: { id: 'project-id', canonical: root } }, session: {},
105
+ worktree: { async create(_input, { signal }) { assert.equal(signal, controller.signal); controller.abort(new Error('cancelled create')); signal.throwIfAborted(); } },
106
+ rpc: { async register(_definition, handlers) { Object.assign(registered, handlers); return { events: { async emit() {} }, dispose() {} }; } },
107
+ event: { subscribe: () => ({ async *[Symbol.asyncIterator]() {} }) },
108
+ };
109
+ const dispose = await registerWorkspaceRpc(ctx, { reviewModel: { providerID: 'fixture', id: 'review-model' } }); t.after(dispose);
110
+ await assert.rejects(registered.createTask({ task: 'cancel me' }, { signal: controller.signal }), /cancelled create/);
111
+ const journal = JSON.parse(readFileSync(join(root, '.harness/opencode-tasks.json'), 'utf8'));
112
+ assert.equal(journal.operations[0].status, 'recovery-required');
113
+ });
114
+
115
+ test('delivery RPC returns bounded authoritative session report projections and rejects stale, foreign, and corrupt reports', async t => {
116
+ const root = mkdtempSync(join(tmpdir(), 'workspace-rpc-delivery-')); t.after(() => rmSync(root, { recursive: true, force: true }));
117
+ git(root, 'init', '-q');
118
+ writeFileSync(join(root, 'source.txt'), 'before');
119
+ const sessionID = 'delivery-session';
120
+ const session = { id: sessionID, projectID: 'project-id', location: { directory: root }, title: 'Delivery', time: { updated: Date.now() } };
121
+ const sessions = new Map([[sessionID, session], ['foreign-session', { ...session, id: 'foreign-session' }]]);
122
+ const registered = {};
123
+ let workspaceContract;
124
+ const ctx = {
125
+ location: { directory: root, project: { id: 'project-id', canonical: root } },
126
+ session: { async get({ sessionID: id }) { const value = sessions.get(id); if (!value) throw Error('missing'); return value; } },
127
+ worktree: {},
128
+ rpc: { async register(definition, handlers) { workspaceContract = definition; Object.assign(registered, handlers); return { events: { async emit() {} }, dispose() {} }; } },
129
+ event: { subscribe: () => ({ async *[Symbol.asyncIterator]() {} }) },
130
+ };
131
+ const dispose = await registerWorkspaceRpc(ctx); t.after(dispose);
132
+ const check = { id: 'unit', kind: 'test', argv: ['node', '--test'], timeoutSeconds: 20 };
133
+ const plan = {
134
+ goal: 'Deliver a visible feature', verification: 'required', artifacts: ['.'], outputs: [],
135
+ steps: [{ id: 'implement', title: 'Implement feature', dependsOn: [], checks: ['unit'] }],
136
+ acceptance: [{ requirement: 'Feature is visible', checks: ['unit'] }], checks: [check],
137
+ design: { components: [{ id: 'core', responsibility: 'Own feature behavior', layer: 'core', excludes: ['UI'], dependencies: [], requirements: ['Feature is visible'] }], ports: [] },
138
+ };
139
+ const runId = 'run-1';
140
+ const current = fingerprint(root, ['.']);
141
+ const report = createReport(root, { runId, revision: 2, status: 'implementing', plan,
142
+ stepStatus: { step0: 'active' }, reviewEvidence: { runId, revision: 2, fingerprint: current },
143
+ evidence: { unit: { runId, revision: 2, checkDigest: checkDigest(check), passed: true, fingerprint: current, executedRevision: 2, durationMs: 9, code: 0, logPath: join(root, '.harness', sessionID, runId, 'logs', 'unit.log'), outputTail: 'must not leave the report DTO' } },
144
+ }, sessionID);
145
+ selectReport(root, report.path);
146
+ const signal = new AbortController().signal;
147
+ const result = await registered.delivery({ sessionID }, { signal });
148
+ assert.equal(result.status, 'implementing');
149
+ assert.equal(result.freshness, 'current');
150
+ assert.equal(result.runId, runId);
151
+ assert.equal(result.revision, 2);
152
+ assert.deepEqual(result.plan.acceptance, [{ requirement: 'Feature is visible', checks: ['unit'] }]);
153
+ assert.equal(result.plan.steps[0].status, 'active');
154
+ assert.ok(result.workflow.nodes.some(node => node.id === 'gate_verify'));
155
+ assert.equal(result.architecture.nodes[0].title, 'Own feature behavior');
156
+ assert.deepEqual(result.evidence, [{ id: 'unit', status: 'fresh', executedRevision: 2, code: 0, filename: 'unit.log' }]);
157
+ assert.equal(JSON.stringify(result).includes('must not leave the report DTO'), false);
158
+ assert.equal(JSON.stringify(result).includes(root), false, 'only a log filename, never the report’s absolute path, is exposed');
159
+ const deliverySchema = workspaceContract.methods.delivery.output;
160
+ assert.equal(deliverySchema.oneOf.length, 2);
161
+ assert.deepEqual(deliverySchema.oneOf.map(branch => branch.properties.status.enum), [['active', 'implementing', 'verifying', 'verified', 'blocked', 'stale'], ['unavailable', 'invalid']]);
162
+ assert.equal(deliverySchema.oneOf[0].additionalProperties, false);
163
+ assert.deepEqual(Object.keys(deliverySchema.oneOf[0].properties).sort(), ['architecture', 'completion', 'evidence', 'fingerprint', 'freshness', 'gate', 'plan', 'revision', 'runId', 'status', 'truncation', 'workflow']);
164
+ assert.deepEqual(deliverySchema.oneOf[0].required, Object.keys(deliverySchema.oneOf[0].properties));
165
+ assert.equal(deliverySchema.oneOf[1].additionalProperties, false);
166
+ assert.deepEqual(deliverySchema.oneOf[1].required, ['status']);
167
+ assert.deepEqual(Object.keys(deliverySchema.oneOf[1].properties), ['status']);
168
+ assert.equal(deliverySchema.oneOf[0].properties.plan.additionalProperties, false);
169
+
170
+ writeFileSync(join(root, 'source.txt'), 'edited');
171
+ const stale = await registered.delivery({ sessionID }, { signal });
172
+ assert.equal(stale.freshness, 'stale');
173
+ assert.equal(stale.evidence[0].status, 'stale');
174
+
175
+ const changedFingerprint = fingerprint(root, ['.']);
176
+ const completeState = {
177
+ runId: 'complete-run', revision: 1, status: 'verified', workflowMode: 'standard', plan: {
178
+ ...plan, steps: [{ ...plan.steps[0], checks: ['unit'] }],
179
+ }, stepStatus: { step0: 'done' },
180
+ };
181
+ const freshReceipt = { runId: 'complete-run', revision: 1, checkDigest: checkDigest(check), passed: true, fingerprint: changedFingerprint, executedRevision: 1, durationMs: 5, code: 0 };
182
+ for (const [name, receipt] of [['missing', undefined], ['failed', { ...freshReceipt, passed: false }]]) {
183
+ const state = { ...completeState, runId: `${name}-run`, evidence: receipt ? { unit: { ...receipt, runId: `${name}-run` } } : {}, reviewEvidence: { runId: `${name}-run`, revision: 1, fingerprint: changedFingerprint } };
184
+ const report = createReport(root, state, sessionID);
185
+ selectReport(root, report.path);
186
+ const projected = await registered.delivery({ sessionID }, { signal });
187
+ assert.notEqual(projected.status, 'verified', `a forged verified state with ${name} evidence cannot be projected as verified`);
188
+ assert.equal(projected.completion, 'incomplete');
189
+ }
190
+
191
+ const staleReviewState = { ...completeState, evidence: { unit: freshReceipt }, reviewEvidence: { runId: 'complete-run', revision: 1, fingerprint: '0'.repeat(64) } };
192
+ const staleReviewReport = createReport(root, staleReviewState, sessionID);
193
+ selectReport(root, staleReviewReport.path);
194
+ const staleReview = await registered.delivery({ sessionID }, { signal });
195
+ assert.equal(staleReview.status, 'stale', 'stale required final-review receipt blocks verified status');
196
+ assert.equal(staleReview.completion, 'incomplete');
197
+
198
+ const lockedState = { ...completeState, runId: 'locked-run', workflowMode: 'strict', evidence: { unit: { ...freshReceipt, runId: 'locked-run' } }, reviewEvidence: { runId: 'locked-run', revision: 1, fingerprint: changedFingerprint } };
199
+ const lockedReport = createReport(root, lockedState, sessionID);
200
+ selectReport(root, lockedReport.path);
201
+ const locked = await registered.delivery({ sessionID }, { signal });
202
+ assert.notEqual(locked.status, 'verified', 'a locked planning gate blocks a forged verified status');
203
+ assert.equal(locked.gate, 'locked');
204
+ assert.equal(locked.completion, 'incomplete');
205
+
206
+ const informationalState = { ...completeState, runId: 'informational-run', plan: { ...plan, verification: 'none', steps: [{ ...plan.steps[0], checks: [] }], checks: [], acceptance: [] }, evidence: {}, reviewEvidence: undefined };
207
+ const informationalReport = createReport(root, informationalState, sessionID);
208
+ selectReport(root, informationalReport.path);
209
+ const informational = await registered.delivery({ sessionID }, { signal });
210
+ assert.notEqual(informational.status, 'verified', 'informational plans cannot claim verified completion');
211
+ assert.equal(informational.completion, 'incomplete');
212
+
213
+ const manyChecks = Array.from({ length: 101 }, (_, index) => ({ id: `check_${String(index).padStart(3, '0')}`, kind: 'test', argv: ['node', '--test'], timeoutSeconds: 20 }));
214
+ const manyPlan = { ...plan, steps: [{ ...plan.steps[0], checks: manyChecks.map(item => item.id) }], checks: manyChecks, acceptance: [{ requirement: 'Feature is visible', checks: manyChecks.map(item => item.id) }] };
215
+ const manyEvidence = Object.fromEntries(manyChecks.map((item, index) => [item.id, {
216
+ runId: 'many-run', revision: 1, checkDigest: checkDigest(item), passed: true,
217
+ fingerprint: index === 100 ? '0'.repeat(64) : changedFingerprint, executedRevision: 1, durationMs: 2, code: 0,
218
+ }]));
219
+ const manyReport = createReport(root, { runId: 'many-run', revision: 1, status: 'implementing', workflowMode: 'standard', plan: manyPlan, stepStatus: { step0: 'done' }, evidence: manyEvidence, reviewEvidence: { runId: 'many-run', revision: 1, fingerprint: changedFingerprint } }, sessionID);
220
+ selectReport(root, manyReport.path);
221
+ const manyResult = await registered.delivery({ sessionID }, { signal });
222
+ assert.equal(manyResult.status, 'stale', 'staleness in a receipt beyond the DTO cap still affects overall status');
223
+ assert.deepEqual(manyResult.truncation.checks, { returned: 100, total: 101 });
224
+ assert.equal(manyResult.completion, 'incomplete');
225
+
226
+ const foreign = createReport(root, { runId: 'foreign-run', revision: 1, status: 'verified', plan, evidence: {} }, 'foreign-session');
227
+ selectReport(root, foreign.path);
228
+ assert.deepEqual(await registered.delivery({ sessionID }, { signal }), { status: 'unavailable' });
229
+
230
+ selectReport(root, report.path);
231
+ writeFileSync(report.path, '{corrupt');
232
+ assert.deepEqual(await registered.delivery({ sessionID }, { signal }), { status: 'invalid' });
233
+
234
+ writeFileSync(join(root, '.harness', 'active.json'), '{corrupt');
235
+ assert.deepEqual(await registered.delivery({ sessionID }, { signal }), { status: 'invalid' });
236
+ });