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