runcastle 1.2.4 → 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
@@ -179,8 +179,11 @@ function loadConfig(env = process.env) {
179
179
  }
180
180
  }
181
181
  const overrides = {};
182
- if (env.RUNCASTLE_SERVER_PORT)
183
- 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);
184
187
  if (env.RUNCASTLE_MODEL)
185
188
  overrides.model = env.RUNCASTLE_MODEL;
186
189
  if (env.RUNCASTLE_SMOKE_MODEL)
@@ -193,21 +196,12 @@ function loadConfig(env = process.env) {
193
196
  overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
194
197
  if (env.RUNCASTLE_SANDBOX_IMAGE)
195
198
  overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
196
- if (env.RUNCASTLE_BURN_CONCURRENCY) {
197
- overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
198
- }
199
- if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
200
- overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
201
- }
202
- if (env.RUNCASTLE_BURN_ATTEMPTS) {
203
- overrides.burnAttempts = Number(env.RUNCASTLE_BURN_ATTEMPTS);
204
- }
205
- if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
206
- overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
207
- }
208
- if (env.RUNCASTLE_BURN_CPUS)
209
- overrides.burnCpus = Number(env.RUNCASTLE_BURN_CPUS);
210
- 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 !== "") {
211
205
  overrides.burnGuard = env.RUNCASTLE_BURN_GUARD !== "0" && env.RUNCASTLE_BURN_GUARD !== "false";
212
206
  }
213
207
  if (env.RUNCASTLE_SETUP_COMMAND)
@@ -236,6 +230,7 @@ function ensureDataDir() {
236
230
  mkdirSync(join2(root, "sessions"), { recursive: true });
237
231
  mkdirSync(join2(root, "worktrees"), { recursive: true });
238
232
  }
