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