runcastle 1.0.23 → 1.0.25

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
@@ -68,6 +68,20 @@ import * as z from "zod";
68
68
  function resolveSandboxImage(config) {
69
69
  return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
70
70
  }
71
+ function resolvePreparedSettings(config, project) {
72
+ const pick = (a, b) => {
73
+ const own = a?.trim();
74
+ if (own)
75
+ return own;
76
+ const inherited = b?.trim();
77
+ return inherited || undefined;
78
+ };
79
+ return {
80
+ setupCommand: pick(project?.setupCommand, config.setupCommand),
81
+ verifyCommands: pick(project?.verifyCommands, config.verifyCommands),
82
+ knownFailures: pick(project?.knownFailures, config.knownFailures)
83
+ };
84
+ }
71
85
  function resolveModel(step, config, project, runOverride) {
72
86
  return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
73
87
  }
@@ -93,6 +107,7 @@ var init_config = __esm(() => {
93
107
  "revisit",
94
108
  "research",
95
109
  "implement",
110
+ "prepare",
96
111
  "smoke"
97
112
  ];
98
113
  ModelStep = z.enum(MODEL_STEPS);
@@ -104,10 +119,15 @@ var init_config = __esm(() => {
104
119
  mainBranch: z.string().default("main"),
105
120
  sandboxImage: z.string().optional(),
106
121
  burnConcurrency: z.number().int().min(1).max(8).default(3),
122
+ burnCpus: z.number().positive().max(256).optional(),
123
+ burnGuard: z.boolean().default(true),
107
124
  burnMaxIterations: z.number().int().min(1).max(10).default(3),
108
125
  burnAttempts: z.number().int().min(1).max(5).default(3),
109
126
  burnConflictAttempts: z.number().int().min(0).max(3).default(2),
127
+ autoPrepare: z.boolean().default(true),
110
128
  setupCommand: z.string().optional(),
129
+ verifyCommands: z.string().optional(),
130
+ knownFailures: z.string().optional(),
111
131
  burnWorkspace: z.enum(["auto", "mounted", "isolated"]).default("auto")
112
132
  }));
113
133
  });
@@ -153,8 +173,17 @@ function loadConfig(env = process.env) {
153
173
  if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
154
174
  overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
155
175
  }
176
+ if (env.RUNCASTLE_BURN_CPUS)
177
+ overrides.burnCpus = Number(env.RUNCASTLE_BURN_CPUS);
178
+ if (env.RUNCASTLE_BURN_GUARD !== undefined) {
179
+ overrides.burnGuard = env.RUNCASTLE_BURN_GUARD !== "0" && env.RUNCASTLE_BURN_GUARD !== "false";
180
+ }
156
181
  if (env.RUNCASTLE_SETUP_COMMAND)
157
182
  overrides.setupCommand = env.RUNCASTLE_SETUP_COMMAND;
183
+ if (env.RUNCASTLE_VERIFY_COMMANDS)
184
+ overrides.verifyCommands = env.RUNCASTLE_VERIFY_COMMANDS;
185
+ if (env.RUNCASTLE_KNOWN_FAILURES)
186
+ overrides.knownFailures = env.RUNCASTLE_KNOWN_FAILURES;
158
187
  if (env.RUNCASTLE_BURN_WORKSPACE)
159
188
  overrides.burnWorkspace = env.RUNCASTLE_BURN_WORKSPACE;
160
189
  const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
@@ -189,7 +218,7 @@ var init_ids = () => {};
189
218
 
190
219
  // ../core/src/schemas.ts
191
220
  import * as z2 from "zod";
