carthagent 1.0.7 → 1.1.0-beta.2

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 (303) hide show
  1. package/README.md +140 -83
  2. package/bin/ctg.mjs +14 -732
  3. package/docs/beta-rewards-e2e-plan.md +48 -0
  4. package/docs/beta-rewards-e2e-results.md +84 -0
  5. package/docs/cloud-onboarding-acceptance.md +65 -0
  6. package/docs/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  7. package/docs/glyph-review/test-isolation-v1.md +26 -0
  8. package/docs/glyph-review/typecheck-base.log +1 -0
  9. package/docs/glyph-review/typecheck-v4.log +1 -0
  10. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  11. package/docs/help-dispatch-review.md +69 -0
  12. package/docs/launch-readiness-live-results.md +65 -0
  13. package/docs/legacy-cli.md +150 -0
  14. package/docs/live-theme-plugin-api.md +18 -0
  15. package/docs/native-cli.md +102 -0
  16. package/docs/native-compatibility.md +16 -0
  17. package/docs/native-daily-quests.md +132 -0
  18. package/docs/native-glyph-pty.md +59 -0
  19. package/docs/native-manual-testing.md +336 -0
  20. package/docs/native-slash-acceptance-handoff.md +49 -0
  21. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  22. package/docs/native-terminal-performance.md +310 -0
  23. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  24. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  25. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  34. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  35. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  36. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  37. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  38. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  39. package/docs/native-terminal-semantics.md +89 -0
  40. package/docs/native-upgrade.md +175 -0
  41. package/docs/opencode-composed-source-review.md +89 -0
  42. package/docs/opencode-migration.md +225 -9
  43. package/docs/opencode-owned-runtime.md +241 -0
  44. package/docs/opencode-runtime.md +12 -0
  45. package/docs/opencode-theme-provider-proof/README.md +55 -0
  46. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  47. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  48. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  49. package/docs/owned-host-controls-proof.md +39 -0
  50. package/docs/owned-runtime-release-manifest.md +42 -0
  51. package/docs/owned-visual-clear-proof.md +40 -0
  52. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  53. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  54. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  55. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  61. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  62. package/docs/release-notes-beta.1.md +60 -0
  63. package/docs/release-notes-beta.2.md +60 -0
  64. package/docs/release-notes-native-draft.md +73 -0
  65. package/docs/release-notes-preview.10.md +121 -0
  66. package/docs/release-notes-preview.11.md +76 -0
  67. package/docs/release-notes-preview.12.md +55 -0
  68. package/docs/release-notes-preview.13.md +68 -0
  69. package/docs/release-notes-preview.9.md +130 -0
  70. package/docs/source-preview.md +120 -0
  71. package/evaluate.mjs +28 -1
  72. package/lib/budget.mjs +1 -1
  73. package/lib/cloud/client.mjs +13 -7
  74. package/lib/codegraph.mjs +1 -1
  75. package/lib/delivery-review.mjs +19 -6
  76. package/lib/delivery.mjs +3 -2
  77. package/lib/environment.mjs +8 -0
  78. package/lib/guidance.mjs +1 -1
  79. package/lib/opencode/cli-runtime.mjs +1188 -0
  80. package/lib/opencode/cli.mjs +253 -0
  81. package/lib/opencode/credentials.mjs +63 -8
  82. package/lib/opencode/demo.mjs +53 -0
  83. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  84. package/lib/opencode/evaluation.mjs +32 -12
  85. package/lib/opencode/headless.mjs +22 -5
  86. package/lib/opencode/provider-policy.mjs +23 -0
  87. package/lib/opencode/review.mjs +37 -16
  88. package/lib/opencode/runtime-artifact.mjs +335 -0
  89. package/lib/opencode/runtime-github-transport.mjs +121 -0
  90. package/lib/opencode/runtime-release.json +36 -0
  91. package/lib/opencode/runtime.mjs +299 -20
  92. package/lib/opencode/service.mjs +77 -35
  93. package/lib/opencode/session-options.mjs +6 -2
  94. package/lib/opencode/update.mjs +222 -0
  95. package/lib/opencode/workspace.mjs +1 -1
  96. package/lib/planning-advice.mjs +2 -2
  97. package/lib/providers/experiential.mjs +2 -2
  98. package/lib/providers/names.mjs +1 -1
  99. package/lib/tui/framing.mjs +1 -80
  100. package/lib/tui/models.mjs +1 -1
  101. package/lib/update.mjs +10 -1
  102. package/package.json +33 -7
  103. package/plugins/carthagent/README.md +23 -0
  104. package/plugins/carthagent/src/bootstrap.ts +1 -1
  105. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  106. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  107. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  108. package/plugins/carthagent/src/index.ts +71 -24
  109. package/plugins/carthagent/src/rpc.ts +51 -6
  110. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  111. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  112. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  113. package/plugins/carthagent/src/server/delivery.ts +227 -112
  114. package/plugins/carthagent/src/server/framing.mjs +80 -0
  115. package/plugins/carthagent/src/server/policy.ts +1 -1
  116. package/plugins/carthagent/src/server/providers.ts +65 -36
  117. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  118. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  119. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  120. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  121. package/plugins/carthagent/src/server/ship-rpc.ts +71 -0
  122. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  123. package/plugins/carthagent/src/testing.ts +5 -0
  124. package/plugins/carthagent/src/tui.tsx +506 -0
  125. package/plugins/carthagent/src/ui/client.ts +127 -0
  126. package/plugins/carthagent/src/ui/commands.ts +70 -0
  127. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  128. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  129. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  130. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  131. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  132. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  133. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  134. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  135. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  136. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  137. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  138. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  139. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  140. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  141. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  142. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  143. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  144. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  145. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  146. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  147. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  148. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  149. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  150. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  151. package/plugins/carthagent/tests/commands.test.ts +107 -0
  152. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  153. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  154. package/plugins/carthagent/themes/carthage.json +67 -0
  155. package/plugins/carthagent/tui.ts +26 -1
  156. package/script/ctg/build-owned-runtime.sh +166 -0
  157. package/tests/budget-engine.test.mjs +4 -3
  158. package/tests/cli.test.mjs +52 -96
  159. package/tests/cloud-client.test.mjs +2 -2
  160. package/tests/daily-evidence.test.mjs +62 -0
  161. package/tests/delivery-review.test.mjs +57 -2
  162. package/tests/delivery.test.mjs +22 -1
  163. package/tests/experiential-extension.test.mjs +2 -2
  164. package/tests/experiential.test.mjs +1 -1
  165. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  166. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  167. package/tests/installed-runtime-contract.test.mjs +43 -0
  168. package/tests/native-cli-host-smoke.mjs +457 -0
  169. package/tests/native-daily-editor-focus.test.mjs +213 -0
  170. package/tests/native-daily-editor.test.mjs +43 -0
  171. package/tests/native-daily-quests-layout.test.mjs +55 -0
  172. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  173. package/tests/native-daily-quests-ui.test.mjs +778 -0
  174. package/tests/native-daily-source.test.mjs +525 -0
  175. package/tests/native-demo-ui.test.mjs +48 -0
  176. package/tests/native-evaluation-admission.test.mjs +24 -0
  177. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  178. package/tests/native-fixture-runtime.test.mjs +111 -0
  179. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  180. package/tests/native-glyph-pty.test.mjs +107 -0
  181. package/tests/native-manual-projects.test.mjs +170 -0
  182. package/tests/native-model-command.test.mjs +67 -0
  183. package/tests/native-owned-runtime-env.test.mjs +140 -0
  184. package/tests/native-paired-guards.test.mjs +464 -0
  185. package/tests/native-provider-policy.test.mjs +33 -0
  186. package/tests/native-pty-invocation.test.mjs +114 -0
  187. package/tests/native-pty-runtime.test.mjs +45 -0
  188. package/tests/native-resume-session.test.mjs +46 -0
  189. package/tests/native-review-consumer.test.mjs +286 -0
  190. package/tests/native-runtime-identity.test.mjs +214 -0
  191. package/tests/native-select-mouse.test.mjs +23 -0
  192. package/tests/native-ship-guidance.test.mjs +16 -0
  193. package/tests/native-ship-parity.test.mjs +526 -0
  194. package/tests/native-ship-plan.test.mjs +85 -0
  195. package/tests/native-ship-rpc.test.mjs +83 -0
  196. package/tests/native-ui-commands.test.mjs +6 -0
  197. package/tests/native-voyages-layout.test.mjs +28 -0
  198. package/tests/native-voyages-navigation.test.mjs +169 -0
  199. package/tests/native-voyages-rpc.test.mjs +397 -0
  200. package/tests/native-voyages-ui.test.mjs +307 -0
  201. package/tests/opencode-atomic-write.test.mjs +55 -0
  202. package/tests/opencode-cli.test.mjs +1312 -0
  203. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  204. package/tests/opencode-delivery.test.mjs +16 -0
  205. package/tests/opencode-providers.test.mjs +248 -20
  206. package/tests/opencode-runtime.test.mjs +597 -8
  207. package/tests/opencode-session-controls.test.mjs +15 -0
  208. package/tests/opencode-ship-rpc.test.mjs +71 -0
  209. package/tests/opencode-update.test.mjs +475 -0
  210. package/tests/opencode-workspace.test.mjs +51 -2
  211. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  212. package/tests/release-workflow.test.mjs +46 -0
  213. package/tests/runtime-artifact.test.mjs +269 -0
  214. package/tests/runtime-development-trust.test.mjs +231 -0
  215. package/tests/runtime-github-transport.test.mjs +132 -0
  216. package/tests/runtime-token-env.test.mjs +23 -0
  217. package/tests/server.test.mjs +3 -1
  218. package/tests/ship-telemetry.test.mjs +1 -1
  219. package/tests/source-preview.test.mjs +253 -0
  220. package/tests/test_native_terminal_probe.py +76 -0
  221. package/tests/test_native_terminal_semantics.py +232 -0
  222. package/tests/tui.test.mjs +2 -2
  223. package/tests/update.test.mjs +42 -5
  224. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  225. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  226. package/tests/workspace-live.test.mjs +127 -0
  227. package/tools/benchmark/diagnose-request.mjs +59 -36
  228. package/tools/benchmark/eval-common.mjs +3 -3
  229. package/tools/benchmark/evaluators.test.mjs +4 -4
  230. package/tools/benchmark/guard.test.mjs +35 -2
  231. package/tools/benchmark/guard.ts +20 -7
  232. package/tools/benchmark/planning-startup.test.mjs +30 -34
  233. package/tools/benchmark/runner.mjs +12 -7
  234. package/tools/benchmark/runner.test.mjs +23 -7
  235. package/tools/benchmark/upstream-error.test.mjs +16 -2
  236. package/tools/generate-owned-runtime-release.mjs +217 -0
  237. package/tools/installed-runtime-contract.mjs +16 -0
  238. package/tools/manual-runtime-manifest.mjs +39 -0
  239. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  240. package/tools/native-fixture-stop-diagnostics.mjs +45 -0
  241. package/tools/native-fixture.mjs +199 -40
  242. package/tools/native-glyph-helpers.py +127 -0
  243. package/tools/native-glyph-pty.py +144 -0
  244. package/tools/native-manual-projects.mjs +259 -0
  245. package/tools/native-manual.mjs +338 -0
  246. package/tools/native-model-picker-acceptance.py +306 -0
  247. package/tools/native-owned-runtime-env.sh +47 -0
  248. package/tools/native-paired-guards.mjs +282 -0
  249. package/tools/native-paired-manifest.mjs +72 -0
  250. package/tools/native-paired-orchestration.sh +51 -0
  251. package/tools/native-plugin-readiness.mjs +33 -0
  252. package/tools/native-plugin-readiness.test.mjs +53 -0
  253. package/tools/native-pty-invocation.mjs +26 -0
  254. package/tools/native-pty-runtime.mjs +83 -0
  255. package/tools/native-pty-smoke.py +147 -24
  256. package/tools/native-public-repo-shell-result.mjs +13 -0
  257. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  258. package/tools/native-runtime-identity.mjs +201 -0
  259. package/tools/native-slash-acceptance-env.py +38 -0
  260. package/tools/native-terminal-semantics.py +882 -0
  261. package/tools/owned-shell-observer/index.ts +16 -0
  262. package/tools/owned-shell-observer/package.json +1 -0
  263. package/tools/owned-visual-clear-env.py +48 -0
  264. package/tools/owned-visual-clear-model.py +21 -0
  265. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  266. package/tools/prepare-native-manual.mjs +122 -0
  267. package/tools/run-native-terminal-paired.sh +403 -0
  268. package/tools/runtime-token-env.mjs +8 -0
  269. package/tools/seed-native-switch-fixture.mjs +79 -0
  270. package/tools/test-native-controls.mjs +3 -1
  271. package/tools/test-native-daily-quests.mjs +65 -0
  272. package/tools/test-native-default-cli.mjs +106 -0
  273. package/tools/test-native-delivery-host.mjs +102 -20
  274. package/tools/test-native-evaluation.mjs +7 -3
  275. package/tools/test-native-glyph-pty.mjs +94 -0
  276. package/tools/test-native-host.mjs +91 -65
  277. package/tools/test-native-outcomes.mjs +3 -1
  278. package/tools/test-native-package.mjs +206 -38
  279. package/tools/test-native-performance.mjs +26 -0
  280. package/tools/test-native-product.mjs +90 -0
  281. package/tools/test-native-pty.mjs +34 -9
  282. package/tools/test-native-public-repo.mjs +224 -0
  283. package/tools/test-native-refresh-reentry.mjs +106 -0
  284. package/tools/test-native-report.mjs +27 -20
  285. package/tools/test-native-review.mjs +75 -0
  286. package/tools/test-native-runtime.mjs +120 -0
  287. package/tools/test-native-ship-models.mjs +176 -0
  288. package/tools/test-native-ship-walkthrough.mjs +96 -0
  289. package/tools/test-native-slash-commands.mjs +125 -0
  290. package/tools/test-native-slash-commands.py +242 -0
  291. package/tools/test-native-swe-host.mjs +224 -55
  292. package/tools/test-native-tasks.mjs +77 -0
  293. package/tools/test-native-terminal-performance.py +887 -0
  294. package/tools/test-native-terminal-readiness.sh +85 -0
  295. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  296. package/tools/test-native-visual-clear-installed.mjs +48 -0
  297. package/tools/test-owned-host-controls.mjs +238 -0
  298. package/tools/test-owned-opencode-runtime.mjs +35 -0
  299. package/tools/test-owned-visual-clear-env.py +120 -0
  300. package/tools/test-owned-visual-clear.py +373 -0
  301. package/tools/verify-draft-runtime-gate.mjs +138 -0
  302. package/tools/verify-owned-runtime-release.mjs +114 -0
  303. package/tools/verify-release-version.mjs +21 -1
