@coreplane/switchboard 1.248.0 → 1.249.1

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 (49) hide show
  1. package/dist/assets/config/config.example.yaml +44 -18
  2. package/dist/assets/deploy/cloudflare/preflight.mjs +21 -19
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +31 -0
  4. package/dist/assets/deploy/cloudflare-resident/drain.ts +109 -0
  5. package/dist/assets/deploy/cloudflare-resident/refresh.ts +5 -3
  6. package/dist/assets/deploy/cloudflare-resident/threadErr.ts +32 -3
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +235 -13
  8. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +56 -9
  9. package/dist/assets/deploy/secrets.manifest.json +6 -0
  10. package/dist/assets/package-lock.json +3 -3
  11. package/dist/assets/package.json +1 -1
  12. package/dist/assets/source.json +3 -3
  13. package/dist/assets/src/agents/registry.ts +4 -4
  14. package/dist/assets/src/core/budgets.ts +24 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +4 -3
  16. package/dist/assets/src/core/coordinator/driver.ts +40 -7
  17. package/dist/assets/src/core/modelCard.ts +348 -0
  18. package/dist/assets/src/core/modelPricing.ts +14 -5
  19. package/dist/assets/src/core/modelRegistry.ts +51 -0
  20. package/dist/assets/src/core/provider.ts +103 -0
  21. package/dist/assets/src/core/refusal.ts +181 -0
  22. package/dist/assets/src/core/runEvents.ts +43 -0
  23. package/dist/assets/src/core/ship/coordinator.ts +80 -12
  24. package/dist/assets/src/core/ship/handoff.ts +54 -19
  25. package/dist/assets/src/core/trace/workerTrace.ts +9 -3
  26. package/dist/assets/src/core/types.ts +327 -0
  27. package/dist/assets/src/deploy/liveGate.ts +35 -0
  28. package/dist/assets/src/deploy/restart.ts +12 -11
  29. package/dist/assets/src/execution/residentRefresh.ts +26 -1
  30. package/dist/assets/src/execution/sandboxErrors.ts +122 -4
  31. package/dist/assets/web/dist/.vite/manifest.json +30 -30
  32. package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +1 -0
  33. package/dist/assets/web/dist/assets/{HomePage-DYxC0izY.js → HomePage-BpQRky8B.js} +1 -1
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-DLIpWYOc.js → ResidentDetailPage-BIUXyz6K.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentsIndexPage-6LipuDjR.js → ResidentsIndexPage-BZymgSAb.js} +1 -1
  36. package/dist/assets/web/dist/assets/{RunFoldRow-V-iSy64e.js → RunFoldRow-3m4CPRI4.js} +1 -1
  37. package/dist/assets/web/dist/assets/{RunRoutePage-DUalB1u2.js → RunRoutePage-bgkjkA0p.js} +3 -3
  38. package/dist/assets/web/dist/assets/{RunsIndexPage-B9Ba1KdD.js → RunsIndexPage-8S944AzB.js} +1 -1
  39. package/dist/assets/web/dist/assets/{ScheduledPage-KdjLtD_7.js → ScheduledPage-8bBtG9y3.js} +1 -1
  40. package/dist/assets/web/dist/assets/{SettingsPage-IT5l_NaL.js → SettingsPage-DQeNvfaV.js} +1 -1
  41. package/dist/assets/web/dist/assets/{StatusDot-DBHAl4Il.js → StatusDot-BPE5syBa.js} +1 -1
  42. package/dist/assets/web/dist/assets/{Tooltip-_LEjptLV.js → Tooltip-DkoeZfTs.js} +1 -1
  43. package/dist/assets/web/dist/assets/UnitRoutePage-BUzw--Ii.js +1 -0
  44. package/dist/assets/web/dist/assets/{dist-BcYPGOBL.js → dist-D11y9ZJ4.js} +1 -1
  45. package/dist/assets/web/dist/assets/{main-DUfSE0dj.js → main-B6LcgNM6.js} +2 -2
  46. package/dist/cli.js +2475 -1021
  47. package/package.json +1 -1
  48. package/dist/assets/web/dist/assets/DeliveryPage-NP4g6bQd.js +0 -1
  49. package/dist/assets/web/dist/assets/UnitRoutePage-DPBsvGPR.js +0 -1
