runcastle 1.0.22 → 1.0.24

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
@@ -105,10 +105,14 @@ var init_config = __esm(() => {
105
105
  mainBranch: z.string().default("main"),
106
106
  sandboxImage: z.string().optional(),
107
107
  burnConcurrency: z.number().int().min(1).max(8).default(3),
108
+ burnCpus: z.number().positive().max(256).optional(),
109
+ burnGuard: z.boolean().default(true),
108
110
  burnMaxIterations: z.number().int().min(1).max(10).default(3),
109
111
  burnAttempts: z.number().int().min(1).max(5).default(3),
110
112
  burnConflictAttempts: z.number().int().min(0).max(3).default(2),
111
113
  setupCommand: z.string().optional(),
114
+ verifyCommands: z.string().optional(),
115
+ knownFailures: z.string().optional(),
112
116
  burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
113
117
  }));
114
118
  });
@@ -154,8 +158,17 @@ function loadConfig(env = process.env) {
154
158
  if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
155
159
  overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
156
160
  }
161
+ if (env.RUNCASTLE_BURN_CPUS)
162
+ overrides.burnCpus = Number(env.RUNCASTLE_BURN_CPUS);
163
+ if (env.RUNCASTLE_BURN_GUARD !== undefined) {
164
+ overrides.burnGuard = env.RUNCASTLE_BURN_GUARD !== "0" && env.RUNCASTLE_BURN_GUARD !== "false";
165
+ }
157
166
  if (env.RUNCASTLE_SETUP_COMMAND)
158
167
  overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
168
+ if (env.RUNCASTLE_VERIFY_COMMANDS)
169
+ overrides.verifyCommands = env.RUNCASTLE_VERIFY_COMMANDS;
170
+ if (env.RUNCASTLE_KNOWN_FAILURES)
171
+ overrides.knownFailures = env.RUNCASTLE_KNOWN_FAILURES;
159
172
  if (env.RUNCASTLE_BURN_WORKSPACE)
160
173
  overrides.burnWorkspace = env.RUNCASTLE_BURN_WORKSPACE;
