diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 2d133edd1..06846dc35 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -55,13 +55,18 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Pass the commit message through the environment, never interpolate it + # directly into the shell — a message containing backticks / $() / ; + # would otherwise break the script or allow command injection in a job + # that holds CLOUDFLARE_API_TOKEN. + COMMIT_MESSAGE: ${{ github.event.head_commit.message || 'Deploy' }} run: | BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" npx wrangler pages deploy docs/.vitepress/dist \ --project-name=protolabs-docs \ --branch="$BRANCH" \ --commit-hash="${GITHUB_SHA}" \ - --commit-message="${{ github.event.head_commit.message || 'Deploy' }}" + --commit-message="${COMMIT_MESSAGE:-Deploy}" - name: Comment PR preview URL if: github.event_name == 'pull_request' diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index e27fb0210..bf480403d 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -48,13 +48,18 @@ jobs: env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # Pass the commit message through the environment, never interpolate it + # directly into the shell — a message containing backticks / $() / ; + # would otherwise break the script or allow command injection in a job + # that holds CLOUDFLARE_API_TOKEN. + COMMIT_MESSAGE: ${{ github.event.head_commit.message || 'Deploy' }} run: | BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" npx -y wrangler pages deploy site \ --project-name=automaker \ --branch="$BRANCH" \ --commit-hash="${GITHUB_SHA}" \ - --commit-message="${{ github.event.head_commit.message || 'Deploy' }}" + --commit-message="${COMMIT_MESSAGE:-Deploy}" - name: Comment PR preview URL if: github.event_name == 'pull_request' diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index a05f89b46..53cf0406e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -1214,11 +1214,15 @@ export class AutoModeService { continue; } - // Start feature execution in background + // Start feature execution in background. + // useWorktrees: default to true so agents always run in an isolated + // worktree, never the main checkout. (Read-only features could skip + // worktrees, but isolation-by-default is the safe choice — a stray + // write/install in the main checkout corrupts the running server.) this.executeFeature( projectPath, nextFeature.id, - nextFeature.executionMode !== 'read-only', // useWorktrees + true, // useWorktrees — always isolate true ).catch((error) => { logger.error(`Feature ${nextFeature.id} error:`, error); @@ -1316,7 +1320,7 @@ export class AutoModeService { async executeFeature( projectPath: string, featureId: string, - useWorktrees = false, + useWorktrees = true, isAutoMode = false, providedWorktreePath?: string, options?: ExecuteFeatureOptions @@ -1699,7 +1703,7 @@ export class AutoModeService { /** * Resume a feature (continues from saved context) */ - async resumeFeature(projectPath: string, featureId: string, useWorktrees = false): Promise { + async resumeFeature(projectPath: string, featureId: string, useWorktrees = true): Promise { if (this.runningFeatures.has(featureId)) { const existing = this.runningFeatures.get(featureId); const runtime = existing ? Math.floor((Date.now() - existing.startTime) / 1000) : 0; diff --git a/apps/server/src/services/auto-mode/execution-service.ts b/apps/server/src/services/auto-mode/execution-service.ts index c9a715dfa..20c1e7780 100644 --- a/apps/server/src/services/auto-mode/execution-service.ts +++ b/apps/server/src/services/auto-mode/execution-service.ts @@ -16,6 +16,11 @@ import { existsSync, readFileSync } from 'node:fs'; import { ProviderFactory } from '../../providers/provider-factory.js'; import { TracedProvider } from '../../providers/traced-provider.js'; import { simpleQuery } from '../../providers/simple-query-service.js'; +import { + buildFreshEyesReviewPrompt, + FRESH_EYES_REVIEW_SYSTEM_PROMPT, + parseFreshEyesVerdict, +} from '@protolabsai/prompts'; import { StreamObserver } from '../stream-observer-service.js'; import { getWorkflowSettings, getEffectivePrBaseBranch } from '../../lib/settings-helpers.js'; import { setFeatureContext } from '@protolabsai/error-tracking'; @@ -1391,6 +1396,20 @@ Output the branch name only.`, logger.warn('Failed to record learnings:', learningError); } + // Local antagonistic review of the worktree diff BEFORE the git workflow + // commits/pushes/opens the PR (#3799). Reviews the agent's first cut and, + // on a BLOCK/CONCERN verdict, runs one fix iteration in the worktree — so + // we open a reviewed, iterated PR instead of pushing the first cut + // unreviewed. Fail-open: never blocks shipping. + await this.reviewAndIterateWorktreeDiff( + workDir, + projectPath, + feature, + abortController, + modelResult.model, + modelResult.providerId + ); + // Run git workflow (commit, push, PR) if enabled // Read-only features skip the git workflow entirely — no commit, push, or PR. let gitWorkflowResult: Awaited< @@ -2371,6 +2390,89 @@ Complete the pipeline step instructions above. Review the previous work and appl return prompt; } + /** + * Local antagonistic review of the worktree diff, run AFTER the agent finishes + * but BEFORE the git workflow commits/pushes/opens the PR (#3799). Reviews the + * agent's diff with a fresh-eyes pass; on a BLOCK/CONCERN verdict, re-invokes + * the agent once to address the findings. Fail-open: any error here just logs + * and proceeds to the git workflow — the local review must never block shipping. + */ + private async reviewAndIterateWorktreeDiff( + workDir: string, + projectPath: string, + feature: Feature, + abortController: AbortController, + model: string, + providerId: string | undefined + ): Promise { + if (feature.executionMode === 'read-only') return; + const MAX_BUF = 64 * 1024 * 1024; + try { + // Stage everything (incl. new files) so the diff is complete. The git + // workflow re-stages before committing, so staging here is harmless. + const GIT_TIMEOUT = 120_000; + await execAsync('git add -A', { cwd: workDir, maxBuffer: MAX_BUF, timeout: GIT_TIMEOUT }); + const { stdout: diff } = await execAsync('git diff --cached', { + cwd: workDir, + maxBuffer: MAX_BUF, + timeout: GIT_TIMEOUT, + }); + if (!diff || diff.trim().length === 0) { + logger.info(`[LocalReview] ${feature.id}: no diff to review`); + return; + } + + const reviewUserPrompt = buildFreshEyesReviewPrompt({ + prDiff: diff, + featureTitle: feature.title ?? feature.id, + featureDescription: feature.description ?? '', + }); + const reviewResult = await simpleQuery({ + prompt: `${FRESH_EYES_REVIEW_SYSTEM_PROMPT}\n\n${reviewUserPrompt}`, + model, + cwd: workDir, + maxTurns: 1, + allowedTools: [], + }); + const verdict = parseFreshEyesVerdict(reviewResult.text || ''); + logger.info( + `[LocalReview] ${feature.id}: verdict ${verdict.verdict} — ${verdict.reasoning.slice(0, 200)}` + ); + + if (verdict.verdict === 'PASS') return; + + // One fix iteration addressing the review findings. + const fixPrompt = + `A pre-PR code review of your changes returned ${verdict.verdict}. ` + + `Address the following findings by editing files in this worktree. ` + + `Make only the changes needed to resolve them — no unrelated changes.\n\n` + + `REVIEW FINDINGS:\n${verdict.reasoning}`; + logger.info(`[LocalReview] ${feature.id}: running one fix iteration (${verdict.verdict})`); + await this.runAgent( + workDir, + feature.id, + fixPrompt, + abortController, + projectPath, + undefined, + model, + { + projectPath, + maxTurns: 30, + providerId, + phase: 'REVIEW', + branchName: feature.branchName ?? null, + } + ); + logger.info(`[LocalReview] ${feature.id}: fix iteration complete`); + } catch (err) { + logger.warn( + `[LocalReview] ${feature.id}: review/iterate failed, proceeding to git workflow:`, + err + ); + } + } + /** * Run the agent for a feature or pipeline step. */ diff --git a/apps/server/src/services/project-orchestration-service.ts b/apps/server/src/services/project-orchestration-service.ts index 503abc401..2aa1a3750 100644 --- a/apps/server/src/services/project-orchestration-service.ts +++ b/apps/server/src/services/project-orchestration-service.ts @@ -231,7 +231,12 @@ export async function orchestrateProjectFeatures( projectSlug, milestoneSlug: milestone.slug, phaseSlug: phase.name, - workflow: phase.workflow ?? options.defaultWorkflow, + // Project phases are code-implementation work — default to the full + // 'standard' pipeline (INTAKE→PLAN→EXECUTE→REVIEW→MERGE→DEPLOY). + // Without this, an unset workflow falls into the match-rule scoring, + // which mis-matched phases to read-only workflows like changelog-digest + // (no PR, no worktree → agents ran in main). See #3788/#3793. + workflow: phase.workflow ?? options.defaultWorkflow ?? 'standard', }); result.phaseFeatureMap[phaseKey] = feature.id; diff --git a/apps/server/src/services/worktree-lifecycle-service.ts b/apps/server/src/services/worktree-lifecycle-service.ts index 73c739769..d61d562de 100644 --- a/apps/server/src/services/worktree-lifecycle-service.ts +++ b/apps/server/src/services/worktree-lifecycle-service.ts @@ -771,10 +771,16 @@ export class WorktreeLifecycleService { * and build tooling. These are symlinked from the main project into worktrees * after creation so that external projects' toolchains work correctly. * - * Only top-level directories are symlinked. Nested node_modules (inside - * workspace packages) are resolved transitively through the root symlink. + * Only top-level directories are symlinked. + * + * NOTE: node_modules is deliberately NOT symlinked. Sharing the host's + * node_modules meant an agent running `npm install` in a worktree (e.g. to add + * a workspace package) rewrote the host's deps and broke the running server. + * Worktrees now install their own node_modules via the worktree-init script + * (`npm ci`), keeping each worktree isolated. dist/build are still symlinked + * since agents read them and don't mutate them. */ -const BUILD_ARTIFACT_DIRS = ['node_modules', 'dist', 'build', '.next', '.nuxt', 'out'] as const; +const BUILD_ARTIFACT_DIRS = ['dist', 'build', '.next', '.nuxt', 'out'] as const; /** * Symlink gitignored build artifacts from the main project into a worktree. diff --git a/docs/reference/auto-mode.md b/docs/reference/auto-mode.md index 2cdc9ce6d..6137bb049 100644 --- a/docs/reference/auto-mode.md +++ b/docs/reference/auto-mode.md @@ -205,14 +205,14 @@ interface AutoModeConfig { Settings read from `workflowSettings` in `.automaker/settings.json`: -| Setting | Description | -| --------------------- | -------------------------------------------- | -| `agentExecutionModel` | Primary model for agent execution | -| `maxConcurrency` | Max parallel agents (capped at system limit) | -| `useWorktrees` | Enable per-feature git worktrees | -| `autoLoadClaudeMd` | Auto-inject CLAUDE.md into agent context | -| `mcpServers` | MCP server config passed to Claude SDK | -| `planningMode` | Enable plan approval gating | +| Setting | Description | Default | +| --------------------- | -------------------------------------------- | ------- | +| `agentExecutionModel` | Primary model for agent execution | — | +| `maxConcurrency` | Max parallel agents (capped at system limit) | — | +| `useWorktrees` | Enable per-feature git worktrees | `true` | +| `autoLoadClaudeMd` | Auto-inject CLAUDE.md into agent context | — | +| `mcpServers` | MCP server config passed to Claude SDK | — | +| `planningMode` | Enable plan approval gating | — | ## Prometheus Metrics diff --git a/package-lock.json b/package-lock.json index 82f597967..e2b4c6e12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,20 +86,20 @@ "@opentelemetry/exporter-trace-otlp-http": "^0.212.0", "@opentelemetry/sdk-node": "^0.212.0", "@opentelemetry/sdk-trace-base": "^2.0.0", - "@protolabsai/context-engine": "^0.68.39", - "@protolabsai/dependency-resolver": "^0.107.1", - "@protolabsai/error-tracking": "^0.53.54", - "@protolabsai/flows": "^0.107.1", - "@protolabsai/git-utils": "^0.107.1", - "@protolabsai/model-resolver": "^0.107.1", - "@protolabsai/observability": "^0.107.1", - "@protolabsai/platform": "^0.107.1", - "@protolabsai/prompts": "^0.107.1", + "@protolabsai/context-engine": "^0.68.40", + "@protolabsai/dependency-resolver": "^0.108.0", + "@protolabsai/error-tracking": "^0.53.55", + "@protolabsai/flows": "^0.108.0", + "@protolabsai/git-utils": "^0.108.0", + "@protolabsai/model-resolver": "^0.108.0", + "@protolabsai/observability": "^0.108.0", + "@protolabsai/platform": "^0.108.0", + "@protolabsai/prompts": "^0.108.0", "@protolabsai/sdk": "^0.3.1", "@protolabsai/templates": "^0.56.0", - "@protolabsai/tools": "^0.107.1", - "@protolabsai/types": "^0.107.1", - "@protolabsai/utils": "^0.107.1", + "@protolabsai/tools": "^0.108.0", + "@protolabsai/types": "^0.108.0", + "@protolabsai/utils": "^0.108.0", "@twurple/api": "^8.0.3", "@twurple/auth": "^8.0.3", "@twurple/chat": "^8.0.3", @@ -690,10 +690,10 @@ "@fontsource/source-sans-3": "^5.2.9", "@fontsource/work-sans": "^5.2.8", "@lezer/highlight": "1.2.3", - "@protolabsai/dependency-resolver": "^0.107.1", - "@protolabsai/spec-parser": "^0.107.1", - "@protolabsai/types": "^0.107.1", - "@protolabsai/utils": "^0.107.1", + "@protolabsai/dependency-resolver": "^0.108.0", + "@protolabsai/spec-parser": "^0.108.0", + "@protolabsai/types": "^0.108.0", + "@protolabsai/utils": "^0.108.0", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-dialog": "1.1.15", @@ -761,7 +761,7 @@ "@chromatic-com/storybook": "^5.0.1", "@eslint/js": "9.0.0", "@playwright/test": "1.57.0", - "@protolabsai/ui": "^0.107.1", + "@protolabsai/ui": "^0.108.0", "@storybook/addon-a11y": "^10.2.8", "@storybook/addon-docs": "^10.2.8", "@storybook/react-vite": "^10.2.8", @@ -829,10 +829,10 @@ }, "libs/context-engine": { "name": "@protolabsai/context-engine", - "version": "0.68.39", + "version": "0.68.40", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/utils": "^0.107.0", + "@protolabsai/utils": "^0.108.0", "better-sqlite3": "^11.7.0" }, "devDependencies": { @@ -848,10 +848,10 @@ }, "libs/dependency-resolver": { "name": "@protolabsai/dependency-resolver", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.1" + "@protolabsai/types": "^0.108.0" }, "devDependencies": { "@types/node": "22.19.3", @@ -865,11 +865,11 @@ }, "libs/error-tracking": { "name": "@protolabsai/error-tracking", - "version": "0.53.54", + "version": "0.53.55", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.0", - "@protolabsai/utils": "^0.107.0", + "@protolabsai/types": "^0.108.0", + "@protolabsai/utils": "^0.108.0", "@sentry/electron": "^5.6.0", "@sentry/node": "^8.47.0", "@sentry/types": "^8.47.0", @@ -887,18 +887,18 @@ }, "libs/flows": { "name": "@protolabsai/flows", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@langchain/anthropic": "^0.3.10", "@langchain/core": "^0.3.33", "@langchain/langgraph": "^0.2.26", "@opentelemetry/api": "^1.9.0", - "@protolabsai/model-resolver": "^0.107.1", - "@protolabsai/observability": "^0.107.1", - "@protolabsai/prompts": "^0.107.1", - "@protolabsai/types": "^0.107.1", - "@protolabsai/utils": "^0.107.1" + "@protolabsai/model-resolver": "^0.108.0", + "@protolabsai/observability": "^0.108.0", + "@protolabsai/prompts": "^0.108.0", + "@protolabsai/types": "^0.108.0", + "@protolabsai/utils": "^0.108.0" }, "devDependencies": { "@types/node": "22.19.3", @@ -913,11 +913,11 @@ }, "libs/git-utils": { "name": "@protolabsai/git-utils", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.1", - "@protolabsai/utils": "^0.107.1" + "@protolabsai/types": "^0.108.0", + "@protolabsai/utils": "^0.108.0" }, "devDependencies": { "@types/node": "22.19.3", @@ -931,10 +931,10 @@ }, "libs/model-resolver": { "name": "@protolabsai/model-resolver", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.1" + "@protolabsai/types": "^0.108.0" }, "devDependencies": { "@types/node": "22.19.3", @@ -948,11 +948,11 @@ }, "libs/observability": { "name": "@protolabsai/observability", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.1", - "@protolabsai/utils": "^0.107.1", + "@protolabsai/types": "^0.108.0", + "@protolabsai/utils": "^0.108.0", "langfuse": "^3.32.0", "zod": "^4.3.6" }, @@ -986,10 +986,10 @@ }, "libs/platform": { "name": "@protolabsai/platform", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.1", + "@protolabsai/types": "^0.108.0", "p-limit": "6.2.0", "tree-kill": "1.2.2", "yaml": "^2.8.1" @@ -1029,11 +1029,11 @@ }, "libs/prompts": { "name": "@protolabsai/prompts", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.1", - "@protolabsai/utils": "^0.107.1" + "@protolabsai/types": "^0.108.0", + "@protolabsai/utils": "^0.108.0" }, "devDependencies": { "@types/node": "22.19.3", @@ -1047,10 +1047,10 @@ }, "libs/spec-parser": { "name": "@protolabsai/spec-parser", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.1", + "@protolabsai/types": "^0.108.0", "fast-xml-parser": "^5.3.7" }, "devDependencies": { @@ -1108,10 +1108,10 @@ }, "libs/tools": { "name": "@protolabsai/tools", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.1", + "@protolabsai/types": "^0.108.0", "zod": "^4.3.6", "zod-to-json-schema": "^3.24.1" }, @@ -1138,7 +1138,7 @@ }, "libs/types": { "name": "@protolabsai/types", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "zod": "^4.3.6" @@ -1154,11 +1154,11 @@ }, "libs/ui": { "name": "@protolabsai/ui", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/types": "^0.107.1", - "@protolabsai/utils": "^0.107.1", + "@protolabsai/types": "^0.108.0", + "@protolabsai/utils": "^0.108.0", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-dialog": "^1.1.4", @@ -1305,11 +1305,11 @@ }, "libs/utils": { "name": "@protolabsai/utils", - "version": "0.107.1", + "version": "0.108.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@protolabsai/platform": "^0.107.1", - "@protolabsai/types": "^0.107.1" + "@protolabsai/platform": "^0.108.0", + "@protolabsai/types": "^0.108.0" }, "devDependencies": { "@types/node": "22.19.3", @@ -9182,6 +9182,10 @@ "resolved": "apps/ui", "link": true }, + "node_modules/@protolabsai/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@protolabsai/context-engine": { "resolved": "libs/context-engine", "link": true @@ -27167,6 +27171,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/cli": { + "name": "@protolabsai/cli", + "version": "0.1.0", + "dependencies": { + "commander": "^14.0.0" + }, + "bin": { + "protomaker": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.3" + } + }, "packages/create-protolab": { "version": "0.6.0", "dependencies": { @@ -27186,10 +27204,10 @@ }, "packages/mcp-server": { "name": "@protolabsai/mcp-server", - "version": "0.107.1", + "version": "0.108.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", - "@protolabsai/tools": "^0.107.1" + "@protolabsai/tools": "^0.108.0" }, "bin": { "automaker-mcp": "dist/index.js" diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts new file mode 100644 index 000000000..712ad4af7 --- /dev/null +++ b/packages/cli/src/agent.ts @@ -0,0 +1,334 @@ +/** + * protomaker agent + * + * Agent lifecycle commands — start, stop, list, output, message. + * + * Usage: + * protomaker agent start [options] + * protomaker agent stop [options] + * protomaker agent list [options] + * protomaker agent output [options] + * protomaker agent message [options] + */ + +import { Command } from 'commander'; +import { ApiClient } from './api-client.js'; +import { output, error, usageError, type GlobalFlags, getOutputMode } from './output.js'; +import { resolveApiConfig } from './config.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Running agent info returned by the auto-mode status endpoint. */ +interface RunningAgent { + featureId: string; + title?: string; + status?: string; + branchName?: string; + projectPath?: string; + startTime?: string; + [key: string]: unknown; +} + +interface RunFeatureResponse { + success: boolean; + error?: string; +} + +interface StopFeatureResponse { + success: boolean; + stopped?: boolean; + error?: string; +} + +interface AgentListResponse { + success: boolean; + isRunning?: boolean; + isAutoLoopRunning?: boolean; + runningFeatures?: RunningAgent[]; + runningCount?: number; + maxConcurrency?: number; + error?: string; +} + +interface AgentOutputResponse { + success: boolean; + content?: string | null; + error?: string; +} + +interface FollowUpResponse { + success: boolean; + error?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Extract global flags from Commander opts. + */ +function getGlobalFlags(opts: Record): GlobalFlags { + return { + json: opts.json === true, + quiet: opts.quiet === true, + project: (opts.project as string) ?? process.cwd(), + }; +} + +/** + * Create an API client from global flags. + */ +function createClient(flags: GlobalFlags): ApiClient { + const config = resolveApiConfig(flags.project); + return new ApiClient(config); +} + +/** + * Format agent list as a human-readable table. + */ +function renderAgentList(agents: RunningAgent[]): string { + if (agents.length === 0) { + return 'No agents currently running.'; + } + + const lines: string[] = []; + lines.push(''); + lines.push(`Running agents (${agents.length}):`); + lines.push(''); + + for (const agent of agents) { + const title = agent.title || agent.featureId; + const branch = agent.branchName ? ` (branch: ${agent.branchName})` : ''; + const started = agent.startTime ? ` [started: ${agent.startTime}]` : ''; + lines.push(` • ${agent.featureId} — ${title}${branch}${started}`); + } + + lines.push(''); + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/** + * protomaker agent start + * + * Dispatch an agent to work on a feature. + * + * Options: --force (skip dependency checks), --worktree + */ +export function startCommand(parent: Command): void { + const cmd = new Command('start '); + cmd.description('Dispatch an agent for a feature'); + cmd.option('--force', 'Skip dependency checks and start anyway'); + cmd.option('--worktree', 'Use git worktree isolation for this feature'); + + cmd.action(async (featureId: string, opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const result = await client.post('/auto-mode/run-feature', { + projectPath: flags.project, + featureId, + force: opts.force ?? false, + useWorktrees: opts.worktree ?? false, + }); + + if (!result.ok) { + error(result.error || 'Failed to start agent'); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output({ success: true, featureId, message: 'Agent dispatched' }, flags); + } else { + output(`Agent dispatched for feature "${featureId}"`, flags); + } + }); + + parent.addCommand(cmd); +} + +/** + * protomaker agent stop + * + * Stop a running agent for a specific feature. + * + * Options: --target-status (set feature status after stopping) + */ +export function stopCommand(parent: Command): void { + const cmd = new Command('stop '); + cmd.description('Stop a running agent for a feature'); + cmd.option( + '--target-status ', + 'Set feature status after stopping (e.g. backlog, blocked)' + ); + + cmd.action(async (featureId: string, opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const body: Record = { featureId }; + if (opts.targetStatus) { + body.targetStatus = opts.targetStatus; + } + + const result = await client.post('/auto-mode/stop-feature', body); + + if (!result.ok) { + error(result.error || 'Failed to stop agent'); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output({ success: true, featureId, stopped: result.data?.stopped ?? true }, flags); + } else { + output(`Agent stopped for feature "${featureId}"`, flags); + } + }); + + parent.addCommand(cmd); +} + +/** + * protomaker agent list + * + * Show running agents (from auto-mode status). + */ +export function listCommand(parent: Command): void { + const cmd = new Command('list'); + cmd.description('Show running agents'); + cmd.option('--branch ', 'Filter by branch name'); + + cmd.action(async (opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const body: Record = { projectPath: flags.project }; + if (opts.branch) { + body.branchName = opts.branch; + } + + const result = await client.post('/auto-mode/status', body); + + if (!result.ok) { + error(result.error || 'Failed to list agents'); + process.exit(1); + return; + } + + const agents = result.data?.runningFeatures ?? []; + + if (getOutputMode(flags) === 'json') { + output( + { + isRunning: result.data?.isRunning, + isAutoLoopRunning: result.data?.isAutoLoopRunning, + runningCount: result.data?.runningCount, + maxConcurrency: result.data?.maxConcurrency, + agents, + }, + flags + ); + } else { + output(renderAgentList(agents), flags); + } + }); + + parent.addCommand(cmd); +} + +/** + * protomaker agent output + * + * Print the agent output (agent-output.md) for a feature. + */ +export function outputCommand(parent: Command): void { + const cmd = new Command('output '); + cmd.description('Print the agent output for a feature'); + + cmd.action(async (featureId: string, opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const result = await client.post('/features/agent-output', { + projectPath: flags.project, + featureId, + }); + + if (!result.ok) { + error(result.error || 'Failed to get agent output'); + process.exit(1); + return; + } + + const content = result.data?.content; + + if (!content) { + if (getOutputMode(flags) === 'json') { + output({ featureId, content: null }, flags); + } else { + output(`No agent output yet for feature "${featureId}"`, flags); + } + return; + } + + if (getOutputMode(flags) === 'json') { + output({ featureId, content }, flags); + } else { + output(content, flags); + } + }); + + parent.addCommand(cmd); +} + +/** + * protomaker agent message + * + * Send a follow-up message to a running agent. + * + * Options: --image (attach image, repeatable) + */ +export function messageCommand(parent: Command): void { + const cmd = new Command('message '); + cmd.description('Send a follow-up message to a running agent'); + cmd.option('--image ', 'Attach an image file (repeatable)'); + + cmd.action(async (featureId: string, prompt: string, opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const body: Record = { + projectPath: flags.project, + featureId, + prompt, + }; + + if (opts.image) { + body.imagePaths = Array.isArray(opts.image) ? opts.image : [opts.image]; + } + + const result = await client.post('/auto-mode/follow-up-feature', body); + + if (!result.ok) { + error(result.error || 'Failed to send message to agent'); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output({ success: true, featureId, prompt }, flags); + } else { + output(`Message sent to agent for feature "${featureId}"`, flags); + } + }); + + parent.addCommand(cmd); +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 02d4bba27..59494aff6 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -23,6 +23,19 @@ import { createRequire } from 'node:module'; import { Command } from 'commander'; import { type GlobalFlags, usageError, exitError } from './output.js'; +import { listCommand, getCommand, createCommand, updateCommand, moveCommand } from './feature.js'; +import { + startCommand, + stopCommand, + listCommand as agentListCommand, + outputCommand, + messageCommand, +} from './agent.js'; +import { + createCommand as prCreateCommand, + statusCommand as prStatusCommand, + mergeCommand as prMergeCommand, +} from './pr.js'; const require = createRequire(import.meta.url); const { version } = require('../package.json') as { version: string }; @@ -64,9 +77,15 @@ agentCmd .description('Manage AI agents and workflows') .addHelpText( 'afterAll', - `\nCommands:\n list List available agents\n run Run an agent workflow` + `\nCommands:\n start Dispatch an agent for a feature\n stop Stop a running agent\n list Show running agents\n output Print agent output for a feature\n message Send a follow-up message to a running agent` ); +startCommand(agentCmd); +stopCommand(agentCmd); +agentListCommand(agentCmd); +outputCommand(agentCmd); +messageCommand(agentCmd); + /** * Dev commands — development and debugging utilities. */ @@ -75,6 +94,32 @@ devCmd .description('Development and debugging utilities') .addHelpText('afterAll', `\nCommands:\n info Show environment and project info`); +/** + * Feature commands — core board operations (list, get, create, update, move). + */ +const featureCmd = new Command('feature'); +featureCmd + .description('Core board commands — manage features') + .addHelpText( + 'afterAll', + `\nCommands:\n list List features grouped by status\n get Show full feature details\n create Create a new feature\n update Update a feature\n move Transition feature status` + ); + +/** + * PR commands — pull request lifecycle (create, status, merge). + */ +const prCmd = new Command('pr'); +prCmd + .description('Pull request commands — create, check status, and merge PRs') + .addHelpText( + 'afterAll', + `\nCommands:\n create Open a PR from a feature worktree\n status Show CI rollup for a PR\n merge Merge a PR with the configured strategy` + ); + +prCreateCommand(prCmd); +prStatusCommand(prCmd); +prMergeCommand(prCmd); + // --------------------------------------------------------------------------- // Register command groups // --------------------------------------------------------------------------- @@ -82,6 +127,8 @@ devCmd program.addCommand(projectCmd); program.addCommand(agentCmd); program.addCommand(devCmd); +program.addCommand(featureCmd); +program.addCommand(prCmd); // --------------------------------------------------------------------------- // Entry — exit-code discipline diff --git a/packages/cli/src/feature.ts b/packages/cli/src/feature.ts new file mode 100644 index 000000000..ce0e192a6 --- /dev/null +++ b/packages/cli/src/feature.ts @@ -0,0 +1,527 @@ +/** + * protomaker feature + * + * Core board commands — list, get, create, update, move features. + * + * Usage: + * protomaker feature list [options] + * protomaker feature get [options] + * protomaker feature create [options] + * protomaker feature update [options] + * protomaker feature move [options] + */ + +import { Command } from 'commander'; +import { ApiClient } from './api-client.js'; +import { output, error, usageError, type GlobalFlags, getOutputMode } from './output.js'; +import { resolveApiConfig } from './config.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type FeatureStatus = 'backlog' | 'in_progress' | 'review' | 'blocked' | 'done' | 'interrupted'; + +interface Feature { + id: string; + title?: string; + category: string; + description: string; + priority?: 0 | 1 | 2 | 3 | 4; + status?: FeatureStatus | string; + dependencies?: string[]; + complexity?: 'small' | 'medium' | 'large' | 'architectural'; + branchName?: string; + isEpic?: boolean; + epicId?: string; + costUsd?: number; + prNumber?: number; + prUrl?: string; + createdAt?: string; + updatedAt?: string; + completedAt?: string; + [key: string]: unknown; +} + +interface ListResponse { + success: boolean; + features: Feature[]; + error?: string; +} + +interface GetResponse { + success: boolean; + feature?: Feature; + error?: string; +} + +interface CreateResponse { + success: boolean; + feature?: Feature; + error?: string; +} + +interface UpdateResponse { + success: boolean; + feature?: Feature; + error?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Extract global flags from Commander opts. + * Global flags are defined on the root program and inherited by subcommands. + */ +function getGlobalFlags(opts: Record): GlobalFlags { + return { + json: opts.json === true, + quiet: opts.quiet === true, + project: (opts.project as string) ?? process.cwd(), + }; +} + +/** + * Create an API client from global flags. + */ +function createClient(flags: GlobalFlags): ApiClient { + const config = resolveApiConfig(flags.project); + return new ApiClient(config); +} + +/** + * Format a feature status for display. + */ +function statusBadge(status?: string): string { + const map: Record = { + backlog: '📋 backlog', + in_progress: '▶️ in_progress', + review: '👀 review', + blocked: '🚫 blocked', + done: '✅ done', + interrupted: '⏸️ interrupted', + }; + return status ? (map[status] ?? status) : '—'; +} + +/** + * Format a complexity level for display. + */ +function complexityLabel(complexity?: string): string { + const map: Record = { + small: 'S', + medium: 'M', + large: 'L', + architectural: 'A', + }; + return complexity ? (map[complexity] ?? complexity) : '—'; +} + +/** + * Render a feature board grouped by status as a text table. + */ +function renderBoard(features: Feature[]): string { + const statusOrder: FeatureStatus[] = [ + 'backlog', + 'in_progress', + 'review', + 'blocked', + 'done', + 'interrupted', + ]; + + // Group by status + const groups: Record = {}; + for (const f of features) { + const s = (f.status as string) || 'backlog'; + if (!groups[s]) groups[s] = []; + groups[s].push(f); + } + + const lines: string[] = []; + lines.push(''); + + for (const status of statusOrder) { + const items = groups[status]; + if (!items || items.length === 0) continue; + + lines.push(`── ${status.toUpperCase()} (${items.length}) ──`); + + for (const f of items) { + const title = f.title || f.id; + const complexity = complexityLabel(f.complexity); + const priority = f.priority !== undefined ? `p${f.priority}` : ''; + const parts = [title]; + if (complexity !== '—') parts.push(complexity); + if (priority) parts.push(priority); + lines.push(` • ${parts.join(' ')}`); + } + + lines.push(''); + } + + // Any statuses not in our known list + for (const [status, items] of Object.entries(groups)) { + if (statusOrder.includes(status as FeatureStatus)) continue; + lines.push(`── ${status.toUpperCase()} (${items.length}) ──`); + for (const f of items) { + lines.push(` • ${f.title || f.id}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Render a single feature in full detail. + */ +function renderFeature(feature: Feature): string { + const lines: string[] = []; + lines.push(`ID: ${feature.id}`); + lines.push(`Title: ${feature.title || '—'}`); + lines.push(`Status: ${statusBadge(feature.status)}`); + lines.push(`Category: ${feature.category || '—'}`); + lines.push(`Complexity: ${feature.complexity || '—'}`); + lines.push(`Priority: ${feature.priority ?? '—'}`); + lines.push(''); + lines.push(`Description:`); + + // Wrap description at ~72 chars + const desc = feature.description || ''; + const wrapped = wrapText(desc, 72); + lines.push(...wrapped.map((l) => ` ${l}`)); + + lines.push(''); + + if (feature.branchName) lines.push(`Branch: ${feature.branchName}`); + if (feature.epicId) lines.push(`Epic: ${feature.epicId}`); + if (feature.isEpic) lines.push(`Is Epic: true`); + if (feature.dependencies && feature.dependencies.length > 0) + lines.push(`Dependencies: ${feature.dependencies.join(', ')}`); + if (feature.costUsd !== undefined) lines.push(`Cost: $${feature.costUsd.toFixed(4)}`); + if (feature.prNumber) lines.push(`PR: #${feature.prNumber}`); + if (feature.prUrl) lines.push(`PR URL: ${feature.prUrl}`); + if (feature.createdAt) lines.push(`Created: ${feature.createdAt}`); + if (feature.updatedAt) lines.push(`Updated: ${feature.updatedAt}`); + if (feature.completedAt) lines.push(`Completed: ${feature.completedAt}`); + + return lines.join('\n'); +} + +/** + * Wrap text to a given line width. + */ +function wrapText(text: string, width: number): string[] { + const words = text.split(/\s+/); + const lines: string[] = []; + let current = ''; + + for (const word of words) { + if ((current + ' ' + word).trim().length > width && current) { + lines.push(current); + current = word; + } else { + current = current ? `${current} ${word}` : word; + } + } + if (current) lines.push(current); + return lines; +} + +/** + * Validate that a status value is known. + */ +function validateStatus(status: string): FeatureStatus { + const valid: FeatureStatus[] = [ + 'backlog', + 'in_progress', + 'review', + 'blocked', + 'done', + 'interrupted', + ]; + if (!valid.includes(status as FeatureStatus)) { + usageError(`Invalid status "${status}". Must be one of: ${valid.join(', ')}`); + } + return status as FeatureStatus; +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/** + * protomaker feature list + * + * List all features grouped by status (board view). + * With --json, output raw JSON array. + */ +export function listCommand(parent: Command): void { + const cmd = new Command('list'); + cmd.description('List all features grouped by status (board view)'); + cmd.option('--status ', 'Filter by status'); + cmd.option('--compact', 'Show compact one-line format (text mode)'); + + cmd.action(async (opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + const body: Record = {}; + if (opts.status) body.status = opts.status; + if (opts.compact && getOutputMode(flags) !== 'json') body.compact = true; + + const result = await client.post('/features/list', body); + + if (!result.ok) { + error(result.error || 'Failed to list features'); + process.exit(1); + return; + } + + const features = result.data?.features ?? []; + + if (getOutputMode(flags) === 'json') { + output(features, flags); + } else if (opts.compact) { + // Compact one-line format + const lines = features.map( + (f) => + `${f.id}\t${statusBadge(f.status)}\t${complexityLabel(f.complexity)}\t${f.title || ''}` + ); + output(lines.join('\n'), flags); + } else { + output(renderBoard(features), flags); + } + }); + + parent.addCommand(cmd); +} + +/** + * protomaker feature get + * + * Show full feature details. + * With --json, output raw JSON. + */ +export function getCommand(parent: Command): void { + const cmd = new Command('get '); + cmd.description('Show full feature details'); + + cmd.action(async (featureId: string, opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const result = await client.post('/features/get', { + projectPath: flags.project, + featureId, + }); + + if (!result.ok) { + error(result.error || `Failed to get feature "${featureId}"`); + process.exit(1); + return; + } + + const feature = result.data?.feature; + if (!feature) { + error(`Feature "${featureId}" not found`); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output(feature, flags); + } else { + output(renderFeature(feature), flags); + } + }); + + parent.addCommand(cmd); +} + +/** + * protomaker feature create + * + * Create a new feature. Returns the new feature id. + * + * Required: --description + * Optional: --title, --category, --complexity, --priority, --epic-id + */ +export function createCommand(parent: Command): void { + const cmd = new Command('create'); + cmd.description('Create a new feature'); + cmd.requiredOption('--description ', 'Feature description'); + cmd.option('--title ', 'Feature title'); + cmd.option('--category ', 'Feature category', 'feature'); + cmd.option('--complexity ', 'Complexity level', 'small|medium|large|architectural'); + cmd.option('--priority ', 'Priority (1=urgent, 2=high, 3=normal, 4=low)'); + cmd.option('--epic-id ', 'Parent epic ID'); + cmd.option('--is-epic', 'Mark as epic container'); + + cmd.action(async (opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const feature: Record = { + description: opts.description, + category: opts.category, + }; + + if (opts.title) feature.title = opts.title; + if (opts.complexity) feature.complexity = opts.complexity; + if (opts.priority) { + const p = parseInt(opts.priority, 10); + if (![1, 2, 3, 4].includes(p)) { + usageError('Priority must be 1, 2, 3, or 4'); + } + feature.priority = p; + } + if (opts.epicId) feature.epicId = opts.epicId; + if (opts.isEpic) feature.isEpic = true; + + const result = await client.post('/features/create', { + projectPath: flags.project, + feature, + }); + + if (!result.ok) { + error(result.error || 'Failed to create feature'); + process.exit(1); + return; + } + + const created = result.data?.feature; + if (!created) { + error('No feature returned from server'); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output(created, flags); + } else { + output(`Created feature: ${created.id}`, flags); + } + }); + + parent.addCommand(cmd); +} + +/** + * protomaker feature update + * + * Update a feature's fields. + * + * Options: --title, --description, --category, --complexity, --priority + */ +export function updateCommand(program: Command, flags: GlobalFlags): void { + const cmd = new Command('update '); + cmd.description('Update a feature'); + cmd.option('--title ', 'New title'); + cmd.option('--description ', 'New description'); + cmd.option('--category ', 'New category'); + cmd.option('--complexity ', 'New complexity level', 'small|medium|large|architectural'); + cmd.option('--priority ', 'New priority (1=urgent, 2=high, 3=normal, 4=low)'); + + cmd.action(async (featureId: string, opts) => { + const updates: Record = {}; + + if (opts.title !== undefined) updates.title = opts.title; + if (opts.description !== undefined) updates.description = opts.description; + if (opts.category !== undefined) updates.category = opts.category; + if (opts.complexity !== undefined) updates.complexity = opts.complexity; + if (opts.priority !== undefined) { + const p = parseInt(opts.priority, 10); + if (![1, 2, 3, 4].includes(p)) { + usageError('Priority must be 1, 2, 3, or 4'); + } + updates.priority = p; + } + + if (Object.keys(updates).length === 0) { + usageError( + 'At least one update option is required (--title, --description, --category, --complexity, --priority)' + ); + } + + const client = createClient(flags); + + const result = await client.post('/features/update', { + projectPath: flags.project, + featureId, + updates, + }); + + if (!result.ok) { + error(result.error || `Failed to update feature "${featureId}"`); + process.exit(1); + return; + } + + const updated = result.data?.feature; + if (!updated) { + error('No feature returned from server'); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output(updated, flags); + } else { + output(`Updated feature: ${featureId}`, flags); + } + }); + + program.addCommand(cmd); +} + +/** + * protomaker feature move + * + * Transition a feature to a new status. + * + * Valid statuses: backlog, in_progress, review, blocked, done, interrupted + */ +export function moveCommand(program: Command, flags: GlobalFlags): void { + const cmd = new Command('move '); + cmd.description('Transition a feature to a new status'); + cmd.option('--reason ', 'Reason for status change (required when blocking)'); + + cmd.action(async (featureId: string, statusArg: string, opts) => { + const status = validateStatus(statusArg); + + const updates: Record = { status }; + if (opts.reason) updates.statusChangeReason = opts.reason; + + const client = createClient(flags); + + const result = await client.post('/features/update', { + projectPath: flags.project, + featureId, + updates, + }); + + if (!result.ok) { + error(result.error || `Failed to move feature "${featureId}" to "${status}"`); + process.exit(1); + return; + } + + const updated = result.data?.feature; + if (!updated) { + error('No feature returned from server'); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output(updated, flags); + } else { + output(`Moved "${featureId}" → ${status}`, flags); + } + }); + + program.addCommand(cmd); +} diff --git a/packages/cli/src/pr.ts b/packages/cli/src/pr.ts new file mode 100644 index 000000000..3fa4492b9 --- /dev/null +++ b/packages/cli/src/pr.ts @@ -0,0 +1,368 @@ +/** + * protomaker pr + * + * Pull request commands — create, status, merge. + * + * Usage: + * protomaker pr create [options] + * protomaker pr status [options] + * protomaker pr merge [options] + */ + +import { Command } from 'commander'; +import { ApiClient } from './api-client.js'; +import { output, error, usageError, type GlobalFlags, getOutputMode } from './output.js'; +import { resolveApiConfig } from './config.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CreatePRResult { + branch: string; + committed: boolean; + commitHash?: string | null; + pushed: boolean; + prUrl?: string | null; + prNumber?: number; + prCreated: boolean; + prAlreadyExisted?: boolean; + prError?: string; + browserUrl?: string | null; + ghCliAvailable: boolean; +} + +interface CreatePRResponse { + success: boolean; + result?: CreatePRResult; + error?: string; +} + +interface PRCheckStatus { + allChecksPassed: boolean; + passedCount: number; + failedCount: number; + pendingCount: number; + failedChecks: string[]; + softFailedChecks: string[]; +} + +interface PROwnershipStatus { + instanceId: string | null; + teamId: string | null; + createdAt: string | null; + isOwnedByThisInstance: boolean; + isStale: boolean; +} + +interface CheckPRStatusResponse { + success: boolean; + allChecksPassed?: boolean; + passedCount?: number; + failedCount?: number; + pendingCount?: number; + failedChecks?: string[]; + softFailedChecks?: string[]; + ownership?: PROwnershipStatus; + error?: string; +} + +interface PRMergeResponse { + success: boolean; + mergeCommitSha?: string; + autoMergeEnabled?: boolean; + checksPending?: boolean; + checksFailed?: boolean; + failedChecks?: string[]; + error?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Extract global flags from Commander opts. + */ +function getGlobalFlags(opts: Record): GlobalFlags { + return { + json: opts.json === true, + quiet: opts.quiet === true, + project: (opts.project as string) ?? process.cwd(), + }; +} + +/** + * Create an API client from global flags. + */ +function createClient(flags: GlobalFlags): ApiClient { + const config = resolveApiConfig(flags.project); + return new ApiClient(config); +} + +/** + * Format check status as a human-readable badge. + */ +function checkBadge(passed: number, failed: number, pending: number): string { + const parts: string[] = []; + if (passed > 0) parts.push(`✅ ${passed} passed`); + if (failed > 0) parts.push(`❌ ${failed} failed`); + if (pending > 0) parts.push(`⏳ ${pending} pending`); + return parts.length > 0 ? parts.join(', ') : 'No checks found'; +} + +/** + * Render the PR status report. + */ +function renderPRStatus(data: CheckPRStatusResponse): string { + const lines: string[] = []; + const passed = data.passedCount ?? 0; + const failed = data.failedCount ?? 0; + const pending = data.pendingCount ?? 0; + const allPassed = data.allChecksPassed ?? false; + + lines.push(''); + lines.push( + `CI Status: ${allPassed ? '✅ All checks passed' : failed > 0 ? '❌ Checks failed' : '⏳ Checks pending'}` + ); + lines.push(` ${checkBadge(passed, failed, pending)}`); + + if (data.failedChecks && data.failedChecks.length > 0) { + lines.push(''); + lines.push('Failed checks:'); + for (const check of data.failedChecks) { + lines.push(` • ${check}`); + } + } + + if (data.softFailedChecks && data.softFailedChecks.length > 0) { + lines.push(''); + lines.push('Soft failures (non-blocking):'); + for (const check of data.softFailedChecks) { + lines.push(` • ${check}`); + } + } + + if (data.ownership) { + lines.push(''); + lines.push(`Owned by this instance: ${data.ownership.isOwnedByThisInstance ? 'yes' : 'no'}`); + lines.push(`Stale: ${data.ownership.isStale ? 'yes' : 'no'}`); + if (data.ownership.instanceId) lines.push(`Instance: ${data.ownership.instanceId}`); + } + + lines.push(''); + return lines.join('\n'); +} + +/** + * Render the merge result. + */ +function renderMergeResult(data: PRMergeResponse): string { + const lines: string[] = []; + lines.push(''); + + if (data.success) { + if (data.autoMergeEnabled) { + lines.push('✅ Auto-merge enabled — PR will merge automatically when checks pass.'); + } else if (data.mergeCommitSha) { + lines.push(`✅ PR merged. Commit: ${data.mergeCommitSha}`); + } else { + lines.push('✅ PR merged successfully.'); + } + } else { + if (data.checksPending) { + lines.push('⏳ Merge blocked — CI checks are still pending.'); + } else if (data.checksFailed) { + lines.push('❌ Merge blocked — CI checks failed.'); + if (data.failedChecks && data.failedChecks.length > 0) { + lines.push('Failed checks:'); + for (const check of data.failedChecks) { + lines.push(` • ${check}`); + } + } + } else { + lines.push(`❌ Merge failed: ${data.error || 'Unknown error'}`); + } + } + + lines.push(''); + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/** + * protomaker pr create + * + * Open a PR from a feature worktree. Commits unpushed changes, pushes the + * branch, and creates (or reuses) the PR on GitHub. + * + * Options: --commit-message, --pr-title, --pr-body, --base-branch, --draft + */ +export function createCommand(parent: Command): void { + const cmd = new Command('create '); + cmd.description('Open a PR from a feature worktree'); + cmd.option('--commit-message ', 'Commit message for unpushed changes'); + cmd.option('--pr-title ', 'Pull request title'); + cmd.option('--pr-body ', 'Pull request body'); + cmd.option('--base-branch ', 'Target base branch'); + cmd.option('--draft', 'Create as draft PR'); + + cmd.action(async (featureId: string, opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const body: Record = { + featureId, + projectPath: flags.project, + }; + + if (opts.commitMessage) body.commitMessage = opts.commitMessage; + if (opts.prTitle) body.prTitle = opts.prTitle; + if (opts.prBody) body.prBody = opts.prBody; + if (opts.baseBranch) body.baseBranch = opts.baseBranch; + if (opts.draft) body.draft = true; + + const result = await client.post('/worktree/create-pr', body); + + if (!result.ok) { + error(result.error || 'Failed to create PR'); + process.exit(1); + return; + } + + const prResult = result.data?.result; + if (!prResult) { + error('No result returned from server'); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output(prResult, flags); + } else { + const lines: string[] = []; + lines.push(''); + + if (prResult.committed && prResult.commitHash) { + lines.push(` Committed: ${prResult.commitHash}`); + } + lines.push(` Pushed: ${prResult.pushed ? 'yes' : 'no'}`); + lines.push(` Branch: ${prResult.branch}`); + + if (prResult.prCreated && prResult.prUrl) { + lines.push(` PR: ${prResult.prUrl}`); + if (prResult.prNumber) lines.push(` Number: #${prResult.prNumber}`); + if (prResult.prAlreadyExisted) lines.push(' (PR already existed)'); + } else if (prResult.prError) { + lines.push(` PR Error: ${prResult.prError}`); + if (prResult.browserUrl) { + lines.push(''); + lines.push(' Create in browser:'); + lines.push(` ${prResult.browserUrl}`); + } + } + + lines.push(''); + output(lines.join('\n'), flags); + } + }); + + parent.addCommand(cmd); +} + +/** + * protomaker pr status + * + * Show CI rollup for a pull request (check statuses, ownership, staleness). + */ +export function statusCommand(parent: Command): void { + const cmd = new Command('status '); + cmd.description('Show CI rollup for a pull request'); + + cmd.action(async (prNumberArg: string, opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const prNumber = parseInt(prNumberArg, 10); + if (isNaN(prNumber)) { + usageError(`Invalid PR number: "${prNumberArg}"`); + return; + } + + const result = await client.post('/github/check-pr-status', { + projectPath: flags.project, + prNumber, + }); + + if (!result.ok) { + error(result.error || `Failed to check PR #${prNumber} status`); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output(result.data, flags); + } else { + output(renderPRStatus(result.data ?? { success: false }), flags); + } + }); + + parent.addCommand(cmd); +} + +/** + * protomaker pr merge + * + * Merge a PR with the configured strategy (defaults to squash). + * + * Options: --strategy , --no-wait-for-ci + */ +export function mergeCommand(parent: Command): void { + const cmd = new Command('merge '); + cmd.description('Merge a pull request with the configured strategy'); + cmd.option('--strategy ', 'Merge strategy (merge, squash, rebase)', 'squash'); + cmd.option('--no-wait-for-ci', 'Do not wait for CI checks before merging'); + + cmd.action(async (prNumberArg: string, opts) => { + const flags = getGlobalFlags(opts); + const client = createClient(flags); + + const prNumber = parseInt(prNumberArg, 10); + if (isNaN(prNumber)) { + usageError(`Invalid PR number: "${prNumberArg}"`); + return; + } + + const validStrategies = ['merge', 'squash', 'rebase']; + if (!validStrategies.includes(opts.strategy)) { + usageError( + `Invalid strategy "${opts.strategy}". Must be one of: ${validStrategies.join(', ')}` + ); + return; + } + + const result = await client.post('/github/merge-pr', { + projectPath: flags.project, + prNumber, + strategy: opts.strategy, + waitForCI: !opts.noWaitForCi, + }); + + if (!result.ok) { + error(result.error || `Failed to merge PR #${prNumber}`); + process.exit(1); + return; + } + + if (getOutputMode(flags) === 'json') { + output(result.data, flags); + } else { + output(renderMergeResult(result.data ?? { success: false }), flags); + } + }); + + parent.addCommand(cmd); +} diff --git a/packages/mcp-server/plugins/automaker/.claude-plugin/plugin.json b/packages/mcp-server/plugins/automaker/.claude-plugin/plugin.json index d1fe13078..02ab76ca5 100644 --- a/packages/mcp-server/plugins/automaker/.claude-plugin/plugin.json +++ b/packages/mcp-server/plugins/automaker/.claude-plugin/plugin.json @@ -5,7 +5,7 @@ "author": { "name": "Proto Labs AI" }, - "repository": "https://github.com/protoLabsAI/automaker", + "repository": "https://github.com/protoLabsAI/protoMaker", "mcpServers": { "studio": { "command": "bash", diff --git a/packages/mcp-server/plugins/automaker/README.md b/packages/mcp-server/plugins/automaker/README.md index d718316ec..3470a05bd 100644 --- a/packages/mcp-server/plugins/automaker/README.md +++ b/packages/mcp-server/plugins/automaker/README.md @@ -78,7 +78,6 @@ The MCP server exposes ~159 tools organized by category: - **Scheduler** (2) -- status, maintenance tasks - **Observability** (8) -- Langfuse traces, costs, prompts, datasets - **Lead Engineer** (4) -- start, stop, status, handoffs -- **Agent Templates** (7) -- template CRUD, execution - **Escalation** (3) -- status, logs, acknowledgment - **Reports** (2) -- generate, open - **SetupLab** (7) -- repo analysis, gap analysis, alignment diff --git a/packages/mcp-server/plugins/automaker/agents/devops-health-check.md b/packages/mcp-server/plugins/automaker/agents/devops-health-check.md index 8a66f9866..91d99ef82 100644 --- a/packages/mcp-server/plugins/automaker/agents/devops-health-check.md +++ b/packages/mcp-server/plugins/automaker/agents/devops-health-check.md @@ -149,8 +149,8 @@ gh run list --limit 5 --json workflowName,status,conclusion,createdAt \ Check for known vulnerabilities: ```bash -# npm audit (from project directory) -cd /home/josh/dev/ava && npm audit --audit-level=moderate 2>/dev/null | tail -5 || echo "npm audit: N/A" +# npm audit (run from the project root) +npm audit --audit-level=moderate 2>/dev/null | tail -5 || echo "npm audit: N/A" ``` ## Output Format diff --git a/packages/mcp-server/plugins/automaker/commands/ava.md b/packages/mcp-server/plugins/automaker/commands/ava.md index abdea7299..10a34813c 100644 --- a/packages/mcp-server/plugins/automaker/commands/ava.md +++ b/packages/mcp-server/plugins/automaker/commands/ava.md @@ -214,7 +214,7 @@ This is your routing table. For every signal, find the right row and delegate ac | **PR Pipeline** | | | | Checks passing, no auto-merge | PR Maintainer agent | `start_agent` or delegate via native Agent tool | | Format failure in worktree | PR Maintainer agent | `start_agent` or delegate via native Agent tool | -| Unresolved CodeRabbit threads | PR Maintainer agent | `start_agent` or delegate via native Agent tool | +| Unresolved Quinn review threads | PR Maintainer agent | `start_agent` or delegate via native Agent tool | | PR behind main | PR Maintainer agent | `start_agent` or delegate via native Agent tool | | Build failure (TypeScript) | Feature agent retry or PR Maintainer | Retry first, delegate if mechanical | | Orphaned worktree with commits | PR Maintainer agent | `start_agent` or delegate via native Agent tool | @@ -295,7 +295,7 @@ You are the **autonomous operator** of this portfolio. Your job is to keep work - Create, update, and reorder features on the board - Delegate implementation to engineering agents via `start_agent` or native Agent tool - **Open PRs yourself** with `create_pr_from_worktree` when an agent finishes work but the PR never materializes -- **Merge PRs yourself** when checks pass and CodeRabbit is satisfied +- **Merge PRs yourself** when checks pass and Quinn's review is satisfied (approved / no unresolved threads) - Adjust settings (`update_settings`) when concurrency, model tier, or workflow gating is starving the pipeline - Run shell commands (`gh`, `git`, `npm run build`) when investigating or unblocking - Read code, logs, config, and trajectories for diagnostics @@ -424,7 +424,7 @@ Execute on every activation. - **Dependency chain** — Features with missing deps, in-progress with unsatisfied deps - **Verified features with no PR** — Check for remote commits, delegate PR creation to PR Maintainer - **Board state** — Merged-not-done, orphaned in-progress features, stale worktrees -- **PR pipeline** — Auto-merge readiness, CodeRabbit threads, format fixes, branch updates +- **PR pipeline** — Auto-merge readiness, Quinn review threads, format fixes, branch updates - **Server health** — Memory, CPU, health monitor, worktree cleanup - **Ava Channel** — Check for peer escalations, help requests, or coordination messages. If this instance is idle and peers are overloaded (visible via channel capacity posts), offer to take work. diff --git a/packages/mcp-server/plugins/automaker/commands/setuplab.md b/packages/mcp-server/plugins/automaker/commands/setuplab.md index c552d8712..ee0a55cb9 100644 --- a/packages/mcp-server/plugins/automaker/commands/setuplab.md +++ b/packages/mcp-server/plugins/automaker/commands/setuplab.md @@ -45,19 +45,19 @@ setuplab onboards projects into the protoLabs Studio ecosystem. Each project get ### TypeScript / Node.js -| Layer | Standard | -| ---------------- | -------------------------------------------------------------------------- | -| **Monorepo** | pnpm + Turborepo, `apps/` + `packages/` (or `libs/`) | -| **Frontend** | React 19 + Next.js 15, app router | -| **UI** | Tailwind CSS 4 + shadcn/ui + Radix primitives | -| **Components** | Storybook 10+ (nextjs-vite adapter) | -| **Testing** | Vitest (unit/integration) + Playwright (E2E) | -| **Linting** | ESLint 9 flat config + typescript-eslint strict | -| **Formatting** | Prettier | -| **Type Safety** | TypeScript 5.5+ strict, composite tsconfig per package | -| **CI/CD** | GitHub Actions (build, test, format, audit, CodeRabbit), branch protection | -| **Automation** | `.automaker/` + Discord project channels | -| **Git workflow** | Squash-only, branch protection, three-branch flow | +| Layer | Standard | +| ---------------- | ------------------------------------------------------------------------------- | +| **Monorepo** | pnpm + Turborepo, `apps/` + `packages/` (or `libs/`) | +| **Frontend** | React 19 + Next.js 15, app router | +| **UI** | Tailwind CSS 4 + shadcn/ui + Radix primitives | +| **Components** | Storybook 10+ (nextjs-vite adapter) | +| **Testing** | Vitest (unit/integration) + Playwright (E2E) | +| **Linting** | ESLint 9 flat config + typescript-eslint strict | +| **Formatting** | Prettier | +| **Type Safety** | TypeScript 5.5+ strict, composite tsconfig per package | +| **CI/CD** | GitHub Actions (build, test, format, audit), Quinn PR review, branch protection | +| **Automation** | `.automaker/` + Discord project channels | +| **Git workflow** | Squash-only, branch protection, three-branch flow | ### Python diff --git a/packages/mcp-server/plugins/automaker/hooks/handle-mcp-failure.sh b/packages/mcp-server/plugins/automaker/hooks/handle-mcp-failure.sh index a5074ff2c..b57ef3652 100755 --- a/packages/mcp-server/plugins/automaker/hooks/handle-mcp-failure.sh +++ b/packages/mcp-server/plugins/automaker/hooks/handle-mcp-failure.sh @@ -8,7 +8,7 @@ set -euo pipefail # Extract tool name and error from hook context TOOL_NAME="${TOOL_NAME:-unknown}" ERROR_MESSAGE="${ERROR_MESSAGE:-No error message available}" -PROJECT_PATH="${PROJECT_PATH:-/Users/kj/dev/protoMaker}" +PROJECT_PATH="${PROJECT_PATH:-${AUTOMAKER_ROOT:-$PWD}}" # Check if this is an MCP tool failure if [[ ! "$TOOL_NAME" =~ ^mcp__plugin_protolabs_studio__ ]]; then diff --git a/scripts/launch-protomaker.sh b/scripts/launch-protomaker.sh new file mode 100755 index 000000000..af79591a4 --- /dev/null +++ b/scripts/launch-protomaker.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# Launches protoMaker as a PRODUCTION build (server :3008 + UI :3007), the +# primary local instance. Invoked by the ai.protolabs.protomaker LaunchAgent at +# login and restarted on crash (KeepAlive). +# +# Why not `npm start`: that runs start-automaker.sh, an interactive TUI launcher +# (menu, spinners, TERM_COLS) that hangs / misbehaves headless under launchd +# with no terminal. This script runs the same production path non-interactively: +# build the prod artifacts, then serve the built server + UI preview. +# +# Build is turbo-cached, so it's near-instant when nothing changed and rebuilds +# on code changes — restart the agent to pick up new code: +# launchctl kickstart -k gui/$(id -u)/ai.protolabs.protomaker +# +# Logs: logs/autostart.{out,err}.log (see the plist). + +set -uo pipefail + +REPO="$HOME/dev/protomaker" +cd "$REPO" || { + echo "[launch-protomaker] FATAL: $REPO not found" + exit 1 +} + +# LaunchAgents start with a minimal PATH and no nvm — source it for node 22. +export NVM_DIR="$HOME/.nvm" +# shellcheck disable=SC1091 +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" +nvm use 22 >/dev/null 2>&1 || true +command -v npm >/dev/null 2>&1 || { + echo "[launch-protomaker] FATAL: npm not on PATH (nvm load failed?)" + exit 1 +} + +# Load repo-root .env into the environment so server + UI inherit secrets +# regardless of each process's cwd (the server's dotenv fallback misses the root +# .env when run from the apps/server workspace dir, leaving it on the DEFAULT +# api key and with NO GATEWAY_API_KEY -> model calls 401). .env is plain +# KEY=value (no multiline/space values). +set -a +# shellcheck disable=SC1091 +[ -f "$REPO/.env" ] && . "$REPO/.env" +set +a +export NODE_ENV=production + +if [ -z "${GATEWAY_API_KEY:-}" ] && [ -z "${OPENAI_API_KEY:-}" ]; then + echo "[launch-protomaker] WARNING: no GATEWAY_API_KEY/OPENAI_API_KEY in .env — model calls will 401" +fi + +# Free our ports before starting. On a KeepAlive restart, concurrently doesn't +# always reap its server/vite children, so a stale listener can orphan and hold +# :3008/:3007 — the new instance then crash-loops on EADDRINUSE forever (seen +# with a 1h-old orphaned server). Killing any prior listener here makes restarts +# deterministic. Safe: only one protoMaker instance should ever own these ports. +for port in 3008 3007; do + pids=$(lsof -ti:"$port" -sTCP:LISTEN 2>/dev/null || true) + if [ -n "$pids" ]; then + echo "[launch-protomaker] freeing port $port (killing stale listener: $pids)" + echo "$pids" | xargs kill -9 2>/dev/null || true + fi +done + +echo "[launch-protomaker] $(date '+%Y-%m-%d %H:%M:%S') PROD build (node $(node -v 2>/dev/null), NODE_ENV=$NODE_ENV, api_key=${AUTOMAKER_API_KEY:+set}, gateway=${GATEWAY_API_KEY:+set})" + +# Build production artifacts. turbo caches packages+server; vite build for UI. +if ! npm run build:packages \ + || ! npm run build --workspace=apps/server \ + || ! npm run build --workspace=apps/ui; then + echo "[launch-protomaker] BUILD FAILED — see errlog" + exit 1 +fi + +echo "[launch-protomaker] $(date '+%Y-%m-%d %H:%M:%S') build done — serving server :3008 + UI :3007" + +# Serve the built server + UI preview. exec so launchd tracks concurrently +# directly; --kill-others-on-fail makes one process dying take down the other so +# KeepAlive cleanly restarts the whole stack. +exec "$REPO/node_modules/.bin/concurrently" \ + --kill-others-on-fail \ + --names "server,ui" \ + --prefix-colors "blue,green" \ + "npm run start --workspace=apps/server" \ + "npm run preview --workspace=apps/ui -- --port 3007 --host" diff --git a/tools/board_monitor.py b/tools/board_monitor.py index 96e0c8ebb..222e059e5 100644 --- a/tools/board_monitor.py +++ b/tools/board_monitor.py @@ -17,6 +17,7 @@ import subprocess import sys from datetime import datetime, timezone +from typing import Optional STALE_THRESHOLD_SECONDS = 7200 # 2 hours @@ -39,7 +40,7 @@ def fetch_open_prs(repo: str) -> list: return json.loads(result.stdout) -def compute_staleness(pr: dict, now: datetime) -> dict | None: +def compute_staleness(pr: dict, now: datetime) -> Optional[dict]: """Return stale PR metadata dict, or None if the PR is not stale.""" updated_at_str = pr.get("updatedAt", "") if not updated_at_str: