@coreplane/switchboard 1.260.1 → 1.260.3

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.260.1",
3
+ "version": "1.260.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.260.1",
9
+ "version": "1.260.3",
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.260.1",
20448
+ "version": "1.260.3",
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.260.1",
3
+ "version": "1.260.3",
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.260.1",
3
- "commit": "c57cb7b614e8916b3fd0ab86c848a8398d8dc781",
4
- "builtAt": "2026-09-21T18:18:08.788Z"
2
+ "version": "1.260.3",
3
+ "commit": "9f64917c9c2520b2797f3d5e6a73494879408e26",
4
+ "builtAt": "2026-09-22T05:04:08.797Z"
5
5
  }
@@ -191,7 +191,7 @@ const CONTRACT_HEADINGS_LIST = Object.values(CONTRACT_SECTION_HEADINGS)
191
191
  // assembled by the child, which would choose what to leave out. Both coding
192
192
  // prompts carry this verbatim so the resident and sandbox children read the
193
193
  // same rule; the review prompts name the same block and the same severity.
194
- const UNIT_CONTRACT = `UNIT CONTRACT: when your first user turn carries a \`${CONTRACT_HEADING}\` block — its sub-headings, in this order: ${CONTRACT_HEADINGS_LIST} — it is the contract for one plan unit, rendered by Switchboard from the plan itself, and it outranks any free-text task beside it. Do its first instruction first: the rebase of the unit's branch onto the merged parent (a conflict ends the unit — report it and stop; never resolve it by force). Then implement the unit's section as written: every test scenario it lists is added as a test, every spec row it names is updated so its proof binding resolves, the agent rules are followed, and no guard it names is weakened. The review is handed the same block and checks the diff against it: a test scenario the unit listed and the diff did not add is a finding at minor severity — the same severity as a spec contradiction. Never edit the plan record itself; where the unit is wrong or a criterion could not be proven, say so in the handoff and in your final message.`;
194
+ const UNIT_CONTRACT = `UNIT CONTRACT: when your first user turn carries a \`${CONTRACT_HEADING}\` block — its sub-headings, in this order: ${CONTRACT_HEADINGS_LIST} — it is the contract for one plan unit, rendered by Switchboard from the plan itself, and it outranks any free-text task beside it. Do its first instruction first: the rebase of the unit's branch onto the merged parent. Then implement the unit's section as written: every test scenario it lists is added as a test, every spec row it names is updated so its proof binding resolves, the agent rules are followed, and no guard it names is weakened. The review is handed the same block and checks the diff against it: a test scenario the unit listed and the diff did not add is a finding at minor severity — the same severity as a spec contradiction. Never edit the plan record itself; where the unit is wrong or a criterion could not be proven, say so in the handoff and in your final message.`;
195
195
 
196
196
  // The unit handoff (docs/reference/specs/agent-coding.md item 9; agent-ship.md
197
197
  // item 14): the contract's return edge, as data. A child that ran for a plan
@@ -289,10 +289,11 @@ export const FAST_GATES_BEFORE_PUSH =
289
289
  // (src/core/ship/contract.ts).
290
290
  export const REBASE_BEFORE_PUSH =
291
291
  "REBASE BEFORE EVERY PUSH — always, not configurable. Immediately before each push: fetch your base branch, " +
292
- "rebase your branch onto it, resolve any conflict with the context you already have (the repository's " +
293
- "AGENTS.md says how a generated file is regenerated — regenerate it, never hand-merge it), re-run THE FAST " +
294
- "GATES on the rebased tree, and only then push. Every head that reaches review is then current with its " +
295
- "base when it lands, and no unit ends merge-ready behind a sibling that merged first.";
292
+ "rebase your branch onto it, and resolve any conflict with the context you already have in one bounded model " +
293
+ "round — the thread's context and the repository's AGENTS.md (which says how a generated file is regenerated: " +
294
+ "regenerate it, never hand-merge it). If you cannot resolve it inside that bound, end the round; otherwise " +
295
+ "re-run THE FAST GATES on the rebased tree, and only then push. Every head that reaches review is then current " +
296
+ "with its base when it lands, and no unit ends merge-ready behind a sibling that merged first.";
296
297
 
