Skip to content

Commit b571242

Browse files
authored
fix: P0 bugfixes and PR message generation (#38)
* Merge fix-integration-copilot * Merge fix-supervisor-loop * fix: add plan merging state transition and improve PR message generation - 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 * fix: remove hardcoded feat: prefix from PR titles, let callers control format 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-).
1 parent a9ea4a2 commit b571242

9 files changed

Lines changed: 192 additions & 24 deletions

File tree

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ AI: → mc_sync(name: "feature-checkout", strategy: "rebase")
163163

164164
```
165165
AI: → mc_plan(
166-
name: "search-upgrade",
166+
name: "feat: search upgrade",
167167
mode: "autopilot",
168168
jobs: [
169169
{ name: "schema", prompt: "Add search index tables" },
@@ -212,7 +212,7 @@ mc_merge("add-pricing") → into main
212212

213213
**Typical plan workflow:**
214214
```
215-
mc_plan("search-upgrade", mode: "autopilot", jobs: [
215+
mc_plan("feat: search upgrade", mode: "autopilot", jobs: [
216216
{ name: "schema", prompt: "Add search tables..." },
217217
{ name: "api", prompt: "Build search endpoints...", dependsOn: ["schema"] },
218218
{ 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
407407
| Parameter | Type | Required | Default | Description |
408408
|-----------|------|----------|---------|-------------|
409409
| `name` | `string` | Yes || Job name |
410-
| `title` | `string` | No | Job prompt | PR title |
411-
| `body` | `string` | No | | PR description |
410+
| `title` | `string` | No | Job name | PR title — use [Conventional Commits](https://www.conventionalcommits.org/) format (e.g. `feat: add login`, `fix: resolve timeout`) |
411+
| `body` | `string` | No | PR template or auto-generated | PR body. If omitted, uses `.github/pull_request_template.md` if found, otherwise generates a summary. |
412412
| `draft` | `boolean` | No | `false` | Create as draft PR |
413413

414414
#### `mc_sync`
@@ -443,7 +443,7 @@ Create and start a multi-job orchestrated plan.
443443

444444
| Parameter | Type | Required | Default | Description |
445445
|-----------|------|----------|---------|-------------|
446-
| `name` | `string` | Yes || Plan name |
446+
| `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`) |
447447
| `jobs` | `JobSpec[]` | Yes || Array of job definitions (see below) |
448448
| `mode` | `"autopilot"` \| `"copilot"` \| `"supervisor"` | No | `"autopilot"` | Execution mode |
449449
| `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.
474474
mc_plan
475475
476476
├─ Validate (unique names, valid deps, no circular deps)
477-
├─ Create integration branch: mc/integration/{plan-id}
477+
├─ Create integration branch: mc/integration-{plan-id}
478478
479479
├─ [copilot] ──→ Pause (pending) ──→ mc_plan_approve ──→ Continue
480480
@@ -534,7 +534,7 @@ This example uses `mc_plan` instead of four separate `mc_launch` calls because:
534534
AI: I'll create a plan for the dashboard feature with proper dependencies.
535535
536536
→ mc_plan(
537-
name: "dashboard-feature",
537+
name: "feat: analytics dashboard",
538538
mode: "autopilot",
539539
jobs: [
540540
{
@@ -571,7 +571,7 @@ Result:
571571
572572
### Merge Train
573573

574-
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}`):
574+
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}`):
575575

576576
1. **Merge**`git merge --no-ff {job-branch}` into the integration worktree
577577
2. **Test** — If a `testCommand` is configured (or detected from `package.json`), it runs after each merge

src/lib/orchestrator.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export class Orchestrator {
166166
private toastCallback: ToastCallback | null = null;
167167
private notifyCallback: NotifyCallback | null = null;
168168
private jobsLaunchedCount = 0;
169+
private approvedForMerge = new Set<string>();
169170
private firstJobCompleted = false;
170171

171172
private getMergeTrainConfig(): {
@@ -280,10 +281,20 @@ export class Orchestrator {
280281
`Checkpoint mismatch: expected "${this.checkpoint}", got "${checkpoint}"`,
281282
);
282283
}
284+
const wasPreMerge = this.checkpoint === 'pre_merge';
283285
this.checkpoint = null;
284286

285287
const plan = await loadPlan();
286288
if (plan && plan.status === 'paused') {
289+
// Track jobs approved for merge so reconciler doesn't re-checkpoint them
290+
if (wasPreMerge) {
291+
for (const job of plan.jobs) {
292+
if (job.status === 'ready_to_merge') {
293+
this.approvedForMerge.add(job.name);
294+
}
295+
}
296+
}
297+
287298
plan.status = 'running';
288299
plan.checkpoint = null;
289300
await savePlan(plan);
@@ -483,7 +494,7 @@ export class Orchestrator {
483494
continue;
484495
}
485496

486-
if (this.isSupervisor(plan)) {
497+
if (this.isSupervisor(plan) && !this.approvedForMerge.has(job.name)) {
487498
await this.setCheckpoint('pre_merge', plan);
488499
return;
489500
}
@@ -492,9 +503,12 @@ export class Orchestrator {
492503
this.mergeTrain.enqueue(job);
493504
await updatePlanJob(plan.id, job.name, { status: 'merging' });
494505
job.status = 'merging';
506+
this.approvedForMerge.delete(job.name);
495507
}
496508

497509
if (this.mergeTrain && this.mergeTrain.getQueue().length > 0) {
510+
plan.status = 'merging';
511+
498512
const nextJob = this.mergeTrain.getQueue()[0];
499513
this.showToast('Mission Control', `Merging job "${nextJob.name}"...`, 'info');
500514
this.notify(`⇄ Merging job "${nextJob.name}" into integration branch...`);
@@ -554,6 +568,10 @@ export class Orchestrator {
554568
}
555569
}
556570

571+
if (plan.status === 'merging' && (!this.mergeTrain || this.mergeTrain.getQueue().length === 0)) {
572+
plan.status = 'running';
573+
}
574+
557575
const latestPlan = await loadPlan();
558576
if (!latestPlan) {
559577
return;
@@ -889,8 +907,50 @@ If your work needs human review before it can proceed: mc_report(status: "needs_
889907
}
890908

891909
const defaultBranch = await getDefaultBranch();
892-
const title = plan.name.replace(/"/g, '\\"');
893-
const body = `Automated PR from Mission Control plan: ${plan.name}\n\nJobs:\n${plan.jobs.map((j) => `- ${j.name}`).join('\n')}`;
910+
const title = plan.name;
911+
const jobLines = plan.jobs.map((j) => {
912+
const status = j.status === 'merged' ? '✅' : j.status === 'failed' ? '❌' : '⏳';
913+
const mergedAt = j.mergedAt ? new Date(j.mergedAt).toISOString().slice(0, 19).replace('T', ' ') : '—';
914+
return `| ${j.name} | ${status} ${j.status} | ${mergedAt} |`;
915+
}).join('\n');
916+
917+
const mergeTrainConfig = this.getMergeTrainConfig();
918+
const testingLines: string[] = [];
919+
if (mergeTrainConfig.testCommand) {
920+
testingLines.push(`- [x] \`${mergeTrainConfig.testCommand}\` passed after each merge`);
921+
}
922+
if (mergeTrainConfig.setupCommands?.length) {
923+
testingLines.push(`- [x] Setup: \`${mergeTrainConfig.setupCommands.join(' && ')}\``);
924+
}
925+
if (testingLines.length === 0) {
926+
testingLines.push('- No test command configured');
927+
}
928+
929+
const body = [
930+
'## Summary',
931+
'',
932+
`Orchestrated plan **${plan.name}** with ${plan.jobs.length} job(s).`,
933+
'',
934+
'## Jobs',
935+
'',
936+
'| Job | Status | Merged At |',
937+
'|-----|--------|-----------|',
938+
jobLines,
939+
'',
940+
'## Testing',
941+
'',
942+
...testingLines,
943+
'',
944+
'## Notes',
945+
'',
946+
`- Integration branch: \`${plan.integrationBranch}\``,
947+
`- Base commit: \`${plan.baseCommit.slice(0, 8)}\``,
948+
`- Mode: ${plan.mode}`,
949+
'',
950+
'---',
951+
'',
952+
'🚀 *Automated PR from [Mission Control](https://github.com/nigel-dev/opencode-mission-control)*',
953+
].join('\n');
894954
const prResult = await this.runCommand([
895955
'gh',
896956
'pr',

src/lib/plan-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ export const VALID_PLAN_TRANSITIONS: Record<PlanStatus, PlanStatus[]> = {
6363
pending: ['running', 'failed', 'canceled'],
6464
running: ['paused', 'merging', 'failed', 'canceled'],
6565
paused: ['running', 'failed', 'canceled'],
66-
merging: ['creating_pr', 'failed', 'canceled'],
66+
merging: ['running', 'paused', 'creating_pr', 'failed', 'canceled'],
6767
creating_pr: ['completed', 'failed', 'canceled'],
6868
completed: [],
6969
failed: [],

src/tools/plan-approve.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { getSharedMonitor, getSharedNotifyCallback, setSharedOrchestrator } from
55
import type { CheckpointType } from '../lib/plan-types';
66
import { loadConfig } from '../lib/config';
77
import { getCurrentModel } from '../lib/model-tracker';
8+
import { createIntegrationBranch } from '../lib/integration';
9+
import { resolvePostCreateHook } from '../lib/worktree-setup';
810

911
export const mc_plan_approve: ToolDefinition = tool({
1012
description:
@@ -49,10 +51,15 @@ export const mc_plan_approve: ToolDefinition = tool({
4951
);
5052
}
5153

54+
// Create integration infrastructure that copilot mode skipped
55+
const config = await loadConfig();
56+
const integrationPostCreate = resolvePostCreateHook(config.worktreeSetup);
57+
const integration = await createIntegrationBranch(plan.id, integrationPostCreate);
58+
plan.integrationBranch = integration.branch;
59+
plan.integrationWorktree = integration.worktreePath;
5260
plan.status = 'running';
5361
await savePlan(plan);
5462

55-
const config = await loadConfig();
5663
const orchestrator = new Orchestrator(getSharedMonitor(), config, { notify: getSharedNotifyCallback() ?? undefined });
5764
setSharedOrchestrator(orchestrator);
5865
orchestrator.setPlanModelSnapshot(getCurrentModel());

src/tools/plan.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const mc_plan: ToolDefinition = tool({
1212
description:
1313
'Create and start a multi-job orchestrated plan with dependency management',
1414
args: {
15-
name: tool.schema.string().describe('Plan name'),
15+
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").'),
1616
jobs: tool.schema
1717
.array(
1818
tool.schema.object({
@@ -111,7 +111,7 @@ export const mc_plan: ToolDefinition = tool({
111111
placement: args.placement,
112112
status: 'pending',
113113
jobs: jobSpecs,
114-
integrationBranch: `mc/integration/${planId.slice(0, 8)}`,
114+
integrationBranch: `mc/integration-${planId}`,
115115
baseCommit,
116116
createdAt: new Date().toISOString(),
117117
};

src/tools/pr.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { tool, type ToolDefinition } from '@opencode-ai/plugin';
22
import { getDefaultBranch } from '../lib/git';
3-
import { getJobByName } from '../lib/job-state';
3+
import { getJobByName, type Job } from '../lib/job-state';
44

55
export async function executeGhCommand(args: string[]): Promise<string> {
66
const proc = Bun.spawn(['gh', 'pr', 'create', ...args], {
@@ -19,6 +19,45 @@ export async function executeGhCommand(args: string[]): Promise<string> {
1919
return stdout.trim();
2020
}
2121

22+
async function loadPrTemplate(cwd?: string): Promise<string | null> {
23+
const candidates = [
24+
'.github/pull_request_template.md',
25+
'.github/PULL_REQUEST_TEMPLATE.md',
26+
'pull_request_template.md',
27+
'PULL_REQUEST_TEMPLATE.md',
28+
'.github/PULL_REQUEST_TEMPLATE/pull_request_template.md',
29+
];
30+
31+
for (const candidate of candidates) {
32+
try {
33+
const fullPath = cwd ? `${cwd}/${candidate}` : candidate;
34+
const file = Bun.file(fullPath);
35+
if (await file.exists()) {
36+
return await file.text();
37+
}
38+
} catch {
39+
continue;
40+
}
41+
}
42+
return null;
43+
}
44+
45+
function buildDefaultBody(job: Job): string {
46+
return [
47+
'## Summary',
48+
'',
49+
job.prompt,
50+
'',
51+
'## Changes',
52+
'',
53+
`Branch: \`${job.branch}\``,
54+
'',
55+
'---',
56+
'',
57+
'🚀 *Created by [Mission Control](https://github.com/nigel-dev/opencode-mission-control)*',
58+
].join('\n');
59+
}
60+
2261
export const mc_pr: ToolDefinition = tool({
2362
description: 'Create a pull request from a job\'s branch',
2463
args: {
@@ -28,11 +67,11 @@ export const mc_pr: ToolDefinition = tool({
2867
title: tool.schema
2968
.string()
3069
.optional()
31-
.describe('PR title (defaults to job prompt)'),
70+
.describe('PR title — use Conventional Commits format (e.g. "feat: add login", "fix: resolve timeout"). Defaults to job name.'),
3271
body: tool.schema
3372
.string()
3473
.optional()
35-
.describe('PR body'),
74+
.describe('PR body (defaults to PR template or generated summary)'),
3675
draft: tool.schema
3776
.boolean()
3877
.optional()
@@ -57,8 +96,8 @@ export const mc_pr: ToolDefinition = tool({
5796
throw new Error(`Failed to push branch "${job.branch}": ${pushStderr}`);
5897
}
5998

60-
// 3. Determine PR title (default to job prompt)
61-
const prTitle = args.title || job.prompt;
99+
// 3. Determine PR title (conventional commit format)
100+
const prTitle = args.title || job.name;
62101

63102
// 4. Build gh pr create arguments
64103
const defaultBranch = await getDefaultBranch(job.worktreePath);
@@ -68,9 +107,17 @@ export const mc_pr: ToolDefinition = tool({
68107
'--base', defaultBranch,
69108
];
70109

71-
// 5. Add optional body
110+
// 5. Build PR body — use explicit body, or fall back to default
111+
const mcAttribution = '\n\n---\n\n🚀 *Created by [Mission Control](https://github.com/nigel-dev/opencode-mission-control)*';
72112
if (args.body) {
73-
ghArgs.push('--body', args.body);
113+
ghArgs.push('--body', args.body + mcAttribution);
114+
} else {
115+
const template = await loadPrTemplate(job.worktreePath);
116+
if (template) {
117+
ghArgs.push('--body', template + mcAttribution);
118+
} else {
119+
ghArgs.push('--body', buildDefaultBody(job));
120+
}
74121
}
75122

76123
// 6. Add draft flag if specified

tests/lib/orchestrator-modes.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,53 @@ describe('orchestrator modes', () => {
388388
expect(orchestrator.clearCheckpoint('pre_pr')).rejects.toThrow('Checkpoint mismatch');
389389
});
390390

391+
it('does not re-checkpoint after pre_merge approval', async () => {
392+
planState = makePlan({
393+
mode: 'supervisor',
394+
status: 'running',
395+
jobs: [
396+
makeJob('merge-me', { status: 'completed', mergeOrder: 0, branch: 'mc/merge-me' }),
397+
],
398+
});
399+
400+
const fakeTrain = {
401+
queue: [] as JobSpec[],
402+
enqueue(job: JobSpec) {
403+
this.queue.push(job);
404+
},
405+
getQueue() {
406+
return [...this.queue];
407+
},
408+
async processNext() {
409+
this.queue.shift();
410+
return { success: true, mergedAt: '2026-01-02T00:00:00.000Z' };
411+
},
412+
};
413+
414+
const orchestrator = new Orchestrator(monitor as any, DEFAULT_CONFIG as any, toastCallback);
415+
416+
// First reconcile: job transitions completed -> ready_to_merge, then supervisor checkpoints
417+
await (orchestrator as any).reconcile();
418+
expect(orchestrator.getCheckpoint()).toBe('pre_merge');
419+
expect(planState?.status).toBe('paused');
420+
421+
// Inject fake merge train before clearing so the auto-reconcile uses it
422+
(orchestrator as any).mergeTrain = fakeTrain;
423+
424+
// Simulate mc_plan_approve clearing the checkpoint
425+
await orchestrator.clearCheckpoint('pre_merge');
426+
expect(orchestrator.getCheckpoint()).toBeNull();
427+
expect(planState?.status).toBe('running');
428+
429+
// Wait for the auto-reconcile triggered by clearCheckpoint/startReconciler
430+
await new Promise((resolve) => setTimeout(resolve, 50));
431+
432+
// Job should have moved to merging (enqueued in merge train) and then merged
433+
expect(orchestrator.getCheckpoint()).not.toBe('pre_merge');
434+
const mergeJob = planState?.jobs.find(j => j.name === 'merge-me');
435+
expect(mergeJob?.status).toBe('merged');
436+
});
437+
391438
it('sends checkpoint toast notifications', async () => {
392439
planState = makePlan({
393440
mode: 'supervisor',

0 commit comments

Comments
 (0)