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