192
- var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, Project, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
221
+ var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, PREPARED_KEYS, PreparedKey, FindingSource, Project, ProjectFinding, PrepStatus, PrepRun, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
193
222
  var init_schemas = __esm(() => {
194
223
  Phase = z2.enum([
195
224
  "ideation",
@@ -242,13 +271,44 @@ var init_schemas = __esm(() => {
242
271
  lastSessionId: z2.string().optional(),
243
272
  summary: z2.string().optional()
244
273
  });
274
+ PREPARED_KEYS = [
275
+ "setupCommand",
276
+ "verifyCommands",
277
+ "knownFailures",
278
+ "devCommand",
279
+ "dbResetCommand"
280
+ ];
281
+ PreparedKey = z2.enum(PREPARED_KEYS);
282
+ FindingSource = z2.enum(["prep", "human"]);
245
283
  Project = z2.object({
246
284
  id: z2.string(),
247
285
  name: z2.string(),
248
286
  repoPath: z2.string(),
249
287
  mainBranch: z2.string(),
250
288
  devCommand: z2.string().optional(),
251
- model: z2.string().optional()
289
+ model: z2.string().optional(),
290
+ setupCommand: z2.string().optional(),
291
+ verifyCommands: z2.string().optional(),
292
+ knownFailures: z2.string().optional(),
293
+ dbResetCommand: z2.string().optional()
294
+ });
295
+ ProjectFinding = z2.object({
296
+ key: PreparedKey,
297
+ source: FindingSource,
298
+ evidence: z2.string().optional(),
299
+ establishedAt: z2.number(),
300
+ establishedSha: z2.string().optional(),
301
+ staleCommits: z2.number().optional()
302
+ });
303
+ PrepStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
304
+ PrepRun = z2.object({
305
+ id: z2.string(),
306
+ projectId: z2.string(),
307
+ status: PrepStatus,
308
+ startedAt: z2.number(),
309
+ endedAt: z2.number().optional(),
310
+ summary: z2.string().optional(),
311
+ headSha: z2.string().optional()
252
312
  });
253
313
  Feature = z2.object({
254
314
  id: z2.string(),
@@ -447,8 +507,8 @@ var init_pipeline = __esm(() => {
447
507
  });
448
508
 
449
509
  // ../core/src/db-schema.ts
450
- import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
451
- var projects, features, sessions, tickets, waypoints, runs, events, gateOverrides;
510
+ import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
511
+ var projects, projectFindings, projectPreps, features, sessions, tickets, waypoints, runs, events, gateOverrides;
452
512
  var init_db_schema = __esm(() => {
453
513
  projects = sqliteTable("projects", {
454
514
  id: text("id").primaryKey(),
@@ -458,7 +518,28 @@ var init_db_schema = __esm(() => {
458
518
  devCommand: text("dev_command"),
459
519
  model: text("model"),
460
520
  sandbox: text("sandbox"),
461
- closedAt: integer("closed_at")
521
+ closedAt: integer("closed_at"),
522
+ setupCommand: text("setup_command"),
523
+ verifyCommands: text("verify_commands"),
524
+ knownFailures: text("known_failures"),
525
+ dbResetCommand: text("db_reset_command")
526
+ });
527
+ projectFindings = sqliteTable("project_findings", {
528
+ projectId: text("project_id").notNull(),
529
+ key: text("key").notNull(),
530
+ source: text("source").notNull().$type(),
531
+ evidence: text("evidence"),
532
+ establishedAt: integer("established_at").notNull(),
533
+ establishedSha: text("established_sha")
534
+ }, (t) => [primaryKey({ columns: [t.projectId, t.key] })]);
535
+ projectPreps = sqliteTable("project_preps", {
536
+ id: text("id").primaryKey(),
537
+ projectId: text("project_id").notNull(),
538
+ status: text("status").notNull().$type(),
539
+ startedAt: integer("started_at").notNull(),
540
+ endedAt: integer("ended_at"),
541
+ summary: text("summary"),
542
+ headSha: text("head_sha")
462
543
  });
463
544
  features = sqliteTable("features", {
464
545
  id: text("id").primaryKey(),
@@ -562,7 +643,9 @@ var init_schema = __esm(() => {
562
643
  waypoints,
563
644
  runs,
564
645
  events,
565
- gateOverrides
646
+ gateOverrides,
647
+ projectFindings,
648
+ projectPreps
566
649
  };
567
650
  });
568
651
 
@@ -659,6 +742,72 @@ var init_migrate = __esm(() => {
659
742
  // src/util/resolve-executable.ts
660
743
  import { existsSync as existsSync3 } from "fs";
661
744
  import { win32, posix } from "path";
745
+ function wellKnownBinDirs(opts = {}) {
746
+ const env = opts.env ?? process.env;
747
+ const isWin = (opts.platform ?? process.platform) === "win32";
748
+ const dirs = [];
749
+ if (isWin) {
750
+ const home2 = env.USERPROFILE;
751
+ if (home2) {
752
+ dirs.push(win32.join(home2, ".local", "bin"), win32.join(home2, ".bun", "bin"));
753
+ }
754
+ if (env.APPDATA)
755
+ dirs.push(win32.join(env.APPDATA, "npm"));
756
+ if (env.LOCALAPPDATA)
757
+ dirs.push(win32.join(env.LOCALAPPDATA, "Programs", "claude"));
758
+ return dirs;
759
+ }
760
+ const home = env.HOME;
761
+ if (home) {
762
+ dirs.push(posix.join(home, ".local", "bin"), posix.join(home, ".bun", "bin"), posix.join(home, ".npm-global", "bin"));
763
+ }
764
+ dirs.push("/usr/local/bin", "/opt/homebrew/bin");
765
+ return dirs;
766
+ }
767
+ function resolveTool(name, opts = {}) {
768
+ const env = opts.env ?? process.env;
769
+ const overrideKey = BIN_OVERRIDE_ENV[name];
770
+ const override = opts.override ?? (overrideKey ? env[overrideKey] : undefined);
771
+ const extraDirs = opts.extraDirs ?? wellKnownBinDirs({ ...opts.platform ? { platform: opts.platform } : {}, env });
772
+ return resolveExecutable(name, { ...opts, extraDirs, ...override ? { override } : {} });
773
+ }
774
+ function spawnTargetFor(resolved, args) {
775
+ if (/\.(cmd|bat)$/i.test(resolved)) {
776
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/c", resolved, ...args] };
777
+ }
778
+ if (/\.ps1$/i.test(resolved)) {
779
+ return {
780
+ file: "powershell.exe",
781
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved, ...args]
782
+ };
783
+ }
784
+ return { file: resolved, args };
785
+ }
786
+ function resolveSpawnTarget(name, args) {
787
+ return spawnTargetFor(resolveTool(name), args);
788
+ }
789
+ function isBareName(cmd) {
790
+ return !cmd.includes("/") && !cmd.includes("\\");
791
+ }
792
+ function explainSpawnFailure(cmd, raw) {
793
+ const detail = raw.trim();
794
+ if (!isBareName(cmd)) {
795
+ return `Could not launch ${cmd}${detail ? ` \u2014 ${detail}` : ""}`;
796
+ }
797
+ const overrideKey = BIN_OVERRIDE_ENV[cmd];
798
+ const lines = [
799
+ `Could not launch \`${cmd}\`: not on the PATH this runcastle server inherited, nor in the usual install locations (~/.local/bin, npm global, ~/.bun/bin).`,
800
+ `A terminal where \`${cmd}\` works does not prove the server can see it: the server's PATH was captured when it started.`,
801
+ `Fix: quit runcastle and start it again from a terminal where \`${cmd} --version\` works.`
802
+ ];
803
+ if (overrideKey) {
804
+ lines.push(`If that fails, set ${overrideKey} to the full path (\`where.exe ${cmd}\` / \`which ${cmd}\`) and restart.`);
805
+ }
806
+ if (detail)
807
+ lines.push(`(underlying error: ${detail})`);
808
+ return lines.join(`
809
+ `);
810
+ }
662
811
  function resolveExecutable(name, opts = {}) {
663
812
  const exists = opts.exists ?? existsSync3;
664
813
  if (opts.override && exists(opts.override))
@@ -666,22 +815,28 @@ function resolveExecutable(name, opts = {}) {
666
815
  const isWin = (opts.platform ?? process.platform) === "win32";
667
816
  const exts = opts.exts ?? (isWin ? WIN_EXTS : [""]);
668
817
  const pathEnv = opts.pathEnv ?? process.env.PATH ?? "";
669
- const dirs = pathEnv.split(isWin ? ";" : ":");
670
818
  const join5 = isWin ? win32.join : posix.join;
671
- for (const dir of dirs) {
672
- if (!dir)
673
- continue;
674
- for (const ext of exts) {
675
- const candidate = join5(dir, `${name}${ext}`);
676
- if (exists(candidate))
677
- return candidate;
819
+ const scan = (dirs) => {
820
+ for (const dir of dirs) {
821
+ if (!dir)
822
+ continue;
823
+ for (const ext of exts) {
824
+ const candidate = join5(dir, `${name}${ext}`);
825
+ if (exists(candidate))
826
+ return candidate;
827
+ }
678
828
  }
679
- }
680
- return name;
829
+ return null;
830
+ };
831
+ return scan(pathEnv.split(isWin ? ";" : ":")) ?? scan(opts.extraDirs ?? []) ?? name;
681
832
  }
682
- var WIN_EXTS;
833
+ var WIN_EXTS, BIN_OVERRIDE_ENV;
683
834
  var init_resolve_executable = __esm(() => {
684
- WIN_EXTS = [".exe", ".cmd", ".bat", ""];
835
+ WIN_EXTS = [".exe", ".cmd", ".bat", ".ps1", ""];
836
+ BIN_OVERRIDE_ENV = {
837
+ claude: "RUNCASTLE_CLAUDE_BIN",
838
+ node: "RUNCASTLE_NODE_BIN"
839
+ };
685
840
  });
686
841
 
687
842
  // src/pty/pty-sidecar.ts
@@ -698,7 +853,7 @@ function resolveNodeExecutable() {
698
853
  return override;
699
854
  if (!isBun())
700
855
  return process.execPath;
701
- return resolveExecutable("node", { exts: process.platform === "win32" ? [".exe", ".cmd", ""] : [""] });
856
+ return resolveTool("node", { exts: process.platform === "win32" ? [".exe", ".cmd", ""] : [""] });
702
857
  }
703
858
  function resolveNodePtyEntry() {
704
859
  try {
@@ -766,11 +921,17 @@ function createSidecarPtySession(cmd, args, opts) {
766
921
  case "exit":
767
922
  fireExit(typeof msg.code === "number" ? msg.code : 0, msg.signal ?? undefined);
768
923
  break;
769
- case "error":
770
- process.stderr.write(`[pty-sidecar] host error: ${msg.message ?? "unknown"}
924
+ case "error": {
925
+ const why = explainSpawnFailure(cmd, msg.message ?? "unknown");
926
+ process.stderr.write(`[pty-sidecar] host error: ${why}
771
927
  `);
928
+ emitData(Buffer.from(`\r
929
+ ${why.replace(/\n/g, `\r
930
+ `)}\r
931
+ `, "utf8"));
772
932
  fireExit(1);
773
933
  break;
934
+ }
774
935
  }
775
936
  }
776
937
  });
@@ -779,8 +940,13 @@ function createSidecarPtySession(cmd, args, opts) {
779
940
  process.stderr.write(chunk);
780
941
  });
781
942
  child.on("error", (err) => {
782
- process.stderr.write(`[pty-sidecar] failed to spawn node host: ${err.message}
943
+ const why = explainSpawnFailure(nodeExe, err.message);
944
+ process.stderr.write(`[pty-sidecar] failed to spawn node host: ${why}
783
945
  `);
946
+ emitData(Buffer.from(`\r
947
+ ${why.replace(/\n/g, `\r
948
+ `)}\r
949
+ `, "utf8"));
784
950
  fireExit(1);
785
951
  });
786
952
  child.on("exit", (code, signal) => {
@@ -915,14 +1081,19 @@ function createPtySession(cmd, args, opts) {
915
1081
  }
916
1082
  function createNativePtySession(cmd, args, opts) {
917
1083
  const pty = loadNodePty();
918
- const proc = pty.spawn(cmd, args, {
919
- name: "xterm-256color",
920
- cols: opts.cols ?? 80,
921
- rows: opts.rows ?? 24,
922
- cwd: opts.cwd,
923
- env: cleanEnv(opts.env),
924
- useConpty: opts.useConpty ?? true
925
- });
1084
+ let proc;
1085
+ try {
1086
+ proc = pty.spawn(cmd, args, {
1087
+ name: "xterm-256color",
1088
+ cols: opts.cols ?? 80,
1089
+ rows: opts.rows ?? 24,
1090
+ cwd: opts.cwd,
1091
+ env: cleanEnv(opts.env),
1092
+ useConpty: opts.useConpty ?? true
1093
+ });
1094
+ } catch (err) {
1095
+ throw new Error(explainSpawnFailure(cmd, err instanceof Error ? err.message : String(err)));
1096
+ }
926
1097
  let killed = false;
927
1098
  proc.onExit(() => {
928
1099
  killed = true;
@@ -957,6 +1128,7 @@ function createNativePtySession(cmd, args, opts) {
957
1128
  }
958
1129
  var cached = null, BACKEND_LOGGED;
959
1130
  var init_pty = __esm(() => {
1131
+ init_resolve_executable();
960
1132
  init_pty_sidecar();
961
1133
  BACKEND_LOGGED = Symbol.for("runcastle.pty.backend.logged");
962
1134
  });
@@ -1238,7 +1410,11 @@ function rowToProject(row) {
1238
1410
  repoPath: row.repoPath,
1239
1411
  mainBranch: row.mainBranch,
1240
1412
  devCommand: row.devCommand ?? undefined,
1241
- model: row.model ?? undefined
1413
+ model: row.model ?? undefined,
1414
+ setupCommand: row.setupCommand ?? undefined,
1415
+ verifyCommands: row.verifyCommands ?? undefined,
1416
+ knownFailures: row.knownFailures ?? undefined,
1417
+ dbResetCommand: row.dbResetCommand ?? undefined
1242
1418
  };
1243
1419
  }
1244
1420
  function rowToRun(row) {
@@ -1570,34 +1746,159 @@ function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS)
1570
1746
  }, submitDelayMs);
1571
1747
  submit.unref?.();
1572
1748
  }
1573
- function scheduleKickoff(ctx, session) {
1574
- const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
1575
- pendingKickoffOverrides.delete(session.id);
1576
- const timer = setTimeout(() => {
1749
+ function stopTimers(d) {
1750
+ for (const t of d.timers)
1751
+ clearTimeout(t);
1752
+ d.timers.clear();
1753
+ }
1754
+ function forgetKickoff(sessionId) {
1755
+ const d = deliveries.get(sessionId);
1756
+ if (d)
1757
+ stopTimers(d);
1758
+ deliveries.delete(sessionId);
1759
+ pendingKickoffOverrides.delete(sessionId);
1760
+ }
1761
+ function ptyIo(sessionId) {
1762
+ return {
1763
+ write: (data) => ptyRegistry().get(sessionId)?.pty.write(data),
1764
+ alive: () => {
1765
+ const entry = ptyRegistry().get(sessionId);
1766
+ return !!entry && !entry.exited;
1767
+ }
1768
+ };
1769
+ }
1770
+ function track(d, timer) {
1771
+ timer.unref?.();
1772
+ d.timers.add(timer);
1773
+ }
1774
+ function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
1775
+ track(d, setTimeout(() => {
1776
+ if (d.settled)
1777
+ return;
1778
+ const io = ptyIo(session.id);
1779
+ if (!io.alive()) {
1780
+ d.settled = true;
1781
+ return;
1782
+ }
1783
+ d.attempts += 1;
1784
+ const attempt = d.attempts;
1577
1785
  try {
1578
- writeKickoffSequence(line, {
1579
- write: (data) => ptyRegistry().get(session.id)?.pty.write(data),
1580
- alive: () => {
1581
- const entry = ptyRegistry().get(session.id);
1582
- return !!entry && !entry.exited;
1583
- },
1584
- onSubmitted: () => {
1585
- emit(ctx, session.featureId, {
1586
- type: "session.kickoff",
1587
- message: `${session.kind} session kicked off automatically`,
1588
- data: { sessionId: session.id, kind: session.kind }
1589
- });
1590
- }
1786
+ if (clearFirst)
1787
+ io.write(CLEAR_INPUT);
1788
+ writeKickoffSequence(d.line, {
1789
+ write: io.write,
1790
+ alive: io.alive,
1791
+ onSubmitted: () => emit(ctx, session.featureId, {
1792
+ type: "session.kickoff",
1793
+ message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
1794
+ data: { sessionId: session.id, kind: session.kind, attempt }
1795
+ })
1591
1796
  });
1592
1797
  } catch {}
1593
- }, KICKOFF_DELAY_MS);
1798
+ armConfirmation(ctx, session, d);
1799
+ }, delayMs));
1800
+ }
1801
+ function armConfirmation(ctx, session, d) {
1802
+ track(d, setTimeout(() => {
1803
+ if (d.settled || d.confirmed)
1804
+ return;
1805
+ if (d.attempts < KICKOFF_MAX_ATTEMPTS && ptyIo(session.id).alive()) {
1806
+ attemptKickoff(ctx, session, d, 0, true);
1807
+ return;
1808
+ }
1809
+ settleUndelivered(ctx, session, d, "unacknowledged");
1810
+ }, KICKOFF_CONFIRM_MS));
1811
+ }
1812
+ function settleUndelivered(ctx, session, d, reason) {
1813
+ d.settled = true;
1814
+ stopTimers(d);
1815
+ emit(ctx, session.featureId, {
1816
+ type: "session.kickoff_undelivered",
1817
+ message: reason === "superseded" ? `the ${session.kind} briefing was never delivered \u2014 you typed first, so runcastle stopped injecting it` : `the ${session.kind} briefing was typed ${d.attempts}\xD7 but Claude Code never acknowledged it \u2014 send it again from the session strip`,
1818
+ data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
1819
+ });
1820
+ }
1821
+ function noteKickoffPrompt(ctx, sessionId, prompt) {
1822
+ const d = deliveries.get(sessionId);
1823
+ if (!d || d.confirmed)
1824
+ return;
1825
+ const session = getSessionRow(ctx, sessionId);
1826
+ if (!session)
1827
+ return;
1828
+ if (promptMatchesKickoff(d.line, prompt)) {
1829
+ d.confirmed = true;
1830
+ d.settled = true;
1831
+ stopTimers(d);
1832
+ return;
1833
+ }
1834
+ if (d.settled)
1835
+ return;
1836
+ settleUndelivered(ctx, session, d, "superseded");
1837
+ }
1838
+ function promptMatchesKickoff(line, prompt) {
1839
+ if (!prompt)
1840
+ return false;
1841
+ const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
1842
+ const want = norm(line);
1843
+ const got = norm(prompt);
1844
+ if (!want || !got)
1845
+ return false;
1846
+ return got.includes(want.slice(0, MATCH_PREFIX));
1847
+ }
1848
+ function resendKickoff(ctx, sessionId) {
1849
+ const session = getSessionRow(ctx, sessionId);
1850
+ if (!session)
1851
+ throw new Error(`unknown session ${sessionId}`);
1852
+ if (session.status === "ended")
1853
+ throw new Error("that session has ended \u2014 open a new terminal");
1854
+ if (!ptyIo(sessionId).alive())
1855
+ throw new Error("the terminal for that session is no longer running");
1856
+ const existing = deliveries.get(sessionId);
1857
+ const d = existing ?? {
1858
+ line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
1859
+ attempts: 0,
1860
+ confirmed: false,
1861
+ settled: false,
1862
+ timers: new Set
1863
+ };
1864
+ stopTimers(d);
1865
+ d.attempts = 0;
1866
+ d.confirmed = false;
1867
+ d.settled = false;
1868
+ deliveries.set(sessionId, d);
1869
+ attemptKickoff(ctx, session, d, 0, true);
1870
+ return { line: d.line };
1871
+ }
1872
+ function armSessionReadyWatchdog(ctx, session) {
1873
+ const timer = setTimeout(() => {
1874
+ const row = getSessionRow(ctx, session.id);
1875
+ if (!row || row.status !== "launching")
1876
+ return;
1877
+ if (!ptyIo(session.id).alive())
1878
+ return;
1879
+ emit(ctx, session.featureId, {
1880
+ type: "session.not_ready",
1881
+ message: `the ${session.kind} terminal is open but Claude Code has not reported ready \u2014 check it for a prompt or dialog waiting on you, then send the briefing`,
1882
+ data: { sessionId: session.id, kind: session.kind }
1883
+ });
1884
+ }, SESSION_READY_TIMEOUT_MS);
1594
1885
  timer.unref?.();
1595
1886
  }
1887
+ function scheduleKickoff(ctx, session) {
1888
+ const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
1889
+ pendingKickoffOverrides.delete(session.id);
1890
+ const existing = deliveries.get(session.id);
1891
+ if (existing)
1892
+ stopTimers(existing);
1893
+ const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
1894
+ deliveries.set(session.id, d);
1895
+ attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
1896
+ }
1596
1897
  function markSessionEnded(ctx, id) {
1597
1898
  const existing = getSessionRow(ctx, id);
1598
1899
  if (!existing)
1599
1900
  return null;
1600
- pendingKickoffOverrides.delete(id);
1901
+ forgetKickoff(id);
1601
1902
  ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
1602
1903
  return getSessionRow(ctx, id);
1603
1904
  }
@@ -1609,7 +1910,7 @@ function mostRecentLiveSession(ctx) {
1609
1910
  const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1610
1911
  return row ? rowToSession(row) : null;
1611
1912
  }
1612
- var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides;
1913
+ var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, KICKOFF_CONFIRM_MS = 12000, KICKOFF_MAX_ATTEMPTS = 3, CLEAR_INPUT = "\x15", SESSION_READY_TIMEOUT_MS = 25000, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides, deliveries, MATCH_PREFIX = 40;
1613
1914
  var init_sessions = __esm(() => {
1614
1915
  init_src();
1615
1916
  init_schema();
@@ -1627,6 +1928,7 @@ var init_sessions = __esm(() => {
1627
1928
  };
1628
1929
  RESUME_KICKOFF_PREFIX = "We are picking this conversation back up \u2014 runcastle restarted and closed the terminal, " + "but this conversation is intact. Do NOT start over: tell me in one or two lines where we " + "left off and what is next, then carry on. Your original instruction was: ";
1629
1930
  pendingKickoffOverrides = new Map;
1931
+ deliveries = new Map;
1630
1932
  });
1631
1933
 
1632
1934
  // src/launcher/reconcile.ts
@@ -1662,315 +1964,93 @@ var init_reconcile = __esm(() => {
1662
1964
  init_sessions();
1663
1965
  });
1664
1966
 
1665
- // src/launcher/runtime.ts
1666
- function setRuntimeCtx(ctx) {
1667
- current = ctx;
1668
- }
1669
- async function getRuntimeCtx() {
1670
- if (current)
1671
- return current;
1672
- const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
1673
- Promise.resolve().then(() => (init_client(), exports_client)),
1674
- Promise.resolve().then(() => (init_paths(), exports_paths)),
1675
- Promise.resolve().then(() => (init_config_load(), exports_config_load))
1676
- ]);
1677
- const db = createDb2(dbPath2());
1678
- current = { db, config: loadConfig2() };
1679
- return current;
1680
- }
1681
- var current = null;
1682
-
1683
- // src/services/feature-docs.ts
1967
+ // src/launcher/skills-root.ts
1684
1968
  import { existsSync as existsSync5 } from "fs";
1685
- import { join as join5 } from "path";
1686
- function featureDocsDir(project, feature) {
1687
- const relSegments = featureDocsRel(feature.slug).split("/");
1688
- const worktree = worktreeDir(project.id, feature.slug);
1689
- const base = existsSync5(worktree) ? worktree : project.repoPath;
1690
- return join5(base, ...relSegments);
1691
- }
1692
- function featureDocPath(project, feature, fileName) {
1693
- return join5(featureDocsDir(project, feature), fileName);
1969
+ import { dirname, join as join5, resolve as resolve3 } from "path";
1970
+ function resolveSkillsRoot(fromDir) {
1971
+ const override = process.env[SKILLS_DIR_ENV];
1972
+ if (override) {
1973
+ const root = resolve3(override);
1974
+ if (existsSync5(join5(root, SKILLS_MARKER)))
1975
+ return root;
1976
+ throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
1977
+ }
1978
+ const searched = [];
1979
+ let dir = fromDir;
1980
+ for (let i = 0;i < 8; i += 1) {
1981
+ const candidate = join5(dir, "packages", "skills");
1982
+ searched.push(candidate);
1983
+ if (existsSync5(join5(candidate, SKILLS_MARKER)))
1984
+ return candidate;
1985
+ const parent = dirname(dir);
1986
+ if (parent === dir)
1987
+ break;
1988
+ dir = parent;
1989
+ }
1990
+ throw new Error(`runcastle skills root not found; searched:
1991
+ ${searched.join(`
1992
+ `)}`);
1694
1993
  }
1695
- var init_feature_docs = __esm(() => {
1696
- init_paths();
1994
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
1995
+ var init_skills_root = __esm(() => {
1996
+ SKILLS_MARKER = join5("packs", "runcastle");
1697
1997
  });
1698
1998
 
1699
- // src/services/tickets.ts
1700
- import { asc as asc3, eq as eq5 } from "drizzle-orm";
1701
- function rowToTicket(row) {
1702
- return {
1703
- id: row.id,
1704
- featureId: row.featureId,
1705
- seq: row.seq,
1706
- title: row.title,
1707
- goal: row.goal,
1708
- context: row.context,
1709
- acceptanceCriteria: row.acceptanceCriteria,
1710
- seams: row.seams,
1711
- blockedBy: row.blockedBy,
1712
- status: row.status,
1713
- commits: row.commits,
1714
- error: row.error ?? undefined,
1715
- attemptBranch: row.attemptBranch ?? undefined,
1716
- conflictFiles: row.conflictFiles ?? undefined
1717
- };
1999
+ // src/pty/dev-pane.ts
2000
+ function drivePaneId(featureId) {
2001
+ return `${DRIVE_PREFIX}${featureId}`;
1718
2002
  }
1719
- function resolveBlocking(inputs, startSeq) {
1720
- try {
1721
- return resolveBatchBlocking(inputs, { startSeq });
1722
- } catch (e) {
1723
- if (e instanceof BlockingEdgeError)
1724
- throw new InvalidInputError(e.message);
1725
- throw e;
1726
- }
2003
+ function sniffDevUrl(text2) {
2004
+ const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
2005
+ return m ? m[0] : undefined;
1727
2006
  }
1728
- function listByFeature2(ctx, featureId) {
1729
- return ctx.db.select().from(tickets).where(eq5(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
2007
+ function devSpawnTarget(devCommand) {
2008
+ if (process.platform === "win32") {
2009
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
2010
+ }
2011
+ return { file: "/bin/sh", args: ["-c", devCommand] };
1730
2012
  }
1731
- function storeTickets(ctx, featureId, inputs) {
1732
- if (inputs.length === 0)
1733
- return [];
1734
- const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq5(tickets.featureId, featureId)).all();
1735
- const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
1736
- const resolved = resolveBlocking(inputs, startSeq);
1737
- const rows = inputs.map((t, i) => ({
1738
- id: newId("tkt"),
1739
- featureId,
1740
- seq: resolved[i].seq,
1741
- title: t.title,
1742
- goal: t.goal,
1743
- context: t.context,
1744
- acceptanceCriteria: t.acceptanceCriteria,
1745
- seams: t.seams,
1746
- blockedBy: resolved[i].blockedBy,
1747
- status: "pending",
1748
- commits: [],
1749
- error: null,
1750
- attemptBranch: null,
1751
- conflictFiles: null
1752
- }));
1753
- ctx.db.insert(tickets).values(rows).run();
2013
+ function startDevPane(input) {
2014
+ const { ctx, featureId, repoPath, devCommand, onUrl } = input;
2015
+ const paneId = drivePaneId(featureId);
2016
+ const { file, args } = devSpawnTarget(devCommand);
2017
+ try {
2018
+ ptyRegistry().create({
2019
+ sessionId: paneId,
2020
+ cmd: file,
2021
+ args,
2022
+ opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
2023
+ });
2024
+ } catch (err) {
2025
+ emit(ctx, featureId, {
2026
+ type: "testdrive.dev_failed",
2027
+ message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
2028
+ data: { paneId }
2029
+ });
2030
+ return;
2031
+ }
2032
+ let scanned = "";
2033
+ let found = false;
2034
+ const sniffer = {
2035
+ sendData(chunk) {
2036
+ if (found)
2037
+ return;
2038
+ scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
2039
+ const url = sniffDevUrl(scanned);
2040
+ if (url) {
2041
+ found = true;
2042
+ onUrl(url);
2043
+ }
2044
+ },
2045
+ sendControl() {}
2046
+ };
2047
+ ptyRegistry().attach(paneId, sniffer);
1754
2048
  emit(ctx, featureId, {
1755
- type: "tickets.stored",
1756
- message: `${rows.length} ticket(s) stored`,
1757
- data: { count: rows.length, seqs: rows.map((r) => r.seq) }
2049
+ type: "testdrive.dev_started",
2050
+ message: `dev server spawned: ${devCommand}`,
2051
+ data: { paneId }
1758
2052
  });
1759
- return rows.map(rowToTicket);
1760
- }
1761
- function getTicket(ctx, id) {
1762
- const row = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1763
- if (!row)
1764
- throw new NotFoundError(`ticket ${id} not found`);
1765
- return rowToTicket(row);
1766
- }
1767
- function assertMutable(t, verb) {
1768
- if (!MUTABLE_STATUSES.has(t.status)) {
1769
- 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"}`);
1770
- }
1771
- }
1772
- function editTicket(ctx, id, patch) {
1773
- const current2 = getTicket(ctx, id);
1774
- assertMutable(current2, "edit");
1775
- const set = {};
1776
- if (patch.title !== undefined)
1777
- set.title = patch.title;
1778
- if (patch.goal !== undefined)
1779
- set.goal = patch.goal;
1780
- if (patch.context !== undefined)
1781
- set.context = patch.context;
1782
- if (patch.acceptanceCriteria !== undefined)
1783
- set.acceptanceCriteria = patch.acceptanceCriteria;
1784
- if (patch.seams !== undefined)
1785
- set.seams = patch.seams;
1786
- if (Object.keys(set).length === 0) {
1787
- throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
1788
- }
1789
- ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1790
- emit(ctx, current2.featureId, {
1791
- type: "ticket.edited",
1792
- message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
1793
- ticketId: id,
1794
- data: { fields: Object.keys(set) }
1795
- });
1796
- return getTicket(ctx, id);
1797
- }
1798
- function cancelTicket(ctx, id, reason) {
1799
- const current2 = getTicket(ctx, id);
1800
- assertMutable(current2, "cancel");
1801
- ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq5(tickets.id, id)).run();
1802
- emit(ctx, current2.featureId, {
1803
- type: "ticket.cancelled",
1804
- message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
1805
- ticketId: id,
1806
- data: { reason: reason ?? null }
1807
- });
1808
- return getTicket(ctx, id);
1809
- }
1810
- function updateTicket(ctx, id, patch) {
1811
- const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1812
- if (!current2)
1813
- throw new NotFoundError(`ticket ${id} not found`);
1814
- const set = {};
1815
- if (patch.status !== undefined)
1816
- set.status = patch.status;
1817
- if (patch.commits !== undefined)
1818
- set.commits = patch.commits;
1819
- if (patch.error !== undefined)
1820
- set.error = patch.error;
1821
- if (patch.attemptBranch !== undefined)
1822
- set.attemptBranch = patch.attemptBranch;
1823
- if (patch.conflictFiles !== undefined)
1824
- set.conflictFiles = patch.conflictFiles;
1825
- ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1826
- emit(ctx, current2.featureId, {
1827
- type: "ticket.updated",
1828
- message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
1829
- ticketId: id,
1830
- data: patch
1831
- });
1832
- const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1833
- return rowToTicket(updated);
1834
- }
1835
- var MUTABLE_STATUSES;
1836
- var init_tickets = __esm(() => {
1837
- init_src();
1838
- init_schema();
1839
- init_errors();
1840
- init_events();
1841
- MUTABLE_STATUSES = new Set(["pending", "failed"]);
1842
- });
1843
-
1844
- // src/services/gates.ts
1845
- import { existsSync as existsSync6 } from "fs";
1846
- function checkGate(ctx, check, feature) {
1847
- switch (check) {
1848
- case "decisions-file-exists":
1849
- return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
1850
- case "all-waypoints-terminal": {
1851
- const wps = listByFeature(ctx, feature.id);
1852
- if (wps.length === 0) {
1853
- return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
1854
- }
1855
- const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
1856
- return open.length === 0 ? { satisfied: true } : {
1857
- satisfied: false,
1858
- reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
1859
- };
1860
- }
1861
- case "spec-file-exists":
1862
- return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
1863
- case "tickets-approved": {
1864
- const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
1865
- return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
1866
- }
1867
- case "all-tickets-terminal": {
1868
- const tickets2 = listByFeature2(ctx, feature.id);
1869
- if (tickets2.length === 0)
1870
- return { satisfied: false, reason: "no tickets have run yet" };
1871
- const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
1872
- return open.length === 0 ? { satisfied: true } : {
1873
- satisfied: false,
1874
- reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
1875
- };
1876
- }
1877
- case "human-merge":
1878
- return { satisfied: false, reason: "use the Merge button to ship" };
1879
- }
1880
- }
1881
- function notYetTerminal(noun, statuses, order) {
1882
- const counts = new Map;
1883
- for (const s of statuses)
1884
- counts.set(s, (counts.get(s) ?? 0) + 1);
1885
- const ordered = [
1886
- ...order.filter((s) => counts.has(s)),
1887
- ...[...counts.keys()].filter((s) => !order.includes(s))
1888
- ];
1889
- const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
1890
- return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
1891
- }
1892
- function fileGate(ctx, feature, fileName, reason) {
1893
- const project = projectForFeature(ctx, feature);
1894
- return existsSync6(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
1895
- }
1896
- function overrideGate(ctx, featureId, gate, reason) {
1897
- const feature = getFeatureRow(ctx, featureId);
1898
- ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
1899
- emit(ctx, featureId, {
1900
- type: "gate.overridden",
1901
- message: `gate ${gate} overridden: ${reason}`,
1902
- data: { gate, reason }
1903
- });
1904
- const next = nextPhase(feature);
1905
- if (next)
1906
- return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
1907
- return feature;
1908
- }
1909
- var init_gates = __esm(() => {
1910
- init_src();
1911
- init_schema();
1912
- init_events();
1913
- init_feature_docs();
1914
- init_repo();
1915
- init_tickets();
1916
- init_waypoints();
1917
- });
1918
-
1919
- // src/pty/dev-pane.ts
1920
- function drivePaneId(featureId) {
1921
- return `${DRIVE_PREFIX}${featureId}`;
1922
- }
1923
- function sniffDevUrl(text2) {
1924
- const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
1925
- return m ? m[0] : undefined;
1926
- }
1927
- function devSpawnTarget(devCommand) {
1928
- if (process.platform === "win32") {
1929
- return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
1930
- }
1931
- return { file: "/bin/sh", args: ["-c", devCommand] };
1932
- }
1933
- function startDevPane(input) {
1934
- const { ctx, featureId, repoPath, devCommand, onUrl } = input;
1935
- const paneId = drivePaneId(featureId);
1936
- const { file, args } = devSpawnTarget(devCommand);
1937
- try {
1938
- ptyRegistry().create({
1939
- sessionId: paneId,
1940
- cmd: file,
1941
- args,
1942
- opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
1943
- });
1944
- } catch (err) {
1945
- emit(ctx, featureId, {
1946
- type: "testdrive.dev_failed",
1947
- message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
1948
- data: { paneId }
1949
- });
1950
- return;
1951
- }
1952
- let scanned = "";
1953
- let found = false;
1954
- const sniffer = {
1955
- sendData(chunk) {
1956
- if (found)
1957
- return;
1958
- scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
1959
- const url = sniffDevUrl(scanned);
1960
- if (url) {
1961
- found = true;
1962
- onUrl(url);
1963
- }
1964
- },
1965
- sendControl() {}
1966
- };
1967
- ptyRegistry().attach(paneId, sniffer);
1968
- emit(ctx, featureId, {
1969
- type: "testdrive.dev_started",
1970
- message: `dev server spawned: ${devCommand}`,
1971
- data: { paneId }
1972
- });
1973
- return paneId;
2053
+ return paneId;
1974
2054
  }
1975
2055
  function stopDevPane(paneId) {
1976
2056
  const reg = ptyRegistry();
@@ -1990,10 +2070,13 @@ var init_dev_pane = __esm(() => {
1990
2070
  });
1991
2071
 
1992
2072
  // src/services/git.ts
1993
- import { existsSync as existsSync7, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2073
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
1994
2074
  import { tmpdir } from "os";
1995
- import { dirname, join as join6, resolve as resolve3 } from "path";
2075
+ import { dirname as dirname2, join as join6, resolve as resolve4 } from "path";
1996
2076
  import { simpleGit } from "simple-git";
2077
+ function migrationPaths(paths) {
2078
+ return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
2079
+ }
1997
2080
  function featureBranch(slug) {
1998
2081
  return `feature/${slug}`;
1999
2082
  }
@@ -2004,14 +2087,14 @@ function errMsg(e) {
2004
2087
  return e instanceof Error ? e.message : String(e);
2005
2088
  }
2006
2089
  function canon(p) {
2007
- let abs = resolve3(p);
2090
+ let abs = resolve4(p);
2008
2091
  try {
2009
2092
  abs = realpathSync.native(abs);
2010
2093
  } catch {}
2011
2094
  return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2012
2095
  }
2013
2096
  async function assertRepo(repoPath) {
2014
- if (!existsSync7(repoPath)) {
2097
+ if (!existsSync6(repoPath)) {
2015
2098
  throw new InvalidInputError(`path does not exist: ${repoPath}`);
2016
2099
  }
2017
2100
  let isRepo = false;
@@ -2100,7 +2183,7 @@ async function ensureTalkWorktree(project, feature) {
2100
2183
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2101
2184
  if (await worktreeIsValid(g, worktreePath, branch))
2102
2185
  return worktreePath;
2103
- mkdirSync2(dirname(worktreePath), { recursive: true });
2186
+ mkdirSync2(dirname2(worktreePath), { recursive: true });
2104
2187
  try {
2105
2188
  await g.raw(["worktree", "add", worktreePath, branch]);
2106
2189
  return worktreePath;
@@ -2123,7 +2206,7 @@ async function ensureBranchExists(g, branch, from) {
2123
2206
  }
2124
2207
  }
2125
2208
  async function worktreeIsValid(g, worktreePath, branch) {
2126
- if (!existsSync7(worktreePath))
2209
+ if (!existsSync6(worktreePath))
2127
2210
  return false;
2128
2211
  const registered = await registeredWorktrees(g);
2129
2212
  if (!registered.has(canon(worktreePath)))
@@ -2169,7 +2252,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2169
2252
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2170
2253
  }
2171
2254
  async function detachWorktree(path) {
2172
- if (!existsSync7(path))
2255
+ if (!existsSync6(path))
2173
2256
  return false;
2174
2257
  const g = git(path);
2175
2258
  let head;
@@ -2184,12 +2267,27 @@ async function detachWorktree(path) {
2184
2267
  return true;
2185
2268
  }
2186
2269
  async function reattachWorktree(path, branch) {
2187
- if (!existsSync7(path))
2270
+ if (!existsSync6(path))
2188
2271
  return;
2189
2272
  try {
2190
2273
  await git(path).checkout(branch);
2191
2274
  } catch {}
2192
2275
  }
2276
+ function prepBranchName(unique) {
2277
+ return `${PREP_BRANCH_PREFIX}${unique}`;
2278
+ }
2279
+ async function readFileAtRef(repoPath, ref, path) {
2280
+ try {
2281
+ return await git(repoPath).raw(["show", `${ref}:${path}`]);
2282
+ } catch {
2283
+ return;
2284
+ }
2285
+ }
2286
+ async function deletePrepBranch(repoPath, branch) {
2287
+ try {
2288
+ await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
2289
+ } catch {}
2290
+ }
2193
2291
  function tempBranchSlugSegment(slug) {
2194
2292
  return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
2195
2293
  }
@@ -2207,14 +2305,14 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2207
2305
  const attempts = Math.max(1, opts.attempts ?? 3);
2208
2306
  const delayMs = opts.delayMs ?? 750;
2209
2307
  const g = git(repoPath);
2210
- for (let attempt = 1;attempt <= attempts && existsSync7(path); attempt++) {
2308
+ for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2211
2309
  try {
2212
2310
  await g.raw(["worktree", "remove", "--force", path]);
2213
2311
  } catch {}
2214
- if (existsSync7(path) && attempt < attempts)
2312
+ if (existsSync6(path) && attempt < attempts)
2215
2313
  await sleep(delayMs);
2216
2314
  }
2217
- if (existsSync7(path)) {
2315
+ if (existsSync6(path)) {
2218
2316
  try {
2219
2317
  rmSync(path, { recursive: true, force: true });
2220
2318
  } catch {}
@@ -2222,7 +2320,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2222
2320
  try {
2223
2321
  await g.raw(["worktree", "prune"]);
2224
2322
  } catch {}
2225
- return !existsSync7(path);
2323
+ return !existsSync6(path);
2226
2324
  }
2227
2325
  function sleep(ms) {
2228
2326
  return new Promise((done) => setTimeout(done, ms));
@@ -2236,6 +2334,32 @@ async function branchCommitsAhead(repoPath, base, branch) {
2236
2334
  return [];
2237
2335
  }
2238
2336
  }
2337
+ async function headSha(repoPath, ref) {
2338
+ try {
2339
+ const out = (await git(repoPath).revparse([ref])).trim();
2340
+ return out.length > 0 ? out : undefined;
2341
+ } catch {
2342
+ return;
2343
+ }
2344
+ }
2345
+ async function commitsSince(repoPath, sha, branch) {
2346
+ try {
2347
+ const out = (await git(repoPath).raw(["rev-list", "--count", `${sha}..${branch}`])).trim();
2348
+ const n = Number(out);
2349
+ return Number.isFinite(n) ? n : undefined;
2350
+ } catch {
2351
+ return;
2352
+ }
2353
+ }
2354
+ async function diffPaths(repoPath, from, to) {
2355
+ try {
2356
+ const out = (await git(repoPath).raw(["diff", "--name-only", from, to])).trim();
2357
+ return out ? out.split(`
2358
+ `).map((s) => s.trim()).filter(Boolean) : [];
2359
+ } catch {
2360
+ return [];
2361
+ }
2362
+ }
2239
2363
  async function commitSummaries(repoPath, base, branch, limit = 20) {
2240
2364
  try {
2241
2365
  const out = await git(repoPath).raw([
@@ -2431,7 +2555,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2431
2555
  try {
2432
2556
  await g.raw(["worktree", "prune"]);
2433
2557
  } catch {}
2434
- if (existsSync7(worktreePath)) {
2558
+ if (existsSync6(worktreePath)) {
2435
2559
  throw new InvalidInputError(`could not remove talk worktree at ${worktreePath} \u2014 a file may be locked; ` + "close anything using it and retry the delete");
2436
2560
  }
2437
2561
  }
@@ -2458,7 +2582,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2458
2582
  return { deleted, kept };
2459
2583
  }
2460
2584
  async function commitDocs(worktreePath, message) {
2461
- if (!existsSync7(resolve3(worktreePath, DOCS_PATHSPEC)))
2585
+ if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
2462
2586
  return;
2463
2587
  const g = git(worktreePath);
2464
2588
  await g.add([DOCS_PATHSPEC]);
@@ -2503,6 +2627,7 @@ async function testDrive(ctx, project, feature, action) {
2503
2627
  const devPaneId = testDriveState.devPaneId;
2504
2628
  if (devPaneId)
2505
2629
  stopDevPane(devPaneId);
2630
+ const carriedChanges = await dirtyPaths(g);
2506
2631
  await g.checkout(previousBranch2);
2507
2632
  if (detachedWorktree2)
2508
2633
  await reattachWorktree(detachedWorktree2, branch);
@@ -2512,7 +2637,20 @@ async function testDrive(ctx, project, feature, action) {
2512
2637
  message: `test drive stopped \u2014 back on ${previousBranch2}`,
2513
2638
  data: { branch: previousBranch2 }
2514
2639
  });
2515
- return { ok: true, branch: previousBranch2 };
2640
+ if (carriedChanges.length > 0) {
2641
+ emit(ctx, feature.id, {
2642
+ type: "testdrive.carried_changes",
2643
+ message: `${carriedChanges.length} uncommitted file(s) came back with you onto ${previousBranch2}: ${carriedChanges.slice(0, 5).join(", ")}${carriedChanges.length > 5 ? ", \u2026" : ""}`,
2644
+ data: { branch: previousBranch2, files: carriedChanges }
2645
+ });
2646
+ }
2647
+ const dbDrift = await detectDbDrift(ctx, project, feature, previousBranch2, branch);
2648
+ return {
2649
+ ok: true,
2650
+ branch: previousBranch2,
2651
+ ...carriedChanges.length > 0 ? { carriedChanges } : {},
2652
+ ...dbDrift ? { dbDrift } : {}
2653
+ };
2516
2654
  }
2517
2655
  const porcelain = (await g.raw(["status", "--porcelain"])).trim();
2518
2656
  if (porcelain !== "")
@@ -2549,6 +2687,35 @@ async function testDrive(ctx, project, feature, action) {
2549
2687
  }
2550
2688
  return { ok: true, branch };
2551
2689
  }
2690
+ async function dirtyPaths(g) {
2691
+ try {
2692
+ const out = (await g.raw(["status", "--porcelain"])).trim();
2693
+ if (!out)
2694
+ return [];
2695
+ return out.split(`
2696
+ `).map((line) => line.slice(3).trim()).map((p) => p.includes(" -> ") ? p.split(" -> ").at(-1) ?? p : p).map((p) => p.replace(/^"|"$/g, "")).filter(Boolean);
2697
+ } catch {
2698
+ return [];
2699
+ }
2700
+ }
2701
+ async function detectDbDrift(ctx, project, feature, previousBranch, driveBranch) {
2702
+ let files;
2703
+ try {
2704
+ files = migrationPaths(await diffPaths(project.repoPath, previousBranch, driveBranch));
2705
+ } catch {
2706
+ return;
2707
+ }
2708
+ if (files.length === 0)
2709
+ return;
2710
+ const resetCommand = project.dbResetCommand?.trim() || undefined;
2711
+ const drift = { files, ...resetCommand ? { resetCommand } : {} };
2712
+ emit(ctx, feature.id, {
2713
+ type: "testdrive.db_drift",
2714
+ message: `${driveBranch} and ${previousBranch} differ by ${files.length} migration file(s) \u2014 ` + "anything you migrated during the drive is still applied to your dev database, so the next " + `migrate on ${previousBranch} may report drift. ` + (resetCommand ? `Rebuild it with: ${resetCommand}` : 'Set a "Database reset command" in project settings to get a one-click fix here.'),
2715
+ data: { files, previousBranch, driveBranch, resetCommand: resetCommand ?? null }
2716
+ });
2717
+ return drift;
2718
+ }
2552
2719
  async function mergeFeature(project, feature) {
2553
2720
  const target = feature.baseBranch ?? project.mainBranch;
2554
2721
  if (testDriveState) {
@@ -2604,171 +2771,18 @@ async function mergeInProgress(g) {
2604
2771
  return false;
2605
2772
  }
2606
2773
  }
2607
- 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;
2774
+ var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, 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/", PREP_BRANCH_PREFIX = "runcastle/prep/", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
2608
2775
  var init_git = __esm(() => {
2609
2776
  init_paths();
2610
2777
  init_errors();
2611
2778
  init_dev_pane();
2612
2779
  init_events();
2613
2780
  init_repo();
2781
+ MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
2782
+ DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
2614
2783
  TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
2615
2784
  });
2616
2785
 
2617
- // src/services/knowledge.ts
2618
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "fs";
2619
- import { join as join7, relative, resolve as resolve4, sep } from "path";
2620
- function scaffoldDocs(ctx, feature) {
2621
- const project = projectForFeature(ctx, feature);
2622
- const dir = featureDocsDir(project, feature);
2623
- mkdirSync3(dir, { recursive: true });
2624
- const briefPath = featureDocPath(project, feature, "brief.md");
2625
- if (!existsSync8(briefPath)) {
2626
- const created = new Date().toISOString();
2627
- const brief = [
2628
- `# ${feature.title}`,
2629
- "",
2630
- feature.oneLiner,
2631
- "",
2632
- `- Slug: ${feature.slug}`,
2633
- `- Created: ${created}`,
2634
- ""
2635
- ].join(`
2636
- `);
2637
- writeFileSync(briefPath, brief, "utf8");
2638
- }
2639
- if (feature.mapped) {
2640
- scaffoldMapDoc(project, feature);
2641
- }
2642
- emit(ctx, feature.id, {
2643
- type: "docs.scaffolded",
2644
- message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
2645
- });
2646
- }
2647
- function scaffoldMapDoc(project, feature, seed) {
2648
- mkdirSync3(featureDocsDir(project, feature), { recursive: true });
2649
- const mapPath = featureDocPath(project, feature, "map.md");
2650
- if (existsSync8(mapPath))
2651
- return;
2652
- writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
2653
- }
2654
- function mapDocBody(feature, seed) {
2655
- const seeded = {
2656
- Destination: seed?.destination?.trim(),
2657
- Notes: seed?.notes?.trim()
2658
- };
2659
- const lines = [`# ${feature.title} \u2014 map`, ""];
2660
- for (const section of MAP_SECTIONS) {
2661
- lines.push(`## ${section}`, "");
2662
- const body = seeded[section];
2663
- if (body)
2664
- lines.push(body, "");
2665
- }
2666
- return lines.join(`
2667
- `);
2668
- }
2669
- function listDocs(ctx, feature) {
2670
- const project = projectForFeature(ctx, feature);
2671
- const dir = featureDocsDir(project, feature);
2672
- if (!existsSync8(dir))
2673
- return [];
2674
- return readdirSync2(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join7(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
2675
- }
2676
- function readDoc(ctx, feature, relPath) {
2677
- const project = projectForFeature(ctx, feature);
2678
- const dir = featureDocsDir(project, feature);
2679
- const root = resolve4(dir);
2680
- const target = resolve4(dir, relPath);
2681
- const rel = relative(root, target);
2682
- if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
2683
- throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
2684
- }
2685
- if (!existsSync8(target))
2686
- throw new NotFoundError(`doc not found: ${relPath}`);
2687
- return { content: readFileSync3(target, "utf8") };
2688
- }
2689
- function readTitle(path, fallback) {
2690
- try {
2691
- const firstLine = readFileSync3(path, "utf8").split(`
2692
- `, 1)[0] ?? "";
2693
- const heading = firstLine.match(/^#\s+(.+)$/);
2694
- return heading ? heading[1].trim() : fallback;
2695
- } catch {
2696
- return fallback;
2697
- }
2698
- }
2699
- var MAP_SECTIONS;
2700
- var init_knowledge = __esm(() => {
2701
- init_errors();
2702
- init_events();
2703
- init_feature_docs();
2704
- init_repo();
2705
- MAP_SECTIONS = [
2706
- "Destination",
2707
- "Notes",
2708
- "Not yet specified",
2709
- "Out of scope"
2710
- ];
2711
- });
2712
-
2713
- // src/pty/end-session.ts
2714
- function endSession(ctx, sessionId) {
2715
- const registry = ptyRegistry();
2716
- const killed = registry.kill(sessionId);
2717
- registry.remove(sessionId);
2718
- const session = markSessionEnded(ctx, sessionId);
2719
- if (session) {
2720
- releaseForSession(ctx, sessionId);
2721
- emit(ctx, session.featureId, {
2722
- type: "session.ended",
2723
- message: "session ended by user",
2724
- data: { sessionId, killed }
2725
- });
2726
- } else {
2727
- const existing = getSessionRow(ctx, sessionId);
2728
- if (!existing)
2729
- return { sessionId, ok: false, killed };
2730
- }
2731
- return { sessionId, ok: true, killed };
2732
- }
2733
- var init_end_session = __esm(() => {
2734
- init_sessions();
2735
- init_events();
2736
- init_waypoints();
2737
- init_registry();
2738
- });
2739
-
2740
- // src/launcher/skills-root.ts
2741
- import { existsSync as existsSync9 } from "fs";
2742
- import { dirname as dirname2, join as join8, resolve as resolve5 } from "path";
2743
- function resolveSkillsRoot(fromDir) {
2744
- const override = process.env[SKILLS_DIR_ENV];
2745
- if (override) {
2746
- const root = resolve5(override);
2747
- if (existsSync9(join8(root, SKILLS_MARKER)))
2748
- return root;
2749
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
2750
- }
2751
- const searched = [];
2752
- let dir = fromDir;
2753
- for (let i = 0;i < 8; i += 1) {
2754
- const candidate = join8(dir, "packages", "skills");
2755
- searched.push(candidate);
2756
- if (existsSync9(join8(candidate, SKILLS_MARKER)))
2757
- return candidate;
2758
- const parent = dirname2(dir);
2759
- if (parent === dir)
2760
- break;
2761
- dir = parent;
2762
- }
2763
- throw new Error(`runcastle skills root not found; searched:
2764
- ${searched.join(`
2765
- `)}`);
2766
- }
2767
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
2768
- var init_skills_root = __esm(() => {
2769
- SKILLS_MARKER = join8("packs", "runcastle");
2770
- });
2771
-
2772
2786
  // src/services/agent-stream.ts
2773
2787
  function beginTranscript(ticketId) {
2774
2788
  transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
@@ -2813,9 +2827,92 @@ var init_agent_stream = __esm(() => {
2813
2827
  transcripts = new Map;
2814
2828
  });
2815
2829
 
2830
+ // src/workflows/burn-guard.ts
2831
+ function renderGuardScript(rules = GUARD_RULES) {
2832
+ const lines = [
2833
+ "#!/bin/sh",
2834
+ "# runcastle burn guard (PreToolUse). Generated \u2014 edit burn-guard.ts, not this.",
2835
+ "set -u",
2836
+ "payload=$(cat 2>/dev/null || true)",
2837
+ '[ -n "$payload" ] || exit 0',
2838
+ "command -v jq >/dev/null 2>&1 || exit 0",
2839
+ 'tool=$(printf %s "$payload" | jq -r ".tool_name // empty" 2>/dev/null || true)',
2840
+ '[ "$tool" = "Bash" ] || exit 0',
2841
+ 'cmd=$(printf %s "$payload" | jq -r ".tool_input.command // empty" 2>/dev/null || true)',
2842
+ '[ -n "$cmd" ] || exit 0',
2843
+ "",
2844
+ "# Match against the command with quoted spans blanked, so an argument is",
2845
+ '# never read as a command: `grep -rn "git stash" docs/` searches for a',
2846
+ "# string, it does not run one.",
2847
+ `cmd=$(printf %s "$cmd" | sed "s/'[^']*'/ /g; s/\\"[^\\"]*\\"/ /g")`,
2848
+ "",
2849
+ "deny() {",
2850
+ ` printf %s "$1" | jq -R -s '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:.}}'`,
2851
+ " exit 0",
2852
+ "}",
2853
+ ""
2854
+ ];
2855
+ for (const rule of rules) {
2856
+ lines.push(`# ${rule.id}`, `if printf %s "$cmd" | grep -Eq -- ${shSingleQuote(rule.pattern)}; then`, ` deny ${shSingleQuote(rule.reason)}`, "fi", "");
2857
+ }
2858
+ lines.push("exit 0", "");
2859
+ return lines.join(`
2860
+ `);
2861
+ }
2862
+ function shSingleQuote(value) {
2863
+ return `'${value.split("'").join(`'\\''`)}'`;
2864
+ }
2865
+ function renderGuardSettings() {
2866
+ return {
2867
+ hooks: {
2868
+ PreToolUse: [
2869
+ {
2870
+ matcher: "Bash",
2871
+ hooks: [{ type: "command", command: `sh ${GUARD_SCRIPT_PATH}`, timeout: 5 }]
2872
+ }
2873
+ ]
2874
+ }
2875
+ };
2876
+ }
2877
+ function buildGuardInstallCommand() {
2878
+ const script = Buffer.from(renderGuardScript(), "utf8").toString("base64");
2879
+ const settings = Buffer.from(JSON.stringify(renderGuardSettings(), null, 2), "utf8").toString("base64");
2880
+ return [
2881
+ `mkdir -p "$HOME/.claude/hooks"`,
2882
+ `printf %s '${script}' | base64 -d > "${GUARD_SCRIPT_PATH}"`,
2883
+ `chmod +x "${GUARD_SCRIPT_PATH}"`,
2884
+ `printf %s '${settings}' | base64 -d > "${GUARD_SETTINGS_PATH}"`
2885
+ ].join(" && ");
2886
+ }
2887
+ var CMD_START = "(^|[;&|(]|&&)[[:space:]]*", GUARD_RULES, GUARD_SCRIPT_PATH = "$HOME/.claude/hooks/burn-guard.sh", GUARD_SETTINGS_PATH = "$HOME/.claude/settings.json";
2888
+ var init_burn_guard = __esm(() => {
2889
+ GUARD_RULES = [
2890
+ {
2891
+ id: "no-stash",
2892
+ pattern: `${CMD_START}git[[:space:]]+stash`,
2893
+ 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."
2894
+ },
2895
+ {
2896
+ id: "no-serialised-tests",
2897
+ pattern: "--maxWorkers|--runInBand|--pool=|--shard=|singleFork|--poolOptions",
2898
+ 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.`
2899
+ },
2900
+ {
2901
+ id: "no-interpreter-heredoc-edit",
2902
+ pattern: `${CMD_START}(python3?|node|bun|perl|ruby)[[:space:]]+-[[:space:]]*<<`,
2903
+ 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."
2904
+ },
2905
+ {
2906
+ id: "no-cat-heredoc-edit",
2907
+ pattern: `${CMD_START}cat[[:space:]]+>>?[[:space:]]*[^[:space:]|]+[[:space:]]*<<`,
2908
+ 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."
2909
+ }
2910
+ ];
2911
+ });
2912
+
2816
2913
  // src/workflows/ticket-burner.ts
2817
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
2818
- import { dirname as dirname3, join as join9 } from "path";
2914
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
2915
+ import { dirname as dirname3, join as join7 } from "path";
2819
2916
  import { fileURLToPath as fileURLToPath2 } from "url";
2820
2917
  import { claudeCode, run } from "@ai-hero/sandcastle";
2821
2918
  import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
@@ -2931,13 +3028,13 @@ function parseEnvFile(content) {
2931
3028
  if (line.length === 0 || line.startsWith("#"))
2932
3029
  continue;
2933
3030
  const body = line.startsWith("export ") ? line.slice(7).trim() : line;
2934
- const eq6 = body.indexOf("=");
2935
- if (eq6 === -1)
3031
+ const eq5 = body.indexOf("=");
3032
+ if (eq5 === -1)
2936
3033
  continue;
2937
- const key = body.slice(0, eq6).trim();
3034
+ const key = body.slice(0, eq5).trim();
2938
3035
  if (key.length === 0)
2939
3036
  continue;
2940
- let value = body.slice(eq6 + 1).trim();
3037
+ let value = body.slice(eq5 + 1).trim();
2941
3038
  if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
2942
3039
  value = value.slice(1, -1);
2943
3040
  }
@@ -3019,13 +3116,31 @@ function buildWorkspaceNotes(mode) {
3019
3116
  ].join(`
3020
3117
  `);
3021
3118
  }
3119
+ function buildVerifyNotes(config2) {
3120
+ const commands = config2.verifyCommands?.trim();
3121
+ const failures = config2.knownFailures?.trim();
3122
+ const out = [];
3123
+ if (commands) {
3124
+ 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, "```");
3125
+ } else {
3126
+ 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.");
3127
+ }
3128
+ out.push("");
3129
+ if (failures) {
3130
+ 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.");
3131
+ } else {
3132
+ 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.");
3133
+ }
3134
+ return out.join(`
3135
+ `);
3136
+ }
3022
3137
  function readRepoToolchain(repoPath) {
3023
- const pkgPath = join9(repoPath, "package.json");
3024
- const hasPackageJson = existsSync10(pkgPath);
3138
+ const pkgPath = join7(repoPath, "package.json");
3139
+ const hasPackageJson = existsSync7(pkgPath);
3025
3140
  let packageManagerField;
3026
3141
  if (hasPackageJson) {
3027
3142
  try {
3028
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
3143
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
3029
3144
  if (typeof pkg.packageManager === "string")
3030
3145
  packageManagerField = pkg.packageManager;
3031
3146
  } catch {}
@@ -3034,10 +3149,10 @@ function readRepoToolchain(repoPath) {
3034
3149
  hasPackageJson,
3035
3150
  ...packageManagerField !== undefined ? { packageManagerField } : {},
3036
3151
  lockfiles: {
3037
- bun: existsSync10(join9(repoPath, "bun.lock")) || existsSync10(join9(repoPath, "bun.lockb")),
3038
- pnpm: existsSync10(join9(repoPath, "pnpm-lock.yaml")),
3039
- yarn: existsSync10(join9(repoPath, "yarn.lock")),
3040
- npm: existsSync10(join9(repoPath, "package-lock.json"))
3152
+ bun: existsSync7(join7(repoPath, "bun.lock")) || existsSync7(join7(repoPath, "bun.lockb")),
3153
+ pnpm: existsSync7(join7(repoPath, "pnpm-lock.yaml")),
3154
+ yarn: existsSync7(join7(repoPath, "yarn.lock")),
3155
+ npm: existsSync7(join7(repoPath, "package-lock.json"))
3041
3156
  }
3042
3157
  };
3043
3158
  }
@@ -3075,6 +3190,63 @@ function createStreamThrottle(emit2, opts = {}) {
3075
3190
  };
3076
3191
  return { onEvent, flush: flushText };
3077
3192
  }
3193
+ function normalizeCommandForClassification(command) {
3194
+ return command.replace(/<<\s*['"]?(\w+)['"]?[\s\S]*?(^|\n)\s*\1\b/gm, "<<$1 ").replace(/<<\s*['"]?(\w+)['"]?[\s\S]*$/, "<<$1 ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
3195
+ }
3196
+ function classifyToolCall(name, args) {
3197
+ const byName = TOOL_NAME_CATEGORY[name];
3198
+ if (byName)
3199
+ return byName;
3200
+ if (name !== "Bash")
3201
+ return "other";
3202
+ const normalized = normalizeCommandForClassification(args);
3203
+ for (const [category, pattern] of BASH_PATTERNS) {
3204
+ if (pattern.test(normalized))
3205
+ return category;
3206
+ }
3207
+ return "other";
3208
+ }
3209
+ function createToolTimer() {
3210
+ const byCategory = {};
3211
+ let calls = 0;
3212
+ let totalMs = 0;
3213
+ let pending = null;
3214
+ let iteration = null;
3215
+ const charge = (category, ms) => {
3216
+ const slot = byCategory[category] ??= { calls: 0, ms: 0 };
3217
+ slot.ms += ms;
3218
+ totalMs += ms;
3219
+ };
3220
+ const onEvent = (event) => {
3221
+ if (event.type === "raw")
3222
+ return;
3223
+ const at = event.timestamp.getTime();
3224
+ if (iteration !== null && event.iteration !== iteration)
3225
+ pending = null;
3226
+ iteration = event.iteration;
3227
+ if (pending) {
3228
+ const gap = at - pending.at;
3229
+ if (gap > 0 && gap <= MAX_ATTRIBUTABLE_GAP_MS)
3230
+ charge(pending.category, gap);
3231
+ }
3232
+ if (event.type === "toolCall") {
3233
+ const category = classifyToolCall(event.name, event.formattedArgs ?? "");
3234
+ const slot = byCategory[category] ??= { calls: 0, ms: 0 };
3235
+ slot.calls += 1;
3236
+ calls += 1;
3237
+ pending = { category, at };
3238
+ } else {
3239
+ pending = { category: "model", at };
3240
+ }
3241
+ };
3242
+ return { onEvent, summary: () => ({ totalMs, calls, byCategory }) };
3243
+ }
3244
+ function formatTimingSummary(s) {
3245
+ if (s.totalMs === 0)
3246
+ return `${s.calls} tool call(s), no measurable time`;
3247
+ 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)}%`);
3248
+ return `${Math.round(s.totalMs / 60000)}min across ${s.calls} tool call(s) \u2014 ${parts.join(", ")}`;
3249
+ }
3078
3250
  function interpretRunResult(result, blockedContent) {
3079
3251
  const commits = result.commits.map((c) => c.sha);
3080
3252
  if (commits.length > 0)
@@ -3121,18 +3293,18 @@ function buildRetryNotes(input) {
3121
3293
  `);
3122
3294
  }
3123
3295
  function delayUnlessAborted(ms, signal) {
3124
- return new Promise((resolve6) => {
3296
+ return new Promise((resolve5) => {
3125
3297
  if (signal.aborted) {
3126
- resolve6();
3298
+ resolve5();
3127
3299
  return;
3128
3300
  }
3129
3301
  const onAbort = () => {
3130
3302
  clearTimeout(t);
3131
- resolve6();
3303
+ resolve5();
3132
3304
  };
3133
3305
  const t = setTimeout(() => {
3134
3306
  signal.removeEventListener("abort", onAbort);
3135
- resolve6();
3307
+ resolve5();
3136
3308
  }, ms);
3137
3309
  signal.addEventListener("abort", onAbort, { once: true });
3138
3310
  });
@@ -3155,38 +3327,38 @@ function createSerialQueue() {
3155
3327
  };
3156
3328
  }
3157
3329
  async function landWithResolve(branch, deps) {
3158
- let current2 = branch;
3330
+ let current = branch;
3159
3331
  for (let attempt = 1;; attempt++) {
3160
- const merge = await deps.merge(current2);
3332
+ const merge = await deps.merge(current);
3161
3333
  if (merge.ok)
3162
- return { status: "landed", branch: current2 };
3334
+ return { status: "landed", branch: current };
3163
3335
  const detail = merge.error ?? "merge failed";
3164
3336
  if (!merge.conflict)
3165
- return { status: "failed", branch: current2, error: detail };
3337
+ return { status: "failed", branch: current, error: detail };
3166
3338
  const files = merge.files ?? [];
3167
3339
  if (attempt > deps.maxResolveAttempts) {
3168
- return { status: "conflict", branch: current2, files, error: detail };
3340
+ return { status: "conflict", branch: current, files, error: detail };
3169
3341
  }
3170
3342
  const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
3171
3343
  deps.emit({
3172
3344
  type: "merge.conflict.resolving",
3173
3345
  message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
3174
- data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
3346
+ data: { branch: current, files, attempt, maxAttempts: deps.maxResolveAttempts }
3175
3347
  });
3176
3348
  const resolved = await deps.resolve({
3177
- branch: current2,
3349
+ branch: current,
3178
3350
  files,
3179
3351
  attempt,
3180
3352
  maxAttempts: deps.maxResolveAttempts
3181
3353
  });
3182
- current2 = resolved.branch;
3354
+ current = resolved.branch;
3183
3355
  if (!resolved.ok) {
3184
- return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
3356
+ return { status: "conflict", branch: current, files, error: resolved.error ?? detail };
3185
3357
  }
3186
3358
  deps.emit({
3187
3359
  type: "merge.conflict.resolved",
3188
- message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
3189
- data: { branch: current2, files, attempt }
3360
+ message: `${deps.label}: conflict resolved on ${current} \u2014 landing`,
3361
+ data: { branch: current, files, attempt }
3190
3362
  });
3191
3363
  }
3192
3364
  }
@@ -3344,26 +3516,26 @@ function resolverTemplatePath() {
3344
3516
  }
3345
3517
  function burnerAssetPath(file) {
3346
3518
  const here = dirname3(fileURLToPath2(import.meta.url));
3347
- return join9(resolveSkillsRoot(here), "burner", file);
3519
+ return join7(resolveSkillsRoot(here), "burner", file);
3348
3520
  }
3349
3521
  function readDocsDigestFromDisk(projectId, slug) {
3350
3522
  const worktree = worktreeDir(projectId, slug);
3351
- if (!existsSync10(worktree))
3523
+ if (!existsSync7(worktree))
3352
3524
  return "_No talk worktree on disk \u2014 docs digest skipped._";
3353
- const docsDir = join9(worktree, ...featureDocsRel(slug).split("/"));
3354
- if (!existsSync10(docsDir))
3525
+ const docsDir = join7(worktree, ...featureDocsRel(slug).split("/"));
3526
+ if (!existsSync7(docsDir))
3355
3527
  return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
3356
- const files = readdirSync3(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync4(join9(docsDir, e.name), "utf8") }));
3528
+ const files = readdirSync2(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync3(join7(docsDir, e.name), "utf8") }));
3357
3529
  return buildDocsDigest(files);
3358
3530
  }
3359
3531
  function readBlockedFile(dirs) {
3360
3532
  for (const dir of dirs) {
3361
3533
  if (!dir)
3362
3534
  continue;
3363
- const p = join9(dir, "BLOCKED.md");
3364
- if (existsSync10(p)) {
3535
+ const p = join7(dir, "BLOCKED.md");
3536
+ if (existsSync7(p)) {
3365
3537
  try {
3366
- return readFileSync4(p, "utf8");
3538
+ return readFileSync3(p, "utf8");
3367
3539
  } catch {}
3368
3540
  }
3369
3541
  }
@@ -3390,10 +3562,7 @@ function buildBurnAgent(config2, token, model) {
3390
3562
  return agent;
3391
3563
  }
3392
3564
  function selectSandbox(config2, mounts = []) {
3393
- const imageOpts = {
3394
- imageName: resolveSandboxImage(config2),
3395
- ...mounts.length > 0 ? { mounts } : {}
3396
- };
3565
+ const imageOpts = buildSandboxOptions(config2, mounts);
3397
3566
  switch (config2.sandbox) {
3398
3567
  case "docker":
3399
3568
  return docker(imageOpts);
@@ -3403,6 +3572,13 @@ function selectSandbox(config2, mounts = []) {
3403
3572
  return noSandbox();
3404
3573
  }
3405
3574
  }
3575
+ function buildSandboxOptions(config2, mounts = []) {
3576
+ return {
3577
+ imageName: resolveSandboxImage(config2),
3578
+ ...mounts.length > 0 ? { mounts } : {},
3579
+ ...config2.burnCpus !== undefined ? { cpus: config2.burnCpus } : {}
3580
+ };
3581
+ }
3406
3582
  async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, ensureIsolatedPushTarget) {
3407
3583
  const { project, feature } = ctx;
3408
3584
  const workspaceMode = resolveBurnWorkspaceMode(config2);
@@ -3412,30 +3588,37 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3412
3588
  const featureBrief = buildFeatureBrief(feature);
3413
3589
  const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3414
3590
  const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3415
- const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3591
+ const prepared = resolvePreparedSettings(config2, project);
3592
+ const verifyNotes = buildVerifyNotes(prepared);
3593
+ const basePrompt = renderTicketPrompt(readFileSync3(burnerTemplatePath(), "utf8"), {
3416
3594
  TICKET_JSON: ticketJson,
3417
3595
  FEATURE_BRIEF: featureBrief,
3418
3596
  DOCS_DIGEST: docsDigest,
3419
3597
  COMMIT_CONVENTION: `ticket(${ticket.seq}): <summary>`,
3598
+ VERIFY_NOTES: verifyNotes,
3420
3599
  WORKSPACE_NOTES: workspaceNotes
3421
3600
  });
3422
3601
  const toolchain = readRepoToolchain(project.repoPath);
3423
3602
  const pm = detectPackageManager(toolchain);
3424
- const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
3603
+ const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
3425
3604
  const mounts = [];
3426
3605
  if (config2.sandbox !== "noSandbox" && pm) {
3427
3606
  const mount = cacheMountFor(pm, burnCacheDir(pm));
3428
3607
  if (mount) {
3429
- mkdirSync4(mount.hostPath, { recursive: true });
3608
+ mkdirSync3(mount.hostPath, { recursive: true });
3430
3609
  mounts.push(mount);
3431
3610
  }
3432
3611
  }
3433
- mkdirSync4(logsDir(), { recursive: true });
3434
- const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3612
+ const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
3613
+ const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
3614
+ mkdirSync3(logsDir(), { recursive: true });
3615
+ const logFilePath = join7(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3435
3616
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3436
3617
  beginTranscript(ticket.id);
3618
+ const timer = createToolTimer();
3437
3619
  const onStreamEvent = (event) => {
3438
3620
  throttle.onEvent(event);
3621
+ timer.onEvent(event);
3439
3622
  if (event.type === "text") {
3440
3623
  appendTranscript(ticket.id, { kind: "text", text: event.message });
3441
3624
  } else if (event.type === "toolCall") {
@@ -3475,17 +3658,18 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3475
3658
  const runResolver = async (input) => {
3476
3659
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
3477
3660
  const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
3478
- const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
3661
+ const prompt = renderTemplate(readFileSync3(resolverTemplatePath(), "utf8"), {
3479
3662
  TICKET_JSON: ticketJson,
3480
3663
  FEATURE_BRIEF: featureBrief,
3481
3664
  DOCS_DIGEST: docsDigest,
3482
3665
  WORKSPACE_NOTES: workspaceNotes,
3666
+ VERIFY_NOTES: verifyNotes,
3483
3667
  FEATURE_BRANCH: feature.branch,
3484
3668
  CONFLICT_FILES: buildConflictFilesBlock(input.files),
3485
3669
  OTHER_SIDE: buildOtherSideBlock(otherSide),
3486
3670
  MERGE_COMMAND: resolveMergeCommand(workspaceMode, feature.branch)
3487
3671
  });
3488
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand;
3672
+ const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(resolveBranch, setupCommand, pm) : setupCommand);
3489
3673
  appendTranscript(ticket.id, {
3490
3674
  kind: "text",
3491
3675
  text: `
@@ -3615,7 +3799,7 @@ ${blocked.trim()}`
3615
3799
  let tempBranch = "";
3616
3800
  for (let attempt = 1;; attempt++) {
3617
3801
  tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
3618
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand;
3802
+ const hookCommand = withGuard(workspaceMode === "isolated" ? buildIsolatedSetupCommand(tempBranch, setupCommand, pm) : setupCommand);
3619
3803
  if (hookCommand && attempt === 1) {
3620
3804
  ctx.emitEvent({
3621
3805
  type: "burn.setup",
@@ -3750,87 +3934,1008 @@ ${blocked.trim()}` };
3750
3934
  activeTicketAborts.delete(ticket.id);
3751
3935
  throttle.flush();
3752
3936
  endTranscript(ticket.id);
3937
+ const timing = timer.summary();
3938
+ if (timing.calls > 0) {
3939
+ ctx.emitEvent({
3940
+ type: "ticket.timing",
3941
+ message: `ticket ${ticket.seq} spent ${formatTimingSummary(timing)}`,
3942
+ ticketId: ticket.id,
3943
+ data: timing
3944
+ });
3945
+ }
3946
+ }
3947
+ }
3948
+ function resolveBurnDeps(ctx) {
3949
+ const config2 = loadConfig();
3950
+ const token = readTokenFromEnvFile(envPath());
3951
+ const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
3952
+ const land = createSerialQueue();
3953
+ let pushTargetReady;
3954
+ const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
3955
+ return {
3956
+ config: config2,
3957
+ hasAuthToken: token !== undefined,
3958
+ concurrency: config2.burnConcurrency,
3959
+ executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land, ensureIsolatedPushTarget)
3960
+ };
3961
+ }
3962
+ function readTokenFromEnvFile(path) {
3963
+ let fromFile;
3964
+ if (existsSync7(path)) {
3965
+ try {
3966
+ fromFile = parseEnvFile(readFileSync3(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
3967
+ } catch {
3968
+ fromFile = undefined;
3969
+ }
3970
+ }
3971
+ const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3972
+ return token && token.length > 0 ? token : undefined;
3973
+ }
3974
+ 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;
3975
+ var init_ticket_burner = __esm(() => {
3976
+ init_src();
3977
+ init_config_load();
3978
+ init_paths();
3979
+ init_skills_root();
3980
+ init_agent_stream();
3981
+ init_git();
3982
+ init_burn_guard();
3983
+ PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3984
+ PM_CACHE_SANDBOX_PATHS = {
3985
+ bun: "~/.bun/install/cache",
3986
+ yarn: "~/.cache/yarn",
3987
+ npm: "~/.npm"
3988
+ };
3989
+ RUN = String.raw`(?![\w./-])`;
3990
+ BASH_PATTERNS = [
3991
+ ["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`)],
3992
+ ["typecheck", /\btsc\b|\btypecheck\b|\bmypy\b|\bcargo check\b/],
3993
+ ["build", new RegExp(String.raw`\bbuild${RUN}|\bprisma\s+(generate|migrate)\b|\bcodegen${RUN}`)],
3994
+ ["lint", new RegExp(String.raw`\b(eslint|prettier|ruff|biome|lint|format)${RUN}`)],
3995
+ ["install", /\b(pnpm|npm|yarn|bun|corepack)\s+\w*\s*install\b|\bnpm ci\b/],
3996
+ ["git", /\bgit\b/],
3997
+ ["search", /\b(grep|rg|ag|find)\b/],
3998
+ ["file-read", /\b(cat|sed|head|tail|less|wc|ls)\b/],
3999
+ ["file-edit", /<<\s*['"]?(PY|EOF|SH|JS|TS)\b|\bpython3?\s+-\s*<</]
4000
+ ];
4001
+ TOOL_NAME_CATEGORY = {
4002
+ Read: "file-read",
4003
+ NotebookRead: "file-read",
4004
+ Grep: "search",
4005
+ Glob: "search",
4006
+ Edit: "file-edit",
4007
+ Write: "file-edit",
4008
+ NotebookEdit: "file-edit"
4009
+ };
4010
+ MAX_ATTRIBUTABLE_GAP_MS = 20 * 60000;
4011
+ BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
4012
+ WORKTREE_REMOVAL_FAILURES = [
4013
+ /failed to delete/i,
4014
+ /directory not empty|enotempty/i,
4015
+ /unable to (unlink|delete)/i,
4016
+ /is not a working tree/i,
4017
+ /resource busy|ebusy|eperm|eacces/i
4018
+ ];
4019
+ FATAL_ERROR_PATTERNS = [
4020
+ /invalid (api key|x-api-key)/i,
4021
+ /authentication|unauthorized|permission denied/i,
4022
+ /credit balance|billing/i,
4023
+ /oauth token|setup-token/i,
4024
+ /issue with the selected model|model not found|unknown model/i,
4025
+ /does not support resumeSession|resumeSession .* not found/i
4026
+ ];
4027
+ RETRYABLE_ERROR_PATTERNS = [
4028
+ /exited with code \d+/i,
4029
+ /idle timeout|AgentIdleTimeout/i,
4030
+ /connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
4031
+ /econnreset|etimedout|econnrefused|epipe|eai_again/i,
4032
+ /overloaded|rate.?limit|too many requests/i,
4033
+ /internal server error|service unavailable|bad gateway|gateway timeout/i,
4034
+ /\bapi error\b/i,
4035
+ /session capture failed/i
4036
+ ];
4037
+ activeTicketAborts = new Map;
4038
+ SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
4039
+ ticketBurner = {
4040
+ id: "ticket-burner",
4041
+ run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
4042
+ };
4043
+ });
4044
+
4045
+ // src/workflows/project-prep.ts
4046
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4 } from "fs";
4047
+ import { dirname as dirname4, join as join8 } from "path";
4048
+ import { fileURLToPath as fileURLToPath3 } from "url";
4049
+ import { run as run2 } from "@ai-hero/sandcastle";
4050
+ import * as z3 from "zod";
4051
+ function parsePrepFindings(raw) {
4052
+ let json;
4053
+ try {
4054
+ json = JSON.parse(raw);
4055
+ } catch (e) {
4056
+ throw new Error(`findings file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
4057
+ }
4058
+ const outer = RawFindings.safeParse(json);
4059
+ if (!outer.success)
4060
+ throw new Error("findings file is not a JSON object");
4061
+ const warnings = [];
4062
+ const values = {};
4063
+ const known = new Set(PREPARED_KEYS);
4064
+ for (const [key, rawValue] of Object.entries(outer.data)) {
4065
+ if (key === "notes")
4066
+ continue;
4067
+ if (!known.has(key)) {
4068
+ warnings.push(`ignored unknown key "${key}"`);
4069
+ continue;
4070
+ }
4071
+ const parsed = RawFinding.safeParse(rawValue);
4072
+ if (!parsed.success) {
4073
+ warnings.push(`ignored "${key}": not a string or {value, evidence} object`);
4074
+ continue;
4075
+ }
4076
+ const entry = parsed.data;
4077
+ const value = (typeof entry === "string" ? entry : entry.value ?? "").trim();
4078
+ if (value === "")
4079
+ continue;
4080
+ const evidence = typeof entry === "string" ? undefined : entry.evidence?.trim();
4081
+ values[key] = { value, ...evidence ? { evidence } : {} };
4082
+ }
4083
+ const notes = outer.data.notes?.trim();
4084
+ return { findings: { values, ...notes ? { notes } : {} }, warnings };
4085
+ }
4086
+ function renderPrepPrompt(template, values) {
4087
+ let out = template;
4088
+ for (const key of PLACEHOLDERS) {
4089
+ out = out.split(`{{${key}}}`).join(values[key]);
4090
+ }
4091
+ return out;
4092
+ }
4093
+ function buildRequestedKeysBlock(keys) {
4094
+ if (keys.length === 0) {
4095
+ return "_Everything is already established. Report an empty findings object and stop._";
4096
+ }
4097
+ const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
4098
+ return [...keys].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)).map((k) => `- ${KEY_BRIEF[k]}`).join(`
4099
+ `);
4100
+ }
4101
+ async function prepRun(ctx, deps) {
4102
+ if (ctx.keys.length === 0) {
4103
+ ctx.emitEvent({
4104
+ type: "prep.skipped",
4105
+ message: "nothing to prepare \u2014 every field is already set"
4106
+ });
4107
+ return { status: "succeeded", summary: "nothing to prepare" };
4108
+ }
4109
+ if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
4110
+ ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
4111
+ return { status: "failed", summary: "preparation aborted: auth token missing" };
4112
+ }
4113
+ ctx.emitEvent({
4114
+ type: "prep.started",
4115
+ message: `preparing ${ctx.project.name} \u2014 establishing ${ctx.keys.join(", ")}`,
4116
+ data: { keys: [...ctx.keys] }
4117
+ });
4118
+ const outcome = await deps.executePrepRun(ctx);
4119
+ if (outcome.status === "failed") {
4120
+ ctx.emitEvent({
4121
+ type: "prep.failed",
4122
+ message: `preparation failed: ${errorHeadline2(outcome.error)}`,
4123
+ data: { error: outcome.error }
4124
+ });
4125
+ return { status: "failed", summary: outcome.error };
4126
+ }
4127
+ for (const warning of outcome.warnings) {
4128
+ ctx.emitEvent({ type: "prep.warning", message: `preparation: ${warning}` });
4129
+ }
4130
+ const found = Object.keys(outcome.findings.values);
4131
+ const missed = ctx.keys.filter((k) => !found.includes(k));
4132
+ const summary = found.length === 0 ? "preparation established nothing" : `established ${found.join(", ")}${missed.length > 0 ? ` (could not establish ${missed.join(", ")})` : ""}`;
4133
+ return { status: "succeeded", summary, findings: outcome.findings };
4134
+ }
4135
+ function errorHeadline2(s) {
4136
+ const lines = s.split(`
4137
+ `).map((l) => l.trim()).filter(Boolean);
4138
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4139
+ return causes.at(-1) ?? lines[0] ?? "";
4140
+ }
4141
+ function createPrepStreamThrottle(emit2, opts = {}) {
4142
+ return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
4143
+ }
4144
+ function prepTemplatePath() {
4145
+ const here = dirname4(fileURLToPath3(import.meta.url));
4146
+ return join8(resolveSkillsRoot(here), "burner", "prepare-project.md");
4147
+ }
4148
+ async function realExecutePrepRun(ctx, config2, token, model) {
4149
+ const { project } = ctx;
4150
+ const branch = prepBranchName(newId("b").slice(2, 10));
4151
+ const toolchain = readRepoToolchain(project.repoPath);
4152
+ const pm = detectPackageManager(toolchain);
4153
+ const prepared = resolvePreparedSettings(config2, project);
4154
+ const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
4155
+ const workspaceMode = resolveBurnWorkspaceMode(config2);
4156
+ const mounts = [];
4157
+ if (config2.sandbox !== "noSandbox" && pm) {
4158
+ const mount = cacheMountFor(pm, burnCacheDir(pm));
4159
+ if (mount) {
4160
+ mkdirSync4(mount.hostPath, { recursive: true });
4161
+ mounts.push(mount);
4162
+ }
4163
+ }
4164
+ const template = readFileSync4(prepTemplatePath(), "utf8");
4165
+ const prompt = renderPrepPrompt(template, {
4166
+ REQUESTED_KEYS: buildRequestedKeysBlock(ctx.keys),
4167
+ SETUP_COMMAND: setupCommand ?? "(no install step detected)"
4168
+ });
4169
+ mkdirSync4(logsDir(), { recursive: true });
4170
+ const logFilePath = join8(logsDir(), `prep-${project.id}.log`);
4171
+ const throttle = createPrepStreamThrottle((e) => ctx.emitEvent(e));
4172
+ if (setupCommand) {
4173
+ ctx.emitEvent({
4174
+ type: "prep.setup",
4175
+ message: `installing deps before the agent starts: ${setupCommand}`
4176
+ });
4177
+ }
4178
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, setupCommand, pm) : setupCommand;
4179
+ const runOptions = {
4180
+ agent: buildBurnAgent(config2, token, model),
4181
+ sandbox: selectSandbox(config2, mounts),
4182
+ cwd: project.repoPath,
4183
+ prompt,
4184
+ branchStrategy: { type: "branch", branch, baseBranch: project.mainBranch },
4185
+ signal: ctx.signal,
4186
+ name: "prepare",
4187
+ maxIterations: 1,
4188
+ ...hookCommand ? {
4189
+ hooks: {
4190
+ sandbox: {
4191
+ onSandboxReady: [{ command: hookCommand, timeoutMs: PREP_SETUP_TIMEOUT_MS }]
4192
+ }
4193
+ }
4194
+ } : {},
4195
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
4196
+ };
4197
+ try {
4198
+ let result;
4199
+ try {
4200
+ result = await run2(runOptions);
4201
+ } catch (err) {
4202
+ throttle.flush();
4203
+ if (ctx.signal.aborted)
4204
+ throw err;
4205
+ const msg = err instanceof Error ? err.message : String(err);
4206
+ const salvaged = await readFindings(project.repoPath, branch);
4207
+ if (!salvaged)
4208
+ return { status: "failed", error: msg };
4209
+ ctx.emitEvent({
4210
+ type: "prep.salvaged",
4211
+ message: `the agent finished but sandcastle errored on teardown (${errorHeadline2(msg)}) \u2014 reading the findings it committed anyway`
4212
+ });
4213
+ return salvaged;
4214
+ }
4215
+ throttle.flush();
4216
+ if (result.commits.length === 0) {
4217
+ return {
4218
+ status: "failed",
4219
+ error: `agent produced no commits \u2014 no ${PREP_FINDINGS_PATH} landed`
4220
+ };
4221
+ }
4222
+ const read = await readFindings(project.repoPath, branch);
4223
+ return read ?? {
4224
+ status: "failed",
4225
+ error: `agent committed, but ${PREP_FINDINGS_PATH} is missing from ${branch}`
4226
+ };
4227
+ } finally {
4228
+ await deletePrepBranch(project.repoPath, branch);
4229
+ }
4230
+ }
4231
+ async function readFindings(repoPath, branch) {
4232
+ const raw = await readFileAtRef(repoPath, branch, PREP_FINDINGS_PATH);
4233
+ if (raw === undefined)
4234
+ return;
4235
+ try {
4236
+ const { findings, warnings } = parsePrepFindings(raw);
4237
+ return { status: "done", findings, warnings };
4238
+ } catch (e) {
4239
+ return { status: "failed", error: e instanceof Error ? e.message : String(e) };
4240
+ }
4241
+ }
4242
+ function resolvePrepDeps(project) {
4243
+ const config2 = loadConfig();
4244
+ const token = readTokenFromEnvFile(envPath());
4245
+ const model = resolveModel("prepare", config2, project);
4246
+ return {
4247
+ config: config2,
4248
+ hasAuthToken: token !== undefined,
4249
+ executePrepRun: (c) => realExecutePrepRun(c, config2, token, model)
4250
+ };
4251
+ }
4252
+ async function prepHeadSha(project) {
4253
+ return existsSync8(project.repoPath) ? headSha(project.repoPath, project.mainBranch) : undefined;
4254
+ }
4255
+ var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PREP_FINDINGS_PATH = ".runcastle/prep.json", PREP_SETUP_TIMEOUT_MS, RawFinding, RawFindings, PLACEHOLDERS, KEY_BRIEF;
4256
+ var init_project_prep = __esm(() => {
4257
+ init_src();
4258
+ init_config_load();
4259
+ init_paths();
4260
+ init_skills_root();
4261
+ init_git();
4262
+ init_ticket_burner();
4263
+ PREP_SETUP_TIMEOUT_MS = 15 * 60000;
4264
+ RawFinding = z3.union([
4265
+ z3.string(),
4266
+ z3.object({
4267
+ value: z3.string().nullish(),
4268
+ evidence: z3.string().nullish()
4269
+ })
4270
+ ]);
4271
+ RawFindings = z3.object({ notes: z3.string().nullish() }).catchall(z3.unknown());
4272
+ PLACEHOLDERS = ["REQUESTED_KEYS", "SETUP_COMMAND"];
4273
+ KEY_BRIEF = {
4274
+ setupCommand: "**`setupCommand`** \u2014 the command that takes a clean checkout to a state where the code builds and tests run: dependency install, plus any codegen or contract build that every task would otherwise discover it needed mid-flight (ORM client generation, protobuf, a `contracts:build` step). Run it and report what worked.",
4275
+ verifyCommands: "**`verifyCommands`** \u2014 the exact typecheck / test / lint commands for this repo, one per line. In a monorepo these must include the correct workspace filter names. Run each one and confirm it executes the thing it claims to.",
4276
+ knownFailures: '**`knownFailures`** \u2014 which tests are ALREADY failing on this checkout, before anything is changed. Run the full suite once and report the count plus the failing suite names. "0 known failures" is a real, useful answer.',
4277
+ devCommand: "**`devCommand`** \u2014 the command that starts this project's dev server on a developer's own machine. Read it from config; do not run it here.",
4278
+ dbResetCommand: "**`dbResetCommand`** \u2014 the command that rebuilds the dev database from the migrations currently in the working tree. Read it from config; do not run it here. Omit it entirely if this repo has no database."
4279
+ };
4280
+ });
4281
+
4282
+ // src/services/findings.ts
4283
+ import { and as and5, eq as eq5 } from "drizzle-orm";
4284
+ function isPreparedKey(key) {
4285
+ return PREPARED_SET.has(key);
4286
+ }
4287
+ function findingRow(ctx, projectId, key) {
4288
+ return ctx.db.select({
4289
+ source: projectFindings.source,
4290
+ evidence: projectFindings.evidence,
4291
+ establishedAt: projectFindings.establishedAt,
4292
+ establishedSha: projectFindings.establishedSha
4293
+ }).from(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).get();
4294
+ }
4295
+ function isOverwritable(ctx, projectId, key) {
4296
+ return findingRow(ctx, projectId, key)?.source !== "human";
4297
+ }
4298
+ function recordFinding(ctx, projectId, input) {
4299
+ const { key, value, source, evidence, establishedSha } = input;
4300
+ ctx.db.transaction((tx) => {
4301
+ tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq5(projects.id, projectId)).run();
4302
+ if (value === null) {
4303
+ tx.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
4304
+ return;
4305
+ }
4306
+ tx.insert(projectFindings).values({
4307
+ projectId,
4308
+ key,
4309
+ source,
4310
+ evidence: evidence ?? null,
4311
+ establishedAt: Date.now(),
4312
+ establishedSha: establishedSha ?? null
4313
+ }).onConflictDoUpdate({
4314
+ target: [projectFindings.projectId, projectFindings.key],
4315
+ set: {
4316
+ source,
4317
+ evidence: evidence ?? null,
4318
+ establishedAt: Date.now(),
4319
+ establishedSha: establishedSha ?? null
4320
+ }
4321
+ }).run();
4322
+ });
4323
+ }
4324
+ function recordHuman(ctx, projectId, key, value) {
4325
+ if (!isPreparedKey(key))
4326
+ return;
4327
+ const existing = findingRow(ctx, projectId, key);
4328
+ if (value === null) {
4329
+ if (existing) {
4330
+ ctx.db.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
4331
+ }
4332
+ return;
4333
+ }
4334
+ ctx.db.insert(projectFindings).values({
4335
+ projectId,
4336
+ key,
4337
+ source: "human",
4338
+ evidence: null,
4339
+ establishedAt: Date.now(),
4340
+ establishedSha: null
4341
+ }).onConflictDoUpdate({
4342
+ target: [projectFindings.projectId, projectFindings.key],
4343
+ set: { source: "human", evidence: null, establishedAt: Date.now(), establishedSha: null }
4344
+ }).run();
4345
+ }
4346
+ async function listFindings(ctx, project) {
4347
+ const rows = ctx.db.select().from(projectFindings).where(eq5(projectFindings.projectId, project.id)).all();
4348
+ const distances = new Map;
4349
+ for (const row of rows) {
4350
+ const sha = row.establishedSha;
4351
+ if (!sha || distances.has(sha))
4352
+ continue;
4353
+ distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
4354
+ }
4355
+ const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
4356
+ return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
4357
+ const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
4358
+ return {
4359
+ key: row.key,
4360
+ source: row.source,
4361
+ ...row.evidence ? { evidence: row.evidence } : {},
4362
+ establishedAt: row.establishedAt,
4363
+ ...row.establishedSha ? { establishedSha: row.establishedSha } : {},
4364
+ ...staleCommits !== undefined ? { staleCommits } : {}
4365
+ };
4366
+ });
4367
+ }
4368
+ function unsetPreparedKeys(project) {
4369
+ return PREPARED_KEYS.filter((key) => {
4370
+ const value = project[key];
4371
+ return value === undefined || value.trim() === "";
4372
+ });
4373
+ }
4374
+ var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET;
4375
+ var init_findings = __esm(() => {
4376
+ init_src();
4377
+ init_schema();
4378
+ init_git();
4379
+ VALUE_COLUMN = {
4380
+ setupCommand: projects.setupCommand,
4381
+ verifyCommands: projects.verifyCommands,
4382
+ knownFailures: projects.knownFailures,
4383
+ devCommand: projects.devCommand,
4384
+ dbResetCommand: projects.dbResetCommand
4385
+ };
4386
+ COLUMN_NAME = {
4387
+ setupCommand: "setupCommand",
4388
+ verifyCommands: "verifyCommands",
4389
+ knownFailures: "knownFailures",
4390
+ devCommand: "devCommand",
4391
+ dbResetCommand: "dbResetCommand"
4392
+ };
4393
+ PREPARED_SET = new Set(PREPARED_KEYS);
4394
+ });
4395
+
4396
+ // src/services/prep.ts
4397
+ import { desc as desc3, eq as eq6 } from "drizzle-orm";
4398
+ function isPreparing(projectId) {
4399
+ return controllers.has(projectId);
4400
+ }
4401
+ function keysToPrepare(ctx, project, opts = {}) {
4402
+ const unset = new Set(unsetPreparedKeys(project));
4403
+ const candidates = opts.keys ?? (opts.refresh ? PREPARED_KEYS : [...unset]);
4404
+ return candidates.filter((key) => (opts.refresh || unset.has(key)) && isOverwritable(ctx, project.id, key));
4405
+ }
4406
+ async function startPrep(ctx, projectId, opts = {}) {
4407
+ const project = requireProjectById(ctx, projectId);
4408
+ if (controllers.has(projectId)) {
4409
+ throw new InvalidInputError(`${project.name} is already being prepared`);
4410
+ }
4411
+ const keys = keysToPrepare(ctx, project, opts);
4412
+ const prepId = newId("prep");
4413
+ const headSha2 = await prepHeadSha(project);
4414
+ ctx.db.insert(projectPreps).values({
4415
+ id: prepId,
4416
+ projectId,
4417
+ status: "running",
4418
+ startedAt: Date.now(),
4419
+ endedAt: null,
4420
+ summary: null,
4421
+ headSha: headSha2 ?? null
4422
+ }).run();
4423
+ emitProject(ctx, projectId, {
4424
+ type: "prep.run.started",
4425
+ message: keys.length === 0 ? `preparing ${project.name} \u2014 nothing left to establish` : `preparing ${project.name} \u2014 ${keys.length} field(s) to establish`,
4426
+ data: { prepId, keys }
4427
+ });
4428
+ const controller = new AbortController;
4429
+ controllers.set(projectId, { runId: prepId, controller });
4430
+ const prepCtx = {
4431
+ project,
4432
+ keys,
4433
+ emitEvent: (e) => {
4434
+ emitProject(ctx, projectId, { type: e.type, message: e.message, data: e.data });
4435
+ },
4436
+ signal: controller.signal
4437
+ };
4438
+ const deps = opts.deps ?? resolvePrepDeps(project);
4439
+ const done = finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2);
4440
+ return { prepId, keys, done };
4441
+ }
4442
+ function cancelPrep(projectId) {
4443
+ controllers.get(projectId)?.controller.abort();
4444
+ }
4445
+ async function finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2) {
4446
+ let status = "failed";
4447
+ let summary = "preparation failed";
4448
+ let findings;
4449
+ try {
4450
+ const result = await prepRun(prepCtx, deps);
4451
+ status = result.status === "succeeded" ? "succeeded" : "failed";
4452
+ summary = result.summary;
4453
+ findings = result.findings;
4454
+ } catch (e) {
4455
+ if (controller.signal.aborted) {
4456
+ status = "cancelled";
4457
+ summary = "preparation cancelled";
4458
+ } else {
4459
+ status = "failed";
4460
+ summary = e instanceof Error ? e.message : "preparation failed";
4461
+ }
4462
+ } finally {
4463
+ controllers.delete(project.id);
4464
+ }
4465
+ const applied = findings ? applyFindings(ctx, project, findings, headSha2) : [];
4466
+ ctx.db.update(projectPreps).set({ status, endedAt: Date.now(), summary }).where(eq6(projectPreps.id, prepId)).run();
4467
+ emitProject(ctx, project.id, {
4468
+ type: "prep.run.finished",
4469
+ message: `preparation ${status}: ${summary}`,
4470
+ data: { prepId, status, summary, applied }
4471
+ });
4472
+ }
4473
+ function applyFindings(ctx, project, findings, headSha2) {
4474
+ const applied = [];
4475
+ for (const [key, finding] of Object.entries(findings.values)) {
4476
+ if (!isOverwritable(ctx, project.id, key)) {
4477
+ emitProject(ctx, project.id, {
4478
+ type: "prep.skipped_field",
4479
+ message: `kept your ${key} \u2014 preparation proposed "${truncate(finding.value)}" but you set this by hand`,
4480
+ data: { key, proposed: finding.value }
4481
+ });
4482
+ continue;
4483
+ }
4484
+ recordFinding(ctx, project.id, {
4485
+ key,
4486
+ value: finding.value,
4487
+ source: "prep",
4488
+ ...finding.evidence ? { evidence: finding.evidence } : {},
4489
+ ...headSha2 ? { establishedSha: headSha2 } : {}
4490
+ });
4491
+ applied.push(key);
4492
+ emitProject(ctx, project.id, {
4493
+ type: "prep.established",
4494
+ message: `${key}: ${truncate(finding.value)}`,
4495
+ data: { key, value: finding.value, evidence: finding.evidence }
4496
+ });
4497
+ }
4498
+ if (findings.notes) {
4499
+ emitProject(ctx, project.id, {
4500
+ type: "prep.notes",
4501
+ message: `preparation notes: ${findings.notes}`,
4502
+ data: { notes: findings.notes }
4503
+ });
4504
+ }
4505
+ return applied;
4506
+ }
4507
+ function truncate(value, max = 120) {
4508
+ const oneLine = value.replace(/\s*\n\s*/g, " \xB7 ").trim();
4509
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
4510
+ }
4511
+ function latestPrep(ctx, projectId) {
4512
+ const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc3(projectPreps.startedAt)).get();
4513
+ if (!row)
4514
+ return null;
4515
+ return {
4516
+ id: row.id,
4517
+ projectId: row.projectId,
4518
+ status: row.status,
4519
+ startedAt: row.startedAt,
4520
+ ...row.endedAt !== null ? { endedAt: row.endedAt } : {},
4521
+ ...row.summary !== null ? { summary: row.summary } : {},
4522
+ ...row.headSha !== null ? { headSha: row.headSha } : {}
4523
+ };
4524
+ }
4525
+ function reconcilePreps(ctx) {
4526
+ const stale = ctx.db.select({ id: projectPreps.id, projectId: projectPreps.projectId }).from(projectPreps).where(eq6(projectPreps.status, "running")).all().filter((row) => !controllers.has(row.projectId));
4527
+ for (const row of stale) {
4528
+ ctx.db.update(projectPreps).set({ status: "failed", endedAt: Date.now(), summary: "server stopped while preparing" }).where(eq6(projectPreps.id, row.id)).run();
4529
+ }
4530
+ return stale.length;
4531
+ }
4532
+ var controllers;
4533
+ var init_prep = __esm(() => {
4534
+ init_src();
4535
+ init_schema();
4536
+ init_errors();
4537
+ init_project_prep();
4538
+ init_events();
4539
+ init_findings();
4540
+ init_repo();
4541
+ controllers = new Map;
4542
+ });
4543
+
4544
+ // src/launcher/runtime.ts
4545
+ function setRuntimeCtx(ctx) {
4546
+ current = ctx;
4547
+ }
4548
+ async function getRuntimeCtx() {
4549
+ if (current)
4550
+ return current;
4551
+ const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
4552
+ Promise.resolve().then(() => (init_client(), exports_client)),
4553
+ Promise.resolve().then(() => (init_paths(), exports_paths)),
4554
+ Promise.resolve().then(() => (init_config_load(), exports_config_load))
4555
+ ]);
4556
+ const db = createDb2(dbPath2());
4557
+ current = { db, config: loadConfig2() };
4558
+ return current;
4559
+ }
4560
+ var current = null;
4561
+
4562
+ // src/services/feature-docs.ts
4563
+ import { existsSync as existsSync9 } from "fs";
4564
+ import { join as join9 } from "path";
4565
+ function featureDocsDir(project, feature) {
4566
+ const relSegments = featureDocsRel(feature.slug).split("/");
4567
+ const worktree = worktreeDir(project.id, feature.slug);
4568
+ const base = existsSync9(worktree) ? worktree : project.repoPath;
4569
+ return join9(base, ...relSegments);
4570
+ }
4571
+ function featureDocPath(project, feature, fileName) {
4572
+ return join9(featureDocsDir(project, feature), fileName);
4573
+ }
4574
+ var init_feature_docs = __esm(() => {
4575
+ init_paths();
4576
+ });
4577
+
4578
+ // src/services/tickets.ts
4579
+ import { and as and6, asc as asc3, eq as eq7 } from "drizzle-orm";
4580
+ function rowToTicket(row) {
4581
+ return {
4582
+ id: row.id,
4583
+ featureId: row.featureId,
4584
+ seq: row.seq,
4585
+ title: row.title,
4586
+ goal: row.goal,
4587
+ context: row.context,
4588
+ acceptanceCriteria: row.acceptanceCriteria,
4589
+ seams: row.seams,
4590
+ blockedBy: row.blockedBy,
4591
+ status: row.status,
4592
+ commits: row.commits,
4593
+ error: row.error ?? undefined,
4594
+ attemptBranch: row.attemptBranch ?? undefined,
4595
+ conflictFiles: row.conflictFiles ?? undefined
4596
+ };
4597
+ }
4598
+ function resolveBlocking(inputs, startSeq) {
4599
+ try {
4600
+ return resolveBatchBlocking(inputs, { startSeq });
4601
+ } catch (e) {
4602
+ if (e instanceof BlockingEdgeError)
4603
+ throw new InvalidInputError(e.message);
4604
+ throw e;
4605
+ }
4606
+ }
4607
+ function listByFeature2(ctx, featureId) {
4608
+ return ctx.db.select().from(tickets).where(eq7(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
4609
+ }
4610
+ function storeTickets(ctx, featureId, inputs) {
4611
+ if (inputs.length === 0)
4612
+ return [];
4613
+ const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
4614
+ const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
4615
+ const resolved = resolveBlocking(inputs, startSeq);
4616
+ const rows = inputs.map((t, i) => ({
4617
+ id: newId("tkt"),
4618
+ featureId,
4619
+ seq: resolved[i].seq,
4620
+ title: t.title,
4621
+ goal: t.goal,
4622
+ context: t.context,
4623
+ acceptanceCriteria: t.acceptanceCriteria,
4624
+ seams: t.seams,
4625
+ blockedBy: resolved[i].blockedBy,
4626
+ status: "pending",
4627
+ commits: [],
4628
+ error: null,
4629
+ attemptBranch: null,
4630
+ conflictFiles: null
4631
+ }));
4632
+ ctx.db.insert(tickets).values(rows).run();
4633
+ emit(ctx, featureId, {
4634
+ type: "tickets.stored",
4635
+ message: `${rows.length} ticket(s) stored`,
4636
+ data: { count: rows.length, seqs: rows.map((r) => r.seq) }
4637
+ });
4638
+ return rows.map(rowToTicket);
4639
+ }
4640
+ function getTicket(ctx, id) {
4641
+ const row = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
4642
+ if (!row)
4643
+ throw new NotFoundError(`ticket ${id} not found`);
4644
+ return rowToTicket(row);
4645
+ }
4646
+ function assertMutable(t, verb) {
4647
+ if (!MUTABLE_STATUSES.has(t.status)) {
4648
+ 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"}`);
4649
+ }
4650
+ }
4651
+ function editTicket(ctx, id, patch) {
4652
+ const current2 = getTicket(ctx, id);
4653
+ assertMutable(current2, "edit");
4654
+ const set = {};
4655
+ if (patch.title !== undefined)
4656
+ set.title = patch.title;
4657
+ if (patch.goal !== undefined)
4658
+ set.goal = patch.goal;
4659
+ if (patch.context !== undefined)
4660
+ set.context = patch.context;
4661
+ if (patch.acceptanceCriteria !== undefined)
4662
+ set.acceptanceCriteria = patch.acceptanceCriteria;
4663
+ if (patch.seams !== undefined)
4664
+ set.seams = patch.seams;
4665
+ if (Object.keys(set).length === 0) {
4666
+ throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
4667
+ }
4668
+ ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
4669
+ emit(ctx, current2.featureId, {
4670
+ type: "ticket.edited",
4671
+ message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
4672
+ ticketId: id,
4673
+ data: { fields: Object.keys(set) }
4674
+ });
4675
+ return getTicket(ctx, id);
4676
+ }
4677
+ function cancelTicket(ctx, id, reason) {
4678
+ const current2 = getTicket(ctx, id);
4679
+ assertMutable(current2, "cancel");
4680
+ ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq7(tickets.id, id)).run();
4681
+ emit(ctx, current2.featureId, {
4682
+ type: "ticket.cancelled",
4683
+ message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
4684
+ ticketId: id,
4685
+ data: { reason: reason ?? null }
4686
+ });
4687
+ return getTicket(ctx, id);
4688
+ }
4689
+ function sweepOrphanedBurning(ctx, featureId, reason) {
4690
+ const orphaned = ctx.db.select().from(tickets).where(and6(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
4691
+ for (const t of orphaned) {
4692
+ updateTicket(ctx, t.id, { status: "failed", error: reason });
4693
+ emit(ctx, featureId, {
4694
+ type: "ticket.failed",
4695
+ message: `ticket ${t.seq} failed: ${reason}`,
4696
+ ticketId: t.id,
4697
+ data: { error: reason, orphaned: true }
4698
+ });
4699
+ }
4700
+ return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
4701
+ }
4702
+ function updateTicket(ctx, id, patch) {
4703
+ const current2 = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
4704
+ if (!current2)
4705
+ throw new NotFoundError(`ticket ${id} not found`);
4706
+ const set = {};
4707
+ if (patch.status !== undefined)
4708
+ set.status = patch.status;
4709
+ if (patch.commits !== undefined)
4710
+ set.commits = patch.commits;
4711
+ if (patch.error !== undefined)
4712
+ set.error = patch.error;
4713
+ if (patch.attemptBranch !== undefined)
4714
+ set.attemptBranch = patch.attemptBranch;
4715
+ if (patch.conflictFiles !== undefined)
4716
+ set.conflictFiles = patch.conflictFiles;
4717
+ ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
4718
+ emit(ctx, current2.featureId, {
4719
+ type: "ticket.updated",
4720
+ message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
4721
+ ticketId: id,
4722
+ data: patch
4723
+ });
4724
+ const updated = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
4725
+ return rowToTicket(updated);
4726
+ }
4727
+ var MUTABLE_STATUSES;
4728
+ var init_tickets = __esm(() => {
4729
+ init_src();
4730
+ init_schema();
4731
+ init_errors();
4732
+ init_events();
4733
+ MUTABLE_STATUSES = new Set(["pending", "failed"]);
4734
+ });
4735
+
4736
+ // src/services/gates.ts
4737
+ import { existsSync as existsSync10 } from "fs";
4738
+ function checkGate(ctx, check, feature) {
4739
+ switch (check) {
4740
+ case "decisions-file-exists":
4741
+ return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
4742
+ case "all-waypoints-terminal": {
4743
+ const wps = listByFeature(ctx, feature.id);
4744
+ if (wps.length === 0) {
4745
+ return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
4746
+ }
4747
+ const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
4748
+ return open.length === 0 ? { satisfied: true } : {
4749
+ satisfied: false,
4750
+ reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
4751
+ };
4752
+ }
4753
+ case "spec-file-exists":
4754
+ return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
4755
+ case "tickets-approved": {
4756
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
4757
+ return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
4758
+ }
4759
+ case "all-tickets-terminal": {
4760
+ const tickets2 = listByFeature2(ctx, feature.id);
4761
+ if (tickets2.length === 0)
4762
+ return { satisfied: false, reason: "no tickets have run yet" };
4763
+ const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
4764
+ return open.length === 0 ? { satisfied: true } : {
4765
+ satisfied: false,
4766
+ reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
4767
+ };
4768
+ }
4769
+ case "human-merge":
4770
+ return { satisfied: false, reason: "use the Merge button to ship" };
4771
+ }
4772
+ }
4773
+ function notYetTerminal(noun, statuses, order) {
4774
+ const counts = new Map;
4775
+ for (const s of statuses)
4776
+ counts.set(s, (counts.get(s) ?? 0) + 1);
4777
+ const ordered = [
4778
+ ...order.filter((s) => counts.has(s)),
4779
+ ...[...counts.keys()].filter((s) => !order.includes(s))
4780
+ ];
4781
+ const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
4782
+ return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
4783
+ }
4784
+ function fileGate(ctx, feature, fileName, reason) {
4785
+ const project = projectForFeature(ctx, feature);
4786
+ return existsSync10(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
4787
+ }
4788
+ function overrideGate(ctx, featureId, gate, reason) {
4789
+ const feature = getFeatureRow(ctx, featureId);
4790
+ ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
4791
+ emit(ctx, featureId, {
4792
+ type: "gate.overridden",
4793
+ message: `gate ${gate} overridden: ${reason}`,
4794
+ data: { gate, reason }
4795
+ });
4796
+ const next = nextPhase(feature);
4797
+ if (next)
4798
+ return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
4799
+ return feature;
4800
+ }
4801
+ var init_gates = __esm(() => {
4802
+ init_src();
4803
+ init_schema();
4804
+ init_events();
4805
+ init_feature_docs();
4806
+ init_repo();
4807
+ init_tickets();
4808
+ init_waypoints();
4809
+ });
4810
+
4811
+ // src/services/knowledge.ts
4812
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync } from "fs";
4813
+ import { join as join10, relative, resolve as resolve5, sep } from "path";
4814
+ function scaffoldDocs(ctx, feature) {
4815
+ const project = projectForFeature(ctx, feature);
4816
+ const dir = featureDocsDir(project, feature);
4817
+ mkdirSync5(dir, { recursive: true });
4818
+ const briefPath = featureDocPath(project, feature, "brief.md");
4819
+ if (!existsSync11(briefPath)) {
4820
+ const created = new Date().toISOString();
4821
+ const brief = [
4822
+ `# ${feature.title}`,
4823
+ "",
4824
+ feature.oneLiner,
4825
+ "",
4826
+ `- Slug: ${feature.slug}`,
4827
+ `- Created: ${created}`,
4828
+ ""
4829
+ ].join(`
4830
+ `);
4831
+ writeFileSync(briefPath, brief, "utf8");
3753
4832
  }
4833
+ if (feature.mapped) {
4834
+ scaffoldMapDoc(project, feature);
4835
+ }
4836
+ emit(ctx, feature.id, {
4837
+ type: "docs.scaffolded",
4838
+ message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
4839
+ });
3754
4840
  }
3755
- function resolveBurnDeps(ctx) {
3756
- const config2 = loadConfig();
3757
- const token = readTokenFromEnvFile(envPath());
3758
- const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
3759
- const land = createSerialQueue();
3760
- let pushTargetReady;
3761
- const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
3762
- return {
3763
- config: config2,
3764
- hasAuthToken: token !== undefined,
3765
- concurrency: config2.burnConcurrency,
3766
- executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land, ensureIsolatedPushTarget)
4841
+ function scaffoldMapDoc(project, feature, seed) {
4842
+ mkdirSync5(featureDocsDir(project, feature), { recursive: true });
4843
+ const mapPath = featureDocPath(project, feature, "map.md");
4844
+ if (existsSync11(mapPath))
4845
+ return;
4846
+ writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
4847
+ }
4848
+ function mapDocBody(feature, seed) {
4849
+ const seeded = {
4850
+ Destination: seed?.destination?.trim(),
4851
+ Notes: seed?.notes?.trim()
3767
4852
  };
4853
+ const lines = [`# ${feature.title} \u2014 map`, ""];
4854
+ for (const section of MAP_SECTIONS) {
4855
+ lines.push(`## ${section}`, "");
4856
+ const body = seeded[section];
4857
+ if (body)
4858
+ lines.push(body, "");
4859
+ }
4860
+ return lines.join(`
4861
+ `);
3768
4862
  }
3769
- function readTokenFromEnvFile(path) {
3770
- let fromFile;
3771
- if (existsSync10(path)) {
3772
- try {
3773
- fromFile = parseEnvFile(readFileSync4(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
3774
- } catch {
3775
- fromFile = undefined;
3776
- }
4863
+ function listDocs(ctx, feature) {
4864
+ const project = projectForFeature(ctx, feature);
4865
+ const dir = featureDocsDir(project, feature);
4866
+ if (!existsSync11(dir))
4867
+ return [];
4868
+ return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join10(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
4869
+ }
4870
+ function readDoc(ctx, feature, relPath) {
4871
+ const project = projectForFeature(ctx, feature);
4872
+ const dir = featureDocsDir(project, feature);
4873
+ const root = resolve5(dir);
4874
+ const target = resolve5(dir, relPath);
4875
+ const rel = relative(root, target);
4876
+ if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
4877
+ throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
3777
4878
  }
3778
- const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3779
- return token && token.length > 0 ? token : undefined;
4879
+ if (!existsSync11(target))
4880
+ throw new NotFoundError(`doc not found: ${relPath}`);
4881
+ return { content: readFileSync5(target, "utf8") };
3780
4882
  }
3781
- 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", BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3782
- var init_ticket_burner = __esm(() => {
3783
- init_src();
3784
- init_config_load();
3785
- init_paths();
3786
- init_skills_root();
3787
- init_agent_stream();
3788
- init_git();
3789
- PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3790
- PM_CACHE_SANDBOX_PATHS = {
3791
- bun: "~/.bun/install/cache",
3792
- yarn: "~/.cache/yarn",
3793
- npm: "~/.npm"
3794
- };
3795
- BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
3796
- WORKTREE_REMOVAL_FAILURES = [
3797
- /failed to delete/i,
3798
- /directory not empty|enotempty/i,
3799
- /unable to (unlink|delete)/i,
3800
- /is not a working tree/i,
3801
- /resource busy|ebusy|eperm|eacces/i
3802
- ];
3803
- FATAL_ERROR_PATTERNS = [
3804
- /invalid (api key|x-api-key)/i,
3805
- /authentication|unauthorized|permission denied/i,
3806
- /credit balance|billing/i,
3807
- /oauth token|setup-token/i,
3808
- /issue with the selected model|model not found|unknown model/i,
3809
- /does not support resumeSession|resumeSession .* not found/i
3810
- ];
3811
- RETRYABLE_ERROR_PATTERNS = [
3812
- /exited with code \d+/i,
3813
- /idle timeout|AgentIdleTimeout/i,
3814
- /connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
3815
- /econnreset|etimedout|econnrefused|epipe|eai_again/i,
3816
- /overloaded|rate.?limit|too many requests/i,
3817
- /internal server error|service unavailable|bad gateway|gateway timeout/i,
3818
- /\bapi error\b/i,
3819
- /session capture failed/i
4883
+ function readTitle(path, fallback) {
4884
+ try {
4885
+ const firstLine = readFileSync5(path, "utf8").split(`
4886
+ `, 1)[0] ?? "";
4887
+ const heading = firstLine.match(/^#\s+(.+)$/);
4888
+ return heading ? heading[1].trim() : fallback;
4889
+ } catch {
4890
+ return fallback;
4891
+ }
4892
+ }
4893
+ var MAP_SECTIONS;
4894
+ var init_knowledge = __esm(() => {
4895
+ init_errors();
4896
+ init_events();
4897
+ init_feature_docs();
4898
+ init_repo();
4899
+ MAP_SECTIONS = [
4900
+ "Destination",
4901
+ "Notes",
4902
+ "Not yet specified",
4903
+ "Out of scope"
3820
4904
  ];
3821
- activeTicketAborts = new Map;
3822
- SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
3823
- ticketBurner = {
3824
- id: "ticket-burner",
3825
- run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
3826
- };
4905
+ });
4906
+
4907
+ // src/pty/end-session.ts
4908
+ function endSession(ctx, sessionId) {
4909
+ const registry = ptyRegistry();
4910
+ const killed = registry.kill(sessionId);
4911
+ registry.remove(sessionId);
4912
+ const session = markSessionEnded(ctx, sessionId);
4913
+ if (session) {
4914
+ releaseForSession(ctx, sessionId);
4915
+ emit(ctx, session.featureId, {
4916
+ type: "session.ended",
4917
+ message: "session ended by user",
4918
+ data: { sessionId, killed }
4919
+ });
4920
+ } else {
4921
+ const existing = getSessionRow(ctx, sessionId);
4922
+ if (!existing)
4923
+ return { sessionId, ok: false, killed };
4924
+ }
4925
+ return { sessionId, ok: true, killed };
4926
+ }
4927
+ var init_end_session = __esm(() => {
4928
+ init_sessions();
4929
+ init_events();
4930
+ init_waypoints();
4931
+ init_registry();
3827
4932
  });
3828
4933
 
3829
4934
  // src/workflows/research.ts
3830
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
3831
- import { dirname as dirname4, join as join10 } from "path";
3832
- import { fileURLToPath as fileURLToPath3 } from "url";
3833
- import { run as run2 } from "@ai-hero/sandcastle";
4935
+ import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync6, readdirSync as readdirSync4 } from "fs";
4936
+ import { dirname as dirname5, join as join11 } from "path";
4937
+ import { fileURLToPath as fileURLToPath4 } from "url";
4938
+ import { run as run3 } from "@ai-hero/sandcastle";
3834
4939
  import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
3835
4940
  import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
3836
4941
  function waypointSlug(waypoint) {
@@ -3847,7 +4952,7 @@ async function researchRun(ctx, deps) {
3847
4952
  return { status: "failed", summary: "no waypoint to research" };
3848
4953
  }
3849
4954
  if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
3850
- ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
4955
+ ctx.emitEvent({ type: AUTH_MISSING_EVENT3, message: AUTH_MISSING_MESSAGE3 });
3851
4956
  return { status: "failed", summary: "research aborted: auth token missing" };
3852
4957
  }
3853
4958
  ctx.emitEvent({
@@ -3868,12 +4973,12 @@ async function researchRun(ctx, deps) {
3868
4973
  }
3869
4974
  ctx.emitEvent({
3870
4975
  type: "research.failed",
3871
- message: `waypoint ${waypoint.seq} research failed: ${errorHeadline2(outcome.error)}`,
4976
+ message: `waypoint ${waypoint.seq} research failed: ${errorHeadline3(outcome.error)}`,
3872
4977
  data: { waypointId: waypoint.id, error: outcome.error }
3873
4978
  });
3874
4979
  return { status: "failed", summary: outcome.error };
3875
4980
  }
3876
- function errorHeadline2(s) {
4981
+ function errorHeadline3(s) {
3877
4982
  const lines = s.split(`
3878
4983
  `).map((l) => l.trim()).filter(Boolean);
3879
4984
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
@@ -3884,7 +4989,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
3884
4989
  }
3885
4990
  function renderResearchPrompt(template, values) {
3886
4991
  let out = template;
3887
- for (const key of PLACEHOLDERS) {
4992
+ for (const key of PLACEHOLDERS2) {
3888
4993
  out = out.split(`{{${key}}}`).join(values[key]);
3889
4994
  }
3890
4995
  return out;
@@ -3899,32 +5004,32 @@ function interpretResearchResult(result, docRelPath) {
3899
5004
  return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
3900
5005
  }
3901
5006
  function researchTemplatePath() {
3902
- const here = dirname4(fileURLToPath3(import.meta.url));
3903
- return join10(resolveSkillsRoot(here), "burner", "research-waypoint.md");
5007
+ const here = dirname5(fileURLToPath4(import.meta.url));
5008
+ return join11(resolveSkillsRoot(here), "burner", "research-waypoint.md");
3904
5009
  }
3905
5010
  function readDocsDigestFromDisk2(projectId, slug) {
3906
5011
  const worktree = worktreeDir(projectId, slug);
3907
- if (!existsSync11(worktree))
5012
+ if (!existsSync12(worktree))
3908
5013
  return "_No talk worktree on disk \u2014 docs digest skipped._";
3909
- const docsDir = join10(worktree, ...featureDocsRel(slug).split("/"));
3910
- if (!existsSync11(docsDir))
5014
+ const docsDir = join11(worktree, ...featureDocsRel(slug).split("/"));
5015
+ if (!existsSync12(docsDir))
3911
5016
  return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
3912
- const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync5(join10(docsDir, e.name), "utf8") }));
5017
+ const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync6(join11(docsDir, e.name), "utf8") }));
3913
5018
  return buildDocsDigest(files);
3914
5019
  }
3915
5020
  async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
3916
5021
  const { project, feature } = ctx;
3917
5022
  const docRelPath = researchDocRel(feature.slug, waypoint);
3918
5023
  const tempBranch = researchBranchName(feature.slug, waypoint.seq, newId("b").slice(2, 10));
3919
- const template = readFileSync5(researchTemplatePath(), "utf8");
5024
+ const template = readFileSync6(researchTemplatePath(), "utf8");
3920
5025
  const prompt = renderResearchPrompt(template, {
3921
5026
  WAYPOINT_JSON: buildWaypointJson(waypoint),
3922
5027
  FEATURE_BRIEF: buildFeatureBrief(feature),
3923
5028
  DOCS_DIGEST: readDocsDigestFromDisk2(project.id, feature.slug),
3924
5029
  RESEARCH_DOC_PATH: docRelPath
3925
5030
  });
3926
- mkdirSync5(logsDir(), { recursive: true });
3927
- const logFilePath = join10(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
5031
+ mkdirSync6(logsDir(), { recursive: true });
5032
+ const logFilePath = join11(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
3928
5033
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
3929
5034
  const runOptions = {
3930
5035
  agent: buildBurnAgent(config2, token, model),
@@ -3939,7 +5044,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
3939
5044
  let result;
3940
5045
  let salvaged = [];
3941
5046
  try {
3942
- result = await run2(runOptions);
5047
+ result = await run3(runOptions);
3943
5048
  } catch (err) {
3944
5049
  throttle.flush();
3945
5050
  if (ctx.signal.aborted)
@@ -3951,7 +5056,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
3951
5056
  const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
3952
5057
  ctx.emitEvent({
3953
5058
  type: "burn.worktree.teardown-failed",
3954
- 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`,
5059
+ message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline3(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
3955
5060
  data: { tempBranch, error: msg, cleanedUp: removed }
3956
5061
  });
3957
5062
  }
@@ -3964,7 +5069,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
3964
5069
  const detail = merge.error ?? "merge failed";
3965
5070
  ctx.emitEvent({
3966
5071
  type: merge.conflict ? "merge.conflict.needs-human" : "research.merge_failed",
3967
- 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`,
5072
+ 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}: ${errorHeadline3(detail)} \u2014 the branch is preserved for manual recovery`,
3968
5073
  data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
3969
5074
  });
3970
5075
  return {
@@ -3986,9 +5091,9 @@ function resolveResearchDeps(ctx) {
3986
5091
  }
3987
5092
  function readTokenFromEnvFile2(path) {
3988
5093
  let fromFile;
3989
- if (existsSync11(path)) {
5094
+ if (existsSync12(path)) {
3990
5095
  try {
3991
- fromFile = parseEnvFile(readFileSync5(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
5096
+ fromFile = parseEnvFile(readFileSync6(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
3992
5097
  } catch {
3993
5098
  fromFile = undefined;
3994
5099
  }
@@ -3996,7 +5101,7 @@ function readTokenFromEnvFile2(path) {
3996
5101
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3997
5102
  return token && token.length > 0 ? token : undefined;
3998
5103
  }
3999
- var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, research;
5104
+ var AUTH_MISSING_EVENT3 = "auth.missing", AUTH_MISSING_MESSAGE3 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS2, research;
4000
5105
  var init_research = __esm(() => {
4001
5106
  init_src();
4002
5107
  init_config_load();
@@ -4004,7 +5109,7 @@ var init_research = __esm(() => {
4004
5109
  init_skills_root();
4005
5110
  init_git();
4006
5111
  init_ticket_burner();
4007
- PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
5112
+ PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
4008
5113
  research = {
4009
5114
  id: "research",
4010
5115
  run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
@@ -4026,12 +5131,12 @@ var init_registry2 = __esm(() => {
4026
5131
  });
4027
5132
 
4028
5133
  // src/workflows/runner.ts
4029
- import { eq as eq6 } from "drizzle-orm";
5134
+ import { eq as eq8 } from "drizzle-orm";
4030
5135
  function workflowClaimsFeatureBranch(workflowId) {
4031
5136
  return BRANCH_CLAIMING.has(workflowId);
4032
5137
  }
4033
5138
  function isRunActive(runId) {
4034
- return controllers.has(runId);
5139
+ return controllers2.has(runId);
4035
5140
  }
4036
5141
  async function startRun(ctx, featureId, workflowId, opts = {}) {
4037
5142
  const feature = getFeatureRow(ctx, featureId);
@@ -4055,7 +5160,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4055
5160
  claim(ctx, opts.claimWaypointId, runId);
4056
5161
  } catch (e) {
4057
5162
  const summary = e instanceof Error ? e.message : "claim failed";
4058
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
5163
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
4059
5164
  throw e;
4060
5165
  }
4061
5166
  }
@@ -4066,7 +5171,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4066
5171
  data: { workflow: workflowId }
4067
5172
  });
4068
5173
  const controller = new AbortController;
4069
- controllers.set(runId, controller);
5174
+ controllers2.set(runId, controller);
4070
5175
  const talkWorktree = worktreeDir(project.id, feature.slug);
4071
5176
  const talkDetached = workflowClaimsFeatureBranch(workflowId) ? await detachWorktree(talkWorktree) : false;
4072
5177
  const wctx = {
@@ -4092,16 +5197,16 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4092
5197
  },
4093
5198
  signal: controller.signal
4094
5199
  };
4095
- const done = executeRun(ctx, runId, featureId, def.run(wctx), controller, async () => {
5200
+ const done = executeRun(ctx, runId, featureId, workflowId, def.run(wctx), controller, async () => {
4096
5201
  if (talkDetached)
4097
5202
  await reattachWorktree(talkWorktree, feature.branch);
4098
5203
  });
4099
5204
  return { runId, done };
4100
5205
  }
4101
5206
  function cancelRun(runId) {
4102
- controllers.get(runId)?.abort();
5207
+ controllers2.get(runId)?.abort();
4103
5208
  }
4104
- async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup) {
5209
+ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
4105
5210
  let status = "failed";
4106
5211
  let summary = "run failed";
4107
5212
  try {
@@ -4118,10 +5223,13 @@ async function executeRun(ctx, runId, featureId, runPromise, controller, cleanup
4118
5223
  }
4119
5224
  emit(ctx, featureId, { type: "run.error", message: summary, runId });
4120
5225
  } finally {
4121
- controllers.delete(runId);
5226
+ controllers2.delete(runId);
4122
5227
  }
4123
- ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
5228
+ ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
4124
5229
  releaseForSession(ctx, runId);
5230
+ if (workflowClaimsFeatureBranch(workflow2)) {
5231
+ sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
5232
+ }
4125
5233
  emit(ctx, featureId, {
4126
5234
  type: "run.finished",
4127
5235
  message: `run ${status}: ${summary}`,
@@ -4147,7 +5255,7 @@ function maybeAutoAdvance(ctx, featureId) {
4147
5255
  if (next)
4148
5256
  setPhase(ctx, featureId, next, "phase.advanced", "auto-advanced to review after successful run");
4149
5257
  }
4150
- var controllers, BRANCH_CLAIMING;
5258
+ var controllers2, BRANCH_CLAIMING;
4151
5259
  var init_runner = __esm(() => {
4152
5260
  init_src();
4153
5261
  init_paths();
@@ -4160,12 +5268,12 @@ var init_runner = __esm(() => {
4160
5268
  init_tickets();
4161
5269
  init_waypoints();
4162
5270
  init_registry2();
4163
- controllers = new Map;
5271
+ controllers2 = new Map;
4164
5272
  BRANCH_CLAIMING = new Set(["ticket-burner"]);
4165
5273
  });
4166
5274
 
4167
5275
  // src/services/features.ts
4168
- import { desc as desc3, eq as eq7 } from "drizzle-orm";
5276
+ import { desc as desc4, eq as eq9 } from "drizzle-orm";
4169
5277
  import { rmSync as rmSync2 } from "fs";
4170
5278
  async function createFeature(ctx, input) {
4171
5279
  const project = requireProjectById(ctx, input.projectId);
@@ -4228,7 +5336,7 @@ function getFeatureFull(ctx, id) {
4228
5336
  };
4229
5337
  }
4230
5338
  function list(ctx, projectId) {
4231
- const rows = ctx.db.select().from(features).where(eq7(features.projectId, projectId)).orderBy(desc3(features.createdAt)).all();
5339
+ const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc4(features.createdAt)).all();
4232
5340
  return rows.map((row) => {
4233
5341
  const feature = rowToFeature(row);
4234
5342
  const tickets2 = listByFeature2(ctx, feature.id);
@@ -4263,7 +5371,7 @@ function advance(ctx, featureId) {
4263
5371
  async function burn(ctx, featureId, opts = {}) {
4264
5372
  const feature = getFeatureRow(ctx, featureId);
4265
5373
  const running = hasActiveRun(ctx, featureId);
4266
- const tickets2 = listByFeature2(ctx, featureId);
5374
+ let tickets2 = listByFeature2(ctx, featureId);
4267
5375
  const pending = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
4268
5376
  const restarting = feature.phase === "implementation" && !running;
4269
5377
  const iterating = feature.phase === "review" && !running && pending.length >= 1;
@@ -4281,6 +5389,8 @@ async function burn(ctx, featureId, opts = {}) {
4281
5389
  throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
4282
5390
  }
4283
5391
  if (restarting) {
5392
+ sweepOrphanedBurning(ctx, featureId, "orphaned \u2014 the previous run died while it was burning");
5393
+ tickets2 = listByFeature2(ctx, featureId);
4284
5394
  const failed = opts.resetFailed === false ? [] : tickets2.filter((t) => t.status === "failed");
4285
5395
  for (const t of failed) {
4286
5396
  updateTicket(ctx, t.id, { status: "pending", error: null });
@@ -4371,7 +5481,7 @@ function escalateToMap(ctx, featureId, input) {
4371
5481
  return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
4372
5482
  }
4373
5483
  const project = projectForFeature(ctx, feature);
4374
- ctx.db.update(features).set({ mapped: true }).where(eq7(features.id, featureId)).run();
5484
+ ctx.db.update(features).set({ mapped: true }).where(eq9(features.id, featureId)).run();
4375
5485
  scaffoldMapDoc(project, { ...feature, mapped: true }, input);
4376
5486
  emit(ctx, featureId, {
4377
5487
  type: "feature.escalated",
@@ -4388,7 +5498,7 @@ function archiveFeature(ctx, featureId) {
4388
5498
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
4389
5499
  if (live)
4390
5500
  endSession(ctx, live.id);
4391
- ctx.db.update(features).set({ status: "archived" }).where(eq7(features.id, featureId)).run();
5501
+ ctx.db.update(features).set({ status: "archived" }).where(eq9(features.id, featureId)).run();
4392
5502
  emit(ctx, featureId, {
4393
5503
  type: "feature.archived",
4394
5504
  message: `feature ${feature.slug} archived`,
@@ -4402,7 +5512,7 @@ function unarchiveFeature(ctx, featureId) {
4402
5512
  throw new GateError(`feature ${feature.slug} is not archived`);
4403
5513
  }
4404
5514
  const restored = feature.phase === "shipped" ? "shipped" : "active";
4405
- ctx.db.update(features).set({ status: restored }).where(eq7(features.id, featureId)).run();
5515
+ ctx.db.update(features).set({ status: restored }).where(eq9(features.id, featureId)).run();
4406
5516
  emit(ctx, featureId, {
4407
5517
  type: "feature.unarchived",
4408
5518
  message: `feature ${feature.slug} unarchived (${restored})`,
@@ -4416,9 +5526,9 @@ async function deleteFeature(ctx, featureId) {
4416
5526
  throw new GateError(`feature ${feature.slug} is shipped \u2014 archive it instead (delete is for non-shipped features)`);
4417
5527
  }
4418
5528
  const project = projectForFeature(ctx, feature);
4419
- for (const run3 of listRunsByFeature(ctx, featureId)) {
4420
- if (run3.status === "running")
4421
- cancelRun(run3.id);
5529
+ for (const run4 of listRunsByFeature(ctx, featureId)) {
5530
+ if (run4.status === "running")
5531
+ cancelRun(run4.id);
4422
5532
  }
4423
5533
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
4424
5534
  if (live)
@@ -4442,13 +5552,13 @@ async function deleteFeature(ctx, featureId) {
4442
5552
  return { ok: true, slug: feature.slug };
4443
5553
  }
4444
5554
  function deleteFeatureRows(ctx, featureId) {
4445
- ctx.db.delete(tickets).where(eq7(tickets.featureId, featureId)).run();
4446
- ctx.db.delete(sessions).where(eq7(sessions.featureId, featureId)).run();
4447
- ctx.db.delete(runs).where(eq7(runs.featureId, featureId)).run();
4448
- ctx.db.delete(events).where(eq7(events.featureId, featureId)).run();
4449
- ctx.db.delete(gateOverrides).where(eq7(gateOverrides.featureId, featureId)).run();
4450
- ctx.db.delete(waypoints).where(eq7(waypoints.featureId, featureId)).run();
4451
- ctx.db.delete(features).where(eq7(features.id, featureId)).run();
5555
+ ctx.db.delete(tickets).where(eq9(tickets.featureId, featureId)).run();
5556
+ ctx.db.delete(sessions).where(eq9(sessions.featureId, featureId)).run();
5557
+ ctx.db.delete(runs).where(eq9(runs.featureId, featureId)).run();
5558
+ ctx.db.delete(events).where(eq9(events.featureId, featureId)).run();
5559
+ ctx.db.delete(gateOverrides).where(eq9(gateOverrides.featureId, featureId)).run();
5560
+ ctx.db.delete(waypoints).where(eq9(waypoints.featureId, featureId)).run();
5561
+ ctx.db.delete(features).where(eq9(features.id, featureId)).run();
4452
5562
  }
4453
5563
  function gateState(ctx, feature) {
4454
5564
  const gate = nextGate(feature);
@@ -4459,7 +5569,7 @@ function gateState(ctx, feature) {
4459
5569
  }
4460
5570
  function uniqueSlug(ctx, projectId, title) {
4461
5571
  const base = slugify(title);
4462
- const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq7(features.projectId, projectId)).all().map((r) => r.slug));
5572
+ const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq9(features.projectId, projectId)).all().map((r) => r.slug));
4463
5573
  if (!existing.has(base))
4464
5574
  return base;
4465
5575
  let n = 2;
@@ -4491,7 +5601,7 @@ var init_features = __esm(() => {
4491
5601
  import { StreamableHTTPTransport } from "@hono/mcp";
4492
5602
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4493
5603
  import { Hono } from "hono";
4494
- import * as z3 from "zod";
5604
+ import * as z4 from "zod";
4495
5605
  function headerSessionId(extra) {
4496
5606
  const headers = extra.requestInfo?.headers;
4497
5607
  if (!headers)
@@ -4645,7 +5755,7 @@ function buildMcpServer() {
4645
5755
  server.registerTool("emit_tickets", {
4646
5756
  title: "Emit tickets",
4647
5757
  description: "Store the ideation session's ticket batch. Each ticket: title, goal, context, acceptanceCriteria[], seams[], blockedBy[] (1-based positions within THIS batch).",
4648
- inputSchema: { tickets: z3.array(TicketInput) }
5758
+ inputSchema: { tickets: z4.array(TicketInput) }
4649
5759
  }, async (args, extra) => {
4650
5760
  const rs = await resolveCtxSession(extra);
4651
5761
  if (!rs)
@@ -4658,12 +5768,12 @@ function buildMcpServer() {
4658
5768
  title: "Update ticket",
4659
5769
  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.",
4660
5770
  inputSchema: {
4661
- id: z3.string(),
4662
- title: z3.string().optional(),
4663
- goal: z3.string().optional(),
4664
- context: z3.string().optional(),
4665
- acceptanceCriteria: z3.array(z3.string()).optional(),
4666
- seams: z3.array(z3.string()).optional()
5771
+ id: z4.string(),
5772
+ title: z4.string().optional(),
5773
+ goal: z4.string().optional(),
5774
+ context: z4.string().optional(),
5775
+ acceptanceCriteria: z4.array(z4.string()).optional(),
5776
+ seams: z4.array(z4.string()).optional()
4667
5777
  }
4668
5778
  }, async (args, extra) => {
4669
5779
  const rs = await resolveCtxSession(extra);
@@ -4676,7 +5786,7 @@ function buildMcpServer() {
4676
5786
  server.registerTool("cancel_ticket", {
4677
5787
  title: "Cancel ticket",
4678
5788
  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.",
4679
- inputSchema: { id: z3.string(), reason: z3.string().optional() }
5789
+ inputSchema: { id: z4.string(), reason: z4.string().optional() }
4680
5790
  }, async (args, extra) => {
4681
5791
  const rs = await resolveCtxSession(extra);
4682
5792
  if (!rs)
@@ -4688,7 +5798,7 @@ function buildMcpServer() {
4688
5798
  server.registerTool("escalate_to_map", {
4689
5799
  title: "Escalate to map",
4690
5800
  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.",
4691
- inputSchema: { destination: z3.string(), notes: z3.string().optional() }
5801
+ inputSchema: { destination: z4.string(), notes: z4.string().optional() }
4692
5802
  }, async (args, extra) => {
4693
5803
  const rs = await resolveCtxSession(extra);
4694
5804
  if (!rs)
@@ -4702,7 +5812,7 @@ function buildMcpServer() {
4702
5812
  server.registerTool("emit_waypoints", {
4703
5813
  title: "Emit waypoints",
4704
5814
  description: "Batch-create waypoints on the map (the feature must already be mapped \u2014 call escalate_to_map first). Each waypoint: title, type (grilling|research|prototype|task), question, blockedBy[] (1-based positions within THIS batch, and/or ids of already-stored waypoints). Available from any session once mapped \u2014 any session may branch the map.",
4705
- inputSchema: { waypoints: z3.array(WaypointInput) }
5815
+ inputSchema: { waypoints: z4.array(WaypointInput) }
4706
5816
  }, async (args, extra) => {
4707
5817
  const rs = await resolveCtxSession(extra);
4708
5818
  if (!rs)
@@ -4712,7 +5822,7 @@ function buildMcpServer() {
4712
5822
  server.registerTool("resolve_waypoint", {
4713
5823
  title: "Resolve waypoint",
4714
5824
  description: "End the current waypoint: `resolved` (its question is answered) or `dropped` (no longer needed). Write the decision prose to decisions.md (or the gist to map.md Out-of-scope for a drop) FIRST \u2014 this tool flips machinery only (marks the waypoint terminal, frees any dependents on the frontier). `summary` is the one-line gist shown in the UI. Call this exactly once, as the last thing you do.",
4715
- inputSchema: { id: z3.string(), disposition: WaypointDisposition, summary: z3.string() }
5825
+ inputSchema: { id: z4.string(), disposition: WaypointDisposition, summary: z4.string() }
4716
5826
  }, async (args, extra) => {
4717
5827
  const rs = await resolveCtxSession(extra);
4718
5828
  if (!rs)
@@ -4724,7 +5834,7 @@ function buildMcpServer() {
4724
5834
  server.registerTool("record_event", {
4725
5835
  title: "Record event",
4726
5836
  description: "Add a note to the feature timeline (decisions recorded, spec saved, milestones).",
4727
- inputSchema: { type: z3.string(), message: z3.string() }
5837
+ inputSchema: { type: z4.string(), message: z4.string() }
4728
5838
  }, async (args, extra) => {
4729
5839
  const rs = await resolveCtxSession(extra);
4730
5840
  if (!rs)
@@ -4847,12 +5957,16 @@ import { Hono as Hono2 } from "hono";
4847
5957
  function handleSessionStart(ctx, sessionId, feature, payload) {
4848
5958
  const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
4849
5959
  const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
5960
+ const source = typeof payload?.source === "string" ? payload.source : undefined;
5961
+ const wasLive = getSessionRow(ctx, sessionId)?.status === "live";
4850
5962
  markSessionLive(ctx, sessionId, { ccSessionId, transcriptPath });
4851
- emit(ctx, feature.id, {
4852
- type: "session.started",
4853
- message: "session live",
4854
- data: { sessionId, ccSessionId, transcriptPath }
4855
- });
5963
+ if (!wasLive) {
5964
+ emit(ctx, feature.id, {
5965
+ type: "session.started",
5966
+ message: source === "resume" ? "session live (conversation resumed)" : "session live",
5967
+ data: { sessionId, ccSessionId, transcriptPath, source: source ?? null }
5968
+ });
5969
+ }
4856
5970
  return {
4857
5971
  hookSpecificOutput: {
4858
5972
  hookEventName: "SessionStart",
@@ -4860,7 +5974,9 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
4860
5974
  }
4861
5975
  };
4862
5976
  }
4863
- function handleUserPrompt(ctx, feature) {
5977
+ function handleUserPrompt(ctx, sessionId, feature, payload) {
5978
+ const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
5979
+ noteKickoffPrompt(ctx, sessionId, prompt);
4864
5980
  const tickets2 = listByFeature2(ctx, feature.id).length;
4865
5981
  return {
4866
5982
  hookSpecificOutput: {
@@ -4927,7 +6043,7 @@ var init_hooks = __esm(() => {
4927
6043
  case "session-start":
4928
6044
  return c.json(handleSessionStart(ctx, sessionId, feature, body.payload));
4929
6045
  case "user-prompt":
4930
- return c.json(handleUserPrompt(ctx, feature));
6046
+ return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
4931
6047
  case "session-end":
4932
6048
  return c.json(handleSessionEnd(ctx, sessionId, feature));
4933
6049
  default:
@@ -4992,9 +6108,9 @@ var init_stream = __esm(() => {
4992
6108
  });
4993
6109
 
4994
6110
  // src/routes/web.ts
4995
- import { existsSync as existsSync12, statSync } from "fs";
6111
+ import { existsSync as existsSync13, statSync } from "fs";
4996
6112
  import { readFile } from "fs/promises";
4997
- import { extname, join as join11, normalize, resolve as resolve6, sep as sep2 } from "path";
6113
+ import { extname, join as join12, normalize, resolve as resolve6, sep as sep2 } from "path";
4998
6114
  function contentType(path) {
4999
6115
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
5000
6116
  }
@@ -5012,7 +6128,7 @@ function safeJoin(root, pathname) {
5012
6128
  } catch {
5013
6129
  return null;
5014
6130
  }
5015
- const joined = normalize(join11(root, decoded));
6131
+ const joined = normalize(join12(root, decoded));
5016
6132
  if (joined !== root && !joined.startsWith(root + sep2))
5017
6133
  return null;
5018
6134
  return joined;
@@ -5025,7 +6141,7 @@ function resolveWebDist() {
5025
6141
  }
5026
6142
  function mountWebApp(app, distDir) {
5027
6143
  const root = resolve6(distDir);
5028
- const indexHtml = join11(root, "index.html");
6144
+ const indexHtml = join12(root, "index.html");
5029
6145
  app.get("*", async (c, next) => {
5030
6146
  const { pathname } = new URL(c.req.url);
5031
6147
  if (/^\/(api|mcp|ws|health)(\/|$)/.test(pathname))
@@ -5045,7 +6161,7 @@ function mountWebApp(app, distDir) {
5045
6161
  }
5046
6162
  function mountWebAppIfBuilt(app) {
5047
6163
  const dist = resolveWebDist();
5048
- if (existsSync12(join11(dist, "index.html")))
6164
+ if (existsSync13(join12(dist, "index.html")))
5049
6165
  mountWebApp(app, dist);
5050
6166
  }
5051
6167
  var MIME;
@@ -5092,14 +6208,14 @@ var init_context = __esm(() => {
5092
6208
  });
5093
6209
 
5094
6210
  // src/trpc/routers/docs.ts
5095
- import * as z4 from "zod";
6211
+ import * as z5 from "zod";
5096
6212
  var docsRouter;
5097
6213
  var init_docs = __esm(() => {
5098
6214
  init_knowledge();
5099
6215
  init_repo();
5100
6216
  init_context();
5101
6217
  docsRouter = router({
5102
- read: publicProcedure.input(z4.object({ featureId: z4.string(), relPath: z4.string() })).query(({ ctx, input }) => {
6218
+ read: publicProcedure.input(z5.object({ featureId: z5.string(), relPath: z5.string() })).query(({ ctx, input }) => {
5103
6219
  const feature = getFeatureRow(ctx, input.featureId);
5104
6220
  return readDoc(ctx, feature, input.relPath);
5105
6221
  })
@@ -5107,23 +6223,23 @@ var init_docs = __esm(() => {
5107
6223
  });
5108
6224
 
5109
6225
  // src/trpc/routers/events.ts
5110
- import * as z5 from "zod";
6226
+ import * as z6 from "zod";
5111
6227
  var eventsRouter;
5112
6228
  var init_events2 = __esm(() => {
5113
6229
  init_events();
5114
6230
  init_context();
5115
6231
  eventsRouter = router({
5116
- list: publicProcedure.input(z5.object({ featureId: z5.string(), afterId: z5.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
5117
- listByProject: publicProcedure.input(z5.object({ projectId: z5.string(), afterId: z5.number().optional() })).query(({ ctx, input }) => listByProject(ctx, input.projectId, input.afterId))
6232
+ list: publicProcedure.input(z6.object({ featureId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
6233
+ listByProject: publicProcedure.input(z6.object({ projectId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listByProject(ctx, input.projectId, input.afterId))
5118
6234
  });
5119
6235
  });
5120
6236
 
5121
6237
  // src/launcher/artifacts.ts
5122
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync2 } from "fs";
5123
- import { fileURLToPath as fileURLToPath4 } from "url";
5124
- import { join as join12 } from "path";
6238
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync2 } from "fs";
6239
+ import { fileURLToPath as fileURLToPath5 } from "url";
6240
+ import { join as join13 } from "path";
5125
6241
  function hookClientPath() {
5126
- return resolveAsset(ASSET_ENV.hookClient, fileURLToPath4(new URL("./hook-client.ts", import.meta.url)));
6242
+ return resolveAsset(ASSET_ENV.hookClient, fileURLToPath5(new URL("./hook-client.ts", import.meta.url)));
5127
6243
  }
5128
6244
  function serverUrlFor(config2) {
5129
6245
  return `http://localhost:${config2.serverPort}`;
@@ -5324,7 +6440,10 @@ function renderSettings(hookClient) {
5324
6440
  return {
5325
6441
  permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...SESSION_BASH_ALLOW_RULES] },
5326
6442
  hooks: {
5327
- SessionStart: [{ matcher: "startup", hooks: [cmd("session-start")] }],
6443
+ SessionStart: SESSION_START_SOURCES.map((source) => ({
6444
+ matcher: source,
6445
+ hooks: [cmd("session-start")]
6446
+ })),
5328
6447
  UserPromptSubmit: [{ hooks: [cmd("user-prompt")] }],
5329
6448
  SessionEnd: [{ hooks: [cmd("session-end")] }]
5330
6449
  }
@@ -5344,16 +6463,16 @@ function renderMcpConfig(session, config2) {
5344
6463
  async function writeSessionArtifacts(input) {
5345
6464
  const { session, feature, config: config2, waypoint } = input;
5346
6465
  const dir = sessionDir(session.id);
5347
- mkdirSync6(dir, { recursive: true });
5348
- const systemPromptPath = join12(dir, "system-prompt.md");
5349
- const settingsPath = join12(dir, "settings.json");
5350
- const mcpConfigPath = join12(dir, "mcp.json");
6466
+ mkdirSync7(dir, { recursive: true });
6467
+ const systemPromptPath = join13(dir, "system-prompt.md");
6468
+ const settingsPath = join13(dir, "settings.json");
6469
+ const mcpConfigPath = join13(dir, "mcp.json");
5351
6470
  writeFileSync2(systemPromptPath, renderSystemPrompt(feature, session.kind, waypoint), "utf8");
5352
6471
  writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
5353
6472
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
5354
6473
  return { systemPromptPath, settingsPath, mcpConfigPath };
5355
6474
  }
5356
- var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES;
6475
+ var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
5357
6476
  var init_artifacts = __esm(() => {
5358
6477
  init_paths();
5359
6478
  init_asset_paths();
@@ -5378,13 +6497,14 @@ var init_artifacts = __esm(() => {
5378
6497
  "Bash(git branch:*)",
5379
6498
  "Bash(git show:*)"
5380
6499
  ];
6500
+ SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
5381
6501
  });
5382
6502
 
5383
6503
  // src/launcher/launcher.ts
5384
- import { existsSync as existsSync13 } from "fs";
5385
- import { dirname as dirname5, join as join13, resolve as resolve7 } from "path";
5386
- import { fileURLToPath as fileURLToPath5 } from "url";
5387
- import { and as and5, eq as eq8 } from "drizzle-orm";
6504
+ import { existsSync as existsSync14 } from "fs";
6505
+ import { dirname as dirname6, join as join14, resolve as resolve7 } from "path";
6506
+ import { fileURLToPath as fileURLToPath6 } from "url";
6507
+ import { and as and7, eq as eq10 } from "drizzle-orm";
5388
6508
  function ptyExitMessage(exitCode) {
5389
6509
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
5390
6510
  return `terminal exited (code ${label})`;
@@ -5410,32 +6530,28 @@ function buildClaudeArgs(input) {
5410
6530
  ];
5411
6531
  }
5412
6532
  function resolveClaudeExecutable() {
5413
- return resolveExecutable("claude", { override: process.env.RUNCASTLE_CLAUDE_BIN });
6533
+ return resolveTool("claude");
5414
6534
  }
5415
6535
  function claudeSpawnTarget(claudeArgs) {
5416
- const exe = resolveClaudeExecutable();
5417
- if (/\.(cmd|bat)$/i.test(exe)) {
5418
- return { file: process.env.ComSpec ?? "cmd.exe", args: ["/c", exe, ...claudeArgs] };
5419
- }
5420
- return { file: exe, args: claudeArgs };
6536
+ return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
5421
6537
  }
5422
- function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5423
- const rel = join13("packages", "skills", "packs", "runcastle");
6538
+ function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
6539
+ const rel = join14("packages", "skills", "packs", "runcastle");
5424
6540
  const override = process.env[SKILLS_DIR_ENV];
5425
6541
  if (override) {
5426
- const dir2 = join13(resolve7(override), "packs", "runcastle");
5427
- if (existsSync13(dir2))
6542
+ const dir2 = join14(resolve7(override), "packs", "runcastle");
6543
+ if (existsSync14(dir2))
5428
6544
  return dir2;
5429
6545
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
5430
6546
  }
5431
6547
  const searched = [];
5432
6548
  let dir = fromDir;
5433
6549
  for (let i = 0;i < 8; i += 1) {
5434
- const candidate = join13(dir, rel);
6550
+ const candidate = join14(dir, rel);
5435
6551
  searched.push(candidate);
5436
- if (existsSync13(candidate))
6552
+ if (existsSync14(candidate))
5437
6553
  return candidate;
5438
- const parent = dirname5(dir);
6554
+ const parent = dirname6(dir);
5439
6555
  if (parent === dir)
5440
6556
  break;
5441
6557
  dir = parent;
@@ -5445,7 +6561,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5445
6561
  `)}`);
5446
6562
  }
5447
6563
  function activeRunFor(ctx, featureId) {
5448
- const row = ctx.db.select().from(runs).where(and5(eq8(runs.featureId, featureId), eq8(runs.status, "running"))).limit(1).get();
6564
+ const row = ctx.db.select().from(runs).where(and7(eq10(runs.featureId, featureId), eq10(runs.status, "running"))).limit(1).get();
5449
6565
  return row ? rowToRun(row) : null;
5450
6566
  }
5451
6567
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -5696,6 +6812,7 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
5696
6812
  message: "embedded terminal spawned",
5697
6813
  data: { sessionId: session.id, mode: "embedded", pid: entry.pty.pid }
5698
6814
  });
6815
+ armSessionReadyWatchdog(ctx, session);
5699
6816
  } catch (err) {
5700
6817
  markSessionEnded(ctx, session.id);
5701
6818
  releaseForSession(ctx, session.id);
@@ -5724,6 +6841,7 @@ var init_launcher = __esm(() => {
5724
6841
  init_runner();
5725
6842
  init_artifacts();
5726
6843
  init_sessions();
6844
+ init_sessions();
5727
6845
  init_end_session();
5728
6846
  CC_NESTING_ENV = [
5729
6847
  "CLAUDE_CODE_CHILD_SESSION",
@@ -5738,7 +6856,7 @@ var init_launcher = __esm(() => {
5738
6856
  });
5739
6857
 
5740
6858
  // src/trpc/routers/feature.ts
5741
- import * as z6 from "zod";
6859
+ import * as z7 from "zod";
5742
6860
  var gateId, featureRouter;
5743
6861
  var init_feature = __esm(() => {
5744
6862
  init_src();
@@ -5749,37 +6867,38 @@ var init_feature = __esm(() => {
5749
6867
  init_git();
5750
6868
  init_repo();
5751
6869
  init_context();
5752
- gateId = z6.enum(["G1", "G2", "G3", "G4", "G5"]);
6870
+ gateId = z7.enum(["G1", "G2", "G3", "G4", "G5"]);
5753
6871
  featureRouter = router({
5754
- create: publicProcedure.input(z6.object({
5755
- projectId: z6.string(),
5756
- title: z6.string().min(1),
5757
- oneLiner: z6.string(),
5758
- baseBranch: z6.string().optional()
6872
+ create: publicProcedure.input(z7.object({
6873
+ projectId: z7.string(),
6874
+ title: z7.string().min(1),
6875
+ oneLiner: z7.string(),
6876
+ baseBranch: z7.string().optional()
5759
6877
  })).mutation(({ ctx, input }) => createFeature(ctx, input)),
5760
- list: publicProcedure.input(z6.object({ projectId: z6.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
5761
- get: publicProcedure.input(z6.object({ id: z6.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
5762
- launchSession: publicProcedure.input(z6.object({
5763
- featureId: z6.string(),
6878
+ list: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
6879
+ get: publicProcedure.input(z7.object({ id: z7.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
6880
+ launchSession: publicProcedure.input(z7.object({
6881
+ featureId: z7.string(),
5764
6882
  kind: SessionKind,
5765
- kickoffLine: z6.string().min(1).optional()
6883
+ kickoffLine: z7.string().min(1).optional()
5766
6884
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
5767
- workWaypoint: publicProcedure.input(z6.object({ featureId: z6.string(), waypointId: z6.string() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
5768
- converge: publicProcedure.input(z6.object({ featureId: z6.string(), overrideReason: z6.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
5769
- endSession: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
5770
- advance: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
5771
- overrideGate: publicProcedure.input(z6.object({ featureId: z6.string(), gate: gateId, reason: z6.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
5772
- archive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
5773
- unarchive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
5774
- delete: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
5775
- burn: publicProcedure.input(z6.object({ featureId: z6.string(), model: z6.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
5776
- testDrive: publicProcedure.input(z6.object({ featureId: z6.string(), action: z6.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
6885
+ workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
6886
+ converge: publicProcedure.input(z7.object({ featureId: z7.string(), overrideReason: z7.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
6887
+ resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
6888
+ endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
6889
+ advance: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
6890
+ overrideGate: publicProcedure.input(z7.object({ featureId: z7.string(), gate: gateId, reason: z7.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
6891
+ archive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
6892
+ unarchive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
6893
+ delete: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
6894
+ burn: publicProcedure.input(z7.object({ featureId: z7.string(), model: z7.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
6895
+ testDrive: publicProcedure.input(z7.object({ featureId: z7.string(), action: z7.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
5777
6896
  const feature = getFeatureRow(ctx, input.featureId);
5778
6897
  const project = projectForFeature(ctx, feature);
5779
6898
  return testDrive(ctx, project, feature, input.action);
5780
6899
  }),
5781
6900
  driveInfo: publicProcedure.query(() => activeDriveInfo()),
5782
- merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
6901
+ merge: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(async ({ ctx, input }) => {
5783
6902
  const feature = getFeatureRow(ctx, input.featureId);
5784
6903
  const project = projectForFeature(ctx, feature);
5785
6904
  if (activeTestDriveFeatureId() === feature.id) {
@@ -5802,9 +6921,9 @@ var init_feature = __esm(() => {
5802
6921
  });
5803
6922
 
5804
6923
  // src/services/fsbrowse.ts
5805
- import { existsSync as existsSync14, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
6924
+ import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
5806
6925
  import { homedir as homedir2 } from "os";
5807
- import { basename, dirname as dirname6, isAbsolute, join as join14, resolve as resolve8, sep as sep3 } from "path";
6926
+ import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve8, sep as sep3 } from "path";
5808
6927
  function expandPath(input, home = homedir2()) {
5809
6928
  const trimmed = input.trim();
5810
6929
  if (trimmed === "")
@@ -5813,12 +6932,12 @@ function expandPath(input, home = homedir2()) {
5813
6932
  if (out === "~")
5814
6933
  out = home;
5815
6934
  else if (out.startsWith("~/") || out.startsWith("~\\"))
5816
- out = join14(home, out.slice(2));
6935
+ out = join15(home, out.slice(2));
5817
6936
  return resolve8(out);
5818
6937
  }
5819
6938
  function looksLikeRepo(dir) {
5820
6939
  try {
5821
- return existsSync14(join14(dir, ".git"));
6940
+ return existsSync15(join15(dir, ".git"));
5822
6941
  } catch {
5823
6942
  return false;
5824
6943
  }
@@ -5829,20 +6948,20 @@ function listRoots(home = homedir2()) {
5829
6948
  for (let code = 67;code <= 90; code++) {
5830
6949
  const drive = `${String.fromCharCode(code)}:${sep3}`;
5831
6950
  try {
5832
- if (existsSync14(drive))
6951
+ if (existsSync15(drive))
5833
6952
  roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
5834
6953
  } catch {}
5835
6954
  }
5836
6955
  } else {
5837
6956
  roots.push({ label: "/", path: "/", kind: "drive" });
5838
6957
  }
5839
- if (existsSync14(home))
6958
+ if (existsSync15(home))
5840
6959
  roots.push({ label: "Home", path: home, kind: "home" });
5841
6960
  const seen = new Set(roots.map(pathKey));
5842
6961
  for (const name of COMMON_DIRS) {
5843
- const candidate = join14(home, name);
6962
+ const candidate = join15(home, name);
5844
6963
  const key = pathKey({ path: candidate });
5845
- if (seen.has(key) || !existsSync14(candidate))
6964
+ if (seen.has(key) || !existsSync15(candidate))
5846
6965
  continue;
5847
6966
  seen.add(key);
5848
6967
  roots.push({ label: name, path: candidate, kind: "common" });
@@ -5857,7 +6976,7 @@ function crumbsFor(dir) {
5857
6976
  let cur = dir;
5858
6977
  for (;; ) {
5859
6978
  out.unshift({ name: basename(cur) || cur, path: cur });
5860
- const parent = dirname6(cur);
6979
+ const parent = dirname7(cur);
5861
6980
  if (parent === cur)
5862
6981
  break;
5863
6982
  cur = parent;
@@ -5865,7 +6984,7 @@ function crumbsFor(dir) {
5865
6984
  return out;
5866
6985
  }
5867
6986
  function parentOf(dir) {
5868
- const parent = dirname6(dir);
6987
+ const parent = dirname7(dir);
5869
6988
  return parent === dir ? null : parent;
5870
6989
  }
5871
6990
  function browseDir(input, showHidden = false, home = homedir2()) {
@@ -5873,13 +6992,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
5873
6992
  if (!isAbsolute(target)) {
5874
6993
  throw new InvalidInputError(`path is not absolute: ${target}`);
5875
6994
  }
5876
- if (!existsSync14(target)) {
6995
+ if (!existsSync15(target)) {
5877
6996
  throw new InvalidInputError(`path does not exist: ${target}`);
5878
6997
  }
5879
6998
  let dir = target;
5880
6999
  try {
5881
7000
  if (!statSync2(target).isDirectory())
5882
- dir = dirname6(target);
7001
+ dir = dirname7(target);
5883
7002
  } catch (e) {
5884
7003
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg2(e)})`);
5885
7004
  }
@@ -5901,7 +7020,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
5901
7020
  continue;
5902
7021
  if (!showHidden && name.startsWith("."))
5903
7022
  continue;
5904
- const path = join14(dir, name);
7023
+ const path = join15(dir, name);
5905
7024
  const isSymlink = dirent.isSymbolicLink();
5906
7025
  let isDirectory = dirent.isDirectory();
5907
7026
  if (isSymlink) {
@@ -5936,9 +7055,9 @@ var init_fsbrowse = __esm(() => {
5936
7055
  });
5937
7056
 
5938
7057
  // src/services/projects.ts
5939
- import { existsSync as existsSync15 } from "fs";
5940
- import { basename as basename2, join as join15 } from "path";
5941
- import { eq as eq9, isNull } from "drizzle-orm";
7058
+ import { existsSync as existsSync16 } from "fs";
7059
+ import { basename as basename2, join as join16 } from "path";
7060
+ import { eq as eq11, isNull } from "drizzle-orm";
5942
7061
  function listProjects(ctx) {
5943
7062
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
5944
7063
  }
@@ -5960,7 +7079,7 @@ async function openProject(ctx, rawPath) {
5960
7079
  const name = basename2(repoPath) || repoPath;
5961
7080
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
5962
7081
  if (existing) {
5963
- ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq9(projects.id, existing.id)).run();
7082
+ ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq11(projects.id, existing.id)).run();
5964
7083
  const reopened = { ...existing, mainBranch, repoPath };
5965
7084
  emitProject(ctx, existing.id, {
5966
7085
  type: "project.opened",
@@ -5984,14 +7103,25 @@ async function openProject(ctx, rawPath) {
5984
7103
  message: `project ${name} at ${repoPath} (${mainBranch})`
5985
7104
  });
5986
7105
  warnIfTranslatedMount(ctx, project.id, repoPath);
7106
+ maybeAutoPrepare(ctx, project);
5987
7107
  return project;
5988
7108
  }
7109
+ function maybeAutoPrepare(ctx, project) {
7110
+ if (!ctx.config.autoPrepare)
7111
+ return;
7112
+ startPrep(ctx, project.id).catch((e) => {
7113
+ emitProject(ctx, project.id, {
7114
+ type: "prep.run.failed",
7115
+ message: `could not start preparation: ${e instanceof Error ? e.message : String(e)}`
7116
+ });
7117
+ });
7118
+ }
5989
7119
  function closeProject(ctx, projectId) {
5990
7120
  const project = requireProjectById(ctx, projectId);
5991
7121
  if (projectHasActiveRun(ctx, projectId)) {
5992
7122
  throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
5993
7123
  }
5994
- ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq9(projects.id, projectId)).run();
7124
+ ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq11(projects.id, projectId)).run();
5995
7125
  emitProject(ctx, projectId, {
5996
7126
  type: "project.closed",
5997
7127
  message: `project ${project.name} closed`
@@ -6000,7 +7130,7 @@ function closeProject(ctx, projectId) {
6000
7130
  }
6001
7131
  function renameProject(ctx, projectId, name) {
6002
7132
  const project = requireProjectById(ctx, projectId);
6003
- ctx.db.update(projects).set({ name }).where(eq9(projects.id, projectId)).run();
7133
+ ctx.db.update(projects).set({ name }).where(eq11(projects.id, projectId)).run();
6004
7134
  emitProject(ctx, projectId, {
6005
7135
  type: "project.renamed",
6006
7136
  message: `project renamed ${project.name} \u2192 ${name}`,
@@ -6018,7 +7148,7 @@ async function assertRepoTolerant(ctx, repoPath) {
6018
7148
  } catch (e) {
6019
7149
  if (!isNotImplemented(e))
6020
7150
  throw e;
6021
- if (!existsSync15(join15(repoPath, ".git"))) {
7151
+ if (!existsSync16(join16(repoPath, ".git"))) {
6022
7152
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
6023
7153
  }
6024
7154
  }
@@ -6039,31 +7169,59 @@ var init_projects = __esm(() => {
6039
7169
  init_events();
6040
7170
  init_fsbrowse();
6041
7171
  init_git();
7172
+ init_prep();
6042
7173
  init_repo();
6043
7174
  });
6044
7175
 
6045
7176
  // src/trpc/routers/project.ts
6046
- import * as z7 from "zod";
7177
+ import * as z8 from "zod";
6047
7178
  var projectRouter;
6048
7179
  var init_project = __esm(() => {
7180
+ init_src();
7181
+ init_findings();
6049
7182
  init_fsbrowse();
6050
7183
  init_git();
7184
+ init_prep();
6051
7185
  init_projects();
6052
7186
  init_repo();
6053
7187
  init_context();
6054
7188
  projectRouter = router({
6055
7189
  list: publicProcedure.query(({ ctx }) => listProjects(ctx)),
6056
7190
  roots: publicProcedure.query(() => listRoots()),
6057
- browse: publicProcedure.input(z7.object({ dir: z7.string().optional(), showHidden: z7.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
6058
- branches: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
6059
- open: publicProcedure.input(z7.object({ repoPath: z7.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
6060
- close: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
6061
- rename: publicProcedure.input(z7.object({ projectId: z7.string(), name: z7.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name))
7191
+ browse: publicProcedure.input(z8.object({ dir: z8.string().optional(), showHidden: z8.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
7192
+ branches: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
7193
+ open: publicProcedure.input(z8.object({ repoPath: z8.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
7194
+ close: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
7195
+ rename: publicProcedure.input(z8.object({ projectId: z8.string(), name: z8.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
7196
+ prepare: publicProcedure.input(z8.object({
7197
+ projectId: z8.string(),
7198
+ refresh: z8.boolean().optional(),
7199
+ keys: z8.array(PreparedKey).optional()
7200
+ })).mutation(async ({ ctx, input }) => {
7201
+ const { prepId, keys } = await startPrep(ctx, input.projectId, {
7202
+ ...input.refresh !== undefined ? { refresh: input.refresh } : {},
7203
+ ...input.keys ? { keys: input.keys } : {}
7204
+ });
7205
+ return { prepId, keys };
7206
+ }),
7207
+ cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
7208
+ cancelPrep(input.projectId);
7209
+ return { ok: true };
7210
+ }),
7211
+ prep: publicProcedure.input(z8.object({ projectId: z8.string() })).query(async ({ ctx, input }) => {
7212
+ const project = requireProjectById(ctx, input.projectId);
7213
+ return {
7214
+ latest: latestPrep(ctx, project.id),
7215
+ running: isPreparing(project.id),
7216
+ pendingKeys: keysToPrepare(ctx, project),
7217
+ findings: await listFindings(ctx, project)
7218
+ };
7219
+ })
6062
7220
  });
6063
7221
  });
6064
7222
 
6065
7223
  // src/trpc/routers/run.ts
6066
- import * as z8 from "zod";
7224
+ import * as z9 from "zod";
6067
7225
  var runRouter;
6068
7226
  var init_run = __esm(() => {
6069
7227
  init_agent_stream();
@@ -6071,9 +7229,9 @@ var init_run = __esm(() => {
6071
7229
  init_runner();
6072
7230
  init_context();
6073
7231
  runRouter = router({
6074
- get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
6075
- agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
6076
- cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
7232
+ get: publicProcedure.input(z9.object({ runId: z9.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
7233
+ agentTranscript: publicProcedure.input(z9.object({ ticketId: z9.string(), after: z9.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
7234
+ cancel: publicProcedure.input(z9.object({ runId: z9.string() })).mutation(({ input }) => {
6077
7235
  cancelRun(input.runId);
6078
7236
  return { ok: true };
6079
7237
  })
@@ -6081,15 +7239,15 @@ var init_run = __esm(() => {
6081
7239
  });
6082
7240
 
6083
7241
  // src/services/settings.ts
6084
- import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
6085
- import { dirname as dirname7 } from "path";
6086
- import * as z9 from "zod";
6087
- import { eq as eq10 } from "drizzle-orm";
7242
+ import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
7243
+ import { dirname as dirname8 } from "path";
7244
+ import * as z10 from "zod";
7245
+ import { eq as eq12 } from "drizzle-orm";
6088
7246
  function readRawConfig(configFile) {
6089
- if (!existsSync16(configFile))
7247
+ if (!existsSync17(configFile))
6090
7248
  return {};
6091
7249
  try {
6092
- const parsed = JSON.parse(readFileSync6(configFile, "utf8"));
7250
+ const parsed = JSON.parse(readFileSync7(configFile, "utf8"));
6093
7251
  if (typeof parsed !== "object" || parsed === null)
6094
7252
  return {};
6095
7253
  return foldLegacyModelConfig(parsed);
@@ -6113,33 +7271,45 @@ function stepModelFields(fileRaw) {
6113
7271
  });
6114
7272
  }
6115
7273
  function projectOverrides(ctx, projectId) {
6116
- const row = ctx.db.select({ model: projects.model, sandbox: projects.sandbox, devCommand: projects.devCommand }).from(projects).where(eq10(projects.id, projectId)).get();
7274
+ const row = ctx.db.select({
7275
+ model: projects.model,
7276
+ sandbox: projects.sandbox,
7277
+ devCommand: projects.devCommand,
7278
+ setupCommand: projects.setupCommand,
7279
+ verifyCommands: projects.verifyCommands,
7280
+ knownFailures: projects.knownFailures,
7281
+ dbResetCommand: projects.dbResetCommand
7282
+ }).from(projects).where(eq12(projects.id, projectId)).get();
6117
7283
  return {
6118
7284
  model: row?.model ?? null,
6119
7285
  sandbox: row?.sandbox ?? null,
6120
- devCommand: row?.devCommand ?? null
7286
+ devCommand: row?.devCommand ?? null,
7287
+ setupCommand: row?.setupCommand ?? null,
7288
+ verifyCommands: row?.verifyCommands ?? null,
7289
+ knownFailures: row?.knownFailures ?? null,
7290
+ dbResetCommand: row?.dbResetCommand ?? null
6121
7291
  };
6122
7292
  }
6123
- function resolveField(desc4, layers) {
7293
+ function resolveField(desc5, layers) {
6124
7294
  const scoped = layers.overrides !== null;
6125
- const scope = scoped && desc4.projectColumn ? "project" : "global";
6126
- const base = { key: desc4.key, restartRequired: desc4.restartRequired, scope };
6127
- if (desc4.envVar) {
6128
- const raw = layers.env[desc4.envVar];
7295
+ const scope = scoped && desc5.projectColumn ? "project" : "global";
7296
+ const base = { key: desc5.key, restartRequired: desc5.restartRequired, scope };
7297
+ if (desc5.envVar) {
7298
+ const raw = layers.env[desc5.envVar];
6129
7299
  if (raw !== undefined && raw !== "") {
6130
- return { ...base, value: desc4.parseEnv(raw), source: "env", editable: false };
7300
+ return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
6131
7301
  }
6132
7302
  }
6133
- if (layers.overrides && desc4.projectColumn) {
6134
- const ov = layers.overrides[desc4.projectColumn];
7303
+ if (layers.overrides && desc5.projectColumn) {
7304
+ const ov = layers.overrides[desc5.projectColumn];
6135
7305
  if (ov !== null && ov !== "") {
6136
7306
  return { ...base, value: ov, source: "project", editable: true };
6137
7307
  }
6138
7308
  }
6139
- if (desc4.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc4.configKey)) {
6140
- return { ...base, value: layers.fileRaw[desc4.configKey], source: "file", editable: true };
7309
+ if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
7310
+ return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
6141
7311
  }
6142
- const value = desc4.configKey ? DEFAULTS[desc4.configKey] ?? null : null;
7312
+ const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
6143
7313
  return { ...base, value, source: "default", editable: true };
6144
7314
  }
6145
7315
  function getSettings(ctx, projectId, io = {}) {
@@ -6156,59 +7326,61 @@ function updateSettings(ctx, input, io = {}) {
6156
7326
  if (input.key.startsWith("stepModels.")) {
6157
7327
  return updateStepModel(ctx, input, configFile, io);
6158
7328
  }
6159
- const desc4 = DESCRIPTORS.find((d) => d.key === input.key);
6160
- if (!desc4)
7329
+ const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
7330
+ if (!desc5)
6161
7331
  throw new InvalidInputError(`unknown setting: ${input.key}`);
6162
- if (desc4.envVar) {
6163
- const raw = env[desc4.envVar];
7332
+ if (desc5.envVar) {
7333
+ const raw = env[desc5.envVar];
6164
7334
  if (raw !== undefined && raw !== "") {
6165
- throw new InvalidInputError(`${desc4.key} is set by environment variable ${desc4.envVar}; unset it to edit`);
7335
+ throw new InvalidInputError(`${desc5.key} is set by environment variable ${desc5.envVar}; unset it to edit`);
6166
7336
  }
6167
7337
  }
6168
- const toProject = input.projectId !== undefined && desc4.projectColumn !== undefined;
7338
+ const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
6169
7339
  if (input.value === null) {
6170
7340
  if (!toProject)
6171
- throw new InvalidInputError(`${desc4.key} cannot be cleared`);
7341
+ throw new InvalidInputError(`${desc5.key} cannot be cleared`);
6172
7342
  const project = requireProjectById(ctx, input.projectId);
6173
- ctx.db.update(projects).set({ [desc4.projectColumn]: null }).where(eq10(projects.id, project.id)).run();
7343
+ ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
7344
+ recordHuman(ctx, project.id, desc5.key, null);
6174
7345
  emitProject(ctx, project.id, {
6175
7346
  type: "settings.updated",
6176
- message: `${desc4.key} override cleared`,
6177
- data: { key: desc4.key, scope: "project", value: null }
7347
+ message: `${desc5.key} override cleared`,
7348
+ data: { key: desc5.key, scope: "project", value: null }
6178
7349
  });
6179
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7350
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6180
7351
  }
6181
- const parsed = desc4.valueSchema.safeParse(input.value);
7352
+ const parsed = desc5.valueSchema.safeParse(input.value);
6182
7353
  if (!parsed.success) {
6183
- throw new InvalidInputError(`invalid value for ${desc4.key}: ${parsed.error.issues[0]?.message}`);
7354
+ throw new InvalidInputError(`invalid value for ${desc5.key}: ${parsed.error.issues[0]?.message}`);
6184
7355
  }
6185
7356
  const value = parsed.data;
6186
7357
  if (toProject) {
6187
7358
  const project = requireProjectById(ctx, input.projectId);
6188
- ctx.db.update(projects).set({ [desc4.projectColumn]: String(value) }).where(eq10(projects.id, project.id)).run();
7359
+ ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
7360
+ recordHuman(ctx, project.id, desc5.key, String(value));
6189
7361
  emitProject(ctx, project.id, {
6190
7362
  type: "settings.updated",
6191
- message: `${desc4.key} override set to ${String(value)}`,
6192
- data: { key: desc4.key, scope: "project", value }
7363
+ message: `${desc5.key} override set to ${String(value)}`,
7364
+ data: { key: desc5.key, scope: "project", value }
6193
7365
  });
6194
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7366
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6195
7367
  }
6196
- if (!desc4.configKey) {
6197
- throw new InvalidInputError(`${desc4.key} is a per-project setting; provide a projectId`);
7368
+ if (!desc5.configKey) {
7369
+ throw new InvalidInputError(`${desc5.key} is a per-project setting; provide a projectId`);
6198
7370
  }
6199
- writeGlobal(configFile, desc4.configKey, value);
6200
- ctx.config[desc4.configKey] = value;
7371
+ writeGlobal(configFile, desc5.configKey, value);
7372
+ ctx.config[desc5.configKey] = value;
6201
7373
  emitProject(ctx, GLOBAL_EVENT_KEY, {
6202
7374
  type: "settings.updated",
6203
- message: `${desc4.key} set to ${String(value)}`,
6204
- data: { key: desc4.key, scope: "global", value }
7375
+ message: `${desc5.key} set to ${String(value)}`,
7376
+ data: { key: desc5.key, scope: "global", value }
6205
7377
  });
6206
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7378
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6207
7379
  }
6208
7380
  function writeGlobal(configFile, configKey, value) {
6209
7381
  const raw = readRawConfig(configFile);
6210
7382
  raw[configKey] = value;
6211
- mkdirSync7(dirname7(configFile), { recursive: true });
7383
+ mkdirSync8(dirname8(configFile), { recursive: true });
6212
7384
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
6213
7385
  `);
6214
7386
  }
@@ -6219,7 +7391,7 @@ function updateStepModel(ctx, input, configFile, io) {
6219
7391
  const modelStep = step;
6220
7392
  let value = null;
6221
7393
  if (input.value !== null) {
6222
- const parsed = z9.string().min(1).safeParse(input.value);
7394
+ const parsed = z10.string().min(1).safeParse(input.value);
6223
7395
  if (!parsed.success) {
6224
7396
  throw new InvalidInputError(`invalid value for ${input.key}: ${parsed.error.issues[0]?.message}`);
6225
7397
  }
@@ -6247,7 +7419,7 @@ function writeStepModel(configFile, step, value) {
6247
7419
  else
6248
7420
  stepModels[step] = value;
6249
7421
  raw.stepModels = stepModels;
6250
- mkdirSync7(dirname7(configFile), { recursive: true });
7422
+ mkdirSync8(dirname8(configFile), { recursive: true });
6251
7423
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
6252
7424
  `);
6253
7425
  }
@@ -6264,6 +7436,7 @@ var init_settings = __esm(() => {
6264
7436
  init_schema();
6265
7437
  init_errors();
6266
7438
  init_events();
7439
+ init_findings();
6267
7440
  init_repo();
6268
7441
  DESCRIPTORS = [
6269
7442
  {
@@ -6271,7 +7444,7 @@ var init_settings = __esm(() => {
6271
7444
  configKey: "serverPort",
6272
7445
  envVar: "RUNCASTLE_SERVER_PORT",
6273
7446
  restartRequired: true,
6274
- valueSchema: z9.number().int().positive(),
7447
+ valueSchema: z10.number().int().positive(),
6275
7448
  parseEnv: (raw) => Number(raw)
6276
7449
  },
6277
7450
  {
@@ -6280,7 +7453,7 @@ var init_settings = __esm(() => {
6280
7453
  envVar: "RUNCASTLE_MODEL",
6281
7454
  projectColumn: "model",
6282
7455
  restartRequired: false,
6283
- valueSchema: z9.string().min(1),
7456
+ valueSchema: z10.string().min(1),
6284
7457
  parseEnv: idEnv
6285
7458
  },
6286
7459
  {
@@ -6289,7 +7462,7 @@ var init_settings = __esm(() => {
6289
7462
  envVar: "RUNCASTLE_SANDBOX",
6290
7463
  projectColumn: "sandbox",
6291
7464
  restartRequired: false,
6292
- valueSchema: z9.enum(["docker", "podman", "noSandbox"]),
7465
+ valueSchema: z10.enum(["docker", "podman", "noSandbox"]),
6293
7466
  parseEnv: idEnv
6294
7467
  },
6295
7468
  {
@@ -6297,7 +7470,7 @@ var init_settings = __esm(() => {
6297
7470
  configKey: "sandboxImage",
6298
7471
  envVar: "RUNCASTLE_SANDBOX_IMAGE",
6299
7472
  restartRequired: false,
6300
- valueSchema: z9.string().min(1),
7473
+ valueSchema: z10.string().min(1),
6301
7474
  parseEnv: idEnv
6302
7475
  },
6303
7476
  {
@@ -6305,7 +7478,7 @@ var init_settings = __esm(() => {
6305
7478
  configKey: "burnConcurrency",
6306
7479
  envVar: "RUNCASTLE_BURN_CONCURRENCY",
6307
7480
  restartRequired: false,
6308
- valueSchema: z9.number().int().min(1).max(8),
7481
+ valueSchema: z10.number().int().min(1).max(8),
6309
7482
  parseEnv: (raw) => Number(raw)
6310
7483
  },
6311
7484
  {
@@ -6313,7 +7486,7 @@ var init_settings = __esm(() => {
6313
7486
  configKey: "burnMaxIterations",
6314
7487
  envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
6315
7488
  restartRequired: false,
6316
- valueSchema: z9.number().int().min(1).max(10),
7489
+ valueSchema: z10.number().int().min(1).max(10),
6317
7490
  parseEnv: (raw) => Number(raw)
6318
7491
  },
6319
7492
  {
@@ -6321,7 +7494,7 @@ var init_settings = __esm(() => {
6321
7494
  configKey: "burnAttempts",
6322
7495
  envVar: "RUNCASTLE_BURN_ATTEMPTS",
6323
7496
  restartRequired: false,
6324
- valueSchema: z9.number().int().min(1).max(5),
7497
+ valueSchema: z10.number().int().min(1).max(5),
6325
7498
  parseEnv: (raw) => Number(raw)
6326
7499
  },
6327
7500
  {
@@ -6329,15 +7502,50 @@ var init_settings = __esm(() => {
6329
7502
  configKey: "burnConflictAttempts",
6330
7503
  envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
6331
7504
  restartRequired: false,
6332
- valueSchema: z9.number().int().min(0).max(3),
7505
+ valueSchema: z10.number().int().min(0).max(3),
7506
+ parseEnv: (raw) => Number(raw)
7507
+ },
7508
+ {
7509
+ key: "burnCpus",
7510
+ configKey: "burnCpus",
7511
+ envVar: "RUNCASTLE_BURN_CPUS",
7512
+ restartRequired: false,
7513
+ valueSchema: z10.number().positive().max(256),
6333
7514
  parseEnv: (raw) => Number(raw)
6334
7515
  },
7516
+ {
7517
+ key: "autoPrepare",
7518
+ configKey: "autoPrepare",
7519
+ envVar: "RUNCASTLE_AUTO_PREPARE",
7520
+ restartRequired: false,
7521
+ valueSchema: z10.boolean(),
7522
+ parseEnv: (raw) => raw !== "0" && raw.toLowerCase() !== "false"
7523
+ },
6335
7524
  {
6336
7525
  key: "setupCommand",
6337
7526
  configKey: "setupCommand",
6338
7527
  envVar: "RUNCASTLE_SETUP_COMMAND",
7528
+ projectColumn: "setupCommand",
7529
+ restartRequired: false,
7530
+ valueSchema: z10.string().min(1),
7531
+ parseEnv: idEnv
7532
+ },
7533
+ {
7534
+ key: "verifyCommands",
7535
+ configKey: "verifyCommands",
7536
+ envVar: "RUNCASTLE_VERIFY_COMMANDS",
7537
+ projectColumn: "verifyCommands",
7538
+ restartRequired: false,
7539
+ valueSchema: z10.string().min(1),
7540
+ parseEnv: idEnv
7541
+ },
7542
+ {
7543
+ key: "knownFailures",
7544
+ configKey: "knownFailures",
7545
+ envVar: "RUNCASTLE_KNOWN_FAILURES",
7546
+ projectColumn: "knownFailures",
6339
7547
  restartRequired: false,
6340
- valueSchema: z9.string().min(1),
7548
+ valueSchema: z10.string().min(1),
6341
7549
  parseEnv: idEnv
6342
7550
  },
6343
7551
  {
@@ -6345,14 +7553,21 @@ var init_settings = __esm(() => {
6345
7553
  configKey: "mainBranch",
6346
7554
  envVar: "RUNCASTLE_MAIN_BRANCH",
6347
7555
  restartRequired: false,
6348
- valueSchema: z9.string().min(1),
7556
+ valueSchema: z10.string().min(1),
6349
7557
  parseEnv: idEnv
6350
7558
  },
6351
7559
  {
6352
7560
  key: "devCommand",
6353
7561
  projectColumn: "devCommand",
6354
7562
  restartRequired: false,
6355
- valueSchema: z9.string().min(1),
7563
+ valueSchema: z10.string().min(1),
7564
+ parseEnv: idEnv
7565
+ },
7566
+ {
7567
+ key: "dbResetCommand",
7568
+ projectColumn: "dbResetCommand",
7569
+ restartRequired: false,
7570
+ valueSchema: z10.string().min(1),
6356
7571
  parseEnv: idEnv
6357
7572
  }
6358
7573
  ];
@@ -6361,14 +7576,14 @@ var init_settings = __esm(() => {
6361
7576
  });
6362
7577
 
6363
7578
  // src/trpc/routers/settings.ts
6364
- import * as z10 from "zod";
7579
+ import * as z11 from "zod";
6365
7580
  var settingsRouter;
6366
7581
  var init_settings2 = __esm(() => {
6367
7582
  init_src();
6368
7583
  init_settings();
6369
7584
  init_context();
6370
7585
  settingsRouter = router({
6371
- get: publicProcedure.input(z10.object({ projectId: z10.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
7586
+ get: publicProcedure.input(z11.object({ projectId: z11.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
6372
7587
  update: publicProcedure.input(SettingsUpdateInput).mutation(({ ctx, input }) => updateSettings(ctx, input))
6373
7588
  });
6374
7589
  });
@@ -6538,10 +7753,7 @@ var init_doctor = __esm(() => {
6538
7753
  import { spawn as spawn2 } from "child_process";
6539
7754
  function createSystemExec(opts = {}) {
6540
7755
  return (command, args) => new Promise((resolve9) => {
6541
- const resolved = resolveExecutable(command);
6542
- const isShim = /\.(cmd|bat)$/i.test(resolved);
6543
- const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
6544
- const spawnArgs = isShim ? ["/c", resolved, ...args] : args;
7756
+ const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
6545
7757
  let stdout = "";
6546
7758
  let stderr = "";
6547
7759
  const child = spawn2(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
@@ -6564,9 +7776,9 @@ var init_system_exec = __esm(() => {
6564
7776
  });
6565
7777
 
6566
7778
  // src/services/setup.ts
6567
- import { copyFileSync, existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
6568
- import { dirname as dirname8, join as join16 } from "path";
6569
- import { fileURLToPath as fileURLToPath6 } from "url";
7779
+ import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
7780
+ import { dirname as dirname9, join as join17 } from "path";
7781
+ import { fileURLToPath as fileURLToPath7 } from "url";
6570
7782
  async function writeGitIdentity(exec, identity) {
6571
7783
  const name = identity.name.trim();
6572
7784
  const email = identity.email.trim();
@@ -6641,16 +7853,16 @@ async function resolveRuntime(exec, preferred) {
6641
7853
  function resolveSandcastleBin() {
6642
7854
  try {
6643
7855
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
6644
- let dir = dirname8(fileURLToPath6(mainUrl));
7856
+ let dir = dirname9(fileURLToPath7(mainUrl));
6645
7857
  for (let hops = 0;hops < 6; hops++) {
6646
- const manifestPath = join16(dir, "package.json");
6647
- if (existsSync17(manifestPath)) {
6648
- const manifest = JSON.parse(readFileSync7(manifestPath, "utf8"));
7858
+ const manifestPath = join17(dir, "package.json");
7859
+ if (existsSync18(manifestPath)) {
7860
+ const manifest = JSON.parse(readFileSync8(manifestPath, "utf8"));
6649
7861
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
6650
- return join16(dir, manifest.bin.sandcastle);
7862
+ return join17(dir, manifest.bin.sandcastle);
6651
7863
  }
6652
7864
  }
6653
- const parent = dirname8(dir);
7865
+ const parent = dirname9(dir);
6654
7866
  if (parent === dir)
6655
7867
  break;
6656
7868
  dir = parent;
@@ -6672,32 +7884,49 @@ function terminalSpec(kind, opts) {
6672
7884
  };
6673
7885
  }
6674
7886
  function sandcastleTemplateDir() {
6675
- return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath6(new URL("../assets/sandcastle", import.meta.url)));
7887
+ return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
6676
7888
  }
6677
7889
  function scaffoldSandcastleConfig(templateDir, targetDir) {
6678
- const dir = join16(targetDir, ".sandcastle");
6679
- if (existsSync17(dir))
7890
+ const dir = join17(targetDir, ".sandcastle");
7891
+ if (existsSync18(dir))
6680
7892
  return { scaffolded: false, dir };
6681
- mkdirSync8(dir, { recursive: true });
7893
+ mkdirSync9(dir, { recursive: true });
6682
7894
  for (const name of readdirSync6(templateDir)) {
6683
- copyFileSync(join16(templateDir, name), join16(dir, name));
7895
+ copyFileSync(join17(templateDir, name), join17(dir, name));
6684
7896
  }
6685
7897
  return { scaffolded: true, dir };
6686
7898
  }
6687
7899
  function prepareSandboxBuildContext() {
6688
7900
  const target = sandboxBuildDir();
6689
- mkdirSync8(target, { recursive: true });
7901
+ mkdirSync9(target, { recursive: true });
6690
7902
  scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
6691
7903
  return target;
6692
7904
  }
6693
7905
  function createTokenVerifier(exec) {
6694
7906
  return async (token) => {
7907
+ if (token.length < 8) {
7908
+ return {
7909
+ valid: false,
7910
+ detail: "token looks malformed (too short) \u2014 saved to ~/.runcastle/.env anyway",
7911
+ fix: "Re-run `claude setup-token` and paste the whole line it prints (it starts with `sk-ant-oat`)."
7912
+ };
7913
+ }
6695
7914
  const out = await exec("claude", ["--version"]);
6696
- if (!(out.ok && out.code === 0)) {
6697
- return { valid: false, detail: "claude CLI not found \u2014 cannot verify the token" };
7915
+ if (!out.ok) {
7916
+ return {
7917
+ valid: false,
7918
+ 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.",
7919
+ 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."
7920
+ };
6698
7921
  }
6699
- if (token.length < 8) {
6700
- return { valid: false, detail: "token looks malformed (too short)" };
7922
+ if (out.code !== 0) {
7923
+ const why = (out.stderr.trim() || out.stdout.trim()).split(`
7924
+ `)[0] ?? "";
7925
+ return {
7926
+ valid: false,
7927
+ detail: `Token saved to ~/.runcastle/.env, but \`claude --version\` exited ${out.code}` + `${why ? `: ${why}` : " with no output"}.`,
7928
+ fix: "Run `claude --version` yourself and fix what it reports \u2014 the Claude Code install is broken, not the token."
7929
+ };
6701
7930
  }
6702
7931
  return { valid: true, detail: "token captured to ~/.runcastle/.env" };
6703
7932
  };
@@ -6705,9 +7934,9 @@ function createTokenVerifier(exec) {
6705
7934
  function fileAfkTokenIo(exec) {
6706
7935
  const path = envPath();
6707
7936
  return {
6708
- read: () => existsSync17(path) ? readFileSync7(path, "utf8") : "",
7937
+ read: () => existsSync18(path) ? readFileSync8(path, "utf8") : "",
6709
7938
  write: (content) => {
6710
- mkdirSync8(dirname8(path), { recursive: true });
7939
+ mkdirSync9(dirname9(path), { recursive: true });
6711
7940
  writeFileSync4(path, content, "utf8");
6712
7941
  },
6713
7942
  verify: createTokenVerifier(exec)
@@ -6722,7 +7951,7 @@ var init_setup = __esm(() => {
6722
7951
  });
6723
7952
 
6724
7953
  // src/trpc/routers/setup.ts
6725
- import * as z11 from "zod";
7954
+ import * as z12 from "zod";
6726
7955
  var setupRouter;
6727
7956
  var init_setup2 = __esm(() => {
6728
7957
  init_src();
@@ -6738,9 +7967,9 @@ var init_setup2 = __esm(() => {
6738
7967
  ...ctx.config.sandboxImage ? { imageName: ctx.config.sandboxImage } : {}
6739
7968
  })),
6740
7969
  runtimeGuide: publicProcedure.query(() => runtimeInstallGuide(process.platform)),
6741
- gitIdentity: publicProcedure.input(z11.object({ name: z11.string(), email: z11.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
6742
- afkToken: publicProcedure.input(z11.object({ token: z11.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
6743
- startTerminal: publicProcedure.input(z11.object({ kind: z11.enum(["setup-token", "build-image"]) })).mutation(async ({ ctx, input }) => {
7970
+ gitIdentity: publicProcedure.input(z12.object({ name: z12.string(), email: z12.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
7971
+ afkToken: publicProcedure.input(z12.object({ token: z12.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
7972
+ startTerminal: publicProcedure.input(z12.object({ kind: z12.enum(["setup-token", "build-image"]) })).mutation(async ({ ctx, input }) => {
6744
7973
  const exec = createSystemExec();
6745
7974
  const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
6746
7975
  const runtime = await resolveRuntime(exec, preferred);
@@ -6749,10 +7978,7 @@ var init_setup2 = __esm(() => {
6749
7978
  const spec = terminalSpec(input.kind, { runtime, imageName, sandcastleBin });
6750
7979
  const sessionId = newId("setup");
6751
7980
  const cwd = input.kind === "build-image" ? prepareSandboxBuildContext() : process.cwd();
6752
- const resolved = resolveExecutable(spec.cmd);
6753
- const isShim = /\.(cmd|bat)$/i.test(resolved);
6754
- const file = isShim ? process.env.ComSpec ?? "cmd.exe" : resolved;
6755
- const args = isShim ? ["/c", resolved, ...spec.args] : spec.args;
7981
+ const { file, args } = resolveSpawnTarget(spec.cmd, spec.args);
6756
7982
  ptyRegistry().create({
6757
7983
  sessionId,
6758
7984
  cmd: file,
@@ -6843,16 +8069,16 @@ var init_update_check = __esm(() => {
6843
8069
  });
6844
8070
 
6845
8071
  // src/version.ts
6846
- import { readFileSync as readFileSync8 } from "fs";
6847
- import { dirname as dirname9, join as join17 } from "path";
6848
- import { fileURLToPath as fileURLToPath7 } from "url";
8072
+ import { readFileSync as readFileSync9 } from "fs";
8073
+ import { dirname as dirname10, join as join18 } from "path";
8074
+ import { fileURLToPath as fileURLToPath8 } from "url";
6849
8075
  function runcastleVersion() {
6850
8076
  if (cached2)
6851
8077
  return cached2;
6852
- const here = dirname9(fileURLToPath7(import.meta.url));
6853
- for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
8078
+ const here = dirname10(fileURLToPath8(import.meta.url));
8079
+ for (const pkgPath of [join18(here, "..", "package.json"), join18(here, "package.json")]) {
6854
8080
  try {
6855
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
8081
+ const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
6856
8082
  if (typeof pkg.version === "string") {
6857
8083
  cached2 = pkg.version;
6858
8084
  return cached2;
@@ -6878,17 +8104,27 @@ var init_system = __esm(() => {
6878
8104
  });
6879
8105
 
6880
8106
  // src/trpc/routers/ticket.ts
6881
- import * as z12 from "zod";
8107
+ import * as z13 from "zod";
6882
8108
  var ticketRouter;
6883
8109
  var init_ticket = __esm(() => {
6884
8110
  init_features();
8111
+ init_repo();
6885
8112
  init_tickets();
6886
8113
  init_ticket_burner();
6887
8114
  init_context();
6888
8115
  ticketRouter = router({
6889
- retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
6890
- stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ input }) => ({ stopped: stopTicketRun(input.ticketId) })),
6891
- cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
8116
+ retry: publicProcedure.input(z13.object({ ticketId: z13.string(), fresh: z13.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
8117
+ stop: publicProcedure.input(z13.object({ ticketId: z13.string() })).mutation(({ ctx, input }) => {
8118
+ if (stopTicketRun(input.ticketId))
8119
+ return { stopped: true, swept: false };
8120
+ const ticket = getTicket(ctx, input.ticketId);
8121
+ if (ticket.status === "burning" && !hasActiveRun(ctx, ticket.featureId)) {
8122
+ sweepOrphanedBurning(ctx, ticket.featureId, "orphaned \u2014 the run that was burning it is gone");
8123
+ return { stopped: false, swept: true };
8124
+ }
8125
+ return { stopped: false, swept: false };
8126
+ }),
8127
+ cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
6892
8128
  });
6893
8129
  });
6894
8130
 
@@ -6919,17 +8155,18 @@ var init_router = __esm(() => {
6919
8155
  });
6920
8156
 
6921
8157
  // src/workflows/reconcile-runs.ts
6922
- import { eq as eq11 } from "drizzle-orm";
8158
+ import { eq as eq13 } from "drizzle-orm";
6923
8159
  async function reconcileStaleRuns(ctx) {
6924
- const stale = ctx.db.select().from(runs).where(eq11(runs.status, "running")).all().map(rowToRun);
8160
+ const stale = ctx.db.select().from(runs).where(eq13(runs.status, "running")).all().map(rowToRun);
6925
8161
  const reconciled = [];
6926
- for (const run3 of stale) {
6927
- if (isRunActive(run3.id))
8162
+ for (const run4 of stale) {
8163
+ if (isRunActive(run4.id))
6928
8164
  continue;
6929
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq11(runs.id, run3.id)).run();
6930
- const released = releaseForSession(ctx, run3.id);
6931
- if (workflowClaimsFeatureBranch(run3.workflow)) {
6932
- const feature = tryGetFeature(ctx, run3.featureId);
8165
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq13(runs.id, run4.id)).run();
8166
+ const released = releaseForSession(ctx, run4.id);
8167
+ const swept = workflowClaimsFeatureBranch(run4.workflow) ? sweepOrphanedBurning(ctx, run4.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
8168
+ if (workflowClaimsFeatureBranch(run4.workflow)) {
8169
+ const feature = tryGetFeature(ctx, run4.featureId);
6933
8170
  const project = feature ? getProjectById(ctx, feature.projectId) : null;
6934
8171
  if (feature && project) {
6935
8172
  try {
@@ -6937,17 +8174,18 @@ async function reconcileStaleRuns(ctx) {
6937
8174
  } catch {}
6938
8175
  }
6939
8176
  }
6940
- emit(ctx, run3.featureId, {
8177
+ emit(ctx, run4.featureId, {
6941
8178
  type: "run.reconciled",
6942
- message: `run marked failed at boot \u2014 the server restarted while it was running (${run3.workflow})`,
6943
- runId: run3.id,
8179
+ message: `run marked failed at boot \u2014 the server restarted while it was running (${run4.workflow})`,
8180
+ runId: run4.id,
6944
8181
  data: {
6945
- runId: run3.id,
6946
- workflow: run3.workflow,
6947
- releasedWaypointIds: released.map((w) => w.id)
8182
+ runId: run4.id,
8183
+ workflow: run4.workflow,
8184
+ releasedWaypointIds: released.map((w) => w.id),
8185
+ sweptTicketSeqs: swept.map((t2) => t2.seq)
6948
8186
  }
6949
8187
  });
6950
- reconciled.push(run3);
8188
+ reconciled.push(run4);
6951
8189
  }
6952
8190
  for (const project of allProjects(ctx)) {
6953
8191
  try {
@@ -6962,6 +8200,7 @@ var init_reconcile_runs = __esm(() => {
6962
8200
  init_events();
6963
8201
  init_git();
6964
8202
  init_repo();
8203
+ init_tickets();
6965
8204
  init_waypoints();
6966
8205
  init_runner();
6967
8206
  });
@@ -7009,6 +8248,10 @@ async function startServer() {
7009
8248
  if (staleRuns.length > 0) {
7010
8249
  console.log(`reconciled ${staleRuns.length} stale run(s) from a previous server run`);
7011
8250
  }
8251
+ const stalePreps = reconcilePreps(ctx);
8252
+ if (stalePreps > 0) {
8253
+ console.log(`reconciled ${stalePreps} stale preparation run(s) from a previous server run`);
8254
+ }
7012
8255
  const server = Bun.serve({
7013
8256
  port: config2.serverPort,
7014
8257
  fetch(req, srv) {
@@ -7038,6 +8281,7 @@ var init_src2 = __esm(() => {
7038
8281
  init_client();
7039
8282
  init_migrate();
7040
8283
  init_reconcile();
8284
+ init_prep();
7041
8285
  init_server();
7042
8286
  init_registry();
7043
8287
  init_ws();