From 7be4d0a35ed39481ec484de42f63a923a1d42e11 Mon Sep 17 00:00:00 2001 From: Rasmus Widing <152263317+Wirasm@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:45:32 +0300 Subject: [PATCH] feat(paths,workflows): unify ~/.archon/{workflows,commands,scripts} + drop globalSearchPath (closes #1136) (#1315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- CHANGELOG.md | 9 + CLAUDE.md | 12 +- packages/cli/src/commands/workflow.test.ts | 10 +- packages/cli/src/commands/workflow.ts | 8 +- .../src/orchestrator/orchestrator-agent.ts | 8 +- .../src/orchestrator/orchestrator.test.ts | 5 +- packages/core/src/utils/commands.ts | 16 +- .../docs/guides/authoring-workflows.md | 2 +- .../content/docs/guides/global-workflows.md | 119 +++++-- .../src/content/docs/reference/cli.md | 2 +- .../content/docs/reference/configuration.md | 5 + packages/paths/src/archon-paths.test.ts | 85 +++++ packages/paths/src/archon-paths.ts | 65 +++- packages/paths/src/index.ts | 4 + packages/server/src/routes/api.ts | 31 +- .../src/routes/schemas/workflow.schemas.ts | 9 +- .../components/workflows/CommandPicker.tsx | 6 +- .../src/components/workflows/NodePalette.tsx | 22 ++ packages/web/src/lib/api.generated.d.ts | 2 +- packages/workflows/package.json | 2 +- packages/workflows/src/dag-executor.ts | 46 ++- packages/workflows/src/executor-shared.ts | 79 +++-- .../workflows/src/load-command-prompt.test.ts | 115 +++++++ packages/workflows/src/loader.test.ts | 301 +++++++++++++----- packages/workflows/src/schemas/workflow.ts | 11 +- .../workflows/src/script-discovery.test.ts | 114 ++++++- packages/workflows/src/script-discovery.ts | 54 +++- packages/workflows/src/validator.test.ts | 55 ++++ packages/workflows/src/validator.ts | 130 +++++--- packages/workflows/src/workflow-discovery.ts | 149 ++++++--- 30 files changed, 1203 insertions(+), 273 deletions(-) create mode 100644 packages/workflows/src/load-command-prompt.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 52947fee..d49c5b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: `/.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 `/.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 `/.archon/.env` (repo scope, overrides user) at boot, both with `override: true`. A new `[archon] loaded N keys from ` line is emitted per source (only when N > 0). `[archon] stripped N keys from (...)` 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 `/.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 `.archon-backup-` 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 diff --git a/CLAUDE.md b/CLAUDE.md index 65cd98cb..f2afd41e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index d7a40306..6eb2aed5 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -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).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 () => { diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 4c28edcb..7e281db7 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -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 { 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( diff --git a/packages/core/src/orchestrator/orchestrator-agent.ts b/packages/core/src/orchestrator/orchestrator-agent.ts index d5eb9397..af748846 100644 --- a/packages/core/src/orchestrator/orchestrator-agent.ts +++ b/packages/core/src/orchestrator/orchestrator-agent.ts @@ -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 { 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) ); }); diff --git a/packages/core/src/utils/commands.ts b/packages/core/src/utils/commands.ts index ae87cbf6..8204b5d7 100644 --- a/packages/core/src/utils/commands.ts +++ b/packages/core/src/utils/commands.ts @@ -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({ diff --git a/packages/docs-web/src/content/docs/guides/authoring-workflows.md b/packages/docs-web/src/content/docs/guides/authoring-workflows.md index 0cc24630..55f1a64d 100644 --- a/packages/docs-web/src/content/docs/guides/authoring-workflows.md +++ b/packages/docs-web/src/content/docs/guides/authoring-workflows.md @@ -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. diff --git a/packages/docs-web/src/content/docs/guides/global-workflows.md b/packages/docs-web/src/content/docs/guides/global-workflows.md index 7494a905..282881e3 100644 --- a/packages/docs-web/src/content/docs/guides/global-workflows.md +++ b/packages/docs-web/src/content/docs/guides/global-workflows.md @@ -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 `/.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** -- `/.archon/workflows/`, `/.archon/commands/`, `/.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 + +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/`. diff --git a/packages/docs-web/src/content/docs/reference/cli.md b/packages/docs-web/src/content/docs/reference/cli.md index c0fede61..adf0471c 100644 --- a/packages/docs-web/src/content/docs/reference/cli.md +++ b/packages/docs-web/src/content/docs/reference/cli.md @@ -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:** diff --git a/packages/docs-web/src/content/docs/reference/configuration.md b/packages/docs-web/src/content/docs/reference/configuration.md index 06ce6ec5..11506517 100644 --- a/packages/docs-web/src/content/docs/reference/configuration.md +++ b/packages/docs-web/src/content/docs/reference/configuration.md @@ -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 `/.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/) ``` diff --git a/packages/paths/src/archon-paths.test.ts b/packages/paths/src/archon-paths.test.ts index 73451637..b6584810 100644 --- a/packages/paths/src/archon-paths.test.ts +++ b/packages/paths/src/archon-paths.test.ts @@ -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; diff --git a/packages/paths/src/archon-paths.ts b/packages/paths/src/archon-paths.ts index 0b12050e..d6db7cf6 100644 --- a/packages/paths/src/archon-paths.ts +++ b/packages/paths/src/archon-paths.ts @@ -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 `/.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 `/.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 `/.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({ diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index d4a00db6..443d55ff 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -8,6 +8,10 @@ export { getArchonConfigPath, getArchonEnvPath, getRepoArchonEnvPath, + getHomeWorkflowsPath, + getHomeCommandsPath, + getHomeScriptsPath, + getLegacyHomeWorkflowsPath, getCommandFolderSearchPaths, getWorkflowFolderSearchPaths, getAppArchonBasePath, diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 1684a9b7..2ba79154 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -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(); // 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'); } diff --git a/packages/server/src/routes/schemas/workflow.schemas.ts b/packages/server/src/routes/schemas/workflow.schemas.ts index 40fb9497..ef35030e 100644 --- a/packages/server/src/routes/schemas/workflow.schemas.ts +++ b/packages/server/src/routes/schemas/workflow.schemas.ts @@ -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 diff --git a/packages/web/src/components/workflows/CommandPicker.tsx b/packages/web/src/components/workflows/CommandPicker.tsx index 153b3c56..84fb8aee 100644 --- a/packages/web/src/components/workflows/CommandPicker.tsx +++ b/packages/web/src/components/workflows/CommandPicker.tsx @@ -119,9 +119,9 @@ export function CommandPicker({ {cmd.source} diff --git a/packages/web/src/components/workflows/NodePalette.tsx b/packages/web/src/components/workflows/NodePalette.tsx index d54c8196..3bec3062 100644 --- a/packages/web/src/components/workflows/NodePalette.tsx +++ b/packages/web/src/components/workflows/NodePalette.tsx @@ -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 && ( + <> +

+ Global (~/.archon/commands/) +

+ {global.map((cmd: CommandEntry) => ( +
{ + 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" + > + CMD + {cmd.name} +
+ ))} + + )} + {bundled.length > 0 && ( <>

diff --git a/packages/web/src/lib/api.generated.d.ts b/packages/web/src/lib/api.generated.d.ts index a474ca31..68b4d0a0 100644 --- a/packages/web/src/lib/api.generated.d.ts +++ b/packages/web/src/lib/api.generated.d.ts @@ -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']; diff --git a/packages/workflows/package.json b/packages/workflows/package.json index 1c0e8951..e442e864 100644 --- a/packages/workflows/package.json +++ b/packages/workflows/package.json @@ -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": { diff --git a/packages/workflows/src/dag-executor.ts b/packages/workflows/src/dag-executor.ts index 2abc259d..63e4d6ca 100644 --- a/packages/workflows/src/dag-executor.ts +++ b/packages/workflows/src/dag-executor.ts @@ -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: /.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>; + 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); diff --git a/packages/workflows/src/executor-shared.ts b/packages/workflows/src/executor-shared.ts index 255895a5..75f67dfa 100644 --- a/packages/workflows/src/executor-shared.ts +++ b/packages/workflows/src/executor-shared.ts @@ -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'); } } } diff --git a/packages/workflows/src/load-command-prompt.test.ts b/packages/workflows/src/load-command-prompt.test.ts new file mode 100644 index 00000000..75fc0928 --- /dev/null +++ b/packages/workflows/src/load-command-prompt.test.ts @@ -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'); + }); +}); diff --git a/packages/workflows/src/loader.test.ts b/packages/workflows/src/loader.test.ts index 573e7208..eff8a6d8 100644 --- a/packages/workflows/src/loader.test.ts +++ b/packages/workflows/src/loader.test.ts @@ -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 }); + } }); }); diff --git a/packages/workflows/src/schemas/workflow.ts b/packages/workflows/src/schemas/workflow.ts index fea1b0e8..589c6a0b 100644 --- a/packages/workflows/src/schemas/workflow.ts +++ b/packages/workflows/src/schemas/workflow.ts @@ -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 `/.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 { diff --git a/packages/workflows/src/script-discovery.test.ts b/packages/workflows/src/script-discovery.test.ts index 18bc9c58..2171d0a9 100644 --- a/packages/workflows/src/script-discovery.test.ts +++ b/packages/workflows/src/script-discovery.test.ts @@ -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 /.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(); diff --git a/packages/workflows/src/script-discovery.ts b/packages/workflows/src/script-discovery.ts index ce74b1a3..7ba8be05 100644 --- a/packages/workflows/src/script-discovery.ts +++ b/packages/workflows/src/script-discovery.ts @@ -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 + scripts: Map, + depth = 0 ): Promise { 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> { @@ -119,6 +134,33 @@ export async function discoverScripts(dir: string): Promise/.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> { + const homeScripts = await discoverScripts(getHomeScriptsPath()); + const repoScripts = await discoverScripts(join(cwd, '.archon', 'scripts')); + + // Start with home, overlay repo (repo wins) + const merged = new Map(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. diff --git a/packages/workflows/src/validator.test.ts b/packages/workflows/src/validator.test.ts index 6b391f54..bd2b418e 100644 --- a/packages/workflows/src/validator.test.ts +++ b/packages/workflows/src/validator.test.ts @@ -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 { + 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); + }); + }); }); // ============================================================================= diff --git a/packages/workflows/src/validator.ts b/packages/workflows/src/validator.ts index ab4c4bee..88cebeef 100644 --- a/packages/workflows/src/validator.ts +++ b/packages/workflows/src/validator.ts @@ -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 { 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 { const names = new Set(); - // 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 { + 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 — `/.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 { - // 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 { 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 }; } diff --git a/packages/workflows/src/workflow-discovery.ts b/packages/workflows/src/workflow-discovery.ts index bcd5d531..188ca9d7 100644 --- a/packages/workflows/src/workflow-discovery.ts +++ b/packages/workflows/src/workflow-discovery.ts @@ -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 `/.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 { 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 { + 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; 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 { +async function loadWorkflowsFromDir(dirPath: string, depth = 0): Promise { const workflows = new Map(); const errors: WorkflowLoadError[] = []; @@ -50,8 +107,11 @@ async function loadWorkflowsFromDir(dirPath: string): Promise { 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 `/.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 { // Map of filename -> workflow+source for deduplication const workflowsByFile = new Map(); @@ -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 { 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 }); }