carthagent 1.0.7 → 1.1.0-beta.1

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 (294) 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/glyph-review/host-chrome-glyph-compatibility-v4.md +83 -0
  6. package/docs/glyph-review/test-isolation-v1.md +26 -0
  7. package/docs/glyph-review/typecheck-base.log +1 -0
  8. package/docs/glyph-review/typecheck-v4.log +1 -0
  9. package/docs/glyph-review/unicode-inventory-v4.csv +402 -0
  10. package/docs/help-dispatch-review.md +69 -0
  11. package/docs/launch-readiness-live-results.md +65 -0
  12. package/docs/legacy-cli.md +150 -0
  13. package/docs/live-theme-plugin-api.md +18 -0
  14. package/docs/native-cli.md +102 -0
  15. package/docs/native-compatibility.md +16 -0
  16. package/docs/native-daily-quests.md +132 -0
  17. package/docs/native-glyph-pty.md +59 -0
  18. package/docs/native-manual-testing.md +336 -0
  19. package/docs/native-slash-acceptance-handoff.md +49 -0
  20. package/docs/native-terminal-paired-failure-2026-10-17.md +40 -0
  21. package/docs/native-terminal-performance.md +310 -0
  22. package/docs/native-terminal-semantics-evidence/README.md +25 -0
  23. package/docs/native-terminal-semantics-evidence/controlled-host-model-selftest.json +16 -0
  24. package/docs/native-terminal-semantics-evidence/current-acceptance-status.json +59 -0
  25. package/docs/native-terminal-semantics-evidence/final-binary-focused-85bb44a.json +154 -0
  26. package/docs/native-terminal-semantics-evidence/final-binary-full-incomplete-85bb44a.json +160 -0
  27. package/docs/native-terminal-semantics-evidence/final-binary-layout-85bb44a.json +149 -0
  28. package/docs/native-terminal-semantics-evidence/final-binary-layout-interactive-85bb44a.json +159 -0
  29. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-escape-unchanged-85bb44a.json +44 -0
  30. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-exact-identity-85bb44a.json +47 -0
  31. package/docs/native-terminal-semantics-evidence/final-binary-model-picker-unchanged-model-incomplete-85bb44a.json +58 -0
  32. package/docs/native-terminal-semantics-evidence/final-binary-model-selftest.json +16 -0
  33. package/docs/native-terminal-semantics-evidence/final-binary-picker-cancel-incomplete-85bb44a.json +62 -0
  34. package/docs/native-terminal-semantics-evidence/focused-clipboard-title-stale-binary.json +161 -0
  35. package/docs/native-terminal-semantics-evidence/layout-stale-binary-observation-not-accepted.json +153 -0
  36. package/docs/native-terminal-semantics-evidence/picker-latest-incomplete-attempt.json +62 -0
  37. package/docs/native-terminal-semantics-evidence/picker-previous-probe-pass.json +61 -0
  38. package/docs/native-terminal-semantics.md +89 -0
  39. package/docs/native-upgrade.md +175 -0
  40. package/docs/opencode-composed-source-review.md +89 -0
  41. package/docs/opencode-migration.md +225 -9
  42. package/docs/opencode-owned-runtime.md +241 -0
  43. package/docs/opencode-runtime.md +12 -0
  44. package/docs/opencode-theme-provider-proof/README.md +55 -0
  45. package/docs/opencode-theme-provider-proof/live-theme-api-base.patch +122 -0
  46. package/docs/opencode-theme-provider-proof/plugin-theme-provider.patch +107 -0
  47. package/docs/opencode-theme-provider-proof/session-ui-base.patch +1217 -0
  48. package/docs/owned-host-controls-proof.md +39 -0
  49. package/docs/owned-runtime-release-manifest.md +42 -0
  50. package/docs/owned-visual-clear-proof.md +40 -0
  51. package/docs/patches/opencode-v2.0.32-glyph-spinner-test-isolation-v1.patch +50 -0
  52. package/docs/patches/opencode-v2.0.32-glyphs.patch +3670 -0
  53. package/docs/patches/opencode-v2.0.32-help-dispatch.patch +156 -0
  54. package/docs/patches/opencode-v2.0.32-live-theme-api.patch +122 -0
  55. package/docs/patches/opencode-v2.0.32-session-ui-combined-v1.patch +5272 -0
  56. package/docs/patches/opencode-v2.0.32-session-ui-combined-v2.patch +6052 -0
  57. package/docs/patches/opencode-v2.0.32-session-ui-combined-v3.patch +8657 -0
  58. package/docs/patches/opencode-v2.0.32-session-ui-combined-v4.patch +9928 -0
  59. package/docs/patches/opencode-v2.0.32-session-ui-combined-v5.patch +13965 -0
  60. package/docs/patches/opencode-v2.0.32-session-ui.patch +5137 -0
  61. package/docs/release-notes-beta.1.md +60 -0
  62. package/docs/release-notes-native-draft.md +73 -0
  63. package/docs/release-notes-preview.10.md +121 -0
  64. package/docs/release-notes-preview.11.md +76 -0
  65. package/docs/release-notes-preview.12.md +55 -0
  66. package/docs/release-notes-preview.13.md +68 -0
  67. package/docs/release-notes-preview.9.md +130 -0
  68. package/docs/source-preview.md +120 -0
  69. package/evaluate.mjs +28 -1
  70. package/lib/budget.mjs +1 -1
  71. package/lib/codegraph.mjs +1 -1
  72. package/lib/delivery-review.mjs +19 -6
  73. package/lib/delivery.mjs +3 -2
  74. package/lib/environment.mjs +8 -0
  75. package/lib/guidance.mjs +1 -1
  76. package/lib/opencode/cli-runtime.mjs +1171 -0
  77. package/lib/opencode/cli.mjs +252 -0
  78. package/lib/opencode/credentials.mjs +63 -8
  79. package/lib/opencode/demo.mjs +53 -0
  80. package/lib/opencode/evaluation-budget-plugin/index.ts +3 -0
  81. package/lib/opencode/evaluation.mjs +32 -12
  82. package/lib/opencode/headless.mjs +22 -5
  83. package/lib/opencode/provider-policy.mjs +23 -0
  84. package/lib/opencode/review.mjs +37 -16
  85. package/lib/opencode/runtime-artifact.mjs +335 -0
  86. package/lib/opencode/runtime-github-transport.mjs +121 -0
  87. package/lib/opencode/runtime-release.json +36 -0
  88. package/lib/opencode/runtime.mjs +299 -20
  89. package/lib/opencode/service.mjs +77 -35
  90. package/lib/opencode/session-options.mjs +6 -2
  91. package/lib/opencode/update.mjs +222 -0
  92. package/lib/opencode/workspace.mjs +1 -1
  93. package/lib/planning-advice.mjs +2 -2
  94. package/lib/providers/experiential.mjs +2 -2
  95. package/lib/tui/framing.mjs +1 -80
  96. package/lib/update.mjs +10 -1
  97. package/package.json +33 -7
  98. package/plugins/carthagent/README.md +23 -0
  99. package/plugins/carthagent/src/bootstrap.ts +1 -1
  100. package/plugins/carthagent/src/daily-evidence.mjs +57 -0
  101. package/plugins/carthagent/src/daily-quests-rpc.ts +50 -0
  102. package/plugins/carthagent/src/daily-source-rpc.ts +52 -0
  103. package/plugins/carthagent/src/index.ts +71 -24
  104. package/plugins/carthagent/src/rpc.ts +51 -6
  105. package/plugins/carthagent/src/server/codegraph.ts +12 -5
  106. package/plugins/carthagent/src/server/daily-quests-rpc.ts +277 -0
  107. package/plugins/carthagent/src/server/daily-source-rpc.ts +243 -0
  108. package/plugins/carthagent/src/server/delivery.ts +227 -112
  109. package/plugins/carthagent/src/server/framing.mjs +80 -0
  110. package/plugins/carthagent/src/server/policy.ts +1 -1
  111. package/plugins/carthagent/src/server/providers.ts +59 -31
  112. package/plugins/carthagent/src/server/session-controls.ts +3 -1
  113. package/plugins/carthagent/src/server/ship-agents.ts +77 -0
  114. package/plugins/carthagent/src/server/ship-context.mjs +60 -0
  115. package/plugins/carthagent/src/server/ship-guidance.ts +41 -0
  116. package/plugins/carthagent/src/server/ship-rpc.ts +69 -0
  117. package/plugins/carthagent/src/server/voyages-rpc.ts +280 -0
  118. package/plugins/carthagent/src/testing.ts +5 -0
  119. package/plugins/carthagent/src/tui.tsx +496 -0
  120. package/plugins/carthagent/src/ui/client.ts +127 -0
  121. package/plugins/carthagent/src/ui/commands.ts +70 -0
  122. package/plugins/carthagent/src/ui/daily-editor-focus.mjs +52 -0
  123. package/plugins/carthagent/src/ui/daily-editor.mjs +4 -0
  124. package/plugins/carthagent/src/ui/daily-quests-events.mjs +18 -0
  125. package/plugins/carthagent/src/ui/daily-quests-state.mjs +218 -0
  126. package/plugins/carthagent/src/ui/daily-quests.tsx +235 -0
  127. package/plugins/carthagent/src/ui/host-state.ts +42 -0
  128. package/plugins/carthagent/src/ui/model-command.ts +32 -0
  129. package/plugins/carthagent/src/ui/native-navigation.ts +23 -0
  130. package/plugins/carthagent/src/ui/native-review.d.mts +33 -0
  131. package/plugins/carthagent/src/ui/native-review.mjs +159 -0
  132. package/plugins/carthagent/src/ui/resume-session.mjs +34 -0
  133. package/plugins/carthagent/src/ui/select-mouse.ts +17 -0
  134. package/plugins/carthagent/src/ui/session-preferences.d.mts +7 -0
  135. package/plugins/carthagent/src/ui/session-preferences.mjs +14 -0
  136. package/plugins/carthagent/src/ui/theme-provision.d.mts +3 -0
  137. package/plugins/carthagent/src/ui/theme-provision.mjs +134 -0
  138. package/plugins/carthagent/src/ui/voyages-navigation-state.mjs +56 -0
  139. package/plugins/carthagent/src/ui/voyages-navigation.tsx +131 -0
  140. package/plugins/carthagent/src/ui/voyages-state.mjs +165 -0
  141. package/plugins/carthagent/src/ui/voyages.tsx +131 -0
  142. package/plugins/carthagent/src/ui/workspace-live.ts +61 -0
  143. package/plugins/carthagent/src/ui/workspace.tsx +379 -0
  144. package/plugins/carthagent/src/voyages-rpc.ts +64 -0
  145. package/plugins/carthagent/src/workspace-rpc.ts +36 -0
  146. package/plugins/carthagent/tests/commands.test.ts +107 -0
  147. package/plugins/carthagent/tests/session-preferences.test.mjs +41 -0
  148. package/plugins/carthagent/tests/voyages-navigation.test.tsx +269 -0
  149. package/plugins/carthagent/themes/carthage.json +67 -0
  150. package/plugins/carthagent/tui.ts +26 -1
  151. package/script/ctg/build-owned-runtime.sh +166 -0
  152. package/tests/budget-engine.test.mjs +4 -3
  153. package/tests/cli.test.mjs +52 -96
  154. package/tests/daily-evidence.test.mjs +62 -0
  155. package/tests/delivery-review.test.mjs +57 -2
  156. package/tests/delivery.test.mjs +22 -1
  157. package/tests/fixtures/native-discovery-plugin/server.ts +15 -0
  158. package/tests/generate-owned-runtime-release.test.mjs +354 -0
  159. package/tests/installed-runtime-contract.test.mjs +43 -0
  160. package/tests/native-cli-host-smoke.mjs +457 -0
  161. package/tests/native-daily-editor-focus.test.mjs +213 -0
  162. package/tests/native-daily-editor.test.mjs +43 -0
  163. package/tests/native-daily-quests-layout.test.mjs +55 -0
  164. package/tests/native-daily-quests-rpc.test.mjs +456 -0
  165. package/tests/native-daily-quests-ui.test.mjs +778 -0
  166. package/tests/native-daily-source.test.mjs +525 -0
  167. package/tests/native-demo-ui.test.mjs +48 -0
  168. package/tests/native-evaluation-admission.test.mjs +24 -0
  169. package/tests/native-fixture-browser-cleanup.test.mjs +65 -0
  170. package/tests/native-fixture-runtime.test.mjs +111 -0
  171. package/tests/native-fixture-stop-diagnostics.test.mjs +67 -0
  172. package/tests/native-glyph-pty.test.mjs +107 -0
  173. package/tests/native-manual-projects.test.mjs +170 -0
  174. package/tests/native-model-command.test.mjs +67 -0
  175. package/tests/native-owned-runtime-env.test.mjs +140 -0
  176. package/tests/native-paired-guards.test.mjs +464 -0
  177. package/tests/native-provider-policy.test.mjs +31 -0
  178. package/tests/native-pty-invocation.test.mjs +114 -0
  179. package/tests/native-pty-runtime.test.mjs +45 -0
  180. package/tests/native-resume-session.test.mjs +46 -0
  181. package/tests/native-review-consumer.test.mjs +286 -0
  182. package/tests/native-runtime-identity.test.mjs +214 -0
  183. package/tests/native-select-mouse.test.mjs +23 -0
  184. package/tests/native-ship-guidance.test.mjs +16 -0
  185. package/tests/native-ship-parity.test.mjs +526 -0
  186. package/tests/native-ship-plan.test.mjs +85 -0
  187. package/tests/native-ship-rpc.test.mjs +71 -0
  188. package/tests/native-ui-commands.test.mjs +6 -0
  189. package/tests/native-voyages-layout.test.mjs +28 -0
  190. package/tests/native-voyages-navigation.test.mjs +169 -0
  191. package/tests/native-voyages-rpc.test.mjs +397 -0
  192. package/tests/native-voyages-ui.test.mjs +307 -0
  193. package/tests/opencode-atomic-write.test.mjs +55 -0
  194. package/tests/opencode-cli.test.mjs +1312 -0
  195. package/tests/opencode-codegraph-plugin.test.mjs +47 -0
  196. package/tests/opencode-delivery.test.mjs +16 -0
  197. package/tests/opencode-providers.test.mjs +245 -18
  198. package/tests/opencode-runtime.test.mjs +597 -8
  199. package/tests/opencode-session-controls.test.mjs +15 -0
  200. package/tests/opencode-ship-rpc.test.mjs +71 -0
  201. package/tests/opencode-update.test.mjs +475 -0
  202. package/tests/opencode-workspace.test.mjs +51 -2
  203. package/tests/owned-runtime-publish-workflow.test.mjs +477 -0
  204. package/tests/release-workflow.test.mjs +46 -0
  205. package/tests/runtime-artifact.test.mjs +269 -0
  206. package/tests/runtime-development-trust.test.mjs +231 -0
  207. package/tests/runtime-github-transport.test.mjs +132 -0
  208. package/tests/runtime-token-env.test.mjs +23 -0
  209. package/tests/server.test.mjs +3 -1
  210. package/tests/ship-telemetry.test.mjs +1 -1
  211. package/tests/source-preview.test.mjs +253 -0
  212. package/tests/test_native_terminal_probe.py +76 -0
  213. package/tests/test_native_terminal_semantics.py +232 -0
  214. package/tests/update.test.mjs +42 -5
  215. package/tests/verify-draft-runtime-gate.test.mjs +280 -0
  216. package/tests/verify-owned-runtime-release.test.mjs +371 -0
  217. package/tests/workspace-live.test.mjs +127 -0
  218. package/tools/benchmark/diagnose-request.mjs +59 -36
  219. package/tools/benchmark/eval-common.mjs +3 -3
  220. package/tools/benchmark/evaluators.test.mjs +4 -4
  221. package/tools/benchmark/guard.test.mjs +35 -2
  222. package/tools/benchmark/guard.ts +20 -7
  223. package/tools/benchmark/planning-startup.test.mjs +30 -34
  224. package/tools/benchmark/runner.mjs +12 -7
  225. package/tools/benchmark/runner.test.mjs +23 -7
  226. package/tools/benchmark/upstream-error.test.mjs +16 -2
  227. package/tools/generate-owned-runtime-release.mjs +217 -0
  228. package/tools/installed-runtime-contract.mjs +16 -0
  229. package/tools/manual-runtime-manifest.mjs +39 -0
  230. package/tools/native-fixture-browser-cleanup.mjs +59 -0
  231. package/tools/native-fixture-stop-diagnostics.mjs +43 -0
  232. package/tools/native-fixture.mjs +199 -40
  233. package/tools/native-glyph-helpers.py +127 -0
  234. package/tools/native-glyph-pty.py +144 -0
  235. package/tools/native-manual-projects.mjs +259 -0
  236. package/tools/native-manual.mjs +338 -0
  237. package/tools/native-model-picker-acceptance.py +306 -0
  238. package/tools/native-owned-runtime-env.sh +47 -0
  239. package/tools/native-paired-guards.mjs +282 -0
  240. package/tools/native-paired-manifest.mjs +72 -0
  241. package/tools/native-paired-orchestration.sh +51 -0
  242. package/tools/native-plugin-readiness.mjs +33 -0
  243. package/tools/native-plugin-readiness.test.mjs +53 -0
  244. package/tools/native-pty-invocation.mjs +26 -0
  245. package/tools/native-pty-runtime.mjs +83 -0
  246. package/tools/native-pty-smoke.py +147 -24
  247. package/tools/native-public-repo-shell-result.mjs +13 -0
  248. package/tools/native-public-repo-shell-result.test.mjs +28 -0
  249. package/tools/native-runtime-identity.mjs +201 -0
  250. package/tools/native-slash-acceptance-env.py +38 -0
  251. package/tools/native-terminal-semantics.py +876 -0
  252. package/tools/owned-shell-observer/index.ts +16 -0
  253. package/tools/owned-shell-observer/package.json +1 -0
  254. package/tools/owned-visual-clear-env.py +48 -0
  255. package/tools/owned-visual-clear-model.py +21 -0
  256. package/tools/pinned-owned-runtime-inputs.mjs +46 -0
  257. package/tools/prepare-native-manual.mjs +122 -0
  258. package/tools/run-native-terminal-paired.sh +403 -0
  259. package/tools/runtime-token-env.mjs +8 -0
  260. package/tools/seed-native-switch-fixture.mjs +79 -0
  261. package/tools/test-native-controls.mjs +3 -1
  262. package/tools/test-native-daily-quests.mjs +65 -0
  263. package/tools/test-native-default-cli.mjs +106 -0
  264. package/tools/test-native-delivery-host.mjs +102 -20
  265. package/tools/test-native-evaluation.mjs +7 -3
  266. package/tools/test-native-glyph-pty.mjs +94 -0
  267. package/tools/test-native-host.mjs +87 -63
  268. package/tools/test-native-outcomes.mjs +3 -1
  269. package/tools/test-native-package.mjs +206 -38
  270. package/tools/test-native-performance.mjs +26 -0
  271. package/tools/test-native-product.mjs +90 -0
  272. package/tools/test-native-pty.mjs +34 -9
  273. package/tools/test-native-public-repo.mjs +224 -0
  274. package/tools/test-native-refresh-reentry.mjs +106 -0
  275. package/tools/test-native-report.mjs +27 -20
  276. package/tools/test-native-review.mjs +75 -0
  277. package/tools/test-native-runtime.mjs +120 -0
  278. package/tools/test-native-ship-models.mjs +176 -0
  279. package/tools/test-native-ship-walkthrough.mjs +96 -0
  280. package/tools/test-native-slash-commands.mjs +125 -0
  281. package/tools/test-native-slash-commands.py +242 -0
  282. package/tools/test-native-swe-host.mjs +224 -55
  283. package/tools/test-native-tasks.mjs +77 -0
  284. package/tools/test-native-terminal-performance.py +887 -0
  285. package/tools/test-native-terminal-readiness.sh +85 -0
  286. package/tools/test-native-terminal-semantics-installed.mjs +38 -0
  287. package/tools/test-native-visual-clear-installed.mjs +48 -0
  288. package/tools/test-owned-host-controls.mjs +238 -0
  289. package/tools/test-owned-opencode-runtime.mjs +35 -0
  290. package/tools/test-owned-visual-clear-env.py +120 -0
  291. package/tools/test-owned-visual-clear.py +373 -0
  292. package/tools/verify-draft-runtime-gate.mjs +138 -0
  293. package/tools/verify-owned-runtime-release.mjs +114 -0
  294. package/tools/verify-release-version.mjs +21 -1
