@coreplane/switchboard 1.257.0 → 1.259.0

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.
Files changed (37) hide show
  1. package/dist/assets/config/config.example.yaml +1 -1
  2. package/dist/assets/package-lock.json +3 -3
  3. package/dist/assets/package.json +1 -1
  4. package/dist/assets/source.json +3 -3
  5. package/dist/assets/src/agents/registry.ts +41 -1
  6. package/dist/assets/src/core/budgets.ts +15 -1
  7. package/dist/assets/src/core/pipelineStanding.ts +2 -0
  8. package/dist/assets/src/core/plane/decide.ts +9 -0
  9. package/dist/assets/src/core/provider.ts +3 -3
  10. package/dist/assets/src/core/runEvents.ts +13 -0
  11. package/dist/assets/src/core/ship/coordinator.ts +81 -22
  12. package/dist/assets/src/core/ship/renewal.ts +4 -3
  13. package/dist/assets/src/core/types.ts +1 -1
  14. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  15. package/dist/assets/web/dist/assets/{DeliveryPage-BBAyLwPq.js → DeliveryPage-DadntjSp.js} +1 -1
  16. package/dist/assets/web/dist/assets/{HomePage-DJqJQzrL.js → HomePage-BEEpTnVT.js} +1 -1
  17. package/dist/assets/web/dist/assets/{PendingTurnRow-fhKqKUVv.js → PendingTurnRow-DBPMLVsm.js} +1 -1
  18. package/dist/assets/web/dist/assets/{PlanePage-Bood0hNO.js → PlanePage-DLusMGQ0.js} +1 -1
  19. package/dist/assets/web/dist/assets/{ResidentDetailPage-DOe1HtUC.js → ResidentDetailPage-CPnHnrxy.js} +1 -1
  20. package/dist/assets/web/dist/assets/{ResidentsIndexPage-TyCGjXM4.js → ResidentsIndexPage-C9hASwHL.js} +1 -1
  21. package/dist/assets/web/dist/assets/{RunFoldRow-C_4t-sit.js → RunFoldRow-OPe7Z0Cz.js} +1 -1
  22. package/dist/assets/web/dist/assets/{RunRoutePage-BNepPMRX.js → RunRoutePage-o1xzkTGZ.js} +3 -3
  23. package/dist/assets/web/dist/assets/{RunsIndexPage-jZnMw1mM.js → RunsIndexPage-0YAClYQh.js} +1 -1
  24. package/dist/assets/web/dist/assets/{ScheduledPage-BkNoudDP.js → ScheduledPage-BIdfC0V3.js} +1 -1
  25. package/dist/assets/web/dist/assets/{SettingsPage-COvWGXl-.js → SettingsPage-CnqWAjl3.js} +1 -1
  26. package/dist/assets/web/dist/assets/{SilentTurn-DNAANKgN.js → SilentTurn-DGNg0sIZ.js} +1 -1
  27. package/dist/assets/web/dist/assets/{StatusDot-BgmkSV0S.js → StatusDot-D_D_iJS8.js} +1 -1
  28. package/dist/assets/web/dist/assets/{Tooltip-DEUPuCPW.js → Tooltip-DVFmM7on.js} +1 -1
  29. package/dist/assets/web/dist/assets/{UnitRoutePage-DNhdzxvY.js → UnitRoutePage-7fAXFoB_.js} +1 -1
  30. package/dist/assets/web/dist/assets/budgets-BRLm6oID.js +1 -0
  31. package/dist/assets/web/dist/assets/{dist-CF3jz9LM.js → dist-C1VX-_mm.js} +1 -1
  32. package/dist/assets/web/dist/assets/{indexRow-DborJPFp.js → indexRow-BO_GIdF6.js} +1 -1
  33. package/dist/assets/web/dist/assets/{main-7SAujY_s.js → main-Cl7xHG34.js} +2 -2
  34. package/dist/assets/web/dist/assets/{sseReplay-BRJEIh83.js → sseReplay-BjlKmQsY.js} +1 -1
  35. package/dist/cli.js +469 -62
  36. package/package.json +1 -1
  37. package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +0 -1