233
+ var SERVE_HOSTNAME = "127.0.0.1", SERVE_IDLE_TIMEOUT_SECONDS = 120;
239
234
  var init_config2 = __esm(() => {
240
235
  init_config_load();
241
236
  init_paths();
@@ -250,7 +245,10 @@ var init_ids = () => {};
250
245
 
251
246
  // ../core/src/schemas.ts
252
247
  import * as z2 from "zod";
253
- var Phase, TicketStatus, SessionKind, 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;
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;
254
252
  var init_schemas = __esm(() => {
255
253
  Phase = z2.enum([
256
254
  "ideation",
@@ -270,13 +268,14 @@ var init_schemas = __esm(() => {
270
268
  "prepare",
271
269
  "project"
272
270
  ]);
271
+ PROJECT_SESSION_KINDS = ["prepare", "project"];
273
272
  SessionPurpose = z2.enum(["resolve-conflict"]);
274
273
  MergeBranchPair = z2.object({
275
274
  mergeFrom: z2.string(),
276
275
  mergeInto: z2.string()
277
276
  });
278
277
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
279
- FeatureStatus = z2.enum(["active", "shipped", "archived"]);
278
+ FeatureStatus = z2.enum(["draft", "active", "shipped", "archived"]);
280
279
  SessionStatus = z2.enum(["launching", "live", "ended"]);
281
280
  TicketInput = z2.object({
282
281
  title: z2.string(),
@@ -295,7 +294,8 @@ var init_schemas = __esm(() => {
295
294
  commits: z2.array(z2.string()),
296
295
  error: z2.string().optional(),
297
296
  attemptBranch: z2.string().optional(),
298
- conflictFiles: z2.array(z2.string()).optional()
297
+ conflictFiles: z2.array(z2.string()).optional(),
298
+ digest: z2.string().optional()
299
299
  });
300
300
  WaypointType = z2.enum(["grilling", "research", "prototype", "task"]);
301
301
  WaypointStatus = z2.enum(["open", "claimed", "resolved", "dropped"]);
@@ -360,7 +360,8 @@ var init_schemas = __esm(() => {
360
360
  dbResetCommand: z2.string().optional(),
361
361
  driveSetupCommand: z2.string().optional(),
362
362
  driveStopCommand: z2.string().optional(),
363
- driveEnv: z2.string().optional()
363
+ driveEnv: z2.string().optional(),
364
+ closedAt: z2.number().optional()
364
365
  });
365
366
  ProjectFinding = z2.object({
366
367
  key: PreparedKey,
@@ -378,6 +379,7 @@ var init_schemas = __esm(() => {
378
379
  slug: z2.string(),
379
380
  title: z2.string(),
380
381
  oneLiner: z2.string(),
382
+ brief: z2.string().optional(),
381
383
  mapped: z2.boolean(),
382
384
  lap: z2.number(),
383
385
  phase: Phase,
@@ -406,7 +408,8 @@ var init_schemas = __esm(() => {
406
408
  status: RunStatus,
407
409
  startedAt: z2.number(),
408
410
  endedAt: z2.number().optional(),
409
- summary: z2.string().optional()
411
+ summary: z2.string().optional(),
412
+ digest: z2.string().optional()
410
413
  });
411
414
  SettingSource = z2.enum(["env", "project", "file", "default"]);
412
415
  SettingScope = z2.enum(["global", "project"]);
@@ -581,7 +584,7 @@ var init_pipeline = __esm(() => {
581
584
  });
582
585
 
583
586
  // ../core/src/db-schema.ts
584
- import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
587
+ import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
585
588
  var projects, projectFindings, features, sessions, tickets, testNotes, waypoints, runs, events, gateOverrides;
586
589
  var init_db_schema = __esm(() => {
587
590
  projects = sqliteTable("projects", {
@@ -617,6 +620,7 @@ var init_db_schema = __esm(() => {
617
620
  slug: text("slug").notNull(),
618
621
  title: text("title").notNull(),
619
622
  oneLiner: text("one_liner").notNull(),
623
+ brief: text("brief"),
620
624
  mapped: integer("mapped", { mode: "boolean" }).notNull().default(false),
621
625
  lap: integer("lap").notNull().default(1),
622
626
  phase: text("phase").notNull().$type(),
@@ -654,7 +658,8 @@ var init_db_schema = __esm(() => {
654
658
  commits: text("commits", { mode: "json" }).notNull().$type(),
655
659
  error: text("error"),
656
660
  attemptBranch: text("attempt_branch"),
657
- conflictFiles: text("conflict_files", { mode: "json" }).$type()
661
+ conflictFiles: text("conflict_files", { mode: "json" }).$type(),
662
+ digest: text("digest")
658
663
  });
659
664
  testNotes = sqliteTable("test_notes", {
660
665
  id: text("id").primaryKey(),
@@ -687,7 +692,8 @@ var init_db_schema = __esm(() => {
687
692
  status: text("status").notNull().$type(),
688
693
  startedAt: integer("started_at").notNull(),
689
694
  endedAt: integer("ended_at"),
690
- summary: text("summary")
695
+ summary: text("summary"),
696
+ digest: text("digest")
691
697
  });
692
698
  events = sqliteTable("events", {
693
699
  id: integer("id").primaryKey({ autoIncrement: true }),
@@ -700,7 +706,10 @@ var init_db_schema = __esm(() => {
700
706
  type: text("type").notNull(),
701
707
  message: text("message").notNull(),
702
708
  data: text("data", { mode: "json" })
703
- });
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
+ ]);
704
713
  gateOverrides = sqliteTable("gate_overrides", {
705
714
  id: integer("id").primaryKey({ autoIncrement: true }),
706
715
  featureId: text("feature_id").notNull(),
@@ -1280,6 +1289,16 @@ var init_ring_buffer = __esm(() => {
1280
1289
  });
1281
1290
 
1282
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
+
1283
1302
  class PtyRegistry {
1284
1303
  entries = new Map;
1285
1304
  create(input) {
@@ -1337,18 +1356,31 @@ class PtyRegistry {
1337
1356
  const entry = this.entries.get(sessionId);
1338
1357
  if (!entry)
1339
1358
  return false;
1340
- 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);
1341
1367
  return true;
1342
1368
  }
1343
1369
  remove(sessionId) {
1344
1370
  this.entries.delete(sessionId);
1345
1371
  }
1346
- killAll() {
1347
- for (const entry of this.entries.values()) {
1348
- if (!entry.exited)
1349
- entry.pty.kill();
1350
- }
1372
+ async killAllTrees() {
1373
+ const all = [...this.entries.values()];
1351
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 {}
1352
1384
  }
1353
1385
  ids() {
1354
1386
  return [...this.entries.keys()];
@@ -1360,7 +1392,7 @@ function ptyRegistry() {
1360
1392
  g[REGISTRY_KEY] = new PtyRegistry;
1361
1393
  return g[REGISTRY_KEY];
1362
1394
  }
1363
- var REGISTRY_KEY;
1395
+ var KILL_TREE_TIMEOUT_MS = 5000, REGISTRY_KEY;
1364
1396
  var init_registry = __esm(() => {
1365
1397
  init_pty();
1366
1398
  init_ring_buffer();
@@ -1441,7 +1473,7 @@ var init_bus = __esm(() => {
1441
1473
  // src/services/events.ts
1442
1474
  import { and, asc, desc, eq, gt, max } from "drizzle-orm";
1443
1475
  function rowToEvent(row) {
1444
- return {
1476
+ return EventRow.parse({
1445
1477
  id: row.id,
1446
1478
  projectId: row.projectId,
1447
1479
  featureId: row.featureId ?? undefined,
@@ -1451,7 +1483,7 @@ function rowToEvent(row) {
1451
1483
  type: row.type,
1452
1484
  message: row.message,
1453
1485
  data: row.data ?? undefined
1454
- };
1486
+ });
1455
1487
  }
1456
1488
  function projectIdForFeature(ctx, featureId) {
1457
1489
  const row = ctx.db.select({ projectId: features.projectId }).from(features).where(eq(features.id, featureId)).get();
@@ -1522,6 +1554,7 @@ function listByProject(ctx, projectId, afterId = 0) {
1522
1554
  return rows.map(rowToEvent);
1523
1555
  }
1524
1556
  var init_events = __esm(() => {
1557
+ init_src();
1525
1558
  init_schema();
1526
1559
  init_errors();
1527
1560
  init_bus();
@@ -1530,12 +1563,13 @@ var init_events = __esm(() => {
1530
1563
  // src/services/repo.ts
1531
1564
  import { and as and2, desc as desc2, eq as eq2 } from "drizzle-orm";
1532
1565
  function rowToFeature(row) {
1533
- return {
1566
+ return Feature.parse({
1534
1567
  id: row.id,
1535
1568
  projectId: row.projectId,
1536
1569
  slug: row.slug,
1537
1570
  title: row.title,
1538
1571
  oneLiner: row.oneLiner,
1572
+ brief: row.brief ?? undefined,
1539
1573
  mapped: row.mapped,
1540
1574
  lap: row.lap,
1541
1575
  phase: row.phase,
@@ -1543,10 +1577,10 @@ function rowToFeature(row) {
1543
1577
  baseBranch: row.baseBranch ?? undefined,
1544
1578
  status: row.status,
1545
1579
  createdAt: row.createdAt
1546
- };
1580
+ });
1547
1581
  }
1548
1582
  function rowToProject(row) {
1549
- return {
1583
+ return Project.parse({
1550
1584
  id: row.id,
1551
1585
  name: row.name,
1552
1586
  repoPath: row.repoPath,
@@ -1559,22 +1593,24 @@ function rowToProject(row) {
1559
1593
  dbResetCommand: row.dbResetCommand ?? undefined,
1560
1594
  driveSetupCommand: row.driveSetupCommand ?? undefined,
1561
1595
  driveStopCommand: row.driveStopCommand ?? undefined,
1562
- driveEnv: row.driveEnv ?? undefined
1563
- };
1596
+ driveEnv: row.driveEnv ?? undefined,
1597
+ closedAt: row.closedAt ?? undefined
1598
+ });
1564
1599
  }
1565
1600
  function rowToRun(row) {
1566
- return {
1601
+ return Run.parse({
1567
1602
  id: row.id,
1568
1603
  featureId: row.featureId,
1569
1604
  workflow: row.workflow,
1570
1605
  status: row.status,
1571
1606
  startedAt: row.startedAt,
1572
1607
  endedAt: row.endedAt ?? undefined,
1573
- summary: row.summary ?? undefined
1574
- };
1608
+ summary: row.summary ?? undefined,
1609
+ digest: row.digest ?? undefined
1610
+ });
1575
1611
  }
1576
1612
  function rowToSession(row) {
1577
- return {
1613
+ return SessionRow.parse({
1578
1614
  id: row.id,
1579
1615
  featureId: row.featureId ?? undefined,
1580
1616
  projectId: row.projectId ?? undefined,
@@ -1586,7 +1622,7 @@ function rowToSession(row) {
1586
1622
  status: row.status,
1587
1623
  awaitingInput: row.awaitingInput,
1588
1624
  worktreePath: row.worktreePath
1589
- };
1625
+ });
1590
1626
  }
1591
1627
  function tryGetFeature(ctx, id) {
1592
1628
  const row = ctx.db.select().from(features).where(eq2(features.id, id)).get();
@@ -1662,6 +1698,7 @@ function setFeatureStatus(ctx, featureId, status) {
1662
1698
  return { ...current, status };
1663
1699
  }
1664
1700
  var init_repo = __esm(() => {
1701
+ init_src();
1665
1702
  init_schema();
1666
1703
  init_errors();
1667
1704
  init_events();
@@ -1670,7 +1707,7 @@ var init_repo = __esm(() => {
1670
1707
  // src/services/waypoints.ts
1671
1708
  import { and as and3, asc as asc2, eq as eq3 } from "drizzle-orm";
1672
1709
  function rowToWaypoint(row) {
1673
- return {
1710
+ return Waypoint.parse({
1674
1711
  id: row.id,
1675
1712
  featureId: row.featureId,
1676
1713
  seq: row.seq,
@@ -1683,7 +1720,7 @@ function rowToWaypoint(row) {
1683
1720
  claimedBy: row.claimedBy ?? undefined,
1684
1721
  lastSessionId: row.lastSessionId ?? undefined,
1685
1722
  summary: row.summary ?? undefined
1686
- };
1723
+ });
1687
1724
  }
1688
1725
  function listByFeature(ctx, featureId) {
1689
1726
  return ctx.db.select().from(waypoints).where(eq3(waypoints.featureId, featureId)).orderBy(asc2(waypoints.seq)).all().map(rowToWaypoint);
@@ -1799,6 +1836,9 @@ function releaseForSession(ctx, claimant) {
1799
1836
  }
1800
1837
  function resolve3(ctx, id, disposition, summary) {
1801
1838
  const wp = getWaypoint(ctx, id);
1839
+ if (isTerminal(wp)) {
1840
+ throw new InvalidInputError(`waypoint ${wp.seq} is already ${wp.status}`);
1841
+ }
1802
1842
  const before = listByFeature(ctx, wp.featureId);
1803
1843
  const bySeqBefore = new Map(before.map((w) => [w.seq, w]));
1804
1844
  ctx.db.update(waypoints).set({ status: disposition, summary, claimedBy: null }).where(eq3(waypoints.id, id)).run();
@@ -1835,6 +1875,91 @@ var init_waypoints = __esm(() => {
1835
1875
  TERMINAL = new Set(["resolved", "dropped"]);
1836
1876
  });
1837
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
+
1838
1963
  // src/pty/dev-pane.ts
1839
1964
  function drivePaneId(ownerId) {
1840
1965
  return `${DRIVE_PREFIX}${ownerId}`;
@@ -1895,24 +2020,12 @@ function devPaneLive(paneId) {
1895
2020
  const entry = ptyRegistry().get(paneId);
1896
2021
  return !!entry && !entry.exited;
1897
2022
  }
1898
- function killTreeBounded(pty) {
1899
- return new Promise((resolve4) => {
1900
- const timer = setTimeout(resolve4, KILL_TREE_TIMEOUT_MS);
1901
- pty.killTree().catch(() => {}).then(() => {
1902
- clearTimeout(timer);
1903
- resolve4();
1904
- });
1905
- });
1906
- }
1907
2023
  async function stopDevPane(paneId) {
1908
2024
  const reg = ptyRegistry();
1909
- const entry = reg.get(paneId);
1910
- if (entry && !entry.exited)
1911
- await killTreeBounded(entry.pty);
1912
- reg.kill(paneId);
2025
+ await reg.killTree(paneId);
1913
2026
  reg.remove(paneId);
1914
2027
  }
1915
- var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192, KILL_TREE_TIMEOUT_MS = 5000;
2028
+ var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
1916
2029
  var init_dev_pane = __esm(() => {
1917
2030
  init_events();
1918
2031
  init_registry();
@@ -1970,7 +2083,8 @@ function runDriveHook(command, opts) {
1970
2083
  cwd: opts.cwd,
1971
2084
  env: opts.env ?? process.env,
1972
2085
  windowsHide: true,
1973
- windowsVerbatimArguments: verbatim
2086
+ windowsVerbatimArguments: verbatim,
2087
+ detached: process.platform !== "win32"
1974
2088
  });
1975
2089
  } catch (err) {
1976
2090
  output = err instanceof Error ? err.message : String(err);
@@ -1979,7 +2093,11 @@ function runDriveHook(command, opts) {
1979
2093
  }
1980
2094
  timer = setTimeout(() => {
1981
2095
  timedOut = true;
1982
- child.kill();
2096
+ const pid = child.pid;
2097
+ if (pid === undefined)
2098
+ child.kill();
2099
+ else
2100
+ killProcessTree(pid).then(() => child.kill());
1983
2101
  const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
1984
2102
  grace.unref?.();
1985
2103
  }, timeoutMs);
@@ -2010,6 +2128,7 @@ function describeHookResult(command, result) {
2010
2128
  }
2011
2129
  var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
2012
2130
  var init_drive_hooks = __esm(() => {
2131
+ init_kill_tree();
2013
2132
  DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
2014
2133
  });
2015
2134
 
@@ -2226,9 +2345,9 @@ var init_findings = __esm(() => {
2226
2345
  });
2227
2346
 
2228
2347
  // src/services/git.ts
2229
- 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";
2230
2349
  import { tmpdir } from "os";
2231
- import { dirname, join as join5, resolve as resolve4 } from "path";
2350
+ import { dirname, join as join6, resolve as resolve4 } from "path";
2232
2351
  import { simpleGit } from "simple-git";
2233
2352
  function migrationPaths(paths) {
2234
2353
  return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
@@ -2253,7 +2372,7 @@ function canon(p) {
2253
2372
  return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2254
2373
  }
2255
2374
  async function assertRepo(repoPath) {
2256
- if (!existsSync5(repoPath)) {
2375
+ if (!existsSync6(repoPath)) {
2257
2376
  throw new InvalidInputError(`path does not exist: ${repoPath}`);
2258
2377
  }
2259
2378
  let isRepo = false;
@@ -2342,7 +2461,7 @@ async function ensureTalkWorktree(project, feature) {
2342
2461
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2343
2462
  if (await worktreeIsValid(g, worktreePath, branch))
2344
2463
  return worktreePath;
2345
- if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2464
+ if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2346
2465
  if (await checkoutInWorktree(worktreePath, branch))
2347
2466
  return worktreePath;
2348
2467
  }
@@ -2365,14 +2484,13 @@ async function addWorktree(g, worktreePath, branch, label) {
2365
2484
  }
2366
2485
  }
2367
2486
  }
2368
- async function ensureProjectWorktree(project) {
2487
+ async function ensureProjectWorktree(project, onLanded) {
2369
2488
  const worktreePath = worktreeDir(project.id, PROJECT_WORKTREE_SLUG);
2370
2489
  const g = git(project.repoPath);
2371
2490
  const base = project.mainBranch;
2372
- const leftover = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2373
- if (leftover.length > 0) {
2374
- await mergeTempBranch(project.repoPath, base, PROJECT_BRANCH);
2375
- }
2491
+ const landed = await landProjectBranch(project);
2492
+ if (landed)
2493
+ onLanded?.(landed);
2376
2494
  const stillAhead = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2377
2495
  if (stillAhead.length === 0) {
2378
2496
  await deleteBranchDetachingWorktrees(g, project.repoPath, PROJECT_BRANCH);
@@ -2380,7 +2498,7 @@ async function ensureProjectWorktree(project) {
2380
2498
  }
2381
2499
  if (await worktreeIsValid(g, worktreePath, PROJECT_BRANCH))
2382
2500
  return worktreePath;
2383
- if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2501
+ if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2384
2502
  if (await checkoutInWorktree(worktreePath, PROJECT_BRANCH))
2385
2503
  return worktreePath;
2386
2504
  }
@@ -2406,7 +2524,7 @@ async function ensureBranchExists(g, branch, from) {
2406
2524
  }
2407
2525
  }
2408
2526
  async function worktreeIsValid(g, worktreePath, branch) {
2409
- if (!existsSync5(worktreePath))
2527
+ if (!existsSync6(worktreePath))
2410
2528
  return false;
2411
2529
  const registered = await registeredWorktrees(g);
2412
2530
  if (!registered.has(canon(worktreePath)))
@@ -2452,7 +2570,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2452
2570
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2453
2571
  }
2454
2572
  async function detachWorktree(path) {
2455
- if (!existsSync5(path))
2573
+ if (!existsSync6(path))
2456
2574
  return false;
2457
2575
  const g = git(path);
2458
2576
  let head;
@@ -2467,7 +2585,7 @@ async function detachWorktree(path) {
2467
2585
  return true;
2468
2586
  }
2469
2587
  async function reattachWorktree(path, branch) {
2470
- if (!existsSync5(path))
2588
+ if (!existsSync6(path))
2471
2589
  return;
2472
2590
  try {
2473
2591
  await git(path).checkout(branch);
@@ -2483,21 +2601,21 @@ function ticketBranchName(slug, ticketSeq, unique) {
2483
2601
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2484
2602
  }
2485
2603
  function burnWorktreePath(repoPath, branch) {
2486
- return join5(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2604
+ return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2487
2605
  }
2488
2606
  async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2489
2607
  const path = burnWorktreePath(repoPath, branch);
2490
2608
  const attempts = Math.max(1, opts.attempts ?? 3);
2491
2609
  const delayMs = opts.delayMs ?? 750;
2492
2610
  const g = git(repoPath);
2493
- for (let attempt = 1;attempt <= attempts && existsSync5(path); attempt++) {
2611
+ for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2494
2612
  try {
2495
2613
  await g.raw(["worktree", "remove", "--force", path]);
2496
2614
  } catch {}
2497
- if (existsSync5(path) && attempt < attempts)
2615
+ if (existsSync6(path) && attempt < attempts)
2498
2616
  await sleep(delayMs);
2499
2617
  }
2500
- if (existsSync5(path)) {
2618
+ if (existsSync6(path)) {
2501
2619
  try {
2502
2620
  rmSync(path, { recursive: true, force: true });
2503
2621
  } catch {}
@@ -2505,7 +2623,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2505
2623
  try {
2506
2624
  await g.raw(["worktree", "prune"]);
2507
2625
  } catch {}
2508
- return !existsSync5(path);
2626
+ return !existsSync6(path);
2509
2627
  }
2510
2628
  function sleep(ms) {
2511
2629
  return new Promise((done) => setTimeout(done, ms));
@@ -2653,11 +2771,11 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2653
2771
  async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2654
2772
  let dir;
2655
2773
  try {
2656
- dir = mkdtempSync(join5(tmpdir(), "rc-land-"));
2774
+ dir = mkdtempSync(join6(tmpdir(), "rc-land-"));
2657
2775
  } catch (e) {
2658
2776
  return { ok: false, error: errMsg(e) };
2659
2777
  }
2660
- const wt = join5(dir, "wt");
2778
+ const wt = join6(dir, "wt");
2661
2779
  try {
2662
2780
  await g.raw(["worktree", "add", wt, featureBranchName]);
2663
2781
  const gw = git(wt);
@@ -2764,7 +2882,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2764
2882
  try {
2765
2883
  await g.raw(["worktree", "prune"]);
2766
2884
  } catch {}
2767
- if (existsSync5(worktreePath)) {
2885
+ if (existsSync6(worktreePath)) {
2768
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");
2769
2887
  }
2770
2888
  }
@@ -2791,7 +2909,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2791
2909
  return { deleted, kept };
2792
2910
  }
2793
2911
  async function commitDocs(worktreePath, message) {
2794
- if (!existsSync5(resolve4(worktreePath, DOCS_PATHSPEC)))
2912
+ if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
2795
2913
  return;
2796
2914
  const g = git(worktreePath);
2797
2915
  await g.add([DOCS_PATHSPEC]);
@@ -3484,35 +3602,46 @@ function landProjectSession(ctx, session) {
3484
3602
  const project = getProjectById(ctx, session.projectId);
3485
3603
  if (!project)
3486
3604
  return;
3487
- const kept = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3488
- const report = (type, message, data = {}) => {
3489
- emitProject(ctx, project.id, {
3490
- type,
3491
- message,
3492
- data: { sessionId: session.id, branch: PROJECT_BRANCH, ...data }
3493
- });
3494
- };
3495
3605
  const landing = landProjectBranch(project).then((res) => {
3496
- if (!res)
3497
- return;
3498
- if (res.landed) {
3499
- report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${project.mainBranch}`, { commits: res.commits });
3500
- } else if (res.conflict) {
3501
- 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 ?? [] });
3502
- } else {
3503
- 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 });
3504
- }
3606
+ if (res)
3607
+ reportProjectLanding(ctx, project, res, { sessionId: session.id });
3505
3608
  }).catch((e) => {
3506
- 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
+ });
3507
3618
  });
3508
3619
  inFlightLandings.add(landing);
3509
3620
  landing.finally(() => inFlightLandings.delete(landing));
3510
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
+ }
3511
3638
  function markSessionEnded(ctx, id) {
3512
3639
  const existing = getSessionRow(ctx, id);
3513
3640
  if (!existing)
3514
3641
  return null;
3515
3642
  forgetKickoff(id);
3643
+ if (existing.featureId)
3644
+ stopDocsWatch(existing.featureId);
3516
3645
  ctx.db.update(sessions).set({ status: "ended" }).where(eq5(sessions.id, id)).run();
3517
3646
  return getSessionRow(ctx, id);
3518
3647
  }
@@ -3535,11 +3664,12 @@ function mostRecentLiveSession(ctx) {
3535
3664
  const row = ctx.db.select().from(sessions).where(eq5(sessions.status, "live")).orderBy(desc3(sql2`rowid`)).limit(1).get();
3536
3665
  return row ? rowToSession(row) : null;
3537
3666
  }
3538
- 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;
3539
3668
  var init_sessions = __esm(() => {
3540
3669
  init_src();
3541
3670
  init_schema();
3542
3671
  init_registry();
3672
+ init_docs_watch();
3543
3673
  init_events();
3544
3674
  init_git();
3545
3675
  init_waypoints();
@@ -3559,6 +3689,7 @@ var init_sessions = __esm(() => {
3559
3689
  deliveries = new Map;
3560
3690
  landedProjectSessions = new Set;
3561
3691
  inFlightLandings = new Set;
3692
+ LANDING_KEPT = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3562
3693
  });
3563
3694
 
3564
3695
  // src/launcher/reconcile.ts
@@ -3612,26 +3743,10 @@ async function getRuntimeCtx() {
3612
3743
  }
3613
3744
  var current = null;
3614
3745
 
3615
- // src/services/feature-docs.ts
3616
- import { existsSync as existsSync6 } from "fs";
3617
- import { join as join6 } from "path";
3618
- function featureDocsDir(project, feature) {
3619
- const relSegments = featureDocsRel(feature.slug).split("/");
3620
- const worktree = worktreeDir(project.id, feature.slug);
3621
- const base = existsSync6(worktree) ? worktree : project.repoPath;
3622
- return join6(base, ...relSegments);
3623
- }
3624
- function featureDocPath(project, feature, fileName) {
3625
- return join6(featureDocsDir(project, feature), fileName);
3626
- }
3627
- var init_feature_docs = __esm(() => {
3628
- init_paths();
3629
- });
3630
-
3631
3746
  // src/services/tickets.ts
3632
3747
  import { and as and6, asc as asc3, eq as eq6 } from "drizzle-orm";
3633
3748
  function rowToTicket(row) {
3634
- return {
3749
+ return Ticket.parse({
3635
3750
  id: row.id,
3636
3751
  featureId: row.featureId,
3637
3752
  seq: row.seq,
@@ -3646,8 +3761,9 @@ function rowToTicket(row) {
3646
3761
  commits: row.commits,
3647
3762
  error: row.error ?? undefined,
3648
3763
  attemptBranch: row.attemptBranch ?? undefined,
3649
- conflictFiles: row.conflictFiles ?? undefined
3650
- };
3764
+ conflictFiles: row.conflictFiles ?? undefined,
3765
+ digest: row.digest ?? undefined
3766
+ });
3651
3767
  }
3652
3768
  function resolveBlocking(inputs, startSeq) {
3653
3769
  try {
@@ -3683,7 +3799,8 @@ function storeTickets(ctx, featureId, inputs) {
3683
3799
  commits: [],
3684
3800
  error: null,
3685
3801
  attemptBranch: null,
3686
- conflictFiles: null
3802
+ conflictFiles: null,
3803
+ digest: null
3687
3804
  }));
3688
3805
  ctx.db.insert(tickets).values(rows).run();
3689
3806
  emit(ctx, featureId, {
@@ -3770,6 +3887,8 @@ function updateTicket(ctx, id, patch) {
3770
3887
  set.attemptBranch = patch.attemptBranch;
3771
3888
  if (patch.conflictFiles !== undefined)
3772
3889
  set.conflictFiles = patch.conflictFiles;
3890
+ if (patch.digest !== undefined)
3891
+ set.digest = patch.digest;
3773
3892
  ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
3774
3893
  emit(ctx, current2.featureId, {
3775
3894
  type: "ticket.updated",
@@ -3855,6 +3974,13 @@ function docGate(ctx, feature, fileName, reason) {
3855
3974
  }
3856
3975
  function overrideGate(ctx, featureId, gate, reason) {
3857
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
+ }
3858
3984
  ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
3859
3985
  emit(ctx, featureId, {
3860
3986
  type: "gate.overridden",
@@ -4411,14 +4537,21 @@ function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
4411
4537
  }
4412
4538
  function buildWorkspaceNotes(mode) {
4413
4539
  if (mode === "mounted") {
4414
- 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
+ `);
4415
4546
  }
4416
4547
  return [
4417
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.`,
4418
4549
  "",
4419
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.`,
4420
4551
  "",
4421
- `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.`
4422
4555
  ].join(`
4423
4556
  `);
4424
4557
  }
@@ -4680,6 +4813,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4680
4813
  const status = new Map(tickets2.map((t) => [t.seq, t.status]));
4681
4814
  const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
4682
4815
  const inFlight = new Map;
4816
+ const digests = [];
4683
4817
  const satisfied = (s) => s === "done" || s === "cancelled";
4684
4818
  const readyState = (seq) => {
4685
4819
  const t = bySeq.get(seq);
@@ -4716,13 +4850,22 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4716
4850
  const outcome = await execute(ctx, t);
4717
4851
  if (outcome.status === "done") {
4718
4852
  status.set(seq, "done");
4719
- ctx.updateTicket(t.id, { status: "done", commits: outcome.commits });
4853
+ ctx.updateTicket(t.id, { status: "done", commits: outcome.commits, digest: outcome.digest });
4720
4854
  ctx.emitEvent({
4721
4855
  type: "ticket.done",
4722
4856
  message: `ticket ${t.seq} done \u2014 ${outcome.commits.length} commit(s)`,
4723
4857
  ticketId: t.id,
4724
4858
  data: { commits: outcome.commits }
4725
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
+ }
4726
4869
  } else {
4727
4870
  failTicket(seq, outcome.error, outcome.event);
4728
4871
  ctx.emitEvent({
@@ -4788,7 +4931,16 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4788
4931
  for (const s of status.values())
4789
4932
  if (s === "done")
4790
4933
  done += 1;
4791
- 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
+ `);
4792
4944
  }
4793
4945
  async function burnRun(ctx, deps) {
4794
4946
  const tickets2 = ctx.tickets;
@@ -4809,10 +4961,11 @@ async function burnRun(ctx, deps) {
4809
4961
  });
4810
4962
  return { status: "failed", summary: `dependency cycle: ${path}` };
4811
4963
  }
4812
- const done = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
4964
+ const { done, digests } = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
4813
4965
  const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
4814
4966
  ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
4815
- return { status: done === total ? "succeeded" : "failed", summary };
4967
+ const digest = composeRunDigest(digests);
4968
+ return { status: done === total ? "succeeded" : "failed", summary, ...digest ? { digest } : {} };
4816
4969
  }
4817
4970
  function burnerTemplatePath() {
4818
4971
  return burnerAssetPath("implement-ticket.md");
@@ -4834,11 +4987,11 @@ function readDocsDigestFromDisk(projectId, slug) {
4834
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") }));
4835
4988
  return buildDocsDigest(files);
4836
4989
  }
4837
- function readBlockedFile(dirs) {
4990
+ function readAgentFile(dirs, file) {
4838
4991
  for (const dir of dirs) {
4839
4992
  if (!dir)
4840
4993
  continue;
4841
- const p = join9(dir, "BLOCKED.md");
4994
+ const p = join9(dir, file);
4842
4995
  if (existsSync10(p)) {
4843
4996
  try {
4844
4997
  return readFileSync4(p, "utf8");
@@ -4847,10 +5000,26 @@ function readBlockedFile(dirs) {
4847
5000
  }
4848
5001
  return;
4849
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
+ }
4850
5019
  function buildBurnAgent(config2, token, model) {
4851
5020
  const onHost = config2.sandbox === "noSandbox";
4852
5021
  const opts = {
4853
- ...token ? { env: { CLAUDE_CODE_OAUTH_TOKEN: token } } : {},
5022
+ env: buildAgentEnv(onHost, token),
4854
5023
  ...onHost ? { permissionMode: "bypassPermissions" } : {}
4855
5024
  };
4856
5025
  const agent = claudeCode(model, opts);
@@ -4874,8 +5043,10 @@ function selectSandbox(config2, mounts = []) {
4874
5043
  return docker(imageOpts);
4875
5044
  case "podman":
4876
5045
  return podman(imageOpts);
4877
- default:
5046
+ case "noSandbox":
4878
5047
  return noSandbox();
5048
+ default:
5049
+ throw new Error(`no sandbox provider for sandbox "${config2.sandbox}" \u2014 refusing to run the agent unsandboxed`);
4879
5050
  }
4880
5051
  }
4881
5052
  function buildSandboxOptions(config2, mounts = []) {
@@ -5020,7 +5191,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
5020
5191
  }
5021
5192
  await cleanupBurnWorktree(project.repoPath, resolveBranch);
5022
5193
  }
5023
- const blocked = readBlockedFile([result?.preservedWorktreePath, project.repoPath]);
5194
+ const blocked = readAgentFile([result?.preservedWorktreePath, project.repoPath], "BLOCKED.md");
5024
5195
  if (blocked !== undefined && blocked.trim().length > 0) {
5025
5196
  return {
5026
5197
  ok: false,
@@ -5054,11 +5225,16 @@ ${blocked.trim()}`
5054
5225
  label: `ticket ${ticket.seq}`,
5055
5226
  featureBranch: feature.branch
5056
5227
  };
5228
+ let harvestedDigest;
5057
5229
  const landChain = async (branch, commits) => {
5058
5230
  const landing = await landWithResolve(branch, landDeps);
5059
5231
  if (landing.status === "landed") {
5060
5232
  ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
5061
- 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
+ };
5062
5238
  }
5063
5239
  preserveChain(landing.branch);
5064
5240
  if (landing.status === "conflict") {
@@ -5219,7 +5395,9 @@ ${retryNotes}` : basePrompt,
5219
5395
  }
5220
5396
  if (!result)
5221
5397
  return { status: "failed", error: "internal: run loop produced no result" };
5222
- 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);
5223
5401
  const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
5224
5402
  let outcome;
5225
5403
  if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
@@ -5353,8 +5531,6 @@ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as re
5353
5531
  import { dirname as dirname4, join as join10 } from "path";
5354
5532
  import { fileURLToPath as fileURLToPath3 } from "url";
5355
5533
  import { run as run2 } from "@ai-hero/sandcastle";
5356
- import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
5357
- import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
5358
5534
  function waypointSlug(waypoint) {
5359
5535
  const base = waypoint.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5360
5536
  return `${waypoint.seq}-${base || "waypoint"}`;
@@ -5368,7 +5544,7 @@ async function researchRun(ctx, deps) {
5368
5544
  ctx.emitEvent({ type: "research.error", message: "research run started without a waypoint" });
5369
5545
  return { status: "failed", summary: "no waypoint to research" };
5370
5546
  }
5371
- if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
5547
+ if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
5372
5548
  ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
5373
5549
  return { status: "failed", summary: "research aborted: auth token missing" };
5374
5550
  }
@@ -5450,7 +5626,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
5450
5626
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
5451
5627
  const runOptions = {
5452
5628
  agent: buildBurnAgent(config2, token, model),
5453
- sandbox: config2.sandbox === "docker" ? docker2({ imageName: resolveSandboxImage(config2) }) : noSandbox2(),
5629
+ sandbox: selectSandbox(config2),
5454
5630
  cwd: project.repoPath,
5455
5631
  prompt,
5456
5632
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
@@ -5626,10 +5802,12 @@ function cancelRun(runId) {
5626
5802
  async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
5627
5803
  let status = "failed";
5628
5804
  let summary = "run failed";
5805
+ let digest;
5629
5806
  try {
5630
5807
  const result = await runPromise;
5631
5808
  status = result.status;
5632
5809
  summary = result.summary;
5810
+ digest = result.digest;
5633
5811
  } catch (e) {
5634
5812
  if (controller.signal.aborted) {
5635
5813
  status = "cancelled";
@@ -5642,7 +5820,7 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
5642
5820
  } finally {
5643
5821
  controllers.delete(runId);
5644
5822
  }
5645
- 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();
5646
5824
  releaseForSession(ctx, runId);
5647
5825
  if (workflowClaimsFeatureBranch(workflow2)) {
5648
5826
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -5697,36 +5875,73 @@ async function createFeature(ctx, input) {
5697
5875
  const slug = uniqueSlug(ctx, project.id, input.title);
5698
5876
  const branch = `feature/${slug}`;
5699
5877
  const requestedBase = input.baseBranch?.trim() || project.mainBranch;
5700
- const { branchReady, baseBranch } = await ensureFeatureBranch(project, slug, requestedBase);
5878
+ const cut = input.draft ? null : await ensureFeatureBranch(project, slug, requestedBase);
5701
5879
  const row = {
5702
5880
  id: newId("feat"),
5703
5881
  projectId: project.id,
5704
5882
  slug,
5705
5883
  title: input.title,
5706
5884
  oneLiner: input.oneLiner,
5885
+ brief: input.draft ? input.brief ?? null : null,
5707
5886
  mapped: false,
5708
5887
  lap: 1,
5709
5888
  phase: "ideation",
5710
5889
  branch,
5711
- baseBranch,
5712
- status: "active",
5890
+ baseBranch: cut?.baseBranch ?? null,
5891
+ status: cut ? "active" : "draft",
5713
5892
  createdAt: Date.now()
5714
5893
  };
5715
5894
  const inserted = ctx.db.insert(features).values(row).returning().get();
5716
5895
  const feature = rowToFeature(inserted);
5717
5896
  emit(ctx, feature.id, {
5718
5897
  type: "feature.created",
5719
- message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
5720
- 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
+ }
5721
5906
  });
5907
+ if (!cut)
5908
+ return feature;
5722
5909
  scaffoldDocs(ctx, feature, { brief: input.brief });
5723
- if (branchReady) {
5910
+ if (cut.branchReady) {
5724
5911
  try {
5725
5912
  await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
5726
5913
  } catch {}
5727
5914
  }
5728
5915
  return feature;
5729
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
+ }
5730
5945
  async function quickChange(ctx, input) {
5731
5946
  const project = requireProjectById(ctx, input.projectId);
5732
5947
  const title = input.title.trim();
@@ -5845,6 +6060,7 @@ function liveSessionOf(ctx, featureId) {
5845
6060
  }
5846
6061
  function advance(ctx, featureId) {
5847
6062
  const feature = getFeatureRow(ctx, featureId);
6063
+ requireNotDraft(feature);
5848
6064
  const gate = nextGate(feature);
5849
6065
  if (!gate)
5850
6066
  throw new GateError("feature is already at the final phase");
@@ -5862,6 +6078,7 @@ function advance(ctx, featureId) {
5862
6078
  }
5863
6079
  async function burn(ctx, featureId, opts = {}) {
5864
6080
  const feature = getFeatureRow(ctx, featureId);
6081
+ requireNotDraft(feature);
5865
6082
  const running = hasActiveRun(ctx, featureId);
5866
6083
  let tickets2 = listByFeature2(ctx, featureId);
5867
6084
  const lapTickets = tickets2.filter((t) => t.lap === feature.lap);
@@ -5912,6 +6129,7 @@ async function burn(ctx, featureId, opts = {}) {
5912
6129
  }
5913
6130
  function rethink(ctx, featureId) {
5914
6131
  const feature = getFeatureRow(ctx, featureId);
6132
+ requireNotDraft(feature);
5915
6133
  if (feature.phase !== RETHINK_LOOP_BACK.from) {
5916
6134
  throw new GateError(`feature must be in the review phase to rethink (currently ${feature.phase})`);
5917
6135
  }
@@ -6025,6 +6243,7 @@ function escalateToMap(ctx, featureId, input) {
6025
6243
  }
6026
6244
  function archiveFeature(ctx, featureId) {
6027
6245
  const feature = getFeatureRow(ctx, featureId);
6246
+ requireNotDraft(feature);
6028
6247
  if (feature.status === "archived") {
6029
6248
  throw new GateError(`feature ${feature.slug} is already archived`);
6030
6249
  }
@@ -6308,8 +6527,19 @@ async function toolDryRunDrive(ctx, session, input) {
6308
6527
  }
6309
6528
  return dryRunDrive(ctx, project, input.action);
6310
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
+ }
6311
6541
  async function toolCreateFeature(ctx, session, input) {
6312
- const project = requireProject(ctx, session);
6542
+ const project = createFeatureProject(ctx, session, input);
6313
6543
  const feature = input.ticket ? await quickChange(ctx, {
6314
6544
  projectId: project.id,
6315
6545
  title: input.title,
@@ -6320,7 +6550,8 @@ async function toolCreateFeature(ctx, session, input) {
6320
6550
  title: input.title,
6321
6551
  oneLiner: input.oneLiner,
6322
6552
  baseBranch: input.baseBranch,
6323
- brief: input.brief
6553
+ brief: input.brief,
6554
+ draft: input.draft
6324
6555
  });
6325
6556
  return {
6326
6557
  id: feature.id,
@@ -6381,7 +6612,8 @@ function toolGetWorkRecord(ctx, session, input) {
6381
6612
  status: t.status,
6382
6613
  seams: t.seams,
6383
6614
  commits: t.commits,
6384
- ...t.error !== undefined ? { error: t.error } : {}
6615
+ ...t.error !== undefined ? { error: t.error } : {},
6616
+ ...t.digest !== undefined ? { digest: t.digest } : {}
6385
6617
  }))
6386
6618
  });
6387
6619
  }
@@ -6435,12 +6667,13 @@ function buildMcpServer() {
6435
6667
  });
6436
6668
  server.registerTool("create_feature", {
6437
6669
  title: "Create a feature",
6438
- 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.",
6439
6671
  inputSchema: {
6440
6672
  title: z3.string().min(1),
6441
6673
  oneLiner: z3.string(),
6442
6674
  baseBranch: z3.string().optional(),
6443
6675
  brief: z3.string().optional(),
6676
+ draft: z3.boolean().optional(),
6444
6677
  ticket: z3.object({ prose: z3.string().min(1) }).optional()
6445
6678
  }
6446
6679
  }, async (args, extra) => {
@@ -6461,7 +6694,7 @@ function buildMcpServer() {
6461
6694
  });
6462
6695
  server.registerTool("get_work_record", {
6463
6696
  title: "Get work record",
6464
- 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.",
6465
6698
  inputSchema: { featureSlug: z3.string().optional(), seam: z3.string().optional() }
6466
6699
  }, async (args, extra) => {
6467
6700
  const rs = await resolveCtxSession(extra);
@@ -6584,7 +6817,7 @@ function buildMcpServer() {
6584
6817
  });
6585
6818
  return server;
6586
6819
  }
6587
- var mcp, server_default;
6820
+ var DRAFTING_KINDS, mcp, server_default;
6588
6821
  var init_server = __esm(() => {
6589
6822
  init_src();
6590
6823
  init_paths();
@@ -6598,6 +6831,7 @@ var init_server = __esm(() => {
6598
6831
  init_repo();
6599
6832
  init_tickets();
6600
6833
  init_waypoints();
6834
+ DRAFTING_KINDS = ["ideation", "revisit", "waypoint", "converge"];
6601
6835
  mcp = new Hono;
6602
6836
  mcp.use("*", async (c, next) => {
6603
6837
  await next();
@@ -6695,21 +6929,23 @@ function evaluateEditGuard(input) {
6695
6929
  return null;
6696
6930
  if (!input.filePath)
6697
6931
  return null;
6932
+ const target = resolve7(input.worktreePath, input.filePath);
6698
6933
  if (!input.featureSlug) {
6699
6934
  return {
6700
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."
6701
6936
  };
6702
6937
  }
6703
6938
  const docs = featureDocsRel(input.featureSlug);
6704
- const docsDir = resolve7(input.worktreePath, docs);
6705
- const target = resolve7(input.worktreePath, input.filePath);
6706
- const rel = relative2(docsDir, target);
6707
- if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel))
6939
+ if (within(resolve7(input.worktreePath, docs), target))
6708
6940
  return null;
6709
6941
  return {
6710
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."
6711
6943
  };
6712
6944
  }
6945
+ function within(dir, target) {
6946
+ const rel = relative2(dir, target);
6947
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
6948
+ }
6713
6949
  function editDenyResponse(denial) {
6714
6950
  return {
6715
6951
  hookSpecificOutput: {
@@ -7285,6 +7521,28 @@ var init_update_check = __esm(() => {
7285
7521
  NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
7286
7522
  });
7287
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
+
7288
7546
  // src/trpc/context.ts
7289
7547
  import { initTRPC } from "@trpc/server";
7290
7548
  var t, errorMapping, router, publicProcedure, createCallerFactory;
@@ -7345,13 +7603,16 @@ function serverUrlFor(config2) {
7345
7603
  function noCodeRule(docs) {
7346
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.";
7347
7605
  }
7348
- 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) {
7349
7610
  if (kind === "waypoint")
7350
7611
  return renderWaypointPrompt(feature, waypoint);
7351
7612
  if (kind === "converge")
7352
7613
  return renderConvergePrompt(feature);
7353
7614
  if (kind === "revisit")
7354
- return renderRevisitPrompt(feature, lap);
7615
+ return renderRevisitPrompt(feature, lap, purpose);
7355
7616
  const docs = featureDocsRel(feature.slug);
7356
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.";
7357
7618
  return [
@@ -7480,7 +7741,7 @@ function renderConvergePrompt(feature) {
7480
7741
  ].join(`
7481
7742
  `);
7482
7743
  }
7483
- function renderRevisitPrompt(feature, lap) {
7744
+ function renderRevisitPrompt(feature, lap, purpose) {
7484
7745
  const docs = featureDocsRel(feature.slug);
7485
7746
  const lapIteration = lap ? [
7486
7747
  `## This is lap ${lap}`,
@@ -7550,7 +7811,7 @@ function renderRevisitPrompt(feature, lap) {
7550
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.",
7551
7812
  "- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
7552
7813
  "- Docs first, tickets second: capture the decision prose before any ticket surgery.",
7553
- noCodeRule(docs),
7814
+ purpose === "resolve-conflict" ? conflictResolutionRule() : noCodeRule(docs),
7554
7815
  "",
7555
7816
  "## Your task",
7556
7817
  "Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
@@ -7708,7 +7969,8 @@ function renderProjectPrompt(brief) {
7708
7969
  "- `get_project_context()` \u2014 the project, its charter, its live ADRs, and a one-line",
7709
7970
  " index of every feature.",
7710
7971
  "- `get_work_record({ featureSlug? | seam? })` \u2014 what features actually did: tickets by",
7711
- " 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.",
7712
7974
  "- `record_event({ type, message })` \u2014 drop a note on the project timeline.",
7713
7975
  "",
7714
7976
  "Every merged feature's docs are already on disk in this worktree \u2014 read them with your",
@@ -7757,13 +8019,13 @@ function renderMcpConfig(session, config2) {
7757
8019
  };
7758
8020
  }
7759
8021
  async function writeSessionArtifacts(input) {
7760
- const { session, feature, config: config2, waypoint, prepare, projectBrief, lap } = input;
8022
+ const { session, feature, config: config2, waypoint, prepare, projectBrief, lap, purpose } = input;
7761
8023
  const dir = sessionDir(session.id);
7762
8024
  mkdirSync7(dir, { recursive: true });
7763
8025
  const systemPromptPath = join13(dir, "system-prompt.md");
7764
8026
  const settingsPath = join13(dir, "settings.json");
7765
8027
  const mcpConfigPath = join13(dir, "mcp.json");
7766
- const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
8028
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap, purpose) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
7767
8029
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
7768
8030
  })();
7769
8031
  writeFileSync3(systemPromptPath, systemPrompt, "utf8");
@@ -7886,8 +8148,10 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
7886
8148
  }
7887
8149
  }
7888
8150
  function sessionFinished(ctx, feature, session) {
7889
- if (session.kind !== "waypoint")
8151
+ if (session.kind === "ideation" || session.kind === "converge")
7890
8152
  return feature.mapped;
8153
+ if (session.kind !== "waypoint")
8154
+ return false;
7891
8155
  const own = listByFeature(ctx, feature.id).find((w) => w.lastSessionId === session.id);
7892
8156
  return !!own && (own.status === "resolved" || own.status === "dropped");
7893
8157
  }
@@ -7926,6 +8190,7 @@ async function ensureWorktree(ctx, project, feature) {
7926
8190
  }
7927
8191
  async function launchSession(ctx, input, opts = {}) {
7928
8192
  const feature = getFeatureRow(ctx, input.featureId);
8193
+ requireNotDraft(feature);
7929
8194
  const project = projectForFeature(ctx, feature);
7930
8195
  const worktreePath = await ensureWorktree(ctx, project, feature);
7931
8196
  const session = createSessionRow(ctx, {
@@ -8008,7 +8273,8 @@ async function launchSession(ctx, input, opts = {}) {
8008
8273
  project,
8009
8274
  config: ctx.config,
8010
8275
  waypoint,
8011
- lap: plan.lap
8276
+ lap: plan.lap,
8277
+ purpose: input.purpose
8012
8278
  });
8013
8279
  const serverUrl = serverUrlFor(ctx.config);
8014
8280
  const buildInput = {
@@ -8108,7 +8374,7 @@ async function launchProjectSession(ctx, input, opts = {}) {
8108
8374
  if (live) {
8109
8375
  throw new GateError(`a project session is already open for ${project.name} \u2014 resume or end it first`);
8110
8376
  }
8111
- const worktreePath = await ensureProjectWorktree(project);
8377
+ const worktreePath = await ensureProjectWorktree(project, (res) => reportProjectLanding(ctx, project, res, { retried: true }));
8112
8378
  const session = createSessionRow(ctx, {
8113
8379
  projectId: project.id,
8114
8380
  kind: "project",
@@ -8179,6 +8445,7 @@ async function buildPrepareBrief(ctx, project) {
8179
8445
  }
8180
8446
  async function workWaypoint(ctx, input, opts = {}) {
8181
8447
  const feature = getFeatureRow(ctx, input.featureId);
8448
+ requireNotDraft(feature);
8182
8449
  if (!feature.mapped) {
8183
8450
  throw new GateError(`feature ${feature.slug} is not mapped \u2014 it has no waypoints to work`);
8184
8451
  }
@@ -8199,6 +8466,7 @@ async function workWaypoint(ctx, input, opts = {}) {
8199
8466
  }
8200
8467
  async function converge(ctx, input, opts = {}) {
8201
8468
  const feature = getFeatureRow(ctx, input.featureId);
8469
+ requireNotDraft(feature);
8202
8470
  if (!feature.mapped) {
8203
8471
  throw new GateError(`feature ${feature.slug} is not mapped \u2014 convergence is only for mapped features`);
8204
8472
  }
@@ -8297,6 +8565,8 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
8297
8565
  opts: { cwd: worktreePath, env, cols: 80, rows: 24, useConpty: true },
8298
8566
  onExit: ({ exitCode }) => handlePtyExit(ctx, feature, session, meta, exitCode)
8299
8567
  });
8568
+ if (feature)
8569
+ startDocsWatch(ctx, feature);
8300
8570
  emitForSession(ctx, session, {
8301
8571
  type: "session.launched",
8302
8572
  message: "embedded terminal spawned",
@@ -8323,10 +8593,12 @@ var init_launcher = __esm(() => {
8323
8593
  init_errors();
8324
8594
  init_end_session();
8325
8595
  init_registry();
8596
+ init_docs_watch();
8326
8597
  init_events();
8327
8598
  init_gates();
8328
8599
  init_git();
8329
8600
  init_repo();
8601
+ init_features();
8330
8602
  init_tickets();
8331
8603
  init_waypoints();
8332
8604
  init_runner();
@@ -8348,6 +8620,74 @@ var init_launcher = __esm(() => {
8348
8620
  ];
8349
8621
  });
8350
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
+
8351
8691
  // src/trpc/routers/feature.ts
8352
8692
  import * as z6 from "zod";
8353
8693
  var gateId, featureRouter;
@@ -8359,6 +8699,7 @@ var init_feature = __esm(() => {
8359
8699
  init_features();
8360
8700
  init_gates();
8361
8701
  init_git();
8702
+ init_outcome();
8362
8703
  init_repo();
8363
8704
  init_context();
8364
8705
  gateId = z6.enum(["G1", "G2", "G3", "G4", "G5"]);
@@ -8367,8 +8708,10 @@ var init_feature = __esm(() => {
8367
8708
  projectId: z6.string(),
8368
8709
  title: z6.string().min(1),
8369
8710
  oneLiner: z6.string(),
8370
- baseBranch: z6.string().optional()
8711
+ baseBranch: z6.string().optional(),
8712
+ draft: z6.boolean().optional()
8371
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 })),
8372
8715
  quickChange: publicProcedure.input(z6.object({
8373
8716
  projectId: z6.string(),
8374
8717
  title: z6.string().min(1),
@@ -8402,6 +8745,7 @@ var init_feature = __esm(() => {
8402
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 })),
8403
8746
  testDrive: publicProcedure.input(z6.object({ featureId: z6.string(), action: z6.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
8404
8747
  const feature = getFeatureRow(ctx, input.featureId);
8748
+ requireNotDraft(feature);
8405
8749
  const project = projectForFeature(ctx, feature);
8406
8750
  return testDrive(ctx, project, feature, input.action);
8407
8751
  }),
@@ -8412,10 +8756,12 @@ var init_feature = __esm(() => {
8412
8756
  }),
8413
8757
  merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
8414
8758
  const feature = getFeatureRow(ctx, input.featureId);
8759
+ requireNotDraft(feature);
8415
8760
  const project = projectForFeature(ctx, feature);
8416
8761
  if (activeTestDriveFeatureId() === feature.id) {
8417
8762
  await testDrive(ctx, project, feature, "stop");
8418
8763
  }
8764
+ await promoteOutcomeDoc(ctx, project, feature);
8419
8765
  const res = await mergeFeature(project, feature);
8420
8766
  if (res.ok) {
8421
8767
  setPhase(ctx, input.featureId, "shipped", "feature.shipped", `merged to ${res.target}`);
@@ -8435,7 +8781,7 @@ var init_feature = __esm(() => {
8435
8781
  // src/services/fsbrowse.ts
8436
8782
  import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
8437
8783
  import { homedir as homedir2 } from "os";
8438
- import { basename, dirname as dirname8, isAbsolute as isAbsolute2, join as join15, 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";
8439
8785
  function expandPath(input, home = homedir2()) {
8440
8786
  const trimmed = input.trim();
8441
8787
  if (trimmed === "")
@@ -8444,12 +8790,12 @@ function expandPath(input, home = homedir2()) {
8444
8790
  if (out === "~")
8445
8791
  out = home;
8446
8792
  else if (out.startsWith("~/") || out.startsWith("~\\"))
8447
- out = join15(home, out.slice(2));
8793
+ out = join16(home, out.slice(2));
8448
8794
  return resolve10(out);
8449
8795
  }
8450
8796
  function looksLikeRepo(dir) {
8451
8797
  try {
8452
- return existsSync15(join15(dir, ".git"));
8798
+ return existsSync15(join16(dir, ".git"));
8453
8799
  } catch {
8454
8800
  return false;
8455
8801
  }
@@ -8471,7 +8817,7 @@ function listRoots(home = homedir2()) {
8471
8817
  roots.push({ label: "Home", path: home, kind: "home" });
8472
8818
  const seen = new Set(roots.map(pathKey));
8473
8819
  for (const name of COMMON_DIRS) {
8474
- const candidate = join15(home, name);
8820
+ const candidate = join16(home, name);
8475
8821
  const key = pathKey({ path: candidate });
8476
8822
  if (seen.has(key) || !existsSync15(candidate))
8477
8823
  continue;
@@ -8532,7 +8878,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
8532
8878
  continue;
8533
8879
  if (!showHidden && name.startsWith("."))
8534
8880
  continue;
8535
- const path = join15(dir, name);
8881
+ const path = join16(dir, name);
8536
8882
  const isSymlink = dirent.isSymbolicLink();
8537
8883
  if (isSymlink && !showHidden)
8538
8884
  continue;
@@ -8570,7 +8916,7 @@ var init_fsbrowse = __esm(() => {
8570
8916
 
8571
8917
  // src/services/projects.ts
8572
8918
  import { existsSync as existsSync16 } from "fs";
8573
- import { basename as basename2, join as join16 } from "path";
8919
+ import { basename as basename2, join as join17 } from "path";
8574
8920
  import { eq as eq12, isNull } from "drizzle-orm";
8575
8921
  function listProjects(ctx) {
8576
8922
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
@@ -8594,7 +8940,7 @@ async function openProject(ctx, rawPath) {
8594
8940
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
8595
8941
  if (existing) {
8596
8942
  ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq12(projects.id, existing.id)).run();
8597
- const reopened = { ...existing, mainBranch, repoPath };
8943
+ const reopened = { ...existing, mainBranch, repoPath, closedAt: undefined };
8598
8944
  emitProject(ctx, existing.id, {
8599
8945
  type: "project.opened",
8600
8946
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
@@ -8651,7 +8997,7 @@ async function assertRepoTolerant(ctx, repoPath) {
8651
8997
  } catch (e) {
8652
8998
  if (!isNotImplemented(e))
8653
8999
  throw e;
8654
- if (!existsSync16(join16(repoPath, ".git"))) {
9000
+ if (!existsSync16(join17(repoPath, ".git"))) {
8655
9001
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
8656
9002
  }
8657
9003
  }
@@ -8716,7 +9062,8 @@ var init_run = __esm(() => {
8716
9062
  runRouter = router({
8717
9063
  get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
8718
9064
  agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
8719
- 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);
8720
9067
  cancelRun(input.runId);
8721
9068
  return { ok: true };
8722
9069
  })
@@ -8724,7 +9071,7 @@ var init_run = __esm(() => {
8724
9071
  });
8725
9072
 
8726
9073
  // src/services/settings.ts
8727
- import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
9074
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
8728
9075
  import { dirname as dirname9 } from "path";
8729
9076
  import * as z9 from "zod";
8730
9077
  import { eq as eq13 } from "drizzle-orm";
@@ -8871,8 +9218,8 @@ function updateSettings(ctx, input, io = {}) {
8871
9218
  function writeGlobal(configFile, configKey, value) {
8872
9219
  const raw = readRawConfig(configFile);
8873
9220
  raw[configKey] = value;
8874
- mkdirSync8(dirname9(configFile), { recursive: true });
8875
- writeFileSync4(configFile, `${JSON.stringify(raw, null, 2)}
9221
+ mkdirSync9(dirname9(configFile), { recursive: true });
9222
+ writeFileSync5(configFile, `${JSON.stringify(raw, null, 2)}
8876
9223
  `);
8877
9224
  }
8878
9225
  function updateStepModel(ctx, input, configFile, io) {
@@ -8910,8 +9257,8 @@ function writeStepModel(configFile, step, value) {
8910
9257
  else
8911
9258
  stepModels[step] = value;
8912
9259
  raw.stepModels = stepModels;
8913
- mkdirSync8(dirname9(configFile), { recursive: true });
8914
- writeFileSync4(configFile, `${JSON.stringify(raw, null, 2)}
9260
+ mkdirSync9(dirname9(configFile), { recursive: true });
9261
+ writeFileSync5(configFile, `${JSON.stringify(raw, null, 2)}
8915
9262
  `);
8916
9263
  }
8917
9264
  function field(view, key) {
@@ -9148,11 +9495,11 @@ async function gitIdentityProbe(exec, cwd) {
9148
9495
  async function containerRuntimeProbe(exec) {
9149
9496
  const id = "container-runtime";
9150
9497
  const label = "Container runtime (Docker / Podman)";
9151
- const docker3 = await exec("docker", ["--version"]);
9152
- if (docker3.ok && docker3.code === 0) {
9498
+ const docker2 = await exec("docker", ["--version"]);
9499
+ if (docker2.ok && docker2.code === 0) {
9153
9500
  const info = await exec("docker", ["info"]);
9154
9501
  if (info.ok && info.code === 0) {
9155
- 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" };
9156
9503
  }
9157
9504
  return {
9158
9505
  id,
@@ -9288,8 +9635,8 @@ var init_system_exec = __esm(() => {
9288
9635
  });
9289
9636
 
9290
9637
  // src/services/setup.ts
9291
- import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync9, readdirSync as readdirSync6, writeFileSync as writeFileSync5 } from "fs";
9292
- import { dirname as dirname10, join as join17 } from "path";
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";
9293
9640
  import { fileURLToPath as fileURLToPath7 } from "url";
9294
9641
  async function writeGitIdentity(exec, identity) {
9295
9642
  const name = identity.name.trim();
@@ -9367,11 +9714,11 @@ function resolveSandcastleBin() {
9367
9714
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
9368
9715
  let dir = dirname10(fileURLToPath7(mainUrl));
9369
9716
  for (let hops = 0;hops < 6; hops++) {
9370
- const manifestPath = join17(dir, "package.json");
9717
+ const manifestPath = join18(dir, "package.json");
9371
9718
  if (existsSync18(manifestPath)) {
9372
9719
  const manifest = JSON.parse(readFileSync9(manifestPath, "utf8"));
9373
9720
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
9374
- return join17(dir, manifest.bin.sandcastle);
9721
+ return join18(dir, manifest.bin.sandcastle);
9375
9722
  }
9376
9723
  }
9377
9724
  const parent = dirname10(dir);
@@ -9399,18 +9746,18 @@ function sandcastleTemplateDir() {
9399
9746
  return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
9400
9747
  }
9401
9748
  function scaffoldSandcastleConfig(templateDir, targetDir) {
9402
- const dir = join17(targetDir, ".sandcastle");
9749
+ const dir = join18(targetDir, ".sandcastle");
9403
9750
  if (existsSync18(dir))
9404
9751
  return { scaffolded: false, dir };
9405
- mkdirSync9(dir, { recursive: true });
9752
+ mkdirSync10(dir, { recursive: true });
9406
9753
  for (const name of readdirSync6(templateDir)) {
9407
- copyFileSync(join17(templateDir, name), join17(dir, name));
9754
+ copyFileSync(join18(templateDir, name), join18(dir, name));
9408
9755
  }
9409
9756
  return { scaffolded: true, dir };
9410
9757
  }
9411
9758
  function prepareSandboxBuildContext() {
9412
9759
  const target = sandboxBuildDir();
9413
- mkdirSync9(target, { recursive: true });
9760
+ mkdirSync10(target, { recursive: true });
9414
9761
  scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
9415
9762
  return target;
9416
9763
  }
@@ -9448,8 +9795,8 @@ function fileAfkTokenIo(exec) {
9448
9795
  return {
9449
9796
  read: () => existsSync18(path) ? readFileSync9(path, "utf8") : "",
9450
9797
  write: (content) => {
9451
- mkdirSync9(dirname10(path), { recursive: true });
9452
- writeFileSync5(path, content, "utf8");
9798
+ mkdirSync10(dirname10(path), { recursive: true });
9799
+ writeFileSync6(path, content, "utf8");
9453
9800
  },
9454
9801
  verify: createTokenVerifier(exec)
9455
9802
  };
@@ -9515,11 +9862,11 @@ var init_system = __esm(() => {
9515
9862
  });
9516
9863
 
9517
9864
  // src/services/test-notes.ts
9518
- import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync6 } from "fs";
9865
+ import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync7 } from "fs";
9519
9866
  import { dirname as dirname11 } from "path";
9520
9867
  import { asc as asc4, eq as eq14 } from "drizzle-orm";
9521
9868
  function rowToNote(row) {
9522
- return {
9869
+ return TestNote.parse({
9523
9870
  id: row.id,
9524
9871
  featureId: row.featureId,
9525
9872
  lap: row.lap,
@@ -9528,7 +9875,7 @@ function rowToNote(row) {
9528
9875
  ticketId: row.ticketId ?? undefined,
9529
9876
  createdAt: row.createdAt,
9530
9877
  updatedAt: row.updatedAt
9531
- };
9878
+ });
9532
9879
  }
9533
9880
  function getNote(ctx, id) {
9534
9881
  const row = ctx.db.select().from(testNotes).where(eq14(testNotes.id, id)).get();
@@ -9661,8 +10008,8 @@ function renderTestNotes(ctx, feature) {
9661
10008
  lines.push(noteLine(ctx, note));
9662
10009
  }
9663
10010
  const path = featureDocPath(projectForFeature(ctx, feature), feature, "test-notes.md");
9664
- mkdirSync10(dirname11(path), { recursive: true });
9665
- writeFileSync6(path, `${lines.join(`
10011
+ mkdirSync11(dirname11(path), { recursive: true });
10012
+ writeFileSync7(path, `${lines.join(`
9666
10013
  `)}
9667
10014
  `, "utf8");
9668
10015
  }
@@ -9853,6 +10200,8 @@ async function startServer() {
9853
10200
  }
9854
10201
  const server = Bun.serve({
9855
10202
  port: config2.serverPort,
10203
+ hostname: SERVE_HOSTNAME,
10204
+ idleTimeout: SERVE_IDLE_TIMEOUT_SECONDS,
9856
10205
  fetch(req, srv) {
9857
10206
  if (tryUpgradeTerminal(req, srv))
9858
10207
  return;
@@ -9866,11 +10215,11 @@ async function startServer() {
9866
10215
  const g = globalThis;
9867
10216
  if (!g[SHUTDOWN_KEY]) {
9868
10217
  g[SHUTDOWN_KEY] = true;
9869
- const shutdown = () => {
9870
- ptyRegistry().killAll();
9871
- server.stop();
9872
- process.exit(0);
9873
- };
10218
+ const shutdown = createShutdown({
10219
+ killAllTrees: () => ptyRegistry().killAllTrees(),
10220
+ stop: () => server.stop(),
10221
+ exit: (code) => process.exit(code)
10222
+ });
9874
10223
  process.on("SIGINT", shutdown);
9875
10224
  process.on("SIGTERM", shutdown);
9876
10225
  }