Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- **[issue-2168] A new "adversarial cuts" editorial check and mechanical applier tightens prose by identifying what a ruthless editor would cut.** The `prose.adversarial-cuts` check asks an LLM editor persona to cut 8–12% of the text as 10–20 passages, classifying each cut by type: FAT (purple prose), REDUNDANT (repeated information), OVER-EXPLAIN (spelling out what's clear), GENERIC (stock phrases), TELL (emotion-telling after showing), or STRUCTURAL (scenes that don't advance the story). Each cut lands as a manuscriptReview finding with the exact verbatim quote. The Manuscript Editor gains an "Apply safe cuts" action that batch-removes OVER-EXPLAIN and REDUNDANT cuts mechanically — exact string match with whitespace-normalized fallback, refusing ambiguous or too-short quotes — through the same snapshotted stage-write path fixes use (full undo via version history). The check also reports `fat_percentage`, `tightest_passage` (protected), and `loosest_passage` for a quick health read.
- **[issue-2170] A simulated four-persona reader panel now reads your whole series and shows you where the readers disagree.** Convene the panel from a new **Reader Panel** tab on the Reader Map page (deep-linkable via `?tab=panel`) and four distinct readers — The Editor (prose texture, subtext, over-explaining), The Genre Reader (pacing and page-turn pull, "gets bored by beautiful prose that doesn't GO anywhere"), The Writer (structure, beats, foreshadowing payoff, whose worst insult is "I can see the outline"), and The First Reader (pure emotional response, no craft vocabulary) — each read a condensed, content-hash-pinned arc digest of the series (per-issue summary, opening/closing excerpts, and notable dialogue) and answer the same ten qualitative questions (where momentum is lost, is the ending earned, what to cut, a missing scene, the thinnest character, best/worst scene, would-recommend, what haunts you, would-read-the-next-book), citing specific issue numbers. The editorial signal is where they *disagree*: an issue flagged by 3+ personas is routed straight into your editorial-findings triage flow as a consensus concern; softer some-flagged-some-didn't calls and "polarizing" issues (one persona's best scene is another's worst) are surfaced for your judgment. One LLM call per persona, run only from the explicit Convene button — nothing fires on its own.
- **[issue-2175] Worldbuilding craft doctrine now shapes universe generation up-front, and two new editorial checks verify it.** The universe-expansion prompt now injects Sanderson's-Laws craft doctrine before the writer model drafts — limitations over powers, costs that drive plot decisions, solutions foreshadowed before they resolve, iceberg depth, interconnected systems, 2–3 societal implications per major system, and a sensory signature per location — so autonomous and interactive runs alike start from a lived-in world instead of decorative set dressing (Series Autopilot calls the same stages, so autonomous mode is upgraded automatically). Two new series-scope editorial checks close the loop on the review side: `world.unforeshadowed-solution` (a plot problem solved by a rule/power the reader was never shown — the worldbuilding sibling of the deus ex machina) and `world.cost-free-power` (an ability used at a decisive moment with no cost or limitation on the page). Both reconcile the prose against the established world canon and the continuity-bible world-rule facts, so a rule that WAS planted earlier is not flagged. (First slice of #2175 — the character-framework and structure-rule pillars ship separately.)
- **[issue-2175] Prose drafting now carries Le Guin's craft doctrine — "style is not ornament, it IS the story" — so first drafts reach for strong verbs instead of adjective-noun clichés.** Following the worldbuilding-doctrine (#2219) and character-framework/structure (#2245) slices, the prose-writing stages (prose, comic script, teleplay) now inject a short, always-on prose-craft block ahead of drafting: style is the reader's experience of the world, not decoration over it; prefer specific nouns and verbs to adjective/adverb padding and cut dead clichés ("ancient wisdom", "piercing gaze", "heavy silence"); let concrete sensory detail carry meaning rather than summarizing an emotion; and vary sentence length and rhythm on purpose. It rides the existing `{{series.styleNotes}}` template variable (via `composeStyleNotes`), so there's no new prompt-template variable and no migration — and it's scoped to the drafting stages only, staying out of the structural beat-sheet/arc/episode-seed passes where sentence-level craft would just be noise. This completes CWQE Phase 10 (#2175).
- **[issue-2179] A series style guide can now carry concrete voice exemplar and anti-exemplar passages — "the tuning fork" — that get injected into every draft and revision prompt.** Adjective lists ("witty, atmospheric") anchor prose voice far more weakly than an actual passage does, so the series style guide gains two new fields: `voiceExemplars` (1–3 short passages that nail the target voice, each with an optional one-line note on what it demonstrates) and `voiceAntiExemplars` (passages in the wrong register kept as negative examples, noted with what's wrong — "too ornate", "wrong emotional temperature"). Add and edit them inline on the series style-guide editor. On generation they render into the house-style block as a "MATCH this voice — echo their rhythm, diction, and register" block and a "NEVER drift toward this" block — conditional, so a guide with no passages injects nothing. They ride the existing `{{series.styleNotes}}` template variable (via `composeStyleNotes`), so prose drafting, revision, arc planning, and episode-seed passes all honor them with no new prompt-template variable and no migration. The sanitizer drops empty passages, trims each to ~2000 chars, and caps the list at 3 so the fixed per-call prompt overhead stays tight. (First slice of #2179 — the AI-assisted voice-discovery flow, Writers Room parity, and the voice-fingerprint baseline hook ship separately.)
- **[issue-2178] Canon can now be reveal-gated so a mystery or twist doesn't leak into the prose before it's due.** Canon context — including character secrets and late-story facts — was injected into every issue's drafting prompt, so the writer model could (and would) spill a reveal issues early. Each canon entry (character, place, or object) now carries optional reveal gating: a `revealIssue` number hides the fact from any issue before it; a `spoiler` flag hard-hides it from all drafting context; and an optional `surfaceDescriptor` is the spoiler-free stand-in shown before the reveal ("the locked east wing" instead of "the wing where the heir is imprisoned"). The drafting/rewrite prompts (`buildStageContext`, the alternate-POV rewrite cast) filter canon to each issue's reveal horizon, while the judge and editorial checks still receive the full canon so they can verify hint placement. Absent gate fields mean "always visible," so every existing canon entry and every federation peer round-trips unchanged. A reveal-timing editor with a spoiler badge lands on every canon-entry card (universe + series). A new `continuity.premature-reveal` editorial check (gated on the series having authored any reveal-gated canon) scans drafted prose for a gated fact stated or unambiguously implied before its reveal issue, distinguishing a genuine leak from deliberate foreshadowing — and its reveal issue feeds the Chekhov setups-payoffs check as an authored payoff so an orphaned reveal is flagged too.
- **[issue-2177] Prose drafts now flow across issue boundaries instead of each issue opening and closing in a vacuum.** When drafting an issue's prose, the generator now injects the previous issue's actual closing prose (its last ~2000 characters) and the next issue's opening beats into the draft prompt — with guidance to open so it flows from the previous ending (without repeating its closing image or re-explaining what the reader just read) and to land the ending so it hands off to the next issue's beats. This is what keeps the narrative voice carrying across units and prevents the "every issue opens/closes the same way" tic, improving the autonomous first draft so the editorial loop has less to fix. Both blocks are token-budgeted (they degrade by trimming on a small-context local model rather than erroring), and when the previous issue hasn't been drafted yet — non-linear or parallel generation, or the first issue of a volume — the block simply doesn't render, so there's never any fabricated continuity. Every full prose (re)generation path gets it: the manual draft button, the auto-runner, and Series Autopilot. Existing installs auto-upgrade their prose prompt (a customized prompt is left untouched and flagged).
Expand Down
28 changes: 26 additions & 2 deletions server/lib/styleGuide.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ export const STYLE_GUIDE_LIMITS = Object.freeze({
EXEMPLAR_NOTE_MAX: 200,
});

// Le Guin prose-craft doctrine (#2175, CWQE Phase 10) — always-on generation-side
// craft guidance, the prose sibling of the Sanderson's-Laws worldbuilding doctrine
// baked into `buildExpansionPrompt`. Ursula K. Le Guin's core lesson from *Steering
// the Craft*: style is not decoration laid over a story — the sound and rhythm of the
// sentences ARE the reader's experience of the world. Kept as short imperative rules
// (not essays) per the "prompts stay lean" rule; injected only into the prose/text
// writing stages (see composeStyleNotes' `proseCraft` option), never the structural
// arc/episode-seed planning stages where prose-level craft would be noise. This is
// baked-in doctrine, NOT a per-series field, so it needs no schema/migration.
export const PROSE_CRAFT_DOCTRINE = Object.freeze([
'Prose craft (Le Guin — apply throughout):',
'- Style is not ornament — it IS the story: the sound, rhythm, and syntax of the sentences create the world the reader lives in, so shape them deliberately.',
'- Prefer strong, specific nouns and verbs to adjective/adverb padding. Cut dead adjective-noun clichés ("ancient wisdom", "piercing gaze", "heavy silence", "cold fury") — replace them with a concrete image or a sharper verb.',
'- Let concrete sensory detail carry meaning; do not summarize an emotion the scene can show.',
'- Vary sentence length and rhythm on purpose — short for impact, longer to build; never let every sentence fall into the same cadence.',
].join('\n'));

export const STYLE_GUIDE_TENSES = Object.freeze(['past', 'present']);
export const STYLE_GUIDE_POV_PERSONS = Object.freeze(['first', 'third-limited', 'third-omniscient', 'second']);
export const STYLE_GUIDE_AUDIENCES = Object.freeze(['children', 'middle-grade', 'YA', 'adult']);
Expand Down Expand Up @@ -227,9 +244,16 @@ export function renderStyleGuide(styleGuide) {
*
* The structured guide leads (deterministic house style first), the author's
* free-text notes trail. Returns `''` when neither is present.
*
* `opts.proseCraft` (default false) appends the always-on Le Guin prose-craft
* doctrine (#2175). It's opt-in per stage: the prose/text writing stages pass
* `true` so drafting follows the craft rules; the structural arc/episode-seed
* planning stages leave it off, since sentence-level craft guidance is noise
* when the model is outlining beats rather than writing prose.
*/
export function composeStyleNotes(series) {
export function composeStyleNotes(series, opts = {}) {
const guide = renderStyleGuide(series?.styleGuide);
const notes = isStr(series?.styleNotes) ? series.styleNotes.trim() : '';
return [guide, notes].filter(Boolean).join('\n\n');
const craft = opts.proseCraft ? PROSE_CRAFT_DOCTRINE : '';
return [guide, notes, craft].filter(Boolean).join('\n\n');
}
41 changes: 41 additions & 0 deletions server/lib/styleGuide.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest';
import {
sanitizeStyleGuide,
renderStyleGuide,
composeStyleNotes,
PROSE_CRAFT_DOCTRINE,
STYLE_GUIDE_LIMITS,
} from './styleGuide.js';

Expand Down Expand Up @@ -163,3 +165,42 @@ describe('renderStyleGuide', () => {
expect(block).not.toContain('NEVER drift toward this');
});
});

describe('composeStyleNotes', () => {
it('returns empty string when neither guide nor notes nor craft is present', () => {
expect(composeStyleNotes(null)).toBe('');
expect(composeStyleNotes({})).toBe('');
expect(composeStyleNotes({ styleNotes: ' ' })).toBe('');
});

it('leads with the rendered guide, trails with free-text notes', () => {
const out = composeStyleNotes({
styleGuide: sanitizeStyleGuide({ tense: 'past' }),
styleNotes: 'Noir and rain.',
});
expect(out).toContain('past tense');
expect(out).toContain('Noir and rain.');
expect(out.indexOf('past tense')).toBeLessThan(out.indexOf('Noir and rain.'));
});

it('does NOT append prose-craft doctrine by default (structural stages)', () => {
const out = composeStyleNotes({ styleNotes: 'Noir.' });
expect(out).toBe('Noir.');
expect(out).not.toContain('Le Guin');
expect(out).not.toContain('Prose craft');
});

it('appends the Le Guin prose-craft doctrine when proseCraft is set', () => {
const out = composeStyleNotes({ styleNotes: 'Noir.' }, { proseCraft: true });
expect(out).toContain('Noir.');
expect(out).toContain(PROSE_CRAFT_DOCTRINE);
expect(out).toContain('ancient wisdom'); // one of the banned clichés
// Author notes lead; the baked doctrine trails.
expect(out.indexOf('Noir.')).toBeLessThan(out.indexOf('Prose craft'));
});

it('appends craft even with an otherwise-empty series', () => {
const out = composeStyleNotes(null, { proseCraft: true });
expect(out).toBe(PROSE_CRAFT_DOCTRINE);
});
});
7 changes: 6 additions & 1 deletion server/services/pipeline/perspectiveRewrite.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { runStagedLLM, resolveStageContext } from '../../lib/stageRunner.js';
import { manuscriptContentBudgetChars, estimateTokens } from '../../lib/contextBudget.js';
import { richCanonDescriptorFragments, flattenCanonDescriptorFragments } from '../../lib/canonPrompt.js';
import { filterCanonListForIssue } from '../../lib/storyBible.js';
import { composeStyleNotes } from '../../lib/styleGuide.js';
import { getIssue } from './issues.js';
import { getSeries } from './series.js';
import { getSeriesCanon } from './seriesCanon.js';
Expand Down Expand Up @@ -249,7 +250,11 @@ export async function generatePerspectiveRewrite(issueId, { povCharacterId, sour
const seriesVars = {
name: series?.name || 'Untitled series',
logline: series?.logline || '',
styleNotes: series?.styleNotes || '',
// POV rewrite is a prose-generating stage, so fold in the structured style
// guide, #2179 voice exemplars, and the #2175 Le Guin prose-craft doctrine
// via composeStyleNotes — not the raw free-text notes, which would bypass
// all three. proseCraft:true matches the prose/comicScript/teleplay stages.
styleNotes: composeStyleNotes(series, { proseCraft: true }),
characters: cast,
};
const issueVars = { number: issue.number, title: issue.title };
Expand Down
6 changes: 5 additions & 1 deletion server/services/pipeline/textStages.js
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,11 @@ function buildStageContext({ series, canon, world, issue, stageId, seedInput, so
// conventions) into the free-text styleNotes the template already renders,
// so prose/script generation honors house style with no new template
// variable (and thus no stage-prompt migration). See composeStyleNotes.
styleNotes: composeStyleNotes(series),
// The Le Guin prose-craft doctrine (#2175) rides along in the same fold,
// but only for the prose-writing stages (prose/comicScript/teleplay) — the
// `idea` beat-sheet stage is outlining, not drafting, so sentence-level
// craft rules would be noise there.
styleNotes: composeStyleNotes(series, { proseCraft: ROSTER_BACKED_STAGES.has(stageId) }),
universeId: series.universeId || '',
characters: scopedCharacters,
},
Expand Down