v0.3 step 2: Orson Black soul + author-aware forge + skald continue CLI

Three new pieces lock the gen pipeline:

1. seeds/authors/orson-black.md — Orson Black's soul. ~2k words,
   strict-headings format per cobb's decision. Voice / Worldview /
   Specifics / Pet peeves / Sense of humor / Biography (fully
   fictional — synthetic literary persona; coal town Durham 1948,
   father died of pneumoconiosis at 14, two winters as welder's
   apprentice on the Tyne, etc) / Anchor authors (Orwell, McCarthy,
   DeLillo, Judt, Modiano) / Do / Don't.

2. skald-core::forge — author-aware. Forge::generate, ::cleanup,
   and ::audit now take Option<&AuthorWithRevision>. When Some:
   scaffold + soul composed and passed as SystemMode::Replace —
   the model BECOMES the author. When None: house neutral scaffold,
   Append mode, claude defaults stay. audit() always neutral,
   regardless of author. Real prompt templates ship for gen +
   cleanup (the prose-craft IP we were deferring) — scaffold has
   {{display_name}}, {{pass_directive}}, {{soul}} substitutions,
   plus separate gen/cleanup directive blocks.

3. skald continue --story <uuid> [--author SLUG] [--direction
   STR] [--target-words N] [--recent N] [--skip-audit] — the
   pipeline CLI:

   load story → resolve author (--flag wins, else story.author_id,
   else None) → pin author/revision onto story → assemble
   ContinuationContext from parent chain → run gen pass →
   parse heading + insert chapter + passages → run cleanup pass
   on the draft → replace chapter body + passages → run audit
   pass (parent prose vs new chapter vs bible) → parse JSON
   findings into audit_findings table → status flow
   seed→generating→cleaning→auditing→complete.

   Plus skald authors seed --slug --display-name --tagline --file
   --note for loading souls from disk into the DB.

End-to-end testable: seed Orson Black, create a sequel stub via
web or SQL, fire 'skald continue' against it. Coast-Down chapter 8
in Orson's voice is the smoke test.
This commit is contained in:
Kayos 2026-05-13 12:06:28 -07:00
parent 713ba41977
commit b08d6ee8bc
5 changed files with 1016 additions and 57 deletions

View file

