runcastle 1.0.6 → 1.0.8

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
@@ -87,6 +87,7 @@ var init_config = __esm(() => {
87
87
  "qa",
88
88
  "waypoint",
89
89
  "converge",
90
+ "revisit",
90
91
  "research",
91
92
  "implement",
92
93
  "smoke"
@@ -98,7 +99,8 @@ var init_config = __esm(() => {
98
99
  stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
99
100
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
100
101
  mainBranch: z.string().default("main"),
101
- sandboxImage: z.string().optional()
102
+ sandboxImage: z.string().optional(),
103
+ burnConcurrency: z.number().int().min(1).max(8).default(3)
102
104
  }));
103
105
  });
104
106
 
@@ -131,6 +133,9 @@ function loadConfig(env = process.env) {
131
133
  overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
132
134
  if (env.RUNCASTLE_SANDBOX_IMAGE)
133
135
  overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
136
+ if (env.RUNCASTLE_BURN_CONCURRENCY) {
137
+ overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
138
+ }
134
139
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
135
140
  return RuncastleConfig.parse({ ...base, ...overrides });
136
141
  }
@@ -174,8 +179,8 @@ var init_schemas = __esm(() => {
174
179
  "shipped"
175
180
  ]);
176
181
  FeatureSize = z2.enum(["full", "collapsed"]);
177
- TicketStatus = z2.enum(["pending", "burning", "done", "failed"]);
178
- 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"]);
179
184
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
180
185
  FeatureStatus = z2.enum(["active", "shipped"]);
181
186
  SessionStatus = z2.enum(["launching", "live", "ended"]);
@@ -1459,7 +1464,7 @@ var init_waypoints = __esm(() => {
1459
1464
  });
1460
1465
 
1461
1466
  // src/launcher/sessions.ts
