@coreplane/switchboard 1.244.0 → 1.246.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 (150) hide show
  1. package/dist/assets/config/config.example.yaml +6 -3
  2. package/dist/assets/deploy/cloudflare/worker.ts +2 -0
  3. package/dist/assets/deploy/cloudflare-resident/threadErr.ts +337 -0
  4. package/dist/assets/deploy/cloudflare-resident/tsconfig.json +1 -1
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +40 -272
  6. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +22 -11
  7. package/dist/assets/deploy/secrets.manifest.json +12 -0
  8. package/dist/assets/package-lock.json +3 -3
  9. package/dist/assets/package.json +1 -1
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/agents/registry.ts +34 -10
  12. package/dist/assets/src/config/profile.ts +17 -1
  13. package/dist/assets/src/core/authz/types.ts +8 -0
  14. package/dist/assets/src/core/budgets.ts +43 -0
  15. package/dist/assets/src/core/coordinator/contract.ts +29 -4
  16. package/dist/assets/src/core/coordinator/driver.ts +7 -1
  17. package/dist/assets/src/core/reviewVerdict.ts +239 -30
  18. package/dist/assets/src/core/runEvents.ts +55 -2
  19. package/dist/assets/src/core/runRecord.ts +56 -0
  20. package/dist/assets/src/core/ship/coordinator.ts +48 -23
  21. package/dist/assets/src/core/ship/renewal.ts +10 -3
  22. package/dist/assets/src/execution/bashTimeout.ts +46 -2
  23. package/dist/assets/src/execution/residentRefresh.ts +34 -11
  24. package/dist/assets/src/execution/sandboxErrors.ts +9 -4
  25. package/dist/assets/web/dist/.vite/manifest.json +481 -462
  26. package/dist/assets/web/dist/assets/AppShell-B4EUiSwP.js +1 -0
  27. package/dist/assets/web/dist/assets/CostsPage-D8VjAtuH.js +2 -0
  28. package/dist/assets/web/dist/assets/DeliveryPage-DpQi5XUI.js +1 -0
  29. package/dist/assets/web/dist/assets/HomePage-CvKuIGqg.js +2 -0
  30. package/dist/assets/web/dist/assets/InputMenu-DmqAxkn4.js +1 -0
  31. package/dist/assets/web/dist/assets/NotFoundPage-DDogyiXA.js +1 -0
  32. package/dist/assets/web/dist/assets/PendingTurnRow-BoSLlR6j.js +1 -0
  33. package/dist/assets/web/dist/assets/ResidentDetailPage-CiBdbzkE.js +1 -0
  34. package/dist/assets/web/dist/assets/ResidentsIndexPage-DgtHtd9K.js +1 -0
  35. package/dist/assets/web/dist/assets/{RunFoldRow-BPMQxTcl.js → RunFoldRow-IF4xbz-3.js} +1 -1
  36. package/dist/assets/web/dist/assets/RunRoutePage-DHYa7rHK.js +9 -0
  37. package/dist/assets/web/dist/assets/RunsIndexPage-aWhNTv8r.js +1 -0
  38. package/dist/assets/web/dist/assets/RunsTabs-CY_tdGTU.js +1 -0
  39. package/dist/assets/web/dist/assets/ScheduledPage-gPEBZjcx.js +1 -0
  40. package/dist/assets/web/dist/assets/SettingSelect-VDanrTZi.js +1 -0
  41. package/dist/assets/web/dist/assets/SettingsPage-CFoltSRD.js +1 -0
  42. package/dist/assets/web/dist/assets/StatusDot-DA3eTVrT.js +1 -0
  43. package/dist/assets/web/dist/assets/Tooltip-BgbCkM12.js +1 -0
  44. package/dist/assets/web/dist/assets/UnitRoutePage-DibegaJJ.js +1 -0
  45. package/dist/assets/web/dist/assets/{angular-html-Dail07rr.js → angular-html-D_3slZqf.js} +1 -1
  46. package/dist/assets/web/dist/assets/{angular-ts-BReExio8.js → angular-ts-D1Fv2cpN.js} +1 -1
  47. package/dist/assets/web/dist/assets/{apl-DhNO6Yyx.js → apl-60Nm_trN.js} +1 -1
  48. package/dist/assets/web/dist/assets/{astro-BYotTY4C.js → astro-CvyaM3Q9.js} +1 -1
  49. package/dist/assets/web/dist/assets/{blade-mUxTtD1X.js → blade-yan3VBls.js} +1 -1
  50. package/dist/assets/web/dist/assets/{c-DhuiwcSQ.js → c-Ddgepap2.js} +1 -1
  51. package/dist/assets/web/dist/assets/{chapel-GD2vFtRi.js → chapel-CgNY6dxC.js} +1 -1
  52. package/dist/assets/web/dist/assets/{cobol-BhFDwkes.js → cobol-C7q82cdP.js} +1 -1
  53. package/dist/assets/web/dist/assets/{coffee-DWUgbE0r.js → coffee-DgRMF_ni.js} +1 -1
  54. package/dist/assets/web/dist/assets/{cpp-CO9XFjGW.js → cpp-B8Lf-aeo.js} +1 -1
  55. package/dist/assets/web/dist/assets/{crystal-B9pVurpn.js → crystal-CkA_dhEO.js} +1 -1
  56. package/dist/assets/web/dist/assets/{css-CFVVV6lI.js → css-Cyu-C_oA.js} +1 -1
  57. package/dist/assets/web/dist/assets/{dist-BtPs2AkQ.js → dist-DS-gMjLZ.js} +2 -2
  58. package/dist/assets/web/dist/assets/{durationTone-BobbycC-.js → durationTone-l6aVrHb8.js} +1 -1
  59. package/dist/assets/web/dist/assets/{edge-BDhIDQIb.js → edge-BxV3rKNI.js} +1 -1
  60. package/dist/assets/web/dist/assets/{elixir-DG3J8WoD.js → elixir-pNSEK39d.js} +1 -1
  61. package/dist/assets/web/dist/assets/{elm-FiOdiaOE.js → elm-zgwWE33W.js} +1 -1
  62. package/dist/assets/web/dist/assets/{erb-vxbtW503.js → erb-yzcYAHp_.js} +1 -1
  63. package/dist/assets/web/dist/assets/{eventSource-tRprhqm7.js → eventSource-Ce94ho6M.js} +1 -1
  64. package/dist/assets/web/dist/assets/{git-rebase-DINz8M3H.js → git-rebase-Dg7Ous1-.js} +1 -1
  65. package/dist/assets/web/dist/assets/{glimmer-js-yann6r9i.js → glimmer-js-CbZEwX27.js} +1 -1
  66. package/dist/assets/web/dist/assets/{glimmer-ts-CXTbrZn_.js → glimmer-ts-D3NIqALy.js} +1 -1
  67. package/dist/assets/web/dist/assets/{glsl-DNYC-hq0.js → glsl-y4-3WrI2.js} +1 -1
  68. package/dist/assets/web/dist/assets/{graphql-CwzhHzi9.js → graphql-D5d0al7K.js} +1 -1
  69. package/dist/assets/web/dist/assets/{hack-BBXF1cwR.js → hack-D8fD6kEl.js} +1 -1
  70. package/dist/assets/web/dist/assets/{haml-oEqBcsTy.js → haml-BWeFFc-V.js} +1 -1
  71. package/dist/assets/web/dist/assets/{handlebars-YE5thkT1.js → handlebars-gIm5FeE_.js} +1 -1
  72. package/dist/assets/web/dist/assets/{html-derivative-DCT1GYtQ.js → html-derivative-B3ySSBrQ.js} +1 -1
  73. package/dist/assets/web/dist/assets/{html-Dp4jRQ1j.js → html-fXJTVLSM.js} +1 -1
  74. package/dist/assets/web/dist/assets/{http-BPcGbmzJ.js → http-DrJfQUmw.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hurl-CQnNHwyd.js → hurl-Bvbnsf55.js} +1 -1
  76. package/dist/assets/web/dist/assets/{java-Dm8Ovj6W.js → java-CUSpb-i-.js} +1 -1
  77. package/dist/assets/web/dist/assets/{javascript-Btm9oJK9.js → javascript-BWRH5fbY.js} +1 -1
  78. package/dist/assets/web/dist/assets/{jinja-VZYjWF_U.js → jinja-D3Ymci8y.js} +1 -1
  79. package/dist/assets/web/dist/assets/{jison-Dcmjxa9x.js → jison-Gt9SO7ZC.js} +1 -1
  80. package/dist/assets/web/dist/assets/{json-DoWiOGZ8.js → json-Cfcblhgn.js} +1 -1
  81. package/dist/assets/web/dist/assets/{jsx-Dvmo55ma.js → jsx-C0366fFM.js} +1 -1
  82. package/dist/assets/web/dist/assets/{julia-d8AtfV26.js → julia-CyBOTbxS.js} +1 -1
  83. package/dist/assets/web/dist/assets/{just-Cc5EStA-.js → just-FPXZcIOd.js} +1 -1
  84. package/dist/assets/web/dist/assets/{latex-B5fKKIH7.js → latex-Bd-c88v0.js} +1 -1
  85. package/dist/assets/web/dist/assets/{liquid-DUYI9JND.js → liquid-5tu9Hc0k.js} +1 -1
  86. package/dist/assets/web/dist/assets/{lua-BhMwKWpw.js → lua-BZ5fdPP8.js} +1 -1
  87. package/dist/assets/web/dist/assets/main-B4A6x82d.js +28 -0
  88. package/dist/assets/web/dist/assets/main-CpkwzHX6.css +1 -0
  89. package/dist/assets/web/dist/assets/{marko-Cnf2cPAi.js → marko-C_mmj4p0.js} +1 -1
  90. package/dist/assets/web/dist/assets/{mdc-FDDXoir9.js → mdc-CtaNqWjY.js} +1 -1
  91. package/dist/assets/web/dist/assets/{nginx-q-8U_1jR.js → nginx-Ddm5Mb8J.js} +1 -1
  92. package/dist/assets/web/dist/assets/{nim-DIA0TUWs.js → nim-COhfroYB.js} +1 -1
  93. package/dist/assets/web/dist/assets/{org-CR88hy7S.js → org-CCHFgmTb.js} +1 -1
  94. package/dist/assets/web/dist/assets/{perl-Bkd8N7ja.js → perl-CXzOE-Z-.js} +1 -1
  95. package/dist/assets/web/dist/assets/{php-BOs84mvy.js → php-1V6jAIhU.js} +1 -1
  96. package/dist/assets/web/dist/assets/{pug-BbpZBb3w.js → pug-uS37efts.js} +1 -1
  97. package/dist/assets/web/dist/assets/{qml-D2jQpjFE.js → qml-Bj1A0Ben.js} +1 -1
  98. package/dist/assets/web/dist/assets/{r-DKzcAQ22.js → r-CMbKbLsS.js} +1 -1
  99. package/dist/assets/web/dist/assets/{razor-SOQMWcC9.js → razor-DVRq_Ndk.js} +1 -1
  100. package/dist/assets/web/dist/assets/{regexp-B6KK4W8d.js → regexp-AbaOr4oB.js} +1 -1
  101. package/dist/assets/web/dist/assets/{rst-kPSg-NPf.js → rst-Dq_QWgOF.js} +1 -1
  102. package/dist/assets/web/dist/assets/{ruby-cKS-JYLH.js → ruby-CVSsAj7T.js} +1 -1
  103. package/dist/assets/web/dist/assets/{sas-DNxzE5gs.js → sas-C-dGfxUi.js} +1 -1
  104. package/dist/assets/web/dist/assets/{scss-DGcHQ5OO.js → scss-DxALxYPA.js} +1 -1
  105. package/dist/assets/web/dist/assets/seed-BhxEVasY.js +6 -0
  106. package/dist/assets/web/dist/assets/{settingsApi-priOPiwu.js → settingsApi-Dm6eaQcu.js} +1 -1
  107. package/dist/assets/web/dist/assets/{shellscript-CRZeZBa5.js → shellscript--qXcvQ5L.js} +1 -1
  108. package/dist/assets/web/dist/assets/{shellsession-DSNEL0HX.js → shellsession-DeqnW8Ux.js} +1 -1
  109. package/dist/assets/web/dist/assets/{soy-zisNbkDO.js → soy-DreoUIMt.js} +1 -1
  110. package/dist/assets/web/dist/assets/{sql-MG06KMMb.js → sql-o3i2VTG6.js} +1 -1
  111. package/dist/assets/web/dist/assets/{sseReplay-DaRtc7nI.js → sseReplay-qF-gpbAY.js} +3 -3
  112. package/dist/assets/web/dist/assets/{stata-CB_wKwv9.js → stata-CGAT41H7.js} +1 -1
  113. package/dist/assets/web/dist/assets/{surrealql-DYdSt7re.js → surrealql-CBU0QuGV.js} +1 -1
  114. package/dist/assets/web/dist/assets/{svelte-DHO-F7c7.js → svelte-CfHaLZiG.js} +1 -1
  115. package/dist/assets/web/dist/assets/{templ-CUL5L4Lr.js → templ-D02MUcma.js} +1 -1
  116. package/dist/assets/web/dist/assets/{tex-_JNEKjVW.js → tex-DjprHYXl.js} +1 -1
  117. package/dist/assets/web/dist/assets/{ts-tags--9EpEV_8.js → ts-tags-DftDOzP-.js} +1 -1
  118. package/dist/assets/web/dist/assets/{tsx-BcNq2x2u.js → tsx-BOdvPohJ.js} +1 -1
  119. package/dist/assets/web/dist/assets/{twig-EsY5Dk_D.js → twig-B_yLS56B.js} +1 -1
  120. package/dist/assets/web/dist/assets/{typescript-CyA_HiUH.js → typescript-CdPCa4nU.js} +1 -1
  121. package/dist/assets/web/dist/assets/{typst-SzaHlDt4.js → typst-C1oIFsUl.js} +1 -1
  122. package/dist/assets/web/dist/assets/{vue-D3cn5acV.js → vue-CcszJEX8.js} +1 -1
  123. package/dist/assets/web/dist/assets/{vue-html-6MxhUxOa.js → vue-html-DcpxPcuT.js} +1 -1
  124. package/dist/assets/web/dist/assets/{vue-vine-qJnqhgo9.js → vue-vine-D26bIKGj.js} +1 -1
  125. package/dist/assets/web/dist/assets/wallClock-DPTuP2FD.js +1 -0
  126. package/dist/assets/web/dist/assets/{xml-DyRpC7xv.js → xml-D8zl6W5P.js} +1 -1
  127. package/dist/assets/web/dist/assets/{xsl-BXMRPAsg.js → xsl-D86Bii28.js} +1 -1
  128. package/dist/assets/web/dist/assets/{yaml-BNmPCtAN.js → yaml-g6Px4KFU.js} +1 -1
  129. package/dist/cli.js +3023 -1049
  130. package/package.json +1 -1
  131. package/dist/assets/web/dist/assets/AppShell-Ck4hSZDi.js +0 -1
  132. package/dist/assets/web/dist/assets/CostsPage-DvhjDMka.js +0 -2
  133. package/dist/assets/web/dist/assets/DeliveryPage-DBlI_Yq2.js +0 -1
  134. package/dist/assets/web/dist/assets/HomePage-XFWPxDiJ.js +0 -2
  135. package/dist/assets/web/dist/assets/NotFoundPage-CiGrVOyv.js +0 -1
  136. package/dist/assets/web/dist/assets/PendingTurnRow-BHCSpsPf.js +0 -1
  137. package/dist/assets/web/dist/assets/ResidentDetailPage-C5USV_8P.js +0 -1
  138. package/dist/assets/web/dist/assets/ResidentsIndexPage-CSKisWnX.js +0 -1
  139. package/dist/assets/web/dist/assets/RunRoutePage-CZe9Yodw.js +0 -9
  140. package/dist/assets/web/dist/assets/RunsIndexPage-BfA333Qy.js +0 -1
  141. package/dist/assets/web/dist/assets/RunsTabs-BWRHzhtT.js +0 -1
  142. package/dist/assets/web/dist/assets/ScheduledPage-DBaChcJZ.js +0 -1
  143. package/dist/assets/web/dist/assets/SettingsPage-CqVi9-Ac.js +0 -1
  144. package/dist/assets/web/dist/assets/StatusDot-_Q_bmReh.js +0 -1
  145. package/dist/assets/web/dist/assets/Tooltip-Cafsgd4m.js +0 -1
  146. package/dist/assets/web/dist/assets/UnitRoutePage-CdoBZwtf.js +0 -1
  147. package/dist/assets/web/dist/assets/main-C4366PZF.css +0 -1
  148. package/dist/assets/web/dist/assets/main-DlTNAC6c.js +0 -28
  149. package/dist/assets/web/dist/assets/seed-DeKSHrt2.js +0 -6
  150. package/dist/assets/web/dist/assets/wallClock-61NpVkY6.js +0 -1
