Skip to content

Release 0.7.0 - #2361

Merged
Wirasm merged 383 commits into
mainfrom
dev
Aug 2, 2026
Merged

Release 0.7.0#2361
Wirasm merged 383 commits into
mainfrom
dev

Conversation

@Wirasm

@Wirasm Wirasm commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Release 0.7.0

Runtime sub-runs (workflow:), the connected Studio builder, usage accounting you can trust, a repaired curl | bash install path, and a security batch across cloning, transport, and path resolution.

Added

Security

Changed

Fixed


Merging this PR releases 0.7.0 to main.

danielscholl and others added 30 commits May 25, 2026 20:21
* feat(providers): add GitHub Copilot provider configuration types
Define CopilotProviderDefaults with model, reasoning effort, and auth options
Include system message injection and CLI path configuration support

* feat(providers): add GitHub Copilot community provider integration
Implement full provider with session management, streaming, and binary resolution
Include comprehensive test coverage and lazy-load SDK pattern

* feat(providers): add Copilot provider registration and exports
Export CopilotProvider, config parser, and binary resolver utilities
Register Copilot provider in community providers initialization

* test(e2e): add GitHub Copilot provider smoke and abort tests
Include streaming verification, token validation, and interrupt handling
Verify connectivity, output plumbing, and session management

* feat(copilot): add reasoning effort alias and session timeout improvements
Map Archon `max` effort to SDK `xhigh` and extend sendAndWait timeout to 60min
Handle fork-session requests with fresh session creation fallback

* feat(copilot): add environment variable override support and auto model default
Add COPILOT_MODEL env var with envOverrides tracking across config system
Update provider to default model to 'auto' and enhance settings UI

* docs(copilot): clarify session option handling comment

* feat(copilot): add MCP, skills, agents, and structured output support

Implement full Copilot SDK feature translation including tool restrictions,
session config assembly, and best-effort JSON parsing for structured output

* feat(copilot): respect useLoggedInUser to override env token
test(copilot): cover env token precedence and override behavior

* refactor(copilot): remove isCopilotModelCompatible and model-ref
delete model-ref.ts and model-ref.test.ts
update copilot index and registration to drop isCopilotModelCompatible export

* fix(struct-out): enforce object requirement for structured output parsing
return undefined if parsed JSON is not an object
add tests covering non-object JSON in structured output parsing

* feat(copilot): add isExecutableFile check for Copilot binary
implement isExecutableFile using stat/access and use it in path resolution
update errors to reference executable file and chmod guidance

* feat(copilot): add PATH lookup for copilot binary resolution
export resolveFromPath and prefer PATH result when executable

* ci(workflows): migrate and add Copilot CI workflows
- rename e2e-copilot-abort.yaml to test-workflows/e2e-copilot-abort.yaml
- add e2e-copilot-all-features.yaml and relocate smoke workflow to test-workflows

* refactor(shared): centralize structured-output parsing and skills
update providers to re-export shared implementations
expose shared utilities: tryParseStructuredOutput, augmentPromptForJsonSchema

* feat(registry): register Copilot community provider
update registry tests to cover copilot provider registration
verify no collision with built-ins and copilot appears in lists

* feat(copilot): defer session error warning and harden abort flow
update event-bridge to emit no system chunk on session.error
add provider-hardening tests for abort, trim model config and cleanup

* ci(workflow): simplify output capture in e2e-copilot-smoke workflow

* ci(workflows): restructure Copilot e2e workflows for clarity
refactor multiple files into sections for fixtures, demos, and checks

* ci(workflow): remove e2e-copilot-all-features workflow

* feat(workflows): add e2e-copilot-all-nodes-smoke workflow
delete old e2e-copilot-smoke workflow
extend Copilot smoke tests to cover all node types and structured outputs

* refactor(config): remove envOverrides support and COPILOT_MODEL usage
use DEFAULT_AI_ASSISTANT env var to select default ai assistant
update tests and docs to reflect new default and env var usage

* docs: update Copilot docs and env sample

* feat(copilot): implement token precedence for Copilot auth
introduce COPILOT_GITHUB_TOKEN and generic GH tokens; track tokenSource
reorder provider registration to register Pi before Copilot

* fe​at(copilot): improve binary resolution and skill dir validation
use isExecutableFile for vendor and autodetect checks
validate skill names to reject absolute or traversal paths

* fix: address review feedback on Copilot community provider

- Add packages/providers/src/shared/structured-output.test.ts covering
  augmentPromptForJsonSchema, the happy-path clean parse, fence stripping
  (both ```json and bare ```), the forward-brace scan recovery for
  reasoning-model prose preamble, fence + preamble combo, whitespace
  trimming, invalid JSON, empty input, and the bare-primitive rejection
  contract (null/number/string/boolean).
- Add packages/providers/src/shared/skills.test.ts covering empty/null
  inputs, non-string and empty-string skipping, missing skills, cwd vs
  home resolution order, cwd-shadows-home semantics, deduplication, and
  the name-only contract (rejection of absolute paths, nested paths,
  and parent traversal). Uses a staged temp HOME so reads are isolated.
- Wire both new test files into packages/providers/package.json so they
  run in CI as separate bun test invocations.
- Add `copilot` to the registered-providers list in the validation
  error example at guides/authoring-workflows.md, add a Copilot bullet
  to the Model strings section, and add an AI Providers -- Copilot
  env-var subsection plus DEFAULT_AI_ASSISTANT enumeration to
  reference/configuration.md.

The two duplicate-import HIGH findings from the May 14 review were
hallucinations — the imports don't exist in the current branch — so
they need no fix.

* chore(rebase): resolve semantic conflicts from dev

- Update loadMcpConfig import to ../../mcp/config — #1459 (Codex MCP
  nodes) extracted it out of claude/provider.ts into its own module.
- Regenerate bun.lock from current dev (configVersion: 1). Old commits
  on this branch carried configVersion: 0; rebased forward unchanged
  but produced different transitive resolution on install (telegram
  markdown tests fail locally despite identical telegramify-markdown
  pin). bun install re-adds @github/copilot-sdk on top of the fresh
  lockfile.

* test(copilot): address CodeRabbit feedback on shared/skills tests

- Stage the home copy of `delta` in `.agents` (not `.claude`) so the
  "prefers cwd over home" precedence test actually verifies precedence
  within `.agents`. Previously the home copy was in `.claude`, which
  could not have beaten the cwd `.agents` copy regardless of the
  resolver's behavior.
- Add explicit return types on `makeFakeWorld` and the inner
  `stageSkill` to satisfy the project's strict TS annotation rule.

* fix(providers): address remaining Wirasm review items

- pi/event-bridge.ts: consolidate the `export-from` + `import-from`
  pair on shared/structured-output into the idiomatic
  `import { X }; export { X };` form. The preceding comment already
  promised "import once for local use and re-export" but the prior
  order said the opposite.
- authoring-workflows.md: add `copilot` to the prose listing of
  registered providers (the example validation error string below it
  already includes copilot).

* chore(copilot): drop stale "Claude's loadMcpConfig" attribution

#1459 (Codex MCP nodes) extracted loadMcpConfig out of
claude/provider.ts into a shared mcp/config.ts module. Update the
applyMcpServers docblock to reflect that the helper is shared, not
Claude-specific.

---------

Co-authored-by: Daniel Scholl <daniel.scholl@microsoft.com>
Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
…1384)

* feat(providers): add OpenCode community provider with correct capabilities

- Add OpenCode provider using @opencode-ai/sdk
- Support both embedded server and external server modes
- Implement session resume, MCP, structured output, env injection
- Correctly declare capabilities: hooks, skills, agents, toolRestrictions,
  effortControl, thinkingControl all supported
- Add model/agent validation (one required)
- Include E2E smoke workflow and registry tests
- Update docs with auth guidance and feature table

* feat(providers/opencode): remove agent field - use Archon's own agent impl

Archon has its own agent implementation and should not delegate to
OpenCode's agent profiles. Removed the agent field from:
- OpencodeProviderDefaults interface
- parseOpencodeConfig parsing
- streamOpencodeSession function
- Updated capabilities to agents: false

Model is now required (no agent fallback).

* feat(providers/opencode): enable agents support with adaptation layer

- Flip agents capability from false to true
- Add agent adaptation layer that maps nodeConfig.agents to OpenCode API:
  - Agent selection by sorted key order
  - Model override from agent config
  - Tools permissions map (deny wins)
- Add 4 tests for agent adaptation behavior
- Update smoke test to verify agent field works

* fix(providers/opencode): address PR review feedback

- Fix assert node to fail with exit 1 when pattern not found
- Set effortControl/thinkingControl to false (not wired to SDK)
- Replace generic 'terminated' with specific crash patterns
- Add TODO for health endpoint (SDK limitation)
- Fix race condition in releaseEmbeddedRuntime
- Call iterator.return on abort in abortableStream
- Tighten isOpencodeModelCompatible validation
- Add agent field to OpencodeProviderDefaults type

* fix(providers/opencode): address Oracle validation issues

- Fix race condition: capture runtime instance at acquire time
- Add agent field parsing in parseOpencodeConfig
- Tighten isOpencodeModelCompatible to trim whitespace
- Update registry test for effortControl/thinkingControl

* fix(providers/opencode): address all CodeRabbit review feedback

- Replace session.create() health check with global.health() (stateless)
- Yield terminal result chunk when stream ends before session.idle
- Move comment under agent: field in ai-assistants.md
- Change 'Inline sub-agents' support to ⚠️ Partial
- Preserve insertion order in selectPrimaryAgent (remove .sort())
- Remove redundant nodeConfig argument from streamOpencodeSession
- Preserve error structure in session.error handler (err.cause)
- Consolidate model-ref validation (parseModelRef in registration.ts)
- Update test mocks to include global.health()

* fix(providers/opencode): address latest CodeRabbit review feedback

- Add warning when multiple agents configured (first wins)
- Add 2s timeout to global.health() probe
- Add TODO for skipped abort test
- Consolidate imports in registration.ts
- Fix TypeScript error: use deferred pattern for creationPromise

* fix(providers/opencode): address remaining PR review feedback

- Fix deferred pattern hang: wire both resolve and reject in deferred
  promise so startup errors propagate to callers (3137799074)
- Fix server close leak: decouple server.close() from cache identity
  check in releaseEmbeddedRuntime (3137799084)
- Update TODO reference to follow-up issue #1400 for abort test (3136883117)

* fix(providers/opencode): use direct HTTP fetch for health check

The SDK's global.health() method only exists in v2, but we import from
the root SDK which uses the old client. Switch to direct HTTP fetch to
/global/health endpoint for checking existing servers.

- Remove global.health from OpencodeClientLike interface
- Use fetch() directly with 2s timeout for health check
- Update tests to mock fetch for health check scenarios

* fix(workflows): bash quoting for linux compatibility

* refactor(providers/opencode): decompose provider into focused modules

Extract runtime, session, multi-agent, agent-config, agent-fs, and error
handling into separate files to reduce provider.ts complexity. Add inline
multi-agent e2e workflow and expand test coverage.

* Self AI Review suggestion.

* chore: update opencode e2e smoke test with hooks coverage + refresh docs

Add hook-node to e2e smoke workflow covering PreToolUse/PostToolUse hooks
(10 node types total). Switch smoke model to cpamc/minimax. Remove
deprecated baseUrl option and refresh feature support table in docs.

* chore(providers/opencode): improve abort error logging and multi-agent e2e workflow

* test(workflows): use default model for opencode e2e tests

Switch from cpamc/minimax to opencode/big-pickle (provider default)
for general e2e testing of OpenCode provider.

* fix: match homebrew formula to upstream/dev

* fix(providers/opencode): address code review findings

