runcastle 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/runcastle.js +936 -356
- package/drizzle/0016_red_nextwave.sql +1 -0
- package/drizzle/0017_certain_ezekiel_stane.sql +2 -0
- package/drizzle/meta/0016_snapshot.json +766 -0
- package/drizzle/meta/0017_snapshot.json +780 -0
- package/drizzle/meta/_journal.json +14 -0
- package/index.js +934 -354
- package/package.json +1 -1
- package/skills/packs/runcastle/skills/project/SKILL.md +10 -4
- package/skills/packs/runcastle/skills/tickets/SKILL.md +17 -14
- package/web/assets/index-D0dcZNJp.css +1 -0
- package/web/assets/index-g3KG__Hv.js +142 -0
- package/web/index.html +2 -2
- package/web/assets/index-BVNfUdrV.css +0 -1
- package/web/assets/index-BeUzCnxq.js +0 -141
package/bin/runcastle.js
CHANGED
|
@@ -108,7 +108,7 @@ function resolvePreparedSettings(config, project) {
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
function resolveModel(step, config, project, runOverride) {
|
|
111
|
-
return runOverride ?? config.stepModels[step] ??
|
|
111
|
+
return runOverride ?? project?.model ?? config.stepModels[step] ?? config.model;
|
|
112
112
|
}
|
|
113
113
|
var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5", foldLegacyModelConfig = (raw) => {
|
|
114
114
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
@@ -246,7 +246,7 @@ var init_ids = () => {};
|
|
|
246
246
|
|
|
247
247
|
// ../core/src/schemas.ts
|
|
248
248
|
import * as z2 from "zod";
|
|
249
|
-
var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, PREPARED_KEYS, PreparedKey, FindingSource, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
|
|
249
|
+
var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, PREPARED_KEYS, PreparedKey, DRIVE_LOOP_KEYS, FindingSource, PROJECT_NAME_MAX = 80, ProjectName, Project, ProjectFinding, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
|
|
250
250
|
var init_schemas = __esm(() => {
|
|
251
251
|
Phase = z2.enum([
|
|
252
252
|
"ideation",
|
|
@@ -319,7 +319,14 @@ var init_schemas = __esm(() => {
|
|
|
319
319
|
"dbResetCommand"
|
|
320
320
|
];
|
|
321
321
|
PreparedKey = z2.enum(PREPARED_KEYS);
|
|
322
|
+
DRIVE_LOOP_KEYS = [
|
|
323
|
+
"devCommand",
|
|
324
|
+
"driveSetupCommand",
|
|
325
|
+
"driveStopCommand",
|
|
326
|
+
"driveEnv"
|
|
327
|
+
];
|
|
322
328
|
FindingSource = z2.enum(["prep", "human", "session"]);
|
|
329
|
+
ProjectName = z2.string().min(1, "a project needs a name").max(PROJECT_NAME_MAX, `a project name can be at most ${PROJECT_NAME_MAX} characters`);
|
|
323
330
|
Project = z2.object({
|
|
324
331
|
id: z2.string(),
|
|
325
332
|
name: z2.string(),
|
|
@@ -341,7 +348,9 @@ var init_schemas = __esm(() => {
|
|
|
341
348
|
evidence: z2.string().optional(),
|
|
342
349
|
establishedAt: z2.number(),
|
|
343
350
|
establishedSha: z2.string().optional(),
|
|
344
|
-
staleCommits: z2.number().optional()
|
|
351
|
+
staleCommits: z2.number().optional(),
|
|
352
|
+
verifiedAt: z2.number().optional(),
|
|
353
|
+
verifiedSha: z2.string().optional()
|
|
345
354
|
});
|
|
346
355
|
Feature = z2.object({
|
|
347
356
|
id: z2.string(),
|
|
@@ -365,6 +374,7 @@ var init_schemas = __esm(() => {
|
|
|
365
374
|
ccSessionId: z2.string().optional(),
|
|
366
375
|
transcriptPath: z2.string().optional(),
|
|
367
376
|
status: SessionStatus,
|
|
377
|
+
awaitingInput: z2.boolean(),
|
|
368
378
|
worktreePath: z2.string()
|
|
369
379
|
});
|
|
370
380
|
Run = z2.object({
|
|
@@ -478,6 +488,12 @@ function nextPhase(feature) {
|
|
|
478
488
|
return null;
|
|
479
489
|
return ORDER[i + 1];
|
|
480
490
|
}
|
|
491
|
+
function previousPhase(feature) {
|
|
492
|
+
const i = ORDER.indexOf(feature.phase);
|
|
493
|
+
if (i <= 0)
|
|
494
|
+
return null;
|
|
495
|
+
return ORDER[i - 1];
|
|
496
|
+
}
|
|
481
497
|
function nextGate(feature) {
|
|
482
498
|
const i = ORDER.indexOf(feature.phase);
|
|
483
499
|
if (i < 0 || i >= ORDER.length - 1)
|
|
@@ -569,7 +585,9 @@ var init_db_schema = __esm(() => {
|
|
|
569
585
|
source: text("source").notNull().$type(),
|
|
570
586
|
evidence: text("evidence"),
|
|
571
587
|
establishedAt: integer("established_at").notNull(),
|
|
572
|
-
establishedSha: text("established_sha")
|
|
588
|
+
establishedSha: text("established_sha"),
|
|
589
|
+
verifiedAt: integer("verified_at"),
|
|
590
|
+
verifiedSha: text("verified_sha")
|
|
573
591
|
}, (t) => [primaryKey({ columns: [t.projectId, t.key] })]);
|
|
574
592
|
features = sqliteTable("features", {
|
|
575
593
|
id: text("id").primaryKey(),
|
|
@@ -594,6 +612,7 @@ var init_db_schema = __esm(() => {
|
|
|
594
612
|
ccSessionId: text("cc_session_id"),
|
|
595
613
|
transcriptPath: text("transcript_path"),
|
|
596
614
|
status: text("status").notNull().$type(),
|
|
615
|
+
awaitingInput: integer("awaiting_input", { mode: "boolean" }).notNull().default(false),
|
|
597
616
|
worktreePath: text("worktree_path").notNull()
|
|
598
617
|
});
|
|
599
618
|
tickets = sqliteTable("tickets", {
|
|
@@ -1360,7 +1379,7 @@ var init_bus = __esm(() => {
|
|
|
1360
1379
|
});
|
|
1361
1380
|
|
|
1362
1381
|
// src/services/events.ts
|
|
1363
|
-
import { and, asc, desc, eq, gt } from "drizzle-orm";
|
|
1382
|
+
import { and, asc, desc, eq, gt, max } from "drizzle-orm";
|
|
1364
1383
|
function rowToEvent(row) {
|
|
1365
1384
|
return {
|
|
1366
1385
|
id: row.id,
|
|
@@ -1390,6 +1409,9 @@ function emit(ctx, featureId, e) {
|
|
|
1390
1409
|
function emitProject(ctx, projectId, e) {
|
|
1391
1410
|
return insertEvent(ctx, projectId, null, e);
|
|
1392
1411
|
}
|
|
1412
|
+
function emitScoped(ctx, scope, e) {
|
|
1413
|
+
return "featureId" in scope ? emit(ctx, scope.featureId, e) : emitProject(ctx, scope.projectId, e);
|
|
1414
|
+
}
|
|
1393
1415
|
function emitForSession(ctx, session, e) {
|
|
1394
1416
|
if (session.featureId)
|
|
1395
1417
|
return emit(ctx, session.featureId, e);
|
|
@@ -1426,6 +1448,15 @@ function latestEventTs(ctx, featureId, type) {
|
|
|
1426
1448
|
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();
|
|
1427
1449
|
return row?.ts;
|
|
1428
1450
|
}
|
|
1451
|
+
function latestTsByFeature(ctx, projectId) {
|
|
1452
|
+
const rows = ctx.db.select({ featureId: events.featureId, ts: max(events.ts) }).from(events).where(eq(events.projectId, projectId)).groupBy(events.featureId).all();
|
|
1453
|
+
const out = new Map;
|
|
1454
|
+
for (const row of rows) {
|
|
1455
|
+
if (row.featureId !== null && row.ts !== null)
|
|
1456
|
+
out.set(row.featureId, row.ts);
|
|
1457
|
+
}
|
|
1458
|
+
return out;
|
|
1459
|
+
}
|
|
1429
1460
|
function listByProject(ctx, projectId, afterId = 0) {
|
|
1430
1461
|
const rows = ctx.db.select().from(events).where(and(eq(events.projectId, projectId), gt(events.id, afterId))).orderBy(asc(events.id)).all();
|
|
1431
1462
|
return rows.map(rowToEvent);
|
|
@@ -1491,6 +1522,7 @@ function rowToSession(row) {
|
|
|
1491
1522
|
ccSessionId: row.ccSessionId ?? undefined,
|
|
1492
1523
|
transcriptPath: row.transcriptPath ?? undefined,
|
|
1493
1524
|
status: row.status,
|
|
1525
|
+
awaitingInput: row.awaitingInput,
|
|
1494
1526
|
worktreePath: row.worktreePath
|
|
1495
1527
|
};
|
|
1496
1528
|
}
|
|
@@ -1607,7 +1639,7 @@ function storeWaypoints(ctx, featureId, inputs) {
|
|
|
1607
1639
|
if (inputs.length === 0)
|
|
1608
1640
|
return [];
|
|
1609
1641
|
const existing = listByFeature(ctx, featureId);
|
|
1610
|
-
const startSeq = existing.reduce((
|
|
1642
|
+
const startSeq = existing.reduce((max2, w) => Math.max(max2, w.seq), 0) + 1;
|
|
1611
1643
|
const seqById = new Map(existing.map((w) => [w.id, w.seq]));
|
|
1612
1644
|
const positionEdges = inputs.map((inp) => ({
|
|
1613
1645
|
blockedBy: inp.blockedBy.filter((b) => typeof b === "number")
|
|
@@ -1742,8 +1774,8 @@ var init_waypoints = __esm(() => {
|
|
|
1742
1774
|
});
|
|
1743
1775
|
|
|
1744
1776
|
// src/pty/dev-pane.ts
|
|
1745
|
-
function drivePaneId(
|
|
1746
|
-
return `${DRIVE_PREFIX}${
|
|
1777
|
+
function drivePaneId(ownerId) {
|
|
1778
|
+
return `${DRIVE_PREFIX}${ownerId}`;
|
|
1747
1779
|
}
|
|
1748
1780
|
function sniffDevUrl(text2) {
|
|
1749
1781
|
const m = text2.match(/https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?(?:\/[^\s"'<>`\x1b]*)?/i);
|
|
@@ -1756,8 +1788,8 @@ function devSpawnTarget(devCommand) {
|
|
|
1756
1788
|
return { file: "/bin/sh", args: ["-c", devCommand] };
|
|
1757
1789
|
}
|
|
1758
1790
|
function startDevPane(input) {
|
|
1759
|
-
const { ctx,
|
|
1760
|
-
const paneId = drivePaneId(featureId);
|
|
1791
|
+
const { ctx, scope, repoPath, devCommand, env, onUrl } = input;
|
|
1792
|
+
const paneId = drivePaneId("featureId" in scope ? scope.featureId : scope.projectId);
|
|
1761
1793
|
const { file, args } = devSpawnTarget(devCommand);
|
|
1762
1794
|
try {
|
|
1763
1795
|
ptyRegistry().create({
|
|
@@ -1767,7 +1799,7 @@ function startDevPane(input) {
|
|
|
1767
1799
|
opts: { cwd: repoPath, env: env ?? process.env, cols: 80, rows: 24, useConpty: true }
|
|
1768
1800
|
});
|
|
1769
1801
|
} catch (err) {
|
|
1770
|
-
|
|
1802
|
+
emitScoped(ctx, scope, {
|
|
1771
1803
|
type: "testdrive.dev_failed",
|
|
1772
1804
|
message: `dev server failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
|
1773
1805
|
data: { paneId }
|
|
@@ -1790,13 +1822,17 @@ function startDevPane(input) {
|
|
|
1790
1822
|
sendControl() {}
|
|
1791
1823
|
};
|
|
1792
1824
|
ptyRegistry().attach(paneId, sniffer);
|
|
1793
|
-
|
|
1825
|
+
emitScoped(ctx, scope, {
|
|
1794
1826
|
type: "testdrive.dev_started",
|
|
1795
1827
|
message: `dev server spawned: ${devCommand}`,
|
|
1796
1828
|
data: { paneId }
|
|
1797
1829
|
});
|
|
1798
1830
|
return paneId;
|
|
1799
1831
|
}
|
|
1832
|
+
function devPaneLive(paneId) {
|
|
1833
|
+
const entry = ptyRegistry().get(paneId);
|
|
1834
|
+
return !!entry && !entry.exited;
|
|
1835
|
+
}
|
|
1800
1836
|
function stopDevPane(paneId) {
|
|
1801
1837
|
const reg = ptyRegistry();
|
|
1802
1838
|
const entry = reg.get(paneId);
|
|
@@ -1816,13 +1852,13 @@ var init_dev_pane = __esm(() => {
|
|
|
1816
1852
|
|
|
1817
1853
|
// src/services/drive-hooks.ts
|
|
1818
1854
|
import { spawn as spawn2 } from "child_process";
|
|
1819
|
-
function tailLines(text2,
|
|
1855
|
+
function tailLines(text2, max2 = OUTPUT_TAIL_LINES) {
|
|
1820
1856
|
const lines = text2.replace(/\r\n/g, `
|
|
1821
1857
|
`).split(`
|
|
1822
1858
|
`);
|
|
1823
1859
|
while (lines.length > 0 && lines[lines.length - 1]?.trim() === "")
|
|
1824
1860
|
lines.pop();
|
|
1825
|
-
return lines.slice(-
|
|
1861
|
+
return lines.slice(-max2).join(`
|
|
1826
1862
|
`).trim();
|
|
1827
1863
|
}
|
|
1828
1864
|
function hookSpawnTarget(command) {
|
|
@@ -1911,13 +1947,13 @@ var init_drive_hooks = __esm(() => {
|
|
|
1911
1947
|
|
|
1912
1948
|
// src/services/drive-env.ts
|
|
1913
1949
|
import { createHash } from "crypto";
|
|
1914
|
-
function identifierSafe(slug,
|
|
1950
|
+
function identifierSafe(slug, max2 = MAX_ID_LENGTH) {
|
|
1915
1951
|
const cleaned = slug.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
1916
1952
|
const safe = cleaned === "" || /^[0-9]/.test(cleaned) ? `x_${cleaned}` : cleaned;
|
|
1917
|
-
if (safe.length <=
|
|
1953
|
+
if (safe.length <= max2)
|
|
1918
1954
|
return safe;
|
|
1919
1955
|
const digest = createHash("sha1").update(slug).digest("hex").slice(0, 6);
|
|
1920
|
-
return `${safe.slice(0,
|
|
1956
|
+
return `${safe.slice(0, max2 - digest.length - 1)}_${digest}`;
|
|
1921
1957
|
}
|
|
1922
1958
|
function driveVars(identity) {
|
|
1923
1959
|
return {
|
|
@@ -1983,6 +2019,144 @@ var init_drive_env = __esm(() => {
|
|
|
1983
2019
|
PLACEHOLDER_RE = /\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g;
|
|
1984
2020
|
});
|
|
1985
2021
|
|
|
2022
|
+
// src/services/findings.ts
|
|
2023
|
+
import { and as and4, eq as eq4 } from "drizzle-orm";
|
|
2024
|
+
function isPreparedKey(key) {
|
|
2025
|
+
return PREPARED_SET.has(key);
|
|
2026
|
+
}
|
|
2027
|
+
function findingRow(ctx, projectId, key) {
|
|
2028
|
+
return ctx.db.select({
|
|
2029
|
+
source: projectFindings.source,
|
|
2030
|
+
evidence: projectFindings.evidence,
|
|
2031
|
+
establishedAt: projectFindings.establishedAt,
|
|
2032
|
+
establishedSha: projectFindings.establishedSha
|
|
2033
|
+
}).from(projectFindings).where(and4(eq4(projectFindings.projectId, projectId), eq4(projectFindings.key, key))).get();
|
|
2034
|
+
}
|
|
2035
|
+
function isOverwritable(ctx, projectId, key) {
|
|
2036
|
+
return findingRow(ctx, projectId, key)?.source !== "human";
|
|
2037
|
+
}
|
|
2038
|
+
function recordFinding(ctx, projectId, input) {
|
|
2039
|
+
const { key, value, source, evidence, establishedSha } = input;
|
|
2040
|
+
ctx.db.transaction((tx) => {
|
|
2041
|
+
tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq4(projects.id, projectId)).run();
|
|
2042
|
+
if (value === null) {
|
|
2043
|
+
tx.delete(projectFindings).where(and4(eq4(projectFindings.projectId, projectId), eq4(projectFindings.key, key))).run();
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
tx.insert(projectFindings).values({
|
|
2047
|
+
projectId,
|
|
2048
|
+
key,
|
|
2049
|
+
source,
|
|
2050
|
+
evidence: evidence ?? null,
|
|
2051
|
+
establishedAt: Date.now(),
|
|
2052
|
+
establishedSha: establishedSha ?? null,
|
|
2053
|
+
...UNVERIFIED
|
|
2054
|
+
}).onConflictDoUpdate({
|
|
2055
|
+
target: [projectFindings.projectId, projectFindings.key],
|
|
2056
|
+
set: {
|
|
2057
|
+
source,
|
|
2058
|
+
evidence: evidence ?? null,
|
|
2059
|
+
establishedAt: Date.now(),
|
|
2060
|
+
establishedSha: establishedSha ?? null,
|
|
2061
|
+
...UNVERIFIED
|
|
2062
|
+
}
|
|
2063
|
+
}).run();
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
function recordHuman(ctx, projectId, key, value) {
|
|
2067
|
+
if (!isPreparedKey(key))
|
|
2068
|
+
return;
|
|
2069
|
+
const existing = findingRow(ctx, projectId, key);
|
|
2070
|
+
if (value === null) {
|
|
2071
|
+
if (existing) {
|
|
2072
|
+
ctx.db.delete(projectFindings).where(and4(eq4(projectFindings.projectId, projectId), eq4(projectFindings.key, key))).run();
|
|
2073
|
+
}
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
ctx.db.insert(projectFindings).values({
|
|
2077
|
+
projectId,
|
|
2078
|
+
key,
|
|
2079
|
+
source: "human",
|
|
2080
|
+
evidence: null,
|
|
2081
|
+
establishedAt: Date.now(),
|
|
2082
|
+
establishedSha: null,
|
|
2083
|
+
...UNVERIFIED
|
|
2084
|
+
}).onConflictDoUpdate({
|
|
2085
|
+
target: [projectFindings.projectId, projectFindings.key],
|
|
2086
|
+
set: {
|
|
2087
|
+
source: "human",
|
|
2088
|
+
evidence: null,
|
|
2089
|
+
establishedAt: Date.now(),
|
|
2090
|
+
establishedSha: null,
|
|
2091
|
+
...UNVERIFIED
|
|
2092
|
+
}
|
|
2093
|
+
}).run();
|
|
2094
|
+
}
|
|
2095
|
+
function markVerified(ctx, projectId, keys, sha) {
|
|
2096
|
+
const verifiedAt = Date.now();
|
|
2097
|
+
for (const key of keys) {
|
|
2098
|
+
ctx.db.update(projectFindings).set({ verifiedAt, verifiedSha: sha }).where(and4(eq4(projectFindings.projectId, projectId), eq4(projectFindings.key, key))).run();
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
async function listFindings(ctx, project) {
|
|
2102
|
+
const rows = ctx.db.select().from(projectFindings).where(eq4(projectFindings.projectId, project.id)).all();
|
|
2103
|
+
const distances = new Map;
|
|
2104
|
+
for (const row of rows) {
|
|
2105
|
+
const sha = row.establishedSha;
|
|
2106
|
+
if (!sha || distances.has(sha))
|
|
2107
|
+
continue;
|
|
2108
|
+
distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
|
|
2109
|
+
}
|
|
2110
|
+
const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
|
|
2111
|
+
return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
|
|
2112
|
+
const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
|
|
2113
|
+
return {
|
|
2114
|
+
key: row.key,
|
|
2115
|
+
source: row.source,
|
|
2116
|
+
...row.evidence ? { evidence: row.evidence } : {},
|
|
2117
|
+
establishedAt: row.establishedAt,
|
|
2118
|
+
...row.establishedSha ? { establishedSha: row.establishedSha } : {},
|
|
2119
|
+
...staleCommits !== undefined ? { staleCommits } : {},
|
|
2120
|
+
...row.verifiedAt !== null ? { verifiedAt: row.verifiedAt } : {},
|
|
2121
|
+
...row.verifiedSha ? { verifiedSha: row.verifiedSha } : {}
|
|
2122
|
+
};
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
function unsetPreparedKeys(project) {
|
|
2126
|
+
return PREPARED_KEYS.filter((key) => {
|
|
2127
|
+
const value = project[key];
|
|
2128
|
+
return value === undefined || value.trim() === "";
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET, UNVERIFIED;
|
|
2132
|
+
var init_findings = __esm(() => {
|
|
2133
|
+
init_src();
|
|
2134
|
+
init_schema();
|
|
2135
|
+
init_git();
|
|
2136
|
+
VALUE_COLUMN = {
|
|
2137
|
+
setupCommand: projects.setupCommand,
|
|
2138
|
+
verifyCommands: projects.verifyCommands,
|
|
2139
|
+
knownFailures: projects.knownFailures,
|
|
2140
|
+
devCommand: projects.devCommand,
|
|
2141
|
+
driveSetupCommand: projects.driveSetupCommand,
|
|
2142
|
+
driveStopCommand: projects.driveStopCommand,
|
|
2143
|
+
driveEnv: projects.driveEnv,
|
|
2144
|
+
dbResetCommand: projects.dbResetCommand
|
|
2145
|
+
};
|
|
2146
|
+
COLUMN_NAME = {
|
|
2147
|
+
setupCommand: "setupCommand",
|
|
2148
|
+
verifyCommands: "verifyCommands",
|
|
2149
|
+
knownFailures: "knownFailures",
|
|
2150
|
+
devCommand: "devCommand",
|
|
2151
|
+
driveSetupCommand: "driveSetupCommand",
|
|
2152
|
+
driveStopCommand: "driveStopCommand",
|
|
2153
|
+
driveEnv: "driveEnv",
|
|
2154
|
+
dbResetCommand: "dbResetCommand"
|
|
2155
|
+
};
|
|
2156
|
+
PREPARED_SET = new Set(PREPARED_KEYS);
|
|
2157
|
+
UNVERIFIED = { verifiedAt: null, verifiedSha: null };
|
|
2158
|
+
});
|
|
2159
|
+
|
|
1986
2160
|
// src/services/git.ts
|
|
1987
2161
|
import { existsSync as existsSync5, mkdirSync as mkdirSync2, mkdtempSync, realpathSync, rmSync } from "fs";
|
|
1988
2162
|
import { tmpdir } from "os";
|
|
@@ -1994,6 +2168,9 @@ function migrationPaths(paths) {
|
|
|
1994
2168
|
function featureBranch(slug) {
|
|
1995
2169
|
return `feature/${slug}`;
|
|
1996
2170
|
}
|
|
2171
|
+
function mergeTarget(project, feature) {
|
|
2172
|
+
return feature.baseBranch ?? project.mainBranch;
|
|
2173
|
+
}
|
|
1997
2174
|
function git(repoPath) {
|
|
1998
2175
|
return simpleGit(repoPath);
|
|
1999
2176
|
}
|
|
@@ -2097,6 +2274,10 @@ async function ensureTalkWorktree(project, feature) {
|
|
|
2097
2274
|
await ensureBranchExists(g, branch, feature.baseBranch ?? project.mainBranch);
|
|
2098
2275
|
if (await worktreeIsValid(g, worktreePath, branch))
|
|
2099
2276
|
return worktreePath;
|
|
2277
|
+
if (existsSync5(worktreePath) && (await registeredWorktrees(g)).has(canon(worktreePath))) {
|
|
2278
|
+
if (await checkoutInWorktree(worktreePath, branch))
|
|
2279
|
+
return worktreePath;
|
|
2280
|
+
}
|
|
2100
2281
|
return addWorktree(g, worktreePath, branch, "talk worktree");
|
|
2101
2282
|
}
|
|
2102
2283
|
async function addWorktree(g, worktreePath, branch, label) {
|
|
@@ -2270,6 +2451,25 @@ async function branchCommitsAhead(repoPath, base, branch) {
|
|
|
2270
2451
|
return [];
|
|
2271
2452
|
}
|
|
2272
2453
|
}
|
|
2454
|
+
async function reviewCommitCount(project, feature) {
|
|
2455
|
+
const base = mergeTarget(project, feature);
|
|
2456
|
+
const branch = featureBranch(feature.slug);
|
|
2457
|
+
try {
|
|
2458
|
+
const out = (await git(project.repoPath).raw(["rev-list", "--count", `${base}..${branch}`])).trim();
|
|
2459
|
+
const n = Number(out);
|
|
2460
|
+
return { base, count: Number.isInteger(n) && n >= 0 ? n : undefined };
|
|
2461
|
+
} catch {
|
|
2462
|
+
return { base };
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
async function headSha(repoPath, ref) {
|
|
2466
|
+
try {
|
|
2467
|
+
const out = (await git(repoPath).revparse([ref])).trim();
|
|
2468
|
+
return out.length > 0 ? out : undefined;
|
|
2469
|
+
} catch {
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2273
2473
|
async function commitsSince(repoPath, sha, branch) {
|
|
2274
2474
|
try {
|
|
2275
2475
|
const out = (await git(repoPath).raw(["rev-list", "--count", `${sha}..${branch}`])).trim();
|
|
@@ -2535,20 +2735,23 @@ async function commitDocs(worktreePath, message) {
|
|
|
2535
2735
|
await g.commit(message, [DOCS_PATHSPEC]);
|
|
2536
2736
|
}
|
|
2537
2737
|
function activeTestDriveFeatureId() {
|
|
2538
|
-
return testDriveState?.featureId;
|
|
2738
|
+
return testDriveState?.kind === "feature" ? testDriveState.featureId : undefined;
|
|
2539
2739
|
}
|
|
2540
2740
|
function activeDriveInfo() {
|
|
2541
2741
|
if (!testDriveState)
|
|
2542
2742
|
return null;
|
|
2543
2743
|
return {
|
|
2544
|
-
featureId: testDriveState.featureId,
|
|
2744
|
+
...testDriveState.kind === "feature" ? { featureId: testDriveState.featureId } : { dryRun: true },
|
|
2545
2745
|
branch: testDriveState.branch,
|
|
2546
2746
|
devPaneId: testDriveState.devPaneId,
|
|
2547
|
-
devUrl: testDriveState.devUrl
|
|
2747
|
+
devUrl: testDriveState.devUrl,
|
|
2748
|
+
devConfigured: testDriveState.devConfigured
|
|
2548
2749
|
};
|
|
2549
2750
|
}
|
|
2550
2751
|
function recordDriveUrl(ctx, featureId, url) {
|
|
2551
|
-
if (
|
|
2752
|
+
if (testDriveState?.kind !== "feature")
|
|
2753
|
+
return;
|
|
2754
|
+
if (testDriveState.featureId !== featureId || testDriveState.devUrl)
|
|
2552
2755
|
return;
|
|
2553
2756
|
testDriveState.devUrl = url;
|
|
2554
2757
|
emit(ctx, featureId, {
|
|
@@ -2560,15 +2763,18 @@ function recordDriveUrl(ctx, featureId, url) {
|
|
|
2560
2763
|
async function testDrive(ctx, project, feature, action) {
|
|
2561
2764
|
const g = git(project.repoPath);
|
|
2562
2765
|
const branch = featureBranch(feature.slug);
|
|
2766
|
+
const scope = { featureId: feature.id };
|
|
2563
2767
|
if (action === "stop") {
|
|
2564
2768
|
if (!testDriveState)
|
|
2565
2769
|
return { ok: false, deniedReason: DENY_NONE_ACTIVE };
|
|
2770
|
+
if (testDriveState.kind !== "feature")
|
|
2771
|
+
return { ok: false, deniedReason: DENY_DRY_RUN_ACTIVE };
|
|
2566
2772
|
const previousBranch2 = testDriveState.previousBranch;
|
|
2567
2773
|
const detachedWorktree2 = testDriveState.detachedWorktree;
|
|
2568
2774
|
const devPaneId = testDriveState.devPaneId;
|
|
2569
2775
|
if (devPaneId)
|
|
2570
2776
|
stopDevPane(devPaneId);
|
|
2571
|
-
const
|
|
2777
|
+
const teardown = await runDriveHookStep(ctx, scope, project.repoPath, "teardown", project.driveStopCommand, driveEnvFor(ctx, project, { slug: feature.slug, branch }, scope).env);
|
|
2572
2778
|
const carriedChanges = await dirtyPaths(g);
|
|
2573
2779
|
await g.checkout(previousBranch2);
|
|
2574
2780
|
if (detachedWorktree2)
|
|
@@ -2592,14 +2798,18 @@ async function testDrive(ctx, project, feature, action) {
|
|
|
2592
2798
|
branch: previousBranch2,
|
|
2593
2799
|
...carriedChanges.length > 0 ? { carriedChanges } : {},
|
|
2594
2800
|
...dbDrift ? { dbDrift } : {},
|
|
2595
|
-
...
|
|
2801
|
+
...teardown?.failure ? { hookFailure: teardown.failure } : {}
|
|
2596
2802
|
};
|
|
2597
2803
|
}
|
|
2598
2804
|
const porcelain = (await g.raw(["status", "--porcelain"])).trim();
|
|
2599
2805
|
if (porcelain !== "")
|
|
2600
2806
|
return { ok: false, deniedReason: DENY_DIRTY };
|
|
2601
|
-
if (testDriveState)
|
|
2602
|
-
return {
|
|
2807
|
+
if (testDriveState) {
|
|
2808
|
+
return {
|
|
2809
|
+
ok: false,
|
|
2810
|
+
deniedReason: testDriveState.kind === "dryRun" ? DENY_DRY_RUN_ACTIVE : DENY_ACTIVE
|
|
2811
|
+
};
|
|
2812
|
+
}
|
|
2603
2813
|
if (hasActiveRun(ctx, feature.id))
|
|
2604
2814
|
return { ok: false, deniedReason: DENY_ACTIVE_RUN };
|
|
2605
2815
|
const talkWorktree = worktreeDir(project.id, feature.slug);
|
|
@@ -2611,18 +2821,25 @@ async function testDrive(ctx, project, feature, action) {
|
|
|
2611
2821
|
}
|
|
2612
2822
|
const previousBranch = (await g.revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
2613
2823
|
await g.checkout(branch);
|
|
2614
|
-
testDriveState = {
|
|
2824
|
+
testDriveState = {
|
|
2825
|
+
kind: "feature",
|
|
2826
|
+
featureId: feature.id,
|
|
2827
|
+
branch,
|
|
2828
|
+
previousBranch,
|
|
2829
|
+
detachedWorktree,
|
|
2830
|
+
devConfigured: !!project.devCommand
|
|
2831
|
+
};
|
|
2615
2832
|
emit(ctx, feature.id, {
|
|
2616
2833
|
type: "testdrive.started",
|
|
2617
2834
|
message: `test driving ${branch} (was on ${previousBranch})`,
|
|
2618
2835
|
data: { branch, previousBranch }
|
|
2619
2836
|
});
|
|
2620
|
-
const driveEnv = driveEnvFor(ctx, project, feature, branch);
|
|
2621
|
-
const
|
|
2837
|
+
const driveEnv = driveEnvFor(ctx, project, { slug: feature.slug, branch }, scope).env;
|
|
2838
|
+
const setup = await runDriveHookStep(ctx, scope, project.repoPath, "setup", project.driveSetupCommand, driveEnv);
|
|
2622
2839
|
if (project.devCommand) {
|
|
2623
2840
|
const devPaneId = startDevPane({
|
|
2624
2841
|
ctx,
|
|
2625
|
-
|
|
2842
|
+
scope,
|
|
2626
2843
|
repoPath: project.repoPath,
|
|
2627
2844
|
devCommand: project.devCommand,
|
|
2628
2845
|
env: driveEnv,
|
|
@@ -2631,45 +2848,188 @@ async function testDrive(ctx, project, feature, action) {
|
|
|
2631
2848
|
if (devPaneId)
|
|
2632
2849
|
testDriveState.devPaneId = devPaneId;
|
|
2633
2850
|
}
|
|
2634
|
-
return { ok: true, branch, ...
|
|
2851
|
+
return { ok: true, branch, ...setup?.failure ? { hookFailure: setup.failure } : {} };
|
|
2635
2852
|
}
|
|
2636
|
-
function
|
|
2637
|
-
|
|
2853
|
+
async function dryRunDrive(ctx, project, action) {
|
|
2854
|
+
if (action === "start")
|
|
2855
|
+
return startDryRun(ctx, project);
|
|
2856
|
+
if (testDriveState?.kind !== "dryRun") {
|
|
2857
|
+
return { ok: false, action, deniedReason: DENY_NO_DRY_RUN };
|
|
2858
|
+
}
|
|
2859
|
+
const state = testDriveState;
|
|
2860
|
+
if (action === "status")
|
|
2861
|
+
return { ok: true, action, ...liveFields(state) };
|
|
2862
|
+
return stopDryRun(ctx, project, state);
|
|
2863
|
+
}
|
|
2864
|
+
async function startDryRun(ctx, project) {
|
|
2865
|
+
if (testDriveState) {
|
|
2866
|
+
return {
|
|
2867
|
+
ok: false,
|
|
2868
|
+
action: "start",
|
|
2869
|
+
deniedReason: testDriveState.kind === "dryRun" ? DENY_DRY_RUN_ACTIVE : DENY_ACTIVE
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
const scope = { projectId: project.id };
|
|
2873
|
+
const branch = (await git(project.repoPath).revparse(["--abbrev-ref", "HEAD"])).trim();
|
|
2874
|
+
const identity = { slug: DRY_RUN_SLUG, branch };
|
|
2875
|
+
emitProject(ctx, project.id, {
|
|
2876
|
+
type: "prep.dryrun.started",
|
|
2877
|
+
message: `preparation dry-run drive on ${branch} as \`${DRY_RUN_SLUG}\``,
|
|
2878
|
+
data: { branch, slug: DRY_RUN_SLUG }
|
|
2879
|
+
});
|
|
2880
|
+
const { env, keys, unknown: unknown2 } = driveEnvFor(ctx, project, identity, scope);
|
|
2881
|
+
const state = {
|
|
2882
|
+
kind: "dryRun",
|
|
2883
|
+
projectId: project.id,
|
|
2884
|
+
branch,
|
|
2885
|
+
devConfigured: !!project.devCommand,
|
|
2886
|
+
env,
|
|
2887
|
+
observed: { envUnknowns: unknown2 }
|
|
2888
|
+
};
|
|
2889
|
+
testDriveState = state;
|
|
2890
|
+
const setup = await runDriveHookStep(ctx, scope, project.repoPath, "setup", project.driveSetupCommand, env);
|
|
2891
|
+
state.observed.setupOk = setup?.result.ok;
|
|
2892
|
+
if (project.devCommand) {
|
|
2893
|
+
const devPaneId = startDevPane({
|
|
2894
|
+
ctx,
|
|
2895
|
+
scope,
|
|
2896
|
+
repoPath: project.repoPath,
|
|
2897
|
+
devCommand: project.devCommand,
|
|
2898
|
+
env,
|
|
2899
|
+
onUrl: (url) => recordDryRunUrl(ctx, project.id, url)
|
|
2900
|
+
});
|
|
2901
|
+
if (devPaneId)
|
|
2902
|
+
state.devPaneId = devPaneId;
|
|
2903
|
+
}
|
|
2904
|
+
return {
|
|
2905
|
+
ok: true,
|
|
2906
|
+
action: "start",
|
|
2907
|
+
envKeys: keys,
|
|
2908
|
+
envUnknowns: unknown2,
|
|
2909
|
+
...setup ? { setup: hookReport(setup) } : {},
|
|
2910
|
+
...liveFields(state)
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
async function stopDryRun(ctx, project, state) {
|
|
2914
|
+
const scope = { projectId: project.id };
|
|
2915
|
+
if (state.devPaneId)
|
|
2916
|
+
stopDevPane(state.devPaneId);
|
|
2917
|
+
const teardown = await runDriveHookStep(ctx, scope, project.repoPath, "teardown", project.driveStopCommand, state.env);
|
|
2918
|
+
state.observed.teardownOk = teardown?.result.ok;
|
|
2919
|
+
testDriveState = undefined;
|
|
2920
|
+
emitProject(ctx, project.id, {
|
|
2921
|
+
type: "prep.dryrun.stopped",
|
|
2922
|
+
message: `preparation dry-run drive stopped on ${state.branch}`,
|
|
2923
|
+
data: { branch: state.branch }
|
|
2924
|
+
});
|
|
2925
|
+
const { participating, failure } = dryRunVerdict(project, state);
|
|
2926
|
+
if (!failure && participating.length > 0) {
|
|
2927
|
+
const sha = await headSha(project.repoPath, project.mainBranch) ?? null;
|
|
2928
|
+
markVerified(ctx, project.id, participating, sha);
|
|
2929
|
+
emitProject(ctx, project.id, {
|
|
2930
|
+
type: "prep.dryrun.verified",
|
|
2931
|
+
message: `dry-run drive verified ${participating.join(", ")}`,
|
|
2932
|
+
data: { keys: participating, sha }
|
|
2933
|
+
});
|
|
2934
|
+
}
|
|
2935
|
+
return {
|
|
2936
|
+
ok: true,
|
|
2937
|
+
action: "stop",
|
|
2938
|
+
identity: { slug: DRY_RUN_SLUG, branch: state.branch },
|
|
2939
|
+
envUnknowns: state.observed.envUnknowns,
|
|
2940
|
+
...teardown ? { teardown: hookReport(teardown) } : {},
|
|
2941
|
+
verified: failure ? [] : participating,
|
|
2942
|
+
...failure ? { failure } : {}
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
function recordDryRunUrl(ctx, projectId, url) {
|
|
2946
|
+
if (testDriveState?.kind !== "dryRun")
|
|
2947
|
+
return;
|
|
2948
|
+
if (testDriveState.projectId !== projectId || testDriveState.devUrl)
|
|
2949
|
+
return;
|
|
2950
|
+
testDriveState.devUrl = url;
|
|
2951
|
+
emitProject(ctx, projectId, {
|
|
2952
|
+
type: "prep.dryrun.url",
|
|
2953
|
+
message: `dry-run dev server ready at ${url}`,
|
|
2954
|
+
data: { url }
|
|
2955
|
+
});
|
|
2956
|
+
}
|
|
2957
|
+
function dryRunVerdict(project, state) {
|
|
2958
|
+
const participating = [];
|
|
2959
|
+
let failure;
|
|
2960
|
+
for (const key of DRIVE_LOOP_KEYS) {
|
|
2961
|
+
if (!project[key]?.trim())
|
|
2962
|
+
continue;
|
|
2963
|
+
participating.push(key);
|
|
2964
|
+
failure ??= observableFailure(key, state);
|
|
2965
|
+
}
|
|
2966
|
+
return { participating, ...failure ? { failure } : {} };
|
|
2967
|
+
}
|
|
2968
|
+
function observableFailure(key, state) {
|
|
2969
|
+
const { envUnknowns, setupOk, teardownOk } = state.observed;
|
|
2970
|
+
switch (key) {
|
|
2971
|
+
case "driveEnv":
|
|
2972
|
+
return envUnknowns.length === 0 ? undefined : `driveEnv left ${envUnknowns.map((u) => `{{${u}}}`).join(", ")} unsubstituted`;
|
|
2973
|
+
case "driveSetupCommand":
|
|
2974
|
+
return setupOk ? undefined : "driveSetupCommand did not exit 0";
|
|
2975
|
+
case "devCommand":
|
|
2976
|
+
if (!state.devPaneId)
|
|
2977
|
+
return "devCommand never spawned a dev pane";
|
|
2978
|
+
return state.devUrl ? undefined : 'devCommand spawned but printed no localhost URL \u2014 "Open app" depends on one';
|
|
2979
|
+
case "driveStopCommand":
|
|
2980
|
+
return teardownOk ? undefined : "driveStopCommand did not exit 0";
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
function liveFields(state) {
|
|
2984
|
+
return {
|
|
2985
|
+
identity: { slug: DRY_RUN_SLUG, branch: state.branch },
|
|
2986
|
+
devConfigured: state.devConfigured,
|
|
2987
|
+
devPaneLive: !!state.devPaneId && devPaneLive(state.devPaneId),
|
|
2988
|
+
...state.devUrl ? { devUrl: state.devUrl } : {}
|
|
2989
|
+
};
|
|
2990
|
+
}
|
|
2991
|
+
function hookReport(run) {
|
|
2992
|
+
const { ok, exitCode, timedOut, output } = run.result;
|
|
2993
|
+
return { command: run.command, ok, exitCode, timedOut, output };
|
|
2994
|
+
}
|
|
2995
|
+
function driveEnvFor(ctx, project, identity, scope) {
|
|
2996
|
+
const { vars, unknown: unknown2 } = parseDriveEnv(project.driveEnv, identity);
|
|
2638
2997
|
if (unknown2.length > 0) {
|
|
2639
|
-
|
|
2998
|
+
emitScoped(ctx, scope, {
|
|
2640
2999
|
type: "testdrive.env_unknown_placeholder",
|
|
2641
3000
|
message: `drive environment left ${unknown2.map((u) => `{{${u}}}`).join(", ")} unsubstituted \u2014 known variables are {{slug}}, {{branch}}, {{id}}`,
|
|
2642
3001
|
data: { unknown: unknown2 }
|
|
2643
3002
|
});
|
|
2644
3003
|
}
|
|
2645
|
-
|
|
2646
|
-
|
|
3004
|
+
const keys = Object.keys(vars);
|
|
3005
|
+
if (keys.length > 0) {
|
|
3006
|
+
emitScoped(ctx, scope, {
|
|
2647
3007
|
type: "testdrive.env",
|
|
2648
3008
|
message: describeDriveEnv(vars),
|
|
2649
|
-
data: { keys
|
|
3009
|
+
data: { keys }
|
|
2650
3010
|
});
|
|
2651
3011
|
}
|
|
2652
|
-
return driveProcessEnv(vars);
|
|
3012
|
+
return { env: driveProcessEnv(vars), keys, unknown: unknown2 };
|
|
2653
3013
|
}
|
|
2654
|
-
async function runDriveHookStep(ctx,
|
|
3014
|
+
async function runDriveHookStep(ctx, scope, repoPath, phase, command, env) {
|
|
2655
3015
|
const cmd = command?.trim();
|
|
2656
3016
|
if (!cmd)
|
|
2657
3017
|
return;
|
|
2658
|
-
|
|
3018
|
+
emitScoped(ctx, scope, {
|
|
2659
3019
|
type: `testdrive.${phase}_started`,
|
|
2660
3020
|
message: phase === "setup" ? `preparing environment: \`${cmd}\`` : `tearing down environment: \`${cmd}\``,
|
|
2661
3021
|
data: { command: cmd }
|
|
2662
3022
|
});
|
|
2663
3023
|
const result = await runDriveHook(cmd, { cwd: repoPath, env });
|
|
2664
3024
|
if (result.ok) {
|
|
2665
|
-
|
|
3025
|
+
emitScoped(ctx, scope, {
|
|
2666
3026
|
type: `testdrive.${phase}_ok`,
|
|
2667
3027
|
message: describeHookResult(cmd, result),
|
|
2668
3028
|
data: { command: cmd, durationMs: result.durationMs }
|
|
2669
3029
|
});
|
|
2670
|
-
return;
|
|
3030
|
+
return { command: cmd, result };
|
|
2671
3031
|
}
|
|
2672
|
-
|
|
3032
|
+
emitScoped(ctx, scope, {
|
|
2673
3033
|
type: `testdrive.${phase}_failed`,
|
|
2674
3034
|
message: `${describeHookResult(cmd, result)}${result.output ? `
|
|
2675
3035
|
${result.output}` : ""}`,
|
|
@@ -2681,11 +3041,15 @@ ${result.output}` : ""}`,
|
|
|
2681
3041
|
}
|
|
2682
3042
|
});
|
|
2683
3043
|
return {
|
|
2684
|
-
phase,
|
|
2685
3044
|
command: cmd,
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
3045
|
+
result,
|
|
3046
|
+
failure: {
|
|
3047
|
+
phase,
|
|
3048
|
+
command: cmd,
|
|
3049
|
+
exitCode: result.exitCode,
|
|
3050
|
+
timedOut: result.timedOut,
|
|
3051
|
+
output: result.output
|
|
3052
|
+
}
|
|
2689
3053
|
};
|
|
2690
3054
|
}
|
|
2691
3055
|
async function dirtyPaths(g) {
|
|
@@ -2718,7 +3082,7 @@ async function detectDbDrift(ctx, project, feature, previousBranch, driveBranch)
|
|
|
2718
3082
|
return drift;
|
|
2719
3083
|
}
|
|
2720
3084
|
async function mergeFeature(project, feature) {
|
|
2721
|
-
const target = feature
|
|
3085
|
+
const target = mergeTarget(project, feature);
|
|
2722
3086
|
if (testDriveState) {
|
|
2723
3087
|
throw new GateError("Cannot merge while a test drive is active \u2014 stop it first");
|
|
2724
3088
|
}
|
|
@@ -2772,14 +3136,16 @@ async function mergeInProgress(g) {
|
|
|
2772
3136
|
return false;
|
|
2773
3137
|
}
|
|
2774
3138
|
}
|
|
2775
|
-
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/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
|
|
3139
|
+
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", DENY_DRY_RUN_ACTIVE = "A preparation dry-run is in progress \u2014 stop it first", DENY_NO_DRY_RUN = "No preparation dry-run is in progress", RESEARCH_BRANCH_PREFIX = "runcastle/research/", TICKET_BRANCH_PREFIX = "runcastle/ticket/", PROJECT_BRANCH = "runcastle/project", TEMP_BRANCH_PREFIXES, TEMP_BRANCH_SLUG_MAX = 16, testDriveState, DRY_RUN_SLUG = "prep-dry-run";
|
|
2776
3140
|
var init_git = __esm(() => {
|
|
3141
|
+
init_src();
|
|
2777
3142
|
init_paths();
|
|
2778
3143
|
init_errors();
|
|
2779
3144
|
init_dev_pane();
|
|
2780
3145
|
init_drive_hooks();
|
|
2781
3146
|
init_drive_env();
|
|
2782
3147
|
init_events();
|
|
3148
|
+
init_findings();
|
|
2783
3149
|
init_repo();
|
|
2784
3150
|
MIGRATION_DIR_RE = /(^|\/)(migrations|migrate)\//i;
|
|
2785
3151
|
DRIZZLE_SQL_RE = /(^|\/)drizzle\/.*\.sql$/i;
|
|
@@ -2787,7 +3153,7 @@ var init_git = __esm(() => {
|
|
|
2787
3153
|
});
|
|
2788
3154
|
|
|
2789
3155
|
// src/launcher/sessions.ts
|
|
2790
|
-
import { and as
|
|
3156
|
+
import { and as and5, desc as desc3, eq as eq5, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
|
|
2791
3157
|
function createSessionRow(ctx, input) {
|
|
2792
3158
|
const row = ctx.db.insert(sessions).values({
|
|
2793
3159
|
id: newId("sess"),
|
|
@@ -2803,11 +3169,20 @@ function createSessionRow(ctx, input) {
|
|
|
2803
3169
|
return rowToSession(row);
|
|
2804
3170
|
}
|
|
2805
3171
|
function getSessionRow(ctx, id) {
|
|
2806
|
-
const row = ctx.db.select().from(sessions).where(
|
|
3172
|
+
const row = ctx.db.select().from(sessions).where(eq5(sessions.id, id)).get();
|
|
2807
3173
|
return row ? rowToSession(row) : null;
|
|
2808
3174
|
}
|
|
2809
3175
|
function activeSessionsForFeature(ctx, featureId) {
|
|
2810
|
-
return ctx.db.select().from(sessions).where(
|
|
3176
|
+
return ctx.db.select().from(sessions).where(and5(eq5(sessions.featureId, featureId), inArray(sessions.status, ["launching", "live"]))).all().map(rowToSession);
|
|
3177
|
+
}
|
|
3178
|
+
function markAgentWorking(ctx, id) {
|
|
3179
|
+
setAwaitingInput(ctx, id, false);
|
|
3180
|
+
}
|
|
3181
|
+
function markAwaitingInput(ctx, id) {
|
|
3182
|
+
setAwaitingInput(ctx, id, true);
|
|
3183
|
+
}
|
|
3184
|
+
function setAwaitingInput(ctx, id, awaitingInput) {
|
|
3185
|
+
ctx.db.update(sessions).set({ awaitingInput }).where(eq5(sessions.id, id)).run();
|
|
2811
3186
|
}
|
|
2812
3187
|
function markSessionLive(ctx, id, input = {}) {
|
|
2813
3188
|
const existing = getSessionRow(ctx, id);
|
|
@@ -2817,7 +3192,7 @@ function markSessionLive(ctx, id, input = {}) {
|
|
|
2817
3192
|
status: "live",
|
|
2818
3193
|
ccSessionId: input.ccSessionId ?? existing.ccSessionId ?? null,
|
|
2819
3194
|
transcriptPath: input.transcriptPath ?? existing.transcriptPath ?? null
|
|
2820
|
-
}).where(
|
|
3195
|
+
}).where(eq5(sessions.id, id)).run();
|
|
2821
3196
|
const firstTimeLive = existing.status !== "live";
|
|
2822
3197
|
if (firstTimeLive) {
|
|
2823
3198
|
promoteLastSession(ctx, id);
|
|
@@ -2831,6 +3206,13 @@ function lapKickoff(lap) {
|
|
|
2831
3206
|
function kickoffLineFor(kind, override) {
|
|
2832
3207
|
return override ?? KICKOFF_LINES[kind];
|
|
2833
3208
|
}
|
|
3209
|
+
function planKickoff(input) {
|
|
3210
|
+
const lapBriefing = input.lap > 1 ? lapKickoff(input.lap) : undefined;
|
|
3211
|
+
const line = input.kickoffLine ?? (input.kind === "ideation" ? lapBriefing : undefined);
|
|
3212
|
+
if (!line)
|
|
3213
|
+
return { explicit: false };
|
|
3214
|
+
return { line, explicit: true, lap: line === lapBriefing ? input.lap : undefined };
|
|
3215
|
+
}
|
|
2834
3216
|
function resumeKickoffLine(kind) {
|
|
2835
3217
|
return RESUME_KICKOFF_PREFIX + KICKOFF_LINES[kind];
|
|
2836
3218
|
}
|
|
@@ -2886,6 +3268,7 @@ function attemptKickoff(ctx, session, d, delayMs, clearFirst = false) {
|
|
|
2886
3268
|
return;
|
|
2887
3269
|
}
|
|
2888
3270
|
d.attempts += 1;
|
|
3271
|
+
d.written = true;
|
|
2889
3272
|
const attempt = d.attempts;
|
|
2890
3273
|
try {
|
|
2891
3274
|
if (clearFirst)
|
|
@@ -2930,6 +3313,8 @@ function noteKickoffPrompt(ctx, sessionId, prompt) {
|
|
|
2930
3313
|
const session = getSessionRow(ctx, sessionId);
|
|
2931
3314
|
if (!session)
|
|
2932
3315
|
return;
|
|
3316
|
+
if (!d.written)
|
|
3317
|
+
return;
|
|
2933
3318
|
if (promptMatchesKickoff(d.line, prompt)) {
|
|
2934
3319
|
d.confirmed = true;
|
|
2935
3320
|
d.settled = true;
|
|
@@ -2963,6 +3348,7 @@ function resendKickoff(ctx, sessionId) {
|
|
|
2963
3348
|
line: kickoffLineFor(session.kind, pendingKickoffOverrides.get(sessionId)),
|
|
2964
3349
|
attempts: 0,
|
|
2965
3350
|
confirmed: false,
|
|
3351
|
+
written: false,
|
|
2966
3352
|
settled: false,
|
|
2967
3353
|
timers: new Set
|
|
2968
3354
|
};
|
|
@@ -2991,11 +3377,17 @@ function armSessionReadyWatchdog(ctx, session) {
|
|
|
2991
3377
|
}
|
|
2992
3378
|
function scheduleKickoff(ctx, session) {
|
|
2993
3379
|
const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
|
|
2994
|
-
pendingKickoffOverrides.delete(session.id);
|
|
2995
3380
|
const existing = deliveries.get(session.id);
|
|
2996
3381
|
if (existing)
|
|
2997
3382
|
stopTimers(existing);
|
|
2998
|
-
const d = {
|
|
3383
|
+
const d = {
|
|
3384
|
+
line,
|
|
3385
|
+
attempts: 0,
|
|
3386
|
+
confirmed: false,
|
|
3387
|
+
written: false,
|
|
3388
|
+
settled: false,
|
|
3389
|
+
timers: new Set
|
|
3390
|
+
};
|
|
2999
3391
|
deliveries.set(session.id, d);
|
|
3000
3392
|
attemptKickoff(ctx, session, d, KICKOFF_DELAY_MS);
|
|
3001
3393
|
}
|
|
@@ -3035,26 +3427,26 @@ function markSessionEnded(ctx, id) {
|
|
|
3035
3427
|
if (!existing)
|
|
3036
3428
|
return null;
|
|
3037
3429
|
forgetKickoff(id);
|
|
3038
|
-
ctx.db.update(sessions).set({ status: "ended" }).where(
|
|
3430
|
+
ctx.db.update(sessions).set({ status: "ended" }).where(eq5(sessions.id, id)).run();
|
|
3039
3431
|
return getSessionRow(ctx, id);
|
|
3040
3432
|
}
|
|
3041
3433
|
function mostRecentResumableSession(ctx, featureId, kind) {
|
|
3042
|
-
const row = ctx.db.select().from(sessions).where(
|
|
3434
|
+
const row = ctx.db.select().from(sessions).where(and5(eq5(sessions.featureId, featureId), eq5(sessions.status, "ended"), isNotNull(sessions.ccSessionId), ...kind ? [eq5(sessions.kind, kind)] : [])).orderBy(desc3(sql2`rowid`)).limit(1).get();
|
|
3043
3435
|
return row ? rowToSession(row) : null;
|
|
3044
3436
|
}
|
|
3045
3437
|
function activeProjectSession(ctx, projectId, kind) {
|
|
3046
|
-
const row = ctx.db.select().from(sessions).where(
|
|
3438
|
+
const row = ctx.db.select().from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), inArray(sessions.status, ["launching", "live"]))).orderBy(desc3(sql2`rowid`)).limit(1).get();
|
|
3047
3439
|
return row ? rowToSession(row) : null;
|
|
3048
3440
|
}
|
|
3049
3441
|
function hasCompletedProjectSession(ctx, projectId, kind) {
|
|
3050
|
-
return ctx.db.select({ id: sessions.id }).from(sessions).where(
|
|
3442
|
+
return ctx.db.select({ id: sessions.id }).from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), eq5(sessions.status, "ended"))).limit(1).get() !== undefined;
|
|
3051
3443
|
}
|
|
3052
3444
|
function mostRecentResumableProjectSession(ctx, projectId, kind) {
|
|
3053
|
-
const row = ctx.db.select().from(sessions).where(
|
|
3445
|
+
const row = ctx.db.select().from(sessions).where(and5(eq5(sessions.projectId, projectId), eq5(sessions.kind, kind), eq5(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc3(sql2`rowid`)).limit(1).get();
|
|
3054
3446
|
return row ? rowToSession(row) : null;
|
|
3055
3447
|
}
|
|
3056
3448
|
function mostRecentLiveSession(ctx) {
|
|
3057
|
-
const row = ctx.db.select().from(sessions).where(
|
|
3449
|
+
const row = ctx.db.select().from(sessions).where(eq5(sessions.status, "live")).orderBy(desc3(sql2`rowid`)).limit(1).get();
|
|
3058
3450
|
return row ? rowToSession(row) : null;
|
|
3059
3451
|
}
|
|
3060
3452
|
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;
|
|
@@ -3150,7 +3542,7 @@ var init_feature_docs = __esm(() => {
|
|
|
3150
3542
|
});
|
|
3151
3543
|
|
|
3152
3544
|
// src/services/tickets.ts
|
|
3153
|
-
import { and as
|
|
3545
|
+
import { and as and6, asc as asc3, eq as eq6 } from "drizzle-orm";
|
|
3154
3546
|
function rowToTicket(row) {
|
|
3155
3547
|
return {
|
|
3156
3548
|
id: row.id,
|
|
@@ -3180,14 +3572,14 @@ function resolveBlocking(inputs, startSeq) {
|
|
|
3180
3572
|
}
|
|
3181
3573
|
}
|
|
3182
3574
|
function listByFeature2(ctx, featureId) {
|
|
3183
|
-
return ctx.db.select().from(tickets).where(
|
|
3575
|
+
return ctx.db.select().from(tickets).where(eq6(tickets.featureId, featureId)).orderBy(asc3(tickets.seq)).all().map(rowToTicket);
|
|
3184
3576
|
}
|
|
3185
3577
|
function storeTickets(ctx, featureId, inputs) {
|
|
3186
3578
|
if (inputs.length === 0)
|
|
3187
3579
|
return [];
|
|
3188
3580
|
const { lap } = getFeatureRow(ctx, featureId);
|
|
3189
|
-
const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(
|
|
3190
|
-
const startSeq = existing.reduce((
|
|
3581
|
+
const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq6(tickets.featureId, featureId)).all();
|
|
3582
|
+
const startSeq = existing.reduce((max2, r) => Math.max(max2, r.seq), 0) + 1;
|
|
3191
3583
|
const resolved = resolveBlocking(inputs, startSeq);
|
|
3192
3584
|
const rows = inputs.map((t, i) => ({
|
|
3193
3585
|
id: newId("tkt"),
|
|
@@ -3215,7 +3607,7 @@ function storeTickets(ctx, featureId, inputs) {
|
|
|
3215
3607
|
return rows.map(rowToTicket);
|
|
3216
3608
|
}
|
|
3217
3609
|
function getTicket(ctx, id) {
|
|
3218
|
-
const row = ctx.db.select().from(tickets).where(
|
|
3610
|
+
const row = ctx.db.select().from(tickets).where(eq6(tickets.id, id)).get();
|
|
3219
3611
|
if (!row)
|
|
3220
3612
|
throw new NotFoundError(`ticket ${id} not found`);
|
|
3221
3613
|
return rowToTicket(row);
|
|
@@ -3242,7 +3634,7 @@ function editTicket(ctx, id, patch) {
|
|
|
3242
3634
|
if (Object.keys(set).length === 0) {
|
|
3243
3635
|
throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
|
|
3244
3636
|
}
|
|
3245
|
-
ctx.db.update(tickets).set(set).where(
|
|
3637
|
+
ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
|
|
3246
3638
|
emit(ctx, current2.featureId, {
|
|
3247
3639
|
type: "ticket.edited",
|
|
3248
3640
|
message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
|
|
@@ -3254,7 +3646,7 @@ function editTicket(ctx, id, patch) {
|
|
|
3254
3646
|
function cancelTicket(ctx, id, reason) {
|
|
3255
3647
|
const current2 = getTicket(ctx, id);
|
|
3256
3648
|
assertMutable(current2, "cancel");
|
|
3257
|
-
ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(
|
|
3649
|
+
ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq6(tickets.id, id)).run();
|
|
3258
3650
|
emit(ctx, current2.featureId, {
|
|
3259
3651
|
type: "ticket.cancelled",
|
|
3260
3652
|
message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
|
|
@@ -3264,7 +3656,7 @@ function cancelTicket(ctx, id, reason) {
|
|
|
3264
3656
|
return getTicket(ctx, id);
|
|
3265
3657
|
}
|
|
3266
3658
|
function sweepOrphanedBurning(ctx, featureId, reason) {
|
|
3267
|
-
const orphaned = ctx.db.select().from(tickets).where(
|
|
3659
|
+
const orphaned = ctx.db.select().from(tickets).where(and6(eq6(tickets.featureId, featureId), eq6(tickets.status, "burning"))).all().map(rowToTicket);
|
|
3268
3660
|
for (const t of orphaned) {
|
|
3269
3661
|
updateTicket(ctx, t.id, { status: "failed", error: reason });
|
|
3270
3662
|
emit(ctx, featureId, {
|
|
@@ -3277,7 +3669,7 @@ function sweepOrphanedBurning(ctx, featureId, reason) {
|
|
|
3277
3669
|
return orphaned.map((t) => ({ ...t, status: "failed", error: reason }));
|
|
3278
3670
|
}
|
|
3279
3671
|
function updateTicket(ctx, id, patch) {
|
|
3280
|
-
const current2 = ctx.db.select().from(tickets).where(
|
|
3672
|
+
const current2 = ctx.db.select().from(tickets).where(eq6(tickets.id, id)).get();
|
|
3281
3673
|
if (!current2)
|
|
3282
3674
|
throw new NotFoundError(`ticket ${id} not found`);
|
|
3283
3675
|
const set = {};
|
|
@@ -3291,14 +3683,14 @@ function updateTicket(ctx, id, patch) {
|
|
|
3291
3683
|
set.attemptBranch = patch.attemptBranch;
|
|
3292
3684
|
if (patch.conflictFiles !== undefined)
|
|
3293
3685
|
set.conflictFiles = patch.conflictFiles;
|
|
3294
|
-
ctx.db.update(tickets).set(set).where(
|
|
3686
|
+
ctx.db.update(tickets).set(set).where(eq6(tickets.id, id)).run();
|
|
3295
3687
|
emit(ctx, current2.featureId, {
|
|
3296
3688
|
type: "ticket.updated",
|
|
3297
3689
|
message: `ticket ${current2.seq} \u2192 ${patch.status ?? current2.status}`,
|
|
3298
3690
|
ticketId: id,
|
|
3299
3691
|
data: patch
|
|
3300
3692
|
});
|
|
3301
|
-
const updated = ctx.db.select().from(tickets).where(
|
|
3693
|
+
const updated = ctx.db.select().from(tickets).where(eq6(tickets.id, id)).get();
|
|
3302
3694
|
return rowToTicket(updated);
|
|
3303
3695
|
}
|
|
3304
3696
|
var MUTABLE_STATUSES;
|
|
@@ -3313,10 +3705,11 @@ var init_tickets = __esm(() => {
|
|
|
3313
3705
|
|
|
3314
3706
|
// src/services/gates.ts
|
|
3315
3707
|
import { existsSync as existsSync7 } from "fs";
|
|
3708
|
+
import { and as and7, desc as desc4, eq as eq7, inArray as inArray3 } from "drizzle-orm";
|
|
3316
3709
|
function checkGate(ctx, check, feature) {
|
|
3317
3710
|
switch (check) {
|
|
3318
3711
|
case "decisions-file-exists":
|
|
3319
|
-
return
|
|
3712
|
+
return docGate(ctx, feature, "decisions.md", "run the ideation session to capture decisions first");
|
|
3320
3713
|
case "all-waypoints-terminal": {
|
|
3321
3714
|
const wps = listByFeature(ctx, feature.id);
|
|
3322
3715
|
if (wps.length === 0) {
|
|
@@ -3329,7 +3722,7 @@ function checkGate(ctx, check, feature) {
|
|
|
3329
3722
|
};
|
|
3330
3723
|
}
|
|
3331
3724
|
case "spec-file-exists":
|
|
3332
|
-
return
|
|
3725
|
+
return docGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
|
|
3333
3726
|
case "tickets-approved": {
|
|
3334
3727
|
const count = listByFeature2(ctx, feature.id).filter((t) => t.lap === feature.lap && t.status !== "cancelled").length;
|
|
3335
3728
|
return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
|
|
@@ -3363,6 +3756,16 @@ function fileGate(ctx, feature, fileName, reason) {
|
|
|
3363
3756
|
const project = projectForFeature(ctx, feature);
|
|
3364
3757
|
return existsSync7(featureDocPath(project, feature, fileName)) ? { satisfied: true } : { satisfied: false, reason };
|
|
3365
3758
|
}
|
|
3759
|
+
function docGate(ctx, feature, fileName, reason) {
|
|
3760
|
+
const file = fileGate(ctx, feature, fileName, reason);
|
|
3761
|
+
if (!file.satisfied || feature.lap === 1)
|
|
3762
|
+
return file;
|
|
3763
|
+
const worked = ctx.db.select({ id: sessions.id }).from(sessions).where(and7(eq7(sessions.featureId, feature.id), eq7(sessions.lap, feature.lap), inArray3(sessions.kind, [...DOC_WRITING_KINDS]))).all();
|
|
3764
|
+
return worked.length > 0 ? { satisfied: true } : {
|
|
3765
|
+
satisfied: false,
|
|
3766
|
+
reason: `${fileName} is an earlier lap's \u2014 no lap ${feature.lap} session has worked this feature yet; open the lap's session before promoting`
|
|
3767
|
+
};
|
|
3768
|
+
}
|
|
3366
3769
|
function overrideGate(ctx, featureId, gate, reason) {
|
|
3367
3770
|
const feature = getFeatureRow(ctx, featureId);
|
|
3368
3771
|
ctx.db.insert(gateOverrides).values({ featureId, gate, reason, ts: Date.now() }).run();
|
|
@@ -3376,14 +3779,35 @@ function overrideGate(ctx, featureId, gate, reason) {
|
|
|
3376
3779
|
return setPhase(ctx, featureId, next, "phase.advanced", `advanced to ${next} (override)`);
|
|
3377
3780
|
return feature;
|
|
3378
3781
|
}
|
|
3782
|
+
function undoLastGateOverride(ctx, featureId, gate) {
|
|
3783
|
+
const last = ctx.db.select({ id: gateOverrides.id }).from(gateOverrides).where(and7(eq7(gateOverrides.featureId, featureId), eq7(gateOverrides.gate, gate))).orderBy(desc4(gateOverrides.id)).get();
|
|
3784
|
+
if (!last)
|
|
3785
|
+
return false;
|
|
3786
|
+
ctx.db.delete(gateOverrides).where(eq7(gateOverrides.id, last.id)).run();
|
|
3787
|
+
return true;
|
|
3788
|
+
}
|
|
3789
|
+
function undoGateOverride(ctx, featureId, gate) {
|
|
3790
|
+
const feature = getFeatureRow(ctx, featureId);
|
|
3791
|
+
const back = previousPhase(feature);
|
|
3792
|
+
if (!back) {
|
|
3793
|
+
throw new GateError(`${feature.phase} is the first phase \u2014 there is nothing to step back to`);
|
|
3794
|
+
}
|
|
3795
|
+
if (!undoLastGateOverride(ctx, featureId, gate)) {
|
|
3796
|
+
throw new GateError(`no ${gate} override is recorded on this feature`);
|
|
3797
|
+
}
|
|
3798
|
+
return setPhase(ctx, featureId, back, "gate.override.undone", `${gate} override undone \u2014 back to ${back}`);
|
|
3799
|
+
}
|
|
3800
|
+
var DOC_WRITING_KINDS;
|
|
3379
3801
|
var init_gates = __esm(() => {
|
|
3380
3802
|
init_src();
|
|
3381
3803
|
init_schema();
|
|
3804
|
+
init_errors();
|
|
3382
3805
|
init_events();
|
|
3383
3806
|
init_feature_docs();
|
|
3384
3807
|
init_repo();
|
|
3385
3808
|
init_tickets();
|
|
3386
3809
|
init_waypoints();
|
|
3810
|
+
DOC_WRITING_KINDS = ["ideation", "revisit", "converge"];
|
|
3387
3811
|
});
|
|
3388
3812
|
|
|
3389
3813
|
// src/services/knowledge.ts
|
|
@@ -3820,13 +4244,13 @@ function parseEnvFile(content) {
|
|
|
3820
4244
|
if (line.length === 0 || line.startsWith("#"))
|
|
3821
4245
|
continue;
|
|
3822
4246
|
const body = line.startsWith("export ") ? line.slice(7).trim() : line;
|
|
3823
|
-
const
|
|
3824
|
-
if (
|
|
4247
|
+
const eq8 = body.indexOf("=");
|
|
4248
|
+
if (eq8 === -1)
|
|
3825
4249
|
continue;
|
|
3826
|
-
const key = body.slice(0,
|
|
4250
|
+
const key = body.slice(0, eq8).trim();
|
|
3827
4251
|
if (key.length === 0)
|
|
3828
4252
|
continue;
|
|
3829
|
-
let value = body.slice(
|
|
4253
|
+
let value = body.slice(eq8 + 1).trim();
|
|
3830
4254
|
if (value.length >= 2 && (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'"))) {
|
|
3831
4255
|
value = value.slice(1, -1);
|
|
3832
4256
|
}
|
|
@@ -5037,7 +5461,7 @@ var init_registry2 = __esm(() => {
|
|
|
5037
5461
|
});
|
|
5038
5462
|
|
|
5039
5463
|
// src/workflows/runner.ts
|
|
5040
|
-
import { eq as
|
|
5464
|
+
import { eq as eq8 } from "drizzle-orm";
|
|
5041
5465
|
function workflowClaimsFeatureBranch(workflowId) {
|
|
5042
5466
|
return BRANCH_CLAIMING.has(workflowId);
|
|
5043
5467
|
}
|
|
@@ -5066,7 +5490,7 @@ async function startRun(ctx, featureId, workflowId, opts = {}) {
|
|
|
5066
5490
|
claim(ctx, opts.claimWaypointId, runId);
|
|
5067
5491
|
} catch (e) {
|
|
5068
5492
|
const summary = e instanceof Error ? e.message : "claim failed";
|
|
5069
|
-
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(
|
|
5493
|
+
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
|
|
5070
5494
|
throw e;
|
|
5071
5495
|
}
|
|
5072
5496
|
}
|
|
@@ -5131,7 +5555,7 @@ async function executeRun(ctx, runId, featureId, workflow2, runPromise, controll
|
|
|
5131
5555
|
} finally {
|
|
5132
5556
|
controllers.delete(runId);
|
|
5133
5557
|
}
|
|
5134
|
-
ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(
|
|
5558
|
+
ctx.db.update(runs).set({ status, endedAt: Date.now(), summary }).where(eq8(runs.id, runId)).run();
|
|
5135
5559
|
releaseForSession(ctx, runId);
|
|
5136
5560
|
if (workflowClaimsFeatureBranch(workflow2)) {
|
|
5137
5561
|
sweepOrphanedBurning(ctx, featureId, `orphaned \u2014 the run ended (${status}) while it was burning`);
|
|
@@ -5179,7 +5603,7 @@ var init_runner = __esm(() => {
|
|
|
5179
5603
|
});
|
|
5180
5604
|
|
|
5181
5605
|
// src/services/features.ts
|
|
5182
|
-
import { desc as
|
|
5606
|
+
import { desc as desc5, eq as eq9 } from "drizzle-orm";
|
|
5183
5607
|
import { rmSync as rmSync2 } from "fs";
|
|
5184
5608
|
async function createFeature(ctx, input) {
|
|
5185
5609
|
const project = requireProjectById(ctx, input.projectId);
|
|
@@ -5303,7 +5727,8 @@ function getFeatureFull(ctx, id) {
|
|
|
5303
5727
|
};
|
|
5304
5728
|
}
|
|
5305
5729
|
function list(ctx, projectId) {
|
|
5306
|
-
const rows = ctx.db.select().from(features).where(
|
|
5730
|
+
const rows = ctx.db.select().from(features).where(eq9(features.projectId, projectId)).orderBy(desc5(features.createdAt)).all();
|
|
5731
|
+
const lastActivity = latestTsByFeature(ctx, projectId);
|
|
5307
5732
|
return rows.map((row) => {
|
|
5308
5733
|
const feature = rowToFeature(row);
|
|
5309
5734
|
const tickets2 = listByFeature2(ctx, feature.id);
|
|
@@ -5315,9 +5740,22 @@ function list(ctx, projectId) {
|
|
|
5315
5740
|
failed: tickets2.filter((t) => t.status === "failed").length,
|
|
5316
5741
|
cancelled: tickets2.filter((t) => t.status === "cancelled").length
|
|
5317
5742
|
};
|
|
5318
|
-
return {
|
|
5743
|
+
return {
|
|
5744
|
+
...feature,
|
|
5745
|
+
ticketCounts: counts,
|
|
5746
|
+
activeRun: hasActiveRun(ctx, feature.id),
|
|
5747
|
+
liveSession: liveSessionOf(ctx, feature.id),
|
|
5748
|
+
lastActivityAt: lastActivity.get(feature.id) ?? feature.createdAt
|
|
5749
|
+
};
|
|
5319
5750
|
});
|
|
5320
5751
|
}
|
|
5752
|
+
function liveSessionOf(ctx, featureId) {
|
|
5753
|
+
const open = activeSessionsForFeature(ctx, featureId);
|
|
5754
|
+
const session = open.find((s) => s.status === "live") ?? open[0];
|
|
5755
|
+
if (!session || session.status === "ended")
|
|
5756
|
+
return null;
|
|
5757
|
+
return { status: session.status, awaitingInput: session.awaitingInput };
|
|
5758
|
+
}
|
|
5321
5759
|
function advance(ctx, featureId) {
|
|
5322
5760
|
const feature = getFeatureRow(ctx, featureId);
|
|
5323
5761
|
const gate = nextGate(feature);
|
|
@@ -5373,10 +5811,17 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
5373
5811
|
} else {
|
|
5374
5812
|
setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
|
|
5375
5813
|
}
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5814
|
+
try {
|
|
5815
|
+
const { runId } = await startRun(ctx, featureId, "ticket-burner", {
|
|
5816
|
+
modelOverride: opts.modelOverride
|
|
5817
|
+
});
|
|
5818
|
+
return { runId };
|
|
5819
|
+
} catch (e) {
|
|
5820
|
+
if (iterating) {
|
|
5821
|
+
setPhase(ctx, featureId, REVIEW_LOOP_BACK.from, "burn.aborted", `the burn never started (${errMsg2(e)}) \u2014 back at review`);
|
|
5822
|
+
}
|
|
5823
|
+
throw e;
|
|
5824
|
+
}
|
|
5380
5825
|
}
|
|
5381
5826
|
function rethink(ctx, featureId) {
|
|
5382
5827
|
const feature = getFeatureRow(ctx, featureId);
|
|
@@ -5390,10 +5835,27 @@ function rethink(ctx, featureId) {
|
|
|
5390
5835
|
if (live.length > 0) {
|
|
5391
5836
|
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`);
|
|
5392
5837
|
}
|
|
5838
|
+
if (activeTestDriveFeatureId() === featureId) {
|
|
5839
|
+
throw new GateError(`${feature.slug} is being test-driven \u2014 stop the test drive first, it holds the feature branch`);
|
|
5840
|
+
}
|
|
5393
5841
|
const lap = feature.lap + 1;
|
|
5394
|
-
ctx.db.update(features).set({ lap }).where(
|
|
5842
|
+
ctx.db.update(features).set({ lap }).where(eq9(features.id, featureId)).run();
|
|
5395
5843
|
return setPhase(ctx, featureId, RETHINK_LOOP_BACK.to, "lap.started", `rethink \u2014 lap ${lap}`);
|
|
5396
5844
|
}
|
|
5845
|
+
async function rethinkAndLaunch(ctx, featureId, launch) {
|
|
5846
|
+
const before = getFeatureRow(ctx, featureId);
|
|
5847
|
+
const feature = rethink(ctx, featureId);
|
|
5848
|
+
try {
|
|
5849
|
+
return await launch(feature);
|
|
5850
|
+
} catch (e) {
|
|
5851
|
+
ctx.db.update(features).set({ lap: before.lap }).where(eq9(features.id, featureId)).run();
|
|
5852
|
+
setPhase(ctx, featureId, before.phase, "lap.aborted", `lap ${feature.lap} aborted \u2014 its terminal could not be opened (${errMsg2(e)}); back at ${before.phase} on lap ${before.lap}`);
|
|
5853
|
+
throw e;
|
|
5854
|
+
}
|
|
5855
|
+
}
|
|
5856
|
+
function errMsg2(e) {
|
|
5857
|
+
return e instanceof Error ? e.message : String(e);
|
|
5858
|
+
}
|
|
5397
5859
|
async function retryTicket(ctx, ticketId, opts = {}) {
|
|
5398
5860
|
const ticket = getTicket(ctx, ticketId);
|
|
5399
5861
|
const feature = getFeatureRow(ctx, ticket.featureId);
|
|
@@ -5465,7 +5927,7 @@ function escalateToMap(ctx, featureId, input) {
|
|
|
5465
5927
|
return { ok: true, warning: `feature ${feature.slug} is already mapped \u2014 no changes made` };
|
|
5466
5928
|
}
|
|
5467
5929
|
const project = projectForFeature(ctx, feature);
|
|
5468
|
-
ctx.db.update(features).set({ mapped: true }).where(
|
|
5930
|
+
ctx.db.update(features).set({ mapped: true }).where(eq9(features.id, featureId)).run();
|
|
5469
5931
|
scaffoldMapDoc(project, { ...feature, mapped: true }, input);
|
|
5470
5932
|
emit(ctx, featureId, {
|
|
5471
5933
|
type: "feature.escalated",
|
|
@@ -5482,7 +5944,7 @@ function archiveFeature(ctx, featureId) {
|
|
|
5482
5944
|
const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
|
|
5483
5945
|
if (live)
|
|
5484
5946
|
endSession(ctx, live.id);
|
|
5485
|
-
ctx.db.update(features).set({ status: "archived" }).where(
|
|
5947
|
+
ctx.db.update(features).set({ status: "archived" }).where(eq9(features.id, featureId)).run();
|
|
5486
5948
|
emit(ctx, featureId, {
|
|
5487
5949
|
type: "feature.archived",
|
|
5488
5950
|
message: `feature ${feature.slug} archived`,
|
|
@@ -5496,7 +5958,7 @@ function unarchiveFeature(ctx, featureId) {
|
|
|
5496
5958
|
throw new GateError(`feature ${feature.slug} is not archived`);
|
|
5497
5959
|
}
|
|
5498
5960
|
const restored = feature.phase === "shipped" ? "shipped" : "active";
|
|
5499
|
-
ctx.db.update(features).set({ status: restored }).where(
|
|
5961
|
+
ctx.db.update(features).set({ status: restored }).where(eq9(features.id, featureId)).run();
|
|
5500
5962
|
emit(ctx, featureId, {
|
|
5501
5963
|
type: "feature.unarchived",
|
|
5502
5964
|
message: `feature ${feature.slug} unarchived (${restored})`,
|
|
@@ -5536,13 +5998,13 @@ async function deleteFeature(ctx, featureId) {
|
|
|
5536
5998
|
return { ok: true, slug: feature.slug };
|
|
5537
5999
|
}
|
|
5538
6000
|
function deleteFeatureRows(ctx, featureId) {
|
|
5539
|
-
ctx.db.delete(tickets).where(
|
|
5540
|
-
ctx.db.delete(sessions).where(
|
|
5541
|
-
ctx.db.delete(runs).where(
|
|
5542
|
-
ctx.db.delete(events).where(
|
|
5543
|
-
ctx.db.delete(gateOverrides).where(
|
|
5544
|
-
ctx.db.delete(waypoints).where(
|
|
5545
|
-
ctx.db.delete(features).where(
|
|
6001
|
+
ctx.db.delete(tickets).where(eq9(tickets.featureId, featureId)).run();
|
|
6002
|
+
ctx.db.delete(sessions).where(eq9(sessions.featureId, featureId)).run();
|
|
6003
|
+
ctx.db.delete(runs).where(eq9(runs.featureId, featureId)).run();
|
|
6004
|
+
ctx.db.delete(events).where(eq9(events.featureId, featureId)).run();
|
|
6005
|
+
ctx.db.delete(gateOverrides).where(eq9(gateOverrides.featureId, featureId)).run();
|
|
6006
|
+
ctx.db.delete(waypoints).where(eq9(waypoints.featureId, featureId)).run();
|
|
6007
|
+
ctx.db.delete(features).where(eq9(features.id, featureId)).run();
|
|
5546
6008
|
}
|
|
5547
6009
|
function gateState(ctx, feature) {
|
|
5548
6010
|
const gate = nextGate(feature);
|
|
@@ -5553,7 +6015,7 @@ function gateState(ctx, feature) {
|
|
|
5553
6015
|
}
|
|
5554
6016
|
function uniqueSlug(ctx, projectId, title) {
|
|
5555
6017
|
const base = slugify(title);
|
|
5556
|
-
const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(
|
|
6018
|
+
const existing = new Set(ctx.db.select({ slug: features.slug }).from(features).where(eq9(features.projectId, projectId)).all().map((r) => r.slug));
|
|
5557
6019
|
if (!existing.has(base))
|
|
5558
6020
|
return base;
|
|
5559
6021
|
let n = 2;
|
|
@@ -5582,126 +6044,6 @@ var init_features = __esm(() => {
|
|
|
5582
6044
|
init_runner();
|
|
5583
6045
|
});
|
|
5584
6046
|
|
|
5585
|
-
// src/services/findings.ts
|
|
5586
|
-
import { and as and6, eq as eq8 } from "drizzle-orm";
|
|
5587
|
-
function isPreparedKey(key) {
|
|
5588
|
-
return PREPARED_SET.has(key);
|
|
5589
|
-
}
|
|
5590
|
-
function findingRow(ctx, projectId, key) {
|
|
5591
|
-
return ctx.db.select({
|
|
5592
|
-
source: projectFindings.source,
|
|
5593
|
-
evidence: projectFindings.evidence,
|
|
5594
|
-
establishedAt: projectFindings.establishedAt,
|
|
5595
|
-
establishedSha: projectFindings.establishedSha
|
|
5596
|
-
}).from(projectFindings).where(and6(eq8(projectFindings.projectId, projectId), eq8(projectFindings.key, key))).get();
|
|
5597
|
-
}
|
|
5598
|
-
function isOverwritable(ctx, projectId, key) {
|
|
5599
|
-
return findingRow(ctx, projectId, key)?.source !== "human";
|
|
5600
|
-
}
|
|
5601
|
-
function recordFinding(ctx, projectId, input) {
|
|
5602
|
-
const { key, value, source, evidence, establishedSha } = input;
|
|
5603
|
-
ctx.db.transaction((tx) => {
|
|
5604
|
-
tx.update(projects).set({ [COLUMN_NAME[key]]: value }).where(eq8(projects.id, projectId)).run();
|
|
5605
|
-
if (value === null) {
|
|
5606
|
-
tx.delete(projectFindings).where(and6(eq8(projectFindings.projectId, projectId), eq8(projectFindings.key, key))).run();
|
|
5607
|
-
return;
|
|
5608
|
-
}
|
|
5609
|
-
tx.insert(projectFindings).values({
|
|
5610
|
-
projectId,
|
|
5611
|
-
key,
|
|
5612
|
-
source,
|
|
5613
|
-
evidence: evidence ?? null,
|
|
5614
|
-
establishedAt: Date.now(),
|
|
5615
|
-
establishedSha: establishedSha ?? null
|
|
5616
|
-
}).onConflictDoUpdate({
|
|
5617
|
-
target: [projectFindings.projectId, projectFindings.key],
|
|
5618
|
-
set: {
|
|
5619
|
-
source,
|
|
5620
|
-
evidence: evidence ?? null,
|
|
5621
|
-
establishedAt: Date.now(),
|
|
5622
|
-
establishedSha: establishedSha ?? null
|
|
5623
|
-
}
|
|
5624
|
-
}).run();
|
|
5625
|
-
});
|
|
5626
|
-
}
|
|
5627
|
-
function recordHuman(ctx, projectId, key, value) {
|
|
5628
|
-
if (!isPreparedKey(key))
|
|
5629
|
-
return;
|
|
5630
|
-
const existing = findingRow(ctx, projectId, key);
|
|
5631
|
-
if (value === null) {
|
|
5632
|
-
if (existing) {
|
|
5633
|
-
ctx.db.delete(projectFindings).where(and6(eq8(projectFindings.projectId, projectId), eq8(projectFindings.key, key))).run();
|
|
5634
|
-
}
|
|
5635
|
-
return;
|
|
5636
|
-
}
|
|
5637
|
-
ctx.db.insert(projectFindings).values({
|
|
5638
|
-
projectId,
|
|
5639
|
-
key,
|
|
5640
|
-
source: "human",
|
|
5641
|
-
evidence: null,
|
|
5642
|
-
establishedAt: Date.now(),
|
|
5643
|
-
establishedSha: null
|
|
5644
|
-
}).onConflictDoUpdate({
|
|
5645
|
-
target: [projectFindings.projectId, projectFindings.key],
|
|
5646
|
-
set: { source: "human", evidence: null, establishedAt: Date.now(), establishedSha: null }
|
|
5647
|
-
}).run();
|
|
5648
|
-
}
|
|
5649
|
-
async function listFindings(ctx, project) {
|
|
5650
|
-
const rows = ctx.db.select().from(projectFindings).where(eq8(projectFindings.projectId, project.id)).all();
|
|
5651
|
-
const distances = new Map;
|
|
5652
|
-
for (const row of rows) {
|
|
5653
|
-
const sha = row.establishedSha;
|
|
5654
|
-
if (!sha || distances.has(sha))
|
|
5655
|
-
continue;
|
|
5656
|
-
distances.set(sha, await commitsSince(project.repoPath, sha, project.mainBranch));
|
|
5657
|
-
}
|
|
5658
|
-
const order = new Map(PREPARED_KEYS.map((k, i) => [k, i]));
|
|
5659
|
-
return rows.filter((row) => isPreparedKey(row.key)).sort((a, b) => (order.get(a.key) ?? 0) - (order.get(b.key) ?? 0)).map((row) => {
|
|
5660
|
-
const staleCommits = row.establishedSha ? distances.get(row.establishedSha) : undefined;
|
|
5661
|
-
return {
|
|
5662
|
-
key: row.key,
|
|
5663
|
-
source: row.source,
|
|
5664
|
-
...row.evidence ? { evidence: row.evidence } : {},
|
|
5665
|
-
establishedAt: row.establishedAt,
|
|
5666
|
-
...row.establishedSha ? { establishedSha: row.establishedSha } : {},
|
|
5667
|
-
...staleCommits !== undefined ? { staleCommits } : {}
|
|
5668
|
-
};
|
|
5669
|
-
});
|
|
5670
|
-
}
|
|
5671
|
-
function unsetPreparedKeys(project) {
|
|
5672
|
-
return PREPARED_KEYS.filter((key) => {
|
|
5673
|
-
const value = project[key];
|
|
5674
|
-
return value === undefined || value.trim() === "";
|
|
5675
|
-
});
|
|
5676
|
-
}
|
|
5677
|
-
var VALUE_COLUMN, COLUMN_NAME, PREPARED_SET;
|
|
5678
|
-
var init_findings = __esm(() => {
|
|
5679
|
-
init_src();
|
|
5680
|
-
init_schema();
|
|
5681
|
-
init_git();
|
|
5682
|
-
VALUE_COLUMN = {
|
|
5683
|
-
setupCommand: projects.setupCommand,
|
|
5684
|
-
verifyCommands: projects.verifyCommands,
|
|
5685
|
-
knownFailures: projects.knownFailures,
|
|
5686
|
-
devCommand: projects.devCommand,
|
|
5687
|
-
driveSetupCommand: projects.driveSetupCommand,
|
|
5688
|
-
driveStopCommand: projects.driveStopCommand,
|
|
5689
|
-
driveEnv: projects.driveEnv,
|
|
5690
|
-
dbResetCommand: projects.dbResetCommand
|
|
5691
|
-
};
|
|
5692
|
-
COLUMN_NAME = {
|
|
5693
|
-
setupCommand: "setupCommand",
|
|
5694
|
-
verifyCommands: "verifyCommands",
|
|
5695
|
-
knownFailures: "knownFailures",
|
|
5696
|
-
devCommand: "devCommand",
|
|
5697
|
-
driveSetupCommand: "driveSetupCommand",
|
|
5698
|
-
driveStopCommand: "driveStopCommand",
|
|
5699
|
-
driveEnv: "driveEnv",
|
|
5700
|
-
dbResetCommand: "dbResetCommand"
|
|
5701
|
-
};
|
|
5702
|
-
PREPARED_SET = new Set(PREPARED_KEYS);
|
|
5703
|
-
});
|
|
5704
|
-
|
|
5705
6047
|
// src/mcp/server.ts
|
|
5706
6048
|
import { StreamableHTTPTransport } from "@hono/mcp";
|
|
5707
6049
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -5872,6 +6214,13 @@ function toolRecordFinding(ctx, session, input) {
|
|
|
5872
6214
|
});
|
|
5873
6215
|
return { ok: true, key: input.key, source };
|
|
5874
6216
|
}
|
|
6217
|
+
async function toolDryRunDrive(ctx, session, input) {
|
|
6218
|
+
const project = requireProject(ctx, session);
|
|
6219
|
+
if (session.kind !== "prepare") {
|
|
6220
|
+
throw new GateError(`the dry-run drive belongs to a preparation conversation, and this is a ${session.kind} ` + "session. It starts services and creates a database on the human's machine \u2014 only the " + "session that established those values may run them.");
|
|
6221
|
+
}
|
|
6222
|
+
return dryRunDrive(ctx, project, input.action);
|
|
6223
|
+
}
|
|
5875
6224
|
async function toolCreateFeature(ctx, session, input) {
|
|
5876
6225
|
const project = requireProject(ctx, session);
|
|
5877
6226
|
const feature = input.ticket ? await quickChange(ctx, {
|
|
@@ -5987,6 +6336,16 @@ function buildMcpServer() {
|
|
|
5987
6336
|
return noSession();
|
|
5988
6337
|
return ok(toolRecordFinding(rs.ctx, rs.session, args));
|
|
5989
6338
|
});
|
|
6339
|
+
server.registerTool("dry_run_drive", {
|
|
6340
|
+
title: "Dry-run the test drive",
|
|
6341
|
+
description: "Prove the drive keys you recorded by having the server run its REAL test-drive " + "machinery \u2014 same env rendering, same hooks, same dev pane \u2014 under a synthetic identity " + "(slug `prep-dry-run`) on the current branch. Nothing is checked out. `start` renders " + "driveEnv, runs driveSetupCommand and spawns devCommand; `status` reports the pane and " + "the sniffed localhost URL while you inspect; `stop` runs driveStopCommand and rules on " + "the run. Ask the human before starting: it starts services and creates a database on " + "their machine. A clean full pass stamps the participating keys verified, computed from " + "what the machinery observed \u2014 your own checks decide whether to fix and re-run, never " + "the stamp.",
|
|
6342
|
+
inputSchema: { action: z3.enum(["start", "status", "stop"]) }
|
|
6343
|
+
}, async (args, extra) => {
|
|
6344
|
+
const rs = await resolveCtxSession(extra);
|
|
6345
|
+
if (!rs)
|
|
6346
|
+
return noSession();
|
|
6347
|
+
return ok(await toolDryRunDrive(rs.ctx, rs.session, args));
|
|
6348
|
+
});
|
|
5990
6349
|
server.registerTool("create_feature", {
|
|
5991
6350
|
title: "Create a feature",
|
|
5992
6351
|
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.",
|
|
@@ -6235,7 +6594,51 @@ var init_ws = __esm(() => {
|
|
|
6235
6594
|
};
|
|
6236
6595
|
});
|
|
6237
6596
|
|
|
6597
|
+
// src/launcher/edit-guard.ts
|
|
6598
|
+
import { isAbsolute, relative as relative2, resolve as resolve7 } from "path";
|
|
6599
|
+
function guardsEdits(kind) {
|
|
6600
|
+
return kind !== "project";
|
|
6601
|
+
}
|
|
6602
|
+
function evaluateEditGuard(input) {
|
|
6603
|
+
if (!guardsEdits(input.kind))
|
|
6604
|
+
return null;
|
|
6605
|
+
if (!input.toolName || !EDIT_TOOLS.includes(input.toolName))
|
|
6606
|
+
return null;
|
|
6607
|
+
if (!input.filePath)
|
|
6608
|
+
return null;
|
|
6609
|
+
if (!input.featureSlug) {
|
|
6610
|
+
return {
|
|
6611
|
+
reason: `This ${input.kind} session does not edit files \u2014 it runs in the developer's own ` + "checkout. Record what you establish with the `record_finding` MCP tool, and ask the " + "human to make any change to the repo itself."
|
|
6612
|
+
};
|
|
6613
|
+
}
|
|
6614
|
+
const docs = featureDocsRel(input.featureSlug);
|
|
6615
|
+
const docsDir = resolve7(input.worktreePath, docs);
|
|
6616
|
+
const target = resolve7(input.worktreePath, input.filePath);
|
|
6617
|
+
const rel = relative2(docsDir, target);
|
|
6618
|
+
if (rel === "" || !rel.startsWith("..") && !isAbsolute(rel))
|
|
6619
|
+
return null;
|
|
6620
|
+
return {
|
|
6621
|
+
reason: `Talk sessions do not write code. This ${input.kind} session may only write this ` + `feature's docs under \`${docs}/\` \u2014 ${input.filePath} is outside them. The change you ` + "want belongs in a ticket: capture the decision in `decisions.md`, amend `spec.md`, and " + "emit a ticket for the work. An implementation agent burns it in its own sandbox."
|
|
6622
|
+
};
|
|
6623
|
+
}
|
|
6624
|
+
function editDenyResponse(denial) {
|
|
6625
|
+
return {
|
|
6626
|
+
hookSpecificOutput: {
|
|
6627
|
+
hookEventName: "PreToolUse",
|
|
6628
|
+
permissionDecision: "deny",
|
|
6629
|
+
permissionDecisionReason: denial.reason
|
|
6630
|
+
}
|
|
6631
|
+
};
|
|
6632
|
+
}
|
|
6633
|
+
var EDIT_TOOLS, EDIT_TOOL_MATCHER;
|
|
6634
|
+
var init_edit_guard = __esm(() => {
|
|
6635
|
+
init_paths();
|
|
6636
|
+
EDIT_TOOLS = ["Edit", "Write", "NotebookEdit"];
|
|
6637
|
+
EDIT_TOOL_MATCHER = EDIT_TOOLS.join("|");
|
|
6638
|
+
});
|
|
6639
|
+
|
|
6238
6640
|
// src/services/prep.ts
|
|
6641
|
+
import { and as and8, desc as desc6, eq as eq10, inArray as inArray4 } from "drizzle-orm";
|
|
6239
6642
|
function keysToPrepare(ctx, project) {
|
|
6240
6643
|
return unsetPreparedKeys(project).filter((key) => isOverwritable(ctx, project.id, key));
|
|
6241
6644
|
}
|
|
@@ -6244,16 +6647,33 @@ function isPrepared(ctx, project) {
|
|
|
6244
6647
|
return true;
|
|
6245
6648
|
return hasCompletedProjectSession(ctx, project.id, "prepare");
|
|
6246
6649
|
}
|
|
6650
|
+
function preparedAt(ctx, projectId) {
|
|
6651
|
+
const prepareSessions = new Set(ctx.db.select({ id: sessions.id }).from(sessions).where(and8(eq10(sessions.projectId, projectId), eq10(sessions.kind, "prepare"))).all().map((r) => r.id));
|
|
6652
|
+
if (prepareSessions.size === 0)
|
|
6653
|
+
return null;
|
|
6654
|
+
const ends = ctx.db.select({ ts: events.ts, data: events.data }).from(events).where(and8(eq10(events.projectId, projectId), inArray4(events.type, ["session.ended", "session.reconciled"]))).orderBy(desc6(events.ts)).all();
|
|
6655
|
+
for (const end of ends) {
|
|
6656
|
+
const sessionId = end.data?.sessionId;
|
|
6657
|
+
if (sessionId && prepareSessions.has(sessionId))
|
|
6658
|
+
return end.ts;
|
|
6659
|
+
}
|
|
6660
|
+
return null;
|
|
6661
|
+
}
|
|
6247
6662
|
async function prepView(ctx, project) {
|
|
6663
|
+
const drive = activeDriveInfo();
|
|
6248
6664
|
return {
|
|
6249
6665
|
pendingKeys: keysToPrepare(ctx, project),
|
|
6250
6666
|
findings: await listFindings(ctx, project),
|
|
6251
|
-
prepared: isPrepared(ctx, project)
|
|
6667
|
+
prepared: isPrepared(ctx, project),
|
|
6668
|
+
preparedAt: preparedAt(ctx, project.id),
|
|
6669
|
+
dryRun: drive?.dryRun ? drive : null
|
|
6252
6670
|
};
|
|
6253
6671
|
}
|
|
6254
6672
|
var init_prep = __esm(() => {
|
|
6673
|
+
init_schema();
|
|
6255
6674
|
init_sessions();
|
|
6256
6675
|
init_findings();
|
|
6676
|
+
init_git();
|
|
6257
6677
|
});
|
|
6258
6678
|
|
|
6259
6679
|
// src/routes/hooks.ts
|
|
@@ -6278,52 +6698,70 @@ function handleSessionStart(ctx, sessionId, feature, payload) {
|
|
|
6278
6698
|
}
|
|
6279
6699
|
};
|
|
6280
6700
|
}
|
|
6281
|
-
function
|
|
6701
|
+
function handleProjectScopedSessionStart(ctx, session, payload) {
|
|
6282
6702
|
const ccSessionId = typeof payload?.session_id === "string" ? payload.session_id : undefined;
|
|
6283
6703
|
const transcriptPath = typeof payload?.transcript_path === "string" ? payload.transcript_path : undefined;
|
|
6284
6704
|
const source = typeof payload?.source === "string" ? payload.source : undefined;
|
|
6705
|
+
const noun = projectScopedNoun(session.kind);
|
|
6285
6706
|
const wasLive = getSessionRow(ctx, session.id)?.status === "live";
|
|
6286
6707
|
markSessionLive(ctx, session.id, { ccSessionId, transcriptPath });
|
|
6287
6708
|
if (!wasLive) {
|
|
6288
6709
|
emitForSession(ctx, session, {
|
|
6289
6710
|
type: "session.started",
|
|
6290
|
-
message: source === "resume" ?
|
|
6711
|
+
message: source === "resume" ? `${noun} live (resumed)` : `${noun} live`,
|
|
6291
6712
|
data: { sessionId: session.id, ccSessionId, transcriptPath, source: source ?? null }
|
|
6292
6713
|
});
|
|
6293
6714
|
}
|
|
6294
6715
|
return {
|
|
6295
6716
|
hookSpecificOutput: {
|
|
6296
6717
|
hookEventName: "SessionStart",
|
|
6297
|
-
additionalContext:
|
|
6718
|
+
additionalContext: projectScopedStartContext(ctx, session)
|
|
6298
6719
|
}
|
|
6299
6720
|
};
|
|
6300
6721
|
}
|
|
6301
|
-
function
|
|
6722
|
+
function handleProjectScopedUserPrompt(ctx, session, payload) {
|
|
6302
6723
|
const prompt = typeof payload?.prompt === "string" ? payload.prompt : undefined;
|
|
6303
6724
|
noteKickoffPrompt(ctx, session.id, prompt);
|
|
6304
6725
|
return {
|
|
6305
6726
|
hookSpecificOutput: {
|
|
6306
6727
|
hookEventName: "UserPromptSubmit",
|
|
6307
|
-
additionalContext:
|
|
6728
|
+
additionalContext: projectScopedPromptLabel(session.kind)
|
|
6308
6729
|
}
|
|
6309
6730
|
};
|
|
6310
6731
|
}
|
|
6311
|
-
function
|
|
6732
|
+
function handleProjectScopedSessionEnd(ctx, session) {
|
|
6312
6733
|
markSessionEnded(ctx, session.id);
|
|
6313
6734
|
emitForSession(ctx, session, {
|
|
6314
6735
|
type: "session.ended",
|
|
6315
|
-
message:
|
|
6736
|
+
message: `${projectScopedNoun(session.kind)} ended`,
|
|
6316
6737
|
data: { sessionId: session.id }
|
|
6317
6738
|
});
|
|
6318
6739
|
return {};
|
|
6319
6740
|
}
|
|
6320
|
-
function
|
|
6741
|
+
function projectScopedNoun(kind) {
|
|
6742
|
+
switch (kind) {
|
|
6743
|
+
case "prepare":
|
|
6744
|
+
return "preparation session";
|
|
6745
|
+
case "project":
|
|
6746
|
+
return "project session";
|
|
6747
|
+
default:
|
|
6748
|
+
return "session";
|
|
6749
|
+
}
|
|
6750
|
+
}
|
|
6751
|
+
function projectScopedPromptLabel(kind) {
|
|
6752
|
+
return kind === "prepare" ? "[runcastle] preparation session (project-scoped, no feature)" : `[runcastle] ${projectScopedNoun(kind)}`;
|
|
6753
|
+
}
|
|
6754
|
+
function projectScopedStartContext(ctx, session) {
|
|
6755
|
+
const noun = projectScopedNoun(session.kind);
|
|
6321
6756
|
const project = session.projectId ? getProjectById(ctx, session.projectId) : null;
|
|
6322
6757
|
if (!project)
|
|
6323
|
-
return
|
|
6758
|
+
return `[runcastle] ${noun} \u2014 project not found`;
|
|
6759
|
+
const scope = `[runcastle] ${noun} for ${project.name} (${project.repoPath})`;
|
|
6760
|
+
if (session.kind !== "prepare")
|
|
6761
|
+
return scope;
|
|
6324
6762
|
const remaining = keysToPrepare(ctx, project);
|
|
6325
6763
|
return [
|
|
6326
|
-
|
|
6764
|
+
`${scope}.`,
|
|
6327
6765
|
remaining.length > 0 ? `Still unestablished: ${remaining.join(", ")}.` : "Every prepared field currently has a value.",
|
|
6328
6766
|
"Record what you establish with the `record_finding` MCP tool \u2014 a value the user",
|
|
6329
6767
|
"gives you or confirms verbatim is theirs (userSupplied: true), anything you measured is not."
|
|
@@ -6350,6 +6788,19 @@ function handleSessionEnd(ctx, sessionId, feature) {
|
|
|
6350
6788
|
});
|
|
6351
6789
|
return {};
|
|
6352
6790
|
}
|
|
6791
|
+
function handlePreToolUse(session, feature, payload) {
|
|
6792
|
+
const toolName = typeof payload?.tool_name === "string" ? payload.tool_name : undefined;
|
|
6793
|
+
const toolInput = payload?.tool_input ?? {};
|
|
6794
|
+
const path = typeof toolInput.file_path === "string" ? toolInput.file_path : typeof toolInput.notebook_path === "string" ? toolInput.notebook_path : undefined;
|
|
6795
|
+
const denial = evaluateEditGuard({
|
|
6796
|
+
kind: session.kind,
|
|
6797
|
+
worktreePath: session.worktreePath,
|
|
6798
|
+
toolName,
|
|
6799
|
+
filePath: path,
|
|
6800
|
+
featureSlug: feature?.slug
|
|
6801
|
+
});
|
|
6802
|
+
return denial ? editDenyResponse(denial) : {};
|
|
6803
|
+
}
|
|
6353
6804
|
function sessionStartContext(ctx, feature) {
|
|
6354
6805
|
const tickets2 = listByFeature2(ctx, feature.id);
|
|
6355
6806
|
const byStatus = tickets2.reduce((acc, t) => {
|
|
@@ -6359,7 +6810,7 @@ function sessionStartContext(ctx, feature) {
|
|
|
6359
6810
|
const ticketSummary = tickets2.length === 0 ? "none yet" : `${tickets2.length} (${Object.entries(byStatus).map(([s, n]) => `${n} ${s}`).join(", ")})`;
|
|
6360
6811
|
return [
|
|
6361
6812
|
`[runcastle] ${feature.title} \u2014 ${feature.oneLiner}`,
|
|
6362
|
-
`phase: ${feature.phase}; branch: ${feature.branch}`,
|
|
6813
|
+
`phase: ${feature.phase}; lap: ${feature.lap}; branch: ${feature.branch}`,
|
|
6363
6814
|
`docs: docs/features/${feature.slug}/ (brief.md, decisions.md, spec.md)`,
|
|
6364
6815
|
`tickets: ${ticketSummary}`,
|
|
6365
6816
|
"Call the runcastle MCP tool `get_feature_context` for the full docs + tickets."
|
|
@@ -6368,6 +6819,7 @@ function sessionStartContext(ctx, feature) {
|
|
|
6368
6819
|
}
|
|
6369
6820
|
var hooks, hooks_default;
|
|
6370
6821
|
var init_hooks = __esm(() => {
|
|
6822
|
+
init_edit_guard();
|
|
6371
6823
|
init_sessions();
|
|
6372
6824
|
init_events();
|
|
6373
6825
|
init_prep();
|
|
@@ -6392,14 +6844,22 @@ var init_hooks = __esm(() => {
|
|
|
6392
6844
|
const session = getSessionRow(ctx, sessionId);
|
|
6393
6845
|
if (!session)
|
|
6394
6846
|
return c.json({});
|
|
6847
|
+
if (event === "user-prompt")
|
|
6848
|
+
markAgentWorking(ctx, sessionId);
|
|
6849
|
+
if (event === "stop") {
|
|
6850
|
+
markAwaitingInput(ctx, sessionId);
|
|
6851
|
+
return c.json({});
|
|
6852
|
+
}
|
|
6395
6853
|
if (!session.featureId) {
|
|
6396
6854
|
switch (event) {
|
|
6397
6855
|
case "session-start":
|
|
6398
|
-
return c.json(
|
|
6856
|
+
return c.json(handleProjectScopedSessionStart(ctx, session, body.payload));
|
|
6399
6857
|
case "user-prompt":
|
|
6400
|
-
return c.json(
|
|
6858
|
+
return c.json(handleProjectScopedUserPrompt(ctx, session, body.payload));
|
|
6401
6859
|
case "session-end":
|
|
6402
|
-
return c.json(
|
|
6860
|
+
return c.json(handleProjectScopedSessionEnd(ctx, session));
|
|
6861
|
+
case "pre-tool":
|
|
6862
|
+
return c.json(handlePreToolUse(session, undefined, body.payload));
|
|
6403
6863
|
default:
|
|
6404
6864
|
return c.json({});
|
|
6405
6865
|
}
|
|
@@ -6414,6 +6874,8 @@ var init_hooks = __esm(() => {
|
|
|
6414
6874
|
return c.json(handleUserPrompt(ctx, sessionId, feature, body.payload));
|
|
6415
6875
|
case "session-end":
|
|
6416
6876
|
return c.json(handleSessionEnd(ctx, sessionId, feature));
|
|
6877
|
+
case "pre-tool":
|
|
6878
|
+
return c.json(handlePreToolUse(session, feature, body.payload));
|
|
6417
6879
|
default:
|
|
6418
6880
|
return c.json({});
|
|
6419
6881
|
}
|
|
@@ -6478,7 +6940,7 @@ var init_stream = __esm(() => {
|
|
|
6478
6940
|
// src/routes/web.ts
|
|
6479
6941
|
import { existsSync as existsSync12, statSync } from "fs";
|
|
6480
6942
|
import { readFile } from "fs/promises";
|
|
6481
|
-
import { extname, join as join11, normalize, resolve as
|
|
6943
|
+
import { extname, join as join11, normalize, resolve as resolve8, sep as sep2 } from "path";
|
|
6482
6944
|
function contentType(path) {
|
|
6483
6945
|
return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
6484
6946
|
}
|
|
@@ -6504,11 +6966,11 @@ function safeJoin(root, pathname) {
|
|
|
6504
6966
|
function resolveWebDist() {
|
|
6505
6967
|
const override = process.env.RUNCASTLE_WEB_DIST;
|
|
6506
6968
|
if (override)
|
|
6507
|
-
return
|
|
6508
|
-
return
|
|
6969
|
+
return resolve8(override);
|
|
6970
|
+
return resolve8(import.meta.dirname, "..", "..", "..", "..", "apps", "web", "dist");
|
|
6509
6971
|
}
|
|
6510
6972
|
function mountWebApp(app, distDir) {
|
|
6511
|
-
const root =
|
|
6973
|
+
const root = resolve8(distDir);
|
|
6512
6974
|
const indexHtml = join11(root, "index.html");
|
|
6513
6975
|
app.get("*", async (c, next) => {
|
|
6514
6976
|
const { pathname } = new URL(c.req.url);
|
|
@@ -6612,13 +7074,16 @@ function hookClientPath() {
|
|
|
6612
7074
|
function serverUrlFor(config2) {
|
|
6613
7075
|
return `http://localhost:${config2.serverPort}`;
|
|
6614
7076
|
}
|
|
6615
|
-
function
|
|
7077
|
+
function noCodeRule(docs) {
|
|
7078
|
+
return `- **Talk sessions do not write code.** You may write this feature's docs under \`${docs}/\` ` + "and nothing else in this checkout. Every code change rides a ticket, burned by an " + "implementation agent in its own sandbox \u2014 even the one-line fix that is obviously faster " + "to just do. This is enforced by a hook, not left to your judgement: edits outside the docs " + "are denied.";
|
|
7079
|
+
}
|
|
7080
|
+
function renderSystemPrompt(feature, kind, waypoint, lap) {
|
|
6616
7081
|
if (kind === "waypoint")
|
|
6617
7082
|
return renderWaypointPrompt(feature, waypoint);
|
|
6618
7083
|
if (kind === "converge")
|
|
6619
7084
|
return renderConvergePrompt(feature);
|
|
6620
7085
|
if (kind === "revisit")
|
|
6621
|
-
return renderRevisitPrompt(feature);
|
|
7086
|
+
return renderRevisitPrompt(feature, lap);
|
|
6622
7087
|
const docs = featureDocsRel(feature.slug);
|
|
6623
7088
|
const entry = kind === "ideation" ? "Begin by invoking the `/runcastle:ideate` skill and drive the ideation session to completion." : "This is a Q&A session: invoke `/runcastle:qa`. Answer questions from the docs + code. Do NOT advance phases or emit tickets.";
|
|
6624
7089
|
return [
|
|
@@ -6654,6 +7119,9 @@ function renderSystemPrompt(feature, kind, waypoint) {
|
|
|
6654
7119
|
" acceptanceCriteria, seams, blockedBy = 1-based positions within the batch).",
|
|
6655
7120
|
"- `complete_phase({ phase })` \u2014 mark a phase done; advances past its gate.",
|
|
6656
7121
|
"",
|
|
7122
|
+
"## Rules",
|
|
7123
|
+
noCodeRule(docs),
|
|
7124
|
+
"",
|
|
6657
7125
|
"## Your task",
|
|
6658
7126
|
entry,
|
|
6659
7127
|
""
|
|
@@ -6744,9 +7212,30 @@ function renderConvergePrompt(feature) {
|
|
|
6744
7212
|
].join(`
|
|
6745
7213
|
`);
|
|
6746
7214
|
}
|
|
6747
|
-
function renderRevisitPrompt(feature) {
|
|
7215
|
+
function renderRevisitPrompt(feature, lap) {
|
|
6748
7216
|
const docs = featureDocsRel(feature.slug);
|
|
6749
|
-
const
|
|
7217
|
+
const lapIteration = lap ? [
|
|
7218
|
+
`## This is lap ${lap}`,
|
|
7219
|
+
`You are running **lap ${lap}** of this feature (ADR-0010): the human test-drove what`,
|
|
7220
|
+
`lap ${lap - 1} burned and came back with what it taught them. Unlike an`,
|
|
7221
|
+
"ordinary revisit, a lap MOVES the pipeline \u2014 you drive the whole front half of it in",
|
|
7222
|
+
"THIS session: grill the human about the drive, then `complete_phase` through",
|
|
7223
|
+
"**ideation \u2192 spec \u2192 tickets**, emitting this lap\u2019s tickets on the way. Nothing else",
|
|
7224
|
+
"advances it; if you stop early the feature sits at ideation with no lap tickets and",
|
|
7225
|
+
"the human has no way forward.",
|
|
7226
|
+
"",
|
|
7227
|
+
"Your inputs, both OPTIONAL \u2014 a missing one is normal, not an error:",
|
|
7228
|
+
`- \`${docs}/test-notes.md\`, section \`## Lap ${lap - 1}\` \u2014 what the drive surfaced.`,
|
|
7229
|
+
`- \`${docs}/spec.md\`, section \`## Later laps\` \u2014 scope parked by earlier laps.`,
|
|
7230
|
+
"Say plainly which you found, then interview the human from what they tell you.",
|
|
7231
|
+
"",
|
|
7232
|
+
`Write what you settle on into \`${docs}/decisions.md\` under a \`## Lap ${lap}\` heading`,
|
|
7233
|
+
"(supersede, never rewrite), amend `spec.md` for this lap (pruning anything you promote",
|
|
7234
|
+
"out of `## Later laps`), then `emit_tickets` for this lap\u2019s work. Finish by telling the",
|
|
7235
|
+
"human to review the cards and click Burn.",
|
|
7236
|
+
""
|
|
7237
|
+
] : [];
|
|
7238
|
+
const reviewIteration = !lap && feature.phase === "review" ? [
|
|
6750
7239
|
"## Review iteration",
|
|
6751
7240
|
"This feature is at **review**: its tickets were burned and the human has been",
|
|
6752
7241
|
"test-driving the branch. Treat this as a fix-ticket interview \u2014 read the latest",
|
|
@@ -6769,6 +7258,7 @@ function renderRevisitPrompt(feature) {
|
|
|
6769
7258
|
"into the record, then reconcile the tickets with it. The docs are the",
|
|
6770
7259
|
"artifact \u2014 later phases read them, never the transcripts.",
|
|
6771
7260
|
"",
|
|
7261
|
+
...lapIteration,
|
|
6772
7262
|
...reviewIteration,
|
|
6773
7263
|
"## Feature",
|
|
6774
7264
|
`- Slug: \`${feature.slug}\``,
|
|
@@ -6789,9 +7279,10 @@ function renderRevisitPrompt(feature) {
|
|
|
6789
7279
|
"- `record_event({ type, message })` \u2014 drop a timeline note summarising the revisit.",
|
|
6790
7280
|
"",
|
|
6791
7281
|
"## Rules",
|
|
6792
|
-
"- Do NOT call `complete_phase` \u2014 a revisit never moves the pipeline.",
|
|
7282
|
+
lap ? "- DO call `complete_phase` \u2014 this lap advances ideation \u2192 spec \u2192 tickets, and only you can." : "- Do NOT call `complete_phase` \u2014 a revisit never moves the pipeline.",
|
|
6793
7283
|
"- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
|
|
6794
7284
|
"- Docs first, tickets second: capture the decision prose before any ticket surgery.",
|
|
7285
|
+
noCodeRule(docs),
|
|
6795
7286
|
"",
|
|
6796
7287
|
"## Your task",
|
|
6797
7288
|
"Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
|
|
@@ -6819,6 +7310,29 @@ function renderPreparePrompt(brief) {
|
|
|
6819
7310
|
"service, creates or migrates a database, or writes outside the repo needs the human to",
|
|
6820
7311
|
"agree first \u2014 say what you are about to run and why, then wait.",
|
|
6821
7312
|
"",
|
|
7313
|
+
"## What the five host keys mean",
|
|
7314
|
+
"Take this from here. These semantics live in runcastle's source, not in the installed",
|
|
7315
|
+
"build \u2014 grepping the shipped bundle for them finds nothing and proves nothing.",
|
|
7316
|
+
"",
|
|
7317
|
+
"- `devCommand` \u2014 spawned in a drive-owned terminal pane for the length of a test drive.",
|
|
7318
|
+
' The first localhost URL it prints becomes the "Open app" link.',
|
|
7319
|
+
"- `driveSetupCommand` / `driveStopCommand` \u2014 run on the host, in the project repo, before",
|
|
7320
|
+
" the dev pane starts and after the drive stops.",
|
|
7321
|
+
"- `driveEnv` \u2014 `KEY=VALUE` lines whose values render `{{slug}}`, `{{branch}}` and `{{id}}`",
|
|
7322
|
+
" (`{{id}}` is the slug made identifier-safe \u2014 legal as a database name). Rendered ONCE per",
|
|
7323
|
+
" drive and shared by the setup hook, the dev pane and the stop hook. So this, not a helper",
|
|
7324
|
+
" script, is where a branch\u2192database-name derivation belongs: name it once here and have both",
|
|
7325
|
+
" hooks read the variable. A database per branch is exactly this shape \u2014",
|
|
7326
|
+
"",
|
|
7327
|
+
" driveEnv: DB_NAME=myapp_{{id}}",
|
|
7328
|
+
" DATABASE_URL=postgres://localhost/myapp_{{id}}",
|
|
7329
|
+
' driveSetupCommand: createdb "$DB_NAME" && npm run migrate',
|
|
7330
|
+
' driveStopCommand: dropdb --if-exists "$DB_NAME"',
|
|
7331
|
+
"",
|
|
7332
|
+
"- `dbResetCommand` \u2014 NOT part of the drive loop. Its only consumer is the migration-drift",
|
|
7333
|
+
" banner after a drive stops: when the drive branch and the branch you returned to disagree",
|
|
7334
|
+
" about migration files, this is offered as the one-click dev-database rebuild.",
|
|
7335
|
+
"",
|
|
6822
7336
|
...established.length > 0 ? [
|
|
6823
7337
|
"## Already established \u2014 do not re-derive",
|
|
6824
7338
|
"These are recorded findings. Treat them as true unless the human says otherwise;",
|
|
@@ -6849,6 +7363,36 @@ function renderPreparePrompt(brief) {
|
|
|
6849
7363
|
"## Your task",
|
|
6850
7364
|
"Open by telling the human which fields are still open and what you need from them for",
|
|
6851
7365
|
"each. Work them one at a time: propose, ask, run it if they agree, then record it.",
|
|
7366
|
+
"",
|
|
7367
|
+
"## Closing move: propose a dry-run drive",
|
|
7368
|
+
"Once the open keys are recorded, end the session by PROPOSING one \u2014 recorded values that",
|
|
7369
|
+
"have never been driven look exactly as trustworthy as values that run perfectly, and the",
|
|
7370
|
+
"first person to find out otherwise is someone mid-feature with a broken environment.",
|
|
7371
|
+
"",
|
|
7372
|
+
"**Ask before you act.** This starts services and creates a database on their machine. Name",
|
|
7373
|
+
"what will run \u2014 the setup command, the dev command, the stop command \u2014 then wait. If they",
|
|
7374
|
+
"decline, end the session normally: the keys simply stay unverified, and the drive UI will",
|
|
7375
|
+
"keep saying so.",
|
|
7376
|
+
"",
|
|
7377
|
+
"On a yes, `dry_run_drive({ action })` runs it in two halves and you inspect between them:",
|
|
7378
|
+
"",
|
|
7379
|
+
"1. `start` \u2014 the server renders `driveEnv`, runs `driveSetupCommand` and spawns `devCommand`",
|
|
7380
|
+
" in a real drive pane. Identity is the reserved slug `prep-dry-run` (`{{id}}` renders",
|
|
7381
|
+
" `prep_dry_run`, so the temp database is e.g. `myapp_prep_dry_run`) on the current branch.",
|
|
7382
|
+
" Nothing is checked out.",
|
|
7383
|
+
"2. While it is up, check what the server cannot: the temp database exists and is FRESH, the",
|
|
7384
|
+
" migrations applied, and the app actually RESPONDS at the sniffed URL \u2014 printing one is not",
|
|
7385
|
+
" the same as serving. `status` gives you the pane and the URL while you work.",
|
|
7386
|
+
"3. `stop` \u2014 the server runs `driveStopCommand`. Then check the cleanup: temp database gone,",
|
|
7387
|
+
" no orphaned process or container left behind.",
|
|
7388
|
+
"",
|
|
7389
|
+
"Anything off at any step, fix the finding with `record_finding` and run the WHOLE thing",
|
|
7390
|
+
"again until a pass is clean. A leftover `myapp_prep_dry_run` making the retry's `createdb`",
|
|
7391
|
+
"fail loudly is not a bug \u2014 that is the freshness check working.",
|
|
7392
|
+
"",
|
|
7393
|
+
"The verified stamps are computed server-side from what the machinery observed, and only on a",
|
|
7394
|
+
"clean full pass. You cannot mark your own homework: your deeper checks decide whether to",
|
|
7395
|
+
"retry, never what gets stamped.",
|
|
6852
7396
|
""
|
|
6853
7397
|
].join(`
|
|
6854
7398
|
`);
|
|
@@ -6903,7 +7447,9 @@ function renderProjectPrompt(brief) {
|
|
|
6903
7447
|
"ordinary file tools. The index says where.",
|
|
6904
7448
|
"",
|
|
6905
7449
|
"## Your task",
|
|
6906
|
-
"Invoke the `/runcastle:project` skill
|
|
7450
|
+
"Invoke the `/runcastle:project` skill, then open by asking the human what they brought.",
|
|
7451
|
+
"Do not explore the project first: orienting before you know the ask spends their wait on",
|
|
7452
|
+
"context you may not need. Once they have told you, read only what answering calls for.",
|
|
6907
7453
|
""
|
|
6908
7454
|
].join(`
|
|
6909
7455
|
`);
|
|
@@ -6915,7 +7461,7 @@ function renderSettings(hookClient, kind) {
|
|
|
6915
7461
|
const cmd = (event) => ({
|
|
6916
7462
|
type: "command",
|
|
6917
7463
|
command: `bun run "${hookClient}" ${event}`,
|
|
6918
|
-
timeout: event === "
|
|
7464
|
+
timeout: event === "session-start" || event === "session-end" ? 10 : 5
|
|
6919
7465
|
});
|
|
6920
7466
|
return {
|
|
6921
7467
|
permissions: { allow: [...RUNCASTLE_MCP_ALLOW_RULES, ...sessionBashAllowRules(kind)] },
|
|
@@ -6925,7 +7471,9 @@ function renderSettings(hookClient, kind) {
|
|
|
6925
7471
|
hooks: [cmd("session-start")]
|
|
6926
7472
|
})),
|
|
6927
7473
|
UserPromptSubmit: [{ hooks: [cmd("user-prompt")] }],
|
|
6928
|
-
|
|
7474
|
+
Stop: [{ hooks: [cmd("stop")] }],
|
|
7475
|
+
SessionEnd: [{ hooks: [cmd("session-end")] }],
|
|
7476
|
+
...kind === undefined || guardsEdits(kind) ? { PreToolUse: [{ matcher: EDIT_TOOL_MATCHER, hooks: [cmd("pre-tool")] }] } : {}
|
|
6929
7477
|
}
|
|
6930
7478
|
};
|
|
6931
7479
|
}
|
|
@@ -6941,13 +7489,13 @@ function renderMcpConfig(session, config2) {
|
|
|
6941
7489
|
};
|
|
6942
7490
|
}
|
|
6943
7491
|
async function writeSessionArtifacts(input) {
|
|
6944
|
-
const { session, feature, config: config2, waypoint, prepare, projectBrief } = input;
|
|
7492
|
+
const { session, feature, config: config2, waypoint, prepare, projectBrief, lap } = input;
|
|
6945
7493
|
const dir = sessionDir(session.id);
|
|
6946
7494
|
mkdirSync6(dir, { recursive: true });
|
|
6947
7495
|
const systemPromptPath = join12(dir, "system-prompt.md");
|
|
6948
7496
|
const settingsPath = join12(dir, "settings.json");
|
|
6949
7497
|
const mcpConfigPath = join12(dir, "mcp.json");
|
|
6950
|
-
const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
|
|
7498
|
+
const systemPrompt = feature ? renderSystemPrompt(feature, session.kind, waypoint, lap) : prepare ? renderPreparePrompt(prepare) : projectBrief ? renderProjectPrompt(projectBrief) : (() => {
|
|
6951
7499
|
throw new Error(`session ${session.id} has no feature and no project-session brief`);
|
|
6952
7500
|
})();
|
|
6953
7501
|
writeFileSync2(systemPromptPath, systemPrompt, "utf8");
|
|
@@ -6959,6 +7507,7 @@ var RUNCASTLE_MCP_ALLOW_RULES, SESSION_BASH_READ_RULES, SESSION_BASH_WRITE_RULES
|
|
|
6959
7507
|
var init_artifacts = __esm(() => {
|
|
6960
7508
|
init_paths();
|
|
6961
7509
|
init_asset_paths();
|
|
7510
|
+
init_edit_guard();
|
|
6962
7511
|
RUNCASTLE_MCP_ALLOW_RULES = [
|
|
6963
7512
|
"mcp__runcastle__get_feature_context",
|
|
6964
7513
|
"mcp__runcastle__emit_tickets",
|
|
@@ -6970,6 +7519,7 @@ var init_artifacts = __esm(() => {
|
|
|
6970
7519
|
"mcp__runcastle__emit_waypoints",
|
|
6971
7520
|
"mcp__runcastle__resolve_waypoint",
|
|
6972
7521
|
"mcp__runcastle__record_finding",
|
|
7522
|
+
"mcp__runcastle__dry_run_drive",
|
|
6973
7523
|
"mcp__runcastle__create_feature",
|
|
6974
7524
|
"mcp__runcastle__get_project_context",
|
|
6975
7525
|
"mcp__runcastle__get_work_record"
|
|
@@ -6995,9 +7545,9 @@ var init_artifacts = __esm(() => {
|
|
|
6995
7545
|
|
|
6996
7546
|
// src/launcher/launcher.ts
|
|
6997
7547
|
import { existsSync as existsSync13 } from "fs";
|
|
6998
|
-
import { dirname as dirname5, join as join13, resolve as
|
|
7548
|
+
import { dirname as dirname5, join as join13, resolve as resolve9 } from "path";
|
|
6999
7549
|
import { fileURLToPath as fileURLToPath5 } from "url";
|
|
7000
|
-
import { and as
|
|
7550
|
+
import { and as and9, eq as eq11 } from "drizzle-orm";
|
|
7001
7551
|
function ptyExitMessage(exitCode) {
|
|
7002
7552
|
const label = typeof exitCode === "number" ? String(exitCode) : "unknown";
|
|
7003
7553
|
return `terminal exited (code ${label})`;
|
|
@@ -7032,7 +7582,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
|
|
|
7032
7582
|
const rel = join13("packages", "skills", "packs", "runcastle");
|
|
7033
7583
|
const override = process.env[SKILLS_DIR_ENV];
|
|
7034
7584
|
if (override) {
|
|
7035
|
-
const dir2 = join13(
|
|
7585
|
+
const dir2 = join13(resolve9(override), "packs", "runcastle");
|
|
7036
7586
|
if (existsSync13(dir2))
|
|
7037
7587
|
return dir2;
|
|
7038
7588
|
throw new Error(`${SKILLS_DIR_ENV}=${override} has no plugin dir at ${dir2}`);
|
|
@@ -7054,7 +7604,7 @@ function resolvePluginDir(fromDir = dirname5(fileURLToPath5(import.meta.url))) {
|
|
|
7054
7604
|
`)}`);
|
|
7055
7605
|
}
|
|
7056
7606
|
function activeRunFor(ctx, featureId) {
|
|
7057
|
-
const row = ctx.db.select().from(runs).where(
|
|
7607
|
+
const row = ctx.db.select().from(runs).where(and9(eq11(runs.featureId, featureId), eq11(runs.status, "running"))).limit(1).get();
|
|
7058
7608
|
return row ? rowToRun(row) : null;
|
|
7059
7609
|
}
|
|
7060
7610
|
function assertSpawnable(ctx, feature, excludeSessionId) {
|
|
@@ -7115,12 +7665,13 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
7115
7665
|
kind: input.kind,
|
|
7116
7666
|
worktreePath
|
|
7117
7667
|
});
|
|
7668
|
+
const plan = planKickoff({ kind: input.kind, lap: feature.lap, kickoffLine: input.kickoffLine });
|
|
7118
7669
|
let waypoint;
|
|
7119
7670
|
let resumeSessionId;
|
|
7120
7671
|
let resumeUnavailableFrom;
|
|
7121
7672
|
if (input.waypointId) {
|
|
7122
7673
|
const before = getWaypoint(ctx, input.waypointId);
|
|
7123
|
-
if (before.lastSessionId) {
|
|
7674
|
+
if (before.lastSessionId && !plan.explicit) {
|
|
7124
7675
|
resumeSessionId = getSessionRow(ctx, before.lastSessionId)?.ccSessionId ?? undefined;
|
|
7125
7676
|
if (!resumeSessionId)
|
|
7126
7677
|
resumeUnavailableFrom = before.lastSessionId;
|
|
@@ -7140,17 +7691,19 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
7140
7691
|
markSessionEnded(ctx, session.id);
|
|
7141
7692
|
throw e;
|
|
7142
7693
|
}
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7694
|
+
if (!plan.explicit) {
|
|
7695
|
+
const prior = mostRecentResumableSession(ctx, feature.id);
|
|
7696
|
+
if (prior?.ccSessionId) {
|
|
7697
|
+
resumeSessionId = prior.ccSessionId;
|
|
7698
|
+
} else {
|
|
7699
|
+
resumeUnavailableFrom = "revisit";
|
|
7700
|
+
}
|
|
7148
7701
|
}
|
|
7149
7702
|
}
|
|
7150
|
-
if (input.kind !== "waypoint" && input.kind !== "revisit") {
|
|
7703
|
+
if (input.kind !== "waypoint" && input.kind !== "revisit" && !plan.explicit) {
|
|
7151
7704
|
resumeSessionId = mostRecentResumableSession(ctx, feature.id, input.kind)?.ccSessionId;
|
|
7152
7705
|
}
|
|
7153
|
-
const kickoffLine =
|
|
7706
|
+
const kickoffLine = plan.line ?? (resumeSessionId ? resumeKickoffLine(input.kind) : undefined);
|
|
7154
7707
|
if (kickoffLine)
|
|
7155
7708
|
setKickoffOverride(session.id, kickoffLine);
|
|
7156
7709
|
emit(ctx, feature.id, {
|
|
@@ -7184,7 +7737,8 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
7184
7737
|
feature,
|
|
7185
7738
|
project,
|
|
7186
7739
|
config: ctx.config,
|
|
7187
|
-
waypoint
|
|
7740
|
+
waypoint,
|
|
7741
|
+
lap: plan.lap
|
|
7188
7742
|
});
|
|
7189
7743
|
const serverUrl = serverUrlFor(ctx.config);
|
|
7190
7744
|
const buildInput = {
|
|
@@ -7225,11 +7779,16 @@ async function launchPrepareSession(ctx, input, opts = {}) {
|
|
|
7225
7779
|
kind: "prepare",
|
|
7226
7780
|
worktreePath: project.repoPath
|
|
7227
7781
|
});
|
|
7228
|
-
const resumeSessionId = mostRecentResumableProjectSession(ctx, project.id, "prepare")?.ccSessionId;
|
|
7782
|
+
const resumeSessionId = input.fresh ? undefined : mostRecentResumableProjectSession(ctx, project.id, "prepare")?.ccSessionId;
|
|
7229
7783
|
emitProject(ctx, project.id, {
|
|
7230
7784
|
type: "session.launching",
|
|
7231
|
-
message: "launching preparation session",
|
|
7232
|
-
data: {
|
|
7785
|
+
message: input.fresh ? "launching preparation session from scratch" : "launching preparation session",
|
|
7786
|
+
data: {
|
|
7787
|
+
sessionId: session.id,
|
|
7788
|
+
kind: "prepare",
|
|
7789
|
+
worktreePath: project.repoPath,
|
|
7790
|
+
...input.fresh ? { fresh: true } : {}
|
|
7791
|
+
}
|
|
7233
7792
|
});
|
|
7234
7793
|
if (resumeSessionId) {
|
|
7235
7794
|
emitProject(ctx, project.id, {
|
|
@@ -7380,6 +7939,7 @@ async function converge(ctx, input, opts = {}) {
|
|
|
7380
7939
|
if (!gate)
|
|
7381
7940
|
throw new GateError("feature is already at the final phase");
|
|
7382
7941
|
const result = checkGate(ctx, gate.check, feature);
|
|
7942
|
+
let overrode = false;
|
|
7383
7943
|
if (result.satisfied) {
|
|
7384
7944
|
const next = nextPhase(feature);
|
|
7385
7945
|
if (!next)
|
|
@@ -7387,10 +7947,21 @@ async function converge(ctx, input, opts = {}) {
|
|
|
7387
7947
|
setPhase(ctx, feature.id, next, "phase.advanced", `converging (${next})`);
|
|
7388
7948
|
} else if (input.overrideReason) {
|
|
7389
7949
|
overrideGate(ctx, feature.id, gate.id, input.overrideReason);
|
|
7950
|
+
overrode = true;
|
|
7390
7951
|
} else {
|
|
7391
7952
|
throw new GateError(result.reason ?? "the map is not ready to converge \u2014 resolve its waypoints or override with a reason");
|
|
7392
7953
|
}
|
|
7393
|
-
|
|
7954
|
+
try {
|
|
7955
|
+
return await launchSession(ctx, { featureId: feature.id, kind: "converge" }, opts);
|
|
7956
|
+
} catch (e) {
|
|
7957
|
+
if (overrode)
|
|
7958
|
+
undoLastGateOverride(ctx, feature.id, gate.id);
|
|
7959
|
+
setPhase(ctx, feature.id, feature.phase, "converge.aborted", `converge aborted \u2014 its session could not be opened (${errMsg3(e)}); back at ${feature.phase}`);
|
|
7960
|
+
throw e;
|
|
7961
|
+
}
|
|
7962
|
+
}
|
|
7963
|
+
function errMsg3(e) {
|
|
7964
|
+
return e instanceof Error ? e.message : String(e);
|
|
7394
7965
|
}
|
|
7395
7966
|
async function reconverge(ctx, feature, opts) {
|
|
7396
7967
|
if (feature.phase !== "spec" && feature.phase !== "tickets") {
|
|
@@ -7543,18 +8114,16 @@ var init_feature = __esm(() => {
|
|
|
7543
8114
|
})).mutation(({ ctx, input }) => launchSession(ctx, input)),
|
|
7544
8115
|
workWaypoint: publicProcedure.input(z6.object({ featureId: z6.string(), waypointId: z6.string(), endLive: z6.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
|
|
7545
8116
|
converge: publicProcedure.input(z6.object({ featureId: z6.string(), overrideReason: z6.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
|
|
7546
|
-
rethink: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => {
|
|
7547
|
-
|
|
7548
|
-
|
|
7549
|
-
|
|
7550
|
-
|
|
7551
|
-
kickoffLine: lapKickoff(feature.lap)
|
|
7552
|
-
});
|
|
7553
|
-
}),
|
|
8117
|
+
rethink: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => rethinkAndLaunch(ctx, input.featureId, (feature) => launchSession(ctx, {
|
|
8118
|
+
featureId: input.featureId,
|
|
8119
|
+
kind: "revisit",
|
|
8120
|
+
kickoffLine: lapKickoff(feature.lap)
|
|
8121
|
+
}))),
|
|
7554
8122
|
resendKickoff: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
|
|
7555
8123
|
endSession: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
|
|
7556
8124
|
advance: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
|
|
7557
8125
|
overrideGate: publicProcedure.input(z6.object({ featureId: z6.string(), gate: gateId, reason: z6.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
|
|
8126
|
+
undoGateOverride: publicProcedure.input(z6.object({ featureId: z6.string(), gate: gateId })).mutation(({ ctx, input }) => undoGateOverride(ctx, input.featureId, input.gate)),
|
|
7558
8127
|
archive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
|
|
7559
8128
|
unarchive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
|
|
7560
8129
|
delete: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
|
|
@@ -7565,6 +8134,10 @@ var init_feature = __esm(() => {
|
|
|
7565
8134
|
return testDrive(ctx, project, feature, input.action);
|
|
7566
8135
|
}),
|
|
7567
8136
|
driveInfo: publicProcedure.query(() => activeDriveInfo()),
|
|
8137
|
+
commitCount: publicProcedure.input(z6.object({ featureId: z6.string() })).query(({ ctx, input }) => {
|
|
8138
|
+
const feature = getFeatureRow(ctx, input.featureId);
|
|
8139
|
+
return reviewCommitCount(projectForFeature(ctx, feature), feature);
|
|
8140
|
+
}),
|
|
7568
8141
|
merge: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(async ({ ctx, input }) => {
|
|
7569
8142
|
const feature = getFeatureRow(ctx, input.featureId);
|
|
7570
8143
|
const project = projectForFeature(ctx, feature);
|
|
@@ -7590,7 +8163,7 @@ var init_feature = __esm(() => {
|
|
|
7590
8163
|
// src/services/fsbrowse.ts
|
|
7591
8164
|
import { existsSync as existsSync14, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
|
|
7592
8165
|
import { homedir as homedir2 } from "os";
|
|
7593
|
-
import { basename, dirname as dirname6, isAbsolute, join as join14, resolve as
|
|
8166
|
+
import { basename, dirname as dirname6, isAbsolute as isAbsolute2, join as join14, resolve as resolve10, sep as sep3 } from "path";
|
|
7594
8167
|
function expandPath(input, home = homedir2()) {
|
|
7595
8168
|
const trimmed = input.trim();
|
|
7596
8169
|
if (trimmed === "")
|
|
@@ -7600,7 +8173,7 @@ function expandPath(input, home = homedir2()) {
|
|
|
7600
8173
|
out = home;
|
|
7601
8174
|
else if (out.startsWith("~/") || out.startsWith("~\\"))
|
|
7602
8175
|
out = join14(home, out.slice(2));
|
|
7603
|
-
return
|
|
8176
|
+
return resolve10(out);
|
|
7604
8177
|
}
|
|
7605
8178
|
function looksLikeRepo(dir) {
|
|
7606
8179
|
try {
|
|
@@ -7656,7 +8229,7 @@ function parentOf(dir) {
|
|
|
7656
8229
|
}
|
|
7657
8230
|
function browseDir(input, showHidden = false, home = homedir2()) {
|
|
7658
8231
|
const target = input && input.trim() !== "" ? expandPath(input, home) : home;
|
|
7659
|
-
if (!
|
|
8232
|
+
if (!isAbsolute2(target)) {
|
|
7660
8233
|
throw new InvalidInputError(`path is not absolute: ${target}`);
|
|
7661
8234
|
}
|
|
7662
8235
|
if (!existsSync14(target)) {
|
|
@@ -7667,13 +8240,13 @@ function browseDir(input, showHidden = false, home = homedir2()) {
|
|
|
7667
8240
|
if (!statSync2(target).isDirectory())
|
|
7668
8241
|
dir = dirname6(target);
|
|
7669
8242
|
} catch (e) {
|
|
7670
|
-
throw new InvalidInputError(`cannot read path: ${target} (${
|
|
8243
|
+
throw new InvalidInputError(`cannot read path: ${target} (${errMsg4(e)})`);
|
|
7671
8244
|
}
|
|
7672
8245
|
let dirents;
|
|
7673
8246
|
try {
|
|
7674
8247
|
dirents = readdirSync5(dir, { withFileTypes: true, encoding: "utf8" });
|
|
7675
8248
|
} catch (e) {
|
|
7676
|
-
throw new InvalidInputError(`cannot read directory: ${dir} (${
|
|
8249
|
+
throw new InvalidInputError(`cannot read directory: ${dir} (${errMsg4(e)})`);
|
|
7677
8250
|
}
|
|
7678
8251
|
const entries = [];
|
|
7679
8252
|
let truncated = false;
|
|
@@ -7689,6 +8262,8 @@ function browseDir(input, showHidden = false, home = homedir2()) {
|
|
|
7689
8262
|
continue;
|
|
7690
8263
|
const path = join14(dir, name);
|
|
7691
8264
|
const isSymlink = dirent.isSymbolicLink();
|
|
8265
|
+
if (isSymlink && !showHidden)
|
|
8266
|
+
continue;
|
|
7692
8267
|
let isDirectory = dirent.isDirectory();
|
|
7693
8268
|
if (isSymlink) {
|
|
7694
8269
|
try {
|
|
@@ -7711,7 +8286,7 @@ function browseDir(input, showHidden = false, home = homedir2()) {
|
|
|
7711
8286
|
truncated
|
|
7712
8287
|
};
|
|
7713
8288
|
}
|
|
7714
|
-
function
|
|
8289
|
+
function errMsg4(e) {
|
|
7715
8290
|
return e instanceof Error ? e.message : String(e);
|
|
7716
8291
|
}
|
|
7717
8292
|
var SKIP_ALWAYS, MAX_ENTRIES = 1000, COMMON_DIRS;
|
|
@@ -7724,7 +8299,7 @@ var init_fsbrowse = __esm(() => {
|
|
|
7724
8299
|
// src/services/projects.ts
|
|
7725
8300
|
import { existsSync as existsSync15 } from "fs";
|
|
7726
8301
|
import { basename as basename2, join as join15 } from "path";
|
|
7727
|
-
import { eq as
|
|
8302
|
+
import { eq as eq12, isNull } from "drizzle-orm";
|
|
7728
8303
|
function listProjects(ctx) {
|
|
7729
8304
|
return ctx.db.select().from(projects).where(isNull(projects.closedAt)).all().map(rowToProject);
|
|
7730
8305
|
}
|
|
@@ -7746,7 +8321,7 @@ async function openProject(ctx, rawPath) {
|
|
|
7746
8321
|
const name = basename2(repoPath) || repoPath;
|
|
7747
8322
|
const existing = getProjectByRepoPath(ctx, repoPath) ?? findProjectByCanonPath(ctx, repoPath);
|
|
7748
8323
|
if (existing) {
|
|
7749
|
-
ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(
|
|
8324
|
+
ctx.db.update(projects).set({ mainBranch, repoPath, closedAt: null }).where(eq12(projects.id, existing.id)).run();
|
|
7750
8325
|
const reopened = { ...existing, mainBranch, repoPath };
|
|
7751
8326
|
emitProject(ctx, existing.id, {
|
|
7752
8327
|
type: "project.opened",
|
|
@@ -7777,7 +8352,7 @@ function closeProject(ctx, projectId) {
|
|
|
7777
8352
|
if (projectHasActiveRun(ctx, projectId)) {
|
|
7778
8353
|
throw new InvalidInputError(`cannot close ${project.name}: a run is in flight \u2014 wait for it to finish or cancel it first`);
|
|
7779
8354
|
}
|
|
7780
|
-
ctx.db.update(projects).set({ closedAt: Date.now() }).where(
|
|
8355
|
+
ctx.db.update(projects).set({ closedAt: Date.now() }).where(eq12(projects.id, projectId)).run();
|
|
7781
8356
|
emitProject(ctx, projectId, {
|
|
7782
8357
|
type: "project.closed",
|
|
7783
8358
|
message: `project ${project.name} closed`
|
|
@@ -7786,7 +8361,7 @@ function closeProject(ctx, projectId) {
|
|
|
7786
8361
|
}
|
|
7787
8362
|
function renameProject(ctx, projectId, name) {
|
|
7788
8363
|
const project = requireProjectById(ctx, projectId);
|
|
7789
|
-
ctx.db.update(projects).set({ name }).where(
|
|
8364
|
+
ctx.db.update(projects).set({ name }).where(eq12(projects.id, projectId)).run();
|
|
7790
8365
|
emitProject(ctx, projectId, {
|
|
7791
8366
|
type: "project.renamed",
|
|
7792
8367
|
message: `project renamed ${project.name} \u2192 ${name}`,
|
|
@@ -7832,6 +8407,7 @@ var init_projects = __esm(() => {
|
|
|
7832
8407
|
import * as z7 from "zod";
|
|
7833
8408
|
var projectRouter;
|
|
7834
8409
|
var init_project = __esm(() => {
|
|
8410
|
+
init_src();
|
|
7835
8411
|
init_fsbrowse();
|
|
7836
8412
|
init_git();
|
|
7837
8413
|
init_prep();
|
|
@@ -7847,12 +8423,13 @@ var init_project = __esm(() => {
|
|
|
7847
8423
|
branches: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => listBranches(requireProjectById(ctx, input.projectId))),
|
|
7848
8424
|
open: publicProcedure.input(z7.object({ repoPath: z7.string().min(1) })).mutation(({ ctx, input }) => openProject(ctx, input.repoPath)),
|
|
7849
8425
|
close: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => closeProject(ctx, input.projectId)),
|
|
7850
|
-
rename: publicProcedure.input(z7.object({ projectId: z7.string(), name:
|
|
7851
|
-
talkToPrep: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId })),
|
|
8426
|
+
rename: publicProcedure.input(z7.object({ projectId: z7.string(), name: ProjectName })).mutation(({ ctx, input }) => renameProject(ctx, input.projectId, input.name)),
|
|
8427
|
+
talkToPrep: publicProcedure.input(z7.object({ projectId: z7.string(), fresh: z7.boolean().optional() })).mutation(({ ctx, input }) => launchPrepareSession(ctx, { projectId: input.projectId, fresh: input.fresh })),
|
|
7852
8428
|
prepSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "prepare")),
|
|
7853
8429
|
talkToProject: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => launchProjectSession(ctx, { projectId: input.projectId })),
|
|
7854
8430
|
projectSession: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => activeProjectSession(ctx, input.projectId, "project")),
|
|
7855
|
-
prep: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => prepView(ctx, requireProjectById(ctx, input.projectId)))
|
|
8431
|
+
prep: publicProcedure.input(z7.object({ projectId: z7.string() })).query(({ ctx, input }) => prepView(ctx, requireProjectById(ctx, input.projectId))),
|
|
8432
|
+
dryRunStop: publicProcedure.input(z7.object({ projectId: z7.string() })).mutation(({ ctx, input }) => dryRunDrive(ctx, requireProjectById(ctx, input.projectId), "stop"))
|
|
7856
8433
|
});
|
|
7857
8434
|
});
|
|
7858
8435
|
|
|
@@ -7878,7 +8455,7 @@ var init_run = __esm(() => {
|
|
|
7878
8455
|
import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
|
|
7879
8456
|
import { dirname as dirname7 } from "path";
|
|
7880
8457
|
import * as z9 from "zod";
|
|
7881
|
-
import { eq as
|
|
8458
|
+
import { eq as eq13 } from "drizzle-orm";
|
|
7882
8459
|
function readRawConfig(configFile) {
|
|
7883
8460
|
if (!existsSync16(configFile))
|
|
7884
8461
|
return {};
|
|
@@ -7918,7 +8495,7 @@ function projectOverrides(ctx, projectId) {
|
|
|
7918
8495
|
driveSetupCommand: projects.driveSetupCommand,
|
|
7919
8496
|
driveStopCommand: projects.driveStopCommand,
|
|
7920
8497
|
driveEnv: projects.driveEnv
|
|
7921
|
-
}).from(projects).where(
|
|
8498
|
+
}).from(projects).where(eq13(projects.id, projectId)).get();
|
|
7922
8499
|
return {
|
|
7923
8500
|
model: row?.model ?? null,
|
|
7924
8501
|
sandbox: row?.sandbox ?? null,
|
|
@@ -7932,26 +8509,26 @@ function projectOverrides(ctx, projectId) {
|
|
|
7932
8509
|
driveEnv: row?.driveEnv ?? null
|
|
7933
8510
|
};
|
|
7934
8511
|
}
|
|
7935
|
-
function resolveField(
|
|
8512
|
+
function resolveField(desc7, layers) {
|
|
7936
8513
|
const scoped = layers.overrides !== null;
|
|
7937
|
-
const scope = scoped &&
|
|
7938
|
-
const base = { key:
|
|
7939
|
-
if (
|
|
7940
|
-
const raw = layers.env[
|
|
8514
|
+
const scope = scoped && desc7.projectColumn ? "project" : "global";
|
|
8515
|
+
const base = { key: desc7.key, restartRequired: desc7.restartRequired, scope };
|
|
8516
|
+
if (desc7.envVar) {
|
|
8517
|
+
const raw = layers.env[desc7.envVar];
|
|
7941
8518
|
if (raw !== undefined && raw !== "") {
|
|
7942
|
-
return { ...base, value:
|
|
8519
|
+
return { ...base, value: desc7.parseEnv(raw), source: "env", editable: false };
|
|
7943
8520
|
}
|
|
7944
8521
|
}
|
|
7945
|
-
if (layers.overrides &&
|
|
7946
|
-
const ov = layers.overrides[
|
|
8522
|
+
if (layers.overrides && desc7.projectColumn) {
|
|
8523
|
+
const ov = layers.overrides[desc7.projectColumn];
|
|
7947
8524
|
if (ov !== null && ov !== "") {
|
|
7948
8525
|
return { ...base, value: ov, source: "project", editable: true };
|
|
7949
8526
|
}
|
|
7950
8527
|
}
|
|
7951
|
-
if (
|
|
7952
|
-
return { ...base, value: layers.fileRaw[
|
|
8528
|
+
if (desc7.configKey && Object.prototype.hasOwnProperty.call(layers.fileRaw, desc7.configKey)) {
|
|
8529
|
+
return { ...base, value: layers.fileRaw[desc7.configKey], source: "file", editable: true };
|
|
7953
8530
|
}
|
|
7954
|
-
const value =
|
|
8531
|
+
const value = desc7.configKey ? DEFAULTS[desc7.configKey] ?? null : null;
|
|
7955
8532
|
return { ...base, value, source: "default", editable: true };
|
|
7956
8533
|
}
|
|
7957
8534
|
function getSettings(ctx, projectId, io = {}) {
|
|
@@ -7968,56 +8545,56 @@ function updateSettings(ctx, input, io = {}) {
|
|
|
7968
8545
|
if (input.key.startsWith("stepModels.")) {
|
|
7969
8546
|
return updateStepModel(ctx, input, configFile, io);
|
|
7970
8547
|
}
|
|
7971
|
-
const
|
|
7972
|
-
if (!
|
|
8548
|
+
const desc7 = DESCRIPTORS.find((d) => d.key === input.key);
|
|
8549
|
+
if (!desc7)
|
|
7973
8550
|
throw new InvalidInputError(`unknown setting: ${input.key}`);
|
|
7974
|
-
if (
|
|
7975
|
-
const raw = env[
|
|
8551
|
+
if (desc7.envVar) {
|
|
8552
|
+
const raw = env[desc7.envVar];
|
|
7976
8553
|
if (raw !== undefined && raw !== "") {
|
|
7977
|
-
throw new InvalidInputError(`${
|
|
8554
|
+
throw new InvalidInputError(`${desc7.key} is set by environment variable ${desc7.envVar}; unset it to edit`);
|
|
7978
8555
|
}
|
|
7979
8556
|
}
|
|
7980
|
-
const toProject = input.projectId !== undefined &&
|
|
8557
|
+
const toProject = input.projectId !== undefined && desc7.projectColumn !== undefined;
|
|
7981
8558
|
if (input.value === null) {
|
|
7982
8559
|
if (!toProject)
|
|
7983
|
-
throw new InvalidInputError(`${
|
|
8560
|
+
throw new InvalidInputError(`${desc7.key} cannot be cleared`);
|
|
7984
8561
|
const project = requireProjectById(ctx, input.projectId);
|
|
7985
|
-
ctx.db.update(projects).set({ [
|
|
7986
|
-
recordHuman(ctx, project.id,
|
|
8562
|
+
ctx.db.update(projects).set({ [desc7.projectColumn]: null }).where(eq13(projects.id, project.id)).run();
|
|
8563
|
+
recordHuman(ctx, project.id, desc7.key, null);
|
|
7987
8564
|
emitProject(ctx, project.id, {
|
|
7988
8565
|
type: "settings.updated",
|
|
7989
|
-
message: `${
|
|
7990
|
-
data: { key:
|
|
8566
|
+
message: `${desc7.key} override cleared`,
|
|
8567
|
+
data: { key: desc7.key, scope: "project", value: null }
|
|
7991
8568
|
});
|
|
7992
|
-
return field(getSettings(ctx, input.projectId, io),
|
|
8569
|
+
return field(getSettings(ctx, input.projectId, io), desc7.key);
|
|
7993
8570
|
}
|
|
7994
|
-
const parsed =
|
|
8571
|
+
const parsed = desc7.valueSchema.safeParse(input.value);
|
|
7995
8572
|
if (!parsed.success) {
|
|
7996
|
-
throw new InvalidInputError(`invalid value for ${
|
|
8573
|
+
throw new InvalidInputError(`invalid value for ${desc7.key}: ${parsed.error.issues[0]?.message}`);
|
|
7997
8574
|
}
|
|
7998
8575
|
const value = parsed.data;
|
|
7999
8576
|
if (toProject) {
|
|
8000
8577
|
const project = requireProjectById(ctx, input.projectId);
|
|
8001
|
-
ctx.db.update(projects).set({ [
|
|
8002
|
-
recordHuman(ctx, project.id,
|
|
8578
|
+
ctx.db.update(projects).set({ [desc7.projectColumn]: String(value) }).where(eq13(projects.id, project.id)).run();
|
|
8579
|
+
recordHuman(ctx, project.id, desc7.key, String(value));
|
|
8003
8580
|
emitProject(ctx, project.id, {
|
|
8004
8581
|
type: "settings.updated",
|
|
8005
|
-
message: `${
|
|
8006
|
-
data: { key:
|
|
8582
|
+
message: `${desc7.key} override set to ${String(value)}`,
|
|
8583
|
+
data: { key: desc7.key, scope: "project", value }
|
|
8007
8584
|
});
|
|
8008
|
-
return field(getSettings(ctx, input.projectId, io),
|
|
8585
|
+
return field(getSettings(ctx, input.projectId, io), desc7.key);
|
|
8009
8586
|
}
|
|
8010
|
-
if (!
|
|
8011
|
-
throw new InvalidInputError(`${
|
|
8587
|
+
if (!desc7.configKey) {
|
|
8588
|
+
throw new InvalidInputError(`${desc7.key} is a per-project setting; provide a projectId`);
|
|
8012
8589
|
}
|
|
8013
|
-
writeGlobal(configFile,
|
|
8014
|
-
ctx.config[
|
|
8590
|
+
writeGlobal(configFile, desc7.configKey, value);
|
|
8591
|
+
ctx.config[desc7.configKey] = value;
|
|
8015
8592
|
emitProject(ctx, GLOBAL_EVENT_KEY, {
|
|
8016
8593
|
type: "settings.updated",
|
|
8017
|
-
message: `${
|
|
8018
|
-
data: { key:
|
|
8594
|
+
message: `${desc7.key} set to ${String(value)}`,
|
|
8595
|
+
data: { key: desc7.key, scope: "global", value }
|
|
8019
8596
|
});
|
|
8020
|
-
return field(getSettings(ctx, input.projectId, io),
|
|
8597
|
+
return field(getSettings(ctx, input.projectId, io), desc7.key);
|
|
8021
8598
|
}
|
|
8022
8599
|
function writeGlobal(configFile, configKey, value) {
|
|
8023
8600
|
const raw = readRawConfig(configFile);
|
|
@@ -8415,7 +8992,7 @@ var init_doctor = __esm(() => {
|
|
|
8415
8992
|
// src/doctor/system-exec.ts
|
|
8416
8993
|
import { spawn as spawn3 } from "child_process";
|
|
8417
8994
|
function createSystemExec(opts = {}) {
|
|
8418
|
-
return (command, args) => new Promise((
|
|
8995
|
+
return (command, args) => new Promise((resolve11) => {
|
|
8419
8996
|
const { file, args: spawnArgs } = resolveSpawnTarget(command, args);
|
|
8420
8997
|
let stdout = "";
|
|
8421
8998
|
let stderr = "";
|
|
@@ -8427,10 +9004,10 @@ function createSystemExec(opts = {}) {
|
|
|
8427
9004
|
stderr += d.toString();
|
|
8428
9005
|
});
|
|
8429
9006
|
child.on("error", (err) => {
|
|
8430
|
-
|
|
9007
|
+
resolve11({ ok: false, code: null, stdout, stderr: stderr || String(err) });
|
|
8431
9008
|
});
|
|
8432
9009
|
child.on("close", (code) => {
|
|
8433
|
-
|
|
9010
|
+
resolve11({ ok: true, code: code ?? 0, stdout, stderr });
|
|
8434
9011
|
});
|
|
8435
9012
|
});
|
|
8436
9013
|
}
|
|
@@ -8653,6 +9230,29 @@ var init_setup2 = __esm(() => {
|
|
|
8653
9230
|
});
|
|
8654
9231
|
});
|
|
8655
9232
|
|
|
9233
|
+
// src/version.ts
|
|
9234
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
9235
|
+
import { dirname as dirname9, join as join17 } from "path";
|
|
9236
|
+
import { fileURLToPath as fileURLToPath7 } from "url";
|
|
9237
|
+
function runcastleVersion() {
|
|
9238
|
+
if (cached2)
|
|
9239
|
+
return cached2;
|
|
9240
|
+
const here = dirname9(fileURLToPath7(import.meta.url));
|
|
9241
|
+
for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
|
|
9242
|
+
try {
|
|
9243
|
+
const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
|
|
9244
|
+
if (typeof pkg.version === "string") {
|
|
9245
|
+
cached2 = pkg.version;
|
|
9246
|
+
return cached2;
|
|
9247
|
+
}
|
|
9248
|
+
} catch {}
|
|
9249
|
+
}
|
|
9250
|
+
cached2 = UNKNOWN_VERSION;
|
|
9251
|
+
return cached2;
|
|
9252
|
+
}
|
|
9253
|
+
var cached2, UNKNOWN_VERSION = "0.0.0";
|
|
9254
|
+
var init_version = () => {};
|
|
9255
|
+
|
|
8656
9256
|
// src/services/update-check.ts
|
|
8657
9257
|
function parse(version) {
|
|
8658
9258
|
const cleaned = version.trim().replace(/^v/, "");
|
|
@@ -8695,6 +9295,8 @@ function compareSemver(a, b) {
|
|
|
8695
9295
|
async function checkForUpdate(opts) {
|
|
8696
9296
|
const { current: current2 } = opts;
|
|
8697
9297
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
9298
|
+
if (current2 === UNKNOWN_VERSION)
|
|
9299
|
+
return NO_UPDATE(current2);
|
|
8698
9300
|
try {
|
|
8699
9301
|
const res = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`);
|
|
8700
9302
|
if (!res.ok)
|
|
@@ -8728,32 +9330,10 @@ var PACKAGE_NAME = "runcastle", UPDATE_COMMAND, NO_UPDATE = (current2) => ({
|
|
|
8728
9330
|
command: UPDATE_COMMAND
|
|
8729
9331
|
}), cache;
|
|
8730
9332
|
var init_update_check = __esm(() => {
|
|
9333
|
+
init_version();
|
|
8731
9334
|
UPDATE_COMMAND = `bun add -g ${PACKAGE_NAME}@latest`;
|
|
8732
9335
|
});
|
|
8733
9336
|
|
|
8734
|
-
// src/version.ts
|
|
8735
|
-
import { readFileSync as readFileSync8 } from "fs";
|
|
8736
|
-
import { dirname as dirname9, join as join17 } from "path";
|
|
8737
|
-
import { fileURLToPath as fileURLToPath7 } from "url";
|
|
8738
|
-
function runcastleVersion() {
|
|
8739
|
-
if (cached2)
|
|
8740
|
-
return cached2;
|
|
8741
|
-
const here = dirname9(fileURLToPath7(import.meta.url));
|
|
8742
|
-
for (const pkgPath of [join17(here, "..", "package.json"), join17(here, "package.json")]) {
|
|
8743
|
-
try {
|
|
8744
|
-
const pkg = JSON.parse(readFileSync8(pkgPath, "utf8"));
|
|
8745
|
-
if (typeof pkg.version === "string") {
|
|
8746
|
-
cached2 = pkg.version;
|
|
8747
|
-
return cached2;
|
|
8748
|
-
}
|
|
8749
|
-
} catch {}
|
|
8750
|
-
}
|
|
8751
|
-
cached2 = "0.0.0";
|
|
8752
|
-
return cached2;
|
|
8753
|
-
}
|
|
8754
|
-
var cached2;
|
|
8755
|
-
var init_version = () => {};
|
|
8756
|
-
|
|
8757
9337
|
// src/trpc/routers/system.ts
|
|
8758
9338
|
var systemRouter;
|
|
8759
9339
|
var init_system = __esm(() => {
|
|
@@ -8828,14 +9408,14 @@ var init_router = __esm(() => {
|
|
|
8828
9408
|
});
|
|
8829
9409
|
|
|
8830
9410
|
// src/workflows/reconcile-runs.ts
|
|
8831
|
-
import { eq as
|
|
9411
|
+
import { eq as eq14 } from "drizzle-orm";
|
|
8832
9412
|
async function reconcileStaleRuns(ctx) {
|
|
8833
|
-
const stale = ctx.db.select().from(runs).where(
|
|
9413
|
+
const stale = ctx.db.select().from(runs).where(eq14(runs.status, "running")).all().map(rowToRun);
|
|
8834
9414
|
const reconciled = [];
|
|
8835
9415
|
for (const run3 of stale) {
|
|
8836
9416
|
if (isRunActive(run3.id))
|
|
8837
9417
|
continue;
|
|
8838
|
-
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(
|
|
9418
|
+
ctx.db.update(runs).set({ status: "failed", endedAt: Date.now(), summary: "orphaned by server restart" }).where(eq14(runs.id, run3.id)).run();
|
|
8839
9419
|
const released = releaseForSession(ctx, run3.id);
|
|
8840
9420
|
const swept = workflowClaimsFeatureBranch(run3.workflow) ? sweepOrphanedBurning(ctx, run3.featureId, "orphaned by server restart \u2014 retry to resume its commits") : [];
|
|
8841
9421
|
if (workflowClaimsFeatureBranch(run3.workflow)) {
|
|
@@ -9065,7 +9645,7 @@ var init_cli = __esm(() => {
|
|
|
9065
9645
|
// src/bin/runcastle.ts
|
|
9066
9646
|
init_asset_paths();
|
|
9067
9647
|
init_version();
|
|
9068
|
-
import { dirname as dirname10, resolve as
|
|
9648
|
+
import { dirname as dirname10, resolve as resolve11 } from "path";
|
|
9069
9649
|
import { fileURLToPath as fileURLToPath8 } from "url";
|
|
9070
9650
|
function parseCommand(argv) {
|
|
9071
9651
|
const [first, ...rest] = argv;
|
|
@@ -9090,7 +9670,7 @@ Usage:
|
|
|
9090
9670
|
runcastle --help Show this help
|
|
9091
9671
|
`;
|
|
9092
9672
|
async function main(argv) {
|
|
9093
|
-
applyInstalledAssetEnv(
|
|
9673
|
+
applyInstalledAssetEnv(resolve11(dirname10(fileURLToPath8(import.meta.url)), ".."));
|
|
9094
9674
|
const { command, args } = parseCommand(argv);
|
|
9095
9675
|
switch (command) {
|
|
9096
9676
|
case "version":
|