Add main/supporting characters, character arcs and open questions
Three things the outline could not express before:
Main vs supporting. A new CharacterImportance sits alongside CharacterRole
rather than inside it — role is the part a character plays (protagonist,
mentor, foil), importance is how much of the book they carry, and a mentor can
be either. Characters start Supporting and get promoted. Listings put main
characters first.
Character arcs. A main character's arc is a flat ordered list of stages, the
same shape as a chapter's beats and for the same reason: an arc is a sequence
of changes, not a tree. A stage can be pinned to the chapter where it lands.
Nothing refuses an arc on a supporting character — demoting someone should not
delete their work.
Open questions. What the writer has not decided yet, hanging off a chapter
outline, a character, both, or neither. They can be resolved, reopened or
deleted, and resolving can append the decision to the notes of whatever the
question was attached to, so it lands where the writer will re-read it.
Resolved questions drop off the list unless asked for.
Also adds GET /api/characters/{id}/beats — every beat a character appears in,
in manuscript order, carrying each beat's chapter so the character page can
link straight into that chapter's outline.
Deletes are deliberately asymmetric: deleting a chapter unpins arc stages and
detaches questions rather than taking them, because a plan outlives a decision
about where the chapter break falls. Deleting a character or project does take
their arcs and questions.
All three capabilities are surfaced in the REST API, the agent toolset and the
MCP server, per the one-source-of-truth rule.
Two things worth flagging in the migration: EF's generated default for the new
Importance column was an empty string, which does not parse back to a
CharacterImportance and would have faulted every read of an existing dossier —
it now defaults to Supporting, verified by migrating a database seeded on the
old schema and reading the row back through the API. And the earlier migrations
were renamed to the namespace EF derives from the output folder, so future
`migrations add` runs stop drifting.
72 tests pass (28 new). The endpoints were also exercised over curl end to end:
arc stages resolving their chapter, a character's beats across chapters, and a
question attached to both a chapter and a character resolving into both sets of
notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
96021c5fee
commit
0358667679
@@ -0,0 +1,92 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Novelly.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class QuestionTools
|
||||
{
|
||||
[McpServerTool(Name = "list_open_questions")]
|
||||
[Description("The decisions the writer has not made yet, newest first. Read this before "
|
||||
+ "proposing changes — an open question marks somewhere the writer is still "
|
||||
+ "thinking, not a gap to fill in for them.")]
|
||||
public static Task<CallToolResult> ListOpenQuestions(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
CancellationToken ct,
|
||||
[Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null,
|
||||
[Description("Narrow to questions about one character.")] Guid? characterId = null,
|
||||
[Description("Include questions already settled. Defaults to false.")] bool includeResolved = false)
|
||||
{
|
||||
var query = new List<string> { $"includeResolved={includeResolved.ToString().ToLowerInvariant()}" };
|
||||
|
||||
if (chapterId is { } chapter)
|
||||
{
|
||||
query.Add($"chapterId={chapter}");
|
||||
}
|
||||
|
||||
if (characterId is { } character)
|
||||
{
|
||||
query.Add($"characterId={character}");
|
||||
}
|
||||
|
||||
return api.GetAsync($"/api/projects/{projectId}/questions?{string.Join('&', query)}", ct);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "raise_open_question")]
|
||||
[Description("Record a question the writer has not settled, attached to the chapter outline "
|
||||
+ "and/or the character it is about. Prefer raising a question over guessing.")]
|
||||
public static Task<CallToolResult> RaiseOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The question, in one line.")] string question,
|
||||
CancellationToken ct,
|
||||
[Description("The thinking around it — options considered, and what each costs.")] string? detail = null,
|
||||
[Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null,
|
||||
[Description("Id of the character this is about, if any.")] Guid? characterId = null) =>
|
||||
api.PostAsync($"/api/projects/{projectId}/questions",
|
||||
new { question, detail, chapterId, characterId }, ct);
|
||||
|
||||
[McpServerTool(Name = "update_open_question")]
|
||||
[Description("Revise a question or change what it is attached to. Only the fields you supply change.")]
|
||||
public static Task<CallToolResult> UpdateOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The question's id.")] Guid questionId,
|
||||
CancellationToken ct,
|
||||
[Description("New wording for the question.")] string? question = null,
|
||||
[Description("New detail. Pass an empty string to clear it.")] string? detail = null,
|
||||
[Description("Attach to this chapter outline.")] Guid? chapterId = null,
|
||||
[Description("Attach to this character.")] Guid? characterId = null,
|
||||
[Description("Detach from its chapter.")] bool clearChapter = false,
|
||||
[Description("Detach from its character.")] bool clearCharacter = false) =>
|
||||
api.PatchAsync($"/api/questions/{questionId}",
|
||||
new { question, detail, chapterId, characterId, clearChapter, clearCharacter }, ct);
|
||||
|
||||
[McpServerTool(Name = "resolve_open_question")]
|
||||
[Description("Settle a question with what the writer decided. Set appendToNotes to also write "
|
||||
+ "the resolution into the notes of the chapter and character it hangs off.")]
|
||||
public static Task<CallToolResult> ResolveOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The question's id.")] Guid questionId,
|
||||
[Description("What was decided.")] string resolution,
|
||||
CancellationToken ct,
|
||||
[Description("Also append the resolution to the associated notes.")] bool appendToNotes = false) =>
|
||||
api.PostAsync($"/api/questions/{questionId}/resolve", new { resolution, appendToNotes }, ct);
|
||||
|
||||
[McpServerTool(Name = "reopen_question")]
|
||||
[Description("Put a resolved question back on the list. Anything already appended to notes stays.")]
|
||||
public static Task<CallToolResult> ReopenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The question's id.")] Guid questionId,
|
||||
CancellationToken ct) =>
|
||||
api.PostAsync($"/api/questions/{questionId}/reopen", new { }, ct);
|
||||
|
||||
[McpServerTool(Name = "delete_open_question")]
|
||||
[Description("Delete a question outright. Resolving is usually better — it keeps the decision.")]
|
||||
public static Task<CallToolResult> DeleteOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The question's id.")] Guid questionId,
|
||||
CancellationToken ct) =>
|
||||
api.DeleteAsync($"/api/questions/{questionId}", ct);
|
||||
}
|
||||
Reference in New Issue
Block a user