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/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, FeatureSize, TicketStatus, SessionKind, RunStatus, FeatureStatus, SessionStatus, TicketInput, Ticket, WaypointType, WaypointStatus, WaypointDisposition, WaypointInput, Waypoint, Project, Feature, SessionRow, Run, SettingSource, SettingScope, SettingField, SettingsView, SettingsUpdateInput, EventRow;
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
- const step = ORDER[i + 1];
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 (auto-satisfied for collapsed features)",
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
- if (existing.kind === "converge")
1519
- scheduleConvergeKickoff(ctx, existing);
1511
+ scheduleKickoff(ctx, existing);
1520
1512
  }
1521
1513
  return getSessionRow(ctx, id);
1522
1514
  }
1523
- function writeKickoffSequence(io, submitDelayMs = CONVERGE_KICKOFF_SUBMIT_DELAY_MS) {
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(CONVERGE_KICKOFF_LINE);
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 scheduleConvergeKickoff(ctx, session) {
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: "converge session kicked off automatically",
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
- }, CONVERGE_KICKOFF_DELAY_MS);
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 CONVERGE_KICKOFF_DELAY_MS = 1500, CONVERGE_KICKOFF_SUBMIT_DELAY_MS = 350, CONVERGE_KICKOFF_LINE;
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
- size: input.size,
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 restarting = feature.phase === "implementation" && !hasActiveRun(ctx, featureId);
3877
- if (feature.phase !== "tickets" && !restarting) {
3878
- const why = feature.phase === "implementation" ? "a run is already burning this feature" : `feature must be in the tickets phase to burn (currently ${feature.phase})`;
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} (size: ${feature.size}); branch: ${feature.branch}`,
4418
- `docs: docs/features/${feature.slug}/ (brief.md, decisions.md${feature.size === "full" ? ", spec.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}** (size: ${feature.size})`,
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
- "A `collapsed` feature skips the `spec` phase. Each transition is guarded by a",
4628
- "gate; you cross a gate by calling the `complete_phase` MCP tool, which runs",
4629
- "the gate check server-side and advances the feature.",
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, for \`full\` features (satisfies gate G2).`,
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}** (size: ${feature.size})`,
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}** (size: ${feature.size})`,
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}** (size: ${feature.size})`,
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({ featureId: z6.string(), kind: SessionKind })).mutation(({ ctx, input }) => launchSession(ctx, input)),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 (size=full) | synthesizes `spec.md`, completes the `spec` phase |
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 (or `tickets` for a `collapsed` feature). Your job: turn the compressed knowledge into a spec and tickets — the exact output an unbroken ideation session produces.
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`, `size`), current `phase`, and the docs on disk.
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. Size branch — run the pipeline in this window
25
+ ## 1. Run the pipeline in this window
26
26
 
27
- Read `feature.size` from step 0:
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 (or the feature is `collapsed`), run the size branch
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