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
2 changes: 2 additions & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,5 @@
- **`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.

- **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`)

- **[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.
145 changes: 142 additions & 3 deletions client/src/components/universe/CharacterDetailEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useState } from 'react';
import {
ChevronDown, ChevronRight, Plus, Trash2, WandSparkles, Loader2,
Palette, Hand, Smile, Package, BookOpen, Eye, Activity, Users, Swords,
Drama, KeyRound,
} from 'lucide-react';
import { BIBLE_LIMITS as L } from '../../lib/bibleLimits';
import useFieldDraft from '../../hooks/useFieldDraft';
Expand Down Expand Up @@ -44,6 +45,16 @@ const SECTIONS = Object.freeze([
{ name: 'skills', label: 'Skills', placeholder: 'concrete abilities, soft and hard', max: L.SKILLS_MAX, type: 'textarea' },
],
},
{
key: 'framework', label: 'Character framework', icon: Drama,
fields: [
{ 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' },
{ name: 'wound', label: 'Wound', placeholder: 'the lasting emotional damage the Ghost left', max: L.WOUND_MAX, type: 'textarea' },
{ name: 'lie', label: 'Lie (false belief)', placeholder: 'state in one sentence — "I only matter if I win"', max: L.LIE_MAX, type: 'textarea' },
{ 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' },
{ name: 'want', label: 'Want (external goal)', placeholder: 'the concrete goal they pursue — usually conflicts with the Need', max: L.WANT_MAX, type: 'textarea' },
],
},
{
key: 'visualIdentity', label: 'Visual identity', icon: Eye,
fields: [
Expand All @@ -66,6 +77,12 @@ const RELATIONSHIP_OPPOSITION_AXES = Object.freeze([
'winner/loser', 'smart/dumb', 'hunter/prey', 'predator/prey', 'custom',
]);

// Mirrors `CHARACTER_ARC_TYPES` in server/lib/storyBible.js (#2175). The server
// sanitizer coerces an unrecognized value to null, so an empty selection clears
// the field. `''` = unset.
const CHARACTER_ARC_TYPES = Object.freeze(['positive', 'negative', 'flat']);
const SLIDER_AXES = Object.freeze(['proactivity', 'likability', 'competence']);

const LIST_SECTIONS = Object.freeze([
{
key: 'stats', label: 'Stats', icon: Activity, field: 'stats',
Expand Down Expand Up @@ -106,6 +123,19 @@ const LIST_SECTIONS = Object.freeze([
],
summary: (e) => `${e.name}${e.description ? ` — ${e.description}` : ''}`,
},
{
key: 'secrets', label: 'Secrets', icon: KeyRound, field: 'secrets',
addLabel: 'Add secret', singular: 'secret',
// `secrets` is a plain string[] on the server (cleanStringArray). The
// generic ListRow works on row objects keyed by column name, so this
// section stores each secret as `{ text }` and marshals to/from string[]
// via `toRows` / `fromRows` below (see ListSectionEditor).
stringList: true,
columns: [
{ name: 'text', placeholder: 'something they hide from others or themselves', max: L.SECRET_MAX },
],
summary: (s) => s.text,
},
{
key: 'handGestures', label: 'Hand gestures', icon: Hand, field: 'handGestures',
addLabel: 'Add gesture', singular: 'gesture',
Expand Down Expand Up @@ -212,14 +242,40 @@ function CollapsibleSection({ icon: Icon, label, summary, defaultOpen = false, c
// `<kind>-<uuid>` under its own convention — see usePendingListRows.js for
// the trade-off this strip implies for sibling drafts.
function ListSectionEditor({ section, entry, onPatchList, disabled }) {
const persisted = Array.isArray(entry[section.field]) ? entry[section.field] : [];
const rawPersisted = Array.isArray(entry[section.field]) ? entry[section.field] : [];
// A `stringList` section (e.g. `secrets`) persists a plain string[] on the
// server, but the generic row editor works on objects keyed by the single
// column name. Marshal string → { [col]: string } on the way in and back to
// string on the way out so the section stays server-shape-correct.
const col0 = section.columns[0].name;
// Stamp a CONTENT-derived stable id on each marshalled string-list row so
// ListRow's React key (and its `useRowDraft` buffer) survives a delete/reorder
// of an earlier row — a plain index key would shift a sibling's draft onto the
// wrong secret, the exact footgun the ListRow key comment warns about. The
// per-content occurrence counter disambiguates exact-duplicate strings.
const stringListRows = () => {
const seen = new Map();
return rawPersisted.map((s) => {
const text = typeof s === 'string' ? s : '';
const n = seen.get(text) || 0;
seen.set(text, n + 1);
return { id: `${section.key}-${n}-${text}`, [col0]: text };
});
};
const persisted = section.stringList ? stringListRows() : rawPersisted;
const emit = (next) => onPatchList(
section.field,
section.stringList
? next.map((r) => (r?.[col0] || '').trim()).filter(Boolean)
: next,
);
const { merged, addRow, updateRow, removeRow } = usePendingListRows({
persisted,
requiredColumn: section.columns[0].name,
requiredColumn: col0,
idPrefix: `pending-${section.key}-`,
stripIdOnPromote: true,
blankRow: () => Object.fromEntries(section.columns.map((c) => [c.name, ''])),
onChange: (next) => onPatchList(section.field, next),
onChange: emit,
});
const summary = merged.length === 0
? 'empty'
Expand Down Expand Up @@ -456,6 +512,82 @@ function RelationshipsSection({ entry, characters, onPatch, disabled }) {
);
}

// Declared arc type + Three Sliders (CWQE Phase 10, #2175). Arc type is a plain
// enum <select> ('' clears); each slider is an integer 1–10 range input with an
// explicit "unset" state (null) so an un-rated axis stays absent rather than
// snapping to a default. Patches commit immediately (no draft buffer needed —
// these are discrete controls, not free text). `idPrefix` scopes the field ids
// so two open cards don't collide.
function ArcFrameworkControls({ entry, onPatch, disabled, idPrefix }) {
const sliders = (entry.sliders && typeof entry.sliders === 'object') ? entry.sliders : {};
const arcId = `chr-arc-${idPrefix || 'unknown'}`;
const patchSlider = (axis, value) => onPatch?.({ sliders: { ...sliders, [axis]: value } });
const setCount = SLIDER_AXES.reduce((n, a) => n + (typeof entry[a] === 'string' ? 0 : (sliders[a] != null ? 1 : 0)), 0);
const summary = `${entry.arcType || 'no arc'}${setCount ? ` · ${setCount}/3 sliders` : ''}`;
return (
<CollapsibleSection icon={Drama} label="Arc type & sliders" summary={summary}>
<div className="space-y-0.5">
<label htmlFor={arcId} className="block text-[10px] uppercase tracking-wider text-gray-500">
Arc type
</label>
<select
id={arcId}
value={entry.arcType || ''}
onChange={(e) => onPatch?.({ arcType: e.target.value })}
disabled={disabled}
className="w-full px-2 py-1 text-xs bg-port-bg border border-port-border rounded text-white disabled:opacity-50"
>
<option value="">— unset —</option>
{CHARACTER_ARC_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<p className="text-[10px] text-gray-500 leading-snug">
Rule: HIGH (≥7) on at least two sliders, or high on one with room to grow. All-low = boring; all-high = Mary Sue.
</p>
{SLIDER_AXES.map((axis) => {
const id = `chr-slider-${idPrefix || 'unknown'}-${axis}`;
const val = sliders[axis];
const set = val != null;
return (
<div key={axis} className="space-y-0.5">
<div className="flex items-center justify-between">
<label htmlFor={id} className="text-[10px] uppercase tracking-wider text-gray-500 capitalize">
{axis}
</label>
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-gray-400 tabular-nums w-6 text-right">{set ? val : '—'}</span>
{set ? (
<button
type="button"
onClick={() => patchSlider(axis, null)}
disabled={disabled}
title={`Clear ${axis}`}
className="text-gray-500 hover:text-port-error disabled:opacity-30"
>
<Trash2 size={11} />
</button>
) : null}
</div>
</div>
<input
id={id}
type="range"
min={L.SLIDER_MIN}
max={L.SLIDER_MAX}
step={1}
value={set ? val : L.SLIDER_MIN}
onChange={(e) => patchSlider(axis, Number(e.target.value))}
disabled={disabled}
className="w-full disabled:opacity-50"
aria-label={`${axis} rating 1 to 10`}
/>
</div>
);
})}
</CollapsibleSection>
);
}

export default function CharacterDetailEditor({ entry, onPatch, onExpand, expanding = false, disabled = false, characters = [] }) {
if (!entry) return null;

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

<ArcFrameworkControls
entry={entry}
onPatch={onPatch}
disabled={disabled}
idPrefix={entry.id}
/>

<RelationshipsSection
entry={entry}
characters={characters}
Expand Down
51 changes: 51 additions & 0 deletions client/src/components/universe/CharacterDetailEditor.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,54 @@ describe('CharacterDetailEditor — Relationships (#1287)', () => {
expect(screen.getByText(/2 links · 1 opposing/i)).toBeInTheDocument();
});
});

describe('CharacterDetailEditor — character framework (#2175)', () => {
const openArc = () => fireEvent.click(screen.getByRole('button', { name: /Arc type & sliders/i }));

it('renders the arc-type select seeded from the entry and patches on change', () => {
const onPatch = vi.fn();
render(<CharacterDetailEditor entry={{ ...ARIA, arcType: 'positive' }} characters={[ARIA]} onPatch={onPatch} />);
openArc();
const select = screen.getByLabelText(/Arc type/i);
expect(select).toHaveValue('positive');
fireEvent.change(select, { target: { value: 'negative' } });
expect(onPatch).toHaveBeenCalledWith({ arcType: 'negative' });
});

it('patches a slider value, merging with existing sliders', () => {
const onPatch = vi.fn();
const entry = { ...ARIA, sliders: { proactivity: 8, likability: null, competence: null } };
render(<CharacterDetailEditor entry={entry} characters={[ARIA]} onPatch={onPatch} />);
openArc();
fireEvent.change(screen.getByLabelText(/likability rating 1 to 10/i), { target: { value: '6' } });
expect(onPatch).toHaveBeenCalledWith({ sliders: { proactivity: 8, likability: 6, competence: null } });
});

it('clears a set slider back to unset (null)', () => {
const onPatch = vi.fn();
const entry = { ...ARIA, sliders: { proactivity: 9, likability: null, competence: null } };
render(<CharacterDetailEditor entry={entry} characters={[ARIA]} onPatch={onPatch} />);
openArc();
fireEvent.click(screen.getByRole('button', { name: /Clear proactivity/i }));
expect(onPatch).toHaveBeenCalledWith({ sliders: { proactivity: null, likability: null, competence: null } });
});

it('marshals the secrets string list to/from row objects', () => {
const onPatch = vi.fn();
const entry = { ...ARIA, secrets: ['forged the charter'] };
render(<CharacterDetailEditor entry={entry} characters={[ARIA]} onPatch={onPatch} />);
fireEvent.click(screen.getByRole('button', { name: /Secrets/i }));
// Existing secret is rendered in its row input.
const input = screen.getByDisplayValue('forged the charter');
fireEvent.change(input, { target: { value: 'forged the charter and the seal' } });
fireEvent.blur(input);
// Commits back as a plain string[] (not row objects).
expect(onPatch).toHaveBeenCalledWith({ secrets: ['forged the charter and the seal'] });
});

it('exposes the Ghost→Wound→Lie→Want→Need prose fields', () => {
render(<CharacterDetailEditor entry={{ ...ARIA, lie: 'I only matter if I win' }} characters={[ARIA]} onPatch={() => {}} />);
fireEvent.click(screen.getByRole('button', { name: /Character framework/i }));
expect(screen.getByDisplayValue('I only matter if I win')).toBeInTheDocument();
});
});
10 changes: 10 additions & 0 deletions client/src/lib/bibleLimits.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export const BIBLE_LIMITS = Object.freeze({
SPECIAL_TRAITS_MAX: 2000,
VISUAL_IDENTITY_MAX: 1000,
MOTIVATIONS_MAX: 2000,
// Character framework (CWQE Phase 10, #2175).
GHOST_MAX: 1000,
WOUND_MAX: 1000,
LIE_MAX: 600,
WANT_MAX: 600,
NEED_MAX: 600,
SECRET_MAX: 600,
SECRETS_PER_CHARACTER_MAX: 12,
SLIDER_MIN: 1,
SLIDER_MAX: 10,
LIKES_MAX: 1500,
DISLIKES_MAX: 1500,
MANNERISMS_MAX: 1500,
Expand Down
3 changes: 2 additions & 1 deletion data.reference/prompts/stages/pipeline-arc-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ The series is grounded in this World Builder world: **{{worldName}}**. When you
- **`logline`** — one sentence; what changes in this season.
- **`endingHook`** — the image or line that pulls the audience into season N+1. Skippable for the final season (leave empty).
- **`episodeCountTarget`** — integer. Divide `issueCountTarget` across the seasons roughly proportionally to season weight. Sum of all `episodeCountTarget`s should approximately equal `issueCountTarget`.
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:
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.
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:
- **`label`** — a short name for the seed (e.g. *The locked room*, *Mara's limp*, *The recurring bell*).
- **`plantIssue`** — the 1-indexed issue number where it's first planted (subtly introduced, not explained).
- **`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.
Expand Down
Loading