diff --git a/codev-skeleton/consult-types/impl-review.md b/codev-skeleton/consult-types/impl-review.md index f35d18a7..53042345 100644 --- a/codev-skeleton/consult-types/impl-review.md +++ b/codev-skeleton/consult-types/impl-review.md @@ -3,6 +3,8 @@ ## Context You are reviewing implementation work at Stage 4 (IMPLEMENTING) of the workflow. A builder has completed a phase (Implement + Defend) and needs feedback before proceeding. Your job is to verify the implementation matches the spec and plan. +**Plan required**: If a plan file is listed in the prompt, you MUST read it. Treat the plan as the phase scope (it may be a subset of the spec). If no plan is provided, state "Plan not provided" in your summary. + ## Focus Areas 1. **Spec Adherence** diff --git a/codev-skeleton/resources/commands/agent-farm.md b/codev-skeleton/resources/commands/agent-farm.md index 40967e72..55269653 100644 --- a/codev-skeleton/resources/commands/agent-farm.md +++ b/codev-skeleton/resources/commands/agent-farm.md @@ -188,6 +188,14 @@ Show status of all agents. af status ``` +Set a builder status: + +```bash +af status set [--notify] +``` + +Valid statuses: `spawning`, `implementing`, `blocked`, `pr-ready`, `complete`. + **Description:** Displays the current state of all builders and the architect: diff --git a/codev-skeleton/roles/builder.md b/codev-skeleton/roles/builder.md index fa157485..764b6a6f 100644 --- a/codev-skeleton/roles/builder.md +++ b/codev-skeleton/roles/builder.md @@ -28,7 +28,7 @@ The `af` commands work from worktrees - they automatically find the main reposit 3. **Follow the assigned protocol** - SPIDER or TICK as specified in the spec 4. **Report status** - Keep status updated (implementing/blocked/pr-ready) 5. **Request help when blocked** - Don't spin; output a clear blocker message -6. **Deliver clean PRs** - Tests passing, code reviewed, protocol artifacts complete +6. **Deliver clean PRs** - Tests passing, protocol artifacts complete, Architect notified ## Protocol Adherence @@ -173,6 +173,11 @@ af status ``` You can check your own status and see other builders. The Architect also monitors status. +Status does not update automatically; send a short status update when it changes: + +```bash +af status set blocked --notify +``` ## Working in a Worktree @@ -199,9 +204,13 @@ Report `blocked` status when: **Do NOT stay blocked silently.** Communicate your blocker clearly: -1. Output a clear message in your terminal describing the blocker and options -2. Add a `` comment in relevant code if applicable -3. The Architect monitors builder status via `af status` and will see you're blocked +1. Update status and notify the Architect: + ```bash + af status set blocked --notify + ``` +2. Send the Architect a short status message describing the blocker and options +3. Add a `` comment in relevant code if applicable +4. The Architect monitors builder status via `af status` and will see you're blocked Example blocker message to output: ``` @@ -224,7 +233,8 @@ When done, a Builder should have: 3. **Documentation** - Updated relevant docs (if needed) 4. **Clean commits** - Atomic, well-messaged commits per phase 5. **Review document** - As specified in the SPIDER protocol (`codev/reviews/XXXX-spec-name.md`) -6. **PR-ready branch** - Ready for Architect review +6. **PR-ready branch** - PR created and ready for Architect review +7. **Architect notified** - Short message with PR link/number ## Communication with Architect @@ -241,9 +251,16 @@ When implementation is complete: 2. Self-review the code 3. Ensure all protocol artifacts are present (especially the review document for SPIDER) 4. Create a PR: `gh pr create --title "[Spec XXXX] Description" --body "..."` -5. Update status to `pr-ready` -6. Wait for Architect review and approval -7. **Merge your own PR** once approved: `gh pr merge --merge --delete-branch` +5. Update status and notify the Architect: + ```bash + af status set pr-ready --notify + ``` +6. Notify the Architect with the PR link/number: + ```bash + af send architect "Status: pr-ready — PR #123 ready for review" + ``` +7. Wait for Architect review and approval +8. **Merge your own PR** once approved: `gh pr merge --merge --delete-branch` **Important**: The Builder is responsible for merging after Architect approval. This ensures the Builder sees the merge succeed and can handle any final cleanup. diff --git a/codev/config.json b/codev/config.json index e4eef81a..3d41e510 100644 --- a/codev/config.json +++ b/codev/config.json @@ -1,6 +1,6 @@ { "shell": { - "architect": "claude --dangerously-skip-permissions", + "architect": "codex --yolo", "builder": "claude --dangerously-skip-permissions", "shell": "bash" }, diff --git a/codev/consult-types/impl-review.md b/codev/consult-types/impl-review.md index 2a827d50..d81b525d 100644 --- a/codev/consult-types/impl-review.md +++ b/codev/consult-types/impl-review.md @@ -3,6 +3,8 @@ ## Context You are reviewing implementation work at Stage 4 (IMPLEMENTING) of the workflow. A builder has completed a phase (Implement + Defend) and needs feedback before proceeding. Your job is to verify the implementation matches the spec and plan. +**Plan required**: If a plan file is listed in the prompt, you MUST read it. Treat the plan as the phase scope (it may be a subset of the spec). If no plan is provided, state "Plan not provided" in your summary. + ## Focus Areas 1. **Spec Adherence** diff --git a/codev/resources/commands/agent-farm.md b/codev/resources/commands/agent-farm.md index 40967e72..55269653 100644 --- a/codev/resources/commands/agent-farm.md +++ b/codev/resources/commands/agent-farm.md @@ -188,6 +188,14 @@ Show status of all agents. af status ``` +Set a builder status: + +```bash +af status set [--notify] +``` + +Valid statuses: `spawning`, `implementing`, `blocked`, `pr-ready`, `complete`. + **Description:** Displays the current state of all builders and the architect: diff --git a/codev/roles/builder.md b/codev/roles/builder.md index a393d473..4e13d59b 100644 --- a/codev/roles/builder.md +++ b/codev/roles/builder.md @@ -49,7 +49,7 @@ This opens files in the agent-farm annotation viewer when clicked in the dashboa 3. **Follow the assigned protocol** - SPIDER or TICK as specified 4. **Report status** - Keep status updated (implementing/blocked/pr-ready) 5. **Request help when blocked** - Don't spin; ask the Architect -6. **Deliver clean PRs** - Tests passing, code reviewed +6. **Deliver clean PRs** - Tests passing, protocol artifacts complete, Architect notified ## Execution Strategy @@ -82,14 +82,20 @@ spawning → implementing → blocked → implementing → pr-ready → complete ### Updating Status -Status is tracked in `.agent-farm/state.json` and visible on the dashboard. +Status is tracked in `.agent-farm/state.db` and visible on the dashboard. To check current status: ```bash af status ``` -Status updates happen automatically based on your progress. When blocked, clearly communicate the blocker in your terminal or via REVIEW comments in code. +Status does not update automatically. Use `af status set` and notify the Architect: + +```bash +af status set blocked --notify +``` + +When you become unblocked or reach PR-ready, send a follow-up status message. ## Working in a Worktree @@ -125,8 +131,11 @@ Report `blocked` status when: ### How to Report Blocked -1. Update status to `blocked` -2. Clearly describe the blocker: +1. Update status and notify the Architect: + ```bash + af status set blocked --notify + ``` +2. Send a short blocker summary: ```markdown ## Builder 0003 - Status: blocked @@ -147,7 +156,9 @@ When done, a Builder should have: 2. **Tests** - Appropriate test coverage 3. **Documentation** - Updated relevant docs (if needed) 4. **Clean commits** - Atomic, well-messaged commits -5. **PR-ready branch** - Ready for Architect to merge +5. **Review document** - `codev/reviews/XXXX-spec-name.md` (SPIDER) +6. **PR-ready branch** - PR created and ready for Architect review +7. **Architect notified** - Short message with PR link/number ## Communication with Architect @@ -167,8 +178,17 @@ If you need help but aren't fully blocked: When implementation is complete: 1. Run all tests 2. Self-review the code -3. Update status to `pr-ready` -4. The Architect will review and merge +3. Write the review document (SPIDER): `codev/reviews/XXXX-spec-name.md` +4. Create the PR and include key context in the description +5. Update status and notify the Architect: + ```bash + af status set pr-ready --notify + ``` +6. Send the PR link/number: + ```bash + af send architect "Status: pr-ready — PR #123 ready for review" + ``` +7. The Architect will review and merge ## Example Builder Session diff --git a/packages/codev/package-lock.json b/packages/codev/package-lock.json index 0c943d52..f258a5be 100644 --- a/packages/codev/package-lock.json +++ b/packages/codev/package-lock.json @@ -2095,7 +2095,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2624,7 +2623,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -2684,7 +2682,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", diff --git a/packages/codev/package.json b/packages/codev/package.json index 26e50491..5f9f7f34 100644 --- a/packages/codev/package.json +++ b/packages/codev/package.json @@ -1,6 +1,6 @@ { "name": "@cluesmith/codev", - "version": "1.6.1", + "version": "1.7.0", "description": "Codev CLI - AI-assisted software development framework", "type": "module", "bin": { @@ -21,7 +21,8 @@ "dev": "tsx src/cli.ts", "start": "node dist/cli.js", "test": "vitest", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "prepare": "npm run build" }, "dependencies": { "@google/genai": "^1.0.0", diff --git a/packages/codev/src/agent-farm/__tests__/project-id.test.ts b/packages/codev/src/agent-farm/__tests__/project-id.test.ts new file mode 100644 index 00000000..7182573c --- /dev/null +++ b/packages/codev/src/agent-farm/__tests__/project-id.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { + normalizeProjectId, + splitProjectId, + getSpecIdCandidates, + applyProjectSuffixToSpecName, +} from '../utils/project-id.js'; + +describe('project-id utils', () => { + describe('normalizeProjectId', () => { + it('lowercases and trims', () => { + expect(normalizeProjectId(' 0006A ')).toBe('0006a'); + }); + }); + + describe('splitProjectId', () => { + it('splits base id and suffix', () => { + expect(splitProjectId('0006a')).toEqual({ baseId: '0006', suffix: 'a' }); + }); + + it('handles numeric ids with no suffix', () => { + expect(splitProjectId('0006')).toEqual({ baseId: '0006', suffix: '' }); + }); + + it('falls back for non-matching ids', () => { + expect(splitProjectId('feature-1')).toEqual({ + baseId: 'feature-1', + suffix: '', + }); + }); + }); + + describe('getSpecIdCandidates', () => { + it('includes base id when suffix is present', () => { + expect(getSpecIdCandidates('0006a')).toEqual(['0006a', '0006']); + }); + + it('returns only the normalized id when no suffix', () => { + expect(getSpecIdCandidates('0006')).toEqual(['0006']); + }); + }); + + describe('applyProjectSuffixToSpecName', () => { + it('injects suffix into spec name', () => { + expect(applyProjectSuffixToSpecName('0006-foo', '0006a')).toBe( + '0006a-foo' + ); + }); + + it('keeps spec name when no suffix', () => { + expect(applyProjectSuffixToSpecName('0006-foo', '0006')).toBe( + '0006-foo' + ); + }); + + it('keeps spec name when prefix is unexpected', () => { + expect( + applyProjectSuffixToSpecName('feature-0006-foo', '0006a') + ).toBe('feature-0006-foo'); + }); + }); +}); diff --git a/packages/codev/src/agent-farm/__tests__/state.test.ts b/packages/codev/src/agent-farm/__tests__/state.test.ts index 0e4dbcc9..654a1d0f 100644 --- a/packages/codev/src/agent-farm/__tests__/state.test.ts +++ b/packages/codev/src/agent-farm/__tests__/state.test.ts @@ -215,6 +215,30 @@ describe('State Management', () => { }); }); + describe('updateBuilderStatus', () => { + it('should update status for existing builder', () => { + state.upsertBuilder({ + id: 'B001', + name: 'test-builder', + port: 4210, + pid: 1234, + status: 'implementing' as const, + phase: 'init', + worktree: '/tmp/worktree', + branch: 'feature-branch', + type: 'spec' as const, + }); + + const updated = state.updateBuilderStatus('B001', 'pr-ready'); + expect(updated?.status).toBe('pr-ready'); + }); + + it('should return null for missing builder', () => { + const updated = state.updateBuilderStatus('B999', 'blocked'); + expect(updated).toBeNull(); + }); + }); + describe('addUtil / removeUtil', () => { it('should add and remove utility terminals', () => { const util = { diff --git a/packages/codev/src/agent-farm/cli.ts b/packages/codev/src/agent-farm/cli.ts index 4ce32804..12d25cba 100644 --- a/packages/codev/src/agent-farm/cli.ts +++ b/packages/codev/src/agent-farm/cli.ts @@ -91,8 +91,10 @@ export async function runAgentFarm(args: string[]): Promise { .option('-l, --layout', 'Create multi-pane layout with status and shell') .action(async (args: string[], options: { layout?: boolean }) => { const { architect } = await import('./commands/architect.js'); + const commands = getResolvedCommands(); + try { - await architect({ args, layout: options.layout }); + await architect({ args, layout: options.layout, cmd: commands.architect }); } catch (error) { logger.error(error instanceof Error ? error.message : String(error)); process.exit(1); @@ -100,13 +102,32 @@ export async function runAgentFarm(args: string[]): Promise { }); // Status command - program + const statusCmd = program .command('status') - .description('Show status of all agents') - .action(async () => { - const { status } = await import('./commands/status.js'); + .description('Show status of all agents'); + + statusCmd.action(async () => { + const { status } = await import('./commands/status.js'); + try { + await status(); + } catch (error) { + logger.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + }); + + statusCmd + .command('set ') + .description('Set a builder status') + .option('--notify', 'Notify architect of status change') + .action(async (builder: string, statusValue: string, options) => { + const { setStatus } = await import('./commands/status.js'); try { - await status(); + await setStatus({ + builder, + status: statusValue, + notify: options.notify, + }); } catch (error) { logger.error(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/packages/codev/src/agent-farm/commands/architect.ts b/packages/codev/src/agent-farm/commands/architect.ts index ab5b0846..76c38113 100644 --- a/packages/codev/src/agent-farm/commands/architect.ts +++ b/packages/codev/src/agent-farm/commands/architect.ts @@ -5,15 +5,17 @@ * requiring the full dashboard. Uses tmux for session persistence. */ -import { writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import { spawn } from 'node:child_process'; -import { getConfig, ensureDirectories } from '../utils/index.js'; -import { logger, fatal } from '../utils/logger.js'; -import { run, commandExists } from '../utils/shell.js'; -import { findRolePromptPath } from '../utils/roles.js'; - -const SESSION_NAME = 'af-architect'; +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { getConfig, ensureDirectories } from "../utils/index.js"; +import { logger, fatal } from "../utils/logger.js"; +import { run, commandExists } from "../utils/shell.js"; +import { findRolePromptPath } from "../utils/roles.js"; +import { quoteShellArg, writeLaunchScript } from "../utils/launch-script.js"; +import { buildPromptCommand } from "../../lib/prompt-command.js"; + +const SESSION_NAME = "af-architect"; // findRolePromptPath imported from ../utils/roles.js @@ -34,15 +36,15 @@ async function tmuxSessionExists(sessionName: string): Promise { */ function attachToSession(sessionName: string): void { // Use spawn with inherited stdio for full interactivity - const child = spawn('tmux', ['attach-session', '-t', sessionName], { - stdio: 'inherit', + const child = spawn("tmux", ["attach-session", "-t", sessionName], { + stdio: "inherit", }); - child.on('exit', (code) => { + child.on("exit", (code) => { process.exit(code ?? 0); }); - child.on('error', (err) => { + child.on("error", (err) => { fatal(`Failed to attach to tmux session: ${err.message}`); }); } @@ -50,35 +52,41 @@ function attachToSession(sessionName: string): void { /** * Create a new tmux session with the architect role and attach to it */ -async function createAndAttach(args: string[]): Promise { +async function createAndAttach(args: string[], cmd: string): Promise { const config = getConfig(); // Ensure state directory exists for launch script await ensureDirectories(config); // Load architect role - const role = findRolePromptPath(config, 'architect'); + const role = findRolePromptPath(config, "architect"); if (!role) { - fatal('Architect role not found. Expected at: codev/roles/architect.md'); + fatal("Architect role not found. Expected at: codev/roles/architect.md"); } logger.info(`Loaded architect role (${role.source})`); // Create a launch script to avoid shell escaping issues // The architect.md file contains backticks, $variables, and other shell-sensitive chars - const launchScript = resolve(config.stateDir, 'launch-architect-cli.sh'); + const launchScript = resolve(config.stateDir, "launch-architect-cli.sh"); - let argsStr = ''; + let argsStr = ""; if (args.length > 0) { - argsStr = ' ' + args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' '); + argsStr = " " + args.map(quoteShellArg).join(" "); } - writeFileSync(launchScript, `#!/bin/bash -cd "${config.projectRoot}" -exec claude --append-system-prompt "$(cat '${role.path}')"${argsStr} -`, { mode: 0o755 }); + writeLaunchScript({ + scriptPath: launchScript, + cwd: config.projectRoot, + execCommand: buildPromptCommand({ + command: cmd, + systemPromptFile: role.path, + userPromptText: argsStr.length > 0 ? argsStr.trim() : undefined, + }), + mode: 0o755, + }); - logger.info('Creating new architect session...'); + logger.info("Creating new architect session..."); // Create tmux session running the launch script await run( @@ -92,14 +100,18 @@ exec claude --append-system-prompt "$(cat '${role.path}')"${argsStr} await run(`tmux set-option -t "${SESSION_NAME}" -g allow-passthrough on`); // Copy selection to clipboard when mouse is released (pbcopy for macOS) - await run(`tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`); - await run(`tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`); + await run( + `tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"` + ); + await run( + `tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"` + ); // Attach to the session attachToSession(SESSION_NAME); } -const LAYOUT_SESSION_NAME = 'af-layout'; +const LAYOUT_SESSION_NAME = "af-layout"; /** * Create a two-pane tmux layout with architect and utility shell @@ -111,34 +123,43 @@ const LAYOUT_SESSION_NAME = 'af-layout'; * │ │ │ * └────────────────────────────────┴──────────────────────────────┘ */ -async function createLayoutAndAttach(args: string[]): Promise { +async function createLayoutAndAttach( + args: string[], + cmd: string +): Promise { const config = getConfig(); // Ensure state directory exists for launch script await ensureDirectories(config); // Load architect role - const role = findRolePromptPath(config, 'architect'); + const role = findRolePromptPath(config, "architect"); if (!role) { - fatal('Architect role not found. Expected at: codev/roles/architect.md'); + fatal("Architect role not found. Expected at: codev/roles/architect.md"); } logger.info(`Loaded architect role (${role.source})`); // Create launch script for architect - const launchScript = resolve(config.stateDir, 'launch-architect-cli.sh'); + const launchScript = resolve(config.stateDir, "launch-architect-cli.sh"); - let argsStr = ''; + let argsStr = ""; if (args.length > 0) { - argsStr = ' ' + args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' '); + argsStr = " " + args.map(quoteShellArg).join(" "); } - writeFileSync(launchScript, `#!/bin/bash -cd "${config.projectRoot}" -exec claude --append-system-prompt "$(cat '${role.path}')"${argsStr} -`, { mode: 0o755 }); + writeLaunchScript({ + scriptPath: launchScript, + cwd: config.projectRoot, + execCommand: buildPromptCommand({ + command: cmd, + systemPromptFile: role.path, + userPromptText: argsStr.length > 0 ? argsStr.trim() : undefined, + }), + mode: 0o755, + }); - logger.info('Creating layout session...'); + logger.info("Creating layout session..."); // Create main session with architect pane (left, 70% width) await run( @@ -149,16 +170,20 @@ exec claude --append-system-prompt "$(cat '${role.path}')"${argsStr} await run(`tmux set-option -t "${LAYOUT_SESSION_NAME}" status off`); await run(`tmux set-option -t "${LAYOUT_SESSION_NAME}" -g mouse on`); await run(`tmux set-option -t "${LAYOUT_SESSION_NAME}" -g set-clipboard on`); - await run(`tmux set-option -t "${LAYOUT_SESSION_NAME}" -g allow-passthrough on`); + await run( + `tmux set-option -t "${LAYOUT_SESSION_NAME}" -g allow-passthrough on` + ); // Split right: create utility shell pane (40% width) - await run(`tmux split-window -h -t "${LAYOUT_SESSION_NAME}" -p 40 -c "${config.projectRoot}"`); + await run( + `tmux split-window -h -t "${LAYOUT_SESSION_NAME}" -p 40 -c "${config.projectRoot}"` + ); // Focus back on architect pane (left) await run(`tmux select-pane -t "${LAYOUT_SESSION_NAME}:0.0"`); - logger.info('Layout: Architect (left) | Shell (right)'); - logger.info('Navigation: Ctrl+B ←/→ | Detach: Ctrl+B d'); + logger.info("Layout: Architect (left) | Shell (right)"); + logger.info("Navigation: Ctrl+B ←/→ | Detach: Ctrl+B d"); // Attach to the session attachToSession(LAYOUT_SESSION_NAME); @@ -167,6 +192,7 @@ exec claude --append-system-prompt "$(cat '${role.path}')"${argsStr} export interface ArchitectOptions { args?: string[]; layout?: boolean; + cmd?: string; } /** @@ -175,13 +201,16 @@ export interface ArchitectOptions { export async function architect(options: ArchitectOptions = {}): Promise { const args = options.args ?? []; const useLayout = options.layout ?? false; + const cmd = options.cmd || "claude"; // Check dependencies - if (!(await commandExists('tmux'))) { - fatal('tmux not found. Install with: brew install tmux'); + if (!(await commandExists("tmux"))) { + fatal("tmux not found. Install with: brew install tmux"); } - if (!(await commandExists('claude'))) { - fatal('claude not found. Install with: npm install -g @anthropic-ai/claude-code'); + if (!(await commandExists("claude"))) { + fatal( + "claude not found. Install with: npm install -g @anthropic-ai/claude-code" + ); } // Determine which session to use @@ -190,11 +219,11 @@ export async function architect(options: ArchitectOptions = {}): Promise { if (sessionExists) { logger.info(`Attaching to existing session: ${sessionName}`); - logger.info('Detach with Ctrl+B, D'); + logger.info("Detach with Ctrl+B, D"); attachToSession(sessionName); } else if (useLayout) { - await createLayoutAndAttach(args); + await createLayoutAndAttach(args, cmd); } else { - await createAndAttach(args); + await createAndAttach(args, cmd); } } diff --git a/packages/codev/src/agent-farm/commands/index.ts b/packages/codev/src/agent-farm/commands/index.ts index ec07e3b9..e93ac49e 100644 --- a/packages/codev/src/agent-farm/commands/index.ts +++ b/packages/codev/src/agent-farm/commands/index.ts @@ -4,7 +4,7 @@ export { start } from './start.js'; export { stop } from './stop.js'; -export { status } from './status.js'; +export { status, setStatus } from './status.js'; export { spawn } from './spawn.js'; export { util } from './util.js'; export { open } from './open.js'; diff --git a/packages/codev/src/agent-farm/commands/send.ts b/packages/codev/src/agent-farm/commands/send.ts index 9e0ccc53..d25c3c48 100644 --- a/packages/codev/src/agent-farm/commands/send.ts +++ b/packages/codev/src/agent-farm/commands/send.ts @@ -14,6 +14,39 @@ import { loadState, getArchitect } from '../state.js'; const MAX_FILE_SIZE = 48 * 1024; // 48KB limit per spec +function detectBuilderCliFromScript(worktreePath: string): string | null { + const scriptPath = join(worktreePath, '.builder-start.sh'); + if (!existsSync(scriptPath)) return null; + const content = readFileSync(scriptPath, 'utf-8'); + const execLine = content + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('exec ')); + if (!execLine) return null; + + const tokens = execLine.replace(/^exec\s+/, '').split(/\s+/); + const wrappers = new Set([ + 'env', + 'npx', + 'npm', + 'pnpm', + 'yarn', + 'bunx', + 'node', + 'deno', + ]); + const skipTokens = new Set(['exec', 'dlx', 'run', 'x']); + for (const token of tokens) { + if (!token) continue; + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) continue; + const name = token.split('/').pop() || token; + if (skipTokens.has(name)) continue; + if (wrappers.has(name)) continue; + return name; + } + return null; +} + /** * Format message from architect to builder */ @@ -122,10 +155,20 @@ async function sendToBuilder( // Ignore delete-buffer errors (buffer may not exist) }); - // Send Enter to submit (unless --no-enter) - if (!options.noEnter) { - await run(`tmux send-keys -t "${builder.tmuxSession}" Enter`); + const builderCli = + builder.worktree && existsSync(builder.worktree) + ? detectBuilderCliFromScript(builder.worktree) + : null; + const enterDelayMs = builderCli === 'codex' ? 150 : 0; + + // Send Enter to submit (unless --no-enter) + if (!options.noEnter) { + if (enterDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, enterDelayMs)); } + await run(`tmux send-keys -t "${builder.tmuxSession}" Enter`); + } + logger.debug(`Sent to ${builderId}: ${message.substring(0, 50)}...`); } finally { @@ -328,11 +371,11 @@ export async function send(options: SendOptions): Promise { logger.error(`Failed for ${results.failed.length} builder(s): ${results.failed.join(', ')}`); } } else { - // Send to specific builder - try { - await sendToBuilder(builder!, message, options); - logger.success(`Message sent to builder ${builder}`); - } catch (error) { + // Send to specific builder + try { + await sendToBuilder(builder!, message, options); + logger.success(`Message sent to builder ${builder}`); + } catch (error) { fatal(error instanceof Error ? error.message : String(error)); } } diff --git a/packages/codev/src/agent-farm/commands/spawn.ts b/packages/codev/src/agent-farm/commands/spawn.ts index 3db33dbd..6dca5d1c 100644 --- a/packages/codev/src/agent-farm/commands/spawn.ts +++ b/packages/codev/src/agent-farm/commands/spawn.ts @@ -8,17 +8,42 @@ * - shell: --shell Bare Claude session (no prompt, no worktree) */ -import { resolve, basename, join } from 'node:path'; -import { existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, symlinkSync, unlinkSync, type Dirent } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import { readdir } from 'node:fs/promises'; -import type { SpawnOptions, Builder, Config, BuilderType } from '../types.js'; -import { getConfig, ensureDirectories, getResolvedCommands } from '../utils/index.js'; -import { logger, fatal } from '../utils/logger.js'; -import { run, spawnDetached, commandExists, findAvailablePort, spawnTtyd } from '../utils/shell.js'; -import { loadState, upsertBuilder } from '../state.js'; -import { loadRolePrompt } from '../utils/roles.js'; +import { resolve, basename, join } from "node:path"; +import { + existsSync, + readFileSync, + writeFileSync, + chmodSync, + readdirSync, + symlinkSync, + unlinkSync, + type Dirent, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { readdir } from "node:fs/promises"; +import type { SpawnOptions, Builder, Config, BuilderType } from "../types.js"; +import { + getConfig, + ensureDirectories, + getResolvedCommands, +} from "../utils/index.js"; +import { logger, fatal } from "../utils/logger.js"; +import { + run, + spawnDetached, + commandExists, + findAvailablePort, + spawnTtyd, +} from "../utils/shell.js"; +import { loadState, upsertBuilder } from "../state.js"; +import { loadRolePrompt } from "../utils/roles.js"; +import { buildPromptCommand } from "../../lib/prompt-command.js"; +import { + applyProjectSuffixToSpecName, + getSpecIdCandidates, + normalizeProjectId, +} from "../utils/project-id.js"; /** * Generate a short 4-character base64-encoded ID @@ -41,25 +66,40 @@ function getSessionName(config: Config, builderId: string): string { function generateShortId(): string { // Generate random 24-bit number and base64 encode to 4 chars - const num = Math.floor(Math.random() * 0xFFFFFF); - const bytes = new Uint8Array([num >> 16, (num >> 8) & 0xFF, num & 0xFF]); + const num = Math.floor(Math.random() * 0xffffff); + const bytes = new Uint8Array([num >> 16, (num >> 8) & 0xff, num & 0xff]); return btoa(String.fromCharCode(...bytes)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, '') + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, "") .substring(0, 4); } +/** + * Detect whether a command launches the Claude CLI. + * Supports env-prefixed commands like: "FOO=bar claude --model opus" + */ +function isClaudeCommand(command: string): boolean { + const tokens = command.trim().split(/\s+/); + for (const token of tokens) { + if (!token) continue; + if (token === "env") continue; + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) continue; + return basename(token) === "claude"; + } + return false; +} + /** * Format current date/time as YYYY-MM-DD HH:MM */ function formatDateTime(): string { const now = new Date(); const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day} ${hours}:${minutes}`; } @@ -86,7 +126,9 @@ function renameClaudeSession(sessionName: string, displayName: string): void { await run(`tmux send-keys -t "${sessionName}" Enter`); // Clean up temp file - try { unlinkSync(tempFile); } catch {} + try { + unlinkSync(tempFile); + } catch {} } catch { // Non-fatal - session naming is a nice-to-have } @@ -107,19 +149,23 @@ function validateSpawnOptions(options: SpawnOptions): void { ].filter(Boolean); if (modes.length === 0) { - fatal('Must specify one of: --project (-p), --issue (-i), --task, --protocol, --shell, --worktree\n\nRun "af spawn --help" for examples.'); + fatal( + 'Must specify one of: --project (-p), --issue (-i), --task, --protocol, --shell, --worktree\n\nRun "af spawn --help" for examples.', + ); } if (modes.length > 1) { - fatal('Flags --project, --issue, --task, --protocol, --shell, --worktree are mutually exclusive'); + fatal( + "Flags --project, --issue, --task, --protocol, --shell, --worktree are mutually exclusive", + ); } if (options.files && !options.task) { - fatal('--files requires --task'); + fatal("--files requires --task"); } if ((options.noComment || options.force) && !options.issue) { - fatal('--no-comment and --force require --issue'); + fatal("--no-comment and --force require --issue"); } } @@ -127,13 +173,13 @@ function validateSpawnOptions(options: SpawnOptions): void { * Determine the spawn mode from options */ function getSpawnMode(options: SpawnOptions): BuilderType { - if (options.project) return 'spec'; - if (options.issue) return 'bugfix'; - if (options.task) return 'task'; - if (options.protocol) return 'protocol'; - if (options.shell) return 'shell'; - if (options.worktree) return 'worktree'; - throw new Error('No mode specified'); + if (options.project) return "spec"; + if (options.issue) return "bugfix"; + if (options.task) return "task"; + if (options.protocol) return "protocol"; + if (options.shell) return "shell"; + if (options.worktree) return "worktree"; + throw new Error("No mode specified"); } // loadRolePrompt imported from ../utils/roles.js @@ -141,38 +187,53 @@ function getSpawnMode(options: SpawnOptions): BuilderType { /** * Load a protocol-specific role if it exists */ -function loadProtocolRole(config: Config, protocolName: string): { content: string; source: string } | null { - const protocolRolePath = resolve(config.codevDir, 'protocols', protocolName, 'role.md'); +function loadProtocolRole( + config: Config, + protocolName: string, +): { content: string; source: string } | null { + const protocolRolePath = resolve( + config.codevDir, + "protocols", + protocolName, + "role.md", + ); if (existsSync(protocolRolePath)) { - return { content: readFileSync(protocolRolePath, 'utf-8'), source: 'protocol' }; + return { + content: readFileSync(protocolRolePath, "utf-8"), + source: "protocol", + }; } // Fall back to builder role - return loadRolePrompt(config, 'builder'); + return loadRolePrompt(config, "builder"); } /** * Find a spec file by project ID */ -async function findSpecFile(codevDir: string, projectId: string): Promise { - const specsDir = resolve(codevDir, 'specs'); +async function findSpecFile( + codevDir: string, + projectId: string, +): Promise { + const specsDir = resolve(codevDir, "specs"); if (!existsSync(specsDir)) { return null; } const files = await readdir(specsDir); - - // Try exact match first (e.g., "0001-feature.md") - for (const file of files) { - if (file.startsWith(projectId) && file.endsWith('.md')) { - return resolve(specsDir, file); - } - } - - // Try partial match (e.g., just "0001") - for (const file of files) { - if (file.startsWith(projectId + '-') && file.endsWith('.md')) { - return resolve(specsDir, file); + const candidates = getSpecIdCandidates(projectId); + + for (const candidate of candidates) { + const candidatePrefix = `${candidate}-`; + const candidateFile = `${candidate}.md`; + for (const file of files) { + const lowerFile = file.toLowerCase(); + if ( + lowerFile.endsWith(".md") && + (lowerFile === candidateFile || lowerFile.startsWith(candidatePrefix)) + ) { + return resolve(specsDir, file); + } } } @@ -183,19 +244,19 @@ async function findSpecFile(codevDir: string, projectId: string): Promise d.isDirectory()) .map((d: Dirent) => d.name); if (dirs.length > 0) { - available = `\n\nAvailable protocols: ${dirs.join(', ')}`; + available = `\n\nAvailable protocols: ${dirs.join(", ")}`; } } fatal(`Protocol not found: ${protocolName}${available}`); @@ -210,12 +271,12 @@ function validateProtocol(config: Config, protocolName: string): void { * Check for required dependencies */ async function checkDependencies(): Promise { - if (!(await commandExists('git'))) { - fatal('git not found'); + if (!(await commandExists("git"))) { + fatal("git not found"); } - if (!(await commandExists('ttyd'))) { - fatal('ttyd not found. Install with: brew install ttyd'); + if (!(await commandExists("ttyd"))) { + fatal("ttyd not found. Install with: brew install ttyd"); } } @@ -238,8 +299,12 @@ async function findFreePort(config: Config): Promise { /** * Create git branch and worktree */ -async function createWorktree(config: Config, branchName: string, worktreePath: string): Promise { - logger.info('Creating branch...'); +async function createWorktree( + config: Config, + branchName: string, + worktreePath: string, +): Promise { + logger.info("Creating branch..."); try { await run(`git branch ${branchName}`, { cwd: config.projectRoot }); } catch (error) { @@ -247,20 +312,22 @@ async function createWorktree(config: Config, branchName: string, worktreePath: logger.debug(`Branch creation: ${error}`); } - logger.info('Creating worktree...'); + logger.info("Creating worktree..."); try { - await run(`git worktree add "${worktreePath}" ${branchName}`, { cwd: config.projectRoot }); + await run(`git worktree add "${worktreePath}" ${branchName}`, { + cwd: config.projectRoot, + }); } catch (error) { fatal(`Failed to create worktree: ${error}`); } // Symlink .env from project root into worktree (if it exists) - const rootEnvPath = resolve(config.projectRoot, '.env'); - const worktreeEnvPath = resolve(worktreePath, '.env'); + const rootEnvPath = resolve(config.projectRoot, ".env"); + const worktreeEnvPath = resolve(worktreePath, ".env"); if (existsSync(rootEnvPath) && !existsSync(worktreeEnvPath)) { try { symlinkSync(rootEnvPath, worktreeEnvPath); - logger.info('Linked .env from project root'); + logger.info("Linked .env from project root"); } catch (error) { logger.debug(`Failed to symlink .env: ${error}`); } @@ -282,25 +349,33 @@ async function startBuilderSession( const port = await findFreePort(config); const sessionName = getSessionName(config, builderId); - logger.info('Creating tmux session...'); + logger.info("Creating tmux session..."); // Write initial prompt to a file for reference - const promptFile = resolve(worktreePath, '.builder-prompt.txt'); + const promptFile = resolve(worktreePath, ".builder-prompt.txt"); writeFileSync(promptFile, prompt); // Build the start script with role if provided - const scriptPath = resolve(worktreePath, '.builder-start.sh'); + const scriptPath = resolve(worktreePath, ".builder-start.sh"); let scriptContent: string; if (roleContent) { // Write role to a file and use $(cat) to avoid shell escaping issues - const roleFile = resolve(worktreePath, '.builder-role.md'); + const roleFile = resolve(worktreePath, ".builder-role.md"); // Inject the actual dashboard port into the role prompt - const roleWithPort = roleContent.replace(/\{PORT\}/g, String(config.dashboardPort)); + const roleWithPort = roleContent.replace( + /\{PORT\}/g, + String(config.dashboardPort), + ); writeFileSync(roleFile, roleWithPort); + + let prompt = buildPromptCommand({ + command: baseCmd, + systemPromptFile: roleFile, + }); logger.info(`Loaded role (${roleSource})`); scriptContent = `#!/bin/bash -exec ${baseCmd} --append-system-prompt "$(cat '${roleFile}')" "$(cat '${promptFile}')" +exec ${prompt} "$(cat '${promptFile}')" `; } else { scriptContent = `#!/bin/bash @@ -309,27 +384,33 @@ exec ${baseCmd} "$(cat '${promptFile}')" } writeFileSync(scriptPath, scriptContent); - chmodSync(scriptPath, '755'); + chmodSync(scriptPath, "755"); // Create tmux session running the script - await run(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${worktreePath}" "${scriptPath}"`); + await run( + `tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${worktreePath}" "${scriptPath}"`, + ); await run(`tmux set-option -t "${sessionName}" status off`); // Enable mouse scrolling in tmux - await run('tmux set -g mouse on'); - await run('tmux set -g set-clipboard on'); - await run('tmux set -g allow-passthrough on'); + await run("tmux set -g mouse on"); + await run("tmux set -g set-clipboard on"); + await run("tmux set -g allow-passthrough on"); // Copy selection to clipboard when mouse is released (pbcopy for macOS) - await run('tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"'); - await run('tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"'); + await run( + 'tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"', + ); + await run( + 'tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"', + ); // Start ttyd connecting to the tmux session - logger.info('Starting builder terminal...'); - const customIndexPath = resolve(config.templatesDir, 'ttyd-index.html'); + logger.info("Starting builder terminal..."); + const customIndexPath = resolve(config.templatesDir, "ttyd-index.html"); const hasCustomIndex = existsSync(customIndexPath); if (hasCustomIndex) { - logger.info('Using custom terminal with file click support'); + logger.info("Using custom terminal with file click support"); } const ttydProcess = spawnTtyd({ @@ -340,11 +421,13 @@ exec ${baseCmd} "$(cat '${promptFile}')" }); if (!ttydProcess?.pid) { - fatal('Failed to start ttyd process for builder'); + fatal("Failed to start ttyd process for builder"); } // Rename Claude session for better history tracking - renameClaudeSession(sessionName, `Builder ${builderId}`); + if (isClaudeCommand(baseCmd)) { + renameClaudeSession(sessionName, `Builder ${builderId}`); + } return { port, pid: ttydProcess.pid, sessionName }; } @@ -360,24 +443,30 @@ async function startShellSession( const port = await findFreePort(config); const sessionName = `shell-${shellId}`; - logger.info('Creating tmux session...'); + logger.info("Creating tmux session..."); // Shell mode: just launch Claude with no prompt - await run(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${config.projectRoot}" "${baseCmd}"`); + await run( + `tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${config.projectRoot}" "${baseCmd}"`, + ); await run(`tmux set-option -t "${sessionName}" status off`); // Enable mouse scrolling in tmux - await run('tmux set -g mouse on'); - await run('tmux set -g set-clipboard on'); - await run('tmux set -g allow-passthrough on'); + await run("tmux set -g mouse on"); + await run("tmux set -g set-clipboard on"); + await run("tmux set -g allow-passthrough on"); // Copy selection to clipboard when mouse is released (pbcopy for macOS) - await run('tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"'); - await run('tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"'); + await run( + 'tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"', + ); + await run( + 'tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"', + ); // Start ttyd connecting to the tmux session - logger.info('Starting shell terminal...'); - const customIndexPath = resolve(config.templatesDir, 'ttyd-index.html'); + logger.info("Starting shell terminal..."); + const customIndexPath = resolve(config.templatesDir, "ttyd-index.html"); const hasCustomIndex = existsSync(customIndexPath); const ttydProcess = spawnTtyd({ @@ -388,11 +477,13 @@ async function startShellSession( }); if (!ttydProcess?.pid) { - fatal('Failed to start ttyd process for shell'); + fatal("Failed to start ttyd process for shell"); } // Rename Claude session for better history tracking - renameClaudeSession(sessionName, `Shell ${shellId}`); + if (isClaudeCommand(baseCmd)) { + renameClaudeSession(sessionName, `Shell ${shellId}`); + } return { port, pid: ttydProcess.pid, sessionName }; } @@ -405,26 +496,47 @@ async function startShellSession( * Spawn builder for a spec (existing behavior) */ async function spawnSpec(options: SpawnOptions, config: Config): Promise { - const projectId = options.project!; + const projectId = normalizeProjectId(options.project!); const specFile = await findSpecFile(config.codevDir, projectId); if (!specFile) { fatal(`Spec not found for project: ${projectId}`); } - const specName = basename(specFile, '.md'); + const specName = basename(specFile, ".md"); const builderId = projectId; - const safeName = specName.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/-+/g, '-'); + const branchBaseName = applyProjectSuffixToSpecName(specName, projectId); + const safeName = branchBaseName + .toLowerCase() + .replace(/[^a-z0-9_-]/g, "-") + .replace(/-+/g, "-"); const branchName = `builder/${safeName}`; const worktreePath = resolve(config.buildersDir, builderId); // Check for corresponding plan file - const planFile = resolve(config.codevDir, 'plans', `${specName}.md`); - const hasPlan = existsSync(planFile); + const planNameCandidate = applyProjectSuffixToSpecName(specName, projectId); + const planFileCandidate = resolve( + config.codevDir, + "plans", + `${planNameCandidate}.md`, + ); + let planName = planNameCandidate; + let hasPlan = existsSync(planFileCandidate); + if (!hasPlan && planNameCandidate !== specName) { + const fallbackPlanFile = resolve( + config.codevDir, + "plans", + `${specName}.md`, + ); + if (existsSync(fallbackPlanFile)) { + planName = specName; + hasPlan = true; + } + } logger.header(`Spawning Builder ${builderId} (spec)`); - logger.kv('Spec', specFile); - logger.kv('Branch', branchName); - logger.kv('Worktree', worktreePath); + logger.kv("Spec", specFile); + logger.kv("Branch", branchName); + logger.kv("Worktree", worktreePath); await ensureDirectories(config); await checkDependencies(); @@ -432,17 +544,36 @@ async function spawnSpec(options: SpawnOptions, config: Config): Promise { // Build the prompt const specRelPath = `codev/specs/${specName}.md`; - const planRelPath = `codev/plans/${specName}.md`; + const planRelPath = `codev/plans/${planName}.md`; + const compliancePrompt = `NON-NEGOTIABLES (follow strictly): +1) Read codev/roles/builder.md and then ${specRelPath}${ + hasPlan ? ` and ${planRelPath}` : "" + } before coding. +2) Study and follow SPIDER (IDER phases) in codev/protocols/spider/protocol.md and commit after each phase. +3) Before coding, inspect in-scope files from the plan and compare them to the plan to determine current phase (resume safely after crashes). +4) Produce the review doc at codev/reviews/${specName}.md. +5) Create a PR and notify the Architect with the PR number/link. +6) Update status with af status set ${builderId} --notify (use blocked/pr-ready/complete). +7) Never stop or go idle without notifying the Architect of your current status. + +Before coding, run this checklist silently and continue without pausing: +- Protocol you will follow (SPIDER/TICK) +- Artifacts you will read and produce (spec/plan to read, review doc to write) +- Your status update plan (when you'll set blocked/pr-ready/complete) +- Current phase assessment based on in-scope files`; + let initialPrompt = `Implement the feature specified in ${specRelPath}.`; if (hasPlan) { initialPrompt += ` Follow the implementation plan in ${planRelPath}.`; } - initialPrompt += ` Start by reading the spec${hasPlan ? ' and plan' : ''}, then begin implementation.`; + initialPrompt += ` Start by reading the spec${ + hasPlan ? " and plan" : "" + }, then begin implementation.`; - const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition. ${initialPrompt}`; + const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.\n\n${compliancePrompt}\n\n${initialPrompt}`; // Load role - const role = options.noRole ? null : loadRolePrompt(config, 'builder'); + const role = options.noRole ? null : loadRolePrompt(config, "builder"); const commands = getResolvedCommands(); const { port, pid, sessionName } = await startBuilderSession( @@ -460,19 +591,19 @@ async function spawnSpec(options: SpawnOptions, config: Config): Promise { name: specName, port, pid, - status: 'spawning', - phase: 'init', + status: "spawning", + phase: "init", worktree: worktreePath, branch: branchName, tmuxSession: sessionName, - type: 'spec', + type: "spec", }; upsertBuilder(builder); logger.blank(); logger.success(`Builder ${builderId} spawned!`); - logger.kv('Terminal', `http://localhost:${port}`); + logger.kv("Terminal", `http://localhost:${port}`); } /** @@ -486,12 +617,15 @@ async function spawnTask(options: SpawnOptions, config: Config): Promise { const worktreePath = resolve(config.buildersDir, builderId); logger.header(`Spawning Builder ${builderId} (task)`); - logger.kv('Task', taskText.substring(0, 60) + (taskText.length > 60 ? '...' : '')); - logger.kv('Branch', branchName); - logger.kv('Worktree', worktreePath); + logger.kv( + "Task", + taskText.substring(0, 60) + (taskText.length > 60 ? "..." : ""), + ); + logger.kv("Branch", branchName); + logger.kv("Worktree", worktreePath); if (options.files && options.files.length > 0) { - logger.kv('Files', options.files.join(', ')); + logger.kv("Files", options.files.join(", ")); } await ensureDirectories(config); @@ -501,13 +635,15 @@ async function spawnTask(options: SpawnOptions, config: Config): Promise { // Build the prompt let prompt = taskText; if (options.files && options.files.length > 0) { - prompt += `\n\nRelevant files to consider:\n${options.files.map(f => `- ${f}`).join('\n')}`; + prompt += `\n\nRelevant files to consider:\n${options.files + .map((f) => `- ${f}`) + .join("\n")}`; } const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition. ${prompt}`; // Load role - const role = options.noRole ? null : loadRolePrompt(config, 'builder'); + const role = options.noRole ? null : loadRolePrompt(config, "builder"); const commands = getResolvedCommands(); const { port, pid, sessionName } = await startBuilderSession( @@ -522,15 +658,17 @@ async function spawnTask(options: SpawnOptions, config: Config): Promise { const builder: Builder = { id: builderId, - name: `Task: ${taskText.substring(0, 30)}${taskText.length > 30 ? '...' : ''}`, + name: `Task: ${taskText.substring(0, 30)}${ + taskText.length > 30 ? "..." : "" + }`, port, pid, - status: 'spawning', - phase: 'init', + status: "spawning", + phase: "init", worktree: worktreePath, branch: branchName, tmuxSession: sessionName, - type: 'task', + type: "task", taskText, }; @@ -538,13 +676,16 @@ async function spawnTask(options: SpawnOptions, config: Config): Promise { logger.blank(); logger.success(`Builder ${builderId} spawned!`); - logger.kv('Terminal', `http://localhost:${port}`); + logger.kv("Terminal", `http://localhost:${port}`); } /** * Spawn builder to run a protocol */ -async function spawnProtocol(options: SpawnOptions, config: Config): Promise { +async function spawnProtocol( + options: SpawnOptions, + config: Config, +): Promise { const protocolName = options.protocol!; validateProtocol(config, protocolName); @@ -554,9 +695,9 @@ async function spawnProtocol(options: SpawnOptions, config: Config): Promise { +async function spawnShell( + options: SpawnOptions, + config: Config, +): Promise { const shortId = generateShortId(); const shellId = `shell-${shortId}`; @@ -624,64 +768,76 @@ async function spawnShell(options: SpawnOptions, config: Config): Promise // They don't have worktrees or branches const builder: Builder = { id: shellId, - name: 'Shell session', + name: "Shell session", port, pid, - status: 'spawning', - phase: 'interactive', - worktree: '', - branch: '', + status: "spawning", + phase: "interactive", + worktree: "", + branch: "", tmuxSession: sessionName, - type: 'shell', + type: "shell", }; upsertBuilder(builder); logger.blank(); logger.success(`Shell ${shellId} spawned!`); - logger.kv('Terminal', `http://localhost:${port}`); + logger.kv("Terminal", `http://localhost:${port}`); } /** * Spawn a worktree session (has worktree/branch, but no initial prompt) * Use case: Small features without spec/plan, like quick fixes */ -async function spawnWorktree(options: SpawnOptions, config: Config): Promise { +async function spawnWorktree( + options: SpawnOptions, + config: Config, +): Promise { const shortId = generateShortId(); const builderId = `worktree-${shortId}`; const branchName = `builder/worktree-${shortId}`; const worktreePath = resolve(config.buildersDir, builderId); logger.header(`Spawning Worktree ${builderId}`); - logger.kv('Branch', branchName); - logger.kv('Worktree', worktreePath); + logger.kv("Branch", branchName); + logger.kv("Worktree", worktreePath); await ensureDirectories(config); await checkDependencies(); await createWorktree(config, branchName, worktreePath); // Load builder role - const role = options.noRole ? null : loadRolePrompt(config, 'builder'); + const role = options.noRole ? null : loadRolePrompt(config, "builder"); const commands = getResolvedCommands(); // Worktree mode: launch Claude with no prompt, but in the worktree directory const port = await findFreePort(config); const sessionName = getSessionName(config, builderId); - logger.info('Creating tmux session...'); + logger.info("Creating tmux session..."); // Build launch script (with role if provided) to avoid shell escaping issues - const scriptPath = resolve(worktreePath, '.builder-start.sh'); + const scriptPath = resolve(worktreePath, ".builder-start.sh"); let scriptContent: string; if (role) { - const roleFile = resolve(worktreePath, '.builder-role.md'); + const roleFile = resolve(worktreePath, ".builder-role.md"); // Inject the actual dashboard port into the role prompt - const roleWithPort = role.content.replace(/\{PORT\}/g, String(config.dashboardPort)); + const roleWithPort = role.content.replace( + /\{PORT\}/g, + String(config.dashboardPort), + ); writeFileSync(roleFile, roleWithPort); logger.info(`Loaded role (${role.source})`); + + let prompt = buildPromptCommand({ + command: commands.builder, + systemPromptFile: roleFile, + }); + scriptContent = `#!/bin/bash -exec ${commands.builder} --append-system-prompt "$(cat '${roleFile}')" +exec ${prompt} `; } else { scriptContent = `#!/bin/bash @@ -692,24 +848,34 @@ exec ${commands.builder} writeFileSync(scriptPath, scriptContent, { mode: 0o755 }); // Create tmux session running the launch script - await run(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${worktreePath}" "${scriptPath}"`); + await run( + `tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${worktreePath}" "${scriptPath}"`, + ); await run(`tmux set-option -t "${sessionName}" status off`); // Enable mouse scrolling in tmux - await run('tmux set -g mouse on'); - await run('tmux set -g set-clipboard on'); - await run('tmux set -g allow-passthrough on'); + await run("tmux set -g mouse on"); + await run("tmux set -g set-clipboard on"); + await run("tmux set -g allow-passthrough on"); // Copy selection to clipboard when mouse is released (pbcopy for macOS) - await run('tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"'); - await run('tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"'); + await run( + 'tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"', + ); + await run( + 'tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"', + ); // Start ttyd connecting to the tmux session - logger.info('Starting worktree terminal...'); - const customIndexPath = resolve(config.codevDir, 'templates', 'ttyd-index.html'); + logger.info("Starting worktree terminal..."); + const customIndexPath = resolve( + config.codevDir, + "templates", + "ttyd-index.html", + ); const hasCustomIndex = existsSync(customIndexPath); if (hasCustomIndex) { - logger.info('Using custom terminal with file click support'); + logger.info("Using custom terminal with file click support"); } const ttydProcess = spawnTtyd({ @@ -720,27 +886,27 @@ exec ${commands.builder} }); if (!ttydProcess?.pid) { - fatal('Failed to start ttyd process for worktree'); + fatal("Failed to start ttyd process for worktree"); } const builder: Builder = { id: builderId, - name: 'Worktree session', + name: "Worktree session", port, pid: ttydProcess.pid, - status: 'spawning', - phase: 'interactive', + status: "spawning", + phase: "interactive", worktree: worktreePath, branch: branchName, tmuxSession: sessionName, - type: 'worktree', + type: "worktree", }; upsertBuilder(builder); logger.blank(); logger.success(`Worktree ${builderId} spawned!`); - logger.kv('Terminal', `http://localhost:${port}`); + logger.kv("Terminal", `http://localhost:${port}`); } /** @@ -749,10 +915,10 @@ exec ${commands.builder} function slugify(title: string): string { return title .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens - .replace(/-+/g, '-') // Collapse multiple hyphens - .replace(/^-|-$/g, '') // Trim leading/trailing hyphens - .slice(0, 30); // Max 30 chars + .replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens + .replace(/-+/g, "-") // Collapse multiple hyphens + .replace(/^-|-$/g, "") // Trim leading/trailing hyphens + .slice(0, 30); // Max 30 chars } /** @@ -774,10 +940,14 @@ interface GitHubIssue { */ async function fetchGitHubIssue(issueNumber: number): Promise { try { - const result = await run(`gh issue view ${issueNumber} --json title,body,state,comments`); + const result = await run( + `gh issue view ${issueNumber} --json title,body,state,comments`, + ); return JSON.parse(result.stdout); } catch (error) { - fatal(`Failed to fetch issue #${issueNumber}. Ensure 'gh' CLI is installed and authenticated.`); + fatal( + `Failed to fetch issue #${issueNumber}. Ensure 'gh' CLI is installed and authenticated.`, + ); throw error; // TypeScript doesn't know fatal() never returns } } @@ -793,12 +963,14 @@ async function checkBugfixCollisions( ): Promise { // 1. Check if worktree already exists if (existsSync(worktreePath)) { - fatal(`Worktree already exists at ${worktreePath}\nRun: af cleanup --issue ${issueNumber}`); + fatal( + `Worktree already exists at ${worktreePath}\nRun: af cleanup --issue ${issueNumber}`, + ); } // 2. Check for recent "On it" comments (< 24h old) const onItComments = issue.comments.filter((c) => - c.body.toLowerCase().includes('on it'), + c.body.toLowerCase().includes("on it"), ); if (onItComments.length > 0) { const lastComment = onItComments[onItComments.length - 1]; @@ -807,31 +979,48 @@ async function checkBugfixCollisions( if (hoursAgo < 24) { if (!force) { - fatal(`Issue #${issueNumber} has "On it" comment from ${hoursAgo}h ago (by @${lastComment.author.login}).\nSomeone may already be working on this. Use --force to override.`); + fatal( + `Issue #${issueNumber} has "On it" comment from ${hoursAgo}h ago (by @${lastComment.author.login}).\nSomeone may already be working on this. Use --force to override.`, + ); } - logger.warn(`Warning: "On it" comment from ${hoursAgo}h ago - proceeding with --force`); + logger.warn( + `Warning: "On it" comment from ${hoursAgo}h ago - proceeding with --force`, + ); } else { - logger.warn(`Warning: Stale "On it" comment (${hoursAgo}h ago). Proceeding.`); + logger.warn( + `Warning: Stale "On it" comment (${hoursAgo}h ago). Proceeding.`, + ); } } // 3. Check for open PRs referencing this issue try { - const prResult = await run(`gh pr list --search "in:body #${issueNumber}" --json number,title --limit 5`); + const prResult = await run( + `gh pr list --search "in:body #${issueNumber}" --json number,title --limit 5`, + ); const openPRs = JSON.parse(prResult.stdout); if (openPRs.length > 0) { if (!force) { - const prList = openPRs.map((pr: { number: number; title: string }) => ` - PR #${pr.number}: ${pr.title}`).join('\n'); - fatal(`Found ${openPRs.length} open PR(s) referencing issue #${issueNumber}:\n${prList}\nUse --force to proceed anyway.`); + const prList = openPRs + .map( + (pr: { number: number; title: string }) => + ` - PR #${pr.number}: ${pr.title}`, + ) + .join("\n"); + fatal( + `Found ${openPRs.length} open PR(s) referencing issue #${issueNumber}:\n${prList}\nUse --force to proceed anyway.`, + ); } - logger.warn(`Warning: Found ${openPRs.length} open PR(s) referencing issue - proceeding with --force`); + logger.warn( + `Warning: Found ${openPRs.length} open PR(s) referencing issue - proceeding with --force`, + ); } } catch { // Non-fatal: continue if PR check fails } // 4. Warn if issue is already closed - if (issue.state === 'CLOSED') { + if (issue.state === "CLOSED") { logger.warn(`Warning: Issue #${issueNumber} is already closed`); } } @@ -839,13 +1028,16 @@ async function checkBugfixCollisions( /** * Spawn builder for a GitHub issue (bugfix mode) */ -async function spawnBugfix(options: SpawnOptions, config: Config): Promise { +async function spawnBugfix( + options: SpawnOptions, + config: Config, +): Promise { const issueNumber = options.issue!; logger.header(`Spawning Bugfix Builder for Issue #${issueNumber}`); // Fetch issue from GitHub - logger.info('Fetching issue from GitHub...'); + logger.info("Fetching issue from GitHub..."); const issue = await fetchGitHubIssue(issueNumber); const slug = slugify(issue.title); @@ -853,12 +1045,17 @@ async function spawnBugfix(options: SpawnOptions, config: Config): Promise const branchName = `builder/bugfix-${issueNumber}-${slug}`; const worktreePath = resolve(config.buildersDir, builderId); - logger.kv('Title', issue.title); - logger.kv('Branch', branchName); - logger.kv('Worktree', worktreePath); + logger.kv("Title", issue.title); + logger.kv("Branch", branchName); + logger.kv("Worktree", worktreePath); // Check for collisions - await checkBugfixCollisions(issueNumber, worktreePath, issue, !!options.force); + await checkBugfixCollisions( + issueNumber, + worktreePath, + issue, + !!options.force, + ); await ensureDirectories(config); await checkDependencies(); @@ -866,11 +1063,13 @@ async function spawnBugfix(options: SpawnOptions, config: Config): Promise // Comment on the issue (unless --no-comment) if (!options.noComment) { - logger.info('Commenting on issue...'); + logger.info("Commenting on issue..."); try { - await run(`gh issue comment ${issueNumber} --body "On it! Working on a fix now."`); + await run( + `gh issue comment ${issueNumber} --body "On it! Working on a fix now."`, + ); } catch { - logger.warn('Warning: Failed to comment on issue (continuing anyway)'); + logger.warn("Warning: Failed to comment on issue (continuing anyway)"); } } @@ -884,7 +1083,7 @@ Follow the BUGFIX protocol: codev/protocols/bugfix/protocol.md **Title**: ${issue.title} **Description**: -${issue.body || '(No description provided)'} +${issue.body || "(No description provided)"} ## Your Mission 1. Reproduce the bug @@ -902,7 +1101,7 @@ Start by reading the issue and reproducing the bug.`; const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.\n\n${prompt}`; // Load role - const role = options.noRole ? null : loadRolePrompt(config, 'builder'); + const role = options.noRole ? null : loadRolePrompt(config, "builder"); const commands = getResolvedCommands(); const { port, pid, sessionName } = await startBuilderSession( @@ -917,15 +1116,17 @@ Start by reading the issue and reproducing the bug.`; const builder: Builder = { id: builderId, - name: `Bugfix #${issueNumber}: ${issue.title.substring(0, 40)}${issue.title.length > 40 ? '...' : ''}`, + name: `Bugfix #${issueNumber}: ${issue.title.substring(0, 40)}${ + issue.title.length > 40 ? "..." : "" + }`, port, pid, - status: 'spawning', - phase: 'init', + status: "spawning", + phase: "init", worktree: worktreePath, branch: branchName, tmuxSession: sessionName, - type: 'bugfix', + type: "bugfix", issueNumber, }; @@ -933,7 +1134,7 @@ Start by reading the issue and reproducing the bug.`; logger.blank(); logger.success(`Bugfix builder for issue #${issueNumber} spawned!`); - logger.kv('Terminal', `http://localhost:${port}`); + logger.kv("Terminal", `http://localhost:${port}`); } // ============================================================================= @@ -951,7 +1152,7 @@ export async function spawn(options: SpawnOptions): Promise { // Prune stale worktrees before spawning to prevent "can't find session" errors // This catches orphaned worktrees from crashes, manual kills, or incomplete cleanups try { - await run('git worktree prune', { cwd: config.projectRoot }); + await run("git worktree prune", { cwd: config.projectRoot }); } catch { // Non-fatal - continue with spawn even if prune fails } @@ -959,22 +1160,22 @@ export async function spawn(options: SpawnOptions): Promise { const mode = getSpawnMode(options); switch (mode) { - case 'spec': + case "spec": await spawnSpec(options, config); break; - case 'bugfix': + case "bugfix": await spawnBugfix(options, config); break; - case 'task': + case "task": await spawnTask(options, config); break; - case 'protocol': + case "protocol": await spawnProtocol(options, config); break; - case 'shell': + case "shell": await spawnShell(options, config); break; - case 'worktree': + case "worktree": await spawnWorktree(options, config); break; } diff --git a/packages/codev/src/agent-farm/commands/start.ts b/packages/codev/src/agent-farm/commands/start.ts index a3d17b85..c9823937 100644 --- a/packages/codev/src/agent-farm/commands/start.ts +++ b/packages/codev/src/agent-farm/commands/start.ts @@ -2,22 +2,35 @@ * Start command - launches the architect dashboard */ -import { resolve, basename, join } from 'node:path'; -import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; -import { spawn, spawnSync, type ChildProcess } from 'node:child_process'; -import * as net from 'node:net'; -import type { StartOptions, ArchitectState } from '../types.js'; -import { version as localVersion } from '../../version.js'; -import { getConfig, ensureDirectories } from '../utils/index.js'; -import { logger, fatal } from '../utils/logger.js'; -import { spawnDetached, commandExists, findAvailablePort, openBrowser, run, spawnTtyd, isProcessRunning } from '../utils/shell.js'; -import { checkCoreDependencies } from '../utils/deps.js'; -import { loadState, setArchitect } from '../state.js'; -import { handleOrphanedSessions, warnAboutStaleArtifacts } from '../utils/orphan-handler.js'; -import { getPortBlock, cleanupStaleEntries } from '../utils/port-registry.js'; -import { loadRolePrompt } from '../utils/roles.js'; +import { resolve, basename, join } from "node:path"; +import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import * as net from "node:net"; +import type { StartOptions, ArchitectState } from "../types.js"; +import { version as localVersion } from "../../version.js"; +import { getConfig, ensureDirectories } from "../utils/index.js"; +import { logger, fatal } from "../utils/logger.js"; +import { + spawnDetached, + commandExists, + findAvailablePort, + openBrowser, + run, + spawnTtyd, + isProcessRunning, +} from "../utils/shell.js"; +import { checkCoreDependencies } from "../utils/deps.js"; +import { loadState, setArchitect } from "../state.js"; +import { + handleOrphanedSessions, + warnAboutStaleArtifacts, +} from "../utils/orphan-handler.js"; +import { getPortBlock, cleanupStaleEntries } from "../utils/port-registry.js"; +import { loadRolePrompt } from "../utils/roles.js"; +import { writeLaunchScript } from "../utils/launch-script.js"; +import { buildPromptCommand } from "../../lib/prompt-command.js"; /** * Format current date/time as YYYY-MM-DD HH:MM @@ -25,10 +38,10 @@ import { loadRolePrompt } from '../utils/roles.js'; function formatDateTime(): string { const now = new Date(); const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); return `${year}-${month}-${day} ${hours}:${minutes}`; } @@ -55,7 +68,9 @@ function renameClaudeSession(sessionName: string, displayName: string): void { await run(`tmux send-keys -t "${sessionName}" Enter`); // Clean up temp file - try { unlinkSync(tempFile); } catch {} + try { + unlinkSync(tempFile); + } catch {} } catch { // Non-fatal - session naming is a nice-to-have } @@ -78,15 +93,15 @@ interface ParsedRemote { export function isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); - server.once('error', () => { + server.once("error", () => { resolve(false); }); - server.once('listening', () => { + server.once("listening", () => { server.close(() => { resolve(true); }); }); - server.listen(port, '127.0.0.1'); + server.listen(port, "127.0.0.1"); }); } @@ -97,10 +112,12 @@ export function parseRemote(remote: string): ParsedRemote { // Match: user@host or user@host:/path const match = remote.match(/^([^@]+)@([^:]+)(?::(.+))?$/); if (!match) { - throw new Error(`Invalid remote format: ${remote}. Use user@host or user@host:/path`); + throw new Error( + `Invalid remote format: ${remote}. Use user@host or user@host:/path` + ); } // Strip trailing slash to avoid duplicate port allocations (e.g., /path/ vs /path) - const remotePath = match[3]?.replace(/\/$/, ''); + const remotePath = match[3]?.replace(/\/$/, ""); return { user: match[1], host: match[2], remotePath }; } @@ -110,25 +127,35 @@ export function parseRemote(remote: string): ParsedRemote { * Check if passwordless SSH is configured for a host * Returns true if SSH works without password, false otherwise */ -async function checkPasswordlessSSH(user: string, host: string): Promise<{ ok: boolean; error?: string }> { +async function checkPasswordlessSSH( + user: string, + host: string +): Promise<{ ok: boolean; error?: string }> { return new Promise((resolve) => { - const ssh = spawn('ssh', [ - '-o', 'ConnectTimeout=10', - '-o', 'BatchMode=yes', // Fail immediately if password required - '-o', 'StrictHostKeyChecking=accept-new', - `${user}@${host}`, - 'true', // Just run 'true' to test connection - ], { - stdio: ['ignore', 'ignore', 'pipe'], - }); + const ssh = spawn( + "ssh", + [ + "-o", + "ConnectTimeout=10", + "-o", + "BatchMode=yes", // Fail immediately if password required + "-o", + "StrictHostKeyChecking=accept-new", + `${user}@${host}`, + "true", // Just run 'true' to test connection + ], + { + stdio: ["ignore", "ignore", "pipe"], + } + ); - let stderr = ''; - ssh.stderr?.on('data', (data: Buffer) => { + let stderr = ""; + ssh.stderr?.on("data", (data: Buffer) => { stderr += data.toString(); }); - ssh.on('error', (err) => resolve({ ok: false, error: err.message })); - ssh.on('exit', (code) => { + ssh.on("error", (err) => resolve({ ok: false, error: err.message })); + ssh.on("exit", (code) => { if (code === 0) { resolve({ ok: true }); } else { @@ -139,7 +166,7 @@ async function checkPasswordlessSSH(user: string, host: string): Promise<{ ok: b // Timeout after 15 seconds setTimeout(() => { ssh.kill(); - resolve({ ok: false, error: 'connection timeout' }); + resolve({ ok: false, error: "connection timeout" }); }, 15000); }); } @@ -148,32 +175,40 @@ async function checkPasswordlessSSH(user: string, host: string): Promise<{ ok: b * Check remote CLI versions and warn about mismatches */ async function checkRemoteVersions(user: string, host: string): Promise { - const commands = ['codev', 'af', 'consult', 'generate-image']; - const versionCmd = commands.map(cmd => `${cmd} --version 2>/dev/null || echo "${cmd}: not found"`).join(' && echo "---" && '); + const commands = ["codev", "af", "consult", "generate-image"]; + const versionCmd = commands + .map((cmd) => `${cmd} --version 2>/dev/null || echo "${cmd}: not found"`) + .join(' && echo "---" && '); // Wrap in bash -l to source login environment (gets PATH from .profile) const wrappedCmd = `bash -l -c '${versionCmd.replace(/'/g, "'\\''")}'`; return new Promise((resolve) => { - const ssh = spawn('ssh', [ - '-o', 'ConnectTimeout=5', - '-o', 'BatchMode=yes', - `${user}@${host}`, - wrappedCmd, - ], { - stdio: ['ignore', 'pipe', 'pipe'], - }); + const ssh = spawn( + "ssh", + [ + "-o", + "ConnectTimeout=5", + "-o", + "BatchMode=yes", + `${user}@${host}`, + wrappedCmd, + ], + { + stdio: ["ignore", "pipe", "pipe"], + } + ); - let stdout = ''; - ssh.stdout?.on('data', (data: Buffer) => { + let stdout = ""; + ssh.stdout?.on("data", (data: Buffer) => { stdout += data.toString(); }); - ssh.on('error', () => { + ssh.on("error", () => { // SSH failed, skip version check resolve(); }); - ssh.on('exit', (code) => { + ssh.on("exit", (code) => { if (code !== 0) { // SSH failed or commands failed, skip version check resolve(); @@ -181,14 +216,14 @@ async function checkRemoteVersions(user: string, host: string): Promise { } // Parse output: each command's version separated by "---" - const outputs = stdout.split('---').map(s => s.trim()); + const outputs = stdout.split("---").map((s) => s.trim()); const mismatches: string[] = []; for (let i = 0; i < commands.length && i < outputs.length; i++) { const output = outputs[i]; const cmd = commands[i]; - if (output.includes('not found')) { + if (output.includes("not found")) { mismatches.push(`${cmd}: not installed on remote`); } else { // Extract version number (e.g., "1.5.3" from "@cluesmith/codev@1.5.3" or "1.5.3") @@ -196,7 +231,9 @@ async function checkRemoteVersions(user: string, host: string): Promise { if (versionMatch) { const remoteVer = versionMatch[1]; if (remoteVer !== localVersion) { - mismatches.push(`${cmd}: local ${localVersion}, remote ${remoteVer}`); + mismatches.push( + `${cmd}: local ${localVersion}, remote ${remoteVer}` + ); } } } @@ -204,11 +241,11 @@ async function checkRemoteVersions(user: string, host: string): Promise { if (mismatches.length > 0) { logger.blank(); - logger.warn('Version mismatch detected:'); + logger.warn("Version mismatch detected:"); for (const m of mismatches) { logger.warn(` ${m}`); } - logger.info('Consider updating: npm install -g @cluesmith/codev'); + logger.info("Consider updating: npm install -g @cluesmith/codev"); logger.blank(); } @@ -240,14 +277,14 @@ async function startRemote(options: StartOptions): Promise { localPort = Number(options.port); } else { // Register with a unique key for this remote target - const remoteKey = `remote:${user}@${host}:${remotePath || 'default'}`; + const remoteKey = `remote:${user}@${host}:${remotePath || "default"}`; localPort = getPortBlock(remoteKey); } - logger.header('Starting Remote Agent Farm'); - logger.kv('Host', `${user}@${host}`); - if (remotePath) logger.kv('Path', remotePath); - logger.kv('Local Port', localPort); + logger.header("Starting Remote Agent Farm"); + logger.kv("Host", `${user}@${host}`); + if (remotePath) logger.kv("Path", remotePath); + logger.kv("Local Port", localPort); // Build the remote command // If no path specified, use the current directory name to find project on remote @@ -261,7 +298,7 @@ async function startRemote(options: StartOptions): Promise { const remoteCommand = `bash -l -c '${innerCommand.replace(/'/g, "'\\''")}'`; // Check passwordless SSH is configured - logger.info('Checking SSH connection...'); + logger.info("Checking SSH connection..."); const sshResult = await checkPasswordlessSSH(user, host); if (!sshResult.ok) { logger.blank(); @@ -275,44 +312,51 @@ Then verify with: } // Check remote CLI versions (non-blocking warning) - logger.info('Checking remote versions...'); + logger.info("Checking remote versions..."); await checkRemoteVersions(user, host); - logger.info('Connecting via SSH...'); + logger.info("Connecting via SSH..."); // Spawn SSH with port forwarding, -f backgrounds after auth const sshArgs = [ - '-f', // Background after authentication - '-L', `${localPort}:localhost:${localPort}`, - '-o', 'ServerAliveInterval=30', - '-o', 'ServerAliveCountMax=3', - '-o', 'ExitOnForwardFailure=yes', + "-f", // Background after authentication + "-L", + `${localPort}:localhost:${localPort}`, + "-o", + "ServerAliveInterval=30", + "-o", + "ServerAliveCountMax=3", + "-o", + "ExitOnForwardFailure=yes", `${user}@${host}`, remoteCommand, ]; - const result = spawnSync('ssh', sshArgs, { - stdio: 'inherit', + const result = spawnSync("ssh", sshArgs, { + stdio: "inherit", }); if (result.status !== 0) { - logger.error('SSH connection failed'); + logger.error("SSH connection failed"); process.exit(1); } logger.blank(); - logger.success('Remote Agent Farm connected!'); - logger.kv('Dashboard', `http://localhost:${localPort}`); - logger.info('SSH tunnel running in background'); + logger.success("Remote Agent Farm connected!"); + logger.kv("Dashboard", `http://localhost:${localPort}`); + logger.info("SSH tunnel running in background"); if (!options.noBrowser) { await openBrowser(`http://localhost:${localPort}`); } // Find and report the SSH PID for cleanup - const pgrep = spawnSync('pgrep', ['-f', `ssh.*${localPort}:localhost:${localPort}.*${host}`]); + const pgrep = spawnSync("pgrep", [ + "-f", + `ssh.*${localPort}:localhost:${localPort}.*${host}`, + ]); if (pgrep.status === 0) { - const pid = pgrep.stdout.toString().trim().split('\n')[0]; + const pid = pgrep.stdout.toString().trim().split("\n")[0]; logger.info(`To disconnect: kill ${pid}`); } } @@ -351,14 +395,14 @@ export async function start(options: StartOptions = {}): Promise { // In remote mode (--no-browser), keep process alive so SSH tunnel stays connected if (options.noBrowser) { - logger.info('Keeping connection alive for remote tunnel...'); + logger.info("Keeping connection alive for remote tunnel..."); // Block forever - SSH disconnect will kill us await new Promise(() => {}); } return; } else { // PID is dead but state exists - clear stale state and continue - logger.info('Clearing stale architect state (process no longer running)'); + logger.info("Clearing stale architect state (process no longer running)"); setArchitect(null); } } @@ -371,33 +415,44 @@ export async function start(options: StartOptions = {}): Promise { // Determine dashboard port early (needed for role prompt and server) // If --port was specified, use it for dashboard (important for remote tunneling) - const dashboardPort = options.port ? Number(options.port) : config.dashboardPort; + const dashboardPort = options.port + ? Number(options.port) + : config.dashboardPort; // Command is passed from index.ts (already resolved via CLI > config.json > default) - let cmd = options.cmd || 'claude'; + let cmd = options.cmd || "claude"; // Check if base command exists before we wrap it in a launch script - const baseCmdName = cmd.split(' ')[0]; + const baseCmdName = cmd.split(" ")[0]; if (!(await commandExists(baseCmdName))) { fatal(`Command not found: ${baseCmdName}`); } // Load architect role if available and not disabled if (!options.noRole) { - const role = loadRolePrompt(config, 'architect'); + const role = loadRolePrompt(config, "architect"); if (role) { // Write role to a file and create a launch script to avoid shell escaping issues // The architect.md file contains backticks, $variables, and other shell-sensitive chars - const roleFile = resolve(config.stateDir, 'architect-role.md'); + const roleFile = resolve(config.stateDir, "architect-role.md"); // Inject the actual dashboard port into the role prompt - const roleContent = role.content.replace(/\{PORT\}/g, String(dashboardPort)); - writeFileSync(roleFile, roleContent, 'utf-8'); - - const launchScript = resolve(config.stateDir, 'launch-architect.sh'); - writeFileSync(launchScript, `#!/bin/bash -cd "${config.projectRoot}" -exec ${cmd} --append-system-prompt "$(cat '${roleFile}')" -`, { mode: 0o755 }); + const roleContent = role.content.replace( + /\{PORT\}/g, + String(dashboardPort) + ); + writeFileSync(roleFile, roleContent, "utf-8"); + + const launchScript = resolve(config.stateDir, "launch-architect.sh"); + + writeLaunchScript({ + scriptPath: launchScript, + cwd: config.projectRoot, + execCommand: buildPromptCommand({ + command: cmd, + systemPromptFile: roleFile, + }), + mode: 0o755, + }); cmd = launchScript; logger.info(`Loaded architect role (${role.source})`); @@ -409,16 +464,22 @@ exec ${cmd} --append-system-prompt "$(cat '${roleFile}')" let architectPort = config.architectPort; if (options.port !== undefined) { const parsedPort = Number(options.port); - if (!Number.isFinite(parsedPort) || parsedPort < 1024 || parsedPort > 65535) { - fatal(`Invalid port: ${options.port}. Must be a number between 1024-65535`); + if ( + !Number.isFinite(parsedPort) || + parsedPort < 1024 || + parsedPort > 65535 + ) { + fatal( + `Invalid port: ${options.port}. Must be a number between 1024-65535` + ); } architectPort = parsedPort + 1; // Offset from dashboard port } - logger.header('Starting Agent Farm'); - logger.kv('Project', config.projectRoot); - logger.kv('Command', cmd); - logger.kv('Port', architectPort); + logger.header("Starting Agent Farm"); + logger.kv("Project", config.projectRoot); + logger.kv("Command", cmd); + logger.kv("Port", architectPort); // Start architect in tmux session for persistence // Use port in session name to ensure uniqueness across projects @@ -433,29 +494,41 @@ exec ${cmd} --append-system-prompt "$(cat '${roleFile}')" // Create tmux session with the command // Note: Inner double quotes handle paths with spaces (e.g., "My Drive") - await run(`tmux new-session -d -s ${sessionName} -x 200 -y 50 '"${cmd}"'`, { cwd: config.projectRoot }); + await run(`tmux new-session -d -s ${sessionName} -x 200 -y 50 '"${cmd}"'`, { + cwd: config.projectRoot, + }); await run(`tmux set-option -t ${sessionName} status off`); await run(`tmux set-option -t ${sessionName} -g mouse on`); await run(`tmux set-option -t ${sessionName} -g set-clipboard on`); await run(`tmux set-option -t ${sessionName} -g allow-passthrough on`); // Copy selection to clipboard when mouse is released (pbcopy for macOS) - await run(`tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`); - await run(`tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"`); + await run( + `tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"` + ); + await run( + `tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"` + ); // Start ttyd attached to the tmux session - const customIndexPath = resolve(config.templatesDir, 'ttyd-index.html'); + const customIndexPath = resolve(config.templatesDir, "ttyd-index.html"); const hasCustomIndex = existsSync(customIndexPath); if (hasCustomIndex) { - logger.info('Using custom terminal with file click support'); + logger.info("Using custom terminal with file click support"); } - const bindHost = options.allowInsecureRemote ? '0.0.0.0' : undefined; + const bindHost = options.allowInsecureRemote ? "0.0.0.0" : undefined; if (options.allowInsecureRemote) { - logger.warn('⚠️ INSECURE MODE: Binding to 0.0.0.0 - accessible from any network!'); - logger.warn(' No authentication - anyone on your network can access the terminal.'); - logger.warn(' DEPRECATED: Use `af start --remote user@host` for secure remote access instead.'); + logger.warn( + "⚠️ INSECURE MODE: Binding to 0.0.0.0 - accessible from any network!" + ); + logger.warn( + " No authentication - anyone on your network can access the terminal." + ); + logger.warn( + " DEPRECATED: Use `af start --remote user@host` for secure remote access instead." + ); } const ttydProcess = spawnTtyd({ @@ -467,7 +540,7 @@ exec ${cmd} --append-system-prompt "$(cat '${roleFile}')" }); if (!ttydProcess?.pid) { - fatal('Failed to start ttyd process'); + fatal("Failed to start ttyd process"); } // Rename Claude session for better history tracking @@ -489,11 +562,16 @@ exec ${cmd} --append-system-prompt "$(cat '${roleFile}')" await new Promise((resolve) => setTimeout(resolve, 500)); // Start the dashboard server on the main port - await startDashboard(config.projectRoot, dashboardPort, architectPort, bindHost); + await startDashboard( + config.projectRoot, + dashboardPort, + architectPort, + bindHost + ); logger.blank(); - logger.success('Agent Farm started!'); - logger.kv('Dashboard', `http://localhost:${dashboardPort}`); + logger.success("Agent Farm started!"); + logger.kv("Dashboard", `http://localhost:${dashboardPort}`); // Open dashboard in browser (unless --no-browser) if (!options.noBrowser) { @@ -505,14 +583,17 @@ exec ${cmd} --append-system-prompt "$(cat '${roleFile}')" * Wait for a server to respond on a given port * Returns true if server responds, false if timeout */ -async function waitForServer(port: number, timeoutMs: number = 5000): Promise { +async function waitForServer( + port: number, + timeoutMs: number = 5000 +): Promise { const startTime = Date.now(); const pollInterval = 100; while (Date.now() - startTime < timeoutMs) { try { const response = await fetch(`http://localhost:${port}/`, { - method: 'HEAD', + method: "HEAD", signal: AbortSignal.timeout(500), }); if (response.ok || response.status === 404) { @@ -530,12 +611,17 @@ async function waitForServer(port: number, timeoutMs: number = 5000): Promise { +async function startDashboard( + projectRoot: string, + port: number, + _architectPort: number, + bindHost?: string +): Promise { const config = getConfig(); // Try TypeScript source first (dev mode), then compiled JS - const tsScript = resolve(config.serversDir, 'dashboard-server.ts'); - const jsScript = resolve(config.serversDir, 'dashboard-server.js'); + const tsScript = resolve(config.serversDir, "dashboard-server.ts"); + const jsScript = resolve(config.serversDir, "dashboard-server.js"); let command: string; let args: string[]; @@ -548,32 +634,34 @@ async function startDashboard(projectRoot: string, port: number, _architectPort: if (existsSync(tsScript)) { // Dev mode: run with tsx - command = 'npx'; - args = ['tsx', tsScript, ...serverArgs]; + command = "npx"; + args = ["tsx", tsScript, ...serverArgs]; } else if (existsSync(jsScript)) { // Prod mode: run compiled JS - command = 'node'; + command = "node"; args = [jsScript, ...serverArgs]; } else { - logger.warn('Dashboard server not found, skipping dashboard'); + logger.warn("Dashboard server not found, skipping dashboard"); return; } - logger.debug(`Starting dashboard: ${command} ${args.join(' ')}`); + logger.debug(`Starting dashboard: ${command} ${args.join(" ")}`); const serverProcess = spawnDetached(command, args, { cwd: projectRoot, }); if (!serverProcess.pid) { - logger.warn('Failed to start dashboard server'); + logger.warn("Failed to start dashboard server"); return; } // Wait for server to actually be ready const isReady = await waitForServer(port, 5000); if (!isReady) { - logger.warn(`Dashboard server did not respond on port ${port} within 5 seconds`); - logger.warn('Check for errors above or run with DEBUG=1 for more details'); + logger.warn( + `Dashboard server did not respond on port ${port} within 5 seconds` + ); + logger.warn("Check for errors above or run with DEBUG=1 for more details"); } } diff --git a/packages/codev/src/agent-farm/commands/status.ts b/packages/codev/src/agent-farm/commands/status.ts index a5c44f33..5c9fb3cc 100644 --- a/packages/codev/src/agent-farm/commands/status.ts +++ b/packages/codev/src/agent-farm/commands/status.ts @@ -2,11 +2,37 @@ * Status command - shows status of all agents */ -import { loadState } from '../state.js'; -import { logger } from '../utils/logger.js'; +import type { Builder } from '../types.js'; +import { loadState, updateBuilderStatus } from '../state.js'; +import { logger, fatal } from '../utils/logger.js'; import { isProcessRunning } from '../utils/shell.js'; +import { send } from './send.js'; import chalk from 'chalk'; +const VALID_STATUSES: Builder['status'][] = [ + 'spawning', + 'implementing', + 'blocked', + 'pr-ready', + 'complete', +]; + +function normalizeStatus(input: string): Builder['status'] { + const normalized = input.trim().toLowerCase().replace(/_/g, '-'); + if (!VALID_STATUSES.includes(normalized as Builder['status'])) { + fatal( + `Invalid status: ${input}. Valid statuses: ${VALID_STATUSES.join(', ')}` + ); + } + return normalized as Builder['status']; +} + +function detectCurrentBuilderId(): string | null { + const cwd = process.cwd(); + const match = cwd.match(/\.builders\/([^/]+)/); + return match ? match[1] : null; +} + /** * Display status of all agent farm processes */ @@ -103,6 +129,44 @@ export async function status(): Promise { } } +interface SetStatusOptions { + builder: string; + status: string; + notify?: boolean; +} + +/** + * Set a builder's status + */ +export async function setStatus(options: SetStatusOptions): Promise { + const builderId = options.builder.trim(); + const statusValue = normalizeStatus(options.status); + + const updated = updateBuilderStatus(builderId, statusValue); + if (!updated) { + fatal(`Builder not found: ${builderId}`); + } + + logger.success(`Builder ${builderId} status set to ${statusValue}`); + + if (options.notify) { + const currentBuilderId = detectCurrentBuilderId(); + if (!currentBuilderId) { + fatal('Must run from a builder worktree to notify the Architect'); + } + if (currentBuilderId !== builderId) { + fatal( + `Current builder (${currentBuilderId}) does not match target (${builderId})` + ); + } + + await send({ + builder: 'architect', + message: `Status: ${builderId} -> ${statusValue}`, + }); + } +} + function getStatusColor(status: string, running: boolean): (text: string) => string { if (!running) { return chalk.gray; diff --git a/packages/codev/src/agent-farm/state.ts b/packages/codev/src/agent-farm/state.ts index b0358cc5..3373441b 100644 --- a/packages/codev/src/agent-farm/state.ts +++ b/packages/codev/src/agent-farm/state.ts @@ -134,6 +134,26 @@ export function getBuilder(id: string): Builder | null { return row ? dbBuilderToBuilder(row) : null; } +/** + * Update a builder's status + */ +export function updateBuilderStatus( + id: string, + status: Builder['status'] +): Builder | null { + const db = getDb(); + const existing = db + .prepare('SELECT * FROM builders WHERE id = ?') + .get(id) as DbBuilder | undefined; + if (!existing) return null; + + db.prepare('UPDATE builders SET status = ? WHERE id = ?').run(status, id); + const updated = db + .prepare('SELECT * FROM builders WHERE id = ?') + .get(id) as DbBuilder | undefined; + return updated ? dbBuilderToBuilder(updated) : null; +} + /** * Get all builders */ diff --git a/packages/codev/src/agent-farm/utils/launch-script.ts b/packages/codev/src/agent-farm/utils/launch-script.ts new file mode 100644 index 00000000..c910a396 --- /dev/null +++ b/packages/codev/src/agent-farm/utils/launch-script.ts @@ -0,0 +1,31 @@ +import { writeFileSync } from "node:fs"; + +export interface LaunchScriptOptions { + scriptPath: string; + cwd: string; + execCommand: string; + mode?: number; +} + +export function writeLaunchScript(options: LaunchScriptOptions): string { + const { scriptPath, cwd, execCommand, mode = 0o755 } = options; + + const content = `#!/bin/bash +cd "${cwd}" +exec ${execCommand} +`; + writeFileSync(scriptPath, content, { mode }); + return scriptPath; +} + +export function quoteShellArg(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +export function buildExecCommand(command: string, args: string[] = []): string { + const base = command.trim(); + if (args.length === 0) { + return base; + } + return `${base} ${args.map(quoteShellArg).join(" ")}`; +} diff --git a/packages/codev/src/agent-farm/utils/project-id.ts b/packages/codev/src/agent-farm/utils/project-id.ts new file mode 100644 index 00000000..8b1344eb --- /dev/null +++ b/packages/codev/src/agent-farm/utils/project-id.ts @@ -0,0 +1,40 @@ +export function normalizeProjectId(projectId: string): string { + return projectId.trim().toLowerCase(); +} + +export function splitProjectId(projectId: string): { + baseId: string; + suffix: string; +} { + const normalized = normalizeProjectId(projectId); + const match = normalized.match(/^(\d{4})([a-z]+)?$/); + if (!match) { + return { baseId: normalized, suffix: "" }; + } + return { baseId: match[1], suffix: match[2] ?? "" }; +} + +export function getSpecIdCandidates(projectId: string): string[] { + const normalized = normalizeProjectId(projectId); + const { baseId, suffix } = splitProjectId(projectId); + const candidates = [normalized]; + if (suffix && baseId !== normalized) { + candidates.push(baseId); + } + return candidates; +} + +export function applyProjectSuffixToSpecName( + specName: string, + projectId: string +): string { + const { baseId, suffix } = splitProjectId(projectId); + if (!suffix) { + return specName; + } + const prefix = `${baseId}-`; + if (specName.toLowerCase().startsWith(prefix)) { + return `${baseId}${suffix}-${specName.slice(prefix.length)}`; + } + return specName; +} diff --git a/packages/codev/src/commands/consult/index.ts b/packages/codev/src/commands/consult/index.ts index 737d7489..7f9c5a22 100644 --- a/packages/codev/src/commands/consult/index.ts +++ b/packages/codev/src/commands/consult/index.ts @@ -4,12 +4,17 @@ * Provides unified interface to gemini-cli, codex, and claude CLIs. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { spawn, execSync } from 'node:child_process'; -import { tmpdir } from 'node:os'; -import chalk from 'chalk'; -import { resolveCodevFile, readCodevFile, findProjectRoot, hasLocalOverride } from '../../lib/skeleton.js'; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { spawn, execSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import chalk from "chalk"; +import { + resolveCodevFile, + readCodevFile, + findProjectRoot, + hasLocalOverride, +} from "../../lib/skeleton.js"; // Model configuration interface ModelConfig { @@ -19,18 +24,18 @@ interface ModelConfig { } const MODEL_CONFIGS: Record = { - gemini: { cli: 'gemini', args: ['--yolo'], envVar: 'GEMINI_SYSTEM_MD' }, + gemini: { cli: "gemini", args: ["--yolo"], envVar: "GEMINI_SYSTEM_MD" }, // Codex uses experimental_instructions_file config flag (not env var) // See: https://github.com/openai/codex/discussions/3896 - codex: { cli: 'codex', args: ['exec', '--full-auto'], envVar: null }, - claude: { cli: 'claude', args: ['--print', '-p'], envVar: null }, + codex: { cli: "codex", args: ["exec", "--full-auto"], envVar: null }, + claude: { cli: "claude", args: ["--print", "-p"], envVar: null }, }; // Model aliases const MODEL_ALIASES: Record = { - pro: 'gemini', - gpt: 'codex', - opus: 'claude', + pro: "gemini", + gpt: "codex", + opus: "claude", }; interface ConsultOptions { @@ -44,11 +49,11 @@ interface ConsultOptions { // Valid review types const VALID_REVIEW_TYPES = [ - 'spec-review', - 'plan-review', - 'impl-review', - 'pr-ready', - 'integration-review', + "spec-review", + "plan-review", + "impl-review", + "pr-ready", + "integration-review", ]; /** @@ -64,18 +69,19 @@ function isValidRoleName(roleName: string): boolean { * Excludes non-role files like README.md, review-types/, etc. */ function listAvailableRoles(projectRoot: string): string[] { - const rolesDir = path.join(projectRoot, 'codev', 'roles'); + const rolesDir = path.join(projectRoot, "codev", "roles"); if (!fs.existsSync(rolesDir)) return []; - const excludePatterns = ['readme', 'review-types', 'overview', 'index']; + const excludePatterns = ["readme", "review-types", "overview", "index"]; - return fs.readdirSync(rolesDir) - .filter(f => { - if (!f.endsWith('.md')) return false; - const basename = f.replace('.md', '').toLowerCase(); - return !excludePatterns.some(pattern => basename.includes(pattern)); + return fs + .readdirSync(rolesDir) + .filter((f) => { + if (!f.endsWith(".md")) return false; + const basename = f.replace(".md", "").toLowerCase(); + return !excludePatterns.some((pattern) => basename.includes(pattern)); }) - .map(f => f.replace('.md', '')); + .map((f) => f.replace(".md", "")); } /** @@ -87,7 +93,7 @@ function loadCustomRole(projectRoot: string, roleName: string): string { if (!isValidRoleName(roleName)) { throw new Error( `Invalid role name: '${roleName}'\n` + - 'Role names can only contain letters, numbers, hyphens, and underscores.' + "Role names can only contain letters, numbers, hyphens, and underscores." ); } @@ -97,12 +103,11 @@ function loadCustomRole(projectRoot: string, roleName: string): string { if (!roleContent) { const available = listAvailableRoles(projectRoot); - const availableStr = available.length > 0 - ? `\n\nAvailable roles:\n${available.map(r => ` - ${r}`).join('\n')}` - : '\n\nNo custom roles found in codev/roles/'; - throw new Error( - `Role '${roleName}' not found.${availableStr}` - ); + const availableStr = + available.length > 0 + ? `\n\nAvailable roles:\n${available.map((r) => ` - ${r}`).join("\n")}` + : "\n\nNo custom roles found in codev/roles/"; + throw new Error(`Role '${roleName}' not found.${availableStr}`); } return roleContent; @@ -113,12 +118,12 @@ function loadCustomRole(projectRoot: string, roleName: string): string { * Checks local codev/roles/consultant.md first, then falls back to embedded skeleton. */ function loadRole(projectRoot: string): string { - const role = readCodevFile('roles/consultant.md', projectRoot); + const role = readCodevFile("roles/consultant.md", projectRoot); if (!role) { throw new Error( - 'consultant.md not found.\n' + - 'Checked: local codev/roles/consultant.md and embedded skeleton.\n' + - 'Run from a codev-enabled project or install @cluesmith/codev globally.' + "consultant.md not found.\n" + + "Checked: local codev/roles/consultant.md and embedded skeleton.\n" + + "Run from a codev-enabled project or install @cluesmith/codev globally." ); } return role; @@ -129,7 +134,10 @@ function loadRole(projectRoot: string): string { * Checks consult-types/{type}.md first (new location), * then falls back to roles/review-types/{type}.md (deprecated) with a warning. */ -function loadReviewTypePrompt(projectRoot: string, reviewType: string): string | null { +function loadReviewTypePrompt( + projectRoot: string, + reviewType: string +): string | null { const primaryPath = `consult-types/${reviewType}.md`; const fallbackPath = `roles/review-types/${reviewType}.md`; @@ -140,8 +148,16 @@ function loadReviewTypePrompt(projectRoot: string, reviewType: string): string | // 2. Check LOCAL roles/review-types/ (deprecated location with warning) if (hasLocalOverride(fallbackPath, projectRoot)) { - console.error(chalk.yellow('Warning: Review types in roles/review-types/ are deprecated.')); - console.error(chalk.yellow('Move your custom types to consult-types/ for future compatibility.')); + console.error( + chalk.yellow( + "Warning: Review types in roles/review-types/ are deprecated." + ) + ); + console.error( + chalk.yellow( + "Move your custom types to consult-types/ for future compatibility." + ) + ); return readCodevFile(fallbackPath, projectRoot); } @@ -158,23 +174,25 @@ function loadReviewTypePrompt(projectRoot: string, reviewType: string): string | * Load .env file if it exists */ function loadDotenv(projectRoot: string): void { - const envFile = path.join(projectRoot, '.env'); + const envFile = path.join(projectRoot, ".env"); if (!fs.existsSync(envFile)) return; - const content = fs.readFileSync(envFile, 'utf-8'); - for (const line of content.split('\n')) { + const content = fs.readFileSync(envFile, "utf-8"); + for (const line of content.split("\n")) { const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; + if (!trimmed || trimmed.startsWith("#")) continue; - const eqIndex = trimmed.indexOf('='); + const eqIndex = trimmed.indexOf("="); if (eqIndex === -1) continue; const key = trimmed.substring(0, eqIndex).trim(); let value = trimmed.substring(eqIndex + 1).trim(); // Remove surrounding quotes - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { value = value.slice(1, -1); } @@ -189,13 +207,13 @@ function loadDotenv(projectRoot: string): void { * Find a spec file by number */ function findSpec(projectRoot: string, number: number): string | null { - const specsDir = path.join(projectRoot, 'codev', 'specs'); - const pattern = String(number).padStart(4, '0'); + const specsDir = path.join(projectRoot, "codev", "specs"); + const pattern = String(number).padStart(4, "0"); if (fs.existsSync(specsDir)) { const files = fs.readdirSync(specsDir); for (const file of files) { - if (file.startsWith(pattern) && file.endsWith('.md')) { + if (file.startsWith(pattern) && file.endsWith(".md")) { return path.join(specsDir, file); } } @@ -207,13 +225,13 @@ function findSpec(projectRoot: string, number: number): string | null { * Find a plan file by number */ function findPlan(projectRoot: string, number: number): string | null { - const plansDir = path.join(projectRoot, 'codev', 'plans'); - const pattern = String(number).padStart(4, '0'); + const plansDir = path.join(projectRoot, "codev", "plans"); + const pattern = String(number).padStart(4, "0"); if (fs.existsSync(plansDir)) { const files = fs.readdirSync(plansDir); for (const file of files) { - if (file.startsWith(pattern) && file.endsWith('.md')) { + if (file.startsWith(pattern) && file.endsWith(".md")) { return path.join(plansDir, file); } } @@ -224,19 +242,28 @@ function findPlan(projectRoot: string, number: number): string | null { /** * Log query to history file */ -function logQuery(projectRoot: string, model: string, query: string, duration?: number): void { +function logQuery( + projectRoot: string, + model: string, + query: string, + duration?: number +): void { try { - const logDir = path.join(projectRoot, '.consult'); + const logDir = path.join(projectRoot, ".consult"); if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } - const logFile = path.join(logDir, 'history.log'); + const logFile = path.join(logDir, "history.log"); const timestamp = new Date().toISOString(); - const queryPreview = query.substring(0, 100).replace(/\n/g, ' '); - const durationStr = duration !== undefined ? ` duration=${duration.toFixed(1)}s` : ''; + const queryPreview = query.substring(0, 100).replace(/\n/g, " "); + const durationStr = + duration !== undefined ? ` duration=${duration.toFixed(1)}s` : ""; - fs.appendFileSync(logFile, `${timestamp} model=${model}${durationStr} query=${queryPreview}...\n`); + fs.appendFileSync( + logFile, + `${timestamp} model=${model}${durationStr} query=${queryPreview}...\n` + ); } catch { // Logging failure should not block consultation } @@ -247,7 +274,7 @@ function logQuery(projectRoot: string, model: string, query: string, duration?: */ function commandExists(cmd: string): boolean { try { - execSync(`which ${cmd}`, { stdio: 'pipe' }); + execSync(`which ${cmd}`, { stdio: "pipe" }); return true; } catch { return false; @@ -266,16 +293,20 @@ async function runConsultation( customRole?: string ): Promise { // Use custom role if specified, otherwise use default consultant role - let role = customRole ? loadCustomRole(projectRoot, customRole) : loadRole(projectRoot); + let role = customRole + ? loadCustomRole(projectRoot, customRole) + : loadRole(projectRoot); // Append review type prompt if specified if (reviewType) { const typePrompt = loadReviewTypePrompt(projectRoot, reviewType); if (typePrompt) { - role = role + '\n\n---\n\n' + typePrompt; + role = role + "\n\n---\n\n" + typePrompt; console.error(`Review type: ${reviewType}`); } else { - console.error(chalk.yellow(`Warning: Review type prompt not found: ${reviewType}`)); + console.error( + chalk.yellow(`Warning: Review type prompt not found: ${reviewType}`) + ); } } @@ -296,43 +327,51 @@ async function runConsultation( // Prepare command and environment based on model let cmd: string[]; - if (model === 'gemini') { + if (model === "gemini") { // Gemini uses GEMINI_SYSTEM_MD env var for role tempFile = path.join(tmpdir(), `codev-role-${Date.now()}.md`); fs.writeFileSync(tempFile, role); - env['GEMINI_SYSTEM_MD'] = tempFile; + env["GEMINI_SYSTEM_MD"] = tempFile; cmd = [config.cli, ...config.args, query]; - } else if (model === 'codex') { + } else if (model === "codex") { // Codex uses experimental_instructions_file config flag (not env var) // This is the official approach per https://github.com/openai/codex/discussions/3896 tempFile = path.join(tmpdir(), `codev-role-${Date.now()}.md`); fs.writeFileSync(tempFile, role); cmd = [ config.cli, - 'exec', - '-c', `experimental_instructions_file=${tempFile}`, - '-c', 'model_reasoning_effort=low', // Faster responses (10-20% improvement) - '--full-auto', + "exec", + "-c", + `developer_instructions=${tempFile}`, + "-c", + "model_reasoning_effort=low", // Faster responses (10-20% improvement) + "--full-auto", query, ]; - } else if (model === 'claude') { + } else if (model === "claude") { // Claude gets role prepended to query const fullQuery = `${role}\n\n---\n\nConsultation Request:\n${query}`; - cmd = [config.cli, ...config.args, fullQuery, '--dangerously-skip-permissions']; + cmd = [ + config.cli, + ...config.args, + fullQuery, + "--dangerously-skip-permissions", + ]; } else { throw new Error(`Unknown model: ${model}`); } if (dryRun) { console.log(chalk.yellow(`[${model}] Would execute:`)); - console.log(` Command: ${cmd.join(' ')}`); + console.log(` Command: ${cmd.join(" ")}`); if (Object.keys(env).length > 0) { for (const [key, value] of Object.entries(env)) { - if (key === 'GEMINI_SYSTEM_MD') { + if (key === "GEMINI_SYSTEM_MD") { console.log(` Env: ${key}=`); } else { - const preview = value.substring(0, 50) + (value.length > 50 ? '...' : ''); + const preview = + value.substring(0, 50) + (value.length > 50 ? "..." : ""); console.log(` Env: ${key}=${preview}`); } } @@ -348,10 +387,10 @@ async function runConsultation( return new Promise((resolve, reject) => { const proc = spawn(cmd[0], cmd.slice(1), { env: fullEnv, - stdio: 'inherit', + stdio: "inherit", }); - proc.on('close', (code) => { + proc.on("close", (code) => { const duration = (Date.now() - startTime) / 1000; logQuery(projectRoot, model, query, duration); @@ -368,7 +407,7 @@ async function runConsultation( } }); - proc.on('error', (error) => { + proc.on("error", (error) => { if (tempFile && fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); } @@ -380,16 +419,25 @@ async function runConsultation( /** * Fetch PR data and return it inline */ -function fetchPRData(prNumber: number): { info: string; diff: string; comments: string } { +function fetchPRData(prNumber: number): { + info: string; + diff: string; + comments: string; +} { console.error(`Fetching PR #${prNumber} data...`); try { - const info = execSync(`gh pr view ${prNumber} --json title,body,state,author,baseRefName,headRefName,files,additions,deletions`, { encoding: 'utf-8' }); - const diff = execSync(`gh pr diff ${prNumber}`, { encoding: 'utf-8' }); + const info = execSync( + `gh pr view ${prNumber} --json title,body,state,author,baseRefName,headRefName,files,additions,deletions`, + { encoding: "utf-8" } + ); + const diff = execSync(`gh pr diff ${prNumber}`, { encoding: "utf-8" }); - let comments = '(No comments)'; + let comments = "(No comments)"; try { - comments = execSync(`gh pr view ${prNumber} --comments`, { encoding: 'utf-8' }); + comments = execSync(`gh pr view ${prNumber} --comments`, { + encoding: "utf-8", + }); } catch { // No comments or error fetching } @@ -408,9 +456,13 @@ function buildPRQuery(prNumber: number, _projectRoot: string): string { // Truncate diff if too large (keep first 50k chars) const maxDiffSize = 50000; - const diff = prData.diff.length > maxDiffSize - ? prData.diff.substring(0, maxDiffSize) + '\n\n... (diff truncated, ' + prData.diff.length + ' chars total)' - : prData.diff; + const diff = + prData.diff.length > maxDiffSize + ? prData.diff.substring(0, maxDiffSize) + + "\n\n... (diff truncated, " + + prData.diff.length + + " chars total)" + : prData.diff; return `Review Pull Request #${prNumber} @@ -459,11 +511,12 @@ Please read and review this specification: `; if (planPath) { - query += `- Plan file: ${planPath}\n`; + query += `- Plan file: ${planPath} (read this too; it defines phase scope and may be a subset of the spec)\n`; } query += ` Please review: +0. **Scope**: If a plan is provided, keep your review scoped to the plan (the plan may omit parts of the spec not in scope for this phase). 1. Clarity and completeness of requirements 2. Technical feasibility 3. Edge cases and error scenarios @@ -524,20 +577,37 @@ KEY_ISSUES: [List of critical issues if any, or "None"]`; * Main consult entry point */ export async function consult(options: ConsultOptions): Promise { - const { model: modelInput, subcommand, args, dryRun = false, reviewType, role: customRole } = options; + const { + model: modelInput, + subcommand, + args, + dryRun = false, + reviewType, + role: customRole, + } = options; // Resolve model alias - const model = MODEL_ALIASES[modelInput.toLowerCase()] || modelInput.toLowerCase(); + const model = + MODEL_ALIASES[modelInput.toLowerCase()] || modelInput.toLowerCase(); // Validate model if (!MODEL_CONFIGS[model]) { - const validModels = [...Object.keys(MODEL_CONFIGS), ...Object.keys(MODEL_ALIASES)]; - throw new Error(`Unknown model: ${modelInput}\nValid models: ${validModels.join(', ')}`); + const validModels = [ + ...Object.keys(MODEL_CONFIGS), + ...Object.keys(MODEL_ALIASES), + ]; + throw new Error( + `Unknown model: ${modelInput}\nValid models: ${validModels.join(", ")}` + ); } // Validate review type if provided if (reviewType && !VALID_REVIEW_TYPES.includes(reviewType)) { - throw new Error(`Invalid review type: ${reviewType}\nValid types: ${VALID_REVIEW_TYPES.join(', ')}`); + throw new Error( + `Invalid review type: ${reviewType}\nValid types: ${VALID_REVIEW_TYPES.join( + ", " + )}` + ); } const projectRoot = findProjectRoot(); @@ -554,9 +624,11 @@ export async function consult(options: ConsultOptions): Promise { let query: string; switch (subcommand.toLowerCase()) { - case 'pr': { + case "pr": { if (args.length === 0) { - throw new Error('PR number required\nUsage: consult -m pr '); + throw new Error( + "PR number required\nUsage: consult -m pr " + ); } const prNumber = parseInt(args[0], 10); if (isNaN(prNumber)) { @@ -566,9 +638,11 @@ export async function consult(options: ConsultOptions): Promise { break; } - case 'spec': { + case "spec": { if (args.length === 0) { - throw new Error('Spec number required\nUsage: consult -m spec '); + throw new Error( + "Spec number required\nUsage: consult -m spec " + ); } const specNumber = parseInt(args[0], 10); if (isNaN(specNumber)) { @@ -585,9 +659,11 @@ export async function consult(options: ConsultOptions): Promise { break; } - case 'plan': { + case "plan": { if (args.length === 0) { - throw new Error('Plan number required\nUsage: consult -m plan '); + throw new Error( + "Plan number required\nUsage: consult -m plan " + ); } const planNumber = parseInt(args[0], 10); if (isNaN(planNumber)) { @@ -604,29 +680,40 @@ export async function consult(options: ConsultOptions): Promise { break; } - case 'general': { + case "general": { if (args.length === 0) { - throw new Error('Query required\nUsage: consult -m general ""'); + throw new Error( + 'Query required\nUsage: consult -m general ""' + ); } - query = args.join(' '); + query = args.join(" "); break; } default: - throw new Error(`Unknown subcommand: ${subcommand}\nValid subcommands: pr, spec, plan, general`); + throw new Error( + `Unknown subcommand: ${subcommand}\nValid subcommands: pr, spec, plan, general` + ); } // Show the query/prompt being sent - console.error(''); - console.error('='.repeat(60)); - console.error('PROMPT:'); - console.error('='.repeat(60)); + console.error(""); + console.error("=".repeat(60)); + console.error("PROMPT:"); + console.error("=".repeat(60)); console.error(query); - console.error(''); - console.error('='.repeat(60)); + console.error(""); + console.error("=".repeat(60)); console.error(`[${model.toUpperCase()}] Starting consultation...`); - console.error('='.repeat(60)); - console.error(''); - - await runConsultation(model, query, projectRoot, dryRun, reviewType, customRole); + console.error("=".repeat(60)); + console.error(""); + + await runConsultation( + model, + query, + projectRoot, + dryRun, + reviewType, + customRole + ); } diff --git a/packages/codev/src/lib/prompt-command.ts b/packages/codev/src/lib/prompt-command.ts new file mode 100644 index 00000000..7f734d25 --- /dev/null +++ b/packages/codev/src/lib/prompt-command.ts @@ -0,0 +1,120 @@ +/** + * Prompt command builder for AI CLIs. + * + * Example usage: + * ```ts + * const cmd = buildPromptCommand({ + * command: 'claude --model opus', + * systemPromptFile: '/tmp/role.md', + * userPromptFile: '/tmp/prompt.txt', + * }); + * + * // Use in a shell script: + * // exec ${cmd} + * ``` + */ + +export type KnownCli = "claude" | "codex" | "gemini"; + +export interface PromptCommandOptions { + command: string | string[]; + systemPromptFile?: string; + userPromptFile?: string; + userPromptText?: string; + mode?: "interactive" | "one-shot"; +} + +function normalizeCommand(command: string | string[]): string { + if (Array.isArray(command)) { + return command + .map((part) => part.trim()) + .join(" ") + .trim(); + } + return command.trim(); +} + +function quoteShellArg(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function catFile(path: string): string { + return `$(cat ${quoteShellArg(path)})`; +} + +function detectCli(command: string): KnownCli | null { + const tokens = command.trim().split(/\s+/); + for (const token of tokens) { + if (!token) continue; + if (token.includes("=") && !token.includes("/")) continue; // env var assignment + if (token.startsWith("-")) continue; + const name = token.split("/").pop() || token; + if (name === "claude" || name === "codex" || name === "gemini") { + return name; + } + } + return null; +} + +function userPromptArg(options: PromptCommandOptions): string | null { + if (options.userPromptText !== undefined) { + return quoteShellArg(options.userPromptText); + } + if (options.userPromptFile) { + return catFile(options.userPromptFile); + } + return null; +} + +/** + * Build a runnable shell command string with prompt injection. + */ +export function buildPromptCommand(options: PromptCommandOptions): string { + const base = normalizeCommand(options.command); + if (!base) return base; + + const cli = detectCli(base); + const userArg = userPromptArg(options); + + if (cli === "claude") { + let cmd = base; + if (options.systemPromptFile) { + cmd += ` --append-system-prompt "${catFile(options.systemPromptFile)}"`; + } + if (userArg) { + cmd += ` ${userArg}`; + } + return cmd; + } + + if (cli === "codex") { + let cmd = base; + if (options.systemPromptFile && !cmd.includes("developer_instructions=")) { + cmd += ` -c developer_instructions=${quoteShellArg( + options.systemPromptFile + )}`; + } + if (userArg && (options.mode ?? "interactive") === "one-shot") { + cmd += ` ${userArg}`; + } else if (userArg && (options.mode ?? "interactive") === "interactive") { + cmd += ` ${userArg}`; + } + return cmd; + } + + if (cli === "gemini") { + let cmd = base; + if (options.systemPromptFile) { + cmd = `GEMINI_SYSTEM_MD=${quoteShellArg( + options.systemPromptFile + )} ${cmd}`; + } + if (userArg) { + cmd += ` ${userArg}`; + } + return cmd; + } + + // Unknown command: only append user prompt if provided. + return userArg ? `${base} ${userArg}` : base; +}