@@ -19,19 +19,83 @@
19
19
  // a fix round can reference each finding by its stable id and answer it with
20
20
  // a typed disposition (`parseDispositionsInput` below). Findings validate
21
21
  // fail-closed PER finding — a malformed finding drops with a note, a
22
- // malformed array drops the whole field — and an `approve` carrying a
23
- // self-declared `blocking` finding is downgraded to `request_changes`, so the
24
- // posted body can never begin with `LGTM:` over a defect the reviewer itself
25
- // called blocking.
22
+ // malformed array drops the whole field.
23
+ //
24
+ // THE SEVERITY GATE (docs/reference/specs/agent-review.md item 5a): an
25
+ // `approve` carrying a finding at or above the severity to address — the
26
+ // level in force for the run, `minor` by default — is downgraded to
27
+ // `request_changes` HERE, where the verdict is parsed, so the posted body can
28
+ // never begin with `LGTM:` over a finding the loop would have to act on. The
29
+ // gate is keyed on the RAW entries' declared severity, so a finding that
30
+ // fails validation for another reason still poisons the approve. The same
31
+ // ladder is what ship's coordinator holds a posted approve to (agent-ship.md
32
+ // item 9) — that check is now defense in depth behind this one.
26
33
 
27
34
  import { normalizeHead } from "./reviewedHead.js";
