feat(paths,workflows): unify ~/.archon/{workflows,commands,scripts} + drop globalSearchPath (closes #1136) (#1315)

* feat(paths,workflows): unify ~/.archon/{workflows,commands,scripts} + drop globalSearchPath

Collapses the awkward `~/.archon/.archon/workflows/` convention to a direct
`~/.archon/workflows/` child (matching `workspaces/`, `archon.db`, etc.), adds
home-scoped commands and scripts with the same loading story, and kills the
opt-in `globalSearchPath` parameter so every call site gets home-scope for free.

Closes #1136 (supersedes @jonasvanderhaegen's tactical fix — the bug was the
primitive itself: an easy-to-forget parameter that five of six call sites on
dev dropped).

Primitive changes:

- Home paths are direct children of `~/.archon/`. New helpers in `@archon/paths`:
  `getHomeWorkflowsPath()`, `getHomeCommandsPath()`, `getHomeScriptsPath()`,
  and `getLegacyHomeWorkflowsPath()` (detection-only for migration).
- `discoverWorkflowsWithConfig(cwd, loadConfig)` reads home-scope internally.
  The old `{ globalSearchPath }` option is removed. Chat command handler, Web
  UI workflow picker, orchestrator resolve path — all inherit home-scope for
  free without maintainer patches at every new site.
- `discoverScriptsForCwd(cwd)` merges home + repo scripts (repo wins on name
  collision). dag-executor and validator use it; the hardcoded
  `resolve(cwd, '.archon', 'scripts')` single-scope path is gone.
- Command resolution is now walked-by-basename in each scope. `loadCommand`
  and `resolveCommand` walk 1 subfolder deep and match by `.md` basename, so
  `.archon/commands/triage/review.md` resolves as `review` — closes the
  latent bug where subfolder commands were listed but unresolvable.
- All three (`workflows/`, `commands/`, `scripts/`) enforce a 1-level
  subfolder cap (matches the existing `defaults/` convention). Deeper
  nesting is silently skipped.
- `WorkflowSource` gains `'global'` alongside `'bundled'` and `'project'`.
  Web UI node palette shows a dedicated "Global (~/.archon/commands/)"
  section; badges updated.

Migration (clean cut — no fallback read):

- First use after upgrade: if `~/.archon/.archon/workflows/` exists, Archon
  logs a one-time WARN per process with the exact `mv` command:
  `mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon`
  The legacy path is NOT read — users migrate manually. Rollback caveat
  noted in CHANGELOG.

Tests:

- `@archon/paths/archon-paths.test.ts`: new helper tests (default HOME,
  ARCHON_HOME override, Docker), plus regression guards for the double-`.archon/`
  path.
- `@archon/workflows/loader.test.ts`: home-scoped workflows, precedence,
  subfolder 1-depth cap, legacy-path deprecation warning fires exactly once
  per process.
- `@archon/workflows/validator.test.ts`: home-scoped commands + subfolder
  resolution.
- `@archon/workflows/script-discovery.test.ts`: depth cap + merge semantics
  (repo wins, home-missing tolerance).
- Existing CLI + orchestrator tests updated to drop `globalSearchPath`
  assertions.

E2E smoke (verified locally, before cleanup):

- `.archon/workflows/e2e-home-scope.yaml` + scratch repo at /tmp
- Home-scoped workflow discovered from an unrelated git repo
- Home-scoped script (`~/.archon/scripts/*.ts`) executes inside a script node
- 1-level subfolder workflow (`~/.archon/workflows/triage/*.yaml`) listed
- Legacy path warning fires with actionable `mv` command; workflows there
  are NOT loaded

Docs: `CLAUDE.md`, `docs-web/guides/global-workflows.md` (full rewrite for
three-type scope + subfolder convention + migration), `docs-web/reference/
configuration.md` (directory tree), `docs-web/reference/cli.md`,
`docs-web/guides/authoring-workflows.md`.

Co-authored-by: Jonas Vanderhaegen <7755555+jonasvanderhaegen@users.noreply.github.com>

* test(script-discovery): normalize path separators in mocks for Windows

The 4 new tests in `scanScriptDir depth cap` and `discoverScriptsForCwd —
merge repo + home with repo winning` compared incoming mock paths with
hardcoded forward-slash strings (`if (path === '/scripts/triage')`). On
Windows, `path.join('/scripts', 'triage')` produces `\scripts\triage`, so
those branches never matched, readdir returned `[]`, and the tests failed.

Added a `norm()` helper at module scope and wrapped the incoming `path`
argument in every `mockImplementation` before comparing. Stored paths go
through `normalizeSep()` in production code, so the existing equality
assertions on `script.path` remain OS-independent.

Fixes Windows CI job `test (windows-latest)` on PR #1315.

* address review feedback: home-scope error handling, depth cap, and tests

Critical fixes:
- api.ts: add `maxDepth: 1` to all 3 findMarkdownFilesRecursive calls in
  GET /api/commands (bundled/home/project). Without this the UI palette
  surfaced commands from deep subfolders that the executor (capped at 1)
  could not resolve — silent "command not found" at runtime.
- validator.ts: wrap home-scope findMarkdownFilesRecursive and
  resolveCommandInDir calls in try/catch so EACCES/EPERM on
  ~/.archon/commands/ doesn't crash the validator with a raw filesystem
  error. ENOENT still returns [] via the underlying helper.

Error handling fixes:
- workflow-discovery.ts: maybeWarnLegacyHomePath now sets the
  "warned-once" flag eagerly before `await access()`, so concurrent
  discovery calls (server startup with parallel codebase resolution)
  can't double-warn. Non-ENOENT probe errors (EACCES/EPERM) now log at
  WARN instead of DEBUG so permission issues on the legacy dir are
  visible in default operation.
- dag-executor.ts: wrap discoverScriptsForCwd in its own try/catch so
  an EACCES on ~/.archon/scripts/ routes through safeSendMessage /
  logNodeError with a dedicated "failed to discover scripts" message
  instead of being mis-attributed by the outer catch's
  "permission denied (check cwd permissions)" branch.

Tests:
- load-command-prompt.test.ts (new): 6 tests covering the executor's
  command resolution hot path — home-scope resolves when repo misses,
  repo shadows home, 1-level subfolder resolvable by basename, 2-level
  rejected, not-found, empty-file. Runs in its own bun test batch.
- archon-paths.test.ts: add getHomeScriptsPath describe block to match
  the existing getHomeCommandsPath / getHomeWorkflowsPath coverage.

Comment clarity:
- workflow-discovery.ts: MAX_DISCOVERY_DEPTH comment now leads with the
  actual value (1) before describing what 0 would mean.
- script-discovery.ts: copy the "routing ambiguity" rationale from
  MAX_DISCOVERY_DEPTH to MAX_SCRIPT_DISCOVERY_DEPTH.

Cleanup:
- Remove .archon/workflows/e2e-home-scope.yaml — one-off smoke test that
  would ship permanently in every project's workflow list. Equivalent
  coverage exists in loader.test.ts.

Addresses all blocking and important feedback from the multi-agent
review on PR #1315.

---------

Co-authored-by: Jonas Vanderhaegen <7755555+jonasvanderhaegen@users.noreply.github.com>
This commit is contained in:
Rasmus Widing 2026-04-20 21:45:32 +03:00 committed by GitHub
parent cc78071ff6
commit 7be4d0a35e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1203 additions and 273 deletions

View file

