@coreplane/switchboard 1.249.0 → 1.250.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 (49) hide show
  1. package/dist/assets/config/config.example.yaml +38 -5
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +229 -1
  3. package/dist/assets/deploy/cloudflare-resident/Dockerfile +12 -3
  4. package/dist/assets/deploy/cloudflare-resident/refresh.ts +5 -3
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +62 -4
  6. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +7 -0
  7. package/dist/assets/package-lock.json +3 -3
  8. package/dist/assets/package.json +3 -2
  9. package/dist/assets/project.json +4 -0
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/agents/registry.ts +1 -1
  12. package/dist/assets/src/core/authz/policy.ts +6 -0
  13. package/dist/assets/src/core/budgets.ts +13 -0
  14. package/dist/assets/src/core/coordinator/contract.ts +120 -0
  15. package/dist/assets/src/core/coordinator/driver.ts +1 -1
  16. package/dist/assets/src/core/prDescriptionTypes.ts +35 -0
  17. package/dist/assets/src/core/provider.ts +8 -3
  18. package/dist/assets/src/core/refusal.ts +11 -0
  19. package/dist/assets/src/core/runEvents.ts +49 -2
  20. package/dist/assets/src/core/runFriction.ts +2 -0
  21. package/dist/assets/src/core/runLedger/decisions.ts +9 -1
  22. package/dist/assets/src/core/runLedger/sessionLog.ts +10 -0
  23. package/dist/assets/src/core/runLedger/transcript.ts +13 -3
  24. package/dist/assets/src/core/runLedger/types.ts +60 -2
  25. package/dist/assets/src/core/runRecord.ts +18 -0
  26. package/dist/assets/src/core/ship/coordinator.ts +9 -1
  27. package/dist/assets/src/core/trace/attrs.ts +13 -2
  28. package/dist/assets/src/core/trace/workerTrace.ts +3 -2
  29. package/dist/assets/src/core/types.ts +3 -0
  30. package/dist/assets/src/execution/residentRefresh.ts +26 -1
  31. package/dist/assets/src/execution/sandboxErrors.ts +8 -0
  32. package/dist/assets/web/dist/.vite/manifest.json +30 -30
  33. package/dist/assets/web/dist/assets/DeliveryPage-CIfBiINK.js +1 -0
  34. package/dist/assets/web/dist/assets/{HomePage-BpQRky8B.js → HomePage-AnycA57D.js} +1 -1
  35. package/dist/assets/web/dist/assets/{ResidentDetailPage-BIUXyz6K.js → ResidentDetailPage-Cb3sFkfj.js} +1 -1
  36. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BZymgSAb.js → ResidentsIndexPage-BZ6n6UxF.js} +1 -1
  37. package/dist/assets/web/dist/assets/{RunFoldRow-3m4CPRI4.js → RunFoldRow-BRXkjkgO.js} +1 -1
  38. package/dist/assets/web/dist/assets/{RunRoutePage-bgkjkA0p.js → RunRoutePage-psSMI3fN.js} +3 -3
  39. package/dist/assets/web/dist/assets/{RunsIndexPage-8S944AzB.js → RunsIndexPage-68YT_RWt.js} +1 -1
  40. package/dist/assets/web/dist/assets/{ScheduledPage-8bBtG9y3.js → ScheduledPage-CBUxbeqN.js} +1 -1
  41. package/dist/assets/web/dist/assets/{SettingsPage-DQeNvfaV.js → SettingsPage-CBTnZ9Qv.js} +1 -1
  42. package/dist/assets/web/dist/assets/{StatusDot-BPE5syBa.js → StatusDot-BnRjWzFN.js} +1 -1
  43. package/dist/assets/web/dist/assets/{Tooltip-DkoeZfTs.js → Tooltip-Brge0wnd.js} +1 -1
  44. package/dist/assets/web/dist/assets/{UnitRoutePage-BUzw--Ii.js → UnitRoutePage-o6sLju16.js} +1 -1
  45. package/dist/assets/web/dist/assets/{dist-D11y9ZJ4.js → dist-rgAhsmE-.js} +1 -1
  46. package/dist/assets/web/dist/assets/{main-B6LcgNM6.js → main-CeRuGONy.js} +2 -2
  47. package/dist/cli.js +3535 -2408
  48. package/package.json +1 -1
  49. package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +0 -1
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.249.0",
3
- "commit": "1145eea8ffdd6a2bd1ae8f1b26a4919db2caf47d",
4
- "builtAt": "2026-09-18T07:16:19.891Z"
2
+ "version": "1.250.0",
3
+ "commit": "4ac4b778bce59eddec48e214def56ad7aa7c62d2",
4
+ "builtAt": "2026-09-18T19:28:39.901Z"
5
5
  }
@@ -148,7 +148,7 @@ export function statusCardRule(examples = '"Implement the fix", "Run the test su
148
148
  );
149
149
  }
150
150
 
