Skip to content

Commit c51b0bc

Browse files
committed
add reveal-gated canon (spoiler scoping) + continuity.premature-reveal check
Canon context is injected into every issue's drafting prompt, so a withheld character secret or late-story twist could leak into the prose long before its reveal. Canon entries now carry optional reveal gating: revealIssue (hide before that issue), spoiler (hard-hide from all drafting), and surfaceDescriptor (the spoiler-free pre-reveal stand-in). buildStageContext and the alternate-POV rewrite cast filter canon to each issue's reveal horizon; the judge and editorial checks keep the full canon so they can verify hint placement. Absent gate fields = always visible, so existing entries and federation peers round-trip unchanged. Adds a reveal-timing editor + spoiler badge to every canon-entry card, and a continuity.premature-reveal editorial check (gated on any reveal-gated canon existing) that flags a gated fact leaked before its reveal issue, distinguishing a leak from deliberate foreshadowing. The reveal issue also feeds the Chekhov setups-payoffs check as an authored payoff. Migration 168 seeds the new stage prompt into existing installs. Closes #2178
1 parent 549d820 commit c51b0bc

22 files changed

Lines changed: 1154 additions & 11 deletions

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
- **[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.
2727
- **[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.)
2828
- **[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.)
29+
- **[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.
2930

3031
## Human Activity Tracking
3132

client/src/components/pipeline/CanonCard.jsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import CharacterDetailEditor from '../universe/CharacterDetailEditor';
2323
import ObjectAttachmentsEditor from '../universe/ObjectAttachmentsEditor';
2424
import CharacterReferenceSheetPanel from '../universe/CharacterReferenceSheetPanel';
2525
import CharacterLoraChip from '../loraTraining/CharacterLoraChip';
26+
import Pill from '../ui/Pill';
2627
import { BIBLE_LIMITS } from '../../lib/bibleLimits';
2728

2829
// Place metadata enums — kept in lock-step with `PLACE_INT_EXT` and
@@ -98,6 +99,113 @@ function ReadonlyChip({ children }) {
9899
);
99100
}
100101

102+
// A canon entry is reveal-gated (#2178) when it carries a hard `spoiler` flag
103+
// or a numeric `revealIssue`. Mirrors the server's `isEntryRevealGated`.
104+
const isEntryGated = (entry) => entry?.spoiler === true || Number.isInteger(entry?.revealIssue);
105+
106+
// Spoiler badge shown in the card title when an entry is reveal-gated (#2178).
107+
// Purely informational; the gating itself happens server-side in
108+
// `buildStageContext`. Uses the shared `Pill` primitive (warning tone).
109+
function SpoilerBadge({ entry }) {
110+
if (!isEntryGated(entry)) return null;
111+
const label = entry.spoiler === true ? 'spoiler' : `reveal #${entry.revealIssue}`;
112+
const title = entry.spoiler === true
113+
? 'Hard spoiler — hidden from every issue’s drafting prompt'
114+
: `Hidden from drafting context until Issue ${entry.revealIssue}`;
115+
return (
116+
<Pill tone="warning" size="xs" title={title} className="rounded-full uppercase tracking-wider">
117+
{label}
118+
</Pill>
119+
);
120+
}
121+
122+
// Reveal-timing editor (#2178) — sets when a canon fact may enter a drafting
123+
// prompt. Applies to every kind. `revealIssue` (int) hides the entry from
124+
// issues before that number; `spoiler` hard-hides it from all drafting;
125+
// `surfaceDescriptor` is the pre-reveal stand-in substituted into context.
126+
// Absent = always visible (backward-compatible default). Labels are
127+
// `htmlFor`/`id` paired per the accessibility convention.
128+
function RevealTimingField({ entry, editable, onPatch }) {
129+
const idBase = `reveal-${entry.id}`;
130+
const surfaceDraft = useFieldDraft(
131+
typeof entry.surfaceDescriptor === 'string' ? entry.surfaceDescriptor : '',
132+
(v) => onPatch({ surfaceDescriptor: v || null }),
133+
);
134+
const gated = isEntryGated(entry);
135+
if (!editable) {
136+
if (!gated) return null;
137+
return (
138+
<div className="mt-2 flex flex-wrap items-center gap-2 text-[10px] text-gray-500">
139+
<span className="uppercase tracking-wider">Reveal:</span>
140+
{entry.spoiler === true
141+
? <ReadonlyChip>hard spoiler</ReadonlyChip>
142+
: <ReadonlyChip>Issue {entry.revealIssue}</ReadonlyChip>}
143+
{entry.surfaceDescriptor
144+
? <span className="italic text-gray-500">surface: {entry.surfaceDescriptor}</span>
145+
: null}
146+
</div>
147+
);
148+
}
149+
return (
150+
<details className="mt-2 group">
151+
<summary className="cursor-pointer list-none flex items-center gap-1 text-[10px] uppercase tracking-wider text-gray-500 hover:text-gray-300">
152+
<ChevronRight size={11} className="group-open:hidden" />
153+
<ChevronDown size={11} className="hidden group-open:inline" />
154+
Reveal timing (spoiler scoping)
155+
{gated ? <span className="ml-1 text-port-warning normal-case tracking-normal">· gated</span> : null}
156+
</summary>
157+
<div className="mt-1.5 space-y-2 pl-3 border-l border-port-border">
158+
<div className="flex flex-wrap items-center gap-3">
159+
<div className="flex flex-col gap-0.5">
160+
<label htmlFor={`${idBase}-issue`} className="text-[10px] uppercase tracking-wider text-gray-500">
161+
Reveal in issue #
162+
</label>
163+
<input
164+
id={`${idBase}-issue`}
165+
type="number"
166+
min={1}
167+
max={BIBLE_LIMITS.REVEAL_ISSUE_MAX}
168+
value={Number.isInteger(entry.revealIssue) ? entry.revealIssue : ''}
169+
onChange={(e) => {
170+
const v = e.target.value.trim();
171+
const n = v === '' ? null : Math.trunc(Number(v));
172+
onPatch({ revealIssue: Number.isInteger(n) && n >= 1 ? n : null });
173+
}}
174+
placeholder="—"
175+
className="w-20 px-2 py-1 text-xs bg-port-bg border border-port-border rounded text-gray-200"
176+
/>
177+
</div>
178+
<label htmlFor={`${idBase}-spoiler`} className="flex items-center gap-1.5 text-[11px] text-gray-400 mt-3">
179+
<input
180+
id={`${idBase}-spoiler`}
181+
type="checkbox"
182+
checked={entry.spoiler === true}
183+
onChange={(e) => onPatch({ spoiler: e.target.checked })}
184+
className="accent-port-warning"
185+
/>
186+
Hard spoiler (never in drafting)
187+
</label>
188+
</div>
189+
<div className="flex flex-col gap-0.5">
190+
<label htmlFor={`${idBase}-surface`} className="text-[10px] uppercase tracking-wider text-gray-500">
191+
Surface descriptor (pre-reveal stand-in)
192+
</label>
193+
<textarea
194+
id={`${idBase}-surface`}
195+
value={surfaceDraft.value}
196+
onChange={surfaceDraft.onChange}
197+
onBlur={surfaceDraft.onBlur}
198+
rows={2}
199+
maxLength={BIBLE_LIMITS.SURFACE_DESCRIPTOR_MAX}
200+
placeholder="What the world looks like BEFORE the reveal (e.g. &quot;the locked east wing&quot;). Substituted into drafting context until the reveal issue."
201+
className="w-full px-2 py-1 text-xs bg-port-bg border border-port-border rounded text-gray-200 whitespace-pre-wrap"
202+
/>
203+
</div>
204+
</div>
205+
</details>
206+
);
207+
}
208+
101209
// Collapsible wrapper for the universe-only character details panel
102210
// (CharacterDetailEditor + CharacterReferenceSheetPanel). Single toggle so the
103211
// card stays terse by default — the user opens it only when filling in
@@ -346,6 +454,7 @@ export default function CanonCard({
346454
seriesName={seriesNameMap?.[entry.sourceSeriesId]}
347455
/>
348456
) : null}
457+
<SpoilerBadge entry={entry} />
349458
</div>
350459
);
351460

