From e2ed9d9f539c49b85998a6721f478e91287beea7 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Thu, 12 Feb 2026 01:25:59 -0600 Subject: [PATCH 1/4] Merge fix-integration-copilot --- src/tools/plan-approve.ts | 9 ++++++++- src/tools/plan.ts | 2 +- tests/tools/plan-approve.test.ts | 9 ++++++++- 3 files changed, 17 insertions(+), 3 deletions(-) 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..f6a50be 100644 --- a/src/tools/plan.ts +++ b/src/tools/plan.ts @@ -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/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); From 4d4a576ddf2a4b5060aa1d517ce880af3d1ba6cf Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Thu, 12 Feb 2026 01:31:59 -0600 Subject: [PATCH 2/4] Merge fix-supervisor-loop --- src/lib/orchestrator.ts | 14 ++++++++- tests/lib/orchestrator-modes.test.ts | 47 ++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index bc52af7..57250a4 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,6 +503,7 @@ 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) { 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', From 7fe05cad2b247d078269119dcea1af6caa8cf375 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Thu, 12 Feb 2026 01:53:11 -0600 Subject: [PATCH 3/4] fix: add plan merging state transition and improve PR message generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Transition plan status to merging while merge train is active - Add merging→running and merging→paused to valid plan transitions - Improve orchestrator createPR with job status table and real test config - Improve mc_pr tool with PR template lookup and conventional commit titles - Always include Mission Control attribution in PR bodies --- src/lib/orchestrator.ts | 52 +++++++++++++++++++++++++++++++++-- src/lib/plan-types.ts | 2 +- src/tools/pr.ts | 61 ++++++++++++++++++++++++++++++++++++----- tests/tools/pr.test.ts | 2 +- 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/src/lib/orchestrator.ts b/src/lib/orchestrator.ts index 57250a4..a384015 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -507,6 +507,8 @@ export class Orchestrator { } 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...`); @@ -566,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; @@ -901,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 = `feat: ${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/pr.ts b/src/tools/pr.ts index 849c3fa..023961b 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 (defaults to conventional commit format using 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 || `feat: ${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/tools/pr.test.ts b/tests/tools/pr.test.ts index e161350..e2f8ba6 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 conventional commit format as default title', async () => { const job: Job = { id: 'job-1', name: 'feature-auth', From af788aac8235c72a8bf3558411d6bb372411ea20 Mon Sep 17 00:00:00 2001 From: Nigel Bazzeghin Date: Thu, 12 Feb 2026 01:58:43 -0600 Subject: [PATCH 4/4] fix: remove hardcoded feat: prefix from PR titles, let callers control format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan name is used directly as the PR title. Tool descriptions and README updated to guide agents toward Conventional Commits format. Also fixes integration branch format in README docs (mc/integration/ → mc/integration-). --- README.md | 16 ++++++++-------- src/lib/orchestrator.ts | 2 +- src/tools/plan.ts | 2 +- src/tools/pr.ts | 4 ++-- tests/tools/pr.test.ts | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) 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 a384015..cda54c6 100644 --- a/src/lib/orchestrator.ts +++ b/src/lib/orchestrator.ts @@ -907,7 +907,7 @@ If your work needs human review before it can proceed: mc_report(status: "needs_ } const defaultBranch = await getDefaultBranch(); - const title = `feat: ${plan.name}`; + 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', ' ') : '—'; diff --git a/src/tools/plan.ts b/src/tools/plan.ts index f6a50be..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({ diff --git a/src/tools/pr.ts b/src/tools/pr.ts index 023961b..e40463c 100644 --- a/src/tools/pr.ts +++ b/src/tools/pr.ts @@ -67,7 +67,7 @@ export const mc_pr: ToolDefinition = tool({ title: tool.schema .string() .optional() - .describe('PR title (defaults to conventional commit format using job name)'), + .describe('PR title — use Conventional Commits format (e.g. "feat: add login", "fix: resolve timeout"). Defaults to job name.'), body: tool.schema .string() .optional() @@ -97,7 +97,7 @@ export const mc_pr: ToolDefinition = tool({ } // 3. Determine PR title (conventional commit format) - const prTitle = args.title || `feat: ${job.name}`; + const prTitle = args.title || job.name; // 4. Build gh pr create arguments const defaultBranch = await getDefaultBranch(job.worktreePath); diff --git a/tests/tools/pr.test.ts b/tests/tools/pr.test.ts index e2f8ba6..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 conventional commit format as default title', async () => { + it('should use job name as default title', async () => { const job: Job = { id: 'job-1', name: 'feature-auth',