@coreplane/switchboard 1.250.0 → 1.251.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 (42) hide show
  1. package/dist/assets/config/config.example.yaml +9 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +31 -0
  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/core/authz/policy.ts +4 -0
  7. package/dist/assets/src/core/authz/resource.ts +6 -2
  8. package/dist/assets/src/core/authz/types.ts +2 -0
  9. package/dist/assets/src/core/coordinator/driver.ts +16 -2
  10. package/dist/assets/src/core/modelCard.ts +19 -3
  11. package/dist/assets/src/core/provider.ts +49 -0
  12. package/dist/assets/src/core/refusal.ts +3 -0
  13. package/dist/assets/src/core/runLedger/types.ts +12 -4
  14. package/dist/assets/src/core/runRecord.ts +16 -0
  15. package/dist/assets/src/core/ship/contract.ts +17 -4
  16. package/dist/assets/src/core/ship/coordinator.ts +70 -4
  17. package/dist/assets/web/dist/.vite/manifest.json +58 -52
  18. package/dist/assets/web/dist/assets/DeliveryPage-DUXd-Sl-.js +1 -0
  19. package/dist/assets/web/dist/assets/HomePage-mSiqEEcN.js +2 -0
  20. package/dist/assets/web/dist/assets/{PendingTurnRow-BuRre8it.js → PendingTurnRow-DDhMhrI7.js} +1 -1
  21. package/dist/assets/web/dist/assets/{ResidentDetailPage-Cb3sFkfj.js → ResidentDetailPage-BnEoOnGQ.js} +1 -1
  22. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BZ6n6UxF.js → ResidentsIndexPage-Dxpgf-l-.js} +1 -1
  23. package/dist/assets/web/dist/assets/RunFoldRow-CSo4-vld.js +1 -0
  24. package/dist/assets/web/dist/assets/{RunRoutePage-psSMI3fN.js → RunRoutePage-9klVWhSF.js} +6 -6
  25. package/dist/assets/web/dist/assets/{RunsIndexPage-68YT_RWt.js → RunsIndexPage-BplMIgaw.js} +1 -1
  26. package/dist/assets/web/dist/assets/{ScheduledPage-CBUxbeqN.js → ScheduledPage-B_GgeJrb.js} +1 -1
  27. package/dist/assets/web/dist/assets/{SettingsPage-CBTnZ9Qv.js → SettingsPage-BXX4R113.js} +1 -1
  28. package/dist/assets/web/dist/assets/{StatusDot-BnRjWzFN.js → StatusDot-BOaw8le9.js} +1 -1
  29. package/dist/assets/web/dist/assets/{Tooltip-Brge0wnd.js → Tooltip-DYZZ4l4V.js} +1 -1
  30. package/dist/assets/web/dist/assets/{UnitRoutePage-o6sLju16.js → UnitRoutePage-BaSW5Odq.js} +1 -1
  31. package/dist/assets/web/dist/assets/budgets-BvWYKPsY.js +1 -0
  32. package/dist/assets/web/dist/assets/{dist-rgAhsmE-.js → dist-BCVXeBJ9.js} +1 -1
  33. package/dist/assets/web/dist/assets/indexRow-Bde9OZxG.js +1 -0
  34. package/dist/assets/web/dist/assets/{main-CeRuGONy.js → main-Dkcbtu3u.js} +2 -2
  35. package/dist/assets/web/dist/assets/sseReplay-DPwdsaok.js +9 -0
  36. package/dist/cli.js +975 -239
  37. package/package.json +1 -1
  38. package/dist/assets/web/dist/assets/DeliveryPage-CIfBiINK.js +0 -1
  39. package/dist/assets/web/dist/assets/HomePage-AnycA57D.js +0 -2
  40. package/dist/assets/web/dist/assets/RunFoldRow-BRXkjkgO.js +0 -1
  41. package/dist/assets/web/dist/assets/indexRow-BmK74Vp1.js +0 -1
  42. package/dist/assets/web/dist/assets/sseReplay-DXC7kGbN.js +0 -9
@@ -87,6 +87,14 @@ defaults:
87
87
  # provider default. Skipped for models without effort support.
88
88
  # efforts:
89
89
  # coding: medium
90
+ # How much the bot says about its own doing (docs/reference/specs/routing-and-config.md
91
+ # item 28): quiet (default) — only what needs the person: answers, verdicts,
92
+ # refusals, questions, results, the card's progress; verbose — plus every
93
+ # acknowledgement (a follow-up folded into a live run, a plan handed to the
94
+ # runner, the workspace and budget on the card); debug — plus the router's
95
+ # reason and the ledger's word. Same layering as effort: per-channel/user
96
+ # `verbosity` and a per-request `verbosity:<level>` directive override it.
97
+ # verbosity: quiet
90
98
  # A boundary caps what ANY run in a scope may have — never grants — on the
