Skip to content

Commit d28780d

Browse files
authored
Merge pull request #2245 from atomantic/claim/issue-2175
CWQE Phase 10 (remainder): generation-side character framework + structure doctrine
2 parents b9d6bb8 + 2d0262a commit d28780d

27 files changed

Lines changed: 804 additions & 30 deletions

.changelog/NEXT.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,5 @@
123123
- **`getSettings()` now serves from an in-memory cache instead of re-reading settings.json on every call.** `getSettings()` is invoked 100+ times across the codebase (several per request), each previously doing a fresh filesystem read + JSON.parse of app-wide, rarely-changing data. It now memoizes the parsed settings, populated lazily on first read and refreshed off the `settings:updated` event that every `save()`/`updateSettings()`/`updateSettingsWith()` already emits — so app-driven writes keep the cache coherent while write paths still read fresh off disk inside their write queue for the read-merge-write, preserving write-ordering guarantees. Mirrors the existing `settings:updated`-invalidated cache in `sharing/annotationIdentity.js`. Alongside it, a few independent per-file read loops are now parallelized (`digital-twin-analysis.validateCompleteness`, editorial series-health's series+settings load) and hot page derivations memoized (`Review` grouping/sorting, `Tribe` care-queue), cutting redundant work on socket-driven re-renders.
124124

125125
- **Branch & PR reconciliation now runs on any managed app as a Chief-of-Staff scheduled task — not just PortOS.** The branch reconciler (finish this machine's unfinished local git work: delete fully-merged orphaned branches + their worktrees, open PRs for pushed-but-unopened work, resolve merge conflicts, drive the review loop, and auto-merge when a PR is green) used to be a PortOS-only feature with its own daily cron, `/api/branch-reconcile` route, `settings.branchReconcile` toggle, and a dedicated Settings tab — hardwired to the PortOS checkout. It is now a first-class per-app CoS task type, **`branch-reconcile`**, configured like every other improvement task (per-app enable, provider/model, cadence, cooldown, budget) under Chief of Staff → task scheduling. It runs `perpetual` (drain-until-done): each dispatch runs the deterministic, peer-safe cleanup + classification against the *app's* repo, dispatches the coordinator agent only while actionable in-flight branches remain, then parks on a daily recheck — so no agent is burned on a no-op run. A progress-signature convergence guard ensures the drain only re-dispatches when the actionable set actually *changed* since the last run (a branch advanced NEEDS_PR→IN_REVIEW→merged, or a new/removed branch): a cycle that leaves the same branches in the same states (a branch judged "not ready", a PR blocked on human review or red CI) parks instead of re-spawning an identical coordinator back-to-back. The peer-safety guarantee is unchanged (only local `refs/heads/` are ever touched; a federated peer's branch exists here only as a remote-tracking ref and is structurally invisible), and the per-behavior toggles (`cleanupMerged` / `openPr` / `resolveConflicts` / `autoMerge`) are now per-app task-metadata overrides. Off by default; enabling it on an app is the user's explicit consent to let it drive PRs on a schedule. The old dedicated scheduler, route, settings key, and Settings tab are removed; a migration (`scripts/migrations/170-branch-reconcile-to-cos-task.js`) carries a previously-enabled `branchReconcile` opt-in into the new task (cron→recheckCron, actions→per-app metadata) scoped to the PortOS app, and drops the dead settings key — so an install that had it on keeps reconciling instead of silently stopping. (`server/services/branchReconcile.js`, `server/services/cosTaskGenerator.js`, `server/services/taskSchedule.js`, `server/services/taskPromptDefaults/prompts.js`)
126+
127+
- **[issue-2175] Generation-side character framework + structure doctrine (CWQE Phase 10 remainder).** Following the worldbuilding-doctrine slice (#2219), the character-generation and structure-planning prompts now teach the craft up front. The character bible gains the Ghost → Wound → Lie → Want → Need chain (with interlock checks: state the Lie in one sentence; the Need/Truth is its direct opposite; the Ghost causally explains the Lie), a declared arc type (positive/negative/flat), the Three Sliders (proactivity/likability/competence 1–10, rule: HIGH on ≥2 or one-with-growth; all-low = boring, all-high = Mary Sue), and a secrets list — all OPTIONAL so pre-#2175 records round-trip unchanged (`server/lib/storyBible.js` sanitizer + Zod-shape parity via the existing `canonArrayField` sanitize-on-write, client mirror in `client/src/lib/bibleLimits.js`, character-sheet UI in `CharacterDetailEditor.jsx` with `htmlFor`/`id` pairing, and no-clobber merge in `universeCharacterExpand.js` for the AI-expand path). The `character.consistency` / `arc.*` editorial checks now receive the authored Lie/Want/Need/arc-type when present (via `canonCharacterTraitsSummary`) so they reconcile plan-vs-delivery instead of inferring both. Structure prompts gain lean doctrine: `pipeline-season-episodes.md` a try-fail mandate (60%+ of middle episodes end "Yes, but"/"No, and"), beat rules (Catalyst external; Break Into Two a protagonist choice; All Is Lost includes a death — literal or of a hope/relationship/identity) and an active-climax rule; `pipeline-arc-overview.md` MICE-style thread nesting (threads close in reverse order of opening; name the opening/closing thread per season). Shipped-template edits ship to existing installs via hash-keyed migrations 171/172/173 (with the drift-cross-sync of prior owners 003/005/019/027/166). Remaining Phase 10 items (Le Guin style doctrine into the style-guide flow) stay tracked on #2175.

client/src/components/universe/CharacterDetailEditor.jsx

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { useState } from 'react';
1414
import {
1515
ChevronDown, ChevronRight, Plus, Trash2, WandSparkles, Loader2,
1616
Palette, Hand, Smile, Package, BookOpen, Eye, Activity, Users, Swords,
17+
Drama, KeyRound,
1718
} from 'lucide-react';
1819
import { BIBLE_LIMITS as L } from '../../lib/bibleLimits';
1920
import useFieldDraft from '../../hooks/useFieldDraft';
@@ -44,6 +45,16 @@ const SECTIONS = Object.freeze([
4445
{ name: 'skills', label: 'Skills', placeholder: 'concrete abilities, soft and hard', max: L.SKILLS_MAX, type: 'textarea' },
4546
],
4647
},
48+
{
49+
key: 'framework', label: 'Character framework', icon: Drama,
50+
fields: [
51+
{ name: 'ghost', label: 'Ghost (backstory wound cause)', placeholder: 'the past event that wounded them — must causally explain the Lie', max: L.GHOST_MAX, type: 'textarea' },
52+
{ name: 'wound', label: 'Wound', placeholder: 'the lasting emotional damage the Ghost left', max: L.WOUND_MAX, type: 'textarea' },
53+
{ name: 'lie', label: 'Lie (false belief)', placeholder: 'state in one sentence — "I only matter if I win"', max: L.LIE_MAX, type: 'textarea' },
54+
{ name: 'need', label: 'Need (Truth — opposite of the Lie)', placeholder: 'the direct opposite of the Lie — "I matter whether I win or lose"', max: L.NEED_MAX, type: 'textarea' },
55+
{ name: 'want', label: 'Want (external goal)', placeholder: 'the concrete goal they pursue — usually conflicts with the Need', max: L.WANT_MAX, type: 'textarea' },
56+
],
57+
},
4758
{
4859
key: 'visualIdentity', label: 'Visual identity', icon: Eye,
4960
fields: [
@@ -66,6 +77,12 @@ const RELATIONSHIP_OPPOSITION_AXES = Object.freeze([
6677
'winner/loser', 'smart/dumb', 'hunter/prey', 'predator/prey', 'custom',
6778
]);
6879

80+
// Mirrors `CHARACTER_ARC_TYPES` in server/lib/storyBible.js (#2175). The server
81+
// sanitizer coerces an unrecognized value to null, so an empty selection clears
82+
// the field. `''` = unset.
83+
const CHARACTER_ARC_TYPES = Object.freeze(['positive', 'negative', 'flat']);
84+
const SLIDER_AXES = Object.freeze(['proactivity', 'likability', 'competence']);
85+
6986
const LIST_SECTIONS = Object.freeze([
7087
{
7188
key: 'stats', label: 'Stats', icon: Activity, field: 'stats',
@@ -106,6 +123,19 @@ const LIST_SECTIONS = Object.freeze([
106123
],
107124
summary: (e) => `${e.name}${e.description ? ` — ${e.description}` : ''}`,
108125
},
126+
{
127+
key: 'secrets', label: 'Secrets', icon: KeyRound, field: 'secrets',
128+
addLabel: 'Add secret', singular: 'secret',
129+
// `secrets` is a plain string[] on the server (cleanStringArray). The
130+
// generic ListRow works on row objects keyed by column name, so this
131+
// section stores each secret as `{ text }` and marshals to/from string[]
132+
// via `toRows` / `fromRows` below (see ListSectionEditor).
133+
stringList: true,
134+
columns: [
135+
{ name: 'text', placeholder: 'something they hide from others or themselves', max: L.SECRET_MAX },
136+
],
137+
summary: (s) => s.text,
138+
},
109139
{
110140
key: 'handGestures', label: 'Hand gestures', icon: Hand, field: 'handGestures',
111141
addLabel: 'Add gesture', singular: 'gesture',
@@ -212,14 +242,40 @@ function CollapsibleSection({ icon: Icon, label, summary, defaultOpen = false, c
212242
// `<kind>-<uuid>` under its own convention — see usePendingListRows.js for
213243
// the trade-off this strip implies for sibling drafts.
214244
function ListSectionEditor({ section, entry, onPatchList, disabled }) {
215-
const persisted = Array.isArray(entry[section.field]) ? entry[section.field] : [];
245+
const rawPersisted = Array.isArray(entry[section.field]) ? entry[section.field] : [];
246+
// A `stringList` section (e.g. `secrets`) persists a plain string[] on the
247+
// server, but the generic row editor works on objects keyed by the single
248+
// column name. Marshal string → { [col]: string } on the way in and back to
249+
// string on the way out so the section stays server-shape-correct.
250+
const col0 = section.columns[0].name;
251+
// Stamp a CONTENT-derived stable id on each marshalled string-list row so
252+
// ListRow's React key (and its `useRowDraft` buffer) survives a delete/reorder
253+
// of an earlier row — a plain index key would shift a sibling's draft onto the
254+
// wrong secret, the exact footgun the ListRow key comment warns about. The
255+
// per-content occurrence counter disambiguates exact-duplicate strings.
256+
const stringListRows = () => {
257+
const seen = new Map();
258+
return rawPersisted.map((s) => {
259+
const text = typeof s === 'string' ? s : '';
260+
const n = seen.get(text) || 0;
261+
seen.set(text, n + 1);
262+
return { id: `${section.key}-${n}-${text}`, [col0]: text };
263+
});
264+
};
265+
const persisted = section.stringList ? stringListRows() : rawPersisted;
266+
const emit = (next) => onPatchList(
267+
section.field,
268+
section.stringList
269+
? next.map((r) => (r?.[col0] || '').trim()).filter(Boolean)
270+
: next,
271+
);
216272
const { merged, addRow, updateRow, removeRow } = usePendingListRows({
217273
persisted,
218-
requiredColumn: section.columns[0].name,
274+
requiredColumn: col0,
219275
idPrefix: `pending-${section.key}-`,
220276
stripIdOnPromote: true,
221277
blankRow: () => Object.fromEntries(section.columns.map((c) => [c.name, ''])),
222-
onChange: (next) => onPatchList(section.field, next),
278+
onChange: emit,
223279
});
224280
const summary = merged.length === 0
225281
? 'empty'
@@ -456,6 +512,82 @@ function RelationshipsSection({ entry, characters, onPatch, disabled }) {
456512
);
457513
}
458514

515+
// Declared arc type + Three Sliders (CWQE Phase 10, #2175). Arc type is a plain
516+
// enum <select> ('' clears); each slider is an integer 1–10 range input with an
517+
// explicit "unset" state (null) so an un-rated axis stays absent rather than
518+
// snapping to a default. Patches commit immediately (no draft buffer needed —
519+
// these are discrete controls, not free text). `idPrefix` scopes the field ids
520+
// so two open cards don't collide.
521+
function ArcFrameworkControls({ entry, onPatch, disabled, idPrefix }) {
522+
const sliders = (entry.sliders && typeof entry.sliders === 'object') ? entry.sliders : {};
523+
const arcId = `chr-arc-${idPrefix || 'unknown'}`;
524+
const patchSlider = (axis, value) => onPatch?.({ sliders: { ...sliders, [axis]: value } });
525+
const setCount = SLIDER_AXES.reduce((n, a) => n + (typeof entry[a] === 'string' ? 0 : (sliders[a] != null ? 1 : 0)), 0);
526+
const summary = `${entry.arcType || 'no arc'}${setCount ? ` · ${setCount}/3 sliders` : ''}`;
527+
return (
528+
<CollapsibleSection icon={Drama} label="Arc type & sliders" summary={summary}>
529+
<div className="space-y-0.5">
530+
<label htmlFor={arcId} className="block text-[10px] uppercase tracking-wider text-gray-500">
531+
Arc type
532+
</label>
533+
<select
534+
id={arcId}
535+
value={entry.arcType || ''}
536+
onChange={(e) => onPatch?.({ arcType: e.target.value })}
537+
disabled={disabled}
538+
className="w-full px-2 py-1 text-xs bg-port-bg border border-port-border rounded text-white disabled:opacity-50"
539+
>
540+
<option value="">— unset —</option>
541+
{CHARACTER_ARC_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
542+
</select>
543+
</div>
544+
<p className="text-[10px] text-gray-500 leading-snug">
545+
Rule: HIGH (≥7) on at least two sliders, or high on one with room to grow. All-low = boring; all-high = Mary Sue.
546+
</p>
547+
{SLIDER_AXES.map((axis) => {
548+
const id = `chr-slider-${idPrefix || 'unknown'}-${axis}`;
549+
const val = sliders[axis];
550+
const set = val != null;
551+
return (
552+
<div key={axis} className="space-y-0.5">
553+
<div className="flex items-center justify-between">
554+
<label htmlFor={id} className="text-[10px] uppercase tracking-wider text-gray-500 capitalize">
555+
{axis}
556+
</label>
557+
<div className="flex items-center gap-1.5">
558+
<span className="text-[10px] text-gray-400 tabular-nums w-6 text-right">{set ? val : '—'}</span>
559+
{set ? (
560+
<button
561+
type="button"
562+
onClick={() => patchSlider(axis, null)}
563+
disabled={disabled}
564+
title={`Clear ${axis}`}
565+
className="text-gray-500 hover:text-port-error disabled:opacity-30"
566+
>
567+
<Trash2 size={11} />
568+
</button>
569+
) : null}
570+
</div>
571+
</div>
572+
<input
573+
id={id}
574+
type="range"
575+
min={L.SLIDER_MIN}
576+
max={L.SLIDER_MAX}
577+
step={1}
578+
value={set ? val : L.SLIDER_MIN}
579+
onChange={(e) => patchSlider(axis, Number(e.target.value))}
580+
disabled={disabled}
581+
className="w-full disabled:opacity-50"
582+
aria-label={`${axis} rating 1 to 10`}
583+
/>
584+
</div>
585+
);
586+
})}
587+
</CollapsibleSection>
588+
);
589+
}
590+
459591
export default function CharacterDetailEditor({ entry, onPatch, onExpand, expanding = false, disabled = false, characters = [] }) {
460592
if (!entry) return null;
461593

@@ -512,6 +644,13 @@ export default function CharacterDetailEditor({ entry, onPatch, onExpand, expand
512644
</CollapsibleSection>
513645
))}
514646

647+
<ArcFrameworkControls
648+
entry={entry}
649+
onPatch={onPatch}
650+
disabled={disabled}
651+
idPrefix={entry.id}
652+
/>
653+
515654
<RelationshipsSection
516655
entry={entry}
517656
characters={characters}

client/src/components/universe/CharacterDetailEditor.test.jsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,54 @@ describe('CharacterDetailEditor — Relationships (#1287)', () => {
112112
expect(screen.getByText(/2 links · 1 opposing/i)).toBeInTheDocument();
113113
});
114114
});
115+
116+
describe('CharacterDetailEditor — character framework (#2175)', () => {
117+
const openArc = () => fireEvent.click(screen.getByRole('button', { name: /Arc type & sliders/i }));
118+
119+
it('renders the arc-type select seeded from the entry and patches on change', () => {
120+
const onPatch = vi.fn();
121+
render(<CharacterDetailEditor entry={{ ...ARIA, arcType: 'positive' }} characters={[ARIA]} onPatch={onPatch} />);
122+
openArc();
123+
const select = screen.getByLabelText(/Arc type/i);
124+
expect(select).toHaveValue('positive');
125+
fireEvent.change(select, { target: { value: 'negative' } });
126+
expect(onPatch).toHaveBeenCalledWith({ arcType: 'negative' });
127+
});
128+
129+
it('patches a slider value, merging with existing sliders', () => {
130+
const onPatch = vi.fn();
131+
const entry = { ...ARIA, sliders: { proactivity: 8, likability: null, competence: null } };
132+
render(<CharacterDetailEditor entry={entry} characters={[ARIA]} onPatch={onPatch} />);
133+
openArc();
134+
fireEvent.change(screen.getByLabelText(/likability rating 1 to 10/i), { target: { value: '6' } });
135+
expect(onPatch).toHaveBeenCalledWith({ sliders: { proactivity: 8, likability: 6, competence: null } });
136+
});
137+
138+
it('clears a set slider back to unset (null)', () => {
139+
const onPatch = vi.fn();
140+
const entry = { ...ARIA, sliders: { proactivity: 9, likability: null, competence: null } };
141+
render(<CharacterDetailEditor entry={entry} characters={[ARIA]} onPatch={onPatch} />);
142+
openArc();
143+
fireEvent.click(screen.getByRole('button', { name: /Clear proactivity/i }));
144+
expect(onPatch).toHaveBeenCalledWith({ sliders: { proactivity: null, likability: null, competence: null } });
145+
});
146+
147+
it('marshals the secrets string list to/from row objects', () => {
148+
const onPatch = vi.fn();
149+
const entry = { ...ARIA, secrets: ['forged the charter'] };
150+
render(<CharacterDetailEditor entry={entry} characters={[ARIA]} onPatch={onPatch} />);
151+
fireEvent.click(screen.getByRole('button', { name: /Secrets/i }));
152+
// Existing secret is rendered in its row input.
153+
const input = screen.getByDisplayValue('forged the charter');
154+
fireEvent.change(input, { target: { value: 'forged the charter and the seal' } });
155+
fireEvent.blur(input);
156+
// Commits back as a plain string[] (not row objects).
157+
expect(onPatch).toHaveBeenCalledWith({ secrets: ['forged the charter and the seal'] });
158+
});
159+
160+
it('exposes the Ghost→Wound→Lie→Want→Need prose fields', () => {
161+
render(<CharacterDetailEditor entry={{ ...ARIA, lie: 'I only matter if I win' }} characters={[ARIA]} onPatch={() => {}} />);
162+
fireEvent.click(screen.getByRole('button', { name: /Character framework/i }));
163+
expect(screen.getByDisplayValue('I only matter if I win')).toBeInTheDocument();
164+
});
165+
});

client/src/lib/bibleLimits.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,16 @@ export const BIBLE_LIMITS = Object.freeze({
2323
SPECIAL_TRAITS_MAX: 2000,
2424
VISUAL_IDENTITY_MAX: 1000,
2525
MOTIVATIONS_MAX: 2000,
26+
// Character framework (CWQE Phase 10, #2175).
27+
GHOST_MAX: 1000,
28+
WOUND_MAX: 1000,
29+
LIE_MAX: 600,
30+
WANT_MAX: 600,
31+
NEED_MAX: 600,
32+
SECRET_MAX: 600,
33+
SECRETS_PER_CHARACTER_MAX: 12,
34+
SLIDER_MIN: 1,
35+
SLIDER_MAX: 10,
2636
LIKES_MAX: 1500,
2737
DISLIKES_MAX: 1500,
2838
MANNERISMS_MAX: 1500,

data.reference/prompts/stages/pipeline-arc-overview.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ The series is grounded in this World Builder world: **{{worldName}}**. When you
6060
- **`logline`** — one sentence; what changes in this season.
6161
- **`endingHook`** — the image or line that pulls the audience into season N+1. Skippable for the final season (leave empty).
6262
- **`episodeCountTarget`** — integer. Divide `issueCountTarget` across the seasons roughly proportionally to season weight. Sum of all `episodeCountTarget`s should approximately equal `issueCountTarget`.
63-
6. **Foreshadowing ledger (2–6 seeds).** Plan the major setups the series plants early and pays off later — a Chekhov's gun, a prophecy, an unexplained scar, a withheld secret. For each seed record:
63+
6. **Thread nesting (MICE).** Treat each season as opening and closing narrative threads (a Milieu / Inquiry / Character / Event question). Threads must close in the REVERSE order they open — the last thread opened is the first resolved, like nested brackets. In each season's `logline` (or the `summary`), name the thread this season OPENS and the thread it CLOSES, so the nesting is legible: a season that opens a thread it never closes leaves a dangling question; one that closes a thread out of order breaks the nesting.
64+
7. **Foreshadowing ledger (2–6 seeds).** Plan the major setups the series plants early and pays off later — a Chekhov's gun, a prophecy, an unexplained scar, a withheld secret. For each seed record:
6465
- **`label`** — a short name for the seed (e.g. *The locked room*, *Mara's limp*, *The recurring bell*).
6566
- **`plantIssue`** — the 1-indexed issue number where it's first planted (subtly introduced, not explained).
6667
- **`reinforceIssues`** — 0 or more issue numbers between plant and payoff where the seed is quietly reinforced so the reader doesn't forget it. Keep it a light touch — a glimpse, not a recap.

0 commit comments

Comments
 (0)