@lemoncat7/dsh-partner 2.4.2 → 2.7.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 (671) hide show
  1. package/README.md +8 -0
  2. package/docs/THIRD_PARTY_LICENSES.md +13 -0
  3. package/docs/attachment-storage.md +18 -0
  4. package/docs/director-skills.md +24 -0
  5. package/docs/long-task-continuation.md +51 -0
  6. package/docs/releases/2.5.0.md +13 -0
  7. package/docs/releases/2.6.0.md +11 -0
  8. package/docs/releases/2.7.0.md +21 -0
  9. package/lib/agent-runtime.d.ts +9 -1
  10. package/lib/agent-runtime.d.ts.map +1 -1
  11. package/lib/agent-runtime.js +117 -17
  12. package/lib/agent-runtime.js.map +1 -1
  13. package/lib/api/features/attachments-api.d.ts.map +1 -1
  14. package/lib/api/features/attachments-api.js +31 -1
  15. package/lib/api/features/attachments-api.js.map +1 -1
  16. package/lib/api.d.ts +1 -1
  17. package/lib/api.d.ts.map +1 -1
  18. package/lib/api.js +1 -1
  19. package/lib/api.js.map +1 -1
  20. package/lib/attachments/grouped-storage.d.ts +5 -0
  21. package/lib/attachments/grouped-storage.d.ts.map +1 -0
  22. package/lib/attachments/grouped-storage.js +47 -0
  23. package/lib/attachments/grouped-storage.js.map +1 -0
  24. package/lib/attachments/service.d.ts +17 -0
  25. package/lib/attachments/service.d.ts.map +1 -1
  26. package/lib/attachments/service.js +100 -8
  27. package/lib/attachments/service.js.map +1 -1
  28. package/lib/attachments/shared-storage.d.ts +7 -0
  29. package/lib/attachments/shared-storage.d.ts.map +1 -0
  30. package/lib/attachments/shared-storage.js +44 -0
  31. package/lib/attachments/shared-storage.js.map +1 -0
  32. package/lib/attachments/storage-admin.d.ts +6 -0
  33. package/lib/attachments/storage-admin.d.ts.map +1 -0
  34. package/lib/attachments/storage-admin.js +43 -0
  35. package/lib/attachments/storage-admin.js.map +1 -0
  36. package/lib/attachments/storage-types.d.ts +33 -0
  37. package/lib/attachments/storage-types.d.ts.map +1 -0
  38. package/lib/attachments/storage-types.js +2 -0
  39. package/lib/attachments/storage-types.js.map +1 -0
  40. package/lib/attachments/tool.d.ts.map +1 -1
  41. package/lib/attachments/tool.js +16 -5
  42. package/lib/attachments/tool.js.map +1 -1
  43. package/lib/channels/delivery-policy.d.ts.map +1 -1
  44. package/lib/channels/delivery-policy.js +7 -4
  45. package/lib/channels/delivery-policy.js.map +1 -1
  46. package/lib/channels/direct/message-format.js +418 -281
  47. package/lib/channels/direct/message-format.js.map +4 -4
  48. package/lib/channels/manager.d.ts +7 -3
  49. package/lib/channels/manager.d.ts.map +1 -1
  50. package/lib/channels/manager.js +73 -38
  51. package/lib/channels/manager.js.map +1 -1
  52. package/lib/channels/weixin/api.d.ts.map +1 -1
  53. package/lib/channels/weixin/api.js +7 -2
  54. package/lib/channels/weixin/api.js.map +1 -1
  55. package/lib/channels/weixin/errors.d.ts +14 -0
  56. package/lib/channels/weixin/errors.d.ts.map +1 -0
  57. package/lib/channels/weixin/errors.js +29 -0
  58. package/lib/channels/weixin/errors.js.map +1 -0
  59. package/lib/channels/weixin/poll.d.ts +14 -0
  60. package/lib/channels/weixin/poll.d.ts.map +1 -0
  61. package/lib/channels/weixin/poll.js +50 -0
  62. package/lib/channels/weixin/poll.js.map +1 -0
  63. package/lib/client-api.d.ts +1 -23
  64. package/lib/client-api.d.ts.map +1 -1
  65. package/lib/client-api.js.map +1 -1
  66. package/lib/client-controller.d.ts.map +1 -1
  67. package/lib/client-controller.js +3 -3
  68. package/lib/client-controller.js.map +1 -1
  69. package/lib/client.d.ts.map +1 -1
  70. package/lib/client.js +1078 -679
  71. package/lib/client.js.map +4 -4
  72. package/lib/collaboration/composition.d.ts.map +1 -1
  73. package/lib/collaboration/composition.js +32 -8
  74. package/lib/collaboration/composition.js.map +1 -1
  75. package/lib/collaboration/domain.d.ts +8 -0
  76. package/lib/collaboration/domain.d.ts.map +1 -1
  77. package/lib/collaboration/domain.js.map +1 -1
  78. package/lib/collaboration/service.d.ts.map +1 -1
  79. package/lib/collaboration/service.js +38 -5
  80. package/lib/collaboration/service.js.map +1 -1
  81. package/lib/concern-notification.d.ts.map +1 -1
  82. package/lib/concern-notification.js +2 -1
  83. package/lib/concern-notification.js.map +1 -1
  84. package/lib/concern-tool.js +1 -1
  85. package/lib/concern-tool.js.map +1 -1
  86. package/lib/execution/reply-progress.d.ts.map +1 -1
  87. package/lib/execution/reply-progress.js +4 -2
  88. package/lib/execution/reply-progress.js.map +1 -1
  89. package/lib/execution/service.d.ts +1 -1
  90. package/lib/execution/service.d.ts.map +1 -1
  91. package/lib/execution/service.js +4 -7
  92. package/lib/execution/service.js.map +1 -1
  93. package/lib/index.d.ts.map +1 -1
  94. package/lib/index.js +7 -1
  95. package/lib/index.js.map +1 -1
  96. package/lib/memory-reflection.js +2 -2
  97. package/lib/memory-reflection.js.map +1 -1
  98. package/lib/message-source.d.ts +25 -0
  99. package/lib/message-source.d.ts.map +1 -0
  100. package/lib/message-source.js +8 -0
  101. package/lib/message-source.js.map +1 -0
  102. package/lib/observation-loop.js +4 -4
  103. package/lib/observation-loop.js.map +1 -1
  104. package/lib/pendant/reader.js +1 -1
  105. package/lib/pendant/reader.js.map +1 -1
  106. package/lib/pendant/settings-panel.js +1 -1
  107. package/lib/pendant/settings-panel.js.map +1 -1
  108. package/lib/persona/service.js +1 -1
  109. package/lib/persona/service.js.map +1 -1
  110. package/lib/requirements/attachments.d.ts +7 -0
  111. package/lib/requirements/attachments.d.ts.map +1 -0
  112. package/lib/requirements/attachments.js +26 -0
  113. package/lib/requirements/attachments.js.map +1 -0
  114. package/lib/requirements/domain.d.ts +1 -1
  115. package/lib/requirements/domain.d.ts.map +1 -1
  116. package/lib/requirements/service.d.ts.map +1 -1
  117. package/lib/requirements/service.js +4 -1
  118. package/lib/requirements/service.js.map +1 -1
  119. package/lib/scheduler/channel-origin.d.ts +7 -0
  120. package/lib/scheduler/channel-origin.d.ts.map +1 -0
  121. package/lib/scheduler/channel-origin.js +27 -0
  122. package/lib/scheduler/channel-origin.js.map +1 -0
  123. package/lib/scheduler/continuations.d.ts +29 -0
  124. package/lib/scheduler/continuations.d.ts.map +1 -0
  125. package/lib/scheduler/continuations.js +286 -0
  126. package/lib/scheduler/continuations.js.map +1 -0
  127. package/lib/scheduler/domain.d.ts +40 -0
  128. package/lib/scheduler/domain.d.ts.map +1 -1
  129. package/lib/scheduler/final-reply.d.ts +13 -0
  130. package/lib/scheduler/final-reply.d.ts.map +1 -0
  131. package/lib/scheduler/final-reply.js +21 -0
  132. package/lib/scheduler/final-reply.js.map +1 -0
  133. package/lib/scheduler/service.d.ts +2 -0
  134. package/lib/scheduler/service.d.ts.map +1 -1
  135. package/lib/scheduler/service.js +30 -1
  136. package/lib/scheduler/service.js.map +1 -1
  137. package/lib/scheduler/wakeup-context.d.ts +7 -0
  138. package/lib/scheduler/wakeup-context.d.ts.map +1 -0
  139. package/lib/scheduler/wakeup-context.js +14 -0
  140. package/lib/scheduler/wakeup-context.js.map +1 -0
  141. package/lib/scheduler/wakeup.d.ts +11 -0
  142. package/lib/scheduler/wakeup.d.ts.map +1 -0
  143. package/lib/scheduler/wakeup.js +52 -0
  144. package/lib/scheduler/wakeup.js.map +1 -0
  145. package/lib/skills/builtin-resources.d.ts +6 -0
  146. package/lib/skills/builtin-resources.d.ts.map +1 -0
  147. package/lib/skills/builtin-resources.js +47 -0
  148. package/lib/skills/builtin-resources.js.map +1 -0
  149. package/lib/skills/builtin.d.ts +2 -5
  150. package/lib/skills/builtin.d.ts.map +1 -1
  151. package/lib/skills/builtin.js +11 -0
  152. package/lib/skills/builtin.js.map +1 -1
  153. package/lib/skills/director-builtins.d.ts +8 -0
  154. package/lib/skills/director-builtins.d.ts.map +1 -0
  155. package/lib/skills/director-builtins.js +65 -0
  156. package/lib/skills/director-builtins.js.map +1 -0
  157. package/lib/skills/drama-builtin.d.ts +3 -0
  158. package/lib/skills/drama-builtin.d.ts.map +1 -0
  159. package/lib/skills/drama-builtin.js +49 -0
  160. package/lib/skills/drama-builtin.js.map +1 -0
  161. package/lib/skills/long-task-continuation.d.ts +4 -0
  162. package/lib/skills/long-task-continuation.d.ts.map +1 -0
  163. package/lib/skills/long-task-continuation.js +43 -0
  164. package/lib/skills/long-task-continuation.js.map +1 -0
  165. package/lib/skills/service.d.ts.map +1 -1
  166. package/lib/skills/service.js +8 -3
  167. package/lib/skills/service.js.map +1 -1
  168. package/lib/skills/viora-canvas-builtin.d.ts +3 -0
  169. package/lib/skills/viora-canvas-builtin.d.ts.map +1 -0
  170. package/lib/skills/viora-canvas-builtin.js +19 -0
  171. package/lib/skills/viora-canvas-builtin.js.map +1 -0
  172. package/lib/storage/migrate-layout.d.ts.map +1 -1
  173. package/lib/storage/migrate-layout.js +13 -4
  174. package/lib/storage/migrate-layout.js.map +1 -1
  175. package/lib/store.d.ts.map +1 -1
  176. package/lib/store.js +2 -0
  177. package/lib/store.js.map +1 -1
  178. package/lib/tasks/context.js +1 -0
  179. package/lib/tasks/context.js.map +1 -1
  180. package/lib/tasks/continuation-yield.d.ts +4 -0
  181. package/lib/tasks/continuation-yield.d.ts.map +1 -0
  182. package/lib/tasks/continuation-yield.js +27 -0
  183. package/lib/tasks/continuation-yield.js.map +1 -0
  184. package/lib/tasks/continuation.d.ts +13 -0
  185. package/lib/tasks/continuation.d.ts.map +1 -0
  186. package/lib/tasks/continuation.js +106 -0
  187. package/lib/tasks/continuation.js.map +1 -0
  188. package/lib/tasks/domain.d.ts +2 -0
  189. package/lib/tasks/domain.d.ts.map +1 -1
  190. package/lib/tasks/scheduling.d.ts.map +1 -1
  191. package/lib/tasks/scheduling.js +7 -0
  192. package/lib/tasks/scheduling.js.map +1 -1
  193. package/lib/tasks/service.d.ts +2 -1
  194. package/lib/tasks/service.d.ts.map +1 -1
  195. package/lib/tasks/service.js +19 -1
  196. package/lib/tasks/service.js.map +1 -1
  197. package/lib/ui/attachment-storage.d.ts +2 -0
  198. package/lib/ui/attachment-storage.d.ts.map +1 -0
  199. package/lib/ui/attachment-storage.js +59 -0
  200. package/lib/ui/attachment-storage.js.map +1 -0
  201. package/lib/ui/capability-editor.js +1 -1
  202. package/lib/ui/capability-editor.js.map +1 -1
  203. package/lib/ui/companion-access-panel.js +1 -1
  204. package/lib/ui/companion-access-panel.js.map +1 -1
  205. package/lib/ui/companion-create.js +1 -1
  206. package/lib/ui/companion-create.js.map +1 -1
  207. package/lib/ui/companion-picker.js +1 -1
  208. package/lib/ui/companion-picker.js.map +1 -1
  209. package/lib/ui/identity-editor.js +1 -1
  210. package/lib/ui/identity-editor.js.map +1 -1
  211. package/lib/ui/mcp-capabilities.js +1 -1
  212. package/lib/ui/mcp-capabilities.js.map +1 -1
  213. package/lib/ui/mcp-panel.js +1 -1
  214. package/lib/ui/mcp-panel.js.map +1 -1
  215. package/lib/ui/memory/concern-board.js +2 -2
  216. package/lib/ui/memory/concern-board.js.map +1 -1
  217. package/lib/ui/memory/concern-deletion.d.ts +1 -1
  218. package/lib/ui/memory/concern-deletion.d.ts.map +1 -1
  219. package/lib/ui/memory/concern-deletion.js +9 -4
  220. package/lib/ui/memory/concern-deletion.js.map +1 -1
  221. package/lib/ui/memory/concern-panel.js +1 -1
  222. package/lib/ui/memory/concern-panel.js.map +1 -1
  223. package/lib/ui/memory/memory-library.js +1 -1
  224. package/lib/ui/memory/memory-library.js.map +1 -1
  225. package/lib/ui/memory/memory-panel.js +1 -1
  226. package/lib/ui/memory/memory-panel.js.map +1 -1
  227. package/lib/ui/partner-components.js +1 -1
  228. package/lib/ui/partner-components.js.map +1 -1
  229. package/lib/ui/requirement-board.js +1 -1
  230. package/lib/ui/requirement-board.js.map +1 -1
  231. package/lib/ui/requirement-dialog.js +1 -1
  232. package/lib/ui/requirement-dialog.js.map +1 -1
  233. package/lib/ui/requirement-tasks-panel.js +1 -1
  234. package/lib/ui/requirement-tasks-panel.js.map +1 -1
  235. package/lib/ui/schedule-panel.d.ts.map +1 -1
  236. package/lib/ui/schedule-panel.js +20 -4
  237. package/lib/ui/schedule-panel.js.map +1 -1
  238. package/lib/ui/skills-panel.js +1 -1
  239. package/lib/ui/skills-panel.js.map +1 -1
  240. package/lib/ui/storage-settings.d.ts.map +1 -1
  241. package/lib/ui/storage-settings.js +2 -1
  242. package/lib/ui/storage-settings.js.map +1 -1
  243. package/lib/ui/task-board-stage.js +1 -1
  244. package/lib/ui/task-board-stage.js.map +1 -1
  245. package/lib/ui/workspace-components.js +1 -1
  246. package/lib/ui/workspace-components.js.map +1 -1
  247. package/lib/ui/workspace-navigation.d.ts +16 -16
  248. package/lib/ui/workspace-navigation.js +1 -1
  249. package/lib/ui/workspace-navigation.js.map +1 -1
  250. package/package.json +55 -41
  251. package/resources/skills/cinematic-director/manifest.json +205 -0
  252. package/resources/skills/cinematic-director/upstream/CHANGELOG.md +213 -0
  253. package/resources/skills/cinematic-director/upstream/CONTRIBUTING.md +316 -0
  254. package/resources/skills/cinematic-director/upstream/LICENSE +21 -0
  255. package/resources/skills/cinematic-director/upstream/README.md +507 -0
  256. package/resources/skills/cinematic-director/upstream/SKILL.md +420 -0
  257. package/resources/skills/cinematic-director/upstream/assets/beat-sheet-template.md +97 -0
  258. package/resources/skills/cinematic-director/upstream/assets/director-book-template.md +254 -0
  259. package/resources/skills/cinematic-director/upstream/assets/edit-timeline-template.md +147 -0
  260. package/resources/skills/cinematic-director/upstream/assets/keyframe-prompt-template.md +334 -0
  261. package/resources/skills/cinematic-director/upstream/assets/qc-checklist.md +153 -0
  262. package/resources/skills/cinematic-director/upstream/assets/shot-plan-template.md +95 -0
  263. package/resources/skills/cinematic-director/upstream/assets/sound-plan-template.md +181 -0
  264. package/resources/skills/cinematic-director/upstream/assets/video-prompt-template.md +305 -0
  265. package/resources/skills/cinematic-director/upstream/evals/run.md +69 -0
  266. package/resources/skills/cinematic-director/upstream/references/ai-video-tool-adapters.md +527 -0
  267. package/resources/skills/cinematic-director/upstream/references/blocking-and-staging.md +424 -0
  268. package/resources/skills/cinematic-director/upstream/references/cinematic-language.md +595 -0
  269. package/resources/skills/cinematic-director/upstream/references/continuity-bible.md +410 -0
  270. package/resources/skills/cinematic-director/upstream/references/director_styles/01_spielberg.md +192 -0
  271. package/resources/skills/cinematic-director/upstream/references/director_styles/02_hitchcock.md +193 -0
  272. package/resources/skills/cinematic-director/upstream/references/director_styles/03_kubrick.md +196 -0
  273. package/resources/skills/cinematic-director/upstream/references/director_styles/04_kurosawa.md +196 -0
  274. package/resources/skills/cinematic-director/upstream/references/director_styles/05_scorsese.md +195 -0
  275. package/resources/skills/cinematic-director/upstream/references/director_styles/06_fellini.md +197 -0
  276. package/resources/skills/cinematic-director/upstream/references/director_styles/07_bergman.md +197 -0
  277. package/resources/skills/cinematic-director/upstream/references/director_styles/08_tarkovsky.md +184 -0
  278. package/resources/skills/cinematic-director/upstream/references/director_styles/09_wong_kar_wai.md +189 -0
  279. package/resources/skills/cinematic-director/upstream/references/director_styles/10_nolan.md +185 -0
  280. package/resources/skills/cinematic-director/upstream/references/director_styles/11_villeneuve.md +188 -0
  281. package/resources/skills/cinematic-director/upstream/references/director_styles/12_fincher.md +185 -0
  282. package/resources/skills/cinematic-director/upstream/references/director_styles/13_refn.md +186 -0
  283. package/resources/skills/cinematic-director/upstream/references/director_styles/14_bi_gan.md +188 -0
  284. package/resources/skills/cinematic-director/upstream/references/director_styles/15_zhang_yimou.md +192 -0
  285. package/resources/skills/cinematic-director/upstream/references/director_styles/16_hou_hsiao_hsien.md +192 -0
  286. package/resources/skills/cinematic-director/upstream/references/director_styles/17_park_chan_wook.md +192 -0
  287. package/resources/skills/cinematic-director/upstream/references/director_styles/18_malick.md +191 -0
  288. package/resources/skills/cinematic-director/upstream/references/director_styles/19_michael_mann.md +192 -0
  289. package/resources/skills/cinematic-director/upstream/references/director_styles/20_coen_brothers.md +194 -0
  290. package/resources/skills/cinematic-director/upstream/references/director_styles/README.md +89 -0
  291. package/resources/skills/cinematic-director/upstream/references/director_styles/example_comparisons.md +140 -0
  292. package/resources/skills/cinematic-director/upstream/references/editing-and-assembly.md +263 -0
  293. package/resources/skills/cinematic-director/upstream/references/failure-modes.md +594 -0
  294. package/resources/skills/cinematic-director/upstream/references/genre-playbooks.md +407 -0
  295. package/resources/skills/cinematic-director/upstream/references/image-model-adapters.md +290 -0
  296. package/resources/skills/cinematic-director/upstream/references/lighting-and-color.md +349 -0
  297. package/resources/skills/cinematic-director/upstream/references/product-and-macro.md +207 -0
  298. package/resources/skills/cinematic-director/upstream/references/production-workflow.md +238 -0
  299. package/resources/skills/cinematic-director/upstream/references/prompt-lexicon.md +602 -0
  300. package/resources/skills/cinematic-director/upstream/references/sound-and-dialogue.md +364 -0
  301. package/resources/skills/director-skills/manifest.json +541 -0
  302. package/resources/skills/director-skills/upstream/CHANGELOG.md +32 -0
  303. package/resources/skills/director-skills/upstream/LICENSE +21 -0
  304. package/resources/skills/director-skills/upstream/MANIFEST.md +366 -0
  305. package/resources/skills/director-skills/upstream/README.md +251 -0
  306. package/resources/skills/director-skills/upstream/RESEARCH_PROVENANCE.md +37 -0
  307. package/resources/skills/director-skills/upstream/SKILL.md +114 -0
  308. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/SKILL.md +81 -0
  309. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/checklists/continuity_checklist.md +8 -0
  310. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/character_continuity_update.md +9 -0
  311. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/location_continuity_update.md +9 -0
  312. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/continuity_system.md +21 -0
  313. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/golden_image_workflow.md +29 -0
  314. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/identity_anchors.md +14 -0
  315. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/visual_bible_system.md +15 -0
  316. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/character_profile.md +15 -0
  317. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/continuity_checklist.md +11 -0
  318. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/costume_profile.md +12 -0
  319. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/location_profile.md +14 -0
  320. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/prop_profile.md +13 -0
  321. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/visual_style_bible.md +15 -0
  322. package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/tests/continuity_tests.md +6 -0
  323. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/SKILL.md +80 -0
  324. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/checklists/image_prompt_quality_checklist.md +10 -0
  325. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/examples/shot_list_to_image_prompts.md +4 -0
  326. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/image_prompt_components.md +22 -0
  327. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/lighting_and_photorealism.md +19 -0
  328. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/negative_prompt_patterns.md +18 -0
  329. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/prompt_anatomy.md +18 -0
  330. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/character_reference_sheet_prompt.md +5 -0
  331. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/cinematic_still_prompt.md +3 -0
  332. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/keyframe_prompt.md +6 -0
  333. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/location_reference_prompt.md +3 -0
  334. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/prop_prompt.md +3 -0
  335. package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/tests/image_prompt_completeness_tests.md +5 -0
  336. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/SKILL.md +84 -0
  337. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/checklists/video_prompt_quality_checklist.md +9 -0
  338. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/examples/image_prompt_to_video_prompt.md +3 -0
  339. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/ai_film_production_pipeline.md +21 -0
  340. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/audio_motion_timing.md +16 -0
  341. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/camera_lens_movement.md +26 -0
  342. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/negative_prompt_patterns.md +18 -0
  343. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/prompt_anatomy.md +18 -0
  344. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/video_prompt_components.md +25 -0
  345. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/image_to_video_prompt.md +5 -0
  346. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/text_to_video_prompt.md +5 -0
  347. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/video_beat_prompt.md +9 -0
  348. package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/tests/video_prompt_completeness_tests.md +5 -0
  349. package/resources/skills/director-skills/upstream/skills/model-adaptation/SKILL.md +82 -0
  350. package/resources/skills/director-skills/upstream/skills/model-adaptation/checklists/model_export_checklist.md +9 -0
  351. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/failed_model_transfer_diagnosis.md +5 -0
  352. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/image_prompt_translation.md +10 -0
  353. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/same_scene_across_models.md +9 -0
  354. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/universal_prompt_to_model_specific_exports.md +14 -0
  355. package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/video_prompt_translation.md +9 -0
  356. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/failure_modes_by_model.md +13 -0
  357. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/image_model_profiles.md +226 -0
  358. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_capability_matrix.md +24 -0
  359. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_update_protocol.md +10 -0
  360. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/parameter_compatibility.md +20 -0
  361. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/prompt_translation_rules.md +18 -0
  362. package/resources/skills/director-skills/upstream/skills/model-adaptation/references/video_model_profiles.md +251 -0
  363. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_comparison_table.md +4 -0
  364. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_specific_prompt_export.md +21 -0
  365. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/multi_model_export_package.md +18 -0
  366. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/prompt_translation_report.md +13 -0
  367. package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/universal_prompt_brief.md +25 -0
  368. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/model_routing_tests.md +8 -0
  369. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/prompt_translation_tests.md +7 -0
  370. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/research_provenance_tests.md +6 -0
  371. package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/unknown_capability_tests.md +7 -0
  372. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/SKILL.md +82 -0
  373. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/diagnostic_checklist.md +8 -0
  374. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/release_quality_checklist.md +9 -0
  375. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/failed_output_to_revised_prompt.md +10 -0
  376. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/final_prompt_package_export.md +10 -0
  377. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/failure_modes.md +13 -0
  378. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/iteration_diagnostics.md +17 -0
  379. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/revision_loops.md +17 -0
  380. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/story_voice_release_qc.md +30 -0
  381. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/failed_output_intake.md +13 -0
  382. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/final_production_package.md +11 -0
  383. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/prompt_revision_report.md +17 -0
  384. package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/tests/prompt_quality_checklist_tests.md +6 -0
  385. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/SKILL.md +81 -0
  386. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/checklists/scene_quality_checklist.md +8 -0
  387. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/examples/scene_to_screenplay_excerpt.md +18 -0
  388. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/dialogue_and_subtext.md +15 -0
  389. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/fountain_workflow.md +29 -0
  390. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/scene_construction.md +20 -0
  391. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/screenplay_format.md +18 -0
  392. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/beat_sheet.md +6 -0
  393. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/dialogue_pass.md +9 -0
  394. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/fountain_scene.md +25 -0
  395. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/screenplay_scene.md +19 -0
  396. package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/tests/screenplay_scene_tests.md +6 -0
  397. package/resources/skills/director-skills/upstream/skills/short-film-development/SKILL.md +83 -0
  398. package/resources/skills/director-skills/upstream/skills/short-film-development/checklists/concept_quality_checklist.md +8 -0
  399. package/resources/skills/director-skills/upstream/skills/short-film-development/examples/idea_to_logline.md +13 -0
  400. package/resources/skills/director-skills/upstream/skills/short-film-development/examples/logline_to_outline.md +10 -0
  401. package/resources/skills/director-skills/upstream/skills/short-film-development/references/ai_storytelling_workflow.md +46 -0
  402. package/resources/skills/director-skills/upstream/skills/short-film-development/references/continuity_handoff.md +8 -0
  403. package/resources/skills/director-skills/upstream/skills/short-film-development/references/screenplay_structure.md +19 -0
  404. package/resources/skills/director-skills/upstream/skills/short-film-development/references/story_development_workflow.md +23 -0
  405. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/beat_sheet.md +7 -0
  406. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/idea_intake.md +16 -0
  407. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/logline.md +10 -0
  408. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_film_treatment.md +23 -0
  409. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_story_plan.md +22 -0
  410. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_premise.md +19 -0
  411. package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_revision_audit.md +22 -0
  412. package/resources/skills/director-skills/upstream/skills/short-film-development/tests/output_format_tests.md +3 -0
  413. package/resources/skills/director-skills/upstream/skills/short-film-development/tests/routing_tests.md +7 -0
  414. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/SKILL.md +77 -0
  415. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/checklists/shotlist_quality_checklist.md +8 -0
  416. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/examples/screenplay_scene_to_shot_list.md +8 -0
  417. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/camera_plan.md +11 -0
  418. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/production_breakdown.md +11 -0
  419. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/shotlist_schema.md +21 -0
  420. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/asset_list.md +7 -0
  421. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/camera_plan.md +10 -0
  422. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/scene_breakdown.md +16 -0
  423. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/shot_list.md +5 -0
  424. package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/tests/screenplay_to_shotlist_tests.md +5 -0
  425. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/SKILL.md +77 -0
  426. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/checklists/style_quality_checklist.md +8 -0
  427. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/examples/genre_to_visual_direction.md +15 -0
  428. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/camera_lens_movement.md +26 -0
  429. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/cinematic_language.md +32 -0
  430. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/genre_style_tone.md +17 -0
  431. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/lighting_and_photorealism.md +19 -0
  432. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/templates/visual_style_bible.md +33 -0
  433. package/resources/skills/director-skills/upstream/skills/style-cinematography-director/tests/style_translation_tests.md +5 -0
  434. package/resources/skills/director-skills/upstream/tests/master_router_tests.md +80 -0
  435. package/resources/skills/drama-production/manifest.json +933 -0
  436. package/resources/skills/drama-production/upstream/LICENSE +21 -0
  437. package/resources/skills/drama-production/upstream/skills/short-drama/SKILL.md +153 -0
  438. package/resources/skills/drama-production/upstream/skills/short-drama/agents/openai.yaml +4 -0
  439. package/resources/skills/drama-production/upstream/skills/short-drama/assets/creator-decision.example.jsonl +4 -0
  440. package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/app.js +1263 -0
  441. package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/index.html +114 -0
  442. package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/styles.css +297 -0
  443. package/resources/skills/drama-production/upstream/skills/short-drama/assets/production-observation.example.jsonl +2 -0
  444. package/resources/skills/drama-production/upstream/skills/short-drama/assets/project-template/short-drama.json +36 -0
  445. package/resources/skills/drama-production/upstream/skills/short-drama/assets/reference-observation.example.jsonl +2 -0
  446. package/resources/skills/drama-production/upstream/skills/short-drama/references/audience-reveal.md +21 -0
  447. package/resources/skills/drama-production/upstream/skills/short-drama/references/contract-and-ownership.md +45 -0
  448. package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-documents.md +230 -0
  449. package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-workflow.md +98 -0
  450. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards/Q/347/211/210/350/241/250/350/276/276.md +73 -0
  451. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//344/272/214/347/273/264/345/212/250/346/200/201/346/274/253.md +74 -0
  452. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/233/275/346/274/253/344/272/214/346/254/241/345/205/203.md +79 -0
  453. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/256/236/346/213/215.md +75 -0
  454. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//346/260/264/345/242/250/347/254/224/350/247/246.md +85 -0
  455. package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//351/243/216/346/240/274/345/214/226/344/270/211/347/273/264.md +72 -0
  456. package/resources/skills/drama-production/upstream/skills/short-drama/references/knowhow-index.md +85 -0
  457. package/resources/skills/drama-production/upstream/skills/short-drama/references/look-development.md +51 -0
  458. package/resources/skills/drama-production/upstream/skills/short-drama/references/pickup-and-alternate.md +24 -0
  459. package/resources/skills/drama-production/upstream/skills/short-drama/references/production-form-profiles.md +126 -0
  460. package/resources/skills/drama-production/upstream/skills/short-drama/references/reference-roles.md +53 -0
  461. package/resources/skills/drama-production/upstream/skills/short-drama/references/runtime-preflight.md +24 -0
  462. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/creator_markdown_check.py +1413 -0
  463. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/dashboard_server.py +1798 -0
  464. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/project_tool.py +2454 -0
  465. package/resources/skills/drama-production/upstream/skills/short-drama/scripts/selftest.py +287 -0
  466. package/resources/skills/drama-production/upstream/skills/short-drama-assets/SKILL.md +76 -0
  467. package/resources/skills/drama-production/upstream/skills/short-drama-assets/agents/openai.yaml +4 -0
  468. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/character-look.example.jsonl +3 -0
  469. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/continuity.example.jsonl +4 -0
  470. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/decisions.example.jsonl +5 -0
  471. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/location-view.example.jsonl +3 -0
  472. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/occurrences.example.jsonl +5 -0
  473. package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/prop-state.example.jsonl +3 -0
  474. package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/characters.jsonl +2 -0
  475. package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/looks.jsonl +2 -0
  476. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/asset-review-checklist.md +82 -0
  477. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/character-and-look.md +190 -0
  478. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-delta.md +120 -0
  479. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-lock.md +122 -0
  480. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/identity-vs-variant.md +140 -0
  481. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/location-and-view.md +84 -0
  482. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/occurrence-extraction.md +108 -0
  483. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/prop-and-state.md +126 -0
  484. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/stage-contract.md +43 -0
  485. package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/voice-direction.md +179 -0
  486. package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/asset_check.py +267 -0
  487. package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/selftest.py +89 -0
  488. package/resources/skills/drama-production/upstream/skills/short-drama-develop/SKILL.md +157 -0
  489. package/resources/skills/drama-production/upstream/skills/short-drama-develop/agents/openai.yaml +4 -0
  490. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -0
  491. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/creative-brief.md +120 -0
  492. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/episode-map.jsonl +1 -0
  493. package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/story-engine.md +181 -0
  494. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/adaptation-craft.md +219 -0
  495. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/creative-reference-intake.md +57 -0
  496. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/director-brief-craft.md +113 -0
  497. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/episode-design.md +509 -0
  498. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-and-hook-playbook.md +240 -0
  499. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/272/262/345/255/220/351/232/220/347/247/230.md +112 -0
  500. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//344/273/231/344/276/240/344/277/256/347/234/237.md +51 -0
  501. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/212/250/344/275/234/344/273/273/345/212/241.md +51 -0
  502. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/217/244/350/243/205/346/235/203/350/260/213.md +51 -0
  503. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/244/215/344/273/207/346/211/223/350/204/270.md +51 -0
  504. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//345/256/266/345/272/255/345/205/263/347/263/273.md +51 -0
  505. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//346/202/254/347/226/221/350/247/204/345/210/231.md +51 -0
  506. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/224/237/346/264/273/346/265/201.md +51 -0
  507. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//347/240/264/351/225/234/351/207/215/345/234/206.md +109 -0
  508. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/201/214/345/234/272/345/226/234/345/211/247.md +51 -0
  509. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/261/252/351/227/250/345/251/232/346/201/213.md +51 -0
  510. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards//350/272/253/344/273/275/351/224/231/344/275/215.md +51 -0
  511. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards.md +92 -0
  512. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/mechanism-loop.md +282 -0
  513. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/multi-episode-intake.md +128 -0
  514. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/premise-devices.md +211 -0
  515. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/reveal-reversal-payoff.md +99 -0
  516. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/serial-character-and-memory.md +293 -0
  517. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/stage-contract.md +88 -0
  518. package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/story-craft.md +359 -0
  519. package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/episode_intake.py +699 -0
  520. package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/selftest.py +47 -0
  521. package/resources/skills/drama-production/upstream/skills/short-drama-edit/SKILL.md +146 -0
  522. package/resources/skills/drama-production/upstream/skills/short-drama-edit/agents/openai.yaml +4 -0
  523. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/README.md +60 -0
  524. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/package.json +15 -0
  525. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
  526. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
  527. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
  528. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
  529. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
  530. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
  531. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
  532. package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
  533. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/cut-craft.md +38 -0
  534. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/delivery-verify.md +29 -0
  535. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/generated-footage.md +54 -0
  536. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/sound-and-subtitles.md +60 -0
  537. package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/stage-contract.md +51 -0
  538. package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/edit_tool.py +1156 -0
  539. package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/selftest.py +520 -0
  540. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/SKILL.md +73 -0
  541. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/agents/openai.yaml +4 -0
  542. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompt-spec.jsonl.md +187 -0
  543. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompts.md +42 -0
  544. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-frame-spec.jsonl.md +128 -0
  545. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-prompts.md +34 -0
  546. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +2 -0
  547. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/character-and-look.md +153 -0
  548. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/common-recipe.md +241 -0
  549. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/edit-and-revision.md +110 -0
  550. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/location-plate.md +151 -0
  551. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/look-and-state-variant.md +97 -0
  552. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/lookdev-frame.md +34 -0
  553. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/production-sheet-recipes.md +177 -0
  554. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/prop-plate.md +137 -0
  555. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/review-and-fixtures.md +92 -0
  556. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/stage-contract.md +32 -0
  557. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/image_prompt_check.py +443 -0
  558. package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/selftest.py +95 -0
  559. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/SKILL.md +245 -0
  560. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/agents/openai.yaml +4 -0
  561. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +3 -0
  562. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-triage.md +116 -0
  563. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-value.md +124 -0
  564. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +155 -0
  565. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/chapter-extraction.md +144 -0
  566. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/stage-contract.md +67 -0
  567. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/novel_index.py +651 -0
  568. package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/selftest.py +51 -0
  569. package/resources/skills/drama-production/upstream/skills/short-drama-produce/SKILL.md +183 -0
  570. package/resources/skills/drama-production/upstream/skills/short-drama-produce/agents/openai.yaml +4 -0
  571. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/adapter-contract.md +219 -0
  572. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/gpt-image-2.md +24 -0
  573. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-h3-video.md +86 -0
  574. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-music.md +25 -0
  575. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
  576. package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/seedance.md +64 -0
  577. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/fixture_adapter.py +77 -0
  578. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/production_tool.py +2205 -0
  579. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/provider_adapters.py +1747 -0
  580. package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/selftest.py +135 -0
  581. package/resources/skills/drama-production/upstream/skills/short-drama-review/SKILL.md +118 -0
  582. package/resources/skills/drama-production/upstream/skills/short-drama-review/agents/openai.yaml +4 -0
  583. package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/finding-template.jsonl +2 -0
  584. package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/supersession-decision.example.json +42 -0
  585. package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/verdict-template.json +32 -0
  586. package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-findings.jsonl +2 -0
  587. package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-verdict.json +34 -0
  588. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/anti-template-repair.md +109 -0
  589. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/production-quality-gates.md +276 -0
  590. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/project-calibration.md +83 -0
  591. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/review-method.md +81 -0
  592. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-assets-prompts.md +111 -0
  593. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-source-analysis.md +108 -0
  594. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-story-script.md +264 -0
  595. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-visual-motion.md +184 -0
  596. package/resources/skills/drama-production/upstream/skills/short-drama-review/references/stage-contract.md +28 -0
  597. package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/review_check.py +325 -0
  598. package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/selftest.py +87 -0
  599. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/SKILL.md +175 -0
  600. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/agents/openai.yaml +4 -0
  601. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +2 -0
  602. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-template.json +49 -0
  603. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-prompts.md +36 -0
  604. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-template.jsonl +2 -0
  605. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +12 -0
  606. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +2 -0
  607. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/shot-template.jsonl +2 -0
  608. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/blocking-playbooks.md +216 -0
  609. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/comic-keyframe-lexicon.md +156 -0
  610. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/coverage-audition.md +34 -0
  611. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/keyframe-craft.md +174 -0
  612. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
  613. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/production-shot-grammar.md +279 -0
  614. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/review-and-fixtures.md +36 -0
  615. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/scene-visual-plan.md +30 -0
  616. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/screenplay-to-keyframe-example.md +155 -0
  617. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-craft.md +266 -0
  618. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-revision-identity.md +25 -0
  619. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/stage-contract.md +57 -0
  620. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/selftest.py +191 -0
  621. package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/storyboard_check.py +824 -0
  622. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/SKILL.md +154 -0
  623. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/agents/openai.yaml +4 -0
  624. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +21 -0
  625. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/delivery-container.jsonl.md +101 -0
  626. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-spec.jsonl.md +217 -0
  627. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +2 -0
  628. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/music-spec.jsonl.md +31 -0
  629. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/performance.fragment.json +38 -0
  630. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/video-prompts.md +67 -0
  631. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +2 -0
  632. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/camera-audio-continuity.md +259 -0
  633. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/delivery-profile.md +273 -0
  634. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/generability.md +93 -0
  635. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/minimax-h3.md +253 -0
  636. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/motion-recipe.md +315 -0
  637. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/performance-action-timing.md +209 -0
  638. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/production-prompt-grammar.md +304 -0
  639. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/review-and-fixtures.md +139 -0
  640. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.0.md +50 -0
  641. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.5.md +122 -0
  642. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/stage-contract.md +60 -0
  643. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/target-model-profile.md +116 -0
  644. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/container_check.py +329 -0
  645. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/motion_timing_check.py +450 -0
  646. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/music_spec_check.py +327 -0
  647. package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/selftest.py +82 -0
  648. package/resources/skills/drama-production/upstream/skills/short-drama-write/SKILL.md +99 -0
  649. package/resources/skills/drama-production/upstream/skills/short-drama-write/agents/openai.yaml +4 -0
  650. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/beats.jsonl +2 -0
  651. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card-standalone.json +120 -0
  652. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card.json +55 -0
  653. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/screenplay.md +36 -0
  654. package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/voice-record-sheet.jsonl.md +89 -0
  655. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/dialogue-craft.md +526 -0
  656. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/production-format-dialect.md +273 -0
  657. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-handoff-capsule.md +25 -0
  658. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-sound-dramaturgy.md +43 -0
  659. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/screenplay-format.md +266 -0
  660. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/script-craft.md +745 -0
  661. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/stage-contract.md +36 -0
  662. package/resources/skills/drama-production/upstream/skills/short-drama-write/references/substitutable-realization.md +107 -0
  663. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/duration_estimate.py +309 -0
  664. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/screenplay_index.py +1091 -0
  665. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/selftest.py +229 -0
  666. package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/voice_sheet_check.py +416 -0
  667. package/resources/skills/viora-canvas-production/manifest.json +23 -0
  668. package/resources/skills/viora-canvas-production/references/canvas-contract.md +50 -0
  669. package/resources/skills/viora-canvas-production/references/creative-production.md +50 -0
  670. package/resources/skills/viora-canvas-production/references/generation-delivery.md +36 -0
  671. package/resources/skills/viora-canvas-production/references/production-ledger.md +25 -0
