@coreplane/switchboard 1.205.0 → 1.206.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 (45) hide show
  1. package/dist/assets/Dockerfile +6 -3
  2. package/dist/assets/config/config.example.yaml +24 -1
  3. package/dist/assets/deploy/cloudflare/coordinator.ts +86 -0
  4. package/dist/assets/deploy/cloudflare/shared.ts +9 -0
  5. package/dist/assets/deploy/cloudflare/worker.ts +142 -6
  6. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +9 -0
  7. package/dist/assets/deploy/cloudflare-memory/worker.ts +266 -18
  8. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +15 -0
  9. package/dist/assets/deploy/cloudflare-resident/Dockerfile +89 -3
  10. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +81 -10
  11. package/dist/assets/deploy/secrets.manifest.json +1 -1
  12. package/dist/assets/package-lock.json +3 -3
  13. package/dist/assets/package.json +1 -1
  14. package/dist/assets/source.json +3 -3
  15. package/dist/assets/src/agents/registry.ts +58 -7
  16. package/dist/assets/src/config/profile.ts +34 -5
  17. package/dist/assets/src/core/authz/policy.ts +15 -0
  18. package/dist/assets/src/core/coordinator/contract.ts +239 -0
  19. package/dist/assets/src/core/coordinator/driver.ts +501 -0
  20. package/dist/assets/src/core/coordinator/instancesRoute.ts +181 -0
  21. package/dist/assets/src/core/delivery.ts +69 -15
  22. package/dist/assets/src/core/deliverySnapshotStore.ts +84 -24
  23. package/dist/assets/src/core/reviewVerdict.ts +296 -0
  24. package/dist/assets/src/core/reviewedHead.ts +77 -0
  25. package/dist/assets/src/core/runEvents.ts +4 -2
  26. package/dist/assets/src/core/runLedger/decisions.ts +2 -1
  27. package/dist/assets/src/core/runLedger/types.ts +17 -1
  28. package/dist/assets/src/core/runRecord.ts +56 -0
  29. package/dist/assets/src/core/ship/coordinator.ts +1039 -0
  30. package/dist/assets/web/dist/.vite/manifest.json +20 -20
  31. package/dist/assets/web/dist/assets/DeliveryPage-CvlWP7Eq.js +1 -0
  32. package/dist/assets/web/dist/assets/{ResidentDetailPage-Bcasasjb.js → ResidentDetailPage-D3P21yeI.js} +1 -1
  33. package/dist/assets/web/dist/assets/{ResidentsIndexPage-BJwyrphn.js → ResidentsIndexPage-DOYqnZ1q.js} +1 -1
  34. package/dist/assets/web/dist/assets/{RunRoutePage-6eStApqP.js → RunRoutePage-OmvrvPXY.js} +4 -4
  35. package/dist/assets/web/dist/assets/{RunsIndexPage-CWrkv7v8.js → RunsIndexPage-DWbSQtL4.js} +1 -1
  36. package/dist/assets/web/dist/assets/{ScheduledPage-BX2py1X3.js → ScheduledPage-CPKfJ4mR.js} +1 -1
  37. package/dist/assets/web/dist/assets/{StatusDot-CwCK84JN.js → StatusDot-COr8jTyM.js} +1 -1
  38. package/dist/assets/web/dist/assets/{Tooltip-CFC88_-Z.js → Tooltip-fOqTZkNT.js} +1 -1
  39. package/dist/assets/web/dist/assets/{dist-BoLiHpua.js → dist-BVjAWgkb.js} +1 -1
  40. package/dist/assets/web/dist/assets/main-CuENKPdD.css +1 -0
  41. package/dist/assets/web/dist/assets/{main-tYcFk9Dc.js → main-DZbJaqUb.js} +2 -2
  42. package/dist/cli.js +4621 -1804
  43. package/package.json +1 -1
  44. package/dist/assets/web/dist/assets/DeliveryPage-Cx7kQC_e.js +0 -1
  45. package/dist/assets/web/dist/assets/main-i3ZNDRLK.css +0 -1
