Rename Project concept to Novel across the stack

Renames the domain concept from Project to Novel throughout the backend
(entities, DTOs, services, endpoints, ProjectAccessService/Permission,
ProjectId foreign keys), MCP server (tool names and routes), and the
React/Vite frontend (types, hooks, routes, components). Adds a new EF
Core migration (RenameProjectToNovel) using RenameTable/RenameColumn to
preserve existing data instead of dropping/recreating tables. Updates
CLAUDE.md's structure section to reference Novels/ instead of Projects/.
This commit is contained in:
James Wampler
2026-08-17 23:03:09 -07:00
parent 0ab4f568b5
commit 4313c8f206
95 changed files with 3192 additions and 1660 deletions
+32 -32
View File
@@ -3,7 +3,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Novels;
using Novelly.Api.Questions;
using Novelly.Api.Tags;
@@ -23,7 +23,7 @@ public record AgentTool(
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
public class NovelAgentToolset(
ProjectService projects,
NovelService novels,
CharacterService characters,
CharacterArcService arcs,
ChapterService chapters,
@@ -45,7 +45,7 @@ public class NovelAgentToolset(
public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
public async Task<AgentToolResult> ExecuteAsync(string name, Guid novelId, JsonElement input, CancellationToken ct = default)
{
if (!ByName.TryGetValue(name, out var tool))
{
@@ -53,29 +53,29 @@ public class NovelAgentToolset(
return new AgentToolResult($"No such tool: '{name}'.", true);
}
logger.LogDebug("Running tool {Tool} for project {ProjectId}", name, projectId);
logger.LogDebug("Running tool {Tool} for novel {NovelId}", name, novelId);
try
{
var result = await tool.Handler(projectId, input, ct);
var result = await tool.Handler(novelId, input, ct);
if (result is ToolNotFound notFound)
{
logger.LogWarning("Tool {Tool} for project {ProjectId} found no {Entity} {EntityId}", name, projectId, notFound.Entity, notFound.Id);
logger.LogWarning("Tool {Tool} for novel {NovelId} found no {Entity} {EntityId}", name, novelId, notFound.Entity, notFound.Id);
return new AgentToolResult(notFound.Message, true);
}
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId);
logger.LogDebug("Tool {Tool} for novel {NovelId} succeeded", name, novelId);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
}
catch (ArgumentException ex)
{
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid argument", name, projectId);
logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid argument", name, novelId);
return new AgentToolResult(ex.Message, true);
}
catch (InvalidOperationException ex)
{
logger.LogWarning(ex, "Tool {Tool} for project {ProjectId} failed: invalid operation", name, projectId);
logger.LogWarning(ex, "Tool {Tool} for novel {NovelId} failed: invalid operation", name, novelId);
return new AgentToolResult(ex.Message, true);
}
}
@@ -95,15 +95,15 @@ public class NovelAgentToolset(
private IEnumerable<AgentTool> Build()
{
yield return new AgentTool(
"get_project_brief",
"Read the project's title, logline, synopsis, genre, notes and word-count target. "
"get_novel_brief",
"Read the novel's title, logline, synopsis, genre, notes and word-count target. "
+ "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), p => p.ToResponse(null), "Project", projectId));
async (novelId, _, ct) => await OrNotFound(novels.GetAsync(novelId, ct), p => p.ToResponse(null), "Novel", novelId));
yield return new AgentTool(
"update_project_brief",
"Revise the project's top-level fields. Only the fields you supply change; "
"update_novel_brief",
"Revise the novel's top-level fields. Only the fields you supply change; "
+ "pass an empty string to clear a field.",
new JsonSchemaBuilder()
.Str("title", "New title.")
@@ -114,27 +114,27 @@ public class NovelAgentToolset(
.Str("notes", "Free-form notes on theme, tone, comparable titles.")
.Int("target_word_count", "Target manuscript length in words.")
.Build(),
async (projectId, input, ct) => await OrNotFound(projects.UpdateAsync(projectId, new UpdateProjectRequest(
async (novelId, input, ct) => await OrNotFound(novels.UpdateAsync(novelId, new UpdateNovelRequest(
JsonInput.String(input, "title"),
JsonInput.String(input, "author"),
JsonInput.String(input, "genre"),
JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Project", projectId));
JsonInput.Int(input, "target_word_count")), ct), p => p.ToResponse(null), "Novel", novelId));
yield return new AgentTool(
"list_characters",
"List every character in the project with their full dossiers.",
"List every character in the novel with their full dossiers.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => (await characters.ListAsync(projectId, ct)).Select(c => c.ToResponse()));
async (novelId, _, ct) => (await characters.ListAsync(novelId, ct)).Select(c => c.ToResponse()));
yield return new AgentTool(
"create_character",
"Add a character dossier. Name is the only requirement — leave fields blank when "
+ "the writer has not decided them yet rather than inventing detail.",
CharacterSchema(includeName: true, nameRequired: true).Build(),
async (projectId, input, ct) => await OrNotFound(characters.CreateAsync(projectId, new CreateCharacterRequest(
async (novelId, input, ct) => await OrNotFound(characters.CreateAsync(novelId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
@@ -152,7 +152,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags"),
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Project", projectId));
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Novel", novelId));
yield return new AgentTool(
"update_character",
@@ -189,7 +189,7 @@ public class NovelAgentToolset(
yield return new AgentTool(
"link_character_identity",
"Record that a character is really another character — e.g. one introduced under one name "
+ "who is later revealed to be a character already in the project under another name. Both "
+ "who is later revealed to be a character already in the novel under another name. Both "
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.",
new JsonSchemaBuilder()
.Str("character_id", "Id of the character being revealed as someone else.", required: true)
@@ -348,10 +348,10 @@ public class NovelAgentToolset(
yield return new AgentTool(
"list_tags",
"List the project's tags with how many characters, chapters and beats carry each. "
"List the novel's tags with how many characters, chapters and beats carry each. "
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await tags.ListAsync(projectId, ct));
async (novelId, _, ct) => await tags.ListAsync(novelId, ct));
yield return new AgentTool(
"get_tag_references",
@@ -368,9 +368,9 @@ public class NovelAgentToolset(
yield return new AgentTool(
"list_chapters",
"List the project's chapters in manuscript order with beat and word counts.",
"List the novel's chapters in manuscript order with beat and word counts.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse()));
yield return new AgentTool(
"get_chapter",
@@ -398,7 +398,7 @@ public class NovelAgentToolset(
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest(
async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
@@ -407,7 +407,7 @@ public class NovelAgentToolset(
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId));
yield return new AgentTool(
"update_chapter",
@@ -548,8 +548,8 @@ public class NovelAgentToolset(
.Str("character_id", "Narrow to questions about one character.")
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
.Build(),
async (projectId, input, ct) => (await questions.ListAsync(
projectId,
async (novelId, input, ct) => (await questions.ListAsync(
novelId,
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false,
@@ -566,13 +566,13 @@ public class NovelAgentToolset(
.Str("chapter_id", "The chapter outline this is about, if any.")
.Str("character_id", "The character this is about, if any.")
.Build(),
async (projectId, input, ct) => await OrNotFound(questions.CreateAsync(
projectId,
async (novelId, input, ct) => await OrNotFound(questions.CreateAsync(
novelId,
new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Project", projectId));
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId));
yield return new AgentTool(
"resolve_open_question",