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