@olegkoval/agent-skills 1.60.0 → 1.62.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 (223) hide show
  1. package/.github/copilot-instructions.md +2 -0
  2. package/.github/prompts/carousel-maker.prompt.md +102 -0
  3. package/.github/prompts/shorts-animator.prompt.md +103 -0
  4. package/.kiro/steering/carousel-maker.md +101 -0
  5. package/.kiro/steering/shorts-animator.md +102 -0
  6. package/.windsurf/rules/carousel-maker.md +100 -0
  7. package/.windsurf/rules/shorts-animator.md +101 -0
  8. package/README.md +4 -2
  9. package/adapters/claude/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  10. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  11. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  12. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  13. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  14. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  15. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  16. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  17. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  18. package/adapters/claude/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  19. package/adapters/claude/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  20. package/adapters/claude/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  21. package/adapters/claude/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  22. package/adapters/claude/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  23. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  24. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  25. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  26. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  27. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  28. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  29. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  30. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  31. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  32. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  33. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  34. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  35. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  36. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  37. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  38. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  39. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  40. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  41. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  42. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  43. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  44. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  45. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  46. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  47. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  48. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  49. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  50. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  51. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  52. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  53. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  54. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  55. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  56. package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  57. package/adapters/claude/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
  58. package/adapters/claude/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
  59. package/adapters/claude/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
  60. package/adapters/codex/olko-creative/README.md +2 -0
  61. package/adapters/cursor/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  62. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  63. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  64. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  65. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  66. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  67. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  68. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  69. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  70. package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  71. package/adapters/cursor/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  72. package/adapters/cursor/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  73. package/adapters/cursor/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  74. package/adapters/cursor/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  75. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  76. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  77. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  78. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  79. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  80. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  81. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  82. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  83. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  84. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  85. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  86. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  87. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  88. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  89. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  90. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  91. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  92. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  93. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  94. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  95. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  96. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  97. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  98. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  99. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  100. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  101. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  102. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  103. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  104. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  105. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  106. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  107. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  108. package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  109. package/adapters/grok/olko-creative/skills/carousel-maker/SKILL.md +112 -0
  110. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  111. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  112. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  113. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  114. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  115. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  116. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  117. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  118. package/adapters/grok/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  119. package/adapters/grok/olko-creative/skills/shorts-animator/SKILL.md +113 -0
  120. package/adapters/grok/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  121. package/adapters/grok/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  122. package/adapters/grok/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  123. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  124. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  125. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  126. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  127. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  128. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  129. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  130. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  131. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  132. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  133. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  134. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  135. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  136. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  137. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  138. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  139. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  140. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  141. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  142. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  143. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  144. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  145. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  146. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  147. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  148. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  149. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  150. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  151. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  152. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  153. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  154. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  155. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  156. package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  157. package/adapters/hermes/olko-creative/README.md +2 -0
  158. package/catalog/skills.json +46 -0
  159. package/package.json +1 -1
  160. package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
  161. package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
  162. package/plugins/olko-creative/skills/carousel-maker/SKILL.md +111 -0
  163. package/plugins/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
  164. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
  165. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
  166. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
  167. package/plugins/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
  168. package/plugins/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
  169. package/plugins/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
  170. package/plugins/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
  171. package/plugins/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
  172. package/plugins/olko-creative/skills/shorts-animator/SKILL.md +112 -0
  173. package/plugins/olko-creative/skills/shorts-animator/references/kit.md +59 -0
  174. package/plugins/olko-creative/skills/shorts-animator/references/looks.md +32 -0
  175. package/plugins/olko-creative/skills/shorts-animator/references/writing.md +9 -0
  176. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
  177. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
  178. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
  179. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
  180. package/plugins/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
  181. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
  182. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
  183. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
  184. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
  185. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
  186. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
  187. package/plugins/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
  188. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
  189. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
  190. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
  191. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
  192. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
  193. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
  194. package/plugins/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
  195. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
  196. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
  197. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
  198. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
  199. package/plugins/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
  200. package/plugins/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
  201. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
  202. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
  203. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
  204. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
  205. package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
  206. package/plugins/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
  207. package/plugins/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
  208. package/plugins/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
  209. package/plugins/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
  210. package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
  211. package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
  212. package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
  213. package/plugins/olko-github-pr/skills/lekker-review/SKILL.md +30 -0
  214. package/plugins/olko-github-pr/skills/lekker-review/references/context-gathering.md +3 -0
  215. package/plugins/olko-github-pr/skills/lekker-review/scripts/finding-state.mjs +207 -0
  216. package/plugins/olko-github-pr/skills/lekker-review/workflow.js +46 -3
  217. package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
  218. package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
  219. package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
  220. package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
  221. package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
  222. package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
  223. package/scripts/lib/catalog.mjs +2 -1
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * Shorts Animator: a script and a look in, an animated short out.
4
+ *
5
+ * SA new <dir> [--look oil] [--format 9:16] a project: short.json, scenes.js
6
+ * SA looks the looks, moods and voices, one line each
7
+ * SA check <dir> finds what a render would get wrong, in a second, no browser
8
+ * SA voice <dir> narrates every line (cached) and prints the timing
9
+ * SA frames <dir> one painted frame per shot -> frames/sheet.jpg
10
+ * SA still <dir> <shot> [at] one shot, full size -> frames/shot-NN.jpg
11
+ * SA render <dir> the MP4: voice, score, every drawing -> <title>.mp4
12
+ * SA set <dir> key=value... change short.json from the command line (JSON values)
13
+ * SA doctor checks the machine and renders a 3-second test
14
+ *
15
+ * SA = node <skill-dir>/scripts/sa.ts
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { check, report } from "./domain/check.ts";
20
+ import { resolveLook, pageState } from "./domain/look.ts";
21
+ import { parseAssignment, setPath } from "./domain/patch.ts";
22
+ import { isJson, isMap, parseShort } from "./domain/schema.ts";
23
+ import { timeline } from "./domain/timeline.ts";
24
+ import type { Json, JsonMap, Result, Short, Take, Timeline } from "./domain/types.ts";
25
+ import { fail, ok } from "./domain/types.ts";
26
+ import type { Page } from "./edge/chrome.ts";
27
+ import { doctor } from "./edge/doctor.ts";
28
+ import { encoder, jpeg, mkdir, readJson, stillSheet, videoSheet, writeJson } from "./edge/media.ts";
29
+ import { pageErrors, paintPage } from "./edge/page.ts";
30
+ import { kitNames, probeScenes } from "./edge/probe.ts";
31
+ import { MOODS, soundtrack } from "./edge/sound.ts";
32
+ import { cachedTakes, narrate } from "./edge/voice.ts";
33
+
34
+ const LOOKS = path.join(import.meta.dirname, "looks");
35
+ const VOICES = "bm_george (warm British man, default), am_michael, am_adam, af_heart, af_nova, bf_emma, bf_isabella";
36
+ const NEW_SCENES = `// One function per shot. k is the drawing kit (references/kit.md).
37
+ window.SCENES = {
38
+ opening: (k) => {
39
+ k.sky({ stops: ["#f3ecd2", "#e9dfb7"] });
40
+ k.ridge({ y: 0.52, amp: 0.02, freq: 4, color: "#4f7a3a", shape: "bush" });
41
+ k.ground({ y: 0.52, color: "#8fbf3a", far: "#c3cf5a", stripes: 14 });
42
+ k.person({ x: 0.5, y: 0.7, h: 0.22, pose: "walk", facing: "front" });
43
+ },
44
+ };
45
+ `;
46
+
47
+ const unwrap = <T>(r: Result<T>): T => {
48
+ if (!r.ok) throw new Error(r.errors.join("\n "));
49
+ return r.value;
50
+ };
51
+
52
+ const slug = (s: string): string =>
53
+ s.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "short";
54
+
55
+ const twosName = (n: number): string => (n === 1 ? "ones" : n === 2 ? "twos" : `${n}s`);
56
+
57
+ const lookFiles = (): readonly string[] => fs.readdirSync(LOOKS).filter((f) => f.endsWith(".json")).toSorted();
58
+
59
+ const namedLook = (name: string): Result<JsonMap> => {
60
+ const f = path.join(LOOKS, `${name}.json`);
61
+ if (!fs.existsSync(f)) return fail(`no look "${name}" (SA looks lists them)`);
62
+ const v = readJson(f);
63
+ return isMap(v) ? ok(v) : fail(`looks/${name}.json is not an object`);
64
+ };
65
+
66
+ const shortFile = (dir: string): string => path.join(dir, "short.json");
67
+
68
+ function rawShort(dir: string): Json {
69
+ const f = shortFile(dir);
70
+ if (!fs.existsSync(f)) throw new Error(`no short.json in ${dir} (start one with: SA new ${dir})`);
71
+ const v = readJson(f);
72
+ if (!isJson(v)) throw new Error("short.json is not JSON");
73
+ return v;
74
+ }
75
+
76
+ const load = (dir: string): Short => unwrap(parseShort(rawShort(dir)));
77
+
78
+ type Project = { readonly dir: string; readonly s: Short; readonly takes: readonly Take[]; readonly tl: Timeline };
79
+
80
+ async function project(dir: string, quiet = false): Promise<Project> {
81
+ const s = load(dir);
82
+ const takes = await narrate(dir, s, quiet);
83
+ return { dir, s, takes, tl: unwrap(timeline(s, takes)) };
84
+ }
85
+
86
+ async function withPage<T>({ dir, s, tl }: Project, use: (pg: Page) => Promise<T>): Promise<T> {
87
+ const state = pageState(s, tl, unwrap(resolveLook(s.look, namedLook)));
88
+ const pg = await paintPage(dir, state, tl.W, tl.H);
89
+ try {
90
+ const out = await use(pg);
91
+ const errs = pageErrors(pg);
92
+ if (errs.length) console.log("⚠ the page said:\n " + errs.join("\n "));
93
+ return out;
94
+ } finally {
95
+ pg.close();
96
+ }
97
+ }
98
+
99
+ const frameName = (i: number): string => `s${String(i).padStart(3, "0")}.jpg`;
100
+
101
+ /* ------------------------------------------------------------------ commands */
102
+
103
+ type Args = { readonly dir: string | undefined; readonly rest: readonly string[] };
104
+ type Command = (a: Args) => Promise<number>;
105
+
106
+ const need = (dir: string | undefined, usage: string): string => {
107
+ if (!dir) throw new Error(usage);
108
+ return dir;
109
+ };
110
+
111
+ const looks: Command = async () => {
112
+ lookFiles().forEach((f) => {
113
+ const l = readJson(path.join(LOOKS, f));
114
+ console.log(`${f.replace(".json", "").padEnd(12)} ${isMap(l) && typeof l.about === "string" ? l.about : ""}`);
115
+ });
116
+ console.log(`\nmusic moods: ${MOODS.join(", ")} (or "none", or a path to their own track)`);
117
+ console.log(`voices: ${VOICES}`);
118
+ return 0;
119
+ };
120
+
121
+ const create: Command = async ({ dir, rest }) => {
122
+ const d = need(dir, "SA new <dir>");
123
+ mkdir(d, "assets");
124
+ const opt = (k: string, dflt: string): string => {
125
+ const i = rest.indexOf(`--${k}`);
126
+ return (i >= 0 ? rest[i + 1] : undefined) ?? dflt;
127
+ };
128
+ if (!fs.existsSync(shortFile(d))) {
129
+ writeJson(shortFile(d), {
130
+ title: "Untitled short", brief: "", format: opt("format", "9:16"), look: opt("look", "oil"),
131
+ voice: { id: "bm_george", speed: 0.92 }, music: { mood: "tender" }, captions: { style: "box" },
132
+ lines: [{ say: "Every story starts somewhere.", scene: "opening", move: "push" }],
133
+ });
134
+ }
135
+ if (!fs.existsSync(path.join(d, "scenes.js"))) fs.writeFileSync(path.join(d, "scenes.js"), NEW_SCENES);
136
+ console.log(`✓ ${d}\n short.json the script, look, voice, music\n scenes.js the drawings\n assets/ anything they gave you`);
137
+ return 0;
138
+ };
139
+
140
+ const set: Command = async ({ dir, rest }) => {
141
+ const d = need(dir, "SA set <dir> key=value...");
142
+ const next = rest.map((a) => unwrap(parseAssignment(a))).reduce<Json>((doc, a) => setPath(doc, a.path, a.value), rawShort(d));
143
+ // Placeholder takes are enough to catch what only the timeline knows (an unknown format).
144
+ const s = unwrap(parseShort(next));
145
+ unwrap(timeline(s, s.lines.map(() => ({ file: null, dur: 1 }))));
146
+ writeJson(shortFile(d), next);
147
+ console.log("✓ short.json updated");
148
+ return 0;
149
+ };
150
+
151
+ const lookKeys = (): readonly string[] =>
152
+ [...new Set(lookFiles().flatMap((f) => {
153
+ const l = readJson(path.join(LOOKS, f));
154
+ return isMap(l) ? Object.keys(l) : [];
155
+ }))];
156
+
157
+ const checkCmd: Command = async ({ dir }) => {
158
+ const d = need(dir, "SA check <dir>");
159
+ const s = load(d);
160
+ const tl = unwrap(timeline(s, cachedTakes(d, s)));
161
+ const findings = check({ short: s, timeline: tl, probes: probeScenes(d, tl), kit: kitNames(), lookKeys: lookKeys() });
162
+ console.log(report(findings));
163
+ return findings.some((f) => f.level === "error") ? 1 : 0;
164
+ };
165
+
166
+ const voice: Command = async ({ dir }) => {
167
+ const { tl } = await project(need(dir, "SA voice <dir>"));
168
+ tl.beats.forEach((b, i) => console.log(`${String(i + 1).padStart(2)} ${b.start.toFixed(2).padStart(6)}s ${b.dur.toFixed(2)}s ${b.scene.padEnd(16)} ${b.say}`));
169
+ console.log(`total ${tl.duration.toFixed(1)}s, ${tl.beats.length} shots`);
170
+ return 0;
171
+ };
172
+
173
+ const frames: Command = async ({ dir }) => {
174
+ const p = await project(need(dir, "SA frames <dir>"), true);
175
+ const fdir = mkdir(p.dir, "frames");
176
+ fs.readdirSync(fdir).filter((f) => /^s\d+\.jpg$/.test(f)).forEach((f) => fs.rmSync(path.join(fdir, f)));
177
+ await withPage(p, (pg) =>
178
+ p.tl.beats.reduce<Promise<void>>(async (prev, _b, i) => {
179
+ await prev;
180
+ fs.writeFileSync(path.join(fdir, frameName(i)), jpeg(await pg.evaluate(`window.still(${i}, 0.6)`)));
181
+ }, Promise.resolve()),
182
+ );
183
+ const sheet = path.join(fdir, "sheet.jpg");
184
+ stillSheet(p.tl, path.join(fdir, "s%03d.jpg"), sheet);
185
+ console.log(`✓ ${sheet} (${p.tl.beats.length} shots, ${p.tl.duration.toFixed(1)}s)`);
186
+ return 0;
187
+ };
188
+
189
+ const still: Command = async ({ dir, rest }) => {
190
+ const p = await project(need(dir, "SA still <dir> <shot> [at]"), true);
191
+ const i = Math.max(0, parseInt(rest[0] ?? "1", 10) - 1);
192
+ const at = parseFloat(rest[1] ?? "0.6");
193
+ if (i >= p.tl.beats.length) throw new Error(`shot ${i + 1}? there are ${p.tl.beats.length}`);
194
+ const f = path.join(mkdir(p.dir, "frames"), `shot-${String(i + 1).padStart(2, "0")}.jpg`);
195
+ await withPage(p, async (pg) => fs.writeFileSync(f, jpeg(await pg.evaluate(`window.still(${i}, ${at})`))));
196
+ console.log(`✓ ${f}`);
197
+ return 0;
198
+ };
199
+
200
+ /** Next free vN: one past the highest, so deleting an old render never reuses its number. */
201
+ const nextVersion = (rdir: string): number =>
202
+ 1 + Math.max(0, ...fs.readdirSync(rdir).flatMap((f) => /^v(\d+)\.mp4$/.exec(f)?.[1] ?? []).map(Number));
203
+
204
+ async function paintAll(pg: Page, write: (b: Buffer) => Promise<void>, drawings: number): Promise<void> {
205
+ const step = async (d: number): Promise<void> => {
206
+ if (d >= drawings) return;
207
+ await write(jpeg(await pg.evaluate(`window.drawing(${d})`)));
208
+ if (d % 60 === 0) process.stdout.write(`\r· painting ${d}/${drawings} drawings`);
209
+ return step(d + 1);
210
+ };
211
+ await step(0);
212
+ process.stdout.write(`\r· painted ${drawings} drawings \n`);
213
+ }
214
+
215
+ const render: Command = async ({ dir }) => {
216
+ const t0 = Date.now();
217
+ const p = await project(need(dir, "SA render <dir>"));
218
+ const { tl, s } = p;
219
+ console.log(`· ${tl.beats.length} shots, ${tl.duration.toFixed(1)}s at ${tl.fps} fps, drawn on ${twosName(tl.twos)}`);
220
+ const audio = soundtrack(p.dir, s, tl, p.takes);
221
+ const rdir = mkdir(p.dir, "renders");
222
+ const n = nextVersion(rdir);
223
+ const outV = path.join(rdir, `v${n}.mp4`);
224
+ const enc = encoder(tl, audio, outV);
225
+ await withPage(p, (pg) => paintAll(pg, enc.write, Math.ceil((tl.duration * tl.fps) / tl.twos)));
226
+ const code = await enc.done();
227
+ if (code !== 0) throw new Error(`ffmpeg exited ${code} while encoding`);
228
+ const final = path.join(p.dir, `${slug(s.title)}.mp4`);
229
+ fs.copyFileSync(outV, final);
230
+ fs.copyFileSync(shortFile(p.dir), path.join(rdir, `v${n}.json`));
231
+ const sheet = path.join(mkdir(p.dir, "qa"), "sheet.jpg");
232
+ videoSheet(tl, final, sheet);
233
+ console.log(`✓ ${final}\n ${tl.duration.toFixed(1)}s · ${tl.W}×${tl.H} · ${((Date.now() - t0) / 1000).toFixed(0)}s to make · check: ${sheet}`);
234
+ return 0;
235
+ };
236
+
237
+ const COMMANDS: Readonly<Record<string, Command>> = {
238
+ looks, new: create, set, check: checkCmd, voice, frames, still, render,
239
+ doctor: async () => doctor(import.meta.filename),
240
+ };
241
+
242
+ const usage = (): string =>
243
+ fs.readFileSync(import.meta.filename, "utf8").split("\n").slice(2, 16).join("\n").replace(/^ \* ?/gm, "");
244
+
245
+ // stripTypeScriptTypes is stable in practice but flagged experimental; that one notice is noise to a user.
246
+ process.removeAllListeners("warning");
247
+ process.on("warning", (w) => {
248
+ if (!(w.name === "ExperimentalWarning" && /stripTypeScriptTypes/.test(w.message))) console.error(`${w.name}: ${w.message}`);
249
+ });
250
+
251
+ const [cmd = "", dirArg, ...rest] = process.argv.slice(2);
252
+ const command = COMMANDS[cmd];
253
+ if (!command) {
254
+ console.log(usage());
255
+ process.exit(cmd ? 1 : 0);
256
+ }
257
+ command({ dir: dirArg ? path.resolve(dirArg) : undefined, rest })
258
+ .then((code) => process.exit(code))
259
+ .catch((e: unknown) => {
260
+ console.error("✗ " + (e instanceof Error ? e.message : String(e)));
261
+ process.exit(1);
262
+ });
@@ -0,0 +1,107 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { captionChunks, chunks, words } from "../domain/captions.ts";
4
+ import { check, report, type Facts, type Probe } from "../domain/check.ts";
5
+ import { resolveLook } from "../domain/look.ts";
6
+ import { parseAssignment, setPath } from "../domain/patch.ts";
7
+ import { parseShort } from "../domain/schema.ts";
8
+ import { timeline } from "../domain/timeline.ts";
9
+ import type { Json, Result, Short, Take, Timeline } from "../domain/types.ts";
10
+ import { fail, ok } from "../domain/types.ts";
11
+
12
+ const value = <T>(r: Result<T>): T => {
13
+ if (!r.ok) throw new Error(r.errors.join("; "));
14
+ return r.value;
15
+ };
16
+
17
+ const RAW = {
18
+ title: "test", voice: "none", music: "none",
19
+ lines: [
20
+ { say: "A bright and open morning, out on the hills.", scene: "day", move: "push" },
21
+ { say: "Night comes down over the city.", scene: "night" },
22
+ ],
23
+ };
24
+ const short = (patch: object = {}): Short => value(parseShort({ ...RAW, ...patch }));
25
+ const silent = (s: Short): readonly Take[] => s.lines.map(() => ({ file: null, dur: 2 }));
26
+ const tl = (s: Short): Timeline => value(timeline(s, silent(s)));
27
+
28
+ test("words drops the writer's emphasis and pause marks", () => {
29
+ assert.deepEqual(words("a *bright* | morning"), ["a", "bright", "morning"]);
30
+ });
31
+
32
+ test("chunks are balanced, not greedy", () => {
33
+ assert.deepEqual(chunks("one two three four five six seven", 5).map((c) => c.split(" ").length), [4, 3]);
34
+ assert.deepEqual(chunks("short line", 5), ["short line"]);
35
+ });
36
+
37
+ test("a slash in the caption forces the break", () => {
38
+ assert.deepEqual(captionChunks({ say: "Everyone, every pose.", caption: "Everyone, / every pose.", shots: [] }, 5), ["Everyone,", "every pose."]);
39
+ });
40
+
41
+ test("parseShort fills defaults and rejects a script with no lines", () => {
42
+ const s = short();
43
+ assert.equal(s.format, "9:16");
44
+ assert.equal(s.fps, 24);
45
+ assert.equal(s.voice.kind, "none");
46
+ assert.equal(parseShort({ title: "x" }).ok, false);
47
+ assert.equal(parseShort([]).ok, false);
48
+ });
49
+
50
+ test("timeline lays beats end to end and rejects an unknown format", () => {
51
+ const t = tl(short());
52
+ assert.equal(t.beats.length, 2);
53
+ assert.equal(t.W, 1080);
54
+ const [a, b] = t.beats;
55
+ assert.ok(a && b && Math.abs(a.start + a.dur - b.start) < 1e-9);
56
+ assert.ok(t.duration >= b.start + b.dur);
57
+ const bad = timeline(short({ format: "3:2" }), silent(short()));
58
+ assert.equal(bad.ok, false);
59
+ });
60
+
61
+ test("parseAssignment reads JSON values and falls back to text", () => {
62
+ assert.deepEqual(value(parseAssignment("lines.0.hold=0.8")), { path: ["lines", "0", "hold"], value: 0.8 });
63
+ assert.deepEqual(value(parseAssignment("look=watercolor")), { path: ["look"], value: "watercolor" });
64
+ assert.equal(parseAssignment("nokey").ok, false);
65
+ assert.equal(parseAssignment("a..b=1").ok, false);
66
+ });
67
+
68
+ test("setPath returns a new document and leaves the old one alone", () => {
69
+ const doc: Json = { lines: [{ say: "hi" }], music: { mood: "tender" } };
70
+ const before = JSON.stringify(doc);
71
+ const next = setPath(setPath(doc, ["lines", "0", "hold"], 1), ["music", "mood"], "night");
72
+ assert.equal(JSON.stringify(doc), before);
73
+ assert.deepEqual(next, { lines: [{ say: "hi", hold: 1 }], music: { mood: "night" } });
74
+ });
75
+
76
+ test("resolveLook merges a named base under the overrides", () => {
77
+ const named = (n: string): Result<{ readonly [k: string]: Json }> => (n === "oil" ? ok({ grain: 0.3, edge: 1 }) : fail(`no look "${n}"`));
78
+ const look = value(resolveLook({ base: "oil", grain: 0.5 }, named));
79
+ assert.equal(look.grain, 0.5);
80
+ assert.equal(look.edge, 1);
81
+ assert.equal(resolveLook("nope", named).ok, false);
82
+ });
83
+
84
+ const facts = (probes: readonly Probe[], s = short()): Facts => ({ short: s, timeline: tl(s), probes, kit: ["sky", "person"], lookKeys: ["grain"] });
85
+
86
+ test("check flags missing scenes, unknown kit calls and throwing scenes as errors", () => {
87
+ const found = check(facts([{ scene: "day", calls: [{ fn: "skyy", opts: {} }] }, { scene: "other", calls: [], error: "boom" }]));
88
+ const errors = found.filter((f) => f.level === "error").map((f) => f.msg);
89
+ assert.ok(errors.some((m) => m.includes('no scene "night"')));
90
+ assert.ok(errors.some((m) => m.includes("k.skyy is not in the kit")));
91
+ assert.ok(errors.some((m) => m.includes("throws: boom")));
92
+ });
93
+
94
+ test("check warns when a person stands in the caption band, unless captions are off", () => {
95
+ const probes: readonly Probe[] = [
96
+ { scene: "day", calls: [{ fn: "person", opts: { y: 0.82, h: 0.28 } }] },
97
+ { scene: "night", calls: [{ fn: "person", opts: { y: 0.5, h: 0.2 } }] },
98
+ ];
99
+ const band = (s: Short) => check(facts(probes, s)).filter((f) => f.msg.includes("caption line"));
100
+ assert.deepEqual(band(short()).map((f) => f.where), ['scene "day"']);
101
+ assert.deepEqual(band(short({ captions: { style: "none" } })), []);
102
+ });
103
+
104
+ test("report is one line per finding, or clean", () => {
105
+ assert.equal(report([]), "check: clean");
106
+ assert.equal(report([{ level: "error", where: "x", msg: "y" }]), "ERROR x: y");
107
+ });
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2023",
4
+ "lib": ["es2023"],
5
+ "module": "nodenext",
6
+ "moduleResolution": "nodenext",
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "allowImportingTsExtensions": true,
11
+ "erasableSyntaxOnly": true,
12
+ "verbatimModuleSyntax": true,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["*.ts", "domain", "edge", "audio", "test"]
16
+ }
@@ -0,0 +1,20 @@
1
+ """The narrator: Kokoro-82M on this computer, every line of a short in one go.
2
+
3
+ python tts.py <model.onnx> <voices.bin> <voice> <speed> < jobs.json
4
+
5
+ jobs.json is [[text, out.wav], ...]. Loads the model once, writes one WAV per line,
6
+ prints one line per file. Apache-2.0 model, no account, nothing leaves the machine.
7
+ """
8
+ import json
9
+ import sys
10
+
11
+ import soundfile as sf
12
+ from kokoro_onnx import Kokoro
13
+
14
+ model, voices, voice, speed = sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4])
15
+ lang = {"a": "en-us", "b": "en-gb", "e": "es", "f": "fr-fr", "h": "hi", "i": "it", "j": "ja", "p": "pt-br", "z": "cmn"}.get(voice[0], "en-us")
16
+ k = Kokoro(model, voices)
17
+ for text, out in json.load(sys.stdin):
18
+ samples, rate = k.create(text, voice=voice, speed=speed, lang=lang)
19
+ sf.write(out, samples, rate)
20
+ print(out, flush=True)
@@ -353,6 +353,33 @@ treat as a full review and leave PREV_SHA unset. Also grep the same file for
353
353
  - Step 3.5 reuses that exact URL or local path so the artifact stays a living
