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/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"])
|
|
@@ -2058,7 +2118,10 @@ async function reattachWorktree(path, branch) {
|
|
|
2058
2118
|
function researchBranchName(slug, waypointSeq, unique) {
|
|
2059
2119
|
return `${RESEARCH_BRANCH_PREFIX}${slug}/${waypointSeq}-${unique}`;
|
|
2060
2120
|
}
|
|
2061
|
-
|
|
2121
|
+
function ticketBranchName(slug, ticketSeq, unique) {
|
|
2122
|
+
return `${TICKET_BRANCH_PREFIX}${slug}/${ticketSeq}-${unique}`;
|
|
2123
|
+
}
|
|
2124
|
+
async function mergeTempBranch(repoPath, featureBranchName, tempBranch) {
|
|
2062
2125
|
const g = git(repoPath);
|
|
2063
2126
|
let branches;
|
|
2064
2127
|
try {
|
|
@@ -2107,7 +2170,10 @@ async function deleteBranchDetachingWorktrees(g, repoPath, branch) {
|
|
|
2107
2170
|
return false;
|
|
2108
2171
|
}
|
|
2109
2172
|
}
|
|
2110
|
-
|
|
2173
|
+
function tempBranchPrefix(name) {
|
|
2174
|
+
return TEMP_BRANCH_PREFIXES.find((p) => name.startsWith(p));
|
|
2175
|
+
}
|
|
2176
|
+
async function cleanupTempBranches(repoPath) {
|
|
2111
2177
|
const deleted = [];
|
|
2112
2178
|
const kept = [];
|
|
2113
2179
|
const g = git(repoPath);
|
|
@@ -2118,9 +2184,10 @@ async function cleanupResearchBranches(repoPath) {
|
|
|
2118
2184
|
return { deleted, kept };
|
|
2119
2185
|
}
|
|
2120
2186
|
for (const name of all) {
|
|
2121
|
-
|
|
2187
|
+
const prefix = tempBranchPrefix(name);
|
|
2188
|
+
if (!prefix)
|
|
2122
2189
|
continue;
|
|
2123
|
-
const slug = name.slice(
|
|
2190
|
+
const slug = name.slice(prefix.length).split("/")[0];
|
|
2124
2191
|
const target = featureBranch(slug);
|
|
2125
2192
|
let merged = false;
|
|
2126
2193
|
if (slug && all.includes(target)) {
|
|
@@ -2277,13 +2344,14 @@ async function mergeInProgress(g) {
|
|
|
2277
2344
|
return false;
|
|
2278
2345
|
}
|
|
2279
2346
|
}
|
|
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;
|
|
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;
|
|
2281
2348
|
var init_git = __esm(() => {
|
|
2282
2349
|
init_paths();
|
|
2283
2350
|
init_errors();
|
|
2284
2351
|
init_dev_pane();
|
|
2285
2352
|
init_events();
|
|
2286
2353
|
init_repo();
|
|
2354
|
+
TEMP_BRANCH_PREFIXES = [RESEARCH_BRANCH_PREFIX, TICKET_BRANCH_PREFIX];
|
|
2287
2355
|
});
|
|
2288
2356
|
|
|
2289
2357
|
// src/services/knowledge.ts
|
|
@@ -2574,16 +2642,28 @@ function isMergeConflictError(err) {
|
|
|
2574
2642
|
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : "";
|
|
2575
2643
|
return /conflict/i.test(msg) || /git branch -D/i.test(msg) || /automatic merge failed/i.test(msg);
|
|
2576
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
|
+
}
|
|
2577
2655
|
function firstLine(s) {
|
|
2578
2656
|
const i = s.indexOf(`
|
|
2579
2657
|
`);
|
|
2580
2658
|
return (i === -1 ? s : s.slice(0, i)).trim();
|
|
2581
2659
|
}
|
|
2582
|
-
async function burnTickets(ctx, tickets2, execute) {
|
|
2660
|
+
async function burnTickets(ctx, tickets2, execute, concurrency = 1) {
|
|
2661
|
+
const width = Math.max(1, Math.floor(concurrency));
|
|
2583
2662
|
const bySeq = indexBySeq(tickets2);
|
|
2584
2663
|
const status = new Map(tickets2.map((t) => [t.seq, t.status]));
|
|
2585
2664
|
const pending = new Set(tickets2.filter((t) => t.status === "pending").map((t) => t.seq));
|
|
2586
2665
|
const inFlight = new Map;
|
|
2666
|
+
const satisfied = (s) => s === "done" || s === "cancelled";
|
|
2587
2667
|
const readyState = (seq) => {
|
|
2588
2668
|
const t = bySeq.get(seq);
|
|
2589
2669
|
if (!t)
|
|
@@ -2594,7 +2674,7 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2594
2674
|
if (!present || bs === "failed")
|
|
2595
2675
|
return { blockedBy: b, present };
|
|
2596
2676
|
}
|
|
2597
|
-
return t.blockedBy.every((b) => status.get(b)
|
|
2677
|
+
return t.blockedBy.every((b) => satisfied(status.get(b))) ? "ready" : "wait";
|
|
2598
2678
|
};
|
|
2599
2679
|
const failTicket = (seq, error, extra) => {
|
|
2600
2680
|
const t = bySeq.get(seq);
|
|
@@ -2655,7 +2735,7 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2655
2735
|
}
|
|
2656
2736
|
}
|
|
2657
2737
|
}
|
|
2658
|
-
while (inFlight.size <
|
|
2738
|
+
while (inFlight.size < width) {
|
|
2659
2739
|
const readySeq = [...pending].find((seq) => readyState(seq) === "ready");
|
|
2660
2740
|
if (readySeq === undefined)
|
|
2661
2741
|
break;
|
|
@@ -2666,7 +2746,12 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2666
2746
|
inFlight.set(readySeq, p);
|
|
2667
2747
|
}
|
|
2668
2748
|
if (inFlight.size > 0) {
|
|
2669
|
-
|
|
2749
|
+
try {
|
|
2750
|
+
await Promise.race(inFlight.values());
|
|
2751
|
+
} catch (err) {
|
|
2752
|
+
await Promise.allSettled([...inFlight.values()]);
|
|
2753
|
+
throw err;
|
|
2754
|
+
}
|
|
2670
2755
|
} else if (pending.size > 0) {
|
|
2671
2756
|
for (const seq of [...pending]) {
|
|
2672
2757
|
pending.delete(seq);
|
|
@@ -2690,12 +2775,14 @@ async function burnTickets(ctx, tickets2, execute) {
|
|
|
2690
2775
|
}
|
|
2691
2776
|
async function burnRun(ctx, deps) {
|
|
2692
2777
|
const tickets2 = ctx.tickets;
|
|
2693
|
-
const
|
|
2778
|
+
const burnable = tickets2.filter((t) => t.status !== "cancelled");
|
|
2779
|
+
const cancelled = tickets2.length - burnable.length;
|
|
2780
|
+
const total = burnable.length;
|
|
2694
2781
|
if (deps.config.sandbox !== "noSandbox" && !deps.hasAuthToken) {
|
|
2695
2782
|
ctx.emitEvent({ type: AUTH_MISSING_EVENT, message: AUTH_MISSING_MESSAGE });
|
|
2696
2783
|
return { status: "failed", summary: "burn aborted: auth token missing" };
|
|
2697
2784
|
}
|
|
2698
|
-
const cycle = detectCycle(
|
|
2785
|
+
const cycle = detectCycle(burnable);
|
|
2699
2786
|
if (cycle) {
|
|
2700
2787
|
const path = cycle.join(" \u2192 ");
|
|
2701
2788
|
ctx.emitEvent({
|
|
@@ -2705,9 +2792,9 @@ async function burnRun(ctx, deps) {
|
|
|
2705
2792
|
});
|
|
2706
2793
|
return { status: "failed", summary: `dependency cycle: ${path}` };
|
|
2707
2794
|
}
|
|
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 } });
|
|
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 } });
|
|
2711
2798
|
return { status: done === total ? "succeeded" : "failed", summary };
|
|
2712
2799
|
}
|
|
2713
2800
|
function burnerTemplatePath() {
|
|
@@ -2768,8 +2855,9 @@ function selectSandbox(config2) {
|
|
|
2768
2855
|
return noSandbox();
|
|
2769
2856
|
}
|
|
2770
2857
|
}
|
|
2771
|
-
async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
2858
|
+
async function realExecuteTicketRun(ctx, ticket, config2, token, model, land) {
|
|
2772
2859
|
const { project, feature } = ctx;
|
|
2860
|
+
const tempBranch = ticketBranchName(feature.slug, ticket.seq, newId("b").slice(2, 10));
|
|
2773
2861
|
const template = readFileSync4(burnerTemplatePath(), "utf8");
|
|
2774
2862
|
const prompt = renderTicketPrompt(template, {
|
|
2775
2863
|
TICKET_JSON: buildTicketJson(ticket),
|
|
@@ -2785,7 +2873,7 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
|
2785
2873
|
sandbox: selectSandbox(config2),
|
|
2786
2874
|
cwd: project.repoPath,
|
|
2787
2875
|
prompt,
|
|
2788
|
-
branchStrategy: { type: "branch", branch:
|
|
2876
|
+
branchStrategy: { type: "branch", branch: tempBranch, baseBranch: feature.branch },
|
|
2789
2877
|
signal: ctx.signal,
|
|
2790
2878
|
name: `ticket-${ticket.seq}`,
|
|
2791
2879
|
logging: { type: "file", path: logFilePath, onAgentStreamEvent: throttle.onEvent }
|
|
@@ -2812,16 +2900,39 @@ async function realExecuteTicketRun(ctx, ticket, config2, token, model) {
|
|
|
2812
2900
|
}
|
|
2813
2901
|
throttle.flush();
|
|
2814
2902
|
const blocked = readBlockedFile([result.preservedWorktreePath, project.repoPath]);
|
|
2815
|
-
|
|
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;
|
|
2816
2925
|
}
|
|
2817
2926
|
function resolveBurnDeps(ctx) {
|
|
2818
2927
|
const config2 = loadConfig();
|
|
2819
2928
|
const token = readTokenFromEnvFile(envPath());
|
|
2820
2929
|
const model = resolveModel("implement", config2, ctx.project, ctx.modelOverride);
|
|
2930
|
+
const land = createSerialQueue();
|
|
2821
2931
|
return {
|
|
2822
2932
|
config: config2,
|
|
2823
2933
|
hasAuthToken: token !== undefined,
|
|
2824
|
-
|
|
2934
|
+
concurrency: config2.burnConcurrency,
|
|
2935
|
+
executeTicketRun: (c, ticket) => realExecuteTicketRun(c, ticket, config2, token, model, land)
|
|
2825
2936
|
};
|
|
2826
2937
|
}
|
|
2827
2938
|
function readTokenFromEnvFile(path) {
|
|
@@ -2836,12 +2947,13 @@ function readTokenFromEnvFile(path) {
|
|
|
2836
2947
|
const token = fromFile && fromFile.length > 0 ? fromFile : process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
|
2837
2948
|
return token && token.length > 0 ? token : undefined;
|
|
2838
2949
|
}
|
|
2839
|
-
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;
|
|
2840
2951
|
var init_ticket_burner = __esm(() => {
|
|
2841
2952
|
init_src();
|
|
2842
2953
|
init_config_load();
|
|
2843
2954
|
init_paths();
|
|
2844
2955
|
init_skills_root();
|
|
2956
|
+
init_git();
|
|
2845
2957
|
PLACEHOLDERS = ["TICKET_JSON", "FEATURE_BRIEF", "DOCS_DIGEST", "COMMIT_CONVENTION"];
|
|
2846
2958
|
ticketBurner = {
|
|
2847
2959
|
id: "ticket-burner",
|
|
@@ -2971,7 +3083,7 @@ async function realExecuteResearchRun(ctx, waypoint, config2, token, model) {
|
|
|
2971
3083
|
const outcome = interpretResearchResult(result, docRelPath);
|
|
2972
3084
|
if (outcome.status !== "done")
|
|
2973
3085
|
return outcome;
|
|
2974
|
-
const merge = await
|
|
3086
|
+
const merge = await mergeTempBranch(project.repoPath, feature.branch, tempBranch);
|
|
2975
3087
|
if (!merge.ok) {
|
|
2976
3088
|
const detail = merge.error ?? "merge failed";
|
|
2977
3089
|
ctx.emitEvent({
|
|
@@ -3249,7 +3361,8 @@ function list(ctx, projectId) {
|
|
|
3249
3361
|
pending: tickets2.filter((t) => t.status === "pending").length,
|
|
3250
3362
|
burning: tickets2.filter((t) => t.status === "burning").length,
|
|
3251
3363
|
done: tickets2.filter((t) => t.status === "done").length,
|
|
3252
|
-
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
|
|
3253
3366
|
};
|
|
3254
3367
|
return { ...feature, ticketCounts: counts, activeRun: hasActiveRun(ctx, feature.id) };
|
|
3255
3368
|
});
|
|
@@ -3278,13 +3391,19 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
3278
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})`;
|
|
3279
3392
|
throw new GateError(why);
|
|
3280
3393
|
}
|
|
3281
|
-
|
|
3282
|
-
|
|
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");
|
|
3283
3397
|
}
|
|
3284
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
|
+
}
|
|
3285
3403
|
emit(ctx, featureId, {
|
|
3286
3404
|
type: "burn.restarted",
|
|
3287
|
-
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) }
|
|
3288
3407
|
});
|
|
3289
3408
|
} else {
|
|
3290
3409
|
setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
|
|
@@ -3396,6 +3515,22 @@ function toolEmitTickets(ctx, session, input) {
|
|
|
3396
3515
|
const stored = storeTickets(ctx, feature.id, input.tickets);
|
|
3397
3516
|
return { stored: stored.length, ids: stored.map((t) => t.id) };
|
|
3398
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
|
+
}
|
|
3399
3534
|
function toolEscalateToMap(ctx, session, input) {
|
|
3400
3535
|
return escalateToMap(ctx, session.featureId, input);
|
|
3401
3536
|
}
|
|
@@ -3495,6 +3630,37 @@ function buildMcpServer() {
|
|
|
3495
3630
|
await commitDocsCheckpoint(rs.ctx, rs.session, `runcastle: tickets emitted (${result.stored})`);
|
|
3496
3631
|
return ok(result);
|
|
3497
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
|
+
});
|
|
3498
3664
|
server.registerTool("escalate_to_map", {
|
|
3499
3665
|
title: "Escalate to map",
|
|
3500
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.",
|
|
@@ -3892,6 +4058,8 @@ function renderSystemPrompt(feature, kind, waypoint) {
|
|
|
3892
4058
|
return renderWaypointPrompt(feature, waypoint);
|
|
3893
4059
|
if (kind === "converge")
|
|
3894
4060
|
return renderConvergePrompt(feature);
|
|
4061
|
+
if (kind === "revisit")
|
|
4062
|
+
return renderRevisitPrompt(feature);
|
|
3895
4063
|
const docs = featureDocsRel(feature.slug);
|
|
3896
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.";
|
|
3897
4065
|
return [
|
|
@@ -4017,6 +4185,49 @@ function renderConvergePrompt(feature) {
|
|
|
4017
4185
|
].join(`
|
|
4018
4186
|
`);
|
|
4019
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
|
+
}
|
|
4020
4231
|
function renderSettings(hookClient) {
|
|
4021
4232
|
const cmd = (event) => ({
|
|
4022
4233
|
type: "command",
|
|
@@ -4062,6 +4273,8 @@ var init_artifacts = __esm(() => {
|
|
|
4062
4273
|
RUNCASTLE_MCP_ALLOW_RULES = [
|
|
4063
4274
|
"mcp__runcastle__get_feature_context",
|
|
4064
4275
|
"mcp__runcastle__emit_tickets",
|
|
4276
|
+
"mcp__runcastle__update_ticket",
|
|
4277
|
+
"mcp__runcastle__cancel_ticket",
|
|
4065
4278
|
"mcp__runcastle__record_event",
|
|
4066
4279
|
"mcp__runcastle__complete_phase",
|
|
4067
4280
|
"mcp__runcastle__escalate_to_map",
|
|
@@ -4228,11 +4441,32 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
4228
4441
|
throw e;
|
|
4229
4442
|
}
|
|
4230
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
|
+
}
|
|
4231
4458
|
emit(ctx, feature.id, {
|
|
4232
4459
|
type: "session.launching",
|
|
4233
4460
|
message: `launching ${input.kind} session`,
|
|
4234
4461
|
data: { sessionId: session.id, kind: input.kind, worktreePath, waypointId: waypoint?.id }
|
|
4235
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
|
+
}
|
|
4236
4470
|
if (waypoint && resumeUnavailableFrom) {
|
|
4237
4471
|
emit(ctx, feature.id, {
|
|
4238
4472
|
type: "session.resume_unavailable",
|
|
@@ -4830,6 +5064,14 @@ var init_settings = __esm(() => {
|
|
|
4830
5064
|
valueSchema: z9.string().min(1),
|
|
4831
5065
|
parseEnv: idEnv
|
|
4832
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
|
+
},
|
|
4833
5075
|
{
|
|
4834
5076
|
key: "mainBranch",
|
|
4835
5077
|
configKey: "mainBranch",
|
|
@@ -5424,7 +5666,7 @@ async function reconcileStaleRuns(ctx) {
|
|
|
5424
5666
|
}
|
|
5425
5667
|
for (const project of allProjects(ctx)) {
|
|
5426
5668
|
try {
|
|
5427
|
-
await
|
|
5669
|
+
await cleanupTempBranches(project.repoPath);
|
|
5428
5670
|
} catch {}
|
|
5429
5671
|
}
|
|
5430
5672
|
return reconciled;
|
package/package.json
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: revisit
|
|
3
|
+
description: Fold late-arriving information into a feature whose sessions are finished — amend the docs, then reconcile tickets (update/cancel/emit). Never advances phases. Entry skill for kind=revisit sessions.
|
|
4
|
+
disable-model-invocation: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Revisit
|
|
8
|
+
|
|
9
|
+
The human came back: they remembered a constraint, changed their mind, or learned something that the grilling/spec didn't capture. This session's job is to make the **record** and the **ticket queue** match the new reality — nothing more. The pipeline does not move; whatever phase the feature is in, it stays in.
|
|
10
|
+
|
|
11
|
+
## Order of operations
|
|
12
|
+
|
|
13
|
+
1. **Listen first.** The human opens with what changed. If this terminal resumed the previous conversation, use that context; do not re-grill what is already settled. Ask only the questions the NEW information raises.
|
|
14
|
+
2. **Context.** Call `mcp__runcastle__get_feature_context` — it returns the feature, phase, all docs, and all tickets with their ids and statuses. You need the ticket list before any surgery.
|
|
15
|
+
3. **Docs.** Capture the change as decision prose:
|
|
16
|
+
- Append to `docs/features/<slug>/decisions.md` under a dated `## Revisited <date>` heading — never rewrite old decisions, supersede them ("Supersedes: <old decision>").
|
|
17
|
+
- If `spec.md` exists and the change touches it, amend the affected sections in place.
|
|
18
|
+
- If the feature is mapped, keep `map.md` honest (destination/out-of-scope).
|
|
19
|
+
4. **Ticket surgery.** Walk the ticket list against the new reality:
|
|
20
|
+
- Stale but still needed → `mcp__runcastle__update_ticket({ id, ...changed fields })` (pending/failed only).
|
|
21
|
+
- No longer needed → `mcp__runcastle__cancel_ticket({ id, reason })` (pending/failed only).
|
|
22
|
+
- New work required → `mcp__runcastle__emit_tickets({ tickets })` (batch, same shape as ideation).
|
|
23
|
+
- `done` work now wrong → emit a NEW ticket that corrects it. Never touch done/burning tickets.
|
|
24
|
+
5. **Close.** `mcp__runcastle__record_event({ type: "feature.revisited", message: "<one-line gist of what changed>" })`, then tell the human what state you left things in — especially whether a re-Burn is needed (pending tickets exist and the feature is at `implementation`).
|
|
25
|
+
|
|
26
|
+
## Do NOT
|
|
27
|
+
|
|
28
|
+
- **Never call `complete_phase`.** A revisit has no gates to cross; the human drives the pipeline from the UI.
|
|
29
|
+
- **Never resolve or reopen waypoints.** If a resolved waypoint's answer is now wrong, record the superseding decision in `decisions.md` — the map's history stays intact.
|
|
30
|
+
- **Never implement changes.** Code changes ride tickets; the burner (or a human) implements them.
|
|
31
|
+
|
|
32
|
+
## Scope check
|
|
33
|
+
|
|
34
|
+
If what the human brings is not an amendment but a whole new capability, say so:
|
|
35
|
+
|
|
36
|
+
> That reads like a new feature, not a revision of this one. Create it in the runcastle UI and grill it there — I can record a pointer note here.
|
|
37
|
+
|
|
38
|
+
Then `record_event` a one-line note so the idea is not lost.
|