runcastle 1.0.5 → 1.0.7

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.
package/bin/runcastle.js CHANGED
@@ -62,6 +62,9 @@ var init_paths = () => {};
62
62
 
63
63
  // ../core/src/config.ts
64
64
  import * as z from "zod";
65
+ function resolveSandboxImage(config) {
66
+ return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
67
+ }
65
68
  function resolveModel(step, config, project, runOverride) {
66
69
  return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
67
70
  }
@@ -77,13 +80,14 @@ var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5-20251001", f
77
80
  }
78
81
  delete obj.smokeModel;
79
82
  return obj;
80
- }, RuncastleConfig;
83
+ }, RuncastleConfig, DEFAULT_SANDBOX_IMAGE = "sandcastle:runcastle";
81
84
  var init_config = __esm(() => {
82
85
  MODEL_STEPS = [
83
86
  "ideation",
84
87
  "qa",
85
88
  "waypoint",
86
89
  "converge",
90
+ "revisit",
87
91
  "research",
88
92
  "implement",
89
93
  "smoke"
@@ -95,7 +99,8 @@ var init_config = __esm(() => {
95
99
  stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
96
100
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
97
101
  mainBranch: z.string().default("main"),
98
- sandboxImage: z.string().optional()
102
+ sandboxImage: z.string().optional(),
103
+ burnConcurrency: z.number().int().min(1).max(8).default(3)
99
104
  }));
100
105
  });
101
106
 
@@ -128,6 +133,9 @@ function loadConfig(env = process.env) {
128
133
  overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
129
134
  if (env.RUNCASTLE_SANDBOX_IMAGE)
130
135
  overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
136
+ if (env.RUNCASTLE_BURN_CONCURRENCY) {
137
+ overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
138
+ }
131
139
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
132
140
  return RuncastleConfig.parse({ ...base, ...overrides });
133
141
  }
@@ -171,8 +179,8 @@ var init_schemas = __esm(() => {
171
179
  "shipped"
172
180
  ]);
173
181
  FeatureSize = z2.enum(["full", "collapsed"]);
174
- TicketStatus = z2.enum(["pending", "burning", "done", "failed"]);
175
- SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge"]);
182
+ TicketStatus = z2.enum(["pending", "burning", "done", "failed", "cancelled"]);
183
+ SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit"]);
176
184
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
177
185
  FeatureStatus = z2.enum(["active", "shipped"]);
178
186
  SessionStatus = z2.enum(["launching", "live", "ended"]);
@@ -1456,7 +1464,7 @@ var init_waypoints = __esm(() => {
1456
1464
  });
1457
1465
 
1458
1466
  // src/launcher/sessions.ts
