@coreplane/switchboard 1.255.0 → 1.256.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 (30) hide show
  1. package/dist/assets/deploy/cloudflare-resident/drain.ts +55 -1
  2. package/dist/assets/deploy/cloudflare-resident/worker.ts +177 -32
  3. package/dist/assets/package-lock.json +3 -3
  4. package/dist/assets/package.json +1 -1
  5. package/dist/assets/source.json +3 -3
  6. package/dist/assets/src/agents/registry.ts +31 -0
  7. package/dist/assets/src/core/coordinator/driver.ts +11 -1
  8. package/dist/assets/src/core/pipelineStanding.ts +57 -0
  9. package/dist/assets/src/core/reviewVerdict.ts +49 -0
  10. package/dist/assets/src/core/ship/contract.ts +20 -30
  11. package/dist/assets/src/core/ship/coordinator.ts +195 -68
  12. package/dist/assets/web/dist/.vite/manifest.json +31 -31
  13. package/dist/assets/web/dist/assets/{HomePage-Be7jLLnU.js → HomePage-BEiStFwW.js} +1 -1
  14. package/dist/assets/web/dist/assets/{ResidentDetailPage-D_RD6wLo.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
  15. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLkSuCxo.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
  16. package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
  17. package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
  18. package/dist/assets/web/dist/assets/{RunsIndexPage-DW-HHuZa.js → RunsIndexPage-JqVSDOok.js} +1 -1
  19. package/dist/assets/web/dist/assets/{ScheduledPage-3aYsDf-q.js → ScheduledPage-Viim-bus.js} +1 -1
  20. package/dist/assets/web/dist/assets/{SettingsPage-DG-p5Xy1.js → SettingsPage-CYUy8McC.js} +1 -1
  21. package/dist/assets/web/dist/assets/{StatusDot-CEnGlyAL.js → StatusDot-Dv6UMaPy.js} +1 -1
  22. package/dist/assets/web/dist/assets/{Tooltip-CiunVowT.js → Tooltip-Bd-5Rypv.js} +1 -1
  23. package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
  24. package/dist/assets/web/dist/assets/{dist-luhv3YSo.js → dist-Nl3uaxrP.js} +1 -1
  25. package/dist/assets/web/dist/assets/{main-mAKx_zo9.js → main-DRxWlffc.js} +2 -2
  26. package/dist/cli.js +319 -64
  27. package/package.json +1 -1
  28. package/dist/assets/web/dist/assets/RunFoldRow-CfuZqf_O.js +0 -1
  29. package/dist/assets/web/dist/assets/RunRoutePage-CmWYGR36.js +0 -9
  30. package/dist/assets/web/dist/assets/UnitRoutePage-iOYnmTcQ.js +0 -1
@@ -439,42 +439,32 @@ export const TIMEOUT_ON_LONG_COMMANDS =
439
439
  "State a timeout on any command you expect to run longer than a minute: a timeout that reaches past the loop's " +
440
440
  "end is refused before the command runs, never cut midway.";
441
441
 
442
- /** The fast gates a plan child runs before every push, named one by one and
443
- * each scoped to the changed set (agent-ship item 13; agent-coding item 9).
444
- * "Its cheapest proving checks" left the choice to the child, and children
445
- * chose wrong in both directions: prettier was reported clean while
446
- * `format:check` was red, hygiene imprints reached CI that `hygiene:check`
447
- * would have caught locally — and children ran the whole suite and the whole
448
- * typecheck on the shared resident, minutes each call, time-sliced against
449
- * every other run. So the gates are the changed-set forms, the full runs are
450
- * said to be CI's alone in the same breath, and each gate is a receipt — the
451
- * exit line goes into the PR description's validation table as the row's proof
452
- * (the handoff has no verified list; parseHandoff carries deviations, followUps,
453
- * unproven and landed), and a gate the child could not run goes under the
454
- * handoff's unproven list, never claimed clean. The test gate is the touched
455
- * files by name, never a changed-set or directory run. */
456
- /** The test command the contract hands a child: the touched files by name, once.
457
- * Never `--changed`: against a base that moves, it selects most of the suite, and on the
458
- * shared resident that is the memory incident the coding contract exists to prevent. */
459
- export const TOUCHED_TESTS_COMMAND = "`npx vitest run` on the test files you touched, by name,";
460
-
461
- export const FAST_GATES_BEFORE_PUSH =
462
- "The fast gates, before every push — each scoped to the changed set, never the whole project: " +
463
- `${TOUCHED_TESTS_COMMAND} once (never \`--changed\`, never a directory: on a moving base that is most of the suite), ` +
464
- "`tsc --noEmit -p` the touched tsconfig under `NODE_OPTIONS=--max-old-space-size=6144`, " +
465
- "`npx prettier --check` on the changed files, `npm run hygiene:check` and `npm run specs:check` — " +
466
- "then your judgement on what else this change needs, not a longer checklist. Every CI pipeline runs the " +
467
- "tests, the types, the formatting and the full verification on your push, so you never run them again: " +
468
- "you validate and fix your own change before pushing, at the changed-set scope. Passing the full test suite " +
469
- "and the full typecheck is NOT part of your criteria: CI is that gate and the only place they run — on a " +
470
- "shared resident they cost minutes that every other run pays for. " +
442
+ /** The fast gates a plan child runs before every push live in the coding
443
+ * preset's own instructions (`FAST_GATES_BEFORE_PUSH`,
444
+ * src/agents/registry.ts — agent-coding item 13; issue 1796): the changed-set
445
+ * forms with their commands named, the full verification named as CI's gate.
446
+ * The contract used to restate the whole paragraph, so every ask that was not
447
+ * a plan unit had to repeat it by hand; now the first instruction points at
448
+ * the preset's paragraph — the child reads the sentence once — and keeps only
449
+ * what is the contract's own: the receipts. Each gate is a receipt — the exit
450
+ * line goes into the PR description's validation table as the row's proof
451
+ * (the handoff has no verified list; parseHandoff carries deviations,
452
+ * followUps, unproven and landed), and a gate the child could not run goes
453
+ * under the handoff's unproven list, never claimed clean — because only a
454
+ * plan child has a handoff to route them to. */
455
+ export const FAST_GATES_POINTER =
456
+ "The fast gates are the ones your preset instructions name (THE FAST GATES): the changed-set forms, " +
457
+ "never the whole project — the full suite, the full typecheck and the full verification are CI's, " +
458
+ "never yours to run.";
459
+
460
+ export const GATE_RECEIPTS =
471
461
  "Paste each command's exit line into the PR description's validation table as the row's proof; a gate you " +
472
462
  "could not run goes under the handoff's unproven list and is never claimed clean.";
473
463
 
474
464
  function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
475
465
  const branch = rebase.branch ? `\`${rebase.branch}\`` : "the unit's branch";
476
466
  const onto = rebase.onto ? `\`${rebase.onto}\`` : "the merged parent";
477
- const gates = FAST_GATES_BEFORE_PUSH;
467
+ const gates = `${FAST_GATES_POINTER} ${GATE_RECEIPTS}`;
478
468
  return (
479
469
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
480
470
  `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +
@@ -41,6 +41,7 @@ import {
41
41
  findingsAtOrAbove,
42
42
  formatFinding,
43
43
  isAddressSeverity,
44
+ severityCounts,
44
45
  type AddressSeverity,
45
46
  type AddressSeveritySource,
46
47
  type Finding,
@@ -76,21 +77,70 @@ export interface ShipCaps {
76
77
  * rounds that must still follow, capped at the preset's ask, refused under the
77
78
  * round's floor. Nothing here holds a reserve of its own. */
78
79
 
79
- /** What a ship pipeline's thread and card say when the bot died under it (run-
80
- * history item 36): the work it did stands on GitHub with nobody driving it,
81
- * so the note names the PR when one was opened and the exact re-issue that
82
- * continues the loop — the same entry the preflight's resume-at-review takes
83
- * (agent-ship item 10). Without a PR the task itself is the re-issue: round 0
84
- * runs again on the pipeline's own deterministic branch. The coordinator says
85
- * the same when a child of its closed `interrupted`. */
86
- export function shipInterruptedNote(prUrl?: string): string {
80
+ /** What actually ended an interrupted child, from the ledger that saw it
81
+ * (issue 1876): the ending's sentence names the cause in the user's nouns —
82
+ * the bot restarted, the resident container was replaced, the sandbox failed
83
+ * — never "the bot restarted" for a site three causes reach. Read off the
84
+ * child's record by the bot's `read-record` (`child_interrupted` / the resume
85
+ * notes) and carried on the `interrupted` ending; absent when the record
86
+ * named none. */
87
+ export type InterruptionCause = "bot_restart" | "container_replaced" | "sandbox_fault";
88
+
89
+ /** What a ship pipeline's thread and card say when its child died under it
90
+ * (run-history item 36): the work it did stands on GitHub with nobody driving
91
+ * it, so the note names the actual cause (issue 1876 — the site is reached by
92
+ * a bot restart, a resident container replacement and a sandbox fault, and
93
+ * only the ledger's word picks one; with none the sentence claims no cause),
94
+ * the PR when one was opened and the exact re-issue that continues the loop —
95
+ * the same entry the preflight's resume-at-review takes (agent-ship item 10).
96
+ * Without a PR the task itself is the re-issue: round 0 runs again on the
97
+ * pipeline's own deterministic branch. A child whose container was replaced
98
+ * resumes from its request by itself (issue 1903) and the pipeline keeps
99
+ * waiting — this note is written only when that resume failed or no resume
100
+ * applied. */
101
+ /** The cause an interruption's recorded words name (issue 1876): the ledger
102
+ * that saw the roll wrote them — the relaunch refusals and the lost-workspace
103
+ * notes name the replaced container, the sandbox executor's wordings the
104
+ * sandbox, the boot gap names the restart — and the ending's sentence repeats
105
+ * the cause, never "the bot restarted" for words that say otherwise. Each
106
+ * pattern is anchored on the exact phrases those ledgers write —
107
+ * `src/core/harness/contract.ts` and `src/core/dispatch/relaunch.ts`
108
+ * ("container replaced under the run", "replacement container"),
109
+ * `src/core/dispatch/reattach.ts` ("workspace lost", "could not be
110
+ * re-attached"), `src/execution/sandboxLifecycle.ts` and the sandbox worker's
111
+ * failures ("sandbox recycled", "sandbox worker", "sandbox exec"),
112
+ * `src/core/boot.ts` and the resume notes ("bot restarted", the deploy
113
+ * hand-off's "next generation") — so an unrelated word ("regenerating…", a
114
+ * reason merely mentioning a sandbox path) cannot classify. Unrecognized
115
+ * words are no cause: the sentence then claims none. */
116
+ export function interruptionCauseOfWords(words: string): InterruptionCause | undefined {
117
+ if (
118
+ /\bcontainer (?:was )?replaced\b|\breplac(?:ed|ement) container\b|\bworkspace lost\b|\bcould not be re-attached\b/i.test(
119
+ words,
120
+ )
121
+ )
122
+ return "container_replaced";
123
+ if (/\bsandbox (?:recycled|worker|exec|runtime)\b/i.test(words)) return "sandbox_fault";
124
+ if (/\bbot restart(?:ed|s)?\b|\b(?:next|previous|another) generation\b/i.test(words)) return "bot_restart";
125
+ return undefined;
126
+ }
127
+
128
+ export function shipInterruptedNote(prUrl?: string, cause?: InterruptionCause): string {
87
129
  const stands = prUrl
88
130
  ? `Its work stands on GitHub: ${prUrl}.`
89
131
  : "Whatever it pushed stands on its pipeline branch; no PR was opened yet.";
90
132
  const reissue = prUrl
91
133
  ? `To continue the review loop, re-issue \`agent:ship\` in this thread with only the PR URL (${prUrl}).`
92
134
  : "To continue, re-issue `agent:ship` in this thread with the task — round 0 runs again on the same branch.";
93
- return `⚠️ The bot restarted while this ship pipeline was running, so the pipeline stopped. ${stands} ${reissue}`;
135
+ const opening =
136
+ cause === "container_replaced"
137
+ ? "⚠️ The resident container running this pipeline's child was replaced (a deploy's image swap) and the child could not resume, so the pipeline stopped."
138
+ : cause === "sandbox_fault"
139
+ ? "⚠️ The sandbox running this pipeline's child failed and the child could not resume, so the pipeline stopped."
140
+ : cause === "bot_restart"
141
+ ? "⚠️ The bot restarted while this ship pipeline was running, so the pipeline stopped."
142
+ : "⚠️ This ship pipeline's child was interrupted and could not resume, so the pipeline stopped.";
143
+ return `${opening} ${stands} ${reissue}`;
94
144
  }
95
145
 
96
146
  // ---- the plan graph --------------------------------------------------------------------------------
@@ -473,9 +523,10 @@ export type CoordinatorAction =
473
523
  * over an already-shipped pull request. */
474
524
  entry?: true;
475
525
  /** Set after a coding child died: the bot opens the pull request from the
476
- * pushed branch itself (title from the unit, body from this run's
477
- * submitted description when the record holds one) instead of answering
478
- * `none` over stranded work. */
526
+ * pushed branch itself (title from this run's submitted description when
527
+ * the record holds one, else a conventional fallback from the unit's
528
+ * title — issue 1877; body from the description when the record holds
529
+ * one) instead of answering `none` over stranded work. */
479
530
  recover?: { runId: string };
480
531
  /** The pull request the machine has adopted (`state.pr`), when it holds
481
532
  * one: the child may have worked that pull request's own head branch,
@@ -536,6 +587,10 @@ export type ChildFacts =
536
587
  * `provider_transient` marks a child a gateway 5xx, a cut stream or a
537
588
  * gateway timeout ended past the harness's retry ladder (issue 1932). */
538
589
  failure?: { kind: string };
590
+ /** What ended an `interrupted` child, off its record's own events (issue
591
+ * 1876): the ending's sentence names it instead of claiming a bot
592
+ * restart for every cause. */
593
+ interruption?: InterruptionCause;
539
594
  };
