diff --git a/README.md b/README.md index adacb8e..dc27c7c 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,7 @@ AI: → mc_sync(name: "feature-checkout", strategy: "rebase") ``` AI: → mc_plan( - name: "search-upgrade", + name: "feat: search upgrade", mode: "autopilot", jobs: [ { name: "schema", prompt: "Add search index tables" }, @@ -212,7 +212,7 @@ mc_merge("add-pricing") → into main **Typical plan workflow:** ``` -mc_plan("search-upgrade", mode: "autopilot", jobs: [ +mc_plan("feat: search upgrade", mode: "autopilot", jobs: [ { name: "schema", prompt: "Add search tables..." }, { name: "api", prompt: "Build search endpoints...", dependsOn: ["schema"] }, { name: "ui", prompt: "Build search UI...", dependsOn: ["api"] } @@ -407,8 +407,8 @@ Push the job's branch and create a GitHub Pull Request. Requires the `gh` CLI to | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `name` | `string` | Yes | — | Job name | -| `title` | `string` | No | Job prompt | PR title | -| `body` | `string` | No | — | PR description | +| `title` | `string` | No | Job name | PR title — use [Conventional Commits](https://www.conventionalcommits.org/) format (e.g. `feat: add login`, `fix: resolve timeout`) | +| `body` | `string` | No | PR template or auto-generated | PR body. If omitted, uses `.github/pull_request_template.md` if found, otherwise generates a summary. | | `draft` | `boolean` | No | `false` | Create as draft PR | #### `mc_sync` @@ -443,7 +443,7 @@ Create and start a multi-job orchestrated plan. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| -| `name` | `string` | Yes | — | Plan name | +| `name` | `string` | Yes | — | Plan name — used as the PR title, so use [Conventional Commits](https://www.conventionalcommits.org/) format (e.g. `feat: add search`, `fix: resolve auth bugs`) | | `jobs` | `JobSpec[]` | Yes | — | Array of job definitions (see below) | | `mode` | `"autopilot"` \| `"copilot"` \| `"supervisor"` | No | `"autopilot"` | Execution mode | | `placement` | `"session"` \| `"window"` | No | Config default | tmux placement for all jobs in this plan | @@ -474,7 +474,7 @@ Create and start a multi-job orchestrated plan. mc_plan │ ├─ Validate (unique names, valid deps, no circular deps) - ├─ Create integration branch: mc/integration/{plan-id} + ├─ Create integration branch: mc/integration-{plan-id} │ ├─ [copilot] ──→ Pause (pending) ──→ mc_plan_approve ──→ Continue │ @@ -534,7 +534,7 @@ This example uses `mc_plan` instead of four separate `mc_launch` calls because: AI: I'll create a plan for the dashboard feature with proper dependencies. → mc_plan( - name: "dashboard-feature", + name: "feat: analytics dashboard", mode: "autopilot", jobs: [ { @@ -571,7 +571,7 @@ Result: ### Merge Train -The Merge Train is the engine behind plan integration. Each completed job's branch is merged into a dedicated **integration branch** (`mc/integration/{plan-id}`): +The Merge Train is the engine behind plan integration. Each completed job's branch is merged into a dedicated **integration branch** (`mc/integration-{plan-id}`): 1. **Merge** — `git merge --no-ff {job-branch}` into the integration worktree 2. **Test** — If a `testCommand` is configured (or detected from `package.json`), it runs after each merge diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index bc52af7..cda54c6 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -166,6 +166,7 @@ export class Orchestrator { private toastCallback: ToastCallback | null = null; private notifyCallback: NotifyCallback | null = null; private jobsLaunchedCount = 0; + private approvedForMerge = new Set(); private firstJobCompleted = false; private getMergeTrainConfig(): { @@ -280,10 +281,20 @@ export class Orchestrator { `Checkpoint mismatch: expected "${this.checkpoint}", got "${checkpoint}"`, ); } + const wasPreMerge = this.checkpoint === 'pre_merge'; this.checkpoint = null; const plan = await loadPlan(); if (plan && plan.status === 'paused') { + // Track jobs approved for merge so reconciler doesn't re-checkpoint them + if (wasPreMerge) { + for (const job of plan.jobs) { + if (job.status === 'ready_to_merge') { + this.approvedForMerge.add(job.name); + } + } + } + plan.status = 'running'; plan.checkpoint = null; await savePlan(plan); @@ -483,7 +494,7 @@ export class Orchestrator { continue; } - if (this.isSupervisor(plan)) { + if (this.isSupervisor(plan) && !this.approvedForMerge.has(job.name)) { await this.setCheckpoint('pre_merge', plan); return; } @@ -492,9 +503,12 @@ export class Orchestrator { this.mergeTrain.enqueue(job); await updatePlanJob(plan.id, job.name, { status: 'merging' }); job.status = 'merging'; + this.approvedForMerge.delete(job.name); } if (this.mergeTrain && this.mergeTrain.getQueue().length > 0) { + plan.status = 'merging'; + const nextJob = this.mergeTrain.getQueue()[0]; this.showToast('Mission Control', `Merging job "${nextJob.name}"...`, 'info'); this.notify(`⇄ Merging job "${nextJob.name}" into integration branch...`); @@ -554,6 +568,10 @@ export class Orchestrator { } } + if (plan.status === 'merging' && (!this.mergeTrain || this.mergeTrain.getQueue().length === 0)) { + plan.status = 'running'; + } + const latestPlan = await loadPlan(); if (!latestPlan) { return; @@ -889,8 +907,50 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ } const defaultBranch = await getDefaultBranch(); - const title = plan.name.replace(/"/g, '\\"'); - const body = `Automated PR from Mission Control plan: ${plan.name}\n\nJobs:\n${plan.jobs.map((j) => `- ${j.name}`).join('\n')}`; + const title = plan.name; + const jobLines = plan.jobs.map((j) => { + const status = j.status === 'merged' ? '✅' : j.status === 'failed' ? '❌' : '⏳'; + const mergedAt = j.mergedAt ? new Date(j.mergedAt).toISOString().slice(0, 19).replace('T', ' ') : '—'; + return `| ${j.name} | ${status} ${j.status} | ${mergedAt} |`; + }).join('\n'); + + const mergeTrainConfig = this.getMergeTrainConfig(); + const testingLines: string[] = []; + if (mergeTrainConfig.testCommand) { + testingLines.push(`- [x] \`${mergeTrainConfig.testCommand}\` passed after each merge`); + } + if (mergeTrainConfig.setupCommands?.length) { + testingLines.push(`- [x] Setup: \`${mergeTrainConfig.setupCommands.join(' && ')}\``); + } + if (testingLines.length === 0) { + testingLines.push('- No test command configured'); + } + + const body = [ + '## Summary', + '', + `Orchestrated plan **${plan.name}** with ${plan.jobs.length} job(s).`, + '', + '## Jobs', + '', + '| Job | Status | Merged At |', + '|-----|--------|-----------|', + jobLines, + '', + '## Testing', + '', + ...testingLines, + '', + '## Notes', + '', + `- Integration branch: \`${plan.integrationBranch}\``, + `- Base commit: \`${plan.baseCommit.slice(0, 8)}\``, + `- Mode: ${plan.mode}`, + '', + '---', + '', + '🚀 *Automated PR from [Mission Control](https://github.com/nigel-dev/opencode-mission-control)*', + ].join('\n'); const prResult = await this.runCommand([ 'gh', 'pr', diff --git a/src/lib/plan-types.ts b/src/lib/plan-types.ts index 7663195..ee03af9 100644 --- a/src/lib/plan-types.ts +++ b/src/lib/plan-types.ts @@ -63,7 +63,7 @@ export const VALID_PLAN_TRANSITIONS: Record = { pending: ['running', 'failed', 'canceled'], running: ['paused', 'merging', 'failed', 'canceled'], paused: ['running', 'failed', 'canceled'], - merging: ['creating_pr', 'failed', 'canceled'], + merging: ['running', 'paused', 'creating_pr', 'failed', 'canceled'], creating_pr: ['completed', 'failed', 'canceled'], completed: [], failed: [], diff --git a/src/tools/plan-approve.ts b/src/tools/plan-approve.ts index cafe1b7..d3e316d 100644 --- a/src/tools/plan-approve.ts +++ b/src/tools/plan-approve.ts @@ -5,6 +5,8 @@ import { getSharedMonitor, getSharedNotifyCallback, setSharedOrchestrator } from import type { CheckpointType } from '../lib/plan-types'; import { loadConfig } from '../lib/config'; import { getCurrentModel } from '../lib/model-tracker'; +import { createIntegrationBranch } from '../lib/integration'; +import { resolvePostCreateHook } from '../lib/worktree-setup'; export const mc_plan_approve: ToolDefinition = tool({ description: @@ -49,10 +51,15 @@ export const mc_plan_approve: ToolDefinition = tool({ ); } + // Create integration infrastructure that copilot mode skipped + const config = await loadConfig(); + const integrationPostCreate = resolvePostCreateHook(config.worktreeSetup); + const integration = await createIntegrationBranch(plan.id, integrationPostCreate); + plan.integrationBranch = integration.branch; + plan.integrationWorktree = integration.worktreePath; plan.status = 'running'; await savePlan(plan); - const config = await loadConfig(); const orchestrator = new Orchestrator(getSharedMonitor(), config, { notify: getSharedNotifyCallback() ?? undefined }); setSharedOrchestrator(orchestrator); orchestrator.setPlanModelSnapshot(getCurrentModel()); diff --git a/src/tools/plan.ts b/src/tools/plan.ts index d7ff591..d4d9d1c 100644 --- a/src/tools/plan.ts +++ b/src/tools/plan.ts @@ -12,7 +12,7 @@ export const mc_plan: ToolDefinition = tool({ description: 'Create and start a multi-job orchestrated plan with dependency management', args: { - name: tool.schema.string().describe('Plan name'), + name: tool.schema.string().describe('Plan name — used as the PR title. Use Conventional Commits format (e.g. "feat: add search", "fix: resolve auth bugs").'), jobs: tool.schema .array( tool.schema.object({ @@ -111,7 +111,7 @@ export const mc_plan: ToolDefinition = tool({ placement: args.placement, status: 'pending', jobs: jobSpecs, - integrationBranch: `mc/integration/${planId.slice(0, 8)}`, + integrationBranch: `mc/integration-${planId}`, baseCommit, createdAt: new Date().toISOString(), }; diff --git a/src/tools/pr.ts b/src/tools/pr.ts index 849c3fa..e40463c 100644 --- a/src/tools/pr.ts +++ b/src/tools/pr.ts @@ -1,6 +1,6 @@ import { tool, type ToolDefinition } from '@opencode-ai/plugin'; import { getDefaultBranch } from '../lib/git'; -import { getJobByName } from '../lib/job-state'; +import { getJobByName, type Job } from '../lib/job-state'; export async function executeGhCommand(args: string[]): Promise { const proc = Bun.spawn(['gh', 'pr', 'create', ...args], { @@ -19,6 +19,45 @@ export async function executeGhCommand(args: string[]): Promise { return stdout.trim(); } +async function loadPrTemplate(cwd?: string): Promise { + const candidates = [ + '.github/pull_request_template.md', + '.github/PULL_REQUEST_TEMPLATE.md', + 'pull_request_template.md', + 'PULL_REQUEST_TEMPLATE.md', + '.github/PULL_REQUEST_TEMPLATE/pull_request_template.md', + ]; + + for (const candidate of candidates) { + try { + const fullPath = cwd ? `${cwd}/${candidate}` : candidate; + const file = Bun.file(fullPath); + if (await file.exists()) { + return await file.text(); + } + } catch { + continue; + } + } + return null; +} + +function buildDefaultBody(job: Job): string { + return [ + '## Summary', + '', + job.prompt, + '', + '## Changes', + '', + `Branch: \`${job.branch}\``, + '', + '---', + '', + '🚀 *Created by [Mission Control](https://github.com/nigel-dev/opencode-mission-control)*', + ].join('\n'); +} + export const mc_pr: ToolDefinition = tool({ description: 'Create a pull request from a job\'s branch', args: { @@ -28,11 +67,11 @@ export const mc_pr: ToolDefinition = tool({ title: tool.schema .string() .optional() - .describe('PR title (defaults to job prompt)'), + .describe('PR title — use Conventional Commits format (e.g. "feat: add login", "fix: resolve timeout"). Defaults to job name.'), body: tool.schema .string() .optional() - .describe('PR body'), + .describe('PR body (defaults to PR template or generated summary)'), draft: tool.schema .boolean() .optional() @@ -57,8 +96,8 @@ export const mc_pr: ToolDefinition = tool({ throw new Error(`Failed to push branch "${job.branch}": ${pushStderr}`); } - // 3. Determine PR title (default to job prompt) - const prTitle = args.title || job.prompt; + // 3. Determine PR title (conventional commit format) + const prTitle = args.title || job.name; // 4. Build gh pr create arguments const defaultBranch = await getDefaultBranch(job.worktreePath); @@ -68,9 +107,17 @@ export const mc_pr: ToolDefinition = tool({ '--base', defaultBranch, ]; - // 5. Add optional body + // 5. Build PR body — use explicit body, or fall back to default + const mcAttribution = '\n\n---\n\n🚀 *Created by [Mission Control](https://github.com/nigel-dev/opencode-mission-control)*'; if (args.body) { - ghArgs.push('--body', args.body); + ghArgs.push('--body', args.body + mcAttribution); + } else { + const template = await loadPrTemplate(job.worktreePath); + if (template) { + ghArgs.push('--body', template + mcAttribution); + } else { + ghArgs.push('--body', buildDefaultBody(job)); + } } // 6. Add draft flag if specified diff --git a/tests/lib/orchestrator-modes.test.ts b/tests/lib/orchestrator-modes.test.ts index f8561d3..ec4f4e3 100644 --- a/tests/lib/orchestrator-modes.test.ts +++ b/tests/lib/orchestrator-modes.test.ts @@ -388,6 +388,53 @@ describe('orchestrator modes', () => { expect(orchestrator.clearCheckpoint('pre_pr')).rejects.toThrow('Checkpoint mismatch'); }); + it('does not re-checkpoint after pre_merge approval', async () => { + planState = makePlan({ + mode: 'supervisor', + status: 'running', + jobs: [ + makeJob('merge-me', { status: 'completed', mergeOrder: 0, branch: 'mc/merge-me' }), + ], + }); + + const fakeTrain = { + queue: [] as JobSpec[], + enqueue(job: JobSpec) { + this.queue.push(job); + }, + getQueue() { + return [...this.queue]; + }, + async processNext() { + this.queue.shift(); + return { success: true, mergedAt: '2026-01-02T00:00:00.000Z' }; + }, + }; + + const orchestrator = new Orchestrator(monitor as any, DEFAULT_CONFIG as any, toastCallback); + + // First reconcile: job transitions completed -> ready_to_merge, then supervisor checkpoints + await (orchestrator as any).reconcile(); + expect(orchestrator.getCheckpoint()).toBe('pre_merge'); + expect(planState?.status).toBe('paused'); + + // Inject fake merge train before clearing so the auto-reconcile uses it + (orchestrator as any).mergeTrain = fakeTrain; + + // Simulate mc_plan_approve clearing the checkpoint + await orchestrator.clearCheckpoint('pre_merge'); + expect(orchestrator.getCheckpoint()).toBeNull(); + expect(planState?.status).toBe('running'); + + // Wait for the auto-reconcile triggered by clearCheckpoint/startReconciler + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Job should have moved to merging (enqueued in merge train) and then merged + expect(orchestrator.getCheckpoint()).not.toBe('pre_merge'); + const mergeJob = planState?.jobs.find(j => j.name === 'merge-me'); + expect(mergeJob?.status).toBe('merged'); + }); + it('sends checkpoint toast notifications', async () => { planState = makePlan({ mode: 'supervisor', diff --git a/tests/tools/plan-approve.test.ts b/tests/tools/plan-approve.test.ts index 86b9a70..e87a6b7 100644 --- a/tests/tools/plan-approve.test.ts +++ b/tests/tools/plan-approve.test.ts @@ -3,6 +3,8 @@ import * as planState from '../../src/lib/plan-state'; import * as orchestrator from '../../src/lib/orchestrator'; import * as config from '../../src/lib/config'; import * as monitor from '../../src/lib/monitor'; +import * as integration from '../../src/lib/integration'; +import * as worktreeSetup from '../../src/lib/worktree-setup'; const { mc_plan_approve } = await import('../../src/tools/plan-approve'); @@ -78,7 +80,7 @@ describe('mc_plan_approve', () => { { id: 'j1', name: 'auth', prompt: 'do auth', status: 'queued' }, { id: 'j2', name: 'api', prompt: 'do api', status: 'queued' }, ], - integrationBranch: 'mc/integration/plan-1', + integrationBranch: 'mc/integration-plan-1', baseCommit: 'abc123', createdAt: new Date().toISOString(), }); @@ -92,6 +94,11 @@ describe('mc_plan_approve', () => { setPlanModelSnapshot: vi.fn(), }) as any, ); + vi.spyOn(integration, 'createIntegrationBranch').mockResolvedValue({ + branch: 'mc/integration-plan-1', + worktreePath: '/tmp/integration-plan-1', + }); + vi.spyOn(worktreeSetup, 'resolvePostCreateHook').mockReturnValue(undefined as any); const result = await mc_plan_approve.execute({}, mockContext); diff --git a/tests/tools/pr.test.ts b/tests/tools/pr.test.ts index e161350..a1ebf51 100644 --- a/tests/tools/pr.test.ts +++ b/tests/tools/pr.test.ts @@ -85,7 +85,7 @@ describe('mc_pr', () => { }); describe('argument handling', () => { - it('should use job prompt as default title', async () => { + it('should use job name as default title', async () => { const job: Job = { id: 'job-1', name: 'feature-auth',