carthagent 0.0.0-stage → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (379) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -2
  3. package/bin/ctg.mjs +736 -0
  4. package/docs/budgets.md +72 -0
  5. package/docs/compaction.md +34 -0
  6. package/docs/context-efficiency-plan.md +24 -0
  7. package/docs/context-efficiency-review.md +27 -0
  8. package/docs/context-efficiency.md +49 -0
  9. package/docs/deepseek-jev-benchmark-plan.md +70 -0
  10. package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
  11. package/docs/deepseek-jev-benchmark-restart.md +9 -0
  12. package/docs/deepseek-jev-benchmark-results.md +77 -0
  13. package/docs/deepseek-jev-benchmark-running.md +60 -0
  14. package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
  15. package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
  16. package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
  17. package/docs/deepseek-jev-http400-diagnosis.md +45 -0
  18. package/docs/delivery-review-evidence.md +53 -0
  19. package/docs/delivery-timeout-repair-plan.md +51 -0
  20. package/docs/evaluation-readiness.md +31 -0
  21. package/docs/evaluation.md +70 -0
  22. package/docs/extending.md +354 -0
  23. package/docs/glm-pilot-execution.md +9 -0
  24. package/docs/glm-pilot-plan.md +15 -0
  25. package/docs/glm-pilot-results.md +71 -0
  26. package/docs/graph-context-optimization.md +15 -0
  27. package/docs/jev-repository-pilot-plan.md +25 -0
  28. package/docs/jev-repository-pilot-protocol.md +39 -0
  29. package/docs/jev-repository-pilot-restart.md +7 -0
  30. package/docs/jev-repository-pilot-results.md +79 -0
  31. package/docs/jev.md +32 -0
  32. package/docs/model-registry.md +25 -0
  33. package/docs/native-compatibility.md +117 -0
  34. package/docs/opencode-archive.md +9 -0
  35. package/docs/opencode-migration.md +115 -0
  36. package/docs/opencode-runtime.md +94 -0
  37. package/docs/opencode-workspace.md +31 -0
  38. package/docs/owned-runtime-workflow-registration.md +17 -0
  39. package/docs/plan-advisor-evaluation.md +38 -0
  40. package/docs/planning-startup-fix-plan.md +23 -0
  41. package/docs/planning-startup-fix-review.md +24 -0
  42. package/docs/planning.md +118 -0
  43. package/docs/production-merge-review.md +23 -0
  44. package/docs/quality.md +35 -0
  45. package/docs/repository-benchmark-design.md +111 -0
  46. package/docs/review-assurance.md +9 -0
  47. package/docs/review-experiment-results.md +59 -0
  48. package/docs/tested-planning-roadmap.md +58 -0
  49. package/docs/tested-planning.md +58 -0
  50. package/docs/tui-pickers.md +9 -0
  51. package/docs/webdriver.md +83 -0
  52. package/docs/workflow-modes.md +26 -0
  53. package/docs/workflow.d2 +17 -0
  54. package/docs/workflow.svg +149 -0
  55. package/docs/workspace-coordination.md +9 -0
  56. package/evaluate.mjs +249 -0
  57. package/extensions/codegraph.ts +36 -0
  58. package/extensions/delivery.ts +819 -0
  59. package/extensions/experiential.ts +21 -0
  60. package/extensions/subscriptions.ts +21 -0
  61. package/guidance/profiles/authenticated-web.json +24 -0
  62. package/guidance/profiles/data-visualization.json +25 -0
  63. package/guidance/profiles/database-change.json +24 -0
  64. package/guidance/profiles/external-api.json +24 -0
  65. package/guidance/profiles/transactional-data.json +25 -0
  66. package/guidance/profiles/web-application.json +26 -0
  67. package/lib/acceptance-review.mjs +4 -0
  68. package/lib/browser-bin.mjs +160 -0
  69. package/lib/browser-errors.mjs +54 -0
  70. package/lib/browser.mjs +219 -0
  71. package/lib/budget.mjs +37 -0
  72. package/lib/cdp.mjs +221 -0
  73. package/lib/cloud/client.mjs +275 -0
  74. package/lib/cloud/control-plane.mjs +350 -0
  75. package/lib/cloud/gateway.mjs +380 -0
  76. package/lib/cloud/http.mjs +135 -0
  77. package/lib/cloud/ledger.mjs +215 -0
  78. package/lib/codegraph-query.mjs +60 -0
  79. package/lib/codegraph.mjs +378 -0
  80. package/lib/context-archive.mjs +18 -0
  81. package/lib/delivery-context.mjs +132 -0
  82. package/lib/delivery-guidance.mjs +9 -0
  83. package/lib/delivery-review.mjs +94 -0
  84. package/lib/delivery.mjs +610 -0
  85. package/lib/engine.mjs +137 -0
  86. package/lib/guidance.mjs +71 -0
  87. package/lib/jev-advisor.mjs +47 -0
  88. package/lib/opencode/archive.mjs +122 -0
  89. package/lib/opencode/compatibility.mjs +19 -0
  90. package/lib/opencode/credentials.mjs +205 -0
  91. package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
  92. package/lib/opencode/evaluation.mjs +242 -0
  93. package/lib/opencode/file-lock.mjs +86 -0
  94. package/lib/opencode/headless.mjs +200 -0
  95. package/lib/opencode/review.mjs +213 -0
  96. package/lib/opencode/runtime.mjs +246 -0
  97. package/lib/opencode/service.mjs +249 -0
  98. package/lib/opencode/session-options.mjs +60 -0
  99. package/lib/opencode/workspace.mjs +133 -0
  100. package/lib/openrouter-jev.mjs +37 -0
  101. package/lib/outcome-contract.mjs +21 -0
  102. package/lib/plan-advisor.mjs +38 -0
  103. package/lib/planning-access.mjs +7 -0
  104. package/lib/planning-advice.mjs +28 -0
  105. package/lib/planning-discovery.mjs +14 -0
  106. package/lib/planning-guide.mjs +34 -0
  107. package/lib/planning-view.mjs +57 -0
  108. package/lib/planning.mjs +155 -0
  109. package/lib/probe-diagnostics.mjs +13 -0
  110. package/lib/providers/experiential.mjs +292 -0
  111. package/lib/providers/model-catalog.json +700 -0
  112. package/lib/providers/model-catalog.mjs +7 -0
  113. package/lib/providers/names.mjs +13 -0
  114. package/lib/providers/subscription-models.mjs +483 -0
  115. package/lib/reports.mjs +80 -0
  116. package/lib/review-state.mjs +49 -0
  117. package/lib/review.mjs +114 -0
  118. package/lib/scenarios.mjs +99 -0
  119. package/lib/ship-auth.mjs +44 -0
  120. package/lib/ship-jev.mjs +59 -0
  121. package/lib/ship-telemetry.mjs +183 -0
  122. package/lib/tui/ansi.mjs +255 -0
  123. package/lib/tui/app.mjs +2445 -0
  124. package/lib/tui/auth.mjs +249 -0
  125. package/lib/tui/carthage.mjs +109 -0
  126. package/lib/tui/clipboard.mjs +122 -0
  127. package/lib/tui/d2.mjs +431 -0
  128. package/lib/tui/feed.mjs +467 -0
  129. package/lib/tui/framing.mjs +80 -0
  130. package/lib/tui/glyphs.mjs +172 -0
  131. package/lib/tui/hash-worker.mjs +11 -0
  132. package/lib/tui/mock-engine.mjs +222 -0
  133. package/lib/tui/models.mjs +73 -0
  134. package/lib/tui/picker.mjs +18 -0
  135. package/lib/tui/theme.mjs +663 -0
  136. package/lib/tui/title.mjs +7 -0
  137. package/lib/update-channel.mjs +62 -0
  138. package/lib/update.mjs +230 -0
  139. package/lib/webdriver.mjs +82 -0
  140. package/lib/workflow.mjs +88 -0
  141. package/lib/workspace.mjs +39 -0
  142. package/metadata.json +8 -0
  143. package/package.json +94 -4
  144. package/plugins/carthagent/index.ts +3 -0
  145. package/plugins/carthagent/package.json +28 -0
  146. package/plugins/carthagent/src/archive-rpc.ts +46 -0
  147. package/plugins/carthagent/src/bootstrap.ts +38 -0
  148. package/plugins/carthagent/src/index.ts +62 -0
  149. package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
  150. package/plugins/carthagent/src/rpc.ts +76 -0
  151. package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
  152. package/plugins/carthagent/src/server/budgets.ts +21 -0
  153. package/plugins/carthagent/src/server/codegraph.ts +46 -0
  154. package/plugins/carthagent/src/server/delivery.ts +488 -0
  155. package/plugins/carthagent/src/server/device-auth.ts +91 -0
  156. package/plugins/carthagent/src/server/native-delivery.ts +26 -0
  157. package/plugins/carthagent/src/server/orchestration.ts +56 -0
  158. package/plugins/carthagent/src/server/policy.ts +24 -0
  159. package/plugins/carthagent/src/server/providers.ts +424 -0
  160. package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
  161. package/plugins/carthagent/src/server/session-controls.ts +82 -0
  162. package/plugins/carthagent/src/server/telemetry.ts +92 -0
  163. package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
  164. package/plugins/carthagent/src/session-rpc.ts +31 -0
  165. package/plugins/carthagent/src/testing.ts +85 -0
  166. package/plugins/carthagent/src/workspace-rpc.ts +133 -0
  167. package/plugins/carthagent/test-plugin/index.ts +4 -0
  168. package/plugins/carthagent/test-plugin/package.json +1 -0
  169. package/plugins/carthagent/tsconfig.json +19 -0
  170. package/plugins/carthagent/tui.ts +1 -0
  171. package/prompts/delivery.md +7 -0
  172. package/public/index.html +983 -0
  173. package/scenarios/cli/grade_cli.py +69 -0
  174. package/scenarios/cli/scenario.json +21 -0
  175. package/scenarios/game/baseline/minecraft.py +193 -0
  176. package/scenarios/game/context.md +1 -0
  177. package/scenarios/game/grade_minecraft_clone.py +603 -0
  178. package/scenarios/game/requirements.txt +2 -0
  179. package/scenarios/game/scenario.json +33 -0
  180. package/server.mjs +341 -0
  181. package/skills/game-development/SKILL.md +36 -0
  182. package/skills/game-development/assets/ursina_starter.py +98 -0
  183. package/skills/game-development/scripts/verify_ursina.py +80 -0
  184. package/skills/software-delivery/SKILL.md +50 -0
  185. package/skills/typescript-delivery/SKILL.md +69 -0
  186. package/tests/acceptance-review.test.mjs +13 -0
  187. package/tests/auth.test.mjs +234 -0
  188. package/tests/bounded-summary.test.mjs +68 -0
  189. package/tests/browser-bin.test.mjs +91 -0
  190. package/tests/browser.test.mjs +96 -0
  191. package/tests/budget-engine.test.mjs +110 -0
  192. package/tests/budget.test.mjs +23 -0
  193. package/tests/cli.test.mjs +245 -0
  194. package/tests/clipboard.test.mjs +79 -0
  195. package/tests/cloud-client.test.mjs +129 -0
  196. package/tests/cloud-control-plane.test.mjs +144 -0
  197. package/tests/cloud-gateway.test.mjs +147 -0
  198. package/tests/cloud-http.test.mjs +102 -0
  199. package/tests/cloud-ledger.test.mjs +75 -0
  200. package/tests/codegraph-query.test.mjs +66 -0
  201. package/tests/codegraph.test.mjs +142 -0
  202. package/tests/delivery-context.test.mjs +66 -0
  203. package/tests/delivery-review.test.mjs +139 -0
  204. package/tests/delivery.test.mjs +343 -0
  205. package/tests/engine-api.mjs +24 -0
  206. package/tests/experiential-extension.test.mjs +37 -0
  207. package/tests/experiential.test.mjs +300 -0
  208. package/tests/extension.test.mjs +921 -0
  209. package/tests/fixtures/archive-plugin/index.ts +10 -0
  210. package/tests/guidance.test.mjs +78 -0
  211. package/tests/model-registry.test.mjs +68 -0
  212. package/tests/models.test.mjs +70 -0
  213. package/tests/openai-idempotency.test.mjs +99 -0
  214. package/tests/opencode-archive-rpc.test.mjs +82 -0
  215. package/tests/opencode-delivery.test.mjs +231 -0
  216. package/tests/opencode-providers.test.mjs +414 -0
  217. package/tests/opencode-runtime.test.mjs +455 -0
  218. package/tests/opencode-session-controls.test.mjs +115 -0
  219. package/tests/opencode-session-options.test.mjs +26 -0
  220. package/tests/opencode-workspace.test.mjs +339 -0
  221. package/tests/outcome-contract.test.mjs +17 -0
  222. package/tests/picker.test.mjs +34 -0
  223. package/tests/plan-advisor.test.mjs +88 -0
  224. package/tests/plan-review-loop.test.mjs +53 -0
  225. package/tests/planning-advice.test.mjs +27 -0
  226. package/tests/planning-contract.test.mjs +65 -0
  227. package/tests/planning-startup.test.mjs +38 -0
  228. package/tests/planning-view.test.mjs +33 -0
  229. package/tests/planning.test.mjs +223 -0
  230. package/tests/probe-diagnostics.test.mjs +21 -0
  231. package/tests/pull-request-rpc.test.mjs +366 -0
  232. package/tests/review-state.test.mjs +32 -0
  233. package/tests/review.test.mjs +127 -0
  234. package/tests/server.test.mjs +252 -0
  235. package/tests/ship-jev.test.mjs +77 -0
  236. package/tests/ship-telemetry.test.mjs +333 -0
  237. package/tests/standard-workflow.test.mjs +28 -0
  238. package/tests/subscription-models.test.mjs +416 -0
  239. package/tests/subscriptions-extension.test.mjs +27 -0
  240. package/tests/terminal-title.test.mjs +13 -0
  241. package/tests/test_grade_minecraft_clone.py +199 -0
  242. package/tests/test_starter.py +78 -0
  243. package/tests/theme.test.mjs +175 -0
  244. package/tests/tui-copy-paste.test.mjs +54 -0
  245. package/tests/tui.test.mjs +808 -0
  246. package/tests/update-channels.test.mjs +129 -0
  247. package/tests/update.test.mjs +175 -0
  248. package/tests/webdriver.test.mjs +31 -0
  249. package/tests/workflow.test.mjs +47 -0
  250. package/tests/workspace-rpc.test.mjs +236 -0
  251. package/tests/workspace.test.mjs +24 -0
  252. package/tools/benchmark/advisor.test.mjs +7 -0
  253. package/tools/benchmark/advisor.ts +27 -0
  254. package/tools/benchmark/campaign.mjs +45 -0
  255. package/tools/benchmark/diagnose-request.mjs +40 -0
  256. package/tools/benchmark/eval-common.mjs +32 -0
  257. package/tools/benchmark/eval-ledger.mjs +22 -0
  258. package/tools/benchmark/eval-shop.mjs +26 -0
  259. package/tools/benchmark/eval-voxel.mjs +20 -0
  260. package/tools/benchmark/evaluate.mjs +24 -0
  261. package/tools/benchmark/evaluators.test.mjs +13 -0
  262. package/tools/benchmark/fixtures/shop/server.mjs +18 -0
  263. package/tools/benchmark/fixtures/shop/ui.html +8 -0
  264. package/tools/benchmark/freeze.mjs +30 -0
  265. package/tools/benchmark/gateway.mjs +91 -0
  266. package/tools/benchmark/gateway.test.mjs +38 -0
  267. package/tools/benchmark/generator.mjs +11 -0
  268. package/tools/benchmark/generator.test.mjs +26 -0
  269. package/tools/benchmark/guard.test.mjs +133 -0
  270. package/tools/benchmark/guard.ts +136 -0
  271. package/tools/benchmark/native-plugin/index.ts +24 -0
  272. package/tools/benchmark/planning-startup.test.mjs +47 -0
  273. package/tools/benchmark/preflight-live.mjs +31 -0
  274. package/tools/benchmark/preflight.mjs +22 -0
  275. package/tools/benchmark/replay-diagnostic.mjs +23 -0
  276. package/tools/benchmark/runner.mjs +141 -0
  277. package/tools/benchmark/runner.test.mjs +93 -0
  278. package/tools/benchmark/runtime.mjs +23 -0
  279. package/tools/benchmark/selftest.mjs +47 -0
  280. package/tools/benchmark/smoke-jev.mjs +5 -0
  281. package/tools/benchmark/tasks.mjs +20 -0
  282. package/tools/benchmark/transport.mjs +65 -0
  283. package/tools/benchmark/transport.test.mjs +44 -0
  284. package/tools/benchmark/upstream-error.mjs +18 -0
  285. package/tools/benchmark/upstream-error.test.mjs +10 -0
  286. package/tools/browser-check.mjs +109 -0
  287. package/tools/check-syntax.mjs +27 -0
  288. package/tools/evaluate-plan-advisor.mjs +20 -0
  289. package/tools/native-fixture.mjs +194 -0
  290. package/tools/native-pty-smoke.py +108 -0
  291. package/tools/native-report-plugin/index.ts +50 -0
  292. package/tools/native-report-plugin/package.json +7 -0
  293. package/tools/patch-compaction.mjs +24 -0
  294. package/tools/patch-model-catalog.mjs +12 -0
  295. package/tools/patch-print-shutdown.mjs +39 -0
  296. package/tools/repo-pilot/evaluator.cjs +32 -0
  297. package/tools/repo-pilot/reference.mjs +43 -0
  298. package/tools/repo-pilot/runner.mjs +85 -0
  299. package/tools/repo-pilot/sandbox-entry.mjs +12 -0
  300. package/tools/repo-pilot/tasks.mjs +20 -0
  301. package/tools/repo-pilot/type-evaluator.cjs +6 -0
  302. package/tools/test-native-controls.mjs +84 -0
  303. package/tools/test-native-delivery-host.mjs +307 -0
  304. package/tools/test-native-evaluation.mjs +46 -0
  305. package/tools/test-native-host.mjs +385 -0
  306. package/tools/test-native-outcomes.mjs +73 -0
  307. package/tools/test-native-package.mjs +70 -0
  308. package/tools/test-native-pty.mjs +20 -0
  309. package/tools/test-native-report.mjs +88 -0
  310. package/tools/test-native-swe-host.mjs +132 -0
  311. package/tools/test-native-swe.mjs +122 -0
  312. package/tools/test-picker-pty.py +45 -0
  313. package/tools/test-python.mjs +29 -0
  314. package/tools/test-webdriver.mjs +54 -0
  315. package/tools/verify-release-version.mjs +37 -0
  316. package/tools/webdriver-check.mjs +52 -0
  317. package/vendor/agent/bounded-summary.mjs +88 -0
  318. package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
  319. package/vendor/agent/chunks/anthropic.js +75 -0
  320. package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
  321. package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
  322. package/vendor/agent/chunks/chord-context.js +96 -0
  323. package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
  324. package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
  325. package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
  326. package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
  327. package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
  328. package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
  329. package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
  330. package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
  331. package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
  332. package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
  333. package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
  334. package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
  335. package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
  336. package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
  337. package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
  338. package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
  339. package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
  340. package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
  341. package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
  342. package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
  343. package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
  344. package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
  345. package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
  346. package/vendor/agent/chunks/github-copilot.js +2 -0
  347. package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
  348. package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
  349. package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
  350. package/vendor/agent/chunks/image-resize-worker.js +2 -0
  351. package/vendor/agent/chunks/kimi-coding.js +2 -0
  352. package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
  353. package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
  354. package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
  355. package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
  356. package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
  357. package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
  358. package/vendor/agent/chunks/openai-codex.js +75 -0
  359. package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
  360. package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
  361. package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
  362. package/vendor/agent/chunks/openrouter.js +75 -0
  363. package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
  364. package/vendor/agent/chunks/radius.js +75 -0
  365. package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
  366. package/vendor/agent/chunks/xai.js +2 -0
  367. package/vendor/agent/cli.js +3 -0
  368. package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
  369. package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
  370. package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
  371. package/vendor/agent/index.js +2 -0
  372. package/vendor/agent/modes/interactive/theme/dark.json +91 -0
  373. package/vendor/agent/modes/interactive/theme/light.json +90 -0
  374. package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
  375. package/vendor/agent/package.json +14 -0
  376. package/vendor/agent/rpc-entry.js +3 -0
  377. package/vendor/agent/theme/dark.json +91 -0
  378. package/vendor/agent/theme/light.json +90 -0
  379. package/vendor/agent/theme/theme-schema.json +356 -0