1462
- 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";
1463
1468
  function createSessionRow(ctx, input) {
1464
1469
  const row = ctx.db.insert(sessions).values({
1465
1470
  id: newId("sess"),
@@ -1538,6 +1543,10 @@ function markSessionEnded(ctx, id) {
1538
1543
  ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
1539
1544
  return getSessionRow(ctx, id);
1540
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
+ }
1541
1550
  function mostRecentLiveSession(ctx) {
1542
1551
  const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1543
1552
  return row ? rowToSession(row) : null;
@@ -1678,6 +1687,55 @@ function storeTickets(ctx, featureId, inputs) {
1678
1687
  });
1679
1688
  return rows.map(rowToTicket);
1680
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
+ }
1681
1739
  function updateTicket(ctx, id, patch) {
1682
1740
  const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1683
1741
  if (!current2)
@@ -1699,11 +1757,13 @@ function updateTicket(ctx, id, patch) {
1699
1757
  const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1700
1758
  return rowToTicket(updated);
1701
1759
  }
1760
+ var MUTABLE_STATUSES;
1702
1761
  var init_tickets = __esm(() => {
1703
1762
  init_src();
1704
1763
  init_schema();
1705
1764
  init_errors();
1706
1765
  init_events();
1766
+ MUTABLE_STATUSES = new Set(["pending", "failed"]);
1707
1767
  });
1708
1768
 
1709
1769
  // src/services/gates.ts
@@ -1728,14 +1788,14 @@ function checkGate(ctx, check, feature) {
1728
1788
  return { satisfied: true };
1729
1789
  return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
1730
1790
  case "tickets-approved": {
1731
- const count = listByFeature2(ctx, feature.id).length;
1791
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
1732
1792
  return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
1733
1793
  }
1734
1794
  case "all-tickets-terminal": {
1735
1795
  const tickets2 = listByFeature2(ctx, feature.id);
1736
1796
  if (tickets2.length === 0)
1737
1797
  return { satisfied: false, reason: "no tickets have run yet" };
1738
- 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");
1739
1799
  return open.length === 0 ? { satisfied: true } : {
1740
1800
  satisfied: false,
1741
1801
  reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
@@ -2056,10 +2116,16 @@ async function reattachWorktree(path, branch) {
2056
2116
  await git(path).checkout(branch);
2057
2117
  } catch {}
2058
2118
  }
2119
+ function tempBranchSlugSegment(slug) {
2120
+ return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
2121
+ }
2059
2122
  function researchBranchName(slug, waypointSeq, unique) {
2060
- return `${RESEARCH_BRANCH_PREFIX}${slug}/${waypointSeq}-${unique}`;
2123
+ return `${RESEARCH_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${waypointSeq}-${unique}`;
2124
+ }
2125
+ function ticketBranchName(slug, ticketSeq, unique) {
2126
+ return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2061
2127
  }
2062
- async function mergeResearchBranch(repoPath, featureBranchName, tempBranch) {
2128
+ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2063
2129
  const g = git(repoPath);
2064
2130
  let branches;
2065
2131
  try {
@@ -2108,7 +2174,10 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
2108
2174
  return false;
2109
2175
  }
2110
2176
  }
2111
- async function cleanupResearchBranches(repoPath) {
2177
+ function tempBranchPrefix(name) {
2178
+ return TEMP_BRANCH_PREFIXES.find((p) => name.startsWith(p));
2179
+ }
2180
+ async function cleanupTempBranches(repoPath) {
2112
2181
  const deleted = [];
2113
2182
  const kept = [];
2114
2183
  const g = git(repoPath);
@@ -2119,19 +2188,26 @@ async function cleanupResearchBranches(repoPath) {
2119
2188
  return { deleted, kept };
2120
2189
  }
2121
2190
  for (const name of all) {
2122
- if (!name.startsWith(RESEARCH_BRANCH_PREFIX))
2191
+ const prefix = tempBranchPrefix(name);
2192
+ if (!prefix)
2123
2193
  continue;
2124
- const slug = name.slice(RESEARCH_BRANCH_PREFIX.length).split("/")[0];
2125
- const target = featureBranch(slug);
2194
+ const seg = name.slice(prefix.length).split("/")[0];
2195
+ const targets = seg ? all.filter((b) => {
2196
+ if (!b.startsWith("feature/"))
2197
+ return false;
2198
+ const featureSlug = b.slice("feature/".length);
2199
+ return featureSlug === seg || tempBranchSlugSegment(featureSlug) === seg;
2200
+ }) : [];
2126
2201
  let merged = false;
2127
- if (slug && all.includes(target)) {
2202
+ for (const target of targets) {
2128
2203
  try {
2129
2204
  const tip = (await g.revparse([name])).trim();
2130
2205
  const base = (await g.raw(["merge-base", name, target])).trim();
2131
- merged = tip.length > 0 && tip === base;
2132
- } catch {
2133
- merged = false;
2134
- }
2206
+ if (tip.length > 0 && tip === base) {
2207
+ merged = true;
2208
+ break;
2209
+ }
2210
+ } catch {}
2135
2211
  }
2136
2212
  if (merged && await deleteBranchDetachingWorktrees(g, repoPath, name)) {
2137
2213
  deleted.push(name);
@@ -2278,13 +2354,14 @@ async function mergeInProgress(g) {
2278
2354
  return false;
2279
2355
  }
2280
2356
  }
2281
- 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;
2357
+ 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, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
2282
2358
  var init_git = __esm(() => {
2283
2359
  init_paths();
2284
2360
  init_errors();
2285
2361
  init_dev_pane();
2286
2362
  init_events();
2287
2363
  init_repo();
2364
+ TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
2288
2365
  });
2289
2366
 
2290
2367
  // src/services/knowledge.ts
@@ -2575,16 +2652,29 @@ function isMergeConflictError(err) {
2575
2652
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
2576
2653
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
2577
2654
  }
2578
- function firstLine(s) {
2579
- const i = s.indexOf(`
2580
- `);
2581
- return (i === -1 ? s : s.slice(0, i)).trim();
2655
+ function createSerialQueue() {
2656
+ let tail = Promise.resolve();
2657
+ return (task) => {
2658
+ const next = tail.then(task);
2659
+ tail = next.catch(() => {
2660
+ return;
2661
+ });
2662
+ return next;
2663
+ };
2664
+ }
2665
+ function errorHeadline(s) {
2666
+ const lines = s.split(`
2667
+ `).map((l) => l.trim()).filter(Boolean);
2668
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
2669
+ return causes.at(-1) ?? lines[0] ?? "";
2582
2670
  }
2583
- async function burnTickets(ctx, tickets2, execute) {
2671
+ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
2672
+ const width = Math.max(1, Math.floor(concurrency));
2584
2673
  const bySeq = indexBySeq(tickets2);
2585
2674
  const status = new Map(tickets2.map((t) => [t.seq, t.status]));
2586
2675
  const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
2587
2676
  const inFlight = new Map;
2677
+ const satisfied = (s) => s === "done" || s === "cancelled";
2588
2678
  const readyState = (seq) => {
2589
2679
  const t = bySeq.get(seq);
2590
2680
  if (!t)
@@ -2595,7 +2685,7 @@ async function burnTickets(ctx, tickets2, execute) {
2595
2685
  if (!present || bs === "failed")
2596
2686
  return { blockedBy: b, present };
2597
2687
  }
2598
- return t.blockedBy.every((b) => status.get(b) === "done") ? "ready" : "wait";
2688
+ return t.blockedBy.every((b) => satisfied(status.get(b))) ? "ready" : "wait";
2599
2689
  };
2600
2690
  const failTicket = (seq, error, extra) => {
2601
2691
  const t = bySeq.get(seq);
@@ -2631,7 +2721,7 @@ async function burnTickets(ctx, tickets2, execute) {
2631
2721
  failTicket(seq, outcome.error, outcome.event);
2632
2722
  ctx.emitEvent({
2633
2723
  type: "ticket.failed",
2634
- message: `ticket ${t.seq} failed: ${firstLine(outcome.error)}`,
2724
+ message: `ticket ${t.seq} failed: ${errorHeadline(outcome.error)}`,
2635
2725
  ticketId: t.id,
2636
2726
  data: { error: outcome.error }
2637
2727
  });
@@ -2656,7 +2746,7 @@ async function burnTickets(ctx, tickets2, execute) {
2656
2746
  }
2657
2747
  }
2658
2748
  }
2659
- while (inFlight.size < CONCURRENCY) {
2749
+ while (inFlight.size < width) {
2660
2750
  const readySeq = [...pending].find((seq) => readyState(seq) === "ready");
2661
2751
  if (readySeq === undefined)
2662
2752
  break;
@@ -2667,7 +2757,12 @@ async function burnTickets(ctx, tickets2, execute) {
2667
2757
  inFlight.set(readySeq, p);
2668
2758
  }
2669
2759
  if (inFlight.size > 0) {
2670
- await Promise.race(inFlight.values());
2760
+ try {
2761
+ await Promise.race(inFlight.values());
2762
+ } catch (err) {
2763
+ await Promise.allSettled([...inFlight.values()]);
2764
+ throw err;
2765
+ }
2671
2766
  } else if (pending.size > 0) {
2672
2767
  for (const seq of [...pending]) {
2673
2768
  pending.delete(seq);
@@ -2691,12 +2786,14 @@ async function burnTickets(ctx, tickets2, execute) {
2691
2786
  }
2692
2787
  async function burnRun(ctx, deps) {
2693
2788
  const tickets2 = ctx.tickets;
2694
- const total = tickets2.length;
2789
+ const burnable = tickets2.filter((t) => t.status !== "cancelled");
2790
+ const cancelled = tickets2.length - burnable.length;
2791
+ const total = burnable.length;
2695
2792
  if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
2696
2793
  ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: AUTH_MISSING_MESSAGE });
2697
2794
  return { status: "failed", summary: "burn aborted: auth token missing" };
2698
2795
  }
2699
- const cycle = detectCycle(tickets2);
2796
+ const cycle = detectCycle(burnable);
2700
2797
  if (cycle) {
2701
2798
  const path = cycle.join(" \u2192 ");
2702
2799
  ctx.emitEvent({
@@ -2706,9 +2803,9 @@ async function burnRun(ctx, deps) {
2706
2803
  });
2707
2804
  return { status: "failed", summary: `dependency cycle: ${path}` };
2708
2805
  }
2709
- const done = await burnTickets(ctx, tickets2, deps.executeTicketRun);
2710
- const summary = `${done}/${total} tickets done`;
2711
- ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total } });
2806
+ const done = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
2807
+ const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
2808
+ ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
2712
2809
  return { status: done === total ? "succeeded" : "failed", summary };
2713
2810
  }
2714
2811
  function burnerTemplatePath() {
@@ -2769,8 +2866,9 @@ function selectSandbox(config2) {
2769
2866
  return noSandbox();
2770
2867
  }
2771
2868
  }
2772
- async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
2869
+ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2773
2870
  const { project, feature } = ctx;
2871
+ const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
2774
2872
  const template = readFileSync4(burnerTemplatePath(), "utf8");
2775
2873
  const prompt = renderTicketPrompt(template, {
2776
2874
  TICKET_JSON: buildTicketJson(ticket),
@@ -2786,7 +2884,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
2786
2884
  sandbox: selectSandbox(config2),
2787
2885
  cwd: project.repoPath,
2788
2886
  prompt,
2789
- branchStrategy: { type: "branch", branch: feature.branch, baseBranch: project.mainBranch },
2887
+ branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
2790
2888
  signal: ctx.signal,
2791
2889
  name: `ticket-${ticket.seq}`,
2792
2890
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
@@ -2813,16 +2911,39 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
2813
2911
  }
2814
2912
  throttle.flush();
2815
2913
  const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
2816
- return interpretRunResult(result, blocked);
2914
+ const outcome = interpretRunResult(result, blocked);
2915
+ if (outcome.status !== "done")
2916
+ return outcome;
2917
+ const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
2918
+ if (!merge.ok) {
2919
+ const detail = merge.error ?? "merge failed";
2920
+ if (merge.conflict) {
2921
+ return {
2922
+ status: "failed",
2923
+ error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
2924
+ event: {
2925
+ type: "merge.conflict.needs-human",
2926
+ message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
2927
+ }
2928
+ };
2929
+ }
2930
+ return {
2931
+ status: "failed",
2932
+ error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
2933
+ };
2934
+ }
2935
+ return outcome;
2817
2936
  }
2818
2937
  function resolveBurnDeps(ctx) {
2819
2938
  const config2 = loadConfig();
2820
2939
  const token = readTokenFromEnvFile(envPath());
2821
2940
  const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
2941
+ const land = createSerialQueue();
2822
2942
  return {
2823
2943
  config: config2,
2824
2944
  hasAuthToken: token !== undefined,
2825
- executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model)
2945
+ concurrency: config2.burnConcurrency,
2946
+ executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land)
2826
2947
  };
2827
2948
  }
2828
2949
  function readTokenFromEnvFile(path) {
@@ -2837,12 +2958,13 @@ function readTokenFromEnvFile(path) {
2837
2958
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
2838
2959
  return token && token.length > 0 ? token : undefined;
2839
2960
  }
2840
- 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;
2961
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
2841
2962
  var init_ticket_burner = __esm(() => {
2842
2963
  init_src();
2843
2964
  init_config_load();
2844
2965
  init_paths();
2845
2966
  init_skills_root();
2967
+ init_git();
2846
2968
  PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
2847
2969
  ticketBurner = {
2848
2970
  id: "ticket-burner",
@@ -2892,15 +3014,16 @@ async function researchRun(ctx, deps) {
2892
3014
  }
2893
3015
  ctx.emitEvent({
2894
3016
  type: "research.failed",
2895
- message: `waypoint ${waypoint.seq} research failed: ${firstLine2(outcome.error)}`,
3017
+ message: `waypoint ${waypoint.seq} research failed: ${errorHeadline2(outcome.error)}`,
2896
3018
  data: { waypointId: waypoint.id, error: outcome.error }
2897
3019
  });
2898
3020
  return { status: "failed", summary: outcome.error };
2899
3021
  }
2900
- function firstLine2(s) {
2901
- const i = s.indexOf(`
2902
- `);
2903
- return (i === -1 ? s : s.slice(0, i)).trim();
3022
+ function errorHeadline2(s) {
3023
+ const lines = s.split(`
3024
+ `).map((l) => l.trim()).filter(Boolean);
3025
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
3026
+ return causes.at(-1) ?? lines[0] ?? "";
2904
3027
  }
2905
3028
  function createResearchStreamThrottle(emit2, opts = {}) {
2906
3029
  return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
@@ -2972,12 +3095,12 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
2972
3095
  const outcome = interpretResearchResult(result, docRelPath);
2973
3096
  if (outcome.status !== "done")
2974
3097
  return outcome;
2975
- const merge = await mergeResearchBranch(project.repoPath, feature.branch, tempBranch);
3098
+ const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
2976
3099
  if (!merge.ok) {
2977
3100
  const detail = merge.error ?? "merge failed";
2978
3101
  ctx.emitEvent({
2979
3102
  type: merge.conflict ? "merge.conflict.needs-human" : "research.merge_failed",
2980
- message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${firstLine2(detail)} \u2014 the branch is preserved for manual recovery`,
3103
+ message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${errorHeadline2(detail)} \u2014 the branch is preserved for manual recovery`,
2981
3104
  data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
2982
3105
  });
2983
3106
  return {
@@ -3250,7 +3373,8 @@ function list(ctx, projectId) {
3250
3373
  pending: tickets2.filter((t) => t.status === "pending").length,
3251
3374
  burning: tickets2.filter((t) => t.status === "burning").length,
3252
3375
  done: tickets2.filter((t) => t.status === "done").length,
3253
- failed: tickets2.filter((t) => t.status === "failed").length
3376
+ failed: tickets2.filter((t) => t.status === "failed").length,
3377
+ cancelled: tickets2.filter((t) => t.status === "cancelled").length
3254
3378
  };
3255
3379
  return { ...feature, ticketCounts: counts, activeRun: hasActiveRun(ctx, feature.id) };
3256
3380
  });
@@ -3279,13 +3403,19 @@ async function burn(ctx, featureId, opts = {}) {
3279
3403
  const why = feature.phase === "implementation" ? "a run is already burning this feature" : `feature must be in the tickets phase to burn (currently ${feature.phase})`;
3280
3404
  throw new GateError(why);
3281
3405
  }
3282
- if (listByFeature2(ctx, featureId).length < 1) {
3283
- throw new GateError("no tickets to burn");
3406
+ const tickets2 = listByFeature2(ctx, featureId);
3407
+ if (tickets2.filter((t) => t.status !== "cancelled").length < 1) {
3408
+ throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
3284
3409
  }
3285
3410
  if (restarting) {
3411
+ const failed = tickets2.filter((t) => t.status === "failed");
3412
+ for (const t of failed) {
3413
+ updateTicket(ctx, t.id, { status: "pending", error: null });
3414
+ }
3286
3415
  emit(ctx, featureId, {
3287
3416
  type: "burn.restarted",
3288
- message: "restarting burn (previous run cancelled or crashed)"
3417
+ message: failed.length > 0 ? `restarting burn \u2014 retrying ${failed.length} failed ticket(s)` : "restarting burn (previous run cancelled or crashed)",
3418
+ data: { retried: failed.map((t) => t.seq) }
3289
3419
  });
3290
3420
  } else {
3291
3421
  setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
@@ -3397,6 +3527,22 @@ function toolEmitTickets(ctx, session, input) {
3397
3527
  const stored = storeTickets(ctx, feature.id, input.tickets);
3398
3528
  return { stored: stored.length, ids: stored.map((t) => t.id) };
3399
3529
  }
3530
+ function requireOwnTicket(ctx, session, ticketId) {
3531
+ const ticket = getTicket(ctx, ticketId);
3532
+ if (ticket.featureId !== session.featureId) {
3533
+ throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
3534
+ }
3535
+ return ticket;
3536
+ }
3537
+ function toolUpdateTicket(ctx, session, input) {
3538
+ requireOwnTicket(ctx, session, input.id);
3539
+ const { id, ...patch } = input;
3540
+ return { ok: true, ticket: editTicket(ctx, id, patch) };
3541
+ }
3542
+ function toolCancelTicket(ctx, session, input) {
3543
+ requireOwnTicket(ctx, session, input.id);
3544
+ return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
3545
+ }
3400
3546
  function toolEscalateToMap(ctx, session, input) {
3401
3547
  return escalateToMap(ctx, session.featureId, input);
3402
3548
  }
@@ -3496,6 +3642,37 @@ function buildMcpServer() {
3496
3642
  await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: tickets emitted (${result.stored})`);
3497
3643
  return ok(result);
3498
3644
  });
3645
+ server.registerTool("update_ticket", {
3646
+ title: "Update ticket",
3647
+ 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.",
3648
+ inputSchema: {
3649
+ id: z3.string(),
3650
+ title: z3.string().optional(),
3651
+ goal: z3.string().optional(),
3652
+ context: z3.string().optional(),
3653
+ acceptanceCriteria: z3.array(z3.string()).optional(),
3654
+ seams: z3.array(z3.string()).optional()
3655
+ }
3656
+ }, async (args, extra) => {
3657
+ const rs = await resolveCtxSession(extra);
3658
+ if (!rs)
3659
+ return noSession();
3660
+ const result = toolUpdateTicket(rs.ctx, rs.session, args);
3661
+ await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} updated`);
3662
+ return ok(result);
3663
+ });
3664
+ server.registerTool("cancel_ticket", {
3665
+ title: "Cancel ticket",
3666
+ 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.",
3667
+ inputSchema: { id: z3.string(), reason: z3.string().optional() }
3668
+ }, async (args, extra) => {
3669
+ const rs = await resolveCtxSession(extra);
3670
+ if (!rs)
3671
+ return noSession();
3672
+ const result = toolCancelTicket(rs.ctx, rs.session, args);
3673
+ await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} cancelled`);
3674
+ return ok(result);
3675
+ });
3499
3676
  server.registerTool("escalate_to_map", {
3500
3677
  title: "Escalate to map",
3501
3678
  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.",
@@ -3893,6 +4070,8 @@ function renderSystemPrompt(feature, kind, waypoint) {
3893
4070
  return renderWaypointPrompt(feature, waypoint);
3894
4071
  if (kind === "converge")
3895
4072
  return renderConvergePrompt(feature);
4073
+ if (kind === "revisit")
4074
+ return renderRevisitPrompt(feature);
3896
4075
  const docs = featureDocsRel(feature.slug);
3897
4076
  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.";
3898
4077
  return [
@@ -4018,6 +4197,49 @@ function renderConvergePrompt(feature) {
4018
4197
  ].join(`
4019
4198
  `);
4020
4199
  }
4200
+ function renderRevisitPrompt(feature) {
4201
+ const docs = featureDocsRel(feature.slug);
4202
+ return [
4203
+ `# runcastle \u2014 ${feature.title} (revisit session)`,
4204
+ "",
4205
+ feature.oneLiner,
4206
+ "",
4207
+ "This is a **revisit session**: the human came back to a feature whose",
4208
+ "earlier sessions are finished \u2014 they remembered something, or a decision",
4209
+ "changed. Where possible this terminal RESUMES the previous conversation, so",
4210
+ "you may already have its context above. Your job: fold the new information",
4211
+ "into the record, then reconcile the tickets with it. The docs are the",
4212
+ "artifact \u2014 later phases read them, never the transcripts.",
4213
+ "",
4214
+ "## Feature",
4215
+ `- Slug: \`${feature.slug}\``,
4216
+ `- Branch: \`${feature.branch}\``,
4217
+ `- Current phase: **${feature.phase}** (size: ${feature.size})`,
4218
+ "",
4219
+ "## Knowledge (versioned in the target repo)",
4220
+ `Feature docs live at \`${docs}/\`:`,
4221
+ `- \`${docs}/decisions.md\` \u2014 append the new/changed decisions with a dated "revisited" note.`,
4222
+ `- \`${docs}/spec.md\` \u2014 if it exists, amend the affected sections in place.`,
4223
+ `- \`${docs}/map.md\` \u2014 if the feature is mapped, keep the map honest too.`,
4224
+ "",
4225
+ "## runcastle MCP tools",
4226
+ "- `get_feature_context()` \u2014 feature + phase + docs + ALL tickets (with ids and statuses).",
4227
+ "- `update_ticket({ id, ...fields })` \u2014 rewrite a stale pending/failed ticket's content.",
4228
+ "- `cancel_ticket({ id, reason })` \u2014 cancel a pending/failed ticket that is now unnecessary.",
4229
+ "- `emit_tickets({ tickets })` \u2014 add tickets for NEW work the change requires.",
4230
+ "- `record_event({ type, message })` \u2014 drop a timeline note summarising the revisit.",
4231
+ "",
4232
+ "## Rules",
4233
+ "- Do NOT call `complete_phase` \u2014 a revisit never moves the pipeline.",
4234
+ "- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
4235
+ "- Docs first, tickets second: capture the decision prose before any ticket surgery.",
4236
+ "",
4237
+ "## Your task",
4238
+ "Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
4239
+ ""
4240
+ ].join(`
4241
+ `);
4242
+ }
4021
4243
  function renderSettings(hookClient) {
4022
4244
  const cmd = (event) => ({
4023
4245
  type: "command",
@@ -4063,6 +4285,8 @@ var init_artifacts = __esm(() => {
4063
4285
  RUNCASTLE_MCP_ALLOW_RULES = [
4064
4286
  "mcp__runcastle__get_feature_context",
4065
4287
  "mcp__runcastle__emit_tickets",
4288
+ "mcp__runcastle__update_ticket",
4289
+ "mcp__runcastle__cancel_ticket",
4066
4290
  "mcp__runcastle__record_event",
4067
4291
  "mcp__runcastle__complete_phase",
4068
4292
  "mcp__runcastle__escalate_to_map",
@@ -4229,11 +4453,32 @@ async function launchSession(ctx, input, opts = {}) {
4229
4453
  throw e;
4230
4454
  }
4231
4455
  }
4456
+ if (input.kind === "revisit") {
4457
+ try {
4458
+ assertSpawnable(ctx, feature, session.id);
4459
+ } catch (e) {
4460
+ markSessionEnded(ctx, session.id);
4461
+ throw e;
4462
+ }
4463
+ const prior = mostRecentResumableSession(ctx, feature.id);
4464
+ if (prior?.ccSessionId) {
4465
+ resumeSessionId = prior.ccSessionId;
4466
+ } else {
4467
+ resumeUnavailableFrom = "revisit";
4468
+ }
4469
+ }
4232
4470
  emit(ctx, feature.id, {
4233
4471
  type: "session.launching",
4234
4472
  message: `launching ${input.kind} session`,
4235
4473
  data: { sessionId: session.id, kind: input.kind, worktreePath, waypointId: waypoint?.id }
4236
4474
  });
4475
+ if (input.kind === "revisit" && resumeUnavailableFrom) {
4476
+ emit(ctx, feature.id, {
4477
+ type: "session.resume_unavailable",
4478
+ message: "no resumable conversation for this feature \u2014 revisiting fresh from the docs",
4479
+ data: { sessionId: session.id }
4480
+ });
4481
+ }
4237
4482
  if (waypoint && resumeUnavailableFrom) {
4238
4483
  emit(ctx, feature.id, {
4239
4484
  type: "session.resume_unavailable",
@@ -4831,6 +5076,14 @@ var init_settings = __esm(() => {
4831
5076
  valueSchema: z9.string().min(1),
4832
5077
  parseEnv: idEnv
4833
5078
  },
5079
+ {
5080
+ key: "burnConcurrency",
5081
+ configKey: "burnConcurrency",
5082
+ envVar: "RUNCASTLE_BURN_CONCURRENCY",
5083
+ restartRequired: false,
5084
+ valueSchema: z9.number().int().min(1).max(8),
5085
+ parseEnv: (raw) => Number(raw)
5086
+ },
4834
5087
  {
4835
5088
  key: "mainBranch",
4836
5089
  configKey: "mainBranch",
@@ -5425,7 +5678,7 @@ async function reconcileStaleRuns(ctx) {
5425
5678
  }
5426
5679
  for (const project of allProjects(ctx)) {
5427
5680
  try {
5428
- await cleanupResearchBranches(project.repoPath);
5681
+ await cleanupTempBranches(project.repoPath);
5429
5682
  } catch {}
5430
5683
  }
5431
5684
  return reconciled;