first-commit
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run

This commit is contained in:
Zakaria
2026-05-04 14:58:14 -04:00
commit a46764fb1b
1210 changed files with 233231 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
# Architecture Boundaries
## Purpose
This document defines the architectural boundaries for the local Open Design app. These boundaries are architectural constraints; some enforcement details can be implemented later through the relevant roadmap workstreams.
## Product Shape
Open Design is a local-first application. The near-term Electron version is a shell around the same `apps/web` and `apps/daemon` architecture.
Electron does not introduce a separate privileged application layer. The web layer and daemon keep the same responsibilities in browser and Electron modes.
## Web Boundary
`apps/web` owns UI, presentation state, and thin BFF/proxy behavior.
`apps/web` must not directly access local privileged capabilities:
- `.od` state
- SQLite storage
- workspace filesystem reads or writes
- agent CLI processes
- task process lifecycle
- local logs and artifacts
The web layer communicates with daemon-owned capabilities through API DTOs and streaming events.
## Daemon Boundary
`apps/daemon` is the sole local capability server. It owns privileged local runtime behavior:
- `.od` state
- SQLite storage, schema, migrations, and storage layout
- workspace filesystem access
- agent CLI invocation
- task lifecycle and process cleanup
- logs, artifacts, and diagnostic state
Daemon capabilities should be isolated behind internal modules such as `db`, `fs`, `agents`, `tasks`, `logs`, and `artifacts`.
## Shared Boundary
Shared code must be pure JavaScript or TypeScript that can run in both web and daemon contexts.
Shared code may contain:
- API DTO types
- runtime schemas such as Zod or TypeBox schemas
- domain constants
- task states
- SSE event names
- error codes
- pure helper functions
- path-related logical string helpers
Shared code must not depend on framework or environment-specific APIs such as Next.js, Express, Node filesystem/process APIs, browser-only APIs, SQLite, or daemon internals.
## API DTO Boundary
The web layer should understand API DTOs, not daemon implementation details.
API DTOs should prefer workspace-scoped logical or relative paths. Machine absolute paths should remain daemon-internal. Enforcement can be implemented later through a workspace path resolver and runtime validation layer.
SQLite schema names, table structure, migration details, and storage layout are daemon-private. The web layer sees API DTOs for display and interaction.
## Workspace Boundary
The current architecture can assume one active workspace. Workspace root selection should come from explicit user choice or an explicit startup parameter.
Daemon filesystem access should be scoped to the active workspace root. Path normalization and root containment checks should be implemented in the daemon path resolver and validation layer.
Precise implementation priority for workspace enforcement can be deferred, but the boundary direction is fixed: web does not construct privileged filesystem paths, and daemon owns path resolution.
## Agent Command Boundary
Users cannot provide free-form shell commands for daemon execution.
Agent invocations should use controlled command templates and argument construction. User-provided content may enter prompts, files, or configuration fields, while command structure remains daemon-controlled.
Plugin or custom-agent command extension is outside the current scope.
## Security Baseline
The app is local-first. Daemon should bind locally, and local API authentication can be deferred.
Daemon output should redact sensitive values by default, including tokens, API keys, environment secrets, and Authorization-like headers.
## Task Lifecycle Boundary
Daemon owns the full task lifecycle. The web layer may create, subscribe to, query, and request cancellation for tasks through API DTOs and events.
Tasks belong to a workspace and an agent. Terminal states are:
- `succeeded`
- `failed`
- `cancelled`
- `interrupted`
The web layer requests cancellation; daemon determines final task state and owns cleanup. Detailed concurrency, timeout, scheduling, and recovery policies can be defined in the process manager workstream.
## Deferred Policy Details
The following policy details can be finalized in later workstreams:
- multiple workspace support
- workspace registry location
- artifact, cache, and log directory layout
- Electron workspace picker behavior
- task concurrency limits
- timeout defaults
- queueing strategy
- restart recovery behavior
- process-tree cleanup strategy
These deferred choices should preserve the boundaries in this document.
File diff suppressed because it is too large Load Diff
+546
View File
@@ -0,0 +1,546 @@
# Critique Theater
## Naming
- **Internal codename (engineering, code, prompts, env vars, modules, SSE event prefix, telemetry):** `Critique Theater`. Used in `apps/daemon/src/critique/`, `apps/web/src/components/Theater/`, `OD_CRITIQUE_*`, `critique.*` SSE events.
- **User-facing label (UI, settings, docs, README, marketing copy):** `Design Jury`. The string is sourced from a single i18n key `critiqueTheater.userFacingName` so the label can be swapped without touching code.
The split is deliberate. Engineers reason about the system; users hire a jury.
## Purpose
Critique Theater turns Open Design's single-pass artifact generation into a panel-tempered, scored, replayable process. Every artifact is born through a visible five-person Design Jury (Designer, Critic, Brand, A11y, Copy) running inside one CLI session, with auto-converging rounds bounded by a configurable score threshold. By default, no artifact ships under 8.0/10.
This spec is normative for the v1 implementation and protocol. It is the source of truth that the prompt template, the daemon parser, the SSE event schema, the SQLite columns, the Theater UI components, and the adapter conformance suite all derive from.
## Non-goals
- Not a new skill protocol. Existing skills under `skills/` work unchanged.
- Not a new agent runtime. The daemon spawns the same single CLI per artifact it spawns today.
- Not a parallel-process architecture. There is exactly one CLI invocation per artifact lifecycle.
- Not a new transport. SSE on the existing project event stream carries every new event variant.
- Not a configurable cast in v1. The five panelists are fixed. Cast extension is reserved for v2.
### Why each non-goal is excluded
The non-goals above are intentional, not arbitrary. Future readers should know the tradeoff that led to each.
- **No parallel processes.** A single CLI session keeps the agent's full context coherent: the Designer's draft and every later panelist's notes share one model context, so the Critic can see the actual hierarchy values the Designer chose, the Brand panelist can read the same DESIGN.md the Designer was passed, and the Copy panelist can pick at the verbs the Designer wrote. Splitting this across processes would require a cross-process artifact handoff and a way to replay prior-panelist notes into each one's context, which adds an estimated two to three weeks to the v1 timeline and turns a debugging session into a multi-process trace correlation problem.
- **No new agent runtime.** The same on-PATH CLI Open Design already detects (Claude Code, Codex, Cursor Agent, Gemini CLI, etc.) is how this feature reaches users. Building a runtime would replicate work that the existing daemon already does well, would force users onto a model we picked rather than the one they signed up for, and would lose BYOK at every layer.
- **No new SSE transport.** SSE is already plumbed through the daemon, the web app, the Electron shell, and the desktop sidecar IPC. A second transport would force every consumer to learn a second connection lifecycle, which is exactly the kind of accidental complexity that takes a feature out of v1.
- **No configurable cast in v1.** A fixed five-panelist roster lets the composite formula and weight defaults stay constant across every artifact. A configurable cast adds UX surface (per-skill picker, override storage, settings sync) and requires the score formula to redistribute weight on the fly. We commit to v2 once we have data on which roles users actually drop or add.
- **No new skill protocol.** Critique Theater layers on top of the skill loader; it does not change what a skill is. This means the 31 existing skills, the 129 design systems, and any future contributions inherit the panel without per-skill migration work.
## High-level architecture
```
[ apps/web ] [ apps/daemon ] [ active CLI ]
brief form POST brief spawnAgent(skill, brief, cfg) role-plays
Theater stage ─────────────────► compose prompt: 5 panelists
score badge base skill prompt across up to
transcript + design system DESIGN.md 3 rounds in
replay + panel.ts protocol addendum one session
+ critique config emits XML-ish
◄─── SSE events ─────── parse stdout incrementally tagged stream
reducer + emit panelEvent / roundEvent
selectors / shipEvent / degradedEvent
persist transcript ndjson +
critique columns in SQLite
```
There are exactly three new modules in the daemon:
| Module | Responsibility | Inputs | Outputs |
| --- | --- | --- | --- |
| `apps/daemon/src/critique/parser.ts` | Streaming tokenizer for `<PANELIST>`, `<ROUND_END>`, `<SHIP>` blocks. Handles partial chunks, malformed input, recovery. | `AsyncIterable<string>` (CLI stdout) | `AsyncIterable<PanelEvent>` |
| `apps/daemon/src/critique/scoreboard.ts` | Pure state machine. Consumes `PanelEvent`s, buffers per-round, decides ship vs continue using injected config. No I/O. | `PanelEvent`, `CritiqueConfig` | `ScoreboardEvent` (delta-driven) |
| `apps/daemon/src/critique/orchestrator.ts` | Wires parser and scoreboard to the SSE bus and SQLite. Owns interrupt cascade, persistence, and degraded fallback. | spawn handle, project id, artifact id | side effects: SSE + DB writes |
Two new web component groups:
| Component group | Responsibility |
| --- | --- |
| `apps/web/src/components/Theater/` | Live theater stage, collapsed score badge, transcript replay, interrupted state, degraded banner, interrupt button. Driven entirely by a pure reducer over the SSE event stream. |
| `apps/web/src/prompts/panel.ts` | The protocol addendum injected into every artifact-generating prompt. Exports `PROTOCOL_VERSION`. |
One new contract package extension:
| File | Purpose |
| --- | --- |
| `packages/contracts/src/critique.ts` | `CritiqueConfig` zod schema, `PANELIST_ROLES` constants, `PanelEvent` discriminated union, `CritiqueSseEvent` extension to the existing SSE event union. |
## Configuration
All thresholds, timeouts, and policies are config-driven. There are no magic numbers in business logic. Defaults live in a single `defaults.ts` next to the schema and are overridable via environment variables read by the existing daemon env layer.
```ts
// packages/contracts/src/critique.ts
export const PANELIST_ROLES = ['designer', 'critic', 'brand', 'a11y', 'copy'] as const;
export type PanelistRole = typeof PANELIST_ROLES[number];
export interface CritiqueConfig {
enabled: boolean;
cast: PanelistRole[];
maxRounds: number;
scoreThreshold: number;
scoreScale: number;
weights: Record<PanelistRole, number>;
perRoundTimeoutMs: number;
totalTimeoutMs: number;
parserMaxBlockBytes: number;
fallbackPolicy: 'ship_best' | 'ship_last' | 'fail';
protocolVersion: number;
maxConcurrentRuns: number;
}
```
| Env var | Default | Notes |
| --- | --- | --- |
| `OD_CRITIQUE_ENABLED` | `false` at M0, `true` from M3 | Master switch. False = legacy generation. |
| `OD_CRITIQUE_MAX_ROUNDS` | `3` | Hard upper bound on rounds. |
| `OD_CRITIQUE_SCORE_THRESHOLD` | `8.0` | Ship gate. Composite below this continues. |
| `OD_CRITIQUE_SCORE_SCALE` | `10` | Score range upper bound. Lower bound is always 0. |
| `OD_CRITIQUE_ROUND_TIMEOUT_MS` | `90000` | Per-round wall clock cap. |
| `OD_CRITIQUE_TOTAL_TIMEOUT_MS` | `240000` | Total run wall clock cap. |
| `OD_CRITIQUE_PARSER_MAX_BLOCK_BYTES` | `262144` | Hard cap on bytes between matched tags. Prevents unbounded buffering. |
| `OD_CRITIQUE_FALLBACK_POLICY` | `ship_best` | When threshold never met, which round to keep. |
| `OD_CRITIQUE_MAX_CONCURRENT_RUNS` | `os.cpus().length` | Daemon-wide cap. Excess requests queue per project FIFO. |
The default weights for composite score are `{ designer: 0, critic: 0.40, brand: 0.20, a11y: 0.20, copy: 0.20 }`. Designer is omitted from the composite because Designer drafts; Designer does not score. If a panelist's score is missing in a round, weight redistributes proportionally across present panelists. The weights object is a single source of truth; the formula lives only in `scoreboard.ts`.
## Wire protocol (`PROTOCOL_VERSION = 1`)
The format is XML-ish tagged regions, not JSON. Tagged regions tolerate streaming, partial chunks, and model variability where JSON does not. Existing OD prompt files (`directions.ts`, `discovery.ts`) already use this style.
```
<CRITIQUE_RUN version="1" maxRounds="3" threshold="8.0" scale="10">
<ROUND n="1">
<PANELIST role="designer">
<NOTES>One sentence stating the design intent for v1.</NOTES>
<ARTIFACT mime="text/html"><![CDATA[
... self-contained artifact for round 1 ...
]]></ARTIFACT>
</PANELIST>
<PANELIST role="critic" score="6.4" must_fix="3">
<DIM name="hierarchy" score="6">CTA competes with logo at top-left.</DIM>
<DIM name="type" score="7">H1 64px reads as poster, not landing.</DIM>
<DIM name="contrast" score="4">CTA 3.9:1, fails AA.</DIM>
<DIM name="rhythm" score="6">Vertical gaps 12/16/24/40, no system.</DIM>
<DIM name="space" score="5">Hero padding asymmetric L vs R.</DIM>
<MUST_FIX>Push CTA background L by 8% to clear AA.</MUST_FIX>
<MUST_FIX>Pick a 4px or 8px vertical rhythm.</MUST_FIX>
<MUST_FIX>Symmetrize hero horizontal padding.</MUST_FIX>
</PANELIST>
<PANELIST role="brand" score="7.5"> ... </PANELIST>
<PANELIST role="a11y" score="5.0"> ... </PANELIST>
<PANELIST role="copy" score="6.0"> ... </PANELIST>
<ROUND_END n="1" composite="6.18" must_fix="7" decision="continue">
<REASON>Composite below threshold 8.0; 7 must-fix open.</REASON>
</ROUND_END>
</ROUND>
<ROUND n="2"> ... </ROUND>
<ROUND n="3"> ... </ROUND>
<SHIP round="3" composite="8.62" status="shipped">
<ARTIFACT mime="text/html"><![CDATA[
... final shipped artifact ...
]]></ARTIFACT>
<SUMMARY>One paragraph describing what changed across rounds and why.</SUMMARY>
</SHIP>
</CRITIQUE_RUN>
```
### Parser invariants
| Invariant | Enforcement |
| --- | --- |
| Tags well-formed and balanced | Streaming parser. Malformed input raises `MalformedBlockError`. |
| `role` value is in `PANELIST_ROLES` | Unknown role drops the block, emits warning, run continues. |
| `score` is `0 <= n <= scoreScale` with one decimal | Out of range clamps to bounds and emits warning. |
| `composite` matches recomputed mean within ±0.05 | Mismatch logs warning; daemon's recomputed value wins. |
| Each round contains every cast role exactly once | Missing role contributes score `0` for that role; must-fix counter advances. |
| Designer round 1 contains exactly one `<ARTIFACT>` | Missing artifact raises `MissingArtifactError` and triggers degraded fallback. |
| `<SHIP>` appears exactly once or never | Duplicates: first wins, rest dropped, warning emitted. |
| `decision` is `continue` or `ship` | Unknown defaults to `continue` (safe). |
| Total bytes between matched open and close tags ≤ `parserMaxBlockBytes` | Excess raises `OversizeBlockError` and triggers degraded fallback. |
| CDATA appears only inside `<ARTIFACT>` and `<NOTES>` | Lexer state machine enforces. |
### Composite score formula
```ts
// Pure function in apps/daemon/src/critique/scoreboard.ts.
// weights come from CritiqueConfig.weights, never hardcoded in business logic.
const present = roles.filter(r => panelistScore[r] != null);
const totalWeight = present.reduce((s, r) => s + weights[r], 0);
const composite = present.reduce((s, r) => s + (weights[r] / totalWeight) * panelistScore[r], 0);
```
### Protocol versioning
- `<CRITIQUE_RUN version="1">` is canonical for v1.
- Parser dispatches to `parsers/v1.ts`. Future v2 lands in `parsers/v2.ts` alongside v1; the daemon supports both indefinitely.
- Each artifact row stores `critique_protocol_version`. Old artifacts replay through their original parser, never the new one.
- The prompt template exports `PROTOCOL_VERSION` as a TypeScript constant; CI fails if this constant is bumped without a new `parsers/v{n}.ts` file.
### Disagreement requirement
Every non-final round must contain at least two panelists with diverging `MUST_FIX` directives. The Critic and at least one of {Brand, A11y, Copy} must each emit at least one `MUST_FIX` whose target subsystem differs from the others. The protocol enforces this in the prompt template; the parser emits a `WeakDebate` warning if the round closes without disagreement, which the orchestrator counts toward `parser_errors_total{kind="weak_debate"}` for observability but does not fail the run on.
### Convergence rule
A round closes with `decision="ship"` when both:
- `composite >= scoreThreshold`
- Sum of open `must_fix` counts across panelists is `0`
Otherwise the round closes with `decision="continue"` and the next round begins. After `maxRounds`, the orchestrator applies `fallbackPolicy`:
- `ship_best`: choose the round with highest composite. Default.
- `ship_last`: choose the last completed round.
- `fail`: persist the run as `below_threshold` with no shipped artifact; UI shows recovery actions.
### Self-bound budget
Round `n+1` transcript bytes must be less than round `n` transcript bytes. Final shipped artifact must be production-ready: no `TODO` comments, no Lorem Ipsum, no broken links. The prompt template enforces; the orchestrator runs a final lint pass before persisting the artifact.
## SSE event protocol
The existing `/api/projects/:id/events` SSE stream carries new event variants. All event names live in `packages/contracts/src/sse.ts` as a discriminated union extension, never as string literals in handlers.
| Event name | Payload fields | Meaning |
| --- | --- | --- |
| `critique.run_started` | `runId`, `protocolVersion`, `cast`, `maxRounds`, `threshold`, `scale` | Theater handshake. UI initializes lanes from `cast`, lays out rounds from `maxRounds`. |
| `critique.panelist_open` | `runId`, `round`, `role` | Tag opened in the stream. UI activates lane caret. |
| `critique.panelist_dim` | `runId`, `round`, `role`, `dimName`, `dimScore`, `dimNote` | Per-dim score line. UI animates the corresponding bar. |
| `critique.panelist_must_fix` | `runId`, `round`, `role`, `text` | Must-fix directive. UI appends to the lane. |
| `critique.panelist_close` | `runId`, `round`, `role`, `score` | Tag closed. UI deactivates caret, locks score. |
| `critique.round_end` | `runId`, `round`, `composite`, `mustFix`, `decision`, `reason` | Round closed. UI advances ScoreTicker, RoundDivider increments. |
| `critique.ship` | `runId`, `round`, `composite`, `status`, `artifactRef`, `summary` | Run shipped. UI collapses theater into score badge. |
| `critique.degraded` | `runId`, `reason`, `adapter` | Parser fallback fired. UI replaces theater with banner. |
| `critique.interrupted` | `runId`, `bestRound`, `composite` | User interrupt. UI shows interrupted state. |
| `critique.failed` | `runId`, `cause` | Unrecoverable error. UI shows failed state with retry. |
| `critique.parser_warning` | `runId`, `kind`, `position` | Non-fatal parser recovery. UI surfaces to log only, never to user, unless `kind == "weak_debate"`. |
The `artifactRef` payload is a logical reference (`{ projectId, artifactId }`), not the artifact body. The UI fetches the body through the existing artifact endpoint and renders in the existing sandboxed iframe.
## Persistence
A new SQLite migration adds critique columns to the existing `artifacts` table. The migration is additive and reversible.
```sql
-- 00XX_critique_rounds.up.sql
ALTER TABLE artifacts ADD COLUMN critique_score REAL;
ALTER TABLE artifacts ADD COLUMN critique_rounds_json TEXT;
ALTER TABLE artifacts ADD COLUMN critique_transcript_path TEXT;
ALTER TABLE artifacts ADD COLUMN critique_status TEXT
CHECK (critique_status IN ('shipped','below_threshold','timed_out','interrupted','degraded','failed','legacy'));
ALTER TABLE artifacts ADD COLUMN critique_protocol_version INTEGER;
CREATE INDEX IF NOT EXISTS idx_artifacts_critique_status ON artifacts(critique_status);
```
```sql
-- 00XX_critique_rounds.down.sql
DROP INDEX IF EXISTS idx_artifacts_critique_status;
ALTER TABLE artifacts DROP COLUMN critique_protocol_version;
ALTER TABLE artifacts DROP COLUMN critique_status;
ALTER TABLE artifacts DROP COLUMN critique_transcript_path;
ALTER TABLE artifacts DROP COLUMN critique_rounds_json;
ALTER TABLE artifacts DROP COLUMN critique_score;
```
| Column | Format | Notes |
| --- | --- | --- |
| `critique_score` | `REAL` | Final composite. `NULL` for legacy artifacts. |
| `critique_rounds_json` | `TEXT` | Compact summary per round: `[{n, composite, mustFix, decision}]`. Bounded; full transcript on disk. |
| `critique_transcript_path` | `TEXT` | Relative path under `.od/artifacts/<artifactId>/`. The stored value is the relative path; absolute resolution is daemon-side only. |
| `critique_status` | `TEXT` | Constrained by `CHECK` clause. `legacy` marks artifacts produced before the feature shipped. |
| `critique_protocol_version` | `INTEGER` | Pin which `parsers/v{n}.ts` to use for replay. |
Transcripts are written to `.od/artifacts/<artifactId>/transcript.ndjson` (one `PanelEvent` per line). Files larger than 256 KiB are gzipped to `transcript.ndjson.gz`. The orchestrator chooses the format at write time; the replay path detects the format by extension.
## UI surface
All Theater components live under `apps/web/src/components/Theater/`. Each file is under 200 lines.
```
Theater/
index.ts
TheaterStage.tsx
PanelistLane.tsx
ScoreTicker.tsx
RoundDivider.tsx
TheaterCollapsed.tsx
TheaterTranscript.tsx
TheaterDegraded.tsx
InterruptButton.tsx
hooks/
useCritiqueStream.ts
useCritiqueReplay.ts
state/
reducer.ts
selectors.ts
__tests__/
reducer.test.ts
TheaterStage.test.tsx
```
### State machine
```ts
type CritiqueState =
| { phase: 'idle' }
| { phase: 'running'; runId: string; rounds: Round[]; activeRound: number; activePanelist: PanelistRole | null }
| { phase: 'shipped'; runId: string; rounds: Round[]; final: ShipPayload }
| { phase: 'degraded'; reason: DegradedReason }
| { phase: 'interrupted'; runId: string; rounds: Round[]; bestRound: number }
| { phase: 'failed'; runId: string; cause: FailedCause };
```
The reducer is pure. Every transition is covered by a vitest case backed by golden-file SSE replays from the adapter conformance suite. No state lives outside the reducer except UI-only ephemera (lane scroll position, badge expanded vs collapsed).
### Visual specification
- Lanes stack vertically inside the right rail with `gap: 12px`. At viewport widths under 720 px the rail itself becomes a full-width drawer.
- Per-dim scores render as horizontal bars whose width animates from 0 to `(score / scaleMax) * 100%` with `transition: width 600ms cubic-bezier(.2,.8,.2,1)`.
- Animation respects `prefers-reduced-motion: reduce`; bars snap to final width and the score-ticker stops easing.
- Lane colors come from CSS custom properties keyed by role. The five role inks are themed against the active design system's OKLch token palette. No hex literals in TSX.
- Score badge in collapsed mode renders four colored dots labeled `C` `B` `A` `W` (Critic, Brand, A11y, copy/Word) plus the composite number.
### Lane density (compact-by-default, expand-on-demand)
The right rail is dense by nature (5 panelists, multiple dims each, must-fix lists, round dividers). To keep it readable for normal users without losing detail for power users, the panel ships three explicit modes, switchable by a segmented control above the lanes.
| Mode | Default? | Behavior |
| --- | --- | --- |
| `Smart` | yes | The active panelist (whichever lane is currently streaming, or, post-ship, the panelist with the lowest score) is expanded. All others are collapsed: head + 1-line summary only. |
| `Active only` | no | Only the active panelist is rendered; others are head-only with no summary, used for the most compact view (e.g., embedded in a small workspace tile). |
| `Expand all` | no | Every lane is fully expanded. Power users who want to see every dim and every must-fix at once. Persisted as a per-user preference once chosen explicitly. |
Lane heads are clickable to toggle that single lane's collapsed state, independent of the segmented control. The segmented control is the bulk operation; the lane head is the per-lane override.
The collapsed-lane summary is **derived**, not authored: it is the most prominent must-fix on that panelist, falling back to the highest-impact dim note if there are no must-fixes (panelist already happy). Truncation at one line with ellipsis. Selectors are pure and unit-tested.
### "Why this matters" explainer
Immediately under the composite score card, a single rule line states the ship contract in plain terms:
> Ships when composite score ≥ `scoreThreshold` and open must-fix == 0. Otherwise refine, up to `maxRounds` rounds.
Three rendering variants:
| Phase | Variant |
| --- | --- |
| `running` | "Ships when composite score &ge; 8.0 and open must-fix == 0. Otherwise refine, up to 3 rounds." |
| `shipped` | "Shipped: composite 8.6 &ge; threshold 8.0 with 0 open must-fix. Consensus N of 5 panelists." |
| `interrupted` | "Did not meet ship rule (8.0 + 0 must-fix), but you stopped early. Best-of-N was kept, transcript stays available." |
Numeric values come from `CritiqueConfig`, never hardcoded. The rule line uses a soft dashed border to read as explanatory chrome, not as actionable UI.
### Label sizing (production, not mockup)
| Element | Production size | Notes |
| --- | --- | --- |
| Composite score (big) | 36px / mono / 700 | the headline number |
| Per-panelist score | 14px / mono / 700 | colored to match lane ink |
| Dim names | 13px / mono | left column, secondary fg color |
| Dim numeric scores | 13px / mono / 600 | right column, primary fg color |
| Dim notes (sentence) | 13px / sans / 1.55 line-height | sentence-level explanation |
| Must-fix body | 13px / sans / 1.5 line-height | red ink on tinted background |
| Lane summary (collapsed) | 12px / sans / 1.4 line-height | derived single line, ellipsis at width |
| Lane name | 14px / sans / 600 | role label inside the head |
| Role tag | 11px / mono / uppercase | colored badge, fixed-width pill |
| Round divider | 11px / mono / uppercase / letter-spacing 0.1em | thin separator |
| Ship rule explainer | 12px / mono / 1.55 line-height | dashed-border explanatory block |
Label sizes ≤ 11px are reserved for tags, badges, and uppercase chrome only. **Body text is never below 12px in production.** This rule is enforced by a CI lint that grep-fails any new `font-size: <= 11px` rule outside the explicit chrome class allowlist (`role-tag`, `dim-dot`, `round-divider`, `meta-pill`).
### Demo-only chrome (must not ship)
The visual companion mockup includes affordances that are **not part of the product**:
- The "demo states" tab strip at the top of the rail.
- Any footer pill labeled "demo · click tabs to walk every state".
- The state-walking keyboard shortcut.
These exist purely to let reviewers walk every state in one page. The production Theater renders exactly one phase at a time, driven by the SSE stream. CI fails if any string matching `data-demo` or class `demo-tabs` appears in production bundles. The mockup HTML lives under `.superpowers/brainstorm/` (gitignored) and never ships.
### Accessibility
- Each lane has `role="region"` with `aria-labelledby` referencing its title.
- A single offscreen status node carries `aria-live="polite"`. It announces only `round_end` and `ship` events, never per-dim.
- Keyboard map: `Tab` cycles lanes, `Enter` toggles dim detail, `Esc` triggers Interrupt with confirm, `[` / `]` step through rounds in collapsed and replay mode.
- Color is never the only signal: must-fix counts use both color and a numeric badge; dim bars carry both color and width; scores always show as text.
- The Theater UI is itself audited by a CI test that pipes its rendered DOM through the same WCAG AA rule set the A11y panelist applies.
### Performance budget
| Metric | Budget | Enforcement |
| --- | --- | --- |
| Stage component bundle | ≤ 18 KiB gzipped | `size-limit` in CI |
| Reducer hot path | p99 ≤ 2 ms per event | vitest bench in CI |
| First lane visible from first SSE event | ≤ 200 ms | Playwright trace in `e2e/critique-theater.spec.ts` |
| Transcript scrub at 1 MiB transcript | 60 fps, no dropped frames | Playwright `Page.metrics` |
### Interrupt semantics
Pressing the Interrupt button or `Esc` while `phase === 'running'`:
1. Reducer transitions optimistically to a transient interrupting state. The button disables and labels itself "Interrupting…".
2. Web posts `POST /api/projects/:id/critique/:runId/interrupt`.
3. Daemon cascades `SIGTERM` to the spawned CLI. Orchestrator drains the parser, flushes the scoreboard, applies `fallbackPolicy` to the rounds completed so far, persists, and emits `critique.interrupted`.
4. Reducer advances to `phase: 'interrupted'`. UI shows the best-completed round's artifact plus a tag indicating which round shipped and the score.
If interrupt fires before any round closes, daemon ships nothing and persists the artifact row as `interrupted` with no `final`. The UI shows an empty state offering one-click retry with the original brief.
### Replay
Reopening an artifact loads the badge from SQLite columns. Clicking expand triggers `useCritiqueReplay`, which streams `transcript.ndjson` (or `.gz`) line by line into the same reducer. The same component code path renders both live and replay. Replay supports 1×, 4×, and instant playback speeds and a click-to-scrub timeline.
## Failure modes
| Failure | Detection | Behavior |
| --- | --- | --- |
| Model emits malformed block | parser sees opening tag with no close after `parserMaxBlockBytes` or before close tag | parser raises `MalformedBlockError`, orchestrator falls back to `legacy_generation` mode for this run, emits `critique.degraded` with reason. Artifact still ships through the legacy path. |
| Score never crosses threshold | scoreboard reaches `maxRounds` without consensus | apply `fallbackPolicy`. Score badge tagged `below_threshold`. |
| Per-round timeout | round wall clock exceeds `perRoundTimeoutMs` | abort current round, scoreboard ships best-so-far, badge tagged `timed_out`. |
| Total timeout | total wall clock exceeds `totalTimeoutMs` | `SIGTERM` CLI, ship best-so-far, transcript marked partial. |
| User Interrupt | `POST /api/projects/:id/critique/:runId/interrupt` | cascade `SIGTERM`, persist partial state, ship best-so-far if any round closed, otherwise mark `interrupted` with no final. |
| CLI process crash | spawn handle exits non-zero before `<SHIP>` | persist partial transcript, mark `failed` with `rounds_json.cause`, emit `critique.failed`, never silently retry. |
| Daemon restart mid-run | next boot scans SQLite for rows in state `running` older than `totalTimeoutMs` | mark `interrupted` with `rounds_json.recoveryReason = "daemon_restart"`. Never auto-resume. |
| Adapter unsupported | adapter fails conformance test in nightly CI | adapter marked `critique:degraded` in adapter registry with 24h TTL. UI shows the degraded banner once per session per adapter. |
### Failure-mode rate targets and recovery
Each failure mode above has an empirical rate target, a deterministic recovery path, and a Prometheus signal so the Phase 12 dashboard and the Phase 11 e2e suite can both pin sane thresholds. Targets are starting values; we tune them with the first 1000 production runs.
| Failure | Target rate | Recovery | Prometheus signal | Alert threshold |
| --- | --- | --- | --- | --- |
| `malformed_block` | < 0.5% of runs per adapter | emit `critique.degraded`, fall through to legacy single-pass generation. No retry. | `open_design_critique_degraded_total{reason="malformed_block",adapter="..."}` | sustained > 2% over 1h on any single adapter |
| `oversize_block` | < 0.1% of runs | same as `malformed_block` plus the parser's position is logged for postmortem | `open_design_critique_degraded_total{reason="oversize_block"}` | any non-zero sustained rate is treated as a model regression and pages |
| `missing_artifact` | < 0.2% of runs | degraded fallback, prompt template flagged for review (it should make this impossible) | `open_design_critique_degraded_total{reason="missing_artifact"}` | > 1% over 24h |
| Score never crosses threshold | < 5% of runs at M3 default | apply `fallbackPolicy` (default `ship_best`); badge tagged `below_threshold`; user can re-run | `open_design_critique_runs_total{status="below_threshold"}` | > 15% sustained over 24h is a quality regression |
| Per-round timeout | < 1% of runs | abort current round, ship best-so-far, badge tagged `timed_out` | `open_design_critique_runs_total{status="timed_out"}` | > 3% over 1h on any adapter |
| Total timeout | < 0.5% of runs | `SIGTERM` CLI, ship best-so-far, transcript marked partial | same series, distinguished by lifecycle attribute | shares the per-round timeout alert |
| User Interrupt | not an error; signal of latency or unwanted direction | preserve transcript, ship best-so-far if any round closed | `open_design_critique_interrupted_total{adapter="..."}` | > 10% over 24h is a UX problem worth investigating |
| CLI process crash | < 0.05% of runs | fail loud with `critique.failed`, no silent retry, daemon process surfaces the cause | `open_design_critique_runs_total{status="failed",cause="cli_exit_nonzero"}` | any non-zero sustained rate pages |
| Daemon restart mid-run | unbounded (depends on operator action) | persist `interrupted` with `recoveryReason="daemon_restart"`, never auto-resume | `open_design_critique_runs_total{status="interrupted",cause="daemon_restart"}` | informational, no alert |
| Adapter unsupported | adapter-specific; surfaces during conformance | adapter marked `critique:degraded` in registry with 24h TTL; degraded banner once per session | `open_design_critique_degraded_total{reason="adapter_unsupported",adapter="..."}` | one alert per adapter on first hit, suppressed during TTL |
A run can satisfy multiple labels (a `timed_out` run that also `below_threshold`s, for instance). The `status` label is a single canonical value derived by the orchestrator at run end; downstream histograms attach `cause` and `decision` as separate labels.
The Phase 11 e2e suite synthesizes each row above against a stub adapter and asserts the recovery actually fires. Phase 12 imports the alert thresholds into the Grafana dashboard JSON committed at `tools/dev/dashboards/critique.json` so an operator never has to guess.
## Concurrency and scalability
- Orchestrator instances are per-project. Daemon-wide concurrency cap via `OD_CRITIQUE_MAX_CONCURRENT_RUNS`. Excess requests queue with project-level FIFO.
- Streaming backpressure: parser is `AsyncIterable`-based. When the SSE consumer is slow, daemon's bounded mailbox (256 events) applies backpressure to the parser, which applies it to CLI stdout via the existing sidecar transport. No unbounded buffers anywhere.
- Transcripts larger than 1 MiB stream to disk only; the SQLite row stores a path, never a blob.
- Cross-skill reuse: every existing skill receives the panel for free; no per-skill code change is required.
- Adapter neutrality: the panel prompt is plain text with no CLI-specific tokens. Every adapter is tested via the conformance harness.
## Skills protocol extension
Skills opt out via a new optional frontmatter field in `SKILL.md`:
```yaml
od:
critique:
policy: always | on-demand | off
```
Default is `always` once `OD_CRITIQUE_ENABLED` flips global at M3. Per-skill overrides ship in the same PR as M2.
## Adapter conformance
For each of the 12 CLI adapters and the BYOK proxy, the conformance suite runs a deterministic mini-brief at `temperature=0` (where supported) and asserts:
- The parser consumes the stream without `MalformedBlockError`.
- All required tags appear in the canonical order.
- Composite score matches recomputed mean within ±0.05.
- `<SHIP>` artifact body parses as well-formed HTML.
Adapters that fail are marked `critique:degraded` in the adapter registry. The daemon falls back to legacy generation for that adapter and shows the degraded banner once per session. We never pretend feature parity that does not exist.
## Observability
| Metric | Type | Labels | Purpose |
| --- | --- | --- | --- |
| `open_design_critique_runs_total` | counter | `status`, `adapter`, `skill` | Run volume by terminal state. |
| `open_design_critique_rounds_total` | counter | `adapter`, `skill` | Average rounds per artifact. |
| `open_design_critique_round_duration_ms` | histogram | `quantile`, `adapter`, `skill`, `round` | Round latency distribution. |
| `open_design_critique_composite_score` | histogram | `quantile`, `adapter`, `skill` | Output quality distribution. |
| `open_design_critique_must_fix_total` | counter | `panelist`, `dim`, `adapter`, `skill` | Where the panel finds problems most often. |
| `open_design_critique_degraded_total` | counter | `reason`, `adapter` | Adapter health proxy. |
| `open_design_critique_interrupted_total` | counter | `adapter` | User abandonment signal. |
| `open_design_critique_parser_errors_total` | counter | `kind`, `adapter` | Parser robustness. |
| `open_design_critique_protocol_version` | gauge | `version` | Active protocol versions in use. |
Structured logs use the existing daemon logger with namespace `critique`. Required events: `run_started`, `round_closed`, `run_shipped`, `degraded`, `parser_recover`, `run_failed`. OpenTelemetry traces wrap each run with spans `critique.run`, `critique.round.<n>`, `critique.parse_chunk`, `critique.scoreboard_eval`, `critique.persist_round`, `critique.ship.persist`.
A Grafana dashboard ships in `tools/dev/dashboards/critique.json` with three default views: fleet quality (composite p50/p90/p99 over time per adapter), adapter health (degraded ratio plus parser-error rate), and brief throughput (runs per hour, rounds per run, time-to-ship).
## Security
| Surface | Threat | Mitigation |
| --- | --- | --- |
| `<ARTIFACT>` body | XSS in shipped HTML | Existing sandboxed iframe pattern with `sandbox` and CSP headers. No new surface. |
| Transcript on disk | Path traversal via stored path | The SQLite column stores a relative path; the daemon resolves under `.od/artifacts/<artifactId>/`; no user-supplied component reaches the resolver. |
| Brand `DESIGN.md` content in prompt | Prompt injection from a malicious system | DESIGN.md is wrapped in a `<BRAND_SOURCE>` block whose framing instructs the agent to treat it as data. Same defence as the existing skill loader. |
| Score and must-fix from agent stdout | Log injection (newlines, ANSI) | Parser strips ANSI; logs JSON-encode every value; UI renders as text only. |
| Pathological agent output | DoS via unbounded buffers | `parserMaxBlockBytes`, `perRoundTimeoutMs`, `totalTimeoutMs` are bounded and config-driven. Orchestrator enforces hard kill. |
| BYOK proxy invocation | SSRF / internal-IP exfil | Existing `/api/proxy/stream` blocks internal IPs at the daemon edge. Unchanged. |
A separate security review pass runs through the `code-reviewer` agent before merge.
## Testing
| Layer | Tool | Gate |
| --- | --- | --- |
| Pure unit | vitest | 95% line and 100% branch on `critique/parser.ts`, `scoreboard.ts`, `reducer.ts`. |
| Golden-file fixtures | vitest with `__fixtures__/critique/v1/*.txt` | Each adapter has at least one happy and two malformed transcripts on disk. |
| Component | RTL + jsdom | Every reducer phase rendered at least once. |
| Integration | vitest + sqlite memory + http mock | End-to-end happy path plus five failure modes. |
| Adapter conformance | nightly e2e against live adapters | Each of 12 CLIs plus BYOK proxy must pass canonical brief. |
| Playwright e2e | `e2e/critique-theater.spec.ts` | Theater renders within 200 ms, Esc triggers Interrupt, replay scrub at 60 fps. |
| Visual regression | Playwright `toHaveScreenshot()` | Each Theater state captured at 375 / 768 / 1280 viewports. |
| A11y self-test | axe-playwright | Theater UI passes WCAG AA. |
| Performance | size-limit + vitest bench | Bundle ≤ 18 KiB gz; reducer p99 ≤ 2 ms. |
| Dead-code | `ts-prune` scoped to `critique/` and `Theater/` | Zero unreferenced exports. |
| i18n | existing duplicate-key check plus new missing-key check | All 6 locales present for every Theater string. |
| Coverage walker | new `pnpm check:critique-coverage` | Each `CritiqueConfig` field, `PanelEvent` variant, SSE event, SQLite column, protocol grammar element, and i18n key has at least one production reference and one test. |
## Rollout
| Milestone | Scope | Default | Reversibility |
| --- | --- | --- | --- |
| M0 | Code lands behind `OD_CRITIQUE_ENABLED=false`. All tests run. No user-visible change. | off | flip env var |
| M1 | Settings UI toggle "Critique Theater (beta)". Adapter conformance grades published in README. 24h TTL on degraded markings. | off | flip toggle |
| M2 | Default-on for skills that benefit most: `magazine-poster`, `saas-landing`, `dashboard`, `finance-report`, `hr-onboarding`, `kanban-board`. Lightweight skills (`weekly-update`, `simple-deck`) remain opt-in. Per-skill `od.critique.policy` introduced in `SKILL.md`. | per-skill | per-skill flag |
| M3 | After 14 consecutive days at ≥ 90% adapter conformance across the fleet, flip the global default to true. Opt-out remains per-run and per-skill. | on | env var or per-skill |
Rollback at any milestone flips the master env var. SQLite columns are additive, never required for reads. Existing artifacts keep their badge; new artifacts go through legacy generation. No data migration is needed in either direction.
## Documentation deliverables
Every item is a CI gate. Missing documentation fails the build.
- `docs/critique-theater.md`, user-facing how-it-works with screenshots of all five states and an adapter compatibility table.
- `docs/spec.md`, adds a "Critique Theater protocol v1" section with the full wire grammar.
- `docs/architecture.md`, adds the `apps/daemon/src/critique/` module diagram.
- `docs/skills-protocol.md`, adds `od.critique.policy` field documentation and extension points.
- `docs/agent-adapters.md`, adds the conformance test contract every adapter must satisfy.
- `docs/roadmap.md`, adds future panelist extensions (Perf, i18n, Motion, Cost) as future work, not committed scope.
- `apps/daemon/src/critique/AGENTS.md`, module-level guide per the existing `AGENTS.md` convention.
- `apps/web/src/components/Theater/AGENTS.md`, same.
- `README.md`, single line in the "What you get" table: Critique Theater, every artifact panel-tempered, scored, replayable.
- All six locales (DE, JA, KO, zh-CN, zh-TW, EN) gain new strings in the same PR. Existing duplicate-key i18n CI gate covers consistency.
## Open questions
None. All foundational decisions are locked in this spec. Future work is reserved for v2 (configurable cast, additional panelist roles, multi-CLI parallel debate, score-driven prompt-stack feedback loop) and is out of v1 scope.
+79
View File
@@ -0,0 +1,79 @@
# Maintainability Roadmap
## Purpose
This document captures the maintainability risks in the current `apps/web` + `apps/daemon` architecture and the recommended optimization path.
The architectural boundary stays unchanged:
- `apps/web`: Next.js frontend and thin BFF/proxy layer.
- `apps/daemon`: local runtime/backend for SQLite, `.od` filesystem state, AI agent CLI processes, and SSE streaming.
The first-principles maintainability goals are:
- **Understandability**: engineers can locate behavior quickly and reason about data flow.
- **Changeability**: common changes can be made with bounded blast radius.
- **Verifiability**: contracts, tests, and types catch regressions early.
- **Isolation**: high-risk capabilities are contained behind explicit boundaries.
- **Recoverability**: failures produce actionable state, logs, and cleanup behavior.
## Priority Scale
| Priority | Meaning |
|---|---|
| P0 | Blocks safe evolution or creates high-risk runtime/security failure modes. |
| P1 | Major maintainability risk that increases regression and debugging cost. |
| P2 | Medium-term risk that affects reliability, portability, or architecture clarity. |
| P3 | Supporting documentation/process improvement. |
## Risk List and Optimization Plan
| ID | Priority | Risk | Evidence | Impact | Optimization Plan |
|---|---:|---|---|---|---|
| R1 | P0 | Daemon lacks TypeScript type checking. | `apps/daemon` is mostly JavaScript while handling API payloads, SQLite rows, filesystem paths, child processes, and SSE events. | API payloads, DB rows, agent events, and task states can drift silently; refactors are riskier. | Add gradual TypeScript support with `allowJs`; write new daemon modules in `.ts`; first type API payloads, SSE events, task lifecycle, DB rows, and agent definitions. |
| R2 | P0 | Web/daemon API contract is implicit. | `apps/web` calls daemon through `/api/*` rewrites; web has TypeScript types, daemon returns manually shaped JSON. | Field mismatches surface at runtime; API evolution is fragile. | Create `packages/api-contract` or an equivalent shared contract layer for request, response, error, and SSE event types. |
| R3 | P0 | Runtime validation is incomplete at the daemon boundary. | Daemon requests can trigger local filesystem access, SQLite writes, and `child_process.spawn()`. | Type correctness alone cannot protect against malformed runtime input, path traversal, invalid agent IDs, or unsafe args. | Add schema validation at HTTP boundaries with Zod/TypeBox; centralize validation for workspace paths, task IDs, agent IDs, models, reasoning options, uploaded files, and command arguments. |
| R4 | P0 | Local capability security boundary needs explicit rules. | Daemon owns high-permission capabilities: local files, `.od`, project workspaces, agent CLIs, and logs. | Unsafe path handling, broad command execution, token leakage, and unintended workspace access become possible failure modes. | Treat daemon as a capability server: bind to localhost, use workspace/path allowlists, normalize and jail paths, allowlist agent commands, and redact sensitive output. |
| R5 | P0 | Agent process lifecycle needs a first-class manager. | `/api/chat` spawns multiple agent runtimes and streams output to the frontend. | Zombie processes, cancellation gaps, orphaned tasks, inconsistent exit handling, and concurrent process conflicts. | Introduce a process/task manager with task state machine, cancellation, timeout, cleanup, exit code capture, signal handling, and concurrency limits. |
| R6 | P1 | `server.ts` is too monolithic. | `apps/daemon/src/server.ts` contains many routes plus orchestration, filesystem logic, streaming, uploads, and artifact handling. | Harder to understand, test, and change; unrelated edits share the same file and increase regression risk. | Split into thin routes plus services/adapters: `routes/`, `services/`, `agents/`, `db/`, `fs/`, `streams/`, `artifacts/`. |
| R7 | P1 | Error handling is inconsistent. | Handlers commonly use local `try/catch` and return ad hoc JSON errors. | UI receives inconsistent failures; logs lose context; task state can stall after partial failures. | Define a unified error model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`; add centralized Express error middleware and adapter-level error mapping. |
| R8 | P1 | SSE protocol is under-specified. | Daemon manually writes `text/event-stream` events for agent output and status. | Frontend parsing is fragile; disconnect, heartbeat, terminal events, and error semantics can drift. | Version the SSE event contract and define canonical events such as `task.started`, `task.output`, `task.error`, `task.completed`, `task.cancelled`, and `heartbeat`. |
| R9 | P1 | SQLite schema and migration lifecycle need stronger guarantees. | `apps/daemon/src/db.ts` owns local `better-sqlite3` tables and migrations. | Local user data upgrades can fail unpredictably; schema drift is hard to diagnose and recover. | Add explicit migration table, ordered forward migrations, startup migration checks, schema version logging, backup-before-migrate strategy, and migration tests. |
| R10 | P1 | Test coverage is thin around daemon behavior. | Existing daemon tests focus on stream parsing and artifact manifest behavior; HTTP/DB/spawn flows have limited coverage. | Changes are validated by manual testing; regressions in filesystem, SQLite, SSE, or agent mocks can ship. | Build layered tests: shared contract tests, route integration tests, service unit tests, SQLite migration tests, SSE parser tests, and agent mock integration tests. |
| R11 | P1 | Logging and observability are insufficient for local runtime debugging. | Agent execution involves long-lived tasks, subprocess output, filesystem state, and frontend SSE consumption. | User issues are hard to reproduce; failures lack correlated context. | Add structured logs with `requestId`, `taskId`, `agentId`, `workspace`, exit code, and duration; separate app logs from agent output; redact secrets. |
| R12 | P2 | Configuration, port, and health behavior can become fragile. | Web proxies `/api/*` to daemon; dev startup coordinates Next.js and daemon ports. | Port conflicts, daemon-not-ready states, and mismatched environment variables can break startup or distribution. | Centralize config resolution; expose `/health`; add daemon readiness checks; make port selection and UI fallback deterministic. |
| R13 | P2 | Cross-platform behavior is a recurring risk. | Daemon uses filesystem paths, SQLite native bindings, shell/process behavior, and signals. | macOS, Linux, and Windows/WSL can differ in path normalization, quoting, permissions, and process termination. | Use Node path APIs consistently, avoid shell string composition, isolate platform-specific process logic, and add CI coverage for supported platforms. |
| R14 | P2 | Framework migration can distract from core maintainability issues. | Current complexity is concentrated in FS/spawn/SSE/SQLite and module boundaries. | A framework rewrite can consume time while preserving the risky domain logic. | Keep Express for now; revisit Fastify only after TS, contracts, validation, tests, and modularization are in place and Express becomes a clear limiter. |
| R15 | P2 | Web/daemon boundary can erode over time. | Next.js has BFF capability and daemon has backend capability; future edits may blur ownership. | High-permission local runtime logic may leak into `apps/web`; deployment and security assumptions become unclear. | Document and enforce ownership: web handles UI/BFF/proxy; daemon owns local runtime capabilities; shared code contains contracts and pure logic only. |
| R16 | P3 | Operational documentation is incomplete. | Local-first daemon behavior depends on ports, `.od`, agent CLIs, runtime logs, and recovery flows. | Onboarding and support costs rise; troubleshooting relies on oral knowledge. | Document daemon architecture, API/SSE contract, task lifecycle, `.od` data layout, agent dependency checks, and common recovery procedures. |
## Optimization Dependencies
The optimization work should proceed in dependency order. Some items can run in parallel once their prerequisites are stable.
| Workstream | Status | Optimization | Covers | Depends on | Output |
|---|---|---|---|---|---|
| W1 | Completed | Confirm architecture and capability boundaries | R4, R15 | — | Written ownership rules for web, daemon, shared contracts, and dangerous local capabilities. See `specs/current/architecture-boundaries.md`. |
| W2 | Completed | Define API, SSE, and error contracts | R2, R7, R8 | W1 | `packages/contracts` now provides shared request/response types, SSE event unions, and error model helpers consumed by web and daemon. |
| W3 | Completed | Migrate project-owned code to TypeScript | R1 | W2 for highest-value shared types | Daemon, root scripts, and e2e support now use TypeScript sources; daemon compiles to `apps/daemon/dist`; residual JS is checked by `pnpm check:residual-js`. |
| W4 | Planned | Add runtime validation at daemon boundaries | R3, R4 | W2 | Schemas for HTTP requests, paths, agents, models, uploads, task IDs, and command args. |
| W5 | Planned | Modularize `server.ts` | R6 | W2, W3, W4 | Thin route handlers plus services/adapters for agents, DB, FS, streams, and artifacts. |
| W6 | Planned | Introduce agent process/task manager | R5, R8, R11 | W2, W5 | Task state machine, cancellation, timeout, cleanup, exit handling, and concurrency controls. |
| W7 | Planned | Strengthen SQLite migrations | R9 | W5 or a clear DB adapter boundary | Migration table, ordered migrations, startup checks, backup strategy, migration tests. |
| W8 | Planned | Build the daemon test pyramid | R10 | W2, W4, W5 | Contract tests, route integration tests, service unit tests, migration tests, SSE tests, and mocked agent-process tests. |
| W9 | Planned | Add structured logs and observability | R11 | W2, W6 | Correlated request/task logs, sanitized agent output, durations, exit status, and diagnostic context. |
| W10 | Planned | Harden config, port, and readiness behavior | R12 | W1 | Centralized config, `/health`, readiness checks, deterministic port behavior. |
| W11 | Planned | Harden cross-platform behavior | R13 | W4, W6, W5 | Platform-specific process handling, path normalization rules, supported-platform CI. |
| W12 | Planned | Revisit HTTP framework choice | R14 | W2, W3, W4, W5, W8 | Evidence-based decision on whether Express remains adequate or Fastify provides clear net value. |
| W13 | Planned | Complete operational documentation | R16 | W1 through W11 as sections stabilize | Current-state docs, runbooks, troubleshooting guides, and recovery procedures. |
## Recommended Execution Order
```text
Phase 1: W1 -> W2 -> W3 -> W4
Phase 2: W5 -> W6 -> W7 -> W8
Phase 3: W9 -> W10 -> W11 -> W13
Phase 4: W12
```
The core principle is to reduce risk before changing framework foundations: establish contracts, types, validation, and module boundaries first; then evaluate whether Express remains the right transport layer.
+264
View File
@@ -0,0 +1,264 @@
# Run Model and Recovery Flow
## Purpose
A run is one daemon-owned background execution instance for a user request. It lets the daemon keep an agent task alive across web page refreshes, tab closes, route changes, and temporary SSE disconnects.
The frontend owns presentation state. The daemon owns execution state. SSE owns live subscription and replay.
## Concept Model
A project is the top-level design workspace. It contains conversations, owns artifacts, and provides the daemon working directory for agent execution.
A conversation is a thread inside a project. It contains ordered messages and provides the UI context for multi-turn work.
A message is user-visible conversation content. A user message records the request. An assistant message records the generated response and can be backed by one run while generation is active or recoverable.
A run is a daemon-owned execution instance. It belongs to one project and one conversation, and it targets one assistant message. The run starts and supervises one agent process, records execution status, and stores replayable SSE events.
The intended cardinality is:
- One project contains many conversations.
- One conversation contains many messages.
- One project can have many runs.
- One conversation can have many runs.
- One assistant message can have zero or one run.
- One run belongs to one project, one conversation, and one assistant message.
- One run can start one agent process during active execution.
The recovery path follows the user-visible hierarchy: open a project, load a conversation, find assistant messages with active run metadata, then reattach to the daemon run.
## Concept Responsibilities
### Project
A project is the design workspace. It provides:
- project metadata, such as skill, design system, and fidelity;
- the daemon working directory, usually `.od/projects/<projectId>/`;
- artifact ownership;
- the top-level scope for conversations and runs.
### Conversation
A conversation is a thread inside a project. It provides:
- the ordered message history;
- the UI context for multi-turn work;
- the grouping key for active run recovery.
### Message
A message is user-visible conversation content. An assistant message is also the durable UI container for a run result. It should store:
- `runId`: the daemon execution backing this assistant response;
- `runStatus`: the latest known run state;
- `lastRunEventId`: the latest applied SSE event ID;
- partial generated content, persisted during streaming.
### Run
A run is a daemon-managed execution instance. It provides:
- agent process startup;
- execution status, such as `queued`, `running`, `succeeded`, `failed`, or `canceled`;
- replayable SSE events;
- reconnect support through `events?after=<lastRunEventId>`;
- explicit cancellation through the cancel endpoint.
Each run should carry `projectId`, `conversationId`, and `assistantMessageId`. These fields let the daemon recover active work for a reopened project page and let the frontend attach output to the correct assistant message.
## Primary Communication Flow
```mermaid
sequenceDiagram
participant Web as Web UI
participant API as Web API Proxy
participant Daemon as Daemon
participant Agent as Agent Process
participant Store as Message Store
Web->>Web: Generate assistantMessageId and clientRequestId
Web->>Store: Persist user message
Web->>Store: Persist empty assistant message with runStatus=running
Web->>API: POST /api/runs with projectId, conversationId, assistantMessageId, clientRequestId, request payload
API->>Daemon: Forward POST /api/runs
Daemon->>Daemon: Create run with status=queued
Daemon->>Agent: Spawn agent in project workspace
Daemon->>Daemon: Mark run status=running
Daemon-->>API: 202 runId
API-->>Web: 202 runId
Web->>Store: Persist runId on assistant message
Web->>API: GET run events
API->>Daemon: Attach SSE client
Daemon-->>Web: SSE start event with id
loop Streaming output
Agent-->>Daemon: stdout / structured event
Daemon->>Daemon: Append event to run buffer
Daemon-->>Web: SSE stdout / agent event with id
Web->>Web: Apply event to assistant message
Web->>Store: Throttled persist content and lastRunEventId
end
Agent-->>Daemon: Process close
Daemon->>Daemon: Mark terminal status
Daemon-->>Web: SSE end event with status
Web->>Store: Persist final content, runStatus, and lastRunEventId
```
## Refresh and Reattach Flow
```mermaid
sequenceDiagram
participant Web1 as Web UI Before Refresh
participant Web2 as Web UI After Refresh
participant API as Web API Proxy
participant Daemon as Daemon
participant Agent as Agent Process
participant Store as Message Store
Web1->>API: GET run events
API->>Daemon: Attach SSE client
Daemon-->>Web1: SSE events
Web1-xAPI: Page refresh closes browser subscription
Note over Daemon,Agent: Run continues in daemon and agent process keeps running
Agent-->>Daemon: More output while page is unavailable
Daemon->>Daemon: Buffer events with increasing IDs
Web2->>Store: Load project conversations and messages
Store-->>Web2: Assistant message with runId, runStatus, lastRunEventId
Web2->>API: GET run status
API->>Daemon: Fetch run status
Daemon-->>Web2: Run status
alt Run is active
Web2->>API: GET run events after lastRunEventId
API->>Daemon: Reattach SSE client after last applied event
Daemon-->>Web2: Replay missed events
Daemon-->>Web2: Continue live SSE events
Web2->>Store: Persist resumed content and lastRunEventId
else Run is terminal
Web2->>API: GET run events after lastRunEventId
API->>Daemon: Request remaining buffered events
Daemon-->>Web2: Replay remaining events and end
Web2->>Store: Persist terminal runStatus
end
```
## Active Run Fallback Flow
The frontend should persist `runId` on the assistant message immediately after run creation. A small failure window still exists between daemon run creation and message update. The daemon should also support an active run list endpoint as a recovery fallback.
```mermaid
sequenceDiagram
participant Web as Web UI
participant API as Web API Proxy
participant Daemon as Daemon
participant Store as Message Store
Web->>Store: Load messages for project and conversation
Store-->>Web: Assistant message with runStatus=running and no runId
Web->>API: GET active runs for project and conversation
API->>Daemon: Query active runs
Daemon-->>Web: Active runs with assistantMessageId
Web->>Web: Match run.assistantMessageId to assistant message ID
Web->>Store: Persist recovered runId on assistant message
Web->>API: GET run events after lastRunEventId
API->>Daemon: Reattach SSE client
Daemon-->>Web: Replay and live events
```
## Explicit Cancel Flow
Browser subscription lifetime and daemon run lifetime are separate. Refresh, tab close, and route changes close the local subscription only. The daemon receives a cancel request only when the user explicitly clicks Stop.
```mermaid
sequenceDiagram
participant Web as Web UI
participant API as Web API Proxy
participant Daemon as Daemon
participant Agent as Agent Process
participant Store as Message Store
Web->>Web: User clicks Stop
Web->>API: POST cancel run
API->>Daemon: Forward cancel request
Daemon->>Daemon: Mark cancelRequested=true
Daemon->>Agent: Send SIGTERM
Agent-->>Daemon: Process closes
Daemon->>Daemon: Mark run status=canceled
Daemon-->>Web: SSE end event with status=canceled
Web->>Store: Persist runStatus=canceled
```
## API Surface
Recommended run APIs:
```http
POST /api/runs
GET /api/runs/:id
GET /api/runs/:id/events?after=<lastRunEventId>
GET /api/runs?projectId=<projectId>&conversationId=<conversationId>&status=active
POST /api/runs/:id/cancel
```
`POST /api/runs` should accept correlation fields:
```ts
interface ChatRunCreateRequest {
projectId: string;
conversationId: string;
assistantMessageId: string;
clientRequestId: string;
agentId: string;
message: string;
model?: string | null;
reasoning?: string | null;
}
```
`GET /api/runs/:id` should return enough state for recovery:
```ts
interface ChatRunStatusResponse {
id: string;
projectId: string;
conversationId: string;
assistantMessageId: string;
agentId: string;
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'canceled';
createdAt: number;
updatedAt: number;
exitCode?: number | null;
signal?: string | null;
}
```
## Persistence Phases
### Phase 1: Refresh and Tab Close Survival
- Keep daemon runs in memory.
- Persist `runId`, `runStatus`, `lastRunEventId`, and partial assistant content in the message store.
- Reattach after refresh while the daemon process is still alive.
- Keep terminal run metadata and event buffers long enough for short-term UI recovery.
### Phase 2: Daemon Restart Visibility
- Persist `chat_runs` and `chat_run_events` in daemon storage.
- Mark active runs as interrupted after daemon restart because the child process exits with the daemon.
- Preserve terminal status and buffered output for user-facing history.
## Implementation Rules
- A browser fetch abort should close only the local SSE subscription.
- The Stop button is the only UI action that should call `/api/runs/:id/cancel`.
- The frontend should persist `runId` immediately after `POST /api/runs` succeeds.
- The frontend should process SSE events idempotently using `lastRunEventId`.
- The daemon should allow multiple simultaneous SSE clients for one run.
- The daemon should expose active runs by project and conversation for fallback recovery.
+283
View File
@@ -0,0 +1,283 @@
# Runtime Adapter Current State
## Purpose
Runtime Adapter is the daemon layer responsible for adapting local AI agent CLIs. It converts Open Design's unified generation requests into the actual command-line invocations for each CLI, and converts CLI output into streaming events that the frontend can consume.
The current implementation is concentrated in:
- `apps/daemon/src/agents.ts`: agent definitions, detection, model lists, argument construction, model validation.
- `apps/daemon/src/server.ts`: `/api/chat` request orchestration, prompt composition, `spawn()` subprocesses, SSE forwarding.
- `apps/daemon/src/claude-stream.ts`: parsing Claude Code structured JSONL output.
- `apps/daemon/src/json-event-stream.ts`: parsing structured JSON/JSONL output from Codex, Gemini, OpenCode, and Cursor Agent.
- `apps/daemon/src/acp.ts`: model detection and streaming session orchestration for the ACP JSON-RPC runtime.
## Currently Supported Runtimes
`AGENT_DEFS` in `apps/daemon/src/agents.ts` defines 8 local runtimes:
| id | Name | CLI | Output format | Model list source |
|---|---|---|---|---|
| `claude` | Claude Code | `claude` | `claude-stream-json` | Static fallback |
| `codex` | Codex CLI | `codex` | `json-event-stream` | Static fallback |
| `gemini` | Gemini CLI | `gemini` | `json-event-stream` | Static fallback |
| `opencode` | OpenCode | `opencode` | `json-event-stream` | `opencode models` + fallback |
| `hermes` | Hermes | `hermes` | `acp-json-rpc` | `session/new` from `hermes acp` + fallback |
| `kimi` | Kimi CLI | `kimi` | `acp-json-rpc` | `session/new` from `kimi acp` + fallback |
| `cursor-agent` | Cursor Agent | `cursor-agent` | `json-event-stream` | `cursor-agent models` + fallback |
| `qwen` | Qwen Code | `qwen` | `plain` | Static fallback |
Each runtime definition contains:
- `id` / `name` / `bin`: used for frontend display and process startup.
- `versionArgs`: used to detect the version.
- `fallbackModels`: static fallback options for the model selector.
- `listModels`: optional model discovery command.
- `fetchModels`: optional custom model detection logic, suitable for runtimes such as ACP that require a handshake before the model list is available.
- `reasoningOptions`: optional reasoning effort options, currently used by Codex.
- `buildArgs()`: converts unified input into the CLI's argv; it can also read `runtimeContext` at runtime, currently used to explicitly pass execution context such as `cwd`.
- `streamFormat`: tells the daemon how to interpret stdout.
## Detection Flow
The detection entry point is `detectAgents()`.
Flow:
1. Iterate over `AGENT_DEFS`.
2. Use `resolveOnPath()` to locate the CLI binary in `PATH`.
3. After locating it, run `versionArgs` to get the version.
4. Generate the model list through `listModels`, `fetchModels`, or `fallbackModels`, depending on runtime capabilities.
5. Return the result to the frontend and refresh the runtime's model validation cache.
The detection result includes:
- `available`: whether the CLI is available.
- `path`: the actual binary path.
- `version`: version string.
- `models`: model list used by the frontend model menu.
- `reasoningOptions`: reasoning effort menu.
- `streamFormat`: output format hint.
## Runtime Flow
Actual execution happens in `POST /api/chat` in `apps/daemon/src/server.ts`.
Flow:
1. The frontend submits `agentId`, user message, system prompt, project ID, attachments, model, and reasoning options.
2. The daemon uses `getAgentDef(agentId)` to find the runtime definition.
3. The daemon creates or locates `.od/projects/<projectId>/` as the agent working directory.
4. The daemon validates uploaded image paths and project attachment paths.
5. The daemon combines the system prompt, working directory hint, existing file list, attachment list, and user request into one prompt.
6. The daemon prepares additional readable directories: `skills/` and `design-systems/`.
7. The daemon validates the model and reasoning option.
8. It calls `def.buildArgs(...)` to generate CLI arguments; currently it also passes `runtimeContext = { cwd }` for CLIs that need an explicit workspace argument.
9. It starts the local runtime with `spawn(def.bin, args, { cwd })`; plain / Claude use read-only stdin, and ACP runtimes use writable stdin.
10. The daemon forwards runtime output to the frontend through SSE.
## Output Stream Handling
There are currently four output formats:
### Claude Code: Structured JSONL
Claude Code uses:
```bash
claude -p <prompt> --output-format stream-json --verbose --include-partial-messages
```
The daemon parses stdout through `createClaudeStreamHandler()` and converts Claude Code JSONL events into UI events:
- `status`
- `text_delta`
- `thinking_delta`
- `thinking_start`
- `tool_use`
- `tool_result`
- `usage`
These events are sent to the frontend through the SSE `agent` event.
### Codex / Gemini / OpenCode / Cursor Agent: Structured JSON Event Stream
These four runtimes currently use the unified `json-event-stream` output format, with stdout parsed by `apps/daemon/src/json-event-stream.ts`.
#### Codex
Codex currently uses:
```bash
codex exec --json --skip-git-repo-check --full-auto -C <cwd> <prompt>
```
The current integration uses the lightweight structured path through `exec --json`. Compared with the original plain-text `codex exec`, this path adds:
- `--json`: structured event output
- `--skip-git-repo-check`: allows running in a temporary working directory
- `--full-auto`: non-interactive automatic execution
- `-C <cwd>`: explicit working directory
The daemon currently maps:
- `thread.started``status(initializing)`
- `turn.started``status(running)`
- `item.completed(agent_message)``text_delta`
- `turn.completed.usage``usage`
#### Gemini
Gemini currently uses:
```bash
GEMINI_CLI_TRUST_WORKSPACE=true gemini --output-format stream-json --yolo
```
The daemon delivers the prompt over stdin rather than argv. It currently maps:
- `init``status(initializing)`
- `message(role=assistant)``text_delta`
- `result.stats``usage`
Gemini may still output some workspace scan warnings on stderr at runtime; the main flow remains unaffected.
#### OpenCode
OpenCode currently uses:
```bash
opencode run --format json --dangerously-skip-permissions <prompt>
```
When the user selects a model, `--model <id>` is appended.
The daemon currently maps:
- `step_start``status(running)`
- `text``text_delta`
- `tool_use``tool_use`
- Completed `tool_use.state``tool_result`
- `step_finish.part.tokens``usage`
#### Cursor Agent
Cursor Agent currently uses:
```bash
cursor-agent --print --output-format stream-json --stream-partial-output --force --trust --workspace <cwd> -p <prompt>
```
When the user selects a model, `--model <id>` is appended.
The daemon currently maps:
- `system(subtype=init)``status(initializing)`
- `assistant` partial chunks with `timestamp_ms``text_delta`
- `result.usage``usage`
Cursor outputs both partial assistant chunks and the final aggregated assistant message. The daemon currently prioritizes partial chunks and ignores the final aggregated text after partial chunks have appeared, avoiding duplicate rendering.
### Qwen: Plain Text Pass-through
Qwen currently still uses the `plain` output format.
The daemon directly forwards stdout chunks to the frontend through the SSE `stdout` event, and stderr chunks through the `stderr` event.
### Hermes / Kimi: ACP JSON-RPC
Hermes uses:
```bash
hermes acp --accept-hooks
```
Kimi uses:
```bash
kimi acp
```
The daemon starts an ACP session over stdio through `apps/daemon/src/acp.ts`:
1. `initialize`
2. `session/new`
3. Optional `session/set_model`
4. `session/prompt`
When an ACP runtime actively emits `session/request_permission`, the daemon prefers `approve_for_session`, which supports headless automatic approval for CLIs such as Kimi that require approval before tool calls.
The `session/new` response returns `sessionId`, `models.availableModels`, and `models.currentModelId`. The daemon reuses this information for model detection and runtime status reporting.
It then converts Hermes / Kimi `session/update` events into frontend-consumable `agent` events:
- `agent_thought_chunk``thinking_start` / `thinking_delta`
- `agent_message_chunk``text_delta`
- Final usage from `session/prompt``usage`
At runtime, two additional status events are added:
- Emit `status(model)` after `session/new` returns the default model.
- Emit `status(streaming)` when the first text token arrives, including `ttftMs`.
Model detection also reuses ACP: during detection, the daemon reads `models.availableModels` and `models.currentModelId` from the `session/new` response.
The current Kimi MVP integration directly reuses the Hermes ACP orchestrator. Automatic permission approval has been added to the shared ACP layer. `multica` also contains Kimi-specific tool title normalization and provider error sniffing; this repository currently keeps a lighter implementation.
## Prompt Injection Approach
Local CLIs currently use a unified approach of folding the system prompt into the user message.
The reason is that most local code-agent CLI command-line entry points lack an independent system channel. The daemon composes the following content into a single input:
- `systemPrompt`: base output contract + skill content + design system content.
- `cwdHint`: current working directory and file writing rules.
- `filesListBlock`: existing file list in the project directory.
- `attachmentHint`: attachments uploaded or selected by the user.
- `message`: original user request.
- `safeImages`: temporary uploaded image paths appended in `@path` form.
Claude Code additionally exposes `skills/` and `design-systems/` through `--add-dir`, making it easier for the agent to read skill seeds, templates, and design system files.
## Safety and Validation
Existing protections include:
- Process startup uses `spawn()` argument arrays, avoiding shell string concatenation.
- Model IDs are first compared with the model list exposed by the most recent `/api/agents` response.
- Custom model IDs are validated by `sanitizeCustomModel()`, limiting length, character set, and starting character.
- Reasoning options must exist in the runtime definition's `reasoningOptions`.
- Image paths must be located inside the daemon temporary upload directory.
- Attachment paths must be located inside the project working directory.
- Agent working directories are constrained to `.od/projects/<projectId>/`.
- ACP runtimes have timeout protection for the initialize, session/new, session/set_model, and session/prompt stages.
- ACP runtimes listen for `stdin` errors and proactively clean up detection processes after model detection completes.
- When the SSE connection closes, the daemon sends `SIGTERM` to the subprocess.
## Current Capability Boundaries
The current runtime adapter is a lightweight adaptation layer that already covers discovery, startup, argument construction, model selection, and streaming forwarding.
Main boundaries:
- The adapter is still a declarative object array and has not yet been split into independent adapter classes or directories.
- The capability model is thin and currently mainly exposes models, reasoning, and output format.
- Claude Code, Codex, Gemini, OpenCode, Cursor Agent, Hermes, and Kimi already have structured event parsing.
- Qwen currently still uses plain text pass-through.
- Skill injection mainly relies on prompt composition; only Claude Code uses `--add-dir` to support reading external directories.
- Hermes currently only integrates the core ACP text session path and has not mapped more `session/update` types into unified UI events.
- Cancellation is triggered by HTTP connection closure and `SIGTERM`; there is no explicit runId / cancel API yet.
- Resume, auth state, permission modes, and capability gating have not yet formed a unified interface.
- API fallback belongs to the frontend provider path and is currently outside the daemon runtime adapter layer.
## Gap from the Target Architecture
`docs/agent-adapters.md` describes a more complete target shape: each agent adapter has interfaces such as `detect()`, `capabilities()`, `run()`, `cancel()`, and `resume()`, and outputs unified `AgentEvent`s.
The current implementation already has the core outline of the target architecture:
- `detectAgents()` corresponds to `detect()`.
- `AGENT_DEFS` corresponds to the adapter registry.
- `buildArgs()` corresponds to runtime-specific invocation.
- `streamFormat` + `claude-stream.ts` + `json-event-stream.ts` + `acp.ts` correspond to stream normalization.
- `/api/chat` corresponds to unified run orchestration.
+94
View File
@@ -0,0 +1,94 @@
# Project Status
## Goal
Show a compact status on each project card that reflects the current state of the project's most relevant run.
## Status source
Project status should be a derived display value, based on runs associated with the project.
The recommended logic is:
1. If the project has an active run, show that active run's status.
2. If the project has no active run, show the latest run's terminal status.
3. If the project has no runs, show `not_started`.
An active run takes priority over the latest terminal run because it tells the user that work is currently happening in the project.
## Display statuses
```ts
type ProjectDisplayStatus =
| 'not_started'
| 'queued'
| 'running'
| 'succeeded'
| 'failed'
| 'canceled';
```
| Display status | Label | Source status | Meaning |
| --- | --- | --- | --- |
| `not_started` | Not started | No run | The project exists and has no run history. |
| `queued` | Queued | `queued` | A run exists and is waiting to start. |
| `running` | Running | `running`, `starting` | A run is currently executing. |
| `succeeded` | Completed | `succeeded` | The latest relevant run completed successfully. |
| `failed` | Failed | `failed` | The latest relevant run failed. |
| `canceled` | Canceled | `canceled`, `cancelled` | The latest relevant run was canceled. |
## Derivation logic
```ts
function deriveProjectDisplayStatus(projectRuns: Run[]): ProjectDisplayStatus {
const activeRun = projectRuns
.filter((run) => run.status === 'queued' || run.status === 'running' || run.status === 'starting')
.sort(byMostRecent)[0];
if (activeRun) {
return normalizeRunStatus(activeRun.status);
}
const latestRun = projectRuns.sort(byMostRecent)[0];
if (latestRun) {
return normalizeRunStatus(latestRun.status);
}
return 'not_started';
}
function normalizeRunStatus(status: RunStatus): ProjectDisplayStatus {
if (status === 'starting') return 'running';
if (status === 'cancelled') return 'canceled';
return status;
}
```
## UI guidance
The project card should show the status near the existing metadata line, together with the relative timestamp when useful.
Examples:
- `Running · just now`
- `Queued · 1 minute ago`
- `Completed · 6 minutes ago`
- `Failed · 36 minutes ago`
- `Canceled · 3 hours ago`
- `Not started`
Use stronger visual treatment for active and error states:
- `running`: primary or accent indicator.
- `queued`: neutral pending indicator.
- `failed`: error indicator.
- `canceled`: muted neutral indicator.
- `succeeded`: subtle success indicator.
- `not_started`: muted placeholder indicator.
## Rationale
Project status represents the user's project-level mental model. Users need to know whether a project is waiting, actively running, completed, failed, canceled, or untouched.
Using `running` as the primary active label keeps the UI aligned with the underlying run model and covers generation, editing, repair, analysis, export, and future run types.