runcastle 1.0.24 → 1.0.26

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
@@ -21,19 +21,38 @@ __export(exports_paths, {
21
21
  worktreeDir: () => worktreeDir,
22
22
  sessionDir: () => sessionDir,
23
23
  sandboxBuildDir: () => sandboxBuildDir,
24
+ sameDataDir: () => sameDataDir,
25
+ projectWorktreesDir: () => projectWorktreesDir,
26
+ prodDataDir: () => prodDataDir,
24
27
  logsDir: () => logsDir,
25
28
  featureDocsRel: () => featureDocsRel,
26
29
  envPath: () => envPath,
30
+ devDataDir: () => devDataDir,
27
31
  dbPath: () => dbPath,
28
32
  dataDir: () => dataDir,
29
33
  configPath: () => configPath,
30
34
  burnCacheDir: () => burnCacheDir
31
35
  });
32
36
  import { homedir } from "os";
33
- import { join } from "path";
34
- function dataDir() {
37
+ import { join, resolve } from "path";
38
+ function prodDataDir() {
35
39
  return join(homedir(), ".runcastle");
36
40
  }
41
+ function devDataDir() {
42
+ const override = process.env.RUNCASTLE_DEV_DATA_DIR;
43
+ return override ? resolve(override) : join(homedir(), ".runcastle-dev");
44
+ }
45
+ function dataDir() {
46
+ const override = process.env.RUNCASTLE_DATA_DIR;
47
+ return override ? resolve(override) : prodDataDir();
48
+ }
49
+ function sameDataDir(a, b) {
50
+ const canon = (p) => {
51
+ const abs = resolve(p).replace(/[\\/]+$/, "");
52
+ return process.platform === "win32" ? abs.toLowerCase().replace(/\//g, "\\") : abs;
53
+ };
54
+ return canon(a) === canon(b);
55
+ }
37
56
  function dbPath() {
38
57
  return join(dataDir(), "runcastle.db");
39
58
  }
@@ -49,8 +68,11 @@ function logsDir() {
49
68
  function sessionDir(id) {
50
69
  return join(dataDir(), "sessions", id);
51
70
  }
71
+ function projectWorktreesDir(projectId) {
72
+ return join(dataDir(), "worktrees", projectId);
73
+ }
52
74
  function worktreeDir(projectId, slug) {
53
- return join(dataDir(), "worktrees", projectId, slug);
75
+ return join(projectWorktreesDir(projectId), slug);
54
76
  }
55
77
  function burnCacheDir(pm) {
56
78
  return join(dataDir(), "cache", pm);
@@ -68,6 +90,20 @@ import * as z from "zod";
68
90
  function resolveSandboxImage(config) {
69
91
  return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
70
92
  }
93
+ function resolvePreparedSettings(config, project) {
94
+ const pick = (a, b) => {
95
+ const own = a?.trim();
96
+ if (own)
97
+ return own;
98
+ const inherited = b?.trim();
99
+ return inherited || undefined;
100
+ };
101
+ return {
102
+ setupCommand: pick(project?.setupCommand, config.setupCommand),
103
+ verifyCommands: pick(project?.verifyCommands, config.verifyCommands),
104
+ knownFailures: pick(project?.knownFailures, config.knownFailures)
105
+ };
106
+ }
71
107
  function resolveModel(step, config, project, runOverride) {
72
108
  return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
73
109
  }
@@ -93,6 +129,7 @@ var init_config = __esm(() => {
93
129
  "revisit",
94
130
  "research",
95
131
  "implement",
132
+ "prepare",
96
133
  "smoke"
97
134
  ];
98
135
  ModelStep = z.enum(MODEL_STEPS);
@@ -101,6 +138,7 @@ var init_config = __esm(() => {
101
138
  model: z.string().default("claude-opus-5"),
102
139
  stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
103
140
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
141
+ sessionMcp: z.enum(["inherit", "runcastleOnly"]).default("inherit"),
104
142
  mainBranch: z.string().default("main"),
105
143
  sandboxImage: z.string().optional(),
106
144
  burnConcurrency: z.number().int().min(1).max(8).default(3),
@@ -109,6 +147,7 @@ var init_config = __esm(() => {
109
147
  burnMaxIterations: z.number().int().min(1).max(10).default(3),
110
148
  burnAttempts: z.number().int().min(1).max(5).default(3),
111
149
  burnConflictAttempts: z.number().int().min(0).max(3).default(2),
150
+ autoPrepare: z.boolean().default(true),
112
151
  setupCommand: z.string().optional(),
113
152
  verifyCommands: z.string().optional(),
114
153
  knownFailures: z.string().optional(),
@@ -141,6 +180,8 @@ function loadConfig(env = process.env) {
141
180
  overrides.smokeModel = env.RUNCASTLE_SMOKE_MODEL;
142
181
  if (env.RUNCASTLE_SANDBOX)
143
182
  overrides.sandbox = env.RUNCASTLE_SANDBOX;
183
+ if (env.RUNCASTLE_SESSION_MCP)
184
+ overrides.sessionMcp = env.RUNCASTLE_SESSION_MCP;
144
185
  if (env.RUNCASTLE_MAIN_BRANCH)
145
186
  overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
146
187
  if (env.RUNCASTLE_SANDBOX_IMAGE)
@@ -202,7 +243,7 @@ var init_ids = () => {};
202
243
 
203
244
  // ../core/src/schemas.ts
204
245
  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;
246
+ 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
247
  var init_schemas = __esm(() => {
207
248
  Phase = z2.enum([
208
249
  "ideation",
@@ -213,7 +254,7 @@ var init_schemas = __esm(() => {
213
254
  "shipped"
214
255
  ]);
215
256
  TicketStatus = z2.enum(["pending", "burning", "done", "failed", "cancelled"]);
216
- SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit"]);
257
+ SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit", "prepare"]);
217
258
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
218
259
  FeatureStatus = z2.enum(["active", "shipped", "archived"]);
219
260
  SessionStatus = z2.enum(["launching", "live", "ended"]);
@@ -255,13 +296,50 @@ var init_schemas = __esm(() => {
255
296
  lastSessionId: z2.string().optional(),
256
297
  summary: z2.string().optional()
257
298
  });
299
+ PREPARED_KEYS = [
300
+ "setupCommand",
301
+ "verifyCommands",
302
+ "knownFailures",
303
+ "devCommand",
304
+ "driveSetupCommand",
305
+ "driveStopCommand",
306
+ "driveEnv",
307
+ "dbResetCommand"
308
+ ];
309
+ PreparedKey = z2.enum(PREPARED_KEYS);
310
+ FindingSource = z2.enum(["prep", "human", "session"]);
258
311
  Project = z2.object({
259
312
  id: z2.string(),
260
313
  name: z2.string(),
261
314
  repoPath: z2.string(),
262
315
  mainBranch: z2.string(),
263
316
  devCommand: z2.string().optional(),
264
- model: z2.string().optional()
317
+ model: z2.string().optional(),
318
+ setupCommand: z2.string().optional(),
319
+ verifyCommands: z2.string().optional(),
320
+ knownFailures: z2.string().optional(),
321
+ dbResetCommand: z2.string().optional(),
322
+ driveSetupCommand: z2.string().optional(),
323
+ driveStopCommand: z2.string().optional(),
324
+ driveEnv: z2.string().optional()
325
+ });
326
+ ProjectFinding = z2.object({
327
+ key: PreparedKey,
328
+ source: FindingSource,
329
+ evidence: z2.string().optional(),
330
+ establishedAt: z2.number(),
331
+ establishedSha: z2.string().optional(),
332
+ staleCommits: z2.number().optional()
333
+ });
334
+ PrepStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
335
+ PrepRun = z2.object({
336
+ id: z2.string(),
337
+ projectId: z2.string(),
338
+ status: PrepStatus,
339
+ startedAt: z2.number(),
340
+ endedAt: z2.number().optional(),
341
+ summary: z2.string().optional(),
342
+ headSha: z2.string().optional()
265
343
  });
266
344
  Feature = z2.object({
267
345
  id: z2.string(),
@@ -278,7 +356,8 @@ var init_schemas = __esm(() => {
278
356
  });
279
357
  SessionRow = z2.object({
280
358
  id: z2.string(),
281
- featureId: z2.string(),
359
+ featureId: z2.string().optional(),
360
+ projectId: z2.string().optional(),
282
361
  kind: SessionKind,
283
362
  ccSessionId: z2.string().optional(),
284
363
  transcriptPath: z2.string().optional(),
@@ -460,8 +539,8 @@ var init_pipeline = __esm(() => {
460
539
  });
461
540
 
462
541
  // ../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;
542
+ import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
543
+ var projects, projectFindings, projectPreps, features, sessions, tickets, waypoints, runs, events, gateOverrides;
465
544
  var init_db_schema = __esm(() => {
466
545
  projects = sqliteTable("projects", {
467
546
  id: text("id").primaryKey(),
@@ -471,7 +550,31 @@ var init_db_schema = __esm(() => {
471
550
  devCommand: text("dev_command"),
472
551
  model: text("model"),
473
552
  sandbox: text("sandbox"),
474
- closedAt: integer("closed_at")
553
+ closedAt: integer("closed_at"),
554
+ setupCommand: text("setup_command"),
555
+ verifyCommands: text("verify_commands"),
556
+ knownFailures: text("known_failures"),
557
+ dbResetCommand: text("db_reset_command"),
558
+ driveSetupCommand: text("drive_setup_command"),
559
+ driveStopCommand: text("drive_stop_command"),
560
+ driveEnv: text("drive_env")
561
+ });
562
+ projectFindings = sqliteTable("project_findings", {
563
+ projectId: text("project_id").notNull(),
564
+ key: text("key").notNull(),
565
+ source: text("source").notNull().$type(),
566
+ evidence: text("evidence"),
567
+ establishedAt: integer("established_at").notNull(),
568
+ establishedSha: text("established_sha")
569
+ }, (t) => [primaryKey({ columns: [t.projectId, t.key] })]);
570
+ projectPreps = sqliteTable("project_preps", {
571
+ id: text("id").primaryKey(),
572
+ projectId: text("project_id").notNull(),
573
+ status: text("status").notNull().$type(),
574
+ startedAt: integer("started_at").notNull(),
575
+ endedAt: integer("ended_at"),
576
+ summary: text("summary"),
577
+ headSha: text("head_sha")
475
578
  });
476
579
  features = sqliteTable("features", {
477
580
  id: text("id").primaryKey(),
@@ -488,7 +591,8 @@ var init_db_schema = __esm(() => {
488
591
  });
489
592
  sessions = sqliteTable("sessions", {
490
593
  id: text("id").primaryKey(),
491
- featureId: text("feature_id").notNull(),
594
+ featureId: text("feature_id"),
595
+ projectId: text("project_id"),
492
596
  kind: text("kind").notNull().$type(),
493
597
  ccSessionId: text("cc_session_id"),
494
598
  transcriptPath: text("transcript_path"),
@@ -575,7 +679,9 @@ var init_schema = __esm(() => {
575
679
  waypoints,
576
680
  runs,
577
681
  events,
578
- gateOverrides
682
+ gateOverrides,
683
+ projectFindings,
684
+ projectPreps
579
685
  };
580
686
  });
581
687
 
@@ -598,11 +704,11 @@ var init_client = __esm(() => {
598
704
 
599
705
  // src/launcher/asset-paths.ts
600
706
  import { existsSync as existsSync2 } from "fs";
601
- import { join as join3, resolve } from "path";
707
+ import { join as join3, resolve as resolve2 } from "path";
602
708
  function resolveAsset(envVar, fallback) {
603
709
  const override = process.env[envVar];
604
710
  if (override) {
605
- const abs = resolve(override);
711
+ const abs = resolve2(override);
606
712
  if (existsSync2(abs))
607
713
  return abs;
608
714
  throw new Error(`${envVar}=${override} does not exist (resolved ${abs})`);
@@ -1282,6 +1388,13 @@ function emit(ctx, featureId, e) {
1282
1388
  function emitProject(ctx, projectId, e) {
1283
1389
  return insertEvent(ctx, projectId, null, e);
1284
1390
  }
1391
+ function emitForSession(ctx, session, e) {
1392
+ if (session.featureId)
1393
+ return emit(ctx, session.featureId, e);
1394
+ if (session.projectId)
1395
+ return emitProject(ctx, session.projectId, e);
1396
+ return null;
1397
+ }
1285
1398
  function insertEvent(ctx, projectId, featureId, e) {
1286
1399
  const row = ctx.db.insert(events).values({
1287
1400
  projectId,
@@ -1340,7 +1453,14 @@ function rowToProject(row) {
1340
1453
  repoPath: row.repoPath,
1341
1454
  mainBranch: row.mainBranch,
1342
1455
  devCommand: row.devCommand ?? undefined,
1343
- model: row.model ?? undefined
1456
+ model: row.model ?? undefined,
1457
+ setupCommand: row.setupCommand ?? undefined,
1458
+ verifyCommands: row.verifyCommands ?? undefined,
1459
+ knownFailures: row.knownFailures ?? undefined,
1460
+ dbResetCommand: row.dbResetCommand ?? undefined,
1461
+ driveSetupCommand: row.driveSetupCommand ?? undefined,
1462
+ driveStopCommand: row.driveStopCommand ?? undefined,
1463
+ driveEnv: row.driveEnv ?? undefined
1344
1464
  };
1345
1465
  }
1346
1466
  function rowToRun(row) {
@@ -1357,7 +1477,8 @@ function rowToRun(row) {
1357
1477
  function rowToSession(row) {
1358
1478
  return {
1359
1479
  id: row.id,
1360
- featureId: row.featureId,
1480
+ featureId: row.featureId ?? undefined,
1481
+ projectId: row.projectId ?? undefined,
1361
1482
  kind: row.kind,
1362
1483
  ccSessionId: row.ccSessionId ?? undefined,
1363
1484
  transcriptPath: row.transcriptPath ?? undefined,
@@ -1574,7 +1695,7 @@ function releaseForSession(ctx, claimant) {
1574
1695
  const held = ctx.db.select().from(waypoints).where(and3(eq3(waypoints.claimedBy, claimant), eq3(waypoints.status, "claimed"))).all().map(rowToWaypoint);
1575
1696
  return held.map((w) => release(ctx, w.id));
1576
1697
  }
1577
- function resolve2(ctx, id, disposition, summary) {
1698
+ function resolve3(ctx, id, disposition, summary) {
1578
1699
  const wp = getWaypoint(ctx, id);
1579
1700
  const before = listByFeature(ctx, wp.featureId);
1580
1701
  const bySeqBefore = new Map(before.map((w) => [w.seq, w]));
@@ -1617,7 +1738,8 @@ import { and as and4, desc as desc2, eq as eq4, inArray, isNotNull, sql as sql2
1617
1738
  function createSessionRow(ctx, input) {
1618
1739
  const row = ctx.db.insert(sessions).values({
1619
1740
  id: newId("sess"),
1620
- featureId: input.featureId,
1741
+ featureId: input.featureId ?? null,
1742
+ projectId: input.projectId ?? null,
1621
1743
  kind: input.kind,
1622
1744
  ccSessionId: null,
1623
1745
  transcriptPath: null,
@@ -1672,34 +1794,159 @@ function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS)
1672
1794
  }, submitDelayMs);
1673
1795
  submit.unref?.();
1674
1796
  }
1675
- function scheduleKickoff(ctx, session) {
1676
- const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
1677
- pendingKickoffOverrides.delete(session.id);
1678
- const timer = setTimeout(() => {
1797
+ function stopTimers(d) {
1798
+ for (const t of d.timers)
1799
+ clearTimeout(t);
1800
+ d.timers.clear();
1801
+ }
1802
+ function forgetKickoff(sessionId) {
1803
+ const d = deliveries.get(sessionId);
1804
+ if (d)
1805
+ stopTimers(d);
1806
+ deliveries.delete(sessionId);
1807
+ pendingKickoffOverrides.delete(sessionId);
1808
+ }
1809
+ function ptyIo(sessionId) {
1810
+ return {
1811
+ write: (data) => ptyRegistry().get(sessionId)?.pty.write(data),
1812
+ alive: () => {
1813
+ const entry = ptyRegistry().get(sessionId);
1814
+ return !!entry && !entry.exited;
1815
+ }
1816
+ };
1817
+ }
1818
+ function track(d, timer) {
1819
+ timer.unref?.();
1820
+ d.timers.add(timer);
1821
+ }
1822
+ function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
1823
+ track(d, setTimeout(() => {
1824
+ if (d.settled)
1825
+ return;
1826
+ const io = ptyIo(session.id);
1827
+ if (!io.alive()) {
1828
+ d.settled = true;
1829
+ return;
1830
+ }
1831
+ d.attempts += 1;
1832
+ const attempt = d.attempts;
1679
1833
  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
- }
1834
+ if (clearFirst)
1835
+ io.write(CLEAR_INPUT);
1836
+ writeKickoffSequence(d.line, {
1837
+ write: io.write,
1838
+ alive: io.alive,
1839
+ onSubmitted: () => emitForSession(ctx, session, {
1840
+ type: "session.kickoff",
1841
+ message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
1842
+ data: { sessionId: session.id, kind: session.kind, attempt }
1843
+ })
1693
1844
  });
1694
1845
  } catch {}
1695
- }, KICKOFF_DELAY_MS);
1846
+ armConfirmation(ctx, session, d);
1847
+ }, delayMs));
1848
+ }
1849
+ function armConfirmation(ctx, session, d) {
1850
+ track(d, setTimeout(() => {
1851
+ if (d.settled || d.confirmed)
1852
+ return;
1853
+ if (d.attempts < KICKOFF_MAX_ATTEMPTS && ptyIo(session.id).alive()) {
1854
+ attemptKickoff(ctx, session, d, 0, true);
1855
+ return;
1856
+ }
1857
+ settleUndelivered(ctx, session, d, "unacknowledged");
1858
+ }, KICKOFF_CONFIRM_MS));
1859
+ }
1860
+ function settleUndelivered(ctx, session, d, reason) {
1861
+ d.settled = true;
1862
+ stopTimers(d);
1863
+ emitForSession(ctx, session, {
1864
+ type: "session.kickoff_undelivered",
1865
+ 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`,
1866
+ data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
1867
+ });
1868
+ }
1869
+ function noteKickoffPrompt(ctx, sessionId, prompt) {
1870
+ const d = deliveries.get(sessionId);
1871
+ if (!d || d.confirmed)
1872
+ return;
1873
+ const session = getSessionRow(ctx, sessionId);
1874
+ if (!session)
1875
+ return;
1876
+ if (promptMatchesKickoff(d.line, prompt)) {
1877
+ d.confirmed = true;
1878
+ d.settled = true;
1879
+ stopTimers(d);
1880
+ return;
1881
+ }
1882
+ if (d.settled)
1883
+ return;
1884
+ settleUndelivered(ctx, session, d, "superseded");
1885
+ }
1886
+ function promptMatchesKickoff(line, prompt) {
1887
+ if (!prompt)
1888
+ return false;
1889
+ const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
1890
+ const want = norm(line);
1891
+ const got = norm(prompt);
1892
+ if (!want || !got)
1893
+ return false;
1894
+ return got.includes(want.slice(0, MATCH_PREFIX));
1895
+ }
1896
+ function resendKickoff(ctx, sessionId) {
1897
+ const session = getSessionRow(ctx, sessionId);
1898
+ if (!session)
1899
+ throw new Error(`unknown session ${sessionId}`);
1900
+ if (session.status === "ended")
1901
+ throw new Error("that session has ended \u2014 open a new terminal");
1902
+ if (!ptyIo(sessionId).alive())
1903
+ throw new Error("the terminal for that session is no longer running");
1904
+ const existing = deliveries.get(sessionId);
1905
+ const d = existing ?? {
1906
+ line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
1907
+ attempts: 0,
1908
+ confirmed: false,
1909
+ settled: false,
1910
+ timers: new Set
1911
+ };
1912
+ stopTimers(d);
1913
+ d.attempts = 0;
1914
+ d.confirmed = false;
1915
+ d.settled = false;
1916
+ deliveries.set(sessionId, d);
1917
+ attemptKickoff(ctx, session, d, 0, true);
1918
+ return { line: d.line };
1919
+ }
1920
+ function armSessionReadyWatchdog(ctx, session) {
1921
+ const timer = setTimeout(() => {
1922
+ const row = getSessionRow(ctx, session.id);
1923
+ if (!row || row.status !== "launching")
1924
+ return;
1925
+ if (!ptyIo(session.id).alive())
1926
+ return;
1927
+ emitForSession(ctx, session, {
1928
+ type: "session.not_ready",
1929
+ 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`,
1930
+ data: { sessionId: session.id, kind: session.kind }
1931
+ });
1932
+ }, SESSION_READY_TIMEOUT_MS);
1696
1933
  timer.unref?.();
1697
1934
  }
1935
+ function scheduleKickoff(ctx, session) {
1936
+ const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
1937
+ pendingKickoffOverrides.delete(session.id);
1938
+ const existing = deliveries.get(session.id);
1939
+ if (existing)
1940
+ stopTimers(existing);
1941
+ const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
1942
+ deliveries.set(session.id, d);
1943
+ attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
1944
+ }
1698
1945
  function markSessionEnded(ctx, id) {
1699
1946
  const existing = getSessionRow(ctx, id);
1700
1947
  if (!existing)
1701
1948
  return null;
1702
- pendingKickoffOverrides.delete(id);
1949
+ forgetKickoff(id);
1703
1950
  ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
1704
1951
  return getSessionRow(ctx, id);
1705
1952
  }
@@ -1707,11 +1954,19 @@ function mostRecentResumableSession(ctx, featureId, kind) {
1707
1954
  const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId), ...kind ? [eq4(sessions.kind, kind)] : [])).orderBy(desc2(sql2`rowid`)).limit(1).get();
1708
1955
  return row ? rowToSession(row) : null;
1709
1956
  }
1957
+ function activeProjectSession(ctx, projectId, kind) {
1958
+ const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc2(sql2`rowid`)).limit(1).get();
1959
+ return row ? rowToSession(row) : null;
1960
+ }
1961
+ function mostRecentResumableProjectSession(ctx, projectId, kind) {
1962
+ const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc2(sql2`rowid`)).limit(1).get();
1963
+ return row ? rowToSession(row) : null;
1964
+ }
1710
1965
  function mostRecentLiveSession(ctx) {
1711
1966
  const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1712
1967
  return row ? rowToSession(row) : null;
1713
1968
  }
1714
- var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides;
1969
+ 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
1970
  var init_sessions = __esm(() => {
1716
1971
  init_src();
1717
1972
  init_schema();
@@ -1725,10 +1980,12 @@ var init_sessions = __esm(() => {
1725
1980
  qa: "Proceed with your task: invoke the /runcastle:qa skill and answer questions from the " + "docs and code \u2014 do not advance phases or emit tickets.",
1726
1981
  waypoint: "Proceed with your task: invoke the /runcastle:waypoint skill and work your assigned " + "waypoint to a resolution.",
1727
1982
  converge: CONVERGE_KICKOFF_LINE,
1728
- revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up."
1983
+ revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up.",
1984
+ prepare: "Proceed with your task: work through the unestablished preparation fields with the human. " + "Start by telling them which fields are still open and what you need from them for each; " + "ask before running anything that touches their database or services."
1729
1985
  };
1730
1986
  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
1987
  pendingKickoffOverrides = new Map;
1988
+ deliveries = new Map;
1732
1989
  });
1733
1990
 
1734
1991
  // src/launcher/reconcile.ts
@@ -1742,7 +1999,7 @@ function reconcileStaleSessions(ctx) {
1742
1999
  continue;
1743
2000
  markSessionEnded(ctx, session.id);
1744
2001
  const released = releaseForSession(ctx, session.id);
1745
- emit(ctx, session.featureId, {
2002
+ emitForSession(ctx, session, {
1746
2003
  type: "session.reconciled",
1747
2004
  message: `session marked ended at boot \u2014 the server restarted while it was ${session.status}`,
1748
2005
  data: {
@@ -1764,351 +2021,288 @@ var init_reconcile = __esm(() => {
1764
2021
  init_sessions();
1765
2022
  });
1766
2023
 
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
2024
+ // src/launcher/skills-root.ts
1786
2025
  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);
2026
+ import { dirname, join as join5, resolve as resolve4 } from "path";
2027
+ function resolveSkillsRoot(fromDir) {
2028
+ const override = process.env[SKILLS_DIR_ENV];
2029
+ if (override) {
2030
+ const root = resolve4(override);
2031
+ if (existsSync5(join5(root, SKILLS_MARKER)))
2032
+ return root;
2033
+ throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
2034
+ }
2035
+ const searched = [];
2036
+ let dir = fromDir;
2037
+ for (let i = 0;i < 8; i += 1) {
2038
+ const candidate = join5(dir, "packages", "skills");
2039
+ searched.push(candidate);
2040
+ if (existsSync5(join5(candidate, SKILLS_MARKER)))
2041
+ return candidate;
2042
+ const parent = dirname(dir);
2043
+ if (parent === dir)
2044
+ break;
2045
+ dir = parent;
2046
+ }
2047
+ throw new Error(`runcastle skills root not found; searched:
2048
+ ${searched.join(`
2049
+ `)}`);
1796
2050
  }
1797
- var init_feature_docs = __esm(() => {
1798
- init_paths();
2051
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
2052
+ var init_skills_root = __esm(() => {
2053
+ SKILLS_MARKER = join5("packs", "runcastle");
1799
2054
  });
1800
2055
 
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
- };
2056
+ // src/pty/dev-pane.ts
2057
+ function drivePaneId(featureId) {
2058
+ return `${DRIVE_PREFIX}${featureId}`;
1820
2059
  }
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
- }
2060
+ function sniffDevUrl(text2) {
2061
+ const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
2062
+ return m ? m[0] : undefined;
1829
2063
  }
1830
- function listByFeature2(ctx, featureId) {
1831
- return ctx.db.select().from(tickets).where(eq5(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
2064
+ function devSpawnTarget(devCommand) {
2065
+ if (process.platform === "win32") {
2066
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
2067
+ }
2068
+ return { file: "/bin/sh", args: ["-c", devCommand] };
1832
2069
  }
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();
2070
+ function startDevPane(input) {
2071
+ const { ctx, featureId, repoPath, devCommand, env, onUrl } = input;
2072
+ const paneId = drivePaneId(featureId);
2073
+ const { file, args } = devSpawnTarget(devCommand);
2074
+ try {
2075
+ ptyRegistry().create({
2076
+ sessionId: paneId,
2077
+ cmd: file,
2078
+ args,
2079
+ opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
2080
+ });
2081
+ } catch (err) {
2082
+ emit(ctx, featureId, {
2083
+ type: "testdrive.dev_failed",
2084
+ message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
2085
+ data: { paneId }
2086
+ });
2087
+ return;
2088
+ }
2089
+ let scanned = "";
2090
+ let found = false;
2091
+ const sniffer = {
2092
+ sendData(chunk) {
2093
+ if (found)
2094
+ return;
2095
+ scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
2096
+ const url = sniffDevUrl(scanned);
2097
+ if (url) {
2098
+ found = true;
2099
+ onUrl(url);
2100
+ }
2101
+ },
2102
+ sendControl() {}
2103
+ };
2104
+ ptyRegistry().attach(paneId, sniffer);
1856
2105
  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) }
2106
+ type: "testdrive.dev_started",
2107
+ message: `dev server spawned: ${devCommand}`,
2108
+ data: { paneId }
1860
2109
  });
1861
- return rows.map(rowToTicket);
2110
+ return paneId;
1862
2111
  }
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);
2112
+ function stopDevPane(paneId) {
2113
+ const reg = ptyRegistry();
2114
+ const entry = reg.get(paneId);
2115
+ if (entry && !entry.exited && process.platform !== "win32") {
2116
+ try {
2117
+ process.kill(-entry.pty.pid, "SIGTERM");
2118
+ } catch {}
2119
+ }
2120
+ reg.kill(paneId);
2121
+ reg.remove(paneId);
1868
2122
  }
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"}`);
2123
+ var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
2124
+ var init_dev_pane = __esm(() => {
2125
+ init_events();
2126
+ init_registry();
2127
+ });
2128
+
2129
+ // src/services/drive-hooks.ts
2130
+ import { spawn as spawn2 } from "child_process";
2131
+ function tailLines(text2, max = OUTPUT_TAIL_LINES) {
2132
+ const lines = text2.replace(/\r\n/g, `
2133
+ `).split(`
2134
+ `);
2135
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
2136
+ lines.pop();
2137
+ return lines.slice(-max).join(`
2138
+ `).trim();
2139
+ }
2140
+ function hookSpawnTarget(command) {
2141
+ if (process.platform === "win32") {
2142
+ return {
2143
+ file: process.env.ComSpec ?? "cmd.exe",
2144
+ args: ["/d", "/s", "/c", `"${command}"`],
2145
+ verbatim: true
2146
+ };
1872
2147
  }
2148
+ return { file: "/bin/sh", args: ["-c", command], verbatim: false };
1873
2149
  }
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");
1890
- }
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
- };
2150
+ function runDriveHook(command, opts) {
2151
+ const spawnFn = opts.spawnFn ?? spawn2;
2152
+ const now = opts.now ?? Date.now;
2153
+ const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
2154
+ const startedAt = now();
2155
+ const { file, args, verbatim } = hookSpawnTarget(command);
2156
+ return new Promise((resolve5) => {
2157
+ let output = "";
2158
+ let timedOut = false;
2159
+ let settled = false;
2160
+ let timer;
2161
+ const finish = (exitCode) => {
2162
+ if (settled)
2163
+ return;
2164
+ settled = true;
2165
+ if (timer)
2166
+ clearTimeout(timer);
2167
+ resolve5({
2168
+ ok: !timedOut && exitCode === 0,
2169
+ exitCode,
2170
+ timedOut,
2171
+ output: tailLines(output),
2172
+ durationMs: now() - startedAt
2173
+ });
2174
+ };
2175
+ let child;
2176
+ try {
2177
+ child = spawnFn(file, args, {
2178
+ cwd: opts.cwd,
2179
+ env: opts.env ?? process.env,
2180
+ windowsHide: true,
2181
+ windowsVerbatimArguments: verbatim
2182
+ });
2183
+ } catch (err) {
2184
+ output = err instanceof Error ? err.message : String(err);
2185
+ finish(null);
2186
+ return;
1991
2187
  }
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 }
2188
+ timer = setTimeout(() => {
2189
+ timedOut = true;
2190
+ child.kill();
2191
+ const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
2192
+ grace.unref?.();
2193
+ }, timeoutMs);
2194
+ timer.unref?.();
2195
+ child.stdout?.on("data", (d) => {
2196
+ output += d.toString();
2197
+ });
2198
+ child.stderr?.on("data", (d) => {
2199
+ output += d.toString();
2200
+ });
2201
+ child.on("error", (err) => {
2202
+ output += `
2203
+ ${err instanceof Error ? err.message : String(err)}`;
2204
+ finish(null);
2205
+ });
2206
+ child.on("close", (code) => {
2207
+ finish(code);
2208
+ });
2018
2209
  });
2019
- const next = nextPhase(feature);
2020
- if (next)
2021
- return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
2022
- return feature;
2023
2210
  }
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();
2211
+ function describeHookResult(command, result) {
2212
+ const secs = Math.max(1, Math.round(result.durationMs / 1000));
2213
+ if (result.timedOut)
2214
+ return `\`${command}\` timed out after ${secs}s`;
2215
+ if (result.ok)
2216
+ return `\`${command}\` finished in ${secs}s`;
2217
+ return `\`${command}\` exited ${result.exitCode ?? "without a code"} after ${secs}s`;
2218
+ }
2219
+ var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
2220
+ var init_drive_hooks = __esm(() => {
2221
+ DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
2032
2222
  });
2033
2223
 
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() {}
2224
+ // src/services/drive-env.ts
2225
+ import { createHash } from "crypto";
2226
+ function identifierSafe(slug, max = MAX_ID_LENGTH) {
2227
+ const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2228
+ const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
2229
+ if (safe.length <= max)
2230
+ return safe;
2231
+ const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
2232
+ return `${safe.slice(0, max - digest.length - 1)}_${digest}`;
2233
+ }
2234
+ function driveVars(identity) {
2235
+ return {
2236
+ slug: identity.slug,
2237
+ branch: identity.branch,
2238
+ id: identifierSafe(identity.slug)
2081
2239
  };
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);
2100
2240
  }
2101
- var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
2102
- var init_dev_pane = __esm(() => {
2103
- init_events();
2104
- init_registry();
2241
+ function renderTemplate(value, vars) {
2242
+ const unknown2 = [];
2243
+ const out = value.replace(PLACEHOLDER_RE, (match, name) => {
2244
+ const replacement = vars[name];
2245
+ if (replacement === undefined) {
2246
+ if (!unknown2.includes(name))
2247
+ unknown2.push(name);
2248
+ return match;
2249
+ }
2250
+ return replacement;
2251
+ });
2252
+ return { value: out, unknown: unknown2 };
2253
+ }
2254
+ function parseDriveEnv(raw, identity) {
2255
+ const vars = {};
2256
+ const unknown2 = [];
2257
+ if (!raw?.trim())
2258
+ return { vars, unknown: unknown2 };
2259
+ const substitutions = driveVars(identity);
2260
+ for (const line of raw.split(/\r?\n/)) {
2261
+ const trimmed = line.trim();
2262
+ if (trimmed === "" || trimmed.startsWith("#"))
2263
+ continue;
2264
+ const eq5 = trimmed.indexOf("=");
2265
+ if (eq5 <= 0)
2266
+ continue;
2267
+ const key = trimmed.slice(0, eq5).trim().replace(/^export\s+/, "");
2268
+ if (key === "")
2269
+ continue;
2270
+ const rendered = renderTemplate(unquote(trimmed.slice(eq5 + 1).trim()), substitutions);
2271
+ vars[key] = rendered.value;
2272
+ for (const name of rendered.unknown)
2273
+ if (!unknown2.includes(name))
2274
+ unknown2.push(name);
2275
+ }
2276
+ return { vars, unknown: unknown2 };
2277
+ }
2278
+ function unquote(value) {
2279
+ if (value.length < 2)
2280
+ return value;
2281
+ const first = value[0];
2282
+ if ((first === '"' || first === "'") && value.endsWith(first))
2283
+ return value.slice(1, -1);
2284
+ return value;
2285
+ }
2286
+ function driveProcessEnv(overrides, base = process.env) {
2287
+ return { ...base, ...overrides };
2288
+ }
2289
+ function describeDriveEnv(vars) {
2290
+ const keys = Object.keys(vars);
2291
+ return `drive environment: ${keys.join(", ")}`;
2292
+ }
2293
+ var MAX_ID_LENGTH = 40, PLACEHOLDER_RE;
2294
+ var init_drive_env = __esm(() => {
2295
+ PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
2105
2296
  });
2106
2297
 
2107
2298
  // src/services/git.ts
2108
- import { existsSync as existsSync7, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2299
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2109
2300
  import { tmpdir } from "os";
2110
- import { dirname, join as join6, resolve as resolve3 } from "path";
2301
+ import { dirname as dirname2, join as join6, resolve as resolve5 } from "path";
2111
2302
  import { simpleGit } from "simple-git";
2303
+ function migrationPaths(paths) {
2304
+ return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
2305
+ }
2112
2306
  function featureBranch(slug) {
2113
2307
  return `feature/${slug}`;
2114
2308
  }
@@ -2119,14 +2313,14 @@ function errMsg(e) {
2119
2313
  return e instanceof Error ? e.message : String(e);
2120
2314
  }
2121
2315
  function canon(p) {
2122
- let abs = resolve3(p);
2316
+ let abs = resolve5(p);
2123
2317
  try {
2124
2318
  abs = realpathSync.native(abs);
2125
2319
  } catch {}
2126
2320
  return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2127
2321
  }
2128
2322
  async function assertRepo(repoPath) {
2129
- if (!existsSync7(repoPath)) {
2323
+ if (!existsSync6(repoPath)) {
2130
2324
  throw new InvalidInputError(`path does not exist: ${repoPath}`);
2131
2325
  }
2132
2326
  let isRepo = false;
@@ -2215,7 +2409,7 @@ async function ensureTalkWorktree(project, feature) {
2215
2409
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2216
2410
  if (await worktreeIsValid(g, worktreePath, branch))
2217
2411
  return worktreePath;
2218
- mkdirSync2(dirname(worktreePath), { recursive: true });
2412
+ mkdirSync2(dirname2(worktreePath), { recursive: true });
2219
2413
  try {
2220
2414
  await g.raw(["worktree", "add", worktreePath, branch]);
2221
2415
  return worktreePath;
@@ -2238,7 +2432,7 @@ async function ensureBranchExists(g, branch, from) {
2238
2432
  }
2239
2433
  }
2240
2434
  async function worktreeIsValid(g, worktreePath, branch) {
2241
- if (!existsSync7(worktreePath))
2435
+ if (!existsSync6(worktreePath))
2242
2436
  return false;
2243
2437
  const registered = await registeredWorktrees(g);
2244
2438
  if (!registered.has(canon(worktreePath)))
@@ -2284,7 +2478,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2284
2478
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2285
2479
  }
2286
2480
  async function detachWorktree(path) {
2287
- if (!existsSync7(path))
2481
+ if (!existsSync6(path))
2288
2482
  return false;
2289
2483
  const g = git(path);
2290
2484
  let head;
@@ -2299,12 +2493,27 @@ async function detachWorktree(path) {
2299
2493
  return true;
2300
2494
  }
2301
2495
  async function reattachWorktree(path, branch) {
2302
- if (!existsSync7(path))
2496
+ if (!existsSync6(path))
2303
2497
  return;
2304
2498
  try {
2305
2499
  await git(path).checkout(branch);
2306
2500
  } catch {}
2307
2501
  }
2502
+ function prepBranchName(unique) {
2503
+ return `${PREP_BRANCH_PREFIX}${unique}`;
2504
+ }
2505
+ async function readFileAtRef(repoPath, ref, path) {
2506
+ try {
2507
+ return await git(repoPath).raw(["show", `${ref}:${path}`]);
2508
+ } catch {
2509
+ return;
2510
+ }
2511
+ }
2512
+ async function deletePrepBranch(repoPath, branch) {
2513
+ try {
2514
+ await deleteBranchDetachingWorktrees(git(repoPath), repoPath, branch);
2515
+ } catch {}
2516
+ }
2308
2517
  function tempBranchSlugSegment(slug) {
2309
2518
  return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
2310
2519
  }
@@ -2322,14 +2531,14 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2322
2531
  const attempts = Math.max(1, opts.attempts ?? 3);
2323
2532
  const delayMs = opts.delayMs ?? 750;
2324
2533
  const g = git(repoPath);
2325
- for (let attempt = 1;attempt <= attempts && existsSync7(path); attempt++) {
2534
+ for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2326
2535
  try {
2327
2536
  await g.raw(["worktree", "remove", "--force", path]);
2328
2537
  } catch {}
2329
- if (existsSync7(path) && attempt < attempts)
2538
+ if (existsSync6(path) && attempt < attempts)
2330
2539
  await sleep(delayMs);
2331
2540
  }
2332
- if (existsSync7(path)) {
2541
+ if (existsSync6(path)) {
2333
2542
  try {
2334
2543
  rmSync(path, { recursive: true, force: true });
2335
2544
  } catch {}
@@ -2337,7 +2546,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2337
2546
  try {
2338
2547
  await g.raw(["worktree", "prune"]);
2339
2548
  } catch {}
2340
- return !existsSync7(path);
2549
+ return !existsSync6(path);
2341
2550
  }
2342
2551
  function sleep(ms) {
2343
2552
  return new Promise((done) => setTimeout(done, ms));
@@ -2351,16 +2560,42 @@ async function branchCommitsAhead(repoPath, base, branch) {
2351
2560
  return [];
2352
2561
  }
2353
2562
  }
2354
- async function commitSummaries(repoPath, base, branch, limit = 20) {
2563
+ async function headSha(repoPath, ref) {
2355
2564
  try {
2356
- const out = await git(repoPath).raw([
2357
- "log",
2358
- `--max-count=${limit}`,
2359
- "--format=%h %s",
2360
- `${base}..${branch}`
2361
- ]);
2362
- return out.split(`
2363
- `).map((l) => l.trim()).filter(Boolean);
2565
+ const out = (await git(repoPath).revparse([ref])).trim();
2566
+ return out.length > 0 ? out : undefined;
2567
+ } catch {
2568
+ return;
2569
+ }
2570
+ }
2571
+ async function commitsSince(repoPath, sha, branch) {
2572
+ try {
2573
+ const out = (await git(repoPath).raw(["rev-list", "--count", `${sha}..${branch}`])).trim();
2574
+ const n = Number(out);
2575
+ return Number.isFinite(n) ? n : undefined;
2576
+ } catch {
2577
+ return;
2578
+ }
2579
+ }
2580
+ async function diffPaths(repoPath, from, to) {
2581
+ try {
2582
+ const out = (await git(repoPath).raw(["diff", "--name-only", from, to])).trim();
2583
+ return out ? out.split(`
2584
+ `).map((s) => s.trim()).filter(Boolean) : [];
2585
+ } catch {
2586
+ return [];
2587
+ }
2588
+ }
2589
+ async function commitSummaries(repoPath, base, branch, limit = 20) {
2590
+ try {
2591
+ const out = await git(repoPath).raw([
2592
+ "log",
2593
+ `--max-count=${limit}`,
2594
+ "--format=%h %s",
2595
+ `${base}..${branch}`
2596
+ ]);
2597
+ return out.split(`
2598
+ `).map((l) => l.trim()).filter(Boolean);
2364
2599
  } catch {
2365
2600
  return [];
2366
2601
  }
@@ -2546,7 +2781,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2546
2781
  try {
2547
2782
  await g.raw(["worktree", "prune"]);
2548
2783
  } catch {}
2549
- if (existsSync7(worktreePath)) {
2784
+ if (existsSync6(worktreePath)) {
2550
2785
  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
2786
  }
2552
2787
  }
@@ -2573,7 +2808,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2573
2808
  return { deleted, kept };
2574
2809
  }
2575
2810
  async function commitDocs(worktreePath, message) {
2576
- if (!existsSync7(resolve3(worktreePath, DOCS_PATHSPEC)))
2811
+ if (!existsSync6(resolve5(worktreePath, DOCS_PATHSPEC)))
2577
2812
  return;
2578
2813
  const g = git(worktreePath);
2579
2814
  await g.add([DOCS_PATHSPEC]);
@@ -2618,6 +2853,8 @@ async function testDrive(ctx, project, feature, action) {
2618
2853
  const devPaneId = testDriveState.devPaneId;
2619
2854
  if (devPaneId)
2620
2855
  stopDevPane(devPaneId);
2856
+ const teardownFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "teardown", project.driveStopCommand, driveEnvFor(ctx, project, feature, branch));
2857
+ const carriedChanges = await dirtyPaths(g);
2621
2858
  await g.checkout(previousBranch2);
2622
2859
  if (detachedWorktree2)
2623
2860
  await reattachWorktree(detachedWorktree2, branch);
@@ -2627,7 +2864,21 @@ async function testDrive(ctx, project, feature, action) {
2627
2864
  message: `test drive stopped \u2014 back on ${previousBranch2}`,
2628
2865
  data: { branch: previousBranch2 }
2629
2866
  });
2630
- return { ok: true, branch: previousBranch2 };
2867
+ if (carriedChanges.length > 0) {
2868
+ emit(ctx, feature.id, {
2869
+ type: "testdrive.carried_changes",
2870
+ message: `${carriedChanges.length} uncommitted file(s) came back with you onto ${previousBranch2}: ${carriedChanges.slice(0, 5).join(", ")}${carriedChanges.length > 5 ? ", \u2026" : ""}`,
2871
+ data: { branch: previousBranch2, files: carriedChanges }
2872
+ });
2873
+ }
2874
+ const dbDrift = await detectDbDrift(ctx, project, feature, previousBranch2, branch);
2875
+ return {
2876
+ ok: true,
2877
+ branch: previousBranch2,
2878
+ ...carriedChanges.length > 0 ? { carriedChanges } : {},
2879
+ ...dbDrift ? { dbDrift } : {},
2880
+ ...teardownFailure ? { hookFailure: teardownFailure } : {}
2881
+ };
2631
2882
  }
2632
2883
  const porcelain = (await g.raw(["status", "--porcelain"])).trim();
2633
2884
  if (porcelain !== "")
@@ -2651,18 +2902,105 @@ async function testDrive(ctx, project, feature, action) {
2651
2902
  message: `test driving ${branch} (was on ${previousBranch})`,
2652
2903
  data: { branch, previousBranch }
2653
2904
  });
2905
+ const driveEnv = driveEnvFor(ctx, project, feature, branch);
2906
+ const setupFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "setup", project.driveSetupCommand, driveEnv);
2654
2907
  if (project.devCommand) {
2655
2908
  const devPaneId = startDevPane({
2656
2909
  ctx,
2657
2910
  featureId: feature.id,
2658
2911
  repoPath: project.repoPath,
2659
2912
  devCommand: project.devCommand,
2913
+ env: driveEnv,
2660
2914
  onUrl: (url) => recordDriveUrl(ctx, feature.id, url)
2661
2915
  });
2662
2916
  if (devPaneId)
2663
2917
  testDriveState.devPaneId = devPaneId;
2664
2918
  }
2665
- return { ok: true, branch };
2919
+ return { ok: true, branch, ...setupFailure ? { hookFailure: setupFailure } : {} };
2920
+ }
2921
+ function driveEnvFor(ctx, project, feature, branch) {
2922
+ const { vars, unknown: unknown2 } = parseDriveEnv(project.driveEnv, { slug: feature.slug, branch });
2923
+ if (unknown2.length > 0) {
2924
+ emit(ctx, feature.id, {
2925
+ type: "testdrive.env_unknown_placeholder",
2926
+ message: `drive environment left ${unknown2.map((u) => `{{${u}}}`).join(", ")} unsubstituted \u2014 known variables are {{slug}}, {{branch}}, {{id}}`,
2927
+ data: { unknown: unknown2 }
2928
+ });
2929
+ }
2930
+ if (Object.keys(vars).length > 0) {
2931
+ emit(ctx, feature.id, {
2932
+ type: "testdrive.env",
2933
+ message: describeDriveEnv(vars),
2934
+ data: { keys: Object.keys(vars) }
2935
+ });
2936
+ }
2937
+ return driveProcessEnv(vars);
2938
+ }
2939
+ async function runDriveHookStep(ctx, featureId, repoPath, phase, command, env) {
2940
+ const cmd = command?.trim();
2941
+ if (!cmd)
2942
+ return;
2943
+ emit(ctx, featureId, {
2944
+ type: `testdrive.${phase}_started`,
2945
+ message: phase === "setup" ? `preparing environment: \`${cmd}\`` : `tearing down environment: \`${cmd}\``,
2946
+ data: { command: cmd }
2947
+ });
2948
+ const result = await runDriveHook(cmd, { cwd: repoPath, env });
2949
+ if (result.ok) {
2950
+ emit(ctx, featureId, {
2951
+ type: `testdrive.${phase}_ok`,
2952
+ message: describeHookResult(cmd, result),
2953
+ data: { command: cmd, durationMs: result.durationMs }
2954
+ });
2955
+ return;
2956
+ }
2957
+ emit(ctx, featureId, {
2958
+ type: `testdrive.${phase}_failed`,
2959
+ message: `${describeHookResult(cmd, result)}${result.output ? `
2960
+ ${result.output}` : ""}`,
2961
+ data: {
2962
+ command: cmd,
2963
+ exitCode: result.exitCode,
2964
+ timedOut: result.timedOut,
2965
+ output: result.output
2966
+ }
2967
+ });
2968
+ return {
2969
+ phase,
2970
+ command: cmd,
2971
+ exitCode: result.exitCode,
2972
+ timedOut: result.timedOut,
2973
+ output: result.output
2974
+ };
2975
+ }
2976
+ async function dirtyPaths(g) {
2977
+ try {
2978
+ const out = (await g.raw(["status", "--porcelain"])).trim();
2979
+ if (!out)
2980
+ return [];
2981
+ return out.split(`
2982
+ `).map((line) => line.slice(3).trim()).map((p) => p.includes(" -> ") ? p.split(" -> ").at(-1) ?? p : p).map((p) => p.replace(/^"|"$/g, "")).filter(Boolean);
2983
+ } catch {
2984
+ return [];
2985
+ }
2986
+ }
2987
+ async function detectDbDrift(ctx, project, feature, previousBranch, driveBranch) {
2988
+ let files;
2989
+ try {
2990
+ files = migrationPaths(await diffPaths(project.repoPath, previousBranch, driveBranch));
2991
+ } catch {
2992
+ return;
2993
+ }
2994
+ if (files.length === 0)
2995
+ return;
2996
+ const resetCommand = project.dbResetCommand?.trim() || undefined;
2997
+ const drift = { files, ...resetCommand ? { resetCommand } : {} };
2998
+ emit(ctx, feature.id, {
2999
+ type: "testdrive.db_drift",
3000
+ 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.'),
3001
+ data: { files, previousBranch, driveBranch, resetCommand: resetCommand ?? null }
3002
+ });
3003
+ return drift;
2666
3004
  }
2667
3005
  async function mergeFeature(project, feature) {
2668
3006
  const target = feature.baseBranch ?? project.mainBranch;
@@ -2719,171 +3057,20 @@ async function mergeInProgress(g) {
2719
3057
  return false;
2720
3058
  }
2721
3059
  }
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;
3060
+ 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
3061
  var init_git = __esm(() => {
2724
3062
  init_paths();
2725
3063
  init_errors();
2726
3064
  init_dev_pane();
3065
+ init_drive_hooks();
3066
+ init_drive_env();
2727
3067
  init_events();
2728
3068
  init_repo();
3069
+ MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
3070
+ DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
2729
3071
  TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
2730
3072
  });
2731
3073
 
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
3074
  // src/services/agent-stream.ts
2888
3075
  function beginTranscript(ticketId) {
2889
3076
  transcripts.set(ticketId, { live: true, chunks: [], nextIndex: 0, bytes: 0 });
@@ -3012,8 +3199,8 @@ var init_burn_guard = __esm(() => {
3012
3199
  });
3013
3200
 
3014
3201
  // 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";
3202
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
3203
+ import { dirname as dirname3, join as join7 } from "path";
3017
3204
  import { fileURLToPath as fileURLToPath2 } from "url";
3018
3205
  import { claudeCode, run } from "@ai-hero/sandcastle";
3019
3206
  import { docker } from "@ai-hero/sandcastle/sandboxes/docker";
@@ -3062,7 +3249,7 @@ function detectCycle(tickets2) {
3062
3249
  }
3063
3250
  return null;
3064
3251
  }
3065
- function renderTemplate(template, values) {
3252
+ function renderTemplate2(template, values) {
3066
3253
  let out = template;
3067
3254
  for (const [key, value] of Object.entries(values)) {
3068
3255
  out = out.split(`{{${key}}}`).join(value);
@@ -3070,7 +3257,7 @@ function renderTemplate(template, values) {
3070
3257
  return out;
3071
3258
  }
3072
3259
  function renderTicketPrompt(template, values) {
3073
- return renderTemplate(template, values);
3260
+ return renderTemplate2(template, values);
3074
3261
  }
3075
3262
  function buildTicketJson(ticket) {
3076
3263
  return JSON.stringify({
@@ -3129,13 +3316,13 @@ function parseEnvFile(content) {
3129
3316
  if (line.length === 0 || line.startsWith("#"))
3130
3317
  continue;
3131
3318
  const body = line.startsWith("export ") ? line.slice(7).trim() : line;
3132
- const eq6 = body.indexOf("=");
3133
- if (eq6 === -1)
3319
+ const eq5 = body.indexOf("=");
3320
+ if (eq5 === -1)
3134
3321
  continue;
3135
- const key = body.slice(0, eq6).trim();
3322
+ const key = body.slice(0, eq5).trim();
3136
3323
  if (key.length === 0)
3137
3324
  continue;
3138
- let value = body.slice(eq6 + 1).trim();
3325
+ let value = body.slice(eq5 + 1).trim();
3139
3326
  if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
3140
3327
  value = value.slice(1, -1);
3141
3328
  }
@@ -3158,6 +3345,9 @@ function detectPackageManager(tc) {
3158
3345
  return "npm";
3159
3346
  return tc.hasPackageJson ? "npm" : undefined;
3160
3347
  }
3348
+ function orFallback(strict, permissive) {
3349
+ return `( ${strict} || ${permissive} )`;
3350
+ }
3161
3351
  function resolveSetupCommand(tc, override) {
3162
3352
  const trimmed = override?.trim();
3163
3353
  if (trimmed)
@@ -3167,13 +3357,13 @@ function resolveSetupCommand(tc, override) {
3167
3357
  return;
3168
3358
  switch (pm) {
3169
3359
  case "bun":
3170
- return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
3360
+ return tc.lockfiles.bun ? orFallback("bun install --frozen-lockfile", "bun install") : "bun install";
3171
3361
  case "pnpm":
3172
- return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
3362
+ return tc.lockfiles.pnpm ? orFallback("corepack pnpm install --frozen-lockfile", "corepack pnpm install") : "corepack pnpm install";
3173
3363
  case "yarn":
3174
- return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
3364
+ return tc.lockfiles.yarn ? orFallback("corepack yarn install --frozen-lockfile", "corepack yarn install") : "corepack yarn install";
3175
3365
  case "npm":
3176
- return tc.lockfiles.npm ? "npm ci" : "npm install";
3366
+ return tc.lockfiles.npm ? orFallback("npm ci", "npm install") : "npm install";
3177
3367
  }
3178
3368
  }
3179
3369
  function cacheMountFor(pm, hostPath) {
@@ -3236,12 +3426,12 @@ function buildVerifyNotes(config2) {
3236
3426
  `);
3237
3427
  }
3238
3428
  function readRepoToolchain(repoPath) {
3239
- const pkgPath = join9(repoPath, "package.json");
3240
- const hasPackageJson = existsSync10(pkgPath);
3429
+ const pkgPath = join7(repoPath, "package.json");
3430
+ const hasPackageJson = existsSync7(pkgPath);
3241
3431
  let packageManagerField;
3242
3432
  if (hasPackageJson) {
3243
3433
  try {
3244
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
3434
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
3245
3435
  if (typeof pkg.packageManager === "string")
3246
3436
  packageManagerField = pkg.packageManager;
3247
3437
  } catch {}
@@ -3250,14 +3440,14 @@ function readRepoToolchain(repoPath) {
3250
3440
  hasPackageJson,
3251
3441
  ...packageManagerField !== undefined ? { packageManagerField } : {},
3252
3442
  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"))
3443
+ bun: existsSync7(join7(repoPath, "bun.lock")) || existsSync7(join7(repoPath, "bun.lockb")),
3444
+ pnpm: existsSync7(join7(repoPath, "pnpm-lock.yaml")),
3445
+ yarn: existsSync7(join7(repoPath, "yarn.lock")),
3446
+ npm: existsSync7(join7(repoPath, "package-lock.json"))
3257
3447
  }
3258
3448
  };
3259
3449
  }
3260
- function createStreamThrottle(emit2, opts = {}) {
3450
+ function createStreamThrottle(emit3, opts = {}) {
3261
3451
  const intervalMs = opts.intervalMs ?? 2000;
3262
3452
  const maxChars = opts.maxChars ?? 500;
3263
3453
  const now = opts.now ?? Date.now;
@@ -3267,7 +3457,7 @@ function createStreamThrottle(emit2, opts = {}) {
3267
3457
  const flushText = () => {
3268
3458
  if (buffer.length === 0)
3269
3459
  return;
3270
- emit2({ type: "burn.text", message: buffer, data: { iteration: lastIteration } });
3460
+ emit3({ type: "burn.text", message: buffer, data: { iteration: lastIteration } });
3271
3461
  buffer = "";
3272
3462
  lastFlush = now();
3273
3463
  };
@@ -3282,7 +3472,7 @@ function createStreamThrottle(emit2, opts = {}) {
3282
3472
  flushText();
3283
3473
  const rawArgs = event.formattedArgs ?? "";
3284
3474
  const args = rawArgs.length > 200 ? `${rawArgs.slice(0, 200)}\u2026` : rawArgs;
3285
- emit2({
3475
+ emit3({
3286
3476
  type: "burn.tool",
3287
3477
  message: args.length > 0 ? `${event.name} ${args}` : event.name,
3288
3478
  data: { name: event.name, args: rawArgs, iteration: event.iteration }
@@ -3428,38 +3618,38 @@ function createSerialQueue() {
3428
3618
  };
3429
3619
  }
3430
3620
  async function landWithResolve(branch, deps) {
3431
- let current2 = branch;
3621
+ let current = branch;
3432
3622
  for (let attempt = 1;; attempt++) {
3433
- const merge = await deps.merge(current2);
3623
+ const merge = await deps.merge(current);
3434
3624
  if (merge.ok)
3435
- return { status: "landed", branch: current2 };
3625
+ return { status: "landed", branch: current };
3436
3626
  const detail = merge.error ?? "merge failed";
3437
3627
  if (!merge.conflict)
3438
- return { status: "failed", branch: current2, error: detail };
3628
+ return { status: "failed", branch: current, error: detail };
3439
3629
  const files = merge.files ?? [];
3440
3630
  if (attempt > deps.maxResolveAttempts) {
3441
- return { status: "conflict", branch: current2, files, error: detail };
3631
+ return { status: "conflict", branch: current, files, error: detail };
3442
3632
  }
3443
3633
  const where = files.length > 0 ? `${files.length} file(s)` : "the merge";
3444
3634
  deps.emit({
3445
3635
  type: "merge.conflict.resolving",
3446
3636
  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 }
3637
+ data: { branch: current, files, attempt, maxAttempts: deps.maxResolveAttempts }
3448
3638
  });
3449
3639
  const resolved = await deps.resolve({
3450
- branch: current2,
3640
+ branch: current,
3451
3641
  files,
3452
3642
  attempt,
3453
3643
  maxAttempts: deps.maxResolveAttempts
3454
3644
  });
3455
- current2 = resolved.branch;
3645
+ current = resolved.branch;
3456
3646
  if (!resolved.ok) {
3457
- return { status: "conflict", branch: current2, files, error: resolved.error ?? detail };
3647
+ return { status: "conflict", branch: current, files, error: resolved.error ?? detail };
3458
3648
  }
3459
3649
  deps.emit({
3460
3650
  type: "merge.conflict.resolved",
3461
- message: `${deps.label}: conflict resolved on ${current2} \u2014 landing`,
3462
- data: { branch: current2, files, attempt }
3651
+ message: `${deps.label}: conflict resolved on ${current} \u2014 landing`,
3652
+ data: { branch: current, files, attempt }
3463
3653
  });
3464
3654
  }
3465
3655
  }
@@ -3617,26 +3807,26 @@ function resolverTemplatePath() {
3617
3807
  }
3618
3808
  function burnerAssetPath(file) {
3619
3809
  const here = dirname3(fileURLToPath2(import.meta.url));
3620
- return join9(resolveSkillsRoot(here), "burner", file);
3810
+ return join7(resolveSkillsRoot(here), "burner", file);
3621
3811
  }
3622
3812
  function readDocsDigestFromDisk(projectId, slug) {
3623
3813
  const worktree = worktreeDir(projectId, slug);
3624
- if (!existsSync10(worktree))
3814
+ if (!existsSync7(worktree))
3625
3815
  return "_No talk worktree on disk \u2014 docs digest skipped._";
3626
- const docsDir = join9(worktree, ...featureDocsRel(slug).split("/"));
3627
- if (!existsSync10(docsDir))
3816
+ const docsDir = join7(worktree, ...featureDocsRel(slug).split("/"));
3817
+ if (!existsSync7(docsDir))
3628
3818
  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") }));
3819
+ 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
3820
  return buildDocsDigest(files);
3631
3821
  }
3632
3822
  function readBlockedFile(dirs) {
3633
3823
  for (const dir of dirs) {
3634
3824
  if (!dir)
3635
3825
  continue;
3636
- const p = join9(dir, "BLOCKED.md");
3637
- if (existsSync10(p)) {
3826
+ const p = join7(dir, "BLOCKED.md");
3827
+ if (existsSync7(p)) {
3638
3828
  try {
3639
- return readFileSync4(p, "utf8");
3829
+ return readFileSync3(p, "utf8");
3640
3830
  } catch {}
3641
3831
  }
3642
3832
  }
@@ -3689,8 +3879,9 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3689
3879
  const featureBrief = buildFeatureBrief(feature);
3690
3880
  const docsDigest = readDocsDigestFromDisk(project.id, feature.slug);
3691
3881
  const workspaceNotes = buildWorkspaceNotes(workspaceMode);
3692
- const verifyNotes = buildVerifyNotes(config2);
3693
- const basePrompt = renderTicketPrompt(readFileSync4(burnerTemplatePath(), "utf8"), {
3882
+ const prepared = resolvePreparedSettings(config2, project);
3883
+ const verifyNotes = buildVerifyNotes(prepared);
3884
+ const basePrompt = renderTicketPrompt(readFileSync3(burnerTemplatePath(), "utf8"), {
3694
3885
  TICKET_JSON: ticketJson,
3695
3886
  FEATURE_BRIEF: featureBrief,
3696
3887
  DOCS_DIGEST: docsDigest,
@@ -3700,19 +3891,19 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3700
3891
  });
3701
3892
  const toolchain = readRepoToolchain(project.repoPath);
3702
3893
  const pm = detectPackageManager(toolchain);
3703
- const setupCommand = resolveSetupCommand(toolchain, config2.setupCommand);
3894
+ const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
3704
3895
  const mounts = [];
3705
3896
  if (config2.sandbox !== "noSandbox" && pm) {
3706
3897
  const mount = cacheMountFor(pm, burnCacheDir(pm));
3707
3898
  if (mount) {
3708
- mkdirSync4(mount.hostPath, { recursive: true });
3899
+ mkdirSync3(mount.hostPath, { recursive: true });
3709
3900
  mounts.push(mount);
3710
3901
  }
3711
3902
  }
3712
3903
  const guardInstall = config2.burnGuard && config2.sandbox !== "noSandbox" ? buildGuardInstallCommand() : undefined;
3713
3904
  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`);
3905
+ mkdirSync3(logsDir(), { recursive: true });
3906
+ const logFilePath = join7(logsDir(), `burn-${feature.id}-${ticket.seq}.log`);
3716
3907
  const throttle = createStreamThrottle((e) => ctx.emitEvent({ ...e, ticketId: ticket.id }));
3717
3908
  beginTranscript(ticket.id);
3718
3909
  const timer = createToolTimer();
@@ -3758,7 +3949,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3758
3949
  const runResolver = async (input) => {
3759
3950
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
3760
3951
  const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
3761
- const prompt = renderTemplate(readFileSync4(resolverTemplatePath(), "utf8"), {
3952
+ const prompt = renderTemplate2(readFileSync3(resolverTemplatePath(), "utf8"), {
3762
3953
  TICKET_JSON: ticketJson,
3763
3954
  FEATURE_BRIEF: featureBrief,
3764
3955
  DOCS_DIGEST: docsDigest,
@@ -4061,9 +4252,9 @@ function resolveBurnDeps(ctx) {
4061
4252
  }
4062
4253
  function readTokenFromEnvFile(path) {
4063
4254
  let fromFile;
4064
- if (existsSync10(path)) {
4255
+ if (existsSync7(path)) {
4065
4256
  try {
4066
- fromFile = parseEnvFile(readFileSync4(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
4257
+ fromFile = parseEnvFile(readFileSync3(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
4067
4258
  } catch {
4068
4259
  fromFile = undefined;
4069
4260
  }
@@ -4134,19 +4325,933 @@ var init_ticket_burner = __esm(() => {
4134
4325
  /\bapi error\b/i,
4135
4326
  /session capture failed/i
4136
4327
  ];
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
- };
4328
+ activeTicketAborts = new Map;
4329
+ SETUP_HOOK_TIMEOUT_MS = 15 * 60000;
4330
+ ticketBurner = {
4331
+ id: "ticket-burner",
4332
+ run: (ctx) => burnRun(ctx, resolveBurnDeps(ctx))
4333
+ };
4334
+ });
4335
+
4336
+ // src/workflows/project-prep.ts
4337
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync4 } from "fs";
4338
+ import { dirname as dirname4, join as join8 } from "path";
4339
+ import { fileURLToPath as fileURLToPath3 } from "url";
4340
+ import { run as run2 } from "@ai-hero/sandcastle";
4341
+ import * as z3 from "zod";
4342
+ function parsePrepFindings(raw) {
4343
+ let json;
4344
+ try {
4345
+ json = JSON.parse(raw);
4346
+ } catch (e) {
4347
+ throw new Error(`findings file is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
4348
+ }
4349
+ const outer = RawFindings.safeParse(json);
4350
+ if (!outer.success)
4351
+ throw new Error("findings file is not a JSON object");
4352
+ const warnings = [];
4353
+ const values = {};
4354
+ const known = new Set(PREPARED_KEYS);
4355
+ for (const [key, rawValue] of Object.entries(outer.data)) {
4356
+ if (key === "notes")
4357
+ continue;
4358
+ if (!known.has(key)) {
4359
+ warnings.push(`ignored unknown key "${key}"`);
4360
+ continue;
4361
+ }
4362
+ const parsed = RawFinding.safeParse(rawValue);
4363
+ if (!parsed.success) {
4364
+ warnings.push(`ignored "${key}": not a string or {value, evidence} object`);
4365
+ continue;
4366
+ }
4367
+ const entry = parsed.data;
4368
+ const value = (typeof entry === "string" ? entry : entry.value ?? "").trim();
4369
+ if (value === "")
4370
+ continue;
4371
+ const evidence = typeof entry === "string" ? undefined : entry.evidence?.trim();
4372
+ values[key] = { value, ...evidence ? { evidence } : {} };
4373
+ }
4374
+ const notes = outer.data.notes?.trim();
4375
+ return { findings: { values, ...notes ? { notes } : {} }, warnings };
4376
+ }
4377
+ function renderPrepPrompt(template, values) {
4378
+ let out = template;
4379
+ for (const key of PLACEHOLDERS) {
4380
+ out = out.split(`{{${key}}}`).join(values[key]);
4381
+ }
4382
+ return out;
4383
+ }
4384
+ function buildRequestedKeysBlock(keys) {
4385
+ if (keys.length === 0) {
4386
+ return "_Everything is already established. Report an empty findings object and stop._";
4387
+ }
4388
+ const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
4389
+ return [...keys].sort((a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0)).map((k) => `- ${KEY_BRIEF[k]}`).join(`
4390
+ `);
4391
+ }
4392
+ async function prepRun(ctx, deps) {
4393
+ if (ctx.keys.length === 0) {
4394
+ ctx.emitEvent({
4395
+ type: "prep.skipped",
4396
+ message: "nothing to prepare \u2014 every field is already set"
4397
+ });
4398
+ return { status: "succeeded", summary: "nothing to prepare" };
4399
+ }
4400
+ if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
4401
+ ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
4402
+ return { status: "failed", summary: "preparation aborted: auth token missing" };
4403
+ }
4404
+ ctx.emitEvent({
4405
+ type: "prep.started",
4406
+ message: `preparing ${ctx.project.name} \u2014 establishing ${ctx.keys.join(", ")}`,
4407
+ data: { keys: [...ctx.keys] }
4408
+ });
4409
+ const outcome = await deps.executePrepRun(ctx);
4410
+ if (outcome.status === "failed") {
4411
+ ctx.emitEvent({
4412
+ type: "prep.failed",
4413
+ message: `preparation failed: ${errorHeadline2(outcome.error)}`,
4414
+ data: { error: outcome.error }
4415
+ });
4416
+ return { status: "failed", summary: outcome.error };
4417
+ }
4418
+ for (const warning of outcome.warnings) {
4419
+ ctx.emitEvent({ type: "prep.warning", message: `preparation: ${warning}` });
4420
+ }
4421
+ const found = Object.keys(outcome.findings.values);
4422
+ const missed = ctx.keys.filter((k) => !found.includes(k));
4423
+ const summary = found.length === 0 ? "preparation established nothing" : `established ${found.join(", ")}${missed.length > 0 ? ` (could not establish ${missed.join(", ")})` : ""}`;
4424
+ return { status: "succeeded", summary, findings: outcome.findings };
4425
+ }
4426
+ function errorHeadline2(s) {
4427
+ const lines = s.split(`
4428
+ `).map((l) => l.trim()).filter(Boolean);
4429
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4430
+ return causes.at(-1) ?? lines[0] ?? "";
4431
+ }
4432
+ function createPrepStreamThrottle(emit3, opts = {}) {
4433
+ return createStreamThrottle((e) => emit3({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
4434
+ }
4435
+ function nonFatalSetup(setupCommand) {
4436
+ return `( ${setupCommand} || true )`;
4437
+ }
4438
+ function prepTemplatePath() {
4439
+ const here = dirname4(fileURLToPath3(import.meta.url));
4440
+ return join8(resolveSkillsRoot(here), "burner", "prepare-project.md");
4441
+ }
4442
+ async function realExecutePrepRun(ctx, config2, token, model) {
4443
+ const { project } = ctx;
4444
+ const branch = prepBranchName(newId("b").slice(2, 10));
4445
+ const toolchain = readRepoToolchain(project.repoPath);
4446
+ const pm = detectPackageManager(toolchain);
4447
+ const prepared = resolvePreparedSettings(config2, project);
4448
+ const setupCommand = resolveSetupCommand(toolchain, prepared.setupCommand);
4449
+ const workspaceMode = resolveBurnWorkspaceMode(config2);
4450
+ const mounts = [];
4451
+ if (config2.sandbox !== "noSandbox" && pm) {
4452
+ const mount = cacheMountFor(pm, burnCacheDir(pm));
4453
+ if (mount) {
4454
+ mkdirSync4(mount.hostPath, { recursive: true });
4455
+ mounts.push(mount);
4456
+ }
4457
+ }
4458
+ const template = readFileSync4(prepTemplatePath(), "utf8");
4459
+ const prompt = renderPrepPrompt(template, {
4460
+ REQUESTED_KEYS: buildRequestedKeysBlock(ctx.keys),
4461
+ SETUP_COMMAND: setupCommand ?? "(no install step detected)"
4462
+ });
4463
+ mkdirSync4(logsDir(), { recursive: true });
4464
+ const logFilePath = join8(logsDir(), `prep-${project.id}.log`);
4465
+ const throttle = createPrepStreamThrottle((e) => ctx.emitEvent(e));
4466
+ if (setupCommand) {
4467
+ ctx.emitEvent({
4468
+ type: "prep.setup",
4469
+ message: `attempting a dependency install before the agent starts (best-effort): ${setupCommand}`
4470
+ });
4471
+ }
4472
+ const prepSetupCommand = setupCommand ? nonFatalSetup(setupCommand) : undefined;
4473
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, prepSetupCommand, pm) : prepSetupCommand;
4474
+ const runOptions = {
4475
+ agent: buildBurnAgent(config2, token, model),
4476
+ sandbox: selectSandbox(config2, mounts),
4477
+ cwd: project.repoPath,
4478
+ prompt,
4479
+ branchStrategy: { type: "branch", branch, baseBranch: project.mainBranch },
4480
+ signal: ctx.signal,
4481
+ name: "prepare",
4482
+ maxIterations: 1,
4483
+ ...hookCommand ? {
4484
+ hooks: {
4485
+ sandbox: {
4486
+ onSandboxReady: [{ command: hookCommand, timeoutMs: PREP_SETUP_TIMEOUT_MS }]
4487
+ }
4488
+ }
4489
+ } : {},
4490
+ logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
4491
+ };
4492
+ try {
4493
+ let result;
4494
+ try {
4495
+ result = await run2(runOptions);
4496
+ } catch (err) {
4497
+ throttle.flush();
4498
+ if (ctx.signal.aborted)
4499
+ throw err;
4500
+ const msg = err instanceof Error ? err.message : String(err);
4501
+ const salvaged = await readFindings(project.repoPath, branch);
4502
+ if (!salvaged)
4503
+ return { status: "failed", error: msg };
4504
+ ctx.emitEvent({
4505
+ type: "prep.salvaged",
4506
+ message: `the agent finished but sandcastle errored on teardown (${errorHeadline2(msg)}) \u2014 reading the findings it committed anyway`
4507
+ });
4508
+ return salvaged;
4509
+ }
4510
+ throttle.flush();
4511
+ if (result.commits.length === 0) {
4512
+ return {
4513
+ status: "failed",
4514
+ error: `agent produced no commits \u2014 no ${PREP_FINDINGS_PATH} landed`
4515
+ };
4516
+ }
4517
+ const read = await readFindings(project.repoPath, branch);
4518
+ return read ?? {
4519
+ status: "failed",
4520
+ error: `agent committed, but ${PREP_FINDINGS_PATH} is missing from ${branch}`
4521
+ };
4522
+ } finally {
4523
+ await deletePrepBranch(project.repoPath, branch);
4524
+ }
4525
+ }
4526
+ async function readFindings(repoPath, branch) {
4527
+ const raw = await readFileAtRef(repoPath, branch, PREP_FINDINGS_PATH);
4528
+ if (raw === undefined)
4529
+ return;
4530
+ try {
4531
+ const { findings, warnings } = parsePrepFindings(raw);
4532
+ return { status: "done", findings, warnings };
4533
+ } catch (e) {
4534
+ return { status: "failed", error: e instanceof Error ? e.message : String(e) };
4535
+ }
4536
+ }
4537
+ function resolvePrepDeps(project) {
4538
+ const config2 = loadConfig();
4539
+ const token = readTokenFromEnvFile(envPath());
4540
+ const model = resolveModel("prepare", config2, project);
4541
+ return {
4542
+ config: config2,
4543
+ hasAuthToken: token !== undefined,
4544
+ executePrepRun: (c) => realExecutePrepRun(c, config2, token, model)
4545
+ };
4546
+ }
4547
+ async function prepHeadSha(project) {
4548
+ return existsSync8(project.repoPath) ? headSha(project.repoPath, project.mainBranch) : undefined;
4549
+ }
4550
+ 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;
4551
+ var init_project_prep = __esm(() => {
4552
+ init_src();
4553
+ init_config_load();
4554
+ init_paths();
4555
+ init_skills_root();
4556
+ init_git();
4557
+ init_ticket_burner();
4558
+ PREP_SETUP_TIMEOUT_MS = 15 * 60000;
4559
+ RawFinding = z3.union([
4560
+ z3.string(),
4561
+ z3.object({
4562
+ value: z3.string().nullish(),
4563
+ evidence: z3.string().nullish()
4564
+ })
4565
+ ]);
4566
+ RawFindings = z3.object({ notes: z3.string().nullish() }).catchall(z3.unknown());
4567
+ PLACEHOLDERS = ["REQUESTED_KEYS", "SETUP_COMMAND"];
4568
+ KEY_BRIEF = {
4569
+ 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.",
4570
+ 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.",
4571
+ 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.',
4572
+ 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.",
4573
+ driveSetupCommand: "**`driveSetupCommand`** \u2014 a single shell command that takes this repo from a fresh checkout to \"the dev server would actually work\": backing services up, schema applied, whatever this project needs. Chain steps with `&&`. Read it from the repo's own config (compose file, Makefile, package scripts, README); do not run it here. Omit it if the dev command alone is enough \u2014 an empty answer is better than a guessed one, because this runs on a developer's real machine.",
4574
+ driveStopCommand: "**`driveStopCommand`** \u2014 the matching teardown for `driveSetupCommand`, if this project has one (`docker compose down`, a stop script). Read it from config; do not run it here. Omit it if setup leaves nothing that needs stopping.",
4575
+ driveEnv: "**`driveEnv`** \u2014 `KEY=VALUE` lines (one per line) overlaid on the dev server's environment during a test drive, with `{{slug}}`, `{{branch}}` and `{{id}}` (an identifier-safe slug) rendered per drive. Its purpose is giving a branch its own database, e.g. `DATABASE_URL=postgres://localhost:5432/myapp_{{id}}` paired with a `driveSetupCommand` that creates it. Report it ONLY if you can read the real connection variable's name and shape out of the repo's own config or `.env.example`; a connection string you assembled from defaults points at the wrong database convincingly, which is worse than omitting the key. Omit it if the project has no database or you had to guess the host, port or credentials.",
4576
+ 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."
4577
+ };
4578
+ });
4579
+
4580
+ // src/services/findings.ts
4581
+ import { and as and5, eq as eq5 } from "drizzle-orm";
4582
+ function isPreparedKey(key) {
4583
+ return PREPARED_SET.has(key);
4584
+ }
4585
+ function findingRow(ctx, projectId, key) {
4586
+ return ctx.db.select({
4587
+ source: projectFindings.source,
4588
+ evidence: projectFindings.evidence,
4589
+ establishedAt: projectFindings.establishedAt,
4590
+ establishedSha: projectFindings.establishedSha
4591
+ }).from(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).get();
4592
+ }
4593
+ function isOverwritable(ctx, projectId, key) {
4594
+ return findingRow(ctx, projectId, key)?.source !== "human";
4595
+ }
4596
+ function recordFinding(ctx, projectId, input) {
4597
+ const { key, value, source, evidence, establishedSha } = input;
4598
+ ctx.db.transaction((tx) => {
4599
+ tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq5(projects.id, projectId)).run();
4600
+ if (value === null) {
4601
+ tx.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
4602
+ return;
4603
+ }
4604
+ tx.insert(projectFindings).values({
4605
+ projectId,
4606
+ key,
4607
+ source,
4608
+ evidence: evidence ?? null,
4609
+ establishedAt: Date.now(),
4610
+ establishedSha: establishedSha ?? null
4611
+ }).onConflictDoUpdate({
4612
+ target: [projectFindings.projectId, projectFindings.key],
4613
+ set: {
4614
+ source,
4615
+ evidence: evidence ?? null,
4616
+ establishedAt: Date.now(),
4617
+ establishedSha: establishedSha ?? null
4618
+ }
4619
+ }).run();
4620
+ });
4621
+ }
4622
+ function recordHuman(ctx, projectId, key, value) {
4623
+ if (!isPreparedKey(key))
4624
+ return;
4625
+ const existing = findingRow(ctx, projectId, key);
4626
+ if (value === null) {
4627
+ if (existing) {
4628
+ ctx.db.delete(projectFindings).where(and5(eq5(projectFindings.projectId, projectId), eq5(projectFindings.key, key))).run();
4629
+ }
4630
+ return;
4631
+ }
4632
+ ctx.db.insert(projectFindings).values({
4633
+ projectId,
4634
+ key,
4635
+ source: "human",
4636
+ evidence: null,
4637
+ establishedAt: Date.now(),
4638
+ establishedSha: null
4639
+ }).onConflictDoUpdate({
4640
+ target: [projectFindings.projectId, projectFindings.key],
4641
+ set: { source: "human", evidence: null, establishedAt: Date.now(), establishedSha: null }
4642
+ }).run();
4643
+ }
4644
+ async function listFindings(ctx, project) {
4645
+ const rows = ctx.db.select().from(projectFindings).where(eq5(projectFindings.projectId, project.id)).all();
4646
+ const distances = new Map;
4647
+ for (const row of rows) {
4648
+ const sha = row.establishedSha;
4649
+ if (!sha || distances.has(sha))
4650
+ continue;
4651
+ distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
4652
+ }
4653
+ const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
4654
+ return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
4655
+ const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
4656
+ return {
4657
+ key: row.key,
4658
+ source: row.source,
4659
+ ...row.evidence ? { evidence: row.evidence } : {},
4660
+ establishedAt: row.establishedAt,
4661
+ ...row.establishedSha ? { establishedSha: row.establishedSha } : {},
4662
+ ...staleCommits !== undefined ? { staleCommits } : {}
4663
+ };
4664
+ });
4665
+ }
4666
+ function unsetPreparedKeys(project) {
4667
+ return PREPARED_KEYS.filter((key) => {
4668
+ const value = project[key];
4669
+ return value === undefined || value.trim() === "";
4670
+ });
4671
+ }
4672
+ var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET;
4673
+ var init_findings = __esm(() => {
4674
+ init_src();
4675
+ init_schema();
4676
+ init_git();
4677
+ VALUE_COLUMN = {
4678
+ setupCommand: projects.setupCommand,
4679
+ verifyCommands: projects.verifyCommands,
4680
+ knownFailures: projects.knownFailures,
4681
+ devCommand: projects.devCommand,
4682
+ driveSetupCommand: projects.driveSetupCommand,
4683
+ driveStopCommand: projects.driveStopCommand,
4684
+ driveEnv: projects.driveEnv,
4685
+ dbResetCommand: projects.dbResetCommand
4686
+ };
4687
+ COLUMN_NAME = {
4688
+ setupCommand: "setupCommand",
4689
+ verifyCommands: "verifyCommands",
4690
+ knownFailures: "knownFailures",
4691
+ devCommand: "devCommand",
4692
+ driveSetupCommand: "driveSetupCommand",
4693
+ driveStopCommand: "driveStopCommand",
4694
+ driveEnv: "driveEnv",
4695
+ dbResetCommand: "dbResetCommand"
4696
+ };
4697
+ PREPARED_SET = new Set(PREPARED_KEYS);
4698
+ });
4699
+
4700
+ // src/services/prep.ts
4701
+ import { and as and6, desc as desc3, eq as eq6 } from "drizzle-orm";
4702
+ function isPreparing(projectId) {
4703
+ return controllers.has(projectId);
4704
+ }
4705
+ function keysToPrepare(ctx, project, opts = {}) {
4706
+ const unset = new Set(unsetPreparedKeys(project));
4707
+ const candidates = opts.keys ?? (opts.refresh ? PREPARED_KEYS : [...unset]);
4708
+ return candidates.filter((key) => (opts.refresh || unset.has(key)) && isOverwritable(ctx, project.id, key));
4709
+ }
4710
+ function shouldAutoPrepare(ctx, project) {
4711
+ if (!ctx.config.autoPrepare)
4712
+ return false;
4713
+ if (latestPrep(ctx, project.id))
4714
+ return false;
4715
+ return keysToPrepare(ctx, project).length > 0;
4716
+ }
4717
+ async function startPrep(ctx, projectId, opts = {}) {
4718
+ const project = requireProjectById(ctx, projectId);
4719
+ if (controllers.has(projectId)) {
4720
+ throw new InvalidInputError(`${project.name} is already being prepared`);
4721
+ }
4722
+ const keys = keysToPrepare(ctx, project, opts);
4723
+ const prepId = newId("prep");
4724
+ const headSha2 = await prepHeadSha(project);
4725
+ ctx.db.insert(projectPreps).values({
4726
+ id: prepId,
4727
+ projectId,
4728
+ status: "running",
4729
+ startedAt: Date.now(),
4730
+ endedAt: null,
4731
+ summary: null,
4732
+ headSha: headSha2 ?? null
4733
+ }).run();
4734
+ emitProject(ctx, projectId, {
4735
+ type: "prep.run.started",
4736
+ message: keys.length === 0 ? `preparing ${project.name} \u2014 nothing left to establish` : `preparing ${project.name} \u2014 ${keys.length} field(s) to establish`,
4737
+ data: { prepId, keys }
4738
+ });
4739
+ const controller = new AbortController;
4740
+ controllers.set(projectId, { runId: prepId, controller });
4741
+ const prepCtx = {
4742
+ project,
4743
+ keys,
4744
+ emitEvent: (e) => {
4745
+ emitProject(ctx, projectId, { type: e.type, message: e.message, data: e.data });
4746
+ },
4747
+ signal: controller.signal
4748
+ };
4749
+ const deps = opts.deps ?? resolvePrepDeps(project);
4750
+ const done = finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2);
4751
+ return { prepId, keys, done };
4752
+ }
4753
+ function cancelPrep(projectId) {
4754
+ controllers.get(projectId)?.controller.abort();
4755
+ }
4756
+ async function finalize(ctx, project, prepId, prepCtx, deps, controller, headSha2) {
4757
+ let status = "failed";
4758
+ let summary = "preparation failed";
4759
+ let findings;
4760
+ try {
4761
+ const result = await prepRun(prepCtx, deps);
4762
+ status = result.status === "succeeded" ? "succeeded" : "failed";
4763
+ summary = result.summary;
4764
+ findings = result.findings;
4765
+ } catch (e) {
4766
+ if (controller.signal.aborted) {
4767
+ status = "cancelled";
4768
+ summary = "preparation cancelled";
4769
+ } else {
4770
+ status = "failed";
4771
+ summary = e instanceof Error ? e.message : "preparation failed";
4772
+ }
4773
+ } finally {
4774
+ controllers.delete(project.id);
4775
+ }
4776
+ const applied = findings ? applyFindings(ctx, project, findings, headSha2) : [];
4777
+ ctx.db.update(projectPreps).set({ status, endedAt: Date.now(), summary }).where(eq6(projectPreps.id, prepId)).run();
4778
+ emitProject(ctx, project.id, {
4779
+ type: "prep.run.finished",
4780
+ message: `preparation ${status}: ${summary}`,
4781
+ data: { prepId, status, summary, applied }
4782
+ });
4783
+ }
4784
+ function applyFindings(ctx, project, findings, headSha2) {
4785
+ const applied = [];
4786
+ for (const [key, finding] of Object.entries(findings.values)) {
4787
+ if (!isOverwritable(ctx, project.id, key)) {
4788
+ emitProject(ctx, project.id, {
4789
+ type: "prep.skipped_field",
4790
+ message: `kept your ${key} \u2014 preparation proposed "${truncate(finding.value)}" but you set this by hand`,
4791
+ data: { key, proposed: finding.value }
4792
+ });
4793
+ continue;
4794
+ }
4795
+ recordFinding(ctx, project.id, {
4796
+ key,
4797
+ value: finding.value,
4798
+ source: "prep",
4799
+ ...finding.evidence ? { evidence: finding.evidence } : {},
4800
+ ...headSha2 ? { establishedSha: headSha2 } : {}
4801
+ });
4802
+ applied.push(key);
4803
+ emitProject(ctx, project.id, {
4804
+ type: "prep.established",
4805
+ message: `${key}: ${truncate(finding.value)}`,
4806
+ data: { key, value: finding.value, evidence: finding.evidence }
4807
+ });
4808
+ }
4809
+ if (findings.notes) {
4810
+ emitProject(ctx, project.id, {
4811
+ type: "prep.notes",
4812
+ message: `preparation notes: ${findings.notes}`,
4813
+ data: { notes: findings.notes }
4814
+ });
4815
+ }
4816
+ return applied;
4817
+ }
4818
+ function truncate(value, max = 120) {
4819
+ const oneLine = value.replace(/\s*\n\s*/g, " \xB7 ").trim();
4820
+ return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
4821
+ }
4822
+ function latestPrep(ctx, projectId) {
4823
+ const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc3(projectPreps.startedAt)).get();
4824
+ if (!row)
4825
+ return null;
4826
+ return {
4827
+ id: row.id,
4828
+ projectId: row.projectId,
4829
+ status: row.status,
4830
+ startedAt: row.startedAt,
4831
+ ...row.endedAt !== null ? { endedAt: row.endedAt } : {},
4832
+ ...row.summary !== null ? { summary: row.summary } : {},
4833
+ ...row.headSha !== null ? { headSha: row.headSha } : {}
4834
+ };
4835
+ }
4836
+ function latestPrepNotes(ctx, projectId) {
4837
+ const row = ctx.db.select({ data: events.data }).from(events).where(and6(eq6(events.projectId, projectId), eq6(events.type, "prep.notes"))).orderBy(desc3(events.id)).get();
4838
+ const notes = row?.data?.notes;
4839
+ return typeof notes === "string" && notes.trim() !== "" ? notes : undefined;
4840
+ }
4841
+ function reconcilePreps(ctx) {
4842
+ 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));
4843
+ for (const row of stale) {
4844
+ ctx.db.update(projectPreps).set({ status: "failed", endedAt: Date.now(), summary: "server stopped while preparing" }).where(eq6(projectPreps.id, row.id)).run();
4845
+ }
4846
+ return stale.length;
4847
+ }
4848
+ var controllers;
4849
+ var init_prep = __esm(() => {
4850
+ init_src();
4851
+ init_schema();
4852
+ init_errors();
4853
+ init_project_prep();
4854
+ init_events();
4855
+ init_findings();
4856
+ init_repo();
4857
+ controllers = new Map;
4858
+ });
4859
+
4860
+ // src/launcher/runtime.ts
4861
+ function setRuntimeCtx(ctx) {
4862
+ current = ctx;
4863
+ }
4864
+ async function getRuntimeCtx() {
4865
+ if (current)
4866
+ return current;
4867
+ const [{ createDb: createDb2 }, { dbPath: dbPath2 }, { loadConfig: loadConfig2 }] = await Promise.all([
4868
+ Promise.resolve().then(() => (init_client(), exports_client)),
4869
+ Promise.resolve().then(() => (init_paths(), exports_paths)),
4870
+ Promise.resolve().then(() => (init_config_load(), exports_config_load))
4871
+ ]);
4872
+ const db = createDb2(dbPath2());
4873
+ current = { db, config: loadConfig2() };
4874
+ return current;
4875
+ }
4876
+ var current = null;
4877
+
4878
+ // src/services/feature-docs.ts
4879
+ import { existsSync as existsSync9 } from "fs";
4880
+ import { join as join9 } from "path";
4881
+ function featureDocsDir(project, feature) {
4882
+ const relSegments = featureDocsRel(feature.slug).split("/");
4883
+ const worktree = worktreeDir(project.id, feature.slug);
4884
+ const base = existsSync9(worktree) ? worktree : project.repoPath;
4885
+ return join9(base, ...relSegments);
4886
+ }
4887
+ function featureDocPath(project, feature, fileName) {
4888
+ return join9(featureDocsDir(project, feature), fileName);
4889
+ }
4890
+ var init_feature_docs = __esm(() => {
4891
+ init_paths();
4892
+ });
4893
+
4894
+ // src/services/tickets.ts
4895
+ import { and as and7, asc as asc3, eq as eq7 } from "drizzle-orm";
4896
+ function rowToTicket(row) {
4897
+ return {
4898
+ id: row.id,
4899
+ featureId: row.featureId,
4900
+ seq: row.seq,
4901
+ title: row.title,
4902
+ goal: row.goal,
4903
+ context: row.context,
4904
+ acceptanceCriteria: row.acceptanceCriteria,
4905
+ seams: row.seams,
4906
+ blockedBy: row.blockedBy,
4907
+ status: row.status,
4908
+ commits: row.commits,
4909
+ error: row.error ?? undefined,
4910
+ attemptBranch: row.attemptBranch ?? undefined,
4911
+ conflictFiles: row.conflictFiles ?? undefined
4912
+ };
4913
+ }
4914
+ function resolveBlocking(inputs, startSeq) {
4915
+ try {
4916
+ return resolveBatchBlocking(inputs, { startSeq });
4917
+ } catch (e) {
4918
+ if (e instanceof BlockingEdgeError)
4919
+ throw new InvalidInputError(e.message);
4920
+ throw e;
4921
+ }
4922
+ }
4923
+ function listByFeature2(ctx, featureId) {
4924
+ return ctx.db.select().from(tickets).where(eq7(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
4925
+ }
4926
+ function storeTickets(ctx, featureId, inputs) {
4927
+ if (inputs.length === 0)
4928
+ return [];
4929
+ const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
4930
+ const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
4931
+ const resolved = resolveBlocking(inputs, startSeq);
4932
+ const rows = inputs.map((t, i) => ({
4933
+ id: newId("tkt"),
4934
+ featureId,
4935
+ seq: resolved[i].seq,
4936
+ title: t.title,
4937
+ goal: t.goal,
4938
+ context: t.context,
4939
+ acceptanceCriteria: t.acceptanceCriteria,
4940
+ seams: t.seams,
4941
+ blockedBy: resolved[i].blockedBy,
4942
+ status: "pending",
4943
+ commits: [],
4944
+ error: null,
4945
+ attemptBranch: null,
4946
+ conflictFiles: null
4947
+ }));
4948
+ ctx.db.insert(tickets).values(rows).run();
4949
+ emit(ctx, featureId, {
4950
+ type: "tickets.stored",
4951
+ message: `${rows.length} ticket(s) stored`,
4952
+ data: { count: rows.length, seqs: rows.map((r) => r.seq) }
4953
+ });
4954
+ return rows.map(rowToTicket);
4955
+ }
4956
+ function getTicket(ctx, id) {
4957
+ const row = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
4958
+ if (!row)
4959
+ throw new NotFoundError(`ticket ${id} not found`);
4960
+ return rowToTicket(row);
4961
+ }
4962
+ function assertMutable(t, verb) {
4963
+ if (!MUTABLE_STATUSES.has(t.status)) {
4964
+ 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"}`);
4965
+ }
4966
+ }
4967
+ function editTicket(ctx, id, patch) {
4968
+ const current2 = getTicket(ctx, id);
4969
+ assertMutable(current2, "edit");
4970
+ const set = {};
4971
+ if (patch.title !== undefined)
4972
+ set.title = patch.title;
4973
+ if (patch.goal !== undefined)
4974
+ set.goal = patch.goal;
4975
+ if (patch.context !== undefined)
4976
+ set.context = patch.context;
4977
+ if (patch.acceptanceCriteria !== undefined)
4978
+ set.acceptanceCriteria = patch.acceptanceCriteria;
4979
+ if (patch.seams !== undefined)
4980
+ set.seams = patch.seams;
4981
+ if (Object.keys(set).length === 0) {
4982
+ throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
4983
+ }
4984
+ ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
4985
+ emit(ctx, current2.featureId, {
4986
+ type: "ticket.edited",
4987
+ message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
4988
+ ticketId: id,
4989
+ data: { fields: Object.keys(set) }
4990
+ });
4991
+ return getTicket(ctx, id);
4992
+ }
4993
+ function cancelTicket(ctx, id, reason) {
4994
+ const current2 = getTicket(ctx, id);
4995
+ assertMutable(current2, "cancel");
4996
+ ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq7(tickets.id, id)).run();
4997
+ emit(ctx, current2.featureId, {
4998
+ type: "ticket.cancelled",
4999
+ message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
5000
+ ticketId: id,
5001
+ data: { reason: reason ?? null }
5002
+ });
5003
+ return getTicket(ctx, id);
5004
+ }
5005
+ function sweepOrphanedBurning(ctx, featureId, reason) {
5006
+ const orphaned = ctx.db.select().from(tickets).where(and7(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
5007
+ for (const t of orphaned) {
5008
+ updateTicket(ctx, t.id, { status: "failed", error: reason });
5009
+ emit(ctx, featureId, {
5010
+ type: "ticket.failed",
5011
+ message: `ticket ${t.seq} failed: ${reason}`,
5012
+ ticketId: t.id,
5013
+ data: { error: reason, orphaned: true }
5014
+ });
5015
+ }
5016
+ return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
5017
+ }
5018
+ function updateTicket(ctx, id, patch) {
5019
+ const current2 = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
5020
+ if (!current2)
5021
+ throw new NotFoundError(`ticket ${id} not found`);
5022
+ const set = {};
5023
+ if (patch.status !== undefined)
5024
+ set.status = patch.status;
5025
+ if (patch.commits !== undefined)
5026
+ set.commits = patch.commits;
5027
+ if (patch.error !== undefined)
5028
+ set.error = patch.error;
5029
+ if (patch.attemptBranch !== undefined)
5030
+ set.attemptBranch = patch.attemptBranch;
5031
+ if (patch.conflictFiles !== undefined)
5032
+ set.conflictFiles = patch.conflictFiles;
5033
+ ctx.db.update(tickets).set(set).where(eq7(tickets.id, id)).run();
5034
+ emit(ctx, current2.featureId, {
5035
+ type: "ticket.updated",
5036
+ message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
5037
+ ticketId: id,
5038
+ data: patch
5039
+ });
5040
+ const updated = ctx.db.select().from(tickets).where(eq7(tickets.id, id)).get();
5041
+ return rowToTicket(updated);
5042
+ }
5043
+ var MUTABLE_STATUSES;
5044
+ var init_tickets = __esm(() => {
5045
+ init_src();
5046
+ init_schema();
5047
+ init_errors();
5048
+ init_events();
5049
+ MUTABLE_STATUSES = new Set(["pending", "failed"]);
5050
+ });
5051
+
5052
+ // src/services/gates.ts
5053
+ import { existsSync as existsSync10 } from "fs";
5054
+ function checkGate(ctx, check, feature) {
5055
+ switch (check) {
5056
+ case "decisions-file-exists":
5057
+ return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
5058
+ case "all-waypoints-terminal": {
5059
+ const wps = listByFeature(ctx, feature.id);
5060
+ if (wps.length === 0) {
5061
+ return { satisfied: false, reason: "no waypoints charted yet \u2014 chart the map before converging" };
5062
+ }
5063
+ const open = wps.filter((w) => w.status !== "resolved" && w.status !== "dropped");
5064
+ return open.length === 0 ? { satisfied: true } : {
5065
+ satisfied: false,
5066
+ reason: notYetTerminal("waypoint", open.map((w) => w.status), ["open", "claimed"])
5067
+ };
5068
+ }
5069
+ case "spec-file-exists":
5070
+ return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
5071
+ case "tickets-approved": {
5072
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
5073
+ return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
5074
+ }
5075
+ case "all-tickets-terminal": {
5076
+ const tickets2 = listByFeature2(ctx, feature.id);
5077
+ if (tickets2.length === 0)
5078
+ return { satisfied: false, reason: "no tickets have run yet" };
5079
+ const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
5080
+ return open.length === 0 ? { satisfied: true } : {
5081
+ satisfied: false,
5082
+ reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
5083
+ };
5084
+ }
5085
+ case "human-merge":
5086
+ return { satisfied: false, reason: "use the Merge button to ship" };
5087
+ }
5088
+ }
5089
+ function notYetTerminal(noun, statuses, order) {
5090
+ const counts = new Map;
5091
+ for (const s of statuses)
5092
+ counts.set(s, (counts.get(s) ?? 0) + 1);
5093
+ const ordered = [
5094
+ ...order.filter((s) => counts.has(s)),
5095
+ ...[...counts.keys()].filter((s) => !order.includes(s))
5096
+ ];
5097
+ const breakdown = ordered.map((s) => `${counts.get(s)} ${s}`).join(", ");
5098
+ return `${statuses.length} ${statuses.length === 1 ? noun : `${noun}s`} not yet terminal (${breakdown})`;
5099
+ }
5100
+ function fileGate(ctx, feature, fileName, reason) {
5101
+ const project = projectForFeature(ctx, feature);
5102
+ return existsSync10(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
5103
+ }
5104
+ function overrideGate(ctx, featureId, gate, reason) {
5105
+ const feature = getFeatureRow(ctx, featureId);
5106
+ ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
5107
+ emit(ctx, featureId, {
5108
+ type: "gate.overridden",
5109
+ message: `gate ${gate} overridden: ${reason}`,
5110
+ data: { gate, reason }
5111
+ });
5112
+ const next = nextPhase(feature);
5113
+ if (next)
5114
+ return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
5115
+ return feature;
5116
+ }
5117
+ var init_gates = __esm(() => {
5118
+ init_src();
5119
+ init_schema();
5120
+ init_events();
5121
+ init_feature_docs();
5122
+ init_repo();
5123
+ init_tickets();
5124
+ init_waypoints();
5125
+ });
5126
+
5127
+ // src/services/knowledge.ts
5128
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync } from "fs";
5129
+ import { join as join10, relative, resolve as resolve6, sep } from "path";
5130
+ function scaffoldDocs(ctx, feature) {
5131
+ const project = projectForFeature(ctx, feature);
5132
+ const dir = featureDocsDir(project, feature);
5133
+ mkdirSync5(dir, { recursive: true });
5134
+ const briefPath = featureDocPath(project, feature, "brief.md");
5135
+ if (!existsSync11(briefPath)) {
5136
+ const created = new Date().toISOString();
5137
+ const brief = [
5138
+ `# ${feature.title}`,
5139
+ "",
5140
+ feature.oneLiner,
5141
+ "",
5142
+ `- Slug: ${feature.slug}`,
5143
+ `- Created: ${created}`,
5144
+ ""
5145
+ ].join(`
5146
+ `);
5147
+ writeFileSync(briefPath, brief, "utf8");
5148
+ }
5149
+ if (feature.mapped) {
5150
+ scaffoldMapDoc(project, feature);
5151
+ }
5152
+ emit(ctx, feature.id, {
5153
+ type: "docs.scaffolded",
5154
+ message: feature.mapped ? `scaffolded docs/features/${feature.slug}/{brief,map}.md` : `scaffolded docs/features/${feature.slug}/brief.md`
5155
+ });
5156
+ }
5157
+ function scaffoldMapDoc(project, feature, seed) {
5158
+ mkdirSync5(featureDocsDir(project, feature), { recursive: true });
5159
+ const mapPath = featureDocPath(project, feature, "map.md");
5160
+ if (existsSync11(mapPath))
5161
+ return;
5162
+ writeFileSync(mapPath, mapDocBody(feature, seed), "utf8");
5163
+ }
5164
+ function mapDocBody(feature, seed) {
5165
+ const seeded = {
5166
+ Destination: seed?.destination?.trim(),
5167
+ Notes: seed?.notes?.trim()
5168
+ };
5169
+ const lines = [`# ${feature.title} \u2014 map`, ""];
5170
+ for (const section of MAP_SECTIONS) {
5171
+ lines.push(`## ${section}`, "");
5172
+ const body = seeded[section];
5173
+ if (body)
5174
+ lines.push(body, "");
5175
+ }
5176
+ return lines.join(`
5177
+ `);
5178
+ }
5179
+ function listDocs(ctx, feature) {
5180
+ const project = projectForFeature(ctx, feature);
5181
+ const dir = featureDocsDir(project, feature);
5182
+ if (!existsSync11(dir))
5183
+ return [];
5184
+ 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));
5185
+ }
5186
+ function readDoc(ctx, feature, relPath) {
5187
+ const project = projectForFeature(ctx, feature);
5188
+ const dir = featureDocsDir(project, feature);
5189
+ const root = resolve6(dir);
5190
+ const target = resolve6(dir, relPath);
5191
+ const rel = relative(root, target);
5192
+ if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
5193
+ throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
5194
+ }
5195
+ if (!existsSync11(target))
5196
+ throw new NotFoundError(`doc not found: ${relPath}`);
5197
+ return { content: readFileSync5(target, "utf8") };
5198
+ }
5199
+ function readTitle(path, fallback) {
5200
+ try {
5201
+ const firstLine = readFileSync5(path, "utf8").split(`
5202
+ `, 1)[0] ?? "";
5203
+ const heading = firstLine.match(/^#\s+(.+)$/);
5204
+ return heading ? heading[1].trim() : fallback;
5205
+ } catch {
5206
+ return fallback;
5207
+ }
5208
+ }
5209
+ var MAP_SECTIONS;
5210
+ var init_knowledge = __esm(() => {
5211
+ init_errors();
5212
+ init_events();
5213
+ init_feature_docs();
5214
+ init_repo();
5215
+ MAP_SECTIONS = [
5216
+ "Destination",
5217
+ "Notes",
5218
+ "Not yet specified",
5219
+ "Out of scope"
5220
+ ];
5221
+ });
5222
+
5223
+ // src/pty/end-session.ts
5224
+ function endSession(ctx, sessionId) {
5225
+ const registry = ptyRegistry();
5226
+ const killed = registry.kill(sessionId);
5227
+ registry.remove(sessionId);
5228
+ const session = markSessionEnded(ctx, sessionId);
5229
+ if (session) {
5230
+ releaseForSession(ctx, sessionId);
5231
+ emitForSession(ctx, session, {
5232
+ type: "session.ended",
5233
+ message: "session ended by user",
5234
+ data: { sessionId, killed }
5235
+ });
5236
+ } else {
5237
+ const existing = getSessionRow(ctx, sessionId);
5238
+ if (!existing)
5239
+ return { sessionId, ok: false, killed };
5240
+ }
5241
+ return { sessionId, ok: true, killed };
5242
+ }
5243
+ var init_end_session = __esm(() => {
5244
+ init_sessions();
5245
+ init_events();
5246
+ init_waypoints();
5247
+ init_registry();
4143
5248
  });
4144
5249
 
4145
5250
  // 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";
5251
+ import { existsSync as existsSync12, mkdirSync as mkdirSync6, readFileSync as readFileSync6, readdirSync as readdirSync4 } from "fs";
5252
+ import { dirname as dirname5, join as join11 } from "path";
5253
+ import { fileURLToPath as fileURLToPath4 } from "url";
5254
+ import { run as run3 } from "@ai-hero/sandcastle";
4150
5255
  import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
4151
5256
  import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
4152
5257
  function waypointSlug(waypoint) {
@@ -4163,7 +5268,7 @@ async function researchRun(ctx, deps) {
4163
5268
  return { status: "failed", summary: "no waypoint to research" };
4164
5269
  }
4165
5270
  if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
4166
- ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
5271
+ ctx.emitEvent({ type: AUTH_MISSING_EVENT3, message: AUTH_MISSING_MESSAGE3 });
4167
5272
  return { status: "failed", summary: "research aborted: auth token missing" };
4168
5273
  }
4169
5274
  ctx.emitEvent({
@@ -4184,23 +5289,23 @@ async function researchRun(ctx, deps) {
4184
5289
  }
4185
5290
  ctx.emitEvent({
4186
5291
  type: "research.failed",
4187
- message: `waypoint ${waypoint.seq} research failed: ${errorHeadline2(outcome.error)}`,
5292
+ message: `waypoint ${waypoint.seq} research failed: ${errorHeadline3(outcome.error)}`,
4188
5293
  data: { waypointId: waypoint.id, error: outcome.error }
4189
5294
  });
4190
5295
  return { status: "failed", summary: outcome.error };
4191
5296
  }
4192
- function errorHeadline2(s) {
5297
+ function errorHeadline3(s) {
4193
5298
  const lines = s.split(`
4194
5299
  `).map((l) => l.trim()).filter(Boolean);
4195
5300
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4196
5301
  return causes.at(-1) ?? lines[0] ?? "";
4197
5302
  }
4198
- function createResearchStreamThrottle(emit2, opts = {}) {
4199
- return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
5303
+ function createResearchStreamThrottle(emit3, opts = {}) {
5304
+ return createStreamThrottle((e) => emit3({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
4200
5305
  }
4201
5306
  function renderResearchPrompt(template, values) {
4202
5307
  let out = template;
4203
- for (const key of PLACEHOLDERS) {
5308
+ for (const key of PLACEHOLDERS2) {
4204
5309
  out = out.split(`{{${key}}}`).join(values[key]);
4205
5310
  }
4206
5311
  return out;
@@ -4215,32 +5320,32 @@ function interpretResearchResult(result, docRelPath) {
4215
5320
  return { status: "failed", error: "agent produced no commits \u2014 no research summary landed" };
4216
5321
  }
4217
5322
  function researchTemplatePath() {
4218
- const here = dirname4(fileURLToPath3(import.meta.url));
4219
- return join10(resolveSkillsRoot(here), "burner", "research-waypoint.md");
5323
+ const here = dirname5(fileURLToPath4(import.meta.url));
5324
+ return join11(resolveSkillsRoot(here), "burner", "research-waypoint.md");
4220
5325
  }
4221
5326
  function readDocsDigestFromDisk2(projectId, slug) {
4222
5327
  const worktree = worktreeDir(projectId, slug);
4223
- if (!existsSync11(worktree))
5328
+ if (!existsSync12(worktree))
4224
5329
  return "_No talk worktree on disk \u2014 docs digest skipped._";
4225
- const docsDir = join10(worktree, ...featureDocsRel(slug).split("/"));
4226
- if (!existsSync11(docsDir))
5330
+ const docsDir = join11(worktree, ...featureDocsRel(slug).split("/"));
5331
+ if (!existsSync12(docsDir))
4227
5332
  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") }));
5333
+ 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
5334
  return buildDocsDigest(files);
4230
5335
  }
4231
5336
  async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4232
5337
  const { project, feature } = ctx;
4233
5338
  const docRelPath = researchDocRel(feature.slug, waypoint);
4234
5339
  const tempBranch = researchBranchName(feature.slug, waypoint.seq, newId("b").slice(2, 10));
4235
- const template = readFileSync5(researchTemplatePath(), "utf8");
5340
+ const template = readFileSync6(researchTemplatePath(), "utf8");
4236
5341
  const prompt = renderResearchPrompt(template, {
4237
5342
  WAYPOINT_JSON: buildWaypointJson(waypoint),
4238
5343
  FEATURE_BRIEF: buildFeatureBrief(feature),
4239
5344
  DOCS_DIGEST: readDocsDigestFromDisk2(project.id, feature.slug),
4240
5345
  RESEARCH_DOC_PATH: docRelPath
4241
5346
  });
4242
- mkdirSync5(logsDir(), { recursive: true });
4243
- const logFilePath = join10(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
5347
+ mkdirSync6(logsDir(), { recursive: true });
5348
+ const logFilePath = join11(logsDir(), `research-${feature.id}-${waypoint.seq}.log`);
4244
5349
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
4245
5350
  const runOptions = {
4246
5351
  agent: buildBurnAgent(config2, token, model),
@@ -4255,7 +5360,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4255
5360
  let result;
4256
5361
  let salvaged = [];
4257
5362
  try {
4258
- result = await run2(runOptions);
5363
+ result = await run3(runOptions);
4259
5364
  } catch (err) {
4260
5365
  throttle.flush();
4261
5366
  if (ctx.signal.aborted)
@@ -4267,7 +5372,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4267
5372
  const removed = await cleanupBurnWorktree(project.repoPath, tempBranch);
4268
5373
  ctx.emitEvent({
4269
5374
  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`,
5375
+ 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
5376
  data: { tempBranch, error: msg, cleanedUp: removed }
4272
5377
  });
4273
5378
  }
@@ -4280,7 +5385,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
4280
5385
  const detail = merge.error ?? "merge failed";
4281
5386
  ctx.emitEvent({
4282
5387
  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`,
5388
+ 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
5389
  data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
4285
5390
  });
4286
5391
  return {
@@ -4302,9 +5407,9 @@ function resolveResearchDeps(ctx) {
4302
5407
  }
4303
5408
  function readTokenFromEnvFile2(path) {
4304
5409
  let fromFile;
4305
- if (existsSync11(path)) {
5410
+ if (existsSync12(path)) {
4306
5411
  try {
4307
- fromFile = parseEnvFile(readFileSync5(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
5412
+ fromFile = parseEnvFile(readFileSync6(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
4308
5413
  } catch {
4309
5414
  fromFile = undefined;
4310
5415
  }
@@ -4312,7 +5417,7 @@ function readTokenFromEnvFile2(path) {
4312
5417
  const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
4313
5418
  return token && token.length > 0 ? token : undefined;
4314
5419
  }
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;
5420
+ 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
5421
  var init_research = __esm(() => {
4317
5422
  init_src();
4318
5423
  init_config_load();
@@ -4320,7 +5425,7 @@ var init_research = __esm(() => {
4320
5425
  init_skills_root();
4321
5426
  init_git();
4322
5427
  init_ticket_burner();
4323
- PLACEHOLDERS = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
5428
+ PLACEHOLDERS2 = ["WAYPOINT_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "RESEARCH_DOC_PATH"];
4324
5429
  research = {
4325
5430
  id: "research",
4326
5431
  run: (ctx) => researchRun(ctx, resolveResearchDeps(ctx))
@@ -4342,12 +5447,12 @@ var init_registry2 = __esm(() => {
4342
5447
  });
4343
5448
 
4344
5449
  // src/workflows/runner.ts
4345
- import { eq as eq6 } from "drizzle-orm";
5450
+ import { eq as eq8 } from "drizzle-orm";
4346
5451
  function workflowClaimsFeatureBranch(workflowId) {
4347
5452
  return BRANCH_CLAIMING.has(workflowId);
4348
5453
  }
4349
5454
  function isRunActive(runId) {
4350
- return controllers.has(runId);
5455
+ return controllers2.has(runId);
4351
5456
  }
4352
5457
  async function startRun(ctx, featureId, workflowId, opts = {}) {
4353
5458
  const feature = getFeatureRow(ctx, featureId);
@@ -4371,7 +5476,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4371
5476
  claim(ctx, opts.claimWaypointId, runId);
4372
5477
  } catch (e) {
4373
5478
  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();
5479
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
4375
5480
  throw e;
4376
5481
  }
4377
5482
  }
@@ -4382,7 +5487,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4382
5487
  data: { workflow: workflowId }
4383
5488
  });
4384
5489
  const controller = new AbortController;
4385
- controllers.set(runId, controller);
5490
+ controllers2.set(runId, controller);
4386
5491
  const talkWorktree = worktreeDir(project.id, feature.slug);
4387
5492
  const talkDetached = workflowClaimsFeatureBranch(workflowId) ? await detachWorktree(talkWorktree) : false;
4388
5493
  const wctx = {
@@ -4404,7 +5509,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4404
5509
  input: opts.input,
4405
5510
  modelOverride: opts.modelOverride,
4406
5511
  resolveWaypoint: (id, disposition, summary) => {
4407
- resolve2(ctx, id, disposition, summary);
5512
+ resolve3(ctx, id, disposition, summary);
4408
5513
  },
4409
5514
  signal: controller.signal
4410
5515
  };
@@ -4415,7 +5520,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
4415
5520
  return { runId, done };
4416
5521
  }
4417
5522
  function cancelRun(runId) {
4418
- controllers.get(runId)?.abort();
5523
+ controllers2.get(runId)?.abort();
4419
5524
  }
4420
5525
  async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
4421
5526
  let status = "failed";
@@ -4434,9 +5539,9 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
4434
5539
  }
4435
5540
  emit(ctx, featureId, { type: "run.error", message: summary, runId });
4436
5541
  } finally {
4437
- controllers.delete(runId);
5542
+ controllers2.delete(runId);
4438
5543
  }
4439
- ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
5544
+ ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
4440
5545
  releaseForSession(ctx, runId);
4441
5546
  if (workflowClaimsFeatureBranch(workflow2)) {
4442
5547
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -4466,7 +5571,7 @@ function maybeAutoAdvance(ctx, featureId) {
4466
5571
  if (next)
4467
5572
  setPhase(ctx, featureId, next, "phase.advanced", "auto-advanced to review after successful run");
4468
5573
  }
4469
- var controllers, BRANCH_CLAIMING;
5574
+ var controllers2, BRANCH_CLAIMING;
4470
5575
  var init_runner = __esm(() => {
4471
5576
  init_src();
4472
5577
  init_paths();
@@ -4479,12 +5584,12 @@ var init_runner = __esm(() => {
4479
5584
  init_tickets();
4480
5585
  init_waypoints();
4481
5586
  init_registry2();
4482
- controllers = new Map;
5587
+ controllers2 = new Map;
4483
5588
  BRANCH_CLAIMING = new Set(["ticket-burner"]);
4484
5589
  });
4485
5590
 
4486
5591
  // src/services/features.ts
4487
- import { desc as desc3, eq as eq7 } from "drizzle-orm";
5592
+ import { desc as desc4, eq as eq9 } from "drizzle-orm";
4488
5593
  import { rmSync as rmSync2 } from "fs";
4489
5594
  async function createFeature(ctx, input) {
4490
5595
  const project = requireProjectById(ctx, input.projectId);
@@ -4547,7 +5652,7 @@ function getFeatureFull(ctx, id) {
4547
5652
  };
4548
5653
  }
4549
5654
  function list(ctx, projectId) {
4550
- const rows = ctx.db.select().from(features).where(eq7(features.projectId, projectId)).orderBy(desc3(features.createdAt)).all();
5655
+ const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc4(features.createdAt)).all();
4551
5656
  return rows.map((row) => {
4552
5657
  const feature = rowToFeature(row);
4553
5658
  const tickets2 = listByFeature2(ctx, feature.id);
@@ -4692,7 +5797,7 @@ function escalateToMap(ctx, featureId, input) {
4692
5797
  return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
4693
5798
  }
4694
5799
  const project = projectForFeature(ctx, feature);
4695
- ctx.db.update(features).set({ mapped: true }).where(eq7(features.id, featureId)).run();
5800
+ ctx.db.update(features).set({ mapped: true }).where(eq9(features.id, featureId)).run();
4696
5801
  scaffoldMapDoc(project, { ...feature, mapped: true }, input);
4697
5802
  emit(ctx, featureId, {
4698
5803
  type: "feature.escalated",
@@ -4709,7 +5814,7 @@ function archiveFeature(ctx, featureId) {
4709
5814
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
4710
5815
  if (live)
4711
5816
  endSession(ctx, live.id);
4712
- ctx.db.update(features).set({ status: "archived" }).where(eq7(features.id, featureId)).run();
5817
+ ctx.db.update(features).set({ status: "archived" }).where(eq9(features.id, featureId)).run();
4713
5818
  emit(ctx, featureId, {
4714
5819
  type: "feature.archived",
4715
5820
  message: `feature ${feature.slug} archived`,
@@ -4723,7 +5828,7 @@ function unarchiveFeature(ctx, featureId) {
4723
5828
  throw new GateError(`feature ${feature.slug} is not archived`);
4724
5829
  }
4725
5830
  const restored = feature.phase === "shipped" ? "shipped" : "active";
4726
- ctx.db.update(features).set({ status: restored }).where(eq7(features.id, featureId)).run();
5831
+ ctx.db.update(features).set({ status: restored }).where(eq9(features.id, featureId)).run();
4727
5832
  emit(ctx, featureId, {
4728
5833
  type: "feature.unarchived",
4729
5834
  message: `feature ${feature.slug} unarchived (${restored})`,
@@ -4737,9 +5842,9 @@ async function deleteFeature(ctx, featureId) {
4737
5842
  throw new GateError(`feature ${feature.slug} is shipped \u2014 archive it instead (delete is for non-shipped features)`);
4738
5843
  }
4739
5844
  const project = projectForFeature(ctx, feature);
4740
- for (const run3 of listRunsByFeature(ctx, featureId)) {
4741
- if (run3.status === "running")
4742
- cancelRun(run3.id);
5845
+ for (const run4 of listRunsByFeature(ctx, featureId)) {
5846
+ if (run4.status === "running")
5847
+ cancelRun(run4.id);
4743
5848
  }
4744
5849
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
4745
5850
  if (live)
@@ -4763,13 +5868,13 @@ async function deleteFeature(ctx, featureId) {
4763
5868
  return { ok: true, slug: feature.slug };
4764
5869
  }
4765
5870
  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();
5871
+ ctx.db.delete(tickets).where(eq9(tickets.featureId, featureId)).run();
5872
+ ctx.db.delete(sessions).where(eq9(sessions.featureId, featureId)).run();
5873
+ ctx.db.delete(runs).where(eq9(runs.featureId, featureId)).run();
5874
+ ctx.db.delete(events).where(eq9(events.featureId, featureId)).run();
5875
+ ctx.db.delete(gateOverrides).where(eq9(gateOverrides.featureId, featureId)).run();
5876
+ ctx.db.delete(waypoints).where(eq9(waypoints.featureId, featureId)).run();
5877
+ ctx.db.delete(features).where(eq9(features.id, featureId)).run();
4773
5878
  }
4774
5879
  function gateState(ctx, feature) {
4775
5880
  const gate = nextGate(feature);
@@ -4780,7 +5885,7 @@ function gateState(ctx, feature) {
4780
5885
  }
4781
5886
  function uniqueSlug(ctx, projectId, title) {
4782
5887
  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));
5888
+ const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq9(features.projectId, projectId)).all().map((r) => r.slug));
4784
5889
  if (!existing.has(base))
4785
5890
  return base;
4786
5891
  let n = 2;
@@ -4812,7 +5917,7 @@ var init_features = __esm(() => {
4812
5917
  import { StreamableHTTPTransport } from "@hono/mcp";
4813
5918
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4814
5919
  import { Hono } from "hono";
4815
- import * as z3 from "zod";
5920
+ import * as z4 from "zod";
4816
5921
  function headerSessionId(extra) {
4817
5922
  const headers = extra.requestInfo?.headers;
4818
5923
  if (!headers)
@@ -4829,8 +5934,14 @@ function resolveSession(ctx, sessionId) {
4829
5934
  }
4830
5935
  return mostRecentLiveSession(ctx);
4831
5936
  }
5937
+ function requireFeatureId(session) {
5938
+ if (!session.featureId) {
5939
+ throw new GateError(`this tool needs a feature, and a ${session.kind} session is project-scoped. ` + "Use record_finding to store what you establish about the project.");
5940
+ }
5941
+ return session.featureId;
5942
+ }
4832
5943
  function toolGetFeatureContext(ctx, session) {
4833
- const feature = getFeatureRow(ctx, session.featureId);
5944
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
4834
5945
  const docs = listDocs(ctx, feature).map((d) => {
4835
5946
  try {
4836
5947
  return { relPath: d.relPath, content: readDoc(ctx, feature, d.relPath).content };
@@ -4852,17 +5963,17 @@ function toolGetFeatureContext(ctx, session) {
4852
5963
  return context;
4853
5964
  }
4854
5965
  function toolResolveWaypoint(ctx, _session, input) {
4855
- resolve2(ctx, input.id, input.disposition, input.summary);
5966
+ resolve3(ctx, input.id, input.disposition, input.summary);
4856
5967
  return { ok: true };
4857
5968
  }
4858
5969
  function toolEmitTickets(ctx, session, input) {
4859
- const feature = getFeatureRow(ctx, session.featureId);
5970
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
4860
5971
  const stored = storeTickets(ctx, feature.id, input.tickets);
4861
5972
  return { stored: stored.length, ids: stored.map((t) => t.id) };
4862
5973
  }
4863
5974
  function requireOwnTicket(ctx, session, ticketId) {
4864
5975
  const ticket = getTicket(ctx, ticketId);
4865
- if (ticket.featureId !== session.featureId) {
5976
+ if (ticket.featureId !== requireFeatureId(session)) {
4866
5977
  throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
4867
5978
  }
4868
5979
  return ticket;
@@ -4877,10 +5988,10 @@ function toolCancelTicket(ctx, session, input) {
4877
5988
  return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
4878
5989
  }
4879
5990
  function toolEscalateToMap(ctx, session, input) {
4880
- return escalateToMap(ctx, session.featureId, input);
5991
+ return escalateToMap(ctx, requireFeatureId(session), input);
4881
5992
  }
4882
5993
  function toolEmitWaypoints(ctx, session, input) {
4883
- const feature = getFeatureRow(ctx, session.featureId);
5994
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
4884
5995
  if (!feature.mapped) {
4885
5996
  throw new GateError("feature is not mapped \u2014 call escalate_to_map before emitting waypoints");
4886
5997
  }
@@ -4888,7 +5999,7 @@ function toolEmitWaypoints(ctx, session, input) {
4888
5999
  return { stored: stored.length, ids: stored.map((w) => w.id) };
4889
6000
  }
4890
6001
  function toolRecordEvent(ctx, session, input) {
4891
- emit(ctx, session.featureId, {
6002
+ emit(ctx, requireFeatureId(session), {
4892
6003
  type: input.type,
4893
6004
  message: input.message,
4894
6005
  data: { source: "mcp" }
@@ -4896,7 +6007,7 @@ function toolRecordEvent(ctx, session, input) {
4896
6007
  return { ok: true };
4897
6008
  }
4898
6009
  function toolCompletePhase(ctx, session, input) {
4899
- const feature = getFeatureRow(ctx, session.featureId);
6010
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
4900
6011
  emit(ctx, feature.id, {
4901
6012
  type: "phase.complete_requested",
4902
6013
  message: `session marked phase '${input.phase}' complete`,
@@ -4925,13 +6036,46 @@ async function commitDocsCheckpoint(ctx, session, message) {
4925
6036
  try {
4926
6037
  await commitDocs(session.worktreePath, message);
4927
6038
  } catch (e) {
4928
- emit(ctx, session.featureId, {
6039
+ emit(ctx, requireFeatureId(session), {
4929
6040
  type: "git.commit_pending",
4930
6041
  message: isNotImplemented(e) ? "docs checkpoint skipped (git service pending)" : `docs checkpoint failed: ${e instanceof Error ? e.message : String(e)}`,
4931
6042
  data: { message }
4932
6043
  });
4933
6044
  }
4934
6045
  }
6046
+ function requireProject(ctx, session) {
6047
+ if (!session.projectId) {
6048
+ throw new GateError(`record_finding is for a project-scoped preparation session; this is a ${session.kind} session.`);
6049
+ }
6050
+ const project = getProjectById(ctx, session.projectId);
6051
+ if (!project)
6052
+ throw new GateError(`project ${session.projectId} not found`);
6053
+ return project;
6054
+ }
6055
+ function toolRecordFinding(ctx, session, input) {
6056
+ const project = requireProject(ctx, session);
6057
+ const source = input.userSupplied ? "human" : "session";
6058
+ if (!isOverwritable(ctx, project.id, input.key) && !input.userSupplied) {
6059
+ return {
6060
+ ok: true,
6061
+ key: input.key,
6062
+ source,
6063
+ skipped: `${input.key} was set by a human and is not auto-overwritable \u2014 ask them to clear it first if it should change`
6064
+ };
6065
+ }
6066
+ recordFinding(ctx, project.id, {
6067
+ key: input.key,
6068
+ value: input.value,
6069
+ source,
6070
+ ...input.evidence ? { evidence: input.evidence } : {}
6071
+ });
6072
+ emitProject(ctx, project.id, {
6073
+ type: "prep.finding_recorded",
6074
+ message: `preparation session established ${input.key} (${source})`,
6075
+ data: { key: input.key, source, sessionId: session.id }
6076
+ });
6077
+ return { ok: true, key: input.key, source };
6078
+ }
4935
6079
  function ok(result) {
4936
6080
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
4937
6081
  }
@@ -4953,6 +6097,21 @@ async function resolveCtxSession(extra) {
4953
6097
  }
4954
6098
  function buildMcpServer() {
4955
6099
  const server = new McpServer({ name: "runcastle", version: "0.1.0" });
6100
+ server.registerTool("record_finding", {
6101
+ title: "Record a preparation finding",
6102
+ description: "Store one established project setting from a preparation conversation. " + "Set userSupplied: true ONLY when the human gave you this value or confirmed it verbatim \u2014 " + "that marks it as theirs and stops any future automatic run from overwriting it. " + "Anything you worked out yourself must leave it false, even if they were watching. " + "Always include evidence: what you ran, or what the human told you.",
6103
+ inputSchema: {
6104
+ key: PreparedKey,
6105
+ value: z4.string(),
6106
+ evidence: z4.string().optional(),
6107
+ userSupplied: z4.boolean().optional()
6108
+ }
6109
+ }, async (args, extra) => {
6110
+ const rs = await resolveCtxSession(extra);
6111
+ if (!rs)
6112
+ return noSession();
6113
+ return ok(toolRecordFinding(rs.ctx, rs.session, args));
6114
+ });
4956
6115
  server.registerTool("get_feature_context", {
4957
6116
  title: "Get feature context",
4958
6117
  description: "Full context for the current feature: the feature row, its phase, all docs/features/<slug>/*.md contents, and its tickets.",
@@ -4966,7 +6125,7 @@ function buildMcpServer() {
4966
6125
  server.registerTool("emit_tickets", {
4967
6126
  title: "Emit tickets",
4968
6127
  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) }
6128
+ inputSchema: { tickets: z4.array(TicketInput) }
4970
6129
  }, async (args, extra) => {
4971
6130
  const rs = await resolveCtxSession(extra);
4972
6131
  if (!rs)
@@ -4979,12 +6138,12 @@ function buildMcpServer() {
4979
6138
  title: "Update ticket",
4980
6139
  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
6140
  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()
6141
+ id: z4.string(),
6142
+ title: z4.string().optional(),
6143
+ goal: z4.string().optional(),
6144
+ context: z4.string().optional(),
6145
+ acceptanceCriteria: z4.array(z4.string()).optional(),
6146
+ seams: z4.array(z4.string()).optional()
4988
6147
  }
4989
6148
  }, async (args, extra) => {
4990
6149
  const rs = await resolveCtxSession(extra);
@@ -4997,7 +6156,7 @@ function buildMcpServer() {
4997
6156
  server.registerTool("cancel_ticket", {
4998
6157
  title: "Cancel ticket",
4999
6158
  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() }
6159
+ inputSchema: { id: z4.string(), reason: z4.string().optional() }
5001
6160
  }, async (args, extra) => {
5002
6161
  const rs = await resolveCtxSession(extra);
5003
6162
  if (!rs)
@@ -5009,7 +6168,7 @@ function buildMcpServer() {
5009
6168
  server.registerTool("escalate_to_map", {
5010
6169
  title: "Escalate to map",
5011
6170
  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() }
6171
+ inputSchema: { destination: z4.string(), notes: z4.string().optional() }
5013
6172
  }, async (args, extra) => {
5014
6173
  const rs = await resolveCtxSession(extra);
5015
6174
  if (!rs)
@@ -5023,7 +6182,7 @@ function buildMcpServer() {
5023
6182
  server.registerTool("emit_waypoints", {
5024
6183
  title: "Emit waypoints",
5025
6184
  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) }
6185
+ inputSchema: { waypoints: z4.array(WaypointInput) }
5027
6186
  }, async (args, extra) => {
5028
6187
  const rs = await resolveCtxSession(extra);
5029
6188
  if (!rs)
@@ -5033,7 +6192,7 @@ function buildMcpServer() {
5033
6192
  server.registerTool("resolve_waypoint", {
5034
6193
  title: "Resolve waypoint",
5035
6194
  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() }
6195
+ inputSchema: { id: z4.string(), disposition: WaypointDisposition, summary: z4.string() }
5037
6196
  }, async (args, extra) => {
5038
6197
  const rs = await resolveCtxSession(extra);
5039
6198
  if (!rs)
@@ -5045,7 +6204,7 @@ function buildMcpServer() {
5045
6204
  server.registerTool("record_event", {
5046
6205
  title: "Record event",
5047
6206
  description: "Add a note to the feature timeline (decisions recorded, spec saved, milestones).",
5048
- inputSchema: { type: z3.string(), message: z3.string() }
6207
+ inputSchema: { type: z4.string(), message: z4.string() }
5049
6208
  }, async (args, extra) => {
5050
6209
  const rs = await resolveCtxSession(extra);
5051
6210
  if (!rs)
@@ -5075,6 +6234,7 @@ var init_server = __esm(() => {
5075
6234
  init_sessions();
5076
6235
  init_features();
5077
6236
  init_events();
6237
+ init_findings();
5078
6238
  init_git();
5079
6239
  init_knowledge();
5080
6240
  init_repo();
@@ -5168,12 +6328,16 @@ import { Hono as Hono2 } from "hono";
5168
6328
  function handleSessionStart(ctx, sessionId, feature, payload) {
5169
6329
  const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
5170
6330
  const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
6331
+ const source = typeof payload?.source === "string" ? payload.source : undefined;
6332
+ const wasLive = getSessionRow(ctx, sessionId)?.status === "live";
5171
6333
  markSessionLive(ctx, sessionId, { ccSessionId, transcriptPath });
5172
- emit(ctx, feature.id, {
5173
- type: "session.started",
5174
- message: "session live",
5175
- data: { sessionId, ccSessionId, transcriptPath }
5176
- });
6334
+ if (!wasLive) {
6335
+ emit(ctx, feature.id, {
6336
+ type: "session.started",
6337
+ message: source === "resume" ? "session live (conversation resumed)" : "session live",
6338
+ data: { sessionId, ccSessionId, transcriptPath, source: source ?? null }
6339
+ });
6340
+ }
5177
6341
  return {
5178
6342
  hookSpecificOutput: {
5179
6343
  hookEventName: "SessionStart",
@@ -5181,7 +6345,60 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
5181
6345
  }
5182
6346
  };
5183
6347
  }
5184
- function handleUserPrompt(ctx, feature) {
6348
+ function handlePrepareSessionStart(ctx, session, payload) {
6349
+ const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
6350
+ const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
6351
+ const source = typeof payload?.source === "string" ? payload.source : undefined;
6352
+ const wasLive = getSessionRow(ctx, session.id)?.status === "live";
6353
+ markSessionLive(ctx, session.id, { ccSessionId, transcriptPath });
6354
+ if (!wasLive) {
6355
+ emitForSession(ctx, session, {
6356
+ type: "session.started",
6357
+ message: source === "resume" ? "preparation session live (resumed)" : "preparation session live",
6358
+ data: { sessionId: session.id, ccSessionId, transcriptPath, source: source ?? null }
6359
+ });
6360
+ }
6361
+ return {
6362
+ hookSpecificOutput: {
6363
+ hookEventName: "SessionStart",
6364
+ additionalContext: prepareStartContext(ctx, session)
6365
+ }
6366
+ };
6367
+ }
6368
+ function handlePrepareUserPrompt(ctx, session, payload) {
6369
+ const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
6370
+ noteKickoffPrompt(ctx, session.id, prompt);
6371
+ return {
6372
+ hookSpecificOutput: {
6373
+ hookEventName: "UserPromptSubmit",
6374
+ additionalContext: `[runcastle] preparation session (project-scoped, no feature)`
6375
+ }
6376
+ };
6377
+ }
6378
+ function handlePrepareSessionEnd(ctx, session) {
6379
+ markSessionEnded(ctx, session.id);
6380
+ emitForSession(ctx, session, {
6381
+ type: "session.ended",
6382
+ message: "preparation session ended",
6383
+ data: { sessionId: session.id }
6384
+ });
6385
+ return {};
6386
+ }
6387
+ function prepareStartContext(ctx, session) {
6388
+ const project = session.projectId ? getProjectById(ctx, session.projectId) : null;
6389
+ if (!project)
6390
+ return "[runcastle] preparation session \u2014 project not found";
6391
+ const remaining = keysToPrepare(ctx, project);
6392
+ return [
6393
+ `[runcastle] preparation session for ${project.name} (${project.repoPath}).`,
6394
+ remaining.length > 0 ? `Still unestablished: ${remaining.join(", ")}.` : "Every prepared field currently has a value.",
6395
+ "Record what you establish with the `record_finding` MCP tool \u2014 a value the user",
6396
+ "gives you or confirms verbatim is theirs (userSupplied: true), anything you measured is not."
6397
+ ].join(" ");
6398
+ }
6399
+ function handleUserPrompt(ctx, sessionId, feature, payload) {
6400
+ const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
6401
+ noteKickoffPrompt(ctx, sessionId, prompt);
5185
6402
  const tickets2 = listByFeature2(ctx, feature.id).length;
5186
6403
  return {
5187
6404
  hookSpecificOutput: {
@@ -5220,6 +6437,7 @@ var hooks, hooks_default;
5220
6437
  var init_hooks = __esm(() => {
5221
6438
  init_sessions();
5222
6439
  init_events();
6440
+ init_prep();
5223
6441
  init_repo();
5224
6442
  init_tickets();
5225
6443
  init_waypoints();
@@ -5241,6 +6459,18 @@ var init_hooks = __esm(() => {
5241
6459
  const session = getSessionRow(ctx, sessionId);
5242
6460
  if (!session)
5243
6461
  return c.json({});
6462
+ if (!session.featureId) {
6463
+ switch (event) {
6464
+ case "session-start":
6465
+ return c.json(handlePrepareSessionStart(ctx, session, body.payload));
6466
+ case "user-prompt":
6467
+ return c.json(handlePrepareUserPrompt(ctx, session, body.payload));
6468
+ case "session-end":
6469
+ return c.json(handlePrepareSessionEnd(ctx, session));
6470
+ default:
6471
+ return c.json({});
6472
+ }
6473
+ }
5244
6474
  const feature = tryGetFeature(ctx, session.featureId);
5245
6475
  if (!feature)
5246
6476
  return c.json({});
@@ -5248,7 +6478,7 @@ var init_hooks = __esm(() => {
5248
6478
  case "session-start":
5249
6479
  return c.json(handleSessionStart(ctx, sessionId, feature, body.payload));
5250
6480
  case "user-prompt":
5251
- return c.json(handleUserPrompt(ctx, feature));
6481
+ return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
5252
6482
  case "session-end":
5253
6483
  return c.json(handleSessionEnd(ctx, sessionId, feature));
5254
6484
  default:
@@ -5313,9 +6543,9 @@ var init_stream = __esm(() => {
5313
6543
  });
5314
6544
 
5315
6545
  // src/routes/web.ts
5316
- import { existsSync as existsSync12, statSync } from "fs";
6546
+ import { existsSync as existsSync13, statSync } from "fs";
5317
6547
  import { readFile } from "fs/promises";
5318
- import { extname, join as join11, normalize, resolve as resolve6, sep as sep2 } from "path";
6548
+ import { extname, join as join12, normalize, resolve as resolve7, sep as sep2 } from "path";
5319
6549
  function contentType(path) {
5320
6550
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
5321
6551
  }
@@ -5333,7 +6563,7 @@ function safeJoin(root, pathname) {
5333
6563
  } catch {
5334
6564
  return null;
5335
6565
  }
5336
- const joined = normalize(join11(root, decoded));
6566
+ const joined = normalize(join12(root, decoded));
5337
6567
  if (joined !== root && !joined.startsWith(root + sep2))
5338
6568
  return null;
5339
6569
  return joined;
@@ -5341,12 +6571,12 @@ function safeJoin(root, pathname) {
5341
6571
  function resolveWebDist() {
5342
6572
  const override = process.env.RUNCASTLE_WEB_DIST;
5343
6573
  if (override)
5344
- return resolve6(override);
5345
- return resolve6(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
6574
+ return resolve7(override);
6575
+ return resolve7(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
5346
6576
  }
5347
6577
  function mountWebApp(app, distDir) {
5348
- const root = resolve6(distDir);
5349
- const indexHtml = join11(root, "index.html");
6578
+ const root = resolve7(distDir);
6579
+ const indexHtml = join12(root, "index.html");
5350
6580
  app.get("*", async (c, next) => {
5351
6581
  const { pathname } = new URL(c.req.url);
5352
6582
  if (/^\/(api|mcp|ws|health)(\/|$)/.test(pathname))
@@ -5366,7 +6596,7 @@ function mountWebApp(app, distDir) {
5366
6596
  }
5367
6597
  function mountWebAppIfBuilt(app) {
5368
6598
  const dist = resolveWebDist();
5369
- if (existsSync12(join11(dist, "index.html")))
6599
+ if (existsSync13(join12(dist, "index.html")))
5370
6600
  mountWebApp(app, dist);
5371
6601
  }
5372
6602
  var MIME;
@@ -5413,14 +6643,14 @@ var init_context = __esm(() => {
5413
6643
  });
5414
6644
 
5415
6645
  // src/trpc/routers/docs.ts
5416
- import * as z4 from "zod";
6646
+ import * as z5 from "zod";
5417
6647
  var docsRouter;
5418
6648
  var init_docs = __esm(() => {
5419
6649
  init_knowledge();
5420
6650
  init_repo();
5421
6651
  init_context();
5422
6652
  docsRouter = router({
5423
- read: publicProcedure.input(z4.object({ featureId: z4.string(), relPath: z4.string() })).query(({ ctx, input }) => {
6653
+ read: publicProcedure.input(z5.object({ featureId: z5.string(), relPath: z5.string() })).query(({ ctx, input }) => {
5424
6654
  const feature = getFeatureRow(ctx, input.featureId);
5425
6655
  return readDoc(ctx, feature, input.relPath);
5426
6656
  })
@@ -5428,23 +6658,23 @@ var init_docs = __esm(() => {
5428
6658
  });
5429
6659
 
5430
6660
  // src/trpc/routers/events.ts
5431
- import * as z5 from "zod";
6661
+ import * as z6 from "zod";
5432
6662
  var eventsRouter;
5433
6663
  var init_events2 = __esm(() => {
5434
6664
  init_events();
5435
6665
  init_context();
5436
6666
  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))
6667
+ list: publicProcedure.input(z6.object({ featureId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listAfter(ctx, input.featureId, input.afterId)),
6668
+ listByProject: publicProcedure.input(z6.object({ projectId: z6.string(), afterId: z6.number().optional() })).query(({ ctx, input }) => listByProject(ctx, input.projectId, input.afterId))
5439
6669
  });
5440
6670
  });
5441
6671
 
5442
6672
  // 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";
6673
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync2 } from "fs";
6674
+ import { fileURLToPath as fileURLToPath5 } from "url";
6675
+ import { join as join13 } from "path";
5446
6676
  function hookClientPath() {
5447
- return resolveAsset(ASSET_ENV.hookClient, fileURLToPath4(new URL("./hook-client.ts", import.meta.url)));
6677
+ return resolveAsset(ASSET_ENV.hookClient, fileURLToPath5(new URL("./hook-client.ts", import.meta.url)));
5448
6678
  }
5449
6679
  function serverUrlFor(config2) {
5450
6680
  return `http://localhost:${config2.serverPort}`;
@@ -5636,6 +6866,69 @@ function renderRevisitPrompt(feature) {
5636
6866
  ].join(`
5637
6867
  `);
5638
6868
  }
6869
+ function renderPreparePrompt(brief) {
6870
+ const { project, remainingKeys, established, notes } = brief;
6871
+ return [
6872
+ `# runcastle \u2014 preparing ${project.name}`,
6873
+ "",
6874
+ "This is a **preparation session**: a project-scoped conversation whose job is to",
6875
+ "establish the settings every later agent depends on, and to record them with evidence.",
6876
+ "There is no feature here and no pipeline to advance.",
6877
+ "",
6878
+ "## Where this runs \u2014 and why that matters",
6879
+ `You are on the developer's own machine, in \`${project.repoPath}\`, NOT in a sandbox.`,
6880
+ "A headless preparation run measures the repo in a throwaway container, which means it",
6881
+ "can never verify anything about *this* machine: the dev server, the local database,",
6882
+ "docker, credentials. Those keys \u2014 `devCommand`, `driveSetupCommand`, `driveStopCommand`,",
6883
+ "`driveEnv`, `dbResetCommand` \u2014 are exactly what it has to leave as proposals.",
6884
+ "You can actually run them. That is the point of this session.",
6885
+ "",
6886
+ "The same access is why you must **ask before you act**. Anything that starts or stops a",
6887
+ "service, creates or migrates a database, or writes outside the repo needs the human to",
6888
+ "agree first \u2014 say what you are about to run and why, then wait.",
6889
+ "",
6890
+ ...established.length > 0 ? [
6891
+ "## Already established \u2014 do not re-derive",
6892
+ "These are recorded findings. Treat them as true unless the human says otherwise;",
6893
+ "replacing a measured value with a fresh guess makes preparation worse, not better.",
6894
+ "",
6895
+ ...established.map((f) => `- \`${f.key}\` (${f.source})${f.evidence ? ` \u2014 ${f.evidence}` : ""}`),
6896
+ ""
6897
+ ] : [],
6898
+ ...notes ? [
6899
+ "## What the last run could not settle",
6900
+ "Its own words. Where it says it declined to guess, it was right to \u2014 that is the",
6901
+ "gap you are here to close, and closing it usually means asking one direct question.",
6902
+ "",
6903
+ notes,
6904
+ ""
6905
+ ] : [],
6906
+ "## Still open",
6907
+ remainingKeys.length > 0 ? remainingKeys.map((k) => `- \`${k}\``).join(`
6908
+ `) : "_Nothing is unset. Confirm the existing values still hold, then say so and stop._",
6909
+ "",
6910
+ "## Recording what you establish",
6911
+ "- `record_finding({ key, value, evidence, userSupplied })` \u2014 one call per key.",
6912
+ "- `userSupplied: true` means the human GAVE you this value or confirmed it verbatim.",
6913
+ " That marks it as theirs and permanently stops automatic runs from overwriting it.",
6914
+ "- Leave it false for anything you worked out yourself, even with them watching \u2014",
6915
+ " that stays improvable by a later run. Getting this backwards silently retires a",
6916
+ " field from preparation forever, and the only way back is the human clearing it.",
6917
+ "- `evidence` is not optional in spirit: record what you ran and what it printed, or",
6918
+ " what the human told you. A value with no account of itself is a guess with a source field.",
6919
+ "",
6920
+ "## Secrets",
6921
+ "This is a development environment and the human has agreed to supply real connection",
6922
+ "strings and credentials here. Store them as given. Do not paste a secret into a timeline",
6923
+ "note or a commit message \u2014 `record_finding` is the only place a value belongs.",
6924
+ "",
6925
+ "## Your task",
6926
+ "Open by telling the human which fields are still open and what you need from them for",
6927
+ "each. Work them one at a time: propose, ask, run it if they agree, then record it.",
6928
+ ""
6929
+ ].join(`
6930
+ `);
6931
+ }
5639
6932
  function renderSettings(hookClient) {
5640
6933
  const cmd = (event) => ({
5641
6934
  type: "command",
@@ -5645,7 +6938,10 @@ function renderSettings(hookClient) {
5645
6938
  return {
5646
6939
  permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...SESSION_BASH_ALLOW_RULES] },
5647
6940
  hooks: {
5648
- SessionStart: [{ matcher: "startup", hooks: [cmd("session-start")] }],
6941
+ SessionStart: SESSION_START_SOURCES.map((source) => ({
6942
+ matcher: source,
6943
+ hooks: [cmd("session-start")]
6944
+ })),
5649
6945
  UserPromptSubmit: [{ hooks: [cmd("user-prompt")] }],
5650
6946
  SessionEnd: [{ hooks: [cmd("session-end")] }]
5651
6947
  }
@@ -5663,18 +6959,21 @@ function renderMcpConfig(session, config2) {
5663
6959
  };
5664
6960
  }
5665
6961
  async function writeSessionArtifacts(input) {
5666
- const { session, feature, config: config2, waypoint } = input;
6962
+ const { session, feature, config: config2, waypoint, prepare } = input;
5667
6963
  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");
5672
- writeFileSync2(systemPromptPath, renderSystemPrompt(feature, session.kind, waypoint), "utf8");
6964
+ mkdirSync7(dir, { recursive: true });
6965
+ const systemPromptPath = join13(dir, "system-prompt.md");
6966
+ const settingsPath = join13(dir, "settings.json");
6967
+ const mcpConfigPath = join13(dir, "mcp.json");
6968
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : (() => {
6969
+ throw new Error(`session ${session.id} has neither a feature nor a preparation brief`);
6970
+ })();
6971
+ writeFileSync2(systemPromptPath, systemPrompt, "utf8");
5673
6972
  writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
5674
6973
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
5675
6974
  return { systemPromptPath, settingsPath, mcpConfigPath };
5676
6975
  }
5677
- var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES;
6976
+ var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
5678
6977
  var init_artifacts = __esm(() => {
5679
6978
  init_paths();
5680
6979
  init_asset_paths();
@@ -5687,7 +6986,8 @@ var init_artifacts = __esm(() => {
5687
6986
  "mcp__runcastle__complete_phase",
5688
6987
  "mcp__runcastle__escalate_to_map",
5689
6988
  "mcp__runcastle__emit_waypoints",
5690
- "mcp__runcastle__resolve_waypoint"
6989
+ "mcp__runcastle__resolve_waypoint",
6990
+ "mcp__runcastle__record_finding"
5691
6991
  ];
5692
6992
  SESSION_BASH_ALLOW_RULES = [
5693
6993
  "Bash(git status:*)",
@@ -5699,13 +6999,14 @@ var init_artifacts = __esm(() => {
5699
6999
  "Bash(git branch:*)",
5700
7000
  "Bash(git show:*)"
5701
7001
  ];
7002
+ SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
5702
7003
  });
5703
7004
 
5704
7005
  // 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";
7006
+ import { existsSync as existsSync14 } from "fs";
7007
+ import { dirname as dirname6, join as join14, resolve as resolve8 } from "path";
7008
+ import { fileURLToPath as fileURLToPath6 } from "url";
7009
+ import { and as and8, eq as eq10 } from "drizzle-orm";
5709
7010
  function ptyExitMessage(exitCode) {
5710
7011
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
5711
7012
  return `terminal exited (code ${label})`;
@@ -5719,7 +7020,7 @@ function buildClaudeArgs(input) {
5719
7020
  input.settingsPath,
5720
7021
  "--mcp-config",
5721
7022
  input.mcpConfigPath,
5722
- "--strict-mcp-config",
7023
+ ...input.strictMcp ? ["--strict-mcp-config"] : [],
5723
7024
  "--plugin-dir",
5724
7025
  input.pluginDir,
5725
7026
  "--append-system-prompt-file",
@@ -5736,23 +7037,23 @@ function resolveClaudeExecutable() {
5736
7037
  function claudeSpawnTarget(claudeArgs) {
5737
7038
  return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
5738
7039
  }
5739
- function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5740
- const rel = join13("packages", "skills", "packs", "runcastle");
7040
+ function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
7041
+ const rel = join14("packages", "skills", "packs", "runcastle");
5741
7042
  const override = process.env[SKILLS_DIR_ENV];
5742
7043
  if (override) {
5743
- const dir2 = join13(resolve7(override), "packs", "runcastle");
5744
- if (existsSync13(dir2))
7044
+ const dir2 = join14(resolve8(override), "packs", "runcastle");
7045
+ if (existsSync14(dir2))
5745
7046
  return dir2;
5746
7047
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
5747
7048
  }
5748
7049
  const searched = [];
5749
7050
  let dir = fromDir;
5750
7051
  for (let i = 0;i < 8; i += 1) {
5751
- const candidate = join13(dir, rel);
7052
+ const candidate = join14(dir, rel);
5752
7053
  searched.push(candidate);
5753
- if (existsSync13(candidate))
7054
+ if (existsSync14(candidate))
5754
7055
  return candidate;
5755
- const parent = dirname5(dir);
7056
+ const parent = dirname6(dir);
5756
7057
  if (parent === dir)
5757
7058
  break;
5758
7059
  dir = parent;
@@ -5762,7 +7063,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
5762
7063
  `)}`);
5763
7064
  }
5764
7065
  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();
7066
+ const row = ctx.db.select().from(runs).where(and8(eq10(runs.featureId, featureId), eq10(runs.status, "running"))).limit(1).get();
5766
7067
  return row ? rowToRun(row) : null;
5767
7068
  }
5768
7069
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -5775,6 +7076,29 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
5775
7076
  throw new GateError(`a ${running.workflow} run is in progress on ${feature.slug} \u2014 it holds the feature branch; terminals are available when it finishes`);
5776
7077
  }
5777
7078
  }
7079
+ function sessionFinished(ctx, feature, session) {
7080
+ if (session.kind !== "waypoint")
7081
+ return feature.mapped;
7082
+ const own = listByFeature(ctx, feature.id).find((w) => w.lastSessionId === session.id);
7083
+ return !!own && (own.status === "resolved" || own.status === "dropped");
7084
+ }
7085
+ function sweepActiveSessions(ctx, feature, endLive) {
7086
+ for (const session of activeSessionsForFeature(ctx, feature.id)) {
7087
+ const finished = sessionFinished(ctx, feature, session);
7088
+ if (!finished && !endLive)
7089
+ continue;
7090
+ endSession(ctx, session.id);
7091
+ emit(ctx, feature.id, {
7092
+ type: "session.auto_ended",
7093
+ message: finished ? `ended the finished ${session.kind} session to work the next waypoint` : `abandoned the in-flight ${session.kind} session to work the next waypoint`,
7094
+ data: {
7095
+ sessionId: session.id,
7096
+ kind: session.kind,
7097
+ reason: finished ? "finished" : "abandoned"
7098
+ }
7099
+ });
7100
+ }
7101
+ }
5778
7102
  async function ensureWorktree(ctx, project, feature) {
5779
7103
  try {
5780
7104
  return await ensureTalkWorktree(project, feature);
@@ -5882,7 +7206,8 @@ async function launchSession(ctx, input, opts = {}) {
5882
7206
  mcpConfigPath: artifacts.mcpConfigPath,
5883
7207
  systemPromptPath: artifacts.systemPromptPath,
5884
7208
  model: resolveModel(input.kind, ctx.config, project),
5885
- resumeSessionId
7209
+ resumeSessionId,
7210
+ strictMcp: ctx.config.sessionMcp === "runcastleOnly"
5886
7211
  };
5887
7212
  if (opts.spawn === false) {
5888
7213
  emit(ctx, feature.id, {
@@ -5902,6 +7227,75 @@ async function launchSession(ctx, input, opts = {}) {
5902
7227
  });
5903
7228
  return { sessionId: session.id };
5904
7229
  }
7230
+ async function launchPrepareSession(ctx, input, opts = {}) {
7231
+ const project = requireProjectById(ctx, input.projectId);
7232
+ const session = createSessionRow(ctx, {
7233
+ projectId: project.id,
7234
+ kind: "prepare",
7235
+ worktreePath: project.repoPath
7236
+ });
7237
+ const resumeSessionId = mostRecentResumableProjectSession(ctx, project.id, "prepare")?.ccSessionId;
7238
+ emitProject(ctx, project.id, {
7239
+ type: "session.launching",
7240
+ message: "launching preparation session",
7241
+ data: { sessionId: session.id, kind: "prepare", worktreePath: project.repoPath }
7242
+ });
7243
+ if (resumeSessionId) {
7244
+ emitProject(ctx, project.id, {
7245
+ type: "session.resumed",
7246
+ message: "resuming the previous preparation conversation",
7247
+ data: { sessionId: session.id, resumeSessionId }
7248
+ });
7249
+ }
7250
+ const artifacts = await writeSessionArtifacts({
7251
+ session,
7252
+ project,
7253
+ config: ctx.config,
7254
+ prepare: await buildPrepareBrief(ctx, project)
7255
+ });
7256
+ const serverUrl = serverUrlFor(ctx.config);
7257
+ const buildInput = {
7258
+ sessionId: session.id,
7259
+ serverUrl,
7260
+ featureTitle: `preparing ${project.name}`,
7261
+ worktreePath: project.repoPath,
7262
+ pluginDir: resolvePluginDir(),
7263
+ settingsPath: artifacts.settingsPath,
7264
+ mcpConfigPath: artifacts.mcpConfigPath,
7265
+ systemPromptPath: artifacts.systemPromptPath,
7266
+ model: resolveModel("prepare", ctx.config, project),
7267
+ resumeSessionId,
7268
+ strictMcp: ctx.config.sessionMcp === "runcastleOnly"
7269
+ };
7270
+ if (opts.spawn === false) {
7271
+ emitProject(ctx, project.id, {
7272
+ type: "session.launched",
7273
+ message: "preparation session prepared (terminal spawn skipped)",
7274
+ data: {
7275
+ sessionId: session.id,
7276
+ command: ["claude", ...buildClaudeArgs(buildInput)].join(" "),
7277
+ spawned: false
7278
+ }
7279
+ });
7280
+ return { sessionId: session.id };
7281
+ }
7282
+ spawnEmbeddedPty(ctx, undefined, session, project.repoPath, serverUrl, buildClaudeArgs(buildInput), { resumeSessionId });
7283
+ return { sessionId: session.id };
7284
+ }
7285
+ async function buildPrepareBrief(ctx, project) {
7286
+ const findings = await listFindings(ctx, project);
7287
+ const notes = latestPrepNotes(ctx, project.id);
7288
+ return {
7289
+ project,
7290
+ remainingKeys: keysToPrepare(ctx, project),
7291
+ established: findings.map((f) => ({
7292
+ key: f.key,
7293
+ source: f.source,
7294
+ ...f.evidence ? { evidence: f.evidence } : {}
7295
+ })),
7296
+ ...notes ? { notes } : {}
7297
+ };
7298
+ }
5905
7299
  async function workWaypoint(ctx, input, opts = {}) {
5906
7300
  const feature = getFeatureRow(ctx, input.featureId);
5907
7301
  if (!feature.mapped) {
@@ -5918,6 +7312,7 @@ async function workWaypoint(ctx, input, opts = {}) {
5918
7312
  });
5919
7313
  return { runId };
5920
7314
  }
7315
+ sweepActiveSessions(ctx, feature, input.endLive === true);
5921
7316
  assertSpawnable(ctx, feature);
5922
7317
  return launchSession(ctx, { featureId: feature.id, kind: "waypoint", waypointId: wp.id }, opts);
5923
7318
  }
@@ -5974,7 +7369,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
5974
7369
  releaseForSession(ctx, session.id);
5975
7370
  if (diedBeforeLive && meta.resumeSessionId) {
5976
7371
  const label = meta.waypoint ? `waypoint ${meta.waypoint.seq} (${meta.waypoint.title})` : session.kind;
5977
- emit(ctx, feature.id, {
7372
+ emitForSession(ctx, session, {
5978
7373
  type: "session.resume_failed",
5979
7374
  message: `resume failed for ${label} \u2014 the session exited before starting (code ${exitCode ?? "unknown"}); the previous conversation is still resumable`,
5980
7375
  data: {
@@ -5985,7 +7380,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
5985
7380
  }
5986
7381
  });
5987
7382
  }
5988
- emit(ctx, feature.id, {
7383
+ emitForSession(ctx, session, {
5989
7384
  type: "session.pty_exited",
5990
7385
  message: ptyExitMessage(exitCode),
5991
7386
  data: { sessionId: session.id, exitCode: exitCode ?? null }
@@ -6008,15 +7403,16 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
6008
7403
  opts: { cwd: worktreePath, env, cols: 80, rows: 24, useConpty: true },
6009
7404
  onExit: ({ exitCode }) => handlePtyExit(ctx, feature, session, meta, exitCode)
6010
7405
  });
6011
- emit(ctx, feature.id, {
7406
+ emitForSession(ctx, session, {
6012
7407
  type: "session.launched",
6013
7408
  message: "embedded terminal spawned",
6014
7409
  data: { sessionId: session.id, mode: "embedded", pid: entry.pty.pid }
6015
7410
  });
7411
+ armSessionReadyWatchdog(ctx, session);
6016
7412
  } catch (err) {
6017
7413
  markSessionEnded(ctx, session.id);
6018
7414
  releaseForSession(ctx, session.id);
6019
- emit(ctx, feature.id, {
7415
+ emitForSession(ctx, session, {
6020
7416
  type: "session.spawn_failed",
6021
7417
  message: `failed to spawn embedded terminal: ${err instanceof Error ? err.message : String(err)}`,
6022
7418
  data: { sessionId: session.id, mode: "embedded" }
@@ -6031,6 +7427,7 @@ var init_launcher = __esm(() => {
6031
7427
  init_skills_root();
6032
7428
  init_schema();
6033
7429
  init_errors();
7430
+ init_end_session();
6034
7431
  init_registry();
6035
7432
  init_events();
6036
7433
  init_gates();
@@ -6039,8 +7436,11 @@ var init_launcher = __esm(() => {
6039
7436
  init_tickets();
6040
7437
  init_waypoints();
6041
7438
  init_runner();
7439
+ init_findings();
7440
+ init_prep();
6042
7441
  init_artifacts();
6043
7442
  init_sessions();
7443
+ init_sessions();
6044
7444
  init_end_session();
6045
7445
  CC_NESTING_ENV = [
6046
7446
  "CLAUDE_CODE_CHILD_SESSION",
@@ -6055,7 +7455,7 @@ var init_launcher = __esm(() => {
6055
7455
  });
6056
7456
 
6057
7457
  // src/trpc/routers/feature.ts
6058
- import * as z6 from "zod";
7458
+ import * as z7 from "zod";
6059
7459
  var gateId, featureRouter;
6060
7460
  var init_feature = __esm(() => {
6061
7461
  init_src();
@@ -6066,37 +7466,38 @@ var init_feature = __esm(() => {
6066
7466
  init_git();
6067
7467
  init_repo();
6068
7468
  init_context();
6069
- gateId = z6.enum(["G1", "G2", "G3", "G4", "G5"]);
7469
+ gateId = z7.enum(["G1", "G2", "G3", "G4", "G5"]);
6070
7470
  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()
7471
+ create: publicProcedure.input(z7.object({
7472
+ projectId: z7.string(),
7473
+ title: z7.string().min(1),
7474
+ oneLiner: z7.string(),
7475
+ baseBranch: z7.string().optional()
6076
7476
  })).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(),
7477
+ list: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
7478
+ get: publicProcedure.input(z7.object({ id: z7.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
7479
+ launchSession: publicProcedure.input(z7.object({
7480
+ featureId: z7.string(),
6081
7481
  kind: SessionKind,
6082
- kickoffLine: z6.string().min(1).optional()
7482
+ kickoffLine: z7.string().min(1).optional()
6083
7483
  })).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 }) => {
7484
+ workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string(), endLive: z7.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
7485
+ 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 })),
7486
+ resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
7487
+ endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
7488
+ advance: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
7489
+ 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)),
7490
+ archive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
7491
+ unarchive: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
7492
+ delete: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
7493
+ burn: publicProcedure.input(z7.object({ featureId: z7.string(), model: z7.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
7494
+ testDrive: publicProcedure.input(z7.object({ featureId: z7.string(), action: z7.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
6094
7495
  const feature = getFeatureRow(ctx, input.featureId);
6095
7496
  const project = projectForFeature(ctx, feature);
6096
7497
  return testDrive(ctx, project, feature, input.action);
6097
7498
  }),
6098
7499
  driveInfo: publicProcedure.query(() => activeDriveInfo()),
6099
- merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
7500
+ merge: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(async ({ ctx, input }) => {
6100
7501
  const feature = getFeatureRow(ctx, input.featureId);
6101
7502
  const project = projectForFeature(ctx, feature);
6102
7503
  if (activeTestDriveFeatureId() === feature.id) {
@@ -6119,9 +7520,9 @@ var init_feature = __esm(() => {
6119
7520
  });
6120
7521
 
6121
7522
  // src/services/fsbrowse.ts
6122
- import { existsSync as existsSync14, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
7523
+ import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
6123
7524
  import { homedir as homedir2 } from "os";
6124
- import { basename, dirname as dirname6, isAbsolute, join as join14, resolve as resolve8, sep as sep3 } from "path";
7525
+ import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve9, sep as sep3 } from "path";
6125
7526
  function expandPath(input, home = homedir2()) {
6126
7527
  const trimmed = input.trim();
6127
7528
  if (trimmed === "")
@@ -6130,12 +7531,12 @@ function expandPath(input, home = homedir2()) {
6130
7531
  if (out === "~")
6131
7532
  out = home;
6132
7533
  else if (out.startsWith("~/") || out.startsWith("~\\"))
6133
- out = join14(home, out.slice(2));
6134
- return resolve8(out);
7534
+ out = join15(home, out.slice(2));
7535
+ return resolve9(out);
6135
7536
  }
6136
7537
  function looksLikeRepo(dir) {
6137
7538
  try {
6138
- return existsSync14(join14(dir, ".git"));
7539
+ return existsSync15(join15(dir, ".git"));
6139
7540
  } catch {
6140
7541
  return false;
6141
7542
  }
@@ -6146,20 +7547,20 @@ function listRoots(home = homedir2()) {
6146
7547
  for (let code = 67;code <= 90; code++) {
6147
7548
  const drive = `${String.fromCharCode(code)}:${sep3}`;
6148
7549
  try {
6149
- if (existsSync14(drive))
7550
+ if (existsSync15(drive))
6150
7551
  roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
6151
7552
  } catch {}
6152
7553
  }
6153
7554
  } else {
6154
7555
  roots.push({ label: "/", path: "/", kind: "drive" });
6155
7556
  }
6156
- if (existsSync14(home))
7557
+ if (existsSync15(home))
6157
7558
  roots.push({ label: "Home", path: home, kind: "home" });
6158
7559
  const seen = new Set(roots.map(pathKey));
6159
7560
  for (const name of COMMON_DIRS) {
6160
- const candidate = join14(home, name);
7561
+ const candidate = join15(home, name);
6161
7562
  const key = pathKey({ path: candidate });
6162
- if (seen.has(key) || !existsSync14(candidate))
7563
+ if (seen.has(key) || !existsSync15(candidate))
6163
7564
  continue;
6164
7565
  seen.add(key);
6165
7566
  roots.push({ label: name, path: candidate, kind: "common" });
@@ -6174,7 +7575,7 @@ function crumbsFor(dir) {
6174
7575
  let cur = dir;
6175
7576
  for (;; ) {
6176
7577
  out.unshift({ name: basename(cur) || cur, path: cur });
6177
- const parent = dirname6(cur);
7578
+ const parent = dirname7(cur);
6178
7579
  if (parent === cur)
6179
7580
  break;
6180
7581
  cur = parent;
@@ -6182,7 +7583,7 @@ function crumbsFor(dir) {
6182
7583
  return out;
6183
7584
  }
6184
7585
  function parentOf(dir) {
6185
- const parent = dirname6(dir);
7586
+ const parent = dirname7(dir);
6186
7587
  return parent === dir ? null : parent;
6187
7588
  }
6188
7589
  function browseDir(input, showHidden = false, home = homedir2()) {
@@ -6190,13 +7591,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
6190
7591
  if (!isAbsolute(target)) {
6191
7592
  throw new InvalidInputError(`path is not absolute: ${target}`);
6192
7593
  }
6193
- if (!existsSync14(target)) {
7594
+ if (!existsSync15(target)) {
6194
7595
  throw new InvalidInputError(`path does not exist: ${target}`);
6195
7596
  }
6196
7597
  let dir = target;
6197
7598
  try {
6198
7599
  if (!statSync2(target).isDirectory())
6199
- dir = dirname6(target);
7600
+ dir = dirname7(target);
6200
7601
  } catch (e) {
6201
7602
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg2(e)})`);
6202
7603
  }
@@ -6218,7 +7619,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
6218
7619
  continue;
6219
7620
  if (!showHidden && name.startsWith("."))
6220
7621
  continue;
6221
- const path = join14(dir, name);
7622
+ const path = join15(dir, name);
6222
7623
  const isSymlink = dirent.isSymbolicLink();
6223
7624
  let isDirectory = dirent.isDirectory();
6224
7625
  if (isSymlink) {
@@ -6253,9 +7654,9 @@ var init_fsbrowse = __esm(() => {
6253
7654
  });
6254
7655
 
6255
7656
  // 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";
7657
+ import { existsSync as existsSync16 } from "fs";
7658
+ import { basename as basename2, join as join16 } from "path";
7659
+ import { eq as eq11, isNull } from "drizzle-orm";
6259
7660
  function listProjects(ctx) {
6260
7661
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
6261
7662
  }
@@ -6277,13 +7678,14 @@ async function openProject(ctx, rawPath) {
6277
7678
  const name = basename2(repoPath) || repoPath;
6278
7679
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
6279
7680
  if (existing) {
6280
- ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq9(projects.id, existing.id)).run();
7681
+ ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq11(projects.id, existing.id)).run();
6281
7682
  const reopened = { ...existing, mainBranch, repoPath };
6282
7683
  emitProject(ctx, existing.id, {
6283
7684
  type: "project.opened",
6284
7685
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
6285
7686
  });
6286
7687
  warnIfTranslatedMount(ctx, existing.id, repoPath);
7688
+ maybeAutoPrepare(ctx, reopened);
6287
7689
  return reopened;
6288
7690
  }
6289
7691
  const row = {
@@ -6301,14 +7703,25 @@ async function openProject(ctx, rawPath) {
6301
7703
  message: `project ${name} at ${repoPath} (${mainBranch})`
6302
7704
  });
6303
7705
  warnIfTranslatedMount(ctx, project.id, repoPath);
7706
+ maybeAutoPrepare(ctx, project);
6304
7707
  return project;
6305
7708
  }
7709
+ function maybeAutoPrepare(ctx, project) {
7710
+ if (!shouldAutoPrepare(ctx, project))
7711
+ return;
7712
+ startPrep(ctx, project.id).catch((e) => {
7713
+ emitProject(ctx, project.id, {
7714
+ type: "prep.run.failed",
7715
+ message: `could not start preparation: ${e instanceof Error ? e.message : String(e)}`
7716
+ });
7717
+ });
7718
+ }
6306
7719
  function closeProject(ctx, projectId) {
6307
7720
  const project = requireProjectById(ctx, projectId);
6308
7721
  if (projectHasActiveRun(ctx, projectId)) {
6309
7722
  throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
6310
7723
  }
6311
- ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq9(projects.id, projectId)).run();
7724
+ ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq11(projects.id, projectId)).run();
6312
7725
  emitProject(ctx, projectId, {
6313
7726
  type: "project.closed",
6314
7727
  message: `project ${project.name} closed`
@@ -6317,7 +7730,7 @@ function closeProject(ctx, projectId) {
6317
7730
  }
6318
7731
  function renameProject(ctx, projectId, name) {
6319
7732
  const project = requireProjectById(ctx, projectId);
6320
- ctx.db.update(projects).set({ name }).where(eq9(projects.id, projectId)).run();
7733
+ ctx.db.update(projects).set({ name }).where(eq11(projects.id, projectId)).run();
6321
7734
  emitProject(ctx, projectId, {
6322
7735
  type: "project.renamed",
6323
7736
  message: `project renamed ${project.name} \u2192 ${name}`,
@@ -6335,7 +7748,7 @@ async function assertRepoTolerant(ctx, repoPath) {
6335
7748
  } catch (e) {
6336
7749
  if (!isNotImplemented(e))
6337
7750
  throw e;
6338
- if (!existsSync15(join15(repoPath, ".git"))) {
7751
+ if (!existsSync16(join16(repoPath, ".git"))) {
6339
7752
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
6340
7753
  }
6341
7754
  }
@@ -6356,31 +7769,63 @@ var init_projects = __esm(() => {
6356
7769
  init_events();
6357
7770
  init_fsbrowse();
6358
7771
  init_git();
7772
+ init_prep();
6359
7773
  init_repo();
6360
7774
  });
6361
7775
 
6362
7776
  // src/trpc/routers/project.ts
6363
- import * as z7 from "zod";
7777
+ import * as z8 from "zod";
6364
7778
  var projectRouter;
6365
7779
  var init_project = __esm(() => {
7780
+ init_src();
7781
+ init_findings();
6366
7782
  init_fsbrowse();
6367
7783
  init_git();
7784
+ init_prep();
7785
+ init_launcher();
7786
+ init_sessions();
6368
7787
  init_projects();
6369
7788
  init_repo();
6370
7789
  init_context();
6371
7790
  projectRouter = router({
6372
7791
  list: publicProcedure.query(({ ctx }) => listProjects(ctx)),
6373
7792
  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))
7793
+ browse: publicProcedure.input(z8.object({ dir: z8.string().optional(), showHidden: z8.boolean().optional() }).optional()).query(({ input }) => browseDir(input?.dir, input?.showHidden ?? false)),
7794
+ branches: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
7795
+ open: publicProcedure.input(z8.object({ repoPath: z8.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
7796
+ close: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
7797
+ rename: publicProcedure.input(z8.object({ projectId: z8.string(), name: z8.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
7798
+ prepare: publicProcedure.input(z8.object({
7799
+ projectId: z8.string(),
7800
+ refresh: z8.boolean().optional(),
7801
+ keys: z8.array(PreparedKey).optional()
7802
+ })).mutation(async ({ ctx, input }) => {
7803
+ const { prepId, keys } = await startPrep(ctx, input.projectId, {
7804
+ ...input.refresh !== undefined ? { refresh: input.refresh } : {},
7805
+ ...input.keys ? { keys: input.keys } : {}
7806
+ });
7807
+ return { prepId, keys };
7808
+ }),
7809
+ talkToPrep: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
7810
+ prepSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
7811
+ cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
7812
+ cancelPrep(input.projectId);
7813
+ return { ok: true };
7814
+ }),
7815
+ prep: publicProcedure.input(z8.object({ projectId: z8.string() })).query(async ({ ctx, input }) => {
7816
+ const project = requireProjectById(ctx, input.projectId);
7817
+ return {
7818
+ latest: latestPrep(ctx, project.id),
7819
+ running: isPreparing(project.id),
7820
+ pendingKeys: keysToPrepare(ctx, project),
7821
+ findings: await listFindings(ctx, project)
7822
+ };
7823
+ })
6379
7824
  });
6380
7825
  });
6381
7826
 
6382
7827
  // src/trpc/routers/run.ts
6383
- import * as z8 from "zod";
7828
+ import * as z9 from "zod";
6384
7829
  var runRouter;
6385
7830
  var init_run = __esm(() => {
6386
7831
  init_agent_stream();
@@ -6388,9 +7833,9 @@ var init_run = __esm(() => {
6388
7833
  init_runner();
6389
7834
  init_context();
6390
7835
  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 }) => {
7836
+ get: publicProcedure.input(z9.object({ runId: z9.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
7837
+ agentTranscript: publicProcedure.input(z9.object({ ticketId: z9.string(), after: z9.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
7838
+ cancel: publicProcedure.input(z9.object({ runId: z9.string() })).mutation(({ input }) => {
6394
7839
  cancelRun(input.runId);
6395
7840
  return { ok: true };
6396
7841
  })
@@ -6398,15 +7843,15 @@ var init_run = __esm(() => {
6398
7843
  });
6399
7844
 
6400
7845
  // 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";
7846
+ import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, writeFileSync as writeFileSync3 } from "fs";
7847
+ import { dirname as dirname8 } from "path";
7848
+ import * as z10 from "zod";
7849
+ import { eq as eq12 } from "drizzle-orm";
6405
7850
  function readRawConfig(configFile) {
6406
- if (!existsSync16(configFile))
7851
+ if (!existsSync17(configFile))
6407
7852
  return {};
6408
7853
  try {
6409
- const parsed = JSON.parse(readFileSync6(configFile, "utf8"));
7854
+ const parsed = JSON.parse(readFileSync7(configFile, "utf8"));
6410
7855
  if (typeof parsed !== "object" || parsed === null)
6411
7856
  return {};
6412
7857
  return foldLegacyModelConfig(parsed);
@@ -6430,33 +7875,51 @@ function stepModelFields(fileRaw) {
6430
7875
  });
6431
7876
  }
6432
7877
  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();
7878
+ const row = ctx.db.select({
7879
+ model: projects.model,
7880
+ sandbox: projects.sandbox,
7881
+ devCommand: projects.devCommand,
7882
+ setupCommand: projects.setupCommand,
7883
+ verifyCommands: projects.verifyCommands,
7884
+ knownFailures: projects.knownFailures,
7885
+ dbResetCommand: projects.dbResetCommand,
7886
+ driveSetupCommand: projects.driveSetupCommand,
7887
+ driveStopCommand: projects.driveStopCommand,
7888
+ driveEnv: projects.driveEnv
7889
+ }).from(projects).where(eq12(projects.id, projectId)).get();
6434
7890
  return {
6435
7891
  model: row?.model ?? null,
6436
7892
  sandbox: row?.sandbox ?? null,
6437
- devCommand: row?.devCommand ?? null
7893
+ devCommand: row?.devCommand ?? null,
7894
+ setupCommand: row?.setupCommand ?? null,
7895
+ verifyCommands: row?.verifyCommands ?? null,
7896
+ knownFailures: row?.knownFailures ?? null,
7897
+ dbResetCommand: row?.dbResetCommand ?? null,
7898
+ driveSetupCommand: row?.driveSetupCommand ?? null,
7899
+ driveStopCommand: row?.driveStopCommand ?? null,
7900
+ driveEnv: row?.driveEnv ?? null
6438
7901
  };
6439
7902
  }
6440
- function resolveField(desc4, layers) {
7903
+ function resolveField(desc5, layers) {
6441
7904
  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];
7905
+ const scope = scoped && desc5.projectColumn ? "project" : "global";
7906
+ const base = { key: desc5.key, restartRequired: desc5.restartRequired, scope };
7907
+ if (desc5.envVar) {
7908
+ const raw = layers.env[desc5.envVar];
6446
7909
  if (raw !== undefined && raw !== "") {
6447
- return { ...base, value: desc4.parseEnv(raw), source: "env", editable: false };
7910
+ return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
6448
7911
  }
6449
7912
  }
6450
- if (layers.overrides && desc4.projectColumn) {
6451
- const ov = layers.overrides[desc4.projectColumn];
7913
+ if (layers.overrides && desc5.projectColumn) {
7914
+ const ov = layers.overrides[desc5.projectColumn];
6452
7915
  if (ov !== null && ov !== "") {
6453
7916
  return { ...base, value: ov, source: "project", editable: true };
6454
7917
  }
6455
7918
  }
6456
- if (desc4.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc4.configKey)) {
6457
- return { ...base, value: layers.fileRaw[desc4.configKey], source: "file", editable: true };
7919
+ if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
7920
+ return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
6458
7921
  }
6459
- const value = desc4.configKey ? DEFAULTS[desc4.configKey] ?? null : null;
7922
+ const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
6460
7923
  return { ...base, value, source: "default", editable: true };
6461
7924
  }
6462
7925
  function getSettings(ctx, projectId, io = {}) {
@@ -6473,59 +7936,61 @@ function updateSettings(ctx, input, io = {}) {
6473
7936
  if (input.key.startsWith("stepModels.")) {
6474
7937
  return updateStepModel(ctx, input, configFile, io);
6475
7938
  }
6476
- const desc4 = DESCRIPTORS.find((d) => d.key === input.key);
6477
- if (!desc4)
7939
+ const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
7940
+ if (!desc5)
6478
7941
  throw new InvalidInputError(`unknown setting: ${input.key}`);
6479
- if (desc4.envVar) {
6480
- const raw = env[desc4.envVar];
7942
+ if (desc5.envVar) {
7943
+ const raw = env[desc5.envVar];
6481
7944
  if (raw !== undefined && raw !== "") {
6482
- throw new InvalidInputError(`${desc4.key} is set by environment variable ${desc4.envVar}; unset it to edit`);
7945
+ throw new InvalidInputError(`${desc5.key} is set by environment variable ${desc5.envVar}; unset it to edit`);
6483
7946
  }
6484
7947
  }
6485
- const toProject = input.projectId !== undefined && desc4.projectColumn !== undefined;
7948
+ const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
6486
7949
  if (input.value === null) {
6487
7950
  if (!toProject)
6488
- throw new InvalidInputError(`${desc4.key} cannot be cleared`);
7951
+ throw new InvalidInputError(`${desc5.key} cannot be cleared`);
6489
7952
  const project = requireProjectById(ctx, input.projectId);
6490
- ctx.db.update(projects).set({ [desc4.projectColumn]: null }).where(eq10(projects.id, project.id)).run();
7953
+ ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
7954
+ recordHuman(ctx, project.id, desc5.key, null);
6491
7955
  emitProject(ctx, project.id, {
6492
7956
  type: "settings.updated",
6493
- message: `${desc4.key} override cleared`,
6494
- data: { key: desc4.key, scope: "project", value: null }
7957
+ message: `${desc5.key} override cleared`,
7958
+ data: { key: desc5.key, scope: "project", value: null }
6495
7959
  });
6496
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7960
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6497
7961
  }
6498
- const parsed = desc4.valueSchema.safeParse(input.value);
7962
+ const parsed = desc5.valueSchema.safeParse(input.value);
6499
7963
  if (!parsed.success) {
6500
- throw new InvalidInputError(`invalid value for ${desc4.key}: ${parsed.error.issues[0]?.message}`);
7964
+ throw new InvalidInputError(`invalid value for ${desc5.key}: ${parsed.error.issues[0]?.message}`);
6501
7965
  }
6502
7966
  const value = parsed.data;
6503
7967
  if (toProject) {
6504
7968
  const project = requireProjectById(ctx, input.projectId);
6505
- ctx.db.update(projects).set({ [desc4.projectColumn]: String(value) }).where(eq10(projects.id, project.id)).run();
7969
+ ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
7970
+ recordHuman(ctx, project.id, desc5.key, String(value));
6506
7971
  emitProject(ctx, project.id, {
6507
7972
  type: "settings.updated",
6508
- message: `${desc4.key} override set to ${String(value)}`,
6509
- data: { key: desc4.key, scope: "project", value }
7973
+ message: `${desc5.key} override set to ${String(value)}`,
7974
+ data: { key: desc5.key, scope: "project", value }
6510
7975
  });
6511
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7976
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6512
7977
  }
6513
- if (!desc4.configKey) {
6514
- throw new InvalidInputError(`${desc4.key} is a per-project setting; provide a projectId`);
7978
+ if (!desc5.configKey) {
7979
+ throw new InvalidInputError(`${desc5.key} is a per-project setting; provide a projectId`);
6515
7980
  }
6516
- writeGlobal(configFile, desc4.configKey, value);
6517
- ctx.config[desc4.configKey] = value;
7981
+ writeGlobal(configFile, desc5.configKey, value);
7982
+ ctx.config[desc5.configKey] = value;
6518
7983
  emitProject(ctx, GLOBAL_EVENT_KEY, {
6519
7984
  type: "settings.updated",
6520
- message: `${desc4.key} set to ${String(value)}`,
6521
- data: { key: desc4.key, scope: "global", value }
7985
+ message: `${desc5.key} set to ${String(value)}`,
7986
+ data: { key: desc5.key, scope: "global", value }
6522
7987
  });
6523
- return field(getSettings(ctx, input.projectId, io), desc4.key);
7988
+ return field(getSettings(ctx, input.projectId, io), desc5.key);
6524
7989
  }
6525
7990
  function writeGlobal(configFile, configKey, value) {
6526
7991
  const raw = readRawConfig(configFile);
6527
7992
  raw[configKey] = value;
6528
- mkdirSync7(dirname7(configFile), { recursive: true });
7993
+ mkdirSync8(dirname8(configFile), { recursive: true });
6529
7994
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
6530
7995
  `);
6531
7996
  }
@@ -6536,7 +8001,7 @@ function updateStepModel(ctx, input, configFile, io) {
6536
8001
  const modelStep = step;
6537
8002
  let value = null;
6538
8003
  if (input.value !== null) {
6539
- const parsed = z9.string().min(1).safeParse(input.value);
8004
+ const parsed = z10.string().min(1).safeParse(input.value);
6540
8005
  if (!parsed.success) {
6541
8006
  throw new InvalidInputError(`invalid value for ${input.key}: ${parsed.error.issues[0]?.message}`);
6542
8007
  }
@@ -6564,7 +8029,7 @@ function writeStepModel(configFile, step, value) {
6564
8029
  else
6565
8030
  stepModels[step] = value;
6566
8031
  raw.stepModels = stepModels;
6567
- mkdirSync7(dirname7(configFile), { recursive: true });
8032
+ mkdirSync8(dirname8(configFile), { recursive: true });
6568
8033
  writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
6569
8034
  `);
6570
8035
  }
@@ -6581,6 +8046,7 @@ var init_settings = __esm(() => {
6581
8046
  init_schema();
6582
8047
  init_errors();
6583
8048
  init_events();
8049
+ init_findings();
6584
8050
  init_repo();
6585
8051
  DESCRIPTORS = [
6586
8052
  {
@@ -6588,7 +8054,7 @@ var init_settings = __esm(() => {
6588
8054
  configKey: "serverPort",
6589
8055
  envVar: "RUNCASTLE_SERVER_PORT",
6590
8056
  restartRequired: true,
6591
- valueSchema: z9.number().int().positive(),
8057
+ valueSchema: z10.number().int().positive(),
6592
8058
  parseEnv: (raw) => Number(raw)
6593
8059
  },
6594
8060
  {
@@ -6597,7 +8063,7 @@ var init_settings = __esm(() => {
6597
8063
  envVar: "RUNCASTLE_MODEL",
6598
8064
  projectColumn: "model",
6599
8065
  restartRequired: false,
6600
- valueSchema: z9.string().min(1),
8066
+ valueSchema: z10.string().min(1),
6601
8067
  parseEnv: idEnv
6602
8068
  },
6603
8069
  {
@@ -6606,7 +8072,7 @@ var init_settings = __esm(() => {
6606
8072
  envVar: "RUNCASTLE_SANDBOX",
6607
8073
  projectColumn: "sandbox",
6608
8074
  restartRequired: false,
6609
- valueSchema: z9.enum(["docker", "podman", "noSandbox"]),
8075
+ valueSchema: z10.enum(["docker", "podman", "noSandbox"]),
6610
8076
  parseEnv: idEnv
6611
8077
  },
6612
8078
  {
@@ -6614,7 +8080,15 @@ var init_settings = __esm(() => {
6614
8080
  configKey: "sandboxImage",
6615
8081
  envVar: "RUNCASTLE_SANDBOX_IMAGE",
6616
8082
  restartRequired: false,
6617
- valueSchema: z9.string().min(1),
8083
+ valueSchema: z10.string().min(1),
8084
+ parseEnv: idEnv
8085
+ },
8086
+ {
8087
+ key: "sessionMcp",
8088
+ configKey: "sessionMcp",
8089
+ envVar: "RUNCASTLE_SESSION_MCP",
8090
+ restartRequired: false,
8091
+ valueSchema: z10.enum(["inherit", "runcastleOnly"]),
6618
8092
  parseEnv: idEnv
6619
8093
  },
6620
8094
  {
@@ -6622,7 +8096,7 @@ var init_settings = __esm(() => {
6622
8096
  configKey: "burnConcurrency",
6623
8097
  envVar: "RUNCASTLE_BURN_CONCURRENCY",
6624
8098
  restartRequired: false,
6625
- valueSchema: z9.number().int().min(1).max(8),
8099
+ valueSchema: z10.number().int().min(1).max(8),
6626
8100
  parseEnv: (raw) => Number(raw)
6627
8101
  },
6628
8102
  {
@@ -6630,7 +8104,7 @@ var init_settings = __esm(() => {
6630
8104
  configKey: "burnMaxIterations",
6631
8105
  envVar: "RUNCASTLE_BURN_MAX_ITERATIONS",
6632
8106
  restartRequired: false,
6633
- valueSchema: z9.number().int().min(1).max(10),
8107
+ valueSchema: z10.number().int().min(1).max(10),
6634
8108
  parseEnv: (raw) => Number(raw)
6635
8109
  },
6636
8110
  {
@@ -6638,7 +8112,7 @@ var init_settings = __esm(() => {
6638
8112
  configKey: "burnAttempts",
6639
8113
  envVar: "RUNCASTLE_BURN_ATTEMPTS",
6640
8114
  restartRequired: false,
6641
- valueSchema: z9.number().int().min(1).max(5),
8115
+ valueSchema: z10.number().int().min(1).max(5),
6642
8116
  parseEnv: (raw) => Number(raw)
6643
8117
  },
6644
8118
  {
@@ -6646,7 +8120,7 @@ var init_settings = __esm(() => {
6646
8120
  configKey: "burnConflictAttempts",
6647
8121
  envVar: "RUNCASTLE_BURN_CONFLICT_ATTEMPTS",
6648
8122
  restartRequired: false,
6649
- valueSchema: z9.number().int().min(0).max(3),
8123
+ valueSchema: z10.number().int().min(0).max(3),
6650
8124
  parseEnv: (raw) => Number(raw)
6651
8125
  },
6652
8126
  {
@@ -6654,31 +8128,42 @@ var init_settings = __esm(() => {
6654
8128
  configKey: "burnCpus",
6655
8129
  envVar: "RUNCASTLE_BURN_CPUS",
6656
8130
  restartRequired: false,
6657
- valueSchema: z9.number().positive().max(256),
8131
+ valueSchema: z10.number().positive().max(256),
6658
8132
  parseEnv: (raw) => Number(raw)
6659
8133
  },
8134
+ {
8135
+ key: "autoPrepare",
8136
+ configKey: "autoPrepare",
8137
+ envVar: "RUNCASTLE_AUTO_PREPARE",
8138
+ restartRequired: false,
8139
+ valueSchema: z10.boolean(),
8140
+ parseEnv: (raw) => raw !== "0" && raw.toLowerCase() !== "false"
8141
+ },
6660
8142
  {
6661
8143
  key: "setupCommand",
6662
8144
  configKey: "setupCommand",
6663
8145
  envVar: "RUNCASTLE_SETUP_COMMAND",
8146
+ projectColumn: "setupCommand",
6664
8147
  restartRequired: false,
6665
- valueSchema: z9.string().min(1),
8148
+ valueSchema: z10.string().min(1),
6666
8149
  parseEnv: idEnv
6667
8150
  },
6668
8151
  {
6669
8152
  key: "verifyCommands",
6670
8153
  configKey: "verifyCommands",
6671
8154
  envVar: "RUNCASTLE_VERIFY_COMMANDS",
8155
+ projectColumn: "verifyCommands",
6672
8156
  restartRequired: false,
6673
- valueSchema: z9.string().min(1),
8157
+ valueSchema: z10.string().min(1),
6674
8158
  parseEnv: idEnv
6675
8159
  },
6676
8160
  {
6677
8161
  key: "knownFailures",
6678
8162
  configKey: "knownFailures",
6679
8163
  envVar: "RUNCASTLE_KNOWN_FAILURES",
8164
+ projectColumn: "knownFailures",
6680
8165
  restartRequired: false,
6681
- valueSchema: z9.string().min(1),
8166
+ valueSchema: z10.string().min(1),
6682
8167
  parseEnv: idEnv
6683
8168
  },
6684
8169
  {
@@ -6686,14 +8171,42 @@ var init_settings = __esm(() => {
6686
8171
  configKey: "mainBranch",
6687
8172
  envVar: "RUNCASTLE_MAIN_BRANCH",
6688
8173
  restartRequired: false,
6689
- valueSchema: z9.string().min(1),
8174
+ valueSchema: z10.string().min(1),
6690
8175
  parseEnv: idEnv
6691
8176
  },
6692
8177
  {
6693
8178
  key: "devCommand",
6694
8179
  projectColumn: "devCommand",
6695
8180
  restartRequired: false,
6696
- valueSchema: z9.string().min(1),
8181
+ valueSchema: z10.string().min(1),
8182
+ parseEnv: idEnv
8183
+ },
8184
+ {
8185
+ key: "dbResetCommand",
8186
+ projectColumn: "dbResetCommand",
8187
+ restartRequired: false,
8188
+ valueSchema: z10.string().min(1),
8189
+ parseEnv: idEnv
8190
+ },
8191
+ {
8192
+ key: "driveSetupCommand",
8193
+ projectColumn: "driveSetupCommand",
8194
+ restartRequired: false,
8195
+ valueSchema: z10.string().min(1),
8196
+ parseEnv: idEnv
8197
+ },
8198
+ {
8199
+ key: "driveStopCommand",
8200
+ projectColumn: "driveStopCommand",
8201
+ restartRequired: false,
8202
+ valueSchema: z10.string().min(1),
8203
+ parseEnv: idEnv
8204
+ },
8205
+ {
8206
+ key: "driveEnv",
8207
+ projectColumn: "driveEnv",
8208
+ restartRequired: false,
8209
+ valueSchema: z10.string().min(1),
6697
8210
  parseEnv: idEnv
6698
8211
  }
6699
8212
  ];
@@ -6702,14 +8215,14 @@ var init_settings = __esm(() => {
6702
8215
  });
6703
8216
 
6704
8217
  // src/trpc/routers/settings.ts
6705
- import * as z10 from "zod";
8218
+ import * as z11 from "zod";
6706
8219
  var settingsRouter;
6707
8220
  var init_settings2 = __esm(() => {
6708
8221
  init_src();
6709
8222
  init_settings();
6710
8223
  init_context();
6711
8224
  settingsRouter = router({
6712
- get: publicProcedure.input(z10.object({ projectId: z10.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
8225
+ get: publicProcedure.input(z11.object({ projectId: z11.string().optional() }).optional()).query(({ ctx, input }) => getSettings(ctx, input?.projectId)),
6713
8226
  update: publicProcedure.input(SettingsUpdateInput).mutation(({ ctx, input }) => updateSettings(ctx, input))
6714
8227
  });
6715
8228
  });
@@ -6876,13 +8389,13 @@ var init_doctor = __esm(() => {
6876
8389
  });
6877
8390
 
6878
8391
  // src/doctor/system-exec.ts
6879
- import { spawn as spawn2 } from "child_process";
8392
+ import { spawn as spawn3 } from "child_process";
6880
8393
  function createSystemExec(opts = {}) {
6881
- return (command, args) => new Promise((resolve9) => {
8394
+ return (command, args) => new Promise((resolve10) => {
6882
8395
  const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
6883
8396
  let stdout = "";
6884
8397
  let stderr = "";
6885
- const child = spawn2(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
8398
+ const child = spawn3(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
6886
8399
  child.stdout?.on("data", (d) => {
6887
8400
  stdout += d.toString();
6888
8401
  });
@@ -6890,10 +8403,10 @@ function createSystemExec(opts = {}) {
6890
8403
  stderr += d.toString();
6891
8404
  });
6892
8405
  child.on("error", (err) => {
6893
- resolve9({ ok: false, code: null, stdout, stderr: stderr || String(err) });
8406
+ resolve10({ ok: false, code: null, stdout, stderr: stderr || String(err) });
6894
8407
  });
6895
8408
  child.on("close", (code) => {
6896
- resolve9({ ok: true, code: code ?? 0, stdout, stderr });
8409
+ resolve10({ ok: true, code: code ?? 0, stdout, stderr });
6897
8410
  });
6898
8411
  });
6899
8412
  }
@@ -6902,9 +8415,9 @@ var init_system_exec = __esm(() => {
6902
8415
  });
6903
8416
 
6904
8417
  // 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";
8418
+ import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
8419
+ import { dirname as dirname9, join as join17 } from "path";
8420
+ import { fileURLToPath as fileURLToPath7 } from "url";
6908
8421
  async function writeGitIdentity(exec, identity) {
6909
8422
  const name = identity.name.trim();
6910
8423
  const email = identity.email.trim();
@@ -6979,16 +8492,16 @@ async function resolveRuntime(exec, preferred) {
6979
8492
  function resolveSandcastleBin() {
6980
8493
  try {
6981
8494
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
6982
- let dir = dirname8(fileURLToPath6(mainUrl));
8495
+ let dir = dirname9(fileURLToPath7(mainUrl));
6983
8496
  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"));
8497
+ const manifestPath = join17(dir, "package.json");
8498
+ if (existsSync18(manifestPath)) {
8499
+ const manifest = JSON.parse(readFileSync8(manifestPath, "utf8"));
6987
8500
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
6988
- return join16(dir, manifest.bin.sandcastle);
8501
+ return join17(dir, manifest.bin.sandcastle);
6989
8502
  }
6990
8503
  }
6991
- const parent = dirname8(dir);
8504
+ const parent = dirname9(dir);
6992
8505
  if (parent === dir)
6993
8506
  break;
6994
8507
  dir = parent;
@@ -7010,21 +8523,21 @@ function terminalSpec(kind, opts) {
7010
8523
  };
7011
8524
  }
7012
8525
  function sandcastleTemplateDir() {
7013
- return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath6(new URL("../assets/sandcastle", import.meta.url)));
8526
+ return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
7014
8527
  }
7015
8528
  function scaffoldSandcastleConfig(templateDir, targetDir) {
7016
- const dir = join16(targetDir, ".sandcastle");
7017
- if (existsSync17(dir))
8529
+ const dir = join17(targetDir, ".sandcastle");
8530
+ if (existsSync18(dir))
7018
8531
  return { scaffolded: false, dir };
7019
- mkdirSync8(dir, { recursive: true });
8532
+ mkdirSync9(dir, { recursive: true });
7020
8533
  for (const name of readdirSync6(templateDir)) {
7021
- copyFileSync(join16(templateDir, name), join16(dir, name));
8534
+ copyFileSync(join17(templateDir, name), join17(dir, name));
7022
8535
  }
7023
8536
  return { scaffolded: true, dir };
7024
8537
  }
7025
8538
  function prepareSandboxBuildContext() {
7026
8539
  const target = sandboxBuildDir();
7027
- mkdirSync8(target, { recursive: true });
8540
+ mkdirSync9(target, { recursive: true });
7028
8541
  scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
7029
8542
  return target;
7030
8543
  }
@@ -7060,9 +8573,9 @@ function createTokenVerifier(exec) {
7060
8573
  function fileAfkTokenIo(exec) {
7061
8574
  const path = envPath();
7062
8575
  return {
7063
- read: () => existsSync17(path) ? readFileSync7(path, "utf8") : "",
8576
+ read: () => existsSync18(path) ? readFileSync8(path, "utf8") : "",
7064
8577
  write: (content) => {
7065
- mkdirSync8(dirname8(path), { recursive: true });
8578
+ mkdirSync9(dirname9(path), { recursive: true });
7066
8579
  writeFileSync4(path, content, "utf8");
7067
8580
  },
7068
8581
  verify: createTokenVerifier(exec)
@@ -7077,7 +8590,7 @@ var init_setup = __esm(() => {
7077
8590
  });
7078
8591
 
7079
8592
  // src/trpc/routers/setup.ts
7080
- import * as z11 from "zod";
8593
+ import * as z12 from "zod";
7081
8594
  var setupRouter;
7082
8595
  var init_setup2 = __esm(() => {
7083
8596
  init_src();
@@ -7093,9 +8606,9 @@ var init_setup2 = __esm(() => {
7093
8606
  ...ctx.config.sandboxImage ? { imageName: ctx.config.sandboxImage } : {}
7094
8607
  })),
7095
8608
  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 }) => {
8609
+ gitIdentity: publicProcedure.input(z12.object({ name: z12.string(), email: z12.string() })).mutation(({ input }) => writeGitIdentity(createSystemExec(), input)),
8610
+ afkToken: publicProcedure.input(z12.object({ token: z12.string() })).mutation(({ input }) => saveAfkToken(fileAfkTokenIo(createSystemExec()), input.token)),
8611
+ startTerminal: publicProcedure.input(z12.object({ kind: z12.enum(["setup-token", "build-image"]) })).mutation(async ({ ctx, input }) => {
7099
8612
  const exec = createSystemExec();
7100
8613
  const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
7101
8614
  const runtime = await resolveRuntime(exec, preferred);
@@ -7195,16 +8708,16 @@ var init_update_check = __esm(() => {
7195
8708
  });
7196
8709
 
7197
8710
  // 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";
8711
+ import { readFileSync as readFileSync9 } from "fs";
8712
+ import { dirname as dirname10, join as join18 } from "path";
8713
+ import { fileURLToPath as fileURLToPath8 } from "url";
7201
8714
  function runcastleVersion() {
7202
8715
  if (cached2)
7203
8716
  return cached2;
7204
- const here = dirname9(fileURLToPath7(import.meta.url));
7205
- for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
8717
+ const here = dirname10(fileURLToPath8(import.meta.url));
8718
+ for (const pkgPath of [join18(here, "..", "package.json"), join18(here, "package.json")]) {
7206
8719
  try {
7207
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
8720
+ const pkg = JSON.parse(readFileSync9(pkgPath, "utf8"));
7208
8721
  if (typeof pkg.version === "string") {
7209
8722
  cached2 = pkg.version;
7210
8723
  return cached2;
@@ -7230,7 +8743,7 @@ var init_system = __esm(() => {
7230
8743
  });
7231
8744
 
7232
8745
  // src/trpc/routers/ticket.ts
7233
- import * as z12 from "zod";
8746
+ import * as z13 from "zod";
7234
8747
  var ticketRouter;
7235
8748
  var init_ticket = __esm(() => {
7236
8749
  init_features();
@@ -7239,8 +8752,8 @@ var init_ticket = __esm(() => {
7239
8752
  init_ticket_burner();
7240
8753
  init_context();
7241
8754
  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 }) => {
8755
+ retry: publicProcedure.input(z13.object({ ticketId: z13.string(), fresh: z13.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
8756
+ stop: publicProcedure.input(z13.object({ ticketId: z13.string() })).mutation(({ ctx, input }) => {
7244
8757
  if (stopTicketRun(input.ticketId))
7245
8758
  return { stopped: true, swept: false };
7246
8759
  const ticket = getTicket(ctx, input.ticketId);
@@ -7250,7 +8763,7 @@ var init_ticket = __esm(() => {
7250
8763
  }
7251
8764
  return { stopped: false, swept: false };
7252
8765
  }),
7253
- cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
8766
+ cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
7254
8767
  });
7255
8768
  });
7256
8769
 
@@ -7281,18 +8794,18 @@ var init_router = __esm(() => {
7281
8794
  });
7282
8795
 
7283
8796
  // src/workflows/reconcile-runs.ts
7284
- import { eq as eq11 } from "drizzle-orm";
8797
+ import { eq as eq13 } from "drizzle-orm";
7285
8798
  async function reconcileStaleRuns(ctx) {
7286
- const stale = ctx.db.select().from(runs).where(eq11(runs.status, "running")).all().map(rowToRun);
8799
+ const stale = ctx.db.select().from(runs).where(eq13(runs.status, "running")).all().map(rowToRun);
7287
8800
  const reconciled = [];
7288
- for (const run3 of stale) {
7289
- if (isRunActive(run3.id))
8801
+ for (const run4 of stale) {
8802
+ if (isRunActive(run4.id))
7290
8803
  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);
8804
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq13(runs.id, run4.id)).run();
8805
+ const released = releaseForSession(ctx, run4.id);
8806
+ const swept = workflowClaimsFeatureBranch(run4.workflow) ? sweepOrphanedBurning(ctx, run4.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
8807
+ if (workflowClaimsFeatureBranch(run4.workflow)) {
8808
+ const feature = tryGetFeature(ctx, run4.featureId);
7296
8809
  const project = feature ? getProjectById(ctx, feature.projectId) : null;
7297
8810
  if (feature && project) {
7298
8811
  try {
@@ -7300,18 +8813,18 @@ async function reconcileStaleRuns(ctx) {
7300
8813
  } catch {}
7301
8814
  }
7302
8815
  }
7303
- emit(ctx, run3.featureId, {
8816
+ emit(ctx, run4.featureId, {
7304
8817
  type: "run.reconciled",
7305
- message: `run marked failed at boot \u2014 the server restarted while it was running (${run3.workflow})`,
7306
- runId: run3.id,
8818
+ message: `run marked failed at boot \u2014 the server restarted while it was running (${run4.workflow})`,
8819
+ runId: run4.id,
7307
8820
  data: {
7308
- runId: run3.id,
7309
- workflow: run3.workflow,
8821
+ runId: run4.id,
8822
+ workflow: run4.workflow,
7310
8823
  releasedWaypointIds: released.map((w) => w.id),
7311
8824
  sweptTicketSeqs: swept.map((t2) => t2.seq)
7312
8825
  }
7313
8826
  });
7314
- reconciled.push(run3);
8827
+ reconciled.push(run4);
7315
8828
  }
7316
8829
  for (const project of allProjects(ctx)) {
7317
8830
  try {
@@ -7342,7 +8855,7 @@ import { Hono as Hono4 } from "hono";
7342
8855
  function buildApp(ctx) {
7343
8856
  const app = new Hono4;
7344
8857
  setRuntimeCtx(ctx);
7345
- app.get("/health", (c) => c.json({ ok: true }));
8858
+ app.get("/health", (c) => c.json({ ok: true, dataDir: dataDir() }));
7346
8859
  app.use("/api/trpc/*", async (c, next) => {
7347
8860
  await next();
7348
8861
  if (c.res.headers.get("content-type") === "application/json") {
@@ -7374,6 +8887,10 @@ async function startServer() {
7374
8887
  if (staleRuns.length > 0) {
7375
8888
  console.log(`reconciled ${staleRuns.length} stale run(s) from a previous server run`);
7376
8889
  }
8890
+ const stalePreps = reconcilePreps(ctx);
8891
+ if (stalePreps > 0) {
8892
+ console.log(`reconciled ${stalePreps} stale preparation run(s) from a previous server run`);
8893
+ }
7377
8894
  const server = Bun.serve({
7378
8895
  port: config2.serverPort,
7379
8896
  fetch(req, srv) {
@@ -7383,7 +8900,7 @@ async function startServer() {
7383
8900
  },
7384
8901
  websocket: terminalWebSocket
7385
8902
  });
7386
- console.log(`runcastle server listening on http://localhost:${config2.serverPort}`);
8903
+ console.log(`runcastle${process.env.RUNCASTLE_DEV ? " [dev]" : ""} server listening on ` + `http://localhost:${config2.serverPort} \u2014 data dir: ${dataDir()}`);
7387
8904
  const SHUTDOWN_KEY = Symbol.for("runcastle.shutdown.wired");
7388
8905
  const g = globalThis;
7389
8906
  if (!g[SHUTDOWN_KEY]) {
@@ -7403,6 +8920,7 @@ var init_src2 = __esm(() => {
7403
8920
  init_client();
7404
8921
  init_migrate();
7405
8922
  init_reconcile();
8923
+ init_prep();
7406
8924
  init_server();
7407
8925
  init_registry();
7408
8926
  init_ws();