runcastle 1.0.6 → 1.0.8
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 +302 -49
- package/index.js +302 -49
- 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
|
@@ -86,6 +86,7 @@ var init_config = __esm(() => {
|
|
|
86
86
|
"qa",
|
|
87
87
|
"waypoint",
|
|
88
88
|
"converge",
|
|
89
|
+
"revisit",
|
|
89
90
|
"research",
|
|
90
91
|
"implement",
|
|
91
92
|
"smoke"
|
|
@@ -97,7 +98,8 @@ var init_config = __esm(() => {
|
|
|
97
98
|
stepModels: z.partialRecord(ModelStep, z.string()).default({ smoke: DEFAULT_SMOKE_MODEL }),
|
|
98
99
|
sandbox: z.enum(["docker", "podman", "noSandbox"]).default("docker"),
|
|
99
100
|
mainBranch: z.string().default("main"),
|
|
100
|
-
sandboxImage: z.string().optional()
|
|
101
|
+
sandboxImage: z.string().optional(),
|
|
102
|
+
burnConcurrency: z.number().int().min(1).max(8).default(3)
|
|
101
103
|
}));
|
|
102
104
|
});
|
|
103
105
|
|
|
@@ -130,6 +132,9 @@ function loadConfig(env = process.env) {
|
|
|
130
132
|
overrides.mainBranch = env.RUNCASTLE_MAIN_BRANCH;
|
|
131
133
|
if (env.RUNCASTLE_SANDBOX_IMAGE)
|
|
132
134
|
overrides.sandboxImage = env.RUNCASTLE_SANDBOX_IMAGE;
|
|
135
|
+
if (env.RUNCASTLE_BURN_CONCURRENCY) {
|
|
136
|
+
overrides.burnConcurrency = Number(env.RUNCASTLE_BURN_CONCURRENCY);
|
|
137
|
+
}
|
|
133
138
|
const base = typeof fileConfig === "object" && fileConfig !== null ? fileConfig : {};
|
|
134
139
|
return RuncastleConfig.parse({ ...base, ...overrides });
|
|
135
140
|
}
|
|
@@ -173,8 +178,8 @@ var init_schemas = __esm(() => {
|
|
|
173
178
|
"shipped"
|
|
174
179
|
]);
|
|
175
180
|
FeatureSize = z2.enum(["full", "collapsed"]);
|
|
176
|
-
TicketStatus = z2.enum(["pending", "burning", "done", "failed"]);
|
|
177
|
-
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"]);
|
|
178
183
|
RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
|
|
179
184
|
FeatureStatus = z2.enum(["active", "shipped"]);
|
|
180
185
|
SessionStatus = z2.enum(["launching", "live", "ended"]);
|
|
@@ -1458,7 +1463,7 @@ var init_waypoints = __esm(() => {
|
|
|
1458
1463
|
});
|
|
1459
1464
|
|
|
1460
1465
|
// src/launcher/sessions.ts
|
|
1461
|
-
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";
|
|
1462
1467
|
function createSessionRow(ctx, input) {
|
|
1463
1468
|
const row = ctx.db.insert(sessions).values({
|
|
1464
1469
|
id: newId("sess"),
|
|
@@ -1537,6 +1542,10 @@ function markSessionEnded(ctx, id) {
|
|
|
1537
1542
|
ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
|
|
1538
1543
|
return getSessionRow(ctx, id);
|
|
1539
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
|
+
}
|
|
1540
1549
|
function mostRecentLiveSession(ctx) {
|
|
1541
1550
|
const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1542
1551
|
return row ? rowToSession(row) : null;
|
|
@@ -1677,6 +1686,55 @@ function storeTickets(ctx, featureId, inputs) {
|
|
|
1677
1686
|
});
|
|
1678
1687
|
return rows.map(rowToTicket);
|
|
1679
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
|
+
}
|
|
1680
1738
|
function updateTicket(ctx, id, patch) {
|
|
1681
1739
|
const current2 = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1682
1740
|
if (!current2)
|
|
@@ -1698,11 +1756,13 @@ function updateTicket(ctx, id, patch) {
|
|
|
1698
1756
|
const updated = ctx.db.select().from(tickets).where(eq5(tickets.id, id)).get();
|
|
1699
1757
|
return rowToTicket(updated);
|
|
1700
1758
|
}
|
|
1759
|
+
var MUTABLE_STATUSES;
|
|
1701
1760
|
var init_tickets = __esm(() => {
|
|
1702
1761
|
init_src();
|
|
1703
1762
|
init_schema();
|
|
1704
1763
|
init_errors();
|
|
1705
1764
|
init_events();
|
|
1765
|
+
MUTABLE_STATUSES = new Set(["pending", "failed"]);
|
|
1706
1766
|
});
|
|
1707
1767
|
|
|
1708
1768
|
// src/services/gates.ts
|
|
@@ -1727,14 +1787,14 @@ function checkGate(ctx, check, feature) {
|
|
|
1727
1787
|
return { satisfied: true };
|
|
1728
1788
|
return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
|
|
1729
1789
|
case "tickets-approved": {
|
|
1730
|
-
const count = listByFeature2(ctx, feature.id).length;
|
|
1790
|
+
const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
|
|
1731
1791
|
return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
|
|
1732
1792
|
}
|
|
1733
1793
|
case "all-tickets-terminal": {
|
|
1734
1794
|
const tickets2 = listByFeature2(ctx, feature.id);
|
|
1735
1795
|
if (tickets2.length === 0)
|
|
1736
1796
|
return { satisfied: false, reason: "no tickets have run yet" };
|
|
1737
|
-
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");
|
|
1738
1798
|
return open.length === 0 ? { satisfied: true } : {
|
|
1739
1799
|
satisfied: false,
|
|
1740
1800
|
reason: notYetTerminal("ticket", open.map((t) => t.status), ["pending", "burning"])
|
|
@@ -2055,10 +2115,16 @@ async function reattachWorktree(path, branch) {
|
|
|
2055
2115
|
await git(path).checkout(branch);
|
|
2056
2116
|
} catch {}
|
|
2057
2117
|
}
|
|
2118
|
+
function tempBranchSlugSegment(slug) {
|
|
2119
|
+
return slug.slice(0, TEMP_BRANCH_SLUG_MAX).replace(/-+$/, "");
|
|
2120
|
+
}
|
|
2058
2121
|
function researchBranchName(slug, waypointSeq, unique) {
|
|
2059
|
-
return `${RESEARCH_BRANCH_PREFIX}${slug}/${waypointSeq}-${unique}`;
|
|
2122
|
+
return `${RESEARCH_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${waypointSeq}-${unique}`;
|
|
2123
|
+
}
|
|
2124
|
+
function ticketBranchName(slug, ticketSeq, unique) {
|
|
2125
|
+
return `${TICKET_BRANCH_PREFIX}${tempBranchSlugSegment(slug)}/${ticketSeq}-${unique}`;
|
|
2060
2126
|
}
|
|
2061
|
-
async function
|
|
2127
|
+
async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
|
|
2062
2128
|
const g = git(repoPath);
|
|
2063
2129
|
let branches;
|
|
2064
2130
|
try {
|
|
@@ -2107,7 +2173,10 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
|
|
|
2107
2173
|
return false;
|
|
2108
2174
|
}
|
|
2109
2175
|
}
|
|
2110
|
-
|
|
2176
|
+
function tempBranchPrefix(name) {
|
|
2177
|
+
return TEMP_BRANCH_PREFIXES.find((p) => name.startsWith(p));
|
|
2178
|
+
}
|
|
2179
|
+
async function cleanupTempBranches(repoPath) {
|
|
2111
2180
|
const deleted = [];
|
|
2112
2181
|
const kept = [];
|
|
2113
2182
|
const g = git(repoPath);
|
|
@@ -2118,19 +2187,26 @@ async function cleanupResearchBranches(repoPath) {
|
|
|
2118
2187
|
return { deleted, kept };
|
|
2119
2188
|
}
|
|
2120
2189
|
for (const name of all) {
|
|
2121
|
-
|
|
2190
|
+
const prefix = tempBranchPrefix(name);
|
|
2191
|
+
if (!prefix)
|
|
2122
2192
|
continue;
|
|
2123
|
-
const
|
|
2124
|
-
const
|
|
2193
|
+
const seg = name.slice(prefix.length).split("/")[0];
|
|
2194
|
+
const targets = seg ? all.filter((b) => {
|
|
2195
|
+
if (!b.startsWith("feature/"))
|
|
2196
|
+
return false;
|
|
2197
|
+
const featureSlug = b.slice("feature/".length);
|
|
2198
|
+
return featureSlug === seg || tempBranchSlugSegment(featureSlug) === seg;
|
|
2199
|
+
}) : [];
|
|
2125
2200
|
let merged = false;
|
|
2126
|
-
|
|
2201
|
+
for (const target of targets) {
|
|
2127
2202
|
try {
|
|
2128
2203
|
const tip = (await g.revparse([name])).trim();
|
|
2129
2204
|
const base = (await g.raw(["merge-base", name, target])).trim();
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2205
|
+
if (tip.length > 0 && tip === base) {
|
|
2206
|
+
merged = true;
|
|
2207
|
+
break;
|
|
2208
|
+
}
|
|
2209
|
+
} catch {}
|
|
2134
2210
|
}
|
|
2135
2211
|
if (merged && await deleteBranchDetachingWorktrees(g, repoPath, name)) {
|
|
2136
2212
|
deleted.push(name);
|
|
@@ -2277,13 +2353,14 @@ async function mergeInProgress(g) {
|
|
|
2277
2353
|
return false;
|
|
2278
2354
|
}
|
|
2279
2355
|
}
|
|
2280
|
-
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;
|
|
2356
|
+
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, TEMP_BRANCH_SLUG_MAX = 16, testDriveState;
|
|
2281
2357
|
var init_git = __esm(() => {
|
|
2282
2358
|
init_paths();
|
|
2283
2359
|
init_errors();
|
|
2284
2360
|
init_dev_pane();
|
|
2285
2361
|
init_events();
|
|
2286
2362
|
init_repo();
|
|
2363
|
+
TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
|
|
2287
2364
|
});
|
|
2288
2365
|
|
|
2289
2366
|
// src/services/knowledge.ts
|
|
@@ -2574,16 +2651,29 @@ function isMergeConflictError(err) {
|
|
|
2574
2651
|
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
2575
2652
|
return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
|
|
2576
2653
|
}
|
|
2577
|
-
function
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2654
|
+
function createSerialQueue() {
|
|
2655
|
+
let tail = Promise.resolve();
|
|
2656
|
+
return (task) => {
|
|
2657
|
+
const next = tail.then(task);
|
|
2658
|
+
tail = next.catch(() => {
|
|
2659
|
+
return;
|
|
2660
|
+
});
|
|
2661
|
+
return next;
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
function errorHeadline(s) {
|
|
2665
|
+
const lines = s.split(`
|
|
2666
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
2667
|
+
const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
|
|
2668
|
+
return causes.at(-1) ?? lines[0] ?? "";
|
|
2581
2669
|
}
|
|
2582
|
-
async function burnTickets(ctx, tickets2, execute) {
|
|
2670
|
+
async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
|
|
2671
|
+
const width = Math.max(1, Math.floor(concurrency));
|
|
2583
2672
|
const bySeq = indexBySeq(tickets2);
|
|
2584
2673
|
const status = new Map(tickets2.map((t) => [t.seq, t.status]));
|
|
2585
2674
|
const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
|
|
2586
2675
|
const inFlight = new Map;
|
|
2676
|
+
const satisfied = (s) => s === "done" || s === "cancelled";
|
|
2587
2677
|
const readyState = (seq) => {
|
|
2588
2678
|
const t = bySeq.get(seq);
|
|
2589
2679
|
if (!t)
|
|
@@ -2594,7 +2684,7 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2594
2684
|
if (!present || bs === "failed")
|
|
2595
2685
|
return { blockedBy: b, present };
|
|
2596
2686
|
}
|
|
2597
|
-
return t.blockedBy.every((b) => status.get(b)
|
|
2687
|
+
return t.blockedBy.every((b) => satisfied(status.get(b))) ? "ready" : "wait";
|
|
2598
2688
|
};
|
|
2599
2689
|
const failTicket = (seq, error, extra) => {
|
|
2600
2690
|
const t = bySeq.get(seq);
|
|
@@ -2630,7 +2720,7 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2630
2720
|
failTicket(seq, outcome.error, outcome.event);
|
|
2631
2721
|
ctx.emitEvent({
|
|
2632
2722
|
type: "ticket.failed",
|
|
2633
|
-
message: `ticket ${t.seq} failed: ${
|
|
2723
|
+
message: `ticket ${t.seq} failed: ${errorHeadline(outcome.error)}`,
|
|
2634
2724
|
ticketId: t.id,
|
|
2635
2725
|
data: { error: outcome.error }
|
|
2636
2726
|
});
|
|
@@ -2655,7 +2745,7 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2655
2745
|
}
|
|
2656
2746
|
}
|
|
2657
2747
|
}
|
|
2658
|
-
while (inFlight.size <
|
|
2748
|
+
while (inFlight.size < width) {
|
|
2659
2749
|
const readySeq = [...pending].find((seq) => readyState(seq) === "ready");
|
|
2660
2750
|
if (readySeq === undefined)
|
|
2661
2751
|
break;
|
|
@@ -2666,7 +2756,12 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2666
2756
|
inFlight.set(readySeq, p);
|
|
2667
2757
|
}
|
|
2668
2758
|
if (inFlight.size > 0) {
|
|
2669
|
-
|
|
2759
|
+
try {
|
|
2760
|
+
await Promise.race(inFlight.values());
|
|
2761
|
+
} catch (err) {
|
|
2762
|
+
await Promise.allSettled([...inFlight.values()]);
|
|
2763
|
+
throw err;
|
|
2764
|
+
}
|
|
2670
2765
|
} else if (pending.size > 0) {
|
|
2671
2766
|
for (const seq of [...pending]) {
|
|
2672
2767
|
pending.delete(seq);
|
|
@@ -2690,12 +2785,14 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2690
2785
|
}
|
|
2691
2786
|
async function burnRun(ctx, deps) {
|
|
2692
2787
|
const tickets2 = ctx.tickets;
|
|
2693
|
-
const
|
|
2788
|
+
const burnable = tickets2.filter((t) => t.status !== "cancelled");
|
|
2789
|
+
const cancelled = tickets2.length - burnable.length;
|
|
2790
|
+
const total = burnable.length;
|
|
2694
2791
|
if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
|
|
2695
2792
|
ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: AUTH_MISSING_MESSAGE });
|
|
2696
2793
|
return { status: "failed", summary: "burn aborted: auth token missing" };
|
|
2697
2794
|
}
|
|
2698
|
-
const cycle = detectCycle(
|
|
2795
|
+
const cycle = detectCycle(burnable);
|
|
2699
2796
|
if (cycle) {
|
|
2700
2797
|
const path = cycle.join(" \u2192 ");
|
|
2701
2798
|
ctx.emitEvent({
|
|
@@ -2705,9 +2802,9 @@ async function burnRun(ctx, deps) {
|
|
|
2705
2802
|
});
|
|
2706
2803
|
return { status: "failed", summary: `dependency cycle: ${path}` };
|
|
2707
2804
|
}
|
|
2708
|
-
const done = await burnTickets(ctx, tickets2, deps.executeTicketRun);
|
|
2709
|
-
const summary = `${done}/${total} tickets done`;
|
|
2710
|
-
ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total } });
|
|
2805
|
+
const done = await burnTickets(ctx, tickets2, deps.executeTicketRun, deps.concurrency);
|
|
2806
|
+
const summary = cancelled > 0 ? `${done}/${total} tickets done (${cancelled} cancelled)` : `${done}/${total} tickets done`;
|
|
2807
|
+
ctx.emitEvent({ type: "burn.summary", message: summary, data: { done, total, cancelled } });
|
|
2711
2808
|
return { status: done === total ? "succeeded" : "failed", summary };
|
|
2712
2809
|
}
|
|
2713
2810
|
function burnerTemplatePath() {
|
|
@@ -2768,8 +2865,9 @@ function selectSandbox(config2) {
|
|
|
2768
2865
|
return noSandbox();
|
|
2769
2866
|
}
|
|
2770
2867
|
}
|
|
2771
|
-
async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
2868
|
+
async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
|
|
2772
2869
|
const { project, feature } = ctx;
|
|
2870
|
+
const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
|
|
2773
2871
|
const template = readFileSync4(burnerTemplatePath(), "utf8");
|
|
2774
2872
|
const prompt = renderTicketPrompt(template, {
|
|
2775
2873
|
TICKET_JSON: buildTicketJson(ticket),
|
|
@@ -2785,7 +2883,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
|
2785
2883
|
sandbox: selectSandbox(config2),
|
|
2786
2884
|
cwd: project.repoPath,
|
|
2787
2885
|
prompt,
|
|
2788
|
-
branchStrategy: { type: "branch", branch:
|
|
2886
|
+
branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
|
|
2789
2887
|
signal: ctx.signal,
|
|
2790
2888
|
name: `ticket-${ticket.seq}`,
|
|
2791
2889
|
logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
|
|
@@ -2812,16 +2910,39 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
|
2812
2910
|
}
|
|
2813
2911
|
throttle.flush();
|
|
2814
2912
|
const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
|
|
2815
|
-
|
|
2913
|
+
const outcome = interpretRunResult(result, blocked);
|
|
2914
|
+
if (outcome.status !== "done")
|
|
2915
|
+
return outcome;
|
|
2916
|
+
const merge = await land(() => mergeTempBranch(project.repoPath, feature.branch, tempBranch));
|
|
2917
|
+
if (!merge.ok) {
|
|
2918
|
+
const detail = merge.error ?? "merge failed";
|
|
2919
|
+
if (merge.conflict) {
|
|
2920
|
+
return {
|
|
2921
|
+
status: "failed",
|
|
2922
|
+
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} hit a conflict: ${detail}`,
|
|
2923
|
+
event: {
|
|
2924
|
+
type: "merge.conflict.needs-human",
|
|
2925
|
+
message: `ticket ${ticket.seq}: ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then re-burn`
|
|
2926
|
+
}
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
return {
|
|
2930
|
+
status: "failed",
|
|
2931
|
+
error: `ticket ${ticket.seq} committed to ${tempBranch} but landing on ${feature.branch} failed: ${errorHeadline(detail)} \u2014 the branch is preserved for manual recovery`
|
|
2932
|
+
};
|
|
2933
|
+
}
|
|
2934
|
+
return outcome;
|
|
2816
2935
|
}
|
|
2817
2936
|
function resolveBurnDeps(ctx) {
|
|
2818
2937
|
const config2 = loadConfig();
|
|
2819
2938
|
const token = readTokenFromEnvFile(envPath());
|
|
2820
2939
|
const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
|
|
2940
|
+
const land = createSerialQueue();
|
|
2821
2941
|
return {
|
|
2822
2942
|
config: config2,
|
|
2823
2943
|
hasAuthToken: token !== undefined,
|
|
2824
|
-
|
|
2944
|
+
concurrency: config2.burnConcurrency,
|
|
2945
|
+
executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land)
|
|
2825
2946
|
};
|
|
2826
2947
|
}
|
|
2827
2948
|
function readTokenFromEnvFile(path) {
|
|
@@ -2836,12 +2957,13 @@ function readTokenFromEnvFile(path) {
|
|
|
2836
2957
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
2837
2958
|
return token && token.length > 0 ? token : undefined;
|
|
2838
2959
|
}
|
|
2839
|
-
var
|
|
2960
|
+
var AUTH_MISSING_EVENT = "auth.missing", AUTH_MISSING_MESSAGE = "run `claude setup-token` and put CLAUDE_CODE_OAUTH_TOKEN in ~/.runcastle/.env", PLACEHOLDERS, ticketBurner;
|
|
2840
2961
|
var init_ticket_burner = __esm(() => {
|
|
2841
2962
|
init_src();
|
|
2842
2963
|
init_config_load();
|
|
2843
2964
|
init_paths();
|
|
2844
2965
|
init_skills_root();
|
|
2966
|
+
init_git();
|
|
2845
2967
|
PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
|
|
2846
2968
|
ticketBurner = {
|
|
2847
2969
|
id: "ticket-burner",
|
|
@@ -2891,15 +3013,16 @@ async function researchRun(ctx, deps) {
|
|
|
2891
3013
|
}
|
|
2892
3014
|
ctx.emitEvent({
|
|
2893
3015
|
type: "research.failed",
|
|
2894
|
-
message: `waypoint ${waypoint.seq} research failed: ${
|
|
3016
|
+
message: `waypoint ${waypoint.seq} research failed: ${errorHeadline2(outcome.error)}`,
|
|
2895
3017
|
data: { waypointId: waypoint.id, error: outcome.error }
|
|
2896
3018
|
});
|
|
2897
3019
|
return { status: "failed", summary: outcome.error };
|
|
2898
3020
|
}
|
|
2899
|
-
function
|
|
2900
|
-
const
|
|
2901
|
-
`);
|
|
2902
|
-
|
|
3021
|
+
function errorHeadline2(s) {
|
|
3022
|
+
const lines = s.split(`
|
|
3023
|
+
`).map((l) => l.trim()).filter(Boolean);
|
|
3024
|
+
const causes = lines.filter((l) => /^(fatal|error):/i.test(l));
|
|
3025
|
+
return causes.at(-1) ?? lines[0] ?? "";
|
|
2903
3026
|
}
|
|
2904
3027
|
function createResearchStreamThrottle(emit2, opts = {}) {
|
|
2905
3028
|
return createStreamThrottle((e) => emit2({ ...e, type: e.type.replace(/^burn\./, "research.") }), opts);
|
|
@@ -2971,12 +3094,12 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
2971
3094
|
const outcome = interpretResearchResult(result, docRelPath);
|
|
2972
3095
|
if (outcome.status !== "done")
|
|
2973
3096
|
return outcome;
|
|
2974
|
-
const merge = await
|
|
3097
|
+
const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
|
|
2975
3098
|
if (!merge.ok) {
|
|
2976
3099
|
const detail = merge.error ?? "merge failed";
|
|
2977
3100
|
ctx.emitEvent({
|
|
2978
3101
|
type: merge.conflict ? "merge.conflict.needs-human" : "research.merge_failed",
|
|
2979
|
-
message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${
|
|
3102
|
+
message: merge.conflict ? `research branch ${tempBranch} conflicts with ${feature.branch} \u2014 merge it manually (git merge ${tempBranch}; resolve; git branch -D ${tempBranch}), then resolve the waypoint` : `could not land ${tempBranch} on ${feature.branch}: ${errorHeadline2(detail)} \u2014 the branch is preserved for manual recovery`,
|
|
2980
3103
|
data: { tempBranch, featureBranch: feature.branch, conflict: merge.conflict ?? false, error: detail }
|
|
2981
3104
|
});
|
|
2982
3105
|
return {
|
|
@@ -3249,7 +3372,8 @@ function list(ctx, projectId) {
|
|
|
3249
3372
|
pending: tickets2.filter((t) => t.status === "pending").length,
|
|
3250
3373
|
burning: tickets2.filter((t) => t.status === "burning").length,
|
|
3251
3374
|
done: tickets2.filter((t) => t.status === "done").length,
|
|
3252
|
-
failed: tickets2.filter((t) => t.status === "failed").length
|
|
3375
|
+
failed: tickets2.filter((t) => t.status === "failed").length,
|
|
3376
|
+
cancelled: tickets2.filter((t) => t.status === "cancelled").length
|
|
3253
3377
|
};
|
|
3254
3378
|
return { ...feature, ticketCounts: counts, activeRun: hasActiveRun(ctx, feature.id) };
|
|
3255
3379
|
});
|
|
@@ -3278,13 +3402,19 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
3278
3402
|
const why = feature.phase === "implementation" ? "a run is already burning this feature" : `feature must be in the tickets phase to burn (currently ${feature.phase})`;
|
|
3279
3403
|
throw new GateError(why);
|
|
3280
3404
|
}
|
|
3281
|
-
|
|
3282
|
-
|
|
3405
|
+
const tickets2 = listByFeature2(ctx, featureId);
|
|
3406
|
+
if (tickets2.filter((t) => t.status !== "cancelled").length < 1) {
|
|
3407
|
+
throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
|
|
3283
3408
|
}
|
|
3284
3409
|
if (restarting) {
|
|
3410
|
+
const failed = tickets2.filter((t) => t.status === "failed");
|
|
3411
|
+
for (const t of failed) {
|
|
3412
|
+
updateTicket(ctx, t.id, { status: "pending", error: null });
|
|
3413
|
+
}
|
|
3285
3414
|
emit(ctx, featureId, {
|
|
3286
3415
|
type: "burn.restarted",
|
|
3287
|
-
message: "restarting burn (previous run cancelled or crashed)"
|
|
3416
|
+
message: failed.length > 0 ? `restarting burn \u2014 retrying ${failed.length} failed ticket(s)` : "restarting burn (previous run cancelled or crashed)",
|
|
3417
|
+
data: { retried: failed.map((t) => t.seq) }
|
|
3288
3418
|
});
|
|
3289
3419
|
} else {
|
|
3290
3420
|
setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
|
|
@@ -3396,6 +3526,22 @@ function toolEmitTickets(ctx, session, input) {
|
|
|
3396
3526
|
const stored = storeTickets(ctx, feature.id, input.tickets);
|
|
3397
3527
|
return { stored: stored.length, ids: stored.map((t) => t.id) };
|
|
3398
3528
|
}
|
|
3529
|
+
function requireOwnTicket(ctx, session, ticketId) {
|
|
3530
|
+
const ticket = getTicket(ctx, ticketId);
|
|
3531
|
+
if (ticket.featureId !== session.featureId) {
|
|
3532
|
+
throw new GateError(`ticket ${ticketId} does not belong to this session's feature`);
|
|
3533
|
+
}
|
|
3534
|
+
return ticket;
|
|
3535
|
+
}
|
|
3536
|
+
function toolUpdateTicket(ctx, session, input) {
|
|
3537
|
+
requireOwnTicket(ctx, session, input.id);
|
|
3538
|
+
const { id, ...patch } = input;
|
|
3539
|
+
return { ok: true, ticket: editTicket(ctx, id, patch) };
|
|
3540
|
+
}
|
|
3541
|
+
function toolCancelTicket(ctx, session, input) {
|
|
3542
|
+
requireOwnTicket(ctx, session, input.id);
|
|
3543
|
+
return { ok: true, ticket: cancelTicket(ctx, input.id, input.reason) };
|
|
3544
|
+
}
|
|
3399
3545
|
function toolEscalateToMap(ctx, session, input) {
|
|
3400
3546
|
return escalateToMap(ctx, session.featureId, input);
|
|
3401
3547
|
}
|
|
@@ -3495,6 +3641,37 @@ function buildMcpServer() {
|
|
|
3495
3641
|
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: tickets emitted (${result.stored})`);
|
|
3496
3642
|
return ok(result);
|
|
3497
3643
|
});
|
|
3644
|
+
server.registerTool("update_ticket", {
|
|
3645
|
+
title: "Update ticket",
|
|
3646
|
+
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.",
|
|
3647
|
+
inputSchema: {
|
|
3648
|
+
id: z3.string(),
|
|
3649
|
+
title: z3.string().optional(),
|
|
3650
|
+
goal: z3.string().optional(),
|
|
3651
|
+
context: z3.string().optional(),
|
|
3652
|
+
acceptanceCriteria: z3.array(z3.string()).optional(),
|
|
3653
|
+
seams: z3.array(z3.string()).optional()
|
|
3654
|
+
}
|
|
3655
|
+
}, async (args, extra) => {
|
|
3656
|
+
const rs = await resolveCtxSession(extra);
|
|
3657
|
+
if (!rs)
|
|
3658
|
+
return noSession();
|
|
3659
|
+
const result = toolUpdateTicket(rs.ctx, rs.session, args);
|
|
3660
|
+
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} updated`);
|
|
3661
|
+
return ok(result);
|
|
3662
|
+
});
|
|
3663
|
+
server.registerTool("cancel_ticket", {
|
|
3664
|
+
title: "Cancel ticket",
|
|
3665
|
+
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.",
|
|
3666
|
+
inputSchema: { id: z3.string(), reason: z3.string().optional() }
|
|
3667
|
+
}, async (args, extra) => {
|
|
3668
|
+
const rs = await resolveCtxSession(extra);
|
|
3669
|
+
if (!rs)
|
|
3670
|
+
return noSession();
|
|
3671
|
+
const result = toolCancelTicket(rs.ctx, rs.session, args);
|
|
3672
|
+
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: ticket ${result.ticket.seq} cancelled`);
|
|
3673
|
+
return ok(result);
|
|
3674
|
+
});
|
|
3498
3675
|
server.registerTool("escalate_to_map", {
|
|
3499
3676
|
title: "Escalate to map",
|
|
3500
3677
|
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.",
|
|
@@ -3892,6 +4069,8 @@ function renderSystemPrompt(feature, kind, waypoint) {
|
|
|
3892
4069
|
return renderWaypointPrompt(feature, waypoint);
|
|
3893
4070
|
if (kind === "converge")
|
|
3894
4071
|
return renderConvergePrompt(feature);
|
|
4072
|
+
if (kind === "revisit")
|
|
4073
|
+
return renderRevisitPrompt(feature);
|
|
3895
4074
|
const docs = featureDocsRel(feature.slug);
|
|
3896
4075
|
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.";
|
|
3897
4076
|
return [
|
|
@@ -4017,6 +4196,49 @@ function renderConvergePrompt(feature) {
|
|
|
4017
4196
|
].join(`
|
|
4018
4197
|
`);
|
|
4019
4198
|
}
|
|
4199
|
+
function renderRevisitPrompt(feature) {
|
|
4200
|
+
const docs = featureDocsRel(feature.slug);
|
|
4201
|
+
return [
|
|
4202
|
+
`# runcastle \u2014 ${feature.title} (revisit session)`,
|
|
4203
|
+
"",
|
|
4204
|
+
feature.oneLiner,
|
|
4205
|
+
"",
|
|
4206
|
+
"This is a **revisit session**: the human came back to a feature whose",
|
|
4207
|
+
"earlier sessions are finished \u2014 they remembered something, or a decision",
|
|
4208
|
+
"changed. Where possible this terminal RESUMES the previous conversation, so",
|
|
4209
|
+
"you may already have its context above. Your job: fold the new information",
|
|
4210
|
+
"into the record, then reconcile the tickets with it. The docs are the",
|
|
4211
|
+
"artifact \u2014 later phases read them, never the transcripts.",
|
|
4212
|
+
"",
|
|
4213
|
+
"## Feature",
|
|
4214
|
+
`- Slug: \`${feature.slug}\``,
|
|
4215
|
+
`- Branch: \`${feature.branch}\``,
|
|
4216
|
+
`- Current phase: **${feature.phase}** (size: ${feature.size})`,
|
|
4217
|
+
"",
|
|
4218
|
+
"## Knowledge (versioned in the target repo)",
|
|
4219
|
+
`Feature docs live at \`${docs}/\`:`,
|
|
4220
|
+
`- \`${docs}/decisions.md\` \u2014 append the new/changed decisions with a dated "revisited" note.`,
|
|
4221
|
+
`- \`${docs}/spec.md\` \u2014 if it exists, amend the affected sections in place.`,
|
|
4222
|
+
`- \`${docs}/map.md\` \u2014 if the feature is mapped, keep the map honest too.`,
|
|
4223
|
+
"",
|
|
4224
|
+
"## runcastle MCP tools",
|
|
4225
|
+
"- `get_feature_context()` \u2014 feature + phase + docs + ALL tickets (with ids and statuses).",
|
|
4226
|
+
"- `update_ticket({ id, ...fields })` \u2014 rewrite a stale pending/failed ticket's content.",
|
|
4227
|
+
"- `cancel_ticket({ id, reason })` \u2014 cancel a pending/failed ticket that is now unnecessary.",
|
|
4228
|
+
"- `emit_tickets({ tickets })` \u2014 add tickets for NEW work the change requires.",
|
|
4229
|
+
"- `record_event({ type, message })` \u2014 drop a timeline note summarising the revisit.",
|
|
4230
|
+
"",
|
|
4231
|
+
"## Rules",
|
|
4232
|
+
"- Do NOT call `complete_phase` \u2014 a revisit never moves the pipeline.",
|
|
4233
|
+
"- Do NOT touch `done`/`burning` tickets; if done work is now wrong, emit a new ticket that fixes it.",
|
|
4234
|
+
"- Docs first, tickets second: capture the decision prose before any ticket surgery.",
|
|
4235
|
+
"",
|
|
4236
|
+
"## Your task",
|
|
4237
|
+
"Invoke the `/runcastle:revisit` skill and work through what the human brings up.",
|
|
4238
|
+
""
|
|
4239
|
+
].join(`
|
|
4240
|
+
`);
|
|
4241
|
+
}
|
|
4020
4242
|
function renderSettings(hookClient) {
|
|
4021
4243
|
const cmd = (event) => ({
|
|
4022
4244
|
type: "command",
|
|
@@ -4062,6 +4284,8 @@ var init_artifacts = __esm(() => {
|
|
|
4062
4284
|
RUNCASTLE_MCP_ALLOW_RULES = [
|
|
4063
4285
|
"mcp__runcastle__get_feature_context",
|
|
4064
4286
|
"mcp__runcastle__emit_tickets",
|
|
4287
|
+
"mcp__runcastle__update_ticket",
|
|
4288
|
+
"mcp__runcastle__cancel_ticket",
|
|
4065
4289
|
"mcp__runcastle__record_event",
|
|
4066
4290
|
"mcp__runcastle__complete_phase",
|
|
4067
4291
|
"mcp__runcastle__escalate_to_map",
|
|
@@ -4228,11 +4452,32 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
4228
4452
|
throw e;
|
|
4229
4453
|
}
|
|
4230
4454
|
}
|
|
4455
|
+
if (input.kind === "revisit") {
|
|
4456
|
+
try {
|
|
4457
|
+
assertSpawnable(ctx, feature, session.id);
|
|
4458
|
+
} catch (e) {
|
|
4459
|
+
markSessionEnded(ctx, session.id);
|
|
4460
|
+
throw e;
|
|
4461
|
+
}
|
|
4462
|
+
const prior = mostRecentResumableSession(ctx, feature.id);
|
|
4463
|
+
if (prior?.ccSessionId) {
|
|
4464
|
+
resumeSessionId = prior.ccSessionId;
|
|
4465
|
+
} else {
|
|
4466
|
+
resumeUnavailableFrom = "revisit";
|
|
4467
|
+
}
|
|
4468
|
+
}
|
|
4231
4469
|
emit(ctx, feature.id, {
|
|
4232
4470
|
type: "session.launching",
|
|
4233
4471
|
message: `launching ${input.kind} session`,
|
|
4234
4472
|
data: { sessionId: session.id, kind: input.kind, worktreePath, waypointId: waypoint?.id }
|
|
4235
4473
|
});
|
|
4474
|
+
if (input.kind === "revisit" && resumeUnavailableFrom) {
|
|
4475
|
+
emit(ctx, feature.id, {
|
|
4476
|
+
type: "session.resume_unavailable",
|
|
4477
|
+
message: "no resumable conversation for this feature \u2014 revisiting fresh from the docs",
|
|
4478
|
+
data: { sessionId: session.id }
|
|
4479
|
+
});
|
|
4480
|
+
}
|
|
4236
4481
|
if (waypoint && resumeUnavailableFrom) {
|
|
4237
4482
|
emit(ctx, feature.id, {
|
|
4238
4483
|
type: "session.resume_unavailable",
|
|
@@ -4830,6 +5075,14 @@ var init_settings = __esm(() => {
|
|
|
4830
5075
|
valueSchema: z9.string().min(1),
|
|
4831
5076
|
parseEnv: idEnv
|
|
4832
5077
|
},
|
|
5078
|
+
{
|
|
5079
|
+
key: "burnConcurrency",
|
|
5080
|
+
configKey: "burnConcurrency",
|
|
5081
|
+
envVar: "RUNCASTLE_BURN_CONCURRENCY",
|
|
5082
|
+
restartRequired: false,
|
|
5083
|
+
valueSchema: z9.number().int().min(1).max(8),
|
|
5084
|
+
parseEnv: (raw) => Number(raw)
|
|
5085
|
+
},
|
|
4833
5086
|
{
|
|
4834
5087
|
key: "mainBranch",
|
|
4835
5088
|
configKey: "mainBranch",
|
|
@@ -5424,7 +5677,7 @@ async function reconcileStaleRuns(ctx) {
|
|
|
5424
5677
|
}
|
|
5425
5678
|
for (const project of allProjects(ctx)) {
|
|
5426
5679
|
try {
|
|
5427
|
-
await
|
|
5680
|
+
await cleanupTempBranches(project.repoPath);
|
|
5428
5681
|
} catch {}
|
|
5429
5682
|
}
|
|
5430
5683
|
return reconciled;
|