diff --git a/.claude/rules/adapters.md b/.claude/rules/adapters.md deleted file mode 100644 index d49e683378..0000000000 --- a/.claude/rules/adapters.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -paths: - - "packages/adapters/**/*.ts" ---- - -# Adapters Conventions - -## Key Patterns - -- **Auth is inside adapters** — every adapter checks authorization before calling `onMessage()`. Silent rejection (no error response), log with masked user ID: `userId.slice(0, 4) + '***'`. -- **Whitelist parsing in constructor** — parse env var (`SLACK_ALLOWED_USER_IDS`, `TELEGRAM_ALLOWED_USER_IDS`, `GITHUB_ALLOWED_USERS`) using a co-located `parseAllowedUserIds()` / `parseAllowedUsers()` function. Empty list = open access. -- **Lazy logger pattern** — ALL adapter files use a module-level `cachedLog` + `getLog()` getter so test mocks intercept `createLogger` before the logger is instantiated. Never initialize logger at module scope. -- **Two handler patterns** (both valid): - - **Chat adapters** (Slack, Telegram, Discord): `onMessage(handler)` — adapter owns the event loop (polling/WebSocket), fires registered callback. Lock manager lives in the server's callback closure. Errors handled by caller via `createMessageErrorHandler`. - - **Forge adapters** (GitHub, Gitea): `handleWebhook(payload, signature)` — server HTTP route calls directly, returns 200 immediately. Full pipeline inside adapter (signature verification, repo cloning, command loading, context building). Lock manager injected in constructor. Errors caught internally and posted to issue/PR. -- **Message splitting** — use shared `splitIntoParagraphChunks(message, maxLength)` from `../../utils/message-splitting`. Two-pass: paragraph breaks first, then line breaks. Limits: Slack 12000, Telegram 4096, GitHub 65000. -- **`ensureThread()` is often a no-op** — Slack returns the same ID (already encoded as `channel:ts`), Telegram has no threads, GitHub issues are inherently threaded. - -## Conversation ID Formats - -| Platform | Format | Example | -|----------|--------|---------| -| Slack | `channel:thread_ts` | `C123ABC:1234567890.123456` | -| Telegram | numeric chat ID as string | `"1234567890"` | -| GitHub | `owner/repo#number` | `"acme/api#42"` | -| Web | user-provided string | `"my-chat"` | -| Discord | channel ID string | `"987654321098765432"` | - -## Architecture - -- All chat adapters implement `IPlatformAdapter` from `@archon/core` -- GitHub adapter is webhook-based (no polling); Slack/Telegram/Discord use polling -- GitHub adapter holds its own `ConversationLockManager` (injected in constructor) -- Slack conversation ID encodes both channel and thread: `sendMessage()` splits on `:` to extract `thread_ts` -- GitHub adapter adds `` marker to prevent self-triggering loops -- GitHub only responds to `issue_comment.created` events — NOT `issues.opened` / `pull_request.opened` (descriptions contain documentation, not commands; see #96) - -## Anti-patterns - -- Never put auth logic outside the adapter (no auth middleware in server routes) -- Never throw from `onMessage` handlers; errors surface to the caller -- Never call `sendMessage()` with a raw token or credential string in the message -- Never use the generic `exec` — always use `execFileAsync` for subprocess calls -- Never add a new adapter method to `IPlatformAdapter` unless ALL adapters need it; use optional methods (`sendStructuredEvent?`) for platform-specific capabilities diff --git a/.claude/rules/cli.md b/.claude/rules/cli.md deleted file mode 100644 index a954b6bd18..0000000000 --- a/.claude/rules/cli.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -paths: - - "packages/cli/**/*.ts" ---- - -# CLI Conventions - -## Commands - -```bash -# Workflow commands (require git repo) -bun run cli workflow list [--json] -bun run cli workflow run [message] [--branch ] [--from-branch ] [--no-worktree] [--resume] -bun run cli workflow status [runId] - -# Isolation commands -bun run cli isolation list -bun run cli isolation cleanup [days] # default: 7 days -bun run cli isolation cleanup --merged # removes merged branches + remote refs -bun run cli complete [--force] # full lifecycle: worktree + local/remote branches - -# Interactive -bun run cli chat [--cwd ] - -# Setup -bun run cli setup -bun run cli version -``` - -## Startup Behavior - -1. `@archon/paths/strip-cwd-env-boot` (first import) removes all Bun-auto-loaded CWD `.env` keys from `process.env` -2. Loads `~/.archon/.env` with `override: true` (Archon config wins over shell-inherited vars) -3. Smart Claude auth default: if no `CLAUDE_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`, sets `CLAUDE_USE_GLOBAL_AUTH=true` -4. Imports all commands AFTER dotenv setup - -## WorkflowRunOptions Interface - -```typescript -interface WorkflowRunOptions { - branchName?: string; // Explicit branch name for the worktree - fromBranch?: string; // Override base branch (start-point for worktree) - noWorktree?: boolean; // Opt out of isolation, run in live checkout - resume?: boolean; // Reuse worktree from last failed run -} -``` - -**Default behavior**: Creates worktree with auto-generated branch name (`archon/task-{workflow}-{timestamp}`). - -**Mutually exclusive** (enforced in both `cli.ts` pre-flight and `workflowRunCommand`): -- `--branch` + `--no-worktree` -- `--from` + `--no-worktree` -- `--resume` + `--branch` - -- `--branch feature-auth` → creates/reuses worktree for that branch -- (no flags) → creates worktree with auto-generated `archon/task-*` branch (isolation by default) -- `--no-worktree` → runs directly in live checkout (opt-out of isolation) -- `--from dev` → overrides the start-point for new worktree (works with or without `--branch`) -- `--resume` → resumes last run for this conversation (mutually exclusive with `--branch`) - -## Git Repo Requirement - -Workflow and isolation commands resolve CWD to the git repo root. Run from within a git repository (subdirectories work). The CLI calls `git rev-parse --show-toplevel` to find the root. - -## Conversation ID Format - -CLI generates: `cli-{timestamp}-{random6}` (e.g., `cli-1703123456789-a7f3bc`) - -## Port Allocation - -Worktree-aware: same hash-based algorithm as server (3190–4089 range). Running `bun dev` in a worktree auto-allocates a unique port. Same worktree always gets same port. - -## CLIAdapter - -The `CLIAdapter` implements `IPlatformAdapter`. It streams output to stdout. `getStreamingMode()` defaults to `'batch'` (configurable via constructor options). No auth needed — CLI is local only. - -## Architecture - -- `@archon/cli` depends on `@archon/core`, `@archon/workflows`, `@archon/git`, `@archon/isolation`, `@archon/paths` -- Uses `createWorkflowDeps()` from `@archon/core/workflows/store-adapter` to build workflow deps -- Database shared with server (same `~/.archon/archon.db` or `DATABASE_URL`) -- Conversation lifecycle: create → run workflow → persist messages (same DB as web UI) - -## Anti-patterns - -- Never run CLI commands without being inside a git repository (workflow/isolation commands will fail) -- Never set `DATABASE_URL` in `~/.archon/.env` to point at a target app's database -- Never use `--force` on `complete` unless branch is truly safe to delete (skips uncommitted check) -- Never add interactive prompts inside CLI commands — use flags for all options (non-interactive tool) diff --git a/.claude/rules/database.md b/.claude/rules/database.md deleted file mode 100644 index 0f579cc1a2..0000000000 --- a/.claude/rules/database.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -paths: - - "packages/core/src/db/**/*.ts" - - "migrations/**/*.sql" ---- - -# Database Conventions - -## 7 Tables (all prefixed `remote_agent_`) - -| Table | Purpose | -|-------|---------| -| `remote_agent_conversations` | Platform conversations, soft-delete (`deleted_at`), title, `hidden` flag | -| `remote_agent_sessions` | AI SDK sessions with `parent_session_id` audit chain, `transition_reason` | -| `remote_agent_codebases` | Repository metadata, `commands` JSONB | -| `remote_agent_isolation_environments` | Git worktree tracking, `workflow_type`, `workflow_id` | -| `remote_agent_workflow_runs` | Execution state, `working_path`, `last_activity_at` | -| `remote_agent_workflow_events` | Step-level event log per run | -| `remote_agent_messages` | Conversation history, tool call metadata as JSONB | - -## IDatabase Interface - -Auto-detects at startup: PostgreSQL if `DATABASE_URL` set, SQLite (`~/.archon/archon.db`) otherwise. - -```typescript -import { pool, getDialect } from './connection'; // pool = IDatabase instance - -// $1, $2 placeholders work for both PostgreSQL and SQLite -const result = await pool.query( - 'SELECT * FROM remote_agent_conversations WHERE id = $1', - [id] -); -const row = result.rows[0]; // rows is readonly T[] -``` - -Use `getDialect()` for dialect-specific expressions: `dialect.generateUuid()`, `dialect.now()`, `dialect.jsonMerge(col, paramIdx)`, `dialect.jsonArrayContains(col, path, paramIdx)`, `dialect.nowMinusDays(paramIdx)`. - -## Import Pattern — Namespaced Exports - -```typescript -// Use namespace imports for DB modules (consistent project-wide pattern) -import * as conversationDb from '@archon/core/db/conversations'; -import * as sessionDb from '@archon/core/db/sessions'; -import * as codebaseDb from '@archon/core/db/codebases'; -import * as workflowDb from '@archon/core/db/workflows'; -import * as messageDb from '@archon/core/db/messages'; -``` - -## INSERT Error Handling - -```typescript -try { - const result = await pool.query('INSERT INTO remote_agent_conversations ...', params); - return result.rows[0]; -} catch (error) { - log.error({ err: error, params }, 'db_insert_failed'); - throw new Error('Failed to create conversation'); -} -``` - -## UPDATE with rowCount Verification - -`updateConversation()` and similar throw `ConversationNotFoundError` / `SessionNotFoundError` when `rowCount === 0`. Callers must handle: - -```typescript -try { - await db.updateConversation(conversationId, { codebase_id: codebaseId }); -} catch (error) { - if (error instanceof ConversationNotFoundError) { - // Handle missing conversation specifically - } - throw error; // Re-throw unexpected errors -} -``` - -## Session Audit Trail - -Sessions are immutable. Every new session links back: `parent_session_id` → previous session, `transition_reason: TransitionTrigger`. Query the chain to understand history. `active = true` means the current session. - -## Soft Delete - -Conversations use soft-delete: `deleted_at IS NULL` filter should be included in all user-facing queries. `hidden = true` conversations are worker conversations (background workflows) — excluded from UI listings. - -## Anti-patterns - -- Never `SELECT *` in production queries on large tables — select specific columns -- Never write raw SQL strings in application code outside `packages/core/src/db/` modules -- Never bypass the `IDatabase` interface to call database drivers directly from other packages -- Never assume `rows[0]` exists without null-checking — queries can return empty arrays -- Never use `RETURNING *` in UPDATE when only checking success — check `rowCount` instead diff --git a/.claude/rules/dx-quirks.md b/.claude/rules/dx-quirks.md deleted file mode 100644 index 3d05e1f843..0000000000 --- a/.claude/rules/dx-quirks.md +++ /dev/null @@ -1,22 +0,0 @@ -# DX Quirks - -## Bun Log Elision - -When running `bun dev` from repo root, `--filter` truncates logs to `[N lines elided]`. -To see full logs: `cd packages/server && bun --watch src/index.ts` or `bun --cwd packages/server run dev`. - -## mock.module() Pollution - -`mock.module()` is process-global and irreversible — `mock.restore()` does NOT undo it. -Never add `afterAll(() => mock.restore())` for `mock.module()` cleanup. -Use `spyOn()` for internal modules (spy.mockRestore() DOES work). -When adding tests with `mock.module()`, ensure package.json runs it in a separate `bun test` invocation. - -## Worktree Port Allocation - -Worktrees auto-allocate ports (3190-4089 range, hash-based on path). Same worktree always gets same port. -Main repo defaults to 3090. Override: `PORT=4000 bun dev`. - -## bun run test vs bun test - -NEVER run `bun test` from repo root — it discovers all test files across packages in one process, causing ~135 mock pollution failures. Always use `bun run test` (which uses `bun --filter '*' test` for per-package isolation). diff --git a/.claude/rules/isolation-patterns.md b/.claude/rules/isolation-patterns.md deleted file mode 100644 index 0e763e03a2..0000000000 --- a/.claude/rules/isolation-patterns.md +++ /dev/null @@ -1,40 +0,0 @@ -# Isolation Architecture Patterns - -## Core Design - -- ALL isolation logic is centralized in the orchestrator — adapters are thin -- Every @mention auto-creates a worktree (simplicity > efficiency; worktrees are cheap) -- Data model is work-centric (`isolation_environments` table), enabling cross-platform sharing -- Cleanup is a separate service using git-first checks - -## Directory Structure - -``` -~/.archon/workspaces/owner/repo/ -├── source/ # Clone or symlink to local path -├── worktrees/ # Git worktrees for this project -├── artifacts/ # Workflow artifacts (NEVER in git) -│ ├── runs/{id}/ # Per-run artifacts ($ARTIFACTS_DIR) -│ └── uploads/{convId}/ # Web UI file uploads (ephemeral) -└── logs/ # Workflow execution logs -``` - -## Resolution Flow - -1. Adapter provides `IsolationHints` (conversationId, workflowId, branch preference) -2. Orchestrator's `validateAndResolveIsolation()` resolves hints → environment -3. WorktreeProvider creates worktree if needed, syncs with origin first -4. Environment tracked in `isolation_environments` table - -## Key Packages - -- `@archon/isolation` (`packages/isolation/src/`) — types, providers, resolver, error classifiers -- `@archon/git` (`packages/git/src/`) — branch, worktree, repo operations -- `@archon/paths` (`packages/paths/src/`) — path resolution utilities - -## Safety Rules - -- NEVER run `git clean -fd` — permanently deletes untracked files -- Use `classifyIsolationError()` to map git errors to user-friendly messages -- Trust git's natural guardrails (refuse to remove worktree with uncommitted changes) -- Use `execFileAsync` (not `exec`) when calling git directly diff --git a/.claude/rules/isolation.md b/.claude/rules/isolation.md deleted file mode 100644 index 1b849e7eca..0000000000 --- a/.claude/rules/isolation.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -paths: - - "packages/isolation/**/*.ts" - - "packages/git/**/*.ts" ---- - -# Isolation & Git Conventions - -## Branded Types (packages/git/src/types.ts) - -Always use the branded constructors — they reject empty strings at runtime and prevent passing the wrong string type: - -```typescript -import { toRepoPath, toBranchName, toWorktreePath } from '@archon/git'; -import type { RepoPath, BranchName, WorktreePath } from '@archon/git'; - -const repo = toRepoPath('/home/user/owner/repo'); // RepoPath -const branch = toBranchName('feature-auth'); // BranchName -const wt = toWorktreePath('/home/.archon/worktrees/x'); // WorktreePath -``` - -Git operations return `GitResult` discriminated union: `{ ok: true; value: T }` or `{ ok: false; error: GitError }`. Always check `.ok` before accessing `.value`. - -## IsolationResolver — 7-Step Resolution Order - -1. **Existing env** — use `existingEnvId` if worktree still exists on disk -2. **No codebase** — skip isolation entirely, return `status: 'none'` -3. **Workflow reuse** — find active env with same `(codebaseId, workflowType, workflowId)` -4. **Linked issue sharing** — PR can reuse the worktree from a linked issue -5. **PR branch adoption** — find existing worktree by branch name (`findWorktreeByBranch`) -6. **Limit check + auto-cleanup** — if at `maxWorktrees` (default 25), try `makeRoom()` first -7. **Create new** — call `provider.create(isolationRequest)` then `store.create()` - -If `store.create()` fails after `provider.create()` succeeds, the orphaned worktree is cleaned up best-effort before re-throwing. - -## Error Handling Pattern - -```typescript -import { classifyIsolationError, isKnownIsolationError } from '@archon/isolation'; - -try { - await provider.create(request); -} catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - if (!isKnownIsolationError(err)) { - throw err; // Unknown = programming bug, propagate as crash - } - const userMessage = classifyIsolationError(err); // Maps to friendly message - // ...send userMessage to platform, return blocked resolution -} -``` - -Known error patterns: `permission denied`, `eacces`, `timeout`, `no space left`, `enospc`, `not a git repository`, `branch not found`. - -`IsolationBlockedError` signals ALL message handling should stop — the user has already been notified. - -## Git Safety Rules - -- **NEVER run `git clean -fd`** — permanently deletes untracked files. Use `git checkout .` instead. -- **Always use `execFileAsync`** (from `@archon/git/exec`), never `exec` or `execSync` -- `hasUncommittedChanges()` returns `true` on unexpected errors (conservative — prevents data loss) -- Worktree paths follow project-scoped layout: `~/.archon/workspaces/{owner}/{repo}/worktrees/{branch}` - -## Architecture - -- `@archon/git` — zero `@archon/*` dependencies; only branded types and `execFileAsync` wrapper -- `@archon/isolation` — depends only on `@archon/git` + `@archon/paths` -- `IIsolationStore` interface injected into `IsolationResolver` — never call DB directly from git package -- `IIsolationProvider` interface — `WorktreeProvider` is the only implementation -- Stale env cleanup is best-effort: `markDestroyedBestEffort()` logs errors but never throws - -## Anti-patterns - -- Never call `git` via `exec()` or shell string — always `execFileAsync('git', [...args])` -- Never treat `IsolationBlockedError` as recoverable — it means user was notified, stop processing -- Never use a plain `string` where `RepoPath` / `BranchName` / `WorktreePath` is expected -- Never skip the `isKnownIsolationError()` check — unknown errors must propagate as crashes diff --git a/.claude/rules/orchestrator.md b/.claude/rules/orchestrator.md deleted file mode 100644 index acc3d64fa0..0000000000 --- a/.claude/rules/orchestrator.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -paths: - - "packages/core/src/orchestrator/**/*.ts" - - "packages/core/src/handlers/**/*.ts" - - "packages/core/src/state/**/*.ts" ---- - -# Orchestrator Conventions - -## Message Flow — Routing Agent Architecture - -``` -Platform message - → ConversationLockManager.acquireLock() - → handleMessage() (orchestrator-agent.ts:383) - → inheritThreadContext() — copy parent's codebase/cwd if child thread - → Deterministic gate: 10 commands (help, status, reset, workflow, register-project, update-project, remove-project, commands, init, worktree) - → Everything else → AI routing call: - → listCodebases() + discoverAllWorkflows() - → buildFullPrompt() → buildOrchestratorPrompt() or buildProjectScopedPrompt() - → AI responds with natural language ± /invoke-workflow or /register-project - → parseOrchestratorCommands() extracts structured commands from AI response - → If /invoke-workflow found → dispatchOrchestratorWorkflow() - → If /register-project found → handleRegisterProject() - → Otherwise → send AI text to user -``` - -Lock manager returns `{ status: 'started' | 'queued-conversation' | 'queued-capacity' }`. Always use the return value to decide whether to emit a "queued" notice — never call `isActive()` separately (TOCTOU race). - -## Deterministic Commands (command-handler.ts) - -Only **10 commands** are handled deterministically: - -| Command | Behavior | -|---------|----------| -| `/help` | Show available commands | -| `/status` | Show conversation/session state | -| `/reset` | Deactivate current session | -| `/workflow` | Subcommands: `list`, `run`, `status`, `cancel`, `reload` | -| `/register-project` | Handled inline — creates codebase DB record | -| `/update-project` | Handled inline — updates codebase path | -| `/remove-project` | Handled inline — deletes codebase DB record | -| `/commands` | List registered codebase commands | -| `/init` | Scaffold `.archon/` in current repo | -| `/worktree` | Worktree subcommands | - -**All other slash commands fall through to the AI router.** Unrecognized commands return an "Unknown command" error. - -## Routing AI — Prompt Building (prompt-builder.ts) - -The choice between prompts depends on whether the conversation has an attached project: - -- **No project** → `buildOrchestratorPrompt()` (prompt-builder.ts:116) — lists all projects equally, asks user to clarify if ambiguous -- **Has project** → `buildProjectScopedPrompt()` (prompt-builder.ts:153) — active project shown first, ambiguous requests default to it - -Both prompts include: registered projects, discovered workflows, and the `/invoke-workflow` + `/register-project` format specification. - -### `/invoke-workflow` Protocol - -The AI emits: `/invoke-workflow --project --prompt "user's intent"` - -`parseOrchestratorCommands()` (orchestrator-agent.ts:90) parses this with: -- Workflow name validated against discovered workflows via `findWorkflow()` -- Project name validated via `findCodebaseByName()` — case-insensitive, supports partial path segment match (e.g., `"repo"` matches `"owner/repo"`) -- `--project` must appear before `--prompt` - -### `filterToolIndicators()` (orchestrator-agent.ts:163) - -Batch mode only. Strips paragraphs starting with emoji tool indicators (🔧💭📝✏️🗑️📂🔍) from accumulated AI response before sending to user. - -## Session Transitions - -Sessions are **immutable** — never mutated, only deactivated and replaced. The audit trail is via `parent_session_id` + `transition_reason`. - -**Only `plan-to-execute` immediately creates a new session.** All other triggers only deactivate; the new session is created on the next AI message. - -```typescript -import { getTriggerForCommand, shouldCreateNewSession } from '../state/session-transitions'; - -const trigger = getTriggerForCommand('reset'); // 'reset-requested' -if (shouldCreateNewSession(trigger)) { - // plan-to-execute only -} -``` - -`TransitionTrigger` values: `'first-message'`, `'plan-to-execute'`, `'isolation-changed'`, `'reset-requested'`, `'worktree-removed'`, `'conversation-closed'`. - -## Isolation Resolution - -`validateAndResolveIsolation()` (orchestrator.ts:108) delegates to `IsolationResolver` and handles: -- Sending contextual messages to the platform (e.g., "Reusing worktree from issue #42") -- Updating the DB (`conversation.isolation_env_id`, `conversation.cwd`) -- Retrying once when a stale reference is found (`stale_cleaned`) -- Throwing `IsolationBlockedError` after platform notification when blocked - -When isolation is blocked, **stop all further processing** — `IsolationBlockedError` means the user was already notified. - -## Background Workflow Dispatch (Web only) - -`dispatchBackgroundWorkflow()` (orchestrator.ts:256) creates a hidden worker conversation (`web-worker-{timestamp}-{random}`), sets up event bridging from worker SSE → parent SSE, pre-creates the workflow run row (prevents 404 on immediate UI navigation), and fires-and-forgets `executeWorkflow()`. On completion, surfaces `result.summary` to the parent conversation. - -## Lazy Logger Pattern - -All files in this area use the deferred logger pattern — NEVER initialize at module scope: - -```typescript -let cachedLog: ReturnType | undefined; -function getLog(): ReturnType { - if (!cachedLog) cachedLog = createLogger('orchestrator'); - return cachedLog; -} -``` - -## Anti-patterns - -- Never call `isActive()` and then `acquireLock()` — race condition, use the lock return value -- Never access `conversation.isolation_env_id` directly without going through the resolver -- Never skip `IsolationBlockedError` — it must propagate to stop all further message handling -- Never add platform-specific logic to the orchestrator; it uses `IPlatformAdapter` interface only -- Never transition sessions by mutating them; always deactivate and create a new linked session -- Never assume a slash command is deterministic — only the 10 listed above bypass the AI router diff --git a/.claude/rules/server-api.md b/.claude/rules/server-api.md deleted file mode 100644 index 912e7db877..0000000000 --- a/.claude/rules/server-api.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -paths: - - "packages/server/**/*.ts" ---- - -# Server API Conventions - -## Hono Framework - -```typescript -import { Hono } from 'hono'; -import { streamSSE } from 'hono/streaming'; -import { cors } from 'hono/cors'; - -// CORS: allow-all for single-developer tool (override with WEB_UI_ORIGIN) -app.use('/api/*', cors({ origin: process.env.WEB_UI_ORIGIN || '*' })); - -// Error response helper pattern -function apiError(c: Context, status: 400 | 404 | 500, message: string): Response { - return c.json({ error: message }, status); -} -``` - -## SSE Streaming - -Always check `stream.closed` before writing. Use `stream.onAbort()` for cleanup. Hono's `streamSSE` callback receives an SSE writer: - -```typescript -app.get('/api/stream/:id', (c) => { - return streamSSE(c, async (stream) => { - stream.onAbort(() => { - transport.removeStream(conversationId, writer); - }); - // Write events: - if (!stream.closed) { - await stream.writeSSE({ data: JSON.stringify(event) }); - } - }); -}); -``` - -`SSETransport` in `src/adapters/web/transport.ts` manages the stream registry. `removeStream()` accepts an `expectedStream` reference to prevent race conditions (StrictMode double-mount). - -## Webhook Signature Verification - -```typescript -// ALWAYS use c.req.text() for raw webhook body — JSON.parse separately -const payload = await c.req.text(); -const signature = c.req.header('X-Hub-Signature-256') ?? ''; - -// timingSafeEqual prevents timing attacks -const hmac = createHmac('sha256', webhookSecret); -const digest = 'sha256=' + hmac.update(payload).digest('hex'); -const isValid = timingSafeEqual(Buffer.from(digest), Buffer.from(signature)); -``` - -Return 200 immediately for webhook events; process async. Never log the full signature. - -## Auto Port Allocation (Worktrees) - -`getPort()` from `@archon/core` returns: -- Main repo: `PORT` env var or `3090` -- Worktrees: hash-based port in range 3190–4089 (deterministic per worktree path) - -Same worktree always gets same port. Override with `PORT=4000` env var. - -## Static SPA Fallback - -```typescript -// Serve web dist; fall back to index.html for client-side routing -app.use('/*', serveStatic({ root: path.join(import.meta.dir, '../../web/dist') })); -app.get('*', (c) => c.html(/* index.html */)); -``` - -Use `import.meta.dir` (absolute) NOT relative paths — `bun --filter @archon/server start` changes CWD to `packages/server/`. - -## Graceful Shutdown - -```typescript -process.on('SIGTERM', () => { - stopCleanupScheduler(); - void pool.close(); - process.exit(0); -}); -``` - -## Key API Routes - -| Method | Path | Purpose | -|--------|------|---------| -| GET | `/api/conversations` | List conversations | -| POST | `/api/conversations` | Create conversation | -| POST | `/api/conversations/:id/message` | Send message | -| GET | `/api/stream/:id` | SSE stream | -| GET | `/api/workflows` | List workflows | -| POST | `/api/workflows/validate` | Validate YAML (in-memory) | -| GET | `/api/workflows/:name` | Get single workflow | -| PUT | `/api/workflows/:name` | Save workflow | -| DELETE | `/api/workflows/:name` | Delete workflow | -| GET | `/api/commands` | List commands | -| POST | `/webhooks/github` | GitHub webhook | - -## Anti-patterns - -- Never use `c.req.json()` for webhooks — signature must be verified against raw body -- Never expose API keys in JSON error responses -- Never serve static files with relative paths (use `import.meta.dir`) -- Never skip the `stream.closed` check before writing SSE -- Never call platform adapters directly from route handlers — use `handleMessage()` + lock manager diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md deleted file mode 100644 index 8ba5bc192e..0000000000 --- a/.claude/rules/testing.md +++ /dev/null @@ -1,105 +0,0 @@ ---- -paths: - - "**/*.test.ts" - - "**/*.spec.ts" ---- - -# Testing Conventions - -## CRITICAL: mock.module() Pollution Rules - -`mock.module()` permanently replaces modules in the **process-wide module cache**. `mock.restore()` does NOT undo it ([oven-sh/bun#7823](https://github.com/oven-sh/bun/issues/7823)). - -**Rules:** -1. **Never add `afterAll(() => mock.restore())` for `mock.module()` calls** — it has no effect -2. **Never have two test files `mock.module()` the same path with different implementations in the same `bun test` invocation** -3. **Use `spyOn()` for internal modules** — `spy.mockRestore()` DOES work for spies - -```typescript -// CORRECT: spy (restorable) -import * as git from '@archon/git'; -const spy = spyOn(git, 'checkout'); -spy.mockImplementation(async () => ({ ok: true, value: undefined })); -// afterEach: -spy.mockRestore(); - -// CORRECT: mock.module() for external deps (not restorable — isolate in separate test file) -mock.module('@slack/bolt', () => ({ App: mock(() => mockApp), LogLevel: { INFO: 'info' } })); -``` - -## Test Batching Per Package - -Each package splits tests into separate `bun test` invocations to prevent pollution: - -| Package | Batches | -|---------|---------| -| `@archon/core` | 7 batches (providers, handlers, db+utils, path-validation, cleanup-service, title-generator, workflows, orchestrator) | -| `@archon/workflows` | 5 batches | -| `@archon/adapters` | 3 batches (chat+community+forge-auth, github-adapter, github-context) | -| `@archon/isolation` | 3 batches | - -**Never run `bun test` from the repo root** — causes ~135 mock pollution failures. Always use: - -```bash -bun run test # Correct: per-package isolation via bun --filter '*' test -bun run test --watch # Watch mode (single package) -``` - -## Mock Pattern for Lazy Loggers - -All adapter/db/orchestrator files use lazy logger pattern. Mock before import: - -```typescript -// MUST come before import of the module under test -const mockLogger = { - fatal: mock(() => undefined), error: mock(() => undefined), - warn: mock(() => undefined), info: mock(() => undefined), - debug: mock(() => undefined), trace: mock(() => undefined), -}; -mock.module('@archon/paths', () => ({ createLogger: mock(() => mockLogger) })); - -import { SlackAdapter } from './adapter'; // Import AFTER mock -``` - -## Database Test Mocking - -```typescript -import { createQueryResult, mockPostgresDialect } from '../test/mocks/database'; - -const mockQuery = mock(() => Promise.resolve(createQueryResult([]))); -mock.module('./connection', () => ({ - pool: { query: mockQuery }, - getDialect: () => mockPostgresDialect, -})); - -// In tests: -mockQuery.mockResolvedValueOnce(createQueryResult([existingRow])); -mockQuery.mockClear(); // in beforeEach -``` - -## Test Structure - -```typescript -import { describe, test, expect, mock, beforeEach, afterEach } from 'bun:test'; - -describe('ComponentName', () => { - beforeEach(() => { - mockFn.mockClear(); // Reset call counts - }); - - test('does thing when condition', async () => { - mockQuery.mockResolvedValueOnce(createQueryResult([fixture])); - const result = await functionUnderTest(input); - expect(result).toEqual(expected); - expect(mockQuery).toHaveBeenCalledTimes(1); - }); -}); -``` - -## Anti-patterns - -- Never `import` a module before all `mock.module()` calls for its dependencies -- Never use `afterAll(() => mock.restore())` for `mock.module()` — it silently does nothing -- Never test with real database or filesystem in unit tests — always mock -- Never run `bun test` from the repo root -- Never add a new test file with conflicting `mock.module()` to an existing batch — create a new batch in the package's `package.json` test script diff --git a/.claude/rules/web-frontend.md b/.claude/rules/web-frontend.md deleted file mode 100644 index 7811997fde..0000000000 --- a/.claude/rules/web-frontend.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -paths: - - "packages/web/**/*.tsx" - - "packages/web/**/*.ts" - - "packages/web/**/*.css" ---- - -# Web Frontend Conventions - -## Tech Stack - -- React 19 + Vite 6 + TypeScript -- Tailwind CSS v4 (CSS-first config) -- shadcn/ui components -- TanStack Query v5 for REST data -- React Router v7 (`react-router`, NOT `react-router-dom`) -- Manual `EventSource` for SSE streaming (no library) -- **Dark theme only** — no light mode toggle - -## Tailwind v4 Critical Differences - -```css -/* CORRECT: CSS-first import */ -@import 'tailwindcss'; -@import 'tw-animate-css'; /* NOT tailwindcss-animate */ - -/* CORRECT: theme variables in @theme inline block */ -@theme inline { - --color-surface: var(--surface); - --color-accent-bright: var(--accent-bright); -} - -/* WRONG: never use @tailwind base/components/utilities */ -``` - -Plugin in `vite.config.ts`: `import tailwindcss from '@tailwindcss/vite'` — uses Vite plugin, **not PostCSS**. `components.json` has blank `tailwind.config` for v4. - -## Color Palette (oklch) - -All custom colors are OKLCH. Key tokens (defined in `:root` in `index.css`): -- `--surface` (0.18): main surface -- `--surface-elevated` (0.22): cards, popovers -- `--background` (0.14): page background -- `--primary` / `--ring`: blue accent at oklch(0.65 0.18 250) -- `--text-primary` (0.93), `--text-secondary` (0.65), `--text-tertiary` (0.45) -- `--success` (green 155), `--warning` (yellow 75), `--error` (red 25) - -Use CSS variables via Tailwind utilities: `bg-surface`, `text-text-primary`, `border-border`, `text-accent-bright`, etc. - -## SSE Streaming Pattern - -`useSSE()` in `src/hooks/useSSE.ts` is the single SSE consumer. It: -- Opens `EventSource` to `/api/stream/{conversationId}` -- Batches text events (50ms flush timer) to reduce re-renders -- Flushes immediately before `tool_call`, `tool_result`, `workflow_dispatch` events -- Marks disconnected only on `CLOSED` state (not `CONNECTING` — avoids flicker) -- `handlersRef` pattern ensures stable EventSource with fresh handlers - -Event types: `text`, `tool_call`, `tool_result`, `error`, `conversation_lock`, `session_info`, `workflow_step`, `workflow_status`, `parallel_agent`, `workflow_artifact`, `dag_node`, `workflow_dispatch`, `workflow_output_preview`, `warning`, `retract`, `heartbeat`. - -## Routing - -```tsx -// CORRECT -import { BrowserRouter, Routes, Route } from 'react-router'; -// WRONG -import { BrowserRouter } from 'react-router-dom'; -``` - -Routes: `/` (Dashboard), `/chat`, `/chat/*`, `/workflows`, `/workflows/builder`, `/workflows/runs/:runId`, `/settings`. - -## API Client Pattern - -```typescript -// src/lib/api.ts exports SSE_BASE_URL and REST functions -import { SSE_BASE_URL } from '@/lib/api'; -// In dev: Vite proxies /api/* to localhost:{VITE_API_PORT} -// API port injected at build time: import.meta.env.VITE_API_PORT -``` - -TanStack Query `staleTime: 10_000`, `refetchOnWindowFocus: true`. - -## Anti-patterns - -- Never add a light mode — dark-only is intentional -- Never use `react-router-dom` — use `react-router` (v7) -- Never configure Tailwind in `tailwind.config.js/ts` — v4 is CSS-first -- Never use `tailwindcss-animate` — use `tw-animate-css` -- Never open a second `EventSource` per conversation — `useSSE()` handles it -- Never pass inline style objects for theme colors — use Tailwind classes with CSS variables diff --git a/.claude/rules/workflows.md b/.claude/rules/workflows.md deleted file mode 100644 index d28f4fd4ff..0000000000 --- a/.claude/rules/workflows.md +++ /dev/null @@ -1,101 +0,0 @@ ---- -paths: - - "packages/workflows/**/*.ts" - - ".archon/workflows/**/*.yaml" - - ".archon/commands/**/*.md" ---- - -# Workflows Conventions - -## DAG Workflow Format - -All workflows use the DAG (Directed Acyclic Graph) format with `nodes:`. Loop nodes are supported as a node type within DAGs. - -```yaml -nodes: - - id: classify - prompt: "Is this a bug or feature? Answer JSON: {type: 'BUG'|'FEATURE'}" - output_format: {type: object, properties: {type: {type: string}}} - - id: implement - command: execute - depends_on: [classify] - when: "$classify.output.type == 'FEATURE'" - - id: run_lint - bash: "bun run lint" - depends_on: [implement] - - id: iterate - loop: - until: "COMPLETE" - max_iterations: 10 - prompt: "Iterate until the tests pass. Signal COMPLETE when done." - depends_on: [run_lint] -``` - -## Variable Substitution - -| Variable | Resolved to | -|----------|-------------| -| `$1`, `$2`, `$3` | Positional arguments from user message | -| `$ARGUMENTS` | All user arguments as single string | -| `$ARTIFACTS_DIR` | Pre-created external artifacts directory | -| `$WORKFLOW_ID` | Current workflow run ID | -| `$BASE_BRANCH` | Base branch from config or auto-detected | -| `$DOCS_DIR` | Documentation directory path (default: `docs/`) | -| `$nodeId.output` | Captured stdout/AI output from completed DAG node | - -## WorkflowDeps — Dependency Injection - -`@archon/workflows` has ZERO `@archon/core` dependency. Everything is injected: - -```typescript -interface WorkflowDeps { - store: IWorkflowStore; // DB abstraction - getAgentProvider: AgentProviderFactory; // Returns claude or codex provider - loadConfig: (cwd: string) => Promise; -} - -// Core creates the adapter: -import { createWorkflowDeps } from '@archon/core/workflows/store-adapter'; -const deps = createWorkflowDeps(); -await executeWorkflow(deps, platform, conversationId, cwd, workflow, ...); -``` - -## DAG Node Types - -- `command:` — named file from `.archon/commands/`, AI-executed -- `prompt:` — inline prompt string, AI-executed -- `bash:` — shell script, no AI; stdout captured as `$nodeId.output`; default timeout 120000ms -- `script:` — inline code or named file from `.archon/scripts/`, runs via `runtime: bun` (`.ts`/`.js`) or `runtime: uv` (`.py`), no AI; stdout captured as `$nodeId.output`; supports `deps:` for dependency installation and `timeout:` (ms); runtime availability checked at load time with a warning if binary is missing - -DAG node options: `depends_on`, `when` (condition expression), `trigger_rule` (`all_success` | `one_success` | `none_failed_min_one_success` | `all_done`), `output_format` (JSON Schema, Claude only), `allowed_tools` / `denied_tools` (Claude only), `idle_timeout` (ms), `context: 'fresh'`, per-node `provider` and `model`, `deps` (script nodes only — dependency list), `runtime` (script nodes only — `'bun'` or `'uv'`). - -## Event Emitter for Observability - -```typescript -import { getWorkflowEventEmitter } from '@archon/workflows'; - -const emitter = getWorkflowEventEmitter(); -emitter.registerRun(runId, conversationId); - -// Subscribe (returns unsubscribe fn) -const unsubscribe = emitter.subscribeForConversation(conversationId, (event) => { - // event.type: 'step_started' | 'step_completed' | 'node_started' | ... -}); -``` - -Listener errors never propagate to the executor — fire-and-forget with internal catch. - -## Architecture - -- Model validation at load time — invalid provider/model combinations fail `parseWorkflow()` with clear error -- Resilient discovery — one broken YAML doesn't abort `discoverWorkflows()`; errors returned in `WorkflowLoadResult.errors` -- Bundled defaults embedded in binary builds; loaded from filesystem in source builds -- Repo workflows override bundled defaults by name -- Router fallback: if no `/invoke-workflow` produced → falls back to `archon-assist`; raw AI response only when `archon-assist` unavailable - -## Anti-patterns - -- Never import `@archon/core` from `@archon/workflows` (circular dependency) -- Never add `clearContext: true` to every step — context continuity is valuable; use sparingly -- Never put `output_format` on Codex nodes — it logs a warning and is ignored -- Never set `allowed_tools: undefined` expecting "no tools" — use `allowed_tools: []` for that diff --git a/CLAUDE.md b/CLAUDE.md index 49a3f3369f..1541841583 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ bun test --watch # Watch mode (single package) bun test packages/core/src/handlers/command-handler.test.ts # Single file ``` -**Test isolation (mock.module pollution):** Bun's `mock.module()` permanently replaces modules in the process-wide cache — `mock.restore()` does NOT undo it ([oven-sh/bun#7823](https://github.com/oven-sh/bun/issues/7823)). To prevent cross-file pollution, packages that have conflicting `mock.module()` calls split their tests into separate `bun test` invocations: `@archon/core` (7 batches), `@archon/workflows` (5), `@archon/adapters` (4), `@archon/isolation` (3). See each package's `package.json` for the exact splits. +**Test isolation (mock.module pollution):** Bun's `mock.module()` permanently replaces modules in the process-wide cache — `mock.restore()` does NOT undo it ([oven-sh/bun#7823](https://github.com/oven-sh/bun/issues/7823)). To prevent cross-file pollution, packages that have conflicting `mock.module()` calls split their tests into separate `bun test` invocations: `@archon/core` (7 batches), `@archon/workflows` (5), `@archon/adapters` (3), `@archon/isolation` (3). See each package's `package.json` for the exact splits. **Do NOT run `bun test` from the repo root** — it discovers all test files across all packages and runs them in one process, causing ~135 mock pollution failures. Always use `bun run test` (which uses `bun --filter '*' test` for per-package isolation). @@ -429,7 +429,8 @@ import type { DagNode, WorkflowDefinition } from '@/lib/api'; **2. Command Handler** (`packages/core/src/handlers/`) - Process slash commands (deterministic, no AI) -- Commands: `/command-set`, `/load-commands`, `/clone`, `/getcwd`, `/setcwd`, `/repos`, `/repo`, `/repo-remove`, `/worktree`, `/workflow`, `/status`, `/commands`, `/help`, `/reset`, `/reset-context`, `/init` +- The orchestrator treats only these top-level commands as deterministic: `/help`, `/status`, `/reset`, `/workflow`, `/register-project`, `/update-project`, `/remove-project`, `/commands`, `/init`, `/worktree` +- `/workflow` handles subcommands like `list`, `run`, `status`, `cancel`, `resume`, `abandon`, `approve`, `reject` - Update database, perform operations, return responses **3. Orchestrator** (`packages/core/src/orchestrator/`) @@ -530,7 +531,7 @@ curl http://localhost:3637/api/conversations//messages ``` ~/.archon/ ├── workspaces/owner/repo/ # Project-centric layout -│ ├── source/ # Clone (from /clone) or symlink → local path +│ ├── source/ # Cloned repo or symlink → local path │ ├── worktrees/ # Git worktrees for this project │ ├── artifacts/ # Workflow artifacts (NEVER in git) │ │ ├── runs/{id}/ # Per-run artifacts ($ARTIFACTS_DIR) @@ -675,8 +676,8 @@ async function createSession(conversationId: string, codebaseId: string) { 1. **Codebase Commands** (per-repo): - Stored in `.archon/commands/` (plain text/markdown) - - Auto-detected via `/clone` or `/load-commands ` - - Loaded by `/clone` or `/load-commands`, invoked by AI via orchestrator routing + - Discovered from the repository `.archon/commands/` directory + - Surfaced via `GET /api/commands` for the workflow builder and invoked by workflow `command:` nodes 2. **Workflows** (YAML-based): - Stored in `.archon/workflows/` (searched recursively) @@ -762,6 +763,9 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er - `POST /api/codebases` - Register a codebase (clone or local path); body accepts `allowEnvKeys` for the env-leak gate - `PATCH /api/codebases/:id` - Flip the `allow_env_keys` consent bit; body: `{ allowEnvKeys: boolean }`. Audit-logged at `warn` level on every grant/revoke (`env_leak_consent_granted` / `env_leak_consent_revoked`) with `codebaseId`, `path`, `files`, `keys`, `scanStatus`, `actor` - `DELETE /api/codebases/:id` - Delete a codebase and clean up resources +- `GET /api/codebases/:id/env` - List env var keys for a codebase (never returns values) +- `PUT /api/codebases/:id/env` / `DELETE /api/codebases/:id/env/:key` - Upsert / delete a single codebase env var +- `GET /api/codebases/:id/environments` - List tracked isolation environments for a codebase **Artifact Files:** - `GET /api/artifacts/:runId/*` - Serve a workflow artifact file by run ID and relative path; returns `text/markdown` for `.md` files, `text/plain` otherwise; 400 on path traversal (`..`), 404 if run or file not found @@ -770,6 +774,7 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er - `GET /api/commands` - List available command names (bundled + project-defined); optional `?cwd=`; returns `{ commands: [{ name, source: 'bundled' | 'project' }] }` **System:** +- `GET /api/health` - Health check with adapter/system status - `GET /api/update-check` - Check for available updates; returns `{ updateAvailable, currentVersion, latestVersion, releaseUrl }`; skips GitHub API call for non-binary builds **OpenAPI Spec:**