Skip to content
Merged
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
11 changes: 7 additions & 4 deletions .archon/workflows/defaults/archon-interactive-prd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ nodes:
- id: foundation-gate
approval:
message: "Answer the foundation questions above. Your answers will guide the research phase."
capture_response: true
depends_on: [initiate]

# ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -106,6 +107,7 @@ nodes:
- id: deepdive-gate
approval:
message: "Answer the deep dive questions above (vision, primary user, JTBD, constraints). Add any adjustments from the research."
capture_response: true
depends_on: [research]

# ═══════════════════════════════════════════════════════════════
Expand Down Expand Up @@ -172,6 +174,7 @@ nodes:
- id: scope-gate
approval:
message: "Answer the scope questions above (MVP, must-haves, hypothesis, exclusions). This is the final input before PRD generation."
capture_response: true
depends_on: [technical]

# ═══════════════════════════════════════════════════════════════
Expand All @@ -188,11 +191,11 @@ nodes:
**Deep dive answers**: $deepdive-gate.output
**Scope answers**: $scope-gate.output

Generate a complete PRD file at `.claude/PRPs/prds/{kebab-case-name}.prd.md`.
Generate a complete PRD file at `$ARTIFACTS_DIR/prds/{kebab-case-name}.prd.md`.

First create the directory:
```bash
mkdir -p .claude/PRPs/prds
mkdir -p $ARTIFACTS_DIR/prds
```

**First principles rule**: Before writing the Technical Approach section, READ the
Expand Down Expand Up @@ -243,9 +246,9 @@ nodes:
Read the PRD file that was just generated. The generate node output the file path:
$generate.output

Find the PRD file — check `.claude/PRPs/prds/` for the most recently created `.prd.md` file:
Find the PRD file — check `$ARTIFACTS_DIR/prds/` for the most recently created `.prd.md` file:
```bash
ls -t .claude/PRPs/prds/*.prd.md | head -1
ls -t $ARTIFACTS_DIR/prds/*.prd.md | head -1
```

Read the entire PRD, then verify EVERY technical claim against the actual codebase:
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ async function createSession(conversationId: string, codebaseId: string) {
2. **Workflows** (YAML-based):
- Stored in `.archon/workflows/` (searched recursively)
- Multi-step AI execution chains, discovered at runtime
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI), `loop:` (iterative AI prompt until completion signal), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
- **`nodes:` (DAG format)**: Nodes with explicit `depends_on` edges; independent nodes in the same topological layer run concurrently. Node types: `command:` (named command file), `prompt:` (inline prompt), `bash:` (shell script, stdout captured as `$nodeId.output`, no AI), `loop:` (iterative AI prompt until completion signal), `approval:` (human gate; pauses until user approves or rejects; `capture_response: true` stores the user's comment as `$<node-id>.output` for downstream nodes, default false), `script:` (inline TypeScript/Python or named script from `.archon/scripts/`, runs via `bun` or `uv`, stdout captured as `$nodeId.output`, no AI, supports `deps:` for dependency installation and `timeout:` in ms, requires `runtime: bun` or `runtime: uv`) . Supports `when:` conditions, `trigger_rule` join semantics, `$nodeId.output` substitution, `output_format` for structured JSON output (Claude and Codex), `allowed_tools`/`denied_tools` for per-node tool restrictions (Claude only), `hooks` for per-node SDK hook callbacks (Claude only), `mcp` for per-node MCP server config files (Claude only, env vars expanded at execution time), and `skills` for per-node skill preloading via AgentDefinition wrapping (Claude only), and `effort`/`thinking`/`maxBudgetUsd`/`systemPrompt`/`fallbackModel`/`betas`/`sandbox` for Claude SDK advanced options (Claude only, also settable at workflow level)
- Provider inherited from `.archon/config.yaml` unless explicitly set; per-node `provider` and `model` overrides supported
- Model and options can be set per workflow or inherited from config defaults
- `interactive: true` at the workflow level forces foreground execution on web (required for approval-gate workflows in the web UI)
Expand Down
108 changes: 108 additions & 0 deletions packages/cli/src/commands/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ mock.module('@archon/core/db/conversations', () => ({
getOrCreateConversation: mock(() =>
Promise.resolve({ id: 'conv-123', platform_type: 'cli', platform_conversation_id: 'cli-123' })
),
getConversationById: mock(() => Promise.resolve(null)),
updateConversation: mock(() => Promise.resolve()),
}));

Expand Down Expand Up @@ -1381,6 +1382,58 @@ describe('workflowApproveCommand', () => {

expect(codebaseDb.getCodebase).toHaveBeenCalledWith('cb-existing');
});

it('should pass original platform conversation ID through to workflowRunCommand', async () => {
const workflowDb = await import('@archon/core/db/workflows');
const codebaseDb = await import('@archon/core/db/codebases');
const conversationsDb = await import('@archon/core/db/conversations');
const workflowDiscovery = await import('@archon/workflows/workflow-discovery');
const core = await import('@archon/core');

(workflowDb.getWorkflowRun as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'run-approve-conv',
workflow_name: 'implement',
status: 'paused',
user_message: 'add auth',
working_path: '/tmp/test-worktree',
codebase_id: 'cb-existing',
conversation_id: 'db-uuid-original',
metadata: { approval: { nodeId: 'review-node', message: 'Approve?' } },
});

// Return a conversation with the original platform ID
(conversationsDb.getConversationById as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'db-uuid-original',
platform_type: 'cli',
platform_conversation_id: 'cli-original-123',
});

(
workflowDiscovery.discoverWorkflowsWithConfig as ReturnType<typeof mock>
).mockResolvedValueOnce({
workflows: [makeTestWorkflowWithSource({ name: 'implement' })],
errors: [],
});

(codebaseDb.getCodebase as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'cb-existing',
name: 'owner/repo',
default_cwd: '/path/to/main-checkout',
});

