runcastle 1.0.25 → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/runcastle.js CHANGED
@@ -22,19 +22,38 @@ __export(exports_paths, {
22
22
  worktreeDir: () => worktreeDir,
23
23
  sessionDir: () => sessionDir,
24
24
  sandboxBuildDir: () => sandboxBuildDir,
25
+ sameDataDir: () => sameDataDir,
26
+ projectWorktreesDir: () => projectWorktreesDir,
27
+ prodDataDir: () => prodDataDir,
25
28
  logsDir: () => logsDir,
26
29
  featureDocsRel: () => featureDocsRel,
27
30
  envPath: () => envPath,
31
+ devDataDir: () => devDataDir,
28
32
  dbPath: () => dbPath,
29
33
  dataDir: () => dataDir,
30
34
  configPath: () => configPath,
31
35
  burnCacheDir: () => burnCacheDir
32
36
  });
33
37
  import { homedir } from "os";
34
- import { join } from "path";
35
- function dataDir() {
38
+ import { join, resolve } from "path";
39
+ function prodDataDir() {
36
40
  return join(homedir(), ".runcastle");
37
41
  }
42
+ function devDataDir() {
43
+ const override = process.env.RUNCASTLE_DEV_DATA_DIR;
44
+ return override ? resolve(override) : join(homedir(), ".runcastle-dev");
45
+ }
46
+ function dataDir() {
47
+ const override = process.env.RUNCASTLE_DATA_DIR;
48
+ return override ? resolve(override) : prodDataDir();
49
+ }
50
+ function sameDataDir(a, b) {
51
+ const canon = (p) => {
52
+ const abs = resolve(p).replace(/[\\/]+$/, "");
53
+ return process.platform === "win32" ? abs.toLowerCase().replace(/\//g, "\\") : abs;
54
+ };
55
+ return canon(a) === canon(b);
56
+ }
38
57
  function dbPath() {
39
58
  return join(dataDir(), "runcastle.db");
40
59
  }
@@ -50,8 +69,11 @@ function logsDir() {
50
69
  function sessionDir(id) {
51
70
  return join(dataDir(), "sessions", id);
52
71
  }
72
+ function projectWorktreesDir(projectId) {
73
+ return join(dataDir(), "worktrees", projectId);
74
+ }
53
75
  function worktreeDir(projectId, slug) {
54
- return join(dataDir(), "worktrees", projectId, slug);
76
+ return join(projectWorktreesDir(projectId), slug);
55
77
  }
56
78
  function burnCacheDir(pm) {
57
79
  return join(dataDir(), "cache", pm);
@@ -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"]);
@@ -277,10 +302,13 @@ var init_schemas = __esm(() => {
277
302
  "verifyCommands",
278
303
  "knownFailures",
279
304
  "devCommand",
305
+ "driveSetupCommand",
306
+ "driveStopCommand",
307
+ "driveEnv",
280
308
  "dbResetCommand"
281
309
  ];
282
310
  PreparedKey = z2.enum(PREPARED_KEYS);
283
- FindingSource = z2.enum(["prep", "human"]);
311
+ FindingSource = z2.enum(["prep", "human", "session"]);
284
312
  Project = z2.object({
285
313
  id: z2.string(),
286
314
  name: z2.string(),
@@ -291,7 +319,10 @@ var init_schemas = __esm(() => {
291
319
  setupCommand: z2.string().optional(),
292
320
  verifyCommands: z2.string().optional(),
293
321
  knownFailures: z2.string().optional(),
294
- dbResetCommand: z2.string().optional()
322
+ dbResetCommand: z2.string().optional(),
323
+ driveSetupCommand: z2.string().optional(),
324
+ driveStopCommand: z2.string().optional(),
325
+ driveEnv: z2.string().optional()
295
326
  });
296
327
  ProjectFinding = z2.object({
297
328
  key: PreparedKey,
@@ -326,7 +357,8 @@ var init_schemas = __esm(() => {
326
357
  });
327
358
  SessionRow = z2.object({
328
359
  id: z2.string(),
329
- featureId: z2.string(),
360
+ featureId: z2.string().optional(),
361
+ projectId: z2.string().optional(),
330
362
  kind: SessionKind,
331
363
  ccSessionId: z2.string().optional(),
332
364
  transcriptPath: z2.string().optional(),
@@ -523,7 +555,10 @@ var init_db_schema = __esm(() => {
523
555
  setupCommand: text("setup_command"),
524
556
  verifyCommands: text("verify_commands"),
525
557
  knownFailures: text("known_failures"),
526
- dbResetCommand: text("db_reset_command")
558
+ dbResetCommand: text("db_reset_command"),
559
+ driveSetupCommand: text("drive_setup_command"),
560
+ driveStopCommand: text("drive_stop_command"),
561
+ driveEnv: text("drive_env")
527
562
  });
528
563
  projectFindings = sqliteTable("project_findings", {
529
564
  projectId: text("project_id").notNull(),
@@ -557,7 +592,8 @@ var init_db_schema = __esm(() => {
557
592
  });
558
593
  sessions = sqliteTable("sessions", {
559
594
  id: text("id").primaryKey(),
560
- featureId: text("feature_id").notNull(),
595
+ featureId: text("feature_id"),
596
+ projectId: text("project_id"),
561
597
  kind: text("kind").notNull().$type(),
562
598
  ccSessionId: text("cc_session_id"),
563
599
  transcriptPath: text("transcript_path"),
@@ -669,11 +705,11 @@ var init_client = __esm(() => {
669
705
 
670
706
  // src/launcher/asset-paths.ts
671
707
  import { existsSync as existsSync2 } from "fs";
672
- import { join as join3, resolve } from "path";
708
+ import { join as join3, resolve as resolve2 } from "path";
673
709
  function resolveAsset(envVar, fallback) {
674
710
  const override = process.env[envVar];
675
711
  if (override) {
676
- const abs = resolve(override);
712
+ const abs = resolve2(override);
677
713
  if (existsSync2(abs))
678
714
  return abs;
679
715
  throw new Error(`${envVar}=${override} does not exist (resolved ${abs})`);
@@ -1353,6 +1389,13 @@ function emit(ctx, featureId, e) {
1353
1389
  function emitProject(ctx, projectId, e) {
1354
1390
  return insertEvent(ctx, projectId, null, e);
1355
1391
  }
1392
+ function emitForSession(ctx, session, e) {
1393
+ if (session.featureId)
1394
+ return emit(ctx, session.featureId, e);
1395
+ if (session.projectId)
1396
+ return emitProject(ctx, session.projectId, e);
1397
+ return null;
1398
+ }
1356
1399
  function insertEvent(ctx, projectId, featureId, e) {
1357
1400
  const row = ctx.db.insert(events).values({
1358
1401
  projectId,
@@ -1415,7 +1458,10 @@ function rowToProject(row) {
1415
1458
  setupCommand: row.setupCommand ?? undefined,
1416
1459
  verifyCommands: row.verifyCommands ?? undefined,
1417
1460
  knownFailures: row.knownFailures ?? undefined,
1418
- dbResetCommand: row.dbResetCommand ?? undefined
1461
+ dbResetCommand: row.dbResetCommand ?? undefined,
1462
+ driveSetupCommand: row.driveSetupCommand ?? undefined,
1463
+ driveStopCommand: row.driveStopCommand ?? undefined,
1464
+ driveEnv: row.driveEnv ?? undefined
1419
1465
  };
1420
1466
  }
1421
1467
  function rowToRun(row) {
@@ -1432,7 +1478,8 @@ function rowToRun(row) {
1432
1478
  function rowToSession(row) {
1433
1479
  return {
1434
1480
  id: row.id,
1435
- featureId: row.featureId,
1481
+ featureId: row.featureId ?? undefined,
1482
+ projectId: row.projectId ?? undefined,
1436
1483
  kind: row.kind,
1437
1484
  ccSessionId: row.ccSessionId ?? undefined,
1438
1485
  transcriptPath: row.transcriptPath ?? undefined,
@@ -1649,7 +1696,7 @@ function releaseForSession(ctx, claimant) {
1649
1696
  const held = ctx.db.select().from(waypoints).where(and3(eq3(waypoints.claimedBy, claimant), eq3(waypoints.status, "claimed"))).all().map(rowToWaypoint);
1650
1697
  return held.map((w) => release(ctx, w.id));
1651
1698
  }
1652
- function resolve2(ctx, id, disposition, summary) {
1699
+ function resolve3(ctx, id, disposition, summary) {
1653
1700
  const wp = getWaypoint(ctx, id);
1654
1701
  const before = listByFeature(ctx, wp.featureId);
1655
1702
  const bySeqBefore = new Map(before.map((w) => [w.seq, w]));
@@ -1692,7 +1739,8 @@ import { and as and4, desc as desc2, eq as eq4, inArray, isNotNull, sql as sql2
1692
1739
  function createSessionRow(ctx, input) {
1693
1740
  const row = ctx.db.insert(sessions).values({
1694
1741
  id: newId("sess"),
1695
- featureId: input.featureId,
1742
+ featureId: input.featureId ?? null,
1743
+ projectId: input.projectId ?? null,
1696
1744
  kind: input.kind,
1697
1745
  ccSessionId: null,
1698
1746
  transcriptPath: null,
@@ -1789,7 +1837,7 @@ function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
1789
1837
  writeKickoffSequence(d.line, {
1790
1838
  write: io.write,
1791
1839
  alive: io.alive,
1792
- onSubmitted: () => emit(ctx, session.featureId, {
1840
+ onSubmitted: () => emitForSession(ctx, session, {
1793
1841
  type: "session.kickoff",
1794
1842
  message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
1795
1843
  data: { sessionId: session.id, kind: session.kind, attempt }
@@ -1813,7 +1861,7 @@ function armConfirmation(ctx, session, d) {
1813
1861
  function settleUndelivered(ctx, session, d, reason) {
1814
1862
  d.settled = true;
1815
1863
  stopTimers(d);
1816
- emit(ctx, session.featureId, {
1864
+ emitForSession(ctx, session, {
1817
1865
  type: "session.kickoff_undelivered",
1818
1866
  message: reason === "superseded" ? `the ${session.kind} briefing was never delivered \u2014 you typed first, so runcastle stopped injecting it` : `the ${session.kind} briefing was typed ${d.attempts}\xD7 but Claude Code never acknowledged it \u2014 send it again from the session strip`,
1819
1867
  data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
@@ -1877,7 +1925,7 @@ function armSessionReadyWatchdog(ctx, session) {
1877
1925
  return;
1878
1926
  if (!ptyIo(session.id).alive())
1879
1927
  return;
1880
- emit(ctx, session.featureId, {
1928
+ emitForSession(ctx, session, {
1881
1929
  type: "session.not_ready",
1882
1930
  message: `the ${session.kind} terminal is open but Claude Code has not reported ready \u2014 check it for a prompt or dialog waiting on you, then send the briefing`,
1883
1931
  data: { sessionId: session.id, kind: session.kind }
@@ -1907,6 +1955,14 @@ function mostRecentResumableSession(ctx, featureId, kind) {
1907
1955
  const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId), ...kind ? [eq4(sessions.kind, kind)] : [])).orderBy(desc2(sql2`rowid`)).limit(1).get();
1908
1956
  return row ? rowToSession(row) : null;
1909
1957
  }
1958
+ function activeProjectSession(ctx, projectId, kind) {
1959
+ const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc2(sql2`rowid`)).limit(1).get();
1960
+ return row ? rowToSession(row) : null;
1961
+ }
1962
+ function mostRecentResumableProjectSession(ctx, projectId, kind) {
1963
+ const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc2(sql2`rowid`)).limit(1).get();
1964
+ return row ? rowToSession(row) : null;
1965
+ }
1910
1966
  function mostRecentLiveSession(ctx) {
1911
1967
  const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1912
1968
  return row ? rowToSession(row) : null;
@@ -1925,7 +1981,8 @@ var init_sessions = __esm(() => {
1925
1981
  qa: "Proceed with your task: invoke the /runcastle:qa skill and answer questions from the " + "docs and code \u2014 do not advance phases or emit tickets.",
1926
1982
  waypoint: "Proceed with your task: invoke the /runcastle:waypoint skill and work your assigned " + "waypoint to a resolution.",
1927
1983
  converge: CONVERGE_KICKOFF_LINE,
1928
- revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up."
1984
+ revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up.",
1985
+ prepare: "Proceed with your task: work through the unestablished preparation fields with the human. " + "Start by telling them which fields are still open and what you need from them for each; " + "ask before running anything that touches their database or services."
1929
1986
  };
1930
1987
  RESUME_KICKOFF_PREFIX = "We are picking this conversation back up \u2014 runcastle restarted and closed the terminal, " + "but this conversation is intact. Do NOT start over: tell me in one or two lines where we " + "left off and what is next, then carry on. Your original instruction was: ";
1931
1988
  pendingKickoffOverrides = new Map;
@@ -1943,7 +2000,7 @@ function reconcileStaleSessions(ctx) {
1943
2000
  continue;
1944
2001
  markSessionEnded(ctx, session.id);
1945
2002
  const released = releaseForSession(ctx, session.id);
1946
- emit(ctx, session.featureId, {
2003
+ emitForSession(ctx, session, {
1947
2004
  type: "session.reconciled",
1948
2005
  message: `session marked ended at boot \u2014 the server restarted while it was ${session.status}`,
1949
2006
  data: {
@@ -1967,11 +2024,11 @@ var init_reconcile = __esm(() => {
1967
2024
 
1968
2025
  // src/launcher/skills-root.ts
1969
2026
  import { existsSync as existsSync5 } from "fs";
1970
- import { dirname, join as join5, resolve as resolve3 } from "path";
2027
+ import { dirname, join as join5, resolve as resolve4 } from "path";
1971
2028
  function resolveSkillsRoot(fromDir) {
1972
2029
  const override = process.env[SKILLS_DIR_ENV];
1973
2030
  if (override) {
1974
- const root = resolve3(override);
2031
+ const root = resolve4(override);
1975
2032
  if (existsSync5(join5(root, SKILLS_MARKER)))
1976
2033
  return root;
1977
2034
  throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
@@ -2012,7 +2069,7 @@ function devSpawnTarget(devCommand) {
2012
2069
  return { file: "/bin/sh", args: ["-c", devCommand] };
2013
2070
  }
2014
2071
  function startDevPane(input) {
2015
- const { ctx, featureId, repoPath, devCommand, onUrl } = input;
2072
+ const { ctx, featureId, repoPath, devCommand, env, onUrl } = input;
2016
2073
  const paneId = drivePaneId(featureId);
2017
2074
  const { file, args } = devSpawnTarget(devCommand);
2018
2075
  try {
@@ -2020,7 +2077,7 @@ function startDevPane(input) {
2020
2077
  sessionId: paneId,
2021
2078
  cmd: file,
2022
2079
  args,
2023
- opts: { cwd: repoPath, env: process.env, cols: 80, rows: 24, useConpty: true }
2080
+ opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
2024
2081
  });
2025
2082
  } catch (err) {
2026
2083
  emit(ctx, featureId, {
@@ -2070,10 +2127,179 @@ var init_dev_pane = __esm(() => {
2070
2127
  init_registry();
2071
2128
  });
2072
2129
 
2130
+ // src/services/drive-hooks.ts
2131
+ import { spawn as spawn2 } from "child_process";
2132
+ function tailLines(text2, max = OUTPUT_TAIL_LINES) {
2133
+ const lines = text2.replace(/\r\n/g, `
2134
+ `).split(`
2135
+ `);
2136
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
2137
+ lines.pop();
2138
+ return lines.slice(-max).join(`
2139
+ `).trim();
2140
+ }
2141
+ function hookSpawnTarget(command) {
2142
+ if (process.platform === "win32") {
2143
+ return {
2144
+ file: process.env.ComSpec ?? "cmd.exe",
2145
+ args: ["/d", "/s", "/c", `"${command}"`],
2146
+ verbatim: true
2147
+ };
2148
+ }
2149
+ return { file: "/bin/sh", args: ["-c", command], verbatim: false };
2150
+ }
2151
+ function runDriveHook(command, opts) {
2152
+ const spawnFn = opts.spawnFn ?? spawn2;
2153
+ const now = opts.now ?? Date.now;
2154
+ const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
2155
+ const startedAt = now();
2156
+ const { file, args, verbatim } = hookSpawnTarget(command);
2157
+ return new Promise((resolve5) => {
2158
+ let output = "";
2159
+ let timedOut = false;
2160
+ let settled = false;
2161
+ let timer;
2162
+ const finish = (exitCode) => {
2163
+ if (settled)
2164
+ return;
2165
+ settled = true;
2166
+ if (timer)
2167
+ clearTimeout(timer);
2168
+ resolve5({
2169
+ ok: !timedOut && exitCode === 0,
2170
+ exitCode,
2171
+ timedOut,
2172
+ output: tailLines(output),
2173
+ durationMs: now() - startedAt
2174
+ });
2175
+ };
2176
+ let child;
2177
+ try {
2178
+ child = spawnFn(file, args, {
2179
+ cwd: opts.cwd,
2180
+ env: opts.env ?? process.env,
2181
+ windowsHide: true,
2182
+ windowsVerbatimArguments: verbatim
2183
+ });
2184
+ } catch (err) {
2185
+ output = err instanceof Error ? err.message : String(err);
2186
+ finish(null);
2187
+ return;
2188
+ }
2189
+ timer = setTimeout(() => {
2190
+ timedOut = true;
2191
+ child.kill();
2192
+ const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
2193
+ grace.unref?.();
2194
+ }, timeoutMs);
2195
+ timer.unref?.();
2196
+ child.stdout?.on("data", (d) => {
2197
+ output += d.toString();
2198
+ });
2199
+ child.stderr?.on("data", (d) => {
2200
+ output += d.toString();
2201
+ });
2202
+ child.on("error", (err) => {
2203
+ output += `
2204
+ ${err instanceof Error ? err.message : String(err)}`;
2205
+ finish(null);
2206
+ });
2207
+ child.on("close", (code) => {
2208
+ finish(code);
2209
+ });
2210
+ });
2211
+ }
2212
+ function describeHookResult(command, result) {
2213
+ const secs = Math.max(1, Math.round(result.durationMs / 1000));
2214
+ if (result.timedOut)
2215
+ return `\`${command}\` timed out after ${secs}s`;
2216
+ if (result.ok)
2217
+ return `\`${command}\` finished in ${secs}s`;
2218
+ return `\`${command}\` exited ${result.exitCode ?? "without a code"} after ${secs}s`;
2219
+ }
2220
+ var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
2221
+ var init_drive_hooks = __esm(() => {
2222
+ DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
2223
+ });
2224
+
2225
+ // src/services/drive-env.ts
2226
+ import { createHash } from "crypto";
2227
+ function identifierSafe(slug, max = MAX_ID_LENGTH) {
2228
+ const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2229
+ const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
2230
+ if (safe.length <= max)
2231
+ return safe;
2232
+ const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
2233
+ return `${safe.slice(0, max - digest.length - 1)}_${digest}`;
2234
+ }
2235
+ function driveVars(identity) {
2236
+ return {
2237
+ slug: identity.slug,
2238
+ branch: identity.branch,
2239
+ id: identifierSafe(identity.slug)
2240
+ };
2241
+ }
2242
+ function renderTemplate(value, vars) {
2243
+ const unknown2 = [];
2244
+ const out = value.replace(PLACEHOLDER_RE, (match, name) => {
2245
+ const replacement = vars[name];
2246
+ if (replacement === undefined) {
2247
+ if (!unknown2.includes(name))
2248
+ unknown2.push(name);
2249
+ return match;
2250
+ }
2251
+ return replacement;
2252
+ });
2253
+ return { value: out, unknown: unknown2 };
2254
+ }
2255
+ function parseDriveEnv(raw, identity) {
2256
+ const vars = {};
2257
+ const unknown2 = [];
2258
+ if (!raw?.trim())
2259
+ return { vars, unknown: unknown2 };
2260
+ const substitutions = driveVars(identity);
2261
+ for (const line of raw.split(/\r?\n/)) {
2262
+ const trimmed = line.trim();
2263
+ if (trimmed === "" || trimmed.startsWith("#"))
2264
+ continue;
2265
+ const eq5 = trimmed.indexOf("=");
2266
+ if (eq5 <= 0)
2267
+ continue;
2268
+ const key = trimmed.slice(0, eq5).trim().replace(/^export\s+/, "");
2269
+ if (key === "")
2270
+ continue;
2271
+ const rendered = renderTemplate(unquote(trimmed.slice(eq5 + 1).trim()), substitutions);
2272
+ vars[key] = rendered.value;
2273
+ for (const name of rendered.unknown)
2274
+ if (!unknown2.includes(name))
2275
+ unknown2.push(name);
2276
+ }
2277
+ return { vars, unknown: unknown2 };
2278
+ }
2279
+ function unquote(value) {
2280
+ if (value.length < 2)
2281
+ return value;
2282
+ const first = value[0];
2283
+ if ((first === '"' || first === "'") && value.endsWith(first))
2284
+ return value.slice(1, -1);
2285
+ return value;
2286
+ }
2287
+ function driveProcessEnv(overrides, base = process.env) {
2288
+ return { ...base, ...overrides };
2289
+ }
2290
+ function describeDriveEnv(vars) {
2291
+ const keys = Object.keys(vars);
2292
+ return `drive environment: ${keys.join(", ")}`;
2293
+ }
2294
+ var MAX_ID_LENGTH = 40, PLACEHOLDER_RE;
2295
+ var init_drive_env = __esm(() => {
2296
+ PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
2297
+ });
2298
+
2073
2299
  // src/services/git.ts
2074
2300
  import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2075
2301
  import { tmpdir } from "os";
2076
- import { dirname as dirname2, join as join6, resolve as resolve4 } from "path";
2302
+ import { dirname as dirname2, join as join6, resolve as resolve5 } from "path";
2077
2303
  import { simpleGit } from "simple-git";
2078
2304
  function migrationPaths(paths) {
2079
2305
  return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
@@ -2088,7 +2314,7 @@ function errMsg(e) {
2088
2314
  return e instanceof Error ? e.message : String(e);
2089
2315
  }
2090
2316
  function canon(p) {
2091
- let abs = resolve4(p);
2317
+ let abs = resolve5(p);
2092
2318
  try {
2093
2319
  abs = realpathSync.native(abs);
2094
2320
  } catch {}
@@ -2583,7 +2809,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2583
2809
  return { deleted, kept };
2584
2810
  }
2585
2811
  async function commitDocs(worktreePath, message) {
2586
- if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
2812
+ if (!existsSync6(resolve5(worktreePath, DOCS_PATHSPEC)))
2587
2813
  return;
2588
2814
  const g = git(worktreePath);
2589
2815
  await g.add([DOCS_PATHSPEC]);
@@ -2628,6 +2854,7 @@ async function testDrive(ctx, project, feature, action) {
2628
2854
  const devPaneId = testDriveState.devPaneId;
2629
2855
  if (devPaneId)
2630
2856
  stopDevPane(devPaneId);
2857
+ const teardownFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "teardown", project.driveStopCommand, driveEnvFor(ctx, project, feature, branch));
2631
2858
  const carriedChanges = await dirtyPaths(g);
2632
2859
  await g.checkout(previousBranch2);
2633
2860
  if (detachedWorktree2)
@@ -2650,7 +2877,8 @@ async function testDrive(ctx, project, feature, action) {
2650
2877
  ok: true,
2651
2878
  branch: previousBranch2,
2652
2879
  ...carriedChanges.length > 0 ? { carriedChanges } : {},
2653
- ...dbDrift ? { dbDrift } : {}
2880
+ ...dbDrift ? { dbDrift } : {},
2881
+ ...teardownFailure ? { hookFailure: teardownFailure } : {}
2654
2882
  };
2655
2883
  }
2656
2884
  const porcelain = (await g.raw(["status", "--porcelain"])).trim();
@@ -2675,18 +2903,76 @@ async function testDrive(ctx, project, feature, action) {
2675
2903
  message: `test driving ${branch} (was on ${previousBranch})`,
2676
2904
  data: { branch, previousBranch }
2677
2905
  });
2906
+ const driveEnv = driveEnvFor(ctx, project, feature, branch);
2907
+ const setupFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "setup", project.driveSetupCommand, driveEnv);
2678
2908
  if (project.devCommand) {
2679
2909
  const devPaneId = startDevPane({
2680
2910
  ctx,
2681
2911
  featureId: feature.id,
2682
2912
  repoPath: project.repoPath,
2683
2913
  devCommand: project.devCommand,
2914
+ env: driveEnv,
2684
2915
  onUrl: (url) => recordDriveUrl(ctx, feature.id, url)
2685
2916
  });
2686
2917
  if (devPaneId)
2687
2918
  testDriveState.devPaneId = devPaneId;
2688
2919
  }
2689
- return { ok: true, branch };
2920
+ return { ok: true, branch, ...setupFailure ? { hookFailure: setupFailure } : {} };
2921
+ }
2922
+ function driveEnvFor(ctx, project, feature, branch) {
2923
+ const { vars, unknown: unknown2 } = parseDriveEnv(project.driveEnv, { slug: feature.slug, branch });
2924
+ if (unknown2.length > 0) {
2925
+ emit(ctx, feature.id, {
2926
+ type: "testdrive.env_unknown_placeholder",
2927
+ message: `drive environment left ${unknown2.map((u) => `{{${u}}}`).join(", ")} unsubstituted \u2014 known variables are {{slug}}, {{branch}}, {{id}}`,
2928
+ data: { unknown: unknown2 }
2929
+ });
2930
+ }
2931
+ if (Object.keys(vars).length > 0) {
2932
+ emit(ctx, feature.id, {
2933
+ type: "testdrive.env",
2934
+ message: describeDriveEnv(vars),
2935
+ data: { keys: Object.keys(vars) }
2936
+ });
2937
+ }
2938
+ return driveProcessEnv(vars);
2939
+ }
2940
+ async function runDriveHookStep(ctx, featureId, repoPath, phase, command, env) {
2941
+ const cmd = command?.trim();
2942
+ if (!cmd)
2943
+ return;
2944
+ emit(ctx, featureId, {
2945
+ type: `testdrive.${phase}_started`,
2946
+ message: phase === "setup" ? `preparing environment: \`${cmd}\`` : `tearing down environment: \`${cmd}\``,
2947
+ data: { command: cmd }
2948
+ });
2949
+ const result = await runDriveHook(cmd, { cwd: repoPath, env });
2950
+ if (result.ok) {
2951
+ emit(ctx, featureId, {
2952
+ type: `testdrive.${phase}_ok`,
2953
+ message: describeHookResult(cmd, result),
2954
+ data: { command: cmd, durationMs: result.durationMs }
2955
+ });
2956
+ return;
2957
+ }
2958
+ emit(ctx, featureId, {
2959
+ type: `testdrive.${phase}_failed`,
2960
+ message: `${describeHookResult(cmd, result)}${result.output ? `
2961
+ ${result.output}` : ""}`,
2962
+ data: {
2963
+ command: cmd,
2964
+ exitCode: result.exitCode,
2965
+ timedOut: result.timedOut,
2966
+ output: result.output
2967
+ }
2968
+ });
2969
+ return {
2970
+ phase,
2971
+ command: cmd,
2972
+ exitCode: result.exitCode,
2973
+ timedOut: result.timedOut,
2974
+ output: result.output
2975
+ };
2690
2976
  }
2691
2977
  async function dirtyPaths(g) {
2692
2978
  try {
@@ -2777,6 +3063,8 @@ var init_git = __esm(() => {
2777
3063
  init_paths();
2778
3064
  init_errors();
2779
3065
  init_dev_pane();
3066
+ init_drive_hooks();
3067
+ init_drive_env();
2780
3068
  init_events();
2781
3069
  init_repo();
2782
3070
  MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
@@ -2962,7 +3250,7 @@ function detectCycle(tickets2) {
2962
3250
  }
2963
3251
  return null;
2964
3252
  }
2965
- function renderTemplate(template, values) {
3253
+ function renderTemplate2(template, values) {
2966
3254
  let out = template;
2967
3255
  for (const [key, value] of Object.entries(values)) {
2968
3256
  out = out.split(`{{${key}}}`).join(value);
@@ -2970,7 +3258,7 @@ function renderTemplate(template, values) {
2970
3258
  return out;
2971
3259
  }
2972
3260
  function renderTicketPrompt(template, values) {
2973
- return renderTemplate(template, values);
3261
+ return renderTemplate2(template, values);
2974
3262
  }
2975
3263
  function buildTicketJson(ticket) {
2976
3264
  return JSON.stringify({
@@ -3058,6 +3346,9 @@ function detectPackageManager(tc) {
3058
3346
  return "npm";
3059
3347
  return tc.hasPackageJson ? "npm" : undefined;
3060
3348
  }
3349
+ function orFallback(strict, permissive) {
3350
+ return `( ${strict} || ${permissive} )`;
3351
+ }
3061
3352
  function resolveSetupCommand(tc, override) {
3062
3353
  const trimmed = override?.trim();
3063
3354
  if (trimmed)
@@ -3067,13 +3358,13 @@ function resolveSetupCommand(tc, override) {
3067
3358
  return;
3068
3359
  switch (pm) {
3069
3360
  case "bun":
3070
- return tc.lockfiles.bun ? "bun install --frozen-lockfile" : "bun install";
3361
+ return tc.lockfiles.bun ? orFallback("bun install --frozen-lockfile", "bun install") : "bun install";
3071
3362
  case "pnpm":
3072
- return tc.lockfiles.pnpm ? "corepack pnpm install --frozen-lockfile" : "corepack pnpm install";
3363
+ return tc.lockfiles.pnpm ? orFallback("corepack pnpm install --frozen-lockfile", "corepack pnpm install") : "corepack pnpm install";
3073
3364
  case "yarn":
3074
- return tc.lockfiles.yarn ? "corepack yarn install --frozen-lockfile" : "corepack yarn install";
3365
+ return tc.lockfiles.yarn ? orFallback("corepack yarn install --frozen-lockfile", "corepack yarn install") : "corepack yarn install";
3075
3366
  case "npm":
3076
- return tc.lockfiles.npm ? "npm ci" : "npm install";
3367
+ return tc.lockfiles.npm ? orFallback("npm ci", "npm install") : "npm install";
3077
3368
  }
3078
3369
  }
3079
3370
  function cacheMountFor(pm, hostPath) {
@@ -3157,7 +3448,7 @@ function readRepoToolchain(repoPath) {
3157
3448
  }
3158
3449
  };
3159
3450
  }
3160
- function createStreamThrottle(emit2, opts = {}) {
3451
+ function createStreamThrottle(emit3, opts = {}) {
3161
3452
  const intervalMs = opts.intervalMs ?? 2000;
3162
3453
  const maxChars = opts.maxChars ?? 500;
3163
3454
  const now = opts.now ?? Date.now;
@@ -3167,7 +3458,7 @@ function createStreamThrottle(emit2, opts = {}) {
3167
3458
  const flushText = () => {
3168
3459
  if (buffer.length === 0)
3169
3460
  return;
3170
- emit2({ type: "burn.text", message: buffer, data: { iteration: lastIteration } });
3461
+ emit3({ type: "burn.text", message: buffer, data: { iteration: lastIteration } });
3171
3462
  buffer = "";
3172
3463
  lastFlush = now();
3173
3464
  };
@@ -3182,7 +3473,7 @@ function createStreamThrottle(emit2, opts = {}) {
3182
3473
  flushText();
3183
3474
  const rawArgs = event.formattedArgs ?? "";
3184
3475
  const args = rawArgs.length > 200 ? `${rawArgs.slice(0, 200)}\u2026` : rawArgs;
3185
- emit2({
3476
+ emit3({
3186
3477
  type: "burn.tool",
3187
3478
  message: args.length > 0 ? `${event.name} ${args}` : event.name,
3188
3479
  data: { name: event.name, args: rawArgs, iteration: event.iteration }
@@ -3294,18 +3585,18 @@ function buildRetryNotes(input) {
3294
3585
  `);
3295
3586
  }
3296
3587
  function delayUnlessAborted(ms, signal) {
3297
- return new Promise((resolve5) => {
3588
+ return new Promise((resolve6) => {
3298
3589
  if (signal.aborted) {
3299
- resolve5();
3590
+ resolve6();
3300
3591
  return;
3301
3592
  }
3302
3593
  const onAbort = () => {
3303
3594
  clearTimeout(t);
3304
- resolve5();
3595
+ resolve6();
3305
3596
  };
3306
3597
  const t = setTimeout(() => {
3307
3598
  signal.removeEventListener("abort", onAbort);
3308
- resolve5();
3599
+ resolve6();
3309
3600
  }, ms);
3310
3601
  signal.addEventListener("abort", onAbort, { once: true });
3311
3602
  });
@@ -3659,7 +3950,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
3659
3950
  const runResolver = async (input) => {
3660
3951
  const resolveBranch = ticketBranchName(feature.slug, ticket.seq, newId("r").slice(2, 10));
3661
3952
  const otherSide = await commitSummaries(project.repoPath, input.branch, feature.branch);
3662
- const prompt = renderTemplate(readFileSync3(resolverTemplatePath(), "utf8"), {
3953
+ const prompt = renderTemplate2(readFileSync3(resolverTemplatePath(), "utf8"), {
3663
3954
  TICKET_JSON: ticketJson,
3664
3955
  FEATURE_BRIEF: featureBrief,
3665
3956
  DOCS_DIGEST: docsDigest,
@@ -4139,8 +4430,11 @@ function errorHeadline2(s) {
4139
4430
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4140
4431
  return causes.at(-1) ?? lines[0] ?? "";
4141
4432
  }
4142
- function createPrepStreamThrottle(emit2, opts = {}) {
4143
- return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
4433
+ function createPrepStreamThrottle(emit3, opts = {}) {
4434
+ return createStreamThrottle((e) => emit3({ ...e, type: e.type.replace(/^burn\./, "prep.") }), opts);
4435
+ }
4436
+ function nonFatalSetup(setupCommand) {
4437
+ return `( ${setupCommand} || true )`;
4144
4438
  }
4145
4439
  function prepTemplatePath() {
4146
4440
  const here = dirname4(fileURLToPath3(import.meta.url));
@@ -4173,10 +4467,11 @@ async function realExecutePrepRun(ctx, config2, token, model) {
4173
4467
  if (setupCommand) {
4174
4468
  ctx.emitEvent({
4175
4469
  type: "prep.setup",
4176
- message: `installing deps before the agent starts: ${setupCommand}`
4470
+ message: `attempting a dependency install before the agent starts (best-effort): ${setupCommand}`
4177
4471
  });
4178
4472
  }
4179
- const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, setupCommand, pm) : setupCommand;
4473
+ const prepSetupCommand = setupCommand ? nonFatalSetup(setupCommand) : undefined;
4474
+ const hookCommand = workspaceMode === "isolated" ? buildIsolatedSetupCommand(branch, prepSetupCommand, pm) : prepSetupCommand;
4180
4475
  const runOptions = {
4181
4476
  agent: buildBurnAgent(config2, token, model),
4182
4477
  sandbox: selectSandbox(config2, mounts),
@@ -4276,6 +4571,9 @@ var init_project_prep = __esm(() => {
4276
4571
  verifyCommands: "**`verifyCommands`** \u2014 the exact typecheck / test / lint commands for this repo, one per line. In a monorepo these must include the correct workspace filter names. Run each one and confirm it executes the thing it claims to.",
4277
4572
  knownFailures: '**`knownFailures`** \u2014 which tests are ALREADY failing on this checkout, before anything is changed. Run the full suite once and report the count plus the failing suite names. "0 known failures" is a real, useful answer.',
4278
4573
  devCommand: "**`devCommand`** \u2014 the command that starts this project's dev server on a developer's own machine. Read it from config; do not run it here.",
4574
+ driveSetupCommand: "**`driveSetupCommand`** \u2014 a single shell command that takes this repo from a fresh checkout to \"the dev server would actually work\": backing services up, schema applied, whatever this project needs. Chain steps with `&&`. Read it from the repo's own config (compose file, Makefile, package scripts, README); do not run it here. Omit it if the dev command alone is enough \u2014 an empty answer is better than a guessed one, because this runs on a developer's real machine.",
4575
+ driveStopCommand: "**`driveStopCommand`** \u2014 the matching teardown for `driveSetupCommand`, if this project has one (`docker compose down`, a stop script). Read it from config; do not run it here. Omit it if setup leaves nothing that needs stopping.",
4576
+ driveEnv: "**`driveEnv`** \u2014 `KEY=VALUE` lines (one per line) overlaid on the dev server's environment during a test drive, with `{{slug}}`, `{{branch}}` and `{{id}}` (an identifier-safe slug) rendered per drive. Its purpose is giving a branch its own database, e.g. `DATABASE_URL=postgres://localhost:5432/myapp_{{id}}` paired with a `driveSetupCommand` that creates it. Report it ONLY if you can read the real connection variable's name and shape out of the repo's own config or `.env.example`; a connection string you assembled from defaults points at the wrong database convincingly, which is worse than omitting the key. Omit it if the project has no database or you had to guess the host, port or credentials.",
4279
4577
  dbResetCommand: "**`dbResetCommand`** \u2014 the command that rebuilds the dev database from the migrations currently in the working tree. Read it from config; do not run it here. Omit it entirely if this repo has no database."
4280
4578
  };
4281
4579
  });
@@ -4382,6 +4680,9 @@ var init_findings = __esm(() => {
4382
4680
  verifyCommands: projects.verifyCommands,
4383
4681
  knownFailures: projects.knownFailures,
4384
4682
  devCommand: projects.devCommand,
4683
+ driveSetupCommand: projects.driveSetupCommand,
4684
+ driveStopCommand: projects.driveStopCommand,
4685
+ driveEnv: projects.driveEnv,
4385
4686
  dbResetCommand: projects.dbResetCommand
4386
4687
  };
4387
4688
  COLUMN_NAME = {
@@ -4389,13 +4690,16 @@ var init_findings = __esm(() => {
4389
4690
  verifyCommands: "verifyCommands",
4390
4691
  knownFailures: "knownFailures",
4391
4692
  devCommand: "devCommand",
4693
+ driveSetupCommand: "driveSetupCommand",
4694
+ driveStopCommand: "driveStopCommand",
4695
+ driveEnv: "driveEnv",
4392
4696
  dbResetCommand: "dbResetCommand"
4393
4697
  };
4394
4698
  PREPARED_SET = new Set(PREPARED_KEYS);
4395
4699
  });
4396
4700
 
4397
4701
  // src/services/prep.ts
4398
- import { desc as desc3, eq as eq6 } from "drizzle-orm";
4702
+ import { and as and6, desc as desc3, eq as eq6 } from "drizzle-orm";
4399
4703
  function isPreparing(projectId) {
4400
4704
  return controllers.has(projectId);
4401
4705
  }
@@ -4404,6 +4708,13 @@ function keysToPrepare(ctx, project, opts = {}) {
4404
4708
  const candidates = opts.keys ?? (opts.refresh ? PREPARED_KEYS : [...unset]);
4405
4709
  return candidates.filter((key) => (opts.refresh || unset.has(key)) && isOverwritable(ctx, project.id, key));
4406
4710
  }
4711
+ function shouldAutoPrepare(ctx, project) {
4712
+ if (!ctx.config.autoPrepare)
4713
+ return false;
4714
+ if (latestPrep(ctx, project.id))
4715
+ return false;
4716
+ return keysToPrepare(ctx, project).length > 0;
4717
+ }
4407
4718
  async function startPrep(ctx, projectId, opts = {}) {
4408
4719
  const project = requireProjectById(ctx, projectId);
4409
4720
  if (controllers.has(projectId)) {
@@ -4523,6 +4834,11 @@ function latestPrep(ctx, projectId) {
4523
4834
  ...row.headSha !== null ? { headSha: row.headSha } : {}
4524
4835
  };
4525
4836
  }
4837
+ function latestPrepNotes(ctx, projectId) {
4838
+ const row = ctx.db.select({ data: events.data }).from(events).where(and6(eq6(events.projectId, projectId), eq6(events.type, "prep.notes"))).orderBy(desc3(events.id)).get();
4839
+ const notes = row?.data?.notes;
4840
+ return typeof notes === "string" && notes.trim() !== "" ? notes : undefined;
4841
+ }
4526
4842
  function reconcilePreps(ctx) {
4527
4843
  const stale = ctx.db.select({ id: projectPreps.id, projectId: projectPreps.projectId }).from(projectPreps).where(eq6(projectPreps.status, "running")).all().filter((row) => !controllers.has(row.projectId));
4528
4844
  for (const row of stale) {
@@ -4577,7 +4893,7 @@ var init_feature_docs = __esm(() => {
4577
4893
  });
4578
4894
 
4579
4895
  // src/services/tickets.ts
4580
- import { and as and6, asc as asc3, eq as eq7 } from "drizzle-orm";
4896
+ import { and as and7, asc as asc3, eq as eq7 } from "drizzle-orm";
4581
4897
  function rowToTicket(row) {
4582
4898
  return {
4583
4899
  id: row.id,
@@ -4688,7 +5004,7 @@ function cancelTicket(ctx, id, reason) {
4688
5004
  return getTicket(ctx, id);
4689
5005
  }
4690
5006
  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);
5007
+ const orphaned = ctx.db.select().from(tickets).where(and7(eq7(tickets.featureId, featureId), eq7(tickets.status, "burning"))).all().map(rowToTicket);
4692
5008
  for (const t of orphaned) {
4693
5009
  updateTicket(ctx, t.id, { status: "failed", error: reason });
4694
5010
  emit(ctx, featureId, {
@@ -4811,7 +5127,7 @@ var init_gates = __esm(() => {
4811
5127
 
4812
5128
  // src/services/knowledge.ts
4813
5129
  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";
5130
+ import { join as join10, relative, resolve as resolve6, sep } from "path";
4815
5131
  function scaffoldDocs(ctx, feature) {
4816
5132
  const project = projectForFeature(ctx, feature);
4817
5133
  const dir = featureDocsDir(project, feature);
@@ -4871,8 +5187,8 @@ function listDocs(ctx, feature) {
4871
5187
  function readDoc(ctx, feature, relPath) {
4872
5188
  const project = projectForFeature(ctx, feature);
4873
5189
  const dir = featureDocsDir(project, feature);
4874
- const root = resolve5(dir);
4875
- const target = resolve5(dir, relPath);
5190
+ const root = resolve6(dir);
5191
+ const target = resolve6(dir, relPath);
4876
5192
  const rel = relative(root, target);
4877
5193
  if (rel === "" || rel.startsWith("..") || rel.split(sep).includes("..")) {
4878
5194
  throw new InvalidInputError(`doc path escapes the feature docs dir: ${relPath}`);
@@ -4913,7 +5229,7 @@ function endSession(ctx, sessionId) {
4913
5229
  const session = markSessionEnded(ctx, sessionId);
4914
5230
  if (session) {
4915
5231
  releaseForSession(ctx, sessionId);
4916
- emit(ctx, session.featureId, {
5232
+ emitForSession(ctx, session, {
4917
5233
  type: "session.ended",
4918
5234
  message: "session ended by user",
4919
5235
  data: { sessionId, killed }
@@ -4985,8 +5301,8 @@ function errorHeadline3(s) {
4985
5301
  const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
4986
5302
  return causes.at(-1) ?? lines[0] ?? "";
4987
5303
  }
4988
- function createResearchStreamThrottle(emit2, opts = {}) {
4989
- return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
5304
+ function createResearchStreamThrottle(emit3, opts = {}) {
5305
+ return createStreamThrottle((e) => emit3({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
4990
5306
  }
4991
5307
  function renderResearchPrompt(template, values) {
4992
5308
  let out = template;
@@ -5194,7 +5510,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
5194
5510
  input: opts.input,
5195
5511
  modelOverride: opts.modelOverride,
5196
5512
  resolveWaypoint: (id, disposition, summary) => {
5197
- resolve2(ctx, id, disposition, summary);
5513
+ resolve3(ctx, id, disposition, summary);
5198
5514
  },
5199
5515
  signal: controller.signal
5200
5516
  };
@@ -5619,8 +5935,14 @@ function resolveSession(ctx, sessionId) {
5619
5935
  }
5620
5936
  return mostRecentLiveSession(ctx);
5621
5937
  }
5938
+ function requireFeatureId(session) {
5939
+ if (!session.featureId) {
5940
+ throw new GateError(`this tool needs a feature, and a ${session.kind} session is project-scoped. ` + "Use record_finding to store what you establish about the project.");
5941
+ }
5942
+ return session.featureId;
5943
+ }
5622
5944
  function toolGetFeatureContext(ctx, session) {
5623
- const feature = getFeatureRow(ctx, session.featureId);
5945
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
5624
5946
  const docs = listDocs(ctx, feature).map((d) => {
5625
5947
  try {
5626
5948
  return { relPath: d.relPath, content: readDoc(ctx, feature, d.relPath).content };
@@ -5642,17 +5964,17 @@ function toolGetFeatureContext(ctx, session) {
5642
5964
  return context;
5643
5965
  }
5644
5966
  function toolResolveWaypoint(ctx, _session, input) {
5645
- resolve2(ctx, input.id, input.disposition, input.summary);
5967
+ resolve3(ctx, input.id, input.disposition, input.summary);
5646
5968
  return { ok: true };
5647
5969
  }
5648
5970
  function toolEmitTickets(ctx, session, input) {
5649
- const feature = getFeatureRow(ctx, session.featureId);
5971
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
5650
5972
  const stored = storeTickets(ctx, feature.id, input.tickets);
5651
5973
  return { stored: stored.length, ids: stored.map((t) => t.id) };
5652
5974
  }
5653
5975
  function requireOwnTicket(ctx, session, ticketId) {
5654
5976
  const ticket = getTicket(ctx, ticketId);
5655
- if (ticket.featureId !== session.featureId) {
5977
+ if (ticket.featureId !== requireFeatureId(session)) {
5656
5978
  throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
5657
5979
  }
5658
5980
  return ticket;
@@ -5667,10 +5989,10 @@ function toolCancelTicket(ctx, session, input) {
5667
5989
  return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
5668
5990
  }
5669
5991
  function toolEscalateToMap(ctx, session, input) {
5670
- return escalateToMap(ctx, session.featureId, input);
5992
+ return escalateToMap(ctx, requireFeatureId(session), input);
5671
5993
  }
5672
5994
  function toolEmitWaypoints(ctx, session, input) {
5673
- const feature = getFeatureRow(ctx, session.featureId);
5995
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
5674
5996
  if (!feature.mapped) {
5675
5997
  throw new GateError("feature is not mapped \u2014 call escalate_to_map before emitting waypoints");
5676
5998
  }
@@ -5678,7 +6000,7 @@ function toolEmitWaypoints(ctx, session, input) {
5678
6000
  return { stored: stored.length, ids: stored.map((w) => w.id) };
5679
6001
  }
5680
6002
  function toolRecordEvent(ctx, session, input) {
5681
- emit(ctx, session.featureId, {
6003
+ emit(ctx, requireFeatureId(session), {
5682
6004
  type: input.type,
5683
6005
  message: input.message,
5684
6006
  data: { source: "mcp" }
@@ -5686,7 +6008,7 @@ function toolRecordEvent(ctx, session, input) {
5686
6008
  return { ok: true };
5687
6009
  }
5688
6010
  function toolCompletePhase(ctx, session, input) {
5689
- const feature = getFeatureRow(ctx, session.featureId);
6011
+ const feature = getFeatureRow(ctx, requireFeatureId(session));
5690
6012
  emit(ctx, feature.id, {
5691
6013
  type: "phase.complete_requested",
5692
6014
  message: `session marked phase '${input.phase}' complete`,
@@ -5715,13 +6037,46 @@ async function commitDocsCheckpoint(ctx, session, message) {
5715
6037
  try {
5716
6038
  await commitDocs(session.worktreePath, message);
5717
6039
  } catch (e) {
5718
- emit(ctx, session.featureId, {
6040
+ emit(ctx, requireFeatureId(session), {
5719
6041
  type: "git.commit_pending",
5720
6042
  message: isNotImplemented(e) ? "docs checkpoint skipped (git service pending)" : `docs checkpoint failed: ${e instanceof Error ? e.message : String(e)}`,
5721
6043
  data: { message }
5722
6044
  });
5723
6045
  }
5724
6046
  }
6047
+ function requireProject(ctx, session) {
6048
+ if (!session.projectId) {
6049
+ throw new GateError(`record_finding is for a project-scoped preparation session; this is a ${session.kind} session.`);
6050
+ }
6051
+ const project = getProjectById(ctx, session.projectId);
6052
+ if (!project)
6053
+ throw new GateError(`project ${session.projectId} not found`);
6054
+ return project;
6055
+ }
6056
+ function toolRecordFinding(ctx, session, input) {
6057
+ const project = requireProject(ctx, session);
6058
+ const source = input.userSupplied ? "human" : "session";
6059
+ if (!isOverwritable(ctx, project.id, input.key) && !input.userSupplied) {
6060
+ return {
6061
+ ok: true,
6062
+ key: input.key,
6063
+ source,
6064
+ skipped: `${input.key} was set by a human and is not auto-overwritable \u2014 ask them to clear it first if it should change`
6065
+ };
6066
+ }
6067
+ recordFinding(ctx, project.id, {
6068
+ key: input.key,
6069
+ value: input.value,
6070
+ source,
6071
+ ...input.evidence ? { evidence: input.evidence } : {}
6072
+ });
6073
+ emitProject(ctx, project.id, {
6074
+ type: "prep.finding_recorded",
6075
+ message: `preparation session established ${input.key} (${source})`,
6076
+ data: { key: input.key, source, sessionId: session.id }
6077
+ });
6078
+ return { ok: true, key: input.key, source };
6079
+ }
5725
6080
  function ok(result) {
5726
6081
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
5727
6082
  }
@@ -5743,6 +6098,21 @@ async function resolveCtxSession(extra) {
5743
6098
  }
5744
6099
  function buildMcpServer() {
5745
6100
  const server = new McpServer({ name: "runcastle", version: "0.1.0" });
6101
+ server.registerTool("record_finding", {
6102
+ title: "Record a preparation finding",
6103
+ description: "Store one established project setting from a preparation conversation. " + "Set userSupplied: true ONLY when the human gave you this value or confirmed it verbatim \u2014 " + "that marks it as theirs and stops any future automatic run from overwriting it. " + "Anything you worked out yourself must leave it false, even if they were watching. " + "Always include evidence: what you ran, or what the human told you.",
6104
+ inputSchema: {
6105
+ key: PreparedKey,
6106
+ value: z4.string(),
6107
+ evidence: z4.string().optional(),
6108
+ userSupplied: z4.boolean().optional()
6109
+ }
6110
+ }, async (args, extra) => {
6111
+ const rs = await resolveCtxSession(extra);
6112
+ if (!rs)
6113
+ return noSession();
6114
+ return ok(toolRecordFinding(rs.ctx, rs.session, args));
6115
+ });
5746
6116
  server.registerTool("get_feature_context", {
5747
6117
  title: "Get feature context",
5748
6118
  description: "Full context for the current feature: the feature row, its phase, all docs/features/<slug>/*.md contents, and its tickets.",
@@ -5865,6 +6235,7 @@ var init_server = __esm(() => {
5865
6235
  init_sessions();
5866
6236
  init_features();
5867
6237
  init_events();
6238
+ init_findings();
5868
6239
  init_git();
5869
6240
  init_knowledge();
5870
6241
  init_repo();
@@ -5975,6 +6346,57 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
5975
6346
  }
5976
6347
  };
5977
6348
  }
6349
+ function handlePrepareSessionStart(ctx, session, payload) {
6350
+ const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
6351
+ const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
6352
+ const source = typeof payload?.source === "string" ? payload.source : undefined;
6353
+ const wasLive = getSessionRow(ctx, session.id)?.status === "live";
6354
+ markSessionLive(ctx, session.id, { ccSessionId, transcriptPath });
6355
+ if (!wasLive) {
6356
+ emitForSession(ctx, session, {
6357
+ type: "session.started",
6358
+ message: source === "resume" ? "preparation session live (resumed)" : "preparation session live",
6359
+ data: { sessionId: session.id, ccSessionId, transcriptPath, source: source ?? null }
6360
+ });
6361
+ }
6362
+ return {
6363
+ hookSpecificOutput: {
6364
+ hookEventName: "SessionStart",
6365
+ additionalContext: prepareStartContext(ctx, session)
6366
+ }
6367
+ };
6368
+ }
6369
+ function handlePrepareUserPrompt(ctx, session, payload) {
6370
+ const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
6371
+ noteKickoffPrompt(ctx, session.id, prompt);
6372
+ return {
6373
+ hookSpecificOutput: {
6374
+ hookEventName: "UserPromptSubmit",
6375
+ additionalContext: `[runcastle] preparation session (project-scoped, no feature)`
6376
+ }
6377
+ };
6378
+ }
6379
+ function handlePrepareSessionEnd(ctx, session) {
6380
+ markSessionEnded(ctx, session.id);
6381
+ emitForSession(ctx, session, {
6382
+ type: "session.ended",
6383
+ message: "preparation session ended",
6384
+ data: { sessionId: session.id }
6385
+ });
6386
+ return {};
6387
+ }
6388
+ function prepareStartContext(ctx, session) {
6389
+ const project = session.projectId ? getProjectById(ctx, session.projectId) : null;
6390
+ if (!project)
6391
+ return "[runcastle] preparation session \u2014 project not found";
6392
+ const remaining = keysToPrepare(ctx, project);
6393
+ return [
6394
+ `[runcastle] preparation session for ${project.name} (${project.repoPath}).`,
6395
+ remaining.length > 0 ? `Still unestablished: ${remaining.join(", ")}.` : "Every prepared field currently has a value.",
6396
+ "Record what you establish with the `record_finding` MCP tool \u2014 a value the user",
6397
+ "gives you or confirms verbatim is theirs (userSupplied: true), anything you measured is not."
6398
+ ].join(" ");
6399
+ }
5978
6400
  function handleUserPrompt(ctx, sessionId, feature, payload) {
5979
6401
  const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
5980
6402
  noteKickoffPrompt(ctx, sessionId, prompt);
@@ -6016,6 +6438,7 @@ var hooks, hooks_default;
6016
6438
  var init_hooks = __esm(() => {
6017
6439
  init_sessions();
6018
6440
  init_events();
6441
+ init_prep();
6019
6442
  init_repo();
6020
6443
  init_tickets();
6021
6444
  init_waypoints();
@@ -6037,6 +6460,18 @@ var init_hooks = __esm(() => {
6037
6460
  const session = getSessionRow(ctx, sessionId);
6038
6461
  if (!session)
6039
6462
  return c.json({});
6463
+ if (!session.featureId) {
6464
+ switch (event) {
6465
+ case "session-start":
6466
+ return c.json(handlePrepareSessionStart(ctx, session, body.payload));
6467
+ case "user-prompt":
6468
+ return c.json(handlePrepareUserPrompt(ctx, session, body.payload));
6469
+ case "session-end":
6470
+ return c.json(handlePrepareSessionEnd(ctx, session));
6471
+ default:
6472
+ return c.json({});
6473
+ }
6474
+ }
6040
6475
  const feature = tryGetFeature(ctx, session.featureId);
6041
6476
  if (!feature)
6042
6477
  return c.json({});
@@ -6111,7 +6546,7 @@ var init_stream = __esm(() => {
6111
6546
  // src/routes/web.ts
6112
6547
  import { existsSync as existsSync13, statSync } from "fs";
6113
6548
  import { readFile } from "fs/promises";
6114
- import { extname, join as join12, normalize, resolve as resolve6, sep as sep2 } from "path";
6549
+ import { extname, join as join12, normalize, resolve as resolve7, sep as sep2 } from "path";
6115
6550
  function contentType(path) {
6116
6551
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
6117
6552
  }
@@ -6137,11 +6572,11 @@ function safeJoin(root, pathname) {
6137
6572
  function resolveWebDist() {
6138
6573
  const override = process.env.RUNCASTLE_WEB_DIST;
6139
6574
  if (override)
6140
- return resolve6(override);
6141
- return resolve6(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
6575
+ return resolve7(override);
6576
+ return resolve7(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
6142
6577
  }
6143
6578
  function mountWebApp(app, distDir) {
6144
- const root = resolve6(distDir);
6579
+ const root = resolve7(distDir);
6145
6580
  const indexHtml = join12(root, "index.html");
6146
6581
  app.get("*", async (c, next) => {
6147
6582
  const { pathname } = new URL(c.req.url);
@@ -6432,6 +6867,69 @@ function renderRevisitPrompt(feature) {
6432
6867
  ].join(`
6433
6868
  `);
6434
6869
  }
6870
+ function renderPreparePrompt(brief) {
6871
+ const { project, remainingKeys, established, notes } = brief;
6872
+ return [
6873
+ `# runcastle \u2014 preparing ${project.name}`,
6874
+ "",
6875
+ "This is a **preparation session**: a project-scoped conversation whose job is to",
6876
+ "establish the settings every later agent depends on, and to record them with evidence.",
6877
+ "There is no feature here and no pipeline to advance.",
6878
+ "",
6879
+ "## Where this runs \u2014 and why that matters",
6880
+ `You are on the developer's own machine, in \`${project.repoPath}\`, NOT in a sandbox.`,
6881
+ "A headless preparation run measures the repo in a throwaway container, which means it",
6882
+ "can never verify anything about *this* machine: the dev server, the local database,",
6883
+ "docker, credentials. Those keys \u2014 `devCommand`, `driveSetupCommand`, `driveStopCommand`,",
6884
+ "`driveEnv`, `dbResetCommand` \u2014 are exactly what it has to leave as proposals.",
6885
+ "You can actually run them. That is the point of this session.",
6886
+ "",
6887
+ "The same access is why you must **ask before you act**. Anything that starts or stops a",
6888
+ "service, creates or migrates a database, or writes outside the repo needs the human to",
6889
+ "agree first \u2014 say what you are about to run and why, then wait.",
6890
+ "",
6891
+ ...established.length > 0 ? [
6892
+ "## Already established \u2014 do not re-derive",
6893
+ "These are recorded findings. Treat them as true unless the human says otherwise;",
6894
+ "replacing a measured value with a fresh guess makes preparation worse, not better.",
6895
+ "",
6896
+ ...established.map((f) => `- \`${f.key}\` (${f.source})${f.evidence ? ` \u2014 ${f.evidence}` : ""}`),
6897
+ ""
6898
+ ] : [],
6899
+ ...notes ? [
6900
+ "## What the last run could not settle",
6901
+ "Its own words. Where it says it declined to guess, it was right to \u2014 that is the",
6902
+ "gap you are here to close, and closing it usually means asking one direct question.",
6903
+ "",
6904
+ notes,
6905
+ ""
6906
+ ] : [],
6907
+ "## Still open",
6908
+ remainingKeys.length > 0 ? remainingKeys.map((k) => `- \`${k}\``).join(`
6909
+ `) : "_Nothing is unset. Confirm the existing values still hold, then say so and stop._",
6910
+ "",
6911
+ "## Recording what you establish",
6912
+ "- `record_finding({ key, value, evidence, userSupplied })` \u2014 one call per key.",
6913
+ "- `userSupplied: true` means the human GAVE you this value or confirmed it verbatim.",
6914
+ " That marks it as theirs and permanently stops automatic runs from overwriting it.",
6915
+ "- Leave it false for anything you worked out yourself, even with them watching \u2014",
6916
+ " that stays improvable by a later run. Getting this backwards silently retires a",
6917
+ " field from preparation forever, and the only way back is the human clearing it.",
6918
+ "- `evidence` is not optional in spirit: record what you ran and what it printed, or",
6919
+ " what the human told you. A value with no account of itself is a guess with a source field.",
6920
+ "",
6921
+ "## Secrets",
6922
+ "This is a development environment and the human has agreed to supply real connection",
6923
+ "strings and credentials here. Store them as given. Do not paste a secret into a timeline",
6924
+ "note or a commit message \u2014 `record_finding` is the only place a value belongs.",
6925
+ "",
6926
+ "## Your task",
6927
+ "Open by telling the human which fields are still open and what you need from them for",
6928
+ "each. Work them one at a time: propose, ask, run it if they agree, then record it.",
6929
+ ""
6930
+ ].join(`
6931
+ `);
6932
+ }
6435
6933
  function renderSettings(hookClient) {
6436
6934
  const cmd = (event) => ({
6437
6935
  type: "command",
@@ -6462,13 +6960,16 @@ function renderMcpConfig(session, config2) {
6462
6960
  };
6463
6961
  }
6464
6962
  async function writeSessionArtifacts(input) {
6465
- const { session, feature, config: config2, waypoint } = input;
6963
+ const { session, feature, config: config2, waypoint, prepare } = input;
6466
6964
  const dir = sessionDir(session.id);
6467
6965
  mkdirSync7(dir, { recursive: true });
6468
6966
  const systemPromptPath = join13(dir, "system-prompt.md");
6469
6967
  const settingsPath = join13(dir, "settings.json");
6470
6968
  const mcpConfigPath = join13(dir, "mcp.json");
6471
- writeFileSync2(systemPromptPath, renderSystemPrompt(feature, session.kind, waypoint), "utf8");
6969
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : (() => {
6970
+ throw new Error(`session ${session.id} has neither a feature nor a preparation brief`);
6971
+ })();
6972
+ writeFileSync2(systemPromptPath, systemPrompt, "utf8");
6472
6973
  writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
6473
6974
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
6474
6975
  return { systemPromptPath, settingsPath, mcpConfigPath };
@@ -6486,7 +6987,8 @@ var init_artifacts = __esm(() => {
6486
6987
  "mcp__runcastle__complete_phase",
6487
6988
  "mcp__runcastle__escalate_to_map",
6488
6989
  "mcp__runcastle__emit_waypoints",
6489
- "mcp__runcastle__resolve_waypoint"
6990
+ "mcp__runcastle__resolve_waypoint",
6991
+ "mcp__runcastle__record_finding"
6490
6992
  ];
6491
6993
  SESSION_BASH_ALLOW_RULES = [
6492
6994
  "Bash(git status:*)",
@@ -6503,9 +7005,9 @@ var init_artifacts = __esm(() => {
6503
7005
 
6504
7006
  // src/launcher/launcher.ts
6505
7007
  import { existsSync as existsSync14 } from "fs";
6506
- import { dirname as dirname6, join as join14, resolve as resolve7 } from "path";
7008
+ import { dirname as dirname6, join as join14, resolve as resolve8 } from "path";
6507
7009
  import { fileURLToPath as fileURLToPath6 } from "url";
6508
- import { and as and7, eq as eq10 } from "drizzle-orm";
7010
+ import { and as and8, eq as eq10 } from "drizzle-orm";
6509
7011
  function ptyExitMessage(exitCode) {
6510
7012
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
6511
7013
  return `terminal exited (code ${label})`;
@@ -6519,7 +7021,7 @@ function buildClaudeArgs(input) {
6519
7021
  input.settingsPath,
6520
7022
  "--mcp-config",
6521
7023
  input.mcpConfigPath,
6522
- "--strict-mcp-config",
7024
+ ...input.strictMcp ? ["--strict-mcp-config"] : [],
6523
7025
  "--plugin-dir",
6524
7026
  input.pluginDir,
6525
7027
  "--append-system-prompt-file",
@@ -6540,7 +7042,7 @@ function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
6540
7042
  const rel = join14("packages", "skills", "packs", "runcastle");
6541
7043
  const override = process.env[SKILLS_DIR_ENV];
6542
7044
  if (override) {
6543
- const dir2 = join14(resolve7(override), "packs", "runcastle");
7045
+ const dir2 = join14(resolve8(override), "packs", "runcastle");
6544
7046
  if (existsSync14(dir2))
6545
7047
  return dir2;
6546
7048
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
@@ -6562,7 +7064,7 @@ function resolvePluginDir(fromDir = dirname6(fileURLToPath6(import.meta.url))) {
6562
7064
  `)}`);
6563
7065
  }
6564
7066
  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();
7067
+ const row = ctx.db.select().from(runs).where(and8(eq10(runs.featureId, featureId), eq10(runs.status, "running"))).limit(1).get();
6566
7068
  return row ? rowToRun(row) : null;
6567
7069
  }
6568
7070
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -6575,6 +7077,29 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
6575
7077
  throw new GateError(`a ${running.workflow} run is in progress on ${feature.slug} \u2014 it holds the feature branch; terminals are available when it finishes`);
6576
7078
  }
6577
7079
  }
7080
+ function sessionFinished(ctx, feature, session) {
7081
+ if (session.kind !== "waypoint")
7082
+ return feature.mapped;
7083
+ const own = listByFeature(ctx, feature.id).find((w) => w.lastSessionId === session.id);
7084
+ return !!own && (own.status === "resolved" || own.status === "dropped");
7085
+ }
7086
+ function sweepActiveSessions(ctx, feature, endLive) {
7087
+ for (const session of activeSessionsForFeature(ctx, feature.id)) {
7088
+ const finished = sessionFinished(ctx, feature, session);
7089
+ if (!finished && !endLive)
7090
+ continue;
7091
+ endSession(ctx, session.id);
7092
+ emit(ctx, feature.id, {
7093
+ type: "session.auto_ended",
7094
+ message: finished ? `ended the finished ${session.kind} session to work the next waypoint` : `abandoned the in-flight ${session.kind} session to work the next waypoint`,
7095
+ data: {
7096
+ sessionId: session.id,
7097
+ kind: session.kind,
7098
+ reason: finished ? "finished" : "abandoned"
7099
+ }
7100
+ });
7101
+ }
7102
+ }
6578
7103
  async function ensureWorktree(ctx, project, feature) {
6579
7104
  try {
6580
7105
  return await ensureTalkWorktree(project, feature);
@@ -6682,7 +7207,8 @@ async function launchSession(ctx, input, opts = {}) {
6682
7207
  mcpConfigPath: artifacts.mcpConfigPath,
6683
7208
  systemPromptPath: artifacts.systemPromptPath,
6684
7209
  model: resolveModel(input.kind, ctx.config, project),
6685
- resumeSessionId
7210
+ resumeSessionId,
7211
+ strictMcp: ctx.config.sessionMcp === "runcastleOnly"
6686
7212
  };
6687
7213
  if (opts.spawn === false) {
6688
7214
  emit(ctx, feature.id, {
@@ -6702,6 +7228,75 @@ async function launchSession(ctx, input, opts = {}) {
6702
7228
  });
6703
7229
  return { sessionId: session.id };
6704
7230
  }
7231
+ async function launchPrepareSession(ctx, input, opts = {}) {
7232
+ const project = requireProjectById(ctx, input.projectId);
7233
+ const session = createSessionRow(ctx, {
7234
+ projectId: project.id,
7235
+ kind: "prepare",
7236
+ worktreePath: project.repoPath
7237
+ });
7238
+ const resumeSessionId = mostRecentResumableProjectSession(ctx, project.id, "prepare")?.ccSessionId;
7239
+ emitProject(ctx, project.id, {
7240
+ type: "session.launching",
7241
+ message: "launching preparation session",
7242
+ data: { sessionId: session.id, kind: "prepare", worktreePath: project.repoPath }
7243
+ });
7244
+ if (resumeSessionId) {
7245
+ emitProject(ctx, project.id, {
7246
+ type: "session.resumed",
7247
+ message: "resuming the previous preparation conversation",
7248
+ data: { sessionId: session.id, resumeSessionId }
7249
+ });
7250
+ }
7251
+ const artifacts = await writeSessionArtifacts({
7252
+ session,
7253
+ project,
7254
+ config: ctx.config,
7255
+ prepare: await buildPrepareBrief(ctx, project)
7256
+ });
7257
+ const serverUrl = serverUrlFor(ctx.config);
7258
+ const buildInput = {
7259
+ sessionId: session.id,
7260
+ serverUrl,
7261
+ featureTitle: `preparing ${project.name}`,
7262
+ worktreePath: project.repoPath,
7263
+ pluginDir: resolvePluginDir(),
7264
+ settingsPath: artifacts.settingsPath,
7265
+ mcpConfigPath: artifacts.mcpConfigPath,
7266
+ systemPromptPath: artifacts.systemPromptPath,
7267
+ model: resolveModel("prepare", ctx.config, project),
7268
+ resumeSessionId,
7269
+ strictMcp: ctx.config.sessionMcp === "runcastleOnly"
7270
+ };
7271
+ if (opts.spawn === false) {
7272
+ emitProject(ctx, project.id, {
7273
+ type: "session.launched",
7274
+ message: "preparation session prepared (terminal spawn skipped)",
7275
+ data: {
7276
+ sessionId: session.id,
7277
+ command: ["claude", ...buildClaudeArgs(buildInput)].join(" "),
7278
+ spawned: false
7279
+ }
7280
+ });
7281
+ return { sessionId: session.id };
7282
+ }
7283
+ spawnEmbeddedPty(ctx, undefined, session, project.repoPath, serverUrl, buildClaudeArgs(buildInput), { resumeSessionId });
7284
+ return { sessionId: session.id };
7285
+ }
7286
+ async function buildPrepareBrief(ctx, project) {
7287
+ const findings = await listFindings(ctx, project);
7288
+ const notes = latestPrepNotes(ctx, project.id);
7289
+ return {
7290
+ project,
7291
+ remainingKeys: keysToPrepare(ctx, project),
7292
+ established: findings.map((f) => ({
7293
+ key: f.key,
7294
+ source: f.source,
7295
+ ...f.evidence ? { evidence: f.evidence } : {}
7296
+ })),
7297
+ ...notes ? { notes } : {}
7298
+ };
7299
+ }
6705
7300
  async function workWaypoint(ctx, input, opts = {}) {
6706
7301
  const feature = getFeatureRow(ctx, input.featureId);
6707
7302
  if (!feature.mapped) {
@@ -6718,6 +7313,7 @@ async function workWaypoint(ctx, input, opts = {}) {
6718
7313
  });
6719
7314
  return { runId };
6720
7315
  }
7316
+ sweepActiveSessions(ctx, feature, input.endLive === true);
6721
7317
  assertSpawnable(ctx, feature);
6722
7318
  return launchSession(ctx, { featureId: feature.id, kind: "waypoint", waypointId: wp.id }, opts);
6723
7319
  }
@@ -6774,7 +7370,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
6774
7370
  releaseForSession(ctx, session.id);
6775
7371
  if (diedBeforeLive && meta.resumeSessionId) {
6776
7372
  const label = meta.waypoint ? `waypoint ${meta.waypoint.seq} (${meta.waypoint.title})` : session.kind;
6777
- emit(ctx, feature.id, {
7373
+ emitForSession(ctx, session, {
6778
7374
  type: "session.resume_failed",
6779
7375
  message: `resume failed for ${label} \u2014 the session exited before starting (code ${exitCode ?? "unknown"}); the previous conversation is still resumable`,
6780
7376
  data: {
@@ -6785,7 +7381,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
6785
7381
  }
6786
7382
  });
6787
7383
  }
6788
- emit(ctx, feature.id, {
7384
+ emitForSession(ctx, session, {
6789
7385
  type: "session.pty_exited",
6790
7386
  message: ptyExitMessage(exitCode),
6791
7387
  data: { sessionId: session.id, exitCode: exitCode ?? null }
@@ -6808,7 +7404,7 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
6808
7404
  opts: { cwd: worktreePath, env, cols: 80, rows: 24, useConpty: true },
6809
7405
  onExit: ({ exitCode }) => handlePtyExit(ctx, feature, session, meta, exitCode)
6810
7406
  });
6811
- emit(ctx, feature.id, {
7407
+ emitForSession(ctx, session, {
6812
7408
  type: "session.launched",
6813
7409
  message: "embedded terminal spawned",
6814
7410
  data: { sessionId: session.id, mode: "embedded", pid: entry.pty.pid }
@@ -6817,7 +7413,7 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
6817
7413
  } catch (err) {
6818
7414
  markSessionEnded(ctx, session.id);
6819
7415
  releaseForSession(ctx, session.id);
6820
- emit(ctx, feature.id, {
7416
+ emitForSession(ctx, session, {
6821
7417
  type: "session.spawn_failed",
6822
7418
  message: `failed to spawn embedded terminal: ${err instanceof Error ? err.message : String(err)}`,
6823
7419
  data: { sessionId: session.id, mode: "embedded" }
@@ -6832,6 +7428,7 @@ var init_launcher = __esm(() => {
6832
7428
  init_skills_root();
6833
7429
  init_schema();
6834
7430
  init_errors();
7431
+ init_end_session();
6835
7432
  init_registry();
6836
7433
  init_events();
6837
7434
  init_gates();
@@ -6840,6 +7437,8 @@ var init_launcher = __esm(() => {
6840
7437
  init_tickets();
6841
7438
  init_waypoints();
6842
7439
  init_runner();
7440
+ init_findings();
7441
+ init_prep();
6843
7442
  init_artifacts();
6844
7443
  init_sessions();
6845
7444
  init_sessions();
@@ -6883,7 +7482,7 @@ var init_feature = __esm(() => {
6883
7482
  kind: SessionKind,
6884
7483
  kickoffLine: z7.string().min(1).optional()
6885
7484
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
6886
- workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
7485
+ workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string(), endLive: z7.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
6887
7486
  converge: publicProcedure.input(z7.object({ featureId: z7.string(), overrideReason: z7.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
6888
7487
  resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
6889
7488
  endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
@@ -6924,7 +7523,7 @@ var init_feature = __esm(() => {
6924
7523
  // src/services/fsbrowse.ts
6925
7524
  import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
6926
7525
  import { homedir as homedir2 } from "os";
6927
- import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve8, sep as sep3 } from "path";
7526
+ import { basename, dirname as dirname7, isAbsolute, join as join15, resolve as resolve9, sep as sep3 } from "path";
6928
7527
  function expandPath(input, home = homedir2()) {
6929
7528
  const trimmed = input.trim();
6930
7529
  if (trimmed === "")
@@ -6934,7 +7533,7 @@ function expandPath(input, home = homedir2()) {
6934
7533
  out = home;
6935
7534
  else if (out.startsWith("~/") || out.startsWith("~\\"))
6936
7535
  out = join15(home, out.slice(2));
6937
- return resolve8(out);
7536
+ return resolve9(out);
6938
7537
  }
6939
7538
  function looksLikeRepo(dir) {
6940
7539
  try {
@@ -7087,6 +7686,7 @@ async function openProject(ctx, rawPath) {
7087
7686
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
7088
7687
  });
7089
7688
  warnIfTranslatedMount(ctx, existing.id, repoPath);
7689
+ maybeAutoPrepare(ctx, reopened);
7090
7690
  return reopened;
7091
7691
  }
7092
7692
  const row = {
@@ -7108,7 +7708,7 @@ async function openProject(ctx, rawPath) {
7108
7708
  return project;
7109
7709
  }
7110
7710
  function maybeAutoPrepare(ctx, project) {
7111
- if (!ctx.config.autoPrepare)
7711
+ if (!shouldAutoPrepare(ctx, project))
7112
7712
  return;
7113
7713
  startPrep(ctx, project.id).catch((e) => {
7114
7714
  emitProject(ctx, project.id, {
@@ -7183,6 +7783,8 @@ var init_project = __esm(() => {
7183
7783
  init_fsbrowse();
7184
7784
  init_git();
7185
7785
  init_prep();
7786
+ init_launcher();
7787
+ init_sessions();
7186
7788
  init_projects();
7187
7789
  init_repo();
7188
7790
  init_context();
@@ -7205,6 +7807,8 @@ var init_project = __esm(() => {
7205
7807
  });
7206
7808
  return { prepId, keys };
7207
7809
  }),
7810
+ talkToPrep: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
7811
+ prepSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
7208
7812
  cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
7209
7813
  cancelPrep(input.projectId);
7210
7814
  return { ok: true };
@@ -7279,7 +7883,10 @@ function projectOverrides(ctx, projectId) {
7279
7883
  setupCommand: projects.setupCommand,
7280
7884
  verifyCommands: projects.verifyCommands,
7281
7885
  knownFailures: projects.knownFailures,
7282
- dbResetCommand: projects.dbResetCommand
7886
+ dbResetCommand: projects.dbResetCommand,
7887
+ driveSetupCommand: projects.driveSetupCommand,
7888
+ driveStopCommand: projects.driveStopCommand,
7889
+ driveEnv: projects.driveEnv
7283
7890
  }).from(projects).where(eq12(projects.id, projectId)).get();
7284
7891
  return {
7285
7892
  model: row?.model ?? null,
@@ -7288,7 +7895,10 @@ function projectOverrides(ctx, projectId) {
7288
7895
  setupCommand: row?.setupCommand ?? null,
7289
7896
  verifyCommands: row?.verifyCommands ?? null,
7290
7897
  knownFailures: row?.knownFailures ?? null,
7291
- dbResetCommand: row?.dbResetCommand ?? null
7898
+ dbResetCommand: row?.dbResetCommand ?? null,
7899
+ driveSetupCommand: row?.driveSetupCommand ?? null,
7900
+ driveStopCommand: row?.driveStopCommand ?? null,
7901
+ driveEnv: row?.driveEnv ?? null
7292
7902
  };
7293
7903
  }
7294
7904
  function resolveField(desc5, layers) {
@@ -7474,6 +8084,14 @@ var init_settings = __esm(() => {
7474
8084
  valueSchema: z10.string().min(1),
7475
8085
  parseEnv: idEnv
7476
8086
  },
8087
+ {
8088
+ key: "sessionMcp",
8089
+ configKey: "sessionMcp",
8090
+ envVar: "RUNCASTLE_SESSION_MCP",
8091
+ restartRequired: false,
8092
+ valueSchema: z10.enum(["inherit", "runcastleOnly"]),
8093
+ parseEnv: idEnv
8094
+ },
7477
8095
  {
7478
8096
  key: "burnConcurrency",
7479
8097
  configKey: "burnConcurrency",
@@ -7570,6 +8188,27 @@ var init_settings = __esm(() => {
7570
8188
  restartRequired: false,
7571
8189
  valueSchema: z10.string().min(1),
7572
8190
  parseEnv: idEnv
8191
+ },
8192
+ {
8193
+ key: "driveSetupCommand",
8194
+ projectColumn: "driveSetupCommand",
8195
+ restartRequired: false,
8196
+ valueSchema: z10.string().min(1),
8197
+ parseEnv: idEnv
8198
+ },
8199
+ {
8200
+ key: "driveStopCommand",
8201
+ projectColumn: "driveStopCommand",
8202
+ restartRequired: false,
8203
+ valueSchema: z10.string().min(1),
8204
+ parseEnv: idEnv
8205
+ },
8206
+ {
8207
+ key: "driveEnv",
8208
+ projectColumn: "driveEnv",
8209
+ restartRequired: false,
8210
+ valueSchema: z10.string().min(1),
8211
+ parseEnv: idEnv
7573
8212
  }
7574
8213
  ];
7575
8214
  DEFAULTS = RuncastleConfig.parse({});
@@ -7751,13 +8390,13 @@ var init_doctor = __esm(() => {
7751
8390
  });
7752
8391
 
7753
8392
  // src/doctor/system-exec.ts
7754
- import { spawn as spawn2 } from "child_process";
8393
+ import { spawn as spawn3 } from "child_process";
7755
8394
  function createSystemExec(opts = {}) {
7756
- return (command, args) => new Promise((resolve9) => {
8395
+ return (command, args) => new Promise((resolve10) => {
7757
8396
  const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
7758
8397
  let stdout = "";
7759
8398
  let stderr = "";
7760
- const child = spawn2(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
8399
+ const child = spawn3(file, spawnArgs, { cwd: opts.cwd, windowsHide: true });
7761
8400
  child.stdout?.on("data", (d) => {
7762
8401
  stdout += d.toString();
7763
8402
  });
@@ -7765,10 +8404,10 @@ function createSystemExec(opts = {}) {
7765
8404
  stderr += d.toString();
7766
8405
  });
7767
8406
  child.on("error", (err) => {
7768
- resolve9({ ok: false, code: null, stdout, stderr: stderr || String(err) });
8407
+ resolve10({ ok: false, code: null, stdout, stderr: stderr || String(err) });
7769
8408
  });
7770
8409
  child.on("close", (code) => {
7771
- resolve9({ ok: true, code: code ?? 0, stdout, stderr });
8410
+ resolve10({ ok: true, code: code ?? 0, stdout, stderr });
7772
8411
  });
7773
8412
  });
7774
8413
  }
@@ -8217,7 +8856,7 @@ import { Hono as Hono4 } from "hono";
8217
8856
  function buildApp(ctx) {
8218
8857
  const app = new Hono4;
8219
8858
  setRuntimeCtx(ctx);
8220
- app.get("/health", (c) => c.json({ ok: true }));
8859
+ app.get("/health", (c) => c.json({ ok: true, dataDir: dataDir() }));
8221
8860
  app.use("/api/trpc/*", async (c, next) => {
8222
8861
  await next();
8223
8862
  if (c.res.headers.get("content-type") === "application/json") {
@@ -8262,7 +8901,7 @@ async function startServer() {
8262
8901
  },
8263
8902
  websocket: terminalWebSocket
8264
8903
  });
8265
- console.log(`runcastle server listening on http://localhost:${config2.serverPort}`);
8904
+ console.log(`runcastle${process.env.RUNCASTLE_DEV ? " [dev]" : ""} server listening on ` + `http://localhost:${config2.serverPort} \u2014 data dir: ${dataDir()}`);
8266
8905
  const SHUTDOWN_KEY = Symbol.for("runcastle.shutdown.wired");
8267
8906
  const g = globalThis;
8268
8907
  if (!g[SHUTDOWN_KEY]) {
@@ -8398,7 +9037,7 @@ var init_cli = __esm(() => {
8398
9037
  // src/bin/runcastle.ts
8399
9038
  init_asset_paths();
8400
9039
  init_version();
8401
- import { dirname as dirname11, resolve as resolve9 } from "path";
9040
+ import { dirname as dirname11, resolve as resolve10 } from "path";
8402
9041
  import { fileURLToPath as fileURLToPath9 } from "url";
8403
9042
  function parseCommand(argv) {
8404
9043
  const [first, ...rest] = argv;
@@ -8423,7 +9062,7 @@ Usage:
8423
9062
  runcastle --help Show this help
8424
9063
  `;
8425
9064
  async function main(argv) {
8426
- applyInstalledAssetEnv(resolve9(dirname11(fileURLToPath9(import.meta.url)), ".."));
9065
+ applyInstalledAssetEnv(resolve10(dirname11(fileURLToPath9(import.meta.url)), ".."));
8427
9066
  const { command, args } = parseCommand(argv);
8428
9067
  switch (command) {
8429
9068
  case "version":