Deprecate database command templates (#425)

* Fix: Deprecate database command templates (#231)

Database command templates still influenced routing and /command-invoke, preventing file-based commands from fully replacing them.

Changes:
- Remove template routing, handlers, and DB module
- Add .archon/commands fallback for /command-invoke
- Drop template table from schema/migrations and update docs/tests

Fixes #231

* docs: update CLAUDE.md and README.md for template deprecation

Remove all references to deprecated database command templates:
- Update table count from 8 to 7 tables
- Remove command_templates table from database schema sections
- Remove "Command Templates (Global)" section from README
- Remove /template-add, /template-delete, /templates command docs
- Update "Custom command templates" to "Custom commands"
- Remove references to global templates stored in database

* Fix review findings: error handling, tests, migration guidance

- Fix commandFileExists to only catch ENOENT, throw on unexpected
  errors (permissions, I/O) instead of silently returning false
- Add test for file read failure after successful existence check
- Add test for path traversal rejection via isValidCommandName
- Add test for registered command taking precedence over fallback
- Add migration guidance comments for users with existing templates
This commit is contained in:
Rasmus Widing 2026-02-17 16:55:17 +02:00 committed by GitHub
parent d3a8fc0823
commit 7dc2e444f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 167 additions and 474 deletions

View file

@ -8,7 +8,7 @@
- No multi-tenant complexity
- Commands versioned with Git (not stored in database)
- All credentials in environment variables only
- 8-table database schema (see Database Schema section)
- 7-table database schema (see Database Schema section)
**User-Controlled Workflows**
- Manual phase transitions via slash commands
@ -342,21 +342,19 @@ import * as core from '@archon/core'; // Don't do this
### Database Schema
**8 Tables (all prefixed with `remote_agent_`):**
**7 Tables (all prefixed with `remote_agent_`):**
1. **`codebases`** - Repository metadata and commands (JSONB)
2. **`conversations`** - Track platform conversations with titles and soft-delete support
3. **`sessions`** - Track AI SDK sessions with resume capability
4. **`command_templates`** - Global command templates (manually added via `/template-add`)
5. **`isolation_environments`** - Git worktree isolation tracking
6. **`workflow_runs`** - Workflow execution tracking and state
7. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors)
8. **`messages`** - Conversation message history with tool call metadata (JSONB)
4. **`isolation_environments`** - Git worktree isolation tracking
5. **`workflow_runs`** - Workflow execution tracking and state
6. **`workflow_events`** - Step-level workflow event log (step transitions, artifacts, errors)
7. **`messages`** - Conversation message history with tool call metadata (JSONB)
**Key Patterns:**
- Conversation ID format: Platform-specific (`thread_ts`, `chat_id`, `user/repo#123`)
- One active session per conversation
- Codebase commands stored in filesystem, paths in `codebases.commands` JSONB
- Global templates stored in database, added via `/template-add`
**Session Transitions:**
- Sessions are immutable - transitions create new linked sessions
@ -513,7 +511,7 @@ All Archon-managed files are organized under a dedicated namespace:
**Repo-level (`.archon/` in any repository):**
```
.archon/
├── commands/ # Custom command templates
├── commands/ # Custom commands
├── workflows/ # Future: workflow definitions
└── config.yaml # Repo-specific configuration
```
@ -658,12 +656,7 @@ log.warn({ envVar: 'MISSING_KEY' }, 'optional_config_missing');
- Auto-detected via `/clone` or `/load-commands <folder>`
- Invoked via `/command-invoke <name> [args]`
2. **Global Templates** (database):
- Stored in `remote_agent_command_templates` table
- Added via `/template-add <name> <file-path>`
- Invoked directly via `/<name> [args]`
3. **Workflows** (YAML-based):
2. **Workflows** (YAML-based):
- Stored in `.archon/workflows/` (searched recursively)
- Multi-step AI execution chains, discovered at runtime
- Provider inherited from `.archon/config.yaml` unless explicitly set

View file

@ -268,11 +268,10 @@ DATABASE_URL=postgresql://user:password@host:5432/dbname
psql $DATABASE_URL < migrations/000_combined.sql
```
This creates 8 tables:
This creates 7 tables:
- `remote_agent_codebases` - Repository metadata
- `remote_agent_conversations` - Platform conversation tracking
- `remote_agent_sessions` - AI session management
- `remote_agent_command_templates` - Global command templates
- `remote_agent_isolation_environments` - Worktree isolation tracking
- `remote_agent_workflow_runs` - Workflow execution tracking
- `remote_agent_workflow_events` - Step-level workflow event log
@ -820,15 +819,6 @@ docker compose --profile with-db down # If using Option B
Once your platform adapter is running, you can use these commands. Type `/help` to see this list.
#### Command Templates (Global)
| Command | Description |
|---------|-------------|
| `/<name> [args]` | Invoke a template directly (e.g., `/plan "Add dark mode"`) |
| `/templates` | List all available templates |
| `/template-add <name> <path>` | Add template from file |
| `/template-delete <name>` | Remove a template |
#### Codebase Commands (Per-Project)
| Command | Description |
@ -1231,10 +1221,9 @@ prompt: |
┌─────────────────────────────────────────────────────────┐
│ SQLite / PostgreSQL (8 Tables) │
│ SQLite / PostgreSQL (7 Tables) │
│ Codebases • Conversations • Sessions • Workflow Runs │
│ Command Templates • Isolation Environments • Messages │
│ Workflow Events │
│ Isolation Environments • Messages • Workflow Events │
└─────────────────────────────────────────────────────────┘
```
@ -1252,7 +1241,7 @@ prompt: |
### Database Schema
<details>
<summary><b>8 tables with `remote_agent_` prefix</b></summary>
<summary><b>7 tables with `remote_agent_` prefix</b></summary>
1. **`remote_agent_codebases`** - Repository metadata
- Commands stored as JSONB: `{command_name: {path, description}}`
@ -1269,25 +1258,21 @@ prompt: |
- Session ID for resume capability
- Metadata JSONB for command context
4. **`remote_agent_command_templates`** - Global command templates
- Shared command definitions (like `/plan`, `/commit`)
- Available across all codebases
5. **`remote_agent_isolation_environments`** - Worktree isolation
4. **`remote_agent_isolation_environments`** - Worktree isolation
- Tracks git worktrees per issue/PR
- Enables worktree sharing between linked issues and PRs
6. **`remote_agent_workflow_runs`** - Workflow execution tracking
5. **`remote_agent_workflow_runs`** - Workflow execution tracking
- Tracks active workflows per conversation
- Prevents concurrent workflow execution
- Stores workflow state, step progress, and parent conversation linkage
7. **`remote_agent_workflow_events`** - Step-level workflow event log
6. **`remote_agent_workflow_events`** - Step-level workflow event log
- Records step transitions, artifacts, and errors per workflow run
- Lean UI-relevant events (verbose logs stored in JSONL files)
- Enables workflow run detail views and debugging
8. **`remote_agent_messages`** - Conversation message history
7. **`remote_agent_messages`** - Conversation message history
- Persists user and assistant messages with timestamps
- Stores tool call metadata (name, input, duration) in JSONB
- Enables message history in Web UI across page refreshes
@ -1359,7 +1344,8 @@ psql $DATABASE_URL -c "SELECT 1"
docker compose exec postgres psql -U postgres -d remote_coding_agent -c "\dt"
# Should show: remote_agent_codebases, remote_agent_conversations, remote_agent_sessions,
# remote_agent_command_templates, remote_agent_isolation_environments
# remote_agent_isolation_environments, remote_agent_workflow_runs, remote_agent_workflow_events,
# remote_agent_messages
```
### Clone Command Fails

View file

@ -1040,12 +1040,6 @@ remote_agent_sessions
├── transition_reason (TEXT) -- Why this session was created (TransitionTrigger)
└── metadata (JSONB) -- {lastCommand: "plan-feature", ...}
remote_agent_command_templates
├── id (UUID)
├── name (VARCHAR, UNIQUE)
├── description (TEXT)
└── content (TEXT)
remote_agent_isolation_environments
├── id (UUID)
├── codebase_id (UUID → remote_agent_codebases.id)

View file

@ -34,7 +34,7 @@ Archon is the unified directory and configuration system for the remote-coding-a
```
any-repo/.archon/
├── commands/ # Custom command templates
├── commands/ # Custom commands
│ ├── plan.md
│ └── execute.md
├── workflows/ # Future: workflow definitions
@ -43,7 +43,7 @@ any-repo/.archon/
```
**Purpose:**
- `commands/` - Slash command templates (auto-loaded on clone)
- `commands/` - Slash commands (auto-loaded on clone)
- `workflows/` - Future workflow engine definitions
- `config.yaml` - Project-specific settings

View file

@ -21,7 +21,7 @@ Archon supports a layered configuration system with sensible defaults, optional
```
.archon/
├── commands/ # Custom command templates
├── commands/ # Custom commands
│ └── plan.md
├── workflows/ # Future: workflow definitions
└── config.yaml # Repo-specific configuration (optional)

View file

@ -107,7 +107,7 @@ Add an `.archon/` directory to your target repo for repo-specific behavior:
your-repo/
└── .archon/
├── config.yaml # AI assistant, worktree copy rules
├── commands/ # Custom command templates (.md files)
├── commands/ # Custom commands (.md files)
└── workflows/ # Custom multi-step workflows (.yaml files)
```

View file

@ -55,21 +55,6 @@ CREATE TABLE IF NOT EXISTS remote_agent_sessions (
CREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_conversation ON remote_agent_sessions(conversation_id, active);
CREATE INDEX IF NOT EXISTS idx_remote_agent_sessions_codebase ON remote_agent_sessions(codebase_id);
-- ============================================================================
-- Migration 002: Command Templates
-- ============================================================================
CREATE TABLE IF NOT EXISTS remote_agent_command_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_remote_agent_command_templates_name ON remote_agent_command_templates(name);
-- ============================================================================
-- Migration 003: Add Worktree Support
-- ============================================================================

View file

@ -0,0 +1,11 @@
-- Migration: Drop deprecated command templates table
-- Command templates were replaced by file-based commands in .archon/commands
--
-- BREAKING CHANGE: If you have existing templates, export them before upgrading:
-- SELECT name, content FROM remote_agent_command_templates;
-- Then save each as .archon/commands/<name>.md in your repositories.
--
-- After this migration, use /command-invoke <name> instead of /<name>
DROP TABLE IF EXISTS remote_agent_command_templates;
DROP INDEX IF EXISTS idx_remote_agent_command_templates_name;

View file

@ -245,16 +245,6 @@ export class SqliteAdapter implements IDatabase {
ended_reason TEXT
);
-- Command templates table
CREATE TABLE IF NOT EXISTS remote_agent_command_templates (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
name TEXT NOT NULL UNIQUE,
description TEXT,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Isolation environments table
CREATE TABLE IF NOT EXISTS remote_agent_isolation_environments (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),

View file

@ -1,149 +0,0 @@
import { mock, describe, test, expect, beforeEach } from 'bun:test';
import { createQueryResult } from '../test/mocks/database';
import { CommandTemplate } from '../types';
const mockQuery = mock(() => Promise.resolve(createQueryResult([])));
mock.module('./connection', () => ({
pool: {
query: mockQuery,
},
}));
import {
createTemplate,
getTemplate,
getAllTemplates,
deleteTemplate,
upsertTemplate,
} from './command-templates';
describe('command-templates', () => {
beforeEach(() => {
mockQuery.mockClear();
});
const mockTemplate: CommandTemplate = {
id: 'template-123',
name: 'plan',
description: 'Create implementation plan',
content: '# Plan\n\n**Input**: $ARGUMENTS',
created_at: new Date(),
updated_at: new Date(),
};
describe('createTemplate', () => {
test('creates template with all fields', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([mockTemplate]));
const result = await createTemplate({
name: 'plan',
description: 'Create implementation plan',
content: '# Plan\n\n**Input**: $ARGUMENTS',
});
expect(result).toEqual(mockTemplate);
expect(mockQuery).toHaveBeenCalledWith(
'INSERT INTO remote_agent_command_templates (name, description, content) VALUES ($1, $2, $3) RETURNING *',
['plan', 'Create implementation plan', '# Plan\n\n**Input**: $ARGUMENTS']
);
});
test('creates template without description', async () => {
const templateWithoutDesc = { ...mockTemplate, description: null };
mockQuery.mockResolvedValueOnce(createQueryResult([templateWithoutDesc]));
await createTemplate({
name: 'plan',
content: '# Plan',
});
expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['plan', null, '# Plan']);
});
});
describe('getTemplate', () => {
test('returns existing template', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([mockTemplate]));
const result = await getTemplate('plan');
expect(result).toEqual(mockTemplate);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT * FROM remote_agent_command_templates WHERE name = $1',
['plan']
);
});
test('returns null for non-existent template', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([]));
const result = await getTemplate('non-existent');
expect(result).toBeNull();
});
});
describe('getAllTemplates', () => {
test('returns all templates ordered by name', async () => {
const templates = [mockTemplate, { ...mockTemplate, id: 'template-456', name: 'commit' }];
mockQuery.mockResolvedValueOnce(createQueryResult(templates));
const result = await getAllTemplates();
expect(result).toEqual(templates);
expect(mockQuery).toHaveBeenCalledWith(
'SELECT * FROM remote_agent_command_templates ORDER BY name'
);
});
test('returns empty array when no templates', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([]));
const result = await getAllTemplates();
expect(result).toEqual([]);
});
});
describe('deleteTemplate', () => {
test('returns true when template deleted', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([], 1));
const result = await deleteTemplate('plan');
expect(result).toBe(true);
expect(mockQuery).toHaveBeenCalledWith(
'DELETE FROM remote_agent_command_templates WHERE name = $1',
['plan']
);
});
test('returns false when template not found', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([], 0));
const result = await deleteTemplate('non-existent');
expect(result).toBe(false);
});
});
describe('upsertTemplate', () => {
test('inserts new template', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([mockTemplate]));
const result = await upsertTemplate({
name: 'plan',
description: 'Create implementation plan',
content: '# Plan',
});
expect(result).toEqual(mockTemplate);
expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('ON CONFLICT'), [
'plan',
'Create implementation plan',
'# Plan',
]);
});
});
});

View file

@ -1,66 +0,0 @@
/**
* Database operations for command templates
*/
import { pool } from './connection';
import { CommandTemplate } from '../types';
import { createLogger } from '../utils/logger';
/** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */
let cachedLog: ReturnType<typeof createLogger> | undefined;
function getLog(): ReturnType<typeof createLogger> {
if (!cachedLog) cachedLog = createLogger('db.command-templates');
return cachedLog;
}
export async function createTemplate(data: {
name: string;
description?: string;
content: string;
}): Promise<CommandTemplate> {
const result = await pool.query<CommandTemplate>(
'INSERT INTO remote_agent_command_templates (name, description, content) VALUES ($1, $2, $3) RETURNING *',
[data.name, data.description ?? null, data.content]
);
return result.rows[0];
}
export async function getTemplate(name: string): Promise<CommandTemplate | null> {
const result = await pool.query<CommandTemplate>(
'SELECT * FROM remote_agent_command_templates WHERE name = $1',
[name]
);
return result.rows[0] || null;
}
export async function getAllTemplates(): Promise<readonly CommandTemplate[]> {
const result = await pool.query<CommandTemplate>(
'SELECT * FROM remote_agent_command_templates ORDER BY name'
);
return result.rows;
}
export async function deleteTemplate(name: string): Promise<boolean> {
const result = await pool.query('DELETE FROM remote_agent_command_templates WHERE name = $1', [
name,
]);
return (result.rowCount ?? 0) > 0;
}
export async function upsertTemplate(data: {
name: string;
description?: string;
content: string;
}): Promise<CommandTemplate> {
const result = await pool.query<CommandTemplate>(
`INSERT INTO remote_agent_command_templates (name, description, content)
VALUES ($1, $2, $3)
ON CONFLICT (name) DO UPDATE SET
description = EXCLUDED.description,
content = EXCLUDED.content,
updated_at = NOW()
RETURNING *`,
[data.name, data.description ?? null, data.content]
);
getLog().debug({ templateName: data.name }, 'template_upserted');
return result.rows[0];
}

View file

@ -17,7 +17,6 @@ export type { IDatabase, SqlDialect, QueryResult } from './adapters/types';
export * as conversationDb from './conversations';
export * as codebaseDb from './codebases';
export * as sessionDb from './sessions';
export * as commandTemplateDb from './command-templates';
export * as isolationEnvDb from './isolation-environments';
export * as workflowDb from './workflows';
@ -26,6 +25,5 @@ export * from './conversations';
export * from './codebases';
export { SessionNotFoundError } from './sessions';
export * from './sessions';
export * from './command-templates';
export * from './isolation-environments';
export * from './workflows';

View file

@ -8,7 +8,6 @@ import { Conversation, CommandResult, ConversationNotFoundError } from '../types
import * as db from '../db/conversations';
import * as codebaseDb from '../db/codebases';
import * as sessionDb from '../db/sessions';
import * as templateDb from '../db/command-templates';
import { isPathWithinWorkspace } from '../utils/path-validation';
import { sanitizeError } from '../utils/credential-sanitizer';
import { listWorktrees, execFileAsync } from '../utils/git';
@ -281,12 +280,6 @@ export async function handleCommand(
success: true,
message: `## Available Commands
### Command Templates (global)
- \`/<name> [args]\` - Invoke a template directly
- \`/templates\` - List all templates
- \`/template-add <name> <path>\` - Add template from file
- \`/template-delete <name>\` - Remove a template
### Codebase Commands (per-project)
- \`/command-set <name> <path> [text]\` - Register command
- \`/load-commands <folder>\` - Bulk load (recursive)
@ -1004,84 +997,6 @@ export async function handleCommand(
}
}
case 'template-add': {
if (args.length < 2) {
return { success: false, message: 'Usage: /template-add <name> <file-path>' };
}
if (!conversation.cwd) {
return {
success: false,
message: 'No working directory set. Use /clone or /setcwd first.',
};
}
const [templateName, ...pathParts] = args;
const filePath = pathParts.join(' ');
const fullPath = resolve(conversation.cwd, filePath);
try {
const content = await readFile(fullPath, 'utf-8');
// Extract description from frontmatter if present
const frontmatterMatch = /^---\n([\s\S]*?)\n---/.exec(content);
let description: string | undefined;
if (frontmatterMatch) {
const descMatch = /description:\s*(.+)/.exec(frontmatterMatch[1]);
description = descMatch?.[1]?.trim();
}
await templateDb.upsertTemplate({
name: templateName,
description: description ?? `From ${filePath}`,
content,
});
return {
success: true,
message: `Template '${templateName}' saved!\n\nUse it with: /${templateName} [args]`,
};
} catch (error) {
const err = error as Error;
return { success: false, message: `Failed to read file: ${err.message}` };
}
}
case 'template-list':
case 'templates': {
const templates = await templateDb.getAllTemplates();
if (templates.length === 0) {
return {
success: true,
message:
'No command templates registered.\n\nUse /template-add <name> <file-path> to add one.',
};
}
let msg = 'Command Templates:\n\n';
for (const t of templates) {
msg += `/${t.name}`;
if (t.description) {
msg += ` - ${t.description}`;
}
msg += '\n';
}
msg += '\nUse /<name> [args] to invoke any template.';
return { success: true, message: msg };
}
case 'template-delete': {
if (args.length < 1) {
return { success: false, message: 'Usage: /template-delete <name>' };
}
const deleted = await templateDb.deleteTemplate(args[0]);
if (deleted) {
return { success: true, message: `Template '${args[0]}' deleted.` };
}
return { success: false, message: `Template '${args[0]}' not found.` };
}
case 'worktree': {
const subcommand = args[0];
@ -1652,11 +1567,11 @@ export async function handleCommand(
// Create example command
const exampleCommand = join(commandsDir, 'example.md');
const exampleContent = `---
description: Example command template
description: Example command
---
# Example Command
This is an example command template.
This is an example command.
Arguments:
- $1 - First positional argument

View file

@ -20,7 +20,6 @@ export {
type IsolationEnvironmentRow,
type Codebase,
type Session,
type CommandTemplate,
type CommandResult,
type IPlatformAdapter,
type IWebPlatformAdapter,
@ -46,7 +45,6 @@ export type { IDatabase, SqlDialect } from './db/adapters/types';
export * as conversationDb from './db/conversations';
export * as codebaseDb from './db/codebases';
export * as sessionDb from './db/sessions';
export * as commandTemplateDb from './db/command-templates';
export * as isolationEnvDb from './db/isolation-environments';
export * as workflowDb from './db/workflows';
export * as messageDb from './db/messages';

View file

@ -49,7 +49,6 @@ const mockTransitionSession = mock(
});
}
);
const mockGetTemplate = mock(() => Promise.resolve(null));
const mockHandleCommand = mock(() => Promise.resolve({ message: '', modified: false }));
const mockParseCommand = mock((message: string) => {
const parts = message.split(/\s+/);
@ -59,6 +58,7 @@ const mockGetAssistantClient = mock(() => null);
// Mock for reading command files (replaces fs/promises mock)
const mockReadCommandFile = mock(() => Promise.resolve(''));
const mockCommandFileExists = mock(() => Promise.resolve(false));
// Mock for workflow discovery
const mockDiscoverWorkflows = mock(() => Promise.resolve({ workflows: [], errors: [] }));
@ -132,10 +132,6 @@ mock.module('../db/sessions', () => ({
transitionSession: mockTransitionSession,
}));
mock.module('../db/command-templates', () => ({
getTemplate: mockGetTemplate,
}));
mock.module('../handlers/command-handler', () => ({
handleCommand: mockHandleCommand,
parseCommand: mockParseCommand,
@ -181,6 +177,7 @@ import * as realOrchestrator from './orchestrator';
mock.module('./orchestrator', () => ({
...realOrchestrator,
readCommandFile: mockReadCommandFile,
commandFileExists: mockCommandFileExists,
}));
import { handleMessage, wrapCommandForExecution } from './orchestrator';
@ -271,11 +268,11 @@ describe('orchestrator', () => {
mockUpdateSession.mockClear();
mockDeactivateSession.mockClear();
mockUpdateSessionMetadata.mockClear();
mockGetTemplate.mockClear();
mockHandleCommand.mockClear();
mockParseCommand.mockClear();
mockGetAssistantClient.mockClear();
mockReadCommandFile.mockClear();
mockCommandFileExists.mockClear();
mockDiscoverWorkflows.mockClear();
mockExecuteWorkflow.mockClear();
mockClient.sendQuery.mockClear();
@ -310,7 +307,7 @@ describe('orchestrator', () => {
mockGetCodebase.mockResolvedValue(mockCodebase);
mockGetActiveSession.mockResolvedValue(null);
mockCreateSession.mockResolvedValue(mockSession);
mockGetTemplate.mockResolvedValue(null); // No templates by default
mockCommandFileExists.mockResolvedValue(false);
mockGetAssistantClient.mockReturnValue(mockClient);
mockParseCommand.mockImplementation((message: string) => {
const parts = message.split(/\s+/);
@ -458,6 +455,91 @@ describe('orchestrator', () => {
);
});
test('falls back to .archon/commands when command not registered', async () => {
mockGetCodebase.mockResolvedValue({
...mockCodebase,
commands: {},
});
mockParseCommand.mockReturnValue({
command: 'command-invoke',
args: ['rca', 'do', 'thing'],
});
mockCommandFileExists.mockResolvedValue(true);
mockReadCommandFile.mockResolvedValue('Do the following: $ARGUMENTS');
mockClient.sendQuery.mockImplementation(async function* () {
yield { type: 'result', sessionId: 'new-session-id' };
});
await handleMessage(platform, 'chat-456', '/command-invoke rca do thing');
const expectedPath = join('/workspace/project', '.archon/commands/rca.md');
expect(mockReadCommandFile).toHaveBeenCalledWith(expectedPath);
expect(mockClient.sendQuery).toHaveBeenCalledWith(
wrapCommandForExecution('rca', 'Do the following: do thing'),
'/workspace/project',
'claude-session-xyz'
);
});
test('handles file read error after fallback existence check', async () => {
mockGetCodebase.mockResolvedValue({
...mockCodebase,
commands: {},
});
mockParseCommand.mockReturnValue({
command: 'command-invoke',
args: ['plan', 'arg1'],
});
mockCommandFileExists.mockResolvedValue(true);
mockReadCommandFile.mockRejectedValue(new Error('EACCES: permission denied'));
await handleMessage(platform, 'chat-456', '/command-invoke plan arg1');
expect(platform.sendMessage).toHaveBeenCalledWith(
'chat-456',
expect.stringContaining('Failed to read command file')
);
expect(mockClient.sendQuery).not.toHaveBeenCalled();
});
test('rejects fallback for invalid command names (path traversal protection)', async () => {
mockGetCodebase.mockResolvedValue({
...mockCodebase,
commands: {},
});
mockParseCommand.mockReturnValue({
command: 'command-invoke',
args: ['../../../etc/passwd'],
});
await handleMessage(platform, 'chat-456', '/command-invoke ../../../etc/passwd');
expect(mockCommandFileExists).not.toHaveBeenCalled();
expect(platform.sendMessage).toHaveBeenCalledWith(
'chat-456',
expect.stringContaining('not found')
);
});
test('uses registered command path instead of fallback', async () => {
mockParseCommand.mockReturnValue({
command: 'command-invoke',
args: ['plan', 'arg1'],
});
mockReadCommandFile.mockResolvedValue('Plan the following: $ARGUMENTS');
mockClient.sendQuery.mockImplementation(async function* () {
yield { type: 'result', sessionId: 'new-session-id' };
});
await handleMessage(platform, 'chat-456', '/command-invoke plan arg1');
// Should NOT check for fallback file since command is registered
expect(mockCommandFileExists).not.toHaveBeenCalled();
// Should use registered path
const expectedPath = join('/workspace/project', '.claude/commands/plan.md');
expect(mockReadCommandFile).toHaveBeenCalledWith(expectedPath);
});
test('appends issueContext after command text', async () => {
mockParseCommand.mockReturnValue({ command: 'command-invoke', args: ['plan'] });
mockReadCommandFile.mockResolvedValue('Command text here');
@ -491,37 +573,8 @@ describe('orchestrator', () => {
});
});
describe('router template', () => {
test('routes non-slash messages through router template when available', async () => {
mockGetTemplate.mockImplementation(async (name: string) => {
if (name === 'router') {
return {
id: 'router-id',
name: 'router',
description: 'Route requests',
content: 'Router prompt with $ARGUMENTS',
created_at: new Date(),
updated_at: new Date(),
};
}
return null;
});
mockClient.sendQuery.mockImplementation(async function* () {
yield { type: 'result', sessionId: 'session-id' };
});
await handleMessage(platform, 'chat-456', 'fix the login bug');
expect(mockGetTemplate).toHaveBeenCalledWith('router');
expect(mockClient.sendQuery).toHaveBeenCalledWith(
'Router prompt with fix the login bug',
'/workspace/project',
'claude-session-xyz'
);
});
test('passes message directly if router template not available', async () => {
mockGetTemplate.mockResolvedValue(null);
describe('no workflows', () => {
test('passes message directly when no workflows are available', async () => {
mockClient.sendQuery.mockImplementation(async function* () {
yield { type: 'result', sessionId: 'session-id' };
});

View file

@ -2,12 +2,26 @@
* Orchestrator - Main conversation handler
* Routes slash commands and AI messages appropriately
*/
import { readFile as fsReadFile } from 'fs/promises';
import { readFile as fsReadFile, access as fsAccess } from 'fs/promises';
// Wrapper function for reading files - allows mocking without polluting fs/promises globally
export async function readCommandFile(path: string): Promise<string> {
return fsReadFile(path, 'utf-8');
}
export async function commandFileExists(path: string): Promise<boolean> {
try {
await fsAccess(path);
return true;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (err.code === 'ENOENT') {
return false;
}
// Unexpected errors (permissions, I/O) should not be swallowed
getLog().error({ err, path, code: err.code }, 'command_file_access_error');
throw new Error(`Cannot access command file at ${path}: ${err.message}`);
}
}
import { join } from 'path';
import { createLogger } from '../utils/logger';
@ -29,7 +43,6 @@ import {
import * as db from '../db/conversations';
import * as codebaseDb from '../db/codebases';
import * as sessionDb from '../db/sessions';
import * as templateDb from '../db/command-templates';
import * as isolationEnvDb from '../db/isolation-environments';
import * as commandHandler from '../handlers/command-handler';
import { formatToolCall } from '../utils/tool-formatter';
@ -45,6 +58,7 @@ import {
parseWorkflowInvocation,
findWorkflow,
executeWorkflow,
isValidCommandName,
} from '../workflows';
import type { WorkflowDefinition, RouterContext } from '../workflows';
import * as workflowDb from '../db/workflows';
@ -718,10 +732,6 @@ export async function handleMessage(
'command-set',
'load-commands',
'commands',
'template-add',
'template-list',
'templates',
'template-delete',
'worktree',
'workflow',
];
@ -848,7 +858,23 @@ export async function handleMessage(
return;
}
const commandDef = codebase.commands[commandName];
// Read command file using the conversation's cwd
const commandCwd = conversation.cwd ?? codebase.default_cwd;
let commandDef = codebase.commands[commandName];
if (!commandDef && isValidCommandName(commandName)) {
const fallbackPath = join('.archon', 'commands', `${commandName}.md`);
const fallbackFilePath = join(commandCwd, fallbackPath);
if (await commandFileExists(fallbackFilePath)) {
commandDef = {
path: fallbackPath,
description: `From ${fallbackPath}`,
};
getLog().debug(
{ commandName, path: fallbackPath },
'command_invoke_fallback_file_found'
);
}
}
if (!commandDef) {
await platform.sendMessage(
conversationId,
@ -857,8 +883,6 @@ export async function handleMessage(
return;
}
// Read command file using the conversation's cwd
const commandCwd = conversation.cwd ?? codebase.default_cwd;
const commandFilePath = join(commandCwd, commandDef.path);
try {
@ -883,31 +907,14 @@ export async function handleMessage(
return;
}
} else {
// Check if it's a global template command
const template = await templateDb.getTemplate(command);
if (template) {
getLog().debug({ command }, 'template_found');
commandName = command;
const substituted = substituteVariables(template.content, args);
promptToSend = wrapCommandForExecution(commandName, substituted);
if (issueContext) {
promptToSend = promptToSend + '\n\n---\n\n' + issueContext;
getLog().debug({ commandName }, 'issue_context_appended_to_template');
}
getLog().debug({ command, argCount: args.length }, 'template_executing');
} else {
// Unknown command
await platform.sendMessage(
conversationId,
`Unknown command: /${command}\n\nType /help for available commands or /templates for command templates.`
);
return;
}
await platform.sendMessage(
conversationId,
`Unknown command: /${command}\n\nType /help for available commands.`
);
return;
}
} else {
// Regular message - route through router template or workflows
// Regular message - route through workflows or pass through directly
if (!conversation.codebase_id) {
await platform.sendMessage(
conversationId,
@ -1027,21 +1034,8 @@ export async function handleMessage(
'router_context_built'
);
} else {
// Fall back to router template for natural language routing
getLog().debug(
{ count: availableWorkflows.length },
'no_workflows_checking_router_template'
);
const routerTemplate = await templateDb.getTemplate('router');
if (routerTemplate) {
getLog().debug('routing_through_router_template');
commandName = 'router';
// Pass the entire message as $ARGUMENTS for the router
promptToSend = substituteVariables(routerTemplate.content, [message]);
} else {
getLog().debug('no_router_template_using_raw_message');
}
// If no router template, message passes through as-is (backward compatible)
getLog().debug({ count: availableWorkflows.length }, 'no_workflows_using_raw_message');
// If no workflows, message passes through as-is (backward compatible)
}
}

View file

@ -97,15 +97,6 @@ export interface Session {
ended_reason: TransitionTrigger | null;
}
export interface CommandTemplate {
id: string;
name: string;
description: string | null;
content: string;
created_at: Date;
updated_at: Date;
}
export interface CommandResult {
success: boolean;
message: string;