Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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"] }
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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
Expand Down
66 changes: 63 additions & 3 deletions src/lib/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export class Orchestrator {
private toastCallback: ToastCallback | null = null;
private notifyCallback: NotifyCallback | null = null;
private jobsLaunchedCount = 0;
private approvedForMerge = new Set<string>();
private firstJobCompleted = false;

private getMergeTrainConfig(): {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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...`);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/lib/plan-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const VALID_PLAN_TRANSITIONS: Record<PlanStatus, PlanStatus[]> = {
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: [],
Expand Down
9 changes: 8 additions & 1 deletion src/tools/plan-approve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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());
Expand Down
4 changes: 2 additions & 2 deletions src/tools/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(),
};
Expand Down
61 changes: 54 additions & 7 deletions src/tools/pr.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const proc = Bun.spawn(['gh', 'pr', 'create', ...args], {
Expand All @@ -19,6 +19,45 @@ export async function executeGhCommand(args: string[]): Promise<string> {
return stdout.trim();
}

async function loadPrTemplate(cwd?: string): Promise<string | null> {
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: {
Expand All @@ -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()
Expand All @@ -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);
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions tests/lib/orchestrator-modes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading