runcastle 1.0.6 → 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
@@ -86,6 +86,7 @@ var init_config = __esm(() => {
86
86
  "qa",
87
87
  "waypoint",
88
88
  "converge",
89
+ "revisit",
89
90
  "research",
90
91
  "implement",
91
92
  "smoke"
@@ -97,7 +98,8 @@ var init_config = __esm(() => {
97
98
  stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
98
99
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
99
100
  mainBranch: z.string().default("main"),
100
- sandboxImage: z.string().optional()
101
+ sandboxImage: z.string().optional(),
102
+ burnConcurrency: z.number().int().min(1).max(8).default(3)
101
103
  }));
102
104
  });
103
105
 
@@ -130,6 +132,9 @@ function loadConfig(env = process.env) {
130
132
  overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
131
133
  if (env.RUNCASTLE_SANDBOX_IMAGE)
132
134
  overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
135
+ if (env.RUNCASTLE_BURN_CONCURRENCY) {
136
+ overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
137
+ }
133
138
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
134
139
  return RuncastleConfig.parse({ ...base, ...overrides });
135
140
  }
@@ -173,8 +178,8 @@ var init_schemas = __esm(() => {
173
178
  "shipped"
174
179
  ]);
175
180
  FeatureSize = z2.enum(["full", "collapsed"]);
176
- TicketStatus = z2.enum(["pending", "burning", "done", "failed"]);
177
- 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"]);
178
183
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
179
184
  FeatureStatus = z2.enum(["active", "shipped"]);
180
185
  SessionStatus = z2.enum(["launching", "live", "ended"]);
@@ -1458,7 +1463,7 @@ var init_waypoints = __esm(() => {
1458
1463
  });
1459
1464
 
1460
1465
  // src/launcher/sessions.ts