28
35
  import { redactSecrets } from "./redact.js";
29
36
 
30
37
  export type ReviewVerdictKind = "approve" | "request_changes";
31
38
 
39
+ /** The severity ladder, most to least severe. One list serves the findings a
40
+ * reviewer submits and the level the loop addresses: a finding's severity is
41
+ * compared to the level in force on this ladder, so nothing outside it can
42
+ * reach a gate — the parser drops an entry with any other severity (`fyi`,
43
+ * none) with a note. */
32
44
  export const FINDING_SEVERITIES = ["blocking", "major", "minor", "nit"] as const;
33
45
  export type FindingSeverity = (typeof FINDING_SEVERITIES)[number];
34
46
 
47
+ /** The severity to address (the loop's gate on a round): an approve carrying a
48
+ * finding at or above this level is not an approve. Resolved once per run —
49
+ * the request's `severity:` directive over the user's scope over the
50
+ * channel's over the org's `review.addressSeverity` — and handed to the
51
+ * verdict parser and ship's coordinator alike. */
52
+ export const ADDRESS_SEVERITIES = FINDING_SEVERITIES;
53
+ export type AddressSeverity = FindingSeverity;
54
+ /** Where the level in force came from, most specific wins: a `severity:`
55
+ * directive on the request (`run`), the user's or the channel's config scope,
56
+ * the org's `review.addressSeverity` (or its default). */
57
+ export type AddressSeveritySource = "org" | "channel" | "user" | "run";
58
+ export const DEFAULT_ADDRESS_SEVERITY: AddressSeverity = "minor";
59
+ export const isAddressSeverity = (v: unknown): v is AddressSeverity =>
60
+ (ADDRESS_SEVERITIES as readonly unknown[]).includes(v);
61
+
62
+ const severityRank = (s: AddressSeverity): number => ADDRESS_SEVERITIES.indexOf(s);
63
+ /** Whether a declared severity sits at or above the level in force on the
64
+ * ladder — the one comparison every gate makes. A value outside the ladder is
65
+ * never at or above anything. */
66
+ export function severityAtOrAbove(severity: unknown, level: AddressSeverity): severity is AddressSeverity {
67
+ return isAddressSeverity(severity) && severityRank(severity) <= severityRank(level);
68
+ }
69
+ /** The findings a gate acts on at `level`: at or above it. */
70
+ export function findingsAtOrAbove(findings: readonly Finding[], level: AddressSeverity): Finding[] {
71
+ return findings.filter((f) => severityAtOrAbove(f.severity, level));
72
+ }
73
+
74
+ /** The level in force and the layer that set it: the request's
75
+ * `severity:` directive wins, then the user's scope, the channel's, the org's
76
+ * `review.addressSeverity` — the default counts as the org's. Resolved once
77
+ * per run by the dispatcher (every review's verdict parser reads it) and once
78
+ * per ship request by the hand-off, which writes it on the instance beside
79
+ * `merge` and hands it to each review child as its `severity:` directive. */
80
+ export function resolveAddressSeverity(layers: {
81
+ org?: AddressSeverity;
82
+ channel?: AddressSeverity;
83
+ user?: AddressSeverity;
84
+ run?: AddressSeverity;
85
+ }): { level: AddressSeverity; source: AddressSeveritySource } {
86
+ if (layers.run !== undefined) return { level: layers.run, source: "run" };
87
+ if (layers.user !== undefined) return { level: layers.user, source: "user" };
88
+ if (layers.channel !== undefined) return { level: layers.channel, source: "channel" };
89
+ return { level: layers.org ?? DEFAULT_ADDRESS_SEVERITY, source: "org" };
90
+ }
91
+
92
+ /** The gate's one sentence — `finding(s) <id> (<severity>)[, …] at or above
93
+ * <level>, the severity to address` — as the downgraded summary carries it into
94
+ * the posted body and the tool's ack repeats it to the model. */
95
+ export function downgradeNote(d: { findings: readonly string[]; level: AddressSeverity }): string {
96
+ return `finding${d.findings.length === 1 ? "" : "s"} ${d.findings.join(", ")} at or above ${d.level}, the severity to address`;
97
+ }
98
+
35
99
  export interface Finding {
36
100
  /** Stable id the reviewer assigned in order ("F1", "F2", …) — dispositions
37
101
  * reference findings by this id. */
@@ -63,50 +127,63 @@ export interface ReviewVerdict {
63
127
  * reasons) — surfaced in the tool ack so the model can resubmit, never
64
128
  * rendered into the posted body. */
65
129
  droppedFindings?: string[];
130
+ /** Parse note: the approve was downgraded by the severity gate — the gated
131
+ * findings as `id (severity)` and the level in force. Surfaced in the tool
132
+ * ack so the model knows its verdict changed; the summary carries the same
133
+ * words into the posted body, so this never rides a record. */
134
+ downgraded?: { findings: string[]; level: AddressSeverity };
66
135
  }
