@oh-story/dsh 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 (488) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/cordis.patch.yml +3 -0
  4. package/lib/client.js +1201 -0
  5. package/lib/client.js.map +7 -0
  6. package/lib/drama/LICENSE.upstream +21 -0
  7. package/lib/drama/manifest.json +1012 -0
  8. package/lib/drama/skills/short-drama/SKILL.md +153 -0
  9. package/lib/drama/skills/short-drama/agents/openai.yaml +4 -0
  10. package/lib/drama/skills/short-drama/assets/creator-decision.example.jsonl +4 -0
  11. package/lib/drama/skills/short-drama/assets/production-observation.example.jsonl +2 -0
  12. package/lib/drama/skills/short-drama/assets/project-template/short-drama.json +36 -0
  13. package/lib/drama/skills/short-drama/assets/reference-observation.example.jsonl +2 -0
  14. package/lib/drama/skills/short-drama/references/audience-reveal.md +74 -0
  15. package/lib/drama/skills/short-drama/references/contract-and-ownership.md +94 -0
  16. package/lib/drama/skills/short-drama/references/creator-workflow.md +82 -0
  17. package/lib/drama/skills/short-drama/references/form-cards/Q/347/211/210/350/241/250/350/276/276.md +73 -0
  18. package/lib/drama/skills/short-drama/references/form-cards//344/272/214/347/273/264/345/212/250/346/200/201/346/274/253.md +74 -0
  19. package/lib/drama/skills/short-drama/references/form-cards//345/233/275/346/274/253/344/272/214/346/254/241/345/205/203.md +79 -0
  20. package/lib/drama/skills/short-drama/references/form-cards//345/256/236/346/213/215.md +75 -0
  21. package/lib/drama/skills/short-drama/references/form-cards//346/260/264/345/242/250/347/254/224/350/247/246.md +85 -0
  22. package/lib/drama/skills/short-drama/references/form-cards//351/243/216/346/240/274/345/214/226/344/270/211/347/273/264.md +72 -0
  23. package/lib/drama/skills/short-drama/references/knowhow-index.md +80 -0
  24. package/lib/drama/skills/short-drama/references/lifecycle-commands.md +130 -0
  25. package/lib/drama/skills/short-drama/references/look-development.md +52 -0
  26. package/lib/drama/skills/short-drama/references/pickup-and-alternate.md +58 -0
  27. package/lib/drama/skills/short-drama/references/production-form-profiles.md +126 -0
  28. package/lib/drama/skills/short-drama/references/reference-roles.md +142 -0
  29. package/lib/drama/skills/short-drama/references/runtime-preflight.md +27 -0
  30. package/lib/drama/skills/short-drama/scripts/project_tool.py +1963 -0
  31. package/lib/drama/skills/short-drama/scripts/selftest.py +287 -0
  32. package/lib/drama/skills/short-drama-assets/SKILL.md +187 -0
  33. package/lib/drama/skills/short-drama-assets/agents/openai.yaml +4 -0
  34. package/lib/drama/skills/short-drama-assets/assets/character-look.example.jsonl +3 -0
  35. package/lib/drama/skills/short-drama-assets/assets/continuity.example.jsonl +4 -0
  36. package/lib/drama/skills/short-drama-assets/assets/decisions.example.jsonl +5 -0
  37. package/lib/drama/skills/short-drama-assets/assets/location-view.example.jsonl +3 -0
  38. package/lib/drama/skills/short-drama-assets/assets/occurrences.example.jsonl +5 -0
  39. package/lib/drama/skills/short-drama-assets/assets/prop-state.example.jsonl +3 -0
  40. package/lib/drama/skills/short-drama-assets/examples/minimal/characters.jsonl +2 -0
  41. package/lib/drama/skills/short-drama-assets/examples/minimal/looks.jsonl +2 -0
  42. package/lib/drama/skills/short-drama-assets/references/asset-review-checklist.md +89 -0
  43. package/lib/drama/skills/short-drama-assets/references/character-and-look.md +164 -0
  44. package/lib/drama/skills/short-drama-assets/references/continuity-delta.md +123 -0
  45. package/lib/drama/skills/short-drama-assets/references/identity-vs-variant.md +146 -0
  46. package/lib/drama/skills/short-drama-assets/references/location-and-view.md +84 -0
  47. package/lib/drama/skills/short-drama-assets/references/occurrence-extraction.md +111 -0
  48. package/lib/drama/skills/short-drama-assets/references/prop-and-state.md +107 -0
  49. package/lib/drama/skills/short-drama-assets/references/stage-contract.md +136 -0
  50. package/lib/drama/skills/short-drama-assets/references/voice-direction.md +171 -0
  51. package/lib/drama/skills/short-drama-assets/scripts/asset_check.py +267 -0
  52. package/lib/drama/skills/short-drama-assets/scripts/selftest.py +89 -0
  53. package/lib/drama/skills/short-drama-develop/SKILL.md +157 -0
  54. package/lib/drama/skills/short-drama-develop/agents/openai.yaml +4 -0
  55. package/lib/drama/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -0
  56. package/lib/drama/skills/short-drama-develop/assets/creative-brief.md +120 -0
  57. package/lib/drama/skills/short-drama-develop/assets/episode-map.jsonl +1 -0
  58. package/lib/drama/skills/short-drama-develop/assets/story-engine.md +181 -0
  59. package/lib/drama/skills/short-drama-develop/references/adaptation-craft.md +219 -0
  60. package/lib/drama/skills/short-drama-develop/references/creative-reference-intake.md +57 -0
  61. package/lib/drama/skills/short-drama-develop/references/director-brief-craft.md +113 -0
  62. package/lib/drama/skills/short-drama-develop/references/episode-design.md +509 -0
  63. package/lib/drama/skills/short-drama-develop/references/genre-and-hook-playbook.md +240 -0
  64. package/lib/drama/skills/short-drama-develop/references/genre-cards//344/272/262/345/255/220/351/232/220/347/247/230.md +112 -0
  65. package/lib/drama/skills/short-drama-develop/references/genre-cards//344/273/231/344/276/240/344/277/256/347/234/237.md +51 -0
  66. package/lib/drama/skills/short-drama-develop/references/genre-cards//345/212/250/344/275/234/344/273/273/345/212/241.md +51 -0
  67. package/lib/drama/skills/short-drama-develop/references/genre-cards//345/217/244/350/243/205/346/235/203/350/260/213.md +51 -0
  68. package/lib/drama/skills/short-drama-develop/references/genre-cards//345/244/215/344/273/207/346/211/223/350/204/270.md +51 -0
  69. package/lib/drama/skills/short-drama-develop/references/genre-cards//345/256/266/345/272/255/345/205/263/347/263/273.md +51 -0
  70. package/lib/drama/skills/short-drama-develop/references/genre-cards//346/202/254/347/226/221/350/247/204/345/210/231.md +51 -0
  71. package/lib/drama/skills/short-drama-develop/references/genre-cards//347/224/237/346/264/273/346/265/201.md +51 -0
  72. package/lib/drama/skills/short-drama-develop/references/genre-cards//347/240/264/351/225/234/351/207/215/345/234/206.md +109 -0
  73. package/lib/drama/skills/short-drama-develop/references/genre-cards//350/201/214/345/234/272/345/226/234/345/211/247.md +51 -0
  74. package/lib/drama/skills/short-drama-develop/references/genre-cards//350/261/252/351/227/250/345/251/232/346/201/213.md +51 -0
  75. package/lib/drama/skills/short-drama-develop/references/genre-cards//350/272/253/344/273/275/351/224/231/344/275/215.md +51 -0
  76. package/lib/drama/skills/short-drama-develop/references/genre-cards.md +92 -0
  77. package/lib/drama/skills/short-drama-develop/references/mechanism-loop.md +282 -0
  78. package/lib/drama/skills/short-drama-develop/references/multi-episode-intake.md +128 -0
  79. package/lib/drama/skills/short-drama-develop/references/premise-devices.md +211 -0
  80. package/lib/drama/skills/short-drama-develop/references/reveal-reversal-payoff.md +99 -0
  81. package/lib/drama/skills/short-drama-develop/references/serial-character-and-memory.md +293 -0
  82. package/lib/drama/skills/short-drama-develop/references/stage-contract.md +88 -0
  83. package/lib/drama/skills/short-drama-develop/references/story-craft.md +359 -0
  84. package/lib/drama/skills/short-drama-develop/scripts/episode_intake.py +699 -0
  85. package/lib/drama/skills/short-drama-develop/scripts/selftest.py +47 -0
  86. package/lib/drama/skills/short-drama-image-prompts/SKILL.md +181 -0
  87. package/lib/drama/skills/short-drama-image-prompts/agents/openai.yaml +4 -0
  88. package/lib/drama/skills/short-drama-image-prompts/assets/image-prompt-spec.jsonl.md +187 -0
  89. package/lib/drama/skills/short-drama-image-prompts/assets/image-prompts.md +37 -0
  90. package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-frame-spec.jsonl.md +128 -0
  91. package/lib/drama/skills/short-drama-image-prompts/assets/lookdev-prompts.md +31 -0
  92. package/lib/drama/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +2 -0
  93. package/lib/drama/skills/short-drama-image-prompts/references/character-and-look.md +154 -0
  94. package/lib/drama/skills/short-drama-image-prompts/references/common-recipe.md +231 -0
  95. package/lib/drama/skills/short-drama-image-prompts/references/edit-and-revision.md +122 -0
  96. package/lib/drama/skills/short-drama-image-prompts/references/location-plate.md +152 -0
  97. package/lib/drama/skills/short-drama-image-prompts/references/look-and-state-variant.md +96 -0
  98. package/lib/drama/skills/short-drama-image-prompts/references/lookdev-frame.md +38 -0
  99. package/lib/drama/skills/short-drama-image-prompts/references/production-sheet-recipes.md +177 -0
  100. package/lib/drama/skills/short-drama-image-prompts/references/prop-plate.md +140 -0
  101. package/lib/drama/skills/short-drama-image-prompts/references/review-and-fixtures.md +92 -0
  102. package/lib/drama/skills/short-drama-image-prompts/references/stage-contract.md +89 -0
  103. package/lib/drama/skills/short-drama-image-prompts/scripts/image_prompt_check.py +443 -0
  104. package/lib/drama/skills/short-drama-image-prompts/scripts/selftest.py +93 -0
  105. package/lib/drama/skills/short-drama-novel-analyze/SKILL.md +245 -0
  106. package/lib/drama/skills/short-drama-novel-analyze/agents/openai.yaml +4 -0
  107. package/lib/drama/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +3 -0
  108. package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-triage.md +116 -0
  109. package/lib/drama/skills/short-drama-novel-analyze/references/adaptation-value.md +124 -0
  110. package/lib/drama/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +155 -0
  111. package/lib/drama/skills/short-drama-novel-analyze/references/chapter-extraction.md +141 -0
  112. package/lib/drama/skills/short-drama-novel-analyze/references/stage-contract.md +67 -0
  113. package/lib/drama/skills/short-drama-novel-analyze/scripts/novel_index.py +651 -0
  114. package/lib/drama/skills/short-drama-novel-analyze/scripts/selftest.py +51 -0
  115. package/lib/drama/skills/short-drama-produce/SKILL.md +103 -0
  116. package/lib/drama/skills/short-drama-produce/agents/openai.yaml +4 -0
  117. package/lib/drama/skills/short-drama-produce/references/adapter-contract.md +140 -0
  118. package/lib/drama/skills/short-drama-produce/references/providers/gpt-image-2.md +24 -0
  119. package/lib/drama/skills/short-drama-produce/references/providers/minimax-music.md +25 -0
  120. package/lib/drama/skills/short-drama-produce/references/providers/seedance.md +40 -0
  121. package/lib/drama/skills/short-drama-produce/scripts/fixture_adapter.py +60 -0
  122. package/lib/drama/skills/short-drama-produce/scripts/production_tool.py +1635 -0
  123. package/lib/drama/skills/short-drama-produce/scripts/provider_adapters.py +964 -0
  124. package/lib/drama/skills/short-drama-produce/scripts/selftest.py +134 -0
  125. package/lib/drama/skills/short-drama-review/SKILL.md +178 -0
  126. package/lib/drama/skills/short-drama-review/agents/openai.yaml +4 -0
  127. package/lib/drama/skills/short-drama-review/assets/finding-template.jsonl +2 -0
  128. package/lib/drama/skills/short-drama-review/assets/supersession-decision.example.json +42 -0
  129. package/lib/drama/skills/short-drama-review/assets/verdict-template.json +32 -0
  130. package/lib/drama/skills/short-drama-review/examples/minimal-findings.jsonl +2 -0
  131. package/lib/drama/skills/short-drama-review/examples/minimal-verdict.json +34 -0
  132. package/lib/drama/skills/short-drama-review/references/anti-template-repair.md +114 -0
  133. package/lib/drama/skills/short-drama-review/references/production-quality-gates.md +270 -0
  134. package/lib/drama/skills/short-drama-review/references/project-calibration.md +85 -0
  135. package/lib/drama/skills/short-drama-review/references/review-method.md +82 -0
  136. package/lib/drama/skills/short-drama-review/references/rubric-assets-prompts.md +115 -0
  137. package/lib/drama/skills/short-drama-review/references/rubric-source-analysis.md +108 -0
  138. package/lib/drama/skills/short-drama-review/references/rubric-story-script.md +164 -0
  139. package/lib/drama/skills/short-drama-review/references/rubric-visual-motion.md +166 -0
  140. package/lib/drama/skills/short-drama-review/references/stage-contract.md +87 -0
  141. package/lib/drama/skills/short-drama-review/scripts/review_check.py +325 -0
  142. package/lib/drama/skills/short-drama-review/scripts/selftest.py +87 -0
  143. package/lib/drama/skills/short-drama-storyboard/SKILL.md +264 -0
  144. package/lib/drama/skills/short-drama-storyboard/agents/openai.yaml +4 -0
  145. package/lib/drama/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +2 -0
  146. package/lib/drama/skills/short-drama-storyboard/assets/coverage-template.json +49 -0
  147. package/lib/drama/skills/short-drama-storyboard/assets/keyframe-prompts.md +32 -0
  148. package/lib/drama/skills/short-drama-storyboard/assets/keyframe-template.jsonl +2 -0
  149. package/lib/drama/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +12 -0
  150. package/lib/drama/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +2 -0
  151. package/lib/drama/skills/short-drama-storyboard/assets/shot-template.jsonl +2 -0
  152. package/lib/drama/skills/short-drama-storyboard/references/blocking-playbooks.md +241 -0
  153. package/lib/drama/skills/short-drama-storyboard/references/coverage-audition.md +39 -0
  154. package/lib/drama/skills/short-drama-storyboard/references/keyframe-craft.md +169 -0
  155. package/lib/drama/skills/short-drama-storyboard/references/production-shot-grammar.md +264 -0
  156. package/lib/drama/skills/short-drama-storyboard/references/review-and-fixtures.md +70 -0
  157. package/lib/drama/skills/short-drama-storyboard/references/scene-visual-plan.md +39 -0
  158. package/lib/drama/skills/short-drama-storyboard/references/screenplay-to-keyframe-example.md +157 -0
  159. package/lib/drama/skills/short-drama-storyboard/references/shot-craft.md +192 -0
  160. package/lib/drama/skills/short-drama-storyboard/references/shot-revision-identity.md +29 -0
  161. package/lib/drama/skills/short-drama-storyboard/references/stage-contract.md +110 -0
  162. package/lib/drama/skills/short-drama-storyboard/scripts/selftest.py +191 -0
  163. package/lib/drama/skills/short-drama-storyboard/scripts/storyboard_check.py +824 -0
  164. package/lib/drama/skills/short-drama-video-prompts/SKILL.md +245 -0
  165. package/lib/drama/skills/short-drama-video-prompts/agents/openai.yaml +4 -0
  166. package/lib/drama/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +21 -0
  167. package/lib/drama/skills/short-drama-video-prompts/assets/delivery-container.jsonl.md +101 -0
  168. package/lib/drama/skills/short-drama-video-prompts/assets/motion-spec.jsonl.md +212 -0
  169. package/lib/drama/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +2 -0
  170. package/lib/drama/skills/short-drama-video-prompts/assets/music-spec.jsonl.md +31 -0
  171. package/lib/drama/skills/short-drama-video-prompts/assets/performance.fragment.json +38 -0
  172. package/lib/drama/skills/short-drama-video-prompts/assets/video-prompts.md +46 -0
  173. package/lib/drama/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +2 -0
  174. package/lib/drama/skills/short-drama-video-prompts/references/camera-audio-continuity.md +240 -0
  175. package/lib/drama/skills/short-drama-video-prompts/references/delivery-profile.md +255 -0
  176. package/lib/drama/skills/short-drama-video-prompts/references/generability.md +93 -0
  177. package/lib/drama/skills/short-drama-video-prompts/references/motion-recipe.md +304 -0
  178. package/lib/drama/skills/short-drama-video-prompts/references/performance-action-timing.md +191 -0
  179. package/lib/drama/skills/short-drama-video-prompts/references/production-prompt-grammar.md +312 -0
  180. package/lib/drama/skills/short-drama-video-prompts/references/review-and-fixtures.md +168 -0
  181. package/lib/drama/skills/short-drama-video-prompts/references/stage-contract.md +110 -0
  182. package/lib/drama/skills/short-drama-video-prompts/scripts/container_check.py +329 -0
  183. package/lib/drama/skills/short-drama-video-prompts/scripts/motion_timing_check.py +450 -0
  184. package/lib/drama/skills/short-drama-video-prompts/scripts/music_spec_check.py +327 -0
  185. package/lib/drama/skills/short-drama-video-prompts/scripts/selftest.py +82 -0
  186. package/lib/drama/skills/short-drama-write/SKILL.md +284 -0
  187. package/lib/drama/skills/short-drama-write/agents/openai.yaml +4 -0
  188. package/lib/drama/skills/short-drama-write/assets/beats.jsonl +2 -0
  189. package/lib/drama/skills/short-drama-write/assets/episode-card-standalone.json +120 -0
  190. package/lib/drama/skills/short-drama-write/assets/episode-card.json +55 -0
  191. package/lib/drama/skills/short-drama-write/assets/screenplay.md +36 -0
  192. package/lib/drama/skills/short-drama-write/assets/voice-record-sheet.jsonl.md +89 -0
  193. package/lib/drama/skills/short-drama-write/references/dialogue-craft.md +519 -0
  194. package/lib/drama/skills/short-drama-write/references/production-format-dialect.md +273 -0
  195. package/lib/drama/skills/short-drama-write/references/scene-handoff-capsule.md +51 -0
  196. package/lib/drama/skills/short-drama-write/references/scene-sound-dramaturgy.md +43 -0
  197. package/lib/drama/skills/short-drama-write/references/screenplay-format.md +260 -0
  198. package/lib/drama/skills/short-drama-write/references/script-craft.md +558 -0
  199. package/lib/drama/skills/short-drama-write/references/stage-contract.md +94 -0
  200. package/lib/drama/skills/short-drama-write/references/substitutable-realization.md +107 -0
  201. package/lib/drama/skills/short-drama-write/scripts/duration_estimate.py +309 -0
  202. package/lib/drama/skills/short-drama-write/scripts/screenplay_index.py +1091 -0
  203. package/lib/drama/skills/short-drama-write/scripts/selftest.py +229 -0
  204. package/lib/drama/skills/short-drama-write/scripts/voice_sheet_check.py +416 -0
  205. package/lib/index.js +756 -0
  206. package/lib/index.js.map +7 -0
  207. package/lib/oh-story/LICENSE.upstream +21 -0
  208. package/lib/oh-story/manifest.json +1351 -0
  209. package/lib/oh-story/roles/chapter-extractor.md +314 -0
  210. package/lib/oh-story/roles/character-designer.md +169 -0
  211. package/lib/oh-story/roles/consistency-checker.md +190 -0
  212. package/lib/oh-story/roles/narrative-writer.md +294 -0
  213. package/lib/oh-story/roles/story-architect.md +195 -0
  214. package/lib/oh-story/roles/story-explorer.md +364 -0
  215. package/lib/oh-story/roles/story-researcher.md +304 -0
  216. package/lib/oh-story/skills/browser-cdp/SKILL.md +175 -0
  217. package/lib/oh-story/skills/story/SKILL.md +116 -0
  218. package/lib/oh-story/skills/story/VERSION +1 -0
  219. package/lib/oh-story/skills/story-cover/SKILL.md +327 -0
  220. package/lib/oh-story/skills/story-cover/references/cover-styles.md +210 -0
  221. package/lib/oh-story/skills/story-deslop/SKILL.md +445 -0
  222. package/lib/oh-story/skills/story-deslop/references/anti-ai-writing.md +582 -0
  223. package/lib/oh-story/skills/story-deslop/references/banned-words.md +147 -0
  224. package/lib/oh-story/skills/story-deslop/scripts/check-ai-patterns.js +1371 -0
  225. package/lib/oh-story/skills/story-deslop/scripts/check-degeneration.js +330 -0
  226. package/lib/oh-story/skills/story-deslop/scripts/check-outline-copy.js +241 -0
  227. package/lib/oh-story/skills/story-deslop/scripts/normalize-punctuation.js +410 -0
  228. package/lib/oh-story/skills/story-import/SKILL.md +634 -0
  229. package/lib/oh-story/skills/story-import/references/character-state-reverse.md +82 -0
  230. package/lib/oh-story/skills/story-import/references/format-and-structure.md +169 -0
  231. package/lib/oh-story/skills/story-import/references/length-routing.md +134 -0
  232. package/lib/oh-story/skills/story-import/references/state-tracking.md +108 -0
  233. package/lib/oh-story/skills/story-import/references/structure-mapping-long.md +442 -0
  234. package/lib/oh-story/skills/story-import/references/structure-mapping-short.md +175 -0
  235. package/lib/oh-story/skills/story-import/references/tracking-transaction.md +161 -0
  236. package/lib/oh-story/skills/story-import/scripts/tracking_commit.py +1139 -0
  237. package/lib/oh-story/skills/story-long-analyze/SKILL.md +329 -0
  238. package/lib/oh-story/skills/story-long-analyze/references/deconstruction-notes.md +270 -0
  239. package/lib/oh-story/skills/story-long-analyze/references/material-decomposition.md +530 -0
  240. package/lib/oh-story/skills/story-long-analyze/references/output-templates.md +559 -0
  241. package/lib/oh-story/skills/story-long-analyze/references/pipeline-ops.md +80 -0
  242. package/lib/oh-story/skills/story-long-analyze/references/style-profile-generator.md +149 -0
  243. package/lib/oh-story/skills/story-long-analyze/references/style-profile-protocol.md +133 -0
  244. package/lib/oh-story/skills/story-long-scan/SKILL.md +342 -0
  245. package/lib/oh-story/skills/story-long-scan/references/genre-trends.md +197 -0
  246. package/lib/oh-story/skills/story-long-scan/references/publishing-guide.md +114 -0
  247. package/lib/oh-story/skills/story-long-scan/references/reader-profiling.md +105 -0
  248. package/lib/oh-story/skills/story-long-scan/references/scan-output-format.md +207 -0
  249. package/lib/oh-story/skills/story-long-scan/references/topic-decision.md +78 -0
  250. package/lib/oh-story/skills/story-long-write/SKILL.md +327 -0
  251. package/lib/oh-story/skills/story-long-write/references/anti-ai-writing.md +582 -0
  252. package/lib/oh-story/skills/story-long-write/references/artifact-protocols.md +302 -0
  253. package/lib/oh-story/skills/story-long-write/references/banned-words.md +147 -0
  254. package/lib/oh-story/skills/story-long-write/references/character-basics.md +445 -0
  255. package/lib/oh-story/skills/story-long-write/references/character-design-methods.md +428 -0
  256. package/lib/oh-story/skills/story-long-write/references/character-relations.md +385 -0
  257. package/lib/oh-story/skills/story-long-write/references/commercial-core-methods.md +502 -0
  258. package/lib/oh-story/skills/story-long-write/references/cross-book-recall.md +62 -0
  259. package/lib/oh-story/skills/story-long-write/references/dialogue-mastery.md +282 -0
  260. package/lib/oh-story/skills/story-long-write/references/emotional-arc-design.md +426 -0
  261. package/lib/oh-story/skills/story-long-write/references/emotional-methods.md +209 -0
  262. package/lib/oh-story/skills/story-long-write/references/female-audience-writing.md +188 -0
  263. package/lib/oh-story/skills/story-long-write/references/format-and-structure.md +169 -0
  264. package/lib/oh-story/skills/story-long-write/references/genre-catalog.md +480 -0
  265. package/lib/oh-story/skills/story-long-write/references/genre-core-mechanics.md +415 -0
  266. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//344/270/234/346/226/271/344/273/231/344/276/240.md +51 -0
  267. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//344/274/240/347/273/237/347/216/204/345/271/273.md +51 -0
  268. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/216/206/345/217/262/345/217/244/344/273/243.md +51 -0
  269. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/216/206/345/217/262/350/204/221/346/264/236.md +51 -0
  270. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/217/214/347/224/267/344/270/273.md +53 -0
  271. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/217/244/350/250/200/350/204/221/346/264/236.md +51 -0
  272. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/217/244/351/243/216/344/270/226/346/203/205.md +51 -0
  273. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/245/263/351/242/221/346/202/254/347/226/221.md +51 -0
  274. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/245/263/351/242/221/347/247/215/347/224/260.md +51 -0
  275. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/256/253/346/226/227/345/256/205/346/226/227.md +51 -0
  276. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/271/264/344/273/243.md +53 -0
  277. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//345/277/253/347/251/277.md +51 -0
  278. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/202/254/347/226/221/347/201/265/345/274/202.md +51 -0
  279. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/202/254/347/226/221/350/204/221/346/264/236.md +51 -0
  280. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/210/230/347/245/236/350/265/230/345/251/277.md +51 -0
  281. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/212/227/346/210/230/350/260/215/346/210/230.md +51 -0
  282. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/230/237/345/205/211/347/222/200/347/222/250.md +51 -0
  283. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/260/221/345/233/275/350/250/200/346/203/205.md +51 -0
  284. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//346/270/270/346/210/217/344/275/223/350/202/262.md +51 -0
  285. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//347/216/204/345/271/273/350/204/221/346/264/236.md +53 -0
  286. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//347/216/204/345/271/273/350/250/200/346/203/205.md +51 -0
  287. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//347/216/260/350/250/200/350/204/221/346/264/236.md +51 -0
  288. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//347/247/221/345/271/273/346/234/253/344/270/226.md +51 -0
  289. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//350/201/214/345/234/272/345/251/232/346/201/213.md +51 -0
  290. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//350/245/277/346/226/271/345/245/207/345/271/273.md +51 -0
  291. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//350/261/252/351/227/250/346/200/273/350/243/201.md +53 -0
  292. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/344/277/256/347/234/237.md +51 -0
  293. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/346/227/245/345/270/270.md +53 -0
  294. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/347/247/215/347/224/260.md +51 -0
  295. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/350/204/221/346/264/236.md +53 -0
  296. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/203/275/345/270/202/351/253/230/346/255/246.md +53 -0
  297. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards//351/235/222/346/230/245/347/224/234/345/256/240.md +51 -0
  298. package/lib/oh-story/skills/story-long-write/references/genre-prose-cards.md +91 -0
  299. package/lib/oh-story/skills/story-long-write/references/genre-readers.md +235 -0
  300. package/lib/oh-story/skills/story-long-write/references/genre-writing-formulas.md +444 -0
  301. package/lib/oh-story/skills/story-long-write/references/hooks-chapter.md +225 -0
  302. package/lib/oh-story/skills/story-long-write/references/hooks-paragraph.md +146 -0
  303. package/lib/oh-story/skills/story-long-write/references/hooks-suspense.md +352 -0
  304. package/lib/oh-story/skills/story-long-write/references/opening-design.md +349 -0
  305. package/lib/oh-story/skills/story-long-write/references/outline-conflict.md +457 -0
  306. package/lib/oh-story/skills/story-long-write/references/outline-methods.md +287 -0
  307. package/lib/oh-story/skills/story-long-write/references/outline-rhythm.md +389 -0
  308. package/lib/oh-story/skills/story-long-write/references/outline-structure-theory.md +326 -0
  309. package/lib/oh-story/skills/story-long-write/references/plot-core-methods.md +522 -0
  310. package/lib/oh-story/skills/story-long-write/references/plot-emotion-system.md +354 -0
  311. package/lib/oh-story/skills/story-long-write/references/plot-frameworks.md +711 -0
  312. package/lib/oh-story/skills/story-long-write/references/plot-special-topics.md +622 -0
  313. package/lib/oh-story/skills/story-long-write/references/quality-checklist.md +184 -0
  314. package/lib/oh-story/skills/story-long-write/references/reader-contract-and-progression.md +109 -0
  315. package/lib/oh-story/skills/story-long-write/references/reversal-toolkit.md +349 -0
  316. package/lib/oh-story/skills/story-long-write/references/state-tracking.md +108 -0
  317. package/lib/oh-story/skills/story-long-write/references/style-combat-face.md +409 -0
  318. package/lib/oh-story/skills/story-long-write/references/style-craft.md +347 -0
  319. package/lib/oh-story/skills/story-long-write/references/style-genre-modules.md +510 -0
  320. package/lib/oh-story/skills/story-long-write/references/tracking-transaction.md +161 -0
  321. package/lib/oh-story/skills/story-long-write/references/workflow-chapter.md +140 -0
  322. package/lib/oh-story/skills/story-long-write/references/workflow-daily.md +171 -0
  323. package/lib/oh-story/skills/story-long-write/references/workflow-revision.md +99 -0
  324. package/lib/oh-story/skills/story-long-write/references/workflow-setup.md +284 -0
  325. package/lib/oh-story/skills/story-long-write/references/writing-craft.md +421 -0
  326. package/lib/oh-story/skills/story-long-write/scripts/check-ai-patterns.js +1371 -0
  327. package/lib/oh-story/skills/story-long-write/scripts/check-degeneration.js +330 -0
  328. package/lib/oh-story/skills/story-long-write/scripts/check-outline-copy.js +241 -0
  329. package/lib/oh-story/skills/story-long-write/scripts/normalize-punctuation.js +410 -0
  330. package/lib/oh-story/skills/story-long-write/scripts/tracking_commit.py +1139 -0
  331. package/lib/oh-story/skills/story-review/SKILL.md +483 -0
  332. package/lib/oh-story/skills/story-review/references/anti-ai-writing.md +582 -0
  333. package/lib/oh-story/skills/story-review/references/banned-words.md +147 -0
  334. package/lib/oh-story/skills/story-review/references/character-relations.md +385 -0
  335. package/lib/oh-story/skills/story-review/references/dialogue-mastery.md +282 -0
  336. package/lib/oh-story/skills/story-review/references/plot-core-methods.md +522 -0
  337. package/lib/oh-story/skills/story-review/references/quality-checklist.md +250 -0
  338. package/lib/oh-story/skills/story-review/references/quality-rubric.md +55 -0
  339. package/lib/oh-story/skills/story-review/references/rubrics/fanqie.md +18 -0
  340. package/lib/oh-story/skills/story-review/references/rubrics/qidian.md +19 -0
  341. package/lib/oh-story/skills/story-review/references/rubrics/zhihu.md +20 -0
  342. package/lib/oh-story/skills/story-review/references/tracking-transaction.md +161 -0
  343. package/lib/oh-story/skills/story-review/scripts/check-ai-patterns.js +1371 -0
  344. package/lib/oh-story/skills/story-review/scripts/check-degeneration.js +330 -0
  345. package/lib/oh-story/skills/story-review/scripts/normalize-punctuation.js +410 -0
  346. package/lib/oh-story/skills/story-review/scripts/tracking_commit.py +1139 -0
  347. package/lib/oh-story/skills/story-setup/SKILL.md +495 -0
  348. package/lib/oh-story/skills/story-setup/references/agent-references/anti-ai-writing.md +582 -0
  349. package/lib/oh-story/skills/story-setup/references/agent-references/banned-words.md +147 -0
  350. package/lib/oh-story/skills/story-setup/references/agent-references/character-basics.md +445 -0
  351. package/lib/oh-story/skills/story-setup/references/agent-references/character-design-methods.md +428 -0
  352. package/lib/oh-story/skills/story-setup/references/agent-references/character-relations.md +385 -0
  353. package/lib/oh-story/skills/story-setup/references/agent-references/dialogue-mastery.md +282 -0
  354. package/lib/oh-story/skills/story-setup/references/agent-references/emotional-arc-design.md +426 -0
  355. package/lib/oh-story/skills/story-setup/references/agent-references/emotional-methods.md +179 -0
  356. package/lib/oh-story/skills/story-setup/references/agent-references/format-and-structure.md +169 -0
  357. package/lib/oh-story/skills/story-setup/references/agent-references/genre-catalog.md +480 -0
  358. package/lib/oh-story/skills/story-setup/references/agent-references/genre-core-mechanics.md +415 -0
  359. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//344/270/234/346/226/271/344/273/231/344/276/240.md +51 -0
  360. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//344/274/240/347/273/237/347/216/204/345/271/273.md +51 -0
  361. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/216/206/345/217/262/345/217/244/344/273/243.md +51 -0
  362. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/216/206/345/217/262/350/204/221/346/264/236.md +51 -0
  363. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/217/214/347/224/267/344/270/273.md +53 -0
  364. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/217/244/350/250/200/350/204/221/346/264/236.md +51 -0
  365. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/217/244/351/243/216/344/270/226/346/203/205.md +51 -0
  366. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/245/263/351/242/221/346/202/254/347/226/221.md +51 -0
  367. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/245/263/351/242/221/347/247/215/347/224/260.md +51 -0
  368. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/256/253/346/226/227/345/256/205/346/226/227.md +51 -0
  369. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/271/264/344/273/243.md +53 -0
  370. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//345/277/253/347/251/277.md +51 -0
  371. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/202/254/347/226/221/347/201/265/345/274/202.md +51 -0
  372. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/202/254/347/226/221/350/204/221/346/264/236.md +51 -0
  373. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/210/230/347/245/236/350/265/230/345/251/277.md +51 -0
  374. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/212/227/346/210/230/350/260/215/346/210/230.md +51 -0
  375. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/230/237/345/205/211/347/222/200/347/222/250.md +51 -0
  376. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/260/221/345/233/275/350/250/200/346/203/205.md +51 -0
  377. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//346/270/270/346/210/217/344/275/223/350/202/262.md +51 -0
  378. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//347/216/204/345/271/273/350/204/221/346/264/236.md +53 -0
  379. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//347/216/204/345/271/273/350/250/200/346/203/205.md +51 -0
  380. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//347/216/260/350/250/200/350/204/221/346/264/236.md +51 -0
  381. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//347/247/221/345/271/273/346/234/253/344/270/226.md +51 -0
  382. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//350/201/214/345/234/272/345/251/232/346/201/213.md +51 -0
  383. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//350/245/277/346/226/271/345/245/207/345/271/273.md +51 -0
  384. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//350/261/252/351/227/250/346/200/273/350/243/201.md +53 -0
  385. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/344/277/256/347/234/237.md +51 -0
  386. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/346/227/245/345/270/270.md +53 -0
  387. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/347/247/215/347/224/260.md +51 -0
  388. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/350/204/221/346/264/236.md +53 -0
  389. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/203/275/345/270/202/351/253/230/346/255/246.md +53 -0
  390. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards//351/235/222/346/230/245/347/224/234/345/256/240.md +51 -0
  391. package/lib/oh-story/skills/story-setup/references/agent-references/genre-prose-cards.md +91 -0
  392. package/lib/oh-story/skills/story-setup/references/agent-references/genre-readers.md +235 -0
  393. package/lib/oh-story/skills/story-setup/references/agent-references/genre-writing-formulas.md +444 -0
  394. package/lib/oh-story/skills/story-setup/references/agent-references/hooks-chapter.md +225 -0
  395. package/lib/oh-story/skills/story-setup/references/agent-references/hooks-suspense.md +352 -0
  396. package/lib/oh-story/skills/story-setup/references/agent-references/opening-design.md +349 -0
  397. package/lib/oh-story/skills/story-setup/references/agent-references/outline-conflict.md +457 -0
  398. package/lib/oh-story/skills/story-setup/references/agent-references/outline-methods.md +287 -0
  399. package/lib/oh-story/skills/story-setup/references/agent-references/outline-rhythm.md +389 -0
  400. package/lib/oh-story/skills/story-setup/references/agent-references/plot-core-methods.md +522 -0
  401. package/lib/oh-story/skills/story-setup/references/agent-references/quality-checklist.md +247 -0
  402. package/lib/oh-story/skills/story-setup/references/agent-references/reversal-toolkit.md +349 -0
  403. package/lib/oh-story/skills/story-setup/references/agent-references/style-combat-face.md +409 -0
  404. package/lib/oh-story/skills/story-setup/references/agent-references/style-genre-modules.md +510 -0
  405. package/lib/oh-story/skills/story-setup/references/agent-references/writing-craft.md +421 -0
  406. package/lib/oh-story/skills/story-short-analyze/SKILL.md +278 -0
  407. package/lib/oh-story/skills/story-short-analyze/references/anti-ai-writing.md +582 -0
  408. package/lib/oh-story/skills/story-short-analyze/references/banned-words.md +147 -0
  409. package/lib/oh-story/skills/story-short-analyze/references/character-basics.md +453 -0
  410. package/lib/oh-story/skills/story-short-analyze/references/character-design-methods.md +438 -0
  411. package/lib/oh-story/skills/story-short-analyze/references/character-relations.md +395 -0
  412. package/lib/oh-story/skills/story-short-analyze/references/deconstruction-examples.md +79 -0
  413. package/lib/oh-story/skills/story-short-analyze/references/genre-catalog.md +490 -0
  414. package/lib/oh-story/skills/story-short-analyze/references/genre-core-mechanics.md +425 -0
  415. package/lib/oh-story/skills/story-short-analyze/references/genre-readers.md +245 -0
  416. package/lib/oh-story/skills/story-short-analyze/references/genre-writing-formulas.md +453 -0
  417. package/lib/oh-story/skills/story-short-analyze/references/genre-writing-techniques.md +475 -0
  418. package/lib/oh-story/skills/story-short-analyze/references/hooks-chapter.md +225 -0
  419. package/lib/oh-story/skills/story-short-analyze/references/hooks-paragraph.md +146 -0
  420. package/lib/oh-story/skills/story-short-analyze/references/hooks-suspense.md +352 -0
  421. package/lib/oh-story/skills/story-short-analyze/references/material-decomposition.md +685 -0
  422. package/lib/oh-story/skills/story-short-analyze/references/output-contract.md +165 -0
  423. package/lib/oh-story/skills/story-short-analyze/references/output-templates.md +561 -0
  424. package/lib/oh-story/skills/story-short-analyze/references/quality-checklist.md +242 -0
  425. package/lib/oh-story/skills/story-short-analyze/references/real-market-data.md +90 -0
  426. package/lib/oh-story/skills/story-short-analyze/references/zhihu-style.md +54 -0
  427. package/lib/oh-story/skills/story-short-scan/SKILL.md +212 -0
  428. package/lib/oh-story/skills/story-short-scan/references/real-market-data.md +90 -0
  429. package/lib/oh-story/skills/story-short-write/SKILL.md +433 -0
  430. package/lib/oh-story/skills/story-short-write/references/banned-words.md +147 -0
  431. package/lib/oh-story/skills/story-short-write/references/cross-book-recall.md +62 -0
  432. package/lib/oh-story/skills/story-short-write/references/dialogue-mastery.md +282 -0
  433. package/lib/oh-story/skills/story-short-write/references/emotional-methods.md +179 -0
  434. package/lib/oh-story/skills/story-short-write/references/genre-styles//344/270/226/346/203/205/346/211/223/350/204/270.md +159 -0
  435. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/217/214/347/224/267/344/270/273.md +156 -0
  436. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/244/215/344/273/207/346/211/223/350/204/270.md +163 -0
  437. package/lib/oh-story/skills/story-short-write/references/genre-styles//345/256/205/346/226/227/345/256/253/346/226/227.md +167 -0
  438. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/200/273/350/243/201/350/261/252/351/227/250.md +178 -0
  439. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/202/254/347/226/221.md +166 -0
  440. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/260/221/344/277/227/346/200/252/350/260/210.md +159 -0
  441. package/lib/oh-story/skills/story-short-write/references/genre-styles//346/262/231/351/233/225/350/204/221/346/264/236.md +151 -0
  442. package/lib/oh-story/skills/story-short-write/references/genre-styles//347/224/234/345/256/240.md +153 -0
  443. package/lib/oh-story/skills/story-short-write/references/genre-styles//350/277/275/345/246/273/347/201/253/350/221/254/345/234/272.md +210 -0
  444. package/lib/oh-story/skills/story-short-write/references/genre-writing-formulas.md +444 -0
  445. package/lib/oh-story/skills/story-short-write/references/genre-writing-techniques.md +488 -0
  446. package/lib/oh-story/skills/story-short-write/references/hooks-chapter.md +225 -0
  447. package/lib/oh-story/skills/story-short-write/references/hooks-paragraph.md +146 -0
  448. package/lib/oh-story/skills/story-short-write/references/hooks-suspense.md +352 -0
  449. package/lib/oh-story/skills/story-short-write/references/output-contract.md +165 -0
  450. package/lib/oh-story/skills/story-short-write/references/quality-checklist.md +222 -0
  451. package/lib/oh-story/skills/story-short-write/references/reversal-toolkit.md +349 -0
  452. package/lib/oh-story/skills/story-short-write/references/short-craft.md +185 -0
  453. package/lib/oh-story/skills/story-short-write/references/short-deslop.md +133 -0
  454. package/lib/oh-story/skills/story-short-write/references/short-format.md +167 -0
  455. package/lib/oh-story/skills/story-short-write/references/submission-craft.md +35 -0
  456. package/lib/oh-story/skills/story-short-write/references/villain-and-reveal.md +158 -0
  457. package/lib/oh-story/skills/story-short-write/references/writing-workflow.md +153 -0
  458. package/lib/oh-story/skills/story-short-write/scripts/check-ai-patterns.js +1371 -0
  459. package/lib/oh-story/skills/story-short-write/scripts/check-degeneration.js +330 -0
  460. package/lib/oh-story/skills/story-short-write/scripts/check-outline-copy.js +241 -0
  461. package/lib/oh-story/skills/story-short-write/scripts/normalize-punctuation.js +410 -0
  462. package/lib/types/client/file-activity.d.ts +30 -0
  463. package/lib/types/client/file-activity.d.ts.map +1 -0
  464. package/lib/types/client/file-tree.d.ts +20 -0
  465. package/lib/types/client/file-tree.d.ts.map +1 -0
  466. package/lib/types/client/index.d.ts +12 -0
  467. package/lib/types/client/index.d.ts.map +1 -0
  468. package/lib/types/client/jsonl-preview.d.ts +15 -0
  469. package/lib/types/client/jsonl-preview.d.ts.map +1 -0
  470. package/lib/types/client/markdown-preview.d.ts +38 -0
  471. package/lib/types/client/markdown-preview.d.ts.map +1 -0
  472. package/lib/types/index.d.ts +25 -0
  473. package/lib/types/index.d.ts.map +1 -0
  474. package/lib/types/native-hooks.d.ts +16 -0
  475. package/lib/types/native-hooks.d.ts.map +1 -0
  476. package/lib/types/role-provider.d.ts +6 -0
  477. package/lib/types/role-provider.d.ts.map +1 -0
  478. package/lib/types/role-tool.d.ts +10 -0
  479. package/lib/types/role-tool.d.ts.map +1 -0
  480. package/lib/types/skill-provider.d.ts +15 -0
  481. package/lib/types/skill-provider.d.ts.map +1 -0
  482. package/lib/types/workspace-request-trust.d.ts +13 -0
  483. package/lib/types/workspace-request-trust.d.ts.map +1 -0
  484. package/lib/types/workspace-route.d.ts +10 -0
  485. package/lib/types/workspace-route.d.ts.map +1 -0
  486. package/lib/types/workspace-security.d.ts +9 -0
  487. package/lib/types/workspace-security.d.ts.map +1 -0
  488. package/package.json +107 -0