1459
- import { and as and4, desc as desc2, eq as eq4, inArray, sql as sql2 } from "drizzle-orm";
1467
+ import { and as and4, desc as desc2, eq as eq4, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
1460
1468
  function createSessionRow(ctx, input) {
1461
1469
  const row = ctx.db.insert(sessions).values({
1462
1470
  id: newId("sess"),
@@ -1535,6 +1543,10 @@ function markSessionEnded(ctx, id) {
1535
1543
  ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
1536
1544
  return getSessionRow(ctx, id);
1537
1545
  }
1546
+ function mostRecentResumableSession(ctx, featureId) {
1547
+ const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc2(sql2`rowid`)).limit(1).get();
1548
+ return row ? rowToSession(row) : null;
1549
+ }
1538
1550
  function mostRecentLiveSession(ctx) {
1539
1551
  const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1540
1552
  return row ? rowToSession(row) : null;
@@ -1675,6 +1687,55 @@ function storeTickets(ctx, featureId, inputs) {
1675
1687
  });
1676
1688
  return rows.map(rowToTicket);
1677
1689
  }
1690
+ function getTicket(ctx, id) {
1691
+ const row = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1692
+ if (!row)
1693
+ throw new NotFoundError(`ticket ${id} not found`);
1694
+ return rowToTicket(row);
1695
+ }
1696
+ function assertMutable(t, verb) {
1697
+ if (!MUTABLE_STATUSES.has(t.status)) {
1698
+ throw new InvalidInputError(`cannot ${verb} ticket ${t.seq} \u2014 it is ${t.status}; only pending or failed tickets can be ${verb}${verb.endsWith("l") ? "led" : "ed"}`);
1699
+ }
1700
+ }
1701
+ function editTicket(ctx, id, patch) {
1702
+ const current2 = getTicket(ctx, id);
1703
+ assertMutable(current2, "edit");
1704
+ const set = {};
1705
+ if (patch.title !== undefined)
1706
+ set.title = patch.title;
1707
+ if (patch.goal !== undefined)
1708
+ set.goal = patch.goal;
1709
+ if (patch.context !== undefined)
1710
+ set.context = patch.context;
1711
+ if (patch.acceptanceCriteria !== undefined)
1712
+ set.acceptanceCriteria = patch.acceptanceCriteria;
1713
+ if (patch.seams !== undefined)
1714
+ set.seams = patch.seams;
1715
+ if (Object.keys(set).length === 0) {
1716
+ throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
1717
+ }
1718
+ ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1719
+ emit(ctx, current2.featureId, {
1720
+ type: "ticket.edited",
1721
+ message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
1722
+ ticketId: id,
1723
+ data: { fields: Object.keys(set) }
1724
+ });
1725
+ return getTicket(ctx, id);
1726
+ }
1727
+ function cancelTicket(ctx, id, reason) {
1728
+ const current2 = getTicket(ctx, id);
1729
+ assertMutable(current2, "cancel");
1730
+ ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq5(tickets.id, id)).run();
1731
+ emit(ctx, current2.featureId, {
1732
+ type: "ticket.cancelled",
1733
+ message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
1734
+ ticketId: id,
1735
+ data: { reason: reason ?? null }
1736
+ });
1737
+ return getTicket(ctx, id);
1738
+ }
1678
1739
  function updateTicket(ctx, id, patch) {
1679
1740
  const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1680
1741
  if (!current2)
@@ -1696,11 +1757,13 @@ function updateTicket(ctx, id, patch) {
1696
1757
  const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1697
1758
  return rowToTicket(updated);
1698
1759
  }
1760
+ var MUTABLE_STATUSES;
1699
1761
  var init_tickets = __esm(() => {
1700
1762
  init_src();
1701
1763
  init_schema();
1702
1764
  init_errors();
1703
1765
  init_events();
1766
+ MUTABLE_STATUSES = new Set(["pending", "failed"]);
1704
1767
  });
1705
1768
 
1706
1769
  // src/services/gates.ts
@@ -1725,14 +1788,14 @@ function checkGate(ctx, check, feature) {
1725
1788
  return { satisfied: true };
1726
1789
  return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
1727
1790
  case "tickets-approved": {
1728
- const count = listByFeature2(ctx, feature.id).length;
1791
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
1729
1792
  return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
1730
1793
  }
1731
1794
  case "all-tickets-terminal": {
1732
1795
  const tickets2 = listByFeature2(ctx, feature.id);
1733
1796
  if (tickets2.length === 0)
1734
1797
  return { satisfied: false, reason: "no tickets have run yet" };
1735
- const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed");
1798
+ const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
1736
1799
  return open.length === 0 ? { satisfied: true } : {
1737
1800
  satisfied: false,
1738
1801
  reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
@@ -2056,7 +2119,10 @@ async function reattachWorktree(path, branch) {
2056
2119
  function researchBranchName(slug, waypointSeq, unique) {
2057
2120
  return `${RESEARCH_BRANCH_PREFIX}${slug}/${waypointSeq}-${unique}`;
2058
2121
  }
2059
- async function mergeResearchBranch(repoPath, featureBranchName, tempBranch) {
2122
+ function ticketBranchName(slug, ticketSeq, unique) {
2123
+ return `${TICKET_BRANCH_PREFIX}${slug}/${ticketSeq}-${unique}`;
2124
+ }
2125
+ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2060
2126
  const g = git(repoPath);
2061
2127
  let branches;
2062
2128
  try {
@@ -2105,7 +2171,10 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
2105
2171
  return false;
2106
2172
  }
2107
2173
  }
2108
- async function cleanupResearchBranches(repoPath) {
2174
+ function tempBranchPrefix(name) {
2175
+ return TEMP_BRANCH_PREFIXES.find((p) => name.startsWith(p));
2176
+ }
2177
+ async function cleanupTempBranches(repoPath) {
2109
2178
  const deleted = [];
2110
2179
  const kept = [];
2111
2180
  const g = git(repoPath);
@@ -2116,9 +2185,10 @@ async function cleanupResearchBranches(repoPath) {
2116
2185
  return { deleted, kept };
2117
2186
  }
2118
2187
  for (const name of all) {
2119
- if (!name.startsWith(RESEARCH_BRANCH_PREFIX))
2188
+ const prefix = tempBranchPrefix(name);
2189
+ if (!prefix)
2120
2190
  continue;
2121
- const slug = name.slice(RESEARCH_BRANCH_PREFIX.length).split("/")[0];
2191
+ const slug = name.slice(prefix.length).split("/")[0];
2122
2192
  const target = featureBranch(slug);
2123
2193
  let merged = false;
2124
2194
  if (slug && all.includes(target)) {
@@ -2275,13 +2345,14 @@ async function mergeInProgress(g) {
2275
2345
  return false;
2276
2346
  }
2277
2347
  }
2278
- var DOCS_PATHSPEC = "docs/features", DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", testDriveState;
2348
+ var DOCS_PATHSPEC = "docs/features", DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", TEMP_BRANCH_PREFIXES, testDriveState;
2279
2349
  var init_git = __esm(() => {
2280
2350
  init_paths();
2281
2351
  init_errors();
2282
2352
  init_dev_pane();
2283
2353
  init_events();
2284
2354
  init_repo();
2355
+ TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
2285
2356
  });
2286
2357
 
2287
2358
  // src/services/knowledge.ts
@@ -2572,16 +2643,28 @@ function isMergeConflictError(err) {
2572
2643
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
2573
2644
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
2574
2645
  }
2646
+ function createSerialQueue() {
2647
+ let tail = Promise.resolve();
2648
+ return (task) => {
2649
+ const next = tail.then(task);
2650
+ tail = next.catch(() => {
2651
+ return;
2652
+ });
2653
+ return next;
2654
+ };
2655
+ }
2575
2656
  function firstLine(s) {
2576
2657
  const i = s.indexOf(`
2577
2658
  `);
2578
2659
  return (i === -1 ? s : s.slice(0, i)).trim();
2579
2660
  }
2580
- async function burnTickets(ctx, tickets2, execute) {
2661
+ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
2662
+ const width = Math.max(1, Math.floor(concurrency));
2581
2663
  const bySeq = indexBySeq(tickets2);
2582
2664
  const status = new Map(tickets2.map((t) => [t.seq, t.status]));
2583
2665
  const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
2584
2666
  const inFlight = new Map;
2667
+ const satisfied = (s) => s === "done" || s === "cancelled";
2585
2668
  const readyState = (seq) => {
2586
2669
  const t = bySeq.get(seq);
2587
2670
  if (!t)
@@ -2592,7 +2675,7 @@ async function burnTickets(ctx, tickets2, execute) {
2592
2675
  if (!present || bs === "failed")
2593
2676
  return { blockedBy: b, present };
2594
2677
  }
2595
- return t.blockedBy.every((b) => status.get(b) === "done") ? "ready" : "wait";
2678
+ return t.blockedBy.every((b) => satisfied(status.get(b))) ? "ready" : "wait";
2596
2679
  };
2597
2680
  const failTicket = (seq, error, extra) => {
2598
2681
  const t = bySeq.get(seq);
@@ -2653,7 +2736,7 @@ async function burnTickets(ctx, tickets2, execute) {
2653
2736
  }
2654
2737
  }
2655
2738
  }
2656
- while (inFlight.size < CONCURRENCY) {
2739
+ while (inFlight.size < width) {
2657
2740
  const readySeq = [...pending].find((seq) => readyState(seq) === "ready");
2658
2741
  if (readySeq === undefined)
2659
2742
  break;
@@ -2664,7 +2747,12 @@ async function burnTickets(ctx, tickets2, execute) {
2664
2747
  inFlight.set(readySeq, p);
2665
2748
  }
2666
2749
  if (inFlight.size > 0) {
2667
- await Promise.race(inFlight.values());
2750
+ try {
2751
+ await Promise.race(inFlight.values());
2752
+ } catch (err) {
2753
+ await Promise.allSettled([...inFlight.values()]);
2754
+ throw err;
2755
+ }
2668
2756
  } else if (pending.size > 0) {
2669
2757
  for (const seq of [...pending]) {
2670
2758
  pending.delete(seq);
@@ -2688,12 +2776,14 @@ async function burnTickets(ctx, tickets2, execute) {
2688
2776
  }
2689
2777
  async function burnRun(ctx, deps) {
2690
2778
  const tickets2 = ctx.tickets;
2691
- const total = tickets2.length;
2779
+ const burnable = tickets2.filter((t) => t.status !== "cancelled");
2780
+ const cancelled = tickets2.length - burnable.length;
2781
+ const total = burnable.length;
2692
2782
  if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
2693
2783
  ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: AUTH_MISSING_MESSAGE });
2694
2784
  return { status: "failed", summary: "burn aborted: auth token missing" };
2695
2785
  }
2696
- const cycle = detectCycle(tickets2);
2786
+ const cycle = detectCycle(burnable);
2697
2787
  if (cycle) {
2698
2788
  const path = cycle.join(" \u2192 ");
2699
2789
  ctx.emitEvent({
@@ -2703,9 +2793,9 @@ async function burnRun(ctx, deps) {
2703
2793
  });
2704
2794
  return { status: "failed", summary: `dependency cycle: ${path}` };
2705
2795
  }
2706
- const done = await burnTickets(ctx, tickets2, deps.executeTicketRun);
2707
- const summary = `${done}/${total} tickets done`;
2708
- ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total } });
2796
+ const done = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
2797
+ const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
2798
+ ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
2709
2799
  return { status: done === total ? "succeeded" : "failed", summary };
2710
2800
  }
2711
2801
  function burnerTemplatePath() {
@@ -2756,7 +2846,7 @@ function buildBurnAgent(config2, token, model) {
2756
2846
  return agent;
2757
2847
  }
2758
2848
  function selectSandbox(config2) {
2759
- const imageOpts = config2.sandboxImage ? { imageName: config2.sandboxImage } : {};
2849
+ const imageOpts = { imageName: resolveSandboxImage(config2) };
2760
2850
  switch (config2.sandbox) {
2761
2851
  case "docker":
2762
2852
  return docker(imageOpts);
@@ -2766,8 +2856,9 @@ function selectSandbox(config2) {
2766
2856
  return noSandbox();
2767
2857
  }
2768
2858
  }
2769
- async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
2859
+ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2770
2860
  const { project, feature } = ctx;
2861
+ const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
2771
2862
  const template = readFileSync4(burnerTemplatePath(), "utf8");
2772
2863
  const prompt = renderTicketPrompt(template, {
2773
2864
  TICKET_JSON: buildTicketJson(ticket),
@@ -2783,7 +2874,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
2783
2874
  sandbox: selectSandbox(config2),
2784
2875
  cwd: project.repoPath,
2785
2876
  prompt,
2786
- branchStrategy: { type: "branch", branch: feature.branch, baseBranch: project.mainBranch },
2877
+ branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
2787
2878
  signal: ctx.signal,
2788
2879
  name: `ticket-${ticket.seq}`,
2789
2880
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
@@ -2810,16 +2901,39 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
2810
2901
  }
2811
2902
  throttle.flush();
2812
2903
  const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
2813
- return interpretRunResult(result, blocked);
2904
+ const outcome = interpretRunResult(result, blocked);
2905
+ if (outcome.status !== "done")
2906
+ return outcome;
2907
+ const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
2908
+ if (!merge.ok) {
2909
+ const detail = merge.error ?? "merge failed";
2910
+ if (merge.conflict) {
2911
+ return {
2912
+ status: "failed",
2913
+ error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
2914
+ event: {
2915
+ type: "merge.conflict.needs-human",
2916
+ message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
2917
+ }
2918
+ };
2919
+ }
2920
+ return {
2921
+ status: "failed",
2922
+ error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${firstLine(detail)} \u2014 the branch is preserved for manual recovery`
2923
+ };
2924
+ }
2925
+ return outcome;
2814
2926
  }
2815
2927
  function resolveBurnDeps(ctx) {
2816
2928
  const config2 = loadConfig();
2817
2929
  const token = readTokenFromEnvFile(envPath());
2818
2930
  const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
2931
+ const land = createSerialQueue();
2819
2932
  return {
2820
2933
  config: config2,
2821
2934
  hasAuthToken: token !== undefined,
2822
- executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model)
2935
+ concurrency: config2.burnConcurrency,
2936
+ executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land)
2823
2937
  };
2824
2938
  }
2825
2939
  function readTokenFromEnvFile(path) {
@@ -2834,12 +2948,13 @@ function readTokenFromEnvFile(path) {
2834
2948
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
2835
2949
  return token && token.length > 0 ? token : undefined;
2836
2950
  }
2837
- var CONCURRENCY = 1, AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
2951
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
2838
2952
  var init_ticket_burner = __esm(() => {
2839
2953
  init_src();
2840
2954
  init_config_load();
2841
2955
  init_paths();
2842
2956
  init_skills_root();
2957
+ init_git();
2843
2958
  PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
2844
2959
  ticketBurner = {
2845
2960
  id: "ticket-burner",
@@ -2948,7 +3063,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
2948
3063
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
2949
3064
  const runOptions = {
2950
3065
  agent: buildBurnAgent(config2, token, model),
2951
- sandbox: config2.sandbox === "docker" ? docker2(config2.sandboxImage ? { imageName: config2.sandboxImage } : {}) : noSandbox2(),
3066
+ sandbox: config2.sandbox === "docker" ? docker2({ imageName: resolveSandboxImage(config2) }) : noSandbox2(),
2952
3067
  cwd: project.repoPath,
2953
3068
  prompt,
2954
3069
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
@@ -2969,7 +3084,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
2969
3084
  const outcome = interpretResearchResult(result, docRelPath);
2970
3085
  if (outcome.status !== "done")
2971
3086
  return outcome;
2972
- const merge = await mergeResearchBranch(project.repoPath, feature.branch, tempBranch);
3087
+ const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
2973
3088
  if (!merge.ok) {
2974
3089
  const detail = merge.error ?? "merge failed";
2975
3090
  ctx.emitEvent({
@@ -3247,7 +3362,8 @@ function list(ctx, projectId) {
3247
3362
  pending: tickets2.filter((t) => t.status === "pending").length,
3248
3363
  burning: tickets2.filter((t) => t.status === "burning").length,
3249
3364
  done: tickets2.filter((t) => t.status === "done").length,
3250
- failed: tickets2.filter((t) => t.status === "failed").length
3365
+ failed: tickets2.filter((t) => t.status === "failed").length,
3366
+ cancelled: tickets2.filter((t) => t.status === "cancelled").length
3251
3367
  };
3252
3368
  return { ...feature, ticketCounts: counts, activeRun: hasActiveRun(ctx, feature.id) };
3253
3369
  });
@@ -3276,13 +3392,19 @@ async function burn(ctx, featureId, opts = {}) {
3276
3392
  const why = feature.phase === "implementation" ? "a run is already burning this feature" : `feature must be in the tickets phase to burn (currently ${feature.phase})`;
3277
3393
  throw new GateError(why);
3278
3394
  }
3279
- if (listByFeature2(ctx, featureId).length < 1) {
3280
- throw new GateError("no tickets to burn");
3395
+ const tickets2 = listByFeature2(ctx, featureId);
3396
+ if (tickets2.filter((t) => t.status !== "cancelled").length < 1) {
3397
+ throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
3281
3398
  }
3282
3399
  if (restarting) {
3400
+ const failed = tickets2.filter((t) => t.status === "failed");
3401
+ for (const t of failed) {
3402
+ updateTicket(ctx, t.id, { status: "pending", error: null });
3403
+ }
3283
3404
  emit(ctx, featureId, {
3284
3405
  type: "burn.restarted",
3285
- message: "restarting burn (previous run cancelled or crashed)"
3406
+ message: failed.length > 0 ? `restarting burn \u2014 retrying ${failed.length} failed ticket(s)` : "restarting burn (previous run cancelled or crashed)",
3407
+ data: { retried: failed.map((t) => t.seq) }
3286
3408
  });
3287
3409
  } else {
3288
3410
  setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
@@ -3394,6 +3516,22 @@ function toolEmitTickets(ctx, session, input) {
3394
3516
  const stored = storeTickets(ctx, feature.id, input.tickets);
3395
3517
  return { stored: stored.length, ids: stored.map((t) => t.id) };
3396
3518
  }
3519
+ function requireOwnTicket(ctx, session, ticketId) {
3520
+ const ticket = getTicket(ctx, ticketId);
3521
+ if (ticket.featureId !== session.featureId) {
3522
+ throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
3523
+ }
3524
+ return ticket;
3525
+ }
3526
+ function toolUpdateTicket(ctx, session, input) {
3527
+ requireOwnTicket(ctx, session, input.id);
3528
+ const { id, ...patch } = input;
3529
+ return { ok: true, ticket: editTicket(ctx, id, patch) };
3530
+ }
3531
+ function toolCancelTicket(ctx, session, input) {
3532
+ requireOwnTicket(ctx, session, input.id);
3533
+ return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
3534
+ }
3397
3535
  function toolEscalateToMap(ctx, session, input) {
3398
3536
  return escalateToMap(ctx, session.featureId, input);
3399
3537
  }
@@ -3493,6 +3631,37 @@ function buildMcpServer() {
3493
3631
  await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: tickets emitted (${result.stored})`);
3494
3632
  return ok(result);
3495
3633
  });
3634
+ server.registerTool("update_ticket", {
3635
+ title: "Update ticket",
3636
+ description: "Rewrite a stored ticket's content \u2014 title, goal, context, acceptanceCriteria, seams (any subset). Only pending or failed tickets can be edited; done/cancelled tickets are history and burning tickets are already running. Use during a revisit when a decision change makes a ticket stale. Get ids from get_feature_context.",
3637
+ inputSchema: {
3638
+ id: z3.string(),
3639
+ title: z3.string().optional(),
3640
+ goal: z3.string().optional(),
3641
+ context: z3.string().optional(),
3642
+ acceptanceCriteria: z3.array(z3.string()).optional(),
3643
+ seams: z3.array(z3.string()).optional()
3644
+ }
3645
+ }, async (args, extra) => {
3646
+ const rs = await resolveCtxSession(extra);
3647
+ if (!rs)
3648
+ return noSession();
3649
+ const result = toolUpdateTicket(rs.ctx, rs.session, args);
3650
+ await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} updated`);
3651
+ return ok(result);
3652
+ });
3653
+ server.registerTool("cancel_ticket", {
3654
+ title: "Cancel ticket",
3655
+ description: "Cancel a ticket that is no longer needed (terminal state; only pending or failed tickets). The burner skips cancelled tickets, and tickets blocked by a cancelled one still burn \u2014 cancellation counts as satisfied. Pass a short reason so the timeline explains why.",
3656
+ inputSchema: { id: z3.string(), reason: z3.string().optional() }
3657
+ }, async (args, extra) => {
3658
+ const rs = await resolveCtxSession(extra);
3659
+ if (!rs)
3660
+ return noSession();
3661
+ const result = toolCancelTicket(rs.ctx, rs.session, args);
3662
+ await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} cancelled`);
3663
+ return ok(result);
3664
+ });
3496
3665
  server.registerTool("escalate_to_map", {
3497
3666
  title: "Escalate to map",
3498
3667
  description: "Escalate this grilling session into a map when the feature outgrows one context window. Flips the feature to mapped and scaffolds docs/features/<slug>/map.md, seeding Destination + Notes from your arguments (Not-yet-specified and Out-of-scope start empty). Idempotent: calling it on an already-mapped feature warns and changes nothing.",
@@ -3890,6 +4059,8 @@ function renderSystemPrompt(feature, kind, waypoint) {
3890
4059
  return renderWaypointPrompt(feature, waypoint);
3891
4060
  if (kind === "converge")
3892
4061
  return renderConvergePrompt(feature);
4062
+ if (kind === "revisit")
4063
+ return renderRevisitPrompt(feature);
3893
4064
  const docs = featureDocsRel(feature.slug);
3894
4065
  const entry = kind === "ideation" ? "Begin by invoking the `/runcastle:ideate` skill and drive the ideation session to completion." : "This is a Q&A session: invoke `/runcastle:qa`. Answer questions from the docs + code. Do NOT advance phases or emit tickets.";
3895
4066
  return [
@@ -4015,6 +4186,49 @@ function renderConvergePrompt(feature) {
4015
4186
  ].join(`
4016
4187
  `);
4017
4188
  }
4189
+ function renderRevisitPrompt(feature) {
4190
+ const docs = featureDocsRel(feature.slug);
4191
+ return [
4192
+ `# runcastle \u2014 ${feature.title} (revisit session)`,
4193
+ "",
4194
+ feature.oneLiner,
4195
+ "",
4196
+ "This is a **revisit session**: the human came back to a feature whose",
4197
+ "earlier sessions are finished \u2014 they remembered something, or a decision",
4198
+ "changed. Where possible this terminal RESUMES the previous conversation, so",
4199
+ "you may already have its context above. Your job: fold the new information",
4200
+ "into the record, then reconcile the tickets with it. The docs are the",
4201
+ "artifact \u2014 later phases read them, never the transcripts.",
4202
+ "",
4203
+ "## Feature",
4204
+ `- Slug: \`${feature.slug}\``,
4205
+ `- Branch: \`${feature.branch}\``,
4206
+ `- Current phase: **${feature.phase}** (size: ${feature.size})`,
4207
+ "",
4208
+ "## Knowledge (versioned in the target repo)",
4209
+ `Feature docs live at \`${docs}/\`:`,
4210
+ `- \`${docs}/decisions.md\` \u2014 append the new/changed decisions with a dated "revisited" note.`,
4211
+ `- \`${docs}/spec.md\` \u2014 if it exists, amend the affected sections in place.`,
4212
+ `- \`${docs}/map.md\` \u2014 if the feature is mapped, keep the map honest too.`,
4213
+ "",
4214
+ "## runcastle MCP tools",
4215
+ "- `get_feature_context()` \u2014 feature + phase + docs + ALL tickets (with ids and statuses).",
4216
+ "- `update_ticket({ id, ...fields })` \u2014 rewrite a stale pending/failed ticket's content.",
4217
+ "- `cancel_ticket({ id, reason })` \u2014 cancel a pending/failed ticket that is now unnecessary.",
4218
+ "- `emit_tickets({ tickets })` \u2014 add tickets for NEW work the change requires.",
4219
+ "- `record_event({ type, message })` \u2014 drop a timeline note summarising the revisit.",
4220
+ "",
4221
+ "## Rules",
4222
+ "- Do NOT call `complete_phase` \u2014 a revisit never moves the pipeline.",
4223
+ "- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
4224
+ "- Docs first, tickets second: capture the decision prose before any ticket surgery.",
4225
+ "",
4226
+ "## Your task",
4227
+ "Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
4228
+ ""
4229
+ ].join(`
4230
+ `);
4231
+ }
4018
4232
  function renderSettings(hookClient) {
4019
4233
  const cmd = (event) => ({
4020
4234
  type: "command",
@@ -4060,6 +4274,8 @@ var init_artifacts = __esm(() => {
4060
4274
  RUNCASTLE_MCP_ALLOW_RULES = [
4061
4275
  "mcp__runcastle__get_feature_context",
4062
4276
  "mcp__runcastle__emit_tickets",
4277
+ "mcp__runcastle__update_ticket",
4278
+ "mcp__runcastle__cancel_ticket",
4063
4279
  "mcp__runcastle__record_event",
4064
4280
  "mcp__runcastle__complete_phase",
4065
4281
  "mcp__runcastle__escalate_to_map",
@@ -4226,11 +4442,32 @@ async function launchSession(ctx, input, opts = {}) {
4226
4442
  throw e;
4227
4443
  }
4228
4444
  }
4445
+ if (input.kind === "revisit") {
4446
+ try {
4447
+ assertSpawnable(ctx, feature, session.id);
4448
+ } catch (e) {
4449
+ markSessionEnded(ctx, session.id);
4450
+ throw e;
4451
+ }
4452
+ const prior = mostRecentResumableSession(ctx, feature.id);
4453
+ if (prior?.ccSessionId) {
4454
+ resumeSessionId = prior.ccSessionId;
4455
+ } else {
4456
+ resumeUnavailableFrom = "revisit";
4457
+ }
4458
+ }
4229
4459
  emit(ctx, feature.id, {
4230
4460
  type: "session.launching",
4231
4461
  message: `launching ${input.kind} session`,
4232
4462
  data: { sessionId: session.id, kind: input.kind, worktreePath, waypointId: waypoint?.id }
4233
4463
  });
4464
+ if (input.kind === "revisit" && resumeUnavailableFrom) {
4465
+ emit(ctx, feature.id, {
4466
+ type: "session.resume_unavailable",
4467
+ message: "no resumable conversation for this feature \u2014 revisiting fresh from the docs",
4468
+ data: { sessionId: session.id }
4469
+ });
4470
+ }
4234
4471
  if (waypoint && resumeUnavailableFrom) {
4235
4472
  emit(ctx, feature.id, {
4236
4473
  type: "session.resume_unavailable",
@@ -4828,6 +5065,14 @@ var init_settings = __esm(() => {
4828
5065
  valueSchema: z9.string().min(1),
4829
5066
  parseEnv: idEnv
4830
5067
  },
5068
+ {
5069
+ key: "burnConcurrency",
5070
+ configKey: "burnConcurrency",
5071
+ envVar: "RUNCASTLE_BURN_CONCURRENCY",
5072
+ restartRequired: false,
5073
+ valueSchema: z9.number().int().min(1).max(8),
5074
+ parseEnv: (raw) => Number(raw)
5075
+ },
4831
5076
  {
4832
5077
  key: "mainBranch",
4833
5078
  configKey: "mainBranch",
@@ -4996,7 +5241,7 @@ function afkTokenProbe(env) {
4996
5241
  async function runDoctor(env) {
4997
5242
  const { exec } = env;
4998
5243
  const processEnv = env.env ?? process.env;
4999
- const imageName = env.imageName ?? "sandcastle:runcastle";
5244
+ const imageName = env.imageName ?? DEFAULT_SANDBOX_IMAGE;
5000
5245
  const results = [
5001
5246
  await bunProbe(exec),
5002
5247
  await nodeProbe(exec),
@@ -5018,6 +5263,9 @@ function exitCodeFor(report, mode) {
5018
5263
  return report.tier1Ok ? 0 : 1;
5019
5264
  return report.ok ? 0 : 1;
5020
5265
  }
5266
+ var init_doctor = __esm(() => {
5267
+ init_src();
5268
+ });
5021
5269
 
5022
5270
  // src/doctor/system-exec.ts
5023
5271
  import { spawn as spawn2 } from "child_process";
@@ -5201,6 +5449,7 @@ function fileAfkTokenIo(exec) {
5201
5449
  var AFK_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
5202
5450
  var init_setup = __esm(() => {
5203
5451
  init_paths();
5452
+ init_doctor();
5204
5453
  init_errors();
5205
5454
  init_asset_paths();
5206
5455
  });
@@ -5210,6 +5459,7 @@ import * as z11 from "zod";
5210
5459
  var setupRouter;
5211
5460
  var init_setup2 = __esm(() => {
5212
5461
  init_src();
5462
+ init_doctor();
5213
5463
  init_system_exec();
5214
5464
  init_registry();
5215
5465
  init_setup();
@@ -5227,7 +5477,7 @@ var init_setup2 = __esm(() => {
5227
5477
  const exec = createSystemExec();
5228
5478
  const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
5229
5479
  const runtime = await resolveRuntime(exec, preferred);
5230
- const imageName = ctx.config.sandboxImage ?? "sandcastle:runcastle";
5480
+ const imageName = resolveSandboxImage(ctx.config);
5231
5481
  const sandcastleBin = input.kind === "build-image" ? resolveSandcastleBin() ?? undefined : undefined;
5232
5482
  const spec = terminalSpec(input.kind, { runtime, imageName, sandcastleBin });
5233
5483
  const sessionId = newId("setup");
@@ -5417,7 +5667,7 @@ async function reconcileStaleRuns(ctx) {
5417
5667
  }
5418
5668
  for (const project of allProjects(ctx)) {
5419
5669
  try {
5420
- await cleanupResearchBranches(project.repoPath);
5670
+ await cleanupTempBranches(project.repoPath);
5421
5671
  } catch {}
5422
5672
  }
5423
5673
  return reconciled;
@@ -5585,7 +5835,7 @@ function envWithToken() {
5585
5835
  function resolveDoctorEnv() {
5586
5836
  let imageName;
5587
5837
  try {
5588
- imageName = loadConfig().sandboxImage;
5838
+ imageName = resolveSandboxImage(loadConfig());
5589
5839
  } catch {
5590
5840
  imageName = undefined;
5591
5841
  }
@@ -5604,9 +5854,11 @@ async function runCli(argv, log = console.log) {
5604
5854
  return exitCodeFor(report, mode);
5605
5855
  }
5606
5856
  var init_cli = __esm(() => {
5857
+ init_src();
5607
5858
  init_paths();
5608
5859
  init_config_load();
5609
5860
  init_ticket_burner();
5861
+ init_doctor();
5610
5862
  init_report();
5611
5863
  init_system_exec();
5612
5864
  if (false) {}