- Add CHANGELOG.md entry for assistants.opencode provider (#1703)
- Elevate silent debug catches to warn level with context (session, multi-agent, runtime)
- Preserve error cause chain in retry loop (provider.ts)
- Include retry count in final throw message
- Fix doc typo: cofnig -> config
- Update CLAUDE.md monorepo layout with community/opencode/

* chore: align SDK versions with origin/dev

* version downgrade fix.

* Add opencode-ai sdk

* fix: enable abort test and remove redundant isModelCompatible

- Enable skipped abort test with deterministic setTimeout timing
- Remove unused isOpencodeModelCompatible function from registration
- Remove isModelCompatible test from registry tests
- Update bundled defaults with archon-four-role-loop workflow

* chore: regenerate bun.lock to sync with package.json after rebase

CI was failing on 'lockfile had changes, but lockfile is frozen' — the
lockfile was missing the overrides entries (@hono/node-server, flatted,
follow-redirects, path-to-regexp, qs) and had a stale @archon/providers
version (0.3.9 → 0.3.12) after rebasing onto current dev.

Net diff: +11/-8 in bun.lock, no source changes.

* chore: regenerate bundled defaults to sync with current commands state

CI failed on 'bundled-defaults.generated.ts is stale' after the lockfile
fix unblocked the install step. The generated file was 1 line out of date
relative to current dev's command set (drift from rebases). Functional
diff is +1/-2 (a single trailing-newline difference in one embedded
command); full diff is large only because the file inlines all commands
as TypeScript strings.

This is mechanical — produced by 'bun run generate:bundled' with no other
changes.

---------

Co-authored-by: cropse <cropse0219@gmail.com>
Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
* fix: coalesce transient chat status updates

* feat(web): improve streaming thinking and tool readability
…es (#1523)

When a workflow run is approved/rejected via the Web UI but
`tryAutoResumeAfterGate` cannot auto-resume — because there is no
`parent_conversation_id`, the parent conversation is gone, or the parent
sits on a non-web platform (Slack/Telegram/GitHub/CLI) — the success
message said only "Send a message to continue" / "On-reject prompt will
run on resume". A web-UI user whose run originated from a terminal has
no obvious next step from that text and the run sits in `failed` status.

Both approve and reject (on_reject branch) now include the exact
`archon workflow resume <runId>` command in the non-auto-resumed
response, so the web-UI surface always carries an actionable next step.

The auto-resume happy path and the no-on_reject cancellation path are
unchanged. The Resume endpoint's CLI hints (covered by #1329) are not
touched.

Closes #1522.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* experiment(console): scaffold primitives-first web UI spike at /console

Greenfield spike of Archon's web UI built around four primitives — Project,
Run, Workflow, Worktree — to validate a simpler mental model before any
migration. Lives under packages/web/src/experiments/console/, mounted at
/console/* outside the shared Layout so it does not inherit the production
TopNav. ESLint no-restricted-imports scope forbids coupling to @/components,
@/contexts, @/hooks, @/routes, @/stores, @tanstack/react-query so the spike
stays extractable or disposable.

Surface:
- Project rail (Discord-style 44x44 tiles with deterministic hashed colors)
  with ALL scope toggle, remove-project via right-click, Add Project dialog.
- Runs view split into Active (rich cards for running/paused, pulsing blue
  LiveDot for running + amber for paused) and Recent (compact monospace rows
  for completed/failed/cancelled). Attention model: running is attention,
  completed is audit trail.
- DraftRunCard — inline "start a run" primitive that lives at the top of
  the Active list. Collapsed = thin + Start a new run row; expanded = full
  card with workflow picker + context textarea. Same shape as a paused
  approval card; N keybind expands.
- ApprovalPanel with ApprovalContext preview — shows the actual last
  agent message so users see the question being asked, not just the gate
  label. Supports capture_response gates and traditional approve/reject.
- Run detail page — header with live-ticking elapsed, StreamToolbar with
  Tool calls / System / Graph toggles persisted to localStorage, stream of
  StreamCards (message / tool / artifact / node_transition), state-
  sensitive ActionBar (cancel/resume/abandon/re-run). Relative timestamps
  (+MM:SS from run start) via a small StreamContext provider.
- RunGraphPanel sidebar — dagre TB layout, parallel nodes side-by-side,
  loop/approval/bash/command/script/prompt glyphs, status-derived from
  node_transition events, click a node to scroll-into-view.

Skill API (packages/web/src/experiments/console/skills/) is the single
mutation surface: listProjects/getProject/addProjectBy{Url,Path}/
removeProject/listWorkflows/getWorkflowGraph/listWorktrees/listRuns/
getRun/startRun/cancelRun/approveRun/rejectRun/resumeRun/abandonRun/
listMessages. Every UI action calls exactly one verb; internal
orchestrators (CLI, Claude Code skill, future LLM driver) hit the same
contract. startRun hides the legacy conversation coupling as a two-call
createConversation -> runWorkflow sequence.

State layer (store/cache.ts) is a Map + subs + useEntity hook. No React
Query, no Zustand, ~100 LOC. Polling fallback every 3s until SSE lands.

Warm theme scoped to .console-root (theme.css) — espresso surfaces,
tangerine accent reserved for CTAs, ocean-blue running, teal-green
completed/approve, amber paused, warm rose-red failed. Production theme
untouched.

Preview route at /console/_preview renders every status, every origin,
swatches for each token.

Milestones done: M1 scaffold, M2 skill+store+populated feed, M3 run
detail + event stream, M5 DraftRunCard, M3 polish (sticky toolbar,
compact tool cards, relative timestamps, empty/system filter, compact
user chips, graph sidebar). Pending: M4 SSE live updates, M6 polish.

* experiment(console): widen project rail with editable title + locator, fix invalidate-without-reload

Rail goes from 44x44 abbreviation tiles to a 240px sidebar of two-line rows:
small color dot + title + monospace locator (owner/repo from a git URL, last
two path segments otherwise). Title is editable per-project — double-click to
rename, Enter saves, Esc cancels, blank reverts to the API name. Override
persists in localStorage (console:displayName:<id>) via a small useDisplayName
hook so the spike stays self-contained. Right-click still removes.

Also fixes a latent bug in store/cache.ts: invalidate() and refetch() cleared
the cache and notified subscribers but never re-ran the loader, so add/remove
project and the run-action / approval flows all required a page reload to
reflect new state. useEntity now registers its loader and ensureLoad() refires
it on any cleared key that still has an active subscriber.

ProjectTile is left in place — still used by /console/_preview.

* experiment(console): fix startRun — pass platform id to dispatch, recover run id by polling

Two bugs were preventing workflows from launching from the spike:

1. The dispatch call was sending conv.id (DB UUID) where the route looks the
   conversation up via findConversationByPlatformId. The lookup silently
   returned null, the orchestrator dispatched against an unknown reference,
   and no workflow_run was ever created. Fix: pass conv.conversationId (the
   web-<ts>-<rand> platform id) to /api/workflows/:name/run. Keep conv.id (the
   DB UUID) for the parent-conversation match in the recovery step.

2. POST /api/workflows/:name/run returns { accepted, status } — never a run
   id, since the workflow_run row is written asynchronously inside the
   orchestrator after the HTTP response returns. The old extractRunId() always
   threw. Replace with pollForRun(): fetches /api/dashboard/runs filtered by
   codebaseId, matches on parent_conversation_id === conv.id, returns the
   first hit. Bound at 30s / 400ms interval to absorb cold-start worktree and
   isolation-env setup; timeout message points users to the active list since
   the run is almost certainly already running by then.

* experiment(console): make startRun optimistic — dispatch and let the runs feed surface the new run

Submit-button no longer blocks for up to 30s while the orchestrator spins up
worktrees and isolation envs. startRun now does just the two dispatch calls
and returns; the workflow_run row appears in the active list as ambient runs
polling picks it up. DraftRunCard fires an immediate invalidate('runs') after
dispatch to nudge the next refetch instead of waiting up to 3s for the next
poll tick.

Drops pollForRun + the runId return value — callers were the navigate-to-run-
detail path only, which traded one bad UX (30s spinner) for another (forced
context switch away from the runs list right after starting). The active card
that appears within a few seconds is a better affordance.

* experiment(console): port to Archon brand foundation — duotone gradient + Geist

Replace the warm espresso/tangerine palette with the cool charcoal +
brand-magenta-to-teal duotone from the Archon brand standalone. All changes
remain scoped under `.console-root` so the production /app surface is
untouched.

theme.css
  - Surfaces shift hue 40° → 265° (warm → cool charcoal)
  - Accent tokens point at --brand-magenta; --success uses --brand-teal so
    affirmative reads as brand
  - --brand-gradient + .brand-text / .brand-bar / .brand-bar-soft utilities
    added (gradient-soft is the translucent wash used for selected states)
  - --accent-ring set to 30% alpha magenta, matching the brand spec
  - Geist + Geist Mono loaded from Google Fonts on console route mount
    only; .console-root font-family override + higher-specificity .font-mono
    rule beat Tailwind v4's @theme inline literal

Components
  - ConsoleApp wordmark: .brand-text on "Archon"
  - DraftRunCard: 4px gradient strip as an absolute child (keeps the card's
    overflow:visible so the workflow picker dropdown can escape); Start run
    button background is the duotone bar
  - FilterChips: active filter shows a 2px gradient underline pill
  - ProjectRail: ALL projects pill now uses brand-bar-soft instead of the
    chunky 2px ring with offset

* experiment(console): brand the run detail page

The first brand pass cascaded surfaces + accents into the detail view via
tokens but never threaded the gradient itself through, leaving the timeline
visually flat. This adds three brand moments:

RunDetailHeader
  - 1px brand-gradient strip along the bottom edge (replaces the flat
    border-border line) anchors the detail view in the same way the
    DraftRunCard strip anchors the runs feed
  - Run id renders with .brand-text so the focal piece of mono data carries
    the duotone

StreamCard
  - YOU pill: accent-soft background + brand-magenta text
  - AGENT pill: success-soft background + brand-teal text
  Role pills now read as the brand duotone across every exchange — magenta
  for user (presence/authorship), teal for agent (execution/affirmative)

RunGraphPanel
  - Same 1px gradient strip under the GRAPH label; bumps the label color
    from tertiary to secondary so the panel header doesn't disappear

theme.css
  - Adds --running-soft / --success-soft / --warning-soft / --error-soft
    translucent companions for status colors (StreamCard now consumes
    --success-soft; the others are there for symmetry)

* fix(experiment/console): surface tool calls from workflow_events

Two independent bugs caused tool calls to never render on the run detail
page despite the toggle being on.

primitives/event.ts
  - Server emits `tool_called` / `tool_completed`; the normalizer matched
    `tool_started` (a name that's never written). Result: 43 tool_called
    events fell through to the text-fallback branch and rendered as
    junk-string placeholders elsewhere
  - Field names were also wrong: read `toolName` / `args` / `durationMs`
    instead of the snake_case `tool_name` / `tool_input` / `duration_ms`
    actually present in the JSONB payload, so the few tool_completed
    events that did match the branch produced empty entries that
    downstream filters dropped

components/RunStream.tsx
  - Even with the normalizer fixed, RunStream explicitly skipped
    `tool_call` events under the assumption that conversation metadata
    is canonical. That's true for Claude (the SDK persists into
    message.metadata.toolCalls) but false for Pi / Codex / bash nodes,
    which only emit workflow events. Now: if no message carries inline
    tool calls, the paired workflow tool events are surfaced instead.
    Pairing matches each tool_called to the next unclaimed tool_completed
    in the same step so the duration shows correctly.

routes/RunDetailPage.tsx
  - Toolbar `toolCallCount` mirrors the same source-of-truth rule so the
    "X tool calls" header counts the rendered events, not just the
    (empty) inline metadata

* fix(experiment/console): tab bar for Log/Graph, wire System toggle, fix subfoldered workflow 404

Detail page now has a Log / Graph tab pair instead of a fixed-width log
with an optional right-rail graph. Both views get the full main content
area (next to the project rail); switching between them is a toggle, not
a side-by-side compromise.

StreamToolbar
  - Hosts the tab pair (Log / Graph) on the left with the gradient
    underline indicating the active tab
  - "X messages · Y tool calls" + Tool calls / System checkboxes only
    render when the Log tab is active — irrelevant in Graph view

RunGraphPanel
  - Drops the fixed 420px aside chrome; renders as full-width content
  - Bigger node dimensions (160×40, 56/20 sep) for the larger canvas
  - Returns to centered overflow-auto when content exceeds viewport

RunDetailPage
  - `view: 'log' | 'graph'` state persisted to localStorage
  - Layout switches single-view; Log view drops the 820px max-width so
    the stream uses the full main area
  - Clicking a graph node switches to Log and scrolls to that node's
    transition

System toggle (the second half of the fix)
  - workflow_started / workflow_completed / workflow_failed were
    falling through to the text-fallback branch, rendering as junk
    `workflow_started — {payload}` strings
  - Added SystemEvent kind + explicit branch in `toRunEvent`; surfaced
    in RunStream as compact rows behind the System toggle
  - Error events also flow into the same system bucket

Graph 404 fix
  - The single-fetch `/api/workflows/:name` endpoint doesn't recurse
    into `.archon/workflows/<subdir>/`; subfoldered workflows like
    `maintainer/maintainer-review-pr.yaml` were unreachable
  - `getWorkflowGraph` now goes through the list endpoint (which does
    recurse) and filters by name. One extra row of JSON, but the graph
    now resolves for every workflow Archon knows about

* feat(experiment/console): live updates via SSE, drop 3s polling

Replaces the per-page 3s setInterval polling loops in RunsPage and
RunDetailPage with subscriptions to the server's existing SSE streams.
Events flow through the existing cache: an SSE message invalidates the
relevant cache keys, useEntity refetches authoritative state, the UI
re-renders. No partial in-memory event-payload merging — keeps the wire
shape decoupled from React state.

lib/sse.ts
  - useDashboardSSE subscribes to /api/stream/__dashboard__ and
    invalidates runs:* (and run:<id> if the event carries a runId) on
    workflow_status / dag_node events. Mounted from RunsPage.
  - useRunStreamSSE subscribes to /api/stream/<conversationPlatformId>
    and invalidates run:<id> + messages:<convId> on text / tool_call /
    tool_result / workflow_* events. A 100ms coalesce timer dedupes
    bursts from streamed text. No-ops while the conversation id is
    still null (e.g. before the run detail loads).

RunsPage
  - Drops the 3s setInterval that re-fetched listRuns; calls
    useDashboardSSE instead.

RunDetailPage
  - Drops the 3s setInterval that re-fetched getRun + listMessages;
    calls useRunStreamSSE with the platform conversation id.

EventSource auto-reconnects on transient failures, so no explicit
recovery logic is needed; permanent close happens at unmount.

* feat(experiment/console): make System toggle reveal real diagnostic content

The toggle was technically working but only added two thin rows
(workflow_started / workflow_completed) for Pi-driven runs that lack
system-role messages. Functional but invisible. This pass turns it into
the framework-chatter view it should always have been.

What System now reveals
  - Workflow lifecycle: workflow_started / workflow_completed /
    workflow_failed (existing, now styled to stand out)
  - Skipped-node reasons: when a node is skipped, an inline second line
    on the NodeDivider shows `reason when_condition · expr ...` — catches
    DAG-branching surprises without making the user open the YAML
  - Workflow dispatch metadata: assistant messages with
    `category: workflow_dispatch_status` (carrying a workflowDispatch
    blob) now collapse into a compact 'Workflow dispatch' system row
    displaying the workflow name, instead of being rendered as agent
    prose. Same for any message whose metadata.category starts with
    workflow_ or system_
  - Empty / no-signal messages: previously dropped by isMeaningful();
    now surface as 'Noise' rows so the timeline is gap-less and SDK
    plumbing chatter is visible

Styling
  - System rows now use brand-teal for the pill label + a translucent
    teal top hairline (instead of a flat charcoal border on all sides).
    Border colors land via inline style because the console's
    .console-root * { border-color: var(--border) } rule outweighs
    Tailwind utility-class color in the cascade; this finally makes
    border-success/30 and friends paint the intended hue too

Cleanup
  - StreamCard kind styles now own their full border (width + sides +
    color) rather than splitting between the base class and a partial
    override
  - message.ts exports isSystemCategory + WorkflowDispatchMeta so
    RunStream can keep the rendering decision local
  - event.ts NodeTransitionEvent carries skipReason + skipExpr;
    NodeDivider accepts them and renders only when showDetail is true

* feat(experiment/console): cost on cards + reject-with-reason expander

Cost on cards
  - Read `metadata.total_cost_usd` into a typed `Run.costUsd: number | null`
  - formatCost picks precision by magnitude: $24.35 / $0.023 / $0.0082
  - Surfaces on RecentRunRow (between elapsed and origin badge), on
    ActiveRunCard (between origin and elapsed), and on the run detail
    header (between origin and elapsed). Hidden when null
  - typeof === 'number' guard so demo runs without the field don't blow
    up at .toFixed()

Reject-with-reason
  - ApprovalPanel now has two distinct flows instead of one shared field
    + Approve / Continue: one click, single-line input above for an
      optional comment captured as $<node-id>.output
    + Reject: two-step. First click reveals a 3-row textarea with a red
      "REASON FOR REJECTING · REQUIRED" label; confirm only enables
      with non-empty text
  - Cmd+Enter confirms reject, Esc cancels back to idle
  - Reduces accidental rejects (which previously fired on any click of
    a single button when the input happened to be non-empty) and makes
    the reviewer's reasoning explicit and unavoidable

* feat(experiment/console): per-project env vars dialog

A gear icon on each project row in the rail (visible on hover / always
on the selected row) opens an EnvVarsDialog modal that lists, adds, and
removes per-project environment variables. Wires straight into the
existing GET/PUT/DELETE /api/codebases/:id/env endpoints.

Design notes
  - The server never returns values, only keys — the UI mirrors that
    constraint (no "reveal" affordance, no edit-in-place). To rotate a
    secret the user adds a new value at the same key; the server
    overwrites
  - Key input auto-uppercases for the conventional ENV_VAR_NAME look;
    value input uses type=password so it doesn't shoulder-surf
  - Cache invalidates on every dialog open so external edits (CLI, other
    web sessions) show up — without it the in-memory cache pinned the
    stale empty list across close/reopen
  - skill.listEnvVarKeys / setEnvVar / deleteEnvVar live in a new
    skills/envVars.ts module, exported through skills/index.ts to match
    the existing skill-verb surface

* feat(experiment/console): artifact tab with sidebar + viewer

Adds a third tab on the run detail page that lets you browse and read the
files a run wrote to disk — the new go-to surface for plans, reports, PR
diffs, and synthesis docs that workflows produce as their actual output.

Server: GET /api/runs/:runId/artifacts
  - Walks the run's artifact directory (recursively, dotfiles skipped)
  - Returns { files: [{ path, size, modifiedAt }] }
  - Needed because workflow_artifact events are empty for nearly every
    run we have — bash/script nodes write straight to $ARTIFACTS_DIR
    without emitting an event, so an event-driven file list shows nothing
  - Reuses the same owner/repo derivation + path-escape guards the
    existing /api/artifacts/:runId/* handler uses

Client: ArtifactPanel
  - 260px sidebar lists every file with size + parent-dir hint; clicking
    a row loads it into the main viewer
  - Viewer renders .md / .mdx through react-markdown + GFM + rehype-
    highlight (same stack the old UI used), everything else as
    pre-formatted monospace text
  - Auto-selects the first file on mount so the tab isn't empty
  - "open raw ↗" link in the file header for downloads or PR pasting
  - Empty-state copy points at $ARTIFACTS_DIR so users understand what
    fills the panel

StreamToolbar
  - Tabs now accept an optional count; Artifacts shows it ("ARTIFACTS 7")
    so users can tell at a glance whether a run produced anything

RunDetailPage
  - The artifact-list useEntity is hoisted above the early returns so
    React's hook order stays stable (the obvious-in-retrospect bug that
    hit the first attempt — early returns after running detail-related
    hooks meant the artifacts hook didn't fire on the loading render)
  - Cache key is K.artifacts(runId), shared between the tab badge and
    the panel so navigating to the tab doesn't refetch

* feat(console + server): file upload on DraftRunCard

Server
  - /api/workflows/:name/run now accepts multipart/form-data alongside the
    existing application/json. conversationId + message + files[] (max 5,
    ≤10 MB each). Body schema dropped from the OpenAPI route config so
    @hono/zod-openapi doesn't try to validate multipart against the JSON
    shape — same pattern sendMessageRoute uses. Handler manually branches
    on content-type
  - persistUploadedFiles helper lifted out of sendMessageRoute so both
    routes go through the same validate-write-rollback logic. Returns
    either { ok: true, savedFiles, uploadDir } or a structured error the
    caller forwards via apiError. sendMessageRoute is untouched for this
    pass; could be refactored to use the helper later
  - extraContext.attachedFiles + filesToCleanup are passed straight to
    dispatchToOrchestrator so cleanup happens inside the lock handler,
    after handleMessage completes — matches the freeform-message flow

Client
  - skill.startRun gains an optional files: File[]. With files, posts
    multipart (browser-set boundary); without, keeps the JSON path
  - DraftRunCard handles three input paths the chat input has always
    handled: drag-and-drop on the whole card, paste of clipboard images
    inside the textarea, and a paperclip button that opens the file
    picker. Same MAX_FILES=5 and MAX_FILE_BYTES=10 MB caps the server
    enforces, surfaced as inline errors
  - File chips render above the start row with name + size + remove (X).
    Drag-over shows a brand-gradient-soft overlay with a "drop files to
    attach" pill so the affordance is obvious without persistent chrome
  - Collapse / submit both clear the file list so reopening the card
    starts clean

* feat(experiment/console): open-in-IDE, rerun, SSE-drop safety net

Three tier-2 affordances that each accelerate the iteration loop without
adding chrome.

Open in IDE
  - vscode://file/<workingPath> button on ActiveRunCard (hover), every
    RecentRunRow (hover), and RunDetailHeader (always visible)
  - Hidden when /api/health reports is_docker=true. The first request
    defaults isDocker to true so a flash of broken links inside Docker
    never happens — matches the old UI's safer default
  - new lib/health.ts exposes useIsDocker() (cached via useEntity on the
    'health' key so all callers share one fetch) and openInIde(path)
    which normalises backslashes on Windows paths the same way the old
    Header.tsx did

Rerun
  - ↻ button on completed/failed/cancelled RecentRunRows. Navigates to
    /console/p/<id>?rerun=1&workflow=<name>&message=<userMessage> with
    URLSearchParams so spaces / unicode survive
  - DraftRunCard watches searchParams: when rerun=1 arrives (whether by
    fresh mount or by within-component navigation) it expands the card,
    fills the workflow picker + textarea, then strips the params via
    setSearchParams(..., { replace: true }) so a reload doesn't re-fire
  - Deliberately depends on [searchParams] not [] — the rerun click
    typically lands while DraftRunCard is already mounted (same project
    route, search-param-only change). The empty-deps version was the
    bug that made the first attempt look like nothing happened

SSE-drop safety net
  - 30s setInterval on RunDetailPage that invalidates K.run(runId) +
    K.messages(convId) while status is running or paused
  - Stops automatically the moment status flips terminal, so it's not
    polling proper — just a heartbeat refetch that catches dropped SSE
    streams (network hiccup, mobile sleep/wake) without us noticing
  - Replaces nothing — the existing useRunStreamSSE keeps streaming
    when the connection is alive; this is purely a "if we missed the
    terminal event, find it within 30s" insurance

* fix(experiment/console): project rail — selection visible, identity vs status, real path locator

Five compounding issues in the project rail, all addressed.

1. Routing param read (the load-bearing bug)
   ProjectRail mounts outside the inner <Routes> (it's sibling to <main>
   in ConsoleApp), so useParams() returns {} for it. `scope` was
   always 'all'; the ALL PROJECTS button was always aria-pressed=true;
   the selected ProjectRow never received selected:true and therefore
   never showed the ring or background. Fix: useLocation() + a regex
   pull on `/console/p/:id`.

2. Selection is now unmistakable
   Each row paints a 4px brand-gradient left strip + bg-surface-elevated +
   brighter title when selected. Replaces the magenta ring (which was
   invisible against the dark inset background even when it did fire).
   The gradient strip rounds at the corners via rounded-l-md so we
   don't need overflow-hidden on the row — which had been clipping the
   ⋯ menu dropdown.

3. Identity vs status disambiguated
   The hash-coloured dot was identity (project tile color) but read as
   a status indicator. Replaced with a 20×20 rounded square showing the
   project's first letter on the hash-coloured background — clearly a
   "this is which project" affordance, can't be confused with status.

4. Activity status, when it exists
   Right-side dot is now real: pulsing blue when the project has a
   running run, pulsing amber when paused, solid red when only failed
   runs are recent. Idle projects show nothing. Sources data from the
   shared K.runs('all') cache (so the dashboard SSE invalidation we
   already have keeps it live; no extra fetch). Priority: running >
   paused > failed-only, so a project with one running and one failed
   run reads as "running", not "broken".

5. Locator below the name = the actual local path
   formatProjectLocator now returns `~/path/to/project` (homedir
   shortened). The old `owner/repo` derivation was identical to the
   project name for github projects, so the row read as duplicated
   text. After rename, the path stays as a stable identity anchor —
   which is what the user wanted: "rename a project but still show the
   path below."

Bonus fixes
   ALL PROJECTS button: same selection treatment as project rows
   (strip + elevated bg), sentence case label ("All projects"), uses
   an `∗` avatar in a small square — visually consistent with rows.

   Remove project is now discoverable: ⋯ menu button on hover (always
   visible on the selected row), opens a small dropdown with "Remove
   project". Right-click still works for power users and now also
   opens the same menu.

   Add project hover treatment normalised to border-bright/surface-hover
   to match the rest of the rail (used to be magenta).

* refactor(experiment/console): drop avatar + activity dot from project rail

Both added noise more than signal:

  - The first-letter avatar carried no information for owner/repo names
    (we were rendering the owner's first letter). Removed it entirely
    rather than try to derive something cleverer
  - The right-side activity dot lit up red for any project with a
    failed run in recent history. That's a thing that happened, not
    something the user needs to act on from the rail. Removed

The rail row is now: optional gradient strip when selected, title,
path subtitle, hover actions (gear + ⋯). Selection is still
unmistakable via the brand strip + elevated background + brighter
title color. Width is reclaimed for the path (Widinglabs/sasha-demo's
full ~/Projects/mine/sasha now fits where it was truncated before).

Also drops the matching ∗ avatar from the "All projects" row for
consistency, and the K.runs('all') fetch + deriveActivityByProject
helper that only existed to feed the now-gone status dots.

* feat(console + old ui): real logo, drop spike chrome, cross-UI switch buttons

Console header
  - Replace text-only "Archon" with the actual shield mark from
    packages/web/public/favicon.png (the existing brand mark) +
    gradient wordmark
  - Drop the "spike" badge — the experiment is real enough now; the
    "console" tag stays as a "this is a separate surface" hint
  - Drop the stray "m2 populated" telemetry text in the right slot;
    replaced with a small "← Old UI" link so users always have an
    escape hatch back to the classic chrome

Old UI TopNav
  - Add a gradient "Try the new console →" CTA between the last tab
    and the version readout. Inline-styled with the brand
    magenta → violet → teal gradient because the old UI's token set
    doesn't include the brand-gradient variables (those live in the
    console-scoped theme.css)
  - Sized to read as a primary CTA without dominating the nav. Arrow
    nudges 2px on hover for an inviting affordance

* tweak(old ui): rename console CTA to 'Try the new console UI'

* fix(experiment/console + server): satisfy validate suite after rebase

Type-check
  - Demo run factories in RunsPage and PreviewPage now include
    costUsd: null so the test fixtures match the Run type that was
    extended with the new cost field
  - startRun's HttpError throw on multipart failure now passes the
    URL path as the 2nd arg (HttpError takes status/path/body) so
    the upload-error path constructs correctly

Server test
  - /api/workflows/:name/run only forwards the message metadata 4th
    arg to addMessage when files are present, so the JSON path keeps
    the 3-arg signature the existing api.workflow-runs.test asserted

Format
  - prettier --write on eslint.config.mjs and theme.css

Telegram-markdown blockquote tests are 3 pre-existing failures on dev
(verified by checking out dev's adapters/ before the run) — unrelated
to this PR's scope.

* fix(console): correct silent invalidate + recover errored entries (C1+C2)

The cache's invalidate(prefix) checked `key === prefix || key.startsWith(`${prefix}:`)`
so passing 'runs:' looked for 'runs::' — three callers (ApprovalPanel
approve/reject, RunActionBar cancel/resume/abandon) silently did nothing,
and the runs feed only refreshed on the next SSE event. Drop the trailing
colon at the three sites.

Separately, errored cache entries lived only in the `errors` Map, but
invalidate() walked `cache.keys()` only — so a failed fetch was stuck
until full page reload. Extend the walk to both maps so recovery works.

* fix(server): guard new artifacts route + register OpenAPI (C3+I1+I3+I4)

Convert GET /api/runs/:runId/artifacts from raw app.get() to
registerOpenApiRoute against a typed schema (ArtifactFile +
ListArtifactsResponse in workflow.schemas.ts). The route was the only
recently-added endpoint bypassing the project's OpenAPI rule
(CLAUDE.md L25) without a constraint that justifies it — the response
is plain JSON of a fixed shape. Generated types now include it, so
skills/runs.ts re-exports the schema type instead of maintaining a
parallel hand-written interface (I3).

Other guards on the same handler:
  - I1: defense-in-depth path-containment check on the resolved
    artifact directory. A maliciously crafted codebase name (`..` in
    owner/repo) would have escaped ARCHON_HOME; now blocked with a
    400 + artifacts.path_escape_blocked log
  - I4: getCodebase() now wrapped in try/catch, mirroring the
    getWorkflowRun() block above it. DB errors produce a logged 500
    instead of an unlogged crash
  - I3: stat() error swallow narrowed — ENOENT/EACCES are skipped
    (file deleted mid-walk, permission flip) but unknown errors now
    propagate to the outer artifacts.walk_failed log + 500 response,
    so we never return a half-list silently

* fix(console): real defects from review (CR-1..CR-5, CR-7, CR-9, I2, I5)

- AddProjectDialog: import FormEvent from 'react' instead of relying on
  the ambient React namespace which isn't actually imported here. Real
  type bug in strict-mode setups (CR-1)
- lib/sse: route EventSource opens through SSE_BASE_URL so dev bypasses
  the Vite proxy. The proxy buffers SSE; bare paths reintroduce the
  buffering useSSE already worked around in the old UI (CR-2)
- DraftRunCard: guard Enter-submit during IME composition. Without the
  e.nativeEvent.isComposing check, Japanese/Chinese/Korean candidate
  selection dispatches the run prematurely (CR-3)
- display-name: wrap localStorage in try/catch. Private-browsing modes
  throw SecurityError and crashed the rail row on mount (CR-4)
- ActiveRunCard: add role/tabIndex/onKeyDown so the card is operable
  with Enter/Space, matching RecentRunRow which already had this (CR-5)
- eslint.config: harden import-restriction patterns. * → ** so nested
  paths (@/components/layout/foo) can't slip past, and the @/lib/api
  restriction now applies to all named imports rather than only the
  default. Generated types from @/lib/api.generated are still allowed
  via a different module path (CR-7)
- NodeDivider: only emit the scroll-anchor id on 'started' transitions
  so multiple transitions for the same node don't produce duplicate
  ids in the DOM. The graph 'jump to node' still works (it lands on
  the entry point, which is the right target anyway) (CR-9)
- primitives/workflow: toWorkflow now preserves 'global' as a distinct
  source. Previously `raw.source === 'project' ? 'project' : 'bundled'`
  silently demoted home-scoped (~/.archon/workflows) workflows to the
  bundled badge + sort rank (I2)
- lib/sse: SSE onerror logs at console.warn when readyState is CLOSED,
  so dropped streams aren't completely silent (I5)

* fix(console): SPA nav + nullable project type + truncate multipart errors (CR-6, CR-8, S4)

- TopNav and ConsoleApp: swap <a href> for <Link to> on the cross-UI
  switch buttons. Same React app, same DOM tree, no need to trigger a
  full reload (CR-6)
- RunsPage and RunDetailPage: useEntity<Project | null> instead of
  useEntity<Project> with a Promise.resolve(null as unknown as Project)
  loader. Removes the type cast and keeps downstream readers honest
  about nullability — added explicit `if (detail === null)` guard in
  RunDetailPage where the type narrowed (CR-8)
- skills/startRun: multipart error path now truncates to 200 chars
  matching requestJson, so an HTML 502 body doesn't land in the error
  toast as raw markup (S4 from multi-agent review)

* test(server): 6 tests for GET /api/runs/:runId/artifacts (I6)

Cover the branches that can be tested without mocking fs/promises:
  - 400 for invalid run ids that fail the [A-Za-z0-9_-] regex guard
  - 404 when the workflow run does not exist
  - 200 + empty files when run has no codebase_id (orphan)
  - 200 + empty files when codebase name lacks owner/repo shape
  - 500 when the codebase DB lookup throws
  - 400 when the resolved artifact dir escapes ARCHON_HOME
    (defense-in-depth path-containment guard)

Multipart-dispatch unit testing would require mocking c.req.parseBody —
deferring; the end-to-end multipart round-trip was verified during
development against a real workflow with server-side
`run_workflow.files_uploaded` log + upload dir written under
~/.archon/artifacts/uploads/. The existing JSON-path tests continue
to assert addMessage is called with 3 args (not 4) for the JSON branch.

Tweaks to the test harness:
  - paths mock now exports getArchonHome and getRunArtifactsPath so the
    new handler can resolve a deterministic test path
  - getCodebase is now a top-level mockGetCodebase that supports
    .mockImplementationOnce per-test

* docs: register new artifacts endpoint + clean stale references (I7+C4+S5)

CLAUDE.md
  - Add GET /api/runs/:runId/artifacts to the API Endpoints section
  - Extend the directory tree to mention packages/web/src/experiments/
    (lint-guarded in-repo spike directory, currently hosting /console)
  - Update the registerOpenApiRoute rule to enumerate the two narrow
    exceptions: raw-content wildcard routes (e.g.
    /api/artifacts/:runId/*) and multipart-or-JSON routes (drop
    request.body from the route config; handler parses both)

docs-web/reference/api.md
  - Add the artifacts row to the Runs table + a 'List Run Artifacts'
    section with curl
  - Expand the 'Run a Workflow' example to show the new multipart
    branch alongside the existing JSON one

packages/web/src/experiments/console/README.md
  - Replace the dead /Users/rasmus/.claude/plans/quiet-twirling-bentley.md
    link with a Status section noting that milestone planning has been
    superseded by PR-template-driven feedback

packages/web/src/experiments/console/lib/format.ts
  - Drop the orphan JSDoc that described formatProjectLocator above the
    formatCost function

packages/web/src/experiments/console/theme.css
  - The 'maps --color-* to --base vars' line invented terminology that
    doesn't exist in Tailwind. Replace with the accurate version:
    @theme inline defines color tokens that reference plain CSS vars,
    redefining those vars inside .console-root cascades through every
    utility that reads them

packages/server/src/routes/api.ts
  - persistUploadedFiles docstring no longer claims to be shared by
    both message + workflow routes (only run uses it today;
    sendMessageRoute still inlines the same logic and could migrate
    in a separate pass)

store/cache.ts and routes/RunDetailPage.tsx
  - Drop the (M4) milestone references — the SSE wiring landed weeks
    ago; the comments now describe the actual lib/sse.ts coupling

* feat(console): neovim-style keymap for project / workflow / run selection

Adds a light-modal keymap so picking a project, picking a workflow, and
starting a run can all be driven from the keyboard:

- p anywhere: full-screen project palette (subsequence fuzzy match,
  ↑↓/Enter/Esc, listbox + combobox a11y)
- n in a project: opens the draft card and auto-summons the workflow
  picker; closing the picker hands focus to the context textarea
- ? anywhere: keyboard shortcuts overlay (esc/? to dismiss)
- runs feed: j/k move, gg/G jump, Enter open, Esc clear, / focus search,
  1-5 filter by status (with magenta selection ring + scroll-into-view)
- run detail: 1/2/3 tabs, t/s toggle tool / system rows, a/r approve /
  reject (paused only), Esc/h back to runs

Shared infrastructure in lib/keymap.ts: chord buffer with 500ms window,
input + modal-dialog guards so route bindings don't leak through when a
palette is open. Help catalogue lives in lib/shortcuts.ts and is kept
in sync per-page.
…slash commands (#1757)

* feat(slack): umbrella Slack UX upgrade — buttons, status, reactions, slash commands

Single Slack adapter PR pulling together the in-thread interactivity primitives
the team will need on a shared instance:

- Interactive Block Kit Approve/Reject buttons on approval gates
- Cancel button on a per-run status message edited in place as DAG nodes progress
- Lifecycle reactions on the triggering message (🔄 → ✅ / ❌)
- Native `/archon` and `/archon-workflow` slash commands (Socket Mode, no URL needed)
- `_part i/n_` annotations on long replies split across multiple messages
- Italic cost/token footer after direct-chat replies and on terminal workflow status

Approve/Reject/Cancel buttons call existing platform-agnostic operations
(approveWorkflow / rejectWorkflow / abandonWorkflow); no schema or workflow
engine changes. Authorization re-uses the existing SLACK_ALLOWED_USER_IDS
whitelist for button clicks and slash commands.

Per-user attribution in thread context is intentionally deferred to a separate
PR — it needs a user_id column on conversations/messages/workflow_runs and
orchestrator plumbing.

* fix(adapters): declare @archon/providers as workspace dep

CI's stricter package-resolution caught that @archon/adapters imports
@archon/providers/types (TokenUsage) without declaring the workspace
dependency. Locally bun resolved it transitively via @archon/core; CI's
clean install does not.

* fix(slack): address coderabbit review

- Drop ephemeral denial from slash command auth path so unauthorized
  users are silently rejected, matching the existing app_mention /
  message.im pattern. Posting a denial leaks that a bot is listening.
- Surface failureReason on cancelled runs too, not just failed. The
  type already documents this for both terminal states.
- Stop forwarding raw error messages to Slack when a cancel click
  fails. Backend / DB errors stay in server logs; user sees a generic
  "check the server logs or try again" line.

Adds a test for the cancelled-with-reason rendering.

* fix(slack): address 6-agent PR review

Critical:
- Declare @archon/workflows as an explicit workspace dep on @archon/adapters
  (same class of fix as the providers one). Resolves today via hoisting but
  breaks under stricter installs.
- Split workflow-bridge.test.ts into its own bun test invocation so its
  irreversible mock.module() calls on @archon/core and
  @archon/workflows/event-emitter cannot leak into the slack/telegram
  batch.
- Fix "trailing-edge" debounce comments — the implementation is
  leading-edge. Document the Slack chat.update rate limit as the 500ms
  rationale.

Important:
- Wire slackBridge.detach() into the server graceful shutdown path so the
  event subscription doesn't leak and a pending chat.update can't fire
  against a closed Bolt socket.
- Drop dead `comment` plumbing through handleApprovalDecision /
  applyResolutionEdit / buildApprovalResolutionBlocks — Block Kit buttons
  have no UI to capture it.
- Widen the action-handler try/catch to also cover applyResolutionEdit so
  block-builder or chat.update failures don't bubble as unhandled
  rejections.
- Cancel-click with missing run state now logs and posts an ephemeral
  acknowledgement (using the button message's channel/ts) so the user
  isn't left wondering whether the click registered.
- Use Bolt's BlockButtonAction / ButtonAction types directly on the
  app.action() registrations instead of the ad-hoc ActionBody /
  ActionElement aliases.

Test coverage:
- Slash command silent-rejection of unauthorized users.
- triggeringMessages 1000-entry FIFO eviction at the cap boundary.
- Slash command seed-post failure → ephemeral error + handler not called.
- Single-chunk message path skips the _part i/n_ footer.
- rejectWorkflow → { cancelled: true, maxAttemptsReached: true } branch.

Docs:
- architecture.md IPlatformAdapter listing includes sendResultFooter.
- approval-nodes.md mentions the Slack in-thread Approve button.
- CLAUDE.md test-isolation batch count for @archon/adapters updated to 6
  (was 3 — pre-existing drift, now also accounts for workflow-bridge).

Polish:
- removeReactionSafe gets the same intentional-fallback comment as
  addReactionSafe (no_reaction is a normal terminal-state interleave).
- IPlatformAdapter.sendResultFooter signature uses TokenUsage directly.
- Drop "for v1" tag on the unhandled-event comment.
- Remove what-comments from blocks.ts / blocks.test.ts / adapter.ts.
)

* fix(orchestrator): resume interactive workflows on chat platforms (#1741)

Interactive approval-gate and interactive-loop workflows started from
Slack, Telegram, Discord, or GitHub never resumed after the user
provided their answer — each approval response triggered a brand-new
workflow run from node 0 in a fresh worktree, re-asking the same
questions indefinitely. The cause was a `platform.getPlatformType() ===
'web'` gate that wrapped the entire resume-detection block in
`dispatchOrchestratorWorkflow`, leaving all chat platforms to
unconditionally fall through to a fresh `executeWorkflow`. The chat-side
`resumeRun` mechanism that previously handled this was removed in
#915 (natural-language approval routing) without lifting the resume
lookup out of the web branch.

Changes:
- Restructure dispatchOrchestratorWorkflow so resume detection
  (findResumableRunByParentConversation + hydrateResumableRun) runs for
  every platform; only the background-dispatch branch remains web-only
- Add codebaseId parameter to findResumableRunByParentConversation so
  persistent chat conversation IDs (Telegram chat_id, Slack thread)
  cannot resume a stale run from a different project
- Add tests for chat resume, codebase scoping, and fresh-run fallback

Fixes #1741

* test(orchestrator): strengthen mock coverage and add web non-interactive resume test

- Add hydrateResumableRun to executor mock in orchestrator.test.ts to
  mirror the real module exports and prevent opaque TypeErrors for future
  test contributors
- Add test asserting that a web non-interactive workflow with a resumable
  run resumes foreground rather than dispatching a fresh background run,
  pinning the priority order of the if/else if dispatch block

* simplify: inline single-use mock vars in orchestrator.test.ts
…1703) (#1746)

createCodebase() hardcoded 'claude' as the fallback when ai_assistant_type
was not provided. Now checks process.env.DEFAULT_AI_ASSISTANT first,
consistent with how getOrCreateConversation() resolves the default.

Falls back to 'claude' only when both the parameter and env var are unset.
…or and workflow runs (#1783)

* feat(core): plumb user_id from chat/forge adapters through orchestrator and workflow runs

Adds remote_agent_users + remote_agent_user_identities tables (Archon
identity + per-platform mapping, UNIQUE(platform, platform_user_id))
and threads a resolved user_id through HandleMessageContext into the
orchestrator, workflow executor, and isolation resolver. Every new
conversation, message, workflow_run, and isolation_environment row
created from Slack/Telegram/Discord/GitHub now carries attribution.

Slack additionally enriches first-sight users with their real name via
users.info (requires bot scope users:read — reinstall the app to grant).
Telegram/Discord derive display name from the inbound event payload.
GitHub resolves event.comment.user.login or event.sender.login on each
webhook. Resolution failure warn-logs and continues — never drops a
message.

Schema is additive and nullable everywhere: existing rows remain valid
with NULL, ON DELETE SET NULL on every new FK. Web POST /api/conversations
and the CLI continue to write NULL user_id; those surfaces become
attributed in a follow-up PR. Solo installs with GITHUB_TOKEN are
unchanged.

Race-safe create-on-first-sight: UNIQUE(platform, platform_user_id)
trips on concurrent first-sight webhooks; the losing transaction rolls
back and we re-SELECT the winner's identity. Orphaned identities (user
row deleted out from under them) are auto-repaired.

Foundation for the small-team Archon initiative. Follow-ups will swap
the shared GITHUB_TOKEN for a GitHub App and wire per-user GitHub
tokens via device flow.

* fix(core): address PR review — FK semantics, narrowed error handling, identity type union

Critical fix: SQLite migrateColumns ALTERs now include ON DELETE SET NULL on
all four new user_id / created_by_user_id FK columns. Upgraded SQLite DBs
previously inherited the default NO ACTION ≈ RESTRICT semantic, contradicting
the PR's documented "no destructive cascade on user deletion" guarantee.

Hardening on the new user-identity surface:

  - findOrCreateUserByPlatformIdentity narrows its race-recovery catch to true
    UNIQUE-constraint violations (PG sqlstate 23505 or SQLite "UNIQUE
    constraint failed" message). Any other error logs as user.create_failed
    and propagates — no more masking generic DB failures as recoverable races.

  - backfillDisplayName wraps its UPDATE in try/catch with a dedicated warn
    event. A failed opportunistic backfill must not silently fail the entire
    user resolution path; the caller already has the resolved user row.

  - repairOrphanedIdentity now logs user.identity_orphan_repair_failed on
    transaction failure (previously surfaced only as a generic resolve_failed
    upstream).

  - New IdentityPlatform literal union ('slack' | 'telegram' | 'discord' |
    'github' | 'web' | 'cli') replaces the unconstrained `platform: string`
    on UserIdentity and the findOrCreate signature. Typos now fail at compile
    time rather than silently breaking the UNIQUE(platform, platform_user_id)
    invariant.

  - user.create_started/_completed/_failed are now properly paired per the
    project event-naming convention.

Slack adapter:

  - users.info missing_scope WARN now gated by an instance flag so it fires
    once per adapter lifetime instead of once per unknown user. The
    misconfiguration is permanent — flooding logs after every restart in a
    100-user workspace was the wrong shape.

  - users_info_failed log strips err (which can include err.data with
    workspace metadata) in favor of structured errMessage / slackErrorCode
    / slackUserId fields. No PII through the log pipeline.

Server resolver:

  - resolveUserId exported for testability and now logs as a single static
    event server.user_resolve_failed (platform in structured fields) instead
    of the templated ${platform}.user_resolve_failed which collided with the
    GitHub adapter's own event name.

  - Dead `=== null` branch removed (TypeScript already narrows the type).

GitHub adapter:

  - User-identity resolution moved up to immediately after self-filtering
    + @mention checks. Now runs before the codebase-ensure and comment-
    history Octokit calls so resolution can't be silently skipped by an
    upstream Octokit failure (which was masking a missing-mock bug in the
    existing test suite).

Tests:

  - New packages/server/src/resolve-user-id.test.ts covers the never-throws
    contract that three adapter handlers depend on. 6 cases including the
    static-event-name regression.

  - GitHub adapter test now mocks @archon/core/db/users and covers the
    comment.user.login ?? sender.login attribution fallback in both
    directions, plus a never-throws case for resolution failure.

  - users.test.ts gains the asymmetric-backfill case, backfill-failure-
    does-not-block-resolution case, both PG and SQLite UNIQUE-error shapes
    for race recovery, and a non-UNIQUE-rethrows-without-recovery case
    that explicitly counts the query calls.

  - isolation-environments.test.ts adds a "ON CONFLICT does NOT update
    created_by_user_id" regression guard so a copy-paste in the SET clause
    can't silently transfer ownership across re-activations.

Comment cleanup: stripped the (PR-A) / (until PR-C) / pre-PR-A history
labels from production types, migrations, and source files. They were PR-
state markers that would rot on merge; the substantive WHY content stays.

Docs:

  - CLAUDE.md table count: 8 → 10; users and user_identities documented.
  - docs-web/reference/database.md: 8 → 10 with explicit ON DELETE semantics
    and a note that re-running 000_combined.sql is idempotent and picks up
    the new ALTERs.
  - docs-web/reference/architecture.md: 7-table diagram → 10-table; full
    schema block extended with the new tables and user_id columns.
  - docs-web/adapters/slack.md: users:read scope added to the Bot Token
    Scopes setup with a note about graceful degradation if omitted.

Skipped (with reason):

  - Converting User/UserIdentity to z.infer<typeof schema>: all sibling row
    interfaces in types/index.ts are hand-crafted; doing this for just the
    two new types creates inconsistency. A separate consistency pass should
    convert the whole file, not selectively.

  - Threading userId into the four web/CLI addMessage callsites: those
    surfaces don't have an auth flow yet, so threading now means passing
    `undefined` from every caller. Added explicit TODOs at each callsite
    pointing at the upcoming web/CLI auth work instead.
…tion routing (#1788)

Phase 2 of the team-foundation PRD. Replaces the bot's single shared GITHUB_TOKEN PAT with a registered GitHub App that supports multi-installation token routing from day one.

New @archon/core/github-auth/ module wrapping @octokit/auth-app with a three-level cache:

  - lookupCache:  owner/repo → installationId (1h TTL; evicted on 401)
  - tokenCache:   installationId → access token (1h GitHub TTL, refreshed 5min before expiry)
  - octokitCache: installationId → Octokit (per-installation auth strategy; evicted on 401 so the SDK's hidden internal token state can't keep serving the dead token)

GitHubAdapter takes a `GitHubAuth` discriminated union at construction. All 4 Octokit callsites (postComment / listComments / repos.get / pulls.get) plus the clone path route through resolveOctokit + a withTokenRefresh wrapper that calls invalidateRepo and retries once on 401. Webhook event.installation.id primes the lookup cache to skip a round-trip. Secondary self-filter compares against `<slug>[bot]` in App mode (via a botLogin getter, distinct from botMention) so PR-C's per-user tokens won't trip it.

Server bootstrap detects App vs PAT mode via env and fails fast if both are configured. In App mode it registers the provider on a module singleton consumed by createWorkflowDeps(), so the workflow executor's bash/script subprocesses inherit a fresh GH_TOKEN/GITHUB_TOKEN. New POST /internal/git-credential endpoint (App mode only) backs a POSIX git credential helper installed at clone time, covering workflows that outlive the 1h installation-token expiry. The public-bind guard runs BEFORE Bun.serve so a rejected config never opens the listening socket — opt-out via ARCHON_ALLOW_INTERNAL_ON_PUBLIC_BIND=1 for deployments where the reverse proxy already drops /internal/*.

Refactor + extracted helpers in server/src/github-auth-bootstrap.ts (selectGitHubAuthMode + parseGitCredentialPath) so the security-critical decisions are testable in isolation without spinning up Hono.

Backwards compat: solo installs running GITHUB_TOKEN only see zero functional change. All 54 existing PAT-mode adapter tests pass unchanged.

Tests added: 23 strictly-mocked auth-module tests (PRD Q7 — no live api.github.com in CI); 10 new App-mode adapter tests (multi-install routing, payload short-circuit, 401 retry + retry-on-retry propagation, AppNotInstalledError surfacing, clone-token resolution, post-clone credential helper install); 20 server-bootstrap unit tests (dual-mode fail-fast, /internal path validation incl. traversal + null bytes).

Closes phase 2 of .claude/PRPs/prds/github-app-and-user-identity.prd.md. Depends on #1783 (PR-A user-identity foundation).
…#1792)

createSchema() ran two CREATE INDEX statements referencing user_id on
remote_agent_conversations and remote_agent_workflow_runs. On databases
created before v0.4.0 those columns don't exist yet — they're added by
migrateColumns(), which runs AFTER createSchema(). The index creation
aborted the entire createSchema() exec block, the constructor threw, and
every subsequent operation failed with "no such column: user_id". New
installs were unaffected because the columns exist in the same schema.

Moves both CREATE INDEX statements into migrateColumns() so they run
after the matching ALTER TABLE. idx_user_identities_user_id stays in
createSchema() because user_identities is a new table whose user_id
column always exists.

Adds a regression test that seeds a pre-0.4.0 schema (no user_id columns
on conversations/workflow_runs/messages, no created_by_user_id on
isolation_environments) and asserts SqliteAdapter construction completes,
migrates the columns, and creates both indexes.

Caught by /test-release brew 0.4.0.
pi-coding-agent 0.71+ dropped the `options.agentDir ?? getAgentDir()`
fallback in DefaultResourceLoader and PackageManager. Without an
explicit `agentDir`, `getBaseDirForScope("user")` returns undefined
and any downstream `join(agentDir, ...)` throws:

    TypeError: paths[0] must be of type string, got undefined
      at resolvePackageSources (pi-coding-agent/dist/core/package-manager.js)
      at reload (pi-coding-agent/dist/core/resource-loader.js)

This was the symptom pinning us to ^0.67.5 and blocking model-catalog
updates (e.g. Gemini 3.5 Flash).

Fix: call Pi's own `getAgentDir()` in createNoopResourceLoader. This
honors `PI_CODING_AGENT_DIR` and matches the exact behavior of the
pre-0.71 implicit fallback. Update provider.test.ts mock to stub
`getAgentDir` for the value import.

Verified: Pi -> OpenRouter -> Gemini 3.5 Flash routes end-to-end
through an Archon workflow (test-pi-gemini, model
openrouter/google/gemini-3.5-flash registered via ~/.pi/agent/models.json).

All pi tests pass (model-ref, config, event-bridge, options-translator,
session-resolver, provider 67/67, provider-lazy-load).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
….0.1, Caddy /internal/* drop, opt-out flag) (#1795)

* docs(github-app): document the canonical Docker deploy recipe

The internal-endpoint-security section in the existing setup guide tells
operators "Operators who use systemd / docker for upstream routing also fall
into this category" for the recommended HOST=127.0.0.1 path. That's
misleading for Docker — the app container has to bind 0.0.0.0 inside the
container for docker-proxy to forward traffic, so option 1 isn't directly
usable on the canonical compose stack. The setup we hit in production is
explicitly option 2 plus three concrete defensive layers, and none of the
three are written down anywhere a Docker operator would find them.

Hit during the dynamous.ai dogfood deploy: server failed to start with
github_app.internal_endpoint_public_bind_rejected; recovery required
re-reading PR-B's source plus reasoning about the docker-compose / Caddy
boundary. Documenting now so the next operator doesn't repeat that loop.

Changes:
- Clarify option 1 is bare-metal / systemd; option 2 is the right path for
  Docker (with a forward link to the new section)
- Tag the existing Caddy snippet as bare-metal so it isn't confused with
  the Docker pattern
- Add "Canonical Docker setup" subsection with the three required changes:
  (1) docker-compose.override.yml port -> 127.0.0.1 with the !override tag,
  (2) Caddyfile handle /internal/* drop block,
  (3) ARCHON_ALLOW_INTERNAL_ON_PUBLIC_BIND=1 in .env. Plus an apply+verify
  recipe (curl from outside must 404; ss -tlnp confirms loopback bind;
  startup log shows internal_endpoint_exposed_acknowledged for audit).
- Cross-reference the new section from the two relevant troubleshooting
  entries.

* docs(github-app): probe /internal/git-credential with POST, not GET

CodeRabbit (#1795 review): a GET probe against the external-exposure check
can false-pass — a reverse proxy can 404 the GET on an unmatched route
while still happily forwarding POSTs upstream. The endpoint is POST-only
and hands out installation tokens, so the probe must exercise the actual
method an attacker would use.

Also tighten the surrounding prose: 404 OR 403 from the proxy both
indicate the request didn't reach the Archon process. Anything else
means the proxy is still relaying.
* fix(providers/pi): migrate to @earendil-works/pi (0.76.0)

Pi moved to a new owner — see https://pi.dev and
https://github.com/earendil-works/pi. The `@mariozechner/pi-coding-agent`
package is no longer maintained at that namespace; the live development
continues under `@earendil-works/pi-coding-agent` (latest 0.76.0).

The previous commit on dev (c839bcc) bumped pi-coding-agent to 0.73.1
under the old @mariozechner namespace. That version's type definitions
broke build (CI test job red on every PR since): `codingTools` aggregate
removed in favor of `createCodingTools` factory, `ExtensionUIContext`
gained four new methods, and `CreateAgentSessionOptions.tools` switched
from `Tool[]` to `string[]` (with the actual Tool objects moving to
`customTools`).

This commit:
  - Bumps both dependencies to @earendil-works/pi-{coding-agent,ai}@^0.76.0.
  - Updates every import (12 source files + 5 test/doc/script files).
  - Replaces the `codingTools` aggregate with `createCodingTools` and
    derives `PiTool` from `ReturnType<typeof createCodingTools>[number]`.
  - Adds the four new ExtensionUIContext methods (setWorkingVisible,
    setWorkingIndicator, addAutocompleteProvider, getEditorComponent) to
    the headless UI stub as no-ops, with comments explaining why.
  - Switches createAgentSession from `tools: PiTool[]` to
    `customTools: PiTool[]` + `noTools: 'builtin'`, preserving the old
    "filteredTools replaces the default set entirely" semantic.
  - Updates the four provider tests that asserted on the old `tools` key
    shape to assert on `customTools` + `noTools` instead.

bun run validate passes locally; all 67 provider tests pass.

* fix(pi-migration): address PR review findings

Multi-agent review (code-reviewer + docs-impact + comment-analyzer +
pr-test-analyzer + type-design-analyzer) on PR #1800 surfaced:

- 7 of 8 "Added in pi 0.71+" comments cite the wrong version. The
  changelog (badlogic/pi-mono → earendil-works/pi-mono) places these
  changes much earlier:
    * codingTools → createCodingTools factory: 0.68 (not 0.71)
    * customTools / tools-as-string[] / noTools: 0.68 (not 0.71)
    * DefaultResourceLoader requires explicit agentDir: 0.68 (not 0.71)
    * setWorkingIndicator: 0.68 (not 0.71)
    * addAutocompleteProvider: 0.69 (not 0.71)
    * setWorkingVisible: 0.70.3 (not 0.71)
    * getEditorComponent: 0.71 (this one was correct)
  All seven incorrect annotations corrected to the version that actually
  introduced the change, matching the published changelog.

- Two user-facing docs still pointed at the abandoned namespace:
    * CLAUDE.md (2 lines) — directory comment + PiProvider bullet
    * examples/workflows/README.md (2 install commands)
  Both now point at @earendil-works/pi-* so users following the docs
  install the live package.

- Docs site "See also" section pointed at the old badlogic/pi-mono
  GitHub mirror. Updated to https://github.com/earendil-works/pi (the
  current source repo) and added https://pi.dev as a second link for
  upstream documentation.

- Expanded the createAgentSession call-site comment with the
  load-bearing invariant the original write-up missed: customTools is
  the only path through which BashSpawnHook env injection can attach to
  Pi's bash tool. Without that hook there's no way to retrofit managed
  env vars onto Pi's pre-constructed default bash tool — explaining why
  we must always supply customTools when env injection is needed, not
  just for tool restrictions.

Lower-priority findings deferred:
- Inline-vs-derived parameter typing inconsistency in ui-context-stub.ts
  (type-design-analyzer, LOW). The current mix doesn't cause bugs and
  the no-op bodies are correctness-insensitive to parameter shape.
- Add `expect(ui.getEditorComponent()).toBeUndefined()` regression test
  (pr-test-analyzer, LOW). The contract is already enforced by the
  ExtensionUIContext return-type check on createArchonUIContext.
- Pre-existing failing theme test in ui-context-stub.test.ts
  (pr-test-analyzer noted it). Not introduced by this PR; out of scope.

* test(pi): fix theme-getter test + re-enable ui-context-stub in CI

The themeProxy returns identity-decorator passthroughs (lastStringArg
for unknown style methods, factories returning identity functions for
the border-color getters) so plannotator-style extensions can decorate
text without crashing. The implementation's docstring says exactly this.

The test, however, asserted the opposite — that `theme.fg('accent', 'text')`
THROWS with /Archon's remote UI stub/. The test never matched the
implementation: both lines were authored in the same commit
(cb44b96, 2026-04-20) but inconsistently, and the test file was
quietly excluded from the providers package's test script chain so
the disagreement never surfaced in CI.

Caught by pr-test-analyzer during the #1800 review.

This commit:
  - Rewrites the test to verify the actual passthrough contract:
    `theme.fg(color, text)` returns `text`, `theme.bold(text)` returns
    `text`, `getThinkingBorderColor()` returns a passthrough function,
    `getColorMode()` returns 'truecolor', `getFgAnsi(...)` returns ''.
  - Adds `ui-context-stub.test.ts` to the providers package's test
    script so the file actually runs (along with the 16 other tests
    in it that were already passing but silent).
* Fix: move per-platform user-identity extraction into adapter callback signatures (#1784)

Discord passed raw discord.js Message objects, forcing the server to reach
into message.author.id/username. Gitea and GitLab webhooks never resolved
senders to Archon user UUIDs at all. This refactor moves extraction into
each adapter and normalizes the callback signatures.

Changes:
- Extend IdentityPlatform union with 'gitea' and 'gitlab'
- Create DiscordMessageContext with platformUserId + displayName
- Update Discord adapter to extract identity before calling handler
- Update server Discord handler to consume normalized context
- Add user resolution to Gitea handleWebhook (mirrors GitHub pattern)
- Add user resolution to GitLab handleWebhook (mirrors GitHub pattern)
- Add tests for Discord context normalization
- Add tests for Gitea/GitLab user identity resolution

Fixes #1784

* fix: address review findings for PR #1801

Fixed:
- Remove dead Pi tool factory mocks and rewrite obsolete spawnHook test
- Remove unused _cwd/_env params from resolvePiTools and update callsite
- Add env-injection-unavailable warning in Pi provider
- Fix GitLab step comment drift (7-13 → 8-14)
- Add fallback behavior comments to Gitea and GitLab adapters
- Fix incorrect module path in Gitea/GitLab test mock comments
- Move Discord JSDoc above export interface
- Add defensive null-check for message.author in Discord adapter
- Add userId propagation tests to Gitea/GitLab adapters
- Add undefined-sender edge-case tests to Gitea/GitLab adapters
- Add Discord author-null edge-case test
- Add Pi UI stub new methods test
- Update architecture.md schema docs with gitea/gitlab platforms

Tests added:
- Gitea/GitLab userId propagation and undefined-sender tests
- Discord missing-author test
- Pi UI stub new no-op methods test

Skipped:
- Pi provider SDK migration scope creep (requires PR split, not a code fix)

* simplify: reduce complexity in changed files

---------

Co-authored-by: Archon Bot <archon@example.com>
…1802)

* Fix: Close parallel-definition drift between hand-crafted interfaces and Zod schemas (#1787)

Eliminate parallel schema drift between core/src/types/index.ts, core/src/db/*.ts,
and server/src/routes/schemas/*.ts by converting hand-crafted TypeScript interfaces
to Zod-derived types (z.infer<typeof schema>) in a new packages/core/src/schemas/
directory.

Changes:
- Create packages/core/src/schemas/ with row schemas for conversation, message,
  user, codebase, session, workflow-event, env-var, and workflow-run concepts
- Update core/src/types/index.ts to re-export schema-derived types instead of
  declaring parallel interfaces
- Update core/src/db/*.ts to import types from schemas instead of declaring
  interfaces inline
- Update server route schemas to extend core row schemas with wire-shape overrides
  (Date -> ISO string) using .extend() and .openapi()
- Add toApi* transform helpers in server/src/routes/api.ts for safe Date/string
  normalization at the API boundary
- Replace hand-crafted response interfaces in web/src/lib/api.ts with generated
  OpenAPI component types (components['schemas']['X'])
- Add ./schemas exports to packages/core/package.json
- Regenerate packages/web/src/lib/api.generated.d.ts (additive only)

Behavioral interfaces (IPlatformAdapter, IDatabase, etc.) remain hand-crafted.

Fixes #1787

* fix: address review findings

Fixed:
- toISOString now guards against invalid Date objects with try/catch + logging
- toApiMessage now guards JSON.stringify with try/catch + logging (falls back to '{}')
- Added inline comment documenting toApiCodebase corrupted-commands fallback intent
- Imported workflowRunStatusSchema to eliminate enum duplication in listDashboardRunsOptionsSchema
- Removed dead UserIdentityPlatform type alias from core schemas index
- Replaced verbose inline type imports in api.ts with top-level schema imports
- Added TODO comment for z.custom<TransitionTrigger>() runtime validation gap
- Updated CLAUDE.md: added schemas/ to monorepo layout, core schema conventions, @hono/zod-openapi dep
- Fixed adapter-implementation-guide.md: replaced stale line numbers with symbol references

Tests added:
- packages/core/src/schemas/index.test.ts — safeParse tests for all 9 core schema files
- api.conversations.test.ts — Date→string conversion and undefined nullable handling
- api.codebases.test.ts — corrupted commands JSON fallback
- api.messages.test.ts — circular metadata serialization fallback
- api.workflow-runs.test.ts — Date→string conversion for workflow runs

Skipped:
- Server route schema extensions untested for OpenAPI validation (medium effort, deserves follow-up PR)
- Web client type refactor untested (type-only change, build provides coverage)

* simplify: reduce complexity in changed files

---------

Co-authored-by: Archon Bot <archon@example.com>
…ted numeric/boolean RHS (#1777)

* feat(workflows/condition-evaluator): support shorthand path and unquoted numeric/boolean RHS

Accept `$nodeId.field` as shorthand for `$nodeId.output.field`, and allow
bare numeric/boolean RHS (`$n.exit_code == 0`, `$n.passed == true`) so number
and flag comparisons no longer have to quote their literal.

The change is confined to the atom regex plus the path/RHS resolution in
evaluateAtom; the AND/OR splitter, resolveOutputRef, and numeric coercion path
are untouched, so every existing `when:` clause evaluates identically. Shorthand
refs with a sub-field (`$n.field.sub`) are rejected fail-closed.

Closes #1763

* test(workflows/condition-evaluator): drop task prefix, add shorthand absent-field case

Address review feedback: remove the stale task-id prefix from the
shorthand/unquoted RHS section comments, and add a test asserting an
absent shorthand field resolves to '' (parsed: true) rather than a
parse error, matching the canonical $node.output.field semantics.

---------

Co-authored-by: Raphael Lechner <raphael.l@asix.pro>
…/fallbackModel/betas/sandbox in loader (#1799)

* fix(workflows): stop silently dropping workflow-level effort/thinking/fallbackModel/betas/sandbox in loader

`workflowBaseSchema` declares five workflow-level fallback fields (`effort`,
`thinking`, `fallbackModel`, `betas`, `sandbox`) that the DAG executor reads
from `workflow.{field}` at the top of `executeDagWorkflow` (dag-executor.ts:
2585-2591) and threads down as `workflowLevelOptions` so per-node options can
inherit them when unset.

But `parseWorkflow` in loader.ts builds the returned workflow object manually
rather than round-tripping `raw` through Zod, and the five fields were never
copied across. Result: YAML → loader → executor silently dropped them, so a
workflow that set e.g. `effort: high` at the root would fall through to SDK
defaults instead of being inherited by its nodes.

Why this wasn't caught: the existing
"forwards workflow-level effort to node when no per-node override" test in
dag-executor.test.ts calls `executeDagWorkflow` directly with a hand-built
workflow object literal — it bypasses the loader entirely, so the executor
side works in tests even though the production path is broken. No bundled
workflow uses any of these as a workflow-level field today (grep returned 0),
which is the only reason there's no shipped user-visible regression.

Fix: add five `safeParse` + warn-and-drop blocks mirroring the existing
`modelReasoningEffort` / `webSearchMode` pattern, and spread each conditionally
onto the returned workflow object alongside `mutates_checkout` and `tags`.

`effort`, `thinking`, and `sandbox` use the existing Zod schemas
(`effortLevelSchema`, `thinkingConfigSchema`, `sandboxSettingsSchema`).
`fallbackModel` is inline (trim + non-empty check) so trailing whitespace is
normalised rather than rejected. `betas` is inline trim+filter (same shape as
`tags`); an empty result drops the field because the Claude SDK expects either
a populated beta header or no header at all.

Tests:
- positive: a YAML with all 5 workflow-level fields set survives parse and
  round-trips to the workflow object with the correct shapes (incl. the
  `thinking` discriminated-union narrowing to `{ type: 'enabled', budgetTokens }`)
- negative: invalid values (`effort: nuclear`, `fallbackModel: ''`,
  `betas: []`) warn-and-drop instead of failing the workflow
- trim semantics: `betas: ['  alpha  ', '', 'beta']` normalises to
  `['alpha', 'beta']`
- absent: a workflow that doesn't set any of the five fields leaves them
  undefined on the parsed object

No schema or executor changes — the bug was the loader silently dropping
values the executor was already prepared to consume.

* refactor(workflows/loader): address review feedback on workflow-level field parsing

Follow-up to the workflow-level effort/thinking/fallbackModel/betas/sandbox
loader fix, addressing multi-agent PR review findings:

- Extract the repeated safeParse + warn-and-drop pattern into a single
  parseOptionalField helper; apply it to effort/thinking/sandbox plus the
  pre-existing modelReasoningEffort/webSearchMode blocks.
- Extract the duplicated betas schema (workflow.ts + dag-node.ts) into a
  shared betasSchema and validate the cleaned list through it. Removes the
  unsound `as [string, ...string[]]` cast — the schema's .nonempty()
  constraint now supplies the tuple type.
- Replace hard-coded `dag-executor.ts:2585-2591` references (in loader.ts
  and loader.test.ts) with stable text anchors per the line-number anti-pattern.
- Add an `expected: 'non-empty string'` hint to the fallbackModel warning.
- Tests: assert the structured warn events fire, cover the thinking string
  shorthand and an invalid thinking/sandbox shape, and verify fallbackModel
  whitespace trimming.
- Docs: note per-node-over-workflow-level precedence for the Claude SDK
  advanced options in the authoring-workflows Configuration Priority section.
…tatus/reset CLI) (#1780)

* telemetry: umbrella trust fixes (CI auto-disable, first-run notice, status/reset CLI)

Cleans up the PostHog telemetry path with a set of low-risk fixes that
improve trust signals, raise data quality, and give users a way to
inspect and control state without env-var spelunking.

  1. Auto-disable when CI=true. Forks running fixtures in GitHub Actions,
     CircleCI, etc. no longer pollute the dataset. Matches the convention
     used by Next.js, Astro, Bun, Prisma.
  2. One-time first-run stderr notice (stamped at
     ${ARCHON_HOME}/telemetry-notice-shown). Only printed when stderr is
     a TTY and telemetry is enabled. Most users learn about the data
     collection without having to read the README.
  3. POSTHOG_API_KEY opt-out is now meaningful: empty / off / 0 / false /
     disabled all disable telemetry instead of silently falling through
     to the embedded key.
  4. silentFetch logs the first network/HTTP failure at warn (so
     self-hosters notice a typo'd POSTHOG_HOST), subsequent failures at
     debug to stay quiet.
  5. New `archon telemetry status` / `archon telemetry reset` CLI
     subcommands. Status surface also wired into `archon doctor`.
  6. workflow_description removed from the event payload — it's
     user-authored YAML that may contain private context.
  7. $ip: '' added to event properties so source IP isn't retained at
     PostHog ingest (disableGeoip only blocks geo enrichment).

The `phc_*` embedded key remains write-only and shipped in source.
Backwards compatible: `isTelemetryDisabled()` retained, opt-out env
vars unchanged.

* test(telemetry): isolate CI and POSTHOG_API_KEY env in disabled=0 test

The 'ARCHON_TELEMETRY_DISABLED=0 does not disable' test left CI and
POSTHOG_API_KEY untouched. GitHub Actions always sets CI=true, so the
new CI auto-disable branch made isTelemetryDisabled() return true and
the test failed in CI while passing locally. Clear both env vars to
match the sibling opt-out tests.

* fix(telemetry): address PR review findings (trust, types, tests, docs)

Behavioral fixes:
- logFetchFailure: gate the first-failure warn on POSTHOG_HOST being
  overridden, so default-host users who are merely offline stay silent
  (warn was only ever justified for self-hosters with a typo'd host).
- getTelemetryStatus: read the install UUID without creating it when
  disabled, so `telemetry status` / `doctor` never materialize a
  telemetry-id file for an opted-out user (new peekTelemetryId helper).
- maybeShowFirstRunNotice: add a self-contained isTelemetryDisabled()
  guard so the function is safe for any future caller, matching its docstring.
- resolveDisabledReason: match CI case-insensitively (AppVeyor sets
  CI=True); CI=1 still does not disable.

Type design:
- TelemetryStatus is now a discriminated union on `enabled`, making
  {enabled:true,disabledReason:...} and {enabled:false,disabledReason:null}
  unrepresentable and removing the unreachable fallback in doctor.ts.

Comments/docs:
- resetTelemetryId: document its non-fire-and-forget throw contract (@throws).
- silentFetch + module docstrings: correct "zero noise" wording and the
  notice-stamp-failure comment; add inline rationale on the capture catch.
- doctor: broaden the POSTHOG_API_KEY disabled message ("opt-out value").
- Add telemetry to CLAUDE.md CLI list; add telemetry section + doctor check
  mention to docs-web cli.md; add Telemetry env-var table to configuration.md.

Tests:
- New packages/cli/src/commands/telemetry.test.ts (status output per reason,
  reset success + write-failure exit code).
- First-run notice coverage (TTY guard, stamp idempotency, disabled, in-process
  guard); CI case-insensitive cases; status-while-disabled writes no file;
  clear POSTHOG_HOST for determinism; doctor POSTHOG_API_KEY/ARCHON_TELEMETRY_DISABLED reasons.

Deferred: silentFetch 3xx handling (effectively dead — fetch follows redirects;
reviewer rated low-confidence).
…e-runs (#1790)

* feat(workflows): persist per-node provider sessions across workflow re-runs

Opt-in via `persist_session: true` on a DAG node (or `persist_sessions: true`
at workflow root). When set, the executor stores the provider's session ID
after each completion keyed by (workflow_name, node_id, scope_key, provider) —
scope = the conversation UUID — and passes it back as `resumeSessionId` on
subsequent runs. Each AI role accumulates conversation across re-invocations
of the same workflow in the same scope; planners remember their plan,
reviewers remember what they already flagged.

Capability-gated on `ProviderCapabilities.sessionResume` at both load time
(loader.ts, when provider is explicit) and runtime (dag-executor.ts, for the
implicit-default-provider case). `node.context: 'fresh'` overrides — the
explicit "always fresh" intent wins.

Reset surfaces: `archon workflow reset-sessions`, `/workflow reset-sessions`
chat command, and `DELETE /api/workflows/{name}/node-sessions`. Conversations
are soft-deleted in this codebase so no cascade is wired; `scope_key` stays
structurally valid.

Distinct from `AgentRequestOptions.persistSession` (Claude SDK on-disk
transcript flag). YAML field is snake_case; the SDK option is camelCase TS;
they sit at different layers and don't conflict — disambiguation comments at
both call sites.

Test coverage:
- 9 cases for the new DB module (get/upsert/delete/deleteByScope, all filter
  combinations, null rowCount coalesce)
- 7 cases for the DAG executor: fresh + upsert; resume + upsert with new ID;
  no sessionId returned → delete stale row; persist_session unset → no store
  calls; workflow-level on + node-level off → opt-out; context:'fresh' →
  bypass in both directions; non-resume-capable provider → clear error
- 3 cases for the loader: schema parses field on node and at workflow root;
  load-time rejection on non-resume-capable provider (registers ephemeral
  provider in test to drive the gate)
- 3 cases for the shared op: workflow-only delegation; full filter
  forwarding; DB error wrapped with descriptive message

Web UI panel deferred to follow-up — REST + OpenAPI types ship now so the
client side can land separately without blocking. Docs updated with usage,
scope rules, capability requirement, reset surfaces, and the Codex/Pi
unbounded-token-growth caveat.

* fix(workflows): address CodeRabbit review on persist_session PR

- CLI `workflow reset-sessions`: reject extra positional args. A call like
  `archon workflow reset-sessions wf planner --yes` silently ignored `planner`
  and wiped every scope; now fails closed and hints `--node`.

- `upsertWorkflowNodeSession`: wrap pool.query in try/catch and log on failure
  before rethrowing. CLAUDE.md INSERT contract was missing here.

- `node_session_resumed` workflow event: mask the resumed session ID to an
  8-char prefix. Workflow events live longer and reach broader observability
  surfaces than the node-session table; storing the raw token there leaked a
  resumable artifact into the audit stream. Field renamed
  `provider_session_id` → `provider_session_id_preview`.

- Stale-session cleanup on no-sessionId: include `provider` in the delete
  filter so switching providers between runs no longer clobbers the other
  side's saved row. Extended `IWorkflowStore.deleteWorkflowNodeSessions`
  filter shape and the underlying DB helper to accept an optional provider
  predicate; reset surfaces (CLI/chat/REST) still omit it so a manual reset
  wipes every provider.

- Loader capability gate: skip non-persistable nodes. Workflow-level
  `persist_sessions: true` was triggering the capability check on bash /
  script / approval / cancel / loop nodes and on `context: 'fresh'` AI nodes,
  producing false validation failures. Now only command/prompt AI nodes
  outside `context: 'fresh'` are checked, matching what the executor actually
  persists.

- Loop nodes: documented as out-of-scope for `persist_session` in this
  release. They have their own per-iteration session machinery; the schema
  already warn-drops the field on loops, and the loader now skips them in
  the capability gate. Use a `prompt:` node for cross-run memory.

- Chat help: `/workflow reset-sessions` was undiscoverable. Added to both the
  fallback `/workflow` usage string and the `/help` workflows section.

- REST: moved the request schemas
  (`resetWorkflowNodeSessionsParamsSchema`, `…QuerySchema`) into
  `workflow.schemas.ts` so the entire endpoint contract is co-located, per
  the project's "Route schemas live in packages/server/src/routes/schemas/"
  rule.

Tests:
- New: upsert rethrows DB errors after logging
- New: delete filter narrows by provider
- New: loader does NOT reject a workflow-level `persist_sessions: true`
  against a non-resume-capable provider when the only node is bash
- Updated: dag-executor "no sessionId → delete" expects `provider` in the
  filter

* fix(workflows): address multi-agent review on persist_session PR

Critical — cascade comments were lying:
- Remove dead deleteWorkflowNodeSessionsByScope (no caller) + its test.
- Correct the "cascade-on-conversation-delete handled by app code" claims in the
  DB docstring, migration 022, and 000_combined.sql: conversation delete is a soft
  delete and the conversation UUID is never reused, so orphans are unreachable and
  harmless; a future hard-delete path should delete by scope_key.

Important:
- Surface persist_session lookup/upsert DB failures to the user (safeSendMessage),
  not just logs; add provider to the upsert failure log context.
- Add --conversation-id to `workflow run` so persisted sessions resume across CLI
  invocations; document the scope-per-run caveat.
- Guard the REST cross-scope reset: DELETE .../node-sessions without ?scope= now
  requires ?confirm=all-scopes (mirrors the CLI --yes guard).
- Derive WorkflowNodeSession / WorkflowNodeSessionRow from a new
  workflowNodeSessionSchema (z.infer) instead of hand-written interfaces.
- Remove the dead persist_sessions field from WorkflowLevelOptions.
- Update CLAUDE.md (11 tables, CLI/REST/subcommand entries, node fields) and
  authoring-workflows.md (CLI scope caveat, cross-scope confirm).
- Fix misleading comments (scope "always populated", loop warning, token citation).

Cleanups:
- last_run_id write type aligned to string | null (matches ON DELETE SET NULL).
- getWorkflowNodeSession takes an object param (no transposable positional strings).
- Drop the no-op conditional spreads at the three reset surfaces.
- Extract shared isPersistableNode predicate; move upsert success log into the try.

Tests:
- Executor: lookup-failure -> fresh + warn, upsert-failure -> completes + warn,
  workflow-level persist_sessions inheritance.
- Chat /workflow reset-sessions (auto-scope, missing-name, DB error).
- CLI workflowResetSessionsCommand --yes guard + scope/json paths.
- DB: object-param + null last_run_id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix: auto-apply Postgres schema on startup (#1796)

PostgresAdapter had no schema management — schema-adding upgrades silently
broke Postgres deployments until someone SSH'd in and ran psql. The Docker
initdb mount only fires on a fresh volume, and external managed Postgres
(Supabase/Neon) had no convergence mechanism at all.

PostgresAdapter now runs the idempotent migrations/000_combined.sql inside
a pg_advisory_xact_lock(1796) transaction during construction, and every
query()/withTransaction() awaits the resulting promise so the first DB op
cannot race init. The SQL is embedded into the compiled binary via a
bundled-schema generator that mirrors the bundled-defaults pattern.

Changes:
- Add PostgresAdapter.initSchema() with advisory-lock transaction
- Add bundled-schema.ts facade + bundled-schema.generated.ts (embedded SQL)
- Add scripts/generate-bundled-schema.ts (mirrors generate-bundled-defaults)
- Wire generate:bundled-schema / check:bundled-schema into validate
- Add tests covering one-shot init, advisory lock ordering, and DDL failure
- Strip manual psql instructions from CLAUDE.md, database.md, docker.md, cloud.md

Fixes #1796

* fix: address review findings from PR #1810

- Move getSchemaSQL() and pool.connect() inside initSchema() try block so
  connection failures (ECONNREFUSED, auth errors, pool timeout) emit a
  db.postgres_schema_init_failed log entry instead of propagating silently
- Rename new log events from db.pg_* to db.postgres_* to match the
  established domain prefix used by other events in the same file
- Add if (client) guard before ROLLBACK attempt in initSchema() error path
- Use instanceof Error guard before cast in initSchema() catch blocks
- Import PoolClient type from pg for proper type annotation
- Add bundled-schema.test.ts covering the BUNDLED_IS_BINARY=true branch;
  wire it into @archon/core test script as its own isolated bun test invocation
- Add client.release() assertion to DDL failure test to guard the finally block
- Update CLAUDE.md: validate count six -> seven, add check:bundled-schema
  and generate:bundled-schema guidance to the Defaults section
- Replace stale manual psql migration step in local.md with a Database Setup
  section matching the cloud.md pattern (advisory-lock auto-apply)
- Remove issue number references from test section-divider comment and
  generator script JSDoc (violates CLAUDE.md "don't reference current task" rule)
- Rephrase "implicitly" comment in postgres.test.ts to be precise from
  all perspectives
* chore(deps): standardize on zod v4 across the codebase

Two zod versions coexisted in the tree (v3 from the project + @hono/zod-openapi@0.19,
v4 from @earendil-works/pi-coding-agent), so a ZodError thrown by one copy was not
instanceof the ZodError imported from the other — breaking cross-version instanceof
checks (sessions.test.ts) and risking subtle validation bugs.

Standardize the project's own packages on zod v4:
- Bump zod ^3 -> ^4.4.3 and @hono/zod-openapi ^0.19.6 -> ^1.4.0 (the zod-v4 line)
  in @archon/core, @archon/workflows, @archon/server. All @archon/* packages now
  resolve a single zod@4.4.3 (remaining v3/older-v4 copies are nested in unrelated
  third-party deps and don't touch our code's instanceof).

v4 breaking-change fixes:
- z.record(value) -> z.record(z.string(), value): v4 requires an explicit key type
  (dag-node, hooks, workflow-run, core schemas, server routes).
- parseOptionalField: v4 dropped the 3-arg z.ZodType<O, Def, I>; infer from the
  schema via <S extends z.ZodType> returning z.output<S>.
- betasSchema .nonempty() no longer infers a [string, ...string[]] tuple in v4;
  loader's local type is now string[].
- agents record: v4 collapses a failing key-schema into a generic "Invalid key in
  record" and drops the custom message, so the kebab-case check moved into a
  superRefine that preserves the guidance (with the offending key in the path).

Tests:
- Update two loader assertions for v4 error wording (v3 "Required" ->
  v4 "Invalid input: expected number, received undefined"); behavior unchanged.
- Add a GET /api/openapi.json regression test confirming the spec generates across
  all routes under @hono/zod-openapi v1 (the major-bump risk).

bun run validate is green: type-check (10 packages), lint, format, tests; the
previously failing sessions ZodError test now passes.

* chore(review): address zod v4 PR review findings

- loader.ts: fix stale betas comment that still claimed the [string, ...string[]]
  tuple type after the v4 change to string[]; move the parseOptionalField generic
  rationale into the JSDoc and make the ZodTypeDef note precise
- dag-node.ts: reword betasSchema comment to a version-agnostic description
- api.health.test.ts: harden the openapi.json test to assert components.schemas is
  populated (Conversation/Codebase/WorkflowEvent) and that the datetime-heavy
  conversations/codebases routes serialize — the actual zod-to-openapi v7->v8 risk
- CLAUDE.md: document the z.record(z.string(), value) v4 requirement
Wirasm and others added 22 commits July 27, 2026 11:21
The form-auth walkthrough showed an unescaped bcrypt hash. Docker Compose
interpolates $ in env_file values, so a copied hash silently loses everything
after the last $ — verified: '$2b$12$abcdef' arrives in the container as
'$2b$12', while '$$2b$$12$$abcdef' arrives correctly as '$2b$12$abcdef'.

Escapes every $ as $$ in the form-auth example and states the Compose
requirement, matching the existing convention in .env.example and the basic-auth
example. Also notes that the cloud guide targets the repository Compose
deployment — edit .env directly rather than running 'archon setup'.

No runtime auth behavior or Docker configuration changed.

Closes #1168
…2205)

The prompt-substitution catch in executeNodeInternal logged and returned a
failed result without emitting anything, so the failure was invisible in the
console run view and in 'workflow get --json'. Adds logNodeError, a persisted
node_failed event, and the emitter call — byte-for-byte parallel to the sibling
command-load failure path 40 lines above. Plus a regression test.

Reachable in production, not theoretical: substituteWorkflowVariables throws
when a prompt references $BASE_BRANCH and none resolves, which is the normal
state for folder projects (non-git, no base branch).

Event shape verified against both consumers — the console normalizer maps
node_failed to a terminal 'failed' state, and buildNodeSummaries reads the
data.error payload this writes.
…, not prompts (#2300)

The rule 'YAML coordinates, code computes, agents judge' was readable as a partition of work between node types, which led a review to argue on constitutional grounds that a prompt-implemented guard should become a script node. The constitution had no opinion on that; the real argument was reliability.

Scopes the rule to the YAML surface in all four places that stated it, adds direction.md §prompt-computation, and records the narrow reliability carve-out explicitly as not a constitutional argument.
…real database (#2303)

Two proven root causes behind the CI timeout class, both the same shape: a unit test silently reaching a real external resource, where nothing fails loudly and the only bound is Bun's 5000 ms default timeout.

GitHubAdapter's self-filtering suite never stubbed the private Octokit client, making 12 real HTTPS requests to api.github.com per run; GiteaAdapter had the same defect via bare fetch. Black-holing the endpoint reproduced #2186's reported failure list exactly.

mock.module() merges over the real module, so an export omitted from the factory keeps its real implementation. findChildRuns was added to db/workflows by #2121 and never added to command-handler.test.ts's factory, so every /workflow abandon test ran the real function and created a schema-initialised SQLite database. It produced a byte-identical success message, so hidden disk I/O was the only trace.

Adds a preloaded no-network guard for @archon/adapters that fails in 0.33 ms naming the URL instead of 5000 ms naming nothing, and documents both mechanisms in CLAUDE.md.

Closes #2186. #2240 referenced, not auto-closed: its root cause is fixed but the share of the observed latency attributable to it is unproven.

Test-only; no production code.
…2301)

The docs build has been broken on dev since 2026-07-21: ea87ddd inserted a marketplace entry after an existing brace, consuming it and leaving the next entry with no opener. deploy-docs.yml only builds on push to main, so this was a landmine set for the next release rather than a visible failure.

The guard mattered more than the fix. marketplace-lint.yml caught this exact error on #1687 and the PR merged with that check red, so the root cause is enforcement, not tooling. What was genuinely missing locally: @archon/docs-web had no type-check script, so bun run type-check skipped it entirely via the --filter glob. It was the only package of eleven without one.

Adds that script (astro sync && tsc --noEmit, since .astro is build-generated and gitignored) and a path-filtered docs-build.yml. Deliberately does not add build:docs to bun run validate: it resolves externalised bare imports out of dist/, which escapes the package in nested worktrees and would fail for reasons unrelated to the change under test.

Also sets fail-fast: false on the test matrix. Windows was being cancelled whenever ubuntu failed, so a windows-only break stayed invisible until the next round — and a cancelled leg cannot report success once it is a required check.

Merged with Run marketplace auto-review red: it has failed on every run since 2026-07-20 with billing_error, unrelated to this change.

Closes #2281.
…writing to ARCHON_HOME (#2307)

GitHub adapter tests created a real archon.db, config.yaml and bin/git-credential-archon under ARCHON_HOME. None of it came from Octokit — it came from real @archon/core functions the test factories left live, three of them on subpaths a root @archon/core factory cannot cover. loadGlobalConfig() creates a default config when absent, so merely reading config wrote one.

A complete Octokit stub would not have fixed the leak, and fixing only the leak would have left a trap armed: six tests terminated cleanly only because a stubbed 401 made step 7 return before steps 8-13, all of which were unmocked. Making repos.get resolve — the obvious move when exercising those steps — would have dropped all six into unmocked code at once. Both are closed here.

Also fixes a spy that existed and looked correct but was dead by the time it mattered: spyOn(core, 'handleMessage') sat inside a nested describe whose afterAll restored it before the App mode block ran, so that test drove the real orchestrator. Spies are now file-scoped. A sweep of all 245 test files confirms this was the only instance, not a class.

Proof: full adapters suite against a probe ARCHON_HOME goes from 7 entries to 0, with gitea/gitlab as controls at 0 both times and each stub separately shown load-bearing. Zero assertion changes across the six self-filtering tests. 14 mutants, 13 killed; the survivor is disclosed rather than papered over.

Closes #2305.
* fix: harden macOS installer detection

Fixed:\n- select the ARM64 release when a Darwin shell is translated by Rosetta\n- verify downloaded binaries before replacing an existing installation\n- keep the public installer mirror synchronized\n\nTests added:\n- deterministic installer platform, mirror, and failed-probe regression coverage\n\nSkipped:\n- PR artifact generation because this branch has no associated PR or registry entry

* fix: address installer review findings

Fixed:
- cover Linux and missing Rosetta-marker platform fallbacks
- cover successful candidate probe, replacement, and version output

Tests added:
- extended deterministic installer regression coverage

Skipped:
- none

* simplify: reduce complexity in changed files
Finalize DAG and loop tool timing when a provider emits tool_result, while retaining the existing next-tool and result fallbacks. Add deterministic regressions that exclude delayed assistant output from tool duration.
…-only migration rule (#2317)

* feat(core): record the database schema vintage and state the additive-only rule

Archon carries no record of which build created a database or last applied
schema to it. Schema convergence re-runs idempotent additive statements on
every connection from every process that opens the database, so an install
from six months ago and a fresh one can differ structurally on identical code
and nothing can say so. The property that keeps this safe -- every shipped
ADD COLUMN NOT NULL carries a DEFAULT -- was an accident of authorship rather
than a stated rule.

The rule is the important half. Both schemas are hand-maintained and have
drifted three times already (#988, #2033, and a live allow_env_keys
divergence found while investigating this), and the only automated guard
compares table names, not columns.

Changes:
- CLAUDE.md: additive-only migrations are now a hard rule; ADD COLUMN NOT NULL
  requires a DEFAULT; NOT NULL added to an existing CREATE TABLE body binds
  only new databases
- New remote_agent_schema_version table on both dialects, written from
  APP_VERSION by the existing idempotent apply-on-connect path, and only when
  the value changes. Postgres writes inside the existing advisory-locked
  transaction; SQLite's write is warn-and-continue so diagnostic metadata can
  never stop the database from opening
- created_app_version is NULL, never guessed, for databases that predate the
  table -- the unknowability is the reportable fact
- Surfaced in `archon doctor` and GET /api/health; nothing gates on it
- @archon/paths gains a bundled-build subpath export so the version constant
  does not have to travel through the barrel that 80+ test files mock

Deliberately not included: a migration framework, a versioned ledger, up/down
migrations, or any two-writer refuse/warn policy. This makes the vintage
legible; choosing a policy is a separate call.

Closes #2316

* fix(core): isolate the Postgres vintage write behind a savepoint

Self-review found a real asymmetry: the SQLite vintage write is wrapped in
warn-and-continue so it can never stop the database from opening, but the
Postgres write was fused into the schema transaction with no isolation. A
throw there would reject schemaInitPromise -- which every query() and
withTransaction() awaits -- bricking the adapter for the life of the process
over a row nothing gates on. That silently broke the "diagnostic only"
contract this change itself states, on the dialect the VPS runs.

Review fixes:
- Postgres: SAVEPOINT/RELEASE around the upsert, ROLLBACK TO SAVEPOINT and
  warn on failure, so the schema transaction still commits
- Postgres: extract the block into recordSchemaVersion(), mirroring the
  existing installNotifyTrigger() precedent and the SQLite method name
- api.ts: shape the health payload next to the read, so the field filtering
  (createdAt is deliberately not exposed) lives in one place
- doctor.test.ts: hoist a makeDeps() helper, matching the convention the
  checkFolderProject block in the same file already uses
- schema-version.ts: inline the table name; the exported constant had one
  reader and was not a single source of truth

Declined from the simplifier: collapsing SQLite's read-then-branch into a
single UPSERT. It is tidier, but an INSERT ... ON CONFLICT whose DO UPDATE
WHERE fails still opens a write transaction, so every process that opens the
database -- including every CLI invocation and every --detach child -- would
take a write lock where a SELECT suffices today, and a read-only database
file would warn on every open instead of only when a write is actually
needed.

* fix(core): do not stamp a vintage onto a partially-migrated SQLite database

migrateColumns() suppresses each table's failure so one bad ALTER cannot abort
startup, but recordSchemaVersion() then stamped APP_VERSION unconditionally. A
database missing a failed migration would therefore report that this build last
applied its schema -- a wrong answer that gets believed, which is the outcome
#2316 itself ranks as the most damaging.

migrateColumns() now returns whether every block succeeded, and the vintage
write is skipped (with a warn naming the version that was withheld) when it did
not. A stale or absent vintage is a state a reader can act on; a confidently
wrong one is not. The next clean open records it.

Test seeds a view named remote_agent_users so the users ALTER genuinely fails,
then asserts no vintage row is written and readSchemaVersion() returns null.

Reported by CodeRabbit on PR 2317. Its other two findings -- duplicate `issued`
declarations in postgres.test.ts and duplicate type assertions in
sqlite.test.ts, both flagged critical -- are false positives: each declaration
is in its own test scope, there are no duplicated assertion lines, and both
tsc and the suites run clean.
…l mirror (#2340)

* fix(install): repair the curl|bash entry point and sync the PowerShell mirror

#2330 added a `[[ "${BASH_SOURCE[0]}" == "$0" ]]` guard so test-install.sh could
source the installer to unit-test detect_platform. Under `set -u` and with bash
reading the script from stdin -- which is exactly what `curl -fsSL … | bash` does
-- BASH_SOURCE[0] is unbound, so the guard aborted the script before main() ran.
The documented install path failed for every user on every platform, turning
#2295 from "installs but won't run on one Mac config" into "installer hard-fails
for everyone". Not yet live: deploy-docs.yml only publishes from main.

Uses `${BASH_SOURCE[0]:-$0}`. Verified reaching main when piped and when executed
by path, and still correctly skipped when sourced -- so detect_with_mocks keeps
working and does not regain its live-network-plus-sudo behaviour.

Also fixed:

- Post-install verification was removed rather than moved. The pre-install probe
  runs against the temp download and its output was cached; after `mv` the
  installer printed that cached string and declared success without ever
  executing the installed file. It now re-runs the version check against
  $INSTALL_DIR/$BINARY_NAME, which is the artifact the user actually invokes.
  A post-install check that never runs the installed binary cannot detect the
  failure class #2295 reported.

- packages/docs-web/public/install.ps1 had already drifted from
  scripts/install.ps1: it was missing the @() array wrapper added by 53cabd4
  ("PowerShell Add-ToUserPath corrupts PATH when single entry exists", #1000).
  `irm https://archon.diy/install.ps1 | iex` is the documented Windows install
  path, so the public installer has been shipping that PATH corruption since the
  repo copy was fixed. Line 191 was the only difference; the mirror is now
  byte-identical.

Tests:

- A piped-stdin regression test. Every existing case invokes the installer by
  path or sources it, so none could catch this. Verified failing against the
  installer at 0872317 with both mirrors reverted (so the parity check does not
  mask it) and passing after the fix.
- A cmp parity assertion for the .ps1 pair, matching the one #2330 added for the
  bash pair. The .ps1 pair had no drift protection at all, which is why #1000
  went unmirrored unnoticed.

Closes #2338
Closes #2339

* test(install): assert the piped installer's exit status, not just its stderr

The regression test captured stderr with `|| true` and asserted only that
"unbound variable" was absent. That passes if the installer aborts at the same
boundary for any other reason while still leaving a usable binary behind -- it
tested for one known message rather than for success.

Now captures the status and asserts 0, so the test covers the boundary rather
than the symptom. Still verified failing against 0872317 with both mirrors
reverted: "Piped installer exits successfully: expected 0, got 1".

Raised by CodeRabbit on #2340.

* fix(install): make the mirror parity check portable and stop a guard regression reaching the network

Two review findings, neither introduced by this PR, both in files it already edits.

.gitattributes: scripts/install.sh is pinned to LF by `*.sh text eol=lf`, but the
published mirror has no extension, so it fell through to `* text=auto` and is
converted to CRLF on a core.autocrlf=true checkout (the Windows default). `cmp`
compares bytes, so the parity assertion failed on a clean tree -- a Windows
contributor running `bun run validate` got "public installer mirror differs" with
nothing actually wrong. Pinned the mirror to LF.

test-install.sh: detect_with_mocks SOURCES the installer, so it relies on the
source guard suppressing main(). make_platform_mocks stubbed only uname and
sysctl, so a guard regression -- the exact failure this PR repairs the other half
of -- would run main(), hit the real release URL via curl, and reach sudo mkdir/mv
with a non-writable INSTALL_DIR, during `bun run validate`. Added hard-failing
curl/sudo stubs.

Verified by neutralising the guard to `if true`: the stub fires with
"TEST BUG: sourced installer invoked curl" instead of the request going out.

Raised in review of #2340.

* test(install): report the cause of a piped-installer regression, not just the exit code

fac6e4c put the exit-status assertion before the stderr case, so on a
regression assert_equals fired first with "expected 0, got 1" and the case block
never ran -- dead code, and no hint at the cause. Reordered: specific diagnosis
first, generic assertion as the catch-all, so both are live.

Verified against 0872317 with both mirrors reverted:
"FAIL: installer aborts when piped to bash (BASH_SOURCE unbound under set -u) — see #2338"

Raised as S1 in review of #2340.
* fix(providers): record resolved model metadata

* fix(workflows,providers): record resolved model for loop nodes; disambiguate multi-model usage

Two gaps in the #2314 resolved-model record.

Loop nodes recorded neither half. executeLoopNode owns its own sendQuery and
chunk loop, so it never saw msg.resolvedModel, and its node_started row omitted
provider/model/tier. Thread the resolved provider/model/tier from the dispatcher
into the loop's node_started (both persisted row and emitter event), capture
msg.resolvedModel per iteration (last-seen wins, like loopFinalStopReason), and
write model_usage on node_completed — mirroring the AI-node path exactly, and
omitting the field entirely when the provider reports no model.

loop_group needs no change: its bodies run through runLayers -> executeNodeInternal,
which already records both halves (verified against the iterCtx dispatch).

Claude's modelUsage can hold more than one key — a subagent pinned via agents:,
or a fallbackModel takeover — so Object.keys()[0] silently picked whichever came
first. Select the greatest-output-token entry instead and warn
(claude.resolved_model_ambiguous) so a multi-model turn is visible rather than
collapsed. Tie / missing counts keep the first key, i.e. the prior behavior.

Tests: multi-key selection with a deliberate non-first winner, empty-record
absence, and single-key warns-not-called; loop-node coverage for both halves plus
absence when the provider reports no model. Also corrects the existing
modelUsage mock to the SDK's camelCase field names.

* fix(workflows): clear the resolved model when a later result omits it

Both capture sites guarded the assignment (`if (msg.resolvedModel)`), which can
set a value but never clear one. Pi and Copilot reask loops emit several result
chunks and Pi omits resolvedModel when its later assistant message carries no
responseModel -- so an earlier attempt's model was persisted as the final
attempt's answer.

That is fabricated attribution, which is the exact defect #2314 exists to
prevent: absence must stay absence rather than being filled from a stale read.
Applies to both the AI-node path and the loop path added in c215734, which
mirrored the flaw along with the pattern.

Assigns unconditionally at both sites.

Test: two results in one loop iteration via the background-task wait (the #2083
shape), the first reporting a model and the final one not. Verified failing with
the guard restored and passing without it, so it covers the behaviour rather than
the code path. Full @archon/workflows suite green across all 18 batches;
type-check clean.

Raised by CodeRabbit on #2337.
* Run installer tests in CI

* fix: preserve installer mirror line endings on Windows

* fix(ci): skip the installer test suite on Windows

The new "Run installer tests" step made test (windows-latest) permanently red.

scripts/install.sh refuses MINGW/MSYS by design -- Windows users are directed to
WSL2 -- and test-install.sh invokes the real installer with only curl mocked, so
uname -s is not stubbed at that call site. On windows-latest, Git Bash reports
MINGW64_NT, the installer exits with "Windows is not supported", and `set -euo
pipefail` in both scripts fails the job.

Guarded with `if: runner.os != 'Windows'`. The ubuntu leg still runs the suite, so
mirror-drift coverage in CI is unaffected -- byte parity is platform-independent
and only needs asserting once.
…pawning tar (#2310)

* test(cli): build the web-dist tarball fixture in-process instead of spawning tar

The windows-only `downloadWebDist` timeout is Bun's timeout-with-a-live-child
path — the `tar` child had not exited after 5 s. It is not slowness, and not a
stream that failed to EOF.

Proof, in three independent pieces:

1. Reproduced the exact CI signature locally by hanging a `beforeAll` while a
   spawned child was still alive:

     killed 1 dangling process
     (fail) ... > (unnamed) [5000.97ms]
       ^ a beforeEach/afterEach hook timed out for this test.

   The same hook hung on an unrelated promise, with an already-exited child,
   produces the timeout WITHOUT the `dangling process` line. So that line means
   "a child was still running when the budget expired" — it is not noise.

2. A sweep of 599 test.yml runs (2026-06-26 → 07-29) agrees: `killed N dangling
   process` appears 0 times in 16 passing job logs, and on exactly the 3 windows
   timeouts that spawn a subprocess — never on the 4 that don't.

3. In the failing run (29801864393), `afterAll`'s `rmSync(tmpRoot)` failed with
   `EBUSY: resource busy or locked` — the only EBUSY in the whole corpus. The
   `tar` child still held a handle on the temp dir. Independent confirmation
   that it was alive rather than merely unreaped.

The three `downloadWebDist` `it()` bodies appear in that log neither as pass nor
fail, which is only consistent with `beforeAll` failing — Bun mislabels a
`beforeAll` timeout as "a beforeEach/afterEach hook".

So the fixture stops shelling out: it emits a ~1.1 KB ustar archive and gzips it
in-process. The hook is now synchronous and has no child to hang on. The three
`it()` bodies still feed that fixture to the real `tar xzf -`, which is what
proves the hand-rolled archive is valid.

Also: `overlay-scripts.test.ts` routes its shell through `resolveBashPath()`
rather than a bare `execFileSync('bash', …)`. It was the only bash spawn in the
repo bypassing that helper, and on Windows a bare `bash` resolves through
System32 to the WSL launcher instead of Git-Bash (#1326). This is consistency
hardening, not a flake fix — no isolation test timeout appears in the swept
corpus.

Ruled out, so the next attempt does not redo it:

- No production code is in this class. A repo-root sweep over `Bun.spawn`,
  `Bun.$`, `child_process`, and every `new Response(proc.*)` site found the
  pattern only in tests. Bun eagerly drains piped stdio into an internal buffer
  (verified with 5 MB of undrained stderr behind `await proc.exited`), so
  `downloadWebDist`'s own extraction step — which awaits exit before reading a
  piped stderr — cannot deadlock.
- Exit code 130 carries no signal. Under `--filter '*' --parallel` the failing
  package exits 1, siblings get SIGINT, and the aggregate reports 130 or 1
  nondeterministically; 130 also shows up on plain assertion failures.

Scope boundary: this removes one of the three subprocess-hang sites. The
`@archon/workflows` `bash node … stdout` test (2 of the 3 occurrences, most
recently 07-29) spawns a real shell because that is the thing under test, and
cannot be fixed this way. Runner starvation under `--filter '*' --parallel`
remains the open lever in #2306.

Refs #2186, #2240, #2306

* test(cli): validate the tarball fixture, and correct the cluster tally

Review found the fixture could rot silently — the exact failure mode a
hand-rolled binary format invites, and worse than the hang it replaced.

The three extraction tests asserted only `existsSync(index.html)`. A `size`
field short by 3 bytes extracts `"<html>ok</ht"` with `tar` exit 0 and all 18
tests green. Mutation-tested each corruption, applied then reverted:

  corruption                        before      now
  size field short by 3 bytes       18 pass     2 fail (content)
  header checksum zeroed            —           2 fail (extraction)
  end-of-archive terminator dropped 18 pass     1 fail (structural)
  block padding dropped             18 pass     1 fail (structural)

So: the extraction tests now compare the extracted file's CONTENT to the
declared fixture string, and two new tests check the archive against the POSIX
ustar envelope (whole 512-byte blocks, trailing 1024 zero bytes). The structural
pair is not redundant — local `tar` is bsdtar 3.5.3, which happily extracts an
archive with no terminator and no padding, so those two corruptions passed on
macOS and would have surfaced only as a platform-specific CI failure. That is
the class of bug this file is being changed to remove.

Still uncaught: a bad `ustar` magic string, which is benign — `tar` extracts
byte-correct content regardless.

Also retracting a false claim from the previous revision. It stated that
`@archon/isolation`'s `.wh.` test "has never timed out" and that #2306's table
mis-attributed it. Both wrong. Job 90566157638 (run 30448971374) contains:

  @archon/isolation:test | killed 1 dangling process
  @archon/isolation:test | ::error title=error: Test "`.wh.` (empty decoded
                           name) does NOT wipe the parent dir" timed out after
                           5000ms::
  @archon/isolation:test | (fail) apply script — C1 whiteout-name traversal …
                           [5015.00ms]

#2306's row is correct, the mechanism matches (synchronous `execFileSync` blocks
the loop; child still alive at 5 s; killed as dangling), and the cluster has FOUR
subprocess sites, not three. This PR removes one of four — which makes `Related`
over `Closes` more correct, not less.

The claim came from a sweep that missed run 30448971374, compounded by
`gh run view --job <id> --log` silently returning a LATER run's logs. The API
path `gh api repos/.../actions/jobs/<id>/logs` returns the right bytes. A clean
grep from the wrong source is indistinguishable from evidence.

The WSL hypothesis stays rejected (zero markers for `wsl`, `/mnt/c/`,
`System32\bash`), so `resolveBashPath()` remains labelled hardening rather than a
proven fix — but it is hardening on a real cluster site, not on a non-problem.

One nuance the corrected corpus adds: the same job shows a `downloadWebDist`
timeout WITHOUT the dangling marker. Presence of the marker proves a live child;
absence proves nothing. Only the positive direction is load-bearing, and that is
the direction the local repro establishes.

Refs #2186, #2240, #2306

---------

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
* Fix: clear stale workflow errors on resume (#2329)

Resumed workflow runs retained the prior attempt's metadata error, causing completed runs to display stale SIGTERM failures.

Changes:
- Clear metadata.error atomically in the resume CAS
- Cover PostgreSQL SQL binding and real SQLite JSON cleanup
- Preserve failed-run output while suppressing cleared completed errors

Fixes #2329

* fix(core): preserve the error the resume CAS clears as an audit event

Clearing metadata.error on resume (#2329) assumed the failure history
survives in workflow_events. For the motivating run it did not: the CLI's
SIGTERM handler calls failWorkflowRun and writes neither an event nor a
log line, so metadata.error was the ONLY record that the run ever failed
— and the clear deleted it.

resumeWorkflowRun now reads that error, runs the CAS, and inserts a
workflow_resumed event carrying it, all in ONE transaction (the shape
resolveApprovalGate established in #2146):

- The row is pinned for the read (FOR UPDATE on Postgres; SQLite needs
  none — its adapter serializes transactions and a cross-process writer
  makes the deferred BEGIN's read→write upgrade fail busy rather than
  admit a stale snapshot), so the value read is the value cleared.
- The event is gated on the CAS rowCount, so a losing concurrent resumer
  writes nothing at all.
- No event when there was no prior error — a paused gate resuming clean
  does not gain a spurious "this failed once" record.
- A failed event INSERT rolls the clear back, leaving the run resumable
  with its error intact.

Read-then-UPDATE rather than UPDATE…RETURNING because the SQLite adapter
rejects RETURNING on UPDATE and points at exactly this pattern.

workflow_resumed joins WORKFLOW_EVENT_TYPES and the console normalizer's
system branch, where data.error (the same key workflow_failed uses)
renders as the event detail instead of falling through to raw JSON.

Also drops the CLI workflowGetCommand test added alongside the original
fix: it passes unchanged against dev, because the renderer already
guarded on `typeof … === 'string'` and so never printed a null error. It
proved nothing and must not be counted as regression coverage. The real
coverage is in workflows.resume-cas.integration.test.ts, whose three new
cases fail against both dev and the pre-fix commit.

Verified on real SQLite; the Postgres row lock is covered by the mock
suite's SQL assertion.
* Fix: detect SQLite and Postgres column drift (#2319)

The schema parity suite compared table names only, allowing missing SQLite columns to ship undetected.

Changes:
- Parse Postgres CREATE TABLE and ALTER TABLE column declarations
- Compare every shared Postgres column against fresh SQLite schema columns
- Keep the #2318 allow_env_keys exception exact and retain parent_run_id index coverage

Fixes #2319

* fix: address review findings

Fixed:\n- cover ALTER-only PostgreSQL migration columns in the schema parity test\n- document the table and column parity exceptions\n\nTests added:\n- strengthened existing SQLite schema parity coverage\n\nSkipped:\n- none

* fix(test): make schema-parity checks blind-spot proof

Follow-up to the column-level parity check. Three ways it could pass while
seeing nothing:

1. Table discovery had become a by-product of column-body parsing (the list
   came from postgresArchonColumns().keys()), so a table only registered if
   its full `(...);` body matched. A `)\n;` terminator made the *following*
   table vanish from the map entirely — re-opening the PR #2033 drift class
   (whole table in the migration, missing from sqlite.ts) that the original
   table-parity test exists to catch. Table discovery is back on a
   body-independent `CREATE TABLE <name>` regex.

2. The non-greedy body match ended at the first `);`, including one inside a
   comment. `migrations/000_combined.sql` already writes `);` in prose
   (lines 447, 467, 469), so this is house style, not a contrived input:
   an ordinary `-- see remote_agent_workflow_runs(id);` cut
   remote_agent_workflow_events from 7 parsed columns to 3 with the suite
   still green. Comments are now stripped first, and bodies are read with
   paren-depth tracking and split on top-level commas, so nested parens in
   REFERENCES / CHECK / DEFAULT clauses cannot end a body early.

3. The anti-vacuity pair only proved two named columns survived parsing —
   both stayed true while four others silently disappeared. Added a floor on
   the number of columns actually compared (136 today, verified by execution;
   the truncating parser scores 132).

Also adds the reverse pass. isolation_environments.updated_at exists in
sqlite.ts and not in the migration, and is the only reverse-direction
difference in the schema with no SQLite-only tables, so the check costs one
allowlist entry. Reverse drift is the works-locally / breaks-on-the-Postgres-VPS
direction, which is worth a guard.

Both allowlists are now self-expiring: each entry must still describe real
drift, so fixing #2318 fails the suite until the entry is deleted rather than
quietly leaving a live column unprotected.

Comparison stays names-only — SQLite type affinity means a declared TEXT
against a Postgres UUID constrains nothing, so a type map would add cost
without catching a bug.

* test(db): report schema drift before the vacuity floor, not after

The MIN_NON_AUTH_COLUMNS floor was asserted before the missing-column check in
the same test, so it could mask the drift it exists to protect. Demonstrated:
two legitimate column removals plus one genuine drift made the floor fire first
and the drift list never printed.

Drift is now asserted first. The floor follows as an explicit throw rather than
an expect(), because a bare "Expected: >= 136 / Received: 135" under a test
named "every non-auth Postgres column exists in a fresh SQLite schema" reads as
drift when it is either a parser regression or a legitimate removal -- the
message now says which to check and how to respond.

136 kept with no headroom, deliberately: the collapse this guards cost only 4
columns (workflow_events 7 -> 3), so a lower floor would miss it.

Verified: 20/20 on the clean tree; with 2 removals + 1 injected drift the run
now names remote_agent_workflow_events.zz_real_drift, which the previous
ordering swallowed.

Raised in second-pass review of #2346.
* Fix: persist per-node workflow token usage (#2333)

Workflow node token usage was only written to local JSONL logs, leaving remote event consumers without provider-neutral per-node usage data.

Changes:
- Persist direct AI-node token usage in node_completed events
- Persist aggregated loop and loop-group token usage
- Add regression coverage for all successful AI-derived completion paths

Fixes #2333

* fix: address review findings

Fixed:\n- cover omission of unreported token usage in completion events\n\nTests added:\n- direct AI and loop_group no-usage completion coverage\n- loop no-usage assertion\n\nSkipped:\n- none

* fix(workflows): close the remaining per-node token gaps (#2333)

Review findings on the initial persistence change.

B1 — loop_group double-counted the persisted event stream. A loop_group's
body nodes write their own namespaced node_completed rows, so persisting the
group total under the same `tokens` field name meant a consumer summing
node_completed rows got 600/60 for 300/30 of real usage, with nothing in the
row marking it as an aggregate. Chose omission over a discriminator: the
leaves are authoritative (per-provider, so usable for the cross-provider
comparison the issue exists to enable) and already summable by step-name
prefix, and omission means a naive consumer gets the right total without
having to know about a discriminator field. The return value still carries
the total — that is the run-level roll-up, which counts each group once.
`cost_usd` has the same double-count shape and predates this; left as-is and
noted rather than silently changed under a token fix.

B2 — the `workflow:` sub-run node persisted the child's rolled-up cost but
not its tokens, dropping the one axis every provider reports. Added.

B3 — finalizeLoopFromSignal (bare approve at a signalled gate) wrote a
node_completed with no usage for iterations that really ran. The gate now
carries the pausing invocation's usage across the pause in the approval
context, matching what the normal re-run completion path reports. Cost and
resolved model are lost across the same gate; those belong to the single
"preserve terminal provider stats across a gate" change tracked by #2345.

Also normalizes the direct-node capture to `{input, output}` with a
finite-value guard, mirroring the loop-node capture: `total` is
provider-defined and is not input + output (Pi folds cache tokens into it),
`cost` duplicates cost_usd, and a NaN would persist as a null a consumer
would believe.

* fix(workflows): drop the loop_group finalize token double-count (#2333)

finalizeLoopFromSignal is shared by executeLoopNode and executeLoopGroupNode.
Passing it the gate's signaledTokens is right for a plain loop -- its
per-iteration rows carry no tokens, so the finalize row is the only record of
the usage -- but wrong for a loop_group, whose body nodes persisted their own
`<groupId>.<nodeId>` rows (with tokens) BEFORE the pause. Those rows survive the
pause, so the finalize row repeated usage already in the same event stream: a
consumer summing `data.tokens` read 200/20 for 100/10 of real usage.

This is the same double-count the natural-completion group row already avoids by
omitting `tokens`; the gate path had simply not been covered.

- loop_group finalize call site no longer passes finalizeTokens
- loop_group gate pause no longer writes signaledTokens (it had no consumer left)
- the schema field and the pauseWorkflowRun explicit-null reset stay: the plain
  loop still writes the field, and the reset still clears a stale value from a
  prior gate in the same run

Tests: a loop_group gate -> bare approve -> resume across ONE shared event store,
asserting the naive sum over persisted node_completed rows equals the true usage.
Per-test isolation is what hid this: the pre-pause body rows have to survive into
the resume for the double-count to appear at all. Plus a guard that the
loop_group gate context carries no signaledTokens.

Also corrects a measurably false claim in the signaledTokens doc comment: earlier
invocations' usage is NOT visible in the run-level totals. A pausing invocation
never reaches completeWorkflowRun (status is `paused`, so the pre-complete check
bails) and `total_tokens_*` are written only there -- measured on a twice-gated
loop, 40/4 then 60/6, the node row and the run row both report only 60/6. That
under-report is pre-existing and belongs to #2345; only the comment changed.
…2297)

* ci: attach provenance and SBOM attestations to the published image

* ci: restore trailing newline at end of file
Records the position taken when declining #2131 (Nix flake + Devbox): the
maintained install channels are the installer, Homebrew, Docker, and the
GitHub release binaries. Additional package-manager channels belong in docs
as community recipes, or upstream in the package manager's own registry —
not as in-repo manifests Archon version-bumps every release.

Reasoning mirrors §deployment-recipes: each maintained hash-pinned channel
doubles a release-critical surface, and rots silently between releases when
nothing exercises it. Stating it here so the next AUR/Scoop/winget proposal
gets an answer before the work is written, rather than a per-PR judgment call.

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…#2292)

findResumableRunByParentConversation ordered candidates by started_at
alone, but the two statuses it can return are not interchangeable for its
caller: a 'paused' run is an open approval gate that gets hydrated and
resumed in the foreground, while a 'failed' one is deliberately gated
behind an explicit user prompt first (#1549).

Bare recency therefore lets a newer, unrelated failure shadow an older
waiting gate — approving that gate resumes nothing and the user instead
gets a resume prompt for the wrong run. #2075 made this more likely to
surface: approve/reject no longer stage a fake 'failed', so gated runs
stay 'paused' while genuine failures accumulate alongside them.

Order status-first, then recency within status. Contrast documented
against getActiveWorkflowRunByPath, which sorts older-wins because it
answers a different question (who holds the path lock).

Adds the first test coverage for this function.

Closes #2289
* Fix: accumulate tokens across resumed workflow runs (#2352)

Resumed DAG runs discarded token usage from earlier failed executions because resume hydration restored only node outputs.

Changes:
- Restore cumulative token usage from completed-node events
- Seed resumed DAG token counters with prior usage
- Add reconciliation and malformed-event regression coverage

Fixes #2352

* fix: address review findings

Fixed:\n- Log malformed persisted gate token metadata with run and node context\n- Avoid warnings for absent optional historical token usage\n- Cover resume snapshot hydration into DAG execution\n\nTests added:\n- Gate-token fallback, missing token usage, and resume hydration coverage\n\nSkipped:\n- None

* simplify: reduce complexity in changed files
…ected (#2358)

The command's decision tree already routes a worktree run to "use the branch you are
on", but the clean-working-tree requirement sat in the sibling ON $BASE_BRANCH case with
nothing scoping it there. Three archon-fix-github-issue runs stopped at the implement step
over .archon/ files that Archon itself had copied into the worktree before the agent
started.

Hoist the disambiguation above the decision tree rather than adding another rule inside
it: a dirty tree is expected in a worktree, .archon/ edits are never the implementer's to
commit or remove, the clean-tree requirement applies only to the ON $BASE_BRANCH case, and
this overrides a stricter git precondition imposed by a loaded skill — which is where the
stricter precondition was coming from.

Classify the checkout with git-dir vs git-common-dir, not git worktree list: the latter
lists every worktree including the primary checkout, so it reads identically from both and
would let an agent claim the worktree exemption on the primary checkout. Dirty paths
outside .archon/ are likewise not a reason to stop and not the agent's to commit — stage by
name, and check git diff --cached --name-only before every commit.

Closes #2328
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f435ceaf-e303-4ea4-8b9a-c48ac4e4e31a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.