@@ -81,6 +81,9 @@ export interface PullRequestFacts {
81
81
  createdAt: string;
82
82
  /** ISO 8601 — every fact here is a MERGED pull request. */
83
83
  mergedAt: string;
84
+ /** ISO 8601 — GitHub's `updated_at` of the pull request when these facts were read; an incremental
85
+ * read re-reads a listed row only when this moved. Absent on facts read before it was kept. */
86
+ updatedAt?: string;
84
87
  /** The head CI first ran on; absent when nothing recorded a head. */
85
88
  firstHeadSha?: string;
86
89
  ci: CiRunFact[];
@@ -201,6 +204,22 @@ export interface DeliveryReport {
201
204
  /** ISO 8601 — when the pull requests' facts were read from GitHub: the snapshot's time, or
202
205
  * the live read's. The service stamps it; a report built straight from facts has none. */
203
206
  snapshotAt?: string;
207
+ /** ISO 8601 — every pull request merged at or after this instant is in the report; when the
208
+ * report is `truncated`, the weeks that began before it hold the newest pull requests only
209
+ * (`weekIncomplete`). The service stamps it from the source; a report built straight from
210
+ * facts has none. */
211
+ completeFrom?: string;
212
+ }
213
+
214
+ /** A week row holds the newest pull requests only — not the week's — when the report is truncated and
215
+ * the week began before the instant the report is complete from (the week holding that instant is
216
+ * partial, so it counts). A report that names no instant marks no week. */
217
+ export function weekIncomplete(report: Pick<DeliveryReport, "truncated" | "completeFrom">, week: string): boolean {
218
+ return (
219
+ report.truncated &&
220
+ report.completeFrom !== undefined &&
221
+ Date.parse(`${week}T00:00:00Z`) < Date.parse(report.completeFrom)
222
+ );
204
223
  }
205
224
 
206
225
  // ---- the arithmetic ---------------------------------------------------------------------
@@ -546,9 +565,18 @@ export function snapshotAgeText(snapshotAt: string, nowMs: number): string {
546
565
  return unit(Math.round(hours / 24), "day");
547
566
  }
548
567
 
568
+ /** `<YYYY-MM-DD> <hh:mm> UTC` — an instant as the text spells one. */
569
+ const clock = (iso: string): string => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
570
+
549
571
  /** `as of <YYYY-MM-DD> <hh:mm> UTC, 12 minutes ago` — the snapshot's time and age. */
550
572
  function asOf(snapshotAt: string, nowMs: number): string {
551
- return `as of ${snapshotAt.slice(0, 10)} ${snapshotAt.slice(11, 16)} UTC, ${snapshotAgeText(snapshotAt, nowMs)}`;
573
+ return `as of ${clock(snapshotAt)}, ${snapshotAgeText(snapshotAt, nowMs)}`;
574
+ }
575
+
576
+ /** `the newest 291 pull requests only, complete from <YYYY-MM-DD> <hh:mm> UTC` — what a truncated report covers. */
577
+ function coverage(report: DeliveryReport): string {
578
+ const from = report.completeFrom === undefined ? "" : `, complete from ${clock(report.completeFrom)}`;
579
+ return `the newest ${report.totals.prsMerged} pull requests only${from}`;
552
580
  }
553
581
 
554
582
  /** One block per week, then the units — single-spaced lines, so chat carries them as they are. */
@@ -557,19 +585,16 @@ export function renderDeliveryReport(report: DeliveryReport, nowMs: number = sys
557
585
  [
558
586
  `${report.repo} · ${report.range.since} → ${report.range.until} · ${report.range.weeks} week${report.range.weeks === 1 ? "" : "s"}`,
559
587
  ...(report.snapshotAt !== undefined ? [asOf(report.snapshotAt, nowMs)] : []),
588
+ ...(report.truncated ? [coverage(report)] : []),
560
589
  ].join(" · "),
561
590
  ];
562
- if (report.truncated) lines.push("(the newest pull requests only — the fetch stopped before the range's start)");
563
591
  for (const w of report.weeks) {
592
+ const week = `Week of ${w.week}${weekIncomplete(report, w.week) ? " (incomplete)" : ""}`;
564
593
  if (w.prsMerged === 0) {
565
- lines.push("", `Week of ${w.week}: nothing merged`);
594
+ lines.push("", `${week}: nothing merged`);
566
595
  continue;
567
596
  }
568
- lines.push(
569
- "",
570
- `Week of ${w.week}: ${w.prsMerged} merged (${w.agentAuthoredPrs} agent-authored)`,
571
- ...indicatorLines(w),
572
- );
597
+ lines.push("", `${week}: ${w.prsMerged} merged (${w.agentAuthoredPrs} agent-authored)`, ...indicatorLines(w));
573
598
  }
574
599
  if (report.weeks.length > 1 && report.totals.prsMerged > 0) {
575
600
  lines.push(
@@ -675,6 +700,11 @@ export interface DeliveryFetch {
675
700
  export interface DeliveryFetchOptions {
676
701
  /** Read GitHub now, whatever a snapshot holds, and refresh the snapshot. */
677
702
  fresh?: boolean;
703
+ /** An incremental read (the snapshot's refresh): only the pull requests touched at or after `since`
704
+ * — the listing is newest-touched first, so the first older row ends it — and, of those, only the
705
+ * rows whose update time `known` does not already hold for their number; a held row's stored facts
706
+ * stand. The fetch is then complete from `since`, not from the range's start. */
707
+ touched?: { since: string; known: ReadonlyMap<number, string> };
678
708
  }
679
709
 
680
710
  /** Where the pull requests' facts come from: GitHub in production (behind the snapshot), memory in tests. */
@@ -682,13 +712,36 @@ export interface DeliverySource {
682
712
  fetchPullRequests(repo: string, range: DeliveryRange, opts?: DeliveryFetchOptions): Promise<DeliveryFetch>;
683
713
  }
684
714
 
685
- /** The second implementation (AGENTS.md invariant 2) and the test double: a map of repo → facts. */
715
+ /** The second implementation (AGENTS.md invariant 2) and the test double: a map of repo → facts.
716
+ * An incremental read answers the rows touched since the instant (by `updatedAt`, else `mergedAt`)
717
+ * whose update time the caller does not hold, complete from that instant. */
686
718
  export class InMemoryDeliverySource implements DeliverySource {
687
- readonly calls: Array<{ repo: string; range: DeliveryRange; fresh?: boolean }> = [];
688
- constructor(private readonly byRepo: Readonly<Record<string, PullRequestFacts[]>> = {}) {}
719
+ readonly calls: Array<{
720
+ repo: string;
721
+ range: DeliveryRange;
722
+ fresh?: boolean;
723
+ touched?: DeliveryFetchOptions["touched"];
724
+ }> = [];
725
+ constructor(private byRepo: Readonly<Record<string, PullRequestFacts[]>> = {}) {}
726
+ /** Replace what a repository answers (a test moving GitHub on). */
727
+ set(repo: string, prs: PullRequestFacts[]): void {
728
+ this.byRepo = { ...this.byRepo, [repo]: prs };
729
+ }
689
730
  fetchPullRequests(repo: string, range: DeliveryRange, opts?: DeliveryFetchOptions): Promise<DeliveryFetch> {
690
- this.calls.push({ repo, range, ...(opts?.fresh ? { fresh: true } : {}) });
691
- return Promise.resolve({ prs: this.byRepo[repo] ?? [], truncated: false });
731
+ this.calls.push({
732
+ repo,
733
+ range,
734
+ ...(opts?.fresh ? { fresh: true } : {}),
735
+ ...(opts?.touched ? { touched: opts.touched } : {}),
736
+ });
737
+ const all = this.byRepo[repo] ?? [];
738
+ const touched = opts?.touched;
739
+ if (!touched) return Promise.resolve({ prs: all, truncated: false });
740
+ const since = Date.parse(touched.since);
741
+ const prs = all.filter(
742
+ (p) => Date.parse(p.updatedAt ?? p.mergedAt) >= since && touched.known.get(p.number) !== p.updatedAt,
743
+ );
744
+ return Promise.resolve({ prs, truncated: false, completeFrom: touched.since });
692
745
  }
693
746
  }
694
747
 
@@ -707,8 +760,8 @@ export interface DeliveryService {
707
760
  /** The configured repositories; the first is the page's default. */
708
761
  repos(): string[];
709
762
  /** The report for one repository over the resolved range — from the snapshot when one fits the
710
- * range, a live read otherwise or on `fresh`; stamped with when its facts were read. Throws on
711
- * upstream failure. */
763
+ * range, a live read otherwise or on `fresh`; stamped with when its facts were read and from
764
+ * when they are complete. Throws on upstream failure. */
712
765
  report(repo: string, opts: DeliveryReportOptions): Promise<DeliveryReport>;
713
766
  }
714
767
 
@@ -769,6 +822,7 @@ export function createDeliveryService(
769
822
  }),
770
823
  // A source behind a snapshot says when it read; a bare one read just now.
771
824
  snapshotAt: fetched.fetchedAt ?? at.toISOString(),
825
+ ...(fetched.completeFrom !== undefined ? { completeFrom: fetched.completeFrom } : {}),
772
826
  };
773
827
  },
774
828
  };