@@ -0,0 +1,51 @@
1
+ // The pi registry as the card's catalog (record 0052): the JSON files the
2
+ // installed `@earendil-works/pi-ai` ships under `providers/data/`, keyed
3
+ // catalog file → wire → model id. A block's `catalog` names the file consulted
4
+ // for its cards (default, the block's own name when such a file exists; `none`
5
+ // for no catalog); a card is looked up under the block's wire first (our
6
+ // `openai-chat` is pi's `openai-completions`), then under any wire, since a
7
+ // card of another wire still carries levels, window and price. Nothing here
8
+ // knows a vendor or a model by name — and nothing here touches Node: this
9
+ // module is the seam's types alone, reachable from the Workers' typechecks
10
+ // through the run events' card type. The file-reading implementation is the
11
+ // bot's ./installedModelRegistry.ts; tests hand an in-memory table.
12
+
13
+ import type { Wire } from "./provider.js";
14
+
15
+ /** One card as pi's registry files carry it (only the fields the bot reads). */
16
+ export interface RegistryCard {
17
+ id?: string;
18
+ name?: string;
19
+ /** pi's own wire word (`anthropic-messages`, `openai-completions`, `openai-responses`). */
20
+ api?: string;
21
+ reasoning?: boolean;
22
+ /** Our tiers as pi spells them: `null` refuses a level, an absent tier is
23
+ * not named (the card's reader applies pi's rule). */
24
+ thinkingLevelMap?: Record<string, string | null>;
25
+ input?: string[];
26
+ cost?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
27
+ contextWindow?: number;
28
+ maxTokens?: number;
29
+ compat?: Record<string, unknown>;
30
+ }
31
+
32
+ /** One registry file: pi's wire word → model id → card. */
33
+ export type RegistryFile = Record<string, Record<string, RegistryCard>>;
34
+
35
+ /** The catalog seam the card resolver reads (record 0052): a card by catalog
36
+ * name and wire, or by any wire; a catalog that does not exist reads as no
37
+ * card. The production implementation reads pi's installed files; tests hand
38
+ * a table. */
39
+ export interface ModelRegistry {
40
+ /** The card for `model` in `catalog`, under `wire` first then any wire. */
41
+ card(catalog: string | undefined, wire: Wire, model: string): RegistryCard | undefined;
42
+ /** The file named, or undefined when the catalog does not exist. */
43
+ file(catalog: string): RegistryFile | undefined;
44
+ /** Every catalog file name the library ships. */
45
+ names(): string[];
46
+ }
47
+
48
+ /** Our wire word as pi's registry spells it. */
49
+ export function piWireOf(wire: Wire): string {
50
+ return wire === "openai-chat" ? "openai-completions" : wire;
51
+ }
@@ -93,14 +93,117 @@ export interface Provider {
93
93
  complete(req: CompletionRequest): Promise<CompletionResult>;
94
94
  }
95
95
 
96
+ /** The three wire shapes a provider block may declare (record 0052):
97
+ * Anthropic's Messages API, OpenAI's Chat Completions and OpenAI's Responses
98
+ * API. `openai-responses` is a declaration only in this slice — the proxy
99
+ * still serves two routes (a later slice adds the third) — and a block that names it
100
+ * keeps routing through the compatible shape until then. */
101
+ export const WIRES = ["anthropic-messages", "openai-chat", "openai-responses"] as const;
102
+ export type Wire = (typeof WIRES)[number];
103
+
104
+ /** The legacy `type` words, as the wires they load as for one release. */
105
+ export const WIRE_ALIASES: Readonly<Record<string, Wire>> = {
106
+ anthropic: "anthropic-messages",
107
+ "openai-compatible": "openai-chat",
108
+ };
109
+
110
+ /** One model's operator override under a block's `models.<id>` (record 0052,
111
+ * the operator layer of the card). Every field is optional and wins over the
112
+ * registry card and the wire defaults where it is set. */
113
+ export interface ProviderModelOverride {
114
+ /** Our effort tiers → the wire's word, or null to refuse the tier. */
115
+ levels?: Record<string, string | null>;
116
+ /** The body field the output cap is spelled with on this model. */
117
+ capField?: string;
118
+ /** The model's context window in tokens. */
119
+ window?: number;
120
+ /** Which input kinds the model takes. */
121
+ inputs?: { image?: boolean; document?: boolean };
122
+ /** The model's cache rule. */
123
+ cache?: "automatic" | "markers" | "none" | "unknown";
124
+ /** USD per million tokens, by kind. */
125
+ price?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
126
+ }
127
+
96
128
  export interface ProviderConfig {
129
+ /** The legacy wire word: `anthropic` or `openai-compatible` (record
130
+ * 0052). `wire` is the new spelling; `type` keeps loading as its alias for
131
+ * one release. A block may declare either; validation derives the one it
132
+ * did not. */
97
133
  type: "anthropic" | "openai-compatible";
134
+ /** The wire the block speaks (`anthropic-messages`, `openai-chat`,
135
+ * `openai-responses`). Absent → derived from `type`. */
136
+ wire?: Wire;
137
+ /** The vendor whose models this block serves: a name (default, the block's
138
+ * own) or `model`, which makes the block an aggregator whose vendor is the
139
+ * model id's first segment. */
140
+ vendor?: string;
141
+ /** The pi registry file consulted for this block's cards (default, the
142
+ * block's own name when such a file exists; `none` otherwise). */
143
+ catalog?: string;
144
+ /** Per-model operator overrides, keyed by the model id as the ref spells it. */
145
+ models?: Record<string, ProviderModelOverride>;
146
+ /** Extra body fields merged into every request on the wires whose adapter
147
+ * takes one. Never a control: not decided, not noted, not in the matrix. */
148
+ passthrough?: Record<string, unknown>;
98
149
  /** Env var holding the API key (never put keys in config files). */
99
150
  apiKeyEnv?: string;
100
151
  /** Base URL for openai-compatible providers (e.g. http://localhost:11434/v1). */
101
152
  baseUrl?: string;
102
153
  }
