Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 12 additions & 14 deletions .archon/commands/defaults/archon-fix-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,30 +131,28 @@ git status

### 3.2 Decision Tree

```text
```
┌─ IN WORKTREE?
│ └─ YES → Use current branch AS-IS. Do NOT switch branches. Do NOT create
│ new branches. The isolation system has already set up the correct
│ branch; any deviation operates on the wrong code.
│ Log: "Using worktree at {path} on branch {branch}"
│ └─ YES → Use it (assume it's for this work)
│ Log: "Using worktree at {path}"
├─ ON $BASE_BRANCH? (main, master, or configured base branch)
├─ ON MAIN/MASTER?
│ └─ Q: Working directory clean?
│ ├─ YES → Create branch: fix/issue-{number}-{slug}
│ │ git checkout -b fix/issue-{number}-{slug}
│ │ (only applies outside a worktree — e.g., manual CLI usage)
│ └─ NO → STOP: "Uncommitted changes on $BASE_BRANCH.
│ Please commit or stash before proceeding."
│ └─ NO → Warn user:
│ "Working directory has uncommitted changes.
│ Please commit or stash before proceeding."
│ STOP
├─ ON OTHER BRANCH?
│ └─ Use it AS-IS (assume it was set up for this work).
│ Do NOT switch to another branch (e.g., one shown by `git branch` but
│ not currently checked out).
├─ ON FEATURE/FIX BRANCH?
│ └─ Use it (assume it's for this work)
│ If branch name doesn't contain issue number:
│ Warn: "Branch '{name}' may not be for issue #{number}"
└─ DIRTY STATE?
└─ STOP: "Uncommitted changes. Please commit or stash first."
└─ Warn and suggest: git stash or git commit
STOP
```

### 3.3 Ensure Up-to-Date
Expand Down
26 changes: 12 additions & 14 deletions .archon/commands/defaults/archon-implement-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,30 +132,28 @@ git status

### 3.2 Decision Tree

```text
```
┌─ IN WORKTREE?
│ └─ YES → Use current branch AS-IS. Do NOT switch branches. Do NOT create
│ new branches. The isolation system has already set up the correct
│ branch; any deviation operates on the wrong code.
│ Log: "Using worktree at {path} on branch {branch}"
│ └─ YES → Use it (assume it's for this work)
│ Log: "Using worktree at {path}"
├─ ON $BASE_BRANCH? (main, master, or configured base branch)
├─ ON MAIN/MASTER?
│ └─ Q: Working directory clean?
│ ├─ YES → Create branch: fix/issue-{number}-{slug}
│ │ git checkout -b fix/issue-{number}-{slug}
│ │ (only applies outside a worktree — e.g., manual CLI usage)
│ └─ NO → STOP: "Uncommitted changes on $BASE_BRANCH.
│ Please commit or stash before proceeding."
│ └─ NO → Warn user:
│ "Working directory has uncommitted changes.
│ Please commit or stash before proceeding."
│ STOP
├─ ON OTHER BRANCH?
│ └─ Use it AS-IS (assume it was set up for this work).
│ Do NOT switch to another branch (e.g., one shown by `git branch` but
│ not currently checked out).
├─ ON FEATURE/FIX BRANCH?
│ └─ Use it (assume it's for this work)
│ If branch name doesn't contain issue number:
│ Warn: "Branch '{name}' may not be for issue #{number}"
└─ DIRTY STATE?
└─ STOP: "Uncommitted changes. Please commit or stash first."
└─ Warn and suggest: git stash or git commit
STOP
```

### 3.3 Ensure Up-to-Date
Expand Down
37 changes: 8 additions & 29 deletions .archon/commands/defaults/archon-implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,40 +93,19 @@ Provide a valid plan path or GitHub issue containing the plan.
### 2.1 Check Current State

```bash
# What branch are we on?
git branch --show-current

# Are we in a worktree?
git rev-parse --show-toplevel
git worktree list

# Is working directory clean?
git status --porcelain
git worktree list
```

### 2.2 Branch Decision

```text
┌─ IN WORKTREE?
│ └─ YES → Use current branch AS-IS. Do NOT switch branches. Do NOT create
│ new branches. The isolation system has already set up the correct
│ branch; any deviation operates on the wrong code.
│ Log: "Using worktree at {path} on branch {branch}"
├─ ON $BASE_BRANCH? (main, master, or configured base branch)
│ └─ Q: Working directory clean?
│ ├─ YES → Create branch: git checkout -b feature/{plan-slug}
│ │ (only applies outside a worktree — e.g., manual CLI usage)
│ └─ NO → STOP: "Stash or commit changes first"
├─ ON OTHER BRANCH?
│ └─ Use it AS-IS. Do NOT switch to another branch (e.g., one shown by
│ `git branch` but not currently checked out).
│ Log: "Using existing branch {name}"
└─ DIRTY STATE?
└─ STOP: "Stash or commit changes first"
```
| Current State | Action |
| ----------------- | ---------------------------------------------------- |
| In worktree | Use it (log: "Using worktree") |
| On base branch, clean | Create branch: `git checkout -b feature/{plan-slug}` |
| On base branch, dirty | STOP: "Stash or commit changes first" |
| On feature branch | Use it (log: "Using existing branch") |

### 2.3 Sync with Remote

Expand All @@ -137,7 +116,7 @@ git pull --rebase origin $BASE_BRANCH 2>/dev/null || true

**PHASE_2_CHECKPOINT:**

- [ ] On correct branch (not $BASE_BRANCH with uncommitted work)
- [ ] On correct branch (not base branch with uncommitted work)
- [ ] Working directory ready
- [ ] Up to date with remote

Expand Down
27 changes: 7 additions & 20 deletions .archon/commands/defaults/archon-plan-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,26 +112,13 @@ gh repo view --json nameWithOwner -q .nameWithOwner

### 2.3 Branch Decision

Evaluate in order (first matching case wins):

```text
┌─ IN WORKTREE?
│ └─ YES → Use current branch AS-IS. Do NOT switch branches. Do NOT create
│ new branches. The isolation system has already set up the correct
│ branch; any deviation operates on the wrong code.
│ Log: "Using worktree branch: {name}"
├─ ON $BASE_BRANCH? (main, master, or configured base branch)
│ └─ Q: Working directory clean?
│ ├─ YES → Create and checkout: `git checkout -b {branch-name}`
│ │ (only applies outside a worktree — e.g., manual CLI usage)
│ └─ NO → STOP: "Uncommitted changes on $BASE_BRANCH. Stash or commit first."
└─ ON OTHER BRANCH?
└─ Q: Does it match the expected branch for this plan?
├─ YES → Use it, log "Using existing branch: {name}"
└─ NO → STOP: "On branch {X}, expected {Y}. Switch branches or adjust plan."
```
| Current State | Action |
|---------------|--------|
| Already on correct feature branch | Use it, log "Using existing branch: {name}" |
| On base branch, clean working directory | Create and checkout: `git checkout -b {branch-name}` |
| On base branch, dirty working directory | STOP with error: "Uncommitted changes on base branch. Stash or commit first." |
| On different feature branch | STOP with error: "On branch {X}, expected {Y}. Switch branches or adjust plan." |
| In a worktree | Use the worktree's branch, log "Using worktree branch: {name}" |

### 2.4 Sync with Remote

Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,20 @@ import { existsSync } from 'fs';
// affects shell-inherited values, which is the intended behavior.
const globalEnvPath = resolve(process.env.HOME ?? '~', '.archon', '.env');
if (existsSync(globalEnvPath)) {
const result = config({ path: globalEnvPath, override: true });
// quiet: true suppresses dotenv's per-file output; we emit our own line below.
const result = config({ path: globalEnvPath, override: true, quiet: true });
if (result.error) {
// Logger may not be available yet (early startup), so use console for user-facing error
console.error(`Error loading .env from ${globalEnvPath}: ${result.error.message}`);
console.error('Hint: Check for syntax errors in your .env file.');
process.exit(1);
}
const loadedCount = result.parsed ? Object.keys(result.parsed).length : 0;
if (loadedCount > 0) {
process.stderr.write(
`[archon] loaded ${String(loadedCount)} key${loadedCount === 1 ? '' : 's'} from ~/.archon/.env\n`
);
}
}

// CLAUDECODE=1 warning is emitted inside stripCwdEnv() (boot import above)
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"./state/*": "./src/state/*.ts"
},
"scripts": {
"test": "bun test src/handlers/command-handler.test.ts && bun test src/handlers/clone.test.ts && bun test src/db/adapters/postgres.test.ts && bun test src/db/connection.test.ts && bun test src/db/adapters/sqlite.test.ts src/db/codebases.test.ts src/db/conversations.test.ts src/db/env-vars.test.ts src/db/isolation-environments.test.ts src/db/messages.test.ts src/db/sessions.test.ts src/db/workflow-events.test.ts src/db/workflows.test.ts src/utils/defaults-copy.test.ts src/utils/worktree-sync.test.ts src/utils/conversation-lock.test.ts src/utils/credential-sanitizer.test.ts src/utils/port-allocation.test.ts src/utils/error.test.ts src/utils/error-formatter.test.ts src/utils/github-graphql.test.ts src/config/ src/state/ && bun test src/utils/path-validation.test.ts && bun test src/services/cleanup-service.test.ts && bun test src/services/title-generator.test.ts && bun test src/workflows/ && bun test src/operations/workflow-operations.test.ts && bun test src/operations/isolation-operations.test.ts && bun test src/orchestrator/orchestrator.test.ts && bun test src/orchestrator/orchestrator-agent.test.ts && bun test src/orchestrator/orchestrator-isolation.test.ts",
"test": "bun test src/",
"type-check": "bun x tsc --noEmit",
"build": "echo 'No build needed - Bun runs TypeScript directly'"
},
Expand Down
47 changes: 37 additions & 10 deletions packages/core/src/config/config-loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
import { homedir } from 'os';
import { join } from 'path';
import { createMockLogger } from '../test/mocks/logger';
import * as paths from '@archon/paths';

const mockLogger = createMockLogger();
const archonHome = join(homedir(), '.archon');
mock.module('@archon/paths', () => ({
createLogger: mock(() => mockLogger),
getArchonHome: mock(() => archonHome),
getArchonConfigPath: mock(() => join(archonHome, 'config.yaml')),
getArchonWorkspacesPath: mock(() => join(archonHome, 'workspaces')),
getArchonWorktreesPath: mock(() => join(archonHome, 'worktrees')),
getDefaultCommandsPath: mock(() => '/app/.archon/commands/defaults'),
getDefaultWorkflowsPath: mock(() => '/app/.archon/workflows/defaults'),
}));

// Spy variable declarations
let spyPathsCreateLogger: ReturnType<typeof spyOn>;
let spyPathsGetArchonHome: ReturnType<typeof spyOn>;
let spyPathsGetArchonConfigPath: ReturnType<typeof spyOn>;
let spyPathsGetArchonWorkspacesPath: ReturnType<typeof spyOn>;
let spyPathsGetArchonWorktreesPath: ReturnType<typeof spyOn>;
let spyPathsGetDefaultCommandsPath: ReturnType<typeof spyOn>;
let spyPathsGetDefaultWorkflowsPath: ReturnType<typeof spyOn>;

// Mock for reading/writing config files (replaces fs/promises mock)
const mockReadConfigFile = mock(() => Promise.resolve(''));
Expand Down Expand Up @@ -50,6 +51,24 @@ describe('config-loader', () => {
];

beforeEach(() => {
spyPathsCreateLogger = spyOn(paths, 'createLogger').mockReturnValue(mockLogger);
spyPathsGetArchonHome = spyOn(paths, 'getArchonHome').mockReturnValue(archonHome);
spyPathsGetArchonConfigPath = spyOn(paths, 'getArchonConfigPath').mockReturnValue(
join(archonHome, 'config.yaml')
);
spyPathsGetArchonWorkspacesPath = spyOn(paths, 'getArchonWorkspacesPath').mockReturnValue(
join(archonHome, 'workspaces')
);
spyPathsGetArchonWorktreesPath = spyOn(paths, 'getArchonWorktreesPath').mockReturnValue(
join(archonHome, 'worktrees')
);
spyPathsGetDefaultCommandsPath = spyOn(paths, 'getDefaultCommandsPath').mockReturnValue(
'/app/.archon/commands/defaults'
);
spyPathsGetDefaultWorkflowsPath = spyOn(paths, 'getDefaultWorkflowsPath').mockReturnValue(
'/app/.archon/workflows/defaults'
);

clearConfigCache();
mockReadConfigFile.mockReset();
mockWriteConfigFile.mockReset();
Expand All @@ -62,6 +81,14 @@ describe('config-loader', () => {
});

afterEach(() => {
spyPathsCreateLogger.mockRestore();
spyPathsGetArchonHome.mockRestore();
spyPathsGetArchonConfigPath.mockRestore();
spyPathsGetArchonWorkspacesPath.mockRestore();
spyPathsGetArchonWorktreesPath.mockRestore();
spyPathsGetDefaultCommandsPath.mockRestore();
spyPathsGetDefaultWorkflowsPath.mockRestore();

// Restore env vars
envVars.forEach(key => {
if (originalEnv[key] === undefined) {
Expand Down
29 changes: 17 additions & 12 deletions packages/core/src/db/adapters/postgres.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, test, expect, mock, beforeEach } from 'bun:test';
import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from 'bun:test';
import * as paths from '@archon/paths';

// ---- pg mock setup --------------------------------------------------------
// Must be declared before importing the module under test so that the mock
Expand Down Expand Up @@ -39,17 +40,8 @@ mock.module('pg', () => ({
Pool: MockPool,
}));

// ---- also mock @archon/paths so logger calls don't blow up ----------------
mock.module('@archon/paths', () => ({
createLogger: () => ({
info: () => {},
warn: () => {},
error: () => {},
fatal: () => {},
debug: () => {},
trace: () => {},
}),
}));
// ---- also spy on @archon/paths so logger calls don't blow up ---------------
let spyPathsCreateLogger: ReturnType<typeof spyOn>;

// ---- import after mocks are registered ------------------------------------
import { PostgresAdapter, postgresDialect } from './postgres';
Expand All @@ -60,6 +52,15 @@ describe('PostgresAdapter', () => {
let adapter: PostgresAdapter;

beforeEach(() => {
spyPathsCreateLogger = spyOn(paths, 'createLogger').mockReturnValue({
info: () => {},
warn: () => {},
error: () => {},
fatal: () => {},
debug: () => {},
trace: () => {},
} as ReturnType<typeof paths.createLogger>);

// Reset shared mock state before each test
mockPoolQuery = async () => ({ rows: [], rowCount: 0 });
mockClient = {
Expand All @@ -71,6 +72,10 @@ describe('PostgresAdapter', () => {
adapter = new PostgresAdapter('postgresql://localhost:5432/testdb');
});

afterEach(() => {
spyPathsCreateLogger.mockRestore();
});

// -------------------------------------------------------------------------
// Static properties
// -------------------------------------------------------------------------
Expand Down
30 changes: 21 additions & 9 deletions packages/core/src/db/codebases.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,10 @@
import { mock, describe, test, expect, beforeEach } from 'bun:test';
import { mock, describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test';
import { createQueryResult, mockPostgresDialect } from '../test/mocks/database';
import { Codebase } from '../types';
import * as connection from './connection';

const mockQuery = mock(() => Promise.resolve(createQueryResult([])));

// Mock the connection module before importing the module under test
mock.module('./connection', () => ({
pool: {
query: mockQuery,
},
getDialect: () => mockPostgresDialect,
}));

import {
createCodebase,
getCodebase,
Expand All @@ -25,11 +18,22 @@ import {
deleteCodebase,
} from './codebases';

// Spy variable declarations
let spyConnectionPoolQuery: ReturnType<typeof spyOn>;
let spyConnectionGetDialect: ReturnType<typeof spyOn>;

describe('codebases', () => {
beforeEach(() => {
spyConnectionPoolQuery = spyOn(connection.pool, 'query').mockImplementation(mockQuery);
spyConnectionGetDialect = spyOn(connection, 'getDialect').mockReturnValue(mockPostgresDialect);
mockQuery.mockClear();
});

afterEach(() => {
spyConnectionPoolQuery.mockRestore();
spyConnectionGetDialect.mockRestore();
});

const mockCodebase: Codebase = {
id: 'codebase-123',
name: 'test-project',
Expand Down Expand Up @@ -175,6 +179,14 @@ describe('codebases', () => {
expect(result).toEqual({});
});

test('throws on corrupt JSON string (SQLite TEXT column)', async () => {
mockQuery.mockResolvedValueOnce(createQueryResult([{ commands: 'not-valid-json{' }]));

await expect(getCodebaseCommands('codebase-123')).rejects.toThrow(
'Corrupt commands JSON for codebase codebase-123'
);
});

test('returns mutable object even when source is frozen (SQLite behavior)', async () => {
const frozenCommands = Object.freeze({
plan: { path: '.archon/commands/plan.md', description: 'Plan feature' },
Expand Down
Loading