From 10792610f79cba44aec189ec04a519c43df2175a Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 30 May 2026 13:50:35 -0700 Subject: [PATCH 1/7] pipeline: let any text stage generate from any other populated stage (backport) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Create Series Pipeline was strictly forward-only: each text stage (idea → prose → comicScript → teleplay) could only be generated from its conventional upstream, so a series that started life as a drafted comic script had no way to backfill the prose / beat sheet from it. Each stage's Generate button now offers a "Generate from:" picker listing every other stage that has content, pre-checked with the conventional forward source. The server renders the chosen stages through a generic {{#sourceMaterials}} block instead of a hardcoded single-source slot, so prose can be written from a comic script, the beat sheet reverse-engineered from finished prose, etc. Omitting sourceStageIds preserves the exact prior behavior (auto-run + legacy callers fall back to the forward source). The forward-source map and a shared stageContentOf accessor live in apiPipeline.js / textStages.js so the client picker and server resolver read one definition instead of duplicating it across the boundary. Migration 054 rewrites the four shipped stage prompts in existing installs to the source-agnostic templates, hash-gated so customized prompts are left intact and warned about. --- .../pipeline/stages/TextStagePanel.jsx | 61 ++++++++++++++- client/src/services/apiPipeline.js | 10 +++ .../prompts/stages/pipeline-comic-script.md | 18 ++++- .../prompts/stages/pipeline-idea-expansion.md | 17 +++++ .../prompts/stages/pipeline-prose.md | 18 ++++- .../prompts/stages/pipeline-teleplay.md | 18 ++++- .../054-source-agnostic-stage-prompts.js | 61 +++++++++++++++ .../054-source-agnostic-stage-prompts.test.js | 14 ++++ server/routes/pipeline.js | 4 + server/services/pipeline/textStages.js | 76 +++++++++++++++++-- server/services/pipeline/textStages.test.js | 58 ++++++++++++++ 11 files changed, 340 insertions(+), 15 deletions(-) create mode 100644 scripts/migrations/054-source-agnostic-stage-prompts.js create mode 100644 scripts/migrations/054-source-agnostic-stage-prompts.test.js diff --git a/client/src/components/pipeline/stages/TextStagePanel.jsx b/client/src/components/pipeline/stages/TextStagePanel.jsx index d213c4c65e..04812d2497 100644 --- a/client/src/components/pipeline/stages/TextStagePanel.jsx +++ b/client/src/components/pipeline/stages/TextStagePanel.jsx @@ -5,18 +5,22 @@ * generate button that calls the server's text-stage runner. */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Loader2, Sparkles, Save, History } from 'lucide-react'; import toast from '../../ui/Toast'; import { generatePipelineStage, updatePipelineIssue, PIPELINE_STAGE_LABELS, + PIPELINE_TEXT_STAGES, + PIPELINE_DEFAULT_FORWARD_SOURCE as DEFAULT_FORWARD_SOURCE, PIPELINE_STAGE_STATUS_LABEL as STATUS_LABEL, PIPELINE_STAGE_STATUS_COLOR as STATUS_COLOR, } from '../../../services/api'; import { useAsyncAction } from '../../../hooks/useAsyncAction'; import StageHistoryModal from './StageHistoryModal'; +const stageHasContent = (stage) => Boolean(stage?.input?.trim() || stage?.output?.trim()); + export default function TextStagePanel({ issue, series, @@ -38,6 +42,34 @@ export default function TextStagePanel({ const [historyOpen, setHistoryOpen] = useState(false); const runHistory = stage.runHistory || []; + // Other text stages that currently have content — the candidate source + // material for this generation. Excludes the target stage itself. Lets you + // generate any stage FROM any other populated stage (backport), e.g. prose + // from a comic script. Ordered by the canonical stage order. + const availableSources = useMemo( + () => PIPELINE_TEXT_STAGES.filter( + (id) => id !== stageId && stageHasContent(issue.stages?.[id]), + ), + [issue.stages, stageId], + ); + + // Selected source stage ids. Defaults to the conventional forward source(s) + // that exist; recomputed whenever the candidate set changes (issue/stage swap). + // `availableKey` is a stable string proxy for the availableSources array so + // the effect re-runs on membership change without an array identity in deps. + const availableKey = availableSources.join(','); + const [selectedSources, setSelectedSources] = useState([]); + useEffect(() => { + const preferred = (DEFAULT_FORWARD_SOURCE[stageId] || []).filter((id) => availableSources.includes(id)); + setSelectedSources(preferred); + // availableSources is captured via its `availableKey` string proxy. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [issue.id, stageId, availableKey]); + + const toggleSource = (id) => setSelectedSources( + (prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]), + ); + // Reset local edits when the stage record changes from the parent (e.g. // auto-run pushed a new output). useEffect(() => { @@ -51,6 +83,9 @@ export default function TextStagePanel({ seedInput: draftInput, providerId: series?.llm?.provider || undefined, model: series?.llm?.model || undefined, + // Only send when there's a real choice to make — omitting it lets the + // server fall back to the conventional forward source (unchanged behavior). + ...(availableSources.length ? { sourceStageIds: selectedSources } : {}), }), { errorMessage: `Failed to generate ${stageId}` }, ); @@ -131,6 +166,30 @@ export default function TextStagePanel({ + {availableSources.length > 0 ? ( +
+ Generate from: + {availableSources.map((id) => { + const active = selectedSources.includes(id); + return ( + + ); + })} +
+ ) : null} + {stageId === 'idea' ? (