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