runcastle 1.0.6 → 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 +273 -31
- package/index.js +273 -31
- 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/bin/runcastle.js
CHANGED
|
@@ -87,6 +87,7 @@ var init_config = __esm(() => {
|
|
|
87
87
|
"qa",
|
|
88
88
|
"waypoint",
|
|
89
89
|
"converge",
|
|
90
|
+
"revisit",
|
|
90
91
|
"research",
|
|
91
92
|
"implement",
|
|
92
93
|
"smoke"
|
|
@@ -98,7 +99,8 @@ var init_config = __esm(() => {
|
|
|
98
99
|
stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
|
|
99
100
|
sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
|
|
100
101
|
mainBranch: z.string().default("main"),
|
|
101
|
-
sandboxImage: z.string().optional()
|
|
102
|
+
sandboxImage: z.string().optional(),
|
|
103
|
+
burnConcurrency: z.number().int().min(1).max(8).default(3)
|
|
102
104
|
}));
|
|
103
105
|
});
|
|
104
106
|
|
|
@@ -131,6 +133,9 @@ function loadConfig(env = process.env) {
|
|
|
131
133
|
overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
|
|
132
134
|
if (env.RUNCASTLE_SANDBOX_IMAGE)
|
|
133
135
|
overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
|
|
136
|
+
if (env.RUNCASTLE_BURN_CONCURRENCY) {
|
|
137
|
+
overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
|
|
138
|
+
}
|
|
134
139
|
const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
|
|
135
140
|
return RuncastleConfig.parse({ ...base, ...overrides });
|
|
136
141
|
}
|
|
@@ -174,8 +179,8 @@ var init_schemas = __esm(() => {
|
|
|
174
179
|
"shipped"
|
|
175
180
|
]);
|
|
176
181
|
FeatureSize = z2.enum(["full", "collapsed"]);
|
|
177
|
-
TicketStatus = z2.enum(["pending", "burning", "done", "failed"]);
|
|
178
|
-
SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge"]);
|
|
182
|
+
TicketStatus = z2.enum(["pending", "burning", "done", "failed", "cancelled"]);
|
|
183
|
+
SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit"]);
|
|
179
184
|
RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
|
|
180
185
|
FeatureStatus = z2.enum(["active", "shipped"]);
|
|
181
186
|
SessionStatus = z2.enum(["launching", "live", "ended"]);
|
|
@@ -1459,7 +1464,7 @@ var init_waypoints = __esm(() => {
|
|
|
1459
1464
|
});
|
|
1460
1465
|
|
|
1461
1466
|
// src/launcher/sessions.ts
|
|
1462
|
-
import { and as and4, desc as desc2, eq as eq4, inArray, sql as sql2 } from "drizzle-orm";
|
|
1467
|
+
import { and as and4, desc as desc2, eq as eq4, inArray, isNotNull, sql as sql2 } from "drizzle-orm";
|
|
1463
1468
|
function createSessionRow(ctx, input) {
|
|
1464
1469
|
const row = ctx.db.insert(sessions).values({
|
|
1465
1470
|
id: newId("sess"),
|
|
@@ -1538,6 +1543,10 @@ function markSessionEnded(ctx, id) {
|
|
|
1538
1543
|
ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
|
|
1539
1544
|
return getSessionRow(ctx, id);
|
|
1540
1545
|
}
|
|
1546
|
+
function mostRecentResumableSession(ctx, featureId) {
|
|
1547
|
+
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();
|
|
1548
|
+
return row ? rowToSession(row) : null;
|
|
1549
|
+
}
|
|
1541
1550
|
function mostRecentLiveSession(ctx) {
|
|
1542
1551
|
const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1543
1552
|
return row ? rowToSession(row) : null;
|
|
@@ -1678,6 +1687,55 @@ function storeTickets(ctx, featureId, inputs) {
|
|
|
1678
1687
|
});
|
|
1679
1688
|
return rows.map(rowToTicket);
|
|
1680
1689
|
}
|
|
1690
|
+
function getTicket(ctx, id) {
|
|
1691
|
+
const row = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1692
|
+
if (!row)
|
|
1693
|
+
throw new NotFoundError(`ticket ${id} not found`);
|
|
1694
|
+
return rowToTicket(row);
|
|
1695
|
+
}
|
|
1696
|
+
function assertMutable(t, verb) {
|
|
1697
|
+
if (!MUTABLE_STATUSES.has(t.status)) {
|
|
1698
|
+
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"}`);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
function editTicket(ctx, id, patch) {
|
|
1702
|
+
const current2 = getTicket(ctx, id);
|
|
1703
|
+
assertMutable(current2, "edit");
|
|
1704
|
+
const set = {};
|
|
1705
|
+
if (patch.title !== undefined)
|
|
1706
|
+
set.title = patch.title;
|
|
1707
|
+
if (patch.goal !== undefined)
|
|
1708
|
+
set.goal = patch.goal;
|
|
1709
|
+
if (patch.context !== undefined)
|
|
1710
|
+
set.context = patch.context;
|
|
1711
|
+
if (patch.acceptanceCriteria !== undefined)
|
|
1712
|
+
set.acceptanceCriteria = patch.acceptanceCriteria;
|
|
1713
|
+
if (patch.seams !== undefined)
|
|
1714
|
+
set.seams = patch.seams;
|
|
1715
|
+
if (Object.keys(set).length === 0) {
|
|
1716
|
+
throw new InvalidInputError("nothing to edit \u2014 pass at least one content field");
|
|
1717
|
+
}
|
|
1718
|
+
ctx.db.update(tickets).set(set).where(eq5(tickets.id, id)).run();
|
|
1719
|
+
emit(ctx, current2.featureId, {
|
|
1720
|
+
type: "ticket.edited",
|
|
1721
|
+
message: `ticket ${current2.seq} content edited (${Object.keys(set).join(", ")})`,
|
|
1722
|
+
ticketId: id,
|
|
1723
|
+
data: { fields: Object.keys(set) }
|
|
1724
|
+
});
|
|
1725
|
+
return getTicket(ctx, id);
|
|
1726
|
+
}
|
|
1727
|
+
function cancelTicket(ctx, id, reason) {
|
|
1728
|
+
const current2 = getTicket(ctx, id);
|
|
1729
|
+
assertMutable(current2, "cancel");
|
|
1730
|
+
ctx.db.update(tickets).set({ status: "cancelled", error: reason?.trim() ? reason.trim() : null }).where(eq5(tickets.id, id)).run();
|
|
1731
|
+
emit(ctx, current2.featureId, {
|
|
1732
|
+
type: "ticket.cancelled",
|
|
1733
|
+
message: `ticket ${current2.seq} cancelled${reason?.trim() ? `: ${reason.trim()}` : ""}`,
|
|
1734
|
+
ticketId: id,
|
|
1735
|
+
data: { reason: reason ?? null }
|
|
1736
|
+
});
|
|
1737
|
+
return getTicket(ctx, id);
|
|
1738
|
+
}
|
|
1681
1739
|
function updateTicket(ctx, id, patch) {
|
|
1682
1740
|
const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1683
1741
|
if (!current2)
|
|
@@ -1699,11 +1757,13 @@ function updateTicket(ctx, id, patch) {
|
|
|
1699
1757
|
const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1700
1758
|
return rowToTicket(updated);
|
|
1701
1759
|
}
|
|
1760
|
+
var MUTABLE_STATUSES;
|
|
1702
1761
|
var init_tickets = __esm(() => {
|
|
1703
1762
|
init_src();
|
|
1704
1763
|
init_schema();
|
|
1705
1764
|
init_errors();
|
|
1706
1765
|
init_events();
|
|
1766
|
+
MUTABLE_STATUSES = new Set(["pending", "failed"]);
|
|
1707
1767
|
});
|
|
1708
1768
|
|
|
1709
1769
|
// src/services/gates.ts
|
|
@@ -1728,14 +1788,14 @@ function checkGate(ctx, check, feature) {
|
|
|
1728
1788
|
return { satisfied: true };
|
|
1729
1789
|
return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
|
|
1730
1790
|
case "tickets-approved": {
|
|
1731
|
-
const count = listByFeature2(ctx, feature.id).length;
|
|
1791
|
+
const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
|
|
1732
1792
|
return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
|
|
1733
1793
|
}
|
|
1734
1794
|
case "all-tickets-terminal": {
|
|
1735
1795
|
const tickets2 = listByFeature2(ctx, feature.id);
|
|
1736
1796
|
if (tickets2.length === 0)
|
|
1737
1797
|
return { satisfied: false, reason: "no tickets have run yet" };
|
|
1738
|
-
const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed");
|
|
1798
|
+
const open = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
|
|
1739
1799
|
return open.length === 0 ? { satisfied: true } : {
|
|
1740
1800
|
satisfied: false,
|
|
1741
1801
|
reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
|
|
@@ -2059,7 +2119,10 @@ async function reattachWorktree(path, branch) {
|
|
|
2059
2119
|
function researchBranchName(slug, waypointSeq, unique) {
|
|
2060
2120
|
return `${RESEARCH_BRANCH_PREFIX}${slug}/${waypointSeq}-${unique}`;
|
|
2061
2121
|
}
|
|
2062
|
-
|
|
2122
|
+
function ticketBranchName(slug, ticketSeq, unique) {
|
|
2123
|
+
return `${TICKET_BRANCH_PREFIX}${slug}/${ticketSeq}-${unique}`;
|
|
2124
|
+
}
|
|
2125
|
+
async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
|
|
2063
2126
|
const g = git(repoPath);
|
|
2064
2127
|
let branches;
|
|
2065
2128
|
try {
|
|
@@ -2108,7 +2171,10 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
|
|
|
2108
2171
|
return false;
|
|
2109
2172
|
}
|
|
2110
2173
|
}
|
|
2111
|
-
|
|
2174
|
+
function tempBranchPrefix(name) {
|
|
2175
|
+
return TEMP_BRANCH_PREFIXES.find((p) => name.startsWith(p));
|
|
2176
|
+
}
|
|
2177
|
+
async function cleanupTempBranches(repoPath) {
|
|
2112
2178
|
const deleted = [];
|
|
2113
2179
|
const kept = [];
|
|
2114
2180
|
const g = git(repoPath);
|
|
@@ -2119,9 +2185,10 @@ async function cleanupResearchBranches(repoPath) {
|
|
|
2119
2185
|
return { deleted, kept };
|
|
2120
2186
|
}
|
|
2121
2187
|
for (const name of all) {
|
|
2122
|
-
|
|
2188
|
+
const prefix = tempBranchPrefix(name);
|
|
2189
|
+
if (!prefix)
|
|
2123
2190
|
continue;
|
|
2124
|
-
const slug = name.slice(
|
|
2191
|
+
const slug = name.slice(prefix.length).split("/")[0];
|
|
2125
2192
|
const target = featureBranch(slug);
|
|
2126
2193
|
let merged = false;
|
|
2127
2194
|
if (slug && all.includes(target)) {
|
|
@@ -2278,13 +2345,14 @@ async function mergeInProgress(g) {
|
|
|
2278
2345
|
return false;
|
|
2279
2346
|
}
|
|
2280
2347
|
}
|
|
2281
|
-
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;
|
|
2348
|
+
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;
|
|
2282
2349
|
var init_git = __esm(() => {
|
|
2283
2350
|
init_paths();
|
|
2284
2351
|
init_errors();
|
|
2285
2352
|
init_dev_pane();
|
|
2286
2353
|
init_events();
|
|
2287
2354
|
init_repo();
|
|
2355
|
+
TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
|
|
2288
2356
|
});
|
|
2289
2357
|
|
|
2290
2358
|
// src/services/knowledge.ts
|
|
@@ -2575,16 +2643,28 @@ function isMergeConflictError(err) {
|
|
|
2575
2643
|
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
2576
2644
|
return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
|
|
2577
2645
|
}
|
|
2646
|
+
function createSerialQueue() {
|
|
2647
|
+
let tail = Promise.resolve();
|
|
2648
|
+
return (task) => {
|
|
2649
|
+
const next = tail.then(task);
|
|
2650
|
+
tail = next.catch(() => {
|
|
2651
|
+
return;
|
|
2652
|
+
});
|
|
2653
|
+
return next;
|
|
2654
|
+
};
|
|
2655
|
+
}
|
|
2578
2656
|
function firstLine(s) {
|
|
2579
2657
|
const i = s.indexOf(`
|
|
2580
2658
|
`);
|
|
2581
2659
|
return (i === -1 ? s : s.slice(0, i)).trim();
|
|
2582
2660
|
}
|
|
2583
|
-
async function burnTickets(ctx, tickets2, execute) {
|
|
2661
|
+
async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
|
|
2662
|
+
const width = Math.max(1, Math.floor(concurrency));
|
|
2584
2663
|
const bySeq = indexBySeq(tickets2);
|
|
2585
2664
|
const status = new Map(tickets2.map((t) => [t.seq, t.status]));
|
|
2586
2665
|
const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
|
|
2587
2666
|
const inFlight = new Map;
|
|
2667
|
+
const satisfied = (s) => s === "done" || s === "cancelled";
|
|
2588
2668
|
const readyState = (seq) => {
|
|
2589
2669
|
const t = bySeq.get(seq);
|
|
2590
2670
|
if (!t)
|
|
@@ -2595,7 +2675,7 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2595
2675
|
if (!present || bs === "failed")
|
|
2596
2676
|
return { blockedBy: b, present };
|
|
2597
2677
|
}
|
|
2598
|
-
return t.blockedBy.every((b) => status.get(b)
|
|
2678
|
+
return t.blockedBy.every((b) => satisfied(status.get(b))) ? "ready" : "wait";
|
|
2599
2679
|
};
|
|
2600
2680
|
const failTicket = (seq, error, extra) => {
|
|
2601
2681
|
const t = bySeq.get(seq);
|
|
@@ -2656,7 +2736,7 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2656
2736
|
}
|
|
2657
2737
|
}
|
|
2658
2738
|
}
|
|
2659
|
-
while (inFlight.size <
|
|
2739
|
+
while (inFlight.size < width) {
|
|
2660
2740
|
const readySeq = [...pending].find((seq) => readyState(seq) === "ready");
|
|
2661
2741
|
if (readySeq === undefined)
|
|
2662
2742
|
break;
|
|
@@ -2667,7 +2747,12 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2667
2747
|
inFlight.set(readySeq, p);
|
|
2668
2748
|
}
|
|
2669
2749
|
if (inFlight.size > 0) {
|
|
2670
|
-
|
|
2750
|
+
try {
|
|
2751
|
+
await Promise.race(inFlight.values());
|
|
2752
|
+
} catch (err) {
|
|
2753
|
+
await Promise.allSettled([...inFlight.values()]);
|
|
2754
|
+
throw err;
|
|
2755
|
+
}
|
|
2671
2756
|
} else if (pending.size > 0) {
|
|
2672
2757
|
for (const seq of [...pending]) {
|
|
2673
2758
|
pending.delete(seq);
|
|
@@ -2691,12 +2776,14 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2691
2776
|
}
|
|
2692
2777
|
async function burnRun(ctx, deps) {
|
|
2693
2778
|
const tickets2 = ctx.tickets;
|
|
2694
|
-
const
|
|
2779
|
+
const burnable = tickets2.filter((t) => t.status !== "cancelled");
|
|
2780
|
+
const cancelled = tickets2.length - burnable.length;
|
|
2781
|
+
const total = burnable.length;
|
|
2695
2782
|
if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
|
|
2696
2783
|
ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: AUTH_MISSING_MESSAGE });
|
|
2697
2784
|
return { status: "failed", summary: "burn aborted: auth token missing" };
|
|
2698
2785
|
}
|
|
2699
|
-
const cycle = detectCycle(
|
|
2786
|
+
const cycle = detectCycle(burnable);
|
|
2700
2787
|
if (cycle) {
|
|
2701
2788
|
const path = cycle.join(" \u2192 ");
|
|
2702
2789
|
ctx.emitEvent({
|
|
@@ -2706,9 +2793,9 @@ async function burnRun(ctx, deps) {
|
|
|
2706
2793
|
});
|
|
2707
2794
|
return { status: "failed", summary: `dependency cycle: ${path}` };
|
|
2708
2795
|
}
|
|
2709
|
-
const done = await burnTickets(ctx, tickets2, deps.executeTicketRun);
|
|
2710
|
-
const summary = `${done}/${total} tickets done`;
|
|
2711
|
-
ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total } });
|
|
2796
|
+
const done = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
|
|
2797
|
+
const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
|
|
2798
|
+
ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
|
|
2712
2799
|
return { status: done === total ? "succeeded" : "failed", summary };
|
|
2713
2800
|
}
|
|
2714
2801
|
function burnerTemplatePath() {
|
|
@@ -2769,8 +2856,9 @@ function selectSandbox(config2) {
|
|
|
2769
2856
|
return noSandbox();
|
|
2770
2857
|
}
|
|
2771
2858
|
}
|
|
2772
|
-
async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
2859
|
+
async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
|
|
2773
2860
|
const { project, feature } = ctx;
|
|
2861
|
+
const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
|
|
2774
2862
|
const template = readFileSync4(burnerTemplatePath(), "utf8");
|
|
2775
2863
|
const prompt = renderTicketPrompt(template, {
|
|
2776
2864
|
TICKET_JSON: buildTicketJson(ticket),
|
|
@@ -2786,7 +2874,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
|
2786
2874
|
sandbox: selectSandbox(config2),
|
|
2787
2875
|
cwd: project.repoPath,
|
|
2788
2876
|
prompt,
|
|
2789
|
-
branchStrategy: { type: "branch", branch:
|
|
2877
|
+
branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
|
|
2790
2878
|
signal: ctx.signal,
|
|
2791
2879
|
name: `ticket-${ticket.seq}`,
|
|
2792
2880
|
logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
|
|
@@ -2813,16 +2901,39 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
|
2813
2901
|
}
|
|
2814
2902
|
throttle.flush();
|
|
2815
2903
|
const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
|
|
2816
|
-
|
|
2904
|
+
const outcome = interpretRunResult(result, blocked);
|
|
2905
|
+
if (outcome.status !== "done")
|
|
2906
|
+
return outcome;
|
|
2907
|
+
const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
|
|
2908
|
+
if (!merge.ok) {
|
|
2909
|
+
const detail = merge.error ?? "merge failed";
|
|
2910
|
+
if (merge.conflict) {
|
|
2911
|
+
return {
|
|
2912
|
+
status: "failed",
|
|
2913
|
+
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
|
|
2914
|
+
event: {
|
|
2915
|
+
type: "merge.conflict.needs-human",
|
|
2916
|
+
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
2917
|
+
}
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
return {
|
|
2921
|
+
status: "failed",
|
|
2922
|
+
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${firstLine(detail)} \u2014 the branch is preserved for manual recovery`
|
|
2923
|
+
};
|
|
2924
|
+
}
|
|
2925
|
+
return outcome;
|
|
2817
2926
|
}
|
|
2818
2927
|
function resolveBurnDeps(ctx) {
|
|
2819
2928
|
const config2 = loadConfig();
|
|
2820
2929
|
const token = readTokenFromEnvFile(envPath());
|
|
2821
2930
|
const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
|
|
2931
|
+
const land = createSerialQueue();
|
|
2822
2932
|
return {
|
|
2823
2933
|
config: config2,
|
|
2824
2934
|
hasAuthToken: token !== undefined,
|
|
2825
|
-
|
|
2935
|
+
concurrency: config2.burnConcurrency,
|
|
2936
|
+
executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land)
|
|
2826
2937
|
};
|
|
2827
2938
|
}
|
|
2828
2939
|
function readTokenFromEnvFile(path) {
|
|
@@ -2837,12 +2948,13 @@ function readTokenFromEnvFile(path) {
|
|
|
2837
2948
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
2838
2949
|
return token && token.length > 0 ? token : undefined;
|
|
2839
2950
|
}
|
|
2840
|
-
var
|
|
2951
|
+
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
|
|
2841
2952
|
var init_ticket_burner = __esm(() => {
|
|
2842
2953
|
init_src();
|
|
2843
2954
|
init_config_load();
|
|
2844
2955
|
init_paths();
|
|
2845
2956
|
init_skills_root();
|
|
2957
|
+
init_git();
|
|
2846
2958
|
PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
|
|
2847
2959
|
ticketBurner = {
|
|
2848
2960
|
id: "ticket-burner",
|
|
@@ -2972,7 +3084,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
2972
3084
|
const outcome = interpretResearchResult(result, docRelPath);
|
|
2973
3085
|
if (outcome.status !== "done")
|
|
2974
3086
|
return outcome;
|
|
2975
|
-
const merge = await
|
|
3087
|
+
const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
|
|
2976
3088
|
if (!merge.ok) {
|
|
2977
3089
|
const detail = merge.error ?? "merge failed";
|
|
2978
3090
|
ctx.emitEvent({
|
|
@@ -3250,7 +3362,8 @@ function list(ctx, projectId) {
|
|
|
3250
3362
|
pending: tickets2.filter((t) => t.status === "pending").length,
|
|
3251
3363
|
burning: tickets2.filter((t) => t.status === "burning").length,
|
|
3252
3364
|
done: tickets2.filter((t) => t.status === "done").length,
|
|
3253
|
-
failed: tickets2.filter((t) => t.status === "failed").length
|
|
3365
|
+
failed: tickets2.filter((t) => t.status === "failed").length,
|
|
3366
|
+
cancelled: tickets2.filter((t) => t.status === "cancelled").length
|
|
3254
3367
|
};
|
|
3255
3368
|
return { ...feature, ticketCounts: counts, activeRun: hasActiveRun(ctx, feature.id) };
|
|
3256
3369
|
});
|
|
@@ -3279,13 +3392,19 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
3279
3392
|
const why = feature.phase === "implementation" ? "a run is already burning this feature" : `feature must be in the tickets phase to burn (currently ${feature.phase})`;
|
|
3280
3393
|
throw new GateError(why);
|
|
3281
3394
|
}
|
|
3282
|
-
|
|
3283
|
-
|
|
3395
|
+
const tickets2 = listByFeature2(ctx, featureId);
|
|
3396
|
+
if (tickets2.filter((t) => t.status !== "cancelled").length < 1) {
|
|
3397
|
+
throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
|
|
3284
3398
|
}
|
|
3285
3399
|
if (restarting) {
|
|
3400
|
+
const failed = tickets2.filter((t) => t.status === "failed");
|
|
3401
|
+
for (const t of failed) {
|
|
3402
|
+
updateTicket(ctx, t.id, { status: "pending", error: null });
|
|
3403
|
+
}
|
|
3286
3404
|
emit(ctx, featureId, {
|
|
3287
3405
|
type: "burn.restarted",
|
|
3288
|
-
message: "restarting burn (previous run cancelled or crashed)"
|
|
3406
|
+
message: failed.length > 0 ? `restarting burn \u2014 retrying ${failed.length} failed ticket(s)` : "restarting burn (previous run cancelled or crashed)",
|
|
3407
|
+
data: { retried: failed.map((t) => t.seq) }
|
|
3289
3408
|
});
|
|
3290
3409
|
} else {
|
|
3291
3410
|
setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
|
|
@@ -3397,6 +3516,22 @@ function toolEmitTickets(ctx, session, input) {
|
|
|
3397
3516
|
const stored = storeTickets(ctx, feature.id, input.tickets);
|
|
3398
3517
|
return { stored: stored.length, ids: stored.map((t) => t.id) };
|
|
3399
3518
|
}
|
|
3519
|
+
function requireOwnTicket(ctx, session, ticketId) {
|
|
3520
|
+
const ticket = getTicket(ctx, ticketId);
|
|
3521
|
+
if (ticket.featureId !== session.featureId) {
|
|
3522
|
+
throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
|
|
3523
|
+
}
|
|
3524
|
+
return ticket;
|
|
3525
|
+
}
|
|
3526
|
+
function toolUpdateTicket(ctx, session, input) {
|
|
3527
|
+
requireOwnTicket(ctx, session, input.id);
|
|
3528
|
+
const { id, ...patch } = input;
|
|
3529
|
+
return { ok: true, ticket: editTicket(ctx, id, patch) };
|
|
3530
|
+
}
|
|
3531
|
+
function toolCancelTicket(ctx, session, input) {
|
|
3532
|
+
requireOwnTicket(ctx, session, input.id);
|
|
3533
|
+
return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
|
|
3534
|
+
}
|
|
3400
3535
|
function toolEscalateToMap(ctx, session, input) {
|
|
3401
3536
|
return escalateToMap(ctx, session.featureId, input);
|
|
3402
3537
|
}
|
|
@@ -3496,6 +3631,37 @@ function buildMcpServer() {
|
|
|
3496
3631
|
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: tickets emitted (${result.stored})`);
|
|
3497
3632
|
return ok(result);
|
|
3498
3633
|
});
|
|
3634
|
+
server.registerTool("update_ticket", {
|
|
3635
|
+
title: "Update ticket",
|
|
3636
|
+
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.",
|
|
3637
|
+
inputSchema: {
|
|
3638
|
+
id: z3.string(),
|
|
3639
|
+
title: z3.string().optional(),
|
|
3640
|
+
goal: z3.string().optional(),
|
|
3641
|
+
context: z3.string().optional(),
|
|
3642
|
+
acceptanceCriteria: z3.array(z3.string()).optional(),
|
|
3643
|
+
seams: z3.array(z3.string()).optional()
|
|
3644
|
+
}
|
|
3645
|
+
}, async (args, extra) => {
|
|
3646
|
+
const rs = await resolveCtxSession(extra);
|
|
3647
|
+
if (!rs)
|
|
3648
|
+
return noSession();
|
|
3649
|
+
const result = toolUpdateTicket(rs.ctx, rs.session, args);
|
|
3650
|
+
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} updated`);
|
|
3651
|
+
return ok(result);
|
|
3652
|
+
});
|
|
3653
|
+
server.registerTool("cancel_ticket", {
|
|
3654
|
+
title: "Cancel ticket",
|
|
3655
|
+
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.",
|
|
3656
|
+
inputSchema: { id: z3.string(), reason: z3.string().optional() }
|
|
3657
|
+
}, async (args, extra) => {
|
|
3658
|
+
const rs = await resolveCtxSession(extra);
|
|
3659
|
+
if (!rs)
|
|
3660
|
+
return noSession();
|
|
3661
|
+
const result = toolCancelTicket(rs.ctx, rs.session, args);
|
|
3662
|
+
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} cancelled`);
|
|
3663
|
+
return ok(result);
|
|
3664
|
+
});
|
|
3499
3665
|
server.registerTool("escalate_to_map", {
|
|
3500
3666
|
title: "Escalate to map",
|
|
3501
3667
|
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.",
|
|
@@ -3893,6 +4059,8 @@ function renderSystemPrompt(feature, kind, waypoint) {
|
|
|
3893
4059
|
return renderWaypointPrompt(feature, waypoint);
|
|
3894
4060
|
if (kind === "converge")
|
|
3895
4061
|
return renderConvergePrompt(feature);
|
|
4062
|
+
if (kind === "revisit")
|
|
4063
|
+
return renderRevisitPrompt(feature);
|
|
3896
4064
|
const docs = featureDocsRel(feature.slug);
|
|
3897
4065
|
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.";
|
|
3898
4066
|
return [
|
|
@@ -4018,6 +4186,49 @@ function renderConvergePrompt(feature) {
|
|
|
4018
4186
|
].join(`
|
|
4019
4187
|
`);
|
|
4020
4188
|
}
|
|
4189
|
+
function renderRevisitPrompt(feature) {
|
|
4190
|
+
const docs = featureDocsRel(feature.slug);
|
|
4191
|
+
return [
|
|
4192
|
+
`# runcastle \u2014 ${feature.title} (revisit session)`,
|
|
4193
|
+
"",
|
|
4194
|
+
feature.oneLiner,
|
|
4195
|
+
"",
|
|
4196
|
+
"This is a **revisit session**: the human came back to a feature whose",
|
|
4197
|
+
"earlier sessions are finished \u2014 they remembered something, or a decision",
|
|
4198
|
+
"changed. Where possible this terminal RESUMES the previous conversation, so",
|
|
4199
|
+
"you may already have its context above. Your job: fold the new information",
|
|
4200
|
+
"into the record, then reconcile the tickets with it. The docs are the",
|
|
4201
|
+
"artifact \u2014 later phases read them, never the transcripts.",
|
|
4202
|
+
"",
|
|
4203
|
+
"## Feature",
|
|
4204
|
+
`- Slug: \`${feature.slug}\``,
|
|
4205
|
+
`- Branch: \`${feature.branch}\``,
|
|
4206
|
+
`- Current phase: **${feature.phase}** (size: ${feature.size})`,
|
|
4207
|
+
"",
|
|
4208
|
+
"## Knowledge (versioned in the target repo)",
|
|
4209
|
+
`Feature docs live at \`${docs}/\`:`,
|
|
4210
|
+
`- \`${docs}/decisions.md\` \u2014 append the new/changed decisions with a dated "revisited" note.`,
|
|
4211
|
+
`- \`${docs}/spec.md\` \u2014 if it exists, amend the affected sections in place.`,
|
|
4212
|
+
`- \`${docs}/map.md\` \u2014 if the feature is mapped, keep the map honest too.`,
|
|
4213
|
+
"",
|
|
4214
|
+
"## runcastle MCP tools",
|
|
4215
|
+
"- `get_feature_context()` \u2014 feature + phase + docs + ALL tickets (with ids and statuses).",
|
|
4216
|
+
"- `update_ticket({ id, ...fields })` \u2014 rewrite a stale pending/failed ticket's content.",
|
|
4217
|
+
"- `cancel_ticket({ id, reason })` \u2014 cancel a pending/failed ticket that is now unnecessary.",
|
|
4218
|
+
"- `emit_tickets({ tickets })` \u2014 add tickets for NEW work the change requires.",
|
|
4219
|
+
"- `record_event({ type, message })` \u2014 drop a timeline note summarising the revisit.",
|
|
4220
|
+
"",
|
|
4221
|
+
"## Rules",
|
|
4222
|
+
"- Do NOT call `complete_phase` \u2014 a revisit never moves the pipeline.",
|
|
4223
|
+
"- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
|
|
4224
|
+
"- Docs first, tickets second: capture the decision prose before any ticket surgery.",
|
|
4225
|
+
"",
|
|
4226
|
+
"## Your task",
|
|
4227
|
+
"Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
|
|
4228
|
+
""
|
|
4229
|
+
].join(`
|
|
4230
|
+
`);
|
|
4231
|
+
}
|
|
4021
4232
|
function renderSettings(hookClient) {
|
|
4022
4233
|
const cmd = (event) => ({
|
|
4023
4234
|
type: "command",
|
|
@@ -4063,6 +4274,8 @@ var init_artifacts = __esm(() => {
|
|
|
4063
4274
|
RUNCASTLE_MCP_ALLOW_RULES = [
|
|
4064
4275
|
"mcp__runcastle__get_feature_context",
|
|
4065
4276
|
"mcp__runcastle__emit_tickets",
|
|
4277
|
+
"mcp__runcastle__update_ticket",
|
|
4278
|
+
"mcp__runcastle__cancel_ticket",
|
|
4066
4279
|
"mcp__runcastle__record_event",
|
|
4067
4280
|
"mcp__runcastle__complete_phase",
|
|
4068
4281
|
"mcp__runcastle__escalate_to_map",
|
|
@@ -4229,11 +4442,32 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
4229
4442
|
throw e;
|
|
4230
4443
|
}
|
|
4231
4444
|
}
|
|
4445
|
+
if (input.kind === "revisit") {
|
|
4446
|
+
try {
|
|
4447
|
+
assertSpawnable(ctx, feature, session.id);
|
|
4448
|
+
} catch (e) {
|
|
4449
|
+
markSessionEnded(ctx, session.id);
|
|
4450
|
+
throw e;
|
|
4451
|
+
}
|
|
4452
|
+
const prior = mostRecentResumableSession(ctx, feature.id);
|
|
4453
|
+
if (prior?.ccSessionId) {
|
|
4454
|
+
resumeSessionId = prior.ccSessionId;
|
|
4455
|
+
} else {
|
|
4456
|
+
resumeUnavailableFrom = "revisit";
|
|
4457
|
+
}
|
|
4458
|
+
}
|
|
4232
4459
|
emit(ctx, feature.id, {
|
|
4233
4460
|
type: "session.launching",
|
|
4234
4461
|
message: `launching ${input.kind} session`,
|
|
4235
4462
|
data: { sessionId: session.id, kind: input.kind, worktreePath, waypointId: waypoint?.id }
|
|
4236
4463
|
});
|
|
4464
|
+
if (input.kind === "revisit" && resumeUnavailableFrom) {
|
|
4465
|
+
emit(ctx, feature.id, {
|
|
4466
|
+
type: "session.resume_unavailable",
|
|
4467
|
+
message: "no resumable conversation for this feature \u2014 revisiting fresh from the docs",
|
|
4468
|
+
data: { sessionId: session.id }
|
|
4469
|
+
});
|
|
4470
|
+
}
|
|
4237
4471
|
if (waypoint && resumeUnavailableFrom) {
|
|
4238
4472
|
emit(ctx, feature.id, {
|
|
4239
4473
|
type: "session.resume_unavailable",
|
|
@@ -4831,6 +5065,14 @@ var init_settings = __esm(() => {
|
|
|
4831
5065
|
valueSchema: z9.string().min(1),
|
|
4832
5066
|
parseEnv: idEnv
|
|
4833
5067
|
},
|
|
5068
|
+
{
|
|
5069
|
+
key: "burnConcurrency",
|
|
5070
|
+
configKey: "burnConcurrency",
|
|
5071
|
+
envVar: "RUNCASTLE_BURN_CONCURRENCY",
|
|
5072
|
+
restartRequired: false,
|
|
5073
|
+
valueSchema: z9.number().int().min(1).max(8),
|
|
5074
|
+
parseEnv: (raw) => Number(raw)
|
|
5075
|
+
},
|
|
4834
5076
|
{
|
|
4835
5077
|
key: "mainBranch",
|
|
4836
5078
|
configKey: "mainBranch",
|
|
@@ -5425,7 +5667,7 @@ async function reconcileStaleRuns(ctx) {
|
|
|
5425
5667
|
}
|
|
5426
5668
|
for (const project of allProjects(ctx)) {
|
|
5427
5669
|
try {
|
|
5428
|
-
await
|
|
5670
|
+
await cleanupTempBranches(project.repoPath);
|
|
5429
5671
|
} catch {}
|
|
5430
5672
|
}
|
|
5431
5673
|
return reconciled;
|