354
354
  page for this PR.
355
355
 
356
+ **Convergence state (every PR, first review included):** the review file
357
+ records only the last head sha, so finding status lives in
358
+ `scripts/finding-state.mjs` (one JSON file per PR under
359
+ `~/.local/state/lekker-review/findings/`). In `pr` mode, on a re-review, first
360
+ fetch the PR's review threads into `<scratchpad>/threads.json` as
361
+ `{ "prAuthor": <login>, "threads": [{ path, line, body, isResolved, isOutdated,
362
+ replies: [{ author, body }] }] }` (`gh api graphql` over `reviewThreads`; `body`
363
+ is the first comment, `replies` the later ones). Then run
364
+ `node "${CLAUDE_PLUGIN_ROOT}/skills/lekker-review/scripts/finding-state.mjs" begin --repo <REPO_SLUG> --target <TARGET_SLUG> --run <RUN_ID> [--threads <scratchpad>/threads.json]`.
365
+ `RUN_ID` is one id per review attempt, saved to `<scratchpad>/finding-run-id`
366
+ and reused when that attempt is retried, so a retry does not count as a new
367
+ cycle. In Codex, replace the quoted script path with the absolute path to
368
+ `scripts/finding-state.mjs` beside the selected `SKILL.md`, as for run-log. Pass
369
+ its `cycle`, `suppressed` and `recurringClasses` to the workflow. A
370
+ finding is `fixed` when its thread is outdated, `intentional` when the author
371
+ replied "by design" and nothing changed, `rejected` when the author dismissed it
372
+ or the thread was resolved with no code change. The workflow never re-raises a
373
+ `suppressed` (rejected or intentional) finding. From `cycle` 4 the workflow
374
+ returns `verdict: "rethink"` with the recurring finding classes and runs no
375
+ agents: skip Steps 2 to 4, render the verdict as `RETHINK THE APPROACH: this PR
376
+ has been reviewed N times; the same classes keep coming back: <classes>`, and
377
+ suggest the author and reviewer agree the approach before another round.
378
+ Run `finding-state.mjs record --run <RUN_ID> --repo ... --target ... --findings <findings.json>`
379
+ before the review is published or the artifact is updated, so a stop after
380
+ publishing cannot lose what was raised. `--run` files the findings under this
381
+ attempt's cycle even when another review of the PR started in the meantime.
382
+
356
383
  **Open the run log** once mode, depth and engine are settled (see
357
384
  `references/traces.md` for where every trace of a run lives):