// Clear call history before our test so we can assert precisely
(conversationsDb.getOrCreateConversation as ReturnType<typeof mock>).mockClear();

try {
await workflowApproveCommand('run-approve-conv');
} catch {
// downstream failure is acceptable — we only need to reach getOrCreateConversation
}

// Verify the original platform conversation ID was passed through
expect(conversationsDb.getConversationById).toHaveBeenCalledWith('db-uuid-original');
expect(conversationsDb.getOrCreateConversation).toHaveBeenCalledWith('cli', 'cli-original-123');
});
});

describe('workflowAbandonCommand', () => {
Expand Down Expand Up @@ -1566,6 +1619,61 @@ describe('workflowRejectCommand', () => {
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Rejected workflow'));
});

it('should pass original platform conversation ID through on reject-resume', async () => {
const workflowDb = await import('@archon/core/db/workflows');
const conversationsDb = await import('@archon/core/db/conversations');
const workflowDiscovery = await import('@archon/workflows/workflow-discovery');

const runData = {
id: 'run-reject-conv',
workflow_name: 'my-wf',
status: 'paused',
user_message: 'build it',
working_path: '/repo',
codebase_id: null,
conversation_id: 'db-uuid-reject',
metadata: {
approval: {
type: 'approval',
nodeId: 'gate',
message: 'Approve?',
onRejectPrompt: 'Fix: $REJECTION_REASON',
onRejectMaxAttempts: 3,
},
rejection_count: 0,
},
};
// rejectWorkflow reads the run twice internally (getRunOrThrow + updateWorkflowRun check)
(workflowDb.getWorkflowRun as ReturnType<typeof mock>).mockResolvedValueOnce(runData);

// Return a conversation with the original platform ID
(conversationsDb.getConversationById as ReturnType<typeof mock>).mockResolvedValueOnce({
id: 'db-uuid-reject',
platform_type: 'cli',
platform_conversation_id: 'cli-reject-456',
});

(
workflowDiscovery.discoverWorkflowsWithConfig as ReturnType<typeof mock>
).mockResolvedValueOnce({
workflows: [makeTestWorkflowWithSource({ name: 'my-wf' })],
errors: [],
});

// Clear call history before our test so we can assert precisely
(conversationsDb.getOrCreateConversation as ReturnType<typeof mock>).mockClear();

try {
await workflowRejectCommand('run-reject-conv', 'needs work');
} catch {
// downstream workflowRunCommand failure is acceptable — we only need to reach getOrCreateConversation
}

// Verify the original platform conversation ID was passed through
expect(conversationsDb.getConversationById).toHaveBeenCalledWith('db-uuid-reject');
expect(conversationsDb.getOrCreateConversation).toHaveBeenCalledWith('cli', 'cli-reject-456');
});

it('cancels when max attempts reached', async () => {
const workflowDb = await import('@archon/core/db/workflows');
const core = await import('@archon/core');
Expand Down
45 changes: 44 additions & 1 deletion packages/cli/src/commands/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export interface WorkflowRunOptions {
allowEnvKeys?: boolean;
quiet?: boolean;
verbose?: boolean;
/** Platform conversation ID (e.g. `cli-{ts}-{rand}`), NOT a DB UUID. */
conversationId?: string;
}

/**
Expand Down Expand Up @@ -269,7 +271,7 @@ export async function workflowRunCommand(
const adapter = new CLIAdapter();

// Generate conversation ID
const conversationId = generateConversationId();
const conversationId = options.conversationId ?? generateConversationId();

// Get or create conversation in database
let conversation;
Expand Down Expand Up @@ -861,10 +863,30 @@ export async function workflowApproveCommand(runId: string, comment?: string): P
console.log('');
console.log('Resuming workflow...');

// Look up the original platform conversation ID to keep all messages in one thread
let platformConversationId: string | undefined;
try {
const originalConversation = await conversationDb.getConversationById(result.conversationId);
platformConversationId = originalConversation?.platform_conversation_id ?? undefined;
if (!originalConversation) {
getLog().info(
{ runId, conversationId: result.conversationId },
'cli.workflow_approve_conversation_not_found'
);
}
} catch (error) {
const err = error as Error;
getLog().warn(
{ err, runId, conversationId: result.conversationId },
'cli.workflow_approve_conversation_lookup_failed'
);
}

try {
await workflowRunCommand(result.workingPath, result.workflowName, result.userMessage ?? '', {
resume: true,
codebaseId: result.codebaseId ?? undefined,
conversationId: platformConversationId,
});
} catch (error) {
const err = error as Error;
Expand Down Expand Up @@ -900,10 +922,31 @@ export async function workflowRejectCommand(runId: string, reason?: string): Pro
}
console.log(`Rejected workflow: ${result.workflowName}`);
console.log('Resuming with on_reject prompt...');

// Look up the original platform conversation ID to keep all messages in one thread
let platformConversationId: string | undefined;
try {
const originalConversation = await conversationDb.getConversationById(result.conversationId);
platformConversationId = originalConversation?.platform_conversation_id ?? undefined;
if (!originalConversation) {
getLog().info(
{ runId, conversationId: result.conversationId },
'cli.workflow_reject_conversation_not_found'
);
}
} catch (error) {
const err = error as Error;
getLog().warn(
{ err, runId, conversationId: result.conversationId },
'cli.workflow_reject_conversation_lookup_failed'
);
}

try {
await workflowRunCommand(result.workingPath, result.workflowName, result.userMessage ?? '', {
resume: true,
codebaseId: result.codebaseId ?? undefined,
conversationId: platformConversationId,
});
} catch (error) {
const err = error as Error;
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/operations/workflow-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface ApprovalOperationResult {
workingPath: string | null;
userMessage: string | null;
codebaseId: string | null;
/** Internal DB UUID — resolve via getConversationById() to get platform_conversation_id. */
conversationId: string;
type: 'interactive_loop' | 'approval_gate';
}

Expand All @@ -42,6 +44,8 @@ export interface RejectionOperationResult {
workingPath: string | null;
userMessage: string | null;
codebaseId: string | null;
/** Internal DB UUID — resolve via getConversationById() to get platform_conversation_id. */
conversationId: string;
/** true = run cancelled; false = transitioning to failed for retry (has onRejectPrompt) */
cancelled: boolean;
/** true when cancelled specifically because max rejection attempts were reached */
Expand Down Expand Up @@ -168,6 +172,7 @@ export async function approveWorkflow(
workingPath: run.working_path,
userMessage: run.user_message,
codebaseId: run.codebase_id,
conversationId: run.conversation_id,
type: 'interactive_loop',
};
}
Expand Down Expand Up @@ -204,6 +209,7 @@ export async function approveWorkflow(
workingPath: run.working_path,
userMessage: run.user_message,
codebaseId: run.codebase_id,
conversationId: run.conversation_id,
type: 'approval_gate',
};
}
Expand Down Expand Up @@ -248,6 +254,7 @@ export async function rejectWorkflow(
workingPath: run.working_path,
userMessage: run.user_message,
codebaseId: run.codebase_id,
conversationId: run.conversation_id,
cancelled: true,
maxAttemptsReached: true,
};
Expand All @@ -261,6 +268,7 @@ export async function rejectWorkflow(
workingPath: run.working_path,
userMessage: run.user_message,
codebaseId: run.codebase_id,
conversationId: run.conversation_id,
cancelled: false,
maxAttemptsReached: false,
};
Expand All @@ -280,6 +288,7 @@ export async function rejectWorkflow(
workingPath: run.working_path,
userMessage: run.user_message,
codebaseId: run.codebase_id,
conversationId: run.conversation_id,
cancelled: true,
maxAttemptsReached: false,
};
Expand Down
Loading