Commit Graph
365 Commits
Author SHA1 Message Date
lefarcen d2ac69b141 fix(plugins): wire Open Design "Open Design PR" button end-to-end
Two-part fix for the Plugin folder card's "Open Design PR" button. The
flow was broken end-to-end: the CLI emitted a 404 URL, and the agent
prompt under-specified the contribution steps so the agent stalled
mid-turn or fell back to the legacy issue-URL path.

Catalog target — `apps/daemon/src/plugins/publish.ts`:

`buildPublishLink({ catalog: 'open-design' })` hardcoded a submission URL
at `github.com/open-design/plugin-registry`, the dedicated registry repo
proposed in docs/plans/plugin-registry.md §1.2. That repo doesn't exist
yet (P3.1 notes "creating the external GitHub repo is an operational
launch step, not a code blocker"), so every generated URL 404'd. Retarget
the catalog at the live `nexu-io/open-design` monorepo and update the
target-path hint in the PR body to `plugins/community/<plugin-name>/`
(the actual layout under main). Plan §1.2 stays the long-term goal — see
the code comment.

Also updates the matching `od plugin yank` issue URL in cli.ts.

Agent prompt — `apps/web/src/components/design-files/pluginFolderActions.ts`:

The `contribute` action prompt only said "use the supported `od plugin
publish` Open Design registry flow", which produces an issue URL (the
legacy path) and left the agent to invent the remaining steps. The agent
ended up calling `AskUserQuestion` mid-turn waiting for input that the
DesignFilesPanel buttons couldn't satisfy, then stalled for 600s.

Rewrite the `contribute` prompt to drive the full PR flow via raw `gh`
commands:
  1. preflight `gh --version` / `gh auth status` (detect-and-instruct on
     missing CLI, never auto-install anything)
  2. read manifest, resolve author login
  3. `gh repo fork nexu-io/open-design --remote=false`
  4. clone fork, branch, cp plugin into `plugins/community/<name>/`,
     commit/push using author's git identity (not a hardcoded bot)
  5. `gh pr create ... --web` so the author reviews and clicks Create

Hard bans on `AskUserQuestion`, retry-on-failure, and the legacy
`od plugin publish --to open-design` CLI keep the turn fire-and-forget.
The `install` / `publish` action prompts are unchanged.

Tests:

* `apps/daemon/tests/plugins-publish.test.ts` — assert URL host +
  catalogLabel match `nexu-io/open-design`, body contains the new path
  hint.
* `apps/web/tests/components/pluginFolderActions.test.ts` (new) — lock
  the contribute prompt's command surface, the `--web` review window,
  the `AskUserQuestion` ban, the install-tool ban, and folder-path
  interpolation. Nine assertions covering the prompt contract without
  coupling to exact wording.

Validation:

* `pnpm --filter @open-design/daemon exec vitest run tests/plugins-publish.test.ts`
  → 11/11 passed
* `pnpm --filter @open-design/web exec vitest run tests/components/pluginFolderActions.test.ts`
  → 9/9 passed
* `pnpm --filter @open-design/web typecheck` clean
* E2E: button click in DesignFilesPanel under namespace=e2e-pr-test ran
  `gh --version` → fork → clone → branch → commit/push → ended at
  `gh pr create --web` opening the GitHub PR draft in browser. No
  AskUserQuestion stall this time.

Doesn't touch: `5f71968f` server-side endpoints (`/contribute-open-design`
+ `/publish-github` still in main as dead code — separate cleanup),
plan/spec docs (per maintainer call to keep the dedicated-repo as the
long-term goal), or `registry-backends.test.ts` (plan terminal-state
fixture; no production caller).
2026-05-19 12:25:15 +08:00
Jeongjin ShinandGitHub 0026dfa344 fix: support CODEX_API_KEY for Codex CLI (#1880)
* fix(daemon): allow Codex API key env

* fix(web): expose Codex API key setting

* fix(web): clarify Codex key labels
2026-05-18 14:29:23 +08:00
2e97ac9e68 Pin daemon data dir in agent runtime env (#2005)
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 14:02:11 +08:00
91be2696dd Persist routine failure reasons (#1963)
Co-authored-by: multica-agent <github@multica.ai>
2026-05-17 23:22:00 +08:00
asim48-ctrlandGitHub 9ce504b933 Improve Claude endpoint connection diagnostics (#1955) 2026-05-17 23:19:01 +08:00
cd56b5e576 fix(cli): plugin publish reads manifest version when the stored row reads as the 0.0.0 pre-handshake sentinel (#1903)
Closes #1765

`od plugin publish --to open-design` builds the registry-submission
body from the daemon's `/api/plugins/:id` row. The row carries a
stored `version` plus the full manifest. For project-local plugins —
`generated-plugin/`, snapshots, freshly imported folders — the
stored `version` reads as `'0.0.0'` until the registry handshake
runs and stamps the real version. The manifest's `version`, however,
is the author's real value the whole time.

Before this patch, the publish flow consumed `row.version` directly
and printed `version: 0.0.0` into the registry submission body even
when the plugin's `open-design.json` declared `0.1.0` or any other
non-sentinel version.

Mirror `apps/daemon/src/plugins/marketplaces.ts:298,328` and prefer
the manifest version when the stored row reads as the pre-handshake
sentinel. The fallback chain is now:

  1. row.version, when it exists and isn't '0.0.0'
  2. row.manifest.version, when it exists and is non-empty
  3. row.version (even if '0.0.0'), preserving today's behavior for
     plugins that genuinely have no manifest data
  4. '0.0.0' literal, when nothing else is available

The fallback chain is conservative: it never substitutes a higher
version that the author didn't already declare somewhere — it just
prefers the manifest over the stored sentinel.

Validation:
- `pnpm --filter @open-design/daemon test -- tests/plugins-publish.test.ts` → green
- `pnpm --filter @open-design/daemon typecheck` → green

Co-authored-by: nicejames <nicejames@gmail.com>
2026-05-17 20:24:41 +08:00
Yuhao ChenandGitHub fb57ef2fc3 Improve DeepSeek TUI diagnostics (#1940) 2026-05-17 15:01:10 +08:00
2d1a7bd727 fix(daemon): isLocalSameOrigin honors OD_ALLOWED_ORIGINS in reverse-proxy deployments (#1902)
Closes #1868

When the daemon runs behind a reverse proxy (Nginx, Caddy, Traefik),
the Host header the daemon observes is the proxy's upstream address,
not the browser-visible origin the user typed in. As a result the
host check inside isLocalSameOrigin rejected requests whose Origin
header was *explicitly listed* in OD_ALLOWED_ORIGINS — defeating the
documented escape hatch for proxy deployments.

Add a single early-return in isLocalSameOrigin: if a non-empty
Origin header exactly matches an entry in OD_ALLOWED_ORIGINS, trust
it. The configuredAllowedOrigins parser already requires the
origin to be a well-formed http(s) URL and rejects unsupported
schemes, so the allow-list is a maintainer-controlled set of
trusted origins.

The existing host-based loopback / private-LAN path remains the
default for no-Origin and unlisted-Origin requests; this change is
strictly additive for the explicit allow-list case.

Validation:
- New tests in apps/daemon/tests/origin-validation.test.ts cover
  the nginx-upstream case (Host is a container IP, Origin is the
  allow-listed proxy origin) plus three negative cases: foreign
  Origin still rejected, no-Origin behavior preserved, trailing
  slash mismatch rejected.
- pnpm --filter @open-design/daemon test -- tests/origin-validation.test.ts → green
- pnpm guard → green

Co-authored-by: nicejames <nicejames@gmail.com>
2026-05-16 21:34:29 +08:00
Yuhao ChenandGitHub 0e61313347 fix(prompts): stabilize discovery brand answers (#1861) 2026-05-16 15:50:52 +08:00
Sam MorreelandGitHub 70e5ea6f70 Removed - sentinel for Qwen CLI (#1332)
* Removed - sentinel for Qwen CLI

* Addressed reviewer's comments

* Removed IDE config file

* Removed IDE file
2026-05-16 12:07:32 +08:00
mehmet turacandGitHub 7eb1e7dcfe fix(daemon): wrap Claude connection smoke stdin (#1844) 2026-05-16 11:33:40 +08:00
9a64fccdc0 fix(security): resolve hostname before approving external API base URLs (#1176)
* fix(security): resolve hostname before approving external API base URLs

Before this change the daemon-side base-URL validator only inspected the
literal hostname string. A public DNS record that points at an internal
address ('internal.example.com' → 10.0.0.5) passed validation, and the
daemon would issue the upstream request anyway — turning the BYOK proxy
into an SSRF primitive against internal infrastructure.

Add a small companion ('validateBaseUrlResolved') that runs the existing
sync check, resolves the hostname with 'dns.lookup({ all: true })', and
re-applies the block-list against every resolved address. Wire it into
the wrapper the daemon already uses ('validateExternalApiBaseUrl'), so
every proxy/finalize handler picks it up without further edits.

Carve-outs match the existing sync validator:

- Loopback hostnames skip DNS (Ollama-style local LLMs still work,
  including '*.localhost' / 'lvh.me'-style names that resolve to 127.0.0.1
  per RFC 6761).
- IP literals are already vetted by the sync pass; no need to re-resolve.
- DNS resolver errors fall through to the existing fetch error path —
  a transient ENOTFOUND should not turn into a 403.

The 6 callers that previously consumed the sync result now 'await' the
async wrapper. All call sites are already inside async route handlers.

Vitest coverage in apps/daemon/tests/connection-test.test.ts covers:
sync rejection passthrough, loopback / IP-literal short-circuits,
private IPv4 and IPv6 resolution, dual-stack with one private record,
public→public passes (api.openai.com), '*.localhost' resolved→loopback,
and resolver-error fallback.

Detected by: aeon (manual review + semgrep + osv + trufflehog).
Class: CWE-918 (SSRF) — DNS-based bypass.

* fix(security): cover remaining daemon baseUrl fetch surfaces + Ollama redirects

Addresses PR #1176 review feedback (lefarcen / mrcfps): the resolved-IP
wrapper only covered the proxy/finalize routes, leaving three adjacent
SSRF gaps open.

- testProviderConnection (/api/test/connection provider mode): switch
  from sync validateBaseUrl to await validateBaseUrlResolved so a
  hostname that resolves to a private IP is rejected before the daemon
  POSTs the smoke prompt upstream.
- listProviderModels (/api/provider/models): same swap. Import the
  DNS-aware helper from ./connectionTest.js since it carries the dns
  binding the daemon owns; contracts stays pure.
- Ollama proxy stream fetch: align with the other four proxy routes
  by setting redirect: 'error', so a validated public host cannot 3xx
  the daemon to a private/internal URL after the pre-fetch check.

Regression coverage:
- POST /api/provider/models — DNS spy returns 10.0.0.5 for a synthetic
  hostname; route must respond { ok: false, kind: 'forbidden' } and
  must not invoke upstream fetch.
- POST /api/test/connection provider mode — same shape.
- /api/proxy/ollama/stream — fetch mock asserts redirect: 'error' on
  the upstream Ollama call.

The existing /api/provider/models timeout test now stubs dnsPromises
so it doesn't race the probe timer against real DNS.

---------

Co-authored-by: aeon <aeon@aaronjmars.com>
2026-05-15 23:12:52 +08:00
Quang DoandGitHub 13c8bc4193 feat(daemon): add OpenAI-compatible media providers (#1712)
* feat(daemon): add openai-compatible media providers

* fix(web): sync media registry with routed providers
2026-05-15 23:05:03 +08:00
lefarcenandGitHub 7c1d9e9634 fix(claude-design-import): restore notched wheel zoom + warn on regex drift (#1814) 2026-05-15 21:44:57 +08:00
lefarcen 91195bc4ac Merge origin/preview/v0.8.0 (PR #1830) into sync branch
Pulls in the teammate's PR #1830 — e2e align to redesigned entry flows
+ watcher and codex runtime test fixes. Conflicts resolved:

- apps/daemon/tests/runtimes/registry-and-args.test.ts: imported both
  withPlatform (HEAD, used 6x) and withEnvSnapshot (theirs, used 1x);
  both helpers are needed in the file.
- apps/web/src/components/ProjectView.tsx: kept both ChatPane-remount
  guards. Main's fix #1710 uses lastSyncedConversationIdRef tracking URL
  sync; the teammate added lastSeenRouteConversationIdRef tracking last
  observed route. Both refs are already defined in the file and the two
  checks are complementary defenses.
- e2e/ui/settings-*.test.ts (5 files): took teammate's version wholesale
  — they replaced inline `page.goto('/') + click Execution mode` with
  `gotoEntryHome + openSettingsDialogFromEntry` helpers, which is exactly
  the PR's intent.
2026-05-15 19:10:39 +08:00
shangxinyu1andGitHub 2181eb376d test(e2e): align UI suites with redesigned entry flows (#1830)
* test(e2e): align critical ui tests with new entry shell

* test(e2e): align ui suites with redesigned entry flows

* fix(e2e): correct entry chrome page helper type

* fix(daemon): stabilize watcher and codex runtime tests

* fix(daemon): satisfy strict watcher option typing
2026-05-15 19:05:39 +08:00
lefarcen 22a3b99a47 Merge origin/main into preview/v0.8.0
Sync 49 commits from main. Conflicts resolved:
- .github/workflows/ci.yml: kept v0.8.0 granular per-area gating, added main's
  linux specs + release-stable.yml + release-preview.yml triggers
- .github/workflows/release-preview.yml: kept v0.8.0's full workflow over main's placeholder
- apps/web/src/components/AssistantMessage.tsx: combined v0.8.0 file-ops
  summary with main's stripTodoToolGroups + suppressAskUserQuestionFallbackText
- apps/web/src/components/ChatPane.tsx: kept both new imports
- apps/web/src/index.css: kept both .msg-plugin-chip and .user-copy-btn blocks
- e2e/ui/*.test.ts: kept v0.8.0 openEntrySettingsDialog helper over main's
  inline dialog navigation (UI was redesigned in v0.8.0)
- nix/package-{daemon,web}.nix: kept v0.8.0 pnpmDepsHash; rerun nix build to refresh
2026-05-15 18:23:33 +08:00
f78b0d3a2a feat: add Leonardo.ai image provider integration (#1123)
* feat: add Leonardo.ai image provider integration

Implements Leonardo.ai as a fully supported image provider with the following models:
- Phoenix (leonardo-phoenix) - versatile general-purpose model
- Kino XL (leonardo-kino-xl) - cinematic quality
- FLUX Dev (leonardo-flux-dev) - FLUX.1 [dev]
- FLUX Schnell (leonardo-flux-schnell) - fast generation
- Anime Pastel Dream (leonardo-anime-pastel) - anime style

Features:
- Async generation with polling (2-minute timeout)
- Support for standard aspect ratios: 1:1, 16:9, 9:16, 4:3, 3:4
- Bearer token authentication
- Automatic image format detection

Implementation:
- Frontend: apps/web/src/media/models.ts
- Backend: apps/daemon/src/media-models.ts
- Renderer: renderLeonardoImage() function (~130 lines)
- Dispatcher: integrated into media generation pipeline

API Integration:
- Submit: POST /generations
- Poll: GET /generations/{id}
- Response: generations_by_pk.generated_images[0].url

Addresses #984

* fix: Add leonardo to MediaProviderId type and register env vars

1. Add 'leonardo' to MediaProviderId union in apps/web/src/media/models.ts
   to fix TypeScript build error
2. Register LEONARDO_API_KEY env vars in apps/daemon/src/media-config.ts
   following the same pattern as other providers

This fixes the CI TypeScript build failure and enables proper env-based
credential lookup for Leonardo.ai provider.

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-05-15 17:16:28 +08:00
4e19c3f4f3 Prevent imported Claude canvases from zooming on scroll (#1726)
* Preserve HTML preview state across mode toggles

HTML previews could rebuild their iframe when switching into Edit or Comment, which reset scroll/canvas state and caused visible churn for multi-file artifacts. The viewer now keeps URL-loaded previews mounted when the artifact owns the mode bridge, relays file-refreshes through frame navigation, and restores preview scroll/viewport state across bridge mode changes.

Constraint: Generic srcdoc-only bridges are still required for unbridged artifacts, inspect mode, palette tweaks, decks, draw overlays, and forceInline.

Rejected: Keep all Edit/Comment previews on srcdoc | causes unnecessary iframe replacement for bridge-capable URL-loaded artifacts.

Confidence: high

Scope-risk: moderate

Directive: Do not enable URL-load for bridge-dependent modes unless the artifact has an owned postMessage bridge.

Tested: pnpm guard

Tested: pnpm --filter @open-design/web typecheck

Tested: pnpm --filter @open-design/web test

Tested: Playwright verified Edit and Comment toggles preserve iframe src and DOM node while receiving comment targets.

* Prevent preview wheel gestures from escaping into zoom

Trackpad pinch-like wheel events arrive with ctrl/meta modifiers on some platforms, which can make a normal vertical scroll feel like the preview zoomed. The preview now consumes those modified wheel events inside the host preview shell and in injected srcdoc previews, then maps the delta back to scroll where a scroll target exists.

Constraint: URL-loaded sandbox iframes cannot always be inspected by the host, so srcdoc previews need their own in-frame guard.

Rejected: Add allow-same-origin to preview iframes | weakens the sandbox boundary for generated artifacts.

Confidence: medium

Scope-risk: narrow

Directive: Do not broaden iframe sandbox permissions to fix gesture handling without a security review.

Tested: pnpm guard

Tested: pnpm --filter @open-design/web typecheck

Tested: pnpm --filter @open-design/web exec vitest run tests/components/FileViewer.test.tsx tests/runtime/srcdoc.test.ts

Tested: playwright-cli verified ctrl-wheel in preview keeps app zoom at 100% and prevents default in the iframe context

* Revert "Prevent preview wheel gestures from escaping into zoom"

This reverts commit 976407ab4c.

* Prevent imported Claude canvases from zooming on scroll

Claude Design exports can classify ordinary macOS two-finger vertical wheel events as mouse-wheel zoom clicks inside design-canvas.jsx. Normalize that imported canvas code so plain wheel input pans, while Cmd+wheel remains the explicit zoom gesture.

Constraint: The offending canvas code lives inside imported user artifacts rather than a tracked runtime component, so the fix belongs in the Claude Design zip import normalization path.\nRejected: Host-side wheel interception | wheel events inside the sandboxed iframe are handled by the artifact before the host can reliably classify them.\nRejected: Disable all wheel zoom | users still need Cmd+wheel as an explicit zoom control.\nConfidence: high\nScope-risk: narrow\nDirective: Keep plain wheel as pan-only for imported design-canvas.jsx unless a future bridge provides an explicit wheel-mode handshake.\nTested: pnpm --filter @open-design/daemon exec vitest run tests/claude-design-import.test.ts\nTested: pnpm --filter @open-design/daemon typecheck\nTested: pnpm guard

---------

Co-authored-by: nicejames <nicejames@gmail.com>
Co-authored-by: lefarcen <935902669@qq.com>
2026-05-15 16:37:57 +08:00
Ethan GuoandGitHub c18be3f130 fix(daemon): honor IP-literal OD_ALLOWED_ORIGINS in no-Origin Host check (#1775)
isLocalSameOrigin's no-Origin branch hard-coded the extras list to [],
so a POST from a configured LAN/Tailscale host (e.g. http://100.86.154.169:7456)
with no Origin header was rejected even though the operator allow-listed it.

Filter extraAllowedOrigins to entries whose hostname is an IP literal
(IPv4 dotted-quad or bracketed IPv6) and pass that subset to the
Host-only isAllowedBrowserHost call. Named-domain entries still require
an Origin header on guarded routes, preserving the existing CSRF invariant
(covered by the pre-existing 'rejects no-Origin POST when Host matches a
non-loopback configured origin domain' test).

Fixes #1337
References: This PR takes a different approach from #1343, which was identified as introducing a CSRF security regression (CHANGES_REQUESTED by reviewers lefarcen and Siri-Ray): simply passing extraAllowedOrigins to the Host-only branch allows no-Origin requests from configured deployment hosts to bypass CSRF guards. This fix addresses the root cause with a safer approach that preserves the Host-only/no-Origin rejection invariant while enabling correct OD_ALLOWED_ORIGINS behavior for requests that include an Origin header.
2026-05-15 16:14:57 +08:00
Chris SeifertandGitHub 9cf265e520 feat(claude): wire AskUserQuestion tool through chat + pin TodoWrite (#1743)
* feat(claude): wire AskUserQuestion tool through chat + pin TodoWrite

Claude calls `AskUserQuestion` for mid-conversation clarifications when
the natural answer is one of a small finite set of choices. Until now
the tool round trip hit two dead ends in headless mode: claude-code -p
cannot prompt the user, so it auto-errored the tool and retried 4x;
the model then hedged by also writing the same options as a markdown
bulleted list. The host had no way to feed a real `tool_result` back.

This change makes the AskUserQuestion round trip work end to end:

* Switch Claude to `--input-format stream-json`. The daemon wraps the
  prompt as a JSONL `user` message on stdin and keeps stdin OPEN, so
  later writes (a `tool_result` for the open AskUserQuestion) feed
  back into the same child instead of needing a fresh spawn.
* New `RuntimeAdapter.promptInputFormat()` ('text' default,
  'stream-json' for Claude) so the spawn loop keeps the old close-on-
  prompt behavior for every other agent.
* New `POST /api/runs/:id/tool-result` daemon endpoint and
  `submitChatRunToolResult` web helper. Body carries `toolUseId` and
  `content`; daemon writes a JSONL `user` message with the matching
  `tool_result` content block.
* Track outstanding host answers on the run (`pendingHostAnswers`)
  and close stdin on either a `usage` event or a synthesized
  `turn_end` event (extracted from `assistant.message.stop_reason`
  in `claude-stream`). Without the per-turn `turn_end` signal stdin
  would never close after the follow up turn finished and the run
  would hang until the inactivity watchdog killed it.
* System prompt: tell Claude to use AskUserQuestion for follow ups
  with 2-4 finite choices, and to STOP after the tool call instead
  of writing a markdown duplicate.

Web UI:

* New `AskUserQuestionCard` renders the tool input as labelled chip
  buttons (single or multi select) with a Submit button styled like
  the composer's Send. On submit the answer routes through
  `submitChatRunToolResult` (live tool_result path) and falls back
  to `onSubmitForm` (plain user message) only if the run has already
  terminated. Selected chips persist across page reloads by re
  parsing the stored `tool_result.content`.
* Hide markdown text that follows an AskUserQuestion in the same
  turn — defense in depth against the model emitting the duplicate.
* Collapse identical `AskUserQuestion` / `TodoWrite` retries inside
  any tool group to a single card. TodoWrite is a snapshot tool,
  so older calls are duplicates of state.
* Pinned TodoCard above the chat composer. The latest TodoWrite
  snapshot across the conversation renders once, expandable /
  collapsible header, count shows in-progress + completed (1/4),
  Done button dismisses when all tasks finish, soft fade gradient
  above so scrolling chat text dissolves into the panel instead of
  hard clipping under the card.
* Composer gains a top shadow that only appears when the pinned
  todo slot sits directly above it (dark mode strengthened).
* Accordion expand / collapse motion shared between TodoCard, the
  ToolGroupCard disclosure, and BashCard output via
  `grid-template-rows: 0fr -> 1fr` with `cubic-bezier(0.23, 1, 0.32, 1)`
  and asymmetric durations (200ms enter, 140ms exit) per Emil
  Kowalski's animation framework.
* Jump-to-latest button no longer unmounts on hide; slides up with
  scale 0.9 -> 1 + fade on show, slides down with scale + fade on
  hide. Always horizontally centered via `margin: 0 auto`.

i18n:

* `tool.askQuestion`, `tool.askQuestionSubmit`, `tool.askQuestionPending`,
  `tool.askQuestionAnswered`, `tool.todosExpand`, `tool.todosCollapse`,
  `tool.todosDone`, `tool.todosDismiss` added to all 18 locales.

Unblocker:

* Fix a pre-existing render loop in `ProjectView` when the user
  clicks "New conversation". `handleNewConversation` now navigates
  to the fresh conversation id synchronously after
  `setActiveConversationId` so the route-sync effect at L512 and
  the URL-sync effect at L851 do not ping pong (route mismatch
  triggered repeated reverts; React's nested-update guard fired).

* fix(claude): order turn_end after content blocks + cover chat switching

Two follow-up fixes to the AskUserQuestion + new-conversation work:

* `claude-stream.ts` emitted `turn_end` BEFORE iterating the assistant
  message's content blocks. When claude-code lacks
  `--include-partial-messages` (older builds), tool_use events surface
  only from that loop, so the daemon's stdin-close handler saw an
  empty `pendingHostAnswers` set and closed stdin before the
  AskUserQuestion tool_use was even registered. The result: the model
  retried, hit the same race, and gave up writing the questions in
  prose. Emit `turn_end` AFTER the content loop so tool_use ids land
  in `pendingHostAnswers` first.

* `server.ts` now ignores `turn_end` events with
  `stop_reason: 'tool_use'`. That stop reason means the model paused
  to wait for a tool execution (claude-code's internal tool runner
  for Bash / Edit / Read, or a host-answered tool like
  AskUserQuestion). Either way the conversation is still in flight —
  closing stdin there would kill the follow-up response. Only the
  natural turn-end stop reasons (`end_turn`, etc.) close stdin.

* `ProjectView.handleSelectConversation` now navigates to the picked
  conversation id synchronously, mirroring the fix already in
  handleNewConversation. The route-sync effect at L512 was reverting
  the active conversation on every switch, ping-ponging with the
  URL-sync effect at L851 until React's nested-update guard fired
  with "Maximum update depth exceeded". Same bug class as the
  pre-existing new-conversation render loop.

* docs(agents): capture AskUserQuestion runtime + chat UI conventions

Record the patterns this PR introduces so future contributors can find
them without spelunking server.ts:

* Agent runtime conventions — `RuntimeAgentDef.promptInputFormat`,
  `run.pendingHostAnswers` / `run.stdinOpen` lifecycle, `turn_end`
  ordering rule, `POST /api/runs/:id/tool-result` endpoint shape, the
  Claude only system prompt block that nudges AskUserQuestion, and the
  `suppressAskUserQuestionFallbackText` defense in depth.
* Chat UI conventions — URL-load vs srcDoc render mode dispatch with
  bridge disqualifiers, the dual iframe visibility swap pattern,
  `isOurIframe` plus the active-iframe re-check for signals that must
  only come from the visible iframe, pinned TodoCard via
  `PinnedTodoSlot`, count includes `in_progress`,
  `dedupeSnapshotToolRetries` for AskUserQuestion / TodoWrite stacks.
* i18n keys — 18 locale files, add the key to `types.ts` first.
* UI animation philosophy — `cubic-bezier(0.23, 1, 0.32, 1)` ease out,
  asymmetric 200/140ms enter/exit, accordion via `grid-template-rows`,
  no `transform: scale(0)`, keep mounted + toggle class for exit
  transitions instead of relying on React unmount.

* fix(claude): read promptInputFormat as field, close stdin on deferred answer

Two PR review follow-ups on the AskUserQuestion stream-json wiring.

* server.ts:4616 referenced `runtimeAdapter.promptInputFormat()` — but
  `runtimeAdapter` is not declared, imported, or assigned anywhere. The
  prior adapter abstraction was deleted in #1656; when the changes
  were folded back into the inline handler the format was moved onto
  `RuntimeAgentDef.promptInputFormat`, but this call site was missed.
  `server.ts` starts with `// @ts-nocheck` so typecheck never caught
  it — every chat run hit `ReferenceError: runtimeAdapter is not
  defined` the moment we wrote the prompt to a stdin-fed child, which
  is every agent with `promptViaStdin: true` (claude, codex, copilot,
  cursor-agent, gemini, opencode, pi, qoder). Read the format off the
  in-scope `def` and default missing values to `'text'`.

* `submitToolResultToRun` cleared the answered id from
  `pendingHostAnswers` but never closed stdin if a `turn_end` /
  `usage` event had already fired with the set non-empty (deferred
  by the event handler). The child then waited indefinitely for
  further input until the inactivity watchdog killed it, losing the
  model's follow-up response. Close stdin on the last-answer
  transition when stream-json stdin is still open.

Test: pin `promptInputFormat` for every `promptViaStdin: true` agent
so future regressions of the field-vs-method contract fail at
typecheck-adjacent test time instead of in production. The new test
asserts `typeof def.promptInputFormat` is a string (or undefined),
not a function — exactly the shape mistake the original line made.

* fix(web): keep AskUserQuestion multi-select chips selected after reload when labels contain commas

`handleSubmit` joined multi-select answers with `', '` while the
reload parser split them on `','`. The pair is asymmetric: a valid
model-generated option like `"Yes, including images"` round-tripped
as `["Yes", "including images"]`, so after a page reload the locked
question card showed the user's pick as unselected — even though the
`tool_result` content the daemon actually wrote into the run was
correct, and the model saw the right answer. Bounded to post-reload
visual state, but silently confusing.

Switch to a `- ` bullet list per option, one per line, with the
parser stripping the leading `- ` back off. Newlines never appear
inside a label so the round trip is exact. The outer pairs separator
stays `\n\n` because individual answer bodies still never contain
that double-newline.

* chore: drop accidental personal design-system file

`design-systems/foldar/DESIGN.md` was added to the AskUserQuestion
branch in 31ac531 by mistake — it's a personal brand spec that does
not belong in the upstream design-systems catalogue. Removing it
keeps the branch's surface area scoped to the feature.
2026-05-15 15:50:27 +08:00
Digital Cr8tiveandGitHub bceca66bb4 fix(daemon): raise ACP stage timeout to match outer chat inactivity watchdog (#1734)
* fix(daemon): raise ACP stage timeout to match outer chat inactivity watchdog

The ACP per-stage silence watchdog defaulted to 180_000ms (3 min), which
killed long generations mid-response (e.g. an agent silently writing a
large HTML artifact). The outer chat-run inactivity watchdog is already
600_000ms (10 min) precisely because 'agents [...] spend several minutes
silently writing large artifacts' (server.ts), so the inner ACP watchdog
was undercutting that design intent with no env override.

- Raise DEFAULT_STAGE_TIMEOUT_MS from 180_000 to 600_000.
- Add OD_ACP_STAGE_TIMEOUT_MS env override resolved in server.ts and
  threaded into attachAcpSession via stageTimeoutMs.
- Bound the override by MAX_CHAT_RUN_INACTIVITY_TIMEOUT_MS so oversized
  values do not get clamped to 1ms by Node's signed-32-bit delay limit.
- Add red-spec tests covering the new default and the override path.

* fix(daemon): treat OD_ACP_STAGE_TIMEOUT_MS<=0 as disable, not 0ms schedule

Addresses review feedback from @Siri-Ray on #1734.

The resolveAcpStageTimeoutMs() clamp passes 0 (or any negative value
normalized to 0) straight through to attachAcpSession, which would then
call setTimeout(..., 0) and fail every ACP session on the next tick.
The outer chat inactivity watchdog at server.ts already treats <=0 as a
disable escape hatch (see inactivityTimer); the inner stage watchdog now
mirrors that semantic.

- attachAcpSession now skips resetStageTimer scheduling when the
  effective stageTimeoutMs is <=0.
- Added regression test locking in the disable semantics: a 60-minute
  silent period under stageTimeoutMs=0 emits no error and does not kill
  the child.
2026-05-15 15:15:15 +08:00
c5d77a03bd Garnet hemisphere (#1769)
* feat(chat-composer): enhance mention handling and input overlay

- Introduced a new overlay for inline mentions in the chat composer, improving user experience by visually indicating mentions as users type.
- Updated the `ChatComposer` component to manage mention entities and integrate them into the input field, allowing for better context and interaction.
- Enhanced the `AssistantMessage` component to support the display of plugin action panels based on the current project context, facilitating easier plugin management.
- Refactored related components to ensure consistent handling of project files and mentions across the application.

This update significantly improves the chat interaction model, making it more intuitive for users to engage with mentions and plugins.

* feat(plugin-management): enhance plugin action panels and UI components

- Updated the `AssistantMessage` component to include plugin action panels based on the latest project context, improving user interaction with generated plugins.
- Refactored the `PluginsView` to support detailed views for available marketplace entries, allowing users to access more information and actions for each plugin.
- Introduced new CSS styles for improved visual representation of plugin-related UI elements, enhancing overall user experience.
- Enhanced the `listPlugins` function to include an option for fetching hidden plugins, providing more flexibility in plugin management.

This update significantly improves the usability and functionality of the plugin management system, making it easier for users to interact with and manage their plugins.

* fix(assistant-message): refine plugin folder candidate selection logic

- Updated the `pluginFoldersTouchedThisTurn` function to improve the logic for selecting plugin folder candidates based on touched paths and message content.
- Introduced a new helper function, `pathMatchesFolderFileBasename`, to enhance the matching criteria for folder candidates.
- Added a check for explicit folder matches before falling back to a single candidate, improving accuracy in folder selection.
- Modified the `shouldRenderSlotAsText` function in `HomeHero` to include the name parameter, refining the rendering logic for slot text.

These changes enhance the functionality and reliability of the assistant message component in managing plugin folder candidates.

* feat(plugin-folder-actions): implement agent-routed CLI actions for plugin management

- Introduced a new `PluginFolderAgentAction` type to streamline actions related to plugin folders, including install, publish, and contribute.
- Updated the `DesignFilesPanel`, `FileWorkspace`, and `AssistantMessage` components to utilize the new agent action handling, improving user interaction with generated plugins.
- Refactored the action handling logic to send commands to the agent, enhancing the workflow for managing plugin folders.
- Added corresponding tests to ensure the new functionality works as expected and integrates seamlessly with existing components.

This update significantly enhances the plugin management experience by routing actions through the agent, allowing for a more cohesive and interactive user experience.

* Fix PR 1702 CI blockers

* Fix PR 1702 remaining CI checks

* Prebuild AGUI adapter after install

* Restore plugin project snapshot wiring

* feat(marketplace): refactor marketplace URL handling and enhance fetching logic

- Introduced new functions to normalize marketplace URLs and manage fetching of marketplace manifests, improving the reliability of marketplace integrations.
- Updated the server and plugin logic to utilize the new fetching mechanisms, ensuring consistent handling of marketplace data.
- Enhanced tests to cover new URL normalization and fetching scenarios, ensuring robustness in marketplace management.

This update significantly improves the marketplace experience by streamlining URL handling and enhancing data fetching capabilities.

* Fix project auto-send cleanup spec

* Reconcile run messages on cancel

* Use active design system as visual direction

* Fix active design system prompt wording

* feat(workspace-tabs): implement workspace tabs functionality and file attachment handling

- Introduced a new `WorkspaceTabsBar` component to manage workspace tabs, allowing users to navigate between different views (projects, marketplace, etc.).
- Enhanced file handling capabilities in the `HomeHero` and `EntryShell` components, enabling users to stage and attach files before project creation.
- Updated the `App` component to support auto-sending attachments alongside the first message in a project.
- Improved CSS styles for workspace tabs and attachment UI, ensuring a cohesive design and user experience.

This update significantly enhances the workspace navigation and file management features, providing users with a more intuitive and efficient workflow.

* refactor(workspace-tabs): streamline workspace tabs and UI components

- Removed unused components and actions from the `WorkspaceTabsBar` and `AppChromeHeader`, simplifying the codebase.
- Updated CSS styles for the workspace shell and tabs, enhancing visual consistency and reducing element sizes for a cleaner layout.
- Introduced a new client type detection mechanism to dynamically adjust the workspace shell's class, improving responsiveness.
- Added tests for the `WorkspaceTabsBar` to ensure proper navigation and tab management functionality.

These changes improve the overall performance and user experience of the workspace navigation system.

* Update critical e2e for entry modal flow

* Stabilize entry critical e2e flows

* fix(ui): adjust workspace tabs and header styles for improved layout

- Updated the CSS for workspace tabs and the app header, reducing element sizes and padding for a cleaner appearance.
- Introduced a new button in the `WorkspaceTabsBar` for quick access to the home tab, enhancing navigation.
- Minor adjustments to the layout and styles to ensure consistency across components.

These changes enhance the user interface and improve the overall user experience in the workspace navigation system.

* feat(workspace-tabs): implement pinned home tab functionality

- Added a new pinned home tab feature to the `WorkspaceTabsBar`, allowing the home tab to remain accessible during navigation.
- Updated tab management logic to collapse duplicate home tabs into a single pinned instance when restoring from local storage.
- Enhanced CSS styles for workspace tabs to accommodate the new pinned tab design.
- Updated tests to verify the behavior of the pinned home tab and its interaction with other tabs.

These changes improve navigation consistency and user experience within the workspace.

* refactor(workspace-tabs): enhance tab management and styling

- Updated CSS styles for workspace tabs, adjusting padding and flex properties for improved layout and consistency.
- Refactored tab creation logic to ensure unique IDs for project and marketplace tabs, enhancing navigation clarity.
- Removed deprecated functions related to pinned home tabs, streamlining the codebase.
- Improved test cases to verify independent behavior of home tabs during navigation.

These changes enhance the user experience by providing a more intuitive tab management system and a cleaner UI.

* style(workspace-tabs): update CSS for improved layout and visibility

- Adjusted CSS properties for workspace tabs, including overflow, position, and z-index to enhance layout and stacking context.
- Ensured consistent styling across tab components for better visual hierarchy.

These changes contribute to a more polished and user-friendly interface within the workspace.

* style(entry-layout): update CSS variables for improved layout consistency

- Replaced fixed width values with CSS variables for the entry rail to enhance flexibility.
- Adjusted padding and height properties for better visual alignment and spacing.
- Introduced a new background style for the entry main topbar to improve aesthetics.

These changes contribute to a more responsive and visually appealing layout in the entry view.

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
Co-authored-by: Eli <129168833+qiongyu1999@users.noreply.github.com>
2026-05-15 14:42:11 +08:00
Quang DoandGitHub 3d0e708720 fix(daemon): treat media generate handoff as success (#1715) 2026-05-15 14:11:40 +08:00
ad275dbc02 fix(daemon): use danger-full-access codex sandbox on Windows to unblock PowerShell (#1745)
Codex CLI's workspace-write sandbox on Windows blocks every shell
invocation with 'powershell.exe ... rejected: blocked by policy', so
the agent cannot list files, navigate the workspace, or call any
shell-backed tool. Codex has no working OS-level sandbox on Windows
and falls back to a coarse policy that rejects shell unconditionally.

Switch to --sandbox danger-full-access on win32 only. macOS (Seatbelt)
and Linux (Landlock+seccomp) keep workspace-write because their sandbox
enforcement permits shell while restricting writes.

Tests anchor the workspace-write expectations explicitly to darwin and
linux via withPlatform(), and a new win32 case asserts the
danger-full-access flag and that the workspace-write-scoped network
config override is dropped.

Fixes #1721.

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-15 12:22:48 +08:00
PerishCode 545aed642e Merge remote-tracking branch 'origin/preview/0.8.0' into preview/v0.8.0
# Conflicts:
#	nix/package-daemon.nix
#	scripts/postinstall.mjs
2026-05-14 21:46:05 +08:00
Tom HuangandGitHub 76defffb93 Garnet hemisphere (#1702)
* feat(chat-composer): enhance mention handling and input overlay

- Introduced a new overlay for inline mentions in the chat composer, improving user experience by visually indicating mentions as users type.
- Updated the `ChatComposer` component to manage mention entities and integrate them into the input field, allowing for better context and interaction.
- Enhanced the `AssistantMessage` component to support the display of plugin action panels based on the current project context, facilitating easier plugin management.
- Refactored related components to ensure consistent handling of project files and mentions across the application.

This update significantly improves the chat interaction model, making it more intuitive for users to engage with mentions and plugins.

* feat(plugin-management): enhance plugin action panels and UI components

- Updated the `AssistantMessage` component to include plugin action panels based on the latest project context, improving user interaction with generated plugins.
- Refactored the `PluginsView` to support detailed views for available marketplace entries, allowing users to access more information and actions for each plugin.
- Introduced new CSS styles for improved visual representation of plugin-related UI elements, enhancing overall user experience.
- Enhanced the `listPlugins` function to include an option for fetching hidden plugins, providing more flexibility in plugin management.

This update significantly improves the usability and functionality of the plugin management system, making it easier for users to interact with and manage their plugins.

* fix(assistant-message): refine plugin folder candidate selection logic

- Updated the `pluginFoldersTouchedThisTurn` function to improve the logic for selecting plugin folder candidates based on touched paths and message content.
- Introduced a new helper function, `pathMatchesFolderFileBasename`, to enhance the matching criteria for folder candidates.
- Added a check for explicit folder matches before falling back to a single candidate, improving accuracy in folder selection.
- Modified the `shouldRenderSlotAsText` function in `HomeHero` to include the name parameter, refining the rendering logic for slot text.

These changes enhance the functionality and reliability of the assistant message component in managing plugin folder candidates.

* feat(plugin-folder-actions): implement agent-routed CLI actions for plugin management

- Introduced a new `PluginFolderAgentAction` type to streamline actions related to plugin folders, including install, publish, and contribute.
- Updated the `DesignFilesPanel`, `FileWorkspace`, and `AssistantMessage` components to utilize the new agent action handling, improving user interaction with generated plugins.
- Refactored the action handling logic to send commands to the agent, enhancing the workflow for managing plugin folders.
- Added corresponding tests to ensure the new functionality works as expected and integrates seamlessly with existing components.

This update significantly enhances the plugin management experience by routing actions through the agent, allowing for a more cohesive and interactive user experience.

* Fix PR 1702 CI blockers

* Fix PR 1702 remaining CI checks

* Prebuild AGUI adapter after install

* Restore plugin project snapshot wiring

* feat(marketplace): refactor marketplace URL handling and enhance fetching logic

- Introduced new functions to normalize marketplace URLs and manage fetching of marketplace manifests, improving the reliability of marketplace integrations.
- Updated the server and plugin logic to utilize the new fetching mechanisms, ensuring consistent handling of marketplace data.
- Enhanced tests to cover new URL normalization and fetching scenarios, ensuring robustness in marketplace management.

This update significantly improves the marketplace experience by streamlining URL handling and enhancing data fetching capabilities.

* Fix project auto-send cleanup spec
2026-05-14 21:12:50 +08:00
PerishCode 4f15c33595 Merge remote-tracking branch 'origin/preview/0.8.0' into preview/v0.8.0 2026-05-14 21:10:03 +08:00
98bc6d63e6 feat: Critique Theater wireup (activate the stack, M0 dark-launch by default) (#1338)
* feat(web): pure reducer for Critique Theater states (Phase 7.1)

Pure CritiqueState reducer driven by the contracts-level PanelEvent
(the same shape both the live SSE stream and the recorded transcript
emit), so a single reducer powers both the in-flight panel and the
rerun replay. Lifecycle covers run_started → running → (shipped /
degraded / interrupted / failed), with panelist_open / dim /
must_fix / close / round_end events building per-round
CritiquePanelistView entries as they arrive.

Defensive behaviour that surfaced while writing the spec tests:
- Terminal phases (shipped / degraded / interrupted / failed) are
  sticky against further lifecycle events for the same run, except
  for parser_warning which can land late and is recorded in a side
  channel without changing phase.
- A new run_started for a different runId at any time discards the
  prior state and reboots, so the UI can launch consecutive runs
  without an explicit reset action.
- Events whose runId does not match the active run return the same
  state reference, so React's useReducer doesn't re-render
  subscribers on stray traffic.
- Round bookkeeping keys by round number rather than "always last",
  so an out-of-order panelist_dim for round 1 arriving after a
  round 2 dim does not corrupt the round 2 bucket.

Test coverage: 18 cases covering each transition, the runId guard,
sticky-terminal behaviour, the out-of-order round invariant, and
the stable-identity guarantee. Sets up Phase 7.2 and 7.3 to wire
SSE + replay into the same reducer.

* feat(web): useCritiqueStream hook subscribes to SSE and feeds reducer (Phase 7.2)

createCritiqueEventsConnection is a pure connection manager that
mirrors apps/web/src/providers/project-events.ts: opens an
EventSource at /api/projects/:id/events, listens for every name in
CRITIQUE_SSE_EVENT_NAMES, decodes each frame back into a PanelEvent
(stripping the critique. prefix and merging the data payload), and
hands it to the caller's onEvent. Reconnect uses exponential
backoff (1s → 30s) and resets on `ready`; malformed payloads drop
with a dev-mode warning rather than tearing the stream.

useCritiqueStream wraps the manager in a useReducer that owns the
CritiqueState. enabled=false or a null projectId tears down the
connection cleanly; switching projectId closes the old connection
and opens a fresh one. The returned dispatch lets local UI
synthesise actions (e.g. an Esc keypress firing a synthetic
interrupted while a kill request is in flight); production traffic
comes from the SSE stream.

Test coverage:
- sse.test.ts (10 cases, node env): subscription set covers every
  CRITIQUE_SSE_EVENT_NAMES channel; payload decoding lifts the wire
  shape back to PanelEvent; malformed JSON is swallowed and does
  not stop the stream; exponential backoff schedule and ready-reset
  semantics are pinned with a setTimeout seam; close() cancels
  pending reconnects and shuts the live source; no-op fallback
  when EventSource is unavailable.
- useCritiqueStream.test.tsx (6 cases, jsdom env): idle pre-event,
  reducer driven by synthetic actions, no connection when disabled
  or projectId is null, clean close on unmount, projectId change
  reopens cleanly.

* feat(web): useCritiqueReplay hook drives reducer from transcript file (Phase 7.3)

Fetches the per-run NDJSON transcript (one PanelEvent per line),
parses every line via the shared isPanelEvent predicate, and
dispatches into the same CritiqueState reducer the live SSE stream
uses. A single reducer means the UI rendering a replay can be
identical to the live panel, and a UI mounting both
useCritiqueStream and useCritiqueReplay in parallel does not have
to reconcile two state shapes.

speed knob is `paused | instant | live | { intervalMs: N }`.
- instant flushes every event synchronously, useful for opening a
  finished run already at its terminal state.
- intervalMs paces dispatches at a fixed cadence so the reviewer
  can watch the run unfold.
- paused parses the transcript but holds events back until the
  caller advances speed (consumers can drive a scrubber later).
- live is reserved for the future "playback at original cadence"
  feature, currently treated as instant; replay timestamps are not
  yet persisted with each event so honest pacing requires a
  follow-up Phase 7+ task.

gunzip seam handles `.ndjson.gz` transcripts via
DecompressionStream when present; the production fetch path picks
between text and arrayBuffer based on the URL extension. Both seams
are injectable so the unit tests don't need to spin up a real
network or a real gzip pipeline.

Test coverage (8 cases, jsdom env):
- Idle status before any URL is provided.
- speed=instant flushes the full transcript synchronously to
  shipped state.
- speed={intervalMs:N} paces with the setTimeout seam, reaching
  done after the last tick.
- speed=paused leaves status=playing with no dispatches.
- Empty transcript reports done with state still idle.
- Fetch rejection surfaces an error status with the message.
- Malformed NDJSON lines are skipped; valid events around them
  still land.
- .gz transcripts route through the gunzip seam.

Closes the Phase 7 plan tasks 7.1 / 7.2 / 7.3 (reducer + stream +
replay), all on one branch ready for review. Phases 8+ (Theater
components) consume these from this PR.

* fix(web): close payload-override gap + paused-resume bug in Critique Theater hooks (Phase 7 review)

Two P1 fixes from lefarcen's review on PR #1307:

SSE payload override

`sseToPanelEvent` previously spread `data` after the channel-derived
`type`, so a payload-provided `type` could override the channel and
route a `critique.run_started` frame into the reducer as a `ship`
action. Reversed the spread so the channel-derived `type` is
authoritative, and revalidated the resulting object through the
contracts-level `isPanelEvent` predicate before returning. Frames
that fail validation (missing runId, empty runId, unknown type) are
dropped, so a malformed or compromised SSE frame can no longer
dispatch a wrong-shape action into the reducer.

Three new sse.test.ts cases pin the regression: hostile `type:'ship'`
in the payload still resolves to `run_started`, missing runId is
dropped, empty runId is dropped.

Replay pause/resume

`useCritiqueReplay` had one big effect keyed on `transcriptUrl`
only, so flipping `speed` from `paused` to `instant` never re-fired
and the held events sat undispatched. Split into a parse effect
(depends on URL, fetches and stores events in state) and a pace
effect (depends on parsed-events + speed, owns the cursor + timers).
The playback cursor lives in a ref that survives pause/resume
cycles, so flipping `paused` -> `instant` flushes from the current
position rather than restarting (which would double-dispatch
`run_started` and reset the reducer).

Two new useCritiqueReplay.test.tsx cases:
- paused-then-instant transitions from `playing` to `done` and
  reaches the shipped terminal phase
- intervalMs paced playback dispatches one event, pauses to drain
  the next scheduled timer, flips to instant, and confirms the
  remaining transcript drains exactly once (cursor was preserved)

Doc consistency

The earlier source comment in useCritiqueReplay.ts claimed `live`
"paces by recorded timestamps" while the impl used zero-delay
timers and the PR body said it behaves like `instant`. Aligned to
reality: `live` currently behaves like `{ intervalMs: 0 }` (events
drain on successive microtasks via setTimeoutFn) because transcripts
do not yet carry per-event timestamps. Honest timestamp-driven
pacing is queued as a Phase 7+ follow-up.

Validated: pnpm guard, pnpm --filter @open-design/web typecheck,
Theater suite 47/47 (up from 42, +3 sse + 2 replay), full web suite
96 files / 888 tests.

* feat(i18n): seed Critique Theater key block (en + zh-CN; other locales fall back via spread)

* feat(web): Theater PanelistLane component (Phase 8.1)

* feat(web): Theater ScoreTicker component (Phase 8.2)

* feat(web): Theater RoundDivider component (Phase 8.3)

* feat(web): Theater InterruptButton component with Escape keybind (Phase 8.4)

* feat(web): Theater TheaterDegraded chip (Phase 8.5)

* feat(web): Theater TheaterCollapsed post-run summary (Phase 8.6)

* feat(web): Theater TheaterTranscript replay surface (Phase 8.7)

* feat(web): Theater TheaterStage top-level container (Phase 8.8)

* feat(web): Theater CSS using existing semantic tokens (no hex literals)

* feat(web): Theater public exports barrel

* fix(web): resolve P2 + P3 review feedback on Phase 8 (PR #1314)

Addresses all 4 P2 + 3 P3 items from codex, Siri-Ray, and lefarcen.

State-lifecycle fixes (3 x P2)
1. Reducer learns a synthetic `__reset__` action (`CritiqueResetAction`).
   Host hooks dispatch it when their gating prop changes so a stale
   run from a prior project / transcript cannot bleed into the next
   context. Reset is idempotent on idle (returns the same reference).
2. `useCritiqueStream` dispatches `__reset__` at the top of its
   connection effect, so a workspace switch from project A (which
   streamed a critique) to project B clears the reducer before the
   new EventSource opens. enabled=false also clears.
3. `useCritiqueReplay` dispatches `__reset__` at the top of its
   parse effect, so transcriptUrl swaps (including swap-to-null after
   a replay reached `shipped`) lift the reducer back to idle before
   the new fetch starts.

SSE validation (1 x P2)
4. `sseToPanelEvent` now runs a per-variant `hasValidVariantShape`
   check after the cheap `isPanelEvent` predicate. A
   `critique.ship` frame missing `composite` / `round` / `status` /
   `artifactRef` is rejected before reaching the reducer, so
   TheaterCollapsed can no longer crash on `undefined.toFixed(1)`.
   Every variant's required fields are validated: run_started
   (protocolVersion, non-empty cast, maxRounds, threshold, scale),
   panelist_* (round, role, plus variant-specific shape), round_end
   (round, composite, mustFix, decision in {continue,ship}, reason),
   ship (round, composite, status, artifactRef.{projectId,artifactId},
   summary), degraded (reason, adapter), interrupted (bestRound,
   composite), failed (cause), parser_warning (kind, position).

Reducer correctness (1 x P2)
5. `panelist_open` now materializes the round + an empty panelist
   view (`{dims: [], mustFixes: []}`) so TheaterStage can highlight
   the in-progress lane the instant the tag opens. Before this, a
   stream that emitted only `panelist_open` after `run_started` left
   `rounds = []` and the UI rendered no current round until a later
   `panelist_dim` arrived.

Polish (3 x P3)
6. Brand role tint swaps from `var(--magenta, var(--accent))` to
   `var(--purple, var(--accent))`. `--purple` is actually defined
   across the design systems; `--magenta` is not, so Brand was
   silently falling through to `--accent` and looking identical to
   Designer.
7. New i18n key `critiqueTheater.interruptedSummary` for the
   interrupted-collapse copy ("Interrupted at round N, best
   composite X.X"). Previously the interrupted branch reused
   `shippedSummary` and the UI read "Shipped at round..." for a run
   that specifically did not ship. Native value in en + zh-CN; other
   locales fall back via `...en` spread.
8. `TheaterDegraded` heading id comes from `useId()` instead of a
   hardcoded `theater-degraded-heading`, so two chips rendered on
   the same page (chat history with multiple completed runs) keep
   their aria-labelledby references unambiguous.

Tests (15 new cases)
- reducer.test.ts (+5): __reset__ on running/terminal/idle, panelist_open materializes round, panelist_open does not stomp prior panelist data.
- sse.test.ts (+6): variant-level rejection for ship without required fields, degraded without adapter, run_started with empty cast, panelist_dim with non-numeric score, round_end with unknown decision, plus a positive fully-formed ship.
- useCritiqueStream.test.tsx (+2): state reset on projectId change, state reset on enabled flip false.
- useCritiqueReplay.test.tsx (+1): state reset on transcriptUrl swap to null after a replay reached shipped.
- TheaterCollapsed.test.tsx (text-pinning update): asserts the interrupted branch reads "Interrupted at round 1" + "best composite 7.9", and explicitly NOT "Shipped at round...".
- TheaterDegraded.test.tsx (+1): two chips on the same page get unique aria-labelledby ids that each resolve to an `<h3>`.

Validated
- pnpm guard clean
- pnpm --filter @open-design/web typecheck clean
- Theater suite: 13 files, 101 tests (was 86 on the first Phase 8 push, +15 new)
- tests/i18n/locales.test.ts 5 of 5 across 18 locales

* feat(web): CritiqueTheaterMount wires SSE + reducer into a single drop-in (Phase 9.1)

* feat(i18n): Critique Theater strings for de + ja + ko + zh-TW (Phase 9.2)

* fix(web): resolve P1 + P2 review feedback on Phase 9 (PR #1315)

Addresses every blocker from codex, Siri-Ray, and lefarcen. The
three state-lifecycle and SSE-validation issues they also flagged
inherit fixes from PR #1314's review pass that this branch now sits
on top of after rebase.

Real daemon kill on Interrupt (P1)
- CritiqueTheaterMount now POSTs to
  /api/projects/:id/critique/:runId/interrupt alongside the
  optimistic local dispatch. Before this fix, clicking Interrupt
  only flipped the React state to interrupted while the daemon job
  kept running. The fetch is best-effort: a 404 (endpoint not wired
  yet, lands in Phase 15) is swallowed with a dev-mode console.warn
  so the UI still moves to the collapsed badge.
- New fetchInterrupt test seam lets RTL assert on the URL / method
  and simulate the "daemon not ready yet" path. Two tests pin both:
  the happy URL proj-42/critique/run-abc/interrupt POSTs, and a
  rejected fetch still flips the UI.

interruptPending reset on new run (P2)
- A ref-backed effect compares the current runId against the last
  one we saw; when it changes, interruptPending is cleared. A user
  who interrupts run-1 and then triggers run-2 from the same mount
  now gets a fresh, enabled kill button instead of one stuck in
  "Interrupting…". Pinned by a new mount test.

Escape keybind scope (P2)
- InterruptButton now checks the keydown target. Escape inside an
  input, textarea, select, or contenteditable element is ignored
  (and any ancestor of those via closest() is treated the same
  way). Body-level focus still fires the keybind so the Theater
  area's affordance keeps working. Four new tests cover textarea,
  input, contenteditable, and the body-focus positive case.

userFacingName i18n key (P2)
- The spec at specs/current/critique-theater.md:6 mandates a single
  critiqueTheater.userFacingName key so the "Design Jury" label can
  be renamed without touching code. Phase 8 introduced
  critiqueTheater.title by mistake; renamed across types.ts, en.ts,
  zh-CN.ts, de.ts, ja.ts, ko.ts, zh-TW.ts, and the lone consumer
  TheaterStage.tsx. The locale alignment test stays green.

Validated
- pnpm guard clean
- pnpm --filter @open-design/web typecheck clean
- Theater suite: 14 files, 112 tests (was 101 before, +11 new for
  the Phase 9 review pass: 3 mount + 4 InterruptButton focus scope;
  the rest were already in #1314's review fix).
- tests/i18n/locales.test.ts 5 of 5 across 18 locales.

* feat(daemon): adapter-degraded registry with TTL (Phase 10.1)

In-memory registry recording adapters that produced malformed or
oversize transcripts so the orchestrator can skip them for a TTL
window (default 24h) instead of cycling through known-bad providers
on every run.

Records carry reason (malformed_block | oversize_block |
missing_artifact), source label, and expiresAt. The test-only
clock seam lets the suite advance time deterministically and prove
that an expired entry stops counting as degraded without anyone
calling clearDegraded.

7/7 vitest cases green.

* feat(daemon): synthetic good + bad adapter fixtures (Phase 10.2)

Two test-only adapters that read the existing v1 transcript
fixtures (happy-3-rounds and malformed-unbalanced) and replay them
as either a full string or a 512-byte chunked stream. The chunked
form is what the conformance harness uses to prove the parser
holds together when the transcript arrives in arbitrary network
slices, not as one buffered blob.

* feat(daemon): adapter conformance harness (Phase 10.3)

runAdapterConformance pulls a transcript through the same
parseCritiqueStream pipeline the orchestrator uses and classifies
the outcome as shipped, degraded, or failed. On a degraded
outcome it forwards the matched reason to the adapter-degraded
registry, so a single nightly conformance run is what populates
the skip list rather than the orchestrator learning each adapter
is broken at request time.

5/5 vitest cases green covering shipped, malformed degraded,
oversize degraded, no-ship failure, and the harness-thrown
failure path.

* test(e2e): Critique Theater Playwright suite (Phase 11)

Six tests, one viewport per visual case, deterministic SSE
fixtures stubbed via page.route(). Adds the suite to
test:ui:extended so the existing extended-UI lane picks it up.

Coverage:

  1. Happy path: a single mounted theater plays the full
     fixture (1 run_started, 5 panelists open / dim / must_fix /
     close, 1 round_end, 1 ship) and ends on the score badge.
  2. Interrupt mid-run: the panelist that is open at the time
     the interrupt button is clicked closes with an interrupted
     marker and the transcript freezes there.
  3. Visual regression at 375x720 mobile.
  4. Visual regression at 768x1024 tablet.
  5. Visual regression at 1280x800 desktop.
  6. A11y role tree: the theater region exposes a labelled
     landmark, each panelist lane is a group with an accessible
     name, the score is a status live region.

All SSE traffic is stubbed by page.route so the suite runs in CI
without a daemon. The toggle is seeded via localStorage by
bootAppWithCritiqueEnabled so the gate behaves as if Settings
flipped it on. typecheck clean; playwright --list reports 6.

* test(web): reducer p99 bench at 10k iterations (Phase 13.1)

Locks the documented 2ms budget for the Critique Theater reducer
on a representative SSE script (27 actions, one full happy run)
behind a regression gate. Asserts p99 stays under 4ms (2x the
documented budget) so CI runners with a noisy neighbour do not
flake while a real regression to 20ms or 200ms still trips.

The bench is a vitest case rather than a bare microbenchmark so
it runs in the same CI lane as every other web test and does not
need a parallel runner.

* test(web): critique surface coverage walker (Phase 13.2)

Walks the public critique surface (11 SSE event names, 5 panelist
roles, 6 lifecycle phases, 9 named i18n keys) and asserts each
named symbol appears in both the src corpus and the test corpus.
The walker is the gate that catches a rename in one half of the
codebase without a matching update in the other half: a future
PR that drops 'panelist_must_fix' from the reducer without also
removing its test reference fails this suite.

62 assertions, one per symbol per corpus.

* docs: Critique Theater user guide (Phase 14.1)

Seven sections aimed at end users (not contributors):

  1. What is Design Jury
  2. How it works (the five panelists, auto-converging rounds,
     the composite formula)
  3. Settings (the M1 toggle and what it does)
  4. Reading the score badge
  5. Replay surface
  6. Troubleshooting (degraded, interrupted, failed)
  7. FAQ

The composite formula is documented as
    designer * 0 + critic * 0.4 + brand * 0.2 + a11y * 0.2 + copy * 0.2
because anyone trying to reverse-engineer the score is going to
search for those weights and the docs are the place they should
land first.

* docs(daemon): critique module AGENTS map (Phase 14.2)

Daemon-side wayfinder for the apps/daemon/src/critique directory.
Tables every file, what owns what invariant, and the 'when you
change anything here' guide so a future contributor does not
have to reverse-engineer the rollout resolver before adding a
new SSE event.

* docs(web): Theater module AGENTS map (Phase 14.3)

Web-side mirror of the daemon AGENTS map. Same file table, same
invariants section, same change-impact guide, sized to the
Theater component package.

* feat(daemon): rollout flag resolver (Phase 15.1)

Single decision point every caller consults to know whether the
orchestrator should wire the critique pipeline for a given run.
Priority:

  1. Skill-level policy (required wins, opt-out wins inversely)
  2. Per-project override from the Settings toggle
  3. OD_CRITIQUE_ENABLED env override
  4. Rollout phase default
       M0 dark-launch      false
       M1 settings only    false (toggle is off until the user flips it)
       M2 per-skill        true if skill opted in
       M3 global default   true

OD_CRITIQUE_ROLLOUT_PHASE parser defaults to M0 on unknown input
so a fresh install never surprises a user with the feature on.

10/10 vitest cases green covering every cell of the matrix.

* feat(web): Settings toggle hook for Critique Theater (Phase 15.2)

React hook that reads critiqueTheaterEnabled from the existing
open-design:config localStorage blob and stays in sync via:

  - the platform storage event (cross-tab)
  - a open-design:critique-theater-toggle CustomEvent (same-tab)

Same-tab event is the one that fires when the Settings panel saves
in the current window: the toggle and every mounted theater update
without a page reload.

setCritiqueTheaterEnabled(next) is the imperative setter the Settings
panel calls. It preserves the rest of the stored config (mode, apiKey,
etc.) and dispatches the same-tab event after the localStorage write.

The web hook reflects what the user toggled; the daemon-side
isCritiqueEnabled is the final routing authority (project override,
env, rollout phase). When they disagree, the daemon wins for backend
gating and the web reflects the toggle state.

6/6 vitest cases green covering first read, stored read, same-tab
event flip, config preservation, corrupted JSON tolerance, and
cross-tab storage event.

* test(web): Phase 15 toggle hook failure-mode coverage (PR #1320)

lefarcen P2 on PR #1320 flagged that the PR body claimed safe
behavior for disabled localStorage, non-object JSON, and missing
CustomEvent shim, but the suite only covered corrupt JSON plus
happy-path storage events. Added four failure-mode tests so the
swallowed errors are not silently traded for a throw in a future
refactor:

1. Returns false on a stored JSON value that parses to an array
   (non-object). Catches a regression where the guard treats
   anything truthy as a config blob.
2. Returns false on a stored JSON value of literal 'null'.
   typeof null === 'object' in JS, so the guard has to check null
   explicitly; this test pins that check.
3. Returns false when localStorage.getItem throws (private mode /
   disabled storage / SecurityError). The hook must swallow and
   return false so the rest of the app keeps rendering.
4. setCritiqueTheaterEnabled still dispatches the same-tab
   CustomEvent when localStorage.setItem throws (quota exceeded /
   disabled storage). The dispatch path is the in-session
   broadcast that keeps every mounted hook coherent even when
   persistence is unavailable; verified by mounting two probes
   and asserting both flip after the setter is called with a
   throwing setItem.

10/10 vitest cases green (6 existing + 4 new).

* fix(web): honor CustomEvent payload in toggle hook listener (PR #1320)

Both Siri-Ray (blocking) and lefarcen (P2 new) caught the same
real bug in the failure-mode test I added in affcdd27: the test
asserts the in-session UI flips when localStorage.setItem throws,
but the CustomEvent listener was ignoring the event's typed
detail and just calling readToggle(). Under a throwing setItem
the localStorage value is stale (or absent), so the listener
would see the OLD value and the test would fail (or worse, the
production claim 'in-session event keeps mounts coherent' was
hollow).

Fixed the hook, not the test: the listener now reads
event.detail.enabled when it is a boolean, falling back to
readToggle() only for malformed events or for cross-tab storage
events (which do not carry a typed payload). The setter already
dispatched the detail; the listener just was not consuming it.

Test changes:

  - The existing 'setItem throws' test now asserts the right
    behavior for the right reason. Updated the inline comment to
    say the listener reads from detail, not localStorage.
  - New test 'falls back to readToggle when the CustomEvent
    carries no usable detail' pins the fallback path: a
    malformed dispatcher (no detail, or detail.enabled not a
    boolean) degrades cleanly instead of throwing or being
    silently ignored.

11 / 11 vitest cases green (10 prior + 1 new fallback).

* feat(daemon): route critique spawn-path eligibility through the rollout resolver

The wireup edit Phase 10 and Phase 15 carved out: today server.ts gates
the critique pipeline on critiqueCfg.enabled, which is just the
OD_CRITIQUE_ENABLED env var. After this commit it gates on
isCritiqueEnabled(...) from the Phase 15 resolver, so the full
priority matrix is live:

  1. Per-skill od.critique.policy veto (opt-out / required)
  2. Per-project override (M1 Settings toggle, written through the
     existing Phase 6 settings endpoint)
  3. OD_CRITIQUE_ENABLED env override (power-user lane / CI fixtures)
  4. OD_CRITIQUE_ROLLOUT_PHASE default
       M0 dark-launch      false
       M1 settings only    false
       M2 per-skill        only when skillPolicy === 'opt-in'
       M3 global default   true

Default behaviour on a fresh install is unchanged: the resolver
returns false at M0 without an env override or a project override,
so prod traffic falls through to the legacy single-pass path
exactly the way it did before.

Inputs threaded today: phase from OD_CRITIQUE_ROLLOUT_PHASE,
envOverride from OD_CRITIQUE_ENABLED. skillPolicy and projectOverride
are passed as null for the v1 cutover; the daemon-side handler that
round-trips critiqueTheaterEnabled on the project settings row and
the od.critique.policy frontmatter resolver land as the next two
commits in this branch.

The three call sites that used critiqueCfg.enabled (the brand-thread
guard, the skill-thread guard, the top-line critiqueShouldRun
compound) now read from a single locally-scoped critiqueEnabledForRun
boolean, so the eligibility check is computed exactly once per spawn
and the prompt composer + orchestrator stay in lockstep the way
the existing comment already promised.

Tests still green: daemon vitest 22 / 22 across rollout +
conformance + adapter-degraded. Daemon typecheck clean.

* feat(web): mount CritiqueTheaterMount in ProjectView

The web counterpart of the daemon wireup. ProjectView now renders
<CritiqueTheaterMount projectId={project.id} enabled={...} /> as a
sibling of <AppChromeHeader> inside the top-level <div className="app">.

The mount is the drop-in from the Phase 9 stack: it owns the SSE
subscription, the kill-request handshake, and the phase-aware swap
from the live <TheaterStage> to the collapsed badge once a run
settles. The mount returns null until the daemon emits a
critique.run_started for the active project, so the visual surface
is byte-for-byte unchanged for users who have not opted in.

Enabled wiring: useCritiqueTheaterEnabled() reads the M1 Settings
toggle from the existing open-design:config localStorage blob and
stays in sync with both the platform storage event (cross-tab) and
the same-tab open-design:critique-theater-toggle CustomEvent the
Phase 15 setter dispatches. The hook honors the event payload
directly so a private-mode browser that cannot persist the toggle
still updates the in-session UI correctly.

The daemon-side gate (isCritiqueEnabled in apps/daemon/src/server.ts)
remains the authority for whether a run is actually wired through
the critique pipeline. This hook only governs whether the web layer
renders the resulting SSE stream when the daemon emits one. The
two-layer gate is intentional: an integrator embedding the Theater
in a custom UI can flip the web visibility independent of the
daemon's routing decision, and a daemon-side env override flips
backend gating without touching the web's localStorage.

Tests still green: web Theater suite 181 / 181 across 16 files.
Web typecheck clean.

* feat(daemon): resolve od.critique.policy frontmatter at the spawn site

The next step in the wireup branch's ladder: replace the placeholder
`skillPolicy: null` with the actual value parsed from the active
skill's SKILL.md frontmatter.

Three small edits, one new field on a public type:

1. SkillInfo gains a `critiquePolicy: SkillCritiquePolicy` field
   carrying the parsed `od.critique.policy` token (required /
   opt-in / opt-out / null). The field is null when the skill has
   no opinion, which lets the lower-priority resolver tiers
   (projectOverride, envOverride, phase default) decide.

2. listSkills() populates the new field via a small
   `normalizeCritiquePolicy` helper that tolerates the YAML
   scalar's casing and trims whitespace. Unknown tokens collapse
   to null so a typo in SKILL.md cannot accidentally force the
   panel on or off; it just falls through. Derived example cards
   inherit the parent's policy.

3. server.ts captures `skill.critiquePolicy` into a hoisted
   `skillCritiquePolicy` variable inside the existing skill-load
   block, then threads it into the isCritiqueEnabled call as the
   skillPolicy input. The hoisting keeps the variable in scope at
   the resolver call site without restructuring the spawn handler.

After this commit, the priority matrix the rollout resolver was
designed for is live for its top tier. The previous commit wired
env + phase; this one wires skill. The projectOverride input
remains null pending the next commit that extends the Phase 6
settings endpoint.

Daemon vitest: 10 / 10 rollout cases pass against the new wiring.
Daemon typecheck: clean.

* feat(daemon): feed projectOverride into the rollout resolver from project metadata

Replaces the placeholder `projectOverride: null` in the spawn
handler with the actual value the Settings panel writes onto the
project's metadata blob: `critiqueTheaterEnabled?: boolean`.

The read is defensive at the boundary: the metadata object is
typed loosely (it round-trips through SQLite as a free-form JSON
blob), so the spawn handler narrows to `boolean` and falls
through to `null` for any other shape. A missing key, a malformed
value, or a project that has never visited Settings collapses to
`null`, which is exactly the resolver's "no opinion, fall
through to env / phase" signal.

The `critique` frontmatter slot also gets typed on the
SkillFrontmatter shape so the `od.critique.policy` chain the
previous commit introduced no longer needs a bracket-access
cast. Same pattern as the existing `craft`, `preview`, and
`design_system` nested-record slots.

After this commit, every tier of the rollout resolver's priority
matrix is wired:

  1. skillPolicy   (from SKILL.md od.critique.policy)
  2. projectOverride (from project metadata critiqueTheaterEnabled)
  3. envOverride   (from OD_CRITIQUE_ENABLED)
  4. rollout phase (from OD_CRITIQUE_ROLLOUT_PHASE)

The write path for projectOverride still flows through the
existing project-update handler the Settings panel already uses
to persist project metadata; no new endpoint is needed. The
Settings UI button that calls setCritiqueTheaterEnabled and
posts the new field is the next commit on this branch.

Daemon typecheck: clean. Daemon vitest: 10 / 10 rollout cases
still green against the new wiring.

* fix(daemon): forward critique events to project sinks + align composer gate (PR #1338)

Two codex review items addressed in one commit since they share the
same root cause (resolver-enabled run hits a transport / prompt
contract that was still env-gated):

P1 (transport mismatch). The daemon emits critique.* SSE frames
through critiqueBus -> design.runs.emit, which fans out on
/api/runs/:runId/events. The web CritiqueTheaterMount subscribes to
/api/projects/:projectId/events (it's project-scoped, not run-
scoped, because the mount lives at the project workspace and
follows the user across runs). Result: in production the mount
never sees a real frame and the e2e tests' stubbed routes hide the
mismatch.

Fixed by extending critiqueBus.emit to fan out to BOTH sinks: the
existing runs.emit transport, AND the per-project event-sinks map.
The project-events route emits via sse.send(payload.type, payload),
so we pack the SSE channel name onto payload.type and let the sink
push the right channel. The web sseToPanelEvent overwrites type
from the channel name on the way back into a PanelEvent, so the
round-trip stays correct.

P2 (prompt gate misalignment). composeSystemPrompt reads
cfg.enabled to decide whether to append the panel addendum, but
critiqueCfg.enabled is loaded from OD_CRITIQUE_ENABLED only. A run
the resolver enabled via phase / project / skill (env unset) would
have critiqueShouldRun = true while critiqueCfg.enabled remained
false, dropping the panel prompt while still routing through
runOrchestrator -> parser waits for tags that never arrive -> run
degrades.

Fixed by passing a derived config { ...critiqueCfg, enabled: true }
to the composer when critiqueShouldRun is true. The composer's own
gate now agrees with the resolver decision on every input the
spec defines.

Daemon typecheck: clean. Daemon vitest: 10 / 10 rollout cases
still green against the new wiring.

* fix: address PerishCode P1 + P2 follow-ups on PR #1338

Two follow-up items PerishCode flagged on the activation PR.
Non-blocking but both are real:

1. Phase 11 e2e suite was wired into test:ui:extended but lands
   the user on '/' (home route) where ProjectView (and therefore
   CritiqueTheaterMount) is never rendered. With the suite as
   written, every assertion would time out the first time the
   lane runs in CI, contradicting the PR body's claim that the
   suite stays parked behind test.describe.fixme.

   The state diverged from my earlier Phase 11 work because the
   merge from main on commit 4ab719c6 brought in #1307's
   squash-merged version of the e2e file (the pre-fixme shape).

   Re-applied test.describe.fixme to the describe block plus
   removed ui/critique-theater.test.ts from the test:ui:extended
   script in e2e/package.json. Added a file-header docblock
   explaining what the follow-up commit needs to do: replace
   goto('/') with /projects/:id navigation similar to
   app-design-files.test.ts, split the SSE fixture into a live
   prefix and terminal suffix (Codex P2 on PR #1320), and commit
   the first PNG baselines.

2. bestRoundOf in CritiqueTheaterMount returned the LAST round
   with a numeric composite, not the round with the HIGHEST
   composite, while bestCompositeOf correctly returned the max.
   A run that closed round 1 at 8.5 and round 2 at 6.0 would
   dispatch interrupted { bestRound: 2, composite: 8.5 } on a
   user-clicked interrupt.

   Folded the two helpers into a single bestRoundAndComposite
   that walks state.rounds once and returns the matching pair so
   the two values cannot drift. The onInterrupt callback now
   destructures from one helper instead of two independent reads.
   Falls back to (state.activeRound, 0) when no round has closed
   with a composite yet.

Web typecheck: clean. CritiqueTheaterMount.test.tsx: 7 / 7 cases
still green against the new helper.

* fix: wire M1 project override end-to-end + correct deferred-surface doc claims (PR #1338)

Three lefarcen P2s on the latest review pass, all real:

1. M1 project override was half-wired: the daemon read
   metadata.critiqueTheaterEnabled but the web setter only
   wrote localStorage. A user opt-in would render the Theater
   on the web (localStorage was set) while the daemon resolved
   projectOverride=null and skipped critique unless env / phase
   already permitted. Two halves talking past each other.

   Extended setCritiqueTheaterEnabled to accept an optional
   { projectId, fetchProjectSettings } options bag. When a
   projectId is supplied, the setter ALSO sends a
   PATCH /api/projects/:id with { metadata: { critiqueTheaterEnabled
   } } so the daemon's spawn-time resolver picks the same value up
   on the next generation. The existing project-routes endpoint
   already accepts arbitrary metadata patches, so no new endpoint
   is needed. The local write + the CustomEvent dispatch still
   fire before the PATCH, so a network failure does not unwind
   the in-session UI flip. Three new vitest cases pin the new
   path: PATCHes when projectId is provided, skips when it is
   not, swallows a rejected PATCH so the in-session UI still
   flips.

2. Rollout docs (docs/critique-theater.md section 3) claimed the
   Settings toggle persists into the daemon settings store, but
   the previous implementation only had a localStorage reader /
   writer plus a daemon read of project metadata, with no
   round-trip. Rewrote the section to lead with the four-tier
   resolver (skill policy / project override / env / phase),
   document that the setter now round-trips via the existing
   PATCH endpoint when given a projectId, and call out the
   Settings panel UI control as a deliberate follow-up.

3. Troubleshooting table pointed users at /api/metrics/critique
   (Phase 12, deferred) and 'od adapters clear-degraded <id>'
   (CLI wrapper that does not exist). Replaced the metrics
   reference with the local conformance harness command
   (pnpm --filter @open-design/daemon vitest run
   tests/critique-conformance.test.ts) that ships today, with a
   note that the Phase 12 dashboard surfaces this status as a
   series once that PR lands. Replaced the CLI command with the
   programmatic clearDegraded() helper that exists today and
   flagged the CLI wrapper as planned follow-up.

Web typecheck: clean. Toggle hook tests: 14 / 14 green (11
existing + 3 new for the round-trip path).

* test(web): multi-round interrupt regression for bestRoundAndComposite (PR #1338)

lefarcen P3 follow-up to the previous bestRoundAndComposite fix:
the existing CritiqueTheaterMount.test.tsx interrupt cases only
exercised a single-round state, so a future refactor back to two
independent helpers wouldn't be caught by the test suite even
though it'd reintroduce the round / composite drift bug.

Added a regression case that:

  1. Drives the reducer through two complete rounds with the
     full 5-role cast closing at distinct composites: round 1
     at 8.5, round 2 at 6.0 (the high-composite round is NOT the
     most recent one).
  2. Clicks Interrupt + waits for the daemon ack via the test
     seam fetcher returning 204.
  3. Asserts the collapsed badge displays "round 1" (the
     correct best-composite round), and queryByText for
     "round 2 ... 8.5" returns null (the buggy pairing
     would have produced that string).

The bestRoundAndComposite helper walks state.rounds in one pass
and returns the matching pair, so the round number and the
composite cannot drift apart. This test locks the fix in: a
refactor that splits the helpers back into independent walks
will be caught here.

8 / 8 vitest cases green on the file.

* fix(web): read-merge-write the project metadata in setCritiqueTheaterEnabled (PerishCode P2 on PR #1338)

The previous round-trip sent { metadata: { critiqueTheaterEnabled: next } }
as the entire PATCH body. The daemon's project-routes handler only
re-stamps three immutable fields (baseDir, importedFrom,
fromTrustedPicker) before calling updateProject(db, id, patch),
which then does a shallow { ...existing, ...patch } in apps/daemon/
src/db.ts. So patch.metadata replaces the row's metadata wholesale,
dropping kind, templateId, linkedDirs, and every other field the rest
of the app reads.

No in-tree caller passes projectId today (only vitest cases), so the
bug had not surfaced yet. But the surface is documented in
docs/critique-theater.md section 3 and the function's own JSDoc as
the M1 round-trip path, so it would have shipped as a latent footgun
for the next integrator: a Settings UI follow-up, or any third party
that wires the setter into a project-aware surface.

Fix: read-merge-write rather than a bare patch.

- GET /api/projects/:id to read the row's current metadata.
- Spread that metadata into the PATCH body and overlay
  critiqueTheaterEnabled: next on top, mirroring the partial-metadata
  pattern already used in ChatComposer.tsx for linkedDirs.
- PATCH the merged object.

Failure handling:
- GET fails: skip the PATCH entirely. We cannot construct a safe
  merged body without the current state, and a bare patch would
  wipe other metadata. The in-session CustomEvent fired earlier in
  the setter still keeps every mounted hook consistent; the next
  save retries the round-trip.
- PATCH fails: log in dev. The in-session UI is already correct via
  the CustomEvent.

Tests (TDD, red-first):

- 'GETs the project then PATCHes with merged metadata when a
  projectId is supplied': stubs a GET that returns
  { kind: 'template', templateId: 'modern-blog', linkedDirs: [...] }
  and asserts the PATCH body equals the merge plus the toggle.
- 'PATCHes with just the toggle when the project has no prior
  metadata': stubs a GET that returns no metadata block.
- 'skips the PATCH (does not stomp metadata) when the prefetch GET
  fails': stubs a rejecting GET and asserts only the GET fires.
- 'swallows a rejected PATCH after a successful prefetch': stubs a
  successful GET and a rejecting PATCH; asserts the in-session UI
  still flips via the CustomEvent.

Doc updated on the setter's JSDoc to describe the new three-step
flow (localStorage, CustomEvent, read-merge-write PATCH) and the
two failure modes.

Verified:
- pnpm --filter @open-design/web typecheck clean.
- pnpm --filter @open-design/web test: 111 files / 1055 tests green
  (was 1052, +3 from the new merge-flow cases).

* fix(web): restore wait-for-daemon-ack pattern on Theater interrupt

Same regression as flagged on PR #1316 post-main-merge: the
optimistic local dispatch fired before the POST resolved, so a
daemon 404 / 409 still terminalized the UI and the real SSE
terminal event got ignored by the sticky interrupted phase.

Snapshot runId / bestRound / composite at click time, dispatch
interrupted only on res.ok, clear interruptPending on rejection or
non-2xx so the user can retry. Tests cover rejection + 404 leaving
the run on the live stage; the 204 path waits for the ack.

* fix(test): add projectKind prop to FileViewer deck render after v0.7.0 merge

* fix(daemon): address PerishCode P3 trio on PR #1338 (emit helper reuse + spawn-input coverage + restored docs)

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-14 20:37:06 +08:00
PerishCode e0c76a09f2 Fix packaged beta build resources 2026-05-14 18:01:52 +08:00
lefarcen 640a332276 Merge remote-tracking branch 'origin/garnet-hemisphere' into reconcile/garnet-main-merge 2026-05-14 17:44:44 +08:00
lefarcen 3b7f87c7ae Merge remote-tracking branch 'origin/main' into reconcile/garnet-main-merge 2026-05-14 17:44:26 +08:00
pftom 8e3af79dea feat(github-installer): enhance GitHub content installation and error handling
- Introduced new interfaces for GitHub content entries and budgets to streamline content fetching.
- Enhanced the `installFromGithub` function to support installation from GitHub contents, including subpath handling.
- Implemented robust error handling and retry logic for fetching GitHub content, improving installation resilience.
- Updated tests to validate the new content fetching logic and ensure correct behavior across various scenarios.

This update significantly improves the GitHub installation process, making it more flexible and user-friendly.
2026-05-14 17:31:29 +08:00
PerishCode cba8bf151d chore: align namespace lifecycle packaging 2026-05-14 16:35:46 +08:00
lefarcen b268bbe169 Merge origin/garnet-hemisphere (post-9e196d34) — Use Plugin handoff fix
Brings in 11 new garnet commits, most importantly:
- 1a90aef4 feat(plugin-use): implement plugin use handoff functionality —
  fixes the bug QA reported where /plugins Use Plugin would 422 silently
  for template plugins; new flow hands off to HomeView with the plugin
  pre-bound + input form prompted there.
- 2ac58544 feat(plugin-inputs): enhance plugin input handling with file
  upload support — extends PluginInputsForm for file uploads.
- 3b167b69 feat(plugins): registry protocol — new @open-design/registry-protocol
  workspace package (needs build before daemon boot).
- Plus enhancements to plugin metadata, GitHub installer, plugin detail
  view, login/whoami, static HTML preview paths.

Conflicts resolved:
- packages/contracts/src/api/projects.ts: HEAD's skipDiscoveryBrief
  field + garnet's contextPlugins (@-mention plugin context refs) both
  kept on ProjectMetadata.
- apps/landing-page/* (3 files): accepted HEAD — garnet had the older
  single-page landing-page header; main has the multi-page layout
  (/skills/, /systems/, /templates/, /craft/) with dynamic counts. Not
  related to the Use Plugin core fix.

New @open-design/registry-protocol package must be built before daemon
boots; pnpm install does this via postinstall already.
2026-05-14 16:32:35 +08:00
pftom fbcf50382a feat(github-installer): enhance GitHub source parsing and error handling
- Updated the GitHub source regex to support more flexible parsing of repository references, including subpaths.
- Introduced a new `parseGithubSource` function to handle the extraction of owner, repo, and potential subpaths, improving the robustness of the installation process.
- Enhanced the `installFromGithub` function to retry fetching from multiple candidates and provide detailed error messages when installation fails.
- Added tests to validate the new parsing logic and ensure correct behavior when handling various GitHub source formats.

This update significantly improves the handling of GitHub sources, making the installation process more resilient and user-friendly.
2026-05-14 16:09:37 +08:00
pftom 9ea33e076b feat(context-plugins): add support for context plugins in project metadata and UI
- Introduced a new `contextPlugins` field in the `ProjectMetadata` type to accommodate plugins selected via `@` mentions, allowing for additive context in project creation.
- Updated the `HomeHero` and `EntryShell` components to handle and display context plugins, enhancing user interaction with selected plugins.
- Implemented rendering logic for context plugins in the metadata block, providing clear visibility of selected plugins and their descriptions.
- Enhanced the UI to support the removal of context plugins and display additional details on hover, improving the overall user experience.

This update significantly enriches the project creation process by allowing users to incorporate multiple context plugins seamlessly.
2026-05-14 15:29:49 +08:00
lefarcen 7011fb0e7f fix(daemon): remove duplicate inline proxy routes from server.ts
The reconcile merge accidentally left three garnet-side proxy routes
(/api/proxy/openai/stream, /api/proxy/azure/stream, /api/proxy/google/stream)
in server.ts, but the helper functions they relied on
(rejectPluginInProxyBody, extractGeminiBlockMessage, extractOpenAIText,
extractStreamErrorMessage, sendProxyError, streamUpstreamSse,
proxyErrorCode, appendVersionedApiPath, redactAuthTokens — 12 helpers
in total) were dropped during one of the hunk merges. The result is a
daemon that crashes on the first BYOK proxy request because every
handler immediately calls an undefined `rejectPluginInProxyBody`.

main already has the same proxy routes in chat-routes.ts (and adds an
ollama route on top), with all helpers properly defined and scoped.
Delete the inline duplicates from server.ts so chat-routes.ts is the
single authority. typecheck and BYOK code path now both pass.

Garnet's plugin-runs-must-go-through-daemon gate (the rejectPluginInProxyBody
check that returned 409 PLUGIN_REQUIRES_DAEMON when a plugin id was
smuggled into a proxy body) is intentionally not re-added here — it
belongs in chat-routes.ts as a small follow-up if plugin runs need to
be blocked from BYOK.
2026-05-14 15:29:09 +08:00
Fl0rencessandGitHub 53148d52c8 feat(media): add SenseAudio TTS provider (#1633)
* feat(media): add SenseAudio TTS provider

Add SenseAudio (https://docs.senseaudio.cn) as a new TTS provider
alongside ElevenLabs / MiniMax / FishAudio / Volcengine. Surfaced as
the `senseaudio-tts` catalogue id, mapped on the wire to
`senseaudio-tts-1.5-260319` — SenseAudio's flagship model with
emotion / 多音字 / 公式朗读 / clone / text-generated voice support.

Scope here is HTTP non-streaming (POST /v1/t2a_v2 with stream=false)
only; SSE and WebSocket transports are intentionally out of scope.

- Mirror provider + model entries in apps/daemon and apps/web
  registries (catalogue drift check stays green).
- ENV_KEYS gets `OD_SENSEAUDIO_API_KEY` / `SENSEAUDIO_API_KEY` so the
  alias scheme matches every other integrated provider.
- `renderSenseAudioTTS` in media.ts mirrors renderMinimaxTTS: Bearer
  auth, voice_setting / audio_setting body, hex-decoded audio under
  `data.audio`, base_resp envelope split from HTTP-level failures.
- NewProjectPanel's audio supportedProviders allowlist now includes
  `senseaudio` so the picker actually surfaces the new entry.
- Audio shape (mp3 / 32kHz / 128kbps / stereo) and default voice
  (`female_0033_b`) hard-coded for parity with the other TTS paths;
  MediaContext is unchanged.
- New apps/daemon/tests/media-senseaudio.test.ts (8 specs) covers
  defaults, custom voice, default base URL fall-back, env-key path,
  missing-key error, base_resp failures, missing audio, and HTTP
  non-2xx — patterned on media-elevenlabs.test.ts.

* docs(media): drop Chinese from SenseAudio provider comment

Translate the model-capabilities line in the SenseAudio block comment
(media.ts) into English. Keeping the source comments in a single
language matches the rest of the daemon and avoids reviewer churn over
mixed-locale prose.

* fix(web): unblock openai and volcengine speech models in audio picker

Per review on #1633, supportedModels()'s audio allowlist in
NewProjectPanel was still filtering out gpt-4o-mini-tts (openai) and
doubao-tts (volcengine) even though both are marked `integrated: true`
in the shared media-models catalogue. Add the two ids so the picker
matches the registry and the PR body's "alongside doubao-tts" claim
holds true.

* style(media): normalize speech hints to bare provider names

Strip the trailing descriptions on the speech catalogue hints so every
entry shows just the provider name (matching FishAudio / ElevenLabs /
SenseAudio): `gpt-4o-mini-tts` → "OpenAI", `minimax-tts` → "MiniMax",
`doubao-tts` → "Volcengine". Also move `gpt-4o-mini-tts` to the end of
the list so the OpenAI entry sits after the upstream-focused providers,
matching the recent picker grouping discussion on #1633.

Mirrored in both apps/daemon/src/media-models.ts and apps/web/src/media/
models.ts; catalogue drift check + daemon (1848) + web (1150) suites all
green.
2026-05-14 15:26:38 +08:00
netteeandGitHub 6b3cc61714 Revert "Refactor agent runtime stream handling behind adapter (#1622)" (#1656)
This reverts commit 8cb9cdb593.
2026-05-14 15:23:19 +08:00
lefarcen 6c16283850 Merge origin/main (post-7c8305f4) into reconcile branch
Brings in 10 new main commits: routine deep-link to specific
conversations (#1508), Windows resource cache fix for Orbit templates,
collapsible comment side panel (#1607), routines project radio polish,
Copilot logo swap, and minor UI fixes.

Conflicts resolved:
- router.ts: garnet's home/view + marketplace routes + main's
  per-project conversationId deep-link field coexist on Route union
- ProjectView.tsx: garnet's isPhantomDaemonRunMessage helper +
  main's isStoppableAssistantMessage helper both kept
- ProjectView.run-cleanup.test.tsx: accepted HEAD (garnet's
  phantom-row regression test); main's three new tests for
  finalizeActiveAssistantMessagesOnStop / clearStreamingConversationMarker
  / shouldClearActiveRunRefs are queued as a follow-up TODO inline.
2026-05-14 15:13:38 +08:00
d5566d7627 feat(daemon): user-configurable model alias for the media dispatcher (#1277) (#1309)
* feat(daemon): user-configurable model alias / redirect for the media dispatcher (#1277)

Tilmirs's use case in #1277: their Doubao access has moved from
`doubao-seedream-3-0-t2i-250415` to `doubao-seedream-5-0`, but the
project's registered catalog still emits the old id. Every call
fails because the old name no longer resolves at Volcengine.
Until now the only workaround was patching the source on every
update.

This adds a user-configurable alias layer that swaps the catalog
id for whatever wire-name the provider expects, without changing
the catalog itself. Two storage layers (env wins over disk,
matching the rest of media-config):

1. **Environment variable** `OD_MEDIA_MODEL_ALIASES` carries a
   JSON map: `'{"doubao-seedream-3-0-t2i-250415":"doubao-seedream-5-0"}'`.
   Single var, portable across shells (Windows cmd.exe rejects
   hyphens in env-var names, so the per-id pattern lefarcen
   suggested wouldn't have worked on Windows). Malformed JSON is
   tolerated — falls through to the on-disk map rather than
   blowing up mid-generation.

2. **media-config.json** gains a top-level `aliases` field:
   ```json
   {
     "providers": { ... },
     "aliases": {
       "doubao-seedream-3-0-t2i-250415": "doubao-seedream-5-0"
     }
   }
   ```
   The Settings UI's existing PUT writes providers only, so the
   writeStored path now reads the existing aliases and preserves
   them on every write. Without that, a Settings save would
   silently wipe the user's aliases. The Settings UI surface for
   editing aliases is a separate follow-up; manual JSON edit and
   the env var are the v1 entry points.

The resolution happens inside `startMediaGeneration` after the
catalog lookup and surface validation have already accepted the
registered id, so users still get the "unknown model" error if
they request a catalog id that doesn't exist. The swap only
changes what the provider receives on the wire (volcengine,
openai, grok, fal, nanobanana etc. each pass `ctx.model`
straight into their request body).

Per-provider auto-output-name and the file-naming side use the
function-level `model` parameter (the catalog id), so a `.png`
named after `doubao-seedream-3-0-t2i-250415` keeps surfacing the
registered id the agent / CLI asked for, not the wire-level
alias. `providerNote` strings include the wire name so the user
can see what was actually sent.

Public API additions:
- `resolveModelAlias(projectRoot, modelId)` -> the wire name (or
  the original if no alias matches).
- `readAliasMap(projectRoot)` -> { effective, env, stored } for
  the future Settings UI's source-attribution display.

Tests
- 8 new cases in tests/media-config.test.ts (suite goes 14 -> 22):
  pass-through, stored map, env map, env-over-stored precedence,
  malformed-env fall-through, coercion of bad entries (null /
  number / nested object / empty string / blank key), readAliasMap
  source attribution, and a writeConfig regression that pins
  alias preservation on a Settings-style provider PUT.

Validated
- pnpm guard clean
- pnpm --filter @open-design/daemon typecheck clean (both
  tsconfig.json and tsconfig.tests.json)
- Media test suite (media-config + media-tasks-routes +
  media-tasks-persistence + media-nanobanana): 33/33

Pre-existing daemon test failures on Windows (symlinks, CODEX_BIN
runtime resolution, MCP config, skills, server-paths) are
unrelated to this change and reproduce on a clean main checkout.

* fix(daemon): preserve catalog id for capability branches, surface aliases via /api/media/config (PR #1309 review)

Lefarcen + codex P2 on PR #1309: the alias swap overwrote
`ctx.model` globally, which silently disabled every renderer
branch that keys behaviour off the catalog id. A user aliasing
`dall-e-3 -> azure-dalle3-deployment` would have the wire name
swapped correctly but `body.response_format = 'b64_json'` and
`body.quality = 'hd'` would no longer be set, because the
`ctx.model.startsWith('dall-e-')` / `ctx.model === 'dall-e-3'`
checks now saw the alias. The same regression hit the
gpt-image-* size selection, the gpt-4o-mini-tts instructions
branch, and the openaiSizeFor() sizing function.

MediaContext now carries both fields:
- `model` — the registered catalog id (`dall-e-3`,
  `gpt-4o-mini-tts`, `doubao-seedream-3-0-t2i-250415`). All
  model-family capability branches read from here.
- `wireModel` — the post-alias wire name. Every `body.model = `,
  every URL template, and every `providerNote` string reads from
  here so the user sees what was actually sent and the provider
  gets the alias.

Renderers updated: openai image (body.model + providerNote +
openaiSizeFor keeps catalog), openai speech (body.model +
providerNote + gpt-4o-mini-tts instructions keeps catalog),
volcengine video (body + note), volcengine image (body + note +
openaiSizeFor keeps catalog), grok image (body + note), grok video
(body + note), nanobanana (`credentials.model || ctx.wireModel ||
default` chain), minimax TTS, fishaudio TTS. The MINIMAX/FISHAUDIO
hardcoded maps now sit BEHIND the user alias: explicit user alias
wins over the project's legacy rebranding table, then the table
wins over the catalog id fallback. Stub-fallback diagnostics (the
SVG placeholder + stub providerNote string) keep the catalog id
since those are debug surfaces, not provider calls.

Lefarcen P3: the PR description claimed readAliasMap was the
daemon-public API, but the /api/media/config route returned only
readMaskedConfig (which had no aliases field). readMaskedConfig
now returns `{ providers, aliases: { effective, env, stored } }`
so the future Settings UI PR can consume the source-attributed
map directly. The `aliases` field is always present (empty maps
when nothing is configured) so the UI has a stable shape to read.

Tests
- New `media-alias-capability.test.ts` (2 jsdom cases) drives
  generateMedia end-to-end with a stubbed fetch and asserts on
  the request body. Pins the regression: aliased dall-e-3 still
  sends `response_format: 'b64_json'` + `quality: 'hd'`; aliased
  gpt-4o-mini-tts still attaches the instructions field from the
  voice prop.
- `media-config.test.ts` grows by 2 cases (suite goes 22 -> 24):
  readMaskedConfig surfaces the alias map (both env and stored
  sources), and the empty-state shape for fresh installs.

Validated
- pnpm guard clean
- pnpm --filter @open-design/daemon typecheck clean (both
  tsconfig.json and tsconfig.tests.json)
- Media test suite (config + alias-capability + nanobanana +
  tasks-persistence + tasks-routes): 37/37

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-14 14:58:39 +08:00
shangxinyu1andGitHub 2976c76fc3 test: expand Memory and Routines coverage (#1521)
* test: expand settings and packaged coverage

* test: extend memory settings coverage

* test: cover routine settings failure states

* test: cover routine operation failures

* test: fix daemon test typing on CI

* test: decouple packaged smoke from orbit bug

* test: avoid live memory LLM calls in route tests

* test: fix daemon fetch typing in CI

* fix: restore preview comment and inspect toggles

* test: align manual edit flow with current inspector UX

* test: align comment attachment flow with current preview comments UI

* fix: probe resolved Codex launch path during detection

* fix: remove duplicate board activation helper after rebase

* test: update ghost cli detection mock

* test: align FileViewer toolbar expectation

* ci: move full app tests to extended lane

* ci: run app tests by changed scope

* ci: cover shared app inputs in test scopes

* ci: avoid setup-node cache in windows packaged smoke

* test: align extended settings and manual edit flows
2026-05-14 14:48:40 +08:00
Siri-RayandGitHub d2738924fb fix(web): freeze completed run durations across conversations (#1351)
* fix(web): freeze completed run durations across conversations

* fix(web): finalize stopped API runs

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix(daemon): optimize conversation latest run lookup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix(web): scope streaming cleanup to conversation

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix(web): capture streaming conversation cleanup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)

* fix(web): guard stale run ref cleanup

Generated-By: looper 0.6.0 (runner=fixer, agent=codex)
2026-05-14 14:25:37 +08:00
Marc ChanandGitHub 055e55abd8 Add batch design system testing (#1515)
* feat: add batch design system testing

* fix: use daemon default agent for batch tests

* fix: honor batch project prompt flags

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix: persist batch run output

* fix: honor dry-run before daemon resolution

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix: persist batch assistant run ids

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* fix: cancel timed-out batch runs

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)
2026-05-14 14:19:32 +08:00
e57e028222 feat(daemon): make design-system token channel default-on (PR-D) (#1544)
* feat(daemon): make design-system token channel default-on (PR-D)

Flip `OD_DESIGN_TOKEN_CHANNEL` from default-off to default-on. Every
chat that picks a brand with `tokens.css` + `components.html` siblings
(today: `default`, `kami`) now gets the structured token contract
appended to the system prompt automatically. `OD_DESIGN_TOKEN_CHANNEL=0`
keeps the DESIGN.md-only path as a kill switch.

Adds `scripts/check-design-system-flag-parity.ts`, registered in
`pnpm guard`. The guard walks every brand and asserts:

- 147 prose-only brands produce byte-identical prompts under flag-off
  vs flag-on (PR-D's "no-op for legacy brands" promise)
- 2 structured brands diverge as expected (catches a future regression
  that silently dropped the structured blocks)

Smoke evidence on #1385 (PR-C):
- `default` — 10/10 brand tokens used byte-for-byte in treatment vs
  0/10 invented colors in control
- `kami` — treatment recovers brand name (`Kami · 纸`), the two-tier
  surface (`--bg` parchment + `--surface` ivory), the CN font stack
  override, and the `components.html` card pattern; control invented
  "Replica" as a brand name

Co-authored-by: Cursor <cursoragent@cursor.com>

* review: address @nettee + @lefarcen feedback on parity guard

Two blocking findings from #1544 review:

1. @nettee — guard's inventory walk silently passed on unreadable
   filesystem state. `fileExists` swallowed every `stat` error and the
   bare `readdir` catch returned `[]` for any failure. A renamed
   `design-systems/` tree, a permission-denied DESIGN.md, or a
   directory at the brand path would have left `pnpm guard` happy
   after checking 0 brands — exactly the silent misconfiguration this
   guard exists to catch. Both error paths now treat only ENOENT /
   ENOTDIR as absence and rethrow everything else, mirroring the
   `readFileOptional` fix already applied to PR-C's
   `apps/daemon/src/design-systems.ts`.

2. @nettee — guard exercised `composeSystemPrompt` directly, bypassing
   the `process.env.OD_DESIGN_TOKEN_CHANNEL !== '0'` gate in server.ts
   that PR-D actually flipped. A regression that restored `=== '1'`,
   typo'd the env name, or stopped reading assets when the var is
   unset would still leave the guard green. Extracted the predicate
   into `isDesignTokenChannelEnabled(env)` next to
   `readDesignSystemAssets` and added 6 unit tests pinning every value
   that matters: unset / `'1'` / `'true'` / empty / `'0'` /
   whitespace-padded. server.ts now calls the predicate. Any
   regression on the env-flag semantics fails
   `tests/design-system-assets.test.ts` independently of the
   composer-level coverage.

Verified: pnpm guard (13/13), tsc -p scripts/tsconfig.json (clean),
@open-design/daemon typecheck (clean), 32/32 prompt + asset tests.

Co-authored-by: Cursor <cursoragent@cursor.com>

* review: pin server-layer asset resolution end-to-end (lefarcen P2)

Round-2 review feedback from @lefarcen on #1544: the predicate suite
in tests/design-system-assets.test.ts pinned the env-flag boolean but
did NOT exercise the server prompt-assembly path that PR-D actually
flipped — the seam where the daemon decides whether to read tokens.css
/ components.html from disk and hand them to composeSystemPrompt. A
regression that, say, restored an inline `=== '1'` gate or stopped
calling isDesignTokenChannelEnabled() from server.ts would still leave
the predicate test green.

Extracted that whole seam into `resolveDesignSystemAssets(id,
builtInRoot, userInstalledRoot, env)` on apps/daemon/src/design-systems.ts.
The function combines:
  1. the env-flag gate (kill switch on `OD_DESIGN_TOKEN_CHANNEL=0`)
  2. the built-in → user-installed root fallback chain (per-file)
  3. the DesignSystemAssets result shape consumed by composeSystemPrompt

server.ts at the prompt-assembly site is now a thin caller of this
function. The previous 13-line inline block (env check + per-file
fallback) collapses to one call, so the whole asset-resolution path
now has a single testable seam.

7 new tests in tests/design-system-assets.test.ts run the full pipeline
end-to-end against real disk fixtures:
  - env unset (default-on): returns built-in assets
  - env=`'0'` (kill switch): returns undefined even with files on disk
  - env=`'1'` (legacy opt-in): still works
  - mixed builtin/user-installed: per-file fallback merges correctly
  - both halves built-in: skips user-installed roundtrip verbatim
  - prose-only brand (no files): undefined / undefined
  - nonexistent brand directory: undefined / undefined

Verified: pnpm guard (13/13), tsc -p scripts/tsconfig.json (clean),
@open-design/daemon typecheck (clean), 39/39 prompt + asset tests
(was 32; +7 new server-layer-resolution tests).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(test): add missing projectKind to FileViewer deck preview test

The deck preview test added in #1556 (086be271) renders <FileViewer/>
without `projectKind`, which became a required prop in #1509. CI on
main is currently red on this; pick up the trivial fix here so PR-D
can land cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 14:14:19 +08:00
netteeandGitHub 8cb9cdb593 Refactor agent runtime stream handling behind adapter (#1622) 2026-05-14 14:12:24 +08:00
ff569fa50c feat(daemon): Critique Theater Phase 16 (M-phase rollout ratchet + /api/critique/conformance) (#1499)
* feat(web): pure reducer for Critique Theater states (Phase 7.1)

Pure CritiqueState reducer driven by the contracts-level PanelEvent
(the same shape both the live SSE stream and the recorded transcript
emit), so a single reducer powers both the in-flight panel and the
rerun replay. Lifecycle covers run_started → running → (shipped /
degraded / interrupted / failed), with panelist_open / dim /
must_fix / close / round_end events building per-round
CritiquePanelistView entries as they arrive.

Defensive behaviour that surfaced while writing the spec tests:
- Terminal phases (shipped / degraded / interrupted / failed) are
  sticky against further lifecycle events for the same run, except
  for parser_warning which can land late and is recorded in a side
  channel without changing phase.
- A new run_started for a different runId at any time discards the
  prior state and reboots, so the UI can launch consecutive runs
  without an explicit reset action.
- Events whose runId does not match the active run return the same
  state reference, so React's useReducer doesn't re-render
  subscribers on stray traffic.
- Round bookkeeping keys by round number rather than "always last",
  so an out-of-order panelist_dim for round 1 arriving after a
  round 2 dim does not corrupt the round 2 bucket.

Test coverage: 18 cases covering each transition, the runId guard,
sticky-terminal behaviour, the out-of-order round invariant, and
the stable-identity guarantee. Sets up Phase 7.2 and 7.3 to wire
SSE + replay into the same reducer.

* feat(web): useCritiqueStream hook subscribes to SSE and feeds reducer (Phase 7.2)

createCritiqueEventsConnection is a pure connection manager that
mirrors apps/web/src/providers/project-events.ts: opens an
EventSource at /api/projects/:id/events, listens for every name in
CRITIQUE_SSE_EVENT_NAMES, decodes each frame back into a PanelEvent
(stripping the critique. prefix and merging the data payload), and
hands it to the caller's onEvent. Reconnect uses exponential
backoff (1s → 30s) and resets on `ready`; malformed payloads drop
with a dev-mode warning rather than tearing the stream.

useCritiqueStream wraps the manager in a useReducer that owns the
CritiqueState. enabled=false or a null projectId tears down the
connection cleanly; switching projectId closes the old connection
and opens a fresh one. The returned dispatch lets local UI
synthesise actions (e.g. an Esc keypress firing a synthetic
interrupted while a kill request is in flight); production traffic
comes from the SSE stream.

Test coverage:
- sse.test.ts (10 cases, node env): subscription set covers every
  CRITIQUE_SSE_EVENT_NAMES channel; payload decoding lifts the wire
  shape back to PanelEvent; malformed JSON is swallowed and does
  not stop the stream; exponential backoff schedule and ready-reset
  semantics are pinned with a setTimeout seam; close() cancels
  pending reconnects and shuts the live source; no-op fallback
  when EventSource is unavailable.
- useCritiqueStream.test.tsx (6 cases, jsdom env): idle pre-event,
  reducer driven by synthetic actions, no connection when disabled
  or projectId is null, clean close on unmount, projectId change
  reopens cleanly.

* feat(web): useCritiqueReplay hook drives reducer from transcript file (Phase 7.3)

Fetches the per-run NDJSON transcript (one PanelEvent per line),
parses every line via the shared isPanelEvent predicate, and
dispatches into the same CritiqueState reducer the live SSE stream
uses. A single reducer means the UI rendering a replay can be
identical to the live panel, and a UI mounting both
useCritiqueStream and useCritiqueReplay in parallel does not have
to reconcile two state shapes.

speed knob is `paused | instant | live | { intervalMs: N }`.
- instant flushes every event synchronously, useful for opening a
  finished run already at its terminal state.
- intervalMs paces dispatches at a fixed cadence so the reviewer
  can watch the run unfold.
- paused parses the transcript but holds events back until the
  caller advances speed (consumers can drive a scrubber later).
- live is reserved for the future "playback at original cadence"
  feature, currently treated as instant; replay timestamps are not
  yet persisted with each event so honest pacing requires a
  follow-up Phase 7+ task.

gunzip seam handles `.ndjson.gz` transcripts via
DecompressionStream when present; the production fetch path picks
between text and arrayBuffer based on the URL extension. Both seams
are injectable so the unit tests don't need to spin up a real
network or a real gzip pipeline.

Test coverage (8 cases, jsdom env):
- Idle status before any URL is provided.
- speed=instant flushes the full transcript synchronously to
  shipped state.
- speed={intervalMs:N} paces with the setTimeout seam, reaching
  done after the last tick.
- speed=paused leaves status=playing with no dispatches.
- Empty transcript reports done with state still idle.
- Fetch rejection surfaces an error status with the message.
- Malformed NDJSON lines are skipped; valid events around them
  still land.
- .gz transcripts route through the gunzip seam.

Closes the Phase 7 plan tasks 7.1 / 7.2 / 7.3 (reducer + stream +
replay), all on one branch ready for review. Phases 8+ (Theater
components) consume these from this PR.

* fix(web): close payload-override gap + paused-resume bug in Critique Theater hooks (Phase 7 review)

Two P1 fixes from lefarcen's review on PR #1307:

SSE payload override

`sseToPanelEvent` previously spread `data` after the channel-derived
`type`, so a payload-provided `type` could override the channel and
route a `critique.run_started` frame into the reducer as a `ship`
action. Reversed the spread so the channel-derived `type` is
authoritative, and revalidated the resulting object through the
contracts-level `isPanelEvent` predicate before returning. Frames
that fail validation (missing runId, empty runId, unknown type) are
dropped, so a malformed or compromised SSE frame can no longer
dispatch a wrong-shape action into the reducer.

Three new sse.test.ts cases pin the regression: hostile `type:'ship'`
in the payload still resolves to `run_started`, missing runId is
dropped, empty runId is dropped.

Replay pause/resume

`useCritiqueReplay` had one big effect keyed on `transcriptUrl`
only, so flipping `speed` from `paused` to `instant` never re-fired
and the held events sat undispatched. Split into a parse effect
(depends on URL, fetches and stores events in state) and a pace
effect (depends on parsed-events + speed, owns the cursor + timers).
The playback cursor lives in a ref that survives pause/resume
cycles, so flipping `paused` -> `instant` flushes from the current
position rather than restarting (which would double-dispatch
`run_started` and reset the reducer).

Two new useCritiqueReplay.test.tsx cases:
- paused-then-instant transitions from `playing` to `done` and
  reaches the shipped terminal phase
- intervalMs paced playback dispatches one event, pauses to drain
  the next scheduled timer, flips to instant, and confirms the
  remaining transcript drains exactly once (cursor was preserved)

Doc consistency

The earlier source comment in useCritiqueReplay.ts claimed `live`
"paces by recorded timestamps" while the impl used zero-delay
timers and the PR body said it behaves like `instant`. Aligned to
reality: `live` currently behaves like `{ intervalMs: 0 }` (events
drain on successive microtasks via setTimeoutFn) because transcripts
do not yet carry per-event timestamps. Honest timestamp-driven
pacing is queued as a Phase 7+ follow-up.

Validated: pnpm guard, pnpm --filter @open-design/web typecheck,
Theater suite 47/47 (up from 42, +3 sse + 2 replay), full web suite
96 files / 888 tests.

* feat(i18n): seed Critique Theater key block (en + zh-CN; other locales fall back via spread)

* feat(web): Theater PanelistLane component (Phase 8.1)

* feat(web): Theater ScoreTicker component (Phase 8.2)

* feat(web): Theater RoundDivider component (Phase 8.3)

* feat(web): Theater InterruptButton component with Escape keybind (Phase 8.4)

* feat(web): Theater TheaterDegraded chip (Phase 8.5)

* feat(web): Theater TheaterCollapsed post-run summary (Phase 8.6)

* feat(web): Theater TheaterTranscript replay surface (Phase 8.7)

* feat(web): Theater TheaterStage top-level container (Phase 8.8)

* feat(web): Theater CSS using existing semantic tokens (no hex literals)

* feat(web): Theater public exports barrel

* fix(web): resolve P2 + P3 review feedback on Phase 8 (PR #1314)

Addresses all 4 P2 + 3 P3 items from codex, Siri-Ray, and lefarcen.

State-lifecycle fixes (3 x P2)
1. Reducer learns a synthetic `__reset__` action (`CritiqueResetAction`).
   Host hooks dispatch it when their gating prop changes so a stale
   run from a prior project / transcript cannot bleed into the next
   context. Reset is idempotent on idle (returns the same reference).
2. `useCritiqueStream` dispatches `__reset__` at the top of its
   connection effect, so a workspace switch from project A (which
   streamed a critique) to project B clears the reducer before the
   new EventSource opens. enabled=false also clears.
3. `useCritiqueReplay` dispatches `__reset__` at the top of its
   parse effect, so transcriptUrl swaps (including swap-to-null after
   a replay reached `shipped`) lift the reducer back to idle before
   the new fetch starts.

SSE validation (1 x P2)
4. `sseToPanelEvent` now runs a per-variant `hasValidVariantShape`
   check after the cheap `isPanelEvent` predicate. A
   `critique.ship` frame missing `composite` / `round` / `status` /
   `artifactRef` is rejected before reaching the reducer, so
   TheaterCollapsed can no longer crash on `undefined.toFixed(1)`.
   Every variant's required fields are validated: run_started
   (protocolVersion, non-empty cast, maxRounds, threshold, scale),
   panelist_* (round, role, plus variant-specific shape), round_end
   (round, composite, mustFix, decision in {continue,ship}, reason),
   ship (round, composite, status, artifactRef.{projectId,artifactId},
   summary), degraded (reason, adapter), interrupted (bestRound,
   composite), failed (cause), parser_warning (kind, position).

Reducer correctness (1 x P2)
5. `panelist_open` now materializes the round + an empty panelist
   view (`{dims: [], mustFixes: []}`) so TheaterStage can highlight
   the in-progress lane the instant the tag opens. Before this, a
   stream that emitted only `panelist_open` after `run_started` left
   `rounds = []` and the UI rendered no current round until a later
   `panelist_dim` arrived.

Polish (3 x P3)
6. Brand role tint swaps from `var(--magenta, var(--accent))` to
   `var(--purple, var(--accent))`. `--purple` is actually defined
   across the design systems; `--magenta` is not, so Brand was
   silently falling through to `--accent` and looking identical to
   Designer.
7. New i18n key `critiqueTheater.interruptedSummary` for the
   interrupted-collapse copy ("Interrupted at round N, best
   composite X.X"). Previously the interrupted branch reused
   `shippedSummary` and the UI read "Shipped at round..." for a run
   that specifically did not ship. Native value in en + zh-CN; other
   locales fall back via `...en` spread.
8. `TheaterDegraded` heading id comes from `useId()` instead of a
   hardcoded `theater-degraded-heading`, so two chips rendered on
   the same page (chat history with multiple completed runs) keep
   their aria-labelledby references unambiguous.

Tests (15 new cases)
- reducer.test.ts (+5): __reset__ on running/terminal/idle, panelist_open materializes round, panelist_open does not stomp prior panelist data.
- sse.test.ts (+6): variant-level rejection for ship without required fields, degraded without adapter, run_started with empty cast, panelist_dim with non-numeric score, round_end with unknown decision, plus a positive fully-formed ship.
- useCritiqueStream.test.tsx (+2): state reset on projectId change, state reset on enabled flip false.
- useCritiqueReplay.test.tsx (+1): state reset on transcriptUrl swap to null after a replay reached shipped.
- TheaterCollapsed.test.tsx (text-pinning update): asserts the interrupted branch reads "Interrupted at round 1" + "best composite 7.9", and explicitly NOT "Shipped at round...".
- TheaterDegraded.test.tsx (+1): two chips on the same page get unique aria-labelledby ids that each resolve to an `<h3>`.

Validated
- pnpm guard clean
- pnpm --filter @open-design/web typecheck clean
- Theater suite: 13 files, 101 tests (was 86 on the first Phase 8 push, +15 new)
- tests/i18n/locales.test.ts 5 of 5 across 18 locales

* feat(web): CritiqueTheaterMount wires SSE + reducer into a single drop-in (Phase 9.1)

* feat(i18n): Critique Theater strings for de + ja + ko + zh-TW (Phase 9.2)

* fix(web): resolve P1 + P2 review feedback on Phase 9 (PR #1315)

Addresses every blocker from codex, Siri-Ray, and lefarcen. The
three state-lifecycle and SSE-validation issues they also flagged
inherit fixes from PR #1314's review pass that this branch now sits
on top of after rebase.

Real daemon kill on Interrupt (P1)
- CritiqueTheaterMount now POSTs to
  /api/projects/:id/critique/:runId/interrupt alongside the
  optimistic local dispatch. Before this fix, clicking Interrupt
  only flipped the React state to interrupted while the daemon job
  kept running. The fetch is best-effort: a 404 (endpoint not wired
  yet, lands in Phase 15) is swallowed with a dev-mode console.warn
  so the UI still moves to the collapsed badge.
- New fetchInterrupt test seam lets RTL assert on the URL / method
  and simulate the "daemon not ready yet" path. Two tests pin both:
  the happy URL proj-42/critique/run-abc/interrupt POSTs, and a
  rejected fetch still flips the UI.

interruptPending reset on new run (P2)
- A ref-backed effect compares the current runId against the last
  one we saw; when it changes, interruptPending is cleared. A user
  who interrupts run-1 and then triggers run-2 from the same mount
  now gets a fresh, enabled kill button instead of one stuck in
  "Interrupting…". Pinned by a new mount test.

Escape keybind scope (P2)
- InterruptButton now checks the keydown target. Escape inside an
  input, textarea, select, or contenteditable element is ignored
  (and any ancestor of those via closest() is treated the same
  way). Body-level focus still fires the keybind so the Theater
  area's affordance keeps working. Four new tests cover textarea,
  input, contenteditable, and the body-focus positive case.

userFacingName i18n key (P2)
- The spec at specs/current/critique-theater.md:6 mandates a single
  critiqueTheater.userFacingName key so the "Design Jury" label can
  be renamed without touching code. Phase 8 introduced
  critiqueTheater.title by mistake; renamed across types.ts, en.ts,
  zh-CN.ts, de.ts, ja.ts, ko.ts, zh-TW.ts, and the lone consumer
  TheaterStage.tsx. The locale alignment test stays green.

Validated
- pnpm guard clean
- pnpm --filter @open-design/web typecheck clean
- Theater suite: 14 files, 112 tests (was 101 before, +11 new for
  the Phase 9 review pass: 3 mount + 4 InterruptButton focus scope;
  the rest were already in #1314's review fix).
- tests/i18n/locales.test.ts 5 of 5 across 18 locales.

* feat(daemon): adapter-degraded registry with TTL (Phase 10.1)

In-memory registry recording adapters that produced malformed or
oversize transcripts so the orchestrator can skip them for a TTL
window (default 24h) instead of cycling through known-bad providers
on every run.

Records carry reason (malformed_block | oversize_block |
missing_artifact), source label, and expiresAt. The test-only
clock seam lets the suite advance time deterministically and prove
that an expired entry stops counting as degraded without anyone
calling clearDegraded.

7/7 vitest cases green.

* feat(daemon): synthetic good + bad adapter fixtures (Phase 10.2)

Two test-only adapters that read the existing v1 transcript
fixtures (happy-3-rounds and malformed-unbalanced) and replay them
as either a full string or a 512-byte chunked stream. The chunked
form is what the conformance harness uses to prove the parser
holds together when the transcript arrives in arbitrary network
slices, not as one buffered blob.

* feat(daemon): adapter conformance harness (Phase 10.3)

runAdapterConformance pulls a transcript through the same
parseCritiqueStream pipeline the orchestrator uses and classifies
the outcome as shipped, degraded, or failed. On a degraded
outcome it forwards the matched reason to the adapter-degraded
registry, so a single nightly conformance run is what populates
the skip list rather than the orchestrator learning each adapter
is broken at request time.

5/5 vitest cases green covering shipped, malformed degraded,
oversize degraded, no-ship failure, and the harness-thrown
failure path.

* test(e2e): Critique Theater Playwright suite (Phase 11)

Six tests, one viewport per visual case, deterministic SSE
fixtures stubbed via page.route(). Adds the suite to
test:ui:extended so the existing extended-UI lane picks it up.

Coverage:

  1. Happy path: a single mounted theater plays the full
     fixture (1 run_started, 5 panelists open / dim / must_fix /
     close, 1 round_end, 1 ship) and ends on the score badge.
  2. Interrupt mid-run: the panelist that is open at the time
     the interrupt button is clicked closes with an interrupted
     marker and the transcript freezes there.
  3. Visual regression at 375x720 mobile.
  4. Visual regression at 768x1024 tablet.
  5. Visual regression at 1280x800 desktop.
  6. A11y role tree: the theater region exposes a labelled
     landmark, each panelist lane is a group with an accessible
     name, the score is a status live region.

All SSE traffic is stubbed by page.route so the suite runs in CI
without a daemon. The toggle is seeded via localStorage by
bootAppWithCritiqueEnabled so the gate behaves as if Settings
flipped it on. typecheck clean; playwright --list reports 6.

* test(web): reducer p99 bench at 10k iterations (Phase 13.1)

Locks the documented 2ms budget for the Critique Theater reducer
on a representative SSE script (27 actions, one full happy run)
behind a regression gate. Asserts p99 stays under 4ms (2x the
documented budget) so CI runners with a noisy neighbour do not
flake while a real regression to 20ms or 200ms still trips.

The bench is a vitest case rather than a bare microbenchmark so
it runs in the same CI lane as every other web test and does not
need a parallel runner.

* test(web): critique surface coverage walker (Phase 13.2)

Walks the public critique surface (11 SSE event names, 5 panelist
roles, 6 lifecycle phases, 9 named i18n keys) and asserts each
named symbol appears in both the src corpus and the test corpus.
The walker is the gate that catches a rename in one half of the
codebase without a matching update in the other half: a future
PR that drops 'panelist_must_fix' from the reducer without also
removing its test reference fails this suite.

62 assertions, one per symbol per corpus.

* docs: Critique Theater user guide (Phase 14.1)

Seven sections aimed at end users (not contributors):

  1. What is Design Jury
  2. How it works (the five panelists, auto-converging rounds,
     the composite formula)
  3. Settings (the M1 toggle and what it does)
  4. Reading the score badge
  5. Replay surface
  6. Troubleshooting (degraded, interrupted, failed)
  7. FAQ

The composite formula is documented as
    designer * 0 + critic * 0.4 + brand * 0.2 + a11y * 0.2 + copy * 0.2
because anyone trying to reverse-engineer the score is going to
search for those weights and the docs are the place they should
land first.

* docs(daemon): critique module AGENTS map (Phase 14.2)

Daemon-side wayfinder for the apps/daemon/src/critique directory.
Tables every file, what owns what invariant, and the 'when you
change anything here' guide so a future contributor does not
have to reverse-engineer the rollout resolver before adding a
new SSE event.

* docs(web): Theater module AGENTS map (Phase 14.3)

Web-side mirror of the daemon AGENTS map. Same file table, same
invariants section, same change-impact guide, sized to the
Theater component package.

* feat(daemon): rollout flag resolver (Phase 15.1)

Single decision point every caller consults to know whether the
orchestrator should wire the critique pipeline for a given run.
Priority:

  1. Skill-level policy (required wins, opt-out wins inversely)
  2. Per-project override from the Settings toggle
  3. OD_CRITIQUE_ENABLED env override
  4. Rollout phase default
       M0 dark-launch      false
       M1 settings only    false (toggle is off until the user flips it)
       M2 per-skill        true if skill opted in
       M3 global default   true

OD_CRITIQUE_ROLLOUT_PHASE parser defaults to M0 on unknown input
so a fresh install never surprises a user with the feature on.

10/10 vitest cases green covering every cell of the matrix.

* feat(web): Settings toggle hook for Critique Theater (Phase 15.2)

React hook that reads critiqueTheaterEnabled from the existing
open-design:config localStorage blob and stays in sync via:

  - the platform storage event (cross-tab)
  - a open-design:critique-theater-toggle CustomEvent (same-tab)

Same-tab event is the one that fires when the Settings panel saves
in the current window: the toggle and every mounted theater update
without a page reload.

setCritiqueTheaterEnabled(next) is the imperative setter the Settings
panel calls. It preserves the rest of the stored config (mode, apiKey,
etc.) and dispatches the same-tab event after the localStorage write.

The web hook reflects what the user toggled; the daemon-side
isCritiqueEnabled is the final routing authority (project override,
env, rollout phase). When they disagree, the daemon wins for backend
gating and the web reflects the toggle state.

6/6 vitest cases green covering first read, stored read, same-tab
event flip, config preservation, corrupted JSON tolerance, and
cross-tab storage event.

* test(web): Phase 15 toggle hook failure-mode coverage (PR #1320)

lefarcen P2 on PR #1320 flagged that the PR body claimed safe
behavior for disabled localStorage, non-object JSON, and missing
CustomEvent shim, but the suite only covered corrupt JSON plus
happy-path storage events. Added four failure-mode tests so the
swallowed errors are not silently traded for a throw in a future
refactor:

1. Returns false on a stored JSON value that parses to an array
   (non-object). Catches a regression where the guard treats
   anything truthy as a config blob.
2. Returns false on a stored JSON value of literal 'null'.
   typeof null === 'object' in JS, so the guard has to check null
   explicitly; this test pins that check.
3. Returns false when localStorage.getItem throws (private mode /
   disabled storage / SecurityError). The hook must swallow and
   return false so the rest of the app keeps rendering.
4. setCritiqueTheaterEnabled still dispatches the same-tab
   CustomEvent when localStorage.setItem throws (quota exceeded /
   disabled storage). The dispatch path is the in-session
   broadcast that keeps every mounted hook coherent even when
   persistence is unavailable; verified by mounting two probes
   and asserting both flip after the setter is called with a
   throwing setItem.

10/10 vitest cases green (6 existing + 4 new).

* fix(web): honor CustomEvent payload in toggle hook listener (PR #1320)

Both Siri-Ray (blocking) and lefarcen (P2 new) caught the same
real bug in the failure-mode test I added in affcdd27: the test
asserts the in-session UI flips when localStorage.setItem throws,
but the CustomEvent listener was ignoring the event's typed
detail and just calling readToggle(). Under a throwing setItem
the localStorage value is stale (or absent), so the listener
would see the OLD value and the test would fail (or worse, the
production claim 'in-session event keeps mounts coherent' was
hollow).

Fixed the hook, not the test: the listener now reads
event.detail.enabled when it is a boolean, falling back to
readToggle() only for malformed events or for cross-tab storage
events (which do not carry a typed payload). The setter already
dispatched the detail; the listener just was not consuming it.

Test changes:

  - The existing 'setItem throws' test now asserts the right
    behavior for the right reason. Updated the inline comment to
    say the listener reads from detail, not localStorage.
  - New test 'falls back to readToggle when the CustomEvent
    carries no usable detail' pins the fallback path: a
    malformed dispatcher (no detail, or detail.enabled not a
    boolean) degrades cleanly instead of throwing or being
    silently ignored.

11 / 11 vitest cases green (10 prior + 1 new fallback).

* feat(daemon): route critique spawn-path eligibility through the rollout resolver

The wireup edit Phase 10 and Phase 15 carved out: today server.ts gates
the critique pipeline on critiqueCfg.enabled, which is just the
OD_CRITIQUE_ENABLED env var. After this commit it gates on
isCritiqueEnabled(...) from the Phase 15 resolver, so the full
priority matrix is live:

  1. Per-skill od.critique.policy veto (opt-out / required)
  2. Per-project override (M1 Settings toggle, written through the
     existing Phase 6 settings endpoint)
  3. OD_CRITIQUE_ENABLED env override (power-user lane / CI fixtures)
  4. OD_CRITIQUE_ROLLOUT_PHASE default
       M0 dark-launch      false
       M1 settings only    false
       M2 per-skill        only when skillPolicy === 'opt-in'
       M3 global default   true

Default behaviour on a fresh install is unchanged: the resolver
returns false at M0 without an env override or a project override,
so prod traffic falls through to the legacy single-pass path
exactly the way it did before.

Inputs threaded today: phase from OD_CRITIQUE_ROLLOUT_PHASE,
envOverride from OD_CRITIQUE_ENABLED. skillPolicy and projectOverride
are passed as null for the v1 cutover; the daemon-side handler that
round-trips critiqueTheaterEnabled on the project settings row and
the od.critique.policy frontmatter resolver land as the next two
commits in this branch.

The three call sites that used critiqueCfg.enabled (the brand-thread
guard, the skill-thread guard, the top-line critiqueShouldRun
compound) now read from a single locally-scoped critiqueEnabledForRun
boolean, so the eligibility check is computed exactly once per spawn
and the prompt composer + orchestrator stay in lockstep the way
the existing comment already promised.

Tests still green: daemon vitest 22 / 22 across rollout +
conformance + adapter-degraded. Daemon typecheck clean.

* feat(web): mount CritiqueTheaterMount in ProjectView

The web counterpart of the daemon wireup. ProjectView now renders
<CritiqueTheaterMount projectId={project.id} enabled={...} /> as a
sibling of <AppChromeHeader> inside the top-level <div className="app">.

The mount is the drop-in from the Phase 9 stack: it owns the SSE
subscription, the kill-request handshake, and the phase-aware swap
from the live <TheaterStage> to the collapsed badge once a run
settles. The mount returns null until the daemon emits a
critique.run_started for the active project, so the visual surface
is byte-for-byte unchanged for users who have not opted in.

Enabled wiring: useCritiqueTheaterEnabled() reads the M1 Settings
toggle from the existing open-design:config localStorage blob and
stays in sync with both the platform storage event (cross-tab) and
the same-tab open-design:critique-theater-toggle CustomEvent the
Phase 15 setter dispatches. The hook honors the event payload
directly so a private-mode browser that cannot persist the toggle
still updates the in-session UI correctly.

The daemon-side gate (isCritiqueEnabled in apps/daemon/src/server.ts)
remains the authority for whether a run is actually wired through
the critique pipeline. This hook only governs whether the web layer
renders the resulting SSE stream when the daemon emits one. The
two-layer gate is intentional: an integrator embedding the Theater
in a custom UI can flip the web visibility independent of the
daemon's routing decision, and a daemon-side env override flips
backend gating without touching the web's localStorage.

Tests still green: web Theater suite 181 / 181 across 16 files.
Web typecheck clean.

* feat(daemon): resolve od.critique.policy frontmatter at the spawn site

The next step in the wireup branch's ladder: replace the placeholder
`skillPolicy: null` with the actual value parsed from the active
skill's SKILL.md frontmatter.

Three small edits, one new field on a public type:

1. SkillInfo gains a `critiquePolicy: SkillCritiquePolicy` field
   carrying the parsed `od.critique.policy` token (required /
   opt-in / opt-out / null). The field is null when the skill has
   no opinion, which lets the lower-priority resolver tiers
   (projectOverride, envOverride, phase default) decide.

2. listSkills() populates the new field via a small
   `normalizeCritiquePolicy` helper that tolerates the YAML
   scalar's casing and trims whitespace. Unknown tokens collapse
   to null so a typo in SKILL.md cannot accidentally force the
   panel on or off; it just falls through. Derived example cards
   inherit the parent's policy.

3. server.ts captures `skill.critiquePolicy` into a hoisted
   `skillCritiquePolicy` variable inside the existing skill-load
   block, then threads it into the isCritiqueEnabled call as the
   skillPolicy input. The hoisting keeps the variable in scope at
   the resolver call site without restructuring the spawn handler.

After this commit, the priority matrix the rollout resolver was
designed for is live for its top tier. The previous commit wired
env + phase; this one wires skill. The projectOverride input
remains null pending the next commit that extends the Phase 6
settings endpoint.

Daemon vitest: 10 / 10 rollout cases pass against the new wiring.
Daemon typecheck: clean.

* feat(daemon): feed projectOverride into the rollout resolver from project metadata

Replaces the placeholder `projectOverride: null` in the spawn
handler with the actual value the Settings panel writes onto the
project's metadata blob: `critiqueTheaterEnabled?: boolean`.

The read is defensive at the boundary: the metadata object is
typed loosely (it round-trips through SQLite as a free-form JSON
blob), so the spawn handler narrows to `boolean` and falls
through to `null` for any other shape. A missing key, a malformed
value, or a project that has never visited Settings collapses to
`null`, which is exactly the resolver's "no opinion, fall
through to env / phase" signal.

The `critique` frontmatter slot also gets typed on the
SkillFrontmatter shape so the `od.critique.policy` chain the
previous commit introduced no longer needs a bracket-access
cast. Same pattern as the existing `craft`, `preview`, and
`design_system` nested-record slots.

After this commit, every tier of the rollout resolver's priority
matrix is wired:

  1. skillPolicy   (from SKILL.md od.critique.policy)
  2. projectOverride (from project metadata critiqueTheaterEnabled)
  3. envOverride   (from OD_CRITIQUE_ENABLED)
  4. rollout phase (from OD_CRITIQUE_ROLLOUT_PHASE)

The write path for projectOverride still flows through the
existing project-update handler the Settings panel already uses
to persist project metadata; no new endpoint is needed. The
Settings UI button that calls setCritiqueTheaterEnabled and
posts the new field is the next commit on this branch.

Daemon typecheck: clean. Daemon vitest: 10 / 10 rollout cases
still green against the new wiring.

* fix(daemon): forward critique events to project sinks + align composer gate (PR #1338)

Two codex review items addressed in one commit since they share the
same root cause (resolver-enabled run hits a transport / prompt
contract that was still env-gated):

P1 (transport mismatch). The daemon emits critique.* SSE frames
through critiqueBus -> design.runs.emit, which fans out on
/api/runs/:runId/events. The web CritiqueTheaterMount subscribes to
/api/projects/:projectId/events (it's project-scoped, not run-
scoped, because the mount lives at the project workspace and
follows the user across runs). Result: in production the mount
never sees a real frame and the e2e tests' stubbed routes hide the
mismatch.

Fixed by extending critiqueBus.emit to fan out to BOTH sinks: the
existing runs.emit transport, AND the per-project event-sinks map.
The project-events route emits via sse.send(payload.type, payload),
so we pack the SSE channel name onto payload.type and let the sink
push the right channel. The web sseToPanelEvent overwrites type
from the channel name on the way back into a PanelEvent, so the
round-trip stays correct.

P2 (prompt gate misalignment). composeSystemPrompt reads
cfg.enabled to decide whether to append the panel addendum, but
critiqueCfg.enabled is loaded from OD_CRITIQUE_ENABLED only. A run
the resolver enabled via phase / project / skill (env unset) would
have critiqueShouldRun = true while critiqueCfg.enabled remained
false, dropping the panel prompt while still routing through
runOrchestrator -> parser waits for tags that never arrive -> run
degrades.

Fixed by passing a derived config { ...critiqueCfg, enabled: true }
to the composer when critiqueShouldRun is true. The composer's own
gate now agrees with the resolver decision on every input the
spec defines.

Daemon typecheck: clean. Daemon vitest: 10 / 10 rollout cases
still green against the new wiring.

* fix: address PerishCode P1 + P2 follow-ups on PR #1338

Two follow-up items PerishCode flagged on the activation PR.
Non-blocking but both are real:

1. Phase 11 e2e suite was wired into test:ui:extended but lands
   the user on '/' (home route) where ProjectView (and therefore
   CritiqueTheaterMount) is never rendered. With the suite as
   written, every assertion would time out the first time the
   lane runs in CI, contradicting the PR body's claim that the
   suite stays parked behind test.describe.fixme.

   The state diverged from my earlier Phase 11 work because the
   merge from main on commit 4ab719c6 brought in #1307's
   squash-merged version of the e2e file (the pre-fixme shape).

   Re-applied test.describe.fixme to the describe block plus
   removed ui/critique-theater.test.ts from the test:ui:extended
   script in e2e/package.json. Added a file-header docblock
   explaining what the follow-up commit needs to do: replace
   goto('/') with /projects/:id navigation similar to
   app-design-files.test.ts, split the SSE fixture into a live
   prefix and terminal suffix (Codex P2 on PR #1320), and commit
   the first PNG baselines.

2. bestRoundOf in CritiqueTheaterMount returned the LAST round
   with a numeric composite, not the round with the HIGHEST
   composite, while bestCompositeOf correctly returned the max.
   A run that closed round 1 at 8.5 and round 2 at 6.0 would
   dispatch interrupted { bestRound: 2, composite: 8.5 } on a
   user-clicked interrupt.

   Folded the two helpers into a single bestRoundAndComposite
   that walks state.rounds once and returns the matching pair so
   the two values cannot drift. The onInterrupt callback now
   destructures from one helper instead of two independent reads.
   Falls back to (state.activeRound, 0) when no round has closed
   with a composite yet.

Web typecheck: clean. CritiqueTheaterMount.test.tsx: 7 / 7 cases
still green against the new helper.

* fix: wire M1 project override end-to-end + correct deferred-surface doc claims (PR #1338)

Three lefarcen P2s on the latest review pass, all real:

1. M1 project override was half-wired: the daemon read
   metadata.critiqueTheaterEnabled but the web setter only
   wrote localStorage. A user opt-in would render the Theater
   on the web (localStorage was set) while the daemon resolved
   projectOverride=null and skipped critique unless env / phase
   already permitted. Two halves talking past each other.

   Extended setCritiqueTheaterEnabled to accept an optional
   { projectId, fetchProjectSettings } options bag. When a
   projectId is supplied, the setter ALSO sends a
   PATCH /api/projects/:id with { metadata: { critiqueTheaterEnabled
   } } so the daemon's spawn-time resolver picks the same value up
   on the next generation. The existing project-routes endpoint
   already accepts arbitrary metadata patches, so no new endpoint
   is needed. The local write + the CustomEvent dispatch still
   fire before the PATCH, so a network failure does not unwind
   the in-session UI flip. Three new vitest cases pin the new
   path: PATCHes when projectId is provided, skips when it is
   not, swallows a rejected PATCH so the in-session UI still
   flips.

2. Rollout docs (docs/critique-theater.md section 3) claimed the
   Settings toggle persists into the daemon settings store, but
   the previous implementation only had a localStorage reader /
   writer plus a daemon read of project metadata, with no
   round-trip. Rewrote the section to lead with the four-tier
   resolver (skill policy / project override / env / phase),
   document that the setter now round-trips via the existing
   PATCH endpoint when given a projectId, and call out the
   Settings panel UI control as a deliberate follow-up.

3. Troubleshooting table pointed users at /api/metrics/critique
   (Phase 12, deferred) and 'od adapters clear-degraded <id>'
   (CLI wrapper that does not exist). Replaced the metrics
   reference with the local conformance harness command
   (pnpm --filter @open-design/daemon vitest run
   tests/critique-conformance.test.ts) that ships today, with a
   note that the Phase 12 dashboard surfaces this status as a
   series once that PR lands. Replaced the CLI command with the
   programmatic clearDegraded() helper that exists today and
   flagged the CLI wrapper as planned follow-up.

Web typecheck: clean. Toggle hook tests: 14 / 14 green (11
existing + 3 new for the round-trip path).

* test(web): multi-round interrupt regression for bestRoundAndComposite (PR #1338)

lefarcen P3 follow-up to the previous bestRoundAndComposite fix:
the existing CritiqueTheaterMount.test.tsx interrupt cases only
exercised a single-round state, so a future refactor back to two
independent helpers wouldn't be caught by the test suite even
though it'd reintroduce the round / composite drift bug.

Added a regression case that:

  1. Drives the reducer through two complete rounds with the
     full 5-role cast closing at distinct composites: round 1
     at 8.5, round 2 at 6.0 (the high-composite round is NOT the
     most recent one).
  2. Clicks Interrupt + waits for the daemon ack via the test
     seam fetcher returning 204.
  3. Asserts the collapsed badge displays "round 1" (the
     correct best-composite round), and queryByText for
     "round 2 ... 8.5" returns null (the buggy pairing
     would have produced that string).

The bestRoundAndComposite helper walks state.rounds in one pass
and returns the matching pair, so the round number and the
composite cannot drift apart. This test locks the fix in: a
refactor that splits the helpers back into independent walks
will be caught here.

8 / 8 vitest cases green on the file.

* fix(web): read-merge-write the project metadata in setCritiqueTheaterEnabled (PerishCode P2 on PR #1338)

The previous round-trip sent { metadata: { critiqueTheaterEnabled: next } }
as the entire PATCH body. The daemon's project-routes handler only
re-stamps three immutable fields (baseDir, importedFrom,
fromTrustedPicker) before calling updateProject(db, id, patch),
which then does a shallow { ...existing, ...patch } in apps/daemon/
src/db.ts. So patch.metadata replaces the row's metadata wholesale,
dropping kind, templateId, linkedDirs, and every other field the rest
of the app reads.

No in-tree caller passes projectId today (only vitest cases), so the
bug had not surfaced yet. But the surface is documented in
docs/critique-theater.md section 3 and the function's own JSDoc as
the M1 round-trip path, so it would have shipped as a latent footgun
for the next integrator: a Settings UI follow-up, or any third party
that wires the setter into a project-aware surface.

Fix: read-merge-write rather than a bare patch.

- GET /api/projects/:id to read the row's current metadata.
- Spread that metadata into the PATCH body and overlay
  critiqueTheaterEnabled: next on top, mirroring the partial-metadata
  pattern already used in ChatComposer.tsx for linkedDirs.
- PATCH the merged object.

Failure handling:
- GET fails: skip the PATCH entirely. We cannot construct a safe
  merged body without the current state, and a bare patch would
  wipe other metadata. The in-session CustomEvent fired earlier in
  the setter still keeps every mounted hook consistent; the next
  save retries the round-trip.
- PATCH fails: log in dev. The in-session UI is already correct via
  the CustomEvent.

Tests (TDD, red-first):

- 'GETs the project then PATCHes with merged metadata when a
  projectId is supplied': stubs a GET that returns
  { kind: 'template', templateId: 'modern-blog', linkedDirs: [...] }
  and asserts the PATCH body equals the merge plus the toggle.
- 'PATCHes with just the toggle when the project has no prior
  metadata': stubs a GET that returns no metadata block.
- 'skips the PATCH (does not stomp metadata) when the prefetch GET
  fails': stubs a rejecting GET and asserts only the GET fires.
- 'swallows a rejected PATCH after a successful prefetch': stubs a
  successful GET and a rejecting PATCH; asserts the in-session UI
  still flips via the CustomEvent.

Doc updated on the setter's JSDoc to describe the new three-step
flow (localStorage, CustomEvent, read-merge-write PATCH) and the
two failure modes.

Verified:
- pnpm --filter @open-design/web typecheck clean.
- pnpm --filter @open-design/web test: 111 files / 1055 tests green
  (was 1052, +3 from the new merge-flow cases).

* fix(web): restore wait-for-daemon-ack pattern on Theater interrupt

Same regression as flagged on PR #1316 post-main-merge: the
optimistic local dispatch fired before the POST resolved, so a
daemon 404 / 409 still terminalized the UI and the real SSE
terminal event got ignored by the sticky interrupted phase.

Snapshot runId / bestRound / composite at click time, dispatch
interrupted only on res.ok, clear interruptPending on rejection or
non-2xx so the user can retry. Tests cover rejection + 404 leaving
the run on the live stage; the 204 path waits for the ack.

* feat(daemon): Critique Theater Phase 12 observability foundations

Lands the metrics registry, the structured logger, the /api/metrics
route, and the adapter-degraded bump that wires up the first data
point. The orchestrator-side bumps for runs / rounds / composite /
must-fix / interrupted / parser_errors / protocol_version land in a
follow-up commit on this branch (kept separate so the wiring diff
reads cleanly against the registry shape).

Surfaces added:

- apps/daemon/src/metrics/index.ts: 9 Prometheus series under the
  open_design_critique_* namespace with the histogram buckets the
  spec calls out (round_duration_ms at 100 / 250 / 500 / 1000 /
  2500 / 5000 / 10000 / 30000 / 60000 ms; composite_score at
  0-10 integer steps).
- apps/daemon/src/logging/critique.ts: 6 typed events, one JSON line
  per call on stdout, namespaced critique. Matches the JSON-per-line
  convention cli.ts already uses; no new logger framework.
- apps/daemon/src/server.ts: GET /api/metrics route. Honors
  OD_METRICS_ENDPOINT=disabled to opt out for air-gapped installs.
- apps/daemon/src/critique/adapter-degraded.ts: markDegraded now
  bumps degraded_total so the adapter-health dashboard panel
  reflects every TTL refresh and every fresh mark.

Deps: prom-client ^15.1.0, @opentelemetry/api ^1.9.0 added to
apps/daemon/package.json. Both are zero-config no-ops without an
exporter wired; daemon bundle size impact is ~150 KB uncompressed.
The @opentelemetry/api dep is in place ahead of the OTel-spans
follow-up commit; it adds no behavior on this commit.

Tests:
- tests/metrics/critique.test.ts (3 cases): registry shape +
  exposition text + reset-between-tests
- tests/logging/critique.test.ts (4 cases): event shape + ordering
  + newline framing + namespace stamping

Verification (Windows-local):
- pnpm --filter @open-design/daemon typecheck: clean
- New metrics + logging suites: 7 / 7 green
- Existing adapter-degraded + conformance + rollout suites:
  22 / 22 green; the bump is non-breaking

* feat(daemon): wire Critique Theater metrics + structured logs from the orchestrator

Lights up the bump sites the Phase 12 foundations PR registered the
series for. Every panel event the parser surfaces now reaches the
matching Prometheus counter / histogram and the matching JSON log
line on stdout.

Switch-loop bumps + logs:

- run_started: log run_started, set protocol_version gauge to the
  observed protocol version (small-integer cardinality).
- panelist_open: record the first-open wall-clock per round so
  round_end can compute round_duration_ms; subsequent opens in the
  same round leave the start time untouched.
- panelist_must_fix: bump must_fix_total with the panelist role.
  The wire event does not yet carry a dim name, so the label is
  'unspecified' for now; a future parser revision can drop in the
  real dim without a metric rename.
- round_end: bump rounds_total, observe composite_score, observe
  round_duration_ms (current ms minus the tracked start), log
  round_closed with the composite / mustFix / decision triple.
- parser_warning (parser-yielded): bump parser_errors_total with
  the kind label, log parser_recover with kind + position.

Orchestrator-side parser warnings (composite_mismatch and
duplicate_ship from the daemon-authoritative scoring checks) go
through a new emitParserWarning helper so the bus emit, the
collectedEvents push, the metric bump, and the log line stay in
lockstep. Three inline emission sites collapse to one-line helper
calls.

After the try/catch, a single terminal-status switch bumps
runs_total{status, adapter, skill} once per run, with branch-
specific log + counter:

- shipped / below_threshold: log run_shipped
- interrupted: bump interrupted_total, log run_failed{cause: interrupted}
- timed_out: log run_failed{cause: timed_out}
- failed: log run_failed{cause: orchestrator_internal}
- degraded: log degraded{reason: orchestrator_classified}

OrchestratorParams gains optional skill: string for the label;
defaults to 'unknown' so spawn sites that have not yet threaded it
keep working without a metric shape change.

Tests:
- The new metrics + logging suites (7 / 7) verify registry shape
  and event framing; orchestrator-side metric integration is
  exercised through the existing critique-conformance and
  critique-adapter-degraded suites (22 / 22 still green).
- Logger test reassigns process.stdout.write directly instead of
  vi.spyOn so the Node overloaded write signature does not
  collide with MockInstance<unknown>.

* feat(observability): Grafana dashboard JSON for Critique Theater

Three default rows mapping to the metrics this branch wires up:

1. Fleet quality: composite score p50 / p90 / p99 line graph by
   adapter, plus a heatmap of the composite distribution. The
   line graph answers 'are my agents getting better over time';
   the heatmap answers 'are the bad runs clustered around one
   adapter or smeared across the fleet'.

2. Adapter health: stacked bar charts for degraded marks (by
   adapter / reason) and parser errors (by adapter / kind) over
   a 5-minute window. The two queries together let an operator
   see 'is this adapter degraded because of malformed wire output
   or because of oversize blocks' without flipping panels.

3. Brief throughput: runs-per-hour by terminal status, an average
   rounds-per-run stat per adapter, and a round-duration ms p50 /
   p90 / p99 line. Throughput numbers fall straight out of the
   runs_total / rounds_total counters; the duration histogram is
   the same one the runs feed.

The dashboard uses a templated $datasource var (defaults to
'prometheus') so an operator with multiple Prometheus instances
can switch without editing JSON. Schema version 39 (Grafana 11).

Operators import via:

  pnpm dlx @grafana/cli dashboard import     tools/dev/dashboards/critique.json

or paste into a provisioned dashboards directory. The file is
checked into the repo as a starting artifact; alert rules and
SLO panels ship after the first 1000 runs inform the right
thresholds. JSON validates with node -e 'JSON.parse(...)' (sanity
checked locally).

* feat(daemon): OpenTelemetry outer span around the critique run

Wraps each runOrchestrator call in a 'critique.run' span via the
existing @opentelemetry/api dep added in the Phase 12 foundations
commit. Attributes set on the span:

- critique.run_id, critique.adapter, critique.skill at start
- critique.final_status, critique.final_composite on terminal
  resolution
- span status flipped to ERROR for failed / timed_out runs so a
  Tempo / Honeycomb / Jaeger filter on traces.status=error
  surfaces the right slice without joining back to Prometheus

No exporter is wired by default; @opentelemetry/api is the API
package and intentionally splits from @opentelemetry/sdk-*, so
the span is zero-overhead until an operator attaches an SDK
through their runtime config.

Inner per-round / parse_chunk / scoreboard_eval / persist_round /
ship.persist spans defined in the Phase 12 plan are a follow-up:
the outer span alone gives the trace a duration + final status +
adapter/skill labels, which is the 80% value for dashboards that
correlate runs across services. Adding child spans inside the
existing 600-line orchestrator without restructuring is a separate
careful change.

Verification:
- pnpm --filter @open-design/daemon typecheck: clean
- 29 / 29 critique + metrics + logging tests still green

* fix(nix): bump pnpmDepsHash for prom-client + @opentelemetry/api lockfile bump

nix-check failed on PR #1485 with hash mismatch in
open-design-daemon-pnpm-deps and open-design-web-pnpm-deps after
the Phase 12 foundations commit (2b8b7445) added prom-client and
@opentelemetry/api to apps/daemon/package.json and refreshed
pnpm-lock.yaml.

CI reported the new sha:
  specified: HFLm+8hv3o5x3Xem4MXNsNclIgiVRc70+EBafL0rVn8=
  got:       7R1sQC38gOT0gsZ2oNOviCZ486cbbGJGJCis6WI8z9s=

Both nix files pin the same workspace lockfile, so both flip in
lockstep. No other Nix surface changes required.

* fix(daemon): four Phase 12 review findings (Codex P2 x2 + Siri-Ray P2 + lefarcen P2)

1. Siri-Ray P2 in orchestrator.ts (round metric / log used untrusted
   agent values). The new observability path now records rs.composite
   and rs.mustFix (daemon-authoritative) instead of event.composite
   and event.mustFix when rs exists, and skips the bumps + log
   entirely when rs is missing (a degenerate round_end without any
   matching panelist_open). The dashboard p50 / p90 / p99 now agrees
   with persistence and ship decisions; an adapter reporting <ROUND_END
   composite='10'> while the daemon computed 6 logs 6 and still emits
   the composite_mismatch parser warning the prior block was already
   producing.

2. Codex P2 in server.ts (skill label always 'unknown'). The spawn
   path called runOrchestrator without passing the resolved skill id,
   so every live run bumped open_design_critique_*{skill='unknown'}
   and the per-skill dashboard breakdown was always empty. Threaded
   effectiveSkillId (already computed at the same handler scope as
   the project skill fallback) through skill: . . . so the metric
   reflects the real skill when one is assigned, and the orchestrator
   default of 'unknown' only fires for runs that genuinely have none.

3. Codex P2 in conformance.ts (protocol-version mismatch let through).
   An adapter that emitted <CRITIQUE_RUN version='2'> followed by a
   valid SHIP classified as shipped because the harness only watched
   for terminal events. Added a guard inside the parse loop: if a
   run_started carries protocolVersion !== CRITIQUE_PROTOCOL_VERSION,
   mark the adapter degraded with reason 'protocol_version_mismatch'
   (already in DEGRADED_REASONS) and return early. ConformanceOutcome
   union widened to accept the new reason.

4. lefarcen P2 in tools/dev/dashboards/critique.json (runs-per-hour
   panel under-reported by 3600x). 'rate(...[1h])' returns per-second.
   Multiplied by 3600 so the panel title and unit match the actual
   value rendered.

Verification:
- pnpm --filter @open-design/daemon typecheck: clean
- New metrics + logging suites (7), existing adapter-degraded (7),
  conformance (5), rollout (10): 29 / 29 green
- Grafana JSON re-parses with node -e 'JSON.parse(...)'

* feat(daemon): Critique Theater Phase 16 (M-phase rollout ratchet)

The PR that takes the rollout out of operator-flips-env-vars-by-hand and into the-fleet-conformance-numbers-decide. Stacks on Phase 12 (#1485): the ratchet reads from the conformance harness's daily output, which only exists once Phase 12's metrics + history surface land.

Five surfaces:

1. apps/daemon/src/critique/ratchet.ts (new)
   Pure evaluator. Takes the current RolloutPhase plus a rolling window of ConformanceDay rows and returns one of three decisions: promote, hold, or demote. Spec defaults (14-day window, 0.90 shipped, 0.95 clean-parse) match specs/current/critique-theater.md. Demote floor is half the promote threshold so a single noisy day does not bounce the rollout back; only sustained breakage walks things back. M0 cannot demote and M3 cannot promote, both collapse to hold with an explicit reason string.

2. apps/daemon/src/critique/conformance-history.ts (new)
   JSON-lines persistence at dataDir/conformance/adapter/date.jsonl. Append-only writer + windowed reader. Last entry per (adapter, date) wins so a retry-after-failure cron writes the right answer without a read-modify-write at write time. Malformed lines, missing files, and missing adapter directories all collapse to skip-this-row since a missing day is data missing, not data wrong.

3. apps/daemon/src/server.ts
   GET /api/critique/conformance returns { window, decision }. Tunables come from query string (windowDays, shippedThreshold, cleanParseThreshold) with spec defaults. The recommendation does not auto-flip OD_CRITIQUE_ROLLOUT_PHASE; an operator-driven follow-up consumes the JSON and decides whether to flip or alert.

4. .github/workflows/critique-conformance.yml (new)
   Nightly cron at 03:00 UTC. Builds the daemon, drives the conformance harness against the synthetic-good and synthetic-bad fixtures, and uploads the .od/conformance/ snapshot as a workflow artifact. The schedule sits outside the busy generation window so the cron does not contend with user runs for adapter rate-limit budgets.

5. apps/daemon/tests/critique-ratchet.test.ts + critique-conformance-history.test.ts
   17 cases. Ratchet: 10 cells of the promote / hold / demote matrix. History: 7 round-trip cases.

Verification:
- pnpm --filter @open-design/daemon typecheck: clean
- 17 / 17 new tests green
- Phase 12 metrics + logging + adapter-degraded + conformance + rollout suites (29) untouched and still green

* fix(daemon): three Phase 16 review findings (Codex P1/P2 + lefarcen P1 x3)

1. Duplicate parseRolloutPhase import in server.ts. The new standalone import collided with the existing grouped import; ESM would fail to parse at module load on every daemon startup path. Removed the standalone import; the grouped one already exports parseRolloutPhase.

2. Validation gap in evaluateRollout. A request like ?windowDays=0 fed passingDays >= windowDays = 0 >= 0 = true, returning promote with zero observed days. Now the evaluator rejects non-positive windowDays and out-of-range thresholds at the function entry with an explicit hold reason. The route also clamps query strings before they reach the evaluator (belt + suspenders so a future caller bypassing the route hits the same defense).

3. Missing nightly runner. The workflow called apps/daemon/src/critique/__fixtures__/run-nightly.ts, which the prior PR did not actually add, and || echo masked the failure. Added the runner: drives every synthetic adapter through runAdapterConformance, walks the resulting events for parser_warning to compute cleanParseRate, and writes one ConformanceDay row per adapter via appendConformanceDay. Removed the || echo mask so the workflow fails loudly when the runner throws.

Tests for the validation fix: four new ratchet cases (windowDays=0 holds with no evidence, windowDays=-7 holds, shippedThreshold > 1 holds, cleanParseThreshold < 0 holds). Ratchet suite goes from 10 -> 14 cases.

Verification:
- pnpm --filter @open-design/daemon typecheck: clean
- 33 / 33 critique tests green (14 ratchet, 7 conformance-history, 7 adapter-degraded, 5 conformance)

* test(daemon): explicit NaN regression cases for the ratchet evaluator (PerishCode follow-up on PR #1499)

The Number.isFinite() guard already rejects NaN on every numeric
input, so this is belt-and-suspenders: pinning the behavior so a
future refactor of the guard (a typed parser, a clamp helper, a
relaxed range check) cannot accidentally let NaN through and
surface a zero-evidence promote signal.

Three new assertions inside one case (windowDays=NaN, shippedThreshold=NaN,
cleanParseThreshold=NaN), each asserting hold + the matching
'invalid X' reason string. Ratchet suite goes from 14 -> 15 cases.

* fix(nix): regenerate lockfile + pin pnpmDepsHash for prom-client + @opentelemetry/api (lefarcen P1 on PR #1499)

---------

Co-authored-by: Nagendhra <nagendhra405@gmail.com>
2026-05-14 11:05:57 +08:00
54498f1ac5 fix(web): parse Provenance with Markdown-bold labels (#1584)
* fix(web): parse Provenance with Markdown-bold labels (#1580)

The daemon's finalize synthesis prompt at apps/daemon/src/finalize-design.ts:560-565
lists the five Provenance fields without pinning field-label syntax, so Claude
renders them with Markdown-bold labels per Markdown convention
(`- **Field:** value`). The parser at apps/web/src/lib/parse-provenance.ts:32-36
uses `[:\s]+` as its label/value separator, which stops at the trailing `**`
after the colon; the capture group then slurps the `**` and any following
whitespace into the value. Downstream of that, transcriptMessageCount and
generatedAt parse as null because the captured tokens don't start with digits
or a valid ISO 8601 prefix, and the Continue in CLI clipboard prompt shows
`Design system: ** ...`, `Transcript message count when DESIGN.md was
generated: unknown`, `DESIGN.md generated at: unknown`.

Fix: strip leading and trailing Markdown emphasis (`*`, `_`, whitespace) from
every captured value via a single helper threaded through extractField /
extractFieldOrNone / extractNumber / extractDate. Widen the
transcriptMessageCount regex's capture from `(\d+)` to `([^\n]+)` so the
strip step gets a chance to run on `** 4`. Add `[^:]*` between `count` and
`[:\s]+` to mirror the other label-walking regexes for bolded label
variants.

Defense-in-depth: tell the synthesis prompt to emit plain `- Field: value`
bullets with no emphasis on the labels. The parser hardening is the
load-bearing fix; this is belt-and-suspenders for new model variants.

Red-Green-Refactor:
- Phase 1 (Red): 3 new parse-provenance tests covering bold labels with
  backticked values, bold labels with a short `Generated:` form, and bold
  labels with `none` sentinels. All 3 failed against pre-fix source.
- Phase 2 (Green): strip + regex widening. All 7 parse-provenance tests
  + 1158 web tests pass.
- Phase 3: empirically verified against a live finalized DESIGN.md — all
  five fields now parse correctly.
- Phase 4 (defense-in-depth): one-line addendum to synthesis prompt.
- Phase 5: bold-labelled Provenance fixture added to the hook test
  (useDesignMdState.test.tsx) so the round-7 `unknown-provenance`
  fail-closed path is regression-pinned end-to-end.

Backticks in field values are intentionally kept (out of scope per the
issue spec; rendered clipboard text reads fine with them). The variant
`- **Field**: value` (colon outside emphasis) is not in the issue
enumeration and is not handled.

Fixes #1580

* fix(web): narrow Provenance strip to Markdown residue only

Round-2 fix per lefarcen's review on PR #1584. The round-1 helper
used `^[\s*_]+` / `[\s*_]+$`, which stripped a literal leading or
trailing `*`/`_` from any captured value — `_draft.html` corrupted
to `draft.html`, and a build id like `build_id_v1_` lost its
trailing underscore.

Narrow stripMarkdownEmphasis to three explicit passes:
  1. Leading `*`/`_` tokens FOLLOWED BY WHITESPACE — only matches
     the `** ` residue left after `- **Field:** value` is captured
     starting at the `*`.
  2. Trailing WHITESPACE followed by `*`/`_` tokens — mirror of (1)
     if the value closes with emphasis after whitespace.
  3. A single balanced wrap around the remaining value
     (`**X**` / `*X*` / `__X__` / `_X_`) — handles the
     `- **Field:** **value**` shape and any plain-label
     `**value**` form.

Asymmetric literal `*`/`_` characters in the value (no whitespace
separator, no balanced closing token) are preserved by
construction.

Added regression tests:
  - plain label + `_draft.html` value
  - plain label + `build_id_v1_` value (trailing underscore)
  - bold label + `_draft.html` value (residue stripped, literal
    leading underscore preserved)
  - plain label + `**wrapped-id**` value (balanced residue stripped)

All 11 parse-provenance tests + 1162 web tests pass. Empirically
re-verified against a live finalized DESIGN.md — all five fields
still parse correctly.

---------

Co-authored-by: DevForgeAI CI/CD Engineer <devforge-ai@development.ai>
2026-05-14 11:04:24 +08:00
pftom 3b167b6921 feat(plugins): add registry protocol and enhance plugin management features
- Introduced the `@open-design/registry-protocol` package, enabling improved interactions with plugin registries.
- Updated the `typecheck` script in the daemon's `package.json` to include the new registry protocol.
- Enhanced the CLI with new flags and commands for better plugin management, including `yank` and additional marketplace functionalities.
- Implemented a plugin lockfile system to manage installed plugins and their versions, improving reliability during upgrades.
- Added new marketplace doctor functionality to validate plugin entries and ensure compliance with registry standards.

This update significantly enhances the plugin ecosystem by providing robust registry interactions and improved management capabilities.
2026-05-14 08:55:36 +08:00