358
385
 
@@ -688,6 +715,9 @@ args: {
688
715
  worktreePath: <null for scan, else from worktree.json>,
689
716
  promptDir: "${SKILL_ROOT}/references/agents",
690
717
  prevSha: <null unless re-review>,
718
+ cycle, // finding-state.mjs begin output; omit when unknown
719
+ suppressed, // rejected/intentional findings never to re-raise
720
+ recurringClasses, // shown in the rethink verdict
691
721
  testRunner: <worktree.json testRunner, null for scan>
692
722
  }
693
723
  ```
@@ -142,6 +142,9 @@ Collect into `EXISTING_REVIEWS`. Purpose:
142
142
  - **Deduplication**: if an existing review already raised a finding at a
143
143
  specific `file:line`, skip that finding in your output and note
144
144
  `(already raised by <reviewer>)` internally in your deduplication pass.
145
+ - **Answered findings**: file:line dedup is not enough across re-reviews. A finding the
146
+ author rejected or called intentional is carried in `suppressed` from
147
+ `scripts/finding-state.mjs` (SKILL.md, Convergence state) and is never re-raised.
145
148
  - **Design decisions**: if the author replied to a review comment explaining
146
149
  an intentional choice, that context informs whether a pattern is a bug or
147
150
  a deliberate trade-off.
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // Per-PR finding state for lekker-review, so re-reviews converge.
3
+ //
4
+ // A re-review used to know only the last head sha and dedupe by file:line, so a
5
+ // finding the author had already rejected came back every round. This keeps one
6
+ // small JSON file per PR next to the run ledger (${LEKKER_STATE_DIR:-
7
+ // ~/.local/state/lekker-review}/findings/) holding each finding's status and the
8
+ // number of review cycles.
9
+ //
10
+ // Statuses: open | fixed | rejected | intentional. A finding is
11
+ // fixed when GitHub marks its thread outdated (the line changed)
12
+ // intentional when the author replied "by design" and the code did not change
13
+ // rejected when the author replied with another dismissal, or the thread
14
+ // was resolved without a code change and the PR author is known
15
+ //
16
+ // Usage:
17
+ // node finding-state.mjs begin --repo <slug> --target <pr-N> [--threads <threads.json>]
18
+ // Folds thread outcomes into the stored statuses, counts the cycle, and
19
+ // prints {cycle, rethink, suppressed, recurringClasses} for workflow.js.
20
+ // node finding-state.mjs record --repo <slug> --target <pr-N> --findings <findings.json>
21
+ // Stores this run's findings as open (an already-known finding keeps its status).
22
+ // node finding-state.mjs show --repo <slug> --target <pr-N>
23
+ //
24
+ // threads.json: { prAuthor, threads: [{ path, line, title?, body, isResolved, isOutdated,
25
+ // replies: [{ author, body }] }] }, where `body` is lekker's comment and only
26
+ // replies by `prAuthor` count as the author's answer. A bare array is accepted
27
+ // when the author is unknown; it can then only yield fixed (outdated) threads.
28
+ // When title is absent, the first nonempty comment line supplies the title.
29
+
30
+ import { createHash, randomUUID } from 'node:crypto';
31
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
32
+ import { homedir } from 'node:os';
33
+ import { dirname, join } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+
36
+ export const RETHINK_CYCLE = 4;
37
+ const INTENT = /\b(intentional(ly)?|by design|on purpose|deliberate(ly)?|as designed|expected behaviou?r)\b/i;
38
+ const DISMISS = /\b(won'?t fix|wontfix|not a (bug|problem|concern)|false positive|disagree|n\/a|not applicable|out of scope|ignor(e|ing)|nit)\b/i;
39
+
40
+ export function titleTokens(title) {
41
+ return (String(title || '').toLowerCase().match(/[a-z0-9]+/g) || []).filter((w) => w.length > 2);
42
+ }
43
+
44
+ export function sameIssue(a, b) {
45
+ const normalize = (title) => String(title || '').trim().replace(/\s+/g, ' ').toLowerCase();
46
+ const title = normalize(a.title);
47
+ return title !== '' && title === normalize(b.title);
48
+ }
49
+
50
+ export function sameFinding(a, b) {
51
+ // Status reuse requires the same anchor and title; word overlap is only a
52
+ // class signal, never proof that two findings identify the same issue.
53
+ return Boolean(a.file) && a.file === b.file && a.line === b.line && sameIssue(a, b);
54
+ }
55
+
56
+ // Group key for "the same kind of finding came back again".
57
+ export function findingClass(f) {
58
+ return f.bugClass || titleTokens(f.title).slice(0, 4).sort().join('-') || 'unclassified';
59
+ }
60
+
61
+ const NEGATOR = /\b(not|no|never|nor|isn'?t|aren'?t|wasn'?t|don'?t|doesn'?t|won'?t|cannot)\b/i;
62
+
63
+ // Check only the three words before each keyword in its clause for negation.
64
+ function mentions(pattern, text) {
65
+ return [...text.matchAll(new RegExp(pattern.source, 'gi'))]
66
+ .some((match) => {
67
+ const before = text.slice(0, match.index).split(/[.!?;:,\n-]/).pop();
68
+ return !NEGATOR.test(before.trim().split(/\s+/).slice(-3).join(' '));
69
+ });
70
+ }
71
+
72
+ export function classifyThread(thread, prAuthor) {
73
+ const authorReplies = (thread.replies || []).filter((r) => r.author && r.author === prAuthor);
74
+ const text = authorReplies.map((r) => r.body).join('\n');
75
+ if (thread.isOutdated) return 'fixed';
76
+ if (mentions(INTENT, text)) return 'intentional';
77
+ if (mentions(DISMISS, text)) return 'rejected';
78
+ if (thread.isResolved && prAuthor) return 'rejected';
79
+ return 'open';
80
+ }
81
+
82
+ export function applyThreads(findings, threads, prAuthor) {
83
+ return findings.map((f) => {
84
+ const thread = threads.find((t) => {
85
+ const title = t.title?.trim() || String(t.body || '').split(/\r?\n/).find((line) => line.trim())
86
+ ?.trim().replace(/^#{1,6}\s+/, '').replace(/^(\*\*|__)(.*?)\1[.:]?$/, '$2');
87
+ return sameFinding(f, { file: t.path, line: t.line, title });
88
+ });
89
+ if (!thread) return f;
90
+ const status = classifyThread(thread, prAuthor);
91
+ return f.status === status ? f : { ...f, status };
92
+ });
93
+ }
94
+
95
+ export function mergeFindings(prior, found, cycle) {
96
+ const added = found
97
+ .filter((f) => !prior.some((p) => sameFinding(p, f)))
98
+ .map((f) => ({ file: f.file, line: f.line, title: f.title, class: findingClass(f), status: 'open', cycles: [cycle] }));
99
+ const seen = prior.map((p) => (found.some((f) => sameFinding(p, f)) && !p.cycles.includes(cycle)
100
+ ? { ...p, cycles: [...p.cycles, cycle] }
101
+ : p));
102
+ return seen.concat(added);
103
+ }
104
+
105
+ export function recurringClasses(findings) {
106
+ const byClass = findings.reduce((acc, f) => {
107
+ const cycles = new Set([...(acc[f.class] || []), ...f.cycles]);
108
+ return { ...acc, [f.class]: [...cycles] };
109
+ }, {});
110
+ return Object.entries(byClass)
111
+ .filter(([, cycles]) => cycles.length >= 2)
112
+ .map(([cls, cycles]) => ({ class: cls, cycles: cycles.length }))
113
+ .sort((a, b) => b.cycles - a.cycles);
114
+ }
115
+
116
+ export function suppressedFindings(findings) {
117
+ return findings
118
+ .filter((f) => f.status === 'rejected' || f.status === 'intentional')
119
+ .map(({ file, line, title, status }) => ({ file, line, title, status }));
120
+ }
121
+
122
+ // A run id makes begin idempotent: a retry of the same run reuses its cycle instead of inflating the count.
123
+ export function begin(state, threads, prAuthor, run = null) {
124
+ const findings = applyThreads(state.findings, threads, prAuthor);
125
+ const known = run === null ? undefined : state.runs?.[run];
126
+ const cycle = known ?? state.cycle + 1;
127
+ const runs = run === null || known !== undefined ? state.runs : { ...state.runs, [run]: cycle };
128
+ return {
129
+ state: { ...state, cycle: Math.max(state.cycle, cycle), findings, ...(runs && { runs }) },
130
+ summary: {
131
+ cycle,
132
+ rethink: cycle >= RETHINK_CYCLE,
133
+ suppressed: suppressedFindings(findings),
134
+ recurringClasses: recurringClasses(findings),
135
+ },
136
+ };
137
+ }
138
+
139
+ const STATE_DIR = process.env.LEKKER_STATE_DIR || join(homedir(), '.local', 'state', 'lekker-review');
140
+ // Hash the tuple so punctuation, case and repo/target boundaries stay distinct.
141
+ const statePath = (repo, target) => join(STATE_DIR, 'findings',
142
+ `${createHash('sha256').update(JSON.stringify([repo, target])).digest('hex')}.json`);
143
+
144
+ function load(path) {
145
+ let raw;
146
+ try {
147
+ raw = readFileSync(path, 'utf8');
148
+ } catch (error) {
149
+ if (error.code === 'ENOENT') return { cycle: 0, findings: [] };
150
+ throw error;
151
+ }
152
+ const state = JSON.parse(raw);
153
+ if (!state || typeof state !== 'object' || Array.isArray(state)
154
+ || !Number.isSafeInteger(state.cycle) || state.cycle < 0 || !Array.isArray(state.findings)) {
155
+ throw new Error(`Invalid finding state: ${path}`);
156
+ }
157
+ if (state.runs !== undefined && (!state.runs || typeof state.runs !== 'object' || Array.isArray(state.runs)
158
+ || !Object.values(state.runs).every((c) => Number.isSafeInteger(c) && c >= 1))) {
159
+ throw new Error(`Invalid finding state runs: ${path}`);
160
+ }
161
+ return state;
162
+ }
163
+
164
+ function save(path, state) {
165
+ mkdirSync(dirname(path), { recursive: true });
166
+ const temporary = `${path}.${randomUUID()}.tmp`;
167
+ try {
168
+ writeFileSync(temporary, JSON.stringify(state, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
169
+ renameSync(temporary, path);
170
+ } finally {
171
+ rmSync(temporary, { force: true });
172
+ }
173
+ }
174
+
175
+ function main(argv) {
176
+ const flag = (name) => { const i = argv.indexOf('--' + name); return i === -1 ? null : argv[i + 1]; };
177
+ const repo = flag('repo');
178
+ const target = flag('target');
179
+ if (!repo || !target) { console.log(JSON.stringify({ error: '--repo and --target are required' })); process.exit(2); }
180
+ const path = statePath(repo, target);
181
+ const state = load(path);
182
+ const cmd = argv[0];
183
+ if (cmd === 'begin') {
184
+ const raw = flag('threads') ? JSON.parse(readFileSync(flag('threads'), 'utf8')) : [];
185
+ const threads = Array.isArray(raw) ? raw : raw.threads || [];
186
+ const prAuthor = Array.isArray(raw) ? null : raw.prAuthor || null;
187
+ const next = begin(state, threads, prAuthor, flag('run') ?? null);
188
+ save(path, next.state);
189
+ console.log(JSON.stringify(next.summary));
190
+ } else if (cmd === 'record') {
191
+ const raw = JSON.parse(readFileSync(flag('findings'), 'utf8'));
192
+ const found = Array.isArray(raw) ? raw : raw.findings || [];
193
+ const run = flag('run') ?? null;
194
+ // A late record for an earlier run keeps that run's cycle; the current cycle stays as it was.
195
+ const cycle = run === null ? Math.max(state.cycle, 1) : state.runs?.[run];
196
+ if (cycle === undefined) { console.log(JSON.stringify({ error: `unknown --run ${run}; call begin --run first` })); process.exit(2); }
197
+ save(path, { ...state, cycle: Math.max(state.cycle, 1), findings: mergeFindings(state.findings, found, cycle) });
198
+ console.log(JSON.stringify({ recorded: found.length, cycle }));
199
+ } else if (cmd === 'show') {
200
+ console.log(JSON.stringify(state, null, 2));
201
+ } else {
202
+ console.log(JSON.stringify({ error: 'usage: begin|record|show' }));
203
+ process.exit(2);
204
+ }
205
+ }
206
+
207
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main(process.argv.slice(2));