91
99
  # three axes of a run's profile (docs/reference/specs/routing-and-config.md item 2):
92
100
  # maxMinutes (the wall-clock budget; at least 2), maxIdentity (the credential
@@ -156,6 +164,7 @@ defaults:
156
164
  # users:
157
165
  # slack:U012345:
158
166
  # model: openai/gpt-5
167
+ # verbosity: verbose # hear every acknowledgement of what the bot does for you
159
168
  # harness:
160
169
  # coding: opencode
161
170
 
@@ -779,6 +779,27 @@ export class ConfigDO extends DurableObject<Env> {
779
779
  });
780
780
  }
781
781
 
782
+ /** The thread's pending row when one exists and is inside its ttl, else
783
+ * null. A pure read: expiry is checked here on this object's clock and
784
+ * nothing is deleted — nothing sweeps, and a consume still finds the
785
+ * expired row to name `expired`. */
786
+ async pendingConfirmationByThread(threadKey: string, now: number): Promise<ConfirmationRow | null> {
787
+ const row = this.sql
788
+ .exec<{ id: string; requester: string; expires_at: number; body: string }>(
789
+ `SELECT id, requester, expires_at, body FROM confirmations WHERE thread_key = ?`,
790
+ threadKey,
791
+ )
792
+ .toArray()[0];
793
+ if (!row || row.expires_at <= now) return null;
794
+ return {
795
+ id: row.id,
796
+ threadKey,
797
+ requester: row.requester,
798
+ expiresAt: row.expires_at,
799
+ body: parseStored(row.body, isJsonObject) ?? {},
800
+ };
801
+ }
802
+
782
803
  private readConfirmation(id: string): ConfirmationRow | undefined {
783
804
  const row = this.sql
784
805
  .exec<{ thread_key: string; requester: string; expires_at: number; body: string }>(
@@ -1199,6 +1220,7 @@ const CONFIG_ROUTES = new Set([
1199
1220
  "/config/confirmations/consume",
1200
1221
  "/config/confirmations/cancel",
1201
1222
  "/config/confirmations/cancel-by-thread",
1223
+ "/config/confirmations/pending-by-thread",
1202
1224
  ]);
1203
1225
  const TICKET_STATES: ReadonlySet<string> = new Set<McpTicketState>(MCP_TICKET_STATES);
1204
1226
  /** A confirmation id as the bot mints it (a UUID) — one token, no whitespace, bounded. */
@@ -1284,6 +1306,15 @@ async function handleConfig(pathname: string, body: unknown, env: Env): Promise<
1284
1306
  console.log(`[config/confirmations/cancel] ${click.id} ${"ok" in outcome ? "cancelled" : outcome.refused}`);
1285
1307
  return json(outcome);
1286
1308
  }
1309
+ case "/config/confirmations/pending-by-thread": {
1310
+ if (typeof b.threadKey !== "string" || !b.threadKey) return json({ error: "threadKey required" }, 400);
1311
+ // The stub types this result `never`: workers-types' Serializable rejects
1312
+ // the row's opaque `Record<string, unknown>` body. What arrives is the
1313
+ // object's declared result, so the boundary restates it.
1314
+ const row = (await dO.pendingConfirmationByThread(b.threadKey, systemClock())) as ConfirmationRow | null;
1315
+ console.log(`[config/confirmations/pending-by-thread] ${b.threadKey} ${row === null ? "none" : row.id}`);
1316
+ return json({ row });
1317
+ }
1287
1318
  case "/config/confirmations/cancel-by-thread": {
1288
1319
  if (typeof b.threadKey !== "string" || !b.threadKey) return json({ error: "threadKey required" }, 400);
1289
1320
  if (!Array.isArray(b.actorIds) || !b.actorIds.every((a): a is string => typeof a === "string"))
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.250.0",
3
+ "version": "1.251.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.250.0",
9
+ "version": "1.251.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.250.0",
20448
+ "version": "1.251.0",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.250.0",
3
+ "version": "1.251.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.250.0",
3
- "commit": "4ac4b778bce59eddec48e214def56ad7aa7c62d2",
4
- "builtAt": "2026-09-18T19:28:39.901Z"
2
+ "version": "1.251.0",
3
+ "commit": "95a98b3da9996ee6194236ec9c6b453c18aa6bcf",
4
+ "builtAt": "2026-09-18T22:15:18.238Z"
5
5
  }
@@ -135,6 +135,10 @@ export const POLICY: readonly Rule[] = [
135
135
  // scope from anywhere, a private one only from inside it, `unknown` never.
136
136
  { action: "config:read", resource: "config-scope", resourceKind: "channel", when: [grant("config:write")] },
137
137
  { action: "config:read", resource: "config-scope", resourceKind: "channel", when: [MEMBER_OF] },
138
+ // A THREAD's scope (`config set thread`, routing-and-config item 27): the
139
+ // channel-config right — whoever may set the channel may set a thread in it;
140
+ // never a baseline, so membership alone admits nobody.
141
+ { action: "config:write", resource: "config-scope", resourceKind: "thread", when: [grant("config:write")] },
138
142
  // A user edits only their own scope.
139
143
  { action: "config:write", resource: "config-scope", resourceKind: "user", when: [IS_SELF] },
140
144
 
@@ -31,14 +31,14 @@ export type AttributeName = Exclude<keyof ResourceAttributes, "visibility">;
31
31
  /** The kinds each kinded resource type takes. Types absent here are not kinded. */
32
32
  export const RESOURCE_KINDS: { readonly [T in ResourceType]?: readonly KindOf<T>[] } = {
33
33
  "memory-scope": ["org", "user", "repo", "channel"],
34
- "config-scope": ["channel", "user", "org"],
34
+ "config-scope": ["channel", "user", "thread", "org"],
35
35
  };
36
36
 
37
37
  /** A resource type, or `type/kind` for the kinded ones — the unit a rule row targets. */
38
38
  export type Target =
39
39
  | Exclude<ResourceType, "memory-scope" | "config-scope">
40
40
  | `memory-scope/${"org" | "user" | "repo" | "channel"}`
41
- | `config-scope/${"channel" | "user" | "org"}`;
41
+ | `config-scope/${"channel" | "user" | "thread" | "org"}`;
42
42
 
43
43
  export const TARGET_ATTRIBUTES: Readonly<Record<Target, readonly AttributeName[]>> = {
44
44
  run: ["channelId", "userId", "repo"],
@@ -50,6 +50,9 @@ export const TARGET_ATTRIBUTES: Readonly<Record<Target, readonly AttributeName[]
50
50
  repo: ["repo"],
51
51
  "config-scope/channel": ["channelId"],
52
52
  "config-scope/user": ["userId"],
53
+ // A thread key carries no attribute a condition reads: the one row on it is
54
+ // a bare grant check (the channel-config right, `config set thread`).
55
+ "config-scope/thread": [],
53
56
  "config-scope/org": [],
54
57
  agent: ["name"],
55
58
  command: [],
@@ -136,6 +139,7 @@ export function attributesOf(resource: Resource): ResourceAttributes {
136
139
  return { channelId: resource.id, visibility: "unknown", channelVisibility: resource.visibility ?? "unknown" };
137
140
  case "user":
138
141
  return { userId: resource.id, visibility: "unknown" };
142
+ case "thread":
139
143
  case "org":
140
144
  return { visibility: "unknown" };
141
145
  }
@@ -114,6 +114,8 @@ export type Resource =
114
114
  readonly visibility?: ChannelVisibility;
115
115
  }
116
116
  | { readonly type: "config-scope"; readonly kind: "user"; readonly id: string }
117
+ /** A thread's runtime scope (`config set thread`, routing-and-config item 27); `id` is the thread key. */
118
+ | { readonly type: "config-scope"; readonly kind: "thread"; readonly id: string }
117
119
  | { readonly type: "config-scope"; readonly kind: "org" }
118
120
  | { readonly type: "agent"; readonly name: string }
119
121
  /** List-shaped actions with no single resource (`runs.list`, `friction.report`). */
@@ -172,6 +172,8 @@ interface PlanFacts {
172
172
  grantSource: GrantSource;
173
173
  /** The instance's mark as the plan route answers it: a generated one-unit plan (a `plan` with no `path`). */
174
174
  generated: boolean;
175
+ /** The runs page base the bot answered: the report links a child's write-up to its run page with it. */
176
+ runPageBase?: string;
175
177
  repo: string;
176
178
  base: string;
177
179
  caps: ShipCaps;
@@ -217,6 +219,7 @@ function readPlan(a: BotAnswer): PlanFacts {
217
219
  grantSource:
218
220
  b.grantSource === "run" || b.grantSource === "user" || b.grantSource === "channel" ? b.grantSource : "org",
219
221
  generated: b.generated === true,
222
+ ...(typeof b.runPageBase === "string" && b.runPageBase.length > 0 ? { runPageBase: b.runPageBase } : {}),
220
223
  repo: b.repo,
221
224
  base: b.base,
222
225
  caps: { maxRounds: b.caps.maxRounds, maxMinutes: b.caps.maxMinutes },
@@ -318,7 +321,7 @@ function isCommitChecks(v: unknown): v is { total: number; pending: string[]; fa
318
321
  function prCheckReturn(step: string, a: BotAnswer): StepReturn {
319
322
  const { ok, state, prNumber, url, headSha, sha, mergedAt, at } = a.body;
320
323
  if (ok === true && state === "none") {
321
- const { unrecovered, aheadOfBase } = a.body;
324
+ const { unrecovered, aheadOfBase, prClosed } = a.body;
322
325
  return {
323
326
  type: "pr-check",
324
327
  step,
@@ -328,6 +331,9 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
328
331
  // The branch's commits over the base, when the bot could read them
329
332
  // (agent-ship item 12): zero is the `already_landed` ending's fact.
330
333
  ...(typeof aheadOfBase === "number" ? { aheadOfBase } : {}),
334
+ // The followed pull request verified closed unmerged (issue 1799):
335
+ // the machine must not brief a review round on it.
336
+ ...(prClosed === true ? { prClosed: true } : {}),
331
337
  },
332
338
  at,
333
339
  };
@@ -453,12 +459,19 @@ async function perform(
453
459
  case "pr-check":
454
460
  // `recover` rides only after a dead coding child: the bot opens the pull
455
461
  // request from the pushed branch itself instead of answering `none`.
462
+ // `pr` is the machine's adopted pull request (issue 1799): the bot
463
+ // follows it when nothing heads the unit's branch and answers its live
464
+ // state instead of `none` over a minutes-old record fact.
456
465
  return prCheckReturn(
457
466
  action.step,
458
467
  answerOf(
459
468
  "pr-check",
460
469
  await step.do(action.step, STEP_CONFIG, () =>
461
- call(bot, "pr-check", { ...tag, ...(action.recover !== undefined ? { recover: action.recover } : {}) }),
470
+ call(bot, "pr-check", {
471
+ ...tag,
472
+ ...(action.recover !== undefined ? { recover: action.recover } : {}),
473
+ ...(action.pr !== undefined ? { pr: action.pr } : {}),
474
+ }),
462
475
  ),
463
476
  ),
464
477
  );
@@ -537,6 +550,7 @@ async function runUnit(
537
550
  grant: plan.grant,
538
551
  grantSource: plan.grantSource,
539
552
  generated: plan.generated,
553
+ ...(plan.runPageBase !== undefined ? { runPageBase: plan.runPageBase } : {}),
540
554
  ...(resume !== undefined ? { resume } : {}),
541
555
  ...(lastPush !== undefined ? { lastPush } : {}),
542
556
  ...(session !== undefined ? { session } : {}),
@@ -10,7 +10,7 @@
10
10
  // catalog lives in ./modelRegistry.ts.
11
11
 
12
12
  import { EFFORT_LEVELS, type Effort } from "../effort.js";
13
- import { vendorOf, wireOf, type ProviderConfig, type Wire } from "./provider.js";
13
+ import { billerHarnessProvider, vendorOf, wireOf, type ProviderConfig, type Wire } from "./provider.js";
14
14
  import type { RegistryCard } from "./modelRegistry.js";
15
15
 
16
16
  /** Which layer named a field: the operator's block, the registry card, or the
@@ -335,13 +335,29 @@ export function decideControls(card: ModelCard, asked: AskedControls): ControlDe
335
335
  why: windowVouched ? "" : `no layer names the window; compacting at ${card.window}`,
336
336
  });
337
337
 
338
- const cacheNative = card.cache !== "unknown";
338
+ // A `markers` rule needs a harness-side write that places the markers
339
+ // (record 0052's amendment: the harness write names the biller's own
340
+ // provider). The Anthropic wire's own packages place per-block breakpoints;
341
+ // on the chat wire a biller the table names caches the aggregator's own way
342
+ // — the pinned OpenCode binary exempts the openrouter route from per-block
343
+ // placement, so the write carries OpenRouter's top-level
344
+ // `cache_control: { type: "ephemeral" }` via `settings.extraBody` (measured
345
+ // in `opencode/testing/realDriver.test.ts`), and pi's compat sends the
346
+ // per-block markers. A biller served generically degrades, never a silent
347
+ // `native`.
348
+ const markersUnplaced =
349
+ card.cache === "markers" && card.wire === "openai-chat" && billerHarnessProvider(card.block) === undefined;
350
+ const cacheNative = card.cache !== "unknown" && !markersUnplaced;
339
351
  decisions.push({
340
352
  control: "cache",
341
353
  outcome: cacheNative ? "native" : "degraded",
342
354
  applied: card.cache,
343
355
  vouched: cacheNative,
344
- why: cacheNative ? "" : `no layer names ${card.model}'s cache rule`,
356
+ why: cacheNative
357
+ ? ""
358
+ : markersUnplaced
359
+ ? `no harness-side provider vouches for the "${card.block}" biller's cache markers; the rule goes out unvouched`
360
+ : `no layer names ${card.model}'s cache rule`,
345
361
  });
346
362
 
347
363
  return decisions;
@@ -209,6 +209,55 @@ export function vendorOf(
209
209
  return { block, model, vendor: block, vendorId: model, vendorSource: "block" };
210
210
  }
211
211
 
212
+ /** The harness-side provider a block's biller implies (record 0052's
213
+ * amendment: the harness write names the biller's own provider, never a
214
+ * generic alias). Keyed by the biller — the block's name — for the billers
215
+ * whose protocol a harness bundles a provider for. The wires with a package
216
+ * of their own (`anthropic-messages` → `@ai-sdk/anthropic`,
217
+ * `openai-responses` → `@ai-sdk/openai`) need no entry: the wire names the
218
+ * package. A chat-wire biller not here is served generically
219
+ * (`@ai-sdk/openai-compatible`), under which a `markers` cache rule cannot be
220
+ * vouched for (`decideControls`): the generic provider places no cache
221
+ * breakpoints. */
222
+ export interface BillerHarnessProvider {
223
+ /** The AI SDK package OpenCode's configuration names for the biller
224
+ * (`openCodeProviderPackage` adds the `aisdk:` prefix). */
225
+ openCodePackage: string;
226
+ /** The compat words pi keys on the biller's identity, copied once from pi's
227
+ * own completions detection of that biller and never inferred from a URL at
228
+ * run time: through the proxy pi sees the bot's URL, so `piModelsJson` must
229
+ * say the words the biller's own base URL would have made pi detect. */
230
+ piCompat: {
231
+ /** How the wire spells reasoning: `reasoning: { effort }` under
232
+ * `"openrouter"`, never the completions shape's flat `reasoning_effort`. */
233
+ thinkingFormat: string;
234
+ /** How a session id would ride the headers, were affinity ever turned on. */
235
+ sessionAffinityFormat: string;
236
+ /** The vendor-qualified id prefixes the biller grants the developer role:
237
+ * any other id is told `supportsDeveloperRole: false`, as pi's own
238
+ * detection would say against the biller directly. */
239
+ developerRoleIdPrefixes: readonly string[];
240
+ };
241
+ }
242
+
243
+ /** The biller-to-provider table: one row per biller a harness speaks natively
244
+ * — OpenCode's package (U44) and pi's compat words (U45) side by side. */
245
+ export const BILLER_HARNESS_PROVIDERS: Readonly<Record<string, BillerHarnessProvider>> = {
246
+ openrouter: {
247
+ openCodePackage: "@openrouter/ai-sdk-provider",
248
+ piCompat: {
249
+ thinkingFormat: "openrouter",
250
+ sessionAffinityFormat: "openrouter",
251
+ developerRoleIdPrefixes: ["anthropic/", "openai/"],
252
+ },
253
+ },
254
+ };
255
+
256
+ /** The biller's harness-side provider, or undefined when it is served generically. */
257
+ export function billerHarnessProvider(biller: string | undefined): BillerHarnessProvider | undefined {
258
+ return biller === undefined ? undefined : BILLER_HARNESS_PROVIDERS[biller];
259
+ }
260
+
212
261
  /** The env var Anthropic's own SDK reads when an `anthropic` provider block names none. */
213
262
  export const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
214
263
 
@@ -56,6 +56,8 @@ const CAUSE_OF = {
56
56
  which_branch: "request",
57
57
  workspace_lost: "system",
58
58
  ship_budget: "request",
59
+ // one pipeline per thread (record 0060): the host key's claim answered thread-live
60
+ ship_thread_live: "request",
59
61
  setup_failed: "system",
60
62
  // the click on a confirmation (confirm.ts)
61
63
  confirmation_used: "request",
@@ -106,6 +108,7 @@ const CAUSE_OF = {
106
108
  directive_budget: "request",
107
109
  directive_severity: "request",
108
110
  directive_renewals: "request",
111
+ directive_verbosity: "request",
109
112
  provider_unknown: "request",
110
113
  // the model card's refusal (record 0052, model-proxy item 11): a control the
111
114
  // resolved card does not take, named before any card or model call
@@ -59,6 +59,14 @@ export interface LiveRunMeta {
59
59
  /** The app that relayed the request for the person (authorization.md item 14): a resume or restart keeps app ∩ person at the gates. */
60
60
  postedBy?: string;
61
61
  effort?: string;
62
+ /** A ship pipeline's parent (record 0060): claimed under the host key
63
+ * (`hostKey.ts`) while `threadKey` here names the thread itself, so every
64
+ * record, notice and rebuilt handle files by the metadata's thread and the
65
+ * occupancy readers that key on the ledger's key column never see it. */
66
+ hosted?: true;
67
+ /** The run's index label (the registry sets it at create): on the row so a
68
+ * hosted run listed from the ledger reads as its registry view does. */
69
+ label?: string;
62
70
  ref?: string;
63
71
  headSha?: string;
64
72
  pr?: number;
@@ -96,10 +104,10 @@ export interface LiveRunMeta {
96
104
  request?: Record<string, unknown>;
97
105
  /** The router's decision when it chose the run's preset (routing-and-config
98
106
  * item 21) — the same fields the record's `route` event carries. On the row
99
- * so a resume repaints the card as it was (` · routed: <reason>`, the
100
- * parts, the override footer on the close) and a reclaim closing the run
101
- * knows it was routed without reading the events. Absent for a preset a
102
- * person, a scope or the default chose. */
107
+ * so a resume repaints the card as it was (the `route reason:` note at
108
+ * debug, the parts) and a record built from the row knows it was routed
109
+ * without reading the events. Absent for a preset a person, a scope or the
110
+ * default chose. */
103
111
  route?: {
104
112
  preset: string;
105
113
  reason: string;
@@ -39,6 +39,12 @@ import {
39
39
  // reached `finish`.
40
40
  export type RunStatus = "completed" | "stopped_soft" | "stopped_hard" | "failed" | "interrupted";
41
41
 
42
+ /** How every store-only reader renders a record still in its provisional
43
+ * window (run-history.md item 27's third state): not `interrupted` (a real
44
+ * terminal state) and not `running` (the store cannot know). One string for
45
+ * the CLI's `runs list`/`runs get` and the web's index row and run page. */
46
+ export const PROVISIONAL_LABEL = "unfinished — no finish recorded";
47
+
42
48
  const RUN_STATUSES: readonly RunStatus[] = ["completed", "stopped_soft", "stopped_hard", "failed", "interrupted"];
43
49
 
44
50
  /** Every `runs.*` id: checked before any store call. */
@@ -115,6 +121,13 @@ export interface RunRecord {
115
121
  stepCount?: number;
116
122
  schema?: number;
117
123
  status: RunStatus;
124
+ /** Present on a tombstone record still in its provisional window — the
125
+ * start-of-run `interrupted` or the drain-deadline upgrade — before the
126
+ * run's final write lands. Absent on every final (finished) record. A
127
+ * store-only reader must render a provisional record as "unfinished — no
128
+ * finish recorded" rather than as `interrupted`, because the run may still
129
+ * be live in a registry the reader cannot see (run-history.md item 27). */
130
+ provisional?: true;
118
131
  /** The failure by name, when a `failed` run has one (item 57):
119
132
  * `policy_refusal`, the provider refused the run's model call under its
120
133
  * usage policy. Absent on a run that did not fail, on one that failed for
@@ -944,6 +957,9 @@ export function isRunRecord(v: unknown): v is RunRecord {
944
957
  return false;
945
958
  }
946
959
  if (!RUN_STATUSES.includes(r.status as RunStatus)) return false;
960
+ // A provisional tombstone carries `provisional: true`; any other value (false,
961
+ // a string, etc.) is a malformed record — only the presence of the flag matters.
962
+ if (r.provisional !== undefined && r.provisional !== true) return false;
947
963
  if (!isFiniteNumber(r.eventCount) || !isFiniteNumber(r.storedEventCount)) return false;
948
964
  if (typeof r.truncated !== "boolean") return false;
949
965
  if (!Array.isArray(r.events)) return false;
@@ -439,17 +439,30 @@ 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
443
+ * (agent-ship item 13; agent-coding item 9). "Its cheapest proving checks"
444
+ * left the choice to the child, and children chose wrong: prettier was
445
+ * reported clean while `format:check` was red, and hygiene imprints reached
446
+ * CI that `hygiene:check` would have caught locally. Naming the commands
447
+ * makes each gate a receipt — the exit line goes into the handoff's verified
448
+ * list, and a gate the child could not run is unproven, never claimed clean. */
449
+ export const FAST_GATES_BEFORE_PUSH =
450
+ "The fast gates, before every push: `npx prettier --check` on the changed files, `npm run hygiene:check`, " +
451
+ "`npm run specs:check`, and `tsc --noEmit` on the touched project under `NODE_OPTIONS=--max-old-space-size=6144`. " +
452
+ "Paste each command's exit line into the handoff's verified list; a gate you could not run goes under unproven " +
453
+ "and is never claimed clean.";
454
+
442
455
  function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
443
456
  const branch = rebase.branch ? `\`${rebase.branch}\`` : "the unit's branch";
444
457
  const onto = rebase.onto ? `\`${rebase.onto}\`` : "the merged parent";
445
458
  return (
446
459
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
447
460
  `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +
448
- `Push the branch as soon as the change exists and its cheapest proving checks pass — before the project's ` +
461
+ `Push the branch as soon as the change exists and the fast gates pass — before the project's ` +
449
462
  `full verification, which runs after that push with any fix as a further commit; an unpushed tree does not ` +
450
- `survive the run's end. Right before each push, fetch ${onto} again and rebase once more if it moved while ` +
451
- `you worked, so the pull request is not born conflicting. At the wind-down note, commit and push what compiles, ` +
452
- `say what does not, then answer. ${TIMEOUT_ON_LONG_COMMANDS}`
463
+ `survive the run's end. ${FAST_GATES_BEFORE_PUSH} Right before each push, fetch ${onto} again and rebase ` +
464
+ `once more if it moved while you worked, so the pull request is not born conflicting. At the wind-down note, ` +
465
+ `commit and push what compiles, say what does not, then answer. ${TIMEOUT_ON_LONG_COMMANDS}`
453
466
  );
454
467
  }
455
468
 
@@ -453,6 +453,13 @@ export type CoordinatorAction =
453
453
  * submitted description when the record holds one) instead of answering
454
454
  * `none` over stranded work. */
455
455
  recover?: { runId: string };
456
+ /** The pull request the machine has adopted (`state.pr`), when it holds
457
+ * one: the child may have worked that pull request's own head branch,
458
+ * not the unit's (issue 1799), so when nothing heads the unit's branch
459
+ * the bot follows this number and answers the pull request's LIVE state
460
+ * — open at a fresh head, merged, or closed (`prClosed`) — never `none`
461
+ * over a record fact written minutes earlier. */
462
+ pr?: number;
456
463
  }
457
464
  | { type: "merge"; step: string; prNumber: number; headSha: string }
458
465
  /** Wait for the intake's checks-settled event at the approved head, bounded as the fallback. */
@@ -509,6 +516,10 @@ export type PrCheck =
509
516
  * naming where the scope landed is the `already_landed` ending
510
517
  * (agent-ship item 12); absent, the fact is unknown and never claimed. */
511
518
  aheadOfBase?: number;
519
+ /** The action's followed pull request (`pr`) was verified CLOSED
520
+ * unmerged: the machine must not brief a review round on it. Absent when
521
+ * nothing was followed or the follow could not read the pull request. */
522
+ prClosed?: boolean;
512
523
  }
513
524
  | {
514
525
  state: "open";
@@ -704,6 +715,10 @@ export interface UnitPipelineInput {
704
715
  * pre-check finds the open pull request still at exactly this head, there is
705
716
  * nothing to code and the attempt starts at the review round. */
706
717
  lastPush?: string;
718
+ /** The runs page base (`<PUBLIC_BASE_URL>/runs`), the plan route's answer:
719
+ * the report's pointer at a child's write-up links its run page with it and
720
+ * names the run id without it — the machine never reads an environment. */
721
+ runPageBase?: string;
707
722
  }
708
723
 
709
724
  type Phase =
@@ -935,6 +950,9 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
935
950
  type: "pr-check",
936
951
  step: `${roundStep(s, p.round)}/pr-check`,
937
952
  ...(p.dead !== undefined ? { recover: { runId: p.runId } } : {}),
953
+ // The adopted pull request rides the check so the bot can follow it
954
+ // when nothing heads the unit's branch (issue 1799).
955
+ ...(s.pr !== undefined ? { pr: s.pr.number } : {}),
938
956
  };
939
957
  case "merge":
940
958
  return { type: "merge", step: `${unit}/merge/${p.n}`, prNumber: p.pr.number, headSha: p.headSha };
@@ -1286,6 +1304,22 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1286
1304
  [roundNote(round, "aborted")],
1287
1305
  );
1288
1306
  }
1307
+ // Nothing heads the unit's branch, but the machine holds the round's pull
1308
+ // request (`pr_opened` off the child's record, or an earlier round's
1309
+ // adoption): the child worked on that pull request's own head branch, not
1310
+ // the unit's — a re-issued task in the thread of an existing pull request
1311
+ // (issue 1799). The round HAS its pull request — at round 0 and after a
1312
+ // findings step alike, since the same child keeps repushing that branch
1313
+ // through every later round — so it carries on to the (re-)review at the
1314
+ // head the child pushed through the shared open settle (a findings step
1315
+ // that repushed nothing keeps its abort, item 7), instead of the unit
1316
+ // ending "no pull request" or "closed out from under" over work that
1317
+ // stands. Never over a followed answer that VERIFIED the pull request
1318
+ // closed (`prClosed`): a review briefed on a closed pull request reviews
1319
+ // nothing, so the endings below stand — and are then truthful. Dead
1320
+ // children never reach here: their endings are above.
1321
+ if (s.pr !== undefined && pr.prClosed !== true)
1322
+ return roundOnOpenPr(s, phase, { prNumber: s.pr.number, url: s.pr.url });
1289
1323
  if (round.index === 0) {
1290
1324
  // The scope already landed (agent-ship item 12): the child's handoff
1291
1325
  // names where, and the branch carries no commits over the base — two
@@ -1370,6 +1404,18 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
1370
1404
  [roundNote(round, "aborted")],
1371
1405
  );
1372
1406
  }
1407
+ return roundOnOpenPr(s, phase, pr);
1408
+ }
1409
+
1410
+ /** The round carried on its open pull request: the pr-check's `open` answer,
1411
+ * or — when nothing heads the unit's branch — the pull request the machine
1412
+ * already holds (issue 1799), at the head the child pushed. */
1413
+ function roundOnOpenPr(
1414
+ s: UnitPipelineState,
1415
+ phase: Extract<Phase, { at: "pr-check" }>,
1416
+ pr: { prNumber: number; url: string; headSha?: string },
1417
+ ): Transition {
1418
+ const { round } = phase;
1373
1419
  const head = pr.headSha ?? phase.childHead;
1374
1420
  const next: UnitPipelineState = { ...s, pr: { number: pr.prNumber, url: pr.url } };
1375
1421
  if (round.kind === "findings") {
@@ -1647,6 +1693,18 @@ function dispositionFor(s: UnitPipelineState, finding: Finding, round: number):
1647
1693
  return undefined;
1648
1694
  }
1649
1695
 
1696
+ /** Where a child's write-up lives, for the report's pointer (agent-ship item
1697
+ * 12): the child's own message in the thread is the single copy of the detail
1698
+ * — posted moments before the unit-end — so the report points at it instead
1699
+ * of repeating it: the run page when the plan named the base, the run id
1700
+ * otherwise. Undefined when no run is known to point at. */
1701
+ function writeUpPointer(s: UnitPipelineState, kind: RoundKind, runId: string | undefined): string | undefined {
1702
+ if (runId === undefined) return undefined;
1703
+ const base = s.input.runPageBase;
1704
+ const at = base !== undefined ? `${base.replace(/\/+$/, "")}/${encodeURIComponent(runId)}` : `run ${runId}`;
1705
+ return `The ${presetOf(kind)} child's write-up is its own message above in this thread — the single copy of the detail (${at}).`;
1706
+ }
1707
+
1650
1708
  /** How the budget went, in the card's words: coding, review, waiting minutes. */
1651
1709
  function budgetSplitLine(spent: ShipBudgetSpent, maxMinutes: number): string {
1652
1710
  const min = (ms: number) => Math.round(ms / MIN);
@@ -1815,7 +1873,11 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1815
1873
  case "stopped":
1816
1874
  return join([
1817
1875
  `${e.mode === "hard" ? "⛔" : "⏹"} Ship stopped by operator (${e.mode} stop) after ${rounds}.${prLine}`,
1818
- e.finalReply,
1876
+ writeUpPointer(
1877
+ s,
1878
+ e.round.kind,
1879
+ e.round.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
1880
+ ),
1819
1881
  e.postedReview
1820
1882
  ? "ℹ️ A changes-requested review was posted this round before the stop — its findings stand on the PR."
1821
1883
  : undefined,
@@ -1823,22 +1885,26 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1823
1885
  ]);
1824
1886
  case "aborted":
1825
1887
  return join([
1826
- e.finalReply,
1827
1888
  e.reason,
1889
+ writeUpPointer(
1890
+ s,
1891
+ e.round?.kind ?? "coding",
1892
+ e.round?.kind === "review" ? s.reviewRunByRound[e.round.index] : s.lastCodingRunId,
1893
+ ),
1828
1894
  e.renewal !== undefined ? `🔁 Not renewed: ${e.renewal.line}.` : undefined,
1829
1895
  `⚠️ Ship aborted after ${rounds}.`,
1830
1896
  reissue,
1831
1897
  ]);
1832
1898
  case "continued":
1833
1899
  return join([
1834
- e.finalReply,
1900
+ writeUpPointer(s, e.round.kind, e.runId),
1835
1901
  `🔁 Segment ${e.segment - 1} ended at its lease with the unit unfinished — ${e.line}. Segment ${e.segment} opens in this thread${e.from !== undefined ? ` from \`${e.from.slice(0, 7)}\`` : ""} under a fresh ${s.input.caps.maxMinutes}-minute lease, with this segment's write-up as its request; ${e.renewalsLeft} renewal${e.renewalsLeft === 1 ? "" : "s"} remain${e.spendUsd !== null ? `, $${e.spendUsd.toFixed(2)} spent so far` : ""}.`,
1836
1902
  budgetSplitLine(e.spent, s.input.caps.maxMinutes),
1837
1903
  ]);
1838
1904
  case "no_verdict":
1839
1905
  return join([
1840
1906
  `⚠️ 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.`,
1841
- e.finalReply ? `Review round's final message:\n\n${e.finalReply}` : undefined,
1907
+ writeUpPointer(s, e.round.kind, s.reviewRunByRound[e.round.index]),
1842
1908
  `⚠️ Ship aborted after ${rounds}.`,
1843
1909
  reissue,
1844
1910
  ]);