@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.
- package/README.md +8 -0
- package/docs/THIRD_PARTY_LICENSES.md +13 -0
- package/docs/attachment-storage.md +18 -0
- package/docs/director-skills.md +24 -0
- package/docs/long-task-continuation.md +51 -0
- package/docs/releases/2.5.0.md +13 -0
- package/docs/releases/2.6.0.md +11 -0
- package/docs/releases/2.7.0.md +21 -0
- package/lib/agent-runtime.d.ts +9 -1
- package/lib/agent-runtime.d.ts.map +1 -1
- package/lib/agent-runtime.js +117 -17
- package/lib/agent-runtime.js.map +1 -1
- package/lib/api/features/attachments-api.d.ts.map +1 -1
- package/lib/api/features/attachments-api.js +31 -1
- package/lib/api/features/attachments-api.js.map +1 -1
- package/lib/api.d.ts +1 -1
- package/lib/api.d.ts.map +1 -1
- package/lib/api.js +1 -1
- package/lib/api.js.map +1 -1
- package/lib/attachments/grouped-storage.d.ts +5 -0
- package/lib/attachments/grouped-storage.d.ts.map +1 -0
- package/lib/attachments/grouped-storage.js +47 -0
- package/lib/attachments/grouped-storage.js.map +1 -0
- package/lib/attachments/service.d.ts +17 -0
- package/lib/attachments/service.d.ts.map +1 -1
- package/lib/attachments/service.js +100 -8
- package/lib/attachments/service.js.map +1 -1
- package/lib/attachments/shared-storage.d.ts +7 -0
- package/lib/attachments/shared-storage.d.ts.map +1 -0
- package/lib/attachments/shared-storage.js +44 -0
- package/lib/attachments/shared-storage.js.map +1 -0
- package/lib/attachments/storage-admin.d.ts +6 -0
- package/lib/attachments/storage-admin.d.ts.map +1 -0
- package/lib/attachments/storage-admin.js +43 -0
- package/lib/attachments/storage-admin.js.map +1 -0
- package/lib/attachments/storage-types.d.ts +33 -0
- package/lib/attachments/storage-types.d.ts.map +1 -0
- package/lib/attachments/storage-types.js +2 -0
- package/lib/attachments/storage-types.js.map +1 -0
- package/lib/attachments/tool.d.ts.map +1 -1
- package/lib/attachments/tool.js +16 -5
- package/lib/attachments/tool.js.map +1 -1
- package/lib/channels/delivery-policy.d.ts.map +1 -1
- package/lib/channels/delivery-policy.js +7 -4
- package/lib/channels/delivery-policy.js.map +1 -1
- package/lib/channels/direct/message-format.js +418 -281
- package/lib/channels/direct/message-format.js.map +4 -4
- package/lib/channels/manager.d.ts +7 -3
- package/lib/channels/manager.d.ts.map +1 -1
- package/lib/channels/manager.js +73 -38
- package/lib/channels/manager.js.map +1 -1
- package/lib/channels/weixin/api.d.ts.map +1 -1
- package/lib/channels/weixin/api.js +7 -2
- package/lib/channels/weixin/api.js.map +1 -1
- package/lib/channels/weixin/errors.d.ts +14 -0
- package/lib/channels/weixin/errors.d.ts.map +1 -0
- package/lib/channels/weixin/errors.js +29 -0
- package/lib/channels/weixin/errors.js.map +1 -0
- package/lib/channels/weixin/poll.d.ts +14 -0
- package/lib/channels/weixin/poll.d.ts.map +1 -0
- package/lib/channels/weixin/poll.js +50 -0
- package/lib/channels/weixin/poll.js.map +1 -0
- package/lib/client-api.d.ts +1 -23
- package/lib/client-api.d.ts.map +1 -1
- package/lib/client-api.js.map +1 -1
- package/lib/client-controller.d.ts.map +1 -1
- package/lib/client-controller.js +3 -3
- package/lib/client-controller.js.map +1 -1
- package/lib/client.d.ts.map +1 -1
- package/lib/client.js +1078 -679
- package/lib/client.js.map +4 -4
- package/lib/collaboration/composition.d.ts.map +1 -1
- package/lib/collaboration/composition.js +32 -8
- package/lib/collaboration/composition.js.map +1 -1
- package/lib/collaboration/domain.d.ts +8 -0
- package/lib/collaboration/domain.d.ts.map +1 -1
- package/lib/collaboration/domain.js.map +1 -1
- package/lib/collaboration/service.d.ts.map +1 -1
- package/lib/collaboration/service.js +38 -5
- package/lib/collaboration/service.js.map +1 -1
- package/lib/concern-notification.d.ts.map +1 -1
- package/lib/concern-notification.js +2 -1
- package/lib/concern-notification.js.map +1 -1
- package/lib/concern-tool.js +1 -1
- package/lib/concern-tool.js.map +1 -1
- package/lib/execution/reply-progress.d.ts.map +1 -1
- package/lib/execution/reply-progress.js +4 -2
- package/lib/execution/reply-progress.js.map +1 -1
- package/lib/execution/service.d.ts +1 -1
- package/lib/execution/service.d.ts.map +1 -1
- package/lib/execution/service.js +4 -7
- package/lib/execution/service.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +7 -1
- package/lib/index.js.map +1 -1
- package/lib/memory-reflection.js +2 -2
- package/lib/memory-reflection.js.map +1 -1
- package/lib/message-source.d.ts +25 -0
- package/lib/message-source.d.ts.map +1 -0
- package/lib/message-source.js +8 -0
- package/lib/message-source.js.map +1 -0
- package/lib/observation-loop.js +4 -4
- package/lib/observation-loop.js.map +1 -1
- package/lib/pendant/reader.js +1 -1
- package/lib/pendant/reader.js.map +1 -1
- package/lib/pendant/settings-panel.js +1 -1
- package/lib/pendant/settings-panel.js.map +1 -1
- package/lib/persona/service.js +1 -1
- package/lib/persona/service.js.map +1 -1
- package/lib/requirements/attachments.d.ts +7 -0
- package/lib/requirements/attachments.d.ts.map +1 -0
- package/lib/requirements/attachments.js +26 -0
- package/lib/requirements/attachments.js.map +1 -0
- package/lib/requirements/domain.d.ts +1 -1
- package/lib/requirements/domain.d.ts.map +1 -1
- package/lib/requirements/service.d.ts.map +1 -1
- package/lib/requirements/service.js +4 -1
- package/lib/requirements/service.js.map +1 -1
- package/lib/scheduler/channel-origin.d.ts +7 -0
- package/lib/scheduler/channel-origin.d.ts.map +1 -0
- package/lib/scheduler/channel-origin.js +27 -0
- package/lib/scheduler/channel-origin.js.map +1 -0
- package/lib/scheduler/continuations.d.ts +29 -0
- package/lib/scheduler/continuations.d.ts.map +1 -0
- package/lib/scheduler/continuations.js +286 -0
- package/lib/scheduler/continuations.js.map +1 -0
- package/lib/scheduler/domain.d.ts +40 -0
- package/lib/scheduler/domain.d.ts.map +1 -1
- package/lib/scheduler/final-reply.d.ts +13 -0
- package/lib/scheduler/final-reply.d.ts.map +1 -0
- package/lib/scheduler/final-reply.js +21 -0
- package/lib/scheduler/final-reply.js.map +1 -0
- package/lib/scheduler/service.d.ts +2 -0
- package/lib/scheduler/service.d.ts.map +1 -1
- package/lib/scheduler/service.js +30 -1
- package/lib/scheduler/service.js.map +1 -1
- package/lib/scheduler/wakeup-context.d.ts +7 -0
- package/lib/scheduler/wakeup-context.d.ts.map +1 -0
- package/lib/scheduler/wakeup-context.js +14 -0
- package/lib/scheduler/wakeup-context.js.map +1 -0
- package/lib/scheduler/wakeup.d.ts +11 -0
- package/lib/scheduler/wakeup.d.ts.map +1 -0
- package/lib/scheduler/wakeup.js +52 -0
- package/lib/scheduler/wakeup.js.map +1 -0
- package/lib/skills/builtin-resources.d.ts +6 -0
- package/lib/skills/builtin-resources.d.ts.map +1 -0
- package/lib/skills/builtin-resources.js +47 -0
- package/lib/skills/builtin-resources.js.map +1 -0
- package/lib/skills/builtin.d.ts +2 -5
- package/lib/skills/builtin.d.ts.map +1 -1
- package/lib/skills/builtin.js +11 -0
- package/lib/skills/builtin.js.map +1 -1
- package/lib/skills/director-builtins.d.ts +8 -0
- package/lib/skills/director-builtins.d.ts.map +1 -0
- package/lib/skills/director-builtins.js +65 -0
- package/lib/skills/director-builtins.js.map +1 -0
- package/lib/skills/drama-builtin.d.ts +3 -0
- package/lib/skills/drama-builtin.d.ts.map +1 -0
- package/lib/skills/drama-builtin.js +49 -0
- package/lib/skills/drama-builtin.js.map +1 -0
- package/lib/skills/long-task-continuation.d.ts +4 -0
- package/lib/skills/long-task-continuation.d.ts.map +1 -0
- package/lib/skills/long-task-continuation.js +43 -0
- package/lib/skills/long-task-continuation.js.map +1 -0
- package/lib/skills/service.d.ts.map +1 -1
- package/lib/skills/service.js +8 -3
- package/lib/skills/service.js.map +1 -1
- package/lib/skills/viora-canvas-builtin.d.ts +3 -0
- package/lib/skills/viora-canvas-builtin.d.ts.map +1 -0
- package/lib/skills/viora-canvas-builtin.js +19 -0
- package/lib/skills/viora-canvas-builtin.js.map +1 -0
- package/lib/storage/migrate-layout.d.ts.map +1 -1
- package/lib/storage/migrate-layout.js +13 -4
- package/lib/storage/migrate-layout.js.map +1 -1
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +2 -0
- package/lib/store.js.map +1 -1
- package/lib/tasks/context.js +1 -0
- package/lib/tasks/context.js.map +1 -1
- package/lib/tasks/continuation-yield.d.ts +4 -0
- package/lib/tasks/continuation-yield.d.ts.map +1 -0
- package/lib/tasks/continuation-yield.js +27 -0
- package/lib/tasks/continuation-yield.js.map +1 -0
- package/lib/tasks/continuation.d.ts +13 -0
- package/lib/tasks/continuation.d.ts.map +1 -0
- package/lib/tasks/continuation.js +106 -0
- package/lib/tasks/continuation.js.map +1 -0
- package/lib/tasks/domain.d.ts +2 -0
- package/lib/tasks/domain.d.ts.map +1 -1
- package/lib/tasks/scheduling.d.ts.map +1 -1
- package/lib/tasks/scheduling.js +7 -0
- package/lib/tasks/scheduling.js.map +1 -1
- package/lib/tasks/service.d.ts +2 -1
- package/lib/tasks/service.d.ts.map +1 -1
- package/lib/tasks/service.js +19 -1
- package/lib/tasks/service.js.map +1 -1
- package/lib/ui/attachment-storage.d.ts +2 -0
- package/lib/ui/attachment-storage.d.ts.map +1 -0
- package/lib/ui/attachment-storage.js +59 -0
- package/lib/ui/attachment-storage.js.map +1 -0
- package/lib/ui/capability-editor.js +1 -1
- package/lib/ui/capability-editor.js.map +1 -1
- package/lib/ui/companion-access-panel.js +1 -1
- package/lib/ui/companion-access-panel.js.map +1 -1
- package/lib/ui/companion-create.js +1 -1
- package/lib/ui/companion-create.js.map +1 -1
- package/lib/ui/companion-picker.js +1 -1
- package/lib/ui/companion-picker.js.map +1 -1
- package/lib/ui/identity-editor.js +1 -1
- package/lib/ui/identity-editor.js.map +1 -1
- package/lib/ui/mcp-capabilities.js +1 -1
- package/lib/ui/mcp-capabilities.js.map +1 -1
- package/lib/ui/mcp-panel.js +1 -1
- package/lib/ui/mcp-panel.js.map +1 -1
- package/lib/ui/memory/concern-board.js +2 -2
- package/lib/ui/memory/concern-board.js.map +1 -1
- package/lib/ui/memory/concern-deletion.d.ts +1 -1
- package/lib/ui/memory/concern-deletion.d.ts.map +1 -1
- package/lib/ui/memory/concern-deletion.js +9 -4
- package/lib/ui/memory/concern-deletion.js.map +1 -1
- package/lib/ui/memory/concern-panel.js +1 -1
- package/lib/ui/memory/concern-panel.js.map +1 -1
- package/lib/ui/memory/memory-library.js +1 -1
- package/lib/ui/memory/memory-library.js.map +1 -1
- package/lib/ui/memory/memory-panel.js +1 -1
- package/lib/ui/memory/memory-panel.js.map +1 -1
- package/lib/ui/partner-components.js +1 -1
- package/lib/ui/partner-components.js.map +1 -1
- package/lib/ui/requirement-board.js +1 -1
- package/lib/ui/requirement-board.js.map +1 -1
- package/lib/ui/requirement-dialog.js +1 -1
- package/lib/ui/requirement-dialog.js.map +1 -1
- package/lib/ui/requirement-tasks-panel.js +1 -1
- package/lib/ui/requirement-tasks-panel.js.map +1 -1
- package/lib/ui/schedule-panel.d.ts.map +1 -1
- package/lib/ui/schedule-panel.js +20 -4
- package/lib/ui/schedule-panel.js.map +1 -1
- package/lib/ui/skills-panel.js +1 -1
- package/lib/ui/skills-panel.js.map +1 -1
- package/lib/ui/storage-settings.d.ts.map +1 -1
- package/lib/ui/storage-settings.js +2 -1
- package/lib/ui/storage-settings.js.map +1 -1
- package/lib/ui/task-board-stage.js +1 -1
- package/lib/ui/task-board-stage.js.map +1 -1
- package/lib/ui/workspace-components.js +1 -1
- package/lib/ui/workspace-components.js.map +1 -1
- package/lib/ui/workspace-navigation.d.ts +16 -16
- package/lib/ui/workspace-navigation.js +1 -1
- package/lib/ui/workspace-navigation.js.map +1 -1
- package/package.json +55 -41
- package/resources/skills/cinematic-director/manifest.json +205 -0
- package/resources/skills/cinematic-director/upstream/CHANGELOG.md +213 -0
- package/resources/skills/cinematic-director/upstream/CONTRIBUTING.md +316 -0
- package/resources/skills/cinematic-director/upstream/LICENSE +21 -0
- package/resources/skills/cinematic-director/upstream/README.md +507 -0
- package/resources/skills/cinematic-director/upstream/SKILL.md +420 -0
- package/resources/skills/cinematic-director/upstream/assets/beat-sheet-template.md +97 -0
- package/resources/skills/cinematic-director/upstream/assets/director-book-template.md +254 -0
- package/resources/skills/cinematic-director/upstream/assets/edit-timeline-template.md +147 -0
- package/resources/skills/cinematic-director/upstream/assets/keyframe-prompt-template.md +334 -0
- package/resources/skills/cinematic-director/upstream/assets/qc-checklist.md +153 -0
- package/resources/skills/cinematic-director/upstream/assets/shot-plan-template.md +95 -0
- package/resources/skills/cinematic-director/upstream/assets/sound-plan-template.md +181 -0
- package/resources/skills/cinematic-director/upstream/assets/video-prompt-template.md +305 -0
- package/resources/skills/cinematic-director/upstream/evals/run.md +69 -0
- package/resources/skills/cinematic-director/upstream/references/ai-video-tool-adapters.md +527 -0
- package/resources/skills/cinematic-director/upstream/references/blocking-and-staging.md +424 -0
- package/resources/skills/cinematic-director/upstream/references/cinematic-language.md +595 -0
- package/resources/skills/cinematic-director/upstream/references/continuity-bible.md +410 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/01_spielberg.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/02_hitchcock.md +193 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/03_kubrick.md +196 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/04_kurosawa.md +196 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/05_scorsese.md +195 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/06_fellini.md +197 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/07_bergman.md +197 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/08_tarkovsky.md +184 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/09_wong_kar_wai.md +189 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/10_nolan.md +185 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/11_villeneuve.md +188 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/12_fincher.md +185 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/13_refn.md +186 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/14_bi_gan.md +188 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/15_zhang_yimou.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/16_hou_hsiao_hsien.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/17_park_chan_wook.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/18_malick.md +191 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/19_michael_mann.md +192 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/20_coen_brothers.md +194 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/README.md +89 -0
- package/resources/skills/cinematic-director/upstream/references/director_styles/example_comparisons.md +140 -0
- package/resources/skills/cinematic-director/upstream/references/editing-and-assembly.md +263 -0
- package/resources/skills/cinematic-director/upstream/references/failure-modes.md +594 -0
- package/resources/skills/cinematic-director/upstream/references/genre-playbooks.md +407 -0
- package/resources/skills/cinematic-director/upstream/references/image-model-adapters.md +290 -0
- package/resources/skills/cinematic-director/upstream/references/lighting-and-color.md +349 -0
- package/resources/skills/cinematic-director/upstream/references/product-and-macro.md +207 -0
- package/resources/skills/cinematic-director/upstream/references/production-workflow.md +238 -0
- package/resources/skills/cinematic-director/upstream/references/prompt-lexicon.md +602 -0
- package/resources/skills/cinematic-director/upstream/references/sound-and-dialogue.md +364 -0
- package/resources/skills/director-skills/manifest.json +541 -0
- package/resources/skills/director-skills/upstream/CHANGELOG.md +32 -0
- package/resources/skills/director-skills/upstream/LICENSE +21 -0
- package/resources/skills/director-skills/upstream/MANIFEST.md +366 -0
- package/resources/skills/director-skills/upstream/README.md +251 -0
- package/resources/skills/director-skills/upstream/RESEARCH_PROVENANCE.md +37 -0
- package/resources/skills/director-skills/upstream/SKILL.md +114 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/SKILL.md +81 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/checklists/continuity_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/character_continuity_update.md +9 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/examples/location_continuity_update.md +9 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/continuity_system.md +21 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/golden_image_workflow.md +29 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/identity_anchors.md +14 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/references/visual_bible_system.md +15 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/character_profile.md +15 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/continuity_checklist.md +11 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/costume_profile.md +12 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/location_profile.md +14 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/prop_profile.md +13 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/templates/visual_style_bible.md +15 -0
- package/resources/skills/director-skills/upstream/skills/character-location-prop-bible/tests/continuity_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/SKILL.md +80 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/checklists/image_prompt_quality_checklist.md +10 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/examples/shot_list_to_image_prompts.md +4 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/image_prompt_components.md +22 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/lighting_and_photorealism.md +19 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/negative_prompt_patterns.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/references/prompt_anatomy.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/character_reference_sheet_prompt.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/cinematic_still_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/keyframe_prompt.md +6 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/location_reference_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/templates/prop_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-image-prompting/tests/image_prompt_completeness_tests.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/SKILL.md +84 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/checklists/video_prompt_quality_checklist.md +9 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/examples/image_prompt_to_video_prompt.md +3 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/ai_film_production_pipeline.md +21 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/audio_motion_timing.md +16 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/camera_lens_movement.md +26 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/negative_prompt_patterns.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/prompt_anatomy.md +18 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/references/video_prompt_components.md +25 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/image_to_video_prompt.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/text_to_video_prompt.md +5 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/templates/video_beat_prompt.md +9 -0
- package/resources/skills/director-skills/upstream/skills/cinematic-video-prompting/tests/video_prompt_completeness_tests.md +5 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/SKILL.md +82 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/checklists/model_export_checklist.md +9 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/failed_model_transfer_diagnosis.md +5 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/image_prompt_translation.md +10 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/same_scene_across_models.md +9 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/universal_prompt_to_model_specific_exports.md +14 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/examples/video_prompt_translation.md +9 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/failure_modes_by_model.md +13 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/image_model_profiles.md +226 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_capability_matrix.md +24 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/model_update_protocol.md +10 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/parameter_compatibility.md +20 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/prompt_translation_rules.md +18 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/references/video_model_profiles.md +251 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_comparison_table.md +4 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/model_specific_prompt_export.md +21 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/multi_model_export_package.md +18 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/prompt_translation_report.md +13 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/templates/universal_prompt_brief.md +25 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/model_routing_tests.md +8 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/prompt_translation_tests.md +7 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/research_provenance_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/model-adaptation/tests/unknown_capability_tests.md +7 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/SKILL.md +82 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/diagnostic_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/checklists/release_quality_checklist.md +9 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/failed_output_to_revised_prompt.md +10 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/examples/final_prompt_package_export.md +10 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/failure_modes.md +13 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/iteration_diagnostics.md +17 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/revision_loops.md +17 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/references/story_voice_release_qc.md +30 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/failed_output_intake.md +13 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/final_production_package.md +11 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/templates/prompt_revision_report.md +17 -0
- package/resources/skills/director-skills/upstream/skills/prompt-iteration-and-diagnostics/tests/prompt_quality_checklist_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/SKILL.md +81 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/checklists/scene_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/examples/scene_to_screenplay_excerpt.md +18 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/dialogue_and_subtext.md +15 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/fountain_workflow.md +29 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/scene_construction.md +20 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/references/screenplay_format.md +18 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/beat_sheet.md +6 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/dialogue_pass.md +9 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/fountain_scene.md +25 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/templates/screenplay_scene.md +19 -0
- package/resources/skills/director-skills/upstream/skills/screenplay-and-scene-writing/tests/screenplay_scene_tests.md +6 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/SKILL.md +83 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/checklists/concept_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/examples/idea_to_logline.md +13 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/examples/logline_to_outline.md +10 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/ai_storytelling_workflow.md +46 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/continuity_handoff.md +8 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/screenplay_structure.md +19 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/references/story_development_workflow.md +23 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/beat_sheet.md +7 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/idea_intake.md +16 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/logline.md +10 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_film_treatment.md +23 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/short_story_plan.md +22 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_premise.md +19 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/templates/story_revision_audit.md +22 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/tests/output_format_tests.md +3 -0
- package/resources/skills/director-skills/upstream/skills/short-film-development/tests/routing_tests.md +7 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/SKILL.md +77 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/checklists/shotlist_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/examples/screenplay_scene_to_shot_list.md +8 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/camera_plan.md +11 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/production_breakdown.md +11 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/references/shotlist_schema.md +21 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/asset_list.md +7 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/camera_plan.md +10 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/scene_breakdown.md +16 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/templates/shot_list.md +5 -0
- package/resources/skills/director-skills/upstream/skills/shotlist-and-visual-breakdown/tests/screenplay_to_shotlist_tests.md +5 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/SKILL.md +77 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/checklists/style_quality_checklist.md +8 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/examples/genre_to_visual_direction.md +15 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/camera_lens_movement.md +26 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/cinematic_language.md +32 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/genre_style_tone.md +17 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/references/lighting_and_photorealism.md +19 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/templates/visual_style_bible.md +33 -0
- package/resources/skills/director-skills/upstream/skills/style-cinematography-director/tests/style_translation_tests.md +5 -0
- package/resources/skills/director-skills/upstream/tests/master_router_tests.md +80 -0
- package/resources/skills/drama-production/manifest.json +933 -0
- package/resources/skills/drama-production/upstream/LICENSE +21 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/SKILL.md +153 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/creator-decision.example.jsonl +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/app.js +1263 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/index.html +114 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/dashboard/styles.css +297 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/production-observation.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/project-template/short-drama.json +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/assets/reference-observation.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/audience-reveal.md +21 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/contract-and-ownership.md +45 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-documents.md +230 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/creator-workflow.md +98 -0
- 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
- 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
- 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
- package/resources/skills/drama-production/upstream/skills/short-drama/references/form-cards//345/256/236/346/213/215.md +75 -0
- 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
- 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
- package/resources/skills/drama-production/upstream/skills/short-drama/references/knowhow-index.md +85 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/look-development.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/pickup-and-alternate.md +24 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/production-form-profiles.md +126 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/reference-roles.md +53 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/references/runtime-preflight.md +24 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/creator_markdown_check.py +1413 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/dashboard_server.py +1798 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/project_tool.py +2454 -0
- package/resources/skills/drama-production/upstream/skills/short-drama/scripts/selftest.py +287 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/SKILL.md +76 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/character-look.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/continuity.example.jsonl +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/decisions.example.jsonl +5 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/location-view.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/occurrences.example.jsonl +5 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/assets/prop-state.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/characters.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/examples/minimal/looks.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/asset-review-checklist.md +82 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/character-and-look.md +190 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-delta.md +120 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/continuity-lock.md +122 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/identity-vs-variant.md +140 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/location-and-view.md +84 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/occurrence-extraction.md +108 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/prop-and-state.md +126 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/stage-contract.md +43 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/references/voice-direction.md +179 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/asset_check.py +267 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-assets/scripts/selftest.py +89 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/SKILL.md +157 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/adaptation-map.example.jsonl +1 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/creative-brief.md +120 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/episode-map.jsonl +1 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/assets/story-engine.md +181 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/adaptation-craft.md +219 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/creative-reference-intake.md +57 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/director-brief-craft.md +113 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/episode-design.md +509 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-and-hook-playbook.md +240 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/genre-cards.md +92 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/mechanism-loop.md +282 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/multi-episode-intake.md +128 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/premise-devices.md +211 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/reveal-reversal-payoff.md +99 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/serial-character-and-memory.md +293 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/stage-contract.md +88 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/references/story-craft.md +359 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/episode_intake.py +699 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-develop/scripts/selftest.py +47 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/SKILL.md +146 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/README.md +60 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/package.json +15 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/remotion.config.ts +6 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Root.tsx +26 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/Subtitles.tsx +88 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/font.ts +46 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/index.ts +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/src/schema.ts +32 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets/remotion/tsconfig.json +13 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/assets//345/211/252/350/276/221/345/215/225.md +43 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/cut-craft.md +38 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/delivery-verify.md +29 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/generated-footage.md +54 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/sound-and-subtitles.md +60 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/references/stage-contract.md +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/edit_tool.py +1156 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-edit/scripts/selftest.py +520 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/SKILL.md +73 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompt-spec.jsonl.md +187 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/image-prompts.md +42 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-frame-spec.jsonl.md +128 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/assets/lookdev-prompts.md +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/examples/minimal-image-prompt-specs.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/character-and-look.md +153 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/common-recipe.md +241 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/edit-and-revision.md +110 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/location-plate.md +151 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/look-and-state-variant.md +97 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/lookdev-frame.md +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/production-sheet-recipes.md +177 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/prop-plate.md +137 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/review-and-fixtures.md +92 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/references/stage-contract.md +32 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/image_prompt_check.py +443 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-image-prompts/scripts/selftest.py +95 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/SKILL.md +245 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/assets/episode-candidate.example.jsonl +3 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-triage.md +116 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/adaptation-value.md +124 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/aggregation-and-entities.md +155 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/chapter-extraction.md +144 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/references/stage-contract.md +67 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/novel_index.py +651 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-novel-analyze/scripts/selftest.py +51 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/SKILL.md +183 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/adapter-contract.md +219 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/gpt-image-2.md +24 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-h3-video.md +86 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-music.md +25 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/minimax-speech.md +41 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/references/providers/seedance.md +64 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/fixture_adapter.py +77 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/production_tool.py +2205 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/provider_adapters.py +1747 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-produce/scripts/selftest.py +135 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/SKILL.md +118 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/finding-template.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/supersession-decision.example.json +42 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/assets/verdict-template.json +32 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-findings.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/examples/minimal-verdict.json +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/anti-template-repair.md +109 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/production-quality-gates.md +276 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/project-calibration.md +83 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/review-method.md +81 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-assets-prompts.md +111 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-source-analysis.md +108 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-story-script.md +264 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/rubric-visual-motion.md +184 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/references/stage-contract.md +28 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/review_check.py +325 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-review/scripts/selftest.py +87 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/SKILL.md +175 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-audition.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/coverage-template.json +49 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-prompts.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/keyframe-template.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/revision-lineage.fragment.json +12 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/scene-visual-plan.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/assets/shot-template.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/blocking-playbooks.md +216 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/comic-keyframe-lexicon.md +156 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/coverage-audition.md +34 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/keyframe-craft.md +174 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/lighting-craft.md +39 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/production-shot-grammar.md +279 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/review-and-fixtures.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/scene-visual-plan.md +30 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/screenplay-to-keyframe-example.md +155 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-craft.md +266 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/shot-revision-identity.md +25 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/references/stage-contract.md +57 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/selftest.py +191 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-storyboard/scripts/storyboard_check.py +824 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/SKILL.md +154 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/coverage-scope.fragment.json +21 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/delivery-container.jsonl.md +101 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-spec.jsonl.md +217 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/motion-terminal.example.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/music-spec.jsonl.md +31 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/performance.fragment.json +38 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/assets/video-prompts.md +67 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/examples/minimal-music-specs.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/camera-audio-continuity.md +259 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/delivery-profile.md +273 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/generability.md +93 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/minimax-h3.md +253 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/motion-recipe.md +315 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/performance-action-timing.md +209 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/production-prompt-grammar.md +304 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/review-and-fixtures.md +139 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.0.md +50 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/seedance-2.5.md +122 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/stage-contract.md +60 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/references/target-model-profile.md +116 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/container_check.py +329 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/motion_timing_check.py +450 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/music_spec_check.py +327 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-video-prompts/scripts/selftest.py +82 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/SKILL.md +99 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/agents/openai.yaml +4 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/beats.jsonl +2 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card-standalone.json +120 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/episode-card.json +55 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/screenplay.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/assets/voice-record-sheet.jsonl.md +89 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/dialogue-craft.md +526 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/production-format-dialect.md +273 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-handoff-capsule.md +25 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/scene-sound-dramaturgy.md +43 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/screenplay-format.md +266 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/script-craft.md +745 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/stage-contract.md +36 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/references/substitutable-realization.md +107 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/duration_estimate.py +309 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/screenplay_index.py +1091 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/selftest.py +229 -0
- package/resources/skills/drama-production/upstream/skills/short-drama-write/scripts/voice_sheet_check.py +416 -0
- package/resources/skills/viora-canvas-production/manifest.json +23 -0
- package/resources/skills/viora-canvas-production/references/canvas-contract.md +50 -0
- package/resources/skills/viora-canvas-production/references/creative-production.md +50 -0
- package/resources/skills/viora-canvas-production/references/generation-delivery.md +36 -0
- 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())
|