161
174
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
@@ -660,6 +673,72 @@ var init_migrate = __esm(() => {
660
673
  // src/util/resolve-executable.ts
661
674
  import { existsSync as existsSync3 } from "fs";
662
675
  import { win32, posix } from "path";
676
+ function wellKnownBinDirs(opts = {}) {
677
+ const env = opts.env ?? process.env;
678
+ const isWin = (opts.platform ?? process.platform) === "win32";
679
+ const dirs = [];
680
+ if (isWin) {
681
+ const home2 = env.USERPROFILE;
682
+ if (home2) {
683
+ dirs.push(win32.join(home2, ".local", "bin"), win32.join(home2, ".bun", "bin"));
684
+ }
685
+ if (env.APPDATA)
686
+ dirs.push(win32.join(env.APPDATA, "npm"));
687
+ if (env.LOCALAPPDATA)
688
+ dirs.push(win32.join(env.LOCALAPPDATA, "Programs", "claude"));
689
+ return dirs;
690
+ }
691
+ const home = env.HOME;
692
+ if (home) {
693
+ dirs.push(posix.join(home, ".local", "bin"), posix.join(home, ".bun", "bin"), posix.join(home, ".npm-global", "bin"));
694
+ }
695
+ dirs.push("/usr/local/bin", "/opt/homebrew/bin");
696
+ return dirs;
697
+ }
698
+ function resolveTool(name, opts = {}) {
699
+ const env = opts.env ?? process.env;
700
+ const overrideKey = BIN_OVERRIDE_ENV[name];
701
+ const override = opts.override ?? (overrideKey ? env[overrideKey] : undefined);
702
+ const extraDirs = opts.extraDirs ?? wellKnownBinDirs({ ...opts.platform ? { platform: opts.platform } : {}, env });
703
+ return resolveExecutable(name, { ...opts, extraDirs, ...override ? { override } : {} });
704
+ }
705
+ function spawnTargetFor(resolved, args) {
706
+ if (/\.(cmd|bat)$/i.test(resolved)) {
707
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/c", resolved, ...args] };
708
+ }
709
+ if (/\.ps1$/i.test(resolved)) {
710
+ return {
711
+ file: "powershell.exe",
712
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved, ...args]
713
+ };
714
+ }
715
+ return { file: resolved, args };
716
+ }
717
+ function resolveSpawnTarget(name, args) {
718
+ return spawnTargetFor(resolveTool(name), args);
719
+ }
720
+ function isBareName(cmd) {
721
+ return !cmd.includes("/") && !cmd.includes("\\");
722
+ }
723
+ function explainSpawnFailure(cmd, raw) {
724
+ const detail = raw.trim();
725
+ if (!isBareName(cmd)) {
726
+ return `Could not launch ${cmd}${detail ? ` \u2014 ${detail}` : ""}`;
727
+ }
728
+ const overrideKey = BIN_OVERRIDE_ENV[cmd];
729
+ const lines = [
730
+ `Could not launch \`${cmd}\`: not on the PATH this runcastle server inherited, nor in the usual install locations (~/.local/bin, npm global, ~/.bun/bin).`,
731
+ `A terminal where \`${cmd}\` works does not prove the server can see it: the server's PATH was captured when it started.`,
732
+ `Fix: quit runcastle and start it again from a terminal where \`${cmd} --version\` works.`
733
+ ];
734
+ if (overrideKey) {
735
+ lines.push(`If that fails, set ${overrideKey} to the full path (\`where.exe ${cmd}\` / \`which ${cmd}\`) and restart.`);
736
+ }
737
+ if (detail)
738
+ lines.push(`(underlying error: ${detail})`);
739
+ return lines.join(`
740
+ `);
741
+ }
663
742
  function resolveExecutable(name, opts = {}) {
664
743
  const exists = opts.exists ?? existsSync3;
665
744
  if (opts.override && exists(opts.override))
@@ -667,22 +746,28 @@ function resolveExecutable(name, opts = {}) {
667
746
  const isWin = (opts.platform ?? process.platform) === "win32";
668
747
  const exts = opts.exts ?? (isWin ? WIN_EXTS : [""]);
669
748
  const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
670
- const dirs = pathEnv.split(isWin ? ";" : ":");
671
749
  const join5 = isWin ? win32.join : posix.join;
672
- for (const dir of dirs) {
673
- if (!dir)
674
- continue;
675
- for (const ext of exts) {
676
- const candidate = join5(dir, `${name}${ext}`);
677
- if (exists(candidate))
678
- return candidate;
750
+ const scan = (dirs) => {
751
+ for (const dir of dirs) {
752
+ if (!dir)
753
+ continue;
754
+ for (const ext of exts) {
755
+ const candidate = join5(dir, `${name}${ext}`);
756
+ if (exists(candidate))
757
+ return candidate;
758
+ }
679
759
  }
680
- }
681
- return name;
760
+ return null;
761
+ };
762
+ return scan(pathEnv.split(isWin ? ";" : ":")) ?? scan(opts.extraDirs ?? []) ?? name;
682
763
  }
683
- var WIN_EXTS;
764
+ var WIN_EXTS, BIN_OVERRIDE_ENV;
684
765
  var init_resolve_executable = __esm(() => {
685
- WIN_EXTS = [".exe", ".cmd", ".bat", ""];
766
+ WIN_EXTS = [".exe", ".cmd", ".bat", ".ps1", ""];
767
+ BIN_OVERRIDE_ENV = {
768
+ claude: "RUNCASTLE_CLAUDE_BIN",
769
+ node: "RUNCASTLE_NODE_BIN"
770
+ };
686
771
  });
687
772
 
688
773
  // src/pty/pty-sidecar.ts
@@ -699,7 +784,7 @@ function resolveNodeExecutable() {
699
784
  return override;
700
785
  if (!isBun())
701
786
  return process.execPath;
702
- return resolveExecutable("node", { exts: process.platform === "win32" ? [".exe", ".cmd", ""] : [""] });
787
+ return resolveTool("node", { exts: process.platform === "win32" ? [".exe", ".cmd", ""] : [""] });
703
788
  }
704
789
  function resolveNodePtyEntry() {
705
790
  try {
@@ -767,11 +852,17 @@ function createSidecarPtySession(cmd, args, opts) {
767
852
  case "exit":
768
853
  fireExit(typeof msg.code === "number" ? msg.code : 0, msg.signal ?? undefined);
769
854
  break;
770
- case "error":
771
- process.stderr.write(`[pty-sidecar] host error: ${msg.message ?? "unknown"}
855
+ case "error": {
856
+ const why = explainSpawnFailure(cmd, msg.message ?? "unknown");
857
+ process.stderr.write(`[pty-sidecar] host error: ${why}
772
858
  `);
859
+ emitData(Buffer.from(`\r
860
+ ${why.replace(/\n/g, `\r
861
+ `)}\r
862
+ `, "utf8"));
773
863
  fireExit(1);
774
864
  break;
865
+ }
775
866
  }
776
867
  }
777
868
  });
@@ -780,8 +871,13 @@ function createSidecarPtySession(cmd, args, opts) {
780
871
  process.stderr.write(chunk);
781
872
  });
782
873
  child.on("error", (err) => {
783
- process.stderr.write(`[pty-sidecar] failed to spawn node host: ${err.message}
874
+ const why = explainSpawnFailure(nodeExe, err.message);
875
+ process.stderr.write(`[pty-sidecar] failed to spawn node host: ${why}
784
876
  `);
877
+ emitData(Buffer.from(`\r
878
+ ${why.replace(/\n/g, `\r
879
+ `)}\r
880
+ `, "utf8"));
785
881
  fireExit(1);
786
882
  });
787
883
  child.on("exit", (code, signal) => {
@@ -916,14 +1012,19 @@ function createPtySession(cmd, args, opts) {
916
1012
  }
917
1013
  function createNativePtySession(cmd, args, opts) {
918
1014
  const pty = loadNodePty();
919
- const proc = pty.spawn(cmd, args, {
920
- name: "xterm-256color",
921
- cols: opts.cols ?? 80,
922
- rows: opts.rows ?? 24,
923
- cwd: opts.cwd,
924
- env: cleanEnv(opts.env),
925
- useConpty: opts.useConpty ?? true
926
- });
1015
+ let proc;
1016
+ try {
1017
+ proc = pty.spawn(cmd, args, {
1018
+ name: "xterm-256color",
1019
+ cols: opts.cols ?? 80,
1020
+ rows: opts.rows ?? 24,
1021
+ cwd: opts.cwd,
1022
+ env: cleanEnv(opts.env),
1023
+ useConpty: opts.useConpty ?? true
1024
+ });
1025
+ } catch (err) {
1026
+ throw new Error(explainSpawnFailure(cmd, err instanceof Error ? err.message : String(err)));
1027
+ }
927
1028
  let killed = false;
928
1029
  proc.onExit(() => {
929
1030
  killed = true;
@@ -958,6 +1059,7 @@ function createNativePtySession(cmd, args, opts) {
958
1059
  }
959
1060
  var cached = null, BACKEND_LOGGED;
960
1061
  var init_pty = __esm(() => {
1062
+ init_resolve_executable();
961
1063
  init_pty_sidecar();
962
1064
  BACKEND_LOGGED = Symbol.for("runcastle.pty.backend.logged");
963
1065
  });
@@ -1698,7 +1800,7 @@ var init_feature_docs = __esm(() => {
1698
1800
  });
1699
1801
 
1700
1802
  // src/services/tickets.ts
1701
- import { asc as asc3, eq as eq5 } from "drizzle-orm";
1803
+ import { and as and5, asc as asc3, eq as eq5 } from "drizzle-orm";
1702
1804
  function rowToTicket(row) {
1703
1805
  return {
1704
1806
  id: row.id,
@@ -1808,6 +1910,19 @@ function cancelTicket(ctx, id, reason) {
1808
1910
  });
1809
1911
  return getTicket(ctx, id);
1810
1912
  }
1913
+ function sweepOrphanedBurning(ctx, featureId, reason) {
1914
+ const orphaned = ctx.db.select().from(tickets).where(and5(eq5(tickets.featureId, featureId), eq5(tickets.status, "burning"))).all().map(rowToTicket);
1915
+ for (const t of orphaned) {
1916
+ updateTicket(ctx, t.id, { status: "failed", error: reason });
1917
+ emit(ctx, featureId, {
1918
+ type: "ticket.failed",
1919
+ message: `ticket ${t.seq} failed: ${reason}`,
1920
+ ticketId: t.id,
1921
+ data: { error: reason, orphaned: true }
1922
+ });
1923
+ }
1924
+ return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
1925
+ }
1811
1926
  function updateTicket(ctx, id, patch) {
1812
1927
  const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1813
1928
  if (!current2)
@@ -2200,6 +2315,34 @@ function researchBranchName(slug, waypointSeq, unique) {
2200
2315
  function ticketBranchName(slug, ticketSeq, unique) {
2201
2316
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2202
2317
  }
2318
+ function burnWorktreePath(repoPath, branch) {
2319
+ return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2320
+ }
2321
+ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2322
+ const path = burnWorktreePath(repoPath, branch);
2323
+ const attempts = Math.max(1, opts.attempts ?? 3);
2324
+ const delayMs = opts.delayMs ?? 750;
2325
+ const g = git(repoPath);
2326
+ for (let attempt = 1;attempt <= attempts && existsSync7(path); attempt++) {
2327
+ try {
2328
+ await g.raw(["worktree", "remove", "--force", path]);
2329
+ } catch {}
2330
+ if (existsSync7(path) && attempt < attempts)
2331
+ await sleep(delayMs);
2332
+ }
2333
+ if (existsSync7(path)) {
2334
+ try {
2335
+ rmSync(path, { recursive: true, force: true });
2336
+ } catch {}
2337
+ }
2338
+ try {
2339
+ await g.raw(["worktree", "prune"]);
2340
+ } catch {}
2341
+ return !existsSync7(path);
2342
+ }
2343
+ function sleep(ms) {
2344
+ return new Promise((done) => setTimeout(done, ms));
2345
+ }
2203
2346
  async function branchCommitsAhead(repoPath, base, branch) {
2204
2347
  try {
2205
2348
  const out = await git(repoPath).raw(["rev-list", "--reverse", `${base}..${branch}`]);
@@ -2786,6 +2929,89 @@ var init_agent_stream = __esm(() => {
2786
2929
  transcripts = new Map;
2787
2930
  });
2788
2931
 
2932
+ // src/workflows/burn-guard.ts
2933
+ function renderGuardScript(rules = GUARD_RULES) {
2934
+ const lines = [
2935
+ "#!/bin/sh",
2936
+ "# runcastle burn guard (PreToolUse). Generated \u2014 edit burn-guard.ts, not this.",
2937
+ "set -u",
2938
+ "payload=$(cat 2>/dev/null || true)",
2939
+ '[ -n "$payload" ] || exit 0',
2940
+ "command -v jq >/dev/null 2>&1 || exit 0",
2941
+ 'tool=$(printf %s "$payload" | jq -r ".tool_name // empty" 2>/dev/null || true)',
2942
+ '[ "$tool" = "Bash" ] || exit 0',
2943
+ 'cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
2944
+ '[ -n "$cmd" ] || exit 0',
2945
+ "",
2946
+ "# Match against the command with quoted spans blanked, so an argument is",
2947
+ '# never read as a command: `grep -rn "git stash" docs/` searches for a',
2948
+ "# string, it does not run one.",
2949
+ `cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g")`,
2950
+ "",
2951
+ "deny() {",
2952
+ ` printf %s "$1" | jq -R -s '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:.}}'`,
2953
+ " exit 0",
2954
+ "}",
2955
+ ""
2956
+ ];
2957
+ for (const rule of rules) {
2958
+ lines.push(`# ${rule.id}`, `if printf %s "$cmd" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
2959
+ }
2960
+ lines.push("exit 0", "");
2961
+ return lines.join(`
2962
+ `);
2963
+ }
2964
+ function shSingleQuote(value) {
2965
+ return `'${value.split("'").join(`'\\''`)}'`;
2966
+ }
2967
+ function renderGuardSettings() {
2968
+ return {
2969
+ hooks: {
2970
+ PreToolUse: [
2971
+ {
2972
+ matcher: "Bash",
2973
+ hooks: [{ type: "command", command: `sh ${GUARD_SCRIPT_PATH}`, timeout: 5 }]
2974
+ }
2975
+ ]
2976
+ }
2977
+ };
2978
+ }
2979
+ function buildGuardInstallCommand() {
2980
+ const script = Buffer.from(renderGuardScript(), "utf8").toString("base64");
2981
+ const settings = Buffer.from(JSON.stringify(renderGuardSettings(), null, 2), "utf8").toString("base64");
2982
+ return [
2983
+ `mkdir -p "$HOME/.claude/hooks"`,
2984
+ `printf %s '${script}' | base64 -d > "${GUARD_SCRIPT_PATH}"`,
2985
+ `chmod +x "${GUARD_SCRIPT_PATH}"`,
2986
+ `printf %s '${settings}' | base64 -d > "${GUARD_SETTINGS_PATH}"`
2987
+ ].join(" && ");
2988
+ }
2989
+ var CMD_START = "(^|[;&|(]|&&)[[:space:]]*", GUARD_RULES, GUARD_SCRIPT_PATH = "$HOME/.claude/hooks/burn-guard.sh", GUARD_SETTINGS_PATH = "$HOME/.claude/settings.json";
2990
+ var init_burn_guard = __esm(() => {
2991
+ GUARD_RULES = [
2992
+ {
2993
+ id: "no-stash",
2994
+ pattern: `${CMD_START}git[[:space:]]+stash`,
2995
+ reason: "git stash is blocked in burns: stashed work is invisible to the orchestrator and unrecoverable if this process dies, and only commits survive. To compare against the pre-change state use `git show HEAD:<path>`, or the pre-existing-failure baseline in your prompt."
2996
+ },
2997
+ {
2998
+ id: "no-serialised-tests",
2999
+ pattern: "--maxWorkers|--runInBand|--pool=|--shard=|singleFork|--poolOptions",
3000
+ reason: `Do not override the test runner's concurrency. Measured in this sandbox, a suite that runs in ~55s at its configured concurrency takes 10-20 minutes serialised, so this "safe" flag is the single most expensive habit in a burn. Run the repo's test command as configured. If it is killed for memory, run only the test files your change touches and say so in your final message.`
3001
+ },
3002
+ {
3003
+ id: "no-interpreter-heredoc-edit",
3004
+ pattern: `${CMD_START}(python3?|node|bun|perl|ruby)[[:space:]]+-[[:space:]]*<<`,
3005
+ reason: "Do not rewrite files by piping a heredoc into an interpreter \u2014 individual such calls have been measured at 29s, 57s, 120s and 761s, and a half-written file survives if this process dies mid-command. Use the Edit tool, which is faster, atomic, and fails loudly when its target text is not found."
3006
+ },
3007
+ {
3008
+ id: "no-cat-heredoc-edit",
3009
+ pattern: `${CMD_START}cat[[:space:]]+>>?[[:space:]]*[^[:space:]|]+[[:space:]]*<<`,
3010
+ reason: "Do not write files with `cat > file <<EOF`. Use the Write tool for a new file, or Edit to change part of an existing one."
3011
+ }
3012
+ ];
3013
+ });
3014
+
2789
3015
  // src/workflows/ticket-burner.ts
2790
3016
  import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2791
3017
  import { dirname as dirname3, join as join9 } from "path";
@@ -2992,6 +3218,24 @@ function buildWorkspaceNotes(mode) {
2992
3218
  ].join(`
2993
3219
  `);
2994
3220
  }
3221
+ function buildVerifyNotes(config2) {
3222
+ const commands = config2.verifyCommands?.trim();
3223
+ const failures = config2.knownFailures?.trim();
3224
+ const out = [];
3225
+ if (commands) {
3226
+ out.push("Verify with exactly these commands \u2014 they are correct for this repo, so do not go looking for alternatives or guess at workspace/package filter names:", "", "```", commands, "```");
3227
+ } else {
3228
+ out.push("No verification commands are configured, so derive them ONCE from the repo (root `package.json` scripts, the workspace's own manifest, CI config) before your first test run \u2014 reading a manifest is free, discovering a filter name by running the wrong suite is not. Write the working commands into your notes and reuse them verbatim for the rest of the ticket.");
3229
+ }
3230
+ out.push("");
3231
+ if (failures) {
3232
+ out.push("These tests ALREADY fail on this repo before you touch anything:", "", "```", failures, "```", "", "Treat that as your baseline \u2014 do NOT spend a run establishing it yourself. Only a failure outside this set is yours to fix; if you hit one that looks pre-existing but is not listed, confirm it on a single targeted run of that one test, never a whole-suite re-run.");
3233
+ } else {
3234
+ out.push("No pre-existing-failure baseline is configured. If the suite is already red, capture the baseline ONCE \u2014 the first full run you do, before your changes land, is the baseline \u2014 and compare every later run against those saved results. Never re-run a whole suite just to re-establish what was already failing.");
3235
+ }
3236
+ return out.join(`
3237
+ `);
3238
+ }
2995
3239
  function readRepoToolchain(repoPath) {
2996
3240
  const pkgPath = join9(repoPath, "package.json");
2997
3241
  const hasPackageJson = existsSync10(pkgPath);
@@ -3048,6 +3292,63 @@ function createStreamThrottle(emit2, opts = {}) {
3048
3292
  };
3049
3293
  return { onEvent, flush: flushText };
3050
3294
  }
3295
+ function normalizeCommandForClassification(command) {
3296
+ return command.replace(/<<\s*['"]?(\w+)['"]?[\s\S]*?(^|\n)\s*\1\b/gm, "<<$1 ").replace(/<<\s*['"]?(\w+)['"]?[\s\S]*$/, "<<$1 ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
3297
+ }
3298
+ function classifyToolCall(name, args) {
3299
+ const byName = TOOL_NAME_CATEGORY[name];
3300
+ if (byName)
3301
+ return byName;
3302
+ if (name !== "Bash")
3303
+ return "other";
3304
+ const normalized = normalizeCommandForClassification(args);
3305
+ for (const [category, pattern] of BASH_PATTERNS) {
3306
+ if (pattern.test(normalized))
3307
+ return category;
3308
+ }
3309
+ return "other";
3310
+ }
3311
+ function createToolTimer() {
3312
+ const byCategory = {};
3313
+ let calls = 0;
3314
+ let totalMs = 0;
3315
+ let pending = null;
3316
+ let iteration = null;
3317
+ const charge = (category, ms) => {
3318
+ const slot = byCategory[category] ??= { calls: 0, ms: 0 };
3319
+ slot.ms += ms;
3320
+ totalMs += ms;
3321
+ };
3322
+ const onEvent = (event) => {
3323
+ if (event.type === "raw")
3324
+ return;
3325
+ const at = event.timestamp.getTime();
3326
+ if (iteration !== null && event.iteration !== iteration)
3327
+ pending = null;
3328
+ iteration = event.iteration;
3329
+ if (pending) {
3330
+ const gap = at - pending.at;
3331
+ if (gap > 0 && gap <= MAX_ATTRIBUTABLE_GAP_MS)
3332
+ charge(pending.category, gap);
3333
+ }
3334
+ if (event.type === "toolCall") {
3335
+ const category = classifyToolCall(event.name, event.formattedArgs ?? "");
3336
+ const slot = byCategory[category] ??= { calls: 0, ms: 0 };
3337
+ slot.calls += 1;
3338
+ calls += 1;
3339
+ pending = { category, at };
3340
+ } else {
3341
+ pending = { category: "model", at };
3342
+ }
3343
+ };
3344
+ return { onEvent, summary: () => ({ totalMs, calls, byCategory }) };
3345
+ }
3346
+ function formatTimingSummary(s) {
3347
+ if (s.totalMs === 0)
3348
+ return `${s.calls} tool call(s), no measurable time`;
3349
+ const parts = Object.entries(s.byCategory).sort((a, b) => (b[1]?.ms ?? 0) - (a[1]?.ms ?? 0)).slice(0, 5).map(([c, t]) => `${c} ${Math.round((t?.ms ?? 0) / s.totalMs * 100)}%`);
3350
+ return `${Math.round(s.totalMs / 60000)}min across ${s.calls} tool call(s) \u2014 ${parts.join(", ")}`;
3351
+ }
3051
3352
  function interpretRunResult(result, blockedContent) {
3052
3353
  const commits = result.commits.map((c) => c.sha);
3053
3354
  if (commits.length > 0)
@@ -3058,6 +3359,12 @@ ${blockedContent.trim()}` };
3058
3359
  }
3059
3360
  return { status: "failed", error: "agent made no commits" };
3060
3361
  }
3362
+ function isWorktreeTeardownError(err) {
3363
+ const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
3364
+ if (!BURN_WORKTREE_PATH.test(msg))
3365
+ return false;
3366
+ return WORKTREE_REMOVAL_FAILURES.some((p) => p.test(msg));
3367
+ }
3061
3368
  function isMergeConflictError(err) {
3062
3369
  const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
3063
3370
  return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
@@ -3357,10 +3664,7 @@ function buildBurnAgent(config2, token, model) {
3357
3664
  return agent;
3358
3665
  }
3359
3666
  function selectSandbox(config2, mounts = []) {
3360
- const imageOpts = {
3361
- imageName: resolveSandboxImage(config2),
3362
- ...mounts.length > 0 ? { mounts } : {}
3363
- };
3667
+ const imageOpts = buildSandboxOptions(config2, mounts);
3364
3668
  switch (config2.sandbox) {
3365
3669
  case "docker":
3366
3670
  return docker(imageOpts);
@@ -3370,6 +3674,13 @@ function selectSandbox(config2, mounts = []) {
3370
3674
  return noSandbox();
3371
3675
  }
3372
3676
  }
3677
+ function buildSandboxOptions(config2, mounts = []) {
3678
+ return {
3679
+ imageName: resolveSandboxImage(config2),
3680
+ ...mounts.length > 0 ? { mounts } : {},
3681
+ ...config2.burnCpus !== undefined ? { cpus: config2.burnCpus } : {}
3682
+ };
3683
+ }
3373
3684
  async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget) {
3374
3685
  const { project, feature } = ctx;
3375
3686
  const workspaceMode = resolveBurnWorkspaceMode(config2);
@@ -3379,11 +3690,13 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3379
3690
  const featureBrief = buildFeatureBrief(feature);
3380
3691
  const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3381
3692
  const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3693
+ const verifyNotes = buildVerifyNotes(config2);
3382
3694
  const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3383
3695
  TICKET_JSON: ticketJson,
3384
3696
  FEATURE_BRIEF: featureBrief,
3385
3697
  DOCS_DIGEST: docsDigest,
3386
3698
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3699
+ VERIFY_NOTES: verifyNotes,
3387
3700
  WORKSPACE_NOTES: workspaceNotes
3388
3701
  });
3389
3702
  const toolchain = readRepoToolchain(project.repoPath);
@@ -3397,12 +3710,16 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3397
3710
  mounts.push(mount);
3398
3711
  }
3399
3712
  }
3713
+ const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
3714
+ const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
3400
3715
  mkdirSync4(logsDir(), { recursive: true });
3401
3716
  const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3402
3717
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3403
3718
  beginTranscript(ticket.id);
3719
+ const timer = createToolTimer();
3404
3720
  const onStreamEvent = (event) => {
3405
3721
  throttle.onEvent(event);
3722
+ timer.onEvent(event);
3406
3723
  if (event.type === "text") {
3407
3724
  appendTranscript(ticket.id, { kind: "text", text: event.message });
3408
3725
  } else if (event.type === "toolCall") {
@@ -3447,12 +3764,13 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3447
3764
  FEATURE_BRIEF: featureBrief,
3448
3765
  DOCS_DIGEST: docsDigest,
3449
3766
  WORKSPACE_NOTES: workspaceNotes,
3767
+ VERIFY_NOTES: verifyNotes,
3450
3768
  FEATURE_BRANCH: feature.branch,
3451
3769
  CONFLICT_FILES: buildConflictFilesBlock(input.files),
3452
3770
  OTHER_SIDE: buildOtherSideBlock(otherSide),
3453
3771
  MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
3454
3772
  });
3455
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
3773
+ const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand);
3456
3774
  appendTranscript(ticket.id, {
3457
3775
  kind: "text",
3458
3776
  text: `
@@ -3486,13 +3804,18 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3486
3804
  } catch (err) {
3487
3805
  if (ctx.signal.aborted)
3488
3806
  throw err;
3489
- return {
3490
- ok: false,
3491
- branch: await bestTip(),
3492
- error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3493
- };
3807
+ const tip = await bestTip();
3808
+ const teardownOnly = !ticketAbort.signal.aborted && isWorktreeTeardownError(err) && tip === resolveBranch;
3809
+ if (!teardownOnly) {
3810
+ return {
3811
+ ok: false,
3812
+ branch: tip,
3813
+ error: ticketAbort.signal.aborted ? "stopped by user during conflict resolution" : errorHeadline(err instanceof Error ? err.message : String(err))
3814
+ };
3815
+ }
3816
+ await cleanupBurnWorktree(project.repoPath, resolveBranch);
3494
3817
  }
3495
- const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
3818
+ const blocked = readBlockedFile([result?.preservedWorktreePath, project.repoPath]);
3496
3819
  if (blocked !== undefined && blocked.trim().length > 0) {
3497
3820
  return {
3498
3821
  ok: false,
@@ -3577,7 +3900,7 @@ ${blocked.trim()}`
3577
3900
  let tempBranch = "";
3578
3901
  for (let attempt = 1;; attempt++) {
3579
3902
  tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
3580
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand;
3903
+ const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand);
3581
3904
  if (hookCommand && attempt === 1) {
3582
3905
  ctx.emitEvent({
3583
3906
  type: "burn.setup",
@@ -3627,6 +3950,18 @@ ${retryNotes}` : basePrompt,
3627
3950
  }
3628
3951
  };
3629
3952
  }
3953
+ if (isWorktreeTeardownError(err)) {
3954
+ const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
3955
+ if (salvaged.length > 0) {
3956
+ ctx.emitEvent({
3957
+ type: "burn.worktree.teardown-failed",
3958
+ message: `ticket ${ticket.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
3959
+ ticketId: ticket.id,
3960
+ data: { branch: tempBranch, error: msg, cleanedUp: removed }
3961
+ });
3962
+ return await landChain(tempBranch, salvaged);
3963
+ }
3964
+ }
3630
3965
  if (isMergeConflictError(err)) {
3631
3966
  if (salvaged.length > 0) {
3632
3967
  preserveChain(tempBranch);
@@ -3700,6 +4035,15 @@ ${blocked.trim()}` };
3700
4035
  activeTicketAborts.delete(ticket.id);
3701
4036
  throttle.flush();
3702
4037
  endTranscript(ticket.id);
4038
+ const timing = timer.summary();
4039
+ if (timing.calls > 0) {
4040
+ ctx.emitEvent({
4041
+ type: "ticket.timing",
4042
+ message: `ticket ${ticket.seq} spent ${formatTimingSummary(timing)}`,
4043
+ ticketId: ticket.id,
4044
+ data: timing
4045
+ });
4046
+ }
3703
4047
  }
3704
4048
  }
3705
4049
  function resolveBurnDeps(ctx) {
@@ -3728,7 +4072,7 @@ function readTokenFromEnvFile(path) {
3728
4072
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3729
4073
  return token && token.length > 0 ? token : undefined;
3730
4074
  }
3731
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
4075
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3732
4076
  var init_ticket_burner = __esm(() => {
3733
4077
  init_src();
3734
4078
  init_config_load();
@@ -3736,12 +4080,43 @@ var init_ticket_burner = __esm(() => {
3736
4080
  init_skills_root();
3737
4081
  init_agent_stream();
3738
4082
  init_git();
4083
+ init_burn_guard();
3739
4084
  PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3740
4085
  PM_CACHE_SANDBOX_PATHS = {
3741
4086
  bun: "~/.bun/install/cache",
3742
4087
  yarn: "~/.cache/yarn",
3743
4088
  npm: "~/.npm"
3744
4089
  };
4090
+ RUN = String.raw`(?![\w./-])`;
4091
+ BASH_PATTERNS = [
4092
+ ["tests", new RegExp(String.raw`\b(vitest|jest|pytest|go test|cargo test|bun test|playwright|maestro)${RUN}|\b(pnpm|npm|yarn|bun|npx)\b[^&;|]*?\s(run\s+)?test${RUN}|--testPathPatterns`)],
4093
+ ["typecheck", /\btsc\b|\btypecheck\b|\bmypy\b|\bcargo check\b/],
4094
+ ["build", new RegExp(String.raw`\bbuild${RUN}|\bprisma\s+(generate|migrate)\b|\bcodegen${RUN}`)],
4095
+ ["lint", new RegExp(String.raw`\b(eslint|prettier|ruff|biome|lint|format)${RUN}`)],
4096
+ ["install", /\b(pnpm|npm|yarn|bun|corepack)\s+\w*\s*install\b|\bnpm ci\b/],
4097
+ ["git", /\bgit\b/],
4098
+ ["search", /\b(grep|rg|ag|find)\b/],
4099
+ ["file-read", /\b(cat|sed|head|tail|less|wc|ls)\b/],
4100
+ ["file-edit", /<<\s*['"]?(PY|EOF|SH|JS|TS)\b|\bpython3?\s+-\s*<</]
4101
+ ];
4102
+ TOOL_NAME_CATEGORY = {
4103
+ Read: "file-read",
4104
+ NotebookRead: "file-read",
4105
+ Grep: "search",
4106
+ Glob: "search",
4107
+ Edit: "file-edit",
4108
+ Write: "file-edit",
4109
+ NotebookEdit: "file-edit"
4110
+ };
4111
+ MAX_ATTRIBUTABLE_GAP_MS = 20 * 60000;
4112
+ BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
4113
+ WORKTREE_REMOVAL_FAILURES = [
4114
+ /failed to delete/i,
4115
+ /directory not empty|enotempty/i,
4116
+ /unable to (unlink|delete)/i,
4117
+ /is not a working tree/i,
4118
+ /resource busy|ebusy|eperm|eacces/i
4119
+ ];
3745
4120
  FATAL_ERROR_PATTERNS = [
3746
4121
  /invalid (api key|x-api-key)/i,
3747
4122
  /authentication|unauthorized|permission denied/i,
@@ -3879,16 +4254,26 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
3879
4254
  logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
3880
4255
  };
3881
4256
  let result;
4257
+ let salvaged = [];
3882
4258
  try {
3883
4259
  result = await run2(runOptions);
3884
4260
  } catch (err) {
3885
4261
  throttle.flush();
3886
4262
  if (ctx.signal.aborted)
3887
4263
  throw err;
3888
- return { status: "failed", error: err instanceof Error ? err.message : String(err) };
4264
+ const msg = err instanceof Error ? err.message : String(err);
4265
+ salvaged = isWorktreeTeardownError(err) ? await branchCommitsAhead(project.repoPath, feature.branch, tempBranch) : [];
4266
+ if (salvaged.length === 0)
4267
+ return { status: "failed", error: msg };
4268
+ const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
4269
+ ctx.emitEvent({
4270
+ type: "burn.worktree.teardown-failed",
4271
+ message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline2(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
4272
+ data: { tempBranch, error: msg, cleanedUp: removed }
4273
+ });
3889
4274
  }
3890
4275
  throttle.flush();
3891
- const outcome = interpretResearchResult(result, docRelPath);
4276
+ const outcome = result ? interpretResearchResult(result, docRelPath) : { status: "done", commits: salvaged, docRelPath };
3892
4277
  if (outcome.status !== "done")
3893
4278
  return outcome;
3894
4279
  const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
@@ -4024,7 +4409,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4024
4409
  },
4025
4410
  signal: controller.signal
4026
4411
  };
4027
- const done = executeRun(ctx, runId, featureId, def.run(wctx), controller, async () => {
4412
+ const done = executeRun(ctx, runId, featureId, workflowId, def.run(wctx), controller, async () => {
4028
4413
  if (talkDetached)
4029
4414
  await reattachWorktree(talkWorktree, feature.branch);
4030
4415
  });
@@ -4033,7 +4418,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4033
4418
  function cancelRun(runId) {
4034
4419
  controllers.get(runId)?.abort();
4035
4420
  }
4036
- async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup) {
4421
+ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
4037
4422
  let status = "failed";
4038
4423
  let summary = "run failed";
4039
4424
  try {
@@ -4054,6 +4439,9 @@ async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup
4054
4439
  }
4055
4440
  ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
4056
4441
  releaseForSession(ctx, runId);
4442
+ if (workflowClaimsFeatureBranch(workflow2)) {
4443
+ sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
4444
+ }
4057
4445
  emit(ctx, featureId, {
4058
4446
  type: "run.finished",
4059
4447
  message: `run ${status}: ${summary}`,
@@ -4195,7 +4583,7 @@ function advance(ctx, featureId) {
4195
4583
  async function burn(ctx, featureId, opts = {}) {
4196
4584
  const feature = getFeatureRow(ctx, featureId);
4197
4585
  const running = hasActiveRun(ctx, featureId);
4198
- const tickets2 = listByFeature2(ctx, featureId);
4586
+ let tickets2 = listByFeature2(ctx, featureId);
4199
4587
  const pending = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
4200
4588
  const restarting = feature.phase === "implementation" && !running;
4201
4589
  const iterating = feature.phase === "review" && !running && pending.length >= 1;
@@ -4213,6 +4601,8 @@ async function burn(ctx, featureId, opts = {}) {
4213
4601
  throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
4214
4602
  }
4215
4603
  if (restarting) {
4604
+ sweepOrphanedBurning(ctx, featureId, "orphaned \u2014 the previous run died while it was burning");
4605
+ tickets2 = listByFeature2(ctx, featureId);
4216
4606
  const failed = opts.resetFailed === false ? [] : tickets2.filter((t) => t.status === "failed");
4217
4607
  for (const t of failed) {
4218
4608
  updateTicket(ctx, t.id, { status: "pending", error: null });
@@ -5316,7 +5706,7 @@ var init_artifacts = __esm(() => {
5316
5706
  import { existsSync as existsSync13 } from "fs";
5317
5707
  import { dirname as dirname5, join as join13, resolve as resolve7 } from "path";
5318
5708
  import { fileURLToPath as fileURLToPath5 } from "url";
5319
- import { and as and5, eq as eq8 } from "drizzle-orm";
5709
+ import { and as and6, eq as eq8 } from "drizzle-orm";
5320
5710
  function ptyExitMessage(exitCode) {
5321
5711
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
5322
5712
  return `terminal exited (code ${label})`;
@@ -5342,14 +5732,10 @@ function buildClaudeArgs(input) {
5342
5732
  ];
5343
5733
  }
5344
5734
  function resolveClaudeExecutable() {
5345
- return resolveExecutable("claude", { override: process.env.RUNCASTLE_CLAUDE_BIN });
5735
+ return resolveTool("claude");
5346
5736
  }
5347
5737
  function claudeSpawnTarget(claudeArgs) {
5348
- const exe = resolveClaudeExecutable();
5349
- if (/\.(cmd|bat)$/i.test(exe)) {
5350
- return { file: process.env.ComSpec ?? "cmd.exe", args: ["/c", exe, ...claudeArgs] };
5351
- }
5352
- return { file: exe, args: claudeArgs };
5738
+ return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
5353
5739
  }
5354
5740
  function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5355
5741
  const rel = join13("packages", "skills", "packs", "runcastle");
@@ -5377,7 +5763,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5377
5763
  `)}`);
5378
5764
  }
5379
5765
  function activeRunFor(ctx, featureId) {
5380
- const row = ctx.db.select().from(runs).where(and5(eq8(runs.featureId, featureId), eq8(runs.status, "running"))).limit(1).get();
5766
+ const row = ctx.db.select().from(runs).where(and6(eq8(runs.featureId, featureId), eq8(runs.status, "running"))).limit(1).get();
5381
5767
  return row ? rowToRun(row) : null;
5382
5768
  }
5383
5769
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -6264,6 +6650,14 @@ var init_settings = __esm(() => {
6264
6650
  valueSchema: z9.number().int().min(0).max(3),
6265
6651
  parseEnv: (raw) => Number(raw)
6266
6652
  },
6653
+ {
6654
+ key: "burnCpus",
6655
+ configKey: "burnCpus",
6656
+ envVar: "RUNCASTLE_BURN_CPUS",
6657
+ restartRequired: false,
6658
+ valueSchema: z9.number().positive().max(256),
6659
+ parseEnv: (raw) => Number(raw)
6660
+ },
6267
6661
  {
6268
6662
  key: "setupCommand",
6269
6663
  configKey: "setupCommand",
@@ -6272,6 +6666,22 @@ var init_settings = __esm(() => {
6272
6666
  valueSchema: z9.string().min(1),
6273
6667
  parseEnv: idEnv
6274
6668
  },
6669
+ {
6670
+ key: "verifyCommands",
6671
+ configKey: "verifyCommands",
6672
+ envVar: "RUNCASTLE_VERIFY_COMMANDS",
6673
+ restartRequired: false,
6674
+ valueSchema: z9.string().min(1),
6675
+ parseEnv: idEnv
6676
+ },
6677
+ {
6678
+ key: "knownFailures",
6679
+ configKey: "knownFailures",
6680
+ envVar: "RUNCASTLE_KNOWN_FAILURES",
6681
+ restartRequired: false,
6682
+ valueSchema: z9.string().min(1),
6683
+ parseEnv: idEnv
6684
+ },
6275
6685
  {
6276
6686
  key: "mainBranch",
6277
6687
  configKey: "mainBranch",
@@ -6470,10 +6880,7 @@ var init_doctor = __esm(() => {
6470
6880
  import { spawn as spawn2 } from "child_process";
6471
6881
  function createSystemExec(opts = {}) {
6472
6882
  return (command, args) => new Promise((resolve9) => {
6473
- const resolved = resolveExecutable(command);
6474
- const isShim = /\.(cmd|bat)$/i.test(resolved);
6475
- const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
6476
- const spawnArgs = isShim ? ["/c", resolved, ...args] : args;
6883
+ const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
6477
6884
  let stdout = "";
6478
6885
  let stderr = "";
6479
6886
  const child = spawn2(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
@@ -6624,12 +7031,29 @@ function prepareSandboxBuildContext() {
6624
7031
  }
6625
7032
  function createTokenVerifier(exec) {
6626
7033
  return async (token) => {
7034
+ if (token.length < 8) {
7035
+ return {
7036
+ valid: false,
7037
+ detail: "token looks malformed (too short) \u2014 saved to ~/.runcastle/.env anyway",
7038
+ fix: "Re-run `claude setup-token` and paste the whole line it prints (it starts with `sk-ant-oat`)."
7039
+ };
7040
+ }
6627
7041
  const out = await exec("claude", ["--version"]);
6628
- if (!(out.ok && out.code === 0)) {
6629
- return { valid: false, detail: "claude CLI not found \u2014 cannot verify the token" };
7042
+ if (!out.ok) {
7043
+ return {
7044
+ valid: false,
7045
+ detail: "Token saved to ~/.runcastle/.env, but runcastle could not launch `claude` to verify it. " + "That is a PATH problem in this server process, not a missing install \u2014 a terminal that finds " + "`claude` proves nothing about the PATH runcastle was started with.",
7046
+ fix: "Quit runcastle and start it again from a terminal where `claude --version` works. " + "If it still fails, pin the path: set RUNCASTLE_CLAUDE_BIN to the full path from " + "`where.exe claude` (Windows) or `which claude` (macOS/Linux), then restart runcastle."
7047
+ };
6630
7048
  }
6631
- if (token.length < 8) {
6632
- return { valid: false, detail: "token looks malformed (too short)" };
7049
+ if (out.code !== 0) {
7050
+ const why = (out.stderr.trim() || out.stdout.trim()).split(`
7051
+ `)[0] ?? "";
7052
+ return {
7053
+ valid: false,
7054
+ detail: `Token saved to ~/.runcastle/.env, but \`claude --version\` exited ${out.code}` + `${why ? `: ${why}` : " with no output"}.`,
7055
+ fix: "Run `claude --version` yourself and fix what it reports \u2014 the Claude Code install is broken, not the token."
7056
+ };
6633
7057
  }
6634
7058
  return { valid: true, detail: "token captured to ~/.runcastle/.env" };
6635
7059
  };
@@ -6681,10 +7105,7 @@ var init_setup2 = __esm(() => {
6681
7105
  const spec = terminalSpec(input.kind, { runtime, imageName, sandcastleBin });
6682
7106
  const sessionId = newId("setup");
6683
7107
  const cwd = input.kind === "build-image" ? prepareSandboxBuildContext() : process.cwd();
6684
- const resolved = resolveExecutable(spec.cmd);
6685
- const isShim = /\.(cmd|bat)$/i.test(resolved);
6686
- const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
6687
- const args = isShim ? ["/c", resolved, ...spec.args] : spec.args;
7108
+ const { file, args } = resolveSpawnTarget(spec.cmd, spec.args);
6688
7109
  ptyRegistry().create({
6689
7110
  sessionId,
6690
7111
  cmd: file,
@@ -6814,12 +7235,22 @@ import * as z12 from "zod";
6814
7235
  var ticketRouter;
6815
7236
  var init_ticket = __esm(() => {
6816
7237
  init_features();
7238
+ init_repo();
6817
7239
  init_tickets();
6818
7240
  init_ticket_burner();
6819
7241
  init_context();
6820
7242
  ticketRouter = router({
6821
7243
  retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
6822
- stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ input }) => ({ stopped: stopTicketRun(input.ticketId) })),
7244
+ stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ ctx, input }) => {
7245
+ if (stopTicketRun(input.ticketId))
7246
+ return { stopped: true, swept: false };
7247
+ const ticket = getTicket(ctx, input.ticketId);
7248
+ if (ticket.status === "burning" && !hasActiveRun(ctx, ticket.featureId)) {
7249
+ sweepOrphanedBurning(ctx, ticket.featureId, "orphaned \u2014 the run that was burning it is gone");
7250
+ return { stopped: false, swept: true };
7251
+ }
7252
+ return { stopped: false, swept: false };
7253
+ }),
6823
7254
  cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
6824
7255
  });
6825
7256
  });
@@ -6860,6 +7291,7 @@ async function reconcileStaleRuns(ctx) {
6860
7291
  continue;
6861
7292
  ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq11(runs.id, run3.id)).run();
6862
7293
  const released = releaseForSession(ctx, run3.id);
7294
+ const swept = workflowClaimsFeatureBranch(run3.workflow) ? sweepOrphanedBurning(ctx, run3.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
6863
7295
  if (workflowClaimsFeatureBranch(run3.workflow)) {
6864
7296
  const feature = tryGetFeature(ctx, run3.featureId);
6865
7297
  const project = feature ? getProjectById(ctx, feature.projectId) : null;
@@ -6876,7 +7308,8 @@ async function reconcileStaleRuns(ctx) {
6876
7308
  data: {
6877
7309
  runId: run3.id,
6878
7310
  workflow: run3.workflow,
6879
- releasedWaypointIds: released.map((w) => w.id)
7311
+ releasedWaypointIds: released.map((w) => w.id),
7312
+ sweptTicketSeqs: swept.map((t2) => t2.seq)
6880
7313
  }
6881
7314
  });
6882
7315
  reconciled.push(run3);
@@ -6894,6 +7327,7 @@ var init_reconcile_runs = __esm(() => {
6894
7327
  init_events();
6895
7328
  init_git();
6896
7329
  init_repo();
7330
+ init_tickets();
6897
7331
  init_waypoints();
6898
7332
  init_runner();
6899
7333
  });