Skip to content

Release 0.7.1 - #2435

Merged
Wirasm merged 412 commits into
mainfrom
dev
Aug 4, 2026
Merged

Release 0.7.1#2435
Wirasm merged 412 commits into
mainfrom
dev

Conversation

@Wirasm

@Wirasm Wirasm commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Release 0.7.1

Workflow runs now record what they actually resolved to — assistant, model, effort, isolation, base branch — so two runs can be told apart after the fact. Plus retry classification for transient Codex failures, and a batch of installer and CLI repairs.

Added

  • Per-dispatch --base overridearchon workflow run --base <branch> sets the branch the worktree is cut from and the PR targets, for that run only. Base resolution was static per codebase (worktree.baseBranchdefault_branch → git auto-detect), all of which describe the repo rather than the run, so fanning out parallel dispatches against one repo with different bases had no encoding short of editing repo config. (feat(cli): per-dispatch --base override for worktree cut-from + PR target #2203)
  • archon-parse-user-request replaces extract-issue-number, parsing the operator's message into structured fields — the verbatim request, issue number, repo shorthand, and repo URL — instead of a number alone. (feat(commands,workflows): archon-parse-user-request replaces extract-issue-number #2420)
  • Compact node summaries in verbose JSON--verbose --json returns ordered node summaries including startedAt by default, with stable ordering for tied timestamps, so machine consumers no longer have to recreate the CLI's node-state fold or filter tool-event noise. (feat: summarize verbose workflow JSON nodes #2414)

Changed

Fixed


Merging this PR releases 0.7.1 to main.

Wirasm and others added 30 commits May 29, 2026 16:47
* 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
* fix(workflows): fail dag node on idle-timeout with zero output (#1807)

The `!nodeIdleTimedOut` exemption in the empty-output guard caused
`review-classify` (and any node with `output_format` + idle_timeout) to
report `completed` with empty output when the provider never emitted any
tokens before the watchdog fired. Downstream aspects then evaluated
`$review-classify.output.run_X` against an empty string, every `when:`
gate failed, all aspects were skipped, `synthesize-review` skipped on
`one_success`, and the workflow reported success having reviewed nothing.

Changes:
- dag-executor: gate the "completed via idle timeout" warning behind
  `hasAnyOutput` so it only surfaces when the AI actually produced
  content (the "subprocess hung after finishing" case).
- dag-executor: remove the `!nodeIdleTimedOut` exemption from the
  empty-output failure guard; emit a distinct, actionable error message
  for the idle-timeout-with-zero-output case.
- maintainer-review-pr.yaml: drop the 60s `idle_timeout` on
  `review-classify` (copy-paste artefact, tightest timeout on the
  largest prompt in the workflow); inherits the 30-minute default.
- dag-executor.test.ts: add cases documenting that a failed
  `review-classify` correctly cascades to skipped aspects and a skipped
  synthesize step.

Fixes #1807

* fix: address review findings for PR #1812

- Condense multi-line comment blocks to single lines (CLAUDE.md compliance)
- Add invariant comment for structuredOutput check
- Fix time unit inconsistency: idle-timeout failure message now uses minutes
  to match the existing completion warning (both paths now use / 60000)
- Add integration test: idle-timeout with zero output → node_failed, not node_completed
- Add integration test: idle-timeout WITH output → node_completed + warning sent
- Update stale troubleshooting row in quick-reference.md to reflect the new
  explicit failure behavior instead of the old "workflow hangs" description
- Add CHANGELOG [Unreleased] entry for the zero-output idle-timeout fix (#1807)

* simplify: reduce nesting in idle-timeout guard and inline one-shot callback
…events, machine context, custom-vs-default) (#1815)

* feat(telemetry): expand anonymous PostHog capture (started/completed events, machine context, custom-vs-default)

Expands the post-#1780 telemetry from a single intent-only `workflow_invoked`
event into a small, decision-useful event set — all categorical/anonymous and
inside the existing IP-free promise. Geo is deliberately excluded.

Events:
- archon_started (once per CLI invocation / server boot) — the basis for honest
  active-install + DAU/WAU/MAU counting (users who only run doctor/serve/chat
  were previously invisible).
- workflow_invoked (extended) — workflow_source/is_builtin, provider, model,
  node_count, uses_loop/approval/script/bash, interactive, used_isolation,
  is_resume, schema_version.
- workflow_completed / workflow_failed (new) — outcome, duration_ms, node
  counts, categorical exit_reason. Fired at the existing emitter sites so de-dup
  is inherited (no double-count on the unhandled-throw path).

Privacy:
- Machine context (os/arch/version/is_binary/runtime/is_ci/is_tty) attached to
  every event via PostHog register() super-properties.
- The privacy invariants ($process_person_profile:false, $ip:'') are kept
  per-event via a shared PRIVACY_INVARIANTS const (not super-properties), so a
  super-property regression can never silently leak IP or create a profile.
- Bundled workflows report their real name; custom/global report "custom" so
  private names never leave the machine.
- README, .env.example, and docs updated; first-run notice copy refreshed and
  the notice stamp bumped to -v2 so existing installs re-consent once.

Plumbing: WorkflowSource threaded from discovery through ExecuteWorkflowOptions
to the executor/dag-executor (CLI, background dispatch, approval-resume, and the
single-codebase resolve path are exact). Two orchestrator routes
(handleWorkflowInvocationResult, handleWorkflowRunCommand) keep the
privacy-conservative default (source undefined -> "custom"); threading them
fully is a fast-follow. install_method (brew/curl/npm/docker) deferred to a
follow-up; is_binary ships now.

* refactor(telemetry): address PR review findings

Follow-up fixes from the multi-agent review of the telemetry expansion:

- Remove dead 'cancelled' outcome from WorkflowCompletedProperties (no
  emitter; cancellation is intentionally untracked) and document why.
- Narrow exitReason from string to a WorkflowExitReason union so the
  "categorical, never error text" contract is compile-time enforced.
- Add sanitizeModelForTelemetry guard: model ids are user-supplied, so only
  forward values matching a real model-ref shape; drop free-text.
- Narrow classifyWorkflowForTelemetry return to WorkflowTelemetrySource.
- Extract a single fireAndForget helper for the three capture functions so
  the swallow-at-debug error policy lives in one place.
- Coerce is_tty so it is present-and-false on non-TTY (server) processes
  instead of silently omitted (bun-types mis-types isTTY as always-boolean).
- Reword captureArchonStarted docstring (call-site once-per contract, no
  in-function dedup) and note captureWorkflowCompleted skips the notice.

Tests:
- executor: assert workflow_failed/unhandled_error fires on the throw path,
  is not fired on success, and that source threads to executeDagWorkflow.
- dag-executor: assert completion telemetry at all three terminal sites
  (completed / no_nodes_completed / node_error) with the threaded source.
- telemetry: cover sanitizeModelForTelemetry; drop a stale archonVersion prop.

Docs:
- security.md: add an Anonymous Telemetry section cross-linking the config
  reference (the privacy page previously covered only local logging).
- README + configuration.md: clarify model id ships only on workflow_invoked.

* fix(telemetry): address CodeRabbit review on the expansion

- cli: emit archon_started before the no-args / --version / --help / parse-
  failure early returns so every CLI invocation is counted (was placed after
  them, undercounting the metric). Each of those paths now flushes telemetry
  before exit; the version path already did.
- tests (dag-executor): assert completion telemetry fires exactly once at each
  terminal site and pin workflowSource on the failure paths, guarding against
  the double-count risk and failed-path source loss.
- tests (executor): assert the unhandled-throw completion fires exactly once.
- tests (telemetry): stub fetch and flush deterministically in the enabled-path
  tests so they never touch the network or leave a live flush timer.

Skipped (with reason):
- "align @archon/paths mock.module shape across workflow tests": not applicable
  here — the workflows test script runs each file in its own `bun test` process
  (see package.json), so there is no cross-file mock.module pollution.
- "add an unhandled_error test in dag-executor.test.ts": that exit reason is
  emitted by executor.ts's catch, not the DAG executor; it is covered in
  executor.test.ts.
…-C) (#1823)

* feat(core): per-user GitHub token store + device-flow engine (PR-C foundation)

Foundation layer for per-user GitHub attribution (PRD Phase 3). Pure additive
infrastructure — nothing wires into the adapters/workflows/web yet, so there is
zero behavior change. Subsequent increments add the invocation gate, token
policy, git-config plumbing, connect surfaces, and interim web auth.

- token-crypto: AES-256-GCM encrypt/decrypt + TOKEN_ENCRYPTION_KEY validation
  (cherry-picked from the #1774 donor branch; decrypt rewritten for strict TS)
- github-auth/config: isPerUserGitHubEnabled (App + key), device-flow client-id
  loader, assertEncryptionKeyAtBoot fail-fast
- remote_agent_user_github_tokens table (PG migration + bundled-schema regen +
  SQLite createSchema); one row per user, encrypted access/refresh tokens
- db/user-github-token-store: save (UPSERT), get, decrypt-with-refresh-on-read
  (5-min buffer, per-user mutex against double-refresh), delete, no-reply email
- github-auth/device-flow: hand-rolled start/poll/refresh/fetchUser over fetch
  (no client_secret, slow_down + authorization_pending handling)
- github-auth/connect-service: device flow -> profile -> conflict-guarded
  identity bind -> encrypted token persist -> cached display_name/email
- users: updateUserGithubProfile, linkGithubIdentity, GithubIdentityConflictError

Tests: 30 new (crypto/device-flow/token-store, 10 each). bun run validate green.

TS filenames carry -row/-store suffixes to satisfy the local secret-guard hook;
the DB table is remote_agent_user_github_tokens.

* fix(core): address multi-agent review findings on per-user github tokens

Hardening + test coverage for the dormant per-user GitHub token store and
device-flow engine. No behavior change for existing installs.

Correctness:
- token store: separate the refresh call from the DB save so a persist failure
  after a successful refresh is no longer mislabeled `refresh_failed` and the
  freshly-issued (now-valid) token is returned instead of discarded; the failure
  logs `persist_failed` (error) (I1)
- linkGithubIdentity: add UNIQUE-violation race recovery (re-SELECT, conflict if
  a different user won, no-op if same user) so a concurrent double-connect no
  longer surfaces a raw 23505 (I2)
- getUserGithubTokenRecord: type the raw read as number|string and coerce
  github_user_id, honest against node-postgres returning BIGINT as a string (I5)

Types/logging/docs:
- row schema: widen token-expiry timestamps to date|string (PG Date vs SQLite
  ISO text) (I6)
- connectGithubForUser: emit github_connect.started/.failed per logging
  convention (I7)
- token store: debug log on the not-connected path; relinked + race-recovered
  audit logs on linkGithubIdentity (S3/S5)
- device-flow: floor-guard the slow_down backoff; surface GitHub's error_description
  from postForm (S1/S2)
- getEncryptionKey: accept an env param so assertEncryptionKeyAtBoot is consistent
  and injectable
- fix stale updateUserGithubProfile comment (I8); CLAUDE.md 11->12 tables and
  migration header table list, regenerated bundled schema (D1/D2)

Tests (+20): linkGithubIdentity (conflict/same-user/new/race) and
updateUserGithubProfile; token-store mutex + re-read fallback + persist-failure;
new config.test.ts and connect-service.test.ts (ordering: no token saved when the
identity bind throws).

Deferred: discriminated-union return for getDecryptedAccessToken (no callers yet
- premature); literal-union DeviceFlowError.code (clean form needs a string & {}
eslint-disable for marginal benefit). Rejected the review's suggestions to key
identity on numeric id (breaks adapter login matching), to drop a dead data.interval
operand (it is a live field GitHub returns), and to remove a fresh && guard (TS
cannot narrow it).

* feat(workflows,isolation): requires:[github] gate, per-user token policy, worktree git identity (PR-C G4–G6)

Attribution core for PR-C. All three paths are no-ops unless per-user GitHub is
enabled (GITHUB_APP_ID + TOKEN_ENCRYPTION_KEY), so solo PAT installs are
unchanged.

G4 — requires gate:
- workflowRequirementSchema + `requires: [github]` on workflowBaseSchema
- workflow-requirements.ts: assertWorkflowRequirementsMet + WorkflowRequirementError
- orchestrator hard-fails a requiring workflow at invocation (before isolation/
  worktree/AI cost) when the originating user hasn't connected

G5 — per-user token in workflow subprocess env:
- github-token-policy.ts cherry-picked from #1774; KEYCLOAK_URL mode-detector
  replaced by an injected isPerUserGitHubEnabled flag (kept pure)
- WorkflowDeps gains getUserGithubToken + isPerUserGitHubEnabled
- executor merges per-user overrides last into config.envVars (user token wins,
  or '' scrubs the org/bot token when unconnected + fallback off)
- store-adapter wires getUserGithubToken → getDecryptedAccessToken

G6 — worktree git identity:
- IsolationRequest/ResolveRequest gain gitIdentity; resolver forwards it
- WorktreeProvider stamps `git config user.email/name` on new worktrees
- orchestrator resolves the no-reply email (<id>+<login>@users.noreply...) from
  the originating userId and passes it on the resolve request

Tests: workflow-requirements (3), github-token-policy (8). Type-check + isolation
suite green.

* feat(adapters,server): route PR/issue comments through the originating user's token (PR-C G7)

When per-user GitHub is enabled (App mode + TOKEN_ENCRYPTION_KEY), the GitHub
adapter authors outbound comments under the human who triggered the thread,
instead of archon[bot].

- handleWebhook caches conversationId → originating archon userId
  (actorByConversation; App mode only, lost on restart → graceful bot fallback)
- postComment prefers a per-user Octokit (getUserOctokit, 5-min cached); on 401
  (revoked/expired) it evicts and falls back to the installation token rather
  than failing the reply
- adapter constructor gains an optional getUserToken resolver; server injects it
  (→ getDecryptedAccessToken) only when isPerUserGitHubEnabled()
- no MessageMetadata / core streaming changes — the actor is resolved entirely
  inside the adapter from the webhook it already processes

Existing 64 adapter tests pass (PAT path: actor undefined → unchanged). A
dedicated App-mode user-token test is deferred (no mock-provider harness yet);
the path is type-safe with a conservative bot fallback.

* feat(cli,adapters): connect-github surfaces for CLI and Slack (PR-C G8)

- CLI: `archon auth github` — device flow for the CLI user. Identity from
  ARCHON_USER_ID else $USER/$USERNAME, resolved to a stable 'cli' Archon user.
  Guarded on isPerUserGitHubEnabled; clear errors for conflict / device-flow /
  not-enabled. Wired into the cli.ts switch + noGitCommands + usage.
- Slack: `/archon connect github` — resolves the invoking user, drives the
  device flow detached, and delivers the code + result as ephemeral follow-ups
  via response_url (within its 30-min/5-use window). No orchestrator dispatch.

Both reuse the shared connectGithubForUser engine. CLI identity uses
ARCHON_USER_ID rather than a config.yaml field (smaller surface than expanding
the config schema this PR). Type-check + slack adapter suite (38) green.

* feat(server,web,docs): web device-flow endpoints, X-Archon-User auth, Connect UI, docs (PR-C G9–G10)

Web surface + interim web auth + docs for per-user GitHub.

Device flow (browser-driven, non-blocking):
- decomposed device-flow: pollDeviceFlowOnce + persistGithubConnection
  (connect-service tail), shared by the blocking CLI/Slack path
- POST /api/auth/github/device/start | /poll, GET/DELETE /api/auth/github
- web api client fns + a Settings "GitHub Identity" card (connect → show code →
  poll → connected; disconnect)

Interim web auth (X-Archon-User):
- resolveWebUserId(c) reads ARCHON_WEB_AUTH_HEADER (default X-Archon-User) and
  resolves a stable 'web' user; absent → NULL (never elevated)
- the three web userId TODO sites (create conversation, send message, run
  workflow) now attribute conversation/message rows and the workflow run

Boot: assertEncryptionKeyAtBoot fails fast on a malformed key; App-without-key
WARNs + disables per-user (App-for-bot-only stays valid).

Docs: .env.example (GITHUB_APP_CLIENT_ID, TOKEN_ENCRYPTION_KEY,
ARCHON_ALLOW_ORG_GITHUB_TOKEN_FALLBACK, ARCHON_WEB_AUTH_HEADER); CLAUDE.md
(requires:[github], auth endpoints).

Test fixes: orchestrator-isolation mocks the new db dep; api.messages /
api.workflow-runs updated for the addMessage userId arg. bun run validate green.

* fix(pr-c): address multi-agent review findings on per-user github tokens

Critical:
- C1 (core): wrap all three decryptToken calls in resolveAccessToken in
  try/catch -> log user_github_token.decrypt_failed and return null, honoring
  the documented "null on irrecoverable failure" contract. A TOKEN_ENCRYPTION_KEY
  rotation or tampered ciphertext no longer throws an opaque crypto error and
  crash workflow dispatch (the requires:[github] gate treats null as unconnected).
- C2 (workflows): until_bash loop subprocess now spreads ...(config.envVars ?? {})
  last, matching executeBashNode/executeScriptNode. The per-user token scrub and
  managed per-project env vars reach until_bash; it no longer inherits the
  server's ambient GitHub token and bypasses the scrub.
- C3 (docs): .env.example and the config.ts module doc now name all three vars
  correctly — GITHUB_APP_ID + TOKEN_ENCRYPTION_KEY arm the gate; GITHUB_APP_CLIENT_ID
  is additionally required for the connect flow. Previous text named CLIENT_ID as
  the gate trigger, which could cause a silent lockout.

Important:
- I1 (server): boot guard warns web_auth.header_trust_on_public_bind when the
  X-Archon-User trust is live (header set, or per-user mode since the default
  header is trusted) on a non-loopback bind, mirroring the /internal/* guard.
- I2 (server): new strict requireWebUser helper for the 4 auth-required endpoints
  distinguishes missing header (401) from identity-resolution backend failure
  (503, added to apiError's union). Best-effort resolveWebUserId kept for
  attribution paths but now logs headerPresent.
- I3 (server): GET/DELETE /api/auth/github wrapped in try/catch with userId
  context instead of an opaque 500 from the global handler.
- I4 (server): corrected the /internal/* inline comment — the guard refuses to
  start (fatal), it does not merely WARN.

Cheap correctness wins:
- S1 (core): assertKeyLength 32-byte guard in encryptToken/decryptToken surfaces
  an actionable error instead of Node's opaque "Invalid key length".
- S3 (server): replaced the nested ternary in the poll-status mapping with a
  testable mapDeviceFlowErrorToPollStatus helper.
- S5 (cli): corrected the stale TODO — archon auth github has landed; remaining
  work is threading resolveCliUserId() (ARCHON_USER_ID/$USER), not config.yaml.

Docs:
- README table count 7 -> 12; CLAUDE.md @archon/core batch count 7 -> 20 and a
  new `auth github` CLI entry.
- docs-web: `auth github` CLI reference, per-user GitHub env var table in
  configuration.md, `requires: [github]` in authoring-workflows.md, and a
  "Step 8: Enable per-user GitHub identity" section in github-app-setup.md.

Tests:
- T1 (core): direct pollDeviceFlowOnce coverage (pending / slow_down /
  default-interval / authorized / error).
- T2 (server): auth-poll-status.test.ts covers the expired/denied/error mapping;
  registered in server package.json.

Deferred:
- T3: locking the isPerUserGitHubEnabled()===false no-op for requires:[github]
  at its only call site (dispatchOrchestratorWorkflow) needs ~100 lines of new
  mock scaffolding in orchestrator-agent.test.ts (which today only tests command
  parsing and mocks neither the config gate nor the token store) to verify a
  one-line && short-circuit whose two halves are already unit-tested
  (config.test.ts + workflow-requirements.test.ts). Not worth the scaffolding.

bun run validate passes (all 6 checks).
…n resume works (#1840)

* fix(providers/codex): capture thread id from thread.started so resume works

CodexProvider snapshotted `thread.id` before consuming the event stream and
emitted that as the result chunk's sessionId. For a NEW thread the SDK assigns
the id during the run — via the `thread.started` event, whose own type comment
says it "can be used to resume the thread later" — so the snapshot was null and
Codex returned no sessionId at all. That silently broke session resume on Codex:
persist_session nodes never received an id to persist, and any suspend/resume
could not re-thread the session.

Capture `thread_id` from the `thread.started` event inside streamCodexEvents and
use it for every result emission (turn.completed, turn.failed, stream_incomplete),
falling back to the snapshot for the resume case where the id is already known.

Verified live (gpt-5.5, ChatGPT-account auth): a two-turn same-cwd round-trip now
returns a thread id and the second turn recalls a token planted in the first —
context survives. With Pi and Claude already warm-resuming, all three in-process
providers now resume, not just Claude.

Test: a thread whose .id is null still surfaces the thread.started thread_id as
the resumable sessionId.

* fix(providers/codex): address PR #1840 review — SDK type, no silent degrade, tests, docs

- I1: cast thread.started to the SDK's exported ThreadStartedEvent instead of a
  re-declared inline shape; the guard collapses to a truthiness check
  (CLAUDE.md SDK Type Patterns).
- I2/S1: on a (contract-impossible) empty/missing thread_id, warn instead of
  silently emitting sessionId: undefined — which the dag-executor reads as
  session-less and uses to drop persist_session continuity. Promote the success
  log to info: the one place a new thread's resumable id is established.
- I3/S3/S4: tests — captured id flows through turn.failed and stream_incomplete;
  resume keeps the snapshot id when thread.started doesn't re-fire; an empty
  thread_id keeps the snapshot.
- I4: update the stale Codex snippet in architecture.md (it taught the pre-fix
  sessionId: thread.id pattern this PR fixes).
- I5/S2/S6: quote the SDK doc comment in full, correct "may or may not re-fire"
  to "only for new threads", document the continue.

Deferred: S5 (narrow resolvedThreadId to string|undefined) — the code-simplifier
judged it churn that only mirrors the param type; kept as-is.
* fix(core): guard workflow resume/cancel against concurrent double-claim

resumeWorkflowRun had no status guard — two callers racing the same run (the web
Resume button + a chat re-dispatch, or the lock-less CLI path; the conversation
lock is keyed per-conversation, not per-run) could both flip it to 'running' and
double-claim the worktree. Make it a compare-and-swap: the UPDATE only matches a
row still in a resumable state (the exact findResumableRun predicate —
failed/paused, or a stale 'running' orphan), and because it refreshes
last_activity_at a second concurrent resumer is excluded in every case. On a CAS
miss, probe the current status and throw an actionable "not resumable
(status: X)" instead of silently proceeding.

cancelWorkflowRun was unguarded (WHERE id=$1) and could re-stamp completed_at or
resurrect a terminal run. Guard it with status NOT IN ('completed','cancelled')
(keeping 'failed' and 'running' cancellable by design) and treat a no-match as an
idempotent no-op.

This is the foundational concurrency-safety primitive the pending-state /
suspend-resume refactor builds on — usable by the console UI and every other
surface that resumes or cancels a run.

Tests: CAS predicate + not-resumable/not-found/probe-error paths for resume;
terminal guard + idempotent no-op + db-error paths for cancel.

* fix(core): address PR #1830 review — fix CAS param-bind bug (C1) + hardening

C1 (merge-blocking): the resume CAS predicate interpolated nowMinusDays(3) → $3
but bound only [id], leaving the day placeholder unbound — resume threw on
Postgres (bind error: 1 param supplied, 3 required) and orphan recovery silently
died on SQLite ($3 → NULL → `last_activity_at < NULL` is false). nowMinusDays
takes a PARAMETER INDEX, not a day count. Fixed by binding the day value and, per
S1, extracting the shared resumableStatusClause(dialect, idx) helper used by BOTH
findResumableRun and resumeWorkflowRun so the predicates can't drift again (the
hand-duplication is what caused C1). The day value is the named
ORPHAN_RESUME_STALE_DAYS constant, bound identically at both call sites.

- I1: cancelWorkflowRun returns { cancelled: boolean }; the cancel route reports
  "nothing to cancel" instead of a false "Cancelled" when the run already finished
  (TOCTOU no-op). IWorkflowStore updated to match.
- I2: resumeWorkflowRun throws a typed WorkflowNotResumableError; the orchestrator
  resume path catches it and surfaces "already being resumed" instead of leaking
  the raw DB-id string to the generic failure catch.
- I3: real-SQLite integration test (workflows.resume-cas.integration.test.ts)
  exercises orphan recovery + the dialect date SQL end-to-end — it fails under C1.
  Added params-array assertions to the mock tests (would have caught C1 directly).
- S2: cancel no-op and resume CAS-miss both log at info (consistent).
- S3: corrected the "mirrors exactly" comment (now a shared helper), stated the
  atomic-UPDATE exclusion mechanism, relabeled the probe "informational only".
- S4: capture probeRows across the try/catch; thread { cause } into the rethrow.
)

* feat(auth): opt-in web login via Better Auth + user-identity seam

Add an opt-in email/password web login (Better Auth, invite-allowlist
gated) and the {userId, role} identity seam so resources can be
user-scoped later without a rewrite.

- A Better Auth session maps to the canonical remote_agent_users row via
  user_identities('web', <betterAuthUserId>); resolveAuthContext resolves
  session-first then the X-Archon-User header at one chokepoint, returning
  { userId, role }. resolveWebUserId delegates to it; requireWebUser is now
  session-aware.
- Better Auth mounts at /api/auth/* and owns four remote_agent_auth_* tables
  (Postgres only). The mount falls through for Archon-owned /api/auth/status
  and /api/auth/github* paths so they are not shadowed.
- Add a role column to remote_agent_users (default 'admin') and a
  non-enforcing ?mine filter on the runs + conversations lists.
- Web UI: login/signup page + session gate (shown only when enabled via
  GET /api/auth/status) + "signed in as" / sign out.

Fully opt-in and Postgres-only (DATABASE_URL + BETTER_AUTH_SECRET). SQLite
and solo installs are completely unchanged. Visibility stays open; role and
?mine are the scoping seam, not a boundary.

* fix(auth): address multi-agent review findings on web auth PR

Review fixes for the opt-in Better Auth web login:

- LoginPage: declarative <Navigate> instead of navigate() in the render
  body (fixes the form-flash + Strict-Mode double-fire); consistent
  staleTime on the auth-status query.
- getAuth(): guard buildAuth() construction (log + cache null) so a bad
  DATABASE_URL surfaces as a clear log line instead of a swallowed
  "session resolve failed"; add closeAuth() to release the dedicated
  pool on shutdown; parse the email allowlist once at construction.
- resolveAuthContext/requireWebUser: return role: UserRole (not string);
  export UserRole through @archon/core; add request path to the soft-path
  catch logs and document the warn-vs-error posture.
- mine query param: z.enum(['true','false']) to make the boolean
  contract explicit.
- Tests: ?mine=true with no identity returns all (runs + conversations);
  session-throws falls through to the header; member role round-trip.
- Docs/comments: correct "created only when web auth is enabled" (the
  auth tables are always created on Postgres) in the migration +
  CLAUDE.md; fix the /api/auth/status fallthrough comment; database.md
  (16 tables, role column, migrations 021/022); security.md, docker.md,
  configuration.md web-auth framing; auth-client/SessionGate comments.

Deferred (out of scope / over-engineering): regenerating
api.generated.d.ts (would pull unrelated drift; hand-written web types
are correct), extracting a shared resolver from requireWebUser
(intentional soft-vs-strict split, already documented), and a
startServer integration test for the route-shadowing guard.

* fix(auth): gate /api/* server-side + safe-by-default signup when web auth enabled

Two security closures so enabling web auth actually protects the app instead of
just adding a login screen. Both gated on isWebAuthEnabled() → zero change for
solo/local (SQLite) installs.

Finding 1 — server-side API gate (was: login UI only, API open to curl):
- new isApiGateEnabled() (on by default when web auth enabled; ARCHON_WEB_AUTH_
  REQUIRED=false escape hatch).
- api.ts: an app.use('/api/*') gate (after CORS, reuses resolveAuthContext) →
  401 without a session/identity. Public allowlist: /api/auth/* (login surface)
  and /api/health* (Docker healthcheck must stay reachable). /webhooks/* and
  /internal/* are outside /api/* and untouched. This makes Better Auth the real
  access boundary so the Caddy forward_auth sidecar can retire.

Finding 2 — signup safe-by-default (was: empty allowlist = silent open signup):
- getSignupMode() is now 3-state: allowlist | open | disabled. Empty allowlist +
  no ARCHON_AUTH_OPEN_SIGNUP=true → 'disabled' (login only) + a boot WARN, never
  silently open on a reachable URL.
- instance.ts: emailAndPassword.disableSignUp when posture is 'disabled' (the
  allowlist hook stays as belt-and-suspenders for 'allowlist' mode).
- /api/auth/status.signup gains 'disabled'.

Tests: config (3-state signup, isApiGateEnabled) and api.auth (6 gate cases:
off→reachable, on→401, /api/auth + /api/health public, session/header pass).
Docs: .env.example, CLAUDE.md, security.md (the flip-the-sidecar runbook),
configuration.md. bun run validate green.

Out of scope (unchanged): resource visibility/ACL scoping, role enforcement —
?mine stays non-enforcing; this is access-gating only.

* fix(auth): address re-review of /api/* gate (web signup UX, doc/comment accuracy, hardening)

Addresses the multi-agent re-review of commit 2b5c9b6.

I1 (web UX bug): web AuthStatus.signup was missing 'disabled' — the server now
defaults to disabled, so LoginPage showed the signup affordance for a registration
the server would only 403. Add 'disabled' to the type and hide the signup toggle
when signup is off.
I2/I3: stale comments — 'empty allowlist means open signup' (now disabled by
default) and 'never an access gate' (resolveAuthContext now backs the /api/* gate).
I4: the gate trusts X-Archon-User; the sidecar-retirement runbook now warns to keep
stripping that header at the proxy (or bind 127.0.0.1), plus a SECURITY note at the
gate.
I5: /api/stream/* (SSE) is under /api/* and is now gated — runbook corrected.
I6: docker.md no longer claims 'allowlist-gated accounts' (default is disabled);
notes the gate can retire the forward_auth sidecar.
S1: defense-in-depth — re-check signupDisabled inside the create.before hook.
S2: tests — assert 401 on gate-on + session-throws + no-header (pins fail-closed);
assert signup: 'disabled' on /api/auth/status; fix the status-test default to the
real 'disabled' posture.
S3: structured boot warn key web_auth.signup_disabled_no_allowlist.
S7: document allowlist-beats-ARCHON_AUTH_OPEN_SIGNUP precedence in getSignupMode.

Deliberately skipped: S4 (exempt-prefix asymmetry — harmless, no bare /api/auth
endpoint), S5 (web AuthStatus dedup — tied to deferred types-regen), S6 (hard-fail
boot — trades fail-closed-but-up for fully-down on a transient DB hiccup).

* fix(web): disable declaration emit for the web SPA (fixes docker-build TS2742)

The Docker web-build stage (tsc --noEmit, root tsconfig has declaration: true)
ran the declaration-portability check and failed with TS2742 on Better Auth's
hash-named internal dist chunks under a hoisted node_modules — only reproducible
in the flat Docker install layout, not the local isolated one. The web package is
a leaf SPA bundled by Vite and is never emitted or consumed as .d.ts, so override
the inherited declaration emit off (declarationMap follows — it can't be set
alone). Real type errors are still caught by tsc --noEmit; only the declaration
check that produced TS2742 is skipped.
…ed agent chat, manage_run native tool (Claude + Pi) (#1819)

Promotes the console experiment surfaces and ships the cross-provider-foundation manage_run native tool (Claude + Pi), project-scoped agent chat, and inline approvals in the workflow dock. Includes multi-agent review fixes (short-id prefix resolution, confirm gates, honest resume/cancel wording, error surfacing).
Normalize Codex output_format JSON Schemas for OpenAI Structured Outputs strict-mode: inject additionalProperties:false on every object node, warn when an open-record subschema is closed, and narrow the normalizer's types. Claude and other providers untouched.

Closes #1843
Resolve markdown command files reached through symlinks during command discovery.

Closes #1501
Detect Claude session-limit notices surfaced in AI node output so they are handled explicitly rather than treated as normal completion.

Closes #1844
…1856)

Keep only the last agent_message as the structured-output parse target instead of concatenating all messages into invalid JSON. Adds regression tests for both the direct outputFormat and nodeConfig.output_format paths (the latter grafted from #1859). Closes #1856.
* docs: sync workflow catalogs and add missing workflows

- Add 9 missing workflows to essential-workflows.md
- Sync workflow tables across overview.md, guides/index.md, README.md
- Fix spelling: revertable → revertible in archon-architect
- Update README workflow count from 17 to 19
- Remove archon-test-loop-dag from user-facing catalogs (test fixture)

Closes #1835

* feat(docs-web): add llms.txt plugin for AI-tool ingestion

Add starlight-llms-txt plugin to generate LLM-friendly documentation
endpoints at /llms.txt, /llms-full.txt, and /llms-small.txt.

- Configure plugin with exclude, customSets, promote/demote, minify
- Create topic-based subsets: quick-start.txt, adapters.txt, reference.txt
- Add post-build script for Unicode normalization (browser compatibility)
- Reduce llms-small.txt from 8,370+ to 5,474 lines (-35%)
- Add "For AI Tools" section in reference docs
- Add llms.txt mention to README

Closes #1379

* fix(docs-web): address PR review feedback

- I1: Fix bare catch {} that swallowed all errors, not just ENOENT
  - Move for loop outside try block so I/O failures are fatal
  - Only catch ENOENT (expected when no customSets configured)
- S1: Use bun run instead of node for consistency with repo conventions
- S2: Move starlight-llms-txt from devDependencies to dependencies
- S3: Remove dead 'reference/changelog' exclude entry (no such file)
- S4: Re-order Quick Reference table to match canonical README order
- S5: Simplify normalizeFile() - compare strings directly, drop test() flag
- S6: Add warning when no llms*.txt files found (plugin may be disabled)

Addresses review by @Wirasm

* fix(docs-web): regenerate bun.lock to match package.json

starlight-llms-txt was recorded under devDependencies in bun.lock but is
declared as a regular dependency in package.json. That mismatch made
`bun install --frozen-lockfile` fail on every CI job (test ubuntu/windows
and docker-build). Regenerated the lockfile so manifest and lock agree.

---------

Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
* feat(docs): add marketing landing page above docs site

- Add self-contained landing at / built on the Archon brand system
  (Geist + Geist Mono, duotone magenta->violet->teal gradient, cool
  charcoal surfaces); hero, feature cards, how-it-works, CTA, footer
- Relocate docs home from / to /docs/ (rename index.mdx -> docs.mdx) so
  the landing can own the root
- Repoint 'Docs' nav links in roadmap and workflows pages to /docs/

* docs: address landing-page review feedback

- README: point the Docs badge and 'Full documentation' link at /docs/
  (the root now serves the marketing landing, not the docs home), and drop
  a pre-existing stray /docs/ from the AI Assistants binary-path link
- landing: add og:image for link previews; extract a --font-mono token so
  the Geist Mono stack is declared once instead of five times; correct the
  brand-token comment (a subset of app.css, not a full mirror)
…manage-run skill, orchestrator delivery (#1853)

Adds cross-provider workflow run management via the CLI: new `workflow get`/`workflow runs`, `--json` on approve/reject/abandon/resume, `--detach`, a bundled manage-run skill, and a run-management section in the orchestrator system prompt for non-native-tool providers. Includes multi-agent review fixes: --json stdout safety (logger silent), `workflow get` exit codes, no-silent-failure on verbose events, fd-leak guard, scopeFallback flag, doc accuracy, and added tests.
…runs (DB-tail poller + Postgres LISTEN/NOTIFY) (#1861)

* feat(server): live console updates for out-of-process (CLI) workflow runs

The __dashboard__ SSE stream that drives the console's Workflow dock was fed only
by the server's in-process WorkflowEventBridge. Runs started in a separate process
— the archon CLI, especially `workflow run --detach` — write events to the
workflow_events table but never reach the server's emitter, so the dock reflected
them only on refetch, never live.

Add a server-side event source that tails the events table and replays new rows to
__dashboard__, covering any process that writes events to the DB:

- DashboardEventPoller (SQLite + Postgres baseline): tails workflow_events with a
  created_at cursor (id is a UUID, no monotonic int). Uses `created_at >= cursor`
  + a boundary seen-id set so SQLite's 1-second CURRENT_TIMESTAMP ties aren't
  skipped; duplicate emits are harmless (the dock reacts by refetching, idempotent).
  Gated on hasActiveStream('__dashboard__') and keeps the cursor fresh when idle so
  a late-connecting client doesn't replay history.
- Postgres LISTEN/NOTIFY real-time push: an AFTER INSERT trigger pg_notify()s, and
  a PgNotifyListener wakes the poller to drain immediately (notification carries no
  payload — cursor/mapping/dedup stay in one place, so a dropped/coalesced notify
  can't desync). Trigger is Postgres-only (applied in PostgresAdapter.initSchema,
  never the shared schema — SQLite can't run it). Reconnects with backoff; the
  poller's backstop interval reconciles anything missed.
- Dialect-selected wiring: SQLite polls @1.5s; Postgres polls @10s backstop + NOTIFY.
- mapWorkflowEventRow: persisted event_type → the dashboard's workflow_status/dag_node
  shapes (incl. step_*); skips high-frequency tool_* and internal markers.
- idx_workflow_events_created_at added to both schema paths (migrations + sqlite.ts).
- DbNotificationListener: narrow capability interface (ISP) implemented by Postgres only.

No frontend changes — the dock already reacts to __dashboard__ events.

* fix: address PR #1861 review (C1 SQLite dead-path + hardening + tests)

Critical:
- C1: the poller was silently inoperative on SQLite (the default DB). SQLite stores
  created_at as datetime('now') ("YYYY-MM-DD HH:MM:SS") and compares TEXT
  lexicographically, but the cursor bound .toISOString() ("…T…Z") which sorts below
  it (space < 'T'), so created_at >= cursor matched nothing. listWorkflowEventsSince
  now formats the cursor to the stored shape per dialect (toDbDateParam). Added a
  real-:memory:-SQLite regression test (the mocked unit tests masked this).

Hardening:
- I1: listen() releases the held client if LISTEN setup throws (no pool-slot leak).
- I2: per-row JSON.parse — a malformed data column degrades to {} instead of
  throwing the whole batch and freezing the poller cursor.
- I3: poller escalates warn → error after N consecutive failed drains.
- I4: the listener's unsubscribe catches log at debug instead of swallowing silently.
- I5: dropped the dead `destroy === false ? true : destroy` ternary.
- Race: guard the stop()/connect() race so a listen() resolving after shutdown
  doesn't leave a live subscription.
- S5: the pg_notify trigger installs in its own best-effort step — a role without
  CREATE TRIGGER degrades to poll-only instead of failing boot.

Overflow: filter listWorkflowEventsSince to the dashboard-relevant event types
(DASHBOARD_SOURCE_EVENT_TYPES), keeping high-frequency tool_* out so a 1-second
bucket can't realistically exceed the drain limit (avoids the >LIMIT-per-second
stall without the cross-dialect precision pitfalls of a (created_at,id) tuple cursor).

Polish: S2 named type guard for the notification capability; S3 typed SSE payload
shapes; S4 narrow DashboardTransport interface (removes test casts); S6 docstring.

Tests: real-row SQLite regression (C1) + event-type filter + comparison direction +
malformed-data (I2); multi-second cursor advancement + boundary dedup (T1); drain
coalescing (T3); mapper branches incl. approval_received / error fields /
node_skipped_prior_success / loop (T4); listener reconnect-with-backoff (T2).

Docs: __dashboard__ now covers CLI/--detach runs (web.md); the created_at index +
Postgres trigger (database.md); table count 10 → 16 (architecture.md).
…uns (#1865)

The console Workflow dock now updates live for detached/CLI runs — a
server-side poller tails the workflow-event table and replays new rows to
the console feed (PostgreSQL NOTIFY trigger pushes within the second; SQLite
picks them up on its short interval), shipped in #1861. The skill's Console
UI note still told users detached runs don't stream live and to refresh
manually; corrected to describe the live behavior.
Pure resolver for the model-alias tier system: buildAiProfile + resolveModelSpec in @archon/workflows, small/medium/large ladder with universal fallback orders, @Custom aliases, seeded tier-defaults.json, and the aliases: config key. Resolver only — execution wiring + cross-provider tiers tracked in #1872.
… codex skills capability (#1868)

* feat(cli): install bundled skills into Codex .agents/skills/ + enable codex skills capability (#1803)

archon skill install previously only wrote to .claude/skills/. Codex
auto-discovers skills from .agents/skills/ — its canonical project-level
skill path — so the bundled archon + manage-run skills were invisible to
Codex. CODEX_CAPABILITIES.skills was also false, triggering a spurious
dag-executor warning whenever a Codex node declared skills.

Changes:
- skill.ts: copyArchonSkill now writes the same bundled archon +
  manage-run files to .agents/skills/ alongside .claude/skills/; install
  output mentions both Claude Code and Codex
- providers/codex/capabilities.ts: skills: false → true (Codex supports
  skills via filesystem auto-discovery)
- skill.test.ts: assert .agents/skills/archon + manage-run get every
  bundled file; assert success message mentions Codex
- setup.test.ts: each copyArchonSkill test now also verifies the Codex
  path is written and overwritten
- providers/codex/provider.test.ts + dag-executor.test.ts: update
  capability matrix and invert the now-stale "Codex warns on skills"
  test to assert no warning is emitted
- docs (reference/cli.md, guides/skills.md): document the dual install
  path, explain Codex's filesystem auto-discovery semantics vs Claude's
  per-node AgentDefinition injection, update troubleshooting

Fixes #1803

* fix: address review findings from PR #1868

- Update copyArchonSkill JSDoc to mention both .claude/skills/ and .agents/skills/ destinations
- Clarify fileCount log as "per destination" to avoid confusion about total files written
- Add inline comment to CODEX_CAPABILITIES.skills explaining filesystem autodiscovery vs per-node injection
- Update CLAUDE.md DAG-node skills description to reflect Codex auto-discovery support
- Add .agents assertion to error-path test for symmetry
- Update setup.ts completion summary to show both Claude and Codex skill paths
- Add Skills subsection to Codex section in ai-assistants.md

* simplify: reduce complexity in changed files
* Fix: unreadable active sidebar link on mobile docs (#1776)

Active page link in the mobile Starlight sidebar rendered as white text on
a bright light-blue background (contrast ~1.78:1). Root cause: our
--sl-color-accent-high: #93c5fd override in :root propagates to Starlight's
--sl-color-text-accent (which it uses as the active item background-color),
and dark mode never re-overrode it. A prior partial fix set the text color
to white, which made the contrast worse rather than better.

Changes:
- Override --sl-color-text-accent (semi-transparent purple) and
  --sl-color-text-invert (white) inside [data-theme='dark'] so the active
  link renders dark purple with white text (~8.5:1 contrast).

Fixes #1776

* docs: add upgrade-warning comment to --sl-color-text-accent override

--sl-color-text-accent is a global Starlight token; note the mobile
sidebar active-link intent so the override is re-tested after upgrades,
consistent with the existing warning comment at the selector below.

* simplify: use 'white' instead of verbose hsl(0, 0%, 100%)
…ar metadata (#1851)

* feat(workflows): typed artifacts — output_type + engine-written sidecar metadata

Foundation slice of the context-passing epic (#1847). Lets a node declare a
semantic `output_type`; when set, the executor persists the node's output as a
typed sidecar artifact so other nodes and later runs can locate it by type
instead of guessing filenames. Foundation for cold-resume pointer recovery
(#1846) and selective/by-reference context passing (#1848).

- schema: optional `output_type` on dagNodeBaseSchema (general field — valid on
  every node type incl. bash/script; NOT added to *_AI_FIELDS), threaded through
  the transform's `base` group so it reaches all variants.
- artifacts-index: new helper writes `nodes/<id>.md` + per-node `<id>.meta.json`
  (no shared index file → no parallel-layer write contention; index derived on
  read by globbing). `readNodeArtifacts`/`latestNodeArtifactOfType` for lookup.
  Corrupt/missing entries are non-fatal (skip + warn). Node id sanitized to a
  safe path segment.
- executor: best-effort sidecar write at the single layer-result funnel (covers
  all node types); a metadata write never fails an otherwise-successful node.
- nodeArtifactSchema in schemas/ (named to avoid clashing with the unrelated
  workflow-event artifactTypeSchema).

Opt-in and additive: default behavior is unchanged when no node sets output_type.

Tests: artifacts-index unit suite (write/read/latest/corrupt-skip/path-escape);
dag-executor integration (typed node writes sidecar; untyped node writes none).
Validation: bundled gates, all-package type-check, lint --max-warnings 0,
format:check, full @archon/workflows test suite — all pass.

* fix(workflows): harden typed-artifacts per PR review

Address review findings on the typed-artifacts feature. All changes are
additive/local; behavior is unchanged for nodes that don't set output_type.

Correctness / invariants
- node-artifact schema: producedAt -> z.string().datetime() so the
  lexicographic "latest" sort in latestNodeArtifactOfType can't silently
  return the wrong artifact for a corrupt/non-ISO value (rejected on read);
  size -> .int().nonnegative(); outputType -> .min(1) (matches output_type
  on the node config and the sibling artifactFileSchema).
- writeNodeArtifact: collision guard. Two distinct node ids that sanitize to
  the same filename segment (e.g. `a.b` / `a_b`) now throw instead of silently
  overwriting — first writer wins, the loser is logged via the best-effort
  caller. Re-writing the same id (resume) still overwrites without error.

Error handling (fail-fast)
- readNodeArtifacts: only swallow readdir ENOENT ("no artifacts yet"); a real
  EACCES/ENOTDIR/EIO now warns + rethrows instead of masquerading as empty,
  matching script-discovery / workflow-discovery precedent.
- index_entry_invalid warn now includes the Zod issues so schema drift is
  debuggable.

Tests
- best-effort write failure is non-fatal (node still completes when the
  sidecar write throws).
- bash node with output_type writes a sidecar with no sessionId.
- assert sessionId propagation in the AI-node integration test.
- artifacts-index: collision throws, same-id overwrite, schema-invalid skip.

Simplification
- writeNodeArtifact params -> Omit<NodeArtifact, 'path' | 'size'> (DRY).
- drop the redundant call-site conditional-spread for sessionId (the guard
  lives inside writeNodeArtifact).
- latestNodeArtifactOfType: reduce -> explicit loop.
- correct the misleading "concurrent contention" rationale in the JSDoc
  (writes are sequential post-allSettled; per-node files isolate nodes/runs).

Docs
- CLAUDE.md: output_type in the node-field list, nodes/ in the artifacts
  layout, node-artifact in the engine-schema list.
- authoring-workflows guide: output_type row in Common fields, a Typed
  Artifacts subsection, and a quick-reference entry.

Deferred (with rationale)
- No user-facing chat warning on best-effort write failure: there are no
  consumers of these artifacts yet (cold-resume/context-passing land in
  #1846/#1848); a chat message per miss would be noise. Revisit with the
  first latestNodeArtifactOfType caller.
- Did NOT constrain the global node `id` charset to fix the collision: that
  touches every workflow and could reject existing definitions — out of scope
  for this PR. The local write-time guard covers it without a breaking change.
- Kept the lazy logger (review suggested removing it): dag-executor.test.ts
  mocks @archon/paths createLogger and imports this module transitively, so
  the deferral is load-bearing for test isolation.
- AGENTS.md mirror skipped: it doesn't exist on this branch (added to dev
  after the branch forked); the 1:1 copy syncs when the branch catches up.
…lizer (#1878)

* fix(web/console): gate /console behind auth + correct run-event normalizer

Two correctness/security fixes that block the console from replacing the old
chat + dashboard UI.

Security: /console/* mounted OUTSIDE SessionGate, so with web auth enabled the
console was reachable without login while every other route is gated. Wrap it
in SessionGate — still outside Layout so it keeps omitting TopNav. SessionGate
is a passthrough when web auth is disabled (the solo default), so this is a
no-op there.

Correctness: the run-event normalizer read keys the server never writes —
- node duration read `duration`, but the row carries `duration_ms`, so every
  rendered node duration was null;
- approvals checked `approval_pending`/`approval_resolved` with a `resolution`
  key, but the server writes `approval_requested`/`approval_received` with
  `decision` + `comment`/`reason`, so approvals fell through to the raw-JSON
  text fallback;
- `node_skipped_prior_success` (emitted on resume for already-done nodes) was
  dropped entirely.
Also carry the node_completed enrichment already in the payload (output
preview, cost, stop reason, turns) for the upcoming per-node detail view.

Adds the first test under experiments/console and wires
src/experiments/console/ into the web test script so CI starts covering the
console as it is promoted.

* fix(web/console): address PR #1878 review (I1/I2 + S1/S2/S4/S5/S6 + docs)

I1  approval_received now matches `decision` explicitly — an unknown/missing
    decision stays unresolved (null) instead of silently rendering as approved
    (the exact silent-mismatch class this normalizer set out to kill).
I2  reworded the approval comment to future tense and noted that nothing renders
    approval events in the run stream today (paused gates use run.approval metadata).
S1  transition mapping → NODE_TRANSITION_BY_EVENT record lookup (flatter, shows
    the whole map; node_failed kept explicit so its test guards the default).
S2  enrichment JSDoc: dropped the "per-node accordion (follow-up)" forward
    reference; stated the completed-only null contract instead.
S6  App.tsx comment: SessionGate is a passthrough after a brief (cached)
    auth-status check, not a literal no-op.
S4  added a node_failed test (guards the map vs the `?? 'skipped'` default).
S5  added error (error>message priority), workflow_*→system, and nodeName
    fallback tests; plus an I1 guard test (unknown decision → unresolved).
docs narrowed the stale "CI does not guarantee these routes work" line in
    experiments/README.md now that console primitives run in CI.

Skipped with rationale: S3 discriminated-union-on-transition refactor (defer to
the PR that renders the fields); argsSummary dead-read cleanup (pre-existing, no
consumer yet); a SessionGate automated test (needs jsdom/testing-library infra
not present in @archon/web — a future SessionGate unit test is the right target).
…ng (#1877) (#1879)

* Fix: reuse Pi extension loader per process so 2nd+ session doesn't hang (#1877)

With Pi extensions enabled (the default), the 2nd and every subsequent
sendQuery() per process hung in session setup and idle-timed-out after 30
min, breaking every Pi workflow with 2+ AI nodes. Pi's reload() re-invokes
every installed extension factory from scratch; that process-scoped state is
never torn down between calls (dispose() never emits session_shutdown), so the
2nd reload() deadlocks on the first call's still-live state.

Changes:
- Add getOrCreateReloadedExtensionLoader(): a process-level cache that builds
  and reload()s the extension-bearing DefaultResourceLoader once per
  (cwd, systemPrompt, skillPaths) and reuses it across sendQuery calls.
  createAgentSession with a supplied loader skips its own reload(), so reuse is
  safe; each call still gets its own session + bindExtensions(session_start).
- provider.ts: use the cache on the extensions-on path; keep a fresh per-call
  loader on the extensions-off path (no reload(), no re-entrancy hazard).
- Add regression tests: reload/construct run once across identical sequential
  calls; distinct cwd or systemPrompt each reload once; extensions-off never
  caches and never reloads.

Fixes #1877

* docs(pi): document loader-cache growth bound and why eviction is omitted (#1877)

Address self-review feedback on the unbounded-growth concern: clarify the cache
is bounded by distinct (cwd, systemPrompt, skillPaths) tuples and explain that
naive size-cap/LRU eviction is unsafe — dropping an in-use loader would make a
running workflow's next node reload a second copy of the same extensions while
the first is live, re-introducing the deadlock the cache prevents.

* Address ultrareview findings on the Pi loader-cache fix (#1877)

- I1: test the primary documented invariant — concurrent same-layer callers
  share a single in-flight reload() (gated mock + Promise.all; asserts one
  construct/one reload/same loader). Caching the resolved value instead of the
  Promise would fail this.
- I2/S1: trim the docblock's unverifiable agent-rooms specifics (keep the
  general "factories build process-scoped singletons" mechanism) and drop the
  Pi SDK `sdk.js` build-artifact filename that will rot on restructure.
- I3: correct the inline comment — setFlagValue itself unconditionally sets;
  the no-op comes from the provider's `if (runner)` guard, skipped when the
  runner is undefined.
- S2: test failure-eviction — a rejected reload evicts its cache entry so the
  next call retries cleanly instead of returning a poisoned promise.
- S3: rethrow reload() failures with an actionable pointer (extensions dir /
  enableExtensions: false), preserving the original error as `cause`.
- S4: build the shared loader options once, then dispatch — removes the
  duplicated spread across the if/else branches.
- S6: test additionalSkillPaths keying (distinct skill sets → distinct loaders).

Deferred S5 (positional key fn → Pick<>): the public helper already uses Pick<>
to document the keyed fields; the 3-line internal key fn stays explicit.
…ns (#1881)

* feat(web/console): show run provenance — input message + platform icons

Console run cards/rows/detail showed status, workflow, cost, and elapsed but
never what input started a run — the prompt was parsed into the Run primitive
(toRun) yet rendered nowhere, and the origin badge was text-only. This surfaces it:

- OriginBadge: a Lucide icon per platform (web/cli/slack/telegram/discord/github;
  none for unknown), mirroring the old dashboard's PLATFORM_ICONS.
- ActiveRunCard: an `input` row folded into the activity grid (truncated, full
  text on hover) for running + paused cards.
- RecentRunRow: the message truncated inline beside the workflow name; the row
  stays h-9 (title tooltip for the full text).
- RunStartedLine: a blockquote input line under "Workflow X started".
- RunDetailHeader: a full-width `input` sub-row.
- run.test.ts: first test for the primitive — normalizeOrigin (all platforms +
  case-insensitive + unknown fallback), userMessage default, pending→running,
  conversationPlatformId passthrough. (normalizeOrigin is now exported.)

Deviation from plan (documented): the "from chat →" link is DEFERRED, not built.
The console has no conversation-deep-link route — ChatPage is mounted at
p/:projectId/chat and manages its active conversation internally, so a chat link
has no valid destination. Per the plan's "do not invent a route" constraint, the
link AND the workerPlatformId/parentPlatformId fields that only existed to feed it
were dropped (they'd be unread data). This ships the unambiguous wins that fully
address the stated need ("what did I enter… cli/chat"); the link moves to a
follow-up once console chat supports conversation deep-linking.

* fix(web/console): address PR #1881 review (I1-I3 + S1-S5)

I1  RecentRunRow layout regression: when userMessage is empty the workflow name
    now fills the full width (flex-1) again instead of being capped at 55% with
    blank space + needless truncation; capped at 55% only when a message shares
    the line.
I2  ActiveRunCard comment: "always" → "when present" (the input row is gated on a
    non-empty message).
I3  OriginBadge comment: "mirrors" → "extends" — the icon map is a 7-origin
    superset of the old 5-entry map, adds discord, and renders no icon for unknown
    (vs the old Globe fallback).
S1  Extracted a local `hasValue` type-guard to replace the repeated
    null/undefined/'' chains on currentNode/lastTool (also narrows to string).
S2  Named the grid guard `showDetailGrid` (matches the file's elapsed/canOpen idiom).
S3  RecentRunRow: rerun-param guard uses `!== ''` to match the render guard.
S4  Backfilled run.ts coverage: unknown-status→running, readCost (positive, the
    $0.00 >0-guard, and non-numeric→null), and approval-metadata parsing
    (well-formed, message default, malformed→null). 29 → 36 console tests.
S5  RunDetailHeader: the deferred "from chat" link now has a tracked anchor —
    TODO(#1882) (filed the follow-up issue).
…vider tiers (#1873)

Wires the model-alias resolver (PR #1867) into execution: cross-provider `tiers:` config key, aiProfile threaded through executeWorkflow → executeDagWorkflow → resolveNodeProviderAndModel (loop nodes included), warn-on-conflict, per-provider effort routing, internal call-sites (chat=large, title=small), and bundled defaults retrofitted to tiers-only with a validate gate. Closes #1872.
… + repair + fail-fast (PR 1/2) (#1883)

* feat(workflows): reliable cross-provider structured output — validate + repair + fail-fast (PR 1/2)

Keystone of the cross-provider structured-output plan (#1849). Makes output_format
handoffs trustworthy across every provider and removes two silent failures.

- Schema validation for ALL providers: parsed structured output is validated
  against the node's declared JSON Schema (ajv) — even SDK-enforced providers
  (Claude/Codex/OpenCode) need this net for refusal / max_tokens-truncation edges.
- JSON repair tier: jsonrepair recovers trailing commas, single quotes, unquoted
  keys, and truncated tails (gated to object-shaped input + object-only results so
  it can't coerce prose into bogus arrays).
- Honest capability tier: ProviderCapabilities.structuredOutput is now
  'enforced' | 'best-effort' | false (was a meaningless boolean true for all five).
  Claude/Codex/OpenCode = enforced; Pi/Copilot = best-effort. Threaded through the
  OpenAPI schema and regenerated web type.
- Fail-fast (no silent degrade): a node that declares output_format but returns no
  schema-valid structured output now FAILS instead of completing with poisoned
  prose. Routed through the existing per-node catch.
- No-silent-drop field access: $node.output.field resolution is strict — a field
  not in the producer's declared schema, or a schemaless node whose output isn't
  JSON / lacks the key, throws (fails the consuming node). The only value that
  resolves to '' is an author-declared-optional field. Captured via
  NodeOutput.declaredFields; shared by prompt/script substitution and when:.

Behavior changes (intended, fail-fast): schemaless bash/script nodes missing a
referenced key now throw; when: field-ref errors throw (reverses #1673 fail-closed
for field refs); explicit null on a declared field resolves to '' (was "null").
Bundled-default workflows audited safe (all field refs target AI nodes whose schemas
declare the fields as required).

Reask loop (Tasks 10-13) is PR 2.

* test(workflows): e2e smokes for cross-provider structured output (PR 1)

Two CLI-runnable smokes exercising the new behavior against Claude (enforced):

- e2e-structured-output (positive): output_format validation (Task 7), declared
  field access, declared-OPTIONAL absent → '' (no throw), schemaless bash JSON
  field access. Completes successfully.
- e2e-structured-output-failfast (negative): a $node.output ref to a field NOT in
  the producer's schema fails the node with an OutputRefError (Task 9 no-silent-drop)
  instead of silently resolving to ''.

Both verified end-to-end via `bun run cli workflow run ... --no-worktree`.

* Address ultrareview findings on structured-output PR 1 (#1883)

Comment/doc accuracy (the contract-inverting set — highest value):
- I1: structured-output.ts module + tryParseStructuredOutput docstrings no longer
  say "degrades via warning" (now fail-fast); "three failure modes" → four (jsonrepair).
- I2: types.ts + pi/copilot capability comments stop describing the reask loop as
  present (it's PR 2) — now "validate; reask = PR 2".
- I3: condition-evaluator module docstring distinguishes fail-closed-skip (bad
  syntax) from throw-and-fail (.field ref that can't resolve).
- I4: docs updated where now-wrong — dag-workflows.md ("fails open" → the two
  real modes), ai-assistants.md (Pi/Copilot "degrades to warning" → validates +
  fails), authoring-workflows.md (output_format validation + strict field access),
  CLAUDE.md/AGENTS.md (output_format parenthetical + structuredOutput tiered union).
- S7: condition-evaluator return-'' comment corrected (lenient null → "null").

Code:
- I5: tryJsonParseObject is object-only across all tiers (tier-1 no longer returns
  bare arrays); removes the tier-1-accepts / tier-3-rejects inconsistency. Tests aligned.
- I7: uncompilable output_format schema now surfaces a user-facing warning (not just
  a log) — the silent validation-bypass is no longer silent.
- S2: dropped "// Task 7/8 —" planning labels (kept the prose).
- S3: StructuredValidationResult is a discriminated union ({valid:true} | {valid:false; errors}).
- S4: $node.output.field on a skipped/pending producer throws a clear
  'producer-not-run' OutputRefError instead of a misleading "not a JSON object".

Tests:
- I6: executor integration test — a when: ref to a field not in the producer's
  schema FAILS the node (regression guard against a silent skip).
- S8: declaredFieldsFromSchema(properties:null) + producer-not-run cases.

Deferred (with rationale): S1 (keep `false` tier — plan-specced, honest "none"
state), S6 (unknown-node throw — direction/scope expansion beyond field resolution),
S8 best-effort integration mirror (PR1 fail-fast is tier-agnostic; meaningful only
once PR2 adds reask), S5 (tier-3 swallow already observable at executor level;
would add noise + a logger dep to a pure module), S9 (no real CLAUDE.md violation).
…-bottom (#1885)

* feat(web/console): chat workflow-completion card + SSE-lock + jump-to-bottom

Three changes that make the console's project-scoped chat a credible /chat
replacement — all web-side; the data already flowed and was being dropped.

Completion card: a chat-launched workflow's `workflow_result` message (summary +
{workflowName, runId}) was swallowed because ChatStream filters the whole
`workflow_` category prefix and toMessage never parsed `workflowResult`. Now
message.ts parses `workflowResult` (mirroring the `dispatch` parse, both fields
guarded), and ChatStream lets `workflow_result` through the filter — without
un-suppressing the other workflow_* narration — and renders a new
ConsoleWorkflowResultCard: status icon + node counts + duration + cost +
"Open run →" + the summary body. The card fetches authoritative state via
skill.getRun (same call RunDetailPage uses) and degrades to the summary alone
if the run can't be loaded.

SSE-lock: the composer stayed disabled ~6s (SETTLE_MS) after each reply because
the SSE lock event wasn't wired. ChatPage now passes a useCallback-stable
onLockChange to useConversationSSE that clears the settle timer + setBusy(false)
on conversation_lock:false. The settle timer remains the correctness fallback
when SSE is absent. (Stable callback is required — the hook's effect depends on
it, so an inline lambda would reconnect the EventSource every render.)

Jump-to-bottom: an `atBottom` state (from an onScroll handler) shows a floating
button when scrolled up; kept in sync with the existing auto-scroll lastBottomRef.

Tests: message.test.ts covers the workflowResult parse (well-formed, malformed→null,
dispatch regression, isSystemCategory). 36 → 42 console tests.

* fix(web/console): address PR #1885 review — render the card (C1) + hardening

C1 (critical): the completion card never rendered its rich content. useEntity
returns `error: Error | undefined` (cache.ts), so `error !== null` was always
true and `run` was always null → the summary-only fallback every time. Compare
against `undefined`. Verified end-to-end against a real completed run:
GET /api/workflows/runs/:id returns {run, events}, so the rich path now executes
(16/22 nodes computed on that run).

I1: guard meta.workflowResult with `!= null` (not `!== undefined`) so an explicit
JSON null can't reach `typeof wr.workflowName` and throw. +regression test.

I2: countTerminalNodes deduped by nodeId — a resumed run reuses one run id and
emits both the original `completed` and a later node_skipped_prior_success, which
double-counted nodes. Relocated to primitives/event.ts (pure RunEvent reducer,
its natural home) and unit-tested (6 cases incl. the dedup guard).

I3: console.warn on the two silent swallows (getRun error path, parseMetadata
catch) — the PR's whole point is to stop silently dropping completions.

S1: dropped the dead `| null` from the useEntity generic (getRun never resolves
to null; undefined is the cache-miss signal).
S3: extracted NEAR_BOTTOM_PX for the duplicated 120px scroll threshold.
S5/S6: corrected the card JSDoc (loading also degrades to summary) and documented
why ParsedMetadata.workflowResult stays inline/untrusted.

Deferred — S2 (overeng): kept RESULT_GLYPH/RESULT_LABEL as Partial<Record> plus a
clarifying comment rather than padding them to a total record with running/paused
entries a completion card never displays.

Validation: web type-check / lint / format:check clean; 51 console tests pass.
…1890)

* feat(web/console): settings core — assistant config + system panel

Adds the console's first settings surface (/console/settings, global) — the
parity floor before cutover. PR #4 of the console sequence (after #1878/#1881/#1885).

- skills/settings.ts: getConfig/updateAssistantConfig/getHealth/getUpdateCheck plus
  the pure buildAssistantUpdate(form) transform (8 unit tests). skills/providers.ts:
  listProviders. Types from @/lib/api.generated (console isolation boundary).
- store/keys.ts: config/health/providers/updateCheck keys (health reuses the literal
  'health' so it shares lib/health's cache entry).
- lib/health.ts: full HealthResponse + useHealth(); useIsDocker derives from it.
- AssistantConfigPanel: default-assistant picker (registered providers) + free-text
  model per provider + codex reasoning/web-search; dirty-gated Save → PATCH
  /api/config/assistants → ~/.archon/config.yaml → invalidate(K.config) re-seeds.
  Model is free-text for every provider (Archon does not validate model strings).
- SystemPanel: status/adapter/db/version, concurrency (active/maxConcurrent, coerced
  defensively — concurrency is an open record), running workflows, platform badges,
  update-check.
- ConsoleApp: /console/settings route, gear header link, ',' global keybinding;
  shortcuts.ts catalogue entry.

Honors the error-is-undefined cache contract throughout (the #1885 gotcha).
Excludes the GitHub device-flow panel (PR #5) and env-var editing (project-scoped).

Validation: web type-check / lint / format:check clean; 59 console tests pass (8 new).
Verified end-to-end on an isolated server: read APIs return the expected shapes, save
round-trips to config.yaml (binary paths preserved via the server deep-merge), and a
browser smoke of /console/settings renders both panels (5 providers, codex
effort/web-search, system grid, Save dirty-gated).

* fix(web/console): address PR #1890 review — comments + update-check silent failure

I1: correct the buildAssistantUpdate JSDoc. Verified the PATCH route does NOT
safe-filter per field on the write path — it validates only provider ids and merges
the body into config.yaml unfiltered (safe-filtering is read-path only). The real
invariant is that this function only ever attaches codex-only fields to the codex
entry; the comment now says that instead of the false "server safe-filters" claim.

I2: rewrite the K.health note. This PR routes lib/health through K.health, so the
old "lib/health already caches under this literal" premise is stale; the invariant
is that both consumers read via useHealth() to share one cache entry.

I3 (silent failure): SystemPanel showed "checking…" forever on a failed
update-check (the error was destructured away). Surface updateError via an
UpdateStatus helper → "update check unavailable".

S1: SettingsSection children typed ReactNode (ReactElement|ReactElement[] fought the
`cond && <el/>` pattern).
S5: replaced the nested update-status ternary with the UpdateStatus helper + early
returns for the health loading/error states.
S6: extracted the shared SettingsSection card shell (PR #5 is the 3rd consumer), a
SELECT_CLASS const for the two codex selects, and bound activePlatforms once.

Docs: added /console/settings to the console README routes.

Deferred (with rationale):
- S2 (re-seed on providers identity): latent only — nothing invalidates K.providers,
  and a ref-snapshot "fix" introduces a config-vs-providers load-order race. Keep the
  simple [config, providers] effect.
- S3 (literal-union effort/webSearch types): kept bare string so seedForm tolerates an
  out-of-enum value in config.yaml; the <select> is the write-side enforcement.
- S4 (show both load errors): an error panel showing the first error is acceptable.

Validation: web type-check / lint / format:check clean; 59 console tests pass.
…utput (PR 2/2) (#1889)

* feat(workflows): validate-and-reask loop for best-effort structured output (PR 2/2)

Completes the cross-provider structured-output plan (#1849). Best-effort providers
(Pi/Copilot) now get a bounded reask loop instead of failing on the first miss.

- Task 10 — reask loop. When a node declares output_format AND the provider is
  'best-effort' AND the parsed output is missing/schema-invalid, the executor
  re-runs sendQuery with the schema errors appended, up to STRUCTURED_OUTPUT_MAX_REASKS
  (3). Enforced providers and non-output_format nodes get 0 reasks (unchanged
  fail-fast). Fresh session per attempt; cost accumulates across attempts; no
  reask on idle-timeout/abort/SDK-error. Exhaustion → the existing fail-fast throw.
  Implemented by extracting the stream into a runStreamPass() closure (no body
  re-indent) wrapped in the reask while-loop.
- Task 11 — observability. Logs dag.structured_output_reask per attempt and sends
  one chat notice on the first reask so auto-correction isn't invisible.
- Task 13 — docs. "Structured output guarantees" matrix in ai-assistants.md.
- Tests: best-effort malformed→fixed recovers within reasks (2 sendQuery calls →
  completed); exhaustion fails loudly (1 + 3 calls → node_failed). Registered Pi in
  the dag-executor test registry so capabilities resolve to 'best-effort'.

Task 12 (capability tests) landed in PR 1. Depends on #1879 (Pi extension-loader
reuse) — already merged — since reask fires multiple Pi sendQuery calls per process.

* Address ultrareview findings on the reask loop (#1889)

Functional bugs (the 3 real-but-narrow ones):
- I1: reset batchMessages per attempt in runStreamPass — else a failed reask
  attempt's prose could flush to the user via an intermediate `msg.flush` chunk.
- I2: an idle-timeout on a best-effort output_format node now throws a
  timeout-specific error instead of the misleading "the model replied with
  prose" message; corrected the inaccurate "post-loop guards handle them" comment.
- I3: carry the accumulated reask cost onto nodeCostUsd every pass so the
  exhaustion throw path reports cost across ALL attempts, not just the last.

Docs (self-contradiction + stale "PR 2" references now that reask ships):
- I4: ai-assistants.md Pi/Copilot rows said reask was "planned" — now "re-asks 3×".
- S4: dropped "reask = PR 2"/"lands in PR 2" from pi/copilot capabilities, the
  shared structured-output docstring, and the types.ts capability doc.
- S5: added a reask mention to CLAUDE.md/AGENTS.md output_format + authoring docs.
- S6: comment fixes (runStreamPass now states what it resets; buildReaskPrompt no
  longer overclaims clean prompt/schema separation).

Tests:
- I5: enforced provider (claude) makes exactly ONE sendQuery on a validation miss
  (regression guard against the best-effort gate silently allowing 4× calls).
- I6: best-effort MISSING structured output (not just invalid) triggers reask and
  recovers.
- S1: recover test asserts accumulated cost (0.01 + 0.02 = 0.03); idle-timeout on
  an output_format node does NOT reask (1 call, "timed out" error).
- S2: extracted scheduleReask() so the two reask blocks can't drift.
- S3: dropped the (Task 10) labels from the reask test names.

Deferred S7 (for-loop vs while(true)): while(true) with inline throws is clearer
for the two distinct exhaustion messages (invalid vs missing); a for-loop would
push the throws after the loop and need failure-reason tracking.
Wirasm and others added 26 commits August 3, 2026 10:30
* Fix: snapshot resolved workflow run configuration (#2397)

Persisted workflow_started events only recorded the workflow name, forcing observability consumers to reconstruct effective run settings from other records.

Changes:
- Snapshot resolved assistant, provider, model, isolation, base, user, input, and origin fields
- Preserve workflowName and asynchronous persistence behavior
- Add focused coverage for precedence, modes, nulls, platform origin, and child runs

Fixes #2397

* simplify: reduce complexity in changed files
* Fix: retry Codex availability failures (#2386, #2425)

Codex 503 circuit-breaker responses and model-capacity errors were classified as fatal or unknown, bypassing the default transient retry loop.

Changes:
- Treat generic auth error text as fatal only after concrete transient signals
- Classify model-capacity errors as transient
- Add regression coverage and update retry precedence documentation

Fixes #2386
Fixes #2425

* simplify: reduce complexity in changed files
@coderabbitai

coderabbitai Bot commented Aug 4, 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: 7691acb0-787e-4bb2-aecc-6b64c2bcc516

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.

@Wirasm
Wirasm merged commit c71f7f5 into main Aug 4, 2026
14 checks passed
Wirasm added a commit that referenced this pull request Aug 4, 2026
Applied 7 of 8 findings. Testing the fixes surfaced two further bugs in the
first pass that the review had not flagged.

Found while testing this commit (not in the review):
- The boundary grep `^Release [0-9]` matched main's SQUASH commit
  (`Release 0.7.1 (#2435)`) in preference to dev's own version-bump commit
  (`Release 0.7.1`). The squash commit's ancestry excludes dev's individual
  history, so the range expanded to 383 commits — the same class of failure this
  whole change exists to prevent. Anchored with `$` so only the bare subject
  matches.
- `^chore(homebrew):` under --extended-regexp parses the parens as a group and
  matches the literal `chorehomebrew:`, silently filtering nothing. Escaped.

From the review:
- Implement the first-release fallback that the prose already promised. Verified
  that an empty LAST_RELEASE yields `..origin/dev`, which resolves against HEAD
  and returns zero commits — a false "nothing to release".
- Move the release/homebrew plumbing exclusions into the git log invocation
  instead of leaving them as prose the reader must apply by hand. Anchored the
  Release pattern to a full x.y.z so a feature commit starting with that word is
  not swallowed.
- Derive the previous changelog heading from CURRENT_VERSION rather than
  hard-coding 0.7.0/0.6, which would have rotted at 0.7.2.
- Make the Step 9 and Step 10 convergence checks exit non-zero. Previously they
  printed divergence and continued into formula and tap publication.
- Replace the formula SHA check. `grep -q "$digest" formula` only asked whether a
  value appeared anywhere, so swapped platform digests both passed, and an empty
  extraction degenerated to `grep -q ""` which matches any file. Now parses the
  sha256 following each platform's own URL, requires a 64-char hex digest, and
  compares exact values.
- Assert the formula version equals the release instead of printing it.
- Pin the env-leak assertion to the test repo's resolved path and an exact key
  count of 1. The prior regex accepted any positive count from any path.

Declined:
- MD028 blank-lines-in-blockquote. Nothing lints markdown in CI or package.json,
  and the blank lines separate distinct warnings that read worse merged.

Verification:
- Corrected Step 4 reproduces the real 0.7.1 range: boundary resolves to
  6c6945c, and 0.7.0..0.7.1 yields 26 commits against the 25 hand-derived for
  the shipped changelog. The extra is ae704a7, which genuinely entered dev via
  the mid-release resync and is then excluded editorially as internal docs.
- New formula check passes on the real v0.7.1 formula and, in a negative test
  with two digests swapped, fails on both — the case the old check missed.
- Exact-match env-leak assertion verified against a live binary run; the
  `pwd -P` resolution is required because the binary logs /private/tmp where the
  test path is /tmp.
Wirasm added a commit that referenced this pull request Aug 4, 2026
… form, and installer env-var docs (#2437)

* fix(release,install): correct the commit boundary, git pull form, and installer env-var docs

Five defects found by running /release and /test-release end to end for v0.7.1.

release skill:
- Step 4 derived the changelog from `git log main..dev`. main squash-merges, so
  that range never loses previously-released commits and grows every release: it
  returned 61 commits for 0.7.1 when 25 were new, and the other 36 were already
  written into the 0.7.0 changelog. Use dev's last `Release x.y.z` commit as the
  boundary, exclude the two release-plumbing commit kinds, and cross-check the
  result against the previous version's recorded PR numbers.
- `git pull origin main` aborts with "Need to specify how to reconcile divergent
  branches" wherever pull.rebase is unset, and would rewrite dev's history where
  it is set to true. Pass --no-rebase explicitly at all four call sites.
- Step 1 had no guard for commits stranded on main. ae704a7 had sat there since
  0.7.0; the content was hand-forward-ported via #2403 but the commit never was,
  so main was not an ancestor of dev. Add an ancestry check that fails closed.
- Document that CI's update-homebrew job races the Step 9/10 pushes, and that a
  clean homebrew/archon.rb merge is not evidence the SHAs are right — both
  branches edit the same four lines. Verify against the published checksums.
- Require flagging a bump that contradicts the range (feat: commits under patch).

test-release skill:
- The curl-mac snippet set INSTALL_DIR on curl rather than bash, so the installer
  never saw it, targeted /usr/local/bin, and failed on sudo after the checksum
  had already verified.
- Test 4 asserted a refusal from the env-leak gate. The shipped guard strips the
  keys and proceeds, so the old criteria reported FAIL against a binary that was
  working correctly. Assert on the strip line and its key count instead.

installer:
- scripts/install.sh documented both VERSION= and INSTALL_DIR= on the curl side,
  so users copying either example silently get the defaults. Mirror in
  packages/docs-web/public/install updated in lockstep (test-install.sh enforces
  byte-equality). install.ps1 is unaffected. Refs #2436.

* fix(release,test-release): address CodeRabbit review on #2437

Applied 7 of 8 findings. Testing the fixes surfaced two further bugs in the
first pass that the review had not flagged.

Found while testing this commit (not in the review):
- The boundary grep `^Release [0-9]` matched main's SQUASH commit
  (`Release 0.7.1 (#2435)`) in preference to dev's own version-bump commit
  (`Release 0.7.1`). The squash commit's ancestry excludes dev's individual
  history, so the range expanded to 383 commits — the same class of failure this
  whole change exists to prevent. Anchored with `$` so only the bare subject
  matches.
- `^chore(homebrew):` under --extended-regexp parses the parens as a group and
  matches the literal `chorehomebrew:`, silently filtering nothing. Escaped.

From the review:
- Implement the first-release fallback that the prose already promised. Verified
  that an empty LAST_RELEASE yields `..origin/dev`, which resolves against HEAD
  and returns zero commits — a false "nothing to release".
- Move the release/homebrew plumbing exclusions into the git log invocation
  instead of leaving them as prose the reader must apply by hand. Anchored the
  Release pattern to a full x.y.z so a feature commit starting with that word is
  not swallowed.
- Derive the previous changelog heading from CURRENT_VERSION rather than
  hard-coding 0.7.0/0.6, which would have rotted at 0.7.2.
- Make the Step 9 and Step 10 convergence checks exit non-zero. Previously they
  printed divergence and continued into formula and tap publication.
- Replace the formula SHA check. `grep -q "$digest" formula` only asked whether a
  value appeared anywhere, so swapped platform digests both passed, and an empty
  extraction degenerated to `grep -q ""` which matches any file. Now parses the
  sha256 following each platform's own URL, requires a 64-char hex digest, and
  compares exact values.
- Assert the formula version equals the release instead of printing it.
- Pin the env-leak assertion to the test repo's resolved path and an exact key
  count of 1. The prior regex accepted any positive count from any path.

Declined:
- MD028 blank-lines-in-blockquote. Nothing lints markdown in CI or package.json,
  and the blank lines separate distinct warnings that read worse merged.

Verification:
- Corrected Step 4 reproduces the real 0.7.1 range: boundary resolves to
  6c6945c, and 0.7.0..0.7.1 yields 26 commits against the 25 hand-derived for
  the shipped changelog. The extra is ae704a7, which genuinely entered dev via
  the mid-release resync and is then excluded editorially as internal docs.
- New formula check passes on the real v0.7.1 formula and, in a negative test
  with two digests swapped, fails on both — the case the old check missed.
- Exact-match env-leak assertion verified against a live binary run; the
  `pwd -P` resolution is required because the binary logs /private/tmp where the
  test path is /tmp.
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.