Reorganise by feature, rename to Novelly, add Aspire and a pre-push hook

The layered split into Domain/Application/Infrastructure/Api was forcing
organisation by layer: adding one capability meant touching four projects and
four folders that each held a slice of it. Those four projects are now one
feature-organised Novelly.Api, where each folder — Projects, Characters,
Chapters, Beats, Scenes, Tags, Agent — holds its entity, DTOs, service and
endpoints together. Common/ holds what genuinely crosses features (the patch
semantics, the two exception types, DraftStatus) and Data/ holds the DbContext
and migrations.

Six .NET projects become five: the three layer projects are gone, and
Novelly.AppHost and Novelly.ServiceDefaults are new.

- Namespaces move from NovelSoftware.* to Novelly.*, including the entity type
  names recorded in the EF model snapshots. The migration ids are untouched, so
  an existing novel.db still migrates cleanly — verified against a fresh file.
- Aspire orchestration mirrors the mic-check setup: the AppHost starts the API
  on :5080 and the Vite dev server on :5173, and the API picks up OpenTelemetry,
  health checks and service discovery from ServiceDefaults. /health and /alive
  now answer in development.
- A Husky pre-push hook runs scripts/ci/prepush.sh: build, test, then a web
  build. The scripts are plain bash so CI can run the same steps.
- The MCP server's env var is now NOVELLY_API_URL.