297
298
  const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
298
299
 
@@ -447,7 +448,7 @@ Your final message is posted to Slack — keep it readable, lead with the outcom
447
448
  // whole reply — so the prose never repeats them and never pads around them.
448
449
  const REVIEW_FINAL_MESSAGE = `YOUR FINAL MESSAGE IS THE REVIEW'S TEXT, NOTHING ELSE. Switchboard renders the verdict line and the findings list from your submit_verdict call — on GitHub as the head of the comment, in Slack as the whole reply — and folds your final message under them on GitHub as the full review. So write only what the list cannot carry: one short paragraph per finding, keyed by its id (what is wrong, the concrete failure, the fix). Do not restate the verdict or the findings, do not summarize what you read, do not list what you verified clean, do not describe your method — what you checked belongs in your notes, which the run page shows. A change with no findings needs one sentence, not a tour.`;
449
450
 
450
- const REVIEW_VERDICT_INSTRUCTION = `VERDICT: before your final message, call the submit_verdict tool exactly once with \`approve\` (no finding at or above the severity to address remains — the level in force for this run, \`minor\` by default: a major or a minor finding means \`request_changes\`; nits alone never block) or \`request_changes\`, a one-line summary, \`head\` = the output of \`git rev-parse HEAD\` in the checkout you reviewed, and \`findings\` — every issue you report as a structured entry with a stable id you assign in order (F1, F2, …), a severity of exactly blocking|major|minor|nit, the file (plus line when it points at one), and a one-line title. The findings array is the index of your review: the full explanation of each finding stays in your prose, keyed by the same ids. Switchboard writes the verdict as the first line of the GitHub comment itself, lists the findings under it and folds your text below them as the full review; a review with no submitted verdict is posted as not approving, so never skip it. An \`approve\` carrying a finding at or above the severity to address is downgraded to \`request_changes\` and the tool's ack says so — approve only when every finding sits below the level. Do not write "LGTM" in your own text — the verdict line carries it.`;
451
+ const REVIEW_VERDICT_INSTRUCTION = `VERDICT: before your final message, call the submit_verdict tool exactly once with \`approve\` (no finding at or above the severity to address remains — the level in force for this run, \`minor\` by default: a major or a minor finding means \`request_changes\`; nits alone never block) or \`request_changes\`, a one-line summary, \`head\` = the output of \`git rev-parse HEAD\` in the checkout you reviewed, and \`findings\` — every issue you report as a structured entry with a stable id you assign in order (F1, F2, …), a severity of exactly blocking|major|minor|nit, the file (plus line when it points at one), and a one-line title. The findings array is the index of your review: the full explanation of each finding stays in your prose, keyed by the same ids. Switchboard writes the verdict as the first line of the GitHub comment itself, lists the findings under it and folds your text below them as the full review; a review with no submitted verdict is posted as not approving, so never skip it. The only exception is a preflight infrastructure failure from the REVIEW TARGET block's first-command HEAD check: stop, submit no finding or verdict, and let Switchboard report it without a GitHub post. An \`approve\` carrying a finding at or above the severity to address is downgraded to \`request_changes\` and the tool's ack says so — approve only when every finding sits below the level. Do not write "LGTM" in your own text — the verdict line carries it.`;
451
452
 
452
453
  // The diff-gated spec review (docs/reference/specs/agent-review.md item 14) and
453
454
  // the test guard under it (item 16; specs-coverage.md item 6), one text for
@@ -485,7 +486,7 @@ Strategy — GATHER ONCE, THEN ANALYZE ONCE. Do not explore file-by-file; your c
485
486
 
486
487
  1. GATHER, in 2-4 batched tool calls total:
