@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,1139 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Maintain one structured story state and its deterministic Markdown views.
|
|
3
|
+
|
|
4
|
+
The language model supplies compact semantic JSON. This tool validates and
|
|
5
|
+
merges that input in memory, renders every derived view, then atomically writes
|
|
6
|
+
``_tracking-state.json`` last as the single commit point. One book project has
|
|
7
|
+
one serial writer; concurrent commits are intentionally unsupported.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import copy
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import unicodedata
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
INPUT_SCHEMA_VERSION = 1
|
|
26
|
+
TRACKING_SCHEMA_VERSION = 4
|
|
27
|
+
DELTA_TARGET_BYTES = 1536
|
|
28
|
+
DELTA_MAX_BYTES = 3072
|
|
29
|
+
CONTEXT_TARGET_BYTES = 8192
|
|
30
|
+
CONTEXT_MAX_BYTES = 12288
|
|
31
|
+
SNAPSHOT_TARGET_BYTES = 4096
|
|
32
|
+
SNAPSHOT_MAX_BYTES = 8192
|
|
33
|
+
|
|
34
|
+
CONTEXT_HEADINGS = (
|
|
35
|
+
"## 当前位置",
|
|
36
|
+
"## 长期约束",
|
|
37
|
+
"## 核心角色状态",
|
|
38
|
+
"## 活跃伏笔",
|
|
39
|
+
"## 近三章速记",
|
|
40
|
+
"## 下一章承诺",
|
|
41
|
+
"## 连贯性风险",
|
|
42
|
+
)
|
|
43
|
+
FORESHADOW_STATUSES = ("已埋", "已回收", "已过期", "放弃")
|
|
44
|
+
FORESHADOW_IMPORTANCE = ("高", "中", "低")
|
|
45
|
+
REVEAL_STATUSES = ("未揭示", "部分揭示", "已揭示")
|
|
46
|
+
INVALID_FILE_CHARS = re.compile(r"[<>:\"/\\|?*\x00-\x1f]")
|
|
47
|
+
FORESHADOW_ID = re.compile(r"^F\d{3,}$")
|
|
48
|
+
EVENT_ID = re.compile(r"^E\d{3,}$")
|
|
49
|
+
WINDOWS_RESERVED_NAMES = {
|
|
50
|
+
"CON",
|
|
51
|
+
"PRN",
|
|
52
|
+
"AUX",
|
|
53
|
+
"NUL",
|
|
54
|
+
*(f"COM{index}" for index in range(1, 10)),
|
|
55
|
+
*(f"LPT{index}" for index in range(1, 10)),
|
|
56
|
+
}
|
|
57
|
+
RETIRED_TRACKING_PATHS = (
|
|
58
|
+
"_tracking-meta.json",
|
|
59
|
+
"阶段摘要.md",
|
|
60
|
+
"角色状态.md",
|
|
61
|
+
"时间线.md",
|
|
62
|
+
"摘要",
|
|
63
|
+
"时间线/事件库.json",
|
|
64
|
+
)
|
|
65
|
+
RETIRED_ARCHIVE_DIR = "_旧追踪存档"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TrackingError(ValueError):
|
|
69
|
+
"""Expected validation or tracking-state error."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def require(condition: bool, message: str) -> None:
|
|
73
|
+
if not condition:
|
|
74
|
+
raise TrackingError(message)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def as_mapping(value: object, label: str) -> dict[str, Any]:
|
|
78
|
+
require(isinstance(value, dict), f"{label} must be a JSON object")
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def as_list(value: object, label: str) -> list[Any]:
|
|
83
|
+
require(isinstance(value, list), f"{label} must be a JSON array")
|
|
84
|
+
return value
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def as_int(value: object, label: str, *, minimum: int = 0) -> int:
|
|
88
|
+
require(isinstance(value, int) and not isinstance(value, bool), f"{label} must be an integer")
|
|
89
|
+
require(value >= minimum, f"{label} must be >= {minimum}")
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def require_known_keys(mapping: dict[str, Any], allowed: set[str], label: str) -> None:
|
|
94
|
+
unknown = set(mapping) - allowed
|
|
95
|
+
require(not unknown, f"{label} contains unsupported fields: {', '.join(sorted(unknown))}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def clean_text(value: object, label: str, *, allow_empty: bool = False, max_bytes: int = 768) -> str:
|
|
99
|
+
require(isinstance(value, str), f"{label} must be a string")
|
|
100
|
+
cleaned = " ".join(value.replace("|", "|").split())
|
|
101
|
+
require(allow_empty or bool(cleaned), f"{label} must not be empty")
|
|
102
|
+
require(len(cleaned.encode("utf-8")) <= max_bytes, f"{label} exceeds {max_bytes} bytes")
|
|
103
|
+
return cleaned
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def clean_string_list(
|
|
107
|
+
value: object,
|
|
108
|
+
label: str,
|
|
109
|
+
*,
|
|
110
|
+
maximum: int | None = None,
|
|
111
|
+
item_max_bytes: int = 384,
|
|
112
|
+
) -> list[str]:
|
|
113
|
+
values = as_list(value, label)
|
|
114
|
+
if maximum is not None:
|
|
115
|
+
require(len(values) <= maximum, f"{label} may contain at most {maximum} items")
|
|
116
|
+
return [clean_text(item, f"{label}[{index}]", max_bytes=item_max_bytes) for index, item in enumerate(values)]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def safe_file_component(value: object, label: str) -> str:
|
|
120
|
+
name = unicodedata.normalize("NFC", clean_text(value, label, max_bytes=180))
|
|
121
|
+
require(not INVALID_FILE_CHARS.search(name), f"{label} contains an invalid filename character")
|
|
122
|
+
require(name not in {".", ".."} and not name.endswith((".", " ")), f"{label} is not a safe filename")
|
|
123
|
+
require(name.split(".", 1)[0].upper() not in WINDOWS_RESERVED_NAMES, f"{label} is reserved on Windows")
|
|
124
|
+
return name
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def portable_name_key(name: str) -> str:
|
|
128
|
+
return unicodedata.normalize("NFC", name).casefold()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def byte_size(text: str) -> int:
|
|
132
|
+
return len(text.encode("utf-8"))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def emit(text: str, *, error: bool = False) -> None:
|
|
136
|
+
"""Write UTF-8 bytes directly.
|
|
137
|
+
|
|
138
|
+
Windows 的文本 stdout 是 cp1252(含中文即 UnicodeEncodeError),stderr 默认
|
|
139
|
+
backslashreplace(中文被转义成反斜杠码位,作者看不懂)。两条路都要绕开。
|
|
140
|
+
"""
|
|
141
|
+
stream = sys.stderr if error else sys.stdout
|
|
142
|
+
stream.flush()
|
|
143
|
+
stream.buffer.write((text + "\n").encode("utf-8"))
|
|
144
|
+
stream.buffer.flush()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def read_json(path: Path) -> object:
|
|
148
|
+
try:
|
|
149
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
150
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
151
|
+
raise TrackingError(f"unable to read JSON {path}: {exc}") from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def json_payload(document: object) -> str:
|
|
155
|
+
return json.dumps(document, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def atomic_write_text(path: Path, payload: str) -> None:
|
|
159
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o644
|
|
161
|
+
fd, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
|
162
|
+
temporary = Path(temporary_name)
|
|
163
|
+
try:
|
|
164
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
165
|
+
handle.write(payload)
|
|
166
|
+
handle.flush()
|
|
167
|
+
os.fsync(handle.fileno())
|
|
168
|
+
os.chmod(temporary, mode)
|
|
169
|
+
os.replace(temporary, path)
|
|
170
|
+
finally:
|
|
171
|
+
temporary.unlink(missing_ok=True)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def write_if_changed(path: Path, payload: str) -> None:
|
|
175
|
+
try:
|
|
176
|
+
if path.read_text(encoding="utf-8") == payload:
|
|
177
|
+
return
|
|
178
|
+
except FileNotFoundError:
|
|
179
|
+
pass
|
|
180
|
+
atomic_write_text(path, payload)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def tracking_root(project: Path) -> Path:
|
|
184
|
+
return project.resolve() / "追踪"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def state_path(project: Path) -> Path:
|
|
188
|
+
return tracking_root(project) / "_tracking-state.json"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def delta_path(tracking: Path, chapter: int) -> Path:
|
|
192
|
+
width = max(3, len(str(chapter)))
|
|
193
|
+
return tracking / "逐章记录" / f"第{chapter:0{width}d}章.md"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def find_retired_tracking_paths(tracking: Path) -> list[str]:
|
|
197
|
+
found = [relative for relative in RETIRED_TRACKING_PATHS if (tracking / relative).exists()]
|
|
198
|
+
found.extend(sorted(path.name for path in tracking.glob("基线_截至第*章.md")))
|
|
199
|
+
return found
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def require_no_retired_tracking_paths(tracking: Path) -> None:
|
|
203
|
+
found = find_retired_tracking_paths(tracking)
|
|
204
|
+
require(not found, f"retired tracking files are not supported: {', '.join(found)}")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def archive_retired_tracking_paths(tracking: Path) -> list[str]:
|
|
208
|
+
"""Move a pre-transaction 追踪/ aside so init can build the current protocol in place.
|
|
209
|
+
|
|
210
|
+
Nothing is parsed or converted: the old files are kept verbatim for the author to
|
|
211
|
+
consult, and the new state is reconstructed from the init document alone.
|
|
212
|
+
"""
|
|
213
|
+
retired = find_retired_tracking_paths(tracking)
|
|
214
|
+
if not retired:
|
|
215
|
+
return []
|
|
216
|
+
archive = tracking / RETIRED_ARCHIVE_DIR
|
|
217
|
+
for relative in retired:
|
|
218
|
+
require(
|
|
219
|
+
not (archive / relative).exists(),
|
|
220
|
+
f"追踪/{RETIRED_ARCHIVE_DIR}/{relative} already exists; move it away before initializing",
|
|
221
|
+
)
|
|
222
|
+
# 先全量校验再搬运;中断后重跑时已搬走的条目不再出现在待搬列表里,可直接续做。
|
|
223
|
+
for relative in retired:
|
|
224
|
+
target = archive / relative
|
|
225
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
os.replace(tracking / relative, target)
|
|
227
|
+
return retired
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def validate_position(value: object, label: str = "context.position") -> dict[str, Any]:
|
|
231
|
+
position = as_mapping(value, label)
|
|
232
|
+
require_known_keys(position, {"volume", "volume_start_chapter", "story_time", "scene"}, label)
|
|
233
|
+
return {
|
|
234
|
+
"volume": safe_file_component(position.get("volume"), f"{label}.volume"),
|
|
235
|
+
"volume_start_chapter": as_int(
|
|
236
|
+
position.get("volume_start_chapter"), f"{label}.volume_start_chapter", minimum=1
|
|
237
|
+
),
|
|
238
|
+
"story_time": clean_text(position.get("story_time"), f"{label}.story_time", max_bytes=240),
|
|
239
|
+
"scene": clean_text(position.get("scene"), f"{label}.scene", max_bytes=240),
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def normalize_snapshot(value: object, label: str) -> dict[str, Any]:
|
|
244
|
+
snapshot = as_mapping(value, label)
|
|
245
|
+
require_known_keys(
|
|
246
|
+
snapshot,
|
|
247
|
+
{"identity", "location", "goal", "state", "abilities_resources", "relationships", "knowledge", "open_threads"},
|
|
248
|
+
label,
|
|
249
|
+
)
|
|
250
|
+
return {
|
|
251
|
+
"identity": clean_text(snapshot.get("identity"), f"{label}.identity", max_bytes=240),
|
|
252
|
+
"location": clean_text(snapshot.get("location"), f"{label}.location", max_bytes=240),
|
|
253
|
+
"goal": clean_text(snapshot.get("goal"), f"{label}.goal", max_bytes=300),
|
|
254
|
+
"state": clean_text(snapshot.get("state"), f"{label}.state", max_bytes=300),
|
|
255
|
+
"abilities_resources": clean_string_list(
|
|
256
|
+
snapshot.get("abilities_resources", []), f"{label}.abilities_resources"
|
|
257
|
+
),
|
|
258
|
+
"relationships": clean_string_list(snapshot.get("relationships", []), f"{label}.relationships"),
|
|
259
|
+
"knowledge": clean_string_list(snapshot.get("knowledge", []), f"{label}.knowledge"),
|
|
260
|
+
"open_threads": clean_string_list(snapshot.get("open_threads", []), f"{label}.open_threads"),
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def normalize_snapshots(value: object, label: str = "character_snapshots") -> dict[str, dict[str, Any]]:
|
|
265
|
+
snapshots = as_mapping(value, label)
|
|
266
|
+
normalized: dict[str, dict[str, Any]] = {}
|
|
267
|
+
portable_names: set[str] = set()
|
|
268
|
+
for raw_name, raw_snapshot in snapshots.items():
|
|
269
|
+
name = safe_file_component(raw_name, f"{label} character name")
|
|
270
|
+
key = portable_name_key(name)
|
|
271
|
+
require(key not in portable_names, f"{label} contains a cross-platform duplicate character {name}")
|
|
272
|
+
portable_names.add(key)
|
|
273
|
+
normalized[name] = normalize_snapshot(raw_snapshot, f"{label}.{name}")
|
|
274
|
+
return normalized
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def render_snapshot(name: str, snapshot: dict[str, Any], through_chapter: int, revision: int) -> str:
|
|
278
|
+
def section(title: str, values: list[str]) -> list[str]:
|
|
279
|
+
return [f"## {title}", *(f"- {item}" for item in values or ["无"]), ""]
|
|
280
|
+
|
|
281
|
+
lines = [
|
|
282
|
+
f"# {name}|当前状态",
|
|
283
|
+
"",
|
|
284
|
+
f"- 状态修订:{revision}",
|
|
285
|
+
f"- 截至章节:第{through_chapter}章",
|
|
286
|
+
f"- 身份:{snapshot['identity']}",
|
|
287
|
+
f"- 位置:{snapshot['location']}",
|
|
288
|
+
f"- 当前目标:{snapshot['goal']}",
|
|
289
|
+
f"- 身心状态:{snapshot['state']}",
|
|
290
|
+
"",
|
|
291
|
+
]
|
|
292
|
+
lines.extend(section("能力与资源", snapshot["abilities_resources"]))
|
|
293
|
+
lines.extend(section("关键关系", snapshot["relationships"]))
|
|
294
|
+
lines.extend(section("已知信息", snapshot["knowledge"]))
|
|
295
|
+
lines.extend(section("未结事项", snapshot["open_threads"]))
|
|
296
|
+
payload = "\n".join(lines).rstrip() + "\n"
|
|
297
|
+
require(
|
|
298
|
+
byte_size(payload) <= SNAPSHOT_MAX_BYTES,
|
|
299
|
+
f"character snapshot {name} exceeds hard cap of {SNAPSHOT_MAX_BYTES} bytes",
|
|
300
|
+
)
|
|
301
|
+
return payload
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def normalize_foreshadow_change(
|
|
305
|
+
value: object,
|
|
306
|
+
label: str,
|
|
307
|
+
*,
|
|
308
|
+
allow_delete: bool,
|
|
309
|
+
through_chapter: int,
|
|
310
|
+
) -> dict[str, Any]:
|
|
311
|
+
row = as_mapping(value, label)
|
|
312
|
+
require_known_keys(
|
|
313
|
+
row,
|
|
314
|
+
{"action", "id", "summary", "planted_chapter", "planned_resolution_chapter", "status", "importance"},
|
|
315
|
+
label,
|
|
316
|
+
)
|
|
317
|
+
action = clean_text(row.get("action", "upsert"), f"{label}.action", max_bytes=24)
|
|
318
|
+
require(action in ({"upsert", "delete"} if allow_delete else {"upsert"}), f"{label}.action is invalid")
|
|
319
|
+
identifier = clean_text(row.get("id"), f"{label}.id", max_bytes=24)
|
|
320
|
+
require(FORESHADOW_ID.fullmatch(identifier) is not None, f"{label}.id must look like F001")
|
|
321
|
+
if action == "delete":
|
|
322
|
+
return {"action": action, "id": identifier}
|
|
323
|
+
planted_chapter = as_int(row.get("planted_chapter"), f"{label}.planted_chapter", minimum=1)
|
|
324
|
+
require(planted_chapter <= through_chapter, f"{label}.planted_chapter cannot be in the future")
|
|
325
|
+
planned_raw = row.get("planned_resolution_chapter")
|
|
326
|
+
planned_chapter = (
|
|
327
|
+
None if planned_raw is None else as_int(planned_raw, f"{label}.planned_resolution_chapter", minimum=1)
|
|
328
|
+
)
|
|
329
|
+
require(
|
|
330
|
+
planned_chapter is None or planned_chapter >= planted_chapter,
|
|
331
|
+
f"{label}.planned_resolution_chapter cannot precede planted_chapter",
|
|
332
|
+
)
|
|
333
|
+
status = clean_text(row.get("status"), f"{label}.status", max_bytes=24)
|
|
334
|
+
importance = clean_text(row.get("importance"), f"{label}.importance", max_bytes=12)
|
|
335
|
+
require(status in FORESHADOW_STATUSES, f"{label}.status must be one of {FORESHADOW_STATUSES}")
|
|
336
|
+
require(importance in FORESHADOW_IMPORTANCE, f"{label}.importance must be one of {FORESHADOW_IMPORTANCE}")
|
|
337
|
+
return {
|
|
338
|
+
"action": action,
|
|
339
|
+
"id": identifier,
|
|
340
|
+
"summary": clean_text(row.get("summary"), f"{label}.summary", max_bytes=360),
|
|
341
|
+
"planted_chapter": planted_chapter,
|
|
342
|
+
"planned_resolution_chapter": planned_chapter,
|
|
343
|
+
"status": status,
|
|
344
|
+
"importance": importance,
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def normalize_foreshadow_state(value: object, last_chapter: int) -> dict[str, dict[str, Any]]:
|
|
349
|
+
rows = as_mapping(value, "tracking state.foreshadow")
|
|
350
|
+
normalized: dict[str, dict[str, Any]] = {}
|
|
351
|
+
for raw_identifier, raw_row in rows.items():
|
|
352
|
+
identifier = clean_text(raw_identifier, "tracking state.foreshadow ID", max_bytes=24)
|
|
353
|
+
row = as_mapping(raw_row, f"tracking state.foreshadow.{identifier}")
|
|
354
|
+
require_known_keys(
|
|
355
|
+
row,
|
|
356
|
+
{"id", "summary", "planted_chapter", "planned_resolution_chapter", "status", "importance", "updated_chapter"},
|
|
357
|
+
f"tracking state.foreshadow.{identifier}",
|
|
358
|
+
)
|
|
359
|
+
require(row.get("id") == identifier, f"tracking state.foreshadow.{identifier}.id does not match its key")
|
|
360
|
+
change = normalize_foreshadow_change(
|
|
361
|
+
{
|
|
362
|
+
"action": "upsert",
|
|
363
|
+
**{key: value for key, value in row.items() if key != "updated_chapter"},
|
|
364
|
+
},
|
|
365
|
+
f"tracking state.foreshadow.{identifier}",
|
|
366
|
+
allow_delete=False,
|
|
367
|
+
through_chapter=last_chapter,
|
|
368
|
+
)
|
|
369
|
+
change.pop("action")
|
|
370
|
+
updated = as_int(row.get("updated_chapter"), f"tracking state.foreshadow.{identifier}.updated_chapter", minimum=1)
|
|
371
|
+
require(updated <= last_chapter, f"foreshadow {identifier} updates after current chapter")
|
|
372
|
+
change["updated_chapter"] = updated
|
|
373
|
+
normalized[identifier] = change
|
|
374
|
+
return normalized
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def render_foreshadow(rows: dict[str, dict[str, Any]], revision: int) -> str:
|
|
378
|
+
lines = [
|
|
379
|
+
"# 伏笔当前状态",
|
|
380
|
+
"",
|
|
381
|
+
f"> 状态修订:{revision}。每个 ID 只保留一行当前状态;历史变化见 `逐章记录/`。",
|
|
382
|
+
"",
|
|
383
|
+
"| ID | 内容 | 埋设章 | 计划回收章 | 状态 | 重要度 | 最近变更章 |",
|
|
384
|
+
"|---|---|---:|---:|---|---|---:|",
|
|
385
|
+
]
|
|
386
|
+
for identifier in sorted(rows):
|
|
387
|
+
row = rows[identifier]
|
|
388
|
+
planned = f"第{row['planned_resolution_chapter']}章" if row["planned_resolution_chapter"] else "—"
|
|
389
|
+
lines.append(
|
|
390
|
+
f"| {identifier} | {row['summary']} | 第{row['planted_chapter']}章 | {planned} | "
|
|
391
|
+
f"{row['status']} | {row['importance']} | 第{row['updated_chapter']}章 |"
|
|
392
|
+
)
|
|
393
|
+
return "\n".join(lines) + "\n"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def normalize_timeline_change(
|
|
397
|
+
value: object,
|
|
398
|
+
label: str,
|
|
399
|
+
*,
|
|
400
|
+
allow_delete: bool,
|
|
401
|
+
through_chapter: int,
|
|
402
|
+
) -> dict[str, Any]:
|
|
403
|
+
event = as_mapping(value, label)
|
|
404
|
+
require_known_keys(
|
|
405
|
+
event,
|
|
406
|
+
{"action", "id", "story_time", "objective_fact", "reader_knowledge", "reveal_status", "reveal_chapter", "characters"},
|
|
407
|
+
label,
|
|
408
|
+
)
|
|
409
|
+
action = clean_text(event.get("action", "upsert"), f"{label}.action", max_bytes=24)
|
|
410
|
+
require(action in ({"upsert", "delete"} if allow_delete else {"upsert"}), f"{label}.action is invalid")
|
|
411
|
+
identifier = clean_text(event.get("id"), f"{label}.id", max_bytes=24)
|
|
412
|
+
require(EVENT_ID.fullmatch(identifier) is not None, f"{label}.id must look like E001")
|
|
413
|
+
if action == "delete":
|
|
414
|
+
return {"action": action, "id": identifier}
|
|
415
|
+
reveal_status = clean_text(event.get("reveal_status"), f"{label}.reveal_status", max_bytes=24)
|
|
416
|
+
require(reveal_status in REVEAL_STATUSES, f"{label}.reveal_status must be one of {REVEAL_STATUSES}")
|
|
417
|
+
reveal_raw = event.get("reveal_chapter")
|
|
418
|
+
reveal_chapter = None if reveal_raw is None else as_int(reveal_raw, f"{label}.reveal_chapter", minimum=1)
|
|
419
|
+
if reveal_status == "未揭示":
|
|
420
|
+
require(reveal_chapter is None, f"{label} must not put a future reveal chapter in established timeline facts")
|
|
421
|
+
else:
|
|
422
|
+
require(reveal_chapter is not None, f"{label}.reveal_chapter is required once revealed")
|
|
423
|
+
require(reveal_chapter <= through_chapter, f"{label}.reveal_chapter cannot be in the future")
|
|
424
|
+
return {
|
|
425
|
+
"action": action,
|
|
426
|
+
"id": identifier,
|
|
427
|
+
"story_time": clean_text(event.get("story_time"), f"{label}.story_time", max_bytes=240),
|
|
428
|
+
"objective_fact": clean_text(event.get("objective_fact"), f"{label}.objective_fact", max_bytes=480),
|
|
429
|
+
"reader_knowledge": clean_text(event.get("reader_knowledge"), f"{label}.reader_knowledge", max_bytes=480),
|
|
430
|
+
"reveal_status": reveal_status,
|
|
431
|
+
"reveal_chapter": reveal_chapter,
|
|
432
|
+
"characters": clean_string_list(event.get("characters", []), f"{label}.characters", maximum=12, item_max_bytes=120),
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def normalize_timeline_state(value: object, last_chapter: int) -> dict[str, dict[str, Any]]:
|
|
437
|
+
events = as_mapping(value, "tracking state.timeline")
|
|
438
|
+
normalized: dict[str, dict[str, Any]] = {}
|
|
439
|
+
for raw_identifier, raw_event in events.items():
|
|
440
|
+
identifier = clean_text(raw_identifier, "tracking state.timeline ID", max_bytes=24)
|
|
441
|
+
event = as_mapping(raw_event, f"tracking state.timeline.{identifier}")
|
|
442
|
+
require_known_keys(
|
|
443
|
+
event,
|
|
444
|
+
{
|
|
445
|
+
"id", "story_time", "objective_fact", "reader_knowledge", "reveal_status", "reveal_chapter",
|
|
446
|
+
"characters", "first_recorded_chapter", "updated_chapter",
|
|
447
|
+
},
|
|
448
|
+
f"tracking state.timeline.{identifier}",
|
|
449
|
+
)
|
|
450
|
+
require(event.get("id") == identifier, f"tracking state.timeline.{identifier}.id does not match its key")
|
|
451
|
+
change = normalize_timeline_change(
|
|
452
|
+
{
|
|
453
|
+
"action": "upsert",
|
|
454
|
+
**{
|
|
455
|
+
key: value
|
|
456
|
+
for key, value in event.items()
|
|
457
|
+
if key not in {"first_recorded_chapter", "updated_chapter"}
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
f"tracking state.timeline.{identifier}",
|
|
461
|
+
allow_delete=False,
|
|
462
|
+
through_chapter=last_chapter,
|
|
463
|
+
)
|
|
464
|
+
change.pop("action")
|
|
465
|
+
first = as_int(event.get("first_recorded_chapter"), f"tracking state.timeline.{identifier}.first_recorded_chapter", minimum=1)
|
|
466
|
+
updated = as_int(event.get("updated_chapter"), f"tracking state.timeline.{identifier}.updated_chapter", minimum=1)
|
|
467
|
+
require(first <= last_chapter, f"timeline event {identifier} starts after current chapter")
|
|
468
|
+
require(updated <= last_chapter, f"timeline event {identifier} updates after current chapter")
|
|
469
|
+
change["first_recorded_chapter"] = first
|
|
470
|
+
change["updated_chapter"] = updated
|
|
471
|
+
normalized[identifier] = change
|
|
472
|
+
return normalized
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def render_timeline_views(events: dict[str, dict[str, Any]], revision: int) -> tuple[str, str]:
|
|
476
|
+
author_lines = [
|
|
477
|
+
"# 作者真相时间线",
|
|
478
|
+
"",
|
|
479
|
+
f"> 状态修订:{revision}。客观事实与读者认知的权威对照;未来揭示计划仍留在大纲。",
|
|
480
|
+
"",
|
|
481
|
+
"| ID | 首次登记章 | 故事时间 | 客观事实 | 读者当前认知 | 揭示状态 | 实际揭示章 |",
|
|
482
|
+
"|---|---:|---|---|---|---|---:|",
|
|
483
|
+
]
|
|
484
|
+
reader_lines = [
|
|
485
|
+
"# 读者已知时间线",
|
|
486
|
+
"",
|
|
487
|
+
f"> 状态修订:{revision}。只呈现读者截至当前章节已经知道或相信的内容,不泄露作者侧客观真相。",
|
|
488
|
+
"",
|
|
489
|
+
"| ID | 读者当前认知 | 认知截至章 |",
|
|
490
|
+
"|---|---|---:|",
|
|
491
|
+
]
|
|
492
|
+
for identifier in sorted(events):
|
|
493
|
+
event = events[identifier]
|
|
494
|
+
reveal = f"第{event['reveal_chapter']}章" if event.get("reveal_chapter") else "—"
|
|
495
|
+
characters = "、".join(event.get("characters", []))
|
|
496
|
+
objective = event["objective_fact"] + (f"(涉及:{characters})" if characters else "")
|
|
497
|
+
author_lines.append(
|
|
498
|
+
f"| {identifier} | 第{event['first_recorded_chapter']}章 | {event['story_time']} | {objective} | "
|
|
499
|
+
f"{event['reader_knowledge']} | {event['reveal_status']} | {reveal} |"
|
|
500
|
+
)
|
|
501
|
+
reader_lines.append(f"| {identifier} | {event['reader_knowledge']} | 第{event['updated_chapter']}章 |")
|
|
502
|
+
return "\n".join(author_lines) + "\n", "\n".join(reader_lines) + "\n"
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def validate_context_input(value: object, *, include_initial_fields: bool) -> dict[str, Any]:
|
|
506
|
+
context = as_mapping(value, "context")
|
|
507
|
+
allowed = {"position", "long_term_constraints", "active_character_names", "continuity_risks"}
|
|
508
|
+
if include_initial_fields:
|
|
509
|
+
allowed.update({"recent_chapters", "next_chapter_commitments"})
|
|
510
|
+
require_known_keys(context, allowed, "context")
|
|
511
|
+
normalized: dict[str, Any] = {
|
|
512
|
+
"position": validate_position(context.get("position")),
|
|
513
|
+
"long_term_constraints": clean_string_list(
|
|
514
|
+
context.get("long_term_constraints", []), "context.long_term_constraints", maximum=6
|
|
515
|
+
),
|
|
516
|
+
"active_character_names": [
|
|
517
|
+
safe_file_component(name, f"context.active_character_names[{index}]")
|
|
518
|
+
for index, name in enumerate(as_list(context.get("active_character_names", []), "context.active_character_names"))
|
|
519
|
+
],
|
|
520
|
+
"continuity_risks": clean_string_list(
|
|
521
|
+
context.get("continuity_risks", []), "context.continuity_risks", maximum=5
|
|
522
|
+
),
|
|
523
|
+
}
|
|
524
|
+
require(len(normalized["active_character_names"]) <= 6, "context.active_character_names may contain at most 6 names")
|
|
525
|
+
require(
|
|
526
|
+
len({portable_name_key(name) for name in normalized["active_character_names"]})
|
|
527
|
+
== len(normalized["active_character_names"]),
|
|
528
|
+
"context.active_character_names contains cross-platform duplicates",
|
|
529
|
+
)
|
|
530
|
+
if include_initial_fields:
|
|
531
|
+
recent: list[dict[str, Any]] = []
|
|
532
|
+
for index, raw_item in enumerate(as_list(context.get("recent_chapters", []), "context.recent_chapters")):
|
|
533
|
+
item = as_mapping(raw_item, f"context.recent_chapters[{index}]")
|
|
534
|
+
require_known_keys(item, {"chapter", "summary"}, f"context.recent_chapters[{index}]")
|
|
535
|
+
recent.append(
|
|
536
|
+
{
|
|
537
|
+
"chapter": as_int(item.get("chapter"), f"context.recent_chapters[{index}].chapter", minimum=1),
|
|
538
|
+
"summary": clean_text(item.get("summary"), f"context.recent_chapters[{index}].summary", max_bytes=360),
|
|
539
|
+
}
|
|
540
|
+
)
|
|
541
|
+
require(len(recent) <= 3, "context.recent_chapters may contain at most 3 items")
|
|
542
|
+
normalized["recent_chapters"] = recent
|
|
543
|
+
normalized["next_chapter_commitments"] = clean_string_list(
|
|
544
|
+
context.get("next_chapter_commitments", []), "context.next_chapter_commitments", maximum=5
|
|
545
|
+
)
|
|
546
|
+
return normalized
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def active_foreshadow_lines(rows: dict[str, dict[str, Any]]) -> list[str]:
|
|
550
|
+
importance = {value: index for index, value in enumerate(FORESHADOW_IMPORTANCE)}
|
|
551
|
+
candidates = [row for row in rows.values() if row["status"] == "已埋"]
|
|
552
|
+
candidates.sort(
|
|
553
|
+
key=lambda row: (importance[row["importance"]], row["planned_resolution_chapter"] or 10**12, row["id"])
|
|
554
|
+
)
|
|
555
|
+
result = []
|
|
556
|
+
for row in candidates[:8]:
|
|
557
|
+
planned = f"第{row['planned_resolution_chapter']}章" if row["planned_resolution_chapter"] else "回收章未定"
|
|
558
|
+
result.append(f"{row['id']}|{row['summary']}|埋第{row['planted_chapter']}章|{planned}|{row['importance']}")
|
|
559
|
+
return result
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def render_context(state: dict[str, Any]) -> str:
|
|
563
|
+
context = state["context"]
|
|
564
|
+
position = context["position"]
|
|
565
|
+
current_chapter = (
|
|
566
|
+
"尚未开篇" if state["last_committed_chapter"] == 0 else f"第{state['last_committed_chapter']}章"
|
|
567
|
+
)
|
|
568
|
+
character_lines = [
|
|
569
|
+
f"{name}|{state['characters'][name]['identity']}|{state['characters'][name]['state']}|"
|
|
570
|
+
f"目标:{state['characters'][name]['goal']}"
|
|
571
|
+
for name in context["active_character_names"]
|
|
572
|
+
]
|
|
573
|
+
sections: list[tuple[str, list[str]]] = [
|
|
574
|
+
(
|
|
575
|
+
"## 当前位置",
|
|
576
|
+
[
|
|
577
|
+
f"当前章:{current_chapter}",
|
|
578
|
+
f"卷:{position['volume']}(始于第{position['volume_start_chapter']}章)",
|
|
579
|
+
f"故事时间:{position['story_time']}",
|
|
580
|
+
f"场景:{position['scene']}",
|
|
581
|
+
],
|
|
582
|
+
),
|
|
583
|
+
("## 长期约束", context["long_term_constraints"]),
|
|
584
|
+
("## 核心角色状态", character_lines),
|
|
585
|
+
("## 活跃伏笔", active_foreshadow_lines(state["foreshadow"])),
|
|
586
|
+
("## 近三章速记", [f"第{item['chapter']}章|{item['summary']}" for item in context["recent_chapters"]]),
|
|
587
|
+
("## 下一章承诺", context["next_chapter_commitments"]),
|
|
588
|
+
("## 连贯性风险", context["continuity_risks"]),
|
|
589
|
+
]
|
|
590
|
+
lines = [
|
|
591
|
+
f"# 写作连续性上下文 — {state['book_title']}",
|
|
592
|
+
"",
|
|
593
|
+
f"> 状态修订:{state['state_revision']}。截至当前章的续写状态卡,只放下一章真正需要的连续性状态。",
|
|
594
|
+
"",
|
|
595
|
+
]
|
|
596
|
+
for heading, values in sections:
|
|
597
|
+
lines.append(heading)
|
|
598
|
+
lines.extend(f"- {value}" for value in values or ["无"])
|
|
599
|
+
lines.append("")
|
|
600
|
+
payload = "\n".join(lines).rstrip() + "\n"
|
|
601
|
+
headings = tuple(line for line in payload.splitlines() if line.startswith("## "))
|
|
602
|
+
require(headings == CONTEXT_HEADINGS, "generated context headings do not match the seven-section schema")
|
|
603
|
+
require(byte_size(payload) <= CONTEXT_MAX_BYTES, f"hot context exceeds {CONTEXT_MAX_BYTES} bytes")
|
|
604
|
+
return payload
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def normalize_delta(
|
|
608
|
+
value: object,
|
|
609
|
+
*,
|
|
610
|
+
through_chapter: int,
|
|
611
|
+
snapshots: dict[str, dict[str, Any]],
|
|
612
|
+
existing_core_names: dict[str, str],
|
|
613
|
+
) -> dict[str, Any]:
|
|
614
|
+
delta = as_mapping(value, "delta")
|
|
615
|
+
require_known_keys(
|
|
616
|
+
delta,
|
|
617
|
+
{
|
|
618
|
+
"result", "character_changes", "foreshadow_changes", "timeline_events", "constraints",
|
|
619
|
+
"next_chapter_commitments", "retired_context_items", "retired_characters",
|
|
620
|
+
},
|
|
621
|
+
"delta",
|
|
622
|
+
)
|
|
623
|
+
retired_characters = [
|
|
624
|
+
safe_file_component(name, f"delta.retired_characters[{index}]")
|
|
625
|
+
for index, name in enumerate(as_list(delta.get("retired_characters", []), "delta.retired_characters"))
|
|
626
|
+
]
|
|
627
|
+
retired_keys = [portable_name_key(name) for name in retired_characters]
|
|
628
|
+
require(len(retired_keys) == len(set(retired_keys)), "delta.retired_characters contains duplicate characters")
|
|
629
|
+
retiring = set(retired_keys)
|
|
630
|
+
character_changes: list[dict[str, Any]] = []
|
|
631
|
+
for index, raw_change in enumerate(as_list(delta.get("character_changes", []), "delta.character_changes")):
|
|
632
|
+
change = as_mapping(raw_change, f"delta.character_changes[{index}]")
|
|
633
|
+
require_known_keys(change, {"name", "change"}, f"delta.character_changes[{index}]")
|
|
634
|
+
name = safe_file_component(change.get("name"), f"delta.character_changes[{index}].name")
|
|
635
|
+
existing = existing_core_names.get(portable_name_key(name))
|
|
636
|
+
is_core = name in snapshots or existing is not None
|
|
637
|
+
# 本章退役的角色记录最后一次变化即可,不必再交一份马上要删的快照。
|
|
638
|
+
require(
|
|
639
|
+
not is_core or name in snapshots or portable_name_key(name) in retiring,
|
|
640
|
+
f"core character {name} changed but has no current snapshot",
|
|
641
|
+
)
|
|
642
|
+
character_changes.append(
|
|
643
|
+
{"name": name, "change": clean_text(change.get("change"), f"delta.character_changes[{index}].change", max_bytes=360)}
|
|
644
|
+
)
|
|
645
|
+
character_keys = [portable_name_key(item["name"]) for item in character_changes]
|
|
646
|
+
require(len(character_keys) == len(set(character_keys)), "delta.character_changes contains duplicate characters")
|
|
647
|
+
foreshadow_changes = [
|
|
648
|
+
normalize_foreshadow_change(
|
|
649
|
+
raw, f"delta.foreshadow_changes[{index}]", allow_delete=True, through_chapter=through_chapter
|
|
650
|
+
)
|
|
651
|
+
for index, raw in enumerate(as_list(delta.get("foreshadow_changes", []), "delta.foreshadow_changes"))
|
|
652
|
+
]
|
|
653
|
+
timeline_events = [
|
|
654
|
+
normalize_timeline_change(
|
|
655
|
+
raw, f"delta.timeline_events[{index}]", allow_delete=True, through_chapter=through_chapter
|
|
656
|
+
)
|
|
657
|
+
for index, raw in enumerate(as_list(delta.get("timeline_events", []), "delta.timeline_events"))
|
|
658
|
+
]
|
|
659
|
+
require(
|
|
660
|
+
len({item["id"] for item in foreshadow_changes}) == len(foreshadow_changes),
|
|
661
|
+
"delta.foreshadow_changes contains duplicate IDs",
|
|
662
|
+
)
|
|
663
|
+
require(
|
|
664
|
+
len({item["id"] for item in timeline_events}) == len(timeline_events),
|
|
665
|
+
"delta.timeline_events contains duplicate IDs",
|
|
666
|
+
)
|
|
667
|
+
require(
|
|
668
|
+
set(snapshots).issubset({item["name"] for item in character_changes}),
|
|
669
|
+
"character_snapshots must contain exactly the core characters changed by this transaction",
|
|
670
|
+
)
|
|
671
|
+
return {
|
|
672
|
+
"result": clean_text(delta.get("result"), "delta.result", max_bytes=480),
|
|
673
|
+
"character_changes": character_changes,
|
|
674
|
+
"foreshadow_changes": foreshadow_changes,
|
|
675
|
+
"timeline_events": timeline_events,
|
|
676
|
+
"constraints": clean_string_list(delta.get("constraints", []), "delta.constraints", maximum=6),
|
|
677
|
+
"next_chapter_commitments": clean_string_list(
|
|
678
|
+
delta.get("next_chapter_commitments", []), "delta.next_chapter_commitments", maximum=5
|
|
679
|
+
),
|
|
680
|
+
"retired_context_items": clean_string_list(
|
|
681
|
+
delta.get("retired_context_items", []), "delta.retired_context_items", maximum=11
|
|
682
|
+
),
|
|
683
|
+
"retired_characters": retired_characters,
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def render_delta(chapter: int, title: str, delta: dict[str, Any], core_names: set[str]) -> str:
|
|
688
|
+
lines = [
|
|
689
|
+
f"# 第{chapter:03d}章 · {title}",
|
|
690
|
+
f"- 结果:{delta['result']}",
|
|
691
|
+
"- 下一章承诺:" + (";".join(delta["next_chapter_commitments"]) or "无"),
|
|
692
|
+
"",
|
|
693
|
+
"## 角色变化",
|
|
694
|
+
]
|
|
695
|
+
lines.extend(
|
|
696
|
+
f"- {item['name']}|{'核心' if item['name'] in core_names else '临时'}|{item['change']}"
|
|
697
|
+
for item in delta["character_changes"]
|
|
698
|
+
)
|
|
699
|
+
if not delta["character_changes"]:
|
|
700
|
+
lines.append("- 无")
|
|
701
|
+
lines.extend(["", "## 伏笔变化"])
|
|
702
|
+
for item in delta["foreshadow_changes"]:
|
|
703
|
+
if item["action"] == "delete":
|
|
704
|
+
lines.append(f"- {item['id']}|删除当前登记")
|
|
705
|
+
else:
|
|
706
|
+
planned = f"第{item['planned_resolution_chapter']}章" if item["planned_resolution_chapter"] else "未定"
|
|
707
|
+
lines.append(f"- {item['id']}|{item['status']}|{item['summary']}|回收{planned}")
|
|
708
|
+
if not delta["foreshadow_changes"]:
|
|
709
|
+
lines.append("- 无")
|
|
710
|
+
lines.extend(["", "## 时间与揭示"])
|
|
711
|
+
for item in delta["timeline_events"]:
|
|
712
|
+
if item["action"] == "delete":
|
|
713
|
+
lines.append(f"- {item['id']}|删除当前登记")
|
|
714
|
+
else:
|
|
715
|
+
lines.append(
|
|
716
|
+
f"- {item['id']}|{item['story_time']}|事实:{item['objective_fact']}|"
|
|
717
|
+
f"读者:{item['reader_knowledge']}|{item['reveal_status']}"
|
|
718
|
+
)
|
|
719
|
+
if not delta["timeline_events"]:
|
|
720
|
+
lines.append("- 无")
|
|
721
|
+
lines.extend(["", "## 连贯性约束"])
|
|
722
|
+
lines.extend(f"- {item}" for item in delta["constraints"])
|
|
723
|
+
if not delta["constraints"]:
|
|
724
|
+
lines.append("- 无")
|
|
725
|
+
retired = delta.get("retired_context_items", []) + [
|
|
726
|
+
f"角色状态:{name}" for name in delta.get("retired_characters", [])
|
|
727
|
+
]
|
|
728
|
+
if retired:
|
|
729
|
+
# 退役条目在此留档,续写状态卡收缩后仍可回查当初撤下了什么。
|
|
730
|
+
lines.extend(["", "## 本章退役登记"])
|
|
731
|
+
lines.extend(f"- {item}" for item in retired)
|
|
732
|
+
payload = "\n".join(lines) + "\n"
|
|
733
|
+
size = byte_size(payload)
|
|
734
|
+
require(size <= DELTA_MAX_BYTES, f"chapter delta is {size} bytes; hard cap is {DELTA_MAX_BYTES}")
|
|
735
|
+
return payload
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def normalize_state(document: object) -> dict[str, Any]:
|
|
739
|
+
root = as_mapping(document, "tracking state")
|
|
740
|
+
require_known_keys(
|
|
741
|
+
root,
|
|
742
|
+
{
|
|
743
|
+
"schema_version", "book_title", "last_committed_chapter", "imported_through_chapter",
|
|
744
|
+
"state_revision", "context", "characters", "foreshadow", "timeline",
|
|
745
|
+
},
|
|
746
|
+
"tracking state",
|
|
747
|
+
)
|
|
748
|
+
require(root.get("schema_version") == TRACKING_SCHEMA_VERSION, "tracking state schema is unsupported")
|
|
749
|
+
last_chapter = as_int(root.get("last_committed_chapter"), "tracking state.last_committed_chapter")
|
|
750
|
+
imported_through = as_int(root.get("imported_through_chapter"), "tracking state.imported_through_chapter")
|
|
751
|
+
require(imported_through <= last_chapter, "imported chapter cutoff exceeds current chapter")
|
|
752
|
+
context = validate_context_input(root.get("context"), include_initial_fields=True)
|
|
753
|
+
require(
|
|
754
|
+
context["position"]["volume_start_chapter"] <= max(1, last_chapter),
|
|
755
|
+
"context.position.volume_start_chapter is after the current writing position",
|
|
756
|
+
)
|
|
757
|
+
recent_numbers = [item["chapter"] for item in context["recent_chapters"]]
|
|
758
|
+
require(recent_numbers == sorted(recent_numbers), "context.recent_chapters must be ordered")
|
|
759
|
+
require(len(recent_numbers) == len(set(recent_numbers)), "context.recent_chapters contains duplicates")
|
|
760
|
+
require(all(chapter <= last_chapter for chapter in recent_numbers), "context.recent_chapters cannot include future chapters")
|
|
761
|
+
characters = normalize_snapshots(root.get("characters", {}), "tracking state.characters")
|
|
762
|
+
for name in context["active_character_names"]:
|
|
763
|
+
require(name in characters, f"active core character {name} has no current snapshot")
|
|
764
|
+
foreshadow = normalize_foreshadow_state(root.get("foreshadow", {}), last_chapter)
|
|
765
|
+
timeline = normalize_timeline_state(root.get("timeline", {}), last_chapter)
|
|
766
|
+
if last_chapter == 0:
|
|
767
|
+
require(not foreshadow, "a chapter-0 project cannot have planted foreshadow facts")
|
|
768
|
+
require(not timeline, "a chapter-0 project cannot have established timeline facts")
|
|
769
|
+
return {
|
|
770
|
+
"schema_version": TRACKING_SCHEMA_VERSION,
|
|
771
|
+
"book_title": clean_text(root.get("book_title"), "tracking state.book_title", max_bytes=240),
|
|
772
|
+
"last_committed_chapter": last_chapter,
|
|
773
|
+
"imported_through_chapter": imported_through,
|
|
774
|
+
"state_revision": as_int(root.get("state_revision"), "tracking state.state_revision"),
|
|
775
|
+
"context": context,
|
|
776
|
+
"characters": characters,
|
|
777
|
+
"foreshadow": foreshadow,
|
|
778
|
+
"timeline": timeline,
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def load_state(project: Path) -> dict[str, Any]:
|
|
783
|
+
path = state_path(project)
|
|
784
|
+
require(path.exists(), "tracking state is missing; run init first")
|
|
785
|
+
return normalize_state(read_json(path))
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def normalize_initial_document(document: object) -> dict[str, Any]:
|
|
789
|
+
root = as_mapping(document, "init input")
|
|
790
|
+
require_known_keys(
|
|
791
|
+
root,
|
|
792
|
+
{"schema_version", "book_title", "last_chapter", "context", "character_snapshots", "foreshadow", "timeline_events"},
|
|
793
|
+
"init input",
|
|
794
|
+
)
|
|
795
|
+
require(root.get("schema_version") == INPUT_SCHEMA_VERSION, "init input schema_version is unsupported")
|
|
796
|
+
last_chapter = as_int(root.get("last_chapter"), "last_chapter")
|
|
797
|
+
context = validate_context_input(root.get("context"), include_initial_fields=True)
|
|
798
|
+
snapshots = normalize_snapshots(root.get("character_snapshots", {}))
|
|
799
|
+
foreshadow: dict[str, dict[str, Any]] = {}
|
|
800
|
+
for index, raw_row in enumerate(as_list(root.get("foreshadow", []), "foreshadow")):
|
|
801
|
+
row = normalize_foreshadow_change(
|
|
802
|
+
raw_row, f"foreshadow[{index}]", allow_delete=False, through_chapter=last_chapter
|
|
803
|
+
)
|
|
804
|
+
require(row["id"] not in foreshadow, f"duplicate foreshadow ID {row['id']}")
|
|
805
|
+
row.pop("action")
|
|
806
|
+
row["updated_chapter"] = max(1, last_chapter)
|
|
807
|
+
foreshadow[row["id"]] = row
|
|
808
|
+
timeline: dict[str, dict[str, Any]] = {}
|
|
809
|
+
for index, raw_event in enumerate(as_list(root.get("timeline_events", []), "timeline_events")):
|
|
810
|
+
event = normalize_timeline_change(
|
|
811
|
+
raw_event, f"timeline_events[{index}]", allow_delete=False, through_chapter=last_chapter
|
|
812
|
+
)
|
|
813
|
+
require(event["id"] not in timeline, f"duplicate timeline event ID {event['id']}")
|
|
814
|
+
event.pop("action")
|
|
815
|
+
event["first_recorded_chapter"] = max(1, last_chapter)
|
|
816
|
+
event["updated_chapter"] = max(1, last_chapter)
|
|
817
|
+
timeline[event["id"]] = event
|
|
818
|
+
return normalize_state(
|
|
819
|
+
{
|
|
820
|
+
"schema_version": TRACKING_SCHEMA_VERSION,
|
|
821
|
+
"book_title": clean_text(root.get("book_title"), "book_title", max_bytes=240),
|
|
822
|
+
"last_committed_chapter": last_chapter,
|
|
823
|
+
"imported_through_chapter": last_chapter,
|
|
824
|
+
"state_revision": 0,
|
|
825
|
+
"context": context,
|
|
826
|
+
"characters": snapshots,
|
|
827
|
+
"foreshadow": foreshadow,
|
|
828
|
+
"timeline": timeline,
|
|
829
|
+
}
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def normalize_transaction(state: dict[str, Any], document: object) -> dict[str, Any]:
|
|
834
|
+
root = as_mapping(document, "transaction")
|
|
835
|
+
require_known_keys(
|
|
836
|
+
root,
|
|
837
|
+
{
|
|
838
|
+
"schema_version", "mode", "chapter", "chapter_title", "expected_state_revision",
|
|
839
|
+
"delta", "context", "character_snapshots",
|
|
840
|
+
},
|
|
841
|
+
"transaction",
|
|
842
|
+
)
|
|
843
|
+
require(root.get("schema_version") == INPUT_SCHEMA_VERSION, "transaction schema_version is unsupported")
|
|
844
|
+
mode = clean_text(root.get("mode"), "mode", max_bytes=24)
|
|
845
|
+
require(mode in {"append", "revision"}, "mode must be append or revision")
|
|
846
|
+
chapter = as_int(root.get("chapter"), "chapter", minimum=1)
|
|
847
|
+
expected_revision = as_int(root.get("expected_state_revision"), "expected_state_revision")
|
|
848
|
+
require(expected_revision == state["state_revision"], "tracking state changed since this transaction was prepared")
|
|
849
|
+
last = state["last_committed_chapter"]
|
|
850
|
+
if mode == "append":
|
|
851
|
+
require(chapter == last + 1, f"append chapter must be {last + 1}, got {chapter}")
|
|
852
|
+
else:
|
|
853
|
+
require(chapter <= last, f"cannot revise unwritten chapter {chapter}; last committed chapter is {last}")
|
|
854
|
+
context = validate_context_input(root.get("context"), include_initial_fields=False)
|
|
855
|
+
snapshots = normalize_snapshots(root.get("character_snapshots", {}))
|
|
856
|
+
existing_names = {portable_name_key(name): name for name in state["characters"]}
|
|
857
|
+
for name in snapshots:
|
|
858
|
+
existing = existing_names.get(portable_name_key(name))
|
|
859
|
+
require(existing is None or existing == name, f"character {name} conflicts with existing character {existing}")
|
|
860
|
+
through_chapter = chapter if mode == "append" else last
|
|
861
|
+
delta = normalize_delta(
|
|
862
|
+
root.get("delta"),
|
|
863
|
+
through_chapter=through_chapter,
|
|
864
|
+
snapshots=snapshots,
|
|
865
|
+
existing_core_names=existing_names,
|
|
866
|
+
)
|
|
867
|
+
return {
|
|
868
|
+
"mode": mode,
|
|
869
|
+
"chapter": chapter,
|
|
870
|
+
"title": clean_text(root.get("chapter_title"), "chapter_title", max_bytes=240),
|
|
871
|
+
"delta": delta,
|
|
872
|
+
"context": context,
|
|
873
|
+
"snapshots": snapshots,
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def checkpoint_record(
|
|
878
|
+
change: dict[str, Any], chapter: int, previous: dict[str, Any] | None, *, keep_first_chapter: bool = False
|
|
879
|
+
) -> dict[str, Any]:
|
|
880
|
+
current = {key: value for key, value in change.items() if key != "action"}
|
|
881
|
+
current["updated_chapter"] = max(previous["updated_chapter"] if previous else chapter, chapter)
|
|
882
|
+
if keep_first_chapter:
|
|
883
|
+
current["first_recorded_chapter"] = previous["first_recorded_chapter"] if previous else chapter
|
|
884
|
+
return current
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def merge_transaction(state: dict[str, Any], transaction: dict[str, Any]) -> dict[str, Any]:
|
|
888
|
+
next_state = copy.deepcopy(state)
|
|
889
|
+
chapter = transaction["chapter"]
|
|
890
|
+
if transaction["mode"] == "append":
|
|
891
|
+
next_state["last_committed_chapter"] = chapter
|
|
892
|
+
next_state["state_revision"] += 1
|
|
893
|
+
next_state["characters"].update(transaction["snapshots"])
|
|
894
|
+
|
|
895
|
+
next_context = transaction["context"]
|
|
896
|
+
# 退役说的是「从此刻起离开当前状态」,只有 append 的逐章记录代表此刻;
|
|
897
|
+
# 修订记录属于被改写的旧章,落在那里会谎报退役发生的章节。
|
|
898
|
+
is_revision = transaction["mode"] == "revision"
|
|
899
|
+
require(
|
|
900
|
+
not (is_revision and transaction["delta"]["retired_characters"]),
|
|
901
|
+
"retired_characters must be committed in an append transaction, not a revision",
|
|
902
|
+
)
|
|
903
|
+
for name in transaction["delta"]["retired_characters"]:
|
|
904
|
+
require(name in next_state["characters"], f"retired character {name} has no current snapshot")
|
|
905
|
+
require(
|
|
906
|
+
name not in transaction["snapshots"],
|
|
907
|
+
f"character {name} cannot be retired and updated in the same transaction",
|
|
908
|
+
)
|
|
909
|
+
require(
|
|
910
|
+
name not in next_context["active_character_names"],
|
|
911
|
+
f"retired character {name} is still listed in context.active_character_names",
|
|
912
|
+
)
|
|
913
|
+
next_state["characters"].pop(name)
|
|
914
|
+
|
|
915
|
+
# 上下文条目是整份提交的;漏写会静默丢历史裁定,因此掉落必须显式声明。
|
|
916
|
+
previous_items = set(state["context"]["long_term_constraints"]) | set(state["context"]["continuity_risks"])
|
|
917
|
+
dropped = previous_items - (set(next_context["long_term_constraints"]) | set(next_context["continuity_risks"]))
|
|
918
|
+
require(
|
|
919
|
+
not (is_revision and dropped),
|
|
920
|
+
"a revision must resubmit every current context item; retire them in an append transaction instead: "
|
|
921
|
+
+ ";".join(sorted(dropped)),
|
|
922
|
+
)
|
|
923
|
+
undeclared = sorted(dropped - set(transaction["delta"]["retired_context_items"]))
|
|
924
|
+
require(
|
|
925
|
+
not undeclared,
|
|
926
|
+
"context items were dropped without being declared in delta.retired_context_items: "
|
|
927
|
+
+ ";".join(undeclared),
|
|
928
|
+
)
|
|
929
|
+
transaction["delta"]["retired_context_items"] = sorted(dropped)
|
|
930
|
+
|
|
931
|
+
for change in transaction["delta"]["foreshadow_changes"]:
|
|
932
|
+
if change["action"] == "delete":
|
|
933
|
+
next_state["foreshadow"].pop(change["id"], None)
|
|
934
|
+
else:
|
|
935
|
+
next_state["foreshadow"][change["id"]] = checkpoint_record(
|
|
936
|
+
change, chapter, next_state["foreshadow"].get(change["id"])
|
|
937
|
+
)
|
|
938
|
+
for change in transaction["delta"]["timeline_events"]:
|
|
939
|
+
if change["action"] == "delete":
|
|
940
|
+
next_state["timeline"].pop(change["id"], None)
|
|
941
|
+
else:
|
|
942
|
+
next_state["timeline"][change["id"]] = checkpoint_record(
|
|
943
|
+
change, chapter, next_state["timeline"].get(change["id"]), keep_first_chapter=True
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
recent_by_chapter = {item["chapter"]: item for item in state["context"]["recent_chapters"]}
|
|
947
|
+
if chapter in recent_by_chapter or transaction["mode"] == "append":
|
|
948
|
+
recent_by_chapter[chapter] = {"chapter": chapter, "summary": transaction["delta"]["result"]}
|
|
949
|
+
recent = sorted(recent_by_chapter.values(), key=lambda item: item["chapter"])[-3:]
|
|
950
|
+
current_last = next_state["last_committed_chapter"]
|
|
951
|
+
next_commitments = (
|
|
952
|
+
transaction["delta"]["next_chapter_commitments"]
|
|
953
|
+
if transaction["mode"] == "append" or chapter == current_last
|
|
954
|
+
else state["context"]["next_chapter_commitments"]
|
|
955
|
+
)
|
|
956
|
+
next_state["context"] = {
|
|
957
|
+
**next_context,
|
|
958
|
+
"recent_chapters": recent,
|
|
959
|
+
"next_chapter_commitments": next_commitments,
|
|
960
|
+
}
|
|
961
|
+
return normalize_state(next_state)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def render_views(state: dict[str, Any]) -> dict[str, str]:
|
|
965
|
+
revision = state["state_revision"]
|
|
966
|
+
views = {
|
|
967
|
+
"上下文.md": render_context(state),
|
|
968
|
+
"伏笔.md": render_foreshadow(state["foreshadow"], revision),
|
|
969
|
+
}
|
|
970
|
+
author, reader = render_timeline_views(state["timeline"], revision)
|
|
971
|
+
views["时间线/作者真相.md"] = author
|
|
972
|
+
views["时间线/读者已知.md"] = reader
|
|
973
|
+
for name, snapshot in state["characters"].items():
|
|
974
|
+
views[f"角色状态/{name}.md"] = render_snapshot(
|
|
975
|
+
name, snapshot, state["last_committed_chapter"], revision
|
|
976
|
+
)
|
|
977
|
+
return views
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def write_views(tracking: Path, views: dict[str, str]) -> None:
|
|
981
|
+
# 上下文携带 next revision,先写它;任何后续失败都会让 hook/check 发现
|
|
982
|
+
# 上下文 revision 与最后提交的 _tracking-state.json 不一致。
|
|
983
|
+
write_if_changed(tracking / "上下文.md", views["上下文.md"])
|
|
984
|
+
for relative in sorted(path for path in views if path != "上下文.md"):
|
|
985
|
+
write_if_changed(tracking / relative, views[relative])
|
|
986
|
+
expected_character_files = {
|
|
987
|
+
Path(relative).name for relative in views if relative.startswith("角色状态/")
|
|
988
|
+
}
|
|
989
|
+
character_dir = tracking / "角色状态"
|
|
990
|
+
character_dir.mkdir(parents=True, exist_ok=True)
|
|
991
|
+
for path in character_dir.glob("*.md"):
|
|
992
|
+
if path.name not in expected_character_files:
|
|
993
|
+
path.unlink()
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def warn_sizes(views: dict[str, str], delta_payload: str | None = None) -> None:
|
|
997
|
+
if delta_payload is not None and byte_size(delta_payload) > DELTA_TARGET_BYTES:
|
|
998
|
+
emit(
|
|
999
|
+
f"WARNING: chapter delta is {byte_size(delta_payload)} bytes; target is <= {DELTA_TARGET_BYTES}",
|
|
1000
|
+
error=True,
|
|
1001
|
+
)
|
|
1002
|
+
context_size = byte_size(views["上下文.md"])
|
|
1003
|
+
if context_size > CONTEXT_TARGET_BYTES:
|
|
1004
|
+
emit(f"WARNING: hot context is {context_size} bytes; target is <= {CONTEXT_TARGET_BYTES}", error=True)
|
|
1005
|
+
for relative, payload in views.items():
|
|
1006
|
+
if not relative.startswith("角色状态/"):
|
|
1007
|
+
continue
|
|
1008
|
+
size = byte_size(payload)
|
|
1009
|
+
if size > SNAPSHOT_TARGET_BYTES:
|
|
1010
|
+
emit(
|
|
1011
|
+
f"WARNING: character snapshot {Path(relative).stem} is {size} bytes; target is <= {SNAPSHOT_TARGET_BYTES}",
|
|
1012
|
+
error=True,
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def initialize(project: Path, document: object) -> dict[str, Any]:
|
|
1017
|
+
tracking = tracking_root(project)
|
|
1018
|
+
require(not state_path(project).exists(), "tracking state already exists; init never overwrites project state")
|
|
1019
|
+
state = normalize_initial_document(document)
|
|
1020
|
+
views = render_views(state)
|
|
1021
|
+
state_payload = json_payload(state)
|
|
1022
|
+
|
|
1023
|
+
# 输入全部校验通过后才动用户文件,失败的 init 不会挪走任何东西。
|
|
1024
|
+
archived = archive_retired_tracking_paths(tracking)
|
|
1025
|
+
for directory in (tracking / "逐章记录", tracking / "角色状态", tracking / "时间线"):
|
|
1026
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1027
|
+
write_views(tracking, views)
|
|
1028
|
+
atomic_write_text(state_path(project), state_payload)
|
|
1029
|
+
warn_sizes(views)
|
|
1030
|
+
if archived:
|
|
1031
|
+
emit(
|
|
1032
|
+
f"NOTE: 旧追踪结构已原样移入 追踪/{RETIRED_ARCHIVE_DIR}/:{', '.join(archived)};"
|
|
1033
|
+
"当前状态以本次 init 输入为准,旧文件不参与解析。",
|
|
1034
|
+
error=True,
|
|
1035
|
+
)
|
|
1036
|
+
return state
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def apply_transaction(project: Path, document: object) -> dict[str, Any]:
|
|
1040
|
+
tracking = tracking_root(project)
|
|
1041
|
+
require_no_retired_tracking_paths(tracking)
|
|
1042
|
+
state = load_state(project)
|
|
1043
|
+
transaction = normalize_transaction(state, document)
|
|
1044
|
+
next_state = merge_transaction(state, transaction)
|
|
1045
|
+
|
|
1046
|
+
delta_payload = render_delta(
|
|
1047
|
+
transaction["chapter"],
|
|
1048
|
+
transaction["title"],
|
|
1049
|
+
transaction["delta"],
|
|
1050
|
+
# 本章退役的角色在 next_state 里已被删除,但本章记录里仍应标为核心。
|
|
1051
|
+
set(next_state["characters"]) | set(transaction["delta"]["retired_characters"]),
|
|
1052
|
+
)
|
|
1053
|
+
views = render_views(next_state)
|
|
1054
|
+
next_state_payload = json_payload(next_state)
|
|
1055
|
+
path = delta_path(tracking, transaction["chapter"])
|
|
1056
|
+
if transaction["mode"] == "append" and path.exists():
|
|
1057
|
+
require(
|
|
1058
|
+
path.read_text(encoding="utf-8") == delta_payload,
|
|
1059
|
+
f"chapter delta {transaction['chapter']} already exists with different content",
|
|
1060
|
+
)
|
|
1061
|
+
|
|
1062
|
+
write_if_changed(path, delta_payload)
|
|
1063
|
+
write_views(tracking, views)
|
|
1064
|
+
# 唯一权威文件最后落盘;在此之前失败可用同一事务直接重跑。
|
|
1065
|
+
atomic_write_text(state_path(project), next_state_payload)
|
|
1066
|
+
warn_sizes(views, delta_payload)
|
|
1067
|
+
return next_state
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def check_project(project: Path) -> dict[str, Any]:
|
|
1071
|
+
tracking = tracking_root(project)
|
|
1072
|
+
require_no_retired_tracking_paths(tracking)
|
|
1073
|
+
state = load_state(project)
|
|
1074
|
+
last_chapter = state["last_committed_chapter"]
|
|
1075
|
+
required_delta_start = state["imported_through_chapter"] + 1
|
|
1076
|
+
for chapter in range(required_delta_start, last_chapter + 1):
|
|
1077
|
+
require(delta_path(tracking, chapter).exists(), f"chapter delta {chapter} is missing")
|
|
1078
|
+
for path in (tracking / "逐章记录").glob("第*章.md"):
|
|
1079
|
+
match = re.fullmatch(r"第(\d+)章\.md", path.name)
|
|
1080
|
+
require(match is not None, f"chapter delta has an invalid filename: {path.name}")
|
|
1081
|
+
chapter = as_int(int(match.group(1)), f"chapter delta {path.name}", minimum=1)
|
|
1082
|
+
require(path == delta_path(tracking, chapter), f"chapter delta {chapter} filename is not canonical")
|
|
1083
|
+
require(chapter <= last_chapter, f"chapter delta {chapter} exceeds last_committed_chapter")
|
|
1084
|
+
require(path.stat().st_size <= DELTA_MAX_BYTES, f"chapter delta {chapter} exceeds {DELTA_MAX_BYTES} bytes")
|
|
1085
|
+
|
|
1086
|
+
expected_views = render_views(state)
|
|
1087
|
+
for relative, expected in expected_views.items():
|
|
1088
|
+
path = tracking / relative
|
|
1089
|
+
require(path.exists(), f"derived view is missing: {relative}")
|
|
1090
|
+
require(
|
|
1091
|
+
path.read_text(encoding="utf-8") == expected,
|
|
1092
|
+
f"derived view differs from _tracking-state.json: {relative}",
|
|
1093
|
+
)
|
|
1094
|
+
expected_character_files = {
|
|
1095
|
+
Path(relative).name for relative in expected_views if relative.startswith("角色状态/")
|
|
1096
|
+
}
|
|
1097
|
+
actual_character_files = {path.name for path in (tracking / "角色状态").glob("*.md")}
|
|
1098
|
+
require(actual_character_files == expected_character_files, "character snapshot files differ from tracking state")
|
|
1099
|
+
return state
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1103
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
1104
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1105
|
+
for command in ("init", "commit"):
|
|
1106
|
+
subparser = subparsers.add_parser(command)
|
|
1107
|
+
subparser.add_argument("--project", type=Path, required=True, help="book project root containing 追踪/")
|
|
1108
|
+
subparser.add_argument("--input", type=Path, required=True, help="UTF-8 JSON input document")
|
|
1109
|
+
check_parser = subparsers.add_parser("check")
|
|
1110
|
+
check_parser.add_argument("--project", type=Path, required=True, help="book project root containing 追踪/")
|
|
1111
|
+
return parser
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def main() -> int:
|
|
1115
|
+
args = build_parser().parse_args()
|
|
1116
|
+
try:
|
|
1117
|
+
if args.command == "init":
|
|
1118
|
+
result = initialize(args.project, read_json(args.input))
|
|
1119
|
+
elif args.command == "commit":
|
|
1120
|
+
result = apply_transaction(args.project, read_json(args.input))
|
|
1121
|
+
else:
|
|
1122
|
+
result = check_project(args.project)
|
|
1123
|
+
except (TrackingError, OSError, UnicodeError) as exc:
|
|
1124
|
+
emit(f"ERROR: {exc}", error=True)
|
|
1125
|
+
return 2
|
|
1126
|
+
emit(
|
|
1127
|
+
json.dumps(
|
|
1128
|
+
{
|
|
1129
|
+
"last_committed_chapter": result["last_committed_chapter"],
|
|
1130
|
+
"state_revision": result["state_revision"],
|
|
1131
|
+
},
|
|
1132
|
+
ensure_ascii=False,
|
|
1133
|
+
)
|
|
1134
|
+
)
|
|
1135
|
+
return 0
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
if __name__ == "__main__":
|
|
1139
|
+
raise SystemExit(main())
|