@coreplane/switchboard 1.260.2 → 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.
@@ -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" &&
@@ -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, ` +
@@ -59,6 +59,7 @@ import { parsePlanUnit, planUnitIds } from "./contract.js";
59
59
  import {
60
60
  carve,
61
61
  DEFAULT_GRANT,
62
+ IDLE_DAYS_MAX,
62
63
  loopPosition,
63
64
  MERGE_WAIT_ASK_MINUTES,
64
65
  MINUTE_MS,
@@ -494,8 +495,10 @@ export type Brief =
494
495
  pr: number;
495
496
  headSha?: string;
496
497
  round: number;
497
- /** The previous review round's run and the coding run that answered its findings, for a re-review. */
498
- prior?: { reviewRunId: string; codingRunId?: string };
498
+ /** The previous review round and the coding run that answered it. A
499
+ * recovered posted verdict carries findings by value when no run row
500
+ * belongs to this attempt. */
501
+ prior?: { reviewRunId?: string; codingRunId?: string; findings?: Finding[] };
499
502
  /** The previous round's check findings (record 0055): they sit on no
500
503
  * run's record, so the brief carries them beside `prior` by value. */
501
504
  checks?: Finding[];
@@ -504,7 +507,12 @@ export type Brief =
504
507
  kind: "findings";
505
508
  unit: string;
506
509
  pr: number;
507
- reviewRunId: string;
510
+ /** The review run normally supplies the findings. An adopted attempt
511
+ * can recover them from the last posted verdict marker instead. */
512
+ reviewRunId?: string;
513
+ findings?: Finding[];
514
+ /** Human replies that answered a parked finding, attributed at intake. */
515
+ answers?: string[];
508
516
  /** The round's check findings (record 0055), carried by value — the
509
517
  * review run's record holds only the reviewer's own findings. */
510
518
  checks?: Finding[];
@@ -522,6 +530,20 @@ export interface PrRef {
522
530
  url: string;
523
531
  }
524
532
 
533
+ /** A human-gated review question parked on its live unit. The review's typed
534
+ * findings and the pull request survive the wait; `reviewRunId` is absent when
535
+ * an adopted attempt recovered the question from GitHub's verdict marker. */
536
+ export interface HumanGatePending {
537
+ pr: PrRef;
538
+ round: number;
539
+ findings: Finding[];
540
+ verdict: "approve" | "request_changes";
541
+ reviewRunId?: string;
542
+ headSha?: string;
543
+ /** When the verdict asked the question, for fencing older unit events. */
544
+ askedAt?: number;
545
+ }
546
+
525
547
  /** What the coordinator asks for next. `step` is the Workflow step's name, and
526
548
  * for a spawn the second half of the child's idempotency key. */
527
549
  export type CoordinatorAction =
@@ -577,6 +599,10 @@ export type ChildFacts =
577
599
  | {
578
600
  finished: true;
579
601
  status: RunStatus;
602
+ /** The terminal record time; fallback fence for an older review record. */
603
+ finishedAt?: number;
604
+ /** When the posted verdict became the person's question. */
605
+ reviewAskedAt?: number;
580
606
  finalReply?: string;
581
607
  /** A coding child's `pr_opened`. */
582
608
  pr?: { number: number; url: string; created: boolean };
@@ -659,6 +685,16 @@ export type PrCheck =
659
685
  * read beside the checks on the ending's facts read, so a `merge_ready`
660
686
  * report tells the person their merge is queued, never a direct one. */
661
687
  baseHasMergeQueue?: boolean;
688
+ /** A person's newest pull-request comment answered the last posted
689
+ * human-gated verdict. Entry starts its fix round before any review. */
690
+ humanGate?: {
691
+ round: number;
692
+ findings: Finding[];
693
+ verdict: "approve" | "request_changes";
694
+ answer: string;
695
+ author: string;
696
+ commentId: string;
697
+ };
662
698
  }
663
699
  | { state: "merged"; prNumber: number; url: string; sha: string; mergedAt: string };
664
700
 
@@ -793,12 +829,9 @@ export type UnitEnding =
793
829
  * The unit is done and its dependents start on a base that carries it. */
794
830
  | { kind: "already_landed"; landed: HandoffLanded[]; round: RoundRef; runId: string; reviewRounds: number }
795
831
  | { kind: "merge_ready"; pr: PrRef; reviewRounds: number }
796
- /** Every finding the round would act on is human-gated — a receipt only a
797
- * person can produce (issue 1990; the reviewer set the flag through
798
- * `submit_verdict`, agent-review item 5) — so a fix round could change
799
- * nothing: the unit ends held for a person, the ending carrying the
800
- * human-gated rows, and a re-issue with the pull request resumes at the
801
- * review round once the receipt is posted (item 10's resume path). */
832
+ /** The report nested inside a parked human-gated question. The unit never
833
+ * publishes this as its terminal kind: `parkHumanGate` carries it under an
834
+ * idle ending until a person's answer opens the fix round. */
802
835
  | {
803
836
  kind: "held";
804
837
  cause?: undefined;
@@ -938,6 +971,10 @@ export type UnitEnding =
938
971
  handoff?: Handoff;
939
972
  round?: RoundRef;
940
973
  reviewRounds: number;
974
+ /** A parked human-gated review is always an idle, independent of the
975
+ * optional idle policy. Its question is the unit's pending state. */
976
+ humanGate?: HumanGatePending;
977
+ parkDays?: number;
941
978
  };
942
979
 
943
980
  /** The kinds that idle: every ending but the ended ones — the unit is
@@ -945,10 +982,9 @@ export type UnitEnding =
945
982
  * a reply could continue it. `merged`, `already_landed`, `merge_ready` and
946
983
  * `refused` never idle: the first two are done, merge-ready waits only for a
947
984
  * person's merge, and a refused child would be refused again. A gated or
948
- * draft `held` never idles either — it waits only for a person's receipt at
949
- * the pull request, which no thread reply can produce — but a blocked hold
950
- * (issue 2086) waits for exactly the person's word the idle's wake carries,
951
- * so it alone idles; `idleEnding` draws that line by cause. */
985
+ * draft `held` never idles either. A blocked hold (issue 2086) follows the
986
+ * configured idle policy; a human-gated hold takes `parkHumanGate` instead,
987
+ * which always creates the pending idle question. */
952
988
  export type IdleWhy =
953
989
  Exclude<UnitEnding["kind"], "idle" | "merged" | "already_landed" | "merge_ready" | "refused"> | RenewalWhy;
954
990
 
@@ -1014,6 +1050,9 @@ export interface LeaseSegmentProgress {
1014
1050
  previousHandoff?: Handoff;
1015
1051
  /** Words that woke an idle segment, already attributed in arrival order. */
1016
1052
  texts?: string[];
1053
+ /** The human-gated question those words answer. It resumes as a findings
1054
+ * round before another review, without spending a renewal. */
1055
+ humanGate?: HumanGatePending;
1017
1056
  /** A stopped segment reopens under the remainder of its cut lease. Each
1018
1057
  * reopen gets a fresh attempt so its Workflow steps cannot replay the
1019
1058
  * stopped attempt's cached answers. */
@@ -1165,6 +1204,8 @@ export interface UnitPipelineState {
1165
1204
  * ids are unique within one round only. A disposition naming an id the review
1166
1205
  * never issued is dropped at the match (`matchDispositions`). */
1167
1206
  readonly findingsByRound: Readonly<Record<number, Finding[]>>;
1207
+ /** Human answers folded into a recovered/parked findings brief. */
1208
+ readonly humanAnswersByRound: Readonly<Record<number, string[]>>;
1168
1209
  readonly dispositionsByRound: Readonly<Record<number, FindingDisposition[]>>;
1169
1210
  readonly reviewRunByRound: Readonly<Record<number, string>>;
1170
1211
  /** The coding run each round's findings step dispatched. */
@@ -1208,20 +1249,29 @@ export const BUSY_RETRY_MS = SHIP_WAIT.busyRetryMinutes * MIN;
1208
1249
  // ---- the unit pipeline: opening and the next action -----------------------------------------------
1209
1250
 
1210
1251
  export function openUnitPipeline(input: UnitPipelineInput, at: number): UnitPipelineState {
1252
+ const pending = input.session?.humanGate;
1211
1253
  const base: UnitPipelineState = {
1212
1254
  input,
1213
1255
  startedAt: at,
1214
1256
  clock: at,
1215
1257
  phase: { at: "pre-check" },
1216
- reviewRounds: 0,
1258
+ reviewRounds: pending?.round ?? 0,
1217
1259
  rebaseAttempts: 0,
1218
1260
  spentMs: { coding: 0, review: 0, waiting: 0 },
1219
1261
  spendUsd: input.session?.spendUsd ?? 0,
1220
- findingsByRound: {},
1262
+ findingsByRound: pending ? { [pending.round]: pending.findings } : {},
1263
+ humanAnswersByRound: pending && input.session?.texts !== undefined ? { [pending.round]: input.session.texts } : {},
1221
1264
  dispositionsByRound: {},
1222
- reviewRunByRound: {},
1265
+ reviewRunByRound: pending?.reviewRunId !== undefined ? { [pending.round]: pending.reviewRunId } : {},
1223
1266
  findingsRunByRound: {},
1267
+ ...(pending !== undefined
1268
+ ? {
1269
+ pr: pending.pr,
1270
+ ...(pending.headSha !== undefined ? { lastReviewHead: pending.headSha } : {}),
1271
+ }
1272
+ : {}),
1224
1273
  };
1274
+ if (pending !== undefined) return enterRound(base, { index: pending.round, kind: "findings" }).state;
1225
1275
  if (!input.resume) return base;
1226
1276
  const url = input.resume.url ?? `https://github.com/${input.repo}/pull/${input.resume.pr}`;
