Skip to content

Latest commit

 

History

History
49 lines (37 loc) · 42.7 KB

design-decisions.md

File metadata and controls

49 lines (37 loc) · 42.7 KB

Key design decisions

Why the code is the way it is, with the incidents and reviews behind each choice. Read the matching entry before changing the behavior it describes, and add an entry when you make a choice a future reader would question.

  1. Native & Bridge MCP - Codex has NO MCP server (it once did; codex-cli 0.154.0 ships none and openai/codex has no mcp-server crate left - codex mcp manages external servers and app-server is a different protocol), so GPT is reached by spawning codex exec from core/providers/codex.js behind the unified server's ask-gpt / expert tools - advisory-only, single-shot. Gemini requires a bundled bridge (server/gemini/index.js) that wraps the Antigravity CLI (agy) in print mode. Grok has no MCP or CLI server mode, so a bundled bridge (server/grok/index.js) wraps the xAI Responses API (/v1/responses) directly - advisory-only (no file editing), but it can READ attached files (files:[{path|file_id|file_url|dir}], optional roots[], per-entry mode for upload-vs-inline delivery); uploaded files are SHA-256 dedup-cached locally, auto-expire (7-day default, GROK_FILE_TTL_SECONDS), and are managed with /grok-files (server/grok/files-admin.js: list/prune/gc). Details in TECHNICAL.md § Grok files and cleanup. OpenRouter uses a bundled bridge (server/openrouter/index.js) that calls any OpenAI-compatible POST {apiBase}/chat/completions endpoint - advisory-only, text-inline file attachment only ({path}/{dir}; no upload path), config-driven via ~/.config/deliberation/config.json (Windows: %APPDATA%\deliberation\config.json; override with DELIBERATION_CONFIG). Details in TECHNICAL.md § OpenRouter bridge.
  2. Single-shot + multi-turn - Single-shot for advisory (full context per call), multi-turn via threadId for chained implementation and retries
  3. Dual mode - Any expert can advise or implement based on task. In core, implementation is two-lock gated: a write happens only when the provider was constructed with allowImplement:true (construction lock, in core/providers/codex.js + antigravity.js) AND the request carries mode:"implement" (request lock, DelegationRequest.mode, a closed "advisory"|"implement" enum defaulting to advisory). A stray or injected mode alone never writes; the OS sandbox string (workspace-write) stays provider-internal so callers cannot smuggle argv. capabilities.canImplement reflects the construction lock, so discovery is honest per process. Gemini's credential env-scrub (*_KEY/*_TOKEN/GIT_ASKPASS/SSH_AUTH_SOCK) runs in both read-only and write mode - a write run edits the worktree but never receives the operator's keys. The construction lock is left OFF in the live composition root today, so the running server stays read-only; the unified-server implement tool + cache-bypass + forced audit record are part of the MCP consolidation. Grok/OpenRouter remain advisory-only (canImplement:false). See TECHNICAL.md § Implementation mode.
  4. Synthesize, don't passthrough - Claude interprets expert output, applies judgment
  5. Proactive triggers - Claude checks for delegation triggers on every message
  6. Opt-in session store - consensus/consensus-step/ask-all runs persist only when sessions.persist is on (default off); per-file JSON at <XDG cache>/deliberation/sessions/ (override DELIBERATION_SESSIONS), secrets scrubbed, retention by count + age (-1 = unlimited). Single schemaVersion:1 (no dual-version support; loop runs carry per-opinion verdict/criticalIssues + converged/confidence/rounds, synthesize runs carry synthesis). Tools: session-get/session-revisit/session-annotate. Body capture is a separate opt-in: the per-opinion RESPONSE body (opinion.text) is stored ONLY when sessions.captureText is also on (default off) - off = summaries only (question + verdict/criticalIssues); on = body, secret-scrubbed (mandatory) then best-effort PII (email) stripped. Gated uniformly at the single writer (persistRun); debug.jsonl NEVER receives body text regardless. Details in TECHNICAL.md § Session persistence.
  7. Consensus engine SSOT - one pure state machine (core/consensus-loop.js) behind two drivers (consensus server-side, consensus-step host-driven); the live /consensus is a thin driver over consensus-step. See Consensus engine.
  8. Orientation auto-attach (on by default) - core/orientation.js resolves a small bundle of high-signal repo files (fixed priority: AGENTS.md, else CLAUDE.md - never both - then package.json, pyproject.toml, Cargo.toml, go.mod, tsconfig.json, main.tf, then README.md; capped at orientation.maxFiles, default 6, AND a content-byte budget orientation.maxBytes, default 16000, about 4K tokens; stat-only, never reads content, never throws). Size, not file count, is what costs tokens: a measured 37K-token Grok prompt did not move when maxFiles went from 6 to 3. Files that fit go whole, then the highest-priority file skipped for size is cut to the remaining budget (dropping lower-priority whole files when they are what leaves too little room), at most one cut file; each entry carries headBytes + mode: "inline", and both bridges read only that head through core/head-read.js (non-blocking open, fstat regular-file check, cut at a line break, [truncated: first N of M bytes] marker, content plus marker within the bridge per-file cap). headBytes is a new path-only field: a {dir} entry keeps maxBytes as its walk cap. orchestrate.js withOrientation gate injects the bundle into file-blind providers (Grok, OpenRouter - those where walksFilesystem === false in ProviderCapabilities) ONLY when the caller passed no files of its own. Injection happens BEFORE the dedup cache key is computed so a now-file-bearing request correctly skips the in-session result cache. The peer fan-out AND the arbiter blind pass are oriented; verdict/adjudication/revision passes are NOT. Bundle travels in files[], never the shared prompt - zero cross-contamination. Provider bridge caps apply. Config: orientation: { enabled: true, maxFiles: 6, maxBytes: 16000 } (default ON since 3.29: with it off, a file-blind reviewer judged a plan against a repo it could not see, flagged missing pieces that existed, and the loop spent a round on a false disagreement; 16 KB per file-blind voice per round is small next to the persona plus plan). Details in TECHNICAL.md § Orientation auto-attach.
  9. A provider stub is an error, not an answer - but a terse answer is an answer - agy can exit 0 having printed only a preamble ("I will begin by finding the repository directory..."), and grok-4.6 (agentic-trained, reached with tools: []) can end its turn on "I'll verify the cited files..." (99-162 chars) waiting for a tool call that never comes. Any non-empty text used to flow downstream as a real opinion, and inside consensus parseReview turned it into verdict: null - silently blocking convergence with no diagnostic. The rule is ONE shared helper, core/answer-floor.js stubReason(text, minChars), called by both bridges: a reply is a stub when it is shorter than GEMINI_MIN_ANSWER_CHARS / GROK_MIN_ANSWER_CHARS (default 1, i.e. empty; 0 disables both checks) OR is under 400 chars and opens with an intent phrase FOLLOWED BY an exploration verb ("I will begin by...", "Let me check..."; a bare "I will." or "I'll go with B" is an answer); it fails as errorKind: "empty" with the reason in the message, and callProvider retries once. The intent check is new for Gemini (Grok already had it). The first cut was an 80-char LENGTH floor; issue #180 showed why that is the wrong signal - the reporter's smoke prompt made agy answer ok (2 chars, exit 0) and every call failed while the CLI worked by hand. A character count cannot tell terse from stub; the opening phrase can, and it catches both observed stubs. Gemini applies it on the clean-exit AND the drain path (validity must not be timing-dependent). The Grok bridge also always seeds the system turn with a runtime note - no tools, no filesystem, no further turns, answer now. Two more lessons from #180: toErrorResult now forwards the bridge's message (bounded; never into the debug log or session store), because the coarse errorKind alone hid a one-line diagnosis behind hours of sandbox instrumentation; and agy --help prints to STDERR on 1.1.x, so the --model capability probe reads both streams - the pin had never actually been sent before, and agy rejects the built-in auto-gemini-3 alias on 1.1.x (invalid model selection). That ONE pin self-heals because it ships in every install's config: runGemini retries once without it (within the remaining budget), warns once, omits it for the rest of the process, and the result says so (pinDropped: true; unified model: "agy-settings-default"). Any other rejected id is the operator's own pin and fails loudly as model-not-allowed with agy's catalog in the message - never a silent model swap. The pin is found by position (right before the -p tail), never by scanning argv values. Details in TECHNICAL.md § Answer floor.
  10. Observability + per-provider progress (host-neutral; every result carries ms + effective reasoningEffort, HTTP results add token usage):
  • panel + ask-one tools - panel echoes the exact selectForAskAll set (fanout cap applied) WITHOUT dispatching; ask-one runs one named provider. /ask-all (commands/ask-all.md) calls panel then issues N parallel ask-one calls in ONE turn, so each provider renders independently as it lands (visible progress) while keeping parallel wall-time. The legacy single-call ask-all tool is retained. When the dashboard journal is on, panel also opens a fanout run and returns its runId; /ask-all passes it to every ask-one so the dashboard draws one run (only ids that this process's panel opened are joined). Empirically, Claude Code does NOT render mid-call MCP notifications/message, but DOES surface each parallel tool result as it settles - so the per-provider path is the progress lever on this host.
  • Universal debug log (core/debug-log.js, config debug.enabled, OFF by default) - an injected logger emitted AT THE SOURCE in core (askAll/askOne/consensus/runToConvergence), so the Claude host-arbiter path and the in-core provider-arbiter loop log identically. Records latency, reasoning effort, HTTP token usage, and voting/approval outcomes; NEVER prompts/responses/issue text (ALLOWED_KEYS whitelist enforced on write). Default path <XDG cache>/deliberation/debug.jsonl (override DELIBERATION_DEBUG_LOG).
  • In-session dedup cache (core/result-cache.js) - identical advisory re-asks return instantly; LRU + 10-min TTL; errors never cached; file-bearing requests skip it; session-revisit bypasses it. Wired to the advisory ask-all/ask-one paths only (NOT the consensus loop).
  • analyze tool (core/analyze.js + commands/analyze.md) - reads the debug log back (tail-bounded, pre-aggregated server-side) for per-model latency/tokens/error/effort (Lens A) and the session store for verdict agreement-rate (Lens B), then returns advisory tuning suggestions (disable a slow/redundant model in ask-all, lower an OpenRouter model's reasoning, adjust maxFanout). The two lenses are NOT joined (no shared run id). This operationalizes the deferred "measure-then-recommend" lever. Read-only; writes nothing. Codex reasoning points at providers.codex.reasoningEffort; Gemini reasoning is surfaced as external advice (it lives in agy, outside deliberation's config).
  • MCP logging capability + notifications/message sink - declared + emitted per provider settle; spec-compliant, so a host that renders server log notifications gets live progress for free.
  1. Resolve the CLI, never interpose a shell - core/resolve-bin.js turns a bare command name into something Node can spawn, because npm install -g on Windows writes a codex.cmd shim that spawn with shell:false refuses to execute (issue #170: every GPT call failed, plus three agy sites in server/gemini/index.js including the boot gate that calls process.exit(1)). Resolution on win32 is FIRST-MATCH-WINS in PATH then PATHEXT order (libuv/where precedence): a .exe/.com is spawned directly; a .cmd/.bat/.ps1 shim is bypassed via process.execPath + the npm package's own entry (@openai/codex -> bin/codex.js, probed in both the global and the node_modules/.bin layout; no such package exists for agy, so it errors explicitly); scanning continues past the first match ONLY when it is a shim with no package behind it, and then anything usable wins (a .exe or another shim that does have its package) - deliberately out of shell order, because failing while a working install sits on PATH is worse; nothing found passes through and keeps today's ENOENT. An explicitly NAMED shim (CODEX_BIN=codex.cmd) is recognised as one, so the error is not "set CODEX_BIN" to someone who just did. Every non-win32 platform and every other path-bearing name pass through untouched - the AGY_BIN fixtures in test/_helpers.js depend on it. shell: true was rejected on LIFECYCLE grounds, not injection (the prompt is stdin, argv is a constant): the shell would become the child, so child.kill("SIGKILL") would kill the shell and leave the CLI running past the timeout the consensus circuit breaker depends on. Overrides: CODEX_BIN, AGY_BIN. Cross-platform part: a failed launch now reports not-found (codex) / missing-cli (gemini) instead of unknown, keyed on a structural spawnFailed flag rather than a stderr pattern - the same precedent as timedOut, because a coding agent legitimately prints ENOENT about the user's own files. The Gemini gate lives in runGemini, NOT only behind require.main === module: server/mcp/index.js require()s the bridge, so a boot-only gate never runs in the process that serves calls. CI is ubuntu-only, so win32 is asserted through injected platform/env/exists, the core/paths.js pattern. Details in TECHNICAL.md § Windows CLI resolution.

  2. A transport ceiling is not a network fault, and a dead peer is not a voice - four defects that turned one bad provider into a 10-minute consensus round, all confirmed from debug.jsonl:

  • undici's ceilings are not raisable. Node's fetch gives up after 300s waiting for response headers and 300s between body chunks, with no public API to change either. The Grok bridge posted stream: false, and xAI sends nothing until the answer is done, so every grok-4.6 call that thought for over five minutes died at exactly 300s - providers.grok.timeout: 480000 was unreachable. Worse, the bare TypeError: fetch failed was classified network, which callProvider RETRIES, so the round paid 600s to fail. Now: server/grok/index.js streams (SSE, branching on the response Content-Type so a non-streaming upstream and every error status still work; response.completed carries the same object the non-streaming call returned, so the parser/usage/output path is unchanged; GROK_STREAM=0 reverts), and core/provider.js classifyFetchFailure maps UND_ERR_HEADERS_TIMEOUT/UND_ERR_BODY_TIMEOUT to timeout - never retried. OpenRouter only escaped this because its API emits processing keepalives.
  • network was a black box. The message is dropped by debug-log.js ALLOWED_KEYS, the session store only writes on a terminal transition, and the panel prints the kind - so a DNS failure, a socket reset, a 300s undici ceiling and a local throw were one indistinguishable label in the only place provider health is diagnosed. provider_result now carries errorCode (the rejection's cause.code; the key was already whitelisted for persist_failed). A bare errno/undici symbol, never a message.
  • The breaker was on the wrong path and counted the wrong thing. timeoutStreak lived in runToConvergence (the provider-arbiter driver) and counted only errorKind === "timeout". The live /consensus drives consensus-step, which had NO breaker: it re-ran selectForConsensus every round and re-dispatched peers that had failed every round since round 1. The streak now lives on LoopState.errorStreak in core/consensus-loop.js (updateErrorStreak/trippedProviders, folded in by addOpinions), counts ANY error, and is read by both drivers - the same SSOT rule as #7. dispatch_peers reports removals as droppedProviders[] and terminates with stopReason: "all-providers-circuit-broken" rather than looping on an empty fan-out.
  • consensus.maxWallMs applied to one driver. consensus-step is a series of stateless calls, so it now carries startedAt on its stored state and gates STARTING a round (never a fan-out in flight), returning stopReason: "budget-exhausted". Also: the network retry no longer fires in the same millisecond as the failure (1s pause), and upstream is now actually in RETRY_ONCE_KINDS rather than merely documented as retryable. Details in TECHNICAL.md § Circuit breaker and § Grok streaming.

Review follow-ups (same PR). Code review found the first cut of the above wrong in ways worth recording, because each is a general trap:

  • An abort has two shapes. classifyFetchFailure caught the AbortError but not the WRAPPED form undici produces when the controller fires mid-body: TypeError: terminated whose cause is a DOMException named AbortError. Neither its name nor its message mentions aborting, so the bridge's own ceiling fell back into the retryable network bucket - reintroducing the exact double-billing through the door built to close it.
  • A fixture that mirrors the parser can never disagree with it. The SSE splitter matched only "\n\n", so a CRLF stream never framed; the tests passed because the fixture also emitted "\n\n". Protocol tests need spec-derived inputs. Framing now lives in core/sse.js (LF/CRLF/CR) with the xAI semantics in server/grok/stream.js, and the event name is read from the SSE event: line when the JSON carries no type.
  • Prefer, do not trust. final || deltas discarded a complete answer whenever a completion event arrived unparseable; response.incomplete (a truncated but usable answer on the non-streaming path) was thrown away as a failure; whitespace-only deltas escaped a truthiness check into a non-retryable parse.
  • Streaming by default needs a way to be wrong. A stream carrying no recognized event now retries once with stream: false, so a drift in the event vocabulary degrades instead of taking Grok offline - which, with the new breaker, would have silently dropped it from the panel.
  • A terminal transition is not the same state as a round. terminateLoop fires BEFORE a fan-out, so it must persist the last COMPLETED round's opinions from history (results was already reset by submitRevision) and report the rounds that finished, not the one about to start.
  • Consolidating a rule means migrating every caller. no-providers was added to consensus-step and not to runToConvergence on the same PR that argues for one rules layer; droppedProviders was recomputed from the whole streak map, so it re-announced every round and could name peers absent from the panel.
  1. Another tool's subcommand is not an API, and a CLI that forwards is worse than one that errors - .claude-plugin/plugin.json registered a deliberation-codex server as codex mcp-server for as long as that subcommand existed. codex-cli removed it (0.154.0 ships no MCP server mode; codex mcp manages external servers, app-server is a different protocol, and no mcp-server crate remains in openai/codex). The removal was invisible at the only moment it mattered: codex forwards an UNKNOWN subcommand to the interactive CLI, which exits on Error: stdin is not a terminal - exit 0, no stderr about a missing command - so the host reported CONNECTION_CLOSED and two tools (codex / codex-reply) stayed advertised, forever unable to answer (issue #185). Same shape as #9 one layer down: a stub is not an answer, and a forwarded subcommand is not a server. The fix was deletion, not a probe - core/providers/codex.js already spawns codex exec (a subcommand that exists), and the Codex/Kiro host manifests already shipped only the unified server, so dropping the entry left ONE GPT path instead of two advertised ones. What the delete costs, honestly: GPT multi-turn (codex-reply) and GPT workspace-write - both already dead upstream, so the docs claiming them were the remaining bug. Implementation now means Gemini. The lesson generalises to every vendored CLI invocation: pin to the narrowest subcommand that the vendor documents as stable (exec), and treat "the process started but said nothing useful" as a failure signal, never as a connection.

  2. The host's cap wins, so fail under it and name it; a peer that cannot answer is not a voice - three independent defects made the plugin unusable in Claude Code on the web, each invisible from the error surface. (a) The web host exports MCP_TOOL_TIMEOUT=60000 and kills any tool call at 60s; the provider ceilings (180-600s) were unreachable, so the host reported tool "ask-grok" timed out after 60s and the provider's own error path - errorKind, message, circuit-breaker input - never ran. core/host-budget.js reads the cap and clamps every ceiling to cap - 5000 ms at the point it is applied (Codex ask, runGrok, runGeminiOnce, callOpenRouter - the standalone /ask-* bridges included); a timeout that fired because of the clamp says so in message (Host MCP_TOOL_TIMEOUT=60000 caps every MCP tool call ...). Review caught that a per-leg clamp alone re-arms the whole cap for every SEQUENTIAL leg (a retry, the arbiter passes of a round, Gemini's 120s drain after a 55s soft timeout = a 175s call under a 60s cap), so the server takes the clock at tool entry and every leg is stamped with what is LEFT (fitToHostBudget -> req.hostBudgetRemainingMs, a separate field so a shorter configured ceiling still wins; the adapters' clamp reads it); a retry whose backoff would not fit is not even slept on; the Grok fallback and stale-file retry spend the same budget; runToConvergence stops budget-exhausted once the cap is spent; and the Gemini drain is off under any cap (graceWithinHostBudget). Deliberately NOT a config key: the cap is the host's, so the fix (raise it where the host is launched) is named, not emulated. Follow-up: the clamp alone still left every web call at 55s, and Claude Code resolves the cap per server as config.timeout ?? MCP_TOOL_TIMEOUT ?? default (progress notifications never extend it), so .claude-plugin/plugin.json now declares "timeout": 1800000 on every server AND mirrors it into the server env as MCP_TOOL_TIMEOUT - without the mirror the server inherits the host's 60000 and this very clamp cancels the override (test/plugin-manifest.test.js keeps them equal). The "still times out at 60s" report that prompted it came from a container whose seed marketplace pinned v3.14.8 two minutes before v3.14.9 shipped; /deliberation:doctor and the hint now say "older install" instead of "raise the variable". (b) Making the clamp fire exposed that Grok's ceiling was unenforceable while an SSE body was flowing: on Node 22 aborting the fetch signal does not reliably error a streaming body (chunks kept arriving 20s+ past the abort) and the for await loop holds the stream lock, so an outside cancel() is refused. core/sse.js readSseStream now takes the signal, races each reader.read() against it, and cancels the reader it owns - without awaiting the cancel, which is async and need not settle (review reproduced a pending cancel() holding the timeout hostage). (c) codex-cli 0.154 reads CODEX_API_KEY or auth.json, never OPENAI_API_KEY - the only key the web container exported - so every GPT call was 401 Missing bearer after 17s of reconnects. The first fix forwarded OPENAI_API_KEY as CODEX_API_KEY, and that broke every laptop that exports OPENAI_API_KEY for other tools: codex ranks the env key above auth.json, so the ChatGPT subscription was bypassed and calls died on You have no credits remaining. The rule now (codexEnv()): a ChatGPT credential (a codex login, or a Business/Enterprise CODEX_ACCESS_TOKEN, which codex reads natively and ranks above auth.json) always wins and CODEX_API_KEY is dropped from the child's env; without one CODEX_API_KEY is used; OPENAI_API_KEY is never used and never reaches the child. A web host must get a login of its OWN: a ChatGPT refresh token works once, so an auth.json copied from a laptop (or restored from a secret every session) dies on the first refresh either side makes, with ...your refresh token was already used. That text names neither "auth" nor "login", so it classified as unknown; classifyCodex now maps codex's exact phrase (access token could not be refreshed, stderr only - codex exec echoes the prompt there, so a bare refresh token match would turn a rate limit on a token-code review into a non-retryable auth) to auth, and the message LEADS with codex's line and the fix, because codex prints its banner and the whole echoed prompt on stderr before the error (codex builds its own result; it never passes through toErrorResult, so its message is not capped - an earlier version of this note claimed it was). The fix itself is #15. And codex.health() was hardcoded {ok:true} while antigravity.health() checked only that the bridge object existed, so the panel kept listing peers with no CLI or no credential and every /consensus round paid to discover it. Health is now stat-only and real (codexHealth, bridge.cliAvailable), registry.selectFor* takes the unhealthy map and reports unavailable[] with reasons, and panel / ask-all / consensus / consensus-step all consult it before dispatch. The Gemini bridge's boot process.exit(1) when agy is missing stays: an honest CONNECTION_CLOSED beats a server advertising tools it cannot serve (#13), and the unified server now simply omits gemini. /deliberation:doctor reports all three. Rule of thumb: every ceiling in the process must be at or under the outermost one, or the outermost one is the only one that ever fires - and it is the one that says nothing.

  3. Log in where the answer is needed, and show the code where the user is looking - a Claude Code web container has no browser and must not borrow a laptop's auth.json (#14c), so a ChatGPT Plus user had to type codex login --device-auth in every session. The unified server now builds codex with deviceLogin: true: with no credential, or a spent one, ask() starts codex login --device-auth itself (makeDeviceLogin, one login per process, replaced when its code expires), parses the link and code (parseDevicePrompt - codex's human text, so loose, with the raw output as the fallback), and asks the HOST to show them through MCP elicitation (confirmLogin: URL mode when offered, else a form). That meant three protocol changes in server/mcp/index.js: negotiate the client's protocol version instead of always answering 2024-11-05 (elicitation is 2025-06-18+), record the client's capabilities at initialize, and route replies to server-sent requests (the stdin loop handles a batch's replies before its requests, because a tool call earlier in the batch may be the one waiting). Timing is the whole design. A SessionStart hook cannot do this: a code lives 15 minutes, so a session-start code is spent before the first GPT call and wasted on sessions that never make one. A tool call cannot show output mid-call, so without elicitation the code rides in the RESULT and GPT answers on the next call; dispatch_peers therefore carries an errored voice's bounded message (plainMessage), and the command files say to print it as-is. The wait is bounded by the code's lifetime, 5 minutes, and half the remaining host budget; approving in the browser ends it even if the dialog is never answered; the codex run afterwards gets the budget minus the wait. Review found the traps: the link is the URL with /device in it (an update notice can print another URL first), one code gets one dialog, elicitation is gated on the NEGOTIATED version (capabilities alone are not enough), a login that died is reported without its dead code, the link leads the message so any truncation keeps it, and the anti-phishing line codex prints is carried into both the dialog and the result. A second review round added: a DECLINE kills that login (only a dismissal or silence keeps the code alive, because a device login someone else completes binds this machine's codex to THEIR account, and every later prompt goes to their history), only an openai.com / chatgpt.com URL is ever offered as the link, and an unused code is reaped at expiry by us rather than trusting codex to exit. Round three: no dialog under 15 s of host budget (the 1000 ms budget floor would otherwise open a dialog nobody can answer in time), and a decline that lands after the login completed says so and points at codex logout - deleting auth.json ourselves was rejected, because on the spent-login path that file is the user's own. A separate GPT review then reproduced six more, all fixed: the 20 s no-code timer was never cleared, so EVERY login died 20 s after showing its code (tests that finish in milliseconds could not see it - now promptWaitMs is injectable and CX-login-keepalive waits past it); killing the npm launcher left the native login polling (now a process group, killed as a tree); SIGTERM skips exit hooks and nothing handled stdin closing (now both kill live logins); a pipe chunk ending mid-code made the regex accept ABCD-1234 (tokens now need trailing whitespace; CX-login-split tries every split point); a prompt quoting codex's exact error could start a login (lines that are part of the sent prompt are skipped); and an abandoned dialog stayed open (now notifications/cancelled once no caller waits, and on timeout). Its re-review then caught that the cancellation was dead in production - the composition root's confirmLogin lambda dropped the third argument, the AbortSignal, and each side's unit test passed alone (EL-e2e now drives the real server over stdio with a fake codex via CODEX_BIN) - plus a spent budget read back as a fresh one (spendHostBudget floors at 1 ms because clampToHostBudget treats a non-positive budget as none), the echo filter hiding a genuine error line that the prompt also quoted (each prompt line is now consumed once), shutdown not refusing logins started afterwards, and a decline misreported when credentials landed just before the kill (the auth.json mtime is the truth, not the exit code). A third pass found that baseline recorded per caller instead of per shared login (a caller joining after the approval was saved compared against the new file), and that waiting for codex to print its code was bounded only by the 20 s prompt timer, not by the host budget; both fixed, both reproduced first. Health reports "no credential" as ok while deviceLogin is on, because that is the one gap ask() closes itself; a panel that skipped codex could never deliver the code. Unverified at merge time: whether Claude Code on the web renders elicitation dialogs - the result path covers it either way. The first real web session then showed the design's blind spot: it assumed the agent always MAKES the GPT call. The agent read codex.js, decided a logged-out call "would only return the device-auth link, not an answer", and answered from local config, so the login never started. Fix (chosen via /ask-all, 4/4 agreeing): a codex-login tool (provider.login(), the same signIn without a question, joining the same shared login) and a /deliberation:codex-login command the USER can run without the agent's judgement, plus a "never skip a GPT call because GPT looks logged out" line in the command files and in the ask-gpt / ask-one tool descriptions (read when the call is decided). Rejected: starting a login from panel/health (a probe must stay side-effect-free) and making ask-gpt call codex-login first (one more step to skip, and ask() already logs in). A prompt rule is not a mechanism; a user-runnable command is. Then the first sessions that DID use it measured the last flaw: ms: 300791, exactly the 5-minute dialog cap. The web host advertises elicitation and never answers, so every login waited the full cap before showing a code codex had printed in a second, and burned a third of that code's 15-minute life. /ask-all was 5/5: never await the dialog. It is sent and the call returns; the outcome belongs to the LOGIN (accept lets it land, decline ends it whenever it arrives, a settled login cancels the dialog), and DIALOG_WAIT_MAX_MS / DIALOG_MIN_BUDGET_MS / the half-budget split are gone along with the same-call answer they bought. The same five rejected the tempting fix - prewarming a login at session start - for three reasons: a 15-minute code would be wasted on every session that never asks GPT, device codes are rate-limited, and a server-side prewarm cannot show the code anyway (Claude Code does not render notifications/message). Prewarm saves a second that no longer exists. The message now puts the link and the code each alone on a line: a code the user must retype is a UI, not a log line. Login before the fan-out (reverses one rejection above). Login on first use still lost GPT for the whole first run: /ask-all synthesized without it, and in /consensus a codex voice that errors every round is circuit-broken off the panel, so the user had to remember /deliberation:codex-login at the start of every web session. codex.health() now returns { ok: true, needsLogin: true } (ok, so GPT stays on the panel), panel reports it as needsLogin[] (for: "consensus" for the consensus panel), and /ask-all, /consensus and /ask-gpt gate on that FIELD: codex-login, print the link and code, AskUserQuestion to approve, codex-login again to confirm authenticated, then dispatch. "Making ask-gpt call codex-login first" was rejected above as one more prose step to skip; the difference now is a structured flag the command reads, not a judgement about whether GPT looks logged out. What stays: panel never starts a login (it reads a flag), no SessionStart login (the code starts inside a command the user ran, so it is spent on that run), and the in-call login remains the fallback for a spent auth.json that stat cannot see.

  4. Ground the date in code, leave the lookup to the host - external PR #204 proposed a standalone "mandatory temporal grounding" skill (run date -u, query AWS/Terraform MCP tools, search the web) pasted into AGENTS.md and the host rules. The problem is real - a delegate trained months ago calls a newer model or tool "hallucinated" and files a false critical issue that blocks a consensus round - but prose aimed at the delegates cannot fix it: Grok and OpenRouter run with tools: [], the vendor tool names exist only on some hosts, and persona text is paid on every call times panel size times rounds. /ask-all (5/5) split it by who can act. The date is a FACT only code can supply, so core/grounding.js groundingNote() stamps it, with a one-sentence no-denial rule (unrecognized = [unverified], never "non-existent"), into every prompt builder (Codex runOnce, Gemini buildAgyArgs, Grok and OpenRouter buildInitialTurns) - the builders, not orchestrate.js, because they are the one place every path meets, standalone /ask-* bridges included. The rule ships WITH the date because a date alone still lets a model deny a name it does not know. Retrieval is the host's job: docs/tool-guide.md "Time-sensitive questions", every /ask-* command, /consensus, and the CONTEXT section of rules/delegation-format.md tell the orchestrator to verify and inline facts with an as-of date and source. Personas are unchanged, no skill, no config key. Details in TECHNICAL.md § Date grounding.

  5. The dashboard tails a journal the MCP processes write - the local dashboard (deliberation-mcp dashboard, /deliberation:dashboard) draws each run as a state graph live and after the fact. Every host session runs its own stdio server, so the MCP processes append to a shared journal (core/journal.js, one <runId>.jsonl per run under <XDG cache>/deliberation/runs/, DELIBERATION_RUNS) and the dashboard is a separate process that tails it. Rejected: a localhost socket (events before the dashboard started are lost) and hosting it inside an MCP process (sessions contend for one port, and the view dies with its session). Events come from the one chokepoint every provider call passes (callProvider: call_start/call_end) plus the tool handlers (run_start/state/arbiter/run_end); consensus-step uses its sessionId as the runId, and /ask-all joins its parallel ask-one calls through the runId that panel returns (only ids this process's panel opened). Privacy is two switches: dashboard.capture (metadata default; content adds prompts/responses, scrubSecrets then capText at write, per-kind key whitelist) and dashboard.showPII (PII redacted by core/redact.js at SERVE time, so flipping it needs no rewrite). Journaling never throws into a delegation, the debug log's content ban is unchanged, and the dashboard is read-only (GET/HEAD). Threat model: loopback bind, Host/Origin pinned to 127.0.0.1:<port>/localhost:<port> against DNS rebinding, a per-start 32-byte token in the printed URL swapped for an HttpOnly; SameSite=Strict cookie (browsers send it to every 127.0.0.1 port: Host/Origin checks and nosniff stop a web page from reading data cross-port, but a local service that receives the cookie can replay the token server-side, e.g. curl with Host: 127.0.0.1:<port> plus the cookie or ?t=, and read all dashboard data. Accepted because any local listener is already inside the same-machine trust boundary - the design scope is a single-user laptop; dlb_dash is only issued after the user opens the tokenized URL, and a restart rotates the token. On shared multi-user machines, do not enable the dashboard or capture: "content"), the token kept off argv via a 0600 redirect file, and CSP + textContent-only rendering. Run status is derived at read time (deriveStatus in server/dashboard/runs.js), never cached, because a crashed writer never touches its file again: a run_end wins; a fanout is done when every listed provider's latest call ended, or after 60 s of quiet with the uncalled providers as undispatched, and abandoned (never-dispatched) with no call after 20 minutes; a consensus-step idle past the loop store TTL is abandoned (expired); otherwise the run is live while its writer pid is (pid reuse checked by start time, Linux only). Packaging: prepack copies server/dashboard/ui to dist/dashboard-ui; the circular require (dashboard -> server/mcp/index.js) makes esbuild wrap the entry, so the main guard compares require.main.filename to __filename rather than require.main === module (test/mcp-bundle.test.js). Details in TECHNICAL.md - Dashboard. Analyzer + project + restart. Every run_start carries project (core/project.js: grouped by the git remote's org/repo with a hashed host+path id, falling back to the main git dir when there is no remote, plus the checkout as a hashed workspace ws; stat and read only, never a spawn. The remote is the key because the same repo is often checked out several times (T3 or other tool worktrees, review clones under /tmp), and debugging is per repo first; the raw URL is never stored since an https remote can carry a token. Runs from before ws are regrouped at read time when their root still exists, never rewritten) and every call_start the request size as sent (promptChars/fileBytes, after orientation and consensus prompt building) plus the ceiling the attempt actually got (grantedMs) and where it came from, attributed by ORIGIN (own/shared/outer/host, sharedBy = the slowest SELECTED peer that set a fan-out deadline, dispatched or not): attributing by "which number is smallest" mislabels an ordinary call once setup time makes its own generated deadline the minimum. The Analyzer (server/dashboard/analyzer.js, /api/analyzer) reads only that metadata. Model overlap is consensus-only, because call_end carries no verdict and ask-all answers are never parsed; a drop candidate also needs arbiter decisions (arbiter.decisions[], consensus-step only), so "agrees with everyone" alone never flags a model. Timeout advice treats timeouts as censored data (a success-only p95 understates large requests) and always suggests more than the calls already got. dashboard --stop (server/dashboard/stop.js) signals a pid only when argv position and start time prove it is the dashboard: the token probe proves a listener, not a pid. dashboard-restart.sh and reload-mcp.sh both go through it. The Stats tab still reads the debug log, so it is the one view the project filter does not reach.

  6. Local providers (Ollama & LM Studio) & fail-closed dispatch - local inference via Ollama and LM Studio uses the OpenAI-compatible HTTP bridge with keyless defaults and dedicated provider blocks (providers.ollama, providers.lmstudio). To prevent credential leakage and transport confusion: (a) Provider lookup must fail closed - pinDelegates and resolveArbiter never fall back across transports. An unregistered provider delegate is excluded from dispatch and reported in unavailable[]. An explicit arbiter model targeting an unregistered provider warns and auto-selects from healthy registered providers, returning an error without dispatch only when the pool is empty. (b) Standalone OpenRouter bridge (server/openrouter/index.js) serves provider: "openrouter" records only (model-not-allowed on others), and evaluates keys in strict precedence: per-record apiKeyEnv wins; record with no apiBase or matching or.apiBase (stripping trailing slashes) uses provider key (OPENROUTER_API_KEY); any custom proxy apiBase without apiKeyEnv defaults to keyless (never leaking the provider key). Mid-session replies (openrouter-reply) reuse stored effective endpoint and env var name (secrets are never stored in memory), ensuring config edits mid-session do not reroute existing threads; switching alias or raw model re-resolves from live config. (c) Per-record apiBase/apiKeyEnv remain standalone-bridge-only; the unified server emits a load warning when a record sets them.