@@ -0,0 +1,2454 @@
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 math
17
+ import os
18
+ import re
19
+ import shutil
20
+ import stat
21
+ import sys
22
+ import unicodedata
23
+ import uuid
24
+ from collections.abc import Callable, Iterable, Mapping, Sequence
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path, PurePosixPath
27
+ from typing import Any, Iterator
28
+
29
+
30
+ MINIMUM_PYTHON = (3, 9)
31
+ if sys.version_info < MINIMUM_PYTHON:
32
+ raise SystemExit(
33
+ "short-drama needs Python {}.{} or newer; this interpreter is {}.{}".format(
34
+ *MINIMUM_PYTHON, sys.version_info.major, sys.version_info.minor
35
+ )
36
+ )
37
+
38
+ PROJECT_FILE = "short-drama.json"
39
+ STATE_FILE = Path(".short-drama/state.json")
40
+ STATE_SCHEMA = "2.0"
41
+ DEFAULT_PROMPT_LANGUAGE = "en"
42
+ LANGUAGE_TAG_RE = re.compile(r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*")
43
+ DELIVERY_SUFFIXES = {".md", ".json", ".jsonl"}
44
+ CREATOR_DOCUMENTS = (
45
+ "剧本.md",
46
+ "视觉设定.md",
47
+ "分镜.md",
48
+ "图片提示词.md",
49
+ "视频提示词.md",
50
+ )
51
+ # The cut list is written after the media exists, so an episode that never
52
+ # reached editing is complete without one. It travels with a handover when it is
53
+ # there -- without it the next person has to re-cut the film from scratch -- but
54
+ # it is never reported as a missing creator document.
55
+ POST_PRODUCTION_DOCUMENTS = ("剪辑单.md",)
56
+ EXPORT_MEDIA_DIRECTORY = "制作成果"
57
+ EXPORT_SCHEMA = "1.0"
58
+ EPISODE_ID_RE = re.compile(r"EP(?:[0-9]{3}|[1-9][0-9]{3,})")
59
+ WINDOWS_FORBIDDEN_PATH_CHARACTERS = frozenset('<>:"|?*')
60
+ WINDOWS_RESERVED_PATH_STEMS = frozenset(
61
+ {
62
+ "con",
63
+ "prn",
64
+ "aux",
65
+ "nul",
66
+ *(f"com{number}" for number in range(1, 10)),
67
+ *(f"lpt{number}" for number in range(1, 10)),
68
+ "com¹",
69
+ "com²",
70
+ "com³",
71
+ "lpt¹",
72
+ "lpt²",
73
+ "lpt³",
74
+ }
75
+ )
76
+
77
+ CANONICAL_ROOTS = {
78
+ "inputs": "输入",
79
+ "development": "项目开发",
80
+ "bible": "设定集",
81
+ "episodes": "剧集",
82
+ "delivery": "交付",
83
+ "creator-decisions": "创作者决策",
84
+ "reviews": "审查",
85
+ }
86
+ LEGACY_ROOTS = {role: role for role in CANONICAL_ROOTS}
87
+ ROOT_ROLE_ALIASES: dict[str, str] = {
88
+ name.casefold(): role
89
+ for roots in (CANONICAL_ROOTS, LEGACY_ROOTS)
90
+ for role, name in roots.items()
91
+ }
92
+ LAYOUT_PINNING_ROLES = frozenset(
93
+ {"development", "bible", "episodes", "delivery", "creator-decisions", "reviews"}
94
+ )
95
+ PUBLISHABLE_ROOT_ROLES = frozenset(
96
+ {"development", "bible", "episodes", "creator-decisions", "reviews"}
97
+ )
98
+ PUBLISHABLE_ROOTS = tuple(
99
+ roots[role]
100
+ for roots in (CANONICAL_ROOTS, LEGACY_ROOTS)
101
+ for role in CANONICAL_ROOTS
102
+ if role in PUBLISHABLE_ROOT_ROLES
103
+ )
104
+ PROJECT_DIRS = (*CANONICAL_ROOTS.values(), ".short-drama")
105
+
106
+ PROTECTED_PUBLISH_ROLE_REASONS = {
107
+ "inputs": "creator inputs are immutable publication sources",
108
+ "delivery": "the delivery tree is written by the packaging gate, not by publication",
109
+ }
110
+ PROTECTED_PUBLISH_ROOTS = {
111
+ name.casefold(): reason
112
+ for role, reason in PROTECTED_PUBLISH_ROLE_REASONS.items()
113
+ for name in (CANONICAL_ROOTS[role], LEGACY_ROOTS[role])
114
+ } | {".short-drama": "operational state cannot be a publication target"}
115
+
116
+ AUTHORITY_ROOT_TOKEN = "creator_authority"
117
+ EPISODE_LENGTH_POINTER = "/format/target_seconds_per_episode"
118
+ PACING_POINTER = "/format/pacing"
119
+ # Everything outside /creator_authority/* that a decision may still bind. Both
120
+ # are read by the write stage to turn a screenplay into seconds, so leaving them
121
+ # out of set-authority left hand-editing short-drama.json as the only way in.
122
+ FORMAT_POINTERS = (EPISODE_LENGTH_POINTER, PACING_POINTER)
123
+
124
+ class ProjectConflictError(RuntimeError):
125
+ """A file changed while a guarded operation was in progress."""
126
+
127
+
128
+ class PackageBlockedError(RuntimeError):
129
+ """A requested delivery contains an artifact that is not approved."""
130
+
131
+
132
+ class NonPortablePathError(ValueError):
133
+ """A path would alias or fail on a supported filesystem."""
134
+
135
+
136
+ def utc_now() -> str:
137
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
138
+
139
+
140
+ def sha256_bytes(content: bytes) -> str:
141
+ return hashlib.sha256(content).hexdigest()
142
+
143
+
144
+ def sha256_file(path: Path) -> str:
145
+ digest = hashlib.sha256()
146
+ with path.open("rb") as handle:
147
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
148
+ digest.update(chunk)
149
+ return digest.hexdigest()
150
+
151
+
152
+ def _is_link_or_reparse(details: os.stat_result) -> bool:
153
+ attributes = getattr(details, "st_file_attributes", 0)
154
+ reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
155
+ return stat.S_ISLNK(details.st_mode) or bool(attributes & reparse_flag)
156
+
157
+
158
+ def _fsync_directory(path: Path) -> None:
159
+ if os.name == "nt":
160
+ return
161
+ descriptor = os.open(path, os.O_RDONLY)
162
+ try:
163
+ os.fsync(descriptor)
164
+ finally:
165
+ os.close(descriptor)
166
+
167
+
168
+ def _atomic_bytes(path: Path, content: bytes) -> None:
169
+ path.parent.mkdir(parents=True, exist_ok=True)
170
+ temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
171
+ try:
172
+ with temporary.open("xb") as handle:
173
+ handle.write(content)
174
+ handle.flush()
175
+ os.fsync(handle.fileno())
176
+ os.replace(temporary, path)
177
+ _fsync_directory(path.parent)
178
+ finally:
179
+ try:
180
+ temporary.unlink()
181
+ except FileNotFoundError:
182
+ pass
183
+
184
+
185
+ def atomic_json(path: Path, document: Mapping[str, Any]) -> None:
186
+ # allow_nan=False: Python reads and writes bare Infinity/NaN, but they are not
187
+ # JSON. Writing one produces a manifest that every other reader rejects.
188
+ encoded = (
189
+ json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True, allow_nan=False)
190
+ + "\n"
191
+ ).encode("utf-8")
192
+ _atomic_bytes(path, encoded)
193
+
194
+
195
+ def normalize_language_tag(value: str, *, field: str) -> str:
196
+ tag = value.strip()
197
+ if not tag:
198
+ raise ValueError(f"{field} must not be empty")
199
+ if LANGUAGE_TAG_RE.fullmatch(tag) is None:
200
+ raise ValueError(f"{field} is not a well-formed language tag: {value!r}")
201
+ return tag
202
+
203
+
204
+ def project_languages(project: Mapping[str, Any]) -> dict[str, str]:
205
+ format_block = project.get("format")
206
+ prompt_language = (
207
+ format_block.get("prompt_language")
208
+ if isinstance(format_block, Mapping)
209
+ else None
210
+ )
211
+ authority = project.get("creator_authority")
212
+ production_profile = (
213
+ authority.get("production_profile")
214
+ if isinstance(authority, Mapping)
215
+ else None
216
+ )
217
+ choices = (
218
+ production_profile.get("choices")
219
+ if isinstance(production_profile, Mapping)
220
+ else None
221
+ )
222
+ video_prompt_language = (
223
+ choices.get("video_prompt_language") if isinstance(choices, Mapping) else None
224
+ )
225
+ return {
226
+ "language": str(project.get("language") or "zh-CN"),
227
+ "prompt_language": str(prompt_language or DEFAULT_PROMPT_LANGUAGE),
228
+ "video_prompt_language": str(
229
+ video_prompt_language or prompt_language or DEFAULT_PROMPT_LANGUAGE
230
+ ),
231
+ }
232
+
233
+
234
+ def project_video_model_profile(project: Mapping[str, Any]) -> dict[str, Any]:
235
+ """Expose the accepted prompt-facing model choices without guessing defaults."""
236
+ authority = project.get("creator_authority")
237
+ production_profile = (
238
+ authority.get("production_profile")
239
+ if isinstance(authority, Mapping)
240
+ else None
241
+ )
242
+ if (
243
+ not isinstance(production_profile, Mapping)
244
+ or production_profile.get("status") != "accepted"
245
+ ):
246
+ return {}
247
+ choices = production_profile.get("choices")
248
+ if not isinstance(choices, Mapping):
249
+ return {}
250
+ fields = (
251
+ "target_video_model",
252
+ "video_prompt_dialect",
253
+ "video_prompt_language",
254
+ "native_duration_seconds",
255
+ "supported_generation_modes",
256
+ "audio_generation",
257
+ )
258
+ return {field: choices[field] for field in fields if field in choices}
259
+
260
+
261
+ def initialize_project(
262
+ path: Path,
263
+ *,
264
+ title: str,
265
+ language: str,
266
+ aspect_ratio: str,
267
+ prompt_language: str = DEFAULT_PROMPT_LANGUAGE,
268
+ episode_count: int | None = None,
269
+ target_seconds_per_episode: float | None = None,
270
+ suite_root: Path | None = None,
271
+ ) -> dict[str, Any]:
272
+ language = normalize_language_tag(language, field="language")
273
+ prompt_language = normalize_language_tag(prompt_language, field="prompt_language")
274
+ if episode_count is not None and (
275
+ isinstance(episode_count, bool)
276
+ or not isinstance(episode_count, int)
277
+ or episode_count <= 0
278
+ ):
279
+ raise ValueError("episode_count must be a positive integer")
280
+ if target_seconds_per_episode is not None and (
281
+ isinstance(target_seconds_per_episode, bool)
282
+ or not isinstance(target_seconds_per_episode, (int, float))
283
+ or not math.isfinite(target_seconds_per_episode)
284
+ or target_seconds_per_episode <= 0
285
+ ):
286
+ raise ValueError("target_seconds_per_episode must be a positive number of seconds")
287
+ root = path.expanduser().resolve()
288
+ project_path = root / PROJECT_FILE
289
+ if project_path.exists():
290
+ raise FileExistsError(f"project already exists: {project_path}")
291
+
292
+ root.mkdir(parents=True, exist_ok=True)
293
+ for relative in PROJECT_DIRS:
294
+ (root / relative).mkdir(parents=True, exist_ok=True)
295
+
296
+ core = suite_root or Path(__file__).resolve().parents[1]
297
+ project = json.loads(
298
+ (core / "assets/project-template/short-drama.json").read_text(encoding="utf-8")
299
+ )
300
+ project.update(
301
+ {
302
+ "project_id": f"SD-{uuid.uuid4().hex[:12].upper()}",
303
+ "title": title.strip() or "未命名短剧",
304
+ "language": language,
305
+ "created_at": utc_now(),
306
+ }
307
+ )
308
+ project["format"]["aspect_ratio"] = aspect_ratio
309
+ project["format"]["prompt_language"] = prompt_language
310
+ if episode_count is not None:
311
+ project["format"]["episode_count"] = episode_count
312
+ if target_seconds_per_episode is not None:
313
+ project["format"]["target_seconds_per_episode"] = target_seconds_per_episode
314
+ state = {
315
+ "schema_version": STATE_SCHEMA,
316
+ "project_id": project["project_id"],
317
+ "project_layout_mode": "auto",
318
+ "updated_at": utc_now(),
319
+ "last_action": "initialized",
320
+ "artifacts": {},
321
+ }
322
+ atomic_json(root / STATE_FILE, state)
323
+ atomic_json(project_path, project)
324
+ return {"project_root": str(root), "project": project, "state": state}
325
+
326
+
327
+ def find_project(start: Path) -> Path:
328
+ candidate = start.expanduser().resolve()
329
+ if candidate.is_file():
330
+ candidate = candidate.parent
331
+ for directory in (candidate, *candidate.parents):
332
+ if (directory / PROJECT_FILE).is_file():
333
+ return directory
334
+ raise FileNotFoundError(f"no {PROJECT_FILE} found from {start}")
335
+
336
+
337
+ def _has_nonportable_path_component(parts: tuple[str, ...]) -> bool:
338
+ for part in parts:
339
+ stem = part.split(".", 1)[0].casefold()
340
+ if (
341
+ part.endswith((" ", "."))
342
+ or any(ord(character) < 32 or ord(character) == 127 for character in part)
343
+ or any(character in WINDOWS_FORBIDDEN_PATH_CHARACTERS for character in part)
344
+ or stem in WINDOWS_RESERVED_PATH_STEMS
345
+ ):
346
+ return True
347
+ return False
348
+
349
+
350
+ def _relative_path(value: str | Path, *, allow_operations: bool = False) -> str:
351
+ raw = str(value).replace("\\", "/")
352
+ pure = PurePosixPath(raw)
353
+ if not raw or pure.is_absolute() or any(part in ("", ".", "..") for part in pure.parts):
354
+ raise ValueError(f"unsafe project-relative path: {value!s}")
355
+ if _has_nonportable_path_component(pure.parts):
356
+ raise NonPortablePathError(f"unsafe project-relative path: {value!s}")
357
+ if not allow_operations and pure.parts[0].casefold() == ".short-drama":
358
+ raise ValueError("operational state cannot be a publication target")
359
+ return pure.as_posix()
360
+
361
+
362
+ def _portable_path_identity(value: str) -> str:
363
+ return unicodedata.normalize("NFC", value.casefold())
364
+
365
+
366
+ def _validate_existing_path_spelling(root: Path, relative: str, *, label: str) -> None:
367
+ current = root
368
+ prefix: list[str] = []
369
+ parts = PurePosixPath(relative).parts
370
+ for index, part in enumerate(parts):
371
+ try:
372
+ entries = list(os.scandir(current))
373
+ except (FileNotFoundError, NotADirectoryError):
374
+ return
375
+ identity = _portable_path_identity(part)
376
+ matches = [entry for entry in entries if _portable_path_identity(entry.name) == identity]
377
+ alias = next((entry.name for entry in matches if entry.name != part), None)
378
+ if alias is not None:
379
+ existing = PurePosixPath(*prefix, alias).as_posix()
380
+ raise NonPortablePathError(
381
+ f"{label} path spelling aliases an existing path: {relative} conflicts with {existing}"
382
+ )
383
+ exact = next((entry for entry in matches if entry.name == part), None)
384
+ if exact is None or index == len(parts) - 1:
385
+ return
386
+ if not exact.is_dir(follow_symlinks=False):
387
+ return
388
+ current /= part
389
+ prefix.append(part)
390
+
391
+
392
+ def _validate_path_set(root: Path, relatives: Iterable[str], *, label: str) -> list[str]:
393
+ normalized: list[str] = []
394
+ seen: dict[str, str] = {}
395
+ for value in relatives:
396
+ relative = _relative_path(value)
397
+ identity = _portable_path_identity(relative)
398
+ previous = seen.get(identity)
399
+ if previous is not None and previous != relative:
400
+ raise NonPortablePathError(
401
+ f"{label} paths are not portable aliases: {previous} and {relative}"
402
+ )
403
+ if previous is None:
404
+ seen[identity] = relative
405
+ normalized.append(relative)
406
+ _validate_existing_path_spelling(root, relative, label=label)
407
+ return sorted(normalized)
408
+
409
+
410
+ def _root_role(name: str) -> str | None:
411
+ return ROOT_ROLE_ALIASES.get(name.casefold())
412
+
413
+
414
+ def _root_layout_mode(name: str) -> str | None:
415
+ role = _root_role(name)
416
+ if role is None:
417
+ return None
418
+ if name == CANONICAL_ROOTS[role]:
419
+ return "canonical"
420
+ if name == LEGACY_ROOTS[role]:
421
+ return "legacy"
422
+ return None
423
+
424
+
425
+ def is_protected_project_text(value: str | Path) -> bool:
426
+ raw = str(value).replace("\\", "/")
427
+ pure = PurePosixPath(raw)
428
+ if (
429
+ not raw
430
+ or pure.is_absolute()
431
+ or any(part in ("", ".", "..") for part in pure.parts)
432
+ or _has_nonportable_path_component(pure.parts)
433
+ ):
434
+ return True
435
+ return (
436
+ pure.name.casefold() == PROJECT_FILE
437
+ or pure.parts[0].casefold() == ".short-drama"
438
+ or _root_role(pure.parts[0]) == "delivery"
439
+ )
440
+
441
+
442
+ def _directory_has_content(path: Path) -> bool:
443
+ try:
444
+ details = os.lstat(path)
445
+ return stat.S_ISLNK(details.st_mode) or (
446
+ stat.S_ISDIR(details.st_mode) and any(path.iterdir())
447
+ )
448
+ except FileNotFoundError:
449
+ return False
450
+
451
+
452
+ def _read_state(root: Path) -> dict[str, Any]:
453
+ state_path = root / STATE_FILE
454
+ if not state_path.is_file() or state_path.is_symlink():
455
+ raise ValueError("project state is missing or unsafe")
456
+ document = json.loads(state_path.read_text(encoding="utf-8"))
457
+ if not isinstance(document, dict):
458
+ raise ValueError("project state must be an object")
459
+ return _normalize_state(document)
460
+
461
+
462
+ def _legacy_artifact(record: Mapping[str, Any]) -> dict[str, Any]:
463
+ candidate = record.get("candidate_targets")
464
+ accepted_targets = record.get("accepted_targets")
465
+ reviewed_targets = record.get("reviewed_targets")
466
+ output_map = (
467
+ candidate
468
+ if isinstance(candidate, Mapping)
469
+ else accepted_targets
470
+ if isinstance(accepted_targets, Mapping)
471
+ else {}
472
+ )
473
+ outputs = sorted(str(path) for path in output_map if isinstance(path, str))
474
+ input_map = (
475
+ record.get("candidate_inputs")
476
+ if isinstance(candidate, Mapping)
477
+ else record.get("accepted_inputs")
478
+ )
479
+ if not isinstance(input_map, Mapping):
480
+ input_map = {}
481
+ inputs = {
482
+ str(path): str(value)
483
+ for path, value in (input_map.items() if isinstance(input_map, Mapping) else [])
484
+ if isinstance(path, str) and isinstance(value, str)
485
+ }
486
+ acceptance: dict[str, Any] | None = None
487
+ if record.get("creator_acceptance") in {"accepted", "rejected"}:
488
+ decision = str(record["creator_acceptance"])
489
+ acceptance = {
490
+ "decision": decision,
491
+ "at": None,
492
+ "outputs": {
493
+ str(path): str(value)
494
+ for path, value in (
495
+ accepted_targets.items()
496
+ if isinstance(accepted_targets, Mapping)
497
+ else []
498
+ )
499
+ if isinstance(path, str) and isinstance(value, str)
500
+ },
501
+ "note": "migrated from v0.3 state",
502
+ }
503
+ review: dict[str, Any] | None = None
504
+ legacy_verdict = record.get("independent_review")
505
+ if legacy_verdict in {"approve", "approve_with_notes", "revise", "provisional"}:
506
+ review_outputs = (
507
+ reviewed_targets
508
+ if isinstance(reviewed_targets, Mapping)
509
+ else accepted_targets
510
+ if isinstance(accepted_targets, Mapping)
511
+ else {}
512
+ )
513
+ review = {
514
+ "verdict": legacy_verdict,
515
+ "at": None,
516
+ "outputs": {
517
+ str(path): str(value)
518
+ for path, value in review_outputs.items()
519
+ if isinstance(path, str) and isinstance(value, str)
520
+ },
521
+ "reviewer": "migrated",
522
+ "note": "migrated from v0.3 state",
523
+ }
524
+ return {
525
+ "owner": str(record.get("owner") or "unknown"),
526
+ "outputs": outputs,
527
+ "inputs": inputs,
528
+ "acceptance": acceptance,
529
+ "review": review,
530
+ "updated_at": None,
531
+ }
532
+
533
+
534
+ def _normalize_state(document: Mapping[str, Any]) -> dict[str, Any]:
535
+ artifacts_raw = document.get("artifacts", {})
536
+ if not isinstance(artifacts_raw, Mapping):
537
+ raise ValueError("state.artifacts must be an object")
538
+ artifacts: dict[str, Any] = {}
539
+ modern = document.get("schema_version") == STATE_SCHEMA
540
+ for artifact_id, value in artifacts_raw.items():
541
+ if not isinstance(artifact_id, str) or not isinstance(value, Mapping):
542
+ raise ValueError("artifact state entries must be objects")
543
+ if modern and isinstance(value.get("outputs"), list):
544
+ outputs = [
545
+ _relative_path(item)
546
+ for item in value["outputs"]
547
+ if isinstance(item, str)
548
+ ]
549
+ inputs_raw = value.get("inputs", {})
550
+ if not isinstance(inputs_raw, Mapping):
551
+ raise ValueError(f"{artifact_id}.inputs must be an object")
552
+ inputs = {
553
+ _relative_path(path, allow_operations=True): str(digest)
554
+ for path, digest in inputs_raw.items()
555
+ if isinstance(path, str) and isinstance(digest, str)
556
+ }
557
+ artifacts[artifact_id] = {
558
+ "owner": str(value.get("owner") or "unknown"),
559
+ "outputs": sorted(set(outputs)),
560
+ "inputs": inputs,
561
+ "acceptance": value.get("acceptance") if isinstance(value.get("acceptance"), Mapping) else None,
562
+ "review": value.get("review") if isinstance(value.get("review"), Mapping) else None,
563
+ "updated_at": value.get("updated_at"),
564
+ }
565
+ else:
566
+ artifacts[artifact_id] = _legacy_artifact(value)
567
+ mode = document.get("project_layout_mode", "auto")
568
+ if mode not in {"auto", "canonical", "legacy"}:
569
+ mode = "auto"
570
+ bindings_raw = document.get("authority")
571
+ bindings = {
572
+ str(field): dict(binding)
573
+ for field, binding in (bindings_raw.items() if isinstance(bindings_raw, Mapping) else [])
574
+ if isinstance(field, str) and isinstance(binding, Mapping)
575
+ }
576
+ return {
577
+ "schema_version": STATE_SCHEMA,
578
+ "project_id": document.get("project_id"),
579
+ "project_layout_mode": mode,
580
+ "updated_at": document.get("updated_at"),
581
+ "last_action": document.get("last_action") or "loaded",
582
+ "artifacts": artifacts,
583
+ "authority": bindings,
584
+ }
585
+
586
+
587
+ def _save_state(root: Path, state: dict[str, Any], *, action: str) -> None:
588
+ state["schema_version"] = STATE_SCHEMA
589
+ state["updated_at"] = utc_now()
590
+ state["last_action"] = action
591
+ atomic_json(root / STATE_FILE, state)
592
+
593
+
594
+ def _project_layout_from_root(root: Path, state: Mapping[str, Any] | None = None) -> dict[str, Any]:
595
+ current = state or _read_state(root)
596
+ recorded = current.get("project_layout_mode", "auto")
597
+ canonical_roles = sorted(
598
+ role
599
+ for role, name in CANONICAL_ROOTS.items()
600
+ if role in LAYOUT_PINNING_ROLES and _directory_has_content(root / name)
601
+ )
602
+ legacy_roles = sorted(
603
+ role
604
+ for role, name in LEGACY_ROOTS.items()
605
+ if role in LAYOUT_PINNING_ROLES and _directory_has_content(root / name)
606
+ )
607
+ nonstandard_roots = sorted(
608
+ entry.name
609
+ for entry in root.iterdir()
610
+ if (entry.is_symlink() or entry.is_dir())
611
+ if (role := _root_role(entry.name)) in LAYOUT_PINNING_ROLES
612
+ if entry.name not in {CANONICAL_ROOTS[role], LEGACY_ROOTS[role]}
613
+ if _directory_has_content(entry)
614
+ )
615
+ unsafe_roots = sorted(
616
+ entry.name
617
+ for entry in root.iterdir()
618
+ if _root_role(entry.name) in LAYOUT_PINNING_ROLES and entry.is_symlink()
619
+ )
620
+ detected = {
621
+ mode
622
+ for mode, roles in (("canonical", canonical_roles), ("legacy", legacy_roles))
623
+ if roles
624
+ }
625
+ conflict = bool(nonstandard_roots or unsafe_roots) or len(detected) > 1 or (
626
+ recorded in {"canonical", "legacy"} and detected and detected != {recorded}
627
+ )
628
+ if conflict:
629
+ mode = "mixed"
630
+ elif recorded in {"canonical", "legacy"}:
631
+ mode = str(recorded)
632
+ elif detected:
633
+ mode = next(iter(detected))
634
+ else:
635
+ mode = "canonical"
636
+ roots = LEGACY_ROOTS if mode == "legacy" else CANONICAL_ROOTS
637
+ return {
638
+ "mode": mode,
639
+ "pinned": recorded != "auto" or bool(detected),
640
+ "roots": dict(roots),
641
+ "nonstandardRoots": nonstandard_roots,
642
+ "unsafeRoots": unsafe_roots,
643
+ }
644
+
645
+
646
+ def project_layout(path: Path) -> dict[str, Any]:
647
+ root = find_project(path)
648
+ return _project_layout_from_root(root)
649
+
650
+
651
+ def _validate_project_output_layout(root: Path, relatives: Iterable[str]) -> str | None:
652
+ families = {
653
+ family
654
+ for relative in relatives
655
+ if (part := PurePosixPath(relative).parts[0])
656
+ if _root_role(part) in LAYOUT_PINNING_ROLES
657
+ if (family := _root_layout_mode(part)) is not None
658
+ }
659
+ if len(families) > 1:
660
+ raise ValueError("不能在同一次发布中混用中文与旧版英文目录")
661
+ family = next(iter(families), None)
662
+ layout = _project_layout_from_root(root)
663
+ if layout["mode"] == "mixed":
664
+ raise ValueError("项目同时包含中文与旧版英文阶段目录,请先迁移并合并")
665
+ if family is not None and layout["pinned"] and family != layout["mode"]:
666
+ expected = "中文" if layout["mode"] == "canonical" else "旧版英文"
667
+ raise ValueError(f"项目已使用{expected}目录布局,不能创建另一套平行目录")
668
+ return family
669
+
670
+
671
+ def _validate_publication_layout(
672
+ relative: str, *, allow_unregistered: bool
673
+ ) -> None:
674
+ pure = PurePosixPath(relative)
675
+ first = pure.parts[0].casefold()
676
+ role = _root_role(pure.parts[0])
677
+ reason = PROTECTED_PUBLISH_ROOTS.get(first)
678
+ if reason is not None:
679
+ raise ValueError(reason)
680
+ if pure.name.casefold() == PROJECT_FILE:
681
+ raise ValueError("creator authority file cannot be a publication target")
682
+ if role == "episodes":
683
+ if len(pure.parts) < 3:
684
+ raise ValueError("episode artifacts live in 剧集/<EP>/(兼容 episodes/<EP>/)")
685
+ if EPISODE_ID_RE.fullmatch(pure.parts[1]) is None:
686
+ raise ValueError(f"episode directory must use an EP001-style identifier: {pure.parts[1]}")
687
+ if not allow_unregistered and role not in PUBLISHABLE_ROOT_ROLES:
688
+ raise ValueError(
689
+ f"{pure.parts[0]} is not a project stage directory; expected one of {', '.join(PUBLISHABLE_ROOTS)}"
690
+ )
691
+ if role is not None and pure.parts[0] not in {CANONICAL_ROOTS[role], LEGACY_ROOTS[role]}:
692
+ raise ValueError(f"阶段目录大小写或拼写不规范:{pure.parts[0]}")
693
+
694
+
695
+ def _project_path(root: Path, relative: str, *, create_parent: bool = False) -> Path:
696
+ root = root.resolve()
697
+ target = root / relative
698
+ current = root
699
+ for part in PurePosixPath(relative).parts[:-1]:
700
+ current /= part
701
+ # ``is_symlink`` misses the reparse points that are not name
702
+ # surrogates, and a Windows creator can make those without any extra
703
+ # privilege. Ask about the attribute directly instead.
704
+ try:
705
+ details = os.lstat(current)
706
+ except FileNotFoundError:
707
+ details = None
708
+ if details is not None and _is_link_or_reparse(details):
709
+ raise ProjectConflictError(f"project parent cannot be a symlink: {part}")
710
+ if details is not None and not stat.S_ISDIR(details.st_mode):
711
+ raise ProjectConflictError(f"project parent is not a directory: {part}")
712
+ if create_parent and details is None:
713
+ current.mkdir()
714
+ try:
715
+ target_details = os.lstat(target)
716
+ except (FileNotFoundError, NotADirectoryError):
717
+ target_details = None
718
+ if target_details is not None and (
719
+ _is_link_or_reparse(target_details) or not stat.S_ISREG(target_details.st_mode)
720
+ ):
721
+ raise ProjectConflictError(f"project target is not a regular file: {relative}")
722
+ if not target.parent.resolve().is_relative_to(root):
723
+ raise ValueError(f"path escapes project root: {relative}")
724
+ return target
725
+
726
+
727
+ def _live_hash(root: Path, relative: str) -> str | None:
728
+ try:
729
+ target = _project_path(root, relative)
730
+ except (OSError, ProjectConflictError, ValueError):
731
+ return None
732
+ if not target.is_file():
733
+ return None
734
+ return sha256_file(target)
735
+
736
+
737
+ @contextlib.contextmanager
738
+ def _lock_handle(handle: Any) -> Iterator[None]:
739
+ if os.name == "nt":
740
+ import msvcrt
741
+
742
+ handle.seek(0, os.SEEK_END)
743
+ if handle.tell() == 0:
744
+ handle.write(b"0")
745
+ handle.flush()
746
+ handle.seek(0)
747
+ locking = getattr(msvcrt, "locking")
748
+ lock = getattr(msvcrt, "LK_LOCK")
749
+ unlock = getattr(msvcrt, "LK_UNLCK")
750
+ locking(handle.fileno(), lock, 1)
751
+ try:
752
+ yield
753
+ finally:
754
+ handle.seek(0)
755
+ locking(handle.fileno(), unlock, 1)
756
+ else:
757
+ import fcntl
758
+
759
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
760
+ try:
761
+ yield
762
+ finally:
763
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
764
+
765
+
766
+ @contextlib.contextmanager
767
+ def _project_lock(root: Path) -> Iterator[None]:
768
+ lock_path = root / ".short-drama/project.lock"
769
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
770
+ with lock_path.open("a+b") as handle, _lock_handle(handle):
771
+ yield
772
+
773
+
774
+ def _hash_mapping(root: Path, relatives: Iterable[str]) -> dict[str, str]:
775
+ result: dict[str, str] = {}
776
+ for relative in relatives:
777
+ digest = _live_hash(root, relative)
778
+ if digest is None:
779
+ raise ProjectConflictError(f"project file is missing or unsafe: {relative}")
780
+ result[relative] = digest
781
+ return result
782
+
783
+
784
+ def _inputs_current(root: Path, record: Mapping[str, Any]) -> bool:
785
+ inputs = record.get("inputs", {})
786
+ return isinstance(inputs, Mapping) and all(
787
+ isinstance(path, str)
788
+ and isinstance(expected, str)
789
+ and _live_hash(root, path) == expected
790
+ for path, expected in inputs.items()
791
+ )
792
+
793
+
794
+ def _artifact_state_from(
795
+ record: Mapping[str, Any], live_hash: Callable[[str], str | None]
796
+ ) -> str:
797
+ """Decide one artifact's lifecycle state from the bytes on disk right now.
798
+
799
+ The whole lifecycle rests on the two `!= live` comparisons below: an
800
+ accepted artifact whose bytes were edited behind the tool's back has not
801
+ been accepted in its current form, and must fall back to `update_needed`.
802
+
803
+ This is written once and reached by two callers -- path-based and
804
+ directory-fd-based -- which differ only in how they hash a file. It used to
805
+ be two transcriptions of the same rules, with nothing asserting they agreed,
806
+ and the dashboard renders the fd one.
807
+ """
808
+
809
+ outputs = record.get("outputs", [])
810
+ if not isinstance(outputs, list) or not outputs:
811
+ return "draft"
812
+ live = {path: live_hash(path) for path in outputs if isinstance(path, str)}
813
+ if len(live) != len(outputs) or any(value is None for value in live.values()):
814
+ return "update_needed"
815
+ inputs = record.get("inputs", {})
816
+ if not isinstance(inputs, Mapping) or any(
817
+ not isinstance(path, str)
818
+ or not isinstance(expected, str)
819
+ or live_hash(path) != expected
820
+ for path, expected in inputs.items()
821
+ ):
822
+ return "update_needed"
823
+ acceptance = record.get("acceptance")
824
+ if not isinstance(acceptance, Mapping):
825
+ return "needs_confirmation"
826
+ if acceptance.get("decision") == "rejected":
827
+ return "revise"
828
+ if acceptance.get("decision") != "accepted" or acceptance.get("outputs") != live:
829
+ return "update_needed"
830
+ review = record.get("review")
831
+ if not isinstance(review, Mapping):
832
+ return "accepted"
833
+ if review.get("outputs") != live:
834
+ return "update_needed"
835
+ verdict = review.get("verdict")
836
+ if verdict in {"approve", "approve_with_notes"}:
837
+ return "approved"
838
+ if verdict == "revise":
839
+ return "revise"
840
+ return "accepted"
841
+
842
+
843
+ def _artifact_state(root: Path, record: Mapping[str, Any]) -> str:
844
+ return _artifact_state_from(record, lambda path: _live_hash(root, path))
845
+
846
+
847
+ def _authority_report(
848
+ project: Mapping[str, Any], state: Mapping[str, Any]
849
+ ) -> dict[str, str]:
850
+ """Say whether the manifest still holds what each bound decision wrote.
851
+
852
+ `set-authority` is the only sanctioned way into `short-drama.json`, and it
853
+ already records the digest of what it wrote. Comparing that against the file
854
+ is what turns a hand edit from invisible into reported.
855
+ """
856
+ bindings = state.get("authority")
857
+ if not isinstance(bindings, Mapping):
858
+ return {}
859
+ report: dict[str, str] = {}
860
+ for field, binding in bindings.items():
861
+ if not isinstance(field, str) or not isinstance(binding, Mapping):
862
+ continue
863
+ try:
864
+ tokens = _authority_tokens(field)
865
+ except ValueError:
866
+ report[field] = "not_authority_field"
867
+ continue
868
+ missing = object()
869
+ cursor: Any = project
870
+ for token in tokens:
871
+ cursor = cursor.get(token, missing) if isinstance(cursor, Mapping) else missing
872
+ if cursor is missing:
873
+ break
874
+ if cursor is missing:
875
+ report[field] = "missing"
876
+ continue
877
+ digest = hashlib.sha256(
878
+ json.dumps(cursor, ensure_ascii=False, sort_keys=True).encode("utf-8")
879
+ ).hexdigest()
880
+ report[field] = "bound" if digest == binding.get("value_sha256") else "hand_edited"
881
+ return report
882
+
883
+
884
+ def _build_status(
885
+ *,
886
+ project: Mapping[str, Any],
887
+ state: Mapping[str, Any],
888
+ layout: Mapping[str, Any],
889
+ project_root: str,
890
+ artifact_state: Callable[[Mapping[str, Any]], str],
891
+ ) -> dict[str, Any]:
892
+ counts: dict[str, int] = {}
893
+ artifacts: dict[str, str] = {}
894
+ # Which stage produced each file. The state already knows; without it every
895
+ # reader has to guess from the path, and a stage added later reads as
896
+ # "unrecognised" until someone updates that reader's own list of names.
897
+ ownership: dict[str, str] = {}
898
+ records = state.get("artifacts", {})
899
+ if isinstance(records, Mapping):
900
+ for artifact_id, record in records.items():
901
+ if not isinstance(artifact_id, str) or not isinstance(record, Mapping):
902
+ continue
903
+ value = artifact_state(record)
904
+ artifacts[artifact_id] = value
905
+ counts[value] = counts.get(value, 0) + 1
906
+ owner = record.get("owner")
907
+ if not isinstance(owner, str) or not owner:
908
+ continue
909
+ for output in record.get("outputs", []) or []:
910
+ if isinstance(output, str) and output:
911
+ ownership[output] = owner
912
+ languages = project_languages(project)
913
+ video_model_profile = project_video_model_profile(project)
914
+ return {
915
+ "project_id": project.get("project_id"),
916
+ "title": project.get("title"),
917
+ "language": languages["language"],
918
+ "prompt_language": languages["prompt_language"],
919
+ "video_prompt_language": languages["video_prompt_language"],
920
+ "video_model_profile": video_model_profile,
921
+ "project_root": project_root,
922
+ "last_action": state.get("last_action"),
923
+ "layout": dict(layout),
924
+ "artifact_states": counts,
925
+ "artifacts": artifacts,
926
+ "ownership": ownership,
927
+ "authority": _authority_report(project, state),
928
+ "lifecycle": {"artifact_state": counts},
929
+ }
930
+
931
+
932
+ def project_status(path: Path) -> dict[str, Any]:
933
+ root = find_project(path)
934
+ project = json.loads((root / PROJECT_FILE).read_text(encoding="utf-8"))
935
+ if not isinstance(project, dict):
936
+ raise ValueError("project manifest must be an object")
937
+ state = _read_state(root)
938
+ return _build_status(
939
+ project=project,
940
+ state=state,
941
+ layout=_project_layout_from_root(root, state),
942
+ project_root=str(root),
943
+ artifact_state=lambda record: _artifact_state(root, record),
944
+ )
945
+
946
+
947
+ def _open_directory_at(directory_fd: int, parts: Iterable[str]) -> int:
948
+ descriptor = os.dup(directory_fd)
949
+ try:
950
+ for part in parts:
951
+ child = os.open(
952
+ part,
953
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
954
+ dir_fd=descriptor,
955
+ )
956
+ os.close(descriptor)
957
+ descriptor = child
958
+ return descriptor
959
+ except Exception:
960
+ os.close(descriptor)
961
+ raise
962
+
963
+
964
+ def _read_regular_at(directory_fd: int, relative: str) -> bytes:
965
+ pure = PurePosixPath(relative)
966
+ parent = _open_directory_at(directory_fd, pure.parts[:-1])
967
+ descriptor = -1
968
+ try:
969
+ descriptor = os.open(
970
+ pure.name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent
971
+ )
972
+ details = os.fstat(descriptor)
973
+ if not stat.S_ISREG(details.st_mode):
974
+ raise OSError("not a regular file")
975
+ with os.fdopen(descriptor, "rb") as handle:
976
+ descriptor = -1
977
+ return handle.read()
978
+ finally:
979
+ if descriptor >= 0:
980
+ os.close(descriptor)
981
+ os.close(parent)
982
+
983
+
984
+ def _directory_has_content_at(directory_fd: int, name: str) -> tuple[bool, bool]:
985
+ try:
986
+ descriptor = os.open(
987
+ name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory_fd
988
+ )
989
+ except FileNotFoundError:
990
+ return False, False
991
+ except OSError:
992
+ return True, True
993
+ try:
994
+ with os.scandir(descriptor) as entries:
995
+ return any(True for _ in entries), False
996
+ finally:
997
+ os.close(descriptor)
998
+
999
+
1000
+ def _project_layout_at(directory_fd: int, state: Mapping[str, Any]) -> dict[str, Any]:
1001
+ recorded = state.get("project_layout_mode", "auto")
1002
+ canonical: list[str] = []
1003
+ legacy: list[str] = []
1004
+ unsafe: list[str] = []
1005
+ nonstandard: list[str] = []
1006
+ with os.scandir(directory_fd) as entries:
1007
+ root_entries = list(entries)
1008
+ for entry in root_entries:
1009
+ role = _root_role(entry.name)
1010
+ if role not in LAYOUT_PINNING_ROLES:
1011
+ continue
1012
+ has_content, unsafe_entry = _directory_has_content_at(directory_fd, entry.name)
1013
+ if not has_content:
1014
+ continue
1015
+ if unsafe_entry or entry.is_symlink():
1016
+ unsafe.append(entry.name)
1017
+ continue
1018
+ if entry.name == CANONICAL_ROOTS[role]:
1019
+ canonical.append(role)
1020
+ elif entry.name == LEGACY_ROOTS[role]:
1021
+ legacy.append(role)
1022
+ else:
1023
+ nonstandard.append(entry.name)
1024
+ detected = {mode for mode, values in (("canonical", canonical), ("legacy", legacy)) if values}
1025
+ conflict = bool(unsafe or nonstandard) or len(detected) > 1 or (
1026
+ recorded in {"canonical", "legacy"} and detected and detected != {recorded}
1027
+ )
1028
+ if conflict:
1029
+ mode = "mixed"
1030
+ elif recorded in {"canonical", "legacy"}:
1031
+ mode = str(recorded)
1032
+ elif detected:
1033
+ mode = next(iter(detected))
1034
+ else:
1035
+ mode = "canonical"
1036
+ roots = LEGACY_ROOTS if mode == "legacy" else CANONICAL_ROOTS
1037
+ return {
1038
+ "mode": mode,
1039
+ "pinned": recorded != "auto" or bool(detected),
1040
+ "roots": dict(roots),
1041
+ "nonstandardRoots": sorted(nonstandard),
1042
+ "unsafeRoots": sorted(unsafe),
1043
+ }
1044
+
1045
+
1046
+ def project_status_at(directory_fd: int, *, project_root: str) -> dict[str, Any]:
1047
+ project = json.loads(_read_regular_at(directory_fd, PROJECT_FILE).decode("utf-8"))
1048
+ try:
1049
+ raw_state = json.loads(
1050
+ _read_regular_at(directory_fd, STATE_FILE.as_posix()).decode("utf-8")
1051
+ )
1052
+ except FileNotFoundError:
1053
+ raw_state = {
1054
+ "schema_version": STATE_SCHEMA,
1055
+ "project_id": project.get("project_id"),
1056
+ "project_layout_mode": "auto",
1057
+ "last_action": "untracked",
1058
+ "artifacts": {},
1059
+ }
1060
+ if not isinstance(project, dict) or not isinstance(raw_state, dict):
1061
+ raise ValueError("project files must contain objects")
1062
+ state = _normalize_state(raw_state)
1063
+ return _build_status(
1064
+ project=project,
1065
+ state=state,
1066
+ layout=_project_layout_at(directory_fd, state),
1067
+ project_root=project_root,
1068
+ artifact_state=lambda record: _artifact_state_at(directory_fd, record),
1069
+ )
1070
+
1071
+
1072
+ def _live_hash_at(directory_fd: int, relative: str) -> str | None:
1073
+ try:
1074
+ return sha256_bytes(_read_regular_at(directory_fd, relative))
1075
+ except (OSError, ValueError):
1076
+ return None
1077
+
1078
+
1079
+ def _artifact_state_at(directory_fd: int, record: Mapping[str, Any]) -> str:
1080
+ return _artifact_state_from(record, lambda path: _live_hash_at(directory_fd, path))
1081
+
1082
+
1083
+ def project_path_lifecycle_at(
1084
+ directory_fd: int, relative: str
1085
+ ) -> dict[str, str] | None:
1086
+ """Return the one creator-facing state for a tracked path."""
1087
+
1088
+ normalized = _relative_path(relative, allow_operations=True)
1089
+ try:
1090
+ raw_state = json.loads(
1091
+ _read_regular_at(directory_fd, STATE_FILE.as_posix()).decode("utf-8")
1092
+ )
1093
+ except (FileNotFoundError, UnicodeError, json.JSONDecodeError):
1094
+ return None
1095
+ if not isinstance(raw_state, Mapping):
1096
+ return None
1097
+ state = _normalize_state(raw_state)
1098
+ for record in state["artifacts"].values():
1099
+ if isinstance(record, Mapping) and normalized in record.get("outputs", []):
1100
+ return {"artifact_state": _artifact_state_at(directory_fd, record)}
1101
+ return None
1102
+
1103
+
1104
+ # The three functions below are the path-based half of the dashboard contract.
1105
+ # Windows has no ``openat``, so the dashboard pins a project root by verified
1106
+ # path there instead of by directory descriptor and calls these. They compose
1107
+ # the same rules the descriptor twins do -- ``_build_status``,
1108
+ # ``_artifact_state_from``, ``_normalize_state`` -- so the two halves cannot
1109
+ # drift on what a status or a lifecycle state means; they differ only in how a
1110
+ # file is reached.
1111
+
1112
+
1113
+ def _read_regular(root: Path, relative: str) -> bytes:
1114
+ """Read one project file, refusing every link and reparse point on the way.
1115
+
1116
+ ``os.lstat`` never follows, so a component swapped for a symlink or a
1117
+ junction is rejected rather than traversed. This is the Windows stand-in
1118
+ for opening each component with ``O_NOFOLLOW``.
1119
+ """
1120
+
1121
+ pure = PurePosixPath(relative)
1122
+ current = root
1123
+ for part in pure.parts[:-1]:
1124
+ current = current / part
1125
+ details = os.lstat(current)
1126
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
1127
+ raise ProjectConflictError(f"project parent is unsafe: {part}")
1128
+ target = current / pure.name
1129
+ details = os.lstat(target)
1130
+ if _is_link_or_reparse(details) or not stat.S_ISREG(details.st_mode):
1131
+ raise ProjectConflictError(f"project file is unsafe: {relative}")
1132
+ with open(target, "rb") as handle:
1133
+ return handle.read()
1134
+
1135
+
1136
+ def project_status_from_root(root: Path, *, project_root: str) -> dict[str, Any]:
1137
+ """Report status for an already-pinned project root.
1138
+
1139
+ ``project_status`` locates the project first and requires recorded state;
1140
+ this twin takes the root the caller pinned and tolerates a project that has
1141
+ never been tracked, matching ``project_status_at``.
1142
+ """
1143
+
1144
+ project = json.loads(_read_regular(root, PROJECT_FILE).decode("utf-8"))
1145
+ try:
1146
+ raw_state = json.loads(
1147
+ _read_regular(root, STATE_FILE.as_posix()).decode("utf-8")
1148
+ )
1149
+ except FileNotFoundError:
1150
+ raw_state = {
1151
+ "schema_version": STATE_SCHEMA,
1152
+ "project_id": project.get("project_id"),
1153
+ "project_layout_mode": "auto",
1154
+ "last_action": "untracked",
1155
+ "artifacts": {},
1156
+ }
1157
+ if not isinstance(project, dict) or not isinstance(raw_state, dict):
1158
+ raise ValueError("project files must contain objects")
1159
+ state = _normalize_state(raw_state)
1160
+ return _build_status(
1161
+ project=project,
1162
+ state=state,
1163
+ layout=_project_layout_from_root(root, state),
1164
+ project_root=project_root,
1165
+ artifact_state=lambda record: _artifact_state(root, record),
1166
+ )
1167
+
1168
+
1169
+ def project_path_lifecycle(root: Path, relative: str) -> dict[str, str] | None:
1170
+ """Return the one creator-facing state for a tracked path."""
1171
+
1172
+ normalized = _relative_path(relative, allow_operations=True)
1173
+ try:
1174
+ raw_state = json.loads(
1175
+ _read_regular(root, STATE_FILE.as_posix()).decode("utf-8")
1176
+ )
1177
+ except (OSError, ProjectConflictError, UnicodeError, json.JSONDecodeError):
1178
+ return None
1179
+ if not isinstance(raw_state, Mapping):
1180
+ return None
1181
+ state = _normalize_state(raw_state)
1182
+ for record in state["artifacts"].values():
1183
+ if isinstance(record, Mapping) and normalized in record.get("outputs", []):
1184
+ return {"artifact_state": _artifact_state(root, record)}
1185
+ return None
1186
+
1187
+
1188
+ @contextlib.contextmanager
1189
+ def coordinated_project_text_edit(
1190
+ root: Path, relative: str, expected_version: str
1191
+ ) -> Iterator[None]:
1192
+ normalized = _relative_path(relative)
1193
+ if not re.fullmatch(r"[0-9a-f]{64}", expected_version):
1194
+ raise ValueError("expected version must be a SHA-256 digest")
1195
+ # The descriptor twin opens the operations directory with O_NOFOLLOW. Match
1196
+ # it: a lock taken through a redirected `.short-drama` would leave two
1197
+ # dashboards each believing they hold the project.
1198
+ try:
1199
+ operations = os.lstat(root / ".short-drama")
1200
+ except FileNotFoundError:
1201
+ operations = None
1202
+ if operations is not None and _is_link_or_reparse(operations):
1203
+ raise OSError("project operations directory is unsafe")
1204
+ with _project_lock(root):
1205
+ current = sha256_bytes(_read_regular(root, normalized))
1206
+ if current != expected_version:
1207
+ raise ProjectConflictError("file changed since it was opened")
1208
+ yield
1209
+
1210
+
1211
+ def _validate_structured_content(relative: str, content: bytes) -> None:
1212
+ try:
1213
+ text = content.decode("utf-8")
1214
+ except UnicodeDecodeError as exc:
1215
+ raise ValueError(f"publication text must be UTF-8: {relative}") from exc
1216
+ suffix = PurePosixPath(relative).suffix.casefold()
1217
+ if suffix not in DELIVERY_SUFFIXES:
1218
+ raise ValueError(f"publication supports Markdown, JSON, and JSONL only: {relative}")
1219
+ if suffix == ".json":
1220
+ json.loads(text)
1221
+ elif suffix == ".jsonl":
1222
+ for number, line in enumerate(text.splitlines(), 1):
1223
+ if not line.strip():
1224
+ continue
1225
+ try:
1226
+ value = json.loads(line)
1227
+ except json.JSONDecodeError as exc:
1228
+ raise ValueError(f"invalid JSONL at {relative}:{number}") from exc
1229
+ if not isinstance(value, dict):
1230
+ raise ValueError(f"JSONL records must be objects: {relative}:{number}")
1231
+
1232
+
1233
+ def _check_output_ownership(state: Mapping[str, Any], artifact_id: str, outputs: Iterable[str]) -> None:
1234
+ wanted = set(outputs)
1235
+ artifacts = state.get("artifacts", {})
1236
+ if not isinstance(artifacts, Mapping):
1237
+ return
1238
+ for other_id, other in artifacts.items():
1239
+ if other_id == artifact_id or not isinstance(other, Mapping):
1240
+ continue
1241
+ existing = other.get("outputs", [])
1242
+ overlap = wanted.intersection(existing if isinstance(existing, list) else [])
1243
+ if overlap:
1244
+ raise ValueError(
1245
+ f"project path already belongs to {other_id}: {', '.join(sorted(overlap))}"
1246
+ )
1247
+
1248
+
1249
+ def publish_candidate(
1250
+ root: Path,
1251
+ *,
1252
+ owner: str,
1253
+ artifact_id: str,
1254
+ outputs: Mapping[str, str | bytes],
1255
+ inputs: Iterable[str] | None = None,
1256
+ allow_unregistered_path: bool = False,
1257
+ ) -> dict[str, Any]:
1258
+ root = find_project(root)
1259
+ if not owner.strip() or not artifact_id.strip():
1260
+ raise ValueError("owner and artifact_id are required")
1261
+ if not outputs:
1262
+ raise ValueError("publish needs at least one output")
1263
+ normalized_outputs = _validate_path_set(root, outputs, label="publication")
1264
+ prepared: dict[str, bytes] = {}
1265
+ for relative in normalized_outputs:
1266
+ _validate_publication_layout(
1267
+ relative, allow_unregistered=allow_unregistered_path
1268
+ )
1269
+ content = outputs[relative]
1270
+ encoded = content.encode("utf-8") if isinstance(content, str) else bytes(content)
1271
+ _validate_structured_content(relative, encoded)
1272
+ prepared[relative] = encoded
1273
+
1274
+ input_paths = list(inputs or [])
1275
+ normalized_inputs = _validate_path_set(root, input_paths, label="input")
1276
+ if set(normalized_inputs).intersection(normalized_outputs):
1277
+ raise ValueError("an output cannot also be its own direct input")
1278
+
1279
+ with _project_lock(root):
1280
+ state = _read_state(root)
1281
+ family = _validate_project_output_layout(root, normalized_outputs)
1282
+ _check_output_ownership(state, artifact_id, normalized_outputs)
1283
+ current_inputs = _hash_mapping(root, normalized_inputs)
1284
+ for relative, content in prepared.items():
1285
+ target = _project_path(root, relative, create_parent=True)
1286
+ _atomic_bytes(target, content)
1287
+ state["artifacts"][artifact_id] = {
1288
+ "owner": owner,
1289
+ "outputs": normalized_outputs,
1290
+ "inputs": current_inputs,
1291
+ "acceptance": None,
1292
+ "review": None,
1293
+ "updated_at": utc_now(),
1294
+ }
1295
+ if state.get("project_layout_mode") == "auto" and family is not None:
1296
+ state["project_layout_mode"] = family
1297
+ _save_state(root, state, action="published")
1298
+ return {
1299
+ "artifact_id": artifact_id,
1300
+ "owner": owner,
1301
+ "outputs": normalized_outputs,
1302
+ "state": "needs_confirmation",
1303
+ }
1304
+
1305
+
1306
+ def record_creator_acceptance(
1307
+ root: Path,
1308
+ *,
1309
+ artifact_id: str,
1310
+ decision: str,
1311
+ note: str = "",
1312
+ ) -> dict[str, Any]:
1313
+ if decision not in {"accepted", "rejected"}:
1314
+ raise ValueError("decision must be accepted or rejected")
1315
+ root = find_project(root)
1316
+ with _project_lock(root):
1317
+ state = _read_state(root)
1318
+ record = state["artifacts"].get(artifact_id)
1319
+ if not isinstance(record, dict):
1320
+ raise KeyError(f"unknown artifact: {artifact_id}")
1321
+ outputs = _hash_mapping(root, record.get("outputs", []))
1322
+ if not _inputs_current(root, record):
1323
+ raise ProjectConflictError("direct input changed; republish before acceptance")
1324
+ record["acceptance"] = {
1325
+ "decision": decision,
1326
+ "at": utc_now(),
1327
+ "outputs": outputs,
1328
+ "note": note.strip(),
1329
+ }
1330
+ record["review"] = None
1331
+ record["updated_at"] = utc_now()
1332
+ _save_state(root, state, action="accepted" if decision == "accepted" else "rejected")
1333
+ return {"artifact_id": artifact_id, "decision": decision, "state": _artifact_state(root, record)}
1334
+
1335
+
1336
+ def record_review(
1337
+ root: Path,
1338
+ *,
1339
+ artifact_id: str,
1340
+ verdict: str,
1341
+ reviewer: str = "",
1342
+ note: str = "",
1343
+ ) -> dict[str, Any]:
1344
+ if verdict not in {"approve", "approve_with_notes", "revise", "provisional"}:
1345
+ raise ValueError("unsupported review verdict")
1346
+ root = find_project(root)
1347
+ with _project_lock(root):
1348
+ state = _read_state(root)
1349
+ record = state["artifacts"].get(artifact_id)
1350
+ if not isinstance(record, dict):
1351
+ raise KeyError(f"unknown artifact: {artifact_id}")
1352
+ outputs = _hash_mapping(root, record.get("outputs", []))
1353
+ acceptance = record.get("acceptance")
1354
+ if (
1355
+ not isinstance(acceptance, Mapping)
1356
+ or acceptance.get("decision") != "accepted"
1357
+ or acceptance.get("outputs") != outputs
1358
+ ):
1359
+ raise ProjectConflictError("review requires current creator acceptance")
1360
+ if not _inputs_current(root, record):
1361
+ raise ProjectConflictError("direct input changed; republish before review")
1362
+ record["review"] = {
1363
+ "verdict": verdict,
1364
+ "at": utc_now(),
1365
+ "outputs": outputs,
1366
+ "reviewer": reviewer.strip(),
1367
+ "note": note.strip(),
1368
+ }
1369
+ record["updated_at"] = utc_now()
1370
+ _save_state(root, state, action="reviewed")
1371
+ return {"artifact_id": artifact_id, "verdict": verdict, "state": _artifact_state(root, record)}
1372
+
1373
+
1374
+ def _authority_tokens(field: str) -> list[str]:
1375
+ if not field.startswith("/"):
1376
+ raise ValueError(f"--field must be a JSON pointer starting with /: {field}")
1377
+ tokens = [
1378
+ token.replace("~1", "/").replace("~0", "~") for token in field[1:].split("/")
1379
+ ]
1380
+ if any(not token for token in tokens):
1381
+ raise ValueError(f"--field has an empty pointer segment: {field}")
1382
+ if field not in FORMAT_POINTERS and (
1383
+ tokens[0] != AUTHORITY_ROOT_TOKEN or len(tokens) < 2
1384
+ ):
1385
+ raise ValueError(
1386
+ f"set-authority writes /{AUTHORITY_ROOT_TOKEN}/* and "
1387
+ f"{', '.join(FORMAT_POINTERS)} only"
1388
+ )
1389
+ if tokens[:2] == [AUTHORITY_ROOT_TOKEN, "decisions_artifact"]:
1390
+ # Where decisions are kept is project layout, not a creative choice; a
1391
+ # decision record must not move the place its own successors are read from.
1392
+ raise ValueError("decisions_artifact is project layout, not a creator choice")
1393
+ return tokens
1394
+
1395
+
1396
+ def _accepted_decision_value(
1397
+ root: Path,
1398
+ state: Mapping[str, Any],
1399
+ *,
1400
+ decision_path: str,
1401
+ decision_id: str,
1402
+ field: str,
1403
+ ) -> Any:
1404
+ relative = _relative_path(decision_path)
1405
+ if _root_role(PurePosixPath(relative).parts[0]) != "creator-decisions":
1406
+ expected = CANONICAL_ROOTS["creator-decisions"]
1407
+ raise ValueError(f"creator decisions live in {expected}/: {relative}")
1408
+ try:
1409
+ _, record = _artifact_for_path(state, relative)
1410
+ except PackageBlockedError as exc:
1411
+ raise ValueError(f"creator decision file is not a published artifact: {relative}") from exc
1412
+ if _artifact_state(root, record) not in {"accepted", "approved"}:
1413
+ raise ValueError(f"creator decision file is not accepted and current: {relative}")
1414
+ text = _project_path(root, relative).read_text(encoding="utf-8")
1415
+ latest: tuple[int, Mapping[str, Any]] | None = None
1416
+ superseded_by: str | None = None
1417
+ for number, line in enumerate(text.splitlines(), 1):
1418
+ if not line.strip():
1419
+ continue
1420
+ try:
1421
+ decision = json.loads(line)
1422
+ except json.JSONDecodeError as exc:
1423
+ raise ValueError(f"{relative}:{number} is not a creator decision record: {exc}") from exc
1424
+ if not isinstance(decision, Mapping):
1425
+ continue
1426
+ # The file is append-only, so a revision arrives as a later line. Read the
1427
+ # whole file before deciding: the first match may already be retracted.
1428
+ if decision.get("decision_id") == decision_id:
1429
+ latest = (number, decision)
1430
+ elif (
1431
+ decision.get("supersedes_decision_id") == decision_id
1432
+ and decision.get("status") == "accepted"
1433
+ ):
1434
+ superseded_by = str(decision.get("decision_id"))
1435
+ if latest is None:
1436
+ raise KeyError(f"unknown creator decision: {decision_id}")
1437
+ if superseded_by is not None:
1438
+ raise ValueError(f"{decision_id} was superseded by {superseded_by}")
1439
+ number, decision = latest
1440
+ if decision.get("status") != "accepted":
1441
+ raise ValueError(f"{decision_id} is not an accepted creator decision")
1442
+ locators = decision.get("target_locators")
1443
+ if not isinstance(locators, list) or not any(
1444
+ isinstance(locator, Mapping)
1445
+ and locator.get("src") == "short-drama"
1446
+ and locator.get("field") == field
1447
+ for locator in locators
1448
+ ):
1449
+ raise ValueError(f"{decision_id} does not target {field}")
1450
+ if "accepted_value" not in decision:
1451
+ raise ValueError(f"{decision_id} carries no accepted_value at {relative}:{number}")
1452
+ return decision["accepted_value"]
1453
+
1454
+
1455
+ def _json_kind(value: Any) -> str:
1456
+ """The JSON type of a value. 90 and 92.5 are both numbers."""
1457
+ if isinstance(value, bool):
1458
+ return "boolean"
1459
+ if isinstance(value, (int, float)):
1460
+ return "number"
1461
+ if isinstance(value, str):
1462
+ return "string"
1463
+ if isinstance(value, Mapping):
1464
+ return "object"
1465
+ if isinstance(value, list):
1466
+ return "array"
1467
+ return "null" if value is None else type(value).__name__
1468
+
1469
+
1470
+ def _write_authority_value(project: dict[str, Any], tokens: list[str], value: Any) -> Any:
1471
+ cursor: Any = project
1472
+ blocks: list[dict[str, Any]] = []
1473
+ walked: list[str] = []
1474
+ for token in tokens[:-1]:
1475
+ cursor = cursor.get(token) if isinstance(cursor, dict) else None
1476
+ walked.append(token)
1477
+ if not isinstance(cursor, dict):
1478
+ raise ValueError(f"project manifest has no object at /{'/'.join(walked)}")
1479
+ if "status" in cursor:
1480
+ blocks.append(cursor)
1481
+ leaf = tokens[-1]
1482
+ if leaf not in cursor:
1483
+ # The manifest shape is declared by the project template. A decision may
1484
+ # fill a declared slot; inventing one would put a field downstream cannot
1485
+ # know to read.
1486
+ raise ValueError(f"project manifest declares no /{'/'.join([*walked, leaf])}")
1487
+ current = cursor.get(leaf)
1488
+ if isinstance(current, Mapping) and not isinstance(value, Mapping) and "status" not in current:
1489
+ # A choices map is merged, never replaced wholesale: replacing it would
1490
+ # silently drop the choices a previous decision already recorded.
1491
+ raise ValueError(f"/{'/'.join([*walked, leaf])} needs an object accepted_value")
1492
+ if isinstance(current, Mapping) and isinstance(value, Mapping) and "status" not in current:
1493
+ merged = {**current, **value}
1494
+ cursor[leaf] = merged
1495
+ for block in blocks:
1496
+ block["status"] = "accepted"
1497
+ return merged
1498
+ if not (isinstance(current, dict) and "status" in current):
1499
+ if current is not None and _json_kind(value) != _json_kind(current):
1500
+ raise ValueError(
1501
+ f"/{'/'.join([*walked, leaf])} is {_json_kind(current)}; "
1502
+ f"accepted_value is {_json_kind(value)}"
1503
+ )
1504
+ cursor[leaf] = value
1505
+ # Writing one choice inside an authority block accepts that block: a
1506
+ # downstream stage gates on the block's status before reading the choice.
1507
+ for block in blocks:
1508
+ block["status"] = "accepted"
1509
+ return value
1510
+ if not isinstance(value, Mapping) or not value:
1511
+ raise ValueError(f"/{'/'.join(tokens)} needs a non-empty object accepted_value")
1512
+ if "status" in value:
1513
+ raise ValueError("accepted_value must not carry its own status")
1514
+ block = dict(current)
1515
+ choices = block.get("choices")
1516
+ if isinstance(choices, Mapping):
1517
+ block["choices"] = {**choices, **value}
1518
+ else:
1519
+ block.update(value)
1520
+ block["status"] = "accepted"
1521
+ cursor[leaf] = block
1522
+ return block
1523
+
1524
+
1525
+ def set_creator_authority(
1526
+ root: Path,
1527
+ *,
1528
+ field: str,
1529
+ decision_path: str,
1530
+ decision_id: str,
1531
+ ) -> dict[str, Any]:
1532
+ tokens = _authority_tokens(field)
1533
+ root = find_project(root)
1534
+ with _project_lock(root):
1535
+ state = _read_state(root)
1536
+ value = _accepted_decision_value(
1537
+ root,
1538
+ state,
1539
+ decision_path=decision_path,
1540
+ decision_id=decision_id,
1541
+ field=field,
1542
+ )
1543
+ if field == EPISODE_LENGTH_POINTER and not (
1544
+ isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
1545
+ ):
1546
+ raise ValueError("target_seconds_per_episode must be a positive number of seconds")
1547
+ project_path = root / PROJECT_FILE
1548
+ project = json.loads(project_path.read_text(encoding="utf-8"))
1549
+ if not isinstance(project, dict):
1550
+ raise ValueError("project manifest must be an object")
1551
+ written = _write_authority_value(project, tokens, value)
1552
+ if field == PACING_POINTER:
1553
+ # Check what the manifest will hold, not what the decision said: an
1554
+ # object slot is merged, so a half decision leaves the other rate at
1555
+ # null and the estimate stays unusable while the write reports bound.
1556
+ # Type only — which rates make an estimate usable is the write
1557
+ # stage's call, and it stays in one place.
1558
+ if not isinstance(written, Mapping) or not written:
1559
+ raise ValueError("pacing must be an object of named rates")
1560
+ for name, rate in written.items():
1561
+ if not (
1562
+ isinstance(rate, (int, float))
1563
+ and not isinstance(rate, bool)
1564
+ and rate > 0
1565
+ ):
1566
+ raise ValueError(
1567
+ f"pacing rate {name} must be a positive number; "
1568
+ f"a decision that sets only some rates leaves the rest unset"
1569
+ )
1570
+ atomic_json(project_path, project)
1571
+ # Record which decision produced the value, so a later reader can tell a
1572
+ # bound write from a hand edit and see what a re-bind replaced.
1573
+ bindings = state.get("authority")
1574
+ if not isinstance(bindings, dict):
1575
+ bindings = {}
1576
+ state["authority"] = bindings
1577
+ bindings[field] = {
1578
+ "decision": f"{_relative_path(decision_path)}#{decision_id}",
1579
+ # The written value, so a later reader can tell the manifest still
1580
+ # holds what the decision said rather than a hand edit made since.
1581
+ "value_sha256": hashlib.sha256(
1582
+ json.dumps(written, ensure_ascii=False, sort_keys=True).encode("utf-8")
1583
+ ).hexdigest(),
1584
+ "set_at": utc_now(),
1585
+ }
1586
+ _save_state(root, state, action="authority_set")
1587
+ return {"field": field, "decision_id": decision_id, "value": written}
1588
+
1589
+
1590
+ def _artifact_for_path(state: Mapping[str, Any], relative: str) -> tuple[str, Mapping[str, Any]]:
1591
+ found: list[tuple[str, Mapping[str, Any]]] = []
1592
+ artifacts = state.get("artifacts", {})
1593
+ if isinstance(artifacts, Mapping):
1594
+ for artifact_id, record in artifacts.items():
1595
+ if (
1596
+ isinstance(artifact_id, str)
1597
+ and isinstance(record, Mapping)
1598
+ and relative in record.get("outputs", [])
1599
+ ):
1600
+ found.append((artifact_id, record))
1601
+ if len(found) != 1:
1602
+ raise PackageBlockedError(f"delivery source has no unique artifact owner: {relative}")
1603
+ return found[0]
1604
+
1605
+
1606
+ def _replace_directory(source: Path, target: Path) -> None:
1607
+ backup = target.with_name(f".{target.name}.{uuid.uuid4().hex}.old")
1608
+ moved_old = False
1609
+ try:
1610
+ if target.exists():
1611
+ details = os.lstat(target)
1612
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
1613
+ raise ProjectConflictError(f"delivery target is unsafe: {target.name}")
1614
+ os.replace(target, backup)
1615
+ moved_old = True
1616
+ os.replace(source, target)
1617
+ _fsync_directory(target.parent)
1618
+ if moved_old:
1619
+ shutil.rmtree(backup)
1620
+ except Exception:
1621
+ if moved_old and backup.exists() and not target.exists():
1622
+ os.replace(backup, target)
1623
+ raise
1624
+
1625
+
1626
+ def _require_episode_delivery_path(relative: str, episode: str) -> None:
1627
+ parts = PurePosixPath(relative).parts
1628
+ if (
1629
+ len(parts) < 3
1630
+ or _root_role(parts[0]) != "episodes"
1631
+ or parts[1] != episode
1632
+ ):
1633
+ raise ValueError(f"delivery paths must belong to {episode}: {relative}")
1634
+
1635
+
1636
+ def build_delivery_package(
1637
+ root: Path,
1638
+ *,
1639
+ episode: str,
1640
+ includes: Iterable[str],
1641
+ omissions: Iterable[str] | Mapping[str, str] | None = None,
1642
+ ) -> dict[str, Any]:
1643
+ if EPISODE_ID_RE.fullmatch(episode) is None:
1644
+ raise ValueError("episode must use an EP001-style identifier")
1645
+ root = find_project(root)
1646
+ include_paths = _validate_path_set(root, includes, label="delivery")
1647
+ if not include_paths:
1648
+ raise ValueError("delivery needs at least one included file")
1649
+ omission_values = omissions.keys() if isinstance(omissions, Mapping) else omissions or []
1650
+ omitted = _validate_path_set(root, omission_values, label="omission")
1651
+ if set(include_paths).intersection(omitted):
1652
+ raise ValueError("a delivery path cannot be both included and omitted")
1653
+ for relative in (*include_paths, *omitted):
1654
+ _require_episode_delivery_path(relative, episode)
1655
+
1656
+ with _project_lock(root):
1657
+ state = _read_state(root)
1658
+ entries: list[dict[str, str]] = []
1659
+ snapshots: dict[str, bytes] = {}
1660
+ for relative in include_paths:
1661
+ if PurePosixPath(relative).suffix.casefold() not in DELIVERY_SUFFIXES:
1662
+ raise PackageBlockedError(f"delivery source is not text/JSON: {relative}")
1663
+ artifact_id, record = _artifact_for_path(state, relative)
1664
+ if _artifact_state(root, record) != "approved":
1665
+ raise PackageBlockedError(f"delivery source is not approved and current: {relative}")
1666
+ source = _project_path(root, relative)
1667
+ data = source.read_bytes()
1668
+ try:
1669
+ data.decode("utf-8")
1670
+ except UnicodeDecodeError as exc:
1671
+ raise PackageBlockedError(f"delivery source must be UTF-8: {relative}") from exc
1672
+ digest = sha256_bytes(data)
1673
+ acceptance = record.get("acceptance")
1674
+ review = record.get("review")
1675
+ accepted_outputs = (
1676
+ acceptance.get("outputs") if isinstance(acceptance, Mapping) else None
1677
+ )
1678
+ reviewed_outputs = (
1679
+ review.get("outputs") if isinstance(review, Mapping) else None
1680
+ )
1681
+ if (
1682
+ not isinstance(accepted_outputs, Mapping)
1683
+ or not isinstance(reviewed_outputs, Mapping)
1684
+ or accepted_outputs.get(relative) != digest
1685
+ or reviewed_outputs.get(relative) != digest
1686
+ or not _inputs_current(root, record)
1687
+ ):
1688
+ raise PackageBlockedError(
1689
+ f"delivery source changed after approval: {relative}"
1690
+ )
1691
+ snapshots[relative] = data
1692
+ entries.append(
1693
+ {
1694
+ "artifact_id": artifact_id,
1695
+ "source": relative,
1696
+ "path": f"artifacts/{relative}",
1697
+ "sha256": digest,
1698
+ }
1699
+ )
1700
+
1701
+ layout = _project_layout_from_root(root, state)
1702
+ if layout["mode"] == "mixed":
1703
+ raise PackageBlockedError("mixed project layouts cannot be packaged")
1704
+ delivery_root = str(layout["roots"]["delivery"])
1705
+ parent = root / delivery_root
1706
+ try:
1707
+ parent_details = os.lstat(parent)
1708
+ except FileNotFoundError:
1709
+ parent.mkdir(parents=True)
1710
+ else:
1711
+ if _is_link_or_reparse(parent_details) or not stat.S_ISDIR(
1712
+ parent_details.st_mode
1713
+ ):
1714
+ raise ProjectConflictError("delivery root is unsafe")
1715
+ temporary = parent / f".{episode}.{uuid.uuid4().hex}.tmp"
1716
+ temporary.mkdir()
1717
+ try:
1718
+ for entry in entries:
1719
+ destination = temporary / entry["path"]
1720
+ destination.parent.mkdir(parents=True, exist_ok=True)
1721
+ _atomic_bytes(destination, snapshots[entry["source"]])
1722
+ manifest = {
1723
+ "schema_version": "1.0",
1724
+ "project_id": state.get("project_id"),
1725
+ "episode": episode,
1726
+ "created_at": utc_now(),
1727
+ "files": entries,
1728
+ "omitted": [
1729
+ {
1730
+ "source": path,
1731
+ "reason": (
1732
+ omissions[path]
1733
+ if isinstance(omissions, Mapping) and path in omissions
1734
+ else "creator_omitted"
1735
+ ),
1736
+ }
1737
+ for path in omitted
1738
+ ],
1739
+ }
1740
+ atomic_json(temporary / "manifest.json", manifest)
1741
+ checksum_members = ["manifest.json", *(entry["path"] for entry in entries)]
1742
+ checksums = "".join(
1743
+ f"{sha256_file(temporary / relative)} {relative}\n"
1744
+ for relative in sorted(checksum_members)
1745
+ )
1746
+ _atomic_bytes(temporary / "checksums.sha256", checksums.encode("utf-8"))
1747
+ target = parent / episode
1748
+ _replace_directory(temporary, target)
1749
+ finally:
1750
+ if temporary.exists():
1751
+ shutil.rmtree(temporary)
1752
+ return {
1753
+ "episode": episode,
1754
+ "delivery_root": f"{delivery_root}/{episode}",
1755
+ "files": entries,
1756
+ "omitted": omitted,
1757
+ }
1758
+
1759
+
1760
+ def _delivery_directory(root: Path, episode: str) -> Path:
1761
+ matches: list[Path] = []
1762
+ for name in {CANONICAL_ROOTS["delivery"], LEGACY_ROOTS["delivery"]}:
1763
+ parent = root / name
1764
+ try:
1765
+ parent_details = os.lstat(parent)
1766
+ except FileNotFoundError:
1767
+ continue
1768
+ if _is_link_or_reparse(parent_details) or not stat.S_ISDIR(
1769
+ parent_details.st_mode
1770
+ ):
1771
+ raise ProjectConflictError("delivery root is unsafe")
1772
+ delivery = parent / episode
1773
+ try:
1774
+ details = os.lstat(delivery)
1775
+ except FileNotFoundError:
1776
+ continue
1777
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
1778
+ raise ProjectConflictError("delivery package is not a regular directory")
1779
+ matches.append(delivery)
1780
+ if len(matches) != 1:
1781
+ raise FileNotFoundError(f"expected exactly one delivered package for {episode}")
1782
+ return matches[0]
1783
+
1784
+
1785
+ def _manifest_problems(
1786
+ delivery: Path,
1787
+ manifest: object,
1788
+ *,
1789
+ episode: str,
1790
+ checksum_paths: set[str],
1791
+ ) -> list[str]:
1792
+ problems: list[str] = []
1793
+ if not isinstance(manifest, Mapping):
1794
+ return ["manifest must be an object"]
1795
+ if manifest.get("schema_version") != "1.0":
1796
+ problems.append("manifest schema is invalid")
1797
+ if manifest.get("episode") != episode:
1798
+ problems.append("manifest does not describe this episode")
1799
+ files = manifest.get("files")
1800
+ if not isinstance(files, list):
1801
+ return [*problems, "manifest files must be a list"]
1802
+ manifest_paths: set[str] = set()
1803
+ sources: set[str] = set()
1804
+ for number, entry in enumerate(files, 1):
1805
+ if not isinstance(entry, Mapping) or set(entry) != {
1806
+ "artifact_id",
1807
+ "source",
1808
+ "path",
1809
+ "sha256",
1810
+ }:
1811
+ problems.append(f"manifest file {number} has invalid fields")
1812
+ continue
1813
+ artifact_id = entry.get("artifact_id")
1814
+ source_raw = entry.get("source")
1815
+ path_raw = entry.get("path")
1816
+ digest = entry.get("sha256")
1817
+ if not isinstance(artifact_id, str) or not artifact_id:
1818
+ problems.append(f"manifest file {number} has no artifact id")
1819
+ if not isinstance(source_raw, str) or not isinstance(path_raw, str):
1820
+ problems.append(f"manifest file {number} has an unsafe path")
1821
+ continue
1822
+ try:
1823
+ source = _relative_path(source_raw)
1824
+ relative = _relative_path(path_raw)
1825
+ except ValueError:
1826
+ problems.append(f"manifest file {number} has an unsafe path")
1827
+ continue
1828
+ try:
1829
+ _require_episode_delivery_path(source, episode)
1830
+ except ValueError:
1831
+ problems.append(f"manifest source does not belong to {episode}: {source}")
1832
+ if relative != f"artifacts/{source}":
1833
+ problems.append(f"manifest path does not match its source: {relative}")
1834
+ if relative in manifest_paths:
1835
+ problems.append(f"duplicate manifest path: {relative}")
1836
+ if source in sources:
1837
+ problems.append(f"duplicate manifest source: {source}")
1838
+ manifest_paths.add(relative)
1839
+ sources.add(source)
1840
+ if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
1841
+ problems.append(f"manifest file {number} has an invalid hash")
1842
+ continue
1843
+ member = delivery / relative
1844
+ if member.is_file() and not member.is_symlink() and sha256_file(member) != digest:
1845
+ problems.append(f"manifest hash mismatch: {relative}")
1846
+ expected_manifest_paths = checksum_paths - {"manifest.json"}
1847
+ if manifest_paths != expected_manifest_paths:
1848
+ problems.append("manifest files do not match checksum members")
1849
+
1850
+ omitted = manifest.get("omitted")
1851
+ if not isinstance(omitted, list):
1852
+ problems.append("manifest omissions must be a list")
1853
+ else:
1854
+ for number, entry in enumerate(omitted, 1):
1855
+ if (
1856
+ not isinstance(entry, Mapping)
1857
+ or set(entry) != {"source", "reason"}
1858
+ or not isinstance(entry.get("source"), str)
1859
+ or not isinstance(entry.get("reason"), str)
1860
+ or not str(entry.get("reason")).strip()
1861
+ ):
1862
+ problems.append(f"manifest omission {number} is invalid")
1863
+ continue
1864
+ try:
1865
+ source = _relative_path(str(entry["source"]))
1866
+ _require_episode_delivery_path(source, episode)
1867
+ except ValueError:
1868
+ problems.append(f"manifest omission {number} has an unsafe source")
1869
+ return problems
1870
+
1871
+
1872
+ def _same_directory(left: Path, right: Path) -> bool:
1873
+ """Do these two paths name the same directory on disk?
1874
+
1875
+ String comparison is not enough: a case-insensitive volume and a filesystem
1876
+ that stores a different Unicode normalisation both spell one directory two
1877
+ ways, and either spelling would otherwise slip past a containment check.
1878
+ """
1879
+ try:
1880
+ return os.path.samefile(left, right)
1881
+ except OSError:
1882
+ return False
1883
+
1884
+
1885
+ def _require_safe_export_destination(destination: Path, root: Path) -> None:
1886
+ """Refuse any destination that would take the project down with it.
1887
+
1888
+ The export replaces its destination directory wholesale, so a destination
1889
+ that is -- or contains -- the project being exported would delete the
1890
+ source. Ancestors matter as much as descendants here.
1891
+ """
1892
+ if destination == root or root in destination.parents:
1893
+ raise ValueError("export destination must live outside the project root")
1894
+ for ancestor in (root, *root.parents):
1895
+ if destination == ancestor or _same_directory(destination, ancestor):
1896
+ raise ValueError(
1897
+ "export destination must not be the project root or any directory containing it"
1898
+ )
1899
+
1900
+
1901
+ def _require_replaceable_export(destination: Path) -> None:
1902
+ """Only ever overwrite a directory this command itself produced.
1903
+
1904
+ `--overwrite` removes the destination tree. Without this check a mistyped
1905
+ path turns an ordinary directory into a deletion, and nothing else in this
1906
+ tool can erase a directory outside the project.
1907
+ """
1908
+ if not destination.exists():
1909
+ return
1910
+ details = os.lstat(destination)
1911
+ if _is_link_or_reparse(details) or not stat.S_ISDIR(details.st_mode):
1912
+ raise ProjectConflictError("export destination is not a regular directory")
1913
+ manifest = destination / "manifest.json"
1914
+ try:
1915
+ document = json.loads(manifest.read_text(encoding="utf-8"))
1916
+ except (OSError, ValueError) as exc:
1917
+ raise ValueError(
1918
+ "refusing to overwrite a directory that is not a previous export: "
1919
+ f"{destination}"
1920
+ ) from exc
1921
+ if not isinstance(document, Mapping) or document.get("kind") != "creator_export":
1922
+ raise ValueError(
1923
+ "refusing to overwrite a directory that is not a previous export: "
1924
+ f"{destination}"
1925
+ )
1926
+
1927
+
1928
+ def _export_regular_file(source: Path, destination: Path) -> str:
1929
+ """Copy one regular project file into the export tree and return its digest."""
1930
+ details = os.lstat(source)
1931
+ if _is_link_or_reparse(details) or not stat.S_ISREG(details.st_mode):
1932
+ raise ProjectConflictError(f"export source is not a regular file: {source.name}")
1933
+ if "\n" in source.name or "\r" in source.name:
1934
+ # checksums.sha256 is one record per line; such a name cannot be written
1935
+ # in a form any verifier could read back.
1936
+ raise ProjectConflictError(
1937
+ "export source name contains a line break and cannot be checksummed"
1938
+ )
1939
+ destination.parent.mkdir(parents=True, exist_ok=True)
1940
+ shutil.copyfile(source, destination)
1941
+ return sha256_file(destination)
1942
+
1943
+
1944
+ def _export_episode(
1945
+ episode_directory: Path, target: Path, *, include_media: bool
1946
+ ) -> tuple[list[dict[str, str]], list[str]]:
1947
+ files: list[dict[str, str]] = []
1948
+ missing: list[str] = []
1949
+ for name in CREATOR_DOCUMENTS:
1950
+ source = episode_directory / name
1951
+ if not source.exists():
1952
+ missing.append(name)
1953
+ continue
1954
+ digest = _export_regular_file(source, target / name)
1955
+ files.append({"path": name, "sha256": digest})
1956
+ for name in POST_PRODUCTION_DOCUMENTS:
1957
+ source = episode_directory / name
1958
+ if not source.exists():
1959
+ continue
1960
+ digest = _export_regular_file(source, target / name)
1961
+ files.append({"path": name, "sha256": digest})
1962
+ media_root = episode_directory / EXPORT_MEDIA_DIRECTORY
1963
+ if include_media and media_root.exists():
1964
+ media_details = os.lstat(media_root)
1965
+ if _is_link_or_reparse(media_details) or not stat.S_ISDIR(media_details.st_mode):
1966
+ # Silently exporting no media reads as "this episode has none".
1967
+ raise ProjectConflictError(
1968
+ f"{EXPORT_MEDIA_DIRECTORY} is not a regular directory: {episode_directory.name}"
1969
+ )
1970
+ for source in sorted(media_root.rglob("*")):
1971
+ if source.is_dir() and not source.is_symlink():
1972
+ continue
1973
+ relative = source.relative_to(episode_directory).as_posix()
1974
+ digest = _export_regular_file(source, target / relative)
1975
+ files.append({"path": relative, "sha256": digest})
1976
+ return files, missing
1977
+
1978
+
1979
+ def _export_exclusions(root: Path, episodes_name: str) -> list[str]:
1980
+ """Name every top-level project directory this export did not copy.
1981
+
1982
+ A fixed list lies twice over: it names roots a legacy-layout project does
1983
+ not have, and it stays silent about a published 设定集/ that the handover is
1984
+ missing.
1985
+ """
1986
+ excluded: list[str] = []
1987
+ for entry in sorted(root.iterdir(), key=lambda item: item.name):
1988
+ if entry.name == episodes_name or entry.name == PROJECT_FILE:
1989
+ continue
1990
+ if entry.is_dir() or entry.is_symlink():
1991
+ excluded.append(entry.name)
1992
+ return excluded
1993
+
1994
+
1995
+ def _is_portable_windows_path(relative: str) -> bool:
1996
+ for part in PurePosixPath(relative).parts:
1997
+ stem = part.split(".")[0].casefold()
1998
+ if stem in WINDOWS_RESERVED_PATH_STEMS:
1999
+ return False
2000
+ if set(part) & WINDOWS_FORBIDDEN_PATH_CHARACTERS:
2001
+ return False
2002
+ if part.endswith((" ", ".")):
2003
+ return False
2004
+ return True
2005
+
2006
+
2007
+ def build_creator_export(
2008
+ root: Path,
2009
+ *,
2010
+ out: Path,
2011
+ episodes: Sequence[str] | None = None,
2012
+ include_media: bool = True,
2013
+ overwrite: bool = False,
2014
+ ) -> dict[str, Any]:
2015
+ """Copy the current creator documents and produced media into a handover tree.
2016
+
2017
+ This is a snapshot of what the project holds right now. It deliberately
2018
+ asserts nothing about review or creator acceptance; `package` remains the
2019
+ only command that speaks for an approved delivery.
2020
+ """
2021
+ root = find_project(root)
2022
+ destination = out.expanduser().resolve()
2023
+ _require_safe_export_destination(destination, root)
2024
+ layout = _project_layout_from_root(root)
2025
+ if layout["mode"] == "mixed":
2026
+ raise PackageBlockedError("mixed project layouts cannot be exported")
2027
+ episodes_root = root / str(layout["roots"]["episodes"])
2028
+ available = sorted(
2029
+ entry.name
2030
+ for entry in episodes_root.iterdir()
2031
+ if entry.is_dir() and not entry.is_symlink()
2032
+ if EPISODE_ID_RE.fullmatch(entry.name)
2033
+ ) if episodes_root.is_dir() and not episodes_root.is_symlink() else []
2034
+ if episodes:
2035
+ selected = []
2036
+ for episode in episodes:
2037
+ if EPISODE_ID_RE.fullmatch(episode) is None:
2038
+ raise ValueError("episode must use an EP001-style identifier")
2039
+ if episode not in available:
2040
+ raise FileNotFoundError(f"episode directory not found: {episode}")
2041
+ if episode not in selected:
2042
+ selected.append(episode)
2043
+ else:
2044
+ selected = available
2045
+ if not selected:
2046
+ raise FileNotFoundError("no episode directory to export")
2047
+ if destination.exists():
2048
+ if not overwrite:
2049
+ raise FileExistsError(f"export destination already exists: {destination}")
2050
+ _require_replaceable_export(destination)
2051
+
2052
+ project = json.loads((root / PROJECT_FILE).read_text(encoding="utf-8"))
2053
+ destination.parent.mkdir(parents=True, exist_ok=True)
2054
+ temporary = destination.parent / f".{destination.name}.{uuid.uuid4().hex}.tmp"
2055
+ temporary.mkdir()
2056
+ try:
2057
+ exported: list[dict[str, Any]] = []
2058
+ members: list[dict[str, str]] = []
2059
+ episodes_name = str(layout["roots"]["episodes"])
2060
+ for episode in selected:
2061
+ target = temporary / episodes_name / episode
2062
+ target.mkdir(parents=True)
2063
+ files, missing = _export_episode(
2064
+ episodes_root / episode, target, include_media=include_media
2065
+ )
2066
+ for entry in files:
2067
+ members.append(
2068
+ {
2069
+ "path": f"{episodes_name}/{episode}/{entry['path']}",
2070
+ "sha256": entry["sha256"],
2071
+ }
2072
+ )
2073
+ exported.append(
2074
+ {"episode": episode, "files": files, "missing_documents": missing}
2075
+ )
2076
+ config_digest = _export_regular_file(
2077
+ root / PROJECT_FILE, temporary / PROJECT_FILE
2078
+ )
2079
+ members.append({"path": PROJECT_FILE, "sha256": config_digest})
2080
+ manifest = {
2081
+ "schema_version": EXPORT_SCHEMA,
2082
+ "kind": "creator_export",
2083
+ "asserts_approval": False,
2084
+ "project_id": project.get("project_id"),
2085
+ "title": project.get("title"),
2086
+ "created_at": utc_now(),
2087
+ "episodes": exported,
2088
+ "selection": {
2089
+ "episodes": "all" if not episodes else list(selected),
2090
+ "available_episodes": list(available),
2091
+ "include_media": bool(include_media),
2092
+ },
2093
+ "excluded": _export_exclusions(root, episodes_name),
2094
+ "windows_unsafe_paths": sorted(
2095
+ entry["path"]
2096
+ for entry in members
2097
+ if not _is_portable_windows_path(entry["path"])
2098
+ ),
2099
+ }
2100
+ atomic_json(temporary / "manifest.json", manifest)
2101
+ checksum_members = ["manifest.json", *(entry["path"] for entry in members)]
2102
+ checksums = "".join(
2103
+ f"{sha256_file(temporary / relative)} {relative}\n"
2104
+ for relative in sorted(checksum_members)
2105
+ )
2106
+ _atomic_bytes(temporary / "checksums.sha256", checksums.encode("utf-8"))
2107
+ _replace_directory(temporary, destination)
2108
+ finally:
2109
+ if temporary.exists():
2110
+ shutil.rmtree(temporary)
2111
+ return {
2112
+ "kind": "creator_export",
2113
+ "out": str(destination),
2114
+ "episodes": [entry["episode"] for entry in exported],
2115
+ "files": [entry["path"] for entry in members],
2116
+ "excluded": manifest["excluded"],
2117
+ "include_media": bool(include_media),
2118
+ "missing_documents": {
2119
+ entry["episode"]: entry["missing_documents"]
2120
+ for entry in exported
2121
+ if entry["missing_documents"]
2122
+ },
2123
+ "windows_unsafe_paths": manifest["windows_unsafe_paths"],
2124
+ }
2125
+
2126
+
2127
+ def verify_delivery_package(root: Path, *, episode: str) -> dict[str, Any]:
2128
+ if EPISODE_ID_RE.fullmatch(episode) is None:
2129
+ raise ValueError("episode must use an EP001-style identifier")
2130
+ root = find_project(root)
2131
+ delivery = _delivery_directory(root, episode)
2132
+ problems: list[str] = []
2133
+ actual: set[str] = set()
2134
+ for path in delivery.rglob("*"):
2135
+ relative = path.relative_to(delivery).as_posix()
2136
+ if path.is_symlink():
2137
+ problems.append(f"symlink is not allowed: {relative}")
2138
+ elif path.is_file():
2139
+ actual.add(relative)
2140
+ elif not path.is_dir():
2141
+ problems.append(f"unsupported delivery member: {relative}")
2142
+ checksums_path = delivery / "checksums.sha256"
2143
+ if checksums_path.is_symlink() or not checksums_path.is_file():
2144
+ problems.append("checksums.sha256 is missing or unsafe")
2145
+ return {"episode": episode, "status": "tampered", "problems": problems}
2146
+ expected: dict[str, str] = {}
2147
+ for number, line in enumerate(checksums_path.read_text(encoding="utf-8").splitlines(), 1):
2148
+ match = re.fullmatch(r"([0-9a-f]{64}) (.+)", line)
2149
+ if match is None:
2150
+ problems.append(f"invalid checksum line {number}")
2151
+ continue
2152
+ digest, raw = match.groups()
2153
+ try:
2154
+ relative = _relative_path(raw)
2155
+ except ValueError:
2156
+ problems.append(f"unsafe checksum path: {raw}")
2157
+ continue
2158
+ if relative in expected:
2159
+ problems.append(f"duplicate checksum path: {relative}")
2160
+ expected[relative] = digest
2161
+ expected_members = set(expected) | {"checksums.sha256"}
2162
+ for extra in sorted(actual - expected_members):
2163
+ problems.append(f"unexpected delivery file: {extra}")
2164
+ for missing in sorted(expected_members - actual):
2165
+ problems.append(f"missing delivery file: {missing}")
2166
+ for relative, digest in expected.items():
2167
+ path = delivery / relative
2168
+ if not path.is_file() or path.is_symlink():
2169
+ continue
2170
+ if sha256_file(path) != digest:
2171
+ problems.append(f"checksum mismatch: {relative}")
2172
+ try:
2173
+ manifest = json.loads((delivery / "manifest.json").read_text(encoding="utf-8"))
2174
+ problems.extend(
2175
+ _manifest_problems(
2176
+ delivery,
2177
+ manifest,
2178
+ episode=episode,
2179
+ checksum_paths=set(expected),
2180
+ )
2181
+ )
2182
+ except (OSError, UnicodeError, json.JSONDecodeError):
2183
+ problems.append("manifest.json is unreadable")
2184
+ return {
2185
+ "episode": episode,
2186
+ "status": "verified" if not problems else "tampered",
2187
+ "problems": problems,
2188
+ }
2189
+
2190
+
2191
+ def _lock_at(directory_fd: int) -> Any:
2192
+ try:
2193
+ operations = os.open(
2194
+ ".short-drama",
2195
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
2196
+ dir_fd=directory_fd,
2197
+ )
2198
+ except FileNotFoundError:
2199
+ try:
2200
+ os.mkdir(".short-drama", 0o700, dir_fd=directory_fd)
2201
+ except FileExistsError:
2202
+ pass
2203
+ operations = os.open(
2204
+ ".short-drama",
2205
+ os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
2206
+ dir_fd=directory_fd,
2207
+ )
2208
+ try:
2209
+ try:
2210
+ descriptor = os.open(
2211
+ "project.lock", os.O_RDWR | os.O_NOFOLLOW, dir_fd=operations
2212
+ )
2213
+ except FileNotFoundError:
2214
+ try:
2215
+ descriptor = os.open(
2216
+ "project.lock",
2217
+ os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
2218
+ 0o600,
2219
+ dir_fd=operations,
2220
+ )
2221
+ except FileExistsError:
2222
+ descriptor = os.open(
2223
+ "project.lock", os.O_RDWR | os.O_NOFOLLOW, dir_fd=operations
2224
+ )
2225
+ finally:
2226
+ os.close(operations)
2227
+ return os.fdopen(descriptor, "a+b", buffering=0)
2228
+
2229
+
2230
+ @contextlib.contextmanager
2231
+ def coordinated_project_text_edit_at(
2232
+ directory_fd: int, relative: str, expected_version: str
2233
+ ) -> Iterator[None]:
2234
+ normalized = _relative_path(relative)
2235
+ if not re.fullmatch(r"[0-9a-f]{64}", expected_version):
2236
+ raise ValueError("expected version must be a SHA-256 digest")
2237
+ with _lock_at(directory_fd) as handle, _lock_handle(handle):
2238
+ current = sha256_bytes(_read_regular_at(directory_fd, normalized))
2239
+ if current != expected_version:
2240
+ raise ProjectConflictError("file changed since it was opened")
2241
+ yield
2242
+
2243
+
2244
+ def _parse_output_bindings(root: Path, values: Iterable[str]) -> dict[str, bytes]:
2245
+ outputs: dict[str, bytes] = {}
2246
+ for value in values:
2247
+ if "=" not in value:
2248
+ raise ValueError("--output uses PROJECT_TARGET=PROJECT_SOURCE")
2249
+ target, source_value = value.split("=", 1)
2250
+ relative = _relative_path(target)
2251
+ source_relative = _relative_path(source_value, allow_operations=True)
2252
+ source = _project_path(root, source_relative)
2253
+ if not source.is_file() or source.is_symlink():
2254
+ raise ValueError(f"publication source is missing or unsafe: {source_relative}")
2255
+ outputs[relative] = source.read_bytes()
2256
+ return outputs
2257
+
2258
+
2259
+ def _parse_decision_ref(value: str) -> tuple[str, str]:
2260
+ path, separator, decision_id = value.rpartition("#")
2261
+ if not separator or not path.strip() or not decision_id.strip():
2262
+ raise ValueError("--decision-ref uses 创作者决策/<file>.jsonl#<decision-id>")
2263
+ return path.strip(), decision_id.strip()
2264
+
2265
+
2266
+ def _parse_omissions(values: Iterable[str]) -> dict[str, str]:
2267
+ result: dict[str, str] = {}
2268
+ for value in values:
2269
+ path, separator, reason = value.partition("=")
2270
+ result[_relative_path(path)] = reason.strip() if separator and reason.strip() else "creator_omitted"
2271
+ return result
2272
+
2273
+
2274
+ def build_parser() -> argparse.ArgumentParser:
2275
+ parser = argparse.ArgumentParser(description="Manage a short-drama filesystem project.")
2276
+ commands = parser.add_subparsers(dest="command", required=True)
2277
+
2278
+ init = commands.add_parser("init", help="Initialize a project without creative content.")
2279
+ init.add_argument("path")
2280
+ init.add_argument("--title", required=True)
2281
+ init.add_argument("--language", default="zh-CN")
2282
+ init.add_argument("--prompt-language", default=DEFAULT_PROMPT_LANGUAGE)
2283
+ init.add_argument("--aspect-ratio", default="9:16")
2284
+ init.add_argument("--episode-count", type=int)
2285
+ init.add_argument("--target-seconds", type=float, dest="target_seconds_per_episode")
2286
+
2287
+ status = commands.add_parser("status", help="Print a creator-safe project summary.")
2288
+ status.add_argument("path", nargs="?", default=".")
2289
+
2290
+ publish = commands.add_parser("publish", help="Atomically publish text/JSON outputs.")
2291
+ publish.add_argument("path")
2292
+ publish.add_argument("--owner", required=True)
2293
+ publish.add_argument("--artifact-id", required=True)
2294
+ publish.add_argument(
2295
+ "--output", action="append", required=True, dest="outputs",
2296
+ help="Bind PROJECT_TARGET=PROJECT_SOURCE; repeat for multiple files.",
2297
+ )
2298
+ publish.add_argument(
2299
+ "--input", action="append", default=[], dest="inputs",
2300
+ help="Record one direct project input path; repeat as needed.",
2301
+ )
2302
+ publish.add_argument("--allow-unregistered-path", action="store_true")
2303
+
2304
+ accept = commands.add_parser("accept", help="Accept or reject the current artifact outputs.")
2305
+ accept.add_argument("path")
2306
+ accept.add_argument("--artifact-id", required=True)
2307
+ accept.add_argument("--decision", choices=("accepted", "rejected"), required=True)
2308
+ accept.add_argument("--note", default="")
2309
+
2310
+ review = commands.add_parser("review", help="Record a lightweight verdict for an accepted artifact.")
2311
+ review.add_argument("path")
2312
+ review.add_argument("--artifact-id", required=True)
2313
+ review.add_argument(
2314
+ "--verdict",
2315
+ choices=("approve", "approve_with_notes", "revise", "provisional"),
2316
+ required=True,
2317
+ )
2318
+ review.add_argument("--reviewer", default="")
2319
+ review.add_argument("--note", default="")
2320
+
2321
+ authority = commands.add_parser(
2322
+ "set-authority", help="Write an accepted creator decision into project authority."
2323
+ )
2324
+ authority.add_argument("path")
2325
+ authority.add_argument(
2326
+ "--field",
2327
+ required=True,
2328
+ help=(
2329
+ f"JSON pointer under /{AUTHORITY_ROOT_TOKEN}/ "
2330
+ f"or one of {', '.join(FORMAT_POINTERS)}."
2331
+ ),
2332
+ )
2333
+ authority.add_argument(
2334
+ "--decision-ref",
2335
+ required=True,
2336
+ dest="decision_ref",
2337
+ help="Bind 创作者决策/<file>.jsonl#<decision-id>.",
2338
+ )
2339
+
2340
+ package = commands.add_parser("package", help="Package approved text/JSON artifacts.")
2341
+ package.add_argument("path")
2342
+ package.add_argument("--episode", required=True)
2343
+ package.add_argument("--include", action="append", required=True, dest="includes")
2344
+ package.add_argument(
2345
+ "--omit", action="append", default=[], dest="omissions",
2346
+ help="Record PATH or PATH=REASON as deliberately omitted.",
2347
+ )
2348
+
2349
+ verify = commands.add_parser("verify", help="Re-check a delivered package checksums.")
2350
+ verify.add_argument("path")
2351
+ verify.add_argument("--episode", required=True)
2352
+
2353
+ export = commands.add_parser(
2354
+ "export",
2355
+ help="Copy the current creator documents and produced media for handover.",
2356
+ )
2357
+ export.add_argument("path")
2358
+ export.add_argument("--out", required=True, type=Path)
2359
+ export.add_argument(
2360
+ "--episode",
2361
+ action="append",
2362
+ dest="episodes",
2363
+ help="Export only this episode; repeat for several. Default: every episode.",
2364
+ )
2365
+ export.add_argument(
2366
+ "--no-media",
2367
+ action="store_true",
2368
+ help=f"Skip 剧集/<EP>/{EXPORT_MEDIA_DIRECTORY}/ and export text only.",
2369
+ )
2370
+ export.add_argument(
2371
+ "--overwrite",
2372
+ action="store_true",
2373
+ help="Replace an existing export directory at --out.",
2374
+ )
2375
+ return parser
2376
+
2377
+
2378
+ def main(argv: list[str] | None = None) -> int:
2379
+ args = build_parser().parse_args(argv)
2380
+ try:
2381
+ if args.command == "init":
2382
+ result = initialize_project(
2383
+ Path(args.path),
2384
+ title=args.title,
2385
+ language=args.language,
2386
+ prompt_language=args.prompt_language,
2387
+ aspect_ratio=args.aspect_ratio,
2388
+ episode_count=args.episode_count,
2389
+ target_seconds_per_episode=args.target_seconds_per_episode,
2390
+ )
2391
+ elif args.command == "status":
2392
+ result = project_status(Path(args.path))
2393
+ elif args.command == "publish":
2394
+ root = find_project(Path(args.path))
2395
+ result = publish_candidate(
2396
+ root,
2397
+ owner=args.owner,
2398
+ artifact_id=args.artifact_id,
2399
+ outputs=_parse_output_bindings(root, args.outputs),
2400
+ inputs=args.inputs,
2401
+ allow_unregistered_path=args.allow_unregistered_path,
2402
+ )
2403
+ elif args.command == "accept":
2404
+ result = record_creator_acceptance(
2405
+ Path(args.path),
2406
+ artifact_id=args.artifact_id,
2407
+ decision=args.decision,
2408
+ note=args.note,
2409
+ )
2410
+ elif args.command == "review":
2411
+ result = record_review(
2412
+ Path(args.path),
2413
+ artifact_id=args.artifact_id,
2414
+ verdict=args.verdict,
2415
+ reviewer=args.reviewer,
2416
+ note=args.note,
2417
+ )
2418
+ elif args.command == "set-authority":
2419
+ decision_path, decision_id = _parse_decision_ref(args.decision_ref)
2420
+ result = set_creator_authority(
2421
+ Path(args.path),
2422
+ field=args.field,
2423
+ decision_path=decision_path,
2424
+ decision_id=decision_id,
2425
+ )
2426
+ elif args.command == "package":
2427
+ result = build_delivery_package(
2428
+ Path(args.path),
2429
+ episode=args.episode,
2430
+ includes=args.includes,
2431
+ omissions=_parse_omissions(args.omissions),
2432
+ )
2433
+ elif args.command == "export":
2434
+ result = build_creator_export(
2435
+ Path(args.path),
2436
+ out=args.out,
2437
+ episodes=args.episodes,
2438
+ include_media=not args.no_media,
2439
+ overwrite=args.overwrite,
2440
+ )
2441
+ else:
2442
+ result = verify_delivery_package(Path(args.path), episode=args.episode)
2443
+ if result["status"] != "verified":
2444
+ print(json.dumps(result, ensure_ascii=True, sort_keys=True))
2445
+ return 1
2446
+ print(json.dumps(result, ensure_ascii=True, sort_keys=True))
2447
+ return 0
2448
+ except (KeyError, OSError, ValueError, ProjectConflictError, PackageBlockedError) as exc:
2449
+ print(str(exc), file=sys.stderr)
2450
+ return 2
2451
+
2452
+
2453
+ if __name__ == "__main__":
2454
+ raise SystemExit(main())