picopilot 0.0.0 → 0.1.0

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 (334) hide show
  1. package/LICENSE +661 -0
  2. package/dist/bin.d.ts +3 -0
  3. package/dist/bin.d.ts.map +1 -0
  4. package/dist/bin.js +4 -0
  5. package/dist/bin.js.map +1 -0
  6. package/dist/cli.d.ts +15 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +59 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/audio.d.ts +17 -0
  11. package/dist/commands/audio.d.ts.map +1 -0
  12. package/dist/commands/audio.js +315 -0
  13. package/dist/commands/audio.js.map +1 -0
  14. package/dist/commands/export.d.ts +36 -0
  15. package/dist/commands/export.d.ts.map +1 -0
  16. package/dist/commands/export.js +257 -0
  17. package/dist/commands/export.js.map +1 -0
  18. package/dist/commands/gfx.d.ts +28 -0
  19. package/dist/commands/gfx.d.ts.map +1 -0
  20. package/dist/commands/gfx.js +521 -0
  21. package/dist/commands/gfx.js.map +1 -0
  22. package/dist/commands/init.d.ts +34 -0
  23. package/dist/commands/init.d.ts.map +1 -0
  24. package/dist/commands/init.js +151 -0
  25. package/dist/commands/init.js.map +1 -0
  26. package/dist/commands/lint.d.ts +20 -0
  27. package/dist/commands/lint.d.ts.map +1 -0
  28. package/dist/commands/lint.js +90 -0
  29. package/dist/commands/lint.js.map +1 -0
  30. package/dist/commands/minify.d.ts +20 -0
  31. package/dist/commands/minify.d.ts.map +1 -0
  32. package/dist/commands/minify.js +129 -0
  33. package/dist/commands/minify.js.map +1 -0
  34. package/dist/commands/music.d.ts +13 -0
  35. package/dist/commands/music.d.ts.map +1 -0
  36. package/dist/commands/music.js +241 -0
  37. package/dist/commands/music.js.map +1 -0
  38. package/dist/commands/playtest.d.ts +29 -0
  39. package/dist/commands/playtest.d.ts.map +1 -0
  40. package/dist/commands/playtest.js +631 -0
  41. package/dist/commands/playtest.js.map +1 -0
  42. package/dist/commands/run.d.ts +30 -0
  43. package/dist/commands/run.d.ts.map +1 -0
  44. package/dist/commands/run.js +202 -0
  45. package/dist/commands/run.js.map +1 -0
  46. package/dist/commands/serve.d.ts +50 -0
  47. package/dist/commands/serve.d.ts.map +1 -0
  48. package/dist/commands/serve.js +213 -0
  49. package/dist/commands/serve.js.map +1 -0
  50. package/dist/commands/sfx.d.ts +13 -0
  51. package/dist/commands/sfx.d.ts.map +1 -0
  52. package/dist/commands/sfx.js +254 -0
  53. package/dist/commands/sfx.js.map +1 -0
  54. package/dist/commands/tokens.d.ts +36 -0
  55. package/dist/commands/tokens.d.ts.map +1 -0
  56. package/dist/commands/tokens.js +129 -0
  57. package/dist/commands/tokens.js.map +1 -0
  58. package/dist/commands/verify.d.ts +57 -0
  59. package/dist/commands/verify.d.ts.map +1 -0
  60. package/dist/commands/verify.js +213 -0
  61. package/dist/commands/verify.js.map +1 -0
  62. package/dist/commands/version.d.ts +12 -0
  63. package/dist/commands/version.d.ts.map +1 -0
  64. package/dist/commands/version.js +24 -0
  65. package/dist/commands/version.js.map +1 -0
  66. package/dist/engine/audio/index.d.ts +14 -0
  67. package/dist/engine/audio/index.d.ts.map +1 -0
  68. package/dist/engine/audio/index.js +14 -0
  69. package/dist/engine/audio/index.js.map +1 -0
  70. package/dist/engine/audio/music.d.ts +116 -0
  71. package/dist/engine/audio/music.d.ts.map +1 -0
  72. package/dist/engine/audio/music.js +138 -0
  73. package/dist/engine/audio/music.js.map +1 -0
  74. package/dist/engine/audio/section.d.ts +34 -0
  75. package/dist/engine/audio/section.d.ts.map +1 -0
  76. package/dist/engine/audio/section.js +67 -0
  77. package/dist/engine/audio/section.js.map +1 -0
  78. package/dist/engine/audio/sfx.d.ts +141 -0
  79. package/dist/engine/audio/sfx.d.ts.map +1 -0
  80. package/dist/engine/audio/sfx.js +506 -0
  81. package/dist/engine/audio/sfx.js.map +1 -0
  82. package/dist/engine/cart/cart.d.ts +75 -0
  83. package/dist/engine/cart/cart.d.ts.map +1 -0
  84. package/dist/engine/cart/cart.js +153 -0
  85. package/dist/engine/cart/cart.js.map +1 -0
  86. package/dist/engine/cart/errors.d.ts +29 -0
  87. package/dist/engine/cart/errors.d.ts.map +1 -0
  88. package/dist/engine/cart/errors.js +26 -0
  89. package/dist/engine/cart/errors.js.map +1 -0
  90. package/dist/engine/cart/gfx.d.ts +79 -0
  91. package/dist/engine/cart/gfx.d.ts.map +1 -0
  92. package/dist/engine/cart/gfx.js +171 -0
  93. package/dist/engine/cart/gfx.js.map +1 -0
  94. package/dist/engine/cart/index.d.ts +15 -0
  95. package/dist/engine/cart/index.d.ts.map +1 -0
  96. package/dist/engine/cart/index.js +15 -0
  97. package/dist/engine/cart/index.js.map +1 -0
  98. package/dist/engine/cart/map.d.ts +46 -0
  99. package/dist/engine/cart/map.d.ts.map +1 -0
  100. package/dist/engine/cart/map.js +118 -0
  101. package/dist/engine/cart/map.js.map +1 -0
  102. package/dist/engine/config/config.d.ts +74 -0
  103. package/dist/engine/config/config.d.ts.map +1 -0
  104. package/dist/engine/config/config.js +103 -0
  105. package/dist/engine/config/config.js.map +1 -0
  106. package/dist/engine/config/index.d.ts +7 -0
  107. package/dist/engine/config/index.d.ts.map +1 -0
  108. package/dist/engine/config/index.js +7 -0
  109. package/dist/engine/config/index.js.map +1 -0
  110. package/dist/engine/gfx/grid.d.ts +51 -0
  111. package/dist/engine/gfx/grid.d.ts.map +1 -0
  112. package/dist/engine/gfx/grid.js +116 -0
  113. package/dist/engine/gfx/grid.js.map +1 -0
  114. package/dist/engine/gfx/index.d.ts +17 -0
  115. package/dist/engine/gfx/index.d.ts.map +1 -0
  116. package/dist/engine/gfx/index.js +17 -0
  117. package/dist/engine/gfx/index.js.map +1 -0
  118. package/dist/engine/gfx/label.d.ts +34 -0
  119. package/dist/engine/gfx/label.d.ts.map +1 -0
  120. package/dist/engine/gfx/label.js +190 -0
  121. package/dist/engine/gfx/label.js.map +1 -0
  122. package/dist/engine/gfx/overlap.d.ts +42 -0
  123. package/dist/engine/gfx/overlap.d.ts.map +1 -0
  124. package/dist/engine/gfx/overlap.js +57 -0
  125. package/dist/engine/gfx/overlap.js.map +1 -0
  126. package/dist/engine/gfx/palette.d.ts +29 -0
  127. package/dist/engine/gfx/palette.d.ts.map +1 -0
  128. package/dist/engine/gfx/palette.js +46 -0
  129. package/dist/engine/gfx/palette.js.map +1 -0
  130. package/dist/engine/gfx/png.d.ts +31 -0
  131. package/dist/engine/gfx/png.d.ts.map +1 -0
  132. package/dist/engine/gfx/png.js +165 -0
  133. package/dist/engine/gfx/png.js.map +1 -0
  134. package/dist/engine/gfx/render.d.ts +42 -0
  135. package/dist/engine/gfx/render.d.ts.map +1 -0
  136. package/dist/engine/gfx/render.js +72 -0
  137. package/dist/engine/gfx/render.js.map +1 -0
  138. package/dist/engine/index.d.ts +22 -0
  139. package/dist/engine/index.d.ts.map +1 -0
  140. package/dist/engine/index.js +22 -0
  141. package/dist/engine/index.js.map +1 -0
  142. package/dist/engine/pico8/adapter.d.ts +267 -0
  143. package/dist/engine/pico8/adapter.d.ts.map +1 -0
  144. package/dist/engine/pico8/adapter.js +37 -0
  145. package/dist/engine/pico8/adapter.js.map +1 -0
  146. package/dist/engine/pico8/drive.d.ts +308 -0
  147. package/dist/engine/pico8/drive.d.ts.map +1 -0
  148. package/dist/engine/pico8/drive.js +581 -0
  149. package/dist/engine/pico8/drive.js.map +1 -0
  150. package/dist/engine/pico8/harness.d.ts +146 -0
  151. package/dist/engine/pico8/harness.d.ts.map +1 -0
  152. package/dist/engine/pico8/harness.js +251 -0
  153. package/dist/engine/pico8/harness.js.map +1 -0
  154. package/dist/engine/pico8/index.d.ts +21 -0
  155. package/dist/engine/pico8/index.d.ts.map +1 -0
  156. package/dist/engine/pico8/index.js +21 -0
  157. package/dist/engine/pico8/index.js.map +1 -0
  158. package/dist/engine/pico8/sentinel.d.ts +63 -0
  159. package/dist/engine/pico8/sentinel.d.ts.map +1 -0
  160. package/dist/engine/pico8/sentinel.js +86 -0
  161. package/dist/engine/pico8/sentinel.js.map +1 -0
  162. package/dist/engine/pico8/session-daemon-entry.d.ts +10 -0
  163. package/dist/engine/pico8/session-daemon-entry.d.ts.map +1 -0
  164. package/dist/engine/pico8/session-daemon-entry.js +11 -0
  165. package/dist/engine/pico8/session-daemon-entry.js.map +1 -0
  166. package/dist/engine/pico8/session-daemon-main.d.ts +44 -0
  167. package/dist/engine/pico8/session-daemon-main.d.ts.map +1 -0
  168. package/dist/engine/pico8/session-daemon-main.js +71 -0
  169. package/dist/engine/pico8/session-daemon-main.js.map +1 -0
  170. package/dist/engine/pico8/session-daemon.d.ts +68 -0
  171. package/dist/engine/pico8/session-daemon.d.ts.map +1 -0
  172. package/dist/engine/pico8/session-daemon.js +264 -0
  173. package/dist/engine/pico8/session-daemon.js.map +1 -0
  174. package/dist/engine/pico8/session.d.ts +140 -0
  175. package/dist/engine/pico8/session.d.ts.map +1 -0
  176. package/dist/engine/pico8/session.js +250 -0
  177. package/dist/engine/pico8/session.js.map +1 -0
  178. package/dist/engine/pico8/shell.d.ts +103 -0
  179. package/dist/engine/pico8/shell.d.ts.map +1 -0
  180. package/dist/engine/pico8/shell.js +324 -0
  181. package/dist/engine/pico8/shell.js.map +1 -0
  182. package/dist/engine/pico8/supervisor.d.ts +137 -0
  183. package/dist/engine/pico8/supervisor.d.ts.map +1 -0
  184. package/dist/engine/pico8/supervisor.js +140 -0
  185. package/dist/engine/pico8/supervisor.js.map +1 -0
  186. package/dist/engine/scaffold/index.d.ts +8 -0
  187. package/dist/engine/scaffold/index.d.ts.map +1 -0
  188. package/dist/engine/scaffold/index.js +8 -0
  189. package/dist/engine/scaffold/index.js.map +1 -0
  190. package/dist/engine/scaffold/scaffold.d.ts +49 -0
  191. package/dist/engine/scaffold/scaffold.d.ts.map +1 -0
  192. package/dist/engine/scaffold/scaffold.js +217 -0
  193. package/dist/engine/scaffold/scaffold.js.map +1 -0
  194. package/dist/engine/shrinko/adapter.d.ts +167 -0
  195. package/dist/engine/shrinko/adapter.d.ts.map +1 -0
  196. package/dist/engine/shrinko/adapter.js +107 -0
  197. package/dist/engine/shrinko/adapter.js.map +1 -0
  198. package/dist/engine/shrinko/index.d.ts +13 -0
  199. package/dist/engine/shrinko/index.d.ts.map +1 -0
  200. package/dist/engine/shrinko/index.js +13 -0
  201. package/dist/engine/shrinko/index.js.map +1 -0
  202. package/dist/engine/shrinko/shell.d.ts +69 -0
  203. package/dist/engine/shrinko/shell.d.ts.map +1 -0
  204. package/dist/engine/shrinko/shell.js +156 -0
  205. package/dist/engine/shrinko/shell.js.map +1 -0
  206. package/dist/engine/skills/index.d.ts +8 -0
  207. package/dist/engine/skills/index.d.ts.map +1 -0
  208. package/dist/engine/skills/index.js +8 -0
  209. package/dist/engine/skills/index.js.map +1 -0
  210. package/dist/engine/skills/skills.d.ts +104 -0
  211. package/dist/engine/skills/skills.d.ts.map +1 -0
  212. package/dist/engine/skills/skills.js +149 -0
  213. package/dist/engine/skills/skills.js.map +1 -0
  214. package/dist/index.d.ts +10 -0
  215. package/dist/index.d.ts.map +1 -0
  216. package/dist/index.js +10 -0
  217. package/dist/index.js.map +1 -0
  218. package/dist/version.d.ts +2 -0
  219. package/dist/version.d.ts.map +1 -0
  220. package/dist/version.js +19 -0
  221. package/dist/version.js.map +1 -0
  222. package/package.json +48 -2
  223. package/src/bin.ts +4 -0
  224. package/src/cli.ts +61 -0
  225. package/src/commands/audio.test.ts +280 -0
  226. package/src/commands/audio.ts +410 -0
  227. package/src/commands/export.test.ts +310 -0
  228. package/src/commands/export.ts +316 -0
  229. package/src/commands/gfx-io.test.ts +178 -0
  230. package/src/commands/gfx.test.ts +470 -0
  231. package/src/commands/gfx.ts +645 -0
  232. package/src/commands/init.test.ts +460 -0
  233. package/src/commands/init.ts +193 -0
  234. package/src/commands/lint.test.ts +137 -0
  235. package/src/commands/lint.ts +117 -0
  236. package/src/commands/minify.test.ts +166 -0
  237. package/src/commands/minify.ts +156 -0
  238. package/src/commands/music.test.ts +272 -0
  239. package/src/commands/music.ts +288 -0
  240. package/src/commands/playtest.test.ts +317 -0
  241. package/src/commands/playtest.ts +773 -0
  242. package/src/commands/run.test.ts +238 -0
  243. package/src/commands/run.ts +259 -0
  244. package/src/commands/serve.test.ts +218 -0
  245. package/src/commands/serve.ts +272 -0
  246. package/src/commands/sfx.test.ts +349 -0
  247. package/src/commands/sfx.ts +300 -0
  248. package/src/commands/tokens.test.ts +212 -0
  249. package/src/commands/tokens.ts +168 -0
  250. package/src/commands/verify.test.ts +238 -0
  251. package/src/commands/verify.ts +256 -0
  252. package/src/commands/version.test.ts +70 -0
  253. package/src/commands/version.ts +24 -0
  254. package/src/engine/audio/index.ts +47 -0
  255. package/src/engine/audio/music.test.ts +188 -0
  256. package/src/engine/audio/music.ts +218 -0
  257. package/src/engine/audio/section.test.ts +124 -0
  258. package/src/engine/audio/section.ts +79 -0
  259. package/src/engine/audio/sfx.test.ts +410 -0
  260. package/src/engine/audio/sfx.ts +694 -0
  261. package/src/engine/cart/cart.test.ts +304 -0
  262. package/src/engine/cart/cart.ts +211 -0
  263. package/src/engine/cart/errors.ts +39 -0
  264. package/src/engine/cart/fixtures/crlf.p8 +6 -0
  265. package/src/engine/cart/fixtures/full.p8 +174 -0
  266. package/src/engine/cart/fixtures/header-only.p8 +2 -0
  267. package/src/engine/cart/fixtures/malformed-no-header.p8 +2 -0
  268. package/src/engine/cart/fixtures/minimal.p8 +10 -0
  269. package/src/engine/cart/fixtures/no-trailing-newline.p8 +6 -0
  270. package/src/engine/cart/gfx.ts +195 -0
  271. package/src/engine/cart/index.ts +27 -0
  272. package/src/engine/cart/map.ts +134 -0
  273. package/src/engine/config/config.ts +115 -0
  274. package/src/engine/config/index.ts +12 -0
  275. package/src/engine/gfx/grid.test.ts +128 -0
  276. package/src/engine/gfx/grid.ts +136 -0
  277. package/src/engine/gfx/index.ts +34 -0
  278. package/src/engine/gfx/label.test.ts +105 -0
  279. package/src/engine/gfx/label.ts +229 -0
  280. package/src/engine/gfx/overlap.test.ts +71 -0
  281. package/src/engine/gfx/overlap.ts +66 -0
  282. package/src/engine/gfx/palette.test.ts +35 -0
  283. package/src/engine/gfx/palette.ts +54 -0
  284. package/src/engine/gfx/png.test.ts +111 -0
  285. package/src/engine/gfx/png.ts +196 -0
  286. package/src/engine/gfx/render.test.ts +180 -0
  287. package/src/engine/gfx/render.ts +106 -0
  288. package/src/engine/index.ts +21 -0
  289. package/src/engine/pico8/adapter.ts +303 -0
  290. package/src/engine/pico8/drive.test.ts +415 -0
  291. package/src/engine/pico8/drive.ts +729 -0
  292. package/src/engine/pico8/harness.test.ts +181 -0
  293. package/src/engine/pico8/harness.ts +354 -0
  294. package/src/engine/pico8/index.ts +20 -0
  295. package/src/engine/pico8/sentinel.test.ts +80 -0
  296. package/src/engine/pico8/sentinel.ts +93 -0
  297. package/src/engine/pico8/session-daemon-entry.ts +12 -0
  298. package/src/engine/pico8/session-daemon-main.ts +98 -0
  299. package/src/engine/pico8/session-daemon.test.ts +212 -0
  300. package/src/engine/pico8/session-daemon.ts +306 -0
  301. package/src/engine/pico8/session.test.ts +239 -0
  302. package/src/engine/pico8/session.ts +321 -0
  303. package/src/engine/pico8/shell.test.ts +583 -0
  304. package/src/engine/pico8/shell.ts +412 -0
  305. package/src/engine/pico8/supervisor.test.ts +105 -0
  306. package/src/engine/pico8/supervisor.ts +197 -0
  307. package/src/engine/scaffold/index.ts +15 -0
  308. package/src/engine/scaffold/scaffold.ts +236 -0
  309. package/src/engine/shrinko/adapter.ts +241 -0
  310. package/src/engine/shrinko/fixtures/count-no-compressed.txt +2 -0
  311. package/src/engine/shrinko/fixtures/count-over-budget.txt +3 -0
  312. package/src/engine/shrinko/fixtures/count-under-budget.txt +3 -0
  313. package/src/engine/shrinko/index.ts +33 -0
  314. package/src/engine/shrinko/shell.ts +224 -0
  315. package/src/engine/shrinko/shrinko.test.ts +182 -0
  316. package/src/engine/skills/index.ts +14 -0
  317. package/src/engine/skills/skills.test.ts +376 -0
  318. package/src/engine/skills/skills.ts +205 -0
  319. package/src/index.ts +13 -0
  320. package/src/skills/game-design-reference/SKILL.md +70 -0
  321. package/src/skills/game-jam/SKILL.md +45 -0
  322. package/src/skills/picopilot-art/SKILL.md +62 -0
  323. package/src/skills/picopilot-audio/SKILL.md +194 -0
  324. package/src/skills/picopilot-code/SKILL.md +97 -0
  325. package/src/skills/picopilot-code/reference/README.md +42 -0
  326. package/src/skills/picopilot-code/reference/mode7-racing.md +95 -0
  327. package/src/skills/picopilot-code/reference/platformer.md +119 -0
  328. package/src/skills/picopilot-code/reference/puzzle-grid.md +106 -0
  329. package/src/skills/picopilot-code/reference/rpg-menus-dialog.md +122 -0
  330. package/src/skills/picopilot-code/reference/top-down-adventure.md +117 -0
  331. package/src/skills/picopilot-code/reference/twin-stick-arcade.md +128 -0
  332. package/src/skills/picopilot-debug/SKILL.md +195 -0
  333. package/src/skills/picopilot-overview/SKILL.md +52 -0
  334. package/src/version.ts +22 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The playtest SESSION SUPERVISOR (ADR-0011, prd US #6): the daemon that keeps a
3
+ * driven PICO-8 process ALIVE + PAUSED across the agent's separate CLI turns, and
4
+ * the client the per-verb sub-invocations use to address it BY ID.
5
+ *
6
+ * The problem this solves: `playtest step <id>` / `input <id>` / `shot <id>` are
7
+ * SEPARATE process invocations, but the driven PICO-8 must survive between them
8
+ * (staying alive + paused). A one-shot CLI process cannot hold another process's
9
+ * pipe, so `start` spawns a small DETACHED daemon that owns the live process + its
10
+ * stdin/stdout (via {@link DriveSession}) and listens on a per-session Unix domain
11
+ * socket; each later verb CONNECTS to that socket, sends its request, and gets the
12
+ * structured result back. The session is addressed by an id = the socket + a
13
+ * session dir under a controlled temp base (never `~/Desktop`, never the carts
14
+ * root).
15
+ *
16
+ * Lifecycle robustness (US #6 acceptance):
17
+ * - ORPHAN reaping: the daemon self-reaps after an IDLE timeout (no verb within
18
+ * the window), so a session whose agent never `stop`s it does not leak a live
19
+ * PICO-8 forever. `stop <id>` tears it down promptly.
20
+ * - PICO-8 dying mid-session surfaces as a STRUCTURED error (via
21
+ * {@link SessionError}), not a hang; the next verb reports `process-dead`.
22
+ * - `start` with PICO-8 ABSENT returns the same structured `pico8-not-found`
23
+ * boundary as `run`/`audio render`.
24
+ *
25
+ * ONE session per id (the id IS the socket). Multiple concurrent sessions ARE
26
+ * allowed (distinct ids/dirs/daemons); the tool does not serialize them, an agent
27
+ * that wants isolation uses distinct ids (the default id is random, so parallel
28
+ * `start`s never collide).
29
+ *
30
+ * The pure, CI-testable pieces (the id/path registry, the request/response codec)
31
+ * live here alongside the IO daemon; the live daemon end-to-end is the
32
+ * manual/opt-in tier. The handshake core it drives ({@link DriveSession}) is
33
+ * separately unit-tested against a fake process.
34
+ */
35
+
36
+ import {randomBytes} from 'node:crypto';
37
+ import {existsSync, mkdirSync, readdirSync, rmSync, statSync} from 'node:fs';
38
+ import {tmpdir} from 'node:os';
39
+ import {join} from 'node:path';
40
+
41
+ /** The controlled base dir all session dirs live under (never `~/Desktop`). */
42
+ export const SESSION_BASE_DIRNAME = 'picopilot-playtest-sessions' as const;
43
+
44
+ /**
45
+ * The default IDLE-reap window: a daemon with no verb for this long tears its
46
+ * session (and the live PICO-8) down, so an orphaned session (the agent walked
47
+ * away without `stop`) cannot leak a process indefinitely. Every verb resets it.
48
+ */
49
+ export const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000;
50
+
51
+ /** The session verbs a client can send the daemon over the socket. */
52
+ export type SessionVerb = 'input' | 'step' | 'shot' | 'stop' | 'status';
53
+
54
+ /** A request sent from a verb sub-invocation to the session daemon. */
55
+ export type SessionRequest =
56
+ | {readonly verb: 'input'; readonly held: number}
57
+ | {readonly verb: 'step'; readonly frames: number}
58
+ | {readonly verb: 'shot'}
59
+ | {readonly verb: 'stop'}
60
+ | {readonly verb: 'status'};
61
+
62
+ /** A structured daemon response: either an ok payload or a structured error. */
63
+ export type SessionResponse =
64
+ | {readonly ok: true; readonly value: SessionResponseValue}
65
+ | {readonly ok: false; readonly code: string; readonly message: string};
66
+
67
+ /** The ok payload shape (a superset; each verb fills the relevant fields). */
68
+ export interface SessionResponseValue {
69
+ /** The session id. */
70
+ readonly id: string;
71
+ /** Total frames advanced so far. */
72
+ readonly frame: number;
73
+ /** The ACK line the cart printed for this command (settled-frame proof). */
74
+ readonly ack?: string;
75
+ /** For `shot`: the absolute screenshot path the cart just wrote. */
76
+ readonly screenshot?: string;
77
+ /** For `shot`: the basename the cart named the capture. */
78
+ readonly shotName?: string;
79
+ /** Whether the session is still alive after this verb. */
80
+ readonly alive: boolean;
81
+ }
82
+
83
+ /**
84
+ * Encodes a request/response as a single newline-terminated JSON frame (the
85
+ * socket protocol). Newline-framed so a reader can split a stream into whole
86
+ * messages; kept pure so the codec is unit-tested without a socket.
87
+ */
88
+ export function encodeFrame(msg: SessionRequest | SessionResponse): string {
89
+ return `${JSON.stringify(msg)}\n`;
90
+ }
91
+
92
+ /**
93
+ * A stateful frame decoder mirroring {@link encodeFrame}: feed it raw socket
94
+ * chunks, it yields whole parsed messages (buffering partial lines). Typed loose
95
+ * (`T`) because a client decodes responses and the daemon decodes requests.
96
+ */
97
+ export class FrameDecoder<T> {
98
+ private buffer = '';
99
+ push(chunk: string): T[] {
100
+ this.buffer += chunk;
101
+ const out: T[] = [];
102
+ let nl: number;
103
+ while ((nl = this.buffer.indexOf('\n')) !== -1) {
104
+ const line = this.buffer.slice(0, nl);
105
+ this.buffer = this.buffer.slice(nl + 1);
106
+ if (line.trim().length > 0) out.push(JSON.parse(line) as T);
107
+ }
108
+ return out;
109
+ }
110
+ }
111
+
112
+ /** A resolved session's on-disk layout (all under the controlled base). */
113
+ export interface SessionPaths {
114
+ /** The session id (also the dir name under the base). */
115
+ readonly id: string;
116
+ /** The session dir: `<base>/<id>`. */
117
+ readonly dir: string;
118
+ /** The Unix domain socket the daemon listens on. */
119
+ readonly socket: string;
120
+ /** The `-desktop` dir PICO-8 writes SHOT screenshots into. */
121
+ readonly shotDir: string;
122
+ /** The pidfile written by the daemon (for reap/liveness checks). */
123
+ readonly pidfile: string;
124
+ }
125
+
126
+ /**
127
+ * The session REGISTRY: the pure id/path logic mapping a session id to its
128
+ * on-disk layout under a controlled base dir. No IO beyond the base-dir join, so
129
+ * the id validation + path derivation are unit-tested. The daemon and the client
130
+ * both resolve a session THROUGH this, so they agree on where the socket is.
131
+ */
132
+ export class SessionRegistry {
133
+ readonly base: string;
134
+
135
+ constructor(base?: string) {
136
+ this.base = base ?? join(tmpdir(), SESSION_BASE_DIRNAME);
137
+ }
138
+
139
+ /** A fresh random session id (short, filesystem-safe, collision-free enough). */
140
+ static freshId(): string {
141
+ return `s-${randomBytes(6).toString('hex')}`;
142
+ }
143
+
144
+ /**
145
+ * Validates a caller-supplied id: filesystem-safe, no path traversal, so an id
146
+ * can never escape the controlled base. Returns the id or throws a plain Error
147
+ * (the command maps it to a structured refusal).
148
+ */
149
+ static validateId(id: string): string {
150
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(id) || id === '.' || id === '..') {
151
+ throw new Error(
152
+ `invalid session id "${id}": use 1-64 chars of [A-Za-z0-9._-]`,
153
+ );
154
+ }
155
+ return id;
156
+ }
157
+
158
+ /** Resolves a session id to its {@link SessionPaths} under the base. */
159
+ resolve(id: string): SessionPaths {
160
+ SessionRegistry.validateId(id);
161
+ const dir = join(this.base, id);
162
+ return {
163
+ id,
164
+ dir,
165
+ socket: join(dir, 'sock'),
166
+ shotDir: join(dir, 'shots'),
167
+ pidfile: join(dir, 'pid'),
168
+ };
169
+ }
170
+
171
+ /** Creates the session dir + shot dir (idempotent), returning the paths. */
172
+ create(id: string): SessionPaths {
173
+ const paths = this.resolve(id);
174
+ mkdirSync(paths.shotDir, {recursive: true});
175
+ return paths;
176
+ }
177
+
178
+ /** The ids of all sessions with a dir present under the base (for listing/reap). */
179
+ list(): string[] {
180
+ if (!existsSync(this.base)) return [];
181
+ return readdirSync(this.base)
182
+ .filter((name) => {
183
+ try {
184
+ return statSync(join(this.base, name)).isDirectory();
185
+ } catch {
186
+ return false;
187
+ }
188
+ })
189
+ .sort();
190
+ }
191
+
192
+ /** Removes a session's whole dir (the reap/cleanup, after the daemon exits). */
193
+ remove(id: string): void {
194
+ const {dir} = this.resolve(id);
195
+ rmSync(dir, {recursive: true, force: true});
196
+ }
197
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The static content `picopilot init` scaffolds: the `#include`-discipline
3
+ * `main.p8` (built through the cart model), the editable `main.lua`, the curated
4
+ * `AGENTS.md` PICO-8 reference, and the `picopilot.json` config. See
5
+ * `scaffold.ts` for the single scaffolding model and why these are the files.
6
+ */
7
+ export {
8
+ agentsMd,
9
+ LUA_INCLUDE_LINE,
10
+ mainLua,
11
+ mainP8,
12
+ picopilotJson,
13
+ type ScaffoldFile,
14
+ scaffoldFiles,
15
+ } from './scaffold.js';
@@ -0,0 +1,236 @@
1
+ import {Cart} from '../cart/index.js';
2
+ import {defaultConfigFile} from '../config/index.js';
3
+
4
+ /**
5
+ * The static content `picopilot init` writes into a fresh cart folder.
6
+ *
7
+ * The single scaffolding model (US #1): the `.p8`'s `__lua__` section is exactly
8
+ * `#include main.lua`, and the plain-Lua `main.lua` is the file the agent edits,
9
+ * so the agent never hand-writes the cart's binary hex sections. `main.p8` is
10
+ * built THROUGH the cart model (not a hand-written string) so init and every
11
+ * later cart-touching command share one serializer and the output is guaranteed
12
+ * to round-trip.
13
+ */
14
+
15
+ /** The PICO-8 cart format version init emits (the current stable text-format version). */
16
+ const CART_VERSION = 42;
17
+
18
+ /**
19
+ * The one line of code in the scaffolded cart. PICO-8's `#include` pulls the
20
+ * external Lua file in at load time; keeping it the ONLY thing in `__lua__` is
21
+ * the `#include` discipline the whole toolchain relies on.
22
+ */
23
+ export const LUA_INCLUDE_LINE = '#include main.lua';
24
+
25
+ /**
26
+ * Builds the scaffolded `main.p8` via the cart model. `__lua__` holds only the
27
+ * `#include`; the cart carries no binary sections yet (a fresh cart has no gfx /
28
+ * map / sfx / music); those are added later by the gfx/audio commands.
29
+ */
30
+ export function mainP8(): string {
31
+ const cart = Cart.parse(
32
+ `pico-8 cartridge // http://www.pico-8.com\nversion ${CART_VERSION}\n__lua__\n`,
33
+ );
34
+ cart.setSection('lua', `${LUA_INCLUDE_LINE}\n`);
35
+ return cart.serialize();
36
+ }
37
+
38
+ /**
39
+ * The starter `main.lua`: a minimal but RUNNABLE cart (the `_init/_update/_draw`
40
+ * loop the API reference calls out as load-bearing) so a fresh folder boots
41
+ * instead of being inert. Intentionally tiny: it is a starting point the agent
42
+ * replaces, not a demo.
43
+ */
44
+ export function mainLua(): string {
45
+ return `-- main.lua, the plain-Lua source picopilot's main.p8 #includes.
46
+ -- Edit THIS file; never hand-write main.p8's binary sections.
47
+
48
+ function _init()
49
+ end
50
+
51
+ function _update()
52
+ end
53
+
54
+ function _draw()
55
+ cls()
56
+ print("hello picopilot", 36, 60, 7)
57
+ end
58
+ `;
59
+ }
60
+
61
+ /** The scaffolded `picopilot.json` (incur config: precedence argv > config > defaults). */
62
+ export function picopilotJson(): string {
63
+ return defaultConfigFile();
64
+ }
65
+
66
+ /**
67
+ * The curated PICO-8 reference written to `AGENTS.md`.
68
+ *
69
+ * This is CONTEXT an agent loads every turn, so it is CURATED from
70
+ * `work/notes/findings/pico8-api-reference.md`, not a dump of the manual: the
71
+ * hard specs, the `_init/_update/_draw` loop, the fixed palette, the memory map
72
+ * + the gfx/map overlap WARNING, the grouped most-used API, and the token
73
+ * discipline, the things models get wrong without a reference.
74
+ */
75
+ export function agentsMd(): string {
76
+ return `# PICO-8 cart context (generated by \`picopilot init\`)
77
+
78
+ You are editing a PICO-8 cart with picopilot. This file is your always-loaded
79
+ reference: the constraints, the loop, the palette, the memory map (+ the overlap
80
+ warning), the most-used API, and how to stay under the token budget. It is
81
+ curated from PICO-8's manual for the things models get wrong without a reminder.
82
+
83
+ ## The \`#include\` discipline (how this cart is laid out)
84
+
85
+ - \`main.p8\` is the cart. Its \`__lua__\` section is a single line: \`#include main.lua\`.
86
+ - \`main.lua\` is the plain Lua you EDIT. Never hand-write \`main.p8\`'s binary
87
+ sections (\`__gfx__\`, \`__map__\`, \`__sfx__\`, \`__music__\`), use picopilot's
88
+ \`gfx\` / audio commands, which edit them safely and round-trippably.
89
+
90
+ ## Hard specs (the constraints that shape everything)
91
+
92
+ - Display: **128x128**, a **fixed 16-colour palette** (indices 0..15).
93
+ - Code: **Lua**, max **8192 tokens** (the #1 thing to watch). Run \`picopilot tokens\`.
94
+ - Sprites: 256 8x8 sprites in a base bank (0..127) plus a shared bank (128..255).
95
+ - Map: 128x32 tiles + a shared half that ALIASES sprites 128..255 (see below).
96
+ - Sound: 4 channels, 64 SFX slots; music is patterns of up to 4 SFX references.
97
+ - Numbers: 16.16 fixed point, range \`-32768.0 .. 32767.99\`.
98
+
99
+ ## The execution loop (every cart)
100
+
101
+ - \`_init()\`: runs once at startup.
102
+ - \`_update()\`: runs every update tick at 30fps (\`_update60()\` for 60fps).
103
+ - \`_draw()\`: runs once per visible frame.
104
+
105
+ A cart with none of these is inert. Keep them defined.
106
+
107
+ ## The fixed 16-colour palette (index → RGB)
108
+
109
+ In a char grid, \`.\` = transparent and \`0-F\` = these indices.
110
+
111
+ | idx | hex | name | R | G | B |
112
+ |-----|-----|-------------|-----|-----|-----|
113
+ | 0 | 0 | black | 0 | 0 | 0 |
114
+ | 1 | 1 | dark-blue | 29 | 43 | 83 |
115
+ | 2 | 2 | dark-purple | 126 | 37 | 83 |
116
+ | 3 | 3 | dark-green | 0 | 135 | 81 |
117
+ | 4 | 4 | brown | 171 | 82 | 54 |
118
+ | 5 | 5 | dark-grey | 95 | 87 | 79 |
119
+ | 6 | 6 | light-grey | 194 | 195 | 199 |
120
+ | 7 | 7 | white | 255 | 241 | 232 |
121
+ | 8 | 8 | red | 255 | 0 | 77 |
122
+ | 9 | 9 | orange | 255 | 163 | 0 |
123
+ | 10 | a | yellow | 255 | 236 | 39 |
124
+ | 11 | b | green | 0 | 228 | 54 |
125
+ | 12 | c | blue | 41 | 173 | 255 |
126
+ | 13 | d | lavender | 131 | 118 | 156 |
127
+ | 14 | e | pink | 255 | 119 | 168 |
128
+ | 15 | f | peach | 255 | 204 | 170 |
129
+
130
+ ## Memory map + the gfx/map overlap warning
131
+
132
+ \`\`\`
133
+ 0x0000 gfx (spritesheet, sprites 0..127)
134
+ 0x1000 shared gfx / map2 <-- sprites 128..255 ALIAS the bottom half of the map
135
+ 0x2000 map
136
+ 0x3000 gfx flags
137
+ 0x3100 music
138
+ 0x3200 sfx
139
+ 0x4300 user data
140
+ 0x5e00 persistent cart data (256 bytes)
141
+ 0x5f00 draw state
142
+ 0x5f40 hardware state
143
+ 0x5f80 gpio pins
144
+ 0x6000 screen (8k)
145
+ \`\`\`
146
+
147
+ **Overlap warning:** sprites 128..255 (\`0x1000\`-\`0x1fff\`) occupy the SAME memory
148
+ as \`__map__\` rows 32..63. Writing a sprite in that range clobbers the map tiles
149
+ there. \`picopilot gfx set\` smart-refuses such a write when real map tiles are at
150
+ risk and nothing authorised the loss (\`--allow-map-overlap\` or \`allowMapOverlap\`
151
+ in \`picopilot.json\`), so you never silently destroy map data you cannot see.
152
+
153
+ ## Most-used API (grouped)
154
+
155
+ - **Graphics:** \`cls([col])\`, \`spr(n,x,y,[w,h],[fx],[fy])\`, \`sspr(...)\`,
156
+ \`map(cx,cy,sx,sy,cw,ch,[layer])\`, \`pset/pget\`, \`sget/sset\`,
157
+ \`print(str,[x,y,[col]])\`, \`rect/rectfill\`, \`circ/circfill\`, \`oval/ovalfill\`,
158
+ \`line\`, \`pal(c0,c1,[p])\`, \`palt(col,t)\`, \`camera([x,y])\`, \`clip([x,y,w,h])\`,
159
+ \`color(col)\`, \`fillp(mask)\`, \`tline(...)\`, \`flip()\`.
160
+ - **Map / flags:** \`mget(x,y)\`, \`mset(x,y,v)\`, \`fget(n,[f])\`, \`fset(n,[f],v)\`.
161
+ - **Input:** \`btn([i,[p]])\`, \`btnp([i,[p]])\` (buttons 0..5 = left,right,up,down,O/Z,X).
162
+ - **Audio:** \`sfx(n,[channel,[offset]])\`, \`music([n,[fade,[mask]]])\`.
163
+ - **Math:** \`abs\`, \`flr\` (\`-flr(-x)\` = ceil), \`min\`, \`max\`, \`mid\`, \`rnd(x)\`,
164
+ \`srand(x)\`, \`sgn\`, \`sqrt\`, \`sin(x)\`/\`cos(x)\` (range 0..1, sin inverted),
165
+ \`atan2(x,y)\`.
166
+ - **Tables:** \`add\`, \`del\`, \`deli\`, \`count\`, \`all(t)\`, \`foreach(t,f)\`, \`pairs\`, \`ipairs\`.
167
+ - **Memory:** \`peek\`, \`poke\`, \`memcpy\`, \`memset\`, \`cstore\`, \`reload\`.
168
+ - **Cart data:** \`cartdata(id)\`, \`dget(i)\`, \`dset(i,v)\`.
169
+ - **Debug:** \`printh(str)\` (to host stdout, \`picopilot run\` captures it), \`stat(n)\`.
170
+
171
+ ## Gotchas that silently break carts (these differ from normal Lua/math)
172
+
173
+ These compile and run but behave WRONG. They are the traps models hit most:
174
+
175
+ - \`sin(t)\`/\`cos(t)\` take TURNS (0..1), NOT radians, and \`sin\` is INVERTED for
176
+ screen space (+y is down). A full circle is \`t: 0->1\`. Do not multiply by
177
+ \`2*pi\`; do not re-negate \`sin\`.
178
+ - \`/\` does NOT truncate (\`5/2\`=\`2.5\`). Integer divide is \`\\\` (\`5\\2\`=\`2\`), which
179
+ floors toward negative infinity (\`-5\\2\`=\`-3\`).
180
+ - \`rnd(x)\` returns a FLOAT in [0,x). Use \`flr(rnd(x))\` for an integer;
181
+ \`rnd(table)\` returns a random element.
182
+ - Tables are 1-BASED. \`del(t,val)\` removes by VALUE, \`deli(t,i)\` by INDEX.
183
+ Deleting the current item inside \`foreach\` is safe; inside an ascending
184
+ \`for i=1,#t\` it ERRORS once the list shrinks (iterate descending, or foreach).
185
+ - Draw state PERSISTS across frames: \`camera\`, \`pal\`, \`palt\`, \`clip\`, \`color\`,
186
+ \`fillp\` stay set until reset (no-arg form: \`camera()\`, \`pal()\`). Reset before
187
+ the HUD or offsets/palette swaps leak.
188
+ - Numbers are 16.16 fixed point; overflow WRAPS at ~32767. Drawing coords are
189
+ floored to integer pixels (keep true position as a float, let draw floor it).
190
+ - Variables are GLOBAL unless \`local\`. The \`x = cond and a or b\` ternary returns
191
+ \`b\` when \`a\` is falsy (\`false\`/\`nil\`); use an explicit \`if\` then.
192
+
193
+ (The \`picopilot-code\` skill expands these, and carries per-genre code references:
194
+ platformer / puzzle-grid / twin-stick-arcade / top-down-adventure and more.)
195
+
196
+ ## Token discipline (stay under 8192)
197
+
198
+ Models blow the budget by writing verbose Lua. Prefer PICO-8 shorthands:
199
+
200
+ - \`?\` for \`print\`.
201
+ - inline if: \`if(c) x=1\`.
202
+ - compound assignment: \`+=\`, \`-=\`, \`*=\`, \`/=\`.
203
+ - \`\\\` for integer divide.
204
+ - reuse locals; avoid needless globals.
205
+
206
+ Run \`picopilot tokens\` each iteration to see the count and where it goes; when
207
+ over budget, \`picopilot minify\` (safe-only) reclaims tokens without changing
208
+ behaviour.
209
+ `;
210
+ }
211
+
212
+ /**
213
+ * One scaffolded file: its cart-relative name and its exact bytes. `init`
214
+ * iterates these, so adding a file is a one-line change here and the tests
215
+ * assert against this list (the "exactly these files" contract).
216
+ */
217
+ export interface ScaffoldFile {
218
+ readonly name: string;
219
+ readonly content: string;
220
+ }
221
+
222
+ /**
223
+ * The complete set of files `picopilot init` writes into a fresh cart folder,
224
+ * in a stable order: the cart, its editable Lua, the agent-context reference,
225
+ * and the config. Exactly FOUR files (the acceptance contract). `AGENTS.md` is
226
+ * the cross-agent context filename; Claude Code, Codex, and others read it, so
227
+ * one portable file beats duplicating identical content into `CLAUDE.md`.
228
+ */
229
+ export function scaffoldFiles(): ScaffoldFile[] {
230
+ return [
231
+ {name: 'main.p8', content: mainP8()},
232
+ {name: 'main.lua', content: mainLua()},
233
+ {name: 'AGENTS.md', content: agentsMd()},
234
+ {name: 'picopilot.json', content: picopilotJson()},
235
+ ];
236
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * The shrinko adapter SEAM: a typed interface every shrinko-backed command
3
+ * (`tokens`, and later `lint`, `minify`, `gfx import/export`, `verify`) talks
4
+ * to, plus the discriminated result shape they all return.
5
+ *
6
+ * The seam exists so the command layer never knows HOW shrinko is reached. The
7
+ * v1 implementation shells out to a user-installed Python `shrinko8` (see
8
+ * `shell.ts` and ADR-0001); a future native-TS implementation can implement the
9
+ * SAME {@link ShrinkoAdapter} interface and drop in without touching a single
10
+ * command. The command-facing contract is entirely in this file.
11
+ *
12
+ * The two-tier capability contract (ADR-0002) lives in the RESULT type: every
13
+ * adapter method returns a {@link ShrinkoResult}, a discriminated union of a
14
+ * success value OR the structured {@link ShrinkoNotFound} failure. Absent
15
+ * shrinko is therefore NOT an exception a command must try/catch, it is a
16
+ * first-class `{ok:false, reason:'shrinko-not-found', ...}` value a command maps
17
+ * onto incur's error envelope with a nonzero exit. Never a crash, never a hollow
18
+ * success.
19
+ */
20
+
21
+ /** The PyPI package name in the remedy: `uv pip install shrinko` (NOT `shrinko8`). */
22
+ export const SHRINKO_REMEDY = 'uv pip install shrinko' as const;
23
+
24
+ /** What shrinko needs to run: Python 3.8+ (the module is `shrinko8`). */
25
+ export const SHRINKO_NEEDS = ['python>=3.8'] as const;
26
+
27
+ /**
28
+ * The structured "shrinko is not installed" failure (ADR-0002). Its shape is
29
+ * fixed by the prd's two-tier contract (US #17): `ok:false`, a machine-readable
30
+ * `reason`, the EXACT `remedy` string, and the `needs` list. A command turns
31
+ * this into an incur `error({ code: reason, ... })` with a nonzero exit.
32
+ */
33
+ export interface ShrinkoNotFound {
34
+ readonly ok: false;
35
+ readonly reason: 'shrinko-not-found';
36
+ /** The EXACT remedy: `uv pip install shrinko` (package is `shrinko`, module `shrinko8`). */
37
+ readonly remedy: typeof SHRINKO_REMEDY;
38
+ /** Prerequisites the remedy assumes are present. */
39
+ readonly needs: readonly string[];
40
+ }
41
+
42
+ /** A successful adapter call carrying the parsed value. */
43
+ export interface ShrinkoOk<T> {
44
+ readonly ok: true;
45
+ readonly value: T;
46
+ }
47
+
48
+ /**
49
+ * The result every {@link ShrinkoAdapter} method returns: either a parsed value
50
+ * or the structured {@link ShrinkoNotFound}. Absence is a value, not a throw, so
51
+ * the two-tier contract is total and type-checked at every call site.
52
+ */
53
+ export type ShrinkoResult<T> = ShrinkoOk<T> | ShrinkoNotFound;
54
+
55
+ /**
56
+ * The parsed `--count` report (US #3). `tokens`/`chars`/`compressed` are the
57
+ * raw counts shrinko prints; `pct` fields are shrinko's own reported
58
+ * percentages (its `--count` prints `tokens: 8053 98%`, i.e. against PICO-8's
59
+ * limits), preserved verbatim so a command can show them without recomputing.
60
+ * `compressed`/`compressedPct` are optional because shrinko omits the
61
+ * compressed line for input formats where it does not apply.
62
+ */
63
+ export interface CountReport {
64
+ readonly tokens: number;
65
+ readonly tokensPct: number;
66
+ readonly chars: number;
67
+ readonly charsPct: number;
68
+ readonly compressed: number | undefined;
69
+ readonly compressedPct: number | undefined;
70
+ }
71
+
72
+ /**
73
+ * The shrinko capability seam. One method per shrinko-backed operation; only
74
+ * `count` exists in this task, the rest (lint, minify, convert) land with their
75
+ * commands and extend THIS interface rather than forking a new one.
76
+ *
77
+ * Every method is async (the shell-out is async) and returns a
78
+ * {@link ShrinkoResult}, so absence is handled uniformly at each seam.
79
+ */
80
+ export interface ShrinkoAdapter {
81
+ /**
82
+ * Runs shrinko's `--count` on the cart at `cartPath` and parses the
83
+ * `tokens/chars/compressed` report. Returns {@link ShrinkoNotFound} when
84
+ * shrinko is not installed.
85
+ */
86
+ count(cartPath: string): Promise<ShrinkoResult<CountReport>>;
87
+
88
+ /**
89
+ * Runs shrinko's `--lint` on the cart at `cartPath` and parses the warnings
90
+ * (undefined globals, unused/duplicate locals, etc.) into structured
91
+ * {@link LintFinding}s. Lint warnings are DATA, not a failure: the report is
92
+ * returned with `findings` (possibly empty), not a throw. Returns
93
+ * {@link ShrinkoNotFound} when shrinko is not installed.
94
+ */
95
+ lint(cartPath: string): Promise<ShrinkoResult<LintReport>>;
96
+
97
+ /**
98
+ * Runs shrinko's SAFE minification on `cartPath` and writes the result to
99
+ * `outPath` (a SEPARATE artifact; the source is never touched, see ADR-0007).
100
+ * Returns the before/after token counts. Returns {@link ShrinkoNotFound} when
101
+ * shrinko is not installed.
102
+ */
103
+ minify(
104
+ cartPath: string,
105
+ outPath: string,
106
+ ): Promise<ShrinkoResult<MinifyReport>>;
107
+
108
+ /**
109
+ * Exports the cart's spritesheet as a 128x128 PNG to `pngPath` (a raw
110
+ * round-trip image for external tools, distinct from `gfx render`'s upscaled
111
+ * JUDGE image). Returns {@link ShrinkoNotFound} when shrinko is not installed.
112
+ */
113
+ exportSpritesheet(
114
+ cartPath: string,
115
+ pngPath: string,
116
+ ): Promise<ShrinkoResult<void>>;
117
+
118
+ /**
119
+ * Imports a 128x128 spritesheet PNG into `srcCartPath`'s `__gfx__`, MERGING
120
+ * (only the gfx section is replaced; code/map/sfx/music are preserved) and
121
+ * writing the result to `outCartPath`. Returns {@link ShrinkoNotFound} when
122
+ * shrinko is not installed.
123
+ */
124
+ importSpritesheet(
125
+ srcCartPath: string,
126
+ pngPath: string,
127
+ outCartPath: string,
128
+ ): Promise<ShrinkoResult<void>>;
129
+ }
130
+
131
+ /** One shrinko lint warning, parsed from a `file:line:col: message` line. */
132
+ export interface LintFinding {
133
+ readonly line: number;
134
+ readonly col: number;
135
+ readonly message: string;
136
+ }
137
+
138
+ /** The parsed `--lint` result: every warning shrinko reported (empty when clean). */
139
+ export interface LintReport {
140
+ readonly findings: readonly LintFinding[];
141
+ }
142
+
143
+ /** The parsed `minify` result: token counts before and after (ADR-0007's delta report). */
144
+ export interface MinifyReport {
145
+ /** Tokens in the source cart. */
146
+ readonly beforeTokens: number;
147
+ /** Tokens in the minified artifact. */
148
+ readonly afterTokens: number;
149
+ /** Absolute path of the written minified cart. */
150
+ readonly outPath: string;
151
+ }
152
+
153
+ /** The frozen {@link ShrinkoNotFound} value, so every call site returns the same shape. */
154
+ export function shrinkoNotFound(): ShrinkoNotFound {
155
+ return {
156
+ ok: false,
157
+ reason: 'shrinko-not-found',
158
+ remedy: SHRINKO_REMEDY,
159
+ needs: [...SHRINKO_NEEDS],
160
+ };
161
+ }
162
+
163
+ /**
164
+ * Parses shrinko's `--count` output into a {@link CountReport}.
165
+ *
166
+ * shrinko prints lines like:
167
+ *
168
+ * ```
169
+ * tokens: 8053 98%
170
+ * chars: 30320 46%
171
+ * compressed: 12176 77%
172
+ * ```
173
+ *
174
+ * (the compressed line is absent for some input formats). Parsing is
175
+ * line-oriented and tolerant of surrounding output/whitespace: it scans for the
176
+ * `tokens:`/`chars:`/`compressed:` labels anywhere in the combined stdout+stderr
177
+ * and pulls the count and percentage off each. Throws if the required
178
+ * `tokens:`/`chars:` lines are missing, so a shrinko that ran but produced
179
+ * unparseable output surfaces as a real error rather than a silent zero.
180
+ */
181
+ export function parseCount(output: string): CountReport {
182
+ const line = (label: string): {n: number; pct: number} | undefined => {
183
+ // `label: <count> <pct>%` (count and percent may be separated by any run
184
+ // of spaces; the `%` is optional-tolerant but shrinko always prints it).
185
+ const re = new RegExp(`^\\s*${label}:\\s*(\\d+)\\s+(\\d+)\\s*%`, 'm');
186
+ const m = re.exec(output);
187
+ if (m === null) return undefined;
188
+ return {n: Number(m[1]), pct: Number(m[2])};
189
+ };
190
+
191
+ const tokens = line('tokens');
192
+ const chars = line('chars');
193
+ if (tokens === undefined || chars === undefined) {
194
+ throw new ShrinkoParseError(
195
+ `could not parse shrinko --count output (missing tokens/chars lines):\n${output}`,
196
+ );
197
+ }
198
+ const compressed = line('compressed');
199
+
200
+ return {
201
+ tokens: tokens.n,
202
+ tokensPct: tokens.pct,
203
+ chars: chars.n,
204
+ charsPct: chars.pct,
205
+ compressed: compressed?.n,
206
+ compressedPct: compressed?.pct,
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Parses shrinko's `--lint` output into {@link LintFinding}s. shrinko prints a
212
+ * `Lint warnings:` header then one `file:line:col: message` per warning; a clean
213
+ * cart prints nothing. Parsing scans the combined stdout+stderr for the
214
+ * `:line:col: message` shape (tolerant of the filename prefix, which we drop),
215
+ * so an empty result is a CLEAN cart, not an error. Unlike parseCount this never
216
+ * throws: no recognisable lines simply means zero findings.
217
+ */
218
+ export function parseLint(output: string): LintReport {
219
+ const findings: LintFinding[] = [];
220
+ // Match `<anything>:<line>:<col>: <message>` (the filename may itself contain
221
+ // colons/paths, so anchor on the two trailing numeric groups before the msg).
222
+ const re = /:(\d+):(\d+):\s*(.+?)\s*$/gm;
223
+ for (const m of output.matchAll(re)) {
224
+ findings.push({
225
+ line: Number(m[1]),
226
+ col: Number(m[2]),
227
+ message: m[3] ?? '',
228
+ });
229
+ }
230
+ return {findings};
231
+ }
232
+
233
+ /**
234
+ * Thrown when shrinko RAN but its output could not be parsed (a shrinko
235
+ * version/format drift), distinct from shrinko being ABSENT (which is the
236
+ * structured {@link ShrinkoNotFound} value, not a throw). Commands surface this
237
+ * as a `shrinko-failed` error, separate from `shrinko-not-found`.
238
+ */
239
+ export class ShrinkoParseError extends Error {
240
+ override readonly name = 'ShrinkoParseError';
241
+ }
@@ -0,0 +1,2 @@
1
+ tokens: 300 3%
2
+ chars: 1200 1%