103
154
 
155
+ /** The wire a block speaks: its `wire` when declared, else its legacy `type`. */
156
+ export function wireOf(block: Pick<ProviderConfig, "type" | "wire">): Wire {
157
+ return block.wire ?? WIRE_ALIASES[block.type] ?? "openai-chat";
158
+ }
159
+
160
+ /** One `<block>/<model>` ref read for the vendor it serves (record 0052):
161
+ * the block, the model id as the ref spells it, the vendor, the vendor's own
162
+ * id (the model id less a vendor prefix) and which layer named the vendor. */
163
+ export interface VendorRef {
164
+ block: string;
165
+ model: string;
166
+ vendor: string;
167
+ vendorId: string;
168
+ vendorSource: "declared" | "model" | "block";
169
+ }
170
+
171
+ /** The one vendor parse (record 0052): `parseModelRef` is the only other parser of a
172
+ * ref. A block that declares a vendor name uses it; a block that declares
173
+ * `vendor: model`, or a model id that carries its own vendor prefix
174
+ * (`openrouter/anthropic/claude-sonnet-5`), reads the vendor off the id's
175
+ * first segment; otherwise the block's own name is the vendor. `catalog`
176
+ * never enters: a block named unlike its catalog still serves the vendor the
177
+ * declaration or the id names. */
178
+ export function vendorOf(
179
+ ref: string,
180
+ blocks: Readonly<Record<string, Pick<ProviderConfig, "vendor">>> = {},
181
+ ): VendorRef {
182
+ const { provider: block, model } = parseModelRef(ref);
183
+ const declared = blocks[block]?.vendor;
184
+ const slash = model.indexOf("/");
185
+ if (declared !== undefined && declared !== "model") {
186
+ const prefix = `${declared}/`;
187
+ return {
188
+ block,
189
+ model,
190
+ vendor: declared,
191
+ vendorId: model.startsWith(prefix) ? model.slice(prefix.length) : model,
192
+ vendorSource: "declared",
193
+ };
194
+ }
195
+ if (slash > 0 && (declared === "model" || declared === undefined)) {
196
+ return {
197
+ block,
198
+ model,
199
+ vendor: model.slice(0, slash),
200
+ vendorId: model.slice(slash + 1),
201
+ vendorSource: "model",
202
+ };
203
+ }
204
+ return { block, model, vendor: block, vendorId: model, vendorSource: "block" };
205
+ }
206
+
104
207
  /** The env var Anthropic's own SDK reads when an `anthropic` provider block names none. */
105
208
  export const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
106
209
 
