runcastle 1.0.25 → 1.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -21,19 +21,38 @@ __export(exports_paths, {
21
21
  worktreeDir: () => worktreeDir,
22
22
  sessionDir: () => sessionDir,
23
23
  sandboxBuildDir: () => sandboxBuildDir,
24
+ sameDataDir: () => sameDataDir,
25
+ projectWorktreesDir: () => projectWorktreesDir,
26
+ prodDataDir: () => prodDataDir,
24
27
  logsDir: () => logsDir,
25
28
  featureDocsRel: () => featureDocsRel,
26
29
  envPath: () => envPath,
30
+ devDataDir: () => devDataDir,
27
31
  dbPath: () => dbPath,
28
32
  dataDir: () => dataDir,
29
33
  configPath: () => configPath,
30
34
  burnCacheDir: () => burnCacheDir
31
35
  });
32
36
  import { homedir } from "os";
33
- import { join } from "path";
34
- function dataDir() {
37
+ import { join, resolve } from "path";
38
+ function prodDataDir() {
35
39
  return join(homedir(), ".runcastle");
36
40
  }
41
+ function devDataDir() {
42
+ const override = process.env.RUNCASTLE_DEV_DATA_DIR;
43
+ return override ? resolve(override) : join(homedir(), ".runcastle-dev");
44
+ }
45
+ function dataDir() {
46
+ const override = process.env.RUNCASTLE_DATA_DIR;
47
+ return override ? resolve(override) : prodDataDir();
48
+ }
49
+ function sameDataDir(a, b) {
50
+ const canon = (p) => {
51
+ const abs = resolve(p).replace(/[\\/]+$/, "");
52
+ return process.platform === "win32" ? abs.toLowerCase().replace(/\//g, "\\") : abs;
53
+ };
54
+ return canon(a) === canon(b);
55
+ }
37
56
  function dbPath() {
38
57
  return join(dataDir(), "runcastle.db");
39
58
  }
@@ -49,8 +68,11 @@ function logsDir() {
49
68
  function sessionDir(id) {
50
69
  return join(dataDir(), "sessions", id);
51
70
  }
71
+ function projectWorktreesDir(projectId) {
72
+ return join(dataDir(), "worktrees", projectId);
73
+ }
52
74
  function worktreeDir(projectId, slug) {
53
- return join(dataDir(), "worktrees", projectId, slug);
75
+ return join(projectWorktreesDir(projectId), slug);
54
76
  }
55
77
  function burnCacheDir(pm) {
56
78
  return join(dataDir(), "cache", pm);
@@ -116,6 +138,7 @@ var init_config = __esm(() => {
116
138
  model: z.string().default("claude-opus-5"),
117
139
  stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
118
140
  sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
141
+ sessionMcp: z.enum(["inherit", "runcastleOnly"]).default("inherit"),
119
142
  mainBranch: z.string().default("main"),
120
143
  sandboxImage: z.string().optional(),
121
144
  burnConcurrency: z.number().int().min(1).max(8).default(3),
@@ -157,6 +180,8 @@ function loadConfig(env = process.env) {
157
180
  overrides.smokeModel = env.RUNCASTLE_SMOKE_MODEL;
158
181
  if (env.RUNCASTLE_SANDBOX)
159
182
  overrides.sandbox = env.RUNCASTLE_SANDBOX;
183
+ if (env.RUNCASTLE_SESSION_MCP)
184
+ overrides.sessionMcp = env.RUNCASTLE_SESSION_MCP;
160
185
  if (env.RUNCASTLE_MAIN_BRANCH)
161
186
  overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
162
187
  if (env.RUNCASTLE_SANDBOX_IMAGE)
@@ -229,7 +254,7 @@ var init_schemas = __esm(() => {
229
254
  "shipped"
230
255
  ]);
231
256
  TicketStatus = z2.enum(["pending", "burning", "done", "failed", "cancelled"]);
232
- SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit"]);
257
+ SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit", "prepare"]);
233
258
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
234
259
  FeatureStatus = z2.enum(["active", "shipped", "archived"]);
235
260
  SessionStatus = z2.enum(["launching", "live", "ended"]);
@@ -246,6 +271,7 @@ var init_schemas = __esm(() => {
246
271
  featureId: z2.string(),
247
272
  seq: z2.number(),
248
273
  status: TicketStatus,
274
+ lap: z2.number(),
249
275
  commits: z2.array(z2.string()),
250
276
  error: z2.string().optional(),
251
277
  attemptBranch: z2.string().optional(),
@@ -276,10 +302,13 @@ var init_schemas = __esm(() => {
276
302
  "verifyCommands",
277
303
  "knownFailures",
278
304
  "devCommand",
305
+ "driveSetupCommand",
306
+ "driveStopCommand",
307
+ "driveEnv",
279
308
  "dbResetCommand"
280
309
  ];
281
310
  PreparedKey = z2.enum(PREPARED_KEYS);
282
- FindingSource = z2.enum(["prep", "human"]);
311
+ FindingSource = z2.enum(["prep", "human", "session"]);
283
312
  Project = z2.object({
284
313
  id: z2.string(),
285
314
  name: z2.string(),
@@ -290,7 +319,10 @@ var init_schemas = __esm(() => {
290
319
  setupCommand: z2.string().optional(),
291
320
  verifyCommands: z2.string().optional(),
292
321
  knownFailures: z2.string().optional(),
293
- dbResetCommand: z2.string().optional()
322
+ dbResetCommand: z2.string().optional(),
323
+ driveSetupCommand: z2.string().optional(),
324
+ driveStopCommand: z2.string().optional(),
325
+ driveEnv: z2.string().optional()
294
326
  });
295
327
  ProjectFinding = z2.object({
296
328
  key: PreparedKey,
@@ -317,6 +349,7 @@ var init_schemas = __esm(() => {
317
349
  title: z2.string(),
318
350
  oneLiner: z2.string(),
319
351
  mapped: z2.boolean(),
352
+ lap: z2.number(),
320
353
  phase: Phase,
321
354
  branch: z2.string(),
322
355
  baseBranch: z2.string().optional(),
@@ -325,7 +358,8 @@ var init_schemas = __esm(() => {
325
358
  });
326
359
  SessionRow = z2.object({
327
360
  id: z2.string(),
328
- featureId: z2.string(),
361
+ featureId: z2.string().optional(),
362
+ projectId: z2.string().optional(),
329
363
  kind: SessionKind,
330
364
  ccSessionId: z2.string().optional(),
331
365
  transcriptPath: z2.string().optional(),
@@ -452,7 +486,7 @@ function nextGate(feature) {
452
486
  return MAPPED_G1;
453
487
  return gate;
454
488
  }
455
- var PIPELINE, ORDER, REVIEW_LOOP_BACK, MAPPED_G1;
489
+ var PIPELINE, ORDER, REVIEW_LOOP_BACK, RETHINK_LOOP_BACK, MAPPED_G1;
456
490
  var init_pipeline = __esm(() => {
457
491
  PIPELINE = [
458
492
  { phase: "ideation" },
@@ -499,6 +533,7 @@ var init_pipeline = __esm(() => {
499
533
  ];
500
534
  ORDER = PIPELINE.map((p) => p.phase);
501
535
  REVIEW_LOOP_BACK = { from: "review", to: "implementation" };
536
+ RETHINK_LOOP_BACK = { from: "review", to: "ideation" };
502
537
  MAPPED_G1 = {
503
538
  id: "G1",
504
539
  description: "Every waypoint resolved or dropped before converging",
@@ -522,7 +557,10 @@ var init_db_schema = __esm(() => {
522
557
  setupCommand: text("setup_command"),
523
558
  verifyCommands: text("verify_commands"),
524
559
  knownFailures: text("known_failures"),
525
- dbResetCommand: text("db_reset_command")
560
+ dbResetCommand: text("db_reset_command"),
561
+ driveSetupCommand: text("drive_setup_command"),
562
+ driveStopCommand: text("drive_stop_command"),
563
+ driveEnv: text("drive_env")
526
564
  });
527
565
  projectFindings = sqliteTable("project_findings", {
528
566
  projectId: text("project_id").notNull(),
@@ -548,6 +586,7 @@ var init_db_schema = __esm(() => {
548
586
  title: text("title").notNull(),
549
587
  oneLiner: text("one_liner").notNull(),
550
588
  mapped: integer("mapped", { mode: "boolean" }).notNull().default(false),
589
+ lap: integer("lap").notNull().default(1),
551
590
  phase: text("phase").notNull().$type(),
552
591
  branch: text("branch").notNull(),
553
592
  baseBranch: text("base_branch"),
@@ -556,7 +595,9 @@ var init_db_schema = __esm(() => {
556
595
  });
557
596
  sessions = sqliteTable("sessions", {
558
597
  id: text("id").primaryKey(),
559
- featureId: text("feature_id").notNull(),
598
+ featureId: text("feature_id"),
599
+ projectId: text("project_id"),
600
+ lap: integer("lap").notNull().default(1),
560
601
  kind: text("kind").notNull().$type(),
561
602
  ccSessionId: text("cc_session_id"),
562
603
  transcriptPath: text("transcript_path"),
@@ -573,6 +614,7 @@ var init_db_schema = __esm(() => {
573
614
  acceptanceCriteria: text("acceptance_criteria", { mode: "json" }).notNull().$type(),
574
615
  seams: text("seams", { mode: "json" }).notNull().$type(),
575
616
  blockedBy: text("blocked_by", { mode: "json" }).notNull().$type(),
617
+ lap: integer("lap").notNull().default(1),
576
618
  status: text("status").notNull().$type(),
577
619
  commits: text("commits", { mode: "json" }).notNull().$type(),
578
620
  error: text("error"),
@@ -608,6 +650,7 @@ var init_db_schema = __esm(() => {
608
650
  featureId: text("feature_id"),
609
651
  runId: text("run_id"),
610
652
  ticketId: text("ticket_id"),
653
+ lap: integer("lap").notNull().default(1),
611
654
  ts: integer("ts").notNull(),
612
655
  type: text("type").notNull(),
613
656
  message: text("message").notNull(),
@@ -668,11 +711,11 @@ var init_client = __esm(() => {
668
711
 
669
712
  // src/launcher/asset-paths.ts
670
713
  import { existsSync as existsSync2 } from "fs";
671
- import { join as join3, resolve } from "path";
714
+ import { join as join3, resolve as resolve2 } from "path";
672
715
  function resolveAsset(envVar, fallback) {
673
716
  const override = process.env[envVar];
674
717
  if (override) {
675
- const abs = resolve(override);
718
+ const abs = resolve2(override);
676
719
  if (existsSync2(abs))
677
720
  return abs;
678
721
  throw new Error(`${envVar}=${override} does not exist (resolved ${abs})`);
@@ -1346,16 +1389,28 @@ function projectIdForFeature(ctx, featureId) {
1346
1389
  throw new NotFoundError(`feature ${featureId} not found`);
1347
1390
  return row.projectId;
1348
1391
  }
1392
+ function lapForFeature(ctx, featureId) {
1393
+ const row = ctx.db.select({ lap: features.lap }).from(features).where(eq(features.id, featureId)).get();
1394
+ return row?.lap ?? 1;
1395
+ }
1349
1396
  function emit(ctx, featureId, e) {
1350
1397
  return insertEvent(ctx, projectIdForFeature(ctx, featureId), featureId, e);
1351
1398
  }
1352
1399
  function emitProject(ctx, projectId, e) {
1353
1400
  return insertEvent(ctx, projectId, null, e);
1354
1401
  }
1402
+ function emitForSession(ctx, session, e) {
1403
+ if (session.featureId)
1404
+ return emit(ctx, session.featureId, e);
1405
+ if (session.projectId)
1406
+ return emitProject(ctx, session.projectId, e);
1407
+ return null;
1408
+ }
1355
1409
  function insertEvent(ctx, projectId, featureId, e) {
1356
1410
  const row = ctx.db.insert(events).values({
1357
1411
  projectId,
1358
1412
  featureId,
1413
+ lap: featureId ? lapForFeature(ctx, featureId) : 1,
1359
1414
  runId: e.runId ?? null,
1360
1415
  ticketId: e.ticketId ?? null,
1361
1416
  ts: Date.now(),
@@ -1396,6 +1451,7 @@ function rowToFeature(row) {
1396
1451
  title: row.title,
1397
1452
  oneLiner: row.oneLiner,
1398
1453
  mapped: row.mapped,
1454
+ lap: row.lap,
1399
1455
  phase: row.phase,
1400
1456
  branch: row.branch,
1401
1457
  baseBranch: row.baseBranch ?? undefined,
@@ -1414,7 +1470,10 @@ function rowToProject(row) {
1414
1470
  setupCommand: row.setupCommand ?? undefined,
1415
1471
  verifyCommands: row.verifyCommands ?? undefined,
1416
1472
  knownFailures: row.knownFailures ?? undefined,
1417
- dbResetCommand: row.dbResetCommand ?? undefined
1473
+ dbResetCommand: row.dbResetCommand ?? undefined,
1474
+ driveSetupCommand: row.driveSetupCommand ?? undefined,
1475
+ driveStopCommand: row.driveStopCommand ?? undefined,
1476
+ driveEnv: row.driveEnv ?? undefined
1418
1477
  };
1419
1478
  }
1420
1479
  function rowToRun(row) {
@@ -1431,7 +1490,8 @@ function rowToRun(row) {
1431
1490
  function rowToSession(row) {
1432
1491
  return {
1433
1492
  id: row.id,
1434
- featureId: row.featureId,
1493
+ featureId: row.featureId ?? undefined,
1494
+ projectId: row.projectId ?? undefined,
1435
1495
  kind: row.kind,
1436
1496
  ccSessionId: row.ccSessionId ?? undefined,
1437
1497
  transcriptPath: row.transcriptPath ?? undefined,
@@ -1648,7 +1708,7 @@ function releaseForSession(ctx, claimant) {
1648
1708
  const held = ctx.db.select().from(waypoints).where(and3(eq3(waypoints.claimedBy, claimant), eq3(waypoints.status, "claimed"))).all().map(rowToWaypoint);
1649
1709
  return held.map((w) => release(ctx, w.id));
1650
1710
  }
1651
- function resolve2(ctx, id, disposition, summary) {
1711
+ function resolve3(ctx, id, disposition, summary) {
1652
1712
  const wp = getWaypoint(ctx, id);
1653
1713
  const before = listByFeature(ctx, wp.featureId);
1654
1714
  const bySeqBefore = new Map(before.map((w) => [w.seq, w]));
@@ -1691,7 +1751,9 @@ import { and as and4, desc as desc2, eq as eq4, inArray, isNotNull, sql as sql2
1691
1751
  function createSessionRow(ctx, input) {
1692
1752
  const row = ctx.db.insert(sessions).values({
1693
1753
  id: newId("sess"),
1694
- featureId: input.featureId,
1754
+ featureId: input.featureId ?? null,
1755
+ projectId: input.projectId ?? null,
1756
+ lap: input.featureId ? getFeatureRow(ctx, input.featureId).lap : 1,
1695
1757
  kind: input.kind,
1696
1758
  ccSessionId: null,
1697
1759
  transcriptPath: null,
@@ -1723,6 +1785,9 @@ function markSessionLive(ctx, id, input = {}) {
1723
1785
  }
1724
1786
  return getSessionRow(ctx, id);
1725
1787
  }
1788
+ function lapKickoff(lap) {
1789
+ return `Proceed with your task: invoke the /runcastle:revisit skill for LAP ${lap} REVIEW ITERATION. ` + `Call get_feature_context, then read this feature's test-notes.md (the "## Lap ${lap - 1}" ` + 'section \u2014 what the last drive surfaced) and the "## Later laps" section of its spec.md. ' + "EITHER MAY NOT EXIST YET; that is normal, not an error \u2014 say so and carry on from what I " + "tell you. Interview me about what the test drive taught: what was wrong, what was missing, " + "what I want next. Write what we settle on into decisions.md and amend spec.md for this lap " + '(pruning anything you promote out of "## Later laps"), then call emit_tickets for this ' + `lap's work. Finish in THIS session: complete_phase through ideation \u2192 spec \u2192 tickets, then ` + "tell me to review the cards and click Burn.";
1790
+ }
1726
1791
  function kickoffLineFor(kind, override) {
1727
1792
  return override ?? KICKOFF_LINES[kind];
1728
1793
  }
@@ -1788,7 +1853,7 @@ function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
1788
1853
  writeKickoffSequence(d.line, {
1789
1854
  write: io.write,
1790
1855
  alive: io.alive,
1791
- onSubmitted: () => emit(ctx, session.featureId, {
1856
+ onSubmitted: () => emitForSession(ctx, session, {
1792
1857
  type: "session.kickoff",
1793
1858
  message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
1794
1859
  data: { sessionId: session.id, kind: session.kind, attempt }
@@ -1812,7 +1877,7 @@ function armConfirmation(ctx, session, d) {
1812
1877
  function settleUndelivered(ctx, session, d, reason) {
1813
1878
  d.settled = true;
1814
1879
  stopTimers(d);
1815
- emit(ctx, session.featureId, {
1880
+ emitForSession(ctx, session, {
1816
1881
  type: "session.kickoff_undelivered",
1817
1882
  message: reason === "superseded" ? `the ${session.kind} briefing was never delivered \u2014 you typed first, so runcastle stopped injecting it` : `the ${session.kind} briefing was typed ${d.attempts}\xD7 but Claude Code never acknowledged it \u2014 send it again from the session strip`,
1818
1883
  data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
@@ -1876,7 +1941,7 @@ function armSessionReadyWatchdog(ctx, session) {
1876
1941
  return;
1877
1942
  if (!ptyIo(session.id).alive())
1878
1943
  return;
1879
- emit(ctx, session.featureId, {
1944
+ emitForSession(ctx, session, {
1880
1945
  type: "session.not_ready",
1881
1946
  message: `the ${session.kind} terminal is open but Claude Code has not reported ready \u2014 check it for a prompt or dialog waiting on you, then send the briefing`,
1882
1947
  data: { sessionId: session.id, kind: session.kind }
@@ -1906,6 +1971,14 @@ function mostRecentResumableSession(ctx, featureId, kind) {
1906
1971
  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();
1907
1972
  return row ? rowToSession(row) : null;
1908
1973
  }
1974
+ function activeProjectSession(ctx, projectId, kind) {
1975
+ 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();
1976
+ return row ? rowToSession(row) : null;
1977
+ }
1978
+ function mostRecentResumableProjectSession(ctx, projectId, kind) {
1979
+ 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();
1980
+ return row ? rowToSession(row) : null;
1981
+ }
1909
1982
  function mostRecentLiveSession(ctx) {
1910
1983
  const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1911
1984
  return row ? rowToSession(row) : null;
@@ -1924,7 +1997,8 @@ var init_sessions = __esm(() => {
1924
1997
  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.",
1925
1998
  waypoint: "Proceed with your task: invoke the /runcastle:waypoint skill and work your assigned " + "waypoint to a resolution.",
1926
1999
  converge: CONVERGE_KICKOFF_LINE,
1927
- revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up."
2000
+ revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up.",
2001
+ 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."
1928
2002
  };
1929
2003
  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: ";
1930
2004
  pendingKickoffOverrides = new Map;
@@ -1942,7 +2016,7 @@ function reconcileStaleSessions(ctx) {
1942
2016
  continue;
1943
2017
  markSessionEnded(ctx, session.id);
1944
2018
  const released = releaseForSession(ctx, session.id);
1945
- emit(ctx, session.featureId, {
2019
+ emitForSession(ctx, session, {
1946
2020
  type: "session.reconciled",
1947
2021
  message: `session marked ended at boot \u2014 the server restarted while it was ${session.status}`,
1948
2022
  data: {
@@ -1966,11 +2040,11 @@ var init_reconcile = __esm(() => {
1966
2040
 
1967
2041
  // src/launcher/skills-root.ts
1968
2042
  import { existsSync as existsSync5 } from "fs";
1969
- import { dirname, join as join5, resolve as resolve3 } from "path";
2043
+ import { dirname, join as join5, resolve as resolve4 } from "path";
1970
2044
  function resolveSkillsRoot(fromDir) {
1971
2045
  const override = process.env[SKILLS_DIR_ENV];
1972
2046
  if (override) {
1973
- const root = resolve3(override);
2047
+ const root = resolve4(override);
1974
2048
  if (existsSync5(join5(root, SKILLS_MARKER)))
1975
2049
  return root;
1976
2050
  throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
@@ -2011,7 +2085,7 @@ function devSpawnTarget(devCommand) {
2011
2085
  return { file: "/bin/sh", args: ["-c", devCommand] };
2012
2086
  }
2013
2087
  function startDevPane(input) {
2014
- const { ctx, featureId, repoPath, devCommand, onUrl } = input;
2088
+ const { ctx, featureId, repoPath, devCommand, env, onUrl } = input;
2015
2089
  const paneId = drivePaneId(featureId);
2016
2090
  const { file, args } = devSpawnTarget(devCommand);
2017
2091
  try {
@@ -2019,7 +2093,7 @@ function startDevPane(input) {
2019
2093
  sessionId: paneId,
2020
2094
  cmd: file,
2021
2095
  args,
2022
- opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
2096
+ opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
2023
2097
  });
2024
2098
  } catch (err) {
2025
2099
  emit(ctx, featureId, {
@@ -2069,10 +2143,179 @@ var init_dev_pane = __esm(() => {
2069
2143
  init_registry();
2070
2144
  });
2071
2145
 
2146
+ // src/services/drive-hooks.ts
2147
+ import { spawn as spawn2 } from "child_process";
2148
+ function tailLines(text2, max = OUTPUT_TAIL_LINES) {
2149
+ const lines = text2.replace(/\r\n/g, `
2150
+ `).split(`
2151
+ `);
2152
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
2153
+ lines.pop();
2154
+ return lines.slice(-max).join(`
2155
+ `).trim();
2156
+ }
2157
+ function hookSpawnTarget(command) {
2158
+ if (process.platform === "win32") {
2159
+ return {
2160
+ file: process.env.ComSpec ?? "cmd.exe",
2161
+ args: ["/d", "/s", "/c", `"${command}"`],
2162
+ verbatim: true
2163
+ };
2164
+ }
2165
+ return { file: "/bin/sh", args: ["-c", command], verbatim: false };
2166
+ }
2167
+ function runDriveHook(command, opts) {
2168
+ const spawnFn = opts.spawnFn ?? spawn2;
2169
+ const now = opts.now ?? Date.now;
2170
+ const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
2171
+ const startedAt = now();
2172
+ const { file, args, verbatim } = hookSpawnTarget(command);
2173
+ return new Promise((resolve5) => {
2174
+ let output = "";
2175
+ let timedOut = false;
2176
+ let settled = false;
2177
+ let timer;
2178
+ const finish = (exitCode) => {
2179
+ if (settled)
2180
+ return;
2181
+ settled = true;
2182
+ if (timer)
2183
+ clearTimeout(timer);
2184
+ resolve5({
2185
+ ok: !timedOut && exitCode === 0,
2186
+ exitCode,
2187
+ timedOut,
2188
+ output: tailLines(output),
2189
+ durationMs: now() - startedAt
2190
+ });
2191
+ };
2192
+ let child;
2193
+ try {
2194
+ child = spawnFn(file, args, {
2195
+ cwd: opts.cwd,
2196
+ env: opts.env ?? process.env,
2197
+ windowsHide: true,
2198
+ windowsVerbatimArguments: verbatim
2199
+ });
2200
+ } catch (err) {
2201
+ output = err instanceof Error ? err.message : String(err);
2202
+ finish(null);
2203
+ return;
2204
+ }
2205
+ timer = setTimeout(() => {
2206
+ timedOut = true;
2207
+ child.kill();
2208
+ const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
2209
+ grace.unref?.();
2210
+ }, timeoutMs);
2211
+ timer.unref?.();
2212
+ child.stdout?.on("data", (d) => {
2213
+ output += d.toString();
2214
+ });
2215
+ child.stderr?.on("data", (d) => {
2216
+ output += d.toString();
2217
+ });
2218
+ child.on("error", (err) => {
2219
+ output += `
2220
+ ${err instanceof Error ? err.message : String(err)}`;
2221
+ finish(null);
2222
+ });
2223
+ child.on("close", (code) => {
2224
+ finish(code);
2225
+ });
2226
+ });
2227
+ }
2228
+ function describeHookResult(command, result) {
2229
+ const secs = Math.max(1, Math.round(result.durationMs / 1000));
2230
+ if (result.timedOut)
2231
+ return `\`${command}\` timed out after ${secs}s`;
2232
+ if (result.ok)
2233
+ return `\`${command}\` finished in ${secs}s`;
2234
+ return `\`${command}\` exited ${result.exitCode ?? "without a code"} after ${secs}s`;
2235
+ }
2236
+ var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
2237
+ var init_drive_hooks = __esm(() => {
2238
+ DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
2239
+ });
2240
+
2241
+ // src/services/drive-env.ts
2242
+ import { createHash } from "crypto";
2243
+ function identifierSafe(slug, max = MAX_ID_LENGTH) {
2244
+ const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2245
+ const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
2246
+ if (safe.length <= max)
2247
+ return safe;
2248
+ const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
2249
+ return `${safe.slice(0, max - digest.length - 1)}_${digest}`;
2250
+ }
2251
+ function driveVars(identity) {
2252
+ return {
2253
+ slug: identity.slug,
2254
+ branch: identity.branch,
2255
+ id: identifierSafe(identity.slug)
2256
+ };
2257
+ }
2258
+ function renderTemplate(value, vars) {
2259
+ const unknown2 = [];
2260
+ const out = value.replace(PLACEHOLDER_RE, (match, name) => {
2261
+ const replacement = vars[name];
2262
+ if (replacement === undefined) {
2263
+ if (!unknown2.includes(name))
2264
+ unknown2.push(name);
2265
+ return match;
2266
+ }
2267
+ return replacement;
2268
+ });
2269
+ return { value: out, unknown: unknown2 };
2270
+ }
2271
+ function parseDriveEnv(raw, identity) {
2272
+ const vars = {};
2273
+ const unknown2 = [];
2274
+ if (!raw?.trim())
2275
+ return { vars, unknown: unknown2 };
2276
+ const substitutions = driveVars(identity);
2277
+ for (const line of raw.split(/\r?\n/)) {
2278
+ const trimmed = line.trim();
2279
+ if (trimmed === "" || trimmed.startsWith("#"))
2280
+ continue;
2281
+ const eq5 = trimmed.indexOf("=");
2282
+ if (eq5 <= 0)
2283
+ continue;
2284
+ const key = trimmed.slice(0, eq5).trim().replace(/^export\s+/, "");
2285
+ if (key === "")
2286
+ continue;
2287
+ const rendered = renderTemplate(unquote(trimmed.slice(eq5 + 1).trim()), substitutions);
2288
+ vars[key] = rendered.value;
2289
+ for (const name of rendered.unknown)
2290
+ if (!unknown2.includes(name))
2291
+ unknown2.push(name);
2292
+ }
2293
+ return { vars, unknown: unknown2 };
2294
+ }
2295
+ function unquote(value) {
2296
+ if (value.length < 2)
2297
+ return value;
2298
+ const first = value[0];
2299
+ if ((first === '"' || first === "'") && value.endsWith(first))
2300
+ return value.slice(1, -1);
2301
+ return value;
2302
+ }
2303
+ function driveProcessEnv(overrides, base = process.env) {
2304
+ return { ...base, ...overrides };
2305
+ }
2306
+ function describeDriveEnv(vars) {
2307
+ const keys = Object.keys(vars);
2308
+ return `drive environment: ${keys.join(", ")}`;
2309
+ }
2310
+ var MAX_ID_LENGTH = 40, PLACEHOLDER_RE;
2311
+ var init_drive_env = __esm(() => {
2312
+ PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
2313
+ });
2314
+
2072
2315
  // src/services/git.ts
2073
2316
  import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2074
2317
  import { tmpdir } from "os";
2075
- import { dirname as dirname2, join as join6, resolve as resolve4 } from "path";
2318
+ import { dirname as dirname2, join as join6, resolve as resolve5 } from "path";
2076
2319
  import { simpleGit } from "simple-git";
2077
2320
  function migrationPaths(paths) {
2078
2321
  return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
@@ -2087,7 +2330,7 @@ function errMsg(e) {
2087
2330
  return e instanceof Error ? e.message : String(e);
2088
2331
  }
2089
2332
  function canon(p) {
2090
- let abs = resolve4(p);
2333
+ let abs = resolve5(p);
2091
2334
  try {
2092
2335
  abs = realpathSync.native(abs);
2093
2336
  } catch {}
@@ -2582,7 +2825,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2582
2825
  return { deleted, kept };
2583
2826
  }
2584
2827
  async function commitDocs(worktreePath, message) {
2585
- if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
2828
+ if (!existsSync6(resolve5(worktreePath, DOCS_PATHSPEC)))
2586
2829
  return;
2587
2830
  const g = git(worktreePath);
2588
2831
  await g.add([DOCS_PATHSPEC]);
@@ -2627,6 +2870,7 @@ async function testDrive(ctx, project, feature, action) {
2627
2870
  const devPaneId = testDriveState.devPaneId;
2628
2871
  if (devPaneId)
2629
2872
  stopDevPane(devPaneId);
2873
+ const teardownFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "teardown", project.driveStopCommand, driveEnvFor(ctx, project, feature, branch));
2630
2874
  const carriedChanges = await dirtyPaths(g);
2631
2875
  await g.checkout(previousBranch2);
2632
2876
  if (detachedWorktree2)
@@ -2649,7 +2893,8 @@ async function testDrive(ctx, project, feature, action) {
2649
2893
  ok: true,
2650
2894
  branch: previousBranch2,
2651
2895
  ...carriedChanges.length > 0 ? { carriedChanges } : {},
2652
- ...dbDrift ? { dbDrift } : {}
2896
+ ...dbDrift ? { dbDrift } : {},
2897
+ ...teardownFailure ? { hookFailure: teardownFailure } : {}
2653
2898
  };
2654
2899
  }
2655
2900
  const porcelain = (await g.raw(["status", "--porcelain"])).trim();
@@ -2674,18 +2919,76 @@ async function testDrive(ctx, project, feature, action) {
2674
2919
  message: `test driving ${branch} (was on ${previousBranch})`,
2675
2920
  data: { branch, previousBranch }
2676
2921
  });
2922
+ const driveEnv = driveEnvFor(ctx, project, feature, branch);
2923
+ const setupFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "setup", project.driveSetupCommand, driveEnv);
2677
2924
  if (project.devCommand) {
2678
2925
  const devPaneId = startDevPane({
2679
2926
  ctx,
2680
2927
  featureId: feature.id,
2681
2928
  repoPath: project.repoPath,
2682
2929
  devCommand: project.devCommand,
2930
+ env: driveEnv,
2683
2931
  onUrl: (url) => recordDriveUrl(ctx, feature.id, url)
2684
2932
  });
2685
2933
  if (devPaneId)
2686
2934
  testDriveState.devPaneId = devPaneId;
2687
2935
  }
2688
- return { ok: true, branch };
2936
+ return { ok: true, branch, ...setupFailure ? { hookFailure: setupFailure } : {} };
2937
+ }
2938
+ function driveEnvFor(ctx, project, feature, branch) {
2939
+ const { vars, unknown: unknown2 } = parseDriveEnv(project.driveEnv, { slug: feature.slug, branch });
2940
+ if (unknown2.length > 0) {
2941
+ emit(ctx, feature.id, {
2942
+ type: "testdrive.env_unknown_placeholder",
2943
+ message: `drive environment left ${unknown2.map((u) => `{{${u}}}`).join(", ")} unsubstituted \u2014 known variables are {{slug}}, {{branch}}, {{id}}`,
2944
+ data: { unknown: unknown2 }
2945
+ });
2946
+ }
2947
+ if (Object.keys(vars).length > 0) {
2948
+ emit(ctx, feature.id, {
2949
+ type: "testdrive.env",
2950
+ message: describeDriveEnv(vars),
2951
+ data: { keys: Object.keys(vars) }
2952
+ });
2953
+ }
2954
+ return driveProcessEnv(vars);
2955
+ }
2956
+ async function runDriveHookStep(ctx, featureId, repoPath, phase, command, env) {
2957
+ const cmd = command?.trim();
2958
+ if (!cmd)
2959
+ return;
2960
+ emit(ctx, featureId, {
2961
+ type: `testdrive.${phase}_started`,
2962
+ message: phase === "setup" ? `preparing environment: \`${cmd}\`` : `tearing down environment: \`${cmd}\``,
2963
+ data: { command: cmd }
2964
+ });
2965
+ const result = await runDriveHook(cmd, { cwd: repoPath, env });
2966
+ if (result.ok) {
2967
+ emit(ctx, featureId, {
2968
+ type: `testdrive.${phase}_ok`,
2969
+ message: describeHookResult(cmd, result),
2970
+ data: { command: cmd, durationMs: result.durationMs }
2971
+ });
2972
+ return;
2973
+ }
2974
+ emit(ctx, featureId, {
2975
+ type: `testdrive.${phase}_failed`,
2976
+ message: `${describeHookResult(cmd, result)}${result.output ? `
2977
+ ${result.output}` : ""}`,
2978
+ data: {
2979
+ command: cmd,
2980
+ exitCode: result.exitCode,
2981
+ timedOut: result.timedOut,
2982
+ output: result.output
2983
+ }
2984
+ });
2985
+ return {
2986
+ phase,
2987
+ command: cmd,
2988
+ exitCode: result.exitCode,
2989
+ timedOut: result.timedOut,
2990
+ output: result.output
2991
+ };
2689
2992
  }
2690
2993
  async function dirtyPaths(g) {
2691
2994
  try {
@@ -2776,6 +3079,8 @@ var init_git = __esm(() => {
2776
3079
  init_paths();
2777
3080
  init_errors();
2778
3081
  init_dev_pane();
3082
+ init_drive_hooks();
3083
+ init_drive_env();
2779
3084
  init_events();
2780
3085
  init_repo();
2781
3086
  MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
@@ -2961,7 +3266,7 @@ function detectCycle(tickets2) {
2961
3266
  }
2962
3267
  return null;
2963
3268
  }
2964
- function renderTemplate(template, values) {
3269
+ function renderTemplate2(template, values) {
2965
3270
  let out = template;
2966
3271
  for (const [key, value] of Object.entries(values)) {
2967
3272
  out = out.split(`{{${key}}}`).join(value);
@@ -2969,7 +3274,7 @@ function renderTemplate(template, values) {
2969
3274
  return out;
2970
3275
  }
2971
3276
  function renderTicketPrompt(template, values) {
2972
- return renderTemplate(template, values);
3277
+ return renderTemplate2(template, values);
2973
3278
  }
2974
3279
  function buildTicketJson(ticket) {
2975
3280
  return JSON.stringify({
@@ -3057,6 +3362,9 @@ function detectPackageManager(tc) {
3057
3362
  return "npm";
3058
3363
  return tc.hasPackageJson ? "npm" : undefined;
3059
3364
  }
3365
+ function orFallback(strict, permissive) {
3366
+ return `( ${strict} || ${permissive} )`;
3367
+ }
3060
3368
  function resolveSetupCommand(tc, override) {
3061
3369
  const trimmed = override?.trim();
3062
3370
  if (trimmed)
@@ -3066,13 +3374,13 @@ function resolveSetupCommand(tc, override) {
3066
3374
  return;
3067
3375
  switch (pm) {
3068
3376
  case "bun":
3069
- return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
3377
+ return tc.lockfiles.bun ? orFallback("bun install --frozen-lockfile", "bun install") : "bun install";
3070
3378
  case "pnpm":
3071
- return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
3379
+ return tc.lockfiles.pnpm ? orFallback("corepack pnpm install --frozen-lockfile", "corepack pnpm install") : "corepack pnpm install";
3072
3380
  case "yarn":
3073
- return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
3381
+ return tc.lockfiles.yarn ? orFallback("corepack yarn install --frozen-lockfile", "corepack yarn install") : "corepack yarn install";
3074
3382
  case "npm":
3075
- return tc.lockfiles.npm ? "npm ci" : "npm install";
3383
+ return tc.lockfiles.npm ? orFallback("npm ci", "npm install") : "npm install";
3076
3384
  }
3077
3385
  }
3078
3386
  function cacheMountFor(pm, hostPath) {
@@ -3156,7 +3464,7 @@ function readRepoToolchain(repoPath) {
3156
3464
  }
3157
3465
  };
3158
3466
  }
3159
- function createStreamThrottle(emit2, opts = {}) {
3467
+ function createStreamThrottle(emit3, opts = {}) {
3160
3468
  const intervalMs = opts.intervalMs ?? 2000;
3161
3469
  const maxChars = opts.maxChars ?? 500;
3162
3470
  const now = opts.now ?? Date.now;
@@ -3166,7 +3474,7 @@ function createStreamThrottle(emit2, opts = {}) {
3166
3474
  const flushText = () => {
3167
3475
  if (buffer.length === 0)
3168
3476
  return;
3169
- emit2({ type: "burn.text", message: buffer, data: { iteration: lastIteration } });
3477
+ emit3({ type: "burn.text", message: buffer, data: { iteration: lastIteration } });
3170
3478
  buffer = "";
3171
3479
  lastFlush = now();
3172
3480
  };
@@ -3181,7 +3489,7 @@ function createStreamThrottle(emit2, opts = {}) {
3181
3489
  flushText();
3182
3490
  const rawArgs = event.formattedArgs ?? "";
3183
3491
  const args = rawArgs.length > 200 ? `${rawArgs.slice(0, 200)}\u2026` : rawArgs;
3184
- emit2({
3492
+ emit3({
3185
3493
  type: "burn.tool",
3186
3494
  message: args.length > 0 ? `${event.name} ${args}` : event.name,
3187
3495
  data: { name: event.name, args: rawArgs, iteration: event.iteration }
@@ -3293,18 +3601,18 @@ function buildRetryNotes(input) {
3293
3601
  `);
3294
3602
  }
3295
3603
  function delayUnlessAborted(ms, signal) {
3296
- return new Promise((resolve5) => {
3604
+ return new Promise((resolve6) => {
3297
3605
  if (signal.aborted) {
3298
- resolve5();
3606
+ resolve6();
3299
3607
  return;
3300
3608
  }
3301
3609
  const onAbort = () => {
3302
3610
  clearTimeout(t);
3303
- resolve5();
3611
+ resolve6();
3304
3612
  };
3305
3613
  const t = setTimeout(() => {
3306
3614
  signal.removeEventListener("abort", onAbort);
3307
- resolve5();
3615
+ resolve6();
3308
3616
  }, ms);
3309
3617
  signal.addEventListener("abort", onAbort, { once: true });
3310
3618
  });
@@ -3658,7 +3966,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3658
3966
  const runResolver = async (input) => {
3659
3967
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
3660
3968
  const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
3661
- const prompt = renderTemplate(readFileSync3(resolverTemplatePath(), "utf8"), {
3969
+ const prompt = renderTemplate2(readFileSync3(resolverTemplatePath(), "utf8"), {
3662
3970
  TICKET_JSON: ticketJson,
3663
3971
  FEATURE_BRIEF: featureBrief,
3664
3972
  DOCS_DIGEST: docsDigest,
@@ -4138,8 +4446,11 @@ function errorHeadline2(s) {
4138
4446
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4139
4447
  return causes.at(-1) ?? lines[0] ?? "";
4140
4448
  }
4141
- function createPrepStreamThrottle(emit2, opts = {}) {
4142
- return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
4449
+ function createPrepStreamThrottle(emit3, opts = {}) {
4450
+ return createStreamThrottle((e) => emit3({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
4451
+ }
4452
+ function nonFatalSetup(setupCommand) {
4453
+ return `( ${setupCommand} || true )`;
4143
4454
  }
4144
4455
  function prepTemplatePath() {
4145
4456
  const here = dirname4(fileURLToPath3(import.meta.url));
@@ -4172,10 +4483,11 @@ async function realExecutePrepRun(ctx, config2, token, model) {
4172
4483
  if (setupCommand) {
4173
4484
  ctx.emitEvent({
4174
4485
  type: "prep.setup",
4175
- message: `installing deps before the agent starts: ${setupCommand}`
4486
+ message: `attempting a dependency install before the agent starts (best-effort): ${setupCommand}`
4176
4487
  });
4177
4488
  }
4178
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, setupCommand, pm) : setupCommand;
4489
+ const prepSetupCommand = setupCommand ? nonFatalSetup(setupCommand) : undefined;
4490
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, prepSetupCommand, pm) : prepSetupCommand;
4179
4491
  const runOptions = {
4180
4492
  agent: buildBurnAgent(config2, token, model),
4181
4493
  sandbox: selectSandbox(config2, mounts),
@@ -4275,6 +4587,9 @@ var init_project_prep = __esm(() => {
4275
4587
  verifyCommands: "**`verifyCommands`** \u2014 the exact typecheck / test / lint commands for this repo, one per line. In a monorepo these must include the correct workspace filter names. Run each one and confirm it executes the thing it claims to.",
4276
4588
  knownFailures: '**`knownFailures`** \u2014 which tests are ALREADY failing on this checkout, before anything is changed. Run the full suite once and report the count plus the failing suite names. "0 known failures" is a real, useful answer.',
4277
4589
  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.",
4590
+ 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.",
4591
+ 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.",
4592
+ 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.",
4278
4593
  dbResetCommand: "**`dbResetCommand`** \u2014 the command that rebuilds the dev database from the migrations currently in the working tree. Read it from config; do not run it here. Omit it entirely if this repo has no database."
4279
4594
  };
4280
4595
  });
@@ -4381,6 +4696,9 @@ var init_findings = __esm(() => {
4381
4696
  verifyCommands: projects.verifyCommands,
4382
4697
  knownFailures: projects.knownFailures,
4383
4698
  devCommand: projects.devCommand,
4699
+ driveSetupCommand: projects.driveSetupCommand,
4700
+ driveStopCommand: projects.driveStopCommand,
4701
+ driveEnv: projects.driveEnv,
4384
4702
  dbResetCommand: projects.dbResetCommand
4385
4703
  };
4386
4704
  COLUMN_NAME = {
@@ -4388,13 +4706,16 @@ var init_findings = __esm(() => {
4388
4706
  verifyCommands: "verifyCommands",
4389
4707
  knownFailures: "knownFailures",
4390
4708
  devCommand: "devCommand",
4709
+ driveSetupCommand: "driveSetupCommand",
4710
+ driveStopCommand: "driveStopCommand",
4711
+ driveEnv: "driveEnv",
4391
4712
  dbResetCommand: "dbResetCommand"
4392
4713
  };
4393
4714
  PREPARED_SET = new Set(PREPARED_KEYS);
4394
4715
  });
4395
4716
 
4396
4717
  // src/services/prep.ts
4397
- import { desc as desc3, eq as eq6 } from "drizzle-orm";
4718
+ import { and as and6, desc as desc3, eq as eq6 } from "drizzle-orm";
4398
4719
  function isPreparing(projectId) {
4399
4720
  return controllers.has(projectId);
4400
4721
  }
@@ -4403,6 +4724,13 @@ function keysToPrepare(ctx, project, opts = {}) {
4403
4724
  const candidates = opts.keys ?? (opts.refresh ? PREPARED_KEYS : [...unset]);
4404
4725
  return candidates.filter((key) => (opts.refresh || unset.has(key)) && isOverwritable(ctx, project.id, key));
4405
4726
  }
4727
+ function shouldAutoPrepare(ctx, project) {
4728
+ if (!ctx.config.autoPrepare)
4729
+ return false;
4730
+ if (latestPrep(ctx, project.id))
4731
+ return false;
4732
+ return keysToPrepare(ctx, project).length > 0;
4733
+ }
4406
4734
  async function startPrep(ctx, projectId, opts = {}) {
4407
4735
  const project = requireProjectById(ctx, projectId);
4408
4736
  if (controllers.has(projectId)) {
@@ -4522,6 +4850,11 @@ function latestPrep(ctx, projectId) {
4522
4850
  ...row.headSha !== null ? { headSha: row.headSha } : {}
4523
4851
  };
4524
4852
  }
4853
+ function latestPrepNotes(ctx, projectId) {
4854
+ 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();
4855
+ const notes = row?.data?.notes;
4856
+ return typeof notes === "string" && notes.trim() !== "" ? notes : undefined;
4857
+ }
4525
4858
  function reconcilePreps(ctx) {
4526
4859
  const stale = ctx.db.select({ id: projectPreps.id, projectId: projectPreps.projectId }).from(projectPreps).where(eq6(projectPreps.status, "running")).all().filter((row) => !controllers.has(row.projectId));
4527
4860
  for (const row of stale) {
@@ -4576,7 +4909,7 @@ var init_feature_docs = __esm(() => {
4576
4909
  });
4577
4910
 
4578
4911
  // src/services/tickets.ts
4579
- import { and as and6, asc as asc3, eq as eq7 } from "drizzle-orm";
4912
+ import { and as and7, asc as asc3, eq as eq7 } from "drizzle-orm";
4580
4913
  function rowToTicket(row) {
4581
4914
  return {
4582
4915
  id: row.id,
@@ -4588,6 +4921,7 @@ function rowToTicket(row) {
4588
4921
  acceptanceCriteria: row.acceptanceCriteria,
4589
4922
  seams: row.seams,
4590
4923
  blockedBy: row.blockedBy,
4924
+ lap: row.lap,
4591
4925
  status: row.status,
4592
4926
  commits: row.commits,
4593
4927
  error: row.error ?? undefined,
@@ -4610,6 +4944,7 @@ function listByFeature2(ctx, featureId) {
4610
4944
  function storeTickets(ctx, featureId, inputs) {
4611
4945
  if (inputs.length === 0)
4612
4946
  return [];
4947
+ const { lap } = getFeatureRow(ctx, featureId);
4613
4948
  const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
4614
4949
  const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
4615
4950
  const resolved = resolveBlocking(inputs, startSeq);
@@ -4623,6 +4958,7 @@ function storeTickets(ctx, featureId, inputs) {
4623
4958
  acceptanceCriteria: t.acceptanceCriteria,
4624
4959
  seams: t.seams,
4625
4960
  blockedBy: resolved[i].blockedBy,
4961
+ lap,
4626
4962
  status: "pending",
4627
4963
  commits: [],
4628
4964
  error: null,
@@ -4687,7 +5023,7 @@ function cancelTicket(ctx, id, reason) {
4687
5023
  return getTicket(ctx, id);
4688
5024
  }
4689
5025
  function sweepOrphanedBurning(ctx, featureId, reason) {
4690
- const orphaned = ctx.db.select().from(tickets).where(and6(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
5026
+ const orphaned = ctx.db.select().from(tickets).where(and7(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
4691
5027
  for (const t of orphaned) {
4692
5028
  updateTicket(ctx, t.id, { status: "failed", error: reason });
4693
5029
  emit(ctx, featureId, {
@@ -4730,6 +5066,7 @@ var init_tickets = __esm(() => {
4730
5066
  init_schema();
4731
5067
  init_errors();
4732
5068
  init_events();
5069
+ init_repo();
4733
5070
  MUTABLE_STATUSES = new Set(["pending", "failed"]);
4734
5071
  });
4735
5072
 
@@ -4753,7 +5090,7 @@ function checkGate(ctx, check, feature) {
4753
5090
  case "spec-file-exists":
4754
5091
  return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
4755
5092
  case "tickets-approved": {
4756
- const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
5093
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.lap === feature.lap && t.status !== "cancelled").length;
4757
5094
  return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
4758
5095
  }
4759
5096
  case "all-tickets-terminal": {
@@ -4810,7 +5147,7 @@ var init_gates = __esm(() => {
4810
5147
 
4811
5148
  // src/services/knowledge.ts
4812
5149
  import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync } from "fs";
4813
- import { join as join10, relative, resolve as resolve5, sep } from "path";
5150
+ import { join as join10, relative, resolve as resolve6, sep } from "path";
4814
5151
  function scaffoldDocs(ctx, feature) {
4815
5152
  const project = projectForFeature(ctx, feature);
4816
5153
  const dir = featureDocsDir(project, feature);
@@ -4870,8 +5207,8 @@ function listDocs(ctx, feature) {
4870
5207
  function readDoc(ctx, feature, relPath) {
4871
5208
  const project = projectForFeature(ctx, feature);
4872
5209
  const dir = featureDocsDir(project, feature);
4873
- const root = resolve5(dir);
4874
- const target = resolve5(dir, relPath);
5210
+ const root = resolve6(dir);
5211
+ const target = resolve6(dir, relPath);
4875
5212
  const rel = relative(root, target);
4876
5213
  if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
4877
5214
  throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
@@ -4912,7 +5249,7 @@ function endSession(ctx, sessionId) {
4912
5249
  const session = markSessionEnded(ctx, sessionId);
4913
5250
  if (session) {
4914
5251
  releaseForSession(ctx, sessionId);
4915
- emit(ctx, session.featureId, {
5252
+ emitForSession(ctx, session, {
4916
5253
  type: "session.ended",
4917
5254
  message: "session ended by user",
4918
5255
  data: { sessionId, killed }
@@ -4984,8 +5321,8 @@ function errorHeadline3(s) {
4984
5321
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4985
5322
  return causes.at(-1) ?? lines[0] ?? "";
4986
5323
  }
4987
- function createResearchStreamThrottle(emit2, opts = {}) {
4988
- return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
5324
+ function createResearchStreamThrottle(emit3, opts = {}) {
5325
+ return createStreamThrottle((e) => emit3({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
4989
5326
  }
4990
5327
  function renderResearchPrompt(template, values) {
4991
5328
  let out = template;
@@ -5193,7 +5530,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
5193
5530
  input: opts.input,
5194
5531
  modelOverride: opts.modelOverride,
5195
5532
  resolveWaypoint: (id, disposition, summary) => {
5196
- resolve2(ctx, id, disposition, summary);
5533
+ resolve3(ctx, id, disposition, summary);
5197
5534
  },
5198
5535
  signal: controller.signal
5199
5536
  };
@@ -5288,6 +5625,7 @@ async function createFeature(ctx, input) {
5288
5625
  title: input.title,
5289
5626
  oneLiner: input.oneLiner,
5290
5627
  mapped: false,
5628
+ lap: 1,
5291
5629
  phase: "ideation",
5292
5630
  branch,
5293
5631
  baseBranch,
@@ -5372,7 +5710,8 @@ async function burn(ctx, featureId, opts = {}) {
5372
5710
  const feature = getFeatureRow(ctx, featureId);
5373
5711
  const running = hasActiveRun(ctx, featureId);
5374
5712
  let tickets2 = listByFeature2(ctx, featureId);
5375
- const pending = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
5713
+ const lapTickets = tickets2.filter((t) => t.lap === feature.lap);
5714
+ const pending = lapTickets.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
5376
5715
  const restarting = feature.phase === "implementation" && !running;
5377
5716
  const iterating = feature.phase === "review" && !running && pending.length >= 1;
5378
5717
  if (feature.phase !== "tickets" && !restarting && !iterating) {
@@ -5385,8 +5724,8 @@ async function burn(ctx, featureId, opts = {}) {
5385
5724
  why = `feature must be in the tickets phase to burn (currently ${feature.phase})`;
5386
5725
  throw new GateError(why);
5387
5726
  }
5388
- if (tickets2.filter((t) => t.status !== "cancelled").length < 1) {
5389
- throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
5727
+ if (lapTickets.filter((t) => t.status !== "cancelled").length < 1) {
5728
+ throw new GateError(lapTickets.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
5390
5729
  }
5391
5730
  if (restarting) {
5392
5731
  sweepOrphanedBurning(ctx, featureId, "orphaned \u2014 the previous run died while it was burning");
@@ -5410,6 +5749,22 @@ async function burn(ctx, featureId, opts = {}) {
5410
5749
  });
5411
5750
  return { runId };
5412
5751
  }
5752
+ function rethink(ctx, featureId) {
5753
+ const feature = getFeatureRow(ctx, featureId);
5754
+ if (feature.phase !== RETHINK_LOOP_BACK.from) {
5755
+ throw new GateError(`feature must be in the review phase to rethink (currently ${feature.phase})`);
5756
+ }
5757
+ if (hasActiveRun(ctx, featureId)) {
5758
+ throw new GateError("a run is burning this feature \u2014 cancel or wait for it before rethinking");
5759
+ }
5760
+ const live = activeSessionsForFeature(ctx, featureId);
5761
+ if (live.length > 0) {
5762
+ throw new GateError(`a ${live[0].kind} session is already live for ${feature.slug} \u2014 only one terminal per feature; end or resume it first`);
5763
+ }
5764
+ const lap = feature.lap + 1;
5765
+ ctx.db.update(features).set({ lap }).where(eq9(features.id, featureId)).run();
5766
+ return setPhase(ctx, featureId, RETHINK_LOOP_BACK.to, "lap.started", `rethink \u2014 lap ${lap}`);
5767
+ }
5413
5768
  async function retryTicket(ctx, ticketId, opts = {}) {
5414
5769
  const ticket = getTicket(ctx, ticketId);
5415
5770
  const feature = getFeatureRow(ctx, ticket.featureId);
@@ -5591,6 +5946,7 @@ var init_features = __esm(() => {
5591
5946
  init_git();
5592
5947
  init_knowledge();
5593
5948
  init_repo();
5949
+ init_sessions();
5594
5950
  init_end_session();
5595
5951
  init_tickets();
5596
5952
  init_waypoints();
@@ -5618,8 +5974,14 @@ function resolveSession(ctx, sessionId) {
5618
5974
  }
5619
5975
  return mostRecentLiveSession(ctx);
5620
5976
  }
5977
+ function requireFeatureId(session) {
5978
+ if (!session.featureId) {
5979
+ 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.");
5980
+ }
5981
+ return session.featureId;
5982
+ }
5621
5983
  function toolGetFeatureContext(ctx, session) {
5622
- const feature = getFeatureRow(ctx, session.featureId);
5984
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
5623
5985
  const docs = listDocs(ctx, feature).map((d) => {
5624
5986
  try {
5625
5987
  return { relPath: d.relPath, content: readDoc(ctx, feature, d.relPath).content };
@@ -5630,6 +5992,7 @@ function toolGetFeatureContext(ctx, session) {
5630
5992
  const context = {
5631
5993
  feature,
5632
5994
  phase: feature.phase,
5995
+ lap: feature.lap,
5633
5996
  docs,
5634
5997
  tickets: listByFeature2(ctx, feature.id)
5635
5998
  };
@@ -5641,17 +6004,17 @@ function toolGetFeatureContext(ctx, session) {
5641
6004
  return context;
5642
6005
  }
5643
6006
  function toolResolveWaypoint(ctx, _session, input) {
5644
- resolve2(ctx, input.id, input.disposition, input.summary);
6007
+ resolve3(ctx, input.id, input.disposition, input.summary);
5645
6008
  return { ok: true };
5646
6009
  }
5647
6010
  function toolEmitTickets(ctx, session, input) {
5648
- const feature = getFeatureRow(ctx, session.featureId);
6011
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
5649
6012
  const stored = storeTickets(ctx, feature.id, input.tickets);
5650
6013
  return { stored: stored.length, ids: stored.map((t) => t.id) };
5651
6014
  }
5652
6015
  function requireOwnTicket(ctx, session, ticketId) {
5653
6016
  const ticket = getTicket(ctx, ticketId);
5654
- if (ticket.featureId !== session.featureId) {
6017
+ if (ticket.featureId !== requireFeatureId(session)) {
5655
6018
  throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
5656
6019
  }
5657
6020
  return ticket;
@@ -5666,10 +6029,10 @@ function toolCancelTicket(ctx, session, input) {
5666
6029
  return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
5667
6030
  }
5668
6031
  function toolEscalateToMap(ctx, session, input) {
5669
- return escalateToMap(ctx, session.featureId, input);
6032
+ return escalateToMap(ctx, requireFeatureId(session), input);
5670
6033
  }
5671
6034
  function toolEmitWaypoints(ctx, session, input) {
5672
- const feature = getFeatureRow(ctx, session.featureId);
6035
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
5673
6036
  if (!feature.mapped) {
5674
6037
  throw new GateError("feature is not mapped \u2014 call escalate_to_map before emitting waypoints");
5675
6038
  }
@@ -5677,7 +6040,7 @@ function toolEmitWaypoints(ctx, session, input) {
5677
6040
  return { stored: stored.length, ids: stored.map((w) => w.id) };
5678
6041
  }
5679
6042
  function toolRecordEvent(ctx, session, input) {
5680
- emit(ctx, session.featureId, {
6043
+ emit(ctx, requireFeatureId(session), {
5681
6044
  type: input.type,
5682
6045
  message: input.message,
5683
6046
  data: { source: "mcp" }
@@ -5685,7 +6048,7 @@ function toolRecordEvent(ctx, session, input) {
5685
6048
  return { ok: true };
5686
6049
  }
5687
6050
  function toolCompletePhase(ctx, session, input) {
5688
- const feature = getFeatureRow(ctx, session.featureId);
6051
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
5689
6052
  emit(ctx, feature.id, {
5690
6053
  type: "phase.complete_requested",
5691
6054
  message: `session marked phase '${input.phase}' complete`,
@@ -5714,13 +6077,46 @@ async function commitDocsCheckpoint(ctx, session, message) {
5714
6077
  try {
5715
6078
  await commitDocs(session.worktreePath, message);
5716
6079
  } catch (e) {
5717
- emit(ctx, session.featureId, {
6080
+ emit(ctx, requireFeatureId(session), {
5718
6081
  type: "git.commit_pending",
5719
6082
  message: isNotImplemented(e) ? "docs checkpoint skipped (git service pending)" : `docs checkpoint failed: ${e instanceof Error ? e.message : String(e)}`,
5720
6083
  data: { message }
5721
6084
  });
5722
6085
  }
5723
6086
  }
6087
+ function requireProject(ctx, session) {
6088
+ if (!session.projectId) {
6089
+ throw new GateError(`record_finding is for a project-scoped preparation session; this is a ${session.kind} session.`);
6090
+ }
6091
+ const project = getProjectById(ctx, session.projectId);
6092
+ if (!project)
6093
+ throw new GateError(`project ${session.projectId} not found`);
6094
+ return project;
6095
+ }
6096
+ function toolRecordFinding(ctx, session, input) {
6097
+ const project = requireProject(ctx, session);
6098
+ const source = input.userSupplied ? "human" : "session";
6099
+ if (!isOverwritable(ctx, project.id, input.key) && !input.userSupplied) {
6100
+ return {
6101
+ ok: true,
6102
+ key: input.key,
6103
+ source,
6104
+ skipped: `${input.key} was set by a human and is not auto-overwritable \u2014 ask them to clear it first if it should change`
6105
+ };
6106
+ }
6107
+ recordFinding(ctx, project.id, {
6108
+ key: input.key,
6109
+ value: input.value,
6110
+ source,
6111
+ ...input.evidence ? { evidence: input.evidence } : {}
6112
+ });
6113
+ emitProject(ctx, project.id, {
6114
+ type: "prep.finding_recorded",
6115
+ message: `preparation session established ${input.key} (${source})`,
6116
+ data: { key: input.key, source, sessionId: session.id }
6117
+ });
6118
+ return { ok: true, key: input.key, source };
6119
+ }
5724
6120
  function ok(result) {
5725
6121
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
5726
6122
  }
@@ -5742,6 +6138,21 @@ async function resolveCtxSession(extra) {
5742
6138
  }
5743
6139
  function buildMcpServer() {
5744
6140
  const server = new McpServer({ name: "runcastle", version: "0.1.0" });
6141
+ server.registerTool("record_finding", {
6142
+ title: "Record a preparation finding",
6143
+ 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.",
6144
+ inputSchema: {
6145
+ key: PreparedKey,
6146
+ value: z4.string(),
6147
+ evidence: z4.string().optional(),
6148
+ userSupplied: z4.boolean().optional()
6149
+ }
6150
+ }, async (args, extra) => {
6151
+ const rs = await resolveCtxSession(extra);
6152
+ if (!rs)
6153
+ return noSession();
6154
+ return ok(toolRecordFinding(rs.ctx, rs.session, args));
6155
+ });
5745
6156
  server.registerTool("get_feature_context", {
5746
6157
  title: "Get feature context",
5747
6158
  description: "Full context for the current feature: the feature row, its phase, all docs/features/<slug>/*.md contents, and its tickets.",
@@ -5864,6 +6275,7 @@ var init_server = __esm(() => {
5864
6275
  init_sessions();
5865
6276
  init_features();
5866
6277
  init_events();
6278
+ init_findings();
5867
6279
  init_git();
5868
6280
  init_knowledge();
5869
6281
  init_repo();
@@ -5974,6 +6386,57 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
5974
6386
  }
5975
6387
  };
5976
6388
  }
6389
+ function handlePrepareSessionStart(ctx, session, payload) {
6390
+ const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
6391
+ const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
6392
+ const source = typeof payload?.source === "string" ? payload.source : undefined;
6393
+ const wasLive = getSessionRow(ctx, session.id)?.status === "live";
6394
+ markSessionLive(ctx, session.id, { ccSessionId, transcriptPath });
6395
+ if (!wasLive) {
6396
+ emitForSession(ctx, session, {
6397
+ type: "session.started",
6398
+ message: source === "resume" ? "preparation session live (resumed)" : "preparation session live",
6399
+ data: { sessionId: session.id, ccSessionId, transcriptPath, source: source ?? null }
6400
+ });
6401
+ }
6402
+ return {
6403
+ hookSpecificOutput: {
6404
+ hookEventName: "SessionStart",
6405
+ additionalContext: prepareStartContext(ctx, session)
6406
+ }
6407
+ };
6408
+ }
6409
+ function handlePrepareUserPrompt(ctx, session, payload) {
6410
+ const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
6411
+ noteKickoffPrompt(ctx, session.id, prompt);
6412
+ return {
6413
+ hookSpecificOutput: {
6414
+ hookEventName: "UserPromptSubmit",
6415
+ additionalContext: `[runcastle] preparation session (project-scoped, no feature)`
6416
+ }
6417
+ };
6418
+ }
6419
+ function handlePrepareSessionEnd(ctx, session) {
6420
+ markSessionEnded(ctx, session.id);
6421
+ emitForSession(ctx, session, {
6422
+ type: "session.ended",
6423
+ message: "preparation session ended",
6424
+ data: { sessionId: session.id }
6425
+ });
6426
+ return {};
6427
+ }
6428
+ function prepareStartContext(ctx, session) {
6429
+ const project = session.projectId ? getProjectById(ctx, session.projectId) : null;
6430
+ if (!project)
6431
+ return "[runcastle] preparation session \u2014 project not found";
6432
+ const remaining = keysToPrepare(ctx, project);
6433
+ return [
6434
+ `[runcastle] preparation session for ${project.name} (${project.repoPath}).`,
6435
+ remaining.length > 0 ? `Still unestablished: ${remaining.join(", ")}.` : "Every prepared field currently has a value.",
6436
+ "Record what you establish with the `record_finding` MCP tool \u2014 a value the user",
6437
+ "gives you or confirms verbatim is theirs (userSupplied: true), anything you measured is not."
6438
+ ].join(" ");
6439
+ }
5977
6440
  function handleUserPrompt(ctx, sessionId, feature, payload) {
5978
6441
  const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
5979
6442
  noteKickoffPrompt(ctx, sessionId, prompt);
@@ -6015,6 +6478,7 @@ var hooks, hooks_default;
6015
6478
  var init_hooks = __esm(() => {
6016
6479
  init_sessions();
6017
6480
  init_events();
6481
+ init_prep();
6018
6482
  init_repo();
6019
6483
  init_tickets();
6020
6484
  init_waypoints();
@@ -6036,6 +6500,18 @@ var init_hooks = __esm(() => {
6036
6500
  const session = getSessionRow(ctx, sessionId);
6037
6501
  if (!session)
6038
6502
  return c.json({});
6503
+ if (!session.featureId) {
6504
+ switch (event) {
6505
+ case "session-start":
6506
+ return c.json(handlePrepareSessionStart(ctx, session, body.payload));
6507
+ case "user-prompt":
6508
+ return c.json(handlePrepareUserPrompt(ctx, session, body.payload));
6509
+ case "session-end":
6510
+ return c.json(handlePrepareSessionEnd(ctx, session));
6511
+ default:
6512
+ return c.json({});
6513
+ }
6514
+ }
6039
6515
  const feature = tryGetFeature(ctx, session.featureId);
6040
6516
  if (!feature)
6041
6517
  return c.json({});
@@ -6110,7 +6586,7 @@ var init_stream = __esm(() => {
6110
6586
  // src/routes/web.ts
6111
6587
  import { existsSync as existsSync13, statSync } from "fs";
6112
6588
  import { readFile } from "fs/promises";
6113
- import { extname, join as join12, normalize, resolve as resolve6, sep as sep2 } from "path";
6589
+ import { extname, join as join12, normalize, resolve as resolve7, sep as sep2 } from "path";
6114
6590
  function contentType(path) {
6115
6591
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
6116
6592
  }
@@ -6136,11 +6612,11 @@ function safeJoin(root, pathname) {
6136
6612
  function resolveWebDist() {
6137
6613
  const override = process.env.RUNCASTLE_WEB_DIST;
6138
6614
  if (override)
6139
- return resolve6(override);
6140
- return resolve6(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
6615
+ return resolve7(override);
6616
+ return resolve7(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
6141
6617
  }
6142
6618
  function mountWebApp(app, distDir) {
6143
- const root = resolve6(distDir);
6619
+ const root = resolve7(distDir);
6144
6620
  const indexHtml = join12(root, "index.html");
6145
6621
  app.get("*", async (c, next) => {
6146
6622
  const { pathname } = new URL(c.req.url);
@@ -6431,6 +6907,69 @@ function renderRevisitPrompt(feature) {
6431
6907
  ].join(`
6432
6908
  `);
6433
6909
  }
6910
+ function renderPreparePrompt(brief) {
6911
+ const { project, remainingKeys, established, notes } = brief;
6912
+ return [
6913
+ `# runcastle \u2014 preparing ${project.name}`,
6914
+ "",
6915
+ "This is a **preparation session**: a project-scoped conversation whose job is to",
6916
+ "establish the settings every later agent depends on, and to record them with evidence.",
6917
+ "There is no feature here and no pipeline to advance.",
6918
+ "",
6919
+ "## Where this runs \u2014 and why that matters",
6920
+ `You are on the developer's own machine, in \`${project.repoPath}\`, NOT in a sandbox.`,
6921
+ "A headless preparation run measures the repo in a throwaway container, which means it",
6922
+ "can never verify anything about *this* machine: the dev server, the local database,",
6923
+ "docker, credentials. Those keys \u2014 `devCommand`, `driveSetupCommand`, `driveStopCommand`,",
6924
+ "`driveEnv`, `dbResetCommand` \u2014 are exactly what it has to leave as proposals.",
6925
+ "You can actually run them. That is the point of this session.",
6926
+ "",
6927
+ "The same access is why you must **ask before you act**. Anything that starts or stops a",
6928
+ "service, creates or migrates a database, or writes outside the repo needs the human to",
6929
+ "agree first \u2014 say what you are about to run and why, then wait.",
6930
+ "",
6931
+ ...established.length > 0 ? [
6932
+ "## Already established \u2014 do not re-derive",
6933
+ "These are recorded findings. Treat them as true unless the human says otherwise;",
6934
+ "replacing a measured value with a fresh guess makes preparation worse, not better.",
6935
+ "",
6936
+ ...established.map((f) => `- \`${f.key}\` (${f.source})${f.evidence ? ` \u2014 ${f.evidence}` : ""}`),
6937
+ ""
6938
+ ] : [],
6939
+ ...notes ? [
6940
+ "## What the last run could not settle",
6941
+ "Its own words. Where it says it declined to guess, it was right to \u2014 that is the",
6942
+ "gap you are here to close, and closing it usually means asking one direct question.",
6943
+ "",
6944
+ notes,
6945
+ ""
6946
+ ] : [],
6947
+ "## Still open",
6948
+ remainingKeys.length > 0 ? remainingKeys.map((k) => `- \`${k}\``).join(`
6949
+ `) : "_Nothing is unset. Confirm the existing values still hold, then say so and stop._",
6950
+ "",
6951
+ "## Recording what you establish",
6952
+ "- `record_finding({ key, value, evidence, userSupplied })` \u2014 one call per key.",
6953
+ "- `userSupplied: true` means the human GAVE you this value or confirmed it verbatim.",
6954
+ " That marks it as theirs and permanently stops automatic runs from overwriting it.",
6955
+ "- Leave it false for anything you worked out yourself, even with them watching \u2014",
6956
+ " that stays improvable by a later run. Getting this backwards silently retires a",
6957
+ " field from preparation forever, and the only way back is the human clearing it.",
6958
+ "- `evidence` is not optional in spirit: record what you ran and what it printed, or",
6959
+ " what the human told you. A value with no account of itself is a guess with a source field.",
6960
+ "",
6961
+ "## Secrets",
6962
+ "This is a development environment and the human has agreed to supply real connection",
6963
+ "strings and credentials here. Store them as given. Do not paste a secret into a timeline",
6964
+ "note or a commit message \u2014 `record_finding` is the only place a value belongs.",
6965
+ "",
6966
+ "## Your task",
6967
+ "Open by telling the human which fields are still open and what you need from them for",
6968
+ "each. Work them one at a time: propose, ask, run it if they agree, then record it.",
6969
+ ""
6970
+ ].join(`
6971
+ `);
6972
+ }
6434
6973
  function renderSettings(hookClient) {
6435
6974
  const cmd = (event) => ({
6436
6975
  type: "command",
@@ -6461,13 +7000,16 @@ function renderMcpConfig(session, config2) {
6461
7000
  };
6462
7001
  }
6463
7002
  async function writeSessionArtifacts(input) {
6464
- const { session, feature, config: config2, waypoint } = input;
7003
+ const { session, feature, config: config2, waypoint, prepare } = input;
6465
7004
  const dir = sessionDir(session.id);
6466
7005
  mkdirSync7(dir, { recursive: true });
6467
7006
  const systemPromptPath = join13(dir, "system-prompt.md");
6468
7007
  const settingsPath = join13(dir, "settings.json");
6469
7008
  const mcpConfigPath = join13(dir, "mcp.json");
6470
- writeFileSync2(systemPromptPath, renderSystemPrompt(feature, session.kind, waypoint), "utf8");
7009
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : (() => {
7010
+ throw new Error(`session ${session.id} has neither a feature nor a preparation brief`);
7011
+ })();
7012
+ writeFileSync2(systemPromptPath, systemPrompt, "utf8");
6471
7013
  writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
6472
7014
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
6473
7015
  return { systemPromptPath, settingsPath, mcpConfigPath };
@@ -6485,7 +7027,8 @@ var init_artifacts = __esm(() => {
6485
7027
  "mcp__runcastle__complete_phase",
6486
7028
  "mcp__runcastle__escalate_to_map",
6487
7029
  "mcp__runcastle__emit_waypoints",
6488
- "mcp__runcastle__resolve_waypoint"
7030
+ "mcp__runcastle__resolve_waypoint",
7031
+ "mcp__runcastle__record_finding"
6489
7032
  ];
6490
7033
  SESSION_BASH_ALLOW_RULES = [
6491
7034
  "Bash(git status:*)",
@@ -6502,9 +7045,9 @@ var init_artifacts = __esm(() => {
6502
7045
 
6503
7046
  // src/launcher/launcher.ts
6504
7047
  import { existsSync as existsSync14 } from "fs";
6505
- import { dirname as dirname6, join as join14, resolve as resolve7 } from "path";
7048
+ import { dirname as dirname6, join as join14, resolve as resolve8 } from "path";
6506
7049
  import { fileURLToPath as fileURLToPath6 } from "url";
6507
- import { and as and7, eq as eq10 } from "drizzle-orm";
7050
+ import { and as and8, eq as eq10 } from "drizzle-orm";
6508
7051
  function ptyExitMessage(exitCode) {
6509
7052
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
6510
7053
  return `terminal exited (code ${label})`;
@@ -6518,7 +7061,7 @@ function buildClaudeArgs(input) {
6518
7061
  input.settingsPath,
6519
7062
  "--mcp-config",
6520
7063
  input.mcpConfigPath,
6521
- "--strict-mcp-config",
7064
+ ...input.strictMcp ? ["--strict-mcp-config"] : [],
6522
7065
  "--plugin-dir",
6523
7066
  input.pluginDir,
6524
7067
  "--append-system-prompt-file",
@@ -6539,7 +7082,7 @@ function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
6539
7082
  const rel = join14("packages", "skills", "packs", "runcastle");
6540
7083
  const override = process.env[SKILLS_DIR_ENV];
6541
7084
  if (override) {
6542
- const dir2 = join14(resolve7(override), "packs", "runcastle");
7085
+ const dir2 = join14(resolve8(override), "packs", "runcastle");
6543
7086
  if (existsSync14(dir2))
6544
7087
  return dir2;
6545
7088
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
@@ -6561,7 +7104,7 @@ function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
6561
7104
  `)}`);
6562
7105
  }
6563
7106
  function activeRunFor(ctx, featureId) {
6564
- const row = ctx.db.select().from(runs).where(and7(eq10(runs.featureId, featureId), eq10(runs.status, "running"))).limit(1).get();
7107
+ const row = ctx.db.select().from(runs).where(and8(eq10(runs.featureId, featureId), eq10(runs.status, "running"))).limit(1).get();
6565
7108
  return row ? rowToRun(row) : null;
6566
7109
  }
6567
7110
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -6574,6 +7117,29 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
6574
7117
  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`);
6575
7118
  }
6576
7119
  }
7120
+ function sessionFinished(ctx, feature, session) {
7121
+ if (session.kind !== "waypoint")
7122
+ return feature.mapped;
7123
+ const own = listByFeature(ctx, feature.id).find((w) => w.lastSessionId === session.id);
7124
+ return !!own && (own.status === "resolved" || own.status === "dropped");
7125
+ }
7126
+ function sweepActiveSessions(ctx, feature, endLive) {
7127
+ for (const session of activeSessionsForFeature(ctx, feature.id)) {
7128
+ const finished = sessionFinished(ctx, feature, session);
7129
+ if (!finished && !endLive)
7130
+ continue;
7131
+ endSession(ctx, session.id);
7132
+ emit(ctx, feature.id, {
7133
+ type: "session.auto_ended",
7134
+ 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`,
7135
+ data: {
7136
+ sessionId: session.id,
7137
+ kind: session.kind,
7138
+ reason: finished ? "finished" : "abandoned"
7139
+ }
7140
+ });
7141
+ }
7142
+ }
6577
7143
  async function ensureWorktree(ctx, project, feature) {
6578
7144
  try {
6579
7145
  return await ensureTalkWorktree(project, feature);
@@ -6681,7 +7247,8 @@ async function launchSession(ctx, input, opts = {}) {
6681
7247
  mcpConfigPath: artifacts.mcpConfigPath,
6682
7248
  systemPromptPath: artifacts.systemPromptPath,
6683
7249
  model: resolveModel(input.kind, ctx.config, project),
6684
- resumeSessionId
7250
+ resumeSessionId,
7251
+ strictMcp: ctx.config.sessionMcp === "runcastleOnly"
6685
7252
  };
6686
7253
  if (opts.spawn === false) {
6687
7254
  emit(ctx, feature.id, {
@@ -6701,6 +7268,75 @@ async function launchSession(ctx, input, opts = {}) {
6701
7268
  });
6702
7269
  return { sessionId: session.id };
6703
7270
  }
7271
+ async function launchPrepareSession(ctx, input, opts = {}) {
7272
+ const project = requireProjectById(ctx, input.projectId);
7273
+ const session = createSessionRow(ctx, {
7274
+ projectId: project.id,
7275
+ kind: "prepare",
7276
+ worktreePath: project.repoPath
7277
+ });
7278
+ const resumeSessionId = mostRecentResumableProjectSession(ctx, project.id, "prepare")?.ccSessionId;
7279
+ emitProject(ctx, project.id, {
7280
+ type: "session.launching",
7281
+ message: "launching preparation session",
7282
+ data: { sessionId: session.id, kind: "prepare", worktreePath: project.repoPath }
7283
+ });
7284
+ if (resumeSessionId) {
7285
+ emitProject(ctx, project.id, {
7286
+ type: "session.resumed",
7287
+ message: "resuming the previous preparation conversation",
7288
+ data: { sessionId: session.id, resumeSessionId }
7289
+ });
7290
+ }
7291
+ const artifacts = await writeSessionArtifacts({
7292
+ session,
7293
+ project,
7294
+ config: ctx.config,
7295
+ prepare: await buildPrepareBrief(ctx, project)
7296
+ });
7297
+ const serverUrl = serverUrlFor(ctx.config);
7298
+ const buildInput = {
7299
+ sessionId: session.id,
7300
+ serverUrl,
7301
+ featureTitle: `preparing ${project.name}`,
7302
+ worktreePath: project.repoPath,
7303
+ pluginDir: resolvePluginDir(),
7304
+ settingsPath: artifacts.settingsPath,
7305
+ mcpConfigPath: artifacts.mcpConfigPath,
7306
+ systemPromptPath: artifacts.systemPromptPath,
7307
+ model: resolveModel("prepare", ctx.config, project),
7308
+ resumeSessionId,
7309
+ strictMcp: ctx.config.sessionMcp === "runcastleOnly"
7310
+ };
7311
+ if (opts.spawn === false) {
7312
+ emitProject(ctx, project.id, {
7313
+ type: "session.launched",
7314
+ message: "preparation session prepared (terminal spawn skipped)",
7315
+ data: {
7316
+ sessionId: session.id,
7317
+ command: ["claude", ...buildClaudeArgs(buildInput)].join(" "),
7318
+ spawned: false
7319
+ }
7320
+ });
7321
+ return { sessionId: session.id };
7322
+ }
7323
+ spawnEmbeddedPty(ctx, undefined, session, project.repoPath, serverUrl, buildClaudeArgs(buildInput), { resumeSessionId });
7324
+ return { sessionId: session.id };
7325
+ }
7326
+ async function buildPrepareBrief(ctx, project) {
7327
+ const findings = await listFindings(ctx, project);
7328
+ const notes = latestPrepNotes(ctx, project.id);
7329
+ return {
7330
+ project,
7331
+ remainingKeys: keysToPrepare(ctx, project),
7332
+ established: findings.map((f) => ({
7333
+ key: f.key,
7334
+ source: f.source,
7335
+ ...f.evidence ? { evidence: f.evidence } : {}
7336
+ })),
7337
+ ...notes ? { notes } : {}
7338
+ };
7339
+ }
6704
7340
  async function workWaypoint(ctx, input, opts = {}) {
6705
7341
  const feature = getFeatureRow(ctx, input.featureId);
6706
7342
  if (!feature.mapped) {
@@ -6717,6 +7353,7 @@ async function workWaypoint(ctx, input, opts = {}) {
6717
7353
  });
6718
7354
  return { runId };
6719
7355
  }
7356
+ sweepActiveSessions(ctx, feature, input.endLive === true);
6720
7357
  assertSpawnable(ctx, feature);
6721
7358
  return launchSession(ctx, { featureId: feature.id, kind: "waypoint", waypointId: wp.id }, opts);
6722
7359
  }
@@ -6773,7 +7410,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
6773
7410
  releaseForSession(ctx, session.id);
6774
7411
  if (diedBeforeLive && meta.resumeSessionId) {
6775
7412
  const label = meta.waypoint ? `waypoint ${meta.waypoint.seq} (${meta.waypoint.title})` : session.kind;
6776
- emit(ctx, feature.id, {
7413
+ emitForSession(ctx, session, {
6777
7414
  type: "session.resume_failed",
6778
7415
  message: `resume failed for ${label} \u2014 the session exited before starting (code ${exitCode ?? "unknown"}); the previous conversation is still resumable`,
6779
7416
  data: {
@@ -6784,7 +7421,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
6784
7421
  }
6785
7422
  });
6786
7423
  }
6787
- emit(ctx, feature.id, {
7424
+ emitForSession(ctx, session, {
6788
7425
  type: "session.pty_exited",
6789
7426
  message: ptyExitMessage(exitCode),
6790
7427
  data: { sessionId: session.id, exitCode: exitCode ?? null }
@@ -6807,7 +7444,7 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
6807
7444
  opts: { cwd: worktreePath, env, cols: 80, rows: 24, useConpty: true },
6808
7445
  onExit: ({ exitCode }) => handlePtyExit(ctx, feature, session, meta, exitCode)
6809
7446
  });
6810
- emit(ctx, feature.id, {
7447
+ emitForSession(ctx, session, {
6811
7448
  type: "session.launched",
6812
7449
  message: "embedded terminal spawned",
6813
7450
  data: { sessionId: session.id, mode: "embedded", pid: entry.pty.pid }
@@ -6816,7 +7453,7 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
6816
7453
  } catch (err) {
6817
7454
  markSessionEnded(ctx, session.id);
6818
7455
  releaseForSession(ctx, session.id);
6819
- emit(ctx, feature.id, {
7456
+ emitForSession(ctx, session, {
6820
7457
  type: "session.spawn_failed",
6821
7458
  message: `failed to spawn embedded terminal: ${err instanceof Error ? err.message : String(err)}`,
6822
7459
  data: { sessionId: session.id, mode: "embedded" }
@@ -6831,6 +7468,7 @@ var init_launcher = __esm(() => {
6831
7468
  init_skills_root();
6832
7469
  init_schema();
6833
7470
  init_errors();
7471
+ init_end_session();
6834
7472
  init_registry();
6835
7473
  init_events();
6836
7474
  init_gates();
@@ -6839,6 +7477,8 @@ var init_launcher = __esm(() => {
6839
7477
  init_tickets();
6840
7478
  init_waypoints();
6841
7479
  init_runner();
7480
+ init_findings();
7481
+ init_prep();
6842
7482
  init_artifacts();
6843
7483
  init_sessions();
6844
7484
  init_sessions();
@@ -6861,6 +7501,7 @@ var gateId, featureRouter;
6861
7501
  var init_feature = __esm(() => {
6862
7502
  init_src();
6863
7503
  init_launcher();
7504
+ init_sessions();
6864
7505
  init_events();
6865
7506
  init_features();
6866
7507
  init_gates();
@@ -6882,8 +7523,16 @@ var init_feature = __esm(() => {
6882
7523
  kind: SessionKind,
6883
7524
  kickoffLine: z7.string().min(1).optional()
6884
7525
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
6885
- workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
7526
+ workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string(), endLive: z7.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
6886
7527
  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 })),
7528
+ rethink: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => {
7529
+ const feature = rethink(ctx, input.featureId);
7530
+ return launchSession(ctx, {
7531
+ featureId: input.featureId,
7532
+ kind: "revisit",
7533
+ kickoffLine: lapKickoff(feature.lap)
7534
+ });
7535
+ }),
6887
7536
  resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
6888
7537
  endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
6889
7538
  advance: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
@@ -6923,7 +7572,7 @@ var init_feature = __esm(() => {
6923
7572
  // src/services/fsbrowse.ts
6924
7573
  import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
6925
7574
  import { homedir as homedir2 } from "os";
6926
- import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve8, sep as sep3 } from "path";
7575
+ import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve9, sep as sep3 } from "path";
6927
7576
  function expandPath(input, home = homedir2()) {
6928
7577
  const trimmed = input.trim();
6929
7578
  if (trimmed === "")
@@ -6933,7 +7582,7 @@ function expandPath(input, home = homedir2()) {
6933
7582
  out = home;
6934
7583
  else if (out.startsWith("~/") || out.startsWith("~\\"))
6935
7584
  out = join15(home, out.slice(2));
6936
- return resolve8(out);
7585
+ return resolve9(out);
6937
7586
  }
6938
7587
  function looksLikeRepo(dir) {
6939
7588
  try {
@@ -7086,6 +7735,7 @@ async function openProject(ctx, rawPath) {
7086
7735
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
7087
7736
  });
7088
7737
  warnIfTranslatedMount(ctx, existing.id, repoPath);
7738
+ maybeAutoPrepare(ctx, reopened);
7089
7739
  return reopened;
7090
7740
  }
7091
7741
  const row = {
@@ -7107,7 +7757,7 @@ async function openProject(ctx, rawPath) {
7107
7757
  return project;
7108
7758
  }
7109
7759
  function maybeAutoPrepare(ctx, project) {
7110
- if (!ctx.config.autoPrepare)
7760
+ if (!shouldAutoPrepare(ctx, project))
7111
7761
  return;
7112
7762
  startPrep(ctx, project.id).catch((e) => {
7113
7763
  emitProject(ctx, project.id, {
@@ -7182,6 +7832,8 @@ var init_project = __esm(() => {
7182
7832
  init_fsbrowse();
7183
7833
  init_git();
7184
7834
  init_prep();
7835
+ init_launcher();
7836
+ init_sessions();
7185
7837
  init_projects();
7186
7838
  init_repo();
7187
7839
  init_context();
@@ -7204,6 +7856,8 @@ var init_project = __esm(() => {
7204
7856
  });
7205
7857
  return { prepId, keys };
7206
7858
  }),
7859
+ talkToPrep: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
7860
+ prepSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
7207
7861
  cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
7208
7862
  cancelPrep(input.projectId);
7209
7863
  return { ok: true };
@@ -7278,7 +7932,10 @@ function projectOverrides(ctx, projectId) {
7278
7932
  setupCommand: projects.setupCommand,
7279
7933
  verifyCommands: projects.verifyCommands,
7280
7934
  knownFailures: projects.knownFailures,
7281
- dbResetCommand: projects.dbResetCommand
7935
+ dbResetCommand: projects.dbResetCommand,
7936
+ driveSetupCommand: projects.driveSetupCommand,
7937
+ driveStopCommand: projects.driveStopCommand,
7938
+ driveEnv: projects.driveEnv
7282
7939
  }).from(projects).where(eq12(projects.id, projectId)).get();
7283
7940
  return {
7284
7941
  model: row?.model ?? null,
@@ -7287,7 +7944,10 @@ function projectOverrides(ctx, projectId) {
7287
7944
  setupCommand: row?.setupCommand ?? null,
7288
7945
  verifyCommands: row?.verifyCommands ?? null,
7289
7946
  knownFailures: row?.knownFailures ?? null,
7290
- dbResetCommand: row?.dbResetCommand ?? null
7947
+ dbResetCommand: row?.dbResetCommand ?? null,
7948
+ driveSetupCommand: row?.driveSetupCommand ?? null,
7949
+ driveStopCommand: row?.driveStopCommand ?? null,
7950
+ driveEnv: row?.driveEnv ?? null
7291
7951
  };
7292
7952
  }
7293
7953
  function resolveField(desc5, layers) {
@@ -7473,6 +8133,14 @@ var init_settings = __esm(() => {
7473
8133
  valueSchema: z10.string().min(1),
7474
8134
  parseEnv: idEnv
7475
8135
  },
8136
+ {
8137
+ key: "sessionMcp",
8138
+ configKey: "sessionMcp",
8139
+ envVar: "RUNCASTLE_SESSION_MCP",
8140
+ restartRequired: false,
8141
+ valueSchema: z10.enum(["inherit", "runcastleOnly"]),
8142
+ parseEnv: idEnv
8143
+ },
7476
8144
  {
7477
8145
  key: "burnConcurrency",
7478
8146
  configKey: "burnConcurrency",
@@ -7569,6 +8237,27 @@ var init_settings = __esm(() => {
7569
8237
  restartRequired: false,
7570
8238
  valueSchema: z10.string().min(1),
7571
8239
  parseEnv: idEnv
8240
+ },
8241
+ {
8242
+ key: "driveSetupCommand",
8243
+ projectColumn: "driveSetupCommand",
8244
+ restartRequired: false,
8245
+ valueSchema: z10.string().min(1),
8246
+ parseEnv: idEnv
8247
+ },
8248
+ {
8249
+ key: "driveStopCommand",
8250
+ projectColumn: "driveStopCommand",
8251
+ restartRequired: false,
8252
+ valueSchema: z10.string().min(1),
8253
+ parseEnv: idEnv
8254
+ },
8255
+ {
8256
+ key: "driveEnv",
8257
+ projectColumn: "driveEnv",
8258
+ restartRequired: false,
8259
+ valueSchema: z10.string().min(1),
8260
+ parseEnv: idEnv
7572
8261
  }
7573
8262
  ];
7574
8263
  DEFAULTS = RuncastleConfig.parse({});
@@ -7750,13 +8439,13 @@ var init_doctor = __esm(() => {
7750
8439
  });
7751
8440
 
7752
8441
  // src/doctor/system-exec.ts
7753
- import { spawn as spawn2 } from "child_process";
8442
+ import { spawn as spawn3 } from "child_process";
7754
8443
  function createSystemExec(opts = {}) {
7755
- return (command, args) => new Promise((resolve9) => {
8444
+ return (command, args) => new Promise((resolve10) => {
7756
8445
  const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
7757
8446
  let stdout = "";
7758
8447
  let stderr = "";
7759
- const child = spawn2(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
8448
+ const child = spawn3(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
7760
8449
  child.stdout?.on("data", (d) => {
7761
8450
  stdout += d.toString();
7762
8451
  });
@@ -7764,10 +8453,10 @@ function createSystemExec(opts = {}) {
7764
8453
  stderr += d.toString();
7765
8454
  });
7766
8455
  child.on("error", (err) => {
7767
- resolve9({ ok: false, code: null, stdout, stderr: stderr || String(err) });
8456
+ resolve10({ ok: false, code: null, stdout, stderr: stderr || String(err) });
7768
8457
  });
7769
8458
  child.on("close", (code) => {
7770
- resolve9({ ok: true, code: code ?? 0, stdout, stderr });
8459
+ resolve10({ ok: true, code: code ?? 0, stdout, stderr });
7771
8460
  });
7772
8461
  });
7773
8462
  }
@@ -8216,7 +8905,7 @@ import { Hono as Hono4 } from "hono";
8216
8905
  function buildApp(ctx) {
8217
8906
  const app = new Hono4;
8218
8907
  setRuntimeCtx(ctx);
8219
- app.get("/health", (c) => c.json({ ok: true }));
8908
+ app.get("/health", (c) => c.json({ ok: true, dataDir: dataDir() }));
8220
8909
  app.use("/api/trpc/*", async (c, next) => {
8221
8910
  await next();
8222
8911
  if (c.res.headers.get("content-type") === "application/json") {
@@ -8261,7 +8950,7 @@ async function startServer() {
8261
8950
  },
8262
8951
  websocket: terminalWebSocket
8263
8952
  });
8264
- console.log(`runcastle server listening on http://localhost:${config2.serverPort}`);
8953
+ console.log(`runcastle${process.env.RUNCASTLE_DEV ? " [dev]" : ""} server listening on ` + `http://localhost:${config2.serverPort} \u2014 data dir: ${dataDir()}`);
8265
8954
  const SHUTDOWN_KEY = Symbol.for("runcastle.shutdown.wired");
8266
8955
  const g = globalThis;
8267
8956
  if (!g[SHUTDOWN_KEY]) {