1227
1277
  const resumed: UnitPipelineState = {
@@ -1294,26 +1344,40 @@ function briefFor(s: UnitPipelineState, round: RoundRef): Brief {
1294
1344
  return { kind: "rebase", unit, pr, headSha: s.lastReviewHead!, base: s.input.base };
1295
1345
  }
1296
1346
  if (round.kind === "findings") {
1297
- const checks = checkFindingsOf(s.findingsByRound[round.index]);
1347
+ const issued = s.findingsByRound[round.index] ?? [];
1348
+ const checks = checkFindingsOf(issued);
1349
+ const reviewRunId = s.reviewRunByRound[round.index];
1350
+ const answers = s.humanAnswersByRound[round.index];
1298
1351
  return {
1299
1352
  kind: "findings",
1300
1353
  unit,
1301
1354
  pr,
1302
- reviewRunId: s.reviewRunByRound[round.index]!,
1355
+ ...(reviewRunId !== undefined ? { reviewRunId } : {}),
1356
+ ...(reviewRunId === undefined || (answers !== undefined && answers.length > 0) ? { findings: issued } : {}),
1357
+ ...(answers !== undefined && answers.length > 0 ? { answers } : {}),
1303
1358
  ...(checks.length > 0 ? { checks } : {}),
1304
1359
  };
1305
1360
  }
1361
+ const priorFindings = s.findingsByRound[round.index - 1] ?? [];
1306
1362
  const priorReview = s.reviewRunByRound[round.index - 1];
1307
1363
  const priorCoding = s.findingsRunByRound[round.index - 1];
1308
- const priorChecks = checkFindingsOf(s.findingsByRound[round.index - 1]);
1364
+ const priorChecks = checkFindingsOf(priorFindings);
1309
1365
  return {
1310
1366
  kind: "review",
1311
1367
  unit,
1312
1368
  pr,
1313
1369
  ...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
1314
1370
  round: round.index,
1315
- ...(priorReview !== undefined
1316
- ? { prior: { reviewRunId: priorReview, ...(priorCoding !== undefined ? { codingRunId: priorCoding } : {}) } }
1371
+ ...(priorReview !== undefined || (priorCoding !== undefined && priorFindings.length > 0)
1372
+ ? {
1373
+ prior: {
1374
+ ...(priorReview !== undefined ? { reviewRunId: priorReview } : {}),
1375
+ ...(priorReview === undefined || s.humanAnswersByRound[round.index - 1] !== undefined
1376
+ ? { findings: priorFindings }
1377
+ : {}),
1378
+ ...(priorCoding !== undefined ? { codingRunId: priorCoding } : {}),
1379
+ },
1380
+ }
1317
1381
  : {}),
1318
1382
  ...(priorChecks.length > 0 ? { checks: priorChecks } : {}),
1319
1383
  };
@@ -1454,9 +1518,9 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
1454
1518
  function idleEnding(s: UnitPipelineState, ending: UnitEnding): Extract<UnitEnding, { kind: "idle" }> | undefined {
1455
1519
  if ((s.input.idleDays ?? 0) <= 0) return undefined;
1456
1520
  if (NEVER_IDLES.has(ending.kind)) return undefined;
1457
- // A gated or draft hold waits only for a person's receipt at the pull
1458
- // request — no thread reply can continue it — while a blocked hold (issue
1459
- // 2086) waits for exactly the person's word the wake carries, so it idles.
1521
+ // A draft hold is not continued by a reply. A blocked hold (issue 2086)
1522
+ // waits for the person's word. Human-gated questions bypass this mapping:
1523
+ // `parkHumanGate` always parks them with their typed pending state.
1460
1524
  if (ending.kind === "held" && ending.cause !== "blocked") return undefined;
1461
1525
  const old = ending as Exclude<UnitEnding, { kind: "idle" | "merged" | "already_landed" | "merge_ready" | "refused" }>;
1462
1526
  const grant = s.input.grant ?? DEFAULT_GRANT;
@@ -1512,7 +1576,7 @@ const heldEnding = (
1512
1576
  round: RoundRef,
1513
1577
  findings: Finding[],
1514
1578
  verdict: "approve" | "request_changes",
1515
- ): UnitEnding => ({
1579
+ ): Extract<UnitEnding, { kind: "held" }> => ({
1516
1580
  kind: "held",
1517
1581
  ...(s.pr !== undefined ? { pr: s.pr } : {}),
1518
1582
  round,
@@ -1521,6 +1585,48 @@ const heldEnding = (
1521
1585
  reviewRounds: s.reviewRounds,
1522
1586
  });
1523
1587
 
1588
+ /** A human-gated finding is a question, not a terminal hold. Park the live
1589
+ * unit in record 0051's idle state regardless of the optional idle policy, so
1590
+ * either a thread reply or a pull-request comment can become its fix brief. */
1591
+ function parkHumanGate(
1592
+ s: UnitPipelineState,
1593
+ round: RoundRef,
1594
+ findings: Finding[],
1595
+ verdict: "approve" | "request_changes",
1596
+ askedAt: number,
1597
+ notes: CoordinatorNote[],
1598
+ ): Transition {
1599
+ const held = heldEnding(s, round, findings, verdict);
1600
+ const humanGate: HumanGatePending = {
1601
+ pr: s.pr!,
1602
+ round: round.index,
1603
+ findings,
1604
+ verdict,
1605
+ ...(s.reviewRunByRound[round.index] !== undefined ? { reviewRunId: s.reviewRunByRound[round.index] } : {}),
1606
+ ...(s.lastReviewHead !== undefined ? { headSha: s.lastReviewHead } : {}),
1607
+ askedAt,
1608
+ };
1609
+ const grant = s.input.grant ?? DEFAULT_GRANT;
1610
+ const ending: Extract<UnitEnding, { kind: "idle" }> = {
1611
+ kind: "idle",
1612
+ why: "held",
1613
+ idled: held,
1614
+ renewalsLeft: Math.max(0, grant.renewals - (s.input.session?.renewalsSpent ?? 0)),
1615
+ ...(s.lastReviewHead !== undefined ? { from: s.lastReviewHead } : {}),
1616
+ ...(s.lastCodingRunId !== undefined ? { runId: s.lastCodingRunId } : {}),
1617
+ spendUsd: s.spendUsd,
1618
+ ...(s.lastCodingHandoff !== undefined ? { handoff: s.lastCodingHandoff } : {}),
1619
+ round,
1620
+ reviewRounds: s.reviewRounds,
1621
+ humanGate,
1622
+ parkDays: IDLE_DAYS_MAX,
1623
+ };
1624
+ return {
1625
+ state: { ...s, phase: ENDED, ending },
1626
+ notes: [...notes, { type: "ended", ending }],
1627
+ };
1628
+ }
1629
+
1524
1630
  const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): RoundNote => ({
1525
1631
  type: "round",
1526
1632
  index: round.index,
@@ -1564,10 +1670,11 @@ function enterRound(s: UnitPipelineState, round: RoundRef, notes: CoordinatorNot
1564
1670
  };
1565
1671
  }
1566
1672
 
1567
- /** A rebase changed the approved patch, so review is mandatory even when
1568
- * the ordinary findings-round count is spent. The run's remaining lease is
1569
- * the bound: `enterRound` refuses when another review no longer fits. */
1570
- function reviewAfterRebase(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
1673
+ /** A rebase changed the approved patch, or a person's answer enabled a
1674
+ * human-gated fix, so review is mandatory even when the ordinary findings-
1675
+ * round count is spent. The run's remaining lease is the bound: `enterRound`
1676
+ * refuses when another review no longer fits. */
1677
+ function mandatoryReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transition {
1571
1678
  return enterRound(s, { index: s.reviewRounds + 1, kind: "review" }, notes);
1572
1679
  }
1573
1680
 
@@ -1634,13 +1741,16 @@ function settleCoding(
1634
1741
  [roundNote(round, "stopped")],
1635
1742
  );
1636
1743
  // A mechanical WIP push means the child did not declare the work ready for
1637
- // review. Keep the unit branch as the resumption point and abort this
1638
- // attempt; an ordinary push still takes the established recover-PR path.
1639
- if (facts.status === "failed" && checkpoint !== undefined) {
1744
+ // review, whatever terminal status its own answer produced. Keep the unit
1745
+ // branch as the resumption point and abort this attempt; an ordinary push
1746
+ // still takes the established recover-PR path.
1747
+ if (checkpoint !== undefined) {
1640
1748
  const cause =
1641
1749
  facts.failure?.kind === "provider_transient"
1642
1750
  ? "the model provider's transport retry budget was spent"
1643
- : "it failed before finishing";
1751
+ : facts.status === "failed"
1752
+ ? "it failed before finishing"
1753
+ : "it ended before its work was ready for review";
1644
1754
  return end(
1645
1755
  next,
1646
1756
  {
@@ -1766,9 +1876,10 @@ function settleReview(
1766
1876
  },
1767
1877
  notes,
1768
1878
  );
1769
- // Every gated finding is human-gated (issue 1990): a fix round could
1770
- // change nothing, so the unit ends held for a person instead.
1771
- if (allHumanGated(gated)) return end(next, heldEnding(next, round, gated, "approve"), notes);
1879
+ // Every gated finding is human-gated: ask a person and park the live
1880
+ // unit; an answer later opens the fix round with the question attached.
1881
+ if (allHumanGated(gated))
1882
+ return parkHumanGate(next, round, gated, "approve", facts.reviewAskedAt ?? facts.finishedAt ?? s.clock, notes);
1772
1883
  if (next.reviewRounds >= next.input.caps.maxRounds)
1773
1884
  return end(
1774
1885
  next,
@@ -1797,11 +1908,18 @@ function settleReview(
1797
1908
  },
1798
1909
  notes,
1799
1910
  );
1800
- // Every finding of the round is human-gated (issue 1990): no fix round can
1801
- // change anything, so the unit ends held for a person's receipt instead of
1802
- // spending a coding child — or the round cap — on it.
1911
+ // Every finding of the round is human-gated: no fix round can change
1912
+ // anything yet, so park the live unit for a person's answer before asking
1913
+ // the round cap.
1803
1914
  if (allHumanGated(verdict.findings ?? []))
1804
- return end(next, heldEnding(next, round, verdict.findings ?? [], "request_changes"), notes);
1915
+ return parkHumanGate(
1916
+ next,
1917
+ round,
1918
+ verdict.findings ?? [],
1919
+ "request_changes",
1920
+ facts.reviewAskedAt ?? facts.finishedAt ?? s.clock,
1921
+ notes,
1922
+ );
1805
1923
  if (next.reviewRounds >= next.input.caps.maxRounds)
1806
1924
  return end(
1807
1925
  next,
@@ -2303,12 +2421,14 @@ function roundOnOpenPr(
2303
2421
  },
2304
2422
  [roundNote(round, "aborted")],
2305
2423
  );
2306
- return reviewAfterRebase({ ...next, lastReviewHead: pushed }, [roundNote(round, "pr_opened")]);
2424
+ return mandatoryReview({ ...next, lastReviewHead: pushed }, [roundNote(round, "pr_opened")]);
2307
2425
  }
2426
+ const hasHumanAnswer = round.kind === "findings" && (s.humanAnswersByRound[round.index]?.length ?? 0) > 0;
2308
2427
  if (round.kind === "findings") {
2309
2428
  // Nothing repushed → nothing to re-review, unless every finding of the
2310
2429
  // last review was declined on the record: that re-review verifies the
2311
- // arguments and may concede.
2430
+ // arguments and may concede. A person's answer is itself new evidence for
2431
+ // the mandatory re-review, even when accepting it changes no code.
2312
2432
  const codingHead = normalizeHead(head);
2313
2433
  const reviewedAt = normalizeHead(s.lastReviewHead);
2314
2434
  if (codingHead !== undefined && reviewedAt !== undefined && sameCommit(codingHead, reviewedAt)) {
@@ -2339,27 +2459,30 @@ function roundOnOpenPr(
2339
2459
  },
2340
2460
  [roundNote(round, "aborted")],
2341
2461
  );
2342
- const findings = s.findingsByRound[round.index] ?? [];
2343
- const dispositions = s.dispositionsByRound[round.index] ?? [];
2344
- const allDeclined =
2345
- findings.length > 0 &&
2346
- findings.every((f) => dispositions.find((d) => d.findingId === f.id)?.disposition === "declined");
2347
- if (!allDeclined)
2348
- return end(
2349
- next,
2350
- {
2351
- kind: "aborted",
2352
- reason: `⚠️ Round ${round.index}'s findings step produced no new head — the branch still sits at \`${codingHead.slice(0, 7)}\`, the commit the review already read, and not every finding was declined on the record, so there is nothing new to re-review.`,
2353
- round,
2354
- reviewRounds: next.reviewRounds,
2355
- },
2356
- [roundNote(round, "aborted")],
2357
- );
2462
+ if (!hasHumanAnswer) {
2463
+ const findings = s.findingsByRound[round.index] ?? [];
2464
+ const dispositions = s.dispositionsByRound[round.index] ?? [];
2465
+ const allDeclined =
2466
+ findings.length > 0 &&
2467
+ findings.every((f) => dispositions.find((d) => d.findingId === f.id)?.disposition === "declined");
2468
+ if (!allDeclined)
2469
+ return end(
2470
+ next,
2471
+ {
2472
+ kind: "aborted",
2473
+ reason: `⚠️ Round ${round.index}'s findings step produced no new head — the branch still sits at \`${codingHead.slice(0, 7)}\`, the commit the review already read, and not every finding was declined on the record, so there is nothing new to re-review.`,
2474
+ round,
2475
+ reviewRounds: next.reviewRounds,
2476
+ },
2477
+ [roundNote(round, "aborted")],
2478
+ );
2479
+ }
2358
2480
  }
2359
2481
  }
2360
- return nextReview({ ...next, ...(head !== undefined ? { lastReviewHead: head } : {}) }, [
2361
- roundNote(round, "pr_opened"),
2362
- ]);
2482
+ const reviewed: UnitPipelineState = { ...next, ...(head !== undefined ? { lastReviewHead: head } : {}) };
2483
+ const notes = [roundNote(round, "pr_opened")];
2484
+ if (hasHumanAnswer) return mandatoryReview(reviewed, notes);
2485
+ return nextReview(reviewed, notes);
2363
2486
  }
2364
2487
 
2365
2488
  /** Move the clock and charge the elapsed time to the budget's bucket: a phase
@@ -2421,6 +2544,20 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2421
2544
  pr: { number: r.pr.prNumber, url: r.pr.url },
2422
2545
  lastReviewHead: entryHead,
2423
2546
  };
2547
+ const answered = r.pr.humanGate;
2548
+ if (answered !== undefined) {
2549
+ const round = Math.max(1, answered.round);
2550
+ const withAnswer: UnitPipelineState = {
2551
+ ...adopted,
2552
+ reviewRounds: round,
2553
+ findingsByRound: { ...adopted.findingsByRound, [round]: answered.findings },
2554
+ humanAnswersByRound: {
2555
+ ...adopted.humanAnswersByRound,
2556
+ [round]: [`${answered.author}: ${answered.answer}`],
2557
+ },
2558
+ };
2559
+ return enterRound(withAnswer, { index: round, kind: "findings" });
2560
+ }
2424
2561
  const checks = r.pr.checks;
2425
2562
  const green =
2426
2563
  checks !== undefined && checks.total > 0 && checks.failed.length === 0 && checks.pending.length === 0;
@@ -2733,7 +2870,7 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
2733
2870
  reviewRounds: s.reviewRounds,
2734
2871
  });
2735
2872
  const rebased: UnitPipelineState = { ...clocked, lastReviewHead: r.headSha };
2736
- if (r.outcome === "changed") return reviewAfterRebase(rebased);
2873
+ if (r.outcome === "changed") return mandatoryReview(rebased);
2737
2874
  return enterChecks(rebased, { index: s.reviewRounds, kind: "review" }, []);
2738
2875
  }
2739
2876
  case "merge-wait":
@@ -3004,25 +3141,16 @@ export function renderUnitReport(
3004
3141
  `No renewal was spent and no second coding child ran — a concluded round is not renewed (a renewal continues a budget that ran out mid-work). Next step: your word in this thread — answer what the child raised, and the unit continues from there. ${reissue}`,
3005
3142
  ]);
3006
3143
  }
3007
- // The person's next step is the report's whole point (issue 1990): the
3008
- // human-gated rows are named with the reviewer's own words, and the
3009
- // re-issue line says the attempt resumes at the review round — item 10's
3010
- // resume path — once the receipt stands on the pull request. That path
3011
- // fires only when the invocation carries NO new task text: re-issuing
3012
- // with the task would ADOPT the pull request and run a coding round
3013
- // first (item 10), the very round this ending exists to avoid — so the
3014
- // held case renders its own re-issue line instead of the shared one.
3144
+ // A human-gated row is a question to a person. The enclosing idle keeps
3145
+ // this unit live; either answer surface wakes a fix round carrying both
3146
+ // the typed finding and the attributed answer.
3015
3147
  const rows = e.findings.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ");
3016
- // The quiet copy is the ending in the user's words (record 0066): the
3017
- // held row named, the pull request linked, nothing about the machinery.
3018
- if (!shows(verbosity, "verbose")) return `⏸️ Held: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
3019
- const heldReissue = s.input.generated
3020
- ? `To continue, re-issue \`agent:ship\` in this thread with only the PR URL${e.pr !== undefined ? ` (${e.pr.url})` : ""} — no new task text.`
3021
- : reissue;
3148
+ if (!shows(verbosity, "verbose"))
3149
+ return `⏸️ Waiting for you: ${rows}${e.pr !== undefined ? ` — ${e.pr.url}` : ""}`;
3022
3150
  return join([
3023
- `⏸️ ${e.verdict === "approve" ? "Approved but held" : "Changes requested but held"} after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — every finding of review round ${e.round.index} is human-gated, a receipt only a person can produce: ${rows}. No fix round was opened: a coding child cannot produce the receipt.`,
3151
+ `⏸️ Waiting for a person after ${rounds}${e.pr !== undefined ? `: ${e.pr.url}` : ""} — every finding of review round ${e.round.index} is human-gated, a receipt only a person can produce: ${rows}. The unit stays live; no unanswered fix round was opened.`,
3024
3152
  levelLine,
3025
- `Next step: produce the receipt each finding names and post it on the pull request. ${heldReissue} The re-issued pipeline resumes at the review round — no coding round runs first.`,
3153
+ `Reply in this unit thread or comment on the pull request with the receipt. The answer and finding become the fix round's brief, then review runs again.`,
3026
3154
  ]);
3027
3155
  }
3028
3156
  case "merge_refused":