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
@@ -0,0 +1,340 @@
---
id: 20260430-implement-maintainability-w2-w3
name: Implement Maintainability W2 W3
status: implemented
created: '2026-04-30'
---
## Overview
### Problem Statement
`apps/web` and `apps/daemon` need the next maintainability-roadmap workstreams implemented: W2 and W3 from `specs/current/maintainability-roadmap.md`. This spec targets a full project migration to TypeScript as the end state; the implementation plan can still use incremental steps so each step is easy to validate.
### Goals
- Implement W2: define shared API, SSE, and error contracts covering R2, R7, and R8.
- Implement W3 as a full TypeScript migration for project-owned JavaScript entrypoints, modules, scripts, tests, and reporters, covering R1 and related maintainability risk across the repository.
- Provide shared request/response types, an SSE event union, and an error model that can be imported by both web and daemon code.
- Configure TypeScript so all migrated project code is checked consistently, with migration steps ordered for safe verification.
### Scope
- Create or update the shared contract layer for web/daemon request, response, error, and SSE event types.
- Add TypeScript configuration and package/script integration needed to migrate daemon, repository scripts, and test support code to TypeScript.
- Keep the existing architecture boundary from the roadmap: `apps/web` remains the Next.js frontend and thin BFF/proxy layer; `apps/daemon` remains the local runtime/backend.
### Constraints
- W2 depends on the completed W1 ownership and capability boundaries.
- W3 should build on W2 for highest-value shared types.
- Runtime validation, server modularization, process/task manager work, and broader daemon test pyramid work belong to later roadmap workstreams.
### Success Criteria
- Web and daemon can import the same contract types.
- Project-owned source, scripts, tests, and reporters have a TypeScript migration path with a typed end state.
- Typecheck covers the shared contracts, web, daemon, scripts, and test support code included in this spec.
- The new contracts explicitly cover HTTP payloads, SSE events, and the unified error model.
## Research
### Existing System
- W2 covers the implicit web/daemon API contract, inconsistent error handling, and under-specified SSE protocol; the roadmap's W3 text names daemon TypeScript support as the original output. Source: `specs/current/maintainability-roadmap.md:57-58`
- W1 defines the shared boundary as pure JavaScript or TypeScript usable by both web and daemon, with API DTO types, runtime schemas, task states, SSE event names, and error codes as allowed shared contents. Source: `specs/current/architecture-boundaries.md:41-56`
- `apps/web` communicates with daemon-owned capabilities through API DTOs and streaming events, while privileged local filesystem, SQLite, agent CLI, task lifecycle, logs, and artifacts stay daemon-owned. Source: `specs/current/architecture-boundaries.md:13-40`
- The workspace currently has no `packages/*` workspace entries; `pnpm-workspace.yaml` includes `apps/*` and `e2e` only. Source: `pnpm-workspace.yaml:1-3`
- No shared package currently exists under `packages/*`. Source: file search `packages/*/package.json`
- Root scripts run daemon, web, build, tests, and typecheck through pnpm filters; root `typecheck` currently targets only `@open-design/web`. Source: `package.json:12-25`
- Dev-mode web rewrites `/api/*`, `/artifacts/*`, and `/frames/*` to the local daemon origin; the config notes that `/api/chat` SSE streams through the rewrite. Source: `apps/web/next.config.ts:35-44`
- Web-side daemon chat types live in `apps/web/src/providers/daemon.ts`: `DaemonStreamOptions` sends `agentId`, `history`, `systemPrompt`, `projectId`, `attachments`, `model`, and `reasoning`. Source: `apps/web/src/providers/daemon.ts:19-38`
- The web chat client posts `/api/chat` with JSON fields `agentId`, `systemPrompt`, `message`, `projectId`, `attachments`, `model`, and `reasoning`. Source: `apps/web/src/providers/daemon.ts:57-77`
- The daemon `/api/chat` handler reads the same request fields from `req.body`, validates agent and message ad hoc, and returns HTTP 400 JSON errors for invalid agent, missing binary, or missing message. Source: `apps/daemon/src/server.ts:868-884`
- Web-side `AgentEvent` currently models UI events as `status`, `text`, `thinking`, `tool_use`, `tool_result`, `usage`, and `raw`. Source: `apps/web/src/types.ts:32-39`
- Daemon SSE setup for `/api/chat` writes `text/event-stream` frames with `event: <name>` and JSON `data`, using events such as `start`, `agent`, `stdout`, `stderr`, `error`, and `end`. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1095`, `apps/daemon/src/server.ts:1136-1180`
- The web SSE parser consumes frame separators, parses event/data fields, maps `stdout` to text, buffers `stderr`, translates `agent` payloads, handles `start`, treats `error` as terminal, and reads `end` exit code. Source: `apps/web/src/providers/daemon.ts:85-151`
- Web translation accepts daemon `agent` payload types `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; unknown payloads are ignored. Source: `apps/web/src/providers/daemon.ts:178-228`
- Agent JSON event parsing emits normalized events such as `status`, `text_delta`, `tool_use`, `tool_result`, `usage`, and `raw`; OpenCode error payloads currently become a `raw` event with embedded error text. Source: `apps/daemon/src/json-event-stream.ts:35-91`
- The daemon API proxy has a separate SSE endpoint at `/api/proxy/stream` with request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`, and returns `start`, `delta`, `error`, and `end` SSE events. Source: `apps/daemon/src/server.ts:1188-1192`, `apps/daemon/src/server.ts:1241-1250`, `apps/daemon/src/server.ts:1262-1275`, `apps/daemon/src/server.ts:1291-1303`
- HTTP error responses are ad hoc: project routes often return `{ error: string }`, upload errors return `{ code, error }`, and preview errors derive status plus `{ error }`. Source: `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:755-763`
- Project CRUD and conversation/message routes shape common response envelopes such as `{ projects }`, `{ project, conversationId }`, `{ project }`, `{ conversations }`, `{ conversation }`, and `{ messages }`. Source: `apps/daemon/src/server.ts:200-269`, `apps/daemon/src/server.ts:325-424`
- File routes shape common response envelopes such as `{ files }`, `{ file }`, and `{ ok: true }`, while raw file routes return binary data. Source: `apps/daemon/src/server.ts:725-752`, `apps/daemon/src/server.ts:776-833`, `apps/daemon/src/server.ts:840-864`
- `apps/daemon/src/projects.ts` owns project file DTO construction with fields `name`, `path`, `type`, `size`, `mtime`, `kind`, `mime`, `artifactKind`, and `artifactManifest`. Source: `apps/daemon/src/projects.ts:30-70`
- Web application types already include daemon-adjacent DTOs such as `AgentInfo`, `ProjectFileKind`, `ProjectFile`, `Project`, and chat attachment/message/event types in `apps/web/src/types.ts`. Source: `apps/web/src/types.ts:41-101`, `apps/web/src/types.ts:150-160`
- `apps/web` has TypeScript configured with `strict`, `noUncheckedIndexedAccess`, `allowJs`, `noEmit`, and a `typecheck` script using `tsc -b --noEmit`. Source: `apps/web/tsconfig.json:2-23`, `apps/web/package.json:6-10`
- `apps/daemon` is ESM, starts with `node cli.js`, tests with `vitest run -c vitest.config.ts`, and currently has no `typecheck` script. Source: `apps/daemon/package.json:1-23`
- The daemon test config is TypeScript and includes `**/*.test.{ts,tsx,js,mjs,cjs}` under a Node test environment. Source: `apps/daemon/vitest.config.ts:1-8`
- `apps/daemon` currently contains JavaScript and MJS project code alongside TypeScript test/config files: `cli.js`, `server.js`, `db.js`, `agents.js`, stream parsers, project/design-system helpers, artifact helpers, `json-event-stream.test.mjs`, `artifact-manifest.test.ts`, and `vitest.config.ts`. Source: file search `apps/daemon/**/*.{js,mjs,cjs,ts,tsx}`
- `apps/web` source and config files are TypeScript/TSX, including `next.config.ts`, `app/**/*.tsx`, `src/**/*.ts`, `src/**/*.tsx`, and `vitest.config.ts`. Source: file search `apps/web/**/*.{js,mjs,cjs,ts,tsx}`
- `e2e` is mixed: Playwright and Vitest config/tests are TypeScript, while runtime/support scripts and reporters include `.mjs` and `.cjs` files. Source: `e2e/package.json:6-12`, `e2e/playwright.config.ts:1-58`, `e2e/vitest.config.ts:1-12`, file search `e2e/**/*.{js,mjs,cjs,ts,tsx}`
- Root scripts are currently MJS files: `scripts/resolve-dev-ports.mjs`, `scripts/dev-all.mjs`, and `scripts/sync-design-systems.mjs`. Source: file search `scripts/**/*.{js,mjs,cjs,ts,tsx}`
### Available Approaches
- **Add a new shared workspace package**: create a workspace package for contracts and add it to the pnpm workspace so both `apps/web` and `apps/daemon` can import pure shared TypeScript. This matches the roadmap output of a shared contract layer and the W1 shared-boundary rules. Source: `specs/current/maintainability-roadmap.md:57-58`, `specs/current/architecture-boundaries.md:41-56`, `pnpm-workspace.yaml:1-3`
- **Keep shared contracts inside an existing app**: moving contract types under `apps/web` would reuse the current location of many UI-adjacent types, but W1 says shared code should contain pure DTOs and avoid framework or environment-specific APIs. Source: `apps/web/src/types.ts:32-101`, `specs/current/architecture-boundaries.md:41-56`
- **Start with type-only contracts**: W2 can define request/response, SSE event, and error model types first, while runtime schemas remain in the later W4 workstream. Source: `specs/current/maintainability-roadmap.md:57-60`
- **Migrate repository code to a typed end state in phases**: W3 can add TypeScript configs and package scripts first, then convert daemon modules, root scripts, and e2e support files in bounded batches while running typecheck/test verification after each batch. Source: `apps/daemon/package.json:9-13`, `apps/daemon/vitest.config.ts:1-8`, `e2e/package.json:6-12`, file search `apps/daemon/**/*.{js,mjs,cjs,ts,tsx}`, file search `scripts/**/*.{js,mjs,cjs,ts,tsx}`
- **Broaden root typecheck**: root `typecheck` currently targets only web, so full project TypeScript verification requires daemon, shared package, scripts, and e2e/support coverage. Source: `package.json:23`, `apps/daemon/package.json:9-13`, `e2e/package.json:6-12`
### Constraints & Dependencies
- W2 depends on completed W1 ownership boundaries, and W3 depends on W2 for the highest-value shared types. Source: `specs/current/maintainability-roadmap.md:56-58`
- Runtime validation for HTTP inputs, paths, agents, models, uploads, task IDs, and command args is W4 scope, so research for W2/W3 should capture type boundaries without implementing full validation policy yet. Source: `specs/current/maintainability-roadmap.md:59`
- Shared code must stay free of Next.js, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals. Source: `specs/current/architecture-boundaries.md:41-56`
- API DTOs should prefer workspace-scoped logical or relative paths; machine absolute paths should remain daemon-internal. Source: `specs/current/architecture-boundaries.md:58-64`
- The `/api/chat` stream currently includes daemon-internal `cwd` in the `start` SSE event. Source: `apps/daemon/src/server.ts:1087-1095`
- Current daemon SSE lifecycle has no heartbeat or version field in emitted events. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180`
- Current error responses and SSE errors do not use a unified model with `code`, `message`, `details`, `retryable`, and `requestId/taskId`. Source: `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1170-1180`
- Daemon package devDependencies currently include `vitest` only; TypeScript and Node/Express type packages are available in web but not daemon. Source: `apps/daemon/package.json:21-23`, `apps/web/package.json:19-24`
- Full-project TypeScript migration includes CommonJS/MJS operational edges such as Playwright reporter loading and Node test/script execution. Source: `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15`
### Key References
- `specs/current/maintainability-roadmap.md:57-58` - W2/W3 outputs and dependency relationship.
- `specs/current/architecture-boundaries.md:41-56` - allowed shared contract contents and shared-code restrictions.
- `apps/web/next.config.ts:35-44` - dev proxy boundary for web-to-daemon API and SSE.
- `apps/web/src/providers/daemon.ts:19-38` - web-side `/api/chat` request options.
- `apps/web/src/providers/daemon.ts:85-151` - web-side SSE frame handling.
- `apps/web/src/providers/daemon.ts:178-228` - web-side daemon agent event translation.
- `apps/web/src/types.ts:32-39` - current UI `AgentEvent` union.
- `apps/daemon/src/server.ts:868-884` - daemon `/api/chat` request field handling and ad hoc HTTP errors.
- `apps/daemon/src/server.ts:1035-1044` - daemon SSE frame writer.
- `apps/daemon/src/server.ts:1087-1180` - daemon `/api/chat` start/agent/stdout/stderr/error/end lifecycle.
- `apps/daemon/src/server.ts:1188-1303` - daemon API proxy stream request and SSE events.
- `apps/daemon/src/json-event-stream.ts:35-91` - normalized agent JSON event output.
- `apps/daemon/package.json:9-23` - daemon scripts and dependencies.
- `apps/web/tsconfig.json:2-23` - web TypeScript baseline.
- `e2e/package.json:6-12` - e2e test scripts that currently execute TS config plus MJS runtime support.
- `pnpm-workspace.yaml:1-3` - current workspace package globs.
## Design
### Architecture Overview
```mermaid
flowchart LR
Web[apps/web\nUI + thin BFF/proxy]
Contracts[packages/contracts\npure TypeScript DTOs\nSSE unions\nerror model]
Daemon[apps/daemon\nlocal capability server]
Scripts[scripts + e2e\nTypeScript-covered operational code]
Web -->|imports HTTP/SSE/error types| Contracts
Daemon -->|imports HTTP/SSE/error types| Contracts
Web -->|/api/* JSON + SSE| Daemon
Scripts -->|typechecked execution helpers| Contracts
```
### Design Decisions
- Decision: Add a new `packages/contracts` workspace package for W2. The package exports pure TypeScript types for daemon HTTP DTOs, SSE event unions, task states, and error codes; this aligns with the shared-boundary allowed contents and the roadmap's shared-contract output. Source: `specs/current/architecture-boundaries.md:41-56`, `specs/current/maintainability-roadmap.md:57-58`, `pnpm-workspace.yaml:1-3`
- Decision: Keep `apps/web/src/types.ts` as the UI/application type layer and move only daemon-facing DTOs/events/errors into `packages/contracts`. Web owns UI state and communicates with daemon through API DTOs and streaming events; current UI `AgentEvent` is a presentation union. Source: `specs/current/architecture-boundaries.md:13-27`, `apps/web/src/types.ts:32-39`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252`
- Decision: Model the current daemon API before tightening behavior. Start with type contracts for `/api/chat`, `/api/proxy/stream`, project routes, conversation/message routes, file routes, artifacts, health, agents, skills, and design systems, then add runtime schemas in W4. Source: `specs/current/maintainability-roadmap.md:57-60`, `apps/daemon/src/server.ts:200-269`, `apps/daemon/src/server.ts:725-864`, `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1188-1303`
- Decision: Define separate transport-level SSE unions and UI-level event unions. `/api/chat` transport events cover `start`, `agent`, `stdout`, `stderr`, `error`, and `end`; normalized agent payloads cover `status`, `text_delta`, `thinking_delta`, `thinking_start`, `tool_use`, `tool_result`, `usage`, and `raw`; web translation remains liberal for forward compatibility. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180`, `apps/web/src/providers/daemon.ts:85-151`, `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/src/json-event-stream.ts:35-91`
- Decision: Define a versioned SSE contract shape for future W8/W6 compatibility while preserving the existing event names during W2 adoption. Include a protocol version constant and typed event payloads; heartbeat, cancellation, and canonical task lifecycle events remain future extensions. Source: `specs/current/maintainability-roadmap.md:40-41`, `specs/current/maintainability-roadmap.md:57-64`, `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180`
- Decision: Introduce a unified `ApiError` and `SseErrorEvent` type with `code`, `message`, `details`, `retryable`, `requestId`, and `taskId`, plus compatibility helpers for existing `{ error }` and `{ code, error }` responses. Current routes return multiple ad hoc shapes; W2 should make the target contract explicit. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1170-1180`
- Decision: Treat machine absolute paths as daemon-internal in public contracts. DTOs should use project-relative or logical paths; the existing `/api/chat` `start` event's `cwd` field should be typed as legacy/internal and removed from web-facing assumptions during adoption. Source: `specs/current/architecture-boundaries.md:58-64`, `apps/daemon/src/server.ts:1087-1095`
- Decision: W3's end state is a compiled TypeScript daemon runtime with a transitional `allowJs` phase. The daemon currently runs `node cli.js` and exposes `./cli.js` as its bin, so TypeScript entrypoint migration needs a deliberate build output and script/bin update. Source: `apps/daemon/package.json:6-13`, `package.json:9-24`
- Decision: Broaden typechecking from web-only to contracts, daemon, scripts, and e2e support. Root `typecheck` currently filters only `@open-design/web`; daemon has tests but no typecheck script; e2e already uses TypeScript configs and MJS/CJS operational files. Source: `package.json:19-25`, `apps/daemon/package.json:9-23`, `apps/web/tsconfig.json:2-23`, `e2e/package.json:6-12`, `e2e/playwright.config.ts:1-58`
- Decision: Migrate JavaScript/MJS/CJS files in dependency order: pure parsers/helpers, project/artifact helpers, DB/agent modules, server/CLI entrypoints, root scripts, then e2e scripts/reporters. This keeps each step verifiable and limits runtime-loader risk around Playwright reporter loading. Source: `apps/daemon/vitest.config.ts:1-8`, `apps/daemon/src/json-event-stream.ts:35-91`, `e2e/playwright.config.ts:22-37`, `e2e/package.json:8-12`, `package.json:14-15`
### Why this design
- A dedicated shared package makes the web/daemon boundary explicit while preserving the existing product architecture: web handles UI/proxy behavior and daemon owns local runtime capabilities. Source: `specs/current/architecture-boundaries.md:13-40`
- Type-only W2 contracts deliver immediate drift protection and give W4 a stable target for runtime schemas. Source: `specs/current/maintainability-roadmap.md:57-60`
- Separating transport events from UI events keeps daemon protocol evolution independent from rendering concerns and preserves the current liberal parser behavior. Source: `apps/web/src/providers/daemon.ts:85-151`, `apps/web/src/providers/daemon.ts:178-228`
- A compiled daemon TypeScript target is the safest full migration end state for the package bin and root `od` entrypoint. Source: `apps/daemon/package.json:6-13`, `package.json:9-10`
### Implementation Steps
1. Create `packages/contracts`, add it to the pnpm workspace, and expose typed exports for API DTOs, SSE events, errors, task states, and shared constants.
2. Add package-level and root-level TypeScript configuration so contracts typecheck independently and participate in root `pnpm run typecheck`.
3. Replace duplicated web daemon-facing types with imports from `packages/contracts`, keeping UI-only state and presentation unions in `apps/web/src/types.ts`.
4. Type daemon request handlers, response envelopes, SSE send helpers, and normalized JSON event parsing against the shared contracts while preserving current runtime behavior.
5. Introduce compatibility error helpers and adopt the unified error model first in `/api/chat`, upload errors, project/file routes, and proxy stream errors.
6. Add daemon TypeScript config and scripts, migrate daemon modules in dependency order, and switch runtime/bin scripts to compiled JavaScript output once `cli.ts` and `server.ts` are converted.
7. Convert root scripts and e2e support scripts/reporters with an explicit execution strategy for Node scripts and Playwright reporter loading.
8. Broaden root verification to contracts, web, daemon, scripts, and e2e support, then run targeted daemon/web/e2e tests.
### Test Strategy
- Contracts: run `pnpm --filter @open-design/contracts typecheck`; add lightweight type-level coverage via exported example payloads or `tsc`-checked fixture files. Source: `specs/current/maintainability-roadmap.md:57-58`
- Web adoption: run `pnpm --filter @open-design/web typecheck` and existing web tests after importing shared DTO/SSE/error types. Source: `apps/web/package.json:6-10`, `apps/web/tsconfig.json:2-23`
- Daemon adoption: add and run `pnpm --filter @open-design/daemon typecheck`, then `pnpm --filter @open-design/daemon test`; daemon already uses Vitest with TypeScript config. Source: `apps/daemon/package.json:9-23`, `apps/daemon/vitest.config.ts:1-8`
- SSE compatibility: add or update parser/translator tests around `/api/chat` `start`, `agent`, `stdout`, `stderr`, `error`, and `end` frames plus normalized agent payloads. Source: `apps/web/src/providers/daemon.ts:85-151`, `apps/daemon/src/json-event-stream.ts:35-91`
- Error model compatibility: add daemon route/helper tests for existing `{ error }` and `{ code, error }` inputs mapping into the new `ApiError` shape. Source: `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`, `apps/daemon/src/server.ts:868-884`
- Runtime migration: after each TypeScript conversion batch, run daemon tests and root typecheck; after script/e2e migration, run `pnpm --filter @open-design/e2e test` and a Playwright reporter smoke run when feasible. Source: `package.json:19-25`, `e2e/package.json:6-12`, `e2e/playwright.config.ts:22-37`
### Pseudocode
Flow:
Add shared package
Export contract modules
api/chat.ts
api/projects.ts
api/files.ts
sse/chat.ts
sse/proxy.ts
errors.ts
Web imports contracts
build typed request body
parse transport SSE frame
translate typed transport event to UI AgentEvent
Daemon imports contracts
type request body reads
type response envelopes
type send(event, data)
wrap legacy errors into ApiError shape
TypeScript migration proceeds by dependency order
helpers/parsers
DTO builders
services/adapters
server/CLI
scripts/e2e
### File Structure
- `packages/contracts/package.json` - new workspace package metadata, exports, and typecheck script.
- `packages/contracts/tsconfig.json` - strict declaration-emitting TypeScript config for shared contracts.
- `packages/contracts/src/index.ts` - public export surface.
- `packages/contracts/src/api/*.ts` - HTTP request/response DTOs and response envelopes.
- `packages/contracts/src/sse/*.ts` - chat/proxy SSE event unions and protocol constants.
- `packages/contracts/src/errors.ts` - error codes, `ApiError`, `ApiErrorResponse`, and SSE error payload types.
- `packages/contracts/src/tasks.ts` - task state/lifecycle constants shared with later W6/W8 work.
- `apps/web/src/types.ts` - keep UI/application types; import shared daemon DTOs where applicable.
- `apps/web/src/providers/daemon.ts` - consume shared chat request and SSE event types; retain UI translator.
- `apps/daemon/tsconfig.json` - new daemon TypeScript config with transitional `allowJs` and strict checking target.
- `apps/daemon/package.json` - add `typecheck`, build/runtime scripts, and TypeScript/type dependencies as migration steps require.
- `apps/daemon/**/*.ts` - migrated daemon modules, server, CLI, parsers, and helpers.
- `scripts/**/*.ts` - migrated root operational scripts.
- `e2e/**/*.ts` - migrated e2e support scripts and reporter strategy.
- `AGENTS.md` - repository development conventions for future work: shared contracts first for web/daemon boundaries, TypeScript-first implementation, no project-owned JavaScript entrypoints/modules/scripts/tests/reporters after W3.
### Interfaces / APIs
- `ChatRequest`: `{ agentId, message, systemPrompt?, projectId?, attachments?, model?, reasoning? }`, matching web post body and daemon handler reads. Source: `apps/web/src/providers/daemon.ts:57-77`, `apps/daemon/src/server.ts:868-884`
- `ChatSseEvent`: discriminated union for `start`, `agent`, `stdout`, `stderr`, `error`, and `end`, with `cwd` treated as legacy/internal on `start`. Source: `apps/daemon/src/server.ts:1035-1044`, `apps/daemon/src/server.ts:1087-1180`
- `DaemonAgentPayload`: discriminated union for normalized agent payloads emitted inside `agent` events. Source: `apps/web/src/providers/daemon.ts:178-228`, `apps/daemon/src/json-event-stream.ts:35-91`
- `ProxyStreamRequest` and `ProxySseEvent`: request fields `baseUrl`, `apiKey`, `model`, `systemPrompt`, and `messages`; events `start`, `delta`, `error`, and `end`. Source: `apps/daemon/src/server.ts:1188-1303`
- `ApiError`: `{ code, message, details?, retryable?, requestId?, taskId? }`; `ApiErrorResponse`: `{ error: ApiError }`; compatibility helpers accept legacy string errors during migration. Source: `specs/current/maintainability-roadmap.md:39-40`, `apps/daemon/src/server.ts:147-177`, `apps/daemon/src/server.ts:200-205`
- Response envelopes: projects, conversations, messages, files, and file mutation responses should mirror the current daemon JSON shapes and reuse existing web DTO fields. Source: `apps/daemon/src/server.ts:200-269`, `apps/daemon/src/server.ts:325-424`, `apps/daemon/src/server.ts:725-864`, `apps/web/src/types.ts:150-179`, `apps/web/src/types.ts:215-252`
### Edge Cases
- Existing SSE consumers should continue ignoring unknown `agent` payloads, so new union members can be added safely. Source: `apps/web/src/providers/daemon.ts:178-228`
- Malformed or partial SSE frames should preserve current parser tolerance until W4 validation defines stricter behavior. Source: `apps/web/src/providers/daemon.ts:163-176`
- `/api/chat` currently emits terminal SSE errors and HTTP 400 JSON errors through different shapes; W2 should type both and allow incremental adoption. Source: `apps/daemon/src/server.ts:868-884`, `apps/daemon/src/server.ts:1170-1180`
- Playwright reporter loading currently points to a `.cjs` reporter path; migration needs a compiled JS reporter path or supported TS execution path. Source: `e2e/playwright.config.ts:22-37`
- The root `od` bin and daemon package bin currently point at JavaScript entrypoints; conversion to `.ts` requires a compiled output target before script/bin paths change. Source: `package.json:9-10`, `apps/daemon/package.json:6-13`
- Shared contracts should stay pure and free of Next, Express, Node filesystem/process APIs, browser APIs, SQLite, and daemon internals. Source: `specs/current/architecture-boundaries.md:41-56`
## Plan
- [x] Step 1: Establish shared contracts package
- [x] Substep 1.1 Implement: Add `packages/contracts` workspace package, exports, and strict TypeScript config.
- [x] Substep 1.2 Implement: Define `ApiError`, error codes, task states, and common response envelope helpers.
- [x] Substep 1.3 Implement: Define HTTP DTOs for chat, proxy stream, projects, conversations, messages, files, agents, skills, design systems, artifacts, and health.
- [x] Substep 1.4 Implement: Define `/api/chat` and `/api/proxy/stream` SSE event unions and normalized agent payload unions.
- [x] Substep 1.5 Verify: Run contracts typecheck and root package graph install/type resolution checks.
- [x] Step 2: Adopt contracts in web and daemon boundary code
- [x] Substep 2.1 Implement: Import shared chat/proxy/file/project DTOs in web provider and app types while keeping UI-only unions local.
- [x] Substep 2.2 Implement: Type daemon response envelopes, chat request body reads, proxy stream request body reads, and SSE send helpers.
- [x] Substep 2.3 Implement: Add compatibility error helpers and adopt them in chat, upload, project/file, and proxy stream paths.
- [x] Substep 2.4 Verify: Run web typecheck, daemon tests, and targeted SSE/error compatibility tests.
- [x] Step 3: Add daemon TypeScript foundation
- [x] Substep 3.1 Implement: Add daemon `tsconfig.json`, `typecheck` script, and required TypeScript/Node/Express type dependencies.
- [x] Substep 3.2 Implement: Configure transitional `allowJs` checking for current daemon modules.
- [x] Substep 3.3 Implement: Update root `typecheck` to include contracts and daemon.
- [x] Substep 3.4 Verify: Run daemon typecheck, daemon tests, and root typecheck.
- [x] Step 4: Migrate daemon modules to TypeScript
- [x] Substep 4.1 Implement: Convert pure parsers/helpers and their tests first.
- [x] Substep 4.2 Implement: Convert project/file/artifact helper modules and DTO builders.
- [x] Substep 4.3 Implement: Convert DB, agents, runtime adapter, and stream orchestration modules.
- [x] Substep 4.4 Implement: Convert `server` and `cli` entrypoints and switch package/runtime bin paths to compiled output.
- [x] Substep 4.5 Verify: Run daemon typecheck/tests after each conversion batch and smoke the daemon CLI locally.
- [x] Step 5: Migrate scripts and e2e support to TypeScript
- [x] Substep 5.1 Implement: Convert root scripts with a documented Node execution strategy.
- [x] Substep 5.2 Implement: Convert e2e runtime/support scripts and preserve Playwright reporter loading through compiled output or supported TS loading.
- [x] Substep 5.3 Implement: Update root `typecheck` to include scripts and e2e support.
- [x] Substep 5.4 Verify: Run root typecheck, repo test suite, e2e tests, and a Playwright reporter smoke check when feasible.
- [x] Step 6: Lock in typed end state and future conventions
- [x] Substep 6.1 Implement: Add or update root `AGENTS.md` with W2/W3 development conventions: put shared web/daemon contracts in `packages/contracts`, keep UI-only types in web, keep daemon capability logic in daemon, use TypeScript for new project-owned code, and route runtime validation work to the later validation workstream.
- [x] Substep 6.2 Implement: Add an automated residual-JavaScript check for project-owned entrypoints, modules, scripts, tests, and reporters, with explicit allowlist entries only for generated, vendored, or compatibility-output files.
- [x] Substep 6.3 Verify: Run the residual-JavaScript check and confirm no project-owned `.js`, `.mjs`, or `.cjs` source files remain outside the documented allowlist.
- [x] Substep 6.4 Verify: Re-run root typecheck and full test suite after the final convention and residual-file checks are in place.
## Notes
<!-- Optional sections — add what's relevant. -->
### Implementation
- `pnpm-workspace.yaml` - added `packages/*` so shared packages participate in the workspace graph.
- `packages/contracts/package.json` - added `@open-design/contracts` package metadata, source exports, and `typecheck` script.
- `packages/contracts/tsconfig.json` - added strict TypeScript configuration for shared contracts.
- `packages/contracts/src/common.ts` - added JSON, nullable, and response envelope helper types.
- `packages/contracts/src/errors.ts` - added `ApiError`, error codes, compatibility response types, SSE error payloads, and small pure construction helpers.
- `packages/contracts/src/tasks.ts` - added shared task state and task status contracts.
- `packages/contracts/src/api/*.ts` - added HTTP DTOs for chat, proxy stream, projects, conversations, messages, files, agents, skills, design systems, artifacts, and health.
- `packages/contracts/src/sse/*.ts` - added typed SSE event helpers plus `/api/chat` and `/api/proxy/stream` event unions with protocol constants.
- `packages/contracts/src/examples.ts` - added tsc-checked example payloads for key contracts.
- `packages/contracts/src/index.ts` - added the public export surface.
- `apps/web/package.json` and `apps/daemon/package.json` - added workspace dependencies on `@open-design/contracts` for boundary type adoption.
- `apps/web/src/types.ts` - re-exported shared chat, registry, project, file, and conversation DTOs while keeping UI/config-only types local.
- `apps/web/src/providers/daemon.ts` - typed `/api/chat` request construction, chat SSE frame handling, daemon agent payload translation, and unified SSE error payload reading with shared contracts.
- `apps/daemon/src/server.ts` - added JSDoc contract imports, typed project/file response envelopes, typed chat/proxy request body reads, typed SSE send events, and shared-shape compatibility error helpers.
- `apps/daemon/src/server.ts` - adopted `ApiErrorResponse`/`SseErrorPayload` shapes for chat, upload, project/file, and proxy stream error paths while preserving runtime behavior.
- `apps/web/src/providers/sse.test.ts` - added coverage for unified daemon SSE error payload handling.
- `apps/daemon/sse-response.test.mjs` - added coverage for compatibility `ApiErrorResponse` construction.
- `apps/daemon/tsconfig.json` - added a strict daemon TypeScript foundation with `allowJs` for the current JavaScript/MJS transition and bundler resolution for workspace contract source imports.
- `apps/daemon/package.json` - added a `typecheck` script plus TypeScript, Node, Express, Multer, and better-sqlite3 type dependencies.
- `package.json` - broadened root `typecheck` to run contracts, web, and daemon checks through Corepack-pinned pnpm.
- `pnpm-lock.yaml` - updated lockfile entries for daemon TypeScript/type dependencies.
- `apps/daemon/*.ts` - migrated remaining daemon-owned modules, helpers, server entrypoint, CLI entrypoint, and daemon tests from `.js`/`.mjs` to `.ts` while preserving runtime `.js` ESM import specifiers for compiled output.
- `apps/daemon/tsconfig.json` - switched daemon compilation to NodeNext module resolution, disabled JavaScript source inclusion, and added `dist` declaration/source-map emit.
- `apps/daemon/package.json` - added daemon `build`, routed daemon/dev/start through compiled `dist/cli.js`, and updated the package bin to compiled output.
- `package.json` - updated the root `od` bin to the compiled daemon CLI path.
- `scripts/*.ts` - migrated root development and design-system sync scripts from MJS to TypeScript, using Node 24 `--experimental-strip-types` for direct script execution.
- `scripts/tsconfig.json` - added strict TypeScript coverage for root operational scripts.
- `e2e/scripts/*.ts` - migrated e2e cleanup and live runtime-adapter smoke scripts to TypeScript; the live script now builds and imports the compiled daemon output.
- `e2e/reporters/markdown-reporter.ts` and `e2e/cases/report-metadata.ts` - migrated the Playwright markdown reporter and report metadata from CommonJS to TypeScript ESM.
- `e2e/tsconfig.json` and `e2e/package.json` - added e2e support typechecking plus Node strip-types execution for support scripts.
- `e2e/playwright.config.ts` - updated root script imports and reporter paths to TypeScript files and routed the Playwright webServer command through Corepack-pinned pnpm.
- `package.json` - broadened root `typecheck` to cover scripts and e2e support, and routed root pnpm-invoking scripts through Corepack so the pinned pnpm version is used consistently.
- `AGENTS.md` - updated project shape, command notes, TypeScript-first conventions, shared contract boundaries, daemon ownership rules, runtime validation scope, and migrated `.ts` file references for future agents.
- `scripts/check-residual-js.ts` - added an automated residual JavaScript scanner for project-owned `.js`, `.mjs`, and `.cjs` files, with documented output/vendor/generated allowlist prefixes and local scratch/dependency directory skips.
- `package.json` - added `check:residual-js` and made root `typecheck` run the residual JavaScript check after package/support typechecks and daemon build output generation.
### Verification
- `corepack pnpm install` - passed; workspace graph recognized all 5 projects and updated lockfile state.
- `corepack pnpm --filter @open-design/contracts typecheck` - passed.
- `corepack pnpm --filter @open-design/web typecheck` - passed as a package graph/type resolution sanity check.
- `corepack pnpm typecheck` - attempted; failed because the root script invokes `pnpm` from PATH version 10.28.0 while the repo requires `>=10.33.2 <11`. The Corepack package-level equivalent above passed.
- `corepack pnpm install` - passed after adding app dependencies on `@open-design/contracts`; lockfile links web and daemon to the workspace package.
- `corepack pnpm --filter @open-design/contracts typecheck` - passed after Step 2 adoption.
- `corepack pnpm --filter @open-design/web typecheck` - passed after Step 2 adoption.
- `corepack pnpm --filter @open-design/web test -- src/providers/sse.test.ts` - passed; Vitest also ran existing artifact manifest tests in the web package.
- `corepack pnpm --filter @open-design/daemon test -- sse-response.test.mjs` - passed; Vitest also ran existing daemon artifact manifest and json event stream tests.
- `corepack pnpm install` - passed after adding daemon TypeScript/type dependencies.
- `corepack pnpm --filter @open-design/daemon typecheck` - passed after adding the daemon TypeScript foundation.
- `corepack pnpm --filter @open-design/daemon test` - passed; all 18 daemon tests passed.
- `corepack pnpm typecheck` - passed after root `typecheck` was broadened to contracts, web, and daemon and routed through Corepack-pinned pnpm.
- `corepack pnpm --filter @open-design/daemon typecheck` - passed after daemon module conversion.
- `corepack pnpm --filter @open-design/daemon build` - passed and emitted compiled daemon output under `apps/daemon/dist`.
- `corepack pnpm --filter @open-design/daemon test` - passed after daemon module conversion; all 18 daemon tests passed.
- `node apps/daemon/dist/cli.js --help` - passed as a compiled CLI smoke check.
- `corepack pnpm typecheck` - passed after daemon module conversion and compiled-bin package updates.
- `corepack pnpm --filter @open-design/e2e exec tsc -p ../scripts/tsconfig.json --noEmit` - passed for root TypeScript scripts.
- `corepack pnpm --filter @open-design/daemon build` - passed before e2e support typechecking and live-script import validation.
- `corepack pnpm --filter @open-design/e2e typecheck` - passed for Playwright config, reporter, report metadata, and e2e support scripts.
- `corepack pnpm typecheck` - passed after script and e2e support migration.
- `node --experimental-strip-types` import smoke checks for `scripts/resolve-dev-ports.ts` and `e2e/reporters/markdown-reporter.ts` - passed.
- `corepack pnpm test` - passed; web 15 tests, daemon 18 tests, and e2e Vitest 9 tests passed.
- `corepack pnpm --filter @open-design/e2e test:ui:clean` - passed against the TypeScript cleanup script.
- `corepack pnpm --filter @open-design/e2e exec playwright test -c playwright.config.ts --list` - passed as a Playwright config/reporter loading smoke check and listed 15 Chromium UI tests.
- `corepack pnpm run check:residual-js` - passed; no project-owned residual `.js`, `.mjs`, or `.cjs` files were found outside the documented allowlist.
- `corepack pnpm --filter @open-design/e2e exec tsc -p ../scripts/tsconfig.json --noEmit` - passed after adding the residual JavaScript scanner.
- `corepack pnpm typecheck` - passed after Step 6; contracts, web, daemon typecheck, daemon build, scripts typecheck, e2e typecheck, and residual JavaScript check all passed.
- `corepack pnpm test` - passed after Step 6; web 15 tests, daemon 18 tests, and e2e Vitest 9 tests passed.
+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.