@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,1963 @@
1
+ #!/usr/bin/env python3
2
+ """Small filesystem lifecycle for short-drama projects.
3
+
4
+ Creative work lives in project files and skills. This module keeps only the
5
+ mechanical boundaries that earn their cost: safe publication, exact creator
6
+ acceptance, a lightweight review verdict, direct-input freshness, and portable
7
+ text delivery. It does not call networks or media services.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import contextlib
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import stat
20
+ import sys
21
+ import unicodedata
22
+ import uuid
23
+ from collections.abc import Callable, Iterable, Mapping
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path, PurePosixPath
26
+ from typing import Any, Iterator
27
+
28
+
29
+ MINIMUM_PYTHON = (3, 9)
30
+ if sys.version_info < MINIMUM_PYTHON:
31
+ raise SystemExit(
32
+ "short-drama needs Python {}.{} or newer; this interpreter is {}.{}".format(
33
+ *MINIMUM_PYTHON, sys.version_info.major, sys.version_info.minor
34
+ )
35
+ )
36
+
37
+ PROJECT_FILE = "short-drama.json"
38
+ STATE_FILE = Path(".short-drama/state.json")
39
+ STATE_SCHEMA = "2.0"
40
+ DEFAULT_PROMPT_LANGUAGE = "en"
41
+ LANGUAGE_TAG_RE = re.compile(r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*")
42
+ DELIVERY_SUFFIXES = {".md", ".json", ".jsonl"}
43
+ EPISODE_ID_RE = re.compile(r"EP(?:[0-9]{3}|[1-9][0-9]{3,})")
44
+ WINDOWS_FORBIDDEN_PATH_CHARACTERS = frozenset('<>:"|?*')
45
+ WINDOWS_RESERVED_PATH_STEMS = frozenset(
46
+ {
47
+ "con",
48
+ "prn",
49
+ "aux",
50
+ "nul",
51
+ *(f"com{number}" for number in range(1, 10)),
52
+ *(f"lpt{number}" for number in range(1, 10)),
53
+ "com¹",
54
+ "com²",
55
+ "com³",
56
+ "lpt¹",
57
+ "lpt²",
58
+ "lpt³",
59
+ }
60
+ )
61
+
62
+ CANONICAL_ROOTS = {
63
+ "inputs": "输入",
64
+ "development": "项目开发",
65
+ "bible": "设定集",
66
+ "episodes": "剧集",
67
+ "delivery": "交付",
68
+ "creator-decisions": "创作者决策",
69
+ "reviews": "审查",
70
+ }
71
+ LEGACY_ROOTS = {role: role for role in CANONICAL_ROOTS}
72
+ ROOT_ROLE_ALIASES: dict[str, str] = {
73
+ name.casefold(): role
74
+ for roots in (CANONICAL_ROOTS, LEGACY_ROOTS)
75
+ for role, name in roots.items()
76
+ }
77
+ LAYOUT_PINNING_ROLES = frozenset(
78
+ {"development", "bible", "episodes", "delivery", "creator-decisions", "reviews"}
79
+ )
80
+ PUBLISHABLE_ROOT_ROLES = frozenset(
81
+ {"development", "bible", "episodes", "creator-decisions", "reviews"}
82
+ )
83
+ PUBLISHABLE_ROOTS = tuple(
84
+ roots[role]
85
+ for roots in (CANONICAL_ROOTS, LEGACY_ROOTS)
86
+ for role in CANONICAL_ROOTS
87
+ if role in PUBLISHABLE_ROOT_ROLES
88
+ )
89
+ PROJECT_DIRS = (*CANONICAL_ROOTS.values(), ".short-drama")
90
+
91
+ PROTECTED_PUBLISH_ROLE_REASONS = {
92
+ "inputs": "creator inputs are immutable publication sources",
93
+ "delivery": "the delivery tree is written by the packaging gate, not by publication",
94
+ }
95
+ PROTECTED_PUBLISH_ROOTS = {
96
+ name.casefold(): reason
97
+ for role, reason in PROTECTED_PUBLISH_ROLE_REASONS.items()
98
+ for name in (CANONICAL_ROOTS[role], LEGACY_ROOTS[role])
99
+ } | {".short-drama": "operational state cannot be a publication target"}
100
+
101
+ AUTHORITY_ROOT_TOKEN = "creator_authority"
102
+ EPISODE_LENGTH_POINTER = "/format/target_seconds_per_episode"
103
+ PACING_POINTER = "/format/pacing"
104
+ # Everything outside /creator_authority/* that a decision may still bind. Both
105
+ # are read by the write stage to turn a screenplay into seconds, so leaving them
106
+ # out of set-authority left hand-editing short-drama.json as the only way in.
107
+ FORMAT_POINTERS = (EPISODE_LENGTH_POINTER, PACING_POINTER)
108
+
109
+ class ProjectConflictError(RuntimeError):
110
+ """A file changed while a guarded operation was in progress."""
111
+
112
+
113
+ class PackageBlockedError(RuntimeError):
114
+ """A requested delivery contains an artifact that is not approved."""
115
+
116
+
117
+ class NonPortablePathError(ValueError):
118
+ """A path would alias or fail on a supported filesystem."""
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 sha256_file(path: Path) -> str:
130
+ digest = hashlib.sha256()
131
+ with path.open("rb") as handle:
132
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
133
+ digest.update(chunk)
134
+ return digest.hexdigest()
135
+
136
+
137
+ def _is_link_or_reparse(details: os.stat_result) -> bool:
138
+ attributes = getattr(details, "st_file_attributes", 0)
139
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
140
+ return stat.S_ISLNK(details.st_mode) or bool(attributes & reparse_flag)
141
+
142
+
143
+ def _fsync_directory(path: Path) -> None:
144
+ if os.name == "nt":
145
+ return
146
+ descriptor = os.open(path, os.O_RDONLY)
147
+ try:
148
+ os.fsync(descriptor)
149
+ finally:
150
+ os.close(descriptor)
151
+
152
+
153
+ def _atomic_bytes(path: Path, content: bytes) -> None:
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
156
+ try:
157
+ with temporary.open("xb") as handle:
158
+ handle.write(content)
159
+ handle.flush()
160
+ os.fsync(handle.fileno())
161
+ os.replace(temporary, path)
162
+ _fsync_directory(path.parent)
163
+ finally:
164
+ try:
165
+ temporary.unlink()
166
+ except FileNotFoundError:
167
+ pass
168
+
169
+
170
+ def atomic_json(path: Path, document: Mapping[str, Any]) -> None:
171
+ # allow_nan=False: Python reads and writes bare Infinity/NaN, but they are not
172
+ # JSON. Writing one produces a manifest that every other reader rejects.
173
+ encoded = (
174
+ json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False)
175
+ + "\n"
176
+ ).encode("utf-8")
177
+ _atomic_bytes(path, encoded)
178
+
179
+
180
+ def normalize_language_tag(value: str, *, field: str) -> str:
181
+ tag = value.strip()
182
+ if not tag:
183
+ raise ValueError(f"{field} must not be empty")
184
+ if LANGUAGE_TAG_RE.fullmatch(tag) is None:
185
+ raise ValueError(f"{field} is not a well-formed language tag: {value!r}")
186
+ return tag
187
+
188
+
189
+ def project_languages(project: Mapping[str, Any]) -> dict[str, str]:
190
+ format_block = project.get("format")
191
+ prompt_language = (
192
+ format_block.get("prompt_language")
193
+ if isinstance(format_block, Mapping)
194
+ else None
195
+ )
196
+ return {
197
+ "language": str(project.get("language") or "zh-CN"),
198
+ "prompt_language": str(prompt_language or DEFAULT_PROMPT_LANGUAGE),
199
+ }
200
+
201
+
202
+ def initialize_project(
203
+ path: Path,
204
+ *,
205
+ title: str,
206
+ language: str,
207
+ aspect_ratio: str,
208
+ prompt_language: str = DEFAULT_PROMPT_LANGUAGE,
209
+ suite_root: Path | None = None,
210
+ ) -> dict[str, Any]:
211
+ language = normalize_language_tag(language, field="language")
212
+ prompt_language = normalize_language_tag(prompt_language, field="prompt_language")
213
+ root = path.expanduser().resolve()
214
+ project_path = root / PROJECT_FILE
215
+ if project_path.exists():
216
+ raise FileExistsError(f"project already exists: {project_path}")
217
+
218
+ root.mkdir(parents=True, exist_ok=True)
219
+ for relative in PROJECT_DIRS:
220
+ (root / relative).mkdir(parents=True, exist_ok=True)
221
+
222
+ core = suite_root or Path(__file__).resolve().parents[1]
223
+ project = json.loads(
224
+ (core / "assets/project-template/short-drama.json").read_text(encoding="utf-8")
225
+ )
226
+ project.update(
227
+ {
228
+ "project_id": f"SD-{uuid.uuid4().hex[:12].upper()}",
229
+ "title": title.strip() or "未命名短剧",
230
+ "language": language,
231
+ "created_at": utc_now(),
232
+ }
233
+ )
234
+ project["format"]["aspect_ratio"] = aspect_ratio
235
+ project["format"]["prompt_language"] = prompt_language
236
+ state = {
237
+ "schema_version": STATE_SCHEMA,
238
+ "project_id": project["project_id"],
239
+ "project_layout_mode": "auto",
240
+ "updated_at": utc_now(),
241
+ "last_action": "initialized",
242
+ "artifacts": {},
243
+ }
244
+ atomic_json(root / STATE_FILE, state)
245
+ atomic_json(project_path, project)
246
+ return {"project_root": str(root), "project": project, "state": state}
247
+
248
+
249
+ def find_project(start: Path) -> Path:
250
+ candidate = start.expanduser().resolve()
251
+ if candidate.is_file():
252
+ candidate = candidate.parent
253
+ for directory in (candidate, *candidate.parents):
254
+ if (directory / PROJECT_FILE).is_file():
255
+ return directory
256
+ raise FileNotFoundError(f"no {PROJECT_FILE} found from {start}")
257
+
258
+
259
+ def _has_nonportable_path_component(parts: tuple[str, ...]) -> bool:
260
+ for part in parts:
261
+ stem = part.split(".", 1)[0].casefold()
262
+ if (
263
+ part.endswith((" ", "."))
264
+ or any(ord(character) < 32 or ord(character) == 127 for character in part)
265
+ or any(character in WINDOWS_FORBIDDEN_PATH_CHARACTERS for character in part)
266
+ or stem in WINDOWS_RESERVED_PATH_STEMS
267
+ ):
268
+ return True
269
+ return False
270
+
271
+
272
+ def _relative_path(value: str | Path, *, allow_operations: bool = False) -> str:
273
+ raw = str(value).replace("\\", "/")
274
+ pure = PurePosixPath(raw)
275
+ if not raw or pure.is_absolute() or any(part in ("", ".", "..") for part in pure.parts):
276
+ raise ValueError(f"unsafe project-relative path: {value!s}")
277
+ if _has_nonportable_path_component(pure.parts):
278
+ raise NonPortablePathError(f"unsafe project-relative path: {value!s}")
279
+ if not allow_operations and pure.parts[0].casefold() == ".short-drama":
280
+ raise ValueError("operational state cannot be a publication target")
281
+ return pure.as_posix()
282
+
283
+
284
+ def _portable_path_identity(value: str) -> str:
285
+ return unicodedata.normalize("NFC", value.casefold())
286
+
287
+
288
+ def _validate_existing_path_spelling(root: Path, relative: str, *, label: str) -> None:
289
+ current = root
290
+ prefix: list[str] = []
291
+ parts = PurePosixPath(relative).parts
292
+ for index, part in enumerate(parts):
293
+ try:
294
+ entries = list(os.scandir(current))
295
+ except (FileNotFoundError, NotADirectoryError):
296
+ return
297
+ identity = _portable_path_identity(part)
298
+ matches = [entry for entry in entries if _portable_path_identity(entry.name) == identity]
299
+ alias = next((entry.name for entry in matches if entry.name != part), None)
300
+ if alias is not None:
301
+ existing = PurePosixPath(*prefix, alias).as_posix()
302
+ raise NonPortablePathError(
303
+ f"{label} path spelling aliases an existing path: {relative} conflicts with {existing}"
304
+ )
305
+ exact = next((entry for entry in matches if entry.name == part), None)
306
+ if exact is None or index == len(parts) - 1:
307
+ return
308
+ if not exact.is_dir(follow_symlinks=False):
309
+ return
310
+ current /= part
311
+ prefix.append(part)
312
+
313
+
314
+ def _validate_path_set(root: Path, relatives: Iterable[str], *, label: str) -> list[str]:
315
+ normalized: list[str] = []
316
+ seen: dict[str, str] = {}
317
+ for value in relatives:
318
+ relative = _relative_path(value)
319
+ identity = _portable_path_identity(relative)
320
+ previous = seen.get(identity)
321
+ if previous is not None and previous != relative:
322
+ raise NonPortablePathError(
323
+ f"{label} paths are not portable aliases: {previous} and {relative}"
324
+ )
325
+ if previous is None:
326
+ seen[identity] = relative
327
+ normalized.append(relative)
328
+ _validate_existing_path_spelling(root, relative, label=label)
329
+ return sorted(normalized)
330
+
331
+
332
+ def _root_role(name: str) -> str | None:
333
+ return ROOT_ROLE_ALIASES.get(name.casefold())
334
+
335
+
336
+ def _root_layout_mode(name: str) -> str | None:
337
+ role = _root_role(name)
338
+ if role is None:
339
+ return None
340
+ if name == CANONICAL_ROOTS[role]:
341
+ return "canonical"
342
+ if name == LEGACY_ROOTS[role]:
343
+ return "legacy"
344
+ return None
345
+
346
+
347
+ def is_protected_project_text(value: str | Path) -> bool:
348
+ raw = str(value).replace("\\", "/")
349
+ pure = PurePosixPath(raw)
350
+ if (
351
+ not raw
352
+ or pure.is_absolute()
353
+ or any(part in ("", ".", "..") for part in pure.parts)
354
+ or _has_nonportable_path_component(pure.parts)
355
+ ):
356
+ return True
357
+ return (
358
+ pure.name.casefold() == PROJECT_FILE
359
+ or pure.parts[0].casefold() == ".short-drama"
360
+ or _root_role(pure.parts[0]) == "delivery"
361
+ )
362
+
363
+
364
+ def _directory_has_content(path: Path) -> bool:
365
+ try:
366
+ details = os.lstat(path)
367
+ return stat.S_ISLNK(details.st_mode) or (
368
+ stat.S_ISDIR(details.st_mode) and any(path.iterdir())
369
+ )
370
+ except FileNotFoundError:
371
+ return False
372
+
373
+
374
+ def _read_state(root: Path) -> dict[str, Any]:
375
+ state_path = root / STATE_FILE
376
+ if not state_path.is_file() or state_path.is_symlink():
377
+ raise ValueError("project state is missing or unsafe")
378
+ document = json.loads(state_path.read_text(encoding="utf-8"))
379
+ if not isinstance(document, dict):
380
+ raise ValueError("project state must be an object")
381
+ return _normalize_state(document)
382
+
383
+
384
+ def _legacy_artifact(record: Mapping[str, Any]) -> dict[str, Any]:
385
+ candidate = record.get("candidate_targets")
386
+ accepted_targets = record.get("accepted_targets")
387
+ reviewed_targets = record.get("reviewed_targets")
388
+ output_map = (
389
+ candidate
390
+ if isinstance(candidate, Mapping)
391
+ else accepted_targets
392
+ if isinstance(accepted_targets, Mapping)
393
+ else {}
394
+ )
395
+ outputs = sorted(str(path) for path in output_map if isinstance(path, str))
396
+ input_map = (
397
+ record.get("candidate_inputs")
398
+ if isinstance(candidate, Mapping)
399
+ else record.get("accepted_inputs")
400
+ )
401
+ if not isinstance(input_map, Mapping):
402
+ input_map = {}
403
+ inputs = {
404
+ str(path): str(value)
405
+ for path, value in (input_map.items() if isinstance(input_map, Mapping) else [])
406
+ if isinstance(path, str) and isinstance(value, str)
407
+ }
408
+ acceptance: dict[str, Any] | None = None
409
+ if record.get("creator_acceptance") in {"accepted", "rejected"}:
410
+ decision = str(record["creator_acceptance"])
411
+ acceptance = {
412
+ "decision": decision,
413
+ "at": None,
414
+ "outputs": {
415
+ str(path): str(value)
416
+ for path, value in (
417
+ accepted_targets.items()
418
+ if isinstance(accepted_targets, Mapping)
419
+ else []
420
+ )
421
+ if isinstance(path, str) and isinstance(value, str)
422
+ },
423
+ "note": "migrated from v0.3 state",
424
+ }
425
+ review: dict[str, Any] | None = None
426
+ legacy_verdict = record.get("independent_review")
427
+ if legacy_verdict in {"approve", "approve_with_notes", "revise", "provisional"}:
428
+ review_outputs = (
429
+ reviewed_targets
430
+ if isinstance(reviewed_targets, Mapping)
431
+ else accepted_targets
432
+ if isinstance(accepted_targets, Mapping)
433
+ else {}
434
+ )
435
+ review = {
436
+ "verdict": legacy_verdict,
437
+ "at": None,
438
+ "outputs": {
439
+ str(path): str(value)
440
+ for path, value in review_outputs.items()
441
+ if isinstance(path, str) and isinstance(value, str)
442
+ },
443
+ "reviewer": "migrated",
444
+ "note": "migrated from v0.3 state",
445
+ }
446
+ return {
447
+ "owner": str(record.get("owner") or "unknown"),
448
+ "outputs": outputs,
449
+ "inputs": inputs,
450
+ "acceptance": acceptance,
451
+ "review": review,
452
+ "updated_at": None,
453
+ }
454
+
455
+
456
+ def _normalize_state(document: Mapping[str, Any]) -> dict[str, Any]:
457
+ artifacts_raw = document.get("artifacts", {})
458
+ if not isinstance(artifacts_raw, Mapping):
459
+ raise ValueError("state.artifacts must be an object")
460
+ artifacts: dict[str, Any] = {}
461
+ modern = document.get("schema_version") == STATE_SCHEMA
462
+ for artifact_id, value in artifacts_raw.items():
463
+ if not isinstance(artifact_id, str) or not isinstance(value, Mapping):
464
+ raise ValueError("artifact state entries must be objects")
465
+ if modern and isinstance(value.get("outputs"), list):
466
+ outputs = [
467
+ _relative_path(item)
468
+ for item in value["outputs"]
469
+ if isinstance(item, str)
470
+ ]
471
+ inputs_raw = value.get("inputs", {})
472
+ if not isinstance(inputs_raw, Mapping):
473
+ raise ValueError(f"{artifact_id}.inputs must be an object")
474
+ inputs = {
475
+ _relative_path(path, allow_operations=True): str(digest)
476
+ for path, digest in inputs_raw.items()
477
+ if isinstance(path, str) and isinstance(digest, str)
478
+ }
479
+ artifacts[artifact_id] = {
480
+ "owner": str(value.get("owner") or "unknown"),
481
+ "outputs": sorted(set(outputs)),
482
+ "inputs": inputs,
483
+ "acceptance": value.get("acceptance") if isinstance(value.get("acceptance"), Mapping) else None,
484
+ "review": value.get("review") if isinstance(value.get("review"), Mapping) else None,
485
+ "updated_at": value.get("updated_at"),
486
+ }
487
+ else:
488
+ artifacts[artifact_id] = _legacy_artifact(value)
489
+ mode = document.get("project_layout_mode", "auto")
490
+ if mode not in {"auto", "canonical", "legacy"}:
491
+ mode = "auto"
492
+ bindings_raw = document.get("authority")
493
+ bindings = {
494
+ str(field): dict(binding)
495
+ for field, binding in (bindings_raw.items() if isinstance(bindings_raw, Mapping) else [])
496
+ if isinstance(field, str) and isinstance(binding, Mapping)
497
+ }
498
+ return {
499
+ "schema_version": STATE_SCHEMA,
500
+ "project_id": document.get("project_id"),
501
+ "project_layout_mode": mode,
502
+ "updated_at": document.get("updated_at"),
503
+ "last_action": document.get("last_action") or "loaded",
504
+ "artifacts": artifacts,
505
+ "authority": bindings,
506
+ }
507
+
508
+
509
+ def _save_state(root: Path, state: dict[str, Any], *, action: str) -> None:
510
+ state["schema_version"] = STATE_SCHEMA
511
+ state["updated_at"] = utc_now()
512
+ state["last_action"] = action
513
+ atomic_json(root / STATE_FILE, state)
514
+
515
+
516
+ def _project_layout_from_root(root: Path, state: Mapping[str, Any] | None = None) -> dict[str, Any]:
517
+ current = state or _read_state(root)
518
+ recorded = current.get("project_layout_mode", "auto")
519
+ canonical_roles = sorted(
520
+ role
521
+ for role, name in CANONICAL_ROOTS.items()
522
+ if role in LAYOUT_PINNING_ROLES and _directory_has_content(root / name)
523
+ )
524
+ legacy_roles = sorted(
525
+ role
526
+ for role, name in LEGACY_ROOTS.items()
527
+ if role in LAYOUT_PINNING_ROLES and _directory_has_content(root / name)
528
+ )
529
+ nonstandard_roots = sorted(
530
+ entry.name
531
+ for entry in root.iterdir()
532
+ if (entry.is_symlink() or entry.is_dir())
533
+ if (role := _root_role(entry.name)) in LAYOUT_PINNING_ROLES
534
+ if entry.name not in {CANONICAL_ROOTS[role], LEGACY_ROOTS[role]}
535
+ if _directory_has_content(entry)
536
+ )
537
+ unsafe_roots = sorted(
538
+ entry.name
539
+ for entry in root.iterdir()
540
+ if _root_role(entry.name) in LAYOUT_PINNING_ROLES and entry.is_symlink()
541
+ )
542
+ detected = {
543
+ mode
544
+ for mode, roles in (("canonical", canonical_roles), ("legacy", legacy_roles))
545
+ if roles
546
+ }
547
+ conflict = bool(nonstandard_roots or unsafe_roots) or len(detected) > 1 or (
548
+ recorded in {"canonical", "legacy"} and detected and detected != {recorded}
549
+ )
550
+ if conflict:
551
+ mode = "mixed"
552
+ elif recorded in {"canonical", "legacy"}:
553
+ mode = str(recorded)
554
+ elif detected:
555
+ mode = next(iter(detected))
556
+ else:
557
+ mode = "canonical"
558
+ roots = LEGACY_ROOTS if mode == "legacy" else CANONICAL_ROOTS
559
+ return {
560
+ "mode": mode,
561
+ "pinned": recorded != "auto" or bool(detected),
562
+ "roots": dict(roots),
563
+ "nonstandardRoots": nonstandard_roots,
564
+ "unsafeRoots": unsafe_roots,
565
+ }
566
+
567
+
568
+ def project_layout(path: Path) -> dict[str, Any]:
569
+ root = find_project(path)
570
+ return _project_layout_from_root(root)
571
+
572
+
573
+ def _validate_project_output_layout(root: Path, relatives: Iterable[str]) -> str | None:
574
+ families = {
575
+ family
576
+ for relative in relatives
577
+ if (part := PurePosixPath(relative).parts[0])
578
+ if _root_role(part) in LAYOUT_PINNING_ROLES
579
+ if (family := _root_layout_mode(part)) is not None
580
+ }
581
+ if len(families) > 1:
582
+ raise ValueError("不能在同一次发布中混用中文与旧版英文目录")
583
+ family = next(iter(families), None)
584
+ layout = _project_layout_from_root(root)
585
+ if layout["mode"] == "mixed":
586
+ raise ValueError("项目同时包含中文与旧版英文阶段目录,请先迁移并合并")
587
+ if family is not None and layout["pinned"] and family != layout["mode"]:
588
+ expected = "中文" if layout["mode"] == "canonical" else "旧版英文"
589
+ raise ValueError(f"项目已使用{expected}目录布局,不能创建另一套平行目录")
590
+ return family
591
+
592
+
593
+ def _validate_publication_layout(
594
+ relative: str, *, allow_unregistered: bool
595
+ ) -> None:
596
+ pure = PurePosixPath(relative)
597
+ first = pure.parts[0].casefold()
598
+ role = _root_role(pure.parts[0])
599
+ reason = PROTECTED_PUBLISH_ROOTS.get(first)
600
+ if reason is not None:
601
+ raise ValueError(reason)
602
+ if pure.name.casefold() == PROJECT_FILE:
603
+ raise ValueError("creator authority file cannot be a publication target")
604
+ if role == "episodes":
605
+ if len(pure.parts) < 3:
606
+ raise ValueError("episode artifacts live in 剧集/<EP>/(兼容 episodes/<EP>/)")
607
+ if EPISODE_ID_RE.fullmatch(pure.parts[1]) is None:
608
+ raise ValueError(f"episode directory must use an EP001-style identifier: {pure.parts[1]}")
609
+ if not allow_unregistered and role not in PUBLISHABLE_ROOT_ROLES:
610
+ raise ValueError(
611
+ f"{pure.parts[0]} is not a project stage directory; expected one of {', '.join(PUBLISHABLE_ROOTS)}"
612
+ )
613
+ if role is not None and pure.parts[0] not in {CANONICAL_ROOTS[role], LEGACY_ROOTS[role]}:
614
+ raise ValueError(f"阶段目录大小写或拼写不规范:{pure.parts[0]}")
615
+
616
+
617
+ def _project_path(root: Path, relative: str, *, create_parent: bool = False) -> Path:
618
+ root = root.resolve()
619
+ target = root / relative
620
+ current = root
621
+ for part in PurePosixPath(relative).parts[:-1]:
622
+ current /= part
623
+ if current.exists() and current.is_symlink():
624
+ raise ProjectConflictError(f"project parent cannot be a symlink: {part}")
625
+ if current.exists() and not current.is_dir():
626
+ raise ProjectConflictError(f"project parent is not a directory: {part}")
627
+ if create_parent and not current.exists():
628
+ current.mkdir()
629
+ if target.exists() and (target.is_symlink() or not target.is_file()):
630
+ raise ProjectConflictError(f"project target is not a regular file: {relative}")
631
+ if not target.parent.resolve().is_relative_to(root):
632
+ raise ValueError(f"path escapes project root: {relative}")
633
+ return target
634
+
635
+
636
+ def _live_hash(root: Path, relative: str) -> str | None:
637
+ try:
638
+ target = _project_path(root, relative)
639
+ except (OSError, ProjectConflictError, ValueError):
640
+ return None
641
+ if not target.is_file():
642
+ return None
643
+ return sha256_file(target)
644
+
645
+
646
+ @contextlib.contextmanager
647
+ def _lock_handle(handle: Any) -> Iterator[None]:
648
+ if os.name == "nt":
649
+ import msvcrt
650
+
651
+ handle.seek(0, os.SEEK_END)
652
+ if handle.tell() == 0:
653
+ handle.write(b"0")
654
+ handle.flush()
655
+ handle.seek(0)
656
+ locking = getattr(msvcrt, "locking")
657
+ lock = getattr(msvcrt, "LK_LOCK")
658
+ unlock = getattr(msvcrt, "LK_UNLCK")
659
+ locking(handle.fileno(), lock, 1)
660
+ try:
661
+ yield
662
+ finally:
663
+ handle.seek(0)
664
+ locking(handle.fileno(), unlock, 1)
665
+ else:
666
+ import fcntl
667
+
668
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
669
+ try:
670
+ yield
671
+ finally:
672
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
673
+
674
+
675
+ @contextlib.contextmanager
676
+ def _project_lock(root: Path) -> Iterator[None]:
677
+ lock_path = root / ".short-drama/project.lock"
678
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
679
+ with lock_path.open("a+b") as handle, _lock_handle(handle):
680
+ yield
681
+
682
+
683
+ def _hash_mapping(root: Path, relatives: Iterable[str]) -> dict[str, str]:
684
+ result: dict[str, str] = {}
685
+ for relative in relatives:
686
+ digest = _live_hash(root, relative)
687
+ if digest is None:
688
+ raise ProjectConflictError(f"project file is missing or unsafe: {relative}")
689
+ result[relative] = digest
690
+ return result
691
+
692
+
693
+ def _inputs_current(root: Path, record: Mapping[str, Any]) -> bool:
694
+ inputs = record.get("inputs", {})
695
+ return isinstance(inputs, Mapping) and all(
696
+ isinstance(path, str)
697
+ and isinstance(expected, str)
698
+ and _live_hash(root, path) == expected
699
+ for path, expected in inputs.items()
700
+ )
701
+
702
+
703
+ def _artifact_state_from(
704
+ record: Mapping[str, Any], live_hash: Callable[[str], str | None]
705
+ ) -> str:
706
+ """Decide one artifact's lifecycle state from the bytes on disk right now.
707
+
708
+ The whole lifecycle rests on the two `!= live` comparisons below: an
709
+ accepted artifact whose bytes were edited behind the tool's back has not
710
+ been accepted in its current form, and must fall back to `update_needed`.
711
+
712
+ This is written once and reached by two callers -- path-based and
713
+ directory-fd-based -- which differ only in how they hash a file. It used to
714
+ be two transcriptions of the same rules, with nothing asserting they agreed,
715
+ and the dashboard renders the fd one.
716
+ """
717
+
718
+ outputs = record.get("outputs", [])
719
+ if not isinstance(outputs, list) or not outputs:
720
+ return "draft"
721
+ live = {path: live_hash(path) for path in outputs if isinstance(path, str)}
722
+ if len(live) != len(outputs) or any(value is None for value in live.values()):
723
+ return "update_needed"
724
+ inputs = record.get("inputs", {})
725
+ if not isinstance(inputs, Mapping) or any(
726
+ not isinstance(path, str)
727
+ or not isinstance(expected, str)
728
+ or live_hash(path) != expected
729
+ for path, expected in inputs.items()
730
+ ):
731
+ return "update_needed"
732
+ acceptance = record.get("acceptance")
733
+ if not isinstance(acceptance, Mapping):
734
+ return "needs_confirmation"
735
+ if acceptance.get("decision") == "rejected":
736
+ return "revise"
737
+ if acceptance.get("decision") != "accepted" or acceptance.get("outputs") != live:
738
+ return "update_needed"
739
+ review = record.get("review")
740
+ if not isinstance(review, Mapping):
741
+ return "accepted"
742
+ if review.get("outputs") != live:
743
+ return "update_needed"
744
+ verdict = review.get("verdict")
745
+ if verdict in {"approve", "approve_with_notes"}:
746
+ return "approved"
747
+ if verdict == "revise":
748
+ return "revise"
749
+ return "accepted"
750
+
751
+
752
+ def _artifact_state(root: Path, record: Mapping[str, Any]) -> str:
753
+ return _artifact_state_from(record, lambda path: _live_hash(root, path))
754
+
755
+
756
+ def _authority_report(
757
+ project: Mapping[str, Any], state: Mapping[str, Any]
758
+ ) -> dict[str, str]:
759
+ """Say whether the manifest still holds what each bound decision wrote.
760
+
761
+ `set-authority` is the only sanctioned way into `short-drama.json`, and it
762
+ already records the digest of what it wrote. Comparing that against the file
763
+ is what turns a hand edit from invisible into reported.
764
+ """
765
+ bindings = state.get("authority")
766
+ if not isinstance(bindings, Mapping):
767
+ return {}
768
+ report: dict[str, str] = {}
769
+ for field, binding in bindings.items():
770
+ if not isinstance(field, str) or not isinstance(binding, Mapping):
771
+ continue
772
+ try:
773
+ tokens = _authority_tokens(field)
774
+ except ValueError:
775
+ report[field] = "not_authority_field"
776
+ continue
777
+ missing = object()
778
+ cursor: Any = project
779
+ for token in tokens:
780
+ cursor = cursor.get(token, missing) if isinstance(cursor, Mapping) else missing
781
+ if cursor is missing:
782
+ break
783
+ if cursor is missing:
784
+ report[field] = "missing"
785
+ continue
786
+ digest = hashlib.sha256(
787
+ json.dumps(cursor, ensure_ascii=False, sort_keys=True).encode("utf-8")
788
+ ).hexdigest()
789
+ report[field] = "bound" if digest == binding.get("value_sha256") else "hand_edited"
790
+ return report
791
+
792
+
793
+ def _build_status(
794
+ *,
795
+ project: Mapping[str, Any],
796
+ state: Mapping[str, Any],
797
+ layout: Mapping[str, Any],
798
+ project_root: str,
799
+ artifact_state: Callable[[Mapping[str, Any]], str],
800
+ ) -> dict[str, Any]:
801
+ counts: dict[str, int] = {}
802
+ artifacts: dict[str, str] = {}
803
+ # Which stage produced each file. The state already knows; without it every
804
+ # reader has to guess from the path, and a stage added later reads as
805
+ # "unrecognised" until someone updates that reader's own list of names.
806
+ ownership: dict[str, str] = {}
807
+ records = state.get("artifacts", {})
808
+ if isinstance(records, Mapping):
809
+ for artifact_id, record in records.items():
810
+ if not isinstance(artifact_id, str) or not isinstance(record, Mapping):
811
+ continue
812
+ value = artifact_state(record)
813
+ artifacts[artifact_id] = value
814
+ counts[value] = counts.get(value, 0) + 1
815
+ owner = record.get("owner")
816
+ if not isinstance(owner, str) or not owner:
817
+ continue
818
+ for output in record.get("outputs", []) or []:
819
+ if isinstance(output, str) and output:
820
+ ownership[output] = owner
821
+ languages = project_languages(project)
822
+ return {
823
+ "project_id": project.get("project_id"),
824
+ "title": project.get("title"),
825
+ "language": languages["language"],
826
+ "prompt_language": languages["prompt_language"],
827
+ "project_root": project_root,
828
+ "last_action": state.get("last_action"),
829
+ "layout": dict(layout),
830
+ "artifact_states": counts,
831
+ "artifacts": artifacts,
832
+ "ownership": ownership,
833
+ "authority": _authority_report(project, state),
834
+ "lifecycle": {"artifact_state": counts},
835
+ }
836
+
837
+
838
+ def project_status(path: Path) -> dict[str, Any]:
839
+ root = find_project(path)
840
+ project = json.loads((root / PROJECT_FILE).read_text(encoding="utf-8"))
841
+ if not isinstance(project, dict):
842
+ raise ValueError("project manifest must be an object")
843
+ state = _read_state(root)
844
+ return _build_status(
845
+ project=project,
846
+ state=state,
847
+ layout=_project_layout_from_root(root, state),
848
+ project_root=str(root),
849
+ artifact_state=lambda record: _artifact_state(root, record),
850
+ )
851
+
852
+
853
+ def _open_directory_at(directory_fd: int, parts: Iterable[str]) -> int:
854
+ descriptor = os.dup(directory_fd)
855
+ try:
856
+ for part in parts:
857
+ child = os.open(
858
+ part,
859
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
860
+ dir_fd=descriptor,
861
+ )
862
+ os.close(descriptor)
863
+ descriptor = child
864
+ return descriptor
865
+ except Exception:
866
+ os.close(descriptor)
867
+ raise
868
+
869
+
870
+ def _read_regular_at(directory_fd: int, relative: str) -> bytes:
871
+ pure = PurePosixPath(relative)
872
+ parent = _open_directory_at(directory_fd, pure.parts[:-1])
873
+ descriptor = -1
874
+ try:
875
+ descriptor = os.open(
876
+ pure.name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent
877
+ )
878
+ details = os.fstat(descriptor)
879
+ if not stat.S_ISREG(details.st_mode):
880
+ raise OSError("not a regular file")
881
+ with os.fdopen(descriptor, "rb") as handle:
882
+ descriptor = -1
883
+ return handle.read()
884
+ finally:
885
+ if descriptor >= 0:
886
+ os.close(descriptor)
887
+ os.close(parent)
888
+
889
+
890
+ def _directory_has_content_at(directory_fd: int, name: str) -> tuple[bool, bool]:
891
+ try:
892
+ descriptor = os.open(
893
+ name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory_fd
894
+ )
895
+ except FileNotFoundError:
896
+ return False, False
897
+ except OSError:
898
+ return True, True
899
+ try:
900
+ with os.scandir(descriptor) as entries:
901
+ return any(True for _ in entries), False
902
+ finally:
903
+ os.close(descriptor)
904
+
905
+
906
+ def _project_layout_at(directory_fd: int, state: Mapping[str, Any]) -> dict[str, Any]:
907
+ recorded = state.get("project_layout_mode", "auto")
908
+ canonical: list[str] = []
909
+ legacy: list[str] = []
910
+ unsafe: list[str] = []
911
+ nonstandard: list[str] = []
912
+ with os.scandir(directory_fd) as entries:
913
+ root_entries = list(entries)
914
+ for entry in root_entries:
915
+ role = _root_role(entry.name)
916
+ if role not in LAYOUT_PINNING_ROLES:
917
+ continue
918
+ has_content, unsafe_entry = _directory_has_content_at(directory_fd, entry.name)
919
+ if not has_content:
920
+ continue
921
+ if unsafe_entry or entry.is_symlink():
922
+ unsafe.append(entry.name)
923
+ continue
924
+ if entry.name == CANONICAL_ROOTS[role]:
925
+ canonical.append(role)
926
+ elif entry.name == LEGACY_ROOTS[role]:
927
+ legacy.append(role)
928
+ else:
929
+ nonstandard.append(entry.name)
930
+ detected = {mode for mode, values in (("canonical", canonical), ("legacy", legacy)) if values}
931
+ conflict = bool(unsafe or nonstandard) or len(detected) > 1 or (
932
+ recorded in {"canonical", "legacy"} and detected and detected != {recorded}
933
+ )
934
+ if conflict:
935
+ mode = "mixed"
936
+ elif recorded in {"canonical", "legacy"}:
937
+ mode = str(recorded)
938
+ elif detected:
939
+ mode = next(iter(detected))
940
+ else:
941
+ mode = "canonical"
942
+ roots = LEGACY_ROOTS if mode == "legacy" else CANONICAL_ROOTS
943
+ return {
944
+ "mode": mode,
945
+ "pinned": recorded != "auto" or bool(detected),
946
+ "roots": dict(roots),
947
+ "nonstandardRoots": sorted(nonstandard),
948
+ "unsafeRoots": sorted(unsafe),
949
+ }
950
+
951
+
952
+ def project_status_at(directory_fd: int, *, project_root: str) -> dict[str, Any]:
953
+ project = json.loads(_read_regular_at(directory_fd, PROJECT_FILE).decode("utf-8"))
954
+ try:
955
+ raw_state = json.loads(
956
+ _read_regular_at(directory_fd, STATE_FILE.as_posix()).decode("utf-8")
957
+ )
958
+ except FileNotFoundError:
959
+ raw_state = {
960
+ "schema_version": STATE_SCHEMA,
961
+ "project_id": project.get("project_id"),
962
+ "project_layout_mode": "auto",
963
+ "last_action": "untracked",
964
+ "artifacts": {},
965
+ }
966
+ if not isinstance(project, dict) or not isinstance(raw_state, dict):
967
+ raise ValueError("project files must contain objects")
968
+ state = _normalize_state(raw_state)
969
+ return _build_status(
970
+ project=project,
971
+ state=state,
972
+ layout=_project_layout_at(directory_fd, state),
973
+ project_root=project_root,
974
+ artifact_state=lambda record: _artifact_state_at(directory_fd, record),
975
+ )
976
+
977
+
978
+ def _live_hash_at(directory_fd: int, relative: str) -> str | None:
979
+ try:
980
+ return sha256_bytes(_read_regular_at(directory_fd, relative))
981
+ except (OSError, ValueError):
982
+ return None
983
+
984
+
985
+ def _artifact_state_at(directory_fd: int, record: Mapping[str, Any]) -> str:
986
+ return _artifact_state_from(record, lambda path: _live_hash_at(directory_fd, path))
987
+
988
+
989
+ def project_path_lifecycle_at(
990
+ directory_fd: int, relative: str
991
+ ) -> dict[str, str] | None:
992
+ """Return the one creator-facing state for a tracked path."""
993
+
994
+ normalized = _relative_path(relative, allow_operations=True)
995
+ try:
996
+ raw_state = json.loads(
997
+ _read_regular_at(directory_fd, STATE_FILE.as_posix()).decode("utf-8")
998
+ )
999
+ except (FileNotFoundError, UnicodeError, json.JSONDecodeError):
1000
+ return None
1001
+ if not isinstance(raw_state, Mapping):
1002
+ return None
1003
+ state = _normalize_state(raw_state)
1004
+ for record in state["artifacts"].values():
1005
+ if isinstance(record, Mapping) and normalized in record.get("outputs", []):
1006
+ return {"artifact_state": _artifact_state_at(directory_fd, record)}
1007
+ return None
1008
+
1009
+
1010
+ def _validate_structured_content(relative: str, content: bytes) -> None:
1011
+ try:
1012
+ text = content.decode("utf-8")
1013
+ except UnicodeDecodeError as exc:
1014
+ raise ValueError(f"publication text must be UTF-8: {relative}") from exc
1015
+ suffix = PurePosixPath(relative).suffix.casefold()
1016
+ if suffix not in DELIVERY_SUFFIXES:
1017
+ raise ValueError(f"publication supports Markdown, JSON, and JSONL only: {relative}")
1018
+ if suffix == ".json":
1019
+ json.loads(text)
1020
+ elif suffix == ".jsonl":
1021
+ for number, line in enumerate(text.splitlines(), 1):
1022
+ if not line.strip():
1023
+ continue
1024
+ try:
1025
+ value = json.loads(line)
1026
+ except json.JSONDecodeError as exc:
1027
+ raise ValueError(f"invalid JSONL at {relative}:{number}") from exc
1028
+ if not isinstance(value, dict):
1029
+ raise ValueError(f"JSONL records must be objects: {relative}:{number}")
1030
+
1031
+
1032
+ def _check_output_ownership(state: Mapping[str, Any], artifact_id: str, outputs: Iterable[str]) -> None:
1033
+ wanted = set(outputs)
1034
+ artifacts = state.get("artifacts", {})
1035
+ if not isinstance(artifacts, Mapping):
1036
+ return
1037
+ for other_id, other in artifacts.items():
1038
+ if other_id == artifact_id or not isinstance(other, Mapping):
1039
+ continue
1040
+ existing = other.get("outputs", [])
1041
+ overlap = wanted.intersection(existing if isinstance(existing, list) else [])
1042
+ if overlap:
1043
+ raise ValueError(
1044
+ f"project path already belongs to {other_id}: {', '.join(sorted(overlap))}"
1045
+ )
1046
+
1047
+
1048
+ def publish_candidate(
1049
+ root: Path,
1050
+ *,
1051
+ owner: str,
1052
+ artifact_id: str,
1053
+ outputs: Mapping[str, str | bytes],
1054
+ inputs: Iterable[str] | None = None,
1055
+ allow_unregistered_path: bool = False,
1056
+ ) -> dict[str, Any]:
1057
+ root = find_project(root)
1058
+ if not owner.strip() or not artifact_id.strip():
1059
+ raise ValueError("owner and artifact_id are required")
1060
+ if not outputs:
1061
+ raise ValueError("publish needs at least one output")
1062
+ normalized_outputs = _validate_path_set(root, outputs, label="publication")
1063
+ prepared: dict[str, bytes] = {}
1064
+ for relative in normalized_outputs:
1065
+ _validate_publication_layout(
1066
+ relative, allow_unregistered=allow_unregistered_path
1067
+ )
1068
+ content = outputs[relative]
1069
+ encoded = content.encode("utf-8") if isinstance(content, str) else bytes(content)
1070
+ _validate_structured_content(relative, encoded)
1071
+ prepared[relative] = encoded
1072
+
1073
+ input_paths = list(inputs or [])
1074
+ normalized_inputs = _validate_path_set(root, input_paths, label="input")
1075
+ if set(normalized_inputs).intersection(normalized_outputs):
1076
+ raise ValueError("an output cannot also be its own direct input")
1077
+
1078
+ with _project_lock(root):
1079
+ state = _read_state(root)
1080
+ family = _validate_project_output_layout(root, normalized_outputs)
1081
+ _check_output_ownership(state, artifact_id, normalized_outputs)
1082
+ current_inputs = _hash_mapping(root, normalized_inputs)
1083
+ for relative, content in prepared.items():
1084
+ target = _project_path(root, relative, create_parent=True)
1085
+ _atomic_bytes(target, content)
1086
+ state["artifacts"][artifact_id] = {
1087
+ "owner": owner,
1088
+ "outputs": normalized_outputs,
1089
+ "inputs": current_inputs,
1090
+ "acceptance": None,
1091
+ "review": None,
1092
+ "updated_at": utc_now(),
1093
+ }
1094
+ if state.get("project_layout_mode") == "auto" and family is not None:
1095
+ state["project_layout_mode"] = family
1096
+ _save_state(root, state, action="published")
1097
+ return {
1098
+ "artifact_id": artifact_id,
1099
+ "owner": owner,
1100
+ "outputs": normalized_outputs,
1101
+ "state": "needs_confirmation",
1102
+ }
1103
+
1104
+
1105
+ def record_creator_acceptance(
1106
+ root: Path,
1107
+ *,
1108
+ artifact_id: str,
1109
+ decision: str,
1110
+ note: str = "",
1111
+ ) -> dict[str, Any]:
1112
+ if decision not in {"accepted", "rejected"}:
1113
+ raise ValueError("decision must be accepted or rejected")
1114
+ root = find_project(root)
1115
+ with _project_lock(root):
1116
+ state = _read_state(root)
1117
+ record = state["artifacts"].get(artifact_id)
1118
+ if not isinstance(record, dict):
1119
+ raise KeyError(f"unknown artifact: {artifact_id}")
1120
+ outputs = _hash_mapping(root, record.get("outputs", []))
1121
+ if not _inputs_current(root, record):
1122
+ raise ProjectConflictError("direct input changed; republish before acceptance")
1123
+ record["acceptance"] = {
1124
+ "decision": decision,
1125
+ "at": utc_now(),
1126
+ "outputs": outputs,
1127
+ "note": note.strip(),
1128
+ }
1129
+ record["review"] = None
1130
+ record["updated_at"] = utc_now()
1131
+ _save_state(root, state, action="accepted" if decision == "accepted" else "rejected")
1132
+ return {"artifact_id": artifact_id, "decision": decision, "state": _artifact_state(root, record)}
1133
+
1134
+
1135
+ def record_review(
1136
+ root: Path,
1137
+ *,
1138
+ artifact_id: str,
1139
+ verdict: str,
1140
+ reviewer: str = "",
1141
+ note: str = "",
1142
+ ) -> dict[str, Any]:
1143
+ if verdict not in {"approve", "approve_with_notes", "revise", "provisional"}:
1144
+ raise ValueError("unsupported review verdict")
1145
+ root = find_project(root)
1146
+ with _project_lock(root):
1147
+ state = _read_state(root)
1148
+ record = state["artifacts"].get(artifact_id)
1149
+ if not isinstance(record, dict):
1150
+ raise KeyError(f"unknown artifact: {artifact_id}")
1151
+ outputs = _hash_mapping(root, record.get("outputs", []))
1152
+ acceptance = record.get("acceptance")
1153
+ if (
1154
+ not isinstance(acceptance, Mapping)
1155
+ or acceptance.get("decision") != "accepted"
1156
+ or acceptance.get("outputs") != outputs
1157
+ ):
1158
+ raise ProjectConflictError("review requires current creator acceptance")
1159
+ if not _inputs_current(root, record):
1160
+ raise ProjectConflictError("direct input changed; republish before review")
1161
+ record["review"] = {
1162
+ "verdict": verdict,
1163
+ "at": utc_now(),
1164
+ "outputs": outputs,
1165
+ "reviewer": reviewer.strip(),
1166
+ "note": note.strip(),
1167
+ }
1168
+ record["updated_at"] = utc_now()
1169
+ _save_state(root, state, action="reviewed")
1170
+ return {"artifact_id": artifact_id, "verdict": verdict, "state": _artifact_state(root, record)}
1171
+
1172
+
1173
+ def _authority_tokens(field: str) -> list[str]:
1174
+ if not field.startswith("/"):
1175
+ raise ValueError(f"--field must be a JSON pointer starting with /: {field}")
1176
+ tokens = [
1177
+ token.replace("~1", "/").replace("~0", "~") for token in field[1:].split("/")
1178
+ ]
1179
+ if any(not token for token in tokens):
1180
+ raise ValueError(f"--field has an empty pointer segment: {field}")
1181
+ if field not in FORMAT_POINTERS and (
1182
+ tokens[0] != AUTHORITY_ROOT_TOKEN or len(tokens) < 2
1183
+ ):
1184
+ raise ValueError(
1185
+ f"set-authority writes /{AUTHORITY_ROOT_TOKEN}/* and "
1186
+ f"{', '.join(FORMAT_POINTERS)} only"
1187
+ )
1188
+ if tokens[:2] == [AUTHORITY_ROOT_TOKEN, "decisions_artifact"]:
1189
+ # Where decisions are kept is project layout, not a creative choice; a
1190
+ # decision record must not move the place its own successors are read from.
1191
+ raise ValueError("decisions_artifact is project layout, not a creator choice")
1192
+ return tokens
1193
+
1194
+
1195
+ def _accepted_decision_value(
1196
+ root: Path,
1197
+ state: Mapping[str, Any],
1198
+ *,
1199
+ decision_path: str,
1200
+ decision_id: str,
1201
+ field: str,
1202
+ ) -> Any:
1203
+ relative = _relative_path(decision_path)
1204
+ if _root_role(PurePosixPath(relative).parts[0]) != "creator-decisions":
1205
+ expected = CANONICAL_ROOTS["creator-decisions"]
1206
+ raise ValueError(f"creator decisions live in {expected}/: {relative}")
1207
+ try:
1208
+ _, record = _artifact_for_path(state, relative)
1209
+ except PackageBlockedError as exc:
1210
+ raise ValueError(f"creator decision file is not a published artifact: {relative}") from exc
1211
+ if _artifact_state(root, record) not in {"accepted", "approved"}:
1212
+ raise ValueError(f"creator decision file is not accepted and current: {relative}")
1213
+ text = _project_path(root, relative).read_text(encoding="utf-8")
1214
+ latest: tuple[int, Mapping[str, Any]] | None = None
1215
+ superseded_by: str | None = None
1216
+ for number, line in enumerate(text.splitlines(), 1):
1217
+ if not line.strip():
1218
+ continue
1219
+ try:
1220
+ decision = json.loads(line)
1221
+ except json.JSONDecodeError as exc:
1222
+ raise ValueError(f"{relative}:{number} is not a creator decision record: {exc}") from exc
1223
+ if not isinstance(decision, Mapping):
1224
+ continue
1225
+ # The file is append-only, so a revision arrives as a later line. Read the
1226
+ # whole file before deciding: the first match may already be retracted.
1227
+ if decision.get("decision_id") == decision_id:
1228
+ latest = (number, decision)
1229
+ elif (
1230
+ decision.get("supersedes_decision_id") == decision_id
1231
+ and decision.get("status") == "accepted"
1232
+ ):
1233
+ superseded_by = str(decision.get("decision_id"))
1234
+ if latest is None:
1235
+ raise KeyError(f"unknown creator decision: {decision_id}")
1236
+ if superseded_by is not None:
1237
+ raise ValueError(f"{decision_id} was superseded by {superseded_by}")
1238
+ number, decision = latest
1239
+ if decision.get("status") != "accepted":
1240
+ raise ValueError(f"{decision_id} is not an accepted creator decision")
1241
+ locators = decision.get("target_locators")
1242
+ if not isinstance(locators, list) or not any(
1243
+ isinstance(locator, Mapping)
1244
+ and locator.get("src") == "short-drama"
1245
+ and locator.get("field") == field
1246
+ for locator in locators
1247
+ ):
1248
+ raise ValueError(f"{decision_id} does not target {field}")
1249
+ if "accepted_value" not in decision:
1250
+ raise ValueError(f"{decision_id} carries no accepted_value at {relative}:{number}")
1251
+ return decision["accepted_value"]
1252
+
1253
+
1254
+ def _json_kind(value: Any) -> str:
1255
+ """The JSON type of a value. 90 and 92.5 are both numbers."""
1256
+ if isinstance(value, bool):
1257
+ return "boolean"
1258
+ if isinstance(value, (int, float)):
1259
+ return "number"
1260
+ if isinstance(value, str):
1261
+ return "string"
1262
+ if isinstance(value, Mapping):
1263
+ return "object"
1264
+ if isinstance(value, list):
1265
+ return "array"
1266
+ return "null" if value is None else type(value).__name__
1267
+
1268
+
1269
+ def _write_authority_value(project: dict[str, Any], tokens: list[str], value: Any) -> Any:
1270
+ cursor: Any = project
1271
+ blocks: list[dict[str, Any]] = []
1272
+ walked: list[str] = []
1273
+ for token in tokens[:-1]:
1274
+ cursor = cursor.get(token) if isinstance(cursor, dict) else None
1275
+ walked.append(token)
1276
+ if not isinstance(cursor, dict):
1277
+ raise ValueError(f"project manifest has no object at /{'/'.join(walked)}")
1278
+ if "status" in cursor:
1279
+ blocks.append(cursor)
1280
+ leaf = tokens[-1]
1281
+ if leaf not in cursor:
1282
+ # The manifest shape is declared by the project template. A decision may
1283
+ # fill a declared slot; inventing one would put a field downstream cannot
1284
+ # know to read.
1285
+ raise ValueError(f"project manifest declares no /{'/'.join([*walked, leaf])}")
1286
+ current = cursor.get(leaf)
1287
+ if isinstance(current, Mapping) and not isinstance(value, Mapping) and "status" not in current:
1288
+ # A choices map is merged, never replaced wholesale: replacing it would
1289
+ # silently drop the choices a previous decision already recorded.
1290
+ raise ValueError(f"/{'/'.join([*walked, leaf])} needs an object accepted_value")
1291
+ if isinstance(current, Mapping) and isinstance(value, Mapping) and "status" not in current:
1292
+ merged = {**current, **value}
1293
+ cursor[leaf] = merged
1294
+ for block in blocks:
1295
+ block["status"] = "accepted"
1296
+ return merged
1297
+ if not (isinstance(current, dict) and "status" in current):
1298
+ if current is not None and _json_kind(value) != _json_kind(current):
1299
+ raise ValueError(
1300
+ f"/{'/'.join([*walked, leaf])} is {_json_kind(current)}; "
1301
+ f"accepted_value is {_json_kind(value)}"
1302
+ )
1303
+ cursor[leaf] = value
1304
+ # Writing one choice inside an authority block accepts that block: a
1305
+ # downstream stage gates on the block's status before reading the choice.
1306
+ for block in blocks:
1307
+ block["status"] = "accepted"
1308
+ return value
1309
+ if not isinstance(value, Mapping) or not value:
1310
+ raise ValueError(f"/{'/'.join(tokens)} needs a non-empty object accepted_value")
1311
+ if "status" in value:
1312
+ raise ValueError("accepted_value must not carry its own status")
1313
+ block = dict(current)
1314
+ choices = block.get("choices")
1315
+ if isinstance(choices, Mapping):
1316
+ block["choices"] = {**choices, **value}
1317
+ else:
1318
+ block.update(value)
1319
+ block["status"] = "accepted"
1320
+ cursor[leaf] = block
1321
+ return block
1322
+
1323
+
1324
+ def set_creator_authority(
1325
+ root: Path,
1326
+ *,
1327
+ field: str,
1328
+ decision_path: str,
1329
+ decision_id: str,
1330
+ ) -> dict[str, Any]:
1331
+ tokens = _authority_tokens(field)
1332
+ root = find_project(root)
1333
+ with _project_lock(root):
1334
+ state = _read_state(root)
1335
+ value = _accepted_decision_value(
1336
+ root,
1337
+ state,
1338
+ decision_path=decision_path,
1339
+ decision_id=decision_id,
1340
+ field=field,
1341
+ )
1342
+ if field == EPISODE_LENGTH_POINTER and not (
1343
+ isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
1344
+ ):
1345
+ raise ValueError("target_seconds_per_episode must be a positive number of seconds")
1346
+ project_path = root / PROJECT_FILE
1347
+ project = json.loads(project_path.read_text(encoding="utf-8"))
1348
+ if not isinstance(project, dict):
1349
+ raise ValueError("project manifest must be an object")
1350
+ written = _write_authority_value(project, tokens, value)
1351
+ if field == PACING_POINTER:
1352
+ # Check what the manifest will hold, not what the decision said: an
1353
+ # object slot is merged, so a half decision leaves the other rate at
1354
+ # null and the estimate stays unusable while the write reports bound.
1355
+ # Type only — which rates make an estimate usable is the write
1356
+ # stage's call, and it stays in one place.
1357
+ if not isinstance(written, Mapping) or not written:
1358
+ raise ValueError("pacing must be an object of named rates")
1359
+ for name, rate in written.items():
1360
+ if not (
1361
+ isinstance(rate, (int, float))
1362
+ and not isinstance(rate, bool)
1363
+ and rate > 0
1364
+ ):
1365
+ raise ValueError(
1366
+ f"pacing rate {name} must be a positive number; "
1367
+ f"a decision that sets only some rates leaves the rest unset"
1368
+ )
1369
+ atomic_json(project_path, project)
1370
+ # Record which decision produced the value, so a later reader can tell a
1371
+ # bound write from a hand edit and see what a re-bind replaced.
1372
+ bindings = state.get("authority")
1373
+ if not isinstance(bindings, dict):
1374
+ bindings = {}
1375
+ state["authority"] = bindings
1376
+ bindings[field] = {
1377
+ "decision": f"{_relative_path(decision_path)}#{decision_id}",
1378
+ # The written value, so a later reader can tell the manifest still
1379
+ # holds what the decision said rather than a hand edit made since.
1380
+ "value_sha256": hashlib.sha256(
1381
+ json.dumps(written, ensure_ascii=False, sort_keys=True).encode("utf-8")
1382
+ ).hexdigest(),
1383
+ "set_at": utc_now(),
1384
+ }
1385
+ _save_state(root, state, action="authority_set")
1386
+ return {"field": field, "decision_id": decision_id, "value": written}
1387
+
1388
+
1389
+ def _artifact_for_path(state: Mapping[str, Any], relative: str) -> tuple[str, Mapping[str, Any]]:
1390
+ found: list[tuple[str, Mapping[str, Any]]] = []
1391
+ artifacts = state.get("artifacts", {})
1392
+ if isinstance(artifacts, Mapping):
1393
+ for artifact_id, record in artifacts.items():
1394
+ if (
1395
+ isinstance(artifact_id, str)
1396
+ and isinstance(record, Mapping)
1397
+ and relative in record.get("outputs", [])
1398
+ ):
1399
+ found.append((artifact_id, record))
1400
+ if len(found) != 1:
1401
+ raise PackageBlockedError(f"delivery source has no unique artifact owner: {relative}")
1402
+ return found[0]
1403
+
1404
+
1405
+ def _replace_directory(source: Path, target: Path) -> None:
1406
+ backup = target.with_name(f".{target.name}.{uuid.uuid4().hex}.old")
1407
+ moved_old = False
1408
+ try:
1409
+ if target.exists():
1410
+ details = os.lstat(target)
1411
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
1412
+ raise ProjectConflictError(f"delivery target is unsafe: {target.name}")
1413
+ os.replace(target, backup)
1414
+ moved_old = True
1415
+ os.replace(source, target)
1416
+ _fsync_directory(target.parent)
1417
+ if moved_old:
1418
+ shutil.rmtree(backup)
1419
+ except Exception:
1420
+ if moved_old and backup.exists() and not target.exists():
1421
+ os.replace(backup, target)
1422
+ raise
1423
+
1424
+
1425
+ def _require_episode_delivery_path(relative: str, episode: str) -> None:
1426
+ parts = PurePosixPath(relative).parts
1427
+ if (
1428
+ len(parts) < 3
1429
+ or _root_role(parts[0]) != "episodes"
1430
+ or parts[1] != episode
1431
+ ):
1432
+ raise ValueError(f"delivery paths must belong to {episode}: {relative}")
1433
+
1434
+
1435
+ def build_delivery_package(
1436
+ root: Path,
1437
+ *,
1438
+ episode: str,
1439
+ includes: Iterable[str],
1440
+ omissions: Iterable[str] | Mapping[str, str] | None = None,
1441
+ ) -> dict[str, Any]:
1442
+ if EPISODE_ID_RE.fullmatch(episode) is None:
1443
+ raise ValueError("episode must use an EP001-style identifier")
1444
+ root = find_project(root)
1445
+ include_paths = _validate_path_set(root, includes, label="delivery")
1446
+ if not include_paths:
1447
+ raise ValueError("delivery needs at least one included file")
1448
+ omission_values = omissions.keys() if isinstance(omissions, Mapping) else omissions or []
1449
+ omitted = _validate_path_set(root, omission_values, label="omission")
1450
+ if set(include_paths).intersection(omitted):
1451
+ raise ValueError("a delivery path cannot be both included and omitted")
1452
+ for relative in (*include_paths, *omitted):
1453
+ _require_episode_delivery_path(relative, episode)
1454
+
1455
+ with _project_lock(root):
1456
+ state = _read_state(root)
1457
+ entries: list[dict[str, str]] = []
1458
+ snapshots: dict[str, bytes] = {}
1459
+ for relative in include_paths:
1460
+ if PurePosixPath(relative).suffix.casefold() not in DELIVERY_SUFFIXES:
1461
+ raise PackageBlockedError(f"delivery source is not text/JSON: {relative}")
1462
+ artifact_id, record = _artifact_for_path(state, relative)
1463
+ if _artifact_state(root, record) != "approved":
1464
+ raise PackageBlockedError(f"delivery source is not approved and current: {relative}")
1465
+ source = _project_path(root, relative)
1466
+ data = source.read_bytes()
1467
+ try:
1468
+ data.decode("utf-8")
1469
+ except UnicodeDecodeError as exc:
1470
+ raise PackageBlockedError(f"delivery source must be UTF-8: {relative}") from exc
1471
+ digest = sha256_bytes(data)
1472
+ acceptance = record.get("acceptance")
1473
+ review = record.get("review")
1474
+ accepted_outputs = (
1475
+ acceptance.get("outputs") if isinstance(acceptance, Mapping) else None
1476
+ )
1477
+ reviewed_outputs = (
1478
+ review.get("outputs") if isinstance(review, Mapping) else None
1479
+ )
1480
+ if (
1481
+ not isinstance(accepted_outputs, Mapping)
1482
+ or not isinstance(reviewed_outputs, Mapping)
1483
+ or accepted_outputs.get(relative) != digest
1484
+ or reviewed_outputs.get(relative) != digest
1485
+ or not _inputs_current(root, record)
1486
+ ):
1487
+ raise PackageBlockedError(
1488
+ f"delivery source changed after approval: {relative}"
1489
+ )
1490
+ snapshots[relative] = data
1491
+ entries.append(
1492
+ {
1493
+ "artifact_id": artifact_id,
1494
+ "source": relative,
1495
+ "path": f"artifacts/{relative}",
1496
+ "sha256": digest,
1497
+ }
1498
+ )
1499
+
1500
+ layout = _project_layout_from_root(root, state)
1501
+ if layout["mode"] == "mixed":
1502
+ raise PackageBlockedError("mixed project layouts cannot be packaged")
1503
+ delivery_root = str(layout["roots"]["delivery"])
1504
+ parent = root / delivery_root
1505
+ try:
1506
+ parent_details = os.lstat(parent)
1507
+ except FileNotFoundError:
1508
+ parent.mkdir(parents=True)
1509
+ else:
1510
+ if _is_link_or_reparse(parent_details) or not stat.S_ISDIR(
1511
+ parent_details.st_mode
1512
+ ):
1513
+ raise ProjectConflictError("delivery root is unsafe")
1514
+ temporary = parent / f".{episode}.{uuid.uuid4().hex}.tmp"
1515
+ temporary.mkdir()
1516
+ try:
1517
+ for entry in entries:
1518
+ destination = temporary / entry["path"]
1519
+ destination.parent.mkdir(parents=True, exist_ok=True)
1520
+ _atomic_bytes(destination, snapshots[entry["source"]])
1521
+ manifest = {
1522
+ "schema_version": "1.0",
1523
+ "project_id": state.get("project_id"),
1524
+ "episode": episode,
1525
+ "created_at": utc_now(),
1526
+ "files": entries,
1527
+ "omitted": [
1528
+ {
1529
+ "source": path,
1530
+ "reason": (
1531
+ omissions[path]
1532
+ if isinstance(omissions, Mapping) and path in omissions
1533
+ else "creator_omitted"
1534
+ ),
1535
+ }
1536
+ for path in omitted
1537
+ ],
1538
+ }
1539
+ atomic_json(temporary / "manifest.json", manifest)
1540
+ checksum_members = ["manifest.json", *(entry["path"] for entry in entries)]
1541
+ checksums = "".join(
1542
+ f"{sha256_file(temporary / relative)} {relative}\n"
1543
+ for relative in sorted(checksum_members)
1544
+ )
1545
+ _atomic_bytes(temporary / "checksums.sha256", checksums.encode("utf-8"))
1546
+ target = parent / episode
1547
+ _replace_directory(temporary, target)
1548
+ finally:
1549
+ if temporary.exists():
1550
+ shutil.rmtree(temporary)
1551
+ return {
1552
+ "episode": episode,
1553
+ "delivery_root": f"{delivery_root}/{episode}",
1554
+ "files": entries,
1555
+ "omitted": omitted,
1556
+ }
1557
+
1558
+
1559
+ def _delivery_directory(root: Path, episode: str) -> Path:
1560
+ matches: list[Path] = []
1561
+ for name in {CANONICAL_ROOTS["delivery"], LEGACY_ROOTS["delivery"]}:
1562
+ parent = root / name
1563
+ try:
1564
+ parent_details = os.lstat(parent)
1565
+ except FileNotFoundError:
1566
+ continue
1567
+ if _is_link_or_reparse(parent_details) or not stat.S_ISDIR(
1568
+ parent_details.st_mode
1569
+ ):
1570
+ raise ProjectConflictError("delivery root is unsafe")
1571
+ delivery = parent / episode
1572
+ try:
1573
+ details = os.lstat(delivery)
1574
+ except FileNotFoundError:
1575
+ continue
1576
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
1577
+ raise ProjectConflictError("delivery package is not a regular directory")
1578
+ matches.append(delivery)
1579
+ if len(matches) != 1:
1580
+ raise FileNotFoundError(f"expected exactly one delivered package for {episode}")
1581
+ return matches[0]
1582
+
1583
+
1584
+ def _manifest_problems(
1585
+ delivery: Path,
1586
+ manifest: object,
1587
+ *,
1588
+ episode: str,
1589
+ checksum_paths: set[str],
1590
+ ) -> list[str]:
1591
+ problems: list[str] = []
1592
+ if not isinstance(manifest, Mapping):
1593
+ return ["manifest must be an object"]
1594
+ if manifest.get("schema_version") != "1.0":
1595
+ problems.append("manifest schema is invalid")
1596
+ if manifest.get("episode") != episode:
1597
+ problems.append("manifest does not describe this episode")
1598
+ files = manifest.get("files")
1599
+ if not isinstance(files, list):
1600
+ return [*problems, "manifest files must be a list"]
1601
+ manifest_paths: set[str] = set()
1602
+ sources: set[str] = set()
1603
+ for number, entry in enumerate(files, 1):
1604
+ if not isinstance(entry, Mapping) or set(entry) != {
1605
+ "artifact_id",
1606
+ "source",
1607
+ "path",
1608
+ "sha256",
1609
+ }:
1610
+ problems.append(f"manifest file {number} has invalid fields")
1611
+ continue
1612
+ artifact_id = entry.get("artifact_id")
1613
+ source_raw = entry.get("source")
1614
+ path_raw = entry.get("path")
1615
+ digest = entry.get("sha256")
1616
+ if not isinstance(artifact_id, str) or not artifact_id:
1617
+ problems.append(f"manifest file {number} has no artifact id")
1618
+ if not isinstance(source_raw, str) or not isinstance(path_raw, str):
1619
+ problems.append(f"manifest file {number} has an unsafe path")
1620
+ continue
1621
+ try:
1622
+ source = _relative_path(source_raw)
1623
+ relative = _relative_path(path_raw)
1624
+ except ValueError:
1625
+ problems.append(f"manifest file {number} has an unsafe path")
1626
+ continue
1627
+ try:
1628
+ _require_episode_delivery_path(source, episode)
1629
+ except ValueError:
1630
+ problems.append(f"manifest source does not belong to {episode}: {source}")
1631
+ if relative != f"artifacts/{source}":
1632
+ problems.append(f"manifest path does not match its source: {relative}")
1633
+ if relative in manifest_paths:
1634
+ problems.append(f"duplicate manifest path: {relative}")
1635
+ if source in sources:
1636
+ problems.append(f"duplicate manifest source: {source}")
1637
+ manifest_paths.add(relative)
1638
+ sources.add(source)
1639
+ if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
1640
+ problems.append(f"manifest file {number} has an invalid hash")
1641
+ continue
1642
+ member = delivery / relative
1643
+ if member.is_file() and not member.is_symlink() and sha256_file(member) != digest:
1644
+ problems.append(f"manifest hash mismatch: {relative}")
1645
+ expected_manifest_paths = checksum_paths - {"manifest.json"}
1646
+ if manifest_paths != expected_manifest_paths:
1647
+ problems.append("manifest files do not match checksum members")
1648
+
1649
+ omitted = manifest.get("omitted")
1650
+ if not isinstance(omitted, list):
1651
+ problems.append("manifest omissions must be a list")
1652
+ else:
1653
+ for number, entry in enumerate(omitted, 1):
1654
+ if (
1655
+ not isinstance(entry, Mapping)
1656
+ or set(entry) != {"source", "reason"}
1657
+ or not isinstance(entry.get("source"), str)
1658
+ or not isinstance(entry.get("reason"), str)
1659
+ or not str(entry.get("reason")).strip()
1660
+ ):
1661
+ problems.append(f"manifest omission {number} is invalid")
1662
+ continue
1663
+ try:
1664
+ source = _relative_path(str(entry["source"]))
1665
+ _require_episode_delivery_path(source, episode)
1666
+ except ValueError:
1667
+ problems.append(f"manifest omission {number} has an unsafe source")
1668
+ return problems
1669
+
1670
+
1671
+ def verify_delivery_package(root: Path, *, episode: str) -> dict[str, Any]:
1672
+ if EPISODE_ID_RE.fullmatch(episode) is None:
1673
+ raise ValueError("episode must use an EP001-style identifier")
1674
+ root = find_project(root)
1675
+ delivery = _delivery_directory(root, episode)
1676
+ problems: list[str] = []
1677
+ actual: set[str] = set()
1678
+ for path in delivery.rglob("*"):
1679
+ relative = path.relative_to(delivery).as_posix()
1680
+ if path.is_symlink():
1681
+ problems.append(f"symlink is not allowed: {relative}")
1682
+ elif path.is_file():
1683
+ actual.add(relative)
1684
+ elif not path.is_dir():
1685
+ problems.append(f"unsupported delivery member: {relative}")
1686
+ checksums_path = delivery / "checksums.sha256"
1687
+ if checksums_path.is_symlink() or not checksums_path.is_file():
1688
+ problems.append("checksums.sha256 is missing or unsafe")
1689
+ return {"episode": episode, "status": "tampered", "problems": problems}
1690
+ expected: dict[str, str] = {}
1691
+ for number, line in enumerate(checksums_path.read_text(encoding="utf-8").splitlines(), 1):
1692
+ match = re.fullmatch(r"([0-9a-f]{64}) (.+)", line)
1693
+ if match is None:
1694
+ problems.append(f"invalid checksum line {number}")
1695
+ continue
1696
+ digest, raw = match.groups()
1697
+ try:
1698
+ relative = _relative_path(raw)
1699
+ except ValueError:
1700
+ problems.append(f"unsafe checksum path: {raw}")
1701
+ continue
1702
+ if relative in expected:
1703
+ problems.append(f"duplicate checksum path: {relative}")
1704
+ expected[relative] = digest
1705
+ expected_members = set(expected) | {"checksums.sha256"}
1706
+ for extra in sorted(actual - expected_members):
1707
+ problems.append(f"unexpected delivery file: {extra}")
1708
+ for missing in sorted(expected_members - actual):
1709
+ problems.append(f"missing delivery file: {missing}")
1710
+ for relative, digest in expected.items():
1711
+ path = delivery / relative
1712
+ if not path.is_file() or path.is_symlink():
1713
+ continue
1714
+ if sha256_file(path) != digest:
1715
+ problems.append(f"checksum mismatch: {relative}")
1716
+ try:
1717
+ manifest = json.loads((delivery / "manifest.json").read_text(encoding="utf-8"))
1718
+ problems.extend(
1719
+ _manifest_problems(
1720
+ delivery,
1721
+ manifest,
1722
+ episode=episode,
1723
+ checksum_paths=set(expected),
1724
+ )
1725
+ )
1726
+ except (OSError, UnicodeError, json.JSONDecodeError):
1727
+ problems.append("manifest.json is unreadable")
1728
+ return {
1729
+ "episode": episode,
1730
+ "status": "verified" if not problems else "tampered",
1731
+ "problems": problems,
1732
+ }
1733
+
1734
+
1735
+ def _lock_at(directory_fd: int) -> Any:
1736
+ try:
1737
+ operations = os.open(
1738
+ ".short-drama",
1739
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
1740
+ dir_fd=directory_fd,
1741
+ )
1742
+ except FileNotFoundError:
1743
+ try:
1744
+ os.mkdir(".short-drama", 0o700, dir_fd=directory_fd)
1745
+ except FileExistsError:
1746
+ pass
1747
+ operations = os.open(
1748
+ ".short-drama",
1749
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
1750
+ dir_fd=directory_fd,
1751
+ )
1752
+ try:
1753
+ try:
1754
+ descriptor = os.open(
1755
+ "project.lock", os.O_RDWR | os.O_NOFOLLOW, dir_fd=operations
1756
+ )
1757
+ except FileNotFoundError:
1758
+ try:
1759
+ descriptor = os.open(
1760
+ "project.lock",
1761
+ os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
1762
+ 0o600,
1763
+ dir_fd=operations,
1764
+ )
1765
+ except FileExistsError:
1766
+ descriptor = os.open(
1767
+ "project.lock", os.O_RDWR | os.O_NOFOLLOW, dir_fd=operations
1768
+ )
1769
+ finally:
1770
+ os.close(operations)
1771
+ return os.fdopen(descriptor, "a+b", buffering=0)
1772
+
1773
+
1774
+ @contextlib.contextmanager
1775
+ def coordinated_project_text_edit_at(
1776
+ directory_fd: int, relative: str, expected_version: str
1777
+ ) -> Iterator[None]:
1778
+ normalized = _relative_path(relative)
1779
+ if not re.fullmatch(r"[0-9a-f]{64}", expected_version):
1780
+ raise ValueError("expected version must be a SHA-256 digest")
1781
+ with _lock_at(directory_fd) as handle, _lock_handle(handle):
1782
+ current = sha256_bytes(_read_regular_at(directory_fd, normalized))
1783
+ if current != expected_version:
1784
+ raise ProjectConflictError("file changed since it was opened")
1785
+ yield
1786
+
1787
+
1788
+ def _parse_output_bindings(root: Path, values: Iterable[str]) -> dict[str, bytes]:
1789
+ outputs: dict[str, bytes] = {}
1790
+ for value in values:
1791
+ if "=" not in value:
1792
+ raise ValueError("--output uses PROJECT_TARGET=PROJECT_SOURCE")
1793
+ target, source_value = value.split("=", 1)
1794
+ relative = _relative_path(target)
1795
+ source_relative = _relative_path(source_value, allow_operations=True)
1796
+ source = _project_path(root, source_relative)
1797
+ if not source.is_file() or source.is_symlink():
1798
+ raise ValueError(f"publication source is missing or unsafe: {source_relative}")
1799
+ outputs[relative] = source.read_bytes()
1800
+ return outputs
1801
+
1802
+
1803
+ def _parse_decision_ref(value: str) -> tuple[str, str]:
1804
+ path, separator, decision_id = value.rpartition("#")
1805
+ if not separator or not path.strip() or not decision_id.strip():
1806
+ raise ValueError("--decision-ref uses 创作者决策/<file>.jsonl#<decision-id>")
1807
+ return path.strip(), decision_id.strip()
1808
+
1809
+
1810
+ def _parse_omissions(values: Iterable[str]) -> dict[str, str]:
1811
+ result: dict[str, str] = {}
1812
+ for value in values:
1813
+ path, separator, reason = value.partition("=")
1814
+ result[_relative_path(path)] = reason.strip() if separator and reason.strip() else "creator_omitted"
1815
+ return result
1816
+
1817
+
1818
+ def build_parser() -> argparse.ArgumentParser:
1819
+ parser = argparse.ArgumentParser(description="Manage a short-drama filesystem project.")
1820
+ commands = parser.add_subparsers(dest="command", required=True)
1821
+
1822
+ init = commands.add_parser("init", help="Initialize a project without creative content.")
1823
+ init.add_argument("path")
1824
+ init.add_argument("--title", required=True)
1825
+ init.add_argument("--language", default="zh-CN")
1826
+ init.add_argument("--prompt-language", default=DEFAULT_PROMPT_LANGUAGE)
1827
+ init.add_argument("--aspect-ratio", default="9:16")
1828
+
1829
+ status = commands.add_parser("status", help="Print a creator-safe project summary.")
1830
+ status.add_argument("path", nargs="?", default=".")
1831
+
1832
+ publish = commands.add_parser("publish", help="Atomically publish text/JSON outputs.")
1833
+ publish.add_argument("path")
1834
+ publish.add_argument("--owner", required=True)
1835
+ publish.add_argument("--artifact-id", required=True)
1836
+ publish.add_argument(
1837
+ "--output", action="append", required=True, dest="outputs",
1838
+ help="Bind PROJECT_TARGET=PROJECT_SOURCE; repeat for multiple files.",
1839
+ )
1840
+ publish.add_argument(
1841
+ "--input", action="append", default=[], dest="inputs",
1842
+ help="Record one direct project input path; repeat as needed.",
1843
+ )
1844
+ publish.add_argument("--allow-unregistered-path", action="store_true")
1845
+
1846
+ accept = commands.add_parser("accept", help="Accept or reject the current artifact outputs.")
1847
+ accept.add_argument("path")
1848
+ accept.add_argument("--artifact-id", required=True)
1849
+ accept.add_argument("--decision", choices=("accepted", "rejected"), required=True)
1850
+ accept.add_argument("--note", default="")
1851
+
1852
+ review = commands.add_parser("review", help="Record a lightweight verdict for an accepted artifact.")
1853
+ review.add_argument("path")
1854
+ review.add_argument("--artifact-id", required=True)
1855
+ review.add_argument(
1856
+ "--verdict",
1857
+ choices=("approve", "approve_with_notes", "revise", "provisional"),
1858
+ required=True,
1859
+ )
1860
+ review.add_argument("--reviewer", default="")
1861
+ review.add_argument("--note", default="")
1862
+
1863
+ authority = commands.add_parser(
1864
+ "set-authority", help="Write an accepted creator decision into project authority."
1865
+ )
1866
+ authority.add_argument("path")
1867
+ authority.add_argument(
1868
+ "--field",
1869
+ required=True,
1870
+ help=(
1871
+ f"JSON pointer under /{AUTHORITY_ROOT_TOKEN}/ "
1872
+ f"or one of {', '.join(FORMAT_POINTERS)}."
1873
+ ),
1874
+ )
1875
+ authority.add_argument(
1876
+ "--decision-ref",
1877
+ required=True,
1878
+ dest="decision_ref",
1879
+ help="Bind 创作者决策/<file>.jsonl#<decision-id>.",
1880
+ )
1881
+
1882
+ package = commands.add_parser("package", help="Package approved text/JSON artifacts.")
1883
+ package.add_argument("path")
1884
+ package.add_argument("--episode", required=True)
1885
+ package.add_argument("--include", action="append", required=True, dest="includes")
1886
+ package.add_argument(
1887
+ "--omit", action="append", default=[], dest="omissions",
1888
+ help="Record PATH or PATH=REASON as deliberately omitted.",
1889
+ )
1890
+
1891
+ verify = commands.add_parser("verify", help="Re-check a delivered package checksums.")
1892
+ verify.add_argument("path")
1893
+ verify.add_argument("--episode", required=True)
1894
+ return parser
1895
+
1896
+
1897
+ def main(argv: list[str] | None = None) -> int:
1898
+ args = build_parser().parse_args(argv)
1899
+ try:
1900
+ if args.command == "init":
1901
+ result = initialize_project(
1902
+ Path(args.path),
1903
+ title=args.title,
1904
+ language=args.language,
1905
+ prompt_language=args.prompt_language,
1906
+ aspect_ratio=args.aspect_ratio,
1907
+ )
1908
+ elif args.command == "status":
1909
+ result = project_status(Path(args.path))
1910
+ elif args.command == "publish":
1911
+ root = find_project(Path(args.path))
1912
+ result = publish_candidate(
1913
+ root,
1914
+ owner=args.owner,
1915
+ artifact_id=args.artifact_id,
1916
+ outputs=_parse_output_bindings(root, args.outputs),
1917
+ inputs=args.inputs,
1918
+ allow_unregistered_path=args.allow_unregistered_path,
1919
+ )
1920
+ elif args.command == "accept":
1921
+ result = record_creator_acceptance(
1922
+ Path(args.path),
1923
+ artifact_id=args.artifact_id,
1924
+ decision=args.decision,
1925
+ note=args.note,
1926
+ )
1927
+ elif args.command == "review":
1928
+ result = record_review(
1929
+ Path(args.path),
1930
+ artifact_id=args.artifact_id,
1931
+ verdict=args.verdict,
1932
+ reviewer=args.reviewer,
1933
+ note=args.note,
1934
+ )
1935
+ elif args.command == "set-authority":
1936
+ decision_path, decision_id = _parse_decision_ref(args.decision_ref)
1937
+ result = set_creator_authority(
1938
+ Path(args.path),
1939
+ field=args.field,
1940
+ decision_path=decision_path,
1941
+ decision_id=decision_id,
1942
+ )
1943
+ elif args.command == "package":
1944
+ result = build_delivery_package(
1945
+ Path(args.path),
1946
+ episode=args.episode,
1947
+ includes=args.includes,
1948
+ omissions=_parse_omissions(args.omissions),
1949
+ )
1950
+ else:
1951
+ result = verify_delivery_package(Path(args.path), episode=args.episode)
1952
+ if result["status"] != "verified":
1953
+ print(json.dumps(result, ensure_ascii=True, sort_keys=True))
1954
+ return 1
1955
+ print(json.dumps(result, ensure_ascii=True, sort_keys=True))
1956
+ return 0
1957
+ except (KeyError, OSError, ValueError, ProjectConflictError, PackageBlockedError) as exc:
1958
+ print(str(exc), file=sys.stderr)
1959
+ return 2
1960
+
1961
+
1962
+ if __name__ == "__main__":
1963
+ raise SystemExit(main())