@@ -0,0 +1,181 @@
1
+ // The refusal seam (docs/decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md): every refusal the
2
+ // bot makes is one `Refusal` value with a cause, produced by the stage that
3
+ // cannot proceed and rendered in one place (`renderRefusal` in
4
+ // dispatch/reply.ts). A code has exactly one cause, in one table — a code the
5
+ // table does not know is a type error — so the trace, the run record and the
6
+ // door report count refusals by the same names.
7
+ import type { IncomingMessage } from "./types.js";
8
+
9
+ /** Why the bot refused, in the record's three classes: a different sentence
10
+ * from the person would work (`request`), the person may not (`policy`), or
11
+ * the bot or a dependency cannot act now (`system`). */
12
+ export type RefusalCause = "request" | "policy" | "system";
13
+
14
+ /** The bot's best guess at what the person meant: the person's message with
15
+ * the fix applied, the line as the person would type it, and the evidence
16
+ * that names the match. Produced by a later unit of record 0054's plan;
17
+ * carried here so the seam's
18
+ * shape is complete from the first unit. */
19
+ export interface Guess {
20
+ proposal: IncomingMessage;
21
+ line: string;
22
+ evidence: string;
23
+ }
24
+
25
+ /** One cause per code — the closed table `causeOf` reads. The codes are the
26
+ * inventory's: the dispatch gates' `refuse(code)` outcomes, the click's four
27
+ * `confirmation_*` codes, the two `elsewhere_*` reasons that never reached a
28
+ * span before, the references step's eight reasons (request 2, policy 3,
29
+ * system 3 — the appendix's split), and `uncaught` for the catch-all. */
30
+ const CAUSE_OF = {
31
+ // the dispatch gates (dispatcher.ts `refuse(code)`)
32
+ agent_allowlist: "policy",
33
+ profile_bounded: "policy",
34
+ repo_not_visible: "policy",
35
+ repo_unverified: "system",
36
+ repo_not_onboarded: "request",
37
+ repo_access: "policy",
38
+ pr_head_unknown: "system",
39
+ branch_moved: "system",
40
+ coordinator_thread_live: "system",
41
+ live_agent_allowlist: "policy",
42
+ follow_up_refused: "request",
43
+ elsewhere_agent_allowlist: "policy",
44
+ elsewhere_follow_up_refused: "request",
45
+ which_branch: "request",
46
+ workspace_lost: "system",
47
+ ship_budget: "request",
48
+ setup_failed: "system",
49
+ // the click on a confirmation (confirm.ts)
50
+ confirmation_used: "request",
51
+ confirmation_expired: "request",
52
+ confirmation_foreign: "policy",
53
+ confirmation_unreadable: "system",
54
+ // the references step's eight reasons (record 0037: one sentence, eight codes)
55
+ reference_over_cap: "request",
56
+ reference_rate_limited: "request",
57
+ reference_guest: "policy",
58
+ reference_not_a_member: "policy",
59
+ reference_denied: "policy",
60
+ reference_timed_out: "system",
61
+ reference_never: "system",
62
+ reference_fetch_failed: "system",
63
+ // the catch-all: an uncaught throw in dispatch()
64
+ uncaught: "system",
65
+ // the ship preflight's nine results (record 0054's plan): the old
66
+ // `ship_preflight` code is kept as these codes' prefix, so a query on the
67
+ // old code still finds them; the sentence did not split with the code.
68
+ ship_preflight_channel: "system",
69
+ ship_preflight_permission: "policy",
70
+ ship_preflight_no_repo: "request",
71
+ ship_preflight_pr_unreachable: "system",
72
+ ship_preflight_pr_facts: "system",
73
+ ship_preflight_fork_head: "request",
74
+ ship_preflight_head_unknown: "system",
75
+ ship_preflight_closed_resume: "request",
76
+ ship_preflight_no_task: "request",
77
+ // the plan hand-off's fifteen sentences (two share `plan_history_unavailable`)
78
+ plan_base_unknown: "request",
79
+ plan_routed_seed: "request",
80
+ plan_id_invalid: "request",
81
+ plan_unreadable: "request",
82
+ plan_no_units: "request",
83
+ plan_units_unknown: "request",
84
+ plan_runner_state_unknown: "system",
85
+ plan_runner_live: "system",
86
+ plan_runner_state_unread: "system",
87
+ plan_units_merged: "request",
88
+ plan_history_unavailable: "system",
89
+ plan_runner_conflict: "system",
90
+ plan_instance_orphaned: "system",
91
+ plan_start_failed: "system",
92
+ // the directive and resolve parsers' thrown errors (A6 of the inventory)
93
+ directive_agent: "request",
94
+ directive_effort: "request",
95
+ directive_budget: "request",
96
+ directive_severity: "request",
97
+ directive_renewals: "request",
98
+ provider_unknown: "request",
99
+ // the model card's refusal (record 0052, model-proxy item 11): a control the
100
+ // resolved card does not take, named before any card or model call
101
+ model_card_refused: "request",
102
+ // a follow-up dropped because the run it was folded into was stopped
103
+ follow_up_dropped: "system",
104
+ // the typed-command codes (`InvokeErrorCode`): `chatErrorLine` renders these
105
+ // through the renderer's one line shape; the cause rides `CommandError`.
106
+ command_unauthorized: "policy",
107
+ command_invalid_input: "request",
108
+ command_not_found: "request",
109
+ command_conflict: "system",
110
+ command_unavailable: "system",
111
+ command_busy: "system",
112
+ command_internal: "system",
113
+ // the resident Worker's JSON errors, at the moment the bot receives them
114
+ // (record 0054): the cause is read from the Worker's `error` prefix.
115
+ resident_attach_rejected: "request",
116
+ resident_attach_failed: "system",
117
+ } as const satisfies Record<string, RefusalCause>;
118
+
119
+ /** Every code a refusal may carry. A new refusal site adds its code here with
120
+ * its one cause; an unknown code does not compile. */
121
+ export type RefusalCode = keyof typeof CAUSE_OF;
122
+
123
+ /** The one cause of a code — exhaustive over the union above. */
124
+ export function causeOf(code: RefusalCode): RefusalCause {
125
+ return CAUSE_OF[code];
126
+ }
127
+
128
+ /** Every code the table knows, for tests and reports. */
129
+ export const REFUSAL_CODES: readonly RefusalCode[] = Object.keys(CAUSE_OF) as RefusalCode[];
130
+
131
+ /** One refusal: the cause, the code, the sentence the person reads (built by
132
+ * the producing site, byte-identical to what it said before the seam), the
133
+ * best guess when the site holds one, and the way forward when the cause is
134
+ * `policy` and the text does not already carry it. */
135
+ export interface Refusal {
136
+ cause: RefusalCause;
137
+ code: RefusalCode;
138
+ text: string;
139
+ guess?: Guess;
140
+ wayForward?: string;
141
+ }
142
+
143
+ /** Build a `Refusal` for a code: the cause comes from the one table. */
144
+ export function refusalOf(code: RefusalCode, text: string, extra?: Pick<Refusal, "guess" | "wayForward">): Refusal {
145
+ return { cause: causeOf(code), code, text, ...(extra ?? {}) };
146
+ }
147
+
148
+ /** The one line a `Refusal` reads as, shared by the async renderer and the
149
+ * string-shaped surfaces (`chatErrorLine`): the producer's own text, plus the
150
+ * way forward when the cause is `policy` and one was set apart. */
151
+ export function refusalLine(refusal: Refusal): string {
152
+ if (refusal.cause === "policy" && refusal.wayForward) return `${refusal.text} ${refusal.wayForward}`;
153
+ return refusal.text;
154
+ }
155
+
156
+ /** A typed command's error code as a refusal code with its one cause. */
157
+ export function commandRefusalCode(
158
+ code: "unauthorized" | "invalid_input" | "not_found" | "conflict" | "unavailable" | "busy" | "internal",
159
+ ): RefusalCode {
160
+ return `command_${code}`;
161
+ }
162
+
163
+ /** The cause the Worker's `error` prefix names (record 0054): a wrong or missing ref is
164
+ * the person's to fix (`request`); everything else is the machinery's —
165
+ * `op-unavailable` included: the Worker says it when its command table has no
166
+ * entry for the op the bot sent, a version skew between bot and Worker, not a
167
+ * sentence the person could reword. */
168
+ export function residentErrorCause(error: string): RefusalCause {
169
+ const prefix = error.split(":", 1)[0]?.trim();
170
+ return prefix === "needs-ref" || prefix === "unknown-ref" ? "request" : "system";
171
+ }
172
+
173
+ /** A refusal as a throwable, for producers whose call shape is a throw. */
174
+ export class RefusalError extends Error {
175
+ readonly refusal: Refusal;
176
+ constructor(refusal: Refusal) {
177
+ super(refusal.text);
178
+ this.name = "RefusalError";
179
+ this.refusal = refusal;
180
+ }
181
+ }
@@ -3,6 +3,7 @@
3
3
  // tsconfigs — importing prDescription.ts would drag zod into those graphs.