@@ -17,27 +17,44 @@ import { errorSuffix } from "./workerError.js";
17
17
  // Node-free on purpose: the state Worker imports `isDeliverySnapshot` by
18
18
  // relative path so the bot and the Worker validate ONE shape.
19
19
  //
20
+ // A snapshot is written whole once — the first read of a repository — and
21
+ // patched by every refresh after: the rows the refresh re-read, the rows that
22
+ // aged out of the window, the new meta. A busy repository's window is many MB
23
+ // and its hourly change a few rows, so the write follows the change.
24
+ //
20
25
  // Route contract (JSON in/out, bearer = the Worker's MEMORY_TOKEN):
21
- // POST /delivery/get {repo} → {snapshot: DeliverySnapshot | null}
22
- // POST /delivery/put {snapshot} → {ok: true, prs}
26
+ // POST /delivery/get {repo} → {snapshot: DeliverySnapshot | null}
27
+ // POST /delivery/put {snapshot} → {ok: true, prs} replace the repository's snapshot whole
28
+ // POST /delivery/merge {patch} → {ok: true, prs} | 404 apply a refresh; 404 with no snapshot to merge into
23
29
 
24
- /** One repository's facts as a source assembled them, and when. */
25
- export interface DeliverySnapshot {
30
+ /** What a snapshot says about itself: everything but the rows. */
31
+ export interface DeliverySnapshotMeta {
26
32
  /** `owner/name`. */
27
33
  repo: string;
28
34
  /** ISO 8601 — when the read from GitHub began. */
29
35
  snapshotAt: string;
30
36
  /** The window the facts were read for: Monday-start weeks ending on the snapshot day. */
31
37
  range: DeliveryRange;
32
- /** Every pull request merged inside the window that the read reached. */
33
- prs: PullRequestFacts[];
34
- /** The listing stopped at its page cap before the window's start — the newest pull requests only. */
38
+ /** The read stopped at its page cap before the window's start — the newest pull requests only. */
35
39
  truncated: boolean;
36
40
  /** ISO 8601 — every pull request merged at or after this instant is in `prs`: the window's
37
- * start on a complete read, the oldest update the capped listing reached otherwise. */
41
+ * start on a complete read, the oldest update a capped listing reached otherwise. */
38
42
  completeFrom: string;
39
43
  }
40
44
 
45
+ /** One repository's facts as a source assembled them, and when. */
46
+ export interface DeliverySnapshot extends DeliverySnapshotMeta {
47
+ /** Every pull request merged inside the window that the reads reached, in number order. */
48
+ prs: PullRequestFacts[];
49
+ }
50
+
51
+ /** What one refresh changes in a stored snapshot: the new meta, the rows the read re-read — they
52
+ * replace the stored ones by number — and the numbers of the rows that aged out of the window. */
53
+ export interface DeliverySnapshotPatch extends DeliverySnapshotMeta {
54
+ upsert: PullRequestFacts[];
55
+ drop: number[];
56
+ }
57
+
41
58
  const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
42
59
  const isInstant = (v: unknown): v is string => typeof v === "string" && Number.isFinite(Date.parse(v));
43
60
 
@@ -52,6 +69,7 @@ export function isPullRequestFacts(v: unknown): v is PullRequestFacts {
52
69
  typeof p.author === "string" &&
53
70
  isInstant(p.createdAt) &&
54
71
  isInstant(p.mergedAt) &&
72
+ (p.updatedAt === undefined || isInstant(p.updatedAt)) &&
55
73
  (p.firstHeadSha === undefined || typeof p.firstHeadSha === "string") &&
56
74
  Array.isArray(p.ci) &&
57
75
  Array.isArray(p.reviews) &&
@@ -60,32 +78,59 @@ export function isPullRequestFacts(v: unknown): v is PullRequestFacts {
60
78
  );
61
79
  }
62
80
 
63
- export function isDeliverySnapshot(v: unknown): v is DeliverySnapshot {
64
- if (typeof v !== "object" || v === null) return false;
65
- const s = v as Record<string, unknown>;
81
+ /** The fields every snapshot document carries, present and of the right kind. */
82
+ function hasSnapshotMeta(s: Record<string, unknown>): boolean {
66
83
  if (typeof s.repo !== "string" || !REPO_SLUG.test(s.repo)) return false;
67
84
  if (!isInstant(s.snapshotAt) || !isInstant(s.completeFrom)) return false;
68
85
  if (typeof s.truncated !== "boolean") return false;
69
86
  const range = s.range as Record<string, unknown> | null | undefined;
70
- if (
71
- typeof range !== "object" ||
72
- range === null ||
73
- typeof range.since !== "string" ||
74
- !ISO_DAY.test(range.since) ||
75
- typeof range.until !== "string" ||
76
- !ISO_DAY.test(range.until) ||
77
- typeof range.weeks !== "number" ||
78
- !Number.isFinite(range.weeks)
79
- )
80
- return false;
81
- return Array.isArray(s.prs) && s.prs.every(isPullRequestFacts);
87
+ return (
88
+ typeof range === "object" &&
89
+ range !== null &&
90
+ typeof range.since === "string" &&
91
+ ISO_DAY.test(range.since) &&
92
+ typeof range.until === "string" &&
93
+ ISO_DAY.test(range.until) &&
94
+ typeof range.weeks === "number" &&
95
+ Number.isFinite(range.weeks)
96
+ );
97
+ }
98
+
99
+ export function isDeliverySnapshot(v: unknown): v is DeliverySnapshot {
100
+ if (typeof v !== "object" || v === null) return false;
101
+ const s = v as Record<string, unknown>;
102
+ return hasSnapshotMeta(s) && Array.isArray(s.prs) && s.prs.every(isPullRequestFacts);
103
+ }
104
+
105
+ export function isDeliverySnapshotPatch(v: unknown): v is DeliverySnapshotPatch {
106
+ if (typeof v !== "object" || v === null) return false;
107
+ const { upsert, drop, ...meta } = v as Record<string, unknown>;
108
+ return (
109
+ hasSnapshotMeta(meta) &&
110
+ Array.isArray(upsert) &&
111
+ upsert.every(isPullRequestFacts) &&
112
+ Array.isArray(drop) &&
113
+ drop.every((n) => typeof n === "number" && Number.isFinite(n))
114
+ );
115
+ }
116
+
117
+ /** The snapshot a patch leaves behind when applied to `stored`: rows by number, in number order. */
118
+ export function applyPatch(stored: DeliverySnapshot, patch: DeliverySnapshotPatch): DeliverySnapshot {
119
+ const { upsert, drop, ...meta } = patch;
120
+ const rows = new Map(stored.prs.map((p) => [p.number, p]));
121
+ for (const p of upsert) rows.set(p.number, p);
122
+ for (const n of drop) rows.delete(n);
123
+ return { ...meta, prs: [...rows.values()].sort((a, b) => a.number - b.number) };
82
124
  }
83
125
 
84
126
  export interface DeliverySnapshotStore {
85
127
  /** The repository's stored snapshot, or undefined when none was ever stored. Throws on failure. */
86
128
  get(repo: string): Promise<DeliverySnapshot | undefined>;
87
- /** Replace the repository's snapshot. Throws on failure; callers warn, never crash. */
129
+ /** Replace the repository's snapshot whole. Throws on failure; callers warn, never crash. */
88
130
  put(snapshot: DeliverySnapshot): Promise<void>;
131
+ /** Apply a refresh to the repository's stored snapshot. False when nothing is stored to merge
132
+ * into — the caller then writes the snapshot whole. Throws on failure. */
133
+ merge(patch: DeliverySnapshotPatch): Promise<boolean>;
89
134
  }
90
135
 
91
136
  export class InMemoryDeliverySnapshotStore implements DeliverySnapshotStore {
@@ -99,6 +144,13 @@ export class InMemoryDeliverySnapshotStore implements DeliverySnapshotStore {
99
144
  async put(snapshot: DeliverySnapshot): Promise<void> {
100
145
  this.byRepo.set(snapshot.repo, structuredClone(snapshot));
101
146
  }
147
+
148
+ async merge(patch: DeliverySnapshotPatch): Promise<boolean> {
149
+ const stored = this.byRepo.get(patch.repo);
150
+ if (!stored) return false;
151
+ this.byRepo.set(patch.repo, applyPatch(stored, structuredClone(patch)));
152
+ return true;
153
+ }
102
154
  }
103
155
 
104
156
  /** Per-request ceiling: a snapshot of a busy repository is a few MB each way. */
@@ -137,6 +189,14 @@ export class WorkerDeliverySnapshotStore implements DeliverySnapshotStore {
137
189
  if (!res.ok) throw new Error(`state Worker /delivery/put HTTP ${res.status}${await errorSuffix(res)}`);
138
190
  }
139
191
 
192
+ async merge(patch: DeliverySnapshotPatch): Promise<boolean> {
193
+ const res = await this.post("/delivery/merge", { patch });
194
+ // Nothing stored to merge into — or a Worker from before the route: either way the caller writes whole.
195
+ if (res.status === 404) return false;
196
+ if (!res.ok) throw new Error(`state Worker /delivery/merge HTTP ${res.status}${await errorSuffix(res)}`);
197
+ return true;
198
+ }
199
+
140
200
  private post(path: string, body: unknown): Promise<Response> {
141
201
  return this.fetchImpl(`${this.baseUrl}${path}`, {
142
202
  method: "POST",
@@ -0,0 +1,296 @@
1
+ // Deterministic review verdict → GitHub comment body.
2
+ //
3
+ // Downstream automation (a repository's opt-in `auto-approve-review-lgtm.yml`
4
+ // workflow) approves a PR when the review App's review body STARTS WITH the
5
+ // exact token `LGTM:`. That token must therefore never depend on how the model happens to
6
+ // phrase its opening line. The model states its judgement through the
7
+ // structured `submit_verdict` tool (src/tools/workspace.ts); this module turns
8
+ // that structured value into the first line of the posted body:
9
+ //
10
+ // approve → "LGTM: <summary>"
11
+ // request_changes → "Changes requested: <summary>"
12
+ // (no verdict) → "No verdict submitted — not approving." (fail-closed)
13
+ //
14
+ // The model's prose follows after a blank line. Whatever the prose says, only
15
+ // an explicit `approve` verdict can produce a body that begins with "LGTM".
16
+ //
17
+ // The verdict also carries typed findings (docs/reference/specs/agent-ship.md item 6):
18
+ // one compact entry per issue, rendered as a list under the verdict line, so
19
+ // a fix round can reference each finding by its stable id and answer it with
20
+ // a typed disposition (`parseDispositionsInput` below). Findings validate
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.
26
+
27
+ import { normalizeHead } from "./reviewedHead.js";
28
+ import { redactSecrets } from "./redact.js";
29
+
30
+ export type ReviewVerdictKind = "approve" | "request_changes";
31
+
32
+ export const FINDING_SEVERITIES = ["blocking", "major", "minor", "nit"] as const;
33
+ export type FindingSeverity = (typeof FINDING_SEVERITIES)[number];
34
+
35
+ export interface Finding {
36
+ /** Stable id the reviewer assigned in order ("F1", "F2", …) — dispositions
37
+ * reference findings by this id. */
38
+ id: string;
39
+ severity: FindingSeverity;
40
+ /** Repo-relative file the finding points at. */
41
+ file: string;
42
+ /** Optional 1-based line. An unusable value is dropped like a malformed
43
+ * `head` — the finding stands without it. */
44
+ line?: number;
45
+ /** One line naming the issue; the full explanation lives in the prose. */
46
+ title: string;
47
+ }
48
+
49
+ export interface ReviewVerdict {
50
+ verdict: ReviewVerdictKind;
51
+ /** One line: why. Newlines are collapsed so the token line stays one line. */
52
+ summary: string;
53
+ /** The commit the agent says it reviewed (`git rev-parse HEAD` in its
54
+ * checkout), 7–40 lowercase hex. The dispatcher's reviewed-head guard
55
+ * (reviewedHead.ts) compares it to the PR head when the workspace HEAD
56
+ * could not be observed directly. Absent when not supplied or malformed. */
57
+ head?: string;
58
+ /** Typed findings enumerated through `submit_verdict`. Present only when
59
+ * the input carried a findings array (possibly empty after drops); absent
60
+ * when no array was supplied or the array itself was malformed. */
61
+ findings?: Finding[];
62
+ /** Parse notes naming what was dropped from `findings` (ids/indices with
63
+ * reasons) — surfaced in the tool ack so the model can resubmit, never
64
+ * rendered into the posted body. */
65
+ droppedFindings?: string[];
66
+ }
67
+
68
+ export const LGTM_TOKEN = "LGTM:";
69
+ export const CHANGES_TOKEN = "Changes requested:";
70
+ export const NO_VERDICT_LINE = "No verdict submitted — not approving.";
71
+
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 {
74
+ const verdict = input.verdict;
75
+ if (verdict !== "approve" && verdict !== "request_changes") return null;
76
+ const summary = typeof input.summary === "string" ? oneLine(input.summary) : "";
77
+ const head = normalizeHead(input.head);
78
+ const out: ReviewVerdict = { verdict, summary };
79
+ if (head) out.head = head;
80
+ if (input.findings !== undefined) {
81
+ const parsed = parseFindings(input.findings);
82
+ if (parsed.findings) out.findings = parsed.findings;
83
+ 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) {
90
+ 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
+ );
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** 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[] } {
104
+ if (!Array.isArray(value)) {
105
+ return { dropped: ["findings: dropped — not an array"], blocking: [] };
106
+ }
107
+ const findings: Finding[] = [];
108
+ const dropped: string[] = [];
109
+ const blocking: string[] = [];
110
+ value.forEach((raw, i) => {
111
+ const parsed = parseFinding(raw);
112
+ if (parsed.finding) {
113
+ findings.push(parsed.finding);
114
+ } else {
115
+ dropped.push(`findings[${i}]${parsed.id ? ` (${parsed.id})` : ""}: dropped — ${parsed.reason}`);
116
+ }
117
+ const label = parsed.finding?.id ?? parsed.id ?? `findings[${i}]`;
118
+ const r = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : undefined;
119
+ if (r?.severity === "blocking") blocking.push(label);
120
+ });
121
+ return { findings, dropped, blocking };
122
+ }
123
+
124
+ function parseFinding(
125
+ raw: unknown,
126
+ ): { finding: Finding; id?: never; reason?: never } | { finding?: never; id?: string; reason: string } {
127
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return { reason: "not an object" };
128
+ const r = raw as Record<string, unknown>;
129
+ const id = typeof r.id === "string" ? oneLine(r.id) : "";
130
+ if (!id) return { reason: "missing or empty id" };
131
+ const severity = r.severity;
132
+ if (!(FINDING_SEVERITIES as readonly unknown[]).includes(severity)) {
133
+ return { id, reason: `invalid severity ${JSON.stringify(r.severity)} (expected ${FINDING_SEVERITIES.join("|")})` };
134
+ }
135
+ const file = typeof r.file === "string" ? oneLine(r.file) : "";
136
+ if (!file) return { id, reason: "missing or empty file" };
137
+ const title = typeof r.title === "string" ? oneLine(r.title) : "";
138
+ if (!title) return { id, reason: "missing or empty title" };
139
+ const finding: Finding = { id, severity: severity as FindingSeverity, file, title };
140
+ // Like `head`: an unusable optional locator is dropped, the finding stands —
141
+ // dropping the whole finding over a bad line would also drop the severity
142
+ // that the approve→request_changes downgrade keys on.
143
+ if (typeof r.line === "number" && Number.isInteger(r.line) && r.line >= 1) finding.line = r.line;
144
+ return { finding };
145
+ }
146
+
147
+ function oneLine(s: string): string {
148
+ return s.replace(/\s*\n+\s*/g, " ").trim();
149
+ }
150
+
151
+ /** The exact first line of the posted body for a verdict (or its absence). */
152
+ export function verdictLine(verdict: ReviewVerdict | undefined): string {
153
+ if (!verdict) return NO_VERDICT_LINE;
154
+ const token = verdict.verdict === "approve" ? LGTM_TOKEN : CHANGES_TOKEN;
155
+ const summary = oneLine(verdict.summary);
156
+ return summary ? `${token} ${summary}` : token;
157
+ }
158
+
159
+ /** One compact finding line — `[severity] id file[:line] — title` — shared by
160
+ * the posted body's list (bulleted below) and ship's synthesized child turns. */
161
+ export function formatFinding(f: Finding): string {
162
+ const location = f.line !== undefined ? `${f.file}:${f.line}` : f.file;
163
+ return `[${f.severity}] ${f.id} ${location} — ${f.title}`;
164
+ }
165
+
166
+ /**
167
+ * Build the body posted to GitHub: the deterministic verdict line, the
168
+ * compact findings list (when present), a blank line, then the model's
169
+ * review text. Never starts with "LGTM" unless the verdict is `approve`.
170
+ */
171
+ export function buildReviewPostBody(answer: string, verdict: ReviewVerdict | undefined): string {
172
+ const head = [verdictLine(verdict), ...(verdict?.findings ?? []).map((f) => `- ${formatFinding(f)}`)];
173
+ return `${head.join("\n")}\n\n${answer.trim()}`;
174
+ }
175
+
176
+ // --- Dispositions (the coding side's answer to findings) -------------------
177
+ //
178
+ // A fix round records one disposition per finding through the
179
+ // `submit_dispositions` tool; the ship orchestrator keeps the last valid set
180
+ // and splits a cap report into declined (disposition recorded) vs unaddressed
181
+ // (none). Validation mirrors the findings above: a malformed entry drops with
182
+ // a note; a non-array input rejects the whole call.
183
+
184
+ export type DispositionKind = "fixed" | "declined";
185
+
186
+ export interface FindingDisposition {
187
+ /** The stable finding id from the review verdict this disposition answers. */
188
+ findingId: string;
189
+ disposition: DispositionKind;
190
+ /** One line: what was done, or why the finding was declined. */
191
+ note: string;
192
+ }
193
+
194
+ /**
195
+ * Parse a `submit_dispositions` input. Null when `dispositions` is not an
196
+ * array (the call is rejected, nothing recorded); otherwise each malformed
197
+ * entry drops with a note naming its index (and findingId when readable),
198
+ * mirroring the per-finding validation above. A missing note is tolerated as
199
+ * "" (the summary convention), never a dropped entry.
200
+ */
201
+ export function parseDispositionsInput(
202
+ input: Record<string, unknown>,
203
+ ): { dispositions: FindingDisposition[]; dropped: string[] } | null {
204
+ const value = input.dispositions;
205
+ if (!Array.isArray(value)) return null;
206
+ const dispositions: FindingDisposition[] = [];
207
+ const dropped: string[] = [];
208
+ value.forEach((raw, i) => {
209
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
210
+ dropped.push(`dispositions[${i}]: dropped — not an object`);
211
+ return;
212
+ }
213
+ const r = raw as Record<string, unknown>;
214
+ const findingId = typeof r.findingId === "string" ? oneLine(r.findingId) : "";
215
+ if (!findingId) {
216
+ dropped.push(`dispositions[${i}]: dropped — missing or empty findingId`);
217
+ return;
218
+ }
219
+ const disposition = r.disposition;
220
+ if (disposition !== "fixed" && disposition !== "declined") {
221
+ dropped.push(
222
+ `dispositions[${i}] (${findingId}): dropped — invalid disposition ${JSON.stringify(r.disposition)} (expected fixed|declined)`,
223
+ );
224
+ return;
225
+ }
226
+ const note = typeof r.note === "string" ? oneLine(r.note) : "";
227
+ dispositions.push({ findingId, disposition, note });
228
+ });
229
+ return { dispositions, dropped };
230
+ }
231
+
232
+ // ---- the stored shapes (docs/reference/specs/run-history.md items 2 and 3) ---------------------------
233
+
234
+ const VERDICT_KINDS: readonly string[] = ["approve", "request_changes"];
235
+ const DISPOSITION_KINDS: readonly string[] = ["fixed", "declined"];
236
+
237
+ const isRecordLike = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null;
238
+
239
+ function isFindingShape(v: unknown): v is Finding {
240
+ if (!isRecordLike(v)) return false;
241
+ return (
242
+ typeof v.id === "string" &&
243
+ (FINDING_SEVERITIES as readonly string[]).includes(v.severity as string) &&
244
+ typeof v.file === "string" &&
245
+ typeof v.title === "string" &&
246
+ (v.line === undefined || typeof v.line === "number")
247
+ );
248
+ }
249
+
250
+ /** Structural check on a verdict read back from a stored record: the kind, a
251
+ * string summary, an optional string head, findings each with an id, a known
252
+ * severity, a file and a title. Shape only, no bounds — what a record carries
253
+ * was validated by `parseVerdictInput` on the way in and redacted since. */
254
+ export function isReviewVerdictShape(v: unknown): v is ReviewVerdict {
255
+ if (!isRecordLike(v)) return false;
256
+ if (!VERDICT_KINDS.includes(v.verdict as string) || typeof v.summary !== "string") return false;
257
+ if (v.head !== undefined && typeof v.head !== "string") return false;
258
+ if (v.findings !== undefined && !(Array.isArray(v.findings) && v.findings.every(isFindingShape))) return false;
259
+ return true;
260
+ }
261
+
262
+ /** Structural check on a disposition set read back from a stored record. */
263
+ export function isFindingDispositionsShape(v: unknown): v is FindingDisposition[] {
264
+ return (
265
+ Array.isArray(v) &&
266
+ v.every(
267
+ (d) =>
268
+ isRecordLike(d) &&
269
+ typeof d.findingId === "string" &&
270
+ DISPOSITION_KINDS.includes(d.disposition as string) &&
271
+ typeof d.note === "string",
272
+ )
273
+ );
274
+ }
275
+
276
+ /** Every string leaf of the verdict through the redaction seam — the summary,
277
+ * each finding's file and title — the input untouched: what the one record
278
+ * assembly writes. `droppedFindings` are parse notes for the model and never
279
+ * ride a record. */
280
+ export function redactVerdict(v: ReviewVerdict, redact: (s: string) => string = redactSecrets): ReviewVerdict {
281
+ return {
282
+ verdict: v.verdict,
283
+ summary: redact(v.summary),
284
+ ...(v.head !== undefined ? { head: v.head } : {}),
285
+ ...(v.findings !== undefined
286
+ ? { findings: v.findings.map((f) => ({ ...f, file: redact(f.file), title: redact(f.title) })) }
287
+ : {}),
288
+ };
289
+ }
290
+
291
+ export function redactDispositions(
292
+ dispositions: readonly FindingDisposition[],
293
+ redact: (s: string) => string = redactSecrets,
294
+ ): FindingDisposition[] {
295
+ return dispositions.map((d) => ({ findingId: d.findingId, disposition: d.disposition, note: redact(d.note) }));
296
+ }