runcastle 1.1.1 → 1.2.1

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
@@ -107,7 +107,7 @@ function resolvePreparedSettings(config, project) {
107
107
  };
108
108
  }
109
109
  function resolveModel(step, config, project, runOverride) {
110
- return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
110
+ return runOverride ?? project?.model ?? config.stepModels[step] ?? config.model;
111
111
  }
112
112
  var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5", foldLegacyModelConfig = (raw) => {
113
113
  if (typeof raw !== "object" || raw === null || Array.isArray(raw))
@@ -245,7 +245,7 @@ var init_ids = () => {};
245
245
 
246
246
  // ../core/src/schemas.ts
247
247
  import * as z2 from "zod";
248
- var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, PREPARED_KEYS, PreparedKey, FindingSource, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
248
+ var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, TestNoteStatus, TestNote, PREPARED_KEYS, PreparedKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
249
249
  var init_schemas = __esm(() => {
250
250
  Phase = z2.enum([
251
251
  "ideation",
@@ -307,6 +307,17 @@ var init_schemas = __esm(() => {
307
307
  lastSessionId: z2.string().optional(),
308
308
  summary: z2.string().optional()
309
309
  });
310
+ TestNoteStatus = z2.enum(["open", "done", "promoted"]);
311
+ TestNote = z2.object({
312
+ id: z2.string(),
313
+ featureId: z2.string(),
314
+ lap: z2.number(),
315
+ text: z2.string(),
316
+ status: TestNoteStatus,
317
+ ticketId: z2.string().optional(),
318
+ createdAt: z2.number(),
319
+ updatedAt: z2.number()
320
+ });
310
321
  PREPARED_KEYS = [
311
322
  "setupCommand",
312
323
  "verifyCommands",
@@ -318,7 +329,14 @@ var init_schemas = __esm(() => {
318
329
  "dbResetCommand"
319
330
  ];
320
331
  PreparedKey = z2.enum(PREPARED_KEYS);
332
+ DRIVE_LOOP_KEYS = [
333
+ "devCommand",
334
+ "driveSetupCommand",
335
+ "driveStopCommand",
336
+ "driveEnv"
337
+ ];
321
338
  FindingSource = z2.enum(["prep", "human", "session"]);
339
+ ProjectName = z2.string().min(1, "a project needs a name").max(PROJECT_NAME_MAX, `a project name can be at most ${PROJECT_NAME_MAX} characters`);
322
340
  Project = z2.object({
323
341
  id: z2.string(),
324
342
  name: z2.string(),
@@ -340,7 +358,9 @@ var init_schemas = __esm(() => {
340
358
  evidence: z2.string().optional(),
341
359
  establishedAt: z2.number(),
342
360
  establishedSha: z2.string().optional(),
343
- staleCommits: z2.number().optional()
361
+ staleCommits: z2.number().optional(),
362
+ verifiedAt: z2.number().optional(),
363
+ verifiedSha: z2.string().optional()
344
364
  });
345
365
  Feature = z2.object({
346
366
  id: z2.string(),
@@ -364,6 +384,7 @@ var init_schemas = __esm(() => {
364
384
  ccSessionId: z2.string().optional(),
365
385
  transcriptPath: z2.string().optional(),
366
386
  status: SessionStatus,
387
+ awaitingInput: z2.boolean(),
367
388
  worktreePath: z2.string()
368
389
  });
369
390
  Run = z2.object({
@@ -477,6 +498,12 @@ function nextPhase(feature) {
477
498
  return null;
478
499
  return ORDER[i + 1];
479
500
  }
501
+ function previousPhase(feature) {
502
+ const i = ORDER.indexOf(feature.phase);
503
+ if (i <= 0)
504
+ return null;
505
+ return ORDER[i - 1];
506
+ }
480
507
  function nextGate(feature) {
481
508
  const i = ORDER.indexOf(feature.phase);
482
509
  if (i < 0 || i >= ORDER.length - 1)
@@ -543,7 +570,7 @@ var init_pipeline = __esm(() => {
543
570
 
544
571
  // ../core/src/db-schema.ts
545
572
  import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
546
- var projects, projectFindings, features, sessions, tickets, waypoints, runs, events, gateOverrides;
573
+ var projects, projectFindings, features, sessions, tickets, testNotes, waypoints, runs, events, gateOverrides;
547
574
  var init_db_schema = __esm(() => {
548
575
  projects = sqliteTable("projects", {
549
576
  id: text("id").primaryKey(),
@@ -568,7 +595,9 @@ var init_db_schema = __esm(() => {
568
595
  source: text("source").notNull().$type(),
569
596
  evidence: text("evidence"),
570
597
  establishedAt: integer("established_at").notNull(),
571
- establishedSha: text("established_sha")
598
+ establishedSha: text("established_sha"),
599
+ verifiedAt: integer("verified_at"),
600
+ verifiedSha: text("verified_sha")
572
601
  }, (t) => [primaryKey({ columns: [t.projectId, t.key] })]);
573
602
  features = sqliteTable("features", {
574
603
  id: text("id").primaryKey(),
@@ -593,6 +622,7 @@ var init_db_schema = __esm(() => {
593
622
  ccSessionId: text("cc_session_id"),
594
623
  transcriptPath: text("transcript_path"),
595
624
  status: text("status").notNull().$type(),
625
+ awaitingInput: integer("awaiting_input", { mode: "boolean" }).notNull().default(false),
596
626
  worktreePath: text("worktree_path").notNull()
597
627
  });
598
628
  tickets = sqliteTable("tickets", {
@@ -612,6 +642,16 @@ var init_db_schema = __esm(() => {
612
642
  attemptBranch: text("attempt_branch"),
613
643
  conflictFiles: text("conflict_files", { mode: "json" }).$type()
614
644
  });
645
+ testNotes = sqliteTable("test_notes", {
646
+ id: text("id").primaryKey(),
647
+ featureId: text("feature_id").notNull(),
648
+ lap: integer("lap").notNull(),
649
+ text: text("text").notNull(),
650
+ status: text("status").notNull().$type(),
651
+ ticketId: text("ticket_id"),
652
+ createdAt: integer("created_at").notNull(),
653
+ updatedAt: integer("updated_at").notNull()
654
+ });
615
655
  waypoints = sqliteTable("waypoints", {
616
656
  id: text("id").primaryKey(),
617
657
  featureId: text("feature_id").notNull(),
@@ -674,6 +714,7 @@ var init_schema = __esm(() => {
674
714
  features,
675
715
  sessions,
676
716
  tickets,
717
+ testNotes,
677
718
  waypoints,
678
719
  runs,
679
720
  events,
@@ -1359,7 +1400,7 @@ var init_bus = __esm(() => {
1359
1400
  });
1360
1401
 
1361
1402
  // src/services/events.ts
1362
- import { and, asc, desc, eq, gt } from "drizzle-orm";
1403
+ import { and, asc, desc, eq, gt, max } from "drizzle-orm";
1363
1404
  function rowToEvent(row) {
1364
1405
  return {
1365
1406
  id: row.id,
@@ -1389,6 +1430,9 @@ function emit(ctx, featureId, e) {
1389
1430
  function emitProject(ctx, projectId, e) {
1390
1431
  return insertEvent(ctx, projectId, null, e);
1391
1432
  }
1433
+ function emitScoped(ctx, scope, e) {
1434
+ return "featureId" in scope ? emit(ctx, scope.featureId, e) : emitProject(ctx, scope.projectId, e);
1435
+ }
1392
1436
  function emitForSession(ctx, session, e) {
1393
1437
  if (session.featureId)
1394
1438
  return emit(ctx, session.featureId, e);
@@ -1425,6 +1469,15 @@ function latestEventTs(ctx, featureId, type) {
1425
1469
  const row = ctx.db.select({ ts: events.ts }).from(events).where(and(eq(events.featureId, featureId), eq(events.type, type))).orderBy(desc(events.ts)).limit(1).get();
1426
1470
  return row?.ts;
1427
1471
  }
1472
+ function latestTsByFeature(ctx, projectId) {
1473
+ const rows = ctx.db.select({ featureId: events.featureId, ts: max(events.ts) }).from(events).where(eq(events.projectId, projectId)).groupBy(events.featureId).all();
1474
+ const out = new Map;
1475
+ for (const row of rows) {
1476
+ if (row.featureId !== null && row.ts !== null)
1477
+ out.set(row.featureId, row.ts);
1478
+ }
1479
+ return out;
1480
+ }
1428
1481
  function listByProject(ctx, projectId, afterId = 0) {
1429
1482
  const rows = ctx.db.select().from(events).where(and(eq(events.projectId, projectId), gt(events.id, afterId))).orderBy(asc(events.id)).all();
1430
1483
  return rows.map(rowToEvent);
@@ -1490,6 +1543,7 @@ function rowToSession(row) {
1490
1543
  ccSessionId: row.ccSessionId ?? undefined,
1491
1544
  transcriptPath: row.transcriptPath ?? undefined,
1492
1545
  status: row.status,
1546
+ awaitingInput: row.awaitingInput,
1493
1547
  worktreePath: row.worktreePath
1494
1548
  };
1495
1549
  }
@@ -1606,7 +1660,7 @@ function storeWaypoints(ctx, featureId, inputs) {
1606
1660
  if (inputs.length === 0)
1607
1661
  return [];
1608
1662
  const existing = listByFeature(ctx, featureId);
1609
- const startSeq = existing.reduce((max, w) => Math.max(max, w.seq), 0) + 1;
1663
+ const startSeq = existing.reduce((max2, w) => Math.max(max2, w.seq), 0) + 1;
1610
1664
  const seqById = new Map(existing.map((w) => [w.id, w.seq]));
1611
1665
  const positionEdges = inputs.map((inp) => ({
1612
1666
  blockedBy: inp.blockedBy.filter((b) => typeof b === "number")
@@ -1741,8 +1795,8 @@ var init_waypoints = __esm(() => {
1741
1795
  });
1742
1796
 
1743
1797
  // src/pty/dev-pane.ts
1744
- function drivePaneId(featureId) {
1745
- return `${DRIVE_PREFIX}${featureId}`;
1798
+ function drivePaneId(ownerId) {
1799
+ return `${DRIVE_PREFIX}${ownerId}`;
1746
1800
  }
1747
1801
  function sniffDevUrl(text2) {
1748
1802
  const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
@@ -1755,8 +1809,8 @@ function devSpawnTarget(devCommand) {
1755
1809
  return { file: "/bin/sh", args: ["-c", devCommand] };
1756
1810
  }
1757
1811
  function startDevPane(input) {
1758
- const { ctx, featureId, repoPath, devCommand, env, onUrl } = input;
1759
- const paneId = drivePaneId(featureId);
1812
+ const { ctx, scope, repoPath, devCommand, env, onUrl } = input;
1813
+ const paneId = drivePaneId("featureId" in scope ? scope.featureId : scope.projectId);
1760
1814
  const { file, args } = devSpawnTarget(devCommand);
1761
1815
  try {
1762
1816
  ptyRegistry().create({
@@ -1766,7 +1820,7 @@ function startDevPane(input) {
1766
1820
  opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
1767
1821
  });
1768
1822
  } catch (err) {
1769
- emit(ctx, featureId, {
1823
+ emitScoped(ctx, scope, {
1770
1824
  type: "testdrive.dev_failed",
1771
1825
  message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
1772
1826
  data: { paneId }
@@ -1789,13 +1843,17 @@ function startDevPane(input) {
1789
1843
  sendControl() {}
1790
1844
  };
1791
1845
  ptyRegistry().attach(paneId, sniffer);
1792
- emit(ctx, featureId, {
1846
+ emitScoped(ctx, scope, {
1793
1847
  type: "testdrive.dev_started",
1794
1848
  message: `dev server spawned: ${devCommand}`,
1795
1849
  data: { paneId }
1796
1850
  });
1797
1851
  return paneId;
1798
1852
  }
1853
+ function devPaneLive(paneId) {
1854
+ const entry = ptyRegistry().get(paneId);
1855
+ return !!entry && !entry.exited;
1856
+ }
1799
1857
  function stopDevPane(paneId) {
1800
1858
  const reg = ptyRegistry();
1801
1859
  const entry = reg.get(paneId);
@@ -1815,13 +1873,13 @@ var init_dev_pane = __esm(() => {
1815
1873
 
1816
1874
  // src/services/drive-hooks.ts
1817
1875
  import { spawn as spawn2 } from "child_process";
1818
- function tailLines(text2, max = OUTPUT_TAIL_LINES) {
1876
+ function tailLines(text2, max2 = OUTPUT_TAIL_LINES) {
1819
1877
  const lines = text2.replace(/\r\n/g, `
1820
1878
  `).split(`
1821
1879
  `);
1822
1880
  while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
1823
1881
  lines.pop();
1824
- return lines.slice(-max).join(`
1882
+ return lines.slice(-max2).join(`
1825
1883
  `).trim();
1826
1884
  }
1827
1885
  function hookSpawnTarget(command) {
@@ -1910,13 +1968,13 @@ var init_drive_hooks = __esm(() => {
1910
1968
 
1911
1969
  // src/services/drive-env.ts
1912
1970
  import { createHash } from "crypto";
1913
- function identifierSafe(slug, max = MAX_ID_LENGTH) {
1971
+ function identifierSafe(slug, max2 = MAX_ID_LENGTH) {
1914
1972
  const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
1915
1973
  const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
1916
- if (safe.length <= max)
1974
+ if (safe.length <= max2)
1917
1975
  return safe;
1918
1976
  const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
1919
- return `${safe.slice(0, max - digest.length - 1)}_${digest}`;
1977
+ return `${safe.slice(0, max2 - digest.length - 1)}_${digest}`;
1920
1978
  }
1921
1979
  function driveVars(identity) {
1922
1980
  return {
@@ -1982,6 +2040,144 @@ var init_drive_env = __esm(() => {
1982
2040
  PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
1983
2041
  });
1984
2042
 
2043
+ // src/services/findings.ts
2044
+ import { and as and4, eq as eq4 } from "drizzle-orm";
2045
+ function isPreparedKey(key) {
2046
+ return PREPARED_SET.has(key);
2047
+ }
2048
+ function findingRow(ctx, projectId, key) {
2049
+ return ctx.db.select({
2050
+ source: projectFindings.source,
2051
+ evidence: projectFindings.evidence,
2052
+ establishedAt: projectFindings.establishedAt,
2053
+ establishedSha: projectFindings.establishedSha
2054
+ }).from(projectFindings).where(and4(eq4(projectFindings.projectId, projectId), eq4(projectFindings.key, key))).get();
2055
+ }
2056
+ function isOverwritable(ctx, projectId, key) {
2057
+ return findingRow(ctx, projectId, key)?.source !== "human";
2058
+ }
2059
+ function recordFinding(ctx, projectId, input) {
2060
+ const { key, value, source, evidence, establishedSha } = input;
2061
+ ctx.db.transaction((tx) => {
2062
+ tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq4(projects.id, projectId)).run();
2063
+ if (value === null) {
2064
+ tx.delete(projectFindings).where(and4(eq4(projectFindings.projectId, projectId), eq4(projectFindings.key, key))).run();
2065
+ return;
2066
+ }
2067
+ tx.insert(projectFindings).values({
2068
+ projectId,
2069
+ key,
2070
+ source,
2071
+ evidence: evidence ?? null,
2072
+ establishedAt: Date.now(),
2073
+ establishedSha: establishedSha ?? null,
2074
+ ...UNVERIFIED
2075
+ }).onConflictDoUpdate({
2076
+ target: [projectFindings.projectId, projectFindings.key],
2077
+ set: {
2078
+ source,
2079
+ evidence: evidence ?? null,
2080
+ establishedAt: Date.now(),
2081
+ establishedSha: establishedSha ?? null,
2082
+ ...UNVERIFIED
2083
+ }
2084
+ }).run();
2085
+ });
2086
+ }
2087
+ function recordHuman(ctx, projectId, key, value) {
2088
+ if (!isPreparedKey(key))
2089
+ return;
2090
+ const existing = findingRow(ctx, projectId, key);
2091
+ if (value === null) {
2092
+ if (existing) {
2093
+ ctx.db.delete(projectFindings).where(and4(eq4(projectFindings.projectId, projectId), eq4(projectFindings.key, key))).run();
2094
+ }
2095
+ return;
2096
+ }
2097
+ ctx.db.insert(projectFindings).values({
2098
+ projectId,
2099
+ key,
2100
+ source: "human",
2101
+ evidence: null,
2102
+ establishedAt: Date.now(),
2103
+ establishedSha: null,
2104
+ ...UNVERIFIED
2105
+ }).onConflictDoUpdate({
2106
+ target: [projectFindings.projectId, projectFindings.key],
2107
+ set: {
2108
+ source: "human",
2109
+ evidence: null,
2110
+ establishedAt: Date.now(),
2111
+ establishedSha: null,
2112
+ ...UNVERIFIED
2113
+ }
2114
+ }).run();
2115
+ }
2116
+ function markVerified(ctx, projectId, keys, sha) {
2117
+ const verifiedAt = Date.now();
2118
+ for (const key of keys) {
2119
+ ctx.db.update(projectFindings).set({ verifiedAt, verifiedSha: sha }).where(and4(eq4(projectFindings.projectId, projectId), eq4(projectFindings.key, key))).run();
2120
+ }
2121
+ }
2122
+ async function listFindings(ctx, project) {
2123
+ const rows = ctx.db.select().from(projectFindings).where(eq4(projectFindings.projectId, project.id)).all();
2124
+ const distances = new Map;
2125
+ for (const row of rows) {
2126
+ const sha = row.establishedSha;
2127
+ if (!sha || distances.has(sha))
2128
+ continue;
2129
+ distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
2130
+ }
2131
+ const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
2132
+ return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
2133
+ const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
2134
+ return {
2135
+ key: row.key,
2136
+ source: row.source,
2137
+ ...row.evidence ? { evidence: row.evidence } : {},
2138
+ establishedAt: row.establishedAt,
2139
+ ...row.establishedSha ? { establishedSha: row.establishedSha } : {},
2140
+ ...staleCommits !== undefined ? { staleCommits } : {},
2141
+ ...row.verifiedAt !== null ? { verifiedAt: row.verifiedAt } : {},
2142
+ ...row.verifiedSha ? { verifiedSha: row.verifiedSha } : {}
2143
+ };
2144
+ });
2145
+ }
2146
+ function unsetPreparedKeys(project) {
2147
+ return PREPARED_KEYS.filter((key) => {
2148
+ const value = project[key];
2149
+ return value === undefined || value.trim() === "";
2150
+ });
2151
+ }
2152
+ var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET, UNVERIFIED;
2153
+ var init_findings = __esm(() => {
2154
+ init_src();
2155
+ init_schema();
2156
+ init_git();
2157
+ VALUE_COLUMN = {
2158
+ setupCommand: projects.setupCommand,
2159
+ verifyCommands: projects.verifyCommands,
2160
+ knownFailures: projects.knownFailures,
2161
+ devCommand: projects.devCommand,
2162
+ driveSetupCommand: projects.driveSetupCommand,
2163
+ driveStopCommand: projects.driveStopCommand,
2164
+ driveEnv: projects.driveEnv,
2165
+ dbResetCommand: projects.dbResetCommand
2166
+ };
2167
+ COLUMN_NAME = {
2168
+ setupCommand: "setupCommand",
2169
+ verifyCommands: "verifyCommands",
2170
+ knownFailures: "knownFailures",
2171
+ devCommand: "devCommand",
2172
+ driveSetupCommand: "driveSetupCommand",
2173
+ driveStopCommand: "driveStopCommand",
2174
+ driveEnv: "driveEnv",
2175
+ dbResetCommand: "dbResetCommand"
2176
+ };
2177
+ PREPARED_SET = new Set(PREPARED_KEYS);
2178
+ UNVERIFIED = { verifiedAt: null, verifiedSha: null };
2179
+ });
2180
+
1985
2181
  // src/services/git.ts
1986
2182
  import { existsSync as existsSync5, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
1987
2183
  import { tmpdir } from "os";
@@ -1993,6 +2189,9 @@ function migrationPaths(paths) {
1993
2189
  function featureBranch(slug) {
1994
2190
  return `feature/${slug}`;
1995
2191
  }
2192
+ function mergeTarget(project, feature) {
2193
+ return feature.baseBranch ?? project.mainBranch;
2194
+ }
1996
2195
  function git(repoPath) {
1997
2196
  return simpleGit(repoPath);
1998
2197
  }
@@ -2096,6 +2295,10 @@ async function ensureTalkWorktree(project, feature) {
2096
2295
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2097
2296
  if (await worktreeIsValid(g, worktreePath, branch))
2098
2297
  return worktreePath;
2298
+ if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2299
+ if (await checkoutInWorktree(worktreePath, branch))
2300
+ return worktreePath;
2301
+ }
2099
2302
  return addWorktree(g, worktreePath, branch, "talk worktree");
2100
2303
  }
2101
2304
  async function addWorktree(g, worktreePath, branch, label) {
@@ -2269,6 +2472,25 @@ async function branchCommitsAhead(repoPath, base, branch) {
2269
2472
  return [];
2270
2473
  }
2271
2474
  }
2475
+ async function reviewCommitCount(project, feature) {
2476
+ const base = mergeTarget(project, feature);
2477
+ const branch = featureBranch(feature.slug);
2478
+ try {
2479
+ const out = (await git(project.repoPath).raw(["rev-list", "--count", `${base}..${branch}`])).trim();
2480
+ const n = Number(out);
2481
+ return { base, count: Number.isInteger(n) && n >= 0 ? n : undefined };
2482
+ } catch {
2483
+ return { base };
2484
+ }
2485
+ }
2486
+ async function headSha(repoPath, ref) {
2487
+ try {
2488
+ const out = (await git(repoPath).revparse([ref])).trim();
2489
+ return out.length > 0 ? out : undefined;
2490
+ } catch {
2491
+ return;
2492
+ }
2493
+ }
2272
2494
  async function commitsSince(repoPath, sha, branch) {
2273
2495
  try {
2274
2496
  const out = (await git(repoPath).raw(["rev-list", "--count", `${sha}..${branch}`])).trim();
@@ -2534,20 +2756,23 @@ async function commitDocs(worktreePath, message) {
2534
2756
  await g.commit(message, [DOCS_PATHSPEC]);
2535
2757
  }
2536
2758
  function activeTestDriveFeatureId() {
2537
- return testDriveState?.featureId;
2759
+ return testDriveState?.kind === "feature" ? testDriveState.featureId : undefined;
2538
2760
  }
2539
2761
  function activeDriveInfo() {
2540
2762
  if (!testDriveState)
2541
2763
  return null;
2542
2764
  return {
2543
- featureId: testDriveState.featureId,
2765
+ ...testDriveState.kind === "feature" ? { featureId: testDriveState.featureId } : { dryRun: true },
2544
2766
  branch: testDriveState.branch,
2545
2767
  devPaneId: testDriveState.devPaneId,
2546
- devUrl: testDriveState.devUrl
2768
+ devUrl: testDriveState.devUrl,
2769
+ devConfigured: testDriveState.devConfigured
2547
2770
  };
2548
2771
  }
2549
2772
  function recordDriveUrl(ctx, featureId, url) {
2550
- if (!testDriveState || testDriveState.featureId !== featureId || testDriveState.devUrl)
2773
+ if (testDriveState?.kind !== "feature")
2774
+ return;
2775
+ if (testDriveState.featureId !== featureId || testDriveState.devUrl)
2551
2776
  return;
2552
2777
  testDriveState.devUrl = url;
2553
2778
  emit(ctx, featureId, {
@@ -2559,15 +2784,18 @@ function recordDriveUrl(ctx, featureId, url) {
2559
2784
  async function testDrive(ctx, project, feature, action) {
2560
2785
  const g = git(project.repoPath);
2561
2786
  const branch = featureBranch(feature.slug);
2787
+ const scope = { featureId: feature.id };
2562
2788
  if (action === "stop") {
2563
2789
  if (!testDriveState)
2564
2790
  return { ok: false, deniedReason: DENY_NONE_ACTIVE };
2791
+ if (testDriveState.kind !== "feature")
2792
+ return { ok: false, deniedReason: DENY_DRY_RUN_ACTIVE };
2565
2793
  const previousBranch2 = testDriveState.previousBranch;
2566
2794
  const detachedWorktree2 = testDriveState.detachedWorktree;
2567
2795
  const devPaneId = testDriveState.devPaneId;
2568
2796
  if (devPaneId)
2569
2797
  stopDevPane(devPaneId);
2570
- const teardownFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "teardown", project.driveStopCommand, driveEnvFor(ctx, project, feature, branch));
2798
+ const teardown = await runDriveHookStep(ctx, scope, project.repoPath, "teardown", project.driveStopCommand, driveEnvFor(ctx, project, { slug: feature.slug, branch }, scope).env);
2571
2799
  const carriedChanges = await dirtyPaths(g);
2572
2800
  await g.checkout(previousBranch2);
2573
2801
  if (detachedWorktree2)
@@ -2591,14 +2819,18 @@ async function testDrive(ctx, project, feature, action) {
2591
2819
  branch: previousBranch2,
2592
2820
  ...carriedChanges.length > 0 ? { carriedChanges } : {},
2593
2821
  ...dbDrift ? { dbDrift } : {},
2594
- ...teardownFailure ? { hookFailure: teardownFailure } : {}
2822
+ ...teardown?.failure ? { hookFailure: teardown.failure } : {}
2595
2823
  };
2596
2824
  }
2597
2825
  const porcelain = (await g.raw(["status", "--porcelain"])).trim();
2598
2826
  if (porcelain !== "")
2599
2827
  return { ok: false, deniedReason: DENY_DIRTY };
2600
- if (testDriveState)
2601
- return { ok: false, deniedReason: DENY_ACTIVE };
2828
+ if (testDriveState) {
2829
+ return {
2830
+ ok: false,
2831
+ deniedReason: testDriveState.kind === "dryRun" ? DENY_DRY_RUN_ACTIVE : DENY_ACTIVE
2832
+ };
2833
+ }
2602
2834
  if (hasActiveRun(ctx, feature.id))
2603
2835
  return { ok: false, deniedReason: DENY_ACTIVE_RUN };
2604
2836
  const talkWorktree = worktreeDir(project.id, feature.slug);
@@ -2610,18 +2842,25 @@ async function testDrive(ctx, project, feature, action) {
2610
2842
  }
2611
2843
  const previousBranch = (await g.revparse(["--abbrev-ref", "HEAD"])).trim();
2612
2844
  await g.checkout(branch);
2613
- testDriveState = { featureId: feature.id, branch, previousBranch, detachedWorktree };
2845
+ testDriveState = {
2846
+ kind: "feature",
2847
+ featureId: feature.id,
2848
+ branch,
2849
+ previousBranch,
2850
+ detachedWorktree,
2851
+ devConfigured: !!project.devCommand
2852
+ };
2614
2853
  emit(ctx, feature.id, {
2615
2854
  type: "testdrive.started",
2616
2855
  message: `test driving ${branch} (was on ${previousBranch})`,
2617
2856
  data: { branch, previousBranch }
2618
2857
  });
2619
- const driveEnv = driveEnvFor(ctx, project, feature, branch);
2620
- const setupFailure = await runDriveHookStep(ctx, feature.id, project.repoPath, "setup", project.driveSetupCommand, driveEnv);
2858
+ const driveEnv = driveEnvFor(ctx, project, { slug: feature.slug, branch }, scope).env;
2859
+ const setup = await runDriveHookStep(ctx, scope, project.repoPath, "setup", project.driveSetupCommand, driveEnv);
2621
2860
  if (project.devCommand) {
2622
2861
  const devPaneId = startDevPane({
2623
2862
  ctx,
2624
- featureId: feature.id,
2863
+ scope,
2625
2864
  repoPath: project.repoPath,
2626
2865
  devCommand: project.devCommand,
2627
2866
  env: driveEnv,
@@ -2630,45 +2869,188 @@ async function testDrive(ctx, project, feature, action) {
2630
2869
  if (devPaneId)
2631
2870
  testDriveState.devPaneId = devPaneId;
2632
2871
  }
2633
- return { ok: true, branch, ...setupFailure ? { hookFailure: setupFailure } : {} };
2872
+ return { ok: true, branch, ...setup?.failure ? { hookFailure: setup.failure } : {} };
2873
+ }
2874
+ async function dryRunDrive(ctx, project, action) {
2875
+ if (action === "start")
2876
+ return startDryRun(ctx, project);
2877
+ if (testDriveState?.kind !== "dryRun") {
2878
+ return { ok: false, action, deniedReason: DENY_NO_DRY_RUN };
2879
+ }
2880
+ const state = testDriveState;
2881
+ if (action === "status")
2882
+ return { ok: true, action, ...liveFields(state) };
2883
+ return stopDryRun(ctx, project, state);
2884
+ }
2885
+ async function startDryRun(ctx, project) {
2886
+ if (testDriveState) {
2887
+ return {
2888
+ ok: false,
2889
+ action: "start",
2890
+ deniedReason: testDriveState.kind === "dryRun" ? DENY_DRY_RUN_ACTIVE : DENY_ACTIVE
2891
+ };
2892
+ }
2893
+ const scope = { projectId: project.id };
2894
+ const branch = (await git(project.repoPath).revparse(["--abbrev-ref", "HEAD"])).trim();
2895
+ const identity = { slug: DRY_RUN_SLUG, branch };
2896
+ emitProject(ctx, project.id, {
2897
+ type: "prep.dryrun.started",
2898
+ message: `preparation dry-run drive on ${branch} as \`${DRY_RUN_SLUG}\``,
2899
+ data: { branch, slug: DRY_RUN_SLUG }
2900
+ });
2901
+ const { env, keys, unknown: unknown2 } = driveEnvFor(ctx, project, identity, scope);
2902
+ const state = {
2903
+ kind: "dryRun",
2904
+ projectId: project.id,
2905
+ branch,
2906
+ devConfigured: !!project.devCommand,
2907
+ env,
2908
+ observed: { envUnknowns: unknown2 }
2909
+ };
2910
+ testDriveState = state;
2911
+ const setup = await runDriveHookStep(ctx, scope, project.repoPath, "setup", project.driveSetupCommand, env);
2912
+ state.observed.setupOk = setup?.result.ok;
2913
+ if (project.devCommand) {
2914
+ const devPaneId = startDevPane({
2915
+ ctx,
2916
+ scope,
2917
+ repoPath: project.repoPath,
2918
+ devCommand: project.devCommand,
2919
+ env,
2920
+ onUrl: (url) => recordDryRunUrl(ctx, project.id, url)
2921
+ });
2922
+ if (devPaneId)
2923
+ state.devPaneId = devPaneId;
2924
+ }
2925
+ return {
2926
+ ok: true,
2927
+ action: "start",
2928
+ envKeys: keys,
2929
+ envUnknowns: unknown2,
2930
+ ...setup ? { setup: hookReport(setup) } : {},
2931
+ ...liveFields(state)
2932
+ };
2933
+ }
2934
+ async function stopDryRun(ctx, project, state) {
2935
+ const scope = { projectId: project.id };
2936
+ if (state.devPaneId)
2937
+ stopDevPane(state.devPaneId);
2938
+ const teardown = await runDriveHookStep(ctx, scope, project.repoPath, "teardown", project.driveStopCommand, state.env);
2939
+ state.observed.teardownOk = teardown?.result.ok;
2940
+ testDriveState = undefined;
2941
+ emitProject(ctx, project.id, {
2942
+ type: "prep.dryrun.stopped",
2943
+ message: `preparation dry-run drive stopped on ${state.branch}`,
2944
+ data: { branch: state.branch }
2945
+ });
2946
+ const { participating, failure } = dryRunVerdict(project, state);
2947
+ if (!failure && participating.length > 0) {
2948
+ const sha = await headSha(project.repoPath, project.mainBranch) ?? null;
2949
+ markVerified(ctx, project.id, participating, sha);
2950
+ emitProject(ctx, project.id, {
2951
+ type: "prep.dryrun.verified",
2952
+ message: `dry-run drive verified ${participating.join(", ")}`,
2953
+ data: { keys: participating, sha }
2954
+ });
2955
+ }
2956
+ return {
2957
+ ok: true,
2958
+ action: "stop",
2959
+ identity: { slug: DRY_RUN_SLUG, branch: state.branch },
2960
+ envUnknowns: state.observed.envUnknowns,
2961
+ ...teardown ? { teardown: hookReport(teardown) } : {},
2962
+ verified: failure ? [] : participating,
2963
+ ...failure ? { failure } : {}
2964
+ };
2965
+ }
2966
+ function recordDryRunUrl(ctx, projectId, url) {
2967
+ if (testDriveState?.kind !== "dryRun")
2968
+ return;
2969
+ if (testDriveState.projectId !== projectId || testDriveState.devUrl)
2970
+ return;
2971
+ testDriveState.devUrl = url;
2972
+ emitProject(ctx, projectId, {
2973
+ type: "prep.dryrun.url",
2974
+ message: `dry-run dev server ready at ${url}`,
2975
+ data: { url }
2976
+ });
2977
+ }
2978
+ function dryRunVerdict(project, state) {
2979
+ const participating = [];
2980
+ let failure;
2981
+ for (const key of DRIVE_LOOP_KEYS) {
2982
+ if (!project[key]?.trim())
2983
+ continue;
2984
+ participating.push(key);
2985
+ failure ??= observableFailure(key, state);
2986
+ }
2987
+ return { participating, ...failure ? { failure } : {} };
2988
+ }
2989
+ function observableFailure(key, state) {
2990
+ const { envUnknowns, setupOk, teardownOk } = state.observed;
2991
+ switch (key) {
2992
+ case "driveEnv":
2993
+ return envUnknowns.length === 0 ? undefined : `driveEnv left ${envUnknowns.map((u) => `{{${u}}}`).join(", ")} unsubstituted`;
2994
+ case "driveSetupCommand":
2995
+ return setupOk ? undefined : "driveSetupCommand did not exit 0";
2996
+ case "devCommand":
2997
+ if (!state.devPaneId)
2998
+ return "devCommand never spawned a dev pane";
2999
+ return state.devUrl ? undefined : 'devCommand spawned but printed no localhost URL \u2014 "Open app" depends on one';
3000
+ case "driveStopCommand":
3001
+ return teardownOk ? undefined : "driveStopCommand did not exit 0";
3002
+ }
3003
+ }
3004
+ function liveFields(state) {
3005
+ return {
3006
+ identity: { slug: DRY_RUN_SLUG, branch: state.branch },
3007
+ devConfigured: state.devConfigured,
3008
+ devPaneLive: !!state.devPaneId && devPaneLive(state.devPaneId),
3009
+ ...state.devUrl ? { devUrl: state.devUrl } : {}
3010
+ };
2634
3011
  }
2635
- function driveEnvFor(ctx, project, feature, branch) {
2636
- const { vars, unknown: unknown2 } = parseDriveEnv(project.driveEnv, { slug: feature.slug, branch });
3012
+ function hookReport(run) {
3013
+ const { ok, exitCode, timedOut, output } = run.result;
3014
+ return { command: run.command, ok, exitCode, timedOut, output };
3015
+ }
3016
+ function driveEnvFor(ctx, project, identity, scope) {
3017
+ const { vars, unknown: unknown2 } = parseDriveEnv(project.driveEnv, identity);
2637
3018
  if (unknown2.length > 0) {
2638
- emit(ctx, feature.id, {
3019
+ emitScoped(ctx, scope, {
2639
3020
  type: "testdrive.env_unknown_placeholder",
2640
3021
  message: `drive environment left ${unknown2.map((u) => `{{${u}}}`).join(", ")} unsubstituted \u2014 known variables are {{slug}}, {{branch}}, {{id}}`,
2641
3022
  data: { unknown: unknown2 }
2642
3023
  });
2643
3024
  }
2644
- if (Object.keys(vars).length > 0) {
2645
- emit(ctx, feature.id, {
3025
+ const keys = Object.keys(vars);
3026
+ if (keys.length > 0) {
3027
+ emitScoped(ctx, scope, {
2646
3028
  type: "testdrive.env",
2647
3029
  message: describeDriveEnv(vars),
2648
- data: { keys: Object.keys(vars) }
3030
+ data: { keys }
2649
3031
  });
2650
3032
  }
2651
- return driveProcessEnv(vars);
3033
+ return { env: driveProcessEnv(vars), keys, unknown: unknown2 };
2652
3034
  }
2653
- async function runDriveHookStep(ctx, featureId, repoPath, phase, command, env) {
3035
+ async function runDriveHookStep(ctx, scope, repoPath, phase, command, env) {
2654
3036
  const cmd = command?.trim();
2655
3037
  if (!cmd)
2656
3038
  return;
2657
- emit(ctx, featureId, {
3039
+ emitScoped(ctx, scope, {
2658
3040
  type: `testdrive.${phase}_started`,
2659
3041
  message: phase === "setup" ? `preparing environment: \`${cmd}\`` : `tearing down environment: \`${cmd}\``,
2660
3042
  data: { command: cmd }
2661
3043
  });
2662
3044
  const result = await runDriveHook(cmd, { cwd: repoPath, env });
2663
3045
  if (result.ok) {
2664
- emit(ctx, featureId, {
3046
+ emitScoped(ctx, scope, {
2665
3047
  type: `testdrive.${phase}_ok`,
2666
3048
  message: describeHookResult(cmd, result),
2667
3049
  data: { command: cmd, durationMs: result.durationMs }
2668
3050
  });
2669
- return;
3051
+ return { command: cmd, result };
2670
3052
  }
2671
- emit(ctx, featureId, {
3053
+ emitScoped(ctx, scope, {
2672
3054
  type: `testdrive.${phase}_failed`,
2673
3055
  message: `${describeHookResult(cmd, result)}${result.output ? `
2674
3056
  ${result.output}` : ""}`,
@@ -2680,11 +3062,15 @@ ${result.output}` : ""}`,
2680
3062
  }
2681
3063
  });
2682
3064
  return {
2683
- phase,
2684
3065
  command: cmd,
2685
- exitCode: result.exitCode,
2686
- timedOut: result.timedOut,
2687
- output: result.output
3066
+ result,
3067
+ failure: {
3068
+ phase,
3069
+ command: cmd,
3070
+ exitCode: result.exitCode,
3071
+ timedOut: result.timedOut,
3072
+ output: result.output
3073
+ }
2688
3074
  };
2689
3075
  }
2690
3076
  async function dirtyPaths(g) {
@@ -2717,7 +3103,7 @@ async function detectDbDrift(ctx, project, feature, previousBranch, driveBranch)
2717
3103
  return drift;
2718
3104
  }
2719
3105
  async function mergeFeature(project, feature) {
2720
- const target = feature.baseBranch ?? project.mainBranch;
3106
+ const target = mergeTarget(project, feature);
2721
3107
  if (testDriveState) {
2722
3108
  throw new GateError("Cannot merge while a test drive is active \u2014 stop it first");
2723
3109
  }
@@ -2771,14 +3157,16 @@ async function mergeInProgress(g) {
2771
3157
  return false;
2772
3158
  }
2773
3159
  }
2774
- var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
3160
+ var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", DENY_DRY_RUN_ACTIVE = "A preparation dry-run is in progress \u2014 stop it first", DENY_NO_DRY_RUN = "No preparation dry-run is in progress", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState, DRY_RUN_SLUG = "prep-dry-run";
2775
3161
  var init_git = __esm(() => {
3162
+ init_src();
2776
3163
  init_paths();
2777
3164
  init_errors();
2778
3165
  init_dev_pane();
2779
3166
  init_drive_hooks();
2780
3167
  init_drive_env();
2781
3168
  init_events();
3169
+ init_findings();
2782
3170
  init_repo();
2783
3171
  MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
2784
3172
  DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
@@ -2786,7 +3174,7 @@ var init_git = __esm(() => {
2786
3174
  });
2787
3175
 
2788
3176
  // src/launcher/sessions.ts
2789
- import { and as and4, desc as desc3, eq as eq4, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
3177
+ import { and as and5, desc as desc3, eq as eq5, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
2790
3178
  function createSessionRow(ctx, input) {
2791
3179
  const row = ctx.db.insert(sessions).values({
2792
3180
  id: newId("sess"),
@@ -2802,11 +3190,20 @@ function createSessionRow(ctx, input) {
2802
3190
  return rowToSession(row);
2803
3191
  }
2804
3192
  function getSessionRow(ctx, id) {
2805
- const row = ctx.db.select().from(sessions).where(eq4(sessions.id, id)).get();
3193
+ const row = ctx.db.select().from(sessions).where(eq5(sessions.id, id)).get();
2806
3194
  return row ? rowToSession(row) : null;
2807
3195
  }
2808
3196
  function activeSessionsForFeature(ctx, featureId) {
2809
- return ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), inArray(sessions.status, ["launching", "live"]))).all().map(rowToSession);
3197
+ return ctx.db.select().from(sessions).where(and5(eq5(sessions.featureId, featureId), inArray(sessions.status, ["launching", "live"]))).all().map(rowToSession);
3198
+ }
3199
+ function markAgentWorking(ctx, id) {
3200
+ setAwaitingInput(ctx, id, false);
3201
+ }
3202
+ function markAwaitingInput(ctx, id) {
3203
+ setAwaitingInput(ctx, id, true);
3204
+ }
3205
+ function setAwaitingInput(ctx, id, awaitingInput) {
3206
+ ctx.db.update(sessions).set({ awaitingInput }).where(eq5(sessions.id, id)).run();
2810
3207
  }
2811
3208
  function markSessionLive(ctx, id, input = {}) {
2812
3209
  const existing = getSessionRow(ctx, id);
@@ -2816,7 +3213,7 @@ function markSessionLive(ctx, id, input = {}) {
2816
3213
  status: "live",
2817
3214
  ccSessionId: input.ccSessionId ?? existing.ccSessionId ?? null,
2818
3215
  transcriptPath: input.transcriptPath ?? existing.transcriptPath ?? null
2819
- }).where(eq4(sessions.id, id)).run();
3216
+ }).where(eq5(sessions.id, id)).run();
2820
3217
  const firstTimeLive = existing.status !== "live";
2821
3218
  if (firstTimeLive) {
2822
3219
  promoteLastSession(ctx, id);
@@ -2830,6 +3227,13 @@ function lapKickoff(lap) {
2830
3227
  function kickoffLineFor(kind, override) {
2831
3228
  return override ?? KICKOFF_LINES[kind];
2832
3229
  }
3230
+ function planKickoff(input) {
3231
+ const lapBriefing = input.lap > 1 ? lapKickoff(input.lap) : undefined;
3232
+ const line = input.kickoffLine ?? (input.kind === "ideation" ? lapBriefing : undefined);
3233
+ if (!line)
3234
+ return { explicit: false };
3235
+ return { line, explicit: true, lap: line === lapBriefing ? input.lap : undefined };
3236
+ }
2833
3237
  function resumeKickoffLine(kind) {
2834
3238
  return RESUME_KICKOFF_PREFIX + KICKOFF_LINES[kind];
2835
3239
  }
@@ -2885,6 +3289,7 @@ function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
2885
3289
  return;
2886
3290
  }
2887
3291
  d.attempts += 1;
3292
+ d.written = true;
2888
3293
  const attempt = d.attempts;
2889
3294
  try {
2890
3295
  if (clearFirst)
@@ -2929,6 +3334,8 @@ function noteKickoffPrompt(ctx, sessionId, prompt) {
2929
3334
  const session = getSessionRow(ctx, sessionId);
2930
3335
  if (!session)
2931
3336
  return;
3337
+ if (!d.written)
3338
+ return;
2932
3339
  if (promptMatchesKickoff(d.line, prompt)) {
2933
3340
  d.confirmed = true;
2934
3341
  d.settled = true;
@@ -2962,6 +3369,7 @@ function resendKickoff(ctx, sessionId) {
2962
3369
  line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
2963
3370
  attempts: 0,
2964
3371
  confirmed: false,
3372
+ written: false,
2965
3373
  settled: false,
2966
3374
  timers: new Set
2967
3375
  };
@@ -2990,11 +3398,17 @@ function armSessionReadyWatchdog(ctx, session) {
2990
3398
  }
2991
3399
  function scheduleKickoff(ctx, session) {
2992
3400
  const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
2993
- pendingKickoffOverrides.delete(session.id);
2994
3401
  const existing = deliveries.get(session.id);
2995
3402
  if (existing)
2996
3403
  stopTimers(existing);
2997
- const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
3404
+ const d = {
3405
+ line,
3406
+ attempts: 0,
3407
+ confirmed: false,
3408
+ written: false,
3409
+ settled: false,
3410
+ timers: new Set
3411
+ };
2998
3412
  deliveries.set(session.id, d);
2999
3413
  attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
3000
3414
  }
@@ -3034,26 +3448,26 @@ function markSessionEnded(ctx, id) {
3034
3448
  if (!existing)
3035
3449
  return null;
3036
3450
  forgetKickoff(id);
3037
- ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
3451
+ ctx.db.update(sessions).set({ status: "ended" }).where(eq5(sessions.id, id)).run();
3038
3452
  return getSessionRow(ctx, id);
3039
3453
  }
3040
3454
  function mostRecentResumableSession(ctx, featureId, kind) {
3041
- 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(desc3(sql2`rowid`)).limit(1).get();
3455
+ const row = ctx.db.select().from(sessions).where(and5(eq5(sessions.featureId, featureId), eq5(sessions.status, "ended"), isNotNull(sessions.ccSessionId), ...kind ? [eq5(sessions.kind, kind)] : [])).orderBy(desc3(sql2`rowid`)).limit(1).get();
3042
3456
  return row ? rowToSession(row) : null;
3043
3457
  }
3044
3458
  function activeProjectSession(ctx, projectId, kind) {
3045
- const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc3(sql2`rowid`)).limit(1).get();
3459
+ const row = ctx.db.select().from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc3(sql2`rowid`)).limit(1).get();
3046
3460
  return row ? rowToSession(row) : null;
3047
3461
  }
3048
3462
  function hasCompletedProjectSession(ctx, projectId, kind) {
3049
- return ctx.db.select({ id: sessions.id }).from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), eq4(sessions.status, "ended"))).limit(1).get() !== undefined;
3463
+ return ctx.db.select({ id: sessions.id }).from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), eq5(sessions.status, "ended"))).limit(1).get() !== undefined;
3050
3464
  }
3051
3465
  function mostRecentResumableProjectSession(ctx, projectId, kind) {
3052
- 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(desc3(sql2`rowid`)).limit(1).get();
3466
+ const row = ctx.db.select().from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), eq5(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc3(sql2`rowid`)).limit(1).get();
3053
3467
  return row ? rowToSession(row) : null;
3054
3468
  }
3055
3469
  function mostRecentLiveSession(ctx) {
3056
- const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc3(sql2`rowid`)).limit(1).get();
3470
+ const row = ctx.db.select().from(sessions).where(eq5(sessions.status, "live")).orderBy(desc3(sql2`rowid`)).limit(1).get();
3057
3471
  return row ? rowToSession(row) : null;
3058
3472
  }
3059
3473
  var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, KICKOFF_CONFIRM_MS = 12000, KICKOFF_MAX_ATTEMPTS = 3, CLEAR_INPUT = "\x15", SESSION_READY_TIMEOUT_MS = 25000, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, RESUME_KICKOFF_PREFIX, pendingKickoffOverrides, deliveries, MATCH_PREFIX = 40, landedProjectSessions;
@@ -3149,7 +3563,7 @@ var init_feature_docs = __esm(() => {
3149
3563
  });
3150
3564
 
3151
3565
  // src/services/tickets.ts
3152
- import { and as and5, asc as asc3, eq as eq5 } from "drizzle-orm";
3566
+ import { and as and6, asc as asc3, eq as eq6 } from "drizzle-orm";
3153
3567
  function rowToTicket(row) {
3154
3568
  return {
3155
3569
  id: row.id,
@@ -3179,14 +3593,14 @@ function resolveBlocking(inputs, startSeq) {
3179
3593
  }
3180
3594
  }
3181
3595
  function listByFeature2(ctx, featureId) {
3182
- return ctx.db.select().from(tickets).where(eq5(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
3596
+ return ctx.db.select().from(tickets).where(eq6(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
3183
3597
  }
3184
3598
  function storeTickets(ctx, featureId, inputs) {
3185
3599
  if (inputs.length === 0)
3186
3600
  return [];
3187
3601
  const { lap } = getFeatureRow(ctx, featureId);
3188
- const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq5(tickets.featureId, featureId)).all();
3189
- const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
3602
+ const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq6(tickets.featureId, featureId)).all();
3603
+ const startSeq = existing.reduce((max2, r) => Math.max(max2, r.seq), 0) + 1;
3190
3604
  const resolved = resolveBlocking(inputs, startSeq);
3191
3605
  const rows = inputs.map((t, i) => ({
3192
3606
  id: newId("tkt"),
@@ -3214,7 +3628,7 @@ function storeTickets(ctx, featureId, inputs) {
3214
3628
  return rows.map(rowToTicket);
3215
3629
  }
3216
3630
  function getTicket(ctx, id) {
3217
- const row = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
3631
+ const row = ctx.db.select().from(tickets).where(eq6(tickets.id, id)).get();
3218
3632
  if (!row)
3219
3633
  throw new NotFoundError(`ticket ${id} not found`);
3220
3634
  return rowToTicket(row);
@@ -3241,7 +3655,7 @@ function editTicket(ctx, id, patch) {
3241
3655
  if (Object.keys(set).length === 0) {
3242
3656
  throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
3243
3657
  }
3244
- ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
3658
+ ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
3245
3659
  emit(ctx, current2.featureId, {
3246
3660
  type: "ticket.edited",
3247
3661
  message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
@@ -3253,7 +3667,7 @@ function editTicket(ctx, id, patch) {
3253
3667
  function cancelTicket(ctx, id, reason) {
3254
3668
  const current2 = getTicket(ctx, id);
3255
3669
  assertMutable(current2, "cancel");
3256
- ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq5(tickets.id, id)).run();
3670
+ ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq6(tickets.id, id)).run();
3257
3671
  emit(ctx, current2.featureId, {
3258
3672
  type: "ticket.cancelled",
3259
3673
  message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
@@ -3263,7 +3677,7 @@ function cancelTicket(ctx, id, reason) {
3263
3677
  return getTicket(ctx, id);
3264
3678
  }
3265
3679
  function sweepOrphanedBurning(ctx, featureId, reason) {
3266
- const orphaned = ctx.db.select().from(tickets).where(and5(eq5(tickets.featureId, featureId), eq5(tickets.status, "burning"))).all().map(rowToTicket);
3680
+ const orphaned = ctx.db.select().from(tickets).where(and6(eq6(tickets.featureId, featureId), eq6(tickets.status, "burning"))).all().map(rowToTicket);
3267
3681
  for (const t of orphaned) {
3268
3682
  updateTicket(ctx, t.id, { status: "failed", error: reason });
3269
3683
  emit(ctx, featureId, {
@@ -3276,7 +3690,7 @@ function sweepOrphanedBurning(ctx, featureId, reason) {
3276
3690
  return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
3277
3691
  }
3278
3692
  function updateTicket(ctx, id, patch) {
3279
- const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
3693
+ const current2 = ctx.db.select().from(tickets).where(eq6(tickets.id, id)).get();
3280
3694
  if (!current2)
3281
3695
  throw new NotFoundError(`ticket ${id} not found`);
3282
3696
  const set = {};
@@ -3290,14 +3704,14 @@ function updateTicket(ctx, id, patch) {
3290
3704
  set.attemptBranch = patch.attemptBranch;
3291
3705
  if (patch.conflictFiles !== undefined)
3292
3706
  set.conflictFiles = patch.conflictFiles;
3293
- ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
3707
+ ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
3294
3708
  emit(ctx, current2.featureId, {
3295
3709
  type: "ticket.updated",
3296
3710
  message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
3297
3711
  ticketId: id,
3298
3712
  data: patch
3299
3713
  });
3300
- const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
3714
+ const updated = ctx.db.select().from(tickets).where(eq6(tickets.id, id)).get();
3301
3715
  return rowToTicket(updated);
3302
3716
  }
3303
3717
  var MUTABLE_STATUSES;
@@ -3312,10 +3726,11 @@ var init_tickets = __esm(() => {
3312
3726
 
3313
3727
  // src/services/gates.ts
3314
3728
  import { existsSync as existsSync7 } from "fs";
3729
+ import { and as and7, desc as desc4, eq as eq7, inArray as inArray3 } from "drizzle-orm";
3315
3730
  function checkGate(ctx, check, feature) {
3316
3731
  switch (check) {
3317
3732
  case "decisions-file-exists":
3318
- return fileGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
3733
+ return docGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
3319
3734
  case "all-waypoints-terminal": {
3320
3735
  const wps = listByFeature(ctx, feature.id);
3321
3736
  if (wps.length === 0) {
@@ -3328,7 +3743,7 @@ function checkGate(ctx, check, feature) {
3328
3743
  };
3329
3744
  }
3330
3745
  case "spec-file-exists":
3331
- return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
3746
+ return docGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
3332
3747
  case "tickets-approved": {
3333
3748
  const count = listByFeature2(ctx, feature.id).filter((t) => t.lap === feature.lap && t.status !== "cancelled").length;
3334
3749
  return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
@@ -3362,6 +3777,16 @@ function fileGate(ctx, feature, fileName, reason) {
3362
3777
  const project = projectForFeature(ctx, feature);
3363
3778
  return existsSync7(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
3364
3779
  }
3780
+ function docGate(ctx, feature, fileName, reason) {
3781
+ const file = fileGate(ctx, feature, fileName, reason);
3782
+ if (!file.satisfied || feature.lap === 1)
3783
+ return file;
3784
+ const worked = ctx.db.select({ id: sessions.id }).from(sessions).where(and7(eq7(sessions.featureId, feature.id), eq7(sessions.lap, feature.lap), inArray3(sessions.kind, [...DOC_WRITING_KINDS]))).all();
3785
+ return worked.length > 0 ? { satisfied: true } : {
3786
+ satisfied: false,
3787
+ reason: `${fileName} is an earlier lap's \u2014 no lap ${feature.lap} session has worked this feature yet; open the lap's session before promoting`
3788
+ };
3789
+ }
3365
3790
  function overrideGate(ctx, featureId, gate, reason) {
3366
3791
  const feature = getFeatureRow(ctx, featureId);
3367
3792
  ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
@@ -3375,14 +3800,35 @@ function overrideGate(ctx, featureId, gate, reason) {
3375
3800
  return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
3376
3801
  return feature;
3377
3802
  }
3803
+ function undoLastGateOverride(ctx, featureId, gate) {
3804
+ const last = ctx.db.select({ id: gateOverrides.id }).from(gateOverrides).where(and7(eq7(gateOverrides.featureId, featureId), eq7(gateOverrides.gate, gate))).orderBy(desc4(gateOverrides.id)).get();
3805
+ if (!last)
3806
+ return false;
3807
+ ctx.db.delete(gateOverrides).where(eq7(gateOverrides.id, last.id)).run();
3808
+ return true;
3809
+ }
3810
+ function undoGateOverride(ctx, featureId, gate) {
3811
+ const feature = getFeatureRow(ctx, featureId);
3812
+ const back = previousPhase(feature);
3813
+ if (!back) {
3814
+ throw new GateError(`${feature.phase} is the first phase \u2014 there is nothing to step back to`);
3815
+ }
3816
+ if (!undoLastGateOverride(ctx, featureId, gate)) {
3817
+ throw new GateError(`no ${gate} override is recorded on this feature`);
3818
+ }
3819
+ return setPhase(ctx, featureId, back, "gate.override.undone", `${gate} override undone \u2014 back to ${back}`);
3820
+ }
3821
+ var DOC_WRITING_KINDS;
3378
3822
  var init_gates = __esm(() => {
3379
3823
  init_src();
3380
3824
  init_schema();
3825
+ init_errors();
3381
3826
  init_events();
3382
3827
  init_feature_docs();
3383
3828
  init_repo();
3384
3829
  init_tickets();
3385
3830
  init_waypoints();
3831
+ DOC_WRITING_KINDS = ["ideation", "revisit", "converge"];
3386
3832
  });
3387
3833
 
3388
3834
  // src/services/knowledge.ts
@@ -3819,13 +4265,13 @@ function parseEnvFile(content) {
3819
4265
  if (line.length === 0 || line.startsWith("#"))
3820
4266
  continue;
3821
4267
  const body = line.startsWith("export ") ? line.slice(7).trim() : line;
3822
- const eq6 = body.indexOf("=");
3823
- if (eq6 === -1)
4268
+ const eq8 = body.indexOf("=");
4269
+ if (eq8 === -1)
3824
4270
  continue;
3825
- const key = body.slice(0, eq6).trim();
4271
+ const key = body.slice(0, eq8).trim();
3826
4272
  if (key.length === 0)
3827
4273
  continue;
3828
- let value = body.slice(eq6 + 1).trim();
4274
+ let value = body.slice(eq8 + 1).trim();
3829
4275
  if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
3830
4276
  value = value.slice(1, -1);
3831
4277
  }
@@ -5036,7 +5482,7 @@ var init_registry2 = __esm(() => {
5036
5482
  });
5037
5483
 
5038
5484
  // src/workflows/runner.ts
5039
- import { eq as eq6 } from "drizzle-orm";
5485
+ import { eq as eq8 } from "drizzle-orm";
5040
5486
  function workflowClaimsFeatureBranch(workflowId) {
5041
5487
  return BRANCH_CLAIMING.has(workflowId);
5042
5488
  }
@@ -5065,7 +5511,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
5065
5511
  claim(ctx, opts.claimWaypointId, runId);
5066
5512
  } catch (e) {
5067
5513
  const summary = e instanceof Error ? e.message : "claim failed";
5068
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
5514
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
5069
5515
  throw e;
5070
5516
  }
5071
5517
  }
@@ -5130,7 +5576,7 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
5130
5576
  } finally {
5131
5577
  controllers.delete(runId);
5132
5578
  }
5133
- ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq6(runs.id, runId)).run();
5579
+ ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
5134
5580
  releaseForSession(ctx, runId);
5135
5581
  if (workflowClaimsFeatureBranch(workflow2)) {
5136
5582
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -5178,7 +5624,7 @@ var init_runner = __esm(() => {
5178
5624
  });
5179
5625
 
5180
5626
  // src/services/features.ts
5181
- import { desc as desc4, eq as eq7 } from "drizzle-orm";
5627
+ import { desc as desc5, eq as eq9 } from "drizzle-orm";
5182
5628
  import { rmSync as rmSync2 } from "fs";
5183
5629
  async function createFeature(ctx, input) {
5184
5630
  const project = requireProjectById(ctx, input.projectId);
@@ -5302,7 +5748,8 @@ function getFeatureFull(ctx, id) {
5302
5748
  };
5303
5749
  }
5304
5750
  function list(ctx, projectId) {
5305
- const rows = ctx.db.select().from(features).where(eq7(features.projectId, projectId)).orderBy(desc4(features.createdAt)).all();
5751
+ const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc5(features.createdAt)).all();
5752
+ const lastActivity = latestTsByFeature(ctx, projectId);
5306
5753
  return rows.map((row) => {
5307
5754
  const feature = rowToFeature(row);
5308
5755
  const tickets2 = listByFeature2(ctx, feature.id);
@@ -5314,9 +5761,22 @@ function list(ctx, projectId) {
5314
5761
  failed: tickets2.filter((t) => t.status === "failed").length,
5315
5762
  cancelled: tickets2.filter((t) => t.status === "cancelled").length
5316
5763
  };
5317
- return { ...feature, ticketCounts: counts, activeRun: hasActiveRun(ctx, feature.id) };
5764
+ return {
5765
+ ...feature,
5766
+ ticketCounts: counts,
5767
+ activeRun: hasActiveRun(ctx, feature.id),
5768
+ liveSession: liveSessionOf(ctx, feature.id),
5769
+ lastActivityAt: lastActivity.get(feature.id) ?? feature.createdAt
5770
+ };
5318
5771
  });
5319
5772
  }
5773
+ function liveSessionOf(ctx, featureId) {
5774
+ const open = activeSessionsForFeature(ctx, featureId);
5775
+ const session = open.find((s) => s.status === "live") ?? open[0];
5776
+ if (!session || session.status === "ended")
5777
+ return null;
5778
+ return { status: session.status, awaitingInput: session.awaitingInput };
5779
+ }
5320
5780
  function advance(ctx, featureId) {
5321
5781
  const feature = getFeatureRow(ctx, featureId);
5322
5782
  const gate = nextGate(feature);
@@ -5372,10 +5832,17 @@ async function burn(ctx, featureId, opts = {}) {
5372
5832
  } else {
5373
5833
  setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
5374
5834
  }
5375
- const { runId } = await startRun(ctx, featureId, "ticket-burner", {
5376
- modelOverride: opts.modelOverride
5377
- });
5378
- return { runId };
5835
+ try {
5836
+ const { runId } = await startRun(ctx, featureId, "ticket-burner", {
5837
+ modelOverride: opts.modelOverride
5838
+ });
5839
+ return { runId };
5840
+ } catch (e) {
5841
+ if (iterating) {
5842
+ setPhase(ctx, featureId, REVIEW_LOOP_BACK.from, "burn.aborted", `the burn never started (${errMsg2(e)}) \u2014 back at review`);
5843
+ }
5844
+ throw e;
5845
+ }
5379
5846
  }
5380
5847
  function rethink(ctx, featureId) {
5381
5848
  const feature = getFeatureRow(ctx, featureId);
@@ -5389,17 +5856,34 @@ function rethink(ctx, featureId) {
5389
5856
  if (live.length > 0) {
5390
5857
  throw new GateError(`a ${live[0].kind} session is already live for ${feature.slug} \u2014 only one terminal per feature; end or resume it first`);
5391
5858
  }
5859
+ if (activeTestDriveFeatureId() === featureId) {
5860
+ throw new GateError(`${feature.slug} is being test-driven \u2014 stop the test drive first, it holds the feature branch`);
5861
+ }
5392
5862
  const lap = feature.lap + 1;
5393
- ctx.db.update(features).set({ lap }).where(eq7(features.id, featureId)).run();
5863
+ ctx.db.update(features).set({ lap }).where(eq9(features.id, featureId)).run();
5394
5864
  return setPhase(ctx, featureId, RETHINK_LOOP_BACK.to, "lap.started", `rethink \u2014 lap ${lap}`);
5395
5865
  }
5396
- async function retryTicket(ctx, ticketId, opts = {}) {
5397
- const ticket = getTicket(ctx, ticketId);
5398
- const feature = getFeatureRow(ctx, ticket.featureId);
5399
- if (ticket.status !== "failed") {
5400
- throw new GateError(`only failed tickets can be retried \u2014 ticket ${ticket.seq} is ${ticket.status}`);
5401
- }
5402
- if (hasActiveRun(ctx, feature.id)) {
5866
+ async function rethinkAndLaunch(ctx, featureId, launch) {
5867
+ const before = getFeatureRow(ctx, featureId);
5868
+ const feature = rethink(ctx, featureId);
5869
+ try {
5870
+ return await launch(feature);
5871
+ } catch (e) {
5872
+ ctx.db.update(features).set({ lap: before.lap }).where(eq9(features.id, featureId)).run();
5873
+ setPhase(ctx, featureId, before.phase, "lap.aborted", `lap ${feature.lap} aborted \u2014 its terminal could not be opened (${errMsg2(e)}); back at ${before.phase} on lap ${before.lap}`);
5874
+ throw e;
5875
+ }
5876
+ }
5877
+ function errMsg2(e) {
5878
+ return e instanceof Error ? e.message : String(e);
5879
+ }
5880
+ async function retryTicket(ctx, ticketId, opts = {}) {
5881
+ const ticket = getTicket(ctx, ticketId);
5882
+ const feature = getFeatureRow(ctx, ticket.featureId);
5883
+ if (ticket.status !== "failed") {
5884
+ throw new GateError(`only failed tickets can be retried \u2014 ticket ${ticket.seq} is ${ticket.status}`);
5885
+ }
5886
+ if (hasActiveRun(ctx, feature.id)) {
5403
5887
  throw new GateError("a run is live for this feature \u2014 retry after it finishes, or cancel it first");
5404
5888
  }
5405
5889
  const project = projectForFeature(ctx, feature);
@@ -5464,7 +5948,7 @@ function escalateToMap(ctx, featureId, input) {
5464
5948
  return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
5465
5949
  }
5466
5950
  const project = projectForFeature(ctx, feature);
5467
- ctx.db.update(features).set({ mapped: true }).where(eq7(features.id, featureId)).run();
5951
+ ctx.db.update(features).set({ mapped: true }).where(eq9(features.id, featureId)).run();
5468
5952
  scaffoldMapDoc(project, { ...feature, mapped: true }, input);
5469
5953
  emit(ctx, featureId, {
5470
5954
  type: "feature.escalated",
@@ -5481,7 +5965,7 @@ function archiveFeature(ctx, featureId) {
5481
5965
  const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
5482
5966
  if (live)
5483
5967
  endSession(ctx, live.id);
5484
- ctx.db.update(features).set({ status: "archived" }).where(eq7(features.id, featureId)).run();
5968
+ ctx.db.update(features).set({ status: "archived" }).where(eq9(features.id, featureId)).run();
5485
5969
  emit(ctx, featureId, {
5486
5970
  type: "feature.archived",
5487
5971
  message: `feature ${feature.slug} archived`,
@@ -5495,7 +5979,7 @@ function unarchiveFeature(ctx, featureId) {
5495
5979
  throw new GateError(`feature ${feature.slug} is not archived`);
5496
5980
  }
5497
5981
  const restored = feature.phase === "shipped" ? "shipped" : "active";
5498
- ctx.db.update(features).set({ status: restored }).where(eq7(features.id, featureId)).run();
5982
+ ctx.db.update(features).set({ status: restored }).where(eq9(features.id, featureId)).run();
5499
5983
  emit(ctx, featureId, {
5500
5984
  type: "feature.unarchived",
5501
5985
  message: `feature ${feature.slug} unarchived (${restored})`,
@@ -5535,13 +6019,13 @@ async function deleteFeature(ctx, featureId) {
5535
6019
  return { ok: true, slug: feature.slug };
5536
6020
  }
5537
6021
  function deleteFeatureRows(ctx, featureId) {
5538
- ctx.db.delete(tickets).where(eq7(tickets.featureId, featureId)).run();
5539
- ctx.db.delete(sessions).where(eq7(sessions.featureId, featureId)).run();
5540
- ctx.db.delete(runs).where(eq7(runs.featureId, featureId)).run();
5541
- ctx.db.delete(events).where(eq7(events.featureId, featureId)).run();
5542
- ctx.db.delete(gateOverrides).where(eq7(gateOverrides.featureId, featureId)).run();
5543
- ctx.db.delete(waypoints).where(eq7(waypoints.featureId, featureId)).run();
5544
- ctx.db.delete(features).where(eq7(features.id, featureId)).run();
6022
+ ctx.db.delete(tickets).where(eq9(tickets.featureId, featureId)).run();
6023
+ ctx.db.delete(sessions).where(eq9(sessions.featureId, featureId)).run();
6024
+ ctx.db.delete(runs).where(eq9(runs.featureId, featureId)).run();
6025
+ ctx.db.delete(events).where(eq9(events.featureId, featureId)).run();
6026
+ ctx.db.delete(gateOverrides).where(eq9(gateOverrides.featureId, featureId)).run();
6027
+ ctx.db.delete(waypoints).where(eq9(waypoints.featureId, featureId)).run();
6028
+ ctx.db.delete(features).where(eq9(features.id, featureId)).run();
5545
6029
  }
5546
6030
  function gateState(ctx, feature) {
5547
6031
  const gate = nextGate(feature);
@@ -5552,7 +6036,7 @@ function gateState(ctx, feature) {
5552
6036
  }
5553
6037
  function uniqueSlug(ctx, projectId, title) {
5554
6038
  const base = slugify(title);
5555
- const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq7(features.projectId, projectId)).all().map((r) => r.slug));
6039
+ const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq9(features.projectId, projectId)).all().map((r) => r.slug));
5556
6040
  if (!existing.has(base))
5557
6041
  return base;
5558
6042
  let n = 2;
@@ -5581,126 +6065,6 @@ var init_features = __esm(() => {
5581
6065
  init_runner();
5582
6066
  });
5583
6067
 
5584
- // src/services/findings.ts
5585
- import { and as and6, eq as eq8 } from "drizzle-orm";
5586
- function isPreparedKey(key) {
5587
- return PREPARED_SET.has(key);
5588
- }
5589
- function findingRow(ctx, projectId, key) {
5590
- return ctx.db.select({
5591
- source: projectFindings.source,
5592
- evidence: projectFindings.evidence,
5593
- establishedAt: projectFindings.establishedAt,
5594
- establishedSha: projectFindings.establishedSha
5595
- }).from(projectFindings).where(and6(eq8(projectFindings.projectId, projectId), eq8(projectFindings.key, key))).get();
5596
- }
5597
- function isOverwritable(ctx, projectId, key) {
5598
- return findingRow(ctx, projectId, key)?.source !== "human";
5599
- }
5600
- function recordFinding(ctx, projectId, input) {
5601
- const { key, value, source, evidence, establishedSha } = input;
5602
- ctx.db.transaction((tx) => {
5603
- tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq8(projects.id, projectId)).run();
5604
- if (value === null) {
5605
- tx.delete(projectFindings).where(and6(eq8(projectFindings.projectId, projectId), eq8(projectFindings.key, key))).run();
5606
- return;
5607
- }
5608
- tx.insert(projectFindings).values({
5609
- projectId,
5610
- key,
5611
- source,
5612
- evidence: evidence ?? null,
5613
- establishedAt: Date.now(),
5614
- establishedSha: establishedSha ?? null
5615
- }).onConflictDoUpdate({
5616
- target: [projectFindings.projectId, projectFindings.key],
5617
- set: {
5618
- source,
5619
- evidence: evidence ?? null,
5620
- establishedAt: Date.now(),
5621
- establishedSha: establishedSha ?? null
5622
- }
5623
- }).run();
5624
- });
5625
- }
5626
- function recordHuman(ctx, projectId, key, value) {
5627
- if (!isPreparedKey(key))
5628
- return;
5629
- const existing = findingRow(ctx, projectId, key);
5630
- if (value === null) {
5631
- if (existing) {
5632
- ctx.db.delete(projectFindings).where(and6(eq8(projectFindings.projectId, projectId), eq8(projectFindings.key, key))).run();
5633
- }
5634
- return;
5635
- }
5636
- ctx.db.insert(projectFindings).values({
5637
- projectId,
5638
- key,
5639
- source: "human",
5640
- evidence: null,
5641
- establishedAt: Date.now(),
5642
- establishedSha: null
5643
- }).onConflictDoUpdate({
5644
- target: [projectFindings.projectId, projectFindings.key],
5645
- set: { source: "human", evidence: null, establishedAt: Date.now(), establishedSha: null }
5646
- }).run();
5647
- }
5648
- async function listFindings(ctx, project) {
5649
- const rows = ctx.db.select().from(projectFindings).where(eq8(projectFindings.projectId, project.id)).all();
5650
- const distances = new Map;
5651
- for (const row of rows) {
5652
- const sha = row.establishedSha;
5653
- if (!sha || distances.has(sha))
5654
- continue;
5655
- distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
5656
- }
5657
- const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
5658
- return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
5659
- const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
5660
- return {
5661
- key: row.key,
5662
- source: row.source,
5663
- ...row.evidence ? { evidence: row.evidence } : {},
5664
- establishedAt: row.establishedAt,
5665
- ...row.establishedSha ? { establishedSha: row.establishedSha } : {},
5666
- ...staleCommits !== undefined ? { staleCommits } : {}
5667
- };
5668
- });
5669
- }
5670
- function unsetPreparedKeys(project) {
5671
- return PREPARED_KEYS.filter((key) => {
5672
- const value = project[key];
5673
- return value === undefined || value.trim() === "";
5674
- });
5675
- }
5676
- var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET;
5677
- var init_findings = __esm(() => {
5678
- init_src();
5679
- init_schema();
5680
- init_git();
5681
- VALUE_COLUMN = {
5682
- setupCommand: projects.setupCommand,
5683
- verifyCommands: projects.verifyCommands,
5684
- knownFailures: projects.knownFailures,
5685
- devCommand: projects.devCommand,
5686
- driveSetupCommand: projects.driveSetupCommand,
5687
- driveStopCommand: projects.driveStopCommand,
5688
- driveEnv: projects.driveEnv,
5689
- dbResetCommand: projects.dbResetCommand
5690
- };
5691
- COLUMN_NAME = {
5692
- setupCommand: "setupCommand",
5693
- verifyCommands: "verifyCommands",
5694
- knownFailures: "knownFailures",
5695
- devCommand: "devCommand",
5696
- driveSetupCommand: "driveSetupCommand",
5697
- driveStopCommand: "driveStopCommand",
5698
- driveEnv: "driveEnv",
5699
- dbResetCommand: "dbResetCommand"
5700
- };
5701
- PREPARED_SET = new Set(PREPARED_KEYS);
5702
- });
5703
-
5704
6068
  // src/mcp/server.ts
5705
6069
  import { StreamableHTTPTransport } from "@hono/mcp";
5706
6070
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -5871,6 +6235,13 @@ function toolRecordFinding(ctx, session, input) {
5871
6235
  });
5872
6236
  return { ok: true, key: input.key, source };
5873
6237
  }
6238
+ async function toolDryRunDrive(ctx, session, input) {
6239
+ const project = requireProject(ctx, session);
6240
+ if (session.kind !== "prepare") {
6241
+ throw new GateError(`the dry-run drive belongs to a preparation conversation, and this is a ${session.kind} ` + "session. It starts services and creates a database on the human's machine \u2014 only the " + "session that established those values may run them.");
6242
+ }
6243
+ return dryRunDrive(ctx, project, input.action);
6244
+ }
5874
6245
  async function toolCreateFeature(ctx, session, input) {
5875
6246
  const project = requireProject(ctx, session);
5876
6247
  const feature = input.ticket ? await quickChange(ctx, {
@@ -5986,6 +6357,16 @@ function buildMcpServer() {
5986
6357
  return noSession();
5987
6358
  return ok(toolRecordFinding(rs.ctx, rs.session, args));
5988
6359
  });
6360
+ server.registerTool("dry_run_drive", {
6361
+ title: "Dry-run the test drive",
6362
+ description: "Prove the drive keys you recorded by having the server run its REAL test-drive " + "machinery \u2014 same env rendering, same hooks, same dev pane \u2014 under a synthetic identity " + "(slug `prep-dry-run`) on the current branch. Nothing is checked out. `start` renders " + "driveEnv, runs driveSetupCommand and spawns devCommand; `status` reports the pane and " + "the sniffed localhost URL while you inspect; `stop` runs driveStopCommand and rules on " + "the run. Ask the human before starting: it starts services and creates a database on " + "their machine. A clean full pass stamps the participating keys verified, computed from " + "what the machinery observed \u2014 your own checks decide whether to fix and re-run, never " + "the stamp.",
6363
+ inputSchema: { action: z3.enum(["start", "status", "stop"]) }
6364
+ }, async (args, extra) => {
6365
+ const rs = await resolveCtxSession(extra);
6366
+ if (!rs)
6367
+ return noSession();
6368
+ return ok(await toolDryRunDrive(rs.ctx, rs.session, args));
6369
+ });
5989
6370
  server.registerTool("create_feature", {
5990
6371
  title: "Create a feature",
5991
6372
  description: "Create a feature from the project session \u2014 the end of intake. Pass `brief` with the " + "reasoning you just worked out with the human (why this feature exists, what it must NOT " + "swallow): it becomes brief.md verbatim, and without it that reasoning is lost. Pass " + "`ticket: { prose }` for a quick change \u2014 work too small to deserve a grill \u2014 which " + "creates the feature at the implementation phase with that one ticket, and whose brief.md " + "is the prose itself (so `brief` is unused there). This does NOT open " + "a terminal on what it creates; the new card in the rail is the feedback, and the human " + "decides what to work on next.",
@@ -6234,7 +6615,51 @@ var init_ws = __esm(() => {
6234
6615
  };
6235
6616
  });
6236
6617
 
6618
+ // src/launcher/edit-guard.ts
6619
+ import { isAbsolute, relative as relative2, resolve as resolve7 } from "path";
6620
+ function guardsEdits(kind) {
6621
+ return kind !== "project";
6622
+ }
6623
+ function evaluateEditGuard(input) {
6624
+ if (!guardsEdits(input.kind))
6625
+ return null;
6626
+ if (!input.toolName || !EDIT_TOOLS.includes(input.toolName))
6627
+ return null;
6628
+ if (!input.filePath)
6629
+ return null;
6630
+ if (!input.featureSlug) {
6631
+ return {
6632
+ reason: `This ${input.kind} session does not edit files \u2014 it runs in the developer's own ` + "checkout. Record what you establish with the `record_finding` MCP tool, and ask the " + "human to make any change to the repo itself."
6633
+ };
6634
+ }
6635
+ const docs = featureDocsRel(input.featureSlug);
6636
+ const docsDir = resolve7(input.worktreePath, docs);
6637
+ const target = resolve7(input.worktreePath, input.filePath);
6638
+ const rel = relative2(docsDir, target);
6639
+ if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel))
6640
+ return null;
6641
+ return {
6642
+ reason: `Talk sessions do not write code. This ${input.kind} session may only write this ` + `feature's docs under \`${docs}/\` \u2014 ${input.filePath} is outside them. The change you ` + "want belongs in a ticket: capture the decision in `decisions.md`, amend `spec.md`, and " + "emit a ticket for the work. An implementation agent burns it in its own sandbox."
6643
+ };
6644
+ }
6645
+ function editDenyResponse(denial) {
6646
+ return {
6647
+ hookSpecificOutput: {
6648
+ hookEventName: "PreToolUse",
6649
+ permissionDecision: "deny",
6650
+ permissionDecisionReason: denial.reason
6651
+ }
6652
+ };
6653
+ }
6654
+ var EDIT_TOOLS, EDIT_TOOL_MATCHER;
6655
+ var init_edit_guard = __esm(() => {
6656
+ init_paths();
6657
+ EDIT_TOOLS = ["Edit", "Write", "NotebookEdit"];
6658
+ EDIT_TOOL_MATCHER = EDIT_TOOLS.join("|");
6659
+ });
6660
+
6237
6661
  // src/services/prep.ts
6662
+ import { and as and8, desc as desc6, eq as eq10, inArray as inArray4 } from "drizzle-orm";
6238
6663
  function keysToPrepare(ctx, project) {
6239
6664
  return unsetPreparedKeys(project).filter((key) => isOverwritable(ctx, project.id, key));
6240
6665
  }
@@ -6243,16 +6668,33 @@ function isPrepared(ctx, project) {
6243
6668
  return true;
6244
6669
  return hasCompletedProjectSession(ctx, project.id, "prepare");
6245
6670
  }
6671
+ function preparedAt(ctx, projectId) {
6672
+ const prepareSessions = new Set(ctx.db.select({ id: sessions.id }).from(sessions).where(and8(eq10(sessions.projectId, projectId), eq10(sessions.kind, "prepare"))).all().map((r) => r.id));
6673
+ if (prepareSessions.size === 0)
6674
+ return null;
6675
+ const ends = ctx.db.select({ ts: events.ts, data: events.data }).from(events).where(and8(eq10(events.projectId, projectId), inArray4(events.type, ["session.ended", "session.reconciled"]))).orderBy(desc6(events.ts)).all();
6676
+ for (const end of ends) {
6677
+ const sessionId = end.data?.sessionId;
6678
+ if (sessionId && prepareSessions.has(sessionId))
6679
+ return end.ts;
6680
+ }
6681
+ return null;
6682
+ }
6246
6683
  async function prepView(ctx, project) {
6684
+ const drive = activeDriveInfo();
6247
6685
  return {
6248
6686
  pendingKeys: keysToPrepare(ctx, project),
6249
6687
  findings: await listFindings(ctx, project),
6250
- prepared: isPrepared(ctx, project)
6688
+ prepared: isPrepared(ctx, project),
6689
+ preparedAt: preparedAt(ctx, project.id),
6690
+ dryRun: drive?.dryRun ? drive : null
6251
6691
  };
6252
6692
  }
6253
6693
  var init_prep = __esm(() => {
6694
+ init_schema();
6254
6695
  init_sessions();
6255
6696
  init_findings();
6697
+ init_git();
6256
6698
  });
6257
6699
 
6258
6700
  // src/routes/hooks.ts
@@ -6277,52 +6719,70 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
6277
6719
  }
6278
6720
  };
6279
6721
  }
6280
- function handlePrepareSessionStart(ctx, session, payload) {
6722
+ function handleProjectScopedSessionStart(ctx, session, payload) {
6281
6723
  const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
6282
6724
  const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
6283
6725
  const source = typeof payload?.source === "string" ? payload.source : undefined;
6726
+ const noun = projectScopedNoun(session.kind);
6284
6727
  const wasLive = getSessionRow(ctx, session.id)?.status === "live";
6285
6728
  markSessionLive(ctx, session.id, { ccSessionId, transcriptPath });
6286
6729
  if (!wasLive) {
6287
6730
  emitForSession(ctx, session, {
6288
6731
  type: "session.started",
6289
- message: source === "resume" ? "preparation session live (resumed)" : "preparation session live",
6732
+ message: source === "resume" ? `${noun} live (resumed)` : `${noun} live`,
6290
6733
  data: { sessionId: session.id, ccSessionId, transcriptPath, source: source ?? null }
6291
6734
  });
6292
6735
  }
6293
6736
  return {
6294
6737
  hookSpecificOutput: {
6295
6738
  hookEventName: "SessionStart",
6296
- additionalContext: prepareStartContext(ctx, session)
6739
+ additionalContext: projectScopedStartContext(ctx, session)
6297
6740
  }
6298
6741
  };
6299
6742
  }
6300
- function handlePrepareUserPrompt(ctx, session, payload) {
6743
+ function handleProjectScopedUserPrompt(ctx, session, payload) {
6301
6744
  const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
6302
6745
  noteKickoffPrompt(ctx, session.id, prompt);
6303
6746
  return {
6304
6747
  hookSpecificOutput: {
6305
6748
  hookEventName: "UserPromptSubmit",
6306
- additionalContext: `[runcastle] preparation session (project-scoped, no feature)`
6749
+ additionalContext: projectScopedPromptLabel(session.kind)
6307
6750
  }
6308
6751
  };
6309
6752
  }
6310
- function handlePrepareSessionEnd(ctx, session) {
6753
+ function handleProjectScopedSessionEnd(ctx, session) {
6311
6754
  markSessionEnded(ctx, session.id);
6312
6755
  emitForSession(ctx, session, {
6313
6756
  type: "session.ended",
6314
- message: "preparation session ended",
6757
+ message: `${projectScopedNoun(session.kind)} ended`,
6315
6758
  data: { sessionId: session.id }
6316
6759
  });
6317
6760
  return {};
6318
6761
  }
6319
- function prepareStartContext(ctx, session) {
6762
+ function projectScopedNoun(kind) {
6763
+ switch (kind) {
6764
+ case "prepare":
6765
+ return "preparation session";
6766
+ case "project":
6767
+ return "project session";
6768
+ default:
6769
+ return "session";
6770
+ }
6771
+ }
6772
+ function projectScopedPromptLabel(kind) {
6773
+ return kind === "prepare" ? "[runcastle] preparation session (project-scoped, no feature)" : `[runcastle] ${projectScopedNoun(kind)}`;
6774
+ }
6775
+ function projectScopedStartContext(ctx, session) {
6776
+ const noun = projectScopedNoun(session.kind);
6320
6777
  const project = session.projectId ? getProjectById(ctx, session.projectId) : null;
6321
6778
  if (!project)
6322
- return "[runcastle] preparation session \u2014 project not found";
6779
+ return `[runcastle] ${noun} \u2014 project not found`;
6780
+ const scope = `[runcastle] ${noun} for ${project.name} (${project.repoPath})`;
6781
+ if (session.kind !== "prepare")
6782
+ return scope;
6323
6783
  const remaining = keysToPrepare(ctx, project);
6324
6784
  return [
6325
- `[runcastle] preparation session for ${project.name} (${project.repoPath}).`,
6785
+ `${scope}.`,
6326
6786
  remaining.length > 0 ? `Still unestablished: ${remaining.join(", ")}.` : "Every prepared field currently has a value.",
6327
6787
  "Record what you establish with the `record_finding` MCP tool \u2014 a value the user",
6328
6788
  "gives you or confirms verbatim is theirs (userSupplied: true), anything you measured is not."
@@ -6349,6 +6809,19 @@ function handleSessionEnd(ctx, sessionId, feature) {
6349
6809
  });
6350
6810
  return {};
6351
6811
  }
6812
+ function handlePreToolUse(session, feature, payload) {
6813
+ const toolName = typeof payload?.tool_name === "string" ? payload.tool_name : undefined;
6814
+ const toolInput = payload?.tool_input ?? {};
6815
+ const path = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.notebook_path === "string" ? toolInput.notebook_path : undefined;
6816
+ const denial = evaluateEditGuard({
6817
+ kind: session.kind,
6818
+ worktreePath: session.worktreePath,
6819
+ toolName,
6820
+ filePath: path,
6821
+ featureSlug: feature?.slug
6822
+ });
6823
+ return denial ? editDenyResponse(denial) : {};
6824
+ }
6352
6825
  function sessionStartContext(ctx, feature) {
6353
6826
  const tickets2 = listByFeature2(ctx, feature.id);
6354
6827
  const byStatus = tickets2.reduce((acc, t) => {
@@ -6358,7 +6831,7 @@ function sessionStartContext(ctx, feature) {
6358
6831
  const ticketSummary = tickets2.length === 0 ? "none yet" : `${tickets2.length} (${Object.entries(byStatus).map(([s, n]) => `${n} ${s}`).join(", ")})`;
6359
6832
  return [
6360
6833
  `[runcastle] ${feature.title} \u2014 ${feature.oneLiner}`,
6361
- `phase: ${feature.phase}; branch: ${feature.branch}`,
6834
+ `phase: ${feature.phase}; lap: ${feature.lap}; branch: ${feature.branch}`,
6362
6835
  `docs: docs/features/${feature.slug}/ (brief.md, decisions.md, spec.md)`,
6363
6836
  `tickets: ${ticketSummary}`,
6364
6837
  "Call the runcastle MCP tool `get_feature_context` for the full docs + tickets."
@@ -6367,6 +6840,7 @@ function sessionStartContext(ctx, feature) {
6367
6840
  }
6368
6841
  var hooks, hooks_default;
6369
6842
  var init_hooks = __esm(() => {
6843
+ init_edit_guard();
6370
6844
  init_sessions();
6371
6845
  init_events();
6372
6846
  init_prep();
@@ -6391,14 +6865,22 @@ var init_hooks = __esm(() => {
6391
6865
  const session = getSessionRow(ctx, sessionId);
6392
6866
  if (!session)
6393
6867
  return c.json({});
6868
+ if (event === "user-prompt")
6869
+ markAgentWorking(ctx, sessionId);
6870
+ if (event === "stop") {
6871
+ markAwaitingInput(ctx, sessionId);
6872
+ return c.json({});
6873
+ }
6394
6874
  if (!session.featureId) {
6395
6875
  switch (event) {
6396
6876
  case "session-start":
6397
- return c.json(handlePrepareSessionStart(ctx, session, body.payload));
6877
+ return c.json(handleProjectScopedSessionStart(ctx, session, body.payload));
6398
6878
  case "user-prompt":
6399
- return c.json(handlePrepareUserPrompt(ctx, session, body.payload));
6879
+ return c.json(handleProjectScopedUserPrompt(ctx, session, body.payload));
6400
6880
  case "session-end":
6401
- return c.json(handlePrepareSessionEnd(ctx, session));
6881
+ return c.json(handleProjectScopedSessionEnd(ctx, session));
6882
+ case "pre-tool":
6883
+ return c.json(handlePreToolUse(session, undefined, body.payload));
6402
6884
  default:
6403
6885
  return c.json({});
6404
6886
  }
@@ -6413,6 +6895,8 @@ var init_hooks = __esm(() => {
6413
6895
  return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
6414
6896
  case "session-end":
6415
6897
  return c.json(handleSessionEnd(ctx, sessionId, feature));
6898
+ case "pre-tool":
6899
+ return c.json(handlePreToolUse(session, feature, body.payload));
6416
6900
  default:
6417
6901
  return c.json({});
6418
6902
  }
@@ -6477,7 +6961,7 @@ var init_stream = __esm(() => {
6477
6961
  // src/routes/web.ts
6478
6962
  import { existsSync as existsSync12, statSync } from "fs";
6479
6963
  import { readFile } from "fs/promises";
6480
- import { extname, join as join11, normalize, resolve as resolve7, sep as sep2 } from "path";
6964
+ import { extname, join as join11, normalize, resolve as resolve8, sep as sep2 } from "path";
6481
6965
  function contentType(path) {
6482
6966
  return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
6483
6967
  }
@@ -6503,11 +6987,11 @@ function safeJoin(root, pathname) {
6503
6987
  function resolveWebDist() {
6504
6988
  const override = process.env.RUNCASTLE_WEB_DIST;
6505
6989
  if (override)
6506
- return resolve7(override);
6507
- return resolve7(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
6990
+ return resolve8(override);
6991
+ return resolve8(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
6508
6992
  }
6509
6993
  function mountWebApp(app, distDir) {
6510
- const root = resolve7(distDir);
6994
+ const root = resolve8(distDir);
6511
6995
  const indexHtml = join11(root, "index.html");
6512
6996
  app.get("*", async (c, next) => {
6513
6997
  const { pathname } = new URL(c.req.url);
@@ -6611,13 +7095,16 @@ function hookClientPath() {
6611
7095
  function serverUrlFor(config2) {
6612
7096
  return `http://localhost:${config2.serverPort}`;
6613
7097
  }
6614
- function renderSystemPrompt(feature, kind, waypoint) {
7098
+ function noCodeRule(docs) {
7099
+ return `- **Talk sessions do not write code.** You may write this feature's docs under \`${docs}/\` ` + "and nothing else in this checkout. Every code change rides a ticket, burned by an " + "implementation agent in its own sandbox \u2014 even the one-line fix that is obviously faster " + "to just do. This is enforced by a hook, not left to your judgement: edits outside the docs " + "are denied.";
7100
+ }
7101
+ function renderSystemPrompt(feature, kind, waypoint, lap) {
6615
7102
  if (kind === "waypoint")
6616
7103
  return renderWaypointPrompt(feature, waypoint);
6617
7104
  if (kind === "converge")
6618
7105
  return renderConvergePrompt(feature);
6619
7106
  if (kind === "revisit")
6620
- return renderRevisitPrompt(feature);
7107
+ return renderRevisitPrompt(feature, lap);
6621
7108
  const docs = featureDocsRel(feature.slug);
6622
7109
  const entry = kind === "ideation" ? "Begin by invoking the `/runcastle:ideate` skill and drive the ideation session to completion." : "This is a Q&A session: invoke `/runcastle:qa`. Answer questions from the docs + code. Do NOT advance phases or emit tickets.";
6623
7110
  return [
@@ -6653,6 +7140,9 @@ function renderSystemPrompt(feature, kind, waypoint) {
6653
7140
  " acceptanceCriteria, seams, blockedBy = 1-based positions within the batch).",
6654
7141
  "- `complete_phase({ phase })` \u2014 mark a phase done; advances past its gate.",
6655
7142
  "",
7143
+ "## Rules",
7144
+ noCodeRule(docs),
7145
+ "",
6656
7146
  "## Your task",
6657
7147
  entry,
6658
7148
  ""
@@ -6743,9 +7233,30 @@ function renderConvergePrompt(feature) {
6743
7233
  ].join(`
6744
7234
  `);
6745
7235
  }
6746
- function renderRevisitPrompt(feature) {
7236
+ function renderRevisitPrompt(feature, lap) {
6747
7237
  const docs = featureDocsRel(feature.slug);
6748
- const reviewIteration = feature.phase === "review" ? [
7238
+ const lapIteration = lap ? [
7239
+ `## This is lap ${lap}`,
7240
+ `You are running **lap ${lap}** of this feature (ADR-0010): the human test-drove what`,
7241
+ `lap ${lap - 1} burned and came back with what it taught them. Unlike an`,
7242
+ "ordinary revisit, a lap MOVES the pipeline \u2014 you drive the whole front half of it in",
7243
+ "THIS session: grill the human about the drive, then `complete_phase` through",
7244
+ "**ideation \u2192 spec \u2192 tickets**, emitting this lap\u2019s tickets on the way. Nothing else",
7245
+ "advances it; if you stop early the feature sits at ideation with no lap tickets and",
7246
+ "the human has no way forward.",
7247
+ "",
7248
+ "Your inputs, both OPTIONAL \u2014 a missing one is normal, not an error:",
7249
+ `- \`${docs}/test-notes.md\`, section \`## Lap ${lap - 1}\` \u2014 what the drive surfaced.`,
7250
+ `- \`${docs}/spec.md\`, section \`## Later laps\` \u2014 scope parked by earlier laps.`,
7251
+ "Say plainly which you found, then interview the human from what they tell you.",
7252
+ "",
7253
+ `Write what you settle on into \`${docs}/decisions.md\` under a \`## Lap ${lap}\` heading`,
7254
+ "(supersede, never rewrite), amend `spec.md` for this lap (pruning anything you promote",
7255
+ "out of `## Later laps`), then `emit_tickets` for this lap\u2019s work. Finish by telling the",
7256
+ "human to review the cards and click Burn.",
7257
+ ""
7258
+ ] : [];
7259
+ const reviewIteration = !lap && feature.phase === "review" ? [
6749
7260
  "## Review iteration",
6750
7261
  "This feature is at **review**: its tickets were burned and the human has been",
6751
7262
  "test-driving the branch. Treat this as a fix-ticket interview \u2014 read the latest",
@@ -6768,6 +7279,7 @@ function renderRevisitPrompt(feature) {
6768
7279
  "into the record, then reconcile the tickets with it. The docs are the",
6769
7280
  "artifact \u2014 later phases read them, never the transcripts.",
6770
7281
  "",
7282
+ ...lapIteration,
6771
7283
  ...reviewIteration,
6772
7284
  "## Feature",
6773
7285
  `- Slug: \`${feature.slug}\``,
@@ -6788,9 +7300,10 @@ function renderRevisitPrompt(feature) {
6788
7300
  "- `record_event({ type, message })` \u2014 drop a timeline note summarising the revisit.",
6789
7301
  "",
6790
7302
  "## Rules",
6791
- "- Do NOT call `complete_phase` \u2014 a revisit never moves the pipeline.",
7303
+ lap ? "- DO call `complete_phase` \u2014 this lap advances ideation \u2192 spec \u2192 tickets, and only you can." : "- Do NOT call `complete_phase` \u2014 a revisit never moves the pipeline.",
6792
7304
  "- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
6793
7305
  "- Docs first, tickets second: capture the decision prose before any ticket surgery.",
7306
+ noCodeRule(docs),
6794
7307
  "",
6795
7308
  "## Your task",
6796
7309
  "Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
@@ -6818,6 +7331,29 @@ function renderPreparePrompt(brief) {
6818
7331
  "service, creates or migrates a database, or writes outside the repo needs the human to",
6819
7332
  "agree first \u2014 say what you are about to run and why, then wait.",
6820
7333
  "",
7334
+ "## What the five host keys mean",
7335
+ "Take this from here. These semantics live in runcastle's source, not in the installed",
7336
+ "build \u2014 grepping the shipped bundle for them finds nothing and proves nothing.",
7337
+ "",
7338
+ "- `devCommand` \u2014 spawned in a drive-owned terminal pane for the length of a test drive.",
7339
+ ' The first localhost URL it prints becomes the "Open app" link.',
7340
+ "- `driveSetupCommand` / `driveStopCommand` \u2014 run on the host, in the project repo, before",
7341
+ " the dev pane starts and after the drive stops.",
7342
+ "- `driveEnv` \u2014 `KEY=VALUE` lines whose values render `{{slug}}`, `{{branch}}` and `{{id}}`",
7343
+ " (`{{id}}` is the slug made identifier-safe \u2014 legal as a database name). Rendered ONCE per",
7344
+ " drive and shared by the setup hook, the dev pane and the stop hook. So this, not a helper",
7345
+ " script, is where a branch\u2192database-name derivation belongs: name it once here and have both",
7346
+ " hooks read the variable. A database per branch is exactly this shape \u2014",
7347
+ "",
7348
+ " driveEnv: DB_NAME=myapp_{{id}}",
7349
+ " DATABASE_URL=postgres://localhost/myapp_{{id}}",
7350
+ ' driveSetupCommand: createdb "$DB_NAME" && npm run migrate',
7351
+ ' driveStopCommand: dropdb --if-exists "$DB_NAME"',
7352
+ "",
7353
+ "- `dbResetCommand` \u2014 NOT part of the drive loop. Its only consumer is the migration-drift",
7354
+ " banner after a drive stops: when the drive branch and the branch you returned to disagree",
7355
+ " about migration files, this is offered as the one-click dev-database rebuild.",
7356
+ "",
6821
7357
  ...established.length > 0 ? [
6822
7358
  "## Already established \u2014 do not re-derive",
6823
7359
  "These are recorded findings. Treat them as true unless the human says otherwise;",
@@ -6848,6 +7384,36 @@ function renderPreparePrompt(brief) {
6848
7384
  "## Your task",
6849
7385
  "Open by telling the human which fields are still open and what you need from them for",
6850
7386
  "each. Work them one at a time: propose, ask, run it if they agree, then record it.",
7387
+ "",
7388
+ "## Closing move: propose a dry-run drive",
7389
+ "Once the open keys are recorded, end the session by PROPOSING one \u2014 recorded values that",
7390
+ "have never been driven look exactly as trustworthy as values that run perfectly, and the",
7391
+ "first person to find out otherwise is someone mid-feature with a broken environment.",
7392
+ "",
7393
+ "**Ask before you act.** This starts services and creates a database on their machine. Name",
7394
+ "what will run \u2014 the setup command, the dev command, the stop command \u2014 then wait. If they",
7395
+ "decline, end the session normally: the keys simply stay unverified, and the drive UI will",
7396
+ "keep saying so.",
7397
+ "",
7398
+ "On a yes, `dry_run_drive({ action })` runs it in two halves and you inspect between them:",
7399
+ "",
7400
+ "1. `start` \u2014 the server renders `driveEnv`, runs `driveSetupCommand` and spawns `devCommand`",
7401
+ " in a real drive pane. Identity is the reserved slug `prep-dry-run` (`{{id}}` renders",
7402
+ " `prep_dry_run`, so the temp database is e.g. `myapp_prep_dry_run`) on the current branch.",
7403
+ " Nothing is checked out.",
7404
+ "2. While it is up, check what the server cannot: the temp database exists and is FRESH, the",
7405
+ " migrations applied, and the app actually RESPONDS at the sniffed URL \u2014 printing one is not",
7406
+ " the same as serving. `status` gives you the pane and the URL while you work.",
7407
+ "3. `stop` \u2014 the server runs `driveStopCommand`. Then check the cleanup: temp database gone,",
7408
+ " no orphaned process or container left behind.",
7409
+ "",
7410
+ "Anything off at any step, fix the finding with `record_finding` and run the WHOLE thing",
7411
+ "again until a pass is clean. A leftover `myapp_prep_dry_run` making the retry's `createdb`",
7412
+ "fail loudly is not a bug \u2014 that is the freshness check working.",
7413
+ "",
7414
+ "The verified stamps are computed server-side from what the machinery observed, and only on a",
7415
+ "clean full pass. You cannot mark your own homework: your deeper checks decide whether to",
7416
+ "retry, never what gets stamped.",
6851
7417
  ""
6852
7418
  ].join(`
6853
7419
  `);
@@ -6902,7 +7468,9 @@ function renderProjectPrompt(brief) {
6902
7468
  "ordinary file tools. The index says where.",
6903
7469
  "",
6904
7470
  "## Your task",
6905
- "Invoke the `/runcastle:project` skill and drive the project session.",
7471
+ "Invoke the `/runcastle:project` skill, then open by asking the human what they brought.",
7472
+ "Do not explore the project first: orienting before you know the ask spends their wait on",
7473
+ "context you may not need. Once they have told you, read only what answering calls for.",
6906
7474
  ""
6907
7475
  ].join(`
6908
7476
  `);
@@ -6914,7 +7482,7 @@ function renderSettings(hookClient, kind) {
6914
7482
  const cmd = (event) => ({
6915
7483
  type: "command",
6916
7484
  command: `bun run "${hookClient}" ${event}`,
6917
- timeout: event === "user-prompt" ? 5 : 10
7485
+ timeout: event === "session-start" || event === "session-end" ? 10 : 5
6918
7486
  });
6919
7487
  return {
6920
7488
  permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...sessionBashAllowRules(kind)] },
@@ -6924,7 +7492,9 @@ function renderSettings(hookClient, kind) {
6924
7492
  hooks: [cmd("session-start")]
6925
7493
  })),
6926
7494
  UserPromptSubmit: [{ hooks: [cmd("user-prompt")] }],
6927
- SessionEnd: [{ hooks: [cmd("session-end")] }]
7495
+ Stop: [{ hooks: [cmd("stop")] }],
7496
+ SessionEnd: [{ hooks: [cmd("session-end")] }],
7497
+ ...kind === undefined || guardsEdits(kind) ? { PreToolUse: [{ matcher: EDIT_TOOL_MATCHER, hooks: [cmd("pre-tool")] }] } : {}
6928
7498
  }
6929
7499
  };
6930
7500
  }
@@ -6940,13 +7510,13 @@ function renderMcpConfig(session, config2) {
6940
7510
  };
6941
7511
  }
6942
7512
  async function writeSessionArtifacts(input) {
6943
- const { session, feature, config: config2, waypoint, prepare, projectBrief } = input;
7513
+ const { session, feature, config: config2, waypoint, prepare, projectBrief, lap } = input;
6944
7514
  const dir = sessionDir(session.id);
6945
7515
  mkdirSync6(dir, { recursive: true });
6946
7516
  const systemPromptPath = join12(dir, "system-prompt.md");
6947
7517
  const settingsPath = join12(dir, "settings.json");
6948
7518
  const mcpConfigPath = join12(dir, "mcp.json");
6949
- const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
7519
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
6950
7520
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
6951
7521
  })();
6952
7522
  writeFileSync2(systemPromptPath, systemPrompt, "utf8");
@@ -6958,6 +7528,7 @@ var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_READ_RULES, SESSION_BASH_WRITE_RULES
6958
7528
  var init_artifacts = __esm(() => {
6959
7529
  init_paths();
6960
7530
  init_asset_paths();
7531
+ init_edit_guard();
6961
7532
  RUNCASTLE_MCP_ALLOW_RULES = [
6962
7533
  "mcp__runcastle__get_feature_context",
6963
7534
  "mcp__runcastle__emit_tickets",
@@ -6969,6 +7540,7 @@ var init_artifacts = __esm(() => {
6969
7540
  "mcp__runcastle__emit_waypoints",
6970
7541
  "mcp__runcastle__resolve_waypoint",
6971
7542
  "mcp__runcastle__record_finding",
7543
+ "mcp__runcastle__dry_run_drive",
6972
7544
  "mcp__runcastle__create_feature",
6973
7545
  "mcp__runcastle__get_project_context",
6974
7546
  "mcp__runcastle__get_work_record"
@@ -6994,9 +7566,9 @@ var init_artifacts = __esm(() => {
6994
7566
 
6995
7567
  // src/launcher/launcher.ts
6996
7568
  import { existsSync as existsSync13 } from "fs";
6997
- import { dirname as dirname5, join as join13, resolve as resolve8 } from "path";
7569
+ import { dirname as dirname5, join as join13, resolve as resolve9 } from "path";
6998
7570
  import { fileURLToPath as fileURLToPath5 } from "url";
6999
- import { and as and7, eq as eq9 } from "drizzle-orm";
7571
+ import { and as and9, eq as eq11 } from "drizzle-orm";
7000
7572
  function ptyExitMessage(exitCode) {
7001
7573
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
7002
7574
  return `terminal exited (code ${label})`;
@@ -7031,7 +7603,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
7031
7603
  const rel = join13("packages", "skills", "packs", "runcastle");
7032
7604
  const override = process.env[SKILLS_DIR_ENV];
7033
7605
  if (override) {
7034
- const dir2 = join13(resolve8(override), "packs", "runcastle");
7606
+ const dir2 = join13(resolve9(override), "packs", "runcastle");
7035
7607
  if (existsSync13(dir2))
7036
7608
  return dir2;
7037
7609
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
@@ -7053,7 +7625,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
7053
7625
  `)}`);
7054
7626
  }
7055
7627
  function activeRunFor(ctx, featureId) {
7056
- const row = ctx.db.select().from(runs).where(and7(eq9(runs.featureId, featureId), eq9(runs.status, "running"))).limit(1).get();
7628
+ const row = ctx.db.select().from(runs).where(and9(eq11(runs.featureId, featureId), eq11(runs.status, "running"))).limit(1).get();
7057
7629
  return row ? rowToRun(row) : null;
7058
7630
  }
7059
7631
  function assertSpawnable(ctx, feature, excludeSessionId) {
@@ -7114,12 +7686,13 @@ async function launchSession(ctx, input, opts = {}) {
7114
7686
  kind: input.kind,
7115
7687
  worktreePath
7116
7688
  });
7689
+ const plan = planKickoff({ kind: input.kind, lap: feature.lap, kickoffLine: input.kickoffLine });
7117
7690
  let waypoint;
7118
7691
  let resumeSessionId;
7119
7692
  let resumeUnavailableFrom;
7120
7693
  if (input.waypointId) {
7121
7694
  const before = getWaypoint(ctx, input.waypointId);
7122
- if (before.lastSessionId) {
7695
+ if (before.lastSessionId && !plan.explicit) {
7123
7696
  resumeSessionId = getSessionRow(ctx, before.lastSessionId)?.ccSessionId ?? undefined;
7124
7697
  if (!resumeSessionId)
7125
7698
  resumeUnavailableFrom = before.lastSessionId;
@@ -7139,17 +7712,19 @@ async function launchSession(ctx, input, opts = {}) {
7139
7712
  markSessionEnded(ctx, session.id);
7140
7713
  throw e;
7141
7714
  }
7142
- const prior = mostRecentResumableSession(ctx, feature.id);
7143
- if (prior?.ccSessionId) {
7144
- resumeSessionId = prior.ccSessionId;
7145
- } else {
7146
- resumeUnavailableFrom = "revisit";
7715
+ if (!plan.explicit) {
7716
+ const prior = mostRecentResumableSession(ctx, feature.id);
7717
+ if (prior?.ccSessionId) {
7718
+ resumeSessionId = prior.ccSessionId;
7719
+ } else {
7720
+ resumeUnavailableFrom = "revisit";
7721
+ }
7147
7722
  }
7148
7723
  }
7149
- if (input.kind !== "waypoint" && input.kind !== "revisit") {
7724
+ if (input.kind !== "waypoint" && input.kind !== "revisit" && !plan.explicit) {
7150
7725
  resumeSessionId = mostRecentResumableSession(ctx, feature.id, input.kind)?.ccSessionId;
7151
7726
  }
7152
- const kickoffLine = input.kickoffLine ?? (resumeSessionId ? resumeKickoffLine(input.kind) : undefined);
7727
+ const kickoffLine = plan.line ?? (resumeSessionId ? resumeKickoffLine(input.kind) : undefined);
7153
7728
  if (kickoffLine)
7154
7729
  setKickoffOverride(session.id, kickoffLine);
7155
7730
  emit(ctx, feature.id, {
@@ -7183,7 +7758,8 @@ async function launchSession(ctx, input, opts = {}) {
7183
7758
  feature,
7184
7759
  project,
7185
7760
  config: ctx.config,
7186
- waypoint
7761
+ waypoint,
7762
+ lap: plan.lap
7187
7763
  });
7188
7764
  const serverUrl = serverUrlFor(ctx.config);
7189
7765
  const buildInput = {
@@ -7224,11 +7800,16 @@ async function launchPrepareSession(ctx, input, opts = {}) {
7224
7800
  kind: "prepare",
7225
7801
  worktreePath: project.repoPath
7226
7802
  });
7227
- const resumeSessionId = mostRecentResumableProjectSession(ctx, project.id, "prepare")?.ccSessionId;
7803
+ const resumeSessionId = input.fresh ? undefined : mostRecentResumableProjectSession(ctx, project.id, "prepare")?.ccSessionId;
7228
7804
  emitProject(ctx, project.id, {
7229
7805
  type: "session.launching",
7230
- message: "launching preparation session",
7231
- data: { sessionId: session.id, kind: "prepare", worktreePath: project.repoPath }
7806
+ message: input.fresh ? "launching preparation session from scratch" : "launching preparation session",
7807
+ data: {
7808
+ sessionId: session.id,
7809
+ kind: "prepare",
7810
+ worktreePath: project.repoPath,
7811
+ ...input.fresh ? { fresh: true } : {}
7812
+ }
7232
7813
  });
7233
7814
  if (resumeSessionId) {
7234
7815
  emitProject(ctx, project.id, {
@@ -7379,6 +7960,7 @@ async function converge(ctx, input, opts = {}) {
7379
7960
  if (!gate)
7380
7961
  throw new GateError("feature is already at the final phase");
7381
7962
  const result = checkGate(ctx, gate.check, feature);
7963
+ let overrode = false;
7382
7964
  if (result.satisfied) {
7383
7965
  const next = nextPhase(feature);
7384
7966
  if (!next)
@@ -7386,10 +7968,21 @@ async function converge(ctx, input, opts = {}) {
7386
7968
  setPhase(ctx, feature.id, next, "phase.advanced", `converging (${next})`);
7387
7969
  } else if (input.overrideReason) {
7388
7970
  overrideGate(ctx, feature.id, gate.id, input.overrideReason);
7971
+ overrode = true;
7389
7972
  } else {
7390
7973
  throw new GateError(result.reason ?? "the map is not ready to converge \u2014 resolve its waypoints or override with a reason");
7391
7974
  }
7392
- return launchSession(ctx, { featureId: feature.id, kind: "converge" }, opts);
7975
+ try {
7976
+ return await launchSession(ctx, { featureId: feature.id, kind: "converge" }, opts);
7977
+ } catch (e) {
7978
+ if (overrode)
7979
+ undoLastGateOverride(ctx, feature.id, gate.id);
7980
+ setPhase(ctx, feature.id, feature.phase, "converge.aborted", `converge aborted \u2014 its session could not be opened (${errMsg3(e)}); back at ${feature.phase}`);
7981
+ throw e;
7982
+ }
7983
+ }
7984
+ function errMsg3(e) {
7985
+ return e instanceof Error ? e.message : String(e);
7393
7986
  }
7394
7987
  async function reconverge(ctx, feature, opts) {
7395
7988
  if (feature.phase !== "spec" && feature.phase !== "tickets") {
@@ -7542,18 +8135,16 @@ var init_feature = __esm(() => {
7542
8135
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
7543
8136
  workWaypoint: publicProcedure.input(z6.object({ featureId: z6.string(), waypointId: z6.string(), endLive: z6.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
7544
8137
  converge: publicProcedure.input(z6.object({ featureId: z6.string(), overrideReason: z6.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
7545
- rethink: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => {
7546
- const feature = rethink(ctx, input.featureId);
7547
- return launchSession(ctx, {
7548
- featureId: input.featureId,
7549
- kind: "revisit",
7550
- kickoffLine: lapKickoff(feature.lap)
7551
- });
7552
- }),
8138
+ rethink: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => rethinkAndLaunch(ctx, input.featureId, (feature) => launchSession(ctx, {
8139
+ featureId: input.featureId,
8140
+ kind: "revisit",
8141
+ kickoffLine: lapKickoff(feature.lap)
8142
+ }))),
7553
8143
  resendKickoff: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
7554
8144
  endSession: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
7555
8145
  advance: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
7556
8146
  overrideGate: publicProcedure.input(z6.object({ featureId: z6.string(), gate: gateId, reason: z6.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
8147
+ undoGateOverride: publicProcedure.input(z6.object({ featureId: z6.string(), gate: gateId })).mutation(({ ctx, input }) => undoGateOverride(ctx, input.featureId, input.gate)),
7557
8148
  archive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
7558
8149
  unarchive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
7559
8150
  delete: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
@@ -7564,6 +8155,10 @@ var init_feature = __esm(() => {
7564
8155
  return testDrive(ctx, project, feature, input.action);
7565
8156
  }),
7566
8157
  driveInfo: publicProcedure.query(() => activeDriveInfo()),
8158
+ commitCount: publicProcedure.input(z6.object({ featureId: z6.string() })).query(({ ctx, input }) => {
8159
+ const feature = getFeatureRow(ctx, input.featureId);
8160
+ return reviewCommitCount(projectForFeature(ctx, feature), feature);
8161
+ }),
7567
8162
  merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
7568
8163
  const feature = getFeatureRow(ctx, input.featureId);
7569
8164
  const project = projectForFeature(ctx, feature);
@@ -7589,7 +8184,7 @@ var init_feature = __esm(() => {
7589
8184
  // src/services/fsbrowse.ts
7590
8185
  import { existsSync as existsSync14, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
7591
8186
  import { homedir as homedir2 } from "os";
7592
- import { basename, dirname as dirname6, isAbsolute, join as join14, resolve as resolve9, sep as sep3 } from "path";
8187
+ import { basename, dirname as dirname6, isAbsolute as isAbsolute2, join as join14, resolve as resolve10, sep as sep3 } from "path";
7593
8188
  function expandPath(input, home = homedir2()) {
7594
8189
  const trimmed = input.trim();
7595
8190
  if (trimmed === "")
@@ -7599,7 +8194,7 @@ function expandPath(input, home = homedir2()) {
7599
8194
  out = home;
7600
8195
  else if (out.startsWith("~/") || out.startsWith("~\\"))
7601
8196
  out = join14(home, out.slice(2));
7602
- return resolve9(out);
8197
+ return resolve10(out);
7603
8198
  }
7604
8199
  function looksLikeRepo(dir) {
7605
8200
  try {
@@ -7655,7 +8250,7 @@ function parentOf(dir) {
7655
8250
  }
7656
8251
  function browseDir(input, showHidden = false, home = homedir2()) {
7657
8252
  const target = input && input.trim() !== "" ? expandPath(input, home) : home;
7658
- if (!isAbsolute(target)) {
8253
+ if (!isAbsolute2(target)) {
7659
8254
  throw new InvalidInputError(`path is not absolute: ${target}`);
7660
8255
  }
7661
8256
  if (!existsSync14(target)) {
@@ -7666,13 +8261,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
7666
8261
  if (!statSync2(target).isDirectory())
7667
8262
  dir = dirname6(target);
7668
8263
  } catch (e) {
7669
- throw new InvalidInputError(`cannot read path: ${target} (${errMsg2(e)})`);
8264
+ throw new InvalidInputError(`cannot read path: ${target} (${errMsg4(e)})`);
7670
8265
  }
7671
8266
  let dirents;
7672
8267
  try {
7673
8268
  dirents = readdirSync5(dir, { withFileTypes: true, encoding: "utf8" });
7674
8269
  } catch (e) {
7675
- throw new InvalidInputError(`cannot read directory: ${dir} (${errMsg2(e)})`);
8270
+ throw new InvalidInputError(`cannot read directory: ${dir} (${errMsg4(e)})`);
7676
8271
  }
7677
8272
  const entries = [];
7678
8273
  let truncated = false;
@@ -7688,6 +8283,8 @@ function browseDir(input, showHidden = false, home = homedir2()) {
7688
8283
  continue;
7689
8284
  const path = join14(dir, name);
7690
8285
  const isSymlink = dirent.isSymbolicLink();
8286
+ if (isSymlink && !showHidden)
8287
+ continue;
7691
8288
  let isDirectory = dirent.isDirectory();
7692
8289
  if (isSymlink) {
7693
8290
  try {
@@ -7710,7 +8307,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
7710
8307
  truncated
7711
8308
  };
7712
8309
  }
7713
- function errMsg2(e) {
8310
+ function errMsg4(e) {
7714
8311
  return e instanceof Error ? e.message : String(e);
7715
8312
  }
7716
8313
  var SKIP_ALWAYS, MAX_ENTRIES = 1000, COMMON_DIRS;
@@ -7723,7 +8320,7 @@ var init_fsbrowse = __esm(() => {
7723
8320
  // src/services/projects.ts
7724
8321
  import { existsSync as existsSync15 } from "fs";
7725
8322
  import { basename as basename2, join as join15 } from "path";
7726
- import { eq as eq10, isNull } from "drizzle-orm";
8323
+ import { eq as eq12, isNull } from "drizzle-orm";
7727
8324
  function listProjects(ctx) {
7728
8325
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
7729
8326
  }
@@ -7745,7 +8342,7 @@ async function openProject(ctx, rawPath) {
7745
8342
  const name = basename2(repoPath) || repoPath;
7746
8343
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
7747
8344
  if (existing) {
7748
- ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq10(projects.id, existing.id)).run();
8345
+ ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq12(projects.id, existing.id)).run();
7749
8346
  const reopened = { ...existing, mainBranch, repoPath };
7750
8347
  emitProject(ctx, existing.id, {
7751
8348
  type: "project.opened",
@@ -7776,7 +8373,7 @@ function closeProject(ctx, projectId) {
7776
8373
  if (projectHasActiveRun(ctx, projectId)) {
7777
8374
  throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
7778
8375
  }
7779
- ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq10(projects.id, projectId)).run();
8376
+ ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq12(projects.id, projectId)).run();
7780
8377
  emitProject(ctx, projectId, {
7781
8378
  type: "project.closed",
7782
8379
  message: `project ${project.name} closed`
@@ -7785,7 +8382,7 @@ function closeProject(ctx, projectId) {
7785
8382
  }
7786
8383
  function renameProject(ctx, projectId, name) {
7787
8384
  const project = requireProjectById(ctx, projectId);
7788
- ctx.db.update(projects).set({ name }).where(eq10(projects.id, projectId)).run();
8385
+ ctx.db.update(projects).set({ name }).where(eq12(projects.id, projectId)).run();
7789
8386
  emitProject(ctx, projectId, {
7790
8387
  type: "project.renamed",
7791
8388
  message: `project renamed ${project.name} \u2192 ${name}`,
@@ -7831,6 +8428,7 @@ var init_projects = __esm(() => {
7831
8428
  import * as z7 from "zod";
7832
8429
  var projectRouter;
7833
8430
  var init_project = __esm(() => {
8431
+ init_src();
7834
8432
  init_fsbrowse();
7835
8433
  init_git();
7836
8434
  init_prep();
@@ -7846,12 +8444,13 @@ var init_project = __esm(() => {
7846
8444
  branches: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
7847
8445
  open: publicProcedure.input(z7.object({ repoPath: z7.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
7848
8446
  close: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
7849
- rename: publicProcedure.input(z7.object({ projectId: z7.string(), name: z7.string().min(1) })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
7850
- talkToPrep: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
8447
+ rename: publicProcedure.input(z7.object({ projectId: z7.string(), name: ProjectName })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
8448
+ talkToPrep: publicProcedure.input(z7.object({ projectId: z7.string(), fresh: z7.boolean().optional() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId, fresh: input.fresh })),
7851
8449
  prepSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
7852
8450
  talkToProject: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => launchProjectSession(ctx, { projectId: input.projectId })),
7853
8451
  projectSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "project")),
7854
- prep: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => prepView(ctx, requireProjectById(ctx, input.projectId)))
8452
+ prep: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => prepView(ctx, requireProjectById(ctx, input.projectId))),
8453
+ dryRunStop: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => dryRunDrive(ctx, requireProjectById(ctx, input.projectId), "stop"))
7855
8454
  });
7856
8455
  });
7857
8456
 
@@ -7877,7 +8476,7 @@ var init_run = __esm(() => {
7877
8476
  import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
7878
8477
  import { dirname as dirname7 } from "path";
7879
8478
  import * as z9 from "zod";
7880
- import { eq as eq11 } from "drizzle-orm";
8479
+ import { eq as eq13 } from "drizzle-orm";
7881
8480
  function readRawConfig(configFile) {
7882
8481
  if (!existsSync16(configFile))
7883
8482
  return {};
@@ -7917,7 +8516,7 @@ function projectOverrides(ctx, projectId) {
7917
8516
  driveSetupCommand: projects.driveSetupCommand,
7918
8517
  driveStopCommand: projects.driveStopCommand,
7919
8518
  driveEnv: projects.driveEnv
7920
- }).from(projects).where(eq11(projects.id, projectId)).get();
8519
+ }).from(projects).where(eq13(projects.id, projectId)).get();
7921
8520
  return {
7922
8521
  model: row?.model ?? null,
7923
8522
  sandbox: row?.sandbox ?? null,
@@ -7931,26 +8530,26 @@ function projectOverrides(ctx, projectId) {
7931
8530
  driveEnv: row?.driveEnv ?? null
7932
8531
  };
7933
8532
  }
7934
- function resolveField(desc5, layers) {
8533
+ function resolveField(desc7, layers) {
7935
8534
  const scoped = layers.overrides !== null;
7936
- const scope = scoped && desc5.projectColumn ? "project" : "global";
7937
- const base = { key: desc5.key, restartRequired: desc5.restartRequired, scope };
7938
- if (desc5.envVar) {
7939
- const raw = layers.env[desc5.envVar];
8535
+ const scope = scoped && desc7.projectColumn ? "project" : "global";
8536
+ const base = { key: desc7.key, restartRequired: desc7.restartRequired, scope };
8537
+ if (desc7.envVar) {
8538
+ const raw = layers.env[desc7.envVar];
7940
8539
  if (raw !== undefined && raw !== "") {
7941
- return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
8540
+ return { ...base, value: desc7.parseEnv(raw), source: "env", editable: false };
7942
8541
  }
7943
8542
  }
7944
- if (layers.overrides && desc5.projectColumn) {
7945
- const ov = layers.overrides[desc5.projectColumn];
8543
+ if (layers.overrides && desc7.projectColumn) {
8544
+ const ov = layers.overrides[desc7.projectColumn];
7946
8545
  if (ov !== null && ov !== "") {
7947
8546
  return { ...base, value: ov, source: "project", editable: true };
7948
8547
  }
7949
8548
  }
7950
- if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
7951
- return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
8549
+ if (desc7.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc7.configKey)) {
8550
+ return { ...base, value: layers.fileRaw[desc7.configKey], source: "file", editable: true };
7952
8551
  }
7953
- const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
8552
+ const value = desc7.configKey ? DEFAULTS[desc7.configKey] ?? null : null;
7954
8553
  return { ...base, value, source: "default", editable: true };
7955
8554
  }
7956
8555
  function getSettings(ctx, projectId, io = {}) {
@@ -7967,56 +8566,56 @@ function updateSettings(ctx, input, io = {}) {
7967
8566
  if (input.key.startsWith("stepModels.")) {
7968
8567
  return updateStepModel(ctx, input, configFile, io);
7969
8568
  }
7970
- const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
7971
- if (!desc5)
8569
+ const desc7 = DESCRIPTORS.find((d) => d.key === input.key);
8570
+ if (!desc7)
7972
8571
  throw new InvalidInputError(`unknown setting: ${input.key}`);
7973
- if (desc5.envVar) {
7974
- const raw = env[desc5.envVar];
8572
+ if (desc7.envVar) {
8573
+ const raw = env[desc7.envVar];
7975
8574
  if (raw !== undefined && raw !== "") {
7976
- throw new InvalidInputError(`${desc5.key} is set by environment variable ${desc5.envVar}; unset it to edit`);
8575
+ throw new InvalidInputError(`${desc7.key} is set by environment variable ${desc7.envVar}; unset it to edit`);
7977
8576
  }
7978
8577
  }
7979
- const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
8578
+ const toProject = input.projectId !== undefined && desc7.projectColumn !== undefined;
7980
8579
  if (input.value === null) {
7981
8580
  if (!toProject)
7982
- throw new InvalidInputError(`${desc5.key} cannot be cleared`);
8581
+ throw new InvalidInputError(`${desc7.key} cannot be cleared`);
7983
8582
  const project = requireProjectById(ctx, input.projectId);
7984
- ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq11(projects.id, project.id)).run();
7985
- recordHuman(ctx, project.id, desc5.key, null);
8583
+ ctx.db.update(projects).set({ [desc7.projectColumn]: null }).where(eq13(projects.id, project.id)).run();
8584
+ recordHuman(ctx, project.id, desc7.key, null);
7986
8585
  emitProject(ctx, project.id, {
7987
8586
  type: "settings.updated",
7988
- message: `${desc5.key} override cleared`,
7989
- data: { key: desc5.key, scope: "project", value: null }
8587
+ message: `${desc7.key} override cleared`,
8588
+ data: { key: desc7.key, scope: "project", value: null }
7990
8589
  });
7991
- return field(getSettings(ctx, input.projectId, io), desc5.key);
8590
+ return field(getSettings(ctx, input.projectId, io), desc7.key);
7992
8591
  }
7993
- const parsed = desc5.valueSchema.safeParse(input.value);
8592
+ const parsed = desc7.valueSchema.safeParse(input.value);
7994
8593
  if (!parsed.success) {
7995
- throw new InvalidInputError(`invalid value for ${desc5.key}: ${parsed.error.issues[0]?.message}`);
8594
+ throw new InvalidInputError(`invalid value for ${desc7.key}: ${parsed.error.issues[0]?.message}`);
7996
8595
  }
7997
8596
  const value = parsed.data;
7998
8597
  if (toProject) {
7999
8598
  const project = requireProjectById(ctx, input.projectId);
8000
- ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq11(projects.id, project.id)).run();
8001
- recordHuman(ctx, project.id, desc5.key, String(value));
8599
+ ctx.db.update(projects).set({ [desc7.projectColumn]: String(value) }).where(eq13(projects.id, project.id)).run();
8600
+ recordHuman(ctx, project.id, desc7.key, String(value));
8002
8601
  emitProject(ctx, project.id, {
8003
8602
  type: "settings.updated",
8004
- message: `${desc5.key} override set to ${String(value)}`,
8005
- data: { key: desc5.key, scope: "project", value }
8603
+ message: `${desc7.key} override set to ${String(value)}`,
8604
+ data: { key: desc7.key, scope: "project", value }
8006
8605
  });
8007
- return field(getSettings(ctx, input.projectId, io), desc5.key);
8606
+ return field(getSettings(ctx, input.projectId, io), desc7.key);
8008
8607
  }
8009
- if (!desc5.configKey) {
8010
- throw new InvalidInputError(`${desc5.key} is a per-project setting; provide a projectId`);
8608
+ if (!desc7.configKey) {
8609
+ throw new InvalidInputError(`${desc7.key} is a per-project setting; provide a projectId`);
8011
8610
  }
8012
- writeGlobal(configFile, desc5.configKey, value);
8013
- ctx.config[desc5.configKey] = value;
8611
+ writeGlobal(configFile, desc7.configKey, value);
8612
+ ctx.config[desc7.configKey] = value;
8014
8613
  emitProject(ctx, GLOBAL_EVENT_KEY, {
8015
8614
  type: "settings.updated",
8016
- message: `${desc5.key} set to ${String(value)}`,
8017
- data: { key: desc5.key, scope: "global", value }
8615
+ message: `${desc7.key} set to ${String(value)}`,
8616
+ data: { key: desc7.key, scope: "global", value }
8018
8617
  });
8019
- return field(getSettings(ctx, input.projectId, io), desc5.key);
8618
+ return field(getSettings(ctx, input.projectId, io), desc7.key);
8020
8619
  }
8021
8620
  function writeGlobal(configFile, configKey, value) {
8022
8621
  const raw = readRawConfig(configFile);
@@ -8414,7 +9013,7 @@ var init_doctor = __esm(() => {
8414
9013
  // src/doctor/system-exec.ts
8415
9014
  import { spawn as spawn3 } from "child_process";
8416
9015
  function createSystemExec(opts = {}) {
8417
- return (command, args) => new Promise((resolve10) => {
9016
+ return (command, args) => new Promise((resolve11) => {
8418
9017
  const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
8419
9018
  let stdout = "";
8420
9019
  let stderr = "";
@@ -8426,10 +9025,10 @@ function createSystemExec(opts = {}) {
8426
9025
  stderr += d.toString();
8427
9026
  });
8428
9027
  child.on("error", (err) => {
8429
- resolve10({ ok: false, code: null, stdout, stderr: stderr || String(err) });
9028
+ resolve11({ ok: false, code: null, stdout, stderr: stderr || String(err) });
8430
9029
  });
8431
9030
  child.on("close", (code) => {
8432
- resolve10({ ok: true, code: code ?? 0, stdout, stderr });
9031
+ resolve11({ ok: true, code: code ?? 0, stdout, stderr });
8433
9032
  });
8434
9033
  });
8435
9034
  }
@@ -8652,6 +9251,29 @@ var init_setup2 = __esm(() => {
8652
9251
  });
8653
9252
  });
8654
9253
 
9254
+ // src/version.ts
9255
+ import { readFileSync as readFileSync8 } from "fs";
9256
+ import { dirname as dirname9, join as join17 } from "path";
9257
+ import { fileURLToPath as fileURLToPath7 } from "url";
9258
+ function runcastleVersion() {
9259
+ if (cached2)
9260
+ return cached2;
9261
+ const here = dirname9(fileURLToPath7(import.meta.url));
9262
+ for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
9263
+ try {
9264
+ const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
9265
+ if (typeof pkg.version === "string") {
9266
+ cached2 = pkg.version;
9267
+ return cached2;
9268
+ }
9269
+ } catch {}
9270
+ }
9271
+ cached2 = UNKNOWN_VERSION;
9272
+ return cached2;
9273
+ }
9274
+ var cached2, UNKNOWN_VERSION = "0.0.0";
9275
+ var init_version = () => {};
9276
+
8655
9277
  // src/services/update-check.ts
8656
9278
  function parse(version) {
8657
9279
  const cleaned = version.trim().replace(/^v/, "");
@@ -8694,6 +9316,8 @@ function compareSemver(a, b) {
8694
9316
  async function checkForUpdate(opts) {
8695
9317
  const { current: current2 } = opts;
8696
9318
  const fetchImpl = opts.fetchImpl ?? fetch;
9319
+ if (current2 === UNKNOWN_VERSION)
9320
+ return NO_UPDATE(current2);
8697
9321
  try {
8698
9322
  const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`);
8699
9323
  if (!res.ok)
@@ -8727,32 +9351,10 @@ var PACKAGE_NAME = "runcastle", UPDATE_COMMAND, NO_UPDATE = (current2) => ({
8727
9351
  command: UPDATE_COMMAND
8728
9352
  }), cache;
8729
9353
  var init_update_check = __esm(() => {
9354
+ init_version();
8730
9355
  UPDATE_COMMAND = `bun add -g ${PACKAGE_NAME}@latest`;
8731
9356
  });
8732
9357
 
8733
- // src/version.ts
8734
- import { readFileSync as readFileSync8 } from "fs";
8735
- import { dirname as dirname9, join as join17 } from "path";
8736
- import { fileURLToPath as fileURLToPath7 } from "url";
8737
- function runcastleVersion() {
8738
- if (cached2)
8739
- return cached2;
8740
- const here = dirname9(fileURLToPath7(import.meta.url));
8741
- for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
8742
- try {
8743
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
8744
- if (typeof pkg.version === "string") {
8745
- cached2 = pkg.version;
8746
- return cached2;
8747
- }
8748
- } catch {}
8749
- }
8750
- cached2 = "0.0.0";
8751
- return cached2;
8752
- }
8753
- var cached2;
8754
- var init_version = () => {};
8755
-
8756
9358
  // src/trpc/routers/system.ts
8757
9359
  var systemRouter;
8758
9360
  var init_system = __esm(() => {
@@ -8765,8 +9367,188 @@ var init_system = __esm(() => {
8765
9367
  });
8766
9368
  });
8767
9369
 
8768
- // src/trpc/routers/ticket.ts
9370
+ // src/services/test-notes.ts
9371
+ import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync5 } from "fs";
9372
+ import { dirname as dirname10 } from "path";
9373
+ import { asc as asc4, eq as eq14 } from "drizzle-orm";
9374
+ function rowToNote(row) {
9375
+ return {
9376
+ id: row.id,
9377
+ featureId: row.featureId,
9378
+ lap: row.lap,
9379
+ text: row.text,
9380
+ status: row.status,
9381
+ ticketId: row.ticketId ?? undefined,
9382
+ createdAt: row.createdAt,
9383
+ updatedAt: row.updatedAt
9384
+ };
9385
+ }
9386
+ function getNote(ctx, id) {
9387
+ const row = ctx.db.select().from(testNotes).where(eq14(testNotes.id, id)).get();
9388
+ if (!row)
9389
+ throw new NotFoundError(`test note ${id} not found`);
9390
+ return rowToNote(row);
9391
+ }
9392
+ function cleanText(text2) {
9393
+ const trimmed = text2.trim();
9394
+ if (!trimmed)
9395
+ throw new InvalidInputError("note text cannot be empty");
9396
+ return trimmed;
9397
+ }
9398
+ function assertOpen(note, action) {
9399
+ if (note.status !== "open") {
9400
+ throw new InvalidInputError(`cannot ${action} note ${note.id} \u2014 it is ${note.status}; only open notes can be changed`);
9401
+ }
9402
+ }
9403
+ function listByFeature3(ctx, featureId) {
9404
+ return ctx.db.select().from(testNotes).where(eq14(testNotes.featureId, featureId)).orderBy(asc4(testNotes.createdAt), asc4(testNotes.id)).all().map(rowToNote);
9405
+ }
9406
+ function addNote(ctx, featureId, text2) {
9407
+ const body = cleanText(text2);
9408
+ const feature = getFeatureRow(ctx, featureId);
9409
+ const now = Date.now();
9410
+ const row = ctx.db.insert(testNotes).values({
9411
+ id: newId("note"),
9412
+ featureId,
9413
+ lap: feature.lap,
9414
+ text: body,
9415
+ status: "open",
9416
+ ticketId: null,
9417
+ createdAt: now,
9418
+ updatedAt: now
9419
+ }).returning().get();
9420
+ const note = rowToNote(row);
9421
+ emit(ctx, featureId, {
9422
+ type: "note.added",
9423
+ message: `note captured on lap ${note.lap}`,
9424
+ data: { noteId: note.id, lap: note.lap }
9425
+ });
9426
+ renderTestNotes(ctx, feature);
9427
+ return note;
9428
+ }
9429
+ function editNote(ctx, noteId, text2) {
9430
+ const current2 = getNote(ctx, noteId);
9431
+ assertOpen(current2, "edit");
9432
+ const body = cleanText(text2);
9433
+ ctx.db.update(testNotes).set({ text: body, updatedAt: Date.now() }).where(eq14(testNotes.id, noteId)).run();
9434
+ emit(ctx, current2.featureId, {
9435
+ type: "note.edited",
9436
+ message: "note edited",
9437
+ data: { noteId }
9438
+ });
9439
+ renderTestNotes(ctx, getFeatureRow(ctx, current2.featureId));
9440
+ return getNote(ctx, noteId);
9441
+ }
9442
+ function deleteNote(ctx, noteId) {
9443
+ const current2 = getNote(ctx, noteId);
9444
+ assertOpen(current2, "delete");
9445
+ ctx.db.delete(testNotes).where(eq14(testNotes.id, noteId)).run();
9446
+ emit(ctx, current2.featureId, {
9447
+ type: "note.deleted",
9448
+ message: "note deleted",
9449
+ data: { noteId }
9450
+ });
9451
+ renderTestNotes(ctx, getFeatureRow(ctx, current2.featureId));
9452
+ }
9453
+ function toggleNote(ctx, noteId) {
9454
+ const current2 = getNote(ctx, noteId);
9455
+ if (current2.status === "promoted") {
9456
+ throw new InvalidInputError(`cannot toggle note ${noteId} \u2014 it is promoted, and promoted notes are frozen`);
9457
+ }
9458
+ const status = current2.status === "open" ? "done" : "open";
9459
+ ctx.db.update(testNotes).set({ status, updatedAt: Date.now() }).where(eq14(testNotes.id, noteId)).run();
9460
+ emit(ctx, current2.featureId, {
9461
+ type: "note.toggled",
9462
+ message: `note ${current2.status} \u2192 ${status}`,
9463
+ data: { noteId, from: current2.status, to: status }
9464
+ });
9465
+ renderTestNotes(ctx, getFeatureRow(ctx, current2.featureId));
9466
+ return getNote(ctx, noteId);
9467
+ }
9468
+ function promotionTicket(feature, note) {
9469
+ const firstLine = note.text.split(`
9470
+ `)[0].trim();
9471
+ const docs = featureDocsRel(feature.slug);
9472
+ return {
9473
+ title: firstLine.length <= TITLE_MAX ? firstLine : `${firstLine.slice(0, TITLE_MAX - 1).trimEnd()}\u2026`,
9474
+ goal: note.text,
9475
+ context: [
9476
+ `Found during lap ${note.lap} test drive of ${feature.slug}.`,
9477
+ `Read ${docs}/spec.md and ${docs}/decisions.md for what this feature is meant to do.`
9478
+ ].join(`
9479
+
9480
+ `),
9481
+ acceptanceCriteria: [`The noted behavior no longer reproduces: ${note.text}`],
9482
+ seams: [],
9483
+ blockedBy: []
9484
+ };
9485
+ }
9486
+ function promoteNote(ctx, noteId) {
9487
+ const current2 = getNote(ctx, noteId);
9488
+ assertOpen(current2, "promote");
9489
+ const feature = getFeatureRow(ctx, current2.featureId);
9490
+ const [ticket] = storeTickets(ctx, current2.featureId, [promotionTicket(feature, current2)]);
9491
+ ctx.db.update(testNotes).set({ status: "promoted", ticketId: ticket.id, updatedAt: Date.now() }).where(eq14(testNotes.id, noteId)).run();
9492
+ emit(ctx, current2.featureId, {
9493
+ type: "note.promoted",
9494
+ message: `note promoted to ticket ${ticket.seq}`,
9495
+ ticketId: ticket.id,
9496
+ data: { noteId, seq: ticket.seq }
9497
+ });
9498
+ renderTestNotes(ctx, feature);
9499
+ return { note: getNote(ctx, noteId), ticket };
9500
+ }
9501
+ function noteLine(ctx, note) {
9502
+ if (note.status === "open")
9503
+ return `- [ ] ${note.text}`;
9504
+ const ticket = note.ticketId ? ` (\u2192 ticket ${getTicket(ctx, note.ticketId).seq})` : "";
9505
+ return `- [x] ${note.text}${ticket}`;
9506
+ }
9507
+ function renderTestNotes(ctx, feature) {
9508
+ const notes = listByFeature3(ctx, feature.id);
9509
+ const laps = [...new Set(notes.map((n) => n.lap))].sort((a, b) => a - b);
9510
+ const lines = ["# Test notes"];
9511
+ for (const lap of laps) {
9512
+ lines.push("", `## Lap ${lap}`, "");
9513
+ for (const note of notes.filter((n) => n.lap === lap))
9514
+ lines.push(noteLine(ctx, note));
9515
+ }
9516
+ const path = featureDocPath(projectForFeature(ctx, feature), feature, "test-notes.md");
9517
+ mkdirSync9(dirname10(path), { recursive: true });
9518
+ writeFileSync5(path, `${lines.join(`
9519
+ `)}
9520
+ `, "utf8");
9521
+ }
9522
+ var TITLE_MAX = 60;
9523
+ var init_test_notes = __esm(() => {
9524
+ init_src();
9525
+ init_paths();
9526
+ init_schema();
9527
+ init_errors();
9528
+ init_events();
9529
+ init_feature_docs();
9530
+ init_repo();
9531
+ init_tickets();
9532
+ });
9533
+
9534
+ // src/trpc/routers/test-notes.ts
8769
9535
  import * as z12 from "zod";
9536
+ var testNotesRouter;
9537
+ var init_test_notes2 = __esm(() => {
9538
+ init_test_notes();
9539
+ init_context();
9540
+ testNotesRouter = router({
9541
+ list: publicProcedure.input(z12.object({ featureId: z12.string() })).query(({ ctx, input }) => listByFeature3(ctx, input.featureId)),
9542
+ add: publicProcedure.input(z12.object({ featureId: z12.string(), text: z12.string().min(1) })).mutation(({ ctx, input }) => addNote(ctx, input.featureId, input.text)),
9543
+ edit: publicProcedure.input(z12.object({ noteId: z12.string(), text: z12.string().min(1) })).mutation(({ ctx, input }) => editNote(ctx, input.noteId, input.text)),
9544
+ remove: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => deleteNote(ctx, input.noteId)),
9545
+ toggle: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => toggleNote(ctx, input.noteId)),
9546
+ promote: publicProcedure.input(z12.object({ noteId: z12.string() })).mutation(({ ctx, input }) => promoteNote(ctx, input.noteId))
9547
+ });
9548
+ });
9549
+
9550
+ // src/trpc/routers/ticket.ts
9551
+ import * as z13 from "zod";
8770
9552
  var ticketRouter;
8771
9553
  var init_ticket = __esm(() => {
8772
9554
  init_features();
@@ -8775,8 +9557,8 @@ var init_ticket = __esm(() => {
8775
9557
  init_ticket_burner();
8776
9558
  init_context();
8777
9559
  ticketRouter = router({
8778
- retry: publicProcedure.input(z12.object({ ticketId: z12.string(), fresh: z12.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
8779
- stop: publicProcedure.input(z12.object({ ticketId: z12.string() })).mutation(({ ctx, input }) => {
9560
+ retry: publicProcedure.input(z13.object({ ticketId: z13.string(), fresh: z13.boolean().optional() })).mutation(({ ctx, input }) => retryTicket(ctx, input.ticketId, { fresh: input.fresh })),
9561
+ stop: publicProcedure.input(z13.object({ ticketId: z13.string() })).mutation(({ ctx, input }) => {
8780
9562
  if (stopTicketRun(input.ticketId))
8781
9563
  return { stopped: true, swept: false };
8782
9564
  const ticket = getTicket(ctx, input.ticketId);
@@ -8786,13 +9568,13 @@ var init_ticket = __esm(() => {
8786
9568
  }
8787
9569
  return { stopped: false, swept: false };
8788
9570
  }),
8789
- cancel: publicProcedure.input(z12.object({ ticketId: z12.string(), reason: z12.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
8790
- edit: publicProcedure.input(z12.object({
8791
- ticketId: z12.string(),
8792
- title: z12.string().min(1).optional(),
8793
- goal: z12.string().min(1).optional(),
8794
- context: z12.string().optional(),
8795
- acceptanceCriteria: z12.array(z12.string().min(1)).optional()
9571
+ cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
9572
+ edit: publicProcedure.input(z13.object({
9573
+ ticketId: z13.string(),
9574
+ title: z13.string().min(1).optional(),
9575
+ goal: z13.string().min(1).optional(),
9576
+ context: z13.string().optional(),
9577
+ acceptanceCriteria: z13.array(z13.string().min(1)).optional()
8796
9578
  })).mutation(({ ctx, input }) => {
8797
9579
  const { ticketId, ...patch } = input;
8798
9580
  return editTicket(ctx, ticketId, patch);
@@ -8812,12 +9594,14 @@ var init_router = __esm(() => {
8812
9594
  init_settings2();
8813
9595
  init_setup2();
8814
9596
  init_system();
9597
+ init_test_notes2();
8815
9598
  init_ticket();
8816
9599
  appRouter = router({
8817
9600
  project: projectRouter,
8818
9601
  feature: featureRouter,
8819
9602
  run: runRouter,
8820
9603
  ticket: ticketRouter,
9604
+ notes: testNotesRouter,
8821
9605
  events: eventsRouter,
8822
9606
  docs: docsRouter,
8823
9607
  settings: settingsRouter,
@@ -8827,14 +9611,14 @@ var init_router = __esm(() => {
8827
9611
  });
8828
9612
 
8829
9613
  // src/workflows/reconcile-runs.ts
8830
- import { eq as eq12 } from "drizzle-orm";
9614
+ import { eq as eq15 } from "drizzle-orm";
8831
9615
  async function reconcileStaleRuns(ctx) {
8832
- const stale = ctx.db.select().from(runs).where(eq12(runs.status, "running")).all().map(rowToRun);
9616
+ const stale = ctx.db.select().from(runs).where(eq15(runs.status, "running")).all().map(rowToRun);
8833
9617
  const reconciled = [];
8834
9618
  for (const run3 of stale) {
8835
9619
  if (isRunActive(run3.id))
8836
9620
  continue;
8837
- ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq12(runs.id, run3.id)).run();
9621
+ ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq15(runs.id, run3.id)).run();
8838
9622
  const released = releaseForSession(ctx, run3.id);
8839
9623
  const swept = workflowClaimsFeatureBranch(run3.workflow) ? sweepOrphanedBurning(ctx, run3.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
8840
9624
  if (workflowClaimsFeatureBranch(run3.workflow)) {