@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Home-scoped commands at `~/.archon/commands/`** — personal command helpers now reusable across every repo. Resolution precedence: `<repoRoot>/.archon/commands/` > `~/.archon/commands/` > bundled defaults. Surfaced in the Web UI workflow-builder node palette under a dedicated "Global (~/.archon/commands/)" section.
- **Home-scoped scripts at `~/.archon/scripts/`** — personal Bun/uv scripts now reusable across every repo. Script nodes (`script: my-helper`) resolve via `<repoRoot>/.archon/scripts/` first, then `~/.archon/scripts/`. Repo-scoped scripts with the same name override home-scoped ones silently; within a single scope, duplicate basenames across extensions still throw (unchanged from prior behavior).
- **1-level subfolder support for workflows, commands, and scripts.** Files can live one folder deep under their respective `.archon/` root (e.g. `.archon/workflows/triage/foo.yaml`) and resolve by name or filename regardless of subfolder. Matches the existing `defaults/` convention. Deeper nesting is ignored silently — see docs for the full convention.
- **`'global'` variant on `WorkflowSource`** — workflows at `~/.archon/workflows/` and commands at `~/.archon/commands/` now render with a distinct source label (no longer coerced to `'project'`). Web UI badges updated.
- **`getHomeWorkflowsPath()`, `getHomeCommandsPath()`, `getHomeScriptsPath()`, `getLegacyHomeWorkflowsPath()`** helpers in `@archon/paths`, exported for both internal discovery and external callers that want to target the home scope directly.
- **`discoverScriptsForCwd(cwd)`** in `@archon/workflows/script-discovery` — merges home-scoped + repo-scoped scripts with repo winning on name collisions. Used by the DAG executor and validator; callers no longer need to know about the two-scope shape.
- **Three-path env model with operator-visible log lines.** The CLI and server now load env vars from `~/.archon/.env` (user scope) and `<cwd>/.archon/.env` (repo scope, overrides user) at boot, both with `override: true`. A new `[archon] loaded N keys from <path>` line is emitted per source (only when N > 0). `[archon] stripped N keys from <cwd> (...)` now also prints when stripCwdEnv removes target-repo env keys, replacing the misleading `[dotenv@17.3.1] injecting env (0) from .env` preamble that always reported 0. The `quiet: true` flag suppresses dotenv's own output. (#1302)
- **`archon setup --scope home|project` and `--force` flags.** Default is `--scope home` (writes `~/.archon/.env`). `--scope project` targets `<cwd>/.archon/.env` instead. `--force` overwrites the target wholesale rather than merging; a timestamped backup is still written. (#1303)
- **Merge-only setup writes with timestamped backups.** `archon setup` now reads the existing target file, preserves non-empty values, carries user-added custom keys forward, and writes a `<target>.archon-backup-<ISO-ts>` before every rewrite. Fixes silent PostgreSQL→SQLite downgrade and silent token loss on re-run. (#1303)
@ -28,6 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **Home-scoped workflow location moved to `~/.archon/workflows/`** (was `~/.archon/.archon/workflows/` — a double-nested path left over from reusing the repo-relative discovery helper for home scope). The new path sits next to `~/.archon/workspaces/`, `archon.db`, and `config.yaml`, matching the rest of the `~/.archon/` convention. If Archon detects workflows at the old location, it emits a one-time WARN per process with the exact migration command: `mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon`. The old path is no longer read — users must migrate manually (clean cut, no deprecation window). Rollback caveat: if you downgrade after migrating, move the directory back to the old location.
- **Workflow discovery no longer takes a `globalSearchPath` option.** `discoverWorkflows()` and `discoverWorkflowsWithConfig()` now consult `~/.archon/workflows/` automatically — every caller gets home-scoped discovery for free. Previously-missed call sites in the chat command handler (`command-handler.ts`), the Web UI workflow picker (`api.ts GET /api/workflows`), and the orchestrator's single-codebase resolve path now see home-scoped workflows without needing a maintainer patch at every new call site. Closes #1136; supersedes that PR (credits @jonasvanderhaegen for surfacing the bug class).
- **Dashboard nav tab** now shows a numeric count of running workflows instead of a binary pulse dot. Reads from the existing `/api/dashboard/runs` `counts.running` field; same 10s polling interval.
- **Workflow run destructive actions** (Abandon, Cancel, Delete, Reject) now use a proper confirmation dialog matching the codebase-delete UX, replacing the browser's native `window.confirm()` popups. Each dialog includes context-appropriate copy describing what the action does to the run record.
@ -41,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed
- **`globalSearchPath` option** from `discoverWorkflows()` and `discoverWorkflowsWithConfig()`. Callers that previously passed `{ globalSearchPath: getArchonHome() }` should drop the argument; home-scoped discovery is now automatic.
- **`@anthropic-ai/claude-agent-sdk/embed` import** — the Bun `with { type: 'file' }` asset-embedding path and its `$bunfs` extraction logic. The embed was a bundler-dependent optimization that failed silently when Bun couldn't produce a usable virtual FS path (#1210, #1087); it is replaced by explicit binary-path resolution.
### Fixed

View file

@ -719,9 +719,15 @@ async function createSession(conversationId: string, codebaseId: string) {
- Opt-out: Set `defaults.loadDefaultCommands: false` or `defaults.loadDefaultWorkflows: false` in `.archon/config.yaml`
- **After adding, removing, or editing a default file, run `bun run generate:bundled`** to refresh the embedded bundle. `bun run validate` (and CI) run `check:bundled` and will fail loudly if the generated file is stale.
**Global workflows** (user-level, applies to every project):
- Path: `~/.archon/.archon/workflows/` (or `$ARCHON_HOME/.archon/workflows/`)
- Load priority: bundled < global < repo-specific (repo overrides global by filename)
**Home-scoped ("global") workflows, commands, and scripts** (user-level, applies to every project):
- Workflows: `~/.archon/workflows/` (or `$ARCHON_HOME/workflows/`)
- Commands: `~/.archon/commands/` (or `$ARCHON_HOME/commands/`)
- Scripts: `~/.archon/scripts/` (or `$ARCHON_HOME/scripts/`)
- Source label: `source: 'global'` on workflows and commands (scripts don't have a source label)
- Load priority: bundled < global < project (repo overrides global by filename or script name)
- Subfolders: supported 1 level deep (e.g. `~/.archon/workflows/triage/foo.yaml`). Deeper nesting is ignored silently.
- Discovery is automatic — `discoverWorkflowsWithConfig(cwd, loadConfig)` and `discoverScriptsForCwd(cwd)` both read home-scoped paths unconditionally; no caller option needed
- **Migration from pre-0.x `~/.archon/.archon/workflows/`**: if Archon detects files at the old location it emits a one-time WARN with the exact `mv` command and does NOT load from there. Move with: `mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon`
- See the docs site at `packages/docs-web/` for details
### Error Handling

View file

@ -310,7 +310,7 @@ describe('workflowListCommand', () => {
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Found 1 workflow(s)'));
});
it('passes globalSearchPath to discoverWorkflowsWithConfig', async () => {
it('calls discoverWorkflowsWithConfig with (cwd, loadConfig) — home scope is internal', async () => {
const { discoverWorkflowsWithConfig } = await import('@archon/workflows/workflow-discovery');
(discoverWorkflowsWithConfig as ReturnType<typeof mock>).mockResolvedValueOnce({
workflows: [],
@ -319,11 +319,9 @@ describe('workflowListCommand', () => {
await workflowListCommand('/test/path');
expect(discoverWorkflowsWithConfig).toHaveBeenCalledWith(
'/test/path',
expect.any(Function),
expect.objectContaining({ globalSearchPath: '/home/test/.archon' })
);
// After the globalSearchPath refactor, discovery reads ~/.archon/workflows/
// on every call with no option — every caller inherits home-scope for free.
expect(discoverWorkflowsWithConfig).toHaveBeenCalledWith('/test/path', expect.any(Function));
});
it('should throw error when discoverWorkflows fails', async () => {

View file

@ -10,7 +10,7 @@ import {
} from '@archon/core';
import { WORKFLOW_EVENT_TYPES, type WorkflowEventType } from '@archon/workflows/store';
import { configureIsolation, getIsolationProvider } from '@archon/isolation';
import { createLogger, getArchonHome } from '@archon/paths';
import { createLogger } from '@archon/paths';
import { createWorkflowDeps } from '@archon/core/workflows/store-adapter';
import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery';
import { resolveWorkflowName } from '@archon/workflows/router';
@ -119,9 +119,9 @@ function renderWorkflowEvent(event: WorkflowEmitterEvent, verbose: boolean): voi
*/
async function loadWorkflows(cwd: string): Promise<WorkflowLoadResult> {
try {
return await discoverWorkflowsWithConfig(cwd, loadConfig, {
globalSearchPath: getArchonHome(),
});
// Home-scoped workflows at ~/.archon/workflows/ are discovered automatically —
// no option needed since the discovery helper reads them unconditionally.
return await discoverWorkflowsWithConfig(cwd, loadConfig);
} catch (error) {
const err = error as Error;
throw new Error(

View file

@ -25,7 +25,7 @@ import { formatToolCall } from '@archon/workflows/utils/tool-formatter';
import { classifyAndFormatError } from '../utils/error-formatter';
import { toError } from '../utils/error';
import { getAgentProvider, getProviderCapabilities } from '@archon/providers';
import { getArchonHome, getArchonWorkspacesPath } from '@archon/paths';
import { getArchonWorkspacesPath } from '@archon/paths';
import { syncArchonToWorktree } from '../utils/worktree-sync';
import { syncWorkspace, toRepoPath } from '@archon/git';
import type { WorkspaceSyncResult } from '@archon/git';
@ -388,9 +388,9 @@ async function discoverAllWorkflows(conversation: Conversation): Promise<Discove
let config: MergedConfig | undefined;
try {
const result = await discoverWorkflowsWithConfig(getArchonWorkspacesPath(), loadConfig, {
globalSearchPath: getArchonHome(),
});
// Home-scoped workflows at ~/.archon/workflows/ are discovered automatically
// by discoverWorkflowsWithConfig — no option needed.
const result = await discoverWorkflowsWithConfig(getArchonWorkspacesPath(), loadConfig);
workflows = [...result.workflows];
allErrors.push(...result.errors);
} catch (error) {

View file

@ -1153,10 +1153,11 @@ describe('orchestrator-agent handleMessage', () => {
await handleMessage(platform, 'chat-456', 'help');
// Discovery is called positionally with (cwd, loadConfig) — no options arg.
// Home-scoped workflows (~/.archon/workflows/) are discovered internally.
expect(mockDiscoverWorkflows).toHaveBeenCalledWith(
'/home/test/.archon/workspaces',
expect.any(Function),
{ globalSearchPath: '/home/test/.archon' }
expect.any(Function)
);
});

View file

@ -7,11 +7,18 @@ import { join, basename } from 'path';
/**
* Recursively find all .md files in a directory and its subdirectories.
* Skips hidden directories and node_modules.
*
* `maxDepth` caps how many folders deep the walk descends. Default is
* `Infinity` (no cap) so callers that copy arbitrary subtrees (e.g.
* `packages/core/src/handlers/clone.ts`) preserve existing behavior.
*/
export async function findMarkdownFilesRecursive(
rootPath: string,
relativePath = ''
relativePath = '',
options?: { maxDepth?: number }
): Promise<{ commandName: string; relativePath: string }[]> {
const maxDepth = options?.maxDepth ?? Infinity;
const currentDepth = relativePath ? relativePath.split(/[/\\]/).filter(Boolean).length : 0;
const results: { commandName: string; relativePath: string }[] = [];
const fullPath = join(rootPath, relativePath);
@ -23,7 +30,12 @@ export async function findMarkdownFilesRecursive(
}
if (entry.isDirectory()) {
const subResults = await findMarkdownFilesRecursive(rootPath, join(relativePath, entry.name));
if (currentDepth >= maxDepth) continue;
const subResults = await findMarkdownFilesRecursive(
rootPath,
join(relativePath, entry.name),
options
);
results.push(...subResults);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push({

View file

@ -59,7 +59,7 @@ Workflows live in `.archon/workflows/` relative to the working directory:
Archon discovers workflows recursively - subdirectories are fine. If a workflow file fails to load (syntax error, validation failure), it's skipped and the error is reported via `/workflow list`.
> **Global workflows:** For workflows that apply to every project, place them in `~/.archon/.archon/workflows/`. Global workflows are overridden by same-named repo workflows. See [Global Workflows](/guides/global-workflows/).
> **Global workflows:** For workflows that apply to every project, place them in `~/.archon/workflows/`. Global workflows are overridden by same-named repo workflows. See [Global Workflows](/guides/global-workflows/).
> **CLI vs Server:** The CLI reads workflow files from wherever you run it (sees uncommitted changes). The server reads from the workspace clone at `~/.archon/workspaces/owner/repo/`, which only syncs from the remote before worktree creation. If you edit a workflow locally but don't push, the server won't see it.

View file

@ -1,6 +1,6 @@
---
title: Global Workflows
description: Define user-level workflows that apply to every project on your machine.
title: Global Workflows, Commands, and Scripts
description: Define user-level workflows, commands, and scripts that apply to every project on your machine.
category: guides
area: workflows
audience: [user]
@ -9,45 +9,62 @@ sidebar:
order: 8
---
Workflows placed in `~/.archon/.archon/workflows/` are loaded globally -- they appear in
every project's `workflow list` and can be invoked from any repository.
Workflows placed in `~/.archon/workflows/`, commands in `~/.archon/commands/`, and scripts in `~/.archon/scripts/` are loaded globally -- they appear in every project and can be invoked from any repository. Workflows and commands carry the `source: 'global'` label in the Web UI node palette; scripts resolve under the same repo-wins-over-home precedence.
## Path
## Paths
```
~/.archon/.archon/workflows/
~/.archon/workflows/
~/.archon/commands/
~/.archon/scripts/
```
Or, if you have set `ARCHON_HOME`:
```
$ARCHON_HOME/.archon/workflows/
$ARCHON_HOME/workflows/
$ARCHON_HOME/commands/
$ARCHON_HOME/scripts/
```
Create the directory if it does not exist:
Create the directories if they do not exist:
```bash
mkdir -p ~/.archon/.archon/workflows
mkdir -p ~/.archon/workflows ~/.archon/commands ~/.archon/scripts
```
> **Note on location.** These are direct children of `~/.archon/` -- same level as `workspaces/`, `archon.db`, and `config.yaml`. Earlier Archon versions stored global workflows at `~/.archon/.archon/workflows/`; see [Migrating from the old path](#migrating-from-the-old-path) below.
## Subfolders (1 level deep)
Each directory supports one level of subfolders for grouping, matching the existing `defaults/` convention. Deeper nesting is ignored silently.
```
~/.archon/workflows/
├── my-review.yaml # ✅ top-level file
├── triage/ # ✅ 1-level subfolder (grouping)
│ └── weekly-cleanup.yaml # ✅ resolvable as `weekly-cleanup`
└── team/personal/too-deep.yaml # ❌ ignored — 2 levels down
```
Resolution is by **filename without extension** (for commands) or **exact filename** (for workflows), regardless of which subfolder the file lives in. Duplicate basenames within the same scope are a user error -- keep each name unique within `~/.archon/commands/` (or `<repoRoot>/.archon/commands/`), across whatever subfolders you use.
## Load Priority
1. **Bundled defaults** (lowest priority)
2. **Global workflows** -- `~/.archon/.archon/workflows/` (override bundled by filename)
3. **Repo-specific workflows** -- `.archon/workflows/` (override global by filename)
1. **Bundled defaults** (lowest priority) -- the `archon-*` workflows/commands embedded in the Archon binary.
2. **Global / home-scoped** -- `~/.archon/workflows/`, `~/.archon/commands/`, `~/.archon/scripts/` (override bundled by filename).
3. **Repo-specific** -- `<repoRoot>/.archon/workflows/`, `<repoRoot>/.archon/commands/`, `<repoRoot>/.archon/scripts/` (override global by filename).
If a global workflow has the same filename as a bundled default, the global version wins. If a repo-specific workflow has the same filename as a global one, the repo-specific version wins.
Same-named files at a higher scope win. A repo can override a personal helper by dropping a file with the same name in its own `.archon/workflows/`, `.archon/commands/`, or `.archon/scripts/`.
## Practical Examples
Global workflows are useful for personal standards that you want enforced everywhere, regardless of the project.
### Personal Code Review
A workflow that runs your preferred review checklist on every project:
```yaml
# ~/.archon/.archon/workflows/my-review.yaml
# ~/.archon/workflows/my-review.yaml
name: my-review
description: Personal code review with my standards
model: sonnet
@ -65,7 +82,7 @@ nodes:
A workflow that runs project-agnostic checks:
```yaml
# ~/.archon/.archon/workflows/lint-check.yaml
# ~/.archon/workflows/lint-check.yaml
name: lint-check
description: Check for common code quality issues across any project
@ -84,7 +101,7 @@ nodes:
A simple workflow for understanding unfamiliar codebases:
```yaml
# ~/.archon/.archon/workflows/explain.yaml
# ~/.archon/workflows/explain.yaml
name: explain
description: Quick explanation of a codebase or module
model: haiku
@ -98,38 +115,64 @@ nodes:
Topic: $ARGUMENTS
```
### Personal Command Helpers
Commands placed in `~/.archon/commands/` are available to every workflow on the machine. Useful for prompts you reuse across projects.
```markdown
<!-- ~/.archon/commands/review-checklist.md -->
Review the uncommitted changes in the current worktree.
Check for:
- Error handling gaps
- Missing tests
- Surprising API shapes
- Unnecessary cleverness
Be terse. Report findings grouped by file.
```
A workflow in any repo can then reference it:
```yaml
nodes:
- id: review
command: review-checklist
```
## Syncing with Dotfiles
If you manage your configuration with a dotfiles repository, you can include your global workflows:
If you manage your configuration with a dotfiles repository, you can include your global content:
```bash
# In your dotfiles repo
dotfiles/
└── archon/
└── .archon/
└── workflows/
├── my-review.yaml
└── explain.yaml
├── workflows/
│ ├── my-review.yaml
│ └── explain.yaml
└── commands/
└── review-checklist.md
```
Then symlink during dotfiles setup:
```bash
ln -sf ~/dotfiles/archon/.archon/workflows ~/.archon/.archon/workflows
ln -sf ~/dotfiles/archon/workflows ~/.archon/workflows
ln -sf ~/dotfiles/archon/commands ~/.archon/commands
```
Or copy them as part of your dotfiles install script:
```bash
mkdir -p ~/.archon/.archon/workflows
cp ~/dotfiles/archon/.archon/workflows/*.yaml ~/.archon/.archon/workflows/
mkdir -p ~/.archon/workflows ~/.archon/commands
cp ~/dotfiles/archon/workflows/*.yaml ~/.archon/workflows/
cp ~/dotfiles/archon/commands/*.md ~/.archon/commands/
```
This way your personal workflows travel with you across machines.
This way your personal workflows and commands travel with you across machines.
## CLI Support
## CLI and Web Support
Both the CLI and the server discover global workflows automatically:
Both the CLI, the server, and the Web UI discover home-scoped content automatically -- no flag, no config option.
```bash
# Lists bundled + global + repo-specific workflows
@ -139,14 +182,26 @@ archon workflow list
archon workflow run my-review
```
In the Web UI workflow builder, commands from `~/.archon/commands/` appear under a **Global (~/.archon/commands/)** section in the node palette, distinct from project and bundled entries.
## Migrating from the old path
Pre-refactor versions of Archon stored global workflows at `~/.archon/.archon/workflows/` (with an extra nested `.archon/`). That location is no longer read. If you have workflows there, Archon emits a one-time deprecation warning on first use telling you the exact migration command:
```bash
mv ~/.archon/.archon/workflows ~/.archon/workflows && rmdir ~/.archon/.archon
```
Run it once; the warning stops firing on subsequent invocations. There was no prior home-scoped commands location, so `~/.archon/commands/` is new capability -- nothing to migrate.
## Troubleshooting
### Workflow Not Appearing in List
1. **Check the path** -- The directory must be exactly `~/.archon/.archon/workflows/` (note the double `.archon`). The first `.archon` is the Archon home directory, the second is the standard config directory structure within it.
1. **Check the path** -- The directory must be exactly `~/.archon/workflows/` (a direct child of `~/.archon/`, not the old double-nested `~/.archon/.archon/workflows/`).
```bash
ls ~/.archon/.archon/workflows/
ls ~/.archon/workflows/
```
2. **Check file extension** -- Workflow files must end in `.yaml` or `.yml`.
@ -159,4 +214,4 @@ archon workflow run my-review
4. **Check for name conflicts** -- If a repo-specific workflow has the same filename, it overrides the global one. The global version will not appear when you are in that repo.
5. **Check ARCHON_HOME** -- If you have set `ARCHON_HOME` to a custom path, global workflows must be at `$ARCHON_HOME/.archon/workflows/`, not `~/.archon/.archon/workflows/`.
5. **Check ARCHON_HOME** -- If you have set `ARCHON_HOME` to a custom path, global workflows must be at `$ARCHON_HOME/workflows/`, not `~/.archon/workflows/`.

View file

@ -95,7 +95,7 @@ archon workflow list --cwd /path/to/repo
archon workflow list --cwd /path/to/repo --json
```
Discovers workflows from `.archon/workflows/` (recursive), `~/.archon/.archon/workflows/` (global), and bundled defaults. See [Global Workflows](/guides/global-workflows/).
Discovers workflows from `.archon/workflows/` (recursive), `~/.archon/workflows/` (global, home-scoped), and bundled defaults. See [Global Workflows](/guides/global-workflows/).
**Flags:**

View file

@ -22,10 +22,15 @@ Archon supports a layered configuration system with sensible defaults, optional
│ ├── worktrees/ # Git worktrees for this project
│ ├── artifacts/ # Workflow artifacts
│ └── logs/ # Workflow execution logs
├── workflows/ # Home-scoped workflows (source: 'global')
├── commands/ # Home-scoped commands (source: 'global')
├── scripts/ # Home-scoped scripts (runtime: bun | uv)
├── archon.db # SQLite database (when DATABASE_URL not set)
└── config.yaml # Global configuration (optional)
```
Home-scoped `workflows/`, `commands/`, and `scripts/` apply to every project on the machine. Repo-local files at `<repoRoot>/.archon/{workflows,commands,scripts}/` override them by filename (or script name). Each directory supports one level of subfolders for grouping; deeper nesting is ignored. See [Global Workflows](/guides/global-workflows/) for details and dotfiles-sync examples.
### Repository-Level (.archon/)
```

View file

@ -12,6 +12,10 @@ import {
getArchonWorkspacesPath,
getArchonWorktreesPath,
getArchonConfigPath,
getHomeWorkflowsPath,
getHomeCommandsPath,
getHomeScriptsPath,
getLegacyHomeWorkflowsPath,
getCommandFolderSearchPaths,
getWorkflowFolderSearchPaths,
expandTilde,
@ -223,6 +227,87 @@ describe('archon-paths', () => {
});
});
describe('getHomeWorkflowsPath', () => {
test('returns ~/.archon/workflows by default (direct child of ~/.archon/)', () => {
delete process.env.ARCHON_HOME;
delete process.env.ARCHON_DOCKER;
expect(getHomeWorkflowsPath()).toBe(join(homedir(), '.archon', 'workflows'));
});
test('returns /.archon/workflows in Docker', () => {
process.env.ARCHON_DOCKER = 'true';
expect(getHomeWorkflowsPath()).toBe(join('/', '.archon', 'workflows'));
});
test('uses ARCHON_HOME when set', () => {
delete process.env.ARCHON_DOCKER;
process.env.ARCHON_HOME = '/custom/archon';
expect(getHomeWorkflowsPath()).toBe(join('/custom/archon', 'workflows'));
});
test('no double `.archon/` nesting — must sit next to workspaces/ and worktrees/', () => {
// Regression guard: the old location was ~/.archon/.archon/workflows/.
// New location must NOT reintroduce the double-nested path.
delete process.env.ARCHON_HOME;
delete process.env.ARCHON_DOCKER;
expect(getHomeWorkflowsPath()).not.toContain(join('.archon', '.archon'));
});
});
describe('getHomeCommandsPath', () => {
test('returns ~/.archon/commands by default', () => {
delete process.env.ARCHON_HOME;
delete process.env.ARCHON_DOCKER;
expect(getHomeCommandsPath()).toBe(join(homedir(), '.archon', 'commands'));
});
test('returns /.archon/commands in Docker', () => {
process.env.ARCHON_DOCKER = 'true';
expect(getHomeCommandsPath()).toBe(join('/', '.archon', 'commands'));
});
test('uses ARCHON_HOME when set', () => {
delete process.env.ARCHON_DOCKER;
process.env.ARCHON_HOME = '/custom/archon';
expect(getHomeCommandsPath()).toBe(join('/custom/archon', 'commands'));
});
});
describe('getHomeScriptsPath', () => {
test('returns ~/.archon/scripts by default', () => {
delete process.env.ARCHON_HOME;
delete process.env.ARCHON_DOCKER;
expect(getHomeScriptsPath()).toBe(join(homedir(), '.archon', 'scripts'));
});
test('returns /.archon/scripts in Docker', () => {
process.env.ARCHON_DOCKER = 'true';
expect(getHomeScriptsPath()).toBe(join('/', '.archon', 'scripts'));
});
test('uses ARCHON_HOME when set', () => {
delete process.env.ARCHON_DOCKER;
process.env.ARCHON_HOME = '/custom/archon';
expect(getHomeScriptsPath()).toBe(join('/custom/archon', 'scripts'));
});
});
describe('getLegacyHomeWorkflowsPath', () => {
// This helper only exists so discovery can DETECT files at the old location
// and emit a deprecation warning. It is not a fallback read path.
test('returns ~/.archon/.archon/workflows (the retired location)', () => {
delete process.env.ARCHON_HOME;
delete process.env.ARCHON_DOCKER;
expect(getLegacyHomeWorkflowsPath()).toBe(join(homedir(), '.archon', '.archon', 'workflows'));
});
test('honors ARCHON_HOME so migration detection works in custom setups', () => {
delete process.env.ARCHON_DOCKER;
process.env.ARCHON_HOME = '/custom/archon';
expect(getLegacyHomeWorkflowsPath()).toBe(join('/custom/archon', '.archon', 'workflows'));
});
});
describe('getAppArchonBasePath', () => {
test('returns repo root .archon path in local development', () => {
delete process.env.ARCHON_DOCKER;

View file

@ -96,6 +96,49 @@ export function getArchonConfigPath(): string {
return join(getArchonHome(), 'config.yaml');
}
/**
* Get the home-scoped workflows directory (`~/.archon/workflows/`).
* Workflows placed here are discovered from every repo and apply globally
* overridden per-filename by the same name under `<repoRoot>/.archon/workflows/`.
*
* Direct child of `~/.archon/`, matching the convention for `workspaces/`,
* `archon.db`, `config.yaml`, etc. Replaces the prior `~/.archon/.archon/workflows/`
* location which was an artifact of reusing the repo-relative discovery helper.
*/
export function getHomeWorkflowsPath(): string {
return join(getArchonHome(), 'workflows');
}
/**
* Get the home-scoped commands directory (`~/.archon/commands/`).
* Commands placed here are resolvable from every repo and apply globally
* overridden per-filename by the same name under `<repoRoot>/.archon/commands/`.
* Command resolution precedence: repo > home > bundled.
*/
export function getHomeCommandsPath(): string {
return join(getArchonHome(), 'commands');
}
/**
* Get the home-scoped scripts directory (`~/.archon/scripts/`).
* Scripts placed here are available to every workflow's `script:` nodes
* overridden per-name by the same name under `<repoRoot>/.archon/scripts/`.
* Script resolution precedence: repo > home.
*/
export function getHomeScriptsPath(): string {
return join(getArchonHome(), 'scripts');
}
/**
* Legacy home-scoped workflows directory (`~/.archon/.archon/workflows/`).
* Retained only so discovery can DETECT files there and emit a one-time
* deprecation warning pointing at the migration command. Archon no longer
* reads workflows from this path it's a signal, not a source.
*/
export function getLegacyHomeWorkflowsPath(): string {
return join(getArchonHome(), '.archon', 'workflows');
}
/**
* Get the home-scope archon env file path (~/.archon/.env).
* This is the archon-owned env location loaded by every entry point.
@ -153,11 +196,21 @@ export function getWorkflowFolderSearchPaths(): string[] {
/**
* Recursively find all .md files in a directory and its subdirectories.
* Skips hidden directories and node_modules.
*
* `maxDepth` caps how many folders deep the walk descends. Depth is counted as
* the number of folder boundaries between `rootPath` and the file so at
* `maxDepth: 1`, files at `rootPath/file.md` (depth 0) and `rootPath/group/file.md`
* (depth 1) are included, but `rootPath/group/sub/file.md` (depth 2) is not.
* Default is `Infinity` (no cap) for backwards compatibility with callers that
* want to copy arbitrary subtrees (e.g. clone handlers).
*/
export async function findMarkdownFilesRecursive(
rootPath: string,
relativePath = ''
relativePath = '',
options?: { maxDepth?: number }
): Promise<{ commandName: string; relativePath: string }[]> {
const maxDepth = options?.maxDepth ?? Infinity;
const currentDepth = relativePath ? relativePath.split(/[/\\]/).filter(Boolean).length : 0;
const results: { commandName: string; relativePath: string }[] = [];
const fullPath = join(rootPath, relativePath);
@ -176,7 +229,15 @@ export async function findMarkdownFilesRecursive(
}
if (entry.isDirectory()) {
const subResults = await findMarkdownFilesRecursive(rootPath, join(relativePath, entry.name));
// Skip descending if we're already at the depth cap — files at deeper
// levels are silently ignored (matches the convention that `.archon/*/`
// folders support one level of grouping like `defaults/`).
if (currentDepth >= maxDepth) continue;
const subResults = await findMarkdownFilesRecursive(
rootPath,
join(relativePath, entry.name),
options
);
results.push(...subResults);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push({

View file

@ -8,6 +8,10 @@ export {
getArchonConfigPath,
getArchonEnvPath,
getRepoArchonEnvPath,
getHomeWorkflowsPath,
getHomeCommandsPath,
getHomeScriptsPath,
getLegacyHomeWorkflowsPath,
getCommandFolderSearchPaths,
getWorkflowFolderSearchPaths,
getAppArchonBasePath,

View file

@ -36,6 +36,7 @@ import {
getDefaultCommandsPath,
getDefaultWorkflowsPath,
getArchonWorkspacesPath,
getHomeCommandsPath,
getRunArtifactsPath,
getArchonHome,
isDocker,
@ -139,7 +140,7 @@ if (BUNDLED_IS_BINARY) {
}
}
type WorkflowSource = 'project' | 'bundled';
type WorkflowSource = 'project' | 'bundled' | 'global';
// =========================================================================
// OpenAPI route configs (module-scope — pure config, no runtime dependencies)
@ -2298,7 +2299,7 @@ export function registerApiRoutes(
if (codebases.length > 0) workingDir = codebases[0].default_cwd;
}
// Collect commands: project-defined override bundled (same name wins)
// Collect commands: precedence bundled < global < project (repo-defined wins).
const commandMap = new Map<string, WorkflowSource>();
// 1. Seed with bundled defaults
@ -2306,11 +2307,17 @@ export function registerApiRoutes(
commandMap.set(name, 'bundled');
}
// maxDepth: 1 matches the executor's resolver (resolveCommand /
// loadCommandPrompt) — without this cap, the UI palette would surface
// commands buried in deep subfolders that the executor silently can't
// resolve at runtime.
const COMMAND_LIST_DEPTH = { maxDepth: 1 };
// 2. If not binary build, also check filesystem defaults
if (!isBinaryBuild()) {
try {
const defaultsPath = getDefaultCommandsPath();
const files = await findMarkdownFilesRecursive(defaultsPath);
const files = await findMarkdownFilesRecursive(defaultsPath, '', COMMAND_LIST_DEPTH);
for (const { commandName } of files) {
commandMap.set(commandName, 'bundled');
}
@ -2322,13 +2329,27 @@ export function registerApiRoutes(
}
}
// 3. Project-defined commands override bundled
// 3. Home-scoped commands (~/.archon/commands/) override bundled
try {
const homeCommandsPath = getHomeCommandsPath();
const files = await findMarkdownFilesRecursive(homeCommandsPath, '', COMMAND_LIST_DEPTH);
for (const { commandName } of files) {
commandMap.set(commandName, 'global');
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
getLog().error({ err }, 'commands.list_home_failed');
}
// ENOENT: home commands dir not created yet — not an error
}
// 4. Project-defined commands override bundled AND global
if (workingDir) {
const searchPaths = getCommandFolderSearchPaths();
for (const folder of searchPaths) {
const dirPath = join(workingDir, folder);
try {
const files = await findMarkdownFilesRecursive(dirPath);
const files = await findMarkdownFilesRecursive(dirPath, '', COMMAND_LIST_DEPTH);
for (const { commandName } of files) {
commandMap.set(commandName, 'project');
}

View file

@ -17,8 +17,13 @@ export const workflowLoadErrorSchema = z
})
.openapi('WorkflowLoadError');
/** Workflow source — project-defined or bundled default. */
export const workflowSourceSchema = z.enum(['project', 'bundled']).openapi('WorkflowSource');
/**
* Workflow source project-defined, bundled default, or home-scoped (global).
* Precedence for same-named entries: `bundled` < `global` < `project`.
*/
export const workflowSourceSchema = z
.enum(['project', 'bundled', 'global'])
.openapi('WorkflowSource');
/** A workflow entry in the list response, including its source. */
export const workflowListEntrySchema = z

View file

@ -119,9 +119,9 @@ export function CommandPicker({
<span
className={cn(
'text-[9px] font-medium px-1.5 py-0.5 rounded shrink-0',
cmd.source === 'project'
? 'bg-node-command/20 text-node-command'
: 'bg-surface-inset text-text-tertiary'
cmd.source === 'project' && 'bg-node-command/20 text-node-command',
cmd.source === 'global' && 'bg-node-loop/20 text-node-loop',
cmd.source === 'bundled' && 'bg-surface-inset text-text-tertiary'
)}
>
{cmd.source}

View file

@ -29,6 +29,7 @@ export function NodePalette(): React.ReactElement {
};
const bundled = commands?.filter((c: CommandEntry) => c.source === 'bundled') ?? [];
const global = commands?.filter((c: CommandEntry) => c.source === 'global') ?? [];
const project = commands?.filter((c: CommandEntry) => c.source === 'project') ?? [];
return (
@ -89,6 +90,27 @@ export function NodePalette(): React.ReactElement {
</>
)}
{global.length > 0 && (
<>
<h4 className="text-[10px] font-medium text-text-tertiary uppercase tracking-wide mt-2 mb-1">
Global (~/.archon/commands/)
</h4>
{global.map((cmd: CommandEntry) => (
<div
key={cmd.name}
draggable
onDragStart={(e): void => {
onDragStart(e, 'command', cmd.name);
}}
className="flex items-center gap-2 px-2 py-1.5 rounded-md border border-border hover:border-accent hover:bg-accent/5 cursor-grab text-xs text-text-primary mb-1"
>
<span className="text-[10px] text-text-tertiary font-medium">CMD</span>
<span className="truncate">{cmd.name}</span>
</div>
))}
</>
)}
{bundled.length > 0 && (
<>
<h4 className="text-[10px] font-medium text-text-tertiary uppercase tracking-wide mt-2 mb-1">

View file

@ -2348,7 +2348,7 @@ export interface components {
nodes: components['schemas']['DagNode'][];
};
/** @enum {string} */
WorkflowSource: 'project' | 'bundled';
WorkflowSource: 'project' | 'bundled' | 'global';
WorkflowListEntry: {
workflow: components['schemas']['WorkflowDefinition'];
source: components['schemas']['WorkflowSource'];

View file

@ -19,7 +19,7 @@
"./test-utils": "./src/test-utils.ts"
},
"scripts": {
"test": "bun test src/dag-executor.test.ts && bun test src/loader.test.ts && bun test src/logger.test.ts && bun test src/condition-evaluator.test.ts && bun test src/event-emitter.test.ts && bun test src/executor-shared.test.ts && bun test src/executor.test.ts && bun test src/executor-preamble.test.ts && bun test src/defaults/ src/model-validation.test.ts src/router.test.ts src/utils/ src/hooks.test.ts && bun test src/validation-parser.test.ts src/schemas.test.ts src/command-validation.test.ts && bun test src/validator.test.ts && bun test src/script-discovery.test.ts && bun test src/runtime-check.test.ts && bun test src/script-node-deps.test.ts",
"test": "bun test src/dag-executor.test.ts && bun test src/loader.test.ts && bun test src/logger.test.ts && bun test src/condition-evaluator.test.ts && bun test src/event-emitter.test.ts && bun test src/executor-shared.test.ts && bun test src/load-command-prompt.test.ts && bun test src/executor.test.ts && bun test src/executor-preamble.test.ts && bun test src/defaults/ src/model-validation.test.ts src/router.test.ts src/utils/ src/hooks.test.ts && bun test src/validation-parser.test.ts src/schemas.test.ts src/command-validation.test.ts && bun test src/validator.test.ts && bun test src/script-discovery.test.ts && bun test src/runtime-check.test.ts && bun test src/script-node-deps.test.ts",
"type-check": "bun x tsc --noEmit"
},
"dependencies": {

View file

@ -5,9 +5,8 @@
* Independent nodes within the same layer run concurrently via Promise.allSettled.
* Captures all assistant output regardless of streaming mode for $node_id.output substitution.
*/
import { resolve } from 'path';
import { execFileAsync } from '@archon/git';
import { discoverScripts } from './script-discovery';
import { discoverScriptsForCwd } from './script-discovery';
import type {
IWorkflowPlatform,
WorkflowMessageMetadata,
@ -1317,13 +1316,48 @@ async function executeScriptNode(
args = ['run', ...withFlags, 'python', '-c', finalScript];
}
} else {
// Named script — look up in .archon/scripts/ directory
const scriptsDir = resolve(cwd, '.archon', 'scripts');
const scripts = await discoverScripts(scriptsDir);
// Named script — look up across repo and home scopes.
// Precedence: <cwd>/.archon/scripts/ > ~/.archon/scripts/ (repo wins).
// Wrap discovery in its own try/catch so a permission error on ~/.archon/scripts/
// isn't mis-attributed by the outer catch's "permission denied (check cwd
// permissions)" branch — that branch is for execFileAsync EACCES.
let scripts: Awaited<ReturnType<typeof discoverScriptsForCwd>>;
try {
scripts = await discoverScriptsForCwd(cwd);
} catch (discoveryErr) {
const err = discoveryErr as Error;
const errorMsg = `Script node '${node.id}': failed to discover scripts — ${err.message}`;
getLog().error({ err, nodeId: node.id, cwd }, 'script_discovery_failed');
await safeSendMessage(platform, conversationId, errorMsg, nodeContext);
await logNodeError(logDir, workflowRun.id, node.id, errorMsg);
emitter.emit({
type: 'node_failed',
runId: workflowRun.id,
nodeId: node.id,
nodeName: node.id,
error: errorMsg,
});
deps.store
.createWorkflowEvent({
workflow_run_id: workflowRun.id,
event_type: 'node_failed',
step_name: node.id,
data: { error: errorMsg, type: 'script' },
})
.catch((dbErr: Error) => {
getLog().error(
{ err: dbErr, workflowRunId: workflowRun.id, eventType: 'node_failed' },
'workflow_event_persist_failed'
);
});
return { state: 'failed', output: '', error: errorMsg };
}
const scriptDef = scripts.get(finalScript);
if (!scriptDef) {
const errorMsg = `Script node '${node.id}': named script '${finalScript}' not found in .archon/scripts/`;
const errorMsg = `Script node '${node.id}': named script '${finalScript}' not found in .archon/scripts/ or ~/.archon/scripts/`;
getLog().error({ nodeId: node.id, scriptName: finalScript }, 'script_not_found');
await safeSendMessage(platform, conversationId, errorMsg, nodeContext);
await logNodeError(logDir, workflowRun.id, node.id, errorMsg);

View file

@ -149,12 +149,22 @@ export async function loadCommandPrompt(
config = { defaults: { loadDefaultCommands: true } };
}
// Use command folder paths with optional configured folder
// Use command folder paths with optional configured folder.
// Each scope is walked 1 subfolder deep so `triage/review.md` resolves as
// `review` — matching the workflows/scripts convention. Resolution
// precedence: repo > home (~/.archon/commands/) > bundled/app defaults.
const searchPaths = archonPaths.getCommandFolderSearchPaths(configuredFolder);
const resolvedSearchPaths: string[] = [
...searchPaths.map(folder => join(cwd, folder)),
archonPaths.getHomeCommandsPath(),
];
// Search repo paths first
for (const folder of searchPaths) {
const filePath = join(cwd, folder, `${commandName}.md`);
for (const dir of resolvedSearchPaths) {
const entries = await archonPaths.findMarkdownFilesRecursive(dir, '', { maxDepth: 1 });
const match = entries.find(e => e.commandName === commandName);
if (!match) continue;
const filePath = join(dir, match.relativePath);
try {
const content = await readFile(filePath, 'utf-8');
if (!content.trim()) {
@ -165,13 +175,10 @@ export async function loadCommandPrompt(
message: `Command file is empty: ${commandName}.md`,
};
}
getLog().debug({ commandName, folder }, 'command_loaded');
getLog().debug({ commandName, filePath }, 'command_loaded');
return { success: true, content };
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
continue;
}
if (err.code === 'EACCES') {
getLog().error({ commandName, filePath }, 'command_file_permission_denied');
return {
@ -180,7 +187,9 @@ export async function loadCommandPrompt(
message: `Permission denied reading command: ${commandName}.md`,
};
}
// Other unexpected errors
// Other unexpected errors (ENOENT shouldn't happen since the walk just found it,
// but if the file was deleted between walk and read we fall through to 'not found'
// with a log.)
getLog().error({ err, commandName, filePath }, 'command_file_read_error');
return {
success: false,
@ -190,7 +199,7 @@ export async function loadCommandPrompt(
}
}
// If not found in repo and app defaults enabled, search app defaults
// If not found in repo/home and app defaults enabled, search app defaults
const loadDefaultCommands = config.defaults?.loadDefaultCommands ?? true;
if (loadDefaultCommands) {
if (isBinaryBuild()) {
@ -202,29 +211,37 @@ export async function loadCommandPrompt(
}
getLog().debug({ commandName }, 'command_bundled_not_found');
} else {
// Bun: load from filesystem
// Bun: load from filesystem (walk 1 level deep so `defaults/archon-*.md` resolves)
const appDefaultsPath = archonPaths.getDefaultCommandsPath();
const filePath = join(appDefaultsPath, `${commandName}.md`);
try {
const content = await readFile(filePath, 'utf-8');
if (!content.trim()) {
getLog().error({ commandName }, 'command_app_default_empty');
return {
success: false,
reason: 'empty_file',
message: `App default command file is empty: ${commandName}.md`,
};
const entries = await archonPaths.findMarkdownFilesRecursive(appDefaultsPath, '', {
maxDepth: 1,
});
const match = entries.find(e => e.commandName === commandName);
if (match) {
const filePath = join(appDefaultsPath, match.relativePath);
try {
const content = await readFile(filePath, 'utf-8');
if (!content.trim()) {
getLog().error({ commandName }, 'command_app_default_empty');
return {
success: false,
reason: 'empty_file',
message: `App default command file is empty: ${commandName}.md`,
};
}
getLog().debug({ commandName }, 'command_loaded_app_defaults');
return { success: true, content };
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
getLog().warn({ err, commandName }, 'command_app_default_read_error');
} else {
getLog().debug({ commandName }, 'command_app_default_not_found');
}
// Fall through to not found
}
getLog().debug({ commandName }, 'command_loaded_app_defaults');
return { success: true, content };
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
getLog().warn({ err, commandName }, 'command_app_default_read_error');
} else {
getLog().debug({ commandName }, 'command_app_default_not_found');
}
// Fall through to not found
} else {
getLog().debug({ commandName }, 'command_app_default_not_found');
}
}
}

View file

@ -0,0 +1,115 @@
import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import * as realPaths from '@archon/paths';
// Mock only the logger so test output stays clean. All other @archon/paths
// exports (findMarkdownFilesRecursive, getHomeCommandsPath, etc.) use real
// implementations — loadCommandPrompt exercises them against a tmp dir set
// via ARCHON_HOME below.
const mockLogFn = mock(() => {});
const mockLogger = {
info: mockLogFn,
warn: mockLogFn,
error: mockLogFn,
debug: mockLogFn,
trace: mockLogFn,
fatal: mockLogFn,
child: mock(() => mockLogger),
bindings: mock(() => ({ module: 'test' })),
isLevelEnabled: mock(() => true),
level: 'info',
};
mock.module('@archon/paths', () => ({
...realPaths,
createLogger: mock(() => mockLogger),
}));
import { loadCommandPrompt } from './executor-shared';
import type { WorkflowDeps } from './deps';
// Minimal deps stub — loadCommandPrompt only calls loadConfig.
function makeDeps(loadDefaultCommands = true): WorkflowDeps {
return {
loadConfig: async () => ({ defaults: { loadDefaultCommands } }),
} as unknown as WorkflowDeps;
}
describe('loadCommandPrompt — home-scope resolution', () => {
let archonHome: string;
let repoRoot: string;
let prevArchonHome: string | undefined;
beforeEach(() => {
prevArchonHome = process.env.ARCHON_HOME;
// Separate tmp dirs for home and repo so they don't collide.
archonHome = mkdtempSync(join(tmpdir(), 'archon-home-'));
repoRoot = mkdtempSync(join(tmpdir(), 'archon-repo-'));
process.env.ARCHON_HOME = archonHome;
mkdirSync(join(archonHome, 'commands'), { recursive: true });
mkdirSync(join(repoRoot, '.archon', 'commands'), { recursive: true });
});
afterEach(() => {
if (prevArchonHome === undefined) delete process.env.ARCHON_HOME;
else process.env.ARCHON_HOME = prevArchonHome;
rmSync(archonHome, { recursive: true, force: true });
rmSync(repoRoot, { recursive: true, force: true });
});
it('resolves a command from ~/.archon/commands/ when repo has none', async () => {
writeFileSync(join(archonHome, 'commands', 'personal-helper.md'), 'Personal helper body');
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'personal-helper');
expect(result.success).toBe(true);
if (result.success) expect(result.content).toBe('Personal helper body');
});
it('repo command shadows home command with the same name', async () => {
writeFileSync(join(archonHome, 'commands', 'shared.md'), 'HOME version');
writeFileSync(join(repoRoot, '.archon', 'commands', 'shared.md'), 'REPO version');
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'shared');
expect(result.success).toBe(true);
if (result.success) expect(result.content).toBe('REPO version');
});
it('resolves a home command inside a 1-level subfolder by basename', async () => {
mkdirSync(join(archonHome, 'commands', 'triage'), { recursive: true });
writeFileSync(join(archonHome, 'commands', 'triage', 'review.md'), 'Review body');
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'review');
expect(result.success).toBe(true);
if (result.success) expect(result.content).toBe('Review body');
});
it('does NOT resolve home commands buried >1 level deep', async () => {
mkdirSync(join(archonHome, 'commands', 'a', 'b'), { recursive: true });
writeFileSync(join(archonHome, 'commands', 'a', 'b', 'too-deep.md'), 'too deep');
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'too-deep');
expect(result.success).toBe(false);
if (!result.success) expect(result.reason).toBe('not_found');
});
it('returns not_found when neither repo nor home has the command (defaults off)', async () => {
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'missing');
expect(result.success).toBe(false);
if (!result.success) expect(result.reason).toBe('not_found');
});
it('surfaces empty_file for a zero-byte home command', async () => {
writeFileSync(join(archonHome, 'commands', 'blank.md'), '');
const result = await loadCommandPrompt(makeDeps(false), repoRoot, 'blank');
expect(result.success).toBe(false);
if (!result.success) expect(result.reason).toBe('empty_file');
});
});

View file

@ -598,82 +598,224 @@ nodes:
});
});
describe('globalSearchPath loading', () => {
it('should load workflows from globalSearchPath and merge with local', async () => {
const globalDir = join(
tmpdir(),
`global-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
const globalWorkflowDir = join(globalDir, '.archon', 'workflows');
const localWorkflowDir = join(testDir, '.archon', 'workflows');
describe('home-scoped workflows (~/.archon/workflows/)', () => {
// Home-scope is read unconditionally by discovery — no caller option. Tests
// redirect `getArchonHome()` to a temp dir via the `ARCHON_HOME` env var so
// they don't touch the user's real `~/.archon/`.
let homeDir: string;
const originalArchonHome = process.env.ARCHON_HOME;
const originalArchonDocker = process.env.ARCHON_DOCKER;
await mkdir(globalWorkflowDir, { recursive: true });
await mkdir(localWorkflowDir, { recursive: true });
beforeEach(async () => {
homeDir = join(tmpdir(), `home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(homeDir, { recursive: true });
process.env.ARCHON_HOME = homeDir;
delete process.env.ARCHON_DOCKER;
// The deprecation warning uses a module-scoped flag; reset between tests
// so each case is independent.
const { resetLegacyHomeWarningForTests } = await import('./workflow-discovery');
resetLegacyHomeWarningForTests();
mockLogger.warn.mockClear();
});
afterEach(async () => {
try {
await rm(homeDir, { recursive: true, force: true });
} catch {
// ignore
}
if (originalArchonHome === undefined) {
delete process.env.ARCHON_HOME;
} else {
process.env.ARCHON_HOME = originalArchonHome;
}
if (originalArchonDocker === undefined) {
delete process.env.ARCHON_DOCKER;
} else {
process.env.ARCHON_DOCKER = originalArchonDocker;
}
});
it('loads home-scoped workflows from ~/.archon/workflows/ and merges with repo', async () => {
const homeWorkflowDir = join(homeDir, 'workflows');
const repoWorkflowDir = join(testDir, '.archon', 'workflows');
await mkdir(homeWorkflowDir, { recursive: true });
await mkdir(repoWorkflowDir, { recursive: true });
await writeFile(
join(globalWorkflowDir, 'global-wf.yaml'),
'name: global-workflow\ndescription: From global\nnodes:\n - id: foo\n command: foo\n'
join(homeWorkflowDir, 'home-wf.yaml'),
'name: home-workflow\ndescription: From home\nnodes:\n - id: foo\n command: foo\n'
);
await writeFile(
join(localWorkflowDir, 'local-wf.yaml'),
'name: local-workflow\ndescription: From local\nnodes:\n - id: bar\n command: bar\n'
join(repoWorkflowDir, 'repo-wf.yaml'),
'name: repo-workflow\ndescription: From repo\nnodes:\n - id: bar\n command: bar\n'
);
const result = await discoverWorkflows(testDir, {
loadDefaults: false,
globalSearchPath: globalDir,
});
const result = await discoverWorkflows(testDir, { loadDefaults: false });
const names = result.workflows.map(w => w.workflow.name);
expect(names).toContain('global-workflow');
expect(names).toContain('local-workflow');
await rm(globalDir, { recursive: true, force: true });
expect(names).toContain('home-workflow');
expect(names).toContain('repo-workflow');
});
it('should allow local workflows to override global by filename', async () => {
const globalDir = join(
tmpdir(),
`global-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
it("classifies home-scoped workflows as source: 'global'", async () => {
const homeWorkflowDir = join(homeDir, 'workflows');
await mkdir(homeWorkflowDir, { recursive: true });
await writeFile(
join(homeWorkflowDir, 'only-home.yaml'),
'name: only-home\ndescription: From home\nnodes:\n - id: n\n command: c\n'
);
const globalWorkflowDir = join(globalDir, '.archon', 'workflows');
const localWorkflowDir = join(testDir, '.archon', 'workflows');
await mkdir(globalWorkflowDir, { recursive: true });
await mkdir(localWorkflowDir, { recursive: true });
const result = await discoverWorkflows(testDir, { loadDefaults: false });
const entry = result.workflows.find(w => w.workflow.name === 'only-home');
expect(entry?.source).toBe('global');
});
it('repo workflow overrides home workflow with the same filename', async () => {
const homeWorkflowDir = join(homeDir, 'workflows');
const repoWorkflowDir = join(testDir, '.archon', 'workflows');
await mkdir(homeWorkflowDir, { recursive: true });
await mkdir(repoWorkflowDir, { recursive: true });
await writeFile(
join(globalWorkflowDir, 'shared.yaml'),
'name: global-version\ndescription: Global version\nnodes:\n - id: global\n command: global\n'
join(homeWorkflowDir, 'shared.yaml'),
'name: home-version\ndescription: Home version\nnodes:\n - id: h\n command: c\n'
);
await writeFile(
join(localWorkflowDir, 'shared.yaml'),
'name: local-version\ndescription: Local override\nnodes:\n - id: local\n command: local\n'
join(repoWorkflowDir, 'shared.yaml'),
'name: repo-version\ndescription: Repo override\nnodes:\n - id: r\n command: c\n'
);
const result = await discoverWorkflows(testDir, {
loadDefaults: false,
globalSearchPath: globalDir,
});
// Local should override global by filename
const result = await discoverWorkflows(testDir, { loadDefaults: false });
const shared = result.workflows.find(
w => w.workflow.name === 'global-version' || w.workflow.name === 'local-version'
w => w.workflow.name === 'home-version' || w.workflow.name === 'repo-version'
);
expect(shared?.workflow.name).toBe('local-version');
await rm(globalDir, { recursive: true, force: true });
expect(shared?.workflow.name).toBe('repo-version');
expect(shared?.source).toBe('project');
});
it('should handle missing globalSearchPath gracefully', async () => {
const result = await discoverWorkflows(testDir, {
loadDefaults: false,
globalSearchPath: '/nonexistent/path',
});
// Should not throw, just return whatever local workflows exist
it('silently skips when ~/.archon/workflows/ does not exist', async () => {
// homeDir exists but no workflows/ subdirectory — should not error.
const result = await discoverWorkflows(testDir, { loadDefaults: false });
expect(result.errors).toEqual([]);
});
it('supports 1-level subfolders under ~/.archon/workflows/ (e.g. triage/foo.yaml)', async () => {
const homeWorkflowDir = join(homeDir, 'workflows', 'triage');
await mkdir(homeWorkflowDir, { recursive: true });
await writeFile(
join(homeWorkflowDir, 'grouped.yaml'),
'name: grouped-workflow\ndescription: In a subfolder\nnodes:\n - id: n\n command: c\n'
);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
const entry = result.workflows.find(w => w.workflow.name === 'grouped-workflow');
expect(entry).toBeDefined();
expect(entry?.source).toBe('global');
});
it('does NOT descend past 1 level of subfolders (rejects workflows/a/b/foo.yaml)', async () => {
const nestedDir = join(homeDir, 'workflows', 'a', 'b');
await mkdir(nestedDir, { recursive: true });
await writeFile(
join(nestedDir, 'too-deep.yaml'),
'name: too-deep\ndescription: Nested too deep\nnodes:\n - id: n\n command: c\n'
);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
const entry = result.workflows.find(w => w.workflow.name === 'too-deep');
expect(entry).toBeUndefined();
});
});
describe('legacy ~/.archon/.archon/workflows/ deprecation warning', () => {
let homeDir: string;
const originalArchonHome = process.env.ARCHON_HOME;
const originalArchonDocker = process.env.ARCHON_DOCKER;
beforeEach(async () => {
homeDir = join(tmpdir(), `legacy-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(homeDir, { recursive: true });
process.env.ARCHON_HOME = homeDir;
delete process.env.ARCHON_DOCKER;
const { resetLegacyHomeWarningForTests } = await import('./workflow-discovery');
resetLegacyHomeWarningForTests();
mockLogger.warn.mockClear();
});
afterEach(async () => {
try {
await rm(homeDir, { recursive: true, force: true });
} catch {
// ignore
}
if (originalArchonHome === undefined) {
delete process.env.ARCHON_HOME;
} else {
process.env.ARCHON_HOME = originalArchonHome;
}
if (originalArchonDocker === undefined) {
delete process.env.ARCHON_DOCKER;
} else {
process.env.ARCHON_DOCKER = originalArchonDocker;
}
});
it('emits a WARN with the migration command when the legacy path exists', async () => {
const legacyDir = join(homeDir, '.archon', 'workflows');
await mkdir(legacyDir, { recursive: true });
await writeFile(
join(legacyDir, 'stranded.yaml'),
'name: stranded\ndescription: At the old path\nnodes:\n - id: n\n command: c\n'
);
await discoverWorkflows(testDir, { loadDefaults: false });
const warnCalls = mockLogger.warn.mock.calls;
const legacyWarn = warnCalls.find(call => call[1] === 'workflow.legacy_home_path_detected');
expect(legacyWarn).toBeDefined();
expect(legacyWarn?.[0]).toMatchObject({
legacyPath: legacyDir,
newPath: join(homeDir, 'workflows'),
moveCommand: expect.stringContaining('mv'),
});
});
it('does NOT load workflows from the legacy path (clean cut)', async () => {
const legacyDir = join(homeDir, '.archon', 'workflows');
await mkdir(legacyDir, { recursive: true });
await writeFile(
join(legacyDir, 'stranded.yaml'),
'name: stranded\ndescription: At the old path\nnodes:\n - id: n\n command: c\n'
);
const result = await discoverWorkflows(testDir, { loadDefaults: false });
const stranded = result.workflows.find(w => w.workflow.name === 'stranded');
expect(stranded).toBeUndefined();
});
it('warns exactly once per process, even across multiple discovery calls', async () => {
const legacyDir = join(homeDir, '.archon', 'workflows');
await mkdir(legacyDir, { recursive: true });
await discoverWorkflows(testDir, { loadDefaults: false });
await discoverWorkflows(testDir, { loadDefaults: false });
await discoverWorkflows(testDir, { loadDefaults: false });
const warnCalls = mockLogger.warn.mock.calls.filter(
call => call[1] === 'workflow.legacy_home_path_detected'
);
expect(warnCalls).toHaveLength(1);
});
it('does not emit the warning when the legacy path is absent', async () => {
// No legacy directory created — warning should not fire.
await discoverWorkflows(testDir, { loadDefaults: false });
const warnCalls = mockLogger.warn.mock.calls.filter(
call => call[1] === 'workflow.legacy_home_path_detected'
);
expect(warnCalls).toHaveLength(0);
});
});
describe('discoverWorkflowsWithConfig', () => {
@ -704,31 +846,48 @@ nodes:
expect(archonWorkflow).toBeDefined();
});
it('should pass globalSearchPath through to discoverWorkflows', async () => {
const { discoverWorkflowsWithConfig } = await import('./workflow-discovery');
const globalDir = join(
it('surfaces home-scoped workflows without any option — discovery reads ~/.archon/workflows/ internally', async () => {
const { discoverWorkflowsWithConfig, resetLegacyHomeWarningForTests } =
await import('./workflow-discovery');
resetLegacyHomeWarningForTests();
const homeDir = join(
tmpdir(),
`global-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
`home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
);
const globalWorkflowDir = join(globalDir, '.archon', 'workflows');
await mkdir(globalWorkflowDir, { recursive: true });
const homeWorkflowDir = join(homeDir, 'workflows');
await mkdir(homeWorkflowDir, { recursive: true });
await writeFile(
join(globalWorkflowDir, 'global-only.yaml'),
'name: global-only\ndescription: From global\nnodes:\n - id: foo\n command: foo\n'
join(homeWorkflowDir, 'home-only.yaml'),
'name: home-only\ndescription: From home\nnodes:\n - id: foo\n command: foo\n'
);
const mockLoadConfig = mock(async () => ({
defaults: { loadDefaultWorkflows: false },
}));
const originalArchonHome = process.env.ARCHON_HOME;
const originalArchonDocker = process.env.ARCHON_DOCKER;
process.env.ARCHON_HOME = homeDir;
delete process.env.ARCHON_DOCKER;
try {
const mockLoadConfig = mock(async () => ({
defaults: { loadDefaultWorkflows: false },
}));
const result = await discoverWorkflowsWithConfig(testDir, mockLoadConfig, {
globalSearchPath: globalDir,
});
const names = result.workflows.map(w => w.workflow.name);
expect(names).toContain('global-only');
await rm(globalDir, { recursive: true, force: true });
const result = await discoverWorkflowsWithConfig(testDir, mockLoadConfig);
const entry = result.workflows.find(w => w.workflow.name === 'home-only');
expect(entry).toBeDefined();
expect(entry?.source).toBe('global');
} finally {
if (originalArchonHome === undefined) {
delete process.env.ARCHON_HOME;
} else {
process.env.ARCHON_HOME = originalArchonHome;
}
if (originalArchonDocker === undefined) {
delete process.env.ARCHON_DOCKER;
} else {
process.env.ARCHON_DOCKER = originalArchonDocker;
}
await rm(homeDir, { recursive: true, force: true });
}
});
});

View file

@ -92,8 +92,15 @@ export type WorkflowExecutionResult =
// WorkflowLoadError / WorkflowLoadResult — workflow discovery results
// ---------------------------------------------------------------------------
/** Workflow origin — bundled default or project-defined. */
export type WorkflowSource = 'bundled' | 'project';
/**
* Workflow origin:
* - `bundled` embedded in the Archon binary / bundled defaults
* - `global` user-level, discovered at `~/.archon/workflows/` (applies to every repo)
* - `project` repo-local, discovered at `<repoRoot>/.archon/workflows/`
*
* Precedence for same-named files: `bundled` < `global` < `project`.
*/
export type WorkflowSource = 'bundled' | 'global' | 'project';
/** A workflow definition paired with its discovery source. */
export interface WorkflowWithSource {

View file

@ -18,9 +18,19 @@ const mockLogger = {
debug: mock(() => undefined),
trace: mock(() => undefined),
};
mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger) }));
let mockHomeScriptsPath = '/home/scripts';
mock.module('@archon/paths', () => ({
createLogger: mock(() => mockLogger),
getHomeScriptsPath: mock(() => mockHomeScriptsPath),
}));
import { discoverScripts, getDefaultScripts } from './script-discovery';
import { discoverScripts, discoverScriptsForCwd, getDefaultScripts } from './script-discovery';
// On Windows, path.join produces backslashes (e.g. `\scripts\triage`). The
// mocks below key on forward-slash paths for readability, so normalize before
// comparing. Production paths are stored via normalizeSep(), so assertions on
// stored paths remain forward-slash on every OS.
const norm = (p: string): string => p.replaceAll('\\', '/');
describe('discoverScripts', () => {
beforeEach(() => {
@ -159,6 +169,106 @@ describe('discoverScripts', () => {
});
});
describe('scanScriptDir depth cap', () => {
// Scripts are discovered 1 level deep (matches the workflows/commands
// convention). `defaults/` style subfolders are fine; nested subfolders are not.
beforeEach(() => {
mockReaddir.mockReset();
mockStat.mockReset();
});
test('allows files in a 1-level subfolder', async () => {
mockReaddir.mockImplementation(async (path: string) => {
const p = norm(path);
if (p === '/scripts') return ['triage', 'top.ts'];
if (p === '/scripts/triage') return ['helper.py'];
return [];
});
mockStat.mockImplementation(async (path: string) => ({
isDirectory: () => norm(path) === '/scripts/triage',
}));
const result = await discoverScripts('/scripts');
expect(result.has('top')).toBe(true);
expect(result.has('helper')).toBe(true);
});
test('does NOT descend into nested subfolders (cap at depth 1)', async () => {
mockReaddir.mockImplementation(async (path: string) => {
const p = norm(path);
if (p === '/scripts') return ['level-one'];
if (p === '/scripts/level-one') return ['level-two'];
if (p === '/scripts/level-one/level-two') return ['too-deep.ts'];
return [];
});
mockStat.mockImplementation(async (path: string) => {
const p = norm(path);
return {
isDirectory: () => p === '/scripts/level-one' || p === '/scripts/level-one/level-two',
};
});
const result = await discoverScripts('/scripts');
expect(result.has('too-deep')).toBe(false);
expect(result.size).toBe(0);
});
});
describe('discoverScriptsForCwd — merge repo + home with repo winning', () => {
beforeEach(() => {
mockReaddir.mockReset();
mockStat.mockReset();
mockHomeScriptsPath = '/home/scripts';
});
test('merges scripts from ~/.archon/scripts and <cwd>/.archon/scripts', async () => {
mockReaddir.mockImplementation(async (path: string) => {
const p = norm(path);
if (p === '/home/scripts') return ['home-only.ts'];
if (p === '/repo/.archon/scripts') return ['repo-only.py'];
return [];
});
mockStat.mockResolvedValue({ isDirectory: () => false });
const result = await discoverScriptsForCwd('/repo');
expect(result.has('home-only')).toBe(true);
expect(result.has('repo-only')).toBe(true);
expect(result.size).toBe(2);
});
test('repo-scoped script overrides same-named home script', async () => {
mockReaddir.mockImplementation(async (path: string) => {
const p = norm(path);
if (p === '/home/scripts') return ['shared.ts'];
if (p === '/repo/.archon/scripts') return ['shared.ts'];
return [];
});
mockStat.mockResolvedValue({ isDirectory: () => false });
const result = await discoverScriptsForCwd('/repo');
expect(result.size).toBe(1);
// Stored paths are normalized to forward slashes via normalizeSep() in
// script-discovery.ts, so this assertion is OS-independent.
expect(result.get('shared')!.path).toBe('/repo/.archon/scripts/shared.ts');
});
test('tolerates missing home dir (new user, no personal scripts yet)', async () => {
mockReaddir.mockImplementation(async (path: string) => {
const p = norm(path);
if (p === '/home/scripts') {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
}
if (p === '/repo/.archon/scripts') return ['only-repo.ts'];
return [];
});
mockStat.mockResolvedValue({ isDirectory: () => false });
const result = await discoverScriptsForCwd('/repo');
expect(result.size).toBe(1);
expect(result.has('only-repo')).toBe(true);
});
});
describe('getDefaultScripts', () => {
test('returns an empty Map', () => {
const defaults = getDefaultScripts();

View file

@ -6,7 +6,7 @@
*/
import { readdir, stat } from 'fs/promises';
import { join, basename, extname } from 'path';
import { createLogger } from '@archon/paths';
import { createLogger, getHomeScriptsPath } from '@archon/paths';
/** Normalize path separators to forward slashes for cross-platform consistency */
function normalizeSep(p: string): string {
@ -46,12 +46,24 @@ function getRuntimeForExtension(ext: string): ScriptRuntime | undefined {
}
/**
* Recursively scan a directory and return all script files with their names, paths, and runtimes.
* Skips files with unknown extensions. Throws on duplicate script names.
* Maximum subfolder depth we descend into when scanning scripts.
*
* `1` matches the workflows/commands convention: allow one level of
* grouping (e.g. `.archon/scripts/triage/foo.ts`) but no nested folders.
* We stop at 1 deliberately deeper nesting has never been part of the
* documented convention and adds no organizational value, just routing
* ambiguity when two basenames collide across folders.
*/
const MAX_SCRIPT_DISCOVERY_DEPTH = 1;
/**
* Scan a directory for script files, descending at most `MAX_SCRIPT_DISCOVERY_DEPTH`
* folders deep. Skips files with unknown extensions. Throws on duplicate script names.
*/
async function scanScriptDir(
dirPath: string,
scripts: Map<string, ScriptDefinition>
scripts: Map<string, ScriptDefinition>,
depth = 0
): Promise<void> {
let entries: string[];
try {
@ -79,7 +91,10 @@ async function scanScriptDir(
}
if (entryStat.isDirectory()) {
await scanScriptDir(entryPath, scripts);
// 1-depth cap: allow one level of grouping (e.g. `.archon/scripts/triage/foo.ts`)
// but stop there. Matches the workflows/commands convention — no nested folders.
if (depth >= MAX_SCRIPT_DISCOVERY_DEPTH) continue;
await scanScriptDir(entryPath, scripts, depth + 1);
continue;
}
@ -109,7 +124,7 @@ async function scanScriptDir(
/**
* Discover scripts from a directory (expected to be .archon/scripts/ or equivalent).
* Returns a Map of script name -> ScriptDefinition.
* Throws if duplicate script names are found across different extensions.
* Throws if duplicate script names are found across different extensions within the directory.
* Returns an empty Map if the directory does not exist.
*/
export async function discoverScripts(dir: string): Promise<Map<string, ScriptDefinition>> {
@ -119,6 +134,33 @@ export async function discoverScripts(dir: string): Promise<Map<string, ScriptDe
return scripts;
}
/**
* Discover scripts across all scopes for a given repo cwd.
*
* Resolution order (repo wins on same-name collision matches the
* workflows/commands precedence):
* 1. `<cwd>/.archon/scripts/` repo-scoped (`source: 'project'` equivalent)
* 2. `~/.archon/scripts/` home-scoped (`source: 'global'` equivalent)
*
* Within a single scope, duplicate basenames across extensions still throw
* (matches `discoverScripts` behavior). Across scopes, the repo-level entry
* silently overrides the home-level one.
*/
export async function discoverScriptsForCwd(cwd: string): Promise<Map<string, ScriptDefinition>> {
const homeScripts = await discoverScripts(getHomeScriptsPath());
const repoScripts = await discoverScripts(join(cwd, '.archon', 'scripts'));
// Start with home, overlay repo (repo wins)
const merged = new Map<string, ScriptDefinition>(homeScripts);
for (const [name, def] of repoScripts) {
if (merged.has(name)) {
getLog().debug({ name }, 'script.repo_overrides_home');
}
merged.set(name, def);
}
return merged;
}
/**
* Returns bundled default scripts (empty no bundled scripts for now).
* Follows the bundled-defaults.ts pattern for future extensibility.

View file

@ -290,6 +290,61 @@ describe('discoverAvailableCommands', () => {
const without = await discoverAvailableCommands(tmpDir, { loadDefaultCommands: false });
expect(withDefaults.length).toBeGreaterThanOrEqual(without.length);
});
// --- Home-scoped commands (~/.archon/commands/) — new capability
describe('home-scoped commands', () => {
let homeDir: string;
const originalArchonHome = process.env.ARCHON_HOME;
const originalArchonDocker = process.env.ARCHON_DOCKER;
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'validator-home-'));
process.env.ARCHON_HOME = homeDir;
delete process.env.ARCHON_DOCKER;
});
afterEach(async () => {
await rm(homeDir, { recursive: true, force: true });
if (originalArchonHome === undefined) {
delete process.env.ARCHON_HOME;
} else {
process.env.ARCHON_HOME = originalArchonHome;
}
if (originalArchonDocker === undefined) {
delete process.env.ARCHON_DOCKER;
} else {
process.env.ARCHON_DOCKER = originalArchonDocker;
}
});
async function createHomeCommand(name: string, content = '# Home helper'): Promise<void> {
const dir = join(homeDir, 'commands');
await mkdir(dir, { recursive: true });
await writeFile(join(dir, `${name}.md`), content);
}
test('discovers commands placed at ~/.archon/commands/', async () => {
await createHomeCommand('my-personal-helper');
const commands = await discoverAvailableCommands(tmpDir, { loadDefaultCommands: false });
expect(commands).toContain('my-personal-helper');
});
test('resolveCommand (via validateCommand) finds home-scoped commands when repo has none', async () => {
await createHomeCommand('only-in-home');
const result = await validateCommand('only-in-home', tmpDir, { loadDefaultCommands: false });
expect(result.valid).toBe(true);
});
test('repo command overrides home command with the same name', async () => {
await createHomeCommand('shared', '# Home version');
await createCommandFile('shared', '# Repo version');
// Both resolve but the repo wins — validator only asserts existence, so the
// strong behavioral assertion lives in the executor-shared loadCommand tests.
// Here we just confirm that having both doesn't error.
const result = await validateCommand('shared', tmpDir, { loadDefaultCommands: false });
expect(result.valid).toBe(true);
});
});
});
// =============================================================================

View file

@ -16,6 +16,7 @@ import {
createLogger,
getCommandFolderSearchPaths,
getDefaultCommandsPath,
getHomeCommandsPath,
findMarkdownFilesRecursive,
} from '@archon/paths';
import { execFileAsync } from '@archon/git';
@ -32,7 +33,7 @@ function getLog(): ReturnType<typeof createLogger> {
import { isScriptNode } from './schemas';
import type { WorkflowDefinition, DagNode } from './schemas';
import type { ScriptRuntime } from './script-discovery';
import { discoverScripts } from './script-discovery';
import { discoverScriptsForCwd } from './script-discovery';
import { isInlineScript } from './executor-shared';
// =============================================================================
@ -141,17 +142,33 @@ export async function discoverAvailableCommands(
): Promise<string[]> {
const names = new Set<string>();
// Repo search paths (findMarkdownFilesRecursive returns [] for ENOENT)
// Each scope is walked 1 subfolder deep (matches the workflows/scripts
// discovery convention — supports `defaults/` grouping, rejects deeper nesting).
// 1. Repo search paths
const searchPaths = getCommandFolderSearchPaths(config?.commandFolder);
for (const folder of searchPaths) {
const dirPath = join(cwd, folder);
const files = await findMarkdownFilesRecursive(dirPath);
const files = await findMarkdownFilesRecursive(dirPath, '', { maxDepth: 1 });
for (const { commandName } of files) {
names.add(commandName);
}
}
// Bundled defaults
// 2. Home-scoped commands (~/.archon/commands/) — personal helpers reusable across repos.
// ENOENT already returns []; we only catch other errors (EACCES/EPERM/EIO) so a broken
// home-scope doesn't take down repo/bundled discovery.
const homePath = getHomeCommandsPath();
try {
const homeCommands = await findMarkdownFilesRecursive(homePath, '', { maxDepth: 1 });
for (const { commandName } of homeCommands) {
names.add(commandName);
}
} catch (err) {
getLog().warn({ err, path: homePath }, 'commands.home_discovery_failed');
}
// 3. Bundled defaults
const loadDefaults = config?.loadDefaultCommands !== false;
if (loadDefaults) {
if (isBinaryBuild()) {
@ -160,7 +177,7 @@ export async function discoverAvailableCommands(
}
} else {
const defaultsPath = getDefaultCommandsPath();
const files = await findMarkdownFilesRecursive(defaultsPath);
const files = await findMarkdownFilesRecursive(defaultsPath, '', { maxDepth: 1 });
for (const { commandName } of files) {
names.add(commandName);
}
@ -170,25 +187,58 @@ export async function discoverAvailableCommands(
return [...names].sort();
}
/**
* Resolve a command name to a file path within a single directory, walking at
* most 1 subfolder deep. Returns the first `.md` file whose basename matches
* `commandName`, or `null` if nothing matches.
*
* Within a single scope, if two files in different subfolders share a basename
* (e.g. `triage/review.md` and `team/review.md`), the earlier match by the
* deterministic walk order wins duplicates within a scope are a user error.
*/
async function resolveCommandInDir(rootDir: string, commandName: string): Promise<string | null> {
const entries = await findMarkdownFilesRecursive(rootDir, '', { maxDepth: 1 });
const match = entries.find(e => e.commandName === commandName);
return match ? join(rootDir, match.relativePath) : null;
}
/**
* Check if a command file can be resolved via the standard search paths.
* Returns the resolved path if found, null otherwise.
*
* Resolution precedence (first hit wins):
* 1. Repo-local `<cwd>/.archon/commands/` and configured folders
* 2. Home-scoped `~/.archon/commands/` (personal helpers, reusable across repos)
* 3. Bundled defaults embedded in the binary or the app's defaults folder
*/
async function resolveCommand(
commandName: string,
cwd: string,
config?: ValidationConfig
): Promise<string | null> {
// Repo search paths
// Each scope is walked 1 subfolder deep by basename — so `triage/review.md`
// is resolvable as `review`. This matches the workflows/scripts discovery
// convention and makes the listed commands in `discoverAvailableCommands`
// actually resolvable.
// 1. Repo search paths
const searchPaths = getCommandFolderSearchPaths(config?.commandFolder);
for (const folder of searchPaths) {
const filePath = join(cwd, folder, `${commandName}.md`);
if (await fileExists(filePath)) {
return filePath;
}
const resolved = await resolveCommandInDir(join(cwd, folder), commandName);
if (resolved) return resolved;
}
// Bundled defaults
// 2. Home-scoped commands (~/.archon/commands/).
// ENOENT on the home dir already returns null; only wrap for other errors so a
// broken home-scope doesn't prevent bundled-default resolution.
try {
const homeResolved = await resolveCommandInDir(getHomeCommandsPath(), commandName);
if (homeResolved) return homeResolved;
} catch (err) {
getLog().warn({ err, commandName }, 'commands.home_resolve_failed');
}
// 3. Bundled defaults
const loadDefaults = config?.loadDefaultCommands !== false;
if (loadDefaults) {
if (isBinaryBuild()) {
@ -196,10 +246,8 @@ async function resolveCommand(
return `[bundled:${commandName}]`;
}
} else {
const defaultsPath = join(getDefaultCommandsPath(), `${commandName}.md`);
if (await fileExists(defaultsPath)) {
return defaultsPath;
}
const defaultsResolved = await resolveCommandInDir(getDefaultCommandsPath(), commandName);
if (defaultsResolved) return defaultsResolved;
}
}
@ -436,22 +484,23 @@ export async function validateWorkflowResources(
if (isScriptNode(node)) {
const script = node.script;
// Named script: validate file exists in .archon/scripts/
// Named script: validate file exists in repo or home scope.
// Precedence mirrors dag-executor: repo > home. Subfolders up to 1 level deep
// are searched by discoverScriptsForCwd, matching the workflows/commands convention.
if (!isInlineScript(script)) {
const scriptsDir = resolve(cwd, '.archon', 'scripts');
const extensions = node.runtime === 'uv' ? ['.py'] : ['.ts', '.js'];
const existsResults = await Promise.all(
extensions.map(ext => fileExists(join(scriptsDir, `${script}${ext}`)))
);
const scriptExists = existsResults.some(Boolean);
const scripts = await discoverScriptsForCwd(cwd);
const entry = scripts.get(script);
const scriptExists =
entry !== undefined &&
(node.runtime === 'uv' ? entry.runtime === 'uv' : entry.runtime === 'bun');
if (!scriptExists) {
issues.push({
level: 'error',
nodeId: node.id,
field: 'script',
message: `Named script '${script}' not found in .archon/scripts/`,
hint: `Create .archon/scripts/${script}.${node.runtime === 'uv' ? 'py' : 'ts'} with your script code`,
message: `Named script '${script}' not found in .archon/scripts/ or ~/.archon/scripts/`,
hint: `Create .archon/scripts/${script}.${node.runtime === 'uv' ? 'py' : 'ts'} with your script code (or place at ~/.archon/scripts/ to share across repos)`,
});
}
}
@ -568,19 +617,19 @@ export interface ScriptValidationResult {
}
/**
* Discover all script names from .archon/scripts/ in the given cwd.
* Returns a list of { name, path, runtime } entries.
* Discover all script names from the repo and home scopes.
* Returns a list of { name, path, runtime } entries. Repo-scoped scripts
* silently override same-named home-scoped entries.
*/
export async function discoverAvailableScripts(
cwd: string
): Promise<{ name: string; path: string; runtime: ScriptRuntime }[]> {
const scriptsDir = resolve(cwd, '.archon', 'scripts');
try {
const scripts = await discoverScripts(scriptsDir);
const scripts = await discoverScriptsForCwd(cwd);
return [...scripts.values()].map(s => ({ name: s.name, path: s.path, runtime: s.runtime }));
} catch (error) {
const err = error as Error;
getLog().warn({ err, scriptsDir }, 'script_discovery_failed');
getLog().warn({ err, cwd }, 'script_discovery_failed');
return [];
}
}
@ -593,28 +642,21 @@ export async function validateScript(
cwd: string
): Promise<ScriptValidationResult> {
const issues: ValidationIssue[] = [];
const scriptsDir = resolve(cwd, '.archon', 'scripts');
// Find the script file (any supported extension)
const allExtensions = ['.ts', '.js', '.py'];
let foundPath: string | null = null;
let detectedRuntime: ScriptRuntime | null = null;
// Look up across repo + home scopes (repo wins). discoverScriptsForCwd handles
// both 1-depth subfolders and the repo/home precedence.
const scripts = await discoverScriptsForCwd(cwd);
const entry = scripts.get(scriptName);
for (const ext of allExtensions) {
const candidate = join(scriptsDir, `${scriptName}${ext}`);
if (await fileExists(candidate)) {
foundPath = candidate;
detectedRuntime = ext === '.py' ? 'uv' : 'bun';
break;
}
}
const foundPath = entry?.path ?? null;
const detectedRuntime = entry?.runtime ?? null;
if (!foundPath || !detectedRuntime) {
issues.push({
level: 'error',
field: 'file',
message: `Script '${scriptName}' not found in .archon/scripts/`,
hint: `Create .archon/scripts/${scriptName}.ts (bun) or .archon/scripts/${scriptName}.py (uv)`,
message: `Script '${scriptName}' not found in .archon/scripts/ or ~/.archon/scripts/`,
hint: `Create .archon/scripts/${scriptName}.ts (bun) or .archon/scripts/${scriptName}.py (uv). Place at ~/.archon/scripts/ to share across repos.`,
});
return { scriptName, valid: false, issues };
}

View file

@ -6,6 +6,15 @@
* full discoverWorkflows entry point.
*
* Imports parseWorkflow from loader.ts (parsing concern stays there).
*
* Scopes (precedence lowest highest):
* 1. `bundled` embedded in the Archon binary (or read from the app's
* defaults folder in source mode).
* 2. `global` home-scoped at `~/.archon/workflows/`. Applies to every
* repo; discovered automatically (no caller option needed).
* 3. `project` repo-local at `<cwd>/.archon/workflows/`.
*
* Same-named files at a higher scope override those at lower scopes.
*/
import { readFile, readdir, access, stat } from 'fs/promises';
import { join } from 'path';
@ -27,16 +36,64 @@ function getLog(): ReturnType<typeof createLogger> {
return cachedLog;
}
/**
* One-time deprecation warning for the pre-refactor `~/.archon/.archon/workflows/`
* location. Scoped to the process so the warning fires exactly once regardless
* of how many times discovery runs.
*
* The legacy path is ONLY probed for detection workflows placed there are not
* read. Users migrate manually via the `mv` command printed in the warning.
* Exported so tests can reset it between cases.
*/
let hasWarnedLegacyHomePath = false;
export function resetLegacyHomeWarningForTests(): void {
hasWarnedLegacyHomePath = false;
}
async function maybeWarnLegacyHomePath(): Promise<void> {
if (hasWarnedLegacyHomePath) return;
// Set the flag eagerly so concurrent discovery calls (e.g. parallel codebase
// resolution at server startup) can't both pass the guard and double-warn.
hasWarnedLegacyHomePath = true;
const legacyPath = archonPaths.getLegacyHomeWorkflowsPath();
const newPath = archonPaths.getHomeWorkflowsPath();
try {
await access(legacyPath);
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') return; // happy path — legacy location not in use
// EACCES/EPERM/EIO: directory exists but we can't read it. Surface at WARN
// so the user sees it — silent debug would hide a real permission issue.
getLog().warn({ err, legacyPath }, 'workflow.legacy_home_path_probe_error');
return;
}
// Legacy directory exists — surface an actionable migration hint exactly once.
const moveCommand = `mv "${legacyPath}" "${newPath}" && rmdir "${join(archonPaths.getArchonHome(), '.archon')}"`;
getLog().warn({ legacyPath, newPath, moveCommand }, 'workflow.legacy_home_path_detected');
}
interface DirLoadResult {
workflows: Map<string, WorkflowDefinition>;
errors: WorkflowLoadError[];
}
/**
* Load workflows from a directory (recursively includes subdirectories).
* Maximum subfolder depth we descend into when discovering workflows/commands/scripts.
*
* `1` allows one level of grouping (e.g. `.archon/workflows/defaults/foo.yaml`);
* `0` would mean only files at the root. We stop at 1 deliberately deeper
* nesting has never been part of the documented convention and adds no
* organizational value, just routing ambiguity.
*/
const MAX_DISCOVERY_DEPTH = 1;
/**
* Load workflows from a directory, descending at most `MAX_DISCOVERY_DEPTH`
* folders deep. Files deeper than the cap are silently skipped.
* Failures are per-file: one broken file does not abort loading the rest.
*/
async function loadWorkflowsFromDir(dirPath: string): Promise<DirLoadResult> {
async function loadWorkflowsFromDir(dirPath: string, depth = 0): Promise<DirLoadResult> {
const workflows = new Map<string, WorkflowDefinition>();
const errors: WorkflowLoadError[] = [];
@ -50,8 +107,11 @@ async function loadWorkflowsFromDir(dirPath: string): Promise<DirLoadResult> {
const entryStat = await stat(entryPath);
if (entryStat.isDirectory()) {
// Recursively load from subdirectories
const subResult = await loadWorkflowsFromDir(entryPath);
// Only descend if we're still within the depth cap. Past the cap,
// subdirectories are ignored (same convention as the paths-package
// `findMarkdownFilesRecursive` depth cap).
if (depth >= MAX_DISCOVERY_DEPTH) continue;
const subResult = await loadWorkflowsFromDir(entryPath, depth + 1);
for (const [filename, workflow] of subResult.workflows) {
workflows.set(filename, workflow);
}
@ -125,17 +185,24 @@ function loadBundledWorkflows(): DirLoadResult {
}
/**
* Discover and load workflows from codebase
* Loads from both app's bundled defaults and repo's workflow folder.
* Repo workflows override app defaults by exact filename match.
* Discover and load workflows from codebase.
*
* When running as a compiled binary, defaults are loaded from the bundled
* content embedded at compile time. When running with Bun, defaults are
* loaded from the filesystem.
* Loads three scopes in order (later overrides earlier by filename):
* 1. Bundled defaults (unless `options.loadDefaults === false`).
* 2. Home-scoped `~/.archon/workflows/` classified as `source: 'global'`.
* No caller option: every caller gets home-scoped discovery for free.
* 3. Repo-scoped `<cwd>/.archon/workflows/` classified as `source: 'project'`.
*
* When running as a compiled binary, bundled defaults are loaded from embedded
* content. In source/dev mode they're loaded from the filesystem.
*
* Migration: if the retired `~/.archon/.archon/workflows/` path exists, the
* first call per process logs a WARN with the exact `mv` command. The legacy
* location is not read users must migrate manually.
*/
export async function discoverWorkflows(
cwd: string,
options?: { globalSearchPath?: string; loadDefaults?: boolean }
options?: { loadDefaults?: boolean }
): Promise<WorkflowLoadResult> {
// Map of filename -> workflow+source for deduplication
const workflowsByFile = new Map<string, WorkflowWithSource>();
@ -182,36 +249,32 @@ export async function discoverWorkflows(
}
}
// 2. Load from global search path (e.g., ~/.archon/.archon/workflows/ for orchestrator)
if (options?.globalSearchPath) {
const [globalWorkflowFolder] = archonPaths.getWorkflowFolderSearchPaths();
const globalWorkflowPath = join(options.globalSearchPath, globalWorkflowFolder);
getLog().debug({ globalWorkflowPath }, 'searching_global_workflows');
try {
await access(globalWorkflowPath);
const globalResult = await loadWorkflowsFromDir(globalWorkflowPath);
for (const [filename, workflow] of globalResult.workflows) {
if (workflowsByFile.has(filename)) {
getLog().debug({ filename }, 'global_workflow_overrides_default');
}
// NOTE: Global workflows (~/.archon/.archon/workflows/) are classified as 'project'
// rather than a separate 'global' source. This is an intentional scope decision for
// the initial source badge feature — a 'global' source variant can be added later.
workflowsByFile.set(filename, { workflow, source: 'project' });
}
allErrors.push(...globalResult.errors);
getLog().info({ count: globalResult.workflows.size }, 'global_workflows_loaded');
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
getLog().warn({ err, globalWorkflowPath }, 'global_workflows_access_error');
} else {
getLog().debug({ globalWorkflowPath }, 'global_workflows_not_found');
// 2. Load home-scoped workflows from ~/.archon/workflows/. No caller option —
// discovery is responsible for surfacing home-scoped content everywhere.
await maybeWarnLegacyHomePath();
const homeWorkflowPath = archonPaths.getHomeWorkflowsPath();
getLog().debug({ homeWorkflowPath }, 'searching_home_workflows');
try {
await access(homeWorkflowPath);
const homeResult = await loadWorkflowsFromDir(homeWorkflowPath);
for (const [filename, workflow] of homeResult.workflows) {
if (workflowsByFile.has(filename)) {
getLog().debug({ filename }, 'home_workflow_overrides_bundled');
}
workflowsByFile.set(filename, { workflow, source: 'global' });
}
allErrors.push(...homeResult.errors);
getLog().info({ count: homeResult.workflows.size }, 'home_workflows_loaded');
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') {
getLog().warn({ err, homeWorkflowPath }, 'home_workflows_access_error');
} else {
getLog().debug({ homeWorkflowPath }, 'home_workflows_not_found');
}
}
// 3. Load from repo's workflow folder (overrides app defaults by exact filename)
// 3. Load from repo's workflow folder (overrides app defaults AND home scope by exact filename)
const [workflowFolder] = archonPaths.getWorkflowFolderSearchPaths();
const workflowPath = join(cwd, workflowFolder);
@ -221,7 +284,7 @@ export async function discoverWorkflows(
await access(workflowPath);
const repoResult = await loadWorkflowsFromDir(workflowPath);
// Repo workflows override app defaults by exact filename match.
// Repo workflows override bundled AND home scope by exact filename match.
// Preserve 'bundled' source for workflows loaded from the defaults/ subdirectory
// that were already registered as bundled in step 1.
for (const [filename, workflow] of repoResult.workflows) {
@ -233,7 +296,10 @@ export async function discoverWorkflows(
workflowsByFile.set(filename, { workflow, source: 'bundled' });
} else {
if (existing) {
getLog().debug({ filename }, 'repo_workflow_overrides_default');
getLog().debug(
{ filename, overriddenSource: existing.source },
'repo_workflow_overrides_lower_scope'
);
}
workflowsByFile.set(filename, { workflow, source: 'project' });
}
@ -290,8 +356,7 @@ export async function discoverWorkflows(
*/
export async function discoverWorkflowsWithConfig(
cwd: string,
loadConfig: (cwd: string) => Promise<{ defaults?: { loadDefaultWorkflows?: boolean } }>,
options?: { globalSearchPath?: string }
loadConfig: (cwd: string) => Promise<{ defaults?: { loadDefaultWorkflows?: boolean } }>
): Promise<WorkflowLoadResult> {
let loadDefaults = true;
try {
@ -303,5 +368,5 @@ export async function discoverWorkflowsWithConfig(
'config_load_failed_using_default_workflow_discovery'
);
}
return discoverWorkflows(cwd, { ...options, loadDefaults });
return discoverWorkflows(cwd, { loadDefaults });
}