diff --git a/.archon/workflows/defaults/archon-interactive-prd.yaml b/.archon/workflows/defaults/archon-interactive-prd.yaml index ccb08cb411..79024445d6 100644 --- a/.archon/workflows/defaults/archon-interactive-prd.yaml +++ b/.archon/workflows/defaults/archon-interactive-prd.yaml @@ -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] # ═══════════════════════════════════════════════════════════════ @@ -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] # ═══════════════════════════════════════════════════════════════ @@ -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] # ═══════════════════════════════════════════════════════════════ @@ -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 @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index 4448b38eff..012498439f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `$.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) diff --git a/packages/cli/src/commands/workflow.test.ts b/packages/cli/src/commands/workflow.test.ts index 0a050cf26f..7f13f8d83f 100644 --- a/packages/cli/src/commands/workflow.test.ts +++ b/packages/cli/src/commands/workflow.test.ts @@ -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()), })); @@ -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).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).mockResolvedValueOnce({ + id: 'db-uuid-original', + platform_type: 'cli', + platform_conversation_id: 'cli-original-123', + }); + + ( + workflowDiscovery.discoverWorkflowsWithConfig as ReturnType + ).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'implement' })], + errors: [], + }); + + (codebaseDb.getCodebase as ReturnType).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).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', () => { @@ -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).mockResolvedValueOnce(runData); + + // Return a conversation with the original platform ID + (conversationsDb.getConversationById as ReturnType).mockResolvedValueOnce({ + id: 'db-uuid-reject', + platform_type: 'cli', + platform_conversation_id: 'cli-reject-456', + }); + + ( + workflowDiscovery.discoverWorkflowsWithConfig as ReturnType + ).mockResolvedValueOnce({ + workflows: [makeTestWorkflowWithSource({ name: 'my-wf' })], + errors: [], + }); + + // Clear call history before our test so we can assert precisely + (conversationsDb.getOrCreateConversation as ReturnType).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'); diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index 99edcb0bfe..89dd5911e4 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -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; } /** @@ -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; @@ -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; @@ -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; diff --git a/packages/core/src/operations/workflow-operations.ts b/packages/core/src/operations/workflow-operations.ts index 8639ff1654..2b9092e649 100644 --- a/packages/core/src/operations/workflow-operations.ts +++ b/packages/core/src/operations/workflow-operations.ts @@ -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'; } @@ -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 */ @@ -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', }; } @@ -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', }; } @@ -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, }; @@ -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, }; @@ -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, };