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