@@ -0,0 +1,526 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { createServer } from 'node:http'
6
+ import { once } from 'node:events'
7
+ import { join } from 'node:path'
8
+ import { registerDelivery } from '../plugins/carthagent/src/server/delivery.ts'
9
+ import { registerShipAgents, SHIP_AGENT_ID, LITE_AGENT_ID } from '../plugins/carthagent/src/server/ship-agents.ts'
10
+ import { fixtureDesign, fixtureReview } from './helpers/tested-design.mjs'
11
+ import { archiveDeliveryExchanges } from '../plugins/carthagent/src/server/ship-context.mjs'
12
+
13
+ const signal = () => new AbortController().signal
14
+
15
+ function host(t, { agent = SHIP_AGENT_ID, files = { 'app.ts': 'export const app = () => 42\n' } } = {}) {
16
+ const directory = mkdtempSync(join(tmpdir(), 'ctg-ship-parity '))
17
+ t.after(() => rmSync(directory, { recursive: true, force: true }))
18
+ for (const [name, content] of Object.entries(files)) writeFileSync(join(directory, name), content)
19
+ const tools = new Map(), hooks = new Map(), commands = new Map(), stores = new Map(), prompts = [], interrupts = []
20
+ const synthetic = []
21
+ const sessions = new Map([['ses-a', { id: 'ses-a', agent, location: { directory } }]])
22
+ let emit
23
+ // Only the OpenCode host boundary is simulated; plans, gates, commands, storage files and archives execute real code.
24
+ const ctx = {
25
+ location: { directory }, options: {},
26
+ model: { list: async () => ({ data: [{ providerID: "p", id: "text-only", capabilities: { input: ["text"] } }] }) },
27
+ session: {
28
+ get: async ({ sessionID }) => ({ ...sessions.get(sessionID), id: sessionID }),
29
+ hook: async (name, callback) => { hooks.set(name, callback); return { dispose: async () => hooks.delete(name) } },
30
+ interrupt: async input => { interrupts.push(input) },
31
+ prompt: async input => { prompts.push(input) },
32
+ synthetic: async input => { synthetic.push(input) },
33
+ },
34
+ tool: {
35
+ transform: async callback => { callback({ add: def => tools.set(def.name, def), namespace() {} }); return { dispose: async () => tools.clear() } },
36
+ hook: async (name, callback) => { hooks.set(`tool:${name}`, callback); return { dispose: async () => hooks.delete(`tool:${name}`) } },
37
+ },
38
+ shell: { hook: async (name, callback) => { hooks.set(`shell:${name}`, callback); return { dispose: async () => hooks.delete(`shell:${name}`) } } },
39
+ command: { transform: async callback => { callback({ add: def => commands.set(def.name, def) }); return { dispose: async () => commands.clear() } } },
40
+ storage: { get: async key => stores.get(key), set: async (key, value) => stores.set(key, structuredClone(value)) },
41
+ event: {
42
+ subscribe: ({ signal: abort }) => (async function* () {
43
+ while (!abort.aborted) {
44
+ const next = await new Promise(resolve => { emit = resolve; abort.addEventListener('abort', () => resolve(null), { once: true }) })
45
+ if (next) yield next
46
+ }
47
+ })(),
48
+ },
49
+ }
50
+ const call = (name, input, agentID = sessions.get('ses-a').agent) => tools.get(name).execute(input, { sessionID: 'ses-a', agent: agentID, signal: signal() })
51
+ const before = (tool, input = {}, id = `${tool}-${Math.random()}`, agentID = sessions.get('ses-a').agent) => hooks.get('tool:execute.before')({ id, messageID: `m-${id}`, tool, sessionID: 'ses-a', agent: agentID, input })
52
+ const after = async (tool, extra = {}, agentID = sessions.get('ses-a').agent) => {
53
+ const event = { id: `a-${Math.random()}`, messageID: 'm', tool, sessionID: 'ses-a', agent: agentID, input: {}, status: 'completed', result: { content: [] }, ...extra }
54
+ await hooks.get('tool:execute.after')(event)
55
+ return event
56
+ }
57
+ const context = async (agentID = sessions.get('ses-a').agent, extra = {}) => {
58
+ const event = { sessionID: 'ses-a', agent: agentID, model: { providerID: 'p', id: 'm' }, system: [], messages: [], options: {}, tools: { edit: {}, carthagent_delivery_plan: {}, carthagent_delivery_status: {}, read: {} }, ...extra }
59
+ await hooks.get('context')(event)
60
+ return event
61
+ }
62
+ const prompt = async text => {
63
+ const event = { sessionID: 'ses-a', messageID: 'msg', prompt: { text }, delivery: 'queue' }
64
+ await hooks.get('prompt')(event)
65
+ return event
66
+ }
67
+ const idle = async () => { emit?.({ type: 'session.idle', data: { sessionID: 'ses-a' } }); await new Promise(resolve => setTimeout(resolve, 30)) }
68
+ const plan = (overrides = {}) => {
69
+ const input = {
70
+ goal: 'Change app output', assumptions: [], artifacts: ['.'], steps: ['Implement'],
71
+ acceptance: [{ requirement: 'app returns 42', checks: ['smoke'] }],
72
+ checks: [{ id: 'smoke', kind: 'test', argv: [process.execPath, '-e', 'process.exit(0)'], timeoutSeconds: 5 }],
73
+ ...overrides,
74
+ }
75
+ return call('delivery_plan', { ...input, design: fixtureDesign(input) })
76
+ }
77
+ return { ctx, tools, hooks, commands, stores, prompts, synthetic, interrupts, sessions, directory, call, before, after, context, prompt, idle, plan }
78
+ }
79
+
80
+ const systemText = event => event.system.map(part => part.text).join('\n')
81
+
82
+ test('Ship and Lite are native primary agents and Ship is the default mode', async () => {
83
+ const agents = new Map(); let defaultID
84
+ const editor = { update: (id, fn) => { const item = agents.get(id) ?? { id, permissions: [] }; agents.set(id, item); fn(item) }, default: id => { defaultID = id } }
85
+ const dispose = await registerShipAgents({ agent: { transform: async callback => { callback(editor); return { dispose: async () => {} } } } })
86
+ assert.equal(defaultID, SHIP_AGENT_ID)
87
+ assert.equal(agents.get(SHIP_AGENT_ID).mode, 'primary')
88
+ assert.equal(agents.get(LITE_AGENT_ID).mode, 'primary')
89
+ assert.equal(agents.get(SHIP_AGENT_ID).system, undefined, 'Ship keeps the native base prompt; delivery guidance arrives per request')
90
+ assert.doesNotMatch(JSON.stringify([...agents.values()]), /todowrite/)
91
+ await dispose()
92
+ })
93
+
94
+ test('delivery guidance is injected on the first Ship turn, before any prompt hook state exists', async t => {
95
+ const h = host(t)
96
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
97
+ const event = await h.context()
98
+ assert.match(systemText(event), /Preserve every user requirement/)
99
+ assert.match(systemText(event), /Standard delivery: inspect relevant files/)
100
+ await cleanup()
101
+ })
102
+
103
+ test('the harness is scoped to Ship: Lite, Plan and Build get no guidance, locks, delivery tools or repairs', async t => {
104
+ for (const agent of [LITE_AGENT_ID, 'plan', 'build']) {
105
+ const h = host(t, { agent })
106
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
107
+ const event = await h.context()
108
+ assert.equal(systemText(event), '', `${agent}: no delivery system guidance`)
109
+ assert.ok(event.tools.edit, `${agent}: implementation tools stay available`)
110
+ assert.equal(event.tools.carthagent_delivery_plan, undefined, `${agent}: delivery tools hidden`)
111
+ await h.before('edit', { path: 'app.ts' })
112
+ await assert.rejects(h.plan(), /Ship/)
113
+ const framed = await h.prompt('build a web dashboard')
114
+ assert.equal(framed.prompt.text, 'build a web dashboard')
115
+ await cleanup()
116
+ }
117
+ })
118
+
119
+ test('switching away from Ship mid-session stops enforcement; switching back restores it', async t => {
120
+ const h = host(t)
121
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
122
+ await assert.rejects(h.before('edit', { path: 'app.ts' }), /delivery_plan/)
123
+ h.sessions.get('ses-a').agent = LITE_AGENT_ID
124
+ await h.before('edit', { path: 'app.ts' })
125
+ h.sessions.get('ses-a').agent = SHIP_AGENT_ID
126
+ await assert.rejects(h.before('edit', { path: 'app.ts' }), /delivery_plan/)
127
+ await cleanup()
128
+ })
129
+
130
+ test('an unknown session agent fails closed to the Ship harness', async t => {
131
+ const h = host(t)
132
+ delete h.sessions.get('ses-a').agent
133
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
134
+ await assert.rejects(h.before('edit', { path: 'app.ts' }, 'x', undefined), /delivery_plan/)
135
+ await cleanup()
136
+ })
137
+
138
+ test('standard guidance matches legacy text, including browser, image and test infrastructure guidance', async t => {
139
+ const h = host(t)
140
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
141
+ await h.prompt('build a browser frontend with a typescript api route')
142
+ const text = systemText(await h.context(SHIP_AGENT_ID, { model: { providerID: 'p', id: 'text-only' }, modelCapabilities: { input: ['text'] } }))
143
+ assert.match(text, /self-contained browser check is available/)
144
+ assert.match(text, /Test infrastructure: reuse the installed runner/)
145
+ assert.match(text, /cannot process images/)
146
+ await cleanup()
147
+ })
148
+
149
+ test('routed guidance profiles persist into the plan and stay active for follow-ups', async t => {
150
+ const h = host(t)
151
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
152
+ await h.prompt('build a web application with a login page and browser UI')
153
+ await h.plan()
154
+ const saved = h.stores.get('session/ses-a')
155
+ assert.ok(saved.state.guidanceProfiles.length > 0)
156
+ await h.prompt('continue')
157
+ assert.match(systemText(await h.context()), /web/i)
158
+ await cleanup()
159
+ })
160
+
161
+ test('repeated unchanged delivery_status calls are blocked after three polls', async t => {
162
+ const h = host(t)
163
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
164
+ await h.plan()
165
+ for (let i = 0; i < 3; i++) await h.before('delivery_status', {}, `status-${i}`)
166
+ await assert.rejects(h.before('delivery_status', {}, 'status-4'), /Repeated unchanged delivery_status/)
167
+ await cleanup()
168
+ })
169
+
170
+ test('protect-existing blocks shell redirection, scripted replacement and rm of original files', async t => {
171
+ const h = host(t)
172
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard', protectExisting: true })
173
+ await h.plan()
174
+ await assert.rejects(h.before('shell', { command: 'echo x > app.ts' }), /redirection/)
175
+ await assert.rejects(h.before('shell', { command: "node -e \"require('fs').writeFileSync('app.ts','')\"" }), /script cannot replace/)
176
+ await assert.rejects(h.before('shell', { command: 'rm app.ts' }), /remove or replace/)
177
+ await cleanup()
178
+ })
179
+
180
+ test('broad process termination patterns match legacy, including Windows forms', async t => {
181
+ const h = host(t)
182
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
183
+ await h.plan()
184
+ for (const command of ['taskkill /IM node.exe /F', 'Stop-Process -Name node', 'Get-Process node | Stop-Process', 'pkill node']) {
185
+ await assert.rejects(h.before('shell', { command }), /Broad process termination/, command)
186
+ }
187
+ await cleanup()
188
+ })
189
+
190
+ test('rewrite cap counts whole-file writes only, never focused edits', async t => {
191
+ const h = host(t)
192
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard', rewriteCap: 1 })
193
+ await h.plan()
194
+ await h.before('write', { path: 'new.ts', content: 'a' })
195
+ for (let i = 0; i < 3; i++) await h.before('edit', { path: 'new.ts' })
196
+ await assert.rejects(h.before('write', { path: 'new.ts', content: 'b' }), /rewrite limit/)
197
+ await cleanup()
198
+ })
199
+
200
+ test('blocked implementation attempts during discovery produce the pre-plan checkpoint', async t => {
201
+ const h = host(t)
202
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'strict' })
203
+ await h.prompt('implement a feature that changes the app')
204
+ await assert.rejects(h.before('edit', { path: 'app.ts' }, 'b1'))
205
+ await assert.rejects(h.before('edit', { path: 'app.ts' }, 'b2'), /Pre-plan checkpoint/)
206
+ await cleanup()
207
+ })
208
+
209
+ test('repeated failing probes append the infrastructure diagnostic checkpoint', async t => {
210
+ const h = host(t)
211
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
212
+ await h.plan()
213
+ const failing = { status: 'completed', result: { content: [{ type: 'text', text: 'Error: Cannot find module x\nexited with code 1' }] } }
214
+ await h.after('shell', structuredClone(failing))
215
+ const second = structuredClone(failing)
216
+ const observed = await h.after('shell', second)
217
+ assert.match(JSON.stringify(observed.result.content), /Diagnostic checkpoint/)
218
+ await cleanup()
219
+ })
220
+
221
+ test('turn delay paces tool results like legacy bounded runs', async t => {
222
+ const h = host(t)
223
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard', turnDelayMs: 60 })
224
+ const started = Date.now()
225
+ await h.after('read')
226
+ assert.ok(Date.now() - started >= 55)
227
+ await cleanup()
228
+ })
229
+
230
+ test('design review normalizes captureId beside the action, flattened fields and echoed id', async t => {
231
+ const h = host(t)
232
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'strict' })
233
+ await h.plan()
234
+ await h.call('delivery_design', { action: 'validate' })
235
+ const inspected = JSON.parse((await h.call('delivery_design', { action: 'inspect' })).content)
236
+ const result = JSON.parse((await h.call('delivery_design', { action: 'review', captureId: inspected.captureId, ...fixtureReview(inspected.result.plan) })).content)
237
+ assert.doesNotMatch(JSON.stringify(result), /captureId is required|missing capture/i)
238
+ await cleanup()
239
+ })
240
+
241
+ test('Jev uses the native OAuth credential, runs once per capture identity, and remains advisory', async t => {
242
+ const h = host(t)
243
+ let calls = 0
244
+ const previous = process.env.CARTHAGENT_CLOUD_URL
245
+ t.after(() => { if (previous === undefined) delete process.env.CARTHAGENT_CLOUD_URL; else process.env.CARTHAGENT_CLOUD_URL = previous })
246
+ const server = createServer(async (request, response) => {
247
+ calls++
248
+ assert.equal(request.url, '/v1/jev')
249
+ assert.equal(request.headers.authorization, 'Bearer native-fixture-credential')
250
+ const chunks = []
251
+ for await (const chunk of request) chunks.push(chunk)
252
+ const body = JSON.parse(Buffer.concat(chunks).toString())
253
+ assert.equal(body.state.goal, 'Change app output')
254
+ assert.equal(h.stores.get('session/ses-a').state.advisorAttempts.length, 1, 'attempt persisted before transport dispatch')
255
+ response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ model: 'jev-latest', answers: { srp: { type: 'noul', noul: 0.6 }, di: { type: 'noul', noul: 0.1 }, tests: { type: 'noul', noul: 0.2 } } }))
256
+ })
257
+ server.listen(0, '127.0.0.1')
258
+ await once(server, 'listening')
259
+ t.after(() => new Promise(resolve => server.close(resolve)))
260
+ process.env.CARTHAGENT_CLOUD_URL = `http://127.0.0.1:${server.address().port}`
261
+ // Native credential resolution is a host boundary; the actual Jev HTTP client runs against loopback.
262
+ h.ctx.integration = { connection: {
263
+ active: async () => ({ type: 'credential', method: 'oauth' }),
264
+ resolve: async () => ({ type: 'oauth', methodID: 'carthagent-device', access: 'native-fixture-credential' }),
265
+ } }
266
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'strict' })
267
+ h.sessions.get('ses-a').model = { providerID: 'experiential-labs', id: 'ship-model' }
268
+ await h.plan()
269
+ await h.call('delivery_design', { action: 'validate' })
270
+ const inspected = JSON.parse((await h.call('delivery_design', { action: 'inspect' })).content)
271
+ assert.equal(inspected.advice.mode, 'shadow')
272
+ assert.equal(inspected.advice.authority, 'none')
273
+ assert.equal(calls, 1)
274
+ await assert.rejects(h.before('edit', { path: 'app.ts' }), /locked/)
275
+ const again = JSON.parse((await h.call('delivery_design', { action: 'inspect' })).content)
276
+ assert.equal(again.advice.reused, true)
277
+ assert.equal(calls, 1)
278
+ h.sessions.get('ses-a').model = { providerID: 'other', id: 'model' }
279
+ const other = JSON.parse((await h.call('delivery_design', { action: 'inspect' })).content)
280
+ assert.equal(other.advice.mode, 'disabled')
281
+ assert.equal(calls, 1)
282
+ await cleanup()
283
+ })
284
+
285
+ test('delivery_check routes passing checks to the handoff action', async t => {
286
+ const h = host(t)
287
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
288
+ await h.plan()
289
+ const result = JSON.parse((await h.call('delivery_check', { id: 'all' })).content)
290
+ assert.match(result.next, /delivery_finish/)
291
+ await cleanup()
292
+ })
293
+
294
+ test('advisory commands cannot bypass approval and informational finish stays available', async t => {
295
+ const h = host(t)
296
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
297
+ await h.plan({ verification: 'advisory', checks: [{ id: 'smoke', kind: 'test', argv: [process.execPath, '-e', 'process.exit(1)'], timeoutSeconds: 5 }] })
298
+ await assert.rejects(h.call('delivery_check', { id: 'all' }), /Implementation locked/)
299
+ const done = JSON.parse((await h.call('delivery_finish', { status: 'verified', review: 'Check was not authorized; informational handoff only.', launch: 'not run', limitations: ['No execution evidence.'] })).content)
300
+ assert.deepEqual(done.advisoryFailures, [])
301
+ assert.match(done.note, /Informational\/advisory finish/)
302
+ await cleanup()
303
+ })
304
+
305
+ test('required validators added on restore force status back to verifying', async t => {
306
+ const h = host(t)
307
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
308
+ await h.plan()
309
+ await h.call('delivery_check', { id: 'all' })
310
+ await cleanup()
311
+ writeFileSync(join(h.directory, 'validators.json'), JSON.stringify({ version: 1, checks: [{ id: 'user-gate', kind: 'test', argv: [process.execPath, '-e', '0'], timeoutSeconds: 5 }] }))
312
+ const again = await registerDelivery(h.ctx, { workflow: 'standard', validators: 'validators.json' })
313
+ const status = JSON.parse((await h.call('delivery_status', {})).content)
314
+ assert.equal(status.status, 'verifying')
315
+ await again()
316
+ })
317
+
318
+ test('repair nudges quote failing check output tails and only follow a succeeded Ship turn', async t => {
319
+ const h = host(t)
320
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
321
+ await h.plan({ checks: [{ id: 'smoke', kind: 'test', argv: [process.execPath, '-e', 'console.log("boom-tail"); process.exit(3)'], timeoutSeconds: 5 }] })
322
+ await h.after('edit')
323
+ await assert.rejects(h.call('delivery_check', { id: 'all' }))
324
+ h.sessions.get('ses-a').outcome = 'succeeded'
325
+ await h.idle()
326
+ assert.equal(h.prompts.length, 1)
327
+ assert.match(h.prompts[0].text, /boom-tail/)
328
+ assert.match(h.prompts[0].text, /exit 3/)
329
+ h.sessions.get('ses-a').agent = LITE_AGENT_ID
330
+ await h.idle()
331
+ assert.equal(h.prompts.length, 1, 'Lite turns never trigger delivery repairs')
332
+ await cleanup()
333
+ })
334
+
335
+ test('context metrics are recorded per Ship request and pressure warnings appear past 240KB', async t => {
336
+ const h = host(t)
337
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
338
+ await h.plan()
339
+ const big = [{ role: 'user', content: [{ type: 'text', text: 'x'.repeat(260_000) }] }]
340
+ await h.context(SHIP_AGENT_ID, { messages: structuredClone(big) })
341
+ const event = await h.context(SHIP_AGENT_ID, { messages: structuredClone(big) })
342
+ const runId = h.stores.get('session/ses-a').state.runId
343
+ const metrics = join(h.directory, '.harness', 'ses-a', runId, 'context-metrics.jsonl')
344
+ assert.ok(existsSync(metrics))
345
+ assert.ok(readFileSync(metrics, 'utf8').trim().split('\n').length >= 2)
346
+ assert.match(systemText(event), /Context pressure/)
347
+ await cleanup()
348
+ })
349
+
350
+ test('successful delivery exchanges are archived after approval while recent ones stay inline', async t => {
351
+ const h = host(t)
352
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'strict' })
353
+ await h.plan()
354
+ await h.call('delivery_design', { action: 'validate' })
355
+ const inspected = JSON.parse((await h.call('delivery_design', { action: 'inspect' })).content)
356
+ await h.call('delivery_design', { action: 'review', captureId: inspected.captureId, review: fixtureReview(inspected.result.plan) })
357
+ await h.call('delivery_design', { action: 'approve' })
358
+ const exchange = n => [
359
+ { role: 'assistant', content: [{ type: 'tool-call', id: `c${n}`, name: 'carthagent_delivery_status', input: {} }] },
360
+ { role: 'tool', content: [{ type: 'tool-result', id: `c${n}`, name: 'carthagent_delivery_status', result: { type: 'text', value: `ok ${n}` } }] },
361
+ ]
362
+ const messages = [...exchange(1), ...exchange(2), ...exchange(3), ...exchange(4)]
363
+ const event = await h.context(SHIP_AGENT_ID, { messages })
364
+ const archived = event.messages.filter(m => JSON.stringify(m).includes('Archived successful delivery exchange'))
365
+ assert.equal(archived.length, 2)
366
+ assert.equal(event.messages.filter(m => m.role === 'tool').length, 2)
367
+ await cleanup()
368
+ })
369
+
370
+ test('Ship registers the bundled delivery skills', async () => {
371
+ const added = []
372
+ const { registerShipSkills } = await import('../plugins/carthagent/src/server/ship-agents.ts')
373
+ const dispose = await registerShipSkills({ skill: { transform: async callback => { callback({ add: skill => added.push(skill), get: () => undefined }); return { dispose: async () => {} } } } })
374
+ assert.deepEqual(added.map(skill => skill.id).sort(), ['game-development', 'software-delivery', 'typescript-delivery'])
375
+ assert.ok(added.every(skill => skill.content.length > 100 && skill.path.endsWith('SKILL.md')))
376
+ await dispose()
377
+ })
378
+
379
+
380
+ test('native output caps keep the tail, preserve files, and leave delivery evidence intact', async t => {
381
+ const h = host(t)
382
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard', toolOutputCap: 12 })
383
+ const output = 'prefix secret-looking fixture text tail-content'
384
+ const result = await h.after('read', { result: { content: [{ type: 'text', text: output }, { type: 'file', uri: 'file:///fixture.png', mime: 'image/png' }] } })
385
+ assert.match(result.result.content[0].text, /^tail-content/)
386
+ assert.equal(result.result.content[1].type, 'file')
387
+ await h.plan()
388
+ const evidence = await h.after('carthagent_delivery_check', { result: { content: [{ type: 'text', text: output }] } })
389
+ assert.equal(evidence.result.content[0].text, output)
390
+ await cleanup()
391
+ })
392
+
393
+ test('post-check scope failures retain failed evidence and still permit an honest blocked handoff', async t => {
394
+ const h = host(t)
395
+ const previous = process.env.CARTHAGENT_MAX_FILES
396
+ process.env.CARTHAGENT_MAX_FILES = '1'
397
+ t.after(() => { if (previous === undefined) delete process.env.CARTHAGENT_MAX_FILES; else process.env.CARTHAGENT_MAX_FILES = previous })
398
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
399
+ await h.plan({ checks: [{ id: 'smoke', kind: 'test', argv: [process.execPath, '-e', 'require("fs").writeFileSync("extra.ts", "export const extra = 1")'], timeoutSeconds: 5 }] })
400
+ await assert.rejects(h.call('delivery_check', { id: 'all' }), /Post-check evidence scope error:.*delivery_finish/)
401
+ const state = h.stores.get('session/ses-a').state
402
+ assert.equal(state.evidence.smoke.passed, false)
403
+ assert.equal(state.evidence.smoke.fingerprint, null)
404
+ assert.ok(existsSync(state.evidence.smoke.logPath))
405
+ const finished = JSON.parse((await h.call('delivery_finish', { status: 'blocked', review: 'Scope could not be established.', launch: 'not run', limitations: ['Evidence scope exceeds configured limit.'] })).content)
406
+ assert.equal(finished.status, 'blocked')
407
+ await cleanup()
408
+ })
409
+
410
+ test('protect-existing captures only session-start files, not new files written later', async t => {
411
+ const h = host(t)
412
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard', protectExisting: true })
413
+ await h.plan()
414
+ await h.before('write', { path: 'new.ts', content: 'one' })
415
+ writeFileSync(join(h.directory, 'new.ts'), 'one')
416
+ await h.before('write', { path: 'new.ts', content: 'two' })
417
+ await assert.rejects(h.before('write', { path: 'app.ts', content: 'overwrite' }), /Preserve existing/)
418
+ await cleanup()
419
+ })
420
+
421
+ test('direct and Code Mode results expose the authoritative plan snapshot', async t => {
422
+ const h = host(t)
423
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
424
+ const plan = await h.plan()
425
+ assert.equal(plan.metadata.deliveryPlan.items[0].status, 'pending')
426
+ await h.call('delivery_progress', { step: 0, status: 'active' })
427
+ const outer = await h.after('execute')
428
+ assert.equal(outer.result.metadata.deliveryPlan.items[0].status, 'active')
429
+ assert.equal(outer.result.metadata.deliveryPlan.runId, plan.metadata.deliveryPlan.runId)
430
+ await cleanup()
431
+ })
432
+
433
+ test('budget exhaustion remains enforced when delivery participation is disabled', async t => {
434
+ const h = host(t, { agent: LITE_AGENT_ID })
435
+ const cleanup = await registerDelivery(h.ctx, { maxTools: 1 })
436
+ await h.before('read')
437
+ await assert.rejects(h.before('read'), /budget exhausted/)
438
+ await assert.rejects(h.context(), /budget exhausted/)
439
+ assert.equal(h.interrupts.length, 2)
440
+ await cleanup()
441
+ })
442
+
443
+
444
+ test('Code Mode wrappers and reads do not reset repeated unchanged status polling', async t => {
445
+ const h = host(t)
446
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
447
+ await h.plan()
448
+ for (let i = 0; i < 3; i++) {
449
+ await h.before('execute', {}, `execute-status-${i}`)
450
+ await h.before('carthagent_delivery_status', {}, `nested-status-${i}`)
451
+ await h.before('read', { path: 'app.ts' })
452
+ }
453
+ await h.before('execute', {}, 'execute-status-4')
454
+ await assert.rejects(h.before('carthagent_delivery_status', {}, 'nested-status-4'), /Repeated unchanged/)
455
+ writeFileSync(join(h.directory, 'app.ts'), 'export const app = () => 43')
456
+ await h.before('carthagent_delivery_status', {}, 'changed-status')
457
+ await cleanup()
458
+ })
459
+
460
+ test('budget commands report and reset without triggering a model turn', async t => {
461
+ const h = host(t)
462
+ const cleanup = await registerDelivery(h.ctx, { maxTools: 1 })
463
+ await h.before('read')
464
+ await assert.rejects(h.before('read'), /budget exhausted/)
465
+ await h.commands.get('delivery-budget-status').execute({ sessionID: 'ses-a' })
466
+ assert.match(h.synthetic[0].text, /tool-calls/)
467
+ await h.commands.get('delivery-budget-reset').execute({ sessionID: 'ses-a' })
468
+ assert.equal(h.stores.get('session/ses-a').budget.tools, 0)
469
+ assert.equal(h.stores.get('session/ses-a').budget.stopped, null)
470
+ assert.equal(h.synthetic.every(message => message.resume === false), true)
471
+ assert.deepEqual(h.prompts, [])
472
+ await h.before('read')
473
+ await cleanup()
474
+ })
475
+
476
+ test('strict approval, recovery and evidence survive a native plugin restart', async t => {
477
+ const h = host(t)
478
+ const first = await registerDelivery(h.ctx, { workflow: 'strict' })
479
+ await h.plan()
480
+ await h.call('delivery_design', { action: 'validate' })
481
+ const inspected = JSON.parse((await h.call('delivery_design', { action: 'inspect' })).content)
482
+ await h.call('delivery_design', { action: 'review', review: { ...fixtureReview(inspected.result.plan), id: inspected.captureId } })
483
+ await h.call('delivery_design', { action: 'approve' })
484
+ await first()
485
+ const second = await registerDelivery(h.ctx, { workflow: 'standard' })
486
+ await h.before('edit', { path: 'app.ts' })
487
+ await h.call('delivery_check', { id: 'all' })
488
+ const event = { sessionID: 'ses-a', agent: SHIP_AGENT_ID, system: [] }
489
+ await h.hooks.get('compaction')(event)
490
+ assert.match(systemText(event), /app returns 42/)
491
+ assert.match(systemText(event), /smoke/)
492
+ assert.equal(h.stores.get('session/ses-a').state.workflowMode, 'strict')
493
+ await second()
494
+ })
495
+
496
+ test('terminal native runs do not reinsert checkpoints during compaction or queue repair turns', async t => {
497
+ const h = host(t)
498
+ const cleanup = await registerDelivery(h.ctx, { workflow: 'standard' })
499
+ await h.plan()
500
+ await h.call('delivery_finish', { status: 'blocked', review: 'Cannot proceed.', launch: 'not run', limitations: ['External dependency unavailable.'] })
501
+ const event = { sessionID: 'ses-a', agent: SHIP_AGENT_ID, system: [] }
502
+ await h.hooks.get('compaction')(event)
503
+ assert.deepEqual(event.system, [])
504
+ h.sessions.get('ses-a').outcome = 'succeeded'
505
+ await h.idle()
506
+ assert.deepEqual(h.prompts, [])
507
+ await cleanup()
508
+ })
509
+
510
+
511
+ test('context archival retains original failures, mixed messages, and content when storage fails', t => {
512
+ const h = host(t)
513
+ const call = { role: 'assistant', content: [{ type: 'tool-call', id: 'archive-call', name: 'carthagent_delivery_check', input: { id: 'all' } }] }
514
+ const failed = { role: 'tool', content: [{ type: 'tool-result', id: 'archive-call', name: 'carthagent_delivery_check', result: { type: 'text', value: '{"passed":false,"outputTail":"failure"}' } }] }
515
+ const failure = [call, failed]
516
+ assert.deepEqual(archiveDeliveryExchanges(failure, { cwd: h.directory, directory: h.directory, allow: true, keepRecent: 0 }).messages, failure)
517
+ const mixed = [{ ...call, content: [...call.content, { type: 'text', text: 'Keep this reasoning.' }] }, { ...failed, content: [{ ...failed.content[0], result: { type: 'text', value: 'ok' } }] }]
518
+ assert.deepEqual(archiveDeliveryExchanges(mixed, { cwd: h.directory, directory: h.directory, allow: true, keepRecent: 0 }).messages, mixed)
519
+ const success = [call, { ...failed, content: [{ ...failed.content[0], result: { type: 'text', value: 'ok' } }] }]
520
+ const obstructed = join(h.directory, 'not-a-directory')
521
+ writeFileSync(obstructed, 'original file')
522
+ const retained = archiveDeliveryExchanges(success, { cwd: h.directory, directory: obstructed, allow: true, keepRecent: 0 })
523
+ assert.equal(retained.failed, true)
524
+ assert.deepEqual(retained.messages, success)
525
+ assert.equal(readFileSync(obstructed, 'utf8'), 'original file')
526
+ })
@@ -0,0 +1,85 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { runInNewContext } from 'node:vm';
7
+ import { createHash } from 'node:crypto';
8
+ import { CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, CTG_RUNTIME_PATCH_SHA256 } from '../lib/opencode/runtime.mjs';
9
+ import ts from 'typescript';
10
+ import { parseOpenCodeArgs } from '../lib/opencode/cli.mjs';
11
+ import { normalizeSessionOptions } from '../lib/opencode/session-options.mjs';
12
+
13
+ const patch = readFileSync(new URL('../docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch', import.meta.url), 'utf8');
14
+ const path = 'packages/tui/src/routes/session/plan-items.ts';
15
+ const section = patch.split(`diff --git a/${path} b/${path}\n`)[1]?.split('\ndiff --git ')[0];
16
+ assert.ok(section, 'the plan graph parser is part of the reproducible runtime patch');
17
+ const source = section.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).map(line => line.slice(1)).join('\n');
18
+ const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2023 } });
19
+ const exports = {};
20
+ runInNewContext(compiled.outputText, { exports });
21
+ const items = messages => JSON.parse(JSON.stringify(exports.planItems(messages)));
22
+ const snapshot = (status = 'pending', revision = 1) => ({ runId: 'run-a', revision, items: [{ id: 'implementation', content: 'Implement the contract', status }] });
23
+ const message = (name, deliveryPlan, status = 'completed') => ({ type: 'assistant', content: [{ type: 'tool', name, state: { status, metadata: { deliveryPlan } } }] });
24
+
25
+ test('the runtime plan graph parser typechecks against the pinned native client API', t => {
26
+ const directory = mkdtempSync(fileURLToPath(new URL('.native-plan-', import.meta.url)));
27
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
28
+ const sourcePath = join(directory, 'plan-items.ts');
29
+ writeFileSync(sourcePath, source);
30
+ const options = { noEmit: true, skipLibCheck: true, strict: true, target: ts.ScriptTarget.ES2023, module: ts.ModuleKind.ESNext, moduleResolution: ts.ModuleResolutionKind.Bundler };
31
+ const program = ts.createProgram([sourcePath], options);
32
+ const errors = ts.getPreEmitDiagnostics(program).map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'));
33
+ assert.deepEqual(errors, []);
34
+ });
35
+
36
+ test('direct and Code Mode graph entries use the latest server-authored snapshot', () => {
37
+ assert.equal(items([message('carthagent_delivery_plan', snapshot())])[0].status, 'pending');
38
+ assert.equal(items([message('carthagent_delivery_plan', snapshot()), message('execute', snapshot('active', 2))])[0].status, 'in_progress');
39
+ assert.equal(items([message('execute', snapshot('done'))])[0].status, 'completed');
40
+ assert.equal(items([message('execute', snapshot('failed'))])[0].status, 'failed');
41
+ });
42
+
43
+ test('failed and running tool calls cannot replace an accepted graph plan', () => {
44
+ const accepted = message('execute', snapshot('active'));
45
+ assert.equal(items([accepted, message('execute', snapshot('done'), 'error')])[0].status, 'in_progress');
46
+ assert.equal(items([accepted, message('execute', snapshot('done'), 'running')])[0].status, 'in_progress');
47
+ assert.deepEqual(items([{ type: 'assistant', content: [{ type: 'tool', name: 'todowrite', state: { status: 'completed', input: { todos: [{ content: 'not a contract' }] } } }] }]), []);
48
+ });
49
+
50
+ test('a new run replaces the graph rather than merging unrelated tasks', () => {
51
+ const next = { runId: 'run-b', revision: 1, items: [{ id: 'new-task', content: 'New task', status: 'pending' }] };
52
+ assert.deepEqual(items([message('execute', snapshot('done')), message('execute', next)]), [{ id: 'new-task', content: 'New task', status: 'pending' }]);
53
+ assert.deepEqual(items([message('execute', snapshot()), message('execute', { ...next, items: [] })]), []);
54
+ assert.deepEqual(items([message('execute', ['malformed'])]), []);
55
+ });
56
+
57
+ test('the runtime no longer owns a separate Ship agent or todowrite workflow', () => {
58
+ const additions = patch.split('\n').filter(line => line.startsWith('+') && !line.startsWith('+++')).join('\n');
59
+ assert.doesNotMatch(additions, /CarthagentPlugin|todowrite|PROMPT_SHIP/);
60
+ });
61
+
62
+ test('native CLI preserves bounded-run and advisor controls in session options', () => {
63
+ const parsed = parseOpenCodeArgs(['--protect-existing', '--rewrite-cap', '2', '--tool-output-cap', '4000', '--turn-delay-ms', '50', '--jev', '--jev-provider', 'ship', 'tui']);
64
+ const normalized = normalizeSessionOptions(parsed);
65
+ assert.deepEqual(normalized, { protectExisting: true, jev: true, rewriteCap: 2, toolOutputCap: 4000, turnDelayMs: 50, jevProvider: 'ship' });
66
+ assert.equal(parseOpenCodeArgs(['--no-jev', 'tui']).jev, false);
67
+ });
68
+
69
+
70
+ test('development runtime source and preparation recipe pin the actual Ship graph patch bytes', () => {
71
+ const digest = createHash('sha256').update(patch).digest('hex');
72
+ assert.equal(CTG_DEVELOPMENT_RUNTIME_PATCH_SHA256, digest);
73
+ const prepare = readFileSync(new URL('../script/ctg/prepare-development-runtime.sh', import.meta.url), 'utf8');
74
+ assert.ok(prepare.includes(`EXPECTED_PATCH="${digest}"`));
75
+ assert.ok(prepare.includes('docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch'));
76
+ assert.doesNotMatch(prepare, /bun (install|run|build)|gh release|npm publish/);
77
+ });
78
+
79
+ test('the shared release build recipe stays on the published patch', () => {
80
+ const published = readFileSync(new URL('../docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch', import.meta.url));
81
+ const digest = createHash('sha256').update(published).digest('hex');
82
+ assert.equal(CTG_RUNTIME_PATCH_SHA256, digest);
83
+ const build = readFileSync(new URL('../script/ctg/build-owned-runtime.sh', import.meta.url), 'utf8');
84
+ assert.ok(build.includes(`EXPECTED_PATCH="${digest}"`));
85
+ });