540
595
 
541
596
  /** What heads the unit's branch on GitHub: nothing, an open pull request, or —
@@ -599,7 +654,10 @@ export type StepReturn =
599
654
  | { type: "wait"; step: string; outcome: "event" | "timeout" }
600
655
  // `stopped` on a read: the instance row carries the hard stop's mark, so a
601
656
  // finished child ends its unit `stopped` whatever the child's own status.
602
- | { type: "read-record"; step: string; run: ChildFacts; stopped?: true; at: number }
657
+ /** `restartedAs`: the interrupted child restarted from its request as this
658
+ * run (issue 1903 — a replaced container's child resumes by itself), so the
659
+ * machine keeps waiting on the successor instead of ending the unit. */
660
+ | { type: "read-record"; step: string; run: ChildFacts; stopped?: true; restartedAs?: string; at: number }
603
661
  | { type: "pr-check"; step: string; pr: PrCheck; at: number }
604
662
  | { type: "merge"; step: string; outcome: "merged"; sha: string; at: number }
605
663
  // The door found the pull request already merged after the approval — auto-merge
@@ -766,7 +824,7 @@ export type UnitEnding =
766
824
  * `transient` so it reads as a condition beside `checks_failed` and `held`
767
825
  * in the plane's table, never as the child failing on its task. */
