@coreplane/switchboard 1.207.0 → 1.208.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 (26) hide show
  1. package/dist/assets/config/config.example.yaml +11 -11
  2. package/dist/assets/package-lock.json +3 -3
  3. package/dist/assets/package.json +1 -1
  4. package/dist/assets/source.json +3 -3
  5. package/dist/assets/src/core/coordinator/contract.ts +11 -0
  6. package/dist/assets/src/core/coordinator/driver.ts +38 -3
  7. package/dist/assets/src/core/reviewVerdict.ts +40 -0
  8. package/dist/assets/src/core/runEvents.ts +29 -1
  9. package/dist/assets/src/core/runFriction.ts +5 -4
  10. package/dist/assets/src/core/runRecord.ts +10 -1
  11. package/dist/assets/src/core/ship/contract.ts +9 -0
  12. package/dist/assets/src/core/ship/coordinator.ts +75 -20
  13. package/dist/assets/src/core/trace/streamSpans.ts +3 -0
  14. package/dist/assets/src/core/trace/workerTrace.ts +3 -0
  15. package/dist/assets/web/dist/.vite/manifest.json +18 -18
  16. package/dist/assets/web/dist/assets/{ResidentDetailPage-D3P21yeI.js → ResidentDetailPage-CV3wfAIP.js} +1 -1
  17. package/dist/assets/web/dist/assets/{ResidentsIndexPage-DOYqnZ1q.js → ResidentsIndexPage-CLkWc50b.js} +1 -1
  18. package/dist/assets/web/dist/assets/{RunRoutePage-OmvrvPXY.js → RunRoutePage-CQYRfQ_B.js} +4 -4
  19. package/dist/assets/web/dist/assets/{RunsIndexPage-DWbSQtL4.js → RunsIndexPage-BLRPp_gk.js} +1 -1
  20. package/dist/assets/web/dist/assets/{ScheduledPage-CPKfJ4mR.js → ScheduledPage-C-VO4Ddl.js} +1 -1
  21. package/dist/assets/web/dist/assets/{StatusDot-COr8jTyM.js → StatusDot-CIAoBB5Y.js} +1 -1
  22. package/dist/assets/web/dist/assets/{Tooltip-fOqTZkNT.js → Tooltip-DEL1ic4g.js} +1 -1
  23. package/dist/assets/web/dist/assets/{dist-BVjAWgkb.js → dist-CawBR4t8.js} +1 -1
  24. package/dist/assets/web/dist/assets/{main-DZbJaqUb.js → main-CctUbVOl.js} +2 -2
  25. package/dist/cli.js +6591 -6596
  26. package/package.json +1 -1
@@ -278,20 +278,20 @@ workspaceDir: ./workspaces
278
278
  # tokenEnv: MEMORY_TOKEN # env var holding the Worker's bearer (default)
279
279
 
280
280
  # agent:ship pipeline caps (docs/reference/specs/agent-ship.md). `agent:ship in owner/repo:
281
- # <task>` runs the coding → review → fix loop to LGTM as one pipeline: at most
281
+ # <task>` hands the coding → review → fix loop to LGTM to the plan runner — the
282
+ # ShipCoordinator Workflow in the bot Worker, whose rounds are child runs — which
283
+ # needs the `coordinator` ingress entry, its grants, run history on the state
284
+ # Worker and PUBLIC_BASE_URL (docs/how-to/turn-features-on-and-off.md); without
285
+ # them the request is refused naming what is missing. Per unit: at most
282
286
  # `maxRounds` review rounds and `maxMinutes` minutes of wall clock — whichever
283
- # hits first ends the loop, and each child round's own budget is clipped to the
284
- # remaining pipeline time. `maxMinutes` is the ship preset's declared budget:
285
- # a channel or user `boundary.maxMinutes` and a per-message `budget:` directive
286
- # clip it like any preset's, and the card says so. Defaults shown; both must be
287
- # integers >= 1.
287
+ # hits first ends the unit, and each child round's own budget is clipped to the
288
+ # remaining time. `maxMinutes` is the ship preset's declared budget: a channel or
289
+ # user `boundary.maxMinutes` and a per-message `budget:` directive clip it like
290
+ # any preset's, and the card says so. Defaults shown; both must be integers >= 1;
291
+ # any other key under `ship` fails the load by name.
288
292
  # ship:
289
- # maxRounds: 3 # review rounds per pipeline
293
+ # maxRounds: 3 # review rounds per unit
290
294
  # maxMinutes: 120 # the ship preset's wall-clock budget in minutes (the registry's default)
291
- # coordinator: false # true hands every agent:ship request to the plan runner — the ShipCoordinator
292
- # # Workflow in the bot Worker — instead of the in-process round loop; needs the
293
- # # `coordinator` ingress entry, its grant, run history on the state Worker and
294
- # # PUBLIC_BASE_URL (see docs/how-to/turn-features-on-and-off.md)
295
295
 
296
296
  # The fan-out cap a spawning run meets (docs/reference/specs/agent-conductor.md).