4
4
  import type { PrDescription, RenderedPointer } from "./prDescriptionTypes.js";
5
5
  import type { HarnessScope } from "./harness/scope.js";
6
+ import type { ModelCard } from "./modelCard.js";
6
7
 
7
8
  /** The `pr_description` review artifact minus the event envelope
8
9
  * (docs/reference/specs/reading-diff.md item 7). */
@@ -122,12 +123,24 @@ export type RunNoteKind =
122
123
  * (docs/reference/specs/run-history.md item 37); the summary says how many calls were
123
124
  * in flight at the kill and how each was settled. Published by the runner. */
124
125
  | "resumed"
126
+ /** A control was decided against the model card and is not native (record
127
+ * 0052): a fallback (`applied` differs from `asked`, `vouched` true) or
128
+ * an unvouched send (`vouched` false). One note per degraded control,
129
+ * published by the dispatcher before the first turn. `why` says what the
130
+ * card could not vouch for; `summary` is the human line. */
131
+ | "control_degraded"
125
132
  /** What the run's session seed could not do (docs/reference/specs/session-log.md
126
133
  * item 9): the log could not be read so the run seeds from the channel, the
127
134
  * newest turn alone was over the seed budget, the previous run's end was
128
135
  * unknown so no line since could be told apart. One note per reason,
129
136
  * published by the dispatcher before the first turn. */
