runcastle 1.0.26 → 1.0.27

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.
@@ -99,6 +99,13 @@
99
99
  "when": 1785243983378,
100
100
  "tag": "0013_sudden_naoko",
101
101
  "breakpoints": true
102
+ },
103
+ {
104
+ "idx": 14,
105
+ "version": "6",
106
+ "when": 1785260479530,
107
+ "tag": "0014_ordinary_mystique",
108
+ "breakpoints": true
102
109
  }
103
110
  ]
104
111
  }
package/index.js CHANGED
@@ -271,6 +271,7 @@ var init_schemas = __esm(() => {
271
271
  featureId: z2.string(),
272
272
  seq: z2.number(),
273
273
  status: TicketStatus,
274
+ lap: z2.number(),
274
275
  commits: z2.array(z2.string()),
275
276
  error: z2.string().optional(),
276
277
  attemptBranch: z2.string().optional(),
@@ -348,6 +349,7 @@ var init_schemas = __esm(() => {
348
349
  title: z2.string(),
349
350
  oneLiner: z2.string(),
350
351
  mapped: z2.boolean(),
352
+ lap: z2.number(),
351
353
  phase: Phase,
352
354
  branch: z2.string(),
353
355
  baseBranch: z2.string().optional(),
@@ -484,7 +486,7 @@ function nextGate(feature) {
484
486
  return MAPPED_G1;
485
487
  return gate;
486
488
  }
487
- var PIPELINE, ORDER, REVIEW_LOOP_BACK, MAPPED_G1;
489
+ var PIPELINE, ORDER, REVIEW_LOOP_BACK, RETHINK_LOOP_BACK, MAPPED_G1;
488
490
  var init_pipeline = __esm(() => {
489
491
  PIPELINE = [
490
492
  { phase: "ideation" },
@@ -531,6 +533,7 @@ var init_pipeline = __esm(() => {
531
533
  ];
532
534
  ORDER = PIPELINE.map((p) => p.phase);
533
535
  REVIEW_LOOP_BACK = { from: "review", to: "implementation" };
536
+ RETHINK_LOOP_BACK = { from: "review", to: "ideation" };
534
537
  MAPPED_G1 = {
535
538
  id: "G1",
536
539
  description: "Every waypoint resolved or dropped before converging",
@@ -583,6 +586,7 @@ var init_db_schema = __esm(() => {
583
586
  title: text("title").notNull(),
584
587
  oneLiner: text("one_liner").notNull(),
585
588
  mapped: integer("mapped", { mode: "boolean" }).notNull().default(false),
589
+ lap: integer("lap").notNull().default(1),
586
590
  phase: text("phase").notNull().$type(),
587
591
  branch: text("branch").notNull(),
588
592
  baseBranch: text("base_branch"),
@@ -593,6 +597,7 @@ var init_db_schema = __esm(() => {
593
597
  id: text("id").primaryKey(),
594
598
  featureId: text("feature_id"),
595
599
  projectId: text("project_id"),
600
+ lap: integer("lap").notNull().default(1),
596
601
  kind: text("kind").notNull().$type(),
597
602
  ccSessionId: text("cc_session_id"),
598
603
  transcriptPath: text("transcript_path"),
@@ -609,6 +614,7 @@ var init_db_schema = __esm(() => {
609
614
  acceptanceCriteria: text("acceptance_criteria", { mode: "json" }).notNull().$type(),
610
615
  seams: text("seams", { mode: "json" }).notNull().$type(),
611
616
  blockedBy: text("blocked_by", { mode: "json" }).notNull().$type(),
617
+ lap: integer("lap").notNull().default(1),
612
618
  status: text("status").notNull().$type(),
613
619
  commits: text("commits", { mode: "json" }).notNull().$type(),
614
620
  error: text("error"),
@@ -644,6 +650,7 @@ var init_db_schema = __esm(() => {
644
650
  featureId: text("feature_id"),
645
651
  runId: text("run_id"),
646
652
  ticketId: text("ticket_id"),
653
+ lap: integer("lap").notNull().default(1),
647
654
  ts: integer("ts").notNull(),
648
655
  type: text("type").notNull(),
649
656
  message: text("message").notNull(),
@@ -1382,6 +1389,10 @@ function projectIdForFeature(ctx, featureId) {
1382
1389
  throw new NotFoundError(`feature ${featureId} not found`);
1383
1390
  return row.projectId;
1384
1391
  }
1392
+ function lapForFeature(ctx, featureId) {
1393
+ const row = ctx.db.select({ lap: features.lap }).from(features).where(eq(features.id, featureId)).get();
1394
+ return row?.lap ?? 1;
1395
+ }
1385
1396
  function emit(ctx, featureId, e) {
1386
1397
  return insertEvent(ctx, projectIdForFeature(ctx, featureId), featureId, e);
1387
1398
  }
@@ -1399,6 +1410,7 @@ function insertEvent(ctx, projectId, featureId, e) {
1399
1410
  const row = ctx.db.insert(events).values({
1400
1411
  projectId,
1401
1412
  featureId,
1413
+ lap: featureId ? lapForFeature(ctx, featureId) : 1,
1402
1414
  runId: e.runId ?? null,
1403
1415
  ticketId: e.ticketId ?? null,
1404
1416
  ts: Date.now(),
@@ -1439,6 +1451,7 @@ function rowToFeature(row) {
1439
1451
  title: row.title,
1440
1452
  oneLiner: row.oneLiner,
1441
1453
  mapped: row.mapped,
1454
+ lap: row.lap,
1442
1455
  phase: row.phase,
1443
1456
  branch: row.branch,
1444
1457
  baseBranch: row.baseBranch ?? undefined,
@@ -1740,6 +1753,7 @@ function createSessionRow(ctx, input) {
1740
1753
  id: newId("sess"),
1741
1754
  featureId: input.featureId ?? null,
1742
1755
  projectId: input.projectId ?? null,
1756
+ lap: input.featureId ? getFeatureRow(ctx, input.featureId).lap : 1,
1743
1757
  kind: input.kind,
1744
1758
  ccSessionId: null,
1745
1759
  transcriptPath: null,
@@ -1771,6 +1785,9 @@ function markSessionLive(ctx, id, input = {}) {
1771
1785
  }
1772
1786
  return getSessionRow(ctx, id);
1773
1787
  }
1788
+ function lapKickoff(lap) {
1789
+ return `Proceed with your task: invoke the /runcastle:revisit skill for LAP ${lap} REVIEW ITERATION. ` + `Call get_feature_context, then read this feature's test-notes.md (the "## Lap ${lap - 1}" ` + 'section \u2014 what the last drive surfaced) and the "## Later laps" section of its spec.md. ' + "EITHER MAY NOT EXIST YET; that is normal, not an error \u2014 say so and carry on from what I " + "tell you. Interview me about what the test drive taught: what was wrong, what was missing, " + "what I want next. Write what we settle on into decisions.md and amend spec.md for this lap " + '(pruning anything you promote out of "## Later laps"), then call emit_tickets for this ' + `lap's work. Finish in THIS session: complete_phase through ideation \u2192 spec \u2192 tickets, then ` + "tell me to review the cards and click Burn.";
1790
+ }
1774
1791
  function kickoffLineFor(kind, override) {
1775
1792
  return override ?? KICKOFF_LINES[kind];
1776
1793
  }
@@ -4904,6 +4921,7 @@ function rowToTicket(row) {
4904
4921
  acceptanceCriteria: row.acceptanceCriteria,
4905
4922
  seams: row.seams,
4906
4923
  blockedBy: row.blockedBy,
4924
+ lap: row.lap,
4907
4925
  status: row.status,
4908
4926
  commits: row.commits,
4909
4927
  error: row.error ?? undefined,
@@ -4926,6 +4944,7 @@ function listByFeature2(ctx, featureId) {
4926
4944
  function storeTickets(ctx, featureId, inputs) {
4927
4945
  if (inputs.length === 0)
4928
4946
  return [];
4947
+ const { lap } = getFeatureRow(ctx, featureId);
4929
4948
  const existing = ctx.db.select({ seq: tickets.seq }).from(tickets).where(eq7(tickets.featureId, featureId)).all();
4930
4949
  const startSeq = existing.reduce((max, r) => Math.max(max, r.seq), 0) + 1;
4931
4950
  const resolved = resolveBlocking(inputs, startSeq);
@@ -4939,6 +4958,7 @@ function storeTickets(ctx, featureId, inputs) {
4939
4958
  acceptanceCriteria: t.acceptanceCriteria,
4940
4959
  seams: t.seams,
4941
4960
  blockedBy: resolved[i].blockedBy,
4961
+ lap,
4942
4962
  status: "pending",
4943
4963
  commits: [],
4944
4964
  error: null,
@@ -5046,6 +5066,7 @@ var init_tickets = __esm(() => {
5046
5066
  init_schema();
5047
5067
  init_errors();
5048
5068
  init_events();
5069
+ init_repo();
5049
5070
  MUTABLE_STATUSES = new Set(["pending", "failed"]);
5050
5071
  });
5051
5072
 
@@ -5069,7 +5090,7 @@ function checkGate(ctx, check, feature) {
5069
5090
  case "spec-file-exists":
5070
5091
  return fileGate(ctx, feature, "spec.md", "write the spec (spec.md) before breaking into tickets");
5071
5092
  case "tickets-approved": {
5072
- const count = listByFeature2(ctx, feature.id).filter((t) => t.status !== "cancelled").length;
5093
+ const count = listByFeature2(ctx, feature.id).filter((t) => t.lap === feature.lap && t.status !== "cancelled").length;
5073
5094
  return count >= 1 ? { satisfied: true } : { satisfied: false, reason: "no tickets to burn" };
5074
5095
  }
5075
5096
  case "all-tickets-terminal": {
@@ -5604,6 +5625,7 @@ async function createFeature(ctx, input) {
5604
5625
  title: input.title,
5605
5626
  oneLiner: input.oneLiner,
5606
5627
  mapped: false,
5628
+ lap: 1,
5607
5629
  phase: "ideation",
5608
5630
  branch,
5609
5631
  baseBranch,
@@ -5688,7 +5710,8 @@ async function burn(ctx, featureId, opts = {}) {
5688
5710
  const feature = getFeatureRow(ctx, featureId);
5689
5711
  const running = hasActiveRun(ctx, featureId);
5690
5712
  let tickets2 = listByFeature2(ctx, featureId);
5691
- const pending = tickets2.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
5713
+ const lapTickets = tickets2.filter((t) => t.lap === feature.lap);
5714
+ const pending = lapTickets.filter((t) => t.status !== "done" && t.status !== "failed" && t.status !== "cancelled");
5692
5715
  const restarting = feature.phase === "implementation" && !running;
5693
5716
  const iterating = feature.phase === "review" && !running && pending.length >= 1;
5694
5717
  if (feature.phase !== "tickets" && !restarting && !iterating) {
@@ -5701,8 +5724,8 @@ async function burn(ctx, featureId, opts = {}) {
5701
5724
  why = `feature must be in the tickets phase to burn (currently ${feature.phase})`;
5702
5725
  throw new GateError(why);
5703
5726
  }
5704
- if (tickets2.filter((t) => t.status !== "cancelled").length < 1) {
5705
- throw new GateError(tickets2.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
5727
+ if (lapTickets.filter((t) => t.status !== "cancelled").length < 1) {
5728
+ throw new GateError(lapTickets.length > 0 ? "no burnable tickets \u2014 every ticket is cancelled" : "no tickets to burn");
5706
5729
  }
5707
5730
  if (restarting) {
5708
5731
  sweepOrphanedBurning(ctx, featureId, "orphaned \u2014 the previous run died while it was burning");
@@ -5726,6 +5749,22 @@ async function burn(ctx, featureId, opts = {}) {
5726
5749
  });
5727
5750
  return { runId };
5728
5751
  }
5752
+ function rethink(ctx, featureId) {
5753
+ const feature = getFeatureRow(ctx, featureId);
5754
+ if (feature.phase !== RETHINK_LOOP_BACK.from) {
5755
+ throw new GateError(`feature must be in the review phase to rethink (currently ${feature.phase})`);
5756
+ }
5757
+ if (hasActiveRun(ctx, featureId)) {
5758
+ throw new GateError("a run is burning this feature \u2014 cancel or wait for it before rethinking");
5759
+ }
5760
+ const live = activeSessionsForFeature(ctx, featureId);
5761
+ if (live.length > 0) {
5762
+ throw new GateError(`a ${live[0].kind} session is already live for ${feature.slug} \u2014 only one terminal per feature; end or resume it first`);
5763
+ }
5764
+ const lap = feature.lap + 1;
5765
+ ctx.db.update(features).set({ lap }).where(eq9(features.id, featureId)).run();
5766
+ return setPhase(ctx, featureId, RETHINK_LOOP_BACK.to, "lap.started", `rethink \u2014 lap ${lap}`);
5767
+ }
5729
5768
  async function retryTicket(ctx, ticketId, opts = {}) {
5730
5769
  const ticket = getTicket(ctx, ticketId);
5731
5770
  const feature = getFeatureRow(ctx, ticket.featureId);
@@ -5907,6 +5946,7 @@ var init_features = __esm(() => {
5907
5946
  init_git();
5908
5947
  init_knowledge();
5909
5948
  init_repo();
5949
+ init_sessions();
5910
5950
  init_end_session();
5911
5951
  init_tickets();
5912
5952
  init_waypoints();
@@ -5952,6 +5992,7 @@ function toolGetFeatureContext(ctx, session) {
5952
5992
  const context = {
5953
5993
  feature,
5954
5994
  phase: feature.phase,
5995
+ lap: feature.lap,
5955
5996
  docs,
5956
5997
  tickets: listByFeature2(ctx, feature.id)
5957
5998
  };
@@ -7460,6 +7501,7 @@ var gateId, featureRouter;
7460
7501
  var init_feature = __esm(() => {
7461
7502
  init_src();
7462
7503
  init_launcher();
7504
+ init_sessions();
7463
7505
  init_events();
7464
7506
  init_features();
7465
7507
  init_gates();
@@ -7483,6 +7525,14 @@ var init_feature = __esm(() => {
7483
7525
  })).mutation(({ ctx, input }) => launchSession(ctx, input)),
7484
7526
  workWaypoint: publicProcedure.input(z7.object({ featureId: z7.string(), waypointId: z7.string(), endLive: z7.boolean().optional() })).mutation(({ ctx, input }) => workWaypoint(ctx, input)),
7485
7527
  converge: publicProcedure.input(z7.object({ featureId: z7.string(), overrideReason: z7.string().min(1).optional() })).mutation(({ ctx, input }) => converge(ctx, { featureId: input.featureId, overrideReason: input.overrideReason })),
7528
+ rethink: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => {
7529
+ const feature = rethink(ctx, input.featureId);
7530
+ return launchSession(ctx, {
7531
+ featureId: input.featureId,
7532
+ kind: "revisit",
7533
+ kickoffLine: lapKickoff(feature.lap)
7534
+ });
7535
+ }),
7486
7536
  resendKickoff: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => resendKickoff(ctx, input.sessionId)),
7487
7537
  endSession: publicProcedure.input(z7.object({ sessionId: z7.string() })).mutation(({ ctx, input }) => endSession(ctx, input.sessionId)),
7488
7538
  advance: publicProcedure.input(z7.object({ featureId: z7.string() })).mutation(({ ctx, input }) => advance(ctx, input.featureId)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcastle",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "description": "Burn tickets into shipped features with Claude Code.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,6 +28,8 @@ Interview the human about every aspect of the feature until you reach a genuinel
28
28
  - **Look up facts; ask only for decisions.** If a fact is discoverable in the codebase — how something currently works, what a type looks like, whether a pattern already exists — read/grep the repo and find it. Never make the human be your grep. *Decisions* are theirs: put each one to them and wait.
29
29
  - **Model the domain as you go** (forked from domain-modeling): challenge terms that conflict with existing usage, sharpen fuzzy words to one canonical meaning ("you said 'account' — Customer or User?"), invent concrete edge-case scenarios that force precision, and cross-check claims against the actual code ("your code cancels whole Orders, but you just said partial — which is right?").
30
30
  - **Probe the size early.** Within your first few questions, ask enough about scope to judge whether this is one-session-sized or map-sized — how many independent areas it touches, what has to be researched or prototyped before decisions can lock, how wide the design tree looks. Make that call up front, not thirty questions deep into one corner. If it smells map-sized, go to §3 *now* rather than rabbit-holing first.
31
+ - **Ask the slicing question: how sure are you this is what you want?** Put it to them early, plainly, with your recommendation attached. **Sure, and small** → spec the whole thing; it is one lap and will likely merge on lap 1 — the old linear flow, verbatim. **Unsure, or large** → recommend a thin **lap 1**: a walking skeleton of the uncertain part, or one sub-feature slice they can actually test-drive, with the consciously deferred scope parked in the spec's `## Later laps` section (from there it seeds the next lap's session alongside the test notes). **Say the recommendation out loud and let them answer** — this is a judgment you offer, not a rule you enforce, and the human decides. There is no mode and no flag: how many laps a feature takes is a description of how it went.
32
+ - **Size and certainty are orthogonal.** Mapping (§3) is for ideation too big to *think* through in one session; laps are for a feature too uncertain to *spec whole*. A feature can need both, one, or neither — a small but fuzzy feature gets a thin lap 1 and no map; a big but settled one gets a map and still merges on lap 1.
31
33
 
32
34
  Do not move on until the human confirms you have reached shared understanding.
33
35
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: revisit
3
- description: Fold late-arriving information into a feature whose sessions are finished — amend the docs, then reconcile tickets (update/cancel/emit). Never advances phases. Entry skill for kind=revisit sessions.
3
+ description: Fold late-arriving information into a feature whose sessions are finished — amend the docs, then reconcile tickets (update/cancel/emit); an ordinary revisit never advances phases. Also the lap session: on a Rethink from review, digest the test drive, amend decisions + spec, emit the lap's tickets and complete ideation → spec → tickets in the one session. Entry skill for kind=revisit sessions.
4
4
  disable-model-invocation: false
5
5
  ---
6
6
 
@@ -8,6 +8,8 @@ disable-model-invocation: false
8
8
 
9
9
  The human came back: they remembered a constraint, changed their mind, or learned something that the grilling/spec didn't capture. This session's job is to make the **record** and the **ticket queue** match the new reality — nothing more. The pipeline does not move; whatever phase the feature is in, it stays in.
10
10
 
11
+ That is the ordinary revisit, and it is what the moves below describe. The one exception is a **lap**: a Rethink from review opens this same session with a lap kickoff and a bigger job — see **Lap mode** below, and work that section instead.
12
+
11
13
  ## Order of operations
12
14
 
13
15
  1. **Listen first.** The human opens with what changed. If this terminal resumed the previous conversation, use that context; do not re-grill what is already settled. Ask only the questions the NEW information raises.
@@ -23,18 +25,26 @@ The human came back: they remembered a constraint, changed their mind, or learne
23
25
  - `done` work now wrong → emit a NEW ticket that corrects it. Never touch done/burning tickets.
24
26
  5. **Close.** `mcp__runcastle__record_event({ type: "feature.revisited", message: "<one-line gist of what changed>" })`, then tell the human what state you left things in — especially whether a re-Burn is needed (pending tickets exist and the feature is at `implementation`).
25
27
 
26
- ## Review iteration ("Iterate")
28
+ ## Lap mode (a Rethink)
29
+
30
+ Your kickoff line reads `LAP <n> REVIEW ITERATION`. The human burned the last lap, test-drove the branch, and clicked **Rethink**: the code was right, the *spec* wasn't. `feature.rethink` has already incremented the lap and looped the feature back, so it sits at **ideation**, not review. One trip round the pipeline is a lap, and this session is the whole front half of lap `<n>` — you carry it from what-the-drive-taught to ticket cards waiting on the human's **Burn** click, in this one conversation:
31
+
32
+ 1. **Digest what the drive taught.** Your inputs are the previous lap's section of `docs/features/<slug>/test-notes.md` and the spec's `## Later laps` section, both injected into your context. **Both may be absent** — that is not an error and not something to go hunting for: interview the human from scratch instead. What did they hit, what surprised them, what do they want instead now they've used it?
33
+ 2. **Context.** `mcp__runcastle__get_feature_context` — the feature (including its `lap`), the docs, and the full ticket history across every lap. Skim what the last lap actually landed and what failed before you interview.
34
+ 3. **Never re-emit a promoted note.** Notes the human already promoted from the review checklist are tickets in *this* lap and arrive as ids in your context. However well such a note reads as a ticket, if its id is in front of you the work is already carded — emitting it again gives the burner the same job twice.
35
+ 4. **Grill, briefly.** A small rethink is a short grilling: a few questions, one at a time, each with your recommended answer attached. Prune and promote `## Later laps` entries with the human as part of that conversation — what the drive taught is usually what decides which deferred scope this lap picks up and which stays parked.
36
+ 5. **Amend the docs.** Append this lap's learning to `docs/features/<slug>/decisions.md` under a `## Lap <n>` heading (the convention) — never rewrite old decisions, supersede them. Then amend `spec.md` in place: the sections this lap changes, plus the pruned `## Later laps`.
37
+ 6. **Emit this lap's tickets.** `mcp__runcastle__emit_tickets({ tickets })` — only the work this lap will burn; the rest stays in `## Later laps`. Reconcile stale pending tickets with `update_ticket` / `cancel_ticket`; `done` work that is now wrong gets a NEW ticket, as always.
38
+ 7. **Advance the pipeline.** `mcp__runcastle__complete_phase` through `ideation` → `spec` → `tickets`, here, without opening another session. It will refuse to cross into implementation — that gate is the human's Burn click, which is the point.
39
+ 8. **Hand to Burn.** Tell the human in a line or two what this lap does, then:
27
40
 
28
- At the **review** phase this same session is surfaced as **Iterate**: the tickets were burned, the human test-drove the branch, and now they want to fix what they found. The moves are the same — docs, then ticket surgery — with a review flavour:
41
+ > Lap `<n>` is specced and carded. Review the ticket cards and click **Burn** — I'll stop here.
29
42
 
30
- 1. **Read the outcome first.** `get_feature_context` carries the latest run outcome and every ticket's final state (done / failed / cancelled). Skim it before the interview so you know what already shipped and what failed.
31
- 2. **Interview about the test drive.** Ask what they hit — bugs, rough edges, missing polish, follow-on tweaks. One concrete change per fix ticket.
32
- 3. **Emit fix tickets.** New work → `emit_tickets`. If a just-burned ticket was wrong, emit a NEW fix ticket (never reopen `done` work). Reconcile stale pending tickets with `update_ticket` / `cancel_ticket`.
33
- 4. **Hand back to Burn.** Do NOT advance the phase. When the cards are ready, tell the human to review them and click **Burn** — burning from review loops the feature back through implementation, and the run returns it to review when it finishes. The loop repeats until they click Merge & ship.
43
+ If the rethink turns out to be genuinely big — whole branches of design reopened, decisions hanging on material nobody has read — say so and escalate the way ideation would (`/runcastle:ideate` §3, the map), rather than grinding it out here.
34
44
 
35
45
  ## Do NOT
36
46
 
37
- - **Never call `complete_phase`.** A revisit has no gates to cross; the human drives the pipeline from the UI.
47
+ - **Never call `complete_phase` in an ordinary revisit.** It has no gates to cross; the human drives the pipeline from the UI. **Lap mode is the one exception** — advancing ideation → spec → tickets is its job (move 7), and nothing else here licenses it.
38
48
  - **Never resolve or reopen waypoints.** If a resolved waypoint's answer is now wrong, record the superseding decision in `decisions.md` — the map's history stays intact.
39
49
  - **Never implement changes.** Code changes ride tickets; the burner (or a human) implements them.
40
50