Refactor of writing tool

This commit is contained in:
K. Hodges 2026-05-23 02:46:59 -07:00
parent e1e6803eb1
commit 78dcf911d6
14 changed files with 200 additions and 282 deletions

3
.gitignore vendored
View File

@ -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.

View File

@ -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():

View File

@ -16,3 +16,7 @@ story/chapters/*.md
.nightshift/project-context.md
.nightshift/project-context-chart.md
.nightshift/nightshift.log
__pycache__/
*.py[cod]
.pytest_cache/

View File

@ -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: <short explanation>
next_stage: <optional stage id>
context_update: <compact useful note>
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.

View File

@ -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: <the exact story/chapters path listed under Writes in the current task>
---CONTENT---
<complete scene prose>
---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.

View File

@ -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: <the exact story/chapters path listed under Writes in the current task>
---CONTENT---
<complete edited scene prose>
---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.

View File

@ -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`.

View File

@ -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---
<complete updated state file>
<complete updated file>
---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.

View File

@ -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: <short explanation>
next_stage: <optional stage id>
context_update: <compact useful note>
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.

View File

@ -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

View File

@ -7,3 +7,4 @@ name = "nightshift-novel-target"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = []

View File

@ -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(

View File

@ -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)

View File

@ -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)
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()