1461
- 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";
1462
1467
  function createSessionRow(ctx, input) {
1463
1468
  const row = ctx.db.insert(sessions).values({
1464
1469
  id: newId("sess"),
@@ -1537,6 +1542,10 @@ function markSessionEnded(ctx, id) {
1537
1542
  ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
1538
1543
  return getSessionRow(ctx, id);
1539
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
+ }
1540
1549
  function mostRecentLiveSession(ctx) {
1541
1550
  const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1542
1551
  return row ? rowToSession(row) : null;
@@ -1677,6 +1686,55 @@ function storeTickets(ctx, featureId, inputs) {
1677
1686
  });
1678
1687
  return rows.map(rowToTicket);
1679
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
+ }
1680
1738
  function updateTicket(ctx, id, patch) {
1681
1739
  const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1682
1740
  if (!current2)
@@ -1698,11 +1756,13 @@ function updateTicket(ctx, id, patch) {
1698
1756
  const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1699
1757
  return rowToTicket(updated);
1700
1758
  }
1759
+ var MUTABLE_STATUSES;
1701
1760
  var init_tickets = __esm(() => {
1702
1761
  init_src();
1703
1762
  init_schema();
1704
1763
  init_errors();
1705
1764
  init_events();
1765
+ MUTABLE_STATUSES = new Set(["pending", "failed"]);
1706
1766
  });
1707
1767
 
1708
1768
  // src/services/gates.ts
@@ -1727,14 +1787,14 @@ function checkGate(ctx, check, feature) {
1727
1787
  return { satisfied: true };
1728
1788
  return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
1729
1789
  case "tickets-approved": {
1730
- const count = listByFeature2(ctx, feature.id).length;
1790
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
1731
1791
  return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
1732
1792
  }
1733
1793
  case "all-tickets-terminal": {
1734
1794
  const tickets2 = listByFeature2(ctx, feature.id);
1735
1795
  if (tickets2.length === 0)
1736
1796
  return { satisfied: false, reason: "no tickets have run yet" };
1737
- 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");
1738
1798
  return open.length === 0 ? { satisfied: true } : {
1739
1799
  satisfied: false,
1740
1800
  reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
@@ -2058,7 +2118,10 @@ async function reattachWorktree(path, branch) {
2058
2118
  function researchBranchName(slug, waypointSeq, unique) {
2059
2119
  return `${RESEARCH_BRANCH_PREFIX}${slug}/${waypointSeq}-${unique}`;
2060
2120
  }
2061
- 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) {
2062
2125
  const g = git(repoPath);
2063
2126
  let branches;
2064
2127
  try {
@@ -2107,7 +2170,10 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
2107
2170
  return false;
2108
2171
  }
2109
2172
  }
2110
- async function cleanupResearchBranches(repoPath) {
2173
+ function tempBranchPrefix(name) {
2174
+ return TEMP_BRANCH_PREFIXES.find((p) => name.startsWith(p));
2175
+ }
2176
+ async function cleanupTempBranches(repoPath) {
2111
2177
  const deleted = [];
2112
2178
  const kept = [];
2113
2179
  const g = git(repoPath);
@@ -2118,9 +2184,10 @@ async function cleanupResearchBranches(repoPath) {
2118
2184
  return { deleted, kept };
2119
2185
  }
2120
2186
  for (const name of all) {
2121
- if (!name.startsWith(RESEARCH_BRANCH_PREFIX))
2187
+ const prefix = tempBranchPrefix(name);
2188
+ if (!prefix)
2122
2189
  continue;
2123
- const slug = name.slice(RESEARCH_BRANCH_PREFIX.length).split("/")[0];
2190
+ const slug = name.slice(prefix.length).split("/")[0];
2124
2191
  const target = featureBranch(slug);
2125
2192
  let merged = false;
2126
2193
  if (slug && all.includes(target)) {
@@ -2277,13 +2344,14 @@ async function mergeInProgress(g) {
2277
2344
  return false;
2278
2345
  }
2279
2346
  }
2280
- 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;
2281
2348
  var init_git = __esm(() => {
2282
2349
  init_paths();
2283
2350
  init_errors();
2284
2351
  init_dev_pane();
2285
2352
  init_events();
2286
2353
  init_repo();
2354
+ TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
2287
2355
  });
2288
2356
 
2289
2357
  // src/services/knowledge.ts
@@ -2574,16 +2642,28 @@ function isMergeConflictError(err) {
2574
2642
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
2575
2643
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
2576
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
+ }
2577
2655
  function firstLine(s) {
2578
2656
  const i = s.indexOf(`
2579
2657
  `);
2580
2658
  return (i === -1 ? s : s.slice(0, i)).trim();
2581
2659
  }
2582
- async function burnTickets(ctx, tickets2, execute) {
2660
+ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
2661
+ const width = Math.max(1, Math.floor(concurrency));
2583
2662
  const bySeq = indexBySeq(tickets2);
2584
2663
  const status = new Map(tickets2.map((t) => [t.seq, t.status]));
2585
2664
  const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
2586
2665
  const inFlight = new Map;
2666
+ const satisfied = (s) => s === "done" || s === "cancelled";
2587
2667
  const readyState = (seq) => {
2588
2668
  const t = bySeq.get(seq);
2589
2669
  if (!t)
@@ -2594,7 +2674,7 @@ async function burnTickets(ctx, tickets2, execute) {
2594
2674
  if (!present || bs === "failed")
2595
2675
  return { blockedBy: b, present };
2596
2676
  }
2597
- return t.blockedBy.every((b) => status.get(b) === "done") ? "ready" : "wait";
2677
+ return t.blockedBy.every((b) => satisfied(status.get(b))) ? "ready" : "wait";
2598
2678
  };
2599
2679
  const failTicket = (seq, error, extra) => {
2600
2680
  const t = bySeq.get(seq);
@@ -2655,7 +2735,7 @@ async function burnTickets(ctx, tickets2, execute) {
2655
2735
  }
2656
2736
  }
2657
2737
  }
2658
- while (inFlight.size < CONCURRENCY) {
2738
+ while (inFlight.size < width) {
2659
2739
  const readySeq = [...pending].find((seq) => readyState(seq) === "ready");
2660
2740
  if (readySeq === undefined)
2661
2741
  break;
@@ -2666,7 +2746,12 @@ async function burnTickets(ctx, tickets2, execute) {
2666
2746
  inFlight.set(readySeq, p);
2667
2747
  }
2668
2748
  if (inFlight.size > 0) {
2669
- 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
+ }
2670
2755
  } else if (pending.size > 0) {
2671
2756
  for (const seq of [...pending]) {
2672
2757
  pending.delete(seq);
@@ -2690,12 +2775,14 @@ async function burnTickets(ctx, tickets2, execute) {
2690
2775
  }
2691
2776
  async function burnRun(ctx, deps) {
2692
2777
  const tickets2 = ctx.tickets;
2693
- 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;
2694
2781
  if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
2695
2782
  ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: AUTH_MISSING_MESSAGE });
2696
2783
  return { status: "failed", summary: "burn aborted: auth token missing" };
2697
2784
  }
2698
- const cycle = detectCycle(tickets2);
2785
+ const cycle = detectCycle(burnable);
2699
2786
  if (cycle) {
2700
2787
  const path = cycle.join(" \u2192 ");
2701
2788
  ctx.emitEvent({
@@ -2705,9 +2792,9 @@ async function burnRun(ctx, deps) {
2705
2792
  });
2706
2793
  return { status: "failed", summary: `dependency cycle: ${path}` };
2707
2794
  }
2708
- const done = await burnTickets(ctx, tickets2, deps.executeTicketRun);
2709
- const summary = `${done}/${total} tickets done`;
2710
- 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 } });
2711
2798
  return { status: done === total ? "succeeded" : "failed", summary };
2712
2799
  }
2713
2800
  function burnerTemplatePath() {
@@ -2768,8 +2855,9 @@ function selectSandbox(config2) {
2768
2855
  return noSandbox();
2769
2856
  }
2770
2857
  }
2771
- async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
2858
+ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
2772
2859
  const { project, feature } = ctx;
2860
+ const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
2773
2861
  const template = readFileSync4(burnerTemplatePath(), "utf8");
2774
2862
  const prompt = renderTicketPrompt(template, {
2775
2863
  TICKET_JSON: buildTicketJson(ticket),
@@ -2785,7 +2873,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
2785
2873
  sandbox: selectSandbox(config2),
2786
2874
  cwd: project.repoPath,
2787
2875
  prompt,
2788
- branchStrategy: { type: "branch", branch: feature.branch, baseBranch: project.mainBranch },
2876
+ branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
2789
2877
  signal: ctx.signal,
2790
2878
  name: `ticket-${ticket.seq}`,
2791
2879
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
@@ -2812,16 +2900,39 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
2812
2900
  }
2813
2901
  throttle.flush();
2814
2902
  const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
2815
- 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;
2816
2925
  }
2817
2926
  function resolveBurnDeps(ctx) {
2818
2927
  const config2 = loadConfig();
2819
2928
  const token = readTokenFromEnvFile(envPath());
2820
2929
  const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
2930
+ const land = createSerialQueue();
2821
2931
  return {
2822
2932
  config: config2,
2823
2933
  hasAuthToken: token !== undefined,
2824
- executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model)
2934
+ concurrency: config2.burnConcurrency,
2935
+ executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land)
2825
2936
  };
2826
2937
  }
2827
2938
  function readTokenFromEnvFile(path) {
@@ -2836,12 +2947,13 @@ function readTokenFromEnvFile(path) {
2836
2947
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
2837
2948
  return token && token.length > 0 ? token : undefined;
2838
2949
  }
2839
- 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;
2840
2951
  var init_ticket_burner = __esm(() => {
2841
2952
  init_src();
2842
2953
  init_config_load();
2843
2954
  init_paths();
2844
2955
  init_skills_root();
2956
+ init_git();
2845
2957
  PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
2846
2958
  ticketBurner = {
2847
2959
  id: "ticket-burner",
@@ -2971,7 +3083,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
2971
3083
  const outcome = interpretResearchResult(result, docRelPath);
2972
3084
  if (outcome.status !== "done")
2973
3085
  return outcome;
2974
- const merge = await mergeResearchBranch(project.repoPath, feature.branch, tempBranch);
3086
+ const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
2975
3087
  if (!merge.ok) {
2976
3088
  const detail = merge.error ?? "merge failed";
2977
3089
  ctx.emitEvent({
@@ -3249,7 +3361,8 @@ function list(ctx, projectId) {
3249
3361
  pending: tickets2.filter((t) => t.status === "pending").length,
3250
3362
  burning: tickets2.filter((t) => t.status === "burning").length,
3251
3363
  done: tickets2.filter((t) => t.status === "done").length,
3252
- 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
3253
3366
  };
3254
3367
  return { ...feature, ticketCounts: counts, activeRun: hasActiveRun(ctx, feature.id) };
3255
3368
  });
@@ -3278,13 +3391,19 @@ async function burn(ctx, featureId, opts = {}) {
3278
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})`;
3279
3392
  throw new GateError(why);
3280
3393
  }
3281
- if (listByFeature2(ctx, featureId).length < 1) {
3282
- 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");
3283
3397
  }
3284
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
+ }
3285
3403
  emit(ctx, featureId, {
3286
3404
  type: "burn.restarted",
3287
- 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) }
3288
3407
  });
3289
3408
  } else {
3290
3409
  setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
@@ -3396,6 +3515,22 @@ function toolEmitTickets(ctx, session, input) {
3396
3515
  const stored = storeTickets(ctx, feature.id, input.tickets);
3397
3516
  return { stored: stored.length, ids: stored.map((t) => t.id) };
3398
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
+ }
3399
3534
  function toolEscalateToMap(ctx, session, input) {
3400
3535
  return escalateToMap(ctx, session.featureId, input);
3401
3536
  }
@@ -3495,6 +3630,37 @@ function buildMcpServer() {
3495
3630
  await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: tickets emitted (${result.stored})`);
3496
3631
  return ok(result);
3497
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
+ });
3498
3664
  server.registerTool("escalate_to_map", {
3499
3665
  title: "Escalate to map",
3500
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.",
@@ -3892,6 +4058,8 @@ function renderSystemPrompt(feature, kind, waypoint) {
3892
4058
  return renderWaypointPrompt(feature, waypoint);
3893
4059
  if (kind === "converge")
3894
4060
  return renderConvergePrompt(feature);
4061
+ if (kind === "revisit")
4062
+ return renderRevisitPrompt(feature);
3895
4063
  const docs = featureDocsRel(feature.slug);
3896
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.";
3897
4065
  return [
@@ -4017,6 +4185,49 @@ function renderConvergePrompt(feature) {
4017
4185
  ].join(`
4018
4186
  `);
4019
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
+ }
4020
4231
  function renderSettings(hookClient) {
4021
4232
  const cmd = (event) => ({
4022
4233
  type: "command",
@@ -4062,6 +4273,8 @@ var init_artifacts = __esm(() => {
4062
4273
  RUNCASTLE_MCP_ALLOW_RULES = [
4063
4274
  "mcp__runcastle__get_feature_context",
4064
4275
  "mcp__runcastle__emit_tickets",
4276
+ "mcp__runcastle__update_ticket",
4277
+ "mcp__runcastle__cancel_ticket",
4065
4278
  "mcp__runcastle__record_event",
4066
4279
  "mcp__runcastle__complete_phase",
4067
4280
  "mcp__runcastle__escalate_to_map",
@@ -4228,11 +4441,32 @@ async function launchSession(ctx, input, opts = {}) {
4228
4441
  throw e;
4229
4442
  }
4230
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
+ }
4231
4458
  emit(ctx, feature.id, {
4232
4459
  type: "session.launching",
4233
4460
  message: `launching ${input.kind} session`,
4234
4461
  data: { sessionId: session.id, kind: input.kind, worktreePath, waypointId: waypoint?.id }
4235
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
+ }
4236
4470
  if (waypoint && resumeUnavailableFrom) {
4237
4471
  emit(ctx, feature.id, {
4238
4472
  type: "session.resume_unavailable",
@@ -4830,6 +5064,14 @@ var init_settings = __esm(() => {
4830
5064
  valueSchema: z9.string().min(1),
4831
5065
  parseEnv: idEnv
4832
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
+ },
4833
5075
  {
4834
5076
  key: "mainBranch",
4835
5077
  configKey: "mainBranch",
@@ -5424,7 +5666,7 @@ async function reconcileStaleRuns(ctx) {
5424
5666
  }
5425
5667
  for (const project of allProjects(ctx)) {
5426
5668
  try {
5427
- await cleanupResearchBranches(project.repoPath);
5669
+ await cleanupTempBranches(project.repoPath);
5428
5670
  } catch {}
5429
5671
  }
5430
5672
  return reconciled;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: revisit
3
+ description: Fold late-arriving information into a feature whose sessions are finished — amend the docs, then reconcile tickets (update/cancel/emit). Never advances phases. Entry skill for kind=revisit sessions.
4
+ disable-model-invocation: false
5
+ ---
6
+
7
+ # Revisit
8
+
9
+ The human came back: they remembered a constraint, changed their mind, or learned something that the grilling/spec didn't capture. This session's job is to make the **record** and the **ticket queue** match the new reality — nothing more. The pipeline does not move; whatever phase the feature is in, it stays in.
10
+
11
+ ## Order of operations
12
+
13
+ 1. **Listen first.** The human opens with what changed. If this terminal resumed the previous conversation, use that context; do not re-grill what is already settled. Ask only the questions the NEW information raises.
14
+ 2. **Context.** Call `mcp__runcastle__get_feature_context` — it returns the feature, phase, all docs, and all tickets with their ids and statuses. You need the ticket list before any surgery.
15
+ 3. **Docs.** Capture the change as decision prose:
16
+ - Append to `docs/features/<slug>/decisions.md` under a dated `## Revisited <date>` heading — never rewrite old decisions, supersede them ("Supersedes: <old decision>").
17
+ - If `spec.md` exists and the change touches it, amend the affected sections in place.
18
+ - If the feature is mapped, keep `map.md` honest (destination/out-of-scope).
19
+ 4. **Ticket surgery.** Walk the ticket list against the new reality:
20
+ - Stale but still needed → `mcp__runcastle__update_ticket({ id, ...changed fields })` (pending/failed only).
21
+ - No longer needed → `mcp__runcastle__cancel_ticket({ id, reason })` (pending/failed only).
22
+ - New work required → `mcp__runcastle__emit_tickets({ tickets })` (batch, same shape as ideation).
23
+ - `done` work now wrong → emit a NEW ticket that corrects it. Never touch done/burning tickets.
24
+ 5. **Close.** `mcp__runcastle__record_event({ type: "feature.revisited", message: "<one-line gist of what changed>" })`, then tell the human what state you left things in — especially whether a re-Burn is needed (pending tickets exist and the feature is at `implementation`).
25
+
26
+ ## Do NOT
27
+
28
+ - **Never call `complete_phase`.** A revisit has no gates to cross; the human drives the pipeline from the UI.
29
+ - **Never resolve or reopen waypoints.** If a resolved waypoint's answer is now wrong, record the superseding decision in `decisions.md` — the map's history stays intact.
30
+ - **Never implement changes.** Code changes ride tickets; the burner (or a human) implements them.
31
+
32
+ ## Scope check
33
+
34
+ If what the human brings is not an amendment but a whole new capability, say so:
35
+
36
+ > That reads like a new feature, not a revision of this one. Create it in the runcastle UI and grill it there — I can record a pointer note here.
37
+
38
+ Then `record_event` a one-line note so the idea is not lost.