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/index.js CHANGED
@@ -68,6 +68,20 @@ import * as z from "zod";
68
68
  function resolveSandboxImage(config) {
69
69
  return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
70
70
  }
71
+ function resolvePreparedSettings(config, project) {
72
+ const pick = (a, b) => {
73
+ const own = a?.trim();
74
+ if (own)
75
+ return own;
76
+ const inherited = b?.trim();
77
+ return inherited || undefined;
78
+ };
79
+ return {
80
+ setupCommand: pick(project?.setupCommand, config.setupCommand),
81
+ verifyCommands: pick(project?.verifyCommands, config.verifyCommands),
82
+ knownFailures: pick(project?.knownFailures, config.knownFailures)
83
+ };
84
+ }
71
85
  function resolveModel(step, config, project, runOverride) {
72
86
  return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
73
87
  }
@@ -93,6 +107,7 @@ var init_config = __esm(() => {
93
107
  "revisit",
94
108
  "research",
95
109
  "implement",
110
+ "prepare",
96
111
  "smoke"
97
112
  ];
98
113
  ModelStep = z.enum(MODEL_STEPS);
@@ -109,6 +124,7 @@ var init_config = __esm(() => {
109
124
  burnMaxIterations: z.number().int().min(1).max(10).default(3),
110
125
  burnAttempts: z.number().int().min(1).max(5).default(3),
111
126
  burnConflictAttempts: z.number().int().min(0).max(3).default(2),
127
+ autoPrepare: z.boolean().default(true),
112
128
  setupCommand: z.string().optional(),
113
129
  verifyCommands: z.string().optional(),
114
130
  knownFailures: z.string().optional(),
@@ -202,7 +218,7 @@ var init_ids = () => {};
202
218
 
203
219
  // ../core/src/schemas.ts
204
220
  import * as z2 from "zod";
205
- var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, Project, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
221
+ var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, PREPARED_KEYS, PreparedKey, FindingSource, Project, ProjectFinding, PrepStatus, PrepRun, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
206
222
  var init_schemas = __esm(() => {
207
223
  Phase = z2.enum([
208
224
  "ideation",
@@ -255,13 +271,44 @@ var init_schemas = __esm(() => {
255
271
  lastSessionId: z2.string().optional(),
256
272
  summary: z2.string().optional()
257
273
  });
274
+ PREPARED_KEYS = [
275
+ "setupCommand",
276
+ "verifyCommands",
277
+ "knownFailures",
278
+ "devCommand",
279
+ "dbResetCommand"
280
+ ];
281
+ PreparedKey = z2.enum(PREPARED_KEYS);
282
+ FindingSource = z2.enum(["prep", "human"]);
258
283
  Project = z2.object({
259
284
  id: z2.string(),
260
285
  name: z2.string(),
261
286
  repoPath: z2.string(),
262
287
  mainBranch: z2.string(),
263
288
  devCommand: z2.string().optional(),
264
- model: z2.string().optional()
289
+ model: z2.string().optional(),
290
+ setupCommand: z2.string().optional(),
291
+ verifyCommands: z2.string().optional(),
292
+ knownFailures: z2.string().optional(),
293
+ dbResetCommand: z2.string().optional()
294
+ });
295
+ ProjectFinding = z2.object({
296
+ key: PreparedKey,
297
+ source: FindingSource,
298
+ evidence: z2.string().optional(),
299
+ establishedAt: z2.number(),
300
+ establishedSha: z2.string().optional(),
301
+ staleCommits: z2.number().optional()
302
+ });
303
+ PrepStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
304
+ PrepRun = z2.object({
305
+ id: z2.string(),
306
+ projectId: z2.string(),
307
+ status: PrepStatus,
308
+ startedAt: z2.number(),
309
+ endedAt: z2.number().optional(),
310
+ summary: z2.string().optional(),
311
+ headSha: z2.string().optional()
265
312
  });
266
313
  Feature = z2.object({
267
314
  id: z2.string(),
@@ -460,8 +507,8 @@ var init_pipeline = __esm(() => {
460
507
  });
461
508
 
462
509
  // ../core/src/db-schema.ts
463
- import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
464
- var projects, features, sessions, tickets, waypoints, runs, events, gateOverrides;
510
+ import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
511
+ var projects, projectFindings, projectPreps, features, sessions, tickets, waypoints, runs, events, gateOverrides;
465
512
  var init_db_schema = __esm(() => {
466
513
  projects = sqliteTable("projects", {
467
514
  id: text("id").primaryKey(),
@@ -471,7 +518,28 @@ var init_db_schema = __esm(() => {
471
518
  devCommand: text("dev_command"),
472
519
  model: text("model"),
473
520
  sandbox: text("sandbox"),
474
- closedAt: integer("closed_at")
521
+ closedAt: integer("closed_at"),
522
+ setupCommand: text("setup_command"),
523
+ verifyCommands: text("verify_commands"),
524
+ knownFailures: text("known_failures"),
525
+ dbResetCommand: text("db_reset_command")
526
+ });
527
+ projectFindings = sqliteTable("project_findings", {
528
+ projectId: text("project_id").notNull(),
529
+ key: text("key").notNull(),
530
+ source: text("source").notNull().$type(),
531
+ evidence: text("evidence"),
532
+ establishedAt: integer("established_at").notNull(),
533
+ establishedSha: text("established_sha")
534
+ }, (t) => [primaryKey({ columns: [t.projectId, t.key] })]);
535
+ projectPreps = sqliteTable("project_preps", {
536
+ id: text("id").primaryKey(),
537
+ projectId: text("project_id").notNull(),
538
+ status: text("status").notNull().$type(),
539
+ startedAt: integer("started_at").notNull(),
540
+ endedAt: integer("ended_at"),
541
+ summary: text("summary"),
542
+ headSha: text("head_sha")
475
543
  });
476
544
  features = sqliteTable("features", {
477
545
  id: text("id").primaryKey(),
@@ -575,7 +643,9 @@ var init_schema = __esm(() => {
575
643
  waypoints,
576
644
  runs,
577
645
  events,
578
- gateOverrides
646
+ gateOverrides,
647
+ projectFindings,
648
+ projectPreps
579
649
  };
580
650
  });
581
651
 
@@ -1340,7 +1410,11 @@ function rowToProject(row) {
1340
1410
  repoPath: row.repoPath,
1341
1411
  mainBranch: row.mainBranch,
1342
1412
  devCommand: row.devCommand ?? undefined,
1343
- model: row.model ?? undefined
1413
+ model: row.model ?? undefined,
1414
+ setupCommand: row.setupCommand ?? undefined,
1415
+ verifyCommands: row.verifyCommands ?? undefined,
1416
+ knownFailures: row.knownFailures ?? undefined,
1417
+ dbResetCommand: row.dbResetCommand ?? undefined
1344
1418
  };
1345
1419
  }
1346
1420
  function rowToRun(row) {
@@ -1672,34 +1746,159 @@ function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS)
1672
1746
  }, submitDelayMs);
1673
1747
  submit.unref?.();
1674
1748
  }
1675
- function scheduleKickoff(ctx, session) {
1676
- const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
1677
- pendingKickoffOverrides.delete(session.id);
1678
- const timer = setTimeout(() => {
1749
+ function stopTimers(d) {
1750
+ for (const t of d.timers)
1751
+ clearTimeout(t);
1752
+ d.timers.clear();
1753
+ }
1754
+ function forgetKickoff(sessionId) {
1755
+ const d = deliveries.get(sessionId);
1756
+ if (d)
1757
+ stopTimers(d);
1758
+ deliveries.delete(sessionId);
1759
+ pendingKickoffOverrides.delete(sessionId);
1760
+ }
1761
+ function ptyIo(sessionId) {
1762
+ return {
1763
+ write: (data) => ptyRegistry().get(sessionId)?.pty.write(data),
1764
+ alive: () => {
1765
+ const entry = ptyRegistry().get(sessionId);
1766
+ return !!entry && !entry.exited;
1767
+ }
1768
+ };
1769
+ }
1770
+ function track(d, timer) {
1771
+ timer.unref?.();
1772
+ d.timers.add(timer);
1773
+ }
1774
+ function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
1775
+ track(d, setTimeout(() => {
1776
+ if (d.settled)
1777
+ return;
1778
+ const io = ptyIo(session.id);
1779
+ if (!io.alive()) {
1780
+ d.settled = true;
1781
+ return;
1782
+ }
1783
+ d.attempts += 1;
1784
+ const attempt = d.attempts;
1679
1785
  try {
1680
- writeKickoffSequence(line, {
1681
- write: (data) => ptyRegistry().get(session.id)?.pty.write(data),
1682
- alive: () => {
1683
- const entry = ptyRegistry().get(session.id);
1684
- return !!entry && !entry.exited;
1685
- },
1686
- onSubmitted: () => {
1687
- emit(ctx, session.featureId, {
1688
- type: "session.kickoff",
1689
- message: `${session.kind} session kicked off automatically`,
1690
- data: { sessionId: session.id, kind: session.kind }
1691
- });
1692
- }
1786
+ if (clearFirst)
1787
+ io.write(CLEAR_INPUT);
1788
+ writeKickoffSequence(d.line, {
1789
+ write: io.write,
1790
+ alive: io.alive,
1791
+ onSubmitted: () => emit(ctx, session.featureId, {
1792
+ type: "session.kickoff",
1793
+ message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
1794
+ data: { sessionId: session.id, kind: session.kind, attempt }
1795
+ })
1693
1796
  });
1694
1797
  } catch {}
1695
- }, KICKOFF_DELAY_MS);
1798
+ armConfirmation(ctx, session, d);
1799
+ }, delayMs));
1800
+ }
1801
+ function armConfirmation(ctx, session, d) {
1802
+ track(d, setTimeout(() => {
1803
+ if (d.settled || d.confirmed)
1804
+ return;
1805
+ if (d.attempts < KICKOFF_MAX_ATTEMPTS && ptyIo(session.id).alive()) {
1806
+ attemptKickoff(ctx, session, d, 0, true);
1807
+ return;
1808
+ }
1809
+ settleUndelivered(ctx, session, d, "unacknowledged");
1810
+ }, KICKOFF_CONFIRM_MS));
1811
+ }
1812
+ function settleUndelivered(ctx, session, d, reason) {
1813
+ d.settled = true;
1814
+ stopTimers(d);
1815
+ emit(ctx, session.featureId, {
1816
+ type: "session.kickoff_undelivered",
1817
+ message: reason === "superseded" ? `the ${session.kind} briefing was never delivered \u2014 you typed first, so runcastle stopped injecting it` : `the ${session.kind} briefing was typed ${d.attempts}\xD7 but Claude Code never acknowledged it \u2014 send it again from the session strip`,
1818
+ data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
1819
+ });
1820
+ }
1821
+ function noteKickoffPrompt(ctx, sessionId, prompt) {
1822
+ const d = deliveries.get(sessionId);
1823
+ if (!d || d.confirmed)
1824
+ return;
1825
+ const session = getSessionRow(ctx, sessionId);
1826
+ if (!session)
1827
+ return;
1828
+ if (promptMatchesKickoff(d.line, prompt)) {
1829
+ d.confirmed = true;
1830
+ d.settled = true;
1831
+ stopTimers(d);
1832
+ return;
1833
+ }
1834
+ if (d.settled)
1835
+ return;
1836
+ settleUndelivered(ctx, session, d, "superseded");
1837
+ }
1838
+ function promptMatchesKickoff(line, prompt) {
1839
+ if (!prompt)
1840
+ return false;
1841
+ const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
1842
+ const want = norm(line);
1843
+ const got = norm(prompt);
1844
+ if (!want || !got)
1845
+ return false;
1846
+ return got.includes(want.slice(0, MATCH_PREFIX));
1847
+ }
1848
+ function resendKickoff(ctx, sessionId) {
1849
+ const session = getSessionRow(ctx, sessionId);
1850
+ if (!session)
1851
+ throw new Error(`unknown session ${sessionId}`);
1852
+ if (session.status === "ended")
1853
+ throw new Error("that session has ended \u2014 open a new terminal");
1854
+ if (!ptyIo(sessionId).alive())
1855
+ throw new Error("the terminal for that session is no longer running");
1856
+ const existing = deliveries.get(sessionId);
1857
+ const d = existing ?? {
1858
+ line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
1859
+ attempts: 0,
1860
+ confirmed: false,
1861
+ settled: false,
1862
+ timers: new Set
1863
+ };
1864
+ stopTimers(d);
1865
+ d.attempts = 0;
1866
+ d.confirmed = false;
1867
+ d.settled = false;
1868
+ deliveries.set(sessionId, d);
1869
+ attemptKickoff(ctx, session, d, 0, true);
1870
+ return { line: d.line };
1871
+ }
1872
+ function armSessionReadyWatchdog(ctx, session) {
1873
+ const timer = setTimeout(() => {
1874
+ const row = getSessionRow(ctx, session.id);
1875
+ if (!row || row.status !== "launching")
1876
+ return;
1877
+ if (!ptyIo(session.id).alive())
1878
+ return;
1879
+ emit(ctx, session.featureId, {
1880
+ type: "session.not_ready",
1881
+ message: `the ${session.kind} terminal is open but Claude Code has not reported ready \u2014 check it for a prompt or dialog waiting on you, then send the briefing`,
1882
+ data: { sessionId: session.id, kind: session.kind }
1883
+ });
1884
+ }, SESSION_READY_TIMEOUT_MS);
1696
1885
  timer.unref?.();
1697
1886
  }
1887
+ function scheduleKickoff(ctx, session) {
1888
+ const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
1889
+ pendingKickoffOverrides.delete(session.id);
1890
+ const existing = deliveries.get(session.id);
1891
+ if (existing)
1892
+ stopTimers(existing);
1893
+ const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
1894
+ deliveries.set(session.id, d);
1895
+ attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
1896
+ }
1698
1897
  function markSessionEnded(ctx, id) {
1699
1898
  const existing = getSessionRow(ctx, id);
1700
1899
  if (!existing)
1701
1900
  return null;
1702
- pendingKickoffOverrides.delete(id);
1901
+ forgetKickoff(id);
1703
1902
  ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
1704
1903
  return getSessionRow(ctx, id);
1705
1904
  }
@@ -1711,7 +1910,7 @@ function mostRecentLiveSession(ctx) {
1711
1910
  const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1712
1911
  return row ? rowToSession(row) : null;
1713
1912
  }
1714
- var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides;
1913
+ var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, KICKOFF_CONFIRM_MS = 12000, KICKOFF_MAX_ATTEMPTS = 3, CLEAR_INPUT = "\x15", SESSION_READY_TIMEOUT_MS = 25000, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides, deliveries, MATCH_PREFIX = 40;
1715
1914
  var init_sessions = __esm(() => {
1716
1915
  init_src();
1717
1916
  init_schema();
@@ -1729,6 +1928,7 @@ var init_sessions = __esm(() => {
1729
1928
  };
1730
1929
  RESUME_KICKOFF_PREFIX = "We are picking this conversation back up \u2014 runcastle restarted and closed the terminal, " + "but this conversation is intact. Do NOT start over: tell me in one or two lines where we " + "left off and what is next, then carry on. Your original instruction was: ";
1731
1930
  pendingKickoffOverrides = new Map;
1931
+ deliveries = new Map;
1732
1932
  });
1733
1933
 
1734
1934
  // src/launcher/reconcile.ts
@@ -1764,339 +1964,104 @@ var init_reconcile = __esm(() => {
1764
1964
  init_sessions();
1765
1965
  });
1766
1966
 
1767
- // src/launcher/runtime.ts
1768
- function setRuntimeCtx(ctx) {
1769
- current = ctx;
1770
- }
1771
- async function getRuntimeCtx() {
1772
- if (current)
1773
- return current;
1774
- const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
1775
- Promise.resolve().then(() => (init_client(), exports_client)),
1776
- Promise.resolve().then(() => (init_paths(), exports_paths)),
1777
- Promise.resolve().then(() => (init_config_load(), exports_config_load))
1778
- ]);
1779
- const db = createDb2(dbPath2());
1780
- current = { db, config: loadConfig2() };
1781
- return current;
1782
- }
1783
- var current = null;
1784
-
1785
- // src/services/feature-docs.ts
1967
+ // src/launcher/skills-root.ts
1786
1968
  import { existsSync as existsSync5 } from "fs";
1787
- import { join as join5 } from "path";
1788
- function featureDocsDir(project, feature) {
1789
- const relSegments = featureDocsRel(feature.slug).split("/");
1790
- const worktree = worktreeDir(project.id, feature.slug);
1791
- const base = existsSync5(worktree) ? worktree : project.repoPath;
1792
- return join5(base, ...relSegments);
1793
- }
1794
- function featureDocPath(project, feature, fileName) {
1795
- return join5(featureDocsDir(project, feature), fileName);
1969
+ import { dirname, join as join5, resolve as resolve3 } from "path";
1970
+ function resolveSkillsRoot(fromDir) {
1971
+ const override = process.env[SKILLS_DIR_ENV];
1972
+ if (override) {
1973
+ const root = resolve3(override);
1974
+ if (existsSync5(join5(root, SKILLS_MARKER)))
1975
+ return root;
1976
+ throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
1977
+ }
1978
+ const searched = [];
1979
+ let dir = fromDir;
1980
+ for (let i = 0;i < 8; i += 1) {
1981
+ const candidate = join5(dir, "packages", "skills");
1982
+ searched.push(candidate);
1983
+ if (existsSync5(join5(candidate, SKILLS_MARKER)))
1984
+ return candidate;
1985
+ const parent = dirname(dir);
1986
+ if (parent === dir)
1987
+ break;
1988
+ dir = parent;
1989
+ }
1990
+ throw new Error(`runcastle skills root not found; searched:
1991
+ ${searched.join(`
1992
+ `)}`);
1796
1993
  }
1797
- var init_feature_docs = __esm(() => {
1798
- init_paths();
1994
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
1995
+ var init_skills_root = __esm(() => {
1996
+ SKILLS_MARKER = join5("packs", "runcastle");
1799
1997
  });
1800
1998
 
1801
- // src/services/tickets.ts
1802
- import { and as and5, asc as asc3, eq as eq5 } from "drizzle-orm";
1803
- function rowToTicket(row) {
1804
- return {
1805
- id: row.id,
1806
- featureId: row.featureId,
1807
- seq: row.seq,
1808
- title: row.title,
1809
- goal: row.goal,
1810
- context: row.context,
1811
- acceptanceCriteria: row.acceptanceCriteria,
1812
- seams: row.seams,
1813
- blockedBy: row.blockedBy,
1814
- status: row.status,
1815
- commits: row.commits,
1816
- error: row.error ?? undefined,
1817
- attemptBranch: row.attemptBranch ?? undefined,
1818
- conflictFiles: row.conflictFiles ?? undefined
1819
- };
1999
+ // src/pty/dev-pane.ts
2000
+ function drivePaneId(featureId) {
2001
+ return `${DRIVE_PREFIX}${featureId}`;
1820
2002
  }
1821
- function resolveBlocking(inputs, startSeq) {
1822
- try {
1823
- return resolveBatchBlocking(inputs, { startSeq });
1824
- } catch (e) {
1825
- if (e instanceof BlockingEdgeError)
1826
- throw new InvalidInputError(e.message);
1827
- throw e;
1828
- }
2003
+ function sniffDevUrl(text2) {
2004
+ const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
2005
+ return m ? m[0] : undefined;
1829
2006
  }
1830
- function listByFeature2(ctx, featureId) {
1831
- return ctx.db.select().from(tickets).where(eq5(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
2007
+ function devSpawnTarget(devCommand) {
2008
+ if (process.platform === "win32") {
2009
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
2010
+ }
2011
+ return { file: "/bin/sh", args: ["-c", devCommand] };
1832
2012
  }
1833
- function storeTickets(ctx, featureId, inputs) {
1834
- if (inputs.length === 0)
1835
- return [];
1836
- const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq5(tickets.featureId, featureId)).all();
1837
- const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
1838
- const resolved = resolveBlocking(inputs, startSeq);
1839
- const rows = inputs.map((t, i) => ({
1840
- id: newId("tkt"),
1841
- featureId,
1842
- seq: resolved[i].seq,
1843
- title: t.title,
1844
- goal: t.goal,
1845
- context: t.context,
1846
- acceptanceCriteria: t.acceptanceCriteria,
1847
- seams: t.seams,
1848
- blockedBy: resolved[i].blockedBy,
1849
- status: "pending",
1850
- commits: [],
1851
- error: null,
1852
- attemptBranch: null,
1853
- conflictFiles: null
1854
- }));
1855
- ctx.db.insert(tickets).values(rows).run();
2013
+ function startDevPane(input) {
2014
+ const { ctx, featureId, repoPath, devCommand, onUrl } = input;
2015
+ const paneId = drivePaneId(featureId);
2016
+ const { file, args } = devSpawnTarget(devCommand);
2017
+ try {
2018
+ ptyRegistry().create({
2019
+ sessionId: paneId,
2020
+ cmd: file,
2021
+ args,
2022
+ opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
2023
+ });
2024
+ } catch (err) {
2025
+ emit(ctx, featureId, {
2026
+ type: "testdrive.dev_failed",
2027
+ message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
2028
+ data: { paneId }
2029
+ });
2030
+ return;
2031
+ }
2032
+ let scanned = "";
2033
+ let found = false;
2034
+ const sniffer = {
2035
+ sendData(chunk) {
2036
+ if (found)
2037
+ return;
2038
+ scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
2039
+ const url = sniffDevUrl(scanned);
2040
+ if (url) {
2041
+ found = true;
2042
+ onUrl(url);
2043
+ }
2044
+ },
2045
+ sendControl() {}
2046
+ };
2047
+ ptyRegistry().attach(paneId, sniffer);
1856
2048
  emit(ctx, featureId, {
1857
- type: "tickets.stored",
1858
- message: `${rows.length} ticket(s) stored`,
1859
- data: { count: rows.length, seqs: rows.map((r) => r.seq) }
2049
+ type: "testdrive.dev_started",
2050
+ message: `dev server spawned: ${devCommand}`,
2051
+ data: { paneId }
1860
2052
  });
1861
- return rows.map(rowToTicket);
1862
- }
1863
- function getTicket(ctx, id) {
1864
- const row = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1865
- if (!row)
1866
- throw new NotFoundError(`ticket ${id} not found`);
1867
- return rowToTicket(row);
1868
- }
1869
- function assertMutable(t, verb) {
1870
- if (!MUTABLE_STATUSES.has(t.status)) {
1871
- 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"}`);
1872
- }
2053
+ return paneId;
1873
2054
  }
1874
- function editTicket(ctx, id, patch) {
1875
- const current2 = getTicket(ctx, id);
1876
- assertMutable(current2, "edit");
1877
- const set = {};
1878
- if (patch.title !== undefined)
1879
- set.title = patch.title;
1880
- if (patch.goal !== undefined)
1881
- set.goal = patch.goal;
1882
- if (patch.context !== undefined)
1883
- set.context = patch.context;
1884
- if (patch.acceptanceCriteria !== undefined)
1885
- set.acceptanceCriteria = patch.acceptanceCriteria;
1886
- if (patch.seams !== undefined)
1887
- set.seams = patch.seams;
1888
- if (Object.keys(set).length === 0) {
1889
- throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
2055
+ function stopDevPane(paneId) {
2056
+ const reg = ptyRegistry();
2057
+ const entry = reg.get(paneId);
2058
+ if (entry && !entry.exited && process.platform !== "win32") {
2059
+ try {
2060
+ process.kill(-entry.pty.pid, "SIGTERM");
2061
+ } catch {}
1890
2062
  }
1891
- ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1892
- emit(ctx, current2.featureId, {
1893
- type: "ticket.edited",
1894
- message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
1895
- ticketId: id,
1896
- data: { fields: Object.keys(set) }
1897
- });
1898
- return getTicket(ctx, id);
1899
- }
1900
- function cancelTicket(ctx, id, reason) {
1901
- const current2 = getTicket(ctx, id);
1902
- assertMutable(current2, "cancel");
1903
- ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq5(tickets.id, id)).run();
1904
- emit(ctx, current2.featureId, {
1905
- type: "ticket.cancelled",
1906
- message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
1907
- ticketId: id,
1908
- data: { reason: reason ?? null }
1909
- });
1910
- return getTicket(ctx, id);
1911
- }
1912
- function sweepOrphanedBurning(ctx, featureId, reason) {
1913
- const orphaned = ctx.db.select().from(tickets).where(and5(eq5(tickets.featureId, featureId), eq5(tickets.status, "burning"))).all().map(rowToTicket);
1914
- for (const t of orphaned) {
1915
- updateTicket(ctx, t.id, { status: "failed", error: reason });
1916
- emit(ctx, featureId, {
1917
- type: "ticket.failed",
1918
- message: `ticket ${t.seq} failed: ${reason}`,
1919
- ticketId: t.id,
1920
- data: { error: reason, orphaned: true }
1921
- });
1922
- }
1923
- return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
1924
- }
1925
- function updateTicket(ctx, id, patch) {
1926
- const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1927
- if (!current2)
1928
- throw new NotFoundError(`ticket ${id} not found`);
1929
- const set = {};
1930
- if (patch.status !== undefined)
1931
- set.status = patch.status;
1932
- if (patch.commits !== undefined)
1933
- set.commits = patch.commits;
1934
- if (patch.error !== undefined)
1935
- set.error = patch.error;
1936
- if (patch.attemptBranch !== undefined)
1937
- set.attemptBranch = patch.attemptBranch;
1938
- if (patch.conflictFiles !== undefined)
1939
- set.conflictFiles = patch.conflictFiles;
1940
- ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
1941
- emit(ctx, current2.featureId, {
1942
- type: "ticket.updated",
1943
- message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
1944
- ticketId: id,
1945
- data: patch
1946
- });
1947
- const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
1948
- return rowToTicket(updated);
1949
- }
1950
- var MUTABLE_STATUSES;
1951
- var init_tickets = __esm(() => {
1952
- init_src();
1953
- init_schema();
1954
- init_errors();
1955
- init_events();
1956
- MUTABLE_STATUSES = new Set(["pending", "failed"]);
1957
- });
1958
-
1959
- // src/services/gates.ts
1960
- import { existsSync as existsSync6 } from "fs";
1961
- function checkGate(ctx, check, feature) {
1962
- switch (check) {
1963
- case "decisions-file-exists":
1964
- return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
1965
- case "all-waypoints-terminal": {
1966
- const wps = listByFeature(ctx, feature.id);
1967
- if (wps.length === 0) {
1968
- return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
1969
- }
1970
- const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
1971
- return open.length === 0 ? { satisfied: true } : {
1972
- satisfied: false,
1973
- reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
1974
- };
1975
- }
1976
- case "spec-file-exists":
1977
- return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
1978
- case "tickets-approved": {
1979
- const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
1980
- return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
1981
- }
1982
- case "all-tickets-terminal": {
1983
- const tickets2 = listByFeature2(ctx, feature.id);
1984
- if (tickets2.length === 0)
1985
- return { satisfied: false, reason: "no tickets have run yet" };
1986
- const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
1987
- return open.length === 0 ? { satisfied: true } : {
1988
- satisfied: false,
1989
- reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
1990
- };
1991
- }
1992
- case "human-merge":
1993
- return { satisfied: false, reason: "use the Merge button to ship" };
1994
- }
1995
- }
1996
- function notYetTerminal(noun, statuses, order) {
1997
- const counts = new Map;
1998
- for (const s of statuses)
1999
- counts.set(s, (counts.get(s) ?? 0) + 1);
2000
- const ordered = [
2001
- ...order.filter((s) => counts.has(s)),
2002
- ...[...counts.keys()].filter((s) => !order.includes(s))
2003
- ];
2004
- const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
2005
- return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
2006
- }
2007
- function fileGate(ctx, feature, fileName, reason) {
2008
- const project = projectForFeature(ctx, feature);
2009
- return existsSync6(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
2010
- }
2011
- function overrideGate(ctx, featureId, gate, reason) {
2012
- const feature = getFeatureRow(ctx, featureId);
2013
- ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
2014
- emit(ctx, featureId, {
2015
- type: "gate.overridden",
2016
- message: `gate ${gate} overridden: ${reason}`,
2017
- data: { gate, reason }
2018
- });
2019
- const next = nextPhase(feature);
2020
- if (next)
2021
- return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
2022
- return feature;
2023
- }
2024
- var init_gates = __esm(() => {
2025
- init_src();
2026
- init_schema();
2027
- init_events();
2028
- init_feature_docs();
2029
- init_repo();
2030
- init_tickets();
2031
- init_waypoints();
2032
- });
2033
-
2034
- // src/pty/dev-pane.ts
2035
- function drivePaneId(featureId) {
2036
- return `${DRIVE_PREFIX}${featureId}`;
2037
- }
2038
- function sniffDevUrl(text2) {
2039
- const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
2040
- return m ? m[0] : undefined;
2041
- }
2042
- function devSpawnTarget(devCommand) {
2043
- if (process.platform === "win32") {
2044
- return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
2045
- }
2046
- return { file: "/bin/sh", args: ["-c", devCommand] };
2047
- }
2048
- function startDevPane(input) {
2049
- const { ctx, featureId, repoPath, devCommand, onUrl } = input;
2050
- const paneId = drivePaneId(featureId);
2051
- const { file, args } = devSpawnTarget(devCommand);
2052
- try {
2053
- ptyRegistry().create({
2054
- sessionId: paneId,
2055
- cmd: file,
2056
- args,
2057
- opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
2058
- });
2059
- } catch (err) {
2060
- emit(ctx, featureId, {
2061
- type: "testdrive.dev_failed",
2062
- message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
2063
- data: { paneId }
2064
- });
2065
- return;
2066
- }
2067
- let scanned = "";
2068
- let found = false;
2069
- const sniffer = {
2070
- sendData(chunk) {
2071
- if (found)
2072
- return;
2073
- scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
2074
- const url = sniffDevUrl(scanned);
2075
- if (url) {
2076
- found = true;
2077
- onUrl(url);
2078
- }
2079
- },
2080
- sendControl() {}
2081
- };
2082
- ptyRegistry().attach(paneId, sniffer);
2083
- emit(ctx, featureId, {
2084
- type: "testdrive.dev_started",
2085
- message: `dev server spawned: ${devCommand}`,
2086
- data: { paneId }
2087
- });
2088
- return paneId;
2089
- }
2090
- function stopDevPane(paneId) {
2091
- const reg = ptyRegistry();
2092
- const entry = reg.get(paneId);
2093
- if (entry && !entry.exited && process.platform !== "win32") {
2094
- try {
2095
- process.kill(-entry.pty.pid, "SIGTERM");
2096
- } catch {}
2097
- }
2098
- reg.kill(paneId);
2099
- reg.remove(paneId);
2063
+ reg.kill(paneId);
2064
+ reg.remove(paneId);
2100
2065
  }
2101
2066
  var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
2102
2067
  var init_dev_pane = __esm(() => {
@@ -2105,10 +2070,13 @@ var init_dev_pane = __esm(() => {
2105
2070
  });
2106
2071
 
2107
2072
  // src/services/git.ts
2108
- import { existsSync as existsSync7, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2073
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2109
2074
  import { tmpdir } from "os";
2110
- import { dirname, join as join6, resolve as resolve3 } from "path";
2075
+ import { dirname as dirname2, join as join6, resolve as resolve4 } from "path";
2111
2076
  import { simpleGit } from "simple-git";
2077
+ function migrationPaths(paths) {
2078
+ return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
2079
+ }
2112
2080
  function featureBranch(slug) {
2113
2081
  return `feature/${slug}`;
2114
2082
  }
@@ -2119,14 +2087,14 @@ function errMsg(e) {
2119
2087
  return e instanceof Error ? e.message : String(e);
2120
2088
  }
2121
2089
  function canon(p) {
2122
- let abs = resolve3(p);
2090
+ let abs = resolve4(p);
2123
2091
  try {
2124
2092
  abs = realpathSync.native(abs);
2125
2093
  } catch {}
2126
2094
  return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2127
2095
  }
2128
2096
  async function assertRepo(repoPath) {
2129
- if (!existsSync7(repoPath)) {
2097
+ if (!existsSync6(repoPath)) {
2130
2098
  throw new InvalidInputError(`path does not exist: ${repoPath}`);
2131
2099
  }
2132
2100
  let isRepo = false;
@@ -2215,7 +2183,7 @@ async function ensureTalkWorktree(project, feature) {
2215
2183
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2216
2184
  if (await worktreeIsValid(g, worktreePath, branch))
2217
2185
  return worktreePath;
2218
- mkdirSync2(dirname(worktreePath), { recursive: true });
2186
+ mkdirSync2(dirname2(worktreePath), { recursive: true });
2219
2187
  try {
2220
2188
  await g.raw(["worktree", "add", worktreePath, branch]);
2221
2189
  return worktreePath;
@@ -2238,7 +2206,7 @@ async function ensureBranchExists(g, branch, from) {
2238
2206
  }
2239
2207
  }
2240
2208
  async function worktreeIsValid(g, worktreePath, branch) {
2241
- if (!existsSync7(worktreePath))
2209
+ if (!existsSync6(worktreePath))
2242
2210
  return false;
2243
2211
  const registered = await registeredWorktrees(g);
2244
2212
  if (!registered.has(canon(worktreePath)))
@@ -2284,7 +2252,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2284
2252
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2285
2253
  }
2286
2254
  async function detachWorktree(path) {
2287
- if (!existsSync7(path))
2255
+ if (!existsSync6(path))
2288
2256
  return false;
2289
2257
  const g = git(path);
2290
2258
  let head;
@@ -2299,12 +2267,27 @@ async function detachWorktree(path) {
2299
2267
  return true;
2300
2268
  }
2301
2269
  async function reattachWorktree(path, branch) {
2302
- if (!existsSync7(path))
2270
+ if (!existsSync6(path))
2303
2271
  return;
2304
2272
  try {
2305
2273
  await git(path).checkout(branch);
2306
2274
  } catch {}
2307
2275
  }
2276
+ function prepBranchName(unique) {
2277
+ return `${PREP_BRANCH_PREFIX}${unique}`;
2278
+ }
2279
+ async function readFileAtRef(repoPath, ref, path) {
2280
+ try {
2281
+ return await git(repoPath).raw(["show", `${ref}:${path}`]);
2282
+ } catch {
2283
+ return;
2284
+ }
2285
+ }
2286
+ async function deletePrepBranch(repoPath, branch) {
2287
+ try {
2288
+ await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
2289
+ } catch {}
2290
+ }
2308
2291
  function tempBranchSlugSegment(slug) {
2309
2292
  return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
2310
2293
  }
@@ -2322,14 +2305,14 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2322
2305
  const attempts = Math.max(1, opts.attempts ?? 3);
2323
2306
  const delayMs = opts.delayMs ?? 750;
2324
2307
  const g = git(repoPath);
2325
- for (let attempt = 1;attempt <= attempts && existsSync7(path); attempt++) {
2308
+ for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2326
2309
  try {
2327
2310
  await g.raw(["worktree", "remove", "--force", path]);
2328
2311
  } catch {}
2329
- if (existsSync7(path) && attempt < attempts)
2312
+ if (existsSync6(path) && attempt < attempts)
2330
2313
  await sleep(delayMs);
2331
2314
  }
2332
- if (existsSync7(path)) {
2315
+ if (existsSync6(path)) {
2333
2316
  try {
2334
2317
  rmSync(path, { recursive: true, force: true });
2335
2318
  } catch {}
@@ -2337,7 +2320,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2337
2320
  try {
2338
2321
  await g.raw(["worktree", "prune"]);
2339
2322
  } catch {}
2340
- return !existsSync7(path);
2323
+ return !existsSync6(path);
2341
2324
  }
2342
2325
  function sleep(ms) {
2343
2326
  return new Promise((done) => setTimeout(done, ms));
@@ -2351,6 +2334,32 @@ async function branchCommitsAhead(repoPath, base, branch) {
2351
2334
  return [];
2352
2335
  }
2353
2336
  }
2337
+ async function headSha(repoPath, ref) {
2338
+ try {
2339
+ const out = (await git(repoPath).revparse([ref])).trim();
2340
+ return out.length > 0 ? out : undefined;
2341
+ } catch {
2342
+ return;
2343
+ }
2344
+ }
2345
+ async function commitsSince(repoPath, sha, branch) {
2346
+ try {
2347
+ const out = (await git(repoPath).raw(["rev-list", "--count", `${sha}..${branch}`])).trim();
2348
+ const n = Number(out);
2349
+ return Number.isFinite(n) ? n : undefined;
2350
+ } catch {
2351
+ return;
2352
+ }
2353
+ }
2354
+ async function diffPaths(repoPath, from, to) {
2355
+ try {
2356
+ const out = (await git(repoPath).raw(["diff", "--name-only", from, to])).trim();
2357
+ return out ? out.split(`
2358
+ `).map((s) => s.trim()).filter(Boolean) : [];
2359
+ } catch {
2360
+ return [];
2361
+ }
2362
+ }
2354
2363
  async function commitSummaries(repoPath, base, branch, limit = 20) {
2355
2364
  try {
2356
2365
  const out = await git(repoPath).raw([
@@ -2546,7 +2555,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2546
2555
  try {
2547
2556
  await g.raw(["worktree", "prune"]);
2548
2557
  } catch {}
2549
- if (existsSync7(worktreePath)) {
2558
+ if (existsSync6(worktreePath)) {
2550
2559
  throw new InvalidInputError(`could not remove talk worktree at ${worktreePath} \u2014 a file may be locked; ` + "close anything using it and retry the delete");
2551
2560
  }
2552
2561
  }
@@ -2573,7 +2582,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2573
2582
  return { deleted, kept };
2574
2583
  }
2575
2584
  async function commitDocs(worktreePath, message) {
2576
- if (!existsSync7(resolve3(worktreePath, DOCS_PATHSPEC)))
2585
+ if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
2577
2586
  return;
2578
2587
  const g = git(worktreePath);
2579
2588
  await g.add([DOCS_PATHSPEC]);
@@ -2618,6 +2627,7 @@ async function testDrive(ctx, project, feature, action) {
2618
2627
  const devPaneId = testDriveState.devPaneId;
2619
2628
  if (devPaneId)
2620
2629
  stopDevPane(devPaneId);
2630
+ const carriedChanges = await dirtyPaths(g);
2621
2631
  await g.checkout(previousBranch2);
2622
2632
  if (detachedWorktree2)
2623
2633
  await reattachWorktree(detachedWorktree2, branch);
@@ -2627,7 +2637,20 @@ async function testDrive(ctx, project, feature, action) {
2627
2637
  message: `test drive stopped \u2014 back on ${previousBranch2}`,
2628
2638
  data: { branch: previousBranch2 }
2629
2639
  });
2630
- return { ok: true, branch: previousBranch2 };
2640
+ if (carriedChanges.length > 0) {
2641
+ emit(ctx, feature.id, {
2642
+ type: "testdrive.carried_changes",
2643
+ message: `${carriedChanges.length} uncommitted file(s) came back with you onto ${previousBranch2}: ${carriedChanges.slice(0, 5).join(", ")}${carriedChanges.length > 5 ? ", \u2026" : ""}`,
2644
+ data: { branch: previousBranch2, files: carriedChanges }
2645
+ });
2646
+ }
2647
+ const dbDrift = await detectDbDrift(ctx, project, feature, previousBranch2, branch);
2648
+ return {
2649
+ ok: true,
2650
+ branch: previousBranch2,
2651
+ ...carriedChanges.length > 0 ? { carriedChanges } : {},
2652
+ ...dbDrift ? { dbDrift } : {}
2653
+ };
2631
2654
  }
2632
2655
  const porcelain = (await g.raw(["status", "--porcelain"])).trim();
2633
2656
  if (porcelain !== "")
@@ -2664,6 +2687,35 @@ async function testDrive(ctx, project, feature, action) {
2664
2687
  }
2665
2688
  return { ok: true, branch };
2666
2689
  }
2690
+ async function dirtyPaths(g) {
2691
+ try {
2692
+ const out = (await g.raw(["status", "--porcelain"])).trim();
2693
+ if (!out)
2694
+ return [];
2695
+ return out.split(`
2696
+ `).map((line) => line.slice(3).trim()).map((p) => p.includes(" -> ") ? p.split(" -> ").at(-1) ?? p : p).map((p) => p.replace(/^"|"$/g, "")).filter(Boolean);
2697
+ } catch {
2698
+ return [];
2699
+ }
2700
+ }
2701
+ async function detectDbDrift(ctx, project, feature, previousBranch, driveBranch) {
2702
+ let files;
2703
+ try {
2704
+ files = migrationPaths(await diffPaths(project.repoPath, previousBranch, driveBranch));
2705
+ } catch {
2706
+ return;
2707
+ }
2708
+ if (files.length === 0)
2709
+ return;
2710
+ const resetCommand = project.dbResetCommand?.trim() || undefined;
2711
+ const drift = { files, ...resetCommand ? { resetCommand } : {} };
2712
+ emit(ctx, feature.id, {
2713
+ type: "testdrive.db_drift",
2714
+ message: `${driveBranch} and ${previousBranch} differ by ${files.length} migration file(s) \u2014 ` + "anything you migrated during the drive is still applied to your dev database, so the next " + `migrate on ${previousBranch} may report drift. ` + (resetCommand ? `Rebuild it with: ${resetCommand}` : 'Set a "Database reset command" in project settings to get a one-click fix here.'),
2715
+ data: { files, previousBranch, driveBranch, resetCommand: resetCommand ?? null }
2716
+ });
2717
+ return drift;
2718
+ }
2667
2719
  async function mergeFeature(project, feature) {
2668
2720
  const target = feature.baseBranch ?? project.mainBranch;
2669
2721
  if (testDriveState) {
@@ -2719,171 +2771,18 @@ async function mergeInProgress(g) {
2719
2771
  return false;
2720
2772
  }
2721
2773
  }
2722
- var DOCS_PATHSPEC = "docs/features", DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
2774
+ var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", PREP_BRANCH_PREFIX = "runcastle/prep/", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
2723
2775
  var init_git = __esm(() => {
2724
2776
  init_paths();
2725
2777
  init_errors();
2726
2778
  init_dev_pane();
2727
2779
  init_events();
2728
2780
  init_repo();
2781
+ MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
2782
+ DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
2729
2783
  TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
2730
2784
  });
2731
2785
 
2732
- // src/services/knowledge.ts
2733
- import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync } from "fs";
2734
- import { join as join7, relative, resolve as resolve4, sep } from "path";
2735
- function scaffoldDocs(ctx, feature) {
2736
- const project = projectForFeature(ctx, feature);
2737
- const dir = featureDocsDir(project, feature);
2738
- mkdirSync3(dir, { recursive: true });
2739
- const briefPath = featureDocPath(project, feature, "brief.md");
2740
- if (!existsSync8(briefPath)) {
2741
- const created = new Date().toISOString();
2742
- const brief = [
2743
- `# ${feature.title}`,
2744
- "",
2745
- feature.oneLiner,
2746
- "",
2747
- `- Slug: ${feature.slug}`,
2748
- `- Created: ${created}`,
2749
- ""
2750
- ].join(`
2751
- `);
2752
- writeFileSync(briefPath, brief, "utf8");
2753
- }
2754
- if (feature.mapped) {
2755
- scaffoldMapDoc(project, feature);
2756
- }
2757
- emit(ctx, feature.id, {
2758
- type: "docs.scaffolded",
2759
- message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
2760
- });
2761
- }
2762
- function scaffoldMapDoc(project, feature, seed) {
2763
- mkdirSync3(featureDocsDir(project, feature), { recursive: true });
2764
- const mapPath = featureDocPath(project, feature, "map.md");
2765
- if (existsSync8(mapPath))
2766
- return;
2767
- writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
2768
- }
2769
- function mapDocBody(feature, seed) {
2770
- const seeded = {
2771
- Destination: seed?.destination?.trim(),
2772
- Notes: seed?.notes?.trim()
2773
- };
2774
- const lines = [`# ${feature.title} \u2014 map`, ""];
2775
- for (const section of MAP_SECTIONS) {
2776
- lines.push(`## ${section}`, "");
2777
- const body = seeded[section];
2778
- if (body)
2779
- lines.push(body, "");
2780
- }
2781
- return lines.join(`
2782
- `);
2783
- }
2784
- function listDocs(ctx, feature) {
2785
- const project = projectForFeature(ctx, feature);
2786
- const dir = featureDocsDir(project, feature);
2787
- if (!existsSync8(dir))
2788
- return [];
2789
- 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));
2790
- }
2791
- function readDoc(ctx, feature, relPath) {
2792
- const project = projectForFeature(ctx, feature);
2793
- const dir = featureDocsDir(project, feature);
2794
- const root = resolve4(dir);
2795
- const target = resolve4(dir, relPath);
2796
- const rel = relative(root, target);
2797
- if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
2798
- throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
2799
- }
2800
- if (!existsSync8(target))
2801
- throw new NotFoundError(`doc not found: ${relPath}`);
2802
- return { content: readFileSync3(target, "utf8") };
2803
- }
2804
- function readTitle(path, fallback) {
2805
- try {
2806
- const firstLine = readFileSync3(path, "utf8").split(`
2807
- `, 1)[0] ?? "";
2808
- const heading = firstLine.match(/^#\s+(.+)$/);
2809
- return heading ? heading[1].trim() : fallback;
2810
- } catch {
2811
- return fallback;
2812
- }
2813
- }
2814
- var MAP_SECTIONS;
2815
- var init_knowledge = __esm(() => {
2816
- init_errors();
2817
- init_events();
2818
- init_feature_docs();
2819
- init_repo();
2820
- MAP_SECTIONS = [
2821
- "Destination",
2822
- "Notes",
2823
- "Not yet specified",
2824
- "Out of scope"
2825
- ];
2826
- });
2827
-
2828
- // src/pty/end-session.ts
2829
- function endSession(ctx, sessionId) {
2830
- const registry = ptyRegistry();
2831
- const killed = registry.kill(sessionId);
2832
- registry.remove(sessionId);
2833
- const session = markSessionEnded(ctx, sessionId);
2834
- if (session) {
2835
- releaseForSession(ctx, sessionId);
2836
- emit(ctx, session.featureId, {
2837
- type: "session.ended",
2838
- message: "session ended by user",
2839
- data: { sessionId, killed }
2840
- });
2841
- } else {
2842
- const existing = getSessionRow(ctx, sessionId);
2843
- if (!existing)
2844
- return { sessionId, ok: false, killed };
2845
- }
2846
- return { sessionId, ok: true, killed };
2847
- }
2848
- var init_end_session = __esm(() => {
2849
- init_sessions();
2850
- init_events();
2851
- init_waypoints();
2852
- init_registry();
2853
- });
2854
-
2855
- // src/launcher/skills-root.ts
2856
- import { existsSync as existsSync9 } from "fs";
2857
- import { dirname as dirname2, join as join8, resolve as resolve5 } from "path";
2858
- function resolveSkillsRoot(fromDir) {
2859
- const override = process.env[SKILLS_DIR_ENV];
2860
- if (override) {
2861
- const root = resolve5(override);
2862
- if (existsSync9(join8(root, SKILLS_MARKER)))
2863
- return root;
2864
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
2865
- }
2866
- const searched = [];
2867
- let dir = fromDir;
2868
- for (let i = 0;i < 8; i += 1) {
2869
- const candidate = join8(dir, "packages", "skills");
2870
- searched.push(candidate);
2871
- if (existsSync9(join8(candidate, SKILLS_MARKER)))
2872
- return candidate;
2873
- const parent = dirname2(dir);
2874
- if (parent === dir)
2875
- break;
2876
- dir = parent;
2877
- }
2878
- throw new Error(`runcastle skills root not found; searched:
2879
- ${searched.join(`
2880
- `)}`);
2881
- }
2882
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
2883
- var init_skills_root = __esm(() => {
2884
- SKILLS_MARKER = join8("packs", "runcastle");
2885
- });
2886
-
2887
2786
  // src/services/agent-stream.ts
2888
2787
  function beginTranscript(ticketId) {
2889
2788
  transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
@@ -3012,8 +2911,8 @@ var init_burn_guard = __esm(() => {
3012
2911
  });
3013
2912
 
3014
2913
  // src/workflows/ticket-burner.ts
3015
- import { existsSync as existsSync10, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync3 } from "fs";
3016
- import { dirname as dirname3, join as join9 } from "path";
2914
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
2915
+ import { dirname as dirname3, join as join7 } from "path";
3017
2916
  import { fileURLToPath as fileURLToPath2 } from "url";
3018
2917
  import { claudeCode, run } from "@ai-hero/sandcastle";
3019
2918
  import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
@@ -3129,13 +3028,13 @@ function parseEnvFile(content) {
3129
3028
  if (line.length === 0 || line.startsWith("#"))
3130
3029
  continue;
3131
3030
  const body = line.startsWith("export ") ? line.slice(7).trim() : line;
3132
- const eq6 = body.indexOf("=");
3133
- if (eq6 === -1)
3031
+ const eq5 = body.indexOf("=");
3032
+ if (eq5 === -1)
3134
3033
  continue;
3135
- const key = body.slice(0, eq6).trim();
3034
+ const key = body.slice(0, eq5).trim();
3136
3035
  if (key.length === 0)
3137
3036
  continue;
3138
- let value = body.slice(eq6 + 1).trim();
3037
+ let value = body.slice(eq5 + 1).trim();
3139
3038
  if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
3140
3039
  value = value.slice(1, -1);
3141
3040
  }
@@ -3236,12 +3135,12 @@ function buildVerifyNotes(config2) {
3236
3135
  `);
3237
3136
  }
3238
3137
  function readRepoToolchain(repoPath) {
3239
- const pkgPath = join9(repoPath, "package.json");
3240
- const hasPackageJson = existsSync10(pkgPath);
3138
+ const pkgPath = join7(repoPath, "package.json");
3139
+ const hasPackageJson = existsSync7(pkgPath);
3241
3140
  let packageManagerField;
3242
3141
  if (hasPackageJson) {
3243
3142
  try {
3244
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
3143
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
3245
3144
  if (typeof pkg.packageManager === "string")
3246
3145
  packageManagerField = pkg.packageManager;
3247
3146
  } catch {}
@@ -3250,10 +3149,10 @@ function readRepoToolchain(repoPath) {
3250
3149
  hasPackageJson,
3251
3150
  ...packageManagerField !== undefined ? { packageManagerField } : {},
3252
3151
  lockfiles: {
3253
- bun: existsSync10(join9(repoPath, "bun.lock")) || existsSync10(join9(repoPath, "bun.lockb")),
3254
- pnpm: existsSync10(join9(repoPath, "pnpm-lock.yaml")),
3255
- yarn: existsSync10(join9(repoPath, "yarn.lock")),
3256
- npm: existsSync10(join9(repoPath, "package-lock.json"))
3152
+ bun: existsSync7(join7(repoPath, "bun.lock")) || existsSync7(join7(repoPath, "bun.lockb")),
3153
+ pnpm: existsSync7(join7(repoPath, "pnpm-lock.yaml")),
3154
+ yarn: existsSync7(join7(repoPath, "yarn.lock")),
3155
+ npm: existsSync7(join7(repoPath, "package-lock.json"))
3257
3156
  }
3258
3157
  };
3259
3158
  }
@@ -3394,18 +3293,18 @@ function buildRetryNotes(input) {
3394
3293
  `);
3395
3294
  }
3396
3295
  function delayUnlessAborted(ms, signal) {
3397
- return new Promise((resolve6) => {
3296
+ return new Promise((resolve5) => {
3398
3297
  if (signal.aborted) {
3399
- resolve6();
3298
+ resolve5();
3400
3299
  return;
3401
3300
  }
3402
3301
  const onAbort = () => {
3403
3302
  clearTimeout(t);
3404
- resolve6();
3303
+ resolve5();
3405
3304
  };
3406
3305
  const t = setTimeout(() => {
3407
3306
  signal.removeEventListener("abort", onAbort);
3408
- resolve6();
3307
+ resolve5();
3409
3308
  }, ms);
3410
3309
  signal.addEventListener("abort", onAbort, { once: true });
3411
3310
  });
@@ -3428,38 +3327,38 @@ function createSerialQueue() {
3428
3327
  };
3429
3328
  }
3430
3329
  async function landWithResolve(branch, deps) {
3431
- let current2 = branch;
3330
+ let current = branch;
3432
3331
  for (let attempt = 1;; attempt++) {
3433
- const merge = await deps.merge(current2);
3332
+ const merge = await deps.merge(current);
3434
3333
  if (merge.ok)
3435
- return { status: "landed", branch: current2 };
3334
+ return { status: "landed", branch: current };
3436
3335
  const detail = merge.error ?? "merge failed";
3437
3336
  if (!merge.conflict)
3438
- return { status: "failed", branch: current2, error: detail };
3337
+ return { status: "failed", branch: current, error: detail };
3439
3338
  const files = merge.files ?? [];
3440
3339
  if (attempt > deps.maxResolveAttempts) {
3441
- return { status: "conflict", branch: current2, files, error: detail };
3340
+ return { status: "conflict", branch: current, files, error: detail };
3442
3341
  }
3443
3342
  const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
3444
3343
  deps.emit({
3445
3344
  type: "merge.conflict.resolving",
3446
3345
  message: `${deps.label}: conflicts with ${deps.featureBranch} on ${where} \u2014 resolving with an agent (pass ${attempt}/${deps.maxResolveAttempts})`,
3447
- data: { branch: current2, files, attempt, maxAttempts: deps.maxResolveAttempts }
3346
+ data: { branch: current, files, attempt, maxAttempts: deps.maxResolveAttempts }
3448
3347
  });
3449
3348
  const resolved = await deps.resolve({
3450
- branch: current2,
3349
+ branch: current,
3451
3350
  files,
3452
3351
  attempt,
3453
3352
  maxAttempts: deps.maxResolveAttempts
3454
3353
  });
3455
- current2 = resolved.branch;
3354
+ current = resolved.branch;
3456
3355
  if (!resolved.ok) {
3457
- return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
3356
+ return { status: "conflict", branch: current, files, error: resolved.error ?? detail };
3458
3357
  }
3459
3358
  deps.emit({
3460
3359
  type: "merge.conflict.resolved",
3461
- message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
3462
- data: { branch: current2, files, attempt }
3360
+ message: `${deps.label}: conflict resolved on ${current} \u2014 landing`,
3361
+ data: { branch: current, files, attempt }
3463
3362
  });
3464
3363
  }
3465
3364
  }
@@ -3617,26 +3516,26 @@ function resolverTemplatePath() {
3617
3516
  }
3618
3517
  function burnerAssetPath(file) {
3619
3518
  const here = dirname3(fileURLToPath2(import.meta.url));
3620
- return join9(resolveSkillsRoot(here), "burner", file);
3519
+ return join7(resolveSkillsRoot(here), "burner", file);
3621
3520
  }
3622
3521
  function readDocsDigestFromDisk(projectId, slug) {
3623
3522
  const worktree = worktreeDir(projectId, slug);
3624
- if (!existsSync10(worktree))
3523
+ if (!existsSync7(worktree))
3625
3524
  return "_No talk worktree on disk \u2014 docs digest skipped._";
3626
- const docsDir = join9(worktree, ...featureDocsRel(slug).split("/"));
3627
- if (!existsSync10(docsDir))
3525
+ const docsDir = join7(worktree, ...featureDocsRel(slug).split("/"));
3526
+ if (!existsSync7(docsDir))
3628
3527
  return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
3629
- const files = readdirSync3(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync4(join9(docsDir, e.name), "utf8") }));
3528
+ const files = readdirSync2(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync3(join7(docsDir, e.name), "utf8") }));
3630
3529
  return buildDocsDigest(files);
3631
3530
  }
3632
3531
  function readBlockedFile(dirs) {
3633
3532
  for (const dir of dirs) {
3634
3533
  if (!dir)
3635
3534
  continue;
3636
- const p = join9(dir, "BLOCKED.md");
3637
- if (existsSync10(p)) {
3535
+ const p = join7(dir, "BLOCKED.md");
3536
+ if (existsSync7(p)) {
3638
3537
  try {
3639
- return readFileSync4(p, "utf8");
3538
+ return readFileSync3(p, "utf8");
3640
3539
  } catch {}
3641
3540
  }
3642
3541
  }
@@ -3689,8 +3588,9 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3689
3588
  const featureBrief = buildFeatureBrief(feature);
3690
3589
  const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3691
3590
  const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3692
- const verifyNotes = buildVerifyNotes(config2);
3693
- const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3591
+ const prepared = resolvePreparedSettings(config2, project);
3592
+ const verifyNotes = buildVerifyNotes(prepared);
3593
+ const basePrompt = renderTicketPrompt(readFileSync3(burnerTemplatePath(), "utf8"), {
3694
3594
  TICKET_JSON: ticketJson,
3695
3595
  FEATURE_BRIEF: featureBrief,
3696
3596
  DOCS_DIGEST: docsDigest,
@@ -3700,19 +3600,19 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3700
3600
  });
3701
3601
  const toolchain = readRepoToolchain(project.repoPath);
3702
3602
  const pm = detectPackageManager(toolchain);
3703
- const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
3603
+ const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
3704
3604
  const mounts = [];
3705
3605
  if (config2.sandbox !== "noSandbox" && pm) {
3706
3606
  const mount = cacheMountFor(pm, burnCacheDir(pm));
3707
3607
  if (mount) {
3708
- mkdirSync4(mount.hostPath, { recursive: true });
3608
+ mkdirSync3(mount.hostPath, { recursive: true });
3709
3609
  mounts.push(mount);
3710
3610
  }
3711
3611
  }
3712
3612
  const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
3713
3613
  const withGuard = (setup) => guardInstall === undefined ? setup : setup ? `${guardInstall} && ${setup}` : guardInstall;
3714
- mkdirSync4(logsDir(), { recursive: true });
3715
- const logFilePath = join9(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3614
+ mkdirSync3(logsDir(), { recursive: true });
3615
+ const logFilePath = join7(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3716
3616
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3717
3617
  beginTranscript(ticket.id);
3718
3618
  const timer = createToolTimer();
@@ -3758,7 +3658,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3758
3658
  const runResolver = async (input) => {
3759
3659
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
3760
3660
  const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
3761
- const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
3661
+ const prompt = renderTemplate(readFileSync3(resolverTemplatePath(), "utf8"), {
3762
3662
  TICKET_JSON: ticketJson,
3763
3663
  FEATURE_BRIEF: featureBrief,
3764
3664
  DOCS_DIGEST: docsDigest,
@@ -4045,108 +3945,997 @@ ${blocked.trim()}` };
4045
3945
  }
4046
3946
  }
4047
3947
  }
4048
- function resolveBurnDeps(ctx) {
4049
- const config2 = loadConfig();
4050
- const token = readTokenFromEnvFile(envPath());
4051
- const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
4052
- const land = createSerialQueue();
4053
- let pushTargetReady;
4054
- const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
4055
- return {
4056
- config: config2,
4057
- hasAuthToken: token !== undefined,
4058
- concurrency: config2.burnConcurrency,
4059
- executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land, ensureIsolatedPushTarget)
3948
+ function resolveBurnDeps(ctx) {
3949
+ const config2 = loadConfig();
3950
+ const token = readTokenFromEnvFile(envPath());
3951
+ const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
3952
+ const land = createSerialQueue();
3953
+ let pushTargetReady;
3954
+ const ensureIsolatedPushTarget = () => pushTargetReady ??= allowPushToCheckedOutBranches(ctx.project.repoPath);
3955
+ return {
3956
+ config: config2,
3957
+ hasAuthToken: token !== undefined,
3958
+ concurrency: config2.burnConcurrency,
3959
+ executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land, ensureIsolatedPushTarget)
3960
+ };
3961
+ }
3962
+ function readTokenFromEnvFile(path) {
3963
+ let fromFile;
3964
+ if (existsSync7(path)) {
3965
+ try {
3966
+ fromFile = parseEnvFile(readFileSync3(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
3967
+ } catch {
3968
+ fromFile = undefined;
3969
+ }
3970
+ }
3971
+ const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
3972
+ return token && token.length > 0 ? token : undefined;
3973
+ }
3974
+ var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PACKAGE_MANAGERS, PM_CACHE_SANDBOX_PATHS, SANDBOX_WORKSPACE_PATH = "/home/agent/workspace", ISOLATED_REPO_PATH = "/home/agent/repo", RUN, BASH_PATTERNS, TOOL_NAME_CATEGORY, MAX_ATTRIBUTABLE_GAP_MS, BURN_WORKTREE_PATH, WORKTREE_REMOVAL_FAILURES, FATAL_ERROR_PATTERNS, RETRYABLE_ERROR_PATTERNS, activeTicketAborts, SETUP_HOOK_TIMEOUT_MS, ticketBurner;
3975
+ var init_ticket_burner = __esm(() => {
3976
+ init_src();
3977
+ init_config_load();
3978
+ init_paths();
3979
+ init_skills_root();
3980
+ init_agent_stream();
3981
+ init_git();
3982
+ init_burn_guard();
3983
+ PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
3984
+ PM_CACHE_SANDBOX_PATHS = {
3985
+ bun: "~/.bun/install/cache",
3986
+ yarn: "~/.cache/yarn",
3987
+ npm: "~/.npm"
3988
+ };
3989
+ RUN = String.raw`(?![\w./-])`;
3990
+ BASH_PATTERNS = [
3991
+ ["tests", new RegExp(String.raw`\b(vitest|jest|pytest|go test|cargo test|bun test|playwright|maestro)${RUN}|\b(pnpm|npm|yarn|bun|npx)\b[^&;|]*?\s(run\s+)?test${RUN}|--testPathPatterns`)],
3992
+ ["typecheck", /\btsc\b|\btypecheck\b|\bmypy\b|\bcargo check\b/],
3993
+ ["build", new RegExp(String.raw`\bbuild${RUN}|\bprisma\s+(generate|migrate)\b|\bcodegen${RUN}`)],
3994
+ ["lint", new RegExp(String.raw`\b(eslint|prettier|ruff|biome|lint|format)${RUN}`)],
3995
+ ["install", /\b(pnpm|npm|yarn|bun|corepack)\s+\w*\s*install\b|\bnpm ci\b/],
3996
+ ["git", /\bgit\b/],
3997
+ ["search", /\b(grep|rg|ag|find)\b/],
3998
+ ["file-read", /\b(cat|sed|head|tail|less|wc|ls)\b/],
3999
+ ["file-edit", /<<\s*['"]?(PY|EOF|SH|JS|TS)\b|\bpython3?\s+-\s*<</]
4000
+ ];
4001
+ TOOL_NAME_CATEGORY = {
4002
+ Read: "file-read",
4003
+ NotebookRead: "file-read",
4004
+ Grep: "search",
4005
+ Glob: "search",
4006
+ Edit: "file-edit",
4007
+ Write: "file-edit",
4008
+ NotebookEdit: "file-edit"
4009
+ };
4010
+ MAX_ATTRIBUTABLE_GAP_MS = 20 * 60000;
4011
+ BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
4012
+ WORKTREE_REMOVAL_FAILURES = [
4013
+ /failed to delete/i,
4014
+ /directory not empty|enotempty/i,
4015
+ /unable to (unlink|delete)/i,
4016
+ /is not a working tree/i,
4017
+ /resource busy|ebusy|eperm|eacces/i
4018
+ ];
4019
+ FATAL_ERROR_PATTERNS = [
4020
+ /invalid (api key|x-api-key)/i,
4021
+ /authentication|unauthorized|permission denied/i,
4022
+ /credit balance|billing/i,
4023
+ /oauth token|setup-token/i,
4024
+ /issue with the selected model|model not found|unknown model/i,
4025
+ /does not support resumeSession|resumeSession .* not found/i
4026
+ ];
4027
+ RETRYABLE_ERROR_PATTERNS = [
4028
+ /exited with code \d+/i,
4029
+ /idle timeout|AgentIdleTimeout/i,
4030
+ /connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
4031
+ /econnreset|etimedout|econnrefused|epipe|eai_again/i,
4032
+ /overloaded|rate.?limit|too many requests/i,
4033
+ /internal server error|service unavailable|bad gateway|gateway timeout/i,
4034
+ /\bapi error\b/i,
4035
+ /session capture failed/i
4036
+ ];
4037
+ activeTicketAborts = new Map;
4038
+ SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
4039
+ ticketBurner = {
4040
+ id: "ticket-burner",
4041
+ run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
4042
+ };
4043
+ });
4044
+
4045
+ // src/workflows/project-prep.ts
4046
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4 } from "fs";
4047
+ import { dirname as dirname4, join as join8 } from "path";
4048
+ import { fileURLToPath as fileURLToPath3 } from "url";
4049
+ import { run as run2 } from "@ai-hero/sandcastle";
4050
+ import * as z3 from "zod";
4051
+ function parsePrepFindings(raw) {
4052
+ let json;
4053
+ try {
4054
+ json = JSON.parse(raw);
4055
+ } catch (e) {
4056
+ throw new Error(`findings file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
4057
+ }
4058
+ const outer = RawFindings.safeParse(json);
4059
+ if (!outer.success)
4060
+ throw new Error("findings file is not a JSON object");
4061
+ const warnings = [];
4062
+ const values = {};
4063
+ const known = new Set(PREPARED_KEYS);
4064
+ for (const [key, rawValue] of Object.entries(outer.data)) {
4065
+ if (key === "notes")
4066
+ continue;
4067
+ if (!known.has(key)) {
4068
+ warnings.push(`ignored unknown key "${key}"`);
4069
+ continue;
4070
+ }
4071
+ const parsed = RawFinding.safeParse(rawValue);
4072
+ if (!parsed.success) {
4073
+ warnings.push(`ignored "${key}": not a string or {value, evidence} object`);
4074
+ continue;
4075
+ }
4076
+ const entry = parsed.data;
4077
+ const value = (typeof entry === "string" ? entry : entry.value ?? "").trim();
4078
+ if (value === "")
4079
+ continue;
4080
+ const evidence = typeof entry === "string" ? undefined : entry.evidence?.trim();
4081
+ values[key] = { value, ...evidence ? { evidence } : {} };
4082
+ }
4083
+ const notes = outer.data.notes?.trim();
4084
+ return { findings: { values, ...notes ? { notes } : {} }, warnings };
4085
+ }
4086
+ function renderPrepPrompt(template, values) {
4087
+ let out = template;
4088
+ for (const key of PLACEHOLDERS) {
4089
+ out = out.split(`{{${key}}}`).join(values[key]);
4090
+ }
4091
+ return out;
4092
+ }
4093
+ function buildRequestedKeysBlock(keys) {
4094
+ if (keys.length === 0) {
4095
+ return "_Everything is already established. Report an empty findings object and stop._";
4096
+ }
4097
+ const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
4098
+ return [...keys].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)).map((k) => `- ${KEY_BRIEF[k]}`).join(`
4099
+ `);
4100
+ }
4101
+ async function prepRun(ctx, deps) {
4102
+ if (ctx.keys.length === 0) {
4103
+ ctx.emitEvent({
4104
+ type: "prep.skipped",
4105
+ message: "nothing to prepare \u2014 every field is already set"
4106
+ });
4107
+ return { status: "succeeded", summary: "nothing to prepare" };
4108
+ }
4109
+ if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
4110
+ ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
4111
+ return { status: "failed", summary: "preparation aborted: auth token missing" };
4112
+ }
4113
+ ctx.emitEvent({
4114
+ type: "prep.started",
4115
+ message: `preparing ${ctx.project.name} \u2014 establishing ${ctx.keys.join(", ")}`,
4116
+ data: { keys: [...ctx.keys] }
4117
+ });
4118
+ const outcome = await deps.executePrepRun(ctx);
4119
+ if (outcome.status === "failed") {
4120
+ ctx.emitEvent({
4121
+ type: "prep.failed",
4122
+ message: `preparation failed: ${errorHeadline2(outcome.error)}`,
4123
+ data: { error: outcome.error }
4124
+ });
4125
+ return { status: "failed", summary: outcome.error };
4126
+ }
4127
+ for (const warning of outcome.warnings) {
4128
+ ctx.emitEvent({ type: "prep.warning", message: `preparation: ${warning}` });
4129
+ }
4130
+ const found = Object.keys(outcome.findings.values);
4131
+ const missed = ctx.keys.filter((k) => !found.includes(k));
4132
+ const summary = found.length === 0 ? "preparation established nothing" : `established ${found.join(", ")}${missed.length > 0 ? ` (could not establish ${missed.join(", ")})` : ""}`;
4133
+ return { status: "succeeded", summary, findings: outcome.findings };
4134
+ }
4135
+ function errorHeadline2(s) {
4136
+ const lines = s.split(`
4137
+ `).map((l) => l.trim()).filter(Boolean);
4138
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4139
+ return causes.at(-1) ?? lines[0] ?? "";
4140
+ }
4141
+ function createPrepStreamThrottle(emit2, opts = {}) {
4142
+ return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
4143
+ }
4144
+ function prepTemplatePath() {
4145
+ const here = dirname4(fileURLToPath3(import.meta.url));
4146
+ return join8(resolveSkillsRoot(here), "burner", "prepare-project.md");
4147
+ }
4148
+ async function realExecutePrepRun(ctx, config2, token, model) {
4149
+ const { project } = ctx;
4150
+ const branch = prepBranchName(newId("b").slice(2, 10));
4151
+ const toolchain = readRepoToolchain(project.repoPath);
4152
+ const pm = detectPackageManager(toolchain);
4153
+ const prepared = resolvePreparedSettings(config2, project);
4154
+ const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
4155
+ const workspaceMode = resolveBurnWorkspaceMode(config2);
4156
+ const mounts = [];
4157
+ if (config2.sandbox !== "noSandbox" && pm) {
4158
+ const mount = cacheMountFor(pm, burnCacheDir(pm));
4159
+ if (mount) {
4160
+ mkdirSync4(mount.hostPath, { recursive: true });
4161
+ mounts.push(mount);
4162
+ }
4163
+ }
4164
+ const template = readFileSync4(prepTemplatePath(), "utf8");
4165
+ const prompt = renderPrepPrompt(template, {
4166
+ REQUESTED_KEYS: buildRequestedKeysBlock(ctx.keys),
4167
+ SETUP_COMMAND: setupCommand ?? "(no install step detected)"
4168
+ });
4169
+ mkdirSync4(logsDir(), { recursive: true });
4170
+ const logFilePath = join8(logsDir(), `prep-${project.id}.log`);
4171
+ const throttle = createPrepStreamThrottle((e) => ctx.emitEvent(e));
4172
+ if (setupCommand) {
4173
+ ctx.emitEvent({
4174
+ type: "prep.setup",
4175
+ message: `installing deps before the agent starts: ${setupCommand}`
4176
+ });
4177
+ }
4178
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, setupCommand, pm) : setupCommand;
4179
+ const runOptions = {
4180
+ agent: buildBurnAgent(config2, token, model),
4181
+ sandbox: selectSandbox(config2, mounts),
4182
+ cwd: project.repoPath,
4183
+ prompt,
4184
+ branchStrategy: { type: "branch", branch, baseBranch: project.mainBranch },
4185
+ signal: ctx.signal,
4186
+ name: "prepare",
4187
+ maxIterations: 1,
4188
+ ...hookCommand ? {
4189
+ hooks: {
4190
+ sandbox: {
4191
+ onSandboxReady: [{ command: hookCommand, timeoutMs: PREP_SETUP_TIMEOUT_MS }]
4192
+ }
4193
+ }
4194
+ } : {},
4195
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
4196
+ };
4197
+ try {
4198
+ let result;
4199
+ try {
4200
+ result = await run2(runOptions);
4201
+ } catch (err) {
4202
+ throttle.flush();
4203
+ if (ctx.signal.aborted)
4204
+ throw err;
4205
+ const msg = err instanceof Error ? err.message : String(err);
4206
+ const salvaged = await readFindings(project.repoPath, branch);
4207
+ if (!salvaged)
4208
+ return { status: "failed", error: msg };
4209
+ ctx.emitEvent({
4210
+ type: "prep.salvaged",
4211
+ message: `the agent finished but sandcastle errored on teardown (${errorHeadline2(msg)}) \u2014 reading the findings it committed anyway`
4212
+ });
4213
+ return salvaged;
4214
+ }
4215
+ throttle.flush();
4216
+ if (result.commits.length === 0) {
4217
+ return {
4218
+ status: "failed",
4219
+ error: `agent produced no commits \u2014 no ${PREP_FINDINGS_PATH} landed`
4220
+ };
4221
+ }
4222
+ const read = await readFindings(project.repoPath, branch);
4223
+ return read ?? {
4224
+ status: "failed",
4225
+ error: `agent committed, but ${PREP_FINDINGS_PATH} is missing from ${branch}`
4226
+ };
4227
+ } finally {
4228
+ await deletePrepBranch(project.repoPath, branch);
4229
+ }
4230
+ }
4231
+ async function readFindings(repoPath, branch) {
4232
+ const raw = await readFileAtRef(repoPath, branch, PREP_FINDINGS_PATH);
4233
+ if (raw === undefined)
4234
+ return;
4235
+ try {
4236
+ const { findings, warnings } = parsePrepFindings(raw);
4237
+ return { status: "done", findings, warnings };
4238
+ } catch (e) {
4239
+ return { status: "failed", error: e instanceof Error ? e.message : String(e) };
4240
+ }
4241
+ }
4242
+ function resolvePrepDeps(project) {
4243
+ const config2 = loadConfig();
4244
+ const token = readTokenFromEnvFile(envPath());
4245
+ const model = resolveModel("prepare", config2, project);
4246
+ return {
4247
+ config: config2,
4248
+ hasAuthToken: token !== undefined,
4249
+ executePrepRun: (c) => realExecutePrepRun(c, config2, token, model)
4250
+ };
4251
+ }
4252
+ async function prepHeadSha(project) {
4253
+ return existsSync8(project.repoPath) ? headSha(project.repoPath, project.mainBranch) : undefined;
4254
+ }
4255
+ var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PREP_FINDINGS_PATH = ".runcastle/prep.json", PREP_SETUP_TIMEOUT_MS, RawFinding, RawFindings, PLACEHOLDERS, KEY_BRIEF;
4256
+ var init_project_prep = __esm(() => {
4257
+ init_src();
4258
+ init_config_load();
4259
+ init_paths();
4260
+ init_skills_root();
4261
+ init_git();
4262
+ init_ticket_burner();
4263
+ PREP_SETUP_TIMEOUT_MS = 15 * 60000;
4264
+ RawFinding = z3.union([
4265
+ z3.string(),
4266
+ z3.object({
4267
+ value: z3.string().nullish(),
4268
+ evidence: z3.string().nullish()
4269
+ })
4270
+ ]);
4271
+ RawFindings = z3.object({ notes: z3.string().nullish() }).catchall(z3.unknown());
4272
+ PLACEHOLDERS = ["REQUESTED_KEYS", "SETUP_COMMAND"];
4273
+ KEY_BRIEF = {
4274
+ setupCommand: "**`setupCommand`** \u2014 the command that takes a clean checkout to a state where the code builds and tests run: dependency install, plus any codegen or contract build that every task would otherwise discover it needed mid-flight (ORM client generation, protobuf, a `contracts:build` step). Run it and report what worked.",
4275
+ verifyCommands: "**`verifyCommands`** \u2014 the exact typecheck / test / lint commands for this repo, one per line. In a monorepo these must include the correct workspace filter names. Run each one and confirm it executes the thing it claims to.",
4276
+ knownFailures: '**`knownFailures`** \u2014 which tests are ALREADY failing on this checkout, before anything is changed. Run the full suite once and report the count plus the failing suite names. "0 known failures" is a real, useful answer.',
4277
+ devCommand: "**`devCommand`** \u2014 the command that starts this project's dev server on a developer's own machine. Read it from config; do not run it here.",
4278
+ dbResetCommand: "**`dbResetCommand`** \u2014 the command that rebuilds the dev database from the migrations currently in the working tree. Read it from config; do not run it here. Omit it entirely if this repo has no database."
4279
+ };
4280
+ });
4281
+
4282
+ // src/services/findings.ts
4283
+ import { and as and5, eq as eq5 } from "drizzle-orm";
4284
+ function isPreparedKey(key) {
4285
+ return PREPARED_SET.has(key);
4286
+ }
4287
+ function findingRow(ctx, projectId, key) {
4288
+ return ctx.db.select({
4289
+ source: projectFindings.source,
4290
+ evidence: projectFindings.evidence,
4291
+ establishedAt: projectFindings.establishedAt,
4292
+ establishedSha: projectFindings.establishedSha
4293
+ }).from(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).get();
4294
+ }
4295
+ function isOverwritable(ctx, projectId, key) {
4296
+ return findingRow(ctx, projectId, key)?.source !== "human";
4297
+ }
4298
+ function recordFinding(ctx, projectId, input) {
4299
+ const { key, value, source, evidence, establishedSha } = input;
4300
+ ctx.db.transaction((tx) => {
4301
+ tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq5(projects.id, projectId)).run();
4302
+ if (value === null) {
4303
+ tx.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
4304
+ return;
4305
+ }
4306
+ tx.insert(projectFindings).values({
4307
+ projectId,
4308
+ key,
4309
+ source,
4310
+ evidence: evidence ?? null,
4311
+ establishedAt: Date.now(),
4312
+ establishedSha: establishedSha ?? null
4313
+ }).onConflictDoUpdate({
4314
+ target: [projectFindings.projectId, projectFindings.key],
4315
+ set: {
4316
+ source,
4317
+ evidence: evidence ?? null,
4318
+ establishedAt: Date.now(),
4319
+ establishedSha: establishedSha ?? null
4320
+ }
4321
+ }).run();
4322
+ });
4323
+ }
4324
+ function recordHuman(ctx, projectId, key, value) {
4325
+ if (!isPreparedKey(key))
4326
+ return;
4327
+ const existing = findingRow(ctx, projectId, key);
4328
+ if (value === null) {
4329
+ if (existing) {
4330
+ ctx.db.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
4331
+ }
4332
+ return;
4333
+ }
4334
+ ctx.db.insert(projectFindings).values({
4335
+ projectId,
4336
+ key,
4337
+ source: "human",
4338
+ evidence: null,
4339
+ establishedAt: Date.now(),
4340
+ establishedSha: null
4341
+ }).onConflictDoUpdate({
4342
+ target: [projectFindings.projectId, projectFindings.key],
4343
+ set: { source: "human", evidence: null, establishedAt: Date.now(), establishedSha: null }
4344
+ }).run();
4345
+ }
4346
+ async function listFindings(ctx, project) {
4347
+ const rows = ctx.db.select().from(projectFindings).where(eq5(projectFindings.projectId, project.id)).all();
4348
+ const distances = new Map;
4349
+ for (const row of rows) {
4350
+ const sha = row.establishedSha;
4351
+ if (!sha || distances.has(sha))
4352
+ continue;
4353
+ distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
4354
+ }
4355
+ const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
4356
+ return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
4357
+ const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
4358
+ return {
4359
+ key: row.key,
4360
+ source: row.source,
4361
+ ...row.evidence ? { evidence: row.evidence } : {},
4362
+ establishedAt: row.establishedAt,
4363
+ ...row.establishedSha ? { establishedSha: row.establishedSha } : {},
4364
+ ...staleCommits !== undefined ? { staleCommits } : {}
4365
+ };
4366
+ });
4367
+ }
4368
+ function unsetPreparedKeys(project) {
4369
+ return PREPARED_KEYS.filter((key) => {
4370
+ const value = project[key];
4371
+ return value === undefined || value.trim() === "";
4372
+ });
4373
+ }
4374
+ var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET;
4375
+ var init_findings = __esm(() => {
4376
+ init_src();
4377
+ init_schema();
4378
+ init_git();
4379
+ VALUE_COLUMN = {
4380
+ setupCommand: projects.setupCommand,
4381
+ verifyCommands: projects.verifyCommands,
4382
+ knownFailures: projects.knownFailures,
4383
+ devCommand: projects.devCommand,
4384
+ dbResetCommand: projects.dbResetCommand
4385
+ };
4386
+ COLUMN_NAME = {
4387
+ setupCommand: "setupCommand",
4388
+ verifyCommands: "verifyCommands",
4389
+ knownFailures: "knownFailures",
4390
+ devCommand: "devCommand",
4391
+ dbResetCommand: "dbResetCommand"
4392
+ };
4393
+ PREPARED_SET = new Set(PREPARED_KEYS);
4394
+ });
4395
+
4396
+ // src/services/prep.ts
4397
+ import { desc as desc3, eq as eq6 } from "drizzle-orm";
4398
+ function isPreparing(projectId) {
4399
+ return controllers.has(projectId);
4400
+ }
4401
+ function keysToPrepare(ctx, project, opts = {}) {
4402
+ const unset = new Set(unsetPreparedKeys(project));
4403
+ const candidates = opts.keys ?? (opts.refresh ? PREPARED_KEYS : [...unset]);
4404
+ return candidates.filter((key) => (opts.refresh || unset.has(key)) && isOverwritable(ctx, project.id, key));
4405
+ }
4406
+ async function startPrep(ctx, projectId, opts = {}) {
4407
+ const project = requireProjectById(ctx, projectId);
4408
+ if (controllers.has(projectId)) {
4409
+ throw new InvalidInputError(`${project.name} is already being prepared`);
4410
+ }
4411
+ const keys = keysToPrepare(ctx, project, opts);
4412
+ const prepId = newId("prep");
4413
+ const headSha2 = await prepHeadSha(project);
4414
+ ctx.db.insert(projectPreps).values({
4415
+ id: prepId,
4416
+ projectId,
4417
+ status: "running",
4418
+ startedAt: Date.now(),
4419
+ endedAt: null,
4420
+ summary: null,
4421
+ headSha: headSha2 ?? null
4422
+ }).run();
4423
+ emitProject(ctx, projectId, {
4424
+ type: "prep.run.started",
4425
+ message: keys.length === 0 ? `preparing ${project.name} \u2014 nothing left to establish` : `preparing ${project.name} \u2014 ${keys.length} field(s) to establish`,
4426
+ data: { prepId, keys }
4427
+ });
4428
+ const controller = new AbortController;
4429
+ controllers.set(projectId, { runId: prepId, controller });
4430
+ const prepCtx = {
4431
+ project,
4432
+ keys,
4433
+ emitEvent: (e) => {
4434
+ emitProject(ctx, projectId, { type: e.type, message: e.message, data: e.data });
4435
+ },
4436
+ signal: controller.signal
4437
+ };
4438
+ const deps = opts.deps ?? resolvePrepDeps(project);
4439
+ const done = finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2);
4440
+ return { prepId, keys, done };
4441
+ }
4442
+ function cancelPrep(projectId) {
4443
+ controllers.get(projectId)?.controller.abort();
4444
+ }
4445
+ async function finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2) {
4446
+ let status = "failed";
4447
+ let summary = "preparation failed";
4448
+ let findings;
4449
+ try {
4450
+ const result = await prepRun(prepCtx, deps);
4451
+ status = result.status === "succeeded" ? "succeeded" : "failed";
4452
+ summary = result.summary;
4453
+ findings = result.findings;
4454
+ } catch (e) {
4455
+ if (controller.signal.aborted) {
4456
+ status = "cancelled";
4457
+ summary = "preparation cancelled";
4458
+ } else {
4459
+ status = "failed";
4460
+ summary = e instanceof Error ? e.message : "preparation failed";
4461
+ }
4462
+ } finally {
4463
+ controllers.delete(project.id);
4464
+ }
4465
+ const applied = findings ? applyFindings(ctx, project, findings, headSha2) : [];
4466
+ ctx.db.update(projectPreps).set({ status, endedAt: Date.now(), summary }).where(eq6(projectPreps.id, prepId)).run();
4467
+ emitProject(ctx, project.id, {
4468
+ type: "prep.run.finished",
4469
+ message: `preparation ${status}: ${summary}`,
4470
+ data: { prepId, status, summary, applied }
4471
+ });
4472
+ }
4473
+ function applyFindings(ctx, project, findings, headSha2) {
4474
+ const applied = [];
4475
+ for (const [key, finding] of Object.entries(findings.values)) {
4476
+ if (!isOverwritable(ctx, project.id, key)) {
4477
+ emitProject(ctx, project.id, {
4478
+ type: "prep.skipped_field",
4479
+ message: `kept your ${key} \u2014 preparation proposed "${truncate(finding.value)}" but you set this by hand`,
4480
+ data: { key, proposed: finding.value }
4481
+ });
4482
+ continue;
4483
+ }
4484
+ recordFinding(ctx, project.id, {
4485
+ key,
4486
+ value: finding.value,
4487
+ source: "prep",
4488
+ ...finding.evidence ? { evidence: finding.evidence } : {},
4489
+ ...headSha2 ? { establishedSha: headSha2 } : {}
4490
+ });
4491
+ applied.push(key);
4492
+ emitProject(ctx, project.id, {
4493
+ type: "prep.established",
4494
+ message: `${key}: ${truncate(finding.value)}`,
4495
+ data: { key, value: finding.value, evidence: finding.evidence }
4496
+ });
4497
+ }
4498
+ if (findings.notes) {
4499
+ emitProject(ctx, project.id, {
4500
+ type: "prep.notes",
4501
+ message: `preparation notes: ${findings.notes}`,
4502
+ data: { notes: findings.notes }
4503
+ });
4504
+ }
4505
+ return applied;
4506
+ }
4507
+ function truncate(value, max = 120) {
4508
+ const oneLine = value.replace(/\s*\n\s*/g, " \xB7 ").trim();
4509
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
4510
+ }
4511
+ function latestPrep(ctx, projectId) {
4512
+ const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc3(projectPreps.startedAt)).get();
4513
+ if (!row)
4514
+ return null;
4515
+ return {
4516
+ id: row.id,
4517
+ projectId: row.projectId,
4518
+ status: row.status,
4519
+ startedAt: row.startedAt,
4520
+ ...row.endedAt !== null ? { endedAt: row.endedAt } : {},
4521
+ ...row.summary !== null ? { summary: row.summary } : {},
4522
+ ...row.headSha !== null ? { headSha: row.headSha } : {}
4523
+ };
4524
+ }
4525
+ function reconcilePreps(ctx) {
4526
+ const stale = ctx.db.select({ id: projectPreps.id, projectId: projectPreps.projectId }).from(projectPreps).where(eq6(projectPreps.status, "running")).all().filter((row) => !controllers.has(row.projectId));
4527
+ for (const row of stale) {
4528
+ ctx.db.update(projectPreps).set({ status: "failed", endedAt: Date.now(), summary: "server stopped while preparing" }).where(eq6(projectPreps.id, row.id)).run();
4529
+ }
4530
+ return stale.length;
4531
+ }
4532
+ var controllers;
4533
+ var init_prep = __esm(() => {
4534
+ init_src();
4535
+ init_schema();
4536
+ init_errors();
4537
+ init_project_prep();
4538
+ init_events();
4539
+ init_findings();
4540
+ init_repo();
4541
+ controllers = new Map;
4542
+ });
4543
+
4544
+ // src/launcher/runtime.ts
4545
+ function setRuntimeCtx(ctx) {
4546
+ current = ctx;
4547
+ }
4548
+ async function getRuntimeCtx() {
4549
+ if (current)
4550
+ return current;
4551
+ const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
4552
+ Promise.resolve().then(() => (init_client(), exports_client)),
4553
+ Promise.resolve().then(() => (init_paths(), exports_paths)),
4554
+ Promise.resolve().then(() => (init_config_load(), exports_config_load))
4555
+ ]);
4556
+ const db = createDb2(dbPath2());
4557
+ current = { db, config: loadConfig2() };
4558
+ return current;
4559
+ }
4560
+ var current = null;
4561
+
4562
+ // src/services/feature-docs.ts
4563
+ import { existsSync as existsSync9 } from "fs";
4564
+ import { join as join9 } from "path";
4565
+ function featureDocsDir(project, feature) {
4566
+ const relSegments = featureDocsRel(feature.slug).split("/");
4567
+ const worktree = worktreeDir(project.id, feature.slug);
4568
+ const base = existsSync9(worktree) ? worktree : project.repoPath;
4569
+ return join9(base, ...relSegments);
4570
+ }
4571
+ function featureDocPath(project, feature, fileName) {
4572
+ return join9(featureDocsDir(project, feature), fileName);
4573
+ }
4574
+ var init_feature_docs = __esm(() => {
4575
+ init_paths();
4576
+ });
4577
+
4578
+ // src/services/tickets.ts
4579
+ import { and as and6, asc as asc3, eq as eq7 } from "drizzle-orm";
4580
+ function rowToTicket(row) {
4581
+ return {
4582
+ id: row.id,
4583
+ featureId: row.featureId,
4584
+ seq: row.seq,
4585
+ title: row.title,
4586
+ goal: row.goal,
4587
+ context: row.context,
4588
+ acceptanceCriteria: row.acceptanceCriteria,
4589
+ seams: row.seams,
4590
+ blockedBy: row.blockedBy,
4591
+ status: row.status,
4592
+ commits: row.commits,
4593
+ error: row.error ?? undefined,
4594
+ attemptBranch: row.attemptBranch ?? undefined,
4595
+ conflictFiles: row.conflictFiles ?? undefined
4596
+ };
4597
+ }
4598
+ function resolveBlocking(inputs, startSeq) {
4599
+ try {
4600
+ return resolveBatchBlocking(inputs, { startSeq });
4601
+ } catch (e) {
4602
+ if (e instanceof BlockingEdgeError)
4603
+ throw new InvalidInputError(e.message);
4604
+ throw e;
4605
+ }
4606
+ }
4607
+ function listByFeature2(ctx, featureId) {
4608
+ return ctx.db.select().from(tickets).where(eq7(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
4609
+ }
4610
+ function storeTickets(ctx, featureId, inputs) {
4611
+ if (inputs.length === 0)
4612
+ return [];
4613
+ const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
4614
+ const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
4615
+ const resolved = resolveBlocking(inputs, startSeq);
4616
+ const rows = inputs.map((t, i) => ({
4617
+ id: newId("tkt"),
4618
+ featureId,
4619
+ seq: resolved[i].seq,
4620
+ title: t.title,
4621
+ goal: t.goal,
4622
+ context: t.context,
4623
+ acceptanceCriteria: t.acceptanceCriteria,
4624
+ seams: t.seams,
4625
+ blockedBy: resolved[i].blockedBy,
4626
+ status: "pending",
4627
+ commits: [],
4628
+ error: null,
4629
+ attemptBranch: null,
4630
+ conflictFiles: null
4631
+ }));
4632
+ ctx.db.insert(tickets).values(rows).run();
4633
+ emit(ctx, featureId, {
4634
+ type: "tickets.stored",
4635
+ message: `${rows.length} ticket(s) stored`,
4636
+ data: { count: rows.length, seqs: rows.map((r) => r.seq) }
4637
+ });
4638
+ return rows.map(rowToTicket);
4639
+ }
4640
+ function getTicket(ctx, id) {
4641
+ const row = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
4642
+ if (!row)
4643
+ throw new NotFoundError(`ticket ${id} not found`);
4644
+ return rowToTicket(row);
4645
+ }
4646
+ function assertMutable(t, verb) {
4647
+ if (!MUTABLE_STATUSES.has(t.status)) {
4648
+ throw new InvalidInputError(`cannot ${verb} ticket ${t.seq} \u2014 it is ${t.status}; only pending or failed tickets can be ${verb}${verb.endsWith("l") ? "led" : "ed"}`);
4649
+ }
4650
+ }
4651
+ function editTicket(ctx, id, patch) {
4652
+ const current2 = getTicket(ctx, id);
4653
+ assertMutable(current2, "edit");
4654
+ const set = {};
4655
+ if (patch.title !== undefined)
4656
+ set.title = patch.title;
4657
+ if (patch.goal !== undefined)
4658
+ set.goal = patch.goal;
4659
+ if (patch.context !== undefined)
4660
+ set.context = patch.context;
4661
+ if (patch.acceptanceCriteria !== undefined)
4662
+ set.acceptanceCriteria = patch.acceptanceCriteria;
4663
+ if (patch.seams !== undefined)
4664
+ set.seams = patch.seams;
4665
+ if (Object.keys(set).length === 0) {
4666
+ throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
4667
+ }
4668
+ ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
4669
+ emit(ctx, current2.featureId, {
4670
+ type: "ticket.edited",
4671
+ message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
4672
+ ticketId: id,
4673
+ data: { fields: Object.keys(set) }
4674
+ });
4675
+ return getTicket(ctx, id);
4676
+ }
4677
+ function cancelTicket(ctx, id, reason) {
4678
+ const current2 = getTicket(ctx, id);
4679
+ assertMutable(current2, "cancel");
4680
+ ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq7(tickets.id, id)).run();
4681
+ emit(ctx, current2.featureId, {
4682
+ type: "ticket.cancelled",
4683
+ message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
4684
+ ticketId: id,
4685
+ data: { reason: reason ?? null }
4686
+ });
4687
+ return getTicket(ctx, id);
4688
+ }
4689
+ function sweepOrphanedBurning(ctx, featureId, reason) {
4690
+ const orphaned = ctx.db.select().from(tickets).where(and6(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
4691
+ for (const t of orphaned) {
4692
+ updateTicket(ctx, t.id, { status: "failed", error: reason });
4693
+ emit(ctx, featureId, {
4694
+ type: "ticket.failed",
4695
+ message: `ticket ${t.seq} failed: ${reason}`,
4696
+ ticketId: t.id,
4697
+ data: { error: reason, orphaned: true }
4698
+ });
4699
+ }
4700
+ return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
4701
+ }
4702
+ function updateTicket(ctx, id, patch) {
4703
+ const current2 = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
4704
+ if (!current2)
4705
+ throw new NotFoundError(`ticket ${id} not found`);
4706
+ const set = {};
4707
+ if (patch.status !== undefined)
4708
+ set.status = patch.status;
4709
+ if (patch.commits !== undefined)
4710
+ set.commits = patch.commits;
4711
+ if (patch.error !== undefined)
4712
+ set.error = patch.error;
4713
+ if (patch.attemptBranch !== undefined)
4714
+ set.attemptBranch = patch.attemptBranch;
4715
+ if (patch.conflictFiles !== undefined)
4716
+ set.conflictFiles = patch.conflictFiles;
4717
+ ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
4718
+ emit(ctx, current2.featureId, {
4719
+ type: "ticket.updated",
4720
+ message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
4721
+ ticketId: id,
4722
+ data: patch
4723
+ });
4724
+ const updated = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
4725
+ return rowToTicket(updated);
4726
+ }
4727
+ var MUTABLE_STATUSES;
4728
+ var init_tickets = __esm(() => {
4729
+ init_src();
4730
+ init_schema();
4731
+ init_errors();
4732
+ init_events();
4733
+ MUTABLE_STATUSES = new Set(["pending", "failed"]);
4734
+ });
4735
+
4736
+ // src/services/gates.ts
4737
+ import { existsSync as existsSync10 } from "fs";
4738
+ function checkGate(ctx, check, feature) {
4739
+ switch (check) {
4740
+ case "decisions-file-exists":
4741
+ return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
4742
+ case "all-waypoints-terminal": {
4743
+ const wps = listByFeature(ctx, feature.id);
4744
+ if (wps.length === 0) {
4745
+ return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
4746
+ }
4747
+ const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
4748
+ return open.length === 0 ? { satisfied: true } : {
4749
+ satisfied: false,
4750
+ reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
4751
+ };
4752
+ }
4753
+ case "spec-file-exists":
4754
+ return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
4755
+ case "tickets-approved": {
4756
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
4757
+ return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
4758
+ }
4759
+ case "all-tickets-terminal": {
4760
+ const tickets2 = listByFeature2(ctx, feature.id);
4761
+ if (tickets2.length === 0)
4762
+ return { satisfied: false, reason: "no tickets have run yet" };
4763
+ const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
4764
+ return open.length === 0 ? { satisfied: true } : {
4765
+ satisfied: false,
4766
+ reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
4767
+ };
4768
+ }
4769
+ case "human-merge":
4770
+ return { satisfied: false, reason: "use the Merge button to ship" };
4771
+ }
4772
+ }
4773
+ function notYetTerminal(noun, statuses, order) {
4774
+ const counts = new Map;
4775
+ for (const s of statuses)
4776
+ counts.set(s, (counts.get(s) ?? 0) + 1);
4777
+ const ordered = [
4778
+ ...order.filter((s) => counts.has(s)),
4779
+ ...[...counts.keys()].filter((s) => !order.includes(s))
4780
+ ];
4781
+ const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
4782
+ return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
4783
+ }
4784
+ function fileGate(ctx, feature, fileName, reason) {
4785
+ const project = projectForFeature(ctx, feature);
4786
+ return existsSync10(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
4787
+ }
4788
+ function overrideGate(ctx, featureId, gate, reason) {
4789
+ const feature = getFeatureRow(ctx, featureId);
4790
+ ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
4791
+ emit(ctx, featureId, {
4792
+ type: "gate.overridden",
4793
+ message: `gate ${gate} overridden: ${reason}`,
4794
+ data: { gate, reason }
4795
+ });
4796
+ const next = nextPhase(feature);
4797
+ if (next)
4798
+ return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
4799
+ return feature;
4800
+ }
4801
+ var init_gates = __esm(() => {
4802
+ init_src();
4803
+ init_schema();
4804
+ init_events();
4805
+ init_feature_docs();
4806
+ init_repo();
4807
+ init_tickets();
4808
+ init_waypoints();
4809
+ });
4810
+
4811
+ // src/services/knowledge.ts
4812
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync } from "fs";
4813
+ import { join as join10, relative, resolve as resolve5, sep } from "path";
4814
+ function scaffoldDocs(ctx, feature) {
4815
+ const project = projectForFeature(ctx, feature);
4816
+ const dir = featureDocsDir(project, feature);
4817
+ mkdirSync5(dir, { recursive: true });
4818
+ const briefPath = featureDocPath(project, feature, "brief.md");
4819
+ if (!existsSync11(briefPath)) {
4820
+ const created = new Date().toISOString();
4821
+ const brief = [
4822
+ `# ${feature.title}`,
4823
+ "",
4824
+ feature.oneLiner,
4825
+ "",
4826
+ `- Slug: ${feature.slug}`,
4827
+ `- Created: ${created}`,
4828
+ ""
4829
+ ].join(`
4830
+ `);
4831
+ writeFileSync(briefPath, brief, "utf8");
4832
+ }
4833
+ if (feature.mapped) {
4834
+ scaffoldMapDoc(project, feature);
4835
+ }
4836
+ emit(ctx, feature.id, {
4837
+ type: "docs.scaffolded",
4838
+ message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
4839
+ });
4840
+ }
4841
+ function scaffoldMapDoc(project, feature, seed) {
4842
+ mkdirSync5(featureDocsDir(project, feature), { recursive: true });
4843
+ const mapPath = featureDocPath(project, feature, "map.md");
4844
+ if (existsSync11(mapPath))
4845
+ return;
4846
+ writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
4847
+ }
4848
+ function mapDocBody(feature, seed) {
4849
+ const seeded = {
4850
+ Destination: seed?.destination?.trim(),
4851
+ Notes: seed?.notes?.trim()
4060
4852
  };
4853
+ const lines = [`# ${feature.title} \u2014 map`, ""];
4854
+ for (const section of MAP_SECTIONS) {
4855
+ lines.push(`## ${section}`, "");
4856
+ const body = seeded[section];
4857
+ if (body)
4858
+ lines.push(body, "");
4859
+ }
4860
+ return lines.join(`
4861
+ `);
4061
4862
  }
4062
- function readTokenFromEnvFile(path) {
4063
- let fromFile;
4064
- if (existsSync10(path)) {
4065
- try {
4066
- fromFile = parseEnvFile(readFileSync4(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
4067
- } catch {
4068
- fromFile = undefined;
4069
- }
4863
+ function listDocs(ctx, feature) {
4864
+ const project = projectForFeature(ctx, feature);
4865
+ const dir = featureDocsDir(project, feature);
4866
+ if (!existsSync11(dir))
4867
+ return [];
4868
+ return readdirSync3(dir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => ({ relPath: e.name, title: readTitle(join10(dir, e.name), e.name) })).sort((a, b) => a.relPath.localeCompare(b.relPath));
4869
+ }
4870
+ function readDoc(ctx, feature, relPath) {
4871
+ const project = projectForFeature(ctx, feature);
4872
+ const dir = featureDocsDir(project, feature);
4873
+ const root = resolve5(dir);
4874
+ const target = resolve5(dir, relPath);
4875
+ const rel = relative(root, target);
4876
+ if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
4877
+ throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
4070
4878
  }
4071
- const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
4072
- return token && token.length > 0 ? token : undefined;
4879
+ if (!existsSync11(target))
4880
+ throw new NotFoundError(`doc not found: ${relPath}`);
4881
+ return { content: readFileSync5(target, "utf8") };
4073
4882
  }
4074
- 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;
4075
- var init_ticket_burner = __esm(() => {
4076
- init_src();
4077
- init_config_load();
4078
- init_paths();
4079
- init_skills_root();
4080
- init_agent_stream();
4081
- init_git();
4082
- init_burn_guard();
4083
- PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"];
4084
- PM_CACHE_SANDBOX_PATHS = {
4085
- bun: "~/.bun/install/cache",
4086
- yarn: "~/.cache/yarn",
4087
- npm: "~/.npm"
4088
- };
4089
- RUN = String.raw`(?![\w./-])`;
4090
- BASH_PATTERNS = [
4091
- ["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`)],
4092
- ["typecheck", /\btsc\b|\btypecheck\b|\bmypy\b|\bcargo check\b/],
4093
- ["build", new RegExp(String.raw`\bbuild${RUN}|\bprisma\s+(generate|migrate)\b|\bcodegen${RUN}`)],
4094
- ["lint", new RegExp(String.raw`\b(eslint|prettier|ruff|biome|lint|format)${RUN}`)],
4095
- ["install", /\b(pnpm|npm|yarn|bun|corepack)\s+\w*\s*install\b|\bnpm ci\b/],
4096
- ["git", /\bgit\b/],
4097
- ["search", /\b(grep|rg|ag|find)\b/],
4098
- ["file-read", /\b(cat|sed|head|tail|less|wc|ls)\b/],
4099
- ["file-edit", /<<\s*['"]?(PY|EOF|SH|JS|TS)\b|\bpython3?\s+-\s*<</]
4100
- ];
4101
- TOOL_NAME_CATEGORY = {
4102
- Read: "file-read",
4103
- NotebookRead: "file-read",
4104
- Grep: "search",
4105
- Glob: "search",
4106
- Edit: "file-edit",
4107
- Write: "file-edit",
4108
- NotebookEdit: "file-edit"
4109
- };
4110
- MAX_ATTRIBUTABLE_GAP_MS = 20 * 60000;
4111
- BURN_WORKTREE_PATH = /[\\/]\.sandcastle[\\/]worktrees[\\/]/i;
4112
- WORKTREE_REMOVAL_FAILURES = [
4113
- /failed to delete/i,
4114
- /directory not empty|enotempty/i,
4115
- /unable to (unlink|delete)/i,
4116
- /is not a working tree/i,
4117
- /resource busy|ebusy|eperm|eacces/i
4118
- ];
4119
- FATAL_ERROR_PATTERNS = [
4120
- /invalid (api key|x-api-key)/i,
4121
- /authentication|unauthorized|permission denied/i,
4122
- /credit balance|billing/i,
4123
- /oauth token|setup-token/i,
4124
- /issue with the selected model|model not found|unknown model/i,
4125
- /does not support resumeSession|resumeSession .* not found/i
4126
- ];
4127
- RETRYABLE_ERROR_PATTERNS = [
4128
- /exited with code \d+/i,
4129
- /idle timeout|AgentIdleTimeout/i,
4130
- /connection (closed|error|refused|reset)|socket hang up|fetch failed/i,
4131
- /econnreset|etimedout|econnrefused|epipe|eai_again/i,
4132
- /overloaded|rate.?limit|too many requests/i,
4133
- /internal server error|service unavailable|bad gateway|gateway timeout/i,
4134
- /\bapi error\b/i,
4135
- /session capture failed/i
4883
+ function readTitle(path, fallback) {
4884
+ try {
4885
+ const firstLine = readFileSync5(path, "utf8").split(`
4886
+ `, 1)[0] ?? "";
4887
+ const heading = firstLine.match(/^#\s+(.+)$/);
4888
+ return heading ? heading[1].trim() : fallback;
4889
+ } catch {
4890
+ return fallback;
4891
+ }
4892
+ }
4893
+ var MAP_SECTIONS;
4894
+ var init_knowledge = __esm(() => {
4895
+ init_errors();
4896
+ init_events();
4897
+ init_feature_docs();
4898
+ init_repo();
4899
+ MAP_SECTIONS = [
4900
+ "Destination",
4901
+ "Notes",
4902
+ "Not yet specified",
4903
+ "Out of scope"
4136
4904
  ];
4137
- activeTicketAborts = new Map;
4138
- SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
4139
- ticketBurner = {
4140
- id: "ticket-burner",
4141
- run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
4142
- };
4905
+ });
4906
+
4907
+ // src/pty/end-session.ts
4908
+ function endSession(ctx, sessionId) {
4909
+ const registry = ptyRegistry();
4910
+ const killed = registry.kill(sessionId);
4911
+ registry.remove(sessionId);
4912
+ const session = markSessionEnded(ctx, sessionId);
4913
+ if (session) {
4914
+ releaseForSession(ctx, sessionId);
4915
+ emit(ctx, session.featureId, {
4916
+ type: "session.ended",
4917
+ message: "session ended by user",
4918
+ data: { sessionId, killed }
4919
+ });
4920
+ } else {
4921
+ const existing = getSessionRow(ctx, sessionId);
4922
+ if (!existing)
4923
+ return { sessionId, ok: false, killed };
4924
+ }
4925
+ return { sessionId, ok: true, killed };
4926
+ }
4927
+ var init_end_session = __esm(() => {
4928
+ init_sessions();
4929
+ init_events();
4930
+ init_waypoints();
4931
+ init_registry();
4143
4932
  });
4144
4933
 
4145
4934
  // src/workflows/research.ts
4146
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
4147
- import { dirname as dirname4, join as join10 } from "path";
4148
- import { fileURLToPath as fileURLToPath3 } from "url";
4149
- import { run as run2 } from "@ai-hero/sandcastle";
4935
+ import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync6, readdirSync as readdirSync4 } from "fs";
4936
+ import { dirname as dirname5, join as join11 } from "path";
4937
+ import { fileURLToPath as fileURLToPath4 } from "url";
4938
+ import { run as run3 } from "@ai-hero/sandcastle";
4150
4939
  import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
4151
4940
  import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
4152
4941
  function waypointSlug(waypoint) {
@@ -4163,7 +4952,7 @@ async function researchRun(ctx, deps) {
4163
4952
  return { status: "failed", summary: "no waypoint to research" };
4164
4953
  }
4165
4954
  if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
4166
- ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
4955
+ ctx.emitEvent({ type: AUTH_MISSING_EVENT3, message: AUTH_MISSING_MESSAGE3 });
4167
4956
  return { status: "failed", summary: "research aborted: auth token missing" };
4168
4957
  }
4169
4958
  ctx.emitEvent({
@@ -4184,12 +4973,12 @@ async function researchRun(ctx, deps) {
4184
4973
  }
4185
4974
  ctx.emitEvent({
4186
4975
  type: "research.failed",
4187
- message: `waypoint ${waypoint.seq} research failed: ${errorHeadline2(outcome.error)}`,
4976
+ message: `waypoint ${waypoint.seq} research failed: ${errorHeadline3(outcome.error)}`,
4188
4977
  data: { waypointId: waypoint.id, error: outcome.error }
4189
4978
  });
4190
4979
  return { status: "failed", summary: outcome.error };
4191
4980
  }
4192
- function errorHeadline2(s) {
4981
+ function errorHeadline3(s) {
4193
4982
  const lines = s.split(`
4194
4983
  `).map((l) => l.trim()).filter(Boolean);
4195
4984
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
@@ -4200,7 +4989,7 @@ function createResearchStreamThrottle(emit2, opts = {}) {
4200
4989
  }
4201
4990
  function renderResearchPrompt(template, values) {
4202
4991
  let out = template;
4203
- for (const key of PLACEHOLDERS) {
4992
+ for (const key of PLACEHOLDERS2) {
4204
4993
  out = out.split(`{{${key}}}`).join(values[key]);
4205
4994
  }
4206
4995
  return out;
@@ -4215,32 +5004,32 @@ function interpretResearchResult(result, docRelPath) {
4215
5004
  return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
4216
5005
  }
4217
5006
  function researchTemplatePath() {
4218
- const here = dirname4(fileURLToPath3(import.meta.url));
4219
- return join10(resolveSkillsRoot(here), "burner", "research-waypoint.md");
5007
+ const here = dirname5(fileURLToPath4(import.meta.url));
5008
+ return join11(resolveSkillsRoot(here), "burner", "research-waypoint.md");
4220
5009
  }
4221
5010
  function readDocsDigestFromDisk2(projectId, slug) {
4222
5011
  const worktree = worktreeDir(projectId, slug);
4223
- if (!existsSync11(worktree))
5012
+ if (!existsSync12(worktree))
4224
5013
  return "_No talk worktree on disk \u2014 docs digest skipped._";
4225
- const docsDir = join10(worktree, ...featureDocsRel(slug).split("/"));
4226
- if (!existsSync11(docsDir))
5014
+ const docsDir = join11(worktree, ...featureDocsRel(slug).split("/"));
5015
+ if (!existsSync12(docsDir))
4227
5016
  return "_No docs/features dir in the talk worktree \u2014 docs digest skipped._";
4228
- const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync5(join10(docsDir, e.name), "utf8") }));
5017
+ const files = readdirSync4(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync6(join11(docsDir, e.name), "utf8") }));
4229
5018
  return buildDocsDigest(files);
4230
5019
  }
4231
5020
  async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4232
5021
  const { project, feature } = ctx;
4233
5022
  const docRelPath = researchDocRel(feature.slug, waypoint);
4234
5023
  const tempBranch = researchBranchName(feature.slug, waypoint.seq, newId("b").slice(2, 10));
4235
- const template = readFileSync5(researchTemplatePath(), "utf8");
5024
+ const template = readFileSync6(researchTemplatePath(), "utf8");
4236
5025
  const prompt = renderResearchPrompt(template, {
4237
5026
  WAYPOINT_JSON: buildWaypointJson(waypoint),
4238
5027
  FEATURE_BRIEF: buildFeatureBrief(feature),
4239
5028
  DOCS_DIGEST: readDocsDigestFromDisk2(project.id, feature.slug),
4240
5029
  RESEARCH_DOC_PATH: docRelPath
4241
5030
  });
4242
- mkdirSync5(logsDir(), { recursive: true });
4243
- const logFilePath = join10(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
5031
+ mkdirSync6(logsDir(), { recursive: true });
5032
+ const logFilePath = join11(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
4244
5033
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
4245
5034
  const runOptions = {
4246
5035
  agent: buildBurnAgent(config2, token, model),
@@ -4255,7 +5044,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4255
5044
  let result;
4256
5045
  let salvaged = [];
4257
5046
  try {
4258
- result = await run2(runOptions);
5047
+ result = await run3(runOptions);
4259
5048
  } catch (err) {
4260
5049
  throttle.flush();
4261
5050
  if (ctx.signal.aborted)
@@ -4267,7 +5056,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4267
5056
  const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
4268
5057
  ctx.emitEvent({
4269
5058
  type: "burn.worktree.teardown-failed",
4270
- message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline2(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
5059
+ message: `waypoint ${waypoint.seq}: agent finished but sandcastle could not remove its worktree (${errorHeadline3(msg)})${removed ? " \u2014 cleaned up" : " \u2014 left on disk"}; landing the ${salvaged.length} commit(s) anyway`,
4271
5060
  data: { tempBranch, error: msg, cleanedUp: removed }
4272
5061
  });
4273
5062
  }
@@ -4280,7 +5069,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4280
5069
  const detail = merge.error ?? "merge failed";
4281
5070
  ctx.emitEvent({
4282
5071
  type: merge.conflict ? "merge.conflict.needs-human" : "research.merge_failed",
4283
- message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${errorHeadline2(detail)} \u2014 the branch is preserved for manual recovery`,
5072
+ message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${errorHeadline3(detail)} \u2014 the branch is preserved for manual recovery`,
4284
5073
  data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
4285
5074
  });
4286
5075
  return {
@@ -4302,9 +5091,9 @@ function resolveResearchDeps(ctx) {
4302
5091
  }
4303
5092
  function readTokenFromEnvFile2(path) {
4304
5093
  let fromFile;
4305
- if (existsSync11(path)) {
5094
+ if (existsSync12(path)) {
4306
5095
  try {
4307
- fromFile = parseEnvFile(readFileSync5(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
5096
+ fromFile = parseEnvFile(readFileSync6(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
4308
5097
  } catch {
4309
5098
  fromFile = undefined;
4310
5099
  }
@@ -4312,7 +5101,7 @@ function readTokenFromEnvFile2(path) {
4312
5101
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
4313
5102
  return token && token.length > 0 ? token : undefined;
4314
5103
  }
4315
- var AUTH_MISSING_EVENT2 = "auth.missing", AUTH_MISSING_MESSAGE2 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, research;
5104
+ var AUTH_MISSING_EVENT3 = "auth.missing", AUTH_MISSING_MESSAGE3 = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS2, research;
4316
5105
  var init_research = __esm(() => {
4317
5106
  init_src();
4318
5107
  init_config_load();
@@ -4320,7 +5109,7 @@ var init_research = __esm(() => {
4320
5109
  init_skills_root();
4321
5110
  init_git();
4322
5111
  init_ticket_burner();
4323
- PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
5112
+ PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
4324
5113
  research = {
4325
5114
  id: "research",
4326
5115
  run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
@@ -4342,12 +5131,12 @@ var init_registry2 = __esm(() => {
4342
5131
  });
4343
5132
 
4344
5133
  // src/workflows/runner.ts
4345
- import { eq as eq6 } from "drizzle-orm";
5134
+ import { eq as eq8 } from "drizzle-orm";
4346
5135
  function workflowClaimsFeatureBranch(workflowId) {
4347
5136
  return BRANCH_CLAIMING.has(workflowId);
4348
5137
  }
4349
5138
  function isRunActive(runId) {
4350
- return controllers.has(runId);
5139
+ return controllers2.has(runId);
4351
5140
  }
4352
5141
  async function startRun(ctx, featureId, workflowId, opts = {}) {
4353
5142
  const feature = getFeatureRow(ctx, featureId);
@@ -4371,7 +5160,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4371
5160
  claim(ctx, opts.claimWaypointId, runId);
4372
5161
  } catch (e) {
4373
5162
  const summary = e instanceof Error ? e.message : "claim failed";
4374
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
5163
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
4375
5164
  throw e;
4376
5165
  }
4377
5166
  }
@@ -4382,7 +5171,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4382
5171
  data: { workflow: workflowId }
4383
5172
  });
4384
5173
  const controller = new AbortController;
4385
- controllers.set(runId, controller);
5174
+ controllers2.set(runId, controller);
4386
5175
  const talkWorktree = worktreeDir(project.id, feature.slug);
4387
5176
  const talkDetached = workflowClaimsFeatureBranch(workflowId) ? await detachWorktree(talkWorktree) : false;
4388
5177
  const wctx = {
@@ -4415,7 +5204,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4415
5204
  return { runId, done };
4416
5205
  }
4417
5206
  function cancelRun(runId) {
4418
- controllers.get(runId)?.abort();
5207
+ controllers2.get(runId)?.abort();
4419
5208
  }
4420
5209
  async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
4421
5210
  let status = "failed";
@@ -4434,9 +5223,9 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
4434
5223
  }
4435
5224
  emit(ctx, featureId, { type: "run.error", message: summary, runId });
4436
5225
  } finally {
4437
- controllers.delete(runId);
5226
+ controllers2.delete(runId);
4438
5227
  }
4439
- ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
5228
+ ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
4440
5229
  releaseForSession(ctx, runId);
4441
5230
  if (workflowClaimsFeatureBranch(workflow2)) {
4442
5231
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -4466,7 +5255,7 @@ function maybeAutoAdvance(ctx, featureId) {
4466
5255
  if (next)
4467
5256
  setPhase(ctx, featureId, next, "phase.advanced", "auto-advanced to review after successful run");
4468
5257
  }
4469
- var controllers, BRANCH_CLAIMING;
5258
+ var controllers2, BRANCH_CLAIMING;
4470
5259
  var init_runner = __esm(() => {
4471
5260
  init_src();
4472
5261
  init_paths();
@@ -4479,12 +5268,12 @@ var init_runner = __esm(() => {
4479
5268
  init_tickets();
4480
5269
  init_waypoints();
4481
5270
  init_registry2();
4482
- controllers = new Map;
5271
+ controllers2 = new Map;
4483
5272
  BRANCH_CLAIMING = new Set(["ticket-burner"]);
4484
5273
  });
4485
5274
 
4486
5275
  // src/services/features.ts
4487
- import { desc as desc3, eq as eq7 } from "drizzle-orm";
5276
+ import { desc as desc4, eq as eq9 } from "drizzle-orm";
4488
5277
  import { rmSync as rmSync2 } from "fs";
4489
5278
  async function createFeature(ctx, input) {
4490
5279
  const project = requireProjectById(ctx, input.projectId);
@@ -4547,7 +5336,7 @@ function getFeatureFull(ctx, id) {
4547
5336
  };
4548
5337
  }
4549
5338
  function list(ctx, projectId) {
4550
- const rows = ctx.db.select().from(features).where(eq7(features.projectId, projectId)).orderBy(desc3(features.createdAt)).all();
5339
+ const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc4(features.createdAt)).all();
4551
5340
  return rows.map((row) => {
4552
5341
  const feature = rowToFeature(row);
4553
5342
  const tickets2 = listByFeature2(ctx, feature.id);
@@ -4692,7 +5481,7 @@ function escalateToMap(ctx, featureId, input) {
4692
5481
  return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
4693
5482
  }
4694
5483
  const project = projectForFeature(ctx, feature);
4695
- ctx.db.update(features).set({ mapped: true }).where(eq7(features.id, featureId)).run();
5484
+ ctx.db.update(features).set({ mapped: true }).where(eq9(features.id, featureId)).run();
4696
5485
  scaffoldMapDoc(project, { ...feature, mapped: true }, input);
4697
5486
  emit(ctx, featureId, {
4698
5487
  type: "feature.escalated",
@@ -4709,7 +5498,7 @@ function archiveFeature(ctx, featureId) {
4709
5498
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
4710
5499
  if (live)
4711
5500
  endSession(ctx, live.id);
4712
- ctx.db.update(features).set({ status: "archived" }).where(eq7(features.id, featureId)).run();
5501
+ ctx.db.update(features).set({ status: "archived" }).where(eq9(features.id, featureId)).run();
4713
5502
  emit(ctx, featureId, {
4714
5503
  type: "feature.archived",
4715
5504
  message: `feature ${feature.slug} archived`,
@@ -4723,7 +5512,7 @@ function unarchiveFeature(ctx, featureId) {
4723
5512
  throw new GateError(`feature ${feature.slug} is not archived`);
4724
5513
  }
4725
5514
  const restored = feature.phase === "shipped" ? "shipped" : "active";
4726
- ctx.db.update(features).set({ status: restored }).where(eq7(features.id, featureId)).run();
5515
+ ctx.db.update(features).set({ status: restored }).where(eq9(features.id, featureId)).run();
4727
5516
  emit(ctx, featureId, {
4728
5517
  type: "feature.unarchived",
4729
5518
  message: `feature ${feature.slug} unarchived (${restored})`,
@@ -4737,9 +5526,9 @@ async function deleteFeature(ctx, featureId) {
4737
5526
  throw new GateError(`feature ${feature.slug} is shipped \u2014 archive it instead (delete is for non-shipped features)`);
4738
5527
  }
4739
5528
  const project = projectForFeature(ctx, feature);
4740
- for (const run3 of listRunsByFeature(ctx, featureId)) {
4741
- if (run3.status === "running")
4742
- cancelRun(run3.id);
5529
+ for (const run4 of listRunsByFeature(ctx, featureId)) {
5530
+ if (run4.status === "running")
5531
+ cancelRun(run4.id);
4743
5532
  }
4744
5533
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
4745
5534
  if (live)
@@ -4763,13 +5552,13 @@ async function deleteFeature(ctx, featureId) {
4763
5552
  return { ok: true, slug: feature.slug };
4764
5553
  }
4765
5554
  function deleteFeatureRows(ctx, featureId) {
4766
- ctx.db.delete(tickets).where(eq7(tickets.featureId, featureId)).run();
4767
- ctx.db.delete(sessions).where(eq7(sessions.featureId, featureId)).run();
4768
- ctx.db.delete(runs).where(eq7(runs.featureId, featureId)).run();
4769
- ctx.db.delete(events).where(eq7(events.featureId, featureId)).run();
4770
- ctx.db.delete(gateOverrides).where(eq7(gateOverrides.featureId, featureId)).run();
4771
- ctx.db.delete(waypoints).where(eq7(waypoints.featureId, featureId)).run();
4772
- ctx.db.delete(features).where(eq7(features.id, featureId)).run();
5555
+ ctx.db.delete(tickets).where(eq9(tickets.featureId, featureId)).run();
5556
+ ctx.db.delete(sessions).where(eq9(sessions.featureId, featureId)).run();
5557
+ ctx.db.delete(runs).where(eq9(runs.featureId, featureId)).run();
5558
+ ctx.db.delete(events).where(eq9(events.featureId, featureId)).run();
5559
+ ctx.db.delete(gateOverrides).where(eq9(gateOverrides.featureId, featureId)).run();
5560
+ ctx.db.delete(waypoints).where(eq9(waypoints.featureId, featureId)).run();
5561
+ ctx.db.delete(features).where(eq9(features.id, featureId)).run();
4773
5562
  }
4774
5563
  function gateState(ctx, feature) {
4775
5564
  const gate = nextGate(feature);
@@ -4780,7 +5569,7 @@ function gateState(ctx, feature) {
4780
5569
  }
4781
5570
  function uniqueSlug(ctx, projectId, title) {
4782
5571
  const base = slugify(title);
4783
- const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq7(features.projectId, projectId)).all().map((r) => r.slug));
5572
+ const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq9(features.projectId, projectId)).all().map((r) => r.slug));
4784
5573
  if (!existing.has(base))
4785
5574
  return base;
4786
5575
  let n = 2;
@@ -4812,7 +5601,7 @@ var init_features = __esm(() => {
4812
5601
  import { StreamableHTTPTransport } from "@hono/mcp";
4813
5602
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4814
5603
  import { Hono } from "hono";
4815
- import * as z3 from "zod";
5604
+ import * as z4 from "zod";
4816
5605
  function headerSessionId(extra) {
4817
5606
  const headers = extra.requestInfo?.headers;
4818
5607
  if (!headers)
@@ -4966,7 +5755,7 @@ function buildMcpServer() {
4966
5755
  server.registerTool("emit_tickets", {
4967
5756
  title: "Emit tickets",
4968
5757
  description: "Store the ideation session's ticket batch. Each ticket: title, goal, context, acceptanceCriteria[], seams[], blockedBy[] (1-based positions within THIS batch).",
4969
- inputSchema: { tickets: z3.array(TicketInput) }
5758
+ inputSchema: { tickets: z4.array(TicketInput) }
4970
5759
  }, async (args, extra) => {
4971
5760
  const rs = await resolveCtxSession(extra);
4972
5761
  if (!rs)
@@ -4979,12 +5768,12 @@ function buildMcpServer() {
4979
5768
  title: "Update ticket",
4980
5769
  description: "Rewrite a stored ticket's content \u2014 title, goal, context, acceptanceCriteria, seams (any subset). Only pending or failed tickets can be edited; done/cancelled tickets are history and burning tickets are already running. Use during a revisit when a decision change makes a ticket stale. Get ids from get_feature_context.",
4981
5770
  inputSchema: {
4982
- id: z3.string(),
4983
- title: z3.string().optional(),
4984
- goal: z3.string().optional(),
4985
- context: z3.string().optional(),
4986
- acceptanceCriteria: z3.array(z3.string()).optional(),
4987
- seams: z3.array(z3.string()).optional()
5771
+ id: z4.string(),
5772
+ title: z4.string().optional(),
5773
+ goal: z4.string().optional(),
5774
+ context: z4.string().optional(),
5775
+ acceptanceCriteria: z4.array(z4.string()).optional(),
5776
+ seams: z4.array(z4.string()).optional()
4988
5777
  }
4989
5778
  }, async (args, extra) => {
4990
5779
  const rs = await resolveCtxSession(extra);
@@ -4997,7 +5786,7 @@ function buildMcpServer() {
4997
5786
  server.registerTool("cancel_ticket", {
4998
5787
  title: "Cancel ticket",
4999
5788
  description: "Cancel a ticket that is no longer needed (terminal state; only pending or failed tickets). The burner skips cancelled tickets, and tickets blocked by a cancelled one still burn \u2014 cancellation counts as satisfied. Pass a short reason so the timeline explains why.",
5000
- inputSchema: { id: z3.string(), reason: z3.string().optional() }
5789
+ inputSchema: { id: z4.string(), reason: z4.string().optional() }
5001
5790
  }, async (args, extra) => {
5002
5791
  const rs = await resolveCtxSession(extra);
5003
5792
  if (!rs)
@@ -5009,7 +5798,7 @@ function buildMcpServer() {
5009
5798
  server.registerTool("escalate_to_map", {
5010
5799
  title: "Escalate to map",
5011
5800
  description: "Escalate this grilling session into a map when the feature outgrows one context window. Flips the feature to mapped and scaffolds docs/features/<slug>/map.md, seeding Destination + Notes from your arguments (Not-yet-specified and Out-of-scope start empty). Idempotent: calling it on an already-mapped feature warns and changes nothing.",
5012
- inputSchema: { destination: z3.string(), notes: z3.string().optional() }
5801
+ inputSchema: { destination: z4.string(), notes: z4.string().optional() }
5013
5802
  }, async (args, extra) => {
5014
5803
  const rs = await resolveCtxSession(extra);
5015
5804
  if (!rs)
@@ -5023,7 +5812,7 @@ function buildMcpServer() {
5023
5812
  server.registerTool("emit_waypoints", {
5024
5813
  title: "Emit waypoints",
5025
5814
  description: "Batch-create waypoints on the map (the feature must already be mapped \u2014 call escalate_to_map first). Each waypoint: title, type (grilling|research|prototype|task), question, blockedBy[] (1-based positions within THIS batch, and/or ids of already-stored waypoints). Available from any session once mapped \u2014 any session may branch the map.",
5026
- inputSchema: { waypoints: z3.array(WaypointInput) }
5815
+ inputSchema: { waypoints: z4.array(WaypointInput) }
5027
5816
  }, async (args, extra) => {
5028
5817
  const rs = await resolveCtxSession(extra);
5029
5818
  if (!rs)
@@ -5033,7 +5822,7 @@ function buildMcpServer() {
5033
5822
  server.registerTool("resolve_waypoint", {
5034
5823
  title: "Resolve waypoint",
5035
5824
  description: "End the current waypoint: `resolved` (its question is answered) or `dropped` (no longer needed). Write the decision prose to decisions.md (or the gist to map.md Out-of-scope for a drop) FIRST \u2014 this tool flips machinery only (marks the waypoint terminal, frees any dependents on the frontier). `summary` is the one-line gist shown in the UI. Call this exactly once, as the last thing you do.",
5036
- inputSchema: { id: z3.string(), disposition: WaypointDisposition, summary: z3.string() }
5825
+ inputSchema: { id: z4.string(), disposition: WaypointDisposition, summary: z4.string() }
5037
5826
  }, async (args, extra) => {
5038
5827
  const rs = await resolveCtxSession(extra);
5039
5828
  if (!rs)
@@ -5045,7 +5834,7 @@ function buildMcpServer() {
5045
5834
  server.registerTool("record_event", {
5046
5835
  title: "Record event",
5047
5836
  description: "Add a note to the feature timeline (decisions recorded, spec saved, milestones).",
5048
- inputSchema: { type: z3.string(), message: z3.string() }
5837
+ inputSchema: { type: z4.string(), message: z4.string() }
5049
5838
  }, async (args, extra) => {
5050
5839
  const rs = await resolveCtxSession(extra);
5051
5840
  if (!rs)
@@ -5168,12 +5957,16 @@ import { Hono as Hono2 } from "hono";
5168
5957
  function handleSessionStart(ctx, sessionId, feature, payload) {
5169
5958
  const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
5170
5959
  const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
5960
+ const source = typeof payload?.source === "string" ? payload.source : undefined;
5961
+ const wasLive = getSessionRow(ctx, sessionId)?.status === "live";
5171
5962
  markSessionLive(ctx, sessionId, { ccSessionId, transcriptPath });
5172
- emit(ctx, feature.id, {
5173
- type: "session.started",
5174
- message: "session live",
5175
- data: { sessionId, ccSessionId, transcriptPath }
5176
- });
5963
+ if (!wasLive) {
5964
+ emit(ctx, feature.id, {
5965
+ type: "session.started",
5966
+ message: source === "resume" ? "session live (conversation resumed)" : "session live",
5967
+ data: { sessionId, ccSessionId, transcriptPath, source: source ?? null }
5968
+ });
5969
+ }
5177
5970
  return {
5178
5971
  hookSpecificOutput: {
5179
5972
  hookEventName: "SessionStart",
@@ -5181,7 +5974,9 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
5181
5974
  }
5182
5975
  };
5183
5976
  }
5184
- function handleUserPrompt(ctx, feature) {
5977
+ function handleUserPrompt(ctx, sessionId, feature, payload) {
5978
+ const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
5979
+ noteKickoffPrompt(ctx, sessionId, prompt);
5185
5980
  const tickets2 = listByFeature2(ctx, feature.id).length;
5186
5981
  return {
5187
5982
  hookSpecificOutput: {
@@ -5248,7 +6043,7 @@ var init_hooks = __esm(() => {
5248
6043
  case "session-start":
5249
6044
  return c.json(handleSessionStart(ctx, sessionId, feature, body.payload));
5250
6045
  case "user-prompt":
5251
- return c.json(handleUserPrompt(ctx, feature));
6046
+ return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
5252
6047
  case "session-end":
5253
6048
  return c.json(handleSessionEnd(ctx, sessionId, feature));
5254
6049
  default:
@@ -5313,9 +6108,9 @@ var init_stream = __esm(() => {
5313
6108
  });
5314
6109
 
5315
6110
  // src/routes/web.ts
5316
- import { existsSync as existsSync12, statSync } from "fs";
6111
+ import { existsSync as existsSync13, statSync } from "fs";
5317
6112
  import { readFile } from "fs/promises";
5318
- import { extname, join as join11, normalize, resolve as resolve6, sep as sep2 } from "path";
6113
+ import { extname, join as join12, normalize, resolve as resolve6, sep as sep2 } from "path";
5319
6114
  function contentType(path) {
5320
6115
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
5321
6116
  }
@@ -5333,7 +6128,7 @@ function safeJoin(root, pathname) {
5333
6128
  } catch {
5334
6129
  return null;
5335
6130
  }
5336
- const joined = normalize(join11(root, decoded));
6131
+ const joined = normalize(join12(root, decoded));
5337
6132
  if (joined !== root && !joined.startsWith(root + sep2))
5338
6133
  return null;
5339
6134
  return joined;
@@ -5346,7 +6141,7 @@ function resolveWebDist() {
5346
6141
  }
5347
6142
  function mountWebApp(app, distDir) {
5348
6143
  const root = resolve6(distDir);
5349
- const indexHtml = join11(root, "index.html");
6144
+ const indexHtml = join12(root, "index.html");
5350
6145
  app.get("*", async (c, next) => {
5351
6146
  const { pathname } = new URL(c.req.url);
5352
6147
  if (/^\/(api|mcp|ws|health)(\/|$)/.test(pathname))
@@ -5366,7 +6161,7 @@ function mountWebApp(app, distDir) {
5366
6161
  }
5367
6162
  function mountWebAppIfBuilt(app) {
5368
6163
  const dist = resolveWebDist();
5369
- if (existsSync12(join11(dist, "index.html")))
6164
+ if (existsSync13(join12(dist, "index.html")))
5370
6165
  mountWebApp(app, dist);
5371
6166
  }
5372
6167
  var MIME;
@@ -5413,14 +6208,14 @@ var init_context = __esm(() => {
5413
6208
  });
5414
6209
 
5415
6210
  // src/trpc/routers/docs.ts
5416
- import * as z4 from "zod";
6211
+ import * as z5 from "zod";
5417
6212
  var docsRouter;
5418
6213
  var init_docs = __esm(() => {
5419
6214
  init_knowledge();
5420
6215
  init_repo();
5421
6216
  init_context();
5422
6217
  docsRouter = router({
5423
- read: publicProcedure.input(z4.object({ featureId: z4.string(), relPath: z4.string() })).query(({ ctx, input }) => {
6218
+ read: publicProcedure.input(z5.object({ featureId: z5.string(), relPath: z5.string() })).query(({ ctx, input }) => {
5424
6219
  const feature = getFeatureRow(ctx, input.featureId);
5425
6220
  return readDoc(ctx, feature, input.relPath);
5426
6221
  })
@@ -5428,23 +6223,23 @@ var init_docs = __esm(() => {
5428
6223
  });
5429
6224
 
5430
6225
  // src/trpc/routers/events.ts
5431
- import * as z5 from "zod";
6226
+ import * as z6 from "zod";
5432
6227
  var eventsRouter;
5433
6228
  var init_events2 = __esm(() => {
5434
6229
  init_events();
5435
6230
  init_context();
5436
6231
  eventsRouter = router({
5437
- list: publicProcedure.input(z5.object({ featureId: z5.string(), afterId: z5.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
5438
- listByProject: publicProcedure.input(z5.object({ projectId: z5.string(), afterId: z5.number().optional() })).query(({ ctx, input }) => listByProject(ctx, input.projectId, input.afterId))
6232
+ list: publicProcedure.input(z6.object({ featureId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
6233
+ listByProject: publicProcedure.input(z6.object({ projectId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listByProject(ctx, input.projectId, input.afterId))
5439
6234
  });
5440
6235
  });
5441
6236
 
5442
6237
  // src/launcher/artifacts.ts
5443
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync2 } from "fs";
5444
- import { fileURLToPath as fileURLToPath4 } from "url";
5445
- import { join as join12 } from "path";
6238
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync2 } from "fs";
6239
+ import { fileURLToPath as fileURLToPath5 } from "url";
6240
+ import { join as join13 } from "path";
5446
6241
  function hookClientPath() {
5447
- return resolveAsset(ASSET_ENV.hookClient, fileURLToPath4(new URL("./hook-client.ts", import.meta.url)));
6242
+ return resolveAsset(ASSET_ENV.hookClient, fileURLToPath5(new URL("./hook-client.ts", import.meta.url)));
5448
6243
  }
5449
6244
  function serverUrlFor(config2) {
5450
6245
  return `http://localhost:${config2.serverPort}`;
@@ -5645,7 +6440,10 @@ function renderSettings(hookClient) {
5645
6440
  return {
5646
6441
  permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...SESSION_BASH_ALLOW_RULES] },
5647
6442
  hooks: {
5648
- SessionStart: [{ matcher: "startup", hooks: [cmd("session-start")] }],
6443
+ SessionStart: SESSION_START_SOURCES.map((source) => ({
6444
+ matcher: source,
6445
+ hooks: [cmd("session-start")]
6446
+ })),
5649
6447
  UserPromptSubmit: [{ hooks: [cmd("user-prompt")] }],
5650
6448
  SessionEnd: [{ hooks: [cmd("session-end")] }]
5651
6449
  }
@@ -5665,16 +6463,16 @@ function renderMcpConfig(session, config2) {
5665
6463
  async function writeSessionArtifacts(input) {
5666
6464
  const { session, feature, config: config2, waypoint } = input;
5667
6465
  const dir = sessionDir(session.id);
5668
- mkdirSync6(dir, { recursive: true });
5669
- const systemPromptPath = join12(dir, "system-prompt.md");
5670
- const settingsPath = join12(dir, "settings.json");
5671
- const mcpConfigPath = join12(dir, "mcp.json");
6466
+ mkdirSync7(dir, { recursive: true });
6467
+ const systemPromptPath = join13(dir, "system-prompt.md");
6468
+ const settingsPath = join13(dir, "settings.json");
6469
+ const mcpConfigPath = join13(dir, "mcp.json");
5672
6470
  writeFileSync2(systemPromptPath, renderSystemPrompt(feature, session.kind, waypoint), "utf8");
5673
6471
  writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
5674
6472
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
5675
6473
  return { systemPromptPath, settingsPath, mcpConfigPath };
5676
6474
  }
5677
- var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES;
6475
+ var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
5678
6476
  var init_artifacts = __esm(() => {
5679
6477
  init_paths();
5680
6478
  init_asset_paths();
@@ -5699,13 +6497,14 @@ var init_artifacts = __esm(() => {
5699
6497
  "Bash(git branch:*)",
5700
6498
  "Bash(git show:*)"
5701
6499
  ];
6500
+ SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
5702
6501
  });
5703
6502
 
5704
6503
  // src/launcher/launcher.ts
5705
- import { existsSync as existsSync13 } from "fs";
5706
- import { dirname as dirname5, join as join13, resolve as resolve7 } from "path";
5707
- import { fileURLToPath as fileURLToPath5 } from "url";
5708
- import { and as and6, eq as eq8 } from "drizzle-orm";
6504
+ import { existsSync as existsSync14 } from "fs";
6505
+ import { dirname as dirname6, join as join14, resolve as resolve7 } from "path";
6506
+ import { fileURLToPath as fileURLToPath6 } from "url";
6507
+ import { and as and7, eq as eq10 } from "drizzle-orm";
5709
6508
  function ptyExitMessage(exitCode) {
5710
6509
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
5711
6510
  return `terminal exited (code ${label})`;
@@ -5736,23 +6535,23 @@ function resolveClaudeExecutable() {
5736
6535
  function claudeSpawnTarget(claudeArgs) {
5737
6536
  return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
5738
6537
  }
5739
- function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5740
- const rel = join13("packages", "skills", "packs", "runcastle");
6538
+ function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
6539
+ const rel = join14("packages", "skills", "packs", "runcastle");
5741
6540
  const override = process.env[SKILLS_DIR_ENV];
5742
6541
  if (override) {
5743
- const dir2 = join13(resolve7(override), "packs", "runcastle");
5744
- if (existsSync13(dir2))
6542
+ const dir2 = join14(resolve7(override), "packs", "runcastle");
6543
+ if (existsSync14(dir2))
5745
6544
  return dir2;
5746
6545
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
5747
6546
  }
5748
6547
  const searched = [];
5749
6548
  let dir = fromDir;
5750
6549
  for (let i = 0;i < 8; i += 1) {
5751
- const candidate = join13(dir, rel);
6550
+ const candidate = join14(dir, rel);
5752
6551
  searched.push(candidate);
5753
- if (existsSync13(candidate))
6552
+ if (existsSync14(candidate))
5754
6553
  return candidate;
5755
- const parent = dirname5(dir);
6554
+ const parent = dirname6(dir);
5756
6555
  if (parent === dir)
5757
6556
  break;
5758
6557
  dir = parent;
@@ -5762,7 +6561,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5762
6561
  `)}`);
5763
6562
  }
5764
6563
  function activeRunFor(ctx, featureId) {
5765
- const row = ctx.db.select().from(runs).where(and6(eq8(runs.featureId, featureId), eq8(runs.status, "running"))).limit(1).get();
6564
+ const row = ctx.db.select().from(runs).where(and7(eq10(runs.featureId, featureId), eq10(runs.status, "running"))).limit(1).get();
5766
6565
  return row ? rowToRun(row) : null;
5767
6566
  }
5768
6567
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -6013,6 +6812,7 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
6013
6812
  message: "embedded terminal spawned",
6014
6813
  data: { sessionId: session.id, mode: "embedded", pid: entry.pty.pid }
6015
6814
  });
6815
+ armSessionReadyWatchdog(ctx, session);
6016
6816
  } catch (err) {
6017
6817
  markSessionEnded(ctx, session.id);
6018
6818
  releaseForSession(ctx, session.id);
@@ -6041,6 +6841,7 @@ var init_launcher = __esm(() => {
6041
6841
  init_runner();
6042
6842
  init_artifacts();
6043
6843
  init_sessions();
6844
+ init_sessions();
6044
6845
  init_end_session();
6045
6846
  CC_NESTING_ENV = [
6046
6847
  "CLAUDE_CODE_CHILD_SESSION",
@@ -6055,7 +6856,7 @@ var init_launcher = __esm(() => {
6055
6856
  });
6056
6857
 
6057
6858
  // src/trpc/routers/feature.ts
6058
- import * as z6 from "zod";
6859
+ import * as z7 from "zod";
6059
6860
  var gateId, featureRouter;
6060
6861
  var init_feature = __esm(() => {
6061
6862
  init_src();
@@ -6066,37 +6867,38 @@ var init_feature = __esm(() => {
6066
6867
  init_git();
6067
6868
  init_repo();
6068
6869
  init_context();
6069
- gateId = z6.enum(["G1", "G2", "G3", "G4", "G5"]);
6870
+ gateId = z7.enum(["G1", "G2", "G3", "G4", "G5"]);
6070
6871
  featureRouter = router({
6071
- create: publicProcedure.input(z6.object({
6072
- projectId: z6.string(),
6073
- title: z6.string().min(1),
6074
- oneLiner: z6.string(),
6075
- baseBranch: z6.string().optional()
6872
+ create: publicProcedure.input(z7.object({
6873
+ projectId: z7.string(),
6874
+ title: z7.string().min(1),
6875
+ oneLiner: z7.string(),
6876
+ baseBranch: z7.string().optional()
6076
6877
  })).mutation(({ ctx, input }) => createFeature(ctx, input)),
6077
- list: publicProcedure.input(z6.object({ projectId: z6.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
6078
- get: publicProcedure.input(z6.object({ id: z6.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
6079
- launchSession: publicProcedure.input(z6.object({
6080
- featureId: z6.string(),
6878
+ list: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
6879
+ get: publicProcedure.input(z7.object({ id: z7.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
6880
+ launchSession: publicProcedure.input(z7.object({
6881
+ featureId: z7.string(),
6081
6882
  kind: SessionKind,
6082
- kickoffLine: z6.string().min(1).optional()
6883
+ kickoffLine: z7.string().min(1).optional()
6083
6884
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
6084
- workWaypoint: publicProcedure.input(z6.object({ featureId: z6.string(), waypointId: z6.string() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
6085
- 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 })),
6086
- endSession: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
6087
- advance: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
6088
- 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)),
6089
- archive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
6090
- unarchive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
6091
- delete: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
6092
- burn: publicProcedure.input(z6.object({ featureId: z6.string(), model: z6.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
6093
- testDrive: publicProcedure.input(z6.object({ featureId: z6.string(), action: z6.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
6885
+ workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
6886
+ converge: publicProcedure.input(z7.object({ featureId: z7.string(), overrideReason: z7.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
6887
+ resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
6888
+ endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
6889
+ advance: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
6890
+ overrideGate: publicProcedure.input(z7.object({ featureId: z7.string(), gate: gateId, reason: z7.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
6891
+ archive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
6892
+ unarchive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
6893
+ delete: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
6894
+ burn: publicProcedure.input(z7.object({ featureId: z7.string(), model: z7.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
6895
+ testDrive: publicProcedure.input(z7.object({ featureId: z7.string(), action: z7.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
6094
6896
  const feature = getFeatureRow(ctx, input.featureId);
6095
6897
  const project = projectForFeature(ctx, feature);
6096
6898
  return testDrive(ctx, project, feature, input.action);
6097
6899
  }),
6098
6900
  driveInfo: publicProcedure.query(() => activeDriveInfo()),
6099
- merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
6901
+ merge: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(async ({ ctx, input }) => {
6100
6902
  const feature = getFeatureRow(ctx, input.featureId);
6101
6903
  const project = projectForFeature(ctx, feature);
6102
6904
  if (activeTestDriveFeatureId() === feature.id) {
@@ -6119,9 +6921,9 @@ var init_feature = __esm(() => {
6119
6921
  });
6120
6922
 
6121
6923
  // src/services/fsbrowse.ts
6122
- import { existsSync as existsSync14, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
6924
+ import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
6123
6925
  import { homedir as homedir2 } from "os";
6124
- import { basename, dirname as dirname6, isAbsolute, join as join14, resolve as resolve8, sep as sep3 } from "path";
6926
+ import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve8, sep as sep3 } from "path";
6125
6927
  function expandPath(input, home = homedir2()) {
6126
6928
  const trimmed = input.trim();
6127
6929
  if (trimmed === "")
@@ -6130,12 +6932,12 @@ function expandPath(input, home = homedir2()) {
6130
6932
  if (out === "~")
6131
6933
  out = home;
6132
6934
  else if (out.startsWith("~/") || out.startsWith("~\\"))
6133
- out = join14(home, out.slice(2));
6935
+ out = join15(home, out.slice(2));
6134
6936
  return resolve8(out);
6135
6937
  }
6136
6938
  function looksLikeRepo(dir) {
6137
6939
  try {
6138
- return existsSync14(join14(dir, ".git"));
6940
+ return existsSync15(join15(dir, ".git"));
6139
6941
  } catch {
6140
6942
  return false;
6141
6943
  }
@@ -6146,20 +6948,20 @@ function listRoots(home = homedir2()) {
6146
6948
  for (let code = 67;code <= 90; code++) {
6147
6949
  const drive = `${String.fromCharCode(code)}:${sep3}`;
6148
6950
  try {
6149
- if (existsSync14(drive))
6951
+ if (existsSync15(drive))
6150
6952
  roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
6151
6953
  } catch {}
6152
6954
  }
6153
6955
  } else {
6154
6956
  roots.push({ label: "/", path: "/", kind: "drive" });
6155
6957
  }
6156
- if (existsSync14(home))
6958
+ if (existsSync15(home))
6157
6959
  roots.push({ label: "Home", path: home, kind: "home" });
6158
6960
  const seen = new Set(roots.map(pathKey));
6159
6961
  for (const name of COMMON_DIRS) {
6160
- const candidate = join14(home, name);
6962
+ const candidate = join15(home, name);
6161
6963
  const key = pathKey({ path: candidate });
6162
- if (seen.has(key) || !existsSync14(candidate))
6964
+ if (seen.has(key) || !existsSync15(candidate))
6163
6965
  continue;
6164
6966
  seen.add(key);
6165
6967
  roots.push({ label: name, path: candidate, kind: "common" });
@@ -6174,7 +6976,7 @@ function crumbsFor(dir) {
6174
6976
  let cur = dir;
6175
6977
  for (;; ) {
6176
6978
  out.unshift({ name: basename(cur) || cur, path: cur });
6177
- const parent = dirname6(cur);
6979
+ const parent = dirname7(cur);
6178
6980
  if (parent === cur)
6179
6981
  break;
6180
6982
  cur = parent;
@@ -6182,7 +6984,7 @@ function crumbsFor(dir) {
6182
6984
  return out;
6183
6985
  }
6184
6986
  function parentOf(dir) {
6185
- const parent = dirname6(dir);
6987
+ const parent = dirname7(dir);
6186
6988
  return parent === dir ? null : parent;
6187
6989
  }
6188
6990
  function browseDir(input, showHidden = false, home = homedir2()) {
@@ -6190,13 +6992,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
6190
6992
  if (!isAbsolute(target)) {
6191
6993
  throw new InvalidInputError(`path is not absolute: ${target}`);
6192
6994
  }
6193
- if (!existsSync14(target)) {
6995
+ if (!existsSync15(target)) {
6194
6996
  throw new InvalidInputError(`path does not exist: ${target}`);
6195
6997
  }
6196
6998
  let dir = target;
6197
6999
  try {
6198
7000
  if (!statSync2(target).isDirectory())
6199
- dir = dirname6(target);
7001
+ dir = dirname7(target);
6200
7002
  } catch (e) {
6201
7003
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg2(e)})`);
6202
7004
  }
@@ -6218,7 +7020,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
6218
7020
  continue;
6219
7021
  if (!showHidden && name.startsWith("."))
6220
7022
  continue;
6221
- const path = join14(dir, name);
7023
+ const path = join15(dir, name);
6222
7024
  const isSymlink = dirent.isSymbolicLink();
6223
7025
  let isDirectory = dirent.isDirectory();
6224
7026
  if (isSymlink) {
@@ -6253,9 +7055,9 @@ var init_fsbrowse = __esm(() => {
6253
7055
  });
6254
7056
 
6255
7057
  // src/services/projects.ts
6256
- import { existsSync as existsSync15 } from "fs";
6257
- import { basename as basename2, join as join15 } from "path";
6258
- import { eq as eq9, isNull } from "drizzle-orm";
7058
+ import { existsSync as existsSync16 } from "fs";
7059
+ import { basename as basename2, join as join16 } from "path";
7060
+ import { eq as eq11, isNull } from "drizzle-orm";
6259
7061
  function listProjects(ctx) {
6260
7062
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
6261
7063
  }
@@ -6277,7 +7079,7 @@ async function openProject(ctx, rawPath) {
6277
7079
  const name = basename2(repoPath) || repoPath;
6278
7080
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
6279
7081
  if (existing) {
6280
- ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq9(projects.id, existing.id)).run();
7082
+ ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq11(projects.id, existing.id)).run();
6281
7083
  const reopened = { ...existing, mainBranch, repoPath };
6282
7084
  emitProject(ctx, existing.id, {
6283
7085
  type: "project.opened",
@@ -6301,14 +7103,25 @@ async function openProject(ctx, rawPath) {
6301
7103
  message: `project ${name} at ${repoPath} (${mainBranch})`
6302
7104
  });
6303
7105
  warnIfTranslatedMount(ctx, project.id, repoPath);
7106
+ maybeAutoPrepare(ctx, project);
6304
7107
  return project;
6305
7108
  }
7109
+ function maybeAutoPrepare(ctx, project) {
7110
+ if (!ctx.config.autoPrepare)
7111
+ return;
7112
+ startPrep(ctx, project.id).catch((e) => {
7113
+ emitProject(ctx, project.id, {
7114
+ type: "prep.run.failed",
7115
+ message: `could not start preparation: ${e instanceof Error ? e.message : String(e)}`
7116
+ });
7117
+ });
7118
+ }
6306
7119
  function closeProject(ctx, projectId) {
6307
7120
  const project = requireProjectById(ctx, projectId);
6308
7121
  if (projectHasActiveRun(ctx, projectId)) {
6309
7122
  throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
6310
7123
  }
6311
- ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq9(projects.id, projectId)).run();
7124
+ ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq11(projects.id, projectId)).run();
6312
7125
  emitProject(ctx, projectId, {
6313
7126
  type: "project.closed",
6314
7127
  message: `project ${project.name} closed`
@@ -6317,7 +7130,7 @@ function closeProject(ctx, projectId) {
6317
7130
  }
6318
7131
  function renameProject(ctx, projectId, name) {
6319
7132
  const project = requireProjectById(ctx, projectId);
6320
- ctx.db.update(projects).set({ name }).where(eq9(projects.id, projectId)).run();
7133
+ ctx.db.update(projects).set({ name }).where(eq11(projects.id, projectId)).run();
6321
7134
  emitProject(ctx, projectId, {
6322
7135
  type: "project.renamed",
6323
7136
  message: `project renamed ${project.name} \u2192 ${name}`,
@@ -6335,7 +7148,7 @@ async function assertRepoTolerant(ctx, repoPath) {
6335
7148
  } catch (e) {
6336
7149
  if (!isNotImplemented(e))
6337
7150
  throw e;
6338
- if (!existsSync15(join15(repoPath, ".git"))) {
7151
+ if (!existsSync16(join16(repoPath, ".git"))) {
6339
7152
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
6340
7153
  }
6341
7154
  }
@@ -6356,31 +7169,59 @@ var init_projects = __esm(() => {
6356
7169
  init_events();
6357
7170
  init_fsbrowse();
6358
7171
  init_git();
7172
+ init_prep();
6359
7173
  init_repo();
6360
7174
  });
6361
7175
 
6362
7176
  // src/trpc/routers/project.ts
6363
- import * as z7 from "zod";
7177
+ import * as z8 from "zod";
6364
7178
  var projectRouter;
6365
7179
  var init_project = __esm(() => {
7180
+ init_src();
7181
+ init_findings();
6366
7182
  init_fsbrowse();
6367
7183
  init_git();
7184
+ init_prep();
6368
7185
  init_projects();
6369
7186
  init_repo();
6370
7187
  init_context();
6371
7188
  projectRouter = router({
6372
7189
  list: publicProcedure.query(({ ctx }) => listProjects(ctx)),
6373
7190
  roots: publicProcedure.query(() => listRoots()),
6374
- browse: publicProcedure.input(z7.object({ dir: z7.string().optional(), showHidden: z7.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
6375
- branches: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
6376
- open: publicProcedure.input(z7.object({ repoPath: z7.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
6377
- close: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
6378
- rename: publicProcedure.input(z7.object({ projectId: z7.string(), name: z7.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name))
7191
+ browse: publicProcedure.input(z8.object({ dir: z8.string().optional(), showHidden: z8.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
7192
+ branches: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
7193
+ open: publicProcedure.input(z8.object({ repoPath: z8.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
7194
+ close: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
7195
+ rename: publicProcedure.input(z8.object({ projectId: z8.string(), name: z8.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
7196
+ prepare: publicProcedure.input(z8.object({
7197
+ projectId: z8.string(),
7198
+ refresh: z8.boolean().optional(),
7199
+ keys: z8.array(PreparedKey).optional()
7200
+ })).mutation(async ({ ctx, input }) => {
7201
+ const { prepId, keys } = await startPrep(ctx, input.projectId, {
7202
+ ...input.refresh !== undefined ? { refresh: input.refresh } : {},
7203
+ ...input.keys ? { keys: input.keys } : {}
7204
+ });
7205
+ return { prepId, keys };
7206
+ }),
7207
+ cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
7208
+ cancelPrep(input.projectId);
7209
+ return { ok: true };
7210
+ }),
7211
+ prep: publicProcedure.input(z8.object({ projectId: z8.string() })).query(async ({ ctx, input }) => {
7212
+ const project = requireProjectById(ctx, input.projectId);
7213
+ return {
7214
+ latest: latestPrep(ctx, project.id),
7215
+ running: isPreparing(project.id),
7216
+ pendingKeys: keysToPrepare(ctx, project),
7217
+ findings: await listFindings(ctx, project)
7218
+ };
7219
+ })
6379
7220
  });
6380
7221
  });
6381
7222
 
6382
7223
  // src/trpc/routers/run.ts
6383
- import * as z8 from "zod";
7224
+ import * as z9 from "zod";
6384
7225
  var runRouter;
6385
7226
  var init_run = __esm(() => {
6386
7227
  init_agent_stream();
@@ -6388,9 +7229,9 @@ var init_run = __esm(() => {
6388
7229
  init_runner();
6389
7230
  init_context();
6390
7231
  runRouter = router({
6391
- get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
6392
- agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
6393
- cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
7232
+ get: publicProcedure.input(z9.object({ runId: z9.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
7233
+ agentTranscript: publicProcedure.input(z9.object({ ticketId: z9.string(), after: z9.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
7234
+ cancel: publicProcedure.input(z9.object({ runId: z9.string() })).mutation(({ input }) => {
6394
7235
  cancelRun(input.runId);
6395
7236
  return { ok: true };
6396
7237
  })
@@ -6398,15 +7239,15 @@ var init_run = __esm(() => {
6398
7239
  });
6399
7240
 
6400
7241
  // src/services/settings.ts
6401
- import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
6402
- import { dirname as dirname7 } from "path";
6403
- import * as z9 from "zod";
6404
- import { eq as eq10 } from "drizzle-orm";
7242
+ import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
7243
+ import { dirname as dirname8 } from "path";
7244
+ import * as z10 from "zod";
7245
+ import { eq as eq12 } from "drizzle-orm";
6405
7246
  function readRawConfig(configFile) {
6406
- if (!existsSync16(configFile))
7247
+ if (!existsSync17(configFile))
6407
7248
  return {};
6408
7249
  try {
6409
- const parsed = JSON.parse(readFileSync6(configFile, "utf8"));
7250
+ const parsed = JSON.parse(readFileSync7(configFile, "utf8"));
6410
7251
  if (typeof parsed !== "object" || parsed === null)
6411
7252
  return {};
6412
7253
  return foldLegacyModelConfig(parsed);
@@ -6430,33 +7271,45 @@ function stepModelFields(fileRaw) {
6430
7271
  });
6431
7272
  }
6432
7273
  function projectOverrides(ctx, projectId) {
6433
- const row = ctx.db.select({ model: projects.model, sandbox: projects.sandbox, devCommand: projects.devCommand }).from(projects).where(eq10(projects.id, projectId)).get();
7274
+ const row = ctx.db.select({
7275
+ model: projects.model,
7276
+ sandbox: projects.sandbox,
7277
+ devCommand: projects.devCommand,
7278
+ setupCommand: projects.setupCommand,
7279
+ verifyCommands: projects.verifyCommands,
7280
+ knownFailures: projects.knownFailures,
7281
+ dbResetCommand: projects.dbResetCommand
7282
+ }).from(projects).where(eq12(projects.id, projectId)).get();
6434
7283
  return {
6435
7284
  model: row?.model ?? null,
6436
7285
  sandbox: row?.sandbox ?? null,
6437
- devCommand: row?.devCommand ?? null
7286
+ devCommand: row?.devCommand ?? null,
7287
+ setupCommand: row?.setupCommand ?? null,
7288
+ verifyCommands: row?.verifyCommands ?? null,
7289
+ knownFailures: row?.knownFailures ?? null,
7290
+ dbResetCommand: row?.dbResetCommand ?? null
6438
7291
  };
6439
7292
  }
6440
- function resolveField(desc4, layers) {
7293
+ function resolveField(desc5, layers) {
6441
7294
  const scoped = layers.overrides !== null;
6442
- const scope = scoped && desc4.projectColumn ? "project" : "global";
6443
- const base = { key: desc4.key, restartRequired: desc4.restartRequired, scope };
6444
- if (desc4.envVar) {
6445
- const raw = layers.env[desc4.envVar];
7295
+ const scope = scoped && desc5.projectColumn ? "project" : "global";
7296
+ const base = { key: desc5.key, restartRequired: desc5.restartRequired, scope };
7297
+ if (desc5.envVar) {
7298
+ const raw = layers.env[desc5.envVar];
6446
7299
  if (raw !== undefined && raw !== "") {
6447
- return { ...base, value: desc4.parseEnv(raw), source: "env", editable: false };
7300
+ return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
6448
7301
  }
6449
7302
  }
6450
- if (layers.overrides && desc4.projectColumn) {
6451
- const ov = layers.overrides[desc4.projectColumn];
7303
+ if (layers.overrides && desc5.projectColumn) {
7304
+ const ov = layers.overrides[desc5.projectColumn];
6452
7305
  if (ov !== null && ov !== "") {
6453
7306
  return { ...base, value: ov, source: "project", editable: true };
6454
7307
  }
6455
7308
  }
6456
- if (desc4.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc4.configKey)) {
6457
- return { ...base, value: layers.fileRaw[desc4.configKey], source: "file", editable: true };
7309
+ if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
7310
+ return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
6458
7311
  }
6459
- const value = desc4.configKey ? DEFAULTS[desc4.configKey] ?? null : null;
7312
+ const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
6460
7313
  return { ...base, value, source: "default", editable: true };
6461
7314
  }
6462
7315
  function getSettings(ctx, projectId, io = {}) {
@@ -6473,59 +7326,61 @@ function updateSettings(ctx, input, io = {}) {
6473
7326
  if (input.key.startsWith("stepModels.")) {
6474
7327
  return updateStepModel(ctx, input, configFile, io);
6475
7328
  }
6476
- const desc4 = DESCRIPTORS.find((d) => d.key === input.key);
6477
- if (!desc4)
7329
+ const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
7330
+ if (!desc5)
6478
7331
  throw new InvalidInputError(`unknown setting: ${input.key}`);
6479
- if (desc4.envVar) {
6480
- const raw = env[desc4.envVar];
7332
+ if (desc5.envVar) {
7333
+ const raw = env[desc5.envVar];
6481
7334
  if (raw !== undefined && raw !== "") {
6482
- throw new InvalidInputError(`${desc4.key} is set by environment variable ${desc4.envVar}; unset it to edit`);
7335
+ throw new InvalidInputError(`${desc5.key} is set by environment variable ${desc5.envVar}; unset it to edit`);
6483
7336
  }
6484
7337
  }
6485
- const toProject = input.projectId !== undefined && desc4.projectColumn !== undefined;
7338
+ const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
6486
7339
  if (input.value === null) {
6487
7340
  if (!toProject)
6488
- throw new InvalidInputError(`${desc4.key} cannot be cleared`);
7341
+ throw new InvalidInputError(`${desc5.key} cannot be cleared`);
6489
7342
  const project = requireProjectById(ctx, input.projectId);
6490
- ctx.db.update(projects).set({ [desc4.projectColumn]: null }).where(eq10(projects.id, project.id)).run();
7343
+ ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
7344
+ recordHuman(ctx, project.id, desc5.key, null);
6491
7345
  emitProject(ctx, project.id, {
6492
7346
  type: "settings.updated",
6493
- message: `${desc4.key} override cleared`,
6494
- data: { key: desc4.key, scope: "project", value: null }
7347
+ message: `${desc5.key} override cleared`,
7348
+ data: { key: desc5.key, scope: "project", value: null }
6495
7349
  });
6496
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7350
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6497
7351
  }
6498
- const parsed = desc4.valueSchema.safeParse(input.value);
7352
+ const parsed = desc5.valueSchema.safeParse(input.value);
6499
7353
  if (!parsed.success) {
6500
- throw new InvalidInputError(`invalid value for ${desc4.key}: ${parsed.error.issues[0]?.message}`);
7354
+ throw new InvalidInputError(`invalid value for ${desc5.key}: ${parsed.error.issues[0]?.message}`);
6501
7355
  }
6502
7356
  const value = parsed.data;
6503
7357
  if (toProject) {
6504
7358
  const project = requireProjectById(ctx, input.projectId);
6505
- ctx.db.update(projects).set({ [desc4.projectColumn]: String(value) }).where(eq10(projects.id, project.id)).run();
7359
+ ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
7360
+ recordHuman(ctx, project.id, desc5.key, String(value));
6506
7361
  emitProject(ctx, project.id, {
6507
7362
  type: "settings.updated",
6508
- message: `${desc4.key} override set to ${String(value)}`,
6509
- data: { key: desc4.key, scope: "project", value }
7363
+ message: `${desc5.key} override set to ${String(value)}`,
7364
+ data: { key: desc5.key, scope: "project", value }
6510
7365
  });
6511
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7366
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6512
7367
  }
6513
- if (!desc4.configKey) {
6514
- throw new InvalidInputError(`${desc4.key} is a per-project setting; provide a projectId`);
7368
+ if (!desc5.configKey) {
7369
+ throw new InvalidInputError(`${desc5.key} is a per-project setting; provide a projectId`);
6515
7370
  }
6516
- writeGlobal(configFile, desc4.configKey, value);
6517
- ctx.config[desc4.configKey] = value;
7371
+ writeGlobal(configFile, desc5.configKey, value);
7372
+ ctx.config[desc5.configKey] = value;
6518
7373
  emitProject(ctx, GLOBAL_EVENT_KEY, {
6519
7374
  type: "settings.updated",
6520
- message: `${desc4.key} set to ${String(value)}`,
6521
- data: { key: desc4.key, scope: "global", value }
7375
+ message: `${desc5.key} set to ${String(value)}`,
7376
+ data: { key: desc5.key, scope: "global", value }
6522
7377
  });
6523
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7378
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6524
7379
  }
6525
7380
  function writeGlobal(configFile, configKey, value) {
6526
7381
  const raw = readRawConfig(configFile);
6527
7382
  raw[configKey] = value;
6528
- mkdirSync7(dirname7(configFile), { recursive: true });
7383
+ mkdirSync8(dirname8(configFile), { recursive: true });
6529
7384
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
6530
7385
  `);
6531
7386
  }
@@ -6536,7 +7391,7 @@ function updateStepModel(ctx, input, configFile, io) {
6536
7391
  const modelStep = step;
6537
7392
  let value = null;
6538
7393
  if (input.value !== null) {
6539
- const parsed = z9.string().min(1).safeParse(input.value);
7394
+ const parsed = z10.string().min(1).safeParse(input.value);
6540
7395
  if (!parsed.success) {
6541
7396
  throw new InvalidInputError(`invalid value for ${input.key}: ${parsed.error.issues[0]?.message}`);
6542
7397
  }
@@ -6564,7 +7419,7 @@ function writeStepModel(configFile, step, value) {
6564
7419
  else
6565
7420
  stepModels[step] = value;
6566
7421
  raw.stepModels = stepModels;
6567
- mkdirSync7(dirname7(configFile), { recursive: true });
7422
+ mkdirSync8(dirname8(configFile), { recursive: true });
6568
7423
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
6569
7424
  `);
6570
7425
  }
@@ -6581,6 +7436,7 @@ var init_settings = __esm(() => {
6581
7436
  init_schema();
6582
7437
  init_errors();
6583
7438
  init_events();
7439
+ init_findings();
6584
7440
  init_repo();
6585
7441
  DESCRIPTORS = [
6586
7442
  {
@@ -6588,7 +7444,7 @@ var init_settings = __esm(() => {
6588
7444
  configKey: "serverPort",
6589
7445
  envVar: "RUNCASTLE_SERVER_PORT",
6590
7446
  restartRequired: true,
6591
- valueSchema: z9.number().int().positive(),
7447
+ valueSchema: z10.number().int().positive(),
6592
7448
  parseEnv: (raw) => Number(raw)
6593
7449
  },
6594
7450
  {
@@ -6597,7 +7453,7 @@ var init_settings = __esm(() => {
6597
7453
  envVar: "RUNCASTLE_MODEL",
6598
7454
  projectColumn: "model",
6599
7455
  restartRequired: false,
6600
- valueSchema: z9.string().min(1),
7456
+ valueSchema: z10.string().min(1),
6601
7457
  parseEnv: idEnv
6602
7458
  },
6603
7459
  {
@@ -6606,7 +7462,7 @@ var init_settings = __esm(() => {
6606
7462
  envVar: "RUNCASTLE_SANDBOX",
6607
7463
  projectColumn: "sandbox",
6608
7464
  restartRequired: false,
6609
- valueSchema: z9.enum(["docker", "podman", "noSandbox"]),
7465
+ valueSchema: z10.enum(["docker", "podman", "noSandbox"]),
6610
7466
  parseEnv: idEnv
6611
7467
  },
6612
7468
  {
@@ -6614,7 +7470,7 @@ var init_settings = __esm(() => {
6614
7470
  configKey: "sandboxImage",
6615
7471
  envVar: "RUNCASTLE_SANDBOX_IMAGE",
6616
7472
  restartRequired: false,
6617
- valueSchema: z9.string().min(1),
7473
+ valueSchema: z10.string().min(1),
6618
7474
  parseEnv: idEnv
6619
7475
  },
6620
7476
  {
@@ -6622,7 +7478,7 @@ var init_settings = __esm(() => {
6622
7478
  configKey: "burnConcurrency",
6623
7479
  envVar: "RUNCASTLE_BURN_CONCURRENCY",
6624
7480
  restartRequired: false,
6625
- valueSchema: z9.number().int().min(1).max(8),
7481
+ valueSchema: z10.number().int().min(1).max(8),
6626
7482
  parseEnv: (raw) => Number(raw)
6627
7483
  },
6628
7484
  {
@@ -6630,7 +7486,7 @@ var init_settings = __esm(() => {
6630
7486
  configKey: "burnMaxIterations",
6631
7487
  envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
6632
7488
  restartRequired: false,
6633
- valueSchema: z9.number().int().min(1).max(10),
7489
+ valueSchema: z10.number().int().min(1).max(10),
6634
7490
  parseEnv: (raw) => Number(raw)
6635
7491
  },
6636
7492
  {
@@ -6638,7 +7494,7 @@ var init_settings = __esm(() => {
6638
7494
  configKey: "burnAttempts",
6639
7495
  envVar: "RUNCASTLE_BURN_ATTEMPTS",
6640
7496
  restartRequired: false,
6641
- valueSchema: z9.number().int().min(1).max(5),
7497
+ valueSchema: z10.number().int().min(1).max(5),
6642
7498
  parseEnv: (raw) => Number(raw)
6643
7499
  },
6644
7500
  {
@@ -6646,7 +7502,7 @@ var init_settings = __esm(() => {
6646
7502
  configKey: "burnConflictAttempts",
6647
7503
  envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
6648
7504
  restartRequired: false,
6649
- valueSchema: z9.number().int().min(0).max(3),
7505
+ valueSchema: z10.number().int().min(0).max(3),
6650
7506
  parseEnv: (raw) => Number(raw)
6651
7507
  },
6652
7508
  {
@@ -6654,31 +7510,42 @@ var init_settings = __esm(() => {
6654
7510
  configKey: "burnCpus",
6655
7511
  envVar: "RUNCASTLE_BURN_CPUS",
6656
7512
  restartRequired: false,
6657
- valueSchema: z9.number().positive().max(256),
7513
+ valueSchema: z10.number().positive().max(256),
6658
7514
  parseEnv: (raw) => Number(raw)
6659
7515
  },
7516
+ {
7517
+ key: "autoPrepare",
7518
+ configKey: "autoPrepare",
7519
+ envVar: "RUNCASTLE_AUTO_PREPARE",
7520
+ restartRequired: false,
7521
+ valueSchema: z10.boolean(),
7522
+ parseEnv: (raw) => raw !== "0" && raw.toLowerCase() !== "false"
7523
+ },
6660
7524
  {
6661
7525
  key: "setupCommand",
6662
7526
  configKey: "setupCommand",
6663
7527
  envVar: "RUNCASTLE_SETUP_COMMAND",
7528
+ projectColumn: "setupCommand",
6664
7529
  restartRequired: false,
6665
- valueSchema: z9.string().min(1),
7530
+ valueSchema: z10.string().min(1),
6666
7531
  parseEnv: idEnv
6667
7532
  },
6668
7533
  {
6669
7534
  key: "verifyCommands",
6670
7535
  configKey: "verifyCommands",
6671
7536
  envVar: "RUNCASTLE_VERIFY_COMMANDS",
7537
+ projectColumn: "verifyCommands",
6672
7538
  restartRequired: false,
6673
- valueSchema: z9.string().min(1),
7539
+ valueSchema: z10.string().min(1),
6674
7540
  parseEnv: idEnv
6675
7541
  },
6676
7542
  {
6677
7543
  key: "knownFailures",
6678
7544
  configKey: "knownFailures",
6679
7545
  envVar: "RUNCASTLE_KNOWN_FAILURES",
7546
+ projectColumn: "knownFailures",
6680
7547
  restartRequired: false,
6681
- valueSchema: z9.string().min(1),
7548
+ valueSchema: z10.string().min(1),
6682
7549
  parseEnv: idEnv
6683
7550
  },
6684
7551
  {
@@ -6686,14 +7553,21 @@ var init_settings = __esm(() => {
6686
7553
  configKey: "mainBranch",
6687
7554
  envVar: "RUNCASTLE_MAIN_BRANCH",
6688
7555
  restartRequired: false,
6689
- valueSchema: z9.string().min(1),
7556
+ valueSchema: z10.string().min(1),
6690
7557
  parseEnv: idEnv
6691
7558
  },
6692
7559
  {
6693
7560
  key: "devCommand",
6694
7561
  projectColumn: "devCommand",
6695
7562
  restartRequired: false,
6696
- valueSchema: z9.string().min(1),
7563
+ valueSchema: z10.string().min(1),
7564
+ parseEnv: idEnv
7565
+ },
7566
+ {
7567
+ key: "dbResetCommand",
7568
+ projectColumn: "dbResetCommand",
7569
+ restartRequired: false,
7570
+ valueSchema: z10.string().min(1),
6697
7571
  parseEnv: idEnv
6698
7572
  }
6699
7573
  ];
@@ -6702,14 +7576,14 @@ var init_settings = __esm(() => {
6702
7576
  });
6703
7577
 
6704
7578
  // src/trpc/routers/settings.ts
6705
- import * as z10 from "zod";
7579
+ import * as z11 from "zod";
6706
7580
  var settingsRouter;
6707
7581
  var init_settings2 = __esm(() => {
6708
7582
  init_src();
6709
7583
  init_settings();
6710
7584
  init_context();
6711
7585
  settingsRouter = router({
6712
- get: publicProcedure.input(z10.object({ projectId: z10.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
7586
+ get: publicProcedure.input(z11.object({ projectId: z11.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
6713
7587
  update: publicProcedure.input(SettingsUpdateInput).mutation(({ ctx, input }) => updateSettings(ctx, input))
6714
7588
  });
6715
7589
  });
@@ -6902,9 +7776,9 @@ var init_system_exec = __esm(() => {
6902
7776
  });
6903
7777
 
6904
7778
  // src/services/setup.ts
6905
- import { copyFileSync, existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
6906
- import { dirname as dirname8, join as join16 } from "path";
6907
- import { fileURLToPath as fileURLToPath6 } from "url";
7779
+ import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
7780
+ import { dirname as dirname9, join as join17 } from "path";
7781
+ import { fileURLToPath as fileURLToPath7 } from "url";
6908
7782
  async function writeGitIdentity(exec, identity) {
6909
7783
  const name = identity.name.trim();
6910
7784
  const email = identity.email.trim();
@@ -6979,16 +7853,16 @@ async function resolveRuntime(exec, preferred) {
6979
7853
  function resolveSandcastleBin() {
6980
7854
  try {
6981
7855
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
6982
- let dir = dirname8(fileURLToPath6(mainUrl));
7856
+ let dir = dirname9(fileURLToPath7(mainUrl));
6983
7857
  for (let hops = 0;hops < 6; hops++) {
6984
- const manifestPath = join16(dir, "package.json");
6985
- if (existsSync17(manifestPath)) {
6986
- const manifest = JSON.parse(readFileSync7(manifestPath, "utf8"));
7858
+ const manifestPath = join17(dir, "package.json");
7859
+ if (existsSync18(manifestPath)) {
7860
+ const manifest = JSON.parse(readFileSync8(manifestPath, "utf8"));
6987
7861
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
6988
- return join16(dir, manifest.bin.sandcastle);
7862
+ return join17(dir, manifest.bin.sandcastle);
6989
7863
  }
6990
7864
  }
6991
- const parent = dirname8(dir);
7865
+ const parent = dirname9(dir);
6992
7866
  if (parent === dir)
6993
7867
  break;
6994
7868
  dir = parent;
@@ -7010,21 +7884,21 @@ function terminalSpec(kind, opts) {
7010
7884
  };
7011
7885
  }
7012
7886
  function sandcastleTemplateDir() {
7013
- return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath6(new URL("../assets/sandcastle", import.meta.url)));
7887
+ return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
7014
7888
  }
7015
7889
  function scaffoldSandcastleConfig(templateDir, targetDir) {
7016
- const dir = join16(targetDir, ".sandcastle");
7017
- if (existsSync17(dir))
7890
+ const dir = join17(targetDir, ".sandcastle");
7891
+ if (existsSync18(dir))
7018
7892
  return { scaffolded: false, dir };
7019
- mkdirSync8(dir, { recursive: true });
7893
+ mkdirSync9(dir, { recursive: true });
7020
7894
  for (const name of readdirSync6(templateDir)) {
7021
- copyFileSync(join16(templateDir, name), join16(dir, name));
7895
+ copyFileSync(join17(templateDir, name), join17(dir, name));
7022
7896
  }
7023
7897
  return { scaffolded: true, dir };
7024
7898
  }
7025
7899
  function prepareSandboxBuildContext() {
7026
7900
  const target = sandboxBuildDir();
7027
- mkdirSync8(target, { recursive: true });
7901
+ mkdirSync9(target, { recursive: true });
7028
7902
  scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
7029
7903
  return target;
7030
7904
  }
@@ -7060,9 +7934,9 @@ function createTokenVerifier(exec) {
7060
7934
  function fileAfkTokenIo(exec) {
7061
7935
  const path = envPath();
7062
7936
  return {
7063
- read: () => existsSync17(path) ? readFileSync7(path, "utf8") : "",
7937
+ read: () => existsSync18(path) ? readFileSync8(path, "utf8") : "",
7064
7938
  write: (content) => {
7065
- mkdirSync8(dirname8(path), { recursive: true });
7939
+ mkdirSync9(dirname9(path), { recursive: true });
7066
7940
  writeFileSync4(path, content, "utf8");
7067
7941
  },
7068
7942
  verify: createTokenVerifier(exec)
@@ -7077,7 +7951,7 @@ var init_setup = __esm(() => {
7077
7951
  });
7078
7952
 
7079
7953
  // src/trpc/routers/setup.ts
7080
- import * as z11 from "zod";
7954
+ import * as z12 from "zod";
7081
7955
  var setupRouter;
7082
7956
  var init_setup2 = __esm(() => {
7083
7957
  init_src();
@@ -7093,9 +7967,9 @@ var init_setup2 = __esm(() => {
7093
7967
  ...ctx.config.sandboxImage ? { imageName: ctx.config.sandboxImage } : {}
7094
7968
  })),
7095
7969
  runtimeGuide: publicProcedure.query(() => runtimeInstallGuide(process.platform)),
7096
- gitIdentity: publicProcedure.input(z11.object({ name: z11.string(), email: z11.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
7097
- afkToken: publicProcedure.input(z11.object({ token: z11.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
7098
- startTerminal: publicProcedure.input(z11.object({ kind: z11.enum(["setup-token", "build-image"]) })).mutation(async ({ ctx, input }) => {
7970
+ gitIdentity: publicProcedure.input(z12.object({ name: z12.string(), email: z12.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
7971
+ afkToken: publicProcedure.input(z12.object({ token: z12.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
7972
+ startTerminal: publicProcedure.input(z12.object({ kind: z12.enum(["setup-token", "build-image"]) })).mutation(async ({ ctx, input }) => {
7099
7973
  const exec = createSystemExec();
7100
7974
  const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
7101
7975
  const runtime = await resolveRuntime(exec, preferred);
@@ -7195,16 +8069,16 @@ var init_update_check = __esm(() => {
7195
8069
  });
7196
8070
 
7197
8071
  // src/version.ts
7198
- import { readFileSync as readFileSync8 } from "fs";
7199
- import { dirname as dirname9, join as join17 } from "path";
7200
- import { fileURLToPath as fileURLToPath7 } from "url";
8072
+ import { readFileSync as readFileSync9 } from "fs";
8073
+ import { dirname as dirname10, join as join18 } from "path";
8074
+ import { fileURLToPath as fileURLToPath8 } from "url";
7201
8075
  function runcastleVersion() {
7202
8076
  if (cached2)
7203
8077
  return cached2;
7204
- const here = dirname9(fileURLToPath7(import.meta.url));
7205
- for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
8078
+ const here = dirname10(fileURLToPath8(import.meta.url));
8079
+ for (const pkgPath of [join18(here, "..", "package.json"), join18(here, "package.json")]) {
7206
8080
  try {
7207
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
8081
+ const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
7208
8082
  if (typeof pkg.version === "string") {
7209
8083
  cached2 = pkg.version;
7210
8084
  return cached2;
@@ -7230,7 +8104,7 @@ var init_system = __esm(() => {
7230
8104
  });
7231
8105
 
7232
8106
  // src/trpc/routers/ticket.ts
7233
- import * as z12 from "zod";
8107
+ import * as z13 from "zod";
7234
8108
  var ticketRouter;
7235
8109
  var init_ticket = __esm(() => {
7236
8110
  init_features();
@@ -7239,8 +8113,8 @@ var init_ticket = __esm(() => {
7239
8113
  init_ticket_burner();
7240
8114
  init_context();
7241
8115
  ticketRouter = router({
7242
- retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
7243
- stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ ctx, input }) => {
8116
+ retry: publicProcedure.input(z13.object({ ticketId: z13.string(), fresh: z13.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
8117
+ stop: publicProcedure.input(z13.object({ ticketId: z13.string() })).mutation(({ ctx, input }) => {
7244
8118
  if (stopTicketRun(input.ticketId))
7245
8119
  return { stopped: true, swept: false };
7246
8120
  const ticket = getTicket(ctx, input.ticketId);
@@ -7250,7 +8124,7 @@ var init_ticket = __esm(() => {
7250
8124
  }
7251
8125
  return { stopped: false, swept: false };
7252
8126
  }),
7253
- cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
8127
+ cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
7254
8128
  });
7255
8129
  });
7256
8130
 
@@ -7281,18 +8155,18 @@ var init_router = __esm(() => {
7281
8155
  });
7282
8156
 
7283
8157
  // src/workflows/reconcile-runs.ts
7284
- import { eq as eq11 } from "drizzle-orm";
8158
+ import { eq as eq13 } from "drizzle-orm";
7285
8159
  async function reconcileStaleRuns(ctx) {
7286
- const stale = ctx.db.select().from(runs).where(eq11(runs.status, "running")).all().map(rowToRun);
8160
+ const stale = ctx.db.select().from(runs).where(eq13(runs.status, "running")).all().map(rowToRun);
7287
8161
  const reconciled = [];
7288
- for (const run3 of stale) {
7289
- if (isRunActive(run3.id))
8162
+ for (const run4 of stale) {
8163
+ if (isRunActive(run4.id))
7290
8164
  continue;
7291
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq11(runs.id, run3.id)).run();
7292
- const released = releaseForSession(ctx, run3.id);
7293
- const swept = workflowClaimsFeatureBranch(run3.workflow) ? sweepOrphanedBurning(ctx, run3.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
7294
- if (workflowClaimsFeatureBranch(run3.workflow)) {
7295
- const feature = tryGetFeature(ctx, run3.featureId);
8165
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq13(runs.id, run4.id)).run();
8166
+ const released = releaseForSession(ctx, run4.id);
8167
+ const swept = workflowClaimsFeatureBranch(run4.workflow) ? sweepOrphanedBurning(ctx, run4.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
8168
+ if (workflowClaimsFeatureBranch(run4.workflow)) {
8169
+ const feature = tryGetFeature(ctx, run4.featureId);
7296
8170
  const project = feature ? getProjectById(ctx, feature.projectId) : null;
7297
8171
  if (feature && project) {
7298
8172
  try {
@@ -7300,18 +8174,18 @@ async function reconcileStaleRuns(ctx) {
7300
8174
  } catch {}
7301
8175
  }
7302
8176
  }
7303
- emit(ctx, run3.featureId, {
8177
+ emit(ctx, run4.featureId, {
7304
8178
  type: "run.reconciled",
7305
- message: `run marked failed at boot \u2014 the server restarted while it was running (${run3.workflow})`,
7306
- runId: run3.id,
8179
+ message: `run marked failed at boot \u2014 the server restarted while it was running (${run4.workflow})`,
8180
+ runId: run4.id,
7307
8181
  data: {
7308
- runId: run3.id,
7309
- workflow: run3.workflow,
8182
+ runId: run4.id,
8183
+ workflow: run4.workflow,
7310
8184
  releasedWaypointIds: released.map((w) => w.id),
7311
8185
  sweptTicketSeqs: swept.map((t2) => t2.seq)
7312
8186
  }
7313
8187
  });
7314
- reconciled.push(run3);
8188
+ reconciled.push(run4);
7315
8189
  }
7316
8190
  for (const project of allProjects(ctx)) {
7317
8191
  try {
@@ -7374,6 +8248,10 @@ async function startServer() {
7374
8248
  if (staleRuns.length > 0) {
7375
8249
  console.log(`reconciled ${staleRuns.length} stale run(s) from a previous server run`);
7376
8250
  }
8251
+ const stalePreps = reconcilePreps(ctx);
8252
+ if (stalePreps > 0) {
8253
+ console.log(`reconciled ${stalePreps} stale preparation run(s) from a previous server run`);
8254
+ }
7377
8255
  const server = Bun.serve({
7378
8256
  port: config2.serverPort,
7379
8257
  fetch(req, srv) {
@@ -7403,6 +8281,7 @@ var init_src2 = __esm(() => {
7403
8281
  init_client();
7404
8282
  init_migrate();
7405
8283
  init_reconcile();
8284
+ init_prep();
7406
8285
  init_server();
7407
8286
  init_registry();
7408
8287
  init_ws();