297
297
  # `agent:conductor` starts child runs as the person who asked — each an
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.207.0",
3
+ "version": "1.208.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.207.0",
9
+ "version": "1.208.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -18999,7 +18999,7 @@
18999
18999
  },
19000
19000
  "packages/switchboard": {
19001
19001
  "name": "@coreplane/switchboard",
19002
- "version": "1.207.0",
19002
+ "version": "1.208.0",
19003
19003
  "license": "Apache-2.0",
19004
19004
  "dependencies": {
19005
19005
  "@anthropic-ai/sdk": "^0.124.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.207.0",
3
+ "version": "1.208.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.207.0",
3
- "commit": "123a4b1aba2acf34fc922a24f3493fa6c05df2d9",
4
- "builtAt": "2026-09-14T00:08:39.382Z"
2
+ "version": "1.208.0",
3
+ "commit": "348d6f6ebbf7cf242f39951c0a81585527f49181",
4
+ "builtAt": "2026-09-14T03:37:32.458Z"
5
5
  }
@@ -145,6 +145,11 @@ export interface CoordinatorUnit {
145
145
  /** The unit's board issue in the repository, when one titled by the unit id exists — the handoff's destination. */
146
146
  issue?: number;
147
147
  pr?: { number: number; url: string };
148
+ /** Resume at review (agent-ship item 10): the open pull request of ship's own
149
+ * the requester named, so the unit's pipeline opens at its first review round
150
+ * — no pre-check, no branch, no round 0. A task string's row only; written by
151
+ * the hand-off, read by the driver into the machine's input. */
152
+ resume?: { pr: number; headSha?: string; url?: string };
148
153
  /** The round boundaries the coordinator reported, oldest first (the `ship_round` vocabulary). */
149
154
  rounds: Array<{ index: number; agent: string; outcome: string; at: number }>;
150
155
  /** How the unit ended: the ending's kind and the thread's report, when it has. */
@@ -163,6 +168,11 @@ const isOptionalText = (v: unknown): boolean => v === undefined || isText(v);
163
168
  const isFinite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
164
169
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
165
170
  const isPr = (v: unknown): boolean => isObject(v) && isFinite(v.number) && isText(v.url, 2048);
171
+ const isResume = (v: unknown): boolean =>
172
+ isObject(v) &&
173
+ isFinite(v.pr) &&
174
+ (v.headSha === undefined || isText(v.headSha)) &&
175
+ (v.url === undefined || isText(v.url, 2048));
166
176
 
167
177
  /** Structural check on a record from outside the process (a Worker response, an HTTP body). */
168
178
  export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
@@ -194,6 +204,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
194
204
  if (!isOptionalText(r.threadKey) || !isOptionalText(r.sourceUrl)) return false;
195
205
  if (r.issue !== undefined && !isFinite(r.issue)) return false;
196
206
  if (r.pr !== undefined && !isPr(r.pr)) return false;
207
+ if (r.resume !== undefined && !isResume(r.resume)) return false;
197
208
  if (
198
209
  !Array.isArray(r.rounds) ||
199
210
  r.rounds.length > MAX_ROUNDS ||
@@ -24,7 +24,10 @@
24
24
  // dependencies are done. `done` is merged: a plan branch's pull request is the
25
25
  // runner's to squash (the `merge` step, under `plan:merge`) once the review
26
26
  // approved at its head and the checks are green, so its dependents start on a
27
- // base that carries it; a unit that ended any other way — a refused merge, a
27
+ // base that carries it — or was found merged already by the machine's first
28
+ // `pr-check` (a re-issued plan whose unit a person, or an earlier attempt,
29
+ // merged), which ends the unit with no branch and no child; a unit that ended
30
+ // any other way — a refused merge, a
28
31
  // cap, a stop — blocks its dependents, each told so as its own ending, and the
29
32
  // plan finishes `failed` so the summary says which units are left for the
30
33
  // plan's re-issue. A task string's ship branch waits for a person. Node-free:
@@ -230,7 +233,18 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
230
233
  // The typed artifacts as the bot's record carries them — shape-checked where
231
234
  // they were written (the run record's validator), read here as they are.
232
235
  const facts = run as unknown as Omit<Extract<ChildFacts, { finished: true }>, "finished" | "status">;
233
- const { finalReply, pr, headSha, description, verdict, reviewPosted, reviewHead, dispositions, handoff } = facts;
236
+ const {
237
+ finalReply,
238
+ pr,
239
+ headSha,
240
+ description,
241
+ verdict,
242
+ reviewPosted,
243
+ reviewPostReason,
244
+ reviewHead,
245
+ dispositions,
246
+ handoff,
247
+ } = facts;
234
248
  return {
235
249
  type: "read-record",
236
250
  step,
@@ -243,6 +257,7 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
243
257
  ...(description !== undefined ? { description } : {}),
244
258
  ...(verdict !== undefined ? { verdict } : {}),
245
259
  ...(reviewPosted !== undefined ? { reviewPosted } : {}),
260
+ ...(reviewPostReason !== undefined ? { reviewPostReason } : {}),
246
261
  ...(reviewHead !== undefined ? { reviewHead } : {}),
247
262
  ...(dispositions !== undefined ? { dispositions } : {}),
248
263
  ...(handoff !== undefined ? { handoff } : {}),
@@ -252,7 +267,7 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
252
267
  }
253
268
 
254
269
  function prCheckReturn(step: string, a: BotAnswer): StepReturn {
255
- const { ok, state, prNumber, url, headSha, at } = a.body;
270
+ const { ok, state, prNumber, url, headSha, sha, mergedAt, at } = a.body;
256
271
  if (ok === true && state === "none") return { type: "pr-check", step, pr: { state: "none" }, at };
257
272
  if (ok === true && state === "open" && typeof prNumber === "number" && typeof url === "string")
258
273
  return {
@@ -261,6 +276,17 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
261
276
  pr: { state: "open", prNumber, url, ...(typeof headSha === "string" ? { headSha } : {}) },
262
277
  at,
263
278
  };
279
+ // A merged pull request is read whole or not at all: the merge commit and
280
+ // the time are what the unit's ending and its report carry.
281
+ if (
282
+ ok === true &&
283
+ state === "merged" &&
284
+ typeof prNumber === "number" &&
285
+ typeof url === "string" &&
286
+ typeof sha === "string" &&
287
+ typeof mergedAt === "string"
288
+ )
289
+ return { type: "pr-check", step, pr: { state: "merged", prNumber, url, sha, mergedAt }, at };
264
290
  throw new UnreadableAnswer("pr-check", a, "state");
265
291
  }
266
292
 
@@ -385,6 +411,10 @@ async function runUnit(
385
411
  const start = readUnitStart(
386
412
  answerOf("unit-start", await step.do(`${unit}/start`, STEP_CONFIG, () => call(bot, "unit-start", tag))),
387
413
  );
414
+ // A resume at review (agent-ship item 10) rides the unit's row: the pull
415
+ // request of ship's own the requester named opens the pipeline at its first
416
+ // review round, with no pre-check, no branch and no round 0.
417
+ const resume = plan.units.find((u) => u.unit === unit)?.resume;
388
418
  let state: UnitPipelineState = openUnitPipeline(
389
419
  {
390
420
  unit: { id: unit, branch: node.branch },
@@ -397,6 +427,7 @@ async function runUnit(
397
427
  // approved at its head and the checks are green; any other branch — a
398
428
  // task string's ship branch — waits for a person.
399
429
  merge: parsePlanBranch(node.branch) !== undefined ? "runner" : "person",
430
+ ...(resume !== undefined ? { resume } : {}),
400
431
  },
401
432
  start.at,
402
433
  );
@@ -411,10 +442,14 @@ async function runUnit(
411
442
  const body = { ...tag, index: note.index, agent: note.agent, outcome: note.outcome };
412
443
  await step.do(`${unit}/note/${++notes}`, STEP_CONFIG, () => call(bot, "round", body));
413
444
  } else {
445
+ // The last coding child's run is named so the bot can put its handoff
446
+ // — the deviations it recorded — on the unit's board issue beside the
447
+ // ending (agent-ship item 14).
414
448
  const body = {
415
449
  ...tag,
416
450
  ending: { kind: note.ending.kind, report: renderUnitReport(state) },
417
451
  ...(state.pr !== undefined ? { pr: state.pr } : {}),
452
+ ...(state.lastCodingRunId !== undefined ? { codingRunId: state.lastCodingRunId } : {}),
418
453
  };
419
454
  await step.do(`${unit}/end`, STEP_CONFIG, () => call(bot, "unit-end", body));
420
455
  }
@@ -259,6 +259,46 @@ export function isReviewVerdictShape(v: unknown): v is ReviewVerdict {
259
259
  return true;
260
260
  }
261
261
 
262
+ /** How a review run's post-step ended, as the run's record carries it
263
+ * (docs/reference/specs/agent-review.md item 18; run-history item 2): the verdict
264
+ * landed on a named pull request pinned to `head` (the verdict kind rides when
265
+ * one was submitted — a review that posted without a verdict posts the
266
+ * no-verdict line), or nothing landed and `reason` says why — a guard's
267
+ * refusal, an opt-out, no pull request, GitHub's own error. A coordinator's
268
+ * `read-record` answers `reviewPosted` from this before it asks GitHub, whose
269
+ * review list can lag a post it accepted a second ago. */
270
+ export type ReviewPost =
271
+ | { posted: true; target: { repo: string; number: number }; head: string; verdict?: ReviewVerdictKind }
272
+ | { posted: false; reason: string };
273
+
274
+ const REVIEW_POST_HEAD = /^[0-9a-f]{7,40}$/;
275
+
276
+ /** Structural check on a review post read back from a stored record: a posted
277
+ * outcome names its pull request and a 7-to-40-hex head, its verdict (when
278
+ * present) a known kind; a skipped one carries a string reason. */
279
+ export function isReviewPostShape(v: unknown): v is ReviewPost {
280
+ if (!isRecordLike(v)) return false;
281
+ if (v.posted === false) return typeof v.reason === "string";
282
+ if (v.posted !== true) return false;
283
+ const target = v.target;
284
+ if (
285
+ !isRecordLike(target) ||
286
+ typeof target.repo !== "string" ||
287
+ typeof target.number !== "number" ||
288
+ !Number.isInteger(target.number) ||
289
+ target.number <= 0
290
+ )
291
+ return false;
292
+ if (typeof v.head !== "string" || !REVIEW_POST_HEAD.test(v.head)) return false;
293
+ return v.verdict === undefined || VERDICT_KINDS.includes(v.verdict as string);
294
+ }
295
+
296
+ /** The skip's reason through the redaction seam (it may carry GitHub's own
297
+ * words); a posted outcome has no free text and is returned as it is. */
298
+ export function redactReviewPost(post: ReviewPost, redact: (s: string) => string = redactSecrets): ReviewPost {
299
+ return post.posted ? post : { posted: false, reason: redact(post.reason) };
300
+ }
301
+
262
302
  /** Structural check on a disposition set read back from a stored record. */
263
303
  export function isFindingDispositionsShape(v: unknown): v is FindingDisposition[] {
264
304
  return (
@@ -110,7 +110,15 @@ export type RunNoteKind =
110
110
  * request would target (docs/reference/specs/pr-description.md item 5) —
111
111
  * the summary names the branch. Published by the post-step, so a unit
112
112
  * that ends without a pull request says why on the record and the card. */
113
- | "pr_not_opened";
113
+ | "pr_not_opened"
114
+ /** A review run's post-step posted nothing to the pull request — a guard's
115
+ * refusal, an opt-out, no pull request resolved, GitHub's own error — and
116
+ * the summary names the pull request (when one was resolved) and the
117
+ * reason (docs/reference/specs/agent-review.md item 18). Published by the
118
+ * post-step beside the thread's Slack-only note, so the record says the
119
+ * verdict is Slack-only and a coordinator reading it never asks GitHub
120
+ * for a review that was never sent. */
121
+ | "review_not_posted";
114
122
 
115
123
  /** Every `RunNoteKind`, as a value (a reader that filters notes by kind uses
116
124
  * this; adding a kind to the union without adding it here is a type error). */
@@ -130,6 +138,7 @@ export const RUN_NOTE_KINDS = [
130
138
  "description_turn",
131
139
  "cold_sandbox",
132
140
  "pr_not_opened",
141
+ "review_not_posted",
133
142
  ] as const satisfies readonly RunNoteKind[];
134
143
  type _EveryKindListed = [RunNoteKind] extends [(typeof RUN_NOTE_KINDS)[number]] ? true : never;
135
144
  const _everyKindListed: _EveryKindListed = true;
@@ -438,6 +447,25 @@ export type RunEvent =
438
447
  * run record carries the PR URL as a fact of the run rather than only the
439
448
  * channel reply's projection of it. Additive: unknown → ignored. */
440
449
  | { type: "pr_opened"; url: string; number: number; created: boolean; seq?: number; at?: number }
450
+ /** The review post-step's outcome when the verdict landed
451
+ * (docs/reference/specs/agent-review.md item 18): the pull request it was
452
+ * posted to, the head it was pinned to (the carried head after a rebase,
453
+ * item 12) and the verdict kind when one was submitted. Published by the
454
+ * post-step straight to the registry BEFORE the stream finishes — the
455
+ * post-step runs inside the run loop, like the coding one — so the record
456
+ * carries the post as a fact of the run and a coordinator woken by the
457
+ * finish reads it there instead of asking GitHub, whose review list can
458
+ * lag the post it just accepted. A post that did not land is a
459
+ * `review_not_posted` note. Additive: unknown → ignored. */
460
+ | {
461
+ type: "review_posted";
462
+ repo: string;
463
+ number: number;
464
+ head: string;
465
+ verdict?: "approve" | "request_changes";
466
+ seq?: number;
467
+ at?: number;
468
+ }
441
469
  /** One `agent:ship` round boundary (docs/reference/specs/agent-ship.md item 12): the
442
470
  * pipeline publishes a `started` event when a round's child is dispatched
443
471
  * and one settle event when its outcome is known (`ShipRoundOutcome`), so
@@ -370,7 +370,7 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
370
370
  // final answer (`answer`) — are the run's story, not its steps: none counts
371
371
  // toward `eventCount`.
372
372
  let narrativeEvents = 0;
373
- let sideFactEvents = 0; // skill_use / review_artifact / pr_description / pr_opened / ship_round: facts about the run, not steps
373
+ let sideFactEvents = 0; // skill_use / review_artifact / pr_description / pr_opened / review_posted / ship_round: facts about the run, not steps
374
374
  let spanEvents = 0; // span_start / span_end (docs/reference/specs/tracing.md): timing records, not steps
375
375
  let wrapUp: { index: number; at?: number } | undefined;
376
376
  events.forEach((ev, index) => {
@@ -385,14 +385,15 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
385
385
  }
386
386
  // Side facts about the run, not steps: skill_use rides beside a use_skill
387
387
  // call that already produced its own tool pair; review_artifact,
388
- // pr_description, pr_opened and the ship_round boundaries are published
389
- // by the dispatcher/pipeline outside the model loop entirely. Counting
390
- // any of them would distort the story.
388
+ // pr_description, pr_opened, review_posted and the ship_round boundaries
389
+ // are published by the dispatcher/pipeline outside the model loop
390
+ // entirely. Counting any of them would distort the story.
391
391
  if (
392
392
  ev.type === "skill_use" ||
393
393
  ev.type === "review_artifact" ||
394
394
  ev.type === "pr_description" ||
395
395
  ev.type === "pr_opened" ||
396
+ ev.type === "review_posted" ||
396
397
  ev.type === "ship_round"
397
398
  ) {
398
399
  sideFactEvents++;
@@ -4,9 +4,11 @@ import type { RunEvent } from "./runEvents.js";
4
4
  import { isHeadMaterial, isSpanRecord } from "./runEvents.js";
5
5
  import { isHandoffShape, type Handoff } from "./ship/handoff.js";
6
6
  import {
7
+ type FindingDisposition,
7
8
  isFindingDispositionsShape,
9
+ isReviewPostShape,
8
10
  isReviewVerdictShape,
9
- type FindingDisposition,
11
+ type ReviewPost,
10
12
  type ReviewVerdict,
11
13
  } from "./reviewVerdict.js";
12
14
  import { IDEMPOTENCY_KEY_PATTERN, INSTANCE_ID_PATTERN } from "./coordinator/contract.js";
@@ -114,6 +116,12 @@ export interface RunRecord {
114
116
  /** The head a review run reviewed and posted against (7 to 40 lowercase hex),
115
117
  * after the settle; present only on a review run that pinned one. */
116
118
  reviewHead?: string;
119
+ /** How the review run's post-step ended (docs/reference/specs/agent-review.md
120
+ * item 18): the verdict posted to a named pull request at a pinned head, or
121
+ * not posted with the reason — what a coordinator's `read-record` answers
122
+ * `reviewPosted` from before it asks GitHub. Present only on a review run
123
+ * that reached its post-step; absent on records written before it existed. */
124
+ reviewPost?: ReviewPost;
117
125
  /** The dispositions a fix round submitted through `submit_dispositions`
118
126
  * (docs/reference/specs/agent-ship.md item 6), the last call's set, redacted;
119
127
  * present only on a coding run dispatched as a fix round that submitted one. */
@@ -502,6 +510,7 @@ export function isRunRecord(v: unknown): v is RunRecord {
502
510
  if (r.reviewHead !== undefined && (typeof r.reviewHead !== "string" || !REVIEW_HEAD_PATTERN.test(r.reviewHead)))
503
511
  return false;
504
512
  if (r.dispositions !== undefined && !isFindingDispositionsShape(r.dispositions)) return false;
513
+ if (r.reviewPost !== undefined && !isReviewPostShape(r.reviewPost)) return false;
505
514
  if (r.profile !== undefined && !isRunProfileRecord(r.profile)) return false;
506
515
  // A parent is named by a run id (item 46): the same shape as the record's own.
507
516
  if (r.parentRunId !== undefined && (typeof r.parentRunId !== "string" || !RUN_ID_PATTERN.test(r.parentRunId)))
@@ -79,6 +79,10 @@ export interface ChildContract {
79
79
  issue: { repo: string; number: number } | undefined;
80
80
  }
81
81
 
82
+ /** The PR-title gate's name, exported so another module can name the same
83
+ * gate without a copied string (`npm run check:pr-title`; CI's `title` check). */
84
+ export const PR_TITLE_GUARD = "check:pr-title";
85
+
82
86
  /** The guards a child may not weaken (AGENTS.md's Commands table), one line each on what they refuse. */
83
87
  export const GUARDS: readonly Guard[] = [
84
88
  {
@@ -96,6 +100,11 @@ export const GUARDS: readonly Guard[] = [
96
100
  refuses:
97
101
  "a new imprint in the public tree (a company, a person, a tracker reference, a plan id, a platform id, a date); the recorded list only shrinks",
98
102
  },
103
+ {
104
+ name: PR_TITLE_GUARD,
105
+ refuses:
106
+ "a title whose type, scope or grammar is not the changelog line, the scope being one of the code map's Areas",
107
+ },
99
108
  {
100
109
  name: "decisions:check",
101
110
  refuses:
@@ -4,11 +4,10 @@
4
4
  // Workflow instance in the bot's shim Worker with no model turn and no
5
5
  // credential — decides between the steps it asks the bot for. The Workflow
6
6
  // asks `nextAction`, performs it (a bot route, a `waitForEvent`, a sleep) and
7
- // feeds the answer to `applyReturn`; everything the in-process round loop
8
- // decides today (`runShipPipeline`: which round is next, what a child's end
9
- // means, when a cap ends the pipeline, when the pull request is merge-ready)
10
- // is decided here over the step returns instead, so the same endings hold
11
- // with the loop's process gone.
7
+ // feeds the answer to `applyReturn`; everything the pipeline decides — which
8
+ // round is next, what a child's end means, when a cap ends the pipeline, when
9
+ // the pull request is merge-ready — is decided here over the step returns, so
10
+ // the endings hold with no process of the pipeline's own to die.
12
11
  //
13
12
  // Two machines, both pure. The plan cursor walks a plan record's unit graph:
14
13
  // which units are ready (their dependencies merged), which one a failure
@@ -16,7 +15,12 @@
16
15
  // approve and the merge: one thread and one head branch per unit
17
16
  // (`plan/<plan-id>/<unit-slug>`), every child a `dispatch()` run the bot
18
17
  // starts as the requesting user, every step retry-safe under the key
19
- // `<instance>:<unit>/<round>/<kind>`. Nothing here reads a clock: the bot
18
+ // `<instance>:<unit>/<round>/<kind>`. Its first step asks what already heads
19
+ // the branch: a pull request merged before the attempt reached the unit — a
20
+ // person's merge, or an earlier attempt's — ends the unit `merged` with no
21
+ // branch and no child, so a re-issued plan walks past its done units instead
22
+ // of aborting them; the same answer after a round ends it the same way, since
23
+ // a merge can land while a child runs. Nothing here reads a clock: the bot
20
24
  // answers every step with its own `at`, and that is the machine's time. Nothing
21
25
  // here carries a task's text or a thread's contents: a spawn's brief names the
22
26
  // unit and the runs whose records the bot reads to compose the child's turn.
@@ -340,15 +344,24 @@ export type ChildFacts =
340
344
  description?: boolean;
341
345
  /** A review child's verdict. */
342
346
  verdict?: { verdict: ReviewVerdictKind; summary?: string; findings: Finding[] };
343
- /** Whether the review child's verdict landed on the pull request. */
347
+ /** Whether the review child's verdict landed on the pull request — the
348
+ * child's own record of its post, or GitHub's review list when the
349
+ * record is silent (http-ingress.md item 9). */
344
350
  reviewPosted?: boolean;
351
+ /** Why the child recorded no post, when it recorded one it chose or failed. */
352
+ reviewPostReason?: string;
345
353
  reviewHead?: string;
346
354
  /** A fix child's dispositions. */
347
355
  dispositions?: FindingDisposition[];
348
356
  handoff?: boolean;
349
357
  };
350
358
 
351
- export type PrCheck = { state: "none" } | { state: "open"; prNumber: number; url: string; headSha?: string };
359
+ /** What heads the unit's branch on GitHub: nothing, an open pull request, or —
360
+ * with no open one — a merged one, `sha` the merge commit on the base. */
361
+ export type PrCheck =
362
+ | { state: "none" }
363
+ | { state: "open"; prNumber: number; url: string; headSha?: string }
364
+ | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
352
365
 
353
366
  /** What a step answered. Every bot answer carries `at`, the bot's clock — the machine's time. */
354
367
  export type StepReturn =
@@ -365,12 +378,15 @@ export type StepReturn =
365
378
  | { type: "merge"; step: string; outcome: "pending" | "refused"; reason: string; at: number }
366
379
  | { type: "sleep"; step: string };
367
380
 
368
- /** How one unit's pipeline ended — the truthful vocabulary the in-process loop
369
- * has, plus the merge's own: `merged` by the runner, `merge_ready` for a
370
- * person, `merge_refused` by the guards; `interrupted` a child the ledger
371
- * closed; `refused` a child the authorize stage never started. */
381
+ /** How one unit's pipeline ended — the truthful vocabulary the ship pipeline
382
+ * has, plus the merge's own: `merged` by the runner, or found merged (`by:
383
+ * other` — a person's merge, or an earlier attempt's that died after it, so
384
+ * the runner merged nothing), `merge_ready` for a person, `merge_refused` by
385
+ * the guards; `interrupted` a child the ledger closed; `refused` a child the
386
+ * authorize stage never started. */
372
387
  export type UnitEnding =
373
- | { kind: "merged"; pr: PrRef; sha: string; reviewRounds: number }
388
+ | { kind: "merged"; by: "runner"; pr: PrRef; sha: string; reviewRounds: number }
389
+ | { kind: "merged"; by: "other"; pr: PrRef; sha: string; mergedAt: string; reviewRounds: number }
374
390
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
375
391
  | { kind: "merge_refused"; pr: PrRef; reason: string; reviewRounds: number }
376
392
  | { kind: "round_cap"; maxRounds: number; reviewRounds: number }
@@ -411,6 +427,8 @@ export interface UnitPipelineInput {
411
427
  }
412
428
 
413
429
  type Phase =
430
+ /** Before anything is created: what already heads the branch — a merged pull request ends the unit here. */
431
+ | { at: "pre-check" }
414
432
  | { at: "branch" }
415
433
  | { at: "spawn"; round: RoundRef; busy: number }
416
434
  | { at: "busy-wait"; round: RoundRef; runId?: string; n: number }
@@ -467,7 +485,7 @@ export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipe
467
485
  input,
468
486
  startedAt: at,
469
487
  clock: at,
470
- phase: { at: "branch" },
488
+ phase: { at: "pre-check" },
471
489
  reviewRounds: 0,
472
490
  findingsByRound: {},
473
491
  dispositionsByRound: {},
@@ -497,7 +515,7 @@ function waitSliceMs(clock: number, until: number): number {
497
515
  }
498
516
 
499
517
  /** The child's budget: its preset's own, clipped to the pipeline's remaining
500
- * wall clock (the in-process loop's `clip`), never under the two minutes a
518
+ * wall clock (agent-ship item 8's clip), never under the two minutes a
501
519
  * spawn accepts. */
502
520
  function budgetMinutesFor(s: UnitPipelineState, preset: ChildPreset): number {
503
521
  return Math.max(2, Math.min(s.input.childMinutes[preset], Math.floor(remainingMs(s) / MIN)));
@@ -531,6 +549,8 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
531
549
  const unit = s.input.unit.id;
532
550
  const p = s.phase;
533
551
  switch (p.at) {
552
+ case "pre-check":
553
+ return { type: "pr-check", step: `${unit}/pr-check` };
534
554
  case "branch":
535
555
  return { type: "branch", step: `${unit}/branch`, branch: s.input.unit.branch, from: s.input.base };
536
556
  case "spawn": {
@@ -589,7 +609,7 @@ const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): CoordinatorNote
589
609
  outcome,
590
610
  });
591
611
 
592
- /** Start a round if the reservation holds (the in-process loop's check: a
612
+ /** Start a round if the reservation holds (agent-ship item 8's check: a
593
613
  * child clipped under the reserve cannot do useful work). */
594
614
  function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNote[] = []): Transition {
595
615
  const remaining = remainingMs(s);
@@ -703,13 +723,15 @@ function settleReview(
703
723
  const notes = [roundNote(round, verdict.verdict)];
704
724
  if (verdict.verdict === "approve") {
705
725
  // Merge-ready stands on the POSTED approval: an approve whose post did
706
- // not land left no approving review on the pull request.
726
+ // not land left no approving review on the pull request. The reason is
727
+ // the child's own when it recorded one; how to continue is the report's
728
+ // re-issue line, in the runner's words (`renderUnitReport`).
707
729
  if (facts.reviewPosted === false)
708
730
  return end(
709
731
  next,
710
732
  {
711
733
  kind: "aborted",
712
- reason: `⚠️ The review approved, but the approval could not be posted — the pull request carries no approving review. Re-run ship with the pull request URL to retry the approval.`,
734
+ reason: `⚠️ The review approved, but the approval could not be posted${facts.reviewPostReason !== undefined ? ` (${facts.reviewPostReason})` : ""} — the pull request carries no approving review.`,
713
735
  round,
714
736
  reviewRounds: next.reviewRounds,
715
737
  },
@@ -753,9 +775,30 @@ function settleReview(
753
775
  return enterRound(next, { index: round.index, kind: "fix" }, notes);
754
776
  }
755
777
 
778
+ /** The unit's ending when its pull request is found merged — by a person, or
779
+ * by an earlier attempt of the plan that died after its merge: the unit is
780
+ * done and its dependents run on a base that carries it, and the runner
781
+ * merged nothing, so the ending says so (`by: other`). */
782
+ function foundMerged(
783
+ s: UnitPipelineState,
784
+ pr: Extract<PrCheck, { state: "merged" }>,
785
+ notes: CoordinatorNote[] = [],
786
+ ): Transition {
787
+ const ref: PrRef = { number: pr.prNumber, url: pr.url };
788
+ return end(
789
+ { ...s, pr: ref },
790
+ { kind: "merged", by: "other", pr: ref, sha: pr.sha, mergedAt: pr.mergedAt, reviewRounds: s.reviewRounds },
791
+ notes,
792
+ );
793
+ }
794
+
756
795
  /** The pull request heading the branch after a coding or fix round. */
757
796
  function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-check" }>, pr: PrCheck): Transition {
758
797
  const { round } = phase;
798
+ // The merge landed during the round: the child found nothing left to ship
799
+ // (or shipped into a pull request a person merged under it). The round
800
+ // completed without a pull request of its own, and the unit is done.
801
+ if (pr.state === "merged") return foundMerged(s, pr, [roundNote(round, "completed")]);
759
802
  if (pr.state === "none") {
760
803
  const reason =
761
804
  round.index === 0
@@ -817,6 +860,16 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
817
860
  const clocked: UnitPipelineState = "at" in ret ? { ...s, clock: ret.at } : s;
818
861
  const p = s.phase;
819
862
  switch (p.at) {
863
+ case "pre-check": {
864
+ // A pull request merged before this attempt reached the unit — a
865
+ // person's merge, or an earlier attempt's that died after it — makes the
866
+ // unit done before a branch or a child: nothing to run. Anything else is
867
+ // round 0's to work on: an open pull request is rebased and re-described
868
+ // by the coding child and adopted at the round's own pr-check.
869
+ const r = ret as Extract<StepReturn, { type: "pr-check" }>;
870
+ if (r.pr.state === "merged") return foundMerged(clocked, r.pr);
871
+ return { state: { ...clocked, phase: { at: "branch" } }, notes: [] };
872
+ }
820
873
  case "branch": {
821
874
  const r = ret as Extract<StepReturn, { type: "branch" }>;
822
875
  if (r.ok) return enterRound(clocked, { index: 0, kind: "coding" });
@@ -914,7 +967,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
914
967
  case "merge": {
915
968
  const r = ret as Extract<StepReturn, { type: "merge" }>;
916
969
  if (r.outcome === "merged")
917
- return end(clocked, { kind: "merged", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
970
+ return end(clocked, { kind: "merged", by: "runner", pr: p.pr, sha: r.sha, reviewRounds: s.reviewRounds });
918
971
  if (r.outcome === "refused")
919
972
  return end(clocked, { kind: "merge_refused", pr: p.pr, reason: r.reason, reviewRounds: s.reviewRounds });
920
973
  const waited = r.at - p.since;
@@ -982,7 +1035,7 @@ function splitReport(s: UnitPipelineState): string {
982
1035
  return lines.join("\n");
983
1036
  }
984
1037
 
985
- /** The thread's report for a unit's ending — the in-process loop's own words
1038
+ /** The thread's report for a unit's ending — the ship pipeline's own words
986
1039
  * for the endings it has, and the merge's for the ones it gains. */
987
1040
  export function renderUnitReport(s: UnitPipelineState): string {
988
1041
  const e = s.ending;
@@ -1000,6 +1053,8 @@ export function renderUnitReport(s: UnitPipelineState): string {
1000
1053
  const join = (parts: Array<string | undefined>) => parts.filter(Boolean).join("\n\n");
1001
1054
  switch (e.kind) {
1002
1055
  case "merged":
1056
+ if (e.by === "other")
1057
+ return `✅ Already merged: ${e.pr.url} (merge commit \`${e.sha.slice(0, 7)}\`, merged ${e.mergedAt}) — the pull request heading \`${s.input.unit.branch}\` was merged before this attempt reached it, by a person or by an earlier attempt of this plan; the runner merged nothing. The unit is done and its dependents start on a base that carries it.`;
1003
1058
  return [
1004
1059
  `✅ Merged after ${rounds}: ${e.pr.url} (squash \`${e.sha.slice(0, 7)}\`) — merged by the plan runner under \`plan:merge\`: the review approved at this head and the guards were green.`,
1005
1060
  verdictLine,
@@ -45,6 +45,7 @@ export const STREAMED_SPANS = [
45
45
  "run.description_turn",
46
46
  "run.observe_workspace",
47
47
  "run.pr_post_step",
48
+ "run.review_post_step",
48
49
  "run.reading_diff_join",
49
50
  "run.pr_description_join",
50
51
  "model.turn",
@@ -92,6 +93,7 @@ const GETTING_READY: ReadonlySet<string> = new Set([
92
93
  const FINISHING_UP: ReadonlySet<string> = new Set([
93
94
  "run.observe_workspace",
94
95
  "run.pr_post_step",
96
+ "run.review_post_step",
95
97
  "run.reading_diff_join",
96
98
  "run.pr_description_join",
97
99
  ]);
@@ -150,6 +152,7 @@ export const PARENTS: Readonly<Record<string, readonly string[]>> = {
150
152
  "run.description_turn": ["request", "ship.round"],
151
153
  "run.observe_workspace": ["request", "ship.round"],
152
154
  "run.pr_post_step": ["request", "ship.round"],
155
+ "run.review_post_step": ["request", "ship.round"],
153
156
  "run.reading_diff_join": ["request", "ship.round"],
154
157
  "run.pr_description_join": ["request", "ship.round"],
155
158
  "model.turn": ["run.agent"],
@@ -52,6 +52,9 @@ export function shimRoute(pathname: string): string | undefined {
52
52
  if (pathname === "/costs" || pathname.startsWith("/costs/")) return "costs";
53
53
  if (pathname.startsWith("/api/")) return "api";
54
54
  if (pathname.startsWith("/admin/")) return "admin";
55
+ // The model proxy's two routes (docs/reference/specs/model-proxy.md): a bounded
56
+ // request per model call, forwarded to the container like everything else.
57
+ if (pathname === "/v1/messages" || pathname === "/v1/chat/completions") return "model-proxy";
55
58
  if (pathname === "/docs" || pathname.startsWith("/docs/")) return "docs";
56
59
  if (pathname === "/" || pathname === "/index.html") return "page";
57
60
  return "other";