768
826
  | { kind: "transient"; round: RoundRef; runId: string; reviewRounds: number }
769
- | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number }
827
+ | { kind: "interrupted"; round: RoundRef; runId: string; reviewRounds: number; cause?: InterruptionCause }
770
828
  | { kind: "refused"; refusal: string; message?: string; round: RoundRef; reviewRounds: number }
771
829
  /** The unit idles instead of ending (record 0051): with the resolved
772
830
  * `ship.idleDays` above zero, `end()` wraps an idling kind — every kind but
@@ -940,6 +998,8 @@ type Phase =
940
998
  * pushed: the pr-check recovers a pushed branch by opening its pull
941
999
  * request; with nothing pushed the unit ends with the child's own reason. */
942
1000
  dead?: "failed" | "interrupted";
1001
+ /** What ended the dead child (issue 1876), for the `interrupted` ending's sentence. */
1002
+ cause?: InterruptionCause;
943
1003
  /** The dead child's record names a provider transient (`failure:
944
1004
  * provider_transient`, issue 1932): with nothing pushed, round 0 is
945
1005
  * re-run once instead of the unit aborting; a second transient in the
@@ -1812,9 +1872,17 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1812
1872
  // A dead child left nothing on the branch to recover: the unit ends with
1813
1873
  // the child's own reason — never the budget clip.
1814
1874
  if (phase.dead === "interrupted")
1815
- return end(s, { kind: "interrupted", round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
1816
- roundNote(round, "aborted"),
1817
- ]);
1875
+ return end(
1876
+ s,
1877
+ {
1878
+ kind: "interrupted",
1879
+ round,
1880
+ runId: phase.runId,
1881
+ reviewRounds: s.reviewRounds,
1882
+ ...(phase.cause !== undefined ? { cause: phase.cause } : {}),
1883
+ },
1884
+ [roundNote(round, "aborted")],
1885
+ );
1818
1886
  if (phase.dead === "failed") {
1819
1887
  // A provider transient with nothing pushed is not the child's failure
1820
1888
  // (issue 1932): the ledger row and the branch are untouched, so round 0
@@ -1971,9 +2039,17 @@ function roundOnOpenPr(
1971
2039
  // ends with the child's own reason — the ship-restart note for a bot
1972
2040
  // roll, the failure for a failed run — never as the round's inaction.
1973
2041
  if (phase.dead === "interrupted")
1974
- return end(next, { kind: "interrupted", round, runId: phase.runId, reviewRounds: next.reviewRounds }, [
1975
- roundNote(round, "aborted"),
1976
- ]);
2042
+ return end(
2043
+ next,
2044
+ {
2045
+ kind: "interrupted",
2046
+ round,
2047
+ runId: phase.runId,
2048
+ reviewRounds: next.reviewRounds,
2049
+ ...(phase.cause !== undefined ? { cause: phase.cause } : {}),
2050
+ },
2051
+ [roundNote(round, "aborted")],
2052
+ );
1977
2053
  if (phase.dead === "failed")
1978
2054
  return end(
1979
2055
  next,
@@ -2169,10 +2245,14 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2169
2245
  case "read": {
2170
2246
  const r = ret as Extract<StepReturn, { type: "read-record" }>;
2171
2247
  if (!r.run.finished)
2248
+ // `restartedAs` (issues 1903/1876): the child's container was replaced
2249
+ // and it restarted from its request as a new run — the round carries on
2250
+ // waiting on the successor, and the unit never ends over a resume that
2251
+ // succeeded. The next wait and read follow the successor's id.
2172
2252
  return {
2173
2253
  state: {
2174
2254
  ...clocked,
2175
- phase: { at: "wait", round: p.round, runId: p.runId, n: p.n + 1, until: p.until },
2255
+ phase: { at: "wait", round: p.round, runId: r.restartedAs ?? p.runId, n: p.n + 1, until: p.until },
2176
2256
  },
2177
2257
  notes: [],
2178
2258
  };
@@ -2192,17 +2272,35 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2192
2272
  [roundNote(p.round, "stopped")],
2193
2273
  );
2194
2274
  if (r.run.status === "interrupted") {
2275
+ const cause = r.run.interruption;
2195
2276
  // A dead CODING child may have pushed before the ledger closed it: the
2196
2277
  // pr-check recovers the branch. A review child has nothing on the
2197
2278
  // branch to recover, so its interruption still ends the unit at once.
2198
2279
  if (p.round.kind !== "review")
2199
2280
  return {
2200
- state: { ...clocked, phase: { at: "pr-check", round: p.round, runId: p.runId, dead: "interrupted" } },
2281
+ state: {
2282
+ ...clocked,
2283
+ phase: {
2284
+ at: "pr-check",
2285
+ round: p.round,
2286
+ runId: p.runId,
2287
+ dead: "interrupted",
2288
+ ...(cause !== undefined ? { cause } : {}),
2289
+ },
2290
+ },
2201
2291
  notes: [],
2202
2292
  };
2203
- return end(clocked, { kind: "interrupted", round: p.round, runId: p.runId, reviewRounds: s.reviewRounds }, [
2204
- roundNote(p.round, "aborted"),
2205
- ]);
2293
+ return end(
2294
+ clocked,
2295
+ {
2296
+ kind: "interrupted",
2297
+ round: p.round,
2298
+ runId: p.runId,
2299
+ reviewRounds: s.reviewRounds,
2300
+ ...(cause !== undefined ? { cause } : {}),
2301
+ },
2302
+ [roundNote(p.round, "aborted")],
2303
+ );
2206
2304
  }
2207
2305
  return p.round.kind === "review"
2208
2306
  ? settleReview(clocked, p.round, r.run)
@@ -2450,13 +2548,16 @@ function mergeReadyHeadline(rounds: string, url: string, base: string, facts: Me
2450
2548
  export function renderUnitReport(
2451
2549
  s: UnitPipelineState,
2452
2550
  facts?: MergeReadyFacts,
2453
- /** The level the report speaks at (routing-and-config item 28). The default
2454
- * is the full report — the row's and the board's copy; the thread's copy is
2455
- * rendered at the request's level, where the asides about the machinery
2456
- * (the level in force, the grant, the write-up pointer, the budget split,
2457
- * a segment boundary) are `verbose` and the outcome, the verdict, the
2458
- * findings left below the gate, the declined ones and what to do next are
2459
- * everyone's. */
2551
+ /** The level the report speaks at (routing-and-config item 28, record
2552
+ * 0066). The default is the full report — the row's and the board's copy;
2553
+ * the thread's copy is rendered at the request's level, where only the
2554
+ * outcome is everyone's: ONE line in the user's words — `✅ Merge-ready
2555
+ * after 2 review rounds: <url>`, `Held: <the human-gated row>`, `Stopped`,
2556
+ * `Round cap reached: <counts>` — and the verdict, the findings left below
2557
+ * the gate, the declined ones, the remaining-gate sentence, the level in
2558
+ * force, the grant, the write-up pointer, the budget split, the re-issue
2559
+ * prompt and a segment boundary are `verbose` asides; the full detail
2560
+ * stays on the pull request, where the round routes put it. */
2460
2561
  verbosity: Verbosity = "verbose",
