@feigi/fleet-ctl 3.84.0 → 3.85.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.
@@ -15,7 +15,9 @@ Your scratch directory is `<scratch>/`; you write only under
15
15
  `<scratch>/pr<N>/merge-bot-<n>/`.
16
16
 
17
17
  Your report is the one file you write outside it: `<scratch>/reports/merge-bot-<n>.json`,
18
- the path your brief names, holding every PR you merged in `merged`. Your final
18
+ the path your brief names, giving every PR queued when you were dispatched
19
+ exactly one verdict — `merged`, `held`, `conflictHold` or `refused` — as
20
+ run-merge-bot.md's **One verdict per queued PR** says. Your final
19
21
  message is the one line your brief gives, `merge-bot-<n>: done <path>`.
20
22
 
21
23
  The shell traps below are copied from the controller's skill, `run-team`. In
@@ -283,6 +283,7 @@ Measured over one three-merge pass: the next queue member went 0 → 2 → 7 →
283
283
  **A PR whose heavy jobs have only ever `skipped` is getting its first real verification from your rebase.** Reviewers may legitimately have labelled on the checks that did run plus local evidence, saying so explicitly. When your post-rebase run finally executes those suites, treat a red there as a **genuine first result**, not a regression you caused — read the failing job before concluding, and do not hand it back as "the rebase broke it".
284
284
 
285
285
  Report merged / skipped-unlabeled / held-behind-#X / worktree-diverged-#X / head-moved-after-label-#X / rebase-carry-#X (merged or not, with the labelled and accepted SHAs) / main-gain-removed:<path>-#X / label-drop-failed-#X / label-read-failed-#X / branch-delete-failed-#X / branch-kept-#X (X the open PR that keeps the merged branch — a keep, not a failure) / rebase-fallback-#X / instrument-set-changed-#X / ci-wait-timeout-#X (with the run id and its `status`) / `<reason>-#X` for any other gate refusal / blocked after the pass — a dispatched bot once, at exit, after its grace (**Grace, then one report**, below). Every acknowledged main-gain removal the gate passed is named in the report, with its reason.
286
+ In the report file, every PR queued when you were dispatched is listed under exactly one of `merged`, `held`, `conflictHold` or `refused` — those four lists are its verdict, the tokens above are not — and a settle check refuses a report that leaves one out (**One verdict per queued PR**, under **Grace, then one report**).
286
287
 
287
288
  ## No-undo audit (before every rebase)
288
289
 
@@ -324,6 +325,8 @@ The wait:
324
325
 
325
326
  **One report, at exit, and none before it** — every PR this pass touched, in the vocabulary above, a pass that merged nothing included. The controller reaps on that report and records each `held-behind-#<lower>` on the held ticket's row, so a report sent mid-pass is one it acts on too early.
326
327
 
328
+ **One verdict per queued PR, in the report file.** A dispatched bot's report file (`<scratch>/reports/merge-bot-<n>.json`) gives every PR that carried `ready-to-merge` when you were dispatched exactly one verdict — the last it reached in the pass: `merged`, `held` (a `held-behind-#<lower>`), `conflictHold` (a `conflict-held-#<pr>`, or a `rebase-fallback-#<pr>` you recorded as a conflict hold), or `refused` with the token above as its `reason` (`main-gain-removed:<path>`, `head-moved-after-label`, `ci-wait-timeout`, any other gate refusal). A PR you never reached is still `refused`, with the reason you stopped short of it; a PR refused `behind:<n>` and merged later in the same pass is only `merged`. `branchKept` is not a verdict. Two checks enforce this, not the rule alone: `report-schema.mjs validate merge-bot` answers `invalid` for a PR named twice, in one list or in two, and for a `refused` entry whose `reason` is empty or only whitespace; and `ledger.mjs settle merge-bot-<n> --from-report`, which `fleet-event.mjs merge-bot-report` runs, judges the report against the queue `ledger.mjs dispatch merge-bot` recorded on your `## Dispatched` entry — never a list your report supplies — and refuses it, naming each PR, when a PR from that queue that is still open and carries `ready-to-merge` has no verdict. A PR labelled after your dispatch, or one that left the queue before the settle, never fails it. Each `refused` entry lands in the ledger's `## Ruled` as `merge-bot-<n> refused #<pr>: <reason>`, the reason trimmed, a record only: it moves no label and holds nothing.
329
+
327
330
  Your `task` result is the report, delivered when you exit.