130
137
  | "seed"
138
+ /** This run is a question's Yes (record 0054;
139
+ * docs/reference/specs/run-history.md item 2): the stored proposal went
140
+ * back through `dispatch()` as the requester, and the summary names the
141
+ * question's refusal code. Published by the dispatcher before the first
142
+ * turn. */
143
+ | "redispatch"
131
144
  /** A coding run pushed onto a branch that already heads an open PR without
132
145
  * resubmitting the PR description, and the same run is being given one
133
146
  * bounded extra model turn to submit it (docs/reference/specs/pr-description.md
@@ -217,6 +230,18 @@ export type RunNoteKind =
217
230
  * item 7): the summary names the tool and the rule; the model read the same
218
231
  * reason as the tool's result. Published by the bot's authorize route. */
219
232
  | "tool_refused"
233
+ /** OpenCode withdrew a pending ask before the gate's reply to it landed
234
+ * (harness.md item 2): the server answered the reply 404 and its pending
235
+ * asks no longer listed the ask — the gate refused a sibling call of the
236
+ * same step, and at a reject the binary declines every other pending ask
237
+ * (`packages/core/src/permission.ts:203-220` at the pinned v2.0.3) and
238
+ * ends their step (measured in `opencode/testing/realDriver.test.ts`).
239
+ * The summary names the call, the reply the gate had decided and the
240
+ * sibling's refusal when the step has one (or says no refusal is on the
241
+ * record). Information, not a failure: nothing ran that the gate did not
242
+ * decide, the loop is not stopped, and the step ends by the server's word.
243
+ * Published by the OpenCode bridge. */
244
+ | "ask_withdrawn"
220
245
  /** An OpenCode tool settled under a step this loop never saw start and the
221
246
  * settle was set aside (harness.md item 13): an earlier execution's late
222
247
  * result — the pinned binary's ordinary shape after a hung call's interrupt
@@ -269,7 +294,9 @@ export const RUN_NOTE_KINDS = [
269
294
  "mcp_unavailable",
270
295
  "follow_up",
271
296
  "resumed",
297
+ "control_degraded",
272
298
  "seed",
299
+ "redispatch",
273
300
  "description_turn",
274
301
  "verdict_turn",
275
302
  "cold_sandbox",
@@ -283,6 +310,7 @@ export const RUN_NOTE_KINDS = [
283
310
  "harness_error",
284
311
  "policy_refusal",
285
312
  "tool_refused",
313
+ "ask_withdrawn",
286
314
  "settle_set_aside",
287
315
  "tool_unnamed",
288
316
  "directory_reached",
@@ -389,6 +417,7 @@ export function isHeadMaterial(event: RunEvent): boolean {
389
417
  event.kind === "mcp_unavailable" ||
390
418
  event.kind === "spans_dropped" ||
391
419
  event.kind === "cold_sandbox" ||
420
+ event.kind === "control_degraded" ||
392
421
  event.kind === "ledger_untracked" ||
393
422
  event.kind === "rebind_refused"
394
423
  );
@@ -525,6 +554,14 @@ export type RunEvent =
525
554
  summary: string;
526
555
  mode?: StopMode;
527
556
  actor?: RunActor;
557
+ /** On a `control_degraded` note only (record 0052): which control,
558
+ * the word asked, the word applied, whether a layer vouched for the
559
+ * applied word, and what the card could not vouch for. */
560
+ control?: string;
561
+ asked?: string;
562
+ applied?: string;
563
+ vouched?: boolean;
564
+ why?: string;
528
565
  /** On a `spans_dropped` note only (docs/reference/specs/tracing.md): the runner-clock
529
566
  * interval the dropped setup records covered — a `not recorded` loss. */
530
567
  from?: number;
@@ -624,6 +661,12 @@ export type RunEvent =
624
661
  * written before the word was a scope setting. */
625
662
  harnessScope?: HarnessScope;
626
663
  effort?: string;
664
+ /** The model card this run resolved before its first call (record
665
+ * 0052): the wire, vendor, levels, cap field, window, inputs, cache rule
666
+ * and price source, each with the layer that named it. Absent on a
667
+ * command run and on a record written before the card existed.
668
+ * Additive. */
669
+ card?: ModelCard;
627
670
  repo?: string;
