runcastle 1.0.26 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/runcastle.js CHANGED
@@ -32,7 +32,8 @@ __export(exports_paths, {
32
32
  dbPath: () => dbPath,
33
33
  dataDir: () => dataDir,
34
34
  configPath: () => configPath,
35
- burnCacheDir: () => burnCacheDir
35
+ burnCacheDir: () => burnCacheDir,
36
+ PROJECT_WORKTREE_SLUG: () => PROJECT_WORKTREE_SLUG
36
37
  });
37
38
  import { homedir } from "os";
38
39
  import { join, resolve } from "path";
@@ -84,6 +85,7 @@ function sandboxBuildDir() {
84
85
  function featureDocsRel(slug) {
85
86
  return `docs/features/${slug}`;
86
87
  }
88
+ var PROJECT_WORKTREE_SLUG = "__project";
87
89
  var init_paths = () => {};
88
90
 
89
91
  // ../core/src/config.ts
@@ -131,6 +133,7 @@ var init_config = __esm(() => {
131
133
  "research",
132
134
  "implement",
133
135
  "prepare",
136
+ "project",
134
137
  "smoke"
135
138
  ];
136
139
  ModelStep = z.enum(MODEL_STEPS);
@@ -255,7 +258,15 @@ var init_schemas = __esm(() => {
255
258
  "shipped"
256
259
  ]);
257
260
  TicketStatus = z2.enum(["pending", "burning", "done", "failed", "cancelled"]);
258
- SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit", "prepare"]);
261
+ SessionKind = z2.enum([
262
+ "ideation",
263
+ "qa",
264
+ "waypoint",
265
+ "converge",
266
+ "revisit",
267
+ "prepare",
268
+ "project"
269
+ ]);
259
270
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
260
271
  FeatureStatus = z2.enum(["active", "shipped", "archived"]);
261
272
  SessionStatus = z2.enum(["launching", "live", "ended"]);
@@ -272,6 +283,7 @@ var init_schemas = __esm(() => {
272
283
  featureId: z2.string(),
273
284
  seq: z2.number(),
274
285
  status: TicketStatus,
286
+ lap: z2.number(),
275
287
  commits: z2.array(z2.string()),
276
288
  error: z2.string().optional(),
277
289
  attemptBranch: z2.string().optional(),
@@ -349,6 +361,7 @@ var init_schemas = __esm(() => {
349
361
  title: z2.string(),
350
362
  oneLiner: z2.string(),
351
363
  mapped: z2.boolean(),
364
+ lap: z2.number(),
352
365
  phase: Phase,
353
366
  branch: z2.string(),
354
367
  baseBranch: z2.string().optional(),
@@ -485,7 +498,7 @@ function nextGate(feature) {
485
498
  return MAPPED_G1;
486
499
  return gate;
487
500
  }
488
- var PIPELINE, ORDER, REVIEW_LOOP_BACK, MAPPED_G1;
501
+ var PIPELINE, ORDER, REVIEW_LOOP_BACK, RETHINK_LOOP_BACK, MAPPED_G1;
489
502
  var init_pipeline = __esm(() => {
490
503
  PIPELINE = [
491
504
  { phase: "ideation" },
@@ -532,6 +545,7 @@ var init_pipeline = __esm(() => {
532
545
  ];
533
546
  ORDER = PIPELINE.map((p) => p.phase);
534
547
  REVIEW_LOOP_BACK = { from: "review", to: "implementation" };
548
+ RETHINK_LOOP_BACK = { from: "review", to: "ideation" };
535
549
  MAPPED_G1 = {
536
550
  id: "G1",
537
551
  description: "Every waypoint resolved or dropped before converging",
@@ -584,6 +598,7 @@ var init_db_schema = __esm(() => {
584
598
  title: text("title").notNull(),
585
599
  oneLiner: text("one_liner").notNull(),
586
600
  mapped: integer("mapped", { mode: "boolean" }).notNull().default(false),
601
+ lap: integer("lap").notNull().default(1),
587
602
  phase: text("phase").notNull().$type(),
588
603
  branch: text("branch").notNull(),
589
604
  baseBranch: text("base_branch"),
@@ -594,6 +609,7 @@ var init_db_schema = __esm(() => {
594
609
  id: text("id").primaryKey(),
595
610
  featureId: text("feature_id"),
596
611
  projectId: text("project_id"),
612
+ lap: integer("lap").notNull().default(1),
597
613
  kind: text("kind").notNull().$type(),
598
614
  ccSessionId: text("cc_session_id"),
599
615
  transcriptPath: text("transcript_path"),
@@ -610,6 +626,7 @@ var init_db_schema = __esm(() => {
610
626
  acceptanceCriteria: text("acceptance_criteria", { mode: "json" }).notNull().$type(),
611
627
  seams: text("seams", { mode: "json" }).notNull().$type(),
612
628
  blockedBy: text("blocked_by", { mode: "json" }).notNull().$type(),
629
+ lap: integer("lap").notNull().default(1),
613
630
  status: text("status").notNull().$type(),
614
631
  commits: text("commits", { mode: "json" }).notNull().$type(),
615
632
  error: text("error"),
@@ -645,6 +662,7 @@ var init_db_schema = __esm(() => {
645
662
  featureId: text("feature_id"),
646
663
  runId: text("run_id"),
647
664
  ticketId: text("ticket_id"),
665
+ lap: integer("lap").notNull().default(1),
648
666
  ts: integer("ts").notNull(),
649
667
  type: text("type").notNull(),
650
668
  message: text("message").notNull(),
@@ -1363,7 +1381,7 @@ var init_bus = __esm(() => {
1363
1381
  });
1364
1382
 
1365
1383
  // src/services/events.ts
1366
- import { and, asc, eq, gt } from "drizzle-orm";
1384
+ import { and, asc, desc, eq, gt } from "drizzle-orm";
1367
1385
  function rowToEvent(row) {
1368
1386
  return {
1369
1387
  id: row.id,
@@ -1383,6 +1401,10 @@ function projectIdForFeature(ctx, featureId) {
1383
1401
  throw new NotFoundError(`feature ${featureId} not found`);
1384
1402
  return row.projectId;
1385
1403
  }
1404
+ function lapForFeature(ctx, featureId) {
1405
+ const row = ctx.db.select({ lap: features.lap }).from(features).where(eq(features.id, featureId)).get();
1406
+ return row?.lap ?? 1;
1407
+ }
1386
1408
  function emit(ctx, featureId, e) {
1387
1409
  return insertEvent(ctx, projectIdForFeature(ctx, featureId), featureId, e);
1388
1410
  }
@@ -1400,6 +1422,7 @@ function insertEvent(ctx, projectId, featureId, e) {
1400
1422
  const row = ctx.db.insert(events).values({
1401
1423
  projectId,
1402
1424
  featureId,
1425
+ lap: featureId ? lapForFeature(ctx, featureId) : 1,
1403
1426
  runId: e.runId ?? null,
1404
1427
  ticketId: e.ticketId ?? null,
1405
1428
  ts: Date.now(),
@@ -1420,6 +1443,10 @@ function listAfter(ctx, featureId, afterId = 0) {
1420
1443
  const rows = ctx.db.select().from(events).where(and(eq(events.featureId, featureId), gt(events.id, afterId))).orderBy(asc(events.id)).all();
1421
1444
  return rows.map(rowToEvent);
1422
1445
  }
1446
+ function latestEventTs(ctx, featureId, type) {
1447
+ const row = ctx.db.select({ ts: events.ts }).from(events).where(and(eq(events.featureId, featureId), eq(events.type, type))).orderBy(desc(events.ts)).limit(1).get();
1448
+ return row?.ts;
1449
+ }
1423
1450
  function listByProject(ctx, projectId, afterId = 0) {
1424
1451
  const rows = ctx.db.select().from(events).where(and(eq(events.projectId, projectId), gt(events.id, afterId))).orderBy(asc(events.id)).all();
1425
1452
  return rows.map(rowToEvent);
@@ -1431,7 +1458,7 @@ var init_events = __esm(() => {
1431
1458
  });
1432
1459
 
1433
1460
  // src/services/repo.ts
1434
- import { and as and2, desc, eq as eq2 } from "drizzle-orm";
1461
+ import { and as and2, desc as desc2, eq as eq2 } from "drizzle-orm";
1435
1462
  function rowToFeature(row) {
1436
1463
  return {
1437
1464
  id: row.id,
@@ -1440,6 +1467,7 @@ function rowToFeature(row) {
1440
1467
  title: row.title,
1441
1468
  oneLiner: row.oneLiner,
1442
1469
  mapped: row.mapped,
1470
+ lap: row.lap,
1443
1471
  phase: row.phase,
1444
1472
  branch: row.branch,
1445
1473
  baseBranch: row.baseBranch ?? undefined,
@@ -1531,7 +1559,7 @@ function getRunRow(ctx, id) {
1531
1559
  return rowToRun(row);
1532
1560
  }
1533
1561
  function listRunsByFeature(ctx, featureId) {
1534
- return ctx.db.select().from(runs).where(eq2(runs.featureId, featureId)).orderBy(desc(runs.startedAt)).all().map(rowToRun);
1562
+ return ctx.db.select().from(runs).where(eq2(runs.featureId, featureId)).orderBy(desc2(runs.startedAt)).all().map(rowToRun);
1535
1563
  }
1536
1564
  function listSessionsByFeature(ctx, featureId) {
1537
1565
  return ctx.db.select().from(sessions).where(eq2(sessions.featureId, featureId)).all().map(rowToSession);
@@ -1734,594 +1762,274 @@ var init_waypoints = __esm(() => {
1734
1762
  TERMINAL = new Set(["resolved", "dropped"]);
1735
1763
  });
1736
1764
 
1737
- // src/launcher/sessions.ts
1738
- import { and as and4, desc as desc2, eq as eq4, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
1739
- function createSessionRow(ctx, input) {
1740
- const row = ctx.db.insert(sessions).values({
1741
- id: newId("sess"),
1742
- featureId: input.featureId ?? null,
1743
- projectId: input.projectId ?? null,
1744
- kind: input.kind,
1745
- ccSessionId: null,
1746
- transcriptPath: null,
1747
- status: "launching",
1748
- worktreePath: input.worktreePath
1749
- }).returning().get();
1750
- return rowToSession(row);
1751
- }
1752
- function getSessionRow(ctx, id) {
1753
- const row = ctx.db.select().from(sessions).where(eq4(sessions.id, id)).get();
1754
- return row ? rowToSession(row) : null;
1765
+ // src/pty/dev-pane.ts
1766
+ function drivePaneId(featureId) {
1767
+ return `${DRIVE_PREFIX}${featureId}`;
1755
1768
  }
1756
- function activeSessionsForFeature(ctx, featureId) {
1757
- return ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), inArray(sessions.status, ["launching", "live"]))).all().map(rowToSession);
1769
+ function sniffDevUrl(text2) {
1770
+ const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
1771
+ return m ? m[0] : undefined;
1758
1772
  }
1759
- function markSessionLive(ctx, id, input = {}) {
1760
- const existing = getSessionRow(ctx, id);
1761
- if (!existing)
1762
- return null;
1763
- ctx.db.update(sessions).set({
1764
- status: "live",
1765
- ccSessionId: input.ccSessionId ?? existing.ccSessionId ?? null,
1766
- transcriptPath: input.transcriptPath ?? existing.transcriptPath ?? null
1767
- }).where(eq4(sessions.id, id)).run();
1768
- const firstTimeLive = existing.status !== "live";
1769
- if (firstTimeLive) {
1770
- promoteLastSession(ctx, id);
1771
- scheduleKickoff(ctx, existing);
1773
+ function devSpawnTarget(devCommand) {
1774
+ if (process.platform === "win32") {
1775
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
1772
1776
  }
1773
- return getSessionRow(ctx, id);
1774
- }
1775
- function kickoffLineFor(kind, override) {
1776
- return override ?? KICKOFF_LINES[kind];
1777
- }
1778
- function resumeKickoffLine(kind) {
1779
- return RESUME_KICKOFF_PREFIX + KICKOFF_LINES[kind];
1780
- }
1781
- function setKickoffOverride(sessionId, line) {
1782
- pendingKickoffOverrides.set(sessionId, line);
1777
+ return { file: "/bin/sh", args: ["-c", devCommand] };
1783
1778
  }
1784
- function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS) {
1785
- if (!io.alive())
1779
+ function startDevPane(input) {
1780
+ const { ctx, featureId, repoPath, devCommand, env, onUrl } = input;
1781
+ const paneId = drivePaneId(featureId);
1782
+ const { file, args } = devSpawnTarget(devCommand);
1783
+ try {
1784
+ ptyRegistry().create({
1785
+ sessionId: paneId,
1786
+ cmd: file,
1787
+ args,
1788
+ opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
1789
+ });
1790
+ } catch (err) {
1791
+ emit(ctx, featureId, {
1792
+ type: "testdrive.dev_failed",
1793
+ message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
1794
+ data: { paneId }
1795
+ });
1786
1796
  return;
1787
- io.write(line);
1788
- const submit = setTimeout(() => {
1789
- try {
1790
- if (!io.alive())
1797
+ }
1798
+ let scanned = "";
1799
+ let found = false;
1800
+ const sniffer = {
1801
+ sendData(chunk) {
1802
+ if (found)
1791
1803
  return;
1792
- io.write("\r");
1793
- io.onSubmitted?.();
1794
- } catch {}
1795
- }, submitDelayMs);
1796
- submit.unref?.();
1797
- }
1798
- function stopTimers(d) {
1799
- for (const t of d.timers)
1800
- clearTimeout(t);
1801
- d.timers.clear();
1804
+ scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
1805
+ const url = sniffDevUrl(scanned);
1806
+ if (url) {
1807
+ found = true;
1808
+ onUrl(url);
1809
+ }
1810
+ },
1811
+ sendControl() {}
1812
+ };
1813
+ ptyRegistry().attach(paneId, sniffer);
1814
+ emit(ctx, featureId, {
1815
+ type: "testdrive.dev_started",
1816
+ message: `dev server spawned: ${devCommand}`,
1817
+ data: { paneId }
1818
+ });
1819
+ return paneId;
1802
1820
  }
1803
- function forgetKickoff(sessionId) {
1804
- const d = deliveries.get(sessionId);
1805
- if (d)
1806
- stopTimers(d);
1807
- deliveries.delete(sessionId);
1808
- pendingKickoffOverrides.delete(sessionId);
1821
+ function stopDevPane(paneId) {
1822
+ const reg = ptyRegistry();
1823
+ const entry = reg.get(paneId);
1824
+ if (entry && !entry.exited && process.platform !== "win32") {
1825
+ try {
1826
+ process.kill(-entry.pty.pid, "SIGTERM");
1827
+ } catch {}
1828
+ }
1829
+ reg.kill(paneId);
1830
+ reg.remove(paneId);
1809
1831
  }
1810
- function ptyIo(sessionId) {
1811
- return {
1812
- write: (data) => ptyRegistry().get(sessionId)?.pty.write(data),
1813
- alive: () => {
1814
- const entry = ptyRegistry().get(sessionId);
1815
- return !!entry && !entry.exited;
1816
- }
1817
- };
1832
+ var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
1833
+ var init_dev_pane = __esm(() => {
1834
+ init_events();
1835
+ init_registry();
1836
+ });
1837
+
1838
+ // src/services/drive-hooks.ts
1839
+ import { spawn as spawn2 } from "child_process";
1840
+ function tailLines(text2, max = OUTPUT_TAIL_LINES) {
1841
+ const lines = text2.replace(/\r\n/g, `
1842
+ `).split(`
1843
+ `);
1844
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
1845
+ lines.pop();
1846
+ return lines.slice(-max).join(`
1847
+ `).trim();
1818
1848
  }
1819
- function track(d, timer) {
1820
- timer.unref?.();
1821
- d.timers.add(timer);
1849
+ function hookSpawnTarget(command) {
1850
+ if (process.platform === "win32") {
1851
+ return {
1852
+ file: process.env.ComSpec ?? "cmd.exe",
1853
+ args: ["/d", "/s", "/c", `"${command}"`],
1854
+ verbatim: true
1855
+ };
1856
+ }
1857
+ return { file: "/bin/sh", args: ["-c", command], verbatim: false };
1822
1858
  }
1823
- function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
1824
- track(d, setTimeout(() => {
1825
- if (d.settled)
1826
- return;
1827
- const io = ptyIo(session.id);
1828
- if (!io.alive()) {
1829
- d.settled = true;
1830
- return;
1831
- }
1832
- d.attempts += 1;
1833
- const attempt = d.attempts;
1859
+ function runDriveHook(command, opts) {
1860
+ const spawnFn = opts.spawnFn ?? spawn2;
1861
+ const now = opts.now ?? Date.now;
1862
+ const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
1863
+ const startedAt = now();
1864
+ const { file, args, verbatim } = hookSpawnTarget(command);
1865
+ return new Promise((resolve4) => {
1866
+ let output = "";
1867
+ let timedOut = false;
1868
+ let settled = false;
1869
+ let timer;
1870
+ const finish = (exitCode) => {
1871
+ if (settled)
1872
+ return;
1873
+ settled = true;
1874
+ if (timer)
1875
+ clearTimeout(timer);
1876
+ resolve4({
1877
+ ok: !timedOut && exitCode === 0,
1878
+ exitCode,
1879
+ timedOut,
1880
+ output: tailLines(output),
1881
+ durationMs: now() - startedAt
1882
+ });
1883
+ };
1884
+ let child;
1834
1885
  try {
1835
- if (clearFirst)
1836
- io.write(CLEAR_INPUT);
1837
- writeKickoffSequence(d.line, {
1838
- write: io.write,
1839
- alive: io.alive,
1840
- onSubmitted: () => emitForSession(ctx, session, {
1841
- type: "session.kickoff",
1842
- message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
1843
- data: { sessionId: session.id, kind: session.kind, attempt }
1844
- })
1886
+ child = spawnFn(file, args, {
1887
+ cwd: opts.cwd,
1888
+ env: opts.env ?? process.env,
1889
+ windowsHide: true,
1890
+ windowsVerbatimArguments: verbatim
1845
1891
  });
1846
- } catch {}
1847
- armConfirmation(ctx, session, d);
1848
- }, delayMs));
1849
- }
1850
- function armConfirmation(ctx, session, d) {
1851
- track(d, setTimeout(() => {
1852
- if (d.settled || d.confirmed)
1853
- return;
1854
- if (d.attempts < KICKOFF_MAX_ATTEMPTS && ptyIo(session.id).alive()) {
1855
- attemptKickoff(ctx, session, d, 0, true);
1892
+ } catch (err) {
1893
+ output = err instanceof Error ? err.message : String(err);
1894
+ finish(null);
1856
1895
  return;
1857
1896
  }
1858
- settleUndelivered(ctx, session, d, "unacknowledged");
1859
- }, KICKOFF_CONFIRM_MS));
1897
+ timer = setTimeout(() => {
1898
+ timedOut = true;
1899
+ child.kill();
1900
+ const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
1901
+ grace.unref?.();
1902
+ }, timeoutMs);
1903
+ timer.unref?.();
1904
+ child.stdout?.on("data", (d) => {
1905
+ output += d.toString();
1906
+ });
1907
+ child.stderr?.on("data", (d) => {
1908
+ output += d.toString();
1909
+ });
1910
+ child.on("error", (err) => {
1911
+ output += `
1912
+ ${err instanceof Error ? err.message : String(err)}`;
1913
+ finish(null);
1914
+ });
1915
+ child.on("close", (code) => {
1916
+ finish(code);
1917
+ });
1918
+ });
1860
1919
  }
1861
- function settleUndelivered(ctx, session, d, reason) {
1862
- d.settled = true;
1863
- stopTimers(d);
1864
- emitForSession(ctx, session, {
1865
- type: "session.kickoff_undelivered",
1866
- message: reason === "superseded" ? `the ${session.kind} briefing was never delivered \u2014 you typed first, so runcastle stopped injecting it` : `the ${session.kind} briefing was typed ${d.attempts}\xD7 but Claude Code never acknowledged it \u2014 send it again from the session strip`,
1867
- data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
1868
- });
1869
- }
1870
- function noteKickoffPrompt(ctx, sessionId, prompt) {
1871
- const d = deliveries.get(sessionId);
1872
- if (!d || d.confirmed)
1873
- return;
1874
- const session = getSessionRow(ctx, sessionId);
1875
- if (!session)
1876
- return;
1877
- if (promptMatchesKickoff(d.line, prompt)) {
1878
- d.confirmed = true;
1879
- d.settled = true;
1880
- stopTimers(d);
1881
- return;
1882
- }
1883
- if (d.settled)
1884
- return;
1885
- settleUndelivered(ctx, session, d, "superseded");
1920
+ function describeHookResult(command, result) {
1921
+ const secs = Math.max(1, Math.round(result.durationMs / 1000));
1922
+ if (result.timedOut)
1923
+ return `\`${command}\` timed out after ${secs}s`;
1924
+ if (result.ok)
1925
+ return `\`${command}\` finished in ${secs}s`;
1926
+ return `\`${command}\` exited ${result.exitCode ?? "without a code"} after ${secs}s`;
1886
1927
  }
1887
- function promptMatchesKickoff(line, prompt) {
1888
- if (!prompt)
1889
- return false;
1890
- const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
1891
- const want = norm(line);
1892
- const got = norm(prompt);
1893
- if (!want || !got)
1894
- return false;
1895
- return got.includes(want.slice(0, MATCH_PREFIX));
1928
+ var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
1929
+ var init_drive_hooks = __esm(() => {
1930
+ DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
1931
+ });
1932
+
1933
+ // src/services/drive-env.ts
1934
+ import { createHash } from "crypto";
1935
+ function identifierSafe(slug, max = MAX_ID_LENGTH) {
1936
+ const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
1937
+ const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
1938
+ if (safe.length <= max)
1939
+ return safe;
1940
+ const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
1941
+ return `${safe.slice(0, max - digest.length - 1)}_${digest}`;
1896
1942
  }
1897
- function resendKickoff(ctx, sessionId) {
1898
- const session = getSessionRow(ctx, sessionId);
1899
- if (!session)
1900
- throw new Error(`unknown session ${sessionId}`);
1901
- if (session.status === "ended")
1902
- throw new Error("that session has ended \u2014 open a new terminal");
1903
- if (!ptyIo(sessionId).alive())
1904
- throw new Error("the terminal for that session is no longer running");
1905
- const existing = deliveries.get(sessionId);
1906
- const d = existing ?? {
1907
- line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
1908
- attempts: 0,
1909
- confirmed: false,
1910
- settled: false,
1911
- timers: new Set
1943
+ function driveVars(identity) {
1944
+ return {
1945
+ slug: identity.slug,
1946
+ branch: identity.branch,
1947
+ id: identifierSafe(identity.slug)
1912
1948
  };
1913
- stopTimers(d);
1914
- d.attempts = 0;
1915
- d.confirmed = false;
1916
- d.settled = false;
1917
- deliveries.set(sessionId, d);
1918
- attemptKickoff(ctx, session, d, 0, true);
1919
- return { line: d.line };
1920
- }
1921
- function armSessionReadyWatchdog(ctx, session) {
1922
- const timer = setTimeout(() => {
1923
- const row = getSessionRow(ctx, session.id);
1924
- if (!row || row.status !== "launching")
1925
- return;
1926
- if (!ptyIo(session.id).alive())
1927
- return;
1928
- emitForSession(ctx, session, {
1929
- type: "session.not_ready",
1930
- message: `the ${session.kind} terminal is open but Claude Code has not reported ready \u2014 check it for a prompt or dialog waiting on you, then send the briefing`,
1931
- data: { sessionId: session.id, kind: session.kind }
1932
- });
1933
- }, SESSION_READY_TIMEOUT_MS);
1934
- timer.unref?.();
1935
1949
  }
1936
- function scheduleKickoff(ctx, session) {
1937
- const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
1938
- pendingKickoffOverrides.delete(session.id);
1939
- const existing = deliveries.get(session.id);
1940
- if (existing)
1941
- stopTimers(existing);
1942
- const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
1943
- deliveries.set(session.id, d);
1944
- attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
1945
- }
1946
- function markSessionEnded(ctx, id) {
1947
- const existing = getSessionRow(ctx, id);
1948
- if (!existing)
1949
- return null;
1950
- forgetKickoff(id);
1951
- ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
1952
- return getSessionRow(ctx, id);
1953
- }
1954
- function mostRecentResumableSession(ctx, featureId, kind) {
1955
- const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId), ...kind ? [eq4(sessions.kind, kind)] : [])).orderBy(desc2(sql2`rowid`)).limit(1).get();
1956
- return row ? rowToSession(row) : null;
1950
+ function renderTemplate(value, vars) {
1951
+ const unknown2 = [];
1952
+ const out = value.replace(PLACEHOLDER_RE, (match, name) => {
1953
+ const replacement = vars[name];
1954
+ if (replacement === undefined) {
1955
+ if (!unknown2.includes(name))
1956
+ unknown2.push(name);
1957
+ return match;
1958
+ }
1959
+ return replacement;
1960
+ });
1961
+ return { value: out, unknown: unknown2 };
1957
1962
  }
1958
- function activeProjectSession(ctx, projectId, kind) {
1959
- const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc2(sql2`rowid`)).limit(1).get();
1960
- return row ? rowToSession(row) : null;
1963
+ function parseDriveEnv(raw, identity) {
1964
+ const vars = {};
1965
+ const unknown2 = [];
1966
+ if (!raw?.trim())
1967
+ return { vars, unknown: unknown2 };
1968
+ const substitutions = driveVars(identity);
1969
+ for (const line of raw.split(/\r?\n/)) {
1970
+ const trimmed = line.trim();
1971
+ if (trimmed === "" || trimmed.startsWith("#"))
1972
+ continue;
1973
+ const eq4 = trimmed.indexOf("=");
1974
+ if (eq4 <= 0)
1975
+ continue;
1976
+ const key = trimmed.slice(0, eq4).trim().replace(/^export\s+/, "");
1977
+ if (key === "")
1978
+ continue;
1979
+ const rendered = renderTemplate(unquote(trimmed.slice(eq4 + 1).trim()), substitutions);
1980
+ vars[key] = rendered.value;
1981
+ for (const name of rendered.unknown)
1982
+ if (!unknown2.includes(name))
1983
+ unknown2.push(name);
1984
+ }
1985
+ return { vars, unknown: unknown2 };
1961
1986
  }
1962
- function mostRecentResumableProjectSession(ctx, projectId, kind) {
1963
- const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc2(sql2`rowid`)).limit(1).get();
1964
- return row ? rowToSession(row) : null;
1987
+ function unquote(value) {
1988
+ if (value.length < 2)
1989
+ return value;
1990
+ const first = value[0];
1991
+ if ((first === '"' || first === "'") && value.endsWith(first))
1992
+ return value.slice(1, -1);
1993
+ return value;
1965
1994
  }
1966
- function mostRecentLiveSession(ctx) {
1967
- const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1968
- return row ? rowToSession(row) : null;
1995
+ function driveProcessEnv(overrides, base = process.env) {
1996
+ return { ...base, ...overrides };
1969
1997
  }
1970
- 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;
1971
- var init_sessions = __esm(() => {
1972
- init_src();
1973
- init_schema();
1974
- init_registry();
1975
- init_events();
1976
- init_waypoints();
1977
- init_repo();
1978
- CONVERGE_KICKOFF_LINE = "Proceed with your task: invoke /runcastle:converge and drive spec then tickets " + "from map.md + decisions.md, per your system prompt.";
1979
- KICKOFF_LINES = {
1980
- ideation: "Proceed with your task: invoke the /runcastle:ideate skill and drive the ideation session.",
1981
- qa: "Proceed with your task: invoke the /runcastle:qa skill and answer questions from the " + "docs and code \u2014 do not advance phases or emit tickets.",
1982
- waypoint: "Proceed with your task: invoke the /runcastle:waypoint skill and work your assigned " + "waypoint to a resolution.",
1983
- converge: CONVERGE_KICKOFF_LINE,
1984
- revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up.",
1985
- prepare: "Proceed with your task: work through the unestablished preparation fields with the human. " + "Start by telling them which fields are still open and what you need from them for each; " + "ask before running anything that touches their database or services."
1986
- };
1987
- RESUME_KICKOFF_PREFIX = "We are picking this conversation back up \u2014 runcastle restarted and closed the terminal, " + "but this conversation is intact. Do NOT start over: tell me in one or two lines where we " + "left off and what is next, then carry on. Your original instruction was: ";
1988
- pendingKickoffOverrides = new Map;
1989
- deliveries = new Map;
1990
- });
1991
-
1992
- // src/launcher/reconcile.ts
1993
- import { inArray as inArray2 } from "drizzle-orm";
1994
- function reconcileStaleSessions(ctx) {
1995
- const stale = ctx.db.select().from(sessions).where(inArray2(sessions.status, ["launching", "live"])).all().map(rowToSession);
1996
- const reconciled = [];
1997
- for (const session of stale) {
1998
- const entry = ptyRegistry().get(session.id);
1999
- if (entry && !entry.exited)
2000
- continue;
2001
- markSessionEnded(ctx, session.id);
2002
- const released = releaseForSession(ctx, session.id);
2003
- emitForSession(ctx, session, {
2004
- type: "session.reconciled",
2005
- message: `session marked ended at boot \u2014 the server restarted while it was ${session.status}`,
2006
- data: {
2007
- sessionId: session.id,
2008
- previousStatus: session.status,
2009
- releasedWaypointIds: released.map((w) => w.id)
2010
- }
2011
- });
2012
- reconciled.push(session);
2013
- }
2014
- return reconciled;
1998
+ function describeDriveEnv(vars) {
1999
+ const keys = Object.keys(vars);
2000
+ return `drive environment: ${keys.join(", ")}`;
2015
2001
  }
2016
- var init_reconcile = __esm(() => {
2017
- init_schema();
2018
- init_registry();
2019
- init_events();
2020
- init_repo();
2021
- init_waypoints();
2022
- init_sessions();
2002
+ var MAX_ID_LENGTH = 40, PLACEHOLDER_RE;
2003
+ var init_drive_env = __esm(() => {
2004
+ PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
2023
2005
  });
2024
2006
 
2025
- // src/launcher/skills-root.ts
2026
- import { existsSync as existsSync5 } from "fs";
2007
+ // src/services/git.ts
2008
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2009
+ import { tmpdir } from "os";
2027
2010
  import { dirname, join as join5, resolve as resolve4 } from "path";
2028
- function resolveSkillsRoot(fromDir) {
2029
- const override = process.env[SKILLS_DIR_ENV];
2030
- if (override) {
2031
- const root = resolve4(override);
2032
- if (existsSync5(join5(root, SKILLS_MARKER)))
2033
- return root;
2034
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
2035
- }
2036
- const searched = [];
2037
- let dir = fromDir;
2038
- for (let i = 0;i < 8; i += 1) {
2039
- const candidate = join5(dir, "packages", "skills");
2040
- searched.push(candidate);
2041
- if (existsSync5(join5(candidate, SKILLS_MARKER)))
2042
- return candidate;
2043
- const parent = dirname(dir);
2044
- if (parent === dir)
2045
- break;
2046
- dir = parent;
2047
- }
2048
- throw new Error(`runcastle skills root not found; searched:
2049
- ${searched.join(`
2050
- `)}`);
2011
+ import { simpleGit } from "simple-git";
2012
+ function migrationPaths(paths) {
2013
+ return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
2051
2014
  }
2052
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
2053
- var init_skills_root = __esm(() => {
2054
- SKILLS_MARKER = join5("packs", "runcastle");
2055
- });
2056
-
2057
- // src/pty/dev-pane.ts
2058
- function drivePaneId(featureId) {
2059
- return `${DRIVE_PREFIX}${featureId}`;
2015
+ function featureBranch(slug) {
2016
+ return `feature/${slug}`;
2060
2017
  }
2061
- function sniffDevUrl(text2) {
2062
- const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
2063
- return m ? m[0] : undefined;
2064
- }
2065
- function devSpawnTarget(devCommand) {
2066
- if (process.platform === "win32") {
2067
- return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
2068
- }
2069
- return { file: "/bin/sh", args: ["-c", devCommand] };
2070
- }
2071
- function startDevPane(input) {
2072
- const { ctx, featureId, repoPath, devCommand, env, onUrl } = input;
2073
- const paneId = drivePaneId(featureId);
2074
- const { file, args } = devSpawnTarget(devCommand);
2075
- try {
2076
- ptyRegistry().create({
2077
- sessionId: paneId,
2078
- cmd: file,
2079
- args,
2080
- opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
2081
- });
2082
- } catch (err) {
2083
- emit(ctx, featureId, {
2084
- type: "testdrive.dev_failed",
2085
- message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
2086
- data: { paneId }
2087
- });
2088
- return;
2089
- }
2090
- let scanned = "";
2091
- let found = false;
2092
- const sniffer = {
2093
- sendData(chunk) {
2094
- if (found)
2095
- return;
2096
- scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
2097
- const url = sniffDevUrl(scanned);
2098
- if (url) {
2099
- found = true;
2100
- onUrl(url);
2101
- }
2102
- },
2103
- sendControl() {}
2104
- };
2105
- ptyRegistry().attach(paneId, sniffer);
2106
- emit(ctx, featureId, {
2107
- type: "testdrive.dev_started",
2108
- message: `dev server spawned: ${devCommand}`,
2109
- data: { paneId }
2110
- });
2111
- return paneId;
2112
- }
2113
- function stopDevPane(paneId) {
2114
- const reg = ptyRegistry();
2115
- const entry = reg.get(paneId);
2116
- if (entry && !entry.exited && process.platform !== "win32") {
2117
- try {
2118
- process.kill(-entry.pty.pid, "SIGTERM");
2119
- } catch {}
2120
- }
2121
- reg.kill(paneId);
2122
- reg.remove(paneId);
2123
- }
2124
- var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
2125
- var init_dev_pane = __esm(() => {
2126
- init_events();
2127
- init_registry();
2128
- });
2129
-
2130
- // src/services/drive-hooks.ts
2131
- import { spawn as spawn2 } from "child_process";
2132
- function tailLines(text2, max = OUTPUT_TAIL_LINES) {
2133
- const lines = text2.replace(/\r\n/g, `
2134
- `).split(`
2135
- `);
2136
- while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
2137
- lines.pop();
2138
- return lines.slice(-max).join(`
2139
- `).trim();
2140
- }
2141
- function hookSpawnTarget(command) {
2142
- if (process.platform === "win32") {
2143
- return {
2144
- file: process.env.ComSpec ?? "cmd.exe",
2145
- args: ["/d", "/s", "/c", `"${command}"`],
2146
- verbatim: true
2147
- };
2148
- }
2149
- return { file: "/bin/sh", args: ["-c", command], verbatim: false };
2150
- }
2151
- function runDriveHook(command, opts) {
2152
- const spawnFn = opts.spawnFn ?? spawn2;
2153
- const now = opts.now ?? Date.now;
2154
- const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
2155
- const startedAt = now();
2156
- const { file, args, verbatim } = hookSpawnTarget(command);
2157
- return new Promise((resolve5) => {
2158
- let output = "";
2159
- let timedOut = false;
2160
- let settled = false;
2161
- let timer;
2162
- const finish = (exitCode) => {
2163
- if (settled)
2164
- return;
2165
- settled = true;
2166
- if (timer)
2167
- clearTimeout(timer);
2168
- resolve5({
2169
- ok: !timedOut && exitCode === 0,
2170
- exitCode,
2171
- timedOut,
2172
- output: tailLines(output),
2173
- durationMs: now() - startedAt
2174
- });
2175
- };
2176
- let child;
2177
- try {
2178
- child = spawnFn(file, args, {
2179
- cwd: opts.cwd,
2180
- env: opts.env ?? process.env,
2181
- windowsHide: true,
2182
- windowsVerbatimArguments: verbatim
2183
- });
2184
- } catch (err) {
2185
- output = err instanceof Error ? err.message : String(err);
2186
- finish(null);
2187
- return;
2188
- }
2189
- timer = setTimeout(() => {
2190
- timedOut = true;
2191
- child.kill();
2192
- const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
2193
- grace.unref?.();
2194
- }, timeoutMs);
2195
- timer.unref?.();
2196
- child.stdout?.on("data", (d) => {
2197
- output += d.toString();
2198
- });
2199
- child.stderr?.on("data", (d) => {
2200
- output += d.toString();
2201
- });
2202
- child.on("error", (err) => {
2203
- output += `
2204
- ${err instanceof Error ? err.message : String(err)}`;
2205
- finish(null);
2206
- });
2207
- child.on("close", (code) => {
2208
- finish(code);
2209
- });
2210
- });
2211
- }
2212
- function describeHookResult(command, result) {
2213
- const secs = Math.max(1, Math.round(result.durationMs / 1000));
2214
- if (result.timedOut)
2215
- return `\`${command}\` timed out after ${secs}s`;
2216
- if (result.ok)
2217
- return `\`${command}\` finished in ${secs}s`;
2218
- return `\`${command}\` exited ${result.exitCode ?? "without a code"} after ${secs}s`;
2219
- }
2220
- var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
2221
- var init_drive_hooks = __esm(() => {
2222
- DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
2223
- });
2224
-
2225
- // src/services/drive-env.ts
2226
- import { createHash } from "crypto";
2227
- function identifierSafe(slug, max = MAX_ID_LENGTH) {
2228
- const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2229
- const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
2230
- if (safe.length <= max)
2231
- return safe;
2232
- const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
2233
- return `${safe.slice(0, max - digest.length - 1)}_${digest}`;
2234
- }
2235
- function driveVars(identity) {
2236
- return {
2237
- slug: identity.slug,
2238
- branch: identity.branch,
2239
- id: identifierSafe(identity.slug)
2240
- };
2241
- }
2242
- function renderTemplate(value, vars) {
2243
- const unknown2 = [];
2244
- const out = value.replace(PLACEHOLDER_RE, (match, name) => {
2245
- const replacement = vars[name];
2246
- if (replacement === undefined) {
2247
- if (!unknown2.includes(name))
2248
- unknown2.push(name);
2249
- return match;
2250
- }
2251
- return replacement;
2252
- });
2253
- return { value: out, unknown: unknown2 };
2254
- }
2255
- function parseDriveEnv(raw, identity) {
2256
- const vars = {};
2257
- const unknown2 = [];
2258
- if (!raw?.trim())
2259
- return { vars, unknown: unknown2 };
2260
- const substitutions = driveVars(identity);
2261
- for (const line of raw.split(/\r?\n/)) {
2262
- const trimmed = line.trim();
2263
- if (trimmed === "" || trimmed.startsWith("#"))
2264
- continue;
2265
- const eq5 = trimmed.indexOf("=");
2266
- if (eq5 <= 0)
2267
- continue;
2268
- const key = trimmed.slice(0, eq5).trim().replace(/^export\s+/, "");
2269
- if (key === "")
2270
- continue;
2271
- const rendered = renderTemplate(unquote(trimmed.slice(eq5 + 1).trim()), substitutions);
2272
- vars[key] = rendered.value;
2273
- for (const name of rendered.unknown)
2274
- if (!unknown2.includes(name))
2275
- unknown2.push(name);
2276
- }
2277
- return { vars, unknown: unknown2 };
2278
- }
2279
- function unquote(value) {
2280
- if (value.length < 2)
2281
- return value;
2282
- const first = value[0];
2283
- if ((first === '"' || first === "'") && value.endsWith(first))
2284
- return value.slice(1, -1);
2285
- return value;
2286
- }
2287
- function driveProcessEnv(overrides, base = process.env) {
2288
- return { ...base, ...overrides };
2289
- }
2290
- function describeDriveEnv(vars) {
2291
- const keys = Object.keys(vars);
2292
- return `drive environment: ${keys.join(", ")}`;
2293
- }
2294
- var MAX_ID_LENGTH = 40, PLACEHOLDER_RE;
2295
- var init_drive_env = __esm(() => {
2296
- PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
2297
- });
2298
-
2299
- // src/services/git.ts
2300
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2301
- import { tmpdir } from "os";
2302
- import { dirname as dirname2, join as join6, resolve as resolve5 } from "path";
2303
- import { simpleGit } from "simple-git";
2304
- function migrationPaths(paths) {
2305
- return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
2306
- }
2307
- function featureBranch(slug) {
2308
- return `feature/${slug}`;
2309
- }
2310
- function git(repoPath) {
2311
- return simpleGit(repoPath);
2018
+ function git(repoPath) {
2019
+ return simpleGit(repoPath);
2312
2020
  }
2313
2021
  function errMsg(e) {
2314
2022
  return e instanceof Error ? e.message : String(e);
2315
2023
  }
2316
2024
  function canon(p) {
2317
- let abs = resolve5(p);
2025
+ let abs = resolve4(p);
2318
2026
  try {
2319
2027
  abs = realpathSync.native(abs);
2320
2028
  } catch {}
2321
2029
  return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2322
2030
  }
2323
2031
  async function assertRepo(repoPath) {
2324
- if (!existsSync6(repoPath)) {
2032
+ if (!existsSync5(repoPath)) {
2325
2033
  throw new InvalidInputError(`path does not exist: ${repoPath}`);
2326
2034
  }
2327
2035
  let isRepo = false;
@@ -2410,7 +2118,10 @@ async function ensureTalkWorktree(project, feature) {
2410
2118
  await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2411
2119
  if (await worktreeIsValid(g, worktreePath, branch))
2412
2120
  return worktreePath;
2413
- mkdirSync2(dirname2(worktreePath), { recursive: true });
2121
+ return addWorktree(g, worktreePath, branch, "talk worktree");
2122
+ }
2123
+ async function addWorktree(g, worktreePath, branch, label) {
2124
+ mkdirSync2(dirname(worktreePath), { recursive: true });
2414
2125
  try {
2415
2126
  await g.raw(["worktree", "add", worktreePath, branch]);
2416
2127
  return worktreePath;
@@ -2422,22 +2133,56 @@ async function ensureTalkWorktree(project, feature) {
2422
2133
  await g.raw(["worktree", "add", worktreePath, branch]);
2423
2134
  return worktreePath;
2424
2135
  } catch (e) {
2425
- throw new InvalidInputError(`could not create talk worktree at ${worktreePath}: ${errMsg(e)}`);
2136
+ throw new InvalidInputError(`could not create ${label} at ${worktreePath}: ${errMsg(e)}`);
2426
2137
  }
2427
2138
  }
2428
2139
  }
2429
- async function ensureBranchExists(g, branch, from) {
2430
- const branches = await g.branchLocal();
2431
- if (!branches.all.includes(branch)) {
2432
- await g.raw(["branch", branch, from]);
2140
+ async function ensureProjectWorktree(project) {
2141
+ const worktreePath = worktreeDir(project.id, PROJECT_WORKTREE_SLUG);
2142
+ const g = git(project.repoPath);
2143
+ const base = project.mainBranch;
2144
+ const leftover = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2145
+ if (leftover.length > 0) {
2146
+ await mergeTempBranch(project.repoPath, base, PROJECT_BRANCH);
2147
+ }
2148
+ const stillAhead = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2149
+ if (stillAhead.length === 0) {
2150
+ await deleteBranchDetachingWorktrees(g, project.repoPath, PROJECT_BRANCH);
2151
+ await g.raw(["branch", PROJECT_BRANCH, base]);
2433
2152
  }
2153
+ if (await worktreeIsValid(g, worktreePath, PROJECT_BRANCH))
2154
+ return worktreePath;
2155
+ if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2156
+ if (await checkoutInWorktree(worktreePath, PROJECT_BRANCH))
2157
+ return worktreePath;
2158
+ }
2159
+ return addWorktree(g, worktreePath, PROJECT_BRANCH, "project worktree");
2434
2160
  }
2435
- async function worktreeIsValid(g, worktreePath, branch) {
2436
- if (!existsSync6(worktreePath))
2437
- return false;
2438
- const registered = await registeredWorktrees(g);
2439
- if (!registered.has(canon(worktreePath)))
2440
- return false;
2161
+ async function checkoutInWorktree(worktreePath, branch) {
2162
+ const gw = git(worktreePath);
2163
+ try {
2164
+ await gw.raw(["checkout", branch]);
2165
+ return true;
2166
+ } catch {}
2167
+ try {
2168
+ await gw.raw(["checkout", "--force", branch]);
2169
+ return true;
2170
+ } catch {
2171
+ return false;
2172
+ }
2173
+ }
2174
+ async function ensureBranchExists(g, branch, from) {
2175
+ const branches = await g.branchLocal();
2176
+ if (!branches.all.includes(branch)) {
2177
+ await g.raw(["branch", branch, from]);
2178
+ }
2179
+ }
2180
+ async function worktreeIsValid(g, worktreePath, branch) {
2181
+ if (!existsSync5(worktreePath))
2182
+ return false;
2183
+ const registered = await registeredWorktrees(g);
2184
+ if (!registered.has(canon(worktreePath)))
2185
+ return false;
2441
2186
  try {
2442
2187
  const head = (await git(worktreePath).revparse(["--abbrev-ref", "HEAD"])).trim();
2443
2188
  return head === branch;
@@ -2479,7 +2224,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2479
2224
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2480
2225
  }
2481
2226
  async function detachWorktree(path) {
2482
- if (!existsSync6(path))
2227
+ if (!existsSync5(path))
2483
2228
  return false;
2484
2229
  const g = git(path);
2485
2230
  let head;
@@ -2494,7 +2239,7 @@ async function detachWorktree(path) {
2494
2239
  return true;
2495
2240
  }
2496
2241
  async function reattachWorktree(path, branch) {
2497
- if (!existsSync6(path))
2242
+ if (!existsSync5(path))
2498
2243
  return;
2499
2244
  try {
2500
2245
  await git(path).checkout(branch);
@@ -2525,21 +2270,21 @@ function ticketBranchName(slug, ticketSeq, unique) {
2525
2270
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2526
2271
  }
2527
2272
  function burnWorktreePath(repoPath, branch) {
2528
- return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2273
+ return join5(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2529
2274
  }
2530
2275
  async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2531
2276
  const path = burnWorktreePath(repoPath, branch);
2532
2277
  const attempts = Math.max(1, opts.attempts ?? 3);
2533
2278
  const delayMs = opts.delayMs ?? 750;
2534
2279
  const g = git(repoPath);
2535
- for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2280
+ for (let attempt = 1;attempt <= attempts && existsSync5(path); attempt++) {
2536
2281
  try {
2537
2282
  await g.raw(["worktree", "remove", "--force", path]);
2538
2283
  } catch {}
2539
- if (existsSync6(path) && attempt < attempts)
2284
+ if (existsSync5(path) && attempt < attempts)
2540
2285
  await sleep(delayMs);
2541
2286
  }
2542
- if (existsSync6(path)) {
2287
+ if (existsSync5(path)) {
2543
2288
  try {
2544
2289
  rmSync(path, { recursive: true, force: true });
2545
2290
  } catch {}
@@ -2547,7 +2292,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2547
2292
  try {
2548
2293
  await g.raw(["worktree", "prune"]);
2549
2294
  } catch {}
2550
- return !existsSync6(path);
2295
+ return !existsSync5(path);
2551
2296
  }
2552
2297
  function sleep(ms) {
2553
2298
  return new Promise((done) => setTimeout(done, ms));
@@ -2684,11 +2429,11 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2684
2429
  async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2685
2430
  let dir;
2686
2431
  try {
2687
- dir = mkdtempSync(join6(tmpdir(), "rc-land-"));
2432
+ dir = mkdtempSync(join5(tmpdir(), "rc-land-"));
2688
2433
  } catch (e) {
2689
2434
  return { ok: false, error: errMsg(e) };
2690
2435
  }
2691
- const wt = join6(dir, "wt");
2436
+ const wt = join5(dir, "wt");
2692
2437
  try {
2693
2438
  await g.raw(["worktree", "add", wt, featureBranchName]);
2694
2439
  const gw = git(wt);
@@ -2727,6 +2472,19 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
2727
2472
  return false;
2728
2473
  }
2729
2474
  }
2475
+ async function landProjectBranch(project) {
2476
+ const ahead = await branchCommitsAhead(project.repoPath, project.mainBranch, PROJECT_BRANCH);
2477
+ if (ahead.length === 0)
2478
+ return null;
2479
+ const res = await mergeTempBranch(project.repoPath, project.mainBranch, PROJECT_BRANCH);
2480
+ return {
2481
+ commits: ahead.length,
2482
+ landed: res.ok,
2483
+ ...res.conflict ? { conflict: true } : {},
2484
+ ...res.files ? { files: res.files } : {},
2485
+ ...res.error ? { error: res.error } : {}
2486
+ };
2487
+ }
2730
2488
  function tempBranchPrefix(name) {
2731
2489
  return TEMP_BRANCH_PREFIXES.find((p) => name.startsWith(p));
2732
2490
  }
@@ -2782,7 +2540,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2782
2540
  try {
2783
2541
  await g.raw(["worktree", "prune"]);
2784
2542
  } catch {}
2785
- if (existsSync6(worktreePath)) {
2543
+ if (existsSync5(worktreePath)) {
2786
2544
  throw new InvalidInputError(`could not remove talk worktree at ${worktreePath} \u2014 a file may be locked; ` + "close anything using it and retry the delete");
2787
2545
  }
2788
2546
  }
@@ -2809,7 +2567,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2809
2567
  return { deleted, kept };
2810
2568
  }
2811
2569
  async function commitDocs(worktreePath, message) {
2812
- if (!existsSync6(resolve5(worktreePath, DOCS_PATHSPEC)))
2570
+ if (!existsSync5(resolve4(worktreePath, DOCS_PATHSPEC)))
2813
2571
  return;
2814
2572
  const g = git(worktreePath);
2815
2573
  await g.add([DOCS_PATHSPEC]);
@@ -3034,42 +2792,400 @@ async function mergeFeature(project, feature) {
3034
2792
  throw e instanceof Error ? e : new Error(errMsg(e));
3035
2793
  }
3036
2794
  }
3037
- async function restoreBranch(g, previous, target) {
3038
- if (previous === target || previous === "HEAD" || !previous)
3039
- return;
3040
- try {
3041
- await g.checkout(previous);
3042
- } catch {}
3043
- }
3044
- async function conflictedFiles(g) {
3045
- try {
3046
- const out = (await g.raw(["diff", "--name-only", "--diff-filter=U"])).trim();
3047
- return out ? out.split(`
3048
- `).map((s) => s.trim()).filter(Boolean) : [];
3049
- } catch {
3050
- return [];
2795
+ async function restoreBranch(g, previous, target) {
2796
+ if (previous === target || previous === "HEAD" || !previous)
2797
+ return;
2798
+ try {
2799
+ await g.checkout(previous);
2800
+ } catch {}
2801
+ }
2802
+ async function conflictedFiles(g) {
2803
+ try {
2804
+ const out = (await g.raw(["diff", "--name-only", "--diff-filter=U"])).trim();
2805
+ return out ? out.split(`
2806
+ `).map((s) => s.trim()).filter(Boolean) : [];
2807
+ } catch {
2808
+ return [];
2809
+ }
2810
+ }
2811
+ async function mergeInProgress(g) {
2812
+ try {
2813
+ await g.raw(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
2814
+ return true;
2815
+ } catch {
2816
+ return false;
2817
+ }
2818
+ }
2819
+ var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", PREP_BRANCH_PREFIX = "runcastle/prep/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
2820
+ var init_git = __esm(() => {
2821
+ init_paths();
2822
+ init_errors();
2823
+ init_dev_pane();
2824
+ init_drive_hooks();
2825
+ init_drive_env();
2826
+ init_events();
2827
+ init_repo();
2828
+ MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
2829
+ DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
2830
+ TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
2831
+ });
2832
+
2833
+ // src/launcher/sessions.ts
2834
+ import { and as and4, desc as desc3, eq as eq4, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
2835
+ function createSessionRow(ctx, input) {
2836
+ const row = ctx.db.insert(sessions).values({
2837
+ id: newId("sess"),
2838
+ featureId: input.featureId ?? null,
2839
+ projectId: input.projectId ?? null,
2840
+ lap: input.featureId ? getFeatureRow(ctx, input.featureId).lap : 1,
2841
+ kind: input.kind,
2842
+ ccSessionId: null,
2843
+ transcriptPath: null,
2844
+ status: "launching",
2845
+ worktreePath: input.worktreePath
2846
+ }).returning().get();
2847
+ return rowToSession(row);
2848
+ }
2849
+ function getSessionRow(ctx, id) {
2850
+ const row = ctx.db.select().from(sessions).where(eq4(sessions.id, id)).get();
2851
+ return row ? rowToSession(row) : null;
2852
+ }
2853
+ function activeSessionsForFeature(ctx, featureId) {
2854
+ return ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), inArray(sessions.status, ["launching", "live"]))).all().map(rowToSession);
2855
+ }
2856
+ function markSessionLive(ctx, id, input = {}) {
2857
+ const existing = getSessionRow(ctx, id);
2858
+ if (!existing)
2859
+ return null;
2860
+ ctx.db.update(sessions).set({
2861
+ status: "live",
2862
+ ccSessionId: input.ccSessionId ?? existing.ccSessionId ?? null,
2863
+ transcriptPath: input.transcriptPath ?? existing.transcriptPath ?? null
2864
+ }).where(eq4(sessions.id, id)).run();
2865
+ const firstTimeLive = existing.status !== "live";
2866
+ if (firstTimeLive) {
2867
+ promoteLastSession(ctx, id);
2868
+ scheduleKickoff(ctx, existing);
2869
+ }
2870
+ return getSessionRow(ctx, id);
2871
+ }
2872
+ function lapKickoff(lap) {
2873
+ return `Proceed with your task: invoke the /runcastle:revisit skill for LAP ${lap} REVIEW ITERATION. ` + `Call get_feature_context, then read this feature's test-notes.md (the "## Lap ${lap - 1}" ` + 'section \u2014 what the last drive surfaced) and the "## Later laps" section of its spec.md. ' + "EITHER MAY NOT EXIST YET; that is normal, not an error \u2014 say so and carry on from what I " + "tell you. Interview me about what the test drive taught: what was wrong, what was missing, " + "what I want next. Write what we settle on into decisions.md and amend spec.md for this lap " + '(pruning anything you promote out of "## Later laps"), then call emit_tickets for this ' + `lap's work. Finish in THIS session: complete_phase through ideation \u2192 spec \u2192 tickets, then ` + "tell me to review the cards and click Burn.";
2874
+ }
2875
+ function kickoffLineFor(kind, override) {
2876
+ return override ?? KICKOFF_LINES[kind];
2877
+ }
2878
+ function resumeKickoffLine(kind) {
2879
+ return RESUME_KICKOFF_PREFIX + KICKOFF_LINES[kind];
2880
+ }
2881
+ function setKickoffOverride(sessionId, line) {
2882
+ pendingKickoffOverrides.set(sessionId, line);
2883
+ }
2884
+ function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS) {
2885
+ if (!io.alive())
2886
+ return;
2887
+ io.write(line);
2888
+ const submit = setTimeout(() => {
2889
+ try {
2890
+ if (!io.alive())
2891
+ return;
2892
+ io.write("\r");
2893
+ io.onSubmitted?.();
2894
+ } catch {}
2895
+ }, submitDelayMs);
2896
+ submit.unref?.();
2897
+ }
2898
+ function stopTimers(d) {
2899
+ for (const t of d.timers)
2900
+ clearTimeout(t);
2901
+ d.timers.clear();
2902
+ }
2903
+ function forgetKickoff(sessionId) {
2904
+ const d = deliveries.get(sessionId);
2905
+ if (d)
2906
+ stopTimers(d);
2907
+ deliveries.delete(sessionId);
2908
+ pendingKickoffOverrides.delete(sessionId);
2909
+ }
2910
+ function ptyIo(sessionId) {
2911
+ return {
2912
+ write: (data) => ptyRegistry().get(sessionId)?.pty.write(data),
2913
+ alive: () => {
2914
+ const entry = ptyRegistry().get(sessionId);
2915
+ return !!entry && !entry.exited;
2916
+ }
2917
+ };
2918
+ }
2919
+ function track(d, timer) {
2920
+ timer.unref?.();
2921
+ d.timers.add(timer);
2922
+ }
2923
+ function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
2924
+ track(d, setTimeout(() => {
2925
+ if (d.settled)
2926
+ return;
2927
+ const io = ptyIo(session.id);
2928
+ if (!io.alive()) {
2929
+ d.settled = true;
2930
+ return;
2931
+ }
2932
+ d.attempts += 1;
2933
+ const attempt = d.attempts;
2934
+ try {
2935
+ if (clearFirst)
2936
+ io.write(CLEAR_INPUT);
2937
+ writeKickoffSequence(d.line, {
2938
+ write: io.write,
2939
+ alive: io.alive,
2940
+ onSubmitted: () => emitForSession(ctx, session, {
2941
+ type: "session.kickoff",
2942
+ message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
2943
+ data: { sessionId: session.id, kind: session.kind, attempt }
2944
+ })
2945
+ });
2946
+ } catch {}
2947
+ armConfirmation(ctx, session, d);
2948
+ }, delayMs));
2949
+ }
2950
+ function armConfirmation(ctx, session, d) {
2951
+ track(d, setTimeout(() => {
2952
+ if (d.settled || d.confirmed)
2953
+ return;
2954
+ if (d.attempts < KICKOFF_MAX_ATTEMPTS && ptyIo(session.id).alive()) {
2955
+ attemptKickoff(ctx, session, d, 0, true);
2956
+ return;
2957
+ }
2958
+ settleUndelivered(ctx, session, d, "unacknowledged");
2959
+ }, KICKOFF_CONFIRM_MS));
2960
+ }
2961
+ function settleUndelivered(ctx, session, d, reason) {
2962
+ d.settled = true;
2963
+ stopTimers(d);
2964
+ emitForSession(ctx, session, {
2965
+ type: "session.kickoff_undelivered",
2966
+ message: reason === "superseded" ? `the ${session.kind} briefing was never delivered \u2014 you typed first, so runcastle stopped injecting it` : `the ${session.kind} briefing was typed ${d.attempts}\xD7 but Claude Code never acknowledged it \u2014 send it again from the session strip`,
2967
+ data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
2968
+ });
2969
+ }
2970
+ function noteKickoffPrompt(ctx, sessionId, prompt) {
2971
+ const d = deliveries.get(sessionId);
2972
+ if (!d || d.confirmed)
2973
+ return;
2974
+ const session = getSessionRow(ctx, sessionId);
2975
+ if (!session)
2976
+ return;
2977
+ if (promptMatchesKickoff(d.line, prompt)) {
2978
+ d.confirmed = true;
2979
+ d.settled = true;
2980
+ stopTimers(d);
2981
+ return;
2982
+ }
2983
+ if (d.settled)
2984
+ return;
2985
+ settleUndelivered(ctx, session, d, "superseded");
2986
+ }
2987
+ function promptMatchesKickoff(line, prompt) {
2988
+ if (!prompt)
2989
+ return false;
2990
+ const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
2991
+ const want = norm(line);
2992
+ const got = norm(prompt);
2993
+ if (!want || !got)
2994
+ return false;
2995
+ return got.includes(want.slice(0, MATCH_PREFIX));
2996
+ }
2997
+ function resendKickoff(ctx, sessionId) {
2998
+ const session = getSessionRow(ctx, sessionId);
2999
+ if (!session)
3000
+ throw new Error(`unknown session ${sessionId}`);
3001
+ if (session.status === "ended")
3002
+ throw new Error("that session has ended \u2014 open a new terminal");
3003
+ if (!ptyIo(sessionId).alive())
3004
+ throw new Error("the terminal for that session is no longer running");
3005
+ const existing = deliveries.get(sessionId);
3006
+ const d = existing ?? {
3007
+ line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
3008
+ attempts: 0,
3009
+ confirmed: false,
3010
+ settled: false,
3011
+ timers: new Set
3012
+ };
3013
+ stopTimers(d);
3014
+ d.attempts = 0;
3015
+ d.confirmed = false;
3016
+ d.settled = false;
3017
+ deliveries.set(sessionId, d);
3018
+ attemptKickoff(ctx, session, d, 0, true);
3019
+ return { line: d.line };
3020
+ }
3021
+ function armSessionReadyWatchdog(ctx, session) {
3022
+ const timer = setTimeout(() => {
3023
+ const row = getSessionRow(ctx, session.id);
3024
+ if (!row || row.status !== "launching")
3025
+ return;
3026
+ if (!ptyIo(session.id).alive())
3027
+ return;
3028
+ emitForSession(ctx, session, {
3029
+ type: "session.not_ready",
3030
+ message: `the ${session.kind} terminal is open but Claude Code has not reported ready \u2014 check it for a prompt or dialog waiting on you, then send the briefing`,
3031
+ data: { sessionId: session.id, kind: session.kind }
3032
+ });
3033
+ }, SESSION_READY_TIMEOUT_MS);
3034
+ timer.unref?.();
3035
+ }
3036
+ function scheduleKickoff(ctx, session) {
3037
+ const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
3038
+ pendingKickoffOverrides.delete(session.id);
3039
+ const existing = deliveries.get(session.id);
3040
+ if (existing)
3041
+ stopTimers(existing);
3042
+ const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
3043
+ deliveries.set(session.id, d);
3044
+ attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
3045
+ }
3046
+ function landProjectSession(ctx, session) {
3047
+ if (session.kind !== "project" || !session.projectId)
3048
+ return;
3049
+ if (landedProjectSessions.has(session.id))
3050
+ return;
3051
+ landedProjectSessions.add(session.id);
3052
+ const project = getProjectById(ctx, session.projectId);
3053
+ if (!project)
3054
+ return;
3055
+ const kept = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3056
+ const report = (type, message, data = {}) => {
3057
+ emitProject(ctx, project.id, {
3058
+ type,
3059
+ message,
3060
+ data: { sessionId: session.id, branch: PROJECT_BRANCH, ...data }
3061
+ });
3062
+ };
3063
+ landProjectBranch(project).then((res) => {
3064
+ if (!res)
3065
+ return;
3066
+ if (res.landed) {
3067
+ report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${project.mainBranch}`, { commits: res.commits });
3068
+ } else if (res.conflict) {
3069
+ 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 ?? [] });
3070
+ } else {
3071
+ 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 });
3072
+ }
3073
+ }).catch((e) => {
3074
+ 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) });
3075
+ });
3076
+ }
3077
+ function markSessionEnded(ctx, id) {
3078
+ const existing = getSessionRow(ctx, id);
3079
+ if (!existing)
3080
+ return null;
3081
+ forgetKickoff(id);
3082
+ ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
3083
+ return getSessionRow(ctx, id);
3084
+ }
3085
+ function mostRecentResumableSession(ctx, featureId, kind) {
3086
+ const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId), ...kind ? [eq4(sessions.kind, kind)] : [])).orderBy(desc3(sql2`rowid`)).limit(1).get();
3087
+ return row ? rowToSession(row) : null;
3088
+ }
3089
+ function activeProjectSession(ctx, projectId, kind) {
3090
+ const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc3(sql2`rowid`)).limit(1).get();
3091
+ return row ? rowToSession(row) : null;
3092
+ }
3093
+ function mostRecentResumableProjectSession(ctx, projectId, kind) {
3094
+ const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.projectId, projectId), eq4(sessions.kind, kind), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc3(sql2`rowid`)).limit(1).get();
3095
+ return row ? rowToSession(row) : null;
3096
+ }
3097
+ function mostRecentLiveSession(ctx) {
3098
+ const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc3(sql2`rowid`)).limit(1).get();
3099
+ return row ? rowToSession(row) : null;
3100
+ }
3101
+ 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;
3102
+ var init_sessions = __esm(() => {
3103
+ init_src();
3104
+ init_schema();
3105
+ init_registry();
3106
+ init_events();
3107
+ init_git();
3108
+ init_waypoints();
3109
+ init_repo();
3110
+ CONVERGE_KICKOFF_LINE = "Proceed with your task: invoke /runcastle:converge and drive spec then tickets " + "from map.md + decisions.md, per your system prompt.";
3111
+ KICKOFF_LINES = {
3112
+ ideation: "Proceed with your task: invoke the /runcastle:ideate skill and drive the ideation session.",
3113
+ qa: "Proceed with your task: invoke the /runcastle:qa skill and answer questions from the " + "docs and code \u2014 do not advance phases or emit tickets.",
3114
+ waypoint: "Proceed with your task: invoke the /runcastle:waypoint skill and work your assigned " + "waypoint to a resolution.",
3115
+ converge: CONVERGE_KICKOFF_LINE,
3116
+ revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up.",
3117
+ prepare: "Proceed with your task: work through the unestablished preparation fields with the human. " + "Start by telling them which fields are still open and what you need from them for each; " + "ask before running anything that touches their database or services.",
3118
+ project: "Proceed with your task: invoke the /runcastle:project skill and drive the project session."
3119
+ };
3120
+ RESUME_KICKOFF_PREFIX = "We are picking this conversation back up \u2014 runcastle restarted and closed the terminal, " + "but this conversation is intact. Do NOT start over: tell me in one or two lines where we " + "left off and what is next, then carry on. Your original instruction was: ";
3121
+ pendingKickoffOverrides = new Map;
3122
+ deliveries = new Map;
3123
+ landedProjectSessions = new Set;
3124
+ });
3125
+
3126
+ // src/launcher/reconcile.ts
3127
+ import { inArray as inArray2 } from "drizzle-orm";
3128
+ function reconcileStaleSessions(ctx) {
3129
+ const stale = ctx.db.select().from(sessions).where(inArray2(sessions.status, ["launching", "live"])).all().map(rowToSession);
3130
+ const reconciled = [];
3131
+ for (const session of stale) {
3132
+ const entry = ptyRegistry().get(session.id);
3133
+ if (entry && !entry.exited)
3134
+ continue;
3135
+ markSessionEnded(ctx, session.id);
3136
+ const released = releaseForSession(ctx, session.id);
3137
+ emitForSession(ctx, session, {
3138
+ type: "session.reconciled",
3139
+ message: `session marked ended at boot \u2014 the server restarted while it was ${session.status}`,
3140
+ data: {
3141
+ sessionId: session.id,
3142
+ previousStatus: session.status,
3143
+ releasedWaypointIds: released.map((w) => w.id)
3144
+ }
3145
+ });
3146
+ reconciled.push(session);
3147
+ }
3148
+ return reconciled;
3149
+ }
3150
+ var init_reconcile = __esm(() => {
3151
+ init_schema();
3152
+ init_registry();
3153
+ init_events();
3154
+ init_repo();
3155
+ init_waypoints();
3156
+ init_sessions();
3157
+ });
3158
+
3159
+ // src/launcher/skills-root.ts
3160
+ import { existsSync as existsSync6 } from "fs";
3161
+ import { dirname as dirname2, join as join6, resolve as resolve5 } from "path";
3162
+ function resolveSkillsRoot(fromDir) {
3163
+ const override = process.env[SKILLS_DIR_ENV];
3164
+ if (override) {
3165
+ const root = resolve5(override);
3166
+ if (existsSync6(join6(root, SKILLS_MARKER)))
3167
+ return root;
3168
+ throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
3051
3169
  }
3052
- }
3053
- async function mergeInProgress(g) {
3054
- try {
3055
- await g.raw(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
3056
- return true;
3057
- } catch {
3058
- return false;
3170
+ const searched = [];
3171
+ let dir = fromDir;
3172
+ for (let i = 0;i < 8; i += 1) {
3173
+ const candidate = join6(dir, "packages", "skills");
3174
+ searched.push(candidate);
3175
+ if (existsSync6(join6(candidate, SKILLS_MARKER)))
3176
+ return candidate;
3177
+ const parent = dirname2(dir);
3178
+ if (parent === dir)
3179
+ break;
3180
+ dir = parent;
3059
3181
  }
3182
+ throw new Error(`runcastle skills root not found; searched:
3183
+ ${searched.join(`
3184
+ `)}`);
3060
3185
  }
3061
- var DOCS_PATHSPEC = "docs/features", MIGRATION_DIR_RE, DRIZZLE_SQL_RE, DENY_DIRTY = "Working tree has uncommitted changes \u2014 commit or stash first", DENY_ACTIVE = "A test drive is already active \u2014 stop it first", DENY_ACTIVE_RUN = "Feature has an active run \u2014 wait for it to finish", DENY_NONE_ACTIVE = "No test drive is active", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", PREP_BRANCH_PREFIX = "runcastle/prep/", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
3062
- var init_git = __esm(() => {
3063
- init_paths();
3064
- init_errors();
3065
- init_dev_pane();
3066
- init_drive_hooks();
3067
- init_drive_env();
3068
- init_events();
3069
- init_repo();
3070
- MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
3071
- DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
3072
- TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
3186
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
3187
+ var init_skills_root = __esm(() => {
3188
+ SKILLS_MARKER = join6("packs", "runcastle");
3073
3189
  });
3074
3190
 
3075
3191
  // src/services/agent-stream.ts
@@ -4699,7 +4815,7 @@ var init_findings = __esm(() => {
4699
4815
  });
4700
4816
 
4701
4817
  // src/services/prep.ts
4702
- import { and as and6, desc as desc3, eq as eq6 } from "drizzle-orm";
4818
+ import { and as and6, desc as desc4, eq as eq6 } from "drizzle-orm";
4703
4819
  function isPreparing(projectId) {
4704
4820
  return controllers.has(projectId);
4705
4821
  }
@@ -4821,7 +4937,7 @@ function truncate(value, max = 120) {
4821
4937
  return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
4822
4938
  }
4823
4939
  function latestPrep(ctx, projectId) {
4824
- const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc3(projectPreps.startedAt)).get();
4940
+ const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc4(projectPreps.startedAt)).get();
4825
4941
  if (!row)
4826
4942
  return null;
4827
4943
  return {
@@ -4835,7 +4951,7 @@ function latestPrep(ctx, projectId) {
4835
4951
  };
4836
4952
  }
4837
4953
  function latestPrepNotes(ctx, projectId) {
4838
- const row = ctx.db.select({ data: events.data }).from(events).where(and6(eq6(events.projectId, projectId), eq6(events.type, "prep.notes"))).orderBy(desc3(events.id)).get();
4954
+ const row = ctx.db.select({ data: events.data }).from(events).where(and6(eq6(events.projectId, projectId), eq6(events.type, "prep.notes"))).orderBy(desc4(events.id)).get();
4839
4955
  const notes = row?.data?.notes;
4840
4956
  return typeof notes === "string" && notes.trim() !== "" ? notes : undefined;
4841
4957
  }
@@ -4905,6 +5021,7 @@ function rowToTicket(row) {
4905
5021
  acceptanceCriteria: row.acceptanceCriteria,
4906
5022
  seams: row.seams,
4907
5023
  blockedBy: row.blockedBy,
5024
+ lap: row.lap,
4908
5025
  status: row.status,
4909
5026
  commits: row.commits,
4910
5027
  error: row.error ?? undefined,
@@ -4927,6 +5044,7 @@ function listByFeature2(ctx, featureId) {
4927
5044
  function storeTickets(ctx, featureId, inputs) {
4928
5045
  if (inputs.length === 0)
4929
5046
  return [];
5047
+ const { lap } = getFeatureRow(ctx, featureId);
4930
5048
  const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
4931
5049
  const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
4932
5050
  const resolved = resolveBlocking(inputs, startSeq);
@@ -4940,6 +5058,7 @@ function storeTickets(ctx, featureId, inputs) {
4940
5058
  acceptanceCriteria: t.acceptanceCriteria,
4941
5059
  seams: t.seams,
4942
5060
  blockedBy: resolved[i].blockedBy,
5061
+ lap,
4943
5062
  status: "pending",
4944
5063
  commits: [],
4945
5064
  error: null,
@@ -5047,6 +5166,7 @@ var init_tickets = __esm(() => {
5047
5166
  init_schema();
5048
5167
  init_errors();
5049
5168
  init_events();
5169
+ init_repo();
5050
5170
  MUTABLE_STATUSES = new Set(["pending", "failed"]);
5051
5171
  });
5052
5172
 
@@ -5070,7 +5190,7 @@ function checkGate(ctx, check, feature) {
5070
5190
  case "spec-file-exists":
5071
5191
  return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
5072
5192
  case "tickets-approved": {
5073
- const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
5193
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.lap === feature.lap && t.status !== "cancelled").length;
5074
5194
  return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
5075
5195
  }
5076
5196
  case "all-tickets-terminal": {
@@ -5128,14 +5248,16 @@ var init_gates = __esm(() => {
5128
5248
  // src/services/knowledge.ts
5129
5249
  import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync } from "fs";
5130
5250
  import { join as join10, relative, resolve as resolve6, sep } from "path";
5131
- function scaffoldDocs(ctx, feature) {
5251
+ function scaffoldDocs(ctx, feature, opts = {}) {
5132
5252
  const project = projectForFeature(ctx, feature);
5133
5253
  const dir = featureDocsDir(project, feature);
5134
5254
  mkdirSync5(dir, { recursive: true });
5135
5255
  const briefPath = featureDocPath(project, feature, "brief.md");
5136
5256
  if (!existsSync11(briefPath)) {
5257
+ const override = opts.brief?.trim();
5137
5258
  const created = new Date().toISOString();
5138
- const brief = [
5259
+ const brief = override ? `${override}
5260
+ ` : [
5139
5261
  `# ${feature.title}`,
5140
5262
  "",
5141
5263
  feature.oneLiner,
@@ -5177,6 +5299,36 @@ function mapDocBody(feature, seed) {
5177
5299
  return lines.join(`
5178
5300
  `);
5179
5301
  }
5302
+ function readCharter(repoRoot) {
5303
+ const path = join10(repoRoot, CHARTER_FILE);
5304
+ if (!existsSync11(path))
5305
+ return;
5306
+ try {
5307
+ return readFileSync5(path, "utf8");
5308
+ } catch {
5309
+ return;
5310
+ }
5311
+ }
5312
+ function listLiveAdrs(repoRoot) {
5313
+ const dir = join10(repoRoot, ADR_DIR_REL);
5314
+ if (!existsSync11(dir))
5315
+ return [];
5316
+ const docs = [];
5317
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
5318
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
5319
+ continue;
5320
+ let content;
5321
+ try {
5322
+ content = readFileSync5(join10(dir, entry.name), "utf8");
5323
+ } catch {
5324
+ continue;
5325
+ }
5326
+ if (SUPERSEDED_RE.test(content))
5327
+ continue;
5328
+ docs.push({ relPath: `${ADR_DIR_REL}/${entry.name}`, content });
5329
+ }
5330
+ return docs.sort((a, b) => a.relPath.localeCompare(b.relPath));
5331
+ }
5180
5332
  function listDocs(ctx, feature) {
5181
5333
  const project = projectForFeature(ctx, feature);
5182
5334
  const dir = featureDocsDir(project, feature);
@@ -5207,7 +5359,7 @@ function readTitle(path, fallback) {
5207
5359
  return fallback;
5208
5360
  }
5209
5361
  }
5210
- var MAP_SECTIONS;
5362
+ var MAP_SECTIONS, CHARTER_FILE = "CONTEXT.md", ADR_DIR_REL = "docs/adr", SUPERSEDED_RE;
5211
5363
  var init_knowledge = __esm(() => {
5212
5364
  init_errors();
5213
5365
  init_events();
@@ -5219,6 +5371,7 @@ var init_knowledge = __esm(() => {
5219
5371
  "Not yet specified",
5220
5372
  "Out of scope"
5221
5373
  ];
5374
+ SUPERSEDED_RE = /superseded by ADR-\d+/i;
5222
5375
  });
5223
5376
 
5224
5377
  // src/pty/end-session.ts
@@ -5229,6 +5382,7 @@ function endSession(ctx, sessionId) {
5229
5382
  const session = markSessionEnded(ctx, sessionId);
5230
5383
  if (session) {
5231
5384
  releaseForSession(ctx, sessionId);
5385
+ landProjectSession(ctx, session);
5232
5386
  emitForSession(ctx, session, {
5233
5387
  type: "session.ended",
5234
5388
  message: "session ended by user",
@@ -5590,7 +5744,7 @@ var init_runner = __esm(() => {
5590
5744
  });
5591
5745
 
5592
5746
  // src/services/features.ts
5593
- import { desc as desc4, eq as eq9 } from "drizzle-orm";
5747
+ import { desc as desc5, eq as eq9 } from "drizzle-orm";
5594
5748
  import { rmSync as rmSync2 } from "fs";
5595
5749
  async function createFeature(ctx, input) {
5596
5750
  const project = requireProjectById(ctx, input.projectId);
@@ -5605,6 +5759,7 @@ async function createFeature(ctx, input) {
5605
5759
  title: input.title,
5606
5760
  oneLiner: input.oneLiner,
5607
5761
  mapped: false,
5762
+ lap: 1,
5608
5763
  phase: "ideation",
5609
5764
  branch,
5610
5765
  baseBranch,
@@ -5618,7 +5773,67 @@ async function createFeature(ctx, input) {
5618
5773
  message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
5619
5774
  data: { slug, branch, baseBranch, branchReady }
5620
5775
  });
5621
- scaffoldDocs(ctx, feature);
5776
+ scaffoldDocs(ctx, feature, { brief: input.brief });
5777
+ if (branchReady) {
5778
+ try {
5779
+ await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
5780
+ } catch {}
5781
+ }
5782
+ return feature;
5783
+ }
5784
+ async function quickChange(ctx, input) {
5785
+ const project = requireProjectById(ctx, input.projectId);
5786
+ const title = input.title.trim();
5787
+ const prose = input.prose.trim();
5788
+ if (!title)
5789
+ throw new InvalidInputError("a quick change needs a title");
5790
+ if (!prose)
5791
+ throw new InvalidInputError("a quick change needs a sentence describing the change");
5792
+ const slug = uniqueSlug(ctx, project.id, title);
5793
+ const branch = `feature/${slug}`;
5794
+ const requestedBase = input.baseBranch?.trim() || project.mainBranch;
5795
+ const { branchReady, baseBranch } = await ensureFeatureBranch(project, slug, requestedBase);
5796
+ const inserted = ctx.db.insert(features).values({
5797
+ id: newId("feat"),
5798
+ projectId: project.id,
5799
+ slug,
5800
+ title,
5801
+ oneLiner: prose.split(`
5802
+ `)[0].trim(),
5803
+ mapped: false,
5804
+ lap: 1,
5805
+ phase: "implementation",
5806
+ branch,
5807
+ baseBranch,
5808
+ status: "active",
5809
+ createdAt: Date.now()
5810
+ }).returning().get();
5811
+ const feature = rowToFeature(inserted);
5812
+ emit(ctx, feature.id, {
5813
+ type: "feature.created",
5814
+ message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
5815
+ data: { slug, branch, baseBranch, branchReady }
5816
+ });
5817
+ scaffoldDocs(ctx, feature, { brief: `# ${title}
5818
+
5819
+ ${prose}
5820
+ ` });
5821
+ const [ticket] = storeTickets(ctx, feature.id, [
5822
+ {
5823
+ title,
5824
+ goal: prose,
5825
+ context: prose,
5826
+ acceptanceCriteria: [prose],
5827
+ seams: [],
5828
+ blockedBy: []
5829
+ }
5830
+ ]);
5831
+ emit(ctx, feature.id, {
5832
+ type: "feature.quick_change",
5833
+ message: `quick change \u2014 born at implementation on lap 1 with one ticket (#${ticket.seq}); no grill session, no spec.md`,
5834
+ ticketId: ticket.id,
5835
+ data: { slug, ticketSeq: ticket.seq, phase: "implementation" }
5836
+ });
5622
5837
  if (branchReady) {
5623
5838
  try {
5624
5839
  await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
@@ -5653,7 +5868,7 @@ function getFeatureFull(ctx, id) {
5653
5868
  };
5654
5869
  }
5655
5870
  function list(ctx, projectId) {
5656
- const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc4(features.createdAt)).all();
5871
+ const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc5(features.createdAt)).all();
5657
5872
  return rows.map((row) => {
5658
5873
  const feature = rowToFeature(row);
5659
5874
  const tickets2 = listByFeature2(ctx, feature.id);
@@ -5689,7 +5904,8 @@ async function burn(ctx, featureId, opts = {}) {
5689
5904
  const feature = getFeatureRow(ctx, featureId);
5690
5905
  const running = hasActiveRun(ctx, featureId);
5691
5906
  let tickets2 = listByFeature2(ctx, featureId);
5692
- const pending = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
5907
+ const lapTickets = tickets2.filter((t) => t.lap === feature.lap);
5908
+ const pending = lapTickets.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
5693
5909
  const restarting = feature.phase === "implementation" && !running;
5694
5910
  const iterating = feature.phase === "review" && !running && pending.length >= 1;
5695
5911
  if (feature.phase !== "tickets" && !restarting && !iterating) {
@@ -5702,8 +5918,8 @@ async function burn(ctx, featureId, opts = {}) {
5702
5918
  why = `feature must be in the tickets phase to burn (currently ${feature.phase})`;
5703
5919
  throw new GateError(why);
5704
5920
  }
5705
- if (tickets2.filter((t) => t.status !== "cancelled").length < 1) {
5706
- throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
5921
+ if (lapTickets.filter((t) => t.status !== "cancelled").length < 1) {
5922
+ throw new GateError(lapTickets.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
5707
5923
  }
5708
5924
  if (restarting) {
5709
5925
  sweepOrphanedBurning(ctx, featureId, "orphaned \u2014 the previous run died while it was burning");
@@ -5727,6 +5943,22 @@ async function burn(ctx, featureId, opts = {}) {
5727
5943
  });
5728
5944
  return { runId };
5729
5945
  }
5946
+ function rethink(ctx, featureId) {
5947
+ const feature = getFeatureRow(ctx, featureId);
5948
+ if (feature.phase !== RETHINK_LOOP_BACK.from) {
5949
+ throw new GateError(`feature must be in the review phase to rethink (currently ${feature.phase})`);
5950
+ }
5951
+ if (hasActiveRun(ctx, featureId)) {
5952
+ throw new GateError("a run is burning this feature \u2014 cancel or wait for it before rethinking");
5953
+ }
5954
+ const live = activeSessionsForFeature(ctx, featureId);
5955
+ if (live.length > 0) {
5956
+ throw new GateError(`a ${live[0].kind} session is already live for ${feature.slug} \u2014 only one terminal per feature; end or resume it first`);
5957
+ }
5958
+ const lap = feature.lap + 1;
5959
+ ctx.db.update(features).set({ lap }).where(eq9(features.id, featureId)).run();
5960
+ return setPhase(ctx, featureId, RETHINK_LOOP_BACK.to, "lap.started", `rethink \u2014 lap ${lap}`);
5961
+ }
5730
5962
  async function retryTicket(ctx, ticketId, opts = {}) {
5731
5963
  const ticket = getTicket(ctx, ticketId);
5732
5964
  const feature = getFeatureRow(ctx, ticket.featureId);
@@ -5908,6 +6140,7 @@ var init_features = __esm(() => {
5908
6140
  init_git();
5909
6141
  init_knowledge();
5910
6142
  init_repo();
6143
+ init_sessions();
5911
6144
  init_end_session();
5912
6145
  init_tickets();
5913
6146
  init_waypoints();
@@ -5937,7 +6170,8 @@ function resolveSession(ctx, sessionId) {
5937
6170
  }
5938
6171
  function requireFeatureId(session) {
5939
6172
  if (!session.featureId) {
5940
- throw new GateError(`this tool needs a feature, and a ${session.kind} session is project-scoped. ` + "Use record_finding to store what you establish about the project.");
6173
+ const instead = session.kind === "project" ? "Your tools are create_feature, get_project_context, get_work_record and record_event \u2014 " + "to work an existing feature, tell the human to open its terminal." : "Use record_finding to store what you establish about the project.";
6174
+ throw new GateError(`this tool belongs to a feature, and a ${session.kind} session is project-scoped. ${instead}`);
5941
6175
  }
5942
6176
  return session.featureId;
5943
6177
  }
@@ -5953,6 +6187,7 @@ function toolGetFeatureContext(ctx, session) {
5953
6187
  const context = {
5954
6188
  feature,
5955
6189
  phase: feature.phase,
6190
+ lap: feature.lap,
5956
6191
  docs,
5957
6192
  tickets: listByFeature2(ctx, feature.id)
5958
6193
  };
@@ -5963,7 +6198,8 @@ function toolGetFeatureContext(ctx, session) {
5963
6198
  }
5964
6199
  return context;
5965
6200
  }
5966
- function toolResolveWaypoint(ctx, _session, input) {
6201
+ function toolResolveWaypoint(ctx, session, input) {
6202
+ requireFeatureId(session);
5967
6203
  resolve3(ctx, input.id, input.disposition, input.summary);
5968
6204
  return { ok: true };
5969
6205
  }
@@ -5973,8 +6209,9 @@ function toolEmitTickets(ctx, session, input) {
5973
6209
  return { stored: stored.length, ids: stored.map((t) => t.id) };
5974
6210
  }
5975
6211
  function requireOwnTicket(ctx, session, ticketId) {
6212
+ const featureId = requireFeatureId(session);
5976
6213
  const ticket = getTicket(ctx, ticketId);
5977
- if (ticket.featureId !== requireFeatureId(session)) {
6214
+ if (ticket.featureId !== featureId) {
5978
6215
  throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
5979
6216
  }
5980
6217
  return ticket;
@@ -6000,11 +6237,14 @@ function toolEmitWaypoints(ctx, session, input) {
6000
6237
  return { stored: stored.length, ids: stored.map((w) => w.id) };
6001
6238
  }
6002
6239
  function toolRecordEvent(ctx, session, input) {
6003
- emit(ctx, requireFeatureId(session), {
6240
+ const event = emitForSession(ctx, session, {
6004
6241
  type: input.type,
6005
6242
  message: input.message,
6006
6243
  data: { source: "mcp" }
6007
6244
  });
6245
+ if (!event) {
6246
+ throw new GateError(`session ${session.id} belongs to neither a feature nor a project \u2014 nothing to record against`);
6247
+ }
6008
6248
  return { ok: true };
6009
6249
  }
6010
6250
  function toolCompletePhase(ctx, session, input) {
@@ -6046,7 +6286,7 @@ async function commitDocsCheckpoint(ctx, session, message) {
6046
6286
  }
6047
6287
  function requireProject(ctx, session) {
6048
6288
  if (!session.projectId) {
6049
- throw new GateError(`record_finding is for a project-scoped preparation session; this is a ${session.kind} session.`);
6289
+ throw new GateError(`this tool belongs to a project-scoped session; this is a ${session.kind} session on a ` + "feature. Use get_feature_context and the pipeline tools for feature work.");
6050
6290
  }
6051
6291
  const project = getProjectById(ctx, session.projectId);
6052
6292
  if (!project)
@@ -6077,6 +6317,85 @@ function toolRecordFinding(ctx, session, input) {
6077
6317
  });
6078
6318
  return { ok: true, key: input.key, source };
6079
6319
  }
6320
+ async function toolCreateFeature(ctx, session, input) {
6321
+ const project = requireProject(ctx, session);
6322
+ const feature = input.ticket ? await quickChange(ctx, {
6323
+ projectId: project.id,
6324
+ title: input.title,
6325
+ prose: input.ticket.prose,
6326
+ baseBranch: input.baseBranch
6327
+ }) : await createFeature(ctx, {
6328
+ projectId: project.id,
6329
+ title: input.title,
6330
+ oneLiner: input.oneLiner,
6331
+ baseBranch: input.baseBranch,
6332
+ brief: input.brief
6333
+ });
6334
+ return {
6335
+ id: feature.id,
6336
+ slug: feature.slug,
6337
+ branch: feature.branch,
6338
+ phase: feature.phase
6339
+ };
6340
+ }
6341
+ function toolGetProjectContext(ctx, session) {
6342
+ const project = requireProject(ctx, session);
6343
+ const charter = readCharter(session.worktreePath);
6344
+ return {
6345
+ project,
6346
+ ...charter !== undefined ? { charter } : {},
6347
+ adrs: listLiveAdrs(session.worktreePath),
6348
+ featureIndex: list(ctx, project.id).map(featureIndexLine)
6349
+ };
6350
+ }
6351
+ function featureIndexLine(feature) {
6352
+ if (feature.status === "shipped") {
6353
+ return `${feature.slug} \u2014 ${feature.oneLiner} [shipped] ${featureDocsRel(feature.slug)}/`;
6354
+ }
6355
+ return `${feature.title} [${feature.status === "archived" ? "archived" : "in flight"}]`;
6356
+ }
6357
+ function toolGetWorkRecord(ctx, session, input) {
6358
+ const project = requireProject(ctx, session);
6359
+ const slug = input.featureSlug?.trim();
6360
+ const seam = input.seam?.trim().toLowerCase();
6361
+ if (!slug && !seam) {
6362
+ throw new InvalidInputError("get_work_record needs a featureSlug or a seam to look up");
6363
+ }
6364
+ const records = [];
6365
+ for (const feature of list(ctx, project.id)) {
6366
+ if (slug && feature.slug !== slug)
6367
+ continue;
6368
+ let tickets2 = listByFeature2(ctx, feature.id);
6369
+ if (seam) {
6370
+ tickets2 = tickets2.filter((t) => t.seams.some((s) => s.toLowerCase().includes(seam)));
6371
+ if (tickets2.length === 0)
6372
+ continue;
6373
+ }
6374
+ const shippedAt = latestEventTs(ctx, feature.id, "feature.shipped");
6375
+ records.push({
6376
+ slug: feature.slug,
6377
+ title: feature.title,
6378
+ status: feature.status,
6379
+ ...shippedAt !== undefined ? { shippedAt } : {},
6380
+ runs: listRunsByFeature(ctx, feature.id).map((r) => ({
6381
+ workflow: r.workflow,
6382
+ status: r.status,
6383
+ startedAt: r.startedAt,
6384
+ ...r.endedAt !== undefined ? { endedAt: r.endedAt } : {},
6385
+ ...r.summary !== undefined ? { summary: r.summary } : {}
6386
+ })),
6387
+ tickets: tickets2.map((t) => ({
6388
+ seq: t.seq,
6389
+ title: t.title,
6390
+ status: t.status,
6391
+ seams: t.seams,
6392
+ commits: t.commits,
6393
+ ...t.error !== undefined ? { error: t.error } : {}
6394
+ }))
6395
+ });
6396
+ }
6397
+ return { features: records };
6398
+ }
6080
6399
  function ok(result) {
6081
6400
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
6082
6401
  }
@@ -6113,6 +6432,42 @@ function buildMcpServer() {
6113
6432
  return noSession();
6114
6433
  return ok(toolRecordFinding(rs.ctx, rs.session, args));
6115
6434
  });
6435
+ server.registerTool("create_feature", {
6436
+ title: "Create a feature",
6437
+ description: "Create a feature from the project session \u2014 the end of intake. Pass `brief` with the " + "reasoning you just worked out with the human (why this feature exists, what it must NOT " + "swallow): it becomes brief.md verbatim, and without it that reasoning is lost. Pass " + "`ticket: { prose }` for a quick change \u2014 work too small to deserve a grill \u2014 which " + "creates the feature at the implementation phase with that one ticket, and whose brief.md " + "is the prose itself (so `brief` is unused there). This does NOT open " + "a terminal on what it creates; the new card in the rail is the feedback, and the human " + "decides what to work on next.",
6438
+ inputSchema: {
6439
+ title: z4.string().min(1),
6440
+ oneLiner: z4.string(),
6441
+ baseBranch: z4.string().optional(),
6442
+ brief: z4.string().optional(),
6443
+ ticket: z4.object({ prose: z4.string().min(1) }).optional()
6444
+ }
6445
+ }, async (args, extra) => {
6446
+ const rs = await resolveCtxSession(extra);
6447
+ if (!rs)
6448
+ return noSession();
6449
+ return ok(await toolCreateFeature(rs.ctx, rs.session, args));
6450
+ });
6451
+ server.registerTool("get_project_context", {
6452
+ title: "Get project context",
6453
+ description: "The project as it stands: the project row, the charter (CONTEXT.md) in full, every live " + "ADR in full (superseded ones are omitted), and a one-line index of every feature \u2014 " + "shipped ones with their one-liner and docs path, in-flight ones by title only. Read a " + "shipped feature's own docs with your ordinary file tools; the index says where they are.",
6454
+ inputSchema: {}
6455
+ }, async (_args, extra) => {
6456
+ const rs = await resolveCtxSession(extra);
6457
+ if (!rs)
6458
+ return noSession();
6459
+ return ok(toolGetProjectContext(rs.ctx, rs.session));
6460
+ });
6461
+ server.registerTool("get_work_record", {
6462
+ title: "Get work record",
6463
+ description: "What features actually DID \u2014 facts only: per feature its status, ship date, run " + "summaries and tickets as { seq, title, status, seams, commits, error? }. Never a " + "ticket's goal or acceptance criteria: those are intent from before the code existed. " + 'Query by `featureSlug` ("what did X do?") or by `seam` ("who has touched this area, and ' + 'what happened?") \u2014 the seam match is a case-insensitive substring, because seams are ' + "free prose that differs between features. At least one argument is required.",
6464
+ inputSchema: { featureSlug: z4.string().optional(), seam: z4.string().optional() }
6465
+ }, async (args, extra) => {
6466
+ const rs = await resolveCtxSession(extra);
6467
+ if (!rs)
6468
+ return noSession();
6469
+ return ok(toolGetWorkRecord(rs.ctx, rs.session, args));
6470
+ });
6116
6471
  server.registerTool("get_feature_context", {
6117
6472
  title: "Get feature context",
6118
6473
  description: "Full context for the current feature: the feature row, its phase, all docs/features/<slug>/*.md contents, and its tickets.",
@@ -6204,7 +6559,7 @@ function buildMcpServer() {
6204
6559
  });
6205
6560
  server.registerTool("record_event", {
6206
6561
  title: "Record event",
6207
- description: "Add a note to the feature timeline (decisions recorded, spec saved, milestones).",
6562
+ description: "Add a note to the timeline (decisions recorded, spec saved, milestones) \u2014 the feature's " + "timeline from a feature session, the project's from a project-scoped one.",
6208
6563
  inputSchema: { type: z4.string(), message: z4.string() }
6209
6564
  }, async (args, extra) => {
6210
6565
  const rs = await resolveCtxSession(extra);
@@ -6231,6 +6586,7 @@ function buildMcpServer() {
6231
6586
  var mcp, server_default;
6232
6587
  var init_server = __esm(() => {
6233
6588
  init_src();
6589
+ init_paths();
6234
6590
  init_errors();
6235
6591
  init_sessions();
6236
6592
  init_features();
@@ -6930,14 +7286,72 @@ function renderPreparePrompt(brief) {
6930
7286
  ].join(`
6931
7287
  `);
6932
7288
  }
6933
- function renderSettings(hookClient) {
7289
+ function renderProjectPrompt(brief) {
7290
+ const { project, branch, worktreePath } = brief;
7291
+ return [
7292
+ `# runcastle \u2014 ${project.name} (project session)`,
7293
+ "",
7294
+ "This is the **project session**: a conversation that belongs to the project, not to",
7295
+ "any one feature. There is no phase to advance and no gate to cross here.",
7296
+ "",
7297
+ "## What this session is for",
7298
+ "- **Intake and decomposition** \u2014 the job no other surface can do. Take whatever the",
7299
+ " human brings, grill it until it resolves into N features, and create them with",
7300
+ " `create_feature`. A feature born here carries a real brief: the reasoning you just",
7301
+ " worked out, not a restated one-liner.",
7302
+ '- **Portfolio Q&A** \u2014 "have we already decided X?", "did we ever build Y?" \u2014 the same',
7303
+ " lookup intake needs anyway to avoid creating a duplicate feature.",
7304
+ "- **Routing** \u2014 an incoming thing is one of exactly five destinations: a new feature,",
7305
+ " a quick change, an existing feature's revisit, a Rethink lap on something in",
7306
+ " review, or nothing. Say which, and why.",
7307
+ "- **Curation, advisory only** \u2014 you may report that two in-flight features are on a",
7308
+ " collision course or that an ADR looks stale. You do NOT fix either. Every fix routes",
7309
+ " back through a feature, through promotion at merge, or through the charter.",
7310
+ "- **The charter (`CONTEXT.md`)** \u2014 you are the only session in runcastle allowed to",
7311
+ " write it. Create it lazily: when there is first something to write, not before.",
7312
+ "",
7313
+ "## Where you are working \u2014 and where your writes go",
7314
+ `- Working directory: \`${worktreePath}\` \u2014 a runcastle-owned worktree, NOT the human's`,
7315
+ ` checkout at \`${project.repoPath}\`.`,
7316
+ `- Branch: \`${branch}\`, cut from \`${project.mainBranch}\` when this session launched.`,
7317
+ `- **Your commits land on \`${project.mainBranch}\` when this terminal closes**, arriving`,
7318
+ " in the human's checkout the way a `git pull` does. Write real code and real docs \u2014",
7319
+ " there is no sandbox here \u2014 and commit everything you write before you finish.",
7320
+ " A session that edits without committing leaves a dirty tree, which is exactly what",
7321
+ " blocks a test drive and jams the next merge. Land what you wrote; leave it clean.",
7322
+ "",
7323
+ "## runcastle MCP tools",
7324
+ "Four, and none of the feature pipeline's \u2014 a session with no feature has no business",
7325
+ "advancing one through a gate:",
7326
+ "- `create_feature({ title, oneLiner, baseBranch?, brief?, ticket? })` \u2014 the point of",
7327
+ " this session. It does NOT open a terminal on what it creates; the new card appearing",
7328
+ " in the rail is the feedback, and the human decides what to work on next.",
7329
+ "- `get_project_context()` \u2014 the project, its charter, its live ADRs, and a one-line",
7330
+ " index of every feature.",
7331
+ "- `get_work_record({ featureSlug? | seam? })` \u2014 what features actually did: tickets by",
7332
+ " status, seams, commits, errors. Facts, never intent.",
7333
+ "- `record_event({ type, message })` \u2014 drop a note on the project timeline.",
7334
+ "",
7335
+ "Every merged feature's docs are already on disk in this worktree \u2014 read them with your",
7336
+ "ordinary file tools. The index says where.",
7337
+ "",
7338
+ "## Your task",
7339
+ "Invoke the `/runcastle:project` skill and drive the project session.",
7340
+ ""
7341
+ ].join(`
7342
+ `);
7343
+ }
7344
+ function sessionBashAllowRules(kind) {
7345
+ return kind === "project" ? SESSION_BASH_READ_RULES : SESSION_BASH_ALLOW_RULES;
7346
+ }
7347
+ function renderSettings(hookClient, kind) {
6934
7348
  const cmd = (event) => ({
6935
7349
  type: "command",
6936
7350
  command: `bun run "${hookClient}" ${event}`,
6937
7351
  timeout: event === "user-prompt" ? 5 : 10
6938
7352
  });
6939
7353
  return {
6940
- permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...SESSION_BASH_ALLOW_RULES] },
7354
+ permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...sessionBashAllowRules(kind)] },
6941
7355
  hooks: {
6942
7356
  SessionStart: SESSION_START_SOURCES.map((source) => ({
6943
7357
  matcher: source,
@@ -6960,21 +7374,21 @@ function renderMcpConfig(session, config2) {
6960
7374
  };
6961
7375
  }
6962
7376
  async function writeSessionArtifacts(input) {
6963
- const { session, feature, config: config2, waypoint, prepare } = input;
7377
+ const { session, feature, config: config2, waypoint, prepare, projectBrief } = input;
6964
7378
  const dir = sessionDir(session.id);
6965
7379
  mkdirSync7(dir, { recursive: true });
6966
7380
  const systemPromptPath = join13(dir, "system-prompt.md");
6967
7381
  const settingsPath = join13(dir, "settings.json");
6968
7382
  const mcpConfigPath = join13(dir, "mcp.json");
6969
- const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : (() => {
6970
- throw new Error(`session ${session.id} has neither a feature nor a preparation brief`);
7383
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
7384
+ throw new Error(`session ${session.id} has no feature and no project-session brief`);
6971
7385
  })();
6972
7386
  writeFileSync2(systemPromptPath, systemPrompt, "utf8");
6973
- writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
7387
+ writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
6974
7388
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
6975
7389
  return { systemPromptPath, settingsPath, mcpConfigPath };
6976
7390
  }
6977
- var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
7391
+ var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_READ_RULES, SESSION_BASH_WRITE_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
6978
7392
  var init_artifacts = __esm(() => {
6979
7393
  init_paths();
6980
7394
  init_asset_paths();
@@ -6988,18 +7402,27 @@ var init_artifacts = __esm(() => {
6988
7402
  "mcp__runcastle__escalate_to_map",
6989
7403
  "mcp__runcastle__emit_waypoints",
6990
7404
  "mcp__runcastle__resolve_waypoint",
6991
- "mcp__runcastle__record_finding"
7405
+ "mcp__runcastle__record_finding",
7406
+ "mcp__runcastle__create_feature",
7407
+ "mcp__runcastle__get_project_context",
7408
+ "mcp__runcastle__get_work_record"
6992
7409
  ];
6993
- SESSION_BASH_ALLOW_RULES = [
7410
+ SESSION_BASH_READ_RULES = [
6994
7411
  "Bash(git status:*)",
6995
7412
  "Bash(git rev-parse:*)",
6996
7413
  "Bash(git log:*)",
6997
7414
  "Bash(git diff:*)",
6998
- "Bash(git add:*)",
6999
- "Bash(git commit:*)",
7000
7415
  "Bash(git branch:*)",
7001
7416
  "Bash(git show:*)"
7002
7417
  ];
7418
+ SESSION_BASH_WRITE_RULES = [
7419
+ "Bash(git add:*)",
7420
+ "Bash(git commit:*)"
7421
+ ];
7422
+ SESSION_BASH_ALLOW_RULES = [
7423
+ ...SESSION_BASH_READ_RULES,
7424
+ ...SESSION_BASH_WRITE_RULES
7425
+ ];
7003
7426
  SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
7004
7427
  });
7005
7428
 
@@ -7283,6 +7706,69 @@ async function launchPrepareSession(ctx, input, opts = {}) {
7283
7706
  spawnEmbeddedPty(ctx, undefined, session, project.repoPath, serverUrl, buildClaudeArgs(buildInput), { resumeSessionId });
7284
7707
  return { sessionId: session.id };
7285
7708
  }
7709
+ async function launchProjectSession(ctx, input, opts = {}) {
7710
+ const project = requireProjectById(ctx, input.projectId);
7711
+ const live = activeProjectSession(ctx, project.id, "project");
7712
+ if (live) {
7713
+ throw new GateError(`a project session is already open for ${project.name} \u2014 resume or end it first`);
7714
+ }
7715
+ const worktreePath = await ensureProjectWorktree(project);
7716
+ const session = createSessionRow(ctx, {
7717
+ projectId: project.id,
7718
+ kind: "project",
7719
+ worktreePath
7720
+ });
7721
+ const resumeSessionId = mostRecentResumableProjectSession(ctx, project.id, "project")?.ccSessionId;
7722
+ emitProject(ctx, project.id, {
7723
+ type: "session.launching",
7724
+ message: "launching project session",
7725
+ data: { sessionId: session.id, kind: "project", worktreePath, branch: PROJECT_BRANCH }
7726
+ });
7727
+ if (resumeSessionId) {
7728
+ emitProject(ctx, project.id, {
7729
+ type: "session.resumed",
7730
+ message: "resuming the previous project conversation",
7731
+ data: { sessionId: session.id, resumeSessionId }
7732
+ });
7733
+ }
7734
+ const artifacts = await writeSessionArtifacts({
7735
+ session,
7736
+ project,
7737
+ config: ctx.config,
7738
+ projectBrief: { project, branch: PROJECT_BRANCH, worktreePath }
7739
+ });
7740
+ const serverUrl = serverUrlFor(ctx.config);
7741
+ const buildInput = {
7742
+ sessionId: session.id,
7743
+ serverUrl,
7744
+ featureTitle: project.name,
7745
+ worktreePath,
7746
+ pluginDir: resolvePluginDir(),
7747
+ settingsPath: artifacts.settingsPath,
7748
+ mcpConfigPath: artifacts.mcpConfigPath,
7749
+ systemPromptPath: artifacts.systemPromptPath,
7750
+ permissionMode: "default",
7751
+ model: resolveModel("project", ctx.config, project),
7752
+ resumeSessionId,
7753
+ strictMcp: ctx.config.sessionMcp === "runcastleOnly"
7754
+ };
7755
+ if (opts.spawn === false) {
7756
+ emitProject(ctx, project.id, {
7757
+ type: "session.launched",
7758
+ message: "project session prepared (terminal spawn skipped)",
7759
+ data: {
7760
+ sessionId: session.id,
7761
+ command: ["claude", ...buildClaudeArgs(buildInput)].join(" "),
7762
+ spawned: false
7763
+ }
7764
+ });
7765
+ return { sessionId: session.id };
7766
+ }
7767
+ spawnEmbeddedPty(ctx, undefined, session, worktreePath, serverUrl, buildClaudeArgs(buildInput), {
7768
+ resumeSessionId
7769
+ });
7770
+ return { sessionId: session.id };
7771
+ }
7286
7772
  async function buildPrepareBrief(ctx, project) {
7287
7773
  const findings = await listFindings(ctx, project);
7288
7774
  const notes = latestPrepNotes(ctx, project.id);
@@ -7368,6 +7854,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
7368
7854
  const diedBeforeLive = getSessionRow(ctx, session.id)?.status === "launching";
7369
7855
  markSessionEnded(ctx, session.id);
7370
7856
  releaseForSession(ctx, session.id);
7857
+ landProjectSession(ctx, session);
7371
7858
  if (diedBeforeLive && meta.resumeSessionId) {
7372
7859
  const label = meta.waypoint ? `waypoint ${meta.waypoint.seq} (${meta.waypoint.title})` : session.kind;
7373
7860
  emitForSession(ctx, session, {
@@ -7461,6 +7948,7 @@ var gateId, featureRouter;
7461
7948
  var init_feature = __esm(() => {
7462
7949
  init_src();
7463
7950
  init_launcher();
7951
+ init_sessions();
7464
7952
  init_events();
7465
7953
  init_features();
7466
7954
  init_gates();
@@ -7475,6 +7963,12 @@ var init_feature = __esm(() => {
7475
7963
  oneLiner: z7.string(),
7476
7964
  baseBranch: z7.string().optional()
7477
7965
  })).mutation(({ ctx, input }) => createFeature(ctx, input)),
7966
+ quickChange: publicProcedure.input(z7.object({
7967
+ projectId: z7.string(),
7968
+ title: z7.string().min(1),
7969
+ prose: z7.string().min(1),
7970
+ baseBranch: z7.string().optional()
7971
+ })).mutation(({ ctx, input }) => quickChange(ctx, input)),
7478
7972
  list: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
7479
7973
  get: publicProcedure.input(z7.object({ id: z7.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
7480
7974
  launchSession: publicProcedure.input(z7.object({
@@ -7484,6 +7978,14 @@ var init_feature = __esm(() => {
7484
7978
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
7485
7979
  workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string(), endLive: z7.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
7486
7980
  converge: publicProcedure.input(z7.object({ featureId: z7.string(), overrideReason: z7.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
7981
+ rethink: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => {
7982
+ const feature = rethink(ctx, input.featureId);
7983
+ return launchSession(ctx, {
7984
+ featureId: input.featureId,
7985
+ kind: "revisit",
7986
+ kickoffLine: lapKickoff(feature.lap)
7987
+ });
7988
+ }),
7487
7989
  resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
7488
7990
  endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
7489
7991
  advance: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
@@ -7809,6 +8311,8 @@ var init_project = __esm(() => {
7809
8311
  }),
7810
8312
  talkToPrep: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
7811
8313
  prepSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
8314
+ talkToProject: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchProjectSession(ctx, { projectId: input.projectId })),
8315
+ projectSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "project")),
7812
8316
  cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
7813
8317
  cancelPrep(input.projectId);
7814
8318
  return { ok: true };
@@ -7901,26 +8405,26 @@ function projectOverrides(ctx, projectId) {
7901
8405
  driveEnv: row?.driveEnv ?? null
7902
8406
  };
7903
8407
  }
7904
- function resolveField(desc5, layers) {
8408
+ function resolveField(desc6, layers) {
7905
8409
  const scoped = layers.overrides !== null;
7906
- const scope = scoped && desc5.projectColumn ? "project" : "global";
7907
- const base = { key: desc5.key, restartRequired: desc5.restartRequired, scope };
7908
- if (desc5.envVar) {
7909
- const raw = layers.env[desc5.envVar];
8410
+ const scope = scoped && desc6.projectColumn ? "project" : "global";
8411
+ const base = { key: desc6.key, restartRequired: desc6.restartRequired, scope };
8412
+ if (desc6.envVar) {
8413
+ const raw = layers.env[desc6.envVar];
7910
8414
  if (raw !== undefined && raw !== "") {
7911
- return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
8415
+ return { ...base, value: desc6.parseEnv(raw), source: "env", editable: false };
7912
8416
  }
7913
8417
  }
7914
- if (layers.overrides && desc5.projectColumn) {
7915
- const ov = layers.overrides[desc5.projectColumn];
8418
+ if (layers.overrides && desc6.projectColumn) {
8419
+ const ov = layers.overrides[desc6.projectColumn];
7916
8420
  if (ov !== null && ov !== "") {
7917
8421
  return { ...base, value: ov, source: "project", editable: true };
7918
8422
  }
7919
8423
  }
7920
- if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
7921
- return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
8424
+ if (desc6.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc6.configKey)) {
8425
+ return { ...base, value: layers.fileRaw[desc6.configKey], source: "file", editable: true };
7922
8426
  }
7923
- const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
8427
+ const value = desc6.configKey ? DEFAULTS[desc6.configKey] ?? null : null;
7924
8428
  return { ...base, value, source: "default", editable: true };
7925
8429
  }
7926
8430
  function getSettings(ctx, projectId, io = {}) {
@@ -7937,56 +8441,56 @@ function updateSettings(ctx, input, io = {}) {
7937
8441
  if (input.key.startsWith("stepModels.")) {
7938
8442
  return updateStepModel(ctx, input, configFile, io);
7939
8443
  }
7940
- const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
7941
- if (!desc5)
8444
+ const desc6 = DESCRIPTORS.find((d) => d.key === input.key);
8445
+ if (!desc6)
7942
8446
  throw new InvalidInputError(`unknown setting: ${input.key}`);
7943
- if (desc5.envVar) {
7944
- const raw = env[desc5.envVar];
8447
+ if (desc6.envVar) {
8448
+ const raw = env[desc6.envVar];
7945
8449
  if (raw !== undefined && raw !== "") {
7946
- throw new InvalidInputError(`${desc5.key} is set by environment variable ${desc5.envVar}; unset it to edit`);
8450
+ throw new InvalidInputError(`${desc6.key} is set by environment variable ${desc6.envVar}; unset it to edit`);
7947
8451
  }
7948
8452
  }
7949
- const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
8453
+ const toProject = input.projectId !== undefined && desc6.projectColumn !== undefined;
7950
8454
  if (input.value === null) {
7951
8455
  if (!toProject)
7952
- throw new InvalidInputError(`${desc5.key} cannot be cleared`);
8456
+ throw new InvalidInputError(`${desc6.key} cannot be cleared`);
7953
8457
  const project = requireProjectById(ctx, input.projectId);
7954
- ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
7955
- recordHuman(ctx, project.id, desc5.key, null);
8458
+ ctx.db.update(projects).set({ [desc6.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
8459
+ recordHuman(ctx, project.id, desc6.key, null);
7956
8460
  emitProject(ctx, project.id, {
7957
8461
  type: "settings.updated",
7958
- message: `${desc5.key} override cleared`,
7959
- data: { key: desc5.key, scope: "project", value: null }
8462
+ message: `${desc6.key} override cleared`,
8463
+ data: { key: desc6.key, scope: "project", value: null }
7960
8464
  });
7961
- return field(getSettings(ctx, input.projectId, io), desc5.key);
8465
+ return field(getSettings(ctx, input.projectId, io), desc6.key);
7962
8466
  }
7963
- const parsed = desc5.valueSchema.safeParse(input.value);
8467
+ const parsed = desc6.valueSchema.safeParse(input.value);
7964
8468
  if (!parsed.success) {
7965
- throw new InvalidInputError(`invalid value for ${desc5.key}: ${parsed.error.issues[0]?.message}`);
8469
+ throw new InvalidInputError(`invalid value for ${desc6.key}: ${parsed.error.issues[0]?.message}`);
7966
8470
  }
7967
8471
  const value = parsed.data;
7968
8472
  if (toProject) {
7969
8473
  const project = requireProjectById(ctx, input.projectId);
7970
- ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
7971
- recordHuman(ctx, project.id, desc5.key, String(value));
8474
+ ctx.db.update(projects).set({ [desc6.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
8475
+ recordHuman(ctx, project.id, desc6.key, String(value));
7972
8476
  emitProject(ctx, project.id, {
7973
8477
  type: "settings.updated",
7974
- message: `${desc5.key} override set to ${String(value)}`,
7975
- data: { key: desc5.key, scope: "project", value }
8478
+ message: `${desc6.key} override set to ${String(value)}`,
8479
+ data: { key: desc6.key, scope: "project", value }
7976
8480
  });
7977
- return field(getSettings(ctx, input.projectId, io), desc5.key);
8481
+ return field(getSettings(ctx, input.projectId, io), desc6.key);
7978
8482
  }
7979
- if (!desc5.configKey) {
7980
- throw new InvalidInputError(`${desc5.key} is a per-project setting; provide a projectId`);
8483
+ if (!desc6.configKey) {
8484
+ throw new InvalidInputError(`${desc6.key} is a per-project setting; provide a projectId`);
7981
8485
  }
7982
- writeGlobal(configFile, desc5.configKey, value);
7983
- ctx.config[desc5.configKey] = value;
8486
+ writeGlobal(configFile, desc6.configKey, value);
8487
+ ctx.config[desc6.configKey] = value;
7984
8488
  emitProject(ctx, GLOBAL_EVENT_KEY, {
7985
8489
  type: "settings.updated",
7986
- message: `${desc5.key} set to ${String(value)}`,
7987
- data: { key: desc5.key, scope: "global", value }
8490
+ message: `${desc6.key} set to ${String(value)}`,
8491
+ data: { key: desc6.key, scope: "global", value }
7988
8492
  });
7989
- return field(getSettings(ctx, input.projectId, io), desc5.key);
8493
+ return field(getSettings(ctx, input.projectId, io), desc6.key);
7990
8494
  }
7991
8495
  function writeGlobal(configFile, configKey, value) {
7992
8496
  const raw = readRawConfig(configFile);
@@ -8764,7 +9268,17 @@ var init_ticket = __esm(() => {
8764
9268
  }
8765
9269
  return { stopped: false, swept: false };
8766
9270
  }),
8767
- cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
9271
+ cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
9272
+ edit: publicProcedure.input(z13.object({
9273
+ ticketId: z13.string(),
9274
+ title: z13.string().min(1).optional(),
9275
+ goal: z13.string().min(1).optional(),
9276
+ context: z13.string().optional(),
9277
+ acceptanceCriteria: z13.array(z13.string().min(1)).optional()
9278
+ })).mutation(({ ctx, input }) => {
9279
+ const { ticketId, ...patch } = input;
9280
+ return editTicket(ctx, ticketId, patch);
9281
+ })
8768
9282
  });
8769
9283
  });
8770
9284