runcastle 1.0.5 → 1.0.7
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 +289 -37
- package/index.js +286 -36
- package/package.json +1 -1
- package/skills/packs/runcastle/skills/revisit/SKILL.md +38 -0
- package/web/assets/index-EKuI75rm.css +1 -0
- package/web/assets/{index-CRhFvrAu.js → index-Y3ehCYIg.js} +2 -2
- package/web/index.html +2 -2
- package/web/assets/index-ClNOSIVg.css +0 -1
package/index.js
CHANGED
|
@@ -61,6 +61,9 @@ var init_paths = () => {};
|
|
|
61
61
|
|
|
62
62
|
// ../core/src/config.ts
|
|
63
63
|
import * as z from "zod";
|
|
64
|
+
function resolveSandboxImage(config) {
|
|
65
|
+
return config.sandboxImage ?? DEFAULT_SANDBOX_IMAGE;
|
|
66
|
+
}
|
|
64
67
|
function resolveModel(step, config, project, runOverride) {
|
|
65
68
|
return runOverride ?? config.stepModels[step] ?? project?.model ?? config.model;
|
|
66
69
|
}
|
|
@@ -76,13 +79,14 @@ var MODEL_STEPS, ModelStep, DEFAULT_SMOKE_MODEL = "claude-haiku-4-5-20251001", f
|
|
|
76
79
|
}
|
|
77
80
|
delete obj.smokeModel;
|
|
78
81
|
return obj;
|
|
79
|
-
}, RuncastleConfig;
|
|
82
|
+
}, RuncastleConfig, DEFAULT_SANDBOX_IMAGE = "sandcastle:runcastle";
|
|
80
83
|
var init_config = __esm(() => {
|
|
81
84
|
MODEL_STEPS = [
|
|
82
85
|
"ideation",
|
|
83
86
|
"qa",
|
|
84
87
|
"waypoint",
|
|
85
88
|
"converge",
|
|
89
|
+
"revisit",
|
|
86
90
|
"research",
|
|
87
91
|
"implement",
|
|
88
92
|
"smoke"
|
|
@@ -94,7 +98,8 @@ var init_config = __esm(() => {
|
|
|
94
98
|
stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
|
|
95
99
|
sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
|
|
96
100
|
mainBranch: z.string().default("main"),
|
|
97
|
-
sandboxImage: z.string().optional()
|
|
101
|
+
sandboxImage: z.string().optional(),
|
|
102
|
+
burnConcurrency: z.number().int().min(1).max(8).default(3)
|
|
98
103
|
}));
|
|
99
104
|
});
|
|
100
105
|
|
|
@@ -127,6 +132,9 @@ function loadConfig(env = process.env) {
|
|
|
127
132
|
overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
|
|
128
133
|
if (env.RUNCASTLE_SANDBOX_IMAGE)
|
|
129
134
|
overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
|
|
135
|
+
if (env.RUNCASTLE_BURN_CONCURRENCY) {
|
|
136
|
+
overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
|
|
137
|
+
}
|
|
130
138
|
const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
|
|
131
139
|
return RuncastleConfig.parse({ ...base, ...overrides });
|
|
132
140
|
}
|
|
@@ -170,8 +178,8 @@ var init_schemas = __esm(() => {
|
|
|
170
178
|
"shipped"
|
|
171
179
|
]);
|
|
172
180
|
FeatureSize = z2.enum(["full", "collapsed"]);
|
|
173
|
-
TicketStatus = z2.enum(["pending", "burning", "done", "failed"]);
|
|
174
|
-
SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge"]);
|
|
181
|
+
TicketStatus = z2.enum(["pending", "burning", "done", "failed", "cancelled"]);
|
|
182
|
+
SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit"]);
|
|
175
183
|
RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
|
|
176
184
|
FeatureStatus = z2.enum(["active", "shipped"]);
|
|
177
185
|
SessionStatus = z2.enum(["launching", "live", "ended"]);
|
|
@@ -1455,7 +1463,7 @@ var init_waypoints = __esm(() => {
|
|
|
1455
1463
|
});
|
|
1456
1464
|
|
|
1457
1465
|
// src/launcher/sessions.ts
|
|
1458
|
-
import { and as and4, desc as desc2, eq as eq4, inArray, sql as sql2 } from "drizzle-orm";
|
|
1466
|
+
import { and as and4, desc as desc2, eq as eq4, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
|
|
1459
1467
|
function createSessionRow(ctx, input) {
|
|
1460
1468
|
const row = ctx.db.insert(sessions).values({
|
|
1461
1469
|
id: newId("sess"),
|
|
@@ -1534,6 +1542,10 @@ function markSessionEnded(ctx, id) {
|
|
|
1534
1542
|
ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
|
|
1535
1543
|
return getSessionRow(ctx, id);
|
|
1536
1544
|
}
|
|
1545
|
+
function mostRecentResumableSession(ctx, featureId) {
|
|
1546
|
+
const row = ctx.db.select().from(sessions).where(and4(eq4(sessions.featureId, featureId), eq4(sessions.status, "ended"), isNotNull(sessions.ccSessionId))).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1547
|
+
return row ? rowToSession(row) : null;
|
|
1548
|
+
}
|
|
1537
1549
|
function mostRecentLiveSession(ctx) {
|
|
1538
1550
|
const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1539
1551
|
return row ? rowToSession(row) : null;
|
|
@@ -1674,6 +1686,55 @@ function storeTickets(ctx, featureId, inputs) {
|
|
|
1674
1686
|
});
|
|
1675
1687
|
return rows.map(rowToTicket);
|
|
1676
1688
|
}
|
|
1689
|
+
function getTicket(ctx, id) {
|
|
1690
|
+
const row = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1691
|
+
if (!row)
|
|
1692
|
+
throw new NotFoundError(`ticket ${id} not found`);
|
|
1693
|
+
return rowToTicket(row);
|
|
1694
|
+
}
|
|
1695
|
+
function assertMutable(t, verb) {
|
|
1696
|
+
if (!MUTABLE_STATUSES.has(t.status)) {
|
|
1697
|
+
throw new InvalidInputError(`cannot ${verb} ticket ${t.seq} \u2014 it is ${t.status}; only pending or failed tickets can be ${verb}${verb.endsWith("l") ? "led" : "ed"}`);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
function editTicket(ctx, id, patch) {
|
|
1701
|
+
const current2 = getTicket(ctx, id);
|
|
1702
|
+
assertMutable(current2, "edit");
|
|
1703
|
+
const set = {};
|
|
1704
|
+
if (patch.title !== undefined)
|
|
1705
|
+
set.title = patch.title;
|
|
1706
|
+
if (patch.goal !== undefined)
|
|
1707
|
+
set.goal = patch.goal;
|
|
1708
|
+
if (patch.context !== undefined)
|
|
1709
|
+
set.context = patch.context;
|
|
1710
|
+
if (patch.acceptanceCriteria !== undefined)
|
|
1711
|
+
set.acceptanceCriteria = patch.acceptanceCriteria;
|
|
1712
|
+
if (patch.seams !== undefined)
|
|
1713
|
+
set.seams = patch.seams;
|
|
1714
|
+
if (Object.keys(set).length === 0) {
|
|
1715
|
+
throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
|
|
1716
|
+
}
|
|
1717
|
+
ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
|
|
1718
|
+
emit(ctx, current2.featureId, {
|
|
1719
|
+
type: "ticket.edited",
|
|
1720
|
+
message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
|
|
1721
|
+
ticketId: id,
|
|
1722
|
+
data: { fields: Object.keys(set) }
|
|
1723
|
+
});
|
|
1724
|
+
return getTicket(ctx, id);
|
|
1725
|
+
}
|
|
1726
|
+
function cancelTicket(ctx, id, reason) {
|
|
1727
|
+
const current2 = getTicket(ctx, id);
|
|
1728
|
+
assertMutable(current2, "cancel");
|
|
1729
|
+
ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq5(tickets.id, id)).run();
|
|
1730
|
+
emit(ctx, current2.featureId, {
|
|
1731
|
+
type: "ticket.cancelled",
|
|
1732
|
+
message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
|
|
1733
|
+
ticketId: id,
|
|
1734
|
+
data: { reason: reason ?? null }
|
|
1735
|
+
});
|
|
1736
|
+
return getTicket(ctx, id);
|
|
1737
|
+
}
|
|
1677
1738
|
function updateTicket(ctx, id, patch) {
|
|
1678
1739
|
const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1679
1740
|
if (!current2)
|
|
@@ -1695,11 +1756,13 @@ function updateTicket(ctx, id, patch) {
|
|
|
1695
1756
|
const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1696
1757
|
return rowToTicket(updated);
|
|
1697
1758
|
}
|
|
1759
|
+
var MUTABLE_STATUSES;
|
|
1698
1760
|
var init_tickets = __esm(() => {
|
|
1699
1761
|
init_src();
|
|
1700
1762
|
init_schema();
|
|
1701
1763
|
init_errors();
|
|
1702
1764
|
init_events();
|
|
1765
|
+
MUTABLE_STATUSES = new Set(["pending", "failed"]);
|
|
1703
1766
|
});
|
|
1704
1767
|
|
|
1705
1768
|
// src/services/gates.ts
|
|
@@ -1724,14 +1787,14 @@ function checkGate(ctx, check, feature) {
|
|
|
1724
1787
|
return { satisfied: true };
|
|
1725
1788
|
return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
|
|
1726
1789
|
case "tickets-approved": {
|
|
1727
|
-
const count = listByFeature2(ctx, feature.id).length;
|
|
1790
|
+
const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
|
|
1728
1791
|
return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
|
|
1729
1792
|
}
|
|
1730
1793
|
case "all-tickets-terminal": {
|
|
1731
1794
|
const tickets2 = listByFeature2(ctx, feature.id);
|
|
1732
1795
|
if (tickets2.length === 0)
|
|
1733
1796
|
return { satisfied: false, reason: "no tickets have run yet" };
|
|
1734
|
-
const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed");
|
|
1797
|
+
const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
|
|
1735
1798
|
return open.length === 0 ? { satisfied: true } : {
|
|
1736
1799
|
satisfied: false,
|
|
1737
1800
|
reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
|
|
@@ -2055,7 +2118,10 @@ async function reattachWorktree(path, branch) {
|
|
|
2055
2118
|
function researchBranchName(slug, waypointSeq, unique) {
|
|
2056
2119
|
return `${RESEARCH_BRANCH_PREFIX}${slug}/${waypointSeq}-${unique}`;
|
|
2057
2120
|
}
|
|
2058
|
-
|
|
2121
|
+
function ticketBranchName(slug, ticketSeq, unique) {
|
|
2122
|
+
return `${TICKET_BRANCH_PREFIX}${slug}/${ticketSeq}-${unique}`;
|
|
2123
|
+
}
|
|
2124
|
+
async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
|
|
2059
2125
|
const g = git(repoPath);
|
|
2060
2126
|
let branches;
|
|
2061
2127
|
try {
|
|
@@ -2104,7 +2170,10 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
|
|
|
2104
2170
|
return false;
|
|
2105
2171
|
}
|
|
2106
2172
|
}
|
|
2107
|
-
|
|
2173
|
+
function tempBranchPrefix(name) {
|
|
2174
|
+
return TEMP_BRANCH_PREFIXES.find((p) => name.startsWith(p));
|
|
2175
|
+
}
|
|
2176
|
+
async function cleanupTempBranches(repoPath) {
|
|
2108
2177
|
const deleted = [];
|
|
2109
2178
|
const kept = [];
|
|
2110
2179
|
const g = git(repoPath);
|
|
@@ -2115,9 +2184,10 @@ async function cleanupResearchBranches(repoPath) {
|
|
|
2115
2184
|
return { deleted, kept };
|
|
2116
2185
|
}
|
|
2117
2186
|
for (const name of all) {
|
|
2118
|
-
|
|
2187
|
+
const prefix = tempBranchPrefix(name);
|
|
2188
|
+
if (!prefix)
|
|
2119
2189
|
continue;
|
|
2120
|
-
const slug = name.slice(
|
|
2190
|
+
const slug = name.slice(prefix.length).split("/")[0];
|
|
2121
2191
|
const target = featureBranch(slug);
|
|
2122
2192
|
let merged = false;
|
|
2123
2193
|
if (slug && all.includes(target)) {
|
|
@@ -2274,13 +2344,14 @@ async function mergeInProgress(g) {
|
|
|
2274
2344
|
return false;
|
|
2275
2345
|
}
|
|
2276
2346
|
}
|
|
2277
|
-
var DOCS_PATHSPEC = "docs/features", 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/", testDriveState;
|
|
2347
|
+
var DOCS_PATHSPEC = "docs/features", 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/", TEMP_BRANCH_PREFIXES, testDriveState;
|
|
2278
2348
|
var init_git = __esm(() => {
|
|
2279
2349
|
init_paths();
|
|
2280
2350
|
init_errors();
|
|
2281
2351
|
init_dev_pane();
|
|
2282
2352
|
init_events();
|
|
2283
2353
|
init_repo();
|
|
2354
|
+
TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
|
|
2284
2355
|
});
|
|
2285
2356
|
|
|
2286
2357
|
// src/services/knowledge.ts
|
|
@@ -2571,16 +2642,28 @@ function isMergeConflictError(err) {
|
|
|
2571
2642
|
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
2572
2643
|
return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
|
|
2573
2644
|
}
|
|
2645
|
+
function createSerialQueue() {
|
|
2646
|
+
let tail = Promise.resolve();
|
|
2647
|
+
return (task) => {
|
|
2648
|
+
const next = tail.then(task);
|
|
2649
|
+
tail = next.catch(() => {
|
|
2650
|
+
return;
|
|
2651
|
+
});
|
|
2652
|
+
return next;
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2574
2655
|
function firstLine(s) {
|
|
2575
2656
|
const i = s.indexOf(`
|
|
2576
2657
|
`);
|
|
2577
2658
|
return (i === -1 ? s : s.slice(0, i)).trim();
|
|
2578
2659
|
}
|
|
2579
|
-
async function burnTickets(ctx, tickets2, execute) {
|
|
2660
|
+
async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
|
|
2661
|
+
const width = Math.max(1, Math.floor(concurrency));
|
|
2580
2662
|
const bySeq = indexBySeq(tickets2);
|
|
2581
2663
|
const status = new Map(tickets2.map((t) => [t.seq, t.status]));
|
|
2582
2664
|
const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
|
|
2583
2665
|
const inFlight = new Map;
|
|
2666
|
+
const satisfied = (s) => s === "done" || s === "cancelled";
|
|
2584
2667
|
const readyState = (seq) => {
|
|
2585
2668
|
const t = bySeq.get(seq);
|
|
2586
2669
|
if (!t)
|
|
@@ -2591,7 +2674,7 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2591
2674
|
if (!present || bs === "failed")
|
|
2592
2675
|
return { blockedBy: b, present };
|
|
2593
2676
|
}
|
|
2594
|
-
return t.blockedBy.every((b) => status.get(b)
|
|
2677
|
+
return t.blockedBy.every((b) => satisfied(status.get(b))) ? "ready" : "wait";
|
|
2595
2678
|
};
|
|
2596
2679
|
const failTicket = (seq, error, extra) => {
|
|
2597
2680
|
const t = bySeq.get(seq);
|
|
@@ -2652,7 +2735,7 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2652
2735
|
}
|
|
2653
2736
|
}
|
|
2654
2737
|
}
|
|
2655
|
-
while (inFlight.size <
|
|
2738
|
+
while (inFlight.size < width) {
|
|
2656
2739
|
const readySeq = [...pending].find((seq) => readyState(seq) === "ready");
|
|
2657
2740
|
if (readySeq === undefined)
|
|
2658
2741
|
break;
|
|
@@ -2663,7 +2746,12 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2663
2746
|
inFlight.set(readySeq, p);
|
|
2664
2747
|
}
|
|
2665
2748
|
if (inFlight.size > 0) {
|
|
2666
|
-
|
|
2749
|
+
try {
|
|
2750
|
+
await Promise.race(inFlight.values());
|
|
2751
|
+
} catch (err) {
|
|
2752
|
+
await Promise.allSettled([...inFlight.values()]);
|
|
2753
|
+
throw err;
|
|
2754
|
+
}
|
|
2667
2755
|
} else if (pending.size > 0) {
|
|
2668
2756
|
for (const seq of [...pending]) {
|
|
2669
2757
|
pending.delete(seq);
|
|
@@ -2687,12 +2775,14 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2687
2775
|
}
|
|
2688
2776
|
async function burnRun(ctx, deps) {
|
|
2689
2777
|
const tickets2 = ctx.tickets;
|
|
2690
|
-
const
|
|
2778
|
+
const burnable = tickets2.filter((t) => t.status !== "cancelled");
|
|
2779
|
+
const cancelled = tickets2.length - burnable.length;
|
|
2780
|
+
const total = burnable.length;
|
|
2691
2781
|
if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
|
|
2692
2782
|
ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: AUTH_MISSING_MESSAGE });
|
|
2693
2783
|
return { status: "failed", summary: "burn aborted: auth token missing" };
|
|
2694
2784
|
}
|
|
2695
|
-
const cycle = detectCycle(
|
|
2785
|
+
const cycle = detectCycle(burnable);
|
|
2696
2786
|
if (cycle) {
|
|
2697
2787
|
const path = cycle.join(" \u2192 ");
|
|
2698
2788
|
ctx.emitEvent({
|
|
@@ -2702,9 +2792,9 @@ async function burnRun(ctx, deps) {
|
|
|
2702
2792
|
});
|
|
2703
2793
|
return { status: "failed", summary: `dependency cycle: ${path}` };
|
|
2704
2794
|
}
|
|
2705
|
-
const done = await burnTickets(ctx, tickets2, deps.executeTicketRun);
|
|
2706
|
-
const summary = `${done}/${total} tickets done`;
|
|
2707
|
-
ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total } });
|
|
2795
|
+
const done = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
|
|
2796
|
+
const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
|
|
2797
|
+
ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
|
|
2708
2798
|
return { status: done === total ? "succeeded" : "failed", summary };
|
|
2709
2799
|
}
|
|
2710
2800
|
function burnerTemplatePath() {
|
|
@@ -2755,7 +2845,7 @@ function buildBurnAgent(config2, token, model) {
|
|
|
2755
2845
|
return agent;
|
|
2756
2846
|
}
|
|
2757
2847
|
function selectSandbox(config2) {
|
|
2758
|
-
const imageOpts =
|
|
2848
|
+
const imageOpts = { imageName: resolveSandboxImage(config2) };
|
|
2759
2849
|
switch (config2.sandbox) {
|
|
2760
2850
|
case "docker":
|
|
2761
2851
|
return docker(imageOpts);
|
|
@@ -2765,8 +2855,9 @@ function selectSandbox(config2) {
|
|
|
2765
2855
|
return noSandbox();
|
|
2766
2856
|
}
|
|
2767
2857
|
}
|
|
2768
|
-
async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
2858
|
+
async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
|
|
2769
2859
|
const { project, feature } = ctx;
|
|
2860
|
+
const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
|
|
2770
2861
|
const template = readFileSync4(burnerTemplatePath(), "utf8");
|
|
2771
2862
|
const prompt = renderTicketPrompt(template, {
|
|
2772
2863
|
TICKET_JSON: buildTicketJson(ticket),
|
|
@@ -2782,7 +2873,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
|
2782
2873
|
sandbox: selectSandbox(config2),
|
|
2783
2874
|
cwd: project.repoPath,
|
|
2784
2875
|
prompt,
|
|
2785
|
-
branchStrategy: { type: "branch", branch:
|
|
2876
|
+
branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
|
|
2786
2877
|
signal: ctx.signal,
|
|
2787
2878
|
name: `ticket-${ticket.seq}`,
|
|
2788
2879
|
logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
|
|
@@ -2809,16 +2900,39 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
|
2809
2900
|
}
|
|
2810
2901
|
throttle.flush();
|
|
2811
2902
|
const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
|
|
2812
|
-
|
|
2903
|
+
const outcome = interpretRunResult(result, blocked);
|
|
2904
|
+
if (outcome.status !== "done")
|
|
2905
|
+
return outcome;
|
|
2906
|
+
const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
|
|
2907
|
+
if (!merge.ok) {
|
|
2908
|
+
const detail = merge.error ?? "merge failed";
|
|
2909
|
+
if (merge.conflict) {
|
|
2910
|
+
return {
|
|
2911
|
+
status: "failed",
|
|
2912
|
+
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
|
|
2913
|
+
event: {
|
|
2914
|
+
type: "merge.conflict.needs-human",
|
|
2915
|
+
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
2916
|
+
}
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
return {
|
|
2920
|
+
status: "failed",
|
|
2921
|
+
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${firstLine(detail)} \u2014 the branch is preserved for manual recovery`
|
|
2922
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
return outcome;
|
|
2813
2925
|
}
|
|
2814
2926
|
function resolveBurnDeps(ctx) {
|
|
2815
2927
|
const config2 = loadConfig();
|
|
2816
2928
|
const token = readTokenFromEnvFile(envPath());
|
|
2817
2929
|
const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
|
|
2930
|
+
const land = createSerialQueue();
|
|
2818
2931
|
return {
|
|
2819
2932
|
config: config2,
|
|
2820
2933
|
hasAuthToken: token !== undefined,
|
|
2821
|
-
|
|
2934
|
+
concurrency: config2.burnConcurrency,
|
|
2935
|
+
executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land)
|
|
2822
2936
|
};
|
|
2823
2937
|
}
|
|
2824
2938
|
function readTokenFromEnvFile(path) {
|
|
@@ -2833,12 +2947,13 @@ function readTokenFromEnvFile(path) {
|
|
|
2833
2947
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
2834
2948
|
return token && token.length > 0 ? token : undefined;
|
|
2835
2949
|
}
|
|
2836
|
-
var
|
|
2950
|
+
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
|
|
2837
2951
|
var init_ticket_burner = __esm(() => {
|
|
2838
2952
|
init_src();
|
|
2839
2953
|
init_config_load();
|
|
2840
2954
|
init_paths();
|
|
2841
2955
|
init_skills_root();
|
|
2956
|
+
init_git();
|
|
2842
2957
|
PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
|
|
2843
2958
|
ticketBurner = {
|
|
2844
2959
|
id: "ticket-burner",
|
|
@@ -2947,7 +3062,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
2947
3062
|
const throttle = createResearchStreamThrottle((e) => ctx.emitEvent(e));
|
|
2948
3063
|
const runOptions = {
|
|
2949
3064
|
agent: buildBurnAgent(config2, token, model),
|
|
2950
|
-
sandbox: config2.sandbox === "docker" ? docker2(
|
|
3065
|
+
sandbox: config2.sandbox === "docker" ? docker2({ imageName: resolveSandboxImage(config2) }) : noSandbox2(),
|
|
2951
3066
|
cwd: project.repoPath,
|
|
2952
3067
|
prompt,
|
|
2953
3068
|
branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
|
|
@@ -2968,7 +3083,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
2968
3083
|
const outcome = interpretResearchResult(result, docRelPath);
|
|
2969
3084
|
if (outcome.status !== "done")
|
|
2970
3085
|
return outcome;
|
|
2971
|
-
const merge = await
|
|
3086
|
+
const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
|
|
2972
3087
|
if (!merge.ok) {
|
|
2973
3088
|
const detail = merge.error ?? "merge failed";
|
|
2974
3089
|
ctx.emitEvent({
|
|
@@ -3246,7 +3361,8 @@ function list(ctx, projectId) {
|
|
|
3246
3361
|
pending: tickets2.filter((t) => t.status === "pending").length,
|
|
3247
3362
|
burning: tickets2.filter((t) => t.status === "burning").length,
|
|
3248
3363
|
done: tickets2.filter((t) => t.status === "done").length,
|
|
3249
|
-
failed: tickets2.filter((t) => t.status === "failed").length
|
|
3364
|
+
failed: tickets2.filter((t) => t.status === "failed").length,
|
|
3365
|
+
cancelled: tickets2.filter((t) => t.status === "cancelled").length
|
|
3250
3366
|
};
|
|
3251
3367
|
return { ...feature, ticketCounts: counts, activeRun: hasActiveRun(ctx, feature.id) };
|
|
3252
3368
|
});
|
|
@@ -3275,13 +3391,19 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
3275
3391
|
const why = feature.phase === "implementation" ? "a run is already burning this feature" : `feature must be in the tickets phase to burn (currently ${feature.phase})`;
|
|
3276
3392
|
throw new GateError(why);
|
|
3277
3393
|
}
|
|
3278
|
-
|
|
3279
|
-
|
|
3394
|
+
const tickets2 = listByFeature2(ctx, featureId);
|
|
3395
|
+
if (tickets2.filter((t) => t.status !== "cancelled").length < 1) {
|
|
3396
|
+
throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
|
|
3280
3397
|
}
|
|
3281
3398
|
if (restarting) {
|
|
3399
|
+
const failed = tickets2.filter((t) => t.status === "failed");
|
|
3400
|
+
for (const t of failed) {
|
|
3401
|
+
updateTicket(ctx, t.id, { status: "pending", error: null });
|
|
3402
|
+
}
|
|
3282
3403
|
emit(ctx, featureId, {
|
|
3283
3404
|
type: "burn.restarted",
|
|
3284
|
-
message: "restarting burn (previous run cancelled or crashed)"
|
|
3405
|
+
message: failed.length > 0 ? `restarting burn \u2014 retrying ${failed.length} failed ticket(s)` : "restarting burn (previous run cancelled or crashed)",
|
|
3406
|
+
data: { retried: failed.map((t) => t.seq) }
|
|
3285
3407
|
});
|
|
3286
3408
|
} else {
|
|
3287
3409
|
setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
|
|
@@ -3393,6 +3515,22 @@ function toolEmitTickets(ctx, session, input) {
|
|
|
3393
3515
|
const stored = storeTickets(ctx, feature.id, input.tickets);
|
|
3394
3516
|
return { stored: stored.length, ids: stored.map((t) => t.id) };
|
|
3395
3517
|
}
|
|
3518
|
+
function requireOwnTicket(ctx, session, ticketId) {
|
|
3519
|
+
const ticket = getTicket(ctx, ticketId);
|
|
3520
|
+
if (ticket.featureId !== session.featureId) {
|
|
3521
|
+
throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
|
|
3522
|
+
}
|
|
3523
|
+
return ticket;
|
|
3524
|
+
}
|
|
3525
|
+
function toolUpdateTicket(ctx, session, input) {
|
|
3526
|
+
requireOwnTicket(ctx, session, input.id);
|
|
3527
|
+
const { id, ...patch } = input;
|
|
3528
|
+
return { ok: true, ticket: editTicket(ctx, id, patch) };
|
|
3529
|
+
}
|
|
3530
|
+
function toolCancelTicket(ctx, session, input) {
|
|
3531
|
+
requireOwnTicket(ctx, session, input.id);
|
|
3532
|
+
return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
|
|
3533
|
+
}
|
|
3396
3534
|
function toolEscalateToMap(ctx, session, input) {
|
|
3397
3535
|
return escalateToMap(ctx, session.featureId, input);
|
|
3398
3536
|
}
|
|
@@ -3492,6 +3630,37 @@ function buildMcpServer() {
|
|
|
3492
3630
|
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: tickets emitted (${result.stored})`);
|
|
3493
3631
|
return ok(result);
|
|
3494
3632
|
});
|
|
3633
|
+
server.registerTool("update_ticket", {
|
|
3634
|
+
title: "Update ticket",
|
|
3635
|
+
description: "Rewrite a stored ticket's content \u2014 title, goal, context, acceptanceCriteria, seams (any subset). Only pending or failed tickets can be edited; done/cancelled tickets are history and burning tickets are already running. Use during a revisit when a decision change makes a ticket stale. Get ids from get_feature_context.",
|
|
3636
|
+
inputSchema: {
|
|
3637
|
+
id: z3.string(),
|
|
3638
|
+
title: z3.string().optional(),
|
|
3639
|
+
goal: z3.string().optional(),
|
|
3640
|
+
context: z3.string().optional(),
|
|
3641
|
+
acceptanceCriteria: z3.array(z3.string()).optional(),
|
|
3642
|
+
seams: z3.array(z3.string()).optional()
|
|
3643
|
+
}
|
|
3644
|
+
}, async (args, extra) => {
|
|
3645
|
+
const rs = await resolveCtxSession(extra);
|
|
3646
|
+
if (!rs)
|
|
3647
|
+
return noSession();
|
|
3648
|
+
const result = toolUpdateTicket(rs.ctx, rs.session, args);
|
|
3649
|
+
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} updated`);
|
|
3650
|
+
return ok(result);
|
|
3651
|
+
});
|
|
3652
|
+
server.registerTool("cancel_ticket", {
|
|
3653
|
+
title: "Cancel ticket",
|
|
3654
|
+
description: "Cancel a ticket that is no longer needed (terminal state; only pending or failed tickets). The burner skips cancelled tickets, and tickets blocked by a cancelled one still burn \u2014 cancellation counts as satisfied. Pass a short reason so the timeline explains why.",
|
|
3655
|
+
inputSchema: { id: z3.string(), reason: z3.string().optional() }
|
|
3656
|
+
}, async (args, extra) => {
|
|
3657
|
+
const rs = await resolveCtxSession(extra);
|
|
3658
|
+
if (!rs)
|
|
3659
|
+
return noSession();
|
|
3660
|
+
const result = toolCancelTicket(rs.ctx, rs.session, args);
|
|
3661
|
+
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} cancelled`);
|
|
3662
|
+
return ok(result);
|
|
3663
|
+
});
|
|
3495
3664
|
server.registerTool("escalate_to_map", {
|
|
3496
3665
|
title: "Escalate to map",
|
|
3497
3666
|
description: "Escalate this grilling session into a map when the feature outgrows one context window. Flips the feature to mapped and scaffolds docs/features/<slug>/map.md, seeding Destination + Notes from your arguments (Not-yet-specified and Out-of-scope start empty). Idempotent: calling it on an already-mapped feature warns and changes nothing.",
|
|
@@ -3889,6 +4058,8 @@ function renderSystemPrompt(feature, kind, waypoint) {
|
|
|
3889
4058
|
return renderWaypointPrompt(feature, waypoint);
|
|
3890
4059
|
if (kind === "converge")
|
|
3891
4060
|
return renderConvergePrompt(feature);
|
|
4061
|
+
if (kind === "revisit")
|
|
4062
|
+
return renderRevisitPrompt(feature);
|
|
3892
4063
|
const docs = featureDocsRel(feature.slug);
|
|
3893
4064
|
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.";
|
|
3894
4065
|
return [
|
|
@@ -4014,6 +4185,49 @@ function renderConvergePrompt(feature) {
|
|
|
4014
4185
|
].join(`
|
|
4015
4186
|
`);
|
|
4016
4187
|
}
|
|
4188
|
+
function renderRevisitPrompt(feature) {
|
|
4189
|
+
const docs = featureDocsRel(feature.slug);
|
|
4190
|
+
return [
|
|
4191
|
+
`# runcastle \u2014 ${feature.title} (revisit session)`,
|
|
4192
|
+
"",
|
|
4193
|
+
feature.oneLiner,
|
|
4194
|
+
"",
|
|
4195
|
+
"This is a **revisit session**: the human came back to a feature whose",
|
|
4196
|
+
"earlier sessions are finished \u2014 they remembered something, or a decision",
|
|
4197
|
+
"changed. Where possible this terminal RESUMES the previous conversation, so",
|
|
4198
|
+
"you may already have its context above. Your job: fold the new information",
|
|
4199
|
+
"into the record, then reconcile the tickets with it. The docs are the",
|
|
4200
|
+
"artifact \u2014 later phases read them, never the transcripts.",
|
|
4201
|
+
"",
|
|
4202
|
+
"## Feature",
|
|
4203
|
+
`- Slug: \`${feature.slug}\``,
|
|
4204
|
+
`- Branch: \`${feature.branch}\``,
|
|
4205
|
+
`- Current phase: **${feature.phase}** (size: ${feature.size})`,
|
|
4206
|
+
"",
|
|
4207
|
+
"## Knowledge (versioned in the target repo)",
|
|
4208
|
+
`Feature docs live at \`${docs}/\`:`,
|
|
4209
|
+
`- \`${docs}/decisions.md\` \u2014 append the new/changed decisions with a dated "revisited" note.`,
|
|
4210
|
+
`- \`${docs}/spec.md\` \u2014 if it exists, amend the affected sections in place.`,
|
|
4211
|
+
`- \`${docs}/map.md\` \u2014 if the feature is mapped, keep the map honest too.`,
|
|
4212
|
+
"",
|
|
4213
|
+
"## runcastle MCP tools",
|
|
4214
|
+
"- `get_feature_context()` \u2014 feature + phase + docs + ALL tickets (with ids and statuses).",
|
|
4215
|
+
"- `update_ticket({ id, ...fields })` \u2014 rewrite a stale pending/failed ticket's content.",
|
|
4216
|
+
"- `cancel_ticket({ id, reason })` \u2014 cancel a pending/failed ticket that is now unnecessary.",
|
|
4217
|
+
"- `emit_tickets({ tickets })` \u2014 add tickets for NEW work the change requires.",
|
|
4218
|
+
"- `record_event({ type, message })` \u2014 drop a timeline note summarising the revisit.",
|
|
4219
|
+
"",
|
|
4220
|
+
"## Rules",
|
|
4221
|
+
"- Do NOT call `complete_phase` \u2014 a revisit never moves the pipeline.",
|
|
4222
|
+
"- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
|
|
4223
|
+
"- Docs first, tickets second: capture the decision prose before any ticket surgery.",
|
|
4224
|
+
"",
|
|
4225
|
+
"## Your task",
|
|
4226
|
+
"Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
|
|
4227
|
+
""
|
|
4228
|
+
].join(`
|
|
4229
|
+
`);
|
|
4230
|
+
}
|
|
4017
4231
|
function renderSettings(hookClient) {
|
|
4018
4232
|
const cmd = (event) => ({
|
|
4019
4233
|
type: "command",
|
|
@@ -4059,6 +4273,8 @@ var init_artifacts = __esm(() => {
|
|
|
4059
4273
|
RUNCASTLE_MCP_ALLOW_RULES = [
|
|
4060
4274
|
"mcp__runcastle__get_feature_context",
|
|
4061
4275
|
"mcp__runcastle__emit_tickets",
|
|
4276
|
+
"mcp__runcastle__update_ticket",
|
|
4277
|
+
"mcp__runcastle__cancel_ticket",
|
|
4062
4278
|
"mcp__runcastle__record_event",
|
|
4063
4279
|
"mcp__runcastle__complete_phase",
|
|
4064
4280
|
"mcp__runcastle__escalate_to_map",
|
|
@@ -4225,11 +4441,32 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
4225
4441
|
throw e;
|
|
4226
4442
|
}
|
|
4227
4443
|
}
|
|
4444
|
+
if (input.kind === "revisit") {
|
|
4445
|
+
try {
|
|
4446
|
+
assertSpawnable(ctx, feature, session.id);
|
|
4447
|
+
} catch (e) {
|
|
4448
|
+
markSessionEnded(ctx, session.id);
|
|
4449
|
+
throw e;
|
|
4450
|
+
}
|
|
4451
|
+
const prior = mostRecentResumableSession(ctx, feature.id);
|
|
4452
|
+
if (prior?.ccSessionId) {
|
|
4453
|
+
resumeSessionId = prior.ccSessionId;
|
|
4454
|
+
} else {
|
|
4455
|
+
resumeUnavailableFrom = "revisit";
|
|
4456
|
+
}
|
|
4457
|
+
}
|
|
4228
4458
|
emit(ctx, feature.id, {
|
|
4229
4459
|
type: "session.launching",
|
|
4230
4460
|
message: `launching ${input.kind} session`,
|
|
4231
4461
|
data: { sessionId: session.id, kind: input.kind, worktreePath, waypointId: waypoint?.id }
|
|
4232
4462
|
});
|
|
4463
|
+
if (input.kind === "revisit" && resumeUnavailableFrom) {
|
|
4464
|
+
emit(ctx, feature.id, {
|
|
4465
|
+
type: "session.resume_unavailable",
|
|
4466
|
+
message: "no resumable conversation for this feature \u2014 revisiting fresh from the docs",
|
|
4467
|
+
data: { sessionId: session.id }
|
|
4468
|
+
});
|
|
4469
|
+
}
|
|
4233
4470
|
if (waypoint && resumeUnavailableFrom) {
|
|
4234
4471
|
emit(ctx, feature.id, {
|
|
4235
4472
|
type: "session.resume_unavailable",
|
|
@@ -4827,6 +5064,14 @@ var init_settings = __esm(() => {
|
|
|
4827
5064
|
valueSchema: z9.string().min(1),
|
|
4828
5065
|
parseEnv: idEnv
|
|
4829
5066
|
},
|
|
5067
|
+
{
|
|
5068
|
+
key: "burnConcurrency",
|
|
5069
|
+
configKey: "burnConcurrency",
|
|
5070
|
+
envVar: "RUNCASTLE_BURN_CONCURRENCY",
|
|
5071
|
+
restartRequired: false,
|
|
5072
|
+
valueSchema: z9.number().int().min(1).max(8),
|
|
5073
|
+
parseEnv: (raw) => Number(raw)
|
|
5074
|
+
},
|
|
4830
5075
|
{
|
|
4831
5076
|
key: "mainBranch",
|
|
4832
5077
|
configKey: "mainBranch",
|
|
@@ -4995,7 +5240,7 @@ function afkTokenProbe(env) {
|
|
|
4995
5240
|
async function runDoctor(env) {
|
|
4996
5241
|
const { exec } = env;
|
|
4997
5242
|
const processEnv = env.env ?? process.env;
|
|
4998
|
-
const imageName = env.imageName ??
|
|
5243
|
+
const imageName = env.imageName ?? DEFAULT_SANDBOX_IMAGE;
|
|
4999
5244
|
const results = [
|
|
5000
5245
|
await bunProbe(exec),
|
|
5001
5246
|
await nodeProbe(exec),
|
|
@@ -5017,6 +5262,9 @@ function exitCodeFor(report, mode) {
|
|
|
5017
5262
|
return report.tier1Ok ? 0 : 1;
|
|
5018
5263
|
return report.ok ? 0 : 1;
|
|
5019
5264
|
}
|
|
5265
|
+
var init_doctor = __esm(() => {
|
|
5266
|
+
init_src();
|
|
5267
|
+
});
|
|
5020
5268
|
|
|
5021
5269
|
// src/doctor/system-exec.ts
|
|
5022
5270
|
import { spawn as spawn2 } from "child_process";
|
|
@@ -5200,6 +5448,7 @@ function fileAfkTokenIo(exec) {
|
|
|
5200
5448
|
var AFK_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
5201
5449
|
var init_setup = __esm(() => {
|
|
5202
5450
|
init_paths();
|
|
5451
|
+
init_doctor();
|
|
5203
5452
|
init_errors();
|
|
5204
5453
|
init_asset_paths();
|
|
5205
5454
|
});
|
|
@@ -5209,6 +5458,7 @@ import * as z11 from "zod";
|
|
|
5209
5458
|
var setupRouter;
|
|
5210
5459
|
var init_setup2 = __esm(() => {
|
|
5211
5460
|
init_src();
|
|
5461
|
+
init_doctor();
|
|
5212
5462
|
init_system_exec();
|
|
5213
5463
|
init_registry();
|
|
5214
5464
|
init_setup();
|
|
@@ -5226,7 +5476,7 @@ var init_setup2 = __esm(() => {
|
|
|
5226
5476
|
const exec = createSystemExec();
|
|
5227
5477
|
const preferred = ctx.config.sandbox === "podman" ? "podman" : "docker";
|
|
5228
5478
|
const runtime = await resolveRuntime(exec, preferred);
|
|
5229
|
-
const imageName = ctx.config
|
|
5479
|
+
const imageName = resolveSandboxImage(ctx.config);
|
|
5230
5480
|
const sandcastleBin = input.kind === "build-image" ? resolveSandcastleBin() ?? undefined : undefined;
|
|
5231
5481
|
const spec = terminalSpec(input.kind, { runtime, imageName, sandcastleBin });
|
|
5232
5482
|
const sessionId = newId("setup");
|
|
@@ -5416,7 +5666,7 @@ async function reconcileStaleRuns(ctx) {
|
|
|
5416
5666
|
}
|
|
5417
5667
|
for (const project of allProjects(ctx)) {
|
|
5418
5668
|
try {
|
|
5419
|
-
await
|
|
5669
|
+
await cleanupTempBranches(project.repoPath);
|
|
5420
5670
|
} catch {}
|
|
5421
5671
|
}
|
|
5422
5672
|
return reconciled;
|