runcastle 1.0.14 → 1.0.16
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 +231 -74
- package/drizzle/0008_panoramic_molecule_man.sql +1 -0
- package/drizzle/meta/0008_snapshot.json +603 -0
- package/drizzle/meta/_journal.json +7 -0
- package/index.js +231 -74
- package/package.json +1 -1
- package/skills/packs/README.md +1 -1
- package/skills/packs/runcastle/skills/converge/SKILL.md +5 -9
- package/skills/packs/runcastle/skills/ideate/SKILL.md +6 -6
- package/skills/packs/runcastle/skills/revisit/SKILL.md +9 -0
- package/skills/packs/runcastle/skills/spec/SKILL.md +1 -1
- package/skills/packs/runcastle/skills/tickets/SKILL.md +2 -2
- package/web/assets/index-9ATu1U6z.css +1 -0
- package/web/assets/index-BXaPmFdw.js +110 -0
- package/web/index.html +2 -2
- package/web/assets/index-BEeyZA5j.js +0 -109
- package/web/assets/index-CI8-AEi9.css +0 -1
package/index.js
CHANGED
|
@@ -185,7 +185,7 @@ var init_ids = () => {};
|
|
|
185
185
|
|
|
186
186
|
// ../core/src/schemas.ts
|
|
187
187
|
import * as z2 from "zod";
|
|
188
|
-
var Phase,
|
|
188
|
+
var Phase, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, Project, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
|
|
189
189
|
var init_schemas = __esm(() => {
|
|
190
190
|
Phase = z2.enum([
|
|
191
191
|
"ideation",
|
|
@@ -195,11 +195,10 @@ var init_schemas = __esm(() => {
|
|
|
195
195
|
"review",
|
|
196
196
|
"shipped"
|
|
197
197
|
]);
|
|
198
|
-
FeatureSize = z2.enum(["full", "collapsed"]);
|
|
199
198
|
TicketStatus = z2.enum(["pending", "burning", "done", "failed", "cancelled"]);
|
|
200
199
|
SessionKind = z2.enum(["ideation", "qa", "waypoint", "converge", "revisit"]);
|
|
201
200
|
RunStatus = z2.enum(["running", "succeeded", "failed", "cancelled"]);
|
|
202
|
-
FeatureStatus = z2.enum(["active", "shipped"]);
|
|
201
|
+
FeatureStatus = z2.enum(["active", "shipped", "archived"]);
|
|
203
202
|
SessionStatus = z2.enum(["launching", "live", "ended"]);
|
|
204
203
|
TicketInput = z2.object({
|
|
205
204
|
title: z2.string(),
|
|
@@ -252,7 +251,6 @@ var init_schemas = __esm(() => {
|
|
|
252
251
|
slug: z2.string(),
|
|
253
252
|
title: z2.string(),
|
|
254
253
|
oneLiner: z2.string(),
|
|
255
|
-
size: FeatureSize,
|
|
256
254
|
mapped: z2.boolean(),
|
|
257
255
|
phase: Phase,
|
|
258
256
|
branch: z2.string(),
|
|
@@ -378,11 +376,7 @@ function nextPhase(feature) {
|
|
|
378
376
|
const i = ORDER.indexOf(feature.phase);
|
|
379
377
|
if (i < 0 || i >= ORDER.length - 1)
|
|
380
378
|
return null;
|
|
381
|
-
|
|
382
|
-
if (feature.size === "collapsed" && step === "spec") {
|
|
383
|
-
return ORDER[i + 2] ?? null;
|
|
384
|
-
}
|
|
385
|
-
return step;
|
|
379
|
+
return ORDER[i + 1];
|
|
386
380
|
}
|
|
387
381
|
function nextGate(feature) {
|
|
388
382
|
const i = ORDER.indexOf(feature.phase);
|
|
@@ -393,7 +387,7 @@ function nextGate(feature) {
|
|
|
393
387
|
return MAPPED_G1;
|
|
394
388
|
return gate;
|
|
395
389
|
}
|
|
396
|
-
var PIPELINE, ORDER, MAPPED_G1;
|
|
390
|
+
var PIPELINE, ORDER, REVIEW_LOOP_BACK, MAPPED_G1;
|
|
397
391
|
var init_pipeline = __esm(() => {
|
|
398
392
|
PIPELINE = [
|
|
399
393
|
{ phase: "ideation" },
|
|
@@ -409,7 +403,7 @@ var init_pipeline = __esm(() => {
|
|
|
409
403
|
phase: "tickets",
|
|
410
404
|
gateToEnter: {
|
|
411
405
|
id: "G2",
|
|
412
|
-
description: "Spec written before breaking into tickets
|
|
406
|
+
description: "Spec written before breaking into tickets",
|
|
413
407
|
check: "spec-file-exists"
|
|
414
408
|
}
|
|
415
409
|
},
|
|
@@ -439,6 +433,7 @@ var init_pipeline = __esm(() => {
|
|
|
439
433
|
}
|
|
440
434
|
];
|
|
441
435
|
ORDER = PIPELINE.map((p) => p.phase);
|
|
436
|
+
REVIEW_LOOP_BACK = { from: "review", to: "implementation" };
|
|
442
437
|
MAPPED_G1 = {
|
|
443
438
|
id: "G1",
|
|
444
439
|
description: "Every waypoint resolved or dropped before converging",
|
|
@@ -466,7 +461,6 @@ var init_db_schema = __esm(() => {
|
|
|
466
461
|
slug: text("slug").notNull(),
|
|
467
462
|
title: text("title").notNull(),
|
|
468
463
|
oneLiner: text("one_liner").notNull(),
|
|
469
|
-
size: text("size").notNull().$type(),
|
|
470
464
|
mapped: integer("mapped", { mode: "boolean" }).notNull().default(false),
|
|
471
465
|
phase: text("phase").notNull().$type(),
|
|
472
466
|
branch: text("branch").notNull(),
|
|
@@ -1194,7 +1188,6 @@ function rowToFeature(row) {
|
|
|
1194
1188
|
slug: row.slug,
|
|
1195
1189
|
title: row.title,
|
|
1196
1190
|
oneLiner: row.oneLiner,
|
|
1197
|
-
size: row.size,
|
|
1198
1191
|
mapped: row.mapped,
|
|
1199
1192
|
phase: row.phase,
|
|
1200
1193
|
branch: row.branch,
|
|
@@ -1515,15 +1508,20 @@ function markSessionLive(ctx, id, input = {}) {
|
|
|
1515
1508
|
const firstTimeLive = existing.status !== "live";
|
|
1516
1509
|
if (firstTimeLive) {
|
|
1517
1510
|
promoteLastSession(ctx, id);
|
|
1518
|
-
|
|
1519
|
-
scheduleConvergeKickoff(ctx, existing);
|
|
1511
|
+
scheduleKickoff(ctx, existing);
|
|
1520
1512
|
}
|
|
1521
1513
|
return getSessionRow(ctx, id);
|
|
1522
1514
|
}
|
|
1523
|
-
function
|
|
1515
|
+
function kickoffLineFor(kind, override) {
|
|
1516
|
+
return override ?? KICKOFF_LINES[kind];
|
|
1517
|
+
}
|
|
1518
|
+
function setKickoffOverride(sessionId, line) {
|
|
1519
|
+
pendingKickoffOverrides.set(sessionId, line);
|
|
1520
|
+
}
|
|
1521
|
+
function writeKickoffSequence(line, io, submitDelayMs = KICKOFF_SUBMIT_DELAY_MS) {
|
|
1524
1522
|
if (!io.alive())
|
|
1525
1523
|
return;
|
|
1526
|
-
io.write(
|
|
1524
|
+
io.write(line);
|
|
1527
1525
|
const submit = setTimeout(() => {
|
|
1528
1526
|
try {
|
|
1529
1527
|
if (!io.alive())
|
|
@@ -1534,10 +1532,12 @@ function writeKickoffSequence(io, submitDelayMs = CONVERGE_KICKOFF_SUBMIT_DELAY_
|
|
|
1534
1532
|
}, submitDelayMs);
|
|
1535
1533
|
submit.unref?.();
|
|
1536
1534
|
}
|
|
1537
|
-
function
|
|
1535
|
+
function scheduleKickoff(ctx, session) {
|
|
1536
|
+
const line = kickoffLineFor(session.kind, pendingKickoffOverrides.get(session.id));
|
|
1537
|
+
pendingKickoffOverrides.delete(session.id);
|
|
1538
1538
|
const timer = setTimeout(() => {
|
|
1539
1539
|
try {
|
|
1540
|
-
writeKickoffSequence({
|
|
1540
|
+
writeKickoffSequence(line, {
|
|
1541
1541
|
write: (data) => ptyRegistry().get(session.id)?.pty.write(data),
|
|
1542
1542
|
alive: () => {
|
|
1543
1543
|
const entry = ptyRegistry().get(session.id);
|
|
@@ -1546,19 +1546,20 @@ function scheduleConvergeKickoff(ctx, session) {
|
|
|
1546
1546
|
onSubmitted: () => {
|
|
1547
1547
|
emit(ctx, session.featureId, {
|
|
1548
1548
|
type: "session.kickoff",
|
|
1549
|
-
message:
|
|
1550
|
-
data: { sessionId: session.id }
|
|
1549
|
+
message: `${session.kind} session kicked off automatically`,
|
|
1550
|
+
data: { sessionId: session.id, kind: session.kind }
|
|
1551
1551
|
});
|
|
1552
1552
|
}
|
|
1553
1553
|
});
|
|
1554
1554
|
} catch {}
|
|
1555
|
-
},
|
|
1555
|
+
}, KICKOFF_DELAY_MS);
|
|
1556
1556
|
timer.unref?.();
|
|
1557
1557
|
}
|
|
1558
1558
|
function markSessionEnded(ctx, id) {
|
|
1559
1559
|
const existing = getSessionRow(ctx, id);
|
|
1560
1560
|
if (!existing)
|
|
1561
1561
|
return null;
|
|
1562
|
+
pendingKickoffOverrides.delete(id);
|
|
1562
1563
|
ctx.db.update(sessions).set({ status: "ended" }).where(eq4(sessions.id, id)).run();
|
|
1563
1564
|
return getSessionRow(ctx, id);
|
|
1564
1565
|
}
|
|
@@ -1570,7 +1571,7 @@ function mostRecentLiveSession(ctx) {
|
|
|
1570
1571
|
const row = ctx.db.select().from(sessions).where(eq4(sessions.status, "live")).orderBy(desc2(sql2`rowid`)).limit(1).get();
|
|
1571
1572
|
return row ? rowToSession(row) : null;
|
|
1572
1573
|
}
|
|
1573
|
-
var
|
|
1574
|
+
var KICKOFF_DELAY_MS = 1500, KICKOFF_SUBMIT_DELAY_MS = 350, CONVERGE_KICKOFF_LINE, KICKOFF_LINES, pendingKickoffOverrides;
|
|
1574
1575
|
var init_sessions = __esm(() => {
|
|
1575
1576
|
init_src();
|
|
1576
1577
|
init_schema();
|
|
@@ -1579,6 +1580,14 @@ var init_sessions = __esm(() => {
|
|
|
1579
1580
|
init_waypoints();
|
|
1580
1581
|
init_repo();
|
|
1581
1582
|
CONVERGE_KICKOFF_LINE = "Proceed with your task: invoke /runcastle:converge and drive spec then tickets " + "from map.md + decisions.md, per your system prompt.";
|
|
1583
|
+
KICKOFF_LINES = {
|
|
1584
|
+
ideation: "Proceed with your task: invoke the /runcastle:ideate skill and drive the ideation session.",
|
|
1585
|
+
qa: "Proceed with your task: invoke the /runcastle:qa skill and answer questions from the " + "docs and code \u2014 do not advance phases or emit tickets.",
|
|
1586
|
+
waypoint: "Proceed with your task: invoke the /runcastle:waypoint skill and work your assigned " + "waypoint to a resolution.",
|
|
1587
|
+
converge: CONVERGE_KICKOFF_LINE,
|
|
1588
|
+
revisit: "Proceed with your task: invoke the /runcastle:revisit skill and work through what the " + "human brings up."
|
|
1589
|
+
};
|
|
1590
|
+
pendingKickoffOverrides = new Map;
|
|
1582
1591
|
});
|
|
1583
1592
|
|
|
1584
1593
|
// src/launcher/reconcile.ts
|
|
@@ -1807,8 +1816,6 @@ function checkGate(ctx, check, feature) {
|
|
|
1807
1816
|
};
|
|
1808
1817
|
}
|
|
1809
1818
|
case "spec-file-exists":
|
|
1810
|
-
if (feature.size === "collapsed")
|
|
1811
|
-
return { satisfied: true };
|
|
1812
1819
|
return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
|
|
1813
1820
|
case "tickets-approved": {
|
|
1814
1821
|
const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
|
|
@@ -2325,6 +2332,44 @@ async function cleanupTempBranches(repoPath) {
|
|
|
2325
2332
|
}
|
|
2326
2333
|
return { deleted, kept };
|
|
2327
2334
|
}
|
|
2335
|
+
async function removeTalkWorktree(repoPath, worktreePath) {
|
|
2336
|
+
const g = git(repoPath);
|
|
2337
|
+
try {
|
|
2338
|
+
await g.raw(["worktree", "remove", "--force", worktreePath]);
|
|
2339
|
+
} catch {
|
|
2340
|
+
try {
|
|
2341
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
2342
|
+
} catch {}
|
|
2343
|
+
}
|
|
2344
|
+
try {
|
|
2345
|
+
await g.raw(["worktree", "prune"]);
|
|
2346
|
+
} catch {}
|
|
2347
|
+
if (existsSync7(worktreePath)) {
|
|
2348
|
+
throw new InvalidInputError(`could not remove talk worktree at ${worktreePath} \u2014 a file may be locked; ` + "close anything using it and retry the delete");
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
async function deleteFeatureBranches(repoPath, slug) {
|
|
2352
|
+
const deleted = [];
|
|
2353
|
+
const kept = [];
|
|
2354
|
+
const g = git(repoPath);
|
|
2355
|
+
const featureBranchName = featureBranch(slug);
|
|
2356
|
+
const seg = tempBranchSlugSegment(slug);
|
|
2357
|
+
const tempPrefixes = TEMP_BRANCH_PREFIXES.map((p) => `${p}${seg}/`);
|
|
2358
|
+
let all;
|
|
2359
|
+
try {
|
|
2360
|
+
all = (await g.branchLocal()).all;
|
|
2361
|
+
} catch {
|
|
2362
|
+
return { deleted, kept };
|
|
2363
|
+
}
|
|
2364
|
+
const targets = all.filter((b) => b === featureBranchName || tempPrefixes.some((p) => b.startsWith(p)));
|
|
2365
|
+
for (const branch of targets) {
|
|
2366
|
+
if (await deleteBranchDetachingWorktrees(g, repoPath, branch))
|
|
2367
|
+
deleted.push(branch);
|
|
2368
|
+
else
|
|
2369
|
+
kept.push(branch);
|
|
2370
|
+
}
|
|
2371
|
+
return { deleted, kept };
|
|
2372
|
+
}
|
|
2328
2373
|
async function commitDocs(worktreePath, message) {
|
|
2329
2374
|
if (!existsSync7(resolve3(worktreePath, DOCS_PATHSPEC)))
|
|
2330
2375
|
return;
|
|
@@ -2440,9 +2485,10 @@ async function mergeFeature(project, feature) {
|
|
|
2440
2485
|
return { ok: true, target };
|
|
2441
2486
|
} catch (e) {
|
|
2442
2487
|
if (await mergeInProgress(g)) {
|
|
2488
|
+
const files = await conflictedFiles(g);
|
|
2443
2489
|
await g.raw(["merge", "--abort"]);
|
|
2444
2490
|
await restoreBranch(g, previous, target);
|
|
2445
|
-
return { ok: false, conflict: true, target };
|
|
2491
|
+
return { ok: false, conflict: true, target, files };
|
|
2446
2492
|
}
|
|
2447
2493
|
throw e instanceof Error ? e : new Error(errMsg(e));
|
|
2448
2494
|
}
|
|
@@ -2454,6 +2500,15 @@ async function restoreBranch(g, previous, target) {
|
|
|
2454
2500
|
await g.checkout(previous);
|
|
2455
2501
|
} catch {}
|
|
2456
2502
|
}
|
|
2503
|
+
async function conflictedFiles(g) {
|
|
2504
|
+
try {
|
|
2505
|
+
const out = (await g.raw(["diff", "--name-only", "--diff-filter=U"])).trim();
|
|
2506
|
+
return out ? out.split(`
|
|
2507
|
+
`).map((s) => s.trim()).filter(Boolean) : [];
|
|
2508
|
+
} catch {
|
|
2509
|
+
return [];
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2457
2512
|
async function mergeInProgress(g) {
|
|
2458
2513
|
try {
|
|
2459
2514
|
await g.raw(["rev-parse", "--verify", "--quiet", "MERGE_HEAD"]);
|
|
@@ -2568,6 +2623,33 @@ var init_knowledge = __esm(() => {
|
|
|
2568
2623
|
];
|
|
2569
2624
|
});
|
|
2570
2625
|
|
|
2626
|
+
// src/pty/end-session.ts
|
|
2627
|
+
function endSession(ctx, sessionId) {
|
|
2628
|
+
const registry = ptyRegistry();
|
|
2629
|
+
const killed = registry.kill(sessionId);
|
|
2630
|
+
registry.remove(sessionId);
|
|
2631
|
+
const session = markSessionEnded(ctx, sessionId);
|
|
2632
|
+
if (session) {
|
|
2633
|
+
releaseForSession(ctx, sessionId);
|
|
2634
|
+
emit(ctx, session.featureId, {
|
|
2635
|
+
type: "session.ended",
|
|
2636
|
+
message: "session ended by user",
|
|
2637
|
+
data: { sessionId, killed }
|
|
2638
|
+
});
|
|
2639
|
+
} else {
|
|
2640
|
+
const existing = getSessionRow(ctx, sessionId);
|
|
2641
|
+
if (!existing)
|
|
2642
|
+
return { sessionId, ok: false, killed };
|
|
2643
|
+
}
|
|
2644
|
+
return { sessionId, ok: true, killed };
|
|
2645
|
+
}
|
|
2646
|
+
var init_end_session = __esm(() => {
|
|
2647
|
+
init_sessions();
|
|
2648
|
+
init_events();
|
|
2649
|
+
init_waypoints();
|
|
2650
|
+
init_registry();
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2571
2653
|
// src/launcher/skills-root.ts
|
|
2572
2654
|
import { existsSync as existsSync9 } from "fs";
|
|
2573
2655
|
import { dirname as dirname2, join as join8, resolve as resolve5 } from "path";
|
|
@@ -3777,6 +3859,7 @@ var init_runner = __esm(() => {
|
|
|
3777
3859
|
|
|
3778
3860
|
// src/services/features.ts
|
|
3779
3861
|
import { desc as desc3, eq as eq7 } from "drizzle-orm";
|
|
3862
|
+
import { rmSync as rmSync2 } from "fs";
|
|
3780
3863
|
async function createFeature(ctx, input) {
|
|
3781
3864
|
const project = requireProjectById(ctx, input.projectId);
|
|
3782
3865
|
const slug = uniqueSlug(ctx, project.id, input.title);
|
|
@@ -3789,8 +3872,7 @@ async function createFeature(ctx, input) {
|
|
|
3789
3872
|
slug,
|
|
3790
3873
|
title: input.title,
|
|
3791
3874
|
oneLiner: input.oneLiner,
|
|
3792
|
-
|
|
3793
|
-
mapped: input.mapped ?? false,
|
|
3875
|
+
mapped: false,
|
|
3794
3876
|
phase: "ideation",
|
|
3795
3877
|
branch,
|
|
3796
3878
|
baseBranch,
|
|
@@ -3873,12 +3955,21 @@ function advance(ctx, featureId) {
|
|
|
3873
3955
|
}
|
|
3874
3956
|
async function burn(ctx, featureId, opts = {}) {
|
|
3875
3957
|
const feature = getFeatureRow(ctx, featureId);
|
|
3876
|
-
const
|
|
3877
|
-
|
|
3878
|
-
|
|
3958
|
+
const running = hasActiveRun(ctx, featureId);
|
|
3959
|
+
const tickets2 = listByFeature2(ctx, featureId);
|
|
3960
|
+
const pending = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
|
|
3961
|
+
const restarting = feature.phase === "implementation" && !running;
|
|
3962
|
+
const iterating = feature.phase === "review" && !running && pending.length >= 1;
|
|
3963
|
+
if (feature.phase !== "tickets" && !restarting && !iterating) {
|
|
3964
|
+
let why;
|
|
3965
|
+
if (running)
|
|
3966
|
+
why = "a run is already burning this feature";
|
|
3967
|
+
else if (feature.phase === "review")
|
|
3968
|
+
why = "no pending tickets to burn \u2014 emit fix tickets before burning from review";
|
|
3969
|
+
else
|
|
3970
|
+
why = `feature must be in the tickets phase to burn (currently ${feature.phase})`;
|
|
3879
3971
|
throw new GateError(why);
|
|
3880
3972
|
}
|
|
3881
|
-
const tickets2 = listByFeature2(ctx, featureId);
|
|
3882
3973
|
if (tickets2.filter((t) => t.status !== "cancelled").length < 1) {
|
|
3883
3974
|
throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
|
|
3884
3975
|
}
|
|
@@ -3892,6 +3983,8 @@ async function burn(ctx, featureId, opts = {}) {
|
|
|
3892
3983
|
message: failed.length > 0 ? `restarting burn \u2014 retrying ${failed.length} failed ticket(s)` : "restarting burn (previous run cancelled or crashed)",
|
|
3893
3984
|
data: { retried: failed.map((t) => t.seq) }
|
|
3894
3985
|
});
|
|
3986
|
+
} else if (iterating) {
|
|
3987
|
+
setPhase(ctx, featureId, REVIEW_LOOP_BACK.to, "burn.started", "burn from review \u2014 iterating");
|
|
3895
3988
|
} else {
|
|
3896
3989
|
setPhase(ctx, featureId, "implementation", "burn.started", "burning tickets");
|
|
3897
3990
|
}
|
|
@@ -3978,6 +4071,76 @@ function escalateToMap(ctx, featureId, input) {
|
|
|
3978
4071
|
});
|
|
3979
4072
|
return { ok: true };
|
|
3980
4073
|
}
|
|
4074
|
+
function archiveFeature(ctx, featureId) {
|
|
4075
|
+
const feature = getFeatureRow(ctx, featureId);
|
|
4076
|
+
if (feature.status === "archived") {
|
|
4077
|
+
throw new GateError(`feature ${feature.slug} is already archived`);
|
|
4078
|
+
}
|
|
4079
|
+
const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
|
|
4080
|
+
if (live)
|
|
4081
|
+
endSession(ctx, live.id);
|
|
4082
|
+
ctx.db.update(features).set({ status: "archived" }).where(eq7(features.id, featureId)).run();
|
|
4083
|
+
emit(ctx, featureId, {
|
|
4084
|
+
type: "feature.archived",
|
|
4085
|
+
message: `feature ${feature.slug} archived`,
|
|
4086
|
+
data: { from: feature.status }
|
|
4087
|
+
});
|
|
4088
|
+
return { ...feature, status: "archived" };
|
|
4089
|
+
}
|
|
4090
|
+
function unarchiveFeature(ctx, featureId) {
|
|
4091
|
+
const feature = getFeatureRow(ctx, featureId);
|
|
4092
|
+
if (feature.status !== "archived") {
|
|
4093
|
+
throw new GateError(`feature ${feature.slug} is not archived`);
|
|
4094
|
+
}
|
|
4095
|
+
const restored = feature.phase === "shipped" ? "shipped" : "active";
|
|
4096
|
+
ctx.db.update(features).set({ status: restored }).where(eq7(features.id, featureId)).run();
|
|
4097
|
+
emit(ctx, featureId, {
|
|
4098
|
+
type: "feature.unarchived",
|
|
4099
|
+
message: `feature ${feature.slug} unarchived (${restored})`,
|
|
4100
|
+
data: { status: restored }
|
|
4101
|
+
});
|
|
4102
|
+
return { ...feature, status: restored };
|
|
4103
|
+
}
|
|
4104
|
+
async function deleteFeature(ctx, featureId) {
|
|
4105
|
+
const feature = getFeatureRow(ctx, featureId);
|
|
4106
|
+
if (feature.status === "shipped") {
|
|
4107
|
+
throw new GateError(`feature ${feature.slug} is shipped \u2014 archive it instead (delete is for non-shipped features)`);
|
|
4108
|
+
}
|
|
4109
|
+
const project = projectForFeature(ctx, feature);
|
|
4110
|
+
for (const run3 of listRunsByFeature(ctx, featureId)) {
|
|
4111
|
+
if (run3.status === "running")
|
|
4112
|
+
cancelRun(run3.id);
|
|
4113
|
+
}
|
|
4114
|
+
const live = listSessionsByFeature(ctx, featureId).find((s) => s.status === "live");
|
|
4115
|
+
if (live)
|
|
4116
|
+
endSession(ctx, live.id);
|
|
4117
|
+
if (activeTestDriveFeatureId() === featureId) {
|
|
4118
|
+
await testDrive(ctx, project, feature, "stop");
|
|
4119
|
+
}
|
|
4120
|
+
await removeTalkWorktree(project.repoPath, worktreeDir(project.id, feature.slug));
|
|
4121
|
+
await deleteFeatureBranches(project.repoPath, feature.slug);
|
|
4122
|
+
emitProject(ctx, project.id, {
|
|
4123
|
+
type: "feature.deleted",
|
|
4124
|
+
message: `feature ${feature.slug} deleted`,
|
|
4125
|
+
data: { featureId, slug: feature.slug }
|
|
4126
|
+
});
|
|
4127
|
+
for (const s of listSessionsByFeature(ctx, featureId)) {
|
|
4128
|
+
try {
|
|
4129
|
+
rmSync2(sessionDir(s.id), { recursive: true, force: true });
|
|
4130
|
+
} catch {}
|
|
4131
|
+
}
|
|
4132
|
+
deleteFeatureRows(ctx, featureId);
|
|
4133
|
+
return { ok: true, slug: feature.slug };
|
|
4134
|
+
}
|
|
4135
|
+
function deleteFeatureRows(ctx, featureId) {
|
|
4136
|
+
ctx.db.delete(tickets).where(eq7(tickets.featureId, featureId)).run();
|
|
4137
|
+
ctx.db.delete(sessions).where(eq7(sessions.featureId, featureId)).run();
|
|
4138
|
+
ctx.db.delete(runs).where(eq7(runs.featureId, featureId)).run();
|
|
4139
|
+
ctx.db.delete(events).where(eq7(events.featureId, featureId)).run();
|
|
4140
|
+
ctx.db.delete(gateOverrides).where(eq7(gateOverrides.featureId, featureId)).run();
|
|
4141
|
+
ctx.db.delete(waypoints).where(eq7(waypoints.featureId, featureId)).run();
|
|
4142
|
+
ctx.db.delete(features).where(eq7(features.id, featureId)).run();
|
|
4143
|
+
}
|
|
3981
4144
|
function gateState(ctx, feature) {
|
|
3982
4145
|
const gate = nextGate(feature);
|
|
3983
4146
|
if (!gate)
|
|
@@ -4001,6 +4164,7 @@ function slugify(title) {
|
|
|
4001
4164
|
}
|
|
4002
4165
|
var init_features = __esm(() => {
|
|
4003
4166
|
init_src();
|
|
4167
|
+
init_paths();
|
|
4004
4168
|
init_schema();
|
|
4005
4169
|
init_errors();
|
|
4006
4170
|
init_events();
|
|
@@ -4008,6 +4172,7 @@ var init_features = __esm(() => {
|
|
|
4008
4172
|
init_git();
|
|
4009
4173
|
init_knowledge();
|
|
4010
4174
|
init_repo();
|
|
4175
|
+
init_end_session();
|
|
4011
4176
|
init_tickets();
|
|
4012
4177
|
init_waypoints();
|
|
4013
4178
|
init_runner();
|
|
@@ -4414,8 +4579,8 @@ function sessionStartContext(ctx, feature) {
|
|
|
4414
4579
|
const ticketSummary = tickets2.length === 0 ? "none yet" : `${tickets2.length} (${Object.entries(byStatus).map(([s, n]) => `${n} ${s}`).join(", ")})`;
|
|
4415
4580
|
return [
|
|
4416
4581
|
`[runcastle] ${feature.title} \u2014 ${feature.oneLiner}`,
|
|
4417
|
-
`phase: ${feature.phase}
|
|
4418
|
-
`docs: docs/features/${feature.slug}/ (brief.md, decisions.md
|
|
4582
|
+
`phase: ${feature.phase}; branch: ${feature.branch}`,
|
|
4583
|
+
`docs: docs/features/${feature.slug}/ (brief.md, decisions.md, spec.md)`,
|
|
4419
4584
|
`tickets: ${ticketSummary}`,
|
|
4420
4585
|
"Call the runcastle MCP tool `get_feature_context` for the full docs + tickets."
|
|
4421
4586
|
].join(`
|
|
@@ -4620,19 +4785,19 @@ function renderSystemPrompt(feature, kind, waypoint) {
|
|
|
4620
4785
|
"## Feature",
|
|
4621
4786
|
`- Slug: \`${feature.slug}\``,
|
|
4622
4787
|
`- Branch: \`${feature.branch}\``,
|
|
4623
|
-
`- Current phase: **${feature.phase}
|
|
4788
|
+
`- Current phase: **${feature.phase}**`,
|
|
4624
4789
|
"",
|
|
4625
4790
|
"## Pipeline",
|
|
4626
4791
|
"Features move ideation \u2192 spec \u2192 tickets \u2192 implementation \u2192 review \u2192 shipped.",
|
|
4627
|
-
"
|
|
4628
|
-
"
|
|
4629
|
-
"the
|
|
4792
|
+
"Each transition is guarded by a gate; you cross a gate by calling the",
|
|
4793
|
+
"`complete_phase` MCP tool, which runs the gate check server-side and advances",
|
|
4794
|
+
"the feature.",
|
|
4630
4795
|
"",
|
|
4631
4796
|
"## Knowledge (versioned in the target repo)",
|
|
4632
4797
|
`Feature docs live at \`${docs}/\`:`,
|
|
4633
4798
|
`- \`${docs}/brief.md\` \u2014 the seed brief (title + one-liner).`,
|
|
4634
4799
|
`- \`${docs}/decisions.md\` \u2014 decisions you capture while grilling (satisfies gate G1).`,
|
|
4635
|
-
`- \`${docs}/spec.md\` \u2014 the spec
|
|
4800
|
+
`- \`${docs}/spec.md\` \u2014 the spec (satisfies gate G2).`,
|
|
4636
4801
|
"Write these files in THIS working directory (the feature's talk worktree);",
|
|
4637
4802
|
"they are committed to the feature branch automatically at phase boundaries.",
|
|
4638
4803
|
"",
|
|
@@ -4674,7 +4839,7 @@ function renderWaypointPrompt(feature, waypoint) {
|
|
|
4674
4839
|
"## Feature",
|
|
4675
4840
|
`- Slug: \`${feature.slug}\``,
|
|
4676
4841
|
`- Branch: \`${feature.branch}\``,
|
|
4677
|
-
`- Current phase: **${feature.phase}
|
|
4842
|
+
`- Current phase: **${feature.phase}**`,
|
|
4678
4843
|
"",
|
|
4679
4844
|
"## Map + knowledge (versioned in the target repo)",
|
|
4680
4845
|
`Feature docs live at \`${docs}/\`:`,
|
|
@@ -4717,7 +4882,7 @@ function renderConvergePrompt(feature) {
|
|
|
4717
4882
|
"## Feature",
|
|
4718
4883
|
`- Slug: \`${feature.slug}\``,
|
|
4719
4884
|
`- Branch: \`${feature.branch}\``,
|
|
4720
|
-
`- Current phase: **${feature.phase}
|
|
4885
|
+
`- Current phase: **${feature.phase}**`,
|
|
4721
4886
|
"",
|
|
4722
4887
|
"## runcastle MCP tools",
|
|
4723
4888
|
"- `get_feature_context()` \u2014 full feature + phase + docs contents (map + decisions).",
|
|
@@ -4737,6 +4902,17 @@ function renderConvergePrompt(feature) {
|
|
|
4737
4902
|
}
|
|
4738
4903
|
function renderRevisitPrompt(feature) {
|
|
4739
4904
|
const docs = featureDocsRel(feature.slug);
|
|
4905
|
+
const reviewIteration = feature.phase === "review" ? [
|
|
4906
|
+
"## Review iteration",
|
|
4907
|
+
"This feature is at **review**: its tickets were burned and the human has been",
|
|
4908
|
+
"test-driving the branch. Treat this as a fix-ticket interview \u2014 read the latest",
|
|
4909
|
+
"run outcome and every ticket\u2019s state via `get_feature_context`, ask what the test",
|
|
4910
|
+
"drive surfaced (bugs, rough edges, tweaks), then emit fix tickets for that work and",
|
|
4911
|
+
"edit/cancel any stale pending tickets. Do NOT advance the phase: once the cards are",
|
|
4912
|
+
"ready, tell the human to review them and click Burn \u2014 burning from review loops the",
|
|
4913
|
+
"feature back through implementation and returns it here when the run finishes.",
|
|
4914
|
+
""
|
|
4915
|
+
] : [];
|
|
4740
4916
|
return [
|
|
4741
4917
|
`# runcastle \u2014 ${feature.title} (revisit session)`,
|
|
4742
4918
|
"",
|
|
@@ -4749,10 +4925,11 @@ function renderRevisitPrompt(feature) {
|
|
|
4749
4925
|
"into the record, then reconcile the tickets with it. The docs are the",
|
|
4750
4926
|
"artifact \u2014 later phases read them, never the transcripts.",
|
|
4751
4927
|
"",
|
|
4928
|
+
...reviewIteration,
|
|
4752
4929
|
"## Feature",
|
|
4753
4930
|
`- Slug: \`${feature.slug}\``,
|
|
4754
4931
|
`- Branch: \`${feature.branch}\``,
|
|
4755
|
-
`- Current phase: **${feature.phase}
|
|
4932
|
+
`- Current phase: **${feature.phase}**`,
|
|
4756
4933
|
"",
|
|
4757
4934
|
"## Knowledge (versioned in the target repo)",
|
|
4758
4935
|
`Feature docs live at \`${docs}/\`:`,
|
|
@@ -4843,33 +5020,6 @@ var init_artifacts = __esm(() => {
|
|
|
4843
5020
|
];
|
|
4844
5021
|
});
|
|
4845
5022
|
|
|
4846
|
-
// src/pty/end-session.ts
|
|
4847
|
-
function endSession(ctx, sessionId) {
|
|
4848
|
-
const registry = ptyRegistry();
|
|
4849
|
-
const killed = registry.kill(sessionId);
|
|
4850
|
-
registry.remove(sessionId);
|
|
4851
|
-
const session = markSessionEnded(ctx, sessionId);
|
|
4852
|
-
if (session) {
|
|
4853
|
-
releaseForSession(ctx, sessionId);
|
|
4854
|
-
emit(ctx, session.featureId, {
|
|
4855
|
-
type: "session.ended",
|
|
4856
|
-
message: "session ended by user",
|
|
4857
|
-
data: { sessionId, killed }
|
|
4858
|
-
});
|
|
4859
|
-
} else {
|
|
4860
|
-
const existing = getSessionRow(ctx, sessionId);
|
|
4861
|
-
if (!existing)
|
|
4862
|
-
return { sessionId, ok: false, killed };
|
|
4863
|
-
}
|
|
4864
|
-
return { sessionId, ok: true, killed };
|
|
4865
|
-
}
|
|
4866
|
-
var init_end_session = __esm(() => {
|
|
4867
|
-
init_sessions();
|
|
4868
|
-
init_events();
|
|
4869
|
-
init_waypoints();
|
|
4870
|
-
init_registry();
|
|
4871
|
-
});
|
|
4872
|
-
|
|
4873
5023
|
// src/launcher/launcher.ts
|
|
4874
5024
|
import { existsSync as existsSync13 } from "fs";
|
|
4875
5025
|
import { dirname as dirname5, join as join13, resolve as resolve7 } from "path";
|
|
@@ -4973,6 +5123,8 @@ async function launchSession(ctx, input, opts = {}) {
|
|
|
4973
5123
|
kind: input.kind,
|
|
4974
5124
|
worktreePath
|
|
4975
5125
|
});
|
|
5126
|
+
if (input.kickoffLine)
|
|
5127
|
+
setKickoffOverride(session.id, input.kickoffLine);
|
|
4976
5128
|
let waypoint;
|
|
4977
5129
|
let resumeSessionId;
|
|
4978
5130
|
let resumeUnavailableFrom;
|
|
@@ -5232,18 +5384,23 @@ var init_feature = __esm(() => {
|
|
|
5232
5384
|
projectId: z6.string(),
|
|
5233
5385
|
title: z6.string().min(1),
|
|
5234
5386
|
oneLiner: z6.string(),
|
|
5235
|
-
size: FeatureSize,
|
|
5236
|
-
mapped: z6.boolean().optional(),
|
|
5237
5387
|
baseBranch: z6.string().optional()
|
|
5238
5388
|
})).mutation(({ ctx, input }) => createFeature(ctx, input)),
|
|
5239
5389
|
list: publicProcedure.input(z6.object({ projectId: z6.string() })).query(({ ctx, input }) => list(ctx, input.projectId)),
|
|
5240
5390
|
get: publicProcedure.input(z6.object({ id: z6.string() })).query(({ ctx, input }) => getFeatureFull(ctx, input.id)),
|
|
5241
|
-
launchSession: publicProcedure.input(z6.object({
|
|
5391
|
+
launchSession: publicProcedure.input(z6.object({
|
|
5392
|
+
featureId: z6.string(),
|
|
5393
|
+
kind: SessionKind,
|
|
5394
|
+
kickoffLine: z6.string().min(1).optional()
|
|
5395
|
+
})).mutation(({ ctx, input }) => launchSession(ctx, input)),
|
|
5242
5396
|
workWaypoint: publicProcedure.input(z6.object({ featureId: z6.string(), waypointId: z6.string() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
|
|
5243
5397
|
converge: publicProcedure.input(z6.object({ featureId: z6.string(), overrideReason: z6.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
|
|
5244
5398
|
endSession: publicProcedure.input(z6.object({ sessionId: z6.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
|
|
5245
5399
|
advance: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
|
|
5246
5400
|
overrideGate: publicProcedure.input(z6.object({ featureId: z6.string(), gate: gateId, reason: z6.string().min(1) })).mutation(({ ctx, input }) => overrideGate(ctx, input.featureId, input.gate, input.reason)),
|
|
5401
|
+
archive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => archiveFeature(ctx, input.featureId)),
|
|
5402
|
+
unarchive: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => unarchiveFeature(ctx, input.featureId)),
|
|
5403
|
+
delete: publicProcedure.input(z6.object({ featureId: z6.string() })).mutation(({ ctx, input }) => deleteFeature(ctx, input.featureId)),
|
|
5247
5404
|
burn: publicProcedure.input(z6.object({ featureId: z6.string(), model: z6.string().min(1).optional() })).mutation(({ ctx, input }) => burn(ctx, input.featureId, { modelOverride: input.model })),
|
|
5248
5405
|
testDrive: publicProcedure.input(z6.object({ featureId: z6.string(), action: z6.enum(["start", "stop"]) })).mutation(async ({ ctx, input }) => {
|
|
5249
5406
|
const feature = getFeatureRow(ctx, input.featureId);
|
|
@@ -5265,10 +5422,10 @@ var init_feature = __esm(() => {
|
|
|
5265
5422
|
emit(ctx, input.featureId, {
|
|
5266
5423
|
type: "merge.conflict",
|
|
5267
5424
|
message: "merge conflict \u2014 resolve and retry",
|
|
5268
|
-
data: { conflict: res.conflict }
|
|
5425
|
+
data: { conflict: res.conflict, base: res.target, files: res.files ?? [] }
|
|
5269
5426
|
});
|
|
5270
5427
|
}
|
|
5271
|
-
return { ok: res.ok, conflict: res.conflict };
|
|
5428
|
+
return { ok: res.ok, conflict: res.conflict, base: res.target, files: res.files ?? [] };
|
|
5272
5429
|
})
|
|
5273
5430
|
});
|
|
5274
5431
|
});
|
package/package.json
CHANGED
package/skills/packs/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Four phase-scoped skills, each namespaced `/runcastle:<skill>`:
|
|
|
9
9
|
| Skill | Invoked | Does |
|
|
10
10
|
|---|---|---|
|
|
11
11
|
| `/runcastle:ideate` | entry for `kind=ideation` | grills the human, locks decisions incrementally, drives spec + tickets out of one unbroken window |
|
|
12
|
-
| `/runcastle:spec` | by ideate
|
|
12
|
+
| `/runcastle:spec` | by ideate | synthesizes `spec.md`, completes the `spec` phase |
|
|
13
13
|
| `/runcastle:tickets` | by ideate | emits tracer-bullet tickets via MCP, completes the `tickets` phase |
|
|
14
14
|
| `/runcastle:qa` | entry for `kind=qa` | read-only Q&A over an existing feature; never advances phases |
|
|
15
15
|
|
|
@@ -7,7 +7,7 @@ disable-model-invocation: false
|
|
|
7
7
|
|
|
8
8
|
# Converge — close a mapped feature
|
|
9
9
|
|
|
10
|
-
A mapped feature was too big for one window, so it was charted into a map and its waypoints were worked in their own sessions. Every waypoint is now terminal, the human clicked **Converge**, and the feature has already crossed G1 into the `spec` phase
|
|
10
|
+
A mapped feature was too big for one window, so it was charted into a map and its waypoints were worked in their own sessions. Every waypoint is now terminal, the human clicked **Converge**, and the feature has already crossed G1 into the `spec` phase. Your job: turn the compressed knowledge into a spec and tickets — the exact output an unbroken ideation session produces.
|
|
11
11
|
|
|
12
12
|
## Context hygiene is the whole game
|
|
13
13
|
|
|
@@ -15,19 +15,16 @@ Everything happens in this one window. **Never compact. Never `/clear`. Never su
|
|
|
15
15
|
|
|
16
16
|
## 0. Read ONLY the compressed knowledge
|
|
17
17
|
|
|
18
|
-
1. Call `mcp__runcastle__get_feature_context`. It returns the feature (`slug`, `title`, `oneLiner
|
|
18
|
+
1. Call `mcp__runcastle__get_feature_context`. It returns the feature (`slug`, `title`, `oneLiner`), current `phase`, and the docs on disk.
|
|
19
19
|
2. Read **only** these two files under `docs/features/<slug>/`:
|
|
20
20
|
- `map.md` — the destination, notes, and out-of-scope decisions.
|
|
21
21
|
- `decisions.md` — every decision the waypoint sessions locked.
|
|
22
22
|
**Do NOT read the waypoint session transcripts.** The map and decisions are the compression; trust them. If a decision is genuinely missing you may check the codebase for a *fact*, but never re-grill the human and never reopen a resolved waypoint — converge, do not re-ideate.
|
|
23
23
|
3. `mcp__runcastle__record_event({ type: "converge.started", message: "<feature title>" })`.
|
|
24
24
|
|
|
25
|
-
## 1.
|
|
25
|
+
## 1. Run the pipeline in this window
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
- **`full`** → invoke `/runcastle:spec` (it writes `spec.md` and completes the `spec` phase); when it returns, invoke `/runcastle:tickets`.
|
|
30
|
-
- **`collapsed`** → skip spec entirely; invoke `/runcastle:tickets` directly (it works from `decisions.md` + `map.md`).
|
|
27
|
+
Invoke `/runcastle:spec` (it writes `spec.md` and completes the `spec` phase); when it returns, invoke `/runcastle:tickets`.
|
|
31
28
|
|
|
32
29
|
**Re-convergence** — you may be a fresh session continuing a converge that crashed
|
|
33
30
|
or was closed mid-way (the feature is past G1 but has no tickets). Pick up from
|
|
@@ -37,8 +34,7 @@ whatever already exists on disk:
|
|
|
37
34
|
rewrite it, and proceed straight to `/runcastle:tickets`. (If the current
|
|
38
35
|
`phase` is still `spec`, call `complete_phase` for spec first — the spec work
|
|
39
36
|
is already done.)
|
|
40
|
-
- If it does not exist
|
|
41
|
-
above as normal.
|
|
37
|
+
- If it does not exist, run `/runcastle:spec` then `/runcastle:tickets` as normal.
|
|
42
38
|
|
|
43
39
|
Do not open a new session for these. They run here, on top of the compressed knowledge you just read.
|
|
44
40
|
|