Replace the outline tree with chapter beat tables and tags

The self-nesting outline tree was more structure than chapter outlining needs.
A chapter outline is now a paragraph plus a flat, ordered table of beats, and
tags do the cross-referencing that nesting was doing badly.

A beat is one row: a three-to-five word title, an optional character, what
happened, and what's next. Ordering is a SortOrder column within the chapter —
no parent pointers, no cycle guards, no recursive tree building. Reordering is
one call taking beat ids in the order wanted; ids left out keep their relative
position at the end rather than jumping to the front.

Beats plan, scenes carry prose. The two layers stay separate and a beat's
SceneId is the optional link between them, nullable in both directions —
deleting a scene ungroups its beats rather than deleting the plan, since that
is a decision about prose and not about the outline.

Tags are project-scoped, unique by name case-insensitively, and attach to
characters, chapters and beats through three join tables so cascade deletes are
the database's job rather than ours. Applying an unknown tag by name creates it,
which keeps tagging a single action; GET /api/tags/{id}/references returns
everything carrying a tag across all three kinds at once.

Removed: OutlineNode, OutlineService, its endpoints, agent and MCP tools, and
the Outline tab. Added: Beat and Tag with their services, endpoints, 5 agent
tools and 10 MCP tools, a beat table on the chapter page, a tag editor used in
three places, and a Tags tab for cross-referencing.

Migration drops OutlineNodes — the scaffolder's data-loss warning is the
intended removal, not an accident.

44 tests, up from 31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 0d7b7a6f30
commit 7678cc7275
51 changed files with 3189 additions and 948 deletions
+38 -13
View File
@@ -48,7 +48,7 @@ and everything else keeps working.
### Tests
```bash
dotnet test # 31 tests
dotnet test # 44 tests
cd src/NovelSoftware.Web && npm run build # typecheck + bundle
```
@@ -79,23 +79,43 @@ out of `appsettings.json` and use user-secrets or the environment.
```
Project ──┬── Character ── CharacterRelationship
├── OutlineNode (self-nesting: Part > Act > Sequence > Beat)
├── Chapter ── Scene (goal / conflict / outcome, prose, word count)
├── Chapter ──┬── Beat (the outline: flat, ordered)
│ └── Scene (the prose)
├── Tag (applied to characters, chapters and beats)
└── AgentConversation ── AgentMessage
```
The outline tree is deliberately loose — nest acts under parts, beats under sequences, or
keep a flat list of beats. An outline node can link to the chapter that realises it.
**A chapter outline is a paragraph plus a table.** The paragraph is the chapter's
`Summary`; the table is its beats. Each beat is one row:
Scenes carry the goal/conflict/outcome trio because that is the unit the agent works from
when turning an outline into prose. Word counts are recomputed on every save.
| Column | What goes in it |
|---|---|
| Beat | A three-to-five word handle — "she burns the atlas", not a sentence |
| Character | Whose beat it is. Optional; not every beat belongs to one person |
| What happened | The event itself |
| What's next | What it sets in motion — the hook into the following beat |
| Scene | Optional grouping: which scene will carry this beat's prose |
Beats are flat and ordered by `SortOrder` within their chapter. There is no nesting and
no tree — reordering is one call that takes the beat ids in the order wanted.
**Beats plan; scenes carry prose.** The two layers are deliberately separate: an outline
is for working out what happens, and a scene is where you write it. A beat's `SceneId` is
the optional link between them, and it is nullable in both directions — deleting a scene
ungroups its beats rather than deleting the plan.
**Tags cross-reference the book.** A tag is scoped to one project, unique by name
(case-insensitively), and can be attached to any character, chapter or beat. Applying an
unknown tag by name creates it, so tagging is one action rather than two. `GET
/api/tags/{id}/references` returns everything carrying a tag, which is how you trace a
motif or a thread across all three kinds at once.
## The embedded agent
`NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools
Claude asks for, feeds every result back in a single user turn, and repeats until Claude
stops asking. It has 15 tools covering the brief, characters, the outline tree, chapters
and scenes — all of them going through the same application services the REST API uses.
stops asking. It has 18 tools covering the brief, characters, chapter outlines (beats), scenes and
tags — all of them going through the same application services the REST API uses.
A few deliberate choices worth knowing about:
@@ -113,7 +133,7 @@ A few deliberate choices worth knowing about:
## The MCP server
A stdio MCP server exposing 21 tools over the same REST API. It holds no domain logic of
A stdio MCP server exposing 26 tools over the same REST API. It holds no domain logic of
its own — it is a second front end, not a second implementation.
Build it, then point your MCP client at the produced binary:
@@ -146,12 +166,15 @@ rather than failing opaquely.
|---|---|
| Projects | `GET\|POST /api/projects`, `GET\|PATCH\|DELETE /api/projects/{id}` |
| Characters | `GET\|POST /api/projects/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` |
| Outline | `GET\|POST /api/projects/{id}/outline`, `GET\|PATCH\|DELETE /api/outline/{id}`, `POST /api/outline/{id}/move` |
| Chapters | `GET\|POST /api/projects/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` |
| Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` |
| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` |
| Tags | `GET\|POST /api/projects/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` |
| Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` |
`PATCH` bodies are partial: an omitted field is left alone, an empty string clears it.
`PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A
`tags` array replaces that item's tags outright and creates any names the project has not
seen; omitting it leaves tags untouched.
Enums travel as names (`"Protagonist"`, `"Drafted"`), never ordinals. In development the
OpenAPI document is at `/openapi/v1.json`.
@@ -168,7 +191,9 @@ OpenAPI document is at `/openapi/v1.json`.
The vertical slice is complete but thin in places. The obvious next steps:
- Stream agent responses over SSE instead of returning the finished turn.
- Drag-and-drop reordering in the outline (the `move` endpoint is already there).
- Drag-and-drop beat reordering (the reorder endpoint is already there; the UI uses
up/down buttons).
- Filter chapters and characters by tag from the list views, not just the Tags tab.
- A manuscript export (Markdown, DOCX) built from chapters and scenes in order.
- Revision history for scene prose.
- Authentication, if this is ever going to run anywhere but localhost.