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