runcastle 1.2.3 → 1.2.5

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
@@ -26,6 +26,7 @@ __export(exports_paths, {
26
26
  projectWorktreesDir: () => projectWorktreesDir,
27
27
  prodDataDir: () => prodDataDir,
28
28
  logsDir: () => logsDir,
29
+ installIdPath: () => installIdPath,
29
30
  featureDocsRel: () => featureDocsRel,
30
31
  envPath: () => envPath,
31
32
  devDataDir: () => devDataDir,
@@ -64,6 +65,9 @@ function configPath() {
64
65
  function envPath() {
65
66
  return join(dataDir(), ".env");
66
67
  }
68
+ function installIdPath() {
69
+ return join(dataDir(), "install-id");
70
+ }
67
71
  function logsDir() {
68
72
  return join(dataDir(), "logs");
69
73
  }
@@ -175,8 +179,11 @@ function loadConfig(env = process.env) {
175
179
  }
176
180
  }
177
181
  const overrides = {};
178
- if (env.RUNCASTLE_SERVER_PORT)
179
- overrides.serverPort = Number(env.RUNCASTLE_SERVER_PORT);
182
+ const num = (key, raw) => {
183
+ if (raw !== undefined && raw !== "")
184
+ overrides[key] = Number(raw);
185
+ };
186
+ num("serverPort", env.RUNCASTLE_SERVER_PORT);
180
187
  if (env.RUNCASTLE_MODEL)
181
188
  overrides.model = env.RUNCASTLE_MODEL;
182
189
  if (env.RUNCASTLE_SMOKE_MODEL)
@@ -189,21 +196,12 @@ function loadConfig(env = process.env) {
189
196
  overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
190
197
  if (env.RUNCASTLE_SANDBOX_IMAGE)
191
198
  overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
192
- if (env.RUNCASTLE_BURN_CONCURRENCY) {
193
- overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
194
- }
195
- if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
196
- overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
197
- }
198
- if (env.RUNCASTLE_BURN_ATTEMPTS) {
199
- overrides.burnAttempts = Number(env.RUNCASTLE_BURN_ATTEMPTS);
200
- }
201
- if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
202
- overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
203
- }
204
- if (env.RUNCASTLE_BURN_CPUS)
205
- overrides.burnCpus = Number(env.RUNCASTLE_BURN_CPUS);
206
- if (env.RUNCASTLE_BURN_GUARD !== undefined) {
199
+ num("burnConcurrency", env.RUNCASTLE_BURN_CONCURRENCY);
200
+ num("burnMaxIterations", env.RUNCASTLE_BURN_MAX_ITERATIONS);
201
+ num("burnAttempts", env.RUNCASTLE_BURN_ATTEMPTS);
202
+ num("burnConflictAttempts", env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
203
+ num("burnCpus", env.RUNCASTLE_BURN_CPUS);
204
+ if (env.RUNCASTLE_BURN_GUARD !== undefined && env.RUNCASTLE_BURN_GUARD !== "") {
207
205
  overrides.burnGuard = env.RUNCASTLE_BURN_GUARD !== "0" && env.RUNCASTLE_BURN_GUARD !== "false";
208
206
  }
209
207
  if (env.RUNCASTLE_SETUP_COMMAND)
@@ -232,6 +230,7 @@ function ensureDataDir() {
232
230
  mkdirSync(join2(root, "sessions"), { recursive: true });
233
231
  mkdirSync(join2(root, "worktrees"), { recursive: true });
234
232
  }
233
+ var SERVE_HOSTNAME = "127.0.0.1", SERVE_IDLE_TIMEOUT_SECONDS = 120;
235
234
  var init_config2 = __esm(() => {
236
235
  init_config_load();
237
236
  init_paths();
@@ -246,7 +245,10 @@ var init_ids = () => {};
246
245
 
247
246
  // ../core/src/schemas.ts
248
247
  import * as z2 from "zod";
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;
248
+ function isProjectSessionKind(kind) {
249
+ return PROJECT_SESSION_KINDS.includes(kind);
250
+ }
251
+ var Phase, TicketStatus, SessionKind, PROJECT_SESSION_KINDS, SessionPurpose, MergeBranchPair, 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
252
  var init_schemas = __esm(() => {
251
253
  Phase = z2.enum([
252
254
  "ideation",
@@ -266,8 +268,14 @@ var init_schemas = __esm(() => {
266
268
  "prepare",
267
269
  "project"
268
270
  ]);
271
+ PROJECT_SESSION_KINDS = ["prepare", "project"];
272
+ SessionPurpose = z2.enum(["resolve-conflict"]);
273
+ MergeBranchPair = z2.object({
274
+ mergeFrom: z2.string(),
275
+ mergeInto: z2.string()
276
+ });
269
277
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
270
- FeatureStatus = z2.enum(["active", "shipped", "archived"]);
278
+ FeatureStatus = z2.enum(["draft", "active", "shipped", "archived"]);
271
279
  SessionStatus = z2.enum(["launching", "live", "ended"]);
272
280
  TicketInput = z2.object({
273
281
  title: z2.string(),
@@ -286,7 +294,8 @@ var init_schemas = __esm(() => {
286
294
  commits: z2.array(z2.string()),
287
295
  error: z2.string().optional(),
288
296
  attemptBranch: z2.string().optional(),
289
- conflictFiles: z2.array(z2.string()).optional()
297
+ conflictFiles: z2.array(z2.string()).optional(),
298
+ digest: z2.string().optional()
290
299
  });
291
300
  WaypointType = z2.enum(["grilling", "research", "prototype", "task"]);
292
301
  WaypointStatus = z2.enum(["open", "claimed", "resolved", "dropped"]);
@@ -351,7 +360,8 @@ var init_schemas = __esm(() => {
351
360
  dbResetCommand: z2.string().optional(),
352
361
  driveSetupCommand: z2.string().optional(),
353
362
  driveStopCommand: z2.string().optional(),
354
- driveEnv: z2.string().optional()
363
+ driveEnv: z2.string().optional(),
364
+ closedAt: z2.number().optional()
355
365
  });
356
366
  ProjectFinding = z2.object({
357
367
  key: PreparedKey,
@@ -369,6 +379,7 @@ var init_schemas = __esm(() => {
369
379
  slug: z2.string(),
370
380
  title: z2.string(),
371
381
  oneLiner: z2.string(),
382
+ brief: z2.string().optional(),
372
383
  mapped: z2.boolean(),
373
384
  lap: z2.number(),
374
385
  phase: Phase,
@@ -382,6 +393,8 @@ var init_schemas = __esm(() => {
382
393
  featureId: z2.string().optional(),
383
394
  projectId: z2.string().optional(),
384
395
  kind: SessionKind,
396
+ purpose: SessionPurpose.optional(),
397
+ purposeData: MergeBranchPair.optional(),
385
398
  ccSessionId: z2.string().optional(),
386
399
  transcriptPath: z2.string().optional(),
387
400
  status: SessionStatus,
@@ -395,7 +408,8 @@ var init_schemas = __esm(() => {
395
408
  status: RunStatus,
396
409
  startedAt: z2.number(),
397
410
  endedAt: z2.number().optional(),
398
- summary: z2.string().optional()
411
+ summary: z2.string().optional(),
412
+ digest: z2.string().optional()
399
413
  });
400
414
  SettingSource = z2.enum(["env", "project", "file", "default"]);
401
415
  SettingScope = z2.enum(["global", "project"]);
@@ -570,7 +584,7 @@ var init_pipeline = __esm(() => {
570
584
  });
571
585
 
572
586
  // ../core/src/db-schema.ts
573
- import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
587
+ import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
574
588
  var projects, projectFindings, features, sessions, tickets, testNotes, waypoints, runs, events, gateOverrides;
575
589
  var init_db_schema = __esm(() => {
576
590
  projects = sqliteTable("projects", {
@@ -606,6 +620,7 @@ var init_db_schema = __esm(() => {
606
620
  slug: text("slug").notNull(),
607
621
  title: text("title").notNull(),
608
622
  oneLiner: text("one_liner").notNull(),
623
+ brief: text("brief"),
609
624
  mapped: integer("mapped", { mode: "boolean" }).notNull().default(false),
610
625
  lap: integer("lap").notNull().default(1),
611
626
  phase: text("phase").notNull().$type(),
@@ -620,6 +635,8 @@ var init_db_schema = __esm(() => {
620
635
  projectId: text("project_id"),
621
636
  lap: integer("lap").notNull().default(1),
622
637
  kind: text("kind").notNull().$type(),
638
+ purpose: text("purpose").$type(),
639
+ purposeData: text("purpose_data", { mode: "json" }).$type(),
623
640
  ccSessionId: text("cc_session_id"),
624
641
  transcriptPath: text("transcript_path"),
625
642
  status: text("status").notNull().$type(),
@@ -641,7 +658,8 @@ var init_db_schema = __esm(() => {
641
658
  commits: text("commits", { mode: "json" }).notNull().$type(),
642
659
  error: text("error"),
643
660
  attemptBranch: text("attempt_branch"),
644
- conflictFiles: text("conflict_files", { mode: "json" }).$type()
661
+ conflictFiles: text("conflict_files", { mode: "json" }).$type(),
662
+ digest: text("digest")
645
663
  });
646
664
  testNotes = sqliteTable("test_notes", {
647
665
  id: text("id").primaryKey(),
@@ -674,7 +692,8 @@ var init_db_schema = __esm(() => {
674
692
  status: text("status").notNull().$type(),
675
693
  startedAt: integer("started_at").notNull(),
676
694
  endedAt: integer("ended_at"),
677
- summary: text("summary")
695
+ summary: text("summary"),
696
+ digest: text("digest")
678
697
  });
679
698
  events = sqliteTable("events", {
680
699
  id: integer("id").primaryKey({ autoIncrement: true }),
@@ -687,7 +706,10 @@ var init_db_schema = __esm(() => {
687
706
  type: text("type").notNull(),
688
707
  message: text("message").notNull(),
689
708
  data: text("data", { mode: "json" })
690
- });
709
+ }, (t) => [
710
+ index("events_project_id_id_idx").on(t.projectId, t.id),
711
+ index("events_feature_id_id_idx").on(t.featureId, t.id)
712
+ ]);
691
713
  gateOverrides = sqliteTable("gate_overrides", {
692
714
  id: integer("id").primaryKey({ autoIncrement: true }),
693
715
  featureId: text("feature_id").notNull(),
@@ -1267,6 +1289,16 @@ var init_ring_buffer = __esm(() => {
1267
1289
  });
1268
1290
 
1269
1291
  // src/pty/registry.ts
1292
+ function killTreeBounded(pty) {
1293
+ return new Promise((resolve3) => {
1294
+ const timer = setTimeout(resolve3, KILL_TREE_TIMEOUT_MS);
1295
+ pty.killTree().catch(() => {}).then(() => {
1296
+ clearTimeout(timer);
1297
+ resolve3();
1298
+ });
1299
+ });
1300
+ }
1301
+
1270
1302
  class PtyRegistry {
1271
1303
  entries = new Map;
1272
1304
  create(input) {
@@ -1324,18 +1356,31 @@ class PtyRegistry {
1324
1356
  const entry = this.entries.get(sessionId);
1325
1357
  if (!entry)
1326
1358
  return false;
1327
- entry.pty.kill();
1359
+ this.tearDown(entry);
1360
+ return true;
1361
+ }
1362
+ async killTree(sessionId) {
1363
+ const entry = this.entries.get(sessionId);
1364
+ if (!entry)
1365
+ return false;
1366
+ await this.tearDown(entry);
1328
1367
  return true;
1329
1368
  }
1330
1369
  remove(sessionId) {
1331
1370
  this.entries.delete(sessionId);
1332
1371
  }
1333
- killAll() {
1334
- for (const entry of this.entries.values()) {
1335
- if (!entry.exited)
1336
- entry.pty.kill();
1337
- }
1372
+ async killAllTrees() {
1373
+ const all = [...this.entries.values()];
1338
1374
  this.entries.clear();
1375
+ await Promise.allSettled(all.map((entry) => this.tearDown(entry)));
1376
+ }
1377
+ async tearDown(entry) {
1378
+ if (entry.exited)
1379
+ return;
1380
+ await killTreeBounded(entry.pty);
1381
+ try {
1382
+ entry.pty.kill();
1383
+ } catch {}
1339
1384
  }
1340
1385
  ids() {
1341
1386
  return [...this.entries.keys()];
@@ -1347,7 +1392,7 @@ function ptyRegistry() {
1347
1392
  g[REGISTRY_KEY] = new PtyRegistry;
1348
1393
  return g[REGISTRY_KEY];
1349
1394
  }
1350
- var REGISTRY_KEY;
1395
+ var KILL_TREE_TIMEOUT_MS = 5000, REGISTRY_KEY;
1351
1396
  var init_registry = __esm(() => {
1352
1397
  init_pty();
1353
1398
  init_ring_buffer();
@@ -1428,7 +1473,7 @@ var init_bus = __esm(() => {
1428
1473
  // src/services/events.ts
1429
1474
  import { and, asc, desc, eq, gt, max } from "drizzle-orm";
1430
1475
  function rowToEvent(row) {
1431
- return {
1476
+ return EventRow.parse({
1432
1477
  id: row.id,
1433
1478
  projectId: row.projectId,
1434
1479
  featureId: row.featureId ?? undefined,
@@ -1438,7 +1483,7 @@ function rowToEvent(row) {
1438
1483
  type: row.type,
1439
1484
  message: row.message,
1440
1485
  data: row.data ?? undefined
1441
- };
1486
+ });
1442
1487
  }
1443
1488
  function projectIdForFeature(ctx, featureId) {
1444
1489
  const row = ctx.db.select({ projectId: features.projectId }).from(features).where(eq(features.id, featureId)).get();
@@ -1509,6 +1554,7 @@ function listByProject(ctx, projectId, afterId = 0) {
1509
1554
  return rows.map(rowToEvent);
1510
1555
  }
1511
1556
  var init_events = __esm(() => {
1557
+ init_src();
1512
1558
  init_schema();
1513
1559
  init_errors();
1514
1560
  init_bus();
@@ -1517,12 +1563,13 @@ var init_events = __esm(() => {
1517
1563
  // src/services/repo.ts
1518
1564
  import { and as and2, desc as desc2, eq as eq2 } from "drizzle-orm";
1519
1565
  function rowToFeature(row) {
1520
- return {
1566
+ return Feature.parse({
1521
1567
  id: row.id,
1522
1568
  projectId: row.projectId,
1523
1569
  slug: row.slug,
1524
1570
  title: row.title,
1525
1571
  oneLiner: row.oneLiner,
1572
+ brief: row.brief ?? undefined,
1526
1573
  mapped: row.mapped,
1527
1574
  lap: row.lap,
1528
1575
  phase: row.phase,
@@ -1530,10 +1577,10 @@ function rowToFeature(row) {
1530
1577
  baseBranch: row.baseBranch ?? undefined,
1531
1578
  status: row.status,
1532
1579
  createdAt: row.createdAt
1533
- };
1580
+ });
1534
1581
  }
1535
1582
  function rowToProject(row) {
1536
- return {
1583
+ return Project.parse({
1537
1584
  id: row.id,
1538
1585
  name: row.name,
1539
1586
  repoPath: row.repoPath,
@@ -1546,32 +1593,36 @@ function rowToProject(row) {
1546
1593
  dbResetCommand: row.dbResetCommand ?? undefined,
1547
1594
  driveSetupCommand: row.driveSetupCommand ?? undefined,
1548
1595
  driveStopCommand: row.driveStopCommand ?? undefined,
1549
- driveEnv: row.driveEnv ?? undefined
1550
- };
1596
+ driveEnv: row.driveEnv ?? undefined,
1597
+ closedAt: row.closedAt ?? undefined
1598
+ });
1551
1599
  }
1552
1600
  function rowToRun(row) {
1553
- return {
1601
+ return Run.parse({
1554
1602
  id: row.id,
1555
1603
  featureId: row.featureId,
1556
1604
  workflow: row.workflow,
1557
1605
  status: row.status,
1558
1606
  startedAt: row.startedAt,
1559
1607
  endedAt: row.endedAt ?? undefined,
1560
- summary: row.summary ?? undefined
1561
- };
1608
+ summary: row.summary ?? undefined,
1609
+ digest: row.digest ?? undefined
1610
+ });
1562
1611
  }
1563
1612
  function rowToSession(row) {
1564
- return {
1613
+ return SessionRow.parse({
1565
1614
  id: row.id,
1566
1615
  featureId: row.featureId ?? undefined,
1567
1616
  projectId: row.projectId ?? undefined,
1568
1617
  kind: row.kind,
1618
+ purpose: row.purpose ?? undefined,
1619
+ purposeData: row.purposeData ?? undefined,
1569
1620
  ccSessionId: row.ccSessionId ?? undefined,
1570
1621
  transcriptPath: row.transcriptPath ?? undefined,
1571
1622
  status: row.status,
1572
1623
  awaitingInput: row.awaitingInput,
1573
1624
  worktreePath: row.worktreePath
1574
- };
1625
+ });
1575
1626
  }
1576
1627
  function tryGetFeature(ctx, id) {
1577
1628
  const row = ctx.db.select().from(features).where(eq2(features.id, id)).get();
@@ -1647,6 +1698,7 @@ function setFeatureStatus(ctx, featureId, status) {
1647
1698
  return { ...current, status };
1648
1699
  }
1649
1700
  var init_repo = __esm(() => {
1701
+ init_src();
1650
1702
  init_schema();
1651
1703
  init_errors();
1652
1704
  init_events();
@@ -1655,7 +1707,7 @@ var init_repo = __esm(() => {
1655
1707
  // src/services/waypoints.ts
1656
1708
  import { and as and3, asc as asc2, eq as eq3 } from "drizzle-orm";
1657
1709
  function rowToWaypoint(row) {
1658
- return {
1710
+ return Waypoint.parse({
1659
1711
  id: row.id,
1660
1712
  featureId: row.featureId,
1661
1713
  seq: row.seq,
@@ -1668,7 +1720,7 @@ function rowToWaypoint(row) {
1668
1720
  claimedBy: row.claimedBy ?? undefined,
1669
1721
  lastSessionId: row.lastSessionId ?? undefined,
1670
1722
  summary: row.summary ?? undefined
1671
- };
1723
+ });
1672
1724
  }
1673
1725
  function listByFeature(ctx, featureId) {
1674
1726
  return ctx.db.select().from(waypoints).where(eq3(waypoints.featureId, featureId)).orderBy(asc2(waypoints.seq)).all().map(rowToWaypoint);
@@ -1784,6 +1836,9 @@ function releaseForSession(ctx, claimant) {
1784
1836
  }
1785
1837
  function resolve3(ctx, id, disposition, summary) {
1786
1838
  const wp = getWaypoint(ctx, id);
1839
+ if (isTerminal(wp)) {
1840
+ throw new InvalidInputError(`waypoint ${wp.seq} is already ${wp.status}`);
1841
+ }
1787
1842
  const before = listByFeature(ctx, wp.featureId);
1788
1843
  const bySeqBefore = new Map(before.map((w) => [w.seq, w]));
1789
1844
  ctx.db.update(waypoints).set({ status: disposition, summary, claimedBy: null }).where(eq3(waypoints.id, id)).run();
@@ -1820,6 +1875,91 @@ var init_waypoints = __esm(() => {
1820
1875
  TERMINAL = new Set(["resolved", "dropped"]);
1821
1876
  });
1822
1877
 
1878
+ // src/services/feature-docs.ts
1879
+ import { existsSync as existsSync5 } from "fs";
1880
+ import { join as join5 } from "path";
1881
+ function featureDocsDir(project, feature) {
1882
+ const relSegments = featureDocsRel(feature.slug).split("/");
1883
+ const worktree = worktreeDir(project.id, feature.slug);
1884
+ const base = existsSync5(worktree) ? worktree : project.repoPath;
1885
+ return join5(base, ...relSegments);
1886
+ }
1887
+ function featureDocPath(project, feature, fileName) {
1888
+ return join5(featureDocsDir(project, feature), fileName);
1889
+ }
1890
+ var init_feature_docs = __esm(() => {
1891
+ init_paths();
1892
+ });
1893
+
1894
+ // src/services/docs-watch.ts
1895
+ import { watch } from "fs";
1896
+ function startDocsWatch(ctx, feature) {
1897
+ if (watches.has(feature.id))
1898
+ return;
1899
+ try {
1900
+ const dir = featureDocsDir(projectForFeature(ctx, feature), feature);
1901
+ const watcher = watch(dir, { persistent: false }, (_event, filename) => {
1902
+ noteDocsChange(ctx, feature.id, typeof filename === "string" ? filename : null);
1903
+ });
1904
+ watcher.on("error", (err) => {
1905
+ console.error(`docs watcher failed for feature ${feature.id}`, err);
1906
+ stopDocsWatch(feature.id);
1907
+ });
1908
+ watches.set(feature.id, { watcher, timer: undefined, changed: new Set });
1909
+ } catch (err) {
1910
+ console.error(`docs watcher could not start for feature ${feature.id}`, err);
1911
+ }
1912
+ }
1913
+ function stopDocsWatch(featureId) {
1914
+ const entry = watches.get(featureId);
1915
+ if (!entry)
1916
+ return;
1917
+ watches.delete(featureId);
1918
+ if (entry.timer)
1919
+ clearTimeout(entry.timer);
1920
+ try {
1921
+ entry.watcher.close();
1922
+ } catch (err) {
1923
+ console.error(`docs watcher failed to close for feature ${featureId}`, err);
1924
+ }
1925
+ }
1926
+ function noteDocsChange(ctx, featureId, filename) {
1927
+ const entry = watches.get(featureId);
1928
+ if (!entry)
1929
+ return;
1930
+ if (filename !== null && !filename.endsWith(".md"))
1931
+ return;
1932
+ if (filename)
1933
+ entry.changed.add(filename);
1934
+ if (entry.timer)
1935
+ clearTimeout(entry.timer);
1936
+ entry.timer = setTimeout(() => emitDocsChanged(ctx, featureId), DOCS_WATCH_DEBOUNCE_MS);
1937
+ }
1938
+ function emitDocsChanged(ctx, featureId) {
1939
+ const entry = watches.get(featureId);
1940
+ if (!entry)
1941
+ return;
1942
+ entry.timer = undefined;
1943
+ const files = [...entry.changed].sort();
1944
+ entry.changed.clear();
1945
+ try {
1946
+ emit(ctx, featureId, {
1947
+ type: "docs.changed",
1948
+ message: files.length > 0 ? `docs changed: ${files.join(", ")}` : "docs changed",
1949
+ data: { files }
1950
+ });
1951
+ } catch (err) {
1952
+ console.error(`docs watcher failed to emit for feature ${featureId}`, err);
1953
+ }
1954
+ }
1955
+ var DOCS_WATCH_DEBOUNCE_MS = 300, watches;
1956
+ var init_docs_watch = __esm(() => {
1957
+ init_events();
1958
+ init_feature_docs();
1959
+ init_repo();
1960
+ watches = new Map;
1961
+ });
1962
+
1823
1963
  // src/pty/dev-pane.ts
1824
1964
  function drivePaneId(ownerId) {
1825
1965
  return `${DRIVE_PREFIX}${ownerId}`;
@@ -1880,24 +2020,12 @@ function devPaneLive(paneId) {
1880
2020
  const entry = ptyRegistry().get(paneId);
1881
2021
  return !!entry && !entry.exited;
1882
2022
  }
1883
- function killTreeBounded(pty) {
1884
- return new Promise((resolve4) => {
1885
- const timer = setTimeout(resolve4, KILL_TREE_TIMEOUT_MS);
1886
- pty.killTree().catch(() => {}).then(() => {
1887
- clearTimeout(timer);
1888
- resolve4();
1889
- });
1890
- });
1891
- }
1892
2023
  async function stopDevPane(paneId) {
1893
2024
  const reg = ptyRegistry();
1894
- const entry = reg.get(paneId);
1895
- if (entry && !entry.exited)
1896
- await killTreeBounded(entry.pty);
1897
- reg.kill(paneId);
2025
+ await reg.killTree(paneId);
1898
2026
  reg.remove(paneId);
1899
2027
  }
1900
- var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192, KILL_TREE_TIMEOUT_MS = 5000;
2028
+ var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
1901
2029
  var init_dev_pane = __esm(() => {
1902
2030
  init_events();
1903
2031
  init_registry();
@@ -1955,7 +2083,8 @@ function runDriveHook(command, opts) {
1955
2083
  cwd: opts.cwd,
1956
2084
  env: opts.env ?? process.env,
1957
2085
  windowsHide: true,
1958
- windowsVerbatimArguments: verbatim
2086
+ windowsVerbatimArguments: verbatim,
2087
+ detached: process.platform !== "win32"
1959
2088
  });
1960
2089
  } catch (err) {
1961
2090
  output = err instanceof Error ? err.message : String(err);
@@ -1964,7 +2093,11 @@ function runDriveHook(command, opts) {
1964
2093
  }
1965
2094
  timer = setTimeout(() => {
1966
2095
  timedOut = true;
1967
- child.kill();
2096
+ const pid = child.pid;
2097
+ if (pid === undefined)
2098
+ child.kill();
2099
+ else
2100
+ killProcessTree(pid).then(() => child.kill());
1968
2101
  const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
1969
2102
  grace.unref?.();
1970
2103
  }, timeoutMs);
@@ -1995,6 +2128,7 @@ function describeHookResult(command, result) {
1995
2128
  }
1996
2129
  var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
1997
2130
  var init_drive_hooks = __esm(() => {
2131
+ init_kill_tree();
1998
2132
  DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
1999
2133
  });
2000
2134
 
@@ -2211,9 +2345,9 @@ var init_findings = __esm(() => {
2211
2345
  });
2212
2346
 
2213
2347
  // src/services/git.ts
2214
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2348
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2215
2349
  import { tmpdir } from "os";
2216
- import { dirname, join as join5, resolve as resolve4 } from "path";
2350
+ import { dirname, join as join6, resolve as resolve4 } from "path";
2217
2351
  import { simpleGit } from "simple-git";
2218
2352
  function migrationPaths(paths) {
2219
2353
  return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
@@ -2238,7 +2372,7 @@ function canon(p) {
2238
2372
  return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2239
2373
  }
2240
2374
  async function assertRepo(repoPath) {
2241
- if (!existsSync5(repoPath)) {
2375
+ if (!existsSync6(repoPath)) {
2242
2376
  throw new InvalidInputError(`path does not exist: ${repoPath}`);
2243
2377
  }
2244
2378
  let isRepo = false;
@@ -2327,7 +2461,7 @@ async function ensureTalkWorktree(project, feature) {
2327
2461
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2328
2462
  if (await worktreeIsValid(g, worktreePath, branch))
2329
2463
  return worktreePath;
2330
- if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2464
+ if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2331
2465
  if (await checkoutInWorktree(worktreePath, branch))
2332
2466
  return worktreePath;
2333
2467
  }
@@ -2350,14 +2484,13 @@ async function addWorktree(g, worktreePath, branch, label) {
2350
2484
  }
2351
2485
  }
2352
2486
  }
2353
- async function ensureProjectWorktree(project) {
2487
+ async function ensureProjectWorktree(project, onLanded) {
2354
2488
  const worktreePath = worktreeDir(project.id, PROJECT_WORKTREE_SLUG);
2355
2489
  const g = git(project.repoPath);
2356
2490
  const base = project.mainBranch;
2357
- const leftover = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2358
- if (leftover.length > 0) {
2359
- await mergeTempBranch(project.repoPath, base, PROJECT_BRANCH);
2360
- }
2491
+ const landed = await landProjectBranch(project);
2492
+ if (landed)
2493
+ onLanded?.(landed);
2361
2494
  const stillAhead = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2362
2495
  if (stillAhead.length === 0) {
2363
2496
  await deleteBranchDetachingWorktrees(g, project.repoPath, PROJECT_BRANCH);
@@ -2365,7 +2498,7 @@ async function ensureProjectWorktree(project) {
2365
2498
  }
2366
2499
  if (await worktreeIsValid(g, worktreePath, PROJECT_BRANCH))
2367
2500
  return worktreePath;
2368
- if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2501
+ if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2369
2502
  if (await checkoutInWorktree(worktreePath, PROJECT_BRANCH))
2370
2503
  return worktreePath;
2371
2504
  }
@@ -2391,7 +2524,7 @@ async function ensureBranchExists(g, branch, from) {
2391
2524
  }
2392
2525
  }
2393
2526
  async function worktreeIsValid(g, worktreePath, branch) {
2394
- if (!existsSync5(worktreePath))
2527
+ if (!existsSync6(worktreePath))
2395
2528
  return false;
2396
2529
  const registered = await registeredWorktrees(g);
2397
2530
  if (!registered.has(canon(worktreePath)))
@@ -2437,7 +2570,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2437
2570
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2438
2571
  }
2439
2572
  async function detachWorktree(path) {
2440
- if (!existsSync5(path))
2573
+ if (!existsSync6(path))
2441
2574
  return false;
2442
2575
  const g = git(path);
2443
2576
  let head;
@@ -2452,7 +2585,7 @@ async function detachWorktree(path) {
2452
2585
  return true;
2453
2586
  }
2454
2587
  async function reattachWorktree(path, branch) {
2455
- if (!existsSync5(path))
2588
+ if (!existsSync6(path))
2456
2589
  return;
2457
2590
  try {
2458
2591
  await git(path).checkout(branch);
@@ -2468,21 +2601,21 @@ function ticketBranchName(slug, ticketSeq, unique) {
2468
2601
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2469
2602
  }
2470
2603
  function burnWorktreePath(repoPath, branch) {
2471
- return join5(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2604
+ return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2472
2605
  }
2473
2606
  async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2474
2607
  const path = burnWorktreePath(repoPath, branch);
2475
2608
  const attempts = Math.max(1, opts.attempts ?? 3);
2476
2609
  const delayMs = opts.delayMs ?? 750;
2477
2610
  const g = git(repoPath);
2478
- for (let attempt = 1;attempt <= attempts && existsSync5(path); attempt++) {
2611
+ for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2479
2612
  try {
2480
2613
  await g.raw(["worktree", "remove", "--force", path]);
2481
2614
  } catch {}
2482
- if (existsSync5(path) && attempt < attempts)
2615
+ if (existsSync6(path) && attempt < attempts)
2483
2616
  await sleep(delayMs);
2484
2617
  }
2485
- if (existsSync5(path)) {
2618
+ if (existsSync6(path)) {
2486
2619
  try {
2487
2620
  rmSync(path, { recursive: true, force: true });
2488
2621
  } catch {}
@@ -2490,7 +2623,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2490
2623
  try {
2491
2624
  await g.raw(["worktree", "prune"]);
2492
2625
  } catch {}
2493
- return !existsSync5(path);
2626
+ return !existsSync6(path);
2494
2627
  }
2495
2628
  function sleep(ms) {
2496
2629
  return new Promise((done) => setTimeout(done, ms));
@@ -2638,11 +2771,11 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2638
2771
  async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2639
2772
  let dir;
2640
2773
  try {
2641
- dir = mkdtempSync(join5(tmpdir(), "rc-land-"));
2774
+ dir = mkdtempSync(join6(tmpdir(), "rc-land-"));
2642
2775
  } catch (e) {
2643
2776
  return { ok: false, error: errMsg(e) };
2644
2777
  }
2645
- const wt = join5(dir, "wt");
2778
+ const wt = join6(dir, "wt");
2646
2779
  try {
2647
2780
  await g.raw(["worktree", "add", wt, featureBranchName]);
2648
2781
  const gw = git(wt);
@@ -2749,7 +2882,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2749
2882
  try {
2750
2883
  await g.raw(["worktree", "prune"]);
2751
2884
  } catch {}
2752
- if (existsSync5(worktreePath)) {
2885
+ if (existsSync6(worktreePath)) {
2753
2886
  throw new InvalidInputError(`could not remove talk worktree at ${worktreePath} \u2014 a file may be locked; ` + "close anything using it and retry the delete");
2754
2887
  }
2755
2888
  }
@@ -2776,7 +2909,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2776
2909
  return { deleted, kept };
2777
2910
  }
2778
2911
  async function commitDocs(worktreePath, message) {
2779
- if (!existsSync5(resolve4(worktreePath, DOCS_PATHSPEC)))
2912
+ if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
2780
2913
  return;
2781
2914
  const g = git(worktreePath);
2782
2915
  await g.add([DOCS_PATHSPEC]);
@@ -3181,10 +3314,24 @@ async function conflictedFiles(g) {
3181
3314
  return [];
3182
3315
  }
3183
3316
  }
3317
+ async function mergeInProgressAt(worktreePath) {
3318
+ try {
3319
+ return await mergeInProgress(git(worktreePath));
3320
+ } catch {
3321
+ return false;
3322
+ }
3323
+ }
3324
+ async function isAncestor(repoPath, ancestor, descendant) {
3325
+ try {
3326
+ const out = await git(repoPath).raw(["rev-list", "--count", ancestor, `^${descendant}`]);
3327
+ return out.trim() === "0";
3328
+ } catch {
3329
+ return false;
3330
+ }
3331
+ }
3184
3332
  async function mergeInProgress(g) {
3185
3333
  try {
3186
- await g.raw(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
3187
- return true;
3334
+ return (await g.raw(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"])).trim() !== "";
3188
3335
  } catch {
3189
3336
  return false;
3190
3337
  }
@@ -3214,6 +3361,8 @@ function createSessionRow(ctx, input) {
3214
3361
  projectId: input.projectId ?? null,
3215
3362
  lap: input.featureId ? getFeatureRow(ctx, input.featureId).lap : 1,
3216
3363
  kind: input.kind,
3364
+ purpose: input.purpose ?? null,
3365
+ purposeData: input.purposeData ?? null,
3217
3366
  ccSessionId: null,
3218
3367
  transcriptPath: null,
3219
3368
  status: "launching",
@@ -3453,35 +3602,46 @@ function landProjectSession(ctx, session) {
3453
3602
  const project = getProjectById(ctx, session.projectId);
3454
3603
  if (!project)
3455
3604
  return;
3456
- const kept = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3457
- const report = (type, message, data = {}) => {
3458
- emitProject(ctx, project.id, {
3459
- type,
3460
- message,
3461
- data: { sessionId: session.id, branch: PROJECT_BRANCH, ...data }
3462
- });
3463
- };
3464
3605
  const landing = landProjectBranch(project).then((res) => {
3465
- if (!res)
3466
- return;
3467
- if (res.landed) {
3468
- report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${project.mainBranch}`, { commits: res.commits });
3469
- } else if (res.conflict) {
3470
- report("project.land_conflict", `the project session's ${res.commits} commit(s) conflict with ${project.mainBranch} \u2014 nothing was overwritten; ${kept}`, { commits: res.commits, files: res.files ?? [] });
3471
- } else {
3472
- report("project.land_failed", `could not land the project session's ${res.commits} commit(s) onto ${project.mainBranch}: ${res.error ?? "unknown error"} \u2014 ${kept}`, { commits: res.commits, error: res.error ?? null });
3473
- }
3606
+ if (res)
3607
+ reportProjectLanding(ctx, project, res, { sessionId: session.id });
3474
3608
  }).catch((e) => {
3475
- report("project.land_failed", `could not land the project session's work onto ${project.mainBranch}: ${e instanceof Error ? e.message : String(e)} \u2014 ${kept}`, { error: e instanceof Error ? e.message : String(e) });
3609
+ emitProject(ctx, project.id, {
3610
+ type: "project.land_failed",
3611
+ message: `could not land the project session's work onto ${project.mainBranch}: ${e instanceof Error ? e.message : String(e)} \u2014 ${LANDING_KEPT}`,
3612
+ data: {
3613
+ sessionId: session.id,
3614
+ branch: PROJECT_BRANCH,
3615
+ error: e instanceof Error ? e.message : String(e)
3616
+ }
3617
+ });
3476
3618
  });
3477
3619
  inFlightLandings.add(landing);
3478
3620
  landing.finally(() => inFlightLandings.delete(landing));
3479
3621
  }
3622
+ function reportProjectLanding(ctx, project, res, data = {}) {
3623
+ const report = (type, message, extra) => {
3624
+ emitProject(ctx, project.id, {
3625
+ type,
3626
+ message,
3627
+ data: { branch: PROJECT_BRANCH, ...data, ...extra }
3628
+ });
3629
+ };
3630
+ if (res.landed) {
3631
+ report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${project.mainBranch}`, { commits: res.commits });
3632
+ } else if (res.conflict) {
3633
+ report("project.land_conflict", `the project session's ${res.commits} commit(s) conflict with ${project.mainBranch} \u2014 nothing was overwritten; ${LANDING_KEPT}`, { commits: res.commits, files: res.files ?? [] });
3634
+ } else {
3635
+ report("project.land_failed", `could not land the project session's ${res.commits} commit(s) onto ${project.mainBranch}: ${res.error ?? "unknown error"} \u2014 ${LANDING_KEPT}`, { commits: res.commits, error: res.error ?? null });
3636
+ }
3637
+ }
3480
3638
  function markSessionEnded(ctx, id) {
3481
3639
  const existing = getSessionRow(ctx, id);
3482
3640
  if (!existing)
3483
3641
  return null;
3484
3642
  forgetKickoff(id);
3643
+ if (existing.featureId)
3644
+ stopDocsWatch(existing.featureId);
3485
3645
  ctx.db.update(sessions).set({ status: "ended" }).where(eq5(sessions.id, id)).run();
3486
3646
  return getSessionRow(ctx, id);
3487
3647
  }
@@ -3504,11 +3664,12 @@ function mostRecentLiveSession(ctx) {
3504
3664
  const row = ctx.db.select().from(sessions).where(eq5(sessions.status, "live")).orderBy(desc3(sql2`rowid`)).limit(1).get();
3505
3665
  return row ? rowToSession(row) : null;
3506
3666
  }
3507
- 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, inFlightLandings;
3667
+ 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, inFlightLandings, LANDING_KEPT;
3508
3668
  var init_sessions = __esm(() => {
3509
3669
  init_src();
3510
3670
  init_schema();
3511
3671
  init_registry();
3672
+ init_docs_watch();
3512
3673
  init_events();
3513
3674
  init_git();
3514
3675
  init_waypoints();
@@ -3528,6 +3689,7 @@ var init_sessions = __esm(() => {
3528
3689
  deliveries = new Map;
3529
3690
  landedProjectSessions = new Set;
3530
3691
  inFlightLandings = new Set;
3692
+ LANDING_KEPT = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3531
3693
  });
3532
3694
 
3533
3695
  // src/launcher/reconcile.ts
@@ -3581,26 +3743,10 @@ async function getRuntimeCtx() {
3581
3743
  }
3582
3744
  var current = null;
3583
3745
 
3584
- // src/services/feature-docs.ts
3585
- import { existsSync as existsSync6 } from "fs";
3586
- import { join as join6 } from "path";
3587
- function featureDocsDir(project, feature) {
3588
- const relSegments = featureDocsRel(feature.slug).split("/");
3589
- const worktree = worktreeDir(project.id, feature.slug);
3590
- const base = existsSync6(worktree) ? worktree : project.repoPath;
3591
- return join6(base, ...relSegments);
3592
- }
3593
- function featureDocPath(project, feature, fileName) {
3594
- return join6(featureDocsDir(project, feature), fileName);
3595
- }
3596
- var init_feature_docs = __esm(() => {
3597
- init_paths();
3598
- });
3599
-
3600
3746
  // src/services/tickets.ts
3601
3747
  import { and as and6, asc as asc3, eq as eq6 } from "drizzle-orm";
3602
3748
  function rowToTicket(row) {
3603
- return {
3749
+ return Ticket.parse({
3604
3750
  id: row.id,
3605
3751
  featureId: row.featureId,
3606
3752
  seq: row.seq,
@@ -3615,8 +3761,9 @@ function rowToTicket(row) {
3615
3761
  commits: row.commits,
3616
3762
  error: row.error ?? undefined,
3617
3763
  attemptBranch: row.attemptBranch ?? undefined,
3618
- conflictFiles: row.conflictFiles ?? undefined
3619
- };
3764
+ conflictFiles: row.conflictFiles ?? undefined,
3765
+ digest: row.digest ?? undefined
3766
+ });
3620
3767
  }
3621
3768
  function resolveBlocking(inputs, startSeq) {
3622
3769
  try {
@@ -3652,7 +3799,8 @@ function storeTickets(ctx, featureId, inputs) {
3652
3799
  commits: [],
3653
3800
  error: null,
3654
3801
  attemptBranch: null,
3655
- conflictFiles: null
3802
+ conflictFiles: null,
3803
+ digest: null
3656
3804
  }));
3657
3805
  ctx.db.insert(tickets).values(rows).run();
3658
3806
  emit(ctx, featureId, {
@@ -3739,6 +3887,8 @@ function updateTicket(ctx, id, patch) {
3739
3887
  set.attemptBranch = patch.attemptBranch;
3740
3888
  if (patch.conflictFiles !== undefined)
3741
3889
  set.conflictFiles = patch.conflictFiles;
3890
+ if (patch.digest !== undefined)
3891
+ set.digest = patch.digest;
3742
3892
  ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
3743
3893
  emit(ctx, current2.featureId, {
3744
3894
  type: "ticket.updated",
@@ -3824,6 +3974,13 @@ function docGate(ctx, feature, fileName, reason) {
3824
3974
  }
3825
3975
  function overrideGate(ctx, featureId, gate, reason) {
3826
3976
  const feature = getFeatureRow(ctx, featureId);
3977
+ const current2 = nextGate(feature);
3978
+ if (!current2) {
3979
+ throw new InvalidInputError(`cannot override ${gate}: feature is at ${feature.phase}, its final phase \u2014 no gate to cross`);
3980
+ }
3981
+ if (current2.id !== gate) {
3982
+ throw new InvalidInputError(`cannot override ${gate}: the feature is at ${feature.phase}, whose gate is ${current2.id}`);
3983
+ }
3827
3984
  ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
3828
3985
  emit(ctx, featureId, {
3829
3986
  type: "gate.overridden",
@@ -4380,14 +4537,21 @@ function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
4380
4537
  }
4381
4538
  function buildWorkspaceNotes(mode) {
4382
4539
  if (mode === "mounted") {
4383
- return "Work in the current directory \u2014 it is the repo checkout on your branch.";
4540
+ return [
4541
+ "Work in the current directory \u2014 it is the repo checkout on your branch.",
4542
+ "",
4543
+ "Write `DIGEST.md` at the root of that checkout, and leave it uncommitted \u2014 the host harvests it from disk."
4544
+ ].join(`
4545
+ `);
4384
4546
  }
4385
4547
  return [
4386
4548
  `Your working repository is \`${ISOLATED_REPO_PATH}\` \u2014 a clone on the container's fast native filesystem, with dependencies already installed. Do ALL work there: \`cd ${ISOLATED_REPO_PATH}\` first; every file you read, edit, test, and commit lives under it.`,
4387
4549
  "",
4388
4550
  `The directory you start in (\`${SANDBOX_WORKSPACE_PATH}\`) is a slow mounted mirror used only to collect your commits \u2014 never edit files, install, or run tests there. Your commits sync back automatically (a post-commit hook pushes them); just commit as normal. If you re-run the dependency install and it reconfigures git hooks (husky), run \`git -C ${ISOLATED_REPO_PATH} config core.hooksPath ${ISOLATED_REPO_PATH}/.git/hooks\` afterwards so the sync hook stays armed.`,
4389
4551
  "",
4390
- `If you are blocked and write \`BLOCKED.md\`, write it at \`${ISOLATED_REPO_PATH}/BLOCKED.md\` AND copy it to \`${SANDBOX_WORKSPACE_PATH}/BLOCKED.md\` so the orchestrator can see it.`
4552
+ `If you are blocked and write \`BLOCKED.md\`, write it at \`${ISOLATED_REPO_PATH}/BLOCKED.md\` AND copy it to \`${SANDBOX_WORKSPACE_PATH}/BLOCKED.md\` so the orchestrator can see it.`,
4553
+ "",
4554
+ `Write \`DIGEST.md\` at \`${SANDBOX_WORKSPACE_PATH}/DIGEST.md\` \u2014 the mounted mirror, so the host can see it \u2014 and NOT inside \`${ISOLATED_REPO_PATH}\`, where it would be committed with your work.`
4391
4555
  ].join(`
4392
4556
  `);
4393
4557
  }
@@ -4649,6 +4813,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4649
4813
  const status = new Map(tickets2.map((t) => [t.seq, t.status]));
4650
4814
  const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
4651
4815
  const inFlight = new Map;
4816
+ const digests = [];
4652
4817
  const satisfied = (s) => s === "done" || s === "cancelled";
4653
4818
  const readyState = (seq) => {
4654
4819
  const t = bySeq.get(seq);
@@ -4685,13 +4850,22 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4685
4850
  const outcome = await execute(ctx, t);
4686
4851
  if (outcome.status === "done") {
4687
4852
  status.set(seq, "done");
4688
- ctx.updateTicket(t.id, { status: "done", commits: outcome.commits });
4853
+ ctx.updateTicket(t.id, { status: "done", commits: outcome.commits, digest: outcome.digest });
4689
4854
  ctx.emitEvent({
4690
4855
  type: "ticket.done",
4691
4856
  message: `ticket ${t.seq} done \u2014 ${outcome.commits.length} commit(s)`,
4692
4857
  ticketId: t.id,
4693
4858
  data: { commits: outcome.commits }
4694
4859
  });
4860
+ if (outcome.digest === undefined) {
4861
+ ctx.emitEvent({
4862
+ type: "digest.missing",
4863
+ message: `ticket ${t.seq} done without DIGEST.md`,
4864
+ ticketId: t.id
4865
+ });
4866
+ } else {
4867
+ digests.push({ seq: t.seq, title: t.title, digest: outcome.digest });
4868
+ }
4695
4869
  } else {
4696
4870
  failTicket(seq, outcome.error, outcome.event);
4697
4871
  ctx.emitEvent({
@@ -4757,7 +4931,16 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4757
4931
  for (const s of status.values())
4758
4932
  if (s === "done")
4759
4933
  done += 1;
4760
- return done;
4934
+ return { done, digests };
4935
+ }
4936
+ function composeRunDigest(entries) {
4937
+ if (entries.length === 0)
4938
+ return null;
4939
+ return [...entries].sort((a, b) => a.seq - b.seq).map((e) => `## ticket ${e.seq} \u2014 ${e.title}
4940
+
4941
+ ${e.digest.trim()}`).join(`
4942
+
4943
+ `);
4761
4944
  }
4762
4945
  async function burnRun(ctx, deps) {
4763
4946
  const tickets2 = ctx.tickets;
@@ -4778,10 +4961,11 @@ async function burnRun(ctx, deps) {
4778
4961
  });
4779
4962
  return { status: "failed", summary: `dependency cycle: ${path}` };
4780
4963
  }
4781
- const done = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
4964
+ const { done, digests } = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
4782
4965
  const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
4783
4966
  ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
4784
- return { status: done === total ? "succeeded" : "failed", summary };
4967
+ const digest = composeRunDigest(digests);
4968
+ return { status: done === total ? "succeeded" : "failed", summary, ...digest ? { digest } : {} };
4785
4969
  }
4786
4970
  function burnerTemplatePath() {
4787
4971
  return burnerAssetPath("implement-ticket.md");
@@ -4803,11 +4987,11 @@ function readDocsDigestFromDisk(projectId, slug) {
4803
4987
  const files = readdirSync3(docsDir, { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name)).map((e) => ({ name: e.name, content: readFileSync4(join9(docsDir, e.name), "utf8") }));
4804
4988
  return buildDocsDigest(files);
4805
4989
  }
4806
- function readBlockedFile(dirs) {
4990
+ function readAgentFile(dirs, file) {
4807
4991
  for (const dir of dirs) {
4808
4992
  if (!dir)
4809
4993
  continue;
4810
- const p = join9(dir, "BLOCKED.md");
4994
+ const p = join9(dir, file);
4811
4995
  if (existsSync10(p)) {
4812
4996
  try {
4813
4997
  return readFileSync4(p, "utf8");
@@ -4816,10 +5000,26 @@ function readBlockedFile(dirs) {
4816
5000
  }
4817
5001
  return;
4818
5002
  }
5003
+ function harvestDigest(dirs) {
5004
+ const trimmed = readAgentFile(dirs, "DIGEST.md")?.trim();
5005
+ return trimmed ? trimmed : undefined;
5006
+ }
5007
+ function buildAgentEnv(onHost, token) {
5008
+ const env = {};
5009
+ if (onHost) {
5010
+ for (const [key, value] of Object.entries(process.env)) {
5011
+ if (value !== undefined)
5012
+ env[key] = value;
5013
+ }
5014
+ }
5015
+ if (token)
5016
+ env.CLAUDE_CODE_OAUTH_TOKEN = token;
5017
+ return env;
5018
+ }
4819
5019
  function buildBurnAgent(config2, token, model) {
4820
5020
  const onHost = config2.sandbox === "noSandbox";
4821
5021
  const opts = {
4822
- ...token ? { env: { CLAUDE_CODE_OAUTH_TOKEN: token } } : {},
5022
+ env: buildAgentEnv(onHost, token),
4823
5023
  ...onHost ? { permissionMode: "bypassPermissions" } : {}
4824
5024
  };
4825
5025
  const agent = claudeCode(model, opts);
@@ -4843,8 +5043,10 @@ function selectSandbox(config2, mounts = []) {
4843
5043
  return docker(imageOpts);
4844
5044
  case "podman":
4845
5045
  return podman(imageOpts);
4846
- default:
5046
+ case "noSandbox":
4847
5047
  return noSandbox();
5048
+ default:
5049
+ throw new Error(`no sandbox provider for sandbox "${config2.sandbox}" \u2014 refusing to run the agent unsandboxed`);
4848
5050
  }
4849
5051
  }
4850
5052
  function buildSandboxOptions(config2, mounts = []) {
@@ -4989,7 +5191,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
4989
5191
  }
4990
5192
  await cleanupBurnWorktree(project.repoPath, resolveBranch);
4991
5193
  }
4992
- const blocked = readBlockedFile([result?.preservedWorktreePath, project.repoPath]);
5194
+ const blocked = readAgentFile([result?.preservedWorktreePath, project.repoPath], "BLOCKED.md");
4993
5195
  if (blocked !== undefined && blocked.trim().length > 0) {
4994
5196
  return {
4995
5197
  ok: false,
@@ -5023,11 +5225,16 @@ ${blocked.trim()}`
5023
5225
  label: `ticket ${ticket.seq}`,
5024
5226
  featureBranch: feature.branch
5025
5227
  };
5228
+ let harvestedDigest;
5026
5229
  const landChain = async (branch, commits) => {
5027
5230
  const landing = await landWithResolve(branch, landDeps);
5028
5231
  if (landing.status === "landed") {
5029
5232
  ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
5030
- return { status: "done", commits: landedCommits.length > 0 ? landedCommits : commits };
5233
+ return {
5234
+ status: "done",
5235
+ commits: landedCommits.length > 0 ? landedCommits : commits,
5236
+ digest: harvestedDigest
5237
+ };
5031
5238
  }
5032
5239
  preserveChain(landing.branch);
5033
5240
  if (landing.status === "conflict") {
@@ -5188,7 +5395,9 @@ ${retryNotes}` : basePrompt,
5188
5395
  }
5189
5396
  if (!result)
5190
5397
  return { status: "failed", error: "internal: run loop produced no result" };
5191
- const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
5398
+ const agentFileDirs = [result.preservedWorktreePath, project.repoPath];
5399
+ const blocked = readAgentFile(agentFileDirs, "BLOCKED.md");
5400
+ harvestedDigest = harvestDigest(agentFileDirs);
5192
5401
  const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
5193
5402
  let outcome;
5194
5403
  if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
@@ -5322,8 +5531,6 @@ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as re
5322
5531
  import { dirname as dirname4, join as join10 } from "path";
5323
5532
  import { fileURLToPath as fileURLToPath3 } from "url";
5324
5533
  import { run as run2 } from "@ai-hero/sandcastle";
5325
- import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
5326
- import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
5327
5534
  function waypointSlug(waypoint) {
5328
5535
  const base = waypoint.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5329
5536
  return `${waypoint.seq}-${base || "waypoint"}`;
@@ -5337,7 +5544,7 @@ async function researchRun(ctx, deps) {
5337
5544
  ctx.emitEvent({ type: "research.error", message: "research run started without a waypoint" });
5338
5545
  return { status: "failed", summary: "no waypoint to research" };
5339
5546
  }
5340
- if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
5547
+ if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
5341
5548
  ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
5342
5549
  return { status: "failed", summary: "research aborted: auth token missing" };
5343
5550
  }
@@ -5419,7 +5626,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
5419
5626
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
5420
5627
  const runOptions = {
5421
5628
  agent: buildBurnAgent(config2, token, model),
5422
- sandbox: config2.sandbox === "docker" ? docker2({ imageName: resolveSandboxImage(config2) }) : noSandbox2(),
5629
+ sandbox: selectSandbox(config2),
5423
5630
  cwd: project.repoPath,
5424
5631
  prompt,
5425
5632
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
@@ -5595,10 +5802,12 @@ function cancelRun(runId) {
5595
5802
  async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
5596
5803
  let status = "failed";
5597
5804
  let summary = "run failed";
5805
+ let digest;
5598
5806
  try {
5599
5807
  const result = await runPromise;
5600
5808
  status = result.status;
5601
5809
  summary = result.summary;
5810
+ digest = result.digest;
5602
5811
  } catch (e) {
5603
5812
  if (controller.signal.aborted) {
5604
5813
  status = "cancelled";
@@ -5611,7 +5820,7 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
5611
5820
  } finally {
5612
5821
  controllers.delete(runId);
5613
5822
  }
5614
- ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
5823
+ ctx.db.update(runs).set({ status, endedAt: Date.now(), summary, ...digest ? { digest } : {} }).where(eq8(runs.id, runId)).run();
5615
5824
  releaseForSession(ctx, runId);
5616
5825
  if (workflowClaimsFeatureBranch(workflow2)) {
5617
5826
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -5666,36 +5875,73 @@ async function createFeature(ctx, input) {
5666
5875
  const slug = uniqueSlug(ctx, project.id, input.title);
5667
5876
  const branch = `feature/${slug}`;
5668
5877
  const requestedBase = input.baseBranch?.trim() || project.mainBranch;
5669
- const { branchReady, baseBranch } = await ensureFeatureBranch(project, slug, requestedBase);
5878
+ const cut = input.draft ? null : await ensureFeatureBranch(project, slug, requestedBase);
5670
5879
  const row = {
5671
5880
  id: newId("feat"),
5672
5881
  projectId: project.id,
5673
5882
  slug,
5674
5883
  title: input.title,
5675
5884
  oneLiner: input.oneLiner,
5885
+ brief: input.draft ? input.brief ?? null : null,
5676
5886
  mapped: false,
5677
5887
  lap: 1,
5678
5888
  phase: "ideation",
5679
5889
  branch,
5680
- baseBranch,
5681
- status: "active",
5890
+ baseBranch: cut?.baseBranch ?? null,
5891
+ status: cut ? "active" : "draft",
5682
5892
  createdAt: Date.now()
5683
5893
  };
5684
5894
  const inserted = ctx.db.insert(features).values(row).returning().get();
5685
5895
  const feature = rowToFeature(inserted);
5686
5896
  emit(ctx, feature.id, {
5687
5897
  type: "feature.created",
5688
- message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
5689
- data: { slug, branch, baseBranch, branchReady }
5898
+ message: !cut ? `feature.created (draft \u2014 ${branch} not cut yet)` : cut.branchReady ? `feature.created (${branch} \u2190 ${cut.baseBranch})` : "feature.created (branch pending)",
5899
+ data: {
5900
+ slug,
5901
+ branch,
5902
+ baseBranch: cut?.baseBranch,
5903
+ branchReady: cut?.branchReady ?? false,
5904
+ draft: !cut
5905
+ }
5690
5906
  });
5907
+ if (!cut)
5908
+ return feature;
5691
5909
  scaffoldDocs(ctx, feature, { brief: input.brief });
5692
- if (branchReady) {
5910
+ if (cut.branchReady) {
5693
5911
  try {
5694
5912
  await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
5695
5913
  } catch {}
5696
5914
  }
5697
5915
  return feature;
5698
5916
  }
5917
+ function requireNotDraft(feature) {
5918
+ if (feature.status === "draft") {
5919
+ throw new GateError(`\`${feature.slug}\` is a draft \u2014 click Start to cut its branch and begin`);
5920
+ }
5921
+ }
5922
+ async function startDraft(ctx, featureId, opts = {}) {
5923
+ const feature = getFeatureRow(ctx, featureId);
5924
+ if (feature.status !== "draft") {
5925
+ throw new GateError(`feature ${feature.slug} is not a draft \u2014 it has already been started`);
5926
+ }
5927
+ const project = projectForFeature(ctx, feature);
5928
+ const requestedBase = opts.baseBranch?.trim() || project.mainBranch;
5929
+ const { branchReady, baseBranch } = await ensureFeatureBranch(project, feature.slug, requestedBase);
5930
+ ctx.db.update(features).set({ status: "active", baseBranch }).where(eq9(features.id, featureId)).run();
5931
+ const started = { ...feature, status: "active", baseBranch };
5932
+ emit(ctx, featureId, {
5933
+ type: "feature.started",
5934
+ message: branchReady ? `feature ${feature.slug} started (${feature.branch} \u2190 ${baseBranch})` : `feature ${feature.slug} started (branch pending)`,
5935
+ data: { branch: feature.branch, baseBranch, branchReady }
5936
+ });
5937
+ scaffoldDocs(ctx, started, { brief: feature.brief });
5938
+ if (branchReady) {
5939
+ try {
5940
+ await commitDocs(project.repoPath, `runcastle: scaffold ${feature.slug} docs`);
5941
+ } catch {}
5942
+ }
5943
+ return started;
5944
+ }
5699
5945
  async function quickChange(ctx, input) {
5700
5946
  const project = requireProjectById(ctx, input.projectId);
5701
5947
  const title = input.title.trim();
@@ -5814,6 +6060,7 @@ function liveSessionOf(ctx, featureId) {
5814
6060
  }
5815
6061
  function advance(ctx, featureId) {
5816
6062
  const feature = getFeatureRow(ctx, featureId);
6063
+ requireNotDraft(feature);
5817
6064
  const gate = nextGate(feature);
5818
6065
  if (!gate)
5819
6066
  throw new GateError("feature is already at the final phase");
@@ -5831,6 +6078,7 @@ function advance(ctx, featureId) {
5831
6078
  }
5832
6079
  async function burn(ctx, featureId, opts = {}) {
5833
6080
  const feature = getFeatureRow(ctx, featureId);
6081
+ requireNotDraft(feature);
5834
6082
  const running = hasActiveRun(ctx, featureId);
5835
6083
  let tickets2 = listByFeature2(ctx, featureId);
5836
6084
  const lapTickets = tickets2.filter((t) => t.lap === feature.lap);
@@ -5881,6 +6129,7 @@ async function burn(ctx, featureId, opts = {}) {
5881
6129
  }
5882
6130
  function rethink(ctx, featureId) {
5883
6131
  const feature = getFeatureRow(ctx, featureId);
6132
+ requireNotDraft(feature);
5884
6133
  if (feature.phase !== RETHINK_LOOP_BACK.from) {
5885
6134
  throw new GateError(`feature must be in the review phase to rethink (currently ${feature.phase})`);
5886
6135
  }
@@ -5994,6 +6243,7 @@ function escalateToMap(ctx, featureId, input) {
5994
6243
  }
5995
6244
  function archiveFeature(ctx, featureId) {
5996
6245
  const feature = getFeatureRow(ctx, featureId);
6246
+ requireNotDraft(feature);
5997
6247
  if (feature.status === "archived") {
5998
6248
  throw new GateError(`feature ${feature.slug} is already archived`);
5999
6249
  }
@@ -6277,8 +6527,19 @@ async function toolDryRunDrive(ctx, session, input) {
6277
6527
  }
6278
6528
  return dryRunDrive(ctx, project, input.action);
6279
6529
  }
6530
+ function createFeatureProject(ctx, session, input) {
6531
+ if (isProjectSessionKind(session.kind))
6532
+ return requireProject(ctx, session);
6533
+ if (!DRAFTING_KINDS.includes(session.kind)) {
6534
+ throw new GateError(`a ${session.kind} session is read-only and may not create features, drafts included. ` + "Tell the human what is worth capturing; the project session is where features are made.");
6535
+ }
6536
+ if (!input.draft || input.ticket) {
6537
+ throw new GateError(`a ${session.kind} session may only PARK a feature here: call create_feature with ` + "`draft: true` and a `brief` carrying why you deferred it. Cutting a branch \u2014 a full " + "create or the quick-change `ticket` shape \u2014 belongs to the project session; tell the " + "human to open it.");
6538
+ }
6539
+ return projectForFeature(ctx, getFeatureRow(ctx, requireFeatureId(session)));
6540
+ }
6280
6541
  async function toolCreateFeature(ctx, session, input) {
6281
- const project = requireProject(ctx, session);
6542
+ const project = createFeatureProject(ctx, session, input);
6282
6543
  const feature = input.ticket ? await quickChange(ctx, {
6283
6544
  projectId: project.id,
6284
6545
  title: input.title,
@@ -6289,7 +6550,8 @@ async function toolCreateFeature(ctx, session, input) {
6289
6550
  title: input.title,
6290
6551
  oneLiner: input.oneLiner,
6291
6552
  baseBranch: input.baseBranch,
6292
- brief: input.brief
6553
+ brief: input.brief,
6554
+ draft: input.draft
6293
6555
  });
6294
6556
  return {
6295
6557
  id: feature.id,
@@ -6350,7 +6612,8 @@ function toolGetWorkRecord(ctx, session, input) {
6350
6612
  status: t.status,
6351
6613
  seams: t.seams,
6352
6614
  commits: t.commits,
6353
- ...t.error !== undefined ? { error: t.error } : {}
6615
+ ...t.error !== undefined ? { error: t.error } : {},
6616
+ ...t.digest !== undefined ? { digest: t.digest } : {}
6354
6617
  }))
6355
6618
  });
6356
6619
  }
@@ -6404,12 +6667,13 @@ function buildMcpServer() {
6404
6667
  });
6405
6668
  server.registerTool("create_feature", {
6406
6669
  title: "Create a feature",
6407
- 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.",
6670
+ 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 " + "`draft: true` to PARK it instead of starting it \u2014 a row and its brief, no branch and no " + "files, until the human clicks Start; ask them per feature whether to start it now or " + "park it. 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). From a feature session (ideation, " + "revisit, waypoint, converge) this tool parks drafts and nothing else: `draft: true` is " + "required and the `ticket` shape is refused, so deflect scope creep here and leave full " + "creation to the project session. 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.",
6408
6671
  inputSchema: {
6409
6672
  title: z3.string().min(1),
6410
6673
  oneLiner: z3.string(),
6411
6674
  baseBranch: z3.string().optional(),
6412
6675
  brief: z3.string().optional(),
6676
+ draft: z3.boolean().optional(),
6413
6677
  ticket: z3.object({ prose: z3.string().min(1) }).optional()
6414
6678
  }
6415
6679
  }, async (args, extra) => {
@@ -6430,7 +6694,7 @@ function buildMcpServer() {
6430
6694
  });
6431
6695
  server.registerTool("get_work_record", {
6432
6696
  title: "Get work record",
6433
- description: "What features actually DID \u2014 facts only: per feature its status, ship date, run " + "summaries and tickets as { seq, title, status, seams, commits, error? }. Never a " + "ticket's goal or acceptance criteria: those are intent from before the code existed. " + 'Query by `featureSlug` ("what did X do?") or by `seam` ("who has touched this area, and ' + 'what happened?") \u2014 the seam match is a case-insensitive substring, because seams are ' + "free prose that differs between features. At least one argument is required.",
6697
+ description: "What features actually DID \u2014 facts only: per feature its status, ship date, run " + "summaries and tickets as { seq, title, status, seams, commits, error?, digest? }. The " + "digest is the burner's own account, written after the work: what it did, what " + "surprised it, what it left undone. Never a ticket's goal or acceptance criteria: " + "those are intent from before the code existed. " + 'Query by `featureSlug` ("what did X do?") or by `seam` ("who has touched this area, and ' + 'what happened?") \u2014 the seam match is a case-insensitive substring, because seams are ' + "free prose that differs between features. At least one argument is required.",
6434
6698
  inputSchema: { featureSlug: z3.string().optional(), seam: z3.string().optional() }
6435
6699
  }, async (args, extra) => {
6436
6700
  const rs = await resolveCtxSession(extra);
@@ -6553,7 +6817,7 @@ function buildMcpServer() {
6553
6817
  });
6554
6818
  return server;
6555
6819
  }
6556
- var mcp, server_default;
6820
+ var DRAFTING_KINDS, mcp, server_default;
6557
6821
  var init_server = __esm(() => {
6558
6822
  init_src();
6559
6823
  init_paths();
@@ -6567,6 +6831,7 @@ var init_server = __esm(() => {
6567
6831
  init_repo();
6568
6832
  init_tickets();
6569
6833
  init_waypoints();
6834
+ DRAFTING_KINDS = ["ideation", "revisit", "waypoint", "converge"];
6570
6835
  mcp = new Hono;
6571
6836
  mcp.use("*", async (c, next) => {
6572
6837
  await next();
@@ -6658,25 +6923,29 @@ function guardsEdits(kind) {
6658
6923
  function evaluateEditGuard(input) {
6659
6924
  if (!guardsEdits(input.kind))
6660
6925
  return null;
6926
+ if (input.purpose === "resolve-conflict" && input.mergeInProgress)
6927
+ return null;
6661
6928
  if (!input.toolName || !EDIT_TOOLS.includes(input.toolName))
6662
6929
  return null;
6663
6930
  if (!input.filePath)
6664
6931
  return null;
6932
+ const target = resolve7(input.worktreePath, input.filePath);
6665
6933
  if (!input.featureSlug) {
6666
6934
  return {
6667
6935
  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."
6668
6936
  };
6669
6937
  }
6670
6938
  const docs = featureDocsRel(input.featureSlug);
6671
- const docsDir = resolve7(input.worktreePath, docs);
6672
- const target = resolve7(input.worktreePath, input.filePath);
6673
- const rel = relative2(docsDir, target);
6674
- if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel))
6939
+ if (within(resolve7(input.worktreePath, docs), target))
6675
6940
  return null;
6676
6941
  return {
6677
6942
  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."
6678
6943
  };
6679
6944
  }
6945
+ function within(dir, target) {
6946
+ const rel = relative2(dir, target);
6947
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
6948
+ }
6680
6949
  function editDenyResponse(denial) {
6681
6950
  return {
6682
6951
  hookSpecificOutput: {
@@ -6834,22 +7103,40 @@ function handleUserPrompt(ctx, sessionId, feature, payload) {
6834
7103
  }
6835
7104
  };
6836
7105
  }
6837
- function handleSessionEnd(ctx, sessionId, feature) {
6838
- markSessionEnded(ctx, sessionId);
6839
- releaseForSession(ctx, sessionId);
7106
+ async function handleSessionEnd(ctx, session, feature) {
7107
+ markSessionEnded(ctx, session.id);
7108
+ releaseForSession(ctx, session.id);
6840
7109
  emit(ctx, feature.id, {
6841
7110
  type: "session.ended",
6842
7111
  message: "session ended",
6843
- data: { sessionId }
7112
+ data: { sessionId: session.id }
6844
7113
  });
7114
+ await noteResolvedMerge(ctx, session, feature);
6845
7115
  return {};
6846
7116
  }
6847
- function handlePreToolUse(session, feature, payload) {
7117
+ async function noteResolvedMerge(ctx, session, feature) {
7118
+ const pair = session.purpose === "resolve-conflict" ? session.purposeData : undefined;
7119
+ if (!pair)
7120
+ return;
7121
+ try {
7122
+ if (!await isAncestor(session.worktreePath, pair.mergeFrom, pair.mergeInto))
7123
+ return;
7124
+ emit(ctx, feature.id, {
7125
+ type: "merge.resolved",
7126
+ message: `merge conflict resolved \u2014 ${pair.mergeFrom} is in ${pair.mergeInto}`,
7127
+ data: { sessionId: session.id, ...pair }
7128
+ });
7129
+ } catch {}
7130
+ }
7131
+ async function handlePreToolUse(session, feature, payload) {
6848
7132
  const toolName = typeof payload?.tool_name === "string" ? payload.tool_name : undefined;
6849
7133
  const toolInput = payload?.tool_input ?? {};
6850
7134
  const path = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.notebook_path === "string" ? toolInput.notebook_path : undefined;
7135
+ const mergeInProgress2 = session.purpose === "resolve-conflict" ? await mergeInProgressAt(session.worktreePath) : false;
6851
7136
  const denial = evaluateEditGuard({
6852
7137
  kind: session.kind,
7138
+ purpose: session.purpose,
7139
+ mergeInProgress: mergeInProgress2,
6853
7140
  worktreePath: session.worktreePath,
6854
7141
  toolName,
6855
7142
  filePath: path,
@@ -6878,6 +7165,7 @@ var init_hooks = __esm(() => {
6878
7165
  init_edit_guard();
6879
7166
  init_sessions();
6880
7167
  init_events();
7168
+ init_git();
6881
7169
  init_prep();
6882
7170
  init_repo();
6883
7171
  init_tickets();
@@ -6915,7 +7203,7 @@ var init_hooks = __esm(() => {
6915
7203
  case "session-end":
6916
7204
  return c.json(handleProjectScopedSessionEnd(ctx, session));
6917
7205
  case "pre-tool":
6918
- return c.json(handlePreToolUse(session, undefined, body.payload));
7206
+ return c.json(await handlePreToolUse(session, undefined, body.payload));
6919
7207
  default:
6920
7208
  return c.json({});
6921
7209
  }
@@ -6929,9 +7217,9 @@ var init_hooks = __esm(() => {
6929
7217
  case "user-prompt":
6930
7218
  return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
6931
7219
  case "session-end":
6932
- return c.json(handleSessionEnd(ctx, sessionId, feature));
7220
+ return c.json(await handleSessionEnd(ctx, session, feature));
6933
7221
  case "pre-tool":
6934
- return c.json(handlePreToolUse(session, feature, body.payload));
7222
+ return c.json(await handlePreToolUse(session, feature, body.payload));
6935
7223
  default:
6936
7224
  return c.json({});
6937
7225
  }
@@ -7073,6 +7361,188 @@ var init_web = __esm(() => {
7073
7361
  };
7074
7362
  });
7075
7363
 
7364
+ // src/version.ts
7365
+ import { readFileSync as readFileSync6 } from "fs";
7366
+ import { dirname as dirname5, join as join12 } from "path";
7367
+ import { fileURLToPath as fileURLToPath4 } from "url";
7368
+ function runcastleVersion() {
7369
+ if (cached2)
7370
+ return cached2;
7371
+ const here = dirname5(fileURLToPath4(import.meta.url));
7372
+ for (const pkgPath of [join12(here, "..", "package.json"), join12(here, "package.json")]) {
7373
+ try {
7374
+ const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
7375
+ if (typeof pkg.version === "string") {
7376
+ cached2 = pkg.version;
7377
+ return cached2;
7378
+ }
7379
+ } catch {}
7380
+ }
7381
+ cached2 = UNKNOWN_VERSION;
7382
+ return cached2;
7383
+ }
7384
+ var cached2, UNKNOWN_VERSION = "0.0.0";
7385
+ var init_version = () => {};
7386
+
7387
+ // src/services/install-id.ts
7388
+ import { randomUUID } from "crypto";
7389
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
7390
+ import { dirname as dirname6 } from "path";
7391
+ function getInstallId() {
7392
+ const file = installIdPath();
7393
+ try {
7394
+ if (existsSync13(file)) {
7395
+ const existing = readFileSync7(file, "utf8").trim();
7396
+ if (UUID_RE.test(existing))
7397
+ return existing;
7398
+ }
7399
+ const id = randomUUID();
7400
+ mkdirSync6(dirname6(file), { recursive: true });
7401
+ writeFileSync2(file, `${id}
7402
+ `);
7403
+ return id;
7404
+ } catch {
7405
+ return randomUUID();
7406
+ }
7407
+ }
7408
+ var UUID_RE;
7409
+ var init_install_id = __esm(() => {
7410
+ init_paths();
7411
+ UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7412
+ });
7413
+
7414
+ // src/services/update-check.ts
7415
+ function parse(version) {
7416
+ const cleaned = version.trim().replace(/^v/, "");
7417
+ const [core = "", pre = ""] = cleaned.split("-", 2);
7418
+ const release2 = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
7419
+ return { release: release2, pre: pre ? pre.split(".") : [] };
7420
+ }
7421
+ function compareSemver(a, b) {
7422
+ const pa = parse(a);
7423
+ const pb = parse(b);
7424
+ const len = Math.max(pa.release.length, pb.release.length);
7425
+ for (let i = 0;i < len; i++) {
7426
+ const diff = (pa.release[i] ?? 0) - (pb.release[i] ?? 0);
7427
+ if (diff !== 0)
7428
+ return diff;
7429
+ }
7430
+ if (pa.pre.length === 0 && pb.pre.length === 0)
7431
+ return 0;
7432
+ if (pa.pre.length === 0)
7433
+ return 1;
7434
+ if (pb.pre.length === 0)
7435
+ return -1;
7436
+ const preLen = Math.max(pa.pre.length, pb.pre.length);
7437
+ for (let i = 0;i < preLen; i++) {
7438
+ const x = pa.pre[i];
7439
+ const y = pb.pre[i];
7440
+ if (x === undefined)
7441
+ return -1;
7442
+ if (y === undefined)
7443
+ return 1;
7444
+ const nx = Number(x);
7445
+ const ny = Number(y);
7446
+ const bothNumeric = !Number.isNaN(nx) && !Number.isNaN(ny);
7447
+ const cmp = bothNumeric ? nx - ny : x < y ? -1 : x > y ? 1 : 0;
7448
+ if (cmp !== 0)
7449
+ return cmp;
7450
+ }
7451
+ return 0;
7452
+ }
7453
+ function doNotTrack(env) {
7454
+ const raw = env.DO_NOT_TRACK;
7455
+ return raw !== undefined && raw !== "" && raw !== "0";
7456
+ }
7457
+ async function latestFromNpm(fetchImpl) {
7458
+ try {
7459
+ const res = await fetchImpl(NPM_LATEST_URL);
7460
+ if (!res.ok)
7461
+ return null;
7462
+ const body = await res.json();
7463
+ return typeof body.version === "string" ? body.version : null;
7464
+ } catch {
7465
+ return null;
7466
+ }
7467
+ }
7468
+ async function latestFromPing(fetchImpl, current2) {
7469
+ try {
7470
+ const res = await fetchImpl(PING_URL, {
7471
+ method: "POST",
7472
+ headers: { "content-type": "application/json" },
7473
+ body: JSON.stringify({
7474
+ installId: getInstallId(),
7475
+ version: current2,
7476
+ platform: process.platform
7477
+ })
7478
+ });
7479
+ if (!res.ok)
7480
+ return null;
7481
+ const body = await res.json();
7482
+ return typeof body.latest === "string" ? body.latest : null;
7483
+ } catch {
7484
+ return null;
7485
+ }
7486
+ }
7487
+ async function checkForUpdate(opts) {
7488
+ const { current: current2 } = opts;
7489
+ const fetchImpl = opts.fetchImpl ?? fetch;
7490
+ const env = opts.env ?? process.env;
7491
+ if (current2 === UNKNOWN_VERSION)
7492
+ return NO_UPDATE(current2);
7493
+ const latest = doNotTrack(env) ? await latestFromNpm(fetchImpl) : await latestFromPing(fetchImpl, current2) ?? await latestFromNpm(fetchImpl);
7494
+ if (!latest)
7495
+ return NO_UPDATE(current2);
7496
+ return {
7497
+ current: current2,
7498
+ latest,
7499
+ updateAvailable: compareSemver(latest, current2) > 0,
7500
+ command: UPDATE_COMMAND
7501
+ };
7502
+ }
7503
+ async function getUpdateInfo(current2, fetchImpl) {
7504
+ if (cache)
7505
+ return cache;
7506
+ const info = await checkForUpdate({ current: current2, fetchImpl });
7507
+ if (info.latest !== null)
7508
+ cache = info;
7509
+ return info;
7510
+ }
7511
+ var PACKAGE_NAME = "runcastle", UPDATE_COMMAND, PING_URL = "https://ping.runcastle.dev/ping", NPM_LATEST_URL, NO_UPDATE = (current2) => ({
7512
+ current: current2,
7513
+ latest: null,
7514
+ updateAvailable: false,
7515
+ command: UPDATE_COMMAND
7516
+ }), cache;
7517
+ var init_update_check = __esm(() => {
7518
+ init_version();
7519
+ init_install_id();
7520
+ UPDATE_COMMAND = `bun add -g ${PACKAGE_NAME}@latest`;
7521
+ NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
7522
+ });
7523
+
7524
+ // src/shutdown.ts
7525
+ function createShutdown(deps, timeoutMs = SHUTDOWN_TIMEOUT_MS) {
7526
+ let settling = false;
7527
+ return () => {
7528
+ if (settling) {
7529
+ deps.exit(1);
7530
+ return;
7531
+ }
7532
+ settling = true;
7533
+ (async () => {
7534
+ const deadline = new Promise((resolve9) => {
7535
+ const timer = setTimeout(resolve9, timeoutMs);
7536
+ timer.unref?.();
7537
+ });
7538
+ await Promise.race([deps.killAllTrees().catch(() => {}), deadline]);
7539
+ deps.stop();
7540
+ deps.exit(0);
7541
+ })();
7542
+ };
7543
+ }
7544
+ var SHUTDOWN_TIMEOUT_MS = 5000;
7545
+
7076
7546
  // src/trpc/context.ts
7077
7547
  import { initTRPC } from "@trpc/server";
7078
7548
  var t, errorMapping, router, publicProcedure, createCallerFactory;
@@ -7121,11 +7591,11 @@ var init_events2 = __esm(() => {
7121
7591
  });
7122
7592
 
7123
7593
  // src/launcher/artifacts.ts
7124
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync2 } from "fs";
7125
- import { fileURLToPath as fileURLToPath4 } from "url";
7126
- import { join as join12 } from "path";
7594
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync3 } from "fs";
7595
+ import { fileURLToPath as fileURLToPath5 } from "url";
7596
+ import { join as join13 } from "path";
7127
7597
  function hookClientPath() {
7128
- return resolveAsset(ASSET_ENV.hookClient, fileURLToPath4(new URL("./hook-client.ts", import.meta.url)));
7598
+ return resolveAsset(ASSET_ENV.hookClient, fileURLToPath5(new URL("./hook-client.ts", import.meta.url)));
7129
7599
  }
7130
7600
  function serverUrlFor(config2) {
7131
7601
  return `http://localhost:${config2.serverPort}`;
@@ -7133,13 +7603,16 @@ function serverUrlFor(config2) {
7133
7603
  function noCodeRule(docs) {
7134
7604
  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.";
7135
7605
  }
7136
- function renderSystemPrompt(feature, kind, waypoint, lap) {
7606
+ function conflictResolutionRule() {
7607
+ return "- **This session resolves a merge conflict, so it DOES write code here.** Edit the " + "conflicted files in this checkout, resolving them from the feature docs\u2019 intent, and " + "commit the merge. That is the exception and its whole extent: the edit guard exempts " + "writes only while the merge is in progress, so work the merge revealed but did not " + "cause still rides a ticket.";
7608
+ }
7609
+ function renderSystemPrompt(feature, kind, waypoint, lap, purpose) {
7137
7610
  if (kind === "waypoint")
7138
7611
  return renderWaypointPrompt(feature, waypoint);
7139
7612
  if (kind === "converge")
7140
7613
  return renderConvergePrompt(feature);
7141
7614
  if (kind === "revisit")
7142
- return renderRevisitPrompt(feature, lap);
7615
+ return renderRevisitPrompt(feature, lap, purpose);
7143
7616
  const docs = featureDocsRel(feature.slug);
7144
7617
  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.";
7145
7618
  return [
@@ -7268,7 +7741,7 @@ function renderConvergePrompt(feature) {
7268
7741
  ].join(`
7269
7742
  `);
7270
7743
  }
7271
- function renderRevisitPrompt(feature, lap) {
7744
+ function renderRevisitPrompt(feature, lap, purpose) {
7272
7745
  const docs = featureDocsRel(feature.slug);
7273
7746
  const lapIteration = lap ? [
7274
7747
  `## This is lap ${lap}`,
@@ -7338,7 +7811,7 @@ function renderRevisitPrompt(feature, lap) {
7338
7811
  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.",
7339
7812
  "- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
7340
7813
  "- Docs first, tickets second: capture the decision prose before any ticket surgery.",
7341
- noCodeRule(docs),
7814
+ purpose === "resolve-conflict" ? conflictResolutionRule() : noCodeRule(docs),
7342
7815
  "",
7343
7816
  "## Your task",
7344
7817
  "Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
@@ -7496,7 +7969,8 @@ function renderProjectPrompt(brief) {
7496
7969
  "- `get_project_context()` \u2014 the project, its charter, its live ADRs, and a one-line",
7497
7970
  " index of every feature.",
7498
7971
  "- `get_work_record({ featureSlug? | seam? })` \u2014 what features actually did: tickets by",
7499
- " status, seams, commits, errors. Facts, never intent.",
7972
+ " status, seams, commits, errors, and each burner's digest of what it actually did,",
7973
+ " what surprised it and what it left undone. Facts, never intent.",
7500
7974
  "- `record_event({ type, message })` \u2014 drop a note on the project timeline.",
7501
7975
  "",
7502
7976
  "Every merged feature's docs are already on disk in this worktree \u2014 read them with your",
@@ -7545,18 +8019,18 @@ function renderMcpConfig(session, config2) {
7545
8019
  };
7546
8020
  }
7547
8021
  async function writeSessionArtifacts(input) {
7548
- const { session, feature, config: config2, waypoint, prepare, projectBrief, lap } = input;
8022
+ const { session, feature, config: config2, waypoint, prepare, projectBrief, lap, purpose } = input;
7549
8023
  const dir = sessionDir(session.id);
7550
- mkdirSync6(dir, { recursive: true });
7551
- const systemPromptPath = join12(dir, "system-prompt.md");
7552
- const settingsPath = join12(dir, "settings.json");
7553
- const mcpConfigPath = join12(dir, "mcp.json");
7554
- const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
8024
+ mkdirSync7(dir, { recursive: true });
8025
+ const systemPromptPath = join13(dir, "system-prompt.md");
8026
+ const settingsPath = join13(dir, "settings.json");
8027
+ const mcpConfigPath = join13(dir, "mcp.json");
8028
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap, purpose) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
7555
8029
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
7556
8030
  })();
7557
- writeFileSync2(systemPromptPath, systemPrompt, "utf8");
7558
- writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
7559
- writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
8031
+ writeFileSync3(systemPromptPath, systemPrompt, "utf8");
8032
+ writeFileSync3(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
8033
+ writeFileSync3(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
7560
8034
  return { systemPromptPath, settingsPath, mcpConfigPath };
7561
8035
  }
7562
8036
  var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_READ_RULES, SESSION_BASH_WRITE_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
@@ -7600,9 +8074,9 @@ var init_artifacts = __esm(() => {
7600
8074
  });
7601
8075
 
7602
8076
  // src/launcher/launcher.ts
7603
- import { existsSync as existsSync13 } from "fs";
7604
- import { dirname as dirname5, join as join13, resolve as resolve9 } from "path";
7605
- import { fileURLToPath as fileURLToPath5 } from "url";
8077
+ import { existsSync as existsSync14 } from "fs";
8078
+ import { dirname as dirname7, join as join14, resolve as resolve9 } from "path";
8079
+ import { fileURLToPath as fileURLToPath6 } from "url";
7606
8080
  import { and as and9, eq as eq11 } from "drizzle-orm";
7607
8081
  function ptyExitMessage(exitCode) {
7608
8082
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
@@ -7634,23 +8108,23 @@ function resolveClaudeExecutable() {
7634
8108
  function claudeSpawnTarget(claudeArgs) {
7635
8109
  return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
7636
8110
  }
7637
- function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
7638
- const rel = join13("packages", "skills", "packs", "runcastle");
8111
+ function resolvePluginDir(fromDir = dirname7(fileURLToPath6(import.meta.url))) {
8112
+ const rel = join14("packages", "skills", "packs", "runcastle");
7639
8113
  const override = process.env[SKILLS_DIR_ENV];
7640
8114
  if (override) {
7641
- const dir2 = join13(resolve9(override), "packs", "runcastle");
7642
- if (existsSync13(dir2))
8115
+ const dir2 = join14(resolve9(override), "packs", "runcastle");
8116
+ if (existsSync14(dir2))
7643
8117
  return dir2;
7644
8118
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
7645
8119
  }
7646
8120
  const searched = [];
7647
8121
  let dir = fromDir;
7648
8122
  for (let i = 0;i < 8; i += 1) {
7649
- const candidate = join13(dir, rel);
8123
+ const candidate = join14(dir, rel);
7650
8124
  searched.push(candidate);
7651
- if (existsSync13(candidate))
8125
+ if (existsSync14(candidate))
7652
8126
  return candidate;
7653
- const parent = dirname5(dir);
8127
+ const parent = dirname7(dir);
7654
8128
  if (parent === dir)
7655
8129
  break;
7656
8130
  dir = parent;
@@ -7674,8 +8148,10 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
7674
8148
  }
7675
8149
  }
7676
8150
  function sessionFinished(ctx, feature, session) {
7677
- if (session.kind !== "waypoint")
8151
+ if (session.kind === "ideation" || session.kind === "converge")
7678
8152
  return feature.mapped;
8153
+ if (session.kind !== "waypoint")
8154
+ return false;
7679
8155
  const own = listByFeature(ctx, feature.id).find((w) => w.lastSessionId === session.id);
7680
8156
  return !!own && (own.status === "resolved" || own.status === "dropped");
7681
8157
  }
@@ -7714,11 +8190,14 @@ async function ensureWorktree(ctx, project, feature) {
7714
8190
  }
7715
8191
  async function launchSession(ctx, input, opts = {}) {
7716
8192
  const feature = getFeatureRow(ctx, input.featureId);
8193
+ requireNotDraft(feature);
7717
8194
  const project = projectForFeature(ctx, feature);
7718
8195
  const worktreePath = await ensureWorktree(ctx, project, feature);
7719
8196
  const session = createSessionRow(ctx, {
7720
8197
  featureId: feature.id,
7721
8198
  kind: input.kind,
8199
+ purpose: input.purpose,
8200
+ purposeData: input.purposeData,
7722
8201
  worktreePath
7723
8202
  });
7724
8203
  const plan = planKickoff({ kind: input.kind, lap: feature.lap, kickoffLine: input.kickoffLine });
@@ -7794,7 +8273,8 @@ async function launchSession(ctx, input, opts = {}) {
7794
8273
  project,
7795
8274
  config: ctx.config,
7796
8275
  waypoint,
7797
- lap: plan.lap
8276
+ lap: plan.lap,
8277
+ purpose: input.purpose
7798
8278
  });
7799
8279
  const serverUrl = serverUrlFor(ctx.config);
7800
8280
  const buildInput = {
@@ -7894,7 +8374,7 @@ async function launchProjectSession(ctx, input, opts = {}) {
7894
8374
  if (live) {
7895
8375
  throw new GateError(`a project session is already open for ${project.name} \u2014 resume or end it first`);
7896
8376
  }
7897
- const worktreePath = await ensureProjectWorktree(project);
8377
+ const worktreePath = await ensureProjectWorktree(project, (res) => reportProjectLanding(ctx, project, res, { retried: true }));
7898
8378
  const session = createSessionRow(ctx, {
7899
8379
  projectId: project.id,
7900
8380
  kind: "project",
@@ -7965,6 +8445,7 @@ async function buildPrepareBrief(ctx, project) {
7965
8445
  }
7966
8446
  async function workWaypoint(ctx, input, opts = {}) {
7967
8447
  const feature = getFeatureRow(ctx, input.featureId);
8448
+ requireNotDraft(feature);
7968
8449
  if (!feature.mapped) {
7969
8450
  throw new GateError(`feature ${feature.slug} is not mapped \u2014 it has no waypoints to work`);
7970
8451
  }
@@ -7985,6 +8466,7 @@ async function workWaypoint(ctx, input, opts = {}) {
7985
8466
  }
7986
8467
  async function converge(ctx, input, opts = {}) {
7987
8468
  const feature = getFeatureRow(ctx, input.featureId);
8469
+ requireNotDraft(feature);
7988
8470
  if (!feature.mapped) {
7989
8471
  throw new GateError(`feature ${feature.slug} is not mapped \u2014 convergence is only for mapped features`);
7990
8472
  }
@@ -8083,6 +8565,8 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
8083
8565
  opts: { cwd: worktreePath, env, cols: 80, rows: 24, useConpty: true },
8084
8566
  onExit: ({ exitCode }) => handlePtyExit(ctx, feature, session, meta, exitCode)
8085
8567
  });
8568
+ if (feature)
8569
+ startDocsWatch(ctx, feature);
8086
8570
  emitForSession(ctx, session, {
8087
8571
  type: "session.launched",
8088
8572
  message: "embedded terminal spawned",
@@ -8109,10 +8593,12 @@ var init_launcher = __esm(() => {
8109
8593
  init_errors();
8110
8594
  init_end_session();
8111
8595
  init_registry();
8596
+ init_docs_watch();
8112
8597
  init_events();
8113
8598
  init_gates();
8114
8599
  init_git();
8115
8600
  init_repo();
8601
+ init_features();
8116
8602
  init_tickets();
8117
8603
  init_waypoints();
8118
8604
  init_runner();
@@ -8134,6 +8620,74 @@ var init_launcher = __esm(() => {
8134
8620
  ];
8135
8621
  });
8136
8622
 
8623
+ // src/services/outcome.ts
8624
+ import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync4 } from "fs";
8625
+ import { join as join15 } from "path";
8626
+ function composeOutcomeDoc(feature, tickets2, shippedAt) {
8627
+ const blocks = [
8628
+ `# Outcome \u2014 ${feature.title}`,
8629
+ feature.oneLiner,
8630
+ [`- Shipped: ${shippedDate(shippedAt)}`, `- Lap: ${feature.lap}`].join(`
8631
+ `)
8632
+ ];
8633
+ let oneLiners = [];
8634
+ const flush = () => {
8635
+ if (oneLiners.length > 0)
8636
+ blocks.push(oneLiners.join(`
8637
+ `));
8638
+ oneLiners = [];
8639
+ };
8640
+ for (const ticket of [...tickets2].sort((a, b) => a.seq - b.seq)) {
8641
+ if (ticket.status === "done") {
8642
+ flush();
8643
+ blocks.push(`## ${ticket.seq}. ${ticket.title}`, ticket.digest?.trim() || NO_DIGEST);
8644
+ } else {
8645
+ oneLiners.push(oneLineEntry(ticket));
8646
+ }
8647
+ }
8648
+ flush();
8649
+ return `${blocks.join(`
8650
+
8651
+ `)}
8652
+ `;
8653
+ }
8654
+ function shippedDate(shippedAt) {
8655
+ return new Date(shippedAt).toISOString().slice(0, 10);
8656
+ }
8657
+ function oneLineEntry(ticket) {
8658
+ const headline = errorHeadline3(ticket.error ?? "");
8659
+ return `- **${ticket.seq}. ${ticket.title}** \u2014 ${ticket.status}${headline ? `: ${headline}` : ""}`;
8660
+ }
8661
+ function errorHeadline3(error) {
8662
+ const lines = error.split(`
8663
+ `).map((l) => l.trim()).filter(Boolean);
8664
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
8665
+ const headline = causes.at(-1) ?? lines[0] ?? "";
8666
+ return headline.length > HEADLINE_MAX ? `${headline.slice(0, HEADLINE_MAX)}\u2026` : headline;
8667
+ }
8668
+ async function promoteOutcomeDoc(ctx, project, feature) {
8669
+ try {
8670
+ const worktreePath = await ensureTalkWorktree(project, feature);
8671
+ const docsDir = join15(worktreePath, ...featureDocsRel(feature.slug).split("/"));
8672
+ mkdirSync8(docsDir, { recursive: true });
8673
+ const doc = composeOutcomeDoc(feature, listByFeature2(ctx, feature.id), Date.now());
8674
+ writeFileSync4(join15(docsDir, "outcome.md"), doc, "utf8");
8675
+ await commitDocs(worktreePath, `runcastle: outcome for ${feature.slug}`);
8676
+ } catch (e) {
8677
+ emit(ctx, feature.id, {
8678
+ type: "docs.outcome_failed",
8679
+ message: `outcome.md not promoted: ${e instanceof Error ? e.message : String(e)}`
8680
+ });
8681
+ }
8682
+ }
8683
+ var NO_DIGEST = "_no digest captured_", HEADLINE_MAX = 160;
8684
+ var init_outcome = __esm(() => {
8685
+ init_paths();
8686
+ init_events();
8687
+ init_git();
8688
+ init_tickets();
8689
+ });
8690
+
8137
8691
  // src/trpc/routers/feature.ts
8138
8692
  import * as z6 from "zod";
8139
8693
  var gateId, featureRouter;
@@ -8145,6 +8699,7 @@ var init_feature = __esm(() => {
8145
8699
  init_features();
8146
8700
  init_gates();
8147
8701
  init_git();
8702
+ init_outcome();
8148
8703
  init_repo();
8149
8704
  init_context();
8150
8705
  gateId = z6.enum(["G1", "G2", "G3", "G4", "G5"]);
@@ -8153,8 +8708,10 @@ var init_feature = __esm(() => {
8153
8708
  projectId: z6.string(),
8154
8709
  title: z6.string().min(1),
8155
8710
  oneLiner: z6.string(),
8156
- baseBranch: z6.string().optional()
8711
+ baseBranch: z6.string().optional(),
8712
+ draft: z6.boolean().optional()
8157
8713
  })).mutation(({ ctx, input }) => createFeature(ctx, input)),
8714
+ start: publicProcedure.input(z6.object({ featureId: z6.string(), baseBranch: z6.string().optional() })).mutation(({ ctx, input }) => startDraft(ctx, input.featureId, { baseBranch: input.baseBranch })),
8158
8715
  quickChange: publicProcedure.input(z6.object({
8159
8716
  projectId: z6.string(),
8160
8717
  title: z6.string().min(1),
@@ -8166,7 +8723,9 @@ var init_feature = __esm(() => {
8166
8723
  launchSession: publicProcedure.input(z6.object({
8167
8724
  featureId: z6.string(),
8168
8725
  kind: SessionKind,
8169
- kickoffLine: z6.string().min(1).optional()
8726
+ kickoffLine: z6.string().min(1).optional(),
8727
+ purpose: SessionPurpose.optional(),
8728
+ purposeData: MergeBranchPair.optional()
8170
8729
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
8171
8730
  workWaypoint: publicProcedure.input(z6.object({ featureId: z6.string(), waypointId: z6.string(), endLive: z6.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
8172
8731
  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 })),
@@ -8186,6 +8745,7 @@ var init_feature = __esm(() => {
8186
8745
  burn: publicProcedure.input(z6.object({ featureId: z6.string(), model: z6.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
8187
8746
  testDrive: publicProcedure.input(z6.object({ featureId: z6.string(), action: z6.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
8188
8747
  const feature = getFeatureRow(ctx, input.featureId);
8748
+ requireNotDraft(feature);
8189
8749
  const project = projectForFeature(ctx, feature);
8190
8750
  return testDrive(ctx, project, feature, input.action);
8191
8751
  }),
@@ -8196,10 +8756,12 @@ var init_feature = __esm(() => {
8196
8756
  }),
8197
8757
  merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
8198
8758
  const feature = getFeatureRow(ctx, input.featureId);
8759
+ requireNotDraft(feature);
8199
8760
  const project = projectForFeature(ctx, feature);
8200
8761
  if (activeTestDriveFeatureId() === feature.id) {
8201
8762
  await testDrive(ctx, project, feature, "stop");
8202
8763
  }
8764
+ await promoteOutcomeDoc(ctx, project, feature);
8203
8765
  const res = await mergeFeature(project, feature);
8204
8766
  if (res.ok) {
8205
8767
  setPhase(ctx, input.featureId, "shipped", "feature.shipped", `merged to ${res.target}`);
@@ -8217,9 +8779,9 @@ var init_feature = __esm(() => {
8217
8779
  });
8218
8780
 
8219
8781
  // src/services/fsbrowse.ts
8220
- import { existsSync as existsSync14, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
8782
+ import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
8221
8783
  import { homedir as homedir2 } from "os";
8222
- import { basename, dirname as dirname6, isAbsolute as isAbsolute2, join as join14, resolve as resolve10, sep as sep3 } from "path";
8784
+ import { basename, dirname as dirname8, isAbsolute as isAbsolute2, join as join16, resolve as resolve10, sep as sep3 } from "path";
8223
8785
  function expandPath(input, home = homedir2()) {
8224
8786
  const trimmed = input.trim();
8225
8787
  if (trimmed === "")
@@ -8228,12 +8790,12 @@ function expandPath(input, home = homedir2()) {
8228
8790
  if (out === "~")
8229
8791
  out = home;
8230
8792
  else if (out.startsWith("~/") || out.startsWith("~\\"))
8231
- out = join14(home, out.slice(2));
8793
+ out = join16(home, out.slice(2));
8232
8794
  return resolve10(out);
8233
8795
  }
8234
8796
  function looksLikeRepo(dir) {
8235
8797
  try {
8236
- return existsSync14(join14(dir, ".git"));
8798
+ return existsSync15(join16(dir, ".git"));
8237
8799
  } catch {
8238
8800
  return false;
8239
8801
  }
@@ -8244,20 +8806,20 @@ function listRoots(home = homedir2()) {
8244
8806
  for (let code = 67;code <= 90; code++) {
8245
8807
  const drive = `${String.fromCharCode(code)}:${sep3}`;
8246
8808
  try {
8247
- if (existsSync14(drive))
8809
+ if (existsSync15(drive))
8248
8810
  roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
8249
8811
  } catch {}
8250
8812
  }
8251
8813
  } else {
8252
8814
  roots.push({ label: "/", path: "/", kind: "drive" });
8253
8815
  }
8254
- if (existsSync14(home))
8816
+ if (existsSync15(home))
8255
8817
  roots.push({ label: "Home", path: home, kind: "home" });
8256
8818
  const seen = new Set(roots.map(pathKey));
8257
8819
  for (const name of COMMON_DIRS) {
8258
- const candidate = join14(home, name);
8820
+ const candidate = join16(home, name);
8259
8821
  const key = pathKey({ path: candidate });
8260
- if (seen.has(key) || !existsSync14(candidate))
8822
+ if (seen.has(key) || !existsSync15(candidate))
8261
8823
  continue;
8262
8824
  seen.add(key);
8263
8825
  roots.push({ label: name, path: candidate, kind: "common" });
@@ -8272,7 +8834,7 @@ function crumbsFor(dir) {
8272
8834
  let cur = dir;
8273
8835
  for (;; ) {
8274
8836
  out.unshift({ name: basename(cur) || cur, path: cur });
8275
- const parent = dirname6(cur);
8837
+ const parent = dirname8(cur);
8276
8838
  if (parent === cur)
8277
8839
  break;
8278
8840
  cur = parent;
@@ -8280,7 +8842,7 @@ function crumbsFor(dir) {
8280
8842
  return out;
8281
8843
  }
8282
8844
  function parentOf(dir) {
8283
- const parent = dirname6(dir);
8845
+ const parent = dirname8(dir);
8284
8846
  return parent === dir ? null : parent;
8285
8847
  }
8286
8848
  function browseDir(input, showHidden = false, home = homedir2()) {
@@ -8288,13 +8850,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
8288
8850
  if (!isAbsolute2(target)) {
8289
8851
  throw new InvalidInputError(`path is not absolute: ${target}`);
8290
8852
  }
8291
- if (!existsSync14(target)) {
8853
+ if (!existsSync15(target)) {
8292
8854
  throw new InvalidInputError(`path does not exist: ${target}`);
8293
8855
  }
8294
8856
  let dir = target;
8295
8857
  try {
8296
8858
  if (!statSync2(target).isDirectory())
8297
- dir = dirname6(target);
8859
+ dir = dirname8(target);
8298
8860
  } catch (e) {
8299
8861
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg4(e)})`);
8300
8862
  }
@@ -8316,7 +8878,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
8316
8878
  continue;
8317
8879
  if (!showHidden && name.startsWith("."))
8318
8880
  continue;
8319
- const path = join14(dir, name);
8881
+ const path = join16(dir, name);
8320
8882
  const isSymlink = dirent.isSymbolicLink();
8321
8883
  if (isSymlink && !showHidden)
8322
8884
  continue;
@@ -8353,8 +8915,8 @@ var init_fsbrowse = __esm(() => {
8353
8915
  });
8354
8916
 
8355
8917
  // src/services/projects.ts
8356
- import { existsSync as existsSync15 } from "fs";
8357
- import { basename as basename2, join as join15 } from "path";
8918
+ import { existsSync as existsSync16 } from "fs";
8919
+ import { basename as basename2, join as join17 } from "path";
8358
8920
  import { eq as eq12, isNull } from "drizzle-orm";
8359
8921
  function listProjects(ctx) {
8360
8922
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
@@ -8378,7 +8940,7 @@ async function openProject(ctx, rawPath) {
8378
8940
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
8379
8941
  if (existing) {
8380
8942
  ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq12(projects.id, existing.id)).run();
8381
- const reopened = { ...existing, mainBranch, repoPath };
8943
+ const reopened = { ...existing, mainBranch, repoPath, closedAt: undefined };
8382
8944
  emitProject(ctx, existing.id, {
8383
8945
  type: "project.opened",
8384
8946
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
@@ -8435,7 +8997,7 @@ async function assertRepoTolerant(ctx, repoPath) {
8435
8997
  } catch (e) {
8436
8998
  if (!isNotImplemented(e))
8437
8999
  throw e;
8438
- if (!existsSync15(join15(repoPath, ".git"))) {
9000
+ if (!existsSync16(join17(repoPath, ".git"))) {
8439
9001
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
8440
9002
  }
8441
9003
  }
@@ -8500,7 +9062,8 @@ var init_run = __esm(() => {
8500
9062
  runRouter = router({
8501
9063
  get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
8502
9064
  agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
8503
- cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
9065
+ cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ ctx, input }) => {
9066
+ getRunRow(ctx, input.runId);
8504
9067
  cancelRun(input.runId);
8505
9068
  return { ok: true };
8506
9069
  })
@@ -8508,15 +9071,15 @@ var init_run = __esm(() => {
8508
9071
  });
8509
9072
 
8510
9073
  // src/services/settings.ts
8511
- import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
8512
- import { dirname as dirname7 } from "path";
9074
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
9075
+ import { dirname as dirname9 } from "path";
8513
9076
  import * as z9 from "zod";
8514
9077
  import { eq as eq13 } from "drizzle-orm";
8515
9078
  function readRawConfig(configFile) {
8516
- if (!existsSync16(configFile))
9079
+ if (!existsSync17(configFile))
8517
9080
  return {};
8518
9081
  try {
8519
- const parsed = JSON.parse(readFileSync6(configFile, "utf8"));
9082
+ const parsed = JSON.parse(readFileSync8(configFile, "utf8"));
8520
9083
  if (typeof parsed !== "object" || parsed === null)
8521
9084
  return {};
8522
9085
  return foldLegacyModelConfig(parsed);
@@ -8655,8 +9218,8 @@ function updateSettings(ctx, input, io = {}) {
8655
9218
  function writeGlobal(configFile, configKey, value) {
8656
9219
  const raw = readRawConfig(configFile);
8657
9220
  raw[configKey] = value;
8658
- mkdirSync7(dirname7(configFile), { recursive: true });
8659
- writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
9221
+ mkdirSync9(dirname9(configFile), { recursive: true });
9222
+ writeFileSync5(configFile, `${JSON.stringify(raw, null, 2)}
8660
9223
  `);
8661
9224
  }
8662
9225
  function updateStepModel(ctx, input, configFile, io) {
@@ -8694,8 +9257,8 @@ function writeStepModel(configFile, step, value) {
8694
9257
  else
8695
9258
  stepModels[step] = value;
8696
9259
  raw.stepModels = stepModels;
8697
- mkdirSync7(dirname7(configFile), { recursive: true });
8698
- writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
9260
+ mkdirSync9(dirname9(configFile), { recursive: true });
9261
+ writeFileSync5(configFile, `${JSON.stringify(raw, null, 2)}
8699
9262
  `);
8700
9263
  }
8701
9264
  function field(view, key) {
@@ -8932,11 +9495,11 @@ async function gitIdentityProbe(exec, cwd) {
8932
9495
  async function containerRuntimeProbe(exec) {
8933
9496
  const id = "container-runtime";
8934
9497
  const label = "Container runtime (Docker / Podman)";
8935
- const docker3 = await exec("docker", ["--version"]);
8936
- if (docker3.ok && docker3.code === 0) {
9498
+ const docker2 = await exec("docker", ["--version"]);
9499
+ if (docker2.ok && docker2.code === 0) {
8937
9500
  const info = await exec("docker", ["info"]);
8938
9501
  if (info.ok && info.code === 0) {
8939
- return { id, label, tier: 2, status: "ok", detail: docker3.stdout.trim() || "docker healthy" };
9502
+ return { id, label, tier: 2, status: "ok", detail: docker2.stdout.trim() || "docker healthy" };
8940
9503
  }
8941
9504
  return {
8942
9505
  id,
@@ -9072,9 +9635,9 @@ var init_system_exec = __esm(() => {
9072
9635
  });
9073
9636
 
9074
9637
  // src/services/setup.ts
9075
- import { copyFileSync, existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
9076
- import { dirname as dirname8, join as join16 } from "path";
9077
- import { fileURLToPath as fileURLToPath6 } from "url";
9638
+ import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync10, readFileSync as readFileSync9, readdirSync as readdirSync6, writeFileSync as writeFileSync6 } from "fs";
9639
+ import { dirname as dirname10, join as join18 } from "path";
9640
+ import { fileURLToPath as fileURLToPath7 } from "url";
9078
9641
  async function writeGitIdentity(exec, identity) {
9079
9642
  const name = identity.name.trim();
9080
9643
  const email = identity.email.trim();
@@ -9149,16 +9712,16 @@ async function resolveRuntime(exec, preferred) {
9149
9712
  function resolveSandcastleBin() {
9150
9713
  try {
9151
9714
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
9152
- let dir = dirname8(fileURLToPath6(mainUrl));
9715
+ let dir = dirname10(fileURLToPath7(mainUrl));
9153
9716
  for (let hops = 0;hops < 6; hops++) {
9154
- const manifestPath = join16(dir, "package.json");
9155
- if (existsSync17(manifestPath)) {
9156
- const manifest = JSON.parse(readFileSync7(manifestPath, "utf8"));
9717
+ const manifestPath = join18(dir, "package.json");
9718
+ if (existsSync18(manifestPath)) {
9719
+ const manifest = JSON.parse(readFileSync9(manifestPath, "utf8"));
9157
9720
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
9158
- return join16(dir, manifest.bin.sandcastle);
9721
+ return join18(dir, manifest.bin.sandcastle);
9159
9722
  }
9160
9723
  }
9161
- const parent = dirname8(dir);
9724
+ const parent = dirname10(dir);
9162
9725
  if (parent === dir)
9163
9726
  break;
9164
9727
  dir = parent;
@@ -9180,21 +9743,21 @@ function terminalSpec(kind, opts) {
9180
9743
  };
9181
9744
  }
9182
9745
  function sandcastleTemplateDir() {
9183
- return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath6(new URL("../assets/sandcastle", import.meta.url)));
9746
+ return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
9184
9747
  }
9185
9748
  function scaffoldSandcastleConfig(templateDir, targetDir) {
9186
- const dir = join16(targetDir, ".sandcastle");
9187
- if (existsSync17(dir))
9749
+ const dir = join18(targetDir, ".sandcastle");
9750
+ if (existsSync18(dir))
9188
9751
  return { scaffolded: false, dir };
9189
- mkdirSync8(dir, { recursive: true });
9752
+ mkdirSync10(dir, { recursive: true });
9190
9753
  for (const name of readdirSync6(templateDir)) {
9191
- copyFileSync(join16(templateDir, name), join16(dir, name));
9754
+ copyFileSync(join18(templateDir, name), join18(dir, name));
9192
9755
  }
9193
9756
  return { scaffolded: true, dir };
9194
9757
  }
9195
9758
  function prepareSandboxBuildContext() {
9196
9759
  const target = sandboxBuildDir();
9197
- mkdirSync8(target, { recursive: true });
9760
+ mkdirSync10(target, { recursive: true });
9198
9761
  scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
9199
9762
  return target;
9200
9763
  }
@@ -9230,10 +9793,10 @@ function createTokenVerifier(exec) {
9230
9793
  function fileAfkTokenIo(exec) {
9231
9794
  const path = envPath();
9232
9795
  return {
9233
- read: () => existsSync17(path) ? readFileSync7(path, "utf8") : "",
9796
+ read: () => existsSync18(path) ? readFileSync9(path, "utf8") : "",
9234
9797
  write: (content) => {
9235
- mkdirSync8(dirname8(path), { recursive: true });
9236
- writeFileSync4(path, content, "utf8");
9798
+ mkdirSync10(dirname10(path), { recursive: true });
9799
+ writeFileSync6(path, content, "utf8");
9237
9800
  },
9238
9801
  verify: createTokenVerifier(exec)
9239
9802
  };
@@ -9286,110 +9849,6 @@ var init_setup2 = __esm(() => {
9286
9849
  });
9287
9850
  });
9288
9851
 
9289
- // src/version.ts
9290
- import { readFileSync as readFileSync8 } from "fs";
9291
- import { dirname as dirname9, join as join17 } from "path";
9292
- import { fileURLToPath as fileURLToPath7 } from "url";
9293
- function runcastleVersion() {
9294
- if (cached2)
9295
- return cached2;
9296
- const here = dirname9(fileURLToPath7(import.meta.url));
9297
- for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
9298
- try {
9299
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
9300
- if (typeof pkg.version === "string") {
9301
- cached2 = pkg.version;
9302
- return cached2;
9303
- }
9304
- } catch {}
9305
- }
9306
- cached2 = UNKNOWN_VERSION;
9307
- return cached2;
9308
- }
9309
- var cached2, UNKNOWN_VERSION = "0.0.0";
9310
- var init_version = () => {};
9311
-
9312
- // src/services/update-check.ts
9313
- function parse(version) {
9314
- const cleaned = version.trim().replace(/^v/, "");
9315
- const [core = "", pre = ""] = cleaned.split("-", 2);
9316
- const release2 = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
9317
- return { release: release2, pre: pre ? pre.split(".") : [] };
9318
- }
9319
- function compareSemver(a, b) {
9320
- const pa = parse(a);
9321
- const pb = parse(b);
9322
- const len = Math.max(pa.release.length, pb.release.length);
9323
- for (let i = 0;i < len; i++) {
9324
- const diff = (pa.release[i] ?? 0) - (pb.release[i] ?? 0);
9325
- if (diff !== 0)
9326
- return diff;
9327
- }
9328
- if (pa.pre.length === 0 && pb.pre.length === 0)
9329
- return 0;
9330
- if (pa.pre.length === 0)
9331
- return 1;
9332
- if (pb.pre.length === 0)
9333
- return -1;
9334
- const preLen = Math.max(pa.pre.length, pb.pre.length);
9335
- for (let i = 0;i < preLen; i++) {
9336
- const x = pa.pre[i];
9337
- const y = pb.pre[i];
9338
- if (x === undefined)
9339
- return -1;
9340
- if (y === undefined)
9341
- return 1;
9342
- const nx = Number(x);
9343
- const ny = Number(y);
9344
- const bothNumeric = !Number.isNaN(nx) && !Number.isNaN(ny);
9345
- const cmp = bothNumeric ? nx - ny : x < y ? -1 : x > y ? 1 : 0;
9346
- if (cmp !== 0)
9347
- return cmp;
9348
- }
9349
- return 0;
9350
- }
9351
- async function checkForUpdate(opts) {
9352
- const { current: current2 } = opts;
9353
- const fetchImpl = opts.fetchImpl ?? fetch;
9354
- if (current2 === UNKNOWN_VERSION)
9355
- return NO_UPDATE(current2);
9356
- try {
9357
- const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`);
9358
- if (!res.ok)
9359
- return NO_UPDATE(current2);
9360
- const body = await res.json();
9361
- const latest = typeof body.version === "string" ? body.version : null;
9362
- if (!latest)
9363
- return NO_UPDATE(current2);
9364
- return {
9365
- current: current2,
9366
- latest,
9367
- updateAvailable: compareSemver(latest, current2) > 0,
9368
- command: UPDATE_COMMAND
9369
- };
9370
- } catch {
9371
- return NO_UPDATE(current2);
9372
- }
9373
- }
9374
- async function getUpdateInfo(current2, fetchImpl) {
9375
- if (cache)
9376
- return cache;
9377
- const info = await checkForUpdate({ current: current2, fetchImpl });
9378
- if (info.latest !== null)
9379
- cache = info;
9380
- return info;
9381
- }
9382
- var PACKAGE_NAME = "runcastle", UPDATE_COMMAND, NO_UPDATE = (current2) => ({
9383
- current: current2,
9384
- latest: null,
9385
- updateAvailable: false,
9386
- command: UPDATE_COMMAND
9387
- }), cache;
9388
- var init_update_check = __esm(() => {
9389
- init_version();
9390
- UPDATE_COMMAND = `bun add -g ${PACKAGE_NAME}@latest`;
9391
- });
9392
-
9393
9852
  // src/trpc/routers/system.ts
9394
9853
  var systemRouter;
9395
9854
  var init_system = __esm(() => {
@@ -9403,11 +9862,11 @@ var init_system = __esm(() => {
9403
9862
  });
9404
9863
 
9405
9864
  // src/services/test-notes.ts
9406
- import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync5 } from "fs";
9407
- import { dirname as dirname10 } from "path";
9865
+ import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync7 } from "fs";
9866
+ import { dirname as dirname11 } from "path";
9408
9867
  import { asc as asc4, eq as eq14 } from "drizzle-orm";
9409
9868
  function rowToNote(row) {
9410
- return {
9869
+ return TestNote.parse({
9411
9870
  id: row.id,
9412
9871
  featureId: row.featureId,
9413
9872
  lap: row.lap,
@@ -9416,7 +9875,7 @@ function rowToNote(row) {
9416
9875
  ticketId: row.ticketId ?? undefined,
9417
9876
  createdAt: row.createdAt,
9418
9877
  updatedAt: row.updatedAt
9419
- };
9878
+ });
9420
9879
  }
9421
9880
  function getNote(ctx, id) {
9422
9881
  const row = ctx.db.select().from(testNotes).where(eq14(testNotes.id, id)).get();
@@ -9549,8 +10008,8 @@ function renderTestNotes(ctx, feature) {
9549
10008
  lines.push(noteLine(ctx, note));
9550
10009
  }
9551
10010
  const path = featureDocPath(projectForFeature(ctx, feature), feature, "test-notes.md");
9552
- mkdirSync9(dirname10(path), { recursive: true });
9553
- writeFileSync5(path, `${lines.join(`
10011
+ mkdirSync11(dirname11(path), { recursive: true });
10012
+ writeFileSync7(path, `${lines.join(`
9554
10013
  `)}
9555
10014
  `, "utf8");
9556
10015
  }
@@ -9741,6 +10200,8 @@ async function startServer() {
9741
10200
  }
9742
10201
  const server = Bun.serve({
9743
10202
  port: config2.serverPort,
10203
+ hostname: SERVE_HOSTNAME,
10204
+ idleTimeout: SERVE_IDLE_TIMEOUT_SECONDS,
9744
10205
  fetch(req, srv) {
9745
10206
  if (tryUpgradeTerminal(req, srv))
9746
10207
  return;
@@ -9749,15 +10210,16 @@ async function startServer() {
9749
10210
  websocket: terminalWebSocket
9750
10211
  });
9751
10212
  console.log(`runcastle${process.env.RUNCASTLE_DEV ? " [dev]" : ""} server listening on ` + `http://localhost:${config2.serverPort} \u2014 data dir: ${dataDir()}`);
10213
+ getUpdateInfo(runcastleVersion()).catch(() => {});
9752
10214
  const SHUTDOWN_KEY = Symbol.for("runcastle.shutdown.wired");
9753
10215
  const g = globalThis;
9754
10216
  if (!g[SHUTDOWN_KEY]) {
9755
10217
  g[SHUTDOWN_KEY] = true;
9756
- const shutdown = () => {
9757
- ptyRegistry().killAll();
9758
- server.stop();
9759
- process.exit(0);
9760
- };
10218
+ const shutdown = createShutdown({
10219
+ killAllTrees: () => ptyRegistry().killAllTrees(),
10220
+ stop: () => server.stop(),
10221
+ exit: (code) => process.exit(code)
10222
+ });
9761
10223
  process.on("SIGINT", shutdown);
9762
10224
  process.on("SIGTERM", shutdown);
9763
10225
  }
@@ -9774,7 +10236,9 @@ var init_src2 = __esm(() => {
9774
10236
  init_hooks();
9775
10237
  init_stream();
9776
10238
  init_web();
10239
+ init_update_check();
9777
10240
  init_router();
10241
+ init_version();
9778
10242
  init_reconcile_runs();
9779
10243
  });
9780
10244
 
@@ -9832,7 +10296,7 @@ __export(exports_cli, {
9832
10296
  resolveDoctorEnv: () => resolveDoctorEnv,
9833
10297
  parseMode: () => parseMode
9834
10298
  });
9835
- import { existsSync as existsSync18, readFileSync as readFileSync9 } from "fs";
10299
+ import { existsSync as existsSync19, readFileSync as readFileSync10 } from "fs";
9836
10300
  function parseMode(argv) {
9837
10301
  return argv.includes("--gate") || argv.includes("--boot") ? "gate" : "diagnostic";
9838
10302
  }
@@ -9840,8 +10304,8 @@ function envWithToken() {
9840
10304
  const merged = { ...process.env };
9841
10305
  try {
9842
10306
  const path = envPath();
9843
- if (existsSync18(path)) {
9844
- const fromFile = parseEnvFile(readFileSync9(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
10307
+ if (existsSync19(path)) {
10308
+ const fromFile = parseEnvFile(readFileSync10(path, "utf8")).CLAUDE_CODE_OAUTH_TOKEN;
9845
10309
  if (fromFile && fromFile.length > 0)
9846
10310
  merged.CLAUDE_CODE_OAUTH_TOKEN = fromFile;
9847
10311
  }
@@ -9883,7 +10347,7 @@ var init_cli = __esm(() => {
9883
10347
  // src/bin/runcastle.ts
9884
10348
  init_asset_paths();
9885
10349
  init_version();
9886
- import { dirname as dirname11, resolve as resolve11 } from "path";
10350
+ import { dirname as dirname12, resolve as resolve11 } from "path";
9887
10351
  import { fileURLToPath as fileURLToPath8 } from "url";
9888
10352
  function parseCommand(argv) {
9889
10353
  const [first, ...rest] = argv;
@@ -9908,7 +10372,7 @@ Usage:
9908
10372
  runcastle --help Show this help
9909
10373
  `;
9910
10374
  async function main(argv) {
9911
- applyInstalledAssetEnv(resolve11(dirname11(fileURLToPath8(import.meta.url)), ".."));
10375
+ applyInstalledAssetEnv(resolve11(dirname12(fileURLToPath8(import.meta.url)), ".."));
9912
10376
  const { command, args } = parseCommand(argv);
9913
10377
  switch (command) {
9914
10378
  case "version":