628
671
  ref?: string;
629
672
  pr?: number;
@@ -30,7 +30,7 @@
30
30
  // arrive in the input rather than from the agent registry.
31
31
 
32
32
  import type { ShipRoundOutcome } from "../runEvents.js";
33
- import type { Handoff } from "./handoff.js";
33
+ import type { Handoff, HandoffLanded } from "./handoff.js";
34
34
  import { progressOf, renderRenewal, renewalDecision, type PushedHeadFact, type RenewalDecision } from "./renewal.js";
35
35
  import type { RunStatus } from "../runRecord.js";
36
36
  import { normalizeHead, sameCommit } from "../reviewedHead.js";
@@ -504,8 +504,21 @@ export type PrCheck =
504
504
  * the base and the head) or `no_base` (the instance names no base to
505
505
  * open against, so no create was tried). Absent on a plain check. */
506
506
  unrecovered?: "no_commits" | "no_base";
507
+ /** On a plain check: the branch's commits over the base as GitHub
508
+ * compares them, when the bot could read them. Zero beside a handoff
509
+ * naming where the scope landed is the `already_landed` ending
510
+ * (agent-ship item 12); absent, the fact is unknown and never claimed. */
511
+ aheadOfBase?: number;
512
+ }
513
+ | {
514
+ state: "open";
515
+ prNumber: number;
516
+ url: string;
517
+ headSha?: string;
518
+ autoMergeEnabled?: boolean;
519
+ /** The check runs at the head, when the read asked for them (the ending's facts). */
520
+ checks?: CommitChecksFacts;
507
521
  }
508
- | { state: "open"; prNumber: number; url: string; headSha?: string; autoMergeEnabled?: boolean }
509
522
  | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
510
523
 
511
524
  /** What a step answered. Every bot answer carries `at`, the bot's clock — the machine's time. */
@@ -536,6 +549,11 @@ export type StepReturn =
536
549
  export type UnitEnding =
537
550
  | { kind: "merged"; by: "runner"; pr: PrRef; sha: string; reviewRounds: number }
538
551
  | { kind: "merged"; by: "other"; pr: PrRef; sha: string; mergedAt: string; reviewRounds: number }
552
+ /** Round 0 found the unit's scope already on the base (agent-ship item 12):
553
+ * the coding child's handoff names where it landed and the branch has no
554
+ * commits over the base, so there is no pull request to open or review.
555
+ * The unit is done and its dependents start on a base that carries it. */
556
+ | { kind: "already_landed"; landed: HandoffLanded[]; round: RoundRef; runId: string; reviewRounds: number }
539
557
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
540
558
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
541
559
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
@@ -1268,15 +1286,26 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1268
1286
  [roundNote(round, "aborted")],
1269
1287
  );
1270
1288
  }
