opencode-mission-control 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +135 -40
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -14045,8 +14045,8 @@ var init_plan_types = __esm(() => {
14045
14045
  waiting_deps: ["running", "stopped", "canceled"],
14046
14046
  running: ["completed", "failed", "stopped", "canceled"],
14047
14047
  completed: ["ready_to_merge", "stopped", "canceled"],
14048
- failed: ["stopped", "canceled"],
14049
- ready_to_merge: ["merging", "stopped", "canceled"],
14048
+ failed: ["ready_to_merge", "stopped", "canceled"],
14049
+ ready_to_merge: ["merging", "needs_rebase", "stopped", "canceled"],
14050
14050
  merging: ["merged", "conflict", "stopped", "canceled"],
14051
14051
  merged: ["needs_rebase"],
14052
14052
  conflict: ["ready_to_merge", "stopped", "canceled"],
@@ -28495,6 +28495,57 @@ var init_integration = __esm(() => {
28495
28495
  // src/lib/merge-train.ts
28496
28496
  import { join as join9 } from "path";
28497
28497
  import { existsSync, lstatSync, rmSync } from "fs";
28498
+ async function validateTouchSet(jobBranch, baseBranch, touchSet, opts) {
28499
+ if (touchSet.length === 0) {
28500
+ return { valid: true };
28501
+ }
28502
+ const diffResult = await gitCommand(["diff", "--name-only", `${baseBranch}...${jobBranch}`], opts);
28503
+ if (diffResult.exitCode !== 0) {
28504
+ return { valid: false, violations: [`Failed to diff: ${diffResult.stderr}`] };
28505
+ }
28506
+ const changedFiles = diffResult.stdout.split(`
28507
+ `).map((f) => f.trim()).filter(Boolean);
28508
+ if (changedFiles.length === 0) {
28509
+ return { valid: true, changedFiles: [] };
28510
+ }
28511
+ const violations = [];
28512
+ for (const file3 of changedFiles) {
28513
+ const matchesAny = touchSet.some((pattern) => {
28514
+ const glob = new Bun.Glob(pattern);
28515
+ return glob.match(file3);
28516
+ });
28517
+ if (!matchesAny) {
28518
+ violations.push(file3);
28519
+ }
28520
+ }
28521
+ return {
28522
+ valid: violations.length === 0,
28523
+ violations: violations.length > 0 ? violations : undefined,
28524
+ changedFiles
28525
+ };
28526
+ }
28527
+ async function checkMergeability(integrationWorktree, jobBranch) {
28528
+ const testMerge = await gitCommand([
28529
+ "-C",
28530
+ integrationWorktree,
28531
+ "merge",
28532
+ "--no-commit",
28533
+ "--no-ff",
28534
+ jobBranch
28535
+ ]);
28536
+ await gitCommand(["-C", integrationWorktree, "merge", "--abort"]).catch(() => {});
28537
+ await gitCommand(["-C", integrationWorktree, "reset", "--hard", "HEAD"]).catch(() => {});
28538
+ await gitCommand(["-C", integrationWorktree, "clean", "-fd"]).catch(() => {});
28539
+ if (testMerge.exitCode !== 0) {
28540
+ const conflicts = extractConflicts([testMerge.stdout, testMerge.stderr].filter(Boolean).join(`
28541
+ `));
28542
+ return {
28543
+ canMerge: false,
28544
+ conflicts: conflicts.length > 0 ? conflicts : undefined
28545
+ };
28546
+ }
28547
+ return { canMerge: true };
28548
+ }
28498
28549
  async function rollbackMerge(worktreePath) {
28499
28550
  await gitCommand(["-C", worktreePath, "merge", "--abort"]).catch(() => {});
28500
28551
  await gitCommand(["-C", worktreePath, "reset", "--hard", "HEAD"]).catch(() => {});
@@ -29368,6 +29419,23 @@ class Orchestrator {
29368
29419
  }
29369
29420
  for (const job of mergeOrder) {
29370
29421
  if (job.status === "completed") {
29422
+ if (job.touchSet && job.touchSet.length > 0 && job.branch && plan.integrationBranch) {
29423
+ const validation = await validateTouchSet(job.branch, plan.integrationBranch, job.touchSet);
29424
+ if (!validation.valid && validation.violations) {
29425
+ await updatePlanJob(plan.id, job.name, {
29426
+ status: "failed",
29427
+ error: `Modified files outside touchSet: ${validation.violations.join(", ")}. Expected only: ${job.touchSet.join(", ")}`
29428
+ });
29429
+ job.status = "failed";
29430
+ this.showToast("Mission Control", `Job "${job.name}" touched files outside its touchSet. Plan paused.`, "error");
29431
+ this.notify(`\u274C Job "${job.name}" modified files outside its touchSet:
29432
+ Violations: ${validation.violations.join(", ")}
29433
+ Allowed: ${job.touchSet.join(", ")}
29434
+ Fix the branch and retry with mc_plan_approve(checkpoint: "on_error", retry: "${job.name}").`);
29435
+ await this.setCheckpoint("on_error", plan);
29436
+ return;
29437
+ }
29438
+ }
29371
29439
  await updatePlanJob(plan.id, job.name, { status: "ready_to_merge" });
29372
29440
  job.status = "ready_to_merge";
29373
29441
  }
@@ -29384,6 +29452,22 @@ class Orchestrator {
29384
29452
  if (!canMergeNow) {
29385
29453
  continue;
29386
29454
  }
29455
+ if (job.branch && plan.integrationWorktree) {
29456
+ const mergeCheck = await checkMergeability(plan.integrationWorktree, job.branch);
29457
+ if (!mergeCheck.canMerge) {
29458
+ await updatePlanJob(plan.id, job.name, {
29459
+ status: "needs_rebase",
29460
+ error: mergeCheck.conflicts?.join(", ") ?? "merge conflict detected in trial merge"
29461
+ });
29462
+ job.status = "needs_rebase";
29463
+ this.showToast("Mission Control", `Job "${job.name}" has merge conflicts. Plan paused.`, "error");
29464
+ this.notify(`\u274C Job "${job.name}" would conflict with the integration branch.
29465
+ Files: ${mergeCheck.conflicts?.join(", ") ?? "unknown"}
29466
+ Rebase the job branch and retry with mc_plan_approve(checkpoint: "on_error", retry: "${job.name}").`);
29467
+ await this.setCheckpoint("on_error", plan);
29468
+ return;
29469
+ }
29470
+ }
29387
29471
  if (this.isSupervisor(plan) && !this.approvedForMerge.has(job.name)) {
29388
29472
  await this.setCheckpoint("pre_merge", plan);
29389
29473
  return;
@@ -29422,13 +29506,10 @@ class Orchestrator {
29422
29506
  status: "conflict",
29423
29507
  error: mergeResult.files?.join(", ") ?? "merge conflict"
29424
29508
  });
29425
- if (this.isSupervisor(plan)) {
29426
- await this.setCheckpoint("on_error", plan);
29427
- return;
29428
- }
29429
- plan.status = "failed";
29430
- this.showToast("Mission Control", `Merge conflict in job "${nextJob.name}".`, "error");
29431
- this.notify(`\u274C Merge conflict in job "${nextJob.name}". Files: ${mergeResult.files?.join(", ") ?? "unknown"}. Plan failed.`);
29509
+ this.showToast("Mission Control", `Merge conflict in job "${nextJob.name}". Plan paused.`, "error");
29510
+ this.notify(`\u274C Merge conflict in job "${nextJob.name}". Files: ${mergeResult.files?.join(", ") ?? "unknown"}. Fix the branch and retry with mc_plan_approve(checkpoint: "on_error", retry: "${nextJob.name}").`);
29511
+ await this.setCheckpoint("on_error", plan);
29512
+ return;
29432
29513
  } else {
29433
29514
  await updatePlanJob(plan.id, nextJob.name, {
29434
29515
  status: "failed",
@@ -29438,13 +29519,10 @@ class Orchestrator {
29438
29519
  if (testSummary) {
29439
29520
  this.notify(`\uD83E\uDDEA ${nextJob.name}: ${testSummary}`);
29440
29521
  }
29441
- if (this.isSupervisor(plan)) {
29442
- await this.setCheckpoint("on_error", plan);
29443
- return;
29444
- }
29445
- plan.status = "failed";
29446
- this.showToast("Mission Control", `Job "${nextJob.name}" failed during merge.`, "error");
29447
- this.notify(`\u274C Job "${nextJob.name}" failed merge tests. Plan failed.`);
29522
+ this.showToast("Mission Control", `Job "${nextJob.name}" failed merge tests. Plan paused.`, "error");
29523
+ this.notify(`\u274C Job "${nextJob.name}" failed merge tests. Fix the branch and retry with mc_plan_approve(checkpoint: "on_error", retry: "${nextJob.name}").`);
29524
+ await this.setCheckpoint("on_error", plan);
29525
+ return;
29448
29526
  }
29449
29527
  }
29450
29528
  if (plan.status === "merging" && (!this.mergeTrain || this.mergeTrain.getQueue().length === 0)) {
@@ -29615,20 +29693,22 @@ IMPORTANT: When you have completed ALL of your work, you MUST commit your change
29615
29693
  }
29616
29694
  }
29617
29695
  handleJobComplete = (job) => {
29618
- if (job.planId && this.activePlanId && job.planId === this.activePlanId) {
29619
- if (!this.firstJobCompleted) {
29620
- this.firstJobCompleted = true;
29621
- this.showToast("Mission Control", `First job completed: "${job.name}".`, "success");
29622
- }
29623
- updatePlanJob(job.planId, job.name, {
29696
+ if (!job.planId || !this.activePlanId || job.planId !== this.activePlanId) {
29697
+ return;
29698
+ }
29699
+ if (!this.firstJobCompleted) {
29700
+ this.firstJobCompleted = true;
29701
+ this.showToast("Mission Control", `First job completed: "${job.name}".`, "success");
29702
+ }
29703
+ const planId = job.planId;
29704
+ (async () => {
29705
+ await updatePlanJob(planId, job.name, {
29624
29706
  status: "completed"
29625
- }).catch((error92) => {
29626
- console.error("Failed to update completed job state:", error92);
29627
- });
29628
- this.reconcile().catch((error92) => {
29629
- console.error("Reconcile after completion failed:", error92);
29630
29707
  });
29631
- }
29708
+ await this.reconcile();
29709
+ })().catch((error92) => {
29710
+ console.error("Failed to reconcile completed job state:", error92);
29711
+ });
29632
29712
  };
29633
29713
  handleJobFailed = (job) => {
29634
29714
  if (job.planId && this.activePlanId && job.planId === this.activePlanId) {
@@ -29640,15 +29720,9 @@ IMPORTANT: When you have completed ALL of your work, you MUST commit your change
29640
29720
  if (!plan || plan.id !== job.planId) {
29641
29721
  return;
29642
29722
  }
29643
- if (this.isSupervisor(plan)) {
29644
- await this.setCheckpoint("on_error", plan);
29645
- return;
29646
- }
29647
- plan.status = "failed";
29648
- plan.completedAt = new Date().toISOString();
29649
- await savePlan(plan);
29650
- this.showToast("Mission Control", `Plan failed: job "${job.name}" failed.`, "error");
29651
- this.notify(`\u274C Plan failed: job "${job.name}" failed.`);
29723
+ this.showToast("Mission Control", `Job "${job.name}" failed. Plan paused.`, "error");
29724
+ this.notify(`\u274C Job "${job.name}" failed. Fix and retry with mc_plan_approve(checkpoint: "on_error", retry: "${job.name}").`);
29725
+ await this.setCheckpoint("on_error", plan);
29652
29726
  }).catch(() => {}).finally(() => {
29653
29727
  if (!this.checkpoint) {
29654
29728
  this.stopReconciler();
@@ -29881,17 +29955,37 @@ var init_plan_approve = __esm(() => {
29881
29955
  init_integration();
29882
29956
  init_worktree_setup();
29883
29957
  mc_plan_approve = tool({
29884
- description: "Approve a pending copilot plan or clear a supervisor checkpoint to continue execution",
29958
+ description: "Approve a pending copilot plan, clear a supervisor checkpoint, or retry a failed job to continue execution",
29885
29959
  args: {
29886
- checkpoint: tool.schema.enum(["pre_merge", "on_error", "pre_pr"]).optional().describe("Specific checkpoint to clear (for supervisor mode)")
29960
+ checkpoint: tool.schema.enum(["pre_merge", "on_error", "pre_pr"]).optional().describe("Specific checkpoint to clear (for supervisor mode)"),
29961
+ retry: tool.schema.string().optional().describe("Name of a failed, conflict, or needs_rebase job to retry")
29887
29962
  },
29888
29963
  async execute(args) {
29889
29964
  const plan = await loadPlan();
29890
29965
  if (!plan) {
29891
29966
  throw new Error("No active plan to approve");
29892
29967
  }
29968
+ if (args.retry) {
29969
+ const job = plan.jobs.find((j) => j.name === args.retry);
29970
+ if (!job) {
29971
+ throw new Error(`Job "${args.retry}" not found in plan`);
29972
+ }
29973
+ if (job.status !== "failed" && job.status !== "conflict" && job.status !== "needs_rebase") {
29974
+ throw new Error(`Job "${args.retry}" is not in a retryable state (current: ${job.status}). Only failed, conflict, or needs_rebase jobs can be retried.`);
29975
+ }
29976
+ }
29893
29977
  if (plan.status === "paused" && plan.checkpoint) {
29894
29978
  const checkpoint = args.checkpoint ?? plan.checkpoint;
29979
+ if (args.retry) {
29980
+ const job = plan.jobs.find((j) => j.name === args.retry);
29981
+ if (!job) {
29982
+ throw new Error(`Job "${args.retry}" not found in plan`);
29983
+ }
29984
+ if (job.status !== "failed" && job.status !== "conflict" && job.status !== "needs_rebase") {
29985
+ throw new Error(`Job "${args.retry}" is not in a retryable state (current: ${job.status}). Only failed, conflict, or needs_rebase jobs can be retried.`);
29986
+ }
29987
+ await updatePlanJob(plan.id, args.retry, { status: "ready_to_merge", error: undefined });
29988
+ }
29895
29989
  plan.status = "running";
29896
29990
  plan.checkpoint = null;
29897
29991
  await savePlan(plan);
@@ -29900,8 +29994,9 @@ var init_plan_approve = __esm(() => {
29900
29994
  setSharedOrchestrator(orchestrator2);
29901
29995
  orchestrator2.setPlanModelSnapshot(getCurrentModel());
29902
29996
  await orchestrator2.resumePlan();
29997
+ const retryMsg = args.retry ? ` Job "${args.retry}" reset to ready_to_merge.` : "";
29903
29998
  return [
29904
- `Checkpoint "${checkpoint}" cleared. Plan "${plan.name}" resuming.`,
29999
+ `Checkpoint "${checkpoint}" cleared.${retryMsg} Plan "${plan.name}" resuming.`,
29905
30000
  "",
29906
30001
  ` ID: ${plan.id}`,
29907
30002
  ` Mode: ${plan.mode}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-mission-control",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "OpenCode plugin for parallel AI coding sessions in isolated git worktrees",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",