runcastle 1.0.27 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -31,7 +31,8 @@ __export(exports_paths, {
31
31
  dbPath: () => dbPath,
32
32
  dataDir: () => dataDir,
33
33
  configPath: () => configPath,
34
- burnCacheDir: () => burnCacheDir
34
+ burnCacheDir: () => burnCacheDir,
35
+ PROJECT_WORKTREE_SLUG: () => PROJECT_WORKTREE_SLUG
35
36
  });
36
37
  import { homedir } from "os";
37
38
  import { join, resolve } from "path";
@@ -83,6 +84,7 @@ function sandboxBuildDir() {
83
84
  function featureDocsRel(slug) {
84
85
  return `docs/features/${slug}`;
85
86
  }
87
+ var PROJECT_WORKTREE_SLUG = "__project";
86
88
  var init_paths = () => {};
87
89
 
88
90
  // ../core/src/config.ts
@@ -130,6 +132,7 @@ var init_config = __esm(() => {
130
132
  "research",
131
133
  "implement",
132
134
  "prepare",
135
+ "project",
133
136
  "smoke"
134
137
  ];
135
138
  ModelStep = z.enum(MODEL_STEPS);
@@ -254,7 +257,15 @@ var init_schemas = __esm(() => {
254
257
  "shipped"
255
258
  ]);
256
259
  TicketStatus = z2.enum(["pending", "burning", "done", "failed", "cancelled"]);
257
- SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit", "prepare"]);
260
+ SessionKind = z2.enum([
261
+ "ideation",
262
+ "qa",
263
+ "waypoint",
264
+ "converge",
265
+ "revisit",
266
+ "prepare",
267
+ "project"
268
+ ]);
258
269
  RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
259
270
  FeatureStatus = z2.enum(["active", "shipped", "archived"]);
260
271
  SessionStatus = z2.enum(["launching", "live", "ended"]);
@@ -1369,7 +1380,7 @@ var init_bus = __esm(() => {
1369
1380
  });
1370
1381
 
1371
1382
  // src/services/events.ts
1372
- import { and, asc, eq, gt } from "drizzle-orm";
1383
+ import { and, asc, desc, eq, gt } from "drizzle-orm";
1373
1384
  function rowToEvent(row) {
1374
1385
  return {
1375
1386
  id: row.id,
@@ -1431,6 +1442,10 @@ function listAfter(ctx, featureId, afterId = 0) {
1431
1442
  const rows = ctx.db.select().from(events).where(and(eq(events.featureId, featureId), gt(events.id, afterId))).orderBy(asc(events.id)).all();
1432
1443
  return rows.map(rowToEvent);
1433
1444
  }
1445
+ function latestEventTs(ctx, featureId, type) {
1446
+ 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();
1447
+ return row?.ts;
1448
+ }
1434
1449
  function listByProject(ctx, projectId, afterId = 0) {
1435
1450
  const rows = ctx.db.select().from(events).where(and(eq(events.projectId, projectId), gt(events.id, afterId))).orderBy(asc(events.id)).all();
1436
1451
  return rows.map(rowToEvent);
@@ -1442,7 +1457,7 @@ var init_events = __esm(() => {
1442
1457
  });
1443
1458
 
1444
1459
  // src/services/repo.ts
1445
- import { and as and2, desc, eq as eq2 } from "drizzle-orm";
1460
+ import { and as and2, desc as desc2, eq as eq2 } from "drizzle-orm";
1446
1461
  function rowToFeature(row) {
1447
1462
  return {
1448
1463
  id: row.id,
@@ -1543,7 +1558,7 @@ function getRunRow(ctx, id) {
1543
1558
  return rowToRun(row);
1544
1559
  }
1545
1560
  function listRunsByFeature(ctx, featureId) {
1546
- return ctx.db.select().from(runs).where(eq2(runs.featureId, featureId)).orderBy(desc(runs.startedAt)).all().map(rowToRun);
1561
+ return ctx.db.select().from(runs).where(eq2(runs.featureId, featureId)).orderBy(desc2(runs.startedAt)).all().map(rowToRun);
1547
1562
  }
1548
1563
  function listSessionsByFeature(ctx, featureId) {
1549
1564
  return ctx.db.select().from(sessions).where(eq2(sessions.featureId, featureId)).all().map(rowToSession);
@@ -1746,710 +1761,423 @@ var init_waypoints = __esm(() => {
1746
1761
  TERMINAL = new Set(["resolved", "dropped"]);
1747
1762
  });
1748
1763
 
1749
- // src/launcher/sessions.ts
1750
- import { and as and4, desc as desc2, eq as eq4, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
1751
- function createSessionRow(ctx, input) {
1752
- const row = ctx.db.insert(sessions).values({
1753
- id: newId("sess"),
1754
- featureId: input.featureId ?? null,
1755
- projectId: input.projectId ?? null,
1756
- lap: input.featureId ? getFeatureRow(ctx, input.featureId).lap : 1,
1757
- kind: input.kind,
1758
- ccSessionId: null,
1759
- transcriptPath: null,
1760
- status: "launching",
1761
- worktreePath: input.worktreePath
1762
- }).returning().get();
1763
- return rowToSession(row);
1764
- }
1765
- function getSessionRow(ctx, id) {
1766
- const row = ctx.db.select().from(sessions).where(eq4(sessions.id, id)).get();
1767
- return row ? rowToSession(row) : null;
1764
+ // src/pty/dev-pane.ts
1765
+ function drivePaneId(featureId) {
1766
+ return `${DRIVE_PREFIX}${featureId}`;
1768
1767
  }
1769
- function activeSessionsForFeature(ctx, featureId) {
1770
- return ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), inArray(sessions.status, ["launching", "live"]))).all().map(rowToSession);
1768
+ function sniffDevUrl(text2) {
1769
+ const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
1770
+ return m ? m[0] : undefined;
1771
1771
  }
1772
- function markSessionLive(ctx, id, input = {}) {
1773
- const existing = getSessionRow(ctx, id);
1774
- if (!existing)
1775
- return null;
1776
- ctx.db.update(sessions).set({
1777
- status: "live",
1778
- ccSessionId: input.ccSessionId ?? existing.ccSessionId ?? null,
1779
- transcriptPath: input.transcriptPath ?? existing.transcriptPath ?? null
1780
- }).where(eq4(sessions.id, id)).run();
1781
- const firstTimeLive = existing.status !== "live";
1782
- if (firstTimeLive) {
1783
- promoteLastSession(ctx, id);
1784
- scheduleKickoff(ctx, existing);
1772
+ function devSpawnTarget(devCommand) {
1773
+ if (process.platform === "win32") {
1774
+ return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
1785
1775
  }
1786
- return getSessionRow(ctx, id);
1776
+ return { file: "/bin/sh", args: ["-c", devCommand] };
1787
1777
  }
1788
- function lapKickoff(lap) {
1789
- 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.";
1778
+ function startDevPane(input) {
1779
+ const { ctx, featureId, repoPath, devCommand, env, onUrl } = input;
1780
+ const paneId = drivePaneId(featureId);
1781
+ const { file, args } = devSpawnTarget(devCommand);
1782
+ try {
1783
+ ptyRegistry().create({
1784
+ sessionId: paneId,
1785
+ cmd: file,
1786
+ args,
1787
+ opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
1788
+ });
1789
+ } catch (err) {
1790
+ emit(ctx, featureId, {
1791
+ type: "testdrive.dev_failed",
1792
+ message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
1793
+ data: { paneId }
1794
+ });
1795
+ return;
1796
+ }
1797
+ let scanned = "";
1798
+ let found = false;
1799
+ const sniffer = {
1800
+ sendData(chunk) {
1801
+ if (found)
1802
+ return;
1803
+ scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
1804
+ const url = sniffDevUrl(scanned);
1805
+ if (url) {
1806
+ found = true;
1807
+ onUrl(url);
1808
+ }
1809
+ },
1810
+ sendControl() {}
1811
+ };
1812
+ ptyRegistry().attach(paneId, sniffer);
1813
+ emit(ctx, featureId, {
1814
+ type: "testdrive.dev_started",
1815
+ message: `dev server spawned: ${devCommand}`,
1816
+ data: { paneId }
1817
+ });
1818
+ return paneId;
1790
1819
  }
1791
- function kickoffLineFor(kind, override) {
1792
- return override ?? KICKOFF_LINES[kind];
1820
+ function stopDevPane(paneId) {
1821
+ const reg = ptyRegistry();
1822
+ const entry = reg.get(paneId);
1823
+ if (entry && !entry.exited && process.platform !== "win32") {
1824
+ try {
1825
+ process.kill(-entry.pty.pid, "SIGTERM");
1826
+ } catch {}
1827
+ }
1828
+ reg.kill(paneId);
1829
+ reg.remove(paneId);
1793
1830
  }
1794
- function resumeKickoffLine(kind) {
1795
- return RESUME_KICKOFF_PREFIX + KICKOFF_LINES[kind];
1831
+ var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
1832
+ var init_dev_pane = __esm(() => {
1833
+ init_events();
1834
+ init_registry();
1835
+ });
1836
+
1837
+ // src/services/drive-hooks.ts
1838
+ import { spawn as spawn2 } from "child_process";
1839
+ function tailLines(text2, max = OUTPUT_TAIL_LINES) {
1840
+ const lines = text2.replace(/\r\n/g, `
1841
+ `).split(`
1842
+ `);
1843
+ while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
1844
+ lines.pop();
1845
+ return lines.slice(-max).join(`
1846
+ `).trim();
1796
1847
  }
1797
- function setKickoffOverride(sessionId, line) {
1798
- pendingKickoffOverrides.set(sessionId, line);
1848
+ function hookSpawnTarget(command) {
1849
+ if (process.platform === "win32") {
1850
+ return {
1851
+ file: process.env.ComSpec ?? "cmd.exe",
1852
+ args: ["/d", "/s", "/c", `"${command}"`],
1853
+ verbatim: true
1854
+ };
1855
+ }
1856
+ return { file: "/bin/sh", args: ["-c", command], verbatim: false };
1799
1857
  }
1800
- function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS) {
1801
- if (!io.alive())
1802
- return;
1803
- io.write(line);
1804
- const submit = setTimeout(() => {
1805
- try {
1806
- if (!io.alive())
1858
+ function runDriveHook(command, opts) {
1859
+ const spawnFn = opts.spawnFn ?? spawn2;
1860
+ const now = opts.now ?? Date.now;
1861
+ const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
1862
+ const startedAt = now();
1863
+ const { file, args, verbatim } = hookSpawnTarget(command);
1864
+ return new Promise((resolve4) => {
1865
+ let output = "";
1866
+ let timedOut = false;
1867
+ let settled = false;
1868
+ let timer;
1869
+ const finish = (exitCode) => {
1870
+ if (settled)
1807
1871
  return;
1808
- io.write("\r");
1809
- io.onSubmitted?.();
1810
- } catch {}
1811
- }, submitDelayMs);
1812
- submit.unref?.();
1872
+ settled = true;
1873
+ if (timer)
1874
+ clearTimeout(timer);
1875
+ resolve4({
1876
+ ok: !timedOut && exitCode === 0,
1877
+ exitCode,
1878
+ timedOut,
1879
+ output: tailLines(output),
1880
+ durationMs: now() - startedAt
1881
+ });
1882
+ };
1883
+ let child;
1884
+ try {
1885
+ child = spawnFn(file, args, {
1886
+ cwd: opts.cwd,
1887
+ env: opts.env ?? process.env,
1888
+ windowsHide: true,
1889
+ windowsVerbatimArguments: verbatim
1890
+ });
1891
+ } catch (err) {
1892
+ output = err instanceof Error ? err.message : String(err);
1893
+ finish(null);
1894
+ return;
1895
+ }
1896
+ timer = setTimeout(() => {
1897
+ timedOut = true;
1898
+ child.kill();
1899
+ const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
1900
+ grace.unref?.();
1901
+ }, timeoutMs);
1902
+ timer.unref?.();
1903
+ child.stdout?.on("data", (d) => {
1904
+ output += d.toString();
1905
+ });
1906
+ child.stderr?.on("data", (d) => {
1907
+ output += d.toString();
1908
+ });
1909
+ child.on("error", (err) => {
1910
+ output += `
1911
+ ${err instanceof Error ? err.message : String(err)}`;
1912
+ finish(null);
1913
+ });
1914
+ child.on("close", (code) => {
1915
+ finish(code);
1916
+ });
1917
+ });
1813
1918
  }
1814
- function stopTimers(d) {
1815
- for (const t of d.timers)
1816
- clearTimeout(t);
1817
- d.timers.clear();
1919
+ function describeHookResult(command, result) {
1920
+ const secs = Math.max(1, Math.round(result.durationMs / 1000));
1921
+ if (result.timedOut)
1922
+ return `\`${command}\` timed out after ${secs}s`;
1923
+ if (result.ok)
1924
+ return `\`${command}\` finished in ${secs}s`;
1925
+ return `\`${command}\` exited ${result.exitCode ?? "without a code"} after ${secs}s`;
1818
1926
  }
1819
- function forgetKickoff(sessionId) {
1820
- const d = deliveries.get(sessionId);
1821
- if (d)
1822
- stopTimers(d);
1823
- deliveries.delete(sessionId);
1824
- pendingKickoffOverrides.delete(sessionId);
1927
+ var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
1928
+ var init_drive_hooks = __esm(() => {
1929
+ DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
1930
+ });
1931
+
1932
+ // src/services/drive-env.ts
1933
+ import { createHash } from "crypto";
1934
+ function identifierSafe(slug, max = MAX_ID_LENGTH) {
1935
+ const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
1936
+ const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
1937
+ if (safe.length <= max)
1938
+ return safe;
1939
+ const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
1940
+ return `${safe.slice(0, max - digest.length - 1)}_${digest}`;
1825
1941
  }
1826
- function ptyIo(sessionId) {
1942
+ function driveVars(identity) {
1827
1943
  return {
1828
- write: (data) => ptyRegistry().get(sessionId)?.pty.write(data),
1829
- alive: () => {
1830
- const entry = ptyRegistry().get(sessionId);
1831
- return !!entry && !entry.exited;
1832
- }
1944
+ slug: identity.slug,
1945
+ branch: identity.branch,
1946
+ id: identifierSafe(identity.slug)
1833
1947
  };
1834
1948
  }
1835
- function track(d, timer) {
1836
- timer.unref?.();
1837
- d.timers.add(timer);
1838
- }
1839
- function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
1840
- track(d, setTimeout(() => {
1841
- if (d.settled)
1842
- return;
1843
- const io = ptyIo(session.id);
1844
- if (!io.alive()) {
1845
- d.settled = true;
1846
- return;
1847
- }
1848
- d.attempts += 1;
1849
- const attempt = d.attempts;
1850
- try {
1851
- if (clearFirst)
1852
- io.write(CLEAR_INPUT);
1853
- writeKickoffSequence(d.line, {
1854
- write: io.write,
1855
- alive: io.alive,
1856
- onSubmitted: () => emitForSession(ctx, session, {
1857
- type: "session.kickoff",
1858
- message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
1859
- data: { sessionId: session.id, kind: session.kind, attempt }
1860
- })
1861
- });
1862
- } catch {}
1863
- armConfirmation(ctx, session, d);
1864
- }, delayMs));
1865
- }
1866
- function armConfirmation(ctx, session, d) {
1867
- track(d, setTimeout(() => {
1868
- if (d.settled || d.confirmed)
1869
- return;
1870
- if (d.attempts < KICKOFF_MAX_ATTEMPTS && ptyIo(session.id).alive()) {
1871
- attemptKickoff(ctx, session, d, 0, true);
1872
- return;
1949
+ function renderTemplate(value, vars) {
1950
+ const unknown2 = [];
1951
+ const out = value.replace(PLACEHOLDER_RE, (match, name) => {
1952
+ const replacement = vars[name];
1953
+ if (replacement === undefined) {
1954
+ if (!unknown2.includes(name))
1955
+ unknown2.push(name);
1956
+ return match;
1873
1957
  }
1874
- settleUndelivered(ctx, session, d, "unacknowledged");
1875
- }, KICKOFF_CONFIRM_MS));
1876
- }
1877
- function settleUndelivered(ctx, session, d, reason) {
1878
- d.settled = true;
1879
- stopTimers(d);
1880
- emitForSession(ctx, session, {
1881
- type: "session.kickoff_undelivered",
1882
- 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`,
1883
- data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
1958
+ return replacement;
1884
1959
  });
1960
+ return { value: out, unknown: unknown2 };
1885
1961
  }
1886
- function noteKickoffPrompt(ctx, sessionId, prompt) {
1887
- const d = deliveries.get(sessionId);
1888
- if (!d || d.confirmed)
1889
- return;
1890
- const session = getSessionRow(ctx, sessionId);
1891
- if (!session)
1892
- return;
1893
- if (promptMatchesKickoff(d.line, prompt)) {
1894
- d.confirmed = true;
1895
- d.settled = true;
1896
- stopTimers(d);
1897
- return;
1962
+ function parseDriveEnv(raw, identity) {
1963
+ const vars = {};
1964
+ const unknown2 = [];
1965
+ if (!raw?.trim())
1966
+ return { vars, unknown: unknown2 };
1967
+ const substitutions = driveVars(identity);
1968
+ for (const line of raw.split(/\r?\n/)) {
1969
+ const trimmed = line.trim();
1970
+ if (trimmed === "" || trimmed.startsWith("#"))
1971
+ continue;
1972
+ const eq4 = trimmed.indexOf("=");
1973
+ if (eq4 <= 0)
1974
+ continue;
1975
+ const key = trimmed.slice(0, eq4).trim().replace(/^export\s+/, "");
1976
+ if (key === "")
1977
+ continue;
1978
+ const rendered = renderTemplate(unquote(trimmed.slice(eq4 + 1).trim()), substitutions);
1979
+ vars[key] = rendered.value;
1980
+ for (const name of rendered.unknown)
1981
+ if (!unknown2.includes(name))
1982
+ unknown2.push(name);
1898
1983
  }
1899
- if (d.settled)
1900
- return;
1901
- settleUndelivered(ctx, session, d, "superseded");
1902
- }
1903
- function promptMatchesKickoff(line, prompt) {
1904
- if (!prompt)
1905
- return false;
1906
- const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
1907
- const want = norm(line);
1908
- const got = norm(prompt);
1909
- if (!want || !got)
1910
- return false;
1911
- return got.includes(want.slice(0, MATCH_PREFIX));
1984
+ return { vars, unknown: unknown2 };
1912
1985
  }
1913
- function resendKickoff(ctx, sessionId) {
1914
- const session = getSessionRow(ctx, sessionId);
1915
- if (!session)
1916
- throw new Error(`unknown session ${sessionId}`);
1917
- if (session.status === "ended")
1918
- throw new Error("that session has ended \u2014 open a new terminal");
1919
- if (!ptyIo(sessionId).alive())
1920
- throw new Error("the terminal for that session is no longer running");
1921
- const existing = deliveries.get(sessionId);
1922
- const d = existing ?? {
1923
- line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
1924
- attempts: 0,
1925
- confirmed: false,
1926
- settled: false,
1927
- timers: new Set
1928
- };
1929
- stopTimers(d);
1930
- d.attempts = 0;
1931
- d.confirmed = false;
1932
- d.settled = false;
1933
- deliveries.set(sessionId, d);
1934
- attemptKickoff(ctx, session, d, 0, true);
1935
- return { line: d.line };
1986
+ function unquote(value) {
1987
+ if (value.length < 2)
1988
+ return value;
1989
+ const first = value[0];
1990
+ if ((first === '"' || first === "'") && value.endsWith(first))
1991
+ return value.slice(1, -1);
1992
+ return value;
1936
1993
  }
1937
- function armSessionReadyWatchdog(ctx, session) {
1938
- const timer = setTimeout(() => {
1939
- const row = getSessionRow(ctx, session.id);
1940
- if (!row || row.status !== "launching")
1941
- return;
1942
- if (!ptyIo(session.id).alive())
1943
- return;
1944
- emitForSession(ctx, session, {
1945
- type: "session.not_ready",
1946
- 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`,
1947
- data: { sessionId: session.id, kind: session.kind }
1948
- });
1949
- }, SESSION_READY_TIMEOUT_MS);
1950
- timer.unref?.();
1994
+ function driveProcessEnv(overrides, base = process.env) {
1995
+ return { ...base, ...overrides };
1951
1996
  }
1952
- function scheduleKickoff(ctx, session) {
1953
- const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
1954
- pendingKickoffOverrides.delete(session.id);
1955
- const existing = deliveries.get(session.id);
1956
- if (existing)
1957
- stopTimers(existing);
1958
- const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
1959
- deliveries.set(session.id, d);
1960
- attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
1997
+ function describeDriveEnv(vars) {
1998
+ const keys = Object.keys(vars);
1999
+ return `drive environment: ${keys.join(", ")}`;
1961
2000
  }
1962
- function markSessionEnded(ctx, id) {
1963
- const existing = getSessionRow(ctx, id);
1964
- if (!existing)
1965
- return null;
1966
- forgetKickoff(id);
1967
- ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
1968
- return getSessionRow(ctx, id);
2001
+ var MAX_ID_LENGTH = 40, PLACEHOLDER_RE;
2002
+ var init_drive_env = __esm(() => {
2003
+ PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
2004
+ });
2005
+
2006
+ // src/services/git.ts
2007
+ import { existsSync as existsSync5, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2008
+ import { tmpdir } from "os";
2009
+ import { dirname, join as join5, resolve as resolve4 } from "path";
2010
+ import { simpleGit } from "simple-git";
2011
+ function migrationPaths(paths) {
2012
+ return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
1969
2013
  }
1970
- function mostRecentResumableSession(ctx, featureId, kind) {
1971
- 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();
1972
- return row ? rowToSession(row) : null;
2014
+ function featureBranch(slug) {
2015
+ return `feature/${slug}`;
1973
2016
  }
1974
- function activeProjectSession(ctx, projectId, kind) {
1975
- 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();
1976
- return row ? rowToSession(row) : null;
2017
+ function git(repoPath) {
2018
+ return simpleGit(repoPath);
1977
2019
  }
1978
- function mostRecentResumableProjectSession(ctx, projectId, kind) {
1979
- 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();
1980
- return row ? rowToSession(row) : null;
2020
+ function errMsg(e) {
2021
+ return e instanceof Error ? e.message : String(e);
1981
2022
  }
1982
- function mostRecentLiveSession(ctx) {
1983
- const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
1984
- return row ? rowToSession(row) : null;
2023
+ function canon(p) {
2024
+ let abs = resolve4(p);
2025
+ try {
2026
+ abs = realpathSync.native(abs);
2027
+ } catch {}
2028
+ return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
1985
2029
  }
1986
- 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;
1987
- var init_sessions = __esm(() => {
1988
- init_src();
1989
- init_schema();
1990
- init_registry();
1991
- init_events();
1992
- init_waypoints();
1993
- init_repo();
1994
- CONVERGE_KICKOFF_LINE = "Proceed with your task: invoke /runcastle:converge and drive spec then tickets " + "from map.md + decisions.md, per your system prompt.";
1995
- KICKOFF_LINES = {
1996
- ideation: "Proceed with your task: invoke the /runcastle:ideate skill and drive the ideation session.",
1997
- 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.",
1998
- waypoint: "Proceed with your task: invoke the /runcastle:waypoint skill and work your assigned " + "waypoint to a resolution.",
1999
- converge: CONVERGE_KICKOFF_LINE,
2000
- revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up.",
2001
- 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."
2002
- };
2003
- 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: ";
2004
- pendingKickoffOverrides = new Map;
2005
- deliveries = new Map;
2006
- });
2007
-
2008
- // src/launcher/reconcile.ts
2009
- import { inArray as inArray2 } from "drizzle-orm";
2010
- function reconcileStaleSessions(ctx) {
2011
- const stale = ctx.db.select().from(sessions).where(inArray2(sessions.status, ["launching", "live"])).all().map(rowToSession);
2012
- const reconciled = [];
2013
- for (const session of stale) {
2014
- const entry = ptyRegistry().get(session.id);
2015
- if (entry && !entry.exited)
2016
- continue;
2017
- markSessionEnded(ctx, session.id);
2018
- const released = releaseForSession(ctx, session.id);
2019
- emitForSession(ctx, session, {
2020
- type: "session.reconciled",
2021
- message: `session marked ended at boot \u2014 the server restarted while it was ${session.status}`,
2022
- data: {
2023
- sessionId: session.id,
2024
- previousStatus: session.status,
2025
- releasedWaypointIds: released.map((w) => w.id)
2026
- }
2027
- });
2028
- reconciled.push(session);
2030
+ async function assertRepo(repoPath) {
2031
+ if (!existsSync5(repoPath)) {
2032
+ throw new InvalidInputError(`path does not exist: ${repoPath}`);
2029
2033
  }
2030
- return reconciled;
2031
- }
2032
- var init_reconcile = __esm(() => {
2033
- init_schema();
2034
- init_registry();
2035
- init_events();
2036
- init_repo();
2037
- init_waypoints();
2038
- init_sessions();
2039
- });
2040
-
2041
- // src/launcher/skills-root.ts
2042
- import { existsSync as existsSync5 } from "fs";
2043
- import { dirname, join as join5, resolve as resolve4 } from "path";
2044
- function resolveSkillsRoot(fromDir) {
2045
- const override = process.env[SKILLS_DIR_ENV];
2046
- if (override) {
2047
- const root = resolve4(override);
2048
- if (existsSync5(join5(root, SKILLS_MARKER)))
2049
- return root;
2050
- throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
2034
+ let isRepo = false;
2035
+ try {
2036
+ isRepo = await git(repoPath).checkIsRepo();
2037
+ } catch (e) {
2038
+ throw new InvalidInputError(`not a git repository: ${repoPath} (${errMsg(e)})`);
2051
2039
  }
2052
- const searched = [];
2053
- let dir = fromDir;
2054
- for (let i = 0;i < 8; i += 1) {
2055
- const candidate = join5(dir, "packages", "skills");
2056
- searched.push(candidate);
2057
- if (existsSync5(join5(candidate, SKILLS_MARKER)))
2058
- return candidate;
2059
- const parent = dirname(dir);
2060
- if (parent === dir)
2061
- break;
2062
- dir = parent;
2063
- }
2064
- throw new Error(`runcastle skills root not found; searched:
2065
- ${searched.join(`
2066
- `)}`);
2067
- }
2068
- var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
2069
- var init_skills_root = __esm(() => {
2070
- SKILLS_MARKER = join5("packs", "runcastle");
2071
- });
2072
-
2073
- // src/pty/dev-pane.ts
2074
- function drivePaneId(featureId) {
2075
- return `${DRIVE_PREFIX}${featureId}`;
2040
+ if (!isRepo)
2041
+ throw new InvalidInputError(`not a git repository: ${repoPath}`);
2076
2042
  }
2077
- function sniffDevUrl(text2) {
2078
- const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
2079
- return m ? m[0] : undefined;
2043
+ async function detectMainBranch(repoPath) {
2044
+ const g = git(repoPath);
2045
+ try {
2046
+ const ref = (await g.raw(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])).trim();
2047
+ const name = ref.replace(/^origin\//, "");
2048
+ if (name)
2049
+ return name;
2050
+ } catch {}
2051
+ try {
2052
+ const branches = await g.branchLocal();
2053
+ if (branches.all.includes("main"))
2054
+ return "main";
2055
+ if (branches.all.includes("master"))
2056
+ return "master";
2057
+ } catch {}
2058
+ try {
2059
+ const cur = (await g.revparse(["--abbrev-ref", "HEAD"])).trim();
2060
+ if (cur && cur !== "HEAD")
2061
+ return cur;
2062
+ } catch {}
2063
+ return "main";
2080
2064
  }
2081
- function devSpawnTarget(devCommand) {
2082
- if (process.platform === "win32") {
2083
- return { file: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", devCommand] };
2065
+ async function createFeatureBranch(project, slug, base) {
2066
+ const branch = featureBranch(slug);
2067
+ const from = base?.trim() || project.mainBranch;
2068
+ const g = git(project.repoPath);
2069
+ const branches = await g.branchLocal();
2070
+ if (branches.all.includes(branch))
2071
+ return branch;
2072
+ if (!branches.all.includes(from)) {
2073
+ throw new Error(`base branch "${from}" does not exist in ${project.repoPath}`);
2084
2074
  }
2085
- return { file: "/bin/sh", args: ["-c", devCommand] };
2075
+ await g.raw(["branch", branch, from]);
2076
+ return branch;
2086
2077
  }
2087
- function startDevPane(input) {
2088
- const { ctx, featureId, repoPath, devCommand, env, onUrl } = input;
2089
- const paneId = drivePaneId(featureId);
2090
- const { file, args } = devSpawnTarget(devCommand);
2078
+ async function listBranches(project) {
2079
+ const g = git(project.repoPath);
2080
+ const local = await g.branchLocal();
2081
+ const branches = local.all.filter((name) => !name.startsWith("feature/"));
2082
+ const localSet = new Set(local.all);
2083
+ let remoteBranches = [];
2091
2084
  try {
2092
- ptyRegistry().create({
2093
- sessionId: paneId,
2094
- cmd: file,
2095
- args,
2096
- opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
2097
- });
2098
- } catch (err) {
2099
- emit(ctx, featureId, {
2100
- type: "testdrive.dev_failed",
2101
- message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
2102
- data: { paneId }
2085
+ const remote = await g.branch(["-r"]);
2086
+ remoteBranches = remote.all.filter((r) => !r.includes(" -> ") && !r.endsWith("/HEAD")).filter((r) => {
2087
+ const short = r.replace(/^[^/]+\//, "");
2088
+ return !short.startsWith("feature/") && !localSet.has(short);
2103
2089
  });
2104
- return;
2090
+ } catch {}
2091
+ return { current: local.current, mainBranch: project.mainBranch, branches, remoteBranches };
2092
+ }
2093
+ async function resolveBaseBranch(project, base) {
2094
+ const g = git(project.repoPath);
2095
+ const local = await g.branchLocal();
2096
+ if (local.all.includes(base))
2097
+ return base;
2098
+ const remotes = (await g.getRemotes()).map((r) => r.name);
2099
+ for (const rem of remotes) {
2100
+ const prefix = `${rem}/`;
2101
+ if (!base.startsWith(prefix))
2102
+ continue;
2103
+ const name = base.slice(prefix.length);
2104
+ if (!name || name.endsWith("/HEAD"))
2105
+ break;
2106
+ if (local.all.includes(name))
2107
+ return name;
2108
+ await g.raw(["branch", "--track", name, base]);
2109
+ return name;
2105
2110
  }
2106
- let scanned = "";
2107
- let found = false;
2108
- const sniffer = {
2109
- sendData(chunk) {
2110
- if (found)
2111
- return;
2112
- scanned = (scanned + chunk.toString("utf8")).slice(-SNIFF_WINDOW);
2113
- const url = sniffDevUrl(scanned);
2114
- if (url) {
2115
- found = true;
2116
- onUrl(url);
2117
- }
2118
- },
2119
- sendControl() {}
2120
- };
2121
- ptyRegistry().attach(paneId, sniffer);
2122
- emit(ctx, featureId, {
2123
- type: "testdrive.dev_started",
2124
- message: `dev server spawned: ${devCommand}`,
2125
- data: { paneId }
2126
- });
2127
- return paneId;
2111
+ throw new Error(`base branch "${base}" does not exist in ${project.repoPath}`);
2128
2112
  }
2129
- function stopDevPane(paneId) {
2130
- const reg = ptyRegistry();
2131
- const entry = reg.get(paneId);
2132
- if (entry && !entry.exited && process.platform !== "win32") {
2113
+ async function ensureTalkWorktree(project, feature) {
2114
+ const worktreePath = worktreeDir(project.id, feature.slug);
2115
+ const branch = featureBranch(feature.slug);
2116
+ const g = git(project.repoPath);
2117
+ await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2118
+ if (await worktreeIsValid(g, worktreePath, branch))
2119
+ return worktreePath;
2120
+ return addWorktree(g, worktreePath, branch, "talk worktree");
2121
+ }
2122
+ async function addWorktree(g, worktreePath, branch, label) {
2123
+ mkdirSync2(dirname(worktreePath), { recursive: true });
2124
+ try {
2125
+ await g.raw(["worktree", "add", worktreePath, branch]);
2126
+ return worktreePath;
2127
+ } catch {
2133
2128
  try {
2134
- process.kill(-entry.pty.pid, "SIGTERM");
2129
+ await g.raw(["worktree", "prune"]);
2135
2130
  } catch {}
2131
+ try {
2132
+ await g.raw(["worktree", "add", worktreePath, branch]);
2133
+ return worktreePath;
2134
+ } catch (e) {
2135
+ throw new InvalidInputError(`could not create ${label} at ${worktreePath}: ${errMsg(e)}`);
2136
+ }
2136
2137
  }
2137
- reg.kill(paneId);
2138
- reg.remove(paneId);
2139
2138
  }
2140
- var DRIVE_PREFIX = "drive:", SNIFF_WINDOW = 8192;
2141
- var init_dev_pane = __esm(() => {
2142
- init_events();
2143
- init_registry();
2144
- });
2145
-
2146
- // src/services/drive-hooks.ts
2147
- import { spawn as spawn2 } from "child_process";
2148
- function tailLines(text2, max = OUTPUT_TAIL_LINES) {
2149
- const lines = text2.replace(/\r\n/g, `
2150
- `).split(`
2151
- `);
2152
- while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
2153
- lines.pop();
2154
- return lines.slice(-max).join(`
2155
- `).trim();
2139
+ async function ensureProjectWorktree(project) {
2140
+ const worktreePath = worktreeDir(project.id, PROJECT_WORKTREE_SLUG);
2141
+ const g = git(project.repoPath);
2142
+ const base = project.mainBranch;
2143
+ const leftover = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2144
+ if (leftover.length > 0) {
2145
+ await mergeTempBranch(project.repoPath, base, PROJECT_BRANCH);
2146
+ }
2147
+ const stillAhead = await branchCommitsAhead(project.repoPath, base, PROJECT_BRANCH);
2148
+ if (stillAhead.length === 0) {
2149
+ await deleteBranchDetachingWorktrees(g, project.repoPath, PROJECT_BRANCH);
2150
+ await g.raw(["branch", PROJECT_BRANCH, base]);
2151
+ }
2152
+ if (await worktreeIsValid(g, worktreePath, PROJECT_BRANCH))
2153
+ return worktreePath;
2154
+ if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
2155
+ if (await checkoutInWorktree(worktreePath, PROJECT_BRANCH))
2156
+ return worktreePath;
2157
+ }
2158
+ return addWorktree(g, worktreePath, PROJECT_BRANCH, "project worktree");
2156
2159
  }
2157
- function hookSpawnTarget(command) {
2158
- if (process.platform === "win32") {
2159
- return {
2160
- file: process.env.ComSpec ?? "cmd.exe",
2161
- args: ["/d", "/s", "/c", `"${command}"`],
2162
- verbatim: true
2163
- };
2160
+ async function checkoutInWorktree(worktreePath, branch) {
2161
+ const gw = git(worktreePath);
2162
+ try {
2163
+ await gw.raw(["checkout", branch]);
2164
+ return true;
2165
+ } catch {}
2166
+ try {
2167
+ await gw.raw(["checkout", "--force", branch]);
2168
+ return true;
2169
+ } catch {
2170
+ return false;
2164
2171
  }
2165
- return { file: "/bin/sh", args: ["-c", command], verbatim: false };
2166
2172
  }
2167
- function runDriveHook(command, opts) {
2168
- const spawnFn = opts.spawnFn ?? spawn2;
2169
- const now = opts.now ?? Date.now;
2170
- const timeoutMs = opts.timeoutMs ?? DRIVE_HOOK_TIMEOUT_MS;
2171
- const startedAt = now();
2172
- const { file, args, verbatim } = hookSpawnTarget(command);
2173
- return new Promise((resolve5) => {
2174
- let output = "";
2175
- let timedOut = false;
2176
- let settled = false;
2177
- let timer;
2178
- const finish = (exitCode) => {
2179
- if (settled)
2180
- return;
2181
- settled = true;
2182
- if (timer)
2183
- clearTimeout(timer);
2184
- resolve5({
2185
- ok: !timedOut && exitCode === 0,
2186
- exitCode,
2187
- timedOut,
2188
- output: tailLines(output),
2189
- durationMs: now() - startedAt
2190
- });
2191
- };
2192
- let child;
2193
- try {
2194
- child = spawnFn(file, args, {
2195
- cwd: opts.cwd,
2196
- env: opts.env ?? process.env,
2197
- windowsHide: true,
2198
- windowsVerbatimArguments: verbatim
2199
- });
2200
- } catch (err) {
2201
- output = err instanceof Error ? err.message : String(err);
2202
- finish(null);
2203
- return;
2204
- }
2205
- timer = setTimeout(() => {
2206
- timedOut = true;
2207
- child.kill();
2208
- const grace = setTimeout(() => finish(null), KILL_GRACE_MS);
2209
- grace.unref?.();
2210
- }, timeoutMs);
2211
- timer.unref?.();
2212
- child.stdout?.on("data", (d) => {
2213
- output += d.toString();
2214
- });
2215
- child.stderr?.on("data", (d) => {
2216
- output += d.toString();
2217
- });
2218
- child.on("error", (err) => {
2219
- output += `
2220
- ${err instanceof Error ? err.message : String(err)}`;
2221
- finish(null);
2222
- });
2223
- child.on("close", (code) => {
2224
- finish(code);
2225
- });
2226
- });
2227
- }
2228
- function describeHookResult(command, result) {
2229
- const secs = Math.max(1, Math.round(result.durationMs / 1000));
2230
- if (result.timedOut)
2231
- return `\`${command}\` timed out after ${secs}s`;
2232
- if (result.ok)
2233
- return `\`${command}\` finished in ${secs}s`;
2234
- return `\`${command}\` exited ${result.exitCode ?? "without a code"} after ${secs}s`;
2235
- }
2236
- var DRIVE_HOOK_TIMEOUT_MS, OUTPUT_TAIL_LINES = 40, KILL_GRACE_MS = 2000;
2237
- var init_drive_hooks = __esm(() => {
2238
- DRIVE_HOOK_TIMEOUT_MS = 10 * 60000;
2239
- });
2240
-
2241
- // src/services/drive-env.ts
2242
- import { createHash } from "crypto";
2243
- function identifierSafe(slug, max = MAX_ID_LENGTH) {
2244
- const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2245
- const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
2246
- if (safe.length <= max)
2247
- return safe;
2248
- const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
2249
- return `${safe.slice(0, max - digest.length - 1)}_${digest}`;
2250
- }
2251
- function driveVars(identity) {
2252
- return {
2253
- slug: identity.slug,
2254
- branch: identity.branch,
2255
- id: identifierSafe(identity.slug)
2256
- };
2257
- }
2258
- function renderTemplate(value, vars) {
2259
- const unknown2 = [];
2260
- const out = value.replace(PLACEHOLDER_RE, (match, name) => {
2261
- const replacement = vars[name];
2262
- if (replacement === undefined) {
2263
- if (!unknown2.includes(name))
2264
- unknown2.push(name);
2265
- return match;
2266
- }
2267
- return replacement;
2268
- });
2269
- return { value: out, unknown: unknown2 };
2270
- }
2271
- function parseDriveEnv(raw, identity) {
2272
- const vars = {};
2273
- const unknown2 = [];
2274
- if (!raw?.trim())
2275
- return { vars, unknown: unknown2 };
2276
- const substitutions = driveVars(identity);
2277
- for (const line of raw.split(/\r?\n/)) {
2278
- const trimmed = line.trim();
2279
- if (trimmed === "" || trimmed.startsWith("#"))
2280
- continue;
2281
- const eq5 = trimmed.indexOf("=");
2282
- if (eq5 <= 0)
2283
- continue;
2284
- const key = trimmed.slice(0, eq5).trim().replace(/^export\s+/, "");
2285
- if (key === "")
2286
- continue;
2287
- const rendered = renderTemplate(unquote(trimmed.slice(eq5 + 1).trim()), substitutions);
2288
- vars[key] = rendered.value;
2289
- for (const name of rendered.unknown)
2290
- if (!unknown2.includes(name))
2291
- unknown2.push(name);
2292
- }
2293
- return { vars, unknown: unknown2 };
2294
- }
2295
- function unquote(value) {
2296
- if (value.length < 2)
2297
- return value;
2298
- const first = value[0];
2299
- if ((first === '"' || first === "'") && value.endsWith(first))
2300
- return value.slice(1, -1);
2301
- return value;
2302
- }
2303
- function driveProcessEnv(overrides, base = process.env) {
2304
- return { ...base, ...overrides };
2305
- }
2306
- function describeDriveEnv(vars) {
2307
- const keys = Object.keys(vars);
2308
- return `drive environment: ${keys.join(", ")}`;
2309
- }
2310
- var MAX_ID_LENGTH = 40, PLACEHOLDER_RE;
2311
- var init_drive_env = __esm(() => {
2312
- PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
2313
- });
2314
-
2315
- // src/services/git.ts
2316
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
2317
- import { tmpdir } from "os";
2318
- import { dirname as dirname2, join as join6, resolve as resolve5 } from "path";
2319
- import { simpleGit } from "simple-git";
2320
- function migrationPaths(paths) {
2321
- return paths.filter((p) => MIGRATION_DIR_RE.test(p) || DRIZZLE_SQL_RE.test(p));
2322
- }
2323
- function featureBranch(slug) {
2324
- return `feature/${slug}`;
2325
- }
2326
- function git(repoPath) {
2327
- return simpleGit(repoPath);
2328
- }
2329
- function errMsg(e) {
2330
- return e instanceof Error ? e.message : String(e);
2331
- }
2332
- function canon(p) {
2333
- let abs = resolve5(p);
2334
- try {
2335
- abs = realpathSync.native(abs);
2336
- } catch {}
2337
- return process.platform === "win32" ? abs.replace(/\\/g, "/").toLowerCase() : abs;
2338
- }
2339
- async function assertRepo(repoPath) {
2340
- if (!existsSync6(repoPath)) {
2341
- throw new InvalidInputError(`path does not exist: ${repoPath}`);
2342
- }
2343
- let isRepo = false;
2344
- try {
2345
- isRepo = await git(repoPath).checkIsRepo();
2346
- } catch (e) {
2347
- throw new InvalidInputError(`not a git repository: ${repoPath} (${errMsg(e)})`);
2348
- }
2349
- if (!isRepo)
2350
- throw new InvalidInputError(`not a git repository: ${repoPath}`);
2351
- }
2352
- async function detectMainBranch(repoPath) {
2353
- const g = git(repoPath);
2354
- try {
2355
- const ref = (await g.raw(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"])).trim();
2356
- const name = ref.replace(/^origin\//, "");
2357
- if (name)
2358
- return name;
2359
- } catch {}
2360
- try {
2361
- const branches = await g.branchLocal();
2362
- if (branches.all.includes("main"))
2363
- return "main";
2364
- if (branches.all.includes("master"))
2365
- return "master";
2366
- } catch {}
2367
- try {
2368
- const cur = (await g.revparse(["--abbrev-ref", "HEAD"])).trim();
2369
- if (cur && cur !== "HEAD")
2370
- return cur;
2371
- } catch {}
2372
- return "main";
2373
- }
2374
- async function createFeatureBranch(project, slug, base) {
2375
- const branch = featureBranch(slug);
2376
- const from = base?.trim() || project.mainBranch;
2377
- const g = git(project.repoPath);
2378
- const branches = await g.branchLocal();
2379
- if (branches.all.includes(branch))
2380
- return branch;
2381
- if (!branches.all.includes(from)) {
2382
- throw new Error(`base branch "${from}" does not exist in ${project.repoPath}`);
2383
- }
2384
- await g.raw(["branch", branch, from]);
2385
- return branch;
2386
- }
2387
- async function listBranches(project) {
2388
- const g = git(project.repoPath);
2389
- const local = await g.branchLocal();
2390
- const branches = local.all.filter((name) => !name.startsWith("feature/"));
2391
- const localSet = new Set(local.all);
2392
- let remoteBranches = [];
2393
- try {
2394
- const remote = await g.branch(["-r"]);
2395
- remoteBranches = remote.all.filter((r) => !r.includes(" -> ") && !r.endsWith("/HEAD")).filter((r) => {
2396
- const short = r.replace(/^[^/]+\//, "");
2397
- return !short.startsWith("feature/") && !localSet.has(short);
2398
- });
2399
- } catch {}
2400
- return { current: local.current, mainBranch: project.mainBranch, branches, remoteBranches };
2401
- }
2402
- async function resolveBaseBranch(project, base) {
2403
- const g = git(project.repoPath);
2404
- const local = await g.branchLocal();
2405
- if (local.all.includes(base))
2406
- return base;
2407
- const remotes = (await g.getRemotes()).map((r) => r.name);
2408
- for (const rem of remotes) {
2409
- const prefix = `${rem}/`;
2410
- if (!base.startsWith(prefix))
2411
- continue;
2412
- const name = base.slice(prefix.length);
2413
- if (!name || name.endsWith("/HEAD"))
2414
- break;
2415
- if (local.all.includes(name))
2416
- return name;
2417
- await g.raw(["branch", "--track", name, base]);
2418
- return name;
2419
- }
2420
- throw new Error(`base branch "${base}" does not exist in ${project.repoPath}`);
2421
- }
2422
- async function ensureTalkWorktree(project, feature) {
2423
- const worktreePath = worktreeDir(project.id, feature.slug);
2424
- const branch = featureBranch(feature.slug);
2425
- const g = git(project.repoPath);
2426
- await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
2427
- if (await worktreeIsValid(g, worktreePath, branch))
2428
- return worktreePath;
2429
- mkdirSync2(dirname2(worktreePath), { recursive: true });
2430
- try {
2431
- await g.raw(["worktree", "add", worktreePath, branch]);
2432
- return worktreePath;
2433
- } catch {
2434
- try {
2435
- await g.raw(["worktree", "prune"]);
2436
- } catch {}
2437
- try {
2438
- await g.raw(["worktree", "add", worktreePath, branch]);
2439
- return worktreePath;
2440
- } catch (e) {
2441
- throw new InvalidInputError(`could not create talk worktree at ${worktreePath}: ${errMsg(e)}`);
2442
- }
2443
- }
2444
- }
2445
- async function ensureBranchExists(g, branch, from) {
2446
- const branches = await g.branchLocal();
2447
- if (!branches.all.includes(branch)) {
2448
- await g.raw(["branch", branch, from]);
2449
- }
2173
+ async function ensureBranchExists(g, branch, from) {
2174
+ const branches = await g.branchLocal();
2175
+ if (!branches.all.includes(branch)) {
2176
+ await g.raw(["branch", branch, from]);
2177
+ }
2450
2178
  }
2451
2179
  async function worktreeIsValid(g, worktreePath, branch) {
2452
- if (!existsSync6(worktreePath))
2180
+ if (!existsSync5(worktreePath))
2453
2181
  return false;
2454
2182
  const registered = await registeredWorktrees(g);
2455
2183
  if (!registered.has(canon(worktreePath)))
@@ -2495,7 +2223,7 @@ async function worktreesOnBranch(g, branch, mainRepoPath) {
2495
2223
  return (await listWorktrees(g)).filter((e) => e.branch === branch && canon(e.path) !== main).map((e) => e.path);
2496
2224
  }
2497
2225
  async function detachWorktree(path) {
2498
- if (!existsSync6(path))
2226
+ if (!existsSync5(path))
2499
2227
  return false;
2500
2228
  const g = git(path);
2501
2229
  let head;
@@ -2510,7 +2238,7 @@ async function detachWorktree(path) {
2510
2238
  return true;
2511
2239
  }
2512
2240
  async function reattachWorktree(path, branch) {
2513
- if (!existsSync6(path))
2241
+ if (!existsSync5(path))
2514
2242
  return;
2515
2243
  try {
2516
2244
  await git(path).checkout(branch);
@@ -2541,21 +2269,21 @@ function ticketBranchName(slug, ticketSeq, unique) {
2541
2269
  return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
2542
2270
  }
2543
2271
  function burnWorktreePath(repoPath, branch) {
2544
- return join6(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2272
+ return join5(repoPath, ".sandcastle", "worktrees", branch.replace(/\//g, "-"));
2545
2273
  }
2546
2274
  async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2547
2275
  const path = burnWorktreePath(repoPath, branch);
2548
2276
  const attempts = Math.max(1, opts.attempts ?? 3);
2549
2277
  const delayMs = opts.delayMs ?? 750;
2550
2278
  const g = git(repoPath);
2551
- for (let attempt = 1;attempt <= attempts && existsSync6(path); attempt++) {
2279
+ for (let attempt = 1;attempt <= attempts && existsSync5(path); attempt++) {
2552
2280
  try {
2553
2281
  await g.raw(["worktree", "remove", "--force", path]);
2554
2282
  } catch {}
2555
- if (existsSync6(path) && attempt < attempts)
2283
+ if (existsSync5(path) && attempt < attempts)
2556
2284
  await sleep(delayMs);
2557
2285
  }
2558
- if (existsSync6(path)) {
2286
+ if (existsSync5(path)) {
2559
2287
  try {
2560
2288
  rmSync(path, { recursive: true, force: true });
2561
2289
  } catch {}
@@ -2563,7 +2291,7 @@ async function cleanupBurnWorktree(repoPath, branch, opts = {}) {
2563
2291
  try {
2564
2292
  await g.raw(["worktree", "prune"]);
2565
2293
  } catch {}
2566
- return !existsSync6(path);
2294
+ return !existsSync5(path);
2567
2295
  }
2568
2296
  function sleep(ms) {
2569
2297
  return new Promise((done) => setTimeout(done, ms));
@@ -2700,11 +2428,11 @@ async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
2700
2428
  async function mergeInDisposableWorktree(g, featureBranchName, tempBranch) {
2701
2429
  let dir;
2702
2430
  try {
2703
- dir = mkdtempSync(join6(tmpdir(), "rc-land-"));
2431
+ dir = mkdtempSync(join5(tmpdir(), "rc-land-"));
2704
2432
  } catch (e) {
2705
2433
  return { ok: false, error: errMsg(e) };
2706
2434
  }
2707
- const wt = join6(dir, "wt");
2435
+ const wt = join5(dir, "wt");
2708
2436
  try {
2709
2437
  await g.raw(["worktree", "add", wt, featureBranchName]);
2710
2438
  const gw = git(wt);
@@ -2743,6 +2471,19 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
2743
2471
  return false;
2744
2472
  }
2745
2473
  }
2474
+ async function landProjectBranch(project) {
2475
+ const ahead = await branchCommitsAhead(project.repoPath, project.mainBranch, PROJECT_BRANCH);
2476
+ if (ahead.length === 0)
2477
+ return null;
2478
+ const res = await mergeTempBranch(project.repoPath, project.mainBranch, PROJECT_BRANCH);
2479
+ return {
2480
+ commits: ahead.length,
2481
+ landed: res.ok,
2482
+ ...res.conflict ? { conflict: true } : {},
2483
+ ...res.files ? { files: res.files } : {},
2484
+ ...res.error ? { error: res.error } : {}
2485
+ };
2486
+ }
2746
2487
  function tempBranchPrefix(name) {
2747
2488
  return TEMP_BRANCH_PREFIXES.find((p) => name.startsWith(p));
2748
2489
  }
@@ -2798,7 +2539,7 @@ async function removeTalkWorktree(repoPath, worktreePath) {
2798
2539
  try {
2799
2540
  await g.raw(["worktree", "prune"]);
2800
2541
  } catch {}
2801
- if (existsSync6(worktreePath)) {
2542
+ if (existsSync5(worktreePath)) {
2802
2543
  throw new InvalidInputError(`could not remove talk worktree at ${worktreePath} \u2014 a file may be locked; ` + "close anything using it and retry the delete");
2803
2544
  }
2804
2545
  }
@@ -2825,7 +2566,7 @@ async function deleteFeatureBranches(repoPath, slug) {
2825
2566
  return { deleted, kept };
2826
2567
  }
2827
2568
  async function commitDocs(worktreePath, message) {
2828
- if (!existsSync6(resolve5(worktreePath, DOCS_PATHSPEC)))
2569
+ if (!existsSync5(resolve4(worktreePath, DOCS_PATHSPEC)))
2829
2570
  return;
2830
2571
  const g = git(worktreePath);
2831
2572
  await g.add([DOCS_PATHSPEC]);
@@ -3066,26 +2807,384 @@ async function conflictedFiles(g) {
3066
2807
  return [];
3067
2808
  }
3068
2809
  }
3069
- async function mergeInProgress(g) {
3070
- try {
3071
- await g.raw(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
3072
- return true;
3073
- } catch {
3074
- return false;
2810
+ async function mergeInProgress(g) {
2811
+ try {
2812
+ await g.raw(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
2813
+ return true;
2814
+ } catch {
2815
+ return false;
2816
+ }
2817
+ }
2818
+ 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;
2819
+ var init_git = __esm(() => {
2820
+ init_paths();
2821
+ init_errors();
2822
+ init_dev_pane();
2823
+ init_drive_hooks();
2824
+ init_drive_env();
2825
+ init_events();
2826
+ init_repo();
2827
+ MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
2828
+ DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
2829
+ TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
2830
+ });
2831
+
2832
+ // src/launcher/sessions.ts
2833
+ import { and as and4, desc as desc3, eq as eq4, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
2834
+ function createSessionRow(ctx, input) {
2835
+ const row = ctx.db.insert(sessions).values({
2836
+ id: newId("sess"),
2837
+ featureId: input.featureId ?? null,
2838
+ projectId: input.projectId ?? null,
2839
+ lap: input.featureId ? getFeatureRow(ctx, input.featureId).lap : 1,
2840
+ kind: input.kind,
2841
+ ccSessionId: null,
2842
+ transcriptPath: null,
2843
+ status: "launching",
2844
+ worktreePath: input.worktreePath
2845
+ }).returning().get();
2846
+ return rowToSession(row);
2847
+ }
2848
+ function getSessionRow(ctx, id) {
2849
+ const row = ctx.db.select().from(sessions).where(eq4(sessions.id, id)).get();
2850
+ return row ? rowToSession(row) : null;
2851
+ }
2852
+ function activeSessionsForFeature(ctx, featureId) {
2853
+ return ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), inArray(sessions.status, ["launching", "live"]))).all().map(rowToSession);
2854
+ }
2855
+ function markSessionLive(ctx, id, input = {}) {
2856
+ const existing = getSessionRow(ctx, id);
2857
+ if (!existing)
2858
+ return null;
2859
+ ctx.db.update(sessions).set({
2860
+ status: "live",
2861
+ ccSessionId: input.ccSessionId ?? existing.ccSessionId ?? null,
2862
+ transcriptPath: input.transcriptPath ?? existing.transcriptPath ?? null
2863
+ }).where(eq4(sessions.id, id)).run();
2864
+ const firstTimeLive = existing.status !== "live";
2865
+ if (firstTimeLive) {
2866
+ promoteLastSession(ctx, id);
2867
+ scheduleKickoff(ctx, existing);
2868
+ }
2869
+ return getSessionRow(ctx, id);
2870
+ }
2871
+ function lapKickoff(lap) {
2872
+ 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.";
2873
+ }
2874
+ function kickoffLineFor(kind, override) {
2875
+ return override ?? KICKOFF_LINES[kind];
2876
+ }
2877
+ function resumeKickoffLine(kind) {
2878
+ return RESUME_KICKOFF_PREFIX + KICKOFF_LINES[kind];
2879
+ }
2880
+ function setKickoffOverride(sessionId, line) {
2881
+ pendingKickoffOverrides.set(sessionId, line);
2882
+ }
2883
+ function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS) {
2884
+ if (!io.alive())
2885
+ return;
2886
+ io.write(line);
2887
+ const submit = setTimeout(() => {
2888
+ try {
2889
+ if (!io.alive())
2890
+ return;
2891
+ io.write("\r");
2892
+ io.onSubmitted?.();
2893
+ } catch {}
2894
+ }, submitDelayMs);
2895
+ submit.unref?.();
2896
+ }
2897
+ function stopTimers(d) {
2898
+ for (const t of d.timers)
2899
+ clearTimeout(t);
2900
+ d.timers.clear();
2901
+ }
2902
+ function forgetKickoff(sessionId) {
2903
+ const d = deliveries.get(sessionId);
2904
+ if (d)
2905
+ stopTimers(d);
2906
+ deliveries.delete(sessionId);
2907
+ pendingKickoffOverrides.delete(sessionId);
2908
+ }
2909
+ function ptyIo(sessionId) {
2910
+ return {
2911
+ write: (data) => ptyRegistry().get(sessionId)?.pty.write(data),
2912
+ alive: () => {
2913
+ const entry = ptyRegistry().get(sessionId);
2914
+ return !!entry && !entry.exited;
2915
+ }
2916
+ };
2917
+ }
2918
+ function track(d, timer) {
2919
+ timer.unref?.();
2920
+ d.timers.add(timer);
2921
+ }
2922
+ function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
2923
+ track(d, setTimeout(() => {
2924
+ if (d.settled)
2925
+ return;
2926
+ const io = ptyIo(session.id);
2927
+ if (!io.alive()) {
2928
+ d.settled = true;
2929
+ return;
2930
+ }
2931
+ d.attempts += 1;
2932
+ const attempt = d.attempts;
2933
+ try {
2934
+ if (clearFirst)
2935
+ io.write(CLEAR_INPUT);
2936
+ writeKickoffSequence(d.line, {
2937
+ write: io.write,
2938
+ alive: io.alive,
2939
+ onSubmitted: () => emitForSession(ctx, session, {
2940
+ type: "session.kickoff",
2941
+ message: attempt === 1 ? `${session.kind} session kicked off automatically` : `${session.kind} kickoff re-sent (attempt ${attempt}) \u2014 the first was never acknowledged`,
2942
+ data: { sessionId: session.id, kind: session.kind, attempt }
2943
+ })
2944
+ });
2945
+ } catch {}
2946
+ armConfirmation(ctx, session, d);
2947
+ }, delayMs));
2948
+ }
2949
+ function armConfirmation(ctx, session, d) {
2950
+ track(d, setTimeout(() => {
2951
+ if (d.settled || d.confirmed)
2952
+ return;
2953
+ if (d.attempts < KICKOFF_MAX_ATTEMPTS && ptyIo(session.id).alive()) {
2954
+ attemptKickoff(ctx, session, d, 0, true);
2955
+ return;
2956
+ }
2957
+ settleUndelivered(ctx, session, d, "unacknowledged");
2958
+ }, KICKOFF_CONFIRM_MS));
2959
+ }
2960
+ function settleUndelivered(ctx, session, d, reason) {
2961
+ d.settled = true;
2962
+ stopTimers(d);
2963
+ emitForSession(ctx, session, {
2964
+ type: "session.kickoff_undelivered",
2965
+ 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`,
2966
+ data: { sessionId: session.id, kind: session.kind, reason, attempts: d.attempts, line: d.line }
2967
+ });
2968
+ }
2969
+ function noteKickoffPrompt(ctx, sessionId, prompt) {
2970
+ const d = deliveries.get(sessionId);
2971
+ if (!d || d.confirmed)
2972
+ return;
2973
+ const session = getSessionRow(ctx, sessionId);
2974
+ if (!session)
2975
+ return;
2976
+ if (promptMatchesKickoff(d.line, prompt)) {
2977
+ d.confirmed = true;
2978
+ d.settled = true;
2979
+ stopTimers(d);
2980
+ return;
2981
+ }
2982
+ if (d.settled)
2983
+ return;
2984
+ settleUndelivered(ctx, session, d, "superseded");
2985
+ }
2986
+ function promptMatchesKickoff(line, prompt) {
2987
+ if (!prompt)
2988
+ return false;
2989
+ const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
2990
+ const want = norm(line);
2991
+ const got = norm(prompt);
2992
+ if (!want || !got)
2993
+ return false;
2994
+ return got.includes(want.slice(0, MATCH_PREFIX));
2995
+ }
2996
+ function resendKickoff(ctx, sessionId) {
2997
+ const session = getSessionRow(ctx, sessionId);
2998
+ if (!session)
2999
+ throw new Error(`unknown session ${sessionId}`);
3000
+ if (session.status === "ended")
3001
+ throw new Error("that session has ended \u2014 open a new terminal");
3002
+ if (!ptyIo(sessionId).alive())
3003
+ throw new Error("the terminal for that session is no longer running");
3004
+ const existing = deliveries.get(sessionId);
3005
+ const d = existing ?? {
3006
+ line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
3007
+ attempts: 0,
3008
+ confirmed: false,
3009
+ settled: false,
3010
+ timers: new Set
3011
+ };
3012
+ stopTimers(d);
3013
+ d.attempts = 0;
3014
+ d.confirmed = false;
3015
+ d.settled = false;
3016
+ deliveries.set(sessionId, d);
3017
+ attemptKickoff(ctx, session, d, 0, true);
3018
+ return { line: d.line };
3019
+ }
3020
+ function armSessionReadyWatchdog(ctx, session) {
3021
+ const timer = setTimeout(() => {
3022
+ const row = getSessionRow(ctx, session.id);
3023
+ if (!row || row.status !== "launching")
3024
+ return;
3025
+ if (!ptyIo(session.id).alive())
3026
+ return;
3027
+ emitForSession(ctx, session, {
3028
+ type: "session.not_ready",
3029
+ 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`,
3030
+ data: { sessionId: session.id, kind: session.kind }
3031
+ });
3032
+ }, SESSION_READY_TIMEOUT_MS);
3033
+ timer.unref?.();
3034
+ }
3035
+ function scheduleKickoff(ctx, session) {
3036
+ const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
3037
+ pendingKickoffOverrides.delete(session.id);
3038
+ const existing = deliveries.get(session.id);
3039
+ if (existing)
3040
+ stopTimers(existing);
3041
+ const d = { line, attempts: 0, confirmed: false, settled: false, timers: new Set };
3042
+ deliveries.set(session.id, d);
3043
+ attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
3044
+ }
3045
+ function landProjectSession(ctx, session) {
3046
+ if (session.kind !== "project" || !session.projectId)
3047
+ return;
3048
+ if (landedProjectSessions.has(session.id))
3049
+ return;
3050
+ landedProjectSessions.add(session.id);
3051
+ const project = getProjectById(ctx, session.projectId);
3052
+ if (!project)
3053
+ return;
3054
+ const kept = `they are kept on ${PROJECT_BRANCH} and retried at the next launch`;
3055
+ const report = (type, message, data = {}) => {
3056
+ emitProject(ctx, project.id, {
3057
+ type,
3058
+ message,
3059
+ data: { sessionId: session.id, branch: PROJECT_BRANCH, ...data }
3060
+ });
3061
+ };
3062
+ landProjectBranch(project).then((res) => {
3063
+ if (!res)
3064
+ return;
3065
+ if (res.landed) {
3066
+ report("project.landed", `landed ${res.commits} commit(s) from the project session onto ${project.mainBranch}`, { commits: res.commits });
3067
+ } else if (res.conflict) {
3068
+ 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 ?? [] });
3069
+ } else {
3070
+ 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 });
3071
+ }
3072
+ }).catch((e) => {
3073
+ 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) });
3074
+ });
3075
+ }
3076
+ function markSessionEnded(ctx, id) {
3077
+ const existing = getSessionRow(ctx, id);
3078
+ if (!existing)
3079
+ return null;
3080
+ forgetKickoff(id);
3081
+ ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
3082
+ return getSessionRow(ctx, id);
3083
+ }
3084
+ function mostRecentResumableSession(ctx, featureId, kind) {
3085
+ 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();
3086
+ return row ? rowToSession(row) : null;
3087
+ }
3088
+ function activeProjectSession(ctx, projectId, kind) {
3089
+ 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();
3090
+ return row ? rowToSession(row) : null;
3091
+ }
3092
+ function mostRecentResumableProjectSession(ctx, projectId, kind) {
3093
+ 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();
3094
+ return row ? rowToSession(row) : null;
3095
+ }
3096
+ function mostRecentLiveSession(ctx) {
3097
+ const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc3(sql2`rowid`)).limit(1).get();
3098
+ return row ? rowToSession(row) : null;
3099
+ }
3100
+ 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;
3101
+ var init_sessions = __esm(() => {
3102
+ init_src();
3103
+ init_schema();
3104
+ init_registry();
3105
+ init_events();
3106
+ init_git();
3107
+ init_waypoints();
3108
+ init_repo();
3109
+ CONVERGE_KICKOFF_LINE = "Proceed with your task: invoke /runcastle:converge and drive spec then tickets " + "from map.md + decisions.md, per your system prompt.";
3110
+ KICKOFF_LINES = {
3111
+ ideation: "Proceed with your task: invoke the /runcastle:ideate skill and drive the ideation session.",
3112
+ 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.",
3113
+ waypoint: "Proceed with your task: invoke the /runcastle:waypoint skill and work your assigned " + "waypoint to a resolution.",
3114
+ converge: CONVERGE_KICKOFF_LINE,
3115
+ revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up.",
3116
+ 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.",
3117
+ project: "Proceed with your task: invoke the /runcastle:project skill and drive the project session."
3118
+ };
3119
+ 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: ";
3120
+ pendingKickoffOverrides = new Map;
3121
+ deliveries = new Map;
3122
+ landedProjectSessions = new Set;
3123
+ });
3124
+
3125
+ // src/launcher/reconcile.ts
3126
+ import { inArray as inArray2 } from "drizzle-orm";
3127
+ function reconcileStaleSessions(ctx) {
3128
+ const stale = ctx.db.select().from(sessions).where(inArray2(sessions.status, ["launching", "live"])).all().map(rowToSession);
3129
+ const reconciled = [];
3130
+ for (const session of stale) {
3131
+ const entry = ptyRegistry().get(session.id);
3132
+ if (entry && !entry.exited)
3133
+ continue;
3134
+ markSessionEnded(ctx, session.id);
3135
+ const released = releaseForSession(ctx, session.id);
3136
+ emitForSession(ctx, session, {
3137
+ type: "session.reconciled",
3138
+ message: `session marked ended at boot \u2014 the server restarted while it was ${session.status}`,
3139
+ data: {
3140
+ sessionId: session.id,
3141
+ previousStatus: session.status,
3142
+ releasedWaypointIds: released.map((w) => w.id)
3143
+ }
3144
+ });
3145
+ reconciled.push(session);
3075
3146
  }
3147
+ return reconciled;
3076
3148
  }
3077
- 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;
3078
- var init_git = __esm(() => {
3079
- init_paths();
3080
- init_errors();
3081
- init_dev_pane();
3082
- init_drive_hooks();
3083
- init_drive_env();
3149
+ var init_reconcile = __esm(() => {
3150
+ init_schema();
3151
+ init_registry();
3084
3152
  init_events();
3085
3153
  init_repo();
3086
- MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
3087
- DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
3088
- TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
3154
+ init_waypoints();
3155
+ init_sessions();
3156
+ });
3157
+
3158
+ // src/launcher/skills-root.ts
3159
+ import { existsSync as existsSync6 } from "fs";
3160
+ import { dirname as dirname2, join as join6, resolve as resolve5 } from "path";
3161
+ function resolveSkillsRoot(fromDir) {
3162
+ const override = process.env[SKILLS_DIR_ENV];
3163
+ if (override) {
3164
+ const root = resolve5(override);
3165
+ if (existsSync6(join6(root, SKILLS_MARKER)))
3166
+ return root;
3167
+ throw new Error(`${SKILLS_DIR_ENV}=${override} is not a runcastle skills root (missing ${SKILLS_MARKER})`);
3168
+ }
3169
+ const searched = [];
3170
+ let dir = fromDir;
3171
+ for (let i = 0;i < 8; i += 1) {
3172
+ const candidate = join6(dir, "packages", "skills");
3173
+ searched.push(candidate);
3174
+ if (existsSync6(join6(candidate, SKILLS_MARKER)))
3175
+ return candidate;
3176
+ const parent = dirname2(dir);
3177
+ if (parent === dir)
3178
+ break;
3179
+ dir = parent;
3180
+ }
3181
+ throw new Error(`runcastle skills root not found; searched:
3182
+ ${searched.join(`
3183
+ `)}`);
3184
+ }
3185
+ var SKILLS_DIR_ENV = "RUNCASTLE_SKILLS_DIR", SKILLS_MARKER;
3186
+ var init_skills_root = __esm(() => {
3187
+ SKILLS_MARKER = join6("packs", "runcastle");
3089
3188
  });
3090
3189
 
3091
3190
  // src/services/agent-stream.ts
@@ -4715,7 +4814,7 @@ var init_findings = __esm(() => {
4715
4814
  });
4716
4815
 
4717
4816
  // src/services/prep.ts
4718
- import { and as and6, desc as desc3, eq as eq6 } from "drizzle-orm";
4817
+ import { and as and6, desc as desc4, eq as eq6 } from "drizzle-orm";
4719
4818
  function isPreparing(projectId) {
4720
4819
  return controllers.has(projectId);
4721
4820
  }
@@ -4837,7 +4936,7 @@ function truncate(value, max = 120) {
4837
4936
  return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
4838
4937
  }
4839
4938
  function latestPrep(ctx, projectId) {
4840
- const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc3(projectPreps.startedAt)).get();
4939
+ const row = ctx.db.select().from(projectPreps).where(eq6(projectPreps.projectId, projectId)).orderBy(desc4(projectPreps.startedAt)).get();
4841
4940
  if (!row)
4842
4941
  return null;
4843
4942
  return {
@@ -4851,7 +4950,7 @@ function latestPrep(ctx, projectId) {
4851
4950
  };
4852
4951
  }
4853
4952
  function latestPrepNotes(ctx, projectId) {
4854
- 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();
4953
+ 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();
4855
4954
  const notes = row?.data?.notes;
4856
4955
  return typeof notes === "string" && notes.trim() !== "" ? notes : undefined;
4857
4956
  }
@@ -5148,14 +5247,16 @@ var init_gates = __esm(() => {
5148
5247
  // src/services/knowledge.ts
5149
5248
  import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, writeFileSync } from "fs";
5150
5249
  import { join as join10, relative, resolve as resolve6, sep } from "path";
5151
- function scaffoldDocs(ctx, feature) {
5250
+ function scaffoldDocs(ctx, feature, opts = {}) {
5152
5251
  const project = projectForFeature(ctx, feature);
5153
5252
  const dir = featureDocsDir(project, feature);
5154
5253
  mkdirSync5(dir, { recursive: true });
5155
5254
  const briefPath = featureDocPath(project, feature, "brief.md");
5156
5255
  if (!existsSync11(briefPath)) {
5256
+ const override = opts.brief?.trim();
5157
5257
  const created = new Date().toISOString();
5158
- const brief = [
5258
+ const brief = override ? `${override}
5259
+ ` : [
5159
5260
  `# ${feature.title}`,
5160
5261
  "",
5161
5262
  feature.oneLiner,
@@ -5197,6 +5298,36 @@ function mapDocBody(feature, seed) {
5197
5298
  return lines.join(`
5198
5299
  `);
5199
5300
  }
5301
+ function readCharter(repoRoot) {
5302
+ const path = join10(repoRoot, CHARTER_FILE);
5303
+ if (!existsSync11(path))
5304
+ return;
5305
+ try {
5306
+ return readFileSync5(path, "utf8");
5307
+ } catch {
5308
+ return;
5309
+ }
5310
+ }
5311
+ function listLiveAdrs(repoRoot) {
5312
+ const dir = join10(repoRoot, ADR_DIR_REL);
5313
+ if (!existsSync11(dir))
5314
+ return [];
5315
+ const docs = [];
5316
+ for (const entry of readdirSync3(dir, { withFileTypes: true })) {
5317
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
5318
+ continue;
5319
+ let content;
5320
+ try {
5321
+ content = readFileSync5(join10(dir, entry.name), "utf8");
5322
+ } catch {
5323
+ continue;
5324
+ }
5325
+ if (SUPERSEDED_RE.test(content))
5326
+ continue;
5327
+ docs.push({ relPath: `${ADR_DIR_REL}/${entry.name}`, content });
5328
+ }
5329
+ return docs.sort((a, b) => a.relPath.localeCompare(b.relPath));
5330
+ }
5200
5331
  function listDocs(ctx, feature) {
5201
5332
  const project = projectForFeature(ctx, feature);
5202
5333
  const dir = featureDocsDir(project, feature);
@@ -5227,7 +5358,7 @@ function readTitle(path, fallback) {
5227
5358
  return fallback;
5228
5359
  }
5229
5360
  }
5230
- var MAP_SECTIONS;
5361
+ var MAP_SECTIONS, CHARTER_FILE = "CONTEXT.md", ADR_DIR_REL = "docs/adr", SUPERSEDED_RE;
5231
5362
  var init_knowledge = __esm(() => {
5232
5363
  init_errors();
5233
5364
  init_events();
@@ -5239,6 +5370,7 @@ var init_knowledge = __esm(() => {
5239
5370
  "Not yet specified",
5240
5371
  "Out of scope"
5241
5372
  ];
5373
+ SUPERSEDED_RE = /superseded by ADR-\d+/i;
5242
5374
  });
5243
5375
 
5244
5376
  // src/pty/end-session.ts
@@ -5249,6 +5381,7 @@ function endSession(ctx, sessionId) {
5249
5381
  const session = markSessionEnded(ctx, sessionId);
5250
5382
  if (session) {
5251
5383
  releaseForSession(ctx, sessionId);
5384
+ landProjectSession(ctx, session);
5252
5385
  emitForSession(ctx, session, {
5253
5386
  type: "session.ended",
5254
5387
  message: "session ended by user",
@@ -5610,7 +5743,7 @@ var init_runner = __esm(() => {
5610
5743
  });
5611
5744
 
5612
5745
  // src/services/features.ts
5613
- import { desc as desc4, eq as eq9 } from "drizzle-orm";
5746
+ import { desc as desc5, eq as eq9 } from "drizzle-orm";
5614
5747
  import { rmSync as rmSync2 } from "fs";
5615
5748
  async function createFeature(ctx, input) {
5616
5749
  const project = requireProjectById(ctx, input.projectId);
@@ -5639,7 +5772,67 @@ async function createFeature(ctx, input) {
5639
5772
  message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
5640
5773
  data: { slug, branch, baseBranch, branchReady }
5641
5774
  });
5642
- scaffoldDocs(ctx, feature);
5775
+ scaffoldDocs(ctx, feature, { brief: input.brief });
5776
+ if (branchReady) {
5777
+ try {
5778
+ await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
5779
+ } catch {}
5780
+ }
5781
+ return feature;
5782
+ }
5783
+ async function quickChange(ctx, input) {
5784
+ const project = requireProjectById(ctx, input.projectId);
5785
+ const title = input.title.trim();
5786
+ const prose = input.prose.trim();
5787
+ if (!title)
5788
+ throw new InvalidInputError("a quick change needs a title");
5789
+ if (!prose)
5790
+ throw new InvalidInputError("a quick change needs a sentence describing the change");
5791
+ const slug = uniqueSlug(ctx, project.id, title);
5792
+ const branch = `feature/${slug}`;
5793
+ const requestedBase = input.baseBranch?.trim() || project.mainBranch;
5794
+ const { branchReady, baseBranch } = await ensureFeatureBranch(project, slug, requestedBase);
5795
+ const inserted = ctx.db.insert(features).values({
5796
+ id: newId("feat"),
5797
+ projectId: project.id,
5798
+ slug,
5799
+ title,
5800
+ oneLiner: prose.split(`
5801
+ `)[0].trim(),
5802
+ mapped: false,
5803
+ lap: 1,
5804
+ phase: "implementation",
5805
+ branch,
5806
+ baseBranch,
5807
+ status: "active",
5808
+ createdAt: Date.now()
5809
+ }).returning().get();
5810
+ const feature = rowToFeature(inserted);
5811
+ emit(ctx, feature.id, {
5812
+ type: "feature.created",
5813
+ message: branchReady ? `feature.created (${branch} \u2190 ${baseBranch})` : "feature.created (branch pending)",
5814
+ data: { slug, branch, baseBranch, branchReady }
5815
+ });
5816
+ scaffoldDocs(ctx, feature, { brief: `# ${title}
5817
+
5818
+ ${prose}
5819
+ ` });
5820
+ const [ticket] = storeTickets(ctx, feature.id, [
5821
+ {
5822
+ title,
5823
+ goal: prose,
5824
+ context: prose,
5825
+ acceptanceCriteria: [prose],
5826
+ seams: [],
5827
+ blockedBy: []
5828
+ }
5829
+ ]);
5830
+ emit(ctx, feature.id, {
5831
+ type: "feature.quick_change",
5832
+ message: `quick change \u2014 born at implementation on lap 1 with one ticket (#${ticket.seq}); no grill session, no spec.md`,
5833
+ ticketId: ticket.id,
5834
+ data: { slug, ticketSeq: ticket.seq, phase: "implementation" }
5835
+ });
5643
5836
  if (branchReady) {
5644
5837
  try {
5645
5838
  await commitDocs(project.repoPath, `runcastle: scaffold ${slug} docs`);
@@ -5674,7 +5867,7 @@ function getFeatureFull(ctx, id) {
5674
5867
  };
5675
5868
  }
5676
5869
  function list(ctx, projectId) {
5677
- const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc4(features.createdAt)).all();
5870
+ const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc5(features.createdAt)).all();
5678
5871
  return rows.map((row) => {
5679
5872
  const feature = rowToFeature(row);
5680
5873
  const tickets2 = listByFeature2(ctx, feature.id);
@@ -5976,7 +6169,8 @@ function resolveSession(ctx, sessionId) {
5976
6169
  }
5977
6170
  function requireFeatureId(session) {
5978
6171
  if (!session.featureId) {
5979
- 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.");
6172
+ 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.";
6173
+ throw new GateError(`this tool belongs to a feature, and a ${session.kind} session is project-scoped. ${instead}`);
5980
6174
  }
5981
6175
  return session.featureId;
5982
6176
  }
@@ -6003,7 +6197,8 @@ function toolGetFeatureContext(ctx, session) {
6003
6197
  }
6004
6198
  return context;
6005
6199
  }
6006
- function toolResolveWaypoint(ctx, _session, input) {
6200
+ function toolResolveWaypoint(ctx, session, input) {
6201
+ requireFeatureId(session);
6007
6202
  resolve3(ctx, input.id, input.disposition, input.summary);
6008
6203
  return { ok: true };
6009
6204
  }
@@ -6013,8 +6208,9 @@ function toolEmitTickets(ctx, session, input) {
6013
6208
  return { stored: stored.length, ids: stored.map((t) => t.id) };
6014
6209
  }
6015
6210
  function requireOwnTicket(ctx, session, ticketId) {
6211
+ const featureId = requireFeatureId(session);
6016
6212
  const ticket = getTicket(ctx, ticketId);
6017
- if (ticket.featureId !== requireFeatureId(session)) {
6213
+ if (ticket.featureId !== featureId) {
6018
6214
  throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
6019
6215
  }
6020
6216
  return ticket;
@@ -6040,11 +6236,14 @@ function toolEmitWaypoints(ctx, session, input) {
6040
6236
  return { stored: stored.length, ids: stored.map((w) => w.id) };
6041
6237
  }
6042
6238
  function toolRecordEvent(ctx, session, input) {
6043
- emit(ctx, requireFeatureId(session), {
6239
+ const event = emitForSession(ctx, session, {
6044
6240
  type: input.type,
6045
6241
  message: input.message,
6046
6242
  data: { source: "mcp" }
6047
6243
  });
6244
+ if (!event) {
6245
+ throw new GateError(`session ${session.id} belongs to neither a feature nor a project \u2014 nothing to record against`);
6246
+ }
6048
6247
  return { ok: true };
6049
6248
  }
6050
6249
  function toolCompletePhase(ctx, session, input) {
@@ -6086,7 +6285,7 @@ async function commitDocsCheckpoint(ctx, session, message) {
6086
6285
  }
6087
6286
  function requireProject(ctx, session) {
6088
6287
  if (!session.projectId) {
6089
- throw new GateError(`record_finding is for a project-scoped preparation session; this is a ${session.kind} session.`);
6288
+ 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.");
6090
6289
  }
6091
6290
  const project = getProjectById(ctx, session.projectId);
6092
6291
  if (!project)
@@ -6117,6 +6316,85 @@ function toolRecordFinding(ctx, session, input) {
6117
6316
  });
6118
6317
  return { ok: true, key: input.key, source };
6119
6318
  }
6319
+ async function toolCreateFeature(ctx, session, input) {
6320
+ const project = requireProject(ctx, session);
6321
+ const feature = input.ticket ? await quickChange(ctx, {
6322
+ projectId: project.id,
6323
+ title: input.title,
6324
+ prose: input.ticket.prose,
6325
+ baseBranch: input.baseBranch
6326
+ }) : await createFeature(ctx, {
6327
+ projectId: project.id,
6328
+ title: input.title,
6329
+ oneLiner: input.oneLiner,
6330
+ baseBranch: input.baseBranch,
6331
+ brief: input.brief
6332
+ });
6333
+ return {
6334
+ id: feature.id,
6335
+ slug: feature.slug,
6336
+ branch: feature.branch,
6337
+ phase: feature.phase
6338
+ };
6339
+ }
6340
+ function toolGetProjectContext(ctx, session) {
6341
+ const project = requireProject(ctx, session);
6342
+ const charter = readCharter(session.worktreePath);
6343
+ return {
6344
+ project,
6345
+ ...charter !== undefined ? { charter } : {},
6346
+ adrs: listLiveAdrs(session.worktreePath),
6347
+ featureIndex: list(ctx, project.id).map(featureIndexLine)
6348
+ };
6349
+ }
6350
+ function featureIndexLine(feature) {
6351
+ if (feature.status === "shipped") {
6352
+ return `${feature.slug} \u2014 ${feature.oneLiner} [shipped] ${featureDocsRel(feature.slug)}/`;
6353
+ }
6354
+ return `${feature.title} [${feature.status === "archived" ? "archived" : "in flight"}]`;
6355
+ }
6356
+ function toolGetWorkRecord(ctx, session, input) {
6357
+ const project = requireProject(ctx, session);
6358
+ const slug = input.featureSlug?.trim();
6359
+ const seam = input.seam?.trim().toLowerCase();
6360
+ if (!slug && !seam) {
6361
+ throw new InvalidInputError("get_work_record needs a featureSlug or a seam to look up");
6362
+ }
6363
+ const records = [];
6364
+ for (const feature of list(ctx, project.id)) {
6365
+ if (slug && feature.slug !== slug)
6366
+ continue;
6367
+ let tickets2 = listByFeature2(ctx, feature.id);
6368
+ if (seam) {
6369
+ tickets2 = tickets2.filter((t) => t.seams.some((s) => s.toLowerCase().includes(seam)));
6370
+ if (tickets2.length === 0)
6371
+ continue;
6372
+ }
6373
+ const shippedAt = latestEventTs(ctx, feature.id, "feature.shipped");
6374
+ records.push({
6375
+ slug: feature.slug,
6376
+ title: feature.title,
6377
+ status: feature.status,
6378
+ ...shippedAt !== undefined ? { shippedAt } : {},
6379
+ runs: listRunsByFeature(ctx, feature.id).map((r) => ({
6380
+ workflow: r.workflow,
6381
+ status: r.status,
6382
+ startedAt: r.startedAt,
6383
+ ...r.endedAt !== undefined ? { endedAt: r.endedAt } : {},
6384
+ ...r.summary !== undefined ? { summary: r.summary } : {}
6385
+ })),
6386
+ tickets: tickets2.map((t) => ({
6387
+ seq: t.seq,
6388
+ title: t.title,
6389
+ status: t.status,
6390
+ seams: t.seams,
6391
+ commits: t.commits,
6392
+ ...t.error !== undefined ? { error: t.error } : {}
6393
+ }))
6394
+ });
6395
+ }
6396
+ return { features: records };
6397
+ }
6120
6398
  function ok(result) {
6121
6399
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
6122
6400
  }
@@ -6153,6 +6431,42 @@ function buildMcpServer() {
6153
6431
  return noSession();
6154
6432
  return ok(toolRecordFinding(rs.ctx, rs.session, args));
6155
6433
  });
6434
+ server.registerTool("create_feature", {
6435
+ title: "Create a feature",
6436
+ 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.",
6437
+ inputSchema: {
6438
+ title: z4.string().min(1),
6439
+ oneLiner: z4.string(),
6440
+ baseBranch: z4.string().optional(),
6441
+ brief: z4.string().optional(),
6442
+ ticket: z4.object({ prose: z4.string().min(1) }).optional()
6443
+ }
6444
+ }, async (args, extra) => {
6445
+ const rs = await resolveCtxSession(extra);
6446
+ if (!rs)
6447
+ return noSession();
6448
+ return ok(await toolCreateFeature(rs.ctx, rs.session, args));
6449
+ });
6450
+ server.registerTool("get_project_context", {
6451
+ title: "Get project context",
6452
+ 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.",
6453
+ inputSchema: {}
6454
+ }, async (_args, extra) => {
6455
+ const rs = await resolveCtxSession(extra);
6456
+ if (!rs)
6457
+ return noSession();
6458
+ return ok(toolGetProjectContext(rs.ctx, rs.session));
6459
+ });
6460
+ server.registerTool("get_work_record", {
6461
+ title: "Get work record",
6462
+ 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.",
6463
+ inputSchema: { featureSlug: z4.string().optional(), seam: z4.string().optional() }
6464
+ }, async (args, extra) => {
6465
+ const rs = await resolveCtxSession(extra);
6466
+ if (!rs)
6467
+ return noSession();
6468
+ return ok(toolGetWorkRecord(rs.ctx, rs.session, args));
6469
+ });
6156
6470
  server.registerTool("get_feature_context", {
6157
6471
  title: "Get feature context",
6158
6472
  description: "Full context for the current feature: the feature row, its phase, all docs/features/<slug>/*.md contents, and its tickets.",
@@ -6244,7 +6558,7 @@ function buildMcpServer() {
6244
6558
  });
6245
6559
  server.registerTool("record_event", {
6246
6560
  title: "Record event",
6247
- description: "Add a note to the feature timeline (decisions recorded, spec saved, milestones).",
6561
+ 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.",
6248
6562
  inputSchema: { type: z4.string(), message: z4.string() }
6249
6563
  }, async (args, extra) => {
6250
6564
  const rs = await resolveCtxSession(extra);
@@ -6271,6 +6585,7 @@ function buildMcpServer() {
6271
6585
  var mcp, server_default;
6272
6586
  var init_server = __esm(() => {
6273
6587
  init_src();
6588
+ init_paths();
6274
6589
  init_errors();
6275
6590
  init_sessions();
6276
6591
  init_features();
@@ -6970,14 +7285,72 @@ function renderPreparePrompt(brief) {
6970
7285
  ].join(`
6971
7286
  `);
6972
7287
  }
6973
- function renderSettings(hookClient) {
7288
+ function renderProjectPrompt(brief) {
7289
+ const { project, branch, worktreePath } = brief;
7290
+ return [
7291
+ `# runcastle \u2014 ${project.name} (project session)`,
7292
+ "",
7293
+ "This is the **project session**: a conversation that belongs to the project, not to",
7294
+ "any one feature. There is no phase to advance and no gate to cross here.",
7295
+ "",
7296
+ "## What this session is for",
7297
+ "- **Intake and decomposition** \u2014 the job no other surface can do. Take whatever the",
7298
+ " human brings, grill it until it resolves into N features, and create them with",
7299
+ " `create_feature`. A feature born here carries a real brief: the reasoning you just",
7300
+ " worked out, not a restated one-liner.",
7301
+ '- **Portfolio Q&A** \u2014 "have we already decided X?", "did we ever build Y?" \u2014 the same',
7302
+ " lookup intake needs anyway to avoid creating a duplicate feature.",
7303
+ "- **Routing** \u2014 an incoming thing is one of exactly five destinations: a new feature,",
7304
+ " a quick change, an existing feature's revisit, a Rethink lap on something in",
7305
+ " review, or nothing. Say which, and why.",
7306
+ "- **Curation, advisory only** \u2014 you may report that two in-flight features are on a",
7307
+ " collision course or that an ADR looks stale. You do NOT fix either. Every fix routes",
7308
+ " back through a feature, through promotion at merge, or through the charter.",
7309
+ "- **The charter (`CONTEXT.md`)** \u2014 you are the only session in runcastle allowed to",
7310
+ " write it. Create it lazily: when there is first something to write, not before.",
7311
+ "",
7312
+ "## Where you are working \u2014 and where your writes go",
7313
+ `- Working directory: \`${worktreePath}\` \u2014 a runcastle-owned worktree, NOT the human's`,
7314
+ ` checkout at \`${project.repoPath}\`.`,
7315
+ `- Branch: \`${branch}\`, cut from \`${project.mainBranch}\` when this session launched.`,
7316
+ `- **Your commits land on \`${project.mainBranch}\` when this terminal closes**, arriving`,
7317
+ " in the human's checkout the way a `git pull` does. Write real code and real docs \u2014",
7318
+ " there is no sandbox here \u2014 and commit everything you write before you finish.",
7319
+ " A session that edits without committing leaves a dirty tree, which is exactly what",
7320
+ " blocks a test drive and jams the next merge. Land what you wrote; leave it clean.",
7321
+ "",
7322
+ "## runcastle MCP tools",
7323
+ "Four, and none of the feature pipeline's \u2014 a session with no feature has no business",
7324
+ "advancing one through a gate:",
7325
+ "- `create_feature({ title, oneLiner, baseBranch?, brief?, ticket? })` \u2014 the point of",
7326
+ " this session. It does NOT open a terminal on what it creates; the new card appearing",
7327
+ " in the rail is the feedback, and the human decides what to work on next.",
7328
+ "- `get_project_context()` \u2014 the project, its charter, its live ADRs, and a one-line",
7329
+ " index of every feature.",
7330
+ "- `get_work_record({ featureSlug? | seam? })` \u2014 what features actually did: tickets by",
7331
+ " status, seams, commits, errors. Facts, never intent.",
7332
+ "- `record_event({ type, message })` \u2014 drop a note on the project timeline.",
7333
+ "",
7334
+ "Every merged feature's docs are already on disk in this worktree \u2014 read them with your",
7335
+ "ordinary file tools. The index says where.",
7336
+ "",
7337
+ "## Your task",
7338
+ "Invoke the `/runcastle:project` skill and drive the project session.",
7339
+ ""
7340
+ ].join(`
7341
+ `);
7342
+ }
7343
+ function sessionBashAllowRules(kind) {
7344
+ return kind === "project" ? SESSION_BASH_READ_RULES : SESSION_BASH_ALLOW_RULES;
7345
+ }
7346
+ function renderSettings(hookClient, kind) {
6974
7347
  const cmd = (event) => ({
6975
7348
  type: "command",
6976
7349
  command: `bun run "${hookClient}" ${event}`,
6977
7350
  timeout: event === "user-prompt" ? 5 : 10
6978
7351
  });
6979
7352
  return {
6980
- permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...SESSION_BASH_ALLOW_RULES] },
7353
+ permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...sessionBashAllowRules(kind)] },
6981
7354
  hooks: {
6982
7355
  SessionStart: SESSION_START_SOURCES.map((source) => ({
6983
7356
  matcher: source,
@@ -7000,21 +7373,21 @@ function renderMcpConfig(session, config2) {
7000
7373
  };
7001
7374
  }
7002
7375
  async function writeSessionArtifacts(input) {
7003
- const { session, feature, config: config2, waypoint, prepare } = input;
7376
+ const { session, feature, config: config2, waypoint, prepare, projectBrief } = input;
7004
7377
  const dir = sessionDir(session.id);
7005
7378
  mkdirSync7(dir, { recursive: true });
7006
7379
  const systemPromptPath = join13(dir, "system-prompt.md");
7007
7380
  const settingsPath = join13(dir, "settings.json");
7008
7381
  const mcpConfigPath = join13(dir, "mcp.json");
7009
- const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : (() => {
7010
- throw new Error(`session ${session.id} has neither a feature nor a preparation brief`);
7382
+ const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
7383
+ throw new Error(`session ${session.id} has no feature and no project-session brief`);
7011
7384
  })();
7012
7385
  writeFileSync2(systemPromptPath, systemPrompt, "utf8");
7013
- writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath()), null, 2), "utf8");
7386
+ writeFileSync2(settingsPath, JSON.stringify(renderSettings(hookClientPath(), session.kind), null, 2), "utf8");
7014
7387
  writeFileSync2(mcpConfigPath, JSON.stringify(renderMcpConfig(session, config2), null, 2), "utf8");
7015
7388
  return { systemPromptPath, settingsPath, mcpConfigPath };
7016
7389
  }
7017
- var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
7390
+ var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_READ_RULES, SESSION_BASH_WRITE_RULES, SESSION_BASH_ALLOW_RULES, SESSION_START_SOURCES;
7018
7391
  var init_artifacts = __esm(() => {
7019
7392
  init_paths();
7020
7393
  init_asset_paths();
@@ -7028,18 +7401,27 @@ var init_artifacts = __esm(() => {
7028
7401
  "mcp__runcastle__escalate_to_map",
7029
7402
  "mcp__runcastle__emit_waypoints",
7030
7403
  "mcp__runcastle__resolve_waypoint",
7031
- "mcp__runcastle__record_finding"
7404
+ "mcp__runcastle__record_finding",
7405
+ "mcp__runcastle__create_feature",
7406
+ "mcp__runcastle__get_project_context",
7407
+ "mcp__runcastle__get_work_record"
7032
7408
  ];
7033
- SESSION_BASH_ALLOW_RULES = [
7409
+ SESSION_BASH_READ_RULES = [
7034
7410
  "Bash(git status:*)",
7035
7411
  "Bash(git rev-parse:*)",
7036
7412
  "Bash(git log:*)",
7037
7413
  "Bash(git diff:*)",
7038
- "Bash(git add:*)",
7039
- "Bash(git commit:*)",
7040
7414
  "Bash(git branch:*)",
7041
7415
  "Bash(git show:*)"
7042
7416
  ];
7417
+ SESSION_BASH_WRITE_RULES = [
7418
+ "Bash(git add:*)",
7419
+ "Bash(git commit:*)"
7420
+ ];
7421
+ SESSION_BASH_ALLOW_RULES = [
7422
+ ...SESSION_BASH_READ_RULES,
7423
+ ...SESSION_BASH_WRITE_RULES
7424
+ ];
7043
7425
  SESSION_START_SOURCES = ["startup", "resume", "clear", "compact", "fork"];
7044
7426
  });
7045
7427
 
@@ -7323,6 +7705,69 @@ async function launchPrepareSession(ctx, input, opts = {}) {
7323
7705
  spawnEmbeddedPty(ctx, undefined, session, project.repoPath, serverUrl, buildClaudeArgs(buildInput), { resumeSessionId });
7324
7706
  return { sessionId: session.id };
7325
7707
  }
7708
+ async function launchProjectSession(ctx, input, opts = {}) {
7709
+ const project = requireProjectById(ctx, input.projectId);
7710
+ const live = activeProjectSession(ctx, project.id, "project");
7711
+ if (live) {
7712
+ throw new GateError(`a project session is already open for ${project.name} \u2014 resume or end it first`);
7713
+ }
7714
+ const worktreePath = await ensureProjectWorktree(project);
7715
+ const session = createSessionRow(ctx, {
7716
+ projectId: project.id,
7717
+ kind: "project",
7718
+ worktreePath
7719
+ });
7720
+ const resumeSessionId = mostRecentResumableProjectSession(ctx, project.id, "project")?.ccSessionId;
7721
+ emitProject(ctx, project.id, {
7722
+ type: "session.launching",
7723
+ message: "launching project session",
7724
+ data: { sessionId: session.id, kind: "project", worktreePath, branch: PROJECT_BRANCH }
7725
+ });
7726
+ if (resumeSessionId) {
7727
+ emitProject(ctx, project.id, {
7728
+ type: "session.resumed",
7729
+ message: "resuming the previous project conversation",
7730
+ data: { sessionId: session.id, resumeSessionId }
7731
+ });
7732
+ }
7733
+ const artifacts = await writeSessionArtifacts({
7734
+ session,
7735
+ project,
7736
+ config: ctx.config,
7737
+ projectBrief: { project, branch: PROJECT_BRANCH, worktreePath }
7738
+ });
7739
+ const serverUrl = serverUrlFor(ctx.config);
7740
+ const buildInput = {
7741
+ sessionId: session.id,
7742
+ serverUrl,
7743
+ featureTitle: project.name,
7744
+ worktreePath,
7745
+ pluginDir: resolvePluginDir(),
7746
+ settingsPath: artifacts.settingsPath,
7747
+ mcpConfigPath: artifacts.mcpConfigPath,
7748
+ systemPromptPath: artifacts.systemPromptPath,
7749
+ permissionMode: "default",
7750
+ model: resolveModel("project", ctx.config, project),
7751
+ resumeSessionId,
7752
+ strictMcp: ctx.config.sessionMcp === "runcastleOnly"
7753
+ };
7754
+ if (opts.spawn === false) {
7755
+ emitProject(ctx, project.id, {
7756
+ type: "session.launched",
7757
+ message: "project session prepared (terminal spawn skipped)",
7758
+ data: {
7759
+ sessionId: session.id,
7760
+ command: ["claude", ...buildClaudeArgs(buildInput)].join(" "),
7761
+ spawned: false
7762
+ }
7763
+ });
7764
+ return { sessionId: session.id };
7765
+ }
7766
+ spawnEmbeddedPty(ctx, undefined, session, worktreePath, serverUrl, buildClaudeArgs(buildInput), {
7767
+ resumeSessionId
7768
+ });
7769
+ return { sessionId: session.id };
7770
+ }
7326
7771
  async function buildPrepareBrief(ctx, project) {
7327
7772
  const findings = await listFindings(ctx, project);
7328
7773
  const notes = latestPrepNotes(ctx, project.id);
@@ -7408,6 +7853,7 @@ function handlePtyExit(ctx, feature, session, meta, exitCode) {
7408
7853
  const diedBeforeLive = getSessionRow(ctx, session.id)?.status === "launching";
7409
7854
  markSessionEnded(ctx, session.id);
7410
7855
  releaseForSession(ctx, session.id);
7856
+ landProjectSession(ctx, session);
7411
7857
  if (diedBeforeLive && meta.resumeSessionId) {
7412
7858
  const label = meta.waypoint ? `waypoint ${meta.waypoint.seq} (${meta.waypoint.title})` : session.kind;
7413
7859
  emitForSession(ctx, session, {
@@ -7516,6 +7962,12 @@ var init_feature = __esm(() => {
7516
7962
  oneLiner: z7.string(),
7517
7963
  baseBranch: z7.string().optional()
7518
7964
  })).mutation(({ ctx, input }) => createFeature(ctx, input)),
7965
+ quickChange: publicProcedure.input(z7.object({
7966
+ projectId: z7.string(),
7967
+ title: z7.string().min(1),
7968
+ prose: z7.string().min(1),
7969
+ baseBranch: z7.string().optional()
7970
+ })).mutation(({ ctx, input }) => quickChange(ctx, input)),
7519
7971
  list: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
7520
7972
  get: publicProcedure.input(z7.object({ id: z7.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
7521
7973
  launchSession: publicProcedure.input(z7.object({
@@ -7858,6 +8310,8 @@ var init_project = __esm(() => {
7858
8310
  }),
7859
8311
  talkToPrep: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
7860
8312
  prepSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
8313
+ talkToProject: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ ctx, input }) => launchProjectSession(ctx, { projectId: input.projectId })),
8314
+ projectSession: publicProcedure.input(z8.object({ projectId: z8.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "project")),
7861
8315
  cancelPrepare: publicProcedure.input(z8.object({ projectId: z8.string() })).mutation(({ input }) => {
7862
8316
  cancelPrep(input.projectId);
7863
8317
  return { ok: true };
@@ -7950,26 +8404,26 @@ function projectOverrides(ctx, projectId) {
7950
8404
  driveEnv: row?.driveEnv ?? null
7951
8405
  };
7952
8406
  }
7953
- function resolveField(desc5, layers) {
8407
+ function resolveField(desc6, layers) {
7954
8408
  const scoped = layers.overrides !== null;
7955
- const scope = scoped && desc5.projectColumn ? "project" : "global";
7956
- const base = { key: desc5.key, restartRequired: desc5.restartRequired, scope };
7957
- if (desc5.envVar) {
7958
- const raw = layers.env[desc5.envVar];
8409
+ const scope = scoped && desc6.projectColumn ? "project" : "global";
8410
+ const base = { key: desc6.key, restartRequired: desc6.restartRequired, scope };
8411
+ if (desc6.envVar) {
8412
+ const raw = layers.env[desc6.envVar];
7959
8413
  if (raw !== undefined && raw !== "") {
7960
- return { ...base, value: desc5.parseEnv(raw), source: "env", editable: false };
8414
+ return { ...base, value: desc6.parseEnv(raw), source: "env", editable: false };
7961
8415
  }
7962
8416
  }
7963
- if (layers.overrides && desc5.projectColumn) {
7964
- const ov = layers.overrides[desc5.projectColumn];
8417
+ if (layers.overrides && desc6.projectColumn) {
8418
+ const ov = layers.overrides[desc6.projectColumn];
7965
8419
  if (ov !== null && ov !== "") {
7966
8420
  return { ...base, value: ov, source: "project", editable: true };
7967
8421
  }
7968
8422
  }
7969
- if (desc5.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc5.configKey)) {
7970
- return { ...base, value: layers.fileRaw[desc5.configKey], source: "file", editable: true };
8423
+ if (desc6.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc6.configKey)) {
8424
+ return { ...base, value: layers.fileRaw[desc6.configKey], source: "file", editable: true };
7971
8425
  }
7972
- const value = desc5.configKey ? DEFAULTS[desc5.configKey] ?? null : null;
8426
+ const value = desc6.configKey ? DEFAULTS[desc6.configKey] ?? null : null;
7973
8427
  return { ...base, value, source: "default", editable: true };
7974
8428
  }
7975
8429
  function getSettings(ctx, projectId, io = {}) {
@@ -7986,56 +8440,56 @@ function updateSettings(ctx, input, io = {}) {
7986
8440
  if (input.key.startsWith("stepModels.")) {
7987
8441
  return updateStepModel(ctx, input, configFile, io);
7988
8442
  }
7989
- const desc5 = DESCRIPTORS.find((d) => d.key === input.key);
7990
- if (!desc5)
8443
+ const desc6 = DESCRIPTORS.find((d) => d.key === input.key);
8444
+ if (!desc6)
7991
8445
  throw new InvalidInputError(`unknown setting: ${input.key}`);
7992
- if (desc5.envVar) {
7993
- const raw = env[desc5.envVar];
8446
+ if (desc6.envVar) {
8447
+ const raw = env[desc6.envVar];
7994
8448
  if (raw !== undefined && raw !== "") {
7995
- throw new InvalidInputError(`${desc5.key} is set by environment variable ${desc5.envVar}; unset it to edit`);
8449
+ throw new InvalidInputError(`${desc6.key} is set by environment variable ${desc6.envVar}; unset it to edit`);
7996
8450
  }
7997
8451
  }
7998
- const toProject = input.projectId !== undefined && desc5.projectColumn !== undefined;
8452
+ const toProject = input.projectId !== undefined && desc6.projectColumn !== undefined;
7999
8453
  if (input.value === null) {
8000
8454
  if (!toProject)
8001
- throw new InvalidInputError(`${desc5.key} cannot be cleared`);
8455
+ throw new InvalidInputError(`${desc6.key} cannot be cleared`);
8002
8456
  const project = requireProjectById(ctx, input.projectId);
8003
- ctx.db.update(projects).set({ [desc5.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
8004
- recordHuman(ctx, project.id, desc5.key, null);
8457
+ ctx.db.update(projects).set({ [desc6.projectColumn]: null }).where(eq12(projects.id, project.id)).run();
8458
+ recordHuman(ctx, project.id, desc6.key, null);
8005
8459
  emitProject(ctx, project.id, {
8006
8460
  type: "settings.updated",
8007
- message: `${desc5.key} override cleared`,
8008
- data: { key: desc5.key, scope: "project", value: null }
8461
+ message: `${desc6.key} override cleared`,
8462
+ data: { key: desc6.key, scope: "project", value: null }
8009
8463
  });
8010
- return field(getSettings(ctx, input.projectId, io), desc5.key);
8464
+ return field(getSettings(ctx, input.projectId, io), desc6.key);
8011
8465
  }
8012
- const parsed = desc5.valueSchema.safeParse(input.value);
8466
+ const parsed = desc6.valueSchema.safeParse(input.value);
8013
8467
  if (!parsed.success) {
8014
- throw new InvalidInputError(`invalid value for ${desc5.key}: ${parsed.error.issues[0]?.message}`);
8468
+ throw new InvalidInputError(`invalid value for ${desc6.key}: ${parsed.error.issues[0]?.message}`);
8015
8469
  }
8016
8470
  const value = parsed.data;
8017
8471
  if (toProject) {
8018
8472
  const project = requireProjectById(ctx, input.projectId);
8019
- ctx.db.update(projects).set({ [desc5.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
8020
- recordHuman(ctx, project.id, desc5.key, String(value));
8473
+ ctx.db.update(projects).set({ [desc6.projectColumn]: String(value) }).where(eq12(projects.id, project.id)).run();
8474
+ recordHuman(ctx, project.id, desc6.key, String(value));
8021
8475
  emitProject(ctx, project.id, {
8022
8476
  type: "settings.updated",
8023
- message: `${desc5.key} override set to ${String(value)}`,
8024
- data: { key: desc5.key, scope: "project", value }
8477
+ message: `${desc6.key} override set to ${String(value)}`,
8478
+ data: { key: desc6.key, scope: "project", value }
8025
8479
  });
8026
- return field(getSettings(ctx, input.projectId, io), desc5.key);
8480
+ return field(getSettings(ctx, input.projectId, io), desc6.key);
8027
8481
  }
8028
- if (!desc5.configKey) {
8029
- throw new InvalidInputError(`${desc5.key} is a per-project setting; provide a projectId`);
8482
+ if (!desc6.configKey) {
8483
+ throw new InvalidInputError(`${desc6.key} is a per-project setting; provide a projectId`);
8030
8484
  }
8031
- writeGlobal(configFile, desc5.configKey, value);
8032
- ctx.config[desc5.configKey] = value;
8485
+ writeGlobal(configFile, desc6.configKey, value);
8486
+ ctx.config[desc6.configKey] = value;
8033
8487
  emitProject(ctx, GLOBAL_EVENT_KEY, {
8034
8488
  type: "settings.updated",
8035
- message: `${desc5.key} set to ${String(value)}`,
8036
- data: { key: desc5.key, scope: "global", value }
8489
+ message: `${desc6.key} set to ${String(value)}`,
8490
+ data: { key: desc6.key, scope: "global", value }
8037
8491
  });
8038
- return field(getSettings(ctx, input.projectId, io), desc5.key);
8492
+ return field(getSettings(ctx, input.projectId, io), desc6.key);
8039
8493
  }
8040
8494
  function writeGlobal(configFile, configKey, value) {
8041
8495
  const raw = readRawConfig(configFile);
@@ -8813,7 +9267,17 @@ var init_ticket = __esm(() => {
8813
9267
  }
8814
9268
  return { stopped: false, swept: false };
8815
9269
  }),
8816
- cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason))
9270
+ cancel: publicProcedure.input(z13.object({ ticketId: z13.string(), reason: z13.string().optional() })).mutation(({ ctx, input }) => cancelTicket(ctx, input.ticketId, input.reason)),
9271
+ edit: publicProcedure.input(z13.object({
9272
+ ticketId: z13.string(),
9273
+ title: z13.string().min(1).optional(),
9274
+ goal: z13.string().min(1).optional(),
9275
+ context: z13.string().optional(),
9276
+ acceptanceCriteria: z13.array(z13.string().min(1)).optional()
9277
+ })).mutation(({ ctx, input }) => {
9278
+ const { ticketId, ...patch } = input;
9279
+ return editTicket(ctx, ticketId, patch);
9280
+ })
8817
9281
  });
8818
9282
  });
8819
9283