runcastle 1.2.3 → 1.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -25,6 +25,7 @@ __export(exports_paths, {
25
25
  projectWorktreesDir: () => projectWorktreesDir,
26
26
  prodDataDir: () => prodDataDir,
27
27
  logsDir: () => logsDir,
28
+ installIdPath: () => installIdPath,
28
29
  featureDocsRel: () => featureDocsRel,
29
30
  envPath: () => envPath,
30
31
  devDataDir: () => devDataDir,
@@ -63,6 +64,9 @@ function configPath() {
63
64
  function envPath() {
64
65
  return join(dataDir(), ".env");
65
66
  }
67
+ function installIdPath() {
68
+ return join(dataDir(), "install-id");
69
+ }
66
70
  function logsDir() {
67
71
  return join(dataDir(), "logs");
68
72
  }
@@ -174,8 +178,11 @@ function loadConfig(env = process.env) {
174
178
  }
175
179
  }
176
180
  const overrides = {};
177
- if (env.RUNCASTLE_SERVER_PORT)
178
- 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);
179
186
  if (env.RUNCASTLE_MODEL)
180
187
  overrides.model = env.RUNCASTLE_MODEL;
181
188
  if (env.RUNCASTLE_SMOKE_MODEL)
@@ -188,21 +195,12 @@ function loadConfig(env = process.env) {
188
195
  overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
189
196
  if (env.RUNCASTLE_SANDBOX_IMAGE)
190
197
  overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
191
- if (env.RUNCASTLE_BURN_CONCURRENCY) {
192
- overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
193
- }
194
- if (env.RUNCASTLE_BURN_MAX_ITERATIONS) {
195
- overrides.burnMaxIterations = Number(env.RUNCASTLE_BURN_MAX_ITERATIONS);
196
- }
197
- if (env.RUNCASTLE_BURN_ATTEMPTS) {
198
- overrides.burnAttempts = Number(env.RUNCASTLE_BURN_ATTEMPTS);
199
- }
200
- if (env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS !== undefined) {
201
- overrides.burnConflictAttempts = Number(env.RUNCASTLE_BURN_CONFLICT_ATTEMPTS);
202
- }
203
- if (env.RUNCASTLE_BURN_CPUS)
204
- overrides.burnCpus = Number(env.RUNCASTLE_BURN_CPUS);
205
- 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 !== "") {
206
204
  overrides.burnGuard = env.RUNCASTLE_BURN_GUARD !== "0" && env.RUNCASTLE_BURN_GUARD !== "false";
207
205
  }
208
206
  if (env.RUNCASTLE_SETUP_COMMAND)
@@ -231,6 +229,7 @@ function ensureDataDir() {
231
229
  mkdirSync(join2(root, "sessions"), { recursive: true });
232
230
  mkdirSync(join2(root, "worktrees"), { recursive: true });
233
231
  }
232
+ var SERVE_HOSTNAME = "127.0.0.1", SERVE_IDLE_TIMEOUT_SECONDS = 120;
234
233
  var init_config2 = __esm(() => {
235
234
  init_config_load();
236
235
  init_paths();
@@ -245,7 +244,10 @@ var init_ids = () => {};
245
244
 
246
245
  // ../core/src/schemas.ts
247
246
  import * as z2 from "zod";
248
- var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, TestNoteStatus, TestNote, PREPARED_KEYS, PreparedKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
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;
249
251
  var init_schemas = __esm(() => {
250
252
  Phase = z2.enum([
251
253
  "ideation",
@@ -265,8 +267,14 @@ var init_schemas = __esm(() => {
265
267
  "prepare",
266
268
  "project"
267
269
  ]);
270
+ PROJECT_SESSION_KINDS = ["prepare", "project"];
271
+ SessionPurpose = z2.enum(["resolve-conflict"]);
272
+ MergeBranchPair = z2.object({
273
+ mergeFrom: z2.string(),
274
+ mergeInto: z2.string()
275
+ });
268
276
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
269
- FeatureStatus = z2.enum(["active", "shipped", "archived"]);
277
+ FeatureStatus = z2.enum(["draft", "active", "shipped", "archived"]);
270
278
  SessionStatus = z2.enum(["launching", "live", "ended"]);
271
279
  TicketInput = z2.object({
272
280
  title: z2.string(),
@@ -285,7 +293,8 @@ var init_schemas = __esm(() => {
285
293
  commits: z2.array(z2.string()),
286
294
  error: z2.string().optional(),
287
295
  attemptBranch: z2.string().optional(),
288
- conflictFiles: z2.array(z2.string()).optional()
296
+ conflictFiles: z2.array(z2.string()).optional(),
297
+ digest: z2.string().optional()
289
298
  });
290
299
  WaypointType = z2.enum(["grilling", "research", "prototype", "task"]);
291
300
  WaypointStatus = z2.enum(["open", "claimed", "resolved", "dropped"]);
@@ -350,7 +359,8 @@ var init_schemas = __esm(() => {
350
359
  dbResetCommand: z2.string().optional(),
351
360
  driveSetupCommand: z2.string().optional(),
352
361
  driveStopCommand: z2.string().optional(),
353
- driveEnv: z2.string().optional()
362
+ driveEnv: z2.string().optional(),
363
+ closedAt: z2.number().optional()
354
364
  });
355
365
  ProjectFinding = z2.object({
356
366
  key: PreparedKey,
@@ -368,6 +378,7 @@ var init_schemas = __esm(() => {
368
378
  slug: z2.string(),
369
379
  title: z2.string(),
370
380
  oneLiner: z2.string(),
381
+ brief: z2.string().optional(),
371
382
  mapped: z2.boolean(),
372
383
  lap: z2.number(),
373
384
  phase: Phase,
@@ -381,6 +392,8 @@ var init_schemas = __esm(() => {
381
392
  featureId: z2.string().optional(),
382
393
  projectId: z2.string().optional(),
383
394
  kind: SessionKind,
395
+ purpose: SessionPurpose.optional(),
396
+ purposeData: MergeBranchPair.optional(),
384
397
  ccSessionId: z2.string().optional(),
385
398
  transcriptPath: z2.string().optional(),
386
399
  status: SessionStatus,
@@ -394,7 +407,8 @@ var init_schemas = __esm(() => {
394
407
  status: RunStatus,
395
408
  startedAt: z2.number(),
396
409
  endedAt: z2.number().optional(),
397
- summary: z2.string().optional()
410
+ summary: z2.string().optional(),
411
+ digest: z2.string().optional()
398
412
  });
399
413
  SettingSource = z2.enum(["env", "project", "file", "default"]);
400
414
  SettingScope = z2.enum(["global", "project"]);
@@ -569,7 +583,7 @@ var init_pipeline = __esm(() => {
569
583
  });
570
584
 
571
585
  // ../core/src/db-schema.ts
572
- import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
586
+ import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
573
587
  var projects, projectFindings, features, sessions, tickets, testNotes, waypoints, runs, events, gateOverrides;
574
588
  var init_db_schema = __esm(() => {
575
589
  projects = sqliteTable("projects", {
@@ -605,6 +619,7 @@ var init_db_schema = __esm(() => {
605
619
  slug: text("slug").notNull(),
606
620
  title: text("title").notNull(),
607
621
  oneLiner: text("one_liner").notNull(),
622
+ brief: text("brief"),
608
623
  mapped: integer("mapped", { mode: "boolean" }).notNull().default(false),
609
624
  lap: integer("lap").notNull().default(1),
610
625
  phase: text("phase").notNull().$type(),
@@ -619,6 +634,8 @@ var init_db_schema = __esm(() => {
619
634
  projectId: text("project_id"),
620
635
  lap: integer("lap").notNull().default(1),
621
636
  kind: text("kind").notNull().$type(),
637
+ purpose: text("purpose").$type(),
638
+ purposeData: text("purpose_data", { mode: "json" }).$type(),
622
639
  ccSessionId: text("cc_session_id"),
623
640
  transcriptPath: text("transcript_path"),
624
641
  status: text("status").notNull().$type(),
@@ -640,7 +657,8 @@ var init_db_schema = __esm(() => {
640
657
  commits: text("commits", { mode: "json" }).notNull().$type(),
641
658
  error: text("error"),
642
659
  attemptBranch: text("attempt_branch"),
643
- conflictFiles: text("conflict_files", { mode: "json" }).$type()
660
+ conflictFiles: text("conflict_files", { mode: "json" }).$type(),
661
+ digest: text("digest")
644
662
  });
645
663
  testNotes = sqliteTable("test_notes", {
646
664
  id: text("id").primaryKey(),
@@ -673,7 +691,8 @@ var init_db_schema = __esm(() => {
673
691
  status: text("status").notNull().$type(),
674
692
  startedAt: integer("started_at").notNull(),
675
693
  endedAt: integer("ended_at"),
676
- summary: text("summary")
694
+ summary: text("summary"),
695
+ digest: text("digest")
677
696
  });
678
697
  events = sqliteTable("events", {
679
698
  id: integer("id").primaryKey({ autoIncrement: true }),
@@ -686,7 +705,10 @@ var init_db_schema = __esm(() => {
686
705
  type: text("type").notNull(),
687
706
  message: text("message").notNull(),
688
707
  data: text("data", { mode: "json" })
689
- });
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
+ ]);
690
712
  gateOverrides = sqliteTable("gate_overrides", {
691
713
  id: integer("id").primaryKey({ autoIncrement: true }),
692
714
  featureId: text("feature_id").notNull(),
@@ -1266,6 +1288,16 @@ var init_ring_buffer = __esm(() => {
1266
1288
  });
1267
1289
 
1268
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
+
1269
1301
  class PtyRegistry {
1270
1302
  entries = new Map;
1271
1303
  create(input) {
@@ -1323,18 +1355,31 @@ class PtyRegistry {
1323
1355
  const entry = this.entries.get(sessionId);
1324
1356
  if (!entry)
1325
1357
  return false;
1326
- 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);
1327
1366
  return true;
1328
1367
  }
1329
1368
  remove(sessionId) {
1330
1369
  this.entries.delete(sessionId);
1331
1370
  }
1332
- killAll() {
1333
- for (const entry of this.entries.values()) {
1334
- if (!entry.exited)
1335
- entry.pty.kill();
1336
- }
1371
+ async killAllTrees() {
1372
+ const all = [...this.entries.values()];
1337
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 {}
1338
1383
  }
1339
1384
  ids() {
1340
1385
  return [...this.entries.keys()];
@@ -1346,7 +1391,7 @@ function ptyRegistry() {
1346
1391
  g[REGISTRY_KEY] = new PtyRegistry;
1347
1392
  return g[REGISTRY_KEY];
1348
1393
  }
1349
- var REGISTRY_KEY;
1394
+ var KILL_TREE_TIMEOUT_MS = 5000, REGISTRY_KEY;
1350
1395
  var init_registry = __esm(() => {
1351
1396
  init_pty();
1352
1397
  init_ring_buffer();
@@ -1427,7 +1472,7 @@ var init_bus = __esm(() => {
1427
1472
  // src/services/events.ts
1428
1473
  import { and, asc, desc, eq, gt, max } from "drizzle-orm";
1429
1474
  function rowToEvent(row) {
1430
- return {
1475
+ return EventRow.parse({
1431
1476
  id: row.id,
1432
1477
  projectId: row.projectId,
1433
1478
  featureId: row.featureId ?? undefined,
@@ -1437,7 +1482,7 @@ function rowToEvent(row) {
1437
1482
  type: row.type,
1438
1483
  message: row.message,
1439
1484
  data: row.data ?? undefined
1440
- };
1485
+ });
1441
1486
  }
1442
1487
  function projectIdForFeature(ctx, featureId) {
1443
1488
  const row = ctx.db.select({ projectId: features.projectId }).from(features).where(eq(features.id, featureId)).get();
@@ -1508,6 +1553,7 @@ function listByProject(ctx, projectId, afterId = 0) {
1508
1553
  return rows.map(rowToEvent);
1509
1554
  }
1510
1555
  var init_events = __esm(() => {
1556
+ init_src();
1511
1557
  init_schema();
1512
1558
  init_errors();
1513
1559
  init_bus();
@@ -1516,12 +1562,13 @@ var init_events = __esm(() => {
1516
1562
  // src/services/repo.ts
1517
1563
  import { and as and2, desc as desc2, eq as eq2 } from "drizzle-orm";
1518
1564
  function rowToFeature(row) {
1519
- return {
1565
+ return Feature.parse({
1520
1566
  id: row.id,
1521
1567
  projectId: row.projectId,
1522
1568
  slug: row.slug,
1523
1569
  title: row.title,
1524
1570
  oneLiner: row.oneLiner,
1571
+ brief: row.brief ?? undefined,
1525
1572
  mapped: row.mapped,
1526
1573
  lap: row.lap,
1527
1574
  phase: row.phase,
@@ -1529,10 +1576,10 @@ function rowToFeature(row) {
1529
1576
  baseBranch: row.baseBranch ?? undefined,
1530
1577
  status: row.status,
1531
1578
  createdAt: row.createdAt
1532
- };
1579
+ });
1533
1580
  }
1534
1581
  function rowToProject(row) {
1535
- return {
1582
+ return Project.parse({
1536
1583
  id: row.id,
1537
1584
  name: row.name,
1538
1585
  repoPath: row.repoPath,
@@ -1545,32 +1592,36 @@ function rowToProject(row) {
1545
1592
  dbResetCommand: row.dbResetCommand ?? undefined,
1546
1593
  driveSetupCommand: row.driveSetupCommand ?? undefined,
1547
1594
  driveStopCommand: row.driveStopCommand ?? undefined,
1548
- driveEnv: row.driveEnv ?? undefined
1549
- };
1595
+ driveEnv: row.driveEnv ?? undefined,
1596
+ closedAt: row.closedAt ?? undefined
1597
+ });
1550
1598
  }
1551
1599
  function rowToRun(row) {
1552
- return {
1600
+ return Run.parse({
1553
1601
  id: row.id,
1554
1602
  featureId: row.featureId,
1555
1603
  workflow: row.workflow,
1556
1604
  status: row.status,
1557
1605
  startedAt: row.startedAt,
1558
1606
  endedAt: row.endedAt ?? undefined,
1559
- summary: row.summary ?? undefined
1560
- };
1607
+ summary: row.summary ?? undefined,
1608
+ digest: row.digest ?? undefined
1609
+ });
1561
1610
  }
1562
1611
  function rowToSession(row) {
1563
- return {
1612
+ return SessionRow.parse({
1564
1613
  id: row.id,
1565
1614
  featureId: row.featureId ?? undefined,
1566
1615
  projectId: row.projectId ?? undefined,
1567
1616
  kind: row.kind,
1617
+ purpose: row.purpose ?? undefined,
1618
+ purposeData: row.purposeData ?? undefined,
1568
1619
  ccSessionId: row.ccSessionId ?? undefined,
1569
1620
  transcriptPath: row.transcriptPath ?? undefined,
1570
1621
  status: row.status,
1571
1622
  awaitingInput: row.awaitingInput,
1572
1623
  worktreePath: row.worktreePath
1573
- };
1624
+ });
1574
1625
  }
1575
1626
  function tryGetFeature(ctx, id) {
1576
1627
  const row = ctx.db.select().from(features).where(eq2(features.id, id)).get();
@@ -1646,6 +1697,7 @@ function setFeatureStatus(ctx, featureId, status) {
1646
1697
  return { ...current, status };
1647
1698
  }
1648
1699
  var init_repo = __esm(() => {
1700
+ init_src();
1649
1701
  init_schema();
1650
1702
  init_errors();
1651
1703
  init_events();
@@ -1654,7 +1706,7 @@ var init_repo = __esm(() => {
1654
1706
  // src/services/waypoints.ts
1655
1707
  import { and as and3, asc as asc2, eq as eq3 } from "drizzle-orm";
1656
1708
  function rowToWaypoint(row) {
1657
- return {
1709
+ return Waypoint.parse({
1658
1710
  id: row.id,
1659
1711
  featureId: row.featureId,
1660
1712
  seq: row.seq,
@@ -1667,7 +1719,7 @@ function rowToWaypoint(row) {
1667
1719
  claimedBy: row.claimedBy ?? undefined,
1668
1720
  lastSessionId: row.lastSessionId ?? undefined,
1669
1721
  summary: row.summary ?? undefined
1670
- };
1722
+ });
1671
1723
  }
1672
1724
  function listByFeature(ctx, featureId) {
1673
1725
  return ctx.db.select().from(waypoints).where(eq3(waypoints.featureId, featureId)).orderBy(asc2(waypoints.seq)).all().map(rowToWaypoint);
@@ -1783,6 +1835,9 @@ function releaseForSession(ctx, claimant) {
1783
1835
  }
1784
1836
  function resolve3(ctx, id, disposition, summary) {
1785
1837
  const wp = getWaypoint(ctx, id);
1838
+ if (isTerminal(wp)) {
1839
+ throw new InvalidInputError(`waypoint ${wp.seq} is already ${wp.status}`);
1840
+ }
1786
1841
  const before = listByFeature(ctx, wp.featureId);
1787
1842
  const bySeqBefore = new Map(before.map((w) => [w.seq, w]));
1788
1843
  ctx.db.update(waypoints).set({ status: disposition, summary, claimedBy: null }).where(eq3(waypoints.id, id)).run();
@@ -1819,6 +1874,91 @@ var init_waypoints = __esm(() => {
1819
1874
  TERMINAL = new Set(["resolved", "dropped"]);
1820
1875
  });
1821
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
+
1822
1962
  // src/pty/dev-pane.ts
1823
1963
  function drivePaneId(ownerId) {
1824
1964
  return `${DRIVE_PREFIX}${ownerId}`;
@@ -1879,24 +2019,12 @@ function devPaneLive(paneId) {
1879
2019
  const entry = ptyRegistry().get(paneId);
1880
2020
  return !!entry && !entry.exited;
1881
2021
  }
1882
- function killTreeBounded(pty) {
1883
- return new Promise((resolve4) => {
1884
- const timer = setTimeout(resolve4, KILL_TREE_TIMEOUT_MS);
1885
- pty.killTree().catch(() => {}).then(() => {
1886
- clearTimeout(timer);
1887
- resolve4();
1888
- });
1889
- });
1890
- }
1891
2022
  async function stopDevPane(paneId) {
1892
2023
  const reg = ptyRegistry();
1893
- const entry = reg.get(paneId);
1894
- if (entry && !entry.exited)
1895
- await killTreeBounded(entry.pty);
1896
- reg.kill(paneId);
2024
+ await reg.killTree(paneId);
1897
2025
  reg.remove(paneId);
1898
2026
  }
1899
- var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192, KILL_TREE_TIMEOUT_MS = 5000;
2027
+ var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
1900
2028
  var init_dev_pane = __esm(() => {
1901
2029
  init_events();
1902
2030
  init_registry();
@@ -1954,7 +2082,8 @@ function runDriveHook(command, opts) {
1954
2082
  cwd: opts.cwd,
1955
2083
  env: opts.env ?? process.env,
1956
2084
  windowsHide: true,
1957
- windowsVerbatimArguments: verbatim
2085
+ windowsVerbatimArguments: verbatim,
2086
+ detached: process.platform !== "win32"
1958
2087
  });
1959
2088
  } catch (err) {
1960
2089
  output = err instanceof Error ? err.message : String(err);
@@ -1963,7 +2092,11 @@ function runDriveHook(command, opts) {
1963
2092
  }
1964
2093
  timer = setTimeout(() => {
1965
2094
  timedOut = true;
1966
- child.kill();
2095
+ const pid = child.pid;
2096
+ if (pid === undefined)
2097
+ child.kill();
2098
+ else
2099
+ killProcessTree(pid).then(() => child.kill());
1967
2100
  const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
1968
2101
  grace.unref?.();
1969
2102
  }, timeoutMs);
@@ -1994,6 +2127,7 @@ function describeHookResult(command, result) {
1994
2127
  }
1995
2128
  var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
1996
2129
  var init_drive_hooks = __esm(() => {
2130
+ init_kill_tree();
1997
2131
  DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
1998
2132
  });
1999
2133
 
@@ -2210,9 +2344,9 @@ var init_findings = __esm(() => {
2210
2344
  });
2211
2345
 
2212
2346
  // src/services/git.ts
2213
- 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";
2214
2348
  import { tmpdir } from "os";
2215
- import { dirname, join as join5, resolve as resolve4 } from "path";
2349
+ import { dirname, join as join6, resolve as resolve4 } from "path";
2216
2350
  import { simpleGit } from "simple-git";
2217
2351
  function migrationPaths(paths) {
2218
2352
  return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
@@ -2237,7 +2371,7 @@ function canon(p) {
2237
2371
  return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2238
2372
  }
2239
2373
  async function assertRepo(repoPath) {
2240
- if (!existsSync5(repoPath)) {
2374
+ if (!existsSync6(repoPath)) {
2241
2375
  throw new InvalidInputError(`path does not exist: ${repoPath}`);
2242
2376
  }
2243
2377
  let isRepo = false;
@@ -2326,7 +2460,7 @@ async function ensureTalkWorktree(project, feature) {
2326
2460
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2327
2461
  if (await worktreeIsValid(g, worktreePath, branch))
2328
2462
  return worktreePath;
2329
- if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2463
+ if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2330
2464
  if (await checkoutInWorktree(worktreePath, branch))
2331
2465
  return worktreePath;
2332
2466
  }
@@ -2349,14 +2483,13 @@ async function addWorktree(g, worktreePath, branch, label) {
2349
2483
  }
2350
2484
  }
2351
2485
  }
2352
- async function ensureProjectWorktree(project) {
2486
+ async function ensureProjectWorktree(project, onLanded) {
2353
2487
  const worktreePath = worktreeDir(project.id, PROJECT_WORKTREE_SLUG);
2354
2488
  const g = git(project.repoPath);
2355
2489
  const base = project.mainBranch;
2356
- const leftover = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2357
- if (leftover.length > 0) {
2358
- await mergeTempBranch(project.repoPath, base, PROJECT_BRANCH);
2359
- }
2490
+ const landed = await landProjectBranch(project);
2491
+ if (landed)
2492
+ onLanded?.(landed);
2360
2493
  const stillAhead = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2361
2494
  if (stillAhead.length === 0) {
2362
2495
  await deleteBranchDetachingWorktrees(g, project.repoPath, PROJECT_BRANCH);
@@ -2364,7 +2497,7 @@ async function ensureProjectWorktree(project) {
2364
2497
  }
2365
2498
  if (await worktreeIsValid(g, worktreePath, PROJECT_BRANCH))
2366
2499
  return worktreePath;
2367
- if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2500
+ if (existsSync6(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2368
2501
  if (await checkoutInWorktree(worktreePath, PROJECT_BRANCH))
2369
2502
  return worktreePath;
2370
2503
  }
@@ -2390,7 +2523,7 @@ async function ensureBranchExists(g, branch, from) {
2390
2523
  }
2391
2524
  }
2392
2525
  async function worktreeIsValid(g, worktreePath, branch) {
2393
- if (!existsSync5(worktreePath))
2526
+ if (!existsSync6(worktreePath))
2394
2527
  return false;
2395
2528
  const registered = await registeredWorktrees(g);
2396
2529
  if (!registered.has(canon(worktreePath)))
@@ -2436,7 +2569,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2436
2569
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2437
2570
  }
2438
2571
  async function detachWorktree(path) {
2439
- if (!existsSync5(path))
2572
+ if (!existsSync6(path))
2440
2573
  return false;
2441
2574
  const g = git(path);
2442
2575
  let head;
@@ -2451,7 +2584,7 @@ async function detachWorktree(path) {
2451
2584
  return true;
2452
2585
  }
2453
2586
  async function reattachWorktree(path, branch) {
2454
- if (!existsSync5(path))
2587
+ if (!existsSync6(path))
2455
2588
  return;
2456
2589
  try {
2457
2590
  await git(path).checkout(branch);
@@ -2467,21 +2600,21 @@ function ticketBranchName(slug, ticketSeq, unique) {
2467
2600
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2468
2601
  }
2469
2602
  function burnWorktreePath(repoPath, branch) {
2470
- return join5(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2603
+ return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2471
2604
  }
2472
2605
  async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2473
2606
  const path = burnWorktreePath(repoPath, branch);
2474
2607
  const attempts = Math.max(1, opts.attempts ?? 3);
2475
2608
  const delayMs = opts.delayMs ?? 750;
2476
2609
  const g = git(repoPath);
2477
- for (let attempt = 1;attempt <= attempts && existsSync5(path); attempt++) {
2610
+ for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2478
2611
  try {
2479
2612
  await g.raw(["worktree", "remove", "--force", path]);
2480
2613
  } catch {}
2481
- if (existsSync5(path) && attempt < attempts)
2614
+ if (existsSync6(path) && attempt < attempts)
2482
2615
  await sleep(delayMs);
2483
2616
  }
2484
- if (existsSync5(path)) {
2617
+ if (existsSync6(path)) {
2485
2618
  try {
2486
2619
  rmSync(path, { recursive: true, force: true });
2487
2620
  } catch {}
@@ -2489,7 +2622,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2489
2622
  try {
2490
2623
  await g.raw(["worktree", "prune"]);
2491
2624
  } catch {}
2492
- return !existsSync5(path);
2625
+ return !existsSync6(path);
2493
2626
  }
2494
2627
  function sleep(ms) {
2495
2628
  return new Promise((done) => setTimeout(done, ms));
@@ -2637,11 +2770,11 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2637
2770
  async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2638
2771
  let dir;
2639
2772
  try {
2640
- dir = mkdtempSync(join5(tmpdir(), "rc-land-"));
2773
+ dir = mkdtempSync(join6(tmpdir(), "rc-land-"));
2641
2774
  } catch (e) {
2642
2775
  return { ok: false, error: errMsg(e) };
2643
2776
  }
2644
- const wt = join5(dir, "wt");
2777
+ const wt = join6(dir, "wt");
2645
2778
  try {
2646
2779
  await g.raw(["worktree", "add", wt, featureBranchName]);
2647
2780
  const gw = git(wt);
@@ -2748,7 +2881,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2748
2881
  try {
2749
2882
  await g.raw(["worktree", "prune"]);
2750
2883
  } catch {}
2751
- if (existsSync5(worktreePath)) {
2884
+ if (existsSync6(worktreePath)) {
2752
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");
2753
2886
  }
2754
2887
  }
@@ -2775,7 +2908,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2775
2908
  return { deleted, kept };
2776
2909
  }
2777
2910
  async function commitDocs(worktreePath, message) {
2778
- if (!existsSync5(resolve4(worktreePath, DOCS_PATHSPEC)))
2911
+ if (!existsSync6(resolve4(worktreePath, DOCS_PATHSPEC)))
2779
2912
  return;
2780
2913
  const g = git(worktreePath);
2781
2914
  await g.add([DOCS_PATHSPEC]);
@@ -3180,10 +3313,24 @@ async function conflictedFiles(g) {
3180
3313
  return [];
3181
3314
  }
3182
3315
  }
3316
+ async function mergeInProgressAt(worktreePath) {
3317
+ try {
3318
+ return await mergeInProgress(git(worktreePath));
3319
+ } catch {
3320
+ return false;
3321
+ }
3322
+ }
3323
+ async function isAncestor(repoPath, ancestor, descendant) {
3324
+ try {
3325
+ const out = await git(repoPath).raw(["rev-list", "--count", ancestor, `^${descendant}`]);
3326
+ return out.trim() === "0";
3327
+ } catch {
3328
+ return false;
3329
+ }
3330
+ }
3183
3331
  async function mergeInProgress(g) {
3184
3332
  try {
3185
- await g.raw(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
3186
- return true;
3333
+ return (await g.raw(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"])).trim() !== "";
3187
3334
  } catch {
3188
3335
  return false;
3189
3336
  }
@@ -3213,6 +3360,8 @@ function createSessionRow(ctx, input) {
3213
3360
  projectId: input.projectId ?? null,
3214
3361
  lap: input.featureId ? getFeatureRow(ctx, input.featureId).lap : 1,
3215
3362
  kind: input.kind,
3363
+ purpose: input.purpose ?? null,
3364
+ purposeData: input.purposeData ?? null,
3216
3365
  ccSessionId: null,
3217
3366
  transcriptPath: null,
3218
3367
  status: "launching",
@@ -3452,35 +3601,46 @@ function landProjectSession(ctx, session) {
3452
3601
  const project = getProjectById(ctx, session.projectId);
3453
3602
  if (!project)
3454
3603
  return;
3455
- const kept = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3456
- const report = (type, message, data = {}) => {
3457
- emitProject(ctx, project.id, {
3458
- type,
3459
- message,
3460
- data: { sessionId: session.id, branch: PROJECT_BRANCH, ...data }
3461
- });
3462
- };
3463
3604
  const landing = landProjectBranch(project).then((res) => {
3464
- if (!res)
3465
- return;
3466
- if (res.landed) {
3467
- report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${project.mainBranch}`, { commits: res.commits });
3468
- } else if (res.conflict) {
3469
- 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 ?? [] });
3470
- } else {
3471
- 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 });
3472
- }
3605
+ if (res)
3606
+ reportProjectLanding(ctx, project, res, { sessionId: session.id });
3473
3607
  }).catch((e) => {
3474
- 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
+ });
3475
3617
  });
3476
3618
  inFlightLandings.add(landing);
3477
3619
  landing.finally(() => inFlightLandings.delete(landing));
3478
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
+ }
3479
3637
  function markSessionEnded(ctx, id) {
3480
3638
  const existing = getSessionRow(ctx, id);
3481
3639
  if (!existing)
3482
3640
  return null;
3483
3641
  forgetKickoff(id);
3642
+ if (existing.featureId)
3643
+ stopDocsWatch(existing.featureId);
3484
3644
  ctx.db.update(sessions).set({ status: "ended" }).where(eq5(sessions.id, id)).run();
3485
3645
  return getSessionRow(ctx, id);
3486
3646
  }
@@ -3503,11 +3663,12 @@ function mostRecentLiveSession(ctx) {
3503
3663
  const row = ctx.db.select().from(sessions).where(eq5(sessions.status, "live")).orderBy(desc3(sql2`rowid`)).limit(1).get();
3504
3664
  return row ? rowToSession(row) : null;
3505
3665
  }
3506
- 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;
3507
3667
  var init_sessions = __esm(() => {
3508
3668
  init_src();
3509
3669
  init_schema();
3510
3670
  init_registry();
3671
+ init_docs_watch();
3511
3672
  init_events();
3512
3673
  init_git();
3513
3674
  init_waypoints();
@@ -3527,6 +3688,7 @@ var init_sessions = __esm(() => {
3527
3688
  deliveries = new Map;
3528
3689
  landedProjectSessions = new Set;
3529
3690
  inFlightLandings = new Set;
3691
+ LANDING_KEPT = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3530
3692
  });
3531
3693
 
3532
3694
  // src/launcher/reconcile.ts
@@ -3580,26 +3742,10 @@ async function getRuntimeCtx() {
3580
3742
  }
3581
3743
  var current = null;
3582
3744
 
3583
- // src/services/feature-docs.ts
3584
- import { existsSync as existsSync6 } from "fs";
3585
- import { join as join6 } from "path";
3586
- function featureDocsDir(project, feature) {
3587
- const relSegments = featureDocsRel(feature.slug).split("/");
3588
- const worktree = worktreeDir(project.id, feature.slug);
3589
- const base = existsSync6(worktree) ? worktree : project.repoPath;
3590
- return join6(base, ...relSegments);
3591
- }
3592
- function featureDocPath(project, feature, fileName) {
3593
- return join6(featureDocsDir(project, feature), fileName);
3594
- }
3595
- var init_feature_docs = __esm(() => {
3596
- init_paths();
3597
- });
3598
-
3599
3745
  // src/services/tickets.ts
3600
3746
  import { and as and6, asc as asc3, eq as eq6 } from "drizzle-orm";
3601
3747
  function rowToTicket(row) {
3602
- return {
3748
+ return Ticket.parse({
3603
3749
  id: row.id,
3604
3750
  featureId: row.featureId,
3605
3751
  seq: row.seq,
@@ -3614,8 +3760,9 @@ function rowToTicket(row) {
3614
3760
  commits: row.commits,
3615
3761
  error: row.error ?? undefined,
3616
3762
  attemptBranch: row.attemptBranch ?? undefined,
3617
- conflictFiles: row.conflictFiles ?? undefined
3618
- };
3763
+ conflictFiles: row.conflictFiles ?? undefined,
3764
+ digest: row.digest ?? undefined
3765
+ });
3619
3766
  }
3620
3767
  function resolveBlocking(inputs, startSeq) {
3621
3768
  try {
@@ -3651,7 +3798,8 @@ function storeTickets(ctx, featureId, inputs) {
3651
3798
  commits: [],
3652
3799
  error: null,
3653
3800
  attemptBranch: null,
3654
- conflictFiles: null
3801
+ conflictFiles: null,
3802
+ digest: null
3655
3803
  }));
3656
3804
  ctx.db.insert(tickets).values(rows).run();
3657
3805
  emit(ctx, featureId, {
@@ -3738,6 +3886,8 @@ function updateTicket(ctx, id, patch) {
3738
3886
  set.attemptBranch = patch.attemptBranch;
3739
3887
  if (patch.conflictFiles !== undefined)
3740
3888
  set.conflictFiles = patch.conflictFiles;
3889
+ if (patch.digest !== undefined)
3890
+ set.digest = patch.digest;
3741
3891
  ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
3742
3892
  emit(ctx, current2.featureId, {
3743
3893
  type: "ticket.updated",
@@ -3823,6 +3973,13 @@ function docGate(ctx, feature, fileName, reason) {
3823
3973
  }
3824
3974
  function overrideGate(ctx, featureId, gate, reason) {
3825
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
+ }
3826
3983
  ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
3827
3984
  emit(ctx, featureId, {
3828
3985
  type: "gate.overridden",
@@ -4379,14 +4536,21 @@ function buildIsolatedSetupCommand(tempBranch, setupCommand, pm) {
4379
4536
  }
4380
4537
  function buildWorkspaceNotes(mode) {
4381
4538
  if (mode === "mounted") {
4382
- 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
+ `);
4383
4545
  }
4384
4546
  return [
4385
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.`,
4386
4548
  "",
4387
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.`,
4388
4550
  "",
4389
- `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.`
4390
4554
  ].join(`
4391
4555
  `);
4392
4556
  }
@@ -4648,6 +4812,7 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4648
4812
  const status = new Map(tickets2.map((t) => [t.seq, t.status]));
4649
4813
  const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
4650
4814
  const inFlight = new Map;
4815
+ const digests = [];
4651
4816
  const satisfied = (s) => s === "done" || s === "cancelled";
4652
4817
  const readyState = (seq) => {
4653
4818
  const t = bySeq.get(seq);
@@ -4684,13 +4849,22 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4684
4849
  const outcome = await execute(ctx, t);
4685
4850
  if (outcome.status === "done") {
4686
4851
  status.set(seq, "done");
4687
- ctx.updateTicket(t.id, { status: "done", commits: outcome.commits });
4852
+ ctx.updateTicket(t.id, { status: "done", commits: outcome.commits, digest: outcome.digest });
4688
4853
  ctx.emitEvent({
4689
4854
  type: "ticket.done",
4690
4855
  message: `ticket ${t.seq} done \u2014 ${outcome.commits.length} commit(s)`,
4691
4856
  ticketId: t.id,
4692
4857
  data: { commits: outcome.commits }
4693
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
+ }
4694
4868
  } else {
4695
4869
  failTicket(seq, outcome.error, outcome.event);
4696
4870
  ctx.emitEvent({
@@ -4756,7 +4930,16 @@ async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
4756
4930
  for (const s of status.values())
4757
4931
  if (s === "done")
4758
4932
  done += 1;
4759
- 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
+ `);
4760
4943
  }
4761
4944
  async function burnRun(ctx, deps) {
4762
4945
  const tickets2 = ctx.tickets;
@@ -4777,10 +4960,11 @@ async function burnRun(ctx, deps) {
4777
4960
  });
4778
4961
  return { status: "failed", summary: `dependency cycle: ${path}` };
4779
4962
  }
4780
- const done = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
4963
+ const { done, digests } = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
4781
4964
  const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
4782
4965
  ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
4783
- return { status: done === total ? "succeeded" : "failed", summary };
4966
+ const digest = composeRunDigest(digests);
4967
+ return { status: done === total ? "succeeded" : "failed", summary, ...digest ? { digest } : {} };
4784
4968
  }
4785
4969
  function burnerTemplatePath() {
4786
4970
  return burnerAssetPath("implement-ticket.md");
@@ -4802,11 +4986,11 @@ function readDocsDigestFromDisk(projectId, slug) {
4802
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") }));
4803
4987
  return buildDocsDigest(files);
4804
4988
  }
4805
- function readBlockedFile(dirs) {
4989
+ function readAgentFile(dirs, file) {
4806
4990
  for (const dir of dirs) {
4807
4991
  if (!dir)
4808
4992
  continue;
4809
- const p = join9(dir, "BLOCKED.md");
4993
+ const p = join9(dir, file);
4810
4994
  if (existsSync10(p)) {
4811
4995
  try {
4812
4996
  return readFileSync4(p, "utf8");
@@ -4815,10 +4999,26 @@ function readBlockedFile(dirs) {
4815
4999
  }
4816
5000
  return;
4817
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
+ }
4818
5018
  function buildBurnAgent(config2, token, model) {
4819
5019
  const onHost = config2.sandbox === "noSandbox";
4820
5020
  const opts = {
4821
- ...token ? { env: { CLAUDE_CODE_OAUTH_TOKEN: token } } : {},
5021
+ env: buildAgentEnv(onHost, token),
4822
5022
  ...onHost ? { permissionMode: "bypassPermissions" } : {}
4823
5023
  };
4824
5024
  const agent = claudeCode(model, opts);
@@ -4842,8 +5042,10 @@ function selectSandbox(config2, mounts = []) {
4842
5042
  return docker(imageOpts);
4843
5043
  case "podman":
4844
5044
  return podman(imageOpts);
4845
- default:
5045
+ case "noSandbox":
4846
5046
  return noSandbox();
5047
+ default:
5048
+ throw new Error(`no sandbox provider for sandbox "${config2.sandbox}" \u2014 refusing to run the agent unsandboxed`);
4847
5049
  }
4848
5050
  }
4849
5051
  function buildSandboxOptions(config2, mounts = []) {
@@ -4988,7 +5190,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model, land, en
4988
5190
  }
4989
5191
  await cleanupBurnWorktree(project.repoPath, resolveBranch);
4990
5192
  }
4991
- const blocked = readBlockedFile([result?.preservedWorktreePath, project.repoPath]);
5193
+ const blocked = readAgentFile([result?.preservedWorktreePath, project.repoPath], "BLOCKED.md");
4992
5194
  if (blocked !== undefined && blocked.trim().length > 0) {
4993
5195
  return {
4994
5196
  ok: false,
@@ -5022,11 +5224,16 @@ ${blocked.trim()}`
5022
5224
  label: `ticket ${ticket.seq}`,
5023
5225
  featureBranch: feature.branch
5024
5226
  };
5227
+ let harvestedDigest;
5025
5228
  const landChain = async (branch, commits) => {
5026
5229
  const landing = await landWithResolve(branch, landDeps);
5027
5230
  if (landing.status === "landed") {
5028
5231
  ctx.updateTicket(ticket.id, { attemptBranch: null, conflictFiles: null });
5029
- 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
+ };
5030
5237
  }
5031
5238
  preserveChain(landing.branch);
5032
5239
  if (landing.status === "conflict") {
@@ -5187,7 +5394,9 @@ ${retryNotes}` : basePrompt,
5187
5394
  }
5188
5395
  if (!result)
5189
5396
  return { status: "failed", error: "internal: run loop produced no result" };
5190
- 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);
5191
5400
  const chain = await branchCommitsAhead(project.repoPath, feature.branch, tempBranch);
5192
5401
  let outcome;
5193
5402
  if (result.commits.length === 0 && blocked !== undefined && blocked.trim().length > 0) {
@@ -5321,8 +5530,6 @@ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as re
5321
5530
  import { dirname as dirname4, join as join10 } from "path";
5322
5531
  import { fileURLToPath as fileURLToPath3 } from "url";
5323
5532
  import { run as run2 } from "@ai-hero/sandcastle";
5324
- import { docker as docker2 } from "@ai-hero/sandcastle/sandboxes/docker";
5325
- import { noSandbox as noSandbox2 } from "@ai-hero/sandcastle/sandboxes/no-sandbox";
5326
5533
  function waypointSlug(waypoint) {
5327
5534
  const base = waypoint.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5328
5535
  return `${waypoint.seq}-${base || "waypoint"}`;
@@ -5336,7 +5543,7 @@ async function researchRun(ctx, deps) {
5336
5543
  ctx.emitEvent({ type: "research.error", message: "research run started without a waypoint" });
5337
5544
  return { status: "failed", summary: "no waypoint to research" };
5338
5545
  }
5339
- if (deps.config.sandbox === "docker" && !deps.hasAuthToken) {
5546
+ if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
5340
5547
  ctx.emitEvent({ type: AUTH_MISSING_EVENT2, message: AUTH_MISSING_MESSAGE2 });
5341
5548
  return { status: "failed", summary: "research aborted: auth token missing" };
5342
5549
  }
@@ -5418,7 +5625,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
5418
5625
  const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
5419
5626
  const runOptions = {
5420
5627
  agent: buildBurnAgent(config2, token, model),
5421
- sandbox: config2.sandbox === "docker" ? docker2({ imageName: resolveSandboxImage(config2) }) : noSandbox2(),
5628
+ sandbox: selectSandbox(config2),
5422
5629
  cwd: project.repoPath,
5423
5630
  prompt,
5424
5631
  branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
@@ -5594,10 +5801,12 @@ function cancelRun(runId) {
5594
5801
  async function executeRun(ctx, runId, featureId, workflow2, runPromise, controller, cleanup) {
5595
5802
  let status = "failed";
5596
5803
  let summary = "run failed";
5804
+ let digest;
5597
5805
  try {
5598
5806
  const result = await runPromise;
5599
5807
  status = result.status;
5600
5808
  summary = result.summary;
5809
+ digest = result.digest;
5601
5810
  } catch (e) {
5602
5811
  if (controller.signal.aborted) {
5603
5812
  status = "cancelled";
@@ -5610,7 +5819,7 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
5610
5819
  } finally {
5611
5820
  controllers.delete(runId);
5612
5821
  }
5613
- 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();
5614
5823
  releaseForSession(ctx, runId);
5615
5824
  if (workflowClaimsFeatureBranch(workflow2)) {
5616
5825
  sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
@@ -5665,36 +5874,73 @@ async function createFeature(ctx, input) {
5665
5874
  const slug = uniqueSlug(ctx, project.id, input.title);
5666
5875
  const branch = `feature/${slug}`;
5667
5876
  const requestedBase = input.baseBranch?.trim() || project.mainBranch;
5668
- const { branchReady, baseBranch } = await ensureFeatureBranch(project, slug, requestedBase);
5877
+ const cut = input.draft ? null : await ensureFeatureBranch(project, slug, requestedBase);
5669
5878
  const row = {
5670
5879
  id: newId("feat"),
5671
5880
  projectId: project.id,
5672
5881
  slug,
5673
5882
  title: input.title,
5674
5883
  oneLiner: input.oneLiner,
5884
+ brief: input.draft ? input.brief ?? null : null,
5675
5885
  mapped: false,
5676
5886
  lap: 1,
5677
5887
  phase: "ideation",
5678
5888
  branch,
5679
- baseBranch,
5680
- status: "active",
5889
+ baseBranch: cut?.baseBranch ?? null,
5890
+ status: cut ? "active" : "draft",
5681
5891
  createdAt: Date.now()
5682
5892
  };
5683
5893
  const inserted = ctx.db.insert(features).values(row).returning().get();
5684
5894
  const feature = rowToFeature(inserted);
5685
5895
  emit(ctx, feature.id, {
5686
5896
  type: "feature.created",
5687
- message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
5688
- 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
+ }
5689
5905
  });
5906
+ if (!cut)
5907
+ return feature;
5690
5908
  scaffoldDocs(ctx, feature, { brief: input.brief });
5691
- if (branchReady) {
5909
+ if (cut.branchReady) {
5692
5910
  try {
5693
5911
  await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
5694
5912
  } catch {}
5695
5913
  }
5696
5914
  return feature;
5697
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
+ }
5698
5944
  async function quickChange(ctx, input) {
5699
5945
  const project = requireProjectById(ctx, input.projectId);
5700
5946
  const title = input.title.trim();
@@ -5813,6 +6059,7 @@ function liveSessionOf(ctx, featureId) {
5813
6059
  }
5814
6060
  function advance(ctx, featureId) {
5815
6061
  const feature = getFeatureRow(ctx, featureId);
6062
+ requireNotDraft(feature);
5816
6063
  const gate = nextGate(feature);
5817
6064
  if (!gate)
5818
6065
  throw new GateError("feature is already at the final phase");
@@ -5830,6 +6077,7 @@ function advance(ctx, featureId) {
5830
6077
  }
5831
6078
  async function burn(ctx, featureId, opts = {}) {
5832
6079
  const feature = getFeatureRow(ctx, featureId);
6080
+ requireNotDraft(feature);
5833
6081
  const running = hasActiveRun(ctx, featureId);
5834
6082
  let tickets2 = listByFeature2(ctx, featureId);
5835
6083
  const lapTickets = tickets2.filter((t) => t.lap === feature.lap);
@@ -5880,6 +6128,7 @@ async function burn(ctx, featureId, opts = {}) {
5880
6128
  }
5881
6129
  function rethink(ctx, featureId) {
5882
6130
  const feature = getFeatureRow(ctx, featureId);
6131
+ requireNotDraft(feature);
5883
6132
  if (feature.phase !== RETHINK_LOOP_BACK.from) {
5884
6133
  throw new GateError(`feature must be in the review phase to rethink (currently ${feature.phase})`);
5885
6134
  }
@@ -5993,6 +6242,7 @@ function escalateToMap(ctx, featureId, input) {
5993
6242
  }
5994
6243
  function archiveFeature(ctx, featureId) {
5995
6244
  const feature = getFeatureRow(ctx, featureId);
6245
+ requireNotDraft(feature);
5996
6246
  if (feature.status === "archived") {
5997
6247
  throw new GateError(`feature ${feature.slug} is already archived`);
5998
6248
  }
@@ -6276,8 +6526,19 @@ async function toolDryRunDrive(ctx, session, input) {
6276
6526
  }
6277
6527
  return dryRunDrive(ctx, project, input.action);
6278
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
+ }
6279
6540
  async function toolCreateFeature(ctx, session, input) {
6280
- const project = requireProject(ctx, session);
6541
+ const project = createFeatureProject(ctx, session, input);
6281
6542
  const feature = input.ticket ? await quickChange(ctx, {
6282
6543
  projectId: project.id,
6283
6544
  title: input.title,
@@ -6288,7 +6549,8 @@ async function toolCreateFeature(ctx, session, input) {
6288
6549
  title: input.title,
6289
6550
  oneLiner: input.oneLiner,
6290
6551
  baseBranch: input.baseBranch,
6291
- brief: input.brief
6552
+ brief: input.brief,
6553
+ draft: input.draft
6292
6554
  });
6293
6555
  return {
6294
6556
  id: feature.id,
@@ -6349,7 +6611,8 @@ function toolGetWorkRecord(ctx, session, input) {
6349
6611
  status: t.status,
6350
6612
  seams: t.seams,
6351
6613
  commits: t.commits,
6352
- ...t.error !== undefined ? { error: t.error } : {}
6614
+ ...t.error !== undefined ? { error: t.error } : {},
6615
+ ...t.digest !== undefined ? { digest: t.digest } : {}
6353
6616
  }))
6354
6617
  });
6355
6618
  }
@@ -6403,12 +6666,13 @@ function buildMcpServer() {
6403
6666
  });
6404
6667
  server.registerTool("create_feature", {
6405
6668
  title: "Create a feature",
6406
- 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.",
6407
6670
  inputSchema: {
6408
6671
  title: z3.string().min(1),
6409
6672
  oneLiner: z3.string(),
6410
6673
  baseBranch: z3.string().optional(),
6411
6674
  brief: z3.string().optional(),
6675
+ draft: z3.boolean().optional(),
6412
6676
  ticket: z3.object({ prose: z3.string().min(1) }).optional()
6413
6677
  }
6414
6678
  }, async (args, extra) => {
@@ -6429,7 +6693,7 @@ function buildMcpServer() {
6429
6693
  });
6430
6694
  server.registerTool("get_work_record", {
6431
6695
  title: "Get work record",
6432
- 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.",
6433
6697
  inputSchema: { featureSlug: z3.string().optional(), seam: z3.string().optional() }
6434
6698
  }, async (args, extra) => {
6435
6699
  const rs = await resolveCtxSession(extra);
@@ -6552,7 +6816,7 @@ function buildMcpServer() {
6552
6816
  });
6553
6817
  return server;
6554
6818
  }
6555
- var mcp, server_default;
6819
+ var DRAFTING_KINDS, mcp, server_default;
6556
6820
  var init_server = __esm(() => {
6557
6821
  init_src();
6558
6822
  init_paths();
@@ -6566,6 +6830,7 @@ var init_server = __esm(() => {
6566
6830
  init_repo();
6567
6831
  init_tickets();
6568
6832
  init_waypoints();
6833
+ DRAFTING_KINDS = ["ideation", "revisit", "waypoint", "converge"];
6569
6834
  mcp = new Hono;
6570
6835
  mcp.use("*", async (c, next) => {
6571
6836
  await next();
@@ -6657,25 +6922,29 @@ function guardsEdits(kind) {
6657
6922
  function evaluateEditGuard(input) {
6658
6923
  if (!guardsEdits(input.kind))
6659
6924
  return null;
6925
+ if (input.purpose === "resolve-conflict" && input.mergeInProgress)
6926
+ return null;
6660
6927
  if (!input.toolName || !EDIT_TOOLS.includes(input.toolName))
6661
6928
  return null;
6662
6929
  if (!input.filePath)
6663
6930
  return null;
6931
+ const target = resolve7(input.worktreePath, input.filePath);
6664
6932
  if (!input.featureSlug) {
6665
6933
  return {
6666
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."
6667
6935
  };
6668
6936
  }
6669
6937
  const docs = featureDocsRel(input.featureSlug);
6670
- const docsDir = resolve7(input.worktreePath, docs);
6671
- const target = resolve7(input.worktreePath, input.filePath);
6672
- const rel = relative2(docsDir, target);
6673
- if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel))
6938
+ if (within(resolve7(input.worktreePath, docs), target))
6674
6939
  return null;
6675
6940
  return {
6676
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."
6677
6942
  };
6678
6943
  }
6944
+ function within(dir, target) {
6945
+ const rel = relative2(dir, target);
6946
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
6947
+ }
6679
6948
  function editDenyResponse(denial) {
6680
6949
  return {
6681
6950
  hookSpecificOutput: {
@@ -6833,22 +7102,40 @@ function handleUserPrompt(ctx, sessionId, feature, payload) {
6833
7102
  }
6834
7103
  };
6835
7104
  }
6836
- function handleSessionEnd(ctx, sessionId, feature) {
6837
- markSessionEnded(ctx, sessionId);
6838
- releaseForSession(ctx, sessionId);
7105
+ async function handleSessionEnd(ctx, session, feature) {
7106
+ markSessionEnded(ctx, session.id);
7107
+ releaseForSession(ctx, session.id);
6839
7108
  emit(ctx, feature.id, {
6840
7109
  type: "session.ended",
6841
7110
  message: "session ended",
6842
- data: { sessionId }
7111
+ data: { sessionId: session.id }
6843
7112
  });
7113
+ await noteResolvedMerge(ctx, session, feature);
6844
7114
  return {};
6845
7115
  }
6846
- function handlePreToolUse(session, feature, payload) {
7116
+ async function noteResolvedMerge(ctx, session, feature) {
7117
+ const pair = session.purpose === "resolve-conflict" ? session.purposeData : undefined;
7118
+ if (!pair)
7119
+ return;
7120
+ try {
7121
+ if (!await isAncestor(session.worktreePath, pair.mergeFrom, pair.mergeInto))
7122
+ return;
7123
+ emit(ctx, feature.id, {
7124
+ type: "merge.resolved",
7125
+ message: `merge conflict resolved \u2014 ${pair.mergeFrom} is in ${pair.mergeInto}`,
7126
+ data: { sessionId: session.id, ...pair }
7127
+ });
7128
+ } catch {}
7129
+ }
7130
+ async function handlePreToolUse(session, feature, payload) {
6847
7131
  const toolName = typeof payload?.tool_name === "string" ? payload.tool_name : undefined;
6848
7132
  const toolInput = payload?.tool_input ?? {};
6849
7133
  const path = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.notebook_path === "string" ? toolInput.notebook_path : undefined;
7134
+ const mergeInProgress2 = session.purpose === "resolve-conflict" ? await mergeInProgressAt(session.worktreePath) : false;
6850
7135
  const denial = evaluateEditGuard({
6851
7136
  kind: session.kind,
7137
+ purpose: session.purpose,
7138
+ mergeInProgress: mergeInProgress2,
6852
7139
  worktreePath: session.worktreePath,
6853
7140
  toolName,
6854
7141
  filePath: path,
@@ -6877,6 +7164,7 @@ var init_hooks = __esm(() => {
6877
7164
  init_edit_guard();
6878
7165
  init_sessions();
6879
7166
  init_events();
7167
+ init_git();
6880
7168
  init_prep();
6881
7169
  init_repo();
6882
7170
  init_tickets();
@@ -6914,7 +7202,7 @@ var init_hooks = __esm(() => {
6914
7202
  case "session-end":
6915
7203
  return c.json(handleProjectScopedSessionEnd(ctx, session));
6916
7204
  case "pre-tool":
6917
- return c.json(handlePreToolUse(session, undefined, body.payload));
7205
+ return c.json(await handlePreToolUse(session, undefined, body.payload));
6918
7206
  default:
6919
7207
  return c.json({});
6920
7208
  }
@@ -6928,9 +7216,9 @@ var init_hooks = __esm(() => {
6928
7216
  case "user-prompt":
6929
7217
  return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
6930
7218
  case "session-end":
6931
- return c.json(handleSessionEnd(ctx, sessionId, feature));
7219
+ return c.json(await handleSessionEnd(ctx, session, feature));
6932
7220
  case "pre-tool":
6933
- return c.json(handlePreToolUse(session, feature, body.payload));
7221
+ return c.json(await handlePreToolUse(session, feature, body.payload));
6934
7222
  default:
6935
7223
  return c.json({});
6936
7224
  }
@@ -7072,6 +7360,188 @@ var init_web = __esm(() => {
7072
7360
  };
7073
7361
  });
7074
7362
 
7363
+ // src/version.ts
7364
+ import { readFileSync as readFileSync6 } from "fs";
7365
+ import { dirname as dirname5, join as join12 } from "path";
7366
+ import { fileURLToPath as fileURLToPath4 } from "url";
7367
+ function runcastleVersion() {
7368
+ if (cached2)
7369
+ return cached2;
7370
+ const here = dirname5(fileURLToPath4(import.meta.url));
7371
+ for (const pkgPath of [join12(here, "..", "package.json"), join12(here, "package.json")]) {
7372
+ try {
7373
+ const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
7374
+ if (typeof pkg.version === "string") {
7375
+ cached2 = pkg.version;
7376
+ return cached2;
7377
+ }
7378
+ } catch {}
7379
+ }
7380
+ cached2 = UNKNOWN_VERSION;
7381
+ return cached2;
7382
+ }
7383
+ var cached2, UNKNOWN_VERSION = "0.0.0";
7384
+ var init_version = () => {};
7385
+
7386
+ // src/services/install-id.ts
7387
+ import { randomUUID } from "crypto";
7388
+ import { existsSync as existsSync13, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
7389
+ import { dirname as dirname6 } from "path";
7390
+ function getInstallId() {
7391
+ const file = installIdPath();
7392
+ try {
7393
+ if (existsSync13(file)) {
7394
+ const existing = readFileSync7(file, "utf8").trim();
7395
+ if (UUID_RE.test(existing))
7396
+ return existing;
7397
+ }
7398
+ const id = randomUUID();
7399
+ mkdirSync6(dirname6(file), { recursive: true });
7400
+ writeFileSync2(file, `${id}
7401
+ `);
7402
+ return id;
7403
+ } catch {
7404
+ return randomUUID();
7405
+ }
7406
+ }
7407
+ var UUID_RE;
7408
+ var init_install_id = __esm(() => {
7409
+ init_paths();
7410
+ UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7411
+ });
7412
+
7413
+ // src/services/update-check.ts
7414
+ function parse(version) {
7415
+ const cleaned = version.trim().replace(/^v/, "");
7416
+ const [core = "", pre = ""] = cleaned.split("-", 2);
7417
+ const release2 = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
7418
+ return { release: release2, pre: pre ? pre.split(".") : [] };
7419
+ }
7420
+ function compareSemver(a, b) {
7421
+ const pa = parse(a);
7422
+ const pb = parse(b);
7423
+ const len = Math.max(pa.release.length, pb.release.length);
7424
+ for (let i = 0;i < len; i++) {
7425
+ const diff = (pa.release[i] ?? 0) - (pb.release[i] ?? 0);
7426
+ if (diff !== 0)
7427
+ return diff;
7428
+ }
7429
+ if (pa.pre.length === 0 && pb.pre.length === 0)
7430
+ return 0;
7431
+ if (pa.pre.length === 0)
7432
+ return 1;
7433
+ if (pb.pre.length === 0)
7434
+ return -1;
7435
+ const preLen = Math.max(pa.pre.length, pb.pre.length);
7436
+ for (let i = 0;i < preLen; i++) {
7437
+ const x = pa.pre[i];
7438
+ const y = pb.pre[i];
7439
+ if (x === undefined)
7440
+ return -1;
7441
+ if (y === undefined)
7442
+ return 1;
7443
+ const nx = Number(x);
7444
+ const ny = Number(y);
7445
+ const bothNumeric = !Number.isNaN(nx) && !Number.isNaN(ny);
7446
+ const cmp = bothNumeric ? nx - ny : x < y ? -1 : x > y ? 1 : 0;
7447
+ if (cmp !== 0)
7448
+ return cmp;
7449
+ }
7450
+ return 0;
7451
+ }
7452
+ function doNotTrack(env) {
7453
+ const raw = env.DO_NOT_TRACK;
7454
+ return raw !== undefined && raw !== "" && raw !== "0";
7455
+ }
7456
+ async function latestFromNpm(fetchImpl) {
7457
+ try {
7458
+ const res = await fetchImpl(NPM_LATEST_URL);
7459
+ if (!res.ok)
7460
+ return null;
7461
+ const body = await res.json();
7462
+ return typeof body.version === "string" ? body.version : null;
7463
+ } catch {
7464
+ return null;
7465
+ }
7466
+ }
7467
+ async function latestFromPing(fetchImpl, current2) {
7468
+ try {
7469
+ const res = await fetchImpl(PING_URL, {
7470
+ method: "POST",
7471
+ headers: { "content-type": "application/json" },
7472
+ body: JSON.stringify({
7473
+ installId: getInstallId(),
7474
+ version: current2,
7475
+ platform: process.platform
7476
+ })
7477
+ });
7478
+ if (!res.ok)
7479
+ return null;
7480
+ const body = await res.json();
7481
+ return typeof body.latest === "string" ? body.latest : null;
7482
+ } catch {
7483
+ return null;
7484
+ }
7485
+ }
7486
+ async function checkForUpdate(opts) {
7487
+ const { current: current2 } = opts;
7488
+ const fetchImpl = opts.fetchImpl ?? fetch;
7489
+ const env = opts.env ?? process.env;
7490
+ if (current2 === UNKNOWN_VERSION)
7491
+ return NO_UPDATE(current2);
7492
+ const latest = doNotTrack(env) ? await latestFromNpm(fetchImpl) : await latestFromPing(fetchImpl, current2) ?? await latestFromNpm(fetchImpl);
7493
+ if (!latest)
7494
+ return NO_UPDATE(current2);
7495
+ return {
7496
+ current: current2,
7497
+ latest,
7498
+ updateAvailable: compareSemver(latest, current2) > 0,
7499
+ command: UPDATE_COMMAND
7500
+ };
7501
+ }
7502
+ async function getUpdateInfo(current2, fetchImpl) {
7503
+ if (cache)
7504
+ return cache;
7505
+ const info = await checkForUpdate({ current: current2, fetchImpl });
7506
+ if (info.latest !== null)
7507
+ cache = info;
7508
+ return info;
7509
+ }
7510
+ var PACKAGE_NAME = "runcastle", UPDATE_COMMAND, PING_URL = "https://ping.runcastle.dev/ping", NPM_LATEST_URL, NO_UPDATE = (current2) => ({
7511
+ current: current2,
7512
+ latest: null,
7513
+ updateAvailable: false,
7514
+ command: UPDATE_COMMAND
7515
+ }), cache;
7516
+ var init_update_check = __esm(() => {
7517
+ init_version();
7518
+ init_install_id();
7519
+ UPDATE_COMMAND = `bun add -g ${PACKAGE_NAME}@latest`;
7520
+ NPM_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
7521
+ });
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
+
7075
7545
  // src/trpc/context.ts
7076
7546
  import { initTRPC } from "@trpc/server";
7077
7547
  var t, errorMapping, router, publicProcedure, createCallerFactory;
@@ -7120,11 +7590,11 @@ var init_events2 = __esm(() => {
7120
7590
  });
7121
7591
 
7122
7592
  // src/launcher/artifacts.ts
7123
- import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync2 } from "fs";
7124
- import { fileURLToPath as fileURLToPath4 } from "url";
7125
- import { join as join12 } from "path";
7593
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync3 } from "fs";
7594
+ import { fileURLToPath as fileURLToPath5 } from "url";
7595
+ import { join as join13 } from "path";
7126
7596
  function hookClientPath() {
7127
- return resolveAsset(ASSET_ENV.hookClient, fileURLToPath4(new URL("./hook-client.ts", import.meta.url)));
7597
+ return resolveAsset(ASSET_ENV.hookClient, fileURLToPath5(new URL("./hook-client.ts", import.meta.url)));
7128
7598
  }
7129
7599
  function serverUrlFor(config2) {
7130
7600
  return `http://localhost:${config2.serverPort}`;
@@ -7132,13 +7602,16 @@ function serverUrlFor(config2) {
7132
7602
  function noCodeRule(docs) {
7133
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.";
7134
7604
  }
7135
- 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) {
7136
7609
  if (kind === "waypoint")
7137
7610
  return renderWaypointPrompt(feature, waypoint);
7138
7611
  if (kind === "converge")
7139
7612
  return renderConvergePrompt(feature);
7140
7613
  if (kind === "revisit")
7141
- return renderRevisitPrompt(feature, lap);
7614
+ return renderRevisitPrompt(feature, lap, purpose);
7142
7615
  const docs = featureDocsRel(feature.slug);
7143
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.";
7144
7617
  return [
@@ -7267,7 +7740,7 @@ function renderConvergePrompt(feature) {
7267
7740
  ].join(`
7268
7741
  `);
7269
7742
  }
7270
- function renderRevisitPrompt(feature, lap) {
7743
+ function renderRevisitPrompt(feature, lap, purpose) {
7271
7744
  const docs = featureDocsRel(feature.slug);
7272
7745
  const lapIteration = lap ? [
7273
7746
  `## This is lap ${lap}`,
@@ -7337,7 +7810,7 @@ function renderRevisitPrompt(feature, lap) {
7337
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.",
7338
7811
  "- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
7339
7812
  "- Docs first, tickets second: capture the decision prose before any ticket surgery.",
7340
- noCodeRule(docs),
7813
+ purpose === "resolve-conflict" ? conflictResolutionRule() : noCodeRule(docs),
7341
7814
  "",
7342
7815
  "## Your task",
7343
7816
  "Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
@@ -7495,7 +7968,8 @@ function renderProjectPrompt(brief) {
7495
7968
  "- `get_project_context()` \u2014 the project, its charter, its live ADRs, and a one-line",
7496
7969
  " index of every feature.",
7497
7970
  "- `get_work_record({ featureSlug? | seam? })` \u2014 what features actually did: tickets by",
7498
- " 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.",
7499
7973
  "- `record_event({ type, message })` \u2014 drop a note on the project timeline.",
7500
7974
  "",
7501
7975
  "Every merged feature's docs are already on disk in this worktree \u2014 read them with your",
@@ -7544,18 +8018,18 @@ function renderMcpConfig(session, config2) {
7544
8018
  };
7545
8019
  }
7546
8020
  async function writeSessionArtifacts(input) {
7547
- const { session, feature, config: config2, waypoint, prepare, projectBrief, lap } = input;
8021
+ const { session, feature, config: config2, waypoint, prepare, projectBrief, lap, purpose } = input;
7548
8022
  const dir = sessionDir(session.id);
7549
- mkdirSync6(dir, { recursive: true });
7550
- const systemPromptPath = join12(dir, "system-prompt.md");
7551
- const settingsPath = join12(dir, "settings.json");
7552
- const mcpConfigPath = join12(dir, "mcp.json");
7553
- const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
8023
+ mkdirSync7(dir, { recursive: true });
8024
+ const systemPromptPath = join13(dir, "system-prompt.md");
8025
+ const settingsPath = join13(dir, "settings.json");
8026
+ const mcpConfigPath = join13(dir, "mcp.json");
8027
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap, purpose) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
7554
8028
  throw new Error(`session ${session.id} has no feature and no project-session brief`);
7555
8029
  })();
7556
- writeFileSync2(systemPromptPath, systemPrompt, "utf8");
7557
- writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
7558
- writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
8030
+ writeFileSync3(systemPromptPath, systemPrompt, "utf8");
8031
+ writeFileSync3(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
8032
+ writeFileSync3(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
7559
8033
  return { systemPromptPath, settingsPath, mcpConfigPath };
7560
8034
  }
7561
8035
  var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_READ_RULES, SESSION_BASH_WRITE_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
@@ -7599,9 +8073,9 @@ var init_artifacts = __esm(() => {
7599
8073
  });
7600
8074
 
7601
8075
  // src/launcher/launcher.ts
7602
- import { existsSync as existsSync13 } from "fs";
7603
- import { dirname as dirname5, join as join13, resolve as resolve9 } from "path";
7604
- import { fileURLToPath as fileURLToPath5 } from "url";
8076
+ import { existsSync as existsSync14 } from "fs";
8077
+ import { dirname as dirname7, join as join14, resolve as resolve9 } from "path";
8078
+ import { fileURLToPath as fileURLToPath6 } from "url";
7605
8079
  import { and as and9, eq as eq11 } from "drizzle-orm";
7606
8080
  function ptyExitMessage(exitCode) {
7607
8081
  const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
@@ -7633,23 +8107,23 @@ function resolveClaudeExecutable() {
7633
8107
  function claudeSpawnTarget(claudeArgs) {
7634
8108
  return spawnTargetFor(resolveClaudeExecutable(), claudeArgs);
7635
8109
  }
7636
- function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
7637
- const rel = join13("packages", "skills", "packs", "runcastle");
8110
+ function resolvePluginDir(fromDir = dirname7(fileURLToPath6(import.meta.url))) {
8111
+ const rel = join14("packages", "skills", "packs", "runcastle");
7638
8112
  const override = process.env[SKILLS_DIR_ENV];
7639
8113
  if (override) {
7640
- const dir2 = join13(resolve9(override), "packs", "runcastle");
7641
- if (existsSync13(dir2))
8114
+ const dir2 = join14(resolve9(override), "packs", "runcastle");
8115
+ if (existsSync14(dir2))
7642
8116
  return dir2;
7643
8117
  throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
7644
8118
  }
7645
8119
  const searched = [];
7646
8120
  let dir = fromDir;
7647
8121
  for (let i = 0;i < 8; i += 1) {
7648
- const candidate = join13(dir, rel);
8122
+ const candidate = join14(dir, rel);
7649
8123
  searched.push(candidate);
7650
- if (existsSync13(candidate))
8124
+ if (existsSync14(candidate))
7651
8125
  return candidate;
7652
- const parent = dirname5(dir);
8126
+ const parent = dirname7(dir);
7653
8127
  if (parent === dir)
7654
8128
  break;
7655
8129
  dir = parent;
@@ -7673,8 +8147,10 @@ function assertSpawnable(ctx, feature, excludeSessionId) {
7673
8147
  }
7674
8148
  }
7675
8149
  function sessionFinished(ctx, feature, session) {
7676
- if (session.kind !== "waypoint")
8150
+ if (session.kind === "ideation" || session.kind === "converge")
7677
8151
  return feature.mapped;
8152
+ if (session.kind !== "waypoint")
8153
+ return false;
7678
8154
  const own = listByFeature(ctx, feature.id).find((w) => w.lastSessionId === session.id);
7679
8155
  return !!own && (own.status === "resolved" || own.status === "dropped");
7680
8156
  }
@@ -7713,11 +8189,14 @@ async function ensureWorktree(ctx, project, feature) {
7713
8189
  }
7714
8190
  async function launchSession(ctx, input, opts = {}) {
7715
8191
  const feature = getFeatureRow(ctx, input.featureId);
8192
+ requireNotDraft(feature);
7716
8193
  const project = projectForFeature(ctx, feature);
7717
8194
  const worktreePath = await ensureWorktree(ctx, project, feature);
7718
8195
  const session = createSessionRow(ctx, {
7719
8196
  featureId: feature.id,
7720
8197
  kind: input.kind,
8198
+ purpose: input.purpose,
8199
+ purposeData: input.purposeData,
7721
8200
  worktreePath
7722
8201
  });
7723
8202
  const plan = planKickoff({ kind: input.kind, lap: feature.lap, kickoffLine: input.kickoffLine });
@@ -7793,7 +8272,8 @@ async function launchSession(ctx, input, opts = {}) {
7793
8272
  project,
7794
8273
  config: ctx.config,
7795
8274
  waypoint,
7796
- lap: plan.lap
8275
+ lap: plan.lap,
8276
+ purpose: input.purpose
7797
8277
  });
7798
8278
  const serverUrl = serverUrlFor(ctx.config);
7799
8279
  const buildInput = {
@@ -7893,7 +8373,7 @@ async function launchProjectSession(ctx, input, opts = {}) {
7893
8373
  if (live) {
7894
8374
  throw new GateError(`a project session is already open for ${project.name} \u2014 resume or end it first`);
7895
8375
  }
7896
- const worktreePath = await ensureProjectWorktree(project);
8376
+ const worktreePath = await ensureProjectWorktree(project, (res) => reportProjectLanding(ctx, project, res, { retried: true }));
7897
8377
  const session = createSessionRow(ctx, {
7898
8378
  projectId: project.id,
7899
8379
  kind: "project",
@@ -7964,6 +8444,7 @@ async function buildPrepareBrief(ctx, project) {
7964
8444
  }
7965
8445
  async function workWaypoint(ctx, input, opts = {}) {
7966
8446
  const feature = getFeatureRow(ctx, input.featureId);
8447
+ requireNotDraft(feature);
7967
8448
  if (!feature.mapped) {
7968
8449
  throw new GateError(`feature ${feature.slug} is not mapped \u2014 it has no waypoints to work`);
7969
8450
  }
@@ -7984,6 +8465,7 @@ async function workWaypoint(ctx, input, opts = {}) {
7984
8465
  }
7985
8466
  async function converge(ctx, input, opts = {}) {
7986
8467
  const feature = getFeatureRow(ctx, input.featureId);
8468
+ requireNotDraft(feature);
7987
8469
  if (!feature.mapped) {
7988
8470
  throw new GateError(`feature ${feature.slug} is not mapped \u2014 convergence is only for mapped features`);
7989
8471
  }
@@ -8082,6 +8564,8 @@ function spawnEmbeddedPty(ctx, feature, session, worktreePath, serverUrl, claude
8082
8564
  opts: { cwd: worktreePath, env, cols: 80, rows: 24, useConpty: true },
8083
8565
  onExit: ({ exitCode }) => handlePtyExit(ctx, feature, session, meta, exitCode)
8084
8566
  });
8567
+ if (feature)
8568
+ startDocsWatch(ctx, feature);
8085
8569
  emitForSession(ctx, session, {
8086
8570
  type: "session.launched",
8087
8571
  message: "embedded terminal spawned",
@@ -8108,10 +8592,12 @@ var init_launcher = __esm(() => {
8108
8592
  init_errors();
8109
8593
  init_end_session();
8110
8594
  init_registry();
8595
+ init_docs_watch();
8111
8596
  init_events();
8112
8597
  init_gates();
8113
8598
  init_git();
8114
8599
  init_repo();
8600
+ init_features();
8115
8601
  init_tickets();
8116
8602
  init_waypoints();
8117
8603
  init_runner();
@@ -8133,6 +8619,74 @@ var init_launcher = __esm(() => {
8133
8619
  ];
8134
8620
  });
8135
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
+
8136
8690
  // src/trpc/routers/feature.ts
8137
8691
  import * as z6 from "zod";
8138
8692
  var gateId, featureRouter;
@@ -8144,6 +8698,7 @@ var init_feature = __esm(() => {
8144
8698
  init_features();
8145
8699
  init_gates();
8146
8700
  init_git();
8701
+ init_outcome();
8147
8702
  init_repo();
8148
8703
  init_context();
8149
8704
  gateId = z6.enum(["G1", "G2", "G3", "G4", "G5"]);
@@ -8152,8 +8707,10 @@ var init_feature = __esm(() => {
8152
8707
  projectId: z6.string(),
8153
8708
  title: z6.string().min(1),
8154
8709
  oneLiner: z6.string(),
8155
- baseBranch: z6.string().optional()
8710
+ baseBranch: z6.string().optional(),
8711
+ draft: z6.boolean().optional()
8156
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 })),
8157
8714
  quickChange: publicProcedure.input(z6.object({
8158
8715
  projectId: z6.string(),
8159
8716
  title: z6.string().min(1),
@@ -8165,7 +8722,9 @@ var init_feature = __esm(() => {
8165
8722
  launchSession: publicProcedure.input(z6.object({
8166
8723
  featureId: z6.string(),
8167
8724
  kind: SessionKind,
8168
- kickoffLine: z6.string().min(1).optional()
8725
+ kickoffLine: z6.string().min(1).optional(),
8726
+ purpose: SessionPurpose.optional(),
8727
+ purposeData: MergeBranchPair.optional()
8169
8728
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
8170
8729
  workWaypoint: publicProcedure.input(z6.object({ featureId: z6.string(), waypointId: z6.string(), endLive: z6.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
8171
8730
  converge: publicProcedure.input(z6.object({ featureId: z6.string(), overrideReason: z6.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
@@ -8185,6 +8744,7 @@ var init_feature = __esm(() => {
8185
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 })),
8186
8745
  testDrive: publicProcedure.input(z6.object({ featureId: z6.string(), action: z6.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
8187
8746
  const feature = getFeatureRow(ctx, input.featureId);
8747
+ requireNotDraft(feature);
8188
8748
  const project = projectForFeature(ctx, feature);
8189
8749
  return testDrive(ctx, project, feature, input.action);
8190
8750
  }),
@@ -8195,10 +8755,12 @@ var init_feature = __esm(() => {
8195
8755
  }),
8196
8756
  merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
8197
8757
  const feature = getFeatureRow(ctx, input.featureId);
8758
+ requireNotDraft(feature);
8198
8759
  const project = projectForFeature(ctx, feature);
8199
8760
  if (activeTestDriveFeatureId() === feature.id) {
8200
8761
  await testDrive(ctx, project, feature, "stop");
8201
8762
  }
8763
+ await promoteOutcomeDoc(ctx, project, feature);
8202
8764
  const res = await mergeFeature(project, feature);
8203
8765
  if (res.ok) {
8204
8766
  setPhase(ctx, input.featureId, "shipped", "feature.shipped", `merged to ${res.target}`);
@@ -8216,9 +8778,9 @@ var init_feature = __esm(() => {
8216
8778
  });
8217
8779
 
8218
8780
  // src/services/fsbrowse.ts
8219
- import { existsSync as existsSync14, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
8781
+ import { existsSync as existsSync15, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
8220
8782
  import { homedir as homedir2 } from "os";
8221
- import { basename, dirname as dirname6, isAbsolute as isAbsolute2, join as join14, 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";
8222
8784
  function expandPath(input, home = homedir2()) {
8223
8785
  const trimmed = input.trim();
8224
8786
  if (trimmed === "")
@@ -8227,12 +8789,12 @@ function expandPath(input, home = homedir2()) {
8227
8789
  if (out === "~")
8228
8790
  out = home;
8229
8791
  else if (out.startsWith("~/") || out.startsWith("~\\"))
8230
- out = join14(home, out.slice(2));
8792
+ out = join16(home, out.slice(2));
8231
8793
  return resolve10(out);
8232
8794
  }
8233
8795
  function looksLikeRepo(dir) {
8234
8796
  try {
8235
- return existsSync14(join14(dir, ".git"));
8797
+ return existsSync15(join16(dir, ".git"));
8236
8798
  } catch {
8237
8799
  return false;
8238
8800
  }
@@ -8243,20 +8805,20 @@ function listRoots(home = homedir2()) {
8243
8805
  for (let code = 67;code <= 90; code++) {
8244
8806
  const drive = `${String.fromCharCode(code)}:${sep3}`;
8245
8807
  try {
8246
- if (existsSync14(drive))
8808
+ if (existsSync15(drive))
8247
8809
  roots.push({ label: drive.replace(/\\$/, ""), path: drive, kind: "drive" });
8248
8810
  } catch {}
8249
8811
  }
8250
8812
  } else {
8251
8813
  roots.push({ label: "/", path: "/", kind: "drive" });
8252
8814
  }
8253
- if (existsSync14(home))
8815
+ if (existsSync15(home))
8254
8816
  roots.push({ label: "Home", path: home, kind: "home" });
8255
8817
  const seen = new Set(roots.map(pathKey));
8256
8818
  for (const name of COMMON_DIRS) {
8257
- const candidate = join14(home, name);
8819
+ const candidate = join16(home, name);
8258
8820
  const key = pathKey({ path: candidate });
8259
- if (seen.has(key) || !existsSync14(candidate))
8821
+ if (seen.has(key) || !existsSync15(candidate))
8260
8822
  continue;
8261
8823
  seen.add(key);
8262
8824
  roots.push({ label: name, path: candidate, kind: "common" });
@@ -8271,7 +8833,7 @@ function crumbsFor(dir) {
8271
8833
  let cur = dir;
8272
8834
  for (;; ) {
8273
8835
  out.unshift({ name: basename(cur) || cur, path: cur });
8274
- const parent = dirname6(cur);
8836
+ const parent = dirname8(cur);
8275
8837
  if (parent === cur)
8276
8838
  break;
8277
8839
  cur = parent;
@@ -8279,7 +8841,7 @@ function crumbsFor(dir) {
8279
8841
  return out;
8280
8842
  }
8281
8843
  function parentOf(dir) {
8282
- const parent = dirname6(dir);
8844
+ const parent = dirname8(dir);
8283
8845
  return parent === dir ? null : parent;
8284
8846
  }
8285
8847
  function browseDir(input, showHidden = false, home = homedir2()) {
@@ -8287,13 +8849,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
8287
8849
  if (!isAbsolute2(target)) {
8288
8850
  throw new InvalidInputError(`path is not absolute: ${target}`);
8289
8851
  }
8290
- if (!existsSync14(target)) {
8852
+ if (!existsSync15(target)) {
8291
8853
  throw new InvalidInputError(`path does not exist: ${target}`);
8292
8854
  }
8293
8855
  let dir = target;
8294
8856
  try {
8295
8857
  if (!statSync2(target).isDirectory())
8296
- dir = dirname6(target);
8858
+ dir = dirname8(target);
8297
8859
  } catch (e) {
8298
8860
  throw new InvalidInputError(`cannot read path: ${target} (${errMsg4(e)})`);
8299
8861
  }
@@ -8315,7 +8877,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
8315
8877
  continue;
8316
8878
  if (!showHidden && name.startsWith("."))
8317
8879
  continue;
8318
- const path = join14(dir, name);
8880
+ const path = join16(dir, name);
8319
8881
  const isSymlink = dirent.isSymbolicLink();
8320
8882
  if (isSymlink && !showHidden)
8321
8883
  continue;
@@ -8352,8 +8914,8 @@ var init_fsbrowse = __esm(() => {
8352
8914
  });
8353
8915
 
8354
8916
  // src/services/projects.ts
8355
- import { existsSync as existsSync15 } from "fs";
8356
- import { basename as basename2, join as join15 } from "path";
8917
+ import { existsSync as existsSync16 } from "fs";
8918
+ import { basename as basename2, join as join17 } from "path";
8357
8919
  import { eq as eq12, isNull } from "drizzle-orm";
8358
8920
  function listProjects(ctx) {
8359
8921
  return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
@@ -8377,7 +8939,7 @@ async function openProject(ctx, rawPath) {
8377
8939
  const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
8378
8940
  if (existing) {
8379
8941
  ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq12(projects.id, existing.id)).run();
8380
- const reopened = { ...existing, mainBranch, repoPath };
8942
+ const reopened = { ...existing, mainBranch, repoPath, closedAt: undefined };
8381
8943
  emitProject(ctx, existing.id, {
8382
8944
  type: "project.opened",
8383
8945
  message: `project ${existing.name} re-opened at ${repoPath} (${mainBranch})`
@@ -8434,7 +8996,7 @@ async function assertRepoTolerant(ctx, repoPath) {
8434
8996
  } catch (e) {
8435
8997
  if (!isNotImplemented(e))
8436
8998
  throw e;
8437
- if (!existsSync15(join15(repoPath, ".git"))) {
8999
+ if (!existsSync16(join17(repoPath, ".git"))) {
8438
9000
  throw new InvalidInputError(`not a git repository: ${repoPath}`);
8439
9001
  }
8440
9002
  }
@@ -8499,7 +9061,8 @@ var init_run = __esm(() => {
8499
9061
  runRouter = router({
8500
9062
  get: publicProcedure.input(z8.object({ runId: z8.string() })).query(({ ctx, input }) => getRunRow(ctx, input.runId)),
8501
9063
  agentTranscript: publicProcedure.input(z8.object({ ticketId: z8.string(), after: z8.number().int().optional() })).query(({ input }) => readTranscript(input.ticketId, input.after ?? -1)),
8502
- 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);
8503
9066
  cancelRun(input.runId);
8504
9067
  return { ok: true };
8505
9068
  })
@@ -8507,15 +9070,15 @@ var init_run = __esm(() => {
8507
9070
  });
8508
9071
 
8509
9072
  // src/services/settings.ts
8510
- import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
8511
- import { dirname as dirname7 } from "path";
9073
+ import { existsSync as existsSync17, mkdirSync as mkdirSync9, readFileSync as readFileSync8, writeFileSync as writeFileSync5 } from "fs";
9074
+ import { dirname as dirname9 } from "path";
8512
9075
  import * as z9 from "zod";
8513
9076
  import { eq as eq13 } from "drizzle-orm";
8514
9077
  function readRawConfig(configFile) {
8515
- if (!existsSync16(configFile))
9078
+ if (!existsSync17(configFile))
8516
9079
  return {};
8517
9080
  try {
8518
- const parsed = JSON.parse(readFileSync6(configFile, "utf8"));
9081
+ const parsed = JSON.parse(readFileSync8(configFile, "utf8"));
8519
9082
  if (typeof parsed !== "object" || parsed === null)
8520
9083
  return {};
8521
9084
  return foldLegacyModelConfig(parsed);
@@ -8654,8 +9217,8 @@ function updateSettings(ctx, input, io = {}) {
8654
9217
  function writeGlobal(configFile, configKey, value) {
8655
9218
  const raw = readRawConfig(configFile);
8656
9219
  raw[configKey] = value;
8657
- mkdirSync7(dirname7(configFile), { recursive: true });
8658
- writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
9220
+ mkdirSync9(dirname9(configFile), { recursive: true });
9221
+ writeFileSync5(configFile, `${JSON.stringify(raw, null, 2)}
8659
9222
  `);
8660
9223
  }
8661
9224
  function updateStepModel(ctx, input, configFile, io) {
@@ -8693,8 +9256,8 @@ function writeStepModel(configFile, step, value) {
8693
9256
  else
8694
9257
  stepModels[step] = value;
8695
9258
  raw.stepModels = stepModels;
8696
- mkdirSync7(dirname7(configFile), { recursive: true });
8697
- writeFileSync3(configFile, `${JSON.stringify(raw, null, 2)}
9259
+ mkdirSync9(dirname9(configFile), { recursive: true });
9260
+ writeFileSync5(configFile, `${JSON.stringify(raw, null, 2)}
8698
9261
  `);
8699
9262
  }
8700
9263
  function field(view, key) {
@@ -8931,11 +9494,11 @@ async function gitIdentityProbe(exec, cwd) {
8931
9494
  async function containerRuntimeProbe(exec) {
8932
9495
  const id = "container-runtime";
8933
9496
  const label = "Container runtime (Docker / Podman)";
8934
- const docker3 = await exec("docker", ["--version"]);
8935
- if (docker3.ok && docker3.code === 0) {
9497
+ const docker2 = await exec("docker", ["--version"]);
9498
+ if (docker2.ok && docker2.code === 0) {
8936
9499
  const info = await exec("docker", ["info"]);
8937
9500
  if (info.ok && info.code === 0) {
8938
- 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" };
8939
9502
  }
8940
9503
  return {
8941
9504
  id,
@@ -9071,9 +9634,9 @@ var init_system_exec = __esm(() => {
9071
9634
  });
9072
9635
 
9073
9636
  // src/services/setup.ts
9074
- import { copyFileSync, existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync7, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "fs";
9075
- import { dirname as dirname8, join as join16 } from "path";
9076
- import { fileURLToPath as fileURLToPath6 } from "url";
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";
9639
+ import { fileURLToPath as fileURLToPath7 } from "url";
9077
9640
  async function writeGitIdentity(exec, identity) {
9078
9641
  const name = identity.name.trim();
9079
9642
  const email = identity.email.trim();
@@ -9148,16 +9711,16 @@ async function resolveRuntime(exec, preferred) {
9148
9711
  function resolveSandcastleBin() {
9149
9712
  try {
9150
9713
  const mainUrl = import.meta.resolve("@ai-hero/sandcastle");
9151
- let dir = dirname8(fileURLToPath6(mainUrl));
9714
+ let dir = dirname10(fileURLToPath7(mainUrl));
9152
9715
  for (let hops = 0;hops < 6; hops++) {
9153
- const manifestPath = join16(dir, "package.json");
9154
- if (existsSync17(manifestPath)) {
9155
- const manifest = JSON.parse(readFileSync7(manifestPath, "utf8"));
9716
+ const manifestPath = join18(dir, "package.json");
9717
+ if (existsSync18(manifestPath)) {
9718
+ const manifest = JSON.parse(readFileSync9(manifestPath, "utf8"));
9156
9719
  if (manifest.name === "@ai-hero/sandcastle" && manifest.bin?.sandcastle) {
9157
- return join16(dir, manifest.bin.sandcastle);
9720
+ return join18(dir, manifest.bin.sandcastle);
9158
9721
  }
9159
9722
  }
9160
- const parent = dirname8(dir);
9723
+ const parent = dirname10(dir);
9161
9724
  if (parent === dir)
9162
9725
  break;
9163
9726
  dir = parent;
@@ -9179,21 +9742,21 @@ function terminalSpec(kind, opts) {
9179
9742
  };
9180
9743
  }
9181
9744
  function sandcastleTemplateDir() {
9182
- return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath6(new URL("../assets/sandcastle", import.meta.url)));
9745
+ return resolveAsset(ASSET_ENV.sandcastleTemplate, fileURLToPath7(new URL("../assets/sandcastle", import.meta.url)));
9183
9746
  }
9184
9747
  function scaffoldSandcastleConfig(templateDir, targetDir) {
9185
- const dir = join16(targetDir, ".sandcastle");
9186
- if (existsSync17(dir))
9748
+ const dir = join18(targetDir, ".sandcastle");
9749
+ if (existsSync18(dir))
9187
9750
  return { scaffolded: false, dir };
9188
- mkdirSync8(dir, { recursive: true });
9751
+ mkdirSync10(dir, { recursive: true });
9189
9752
  for (const name of readdirSync6(templateDir)) {
9190
- copyFileSync(join16(templateDir, name), join16(dir, name));
9753
+ copyFileSync(join18(templateDir, name), join18(dir, name));
9191
9754
  }
9192
9755
  return { scaffolded: true, dir };
9193
9756
  }
9194
9757
  function prepareSandboxBuildContext() {
9195
9758
  const target = sandboxBuildDir();
9196
- mkdirSync8(target, { recursive: true });
9759
+ mkdirSync10(target, { recursive: true });
9197
9760
  scaffoldSandcastleConfig(sandcastleTemplateDir(), target);
9198
9761
  return target;
9199
9762
  }
@@ -9229,10 +9792,10 @@ function createTokenVerifier(exec) {
9229
9792
  function fileAfkTokenIo(exec) {
9230
9793
  const path = envPath();
9231
9794
  return {
9232
- read: () => existsSync17(path) ? readFileSync7(path, "utf8") : "",
9795
+ read: () => existsSync18(path) ? readFileSync9(path, "utf8") : "",
9233
9796
  write: (content) => {
9234
- mkdirSync8(dirname8(path), { recursive: true });
9235
- writeFileSync4(path, content, "utf8");
9797
+ mkdirSync10(dirname10(path), { recursive: true });
9798
+ writeFileSync6(path, content, "utf8");
9236
9799
  },
9237
9800
  verify: createTokenVerifier(exec)
9238
9801
  };
@@ -9285,110 +9848,6 @@ var init_setup2 = __esm(() => {
9285
9848
  });
9286
9849
  });
9287
9850
 
9288
- // src/version.ts
9289
- import { readFileSync as readFileSync8 } from "fs";
9290
- import { dirname as dirname9, join as join17 } from "path";
9291
- import { fileURLToPath as fileURLToPath7 } from "url";
9292
- function runcastleVersion() {
9293
- if (cached2)
9294
- return cached2;
9295
- const here = dirname9(fileURLToPath7(import.meta.url));
9296
- for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
9297
- try {
9298
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
9299
- if (typeof pkg.version === "string") {
9300
- cached2 = pkg.version;
9301
- return cached2;
9302
- }
9303
- } catch {}
9304
- }
9305
- cached2 = UNKNOWN_VERSION;
9306
- return cached2;
9307
- }
9308
- var cached2, UNKNOWN_VERSION = "0.0.0";
9309
- var init_version = () => {};
9310
-
9311
- // src/services/update-check.ts
9312
- function parse(version) {
9313
- const cleaned = version.trim().replace(/^v/, "");
9314
- const [core = "", pre = ""] = cleaned.split("-", 2);
9315
- const release2 = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
9316
- return { release: release2, pre: pre ? pre.split(".") : [] };
9317
- }
9318
- function compareSemver(a, b) {
9319
- const pa = parse(a);
9320
- const pb = parse(b);
9321
- const len = Math.max(pa.release.length, pb.release.length);
9322
- for (let i = 0;i < len; i++) {
9323
- const diff = (pa.release[i] ?? 0) - (pb.release[i] ?? 0);
9324
- if (diff !== 0)
9325
- return diff;
9326
- }
9327
- if (pa.pre.length === 0 && pb.pre.length === 0)
9328
- return 0;
9329
- if (pa.pre.length === 0)
9330
- return 1;
9331
- if (pb.pre.length === 0)
9332
- return -1;
9333
- const preLen = Math.max(pa.pre.length, pb.pre.length);
9334
- for (let i = 0;i < preLen; i++) {
9335
- const x = pa.pre[i];
9336
- const y = pb.pre[i];
9337
- if (x === undefined)
9338
- return -1;
9339
- if (y === undefined)
9340
- return 1;
9341
- const nx = Number(x);
9342
- const ny = Number(y);
9343
- const bothNumeric = !Number.isNaN(nx) && !Number.isNaN(ny);
9344
- const cmp = bothNumeric ? nx - ny : x < y ? -1 : x > y ? 1 : 0;
9345
- if (cmp !== 0)
9346
- return cmp;
9347
- }
9348
- return 0;
9349
- }
9350
- async function checkForUpdate(opts) {
9351
- const { current: current2 } = opts;
9352
- const fetchImpl = opts.fetchImpl ?? fetch;
9353
- if (current2 === UNKNOWN_VERSION)
9354
- return NO_UPDATE(current2);
9355
- try {
9356
- const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`);
9357
- if (!res.ok)
9358
- return NO_UPDATE(current2);
9359
- const body = await res.json();
9360
- const latest = typeof body.version === "string" ? body.version : null;
9361
- if (!latest)
9362
- return NO_UPDATE(current2);
9363
- return {
9364
- current: current2,
9365
- latest,
9366
- updateAvailable: compareSemver(latest, current2) > 0,
9367
- command: UPDATE_COMMAND
9368
- };
9369
- } catch {
9370
- return NO_UPDATE(current2);
9371
- }
9372
- }
9373
- async function getUpdateInfo(current2, fetchImpl) {
9374
- if (cache)
9375
- return cache;
9376
- const info = await checkForUpdate({ current: current2, fetchImpl });
9377
- if (info.latest !== null)
9378
- cache = info;
9379
- return info;
9380
- }
9381
- var PACKAGE_NAME = "runcastle", UPDATE_COMMAND, NO_UPDATE = (current2) => ({
9382
- current: current2,
9383
- latest: null,
9384
- updateAvailable: false,
9385
- command: UPDATE_COMMAND
9386
- }), cache;
9387
- var init_update_check = __esm(() => {
9388
- init_version();
9389
- UPDATE_COMMAND = `bun add -g ${PACKAGE_NAME}@latest`;
9390
- });
9391
-
9392
9851
  // src/trpc/routers/system.ts
9393
9852
  var systemRouter;
9394
9853
  var init_system = __esm(() => {
@@ -9402,11 +9861,11 @@ var init_system = __esm(() => {
9402
9861
  });
9403
9862
 
9404
9863
  // src/services/test-notes.ts
9405
- import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync5 } from "fs";
9406
- import { dirname as dirname10 } from "path";
9864
+ import { mkdirSync as mkdirSync11, writeFileSync as writeFileSync7 } from "fs";
9865
+ import { dirname as dirname11 } from "path";
9407
9866
  import { asc as asc4, eq as eq14 } from "drizzle-orm";
9408
9867
  function rowToNote(row) {
9409
- return {
9868
+ return TestNote.parse({
9410
9869
  id: row.id,
9411
9870
  featureId: row.featureId,
9412
9871
  lap: row.lap,
@@ -9415,7 +9874,7 @@ function rowToNote(row) {
9415
9874
  ticketId: row.ticketId ?? undefined,
9416
9875
  createdAt: row.createdAt,
9417
9876
  updatedAt: row.updatedAt
9418
- };
9877
+ });
9419
9878
  }
9420
9879
  function getNote(ctx, id) {
9421
9880
  const row = ctx.db.select().from(testNotes).where(eq14(testNotes.id, id)).get();
@@ -9548,8 +10007,8 @@ function renderTestNotes(ctx, feature) {
9548
10007
  lines.push(noteLine(ctx, note));
9549
10008
  }
9550
10009
  const path = featureDocPath(projectForFeature(ctx, feature), feature, "test-notes.md");
9551
- mkdirSync9(dirname10(path), { recursive: true });
9552
- writeFileSync5(path, `${lines.join(`
10010
+ mkdirSync11(dirname11(path), { recursive: true });
10011
+ writeFileSync7(path, `${lines.join(`
9553
10012
  `)}
9554
10013
  `, "utf8");
9555
10014
  }
@@ -9740,6 +10199,8 @@ async function startServer() {
9740
10199
  }
9741
10200
  const server = Bun.serve({
9742
10201
  port: config2.serverPort,
10202
+ hostname: SERVE_HOSTNAME,
10203
+ idleTimeout: SERVE_IDLE_TIMEOUT_SECONDS,
9743
10204
  fetch(req, srv) {
9744
10205
  if (tryUpgradeTerminal(req, srv))
9745
10206
  return;
@@ -9748,15 +10209,16 @@ async function startServer() {
9748
10209
  websocket: terminalWebSocket
9749
10210
  });
9750
10211
  console.log(`runcastle${process.env.RUNCASTLE_DEV ? " [dev]" : ""} server listening on ` + `http://localhost:${config2.serverPort} \u2014 data dir: ${dataDir()}`);
10212
+ getUpdateInfo(runcastleVersion()).catch(() => {});
9751
10213
  const SHUTDOWN_KEY = Symbol.for("runcastle.shutdown.wired");
9752
10214
  const g = globalThis;
9753
10215
  if (!g[SHUTDOWN_KEY]) {
9754
10216
  g[SHUTDOWN_KEY] = true;
9755
- const shutdown = () => {
9756
- ptyRegistry().killAll();
9757
- server.stop();
9758
- process.exit(0);
9759
- };
10217
+ const shutdown = createShutdown({
10218
+ killAllTrees: () => ptyRegistry().killAllTrees(),
10219
+ stop: () => server.stop(),
10220
+ exit: (code) => process.exit(code)
10221
+ });
9760
10222
  process.on("SIGINT", shutdown);
9761
10223
  process.on("SIGTERM", shutdown);
9762
10224
  }
@@ -9773,7 +10235,9 @@ var init_src2 = __esm(() => {
9773
10235
  init_hooks();
9774
10236
  init_stream();
9775
10237
  init_web();
10238
+ init_update_check();
9776
10239
  init_router();
10240
+ init_version();
9777
10241
  init_reconcile_runs();
9778
10242
  });
9779
10243
  init_src2();