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/index.js CHANGED
@@ -178,8 +178,11 @@ function loadConfig(env = process.env) {
178
178
  }
179
179
  }
180
180
  const overrides = {};
181
- if (env.RUNCASTLE_SERVER_PORT)
182
- overrides.serverPort = Number(env.RUNCASTLE_SERVER_PORT);
181
+ const num = (key, raw) => {
182
+ if (raw !== undefined && raw !== "")
183
+ overrides[key] = Number(raw);
184
+ };
185
+ num("serverPort", env.RUNCASTLE_SERVER_PORT);
183
186
  if (env.RUNCASTLE_MODEL)
184
187
  overrides.model = env.RUNCASTLE_MODEL;
185
188
  if (env.RUNCASTLE_SMOKE_MODEL)
@@ -192,21 +195,12 @@ function loadConfig(env = process.env) {
192
195
  overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
193
196
  if (env.RUNCASTLE_SANDBOX_IMAGE)
194
197
  overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
195
- if (env.RUNCASTLE_BURN_CONCURRENCY) {
196
- overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
197
- }
198
- if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
199
- overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
200
- }
201
- if (env.RUNCASTLE_BURN_ATTEMPTS) {
202
- overrides.burnAttempts = Number(env.RUNCASTLE_BURN_ATTEMPTS);
203
- }
204
- if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
205
- overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
206
- }
207
- if (env.RUNCASTLE_BURN_CPUS)
208
- overrides.burnCpus = Number(env.RUNCASTLE_BURN_CPUS);
209
- if (env.RUNCASTLE_BURN_GUARD !== undefined) {
198
+ num("burnConcurrency", env.RUNCASTLE_BURN_CONCURRENCY);
199
+ num("burnMaxIterations", env.RUNCASTLE_BURN_MAX_ITERATIONS);
200
+ num("burnAttempts", env.RUNCASTLE_BURN_ATTEMPTS);
201
+ num("burnConflictAttempts", env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
202
+ num("burnCpus", env.RUNCASTLE_BURN_CPUS);
203
+ if (env.RUNCASTLE_BURN_GUARD !== undefined && env.RUNCASTLE_BURN_GUARD !== "") {
210
204
  overrides.burnGuard = env.RUNCASTLE_BURN_GUARD !== "0" && env.RUNCASTLE_BURN_GUARD !== "false";
211
205
  }
212
206
  if (env.RUNCASTLE_SETUP_COMMAND)
@@ -235,6 +229,7 @@ function ensureDataDir() {
235
229
  mkdirSync(join2(root, "sessions"), { recursive: true });
236
230
  mkdirSync(join2(root, "worktrees"), { recursive: true });
237
231
  }
232
+ var SERVE_HOSTNAME = "127.0.0.1", SERVE_IDLE_TIMEOUT_SECONDS = 120;
238
233
  var init_config2 = __esm(() => {
239
234
  init_config_load();
240
235
  init_paths();
@@ -249,7 +244,10 @@ var init_ids = () => {};
249
244
 
250
245
  // ../core/src/schemas.ts
251
246
  import * as z2 from "zod";
252
- 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;
247
+ function isProjectSessionKind(kind) {
248
+ return PROJECT_SESSION_KINDS.includes(kind);
249
+ }
250
+ 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;
253
251
  var init_schemas = __esm(() => {
254
252
  Phase = z2.enum([
255
253
  "ideation",
@@ -269,13 +267,14 @@ var init_schemas = __esm(() => {
269
267
  "prepare",
270
268
  "project"
271
269
  ]);
270
+ PROJECT_SESSION_KINDS = ["prepare", "project"];
272
271
  SessionPurpose = z2.enum(["resolve-conflict"]);
273
272
  MergeBranchPair = z2.object({
274
273
  mergeFrom: z2.string(),
275
274
  mergeInto: z2.string()
276
275
  });
277
276
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
278
- FeatureStatus = z2.enum(["active", "shipped", "archived"]);
277
+ FeatureStatus = z2.enum(["draft", "active", "shipped", "archived"]);
279
278
  SessionStatus = z2.enum(["launching", "live", "ended"]);
280
279
  TicketInput = z2.object({
281
280
  title: z2.string(),
@@ -294,7 +293,8 @@ var init_schemas = __esm(() => {
294
293
  commits: z2.array(z2.string()),
295
294
  error: z2.string().optional(),
296
295
  attemptBranch: z2.string().optional(),
297
- conflictFiles: z2.array(z2.string()).optional()
296
+ conflictFiles: z2.array(z2.string()).optional(),
297
+ digest: z2.string().optional()
298
298
  });
299
299
  WaypointType = z2.enum(["grilling", "research", "prototype", "task"]);
300
300
  WaypointStatus = z2.enum(["open", "claimed", "resolved", "dropped"]);
@@ -359,7 +359,8 @@ var init_schemas = __esm(() => {
359
359
  dbResetCommand: z2.string().optional(),
360
360
  driveSetupCommand: z2.string().optional(),
361
361
  driveStopCommand: z2.string().optional(),
362
- driveEnv: z2.string().optional()
362
+ driveEnv: z2.string().optional(),
363
+ closedAt: z2.number().optional()
363
364
  });
364
365
  ProjectFinding = z2.object({
365
366
  key: PreparedKey,
@@ -377,6 +378,7 @@ var init_schemas = __esm(() => {
377
378
  slug: z2.string(),
378
379
  title: z2.string(),
379
380
  oneLiner: z2.string(),
381
+ brief: z2.string().optional(),
380
382
  mapped: z2.boolean(),
381
383
  lap: z2.number(),
382
384
  phase: Phase,
@@ -405,7 +407,8 @@ var init_schemas = __esm(() => {
405
407
  status: RunStatus,
406
408
  startedAt: z2.number(),
407
409
  endedAt: z2.number().optional(),
408
- summary: z2.string().optional()
410
+ summary: z2.string().optional(),
411
+ digest: z2.string().optional()
409
412
  });
410
413
  SettingSource = z2.enum(["env", "project", "file", "default"]);
411
414
  SettingScope = z2.enum(["global", "project"]);
@@ -580,7 +583,7 @@ var init_pipeline = __esm(() => {
580
583
  });
581
584
 
582
585
  // ../core/src/db-schema.ts
583
- import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
586
+ import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
584
587
  var projects, projectFindings, features, sessions, tickets, testNotes, waypoints, runs, events, gateOverrides;
585
588
  var init_db_schema = __esm(() => {
586
589
  projects = sqliteTable("projects", {
@@ -616,6 +619,7 @@ var init_db_schema = __esm(() => {
616
619
  slug: text("slug").notNull(),
617
620
  title: text("title").notNull(),
618
621
  oneLiner: text("one_liner").notNull(),
622
+ brief: text("brief"),
619
623
  mapped: integer("mapped", { mode: "boolean" }).notNull().default(false),
620
624
  lap: integer("lap").notNull().default(1),
621
625
  phase: text("phase").notNull().$type(),
@@ -653,7 +657,8 @@ var init_db_schema = __esm(() => {
653
657
  commits: text("commits", { mode: "json" }).notNull().$type(),
654
658
  error: text("error"),
655
659
  attemptBranch: text("attempt_branch"),
656
- conflictFiles: text("conflict_files", { mode: "json" }).$type()
660
+ conflictFiles: text("conflict_files", { mode: "json" }).$type(),
661
+ digest: text("digest")
657
662
  });
658
663
  testNotes = sqliteTable("test_notes", {
659
664
  id: text("id").primaryKey(),
@@ -686,7 +691,8 @@ var init_db_schema = __esm(() => {
686
691
  status: text("status").notNull().$type(),
687
692
  startedAt: integer("started_at").notNull(),
688
693
  endedAt: integer("ended_at"),
689
- summary: text("summary")
694
+ summary: text("summary"),
695
+ digest: text("digest")
690
696
  });
691
697
  events = sqliteTable("events", {
692
698
  id: integer("id").primaryKey({ autoIncrement: true }),
@@ -699,7 +705,10 @@ var init_db_schema = __esm(() => {
699
705
  type: text("type").notNull(),
700
706
  message: text("message").notNull(),
701
707
  data: text("data", { mode: "json" })
702
- });
708
+ }, (t) => [
709
+ index("events_project_id_id_idx").on(t.projectId, t.id),
710
+ index("events_feature_id_id_idx").on(t.featureId, t.id)
711
+ ]);
703
712
  gateOverrides = sqliteTable("gate_overrides", {
704
713
  id: integer("id").primaryKey({ autoIncrement: true }),
705
714
  featureId: text("feature_id").notNull(),
@@ -1279,6 +1288,16 @@ var init_ring_buffer = __esm(() => {
1279
1288
  });
1280
1289
 
1281
1290
  // src/pty/registry.ts
1291
+ function killTreeBounded(pty) {
1292
+ return new Promise((resolve3) => {
1293
+ const timer = setTimeout(resolve3, KILL_TREE_TIMEOUT_MS);
1294
+ pty.killTree().catch(() => {}).then(() => {
1295
+ clearTimeout(timer);
1296
+ resolve3();
1297
+ });
1298
+ });
1299
+ }
1300
+
1282
1301
  class PtyRegistry {
1283
1302
  entries = new Map;
1284
1303
  create(input) {
@@ -1336,18 +1355,31 @@ class PtyRegistry {
1336
1355
  const entry = this.entries.get(sessionId);
1337
1356
  if (!entry)
1338
1357
  return false;
1339
- entry.pty.kill();
1358
+ this.tearDown(entry);
1359
+ return true;
1360
+ }
1361
+ async killTree(sessionId) {
1362
+ const entry = this.entries.get(sessionId);
1363
+ if (!entry)
1364
+ return false;
1365
+ await this.tearDown(entry);
1340
1366
  return true;
1341
1367
  }
1342
1368
  remove(sessionId) {
1343
1369
  this.entries.delete(sessionId);
1344
1370
  }
1345
- killAll() {
1346
- for (const entry of this.entries.values()) {
1347
- if (!entry.exited)
1348
- entry.pty.kill();
1349
- }
1371
+ async killAllTrees() {
1372
+ const all = [...this.entries.values()];
1350
1373
  this.entries.clear();
1374
+ await Promise.allSettled(all.map((entry) => this.tearDown(entry)));
1375
+ }
1376
+ async tearDown(entry) {
1377
+ if (entry.exited)
1378
+ return;
1379
+ await killTreeBounded(entry.pty);
1380
+ try {
1381
+ entry.pty.kill();
1382
+ } catch {}
1351
1383
  }
1352
1384
  ids() {
1353
1385
  return [...this.entries.keys()];
@@ -1359,7 +1391,7 @@ function ptyRegistry() {
1359
1391
  g[REGISTRY_KEY] = new PtyRegistry;
1360
1392
  return g[REGISTRY_KEY];
1361
1393
  }
1362
- var REGISTRY_KEY;
1394
+ var KILL_TREE_TIMEOUT_MS = 5000, REGISTRY_KEY;
1363
1395
  var init_registry = __esm(() => {
1364
1396
  init_pty();
1365
1397
  init_ring_buffer();
@@ -1440,7 +1472,7 @@ var init_bus = __esm(() => {
1440
1472
  // src/services/events.ts
1441
1473
  import { and, asc, desc, eq, gt, max } from "drizzle-orm";
1442
1474
  function rowToEvent(row) {
1443
- return {
1475
+ return EventRow.parse({
1444
1476
  id: row.id,
1445
1477
  projectId: row.projectId,
1446
1478
  featureId: row.featureId ?? undefined,
@@ -1450,7 +1482,7 @@ function rowToEvent(row) {
1450
1482
  type: row.type,
1451
1483
  message: row.message,
1452
1484
  data: row.data ?? undefined
1453
- };
1485
+ });
1454
1486
  }
1455
1487
  function projectIdForFeature(ctx, featureId) {
1456
1488
  const row = ctx.db.select({ projectId: features.projectId }).from(features).where(eq(features.id, featureId)).get();
@@ -1521,6 +1553,7 @@ function listByProject(ctx, projectId, afterId = 0) {
1521
1553
  return rows.map(rowToEvent);
1522
1554
  }
1523
1555
  var init_events = __esm(() => {
1556
+ init_src();
1524
1557
  init_schema();
1525
1558
  init_errors();
1526
1559
  init_bus();
@@ -1529,12 +1562,13 @@ var init_events = __esm(() => {
1529
1562
  // src/services/repo.ts
1530
1563
  import { and as and2, desc as desc2, eq as eq2 } from "drizzle-orm";
1531
1564
  function rowToFeature(row) {
1532
- return {
1565
+ return Feature.parse({
1533
1566
  id: row.id,
1534
1567
  projectId: row.projectId,
1535
1568
  slug: row.slug,
1536
1569
  title: row.title,
1537
1570
  oneLiner: row.oneLiner,
1571
+ brief: row.brief ?? undefined,
1538
1572
  mapped: row.mapped,
1539
1573
  lap: row.lap,
1540
1574
  phase: row.phase,
@@ -1542,10 +1576,10 @@ function rowToFeature(row) {
1542
1576
  baseBranch: row.baseBranch ?? undefined,
1543
1577
  status: row.status,
1544
1578
  createdAt: row.createdAt
1545
- };
1579
+ });
1546
1580
  }
1547
1581
  function rowToProject(row) {
1548
- return {
1582
+ return Project.parse({
1549
1583
  id: row.id,
1550
1584
  name: row.name,
1551
1585
  repoPath: row.repoPath,
@@ -1558,22 +1592,24 @@ function rowToProject(row) {
1558
1592
  dbResetCommand: row.dbResetCommand ?? undefined,
1559
1593
  driveSetupCommand: row.driveSetupCommand ?? undefined,
1560
1594
  driveStopCommand: row.driveStopCommand ?? undefined,
1561
- driveEnv: row.driveEnv ?? undefined
1562
- };
1595
+ driveEnv: row.driveEnv ?? undefined,
1596
+ closedAt: row.closedAt ?? undefined
1597
+ });
1563
1598
  }
1564
1599
  function rowToRun(row) {
1565
- return {
1600
+ return Run.parse({
1566
1601
  id: row.id,
1567
1602
  featureId: row.featureId,
1568
1603
  workflow: row.workflow,
1569
1604
  status: row.status,
1570
1605
  startedAt: row.startedAt,
1571
1606
  endedAt: row.endedAt ?? undefined,
1572
- summary: row.summary ?? undefined
1573
- };
1607
+ summary: row.summary ?? undefined,
1608
+ digest: row.digest ?? undefined
1609
+ });
1574
1610
  }
1575
1611
  function rowToSession(row) {
1576
- return {
1612
+ return SessionRow.parse({
1577
1613
  id: row.id,
1578
1614
  featureId: row.featureId ?? undefined,
1579
1615
  projectId: row.projectId ?? undefined,
@@ -1585,7 +1621,7 @@ function rowToSession(row) {
1585
1621
  status: row.status,
1586
1622
  awaitingInput: row.awaitingInput,
1587
1623
  worktreePath: row.worktreePath
1588
- };
1624
+ });
1589
1625
  }
1590
1626
  function tryGetFeature(ctx, id) {
1591
1627
  const row = ctx.db.select().from(features).where(eq2(features.id, id)).get();
@@ -1661,6 +1697,7 @@ function setFeatureStatus(ctx, featureId, status) {
1661
1697
  return { ...current, status };
1662
1698
  }
1663
1699
  var init_repo = __esm(() => {
1700
+ init_src();
1664
1701
  init_schema();
1665
1702
  init_errors();
1666
1703
  init_events();
@@ -1669,7 +1706,7 @@ var init_repo = __esm(() => {
1669
1706
  // src/services/waypoints.ts
1670
1707
  import { and as and3, asc as asc2, eq as eq3 } from "drizzle-orm";
1671
1708
  function rowToWaypoint(row) {
1672
- return {
1709
+ return Waypoint.parse({
1673
1710
  id: row.id,
1674
1711
  featureId: row.featureId,
1675
1712
  seq: row.seq,
@@ -1682,7 +1719,7 @@ function rowToWaypoint(row) {
1682
1719
  claimedBy: row.claimedBy ?? undefined,
1683
1720
  lastSessionId: row.lastSessionId ?? undefined,
1684
1721
  summary: row.summary ?? undefined
1685
- };
1722
+ });
1686
1723
  }
1687
1724
  function listByFeature(ctx, featureId) {
1688
1725
  return ctx.db.select().from(waypoints).where(eq3(waypoints.featureId, featureId)).orderBy(asc2(waypoints.seq)).all().map(rowToWaypoint);
@@ -1798,6 +1835,9 @@ function releaseForSession(ctx, claimant) {
1798
1835
  }
1799
1836
  function resolve3(ctx, id, disposition, summary) {
1800
1837
  const wp = getWaypoint(ctx, id);
1838
+ if (isTerminal(wp)) {
1839
+ throw new InvalidInputError(`waypoint ${wp.seq} is already ${wp.status}`);
1840
+ }
1801
1841
  const before = listByFeature(ctx, wp.featureId);
1802
1842
  const bySeqBefore = new Map(before.map((w) => [w.seq, w]));
1803
1843
  ctx.db.update(waypoints).set({ status: disposition, summary, claimedBy: null }).where(eq3(waypoints.id, id)).run();
@@ -1834,6 +1874,91 @@ var init_waypoints = __esm(() => {
1834
1874
  TERMINAL = new Set(["resolved", "dropped"]);
1835
1875
  });
1836
1876
 
1877
+ // src/services/feature-docs.ts
1878
+ import { existsSync as existsSync5 } from "fs";
1879
+ import { join as join5 } from "path";
1880
+ function featureDocsDir(project, feature) {
1881
+ const relSegments = featureDocsRel(feature.slug).split("/");
1882
+ const worktree = worktreeDir(project.id, feature.slug);
1883
+ const base = existsSync5(worktree) ? worktree : project.repoPath;
1884
+ return join5(base, ...relSegments);
1885
+ }
1886
+ function featureDocPath(project, feature, fileName) {
1887
+ return join5(featureDocsDir(project, feature), fileName);
1888
+ }
1889
+ var init_feature_docs = __esm(() => {
1890
+ init_paths();
1891
+ });
1892
+
1893
+ // src/services/docs-watch.ts
1894
+ import { watch } from "fs";
1895
+ function startDocsWatch(ctx, feature) {
1896
+ if (watches.has(feature.id))
1897
+ return;
1898
+ try {
1899
+ const dir = featureDocsDir(projectForFeature(ctx, feature), feature);
1900
+ const watcher = watch(dir, { persistent: false }, (_event, filename) => {
1901
+ noteDocsChange(ctx, feature.id, typeof filename === "string" ? filename : null);
1902
+ });
1903
+ watcher.on("error", (err) => {
1904
+ console.error(`docs watcher failed for feature ${feature.id}`, err);
1905
+ stopDocsWatch(feature.id);
1906
+ });
1907
+ watches.set(feature.id, { watcher, timer: undefined, changed: new Set });
1908
+ } catch (err) {
1909
+ console.error(`docs watcher could not start for feature ${feature.id}`, err);
1910
+ }
1911
+ }
1912
+ function stopDocsWatch(featureId) {
1913
+ const entry = watches.get(featureId);
1914
+ if (!entry)
1915
+ return;
1916
+ watches.delete(featureId);
1917
+ if (entry.timer)
1918
+ clearTimeout(entry.timer);
1919
+ try {
1920
+ entry.watcher.close();
1921
+ } catch (err) {
1922
+ console.error(`docs watcher failed to close for feature ${featureId}`, err);
1923
+ }
1924
+ }
1925
+ function noteDocsChange(ctx, featureId, filename) {
1926
+ const entry = watches.get(featureId);
1927
+ if (!entry)
1928
+ return;
1929
+ if (filename !== null && !filename.endsWith(".md"))
1930
+ return;
1931
+ if (filename)
1932
+ entry.changed.add(filename);
1933
+ if (entry.timer)
1934
+ clearTimeout(entry.timer);
1935
+ entry.timer = setTimeout(() => emitDocsChanged(ctx, featureId), DOCS_WATCH_DEBOUNCE_MS);
1936
+ }
1937
+ function emitDocsChanged(ctx, featureId) {
1938
+ const entry = watches.get(featureId);
1939
+ if (!entry)
1940
+ return;
1941
+ entry.timer = undefined;
1942
+ const files = [...entry.changed].sort();
1943
+ entry.changed.clear();
1944
+ try {
1945
+ emit(ctx, featureId, {
1946
+ type: "docs.changed",
1947
+ message: files.length > 0 ? `docs changed: ${files.join(", ")}` : "docs changed",
1948
+ data: { files }
1949
+ });
1950
+ } catch (err) {
1951
+ console.error(`docs watcher failed to emit for feature ${featureId}`, err);
1952
+ }
1953
+ }
1954
+ var DOCS_WATCH_DEBOUNCE_MS = 300, watches;
1955
+ var init_docs_watch = __esm(() => {
1956
+ init_events();
1957
+ init_feature_docs();
1958
+ init_repo();
1959
+ watches = new Map;
1960
+ });
1961
+
1837
1962
  // src/pty/dev-pane.ts
1838
1963
  function drivePaneId(ownerId) {
1839
1964
  return `${DRIVE_PREFIX}${ownerId}`;
@@ -1894,24 +2019,12 @@ function devPaneLive(paneId) {
1894
2019
  const entry = ptyRegistry().get(paneId);
1895
2020
  return !!entry && !entry.exited;
1896
2021
  }
1897
- function killTreeBounded(pty) {
1898
- return new Promise((resolve4) => {
1899
- const timer = setTimeout(resolve4, KILL_TREE_TIMEOUT_MS);
1900
- pty.killTree().catch(() => {}).then(() => {
1901
- clearTimeout(timer);
1902
- resolve4();
1903
- });
1904
- });
1905
- }
1906
2022
  async function stopDevPane(paneId) {
1907
2023
  const reg = ptyRegistry();
1908
- const entry = reg.get(paneId);
1909
- if (entry && !entry.exited)
1910
- await killTreeBounded(entry.pty);
1911
- reg.kill(paneId);
2024
+ await reg.killTree(paneId);
1912
2025
  reg.remove(paneId);
1913
2026
  }
1914
- var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192, KILL_TREE_TIMEOUT_MS = 5000;
2027
+ var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
1915
2028
  var init_dev_pane = __esm(() => {
1916
2029
  init_events();
1917
2030
  init_registry();
@@ -1969,7 +2082,8 @@ function runDriveHook(command, opts) {
1969
2082
  cwd: opts.cwd,
1970
2083
  env: opts.env ?? process.env,
1971
2084
  windowsHide: true,
1972
- windowsVerbatimArguments: verbatim
2085
+ windowsVerbatimArguments: verbatim,
2086
+ detached: process.platform !== "win32"
1973
2087
  });
1974
2088
  } catch (err) {
1975
2089
  output = err instanceof Error ? err.message : String(err);
@@ -1978,7 +2092,11 @@ function runDriveHook(command, opts) {
1978
2092
  }
1979
2093
  timer = setTimeout(() => {
1980
2094
  timedOut = true;
1981
- child.kill();
2095
+ const pid = child.pid;
2096
+ if (pid === undefined)
2097
+ child.kill();
2098
+ else
2099
+ killProcessTree(pid).then(() => child.kill());
1982
2100
  const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
1983
2101
  grace.unref?.();
1984
2102
  }, timeoutMs);
@@ -2009,6 +2127,7 @@ function describeHookResult(command, result) {
2009
2127
  }
2010
2128
  var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
2011
2129
  var init_drive_hooks = __esm(() => {
2130
+ init_kill_tree();
2012
2131
  DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
2013
2132
  });
2014
2133
 
@@ -2225,9 +2344,9 @@ var init_findings = __esm(() => {
2225
2344
  });
2226
2345
 
2227
2346
  // src/services/git.ts
2228
- import { existsSync as existsSync5, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2347
+ import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2229
2348
  import { tmpdir } from "os";
2230
- import { dirname, join as join5, resolve as resolve4 } from "path";
2349
+ import { dirname, join as join6, resolve as resolve4 } from "path";
2231
2350
  import { simpleGit } from "simple-git";
2232
2351
  function migrationPaths(paths) {
2233
2352
  return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
@@ -2252,7 +2371,7 @@ function canon(p) {
2252
2371
  return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2253
2372
  }
2254
2373
  async function assertRepo(repoPath) {
2255
- if (!existsSync5(repoPath)) {
2374
+ if (!existsSync6(repoPath)) {
2256
2375
  throw new InvalidInputError(`path does not exist: ${repoPath}`);
2257
2376
  }
2258
2377
  let isRepo = false;
@@ -2341,7 +2460,7 @@ async function ensureTalkWorktree(project, feature) {
2341
2460
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2342
2461
  if (await worktreeIsValid(g, worktreePath, branch))
2343
2462
  return worktreePath;
2344
- if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2463
+ if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2345
2464
  if (await checkoutInWorktree(worktreePath, branch))
2346
2465
  return worktreePath;
2347
2466
  }
@@ -2364,14 +2483,13 @@ async function addWorktree(g, worktreePath, branch, label) {
2364
2483
  }
2365
2484
  }
2366
2485
  }
2367
- async function ensureProjectWorktree(project) {
2486
+ async function ensureProjectWorktree(project, onLanded) {
2368
2487
  const worktreePath = worktreeDir(project.id, PROJECT_WORKTREE_SLUG);
2369
2488
  const g = git(project.repoPath);
2370
2489
  const base = project.mainBranch;
2371
- const leftover = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2372
- if (leftover.length > 0) {
2373
- await mergeTempBranch(project.repoPath, base, PROJECT_BRANCH);
2374
- }
2490
+ const landed = await landProjectBranch(project);
2491
+ if (landed)
2492
+ onLanded?.(landed);
2375
2493
  const stillAhead = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2376
2494
  if (stillAhead.length === 0) {
2377
2495
  await deleteBranchDetachingWorktrees(g, project.repoPath, PROJECT_BRANCH);
@@ -2379,7 +2497,7 @@ async function ensureProjectWorktree(project) {
2379
2497
  }
2380
2498
  if (await worktreeIsValid(g, worktreePath, PROJECT_BRANCH))
2381
2499
  return worktreePath;
2382
- if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2500
+ if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2383
2501
  if (await checkoutInWorktree(worktreePath, PROJECT_BRANCH))
2384
2502
  return worktreePath;
2385
2503
  }
@@ -2405,7 +2523,7 @@ async function ensureBranchExists(g, branch, from) {
2405
2523
  }
2406
2524
  }
2407
2525
  async function worktreeIsValid(g, worktreePath, branch) {
2408
- if (!existsSync5(worktreePath))
2526
+ if (!existsSync6(worktreePath))
2409
2527
  return false;
2410
2528
  const registered = await registeredWorktrees(g);
2411
2529
  if (!registered.has(canon(worktreePath)))
@@ -2451,7 +2569,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2451
2569
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2452
2570
  }
2453
2571
  async function detachWorktree(path) {
2454
- if (!existsSync5(path))
2572
+ if (!existsSync6(path))
2455
2573
  return false;
2456
2574
  const g = git(path);
2457
2575
  let head;
@@ -2466,7 +2584,7 @@ async function detachWorktree(path) {
2466
2584
  return true;
2467
2585
  }
2468
2586
  async function reattachWorktree(path, branch) {
2469
- if (!existsSync5(path))
2587
+ if (!existsSync6(path))
2470
2588
  return;
2471
2589
  try {
2472
2590
  await git(path).checkout(branch);
@@ -2482,21 +2600,21 @@ function ticketBranchName(slug, ticketSeq, unique) {
2482
2600
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2483
2601
  }
2484
2602
  function burnWorktreePath(repoPath, branch) {
2485
- return join5(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2603
+ return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2486
2604
  }
2487
2605
  async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2488
2606
  const path = burnWorktreePath(repoPath, branch);
2489
2607
  const attempts = Math.max(1, opts.attempts ?? 3);
2490
2608
  const delayMs = opts.delayMs ?? 750;
2491
2609
  const g = git(repoPath);
2492
- for (let attempt = 1;attempt <= attempts && existsSync5(path); attempt++) {
2610
+ for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2493
2611
  try {
2494
2612
  await g.raw(["worktree", "remove", "--force", path]);
2495
2613
  } catch {}
2496
- if (existsSync5(path) && attempt < attempts)
2614
+ if (existsSync6(path) && attempt < attempts)
2497
2615
  await sleep(delayMs);
2498
2616
  }
2499
- if (existsSync5(path)) {
2617
+ if (existsSync6(path)) {
2500
2618
  try {
2501
2619
  rmSync(path, { recursive: true, force: true });
2502
2620
  } catch {}
@@ -2504,7 +2622,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2504
2622
  try {
2505
2623
  await g.raw(["worktree", "prune"]);
2506
2624
  } catch {}
2507
- return !existsSync5(path);
2625
+ return !existsSync6(path);
2508
2626
  }
2509
2627
  function sleep(ms) {
2510
2628
  return new Promise((done) => setTimeout(done, ms));
@@ -2652,11 +2770,11 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2652
2770
  async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2653
2771
  let dir;
2654
2772
  try {
2655
- dir = mkdtempSync(join5(tmpdir(), "rc-land-"));
2773
+ dir = mkdtempSync(join6(tmpdir(), "rc-land-"));
2656
2774
  } catch (e) {
2657
2775
  return { ok: false, error: errMsg(e) };
2658
2776
  }
2659
- const wt = join5(dir, "wt");
2777
+ const wt = join6(dir, "wt");
2660
2778
  try {
2661
2779
  await g.raw(["worktree", "add", wt, featureBranchName]);
2662
2780
  const gw = git(wt);
@@ -2763,7 +2881,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2763
2881
  try {
2764
2882
  await g.raw(["worktree", "prune"]);
2765
2883
  } catch {}
2766
- if (existsSync5(worktreePath)) {
2884
+ if (existsSync6(worktreePath)) {
2767
2885
  throw new InvalidInputError(`could not remove talk worktree at ${worktreePath} \u2014 a file may be locked; ` + "close anything using it and retry the delete");
2768
2886
  }
2769
2887
  }
@@ -2790,7 +2908,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2790
2908
  return { deleted, kept };
2791
2909
  }
2792
2910
  async function commitDocs(worktreePath, message) {
2793
- if (!existsSync5(resolve4(worktreePath, DOCS_PATHSPEC)))
2911
+ if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
2794
2912
  return;
2795
2913
  const g = git(worktreePath);
2796
2914
  await g.add([DOCS_PATHSPEC]);
@@ -3483,35 +3601,46 @@ function landProjectSession(ctx, session) {
3483
3601
  const project = getProjectById(ctx, session.projectId);
3484
3602
  if (!project)
3485
3603
  return;
3486
- const kept = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3487
- const report = (type, message, data = {}) => {
3488
- emitProject(ctx, project.id, {
3489
- type,
3490
- message,
3491
- data: { sessionId: session.id, branch: PROJECT_BRANCH, ...data }
3492
- });
3493
- };
3494
3604
  const landing = landProjectBranch(project).then((res) => {
3495
- if (!res)
3496
- return;
3497
- if (res.landed) {
3498
- report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${project.mainBranch}`, { commits: res.commits });
3499
- } else if (res.conflict) {
3500
- 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 ?? [] });
3501
- } else {
3502
- 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 });
3503
- }
3605
+ if (res)
3606
+ reportProjectLanding(ctx, project, res, { sessionId: session.id });
3504
3607
  }).catch((e) => {
3505
- 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) });
3608
+ emitProject(ctx, project.id, {
3609
+ type: "project.land_failed",
3610
+ message: `could not land the project session's work onto ${project.mainBranch}: ${e instanceof Error ? e.message : String(e)} \u2014 ${LANDING_KEPT}`,
3611
+ data: {
3612
+ sessionId: session.id,
3613
+ branch: PROJECT_BRANCH,
3614
+ error: e instanceof Error ? e.message : String(e)
3615
+ }
3616
+ });
3506
3617
  });
3507
3618
  inFlightLandings.add(landing);
3508
3619
  landing.finally(() => inFlightLandings.delete(landing));
3509
3620
  }
3621
+ function reportProjectLanding(ctx, project, res, data = {}) {
3622
+ const report = (type, message, extra) => {
3623
+ emitProject(ctx, project.id, {
3624
+ type,
3625
+ message,
3626
+ data: { branch: PROJECT_BRANCH, ...data, ...extra }
3627
+ });
3628
+ };
3629
+ if (res.landed) {
3630
+ report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${project.mainBranch}`, { commits: res.commits });
3631
+ } else if (res.conflict) {
3632
+ 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 ?? [] });
3633
+ } else {
3634
+ 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 });
3635
+ }
3636
+ }
3510
3637
  function markSessionEnded(ctx, id) {
3511
3638
  const existing = getSessionRow(ctx, id);
3512
3639
  if (!existing)
3513
3640
  return null;
3514
3641
  forgetKickoff(id);
3642
+ if (existing.featureId)
3643
+ stopDocsWatch(existing.featureId);
3515
3644
  ctx.db.update(sessions).set({ status: "ended" }).where(eq5(sessions.id, id)).run();
3516
3645
  return getSessionRow(ctx, id);
3517
3646
  }
@@ -3534,11 +3663,12 @@ function mostRecentLiveSession(ctx) {
3534
3663
  const row = ctx.db.select().from(sessions).where(eq5(sessions.status, "live")).orderBy(desc3(sql2`rowid`)).limit(1).get();
3535
3664
  return row ? rowToSession(row) : null;
3536
3665
  }
3537
- 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;
3666
+ 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;
3538
3667
  var init_sessions = __esm(() => {
3539
3668
  init_src();
3540
3669
  init_schema();
3541
3670
  init_registry();
3671
+ init_docs_watch();
3542
3672
  init_events();
3543
3673
  init_git();
3544
3674
  init_waypoints();
@@ -3558,6 +3688,7 @@ var init_sessions = __esm(() => {
3558
3688
  deliveries = new Map;
3559
3689
  landedProjectSessions = new Set;
3560
3690
  inFlightLandings = new Set;
3691
+ LANDING_KEPT = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3561
3692
  });
3562
3693
 
3563
3694
  // src/launcher/reconcile.ts
@@ -3611,26 +3742,10 @@ async function getRuntimeCtx() {
3611
3742
  }
3612
3743
  var current = null;
3613
3744
 
3614
- // src/services/feature-docs.ts
3615
- import { existsSync as existsSync6 } from "fs";
3616
- import { join as join6 } from "path";
3617
- function featureDocsDir(project, feature) {
3618
- const relSegments = featureDocsRel(feature.slug).split("/");
3619
- const worktree = worktreeDir(project.id, feature.slug);
3620
- const base = existsSync6(worktree) ? worktree : project.repoPath;
3621
- return join6(base, ...relSegments);
3622
- }
3623
- function featureDocPath(project, feature, fileName) {
3624
- return join6(featureDocsDir(project, feature), fileName);
3625
- }
3626
- var init_feature_docs = __esm(() => {
3627
- init_paths();
3628
- });
3629
-
3630
3745
  // src/services/tickets.ts
3631
3746
  import { and as and6, asc as asc3, eq as eq6 } from "drizzle-orm";
3632
3747
  function rowToTicket(row) {
3633
- return {
3748
+ return Ticket.parse({
3634
3749
  id: row.id,
3635
3750
  featureId: row.featureId,
3636
3751
  seq: row.seq,
@@ -3645,8 +3760,9 @@ function rowToTicket(row) {
3645
3760
  commits: row.commits,
3646
3761
  error: row.error ?? undefined,
3647
3762
  attemptBranch: row.attemptBranch ?? undefined,
3648
- conflictFiles: row.conflictFiles ?? undefined
3649
- };
3763
+ conflictFiles: row.conflictFiles ?? undefined,
3764
+ digest: row.digest ?? undefined
3765
+ });
3650
3766
  }
3651
3767
  function resolveBlocking(inputs, startSeq) {
3652
3768
  try {
@@ -3682,7 +3798,8 @@ function storeTickets(ctx, featureId, inputs) {
3682
3798
  commits: [],
3683
3799
  error: null,
3684
3800
  attemptBranch: null,
3685
- conflictFiles: null
3801
+ conflictFiles: null,
3802
+ digest: null
3686
3803
  }));
3687
3804
  ctx.db.insert(tickets).values(rows).run();
3688
3805
  emit(ctx, featureId, {
@@ -3769,6 +3886,8 @@ function updateTicket(ctx, id, patch) {
3769
3886
  set.attemptBranch = patch.attemptBranch;
3770
3887
  if (patch.conflictFiles !== undefined)
3771
3888
  set.conflictFiles = patch.conflictFiles;
3889
+ if (patch.digest !== undefined)
3890
+ set.digest = patch.digest;
3772
3891
  ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
3773
3892
  emit(ctx, current2.featureId, {
3774
3893
  type: "ticket.updated",
@@ -3854,6 +3973,13 @@ function docGate(ctx, feature, fileName, reason) {
3854
3973
  }
3855
3974
  function overrideGate(ctx, featureId, gate, reason) {
3856
3975
  const feature = getFeatureRow(ctx, featureId);
3976
+ const current2 = nextGate(feature);
3977
+ if (!current2) {
3978
+ throw new InvalidInputError(`cannot override ${gate}: feature is at ${feature.phase}, its final phase \u2014 no gate to cross`);
3979
+ }
3980
+ if (current2.id !== gate) {
3981
+ throw new InvalidInputError(`cannot override ${gate}: the feature is at ${feature.phase}, whose gate is ${current2.id}`);
3982
+ }
3857
3983
  ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
3858
3984
  emit(ctx, featureId, {
3859
3985
  type: "gate.overridden",
@@ -4410,14 +4536,21 @@ function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
4410
4536
  }
4411
4537
  function buildWorkspaceNotes(mode) {
4412
4538
  if (mode === "mounted") {
4413
- return "Work in the current directory \u2014 it is the repo checkout on your branch.";
4539
+ return [
4540
+ "Work in the current directory \u2014 it is the repo checkout on your branch.",
4541
+ "",
4542
+ "Write `DIGEST.md` at the root of that checkout, and leave it uncommitted \u2014 the host harvests it from disk."
4543
+ ].join(`
4544
+ `);
4414
4545
  }
4415
4546
  return [
4416
4547
  `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.`,
4417
4548
  "",
4418
4549
  `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.`,
4419
4550
  "",
4420
- `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.`
4551
+ `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
+ "",
4553
+ `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.`
4421
4554
  ].join(`
4422
4555
  `);
4423
4556
  }
@@ -4679,6 +4812,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4679
4812
  const status = new Map(tickets2.map((t) => [t.seq, t.status]));
4680
4813
  const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
4681
4814
  const inFlight = new Map;
4815
+ const digests = [];
4682
4816
  const satisfied = (s) => s === "done" || s === "cancelled";
4683
4817
  const readyState = (seq) => {
4684
4818
  const t = bySeq.get(seq);
@@ -4715,13 +4849,22 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4715
4849
  const outcome = await execute(ctx, t);
4716
4850
  if (outcome.status === "done") {
4717
4851
  status.set(seq, "done");
4718
- ctx.updateTicket(t.id, { status: "done", commits: outcome.commits });
4852
+ ctx.updateTicket(t.id, { status: "done", commits: outcome.commits, digest: outcome.digest });
4719
4853
  ctx.emitEvent({
4720
4854
  type: "ticket.done",
4721
4855
  message: `ticket ${t.seq} done \u2014 ${outcome.commits.length} commit(s)`,
4722
4856
  ticketId: t.id,
4723
4857
  data: { commits: outcome.commits }
4724
4858
  });
4859
+ if (outcome.digest === undefined) {
4860
+ ctx.emitEvent({
4861
+ type: "digest.missing",
4862
+ message: `ticket ${t.seq} done without DIGEST.md`,
4863
+ ticketId: t.id
4864
+ });
4865
+ } else {
4866
+ digests.push({ seq: t.seq, title: t.title, digest: outcome.digest });
4867
+ }
4725
4868
  } else {
4726
4869
  failTicket(seq, outcome.error, outcome.event);
4727
4870
  ctx.emitEvent({
@@ -4787,7 +4930,16 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4787
4930
  for (const s of status.values())
4788
4931
  if (s === "done")
4789
4932
  done += 1;
4790
- return done;
4933
+ return { done, digests };
4934
+ }
4935
+ function composeRunDigest(entries) {
4936
+ if (entries.length === 0)
4937
+ return null;
4938
+ return [...entries].sort((a, b) => a.seq - b.seq).map((e) => `## ticket ${e.seq} \u2014 ${e.title}
4939
+
4940
+ ${e.digest.trim()}`).join(`
4941
+
4942
+ `);
4791
4943
  }
4792
4944
  async function burnRun(ctx, deps) {
4793
4945
  const tickets2 = ctx.tickets;
@@ -4808,10 +4960,11 @@ async function burnRun(ctx, deps) {
4808
4960
  });
4809
4961
  return { status: "failed", summary: `dependency cycle: ${path}` };
4810
4962
  }
4811
- const done = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
4963
+ const { done, digests } = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
4812
4964
  const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
4813
4965
  ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
4814
- return { status: done === total ? "succeeded" : "failed", summary };
4966
+ const digest = composeRunDigest(digests);
4967
+ return { status: done === total ? "succeeded" : "failed", summary, ...digest ? { digest } : {} };
4815
4968
  }
4816
4969
  function burnerTemplatePath() {
4817
4970
  return burnerAssetPath("implement-ticket.md");
@@ -4833,11 +4986,11 @@ function readDocsDigestFromDisk(projectId, slug) {
4833
4986
  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") }));
4834
4987
  return buildDocsDigest(files);
4835
4988
  }
4836
- function readBlockedFile(dirs) {
4989
+ function readAgentFile(dirs, file) {
4837
4990
  for (const dir of dirs) {
4838
4991
  if (!dir)
4839
4992
  continue;
4840
- const p = join9(dir, "BLOCKED.md");
4993
+ const p = join9(dir, file);
4841
4994
  if (existsSync10(p)) {
4842
4995
  try {
4843
4996
  return readFileSync4(p, "utf8");
@@ -4846,10 +4999,26 @@ function readBlockedFile(dirs) {
4846
4999
  }
4847
5000
  return;
4848
5001
  }
5002
+ function harvestDigest(dirs) {
5003
+ const trimmed = readAgentFile(dirs, "DIGEST.md")?.trim();
5004
+ return trimmed ? trimmed : undefined;
5005
+ }
5006
+ function buildAgentEnv(onHost, token) {
5007
+ const env = {};
5008
+ if (onHost) {
5009
+ for (const [key, value] of Object.entries(process.env)) {
5010
+ if (value !== undefined)
5011
+ env[key] = value;
5012
+ }
5013
+ }
5014
+ if (token)
5015
+ env.CLAUDE_CODE_OAUTH_TOKEN = token;
5016
+ return env;
5017
+ }
4849
5018
  function buildBurnAgent(config2, token, model) {
4850
5019
  const onHost = config2.sandbox === "noSandbox";
4851
5020
  const opts = {
4852
- ...token ? { env: { CLAUDE_CODE_OAUTH_TOKEN: token } } : {},
5021
+ env: buildAgentEnv(onHost, token),
4853
5022
  ...onHost ? { permissionMode: "bypassPermissions" } : {}
4854
5023
  };
4855
5024
  const agent = claudeCode(model, opts);
@@ -4873,8 +5042,10 @@ function selectSandbox(config2, mounts = []) {
4873
5042
  return docker(imageOpts);
4874
5043
  case "podman":
4875
5044
  return podman(imageOpts);
4876
- default:
5045
+ case "noSandbox":
4877
5046
  return noSandbox();
5047
+ default:
5048
+ throw new Error(`no sandbox provider for sandbox "${config2.sandbox}" \u2014 refusing to run the agent unsandboxed`);
4878
5049
  }
4879
5050
  }
4880
5051
  function buildSandboxOptions(config2, mounts = []) {
@@ -5019,7 +5190,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
5019
5190
  }
5020
5191
  await cleanupBurnWorktree(project.repoPath, resolveBranch);
5021
5192
  }
5022
- const blocked = readBlockedFile([result?.preservedWorktreePath, project.repoPath]);
5193
+ const blocked = readAgentFile([result?.preservedWorktreePath, project.repoPath], "BLOCKED.md");
5023
5194
  if (blocked !== undefined && blocked.trim().length > 0) {
5024
5195
  return {
5025
5196
  ok: false,
@@ -5053,11 +5224,16 @@ ${blocked.trim()}`
5053
5224
  label: `ticket ${ticket.seq}`,
5054
5225
  featureBranch: feature.branch
5055
5226
  };
5227
+ let harvestedDigest;
5056
5228
  const landChain = async (branch, commits) => {
5057
5229
  const landing = await landWithResolve(branch, landDeps);
5058
5230
  if (landing.status === "landed") {
5059
5231
  ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
5060
- return { status: "done", commits: landedCommits.length > 0 ? landedCommits : commits };
5232
+ return {
5233
+ status: "done",
5234
+ commits: landedCommits.length > 0 ? landedCommits : commits,
5235
+ digest: harvestedDigest
5236
+ };
5061
5237
  }
5062
5238
  preserveChain(landing.branch);
5063
5239
  if (landing.status === "conflict") {
@@ -5218,7 +5394,9 @@ ${retryNotes}` : basePrompt,
5218
5394
  }
5219
5395
  if (!result)
5220
5396
  return { status: "failed", error: "internal: run loop produced no result" };
5221
- const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
5397
+ const agentFileDirs = [result.preservedWorktreePath, project.repoPath];
5398
+ const blocked = readAgentFile(agentFileDirs, "BLOCKED.md");
5399
+ harvestedDigest = harvestDigest(agentFileDirs);
5222
5400
  const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
5223
5401
  let outcome;
5224
5402
  if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
@@ -5352,8 +5530,6 @@ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as re
5352
5530
  import { dirname as dirname4, join as join10 } from "path";
5353
5531
  import { fileURLToPath as fileURLToPath3 } from "url";
5354
5532
  import { run as run2 } from "@ai-hero/sandcastle";
5355
- import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
5356
- import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
5357
5533
  function waypointSlug(waypoint) {
5358
5534
  const base = waypoint.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5359
5535
  return `${waypoint.seq}-${base || "waypoint"}`;
@@ -5367,7 +5543,7 @@ async function researchRun(ctx, deps) {
5367
5543
  ctx.emitEvent({ type: "research.error", message: "research run started without a waypoint" });
5368
5544
  return { status: "failed", summary: "no waypoint to research" };
5369
5545
  }
5370
- if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
5546
+ if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
5371
5547
  ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
5372
5548
  return { status: "failed", summary: "research aborted: auth token missing" };
5373
5549
  }
@@ -5449,7 +5625,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
5449
5625
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
5450
5626
  const runOptions = {
5451
5627
  agent: buildBurnAgent(config2, token, model),
5452
- sandbox: config2.sandbox === "docker" ? docker2({ imageName: resolveSandboxImage(config2) }) : noSandbox2(),
5628
+ sandbox: selectSandbox(config2),
5453
5629
  cwd: project.repoPath,
5454
5630
  prompt,
5455
5631
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
@@ -5625,10 +5801,12 @@ function cancelRun(runId) {
5625
5801
  async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
5626
5802
  let status = "failed";
5627
5803
  let summary = "run failed";
5804
+ let digest;
5628
5805
  try {
5629
5806
  const result = await runPromise;
5630
5807
  status = result.status;
5631
5808
  summary = result.summary;
5809
+ digest = result.digest;
5632
5810
  } catch (e) {
5633
5811
  if (controller.signal.aborted) {
5634
5812
  status = "cancelled";
@@ -5641,7 +5819,7 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
5641
5819
  } finally {
5642
5820
  controllers.delete(runId);
5643
5821
  }
5644
- ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
5822
+ ctx.db.update(runs).set({ status, endedAt: Date.now(), summary, ...digest ? { digest } : {} }).where(eq8(runs.id, runId)).run();
5645
5823
  releaseForSession(ctx, runId);
5646
5824
  if (workflowClaimsFeatureBranch(workflow2)) {
5647
5825
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -5696,36 +5874,73 @@ async function createFeature(ctx, input) {
5696
5874
  const slug = uniqueSlug(ctx, project.id, input.title);
5697
5875
  const branch = `feature/${slug}`;
5698
5876
  const requestedBase = input.baseBranch?.trim() || project.mainBranch;
5699
- const { branchReady, baseBranch } = await ensureFeatureBranch(project, slug, requestedBase);
5877
+ const cut = input.draft ? null : await ensureFeatureBranch(project, slug, requestedBase);
5700
5878
  const row = {
5701
5879
  id: newId("feat"),
5702
5880
  projectId: project.id,
5703
5881
  slug,
5704
5882
  title: input.title,
5705
5883
  oneLiner: input.oneLiner,
5884
+ brief: input.draft ? input.brief ?? null : null,
5706
5885
  mapped: false,
5707
5886
  lap: 1,
5708
5887
  phase: "ideation",
5709
5888
  branch,
5710
- baseBranch,
5711
- status: "active",
5889
+ baseBranch: cut?.baseBranch ?? null,
5890
+ status: cut ? "active" : "draft",
5712
5891
  createdAt: Date.now()
5713
5892
  };
5714
5893
  const inserted = ctx.db.insert(features).values(row).returning().get();
5715
5894
  const feature = rowToFeature(inserted);
5716
5895
  emit(ctx, feature.id, {
5717
5896
  type: "feature.created",
5718
- message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
5719
- data: { slug, branch, baseBranch, branchReady }
5897
+ message: !cut ? `feature.created (draft \u2014 ${branch} not cut yet)` : cut.branchReady ? `feature.created (${branch} \u2190 ${cut.baseBranch})` : "feature.created (branch pending)",
5898
+ data: {
5899
+ slug,
5900
+ branch,
5901
+ baseBranch: cut?.baseBranch,
5902
+ branchReady: cut?.branchReady ?? false,
5903
+ draft: !cut
5904
+ }
5720
5905
  });
5906
+ if (!cut)
5907
+ return feature;
5721
5908
  scaffoldDocs(ctx, feature, { brief: input.brief });
5722
- if (branchReady) {
5909
+ if (cut.branchReady) {
5723
5910
  try {
5724
5911
  await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
5725
5912
  } catch {}
5726
5913
  }
5727
5914
  return feature;
5728
5915
  }
5916
+ function requireNotDraft(feature) {
5917
+ if (feature.status === "draft") {
5918
+ throw new GateError(`\`${feature.slug}\` is a draft \u2014 click Start to cut its branch and begin`);
5919
+ }
5920
+ }
5921
+ async function startDraft(ctx, featureId, opts = {}) {
5922
+ const feature = getFeatureRow(ctx, featureId);
5923
+ if (feature.status !== "draft") {
5924
+ throw new GateError(`feature ${feature.slug} is not a draft \u2014 it has already been started`);
5925
+ }
5926
+ const project = projectForFeature(ctx, feature);
5927
+ const requestedBase = opts.baseBranch?.trim() || project.mainBranch;
5928
+ const { branchReady, baseBranch } = await ensureFeatureBranch(project, feature.slug, requestedBase);
5929
+ ctx.db.update(features).set({ status: "active", baseBranch }).where(eq9(features.id, featureId)).run();
5930
+ const started = { ...feature, status: "active", baseBranch };
5931
+ emit(ctx, featureId, {
5932
+ type: "feature.started",
5933
+ message: branchReady ? `feature ${feature.slug} started (${feature.branch} \u2190 ${baseBranch})` : `feature ${feature.slug} started (branch pending)`,
5934
+ data: { branch: feature.branch, baseBranch, branchReady }
5935
+ });
5936
+ scaffoldDocs(ctx, started, { brief: feature.brief });
5937
+ if (branchReady) {
5938
+ try {
5939
+ await commitDocs(project.repoPath, `runcastle: scaffold ${feature.slug} docs`);
5940
+ } catch {}
5941
+ }
5942
+ return started;
5943
+ }
5729
5944
  async function quickChange(ctx, input) {
5730
5945
  const project = requireProjectById(ctx, input.projectId);
5731
5946
  const title = input.title.trim();
@@ -5844,6 +6059,7 @@ function liveSessionOf(ctx, featureId) {
5844
6059
  }
5845
6060
  function advance(ctx, featureId) {
5846
6061
  const feature = getFeatureRow(ctx, featureId);
6062
+ requireNotDraft(feature);
5847
6063
  const gate = nextGate(feature);
5848
6064
  if (!gate)
5849
6065
  throw new GateError("feature is already at the final phase");
@@ -5861,6 +6077,7 @@ function advance(ctx, featureId) {
5861
6077
  }
5862
6078
  async function burn(ctx, featureId, opts = {}) {
5863
6079
  const feature = getFeatureRow(ctx, featureId);
6080
+ requireNotDraft(feature);
5864
6081
  const running = hasActiveRun(ctx, featureId);
5865
6082
  let tickets2 = listByFeature2(ctx, featureId);
5866
6083
  const lapTickets = tickets2.filter((t) => t.lap === feature.lap);
@@ -5911,6 +6128,7 @@ async function burn(ctx, featureId, opts = {}) {
5911
6128
  }
5912
6129
  function rethink(ctx, featureId) {
5913
6130
  const feature = getFeatureRow(ctx, featureId);
6131
+ requireNotDraft(feature);
5914
6132
  if (feature.phase !== RETHINK_LOOP_BACK.from) {
5915
6133
  throw new GateError(`feature must be in the review phase to rethink (currently ${feature.phase})`);
5916
6134
  }
@@ -6024,6 +6242,7 @@ function escalateToMap(ctx, featureId, input) {
6024
6242
  }
6025
6243
  function archiveFeature(ctx, featureId) {
6026
6244
  const feature = getFeatureRow(ctx, featureId);
6245
+ requireNotDraft(feature);
6027
6246
  if (feature.status === "archived") {
6028
6247
  throw new GateError(`feature ${feature.slug} is already archived`);
6029
6248
  }
@@ -6307,8 +6526,19 @@ async function toolDryRunDrive(ctx, session, input) {
6307
6526
  }
6308
6527
  return dryRunDrive(ctx, project, input.action);
6309
6528
  }
6529
+ function createFeatureProject(ctx, session, input) {
6530
+ if (isProjectSessionKind(session.kind))
6531
+ return requireProject(ctx, session);
6532
+ if (!DRAFTING_KINDS.includes(session.kind)) {
6533
+ 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.");
6534
+ }
6535
+ if (!input.draft || input.ticket) {
6536
+ 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.");
6537
+ }
6538
+ return projectForFeature(ctx, getFeatureRow(ctx, requireFeatureId(session)));
6539
+ }
6310
6540
  async function toolCreateFeature(ctx, session, input) {
6311
- const project = requireProject(ctx, session);
6541
+ const project = createFeatureProject(ctx, session, input);
6312
6542
  const feature = input.ticket ? await quickChange(ctx, {
6313
6543
  projectId: project.id,
6314
6544
  title: input.title,
@@ -6319,7 +6549,8 @@ async function toolCreateFeature(ctx, session, input) {
6319
6549
  title: input.title,
6320
6550
  oneLiner: input.oneLiner,
6321
6551
  baseBranch: input.baseBranch,
6322
- brief: input.brief
6552
+ brief: input.brief,
6553
+ draft: input.draft
6323
6554
  });
6324
6555
  return {
6325
6556
  id: feature.id,
@@ -6380,7 +6611,8 @@ function toolGetWorkRecord(ctx, session, input) {
6380
6611
  status: t.status,
6381
6612
  seams: t.seams,
6382
6613
  commits: t.commits,
6383
- ...t.error !== undefined ? { error: t.error } : {}
6614
+ ...t.error !== undefined ? { error: t.error } : {},
6615
+ ...t.digest !== undefined ? { digest: t.digest } : {}
6384
6616
  }))
6385
6617
  });
6386
6618
  }
@@ -6434,12 +6666,13 @@ function buildMcpServer() {
6434
6666
  });
6435
6667
  server.registerTool("create_feature", {
6436
6668
  title: "Create a feature",
6437
- 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.",
6669
+ 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.",
6438
6670
  inputSchema: {
6439
6671
  title: z3.string().min(1),
6440
6672
  oneLiner: z3.string(),
6441
6673
  baseBranch: z3.string().optional(),
6442
6674
  brief: z3.string().optional(),
6675
+ draft: z3.boolean().optional(),
6443
6676
  ticket: z3.object({ prose: z3.string().min(1) }).optional()
6444
6677
  }
6445
6678
  }, async (args, extra) => {
@@ -6460,7 +6693,7 @@ function buildMcpServer() {
6460
6693
  });
6461
6694
  server.registerTool("get_work_record", {
6462
6695
  title: "Get work record",
6463
- 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.",
6696
+ 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.",
6464
6697
  inputSchema: { featureSlug: z3.string().optional(), seam: z3.string().optional() }
6465
6698
  }, async (args, extra) => {
6466
6699
  const rs = await resolveCtxSession(extra);
@@ -6583,7 +6816,7 @@ function buildMcpServer() {
6583
6816
  });
6584
6817
  return server;
6585
6818
  }
6586
- var mcp, server_default;
6819
+ var DRAFTING_KINDS, mcp, server_default;
6587
6820
  var init_server = __esm(() => {
6588
6821
  init_src();
6589
6822
  init_paths();
@@ -6597,6 +6830,7 @@ var init_server = __esm(() => {
6597
6830
  init_repo();
6598
6831
  init_tickets();
6599
6832
  init_waypoints();
6833
+ DRAFTING_KINDS = ["ideation", "revisit", "waypoint", "converge"];
6600
6834
  mcp = new Hono;
6601
6835
  mcp.use("*", async (c, next) => {
6602
6836
  await next();
@@ -6694,21 +6928,23 @@ function evaluateEditGuard(input) {
6694
6928
  return null;
6695
6929
  if (!input.filePath)
6696
6930
  return null;
6931
+ const target = resolve7(input.worktreePath, input.filePath);
6697
6932
  if (!input.featureSlug) {
6698
6933
  return {
6699
6934
  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."
6700
6935
  };
6701
6936
  }
6702
6937
  const docs = featureDocsRel(input.featureSlug);
6703
- const docsDir = resolve7(input.worktreePath, docs);
6704
- const target = resolve7(input.worktreePath, input.filePath);
6705
- const rel = relative2(docsDir, target);
6706
- if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel))
6938
+ if (within(resolve7(input.worktreePath, docs), target))
6707
6939
  return null;
6708
6940
  return {
6709
6941
  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."
6710
6942
  };
6711
6943
  }
6944
+ function within(dir, target) {
6945
+ const rel = relative2(dir, target);
6946
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
6947
+ }
6712
6948
  function editDenyResponse(denial) {
6713
6949
  return {
6714
6950
  hookSpecificOutput: {
@@ -7284,6 +7520,28 @@ var init_update_check = __esm(() => {
7284
7520
  NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
7285
7521
  });
7286
7522
 
7523
+ // src/shutdown.ts
7524
+ function createShutdown(deps, timeoutMs = SHUTDOWN_TIMEOUT_MS) {
7525
+ let settling = false;
7526
+ return () => {
7527
+ if (settling) {
7528
+ deps.exit(1);
7529
+ return;
7530
+ }
7531
+ settling = true;
7532
+ (async () => {
7533
+ const deadline = new Promise((resolve9) => {
7534
+ const timer = setTimeout(resolve9, timeoutMs);
7535
+ timer.unref?.();
7536
+ });
7537
+ await Promise.race([deps.killAllTrees().catch(() => {}), deadline]);
7538
+ deps.stop();
7539
+ deps.exit(0);
7540
+ })();
7541
+ };
7542
+ }
7543
+ var SHUTDOWN_TIMEOUT_MS = 5000;
7544
+
7287
7545
  // src/trpc/context.ts
7288
7546
  import { initTRPC } from "@trpc/server";
7289
7547
  var t, errorMapping, router, publicProcedure, createCallerFactory;
@@ -7344,13 +7602,16 @@ function serverUrlFor(config2) {
7344
7602
  function noCodeRule(docs) {
7345
7603
  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.";
7346
7604
  }
7347
- function renderSystemPrompt(feature, kind, waypoint, lap) {
7605
+ function conflictResolutionRule() {
7606
+ 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.";
7607
+ }
7608
+ function renderSystemPrompt(feature, kind, waypoint, lap, purpose) {
7348
7609
  if (kind === "waypoint")
7349
7610
  return renderWaypointPrompt(feature, waypoint);
7350
7611
  if (kind === "converge")
7351
7612
  return renderConvergePrompt(feature);
7352
7613
  if (kind === "revisit")
7353
- return renderRevisitPrompt(feature, lap);
7614
+ return renderRevisitPrompt(feature, lap, purpose);
7354
7615
  const docs = featureDocsRel(feature.slug);
7355
7616
  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.";
7356
7617
  return [
@@ -7479,7 +7740,7 @@ function renderConvergePrompt(feature) {
7479
7740
  ].join(`
7480
7741
  `);
7481
7742
  }
7482
- function renderRevisitPrompt(feature, lap) {
7743
+ function renderRevisitPrompt(feature, lap, purpose) {
7483
7744
  const docs = featureDocsRel(feature.slug);
7484
7745
  const lapIteration = lap ? [
7485
7746
  `## This is lap ${lap}`,
@@ -7549,7 +7810,7 @@ function renderRevisitPrompt(feature, lap) {
7549
7810
  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.",
7550
7811
  "- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
7551
7812
  "- Docs first, tickets second: capture the decision prose before any ticket surgery.",
7552
- noCodeRule(docs),
7813
+ purpose === "resolve-conflict" ? conflictResolutionRule() : noCodeRule(docs),
7553
7814
  "",
7554
7815
  "## Your task",
7555
7816
  "Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
@@ -7707,7 +7968,8 @@ function renderProjectPrompt(brief) {
7707
7968
  "- `get_project_context()` \u2014 the project, its charter, its live ADRs, and a one-line",
7708
7969
  " index of every feature.",
7709
7970
  "- `get_work_record({ featureSlug? | seam? })` \u2014 what features actually did: tickets by",
7710
- " status, seams, commits, errors. Facts, never intent.",
7971
+ " status, seams, commits, errors, and each burner's digest of what it actually did,",
7972
+ " what surprised it and what it left undone. Facts, never intent.",
7711
7973
  "- `record_event({ type, message })` \u2014 drop a note on the project timeline.",
7712
7974
  "",
7713
7975
  "Every merged feature's docs are already on disk in this worktree \u2014 read them with your",
@@ -7756,13 +8018,13 @@ function renderMcpConfig(session, config2) {
7756
8018
  };
7757
8019
  }
7758
8020
  async function writeSessionArtifacts(input) {
7759
- const { session, feature, config: config2, waypoint, prepare, projectBrief, lap } = input;
8021
+ const { session, feature, config: config2, waypoint, prepare, projectBrief, lap, purpose } = input;
7760
8022
  const dir = sessionDir(session.id);
7761
8023
  mkdirSync7(dir, { recursive: true });
7762
8024
  const systemPromptPath = join13(dir, "system-prompt.md");
7763
8025
  const settingsPath = join13(dir, "settings.json");
7764
8026
  const mcpConfigPath = join13(dir, "mcp.json");
7765
- const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
8027
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap, purpose) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
7766
8028
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
7767
8029
  })();
7768
8030
  writeFileSync3(systemPromptPath, systemPrompt, "utf8");
@@ -7885,8 +8147,10 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
7885
8147
  }
7886
8148
  }
7887
8149
  function sessionFinished(ctx, feature, session) {
7888
- if (session.kind !== "waypoint")
8150
+ if (session.kind === "ideation" || session.kind === "converge")
7889
8151
  return feature.mapped;
8152
+ if (session.kind !== "waypoint")
8153
+ return false;
7890
8154
  const own = listByFeature(ctx, feature.id).find((w) => w.lastSessionId === session.id);
7891
8155
  return !!own && (own.status === "resolved" || own.status === "dropped");
7892
8156
  }
@@ -7925,6 +8189,7 @@ async function ensureWorktree(ctx, project, feature) {
7925
8189
  }
7926
8190
  async function launchSession(ctx, input, opts = {}) {
7927
8191
  const feature = getFeatureRow(ctx, input.featureId);
8192
+ requireNotDraft(feature);
7928
8193
  const project = projectForFeature(ctx, feature);
7929
8194
  const worktreePath = await ensureWorktree(ctx, project, feature);
7930
8195
  const session = createSessionRow(ctx, {
@@ -8007,7 +8272,8 @@ async function launchSession(ctx, input, opts = {}) {
8007
8272
  project,
8008
8273
  config: ctx.config,
8009
8274
  waypoint,
8010
- lap: plan.lap
8275
+ lap: plan.lap,
8276
+ purpose: input.purpose
8011
8277
  });
8012
8278
  const serverUrl = serverUrlFor(ctx.config);
8013
8279
  const buildInput = {
@@ -8107,7 +8373,7 @@ async function launchProjectSession(ctx, input, opts = {}) {
8107
8373
  if (live) {
8108
8374
  throw new GateError(`a project session is already open for ${project.name} \u2014 resume or end it first`);
8109
8375
  }
8110
- const worktreePath = await ensureProjectWorktree(project);
8376
+ const worktreePath = await ensureProjectWorktree(project, (res) => reportProjectLanding(ctx, project, res, { retried: true }));
8111
8377
  const session = createSessionRow(ctx, {
8112
8378
  projectId: project.id,
8113
8379
  kind: "project",
@@ -8178,6 +8444,7 @@ async function buildPrepareBrief(ctx, project) {
8178
8444
  }
8179
8445
  async function workWaypoint(ctx, input, opts = {}) {
8180
8446
  const feature = getFeatureRow(ctx, input.featureId);
8447
+ requireNotDraft(feature);
8181
8448
  if (!feature.mapped) {
8182
8449
  throw new GateError(`feature ${feature.slug} is not mapped \u2014 it has no waypoints to work`);
8183
8450
  }
@@ -8198,6 +8465,7 @@ async function workWaypoint(ctx, input, opts = {}) {
8198
8465
  }
8199
8466
  async function converge(ctx, input, opts = {}) {
8200
8467
  const feature = getFeatureRow(ctx, input.featureId);
8468
+ requireNotDraft(feature);
8201
8469
  if (!feature.mapped) {
8202
8470
  throw new GateError(`feature ${feature.slug} is not mapped \u2014 convergence is only for mapped features`);
8203
8471
  }
@@ -8296,6 +8564,8 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
8296
8564
  opts: { cwd: worktreePath, env, cols: 80, rows: 24, useConpty: true },
8297
8565
  onExit: ({ exitCode }) => handlePtyExit(ctx, feature, session, meta, exitCode)
8298
8566
  });
8567
+ if (feature)
8568
+ startDocsWatch(ctx, feature);
8299
8569
  emitForSession(ctx, session, {
8300
8570
  type: "session.launched",
8301
8571
  message: "embedded terminal spawned",
@@ -8322,10 +8592,12 @@ var init_launcher = __esm(() => {
8322
8592
  init_errors();
8323
8593
  init_end_session();
8324
8594
  init_registry();
8595
+ init_docs_watch();
8325
8596
  init_events();
8326
8597
  init_gates();
8327
8598
  init_git();
8328
8599
  init_repo();
8600
+ init_features();
8329
8601
  init_tickets();
8330
8602
  init_waypoints();
8331
8603
  init_runner();
@@ -8347,6 +8619,74 @@ var init_launcher = __esm(() => {
8347
8619
  ];
8348
8620
  });
8349
8621
 
8622
+ // src/services/outcome.ts
8623
+ import { mkdirSync as mkdirSync8, writeFileSync as writeFileSync4 } from "fs";
8624
+ import { join as join15 } from "path";
8625
+ function composeOutcomeDoc(feature, tickets2, shippedAt) {
8626
+ const blocks = [
8627
+ `# Outcome \u2014 ${feature.title}`,
8628
+ feature.oneLiner,
8629
+ [`- Shipped: ${shippedDate(shippedAt)}`, `- Lap: ${feature.lap}`].join(`
8630
+ `)
8631
+ ];
8632
+ let oneLiners = [];
8633
+ const flush = () => {
8634
+ if (oneLiners.length > 0)
8635
+ blocks.push(oneLiners.join(`
8636
+ `));
8637
+ oneLiners = [];
8638
+ };
8639
+ for (const ticket of [...tickets2].sort((a, b) => a.seq - b.seq)) {
8640
+ if (ticket.status === "done") {
8641
+ flush();
8642
+ blocks.push(`## ${ticket.seq}. ${ticket.title}`, ticket.digest?.trim() || NO_DIGEST);
8643
+ } else {
8644
+ oneLiners.push(oneLineEntry(ticket));
8645
+ }
8646
+ }
8647
+ flush();
8648
+ return `${blocks.join(`
8649
+
8650
+ `)}
8651
+ `;
8652
+ }
8653
+ function shippedDate(shippedAt) {
8654
+ return new Date(shippedAt).toISOString().slice(0, 10);
8655
+ }
8656
+ function oneLineEntry(ticket) {
8657
+ const headline = errorHeadline3(ticket.error ?? "");
8658
+ return `- **${ticket.seq}. ${ticket.title}** \u2014 ${ticket.status}${headline ? `: ${headline}` : ""}`;
8659
+ }
8660
+ function errorHeadline3(error) {
8661
+ const lines = error.split(`
8662
+ `).map((l) => l.trim()).filter(Boolean);
8663
+ const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
8664
+ const headline = causes.at(-1) ?? lines[0] ?? "";
8665
+ return headline.length > HEADLINE_MAX ? `${headline.slice(0, HEADLINE_MAX)}\u2026` : headline;
8666
+ }
8667
+ async function promoteOutcomeDoc(ctx, project, feature) {
8668
+ try {
8669
+ const worktreePath = await ensureTalkWorktree(project, feature);
8670
+ const docsDir = join15(worktreePath, ...featureDocsRel(feature.slug).split("/"));
8671
+ mkdirSync8(docsDir, { recursive: true });
8672
+ const doc = composeOutcomeDoc(feature, listByFeature2(ctx, feature.id), Date.now());
8673
+ writeFileSync4(join15(docsDir, "outcome.md"), doc, "utf8");
8674
+ await commitDocs(worktreePath, `runcastle: outcome for ${feature.slug}`);
8675
+ } catch (e) {
8676
+ emit(ctx, feature.id, {
8677
+ type: "docs.outcome_failed",
8678
+ message: `outcome.md not promoted: ${e instanceof Error ? e.message : String(e)}`
8679
+ });
8680
+ }
8681
+ }
8682
+ var NO_DIGEST = "_no digest captured_", HEADLINE_MAX = 160;
8683
+ var init_outcome = __esm(() => {
8684
+ init_paths();
8685
+ init_events();
8686
+ init_git();
8687
+ init_tickets();
8688
+ });
8689
+
8350
8690
  // src/trpc/routers/feature.ts
8351
8691
  import * as z6 from "zod";
8352
8692
  var gateId, featureRouter;
@@ -8358,6 +8698,7 @@ var init_feature = __esm(() => {
8358
8698
  init_features();
8359
8699
  init_gates();
8360
8700
  init_git();
8701
+ init_outcome();
8361
8702
  init_repo();
8362
8703
  init_context();
8363
8704
  gateId = z6.enum(["G1", "G2", "G3", "G4", "G5"]);
@@ -8366,8 +8707,10 @@ var init_feature = __esm(() => {
8366
8707
  projectId: z6.string(),
8367
8708
  title: z6.string().min(1),
8368
8709
  oneLiner: z6.string(),
8369
- baseBranch: z6.string().optional()
8710
+ baseBranch: z6.string().optional(),
8711
+ draft: z6.boolean().optional()
8370
8712
  })).mutation(({ ctx, input }) => createFeature(ctx, input)),
8713
+ start: publicProcedure.input(z6.object({ featureId: z6.string(), baseBranch: z6.string().optional() })).mutation(({ ctx, input }) => startDraft(ctx, input.featureId, { baseBranch: input.baseBranch })),
8371
8714
  quickChange: publicProcedure.input(z6.object({
8372
8715
  projectId: z6.string(),
8373
8716
  title: z6.string().min(1),
@@ -8401,6 +8744,7 @@ var init_feature = __esm(() => {
8401
8744
  burn: publicProcedure.input(z6.object({ featureId: z6.string(), model: z6.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
8402
8745
  testDrive: publicProcedure.input(z6.object({ featureId: z6.string(), action: z6.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
8403
8746
  const feature = getFeatureRow(ctx, input.featureId);
8747
+ requireNotDraft(feature);
8404
8748
  const project = projectForFeature(ctx, feature);
8405
8749
  return testDrive(ctx, project, feature, input.action);
8406
8750
  }),
@@ -8411,10 +8755,12 @@ var init_feature = __esm(() => {
8411
8755
  }),
8412
8756
  merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
8413
8757
  const feature = getFeatureRow(ctx, input.featureId);
8758
+ requireNotDraft(feature);
8414
8759
  const project = projectForFeature(ctx, feature);
8415
8760
  if (activeTestDriveFeatureId() === feature.id) {
8416
8761
  await testDrive(ctx, project, feature, "stop");
8417
8762
  }
8763
+ await promoteOutcomeDoc(ctx, project, feature);
8418
8764
  const res = await mergeFeature(project, feature);
8419
8765
  if (res.ok) {
8420
8766
  setPhase(ctx, input.featureId, "shipped", "feature.shipped", `merged to ${res.target}`);
@@ -8434,7 +8780,7 @@ var init_feature = __esm(() => {
8434
8780
  // src/services/fsbrowse.ts
8435
8781
  import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
8436
8782
  import { homedir as homedir2 } from "os";
8437
- import { basename, dirname as dirname8, isAbsolute as isAbsolute2, join as join15, resolve as resolve10, sep as sep3 } from "path";
8783
+ import { basename, dirname as dirname8, isAbsolute as isAbsolute2, join as join16, resolve as resolve10, sep as sep3 } from "path";
8438
8784
  function expandPath(input, home = homedir2()) {
8439
8785
  const trimmed = input.trim();
8440
8786
  if (trimmed === "")
@@ -8443,12 +8789,12 @@ function expandPath(input, home = homedir2()) {
8443
8789
  if (out === "~")
8444
8790
  out = home;
8445
8791
  else if (out.startsWith("~/") || out.startsWith("~\\"))
8446
- out = join15(home, out.slice(2));
8792
+ out = join16(home, out.slice(2));
8447
8793
  return resolve10(out);
8448
8794
  }
8449
8795
  function looksLikeRepo(dir) {
8450
8796
  try {
8451
- return existsSync15(join15(dir, ".git"));
8797
+ return existsSync15(join16(dir, ".git"));
8452
8798
  } catch {
8453
8799
  return false;
8454
8800
  }
@@ -8470,7 +8816,7 @@ function listRoots(home = homedir2()) {
8470
8816
  roots.push({ label: "Home", path: home, kind: "home" });
8471
8817
  const seen = new Set(roots.map(pathKey));
8472
8818
  for (const name of COMMON_DIRS) {
8473
- const candidate = join15(home, name);
8819
+ const candidate = join16(home, name);
8474
8820
  const key = pathKey({ path: candidate });
8475
8821
  if (seen.has(key) || !existsSync15(candidate))
8476
8822
  continue;
@@ -8531,7 +8877,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
8531
8877
  continue;
8532
8878
  if (!showHidden && name.startsWith("."))
8533
8879
  continue;
8534
- const path = join15(dir, name);
8880
+ const path = join16(dir, name);
8535
8881
  const isSymlink = dirent.isSymbolicLink();
8536
8882
  if (isSymlink && !showHidden)
8537
8883
  continue;
@@ -8569,7 +8915,7 @@ var init_fsbrowse = __esm(() => {
8569
8915
 
8570
8916
  // src/services/projects.ts
8571
8917
  import { existsSync as existsSync16 } from "fs";
8572
- import { basename as basename2, join as join16 } from "path";
8918
+ import { basename as basename2, join as join17 } from "path";
8573
8919
  import { eq as eq12, isNull } from "drizzle-orm";
8574
8920
  function listProjects(ctx) {
8575
8921
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
@@ -8593,7 +8939,7 @@ async function openProject(ctx, rawPath) {
8593
8939
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
8594
8940
  if (existing) {
8595
8941
  ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq12(projects.id, existing.id)).run();
8596
- const reopened = { ...existing, mainBranch, repoPath };
8942
+ const reopened = { ...existing, mainBranch, repoPath, closedAt: undefined };
8597
8943
  emitProject(ctx, existing.id, {
8598
8944
  type: "project.opened",
8599
8945
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
@@ -8650,7 +8996,7 @@ async function assertRepoTolerant(ctx, repoPath) {
8650
8996
  } catch (e) {
8651
8997
  if (!isNotImplemented(e))
8652
8998
  throw e;
8653
- if (!existsSync16(join16(repoPath, ".git"))) {
8999
+ if (!existsSync16(join17(repoPath, ".git"))) {
8654
9000
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
8655
9001
  }
8656
9002
  }
@@ -8715,7 +9061,8 @@ var init_run = __esm(() => {
8715
9061
  runRouter = router({
8716
9062
  get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
8717
9063
  agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
8718
- cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ input }) => {
9064
+ cancel: publicProcedure.input(z8.object({ runId: z8.string() })).mutation(({ ctx, input }) => {
9065
+ getRunRow(ctx, input.runId);
8719
9066
  cancelRun(input.runId);
8720
9067
  return { ok: true };
8721
9068
  })
@@ -8723,7 +9070,7 @@ var init_run = __esm(() => {
8723
9070
  });
8724
9071
 
8725
9072
  // src/services/settings.ts
8726
- import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync4 } from "fs";
9073
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
8727
9074
  import { dirname as dirname9 } from "path";
8728
9075
  import * as z9 from "zod";
8729
9076
  import { eq as eq13 } from "drizzle-orm";
@@ -8870,8 +9217,8 @@ function updateSettings(ctx, input, io = {}) {
8870
9217
  function writeGlobal(configFile, configKey, value) {
8871
9218
  const raw = readRawConfig(configFile);
8872
9219
  raw[configKey] = value;
8873
- mkdirSync8(dirname9(configFile), { recursive: true });
8874
- writeFileSync4(configFile, `${JSON.stringify(raw, null, 2)}
9220
+ mkdirSync9(dirname9(configFile), { recursive: true });
9221
+ writeFileSync5(configFile, `${JSON.stringify(raw, null, 2)}
8875
9222
  `);
8876
9223
  }
8877
9224
  function updateStepModel(ctx, input, configFile, io) {
@@ -8909,8 +9256,8 @@ function writeStepModel(configFile, step, value) {
8909
9256
  else
8910
9257
  stepModels[step] = value;
8911
9258
  raw.stepModels = stepModels;
8912
- mkdirSync8(dirname9(configFile), { recursive: true });
8913
- writeFileSync4(configFile, `${JSON.stringify(raw, null, 2)}
9259
+ mkdirSync9(dirname9(configFile), { recursive: true });
9260
+ writeFileSync5(configFile, `${JSON.stringify(raw, null, 2)}
8914
9261
  `);
8915
9262
  }
8916
9263
  function field(view, key) {
@@ -9147,11 +9494,11 @@ async function gitIdentityProbe(exec, cwd) {
9147
9494
  async function containerRuntimeProbe(exec) {
9148
9495
  const id = "container-runtime";
9149
9496
  const label = "Container runtime (Docker / Podman)";
9150
- const docker3 = await exec("docker", ["--version"]);
9151
- if (docker3.ok && docker3.code === 0) {
9497
+ const docker2 = await exec("docker", ["--version"]);
9498
+ if (docker2.ok && docker2.code === 0) {
9152
9499
  const info = await exec("docker", ["info"]);
9153
9500
  if (info.ok && info.code === 0) {
9154
- return { id, label, tier: 2, status: "ok", detail: docker3.stdout.trim() || "docker healthy" };
9501
+ return { id, label, tier: 2, status: "ok", detail: docker2.stdout.trim() || "docker healthy" };
9155
9502
  }
9156
9503
  return {
9157
9504
  id,
@@ -9287,8 +9634,8 @@ var init_system_exec = __esm(() => {
9287
9634
  });
9288
9635
 
9289
9636
  // src/services/setup.ts
9290
- import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync9, readdirSync as readdirSync6, writeFileSync as writeFileSync5 } from "fs";
9291
- import { dirname as dirname10, join as join17 } from "path";
9637
+ import { copyFileSync, existsSync as existsSync18, mkdirSync as mkdirSync10, readFileSync as readFileSync9, readdirSync as readdirSync6, writeFileSync as writeFileSync6 } from "fs";
9638
+ import { dirname as dirname10, join as join18 } from "path";
9292
9639
  import { fileURLToPath as fileURLToPath7 } from "url";
9293
9640
  async function writeGitIdentity(exec, identity) {
9294
9641
  const name = identity.name.trim();
@@ -9366,11 +9713,11 @@ function resolveSandcastleBin() {
9366
9713
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
9367
9714
  let dir = dirname10(fileURLToPath7(mainUrl));
9368
9715
  for (let hops = 0;hops < 6; hops++) {
9369
- const manifestPath = join17(dir, "package.json");
9716
+ const manifestPath = join18(dir, "package.json");
9370
9717
  if (existsSync18(manifestPath)) {
9371
9718
  const manifest = JSON.parse(readFileSync9(manifestPath, "utf8"));
9372
9719
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
9373
- return join17(dir, manifest.bin.sandcastle);
9720
+ return join18(dir, manifest.bin.sandcastle);
9374
9721
  }
9375
9722
  }
9376
9723
  const parent = dirname10(dir);
@@ -9398,18 +9745,18 @@ function sandcastleTemplateDir() {
9398
9745
  return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
9399
9746
  }
9400
9747
  function scaffoldSandcastleConfig(templateDir, targetDir) {
9401
- const dir = join17(targetDir, ".sandcastle");
9748
+ const dir = join18(targetDir, ".sandcastle");
9402
9749
  if (existsSync18(dir))
9403
9750
  return { scaffolded: false, dir };
9404
- mkdirSync9(dir, { recursive: true });
9751
+ mkdirSync10(dir, { recursive: true });
9405
9752
  for (const name of readdirSync6(templateDir)) {
9406
- copyFileSync(join17(templateDir, name), join17(dir, name));
9753
+ copyFileSync(join18(templateDir, name), join18(dir, name));
9407
9754
  }
9408
9755
  return { scaffolded: true, dir };
9409
9756
  }
9410
9757
  function prepareSandboxBuildContext() {
9411
9758
  const target = sandboxBuildDir();
9412
- mkdirSync9(target, { recursive: true });
9759
+ mkdirSync10(target, { recursive: true });
9413
9760
  scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
9414
9761
  return target;
9415
9762
  }
@@ -9447,8 +9794,8 @@ function fileAfkTokenIo(exec) {
9447
9794
  return {
9448
9795
  read: () => existsSync18(path) ? readFileSync9(path, "utf8") : "",
9449
9796
  write: (content) => {
9450
- mkdirSync9(dirname10(path), { recursive: true });
9451
- writeFileSync5(path, content, "utf8");
9797
+ mkdirSync10(dirname10(path), { recursive: true });
9798
+ writeFileSync6(path, content, "utf8");
9452
9799
  },
9453
9800
  verify: createTokenVerifier(exec)
9454
9801
  };
@@ -9514,11 +9861,11 @@ var init_system = __esm(() => {
9514
9861
  });
9515
9862
 
9516
9863
  // src/services/test-notes.ts
9517
- import { mkdirSync as mkdirSync10, writeFileSync as writeFileSync6 } from "fs";
9864
+ import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync7 } from "fs";
9518
9865
  import { dirname as dirname11 } from "path";
9519
9866
  import { asc as asc4, eq as eq14 } from "drizzle-orm";
9520
9867
  function rowToNote(row) {
9521
- return {
9868
+ return TestNote.parse({
9522
9869
  id: row.id,
9523
9870
  featureId: row.featureId,
9524
9871
  lap: row.lap,
@@ -9527,7 +9874,7 @@ function rowToNote(row) {
9527
9874
  ticketId: row.ticketId ?? undefined,
9528
9875
  createdAt: row.createdAt,
9529
9876
  updatedAt: row.updatedAt
9530
- };
9877
+ });
9531
9878
  }
9532
9879
  function getNote(ctx, id) {
9533
9880
  const row = ctx.db.select().from(testNotes).where(eq14(testNotes.id, id)).get();
@@ -9660,8 +10007,8 @@ function renderTestNotes(ctx, feature) {
9660
10007
  lines.push(noteLine(ctx, note));
9661
10008
  }
9662
10009
  const path = featureDocPath(projectForFeature(ctx, feature), feature, "test-notes.md");
9663
- mkdirSync10(dirname11(path), { recursive: true });
9664
- writeFileSync6(path, `${lines.join(`
10010
+ mkdirSync11(dirname11(path), { recursive: true });
10011
+ writeFileSync7(path, `${lines.join(`
9665
10012
  `)}
9666
10013
  `, "utf8");
9667
10014
  }
@@ -9852,6 +10199,8 @@ async function startServer() {
9852
10199
  }
9853
10200
  const server = Bun.serve({
9854
10201
  port: config2.serverPort,
10202
+ hostname: SERVE_HOSTNAME,
10203
+ idleTimeout: SERVE_IDLE_TIMEOUT_SECONDS,
9855
10204
  fetch(req, srv) {
9856
10205
  if (tryUpgradeTerminal(req, srv))
9857
10206
  return;
@@ -9865,11 +10214,11 @@ async function startServer() {
9865
10214
  const g = globalThis;
9866
10215
  if (!g[SHUTDOWN_KEY]) {
9867
10216
  g[SHUTDOWN_KEY] = true;
9868
- const shutdown = () => {
9869
- ptyRegistry().killAll();
9870
- server.stop();
9871
- process.exit(0);
9872
- };
10217
+ const shutdown = createShutdown({
10218
+ killAllTrees: () => ptyRegistry().killAllTrees(),
10219
+ stop: () => server.stop(),
10220
+ exit: (code) => process.exit(code)
10221
+ });
9873
10222
  process.on("SIGINT", shutdown);
9874
10223
  process.on("SIGTERM", shutdown);
9875
10224
  }