@@ -390,6 +499,13 @@ export default function CanonCard({
390499
{entry.timeOfDay ? <ReadonlyChip>{entry.timeOfDay}</ReadonlyChip> : null}
391500
</div>
392501
) : null}
502+
{/* Reveal-gated canon / spoiler scoping (#2178) — every kind. Editable
503+
when a PATCH channel exists and the entry isn't locked. */}
504+
<RevealTimingField
505+
entry={entry}
506+
editable={!!onPatchEntry && !locked}
507+
onPatch={(patch) => onPatchEntry?.(entry.id, patch)}
508+
/>
393509
{kind.key === 'characters' ? (
394510
<WardrobeSection
395511
wardrobes={Array.isArray(entry.wardrobes) ? entry.wardrobes : []}

client/src/components/pipeline/CanonCard.test.jsx

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,69 @@ describe('CanonCard — inline description editor', () => {
154154
});
155155
});
156156

157+
describe('CanonCard — reveal timing / spoiler scoping (#2178)', () => {
158+
const editableKind = {
159+
key: 'characters', label: 'Characters',
160+
descFor: (e) => e.physicalDescription || e.description || '',
161+
descField: 'physicalDescription', descFieldFallback: 'description', descFieldMax: 2000,
162+
};
163+
164+
it('shows a spoiler badge when the entry has a revealIssue', () => {
165+
render_({ entry: { ...baseEntry, revealIssue: 8 } });
166+
expect(screen.getByText('reveal #8')).toBeInTheDocument();
167+
});
168+
169+
it('shows a hard-spoiler badge when the entry has spoiler: true', () => {
170+
render_({ entry: { ...baseEntry, spoiler: true } });
171+
expect(screen.getByText('spoiler')).toBeInTheDocument();
172+
});
173+
174+
it('omits the badge for an ungated entry', () => {
175+
render_();
176+
expect(screen.queryByText(/^reveal #/)).not.toBeInTheDocument();
177+
expect(screen.queryByText('spoiler')).not.toBeInTheDocument();
178+
});
179+
180+
it('PATCHes revealIssue from the number input when editable', () => {
181+
const onPatchEntry = vi.fn();
182+
render(
183+
<CanonCard kind={editableKind} entry={baseEntry} onRender={() => {}} onPatchEntry={onPatchEntry} />,
184+
);
185+
fireEvent.click(screen.getByText(/Reveal timing/));
186+
const input = screen.getByLabelText(/Reveal in issue/);
187+
fireEvent.change(input, { target: { value: '8' } });
188+
expect(onPatchEntry).toHaveBeenCalledWith('ent-1', { revealIssue: 8 });
189+
});
190+
191+
it('PATCHes spoiler from the checkbox and surfaceDescriptor on blur', () => {
192+
const onPatchEntry = vi.fn();
193+
render(
194+
<CanonCard kind={editableKind} entry={baseEntry} onRender={() => {}} onPatchEntry={onPatchEntry} />,
195+
);
196+
fireEvent.click(screen.getByText(/Reveal timing/));
197+
fireEvent.click(screen.getByLabelText(/Hard spoiler/));
198+
expect(onPatchEntry).toHaveBeenCalledWith('ent-1', { spoiler: true });
199+
200+
const surface = screen.getByLabelText(/Surface descriptor/);
201+
fireEvent.change(surface, { target: { value: 'a quiet neighbor' } });
202+
fireEvent.blur(surface);
203+
expect(onPatchEntry).toHaveBeenCalledWith('ent-1', { surfaceDescriptor: 'a quiet neighbor' });
204+
});
205+
206+
it('renders read-only reveal info when locked (no editor inputs)', () => {
207+
render(
208+
<CanonCard
209+
kind={editableKind}
210+
entry={{ ...baseEntry, revealIssue: 5, locked: true }}
211+
onRender={() => {}}
212+
onPatchEntry={vi.fn()}
213+
/>,
214+
);
215+
expect(screen.getByText('Issue 5')).toBeInTheDocument();
216+
expect(screen.queryByLabelText(/Reveal in issue/)).not.toBeInTheDocument();
217+
});
218+
});
219+
157220
describe('CanonCard — wardrobe pending-row promotion', () => {
158221
const renderEditable = (onPatchEntry) => render(
159222
<CanonCard

client/src/lib/bibleLimits.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ export const BIBLE_LIMITS = Object.freeze({
7878
SOURCE_SERIES_ID_MAX: 64,
7979
VOICE_ID_MAX: 200,
8080
INGREDIENT_ID_MAX: 64,
81+
// Reveal-gated canon / spoiler scoping (#2178).
82+
SURFACE_DESCRIPTOR_MAX: 2000,
83+
REVEAL_ISSUE_MAX: 100000,
8184
});
8285

8386
// Client-only helper (the cap *value* IMAGE_REFS_PER_ENTRY_MAX is mirrored from

data.reference/prompts/stage-config.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,13 @@
548548
"returnsJson": true,
549549
"variables": []
550550
},
551+
"pipeline-editorial-premature-reveal": {
552+
"name": "Pipeline — Editorial Check: Premature reveal (spoiler leak)",
553+
"description": "Editorial check (#2178): the information-economy guard for reveal-gated canon — flags a deliberately-withheld fact (character secret, late-story twist, hidden identity) that the prose states or unambiguously implies BEFORE its reveal issue, so a first-time reader learns it too early. Reconciles the stitched manuscript against the reveal-gated canon entries (reveal issue / hard-spoiler flag, spoiler-free surface descriptor, and the underlying gated fact) and distinguishes a genuine leak from deliberate foreshadowing, flagging only leaks. Returns issue-anchored findings.",
554+
"model": "default",
555+
"returnsJson": true,
556+
"variables": []
557+
},
551558
"pipeline-editorial-world-unforeshadowed-solution": {
552559
"name": "Pipeline — Editorial Check: Unforeshadowed solution",
553560
"description": "Editorial check (#2175): the worldbuilding sibling of the deus ex machina — flags a story problem resolved by a rule, power, property, or artifact the reader was never shown before it saved the day (Sanderson's First Law). Reconciles the stitched manuscript against the established world canon (artifacts/objects + places) and the continuity-bible world-rule facts, so a rule that WAS planted earlier is not flagged; the whole-arc verdict lands on the final part with earlier plants carried in the setup digest. Returns issue-anchored findings.",

0 commit comments

Comments
 (0)