diff --git a/.gitignore b/.gitignore index d41fdcc..1fb08d8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ MANIFEST # Codex working notes and generated analysis docs docs/codex/ +# Locally preserved pre-pivot writing template +tutorial-writing-complex/ + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/nightshift/pipeline.py b/nightshift/pipeline.py index 6a71943..4a4185d 100644 --- a/nightshift/pipeline.py +++ b/nightshift/pipeline.py @@ -48,7 +48,7 @@ from .runlog import RunLogger from .stages import StageResult from .tasks import Task, mark_task_completed from .telemetry import TelemetryEntry, format_telemetry_summary, telemetry_from_stage_output -from .writing_validators import validate_writing_file_updates +from .writing_validators import collect_writing_warnings, validate_writing_file_updates @dataclass(frozen=True) @@ -767,6 +767,7 @@ class PipelineRunner: stdout = self._read_agent_stdout(result.output_path) invalid_rerun_done = False candidate_index_path: Path | None = None + warning_path: Path | None = None while True: updates: tuple[FileUpdate, ...] = () try: @@ -779,6 +780,7 @@ class PipelineRunner: ) if _is_writing_file_writer_stage(stage): validate_writing_file_updates(updates, self.config.project.root) + warning_path = self._write_file_writer_warnings(task.id, stage, updates, retry_count) patch = generate_patch_from_file_updates( updates, self.config.project.root, @@ -799,6 +801,12 @@ class PipelineRunner: ): if _is_writing_file_writer_stage(stage): validate_writing_file_updates(allowed_updates, self.config.project.root) + warning_path = self._write_file_writer_warnings( + task.id, + stage, + allowed_updates, + retry_count, + ) patch = generate_patch_from_file_updates( allowed_updates, self.config.project.root, @@ -910,7 +918,11 @@ class PipelineRunner: "pass", patch_reason, output_path=str(proposed_path.relative_to(self.config.project.root)), - context_update=f"Implementation summary: {summary_path.relative_to(self.config.project.root).as_posix()}", + context_update=_format_writer_context_update( + self.config.project.root, + summary_path, + warning_path, + ), ) def _write_file_writer_candidates( @@ -954,6 +966,31 @@ class PipelineRunner: lines.append("") return self.artifacts.write_stage_output(task_id, f"{base}/index.md", "\n".join(lines)) + def _write_file_writer_warnings( + self, + task_id: str, + stage: StageConfig, + updates: tuple[FileUpdate, ...], + retry_count: int, + ) -> Path | None: + warnings = collect_writing_warnings(updates, self.config.project.root) + if not warnings: + return None + filename = _attempt_filename(f"{stage.id}-warnings.md", retry_count) + lines = [ + "# Writing Warnings", + "", + f"Stage: `{stage.id}`", + "", + "These are soft writing concerns. They do not block artifact creation.", + "", + "## Warnings", + "", + *[f"- {warning}" for warning in warnings], + "", + ] + return self.artifacts.write_stage_output(task_id, filename, "\n".join(lines)) + def _allowed_file_contents(self, stage: StageConfig, max_chars: int = 2400) -> str: sections: list[str] = [] for path_text in stage.allowed_paths: @@ -1677,6 +1714,18 @@ def format_implementation_summary( return "\n".join(lines) +def _format_writer_context_update( + project_root: Path, + summary_path: Path, + warning_path: Path | None, +) -> str: + summary = summary_path.relative_to(project_root).as_posix() + if warning_path is None: + return f"Implementation summary: {summary}" + warnings = warning_path.relative_to(project_root).as_posix() + return f"Implementation summary: {summary}; writing warnings: {warnings}" + + def _latest_patch_like_output(previous_outputs: dict[str, str]) -> str: for name in ("normalized.patch", "applied.patch", "proposed.patch", "patch_input"): if name in previous_outputs and previous_outputs[name].strip(): diff --git a/nightshift/project_templates/tutorial-novel/.gitignore b/nightshift/project_templates/tutorial-novel/.gitignore index b1a5712..66e5338 100644 --- a/nightshift/project_templates/tutorial-novel/.gitignore +++ b/nightshift/project_templates/tutorial-novel/.gitignore @@ -16,3 +16,7 @@ story/chapters/*.md .nightshift/project-context.md .nightshift/project-context-chart.md .nightshift/nightshift.log + +__pycache__/ +*.py[cod] +.pytest_cache/ diff --git a/nightshift/project_templates/tutorial-novel/.nightshift/agents/continuity-reviewer.md b/nightshift/project_templates/tutorial-novel/.nightshift/agents/continuity-reviewer.md deleted file mode 100644 index 75530d4..0000000 --- a/nightshift/project_templates/tutorial-novel/.nightshift/agents/continuity-reviewer.md +++ /dev/null @@ -1,42 +0,0 @@ -You are the continuity reviewer for a NightShift novel-writing workflow. - -Review the drafted scene against: -- the current task -- `story/worldbuilding.md` -- `story/characters.md` -- `story/plot-state.md` -- `story/timeline.md` -- `story/unresolved-threads.md` -- `story/continuity-rules.md` -- prior scene context provided in artifacts - -Check for: -- contradictions -- wrong character knowledge -- wrong character pronouns or narrative reference, using `Pronouns / Reference` in `story/characters.md` as hard canon -- impossible locations or timing -- accidental resolution of future threads -- missing required beats from the task -- invented lore that should have been added deliberately - -Do not fail the scene because durable state files are not updated yet. State files are updated by a later `update_state` stage after review. If the task lists `Updates:`, treat those as future state-update requirements and mention them only as `context_update` guidance. - -Wrong pronouns are a continuity failure. If a drafted scene uses non-canonical pronouns for a named character, return `status: fail` and explain which character drifted. Do not pass the scene with only `context_update` guidance. - -Pronoun canon quick reference: -- Proxy: she/her -- BLOODMONEY: narrative default they/them; he/him allowed only when dialogue or close character voice has a specific reason -- Cricket: she/her -- Saint: he/him -- Miette: she/her - -If retry notes, previous reviewer output, or generated scene text conflict with `story/characters.md`, obey `story/characters.md`. Do not infer pronouns from a previous failure note. Before failing a pronoun issue, verify the character's `Pronouns / Reference` section. - -Output exactly: - -status: pass | fail | retry | escalate -reason: -next_stage: -context_update: - -When `status: pass`, leave `next_stage` blank. Use `retry` when the scene can be repaired by drafting again. For retryable scene issues, leave `next_stage` blank; NightShift will route back to the configured drafting stage. diff --git a/nightshift/project_templates/tutorial-novel/.nightshift/agents/drafter.md b/nightshift/project_templates/tutorial-novel/.nightshift/agents/drafter.md index 22627cc..4b115cc 100644 --- a/nightshift/project_templates/tutorial-novel/.nightshift/agents/drafter.md +++ b/nightshift/project_templates/tutorial-novel/.nightshift/agents/drafter.md @@ -1,25 +1,22 @@ -You are the drafting agent for a NightShift novel-writing workflow. +You are the scene writer for a NightShift fiction workflow. -Draft only the current scene or section requested by the task. +Write the scene requested by the current task. Rules: - Write prose only under `story/chapters/`. -- Do not edit `story/worldbuilding.md`, `story/characters.md`, `story/style-guide.md`, `story/plot-state.md`, `story/timeline.md`, `story/unresolved-threads.md`, `story/continuity-rules.md`, or `story/outline.md`. - Use `story/style-guide.md` for POV, tense, tone, and prose rules. -- Use `story/plot-state.md` and `story/timeline.md` as current state. -- Use the `Pronouns / Reference` sections in `story/characters.md` as hard canon. -- Do not infer, vary, or "smooth out" character pronouns. Use canonical narrative reference exactly. +- Use `story/characters.md`, especially `Pronouns / Reference`, as canon. +- Use `story/plot-state.md`, `story/timeline.md`, and `story/unresolved-threads.md` as current state. - Keep the scene bounded to the task acceptance criteria. -- Do not resolve future plot threads unless the task explicitly asks for that. +- Do not update state files, character files, worldbuilding, outline, continuity rules, or style guide. - Do not include author notes, TODOs, bracket placeholders, or analysis in the scene file. -Output only one complete file block using this delimiter format: +Output only one complete file block: + FILE: ---CONTENT--- ---END--- -Do not use markdown code fences for scene prose output. -Do not output a plan, notes, analysis, or any text outside the delimiter block. +Do not use markdown code fences. Do not output any text outside the file block. -If the task does not specify a scene path, choose the next obvious path under `story/chapters/` and keep it stable. diff --git a/nightshift/project_templates/tutorial-novel/.nightshift/agents/editor.md b/nightshift/project_templates/tutorial-novel/.nightshift/agents/editor.md deleted file mode 100644 index 9a34dee..0000000 --- a/nightshift/project_templates/tutorial-novel/.nightshift/agents/editor.md +++ /dev/null @@ -1,28 +0,0 @@ -You are the scene editor for a NightShift novel-writing workflow. - -Edit an already drafted scene after a continuity or style review failure. - -Rules: -- Preserve the existing scene's structure, voice, events, pacing, and best lines. -- Make the smallest changes needed to satisfy the review failure and task acceptance criteria. -- Do not restart, summarize, replace the scene premise, or change scene direction. -- Use `story/style-guide.md` for POV, tense, tone, and prose rules. -- Use `story/characters.md`, especially `Pronouns / Reference`, as hard canon. -- Wrong pronouns are mandatory fixes. -- If retry notes or reviewer feedback conflict with `story/characters.md`, obey `story/characters.md`. -- Never change correct canonical pronouns because a review note claims a different canon. -- Do not edit state files, worldbuilding, outline, continuity rules, or style guide. -- Do not resolve future plot threads unless the task explicitly asks for that. -- Do not include author notes, TODOs, bracket placeholders, or analysis in the scene file. - -Use the `current_scene_file` context as the source text to edit. -Use the retry notes and latest review output to identify the required repair. - -Output only one complete file block using this delimiter format: -FILE: ----CONTENT--- - ----END--- - -Do not use markdown code fences for scene prose output. -Do not output a plan, notes, analysis, or any text outside the delimiter block. diff --git a/nightshift/project_templates/tutorial-novel/.nightshift/agents/planner.md b/nightshift/project_templates/tutorial-novel/.nightshift/agents/planner.md deleted file mode 100644 index 1653abf..0000000 --- a/nightshift/project_templates/tutorial-novel/.nightshift/agents/planner.md +++ /dev/null @@ -1,23 +0,0 @@ -You are the planning agent for a NightShift novel-writing workflow. - -Create a concise scene plan for the current task. - -Use the story files as source of truth: -- `story/worldbuilding.md` -- `story/characters.md` -- `story/style-guide.md` -- `story/plot-state.md` -- `story/timeline.md` -- `story/unresolved-threads.md` -- `story/continuity-rules.md` -- `story/outline.md` - -Plan in this order: -1. scene purpose -2. POV and tone constraints -3. relevant characters, locations, timeline facts, and unresolved threads -4. concrete beats for this scene -5. state files likely to need updates after drafting - -Do not write prose. Do not invent large new plot arcs unless the task asks for them. -If repository context is needed, request it with `lookup_requests`. diff --git a/nightshift/project_templates/tutorial-novel/.nightshift/agents/state-updater.md b/nightshift/project_templates/tutorial-novel/.nightshift/agents/state-updater.md index a18c22e..fe96cbe 100644 --- a/nightshift/project_templates/tutorial-novel/.nightshift/agents/state-updater.md +++ b/nightshift/project_templates/tutorial-novel/.nightshift/agents/state-updater.md @@ -1,46 +1,37 @@ -You are the state updater for a NightShift novel-writing workflow. +You are the state updater for a NightShift fiction workflow. -Update durable story state after an accepted scene. +Read the accepted scene and update durable story state conservatively. You may edit only: - `story/plot-state.md` -- `story/characters.md` - `story/timeline.md` - `story/unresolved-threads.md` -Do not edit scene prose. Do not edit worldbuilding, style guide, continuity rules, or outline unless a later template explicitly allows it. +Do not edit: +- scene prose +- `story/characters.md` +- `story/worldbuilding.md` +- `story/style-guide.md` +- `story/continuity-rules.md` +- `story/outline.md` -State updates should reflect only what happened in the accepted scene: -- current character locations -- what each important character knows -- relationship changes -- injuries, resources, items, and commitments -- timeline movement -- unresolved questions opened or resolved -- promises made to the reader +State updates should be extractive. Reflect only facts/events/thread changes that are actually present in the accepted scene. -Do not invent events that are not in the scene. +Prefer additive updates: +- record completed scene events +- add timeline bullets +- add or update unresolved threads +- update current story moment or character locations only when clearly changed +- keep all existing sections and bullets unless they directly contradict the accepted scene -Preserve existing durable state. Make minimal additive edits: -- append new scene facts, timeline bullets, character knowledge, and unresolved threads -- update current locations/status only where the accepted scene changes them -- do not remove or compress existing character profiles, faction notes, world notes, or open threads -- do not rewrite whole files for style, brevity, or cleanup -- if a section already contains useful detail, keep it and add only the new facts needed +Do not invent events, compress existing state, rewrite for style, or alter canon. +If a full-file update would require removing or reorganizing existing material, do less. Add a small `Recently Changed` or scene-specific bullet instead. -Protect character canon: -- Never change any `Pronouns / Reference` section. -- Never change a character's canonical pronouns, narrative reference, identity, or core wound. -- Prefer updating `story/plot-state.md`, `story/timeline.md`, and `story/unresolved-threads.md`. -- Edit `story/characters.md` only when the accepted scene adds a small current-status fact or introduces a new named character. -- If editing `story/characters.md`, preserve all existing sections and add only the minimal new status/detail needed. - -Output only complete file content blocks. -Use this delimiter format for each state file you update: +Output complete file blocks only: FILE: story/plot-state.md ---CONTENT--- - + ---END--- -Do not use markdown code fences. Do not include prose outside FILE blocks. +Do not use markdown code fences. Do not output any text outside file blocks. diff --git a/nightshift/project_templates/tutorial-novel/.nightshift/agents/style-reviewer.md b/nightshift/project_templates/tutorial-novel/.nightshift/agents/style-reviewer.md deleted file mode 100644 index 8cd90ee..0000000 --- a/nightshift/project_templates/tutorial-novel/.nightshift/agents/style-reviewer.md +++ /dev/null @@ -1,28 +0,0 @@ -You are the style reviewer for a NightShift novel-writing workflow. - -Review the drafted scene against: -- the current task -- `story/style-guide.md` -- the scene plan -- the applied scene file - -Check for: -- POV discipline -- tense consistency -- tone match -- pacing -- excessive exposition -- dialogue that violates established voice -- placeholders such as TODO, TBD, `[insert]`, or author notes -- scene length far outside the requested range - -Do not fail the scene because durable state files are not updated yet. State files are updated by a later `update_state` stage after review. - -Output exactly: - -status: pass | fail | retry | escalate -reason: -next_stage: -context_update: - -When `status: pass`, leave `next_stage` blank. Use `retry` when the drafter should revise the scene. For retryable scene issues, leave `next_stage` blank; NightShift will route back to the configured drafting stage. diff --git a/nightshift/project_templates/tutorial-novel/nightshift.yaml b/nightshift/project_templates/tutorial-novel/nightshift.yaml index 06b288b..53ae4a2 100644 --- a/nightshift/project_templates/tutorial-novel/nightshift.yaml +++ b/nightshift/project_templates/tutorial-novel/nightshift.yaml @@ -9,6 +9,8 @@ safety: scoped_paths: - story - README.md + - STORY_FILES.md + - pyproject.toml allowed_commands: - python -m pytest -q forbidden_commands: @@ -17,18 +19,10 @@ safety: - curl | bash experiment: - label: tutorial-novel - prompt_variant: scene-state-workflow-v1 + label: tutorial-novel-simple + prompt_variant: scene-state-simple-v1 agents: - planner: - backend: ollama - model: nightshift-base - temperature: 0.4 - num_ctx: 8192 - num_predict: 4096 - system_prompt: .nightshift/agents/planner.md - drafter: backend: ollama model: nightshift-writer @@ -37,30 +31,6 @@ agents: num_predict: 8192 system_prompt: .nightshift/agents/drafter.md - editor: - backend: ollama - model: nightshift-writer - temperature: 0.3 - num_ctx: 16384 - num_predict: 8192 - system_prompt: .nightshift/agents/editor.md - - continuity_reviewer: - backend: ollama - model: nightshift-base - temperature: 0.2 - num_ctx: 8192 - num_predict: 4096 - system_prompt: .nightshift/agents/continuity-reviewer.md - - style_reviewer: - backend: ollama - model: nightshift-base - temperature: 0.3 - num_ctx: 8192 - num_predict: 4096 - system_prompt: .nightshift/agents/style-reviewer.md - state_updater: backend: ollama model: nightshift-writer @@ -74,16 +44,7 @@ pipeline: stop_on_repeated_failure_signature_after: 3 continue_on_task_failure: false stages: - - id: plan - type: agent - agent: planner - output: scene-plan.md - - - id: semantic_context - type: semantic_context - output: semantic-context.md - - - id: context + - id: build_context type: repo_context output: context-pack.md @@ -93,101 +54,56 @@ pipeline: output: scene-draft.patch allowed_paths: - story/chapters + on_fail: draft_scene - - id: normalize_draft + - id: normalize_patch type: patch_normalizer - output: normalized-draft.patch + output: normalized-scene.patch - - id: validate_draft + - id: hard_validate_patch type: patch_validator - output: draft-validation.md - max_files: 2 + output: scene-validation.md + max_files: 1 max_lines: 4000 max_delete_ratio: 0.50 allowed_paths: - story/chapters on_fail: draft_scene - - id: apply_draft + - id: apply_scene type: patch_apply mode: apply - output: draft-apply-output.txt + output: scene-apply-output.txt on_fail: draft_scene - - id: continuity_review - type: agent_review - agent: continuity_reviewer - output: continuity-review.md - on_fail: edit_scene - - - id: style_review - type: agent_review - agent: style_reviewer - output: style-review.md - on_fail: edit_scene - on_pass: update_state - - - id: edit_scene - type: file_writer - agent: editor - output: scene-edit.patch - allowed_paths: - - story/chapters - - - id: normalize_edit - type: patch_normalizer - output: normalized-edit.patch - - - id: validate_edit - type: patch_validator - output: edit-validation.md - max_files: 2 - max_lines: 1200 - max_delete_ratio: 0.50 - allowed_paths: - - story/chapters - on_fail: edit_scene - - - id: apply_edit - type: patch_apply - mode: apply - output: edit-apply-output.txt - on_fail: edit_scene - on_pass: continuity_review - - id: update_state type: file_writer agent: state_updater output: state-update.patch allowed_paths: - story/plot-state.md - - story/characters.md - story/timeline.md - story/unresolved-threads.md + on_fail: summarize_warnings - - id: normalize_state - type: patch_normalizer - output: normalized-state.patch - - - id: validate_state + - id: hard_validate_state type: patch_validator output: state-validation.md - max_files: 5 + max_files: 3 max_lines: 1200 max_delete_ratio: 0.35 allowed_paths: - story/plot-state.md - - story/characters.md - story/timeline.md - story/unresolved-threads.md - on_fail: update_state + on_fail: summarize_warnings - id: apply_state type: patch_apply mode: apply output: state-apply-output.txt - on_fail: update_state + on_fail: summarize_warnings - - id: summarize + - id: summarize_warnings type: summarize output: final-notes.md diff --git a/nightshift/project_templates/tutorial-novel/pyproject.toml b/nightshift/project_templates/tutorial-novel/pyproject.toml index 31954d7..a094e71 100644 --- a/nightshift/project_templates/tutorial-novel/pyproject.toml +++ b/nightshift/project_templates/tutorial-novel/pyproject.toml @@ -7,3 +7,4 @@ name = "nightshift-novel-target" version = "0.1.0" requires-python = ">=3.11" dependencies = [] + diff --git a/nightshift/writing_validators.py b/nightshift/writing_validators.py index 803ac38..2eaa2b9 100644 --- a/nightshift/writing_validators.py +++ b/nightshift/writing_validators.py @@ -14,7 +14,7 @@ from .patches import FileUpdate def validate_writing_file_updates(updates: tuple[FileUpdate, ...], project_root: Path) -> None: - """Validate writing-specific invariants for novel scene/state file updates.""" + """Validate hard writing-specific invariants for novel scene/state updates.""" root = Path(project_root) characters_path = root / "story" / "characters.md" @@ -27,8 +27,24 @@ def validate_writing_file_updates(updates: tuple[FileUpdate, ...], project_root: normalized_path = update.path.replace("\\", "/").strip().strip("/") if normalized_path == "story/characters.md": _validate_protected_character_canon(normalized_path, character_sections, update.content) + + +def collect_writing_warnings(updates: tuple[FileUpdate, ...], project_root: Path) -> tuple[str, ...]: + """Collect soft writing concerns without blocking artifact creation.""" + + root = Path(project_root) + characters_path = root / "story" / "characters.md" + character_sections = ( + _pronoun_reference_sections(characters_path.read_text(encoding="utf-8", errors="replace")) + if characters_path.is_file() + else {} + ) + warnings: list[str] = [] + for update in updates: + normalized_path = update.path.replace("\\", "/").strip().strip("/") if normalized_path.startswith("story/chapters/") and normalized_path.endswith(".md"): - _validate_scene_pronoun_canon(normalized_path, update.content, character_sections) + warnings.extend(_scene_pronoun_canon_warnings(normalized_path, update.content, character_sections)) + return tuple(warnings) def _validate_protected_character_canon( @@ -52,18 +68,19 @@ def _validate_protected_character_canon( ) -def _validate_scene_pronoun_canon( +def _scene_pronoun_canon_warnings( path_text: str, scene_text: str, sections: dict[str, str], -) -> None: +) -> tuple[str, ...]: if not sections: - return + return () rules = _pronoun_rules_from_sections(sections) if not rules: - return + return () aliases = {alias: character for character in rules for alias in _character_aliases(character)} active_character: str | None = None + warnings: list[str] = [] for sentence in _scene_sentences(scene_text): present = { character @@ -78,7 +95,7 @@ def _validate_scene_pronoun_canon( continue forbidden = rules[character] if present: - bad = _first_forbidden_pronoun(sentence, forbidden) + bad = _first_forbidden_pronoun_after_alias(sentence, character, forbidden) active_character = character else: bad = _leading_forbidden_pronoun(sentence, forbidden) @@ -88,10 +105,12 @@ def _validate_scene_pronoun_canon( excerpt = sentence.strip() if len(excerpt) > 160: excerpt = excerpt[:157].rstrip() + "..." - raise PipelineError( - "File writer error: scene pronoun canon violation for " - f"{character}: found `{bad}` near character reference. Excerpt: {excerpt}" + warnings.append( + "Scene pronoun canon warning in " + f"`{path_text}` for {character}: found `{bad}` near character reference. " + f"Excerpt: {excerpt}" ) + return tuple(warnings) def _first_forbidden_pronoun(sentence: str, forbidden: tuple[str, ...]) -> str | None: @@ -105,6 +124,26 @@ def _first_forbidden_pronoun(sentence: str, forbidden: tuple[str, ...]) -> str | ) +def _first_forbidden_pronoun_after_alias( + sentence: str, + character: str, + forbidden: tuple[str, ...], +) -> str | None: + alias_match = _first_alias_match(sentence, character) + if alias_match is None: + return _first_forbidden_pronoun(sentence, forbidden) + return _first_forbidden_pronoun(sentence[alias_match.end() :], forbidden) + + +def _first_alias_match(sentence: str, character: str) -> re.Match[str] | None: + matches = [ + match + for alias in _character_aliases(character) + for match in re.finditer(rf"\b{re.escape(alias)}\b", sentence) + ] + return min(matches, key=lambda match: match.start()) if matches else None + + def _leading_forbidden_pronoun(sentence: str, forbidden: tuple[str, ...]) -> str | None: stripped = sentence.strip() return next( diff --git a/tests/test_init.py b/tests/test_init.py index 9fbd417..39a9ad9 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -120,7 +120,8 @@ class InitProjectTests(unittest.TestCase): self.assertTrue((root / "story" / "chapters" / ".gitkeep").exists()) self.assertIn("type: file_writer", config) self.assertIn("model: nightshift-writer", config) - self.assertIn("model: nightshift-base", config) + self.assertIn("summarize_warnings", config) + self.assertNotIn("model: nightshift-base", config) self.assertIn("story/chapters", config) self.assertIn("story/worldbuilding.md", gitignore) self.assertIn("story/chapters/**/*.md", gitignore) diff --git a/tests/test_writing_validators.py b/tests/test_writing_validators.py index db50d50..4c5c61e 100644 --- a/tests/test_writing_validators.py +++ b/tests/test_writing_validators.py @@ -4,7 +4,7 @@ import unittest from nightshift.errors import PipelineError from nightshift.patches import FileUpdate -from nightshift.writing_validators import validate_writing_file_updates +from nightshift.writing_validators import collect_writing_warnings, validate_writing_file_updates class WritingValidatorTests(unittest.TestCase): @@ -44,7 +44,7 @@ Scavenger. with self.assertRaisesRegex(PipelineError, "protected character pronoun canon changed"): validate_writing_file_updates(updates, root) - def test_rejects_scene_pronoun_drift(self) -> None: + def test_reports_scene_pronoun_drift_as_warning(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / "story" / "chapters").mkdir(parents=True) @@ -66,8 +66,12 @@ Scavenger. ), ) - with self.assertRaisesRegex(PipelineError, "scene pronoun canon violation for Proxy"): - validate_writing_file_updates(updates, root) + validate_writing_file_updates(updates, root) + warnings = collect_writing_warnings(updates, root) + + self.assertEqual(len(warnings), 1) + self.assertIn("Proxy", warnings[0]) + self.assertIn("found `he`", warnings[0]) def test_allows_scene_pronouns_when_multiple_characters_make_ambiguous_sentence(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -99,6 +103,40 @@ Scavenger. validate_writing_file_updates(updates, root) + def test_allows_pronoun_before_other_character_reference(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "story" / "chapters" / "chapter-001").mkdir(parents=True) + (root / "story" / "characters.md").write_text( + """# Characters + +## Proxy + +### Pronouns / Reference +- Pronouns: she/her +- Narrative reference: Proxy; she/her + +## DJ BLOODMONEY + +### Pronouns / Reference +- Pronouns: they/them or he/him +- Narrative default: BLOODMONEY; they/them +""", + encoding="utf-8", + ) + updates = ( + FileUpdate( + path="story/chapters/chapter-001/scene-001.md", + content=( + "BLOODMONEY stood behind the turntables. " + "He adjusted the EQ with one hand, let his hair fall into his eyes, " + "and glanced over at Proxy without breaking the groove.\n" + ), + ), + ) + + validate_writing_file_updates(updates, root) + if __name__ == "__main__": unittest.main()