runcastle 1.0.24 → 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);
@@ -110,6 +125,7 @@ var init_config = __esm(() => {
110
125
  burnMaxIterations: z.number().int().min(1).max(10).default(3),
111
126
  burnAttempts: z.number().int().min(1).max(5).default(3),
112
127
  burnConflictAttempts: z.number().int().min(0).max(3).default(2),
128
+ autoPrepare: z.boolean().default(true),
113
129
  setupCommand: z.string().optional(),
114
130
  verifyCommands: z.string().optional(),
115
131
  knownFailures: z.string().optional(),
@@ -203,7 +219,7 @@ var init_ids = () => {};
203
219
 
204
220
  // ../core/src/schemas.ts
205
221
  import * as z2 from "zod";
206
- 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;
207
223
  var init_schemas = __esm(() => {
208
224
  Phase = z2.enum([
209
225
  "ideation",
@@ -256,13 +272,44 @@ var init_schemas = __esm(() => {
256
272
  lastSessionId: z2.string().optional(),
257
273
  summary: z2.string().optional()
258
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"]);
259
284
  Project = z2.object({
260
285
  id: z2.string(),
261
286
  name: z2.string(),
262
287
  repoPath: z2.string(),
263
288
  mainBranch: z2.string(),
264
289
  devCommand: z2.string().optional(),
265
- 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()
266
313
  });
267
314
  Feature = z2.object({
268
315
  id: z2.string(),
@@ -461,8 +508,8 @@ var init_pipeline = __esm(() => {
461
508
  });
462
509
 
463
510
  // ../core/src/db-schema.ts
464
- import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
465
- 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;
466
513
  var init_db_schema = __esm(() => {
467
514
  projects = sqliteTable("projects", {
468
515
  id: text("id").primaryKey(),
@@ -472,7 +519,28 @@ var init_db_schema = __esm(() => {
472
519
  devCommand: text("dev_command"),
473
520
  model: text("model"),
474
521
  sandbox: text("sandbox"),
475
- 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")
476
544
  });
477
545
  features = sqliteTable("features", {
478
546
  id: text("id").primaryKey(),
@@ -576,7 +644,9 @@ var init_schema = __esm(() => {
576
644
  waypoints,
577
645
  runs,
578
646
  events,
579
- gateOverrides
647
+ gateOverrides,
648
+ projectFindings,
649
+ projectPreps
580
650
  };
581
651
  });
582
652
 
@@ -1341,7 +1411,11 @@ function rowToProject(row) {
1341
1411
  repoPath: row.repoPath,
1342
1412
  mainBranch: row.mainBranch,
1343
1413
  devCommand: row.devCommand ?? undefined,
1344
- 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
1345
1419
  };
1346
1420
  }
1347
1421
  function rowToRun(row) {
@@ -1673,34 +1747,159 @@ function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS)
1673
1747
  }, submitDelayMs);
1674
1748
  submit.unref?.();
1675
1749
  }
1676
- function scheduleKickoff(ctx, session) {
1677
- const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
1678
- pendingKickoffOverrides.delete(session.id);
1679
- 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;
1680
1786
  try {
1681
- writeKickoffSequence(line, {
1682
- write: (data) => ptyRegistry().get(session.id)?.pty.write(data),
1683
- alive: () => {
1684
- const entry = ptyRegistry().get(session.id);
1685
- return !!entry && !entry.exited;
1686
- },
1687
- onSubmitted: () => {
1688
- emit(ctx, session.featureId, {
1689
- type: "session.kickoff",
1690
- message: `${session.kind} session kicked off automatically`,
1691
- data: { sessionId: session.id, kind: session.kind }
1692
- });
1693
- }
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
+ })
1694
1797
  });
1695
1798
  } catch {}
1696
- }, 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);
1697
1886
  timer.unref?.();
1698
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
+ }
1699
1898
  function markSessionEnded(ctx, id) {
1700
1899
  const existing = getSessionRow(ctx, id);
1701
1900
  if (!existing)
1702
1901
  return null;
1703
- pendingKickoffOverrides.delete(id);
1902
+ forgetKickoff(id);
1704
1903
  ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
1705
1904
  return getSessionRow(ctx, id);
1706
1905
  }
@@ -1712,7 +1911,7 @@ function mostRecentLiveSession(ctx) {
1712
1911
  const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1713
1912
  return row ? rowToSession(row) : null;
1714
1913
  }
1715
- 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;
1716
1915
  var init_sessions = __esm(() => {
1717
1916
  init_src();
1718
1917
  init_schema();
@@ -1730,6 +1929,7 @@ var init_sessions = __esm(() => {
1730
1929
  };
1731
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: ";
1732
1931
  pendingKickoffOverrides = new Map;
1932
+ deliveries = new Map;
1733
1933
  });
1734
1934
 
1735
1935
  // src/launcher/reconcile.ts
@@ -1765,339 +1965,104 @@ var init_reconcile = __esm(() => {
1765
1965
  init_sessions();
1766
1966
  });
1767
1967
 
1768
- // src/launcher/runtime.ts
1769
- function setRuntimeCtx(ctx) {
1770
- current = ctx;
1771
- }
1772
- async function getRuntimeCtx() {
1773
- if (current)
1774
- return current;
1775
- const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
1776
- Promise.resolve().then(() => (init_client(), exports_client)),
1777
- Promise.resolve().then(() => (init_paths(), exports_paths)),
1778
- Promise.resolve().then(() => (init_config_load(), exports_config_load))
1779
- ]);
1780
- const db = createDb2(dbPath2());
1781
- current = { db, config: loadConfig2() };
1782
- return current;
1783
- }
1784
- var current = null;
1785
-
1786
- // src/services/feature-docs.ts
1968
+ // src/launcher/skills-root.ts
1787
1969
  import { existsSync as existsSync5 } from "fs";
1788
- import { join as join5 } from "path";
1789
- function featureDocsDir(project, feature) {
1790
- const relSegments = featureDocsRel(feature.slug).split("/");
1791
- const worktree = worktreeDir(project.id, feature.slug);
1792
- const base = existsSync5(worktree) ? worktree : project.repoPath;
1793
- return join5(base, ...relSegments);
1794
- }
1795
- function featureDocPath(project, feature, fileName) {
1796
- 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
+ `)}`);
1797
1994
  }
1798
- var init_feature_docs = __esm(() => {
1799
- init_paths();
1995
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
1996
+ var init_skills_root = __esm(() => {
1997
+ SKILLS_MARKER = join5("packs", "runcastle");
1800
1998
  });
1801
1999
 
1802
- // src/services/tickets.ts
1803
- import { and as and5, asc as asc3, eq as eq5 } from "drizzle-orm";
1804
- function rowToTicket(row) {
1805
- return {
1806
- id: row.id,
1807
- featureId: row.featureId,
1808
- seq: row.seq,
1809
- title: row.title,
1810
- goal: row.goal,
1811
- context: row.context,
1812
- acceptanceCriteria: row.acceptanceCriteria,
1813
- seams: row.seams,
1814
- blockedBy: row.blockedBy,
1815
- status: row.status,
1816
- commits: row.commits,
1817
- error: row.error ?? undefined,
1818
- attemptBranch: row.attemptBranch ?? undefined,
1819
- conflictFiles: row.conflictFiles ?? undefined
1820
- };
2000
+ // src/pty/dev-pane.ts
2001
+ function drivePaneId(featureId) {
2002
+ return `${DRIVE_PREFIX}${featureId}`;
1821
2003
  }
1822
- function resolveBlocking(inputs, startSeq) {
1823
- try {
1824
- return resolveBatchBlocking(inputs, { startSeq });
1825
- } catch (e) {
1826
- if (e instanceof BlockingEdgeError)
1827
- throw new InvalidInputError(e.message);
1828
- throw e;
1829
- }
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;
1830
2007
  }
1831
- function listByFeature2(ctx, featureId) {
1832
- 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] };
1833
2013
  }
1834
- function storeTickets(ctx, featureId, inputs) {
1835
- if (inputs.length === 0)
1836
- return [];
1837
- const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq5(tickets.featureId, featureId)).all();
1838
- const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
1839
- const resolved = resolveBlocking(inputs, startSeq);
1840
- const rows = inputs.map((t, i) => ({
1841
- id: newId("tkt"),
1842
- featureId,
1843
- seq: resolved[i].seq,
1844
- title: t.title,
1845
- goal: t.goal,
1846
- context: t.context,
1847
- acceptanceCriteria: t.acceptanceCriteria,
1848
- seams: t.seams,
1849
- blockedBy: resolved[i].blockedBy,
1850
- status: "pending",
1851
- commits: [],
1852
- error: null,
1853
- attemptBranch: null,
1854
- conflictFiles: null
1855
- }));
1856
- 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);
1857
2049
  emit(ctx, featureId, {
1858
- type: "tickets.stored",
1859
- message: `${rows.length} ticket(s) stored`,
1860
- data: { count: rows.length, seqs: rows.map((r) => r.seq) }
2050
+ type: "testdrive.dev_started",
2051
+ message: `dev server spawned: ${devCommand}`,
2052
+ data: { paneId }
1861
2053
  });
1862
- return rows.map(rowToTicket);
1863
- }
1864
- function getTicket(ctx, id) {
1865
- const row = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1866
- if (!row)
1867
- throw new NotFoundError(`ticket ${id} not found`);
1868
- return rowToTicket(row);
1869
- }
1870
- function assertMutable(t, verb) {
1871
- if (!MUTABLE_STATUSES.has(t.status)) {
1872
- 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"}`);
1873
- }
2054
+ return paneId;
1874
2055
  }
1875
- function editTicket(ctx, id, patch) {
1876
- const current2 = getTicket(ctx, id);
1877
- assertMutable(current2, "edit");
1878
- const set = {};
1879
- if (patch.title !== undefined)
1880
- set.title = patch.title;
1881
- if (patch.goal !== undefined)
1882
- set.goal = patch.goal;
1883
- if (patch.context !== undefined)
1884
- set.context = patch.context;
1885
- if (patch.acceptanceCriteria !== undefined)
1886
- set.acceptanceCriteria = patch.acceptanceCriteria;
1887
- if (patch.seams !== undefined)
1888
- set.seams = patch.seams;
1889
- if (Object.keys(set).length === 0) {
1890
- throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
2056
+ function stopDevPane(paneId) {
2057
+ const reg = ptyRegistry();
2058
+ const entry = reg.get(paneId);
2059
+ if (entry && !entry.exited && process.platform !== "win32") {
2060
+ try {
2061
+ process.kill(-entry.pty.pid, "SIGTERM");
2062
+ } catch {}
1891
2063
  }
1892
- ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1893
- emit(ctx, current2.featureId, {
1894
- type: "ticket.edited",
1895
- message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
1896
- ticketId: id,
1897
- data: { fields: Object.keys(set) }
1898
- });
1899
- return getTicket(ctx, id);
1900
- }
1901
- function cancelTicket(ctx, id, reason) {
1902
- const current2 = getTicket(ctx, id);
1903
- assertMutable(current2, "cancel");
1904
- ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq5(tickets.id, id)).run();
1905
- emit(ctx, current2.featureId, {
1906
- type: "ticket.cancelled",
1907
- message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
1908
- ticketId: id,
1909
- data: { reason: reason ?? null }
1910
- });
1911
- return getTicket(ctx, id);
1912
- }
1913
- function sweepOrphanedBurning(ctx, featureId, reason) {
1914
- const orphaned = ctx.db.select().from(tickets).where(and5(eq5(tickets.featureId, featureId), eq5(tickets.status, "burning"))).all().map(rowToTicket);
1915
- for (const t of orphaned) {
1916
- updateTicket(ctx, t.id, { status: "failed", error: reason });
1917
- emit(ctx, featureId, {
1918
- type: "ticket.failed",
1919
- message: `ticket ${t.seq} failed: ${reason}`,
1920
- ticketId: t.id,
1921
- data: { error: reason, orphaned: true }
1922
- });
1923
- }
1924
- return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
1925
- }
1926
- function updateTicket(ctx, id, patch) {
1927
- const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1928
- if (!current2)
1929
- throw new NotFoundError(`ticket ${id} not found`);
1930
- const set = {};
1931
- if (patch.status !== undefined)
1932
- set.status = patch.status;
1933
- if (patch.commits !== undefined)
1934
- set.commits = patch.commits;
1935
- if (patch.error !== undefined)
1936
- set.error = patch.error;
1937
- if (patch.attemptBranch !== undefined)
1938
- set.attemptBranch = patch.attemptBranch;
1939
- if (patch.conflictFiles !== undefined)
1940
- set.conflictFiles = patch.conflictFiles;
1941
- ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1942
- emit(ctx, current2.featureId, {
1943
- type: "ticket.updated",
1944
- message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
1945
- ticketId: id,
1946
- data: patch
1947
- });
1948
- const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1949
- return rowToTicket(updated);
1950
- }
1951
- var MUTABLE_STATUSES;
1952
- var init_tickets = __esm(() => {
1953
- init_src();
1954
- init_schema();
1955
- init_errors();
1956
- init_events();
1957
- MUTABLE_STATUSES = new Set(["pending", "failed"]);
1958
- });
1959
-
1960
- // src/services/gates.ts
1961
- import { existsSync as existsSync6 } from "fs";
1962
- function checkGate(ctx, check, feature) {
1963
- switch (check) {
1964
- case "decisions-file-exists":
1965
- return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
1966
- case "all-waypoints-terminal": {
1967
- const wps = listByFeature(ctx, feature.id);
1968
- if (wps.length === 0) {
1969
- return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
1970
- }
1971
- const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
1972
- return open.length === 0 ? { satisfied: true } : {
1973
- satisfied: false,
1974
- reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
1975
- };
1976
- }
1977
- case "spec-file-exists":
1978
- return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
1979
- case "tickets-approved": {
1980
- const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
1981
- return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
1982
- }
1983
- case "all-tickets-terminal": {
1984
- const tickets2 = listByFeature2(ctx, feature.id);
1985
- if (tickets2.length === 0)
1986
- return { satisfied: false, reason: "no tickets have run yet" };
1987
- const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
1988
- return open.length === 0 ? { satisfied: true } : {
1989
- satisfied: false,
1990
- reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
1991
- };
1992
- }
1993
- case "human-merge":
1994
- return { satisfied: false, reason: "use the Merge button to ship" };
1995
- }
1996
- }
1997
- function notYetTerminal(noun, statuses, order) {
1998
- const counts = new Map;
1999
- for (const s of statuses)
2000
- counts.set(s, (counts.get(s) ?? 0) + 1);
2001
- const ordered = [
2002
- ...order.filter((s) => counts.has(s)),
2003
- ...[...counts.keys()].filter((s) => !order.includes(s))
2004
- ];
2005
- const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
2006
- return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
2007
- }
2008
- function fileGate(ctx, feature, fileName, reason) {
2009
- const project = projectForFeature(ctx, feature);
2010
- return existsSync6(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
2011
- }
2012
- function overrideGate(ctx, featureId, gate, reason) {
2013
- const feature = getFeatureRow(ctx, featureId);
2014
- ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
2015
- emit(ctx, featureId, {
2016
- type: "gate.overridden",
2017
- message: `gate ${gate} overridden: ${reason}`,
2018
- data: { gate, reason }
2019
- });
2020
- const next = nextPhase(feature);
2021
- if (next)
2022
- return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
2023
- return feature;
2024
- }
2025
- var init_gates = __esm(() => {
2026
- init_src();
2027
- init_schema();
2028
- init_events();
2029
- init_feature_docs();
2030
- init_repo();
2031
- init_tickets();
2032
- init_waypoints();
2033
- });
2034
-
2035
- // src/pty/dev-pane.ts
2036
- function drivePaneId(featureId) {
2037
- return `${DRIVE_PREFIX}${featureId}`;
2038
- }
2039
- function sniffDevUrl(text2) {
2040
- const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
2041
- return m ? m[0] : undefined;
2042
- }
2043
- function devSpawnTarget(devCommand) {
2044
- if (process.platform === "win32") {
2045
- return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
2046
- }
2047
- return { file: "/bin/sh", args: ["-c", devCommand] };
2048
- }
2049
- function startDevPane(input) {
2050
- const { ctx, featureId, repoPath, devCommand, onUrl } = input;
2051
- const paneId = drivePaneId(featureId);
2052
- const { file, args } = devSpawnTarget(devCommand);
2053
- try {
2054
- ptyRegistry().create({
2055
- sessionId: paneId,
2056
- cmd: file,
2057
- args,
2058
- opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
2059
- });
2060
- } catch (err) {
2061
- emit(ctx, featureId, {
2062
- type: "testdrive.dev_failed",
2063
- message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
2064
- data: { paneId }
2065
- });
2066
- return;
2067
- }
2068
- let scanned = "";
2069
- let found = false;
2070
- const sniffer = {
2071
- sendData(chunk) {
2072
- if (found)
2073
- return;
2074
- scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
2075
- const url = sniffDevUrl(scanned);
2076
- if (url) {
2077
- found = true;
2078
- onUrl(url);
2079
- }
2080
- },
2081
- sendControl() {}
2082
- };
2083
- ptyRegistry().attach(paneId, sniffer);
2084
- emit(ctx, featureId, {
2085
- type: "testdrive.dev_started",
2086
- message: `dev server spawned: ${devCommand}`,
2087
- data: { paneId }
2088
- });
2089
- return paneId;
2090
- }
2091
- function stopDevPane(paneId) {
2092
- const reg = ptyRegistry();
2093
- const entry = reg.get(paneId);
2094
- if (entry && !entry.exited && process.platform !== "win32") {
2095
- try {
2096
- process.kill(-entry.pty.pid, "SIGTERM");
2097
- } catch {}
2098
- }
2099
- reg.kill(paneId);
2100
- reg.remove(paneId);
2064
+ reg.kill(paneId);
2065
+ reg.remove(paneId);
2101
2066
  }
2102
2067
  var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
2103
2068
  var init_dev_pane = __esm(() => {
@@ -2106,10 +2071,13 @@ var init_dev_pane = __esm(() => {
2106
2071
  });
2107
2072
 
2108
2073
  // src/services/git.ts
2109
- 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";
2110
2075
  import { tmpdir } from "os";
2111
- import { dirname, join as join6, resolve as resolve3 } from "path";
2076
+ import { dirname as dirname2, join as join6, resolve as resolve4 } from "path";
2112
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
+ }
2113
2081
  function featureBranch(slug) {
2114
2082
  return `feature/${slug}`;
2115
2083
  }
@@ -2120,14 +2088,14 @@ function errMsg(e) {
2120
2088
  return e instanceof Error ? e.message : String(e);
2121
2089
  }
2122
2090
  function canon(p) {
2123
- let abs = resolve3(p);
2091
+ let abs = resolve4(p);
2124
2092
  try {
2125
2093
  abs = realpathSync.native(abs);
2126
2094
  } catch {}
2127
2095
  return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2128
2096
  }
2129
2097
  async function assertRepo(repoPath) {
2130
- if (!existsSync7(repoPath)) {
2098
+ if (!existsSync6(repoPath)) {
2131
2099
  throw new InvalidInputError(`path does not exist: ${repoPath}`);
2132
2100
  }
2133
2101
  let isRepo = false;
@@ -2216,7 +2184,7 @@ async function ensureTalkWorktree(project, feature) {
2216
2184
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2217
2185
  if (await worktreeIsValid(g, worktreePath, branch))
2218
2186
  return worktreePath;
2219
- mkdirSync2(dirname(worktreePath), { recursive: true });
2187
+ mkdirSync2(dirname2(worktreePath), { recursive: true });
2220
2188
  try {
2221
2189
  await g.raw(["worktree", "add", worktreePath, branch]);
2222
2190
  return worktreePath;
@@ -2239,7 +2207,7 @@ async function ensureBranchExists(g, branch, from) {
2239
2207
  }
2240
2208
  }
2241
2209
  async function worktreeIsValid(g, worktreePath, branch) {
2242
- if (!existsSync7(worktreePath))
2210
+ if (!existsSync6(worktreePath))
2243
2211
  return false;
2244
2212
  const registered = await registeredWorktrees(g);
2245
2213
  if (!registered.has(canon(worktreePath)))
@@ -2285,7 +2253,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2285
2253
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2286
2254
  }
2287
2255
  async function detachWorktree(path) {
2288
- if (!existsSync7(path))
2256
+ if (!existsSync6(path))
2289
2257
  return false;
2290
2258
  const g = git(path);
2291
2259
  let head;
@@ -2300,12 +2268,27 @@ async function detachWorktree(path) {
2300
2268
  return true;
2301
2269
  }
2302
2270
  async function reattachWorktree(path, branch) {
2303
- if (!existsSync7(path))
2271
+ if (!existsSync6(path))
2304
2272
  return;
2305
2273
  try {
2306
2274
  await git(path).checkout(branch);
2307
2275
  } catch {}
2308
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
+ }
2309
2292
  function tempBranchSlugSegment(slug) {
2310
2293
  return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
2311
2294
  }
@@ -2323,14 +2306,14 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2323
2306
  const attempts = Math.max(1, opts.attempts ?? 3);
2324
2307
  const delayMs = opts.delayMs ?? 750;
2325
2308
  const g = git(repoPath);
2326
- for (let attempt = 1;attempt <= attempts && existsSync7(path); attempt++) {
2309
+ for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2327
2310
  try {
2328
2311
  await g.raw(["worktree", "remove", "--force", path]);
2329
2312
  } catch {}
2330
- if (existsSync7(path) && attempt < attempts)
2313
+ if (existsSync6(path) && attempt < attempts)
2331
2314
  await sleep(delayMs);
2332
2315
  }
2333
- if (existsSync7(path)) {
2316
+ if (existsSync6(path)) {
2334
2317
  try {
2335
2318
  rmSync(path, { recursive: true, force: true });
2336
2319
  } catch {}
@@ -2338,7 +2321,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2338
2321
  try {
2339
2322
  await g.raw(["worktree", "prune"]);
2340
2323
  } catch {}
2341
- return !existsSync7(path);
2324
+ return !existsSync6(path);
2342
2325
  }
2343
2326
  function sleep(ms) {
2344
2327
  return new Promise((done) => setTimeout(done, ms));
@@ -2352,6 +2335,32 @@ async function branchCommitsAhead(repoPath, base, branch) {
2352
2335
  return [];
2353
2336
  }
2354
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
+ }
2355
2364
  async function commitSummaries(repoPath, base, branch, limit = 20) {
2356
2365
  try {
2357
2366
  const out = await git(repoPath).raw([
@@ -2547,7 +2556,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2547
2556
  try {
2548
2557
  await g.raw(["worktree", "prune"]);
2549
2558
  } catch {}
2550
- if (existsSync7(worktreePath)) {
2559
+ if (existsSync6(worktreePath)) {
2551
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");
2552
2561
  }
2553
2562
  }
@@ -2574,7 +2583,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2574
2583
  return { deleted, kept };
2575
2584
  }
2576
2585
  async function commitDocs(worktreePath, message) {
2577
- if (!existsSync7(resolve3(worktreePath, DOCS_PATHSPEC)))
2586
+ if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
2578
2587
  return;
2579
2588
  const g = git(worktreePath);
2580
2589
  await g.add([DOCS_PATHSPEC]);
@@ -2619,6 +2628,7 @@ async function testDrive(ctx, project, feature, action) {
2619
2628
  const devPaneId = testDriveState.devPaneId;
2620
2629
  if (devPaneId)
2621
2630
  stopDevPane(devPaneId);
2631
+ const carriedChanges = await dirtyPaths(g);
2622
2632
  await g.checkout(previousBranch2);
2623
2633
  if (detachedWorktree2)
2624
2634
  await reattachWorktree(detachedWorktree2, branch);
@@ -2628,7 +2638,20 @@ async function testDrive(ctx, project, feature, action) {
2628
2638
  message: `test drive stopped \u2014 back on ${previousBranch2}`,
2629
2639
  data: { branch: previousBranch2 }
2630
2640
  });
2631
- 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
+ };
2632
2655
  }
2633
2656
  const porcelain = (await g.raw(["status", "--porcelain"])).trim();
2634
2657
  if (porcelain !== "")
@@ -2665,6 +2688,35 @@ async function testDrive(ctx, project, feature, action) {
2665
2688
  }
2666
2689
  return { ok: true, branch };
2667
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
+ }
2668
2720
  async function mergeFeature(project, feature) {
2669
2721
  const target = feature.baseBranch ?? project.mainBranch;
2670
2722
  if (testDriveState) {
@@ -2720,171 +2772,18 @@ async function mergeInProgress(g) {
2720
2772
  return false;
2721
2773
  }
2722
2774
  }
2723
- 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;
2724
2776
  var init_git = __esm(() => {
2725
2777
  init_paths();
2726
2778
  init_errors();
2727
2779
  init_dev_pane();
2728
2780
  init_events();
2729
2781
  init_repo();
2782
+ MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
2783
+ DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
2730
2784
  TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
2731
2785
  });
2732
2786
 
2733
- // src/services/knowledge.ts
2734
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "fs";
2735
- import { join as join7, relative, resolve as resolve4, sep } from "path";
2736
- function scaffoldDocs(ctx, feature) {
2737
- const project = projectForFeature(ctx, feature);
2738
- const dir = featureDocsDir(project, feature);
2739
- mkdirSync3(dir, { recursive: true });
2740
- const briefPath = featureDocPath(project, feature, "brief.md");
2741
- if (!existsSync8(briefPath)) {
2742
- const created = new Date().toISOString();
2743
- const brief = [
2744
- `# ${feature.title}`,
2745
- "",
2746
- feature.oneLiner,
2747
- "",
2748
- `- Slug: ${feature.slug}`,
2749
- `- Created: ${created}`,
2750
- ""
2751
- ].join(`
2752
- `);
2753
- writeFileSync(briefPath, brief, "utf8");
2754
- }
2755
- if (feature.mapped) {
2756
- scaffoldMapDoc(project, feature);
2757
- }
2758
- emit(ctx, feature.id, {
2759
- type: "docs.scaffolded",
2760
- message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
2761
- });
2762
- }
2763
- function scaffoldMapDoc(project, feature, seed) {
2764
- mkdirSync3(featureDocsDir(project, feature), { recursive: true });
2765
- const mapPath = featureDocPath(project, feature, "map.md");
2766
- if (existsSync8(mapPath))
2767
- return;
2768
- writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
2769
- }
2770
- function mapDocBody(feature, seed) {
2771
- const seeded = {
2772
- Destination: seed?.destination?.trim(),
2773
- Notes: seed?.notes?.trim()
2774
- };
2775
- const lines = [`# ${feature.title} \u2014 map`, ""];
2776
- for (const section of MAP_SECTIONS) {
2777
- lines.push(`## ${section}`, "");
2778
- const body = seeded[section];
2779
- if (body)
2780
- lines.push(body, "");
2781
- }
2782
- return lines.join(`
2783
- `);
2784
- }
2785
- function listDocs(ctx, feature) {
2786
- const project = projectForFeature(ctx, feature);
2787
- const dir = featureDocsDir(project, feature);
2788
- if (!existsSync8(dir))
2789
- return [];
2790
- 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));
2791
- }
2792
- function readDoc(ctx, feature, relPath) {
2793
- const project = projectForFeature(ctx, feature);
2794
- const dir = featureDocsDir(project, feature);
2795
- const root = resolve4(dir);
2796
- const target = resolve4(dir, relPath);
2797
- const rel = relative(root, target);
2798
- if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
2799
- throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
2800
- }
2801
- if (!existsSync8(target))
2802
- throw new NotFoundError(`doc not found: ${relPath}`);
2803
- return { content: readFileSync3(target, "utf8") };
2804
- }
2805
- function readTitle(path, fallback) {
2806
- try {
2807
- const firstLine = readFileSync3(path, "utf8").split(`
2808
- `, 1)[0] ?? "";
2809
- const heading = firstLine.match(/^#\s+(.+)$/);
2810
- return heading ? heading[1].trim() : fallback;
2811
- } catch {
2812
- return fallback;
2813
- }
2814
- }
2815
- var MAP_SECTIONS;
2816
- var init_knowledge = __esm(() => {
2817
- init_errors();
2818
- init_events();
2819
- init_feature_docs();
2820
- init_repo();
2821
- MAP_SECTIONS = [
2822
- "Destination",
2823
- "Notes",
2824
- "Not yet specified",
2825
- "Out of scope"
2826
- ];
2827
- });
2828
-
2829
- // src/pty/end-session.ts
2830
- function endSession(ctx, sessionId) {
2831
- const registry = ptyRegistry();
2832
- const killed = registry.kill(sessionId);
2833
- registry.remove(sessionId);
2834
- const session = markSessionEnded(ctx, sessionId);
2835
- if (session) {
2836
- releaseForSession(ctx, sessionId);
2837
- emit(ctx, session.featureId, {
2838
- type: "session.ended",
2839
- message: "session ended by user",
2840
- data: { sessionId, killed }
2841
- });
2842
- } else {
2843
- const existing = getSessionRow(ctx, sessionId);
2844
- if (!existing)
2845
- return { sessionId, ok: false, killed };
2846
- }
2847
- return { sessionId, ok: true, killed };
2848
- }
2849
- var init_end_session = __esm(() => {
2850
- init_sessions();
2851
- init_events();
2852
- init_waypoints();
2853
- init_registry();
2854
- });
2855
-
2856
- // src/launcher/skills-root.ts
2857
- import { existsSync as existsSync9 } from "fs";
2858
- import { dirname as dirname2, join as join8, resolve as resolve5 } from "path";
2859
- function resolveSkillsRoot(fromDir) {
2860
- const override = process.env[SKILLS_DIR_ENV];
2861
- if (override) {
2862
- const root = resolve5(override);
2863
- if (existsSync9(join8(root, SKILLS_MARKER)))
2864
- return root;
2865
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
2866
- }
2867
- const searched = [];
2868
- let dir = fromDir;
2869
- for (let i = 0;i < 8; i += 1) {
2870
- const candidate = join8(dir, "packages", "skills");
2871
- searched.push(candidate);
2872
- if (existsSync9(join8(candidate, SKILLS_MARKER)))
2873
- return candidate;
2874
- const parent = dirname2(dir);
2875
- if (parent === dir)
2876
- break;
2877
- dir = parent;
2878
- }
2879
- throw new Error(`runcastle skills root not found; searched:
2880
- ${searched.join(`
2881
- `)}`);
2882
- }
2883
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
2884
- var init_skills_root = __esm(() => {
2885
- SKILLS_MARKER = join8("packs", "runcastle");
2886
- });
2887
-
2888
2787
  // src/services/agent-stream.ts
2889
2788
  function beginTranscript(ticketId) {
2890
2789
  transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
@@ -3013,8 +2912,8 @@ var init_burn_guard = __esm(() => {
3013
2912
  });
3014
2913
 
3015
2914
  // src/workflows/ticket-burner.ts
3016
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
3017
- 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";
3018
2917
  import { fileURLToPath as fileURLToPath2 } from "url";
3019
2918
  import { claudeCode, run } from "@ai-hero/sandcastle";
3020
2919
  import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
@@ -3130,13 +3029,13 @@ function parseEnvFile(content) {
3130
3029
  if (line.length === 0 || line.startsWith("#"))
3131
3030
  continue;
3132
3031
  const body = line.startsWith("export ") ? line.slice(7).trim() : line;
3133
- const eq6 = body.indexOf("=");
3134
- if (eq6 === -1)
3032
+ const eq5 = body.indexOf("=");
3033
+ if (eq5 === -1)
3135
3034
  continue;
3136
- const key = body.slice(0, eq6).trim();
3035
+ const key = body.slice(0, eq5).trim();
3137
3036
  if (key.length === 0)
3138
3037
  continue;
3139
- let value = body.slice(eq6 + 1).trim();
3038
+ let value = body.slice(eq5 + 1).trim();
3140
3039
  if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
3141
3040
  value = value.slice(1, -1);
3142
3041
  }
@@ -3237,12 +3136,12 @@ function buildVerifyNotes(config2) {
3237
3136
  `);
3238
3137
  }
3239
3138
  function readRepoToolchain(repoPath) {
3240
- const pkgPath = join9(repoPath, "package.json");
3241
- const hasPackageJson = existsSync10(pkgPath);
3139
+ const pkgPath = join7(repoPath, "package.json");
3140
+ const hasPackageJson = existsSync7(pkgPath);
3242
3141
  let packageManagerField;
3243
3142
  if (hasPackageJson) {
3244
3143
  try {
3245
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
3144
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
3246
3145
  if (typeof pkg.packageManager === "string")
3247
3146
  packageManagerField = pkg.packageManager;
3248
3147
  } catch {}
@@ -3251,10 +3150,10 @@ function readRepoToolchain(repoPath) {
3251
3150
  hasPackageJson,
3252
3151
  ...packageManagerField !== undefined ? { packageManagerField } : {},
3253
3152
  lockfiles: {
3254
- bun: existsSync10(join9(repoPath, "bun.lock")) || existsSync10(join9(repoPath, "bun.lockb")),
3255
- pnpm: existsSync10(join9(repoPath, "pnpm-lock.yaml")),
3256
- yarn: existsSync10(join9(repoPath, "yarn.lock")),
3257
- 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"))
3258
3157
  }
3259
3158
  };
3260
3159
  }
@@ -3395,18 +3294,18 @@ function buildRetryNotes(input) {
3395
3294
  `);
3396
3295
  }
3397
3296
  function delayUnlessAborted(ms, signal) {
3398
- return new Promise((resolve6) => {
3297
+ return new Promise((resolve5) => {
3399
3298
  if (signal.aborted) {
3400
- resolve6();
3299
+ resolve5();
3401
3300
  return;
3402
3301
  }
3403
3302
  const onAbort = () => {
3404
3303
  clearTimeout(t);
3405
- resolve6();
3304
+ resolve5();
3406
3305
  };
3407
3306
  const t = setTimeout(() => {
3408
3307
  signal.removeEventListener("abort", onAbort);
3409
- resolve6();
3308
+ resolve5();
3410
3309
  }, ms);
3411
3310
  signal.addEventListener("abort", onAbort, { once: true });
3412
3311
  });
@@ -3429,38 +3328,38 @@ function createSerialQueue() {
3429
3328
  };
3430
3329
  }
3431
3330
  async function landWithResolve(branch, deps) {
3432
- let current2 = branch;
3331
+ let current = branch;
3433
3332
  for (let attempt = 1;; attempt++) {
3434
- const merge = await deps.merge(current2);
3333
+ const merge = await deps.merge(current);
3435
3334
  if (merge.ok)
3436
- return { status: "landed", branch: current2 };
3335
+ return { status: "landed", branch: current };
3437
3336
  const detail = merge.error ?? "merge failed";
3438
3337
  if (!merge.conflict)
3439
- return { status: "failed", branch: current2, error: detail };
3338
+ return { status: "failed", branch: current, error: detail };
3440
3339
  const files = merge.files ?? [];
3441
3340
  if (attempt > deps.maxResolveAttempts) {
3442
- return { status: "conflict", branch: current2, files, error: detail };
3341
+ return { status: "conflict", branch: current, files, error: detail };
3443
3342
  }
3444
3343
  const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
3445
3344
  deps.emit({
3446
3345
  type: "merge.conflict.resolving",
3447
3346
  message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
3448
- data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
3347
+ data: { branch: current, files, attempt, maxAttempts: deps.maxResolveAttempts }
3449
3348
  });
3450
3349
  const resolved = await deps.resolve({
3451
- branch: current2,
3350
+ branch: current,
3452
3351
  files,
3453
3352
  attempt,
3454
3353
  maxAttempts: deps.maxResolveAttempts
3455
3354
  });
3456
- current2 = resolved.branch;
3355
+ current = resolved.branch;
3457
3356
  if (!resolved.ok) {
3458
- return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
3357
+ return { status: "conflict", branch: current, files, error: resolved.error ?? detail };
3459
3358
  }
3460
3359
  deps.emit({
3461
3360
  type: "merge.conflict.resolved",
3462
- message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
3463
- data: { branch: current2, files, attempt }
3361
+ message: `${deps.label}: conflict resolved on ${current} \u2014 landing`,
3362
+ data: { branch: current, files, attempt }
3464
3363
  });
3465
3364
  }
3466
3365
  }
@@ -3618,26 +3517,26 @@ function resolverTemplatePath() {
3618
3517
  }
3619
3518
  function burnerAssetPath(file) {
3620
3519
  const here = dirname3(fileURLToPath2(import.meta.url));
3621
- return join9(resolveSkillsRoot(here), "burner", file);
3520
+ return join7(resolveSkillsRoot(here), "burner", file);
3622
3521
  }
3623
3522
  function readDocsDigestFromDisk(projectId, slug) {
3624
3523
  const worktree = worktreeDir(projectId, slug);
3625
- if (!existsSync10(worktree))
3524
+ if (!existsSync7(worktree))
3626
3525
  return "_No talk worktree on disk \u2014 docs digest skipped._";
3627
- const docsDir = join9(worktree, ...featureDocsRel(slug).split("/"));
3628
- if (!existsSync10(docsDir))
3526
+ const docsDir = join7(worktree, ...featureDocsRel(slug).split("/"));
3527
+ if (!existsSync7(docsDir))
3629
3528
  return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
3630
- 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") }));
3631
3530
  return buildDocsDigest(files);
3632
3531
  }
3633
3532
  function readBlockedFile(dirs) {
3634
3533
  for (const dir of dirs) {
3635
3534
  if (!dir)
3636
3535
  continue;
3637
- const p = join9(dir, "BLOCKED.md");
3638
- if (existsSync10(p)) {
3536
+ const p = join7(dir, "BLOCKED.md");
3537
+ if (existsSync7(p)) {
3639
3538
  try {
3640
- return readFileSync4(p, "utf8");
3539
+ return readFileSync3(p, "utf8");
3641
3540
  } catch {}
3642
3541
  }
3643
3542
  }
@@ -3690,8 +3589,9 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3690
3589
  const featureBrief = buildFeatureBrief(feature);
3691
3590
  const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3692
3591
  const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3693
- const verifyNotes = buildVerifyNotes(config2);
3694
- const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3592
+ const prepared = resolvePreparedSettings(config2, project);
3593
+ const verifyNotes = buildVerifyNotes(prepared);
3594
+ const basePrompt = renderTicketPrompt(readFileSync3(burnerTemplatePath(), "utf8"), {
3695
3595
  TICKET_JSON: ticketJson,
3696
3596
  FEATURE_BRIEF: featureBrief,
3697
3597
  DOCS_DIGEST: docsDigest,
@@ -3701,19 +3601,19 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3701
3601
  });
3702
3602
  const toolchain = readRepoToolchain(project.repoPath);
3703
3603
  const pm = detectPackageManager(toolchain);
3704
- const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
3604
+ const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
3705
3605
  const mounts = [];
3706
3606
  if (config2.sandbox !== "noSandbox" && pm) {
3707
3607
  const mount = cacheMountFor(pm, burnCacheDir(pm));
3708
3608
  if (mount) {
3709
- mkdirSync4(mount.hostPath, { recursive: true });
3609
+ mkdirSync3(mount.hostPath, { recursive: true });
3710
3610
  mounts.push(mount);
3711
3611
  }
3712
3612
  }
3713
3613
  const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
3714
3614
  const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
3715
- mkdirSync4(logsDir(), { recursive: true });
3716
- const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3615
+ mkdirSync3(logsDir(), { recursive: true });
3616
+ const logFilePath = join7(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3717
3617
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3718
3618
  beginTranscript(ticket.id);
3719
3619
  const timer = createToolTimer();
@@ -3759,7 +3659,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3759
3659
  const runResolver = async (input) => {
3760
3660
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
3761
3661
  const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
3762
- const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
3662
+ const prompt = renderTemplate(readFileSync3(resolverTemplatePath(), "utf8"), {
3763
3663
  TICKET_JSON: ticketJson,
3764
3664
  FEATURE_BRIEF: featureBrief,
3765
3665
  DOCS_DIGEST: docsDigest,
@@ -4046,108 +3946,997 @@ ${blocked.trim()}` };
4046
3946
  }
4047
3947
  }
4048
3948
  }
4049
- function resolveBurnDeps(ctx) {
4050
- const config2 = loadConfig();
4051
- const token = readTokenFromEnvFile(envPath());
4052
- const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
4053
- const land = createSerialQueue();
4054
- let pushTargetReady;
4055
- const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
4056
- return {
4057
- config: config2,
4058
- hasAuthToken: token !== undefined,
4059
- concurrency: config2.burnConcurrency,
4060
- executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land, ensureIsolatedPushTarget)
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);
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
+ });
4841
+ }
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()
4061
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
+ `);
4062
4863
  }
4063
- function readTokenFromEnvFile(path) {
4064
- let fromFile;
4065
- if (existsSync10(path)) {
4066
- try {
4067
- fromFile = parseEnvFile(readFileSync4(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
4068
- } catch {
4069
- fromFile = undefined;
4070
- }
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}`);
4071
4879
  }
4072
- const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
4073
- 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") };
4074
4883
  }
4075
- var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
4076
- var init_ticket_burner = __esm(() => {
4077
- init_src();
4078
- init_config_load();
4079
- init_paths();
4080
- init_skills_root();
4081
- init_agent_stream();
4082
- init_git();
4083
- init_burn_guard();
4084
- PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
4085
- PM_CACHE_SANDBOX_PATHS = {
4086
- bun: "~/.bun/install/cache",
4087
- yarn: "~/.cache/yarn",
4088
- npm: "~/.npm"
4089
- };
4090
- RUN = String.raw`(?![\w./-])`;
4091
- BASH_PATTERNS = [
4092
- ["tests", new RegExp(String.raw`\b(vitest|jest|pytest|go test|cargo test|bun test|playwright|maestro)${RUN}|\b(pnpm|npm|yarn|bun|npx)\b[^&;|]*?\s(run\s+)?test${RUN}|--testPathPatterns`)],
4093
- ["typecheck", /\btsc\b|\btypecheck\b|\bmypy\b|\bcargo check\b/],
4094
- ["build", new RegExp(String.raw`\bbuild${RUN}|\bprisma\s+(generate|migrate)\b|\bcodegen${RUN}`)],
4095
- ["lint", new RegExp(String.raw`\b(eslint|prettier|ruff|biome|lint|format)${RUN}`)],
4096
- ["install", /\b(pnpm|npm|yarn|bun|corepack)\s+\w*\s*install\b|\bnpm ci\b/],
4097
- ["git", /\bgit\b/],
4098
- ["search", /\b(grep|rg|ag|find)\b/],
4099
- ["file-read", /\b(cat|sed|head|tail|less|wc|ls)\b/],
4100
- ["file-edit", /<<\s*['"]?(PY|EOF|SH|JS|TS)\b|\bpython3?\s+-\s*<</]
4101
- ];
4102
- TOOL_NAME_CATEGORY = {
4103
- Read: "file-read",
4104
- NotebookRead: "file-read",
4105
- Grep: "search",
4106
- Glob: "search",
4107
- Edit: "file-edit",
4108
- Write: "file-edit",
4109
- NotebookEdit: "file-edit"
4110
- };
4111
- MAX_ATTRIBUTABLE_GAP_MS = 20 * 60000;
4112
- BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
4113
- WORKTREE_REMOVAL_FAILURES = [
4114
- /failed to delete/i,
4115
- /directory not empty|enotempty/i,
4116
- /unable to (unlink|delete)/i,
4117
- /is not a working tree/i,
4118
- /resource busy|ebusy|eperm|eacces/i
4119
- ];
4120
- FATAL_ERROR_PATTERNS = [
4121
- /invalid (api key|x-api-key)/i,
4122
- /authentication|unauthorized|permission denied/i,
4123
- /credit balance|billing/i,
4124
- /oauth token|setup-token/i,
4125
- /issue with the selected model|model not found|unknown model/i,
4126
- /does not support resumeSession|resumeSession .* not found/i
4127
- ];
4128
- RETRYABLE_ERROR_PATTERNS = [
4129
- /exited with code \d+/i,
4130
- /idle timeout|AgentIdleTimeout/i,
4131
- /connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
4132
- /econnreset|etimedout|econnrefused|epipe|eai_again/i,
4133
- /overloaded|rate.?limit|too many requests/i,
4134
- /internal server error|service unavailable|bad gateway|gateway timeout/i,
4135
- /\bapi error\b/i,
4136
- /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"
4137
4905
  ];
4138
- activeTicketAborts = new Map;
4139
- SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
4140
- ticketBurner = {
4141
- id: "ticket-burner",
4142
- run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
4143
- };
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();
4144
4933
  });
4145
4934
 
4146
4935
  // src/workflows/research.ts
4147
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
4148
- import { dirname as dirname4, join as join10 } from "path";
4149
- import { fileURLToPath as fileURLToPath3 } from "url";
4150
- 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";
4151
4940
  import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
4152
4941
  import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
4153
4942
  function waypointSlug(waypoint) {
@@ -4164,7 +4953,7 @@ async function researchRun(ctx, deps) {
4164
4953
  return { status: "failed", summary: "no waypoint to research" };
4165
4954
  }
4166
4955
  if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
4167
- ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
4956
+ ctx.emitEvent({ type: AUTH_MISSING_EVENT3, message: AUTH_MISSING_MESSAGE3 });
4168
4957
  return { status: "failed", summary: "research aborted: auth token missing" };
4169
4958
  }
4170
4959
  ctx.emitEvent({
@@ -4185,12 +4974,12 @@ async function researchRun(ctx, deps) {
4185
4974
  }
4186
4975
  ctx.emitEvent({
4187
4976
  type: "research.failed",
4188
- message: `waypoint ${waypoint.seq} research failed: ${errorHeadline2(outcome.error)}`,
4977
+ message: `waypoint ${waypoint.seq} research failed: ${errorHeadline3(outcome.error)}`,
4189
4978
  data: { waypointId: waypoint.id, error: outcome.error }
4190
4979
  });
4191
4980
  return { status: "failed", summary: outcome.error };
4192
4981
  }
4193
- function errorHeadline2(s) {
4982
+ function errorHeadline3(s) {
4194
4983
  const lines = s.split(`
4195
4984
  `).map((l) => l.trim()).filter(Boolean);
4196
4985
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
@@ -4201,7 +4990,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
4201
4990
  }
4202
4991
  function renderResearchPrompt(template, values) {
4203
4992
  let out = template;
4204
- for (const key of PLACEHOLDERS) {
4993
+ for (const key of PLACEHOLDERS2) {
4205
4994
  out = out.split(`{{${key}}}`).join(values[key]);
4206
4995
  }
4207
4996
  return out;
@@ -4216,32 +5005,32 @@ function interpretResearchResult(result, docRelPath) {
4216
5005
  return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
4217
5006
  }
4218
5007
  function researchTemplatePath() {
4219
- const here = dirname4(fileURLToPath3(import.meta.url));
4220
- 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");
4221
5010
  }
4222
5011
  function readDocsDigestFromDisk2(projectId, slug) {
4223
5012
  const worktree = worktreeDir(projectId, slug);
4224
- if (!existsSync11(worktree))
5013
+ if (!existsSync12(worktree))
4225
5014
  return "_No talk worktree on disk \u2014 docs digest skipped._";
4226
- const docsDir = join10(worktree, ...featureDocsRel(slug).split("/"));
4227
- if (!existsSync11(docsDir))
5015
+ const docsDir = join11(worktree, ...featureDocsRel(slug).split("/"));
5016
+ if (!existsSync12(docsDir))
4228
5017
  return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
4229
- 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") }));
4230
5019
  return buildDocsDigest(files);
4231
5020
  }
4232
5021
  async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4233
5022
  const { project, feature } = ctx;
4234
5023
  const docRelPath = researchDocRel(feature.slug, waypoint);
4235
5024
  const tempBranch = researchBranchName(feature.slug, waypoint.seq, newId("b").slice(2, 10));
4236
- const template = readFileSync5(researchTemplatePath(), "utf8");
5025
+ const template = readFileSync6(researchTemplatePath(), "utf8");
4237
5026
  const prompt = renderResearchPrompt(template, {
4238
5027
  WAYPOINT_JSON: buildWaypointJson(waypoint),
4239
5028
  FEATURE_BRIEF: buildFeatureBrief(feature),
4240
5029
  DOCS_DIGEST: readDocsDigestFromDisk2(project.id, feature.slug),
4241
5030
  RESEARCH_DOC_PATH: docRelPath
4242
5031
  });
4243
- mkdirSync5(logsDir(), { recursive: true });
4244
- 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`);
4245
5034
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
4246
5035
  const runOptions = {
4247
5036
  agent: buildBurnAgent(config2, token, model),
@@ -4256,7 +5045,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4256
5045
  let result;
4257
5046
  let salvaged = [];
4258
5047
  try {
4259
- result = await run2(runOptions);
5048
+ result = await run3(runOptions);
4260
5049
  } catch (err) {
4261
5050
  throttle.flush();
4262
5051
  if (ctx.signal.aborted)
@@ -4268,7 +5057,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4268
5057
  const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
4269
5058
  ctx.emitEvent({
4270
5059
  type: "burn.worktree.teardown-failed",
4271
- message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline2(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
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`,
4272
5061
  data: { tempBranch, error: msg, cleanedUp: removed }
4273
5062
  });
4274
5063
  }
@@ -4281,7 +5070,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4281
5070
  const detail = merge.error ?? "merge failed";
4282
5071
  ctx.emitEvent({
4283
5072
  type: merge.conflict ? "merge.conflict.needs-human" : "research.merge_failed",
4284
- 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`,
4285
5074
  data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
4286
5075
  });
4287
5076
  return {
@@ -4303,9 +5092,9 @@ function resolveResearchDeps(ctx) {
4303
5092
  }
4304
5093
  function readTokenFromEnvFile2(path) {
4305
5094
  let fromFile;
4306
- if (existsSync11(path)) {
5095
+ if (existsSync12(path)) {
4307
5096
  try {
4308
- fromFile = parseEnvFile(readFileSync5(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
5097
+ fromFile = parseEnvFile(readFileSync6(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
4309
5098
  } catch {
4310
5099
  fromFile = undefined;
4311
5100
  }
@@ -4313,7 +5102,7 @@ function readTokenFromEnvFile2(path) {
4313
5102
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
4314
5103
  return token && token.length > 0 ? token : undefined;
4315
5104
  }
4316
- 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;
4317
5106
  var init_research = __esm(() => {
4318
5107
  init_src();
4319
5108
  init_config_load();
@@ -4321,7 +5110,7 @@ var init_research = __esm(() => {
4321
5110
  init_skills_root();
4322
5111
  init_git();
4323
5112
  init_ticket_burner();
4324
- PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
5113
+ PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
4325
5114
  research = {
4326
5115
  id: "research",
4327
5116
  run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
@@ -4343,12 +5132,12 @@ var init_registry2 = __esm(() => {
4343
5132
  });
4344
5133
 
4345
5134
  // src/workflows/runner.ts
4346
- import { eq as eq6 } from "drizzle-orm";
5135
+ import { eq as eq8 } from "drizzle-orm";
4347
5136
  function workflowClaimsFeatureBranch(workflowId) {
4348
5137
  return BRANCH_CLAIMING.has(workflowId);
4349
5138
  }
4350
5139
  function isRunActive(runId) {
4351
- return controllers.has(runId);
5140
+ return controllers2.has(runId);
4352
5141
  }
4353
5142
  async function startRun(ctx, featureId, workflowId, opts = {}) {
4354
5143
  const feature = getFeatureRow(ctx, featureId);
@@ -4372,7 +5161,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4372
5161
  claim(ctx, opts.claimWaypointId, runId);
4373
5162
  } catch (e) {
4374
5163
  const summary = e instanceof Error ? e.message : "claim failed";
4375
- 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();
4376
5165
  throw e;
4377
5166
  }
4378
5167
  }
@@ -4383,7 +5172,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4383
5172
  data: { workflow: workflowId }
4384
5173
  });
4385
5174
  const controller = new AbortController;
4386
- controllers.set(runId, controller);
5175
+ controllers2.set(runId, controller);
4387
5176
  const talkWorktree = worktreeDir(project.id, feature.slug);
4388
5177
  const talkDetached = workflowClaimsFeatureBranch(workflowId) ? await detachWorktree(talkWorktree) : false;
4389
5178
  const wctx = {
@@ -4416,7 +5205,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4416
5205
  return { runId, done };
4417
5206
  }
4418
5207
  function cancelRun(runId) {
4419
- controllers.get(runId)?.abort();
5208
+ controllers2.get(runId)?.abort();
4420
5209
  }
4421
5210
  async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
4422
5211
  let status = "failed";
@@ -4435,9 +5224,9 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
4435
5224
  }
4436
5225
  emit(ctx, featureId, { type: "run.error", message: summary, runId });
4437
5226
  } finally {
4438
- controllers.delete(runId);
5227
+ controllers2.delete(runId);
4439
5228
  }
4440
- 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();
4441
5230
  releaseForSession(ctx, runId);
4442
5231
  if (workflowClaimsFeatureBranch(workflow2)) {
4443
5232
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -4467,7 +5256,7 @@ function maybeAutoAdvance(ctx, featureId) {
4467
5256
  if (next)
4468
5257
  setPhase(ctx, featureId, next, "phase.advanced", "auto-advanced to review after successful run");
4469
5258
  }
4470
- var controllers, BRANCH_CLAIMING;
5259
+ var controllers2, BRANCH_CLAIMING;
4471
5260
  var init_runner = __esm(() => {
4472
5261
  init_src();
4473
5262
  init_paths();
@@ -4480,12 +5269,12 @@ var init_runner = __esm(() => {
4480
5269
  init_tickets();
4481
5270
  init_waypoints();
4482
5271
  init_registry2();
4483
- controllers = new Map;
5272
+ controllers2 = new Map;
4484
5273
  BRANCH_CLAIMING = new Set(["ticket-burner"]);
4485
5274
  });
4486
5275
 
4487
5276
  // src/services/features.ts
4488
- import { desc as desc3, eq as eq7 } from "drizzle-orm";
5277
+ import { desc as desc4, eq as eq9 } from "drizzle-orm";
4489
5278
  import { rmSync as rmSync2 } from "fs";
4490
5279
  async function createFeature(ctx, input) {
4491
5280
  const project = requireProjectById(ctx, input.projectId);
@@ -4548,7 +5337,7 @@ function getFeatureFull(ctx, id) {
4548
5337
  };
4549
5338
  }
4550
5339
  function list(ctx, projectId) {
4551
- 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();
4552
5341
  return rows.map((row) => {
4553
5342
  const feature = rowToFeature(row);
4554
5343
  const tickets2 = listByFeature2(ctx, feature.id);
@@ -4693,7 +5482,7 @@ function escalateToMap(ctx, featureId, input) {
4693
5482
  return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
4694
5483
  }
4695
5484
  const project = projectForFeature(ctx, feature);
4696
- 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();
4697
5486
  scaffoldMapDoc(project, { ...feature, mapped: true }, input);
4698
5487
  emit(ctx, featureId, {
4699
5488
  type: "feature.escalated",
@@ -4710,7 +5499,7 @@ function archiveFeature(ctx, featureId) {
4710
5499
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
4711
5500
  if (live)
4712
5501
  endSession(ctx, live.id);
4713
- 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();
4714
5503
  emit(ctx, featureId, {
4715
5504
  type: "feature.archived",
4716
5505
  message: `feature ${feature.slug} archived`,
@@ -4724,7 +5513,7 @@ function unarchiveFeature(ctx, featureId) {
4724
5513
  throw new GateError(`feature ${feature.slug} is not archived`);
4725
5514
  }
4726
5515
  const restored = feature.phase === "shipped" ? "shipped" : "active";
4727
- 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();
4728
5517
  emit(ctx, featureId, {
4729
5518
  type: "feature.unarchived",
4730
5519
  message: `feature ${feature.slug} unarchived (${restored})`,
@@ -4738,9 +5527,9 @@ async function deleteFeature(ctx, featureId) {
4738
5527
  throw new GateError(`feature ${feature.slug} is shipped \u2014 archive it instead (delete is for non-shipped features)`);
4739
5528
  }
4740
5529
  const project = projectForFeature(ctx, feature);
4741
- for (const run3 of listRunsByFeature(ctx, featureId)) {
4742
- if (run3.status === "running")
4743
- cancelRun(run3.id);
5530
+ for (const run4 of listRunsByFeature(ctx, featureId)) {
5531
+ if (run4.status === "running")
5532
+ cancelRun(run4.id);
4744
5533
  }
4745
5534
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
4746
5535
  if (live)
@@ -4764,13 +5553,13 @@ async function deleteFeature(ctx, featureId) {
4764
5553
  return { ok: true, slug: feature.slug };
4765
5554
  }
4766
5555
  function deleteFeatureRows(ctx, featureId) {
4767
- ctx.db.delete(tickets).where(eq7(tickets.featureId, featureId)).run();
4768
- ctx.db.delete(sessions).where(eq7(sessions.featureId, featureId)).run();
4769
- ctx.db.delete(runs).where(eq7(runs.featureId, featureId)).run();
4770
- ctx.db.delete(events).where(eq7(events.featureId, featureId)).run();
4771
- ctx.db.delete(gateOverrides).where(eq7(gateOverrides.featureId, featureId)).run();
4772
- ctx.db.delete(waypoints).where(eq7(waypoints.featureId, featureId)).run();
4773
- 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();
4774
5563
  }
4775
5564
  function gateState(ctx, feature) {
4776
5565
  const gate = nextGate(feature);
@@ -4781,7 +5570,7 @@ function gateState(ctx, feature) {
4781
5570
  }
4782
5571
  function uniqueSlug(ctx, projectId, title) {
4783
5572
  const base = slugify(title);
4784
- 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));
4785
5574
  if (!existing.has(base))
4786
5575
  return base;
4787
5576
  let n = 2;
@@ -4813,7 +5602,7 @@ var init_features = __esm(() => {
4813
5602
  import { StreamableHTTPTransport } from "@hono/mcp";
4814
5603
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4815
5604
  import { Hono } from "hono";
4816
- import * as z3 from "zod";
5605
+ import * as z4 from "zod";
4817
5606
  function headerSessionId(extra) {
4818
5607
  const headers = extra.requestInfo?.headers;
4819
5608
  if (!headers)
@@ -4967,7 +5756,7 @@ function buildMcpServer() {
4967
5756
  server.registerTool("emit_tickets", {
4968
5757
  title: "Emit tickets",
4969
5758
  description: "Store the ideation session's ticket batch. Each ticket: title, goal, context, acceptanceCriteria[], seams[], blockedBy[] (1-based positions within THIS batch).",
4970
- inputSchema: { tickets: z3.array(TicketInput) }
5759
+ inputSchema: { tickets: z4.array(TicketInput) }
4971
5760
  }, async (args, extra) => {
4972
5761
  const rs = await resolveCtxSession(extra);
4973
5762
  if (!rs)
@@ -4980,12 +5769,12 @@ function buildMcpServer() {
4980
5769
  title: "Update ticket",
4981
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.",
4982
5771
  inputSchema: {
4983
- id: z3.string(),
4984
- title: z3.string().optional(),
4985
- goal: z3.string().optional(),
4986
- context: z3.string().optional(),
4987
- acceptanceCriteria: z3.array(z3.string()).optional(),
4988
- 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()
4989
5778
  }
4990
5779
  }, async (args, extra) => {
4991
5780
  const rs = await resolveCtxSession(extra);
@@ -4998,7 +5787,7 @@ function buildMcpServer() {
4998
5787
  server.registerTool("cancel_ticket", {
4999
5788
  title: "Cancel ticket",
5000
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.",
5001
- inputSchema: { id: z3.string(), reason: z3.string().optional() }
5790
+ inputSchema: { id: z4.string(), reason: z4.string().optional() }
5002
5791
  }, async (args, extra) => {
5003
5792
  const rs = await resolveCtxSession(extra);
5004
5793
  if (!rs)
@@ -5010,7 +5799,7 @@ function buildMcpServer() {
5010
5799
  server.registerTool("escalate_to_map", {
5011
5800
  title: "Escalate to map",
5012
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.",
5013
- inputSchema: { destination: z3.string(), notes: z3.string().optional() }
5802
+ inputSchema: { destination: z4.string(), notes: z4.string().optional() }
5014
5803
  }, async (args, extra) => {
5015
5804
  const rs = await resolveCtxSession(extra);
5016
5805
  if (!rs)
@@ -5024,7 +5813,7 @@ function buildMcpServer() {
5024
5813
  server.registerTool("emit_waypoints", {
5025
5814
  title: "Emit waypoints",
5026
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.",
5027
- inputSchema: { waypoints: z3.array(WaypointInput) }
5816
+ inputSchema: { waypoints: z4.array(WaypointInput) }
5028
5817
  }, async (args, extra) => {
5029
5818
  const rs = await resolveCtxSession(extra);
5030
5819
  if (!rs)
@@ -5034,7 +5823,7 @@ function buildMcpServer() {
5034
5823
  server.registerTool("resolve_waypoint", {
5035
5824
  title: "Resolve waypoint",
5036
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.",
5037
- inputSchema: { id: z3.string(), disposition: WaypointDisposition, summary: z3.string() }
5826
+ inputSchema: { id: z4.string(), disposition: WaypointDisposition, summary: z4.string() }
5038
5827
  }, async (args, extra) => {
5039
5828
  const rs = await resolveCtxSession(extra);
5040
5829
  if (!rs)
@@ -5046,7 +5835,7 @@ function buildMcpServer() {
5046
5835
  server.registerTool("record_event", {
5047
5836
  title: "Record event",
5048
5837
  description: "Add a note to the feature timeline (decisions recorded, spec saved, milestones).",
5049
- inputSchema: { type: z3.string(), message: z3.string() }
5838
+ inputSchema: { type: z4.string(), message: z4.string() }
5050
5839
  }, async (args, extra) => {
5051
5840
  const rs = await resolveCtxSession(extra);
5052
5841
  if (!rs)
@@ -5169,12 +5958,16 @@ import { Hono as Hono2 } from "hono";
5169
5958
  function handleSessionStart(ctx, sessionId, feature, payload) {
5170
5959
  const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
5171
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";
5172
5963
  markSessionLive(ctx, sessionId, { ccSessionId, transcriptPath });
5173
- emit(ctx, feature.id, {
5174
- type: "session.started",
5175
- message: "session live",
5176
- data: { sessionId, ccSessionId, transcriptPath }
5177
- });
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
+ }
5178
5971
  return {
5179
5972
  hookSpecificOutput: {
5180
5973
  hookEventName: "SessionStart",
@@ -5182,7 +5975,9 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
5182
5975
  }
5183
5976
  };
5184
5977
  }
5185
- 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);
5186
5981
  const tickets2 = listByFeature2(ctx, feature.id).length;
5187
5982
  return {
5188
5983
  hookSpecificOutput: {
@@ -5249,7 +6044,7 @@ var init_hooks = __esm(() => {
5249
6044
  case "session-start":
5250
6045
  return c.json(handleSessionStart(ctx, sessionId, feature, body.payload));
5251
6046
  case "user-prompt":
5252
- return c.json(handleUserPrompt(ctx, feature));
6047
+ return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
5253
6048
  case "session-end":
5254
6049
  return c.json(handleSessionEnd(ctx, sessionId, feature));
5255
6050
  default:
@@ -5314,9 +6109,9 @@ var init_stream = __esm(() => {
5314
6109
  });
5315
6110
 
5316
6111
  // src/routes/web.ts
5317
- import { existsSync as existsSync12, statSync } from "fs";
6112
+ import { existsSync as existsSync13, statSync } from "fs";
5318
6113
  import { readFile } from "fs/promises";
5319
- 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";
5320
6115
  function contentType(path) {
5321
6116
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
5322
6117
  }
@@ -5334,7 +6129,7 @@ function safeJoin(root, pathname) {
5334
6129
  } catch {
5335
6130
  return null;
5336
6131
  }
5337
- const joined = normalize(join11(root, decoded));
6132
+ const joined = normalize(join12(root, decoded));
5338
6133
  if (joined !== root && !joined.startsWith(root + sep2))
5339
6134
  return null;
5340
6135
  return joined;
@@ -5347,7 +6142,7 @@ function resolveWebDist() {
5347
6142
  }
5348
6143
  function mountWebApp(app, distDir) {
5349
6144
  const root = resolve6(distDir);
5350
- const indexHtml = join11(root, "index.html");
6145
+ const indexHtml = join12(root, "index.html");
5351
6146
  app.get("*", async (c, next) => {
5352
6147
  const { pathname } = new URL(c.req.url);
5353
6148
  if (/^\/(api|mcp|ws|health)(\/|$)/.test(pathname))
@@ -5367,7 +6162,7 @@ function mountWebApp(app, distDir) {
5367
6162
  }
5368
6163
  function mountWebAppIfBuilt(app) {
5369
6164
  const dist = resolveWebDist();
5370
- if (existsSync12(join11(dist, "index.html")))
6165
+ if (existsSync13(join12(dist, "index.html")))
5371
6166
  mountWebApp(app, dist);
5372
6167
  }
5373
6168
  var MIME;
@@ -5414,14 +6209,14 @@ var init_context = __esm(() => {
5414
6209
  });
5415
6210
 
5416
6211
  // src/trpc/routers/docs.ts
5417
- import * as z4 from "zod";
6212
+ import * as z5 from "zod";
5418
6213
  var docsRouter;
5419
6214
  var init_docs = __esm(() => {
5420
6215
  init_knowledge();
5421
6216
  init_repo();
5422
6217
  init_context();
5423
6218
  docsRouter = router({
5424
- 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 }) => {
5425
6220
  const feature = getFeatureRow(ctx, input.featureId);
5426
6221
  return readDoc(ctx, feature, input.relPath);
5427
6222
  })
@@ -5429,23 +6224,23 @@ var init_docs = __esm(() => {
5429
6224
  });
5430
6225
 
5431
6226
  // src/trpc/routers/events.ts
5432
- import * as z5 from "zod";
6227
+ import * as z6 from "zod";
5433
6228
  var eventsRouter;
5434
6229
  var init_events2 = __esm(() => {
5435
6230
  init_events();
5436
6231
  init_context();
5437
6232
  eventsRouter = router({
5438
- list: publicProcedure.input(z5.object({ featureId: z5.string(), afterId: z5.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
5439
- 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))
5440
6235
  });
5441
6236
  });
5442
6237
 
5443
6238
  // src/launcher/artifacts.ts
5444
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync2 } from "fs";
5445
- import { fileURLToPath as fileURLToPath4 } from "url";
5446
- 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";
5447
6242
  function hookClientPath() {
5448
- 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)));
5449
6244
  }
5450
6245
  function serverUrlFor(config2) {
5451
6246
  return `http://localhost:${config2.serverPort}`;
@@ -5646,7 +6441,10 @@ function renderSettings(hookClient) {
5646
6441
  return {
5647
6442
  permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...SESSION_BASH_ALLOW_RULES] },
5648
6443
  hooks: {
5649
- SessionStart: [{ matcher: "startup", hooks: [cmd("session-start")] }],
6444
+ SessionStart: SESSION_START_SOURCES.map((source) => ({
6445
+ matcher: source,
6446
+ hooks: [cmd("session-start")]
6447
+ })),
5650
6448
  UserPromptSubmit: [{ hooks: [cmd("user-prompt")] }],
5651
6449
  SessionEnd: [{ hooks: [cmd("session-end")] }]
5652
6450
  }
@@ -5666,16 +6464,16 @@ function renderMcpConfig(session, config2) {
5666
6464
  async function writeSessionArtifacts(input) {
5667
6465
  const { session, feature, config: config2, waypoint } = input;
5668
6466
  const dir = sessionDir(session.id);
5669
- mkdirSync6(dir, { recursive: true });
5670
- const systemPromptPath = join12(dir, "system-prompt.md");
5671
- const settingsPath = join12(dir, "settings.json");
5672
- 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");
5673
6471
  writeFileSync2(systemPromptPath, renderSystemPrompt(feature, session.kind, waypoint), "utf8");
5674
6472
  writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
5675
6473
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
5676
6474
  return { systemPromptPath, settingsPath, mcpConfigPath };
5677
6475
  }
5678
- var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES;
6476
+ var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
5679
6477
  var init_artifacts = __esm(() => {
5680
6478
  init_paths();
5681
6479
  init_asset_paths();
@@ -5700,13 +6498,14 @@ var init_artifacts = __esm(() => {
5700
6498
  "Bash(git branch:*)",
5701
6499
  "Bash(git show:*)"
5702
6500
  ];
6501
+ SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
5703
6502
  });
5704
6503
 
5705
6504
  // src/launcher/launcher.ts
5706
- import { existsSync as existsSync13 } from "fs";
5707
- import { dirname as dirname5, join as join13, resolve as resolve7 } from "path";
5708
- import { fileURLToPath as fileURLToPath5 } from "url";
5709
- import { and as and6, 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";
5710
6509
  function ptyExitMessage(exitCode) {
5711
6510
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
5712
6511
  return `terminal exited (code ${label})`;
@@ -5737,23 +6536,23 @@ function resolveClaudeExecutable() {
5737
6536
  function claudeSpawnTarget(claudeArgs) {
5738
6537
  return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
5739
6538
  }
5740
- function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5741
- const rel = join13("packages", "skills", "packs", "runcastle");
6539
+ function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
6540
+ const rel = join14("packages", "skills", "packs", "runcastle");
5742
6541
  const override = process.env[SKILLS_DIR_ENV];
5743
6542
  if (override) {
5744
- const dir2 = join13(resolve7(override), "packs", "runcastle");
5745
- if (existsSync13(dir2))
6543
+ const dir2 = join14(resolve7(override), "packs", "runcastle");
6544
+ if (existsSync14(dir2))
5746
6545
  return dir2;
5747
6546
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
5748
6547
  }
5749
6548
  const searched = [];
5750
6549
  let dir = fromDir;
5751
6550
  for (let i = 0;i < 8; i += 1) {
5752
- const candidate = join13(dir, rel);
6551
+ const candidate = join14(dir, rel);
5753
6552
  searched.push(candidate);
5754
- if (existsSync13(candidate))
6553
+ if (existsSync14(candidate))
5755
6554
  return candidate;
5756
- const parent = dirname5(dir);
6555
+ const parent = dirname6(dir);
5757
6556
  if (parent === dir)
5758
6557
  break;
5759
6558
  dir = parent;
@@ -5763,7 +6562,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5763
6562
  `)}`);
5764
6563
  }
5765
6564
  function activeRunFor(ctx, featureId) {
5766
- const row = ctx.db.select().from(runs).where(and6(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();
5767
6566
  return row ? rowToRun(row) : null;
5768
6567
  }
5769
6568
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -6014,6 +6813,7 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
6014
6813
  message: "embedded terminal spawned",
6015
6814
  data: { sessionId: session.id, mode: "embedded", pid: entry.pty.pid }
6016
6815
  });
6816
+ armSessionReadyWatchdog(ctx, session);
6017
6817
  } catch (err) {
6018
6818
  markSessionEnded(ctx, session.id);
6019
6819
  releaseForSession(ctx, session.id);
@@ -6042,6 +6842,7 @@ var init_launcher = __esm(() => {
6042
6842
  init_runner();
6043
6843
  init_artifacts();
6044
6844
  init_sessions();
6845
+ init_sessions();
6045
6846
  init_end_session();
6046
6847
  CC_NESTING_ENV = [
6047
6848
  "CLAUDE_CODE_CHILD_SESSION",
@@ -6056,7 +6857,7 @@ var init_launcher = __esm(() => {
6056
6857
  });
6057
6858
 
6058
6859
  // src/trpc/routers/feature.ts
6059
- import * as z6 from "zod";
6860
+ import * as z7 from "zod";
6060
6861
  var gateId, featureRouter;
6061
6862
  var init_feature = __esm(() => {
6062
6863
  init_src();
@@ -6067,37 +6868,38 @@ var init_feature = __esm(() => {
6067
6868
  init_git();
6068
6869
  init_repo();
6069
6870
  init_context();
6070
- gateId = z6.enum(["G1", "G2", "G3", "G4", "G5"]);
6871
+ gateId = z7.enum(["G1", "G2", "G3", "G4", "G5"]);
6071
6872
  featureRouter = router({
6072
- create: publicProcedure.input(z6.object({
6073
- projectId: z6.string(),
6074
- title: z6.string().min(1),
6075
- oneLiner: z6.string(),
6076
- 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()
6077
6878
  })).mutation(({ ctx, input }) => createFeature(ctx, input)),
6078
- list: publicProcedure.input(z6.object({ projectId: z6.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
6079
- get: publicProcedure.input(z6.object({ id: z6.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
6080
- launchSession: publicProcedure.input(z6.object({
6081
- 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(),
6082
6883
  kind: SessionKind,
6083
- kickoffLine: z6.string().min(1).optional()
6884
+ kickoffLine: z7.string().min(1).optional()
6084
6885
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
6085
- workWaypoint: publicProcedure.input(z6.object({ featureId: z6.string(), waypointId: z6.string() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
6086
- 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 })),
6087
- endSession: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
6088
- advance: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
6089
- 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)),
6090
- archive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
6091
- unarchive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
6092
- delete: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
6093
- burn: publicProcedure.input(z6.object({ featureId: z6.string(), model: z6.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
6094
- 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 }) => {
6095
6897
  const feature = getFeatureRow(ctx, input.featureId);
6096
6898
  const project = projectForFeature(ctx, feature);
6097
6899
  return testDrive(ctx, project, feature, input.action);
6098
6900
  }),
6099
6901
  driveInfo: publicProcedure.query(() => activeDriveInfo()),
6100
- 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 }) => {
6101
6903
  const feature = getFeatureRow(ctx, input.featureId);
6102
6904
  const project = projectForFeature(ctx, feature);
6103
6905
  if (activeTestDriveFeatureId() === feature.id) {
@@ -6120,9 +6922,9 @@ var init_feature = __esm(() => {
6120
6922
  });
6121
6923
 
6122
6924
  // src/services/fsbrowse.ts
6123
- 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";
6124
6926
  import { homedir as homedir2 } from "os";
6125
- 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";
6126
6928
  function expandPath(input, home = homedir2()) {
6127
6929
  const trimmed = input.trim();
6128
6930
  if (trimmed === "")
@@ -6131,12 +6933,12 @@ function expandPath(input, home = homedir2()) {
6131
6933
  if (out === "~")
6132
6934
  out = home;
6133
6935
  else if (out.startsWith("~/") || out.startsWith("~\\"))
6134
- out = join14(home, out.slice(2));
6936
+ out = join15(home, out.slice(2));
6135
6937
  return resolve8(out);
6136
6938
  }
6137
6939
  function looksLikeRepo(dir) {
6138
6940
  try {
6139
- return existsSync14(join14(dir, ".git"));
6941
+ return existsSync15(join15(dir, ".git"));
6140
6942
  } catch {
6141
6943
  return false;
6142
6944
  }
@@ -6147,20 +6949,20 @@ function listRoots(home = homedir2()) {
6147
6949
  for (let code = 67;code <= 90; code++) {
6148
6950
  const drive = `${String.fromCharCode(code)}:${sep3}`;
6149
6951
  try {
6150
- if (existsSync14(drive))
6952
+ if (existsSync15(drive))
6151
6953
  roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
6152
6954
  } catch {}
6153
6955
  }
6154
6956
  } else {
6155
6957
  roots.push({ label: "/", path: "/", kind: "drive" });
6156
6958
  }
6157
- if (existsSync14(home))
6959
+ if (existsSync15(home))
6158
6960
  roots.push({ label: "Home", path: home, kind: "home" });
6159
6961
  const seen = new Set(roots.map(pathKey));
6160
6962
  for (const name of COMMON_DIRS) {
6161
- const candidate = join14(home, name);
6963
+ const candidate = join15(home, name);
6162
6964
  const key = pathKey({ path: candidate });
6163
- if (seen.has(key) || !existsSync14(candidate))
6965
+ if (seen.has(key) || !existsSync15(candidate))
6164
6966
  continue;
6165
6967
  seen.add(key);
6166
6968
  roots.push({ label: name, path: candidate, kind: "common" });
@@ -6175,7 +6977,7 @@ function crumbsFor(dir) {
6175
6977
  let cur = dir;
6176
6978
  for (;; ) {
6177
6979
  out.unshift({ name: basename(cur) || cur, path: cur });
6178
- const parent = dirname6(cur);
6980
+ const parent = dirname7(cur);
6179
6981
  if (parent === cur)
6180
6982
  break;
6181
6983
  cur = parent;
@@ -6183,7 +6985,7 @@ function crumbsFor(dir) {
6183
6985
  return out;
6184
6986
  }
6185
6987
  function parentOf(dir) {
6186
- const parent = dirname6(dir);
6988
+ const parent = dirname7(dir);
6187
6989
  return parent === dir ? null : parent;
6188
6990
  }
6189
6991
  function browseDir(input, showHidden = false, home = homedir2()) {
@@ -6191,13 +6993,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
6191
6993
  if (!isAbsolute(target)) {
6192
6994
  throw new InvalidInputError(`path is not absolute: ${target}`);
6193
6995
  }
6194
- if (!existsSync14(target)) {
6996
+ if (!existsSync15(target)) {
6195
6997
  throw new InvalidInputError(`path does not exist: ${target}`);
6196
6998
  }
6197
6999
  let dir = target;
6198
7000
  try {
6199
7001
  if (!statSync2(target).isDirectory())
6200
- dir = dirname6(target);
7002
+ dir = dirname7(target);
6201
7003
  } catch (e) {
6202
7004
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg2(e)})`);
6203
7005
  }
@@ -6219,7 +7021,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
6219
7021
  continue;
6220
7022
  if (!showHidden && name.startsWith("."))
6221
7023
  continue;
6222
- const path = join14(dir, name);
7024
+ const path = join15(dir, name);
6223
7025
  const isSymlink = dirent.isSymbolicLink();
6224
7026
  let isDirectory = dirent.isDirectory();
6225
7027
  if (isSymlink) {
@@ -6254,9 +7056,9 @@ var init_fsbrowse = __esm(() => {
6254
7056
  });
6255
7057
 
6256
7058
  // src/services/projects.ts
6257
- import { existsSync as existsSync15 } from "fs";
6258
- import { basename as basename2, join as join15 } from "path";
6259
- 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";
6260
7062
  function listProjects(ctx) {
6261
7063
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
6262
7064
  }
@@ -6278,7 +7080,7 @@ async function openProject(ctx, rawPath) {
6278
7080
  const name = basename2(repoPath) || repoPath;
6279
7081
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
6280
7082
  if (existing) {
6281
- 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();
6282
7084
  const reopened = { ...existing, mainBranch, repoPath };
6283
7085
  emitProject(ctx, existing.id, {
6284
7086
  type: "project.opened",
@@ -6302,14 +7104,25 @@ async function openProject(ctx, rawPath) {
6302
7104
  message: `project ${name} at ${repoPath} (${mainBranch})`
6303
7105
  });
6304
7106
  warnIfTranslatedMount(ctx, project.id, repoPath);
7107
+ maybeAutoPrepare(ctx, project);
6305
7108
  return project;
6306
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
+ }
6307
7120
  function closeProject(ctx, projectId) {
6308
7121
  const project = requireProjectById(ctx, projectId);
6309
7122
  if (projectHasActiveRun(ctx, projectId)) {
6310
7123
  throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
6311
7124
  }
6312
- 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();
6313
7126
  emitProject(ctx, projectId, {
6314
7127
  type: "project.closed",
6315
7128
  message: `project ${project.name} closed`
@@ -6318,7 +7131,7 @@ function closeProject(ctx, projectId) {
6318
7131
  }
6319
7132
  function renameProject(ctx, projectId, name) {
6320
7133
  const project = requireProjectById(ctx, projectId);
6321
- 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();
6322
7135
  emitProject(ctx, projectId, {
6323
7136
  type: "project.renamed",
6324
7137
  message: `project renamed ${project.name} \u2192 ${name}`,
@@ -6336,7 +7149,7 @@ async function assertRepoTolerant(ctx, repoPath) {
6336
7149
  } catch (e) {
6337
7150
  if (!isNotImplemented(e))
6338
7151
  throw e;
6339
- if (!existsSync15(join15(repoPath, ".git"))) {
7152
+ if (!existsSync16(join16(repoPath, ".git"))) {
6340
7153
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
6341
7154
  }
6342
7155
  }
@@ -6357,31 +7170,59 @@ var init_projects = __esm(() => {
6357
7170
  init_events();
6358
7171
  init_fsbrowse();
6359
7172
  init_git();
7173
+ init_prep();
6360
7174
  init_repo();
6361
7175
  });
6362
7176
 
6363
7177
  // src/trpc/routers/project.ts
6364
- import * as z7 from "zod";
7178
+ import * as z8 from "zod";
6365
7179
  var projectRouter;
6366
7180
  var init_project = __esm(() => {
7181
+ init_src();
7182
+ init_findings();
6367
7183
  init_fsbrowse();
6368
7184
  init_git();
7185
+ init_prep();
6369
7186
  init_projects();
6370
7187
  init_repo();
6371
7188
  init_context();
6372
7189
  projectRouter = router({
6373
7190
  list: publicProcedure.query(({ ctx }) => listProjects(ctx)),
6374
7191
  roots: publicProcedure.query(() => listRoots()),
6375
- browse: publicProcedure.input(z7.object({ dir: z7.string().optional(), showHidden: z7.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
6376
- branches: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
6377
- open: publicProcedure.input(z7.object({ repoPath: z7.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
6378
- close: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
6379
- 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
+ })
6380
7221
  });
6381
7222
  });
6382
7223
 
6383
7224
  // src/trpc/routers/run.ts
6384
- import * as z8 from "zod";
7225
+ import * as z9 from "zod";
6385
7226
  var runRouter;
6386
7227
  var init_run = __esm(() => {
6387
7228
  init_agent_stream();
@@ -6389,9 +7230,9 @@ var init_run = __esm(() => {
6389
7230
  init_runner();
6390
7231
  init_context();
6391
7232
  runRouter = router({
6392
- get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
6393
- agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
6394
- 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 }) => {
6395
7236
  cancelRun(input.runId);
6396
7237
  return { ok: true };
6397
7238
  })
@@ -6399,15 +7240,15 @@ var init_run = __esm(() => {
6399
7240
  });
6400
7241
 
6401
7242
  // src/services/settings.ts
6402
- import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
6403
- import { dirname as dirname7 } from "path";
6404
- import * as z9 from "zod";
6405
- 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";
6406
7247
  function readRawConfig(configFile) {
6407
- if (!existsSync16(configFile))
7248
+ if (!existsSync17(configFile))
6408
7249
  return {};
6409
7250
  try {
6410
- const parsed = JSON.parse(readFileSync6(configFile, "utf8"));
7251
+ const parsed = JSON.parse(readFileSync7(configFile, "utf8"));
6411
7252
  if (typeof parsed !== "object" || parsed === null)
6412
7253
  return {};
6413
7254
  return foldLegacyModelConfig(parsed);
@@ -6431,33 +7272,45 @@ function stepModelFields(fileRaw) {
6431
7272
  });
6432
7273
  }
6433
7274
  function projectOverrides(ctx, projectId) {
6434
- 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();
6435
7284
  return {
6436
7285
  model: row?.model ?? null,
6437
7286
  sandbox: row?.sandbox ?? null,
6438
- 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
6439
7292
  };
6440
7293
  }
6441
- function resolveField(desc4, layers) {
7294
+ function resolveField(desc5, layers) {
6442
7295
  const scoped = layers.overrides !== null;
6443
- const scope = scoped && desc4.projectColumn ? "project" : "global";
6444
- const base = { key: desc4.key, restartRequired: desc4.restartRequired, scope };
6445
- if (desc4.envVar) {
6446
- 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];
6447
7300
  if (raw !== undefined && raw !== "") {
6448
- return { ...base, value: desc4.parseEnv(raw), source: "env", editable: false };
7301
+ return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
6449
7302
  }
6450
7303
  }
6451
- if (layers.overrides && desc4.projectColumn) {
6452
- const ov = layers.overrides[desc4.projectColumn];
7304
+ if (layers.overrides && desc5.projectColumn) {
7305
+ const ov = layers.overrides[desc5.projectColumn];
6453
7306
  if (ov !== null && ov !== "") {
6454
7307
  return { ...base, value: ov, source: "project", editable: true };
6455
7308
  }
6456
7309
  }
6457
- if (desc4.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc4.configKey)) {
6458
- 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 };
6459
7312
  }
6460
- const value = desc4.configKey ? DEFAULTS[desc4.configKey] ?? null : null;
7313
+ const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
6461
7314
  return { ...base, value, source: "default", editable: true };
6462
7315
  }
6463
7316
  function getSettings(ctx, projectId, io = {}) {
@@ -6474,59 +7327,61 @@ function updateSettings(ctx, input, io = {}) {
6474
7327
  if (input.key.startsWith("stepModels.")) {
6475
7328
  return updateStepModel(ctx, input, configFile, io);
6476
7329
  }
6477
- const desc4 = DESCRIPTORS.find((d) => d.key === input.key);
6478
- if (!desc4)
7330
+ const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
7331
+ if (!desc5)
6479
7332
  throw new InvalidInputError(`unknown setting: ${input.key}`);
6480
- if (desc4.envVar) {
6481
- const raw = env[desc4.envVar];
7333
+ if (desc5.envVar) {
7334
+ const raw = env[desc5.envVar];
6482
7335
  if (raw !== undefined && raw !== "") {
6483
- 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`);
6484
7337
  }
6485
7338
  }
6486
- const toProject = input.projectId !== undefined && desc4.projectColumn !== undefined;
7339
+ const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
6487
7340
  if (input.value === null) {
6488
7341
  if (!toProject)
6489
- throw new InvalidInputError(`${desc4.key} cannot be cleared`);
7342
+ throw new InvalidInputError(`${desc5.key} cannot be cleared`);
6490
7343
  const project = requireProjectById(ctx, input.projectId);
6491
- 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);
6492
7346
  emitProject(ctx, project.id, {
6493
7347
  type: "settings.updated",
6494
- message: `${desc4.key} override cleared`,
6495
- data: { key: desc4.key, scope: "project", value: null }
7348
+ message: `${desc5.key} override cleared`,
7349
+ data: { key: desc5.key, scope: "project", value: null }
6496
7350
  });
6497
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7351
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6498
7352
  }
6499
- const parsed = desc4.valueSchema.safeParse(input.value);
7353
+ const parsed = desc5.valueSchema.safeParse(input.value);
6500
7354
  if (!parsed.success) {
6501
- 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}`);
6502
7356
  }
6503
7357
  const value = parsed.data;
6504
7358
  if (toProject) {
6505
7359
  const project = requireProjectById(ctx, input.projectId);
6506
- 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));
6507
7362
  emitProject(ctx, project.id, {
6508
7363
  type: "settings.updated",
6509
- message: `${desc4.key} override set to ${String(value)}`,
6510
- data: { key: desc4.key, scope: "project", value }
7364
+ message: `${desc5.key} override set to ${String(value)}`,
7365
+ data: { key: desc5.key, scope: "project", value }
6511
7366
  });
6512
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7367
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6513
7368
  }
6514
- if (!desc4.configKey) {
6515
- 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`);
6516
7371
  }
6517
- writeGlobal(configFile, desc4.configKey, value);
6518
- ctx.config[desc4.configKey] = value;
7372
+ writeGlobal(configFile, desc5.configKey, value);
7373
+ ctx.config[desc5.configKey] = value;
6519
7374
  emitProject(ctx, GLOBAL_EVENT_KEY, {
6520
7375
  type: "settings.updated",
6521
- message: `${desc4.key} set to ${String(value)}`,
6522
- data: { key: desc4.key, scope: "global", value }
7376
+ message: `${desc5.key} set to ${String(value)}`,
7377
+ data: { key: desc5.key, scope: "global", value }
6523
7378
  });
6524
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7379
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6525
7380
  }
6526
7381
  function writeGlobal(configFile, configKey, value) {
6527
7382
  const raw = readRawConfig(configFile);
6528
7383
  raw[configKey] = value;
6529
- mkdirSync7(dirname7(configFile), { recursive: true });
7384
+ mkdirSync8(dirname8(configFile), { recursive: true });
6530
7385
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
6531
7386
  `);
6532
7387
  }
@@ -6537,7 +7392,7 @@ function updateStepModel(ctx, input, configFile, io) {
6537
7392
  const modelStep = step;
6538
7393
  let value = null;
6539
7394
  if (input.value !== null) {
6540
- const parsed = z9.string().min(1).safeParse(input.value);
7395
+ const parsed = z10.string().min(1).safeParse(input.value);
6541
7396
  if (!parsed.success) {
6542
7397
  throw new InvalidInputError(`invalid value for ${input.key}: ${parsed.error.issues[0]?.message}`);
6543
7398
  }
@@ -6565,7 +7420,7 @@ function writeStepModel(configFile, step, value) {
6565
7420
  else
6566
7421
  stepModels[step] = value;
6567
7422
  raw.stepModels = stepModels;
6568
- mkdirSync7(dirname7(configFile), { recursive: true });
7423
+ mkdirSync8(dirname8(configFile), { recursive: true });
6569
7424
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
6570
7425
  `);
6571
7426
  }
@@ -6582,6 +7437,7 @@ var init_settings = __esm(() => {
6582
7437
  init_schema();
6583
7438
  init_errors();
6584
7439
  init_events();
7440
+ init_findings();
6585
7441
  init_repo();
6586
7442
  DESCRIPTORS = [
6587
7443
  {
@@ -6589,7 +7445,7 @@ var init_settings = __esm(() => {
6589
7445
  configKey: "serverPort",
6590
7446
  envVar: "RUNCASTLE_SERVER_PORT",
6591
7447
  restartRequired: true,
6592
- valueSchema: z9.number().int().positive(),
7448
+ valueSchema: z10.number().int().positive(),
6593
7449
  parseEnv: (raw) => Number(raw)
6594
7450
  },
6595
7451
  {
@@ -6598,7 +7454,7 @@ var init_settings = __esm(() => {
6598
7454
  envVar: "RUNCASTLE_MODEL",
6599
7455
  projectColumn: "model",
6600
7456
  restartRequired: false,
6601
- valueSchema: z9.string().min(1),
7457
+ valueSchema: z10.string().min(1),
6602
7458
  parseEnv: idEnv
6603
7459
  },
6604
7460
  {
@@ -6607,7 +7463,7 @@ var init_settings = __esm(() => {
6607
7463
  envVar: "RUNCASTLE_SANDBOX",
6608
7464
  projectColumn: "sandbox",
6609
7465
  restartRequired: false,
6610
- valueSchema: z9.enum(["docker", "podman", "noSandbox"]),
7466
+ valueSchema: z10.enum(["docker", "podman", "noSandbox"]),
6611
7467
  parseEnv: idEnv
6612
7468
  },
6613
7469
  {
@@ -6615,7 +7471,7 @@ var init_settings = __esm(() => {
6615
7471
  configKey: "sandboxImage",
6616
7472
  envVar: "RUNCASTLE_SANDBOX_IMAGE",
6617
7473
  restartRequired: false,
6618
- valueSchema: z9.string().min(1),
7474
+ valueSchema: z10.string().min(1),
6619
7475
  parseEnv: idEnv
6620
7476
  },
6621
7477
  {
@@ -6623,7 +7479,7 @@ var init_settings = __esm(() => {
6623
7479
  configKey: "burnConcurrency",
6624
7480
  envVar: "RUNCASTLE_BURN_CONCURRENCY",
6625
7481
  restartRequired: false,
6626
- valueSchema: z9.number().int().min(1).max(8),
7482
+ valueSchema: z10.number().int().min(1).max(8),
6627
7483
  parseEnv: (raw) => Number(raw)
6628
7484
  },
6629
7485
  {
@@ -6631,7 +7487,7 @@ var init_settings = __esm(() => {
6631
7487
  configKey: "burnMaxIterations",
6632
7488
  envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
6633
7489
  restartRequired: false,
6634
- valueSchema: z9.number().int().min(1).max(10),
7490
+ valueSchema: z10.number().int().min(1).max(10),
6635
7491
  parseEnv: (raw) => Number(raw)
6636
7492
  },
6637
7493
  {
@@ -6639,7 +7495,7 @@ var init_settings = __esm(() => {
6639
7495
  configKey: "burnAttempts",
6640
7496
  envVar: "RUNCASTLE_BURN_ATTEMPTS",
6641
7497
  restartRequired: false,
6642
- valueSchema: z9.number().int().min(1).max(5),
7498
+ valueSchema: z10.number().int().min(1).max(5),
6643
7499
  parseEnv: (raw) => Number(raw)
6644
7500
  },
6645
7501
  {
@@ -6647,7 +7503,7 @@ var init_settings = __esm(() => {
6647
7503
  configKey: "burnConflictAttempts",
6648
7504
  envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
6649
7505
  restartRequired: false,
6650
- valueSchema: z9.number().int().min(0).max(3),
7506
+ valueSchema: z10.number().int().min(0).max(3),
6651
7507
  parseEnv: (raw) => Number(raw)
6652
7508
  },
6653
7509
  {
@@ -6655,31 +7511,42 @@ var init_settings = __esm(() => {
6655
7511
  configKey: "burnCpus",
6656
7512
  envVar: "RUNCASTLE_BURN_CPUS",
6657
7513
  restartRequired: false,
6658
- valueSchema: z9.number().positive().max(256),
7514
+ valueSchema: z10.number().positive().max(256),
6659
7515
  parseEnv: (raw) => Number(raw)
6660
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
+ },
6661
7525
  {
6662
7526
  key: "setupCommand",
6663
7527
  configKey: "setupCommand",
6664
7528
  envVar: "RUNCASTLE_SETUP_COMMAND",
7529
+ projectColumn: "setupCommand",
6665
7530
  restartRequired: false,
6666
- valueSchema: z9.string().min(1),
7531
+ valueSchema: z10.string().min(1),
6667
7532
  parseEnv: idEnv
6668
7533
  },
6669
7534
  {
6670
7535
  key: "verifyCommands",
6671
7536
  configKey: "verifyCommands",
6672
7537
  envVar: "RUNCASTLE_VERIFY_COMMANDS",
7538
+ projectColumn: "verifyCommands",
6673
7539
  restartRequired: false,
6674
- valueSchema: z9.string().min(1),
7540
+ valueSchema: z10.string().min(1),
6675
7541
  parseEnv: idEnv
6676
7542
  },
6677
7543
  {
6678
7544
  key: "knownFailures",
6679
7545
  configKey: "knownFailures",
6680
7546
  envVar: "RUNCASTLE_KNOWN_FAILURES",
7547
+ projectColumn: "knownFailures",
6681
7548
  restartRequired: false,
6682
- valueSchema: z9.string().min(1),
7549
+ valueSchema: z10.string().min(1),
6683
7550
  parseEnv: idEnv
6684
7551
  },
6685
7552
  {
@@ -6687,14 +7554,21 @@ var init_settings = __esm(() => {
6687
7554
  configKey: "mainBranch",
6688
7555
  envVar: "RUNCASTLE_MAIN_BRANCH",
6689
7556
  restartRequired: false,
6690
- valueSchema: z9.string().min(1),
7557
+ valueSchema: z10.string().min(1),
6691
7558
  parseEnv: idEnv
6692
7559
  },
6693
7560
  {
6694
7561
  key: "devCommand",
6695
7562
  projectColumn: "devCommand",
6696
7563
  restartRequired: false,
6697
- 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),
6698
7572
  parseEnv: idEnv
6699
7573
  }
6700
7574
  ];
@@ -6703,14 +7577,14 @@ var init_settings = __esm(() => {
6703
7577
  });
6704
7578
 
6705
7579
  // src/trpc/routers/settings.ts
6706
- import * as z10 from "zod";
7580
+ import * as z11 from "zod";
6707
7581
  var settingsRouter;
6708
7582
  var init_settings2 = __esm(() => {
6709
7583
  init_src();
6710
7584
  init_settings();
6711
7585
  init_context();
6712
7586
  settingsRouter = router({
6713
- 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)),
6714
7588
  update: publicProcedure.input(SettingsUpdateInput).mutation(({ ctx, input }) => updateSettings(ctx, input))
6715
7589
  });
6716
7590
  });
@@ -6903,9 +7777,9 @@ var init_system_exec = __esm(() => {
6903
7777
  });
6904
7778
 
6905
7779
  // src/services/setup.ts
6906
- import { copyFileSync, existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
6907
- import { dirname as dirname8, join as join16 } from "path";
6908
- 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";
6909
7783
  async function writeGitIdentity(exec, identity) {
6910
7784
  const name = identity.name.trim();
6911
7785
  const email = identity.email.trim();
@@ -6980,16 +7854,16 @@ async function resolveRuntime(exec, preferred) {
6980
7854
  function resolveSandcastleBin() {
6981
7855
  try {
6982
7856
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
6983
- let dir = dirname8(fileURLToPath6(mainUrl));
7857
+ let dir = dirname9(fileURLToPath7(mainUrl));
6984
7858
  for (let hops = 0;hops < 6; hops++) {
6985
- const manifestPath = join16(dir, "package.json");
6986
- if (existsSync17(manifestPath)) {
6987
- 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"));
6988
7862
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
6989
- return join16(dir, manifest.bin.sandcastle);
7863
+ return join17(dir, manifest.bin.sandcastle);
6990
7864
  }
6991
7865
  }
6992
- const parent = dirname8(dir);
7866
+ const parent = dirname9(dir);
6993
7867
  if (parent === dir)
6994
7868
  break;
6995
7869
  dir = parent;
@@ -7011,21 +7885,21 @@ function terminalSpec(kind, opts) {
7011
7885
  };
7012
7886
  }
7013
7887
  function sandcastleTemplateDir() {
7014
- 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)));
7015
7889
  }
7016
7890
  function scaffoldSandcastleConfig(templateDir, targetDir) {
7017
- const dir = join16(targetDir, ".sandcastle");
7018
- if (existsSync17(dir))
7891
+ const dir = join17(targetDir, ".sandcastle");
7892
+ if (existsSync18(dir))
7019
7893
  return { scaffolded: false, dir };
7020
- mkdirSync8(dir, { recursive: true });
7894
+ mkdirSync9(dir, { recursive: true });
7021
7895
  for (const name of readdirSync6(templateDir)) {
7022
- copyFileSync(join16(templateDir, name), join16(dir, name));
7896
+ copyFileSync(join17(templateDir, name), join17(dir, name));
7023
7897
  }
7024
7898
  return { scaffolded: true, dir };
7025
7899
  }
7026
7900
  function prepareSandboxBuildContext() {
7027
7901
  const target = sandboxBuildDir();
7028
- mkdirSync8(target, { recursive: true });
7902
+ mkdirSync9(target, { recursive: true });
7029
7903
  scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
7030
7904
  return target;
7031
7905
  }
@@ -7061,9 +7935,9 @@ function createTokenVerifier(exec) {
7061
7935
  function fileAfkTokenIo(exec) {
7062
7936
  const path = envPath();
7063
7937
  return {
7064
- read: () => existsSync17(path) ? readFileSync7(path, "utf8") : "",
7938
+ read: () => existsSync18(path) ? readFileSync8(path, "utf8") : "",
7065
7939
  write: (content) => {
7066
- mkdirSync8(dirname8(path), { recursive: true });
7940
+ mkdirSync9(dirname9(path), { recursive: true });
7067
7941
  writeFileSync4(path, content, "utf8");
7068
7942
  },
7069
7943
  verify: createTokenVerifier(exec)
@@ -7078,7 +7952,7 @@ var init_setup = __esm(() => {
7078
7952
  });
7079
7953
 
7080
7954
  // src/trpc/routers/setup.ts
7081
- import * as z11 from "zod";
7955
+ import * as z12 from "zod";
7082
7956
  var setupRouter;
7083
7957
  var init_setup2 = __esm(() => {
7084
7958
  init_src();
@@ -7094,9 +7968,9 @@ var init_setup2 = __esm(() => {
7094
7968
  ...ctx.config.sandboxImage ? { imageName: ctx.config.sandboxImage } : {}
7095
7969
  })),
7096
7970
  runtimeGuide: publicProcedure.query(() => runtimeInstallGuide(process.platform)),
7097
- gitIdentity: publicProcedure.input(z11.object({ name: z11.string(), email: z11.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
7098
- afkToken: publicProcedure.input(z11.object({ token: z11.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
7099
- 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 }) => {
7100
7974
  const exec = createSystemExec();
7101
7975
  const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
7102
7976
  const runtime = await resolveRuntime(exec, preferred);
@@ -7196,16 +8070,16 @@ var init_update_check = __esm(() => {
7196
8070
  });
7197
8071
 
7198
8072
  // src/version.ts
7199
- import { readFileSync as readFileSync8 } from "fs";
7200
- import { dirname as dirname9, join as join17 } from "path";
7201
- 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";
7202
8076
  function runcastleVersion() {
7203
8077
  if (cached2)
7204
8078
  return cached2;
7205
- const here = dirname9(fileURLToPath7(import.meta.url));
7206
- 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")]) {
7207
8081
  try {
7208
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
8082
+ const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
7209
8083
  if (typeof pkg.version === "string") {
7210
8084
  cached2 = pkg.version;
7211
8085
  return cached2;
@@ -7231,7 +8105,7 @@ var init_system = __esm(() => {
7231
8105
  });
7232
8106
 
7233
8107
  // src/trpc/routers/ticket.ts
7234
- import * as z12 from "zod";
8108
+ import * as z13 from "zod";
7235
8109
  var ticketRouter;
7236
8110
  var init_ticket = __esm(() => {
7237
8111
  init_features();
@@ -7240,8 +8114,8 @@ var init_ticket = __esm(() => {
7240
8114
  init_ticket_burner();
7241
8115
  init_context();
7242
8116
  ticketRouter = router({
7243
- retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
7244
- stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ ctx, input }) => {
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 }) => {
7245
8119
  if (stopTicketRun(input.ticketId))
7246
8120
  return { stopped: true, swept: false };
7247
8121
  const ticket = getTicket(ctx, input.ticketId);
@@ -7251,7 +8125,7 @@ var init_ticket = __esm(() => {
7251
8125
  }
7252
8126
  return { stopped: false, swept: false };
7253
8127
  }),
7254
- cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
8128
+ cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
7255
8129
  });
7256
8130
  });
7257
8131
 
@@ -7282,18 +8156,18 @@ var init_router = __esm(() => {
7282
8156
  });
7283
8157
 
7284
8158
  // src/workflows/reconcile-runs.ts
7285
- import { eq as eq11 } from "drizzle-orm";
8159
+ import { eq as eq13 } from "drizzle-orm";
7286
8160
  async function reconcileStaleRuns(ctx) {
7287
- 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);
7288
8162
  const reconciled = [];
7289
- for (const run3 of stale) {
7290
- if (isRunActive(run3.id))
8163
+ for (const run4 of stale) {
8164
+ if (isRunActive(run4.id))
7291
8165
  continue;
7292
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq11(runs.id, run3.id)).run();
7293
- const released = releaseForSession(ctx, run3.id);
7294
- const swept = workflowClaimsFeatureBranch(run3.workflow) ? sweepOrphanedBurning(ctx, run3.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
7295
- if (workflowClaimsFeatureBranch(run3.workflow)) {
7296
- 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);
7297
8171
  const project = feature ? getProjectById(ctx, feature.projectId) : null;
7298
8172
  if (feature && project) {
7299
8173
  try {
@@ -7301,18 +8175,18 @@ async function reconcileStaleRuns(ctx) {
7301
8175
  } catch {}
7302
8176
  }
7303
8177
  }
7304
- emit(ctx, run3.featureId, {
8178
+ emit(ctx, run4.featureId, {
7305
8179
  type: "run.reconciled",
7306
- message: `run marked failed at boot \u2014 the server restarted while it was running (${run3.workflow})`,
7307
- runId: run3.id,
8180
+ message: `run marked failed at boot \u2014 the server restarted while it was running (${run4.workflow})`,
8181
+ runId: run4.id,
7308
8182
  data: {
7309
- runId: run3.id,
7310
- workflow: run3.workflow,
8183
+ runId: run4.id,
8184
+ workflow: run4.workflow,
7311
8185
  releasedWaypointIds: released.map((w) => w.id),
7312
8186
  sweptTicketSeqs: swept.map((t2) => t2.seq)
7313
8187
  }
7314
8188
  });
7315
- reconciled.push(run3);
8189
+ reconciled.push(run4);
7316
8190
  }
7317
8191
  for (const project of allProjects(ctx)) {
7318
8192
  try {
@@ -7375,6 +8249,10 @@ async function startServer() {
7375
8249
  if (staleRuns.length > 0) {
7376
8250
  console.log(`reconciled ${staleRuns.length} stale run(s) from a previous server run`);
7377
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
+ }
7378
8256
  const server = Bun.serve({
7379
8257
  port: config2.serverPort,
7380
8258
  fetch(req, srv) {
@@ -7404,6 +8282,7 @@ var init_src2 = __esm(() => {
7404
8282
  init_client();
7405
8283
  init_migrate();
7406
8284
  init_reconcile();
8285
+ init_prep();
7407
8286
  init_server();
7408
8287
  init_registry();
7409
8288
  init_ws();
@@ -7468,7 +8347,7 @@ __export(exports_cli, {
7468
8347
  resolveDoctorEnv: () => resolveDoctorEnv,
7469
8348
  parseMode: () => parseMode
7470
8349
  });
7471
- import { existsSync as existsSync18, readFileSync as readFileSync9 } from "fs";
8350
+ import { existsSync as existsSync19, readFileSync as readFileSync10 } from "fs";
7472
8351
  function parseMode(argv) {
7473
8352
  return argv.includes("--gate") || argv.includes("--boot") ? "gate" : "diagnostic";
7474
8353
  }
@@ -7476,8 +8355,8 @@ function envWithToken() {
7476
8355
  const merged = { ...process.env };
7477
8356
  try {
7478
8357
  const path = envPath();
7479
- if (existsSync18(path)) {
7480
- 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;
7481
8360
  if (fromFile && fromFile.length > 0)
7482
8361
  merged.CLAUDE_CODE_OAUTH_TOKEN = fromFile;
7483
8362
  }
@@ -7519,8 +8398,8 @@ var init_cli = __esm(() => {
7519
8398
  // src/bin/runcastle.ts
7520
8399
  init_asset_paths();
7521
8400
  init_version();
7522
- import { dirname as dirname10, resolve as resolve9 } from "path";
7523
- import { fileURLToPath as fileURLToPath8 } from "url";
8401
+ import { dirname as dirname11, resolve as resolve9 } from "path";
8402
+ import { fileURLToPath as fileURLToPath9 } from "url";
7524
8403
  function parseCommand(argv) {
7525
8404
  const [first, ...rest] = argv;
7526
8405
  if (first === undefined)
@@ -7544,7 +8423,7 @@ Usage:
7544
8423
  runcastle --help Show this help
7545
8424
  `;
7546
8425
  async function main(argv) {
7547
- applyInstalledAssetEnv(resolve9(dirname10(fileURLToPath8(import.meta.url)), ".."));
8426
+ applyInstalledAssetEnv(resolve9(dirname11(fileURLToPath9(import.meta.url)), ".."));
7548
8427
  const { command, args } = parseCommand(argv);
7549
8428
  switch (command) {
7550
8429
  case "version":