Verified beyond the build: 44 tests pass, the web client builds, the API was
exercised over curl (project/chapter/beat/tag round trip, tag cross-reference,
503 on the agent without a key while conversation listing still returns 200),
the MCP server was driven over stdio JSON-RPC (26 tools, errors still surface
the API's own message rather than being flattened), and the AppHost was run to
confirm both resources come up and Vite proxies /api through to the API.

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 30e0c6926e
commit 725758ccd9
120 changed files with 811 additions and 421 deletions
+416
View File
@@ -0,0 +1,416 @@
using System.Text.Json;
using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Projects;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Agent;
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
public record AgentToolResult(string Content, bool IsError);
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
public record AgentTool(
string Name,
string Description,
JsonElement InputSchema,
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
/// <summary>
/// The tools the writing agent can reach for. Everything here goes through the same
/// application services the REST API uses, so an edit made by the agent is
/// indistinguishable from one made in the UI.
/// </summary>
public class NovelAgentToolset(
ProjectService projects,
CharacterService characters,
ChapterService chapters,
BeatService beats,
SceneService scenes,
TagService tags)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = false,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private Dictionary<string, AgentTool>? _byName;
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
/// <summary>
/// Runs a tool and serialises its result. Failures come back as text rather than
/// exceptions so the model can read the message and correct itself.
/// </summary>
public async Task<AgentToolResult> ExecuteAsync(
string name, Guid projectId, JsonElement input, CancellationToken ct = default)
{
if (!ByName.TryGetValue(name, out var tool))
{
return new AgentToolResult($"No such tool: '{name}'.", true);
}
try
{
var result = await tool.Handler(projectId, input, ct);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
}
catch (NotFoundException ex)
{
return new AgentToolResult(ex.Message, true);
}
catch (ArgumentException ex)
{
return new AgentToolResult(ex.Message, true);
}
catch (InvalidOperationException ex)
{
return new AgentToolResult(ex.Message, true);
}
}
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
private IEnumerable<AgentTool> Build()
{
yield return new AgentTool(
"get_project_brief",
"Read the project'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 projects.GetAsync(projectId, ct));
yield return new AgentTool(
"update_project_brief",
"Revise the project's top-level fields. Only the fields you supply change; "
+ "pass an empty string to clear a field.",
new JsonSchemaBuilder()
.Str("title", "New title.")
.Str("author", "Author name.")
.Str("genre", "Genre or category.")
.Str("logline", "One-sentence pitch.")
.Str("synopsis", "Paragraph-length summary of the whole book.")
.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 projects.UpdateAsync(projectId, new UpdateProjectRequest(
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));
yield return new AgentTool(
"list_characters",
"List every character in the project with their full dossiers.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await characters.ListAsync(projectId, ct));
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 characters.CreateAsync(projectId, new CreateCharacterRequest(
JsonInput.RequiredString(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
JsonInput.String(input, "age"),
JsonInput.String(input, "pronouns"),
JsonInput.String(input, "occupation"),
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"update_character",
"Revise an existing character dossier. Only the fields you supply change.",
CharacterSchema(includeName: true, nameRequired: false)
.Str("character_id", "Id of the character to update.", required: true)
.Build(),
async (_, input, ct) => await characters.UpdateAsync(
JsonInput.RequiredGuid(input, "character_id"),
new UpdateCharacterRequest(
JsonInput.String(input, "name"),
JsonInput.Enum<CharacterRole>(input, "role"),
JsonInput.String(input, "age"),
JsonInput.String(input, "pronouns"),
JsonInput.String(input, "occupation"),
JsonInput.String(input, "appearance"),
JsonInput.String(input, "personality"),
JsonInput.String(input, "backstory"),
JsonInput.String(input, "want"),
JsonInput.String(input, "need"),
JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"get_chapter_outline",
"Read a chapter's outline: its summary paragraph and its beat table, in order. "
+ "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
.Build(),
async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
yield return new AgentTool(
"create_beat",
"Add a beat to a chapter's outline. Keep the title to three to five words — it is a "
+ "handle, not a sentence; the detail belongs in what_happened and whats_next.",
BeatSchema()
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Str("title", "Three to five words naming the beat.", required: true)
.Build(),
async (_, input, ct) => await beats.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateBeatRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"update_beat",
"Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
+ "the beat's tags outright, so include the ones you want to keep.",
BeatSchema()
.Str("beat_id", "Id of the beat to update.", required: true)
.Str("title", "Three to five words naming the beat.")
.Build(),
async (_, input, ct) => await beats.UpdateAsync(
JsonInput.RequiredGuid(input, "beat_id"),
new UpdateBeatRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"delete_beat",
"Remove a beat from a chapter's outline. Confirm with the writer before calling it.",
new JsonSchemaBuilder()
.Str("beat_id", "Id of the beat to delete.", required: true)
.Build(),
async (_, input, ct) =>
{
await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct);
return new { deleted = true };
});
yield return new AgentTool(
"reorder_beats",
"Renumber a chapter's beats to match the order given. List every beat id in the "
+ "order you want; any you leave out keep their relative position at the end.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
.Build(),
async (_, input, ct) => await beats.ReorderAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new ReorderBeatsRequest(
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
.Where(g => g != Guid.Empty)]), ct));
yield return new AgentTool(
"list_tags",
"List the project'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));
yield return new AgentTool(
"get_tag_references",
"Cross-reference a tag: every character, chapter and beat carrying it. Use this to "
+ "trace a motif or a thread through the book.",
new JsonSchemaBuilder()
.Str("tag_id", "Id of the tag to trace.", required: true)
.Build(),
async (_, input, ct) => await tags.GetReferencesAsync(JsonInput.RequiredGuid(input, "tag_id"), ct));
yield return new AgentTool(
"list_chapters",
"List the project's chapters in manuscript order with scene and word counts.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await chapters.ListAsync(projectId, ct));
yield return new AgentTool(
"get_chapter",
"Read one chapter in full, including all of its scenes and any drafted prose.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to read.", required: true)
.Build(),
async (_, input, ct) => await chapters.GetAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
yield return new AgentTool(
"create_chapter",
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
new JsonSchemaBuilder()
.Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based.")
.Str("summary", "What the chapter covers.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("setting", "Where and when the chapter takes place.")
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"update_chapter",
"Revise a chapter's title, number, summary, POV, setting, notes or status.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to update.", required: true)
.Str("title", "New title.")
.Int("number", "Position in the manuscript.")
.Str("summary", "What the chapter covers.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("setting", "Where and when the chapter takes place.")
.Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(),
async (_, input, ct) => await chapters.UpdateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new UpdateChapterRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"create_scene",
"Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
+ "draftable later, so fill those in when the writer has given you enough to work with.",
SceneSchema()
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
.Str("title", "Scene title.", required: true)
.Build(),
async (_, input, ct) => await scenes.CreateAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new CreateSceneRequest(
JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "summary"),
JsonInput.String(input, "goal"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "outcome"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct));
yield return new AgentTool(
"update_scene",
"Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; "
+ "the word count is recomputed automatically.",
SceneSchema()
.Str("scene_id", "Id of the scene to update.", required: true)
.Str("title", "New title.")
.Build(),
async (_, input, ct) => await scenes.UpdateAsync(
JsonInput.RequiredGuid(input, "scene_id"),
new UpdateSceneRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "summary"),
JsonInput.String(input, "goal"),
JsonInput.String(input, "conflict"),
JsonInput.String(input, "outcome"),
JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status")), ct));
}
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
{
var schema = new JsonSchemaBuilder();
if (includeName)
{
schema.Str("name", "The character's name.", nameRequired);
}
return schema
.Enum("role", "The part they play in the story.", System.Enum.GetNames<CharacterRole>())
.Str("age", "Age, exact or approximate.")
.Str("pronouns", "The pronouns this character uses.")
.Str("occupation", "What they do.")
.Str("appearance", "How they look.")
.Str("personality", "Temperament, habits, how they treat people.")
.Str("backstory", "History that shapes who they are now.")
.Str("want", "What they consciously pursue.")
.Str("need", "What they actually need, usually at odds with what they want.")
.Str("internal_conflict", "The war inside them.")
.Str("external_conflict", "What in the world opposes them.")
.Str("arc_summary", "How they change over the course of the book.")
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
.Str("notes", "Anything else worth recording.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
}
private static JsonSchemaBuilder BeatSchema() =>
new JsonSchemaBuilder()
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
.Str("character_id", "Id of the character whose beat this is.")
.Str("what_happened", "The event itself.")
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
.Str("scene_id", "Id of the scene this beat will be written into, if decided.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
private static JsonSchemaBuilder SceneSchema() =>
new JsonSchemaBuilder()
.Int("sort_order", "Position within the chapter. Appended to the end when omitted.")
.Str("summary", "What happens in the scene.")
.Str("goal", "What the POV character is trying to achieve.")
.Str("conflict", "What stands in the way.")
.Str("outcome", "How it lands, and what it costs.")
.Str("pov_character_id", "Id of the point-of-view character.")
.Str("location", "Where the scene takes place.")
.Str("prose", "The drafted prose for this scene.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>());
}