328
331
 
329
332
  ## Then stay armed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.84.0",
3
+ "version": "3.85.0",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -117,27 +117,37 @@ export function memberRole(family) {
117
117
 
118
118
  // A `## Dispatched` entry is a member token, followed by `brief=<hash>` when
119
119
  // `ledger.mjs dispatch` recorded the brief the member was dispatched with:
120
- // the first 8 hex of the sha256 the brief's sidecar carries. `settle`
121
- // rewrites the token and keeps the word beside it.
122
- const BRIEF_WORD = /^brief=([0-9a-f]{8})$/;
120
+ // the first 8 hex of the sha256 the brief's sidecar carries. A merge bot's
121
+ // entry then carries `queued=<pr>,<pr>…` — `queued=none` for an empty queue —
122
+ // the open `ready-to-merge` PRs `dispatch` read when it dispatched the bot,
123
+ // which its report is judged against at `settle --from-report`. `settle`
124
+ // rewrites the token and keeps the words beside it.
125
+ const DISPATCHED_ENTRY = /^([^ ]*) brief=([0-9a-f]{8})(?: queued=(none|[1-9][0-9]*(?:,[1-9][0-9]*)*))?$/;
123
126
 
124
127
  /**
125
- * `{token, brief}` for one `## Dispatched` entry: the member token and the
126
- * brief's hash, or null for an entry without one. An entry that is not a
127
- * member token plus one well-formed `brief=` word is returned whole as
128
- * `token`, so a reader parsing it with parseToken() refuses it exactly as it
129
- * refuses any other entry this grammar cannot parse.
128
+ * `{token, brief, queued}` for one `## Dispatched` entry: the member token,
129
+ * the brief's hash or null, and the PR numbers of its `queued=` word or null
130
+ * for an entry without one. An entry that is not a member token plus a
131
+ * well-formed `brief=` word, optionally followed by a well-formed `queued=`
132
+ * word, is returned whole as `token`, so a reader parsing it with
133
+ * parseToken() refuses it exactly as it refuses any other entry this grammar
134
+ * cannot parse.
130
135
  */
131
136
  export function parseDispatchedEntry(entry) {
132
- const words = entry.split(" ");
133
- const m = words.length === 2 ? BRIEF_WORD.exec(words[1]) : null;
134
- if (m === null || parseToken(words[0]) === null) return { token: entry, brief: null };
135
- return { token: words[0], brief: m[1] };
137
+ const m = DISPATCHED_ENTRY.exec(entry);
138
+ if (m === null || parseToken(m[1]) === null) return { token: entry, brief: null, queued: null };
139
+ const queued = m[3] === undefined ? null : m[3] === "none" ? [] : m[3].split(",").map(Number);
140
+ return { token: m[1], brief: m[2], queued };
136
141
  }
137
142
 
138
- /** The `## Dispatched` entry for `token`, with `brief=<brief>` unless brief is null. */
139
- export function dispatchedEntry(token, brief) {
140
- return brief === null ? token : `${token} brief=${brief}`;
143
+ /**
144
+ * The `## Dispatched` entry for `token`, with `brief=<brief>` unless brief is
145
+ * null and, after it, `queued=…` unless queued is null.
146
+ */
147
+ export function dispatchedEntry(token, brief, queued = null) {
148
+ if (brief === null) return token;
149
+ const entry = `${token} brief=${brief}`;
150
+ return queued === null ? entry : `${entry} queued=${queued.join(",") || "none"}`;
141
151
  }
142
152
 
143
153
  // `PR#<n>` as a word of a row's text, `PR #<n>` too.
@@ -13,7 +13,9 @@
13
13
  // every liveness count from this file alone, rather than from row text a
14
14
  // controller typed by hand: `dispatch` writes a member's live token onto its
15
15
  // row and appends it to `## Dispatched` with `brief=<hash>`, the brief it
16
- // refuses to dispatch without; when that brief's sidecar names an absolute
16
+ // refuses to dispatch without — and, for a merge bot, `queued=<pr>,…`, the
17
+ // open `ready-to-merge` PRs gh lists at dispatch, which its report must give
18
+ // a verdict each; when that brief's sidecar names an absolute
17
19
  // scratch, it also clears the member's report file there, so a prior run's
18
20
  // report is not read as this run's; `settle` rewrites that token to
19
21
  // `<member>=<outcome>` in both places (an implementer's settle to `PR#M` also
@@ -59,6 +61,7 @@ import { stateFileIn, readState, writeState, assessBeat, stallsAt } from "./flee
59
61
  import { processSource, ancestry, controllerOf, judgeController, ProcUnreadable } from "./proc.mjs";
60
62
  import { attemptLock, releaseLockFile, reapPathOf } from "./pid-lock.mjs";
61
63
  import { readCheckRecordIn, readReportIn, reportsDir, reportFamily, defaultScratch, reportPath } from "./report-files.mjs";
64
+ import { mergeBotVerdicts } from "./report-schema.mjs";
62
65
 
63
66
  const NAME = "ledger";
64
67
  // The plugin's own agent definitions — the directory tier-check.mjs reads
@@ -494,7 +497,7 @@ function sectionOf(text, name) {
494
497
  }
495
498
 
496
499
  function load() {
497
- if (!existsSync(file)) return { rows: [], filed: [], ruled: [], dispatched: [], briefs: {}, drain: null, retired: [] };
500
+ if (!existsSync(file)) return { rows: [], filed: [], ruled: [], dispatched: [], briefs: {}, queued: {}, drain: null, retired: [] };
498
501
  let text;
499
502
  try {
500
503
  text = readFileSync(file, "utf8");
@@ -526,15 +529,14 @@ function load() {
526
529
  }
527
530
 
528
531
  // `## Dispatched` held as its tokens, which is what every reader of `read`
529
- // parses, and `briefs`, each member's `brief=` hash by name: one token per
530
- // member name for the session, so a settle that rewrites the token keeps the
531
- // name its brief is filed under. save() joins them back.
532
+ // parses, `briefs`, each member's `brief=` hash by name, and `queued`, each
533
+ // merge bot's `queued=` PR numbers by name: one token per member name for
534
+ // the session, so a settle that rewrites the token keeps the name its brief
535
+ // and its queue are filed under. save() joins them back.
532
536
  function dispatchedOf(entries) {
533
537
  const parsed = entries.map(parseDispatchedEntry);
534
- return {
535
- dispatched: parsed.map((p) => p.token),
536
- briefs: Object.fromEntries(parsed.filter((p) => p.brief !== null).map((p) => [parseToken(p.token).name, p.brief])),
537
- };
538
+ const byName = (field) => Object.fromEntries(parsed.filter((p) => p[field] !== null).map((p) => [parseToken(p.token).name, p[field]]));
539
+ return { dispatched: parsed.map((p) => p.token), briefs: byName("brief"), queued: byName("queued") };
538
540
  }
539
541
 
540
542
  // `## Drain` is written only once there is a marker to hold: an empty section
@@ -547,7 +549,10 @@ function save(d) {
547
549
  const list = (entries) => entries.map((r) => `- ${escapeText(r)}`).join("\n");
548
550
  const out =
549
551
  `# Fleet run ledger\n\n${ROWS}\n\n` + list(d.rows) +
550
- `\n\n${DISPATCHED}\n\n` + list(d.dispatched.map((t) => dispatchedEntry(t, d.briefs[parseToken(t)?.name] ?? null))) +
552
+ `\n\n${DISPATCHED}\n\n` + list(d.dispatched.map((t) => {
553
+ const name = parseToken(t)?.name;
554
+ return dispatchedEntry(t, d.briefs[name] ?? null, d.queued[name] ?? null);
555
+ })) +
551
556
  `\n\n${FILED}\n\n` + list(d.filed) +
552
557
  `\n\n${RULED}\n\n` + list(d.ruled) +
553
558
  (d.drain === null ? "" : `\n\n${DRAIN}\n\n` + list([d.drain])) +
@@ -694,6 +699,9 @@ const WRITE_COMMANDS = new Set(["row", "token", "filed", "ruled", "dispatch", "s
694
699
  // `settle --from-report` takes the lock itself, once its checks have run —
695
700
  // see runSettleFromReport().
696
701
  const FROM_REPORT = cmd === "settle" && rest.includes("--from-report");
702
+ // A merge bot's dispatch reads the queue it records before the lock, for the
703
+ // same reason: a gh call held under the lock stalls every other writer.
704
+ const QUEUED = cmd === "dispatch" && rest.length === 1 && /^merge-bot(-[0-9]+)?$/.test(rest[0]) ? queueSnapshot() : null;
697
705
  if (WRITE_COMMANDS.has(cmd) && !FROM_REPORT) await acquireLock();
698
706
  // `rotate` never load()s: a ledger load() would refuse is still the previous
699
707
  // run's record, and it is archived as-is — and read line by line when it
@@ -1630,7 +1638,7 @@ function runRotate() {
1630
1638
  }
1631
1639
  // Read before anything moves: a ledger whose live tokens cannot be read is
1632
1640
  // refused with nothing moved, never archived with those tokens dropped.
1633
- let carry = { rows: [], dispatched: [], briefs: {}, retired: [], skipped: [] };
1641
+ let carry = { rows: [], dispatched: [], briefs: {}, queued: {}, retired: [], skipped: [] };
1634
1642
  if (prior === "ancestor" && exists) {
1635
1643
  let text;
1636
1644
  try {
@@ -1691,7 +1699,7 @@ function runRotate() {
1691
1699
  // Nothing to carry writes nothing, so the next write starts the skeleton
1692
1700
  // exactly as after any other verdict.
1693
1701
  if (carrying) {
1694
- save({ rows: carry.rows, dispatched: carry.dispatched, briefs: carry.briefs, filed: [], ruled: [], drain: null, retired: carry.retired });
1702
+ save({ ...carry, filed: [], ruled: [], drain: null });
1695
1703
  }
1696
1704
  if (archive !== null) console.error(` rotated ${file} -> ${archive}`);
1697
1705
  for (const { line, why } of carry.skipped) {
@@ -1751,10 +1759,11 @@ function carryOf(text) {
1751
1759
  return false;
1752
1760
  }
1753
1761
  });
1754
- // An entry's `brief=` travels with it: save() writes a brief only beside
1755
- // the token it writes, so a carried member's record still names the brief
1756
- // it was dispatched with and a dropped member's does not.
1757
- const { dispatched: allDispatched, briefs: allBriefs } = dispatchedOf(sectionOf(text, DISPATCHED));
1762
+ // An entry's `brief=` and `queued=` travel with it: save() writes them only
1763
+ // beside the token it writes, so a carried member's record still names the
1764
+ // brief it was dispatched with — and a carried merge bot the queue it is
1765
+ // judged against — and a dropped member's does not.
1766
+ const { dispatched: allDispatched, briefs: allBriefs, queued: allQueued } = dispatchedOf(sectionOf(text, DISPATCHED));
1758
1767
  const dispatched = allDispatched.filter((e) => {
1759
1768
  const t = parseToken(e);
1760
1769
  if (t !== null && t.error === null) return true;
@@ -1777,7 +1786,7 @@ function carryOf(text) {
1777
1786
  const live = liveOnLedger(rows, dispatched);
1778
1787
  const carried = { rows: live.rows.map((i) => rows[i]), dispatched: live.dispatched };
1779
1788
  const retired = retiredOnLedger([...rows, ...skippedRows], allDispatched, archivedRetired, carried);
1780
- return { ...carried, briefs: allBriefs, retired, skipped };
1789
+ return { ...carried, briefs: allBriefs, queued: allQueued, retired, skipped };
1781
1790
  }
1782
1791
 
1783
1792
  // ── dispatch / settle ────────────────────────────────────────────────────────
@@ -1884,6 +1893,37 @@ function dispositionsRefusal(pr, review) {
1884
1893
  return null;
1885
1894
  }
1886
1895
 
1896
+ // The open `ready-to-merge` PRs, ascending, as gh answers from the ledger's
1897
+ // own directory with runCheck()'s environment: the queue a merge bot is
1898
+ // dispatched against, recorded on its `## Dispatched` entry. Anything but a
1899
+ // JSON array of PR numbers, or an answer filling the limit — gh stops there
1900
+ // at exit 0 — could not read the queue: refused, nothing written.
1901
+ function queueSnapshot() {
1902
+ const limit = 1000;
1903
+ const what = `gh pr list --state open --label ready-to-merge --limit ${limit} --json number`;
1904
+ const env = { ...process.env, GH_REPO: "" };
1905
+ delete env.GIT_DIR;
1906
+ delete env.GIT_WORK_TREE;
1907
+ let dir = dirname(resolve(file));
1908
+ while (!existsSync(dir) && dirname(dir) !== dir) dir = dirname(dir);
1909
+ const r = spawnSync("gh", what.split(" ").slice(1), { encoding: "utf8", cwd: dir, env, timeout: 60_000, stdio: ["ignore", "pipe", "pipe"] });
1910
+ const refuse = (why) => die(`dispatch merge-bot: could not read the queue to record — ${what}${why}; nothing written`);
1911
+ if (r.status !== 0) {
1912
+ const why = cause(r.error && r.error.message, r.stderr);
1913
+ refuse(`${r.error ? "" : r.signal ? ` killed by ${r.signal}` : ` exited ${r.status}`}${why ? `: ${why}` : ""}`);
1914
+ }
1915
+ let list = null;
1916
+ try {
1917
+ list = JSON.parse(r.stdout);
1918
+ } catch {
1919
+ // Refused below.
1920
+ }
1921
+ const prs = Array.isArray(list) ? list.map((p) => p?.number) : null;
1922
+ if (prs === null || !prs.every((n) => Number.isSafeInteger(n) && n > 0)) refuse(` printed no list of PR numbers: ${cause(r.stdout)}`);
1923
+ if (prs.length >= limit) refuse(` answered ${prs.length} PRs, the limit, so the queue may be longer`);
1924
+ return [...new Set(prs)].sort((a, b) => a - b);
1925
+ }
1926
+
1887
1927
  function runDispatch() {
1888
1928
  const usage = "usage: ledger.mjs dispatch <ticket|pr> <member>, or dispatch merge-bot";
1889
1929
  if (rest.length === 0 || rest.length > 2) die(usage);
@@ -2055,10 +2095,14 @@ function runDispatch() {
2055
2095
  }
2056
2096
  data.dispatched.push(member.name);
2057
2097
  data.briefs[member.name] = brief.sha256.slice(0, 8);
2098
+ // A merge bot's queue is recorded here, beside its brief, where nothing in
2099
+ // its report can reach: `settle --from-report` judges the report against it.
2100
+ if (QUEUED !== null) data.queued[member.name] = QUEUED;
2058
2101
  save(data);
2059
2102
  console.error(` dispatched ${member.name}`);
2060
2103
  console.log(JSON.stringify({
2061
2104
  member: member.name, agent, ticket: line === null ? null : rowKey(line), line, created, total: data.dispatched.length, brief,
2105
+ queued: QUEUED,
2062
2106
  }));
2063
2107
  }
2064
2108
 
@@ -2292,7 +2336,11 @@ function recordFiled(id, subject) {
2292
2336
  // the report's labelsReadBack
2293
2337
  // finisher-pr-M=halted:<c> cause is in the halted:<cause> vocabulary and
2294
2338
  // evidence is not empty
2295
- // merge-bot-n=done every merged[].pr reads MERGED
2339
+ // merge-bot-n=done every merged[].pr reads MERGED; every PR the
2340
+ // bot's `## Dispatched` entry records as queued=
2341
+ // at dispatch has a verdict in merged, held,
2342
+ // conflictHold or refused, or no longer reads
2343
+ // OPEN with ready-to-merge
2296
2344
  //
2297
2345
  // THE CHECK-STEP RECORD, for `impl-N=PR#M` and `fix-pr-M=applied:<head>`:
2298
2346
  // recipe-check.mjs writes `<reports>/<member>.check.json` on the member's
@@ -2312,15 +2360,21 @@ function recordFiled(id, subject) {
2312
2360
  // stderr names each failed check; nothing written. Exit 2: the report is
2313
2361
  // absent, unreadable or invalid, a check could not look (a `gh`, `git` or
2314
2362
  // verify-sha.sh call that failed, printed what it was not asked for, or never
2315
- // finished), or a refusal the bare form makes; nothing written either.
2363
+ // finished), a merge bot's `## Dispatched` entry records no queued=, or a
2364
+ // refusal the bare form makes; nothing written either.
2316
2365
  //
2317
2366
  // After the settle, in the same write: every `unrecorded` entry goes to `##
2318
- // Filed` the way `filed` records one, and for a fix-applier every `deferred`
2367
+ // Filed` the way `filed` records one, for a fix-applier every `deferred`
2319
2368
  // entry in the `refuted` bucket goes to `## Ruled` as `#M reversed refutation
2320
- // <index>: <reason>`. A line already there is not written again, so a second
2321
- // run of the same settle writes nothing. The receipt on stdout is `{member,
2322
- // outcome, checks: [{name, ok}], filed, ruled}`, `filed` and `ruled` the
2323
- // lines this call added.
2369
+ // <index>: <reason>`, and for a merge bot every `refused` entry goes to `##
2370
+ // Ruled` as `merge-bot-n refused #<pr>: <reason>`, the reason trimmed — a
2371
+ // record, which moves no label and writes no row token. It is led by the bot,
2372
+ // never by `#<pr>`: compute-board.mjs reads a `## Ruled` line led by `#<n>` as
2373
+ // the controller's ruling on PR n, the last such line winning, so a refusal
2374
+ // led by its PR would replace that ruling on the board. A line already there
2375
+ // is not written again, so a second run of the same settle writes nothing.
2376
+ // The receipt on stdout is `{member, outcome, checks: [{name, ok}], filed,
2377
+ // ruled}`, `filed` and `ruled` the lines this call added.
2324
2378
  //
2325
2379
  // The checks run before the write lock is taken, against the ledger as read
2326
2380
  // without it, because they are network calls and every other writer waits
@@ -2558,6 +2612,22 @@ async function runSettleFromReport() {
2558
2612
  } else {
2559
2613
  outcome = "done";
2560
2614
  for (const { pr } of report.merged) check(`PR #${pr} reads MERGED`, prView(pr, "state").state === "MERGED");
2615
+ // Every PR queued when the bot was dispatched has a verdict, unless it has
2616
+ // left the queue since — merged by someone else, closed or unlabelled. The
2617
+ // queue is the one `dispatch` recorded, never one the report names, so a
2618
+ // PR the report leaves out cannot shrink it; a PR labelled after dispatch
2619
+ // is not in it.
2620
+ const queued = data.queued[member];
2621
+ if (queued === undefined) {
2622
+ die(`${where}: ## Dispatched records no queued= for ${member}, so whether its report gives every PR queued at dispatch a verdict cannot be judged — settle it by hand; nothing written`);
2623
+ }
2624
+ const given = new Set(mergeBotVerdicts(report).map((v) => v.pr));
2625
+ for (const pr of queued.filter((n) => !given.has(n))) {
2626
+ const v = prView(pr, "state,labels");
2627
+ const left = v.state !== "OPEN" || !v.labels.some((l) => l?.name === ready);
2628
+ check(`PR #${pr}, queued at dispatch, has a verdict in merged, held, conflictHold or refused, or has left the queue`, left);
2629
+ }
2630
+ for (const { pr, reason } of report.refused) ruledLines.push(`${member} refused #${pr}: ${reason.trim()}`);
2561
2631
  }
2562
2632
 
2563
2633
  const failed = checks.filter((c) => !c.ok);
@@ -6,8 +6,9 @@
6
6
  // (flat: no per-run directory), `<member>` being its ledger name (`impl-412`,
7
7
  // `impl-412-b`, `fix-pr-88`, `finisher-pr-88`, `merge-bot-3`). The file is
8
8
  // checked against the schema of the member's family, report-schema.mjs's
9
- // SCHEMAS. The review runner and every member outside those four families
10
- // have no schema, so no file to look up.
9
+ // SCHEMAS, and the rule reportViolation() adds to it. The review runner and
10
+ // every member outside those four families have no schema, so no file to
11
+ // look up.
11
12
  //
12
13
  // The Check-step record beside a report, `<reports>/<member>.check.json`, is
13
14
  // looked up here the same way: recipe-check.mjs writes it, and this module
@@ -18,7 +19,7 @@
18
19
 
19
20
  import { readdirSync, readFileSync } from "node:fs";
20
21
  import { join } from "node:path";
21
- import { SCHEMAS, parseReport, validate } from "./report-schema.mjs";
22
+ import { parseReport, reportViolation } from "./report-schema.mjs";
22
23
  import { parseMember } from "./ledger-grammar.mjs";
23
24
  import { scratchRoot } from "./fleet-dir.mjs";
24
25
 
@@ -113,7 +114,7 @@ export function readReportIn(dir, member) {
113
114
  const read = readAt(path);
114
115
  if (read.text === undefined) return read;
115
116
  const parsed = parseReport(read.text);
116
- const violation = parsed.violation ?? validate(SCHEMAS[family], parsed.value);
117
+ const violation = parsed.violation ?? reportViolation(family, parsed.value);
117
118
  return violation === null ? { state: "ok", path, report: parsed.value } : { state: "invalid", path, violation };
118
119
  }
119
120
 
@@ -18,7 +18,9 @@
18
18
  // 0 print: the schema is on stdout. validate: `valid` on stdout
19
19
  // 1 validate: `invalid: <violation>` on stdout, naming the FIRST violation;
20
20
  // a file that is not JSON is invalid too, as `$: not JSON (...)`, and so
21
- // is one that repeats a key, as `$.merged: duplicate key "merged"`
21
+ // is one that repeats a key, as `$.merged: duplicate key "merged"`, and
22
+ // a merge-bot report giving one PR two verdicts, as
23
+ // `$.refused[0].pr: PR #<pr> already has a verdict at $.merged[0].pr`
22
24
  // 2 usage: no or unknown subcommand, a wrong argument count, an unknown
23
25
  // family, any flag; or <file> could not be read; or print could not
24
26
  // write the schema to stdout. Refused on stderr, nothing on stdout
@@ -153,11 +155,41 @@ export const SCHEMAS = {
153
155
  merged: listOf({ pr: id, sha }),
154
156
  held: listOf({ pr: id, behind: id }),
155
157
  conflictHold: { type: "array", items: id },
156
- refused: listOf({ pr: id, reason: str }),
158
+ // A refusal is a verdict only with a reason: the reason is the record
159
+ // `## Ruled` gets, so one with no text but whitespace is invalid.
160
+ refused: listOf({ pr: id, reason: { type: "string", pattern: "\\S" } }),
157
161
  branchKept: listOf({ pr: id, by: id }),
158
162
  }),
159
163
  };
160
164
 
165
+ // Every verdict a merge-bot report gives, as `{pr, path}`, `path` the entry's
166
+ // PR field: `merged`, `held`, `conflictHold`, then `refused`, each in report
167
+ // order. `branchKept` gives none: it names a merged PR's branch and the open
168
+ // PR keeping it.
169
+ export function mergeBotVerdicts(report) {
170
+ return [
171
+ ...["merged", "held"].flatMap((k) => report[k].map((e, i) => ({ pr: e.pr, path: `$.${k}[${i}].pr` }))),
172
+ ...report.conflictHold.map((pr, i) => ({ pr, path: `$.conflictHold[${i}]` })),
173
+ ...report.refused.map((e, i) => ({ pr: e.pr, path: `$.refused[${i}].pr` })),
174
+ ];
175
+ }
176
+
177
+ // The first violation of `family`'s schema by `value`, or null; for a
178
+ // merge-bot report then the rule no schema keyword states: each PR gets one
179
+ // verdict — merged, held, conflict-held or refused — the last it reached in
180
+ // the pass, so a PR named twice, in one list or in two, is a violation at
181
+ // its second entry.
182
+ export function reportViolation(family, value) {
183
+ const found = validate(SCHEMAS[family], value);
184
+ if (found !== null || family !== "merge-bot") return found;
185
+ const first = new Map();
186
+ for (const { pr, path } of mergeBotVerdicts(value)) {
187
+ if (first.has(pr)) return `${path}: PR #${pr} already has a verdict at ${first.get(pr)}`;
188
+ first.set(pr, path);
189
+ }
190
+ return null;
191
+ }
192
+
161
193
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
162
194
 
163
195
  function typeOf(v) {
@@ -279,7 +311,7 @@ function main() {
279
311
  }
280
312
  if (cmd !== "validate" || path === undefined) die(USAGE);
281
313
 
282
- const schema = familyOf(family);
314
+ familyOf(family);
283
315
  let text;
284
316
  try {
285
317
  text = readFileSync(path, "utf8");
@@ -287,7 +319,7 @@ function main() {
287
319
  die(`cannot read ${path}: ${e.code ?? e.message}`);
288
320
  }
289
321
  const parsed = parseReport(text);
290
- const violation = parsed.violation ?? validate(schema, parsed.value);
322
+ const violation = parsed.violation ?? reportViolation(family, parsed.value);
291
323
  if (violation !== null) {
292
324
  writeAll(1, `invalid: ${violation}\n`);
293
325
  process.exit(1);
@@ -76,11 +76,13 @@ One line per ticket, rewritten in place (`ledger.mjs row <ticket> <text>`; `ledg
76
76
 
77
77
  A member that dies between spawn and write is invisible — and members die in batches.
78
78
 
79
+ `dispatch merge-bot` also records the queue the bot is dispatched against: its `## Dispatched` entry carries `queued=<pr>,<pr>…` (`queued=none` for an empty queue), the open `ready-to-merge` PRs gh lists at that moment. A gh that cannot answer refuses the dispatch, nothing written. `settle merge-bot-<n> --from-report` judges the bot's report against that queue, not against anything the report names: a PR from it that is still open and carries the label, with no verdict in `merged`, `held`, `conflictHold` or `refused`, fails the settle at exit 3, naming the PR, and an entry without `queued=` cannot be judged (exit 2) — settle that bot by hand.
80
+
79
81
  ## filed and ruled
80
82
 
81
83
  `filed` is checked with `ledger.mjs check <subject>` before every `gh issue create`, so a finding already recorded this run is not filed twice. That is not a guarantee against duplicates at large; the tracker query, described under `check`'s exits and its `verdict` above, is what covers those.
82
84
 
83
- `ruled` records PR + decision + one-line reason, so a replacement controller does not re-litigate a settled call.
85
+ `ruled` records PR + decision + one-line reason, so a replacement controller does not re-litigate a settled call. A merge bot's settle adds one line per `refused[]` entry of its report, `merge-bot-<n> refused #<pr>: <reason>`: a record of the refusal and nothing more — no label moves and no row token is written. It is led by the bot, not by `#<pr>`, because the board reads a `#<pr>`-led line as your ruling on that PR, the last one winning, and a refusal must not replace it.
84
86
 
85
87
  ## Label-off and conflict holds
86
88