487
488
  - \`gh pr view <ref> --json title,body,url,baseRefName\` and \`gh pr diff <ref>\` (the complete diff) in one command
488
- - clone the repo and check out the PR branch, then call the \`diff_digest\` tool to orient: per-file churn, totals, and risky-file flags (migrations/schema, auth/permission, whole-file deletions, lockfiles, very large files) so you know where to look hardest before you read a line
489
+ - for a PR review, use the checkout Switchboard provisioned at the head named in the REVIEW TARGET block — do not clone or check out another ref — then call the \`diff_digest\` tool to orient: per-file churn, totals, and risky-file flags (migrations/schema, auth/permission, whole-file deletions, lockfiles, very large files) so you know where to look hardest before you read a line
489
490
  ${REVIEW_WHOLE_CHANGE}
490
491
  - in ONE command, print the full current contents of every changed source file, e.g.: \`gh pr diff <ref> --name-only | grep -v -E "lock|generated|snap" | while read f; do echo "=== $f ==="; cat "$f"; done\`
491
492
  - if the PR is enormous (>~6k changed lines), print the riskiest files in full (state mutation, auth, concurrency, data deletion, public APIs) and only the diff hunks for the rest — and say which files you skimmed
@@ -231,6 +231,13 @@ export const PULL_SWEEP = { leaseMinutes: 15, spendCapUsd: 5 } as const;
231
231
  * budget. */
232
232
  export const PROVIDER_RETRY_BACKOFFS_MS = [5_000, 15_000, 45_000] as const;
233
233
 
234
+ /** How often the model proxy writes an SSE comment while the provider is
235
+ * reasoning silently. The public container hop has cut an otherwise healthy
236
+ * stream at about thirty seconds of silence; a comment inside half that
237
+ * window keeps the transport alive without becoming a model event. The
238
+ * model call itself remains bounded by the run's lease. */
239
+ export const MODEL_STREAM_HEARTBEAT_MS = 15_000;
240
+
234
241
  /** A hosted ship parent's deadline margin past the pipeline's wall clock
235
242
  * (record 0060): the row's `state.hosting.until` is the hand-off time plus
236
243
  * the instance's `caps.maxMinutes` plus this hour, absorbing the runner's own
@@ -10,7 +10,13 @@
10
10
  //
11
11
  import { IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
12
12
  import { isVerbosity, type Verbosity } from "../verbosity.js";
13
- import { isAddressSeverity, type AddressSeverity, type AddressSeveritySource } from "../ship/coordinator.js";
13
+ import { isFindingShape } from "../reviewVerdict.js";
14
+ import {
15
+ isAddressSeverity,
16
+ type AddressSeverity,
17
+ type AddressSeveritySource,
18
+ type HumanGatePending,
19
+ } from "../ship/coordinator.js";
14
20
  import { isHandoffShape, type Handoff } from "../ship/handoff.js";
15
21
 
16
22
  // A coordinator is a Workflow instance in the shim Worker whose children are
@@ -421,6 +427,8 @@ export interface UnitIdle {
421
427
  runId?: string;
422
428
  spendUsd: number | null;
423
429
  handoff?: Handoff;
430
+ /** The human-gated question this idle is waiting on. */
431
+ humanGate?: HumanGatePending;
424
432
  wakes: number;
425
433
  }
426
434
 
@@ -439,6 +447,7 @@ export type UnitWakeAnswer =
439
447
  texts: string[];
440
448
  senders: string[];
441
449
  leaseMs?: number;
450
+ humanGate?: HumanGatePending;
442
451
  }
443
452
  | { kind: "answered"; reply: string }
444
453
  | { kind: "stopped" }
@@ -579,6 +588,23 @@ const isRoundGate = (v: unknown): boolean =>
579
588
  Array.isArray(v.findings) &&
580
589
  v.findings.every((f) => typeof f === "string");
581
590
 
591
+ export const isHumanGatePending = (v: unknown): v is HumanGatePending =>
592
+ isObject(v) &&
593
+ isObject(v.pr) &&
594
+ typeof v.pr.number === "number" &&
595
+ Number.isInteger(v.pr.number) &&
596
+ v.pr.number > 0 &&
597
+ isText(v.pr.url) &&
598
+ typeof v.round === "number" &&
599
+ Number.isInteger(v.round) &&
600
+ v.round >= 1 &&
601
+ Array.isArray(v.findings) &&
602
+ v.findings.every((finding: unknown) => isFindingShape(finding)) &&
603
+ (v.verdict === "approve" || v.verdict === "request_changes") &&
604
+ (v.reviewRunId === undefined || isText(v.reviewRunId)) &&
605
+ (v.headSha === undefined || isText(v.headSha)) &&
606
+ (v.askedAt === undefined || isFinite(v.askedAt));
607
+
582
608
  const isUnitIdle = (v: unknown): boolean =>
583
609
  isObject(v) &&
584
610
  isText(v.why, IDLE_WHY_MAX) &&
@@ -588,6 +614,7 @@ const isUnitIdle = (v: unknown): boolean =>
588
614
  (v.runId === undefined || isText(v.runId)) &&
589
615
  (v.spendUsd === null || isFinite(v.spendUsd)) &&
590
616
  (v.handoff === undefined || isHandoffShape(v.handoff)) &&
617
+ (v.humanGate === undefined || isHumanGatePending(v.humanGate)) &&
591
618
  isCount(v.wakes);
592
619
 
593
620
  const isSegment = (v: unknown): boolean =>
@@ -616,7 +643,8 @@ export const isUnitWakeAnswer = (v: unknown): v is UnitWakeAnswer => {
616
643
  v.texts.every((text) => typeof text === "string") &&
617
644
  Array.isArray(v.senders) &&
618
645
  v.senders.every((sender) => isText(sender)) &&
619
- (v.leaseMs === undefined || (isFinite(v.leaseMs) && v.leaseMs > 0))
646
+ (v.leaseMs === undefined || (isFinite(v.leaseMs) && v.leaseMs > 0)) &&
647
+ (v.humanGate === undefined || isHumanGatePending(v.humanGate))
620
648
  );
621
649
  };
622
650
 
@@ -693,6 +721,24 @@ export type RunFinishedSend =
693
721
  | { kind: "no-binding"; instance: string }
694
722
  | { kind: "failed"; instance: string; type: string; reason: string };
695
723
 
724
+ /** Wake one unit after durable outside input was appended. The payload is
725
+ * deliberately empty: the unit re-reads its own event list before acting. */
726
+ export async function sendUnitNudge(
727
+ workflow: WorkflowSender | undefined,
728
+ key: { instanceId: string; unit: string },
729
+ ): Promise<RunFinishedSend> {
730
+ const instance = key.instanceId;
731
+ if (!workflow) return { kind: "no-binding", instance };
732
+ const type = unitNudgeEventType(key);
733
+ try {
734
+ const handle = await workflow.get(instance);
735
+ await handle.sendEvent({ type, payload: {} });
736
+ return { kind: "sent", instance, type };
737
+ } catch (err) {
738
+ return { kind: "failed", instance, type, reason: err instanceof Error ? err.message : String(err) };
739
+ }
740
+ }
741
+
696
742
  /** The one send per settled head (http-ingress.md item 12): best effort like
697
743
  * `sendRunFinished` — a refusal is answered, never thrown, and the parent's
698
744
  * bounded merge wait times out on its own. */
@@ -40,6 +40,7 @@
40
40
  // the shim Worker imports this by relative path.
41
41
 
42
42
  import { DAY_MS, DEFAULT_GRANT, GRANT_RENEWALS_MAX, IDLE_DAYS_MAX, type Grant, type GrantSource } from "../budgets.js";
43
+ import { isFindingShape, type Finding } from "../reviewVerdict.js";
43
44
  import { DEFAULT_VERBOSITY, isVerbosity, type Verbosity } from "../verbosity.js";
44
45
  import {
45
46
  applyReturn,
@@ -51,6 +52,7 @@ import {
51
52
  type PlanCursor,
52
53
  type UnitStatus,
53
54
  renderUnitReport,
55
+ reenterApprovedRebase,
54
56
  settleUnit,
55
57
  startUnit,
56
58
  type ChildFacts,
@@ -114,6 +116,7 @@ export type CoordinatorStepRoute =
114
116
  | "unit-wake"
115
117
  | "checks"
116
118
  | "merge"
119
+ | "rebase"
117
120
  | "finish";
118
121
 
119
122
  /** What a step stores: the bot's reply as the wire carried it — its status and
@@ -341,6 +344,8 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
341
344
  // they were written (the run record's validator), read here as they are.
342
345
  const facts = run as unknown as Omit<Extract<ChildFacts, { finished: true }>, "finished" | "status">;
343
346
  const {
347
+ finishedAt,
348
+ reviewAskedAt,
344
349
  finalReply,
345
350
  pr,
346
351
  headSha,
@@ -365,6 +370,8 @@ function readRecordReturn(step: string, a: BotAnswer): StepReturn {
365
370
  run: {
366
371
  finished: true,
367
372
  status: run.status as Extract<ChildFacts, { finished: true }>["status"],
373
+ ...(typeof finishedAt === "number" ? { finishedAt } : {}),
374
+ ...(typeof reviewAskedAt === "number" ? { reviewAskedAt } : {}),
368
375
  ...(finalReply !== undefined ? { finalReply } : {}),
369
376
  ...(pr !== undefined ? { pr } : {}),
370
377
  ...(headSha !== undefined ? { headSha } : {}),
@@ -401,6 +408,33 @@ function isCommitChecks(v: unknown): v is { total: number; pending: string[]; fa
401
408
  return typeof c.total === "number" && names(c.pending) && names(c.failed);
402
409
  }
403
410
 
411
+ function entryHumanGate(v: unknown):
412
+ | {
413
+ round: number;
414
+ findings: Finding[];
415
+ verdict: "approve" | "request_changes";
416
+ answer: string;
417
+ author: string;
418
+ commentId: string;
419
+ }
420
+ | undefined {
421
+ if (typeof v !== "object" || v === null) return undefined;
422
+ const row = v as Record<string, unknown>;
423
+ if (typeof row.round !== "number" || !Number.isInteger(row.round) || row.round < 1) return undefined;
424
+ if (!Array.isArray(row.findings) || !row.findings.every(isFindingShape)) return undefined;
425
+ if (row.verdict !== "approve" && row.verdict !== "request_changes") return undefined;
426
+ if (typeof row.answer !== "string" || typeof row.author !== "string" || typeof row.commentId !== "string")
427
+ return undefined;
428
+ return {
429
+ round: row.round,
430
+ findings: row.findings,
431
+ verdict: row.verdict,
432
+ answer: row.answer,
433
+ author: row.author,
434
+ commentId: row.commentId,
435
+ };
436
+ }
437
+
404
438
  function prCheckReturn(step: string, a: BotAnswer): StepReturn {
405
439
  const { ok, state, prNumber, url, headSha, sha, mergedAt, at } = a.body;
406
440
  if (ok === true && state === "none") {
@@ -446,6 +480,7 @@ function prCheckReturn(step: string, a: BotAnswer): StepReturn {
446
480
  // The base's merge-queue rule beside the checks (issue 2011): the
447
481
  // merge:person report says the person's merge is queued.
448
482
  ...(typeof a.body.baseHasMergeQueue === "boolean" ? { baseHasMergeQueue: a.body.baseHasMergeQueue } : {}),
483
+ ...(entryHumanGate(a.body.humanGate) !== undefined ? { humanGate: entryHumanGate(a.body.humanGate)! } : {}),
449
484
  },
450
485
  at,
451
486
  };
@@ -505,13 +540,26 @@ function mergeReturn(step: string, a: BotAnswer): StepReturn {
505
540
  return { type: "merge", step, outcome: "merged", sha, at };
506
541
  if (
507
542
  ok === true &&
508
- (outcome === "pending" || outcome === "refused" || outcome === "enqueued" || outcome === "removed") &&
543
+ (outcome === "pending" ||
544
+ outcome === "refused" ||
545
+ outcome === "conflict" ||
546
+ outcome === "enqueued" ||
547
+ outcome === "removed") &&
509
548
  typeof reason === "string"
510
549
  )
511
550
  return { type: "merge", step, outcome, reason, at };
512
551
  throw new UnreadableAnswer("merge", a, "outcome");
513
552
  }
514
553
 
554
+ function rebaseReturn(step: string, a: BotAnswer): StepReturn {
555
+ const { ok, outcome, headSha, reason, at } = a.body;
556
+ if (ok === true && (outcome === "carried" || outcome === "changed") && typeof headSha === "string")
557
+ return { type: "rebase", step, outcome, headSha, at };
558
+ if (ok === true && (outcome === "conflict" || outcome === "refused") && typeof reason === "string")
559
+ return { type: "rebase", step, outcome, reason, at };
560
+ throw new UnreadableAnswer("rebase", a, "outcome");
561
+ }
562
+
515
563
  // ---- the steps ----------------------------------------------------------------------------------------
516
564
 
517
565
  /** One bot call inside a step: a reply that is not the bot's answer and a
@@ -706,6 +754,16 @@ async function perform(
706
754
  ),
707
755
  ),
708
756
  );
757
+ case "rebase":
758
+ return rebaseReturn(
759
+ action.step,
760
+ answerOf(
761
+ "rebase",
762
+ await step.do(action.step, STEP_CONFIG, () =>
763
+ call(bot, "rebase", { ...tag, prNumber: action.prNumber, headSha: action.headSha }),
764
+ ),
765
+ ),
766
+ );
709
767
  }
710
768
  }
711
769
 
@@ -838,7 +896,7 @@ async function runUnit(
838
896
  let notes = 0;
839
897
  let endedAt: number | undefined;
840
898
  try {
841
- for (;;) {
899
+ pipeline: for (;;) {
842
900
  const action = nextAction(state);
843
901
  if (action.type === "end") return { ...action.ending, ...(endedAt !== undefined ? { endedAt } : {}) };
844
902
  // Keep the current round across its non-round phases (for example merge)
@@ -892,9 +950,9 @@ async function runUnit(
892
950
  // The checks at the approved head (record 0055): the report's
893
951
  // headline is a claim about them, never "merge-ready" over a red one.
894
952
  ...(check.pr.checks !== undefined ? { checks: check.pr.checks } : {}),
895
- // The ready state beside them (agent-ship item 9): a conflicting
896
- // head, or one carrying an unsquashed fix-up commit, is reported
897
- // approved-but-not-merge-ready, never "merge-ready".
953
+ // The ready state beside them (agent-ship item 9): a conflict
954
+ // feeds the runner re-entry below, while an unsquashed fix-up
955
+ // commit remains an approved-but-not-merge-ready report.
898
956
  ...(check.pr.mergeableState !== undefined ? { mergeableState: check.pr.mergeableState } : {}),
899
957
  ...(check.pr.fixupCommits !== undefined ? { fixupCommits: check.pr.fixupCommits } : {}),
900
958
  // The base's merge-queue rule (issue 2011): the merge:person
@@ -907,6 +965,14 @@ async function runUnit(
907
965
  // the report simply omits the fact
908
966
  }
909
967
  }
968
+ // A person-merge pipeline used to publish this dirty state as its
969
+ // ending and hand the pull request to the sweep. It remains the
970
+ // owner instead: rung one runs now, and only a conflict buys a coding
971
+ // child under this run's remaining lease.
972
+ if (note.ending.kind === "merge_ready" && endFacts?.mergeableState === "dirty") {
973
+ state = reenterApprovedRebase(state);
974
+ continue pipeline;
975
+ }
910
976
  // The last coding child's run is named so the bot can put its handoff
911
977
  // — the deviations it recorded — on the unit's board issue beside the
912
978
  // ending (agent-ship item 14).
@@ -928,6 +994,7 @@ async function runUnit(
928
994
  ...(note.ending.from !== undefined ? { from: note.ending.from } : {}),
929
995
  spendUsd: note.ending.spendUsd,
930
996
  ...(note.ending.handoff !== undefined ? { handoff: note.ending.handoff } : {}),
997
+ ...(note.ending.humanGate !== undefined ? { humanGate: note.ending.humanGate } : {}),
931
998
  }
932
999
  : {}),
933
1000
  },
@@ -1138,6 +1205,7 @@ async function waitOnIdle(
1138
1205
  ...(answer.runId !== undefined ? { previousRunId: answer.runId } : {}),
1139
1206
  ...(answer.handoff !== undefined ? { previousHandoff: answer.handoff } : {}),
1140
1207
  ...(answer.texts.length > 0 ? { texts: answer.texts } : {}),
1208
+ ...(answer.humanGate !== undefined ? { humanGate: answer.humanGate } : {}),
1141
1209
  ...(answer.leaseMs !== undefined
1142
1210
  ? { resume: { leaseMs: answer.leaseMs, attempt: (currentSession?.resume?.attempt ?? 0) + 1 } }
1143
1211
  : {}),
@@ -1207,7 +1275,7 @@ async function walk(step: StepRunner, bot: CoordinatorBot, instanceId: string):
1207
1275
  next,
1208
1276
  stepPrefixOf(next, session),
1209
1277
  ending,
1210
- plan.idleDays,
1278
+ ending.parkDays ?? plan.idleDays,
1211
1279
  session,
1212
1280
  );
1213
1281
  if (parked.kind === "ending") {
@@ -328,8 +328,14 @@ function findingCounts(findings: readonly Finding[] | undefined): string {
328
328
  return `${findings.length} finding${findings.length === 1 ? "" : "s"}: ${by}`;
329
329
  }
330
330
 
331
- /** A table cell: pipes escaped so the row holds, already one line. */
332
- const cell = (text: string): string => oneLine(text).replace(/\|/g, "\\|");
331
+ /** A one-line Markdown table cell. Backslashes are escaped before pipes so
332
+ * input cannot consume the pipe escape and split the row. */
333
+ export const escapeMarkdownTableCell = (text: string): string =>
334
+ oneLine(text).replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
335
+
336
+ /** Decode the two escapes emitted by `escapeMarkdownTableCell`. This also
337
+ * accepts the legacy form that escaped pipes without first escaping slashes. */
338
+ export const unescapeMarkdownTableCell = (text: string): string => text.replace(/\\([\\|])/g, "$1");
333
339
 
334
340
  /** Whether a finding's `file` is a path a blob URL can point at: no
335
341
  * whitespace, no backtick, not a URL. */
@@ -339,7 +345,7 @@ const isRepoPath = (file: string): boolean => /^[^\s`]+$/.test(file) && !/^[a-z]
339
345
  * file is a path and a target is known. */