151
- const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate — \`npm run check:pr-title -- "<title>"\` — and submit only a title it accepts; the same gate refuses the PR in CI, and on Switchboard's own repository the tool refuses the same titles with the gate's own sentence, so fix the title and resubmit rather than pushing on. The body is a fixed-size MAP for the reader with everything for agents collapsed under it; every field is capped in visible characters (a link's URL is not counted) and the tool refuses an object over a cap naming the field and the count — cut and resubmit. Prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. BEFORE authoring the pointers, load the \`pr-description\` skill with use_skill — it defines how to choose at most seven pointers, the mechanical anchor rules and what goes below the fold; follow it for every PR.
151
+ const PR_DESCRIPTION_TEMPLATE = `PR description — submit it with the submit_pr_description tool for EVERY PR (this is the default, not something to wait to be asked for). Switchboard renders the GitHub body from the object you submit, so never author PR-body markdown yourself. Before submitting, judge your title with the ${PR_TITLE_GUARD} gate — \`npm run check:pr-title -- "<title>"\` — and submit only a title it accepts; the same gate refuses the PR in CI, and on Switchboard's own repository the tool refuses the same titles with the gate's own sentence, so fix the title and resubmit rather than pushing on. The body is a fixed-size MAP for the reader with everything for agents collapsed under it; every field is capped in visible characters (a link's URL is not counted) and the tool refuses an object over a cap naming every field over it with the count, how many visible characters to remove and a prefix that fits — take the prefix as is, or remove at least that many visible characters (shortening a URL removes nothing), and resubmit once. Prose is unwrapped — no hard line breaks inside a paragraph. Always hyperlink the triggering issue/request. Never fabricate validation — state exactly what you ran and the real result. BEFORE authoring the pointers, load the \`pr-description\` skill with use_skill — it defines how to choose at most seven pointers, the mechanical anchor rules and what goes below the fold; follow it for every PR.
152
152
  EVERY PR includes one that already exists when you push — opened by a person, by dependabot, or by an earlier run. After EVERY push to such a PR: read its current title and body (\`github_issue_get\` with the PR number works for pull requests; \`gh pr view\` where gh exists), judge them against the change as it now stands at the pushed head, and submit the object that describes the PR as it is NOW — carry forward what the existing body says that is still true (a dependency bump's release notes belong in why), add what you changed, and anchor the pointers at the new head. Switchboard replaces the PR's title and body with your rendering. A description that describes an earlier state of its branch is a bug; "it is someone else's PR" is never a reason to leave it.
153
153
  - **title** (≤72 characters in all): the changelog line — \`type(scope): what a reader can now do or expect\`, one change, one clause, present tense. The type and the scope spend the same budget as the description, so the description is short; the body carries the rest. The tool refuses a longer title naming the count, and so does the gate in CI.
154
154
  - **TL;DR** (\`tldr\`, rendered first, ≤300): two sentences for a reader with zero context — what this PR does and why it matters.
@@ -91,6 +91,12 @@ export const POLICY: readonly Rule[] = [
91
91
  // `all` and a named `grants` entry hold it).
92
92
  { action: "costs:write", resource: "command", when: [grant("costs:write")] },
93
93
 
94
+ // ── providers ────────────────────────────────────────────────────────────
95
+ // `providers check` reads the provider's own endpoints on request: a browser
96
+ // session's read baseline, a grant everywhere else — an on-request provider
97
+ // read is never a chat baseline, like the costs reads.
98
+ { action: "providers:read", resource: "command", when: [grant("providers:read")] },
99
+
94
100
  // ── repos ────────────────────────────────────────────────────────────────
95
101
  { action: "repo:read", resource: "command", when: [grant("repo:read")] },
96
102
  { action: "repo:write", resource: "repo", when: [grant("repo:write")] },
@@ -85,6 +85,19 @@ export const DRAIN = {
85
85
  defaultMinutes: 60,
86
86
  } as const;
87
87
 
88
+ /** How long an intake receipt row is kept on the run history object
89
+ * (docs/reference/specs/run-history.md item 59; docs/decisions/0058): the larger of one day
90
+ * and the reconnect catch-up window the write named plus the drain deadline
91
+ * (`DRAIN.maxMinutes`, the longest a fleet drain may last), so a catch-up
92
+ * that runs after the longest allowed drain still reads the verdict instead
93
+ * of deciding the reply again. The window is clamped to a month so a
94
+ * misconfigured writer cannot make retention unbounded. */
95
+ export const INTAKE_WINDOW_MAX_MS = 30 * DAY_MS;
96
+ export function intakeReceiptRetentionMs(catchUpWindowMs: number): number {
97
+ const window = Math.min(Math.max(0, catchUpWindowMs), INTAKE_WINDOW_MAX_MS);
98
+ return Math.max(DAY_MS, window + minutesToMs(DRAIN.maxMinutes));
99
+ }
100
+
88
101
  /** The presets that run the tool loop, and the one pipeline preset. */
89
102
  export const LOOP_PRESETS = ["general", "coding", "review", "research", "explore", "conductor"] as const;
90
103
  export type LoopPreset = (typeof LOOP_PRESETS)[number];
@@ -81,6 +81,124 @@ export function runFinishedEventType(runId: string): string {
81
81
  return `${RUN_FINISHED_EVENT_PREFIX}${runId}`;
82
82
  }
83
83
 
84
+ /** A message into a thread an unfinished unit owns (record 0051's reply-as-event rule): one row
85
+ * of the unit's event list, appended by the dispatcher, folded into the
86
+ * unit's next coding spawn — or run as one fresh turn at the unit's end.
87
+ * `mode` is a receipt of the owner's state at append (record 0051): `steer` into a
88
+ * live run or between rounds, `wake` into an idle owner, `interrupt` into an
89
+ * owner idle after a stop — never a switch the sender fills. */
90
+ export type ThreadEventMode = "steer" | "wake" | "interrupt";
91
+
92
+ export interface ThreadEventAttachment {
93
+ mediaType: string;
94
+ data: string;
95
+ name?: string;
96
+ }
97
+
98
+ export interface ThreadEvent {
99
+ /** Assigned by the store's append, in arrival order, per unit. */
100
+ seq: number;
101
+ /** The channel's message id, when the platform gave one. */
102
+ id?: string;
103
+ /** The sender (platform-namespaced) and the display name the channel knew. */
104
+ sender: string;
105
+ senderName?: string;
106
+ text: string;
107
+ attachments?: ThreadEventAttachment[];
108
+ /** How many attachments were dropped because the event was over the cap. */
109
+ attachmentsDropped?: number;
110
+ /** How many characters were cut from the end of `text` because the row was
111
+ * still over the cap without any attachment. */
112
+ textDropped?: number;
113
+ mode: ThreadEventMode;
114
+ at: number;
115
+ /** The spawn step or run that consumed the event; absent while unconsumed. */
116
+ consumedBy?: string;
117
+ }
118
+
119
+ /** The most one durable event row may weigh, serialized — the durable inbox's
120
+ * own cap (`DURABLE_INBOX_MAX_BYTES`), restated here because this contract is
121
+ * node-free and the state Worker enforces it too. */
122
+ export const THREAD_EVENT_MAX_BYTES = 400 * 1024;
123
+
124
+ const serializedBytes = (v: unknown): number => new TextEncoder().encode(JSON.stringify(v)).length;
125
+
126
+ /** The event under the cap: attachments ride when the serialized row fits,
127
+ * else all of them are dropped and the row says how many — all or nothing,
128
+ * like the durable inbox (a partial carry would hand the model some of the
129
+ * sender's attachments as if they were all of them). A row still over the cap
130
+ * with no attachment left — a text alone past 400 KiB, possible through the
131
+ * ingress body — has its text cut from the end until the row fits, and the
132
+ * row says how many characters went: no event escapes the constant's promise.
133
+ * `TextEncoder`, not `Buffer`: both stores — the bot's and the state Worker's —
134
+ * apply it. */
135
+ export function capThreadEvent<T extends Omit<ThreadEvent, "seq"> & { seq?: number }>(
136
+ event: T,
137
+ ): T & Pick<ThreadEvent, "attachmentsDropped" | "textDropped"> {
138
+ if (serializedBytes(event) <= THREAD_EVENT_MAX_BYTES) return event;
139
+ const attachments = event.attachments;
140
+ let capped: T & Pick<ThreadEvent, "attachmentsDropped" | "textDropped"> = event;
141
+ if (attachments && attachments.length > 0) {
142
+ const { attachments: _dropped, ...rest } = event;
143
+ capped = { ...rest, attachmentsDropped: attachments.length } as typeof capped;
144
+ if (serializedBytes(capped) <= THREAD_EVENT_MAX_BYTES) return capped;
145
+ }
146
+ // Text-only overflow: the row's shape is fixed except for the text, so the
147
+ // text is cut — by characters, so a multibyte cut never splits a code point
148
+ // pair the decoder would read as garbage — and shrunk until the bytes fit.
149
+ const text = capped.text;
150
+ const overhead = serializedBytes({ ...capped, text: "", textDropped: text.length });
151
+ let keep = Math.max(0, Math.min(text.length, THREAD_EVENT_MAX_BYTES - overhead));
152
+ for (;;) {
153
+ const cut = { ...capped, text: text.slice(0, keep), textDropped: text.length - keep };
154
+ if (keep === 0 || serializedBytes(cut) <= THREAD_EVENT_MAX_BYTES) return cut;
155
+ keep = Math.floor(keep * 0.9);
156
+ }
157
+ }
158
+
159
+ const isThreadEventAttachment = (v: unknown): v is ThreadEventAttachment =>
160
+ isObject(v) &&
161
+ typeof v.mediaType === "string" &&
162
+ typeof v.data === "string" &&
163
+ (v.name === undefined || typeof v.name === "string");
164
+
165
+ const isThreadEventMode = (v: unknown): v is ThreadEventMode => v === "steer" || v === "wake" || v === "interrupt";
166
+
167
+ /** Structural check on an event row from outside the process. */
168
+ export function isThreadEvent(v: unknown): v is ThreadEvent {
169
+ if (!isObject(v)) return false;
170
+ const r = v;
171
+ if (typeof r.seq !== "number" || !Number.isInteger(r.seq) || r.seq < 1) return false;
172
+ // The fixed fields are bounded too, so the cap's text cut has a floor to
173
+ // land on: a row whose id or names alone weighed the cap could never fit.
174
+ if (!isOptionalText(r.id)) return false;
175
+ if (!isText(r.sender)) return false;
176
+ if (!isOptionalText(r.senderName)) return false;
177
+ if (typeof r.text !== "string") return false;
178
+ if (r.attachments !== undefined && (!Array.isArray(r.attachments) || !r.attachments.every(isThreadEventAttachment)))
179
+ return false;
180
+ if (r.attachmentsDropped !== undefined && !isCount(r.attachmentsDropped)) return false;
181
+ if (r.textDropped !== undefined && !isCount(r.textDropped)) return false;
182
+ if (!isThreadEventMode(r.mode)) return false;
183
+ if (!isFinite(r.at)) return false;
184
+ if (!isOptionalText(r.consumedBy)) return false;
185
+ return true;
186
+ }
187
+
188
+ /** The payload-free nudge the dispatcher sends an instance when a thread
189
+ * event lands on one of its units (record 0051's reply-as-event rule): the relay's
190
+ * alphabet — letters, digits, `_` and `-`, at most 100 characters, a colon
191
+ * refused — so the type is the two ids joined by `-`. The send addresses the
192
+ * instance by id (`workflow.get(id)`), so the type only has to name the unit
193
+ * within it: when the two ids together overflow the cap, the INSTANCE id is
194
+ * clipped and the unit is kept whole, and both ends compute the same string. */
195
+ export const UNIT_NUDGE_EVENT_PREFIX = "unit-nudge-";
196
+ export function unitNudgeEventType(key: { instanceId: string; unit: string }): string {
197
+ const suffix = `-${key.unit}`;
198
+ const room = 100 - UNIT_NUDGE_EVENT_PREFIX.length - suffix.length;
199
+ return `${UNIT_NUDGE_EVENT_PREFIX}${key.instanceId.slice(0, room)}${suffix}`;
200
+ }
201
+
84
202
  /** The event the bot's GitHub check-run intake sends a merge-waiting parent:
85
203
  * the type carries the head sha (hex — inside the platform's alphabet), so a
86
204
  * driver waiting at that head matches its own event and any other head's is
@@ -278,6 +396,8 @@ const MAX_ROUNDS = 200;
278
396
  const isText = (v: unknown, max = MAX_TEXT): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
279
397
  const isOptionalText = (v: unknown): boolean => v === undefined || isText(v);
280
398
  const isFinite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
399
+ /** A count the writers produce: a non-negative integer, never a fraction, a negative or NaN. */
400
+ const isCount = (v: unknown): v is number => typeof v === "number" && Number.isInteger(v) && v >= 0;
281
401
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
282
402
  const isPr = (v: unknown): boolean => isObject(v) && isFinite(v.number) && isText(v.url, 2048);
283
403
  const isResume = (v: unknown): boolean =>
@@ -145,7 +145,7 @@ export function readBotAnswer(
145
145
 
146
146
  /** The answers the bot itself calls a passing condition — the step is asked
147
147
  * again, under the policy. Everything else, refusals included, is the machine's. */
148
- const TRANSIENT = new Set(["github_unavailable", "no_channel", "thread_failed", "unit_not_started"]);
148
+ const TRANSIENT = new Set(["github_unavailable", "no_channel", "thread_failed", "unit_not_started", "not_host"]);
149
149
  export function transientRefusal(answer: BotAnswer): string | undefined {
150
150
  const { ok, error, message } = answer.body;
151
151
  if (ok !== false || typeof error !== "string" || !TRANSIENT.has(error)) return undefined;
@@ -37,6 +37,41 @@ export interface RenderedPointer extends Pointer {
37
37
  anchor: RenderedPrAnchor;
38
38
  }
39
39
 
40
+ /** One reason a submitted description was refused, as the refusal and the
41
+ * `description_refused` run note carry it: the zod path (`risk`,
42
+ * `pointers.0.text`) and the schema's message; on a cap issue also the
43
+ * characters to remove (visible ones — raw for the title) and the longest
44
+ * prefix of the field that fits, cut at a word boundary (`fitToCap`), absent
45
+ * when no word of it fits. */
46
+ export interface DescriptionIssue {
47
+ path: string;
48
+ message: string;
49
+ remove?: number;
50
+ prefix?: string;
51
+ }
52
+
53
+ /** The refused object as the `description_refused` note records it: JSON,
54
+ * nested at most four objects deep, and nothing else. The record crosses the
55
+ * run store's RPC boundary, whose typing walks every field: a field of
56
+ * `unknown` types the whole record `never`, and a recursive alias is
57
+ * "excessively deep" to it, so the nesting is spelled out level by level
58
+ * (as `RouteInputValue` is). A description is three deep at most —
59
+ * `pointers[i].anchor` — so one level is to spare; `recordedJson` stores a
60
+ * deeper value as its JSON text. */
61
+ export type RecordedJsonLeaf = string | number | boolean | null;
62
+ export type RecordedJsonObject1 = { readonly [key: string]: RecordedJsonLeaf | ReadonlyArray<RecordedJsonLeaf> };
63
+ export type RecordedJson1 =
64
+ RecordedJsonLeaf | RecordedJsonObject1 | ReadonlyArray<RecordedJsonLeaf | RecordedJsonObject1>;
65
+ export type RecordedJsonObject2 = { readonly [key: string]: RecordedJson1 };
66
+ export type RecordedJson2 =
67
+ RecordedJsonLeaf | RecordedJsonObject2 | ReadonlyArray<RecordedJsonLeaf | RecordedJsonObject2>;
68
+ export type RecordedJsonObject3 = { readonly [key: string]: RecordedJson2 };
69
+ export type RecordedJson3 =
70
+ RecordedJsonLeaf | RecordedJsonObject3 | ReadonlyArray<RecordedJsonLeaf | RecordedJsonObject3>;
71
+ export type RecordedJsonObject4 = { readonly [key: string]: RecordedJson3 };
72
+ export type RecordedJson =
73
+ RecordedJsonLeaf | RecordedJsonObject4 | ReadonlyArray<RecordedJsonLeaf | RecordedJsonObject4>;
74
+
40
75
  /** The PR description as data (docs/decisions/0050): the map above the fold
41
76
  * (tldr, why, pointers, feedbackWanted, risk, verified), every field capped
42
77
  * so the map's size does not grow with the diff, and the collapsed half
@@ -95,9 +95,8 @@ export interface Provider {
95
95
 
96
96
  /** The three wire shapes a provider block may declare (record 0052):
97
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. */
98
+ * API. Each is a proxy route of its own (`PROXY_PATHS`): an `openai-responses`
99
+ * block runs on `/v1/responses`, pinned and metered like the other two. */
101
100
  export const WIRES = ["anthropic-messages", "openai-chat", "openai-responses"] as const;
102
101
  export type Wire = (typeof WIRES)[number];
103
102
 
@@ -123,6 +122,12 @@ export interface ProviderModelOverride {
123
122
  cache?: "automatic" | "markers" | "none" | "unknown";
124
123
  /** USD per million tokens, by kind. */
125
124
  price?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
125
+ /** The answer shapes the model can produce for a forced one-call turn (the
126
+ * router's, intake's): `tool` — a forced tool call — and `text` — the
127
+ * one-JSON-object text contract. Absent, both are assumed; an empty list
128
+ * declares neither, and a load whose intake would classify on such a card
129
+ * is refused by name (routing-and-config item 27). */
130
+ answers?: ("tool" | "text")[];
126
131
  }
127
132
 
128
133
  export interface ProviderConfig {
@@ -22,6 +22,17 @@ export interface Guess {
22
22
  evidence: string;
23
23
  }
24
24
 
25
+ /** A command handler's guess hint (record 0054): the corrected chat form and
26
+ * the evidence, without the full proposal — the proposal is synthesised at
27
+ * the invocation point from the original message and this hint's `line`. The
28
+ * handler carries this lighter type on `CommandError.guess`; the adapter
29
+ * (`invokeChatCommand`, `answerCommand`) builds the `Guess` that
30
+ * `renderRefusal` expects. */
31
+ export interface CommandGuessHint {
32
+ line: string;
33
+ evidence: string;
34
+ }
35
+
25
36
  /** One cause per code — the closed table `causeOf` reads. The codes are the
26
37
  * inventory's: the dispatch gates' `refuse(code)` outcomes, the click's four
27
38
  * `confirmation_*` codes, the two `elsewhere_*` reasons that never reached a
@@ -1,7 +1,7 @@
1
1
  // Types only, and from the zod-free module deliberately: this file is part of
2
2
  // the node-free contract the memory Worker and web app compile with their own
3
3
  // tsconfigs — importing prDescription.ts would drag zod into those graphs.
4
- import type { PrDescription, RenderedPointer } from "./prDescriptionTypes.js";
4
+ import type { DescriptionIssue, PrDescription, RecordedJson, RenderedPointer } from "./prDescriptionTypes.js";
5
5
  import type { HarnessScope } from "./harness/scope.js";
6
6
  import type { ModelCard } from "./modelCard.js";
7
7
 
@@ -146,6 +146,15 @@ export type RunNoteKind =
146
146
  * bounded extra model turn to submit it (docs/reference/specs/pr-description.md
147
147
  * item 5). Published by the dispatcher before that turn. */
148
148
  | "description_turn"
149
+ /** `submit_pr_description` refused the object (docs/reference/specs/pr-description.md
150
+ * item 5): the summary counts the fields over their cap and names them
151
+ * (or the issues, when none is a cap), `description` is the object as
152
+ * submitted — redacted like the accepted `pr_description` event's, and not
153
+ * necessarily a valid `PrDescription` — and `issues` the refusal's list
154
+ * with each cap's count to remove and the prefix that fits, so the record
155
+ * says what the model changed between one submit and the next. Published
156
+ * by the tool, before it answers. */
157
+ | "description_refused"
149
158
  /** A review run's loop ended on a pull request without `submit_verdict`, and
150
159
  * the same run is being given one bounded extra model turn to call it
151
160
  * (docs/reference/specs/agent-review.md item 5; verdictTurn.ts). Published by
@@ -273,7 +282,15 @@ export type RunNoteKind =
273
282
  /** The native loop's stuck-loop guard: the same tool call failed identically
274
283
  * six times in a row and the run was forced into its write-up. Written by
275
284
  * no loop since that loop's deletion; a record from before it may carry it. */
276
- | "stuck_loop";
285
+ | "stuck_loop"
286
+ /** OpenCode's reject cascade ended the execution `interrupted` after the bot
287
+ * refused one of a step's two (or more) calls — the binary declines every
288
+ * other pending ask at a reject and ends the step `session.step.failed
289
+ * {aborted}`, the execution ending `session.execution.interrupted` — and the
290
+ * model never read the refusal. The loop re-prompts with the refusal so the
291
+ * model can continue, exactly as it does after a single refusal. Published
292
+ * by the OpenCode bridge. */
293
+ | "decline_cascade";
277
294
 
278
295
  /** Every `RunNoteKind`, as a value (a reader that filters notes by kind uses
279
296
  * this; adding a kind to the union without adding it here is a type error). */
@@ -298,6 +315,7 @@ export const RUN_NOTE_KINDS = [
298
315
  "seed",
299
316
  "redispatch",
300
317
  "description_turn",
318
+ "description_refused",
301
319
  "verdict_turn",
302
320
  "cold_sandbox",
303
321
  "ledger_untracked",
@@ -316,6 +334,7 @@ export const RUN_NOTE_KINDS = [
316
334
  "directory_reached",
317
335
  "budget_salvage",
318
336
  "stuck_loop",
337
+ "decline_cascade",
319
338
  ] as const satisfies readonly RunNoteKind[];
320
339
  type _EveryKindListed = [RunNoteKind] extends [(typeof RUN_NOTE_KINDS)[number]] ? true : never;
321
340
  const _everyKindListed: _EveryKindListed = true;
@@ -566,6 +585,12 @@ export type RunEvent =
566
585
  * interval the dropped setup records covered — a `not recorded` loss. */
567
586
  from?: number;
568
587
  to?: number;
588
+ /** On a `description_refused` note only: the refused object as submitted
589
+ * (JSON, not necessarily a valid `PrDescription`; `RecordedJson` says
590
+ * why it is typed level by level), every string leaf redacted, and the
591
+ * issues the refusal named. */
592
+ description?: RecordedJson;
593
+ issues?: DescriptionIssue[];
569
594
  spanId?: string;
570
595
  seq?: number;
571
596
  at?: number;
@@ -600,6 +625,13 @@ export type RunEvent =
600
625
  * run sent rather than a person (a parent's `send_to_run`), that run's
601
626
  * id (docs/reference/specs/agent-conductor.md item 8). */
602
627
  source?: { url?: string; channel?: string; user?: string; run?: string };
628
+ /** How the turn was delivered when it consumed a unit's thread events
629
+ * (record 0051's mode-as-receipt rule): the mode read off the owner's state when
630
+ * each event arrived — a receipt, never a switch — and the sequence
631
+ * numbers consumed, so the record names the events it folded. Absent on
632
+ * every run that consumed none. */
633
+ mode?: "steer" | "wake" | "interrupt";
634
+ consumed?: number[];
603
635
  seq?: number;
604
636
  at?: number;
605
637
  }
@@ -826,6 +858,13 @@ export type RunEvent =
826
858
  * `base` is absent when the spawn knew none; the post-step then falls to
827
859
  * the coordinator store's `instance.base`. Additive: unknown → ignored. */
828
860
  | { type: "coordinator_tag"; parentInstanceId: string; unit?: string; base?: string; seq?: number; at?: number }
861
+ /** The plan runner instance a ship run's hand-off created (record 0051 R2;
862
+ * docs/reference/specs/run-history.md item 2): published by the ship branch
863
+ * after `handOffToCoordinator` succeeds, straight to the registry like
864
+ * `pr_opened`, and projected onto `RunRecord.instanceId` the way
865
+ * `coordinator_tag` is — so the thread's owner rule can find the instance
866
+ * from the page's ship run. Additive: unknown → ignored. */
867
+ | { type: "ship_handoff"; instanceId: string; seq?: number; at?: number }
829
868
  /** The review post-step's outcome when the verdict landed
830
869
  * (docs/reference/specs/agent-review.md item 18): the pull request it was
831
870
  * posted to, the head it was pinned to (the carried head after a rebase,
@@ -915,6 +954,14 @@ export type RunEvent =
915
954
  seq?: number;
916
955
  at?: number;
917
956
  }
957
+ /** A refusal the door made ([record 0054](../../docs/decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md),
958
+ * as amended: every refusal is a run record; run-history.md item 2): the
959
+ * code, its one cause, and the sentence the person read — redacted and
960
+ * capped like a route receipt (`ROUTE_RECEIPT_CAP`). Exactly one per `door`
961
+ * record, published by `recordRefusal` beside the redacted request, so the
962
+ * door report counts every refusal — a gate refusal before any command is
963
+ * bound included — from the run store alone. Additive: unknown → ignored. */
964
+ | { type: "refusal"; code: string; cause: string; text: string; seq?: number; at?: number }
918
965
  /** The span records (docs/reference/specs/tracing.md): published, counted and stored like
919
966
  * every other event, read as timing and never as content. */
920
967
  | SpanStartEvent
@@ -422,9 +422,11 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
422
422
  ev.type === "pr_description" ||
423
423
  ev.type === "pr_opened" ||
424
424
  ev.type === "coordinator_tag" ||
425
+ ev.type === "ship_handoff" ||
425
426
  ev.type === "review_posted" ||
426
427
  ev.type === "ship_round" ||
427
428
  ev.type === "route" ||
429
+ ev.type === "refusal" ||
428
430
  ev.type === "reference" ||
429
431
  ev.type === "lease" ||
430
432
  ev.type === "pushed_head"
@@ -2,7 +2,7 @@
2
2
  // Durable Object applies them inside one transaction; the in-memory ledger
3
3
  // applies them in tests; both agree because this is the only copy.
4
4
 
5
- import type { ClaimResult, FenceResult, LivePhase } from "./types.js";
5
+ import type { ClaimResult, FenceResult, IntakeReceipt, IntakeWriteResult, LivePhase } from "./types.js";
6
6
 
7
7
  /** One live run per thread. The existing row, if any, is what `live_runs` holds
8
8
  * for the thread; the same run re-claimed by its owner is idempotent (a retry
@@ -26,6 +26,14 @@ export function decideClaim(
26
26
  };
27
27
  }
28
28
 
29
+ /** First writer wins on an intake receipt (item 59): the existing row, if
30
+ * any, is what `intake_receipts` holds for the key; only when none stands
31
+ * does this write land, and every caller acts on the STORED row. */
32
+ export function decideIntakeInsert(existing: IntakeReceipt | undefined, receipt: IntakeReceipt): IntakeWriteResult {
33
+ if (existing) return { inserted: false, stored: existing };
34
+ return { inserted: true, stored: receipt };
35
+ }
36
+
29
37
  /** What an accepted claim does to the thread's row (item 42). `insert`: no row.
30
38
  * `promote`: the owner's claim WITH a prompt on its own `attaching` row — the
31
39
  * prompt, tools, card and state land and the phase goes `live`, identity and
@@ -84,6 +84,16 @@ export function roleOfStoredRow(json: string): "user" | "assistant" | undefined
84
84
  return role === "user" || role === "assistant" ? role : undefined;
85
85
  }
86
86
 
87
+ /** The platform-namespaced id of the person who authored the turn a stored
88
+ * row belongs to (e.g. `slack:U…`); absent for machine turns, compaction
89
+ * rows, unreadable rows, and rows written before record 0057. */
90
+ export function actorOfStoredRow(json: string): string | undefined {
91
+ const stored = parseStored(json);
92
+ if (!stored || "compaction" in stored) return undefined;
93
+ const actor = (stored as { actor?: unknown }).actor;
94
+ return typeof actor === "string" ? actor : undefined;
95
+ }
96
+
87
97
  /** The notepad's size (record 0035, "The notepad"): one document per session,
88
98
  * written whole, at most this many UTF-8 bytes; `notes` refuses over it naming
89
99
  * the size, and the object's write route does too. */
@@ -23,10 +23,13 @@ import {
23
23
  // ~4.2 MB on the wire.
24
24
 
25
25
  /** The stored form of one part: the turn's role rides on every row so a turn
26
- * is reconstructible from its rows alone. */
26
+ * is reconstructible from its rows alone. The actor is the platform-namespaced
27
+ * id of the person who authored the turn (e.g. `slack:U…`); absent for
28
+ * machine turns and rows written before record 0057. */
27
29
  export interface StoredPart {
28
30
  role: ChatMessage["role"];
29
31
  part: ContentPart | (ContentPart & { dataRef: string });
32
+ actor?: string;
30
33
  }
31
34
 
32
35
  /** The stored form of a compaction row: no role, no part — the entry alone. */
@@ -46,11 +49,14 @@ const isCompaction = (v: ChatMessage | StoredCompaction): v is StoredCompaction
46
49
 
47
50
  /** One turn → its rows (and any externalized attachments). Refuses a part the
48
51
  * row budget cannot hold: a transcript is never truncated. A compaction entry
49
- * is one row at its index, part 0. */
52
+ * is one row at its index, part 0. The optional `actor` is the
53
+ * platform-namespaced id of the person who authored the turn (absent for
54
+ * machine turns, compaction rows and rows written before record 0057). */
50
55
  export function turnRows(
51
56
  idx: number,
52
57
  message: ChatMessage | StoredCompaction,
53
58
  opts: { partBytes?: number; attachmentRefBytes?: number } = {},
59
+ actor?: string,
54
60
  ): { rows: TranscriptRow[]; attachments: TranscriptAttachment[] } {
55
61
  const partBytes = opts.partBytes ?? TRANSCRIPT_PART_BYTES;
56
62
  const refBytes = opts.attachmentRefBytes ?? ATTACHMENT_REF_BYTES;
@@ -73,7 +79,11 @@ export function turnRows(
73
79
  attachments.push({ ref, mediaType: part.mediaType, data: part.data });
74
80
  stored = { ...(part as ContentPart), data: "", dataRef: ref } as StoredPart["part"];
75
81
  }
76
- const json = JSON.stringify({ role: message.role, part: stored } satisfies StoredPart);
82
+ const json = JSON.stringify({
83
+ role: message.role,
84
+ part: stored,
85
+ ...(actor !== undefined ? { actor } : {}),
86
+ } satisfies StoredPart);
77
87
  const bytes = utf8ByteLength(json);
78
88
  if (bytes > partBytes) {
79
89
  throw new Error(`transcript: part ${i} of turn ${idx} is ${bytes} bytes, over the ${partBytes} row budget`);
@@ -250,7 +250,65 @@ export interface CompactionEntry {
250
250
 
251
251
  /** The rows a step write carries, each at its log index: the previous step's
252
252
  * results and this step's assistant turn as messages, and pi's compaction
253
- * entry as a row of its own between them. */
254
- export type TranscriptTurn = { idx: number; message: ChatMessage } | { idx: number; compaction: CompactionEntry };
253
+ * entry as a row of its own between them. The optional `actor` on a message
254
+ * turn is the platform-namespaced id of the person who authored it (record
255
+ * 0057); absent for machine turns, compaction rows and pre-0057 rows. */
256
+ export type TranscriptTurn =
257
+ { idx: number; message: ChatMessage; actor?: string } | { idx: number; compaction: CompactionEntry };
255
258
 
256
259
  export type AppendableEvent = RunEvent & { seq: number };
260
+
261
+ /** One intake verdict as the ledger stores it (docs/reference/specs/run-history.md item
262
+ * 59; docs/decisions/0058): keyed by the message (`<channel>:<ts>`), written
263
+ * first-writer-wins so one unmentioned thread reply is decided once across
264
+ * processes and the reconnect catch-up reads the verdict instead of deciding
265
+ * again. The shape is the intake seam's (`src/core/intake.ts` re-exports it);
266
+ * spelled out here because this file is shared with the state Worker. */
267
+ export interface IntakeReceipt {
268
+ verdict: "addressed" | "silent";
269
+ reason: string;
270
+ source: "model" | "mode" | "error" | "timeout";
271
+ mode: "mention" | "classify";
272
+ /** The `<provider>/<model>` ref the verdict ran on. */
273
+ model: string;
274
+ /** The deciding process's generation counter, for telling a retried write's
275
+ * own landed row from another writer's. */
276
+ gen: number;
277
+ threadKey: string;
278
+ /** Epoch ms. */
279
+ decidedAt: number;
280
+ }
281
+
282
+ /** What an intake write answers: whether THIS write landed, and the row that
283
+ * stands — the first writer's, whoever that was. */
284
+ export interface IntakeWriteResult {
285
+ inserted: boolean;
286
+ stored: IntakeReceipt;
287
+ }
288
+
289
+ /** `listIntake`'s filters: a thread's rows, rows since an instant, or both. */
290
+ export interface IntakeQuery {
291
+ threadKey?: string;
292
+ since?: number;
293
+ }
294
+
295
+ /** The receipt as the Worker route validates it: every field present and of
296
+ * its type, the enums closed — a malformed receipt is 400, never stored. */
297
+ export function isIntakeReceipt(v: unknown): v is IntakeReceipt {
298
+ if (typeof v !== "object" || v === null) return false;
299
+ const r = v as Record<string, unknown>;
300
+ return (
301
+ (r.verdict === "addressed" || r.verdict === "silent") &&
302
+ typeof r.reason === "string" &&
303
+ (r.source === "model" || r.source === "mode" || r.source === "error" || r.source === "timeout") &&
304
+ (r.mode === "mention" || r.mode === "classify") &&
305
+ typeof r.model === "string" &&
306
+ typeof r.gen === "number" &&
307
+ Number.isFinite(r.gen) &&
308
+ typeof r.threadKey === "string" &&
309
+ r.threadKey.length > 0 &&
310
+ r.threadKey.length <= 256 &&
311
+ typeof r.decidedAt === "number" &&
312
+ Number.isFinite(r.decidedAt)
313
+ );
314
+ }
@@ -190,6 +190,11 @@ export interface RunRecord {
190
190
  * item 48), stored at the claim so a retried spawn finds its run. Present
191
191
  * exactly when `parentInstanceId` is — both or neither, never one alone. */
192
192
  idempotencyKey?: string;
193
+ /** The plan runner instance this run's hand-off created (record 0051 R2;
194
+ * item 2): the last `ship_handoff` event, folded at the assembly like the
195
+ * coordinator tag. Present only on a ship run whose hand-off succeeded;
196
+ * a record written before the event has none. */
197
+ instanceId?: string;
193
198
  /** Where the run's conversation started (item 52): `channel` — its own
194
199
  * thread's history, as for every run a person, a schedule or a coordinator
195
200
  * started — or `parent` — a spawned child seeded from its parent's text
@@ -266,6 +271,16 @@ export function leaseOfEvents(events: readonly RunEvent[]): RunLease | undefined
266
271
  return undefined;
267
272
  }
268
273
 
274
+ /** The plan runner instance a ship run's events say its hand-off created —
275
+ * the last `ship_handoff` wins — or nothing (record 0051 R2): projected onto
276
+ * `RunRecord.instanceId` the way `coordinator_tag` rides the record, so the
277
+ * thread's owner rule finds the instance from the page's ship run. */
278
+ export function instanceIdOfEvents(events: readonly RunEvent[]): string | undefined {
279
+ let id: string | undefined;
280
+ for (const e of events) if (e.type === "ship_handoff") id = e.instanceId;
281
+ return id;
282
+ }
283
+
269
284
  /** The pull request a run's events say it opened or edited — the last
270
285
  * `pr_opened` wins, as an edit after an open names the same PR — or nothing. */
271
286
  export function prOfEvents(events: readonly RunEvent[]): RunPullRequest | undefined {
@@ -908,6 +923,9 @@ export function isRunRecord(v: unknown): v is RunRecord {
908
923
  (typeof r.idempotencyKey !== "string" || !IDEMPOTENCY_KEY_PATTERN.test(r.idempotencyKey))
909
924
  )
910
925
  return false;
926
+ // The instance a ship run's hand-off created (record 0051 R2; item 2).
927
+ if (r.instanceId !== undefined && (typeof r.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(r.instanceId)))
928
+ return false;
911
929
  if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
912
930
  if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
913
931
  if (r.authenticatedAs !== undefined && typeof r.authenticatedAs !== "string") return false;
@@ -1781,9 +1781,17 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1781
1781
  : "Remaining gate: a person's merge — the runner merges only when the instance's `merge` field says runner, and ship never approves.",
1782
1782
  ].join("\n");
1783
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.
1784
1790
  return join([
1785
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.`,
1786
- 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.`,
1787
1795
  ]);
1788
1796
  case "round_cap":
1789
1797
  return join([