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