67
136
 
68
137
  export const LGTM_TOKEN = "LGTM:";
69
138
  export const CHANGES_TOKEN = "Changes requested:";
70
139
  export const NO_VERDICT_LINE = "No verdict submitted — not approving.";
71
140
 
72
- /** Parse an arbitrary tool input into a verdict, or null when it is not one. */
73
- export function parseVerdictInput(input: Record<string, unknown>): ReviewVerdict | null {
141
+ /** Parse an arbitrary tool input into a verdict, or null when it is not one.
142
+ * `addressSeverity` is the level in force for the run (default `minor`): an
143
+ * approve carrying a finding at or above it is parsed as `request_changes`. */
144
+ export function parseVerdictInput(
145
+ input: Record<string, unknown>,
146
+ opts: { addressSeverity?: AddressSeverity } = {},
147
+ ): ReviewVerdict | null {
74
148
  const verdict = input.verdict;
75
149
  if (verdict !== "approve" && verdict !== "request_changes") return null;
150
+ const level = opts.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
76
151
  const summary = typeof input.summary === "string" ? oneLine(input.summary) : "";
77
152
  const head = normalizeHead(input.head);
78
153
  const out: ReviewVerdict = { verdict, summary };
79
154
  if (head) out.head = head;
80
155
  if (input.findings !== undefined) {
81
- const parsed = parseFindings(input.findings);
156
+ const parsed = parseFindings(input.findings, level);
82
157
  if (parsed.findings) out.findings = parsed.findings;
83
158
  if (parsed.dropped.length) out.droppedFindings = parsed.dropped;
84
- // Verdict/severity consistency, fail-closed: an approve carrying a
85
- // blocking finding downgrades so the posted body can never start with
86
- // `LGTM:` over a self-declared blocking defect. Keyed on the RAW entries'
87
- // declared severity, not the validated survivors — a blocking entry that
88
- // itself failed validation and dropped still poisons the approve.
89
- if (out.verdict === "approve" && parsed.blocking.length > 0) {
159
+ // The severity gate, fail-closed: an approve carrying a finding at or
160
+ // above the level in force downgrades, so the posted body can never start
161
+ // with `LGTM:` over a finding the loop has to address. Keyed on the RAW
162
+ // entries' declared severity, not the validated survivors — a gated entry
163
+ // that itself failed validation and dropped still poisons the approve.
164
+ if (out.verdict === "approve" && parsed.gated.length > 0) {
90
165
  out.verdict = "request_changes";
91
- out.summary = oneLine(
92
- `${out.summary} [downgraded from approve: blocking finding${parsed.blocking.length === 1 ? "" : "s"} ${parsed.blocking.join(", ")}]`,
93
- );
166
+ out.downgraded = { findings: parsed.gated, level };
167
+ out.summary = oneLine(`${out.summary} [downgraded from approve: ${downgradeNote(out.downgraded)}]`);
94
168
  }
95
169
  }
96
170
  return out;
97
171
  }
98
172
 
99
173
  /** Fail-closed per finding: each malformed entry drops with a note naming its
100
- * index (and id when readable); a non-array drops the whole field.
101
- * `blocking` labels every raw entry that declared severity "blocking" —
102
- * valid or not — for the approve downgrade above. */
103
- function parseFindings(value: unknown): { findings?: Finding[]; dropped: string[]; blocking: string[] } {
174
+ * index (and id when readable); a non-array drops the whole field. `gated`
175
+ * labels every raw entry whose declared severity sits at or above `level` —
176
+ * valid or not — as `id (severity)`, for the approve downgrade above. */
177
+ function parseFindings(
178
+ value: unknown,
179
+ level: AddressSeverity,
180
+ ): { findings?: Finding[]; dropped: string[]; gated: string[] } {
104
181
  if (!Array.isArray(value)) {
105
- return { dropped: ["findings: dropped — not an array"], blocking: [] };
182
+ return { dropped: ["findings: dropped — not an array"], gated: [] };
106
183
  }
107
184
  const findings: Finding[] = [];
108
185
  const dropped: string[] = [];
109
- const blocking: string[] = [];
186
+ const gated: string[] = [];
110
187
  value.forEach((raw, i) => {
111
188
  const parsed = parseFinding(raw);
112
189
  if (parsed.finding) {
@@ -116,9 +193,9 @@ function parseFindings(value: unknown): { findings?: Finding[]; dropped: string[
116
193
  }
117
194
  const label = parsed.finding?.id ?? parsed.id ?? `findings[${i}]`;
118
195
  const r = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : undefined;
119
- if (r?.severity === "blocking") blocking.push(label);
196
+ if (severityAtOrAbove(r?.severity, level)) gated.push(`${label} (${r?.severity})`);
120
197
  });
121
- return { findings, dropped, blocking };
198
+ return { findings, dropped, gated };
122
199
  }
123
200
 
124
201
  function parseFinding(
@@ -170,14 +247,146 @@ export function formatDisposition(d: FindingDisposition): string {
170
247
  return `${d.findingId}: ${d.disposition}${d.note ? ` — ${d.note}` : ""}`;
171
248
  }
172
249
 
250
+ /** Where the posted body's file links point: the PR's repository and the
251
+ * head the review is pinned to (the post-step's `commitId`). */
252
+ export interface ReviewBodyTarget {
253
+ /** `owner/name` */
254
+ repo: string;
255
+ /** The pinned head, full sha. */
256
+ head: string;
257
+ }
258
+
259
+ /** The verdict word the callout carries, per verdict kind (or its absence). */
260
+ function verdictWord(verdict: ReviewVerdict | undefined): string {
261
+ if (!verdict) return "No verdict";
262
+ return verdict.verdict === "approve" ? "Approved" : "Changes requested";
263
+ }
264
+
265
+ /** GitHub's alert callout kind per verdict: an approve is a note, changes
266
+ * requested a warning, a missing verdict a caution. */
267
+ function calloutKind(verdict: ReviewVerdict | undefined): "NOTE" | "WARNING" | "CAUTION" {
268
+ if (!verdict) return "CAUTION";
269
+ return verdict.verdict === "approve" ? "NOTE" : "WARNING";
270
+ }
271
+
272
+ /** `2 findings: 1 minor, 1 nit` — counted on the ladder, most severe first;
273
+ * an empty array is `no findings`, no array `findings not itemized`. */
274
+ function findingCounts(findings: readonly Finding[] | undefined): string {
275
+ if (findings === undefined) return "findings not itemized";
276
+ if (findings.length === 0) return "no findings";
277
+ const by = FINDING_SEVERITIES.map((sev) => [sev, findings.filter((f) => f.severity === sev).length] as const)
278
+ .filter(([, n]) => n > 0)
279
+ .map(([sev, n]) => `${n} ${sev}`)
280
+ .join(", ");
281
+ return `${findings.length} finding${findings.length === 1 ? "" : "s"}: ${by}`;
282
+ }
283
+
284
+ /** A table cell: pipes escaped so the row holds, already one line. */
285
+ const cell = (text: string): string => oneLine(text).replace(/\|/g, "\\|");
286
+
287
+ /** Whether a finding's `file` is a path a blob URL can point at: no
288
+ * whitespace, no backtick, not a URL. */
289
+ const isRepoPath = (file: string): boolean => /^[^\s`]+$/.test(file) && !/^[a-z][a-z0-9+.-]*:\/\//i.test(file);
290
+
291
+ /** The `Where` cell: the location in code, linked at the pinned head when the
292
+ * file is a path and a target is known. */
293
+ function whereCell(f: Finding, target: ReviewBodyTarget | undefined): string {
294
+ const location = f.line !== undefined ? `${f.file}:${f.line}` : f.file;
295
+ const label = `\`${cell(location)}\``;
296
+ if (!target || !isRepoPath(f.file)) return label;
297
+ const path = encodeURI(f.file).replace(/#/g, "%23").replace(/\?/g, "%3F");
298
+ const url = `https://github.com/${target.repo}/blob/${target.head}/${path}${f.line !== undefined ? `#L${f.line}` : ""}`;
299
+ return `[${label}](${url})`;
300
+ }
301
+
302
+ /** The machine-readable marker the body ends with — the verdict, the head and
303
+ * the findings index as JSON inside an HTML comment, for a scanner that would
304
+ * otherwise parse the token line. `-->` can never occur inside it. */
305
+ function verdictMarker(verdict: ReviewVerdict | undefined, target: ReviewBodyTarget | undefined): string {
306
+ const head = target?.head ?? verdict?.head;
307
+ const payload = {
308
+ verdict: verdict?.verdict ?? "none",
309
+ ...(head ? { head } : {}),
310
+ ...(verdict?.findings
311
+ ? {
312
+ findings: verdict.findings.map((f) => ({
313
+ id: f.id,
314
+ severity: f.severity,
315
+ file: f.file,
316
+ ...(f.line !== undefined ? { line: f.line } : {}),
317
+ })),
318
+ }
319
+ : {}),
320
+ };
321
+ return `<!-- switchboard:verdict ${JSON.stringify(payload).replace(/-->/g, "--\\u003e")} -->`;
322
+ }
323
+
324
+ /**
325
+ * Build the body posted to GitHub, rendered from the typed verdict
326
+ * (docs/reference/specs/agent-review.md item 5b): the deterministic verdict
327
+ * line first (the auto-approve contract — never `LGTM:` unless the verdict is
328
+ * `approve`), a GitHub alert callout with the verdict word, the pinned head
329
+ * and the finding counts, the findings as a table (severity, id + title, the
330
+ * file linked at the head), the model's text folded under `Full review`, and
331
+ * the machine-readable marker last. The prose decides nothing above the fold.
332
+ */
333
+ export function buildReviewPostBody(
334
+ answer: string,
335
+ verdict: ReviewVerdict | undefined,
336
+ target?: ReviewBodyTarget,
337
+ ): string {
338
+ const facts = [
339
+ `**${verdictWord(verdict)}**`,
340
+ ...(target ? [`head \`${target.head.slice(0, 7)}\``] : []),
341
+ verdict ? findingCounts(verdict.findings) : "the run ended without a submit_verdict call",
342
+ ];
343
+ const parts: string[] = [verdictLine(verdict), `> [!${calloutKind(verdict)}]\n> ${facts.join(" · ")}`];
344
+ const findings = verdict?.findings ?? [];
345
+ if (findings.length > 0) {
346
+ parts.push(
347
+ [
348
+ "| Severity | Finding | Where |",
349
+ "| --- | --- | --- |",
350
+ ...findings.map((f) => `| ${f.severity} | **${cell(f.id)}** ${cell(f.title)} | ${whereCell(f, target)} |`),
351
+ ].join("\n"),
352
+ );
353
+ }
354
+ const prose = answer.trim();
355
+ if (prose) parts.push(`<details>\n<summary>Full review</summary>\n\n${prose}\n\n</details>`);
356
+ parts.push(verdictMarker(verdict, target));
357
+ return parts.join("\n\n");
358
+ }
359
+
173
360
  /**
174
- * Build the body posted to GitHub: the deterministic verdict line, the
175
- * compact findings list (when present), a blank line, then the model's
176
- * review text. Never starts with "LGTM" unless the verdict is `approve`.
361
+ * The channel reply for a review run, rendered from the typed verdict
362
+ * (agent-review.md item 5b): the verdict line, one bullet per finding, then
363
+ * where the review was posted and the run link. The model's write-up rides
364
+ * along only when it landed nowhere else — no GitHub post (Slack-only, an
365
+ * opt-out, a guard refusal) or findings not itemized (a list that says
366
+ * nothing is no substitute) — so the review's text is always somewhere a
367
+ * person reads it. No verdict → the bare answer with the link, as before.
177
368
  */
178
- export function buildReviewPostBody(answer: string, verdict: ReviewVerdict | undefined): string {
179
- const head = [verdictLine(verdict), ...(verdict?.findings ?? []).map((f) => `- ${formatFinding(f)}`)];
180
- return `${head.join("\n")}\n\n${answer.trim()}`;
369
+ export function buildReviewChannelReply(input: {
370
+ answer: string;
371
+ verdict: ReviewVerdict | undefined;
372
+ /** The PR the post landed on, or undefined when nothing was posted. */
373
+ posted: { repo: string; number: number } | undefined;
374
+ liveUrl: string | undefined;
375
+ }): string {
376
+ const { answer, verdict, posted, liveUrl } = input;
377
+ const tail = [
378
+ ...(posted && verdict ? [`Posted to ${posted.repo}#${posted.number}`] : []),
379
+ ...(liveUrl ? [`[Live run](${liveUrl})`] : []),
380
+ ].join(" · ");
381
+ const blocks: string[] = [];
382
+ if (!verdict) blocks.push(answer);
383
+ else {
384
+ blocks.push([verdictLine(verdict), ...(verdict.findings ?? []).map((f) => `- ${formatFinding(f)}`)].join("\n"));
385
+ const compact = posted !== undefined && verdict.findings !== undefined;
386
+ if (!compact && answer.trim()) blocks.push(answer.trim());
387
+ }
388
+ if (tail) blocks.push(tail);
389
+ return blocks.join("\n\n");
181
390
  }
182
391
 
183
392
  // --- Dispositions (the coding side's answer to findings) -------------------
@@ -94,6 +94,10 @@ export type RunNoteKind =
94
94
  | "sandbox_restarted"
95
95
  | "stop_requested"
96
96
  | "stopped"
97
+ /** A stop the loop or turn had to ask pi for again reached it: the series a
98
+ * failed abort's write opened (its `harness_error` line) closes here with
99
+ * the count of re-asks (harness-pi item 16). Written by the pi harness. */
100
+ | "stop_landed"
97
101
  /** Setup spans the request's stream sink had to drop before this run was
98
102
  * bound (docs/reference/specs/tracing.md): `summary` says how many, `from`/`to` the
99
103
  * interval, which the partition reports as not recorded. */
@@ -142,6 +146,16 @@ export type RunNoteKind =
142
146
  * after the attach, before the first turn, so the run page explains a
143
147
  * sandbox run that shows resident steps. */
144
148
  | "cold_sandbox"
149
+ /** The run ledger would not track this run (docs/reference/specs/run-history.md
150
+ * item 54): its reservation met the row of a run this process was closing
151
+ * (a restart from its request, or the fresh turn for its follow-ups), waited
152
+ * for that finish, and the row still stood — the finish failed or was
153
+ * refused — so no handoff, resume or reclaim reaches this run, and its
154
+ * record reaches the store when it finishes. The summary names that run and
155
+ * how its finish ended; published by the dispatcher right after the
156
+ * reservation, before the attach, and carried on the card's label — the bot
157
+ * log's warning is not the only witness. */
158
+ | "ledger_untracked"
145
159
  /** The resident kept this thread's binding where it was instead of moving
146
160
  * it onto the branch the thread's own run opened a pull request on
147
161
  * (docs/reference/specs/resident-repos.md item 16): the summary names the
@@ -158,6 +172,13 @@ export type RunNoteKind =
158
172
  * after the model's last turn — the release itself runs after the record
159
173
  * is sealed — and set on the card's label too, so the loss is never silent. */
160
174
  | "work_left_behind"
175
+ /** The run's ending may have left a command running in its workspace — a
176
+ * call cut by the ending's abort or interrupt, or open when the run failed —
177
+ * so the release tears the workspace down rather than pair it for the
178
+ * thread's next run (harness.md item 13). The summary names the calls.
179
+ * Written by the run loop once the harness session has ended and before the
180
+ * record is sealed; the release itself runs after. */
181
+ | "workspace_torn_down"
161
182
  /** A coding run submitted a PR description but the post-step opened no
162
183
  * pull request because the branch it observed IS the base the pull
163
184
  * request would target (docs/reference/specs/pr-description.md item 5) —
@@ -210,11 +231,20 @@ export type RunNoteKind =
210
231
  * names the call and the step. Information, not a failure: the tool ran
211
232
  * under the gate's decision either way. Published by the OpenCode bridge. */
212
233
  | "tool_unnamed"
234
+ /** An `external_directory` ask answered once its call's line was already on
235
+ * the record (harness.md item 13): the line cannot be amended, so the
236
+ * directory the call reached is said here, naming the call. Published by
237
+ * the OpenCode bridge. */
238
+ | "directory_reached"
213
239
  /** A ship coding child's budget ended with work still in the tree: the run
214
240
  * loop committed and pushed it to the unit's branch (or says plainly that
215
241
  * there was nothing to push), so a re-issue starts from the partial work
216
242
  * (docs/reference/specs/agent-ship.md item 8). */
217
243
  | "budget_salvage"
244
+ /** The loop's end found a tool call in flight and ended it, so the write-up
245
+ * keeps its allowance (decision 0046, unit seven; harness-pi item 6). The
246
+ * summary names the tools. Published by the harness beside the budget note. */
247
+ | "tool_cut"
218
248
  /** The native loop's stuck-loop guard: the same tool call failed identically
219
249
  * six times in a row and the run was forced into its write-up. Written by
220
250
  * no loop since that loop's deletion; a record from before it may carry it. */
@@ -225,12 +255,14 @@ export type RunNoteKind =
225
255
  export const RUN_NOTE_KINDS = [
226
256
  "wrap_up",
227
257
  "time_budget_exhausted",
258
+ "tool_cut",
228
259
  "turn_budget_exhausted",
229
260
  "sandbox_dead",
230
261
  "fleet_busy",
231
262
  "sandbox_restarted",
232
263
  "stop_requested",
233
264
  "stopped",
265
+ "stop_landed",
234
266
  "spans_dropped",
235
267
  "head_moved",
236
268
  "run_failed",
@@ -241,8 +273,10 @@ export const RUN_NOTE_KINDS = [
241
273
  "description_turn",
242
274
  "verdict_turn",
243
275
  "cold_sandbox",
276
+ "ledger_untracked",
244
277
  "rebind_refused",
245
278
  "work_left_behind",
279
+ "workspace_torn_down",
246
280
  "pr_not_opened",
247
281
  "review_not_posted",
248
282
  "compacted",
@@ -251,6 +285,7 @@ export const RUN_NOTE_KINDS = [
251
285
  "tool_refused",
252
286
  "settle_set_aside",
253
287
  "tool_unnamed",
288
+ "directory_reached",
254
289
  "budget_salvage",
255
290
  "stuck_loop",
256
291
  ] as const satisfies readonly RunNoteKind[];
@@ -354,6 +389,7 @@ export function isHeadMaterial(event: RunEvent): boolean {
354
389
  event.kind === "mcp_unavailable" ||
355
390
  event.kind === "spans_dropped" ||
356
391
  event.kind === "cold_sandbox" ||
392
+ event.kind === "ledger_untracked" ||
357
393
  event.kind === "rebind_refused"
358
394
  );
359
395
  case "span_start":
@@ -460,7 +496,12 @@ export type RunEvent =
460
496
  * executors' shared `exit N:` prefix, 0 for a clean run; absent when the code
461
497
  * was not numeric). `output` is the tool's text — control-stripped, redacted,
462
498
  * capped at TOOL_OUTPUT_CAP — for the run page's expandable card; the status
463
- * card and the friction analyzer keep reading `summary`. */
499
+ * card and the friction analyzer keep reading `summary`. `cut` marks the
500
+ * result of a call the run's ending cut rather than settled — pi's abort (the
501
+ * tool's own end after it), OpenCode's interrupt (a call still open when its
502
+ * loop left), a session's end (`closeOpenSpans`): the command behind it may
503
+ * still be running, and the workspace's release reads it so (`callsInFlight`,
504
+ * harness.md item 13). */
464
505
  | {
465
506
  type: "tool_result";
466
507
  tool: string;
@@ -470,6 +511,7 @@ export type RunEvent =
470
511
  exitCode?: number;
471
512
  output?: string;
472
513
  infra?: true;
514
+ cut?: true;
473
515
  spanId?: string;
474
516
  logIndex?: number;
475
517
  seq?: number;
@@ -769,7 +811,18 @@ export type RunEvent =
769
811
  * round and its fix round share an index. Published by the ship pipeline
770
812
  * straight to the registry (like `pr_opened`), never through the runner.
771
813
  * Additive: unknown → ignored. */
772
- | { type: "ship_round"; index: number; agent: string; outcome: ShipRoundOutcome; seq?: number; at?: number }
814
+ | {
815
+ type: "ship_round";
816
+ index: number;
817
+ agent: string;
818
+ outcome: ShipRoundOutcome;
819
+ /** The severity gate fired on this approve (agent-ship item 9): the level
820
+ * in force and the gated findings as `id (severity)` — a mismatch the
821
+ * child's own parser should have made impossible, kept visible. */
822
+ gate?: { level: string; findings: string[] };
823
+ seq?: number;
824
+ at?: number;
825
+ }
773
826
  /** The request router's decision (docs/reference/specs/routing-and-config.md
774
827
  * item 21): the preset a plain message was routed to, the one-line reason
775
828
  * the router gave (redacted, capped — the same text the card's `routed:`
@@ -291,6 +291,62 @@ export function pushedBranchesOf(events: readonly RunEvent[]): PushedBranch[] {
291
291
  return [...byRef].map(([ref, pr]) => ({ ref, pr }));
292
292
  }
293
293
 
294
+ /** A call a run's ending may have left running in its workspace (harness.md
295
+ * item 13): the id the record pairs by, the tool, and the line the
296
+ * `workspace_torn_down` note names it by — the `tool_call`'s own summary
297
+ * while the record holds it, else the tool and the id. */
298
+ export interface CallInFlight {
299
+ callId: string;
300
+ tool: string;
301
+ summary: string;
302
+ }
303
+
304
+ /** The tool calls a run's ending may have left running in its workspace, in
305
+ * the record's order — what the workspace's release reads to tear the
306
+ * workspace down rather than wait for an idle the command would refuse, and
307
+ * what its note names (harness.md item 13). A call whose result is marked
308
+ * `cut` — pi's abort, OpenCode's interrupt, a session's end — was ended, not
309
+ * settled, whatever the run's status, and stays cut whatever lands for it
310
+ * later — an unmarked result (a post-turn's bridge reads the earlier
311
+ * execution's aborted settle as its own), or the call announced again under
312
+ * the same id; a call with no result at all was left open by a run that
313
+ * failed, was interrupted or hard-stopped, while on a run that completed or
314
+ * stopped softly it is a relayed tool that ran in the bot or a result lost to
315
+ * a transport gap, nothing running. Calls and results pair by id. The events
316
+ * are the registry's bounded backlog (`runRegistry/backlog.ts` drops the
317
+ * oldest past its count or byte bound), so on a long run the line of a
318
+ * command that hung early may be gone while its cut result — the ending's,
319
+ * landing last — survives: a cut result for a call the record no longer
320
+ * holds is the harness's own word that the call was ended and not settled,
321
+ * and counts by itself, named by its tool and id; an unmarked result for a
322
+ * call never opened counts for nothing. */
323
+ export function callsInFlight(events: readonly RunEvent[], status: RunStatus): CallInFlight[] {
324
+ const calls = new Map<string, { call: CallInFlight; state: "open" | "cut" | "settled" }>();
325
+ for (const e of events) {
326
+ if (e.type === "tool_call" && e.callId !== undefined) {
327
+ const seen = calls.get(e.callId);
328
+ if (seen?.state === "cut") {
329
+ // Announced again after its cut — or after an orphan cut, the line the
330
+ // backlog evicted now written again: the line is the record's, the
331
+ // state stays the cut's.
332
+ seen.call.tool = e.tool;
333
+ seen.call.summary = e.summary;
334
+ } else calls.set(e.callId, { call: { callId: e.callId, tool: e.tool, summary: e.summary }, state: "open" });
335
+ } else if (e.type === "tool_result" && e.callId !== undefined) {
336
+ const seen = calls.get(e.callId);
337
+ if (seen === undefined) {
338
+ if (e.cut === true)
339
+ calls.set(e.callId, {
340
+ call: { callId: e.callId, tool: e.tool, summary: `${e.tool} (call ${e.callId})` },
341
+ state: "cut",
342
+ });
343
+ } else if (seen.state !== "cut") seen.state = e.cut === true ? "cut" : "settled";
344
+ }
345
+ }
346
+ const orderly = status === "completed" || status === "stopped_soft";
347
+ return [...calls.values()].filter((c) => c.state === "cut" || (c.state === "open" && !orderly)).map((c) => c.call);
348
+ }
349
+
294
350
  /** The router's decision as a record carries it — the same fields the
295
351
  * `route` event and the ledger row's `meta.route` carry (routing-and-config
296
352
  * item 21). */
@@ -34,7 +34,18 @@ import type { Handoff } from "./handoff.js";
34
34
  import { progressOf, renderRenewal, renewalDecision, type PushedHeadFact, type RenewalDecision } from "./renewal.js";
35
35
  import type { RunStatus } from "../runRecord.js";
36
36
  import { normalizeHead, sameCommit } from "../reviewedHead.js";
37
- import { formatFinding, type Finding, type FindingDisposition, type ReviewVerdictKind } from "../reviewVerdict.js";
37
+ import {
38
+ ADDRESS_SEVERITIES,
39
+ DEFAULT_ADDRESS_SEVERITY,
40
+ findingsAtOrAbove,
41
+ formatFinding,
42
+ isAddressSeverity,
43
+ type AddressSeverity,
44
+ type AddressSeveritySource,
45
+ type Finding,
46
+ type FindingDisposition,
47
+ type ReviewVerdictKind,
48
+ } from "../reviewVerdict.js";
38
49
  import { parsePlanUnit, planUnitIds } from "./contract.js";
39
50
  import {
40
51
  carve,
@@ -589,8 +600,22 @@ export type UnitEnding =
589
600
  /** What a transition tells the driver beyond the next action: a round boundary
590
601
  * the card draws (`shipRoundHeader`) and the run stream records, and the end. */
591
602
  export type CoordinatorNote =
592
- | { type: "round"; index: number; agent: ChildPreset; outcome: ShipRoundOutcome }
603
+ | {
604
+ type: "round";
605
+ index: number;
606
+ agent: ChildPreset;
607
+ outcome: ShipRoundOutcome;
608
+ /** The severity gate fired on this approve (agent-ship item 9): the
609
+ * findings at or above the level in force, as `id (severity)`. The
610
+ * child's own verdict parser holds an approve to the same level
611
+ * (agent-review item 5a), so this should never be set — when it is, the
612
+ * verdict was parsed at another level (a lost directive, an older
613
+ * record, a harness around `submit_verdict`) and the row, the card and
614
+ * the log say so instead of routing silently into the findings step. */
615
+ gate?: { level: AddressSeverity; findings: string[] };
616
+ }
593
617
  | { type: "ended"; ending: UnitEnding };
618
+ type RoundNote = Extract<CoordinatorNote, { type: "round" }>;
594
619
 
595
620
  /** How the pipeline's budget went, in ms: the coding rounds' (round 0 and the
596
621
  * findings steps), the review rounds', and everything else (branching,
@@ -598,26 +623,19 @@ export type CoordinatorNote =
598
623
  * person can see whether the cap or the child is the problem. */
599
624
  export type ShipBudgetSpent = Readonly<Record<"coding" | "review" | "waiting", number>>;
600
625
 
601
- /** The severity ship must address before an approve stands (agent-ship item
602
- * 9's gate): an approve carrying a finding at or above this level continues
603
- * into the findings step exactly as a request_changes does. Ordered most to
604
- * least severe. */
605
- export const ADDRESS_SEVERITIES = ["blocking", "major", "minor", "nit"] as const;
606
- export type AddressSeverity = (typeof ADDRESS_SEVERITIES)[number];
607
- /** Where the level in force came from, most specific wins: a `severity:` directive on the request (`run`), the user's or the channel's config scope, the org's `ship.addressSeverity` (or its default). */
608
- export type AddressSeveritySource = "org" | "channel" | "user" | "run";
609
- export const DEFAULT_ADDRESS_SEVERITY: AddressSeverity = "minor";
610
- export const isAddressSeverity = (v: unknown): v is AddressSeverity =>
611
- (ADDRESS_SEVERITIES as readonly unknown[]).includes(v);
612
-
613
- const severityRank = (s: AddressSeverity): number => ADDRESS_SEVERITIES.indexOf(s);
614
- /** The findings the gate acts on at `level`: at or above it. Every parsed
615
- * finding carries one of the ladder's four levels — the verdict parser
616
- * (`parseVerdictInput`) drops an entry with any other severity, `fyi` or none,
617
- * with a note — so nothing outside the ladder ever reaches the gate. */
618
- export function findingsAtOrAbove(findings: readonly Finding[], level: AddressSeverity): Finding[] {
619
- return findings.filter((f) => severityRank(f.severity) <= severityRank(level));
620
- }
626
+ // The severity to address (agent-ship item 9's gate) is the verdict parser's
627
+ // ladder (src/core/reviewVerdict.ts): the same level the parser holds a
628
+ // submitted approve to, so a posted approve can carry a gated finding only
629
+ // when it was parsed at a different level — the check below is defense in
630
+ // depth behind the parser's. Re-exported here for the runner's callers.
631
+ export {
632
+ ADDRESS_SEVERITIES,
633
+ DEFAULT_ADDRESS_SEVERITY,
634
+ findingsAtOrAbove,
635
+ isAddressSeverity,
636
+ type AddressSeverity,
637
+ type AddressSeveritySource,
638
+ };
621
639
 
622
640
  export interface UnitSession {
623
641
  segment: number;
@@ -935,7 +953,10 @@ function end(s: UnitPipelineState, ending: UnitEnding, notes: CoordinatorNote[]
935
953
  return { state: { ...s, phase: ENDED, ending }, notes: [...notes, { type: "ended", ending }] };
936
954
  }
937
955
 
938
- const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): CoordinatorNote => ({
956
+ /** A gated finding as the gate note names it: `F1 (minor)`. */
957
+ const gateLabel = (f: Finding): string => `${f.id} (${f.severity})`;
958
+
959
+ const roundNote = (round: RoundRef, outcome: ShipRoundOutcome): RoundNote => ({
939
960
  type: "round",
940
961
  index: round.index,
941
962
  agent: presetOf(round.kind),
@@ -1110,6 +1131,10 @@ function settleReview(
1110
1131
  const level = next.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
1111
1132
  const gated = findingsAtOrAbove(verdict.findings, level);
1112
1133
  if (gated.length > 0) {
1134
+ // The gate fired — which the child's parser should have made impossible
1135
+ // (agent-review item 5a): the round note carries what it caught, so the
1136
+ // mismatch is seen and not just routed around.
1137
+ notes[0] = { ...roundNote(round, verdict.verdict), gate: { level, findings: gated.map(gateLabel) } };
1113
1138
  if (mode !== undefined)
1114
1139
  return end(
1115
1140
  next,