2461
2562
  ): string {
2462
2563
  const e = s.ending;
@@ -2498,11 +2599,11 @@ export function renderUnitReport(
2498
2599
  return `✅ Already merged: ${e.pr.url} (merge commit \`${e.sha.slice(0, 7)}\`, merged ${e.mergedAt}) — the pull request heading \`${s.input.unit.branch}\` was merged before this attempt reached it, by a person or by an earlier attempt of this plan; the runner merged nothing. The unit is done and its dependents start on a base that carries it.`;
2499
2600
  return [
2500
2601
  `✅ Merged after ${rounds}: ${e.pr.url} (squash \`${e.sha.slice(0, 7)}\`) — merged by the plan runner under \`plan:merge\`: the review approved at this head and the guards were green.`,
2501
- verdictLine,
2602
+ aside(verdictLine),
2502
2603
  aside(levelLine),
2503
2604
  aside(grantLine),
2504
- skippedLine,
2505
- declinedLine,
2605
+ aside(skippedLine),
2606
+ aside(declinedLine),
2506
2607
  ]
2507
2608
  .filter(Boolean)
2508
2609
  .join("\n");
@@ -2516,19 +2617,23 @@ export function renderUnitReport(
2516
2617
  facts?.merged
2517
2618
  ? `✅ Merge-ready after ${rounds}: ${e.pr.url}`
2518
2619
  : mergeReadyHeadline(rounds, e.pr.url, s.input.base, facts),
2519
- verdictLine,
2620
+ aside(verdictLine),
2520
2621
  aside(levelLine),
2521
2622
  aside(grantLine),
2522
- skippedLine,
2523
- declinedLine,
2623
+ aside(skippedLine),
2624
+ aside(declinedLine),
2524
2625
  // What the driver read at the approved head when it composed this
2525
2626
  // ending (agent-ship item 9): a merge that already happened is named
2526
2627
  // as such, else the pull request's own auto-merge fact, else the gate.
2527
- facts?.merged
2528
- ? `Already merged: ${e.pr.url} (merge commit \`${facts.merged.sha.slice(0, 7)}\`, merged ${facts.merged.mergedAt}) — auto-merge or a person merged it after the approval; the runner merged nothing.`
2529
- : facts?.autoMergeEnabled
2530
- ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
2531
- : "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
2628
+ // A `verbose` aside like the rest — the quiet thread copy is the
2629
+ // headline alone (record 0066).
2630
+ aside(
2631
+ facts?.merged
2632
+ ? `Already merged: ${e.pr.url} (merge commit \`${facts.merged.sha.slice(0, 7)}\`, merged ${facts.merged.mergedAt}) — auto-merge or a person merged it after the approval; the runner merged nothing.`
2633
+ : facts?.autoMergeEnabled
2634
+ ? "Auto-merge is on for this pull request: the approval merges it once checks pass."
2635
+ : "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
2636
+ ),
2532
2637
  ]
2533
2638
  .filter(Boolean)
2534
2639
  .join("\n");
@@ -2542,12 +2647,15 @@ export function renderUnitReport(
2542
2647
  // first (item 10), the very round this ending exists to avoid — so the
2543
2648
  // held case renders its own re-issue line instead of the shared one.
2544
2649
  const rows = e.findings.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ");
2650
+ // The quiet copy is the ending in the user's words (record 0066): the
2651
+ // held row named, the pull request linked, nothing about the machinery.
2652
+ if (!shows(verbosity, "verbose")) return `⏸️ Held: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
2545
2653
  const heldReissue = s.input.generated
2546
2654
  ? `To continue, re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text.`
2547
2655
  : reissue;
2548
2656
  return join([
2549
2657
  `⏸️ ${e.verdict === "approve" ? "Approved but held" : "Changes requested but held"} after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — every finding of review round ${e.round.index} is human-gated, a receipt only a person can produce: ${rows}. No fix round was opened: a coding child cannot produce the receipt.`,
2550
- aside(levelLine),
2658
+ levelLine,
2551
2659
  `Next step: produce the receipt each finding names and post it on the pull request. ${heldReissue} The re-issued attempt resumes at the review round — no coding round runs first.`,
2552
2660
  ]);
2553
2661
  }
@@ -2558,22 +2666,39 @@ export function renderUnitReport(
2558
2666
  // `already_landed`) and moves on to the dependents. The generated
2559
2667
  // plan's line already says to re-issue with the PR URL, which takes the
2560
2668
  // same recognition path.
2669
+ // The quiet copy: the outcome in the user's words with the person's
2670
+ // remedy — the hand merge is what a person must act on (record 0066).
2671
+ if (!shows(verbosity, "verbose")) return `⚠️ Not merged: ${e.reason} — ${e.pr.url}`;
2561
2672
  return join([
2562
2673
  `⚠️ The review approved ${e.pr.url} but the runner did not merge it: ${e.reason}. A person decides what becomes of the pull request.`,
2563
2674
  s.input.generated
2564
2675
  ? reissue
2565
2676
  : `The approved work is on the branch: rebase or fix it, push, and merge it by hand. Then re-issue the plan naming the remaining units — a unit whose pull request has merged is recognized and not run again, and its dependents start from there.`,
2566
2677
  ]);
2567
- case "round_cap":
2568
- return join([
2569
- `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — no approval after ${rounds}.${prLine}`,
2570
- splitReport(s),
2571
- reissue,
2572
- ]);
2678
+ case "round_cap": {
2679
+ // The quiet copy counts the last review's open findings, only the
2680
+ // non-zero severities (record 0066): `Round cap reached: 2 blockers, 1 major`.
2681
+ if (!shows(verbosity, "verbose")) {
2682
+ const counts = severityCounts(s.findingsByRound[e.reviewRounds] ?? []);
2683
+ return `🧢 Round cap reached${counts ? `: ${counts}` : ""}${prUrl !== undefined ? ` — ${prUrl}` : ""}`;
2684
+ }
2685
+ // The cap bounds fix rounds, never the terminal steps (issue 2023): an
2686
+ // approval in the last allowed round still runs the checks step and the
2687
+ // merge, so a round_cap after an approve means the checks (or the merge
2688
+ // queue) failed at the approved head with no fix round left — and the
2689
+ // report names those findings instead of claiming no approval landed.
2690
+ const failedChecks = checkFindingsOf(s.findingsByRound[s.reviewRounds]);
2691
+ const headline =
2692
+ failedChecks.length > 0
2693
+ ? `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — the review of round ${e.reviewRounds} approved, but ${failedChecks.map((f) => `\`${f.id}\``).join(", ")} failed at the approved head and no fix round remains.${prLine}`
2694
+ : `🧢 Ship stopped at a cap: the ${e.maxRounds}-round cap — no approval after ${rounds}.${prLine}`;
2695
+ return join([headline, splitReport(s), reissue]);
2696
+ }
2573
2697
  case "wall_clock_cap":
2698
+ if (!shows(verbosity, "verbose")) return `🧢 Out of budget — no approval after ${rounds}.${prLine}`;
2574
2699
  return join([
2575
2700
  `🧢 Ship stopped at a cap: the remaining pipeline time (~${Math.max(0, Math.round(e.remainingMs / MIN))} min of the ${s.input.caps.maxMinutes}-minute budget) cannot hold another round${e.refused ? ` (the ${e.refused.round} round would get ${e.refused.minutes} min, under its floor of ${e.refused.floor})` : ""} — no approval after ${rounds}.${prLine}`,
2576
- aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2701
+ budgetSplitLine(e.spent, s.input.caps.maxMinutes),
2577
2702
  splitReport(s),
2578
2703
  reissue,
2579
2704
  ]);
@@ -2581,17 +2706,16 @@ export function renderUnitReport(
2581
2706
  return join([
2582
2707
  `⏳ Review pending: the coding child shipped ${e.pr.url}${e.headSha !== undefined ? ` (head \`${e.headSha.slice(0, 7)}\`)` : ""} but the remaining pipeline time cannot hold the review round — the work stands, only the review is missing. The next attempt starts at the review round while the pull request still heads at the child's own last push.`,
2583
2708
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2584
- reissue,
2709
+ aside(reissue),
2585
2710
  ]);
2586
2711
  case "stopped":
2712
+ if (!shows(verbosity, "verbose")) return `${e.mode === "hard" ? "⛔" : "⏹"} Stopped.${prLine}`;
2587
2713
  return join([
2588
2714
  `${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
2589
- aside(
2590
- writeUpPointer(
2591
- s,
2592
- e.round.kind,
2593
- e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2594
- ),
2715
+ writeUpPointer(
2716
+ s,
2717
+ e.round.kind,
2718
+ e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2595
2719
  ),
2596
2720
  e.postedReview
2597
2721
  ? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
@@ -2599,14 +2723,13 @@ export function renderUnitReport(
2599
2723
  reissue,
2600
2724
  ]);
2601
2725
  case "aborted":
2726
+ if (!shows(verbosity, "verbose")) return `⚠️ Aborted after ${rounds}: ${e.reason}${prLine}`;
2602
2727
  return join([
2603
2728
  e.reason,
2604
- aside(
2605
- writeUpPointer(
2606
- s,
2607
- e.round?.kind ?? "coding",
2608
- e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2609
- ),
2729
+ writeUpPointer(
2730
+ s,
2731
+ e.round?.kind ?? "coding",
2732
+ e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
2610
2733
  ),
2611
2734
  e.renewal !== undefined ? `🔁 Not renewed: ${e.renewal.line}.` : undefined,
2612
2735
  `⚠️ Ship aborted after ${rounds}.`,
@@ -2623,25 +2746,29 @@ export function renderUnitReport(
2623
2746
  aside(budgetSplitLine(e.spent, s.input.caps.maxMinutes)),
2624
2747
  ]);
2625
2748
  case "transient":
2749
+ if (!shows(verbosity, "verbose"))
2750
+ return `⚠️ Aborted after ${rounds}: the model provider failed twice; re-issue once it settles.${prLine}`;
2626
2751
  return join([
2627
2752
  `⚠️ The coding child of round ${e.round.index} (run ${e.runId}) died on a provider transient — a model-gateway 5xx, a cut stream or a gateway timeout past the harness's retry ladder — with nothing pushed, after the round was already re-run once for the same reason. The task itself was never the problem.`,
2628
- aside(writeUpPointer(s, e.round.kind, s.lastCodingRunId)),
2753
+ writeUpPointer(s, e.round.kind, s.lastCodingRunId),
2629
2754
  `⚠️ Ship ended after ${rounds}; re-issue once the provider settles.`,
2630
2755
  reissue,
2631
2756
  ]);
2632
2757
  case "no_verdict":
2758
+ if (!shows(verbosity, "verbose"))
2759
+ return `⚠️ No verdict from review round ${e.round.index} — aborted after ${rounds}.${prLine}`;
2633
2760
  return join([
2634
2761
  `⚠️ Review round ${e.round.index} ended without a submitted verdict (budget, refusal, or stop) — ship never converts that into a request for changes, so no findings step ran.`,
2635
- aside(writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index])),
2762
+ writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index]),
2636
2763
  `⚠️ Ship aborted after ${rounds}.`,
2637
2764
  reissue,
2638
2765
  ]);
2639
2766
  case "interrupted":
2640
- return shipInterruptedNote(prUrl);
2767
+ return shipInterruptedNote(prUrl, e.cause);
2641
2768
  case "refused":
2642
2769
  return join([
2643
2770
  `🚫 The ${presetOf(e.round.kind)} child of round ${e.round.index} was refused by the authorize stage (${e.refusal})${e.message ? `: ${e.message}` : ""} — every child is authorized as the requesting user, so the pipeline ends here.`,
2644
- reissue,
2771
+ aside(reissue),
2645
2772
  ]);
2646
2773
  case "idle":
2647
2774
  // The old kind's sentence at this copy's level — the report is unchanged