@@ -0,0 +1,1635 @@
1
+ #!/usr/bin/env python3
2
+ """Prepare and execute explicitly confirmed short-drama media jobs.
3
+
4
+ A configured adapter receives one bounded JSON job on stdin and returns local
5
+ output files on stdout. The adapter is launched without a shell and only after
6
+ a confirmation bound to the exact job and current project inputs. Optional
7
+ provider adapters can ship with this skill, but credentials and adapter config
8
+ remain outside creator projects.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import contextlib
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import re
19
+ import stat
20
+ import subprocess
21
+ import sys
22
+ import tempfile
23
+ import uuid
24
+ from collections.abc import Iterator, Mapping
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path, PurePosixPath
27
+ from typing import Any, BinaryIO
28
+
29
+ MINIMUM_PYTHON = (3, 9)
30
+ if sys.version_info < MINIMUM_PYTHON:
31
+ raise SystemExit(
32
+ "short-drama-produce needs Python {}.{} or newer".format(*MINIMUM_PYTHON)
33
+ )
34
+
35
+ PROJECT_FILE = "short-drama.json"
36
+ PRODUCTION_ROOT = Path(".short-drama/production")
37
+ JOB_SCHEMA = "1.0"
38
+ JOB_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}")
39
+ MAX_JOB_BYTES = 256 * 1024
40
+ MAX_RUN_RECORD_BYTES = 256 * 1024
41
+ MAX_ADAPTER_RESPONSE_BYTES = 1024 * 1024
42
+ MAX_OUTPUT_BYTES = 512 * 1024 * 1024
43
+ MAX_INPUT_BYTES = 50 * 1024 * 1024
44
+ MAX_TOTAL_INPUT_BYTES = 200 * 1024 * 1024
45
+ MAX_TIMEOUT_SECONDS = 3600
46
+ PUBLIC_ERROR_CATEGORIES = {
47
+ "authentication",
48
+ "configuration",
49
+ "contract",
50
+ "invalid_request",
51
+ "network",
52
+ "permission",
53
+ "provider_response",
54
+ "rate_limit",
55
+ "server",
56
+ "timeout",
57
+ }
58
+ PUBLIC_ERROR_TOKEN_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,199}")
59
+ ALLOWED_JOB_KEYS = {
60
+ "schema_version",
61
+ "job_id",
62
+ "modality",
63
+ "adapter",
64
+ "prompt",
65
+ "source",
66
+ "references",
67
+ "outputs",
68
+ "parameters",
69
+ "overwrite",
70
+ }
71
+ STORED_EXECUTION_KEYS = ALLOWED_JOB_KEYS | {"inputs"}
72
+ STORED_JOB_KEYS = STORED_EXECUTION_KEYS | {"fingerprint", "prepared_at"}
73
+ SECRET_KEYS = {
74
+ "authorization",
75
+ "credential",
76
+ "credentials",
77
+ "password",
78
+ "secret",
79
+ "token",
80
+ "access_token",
81
+ "api_key",
82
+ "apikey",
83
+ }
84
+ MEDIA_EXTENSIONS = {
85
+ "image": {".png", ".jpg", ".jpeg", ".webp"},
86
+ "video": {".mp4", ".mov", ".webm"},
87
+ "tts": {".wav", ".mp3", ".m4a", ".aac", ".flac", ".opus"},
88
+ "music": {".wav", ".mp3", ".m4a", ".aac", ".flac", ".opus"},
89
+ }
90
+ MEDIA_TYPES = {
91
+ ".png": "image/png",
92
+ ".jpg": "image/jpeg",
93
+ ".jpeg": "image/jpeg",
94
+ ".webp": "image/webp",
95
+ ".mp4": "video/mp4",
96
+ ".mov": "video/quicktime",
97
+ ".webm": "video/webm",
98
+ ".wav": "audio/wav",
99
+ ".mp3": "audio/mpeg",
100
+ ".m4a": "audio/mp4",
101
+ ".aac": "audio/aac",
102
+ ".flac": "audio/flac",
103
+ ".opus": "audio/ogg",
104
+ }
105
+
106
+
107
+ class ConfirmationRequiredError(RuntimeError):
108
+ """The exact current job has not been explicitly confirmed."""
109
+
110
+
111
+ class AdapterError(RuntimeError):
112
+ """A configured media adapter failed or broke its output contract."""
113
+
114
+ def __init__(
115
+ self, message: str, *, public_error: Mapping[str, Any] | None = None
116
+ ) -> None:
117
+ super().__init__(message)
118
+ self.public_error = dict(public_error) if public_error is not None else None
119
+
120
+
121
+ def utc_now() -> str:
122
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
123
+
124
+
125
+ def sha256_bytes(content: bytes) -> str:
126
+ return hashlib.sha256(content).hexdigest()
127
+
128
+
129
+ def find_project(start: Path) -> Path:
130
+ candidate = start.expanduser().resolve()
131
+ if candidate.is_file():
132
+ candidate = candidate.parent
133
+ for directory in (candidate, *candidate.parents):
134
+ if (directory / PROJECT_FILE).is_file():
135
+ return directory
136
+ raise FileNotFoundError(f"no {PROJECT_FILE} found from {start}")
137
+
138
+
139
+ def _relative_path(value: object, *, output: bool = False) -> str:
140
+ if not isinstance(value, str):
141
+ raise ValueError("project paths must be strings")
142
+ raw = value.replace("\\", "/")
143
+ pure = PurePosixPath(raw)
144
+ if not raw or pure.is_absolute() or any(part in {"", ".", ".."} for part in pure.parts):
145
+ raise ValueError(f"unsafe project-relative path: {value}")
146
+ if pure.parts[0].casefold() == ".short-drama" or pure.name.casefold() == PROJECT_FILE:
147
+ raise ValueError(f"operational project path is not allowed: {value}")
148
+ if output:
149
+ parts = pure.parts
150
+ top_level_production = len(parts) >= 2 and parts[0].casefold() == "production"
151
+ episode_production = (
152
+ len(parts) >= 4
153
+ and parts[0] in {"剧集", "episodes"}
154
+ and re.fullmatch(r"EP\d{3,}", parts[1], re.IGNORECASE) is not None
155
+ and parts[2] in {"制作成果", "production"}
156
+ )
157
+ if not top_level_production and not episode_production:
158
+ raise ValueError(
159
+ "media outputs must use top-level production/ or "
160
+ "剧集|episodes/<EP>/制作成果|production/"
161
+ )
162
+ return pure.as_posix()
163
+
164
+
165
+ def _project_file(root: Path, relative: str, *, create_parent: bool = False) -> Path:
166
+ target = root / relative
167
+ current = root
168
+ for part in PurePosixPath(relative).parts[:-1]:
169
+ current /= part
170
+ if current.exists() and (current.is_symlink() or not current.is_dir()):
171
+ raise ValueError(f"unsafe project directory: {part}")
172
+ if create_parent and not current.exists():
173
+ current.mkdir()
174
+ if target.exists() and (target.is_symlink() or not target.is_file()):
175
+ raise ValueError(f"unsafe project file: {relative}")
176
+ if not target.parent.resolve().is_relative_to(root):
177
+ raise ValueError(f"path escapes project root: {relative}")
178
+ return target
179
+
180
+
181
+ def _is_link_or_reparse(details: os.stat_result) -> bool:
182
+ attributes = getattr(details, "st_file_attributes", 0)
183
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
184
+ return stat.S_ISLNK(details.st_mode) or bool(attributes & reparse_flag)
185
+
186
+
187
+ @contextlib.contextmanager
188
+ def _open_project_input(root: Path, relative: str) -> Iterator[BinaryIO]:
189
+ """Open one regular project input without following path components on POSIX."""
190
+ parts = PurePosixPath(relative).parts
191
+ if not parts:
192
+ raise ValueError("job input path is empty")
193
+ if os.name != "nt" and os.open in os.supports_dir_fd:
194
+ directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
195
+ nofollow = getattr(os, "O_NOFOLLOW", 0)
196
+ directory_fd = os.open(root, directory_flags)
197
+ file_fd: int | None = None
198
+ try:
199
+ for part in parts[:-1]:
200
+ next_fd = os.open(
201
+ part,
202
+ directory_flags | nofollow,
203
+ dir_fd=directory_fd,
204
+ )
205
+ os.close(directory_fd)
206
+ directory_fd = next_fd
207
+ file_fd = os.open(
208
+ parts[-1], os.O_RDONLY | nofollow, dir_fd=directory_fd
209
+ )
210
+ details = os.fstat(file_fd)
211
+ if not stat.S_ISREG(details.st_mode):
212
+ raise ValueError(f"job input is not a regular file: {relative}")
213
+ with os.fdopen(file_fd, "rb", closefd=True) as handle:
214
+ file_fd = None
215
+ yield handle
216
+ except FileNotFoundError as exc:
217
+ raise FileNotFoundError(f"job input is missing: {relative}") from exc
218
+ except OSError as exc:
219
+ raise ValueError(f"unsafe job input path: {relative}") from exc
220
+ finally:
221
+ if file_fd is not None:
222
+ os.close(file_fd)
223
+ os.close(directory_fd)
224
+ return
225
+
226
+ # Windows lacks portable openat/O_NOFOLLOW support. Reject reparse/symlink
227
+ # components, pin the final file handle, and verify its identity before use.
228
+ path = root
229
+ for part in parts:
230
+ path /= part
231
+ try:
232
+ details = path.lstat()
233
+ except FileNotFoundError as exc:
234
+ raise FileNotFoundError(f"job input is missing: {relative}") from exc
235
+ if _is_link_or_reparse(details):
236
+ raise ValueError(f"unsafe job input path: {relative}")
237
+ if not path.resolve().is_relative_to(root):
238
+ raise ValueError(f"job input escapes project root: {relative}")
239
+ before = path.stat(follow_symlinks=False)
240
+ if not stat.S_ISREG(before.st_mode):
241
+ raise ValueError(f"job input is not a regular file: {relative}")
242
+ with path.open("rb") as handle:
243
+ opened = os.fstat(handle.fileno())
244
+ if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
245
+ raise ValueError(f"job input changed while opening: {relative}")
246
+ yield handle
247
+
248
+
249
+ def _hash_project_file(root: Path, relative: str) -> str:
250
+ digest = hashlib.sha256()
251
+ size = 0
252
+ with _open_project_input(root, relative) as handle:
253
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
254
+ size += len(chunk)
255
+ if size > MAX_INPUT_BYTES:
256
+ raise ValueError(f"job input exceeds the size limit: {relative}")
257
+ digest.update(chunk)
258
+ return digest.hexdigest()
259
+
260
+
261
+ def _snapshot_inputs(
262
+ root: Path, job: Mapping[str, Any], snapshot_root: Path
263
+ ) -> None:
264
+ inputs = job.get("inputs")
265
+ if not isinstance(inputs, Mapping):
266
+ raise ConfirmationRequiredError("stored job inputs are invalid")
267
+ total = 0
268
+ for relative_value, expected_value in inputs.items():
269
+ relative = _relative_path(relative_value)
270
+ if not isinstance(expected_value, str) or re.fullmatch(
271
+ r"[0-9a-f]{64}", expected_value
272
+ ) is None:
273
+ raise ConfirmationRequiredError("stored job input hash is invalid")
274
+ target = snapshot_root.joinpath(*PurePosixPath(relative).parts)
275
+ target.parent.mkdir(parents=True, exist_ok=True)
276
+ digest = hashlib.sha256()
277
+ size = 0
278
+ try:
279
+ with _open_project_input(root, relative) as incoming, target.open(
280
+ "xb"
281
+ ) as outgoing:
282
+ for chunk in iter(lambda: incoming.read(1024 * 1024), b""):
283
+ size += len(chunk)
284
+ total += len(chunk)
285
+ if size > MAX_INPUT_BYTES or total > MAX_TOTAL_INPUT_BYTES:
286
+ raise ConfirmationRequiredError(
287
+ "job inputs exceed the production size limit"
288
+ )
289
+ digest.update(chunk)
290
+ outgoing.write(chunk)
291
+ except (FileNotFoundError, OSError, ValueError) as exc:
292
+ raise ConfirmationRequiredError(
293
+ "job inputs changed; prepare and confirm again"
294
+ ) from exc
295
+ if digest.hexdigest() != expected_value:
296
+ raise ConfirmationRequiredError(
297
+ "job inputs changed; prepare and confirm again"
298
+ )
299
+
300
+
301
+ def _canonical(document: object) -> bytes:
302
+ return json.dumps(
303
+ document, ensure_ascii=False, sort_keys=True, separators=(",", ":")
304
+ ).encode("utf-8")
305
+
306
+
307
+ def _check_metadata_parts(parts: tuple[str, ...]) -> None:
308
+ if any(
309
+ not part
310
+ or part in {".", ".."}
311
+ or "/" in part
312
+ or "\\" in part
313
+ for part in parts
314
+ ):
315
+ raise ValueError("production metadata path is invalid")
316
+
317
+
318
+ @contextlib.contextmanager
319
+ def _metadata_directory(
320
+ root: Path, parts: tuple[str, ...], *, create: bool
321
+ ) -> Iterator[tuple[Path, int | None]]:
322
+ """Pin one metadata directory without following project-controlled parents."""
323
+ root = root.resolve()
324
+ _check_metadata_parts(parts)
325
+ all_parts = (".short-drama", "production", *parts)
326
+ directory = root.joinpath(*all_parts)
327
+ if os.name != "nt" and os.open in os.supports_dir_fd:
328
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
329
+ nofollow = getattr(os, "O_NOFOLLOW", 0)
330
+ directory_fd = os.open(root, flags)
331
+ try:
332
+ try:
333
+ for part in all_parts:
334
+ try:
335
+ next_fd = os.open(
336
+ part, flags | nofollow, dir_fd=directory_fd
337
+ )
338
+ except FileNotFoundError:
339
+ if not create:
340
+ raise
341
+ try:
342
+ os.mkdir(part, mode=0o700, dir_fd=directory_fd)
343
+ except FileExistsError:
344
+ pass
345
+ next_fd = os.open(
346
+ part, flags | nofollow, dir_fd=directory_fd
347
+ )
348
+ details = os.fstat(next_fd)
349
+ if not stat.S_ISDIR(details.st_mode):
350
+ os.close(next_fd)
351
+ raise ValueError("production metadata directory is unsafe")
352
+ os.close(directory_fd)
353
+ directory_fd = next_fd
354
+ except FileNotFoundError:
355
+ raise
356
+ except OSError as exc:
357
+ raise ValueError("production metadata directory is unsafe") from exc
358
+ yield directory, directory_fd
359
+ finally:
360
+ os.close(directory_fd)
361
+ return
362
+
363
+ current = root
364
+ for part in all_parts:
365
+ current /= part
366
+ try:
367
+ details = current.lstat()
368
+ except FileNotFoundError:
369
+ if not create:
370
+ raise
371
+ try:
372
+ current.mkdir(mode=0o700)
373
+ except FileExistsError:
374
+ pass
375
+ details = current.lstat()
376
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
377
+ raise ValueError("production metadata directory is unsafe")
378
+ if not current.resolve().is_relative_to(root):
379
+ raise ValueError("production metadata directory escapes the project")
380
+ yield current, None
381
+
382
+
383
+ def _metadata_atomic_json(
384
+ root: Path, directory_parts: tuple[str, ...], name: str, document: Mapping[str, Any]
385
+ ) -> None:
386
+ _check_metadata_parts((name,))
387
+ content = _canonical(document) + b"\n"
388
+ temporary_name = f".{name}.{uuid.uuid4().hex}.tmp"
389
+ with _metadata_directory(root, directory_parts, create=True) as (
390
+ directory,
391
+ directory_fd,
392
+ ):
393
+ if directory_fd is not None:
394
+ descriptor = -1
395
+ try:
396
+ descriptor = os.open(
397
+ temporary_name,
398
+ os.O_WRONLY
399
+ | os.O_CREAT
400
+ | os.O_EXCL
401
+ | getattr(os, "O_NOFOLLOW", 0),
402
+ 0o600,
403
+ dir_fd=directory_fd,
404
+ )
405
+ with os.fdopen(descriptor, "wb", closefd=True) as handle:
406
+ descriptor = -1
407
+ handle.write(content)
408
+ handle.flush()
409
+ os.fsync(handle.fileno())
410
+ os.replace(
411
+ temporary_name,
412
+ name,
413
+ src_dir_fd=directory_fd,
414
+ dst_dir_fd=directory_fd,
415
+ )
416
+ os.fsync(directory_fd)
417
+ finally:
418
+ if descriptor >= 0:
419
+ os.close(descriptor)
420
+ try:
421
+ os.unlink(temporary_name, dir_fd=directory_fd)
422
+ except FileNotFoundError:
423
+ pass
424
+ return
425
+
426
+ before = directory.stat(follow_symlinks=False)
427
+ temporary = directory / temporary_name
428
+ try:
429
+ with temporary.open("xb") as handle:
430
+ opened = os.fstat(handle.fileno())
431
+ if not stat.S_ISREG(opened.st_mode):
432
+ raise ValueError("production metadata file is unsafe")
433
+ handle.write(content)
434
+ handle.flush()
435
+ os.fsync(handle.fileno())
436
+ after = directory.lstat()
437
+ if (
438
+ _is_link_or_reparse(after)
439
+ or not stat.S_ISDIR(after.st_mode)
440
+ or (after.st_dev, after.st_ino) != (before.st_dev, before.st_ino)
441
+ ):
442
+ raise ValueError("production metadata directory changed")
443
+ os.replace(temporary, directory / name)
444
+ finally:
445
+ try:
446
+ temporary.unlink()
447
+ except FileNotFoundError:
448
+ pass
449
+
450
+
451
+ def _metadata_read_json(
452
+ root: Path, directory_parts: tuple[str, ...], name: str, *, maximum: int
453
+ ) -> object:
454
+ _check_metadata_parts((name,))
455
+ with _metadata_directory(root, directory_parts, create=False) as (
456
+ directory,
457
+ directory_fd,
458
+ ):
459
+ if directory_fd is not None:
460
+ descriptor = os.open(
461
+ name,
462
+ os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
463
+ dir_fd=directory_fd,
464
+ )
465
+ with os.fdopen(descriptor, "rb", closefd=True) as handle:
466
+ details = os.fstat(handle.fileno())
467
+ if not stat.S_ISREG(details.st_mode) or details.st_size > maximum:
468
+ raise ValueError("production metadata file is unsafe")
469
+ raw = handle.read(maximum + 1)
470
+ else:
471
+ path = directory / name
472
+ before = path.lstat()
473
+ if (
474
+ _is_link_or_reparse(before)
475
+ or not stat.S_ISREG(before.st_mode)
476
+ or before.st_size > maximum
477
+ ):
478
+ raise ValueError("production metadata file is unsafe")
479
+ with path.open("rb") as handle:
480
+ opened = os.fstat(handle.fileno())
481
+ if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
482
+ raise ValueError("production metadata file changed while opening")
483
+ raw = handle.read(maximum + 1)
484
+ if len(raw) > maximum:
485
+ raise ValueError("production metadata file is too large")
486
+ return json.loads(raw.decode("utf-8"))
487
+
488
+
489
+ def _metadata_unlink(
490
+ root: Path, directory_parts: tuple[str, ...], name: str
491
+ ) -> None:
492
+ _check_metadata_parts((name,))
493
+ with _metadata_directory(root, directory_parts, create=False) as (
494
+ directory,
495
+ directory_fd,
496
+ ):
497
+ if directory_fd is not None:
498
+ os.unlink(name, dir_fd=directory_fd)
499
+ else:
500
+ path = directory / name
501
+ details = path.lstat()
502
+ if _is_link_or_reparse(details) or not stat.S_ISREG(details.st_mode):
503
+ raise ValueError("production metadata file is unsafe")
504
+ path.unlink()
505
+
506
+
507
+ def _metadata_json_names(root: Path, directory_parts: tuple[str, ...]) -> list[str]:
508
+ try:
509
+ with _metadata_directory(root, directory_parts, create=False) as (
510
+ directory,
511
+ directory_fd,
512
+ ):
513
+ names = os.listdir(directory_fd if directory_fd is not None else directory)
514
+ except FileNotFoundError:
515
+ return []
516
+ return sorted(
517
+ name
518
+ for name in names
519
+ if isinstance(name, str)
520
+ and name.endswith(".json")
521
+ and "/" not in name
522
+ and "\\" not in name
523
+ )
524
+
525
+
526
+ @contextlib.contextmanager
527
+ def _project_lock(root: Path) -> Iterator[None]:
528
+ with _metadata_directory(root, (), create=True) as (directory, directory_fd):
529
+ if directory_fd is not None:
530
+ descriptor = os.open(
531
+ "lock",
532
+ os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
533
+ 0o600,
534
+ dir_fd=directory_fd,
535
+ )
536
+ details = os.fstat(descriptor)
537
+ if not stat.S_ISREG(details.st_mode):
538
+ os.close(descriptor)
539
+ raise ValueError("production lock is unsafe")
540
+ handle_context = os.fdopen(descriptor, "a+b", closefd=True)
541
+ else:
542
+ lock_path = directory / "lock"
543
+ before: os.stat_result | None
544
+ try:
545
+ before = lock_path.lstat()
546
+ except FileNotFoundError:
547
+ before = None
548
+ else:
549
+ if _is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode):
550
+ raise ValueError("production lock is unsafe")
551
+ handle_context = lock_path.open("a+b")
552
+ opened = os.fstat(handle_context.fileno())
553
+ after = lock_path.lstat()
554
+ if (
555
+ not stat.S_ISREG(opened.st_mode)
556
+ or _is_link_or_reparse(after)
557
+ or not stat.S_ISREG(after.st_mode)
558
+ or (opened.st_dev, opened.st_ino) != (after.st_dev, after.st_ino)
559
+ or (
560
+ before is not None
561
+ and (before.st_dev, before.st_ino)
562
+ != (opened.st_dev, opened.st_ino)
563
+ )
564
+ ):
565
+ handle_context.close()
566
+ raise ValueError("production lock changed while opening")
567
+ with handle_context as handle:
568
+ if os.name == "nt":
569
+ import msvcrt
570
+
571
+ handle.seek(0, os.SEEK_END)
572
+ if handle.tell() == 0:
573
+ handle.write(b"0")
574
+ handle.flush()
575
+ handle.seek(0)
576
+ locking = getattr(msvcrt, "locking")
577
+ lock = getattr(msvcrt, "LK_LOCK")
578
+ unlock = getattr(msvcrt, "LK_UNLCK")
579
+ locking(handle.fileno(), lock, 1)
580
+ try:
581
+ yield
582
+ finally:
583
+ handle.seek(0)
584
+ locking(handle.fileno(), unlock, 1)
585
+ else:
586
+ import fcntl
587
+
588
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
589
+ try:
590
+ yield
591
+ finally:
592
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
593
+
594
+
595
+ def _job_key(job_id: str) -> str:
596
+ return sha256_bytes(job_id.encode("utf-8"))[:24]
597
+
598
+
599
+ def _job_path(root: Path, job_id: str) -> Path:
600
+ return root / PRODUCTION_ROOT / "jobs" / f"{_job_key(job_id)}.json"
601
+
602
+
603
+ def _confirmation_path(root: Path, job_id: str) -> Path:
604
+ return root / PRODUCTION_ROOT / "confirmations" / f"{_job_key(job_id)}.json"
605
+
606
+
607
+ def _run_directory(root: Path, job_id: str) -> Path:
608
+ return root / PRODUCTION_ROOT / "runs" / _job_key(job_id)
609
+
610
+
611
+ def _contains_secret_key(value: object) -> bool:
612
+ if isinstance(value, Mapping):
613
+ return any(
614
+ str(key).casefold() in SECRET_KEYS or _contains_secret_key(child)
615
+ for key, child in value.items()
616
+ )
617
+ if isinstance(value, list):
618
+ return any(_contains_secret_key(child) for child in value)
619
+ return False
620
+
621
+
622
+ def _string_list(value: object, *, label: str, limit: int = 32) -> list[str]:
623
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
624
+ raise ValueError(f"{label} must be a string list")
625
+ if len(value) > limit:
626
+ raise ValueError(f"{label} has too many entries")
627
+ return list(value)
628
+
629
+
630
+ def _normalize_job(root: Path, raw: object) -> dict[str, Any]:
631
+ if not isinstance(raw, Mapping) or set(raw) - ALLOWED_JOB_KEYS:
632
+ raise ValueError("job contains unsupported fields")
633
+ if raw.get("schema_version", JOB_SCHEMA) != JOB_SCHEMA:
634
+ raise ValueError("unsupported job schema")
635
+ job_id = raw.get("job_id")
636
+ if not isinstance(job_id, str) or JOB_ID_RE.fullmatch(job_id) is None:
637
+ raise ValueError("job_id must be a portable 1-80 character identifier")
638
+ modality = raw.get("modality")
639
+ if modality not in MEDIA_EXTENSIONS:
640
+ raise ValueError("modality must be image, video, tts, or music")
641
+ adapter = raw.get("adapter")
642
+ if not isinstance(adapter, str) or JOB_ID_RE.fullmatch(adapter) is None:
643
+ raise ValueError("adapter must be a portable profile name")
644
+ prompt = raw.get("prompt")
645
+ if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 100_000:
646
+ raise ValueError("prompt must be non-empty and at most 100000 characters")
647
+ source_raw = raw.get("source")
648
+ source = _relative_path(source_raw) if source_raw is not None else None
649
+ references = [
650
+ _relative_path(path)
651
+ for path in _string_list(raw.get("references", []), label="references", limit=16)
652
+ ]
653
+ outputs = [
654
+ _relative_path(path, output=True)
655
+ for path in _string_list(raw.get("outputs"), label="outputs", limit=16)
656
+ ]
657
+ if not outputs or len(set(outputs)) != len(outputs):
658
+ raise ValueError("outputs must contain unique target paths")
659
+ for output_path in outputs:
660
+ if PurePosixPath(output_path).suffix.casefold() not in MEDIA_EXTENSIONS[str(modality)]:
661
+ raise ValueError(f"output extension does not match {modality}: {output_path}")
662
+ _project_file(root, output_path)
663
+ parameters = raw.get("parameters", {})
664
+ if not isinstance(parameters, Mapping):
665
+ raise ValueError("parameters must be an object")
666
+ if _contains_secret_key(parameters):
667
+ raise ValueError("job parameters must not contain credentials or secrets")
668
+ if len(_canonical(parameters)) > 64 * 1024:
669
+ raise ValueError("job parameters are too large")
670
+ input_paths = ([source] if source is not None else []) + references
671
+ if len(set(input_paths)) != len(input_paths):
672
+ raise ValueError("source and references must be unique")
673
+ input_hashes = {path: _hash_project_file(root, path) for path in input_paths}
674
+ execution = {
675
+ "schema_version": JOB_SCHEMA,
676
+ "job_id": job_id,
677
+ "modality": modality,
678
+ "adapter": adapter,
679
+ "prompt": prompt,
680
+ "source": source,
681
+ "references": references,
682
+ "outputs": outputs,
683
+ "parameters": dict(parameters),
684
+ "overwrite": raw.get("overwrite", False),
685
+ "inputs": input_hashes,
686
+ }
687
+ if not isinstance(execution["overwrite"], bool):
688
+ raise ValueError("overwrite must be a boolean")
689
+ execution["fingerprint"] = sha256_bytes(_canonical(execution))
690
+ execution["prepared_at"] = utc_now()
691
+ return execution
692
+
693
+
694
+ def prepare_job(root: Path, job_file: Path) -> dict[str, Any]:
695
+ root = find_project(root)
696
+ if job_file.stat().st_size > MAX_JOB_BYTES:
697
+ raise ValueError("job file is too large")
698
+ raw = json.loads(job_file.read_text(encoding="utf-8"))
699
+ job = _normalize_job(root, raw)
700
+ with _project_lock(root):
701
+ if _active_run(root, str(job["job_id"])) is not None:
702
+ raise RuntimeError("this job is already running")
703
+ job_name = f"{_job_key(str(job['job_id']))}.json"
704
+ _metadata_atomic_json(root, ("jobs",), job_name, job)
705
+ try:
706
+ _metadata_unlink(root, ("confirmations",), job_name)
707
+ except FileNotFoundError:
708
+ pass
709
+ return _preview(job)
710
+
711
+
712
+ def _validate_stored_job(
713
+ root: Path, document: object, *, expected_job_id: str | None = None
714
+ ) -> dict[str, Any]:
715
+ root = root.resolve()
716
+ if not isinstance(document, dict) or set(document) != STORED_JOB_KEYS:
717
+ raise ValueError("stored job fields are invalid")
718
+ job_id = document.get("job_id")
719
+ if (
720
+ not isinstance(job_id, str)
721
+ or JOB_ID_RE.fullmatch(job_id) is None
722
+ or (expected_job_id is not None and job_id != expected_job_id)
723
+ ):
724
+ raise ValueError("stored job id is invalid")
725
+ if document.get("schema_version") != JOB_SCHEMA:
726
+ raise ValueError("stored job schema is invalid")
727
+ modality = document.get("modality")
728
+ if modality not in MEDIA_EXTENSIONS:
729
+ raise ValueError("stored job modality is invalid")
730
+ adapter = document.get("adapter")
731
+ if not isinstance(adapter, str) or JOB_ID_RE.fullmatch(adapter) is None:
732
+ raise ValueError("stored job adapter is invalid")
733
+ prompt = document.get("prompt")
734
+ if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 100_000:
735
+ raise ValueError("stored job prompt is invalid")
736
+ source = document.get("source")
737
+ if source is not None and _relative_path(source) != source:
738
+ raise ValueError("stored job source is invalid")
739
+ references = _string_list(
740
+ document.get("references"), label="stored references", limit=16
741
+ )
742
+ if any(_relative_path(reference) != reference for reference in references):
743
+ raise ValueError("stored job references are invalid")
744
+ outputs = _string_list(document.get("outputs"), label="stored outputs", limit=16)
745
+ if not outputs or len(outputs) != len(set(outputs)):
746
+ raise ValueError("stored job outputs are invalid")
747
+ for output in outputs:
748
+ if _relative_path(output, output=True) != output:
749
+ raise ValueError("stored job output is invalid")
750
+ if PurePosixPath(output).suffix.casefold() not in MEDIA_EXTENSIONS[str(modality)]:
751
+ raise ValueError("stored job output extension is invalid")
752
+ _project_file(root, output)
753
+ parameters = document.get("parameters")
754
+ if (
755
+ not isinstance(parameters, Mapping)
756
+ or _contains_secret_key(parameters)
757
+ or len(_canonical(parameters)) > 64 * 1024
758
+ ):
759
+ raise ValueError("stored job parameters are invalid")
760
+ if not isinstance(document.get("overwrite"), bool):
761
+ raise ValueError("stored job overwrite flag is invalid")
762
+ input_paths = ([source] if source is not None else []) + references
763
+ if len(input_paths) != len(set(input_paths)):
764
+ raise ValueError("stored job inputs are duplicated")
765
+ inputs = document.get("inputs")
766
+ if not isinstance(inputs, Mapping) or set(inputs) != set(input_paths):
767
+ raise ValueError("stored job input hashes are invalid")
768
+ if any(
769
+ not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None
770
+ for digest in inputs.values()
771
+ ):
772
+ raise ValueError("stored job input hash is invalid")
773
+ fingerprint = document.get("fingerprint")
774
+ execution = {key: document[key] for key in STORED_EXECUTION_KEYS}
775
+ if (
776
+ not isinstance(fingerprint, str)
777
+ or re.fullmatch(r"[0-9a-f]{64}", fingerprint) is None
778
+ or sha256_bytes(_canonical(execution)) != fingerprint
779
+ ):
780
+ raise ValueError("stored job fingerprint is invalid")
781
+ _parse_run_timestamp(document.get("prepared_at"), label="prepared_at")
782
+ return document
783
+
784
+
785
+ def _read_job(root: Path, job_id: str) -> dict[str, Any]:
786
+ if JOB_ID_RE.fullmatch(job_id) is None:
787
+ raise ValueError("invalid job_id")
788
+ document = _metadata_read_json(
789
+ root,
790
+ ("jobs",),
791
+ f"{_job_key(job_id)}.json",
792
+ maximum=MAX_JOB_BYTES,
793
+ )
794
+ return _validate_stored_job(root, document, expected_job_id=job_id)
795
+
796
+
797
+ def _preview(job: Mapping[str, Any]) -> dict[str, Any]:
798
+ confirmation = f"CONFIRM {job['job_id']} {str(job['fingerprint'])[:12]}"
799
+ return {
800
+ "job_id": job["job_id"],
801
+ "modality": job["modality"],
802
+ "adapter": job["adapter"],
803
+ "count": len(job["outputs"]),
804
+ "prompt": job["prompt"],
805
+ "source": job["source"],
806
+ "references": job["references"],
807
+ "outputs": job["outputs"],
808
+ "parameters": job["parameters"],
809
+ "overwrite": job["overwrite"],
810
+ "confirmation": confirmation,
811
+ "state": "needs_confirmation",
812
+ }
813
+
814
+
815
+ def confirm_job(root: Path, *, job_id: str, confirmation: str) -> dict[str, Any]:
816
+ root = find_project(root)
817
+ with _project_lock(root):
818
+ if _active_run(root, job_id) is not None:
819
+ raise RuntimeError("this job is already running")
820
+ job = _read_job(root, job_id)
821
+ expected = _preview(job)["confirmation"]
822
+ if confirmation != expected:
823
+ raise ConfirmationRequiredError("confirmation does not match the exact current job")
824
+ receipt = {
825
+ "schema_version": JOB_SCHEMA,
826
+ "job_id": job_id,
827
+ "fingerprint": job["fingerprint"],
828
+ "confirmed_at": utc_now(),
829
+ "consumed_at": None,
830
+ "run_id": None,
831
+ }
832
+ _metadata_atomic_json(
833
+ root, ("confirmations",), f"{_job_key(job_id)}.json", receipt
834
+ )
835
+ return {"job_id": job_id, "state": "confirmed"}
836
+
837
+
838
+ def _inputs_current(root: Path, job: Mapping[str, Any]) -> bool:
839
+ inputs = job.get("inputs")
840
+ if not isinstance(inputs, Mapping):
841
+ return False
842
+ try:
843
+ return all(_hash_project_file(root, str(path)) == digest for path, digest in inputs.items())
844
+ except (FileNotFoundError, OSError, ValueError):
845
+ return False
846
+
847
+
848
+ def _load_adapter(config_path: Path, profile: str, root: Path) -> tuple[list[str], int]:
849
+ resolved = config_path.expanduser().resolve()
850
+ if resolved.is_relative_to(root):
851
+ raise ValueError("adapter config must live outside the project")
852
+ document = json.loads(resolved.read_text(encoding="utf-8"))
853
+ adapters = document.get("adapters") if isinstance(document, Mapping) else None
854
+ selected = adapters.get(profile) if isinstance(adapters, Mapping) else None
855
+ if not isinstance(selected, Mapping) or set(selected) - {"command", "timeout_seconds"}:
856
+ raise ValueError(f"adapter profile is missing or invalid: {profile}")
857
+ command = _string_list(selected.get("command"), label="adapter command", limit=32)
858
+ if not command or any(not part for part in command):
859
+ raise ValueError("adapter command must be a non-empty argv list")
860
+ timeout = selected.get("timeout_seconds", 300)
861
+ if not isinstance(timeout, int) or isinstance(timeout, bool) or not 1 <= timeout <= MAX_TIMEOUT_SECONDS:
862
+ raise ValueError(f"adapter timeout must be 1-{MAX_TIMEOUT_SECONDS} seconds")
863
+ return command, timeout
864
+
865
+
866
+ def _generic_adapter_error(
867
+ profile: str,
868
+ *,
869
+ category: str,
870
+ code: str,
871
+ retryable: bool,
872
+ ) -> dict[str, Any]:
873
+ return {
874
+ "provider": profile,
875
+ "category": category,
876
+ "code": code,
877
+ "retryable": retryable,
878
+ }
879
+
880
+
881
+ def _parse_public_adapter_error(
882
+ raw: bytes, *, profile: str, returncode: int
883
+ ) -> dict[str, Any]:
884
+ fallback = _generic_adapter_error(
885
+ profile,
886
+ category="provider_response",
887
+ code=f"adapter_exit_{returncode}",
888
+ retryable=False,
889
+ )
890
+ if len(raw) > MAX_ADAPTER_RESPONSE_BYTES:
891
+ return fallback
892
+ try:
893
+ document = json.loads(raw.decode("utf-8"))
894
+ except (UnicodeError, json.JSONDecodeError):
895
+ return fallback
896
+ error = document.get("error") if isinstance(document, Mapping) else None
897
+ if not isinstance(error, Mapping) or set(error) - {
898
+ "provider",
899
+ "category",
900
+ "code",
901
+ "http_status",
902
+ "request_id",
903
+ "retryable",
904
+ }:
905
+ return fallback
906
+ provider = error.get("provider")
907
+ category = error.get("category")
908
+ code = error.get("code")
909
+ retryable = error.get("retryable")
910
+ if (
911
+ provider != profile
912
+ or category not in PUBLIC_ERROR_CATEGORIES
913
+ or not isinstance(code, str)
914
+ or PUBLIC_ERROR_TOKEN_RE.fullmatch(code) is None
915
+ or not isinstance(retryable, bool)
916
+ ):
917
+ return fallback
918
+ result: dict[str, Any] = {
919
+ "provider": provider,
920
+ "category": category,
921
+ "code": code,
922
+ "retryable": retryable,
923
+ }
924
+ status = error.get("http_status")
925
+ if status is not None:
926
+ if not isinstance(status, int) or isinstance(status, bool) or not 100 <= status <= 599:
927
+ return fallback
928
+ result["http_status"] = status
929
+ request_id = error.get("request_id")
930
+ if request_id is not None:
931
+ if (
932
+ not isinstance(request_id, str)
933
+ or PUBLIC_ERROR_TOKEN_RE.fullmatch(request_id) is None
934
+ ):
935
+ return fallback
936
+ result["request_id"] = request_id
937
+ return result
938
+
939
+
940
+ def _run_adapter(command: list[str], timeout: int, payload: Mapping[str, Any], root: Path) -> dict[str, Any]:
941
+ with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr:
942
+ try:
943
+ completed = subprocess.run(
944
+ command,
945
+ input=_canonical(payload),
946
+ stdout=stdout,
947
+ stderr=stderr,
948
+ cwd=root,
949
+ timeout=timeout,
950
+ check=False,
951
+ )
952
+ except subprocess.TimeoutExpired as exc:
953
+ raise AdapterError(
954
+ "adapter timed out; confirmation was consumed",
955
+ public_error=_generic_adapter_error(
956
+ str(payload["adapter"]),
957
+ category="timeout",
958
+ code="adapter_timeout",
959
+ retryable=True,
960
+ ),
961
+ ) from exc
962
+ except OSError as exc:
963
+ raise AdapterError(
964
+ "adapter could not be started; confirmation was consumed",
965
+ public_error=_generic_adapter_error(
966
+ str(payload["adapter"]),
967
+ category="configuration",
968
+ code="adapter_start_failed",
969
+ retryable=False,
970
+ ),
971
+ ) from exc
972
+ if completed.returncode != 0:
973
+ size = stdout.tell()
974
+ stdout.seek(0)
975
+ raw_error = stdout.read(MAX_ADAPTER_RESPONSE_BYTES + 1)
976
+ public_error = _parse_public_adapter_error(
977
+ raw_error if size <= MAX_ADAPTER_RESPONSE_BYTES else b"",
978
+ profile=str(payload["adapter"]),
979
+ returncode=completed.returncode,
980
+ )
981
+ raise AdapterError(
982
+ f"adapter exited with code {completed.returncode}; confirmation was consumed",
983
+ public_error=public_error,
984
+ )
985
+ size = stdout.tell()
986
+ if size > MAX_ADAPTER_RESPONSE_BYTES:
987
+ raise AdapterError("adapter response is too large; confirmation was consumed")
988
+ stdout.seek(0)
989
+ try:
990
+ response = json.loads(stdout.read().decode("utf-8"))
991
+ except (UnicodeError, json.JSONDecodeError) as exc:
992
+ raise AdapterError("adapter returned invalid JSON; confirmation was consumed") from exc
993
+ if not isinstance(response, dict):
994
+ raise AdapterError("adapter response must be an object; confirmation was consumed")
995
+ return response
996
+
997
+
998
+ def _validate_adapter_outputs(
999
+ job: Mapping[str, Any], response: Mapping[str, Any], output_root: Path
1000
+ ) -> list[tuple[str, Path]]:
1001
+ entries = response.get("outputs")
1002
+ if not isinstance(entries, list) or not all(isinstance(entry, Mapping) for entry in entries):
1003
+ raise AdapterError("adapter outputs are invalid; confirmation was consumed")
1004
+ result: list[tuple[str, Path]] = []
1005
+ for entry in entries:
1006
+ if set(entry) != {"target", "source"}:
1007
+ raise AdapterError("adapter output fields are invalid; confirmation was consumed")
1008
+ target = entry.get("target")
1009
+ source = entry.get("source")
1010
+ if not isinstance(target, str) or not isinstance(source, str):
1011
+ raise AdapterError("adapter output paths are invalid; confirmation was consumed")
1012
+ path = Path(source)
1013
+ if not path.is_absolute() or path.parent != output_root or path.name in {"", ".", ".."}:
1014
+ raise AdapterError(
1015
+ "adapter output must use the run staging directory; confirmation was consumed"
1016
+ )
1017
+ result.append((target, path))
1018
+ expected = list(job["outputs"])
1019
+ if [target for target, _ in result] != expected:
1020
+ raise AdapterError("adapter outputs do not match the confirmed targets; confirmation was consumed")
1021
+ return result
1022
+
1023
+
1024
+ def _copy_output(
1025
+ source: Path, target: Path, *, overwrite: bool
1026
+ ) -> tuple[str, int]:
1027
+ temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp")
1028
+ target.parent.mkdir(parents=True, exist_ok=True)
1029
+ digest = hashlib.sha256()
1030
+ size = 0
1031
+ descriptor = -1
1032
+ try:
1033
+ try:
1034
+ before = source.lstat()
1035
+ except OSError as exc:
1036
+ raise AdapterError(
1037
+ "adapter output file is missing; confirmation was consumed"
1038
+ ) from exc
1039
+ if _is_link_or_reparse(before) or not stat.S_ISREG(before.st_mode):
1040
+ raise AdapterError("adapter output file is unsafe; confirmation was consumed")
1041
+ descriptor = os.open(source, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
1042
+ opened = os.fstat(descriptor)
1043
+ if (
1044
+ not stat.S_ISREG(opened.st_mode)
1045
+ or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino)
1046
+ ):
1047
+ raise AdapterError("adapter output changed while opening; confirmation was consumed")
1048
+ if opened.st_size > MAX_OUTPUT_BYTES:
1049
+ raise AdapterError("adapter output file is too large; confirmation was consumed")
1050
+ with os.fdopen(descriptor, "rb", closefd=True) as incoming, temporary.open(
1051
+ "xb"
1052
+ ) as outgoing:
1053
+ descriptor = -1
1054
+ for chunk in iter(lambda: incoming.read(1024 * 1024), b""):
1055
+ size += len(chunk)
1056
+ if size > MAX_OUTPUT_BYTES:
1057
+ raise AdapterError("adapter output exceeded the size limit")
1058
+ digest.update(chunk)
1059
+ outgoing.write(chunk)
1060
+ outgoing.flush()
1061
+ os.fsync(outgoing.fileno())
1062
+ if overwrite:
1063
+ os.replace(temporary, target)
1064
+ else:
1065
+ try:
1066
+ os.link(temporary, target, follow_symlinks=False)
1067
+ except FileExistsError as exc:
1068
+ raise FileExistsError(
1069
+ f"output appeared while production was running: {target.name}"
1070
+ ) from exc
1071
+ temporary.unlink()
1072
+ finally:
1073
+ if descriptor >= 0:
1074
+ os.close(descriptor)
1075
+ try:
1076
+ temporary.unlink()
1077
+ except FileNotFoundError:
1078
+ pass
1079
+ return digest.hexdigest(), size
1080
+
1081
+
1082
+ def _latest_run(root: Path, job_id: str) -> dict[str, Any] | None:
1083
+ history = _read_run_history(root, job_id)
1084
+ return history[-1] if history else None
1085
+
1086
+
1087
+ def _parse_run_timestamp(value: object, *, label: str) -> datetime:
1088
+ if not isinstance(value, str) or not value:
1089
+ raise ValueError(f"production run {label} is invalid")
1090
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
1091
+ try:
1092
+ parsed = datetime.fromisoformat(normalized)
1093
+ except ValueError as exc:
1094
+ raise ValueError(f"production run {label} is invalid") from exc
1095
+ if parsed.tzinfo is None or parsed.utcoffset() is None:
1096
+ raise ValueError(f"production run {label} is invalid")
1097
+ return parsed.astimezone(timezone.utc)
1098
+
1099
+
1100
+ def _completed_run_order(run: Mapping[str, Any]) -> tuple[datetime, str]:
1101
+ return (
1102
+ _parse_run_timestamp(run.get("finished_at"), label="finished_at"),
1103
+ str(run["run_id"]),
1104
+ )
1105
+
1106
+
1107
+ def _read_run_history(root: Path, job_id: str) -> list[dict[str, Any]]:
1108
+ directory_parts = ("runs", _job_key(job_id))
1109
+ history: list[dict[str, Any]] = []
1110
+ for name in _metadata_json_names(root, directory_parts):
1111
+ document = _metadata_read_json(
1112
+ root, directory_parts, name, maximum=MAX_RUN_RECORD_BYTES
1113
+ )
1114
+ if (
1115
+ not isinstance(document, dict)
1116
+ or document.get("job_id") != job_id
1117
+ or not isinstance(document.get("run_id"), str)
1118
+ or document.get("status") not in {"running", "succeeded", "failed"}
1119
+ or not isinstance(document.get("fingerprint"), str)
1120
+ or re.fullmatch(r"[0-9a-f]{64}", str(document.get("fingerprint"))) is None
1121
+ ):
1122
+ raise ValueError("production run record is invalid")
1123
+ started_at = _parse_run_timestamp(
1124
+ document.get("started_at"), label="started_at"
1125
+ )
1126
+ finished_at = document.get("finished_at")
1127
+ if document["status"] == "running":
1128
+ if finished_at is not None:
1129
+ raise ValueError("running production run has finished_at")
1130
+ else:
1131
+ completed_at = _parse_run_timestamp(finished_at, label="finished_at")
1132
+ if completed_at < started_at:
1133
+ raise ValueError("production run finished before it started")
1134
+ history.append(document)
1135
+ # Completed runs are ordered by completion because that is when their
1136
+ # terminal state and output claim become authoritative. Any unresolved
1137
+ # running attempt sorts last so status cannot hide it behind a later start.
1138
+ history.sort(
1139
+ key=lambda run: (
1140
+ run["status"] == "running",
1141
+ (
1142
+ _parse_run_timestamp(run.get("started_at"), label="started_at")
1143
+ if run["status"] == "running"
1144
+ else _completed_run_order(run)[0]
1145
+ ),
1146
+ str(run["run_id"]),
1147
+ )
1148
+ )
1149
+ return history
1150
+
1151
+
1152
+ def _active_run(root: Path, job_id: str) -> dict[str, Any] | None:
1153
+ running = [
1154
+ run for run in _read_run_history(root, job_id) if run["status"] == "running"
1155
+ ]
1156
+ return running[-1] if running else None
1157
+
1158
+
1159
+ def _write_run(root: Path, job_id: str, run: Mapping[str, Any]) -> None:
1160
+ _metadata_atomic_json(
1161
+ root,
1162
+ ("runs", _job_key(job_id)),
1163
+ f"{run['run_id']}.json",
1164
+ run,
1165
+ )
1166
+
1167
+
1168
+ def run_job(root: Path, *, job_id: str, adapter_config: Path) -> dict[str, Any]:
1169
+ root = find_project(root)
1170
+ with tempfile.TemporaryDirectory(prefix="short-drama-inputs-") as directory:
1171
+ attempt_root = Path(directory)
1172
+ snapshot_root = attempt_root / "inputs"
1173
+ output_root = attempt_root / "outputs"
1174
+ snapshot_root.mkdir()
1175
+ output_root.mkdir()
1176
+ with _project_lock(root):
1177
+ if _active_run(root, job_id) is not None:
1178
+ raise RuntimeError("this job is already running")
1179
+ job = _read_job(root, job_id)
1180
+ command, timeout = _load_adapter(adapter_config, str(job["adapter"]), root)
1181
+ try:
1182
+ receipt = _metadata_read_json(
1183
+ root,
1184
+ ("confirmations",),
1185
+ f"{_job_key(job_id)}.json",
1186
+ maximum=MAX_RUN_RECORD_BYTES,
1187
+ )
1188
+ except FileNotFoundError as exc:
1189
+ raise ConfirmationRequiredError("job needs explicit confirmation") from exc
1190
+ if (
1191
+ not isinstance(receipt, dict)
1192
+ or receipt.get("fingerprint") != job.get("fingerprint")
1193
+ or receipt.get("consumed_at") is not None
1194
+ ):
1195
+ raise ConfirmationRequiredError("job needs a new explicit confirmation")
1196
+ for output in job["outputs"]:
1197
+ target = _project_file(root, output)
1198
+ if target.exists() and not job["overwrite"]:
1199
+ raise FileExistsError(f"output exists and overwrite is false: {output}")
1200
+ # Pin every confirmed input into a private immutable snapshot before
1201
+ # consuming confirmation. Provider adapters never reopen live project paths.
1202
+ _snapshot_inputs(root, job, snapshot_root)
1203
+ run_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
1204
+ receipt["consumed_at"] = utc_now()
1205
+ receipt["run_id"] = run_id
1206
+ _metadata_atomic_json(
1207
+ root,
1208
+ ("confirmations",),
1209
+ f"{_job_key(job_id)}.json",
1210
+ receipt,
1211
+ )
1212
+ run = {
1213
+ "schema_version": JOB_SCHEMA,
1214
+ "run_id": run_id,
1215
+ "job_id": job_id,
1216
+ "fingerprint": job["fingerprint"],
1217
+ "modality": job["modality"],
1218
+ "adapter": job["adapter"],
1219
+ "status": "running",
1220
+ "started_at": utc_now(),
1221
+ "finished_at": None,
1222
+ "outputs": [],
1223
+ }
1224
+ _write_run(root, job_id, run)
1225
+
1226
+ payload = {key: job[key] for key in ALLOWED_JOB_KEYS if key in job}
1227
+ payload.update(
1228
+ {
1229
+ "run_id": run_id,
1230
+ "project_root": str(snapshot_root),
1231
+ "output_root": str(output_root),
1232
+ }
1233
+ )
1234
+ try:
1235
+ response = _run_adapter(command, timeout, payload, root)
1236
+ adapter_outputs = _validate_adapter_outputs(job, response, output_root)
1237
+ written: list[dict[str, Any]] = []
1238
+ with _project_lock(root):
1239
+ for target_name, source in adapter_outputs:
1240
+ target = _project_file(root, target_name, create_parent=True)
1241
+ digest, size = _copy_output(
1242
+ source, target, overwrite=bool(job["overwrite"])
1243
+ )
1244
+ written.append(
1245
+ {
1246
+ "path": target_name,
1247
+ "media_type": MEDIA_TYPES[PurePosixPath(target_name).suffix.casefold()],
1248
+ "bytes": size,
1249
+ "sha256": digest,
1250
+ }
1251
+ )
1252
+ run["status"] = "succeeded"
1253
+ run["finished_at"] = utc_now()
1254
+ run["outputs"] = written
1255
+ provider_job_id = response.get("provider_job_id")
1256
+ if isinstance(provider_job_id, str) and len(provider_job_id) <= 200:
1257
+ run["provider_job_id"] = provider_job_id
1258
+ _write_run(root, job_id, run)
1259
+ except Exception as exc:
1260
+ with _project_lock(root):
1261
+ run["status"] = "failed"
1262
+ run["finished_at"] = utc_now()
1263
+ if isinstance(exc, AdapterError) and exc.public_error is not None:
1264
+ run["error"] = exc.public_error
1265
+ _write_run(root, job_id, run)
1266
+ raise
1267
+ return {
1268
+ "job_id": job_id,
1269
+ "run_id": run_id,
1270
+ "state": "succeeded",
1271
+ "outputs": run["outputs"],
1272
+ }
1273
+
1274
+
1275
+ def job_status(root: Path, *, job_id: str) -> dict[str, Any]:
1276
+ root = find_project(root)
1277
+ job = _read_job(root, job_id)
1278
+ latest = _latest_run(root, job_id)
1279
+ if not _inputs_current(root, job):
1280
+ state = "needs_reconfirmation"
1281
+ else:
1282
+ try:
1283
+ receipt = _metadata_read_json(
1284
+ root,
1285
+ ("confirmations",),
1286
+ f"{_job_key(job_id)}.json",
1287
+ maximum=MAX_RUN_RECORD_BYTES,
1288
+ )
1289
+ except FileNotFoundError:
1290
+ receipt = None
1291
+ if latest and latest.get("status") == "running":
1292
+ state = "running"
1293
+ elif latest and latest.get("status") in {"succeeded", "failed"}:
1294
+ state = str(latest["status"])
1295
+ elif isinstance(receipt, Mapping) and receipt.get("consumed_at") is None:
1296
+ state = "confirmed"
1297
+ else:
1298
+ state = "needs_confirmation"
1299
+ return {
1300
+ "job_id": job_id,
1301
+ "modality": job["modality"],
1302
+ "adapter": job["adapter"],
1303
+ "outputs": job["outputs"],
1304
+ "state": state,
1305
+ "latest_run": latest,
1306
+ }
1307
+
1308
+
1309
+ def _hash_production_output(root: Path, relative: str) -> tuple[str, int]:
1310
+ digest = hashlib.sha256()
1311
+ size = 0
1312
+ with _open_project_input(root, relative) as handle:
1313
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
1314
+ size += len(chunk)
1315
+ if size > MAX_OUTPUT_BYTES:
1316
+ raise ValueError("production output exceeds the size limit")
1317
+ digest.update(chunk)
1318
+ return digest.hexdigest(), size
1319
+
1320
+
1321
+ def _validated_succeeded_outputs(
1322
+ job: Mapping[str, Any], run: Mapping[str, Any]
1323
+ ) -> list[tuple[str, str, int]]:
1324
+ expected = job.get("outputs")
1325
+ entries = run.get("outputs")
1326
+ if (
1327
+ not isinstance(expected, list)
1328
+ or not isinstance(entries, list)
1329
+ or len(entries) != len(expected)
1330
+ ):
1331
+ raise ValueError("succeeded outputs do not match the current job")
1332
+ validated: list[tuple[str, str, int]] = []
1333
+ # ``zip(strict=True)`` needs 3.10; the suite's floor is 3.9, so the length
1334
+ # agreement is asserted directly rather than by the zip flag.
1335
+ if len(expected) != len(entries):
1336
+ raise ValueError("expected %d outputs, got %d" % (len(expected), len(entries)))
1337
+ for expected_path, output in zip(expected, entries):
1338
+ if not isinstance(output, Mapping) or set(output) != {
1339
+ "path",
1340
+ "media_type",
1341
+ "bytes",
1342
+ "sha256",
1343
+ }:
1344
+ raise ValueError("succeeded output record fields are invalid")
1345
+ output_path = output.get("path")
1346
+ digest = output.get("sha256")
1347
+ size = output.get("bytes")
1348
+ media_type = output.get("media_type")
1349
+ if (
1350
+ not isinstance(expected_path, str)
1351
+ or output_path != expected_path
1352
+ or not isinstance(digest, str)
1353
+ or re.fullmatch(r"[0-9a-f]{64}", digest) is None
1354
+ or not isinstance(size, int)
1355
+ or isinstance(size, bool)
1356
+ or size < 0
1357
+ or media_type
1358
+ != MEDIA_TYPES.get(PurePosixPath(expected_path).suffix.casefold())
1359
+ ):
1360
+ raise ValueError("succeeded output record is invalid")
1361
+ validated.append((output_path, digest, size))
1362
+ return validated
1363
+
1364
+
1365
+ def audit_project(root: Path) -> dict[str, Any]:
1366
+ """Reconcile local attempt history and current output bytes, not media quality."""
1367
+ root = find_project(root)
1368
+ jobs: list[dict[str, Any]] = []
1369
+ problems: list[dict[str, Any]] = []
1370
+ for name in _metadata_json_names(root, ("jobs",)):
1371
+ try:
1372
+ document = _metadata_read_json(
1373
+ root, ("jobs",), name, maximum=MAX_JOB_BYTES
1374
+ )
1375
+ job = _validate_stored_job(root, document)
1376
+ job_id = job["job_id"]
1377
+ if not isinstance(job_id, str) or name != f"{_job_key(job_id)}.json":
1378
+ raise ValueError("stored job is invalid")
1379
+ jobs.append(job)
1380
+ except (OSError, ValueError, json.JSONDecodeError):
1381
+ problems.append(
1382
+ {
1383
+ "code": "invalid_job_record",
1384
+ "record": name,
1385
+ "action": "repair_production_metadata",
1386
+ }
1387
+ )
1388
+
1389
+ attempts_total = 0
1390
+ attempts_succeeded = 0
1391
+ attempts_failed = 0
1392
+ attempts_running = 0
1393
+ attempts_superseded = 0
1394
+ repeated_content = 0
1395
+ recovered_jobs = 0
1396
+ running_jobs = 0
1397
+ terminal_failed_jobs = 0
1398
+ retryable_terminal_failed_jobs = 0
1399
+ current_output_claims: dict[
1400
+ str, tuple[tuple[datetime, str], str, int, str, str]
1401
+ ] = {}
1402
+
1403
+ for job in jobs:
1404
+ job_id = str(job["job_id"])
1405
+ try:
1406
+ history = _read_run_history(root, job_id)
1407
+ except (OSError, ValueError, json.JSONDecodeError):
1408
+ problems.append(
1409
+ {
1410
+ "code": "invalid_run_history",
1411
+ "job_id": job_id,
1412
+ "action": "repair_production_metadata",
1413
+ }
1414
+ )
1415
+ continue
1416
+ attempts_total += len(history)
1417
+ completed = [run for run in history if run["status"] != "running"]
1418
+ running = [run for run in history if run["status"] == "running"]
1419
+ current = [
1420
+ run for run in history if run["fingerprint"] == job["fingerprint"]
1421
+ ]
1422
+ current_completed = [run for run in current if run["status"] != "running"]
1423
+ current_running = [run for run in current if run["status"] == "running"]
1424
+ attempts_succeeded += sum(run["status"] == "succeeded" for run in completed)
1425
+ attempts_failed += sum(run["status"] == "failed" for run in completed)
1426
+ attempts_running += len(running)
1427
+ attempts_superseded += len(history) - len(current)
1428
+ if running:
1429
+ running_jobs += 1
1430
+ for run in running:
1431
+ problems.append(
1432
+ {
1433
+ "code": "running_attempt",
1434
+ "job_id": job_id,
1435
+ "run_id": str(run["run_id"]),
1436
+ "action": "wait_or_investigate_running_attempt",
1437
+ }
1438
+ )
1439
+ fingerprints = [
1440
+ str(run.get("fingerprint"))
1441
+ for run in history
1442
+ if isinstance(run.get("fingerprint"), str)
1443
+ ]
1444
+ repeated_content += len(fingerprints) - len(set(fingerprints))
1445
+ succeeded_indexes = [
1446
+ index
1447
+ for index, run in enumerate(current_completed)
1448
+ if run["status"] == "succeeded"
1449
+ ]
1450
+ failed_indexes = [
1451
+ index
1452
+ for index, run in enumerate(current_completed)
1453
+ if run["status"] == "failed"
1454
+ ]
1455
+ if succeeded_indexes and failed_indexes and min(failed_indexes) < max(succeeded_indexes):
1456
+ recovered_jobs += 1
1457
+ if (
1458
+ not current_running
1459
+ and current_completed
1460
+ and current_completed[-1]["status"] == "failed"
1461
+ ):
1462
+ terminal_failed_jobs += 1
1463
+ error = current_completed[-1].get("error")
1464
+ retryable = isinstance(error, Mapping) and error.get("retryable") is True
1465
+ if retryable:
1466
+ retryable_terminal_failed_jobs += 1
1467
+ problems.append(
1468
+ {
1469
+ "code": (
1470
+ "terminal_retryable_failure" if retryable else "terminal_failure"
1471
+ ),
1472
+ "job_id": job_id,
1473
+ "run_id": current_completed[-1]["run_id"],
1474
+ "action": (
1475
+ "inspect_then_reconfirm_retry" if retryable else "inspect_failure"
1476
+ ),
1477
+ }
1478
+ )
1479
+ for run in current:
1480
+ if run["status"] != "succeeded":
1481
+ continue
1482
+ try:
1483
+ outputs = _validated_succeeded_outputs(job, run)
1484
+ except ValueError:
1485
+ problems.append(
1486
+ {
1487
+ "code": "invalid_succeeded_output_record",
1488
+ "job_id": job_id,
1489
+ "run_id": str(run["run_id"]),
1490
+ "action": "repair_production_metadata",
1491
+ }
1492
+ )
1493
+ continue
1494
+ for output_path, digest, size in outputs:
1495
+ run_id = str(run["run_id"])
1496
+ claim_order = _completed_run_order(run)
1497
+ claim = current_output_claims.get(output_path)
1498
+ if claim is None or claim_order > claim[0]:
1499
+ current_output_claims[output_path] = (
1500
+ claim_order,
1501
+ digest,
1502
+ size,
1503
+ job_id,
1504
+ run_id,
1505
+ )
1506
+
1507
+ if not _inputs_current(root, job):
1508
+ problems.append(
1509
+ {
1510
+ "code": "job_inputs_changed",
1511
+ "job_id": job_id,
1512
+ "action": "prepare_and_confirm_again",
1513
+ }
1514
+ )
1515
+
1516
+ output_verified = 0
1517
+ output_missing = 0
1518
+ output_modified = 0
1519
+ for relative, (
1520
+ _claim_order,
1521
+ expected_digest,
1522
+ expected_size,
1523
+ job_id,
1524
+ run_id,
1525
+ ) in sorted(current_output_claims.items()):
1526
+ try:
1527
+ safe_relative = _relative_path(relative, output=True)
1528
+ digest, size = _hash_production_output(root, safe_relative)
1529
+ except (FileNotFoundError, OSError, ValueError):
1530
+ output_missing += 1
1531
+ problems.append(
1532
+ {
1533
+ "code": "output_missing_or_unsafe",
1534
+ "job_id": job_id,
1535
+ "run_id": run_id,
1536
+ "path": relative,
1537
+ "action": "restore_or_reproduce_output",
1538
+ }
1539
+ )
1540
+ continue
1541
+ if digest != expected_digest or size != expected_size:
1542
+ output_modified += 1
1543
+ problems.append(
1544
+ {
1545
+ "code": "output_digest_mismatch",
1546
+ "job_id": job_id,
1547
+ "run_id": run_id,
1548
+ "path": relative,
1549
+ "action": "review_current_bytes_or_reproduce_output",
1550
+ }
1551
+ )
1552
+ else:
1553
+ output_verified += 1
1554
+
1555
+ return {
1556
+ "status": "attention" if problems else "pass",
1557
+ "scope": "operational_evidence_only",
1558
+ "quality_verdict": "not_assessed",
1559
+ "jobs": {
1560
+ "total": len(jobs),
1561
+ "running": running_jobs,
1562
+ "recovered": recovered_jobs,
1563
+ "terminal_failed": terminal_failed_jobs,
1564
+ "retryable_terminal_failed": retryable_terminal_failed_jobs,
1565
+ },
1566
+ "attempts": {
1567
+ "total": attempts_total,
1568
+ "succeeded": attempts_succeeded,
1569
+ "failed": attempts_failed,
1570
+ "running": attempts_running,
1571
+ "superseded": attempts_superseded,
1572
+ "repeated_content": repeated_content,
1573
+ },
1574
+ "outputs": {
1575
+ "claimed_current": len(current_output_claims),
1576
+ "verified": output_verified,
1577
+ "missing": output_missing,
1578
+ "modified": output_modified,
1579
+ },
1580
+ "problems": problems,
1581
+ }
1582
+
1583
+
1584
+ def build_parser() -> argparse.ArgumentParser:
1585
+ parser = argparse.ArgumentParser(description="Run confirmed short-drama media jobs.")
1586
+ commands = parser.add_subparsers(dest="command", required=True)
1587
+ prepare = commands.add_parser("prepare", help="Validate and preview a media job.")
1588
+ prepare.add_argument("project")
1589
+ prepare.add_argument("--job", required=True)
1590
+ confirm = commands.add_parser("confirm", help="Confirm the exact prepared job.")
1591
+ confirm.add_argument("project")
1592
+ confirm.add_argument("--job-id", required=True)
1593
+ confirm.add_argument("--confirmation", required=True)
1594
+ run = commands.add_parser("run", help="Execute a confirmed job through an adapter.")
1595
+ run.add_argument("project")
1596
+ run.add_argument("--job-id", required=True)
1597
+ run.add_argument("--adapter-config", required=True)
1598
+ status = commands.add_parser("status", help="Show one media job state.")
1599
+ status.add_argument("project")
1600
+ status.add_argument("--job-id", required=True)
1601
+ audit = commands.add_parser(
1602
+ "audit", help="Reconcile attempt history and current output bytes."
1603
+ )
1604
+ audit.add_argument("project")
1605
+ return parser
1606
+
1607
+
1608
+ def main(argv: list[str] | None = None) -> int:
1609
+ args = build_parser().parse_args(argv)
1610
+ try:
1611
+ if args.command == "prepare":
1612
+ result = prepare_job(Path(args.project), Path(args.job))
1613
+ elif args.command == "confirm":
1614
+ result = confirm_job(
1615
+ Path(args.project), job_id=args.job_id, confirmation=args.confirmation
1616
+ )
1617
+ elif args.command == "run":
1618
+ result = run_job(
1619
+ Path(args.project),
1620
+ job_id=args.job_id,
1621
+ adapter_config=Path(args.adapter_config),
1622
+ )
1623
+ elif args.command == "status":
1624
+ result = job_status(Path(args.project), job_id=args.job_id)
1625
+ else:
1626
+ result = audit_project(Path(args.project))
1627
+ print(json.dumps(result, ensure_ascii=True, sort_keys=True))
1628
+ return 0
1629
+ except Exception as exc:
1630
+ print(str(exc), file=sys.stderr)
1631
+ return 2
1632
+
1633
+
1634
+ if __name__ == "__main__":
1635
+ raise SystemExit(main())