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