@@ -1,8 +1,8 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, chmodSync, copyFileSync } from 'node:fs';
3
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
- import { dirname, join, resolve, delimiter } from 'node:path';
5
+ import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { approveFixture } from './helpers/tested-design.mjs';
@@ -23,6 +23,31 @@ function run(cwd, ...args) {
23
23
  return spawnSync(process.execPath, [cli, ...args], { cwd, encoding: 'utf8' });
24
24
  }
25
25
 
26
+ test('default CLI and its native alias always use the native console, even with the old opt-out variable', t => {
27
+ const cwd = fixture(t);
28
+ for (const prefix of [[], ['native']]) {
29
+ const result = spawnSync(process.execPath, [cli, ...prefix, '--help'], { cwd, encoding: 'utf8', env: { ...process.env, CTG_NATIVE: '0' } });
30
+ assert.equal(result.status, 0, result.stderr);
31
+ assert.match(result.stdout, /Launch the native Carthagent delivery console/);
32
+ assert.doesNotMatch(result.stdout, /split-terminal|opt-in native|live plan\.d2 side panel/);
33
+ assert.match(result.stdout, /ctg service stop/);
34
+ }
35
+ });
36
+
37
+ test('native runtime selection never falls back to a legacy engine override', t => {
38
+ const cwd = fixture(t);
39
+ const missing = join(cwd, 'missing-native-runtime');
40
+ const fallback = join(cwd, 'legacy.mjs');
41
+ const marker = join(cwd, 'legacy-started');
42
+ writeFileSync(fallback, `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'unexpected');`);
43
+ const result = spawnSync(process.execPath, [cli, '-p', '--engine-cli', missing, '--no-delivery', '--max-tools', '1', 'Task'], {
44
+ cwd, encoding: 'utf8', timeout: 10000, env: { ...process.env, CTG_NATIVE: '0', CARTHAGENT_CLI: fallback },
45
+ });
46
+ assert.equal(result.status, 1, result.stderr);
47
+ assert.match(result.stderr, /missing-native-runtime/);
48
+ assert.throws(() => readFileSync(marker), { code: 'ENOENT' });
49
+ });
50
+
26
51
  test('status reads the newest persisted extension report', t => {
27
52
  const cwd = fixture(t);
28
53
  createReport(cwd, {
@@ -86,13 +111,17 @@ test('guidance routing is not a user-facing command and Ship account commands ar
86
111
  assert.match(result.stdout, /logout-cloud/);
87
112
  });
88
113
 
89
- test('Cloud account commands fail closed when no browser session is stored', t => {
114
+ test('native account commands fail closed instead of consuming legacy engine credentials', t => {
90
115
  const cwd = fixture(t);
91
- const env = { ...process.env, CARTHAGENT_CODING_AGENT_DIR: join(cwd, 'agent'), PI_CODING_AGENT_DIR: join(cwd, 'agent') };
116
+ const agent = join(cwd, 'legacy-agent');
117
+ mkdirSync(agent);
118
+ writeFileSync(join(agent, 'auth.json'), JSON.stringify({ 'experiential-labs': { type: 'api_key', key: 'legacy-fixture-credential' } }));
119
+ const env = { ...process.env, CTG_OPENCODE_BIN: join(cwd, 'missing-native-runtime'), CARTHAGENT_CODING_AGENT_DIR: agent, PI_CODING_AGENT_DIR: agent };
92
120
  for (const args of [['account'], ['billing'], ['logout-cloud']]) {
93
121
  const result = spawnSync(process.execPath, [cli, ...args], { cwd, env, encoding: 'utf8' });
94
122
  assert.equal(result.status, 1, result.stderr);
95
- assert.match(result.stderr, /not signed in/);
123
+ assert.match(result.stderr, /missing-native-runtime/);
124
+ assert.doesNotMatch(result.stdout + result.stderr, /legacy-fixture-credential/);
96
125
  }
97
126
  });
98
127
 
@@ -107,69 +136,12 @@ test('review shows and persists the self-review default', t => {
107
136
  assert.equal(runEnv('review', 'bogus').status, 2);
108
137
  });
109
138
 
110
- /** Fake `gh` + fake engine CLI so `ctg review <pr>` runs end-to-end offline. */
111
- function reviewFixture(t, verdictLine) {
139
+ test('native review rejects invalid targets and bounds before resolving a host', t => {
112
140
  const cwd = fixture(t);
113
- const bin = join(cwd, 'bin');
114
- mkdirSync(join(cwd, 'bin'), { recursive: true });
115
- const gh = join(bin, 'gh');
116
- writeFileSync(gh, `#!/bin/sh
117
- if [ "$1" = "pr" ] && [ "$2" = "view" ]; then echo '{"title":"Fixture PR","url":"https://example.test/pr/14","headRefName":"feat","baseRefName":"main","headRefOid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'; exit 0; fi
118
- exit 0
119
- `);
120
- chmodSync(gh, 0o755);
121
- // Native executable fixture: Windows cannot execute a POSIX shebang shim.
122
- if (process.platform === 'win32') {
123
- copyFileSync(process.execPath, join(bin, 'gh.exe'));
124
- writeFileSync(join(cwd, 'gh-preload.cjs'), `if (require('node:path').basename(process.execPath).toLowerCase() === 'gh.exe') { console.log(JSON.stringify({title:'Fixture PR',url:'https://example.test/pr/14',headRefName:'feat',baseRefName:'main',headRefOid:'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'})); process.exit(0); }`);
125
- }
126
- const reviewer = join(cwd, 'reviewer.mjs');
127
- writeFileSync(reviewer, `const prompt = process.argv.at(-1); const snapshot = /snapshot:"([a-f0-9]+)"/.exec(prompt)?.[1]; const head = /head:"([a-f0-9]+)"/.exec(prompt)?.[1]; console.log('fixture findings'); console.log('REVIEW_JSON: ' + JSON.stringify({version:1,snapshot,head,findings:${JSON.stringify(verdictLine.includes('CHANGES-REQUESTED') ? [{file:'app.mjs',line:1,severity:'blocking',issue:'fixture issue',fix:'fixture fix'}] : [])}})); console.log('${verdictLine}');\n`);
128
- const env = { ...process.env, CARTHAGENT_CONFIG: join(cwd, 'config.json'), CARTHAGENT_CLI: reviewer, PATH: `${bin}${delimiter}${process.env.PATH}` };
129
- if (process.platform === 'win32') env.NODE_OPTIONS = `${env.NODE_OPTIONS || ''} --require ${JSON.stringify(join(cwd, 'gh-preload.cjs'))}`;
130
- return { cwd, env };
131
- }
132
-
133
- test('ctg review <pr> exits 0 when the fresh reviewer approves', t => {
134
- const { cwd, env } = reviewFixture(t, 'VERDICT: APPROVE');
135
- const result = spawnSync(process.execPath, [cli, 'review', '14'], { cwd, env, encoding: 'utf8' });
136
- assert.equal(result.status, 0, result.stderr);
137
- assert.match(result.stdout, /fixture findings/);
138
- assert.match(result.stdout, /VERDICT: APPROVE/);
139
- });
140
-
141
- test('ctg review <pr> exits 1 on requested changes and 2 without a verdict', t => {
142
- const { cwd, env } = reviewFixture(t, 'VERDICT: CHANGES-REQUESTED');
143
- const result = spawnSync(process.execPath, [cli, 'review', '14'], { cwd, env, encoding: 'utf8' });
144
- assert.equal(result.status, 1, result.stderr);
145
- const silent = reviewFixture(t, 'analysis but no verdict line');
146
- const bad = spawnSync(process.execPath, [cli, 'review', '14'], { cwd: silent.cwd, env: silent.env, encoding: 'utf8' });
147
- assert.equal(bad.status, 2);
148
- assert.match(bad.stderr, /no VERDICT/);
149
- });
150
-
151
- test('review persists admission limits across fresh CLI processes', t => {
152
- const { cwd, env } = reviewFixture(t, 'VERDICT: CHANGES-REQUESTED');
153
- for (let i = 0; i < 3; i++) {
154
- const result = spawnSync(process.execPath, [cli, 'review', '14'], { cwd, env, encoding: 'utf8' });
155
- assert.equal(result.status, 1, result.stderr);
156
- }
157
- const exhausted = spawnSync(process.execPath, [cli, 'review', '14'], { cwd, env, encoding: 'utf8' });
158
- assert.equal(exhausted.status, 2);
159
- assert.match(exhausted.stderr, /round limit/);
160
- assert.doesNotMatch(exhausted.stdout, /fixture findings/);
161
- });
162
-
163
- test('review rejects source mutation and invalid execution bounds', t => {
164
- const { cwd, env } = reviewFixture(t, 'VERDICT: APPROVE');
165
- writeFileSync(join(cwd, 'reviewer.mjs'), `import {writeFileSync} from 'node:fs'; writeFileSync('changed.txt', 'café'); console.log('VERDICT: APPROVE');`);
166
- const changed = spawnSync(process.execPath, [cli, 'review', '14'], { cwd, env, encoding: 'utf8' });
167
- assert.equal(changed.status, 2);
168
- assert.match(changed.stderr, /snapshot changed/);
169
- for (const value of ['0', '301', 'Infinity', 'invalid']) {
170
- const invalid = spawnSync(process.execPath, [cli, 'review', '14', '--timeout', value], { cwd, env, encoding: 'utf8' });
171
- assert.equal(invalid.status, 2);
172
- assert.match(invalid.stderr, /integer from 1 to 300/);
141
+ for (const args of [['review', 'bogus'], ['review', '14', '--timeout', '0'], ['review', '14', '--timeout', '301'], ['review', '14', '--timeout', 'Infinity'], ['review', '14', '--timeout', 'invalid']]) {
142
+ const result = run(cwd, ...args);
143
+ assert.equal(result.status, 2, result.stderr);
144
+ assert.match(result.stderr, /Invalid review target|integer from 1 to 300/);
173
145
  }
174
146
  });
175
147
 
@@ -194,33 +166,17 @@ test('review modes launch the real bundled engine without unknown flags', t => {
194
166
  }
195
167
  });
196
168
 
197
- test('ctg --list-models renders stored identity-only Cloud metadata as unknown', t => {
169
+ test('native model listing cannot silently fall back to legacy cached model metadata', t => {
198
170
  const cwd = fixture(t);
199
- const agentDir = join(cwd, 'agent');
200
- mkdirSync(agentDir, { recursive: true });
201
- writeFileSync(join(agentDir, 'auth.json'), JSON.stringify({
202
- 'experiential-labs': { type: 'api_key', key: 'fixture-credential' },
203
- }));
204
- writeFileSync(join(agentDir, 'models-store.json'), JSON.stringify({
205
- 'experiential-labs': {
206
- models: [{ id: 'carthagent-code', name: 'carthagent-code' }],
207
- checkedAt: 1,
208
- },
209
- }));
210
- const result = spawnSync(process.execPath, [cli, '--list-models', 'Carthagent Ship'], {
211
- cwd,
212
- encoding: 'utf8',
213
- env: {
214
- ...process.env,
215
- PI_OFFLINE: '1',
216
- CARTHAGENT_CODING_AGENT_DIR: agentDir,
217
- PI_CODING_AGENT_DIR: agentDir,
218
- },
219
- });
220
- assert.equal(result.status, 0, result.stderr);
221
- assert.match(result.stdout, /Carthagent Ship\s+carthagent-code\s+\?\s+\?\s+\?\s+\?/);
222
- assert.doesNotMatch(result.stdout, /experiential/i);
223
- assert.doesNotMatch(result.stderr, /Cannot read properties|Failed to load extension/);
171
+ const agent = join(cwd, 'legacy-agent');
172
+ mkdirSync(agent);
173
+ writeFileSync(join(agent, 'models-store.json'), JSON.stringify({ 'experiential-labs': { models: [{ id: 'legacy-only-model' }], checkedAt: 1 } }));
174
+ const result = spawnSync(process.execPath, [cli, '--list-models'], { cwd, encoding: 'utf8', env: {
175
+ ...process.env, CTG_OPENCODE_BIN: join(cwd, 'missing-native-runtime'), CARTHAGENT_CODING_AGENT_DIR: agent, PI_CODING_AGENT_DIR: agent,
176
+ } });
177
+ assert.equal(result.status, 1, result.stderr);
178
+ assert.match(result.stderr, /missing-native-runtime/);
179
+ assert.doesNotMatch(result.stdout, /legacy-only-model/);
224
180
  });
225
181
 
226
182
  test('vendored agent engine is bundled and loads ModelRuntime cleanly', async () => {
@@ -235,11 +191,11 @@ test('vendored agent engine is bundled and loads ModelRuntime cleanly', async ()
235
191
  assert.ok(providers.size >= 35);
236
192
  });
237
193
 
238
- test('CLI rejects invalid, missing and disabled-extension budget options', t => {
194
+ test('native CLI rejects invalid and missing budget options', t => {
239
195
  const cwd = fixture(t);
240
- for (const args of [['--max-tools'], ['--max-seconds', 'NaN', 'task'], ['--no-delivery', '--max-tools', '1', 'task']]) {
196
+ for (const args of [['--max-tools'], ['--max-seconds', 'NaN', 'task'], ['--no-delivery', '--max-tools', '-1', 'task']]) {
241
197
  const result = spawnSync(process.execPath, [cli, '-p', ...args], { cwd, encoding: 'utf8', timeout: 10000 });
242
198
  assert.equal(result.status, 1, result.stderr);
243
- assert.match(result.stderr, /Missing value|must be an integer|require the delivery/);
199
+ assert.match(result.stderr, /Missing value|must be an integer|non-negative/);
244
200
  }
245
201
  });
@@ -0,0 +1,62 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { canonicalDailyEvidence, dailyRequest } from '../plugins/carthagent/src/daily-evidence.mjs';
4
+
5
+ const ID = 'ABCDEF01-2345-6789-ABCD-EF0123456789';
6
+ const REV = 'A'.repeat(40);
7
+ const evidence = () => ({ projectId: ID, outcomeId: ID, runId: ID, revision: REV, goal: ' A goal\r\nline ', change: 'A change', checks: [{ name: 'test', before: 'failed', after: 'passed', revision: REV, result: 'Observed passing check' }], findings: [{ issue: 'Issue', evidence: 'Observation', resolution: 'Resolved' }] });
8
+ const invalid = value => assert.throws(() => canonicalDailyEvidence(value), { message: 'invalid_daily_evidence' });
9
+ test('canonical evidence has backend field ordering, lowercase identifiers, LF and trim; preserves astral pairs', () => {
10
+ const e = evidence(); e.change = '\r valid \u{1f680} \r';
11
+ const c = canonicalDailyEvidence(e);
12
+ assert.equal(c.goal, 'A goal\nline'); assert.equal(c.change, 'valid \u{1f680}');
13
+ assert.equal(c.projectId, ID.toLowerCase()); assert.equal(c.revision, REV.toLowerCase());
14
+ assert.deepEqual(Object.keys(c), ['projectId', 'outcomeId', 'runId', 'revision', 'goal', 'change', 'checks', 'findings']);
15
+ assert.deepEqual(canonicalDailyEvidence(c), c);
16
+ assert.deepEqual(dailyRequest(ID, e), { id: ID.toLowerCase(), consent: true, evidence: c });
17
+ assert.throws(() => dailyRequest('bad-id', e), { message: 'invalid_daily_evidence' });
18
+ });
19
+ test('exact fields, plain data records, dense arrays, limits and enums fail closed', () => {
20
+ for (const mutate of [
21
+ e => { e.extra = true; }, e => { delete e.runId; }, e => { e.checks[0].extra = true; }, e => { delete e.findings[0].issue; },
22
+ e => { e.projectId = 'not-a-uuid'; }, e => { e.revision = 'a'.repeat(41); }, e => { e.checks[0].after = 'ok'; },
23
+ e => { e.checks[0].before = 'passed'; }, e => { e.checks[0].revision = 'b'.repeat(40); },
24
+ e => { e.checks = Array(1); }, e => { e.findings = Array(1); }, e => { e.checks.extra = true; },
25
+ e => { e.checks = Array.from({ length: 13 }, () => e.checks[0]); }, e => { e.findings = Array.from({ length: 6 }, () => e.findings[0]); },
26
+ e => { Object.defineProperty(e, 'goal', { get() { throw new Error('must not read getter'); }, enumerable: true }); },
27
+ e => { e.checks = [Object.create(e.checks[0])]; }, e => { e[Symbol('extra')] = true; },
28
+ ]) { const e = evidence(); mutate(e); invalid(e); }
29
+ const e = evidence(); e.checks[0].after = 'failed'; e.checks[0].revision = 'b'.repeat(64);
30
+ assert.equal(canonicalDailyEvidence(e).checks[0].revision.length, 64);
31
+ });
32
+ test('rejects controls and lone surrogates before normalization and bounds UTF8, not character count', () => {
33
+ for (const value of ['\u0000', '\u000b', '\u001f', '\u007f', '\u0085', '\ud800', '\udfff', 'x\ud800y']) {
34
+ const e = evidence(); e.goal = value; invalid(e);
35
+ }
36
+ for (const [field, limit] of [['goal', 1024], ['change', 3072]]) {
37
+ const e = evidence(); e[field] = ' '.repeat(limit + 1); invalid(e);
38
+ e[field] = 'é'.repeat(limit / 2); assert.equal(canonicalDailyEvidence(e)[field], e[field]);
39
+ e[field] += 'a'; invalid(e);
40
+ }
41
+ for (const [collection, index, field, limit] of [['checks', 0, 'name', 160], ['checks', 0, 'result', 1024], ['findings', 0, 'issue', 768], ['findings', 0, 'evidence', 1536], ['findings', 0, 'resolution', 1024]]) {
42
+ const e = evidence(); e[collection][index][field] = ' '.repeat(limit + 1); invalid(e);
43
+ }
44
+ });
45
+ test('canonical aggregate evidence limit is exact, includes escaping, request is bounded', () => {
46
+ const e = evidence(); e.goal = 'g'.repeat(1024); e.change = 'c'.repeat(3072);
47
+ e.checks = Array.from({ length: 7 }, () => ({ name: 'n', before: 'failed', after: 'passed', revision: REV, result: 'r'.repeat(1024) }));
48
+ e.findings = [];
49
+ while (Buffer.byteLength(JSON.stringify(canonicalDailyEvidence({ ...e, checks: e.checks.slice(0, -1) }))) > 12288) e.checks.pop();
50
+ const base = { ...e, checks: e.checks.slice(0, -1) };
51
+ let last = canonicalDailyEvidence(base);
52
+ // Fill another bounded field to find the precise canonical edge.
53
+ base.findings = [{ issue: '', evidence: '', resolution: '' }];
54
+ for (let n = 0; n <= 1536; n++) {
55
+ base.findings[0].evidence = 'x'.repeat(n);
56
+ try { last = canonicalDailyEvidence(base); } catch {
57
+ assert.equal(Buffer.byteLength(JSON.stringify(last)), 12288); invalid(base);
58
+ assert.ok(Buffer.byteLength(JSON.stringify(dailyRequest(ID, last))) <= 16384); return;
59
+ }
60
+ }
61
+ assert.fail('fixture must reach evidence byte boundary');
62
+ });
@@ -115,7 +115,7 @@ test('review rejects modified receipts, stale coverage, and cancellation', async
115
115
  await assert.rejects(captureDeliveryReview(state, cwd, dir, AbortSignal.abort()), /cancelled/);
116
116
  });
117
117
 
118
- test('review accepts filesystem aliases of the Git root but rejects ancestor repositories', async t => {
118
+ test('review accepts filesystem aliases of the Git root and nested workspaces', async t => {
119
119
  const { cwd, state } = fixture(t);
120
120
  const parent = mkdtempSync(join(tmpdir(), 'review-alias-'));
121
121
  t.after(() => rmSync(parent, { recursive: true, force: true }));
@@ -126,7 +126,51 @@ test('review accepts filesystem aliases of the Git root but rejects ancestor rep
126
126
  assert.match(capture.diff, /diff --git/);
127
127
  const child = join(cwd, 'nested');
128
128
  mkdirSync(child);
129
- await assert.rejects(captureDeliveryReview(state, child, join(child, '.harness')), /workspace Git root/);
129
+ writeFileSync(join(child, 'new.txt'), 'nested source');
130
+ const nested = await captureDeliveryReview(state, child, join(child, '.harness'));
131
+ assert.match(nested.diff, /nested source/);
132
+ assert.doesNotMatch(nested.diff, /export const amount|export const result/);
133
+ });
134
+
135
+ test('nested review scopes tracked, staged, deleted and new files and retains freshness', async t => {
136
+ const { cwd: root, state, params } = fixture(t);
137
+ const cwd = join(root, 'app café [one]');
138
+ mkdirSync(cwd);
139
+ const git = args => execFileSync('git', args, { cwd: root, stdio: 'pipe' });
140
+ for (const name of ['changed.txt', 'deleted.txt', 'moved.txt']) writeFileSync(join(cwd, name), 'baseline\n');
141
+ git(['add', '.']);
142
+ git(['-c', 'user.name=Test', '-c', 'user.email=test@localhost', 'commit', '-qm', 'Nested baseline']);
143
+ writeFileSync(join(root, 'app.mjs'), 'OUTSIDE_TRACKED_SECRET');
144
+ writeFileSync(join(root, 'outside.txt'), 'OUTSIDE_NEW_SECRET');
145
+ writeFileSync(join(cwd, 'changed.txt'), 'inside staged change\n');
146
+ git(['add', '.']);
147
+ git(['mv', 'app café [one]/moved.txt', 'outside-moved.txt']);
148
+ rmSync(join(cwd, 'deleted.txt'));
149
+ writeFileSync(join(cwd, 'new café.txt'), 'inside new source');
150
+ for (const name of ['node_modules', '.harness', 'artifacts']) {
151
+ mkdirSync(join(cwd, name), { recursive: true });
152
+ writeFileSync(join(cwd, name, 'noise.txt'), 'EXCLUDED_SECRET');
153
+ }
154
+ const capture = await captureDeliveryReview(state, cwd, join(cwd, '.harness', 'review'));
155
+ assert.match(capture.diff, /inside staged change/);
156
+ assert.match(capture.diff, /deleted file mode/);
157
+ assert.match(capture.diff, /inside new source/);
158
+ assert.match(capture.diff, /a\/moved.txt/);
159
+ assert.doesNotMatch(capture.diff, /OUTSIDE_|outside-moved|EXCLUDED_SECRET/);
160
+ state.evidence.unit.fingerprint = fingerprint(cwd, ['.']);
161
+ recordDeliveryReview(state, cwd, params(capture.id));
162
+ writeFileSync(join(root, 'outside.txt'), 'outside changes do not stale app review');
163
+ recordDeliveryReview(state, cwd, params(capture.id));
164
+ writeFileSync(join(cwd, 'new café.txt'), 'changed after review');
165
+ assert.throws(() => recordDeliveryReview(state, cwd, params(capture.id)), /stale/);
166
+ });
167
+
168
+ test('nested review rejects untracked symlinks escaping the workspace', async t => {
169
+ const { cwd: root, state } = fixture(t);
170
+ const cwd = join(root, 'nested');
171
+ mkdirSync(cwd);
172
+ symlinkSync(join(root, 'app.mjs'), join(cwd, 'escape.mjs'));
173
+ await assert.rejects(captureDeliveryReview(state, cwd, join(cwd, '.harness')), /outside|escape|symlink/i);
130
174
  });
131
175
 
132
176
  test('large diffs retain full receipt instead of silently using output tail', async t => {
@@ -137,3 +181,14 @@ test('large diffs retain full receipt instead of silently using output tail', as
137
181
  assert.ok(readFileSync(capture.path, 'utf8').length > 12000);
138
182
  assert.match(capture.diff, /diff --git/);
139
183
  });
184
+
185
+ test('review accepts assertion aliases and names missing requirements', async t => {
186
+ const { cwd, state, params, dir } = fixture(t);
187
+ const capture = await captureDeliveryReview(state, cwd, dir);
188
+ assert.throws(() => recordDeliveryReview(state, cwd, { ...params(capture.id), coverage: [{ requirement: 'Other', assertions: 'x' }] }), /Missing: \["Correct amount"\]/);
189
+ recordDeliveryReview(state, cwd, { ...params(capture.id), coverage: [{ requirement: 'Correct amount', assertion: 'app amount equals 2' }] });
190
+ assert.equal(state.reviewEvidence.coverage[0].assertions, 'app amount equals 2');
191
+ const again = await captureDeliveryReview(state, cwd, dir);
192
+ recordDeliveryReview(state, cwd, { ...params(again.id), coverage: [{ requirement: 'Correct amount', assertions: ['amount is 2', 'new result is 2'] }] });
193
+ assert.equal(state.reviewEvidence.coverage[0].assertions, 'amount is 2; new result is 2');
194
+ });
@@ -79,6 +79,28 @@ test('argv execution captures output without shell expansion, including spaces',
79
79
  assert.equal(result.code, 0); assert.match(result.output, /literal \$HOME ; echo bad/);
80
80
  assert.match(readFileSync(logPath, 'utf8'), /literal/);
81
81
  });
82
+ test('runCommand strips runtime transport tokens case-insensitively without mutating parent env', async t => {
83
+ const cwd = fixture(t);
84
+ const tokenKeys = ['CTG_RUNTIME_GITHUB_TOKEN', 'ctg_runtime_github_token', 'CtG_RuNtImE_GiThUb_ToKeN'];
85
+ const original = new Map(tokenKeys.map(key => [key, process.env[key]]));
86
+ const preservedOriginal = process.env.DELIVERY_ENV_PRESERVED;
87
+ process.env.DELIVERY_ENV_PRESERVED = 'yes';
88
+ for (const key of tokenKeys) process.env[key] = 'test-only-marker';
89
+ t.after(() => {
90
+ if (preservedOriginal === undefined) delete process.env.DELIVERY_ENV_PRESERVED;
91
+ else process.env.DELIVERY_ENV_PRESERVED = preservedOriginal;
92
+ for (const [key, value] of original) {
93
+ if (value === undefined) delete process.env[key];
94
+ else process.env[key] = value;
95
+ }
96
+ });
97
+
98
+ const script = `const keys = Object.keys(process.env); console.log(JSON.stringify({ tokensAbsent: !keys.some(key => key.toUpperCase() === 'CTG_RUNTIME_GITHUB_TOKEN'), preserved: process.env.DELIVERY_ENV_PRESERVED === 'yes' }));`;
99
+ const result = await runCommand([process.execPath, '-e', script], { cwd });
100
+ assert.equal(result.code, 0);
101
+ assert.deepEqual(JSON.parse(result.output.trim()), { tokensAbsent: true, preserved: true });
102
+ assert.deepEqual(tokenKeys.map(key => process.env[key]), tokenKeys.map(key => 'test-only-marker'));
103
+ });
82
104
  test('nonzero exit and missing executable cannot be successful', async t => {
83
105
  const cwd = fixture(t);
84
106
  const fail = await runCommand([process.execPath, '-e', 'process.exit(7)'], { cwd, logPath: join(cwd, 'fail.log') });
@@ -340,4 +362,3 @@ test('guidance, prompt template and README document the classification', () => {
340
362
  assert.match(readme, /Informational vs delivery asks/);
341
363
  assert.match(readme, /advisory/);
342
364
  });
343
-
@@ -0,0 +1,15 @@
1
+ import { Plugin } from '@opencode/plugin';
2
+
3
+ export default Plugin.define({
4
+ id: 'ctg-project-discovery-fixture',
5
+ setup: async ctx => {
6
+ const registration = await ctx.command.transform(editor => {
7
+ editor.add({
8
+ name: 'fixture-project-plugin-marker',
9
+ description: 'Proves project-local plugin discovery is active.',
10
+ execute: async () => { throw new Error('fixture marker command is not intended to execute'); },
11
+ });
12
+ });
13
+ return () => registration.dispose();
14
+ },
15
+ });