1271
- // Round 0 ended without a pull request: the segment is over with the unit
1272
- // unfinished, and the grant decides whether the next opens (decision
1273
- // 0046, Renewal). Progress is read off the child's record — a head pushed
1274
- // to the unit's branch since the segment started, or a handoff that moved —
1275
- // never off its words; the decision then asks the grant's count, the cap
1276
- // and the fit, in that order, and a refusal names the clause. A plain
1277
- // abort keeps its old shape when nothing was pushed under a grant of zero:
1278
- // a clarifying question is not a stop to explain.
1279
1289
  if (round.index === 0) {
1290
+ // The scope already landed (agent-ship item 12): the child's handoff
1291
+ // names where, and the branch carries no commits over the base — two
1292
+ // facts off the record and GitHub, never the child's prose alone. There
1293
+ // is nothing to open, review or renew: the unit is done. Either fact
1294
+ // missing (a handoff that names no landing, commits on the branch, a
1295
+ // compare the bot could not read) leaves the round-0 ending below.
1296
+ const landed = phase.childHandoff?.landed ?? [];
1297
+ if (landed.length > 0 && pr.aheadOfBase === 0)
1298
+ return end(s, { kind: "already_landed", landed, round, runId: phase.runId, reviewRounds: s.reviewRounds }, [
1299
+ roundNote(round, "completed"),
1300
+ ]);
1301
+ // Round 0 ended without a pull request: the segment is over with the unit
1302
+ // unfinished, and the grant decides whether the next opens (decision
1303
+ // 0046, Renewal). Progress is read off the child's record — a head pushed
1304
+ // to the unit's branch since the segment started, or a handoff that moved —
1305
+ // never off its words; the decision then asks the grant's count, the cap
1306
+ // and the fit, in that order, and a refusal names the clause. A plain
1307
+ // abort keeps its old shape when nothing was pushed under a grant of zero:
1308
+ // a clarifying question is not a stop to explain.
1280
1309
  const grant = s.input.grant ?? DEFAULT_GRANT;
1281
1310
  const session = s.input.session;
1282
1311
  const progress = progressOf({
@@ -1656,6 +1685,30 @@ function splitReport(s: UnitPipelineState): string {
1656
1685
  export interface MergeReadyFacts {
1657
1686
  autoMergeEnabled?: boolean;
1658
1687
  merged?: { sha: string; mergedAt: string };
1688
+ /** The check runs at the approved head as the merge door reads them (record 0055). */
1689
+ checks?: CommitChecksFacts;
1690
+ }
1691
+
1692
+ /** The check runs at one commit: how many, which still run, which failed. */
1693
+ export interface CommitChecksFacts {
1694
+ total: number;
1695
+ pending: string[];
1696
+ failed: string[];
1697
+ }
1698
+
1699
+ /** The merge-ready report's headline is a claim about the approved head
1700
+ * (record 0055): a failed check is never called merge-ready, a pending one
1701
+ * is named, green is said, and without the fact the line is unchanged. */
1702
+ function mergeReadyHeadline(rounds: string, url: string, checks: CommitChecksFacts | undefined): string {
1703
+ if (checks === undefined) return `✅ Merge-ready after ${rounds}: ${url}`;
1704
+ if (checks.failed.length > 0) {
1705
+ const pending = checks.pending.length > 0 ? `; pending: ${checks.pending.join(", ")}` : "";
1706
+ return `⚠️ Approved but not merge-ready after ${rounds}: ${url} — CI is red at the approved head: ${checks.failed.join(", ")}${pending}. Fix it and re-review, or rerun a flake; the runner calls a head merge-ready only over green checks.`;
1707
+ }
1708
+ if (checks.pending.length > 0)
1709
+ return `✅ Approved after ${rounds}: ${url} — checks pending at the approved head: ${checks.pending.join(", ")}; merge-ready once they pass.`;
1710
+ if (checks.total === 0) return `✅ Merge-ready after ${rounds}: ${url} — no check reported at the approved head.`;
1711
+ return `✅ Merge-ready after ${rounds}: ${url} — ${checks.total} check${checks.total === 1 ? "" : "s"} green at the approved head.`;
1659
1712
  }
1660
1713
 
1661
1714
  export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts): string {
@@ -1703,9 +1756,16 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1703
1756
  ...(skippedLine ? [skippedLine] : []),
1704
1757
  declinedLine,
1705
1758
  ].join("\n");
1759
+ case "already_landed":
1760
+ // No compare link, no renewal line, no re-issue prompt: there was
1761
+ // nothing to ship, so none of them has a question to answer.
1762
+ return `✅ Already on \`${s.input.base}\`: the unit's scope landed before this attempt — ${e.landed.map((l) => `${l.what} (${l.where})`).join("; ")}. The coding child (run ${e.runId}) found it there and pushed nothing of its own: \`${s.input.unit.branch}\` has no commits over \`${s.input.base}\`, so there is no pull request to open or review. The unit is done and its dependents start on a base that carries it.`;
1706
1763
  case "merge_ready":
1707
1764
  return [
1708
- `✅ Merge-ready after ${rounds}: ${e.pr.url}`,
1765
+ // A merge that already happened outranks the checks: there is no head left to gate.
1766
+ facts?.merged
1767
+ ? `✅ Merge-ready after ${rounds}: ${e.pr.url}`
1768
+ : mergeReadyHeadline(rounds, e.pr.url, facts?.checks),
1709
1769
  verdictLine,
1710
1770
  levelLine,
1711
1771
  grantLine,
@@ -1721,9 +1781,17 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1721
1781
  : "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
1722
1782
  ].join("\n");
1723
1783
  case "merge_refused":
1784
+ // The approved work is on the branch, so the remedy is a person's hand
1785
+ // merge, never a re-run: a seeded plan re-issued afterwards finds the
1786
+ // merged pull request (the pre-check's `merged` by other, or
1787
+ // `already_landed`) and moves on to the dependents. The generated
1788
+ // plan's line already says to re-issue with the PR URL, which takes the
1789
+ // same recognition path.
1724
1790
  return join([
1725
1791
  `⚠️ The review approved ${e.pr.url} but the runner did not merge it: ${e.reason}. A person decides what becomes of the pull request.`,
1726
- reissue,
1792
+ s.input.generated
1793
+ ? reissue
1794
+ : `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.`,
1727
1795
  ]);
1728
1796
  case "round_cap":
1729
1797
  return join([