@ -30,9 +30,10 @@
use std::time::Duration;
use clawdforge::{Client, ClientBuilder, RunRequest, RunResult};
use clawdforge::{Client, ClientBuilder, RunRequest, RunResult, SystemMode};
use serde::{Deserialize, Serialize};
use crate::authors::AuthorWithRevision;
use crate::config::ForgeConfig;
/// Thin wrapper around the clawdforge `Client`. Configured once,
@ -105,46 +106,74 @@ impl Forge {
///
/// Prompt template is TODO (v0.2). Stub builds the simplest
/// possible request shape so the wiring compiles.
pub async fn generate(&self, prompt: &str, context: &str) -> anyhow::Result<PassOutput> {
let body = build_request(
&self.model,
PassKind::Gen,
prompt,
context,
SYSTEM_GEN_TODO,
);
/// Generation pass — produce the next chapter prose. If
/// `author` is supplied, the soul REPLACES claude's base system
/// prompt (SystemMode::Replace); the model becomes the author.
/// If None, we fall back to default append-mode with the
/// "House" neutral scaffold.
pub async fn generate(
&self,
context: &str,
direction: Option<&str>,
target_words: Option<i32>,
author: Option<&AuthorWithRevision>,
) -> anyhow::Result<PassOutput> {
let user_prompt = gen_user_prompt(context, direction, target_words);
let (system, mode) = compose_system(author, PassKind::Gen);
let body = RunRequest {
prompt: user_prompt,
model: Some(self.model.clone()),
system: Some(system),
system_mode: Some(mode),
timeout_secs: Some(600),
..Default::default()
};
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Gen, result: r, duration_ms })
}
/// Cleanup / humanize pass over the gen draft.
pub async fn cleanup(&self, draft: &str, context: &str) -> anyhow::Result<PassOutput> {
let body = build_request(
&self.model,
PassKind::Cleanup,
draft,
context,
SYSTEM_CLEANUP_TODO,
);
/// Cleanup / humanize pass over the gen draft. Author voice
/// stays — same scaffold + soul, same Replace mode.
pub async fn cleanup(
&self,
draft: &str,
context: &str,
author: Option<&AuthorWithRevision>,
) -> anyhow::Result<PassOutput> {
let user_prompt = cleanup_user_prompt(draft, context);
let (system, mode) = compose_system(author, PassKind::Cleanup);
let body = RunRequest {
prompt: user_prompt,
model: Some(self.model.clone()),
system: Some(system),
system_mode: Some(mode),
timeout_secs: Some(600),
..Default::default()
};
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Cleanup, result: r, duration_ms })
}
/// Canon audit comparing parent + sequel against the bible.
/// Expected to return structured JSON parseable into
/// `Vec<AuditFinding>`.
pub async fn audit(&self, parent_prose: &str, sequel_prose: &str, bible: &str) -> anyhow::Result<PassOutput> {
let body = build_audit_request(
&self.model,
parent_prose,
sequel_prose,
bible,
);
/// Canon audit — neutral always. Author intentionally NOT
/// threaded through; the audit checks the author's work with
/// fresh eyes. Stays on Append (claude's default base prompt is
/// useful substrate for structured-JSON tool-use).
pub async fn audit(
&self,
parent_prose: &str,
sequel_prose: &str,
bible: &str,
) -> anyhow::Result<PassOutput> {
let body = build_audit_request(&self.model, parent_prose, sequel_prose, bible);
let r = self.client.run(body).await?;
let duration_ms = r.duration_ms;
Ok(PassOutput { kind: PassKind::Audit, result: r, duration_ms })
Ok(PassOutput {
kind: PassKind::Audit,
result: r,
duration_ms,
})
}
/// Summarize one chapter to ~250 words. The summary feeds into
@ -186,48 +215,114 @@ fiction author. You write chapter summaries that future authors of sequels \
will read to understand what happened. Be specific. Names, dates, locations. \
Don't editorialize just compress the events.";
fn build_request(model: &str, kind: PassKind, primary: &str, context: &str, system: &str) -> RunRequest {
let prompt = format!(
"# Pass: {kind}\n\n## Context\n\n{context}\n\n## Input\n\n{primary}",
kind = kind.as_str(),
);
RunRequest {
prompt,
model: Some(model.to_string()),
system: Some(system.to_string()),
timeout_secs: Some(600),
..Default::default()
// ─── System-prompt composition ──────────────────────────────────
//
// Three passes, three system-prompt shapes:
//
// - Gen / Cleanup with an author: scaffold + soul, sent as
// SystemMode::Replace. The model BECOMES the author.
// - Gen / Cleanup without an author: a neutral house scaffold,
// sent as Append (claude's default base prompt stays).
// - Audit: always neutral, always Append. The audit checks the
// author's work with fresh eyes.
fn compose_system(author: Option<&AuthorWithRevision>, kind: PassKind) -> (String, SystemMode) {
match author {
Some(a) => {
let scaffold = a
.revision
.system_template
.as_deref()
.unwrap_or(DEFAULT_AUTHOR_SCAFFOLD);
let pass_directive = match kind {
PassKind::Gen => GEN_DIRECTIVE,
PassKind::Cleanup => CLEANUP_DIRECTIVE,
_ => "",
};
let composed = scaffold
.replace("{{display_name}}", &a.author.display_name)
.replace("{{pass_directive}}", pass_directive)
.replace("{{soul}}", &a.revision.soul);
(composed, SystemMode::Replace)
}
None => {
let house = match kind {
PassKind::Gen => HOUSE_GEN_SYSTEM,
PassKind::Cleanup => HOUSE_CLEANUP_SYSTEM,
_ => HOUSE_GEN_SYSTEM,
};
(house.to_string(), SystemMode::Append)
}
}
}
const DEFAULT_AUTHOR_SCAFFOLD: &str = r#"You are {{display_name}}, an author. Your voice and identity are described in the soul block below. Honor them in every sentence — not as performance, but as substrate. You are not playing a role; you ARE the author.
You will receive a user-facing prompt containing the story's canon (characters, setting, established facts), prior chapters or summaries of them, and an optional user direction.
Hard rules:
- Canon is non-negotiable. Names, dates, established events do not bend to your voice. Your voice bends to them.
- Voice is yours. Express it.
- Return ONLY the requested output. No preamble, no "here is the chapter," no commentary about the task, no closing remarks. Start with the requested heading line.
- Markdown formatting (## headings, blank-line paragraphs, em-dashes) is fine.
- Never break the fourth wall to comment on yourself or the task.
{{pass_directive}}
---
{{soul}}
"#;
const GEN_DIRECTIVE: &str = "This is a GENERATION pass. Write the next chapter from scratch. Honor canon. Begin with a chapter heading on the first line.";
const CLEANUP_DIRECTIVE: &str = "This is a CLEANUP pass. The user prompt contains a draft you wrote. Polish for prose quality — tighten dialogue, fix pacing dead spots, hold your voice steady. Do NOT add new plot, do NOT retcon canon. Return ONLY the polished chapter.";
const HOUSE_GEN_SYSTEM: &str = "You are a long-form fiction author writing the next chapter of a series. Honor the canon (characters, setting, established facts) exactly. Return only the chapter prose, starting with a heading line. No preamble.";
const HOUSE_CLEANUP_SYSTEM: &str = "You are a copy editor polishing a draft chapter. Tighten dialogue, fix pacing, keep voice consistent. Do not add new plot. Return only the polished chapter.";
const SYSTEM_AUDIT: &str = "You are a canon auditor for long-form fiction. You compare a parent story and a new chapter against the bible. You flag continuity drift, character voice shift, retconned facts, dropped threads, timeline contradictions. You return STRUCTURED JSON ONLY — no commentary, no preamble. The exact shape: { \"findings\": [ { \"severity\": \"info\"|\"warn\"|\"crit\", \"area\": \"character\"|\"continuity\"|\"tone\"|\"fact\"|\"timeline\"|\"other\", \"body\": \"...\" } ] }. If no findings, return { \"findings\": [] }.";
// ─── User-prompt builders ───────────────────────────────────────
fn gen_user_prompt(context: &str, direction: Option<&str>, target_words: Option<i32>) -> String {
let mut out = String::with_capacity(context.len() + 512);
out.push_str("# Story canon and prior chapters\n\n");
out.push_str(context);
out.push_str("\n\n# User direction\n\n");
out.push_str(direction.unwrap_or("(none — choose what the next chapter should explore based on the canon and recent prose)"));
out.push_str("\n\n# Task\n\nWrite the next chapter. ");
if let Some(w) = target_words {
out.push_str(&format!("Target length: roughly {} words. ", w));
}
out.push_str("Begin with a chapter heading line (`## Chapter N — title or date`). Return only the chapter prose. No preamble.\n");
out
}
fn cleanup_user_prompt(draft: &str, context: &str) -> String {
let mut out = String::with_capacity(context.len() + draft.len() + 512);
out.push_str("# Story canon (for reference — do not retcon)\n\n");
out.push_str(context);
out.push_str("\n\n# Draft to polish\n\n");
out.push_str(draft);
out.push_str("\n\n# Task\n\nPolish the draft above for prose quality. Tighten dialogue, fix pacing dead spots, hold the voice steady. Do NOT add new plot. Do NOT retcon canon. Return only the polished chapter.\n");
out
}
fn build_audit_request(model: &str, parent: &str, sequel: &str, bible: &str) -> RunRequest {
let prompt = format!(
"## Bible\n\n{bible}\n\n## Parent story prose\n\n{parent}\n\n## Sequel story prose\n\n{sequel}\n\nReturn JSON: {{ \"findings\": [ {{ \"severity\": \"info|warn|crit\", \"area\": \"character|continuity|tone|fact|timeline|other\", \"body\": \"...\" }} ] }}"
"## Bible\n\n{bible}\n\n## Parent story prose\n\n{parent}\n\n## Sequel story prose\n\n{sequel}\n\nReturn JSON only matching the schema described in the system prompt.",
);
RunRequest {
prompt,
model: Some(model.to_string()),
system: Some(SYSTEM_AUDIT_TODO.to_string()),
system: Some(SYSTEM_AUDIT.to_string()),
timeout_secs: Some(600),
..Default::default()
}
}
// ─── Prompt templates (TODO v0.2 — these are placeholder stubs) ───
const SYSTEM_GEN_TODO: &str = "You are a long-form fiction author. \
Write in measured, literary prose. Honor the bible and character voices \
exactly. (Full prompt template: TODO v0.2.)";
const SYSTEM_CLEANUP_TODO: &str = "You are a copy editor for long-form fiction. \
Polish the draft for prose quality, tighten dialogue, fix pacing dead \
spots, keep voice consistent. Do not add new plot. (Full prompt template: TODO v0.2.)";
const SYSTEM_AUDIT_TODO: &str = "You are a canon auditor. Compare the parent \
and sequel against the bible. Flag contradictions, character voice drift, \
retconned facts, dropped threads, timeline issues. Output structured \
JSON only no commentary. (Full prompt template: TODO v0.2.)";
/// Audit finding shape returned by the audit pass. Parses out of the
/// `result` field on the audit pass's [`RunResult`].
#[derive(Debug, Clone, Serialize, Deserialize)]