340
346
  function whereCell(f: Finding, target: ReviewBodyTarget | undefined): string {
341
347
  const location = f.line !== undefined ? `${f.file}:${f.line}` : f.file;
342
- const label = `\`${cell(location)}\``;
348
+ const label = `\`${escapeMarkdownTableCell(location)}\``;
343
349
  if (!target || !isRepoPath(f.file)) return label;
344
350
  const path = encodeURI(f.file).replace(/#/g, "%23").replace(/\?/g, "%3F");
345
351
  const url = `https://github.com/${target.repo}/blob/${target.head}/${path}${f.line !== undefined ? `#L${f.line}` : ""}`;
@@ -360,6 +366,7 @@ function verdictMarker(verdict: ReviewVerdict | undefined, target: ReviewBodyTar
360
366
  id: f.id,
361
367
  severity: f.severity,
362
368
  file: f.file,
369
+ title: f.title,
363
370
  ...(f.line !== undefined ? { line: f.line } : {}),
364
371
  ...(f.humanGated ? { humanGated: true } : {}),
365
372
  })),
@@ -395,7 +402,10 @@ export function buildReviewPostBody(
395
402
  [
396
403
  "| Severity | Finding | Where |",
397
404
  "| --- | --- | --- |",
398
- ...findings.map((f) => `| ${f.severity} | **${cell(f.id)}** ${cell(f.title)} | ${whereCell(f, target)} |`),
405
+ ...findings.map(
406
+ (f) =>
407
+ `| ${f.severity} | **${escapeMarkdownTableCell(f.id)}** ${escapeMarkdownTableCell(f.title)} | ${whereCell(f, target)} |`,
408
+ ),
399
409
  ].join("\n"),
400
410
  );