@@ -164,7 +164,7 @@ defaults:
164
164
  # addressSeverity: minor # blocking | major | minor (default) | nit
165
165
  # readingDiff:
166
166
  # provider: git # git (default) | meat | off
167
- # meatModel: claude-opus-5 # meat's -model; Opus-class is the floor that actually abridges
167
+ # meatModel: claude-opus-5 # meat's -model; required with provider: meat (no built-in model) — Opus-class is the floor that actually abridges
168
168
  # meatTimeoutS: 240 # meat's own budget on the host; past it the abridging fails by name
169
169
 
170
170
  # Static per-channel defaults (channel ID -> scope). Runtime overrides set via
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.257.0",
3
+ "version": "1.259.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.257.0",
9
+ "version": "1.259.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.257.0",
20448
+ "version": "1.259.0",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.257.0",
3
+ "version": "1.259.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.257.0",
3
- "commit": "222646c5fe1a6686c238020f6571c69c3ab87e43",
4
- "builtAt": "2026-09-20T15:27:11.679Z"
2
+ "version": "1.259.0",
3
+ "commit": "0b28f0fb4925d069547233cbae61f1d3187972d8",
4
+ "builtAt": "2026-09-21T02:10:55.399Z"
5
5
  }
@@ -74,7 +74,7 @@ export interface AgentDef {
74
74
  system: string;
75
75
  /** key into TOOLSETS (src/tools/toolsets.ts): the tools the bot relays to
76
76
  * the preset's pi; pi's own workspace tools follow `identity`. */
77
- toolset: "full" | "readonly" | "web" | "assistant" | "explore" | "conductor" | "none";
77
+ toolset: "full" | "readonly" | "web" | "assistant" | "explore" | "conductor" | "orchestrator" | "none";
78
78
  /** The runaway guard, not a budget: `runawayTurnCap(maxMinutes)` for every
79
79
  * preset that runs the loop (`loopBudget`). The wall clock below is the
80
80
  * budget; a run that reaches this cap first was pacing like a loop, and its
@@ -647,6 +647,32 @@ ${BREVITY_RULE}
647
647
  ${FENCED_CONTENT_RULE}
648
648
  Report outcomes faithfully: a check you could not run is "could not check", never a guess. Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks — render the claim table as aligned rows inside a code block). Your final message is posted to Slack: lead with the overall verdict in one line, then the claim table, then what a follow-up should do.`;
649
649
 
650
+ // The orchestrator (record 0070; docs/reference/specs/orchestration-plane.md
651
+ // item 11): the plane's chat preset — one long-lived thread per person beside
652
+ // the panels, and `agent:orchestrator` from any channel a grant admits. It
653
+ // reads the fleet from the plane's projections (the `plane_show` tool answers
654
+ // the SAME rows the panels paint) and never from its own context: every fleet
655
+ // fact cites the row it read, and a question the tables cannot answer says so
656
+ // instead of recalling. Machine `none`, identity `none`: no workspace, no
657
+ // shell, no credential — the read tool set is its whole reach, and its writes
658
+ // are the registry's fenced commands through the door (record 0070's later
659
+ // units), never a tool here. The prose speaks record 0066's twelve nouns:
660
+ // thread, run, agent, pipeline, unit, round, budget, follow-up, verdict,
661
+ // outcome, card, pull request — no thirteenth noun rides in.
662
+ const ORCHESTRATOR_SYSTEM = `You are Switchboard's orchestrator: the control plane's chat, answering a person about their fleet — every thread, run, agent, pipeline, unit, round, budget, follow-up, verdict, outcome, card and pull request the plane's tables carry.
663
+
664
+ You have no workspace and no shell. Your tools: \`plane_show\` (the plane's live tables — every live and recently ended run, every unit and every tracked pull request, each with its owner and its health: the same rows the /plane panels paint, seen as the person who asked may see them), \`recall\` and \`notes\` (this thread's log and your notepad), and \`update_status\`.
665
+
666
+ EVERY FLEET FACT COMES FROM THE TABLES, NEVER FROM MEMORY. Before answering any question about the fleet — what is running, which unit is idle, which pull request is merge-ready and nobody's — read the tables with \`plane_show\` and cite the row you read: name the run id, the unit or the pull request the answer stands on, so the person can check it against the panel beside you. Never answer a standing question from this conversation's earlier turns or your own context: an earlier turn is history, the table is now. A question the tables cannot answer — a why the rows do not carry, a fact outside the fleet — is answered "the tables do not say", plainly, instead of recalling or guessing; say what the rows do show and where the answer would live.
667
+
668
+ You cannot run commands, edit code, merge or stop anything from here: acting on the fleet goes through Switchboard's own commands, which the person's own words reach through the door under their own grants — say what you found and let their next sentence act.
669
+
670
+ ${statusCardRule('"Read the plane tables", "Check the run\'s standing"')} A one-read answer needs no checklist.
671
+
672
+ ${BREVITY_RULE}
673
+ ${FENCED_CONTENT_RULE}
674
+ Lead with the answer, then the cited rows — short lines, the row's own words. Your reply renders in the plane's chat column or the channel the question came from.`;
675
+
650
676
  // The conductor (docs/reference/specs/agent-conductor.md): a run that starts
651
677
  // other runs instead of doing the work — the spawn/await substrate's first
652
678
  // preset. A child is a `dispatch()` run as the requesting user, in a thread of
@@ -825,6 +851,20 @@ const WORK_PRESETS = {
825
851
  ...loopBudget("explore"),
826
852
  // No built-in effort: the deployment decides, as for coding.
827
853
  },
854
+ orchestrator: {
855
+ name: "orchestrator",
856
+ description:
857
+ "Answers fleet questions from the plane's live tables — runs, units, pull requests — with the row cited. No workspace or shell.",
858
+ system: ORCHESTRATOR_SYSTEM,
859
+ toolset: "orchestrator",
860
+ // The plane read is a call in the bot process: nothing is provisioned
861
+ // and no credential is minted (record 0070, criterion 3).
862
+ machine: "none",
863
+ identity: "none",
864
+ tiers: ["fast", "strong"], // reads and cites rows: may run fast
865
+ maxTokens: 16000,
866
+ ...loopBudget("orchestrator"),
867
+ },
828
868
  } satisfies Record<string, AgentDef>;
829
869
 
830
870
  export const AGENTS: Record<string, AgentDef> = {
@@ -144,7 +144,15 @@ export function intakeReceiptRetentionMs(catchUpWindowMs: number): number {
144
144
  export const INTAKE_RECOVERY_WINDOW_MS = 10 * MINUTE_MS;
145
145
 
146
146
  /** The presets that run the tool loop, and the one pipeline preset. */
147
- export const LOOP_PRESETS = ["general", "coding", "review", "research", "explore", "conductor"] as const;
147
+ export const LOOP_PRESETS = [
148
+ "general",
149
+ "coding",
150
+ "review",
151
+ "research",
152
+ "explore",
153
+ "conductor",
154
+ "orchestrator",
155
+ ] as const;
148
156
  export type LoopPreset = (typeof LOOP_PRESETS)[number];
149
157
  export type Preset = LoopPreset | "ship";
150
158
 
@@ -165,6 +173,10 @@ export const ASKS: Readonly<Record<Preset, number>> = {
165
173
  research: 8,
166
174
  explore: 120,
167
175
  conductor: 120,
176
+ // The plane's chat preset (record 0070): reads the fleet's tables and
177
+ // answers; a turn is a projection read plus a write-up, so its ask sits
178
+ // between general's and research's kind of work, with room for a few reads.
179
+ orchestrator: 10,
168
180
  };
169
181
 
170
182
  /** The rounds a ship loop is made of. `findings` is a coding child handed the
@@ -187,6 +199,7 @@ export const PRESET_FLOORS: Readonly<Record<LoopPreset, number>> = {
187
199
  research: 3,
188
200
  explore: 15,
189
201
  conductor: 15,
202
+ orchestrator: 2,
190
203
  };
191
204
  export const FLOORS: Readonly<Record<RoundKind, number>> = {
192
205
  coding: PRESET_FLOORS.coding,
@@ -265,6 +278,7 @@ export const POST_STEP_MINUTES: Readonly<Record<Preset, number>> = {
265
278
  research: 0,
266
279
  explore: 0,
267
280
  conductor: 0,
281
+ orchestrator: 0,
268
282
  ship: 0,
269
283
  };
270
284
 
@@ -99,6 +99,7 @@ export const ROUND_STAGE = {
99
99
  dequeued: "fix",
100
100
  aborted: "hold",
101
101
  stopped: "hold",
102
+ held: "hold",
102
103
  continued: "continue",
103
104
  idle: "idle",
104
105
  } as const satisfies Record<ShipRoundOutcome, Stage | "start" | "continue" | "hold">;
@@ -171,6 +172,7 @@ export const ROUND_OUTCOME_WORDS = {
171
172
  dequeued: "removed from the merge queue",
172
173
  aborted: "aborted",
173
174
  stopped: "stopped",
175
+ held: "held",
174
176
  continued: "continuing",
175
177
  idle: "idle",
176
178
  } as const satisfies Record<ShipRoundOutcome, string>;
@@ -265,6 +265,15 @@ export function reissueSteerSentence(provider: string): string {
265
265
  return `the model provider ${provider} is answering again — re-issue the held turn and continue`;
266
266
  }
267
267
 
268
+ /** The reissue steer read back (model-proxy item 12a): what the pi harness
269
+ * releases a held turn on — the sentence above with any provider's name,
270
+ * judged whole against the inbox row's text beside its `plane` sender, so a
271
+ * parked turn is only re-driven by the plane's own words, never a person's
272
+ * follow-up that happens to mention a provider. */
273
+ export function isReissueSteerText(text: string): boolean {
274
+ return /^the model provider .+ is answering again — re-issue the held turn and continue$/.test(text.trim());
275
+ }
276
+
268
277
  /** The checkpoint steer's causes: an in-flight call past its bound (or the
269
278
  * no-bound line) and a coding round with no pushed head past `noPushMinutes`. */
270
279
  export type SteerCause = "long_call" | "no_push";
@@ -187,7 +187,7 @@ export interface VendorRef {
187
187
  /** The one vendor parse (record 0052): `parseModelRef` is the only other parser of a
188
188
  * ref. A block that declares a vendor name uses it; a block that declares
189
189
  * `vendor: model`, or a model id that carries its own vendor prefix
190
- * (`openrouter/anthropic/claude-sonnet-5`), reads the vendor off the id's
190
+ * (`openrouter/<vendor>/<model>`), reads the vendor off the id's
191
191
  * first segment; otherwise the block's own name is the vendor. `catalog`
192
192
  * never enters: a block named unlike its catalog still serves the vendor the
193
193
  * declaration or the id names. */
@@ -272,11 +272,11 @@ export function billerHarnessProvider(biller: string | undefined): BillerHarness
272
272
  /** The env var Anthropic's own SDK reads when an `anthropic` provider block names none. */
273
273
  export const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
274
274
 
275
- /** "anthropic/claude-opus-5" -> { provider: "anthropic", model: "claude-opus-5" } */
275
+ /** "<provider>/<model>" -> { provider, model }: the split at the ref's first slash. */
276
276
  export function parseModelRef(ref: string): { provider: string; model: string } {
277
277
  const i = ref.indexOf("/");
278
278
  if (i === -1) {
279
- throw new Error(`Model "${ref}" must be qualified as "<provider>/<model>", e.g. "anthropic/claude-opus-5"`);
279
+ throw new Error(`Model "${ref}" must be qualified as "<provider>/<model>"`);
280
280
  }
281
281
  return { provider: ref.slice(0, i), model: ref.slice(i + 1) };
282
282
  }
@@ -128,6 +128,13 @@ export type RunNoteKind =
128
128
  * (docs/reference/specs/run-history.md item 37); the summary says how many calls were
129
129
  * in flight at the kill and how each was settled. Published by the runner. */
130
130
  | "resumed"
131
+ /** The restart the run's `restarting` close promised never claimed the run
132
+ * (issue 2081): its dispatch died between the close and the successor's
133
+ * claim, so this interrupted close is the run's end, not a restart. The
134
+ * summary names how the dispatch ended. Published by the dispatcher over
135
+ * the closed record — a kind of its own, so the interruption's cause is
136
+ * still read off the roll's earlier words, never off the dispatch error. */
137
+ | "restart_died"
131
138
  /** A control was decided against the model card and is not native (record
132
139
  * 0052): a fallback (`applied` differs from `asked`, `vouched` true) or
133
140
  * an unvouched send (`vouched` false). One note per degraded control,
@@ -332,6 +339,7 @@ export const RUN_NOTE_KINDS = [
332
339
  "mcp_unavailable",
333
340
  "follow_up",
334
341
  "resumed",
342
+ "restart_died",
335
343
  "control_degraded",
336
344
  "seed",
337
345
  "redispatch",
@@ -436,6 +444,11 @@ export type ShipRoundOutcome =
436
444
  | "dequeued"
437
445
  | "aborted"
438
446
  | "stopped"
447
+ /** The round-0 coding child concluded its round with an answer instead of a
448
+ * pull request (issue 2086): its handoff records a deviation — a stop
449
+ * condition, a blocked precondition — so the unit ends held for the
450
+ * person's word and no renewal is judged. */
451
+ | "held"
439
452
  /** The coding round ended at its lease with the unit unfinished and the row
440
453
  * showing progress, and the grant renewed: the next segment opens (decision 0046). */
441
454
  | "continued"
@@ -776,6 +776,25 @@ export type UnitEnding =
776
776
  round: RoundRef;
777
777
  reviewRounds: number;
778
778
  }
779
+ /** The round-0 coding child concluded its round with an answer instead of a
780
+ * pull request (issue 2086): its handoff records a deviation — a stop
781
+ * condition it hit, a precondition that does not hold, a question it could
782
+ * not resolve — read off the record, never its prose. The round has ended;
783
+ * the unit ends held with the deviations as its reason and waits for the
784
+ * person's word (it idles when the flag is on — the one held cause that
785
+ * does), and the round is never renewed as a second segment: the renewal
786
+ * grant (decision 0046, amended) is for a lease that ran out mid-work, not
787
+ * for a round with an answer. */
788
+ | {
789
+ kind: "held";
790
+ cause: "blocked";
791
+ pr?: PrRef;
792
+ round: RoundRef;
793
+ runId: string;
794
+ reason: string;
795
+ finalReply?: string;
796
+ reviewRounds: number;
797
+ }
779
798
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
780
799
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
781
800
  | {
@@ -870,22 +889,20 @@ export type UnitEnding =
870
889
 
871
890
  /** The kinds that idle: every ending but the ended ones — the unit is
872
891
  * unfinished (a cap, a stop, an abort, a refused merge, a segment's end) and
873
- * a reply could continue it. `merged`, `already_landed`, `merge_ready`,
874
- * `held` and `refused` never idle: the first two are done, merge-ready waits
875
- * only for a person's merge, held waits only for a person's receipt (a fix
876
- * round could change nothing, so nothing here can continue it), and a
877
- * refused child would be refused again. */
878
- export type IdleWhy = Exclude<
879
- UnitEnding["kind"],
880
- "idle" | "merged" | "already_landed" | "merge_ready" | "held" | "refused"
881
- >;
892
+ * a reply could continue it. `merged`, `already_landed`, `merge_ready` and
893
+ * `refused` never idle: the first two are done, merge-ready waits only for a
894
+ * person's merge, and a refused child would be refused again. A gated or
895
+ * draft `held` never idles either — it waits only for a person's receipt at
896
+ * the pull request, which no thread reply can produce — but a blocked hold
897
+ * (issue 2086) waits for exactly the person's word the idle's wake carries,
898
+ * so it alone idles; `idleEnding` draws that line by cause. */
899
+ export type IdleWhy = Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused">;
882
900
 
883
901
  const NEVER_IDLES: ReadonlySet<UnitEnding["kind"]> = new Set([
884
902
  "idle",
885
903
  "merged",
886
904
  "already_landed",
887
905
  "merge_ready",
888
- "held",
889
906
  "refused",
890
907
  ]);
891
908
 
@@ -1354,10 +1371,11 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
1354
1371
  function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEnding, { kind: "idle" }> | undefined {
1355
1372
  if ((s.input.idleDays ?? 0) <= 0) return undefined;
1356
1373
  if (NEVER_IDLES.has(ending.kind)) return undefined;
1357
- const old = ending as Exclude<
1358
- UnitEnding,
1359
- { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "held" | "refused" }
1360
- >;
1374
+ // A gated or draft hold waits only for a person's receipt at the pull
1375
+ // request — no thread reply can continue it — while a blocked hold (issue
1376
+ // 2086) waits for exactly the person's word the wake carries, so it idles.
1377
+ if (ending.kind === "held" && ending.cause !== "blocked") return undefined;
1378
+ const old = ending as Exclude<UnitEnding, { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "refused" }>;
1361
1379
  const grant = s.input.grant ?? DEFAULT_GRANT;
1362
1380
  // Unspent: an idle spends no renewal — the wake's segment does (this plan's
1363
1381
  // fifth unit) — so the row says what the grant still holds.
@@ -1996,14 +2014,42 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1996
2014
  return end(s, { kind: "already_landed", landed, round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
1997
2015
  roundNote(round, "completed"),
1998
2016
  ]);
1999
- // Round 0 ended without a pull request: the segment is over with the unit
2000
- // unfinished, and the grant decides whether the next opens (decision
2001
- // 0046, Renewal). Progress is read off the child's record — a head pushed
2002
- // to the unit's branch since the segment started, or a handoff that moved —
2003
- // never off its words; the decision then asks the grant's count, the cap
2004
- // and the fit, in that order, and a refusal names the clause. A plain
2005
- // abort keeps its old shape when nothing was pushed under a grant of zero:
2006
- // a clarifying question is not a stop to explain.
2017
+ // A concluded round is never renewed (issue 2086; decision 0046,
2018
+ // amended): a handoff recording a deviation is the round's own ending —
2019
+ // a stop condition the child hit, a precondition that does not hold, a
2020
+ // question it could not resolve — a typed fact off the record, never its
2021
+ // prose. The unit ends held with the deviations as its reason and waits
2022
+ // for the person's word; no renewal is judged and no second segment
2023
+ // opens — the grant is for a lease that ran out mid-work, and a renewed
2024
+ // segment would re-run the same brief with no memory of the answer. A
2025
+ // handoff without deviations is the lease-end checkpoint (follow-ups,
2026
+ // unproven criteria) and leaves the renewal decision below to the grant.
2027
+ const deviations = phase.childHandoff?.deviations ?? [];
2028
+ if (deviations.length > 0)
2029
+ return end(
2030
+ s,
2031
+ {
2032
+ kind: "held",
2033
+ cause: "blocked",
2034
+ ...(s.pr !== undefined ? { pr: s.pr } : {}),
2035
+ round,
2036
+ runId: phase.runId,
2037
+ reason: deviations.map((dv) => `${dv.from} → ${dv.to} — ${dv.why}`).join("; "),
2038
+ ...(phase.finalReply !== undefined ? { finalReply: phase.finalReply } : {}),
2039
+ reviewRounds: s.reviewRounds,
2040
+ },
2041
+ [roundNote(round, "held")],
2042
+ );
2043
+ // Round 0 ended without a pull request and without an ending of its own:
2044
+ // the segment is over with the unit unfinished — a lease end, never a
2045
+ // concluded round (those ended above) — and the grant decides whether
2046
+ // the next opens (decision 0046, Renewal). Progress is read off the
2047
+ // child's record — a head pushed to the unit's branch since the segment
2048
+ // started, or a checkpoint handoff that moved — never off its words; the
2049
+ // decision then asks the grant's count, the cap and the fit, in that
2050
+ // order, and a refusal names the clause. A plain abort keeps its old
2051
+ // shape when nothing was pushed under a grant of zero: a clarifying
2052
+ // question is not a stop to explain.
2007
2053
  const grant = s.input.grant ?? DEFAULT_GRANT;
2008
2054
  const session = s.input.session;
2009
2055
  const progress = progressOf({
@@ -2707,6 +2753,19 @@ export function renderUnitReport(
2707
2753
  draftReissue,
2708
2754
  ]);
2709
2755
  }
2756
+ // The blocked hold (issue 2086): the coding child concluded its round
2757
+ // with an answer instead of a pull request — its handoff's deviations
2758
+ // are the reason — so the report leads with that answer and says plainly
2759
+ // that no renewal was spent and no second coding child ran: the person's
2760
+ // word in this thread is what continues the unit.
2761
+ if (e.cause === "blocked") {
2762
+ if (!shows(verbosity, "verbose")) return `⏸️ Held: blocked — ${e.reason}${prLine}`;
2763
+ return join([
2764
+ `⏸️ Held after ${rounds}: the coding child of round ${e.round.index} concluded its round blocked instead of opening a pull request — ${e.reason}.${prLine}`,
2765
+ writeUpPointer(s, e.round.kind, e.runId),
2766
+ `No renewal was spent and no second coding child ran — a concluded round is not renewed (a renewal continues a budget that ran out mid-work). Next step: your word in this thread — answer what the child raised, and the unit continues from there. ${reissue}`,
2767
+ ]);
2768
+ }
2710
2769
  // The person's next step is the report's whole point (issue 1990): the
2711
2770
  // human-gated rows are named with the reviewer's own words, and the
2712
2771
  // re-issue line says the attempt resumes at the review round — item 10's
@@ -54,7 +54,9 @@ const sameSha = (a: string, b: string): boolean => {
54
54
  /** Progress is a fact the row records after unit eight and the runner reads
55
55
  * without a model: the last head pushed to the unit's branch that differs from
56
56
  * the head the segment started at and is not older than the lease; failing
57
- * that, a handoff whose follow-ups shrank or whose deviations grew. */
57
+ * that, a checkpoint handoff whose follow-ups shrank. A handoff carrying
58
+ * deviations never reaches this decision: it is the round's own ending (issue
59
+ * 2086) and the unit ends held before any renewal is judged. */
58
60
  export function progressOf(input: ProgressInput): Progress {
59
61
  const last = [...input.pushed].reverse().find((h) => h.ref === input.branch);
60
62
  if (last !== undefined) {
@@ -69,8 +71,7 @@ export function progressOf(input: ProgressInput): Progress {
69
71
  const pushWhy = `no head newer than the budget's start was pushed to \`${input.branch}\``;
70
72
  if (input.handoff === undefined) return { progressed: false, why: pushWhy };
71
73
  const { previous, current } = input.handoff;
72
- if (current.followUps.length < previous.followUps.length || current.deviations.length > previous.deviations.length)
73
- return { progressed: true, by: "handoff" };
74
+ if (current.followUps.length < previous.followUps.length) return { progressed: true, by: "handoff" };
74
75
  return { progressed: false, why: `${pushWhy} and the write-up is unchanged` };
75
76
  }
76
77
 
@@ -162,7 +162,7 @@ export type StatusActivity = { kind: "command"; tool: string; command: string }
162
162
 
163
163
  /** A structured progress frame; adapters decide how to render it. */
164
164
  export interface StatusUpdate {
165
- /** one-line headline, e.g. "⚡ review on anthropic/claude-fable-5 · 42s" */
165
+ /** one-line headline, e.g. "⚡ review on <provider>/<model> · 42s" */
166
166
  title: string;
167
167
  /** the agent's checklist and any lead lines, one per line */
168
168
  detail?: string;