Skip to content

Release 0.6.0 - #2202

Merged
Wirasm merged 332 commits into
mainfrom
dev
Jul 20, 2026
Merged

Release 0.6.0#2202
Wirasm merged 332 commits into
mainfrom
dev

Conversation

@Wirasm

@Wirasm Wirasm commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Release 0.6.0

Folder projects, opt-in Docker container isolation, three new workflow-composition primitives (include:, loop_group, loop.command), the Archon Studio builder preview, and a large security + reliability batch spanning gates, providers, Windows, Docker, and the console.

Added

Security

Changed

Fixed


Merging this PR releases 0.6.0 to main.

github-actions Bot and others added 30 commits May 12, 2026 10:13
…nd (fixes #1591) (#1634)

* fix(orchestrator): move system context to cacheable systemPrompt.append (fixes #1591)

Prompt caching was broken because the orchestrator embedded its static
system context (project list, workflows, routing rules) in the prompt
parameter, which changes every turn. This caused the Anthropic API to
rebuild the cache prefix on each request (high cache_creation_input_tokens,
zero cache_read_input_tokens).

Move the static orchestrator context into systemPrompt.append, which
extends the Claude Code preset and is part of the cacheable system prompt
prefix. The prompt parameter now contains only per-turn dynamic content
(workflow results, thread context, user message, issue context, files).

Changes:
- Add buildOrchestratorSystemAppend() to prompt-builder.ts
- Simplify buildFullPrompt() to user-facing content only
- Set requestOptions.systemPrompt with preset + append in handleMessage()
- Widen systemPrompt type in AgentRequestOptions and NodeConfig to accept
  the SDK's full union type (string | string[] | preset object)
- Update tests for new prompt construction path

* fix(orchestrator): move system context to cacheable systemPrompt.append

Fixes #1591

* refactor: extract SystemPromptInput type alias to prevent drift

Address CodeRabbit review: consolidate the duplicated systemPrompt
union type into a named type (SystemPromptPreset + SystemPromptInput)
so both AgentRequestOptions and NodeConfig reference a single definition.

* fix: restore file modes (0755 → 0644) from rebase churn

Restore 812 files that had their mode bits changed from 100644 to
100755 during a rebase. No content changes — mode-only fix.

Addresses S4 from review feedback.
…uccess (#1425) (#1662)

* fix(providers,workflows): treat Claude SDK stop_sequence success as success (#1425)

The Claude Agent SDK's SDKResultSuccess type declares is_error as boolean
(not literal false). When a model terminates via a configured stop sequence
the SDK sets is_error: true while keeping subtype: 'success' — its encoding
of "non-default termination, but not a failure".

The claude provider forwarded is_error verbatim into MessageChunk.isError,
so three downstream consumers (dag-executor main path, dag-executor loop
branch, orchestrator-agent direct chat) misclassified clean stop_sequence
terminations as node failures and produced the contradictory user-hostile
error "Node '<X>' failed: SDK returned success" — even though the AI had
already completed correctly and written its output. Multi-user impact on
v0.3.9 / v0.3.11 across review-classify and synthesis pipelines.

Changes:
- claude/provider.ts: normalise is_error + subtype: 'success' as a clean
  result. Don't propagate isError downstream; log a debug-level
  claude.result_success_stop_sequence event for observability instead.
- dag-executor.ts (main path + loop branch): defense-in-depth guard so
  third-party IAgentProvider implementations that forward the SDK pair raw
  can't reintroduce the same false-failure.
- provider/dag-executor/orchestrator-agent tests: regression tests covering
  the stop_sequence success path; guard test ensures real error subtypes
  (error_max_turns) still propagate.

Fixes #1425

* fix(orchestrator,providers,workflows): address review feedback for #1425

- Apply errorSubtype !== 'success' guard at orchestrator-agent.ts handleStreamMode
  and handleBatchMode result branches (defense-in-depth, mirrors dag-executor).
  Without this, a third-party IAgentProvider that forwards the SDK pair raw
  would surface a spurious error on direct chat and drop conversation output.
  Adds matching regression test.
- Rename log event claude.result_success_stop_sequence -> claude.result_success_validated
  per CodeRabbit; aligns with {domain}.{action}_{state} pino convention.
- Lock the new debug log into the provider regression test.
- Drop (#1425) issue refs from production comments (rot risk per CLAUDE.md).
- Rewrite loop-branch guard comment to be self-contained instead of cross-
  referencing the main-path guard 1000+ lines away.
- Add CHANGELOG entry under [Unreleased] -> Fixed.
…owed_tools (#1605) (#1661)

When a DAG node has `skills:` but no `allowed_tools:`, the
AgentDefinition wrapper defaulted tools to `['Skill']` only, stripping
all native Claude Code tools (Read, Bash, Write, etc.).

Fix: omit the `tools` field on AgentDefinition when `options.tools` is
undefined, letting the SDK provide its full default tool set. When
`allowed_tools` is explicitly set, Skill is still appended to the list.
The SSH-to-HTTPS converter in normalizeRepoUrl() and registerRepository()
only matched `git@github.com:` literally. Custom SSH host aliases, GitHub
Enterprise, Gitea, GitLab, and Bitbucket SSH URLs were left unchanged,
which produced workspace paths containing literal `git@<host>:` segments.
On Windows the colon makes mkdir fail with ENOTDIR; on Unix the owner
extraction is malformed.

Replace the literal-host check with an SCP-style regex
`/^git@([^:]+):(.+)$/` at both call sites. The github.com case still
converts identically; new hosts (custom aliases, GHE, GitLab, Bitbucket)
now produce path-safe HTTPS URLs.

Closes #1614.
…t.field works for Pi (#1654)

* fix(workflows): persist structuredOutput on NodeOutput so $node.output.field works for Pi

When a provider parses fence-wrapped or preamble-prefixed JSON onto the
result chunk (Pi/Minimax via tryParseStructuredOutput), the executor
captured it locally but never persisted it onto NodeOutput. Downstream
consumers (substituteNodeOutputRefs, condition-evaluator) then
JSON.parse(output)'d the original prose-prefixed text, which threw, and
$node.output.field resolved to empty.

This persists structuredOutput on NodeOutput (single-shot and
loop-terminal-iteration success paths) and teaches both consumers to
prefer the parsed object over re-parsing prose. Falls back to
JSON.parse(output) when structuredOutput is absent so Claude/Codex
output_format-encoded NodeOutput rows (and older rows written before
this field existed) keep working.

Cross-resume rehydration of structuredOutput from event_data is
out of scope here; resumed runs that re-execute downstream nodes
will fall through to the JSON.parse path, which matches existing
behavior.

Closes #1571

* test(workflows): docstring the structuredOutput makeOutput fixtures
…#1675)

The gate node was the most fragile part of the workflow. It used Pi/Minimax
to return a structured JSON verdict that the DAG branched on, but Pi
intermittently wrapped the JSON in markdown fences or prefixed reasoning
prose, breaking the condition-evaluator's field extraction. When that
happened, every downstream review aspect was silently skipped and the
workflow exited 0 with no review posted — indistinguishable from a
successful run where the gate legitimately declined.

In practice the gate added no signal for hand-picked PRs from the morning
standup brief: across two full days of usage (~13 runs) the gate returned
"review" every single time. The decline/needs_split/unclear branches were
never exercised. Removing the gate eliminates the failure mode without
losing any verdict the workflow has actually produced.

Changes:
- Remove gate, approve-decline, post-decline, approve-unclear nodes from
  maintainer-review-pr.yaml.
- Rewire review-classify to depend on [fetch-pr, fetch-diff].
- Drop read-context (only the gate command consumed it).
- Simplify record-review: hardcode gate_verdict to "review" for back-compat
  with the standup brief's marker logic; drop the one_success join.
- Drop interactive: true (no more approval gate).
- Update synthesize and report commands to remove gate-decision references.
- Delete the now-orphan maintainer-review-gate.md command.

If we later wire up automated review on every open PR (where the
direction/scope decline would matter), reintroduce the gate then — and
this time with a hardened parser or Claude provider on the gate node.
… when no project context (#1618)

* fix(server,workflows,web): surface bundled defaults on /api/workflows when no project context (#1173)

GET /api/workflows short-circuits to an empty array when there is no
`cwd` query param and no registered codebases. The handler never
reaches discovery, so bundled defaults are not surfaced and the UI
renders a misleading "Add workflow definitions to .archon/workflows/"
empty state on first run — even though the bundled YAML files are
present on disk.

This change:

- Threads `cwd: string | null` through `discoverWorkflows` and
  `discoverWorkflowsWithConfig`. When `cwd` is `null` the discovery
  function loads bundled + home scopes and skips the project step
  cleanly (no path-join with an empty cwd, no read-error noise).
- Removes the early-return in the GET handler. When no project
  context exists, it now calls `discoverWorkflowsWithConfig(null, ...)`
  so the response carries the bundled set instead of `[]`.
- Distinguishes the empty-state copy in `WorkflowList` so the rare
  case where the list is genuinely empty reads correctly. With a
  project selected: "No workflows found in this project. Add
  workflow definitions to .archon/workflows/ in the project root."
  Without a project: "No workflows are available. Bundled defaults
  should appear here automatically; if they do not, check that
  `defaults.loadDefaultWorkflows` is enabled in your config."

Tests cover both the API (new `falls back to null cwd when no cwd
query and no codebases registered` case in `api.workflows.test.ts`)
and the discovery layer (new `discoverWorkflows with null cwd` block
in `loader.test.ts` asserting no project-source entries and no
project-step read errors).

* test(workflows): assert bundled defaults surface when cwd is null

The second test in the null-cwd discovery group only verified that
project-source workflows are absent. That assertion would still pass
if the bundled-defaults loader silently regressed.

Add an explicit `bundled` source-label assertion so the test catches
that regression directly.

* fix(workflows): address review on #1618

- api.md: document cwd-omitted behavior so the empty-state case is discoverable
- workflow-discovery.ts: docstring explains loadDefaults default rather than just naming the skipped branch
- api.workflows.test.ts: mockDiscoverWorkflows accepts string | null to match the wider signature
- api.ts: drop trailing period inside the multi-line inline comment
- CHANGELOG.md: add the #1173 Fixed entry under [Unreleased]
- loader.test.ts: add a regression test that asserts loadConfig is not invoked when cwd is null

* test(workflows): tighten null-cwd assertions + drop rot-prone refs

Address Wirasm's polish review on #1618:

- loader.test.ts: assert result.workflows.length === 0 in the
  loadDefaults:false case so the test no longer passes if bundled
  defaults are accidentally loaded.
- loader.test.ts: drop the inline (issue #1173) reference and the
  workflow-discovery.ts file+line pointer from the two comments
  that risk rotting on future refactor.
- api.workflows.test.ts: drop the inline (issue #1173) reference.
- CHANGELOG: append positive framing to the #1173 line so the
  entry reads as "what works now" not just "what no longer breaks".
The pull_request_target event doesn't fire on type=ready_for_review
unless explicitly listed. Add it so flipping a draft PR to ready
triggers the marketplace auto-review.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(marketplace): add archon-idea-to-wo workflow

Adds one entry to the marketplace registry for archon-idea-to-wo —
an interactive 8-node workflow that turns a raw idea into BKM-format
Work Orders through four AI phases with approval gates between each.

Originally authored by @lamachine in PR #1647, where it was proposed
as a bundled default. Repackaged as a standalone SHA-pinned external
repo (coleam00/archon-idea-to-wo) so it can be published through the
community marketplace without waiting for an Archon release.

- Author: lamachine
- Tags: planning, development
- Source: coleam00/archon-idea-to-wo @ 3b0d5d82 (directory format)
- archonVersionCompat: >=0.3.0

Closes #1647

* chore: re-trigger marketplace auto-review after ready-for-review

PR was flipped to ready-for-review before the action's trigger list
included ready_for_review (fixed in d8d5a35 on dev). Empty commit
fires the synchronize event so the auto-review runs now that the
draft gate is cleared.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…to_approve

The 'gh pr review --approve' call fails with 'GitHub Actions is not
permitted to approve pull requests' unless the repo has 'Allow GitHub
Actions to create and approve pull requests' enabled in Settings.
When the approve call fails, fall back to 'gh pr comment' so the PR
author still gets a notification with the auto-review reasoning. The
merge step still runs either way.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GitLab counterpart to archon-smart-pr-review. Adaptive code review of a
GitLab MR — Haiku classifies which review agents are relevant, runs them
in parallel, posts resolvable Discussion threads, auto-approves on 0
critical findings.

Source: lraphael/archon-gitlab-workflows@55ca7349

Co-authored-by: Raphael Lechner <raphael.l@asix.pro>
# Conflicts:
#	packages/docs-web/src/data/marketplace.ts
Before this change the ai-review node only saw four things in its
prompt: PR metadata, the entry diff, the schema validator's pass/fail
summary, and the security scanner's severity+findings summary. The
fetched workflow YAML and command files at the pinned SHA were saved
to $ARTIFACTS_DIR/source/ but never surfaced to the AI, so Haiku ended
up 'reviewing' the registry diff instead of the actual workflow.

Add a bundle-source node that emits every fetched file (capped at 12k
chars each to keep the prompt sane) and reference it in the ai-review
prompt as the artifact under review. Rewrite the prompt instructions
to make Haiku explicitly read the YAML + commands and name what it
concluded about the workflow's behavior in 'reasoning'. Update the
auto_merge comment template so it names what was actually reviewed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GitLab counterpart to archon-comprehensive-pr-review. Full code review of
a GitLab MR — all 5 review agents (code-review, error-handling,
test-coverage, comment-quality, docs-impact) run in parallel, posts
resolvable Discussion threads, auto-approves on 0 critical findings.

Source: lraphael/archon-gitlab-workflows@6e39b359

Co-authored-by: Raphael Lechner <raphael.l@asix.pro>
… instead of silent skip (#1673) (#1694)

When a when: condition references $nodeId.output.field and the node's
output text is not valid JSON, the condition evaluator now returns
parsed:false so the DAG executor treats it as an error (instead of
silently skipping downstream nodes and exiting 0).

Additionally, strip common markdown fences (e.g. ```json blocks)
from output text before attempting JSON.parse, handling the common
Pi/Minimax pattern of wrapping JSON in fences.
…rkers (#1695)

* Fix: extract ARCHON_STATE_JSON markers as standalone lines (#1674)

The persist script extracted Claude's state-JSON block by substring-matching
the BEGIN/END markers, which gave a false match whenever the marker string
appeared inside the brief's prose or inside a JSON string value (e.g. a PR
title narrating this very bug). PR #1676's switch to lastIndexOf made the
self-referential case worse, since the last substring occurrence may now sit
inside a JSON value.

Match the markers only when they occupy an entire line (line-anchored
^...$ regex with the m flag) and pick the last END plus the last BEGIN
before it, so duplicate-emission and substring-in-content both resolve
correctly.

Changes:
- Replace indexOf/lastIndexOf substring matching in Tier 1 with
  line-anchored matchAll regex over BEGIN/END markers
- Add tests for marker substring in brief prose, marker substring inside
  state JSON value, and combined duplicate-BEGIN + marker-in-prose case
- Keep PR #1676's existing test cases (single block, duplicate BEGINs,
  JSON-wrapper fallback, no-valid-format exit 1)

Fixes #1674

* fix(scripts): address review findings in maintainer-standup-persist

- Add WARN diagnostic when all BEGIN markers appear after last END
  (previously silent fallthrough produced misleading terminal error)
- Include 200-char candidate preview in JSON parse error message
  (error was unactionable in headless workflow without raw output)
- Wrap mkdirSync/writeFileSync in try-catch with structured PERSIST FAILED
  message instead of raw Bun stack trace on disk errors
- Fix brief assignment comment to explain WHY [0] vs last-pair asymmetry
- Add test: BEGIN present but END absent (truncated output) exits 1
- Add test: prose preamble before first heading is stripped from brief
- Add clarifying comment to Test 6 noting it is defence-in-depth, not
  a case that failed under the old indexOf approach

* simplify: collapse multi-line comment blocks to single lines
* fix(web): add error handling for copy message button

Handle navigator.clipboard.writeText() failures gracefully:
- Add copyError state to track clipboard API errors
- Show X icon with error color when copy fails
- Reset error state after 2 seconds
- Closes #1540

* docs(MessageBubble): add JSDoc comments for CodeRabbit coverage

Add docstrings to MessageBubbleRaw component and copyMessage function
to satisfy 80% docstring coverage requirement.

Closes #1540

* refactor(MessageBubble): remove JSDoc comments per policy, add debug logging

Per Wirasm review - remove redundant JSDoc blocks that restate code.
The function names and types already convey what the code does.
Add console.debug for clipboard errors for debugging support.

Closes #1540
* fix(workflows): support editing global workflows

* fix: address review feedback on global workflow editing

- Document the `source` query parameter on PUT and DELETE in the API
  reference, including the 400 error for invalid values.
- Document the new `source: "global"` response value for GET, with the
  three-tier auto-discovery order spelled out.
- Add a DELETE test for `?source=global` to confirm the home-scoped file
  is removed (PUT already had coverage; DELETE was missing).
- Add 400 "Invalid workflow source" validation tests for both PUT and
  DELETE so the enum rejection path is locked down.

The blocking issue from the prior review — PUT/DELETE routes using
cwdQuerySchema instead of workflowTargetQuerySchema — was already
resolved on the branch via the rebase against current dev; both routes
now correctly use workflowTargetQuerySchema. The comment-rot reference
(`source:global` string in a comment) was dropped as part of the rebase
conflict resolution where dev's home-scoped resolver block was kept.

---------

Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
* feat(workflows): support Codex MCP nodes

* fix: address MCP workflow review feedback

* fix: address review feedback on Codex MCP support

- Drop the broken `inferProviderFromModel` import from
  packages/cli/src/commands/workflow.ts. The module it referenced
  (@archon/workflows/model-validation) does not exist, which caused the
  CLI package's type-check to fail. Per CLAUDE.md, provider is resolved
  via an explicit chain (`node.provider ?? workflow.provider ?? config.assistant`)
  and model never influences provider selection — vendor SDKs add new
  model names faster than we can keep a mapping in sync, so the
  model-based inference fallback was the wrong primitive anyway. Removed
  the use site and added a comment anchoring the explicit-chain rule.
- Drop "for Claude workflows" from the
  `dag.mcp_plugin_connection_suppressed` documentation in mcp-servers.md.
  The log event is provider-agnostic and the qualifier was misleading
  once Codex got MCP support too.
- Add CHANGELOG.md entry under [Unreleased] for the MCP-for-Codex
  feature with a short note on the system-chunk surfacing behavior.

---------

Co-authored-by: Kirill <borshyo@users.noreply.github.com>
Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
…odes (#1651)

* fix(workflows): pass user-controlled variables via env vars in bash nodes (#1585)

Stop substituting user-controlled variables ($USER_MESSAGE, $ARGUMENTS,
$LOOP_USER_INPUT, $REJECTION_REASON, $LOOP_PREV_OUTPUT, and context
variables) into bash node script bodies before passing to `bash -c`.
Instead, pass them as environment variables on the subprocess.

This prevents shell injection when user messages contain metacharacters
(backticks, $(), unbalanced quotes, etc.) that would otherwise be
interpreted as shell syntax.

The fix adds a `shellSafe` option to `substituteWorkflowVariables()`
that skips user-controlled variable replacement. System-controlled
variables ($WORKFLOW_ID, $ARTIFACTS_DIR, $BASE_BRANCH, $DOCS_DIR)
are still substituted normally since they contain safe, known values.

`substituteNodeOutputRefs()` with `escapedForBash=true` was already
safe via `shellQuote()` — only the Pass 1 variables needed fixing.

Closes #1585. Subsumes #1377.

* fix: address CodeRabbit review — correct loop env semantics and harden test cleanup

- LOOP_PREV_OUTPUT in until_bash now correctly references the previous
  iteration's output (not the current one)
- Test uses try/finally for spy cleanup and asserts call count before
  dereferencing mock.calls

* fix(clone): authenticate non-GitHub forge URLs via GITLAB_TOKEN / GITEA_TOKEN (fixes #1655)

The clone handler only injected auth tokens for github.com URLs.
Private repos on GitLab, Gitea, or Forgejo failed to clone via the
Web UI URL form.

Replace the github.com-specific string substitution with a
forge-aware resolver that:

- Looks up the correct env var per hostname (GH_TOKEN, GITLAB_TOKEN,
  GITEA_TOKEN)
- Applies the correct auth URL scheme (oauth2: prefix for GitLab,
  bare token for GitHub/Gitea)
- Handles self-hosted instances (gitlab.mycompany.com, gitea.myorg.com)

Add 11 new tests: 5 integration (GitLab, self-hosted GitLab, Gitea,
Forgejo, unknown forge) + 4 resolveForgeAuth unit tests + 2 updated
existing tests.

* fix: match forge auth on parsed hostname only (CodeRabbit security review)

Replace substring matching on the full URL with hostname-based
matching. This prevents token leakage when forge names appear in
URL paths (e.g. https://evil.example.com/gitlab/mirror).

- Exact hostname match for known forges (github.com, gitlab.com, gitea.com)
- Label-based match for self-hosted instances (gitlab.mycompany.com)
- Add 2 security regression tests for path-based injection vectors

* fix: add timeout and error logging to until_bash execution

Address review feedback from @Wirasm:
- Add SUBPROCESS_DEFAULT_TIMEOUT to until_bash execFileAsync to prevent
  indefinite hangs (matching other bash invocations in this file)
- Log non-ENOENT errors in until_bash catch block so syntax errors and
  permission issues are visible instead of silently swallowed

* fix: address review feedback — loop env semantics, test cleanup, docs

- Fix LOOP_USER_INPUT in until_bash: only populate on first iteration
  (consistent with LLM prompt substitution at line 1874)
- Change afterAll → afterEach in multi-forge test cleanup (throw-safe)
- Add GITLAB_TOKEN/GITEA_TOKEN cleanup to parent beforeEach
- Add auth env vars section to variables.md reference
- Add shell injection prevention note to script-nodes.md
…EA_TOKEN (fixes #1655) (#1658)

* fix: address review feedback — hostname bug, docs, SSH support, test cleanup

- Fix parsed.host → parsed.hostname to avoid port duplication in clone URLs
- Generalize SSH→HTTPS conversion from GitHub-only to all forges (git@host:path)
- Fix ForgeAuthEntry docstring (remove non-existent exact: true/false refs)
- Add GITLAB_TOKEN/GITEA_TOKEN docs to quick-start, configuration, adapter pages
- Add SSH URL + port duplication regression tests
- Harden test env cleanup with beforeEach/afterEach, import afterEach from bun:test
- Remove redundant per-test env cleanup (beforeEach/afterEach handles it)

Addresses review by @Wirasm on #1658.

* fix(clone): hostname boundary check, remove dead code, add bare URL test

- Only match first hostname label for self-hosted forge detection,
  preventing token leakage to crafted hostnames like
  gitlab.example.com.gitlab.attacker.com
- Remove dead gitea.com entry from FORGE_AUTH (handled by SELF_HOSTED_FORGE)
- Add test for bare host/path URL form (github.com/owner/repo)
- Move orphaned JSDoc comment above resolveForgeAuth function
* feat(pi): support extension-registered provider models via deferred resolution

Extension providers (e.g. pi-provider-kiro) register their models on
the ModelRegistry during session.bindExtensions(), not during the
initial modelRegistry.find() call. The previous approach threw
immediately when find() returned undefined, blocking extension models.

New flow:
1. modelRegistry.find() checks static catalog + models.json (step 3)
2. If found: pass to createAgentSession, fail-fast auth as before
3. If not found: log info, skip auth (extension providers manage their
   own credentials), create session without model
4. After bindExtensions() (step 4g): retry modelRegistry.find() — now
   extension-registered models are discoverable — and call
   session.setModel() to switch to the resolved model
5. If still not found after bindExtensions(): throw with a hint about
   installing the provider extension with enableExtensions: true

This preserves all existing behavior for built-in providers and
models.json custom models while adding support for extension-registered
providers like pi-provider-kiro (Kiro API — 20 free models including
Claude, DeepSeek, Qwen, etc.).

Changes:
- provider.ts: deferred model resolution after bindExtensions();
  conditional model arg to createAgentSession; conditional auth
  fail-fast; session.setModel() for extension-resolved models
- provider.test.ts: add setModel mock; update model-not-found tests
  for two-phase find() behavior

* test: add archon-test-pi example workflow for Kiro extension validation

Simple two-node workflow that verifies the Pi provider works with
extension-registered models (e.g. pi-provider-kiro). Node 1 asks a
factual question, node 2 validates the answer.

Tested with kiro/claude-sonnet-4-6 and kiro/minimax-m2-5.

* docs: add Pi/Kiro prerequisites and setup to examples README

* fix(pi): load models.json custom providers and surface SDK error messages

- Switch ModelRegistry.inMemory() to ModelRegistry.create() so custom
  providers from ~/.pi/agent/models.json (ollama, LM Studio, etc.)
  resolve correctly. Both return the same mutable class supporting
  registerProvider() for extensions.
- Surface Pi's AssistantMessage.errorMessage in the result chunk errors
  array so dag-executor shows actual errors instead of generic
  'SDK returned error'.
- Add ollama/qwen3.5 node to archon-test-pi workflow to validate both
  extension (kiro) and models.json (ollama) provider paths.

* fix(pi): address Wirasm PR review — comment clarity, session cleanup, test mock alignment

- event-bridge: document intentional design of yielding error chunks (isError:true
  propagates in chunk, not via throw); fix event name to pi.result_chunk_error;
  remove noisy model field from error log
- provider: replace verbose multi-paragraph step-numbered comments with concise
  WHY explanations; fix stale ModelRegistry.inMemory() reference (code uses create());
  add session.dispose() on model-not-found throw and setModel() failure to close
  the session cleanup gap
- provider.test: align mock from ModelRegistry.inMemory → ModelRegistry.create to
  match actual provider code (was already broken on the branch); update test
  description and comments to describe behavior rather than step numbers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(pi): add happy-path test for deferred extension model resolution

Verifies that when find() returns undefined on the first call (static catalog miss)
and a model on the second call (after bindExtensions()), session.setModel() is
invoked with the resolved model and no error is returned.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: jhegeman-ds <joel.hegeman@deepseas.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
… cycles (#1530)

* fix(workflows): preserve completed node state across DAG multi-resume cycles (#1520)

Bug: getCompletedDagNodeOutputs only queried 'node_completed' events, but
resumed runs emit 'node_skipped_prior_success' for already-completed nodes.
On a second resume, previously completed nodes were re-executed because
their skip events were invisible to the query.

Fix:
- Query both 'node_completed' and 'node_skipped_prior_success' event types
  in getCompletedDagNodeOutputs so multi-resume correctly identifies all
  previously completed nodes
- Store original node_output in node_skipped_prior_success event data so
  the output is available for $nodeId.output substitution on next resume
- Improve error messaging when all nodes fail: name the specific failed
  nodes and count skipped downstream nodes instead of the generic
  'no successful nodes' message

Closes #1520

* test: address review feedback — assert node_output, test failure message format

- Assert node_output field in node_skipped_prior_success event test
- Add test for empty-string fallback when node ID has undefined output
- Add dedicated test verifying failure message names failing nodes
- Update stale comment from 'no successful nodes' to new behavior
- Add edge-case test for only node_skipped_prior_success rows (no node_completed)
…#1554)

The Gitea, GitHub, and GitLab adapters constructed the canonical clone
path manually as `getArchonWorkspacesPath() + owner + repo`, which
nested the cloned repo at the project root. The CLI clone handler
uses `getProjectSourcePath(owner, repo)` (returning
`workspaces/owner/repo/source/`) and `ensureProjectStructure(owner, repo)`
so that `worktrees/`, `artifacts/`, and `logs/` live as siblings of the
cloned repo. The webhook adapters bypassed this, so the first webhook
clone left the workspace in a layout the rest of the system did not
expect, breaking worktree creation and command discovery.

Replace the manual path construction with `getProjectSourcePath` and
call `ensureProjectStructure` immediately before the clone in all three
adapters. For GitLab nested namespaces (group/subgroup/repo), the leaf
segment becomes the repo and the remaining path becomes the owner,
which matches how the workspace tree treats nested groups.

Closes #1547
Wirasm and others added 26 commits July 17, 2026 19:56
…ut (#2173)

Pi streams assistant text as many tiny text_delta events (often a few
characters). The bridge mapped each to its own `assistant` chunk, and the
DAG executor joins assistant chunks with "\n\n" in batch mode — shattering
Pi output into "Се\n\nгод\n\nня" instead of "Сегодня". Claude and Codex
each yield one chunk per complete text block, so the join is correct for
them but wrong for Pi's char-level deltas.

Coalesce consecutive text_delta chunks in bridgeSession into one
block-level assistant chunk, flushed only at natural boundaries (turn
start, text-block end, before any non-assistant chunk, end-of-stream, and
on error). currentTurnText/assistantBuffer still see every delta, so
streaming-tail detection and structured-output buffering are unchanged;
the DAG executor and other providers are untouched.

Closes #1814

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…ed binary (#2174)

* fix(providers): register Pi Bedrock backend so it loads in the compiled binary

Pi lazy-loads every backend via dynamic import(). All backends use
string-literal specifiers that Bun's --compile bundles, except Bedrock,
whose loader routes through a computed-specifier indirection Bun can't
resolve — so bedrock-converse-stream.js and @aws-sdk/client-bedrock-runtime
never get embedded, and amazon-bedrock/* models fail in the binary with
"Cannot find module './bedrock-converse-stream.js' from '/$bunfs/…'".

Mirror Pi's own binary fix (earendil-works/pi#2350): register the Bedrock
module override via setBedrockProviderModule() once per process, fed through
the static @earendil-works/pi-ai/compat + /bedrock-provider subpaths that Bun
does bundle. Called from sendQuery() (not module load) so the lazy-load
invariant holds; unconditional so dev and binary stay in parity; a
registration failure is swallowed with a WARN so non-Bedrock backends are
unaffected.

Closes #2154

* chore: pair the bedrock register log events per the logging convention

---------

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

* dx(providers): generate provider capability matrix from capabilities.ts

Provider capability docs were hand-maintained across CLAUDE.md, the
docs-web assistant guides, and the archon skill, and all three drifted
from packages/providers/src/*/capabilities.ts (the 2026-07-14 audits and
PR #2167 corrected the latest instances by hand). Nothing prevented
recurrence.

Add a generated-artifact + drift-gate, mirroring generate:pi-vendor-map:

- scripts/generate-capability-matrix.ts renders a canonical matrix from
  the registry's capability constants (the same objects the dag-executor
  reads for its ignored-capability warnings) into a docs reference page,
  reference/provider-capabilities.md.
- check:capability-matrix wired into `bun run validate` and CI; a
  capability change now fails CI until the page is regenerated.
- a new ProviderCapabilities field fails the generator until it gets a
  matrix axis (totality guard), so the matrix can't silently omit one.

Also fix a declared-but-unwired capability found in the sub-task audit:
opencode declared hooks: true but has no nodeConfig.hooks translation
site, so the dag-executor's ignored-capability warning was suppressed and
a node's hooks: were dropped silently (a fail-fast violation). Set
hooks: false and correct the ai-assistants doc row. agents: true is
correct — verified wired via getOrderedAgents -> materializeAgents.

Closes #2116

* docs(skill): correct OpenCode hooks claims in the archon skill tables

The archon skill's provider tables were the third drift surface #2116
names, and the capabilities.ts hooks fix in the previous commit made
their "OpenCode supports hooks" claims definitively wrong. Align them:

- parameter-matrix.md Providers-at-a-Glance: OpenCode hooks -> ignored + warn
- dag-advanced.md / SKILL.md / workflow-dag.md: hooks is Claude-only
- point the skill's matrix at the canonical generated Provider Capability
  Matrix as the source of truth (issue proposal step 2)

check:bundled-skill unaffected (file set unchanged; content is imported
at build time).

* docs: qualify OpenCode/Copilot agents as configured selection, not inline definitions

---------

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
… paths (#2178) (#2179)

* fix(workflows): align skill validation with execution resolver search paths (#2178)

The validator was checking only .claude/skills/ directories while the
execution resolver searches both .agents/skills/ and .claude/skills/
(with .agents/ preferred). This caused false validation warnings for
skills that existed in .agents/skills/.

Now the validator reuses the skillSearchRoots() function from
@archon/providers to check all four search locations, ensuring
consistency between validation and execution.

Closes #2178

* test(workflows): cover skill validation across all resolver search roots

Regression tests for #2178: the validator now accepts skills anywhere the
runtime resolver looks (.agents/skills/ and .claude/skills/, project and
user level) and warns only when a skill is found in none of them. HOME is
pointed at a temp dir per test so real user-level skills can't leak in.

Also drop the unused resolveSkillDirectories barrel export from
@archon/providers — all consumers import it via relative paths; the
validator only needs skillSearchRoots.

---------

Co-authored-by: 王珩玮 <jtu_111@126.com>
Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…etried) (#2181)

* fix(workflows): classify session-limit/quota errors as FATAL so retry never burns attempts

'Claude session limit reached' node failures were classified UNKNOWN by
classifyError — the never-retry FATAL guard in the DAG retry loop did not
apply, so retry: blocks could re-attempt them back-to-back inside the same
five-hour limit window where every attempt is doomed (on_error: all directly;
on_error: transient via the 'exited with code' manifestation of the same
limit). The synthesized 'Credit exhaustion detected' message had the same gap:
the 'credit balance' FATAL pattern does not match it.

Add 'session limit', 'usage limit reached', and 'credit exhaustion' to
FATAL_PATTERNS. FATAL already takes precedence over TRANSIENT in
classifyError, so limit messages that also mention rate limits stay FATAL,
and the existing !isFatal guard now fails these nodes on first occurrence
under every on_error mode. Telemetry errorClass becomes 'fatal' instead of
'unknown'.

Tests cover both synthesized session-limit strings, the usage-limit and
credit-exhaustion strings, FATAL-over-TRANSIENT precedence, and a drift
guard asserting every detectCreditExhaustion output string classifies FATAL
so future rewording cannot silently regress to UNKNOWN.

Closes #2177

* test(workflows): consolidate session-limit FATAL tests, drop drift-guard duplicates

---------

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

* fix(providers): retry Claude 400 tool-use-concurrency errors as rate limits

The Anthropic API's "400 due to tool use concurrency issues" error is
transient — a retry after backoff almost always succeeds — but it was
classified as non-retryable on both error paths:

- Text path: add 'tool use concurrency' to RATE_LIMIT_PATTERNS so thrown
  subprocess errors carrying the message retry (the fix proposed in #1341).
- Structural path (#1797, added after the issue was filed): a
  ClaudeApiResultError with this text arrives under a catch-all SDK code
  ('unknown'/'invalid_request' both classify to 'unknown'), which fails
  fast. Reclassify to rate_limit via a narrow text fallback that applies
  ONLY when the typed code resolves to the catch-all class — specific
  typed codes remain authoritative and are never overridden by text.

Closes #1341

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(providers): codify untyped-transient text-match policy behind UNTYPED_TRANSIENT_PATTERNS

Extract the tool-use-concurrency text match in classifyAndEnrichError into a
named UNTYPED_TRANSIENT_PATTERNS constant with an explicit admission contract:
entries apply only when the SDK's typed code resolved to the catch-all
'unknown' class, must name the upstream error, link an upstream typing
request, and be removed once the SDK types them. Behavior unchanged.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
…nt (#2187)

In the console AI Settings model pickers (Model Tiers / Aliases /
Defaults), suggestion rows gave the cost/context hint layout priority:
the hint span was shrink-0 while the model name carried min-w-0
truncate, so with justify-between the name collapsed to a couple of
characters (e.g. 'a', 'ama…') on Pi's long hints.

Stack the row instead (same shape as ArtifactPanel's list rows): the
model name keeps the full row width on its own line, and the hint moves
to a muted second line that truncates as the secondary element.

Closes #2031

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
The bundled archon-workflow-builder saves its generated YAML to the
relative path .archon/workflows/<name>.yaml, but runs default to worktree
isolation - so the file landed in a throwaway worktree and the user's repo
stayed empty despite the 'Workflow saved!' report.

Pin the workflow with worktree.enabled: false (same mechanism and rationale
as archon-assist, #1546) so the save lands in the user's actual checkout.
mutates_checkout stays unset: the workflow does write into the live
checkout, so the path-lock guard must remain active.

Regenerated the embedded bundle via bun run generate:bundled.

Closes #1220

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

* fix(console): show chat-dispatched run messages on the run detail page

The getRun route deliberately splits the conversation pointer: CLI runs
get conversation_platform_id, while web/chat-dispatched runs (parent
conversation set) only get worker_platform_id. The console run primitive
consumed only the former, so for web runs the messages query never fired
and the run detail page showed "0 messages" without the agent's answer.

Map worker_platform_id in the console run primitive and pick the message
source via a new runMessageConversationId() helper that falls back to
the worker conversation when conversation_platform_id is absent. CLI
behavior unchanged; no server or OpenAPI change needed — the field was
already exposed in the getRun response schema.

Fixes #2048

* refactor(console): accept undefined in runMessageConversationId

Self-review follow-up: widen the helper to Run | undefined so the
RunDetailPage call site collapses to plain optional chaining (matching
the file's existing style for not-yet-loaded detail), trim the
duplicated split-pointer explanation from the field doc, and cover the
undefined input in tests.

---------

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

The create-with-message and web workflow-run routes fired title generation
with no model, so the Codex provider fell through to the raw config default
(gpt-5.3-codex) — which 400s on ChatGPT-plan accounts, leaving every title
stuck on truncated message text and logging a provider error on each
conversation start.

Add resolveTitleRequest() to the orchestrator: it resolves the small tier
(config tiers + per-user prefs when a userId is available, mirroring the
chat-path fix from #1873) into a provider plus fully resolved request
options, and never throws — on any failure it degrades to the legacy bare
request. Both server call sites now route title generation through it.

Fixes #1855

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

* fix(web): render console timestamps in the viewer's local timezone

SQLite rows reach the console as naive UTC strings ("YYYY-MM-DD HH:MM:SS",
no suffix) because the API serializers pass string timestamps through
verbatim. The console's format helpers parsed them with bare new Date(),
which treats suffix-less strings as browser-local time — so every clock,
"Xm ago" label, and elapsed counter showed UTC digits as local time.

Add an offset-aware ensureUtc guard (append Z and swap the space
separator for T only when the string carries no Z or numeric offset) and
route formatClock, relativeTime, elapsedSince, and
formatRelativeToBaseline through it. Z-suffixed inputs (live SSE values,
Date-serialized Postgres rows) pass through untouched, so nothing is
double-shifted — and mixed naive/Z deltas now compute correctly.

Closes #1990

* Extract toUtcMs helper for repeated epoch conversions

* Pin non-UTC timezone in format tests so they can catch the regression

---------

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…ocess exit (#2191)

* fix(cli): scope workflow signal cleanup to the owned run and remove handlers on settle

The SIGTERM/SIGINT cleanup registered by `workflow run` (and reached
through the approve/reject/resume inline auto-resume) had three hazards
that could destructively mark runs failed on process exit (#1123):

- The handlers were anonymous `process.once` closures — impossible to
  remove, so they stayed live after executeWorkflow returned with the
  run legitimately paused at the next gate, and repeated
  workflowRunCommand calls in one process stacked handlers.
- The cleanup failed whatever `getActiveWorkflowRun(conversation.id)`
  returned — a conversation-wide query (children match via
  parent_conversation_id) that could hit a run driven by another
  process, violating the no-autonomous-lifecycle-mutation principle.

The cleanup now only ever fails THE run this process started or
resumed: the id is preset from the resumable lookup on resume, and
learned from the workflow_started emitter event for fresh runs (the
progress subscription is now always registered; --quiet only gates
rendering). Before failing, the handler re-reads the run status and
leaves any non-running state alone — failWorkflowRun's own
status='running' CAS closes the remaining read-then-write window. The
handlers are named and deregistered in the finally once the run
settles. A genuine mid-run interrupt still marks the run failed.

In the engine, the three human-gate pause sites (approval, loop,
loop_group) now tolerate losing the pause CAS to an external
transition: instead of cascading into a spurious node failure, they
log, skip the approval_pending emit, and let the between-layer status
check halt the DAG cleanly. A pause failure while the run is still
running rethrows unchanged, and the container write-back gate stays
deliberately fail-closed. The approve/reject gate-resolution CAS
(#2146) and paused-staging semantics (#2112) are untouched.

Closes #1123

* refactor(workflows): emit approval_pending inside the gate-pause helper

Review follow-up on the #1123 fix: the pause-then-conditionally-emit
pairing appeared identically at all three gate sites, and every future
caller would have had to remember to gate its emit on the return value
to avoid a phantom approval_pending after a lost CAS. The helper now
owns the emit (ApprovalContext already carries nodeId + message), so
the invariant can't be gotten wrong at a new call site. Also collapses
the trivial signal-handler wrappers to expression bodies. No behavior
change — the emitter-observing tests pass unchanged.

---------

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…ner start (#1981)

* fix(docker): eliminate slow recursive chown in image build and container start (#1970)

* review fixes: surface chown stderr on failure, tighten comments

- fix_ownership now captures stderr from find/chown and emits it before
  exit 1, so a read-only or permission-denied volume is diagnosable
  instead of only the canned error line (still fails fast)
- reword comments that referenced removed code (old chown -Rh semantics,
  'same way we do /.archon') to state the invariant directly
- trim the Dockerfile layer comment and entrypoint rationale to
  one-liners; keep the #1970 issue ref, drop timing snapshots
- note why the find predicate uses -o, not -a
- split a double-em-dash sentence in the docker deployment doc

---------

Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
* fix(adapters): drop duplicate webhook deliveries at ingest (#1951)

A duplicate delivery of the same comment passed every guard and queued a
byte-identical second workflow run behind the first: the lock manager
orders per-conversation messages but never dedups them. Dual repo+App
webhook subscriptions (different delivery GUIDs for one comment), LB
double-forwards, and redeliveries all hit this.

Adds a bounded TTL first-seen cache in core and gates GitHub webhook
processing on a logical idempotency key (comment id + updated_at, so
edited comments still re-trigger), falling back to the X-GitHub-Delivery
GUID when the payload lacks comment identity. Fails open when neither is
available.

* fix(adapters): require full comment identity for dedup key; deterministic TTL tests

Keying on comment id alone (updated_at absent) would dedup an edit
against the original within the TTL window — require both fields and
use the delivery-GUID fallback otherwise. Dedup TTL tests now use an
injected clock instead of wall-clock sleeps.

* fix(core,adapters): name dedup eviction log per convention; document seen() ordering

The eviction debug log used 'evicted_oldest_entries', which does not
follow the {domain}.{action}_{state} event-name convention; rename it to
'delivery_dedup.entries_evicted'.

Also document why the ingest dedup marks the key before downstream
processing: the duplicates it guards against are dual subscriptions
delivering the same comment near-simultaneously, so the key must be
claimed before either delivery finishes or both would double-process.

* test(server): extract GitHub webhook route and cover delivery-id forwarding

Move the /webhooks/github handler into routes/webhooks.ts behind a narrow
GithubWebhookTarget seam so it can be tested against a mocked adapter.
The new route tests pin the X-GitHub-Delivery forwarding (present header,
omitted header -> undefined), the missing-signature 400, and the
fire-and-forget 200 on async processing failure. The adapter tests pass
deliveryId directly, so only this seam catches broken header forwarding.

* test(adapters,core): make dedup webhook tests hermetic; pin claim-at-ingest tradeoff

The deliver() helper previously swallowed every error while first-seen
deliveries ran into the unmocked Octokit path, making live GitHub API
calls from the test suite. Stub the Octokit methods the webhook flow hits
(repos.get, issues.listComments/createComment), stub handleMessage via
spyOn, and drop the broad catch so unexpected failures fail the test.
Assertions on the stubs verify the pipeline completes locally.

Also:
- regression test pinning that a redelivery within the TTL is dropped
  even when the first attempt failed transiently (the documented
  fire-and-forget tradeoff of claiming the key at ingest)
- assert github.duplicate_delivery_dropped fires on the dedup path
- rework the eviction-position test with real size pressure (maxEntries=2)
- type the test helpers (TestClock, deliver return type)

* docs(adapters,core): tighten dedup comments; document three-arg webhook flow

- condense the dedup-key and claim-timing comment blocks to the
  load-bearing WHY (dual subscriptions deliver the same comment under
  different GUIDs; claim before downstream work or both pass)
- trim the delivery-dedup file JSDoc to the rationale paragraph and drop
  the name-restating constant/field JSDocs
- note in prune() that its front-of-map invariant relies on seen()
  short-circuiting on fresh entries
- drop issue-number references from source comments (they live in the
  changelog/PR)
- neutralize the WebhookEvent.comment field docs so the type is not
  coupled to the dedup consumer
- architecture.md: three-arg handleWebhook + dedup step in the flow
- github-app-setup.md: note that dual webhooks are deduplicated during
  PAT->App migration

---------

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

WebUI-dispatched background workflows unconditionally resolved isolation
whenever the conversation had a codebase, so a workflow declaring
worktree.enabled: false still tried to create a worktree (and failed on
local-path-only codebases). Mirror the CLI and foreground guards: skip
validateAndResolveIsolation on explicit opt-out, run the workflow in the
parent conversation's cwd, and emit workflow.worktree_disabled_by_policy
so operators can tell live-checkout runs from worktree runs.

Closes #1368

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
Co-authored-by: StuartJAtkinson <StuartJAtkinson@users.noreply.github.com>
…iles (#2194)

Direction call forced by #1224 (Traefik --profile request): Caddy stays the
single maintained reference proxy; alternative proxies/infra land as docs
examples against the proxy contract (#2193). Adds the citable
$deployment-recipes clause for future triage.

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…ense-in-depth (#1243)

* refactor(server): use parseOwnerRepo helper in artifact route for defense-in-depth

The artifact endpoint parsed codebase.name with split('/') and only
checked nameParts.length < 2, duplicating and weakening the shared
parseOwnerRepo helper already used by registerRepository. Replace with
the helper that also rejects '..', '.', empty segments, and non-safe
characters.

Not an exploitable bug today — registration already goes through
basename()/URL parsing which feed parseOwnerRepo — but collapses a
future regression class if a new name-creation path is ever added.

* refactor(server): extend parseOwnerRepo guard to the artifact-listing route

The artifact-listing endpoint (GET /api/runs/:runId/artifacts) was added
after PR #1243 was written and reintroduced the same inline
codebase.name.split('/') pattern the PR removes from the file-serving
route. Apply the shared parseOwnerRepo helper there too, keeping that
site's existing rejection shape (200 with an empty files list).

Traversal-shaped names are now rejected at the parse stage before any
path is built; the downstream ARCHON_HOME containment check stays as a
second layer. Folder projects (kind: 'folder') are unaffected: their
plain display names never had an owner/repo shape, so both routes keep
returning the same empty-list/404 responses they did before.

Adds route tests for malformed names on both endpoints and locks in the
folder-project behavior.

Co-authored-by: shaun0927 <70629228+shaun0927@users.noreply.github.com>

---------

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…grity (#1246) (#1251)

* security(cli): add build-time embedded hash for web-dist tarball integrity (#1246)

* test(cli): cover embedded, mismatch, and remote-fallback paths in downloadWebDist

Export downloadWebDist with the embedded checksum as a defaulted parameter so
tests can drive all three verification paths against a real tarball:
embedded hash match (no checksums.txt fetch), embedded mismatch (hard fail
before extraction), and empty constant (remote checksums.txt fallback).

---------

Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
…1691)

* fix(marketplace): guard fetch-source against missing sourceUrl/sha

The marketplace-fetch-source script crashes with TypeError when sourceUrl
is undefined, which happens when the CI workflow runs on PRs that don't
add a marketplace entry (no sourceUrl in entry.json).

- Make sourceUrl and sha optional in MarketplaceEntry interface
- Add early return with exit(0) when either field is missing
- Output empty {files, errors} result so downstream AI review node
  can proceed without source files instead of killing the workflow

Fixes the Run marketplace auto-review CI failure on PRs #1685 and #1689.

* test(marketplace): add test coverage for fetch-source guard block

- Add marketplace-fetch-source.test.ts covering all guard paths:
  - missing sourceUrl only (stderr mentions sourceUrl, exit 0)
  - missing sha only (stderr mentions sha, exit 0)
  - missing both (stderr mentions both, exit 0)
  - both present (guard does not trigger, gh api errors expected)
  - missing entry.json (exit 1)
- Trim inline comment to 2 lines per review feedback

* test(marketplace): make fetch-source guard tests hermetic

Replace the network-dependent 'both fields present' case with an
intentionally unrecognized sourceUrl so the script stops deterministically
at URL validation instead of reaching gh api. Trim WHAT-restating comments
per review and add the missing trailing newline.

---------

Co-authored-by: Hermes <hermes@local>
Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…1711)

* fix(server/workflows): resolve project workflows with .yml extension

`GET /api/workflows/:name` and `DELETE /api/workflows/:name` constructed
the on-disk filename as `${name}.yaml` and only tried that one extension.
The discovery scanner in `workflow-discovery.ts` (the source for
`GET /api/workflows`) accepts both `.yaml` and `.yml`, so a project that
named its workflow `phase-0-spike.yml` would appear in the list endpoint
but 404 on the detail endpoint and could not be deleted via the API.

This is the same scope-mismatch class as the trailing-newline / case-sensitivity
bugs that crop up whenever two endpoints derive paths independently — fix it
once at the read site rather than forcing every author to rename files.

Changes:
- GET /api/workflows/:name now probes `${name}.yaml` then `${name}.yml`
  in each scope (project, home, default). Extracted as a small inline
  helper so the same logic isn't duplicated three times.
- DELETE /api/workflows/:name probes both extensions before returning 404.
- PUT (save) is unchanged: writes continue to use `.yaml` as the
  canonical extension for newly-created workflows. Existing `.yml` files
  remain readable and deletable.
- Bundled defaults are still keyed on `${name}.yaml` (the suffix is a
  synthetic in-memory key there, not a filesystem path).

Tests:
- Added regression: `phase-0-spike.yml` in `<cwd>/.archon/workflows/`
  returns 200 with `source: project` and `filename: phase-0-spike.yml`.
- All 36 tests in `api.workflows.test.ts` pass (was 35).
- `tsc --noEmit` on `packages/server` is clean.

* fix(server/workflows): delete both .yaml and .yml twins, cover .yml fallbacks

- DELETE /api/workflows/:name now unlinks both extension variants before
  responding, so a twin file can't stay discoverable after a reported
  deletion; 404 only when neither existed
- add tests: home-scope and source-build defaults .yml GET lookups,
  .yml-only and twin DELETE regressions, plus the source=global .yml DELETE
- tighten the tryReadWorkflowAt comment to reference loadWorkflowsFromDir
  instead of a raw line number

---------

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
* fix(web): URL-encode conversation IDs in PATCH/DELETE requests

updateConversation and deleteConversation built request URLs with raw
template interpolation, so platform conversation IDs containing slashes
(GitHub/Gitea forge format: owner/repo#issue) split the path and missed
the route, returning 404. sendMessage and getMessages already used
encodeURIComponent — apply the same to PATCH/DELETE.

Adds DELETE and PATCH regression tests at the route layer covering the
encoded-slash + hash case.

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

* test: cover client-side URL encoding of conversation IDs in PATCH/DELETE

The existing regression tests exercise the server route's decoding, so they
would still pass if the client-side encodeURIComponent fix were reverted.
Add web API-client tests that spy on fetch and assert updateConversation/
deleteConversation hit /api/conversations/Solvation-BV%2FArchon%2342 with
the right method (and title body for PATCH). Verified they fail against the
unencoded URLs.

Also extend the server PATCH/DELETE route tests with the Gitea ! separator
and unknown-ID 404 cases for parity with the existing GET coverage.

---------

Co-authored-by: LLMsolution <llmsolution@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
* fix(core): classify Claude usage-limit errors as rate limits

Claude's 5-hour subscription cap surfaces as "You've hit your limit ·
resets <time>" (and "usage limit" when org overage is disabled). Neither
matched the rate-limit branch in classifyAndFormatError, so users hit the
generic "An unexpected error occurred" fallback instead of a clear message.

Match both phrasings case-insensitively alongside "rate limit" and echo the
reset time when present, e.g. "⚠️ AI usage limit reached (resets 4:50pm
(UTC)). Please wait and try again." Applies to every platform.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(core/error-formatter): tighten comments per Wirasm review on PR #1761

Address the maintainer-review-pr suggestions on the upstream PR. No behavior
change; @Wirasm's verdict was 'ready-to-merge' with these comment-quality nits:

- Replace the 5-line block comment that restated what the regex does and
  embedded task-specific labels ("Claude subscription cap", "org-disabled-
  overage variant") with a single line stating intent: handles AI-provider
  rate-limit and usage-cap errors (both generic and Claude-specific formats).
- Tighten the p.m.-truncation comment to a single line. The constraint it
  protects is genuinely non-obvious (anchor on · / stop at · or newline so
  abbreviated periods aren't cut) but it doesn't need two lines of prose.

The third item Wirasm flagged (missing explicit `string` return type on
classifyAndFormatError) is already present on the current PR head and needs
no change.

Validation: core type-check, 57 error-formatter tests pass, ESLint +
Prettier clean.

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

* fix(core/error-formatter): pin reset-time fallback and cover session-limit shapes

- Turn the classification comment into a durable section label and note why
  broad substrings are safe (call sites only feed AI conversation-turn errors)
- Constrain the no-separator resets fallback: drop any follow-on sentence so
  the workflow session-limit FATAL shape yields just the reset clause
- Classify 'session limit' as a usage cap so it no longer falls through to the
  generic session branch and misleadingly suggests /reset
- Pin the fallback with direct tests: standalone 'Resets in 5 minutes',
  reset-without-plural negative, multi-separator single-segment capture, p.m.
  preservation, and the exact #2181 FATAL shape

---------

Co-authored-by: k4n4lm00n <191119700+k4n4lm00n@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
… routes (#2047)

* fix(server): accept namespaced workflow names on HTTP launch and read routes

Workflows can be namespaced one subfolder deep on disk (e.g. triage/foo),
discovered at MAX_DISCOVERY_DEPTH = 1, and the CLI launches them by that name.
The HTTP /run and GET routes validated the name with isValidCommandName, which
rejects any '/', so namespaced workflows could only be run from the CLI (#2007).

Add isValidWorkflowName: allows a single '/' by requiring every slash-separated
segment to be a valid command name, so '..', '\', leading dots, and empty
segments (leading/trailing/double slash) stay rejected and path traversal is
unchanged. Wire it into the non-mutating /run and GET routes only; PUT and
DELETE keep isValidCommandName since creating a subfolder on save is separate.

* test(server): mock isValidWorkflowName in the /run validation test

The POST /api/workflows/:name/run route now validates with
isValidWorkflowName, but this test still mocked isValidCommandName to
reject, so validation passed and the route returned 200 instead of 400.
Mock the validator the route actually calls.

* refactor(workflows): share MAX_DISCOVERY_DEPTH between validator and loader

The workflow-name validator hard-coded a two-segment cap to mirror the
loader's discovery depth, duplicating the one-subfolder contract. If the
loader's MAX_DISCOVERY_DEPTH ever changed, the validator would silently
drift and start rejecting discoverable names or accepting names the loader
never finds.

Move MAX_DISCOVERY_DEPTH into command-validation (the dependency-free leaf
module) as the single source of truth, derive the segment cap from it, and
import it back into workflow-discovery. The import direction keeps the leaf
import-free, so the executor/dag-executor cycle this module breaks stays
broken.

* test(server): cover namespaced workflow name on the run endpoint

The namespaced-name fix updated both GET /api/workflows/:name and
POST /api/workflows/:name/run, but the new coverage only locked down the
GET path. Since launchability over HTTP is the point of the fix, exercise a
percent-encoded namespaced name on the run endpoint too: assert it is
accepted and that the decoded `triage/review` reaches the orchestrator as
`/workflow run triage/review ...`. The test installs the real validator
logic so it goes red if the run route validates with isValidCommandName
instead of isValidWorkflowName.

* docs: correct MAX_DISCOVERY_DEPTH JSDoc and trim review-flagged comments

Review follow-ups on the namespaced-name fix:

- MAX_DISCOVERY_DEPTH's JSDoc claimed the constant had to live in
  command-validation to avoid re-introducing a module cycle, and pointed at a
  'file note above' that doesn't exist. workflow-discovery already imports from
  this module, so there is no new edge and no cycle. Replaced with the real
  rationale: shared so discovery depth and the name validator can't drift.
- Dropped the isValidWorkflowName JSDoc paragraph narrating HTTP/CLI callers;
  the first paragraph covers the contract.
- Keyed the run-route regression test comment on the contract instead of 'the
  fix', and collapsed the describe-block preamble in command-validation.test.ts
  to one line.

---------

Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
* Fix: align docker/build-push-action to @v6 in publish.yml (#127)

Issue #127 (Dependabot bump for actions/setup-python 5.4.0 → 5.6.0) is
superseded — actions/setup-python is no longer used in any workflow after
the Archon overhaul. This commit addresses the spirit of the issue by
fixing a related CI hygiene gap: docker/build-push-action was pinned at
@v5 in publish.yml while test.yml already uses @v6.

Changes:
- Bump docker/build-push-action from @v5 to @v6 in .github/workflows/publish.yml

Fixes #127

* ci: retrigger checks after undraft

---------

Co-authored-by: waltg101 <145871366+waltg101@users.noreply.github.com>
Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
…#1759) (#1789)

* feat(workflows): add loop.command as exactly-one alternative to loop.prompt

A loop node can now load its iteration prompt from a named command file
(`loop.command: <name>`) instead of inlining it as `loop.prompt`. The schema
enforces exactly one of the two. The loaded file is read once at node start
and reused for every iteration; substitution semantics (`$LOOP_PREV_OUTPUT`,
`$LOOP_USER_INPUT`, `$nodeId.output`, etc.) are unchanged. A bad reference
fails the node with an actionable error before iteration 1.

This mirrors the existing `prompt:` ⇄ `command:` relationship at the node
level, so the longest/most-reusable loop prompts (Ralph-style implement loops)
can live as Markdown files instead of being inlined in YAML.

Refs specs/loop-command.md (#1759).

* feat(workflows): validate loop.command resolves to a real command file

Adds Level 3 (resource resolution) checks for loop.command parallel to
existing command-node checks: invalid name, unresolved file, and
"did you mean…" suggestions, all surfaced before a workflow runs and
labelled with field 'loop.command'. Reuses availableCommands already
computed at the top of the validator loop.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(web): regenerate API types so loop.command surfaces in DagNode

Run after the workflow Zod schema gained an optional loop.command sibling
to loop.prompt (items 1/2/4). Regenerated via `bun --filter @archon/web
generate:types` against `bun run dev:server` on port 3090, then
`bun x prettier --write` to keep the committed file in repo style and
reduce the diff to the actual schema delta.

Net effect: components['schemas']['DagNode']['loop'] now has prompt and
command both optional, unblocking web-side consumers (next: canvas label
for command-backed loops).

* feat(web): label command-backed loop nodes on the canvas

`resolveNodeDisplay` now branches inside the `'loop' in dn` block on
`dn.loop?.command`: when a loop node carries `loop.command`, it returns
`{ label: dn.loop.command, nodeType: 'loop' }` (no `promptText`), so the
read-only builder canvas shows the command name as the node's label —
mirroring how `command:` nodes display today. Inline-prompt loops keep
their existing `{ label: 'Loop', promptText }` shape unchanged.

`DagNodeComponent` needs no change: its `'loop'` case in
`getContentPreview` reads `promptText?.split('\n')[0] ?? ''`, which is
empty for command-backed loops (same effective preview as command
nodes, whose label already lives in the header). The `LOOP` badge and
loop stripe stay — only the label text changes — because the node is
still semantically a loop.

Closes the web-side acceptance criterion in specs/loop-command.md:
"the builder canvas labels a command-backed loop by its command name."

* test(workflows): loader cases for loop.command happy path, both/neither, unsafe name, ref-scan

Lock the loader's loop.command behaviour with five cases inside
'describe(loop node parsing)': cleanly parses loop.command on its own,
rejects both-present with an 'exactly one' message that names both
fields, rejects neither-present with both alternatives named (so authors
discover loop.command exists, not just the legacy loop.prompt), rejects
'../escape' with 'invalid command name', and regression-guards the
\$nodeId.output ref scanner so a command-backed loop neither crashes nor
hides a sibling's reference to its output.

Adds 5 tests (126/126 in loader.test.ts; was 121). Pairs with the
schema + loader + executor + validator changes from earlier items in
specs/loop-command.md.

* test(workflows): validator cases for loop.command resource resolution

Mirror the Level-3 command-node coverage for the new loop.command branch:
repo-local hit, missing-target with suggestions, unsafe name guard,
bundled-default fallback, and home-scope (ARCHON_HOME) resolution. Pure
test addition — pins the behaviour landed in the validator change so a
refactor cannot silently drop the defense-in-depth isValidCommandName
check or the bundled/home resolution paths.

* test(workflows): executor cases for loop.command runtime contract

Pin the runtime behaviour of command-backed loop nodes with five tests in
the existing `loop node execution` block:

- read-once invariant: writes a command file, deletes it synchronously
  inside iter 1's mock generator, asserts iter 2 still runs from the
  in-memory template (no node_failed / loop_iteration_failed events).
- fail-fast paths: missing target, empty target, and unsafe-name
  (../escape) each return before any sendQuery call and emit
  node_failed with the actionable diagnostic. The unsafe-name case
  bypasses the loop schema's superRefine via an "as unknown as DagNode"
  cast so the executor's defense-in-depth branch is exercised directly.
- substitution: command-file body contains LOOP_PREV_OUTPUT and
  LOOP_USER_INPUT placeholders; iter 1 substitutes both to empty, iter 2
  picks up iter 1's cleaned output for PREV while USER stays empty
  (non-interactive). Proves the loaded text flows through
  substituteWorkflowVariables identically to inline loop.prompt.

Adds unlinkSync from 'fs' for the mid-generator deletion.

bun test packages/workflows/src/dag-executor.test.ts now reports 250/250
(was 245, +5).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(workflows): describe loop.command alongside loop.prompt

Adds a top-line note pointing readers at both authoring shapes, surfaces
`command:` in the Configuration Fields YAML block with the "exactly one"
rule inline, and inserts a `### command` subsection that covers
repo/home/bundled resolution, command-name safety, load-once-on-node-start
semantics, fail-fast on missing/empty/unreadable targets, and parity with
inline `prompt` for variable substitution. Worked example mirrors the
spec's `archon-ralph-implement` running scenario.

Closes the last spec acceptance criterion for `loop.command`.

* fix(workflows): trim loop.command at schema level

Whitespace-padded values like " my-cmd " previously passed parse-time
validation (the superRefine trimmed for isValidCommandName) but were
stored untrimmed, so downstream loadCommandPrompt looked up the literal
padded filename and failed at runtime with a confusing "not found"
diagnostic. Normalize at the Zod schema (z.string().trim()) so the parsed
value matches what resolution sees, and the existing parse-time errors
remain the actionable surface.

Caught by CodeRabbit on PR #1789.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(workflows): include command name in loop node_failed event payload

The structured log on loop.command load failure already carries the
failing command, but the workflow event written to the store did not —
event-stream consumers (web UI, run inspectors, downstream automation)
saw only the error string. Mirror the log context onto the event so both
observability paths surface the same diagnostic.

Caught by CodeRabbit on PR #1789.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(workflows): emit node_started for loop nodes to match other node executors

executeLoopNode never emitted a node_started workflow event — neither on
the success path (only the per-iteration loop_iteration_started fires,
then node_completed at the end) nor on the new loop.command load-failure
path. That breaks the project-wide event-pairing rule (CLAUDE.md:
"Always pair _started with _completed or _failed") and was visible to
event-stream consumers as a loop node that just appeared in node_failed
without warning.

Mirror executeBashNode and executeScriptNode: log dag_node_started, write
the node_started workflow event (carrying the optional loop.command name
in data so the start event captures the same context the failure event
does), and emit the in-process WorkflowEmitterEvent. The outer DAG
dispatcher already delegates the start event to each per-node executor,
so no double emission.

Caught by CodeRabbit on PR #1789.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): handle optional loop.prompt in console builder variant

The loop.command rebase makes loop.prompt optional on the wire, but the
console builder only models prompt-based loops. Coalesce to the empty
default so the round-trip stays type-correct.

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

* fix(workflows): loop-node lifecycle finalizer + read-once command snapshot across gate pauses

Two review findings on the loop.command feature:

- Every failed exit of executeLoopNode now returns through a single
  failLoopNode finalizer: one terminal log line, one persisted node_failed
  row (namespaced step_name), exactly one node_failed emitter event —
  covering command-load failure, provider-resolve failure, between-iteration
  and mid-stream cancellation, iteration throw, empty output, gate-send
  failure, and max-iterations exhaustion. Thrown exits stay paired by the
  runLayers dispatcher catch. The node_started pairing comment now states
  the actual contract, and the start/terminal logs use the dotted
  loop_node.* event names.

- The command body is read ONCE per run/node: the interactive gate persists
  the loaded text as ApprovalContext.commandSnapshot and the resumed
  invocation reuses it, so a command file edited or deleted while the run
  sits paused can neither change nor break the loop's prompt. Prompt-source
  resolution also moved below the finalize-on-approve check so a bare
  approve never re-reads the file. pauseWorkflowRun null-resets the new
  field like every other optional approval sub-field (SQLite json_patch),
  and runs paused by older builds fall back to a fresh read.

Tests: two-invocation approval-resume test that rewrites AND deletes the
file while paused, exactly-one node_failed on exhaustion, node_started
command metadata, and a command-backed loop inside a loop_group body with
namespaced lifecycle events.

Co-authored-by: marc0der <marc0der@users.noreply.github.com>

* fix(web): preserve the loop prompt/command union through the builder round-trip

A command-backed loop imported into the console builder previously
collapsed to prompt: '' on export — silent data loss flagged as the
blocking issue in review. The builder now models the engine's exactly-one
rule end to end:

- LoopNodeData carries an optional prompt/command pair with the one-of
  invariant; loopFromDag/loopToDag keep exactly the source the wire node
  carries (command never degrades to an empty prompt).
- The inspector gains a prompt-source toggle (inline prompt vs command
  file); switching drops the other field so stale values cannot leak into
  the export. Node previews show the command name, like command nodes.
- Structural validation enforces one-of + non-empty; content ref-scanning
  skips command-backed loops (file body is runtime-loaded, same posture as
  the engine loader).
- A wire node carrying BOTH sources (not engine-producible) imports with an
  error-severity issue instead of a silent drop.

Round-trip test: a { command } loop fixture must export byte-identical,
with no prompt key introduced.

Co-authored-by: marc0der <marc0der@users.noreply.github.com>

* docs(workflows): present loop.prompt / loop.command as exactly-one alternatives

- quick-reference: the Loop Node Options table now marks prompt and command
  as one-of alternatives (exactly one required) instead of prompt-required.
- loop-nodes guide: the copyable configuration YAML no longer shows both
  keys together (the schema rejects that); the command alternative is a
  commented-out line with the one-of rule spelled out. The read-once
  paragraph now covers the pause snapshot (read once per RUN, resume reuses
  the persisted body), and the example comment describes resolution
  precedence instead of claiming one concrete path.
- loader.test: the whitespace-trim comment now says parsing NORMALIZES the
  command name rather than rejecting it.

Co-authored-by: marc0der <marc0der@users.noreply.github.com>

* chore(web): regenerate api.generated.d.ts from the current OpenAPI spec

Regenerated against the running dev server (bun --filter @archon/web
generate:types + format), confirming the hand-merged loop prompt?/command?
shape and picking up drift the checked-in file had accumulated:
signal_completes on loop/loop_group, workflow-level container:, per-node
pi:, and include/with.

Co-authored-by: marc0der <marc0der@users.noreply.github.com>

* test(workflows): drop specification-style narration from loop.command comments

Review asked for hidden-mechanics comments only — the block referenced a
nonexistent specs/loop-command.md and quoted acceptance criteria instead of
stating what the tests pin down.

Co-authored-by: marc0der <marc0der@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Archon Maintainer Bot <maintainer-implementer@archon.local>
Co-authored-by: marc0der <marc0der@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 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

Run ID: a5006fc2-d7ac-4eca-a7c1-391b2be210d5

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev

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.

Archon Maintainer Bot added 2 commits July 20, 2026 22:19
# Conflicts:
#	packages/docs-web/src/data/marketplace.ts
…serts clean teardown

The smoke predates #2160: a container run that produces changes now ends
paused at the approval-gated write-back with its container suspended and
kept (resumable by contract). The job's leak assertion counted that as a
leaked container. Reject the gate after the run — discarding the overlay
and destroying the container — which is the Phase B semantic the
assertions were written for.
@Wirasm
Wirasm merged commit e67940a into main Jul 20, 2026
14 of 15 checks passed
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.