401
411
  }
@@ -517,7 +527,7 @@ const DISPOSITION_KINDS: readonly string[] = ["fixed", "declined"];
517
527
 
518
528
  const isRecordLike = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
519
529
 
520
- function isFindingShape(v: unknown): v is Finding {
530
+ export function isFindingShape(v: unknown): v is Finding {
521
531
  if (!isRecordLike(v)) return false;
522
532
  return (
523
533
  typeof v.id === "string" &&
@@ -51,6 +51,20 @@ export function sameCommit(a: string, b: string): boolean {
51
51
  return n >= 7 && a.slice(0, n) === b.slice(0, n);
52
52
  }
53
53
 
54
+ /** A mismatch sentence that cannot print two identical accepted-looking
55
+ * prefixes: it names the authoritative source, both values and their first
56
+ * differing hex position. */
57
+ function mismatchReason(source: "workspace-observed" | "model-reported", reviewed: string, expected: string): string {
58
+ const compared = Math.min(reviewed.length, expected.length);
59
+ let at = 0;
60
+ while (at < compared && reviewed[at] === expected[at]) at += 1;
61
+ const divergence =
62
+ at < compared
63
+ ? `they first differ at hex ${at + 1} (${reviewed[at]} ≠ ${expected[at]})`
64
+ : `the ${reviewed.length}-hex reviewed value ends before the ${expected.length}-hex PR head`;
65
+ return `${source} reviewed head ${reviewed} is not the PR head ${expected}; ${divergence}`;
66
+ }
67
+
54
68
  /**
55
69
  * Decide whether the reviewed head is the PR head. `expected` is the PR head
56
70
  * from repo resolution; `observed` the dispatcher's own `git rev-parse HEAD`
@@ -63,7 +77,7 @@ export function checkReviewedHead(input: { expected?: string; observed?: string;
63
77
  if (observed) {
64
78
  return sameCommit(observed, expected)
65
79
  ? { ok: true, head: observed, source: "observed" }
66
- : { ok: false, reason: `reviewed head ${observed.slice(0, 7)} is not the PR head ${expected.slice(0, 7)}` };
80
+ : { ok: false, reason: mismatchReason("workspace-observed", observed, expected) };
67
81
  }
68
82
  const reported = normalizeHead(input.reported);
69
83
  if (!reported)
@@ -73,5 +87,5 @@ export function checkReviewedHead(input: { expected?: string; observed?: string;
73
87
  };
74
88
  return sameCommit(reported, expected)
75
89
  ? { ok: true, head: reported, source: "reported" }
76
- : { ok: false, reason: `reviewed head ${reported.slice(0, 7)} is not the PR head ${expected.slice(0, 7)}` };
90
+ : { ok: false, reason: mismatchReason("model-reported", reported, expected) };
77
91
  }
@@ -480,7 +480,7 @@ function renderFirstInstruction(rebase: ChildContract["rebase"]): string {
480
480
  const gates = `${FAST_GATES_POINTER} ${GATE_RECEIPTS}`;
481
481
  return (
482
482
  `Rebase ${branch} onto ${onto} before any other work — the parent unit has merged and the base has moved; ` +
483
- `the only writes are your own on that branch. A conflict ends the unit: report it as the handoff and stop. ` +
483
+ `the only writes are your own on that branch. ` +
484
484
  `Push the branch as soon as the change exists and the fast gates pass — the project's full verification ` +
485
485
  `is CI's gate, run there after the push with any fix as a further commit; an unpushed tree does not ` +
486
486
  `survive the run's end. ${gates} ${REBASE_POINTER} At the wind-down note, ` +