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