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