@@ -0,0 +1,603 @@
1
+ #!/usr/bin/env python3
2
+ """External acceptance grader for generated Minecraft-style Ursina games.
3
+
4
+ This is a scenario-level quality gate, not core harness logic. It checks that
5
+ the submitted project proves the user-visible behaviors it claims: docs/tests,
6
+ safe import, real renderer startup, structured smoke evidence, persisted state,
7
+ and an independent normal-window keyboard probe.
8
+ """
9
+ import argparse
10
+ import ast
11
+ import importlib.util
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import time
20
+
21
+ from PIL import Image, ImageChops
22
+
23
+
24
+ MARKER = "GAME_SMOKE_RESULT "
25
+
26
+
27
+ class Failure(Exception):
28
+ pass
29
+
30
+
31
+ def run(argv, cwd, timeout=90):
32
+ try:
33
+ return subprocess.run(
34
+ argv,
35
+ cwd=cwd,
36
+ text=True,
37
+ stdout=subprocess.PIPE,
38
+ stderr=subprocess.STDOUT,
39
+ timeout=timeout,
40
+ )
41
+ except subprocess.TimeoutExpired as error:
42
+ output = error.stdout or ""
43
+ if isinstance(output, bytes):
44
+ output = output.decode(errors="replace")
45
+ return subprocess.CompletedProcess(
46
+ argv,
47
+ 124,
48
+ f"{output}\nTimed out after {timeout} seconds",
49
+ )
50
+
51
+
52
+ def fail_if(condition, message, failures):
53
+ if condition:
54
+ failures.append(message)
55
+
56
+
57
+ def import_safely(entry):
58
+ try:
59
+ from direct.showbase.ShowBase import ShowBase
60
+ except Exception:
61
+ ShowBase = None
62
+ opened = []
63
+ original_init = ShowBase.__init__ if ShowBase is not None else None
64
+
65
+ def blocked_init(self, *args, **kwargs):
66
+ opened.append(True)
67
+ raise RuntimeError("minecraft.py opened a graphics window during import")
68
+
69
+ if ShowBase is not None:
70
+ ShowBase.__init__ = blocked_init
71
+ old_path = list(sys.path)
72
+ try:
73
+ sys.path.insert(0, str(entry.parent))
74
+ spec = importlib.util.spec_from_file_location("candidate_minecraft", entry)
75
+ module = importlib.util.module_from_spec(spec)
76
+ spec.loader.exec_module(module)
77
+ finally:
78
+ sys.path = old_path
79
+ if ShowBase is not None:
80
+ ShowBase.__init__ = original_init
81
+ if opened:
82
+ raise Failure("Importing minecraft.py must not construct Ursina/ShowBase")
83
+
84
+
85
+ def sampled_colors_and_dark_ratio(path):
86
+ with Image.open(path) as image:
87
+ rgb = image.convert("RGB")
88
+ pixels = [
89
+ rgb.getpixel((x, y))
90
+ for x in range(0, rgb.width, max(1, rgb.width // 48))
91
+ for y in range(0, rgb.height, max(1, rgb.height // 48))
92
+ ]
93
+ colors = len(set(pixels))
94
+ dark = sum(1 for r, g, b in pixels if r + g + b < 90) / max(1, len(pixels))
95
+ return colors, dark
96
+
97
+
98
+ def changed_pixels(first, second):
99
+ with Image.open(first) as before, Image.open(second) as after:
100
+ difference = ImageChops.difference(before.convert("RGB"), after.convert("RGB"))
101
+ return sum(1 for pixel in difference.getdata() if pixel != (0, 0, 0))
102
+
103
+
104
+ def playable_frame(path):
105
+ colors, dark = sampled_colors_and_dark_ratio(path)
106
+ return colors >= 8 and dark <= 0.70
107
+
108
+
109
+ def nested_ursina_callbacks(entry):
110
+ try:
111
+ tree = ast.parse(entry.read_text(errors="replace"))
112
+ except SyntaxError:
113
+ return []
114
+ parents = {}
115
+ for parent in ast.walk(tree):
116
+ for child in ast.iter_child_nodes(parent):
117
+ parents[child] = parent
118
+ return sorted({
119
+ node.name
120
+ for node in ast.walk(tree)
121
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
122
+ and node.name in {"input", "update"}
123
+ and isinstance(parents.get(node), (ast.FunctionDef, ast.AsyncFunctionDef))
124
+ })
125
+
126
+
127
+ def xdotool(*argv, timeout=5):
128
+ return subprocess.run(
129
+ ["xdotool", *argv],
130
+ text=True,
131
+ stdout=subprocess.PIPE,
132
+ stderr=subprocess.STDOUT,
133
+ timeout=timeout,
134
+ )
135
+
136
+
137
+ def wait_for_window(process, timeout=12):
138
+ deadline = time.monotonic() + timeout
139
+ while time.monotonic() < deadline:
140
+ result = xdotool("search", "--onlyvisible", "--pid", str(process.pid))
141
+ windows = [line.strip() for line in result.stdout.splitlines() if line.strip()]
142
+ if windows:
143
+ return windows[-1]
144
+ if process.poll() is not None:
145
+ break
146
+ time.sleep(0.2)
147
+ return None
148
+
149
+
150
+ def window_geometry(window_id):
151
+ result = xdotool("getwindowgeometry", "--shell", window_id)
152
+ values = {}
153
+ for line in result.stdout.splitlines():
154
+ if "=" in line:
155
+ key, value = line.split("=", 1)
156
+ values[key] = int(value)
157
+ if not {"X", "Y", "WIDTH", "HEIGHT"}.issubset(values):
158
+ raise Failure(f"Game window disappeared during interaction: {result.stdout.strip()}")
159
+ return values["X"], values["Y"], values["WIDTH"], values["HEIGHT"]
160
+
161
+
162
+ def capture_window(window_id, destination):
163
+ x, y, width, height = window_geometry(window_id)
164
+ result = subprocess.run(
165
+ ["scrot", "-o", "-a", f"{x},{y},{width},{height}", str(destination)],
166
+ text=True,
167
+ stdout=subprocess.PIPE,
168
+ stderr=subprocess.STDOUT,
169
+ timeout=10,
170
+ )
171
+ if result.returncode != 0 or not destination.is_file():
172
+ raise Failure(f"Could not capture game window: {result.stdout[-1000:]}")
173
+
174
+
175
+ def press(window_id, key, hold=0):
176
+ xdotool("windowfocus", "--sync", window_id)
177
+ if hold:
178
+ xdotool("keydown", key)
179
+ time.sleep(hold)
180
+ xdotool("keyup", key)
181
+ else:
182
+ xdotool("key", key)
183
+ time.sleep(0.35)
184
+
185
+
186
+ def require_running(process, log_path):
187
+ if process.poll() is not None:
188
+ raise Failure(
189
+ f"Game exited during interactive input (code {process.returncode}):\n"
190
+ f"{log_path.read_text(errors='replace')[-2000:]}"
191
+ )
192
+
193
+
194
+ def check_interactive_window(candidate, entry, artifacts, failures):
195
+ if not os.environ.get("DISPLAY") or not shutil.which("xdotool") or not shutil.which("scrot"):
196
+ failures.append("Interactive probe needs DISPLAY, xdotool, and scrot")
197
+ return
198
+
199
+ failure_count = len(failures)
200
+ log_path = artifacts / "grade-interactive.log"
201
+ save_path = artifacts / "grade-interactive-save.json"
202
+ save_path.unlink(missing_ok=True)
203
+ with log_path.open("w") as log:
204
+ process = subprocess.Popen(
205
+ [sys.executable, str(entry), "--save", str(save_path)],
206
+ cwd=candidate,
207
+ stdout=log,
208
+ stderr=subprocess.STDOUT,
209
+ text=True,
210
+ )
211
+ try:
212
+ try:
213
+ window_id = wait_for_window(process)
214
+ if window_id is None:
215
+ failures.append(f"Game did not open an interactive window:\n{log_path.read_text(errors='replace')[-2000:]}")
216
+ return
217
+ time.sleep(1)
218
+ require_running(process, log_path)
219
+ captures = {"initial": artifacts / "grade-interactive-initial.png"}
220
+ capture_window(window_id, captures["initial"])
221
+
222
+ press(window_id, "w", hold=0.7)
223
+ require_running(process, log_path)
224
+ captures["move"] = artifacts / "grade-interactive-move.png"
225
+ capture_window(window_id, captures["move"])
226
+ fail_if(
227
+ not playable_frame(captures["move"]),
228
+ "Interactive movement left no visible playable world",
229
+ failures,
230
+ )
231
+
232
+ xdotool("windowfocus", "--sync", window_id)
233
+ xdotool("mousemove_relative", "--", "100", "0")
234
+ time.sleep(0.35)
235
+ require_running(process, log_path)
236
+ captures["look"] = artifacts / "grade-interactive-look.png"
237
+ capture_window(window_id, captures["look"])
238
+ fail_if(
239
+ changed_pixels(captures["move"], captures["look"]) < 100,
240
+ "Interactive mouse movement did not visibly change the camera",
241
+ failures,
242
+ )
243
+ fail_if(
244
+ not playable_frame(captures["look"]),
245
+ "Interactive mouselook left no visible playable world",
246
+ failures,
247
+ )
248
+
249
+ press(window_id, "space")
250
+ require_running(process, log_path)
251
+ captures["jump"] = artifacts / "grade-interactive-jump.png"
252
+ capture_window(window_id, captures["jump"])
253
+ fail_if(
254
+ changed_pixels(captures["look"], captures["jump"]) < 100,
255
+ "Interactive Space key did not visibly trigger a jump/gravity response",
256
+ failures,
257
+ )
258
+
259
+ press(window_id, "2")
260
+ require_running(process, log_path)
261
+ captures["select"] = artifacts / "grade-interactive-select.png"
262
+ capture_window(window_id, captures["select"])
263
+
264
+ press(window_id, "p")
265
+ require_running(process, log_path)
266
+ captures["pause"] = artifacts / "grade-interactive-pause.png"
267
+ capture_window(window_id, captures["pause"])
268
+ press(window_id, "p")
269
+
270
+ save_path.unlink(missing_ok=True)
271
+ press(window_id, "s")
272
+ require_running(process, log_path)
273
+ fail_if(
274
+ not save_path.is_file(),
275
+ "Interactive S key did not write the requested save file",
276
+ failures,
277
+ )
278
+ if save_path.is_file():
279
+ try:
280
+ interactive_save = json.loads(save_path.read_text())
281
+ except Exception as error:
282
+ failures.append(f"Interactive save file is not valid JSON: {error}")
283
+ else:
284
+ fail_if(
285
+ world_size(interactive_save) < 20,
286
+ "Interactive save does not contain the playable world",
287
+ failures,
288
+ )
289
+ fail_if(
290
+ selected_value(interactive_save) in (None, 0, "0", "dirt"),
291
+ "Interactive save did not preserve number-key selection",
292
+ failures,
293
+ )
294
+ selected = selected_value(interactive_save)
295
+ before_click = world_size(interactive_save)
296
+
297
+ _, _, width, height = window_geometry(window_id)
298
+ xdotool(
299
+ "mousemove",
300
+ "--window",
301
+ window_id,
302
+ str(width // 2),
303
+ str(height // 2),
304
+ )
305
+ xdotool("click", "1")
306
+ time.sleep(0.35)
307
+ require_running(process, log_path)
308
+ save_path.unlink(missing_ok=True)
309
+ press(window_id, "s")
310
+ require_running(process, log_path)
311
+ fail_if(
312
+ not save_path.is_file(),
313
+ "Interactive save failed after left-click",
314
+ failures,
315
+ )
316
+ if save_path.is_file():
317
+ after_break = json.loads(save_path.read_text())
318
+ fail_if(
319
+ world_size(after_break) >= before_click,
320
+ "Interactive left-click did not break a targeted block",
321
+ failures,
322
+ )
323
+
324
+ xdotool("click", "3")
325
+ time.sleep(0.35)
326
+ require_running(process, log_path)
327
+ save_path.unlink(missing_ok=True)
328
+ press(window_id, "s")
329
+ require_running(process, log_path)
330
+ fail_if(
331
+ not save_path.is_file(),
332
+ "Interactive save failed after right-click",
333
+ failures,
334
+ )
335
+ if save_path.is_file():
336
+ after_place = json.loads(save_path.read_text())
337
+ fail_if(
338
+ world_size(after_place) <= world_size(after_break),
339
+ "Interactive right-click did not place the selected block",
340
+ failures,
341
+ )
342
+ fail_if(
343
+ selected_value(after_place) != selected,
344
+ "Placed-block save lost the selected block state",
345
+ failures,
346
+ )
347
+
348
+ movement_failed = changed_pixels(captures["initial"], captures["move"]) < 100
349
+ fail_if(
350
+ movement_failed,
351
+ "Interactive W key did not visibly move the player/camera",
352
+ failures,
353
+ )
354
+ if movement_failed and "FirstPersonController" in entry.read_text():
355
+ failures.append(
356
+ "FirstPersonController movement can be blocked by spawn geometry: "
357
+ "start on terrain with a traversable forward approach before the target."
358
+ )
359
+ fail_if(
360
+ changed_pixels(captures["move"], captures["select"]) < 20,
361
+ "Interactive number key did not visibly change block selection",
362
+ failures,
363
+ )
364
+ fail_if(
365
+ changed_pixels(captures["select"], captures["pause"]) < 100,
366
+ "Interactive P key did not show a paused state",
367
+ failures,
368
+ )
369
+ except Exception as error:
370
+ failures.append(f"Interactive probe failed: {error}")
371
+ finally:
372
+ if process.poll() is None:
373
+ process.terminate()
374
+ try:
375
+ process.wait(timeout=5)
376
+ except subprocess.TimeoutExpired:
377
+ process.kill()
378
+ process.wait(timeout=5)
379
+ callbacks = nested_ursina_callbacks(entry)
380
+ if len(failures) > failure_count and callbacks:
381
+ failures.append(
382
+ "Ursina does not auto-dispatch callbacks nested inside another function: "
383
+ f"{', '.join(callbacks)}. Put them at module scope or on an instantiated Entity."
384
+ )
385
+
386
+
387
+ def world_size(data):
388
+ if isinstance(data, dict):
389
+ for key in ("world", "blocks", "tiles", "voxels"):
390
+ value = data.get(key)
391
+ if isinstance(value, dict):
392
+ return len(value)
393
+ if isinstance(value, list):
394
+ return len(value)
395
+ return 0
396
+
397
+
398
+ def selected_value(data):
399
+ if isinstance(data, dict):
400
+ for key in ("selected", "selected_block", "selected_block_index", "active_block"):
401
+ if key in data:
402
+ return data[key]
403
+ return None
404
+
405
+
406
+ def parse_smoke_result(output):
407
+ for line in output.splitlines():
408
+ if line.startswith(MARKER):
409
+ return json.loads(line[len(MARKER):])
410
+ raise Failure(
411
+ f"Missing {MARKER.strip()} JSON line. --smoke must report gameplay evidence, "
412
+ "not just save a screenshot."
413
+ )
414
+
415
+
416
+ def check_smoke_protocol(result, save_path, screenshot, failures):
417
+ required_true = {
418
+ "spawn_safe": "safe spawn",
419
+ "moved": "WASD/player movement",
420
+ "jumped": "jump or gravity response",
421
+ "looked": "camera/mouselook change",
422
+ "selected_block_changed": "number-key block selection",
423
+ "break_changed_world": "breaking a targeted block",
424
+ "place_changed_world": "placing a selected block",
425
+ "pause_toggled": "pause/resume toggle",
426
+ "save_roundtrip": "save/load round-trip",
427
+ }
428
+ for key, label in required_true.items():
429
+ fail_if(result.get(key) is not True, f"Smoke did not prove {label} ({key}=true)", failures)
430
+
431
+ block_types = result.get("block_type_count") or result.get("selectable_block_types") or 0
432
+ fail_if(
433
+ not isinstance(block_types, int) or block_types < 4,
434
+ "Game needs at least four selectable block types",
435
+ failures,
436
+ )
437
+
438
+ before = result.get("world_blocks_before")
439
+ after_break = result.get("world_blocks_after_break")
440
+ after_place = result.get("world_blocks_after_place")
441
+ fail_if(
442
+ not isinstance(before, int) or before < 20,
443
+ "Smoke world must contain a real block field (>=20 blocks)",
444
+ failures,
445
+ )
446
+ fail_if(
447
+ not isinstance(after_break, int) or after_break >= before,
448
+ "Breaking must reduce the world/block count",
449
+ failures,
450
+ )
451
+ fail_if(
452
+ not isinstance(after_place, int) or after_place <= after_break,
453
+ "Placing must add a block after breaking",
454
+ failures,
455
+ )
456
+
457
+ fail_if(not save_path.is_file(), "Smoke did not write the requested save file", failures)
458
+ if save_path.is_file():
459
+ try:
460
+ data = json.loads(save_path.read_text())
461
+ except Exception as error:
462
+ failures.append(f"Save file is not valid JSON: {error}")
463
+ else:
464
+ fail_if(
465
+ world_size(data) < 20,
466
+ "Save file does not contain meaningful world progress",
467
+ failures,
468
+ )
469
+ fail_if(
470
+ selected_value(data) in (None, 0, "0", "dirt"),
471
+ "Save file did not preserve changed selected block",
472
+ failures,
473
+ )
474
+
475
+ fail_if(not screenshot.is_file(), "Smoke did not write the requested screenshot", failures)
476
+ if screenshot.is_file():
477
+ colors, dark = sampled_colors_and_dark_ratio(screenshot)
478
+ fail_if(
479
+ colors < 8,
480
+ f"Smoke screenshot has too little visual variety ({colors} sampled colors)",
481
+ failures,
482
+ )
483
+ fail_if(
484
+ dark > 0.70,
485
+ "Smoke screenshot is mostly dark/pause overlay, not playable world evidence",
486
+ failures,
487
+ )
488
+
489
+
490
+ def check_docs_and_tests(candidate, failures):
491
+ readmes = [p for p in (candidate / "README.md", candidate / "readme.md") if p.is_file()]
492
+ fail_if(
493
+ not readmes,
494
+ "Missing README with setup, run command, controls, and save/load notes",
495
+ failures,
496
+ )
497
+ if readmes:
498
+ text = readmes[0].read_text(errors="replace").lower()
499
+ for needle in ("python", "minecraft.py", "pause", "save"):
500
+ fail_if(needle not in text, f"README missing {needle!r} instruction", failures)
501
+ fail_if(
502
+ not re.search(r"\bwasd\b|w\s*/\s*a\s*/\s*s\s*/\s*d", text),
503
+ "README missing WASD movement instructions",
504
+ failures,
505
+ )
506
+ fail_if(
507
+ "pytest" in text and not importlib.util.find_spec("pytest"),
508
+ "README asks for pytest but pytest is not installed",
509
+ failures,
510
+ )
511
+
512
+ tests = candidate / "tests"
513
+ fail_if(
514
+ not any(tests.glob("test*.py")) if tests.is_dir() else True,
515
+ "Missing Python tests under tests/",
516
+ failures,
517
+ )
518
+ if tests.is_dir() and any(tests.glob("test*.py")):
519
+ result = run([sys.executable, "-m", "unittest", "discover", "-s", "tests"], candidate, timeout=30)
520
+ fail_if(result.returncode != 0, f"Candidate tests failed under unittest:\n{result.stdout[-2000:]}", failures)
521
+
522
+
523
+ def check_static(entry, failures):
524
+ text = entry.read_text(errors="replace")
525
+ lowered = text.lower()
526
+ for token, label in [
527
+ ("--smoke", "smoke CLI"),
528
+ ("--screenshot", "screenshot CLI"),
529
+ ("save", "save/load code"),
530
+ ("pause", "pause code"),
531
+ ]:
532
+ fail_if(token not in lowered, f"minecraft.py missing {label}", failures)
533
+ fail_if(
534
+ not re.search(r"if\s+__name__\s*==\s*['\"]__main__['\"]", text),
535
+ "minecraft.py must use a main guard",
536
+ failures,
537
+ )
538
+
539
+
540
+ def main():
541
+ parser = argparse.ArgumentParser(description=__doc__)
542
+ parser.add_argument("candidate", help="Candidate work directory")
543
+ parser.add_argument("--probe", required=True, help="Path to verify_ursina.py or compatible renderer probe")
544
+ parser.add_argument("--artifacts", default="artifacts", help="Artifact directory relative to the candidate")
545
+ parser.add_argument("--skip-interactive", action="store_true", help=argparse.SUPPRESS)
546
+ args = parser.parse_args()
547
+
548
+ candidate = Path(args.candidate).resolve()
549
+ entry = candidate / "minecraft.py"
550
+ artifacts = candidate / args.artifacts
551
+ artifacts.mkdir(parents=True, exist_ok=True)
552
+ failures = []
553
+
554
+ fail_if(not entry.is_file(), "Missing minecraft.py launcher", failures)
555
+ if failures:
556
+ raise SystemExit("\n".join(failures))
557
+
558
+ try:
559
+ import_safely(entry)
560
+ except SystemExit as error:
561
+ failures.append(f"Importing minecraft.py exited the process: {error}")
562
+ except Exception as error:
563
+ failures.append(str(error))
564
+ check_static(entry, failures)
565
+ check_docs_and_tests(candidate, failures)
566
+
567
+ probe_shot = artifacts / "grade-probe.png"
568
+ probe = run(
569
+ [sys.executable, str(Path(args.probe).resolve()), "minecraft.py", "--screenshot", str(probe_shot)],
570
+ candidate,
571
+ timeout=30,
572
+ )
573
+ fail_if(
574
+ probe.returncode != 0 or "URSINA_PROBE_RESULT " not in probe.stdout,
575
+ f"Renderer probe failed:\n{probe.stdout[-2000:]}",
576
+ failures,
577
+ )
578
+
579
+ smoke_shot = artifacts / "grade-smoke.png"
580
+ smoke_save = artifacts / "grade-save.json"
581
+ smoke = run(
582
+ [sys.executable, "minecraft.py", "--smoke", "--screenshot", str(smoke_shot), "--save", str(smoke_save)],
583
+ candidate,
584
+ timeout=30,
585
+ )
586
+ fail_if(smoke.returncode != 0, f"Candidate smoke failed:\n{smoke.stdout[-2000:]}", failures)
587
+ if smoke.returncode == 0:
588
+ try:
589
+ result = parse_smoke_result(smoke.stdout)
590
+ check_smoke_protocol(result, smoke_save, smoke_shot, failures)
591
+ except Exception as error:
592
+ failures.append(str(error))
593
+ if not args.skip_interactive:
594
+ check_interactive_window(candidate, entry, artifacts, failures)
595
+
596
+ if failures:
597
+ print("GAME_GRADE_RESULT " + json.dumps({"status": "failed", "failures": failures}, indent=2))
598
+ raise SystemExit(1)
599
+ print("GAME_GRADE_RESULT " + json.dumps({"status": "passed", "candidate": str(candidate)}))
600
+
601
+
602
+ if __name__ == "__main__":
603
+ main()
@@ -0,0 +1,2 @@
1
+ ursina==7.0.0
2
+ Pillow==11.3.0
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "game",
3
+ "description": "Minecraft-style Ursina game starting from a verified renderer slice, graded headless and in a real window",
4
+ "prompt": "Build me a simple Minecraft-style game in Python. I should be able to explore the world, break and place different blocks, pause, and save my progress. Make it feel complete enough to play, and include tests and setup instructions.",
5
+ "setup": "Ursina and Pillow are already installed in the supplied Python. External renderer probe: {root}/skills/game-development/scripts/verify_ursina.py. Run it early against minecraft.py and again after renderer changes; the required check uses the same probe. Preserve the seeded renderer lifecycle: default launch must call app.run() and stay interactive, while app.destroy() belongs only after the bounded --smoke path. Normal-window acceptance drives the public keyboard and mouse paths and rejects smoke-only state changes, so attach input/update methods to a live Entity. Keep the initial positive-Z movement path on terrain and clear while leaving a target reachable afterward.",
6
+ "skills": ["game-development"],
7
+ "context": ["context.md"],
8
+ "seeds": [
9
+ { "from": "{root}/skills/game-development/assets/ursina_starter.py", "to": "minecraft.py" }
10
+ ],
11
+ "developmentChecks": [
12
+ {
13
+ "id": "acceptance_core",
14
+ "kind": "runtime",
15
+ "argv": ["{python}", "{scenario}/grade_minecraft_clone.py", "{cwd}", "--probe", "{root}/skills/game-development/scripts/verify_ursina.py", "--skip-interactive"],
16
+ "timeoutSeconds": 90
17
+ },
18
+ {
19
+ "id": "acceptance_interactive",
20
+ "kind": "runtime",
21
+ "argv": ["{python}", "{scenario}/grade_minecraft_clone.py", "{cwd}", "--probe", "{root}/skills/game-development/scripts/verify_ursina.py"],
22
+ "timeoutSeconds": 120
23
+ }
24
+ ],
25
+ "holdoutChecks": [
26
+ {
27
+ "id": "acceptance_holdout",
28
+ "kind": "runtime",
29
+ "argv": ["{python}", "{scenario}/grade_minecraft_clone.py", "{cwd}", "--probe", "{root}/skills/game-development/scripts/verify_ursina.py"],
30
+ "timeoutSeconds": 120
31
+ }
32
+ ]
33
+ }