@feigi/fleet-ctl 3.100.3 → 3.102.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.
package/scripts/pull.mjs CHANGED
@@ -137,7 +137,7 @@ import { gitEnv } from "./git-env.mjs";
137
137
  import { isCLI } from "./is-cli.mjs";
138
138
  import { EXCLUDED_REASON, memberTokens, premisesOf, pullEnded, tierValues } from "./ledger-grammar.mjs";
139
139
  import { sessionFromReceipt } from "./phase0-receipt.mjs";
140
- import { briefPaths, briefText, outOfScope, strandedRuling } from "./ticket-router.mjs";
140
+ import { briefPaths, briefText, defang, outOfScope, strandedRuling, ticketText } from "./ticket-text.mjs";
141
141
 
142
142
  const NAME = "pull";
143
143
  const SCRIPTS = dirname(fileURLToPath(import.meta.url));
@@ -399,11 +399,6 @@ function declaredPlaceholders() {
399
399
  return names;
400
400
  }
401
401
 
402
- /** `text` with each declared placeholder's spelling `<name>` written `‹name›`, which the renderer does not read. */
403
- export function defang(text, names) {
404
- return names.reduce((t, n) => t.split(`<${n}>`).join(`‹${n}›`), text);
405
- }
406
-
407
402
  // ── children ─────────────────────────────────────────────────────────────────
408
403
 
409
404
  const trimmed = (s) => String(s ?? "").trim();
@@ -686,15 +681,6 @@ class Pull {
686
681
  ];
687
682
  }
688
683
 
689
- // What the dispatched member is told the ticket is: its title, its brief,
690
- // and the Out of scope section when that sits outside the brief.
691
- dispatchBrief() {
692
- const issue = this.issue();
693
- const text = briefText(issue).trim();
694
- const outside = outOfScope(text) === null ? outOfScope(String(issue.body ?? "")) : null;
695
- return [`Title: ${issue.title}`, text, ...(outside === null ? [] : [outside])].join("\n\n");
696
- }
697
-
698
684
  // ── the question standing, printed in full: what a Pull picked up again re-reads ──
699
685
  describe() {
700
686
  const { qid } = this.st.pending;
@@ -998,7 +984,7 @@ class Pull {
998
984
  const r = this.script("render", "dispatch-prompt.mjs", [
999
985
  "render", "implementer", "--member", st.member, "--ticket", String(st.ticket), "--worktree", worktree,
1000
986
  "--branch", `implementer/${st.ticket}-${st.member}`, "--scratch", this.p.scratch,
1001
- "--brief", defang(this.dispatchBrief(), names), "--staleness", defang(staleness, names),
987
+ "--brief", defang(ticketText(this.issue()), names), "--staleness", defang(staleness, names),
1002
988
  ]);
1003
989
  if (r.status !== 0) return this.stop(withText(`dispatch-prompt.mjs render refused (${r.how}) — the claim stands`, r));
1004
990
  st.stage = "dispatch";
@@ -55,6 +55,7 @@ import {
55
55
  AttributionError, FEATURE_COLUMNS, TRIP_POINTS, attribute, bootUnit, bootstrapDiffCI, generations, parseFeatures, tally, underFloor,
56
56
  } from "./pull-attribution.mjs";
57
57
  import { RulingError, parseTierOutcomes } from "./tier-outcomes.mjs";
58
+ import { briefPaths, briefText } from "./ticket-text.mjs";
58
59
 
59
60
  const NAME = "ticket-router";
60
61
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
@@ -80,76 +81,6 @@ const DAY_MS = 86_400_000;
80
81
  // metrics — every one free, deterministic and known before dispatch
81
82
  // ---------------------------------------------------------------------------
82
83
 
83
- /** An `## Agent Brief` heading line: what marks the comment the brief is taken from. */
84
- const AGENT_BRIEF = /^## Agent Brief/m;
85
-
86
- /** The brief: the last comment carrying an `## Agent Brief` heading line, else the body. */
87
- export function briefText(issue) {
88
- const comments = Array.isArray(issue.comments) ? issue.comments : [];
89
- for (let i = comments.length - 1; i >= 0; i--) {
90
- const body = String(comments[i]?.body ?? "");
91
- if (AGENT_BRIEF.test(body)) return body;
92
- }
93
- return String(issue.body ?? "");
94
- }
95
-
96
- /** A ruling or triage heading line: what a decision posted outside an `## Agent Brief` comment is headed with. */
97
- const RULING_HEADING = /^## (?:Ruling|Triage).*$/m;
98
-
99
- /**
100
- * The newest comment with a ruling or triage heading line and no `## Agent
101
- * Brief` heading line that comes after the last comment `briefText` takes the
102
- * brief from, or after none when there is no such comment, as its first ruling
103
- * or triage heading line and its `url`; null when none is.
104
- */
105
- export function strandedRuling(issue) {
106
- const comments = Array.isArray(issue.comments) ? issue.comments : [];
107
- for (let i = comments.length - 1; i >= 0; i--) {
108
- const body = String(comments[i]?.body ?? "");
109
- if (AGENT_BRIEF.test(body)) return null;
110
- const heading = RULING_HEADING.exec(body);
111
- if (heading !== null) return { heading: heading[0], url: comments[i].url };
112
- }
113
- return null;
114
- }
115
-
116
- /** The line span `[at, end)` of `lines`' `Out of scope` section, heading to the next heading of its level or above; null when there is none. */
117
- function outOfScopeSpan(lines) {
118
- const at = lines.findIndex((l) => /^#{1,6}\s*out of scope\b/i.test(l));
119
- if (at === -1) return null;
120
- const level = /^#+/.exec(lines[at])[0].length;
121
- for (let i = at + 1; i < lines.length; i++) {
122
- const h = /^(#{1,6})\s/.exec(lines[i]);
123
- if (h !== null && h[1].length <= level) return { at, end: i };
124
- }
125
- return { at, end: lines.length };
126
- }
127
-
128
- /** The `Out of scope` section of `text`, heading to the next heading of its level or above; null when there is none. */
129
- export function outOfScope(text) {
130
- const all = text.split("\n");
131
- const span = outOfScopeSpan(all);
132
- return span === null ? null : all.slice(span.at, span.end).join("\n").trim();
133
- }
134
-
135
- /**
136
- * The file paths a brief names: every backticked token without whitespace that
137
- * holds a `/` or ends in an extension, in first-seen order, each once. The
138
- * brief's `Out of scope` section names no target, so its tokens are skipped.
139
- */
140
- export function briefPaths(brief) {
141
- const all = brief.split("\n");
142
- const span = outOfScopeSpan(all);
143
- const kept = span === null ? brief : [...all.slice(0, span.at), ...all.slice(span.end)].join("\n");
144
- const paths = new Set();
145
- for (const m of kept.matchAll(/`([^`\n]+)`/g)) {
146
- const tok = m[1];
147
- if (/\s/.test(tok)) continue;
148
- if (tok.includes("/") || /\.[A-Za-z][A-Za-z0-9]*$/.test(tok)) paths.add(tok);
149
- }
150
- return [...paths];
151
- }
152
-
153
84
  /** The metric columns for a parsed `gh issue view --json title,body,comments,labels,createdAt`, or null when it is not one. */
154
85
  export function issueMetrics(issue, now = Date.now()) {
155
86
  if (issue === null || typeof issue !== "object" || Array.isArray(issue) || typeof issue.body !== "string") return null;
@@ -0,0 +1,94 @@
1
+ // A ticket's text as the fleet reads it off a saved `gh issue view --json
2
+ // title,body,comments,…`: its brief, its `Out of scope` section, the paths it
3
+ // names, a ruling posted outside its brief, and the text a member is
4
+ // dispatched with. A module of its own, importing nothing, because
5
+ // ticket-router.mjs, pull.mjs and dispatch-prompt.mjs each read a ticket this
6
+ // way, and the renderer has no use for the router's fit or anything it
7
+ // imports.
8
+
9
+ /** An `## Agent Brief` heading line: what marks the comment the brief is taken from. */
10
+ const AGENT_BRIEF = /^## Agent Brief/m;
11
+
12
+ /** The brief: the last comment carrying an `## Agent Brief` heading line, else the body. */
13
+ export function briefText(issue) {
14
+ const comments = Array.isArray(issue.comments) ? issue.comments : [];
15
+ for (let i = comments.length - 1; i >= 0; i--) {
16
+ const body = String(comments[i]?.body ?? "");
17
+ if (AGENT_BRIEF.test(body)) return body;
18
+ }
19
+ return String(issue.body ?? "");
20
+ }
21
+
22
+ /** A ruling or triage heading line: what a decision posted outside an `## Agent Brief` comment is headed with. */
23
+ const RULING_HEADING = /^## (?:Ruling|Triage).*$/m;
24
+
25
+ /**
26
+ * The newest comment with a ruling or triage heading line and no `## Agent
27
+ * Brief` heading line that comes after the last comment `briefText` takes the
28
+ * brief from, or after none when there is no such comment, as its first ruling
29
+ * or triage heading line and its `url`; null when none is.
30
+ */
31
+ export function strandedRuling(issue) {
32
+ const comments = Array.isArray(issue.comments) ? issue.comments : [];
33
+ for (let i = comments.length - 1; i >= 0; i--) {
34
+ const body = String(comments[i]?.body ?? "");
35
+ if (AGENT_BRIEF.test(body)) return null;
36
+ const heading = RULING_HEADING.exec(body);
37
+ if (heading !== null) return { heading: heading[0], url: comments[i].url };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ /** The line span `[at, end)` of `lines`' `Out of scope` section, heading to the next heading of its level or above; null when there is none. */
43
+ function outOfScopeSpan(lines) {
44
+ const at = lines.findIndex((l) => /^#{1,6}\s*out of scope\b/i.test(l));
45
+ if (at === -1) return null;
46
+ const level = /^#+/.exec(lines[at])[0].length;
47
+ for (let i = at + 1; i < lines.length; i++) {
48
+ const h = /^(#{1,6})\s/.exec(lines[i]);
49
+ if (h !== null && h[1].length <= level) return { at, end: i };
50
+ }
51
+ return { at, end: lines.length };
52
+ }
53
+
54
+ /** The `Out of scope` section of `text`, heading to the next heading of its level or above; null when there is none. */
55
+ export function outOfScope(text) {
56
+ const all = text.split("\n");
57
+ const span = outOfScopeSpan(all);
58
+ return span === null ? null : all.slice(span.at, span.end).join("\n").trim();
59
+ }
60
+
61
+ /**
62
+ * The ticket as a member is told it: its title, its brief (briefText), and the
63
+ * body's `Out of scope` section when the brief carries none. An implementer's
64
+ * dispatch carries it, and a review fix-applier's brief carries the same text
65
+ * for the PR that implementer opened.
66
+ */
67
+ export function ticketText(issue) {
68
+ const text = briefText(issue).trim();
69
+ const outside = outOfScope(text) === null ? outOfScope(String(issue.body ?? "")) : null;
70
+ return [`Title: ${issue.title}`, text, ...(outside === null ? [] : [outside])].join("\n\n");
71
+ }
72
+
73
+ /** `text` with each declared placeholder's spelling `<name>` written `‹name›`, which dispatch-prompt.mjs does not read. */
74
+ export function defang(text, names) {
75
+ return names.reduce((t, n) => t.split(`<${n}>`).join(`‹${n}›`), text);
76
+ }
77
+
78
+ /**
79
+ * The file paths a brief names: every backticked token without whitespace that
80
+ * holds a `/` or ends in an extension, in first-seen order, each once. The
81
+ * brief's `Out of scope` section names no target, so its tokens are skipped.
82
+ */
83
+ export function briefPaths(brief) {
84
+ const all = brief.split("\n");
85
+ const span = outOfScopeSpan(all);
86
+ const kept = span === null ? brief : [...all.slice(0, span.at), ...all.slice(span.end)].join("\n");
87
+ const paths = new Set();
88
+ for (const m of kept.matchAll(/`([^`\n]+)`/g)) {
89
+ const tok = m[1];
90
+ if (/\s/.test(tok)) continue;
91
+ if (tok.includes("/") || /\.[A-Za-z][A-Za-z0-9]*$/.test(tok)) paths.add(tok);
92
+ }
93
+ return [...paths];
94
+ }
@@ -5,15 +5,18 @@
5
5
  // `opus` for implementers dispatched as a generic `task` that ran
6
6
  // sonnet/high, which is neither tier arm.
7
7
  //
8
- // append <pr> --closed-own-ticket yes|no --minted-false-claim yes|no
9
- // --note <text> [--sizing light|heavy] [--profile <p>] [--loc <n>] [--files <n>]
10
- // [--scratch <dir>]
8
+ // append <pr> [--note <text>] [--sizing light|heavy] [--profile <p>] [--loc <n>]
9
+ // [--files <n>] [--scratch <dir>]
10
+ // append <pr> --closed-own-ticket yes|no --minted-false-claim yes|no --note <text>
11
+ // [--sizing light|heavy] [--profile <p>] [--loc <n>] [--files <n>] [--scratch <dir>]
11
12
  // Fills `run_date` (today's UTC date, the ruling date), `pr`, `ticket` (the
12
13
  // PR's one `gh pr view --json closingIssuesReferences,url` issue in its own
13
14
  // repository; when the PR closes zero or several there, the N of its one
14
- // settled `impl-<N>=PR#<pr>` token in the run ledger), `tier` and, unless
15
+ // settled `impl-<N>=PR#<pr>` token in the run ledger), `tier`, the two
16
+ // verdict columns and the `note` (see "The verdicts" below) and, unless
15
17
  // `--sizing` names it, `sizing` (see below). Idempotent by
16
18
  // PR: a row already present is named and nothing is written, exit 0.
19
+ // Every refusal writes no row and exits 2.
17
20
  // check [--live]
18
21
  // Post-switch rows only: a filled `tier` must equal the short name of the
19
22
  // ticket's single implementer row in member-outcomes.tsv, and that row
@@ -71,16 +74,50 @@
71
74
  // is appended. Readers pick a ticket's ruling by file order, not by `run_date`.
72
75
  //
73
76
  // Exit codes: 0 ok, 1 `check` found a mismatch or checked nothing from the latest
74
- // run_date (see `check`), 2 usage or unreadable input.
77
+ // run_date (see `check`), 2 usage, unreadable input or a refused `append`.
78
+
79
+ // The verdicts. With no verdict flag, `closed_own_ticket` and
80
+ // `minted_false_claim` are worked out from the Charges the fix-applier
81
+ // recorded on the standing findings of the PR's first review, and the `note`
82
+ // is composed from them, so the row explains itself once the evidence is
83
+ // reclaimed:
84
+ // - the review is ledger-grammar.mjs's firstReview() over the PR's ledger
85
+ // tokens: the first `reviewed=` that either counted no survived and no
86
+ // unverified finding or that a fix-applier answered. Its `review.json` is
87
+ // `<scratch>/pr<pr>/<run>/review.json`, and its head and its survived and
88
+ // unverified counts are the ledger token's, or it refuses;
89
+ // - the record is the last answering that review's run:
90
+ // `<scratch>/dispositions-fix-pr-<pr>[-x].json` with the highest retry
91
+ // letter whose `run` is the review's run. It is read whatever
92
+ // dispositions-check.mjs concluded about its remedies;
93
+ // - a finding stands as dispositions-check.mjs's `stands` says. `unmet` or
94
+ // `both` on one fails `closed_own_ticket`, `false-claim` or `both` makes
95
+ // `minted_false_claim` yes. A review that counted no survived and no
96
+ // unverified finding is a pass and needs no record;
97
+ // - a standing finding with no Charge, or a review, `review.json` or record
98
+ // that is missing, refuses. So does a record entry that names no finding
99
+ // of the review, or a second entry for a finding an earlier entry
100
+ // answers. A dimension that never ran does not: the note names it.
101
+ // The note reads `review <run> at <head7>, record <file>: <bucket>[<index>]
102
+ // <charge> "<claim>"; …; none: <bucket>[<index>] <reason>, …; cleared:
103
+ // survived[<index>] false-rationale, …; unrun: <dimension>, …`, and ` — <--note>`
104
+ // when `--note` is given. Tabs and newlines in a claim become spaces.
105
+ //
106
+ // Verdicts given by hand — `--closed-own-ticket` and `--minted-false-claim`
107
+ // together, with `--note` naming the evidence — are written as given for a PR
108
+ // GitHub reports MERGED, and refused for any other. A merged PR given no
109
+ // verdict flags is derived when its evidence is still in scratch, and
110
+ // otherwise refused with the flags to give.
75
111
 
76
- import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
112
+ import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
77
113
  import { execFileSync } from "node:child_process";
78
114
  import { dirname, join } from "node:path";
79
115
  import { fileURLToPath } from "node:url";
80
116
  import { isCLI } from "./is-cli.mjs";
81
117
  import { makeDie, defineFlags, isDigits } from "./arg.mjs";
82
118
  import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
83
- import { parseMember, parseToken, memberTokens, rowPr } from "./ledger-grammar.mjs";
119
+ import { parseMember, parseToken, memberTokens, rowPr, prTokens, firstReview } from "./ledger-grammar.mjs";
120
+ import { CHARGES, CHARGE_REASONS, reviewProblem, sameHead, stands } from "./dispositions-check.mjs";
84
121
  import { rowText } from "./tier-check.mjs";
85
122
  import { latestImplReport, readReport, reportsDir, defaultScratch } from "./report-files.mjs";
86
123
  import { FleetDirUnresolvable } from "./fleet-dir.mjs";
@@ -382,8 +419,9 @@ export function unrecordedReviewedPrs(ledger, rows) {
382
419
  // CLI
383
420
  // ---------------------------------------------------------------------------
384
421
 
385
- const USAGE = "usage: tier-outcomes.mjs append <pr> --closed-own-ticket yes|no --minted-false-claim yes|no --note <text> "
422
+ const USAGE = "usage: tier-outcomes.mjs append <pr> [--note <text>] "
386
423
  + "[--sizing light|heavy] [--profile <p>] [--loc <n>] [--files <n>] [--scratch <dir>] [--file <tsv>] [--member-outcomes <tsv>] [--ledger <path>]\n"
424
+ + " tier-outcomes.mjs append <pr> --closed-own-ticket yes|no --minted-false-claim yes|no --note <text> (a merged PR only) [the same options]\n"
387
425
  + " tier-outcomes.mjs check [--live] [--file <tsv>] [--member-outcomes <tsv>] [--ledger <path>]";
388
426
 
389
427
  const die = makeDie(NAME);
@@ -456,29 +494,33 @@ function readLedger(ledgerFile, requireFile) {
456
494
  }
457
495
  }
458
496
 
459
- // The unique issue numbers of a PR's closingIssuesReferences in its own
460
- // repository, by ownClosingIssues()'s rule, in GitHub's order — another
461
- // repository's reference is none of them — or an Error naming why they could
462
- // not be read, a reference that rule cannot read among the reasons. GH_REPO is
463
- // blanked so an ambient one naming another repository never decides which PR
464
- // is read; the timeout keeps a hung `gh` from stalling `append` or a
465
- // close-out's `check --live`.
497
+ // `gh pr view <pr> --json <fields>` parsed, or an Error naming why it could
498
+ // not be read. GH_REPO is blanked so an ambient one naming another repository
499
+ // never decides which PR is read; the timeout keeps a hung `gh` from stalling
500
+ // `append` or a close-out's `check --live`.
466
501
  const GH_TIMEOUT_MS = 60_000;
467
- function closingIssueNumbers(pr) {
502
+ function ghPrView(pr, fields) {
468
503
  let raw;
469
504
  try {
470
- raw = execFileSync("gh", ["pr", "view", String(pr), "--json", "closingIssuesReferences,url"], {
505
+ raw = execFileSync("gh", ["pr", "view", String(pr), "--json", fields], {
471
506
  encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GH_REPO: "" }, timeout: GH_TIMEOUT_MS,
472
507
  });
473
508
  } catch (e) {
474
509
  throw new Error(`gh pr view ${pr} failed: ${e.stderr?.trim() || e.message}`);
475
510
  }
476
- let view;
477
511
  try {
478
- view = JSON.parse(raw);
512
+ return JSON.parse(raw);
479
513
  } catch (e) {
480
514
  throw new Error(`gh pr view ${pr} returned unreadable JSON: ${e.message}`);
481
515
  }
516
+ }
517
+
518
+ // The unique issue numbers of a PR's closingIssuesReferences in its own
519
+ // repository, by ownClosingIssues()'s rule, in GitHub's order — another
520
+ // repository's reference is none of them — or an Error naming why they could
521
+ // not be read, a reference that rule cannot read among the reasons.
522
+ function closingIssueNumbers(pr) {
523
+ const view = ghPrView(pr, "closingIssuesReferences,url");
482
524
  if (!Array.isArray(view?.closingIssuesReferences)) throw new Error(`gh pr view ${pr} returned no closingIssuesReferences array`);
483
525
  try {
484
526
  return ownClosingIssues(pr, view.url, view.closingIssuesReferences);
@@ -538,6 +580,202 @@ function today() {
538
580
  return new Date().toISOString().slice(0, 10);
539
581
  }
540
582
 
583
+ // ---------------------------------------------------------------------------
584
+ // the Ruling worked out from the Charges
585
+ // ---------------------------------------------------------------------------
586
+
587
+ const oneLine = (s) => String(s ?? "").replace(/[\t\r\n]/g, " ");
588
+
589
+ // `path` parsed as JSON: `{value}`, or `{problem}` naming the failure — with
590
+ // `code` the error code when the file could not be read at all, and no `code`
591
+ // when it was read and is not valid JSON.
592
+ function readJson(path) {
593
+ let text;
594
+ try {
595
+ text = readFileSync(path, "utf8");
596
+ } catch (e) {
597
+ return { code: e.code, problem: `${path} cannot be read: ${e.code ?? e.message}` };
598
+ }
599
+ try {
600
+ return { value: JSON.parse(text) };
601
+ } catch (e) {
602
+ return { problem: `${path} is not valid JSON: ${e.message}` };
603
+ }
604
+ }
605
+
606
+ // The Charge an entry carries, `{charge, reason}`, or null when it carries
607
+ // none a Ruling can read: a value outside CHARGES, or `none` with no reason
608
+ // from CHARGE_REASONS.
609
+ function chargeOf(e) {
610
+ if (!CHARGES.includes(e.charge)) return null;
611
+ if (e.charge !== "none") return { charge: e.charge, reason: null };
612
+ return CHARGE_REASONS.includes(e.chargeReason) ? { charge: "none", reason: e.chargeReason } : null;
613
+ }
614
+
615
+ // The record file names under `scratch` that answer PR `pr`, as `{file,
616
+ // letter}`, highest retry letter first — the first attempt, which carries no
617
+ // letter, last. Empty when the directory is not there.
618
+ function recordFiles(scratch, pr) {
619
+ let names;
620
+ try {
621
+ names = readdirSync(scratch);
622
+ } catch (e) {
623
+ if (e.code === "ENOENT") return [];
624
+ throw new Error(`cannot list ${scratch}: ${e.code ?? e.message}`);
625
+ }
626
+ const answering = new RegExp(`^dispositions-fix-pr-${pr}(?:-([a-z]))?\\.json$`);
627
+ return names
628
+ .flatMap((file) => {
629
+ const m = answering.exec(file);
630
+ return m === null ? [] : [{ file, letter: m[1] ?? "" }];
631
+ })
632
+ .sort((a, b) => (a.letter < b.letter ? 1 : a.letter > b.letter ? -1 : 0));
633
+ }
634
+
635
+ // The last record answering review run `run`: `{file, record}`, `{missing}`
636
+ // naming why there is none, a retry's record replacing the first attempt's on
637
+ // the same run and a record answering any other run changing nothing. A file
638
+ // met before the answering one that cannot be read, or is not valid JSON,
639
+ // names no run, so it could be the retry's: it refuses rather than letting an
640
+ // older record stand in.
641
+ function recordFor(scratch, pr, run) {
642
+ let files;
643
+ try {
644
+ files = recordFiles(scratch, pr);
645
+ } catch (e) {
646
+ return { missing: e.message };
647
+ }
648
+ for (const { file } of files) {
649
+ const path = join(scratch, file);
650
+ const read = readJson(path);
651
+ if (read.problem !== undefined) return { missing: read.problem };
652
+ if (read.value?.run === run) return { file, path, record: read.value };
653
+ }
654
+ return { missing: `no ${join(scratch, `dispositions-fix-pr-${pr}[-x].json`)} names review ${run} as the run it answers` };
655
+ }
656
+
657
+ // The Charges `record` carries on the findings of `review` that stand, as the
658
+ // pieces of the note and the findings with none. A survived finding no entry
659
+ // answers stands, with no Charge. `broken` names each entry the Ruling cannot
660
+ // read — not an object, a bucket the review has not got, an index naming no
661
+ // finding, or a second entry for a finding an earlier one answers — since
662
+ // skipping one could hide the Charge that decides a column.
663
+ function chargesOf(review, record) {
664
+ const entries = new Map();
665
+ const broken = [];
666
+ for (const [n, e] of (Array.isArray(record.entries) ? record.entries : []).entries()) {
667
+ if (e === null || typeof e !== "object" || Array.isArray(e)) {
668
+ broken.push(`entries[${n}] is not an object`);
669
+ continue;
670
+ }
671
+ if (!["survived", "refuted", "unverified"].includes(e.bucket)) {
672
+ broken.push(`entries[${n}] names bucket ${JSON.stringify(e.bucket ?? null)}, not survived, refuted or unverified`);
673
+ continue;
674
+ }
675
+ if (!Number.isInteger(e.index) || e.index < 0 || e.index >= review[e.bucket].length) {
676
+ broken.push(`entries[${n}] names ${e.bucket}[${JSON.stringify(e.index ?? null)}], and ${e.bucket} holds ${review[e.bucket].length}`);
677
+ continue;
678
+ }
679
+ const key = `${e.bucket}[${e.index}]`;
680
+ if (entries.has(key)) broken.push(`entries[${n}] is a second entry for ${key}, which entries[${entries.get(key).n}] already answers`);
681
+ else entries.set(key, { e, n });
682
+ }
683
+ const entryFor = (key) => entries.get(key)?.e;
684
+ const out = { charged: [], none: [], cleared: [], missing: [], broken };
685
+ const take = (bucket, index, e) => {
686
+ const key = `${bucket}[${index}]`;
687
+ const c = e === undefined ? null : chargeOf(e);
688
+ if (c === null) out.missing.push(`${key} "${oneLine(review[bucket][index].claim)}"`);
689
+ else if (c.charge === "none") out.none.push(`${key} ${c.reason}`);
690
+ else out.charged.push({ charge: c.charge, text: `${key} ${c.charge} "${oneLine(review[bucket][index].claim)}"` });
691
+ };
692
+ review.survived.forEach((_, index) => {
693
+ const e = entryFor(`survived[${index}]`);
694
+ if (e !== undefined && !stands("survived", e)) out.cleared.push(`survived[${index}] ${e.reason}`);
695
+ else take("survived", index, e);
696
+ });
697
+ review.refuted.forEach((_, index) => {
698
+ const e = entryFor(`refuted[${index}]`);
699
+ if (e !== undefined) take("refuted", index, e);
700
+ });
701
+ return out;
702
+ }
703
+
704
+ const FAILS_CLOSED = ["unmet", "both"];
705
+ const MINTS = ["false-claim", "both"];
706
+
707
+ // PR `pr`'s verdict columns and note off its first review's Charges:
708
+ // `{closed_own_ticket, minted_false_claim, note}`, or `{missing}` saying what
709
+ // is not there to read. `scratch` is `{root}` or `{unresolvable}`.
710
+ function derivedRuling(pr, ledger, scratch) {
711
+ const review = firstReview(prTokens(ledger?.rows ?? [], Number(pr)));
712
+ if (review === null) {
713
+ return { missing: `PR #${pr} has no first review on the ledger: no \`reviewed=\` token of its rows counted no survived and no unverified finding, or was answered by a fix-applier` };
714
+ }
715
+ if (scratch.unresolvable !== undefined) return { missing: `no scratch root to look in: ${scratch.unresolvable}` };
716
+ const reviewPath = join(scratch.root, `pr${pr}`, review.run, "review.json");
717
+ const read = readJson(reviewPath);
718
+ if (read.code === "ENOENT") return { missing: `review ${review.run}'s result file ${reviewPath} is missing` };
719
+ if (read.problem !== undefined) return { missing: read.problem };
720
+ const file = read.value;
721
+ const problem = reviewProblem(file);
722
+ if (problem !== null) return { missing: `${reviewPath} ${problem}` };
723
+ if (!sameHead(String(file.head).toLowerCase(), review.head)) {
724
+ return { missing: `${reviewPath} is the review of head ${file.head}, not the ledger's review ${review.run} at ${review.head}` };
725
+ }
726
+ if (file.survived.length !== review.survived || file.unverified.length !== review.unverified) {
727
+ return { missing: `${reviewPath} holds ${file.survived.length} survived and ${file.unverified.length} unverified, not the ${review.survived} and ${review.unverified} the ledger's review ${review.run} counted` };
728
+ }
729
+ // An entry that is neither a dimension's name nor {dimension} is named as
730
+ // the JSON it is, so the note never reads as a blank dimension.
731
+ const unrun = (Array.isArray(file.dimensionsUnrun) ? file.dimensionsUnrun : []).map((d) => oneLine(
732
+ typeof d === "string" ? d : typeof d?.dimension === "string" ? d.dimension : JSON.stringify(d ?? null),
733
+ ));
734
+ const opening = `review ${review.run} at ${review.head.slice(0, 7)}`;
735
+ const noted = (parts) => (unrun.length === 0 ? parts : [...parts, `unrun: ${unrun.join(", ")}`]).join("; ");
736
+
737
+ if (review.survived === 0 && review.unverified === 0) {
738
+ return { review: review.run, closed_own_ticket: "yes", minted_false_claim: "no", note: `${opening}, no record: ${noted(["counted no survived and no unverified finding"])}` };
739
+ }
740
+ const found = recordFor(scratch.root, pr, review.run);
741
+ if (found.missing !== undefined) return { missing: found.missing };
742
+ const charges = chargesOf(file, found.record);
743
+ if (charges.broken.length > 0) {
744
+ return { missing: `${found.path} has ${charges.broken.length === 1 ? "an entry" : "entries"} a Ruling cannot read: ${charges.broken.join("; ")}` };
745
+ }
746
+ if (charges.missing.length > 0) {
747
+ return { missing: `${found.path} carries no Charge on ${charges.missing.length === 1 ? "this standing finding" : "these standing findings"}: ${charges.missing.join("; ")}` };
748
+ }
749
+ const parts = charges.charged.map((c) => c.text);
750
+ if (charges.none.length > 0) parts.push(`none: ${charges.none.join(", ")}`);
751
+ if (charges.cleared.length > 0) parts.push(`cleared: ${charges.cleared.join(", ")}`);
752
+ return {
753
+ closed_own_ticket: charges.charged.some((c) => FAILS_CLOSED.includes(c.charge)) ? "no" : "yes",
754
+ minted_false_claim: charges.charged.some((c) => MINTS.includes(c.charge)) ? "yes" : "no",
755
+ note: `${opening}, record ${found.file}: ${noted(parts.length === 0 ? ["no finding stands"] : parts)}`,
756
+ review: review.run,
757
+ };
758
+ }
759
+
760
+ // The step a refusal hands the controller. A merged PR's evidence is
761
+ // reclaimed with its scratch, so the verdicts are given by hand; any other
762
+ // PR's is still wanted, and a ruling is never typed for it.
763
+ function nextStep(pr, state) {
764
+ if (state === "MERGED") {
765
+ return `the PR is merged and its evidence is not there to read — give the verdicts by hand: append ${pr} --closed-own-ticket yes|no --minted-false-claim yes|no --note "<the evidence>"`;
766
+ }
767
+ return `check the record again (\`dispositions-check.mjs --member fix-pr-${pr} --scratch <scratch>\`), or dispatch the fix-applier again as a dispositions retry (\`--kind dispositions-retry\`), then run append ${pr} again`;
768
+ }
769
+
770
+ // The PR's state as GitHub gives it: OPEN, CLOSED (never merged) or MERGED.
771
+ function prState(pr) {
772
+ const view = ghPrView(pr, "state");
773
+ if (!["OPEN", "CLOSED", "MERGED"].includes(view?.state)) {
774
+ throw new Error(`gh pr view ${pr} returned state ${JSON.stringify(view?.state ?? null)}, not OPEN, CLOSED or MERGED`);
775
+ }
776
+ return view.state;
777
+ }
778
+
541
779
  function append(pr, paths) {
542
780
  if (!isDigits(pr) || Number(pr) === 0) die(`append needs a PR number, got '${pr}'`);
543
781
  pr = String(Number(pr));
@@ -547,14 +785,17 @@ function append(pr, paths) {
547
785
  closed_own_ticket: arg("closed-own-ticket"), minted_false_claim: arg("minted-false-claim"),
548
786
  note: arg("note"), sizing: sizingFlag ?? "", profile: arg("profile") ?? "", loc: arg("loc") ?? "", files: arg("files") ?? "",
549
787
  };
550
- for (const col of ["closed_own_ticket", "minted_false_claim", "note"]) {
551
- if (fields[col] === null) die(`append needs --${col.replaceAll("_", "-")}\n${USAGE}`);
552
- }
788
+ const given = VERDICT_COLUMNS.filter((col) => fields[col] !== null);
789
+ if (given.length === 1) die(`--${given[0].replaceAll("_", "-")} comes with ${VERDICT_COLUMNS.filter((c) => c !== given[0]).map((c) => `--${c.replaceAll("_", "-")}`)} or not at all\n${USAGE}`);
790
+ const byHand = given.length > 0;
791
+ if (byHand && fields.note === null) die(`verdicts given by hand need --note, naming the evidence for them\n${USAGE}`);
553
792
  for (const [col, value] of Object.entries(fields)) {
554
- if (/[\t\r\n]/.test(value)) die(`--${col.replaceAll("_", "-")} holds a tab or newline — it would shift every field after it`);
793
+ if (value !== null && /[\t\r\n]/.test(value)) die(`--${col.replaceAll("_", "-")} holds a tab or newline — it would shift every field after it`);
555
794
  }
556
- for (const col of VERDICT_COLUMNS) {
557
- if (!VERDICT.includes(fields[col])) die(`--${col.replaceAll("_", "-")} must be yes or no, got '${fields[col]}'`);
795
+ if (byHand) {
796
+ for (const col of VERDICT_COLUMNS) {
797
+ if (!VERDICT.includes(fields[col])) die(`--${col.replaceAll("_", "-")} must be yes or no, got '${fields[col]}'`);
798
+ }
558
799
  }
559
800
  if (fields.sizing && !["light", "heavy"].includes(fields.sizing)) die(`--sizing must be light or heavy, got '${fields.sizing}'`);
560
801
  for (const col of ["loc", "files"]) {
@@ -568,7 +809,45 @@ function append(pr, paths) {
568
809
  return;
569
810
  }
570
811
 
812
+ let scratch = { root: paths.scratch };
813
+ if (scratch.root === null) {
814
+ try {
815
+ scratch = { root: defaultScratch() };
816
+ } catch (e) {
817
+ if (!(e instanceof FleetDirUnresolvable)) throw e;
818
+ scratch = { unresolvable: e.message };
819
+ }
820
+ }
821
+
571
822
  const ledger = readLedger(paths.ledger, false);
823
+ if (byHand) {
824
+ let state;
825
+ try {
826
+ state = prState(pr);
827
+ } catch (e) {
828
+ die(`could not read PR #${pr}'s state, which decides whether verdicts may be given by hand: ${e.message}`);
829
+ }
830
+ if (state !== "MERGED") {
831
+ die(`PR #${pr} is ${state === "OPEN" ? "open" : "closed without merging"}, and verdicts are given by hand only for a merged PR — leave out --closed-own-ticket and --minted-false-claim, and \`append\` works them out from the record: ${nextStep(pr, state)}`);
832
+ }
833
+ } else {
834
+ const ruled = derivedRuling(pr, ledger, scratch);
835
+ if (ruled.missing !== undefined) {
836
+ let state = null;
837
+ let unread = "";
838
+ try {
839
+ state = prState(pr);
840
+ } catch (e) {
841
+ unread = ` (the PR's state could not be read: ${e.message})`;
842
+ }
843
+ die(`cannot rule PR #${pr} from its record — ${ruled.missing}${unread}; ${nextStep(pr, state)}`);
844
+ }
845
+ fields.closed_own_ticket = ruled.closed_own_ticket;
846
+ fields.minted_false_claim = ruled.minted_false_claim;
847
+ fields.note = fields.note === null ? ruled.note : `${ruled.note} — ${fields.note}`;
848
+ console.error(`${NAME}: closed_own_ticket=${fields.closed_own_ticket} minted_false_claim=${fields.minted_false_claim} from the Charges on review ${ruled.review}`);
849
+ }
850
+
572
851
  const { ticket, refusal } = ticketFor(pr, closingNumbers(pr), ledger);
573
852
  if (refusal) die(refusal);
574
853
  const memberRows = readMemberRows(paths.members);
@@ -579,17 +858,7 @@ function append(pr, paths) {
579
858
  if (sizingFlag !== null) {
580
859
  console.error(`${NAME}: sizing=${fields.sizing} from --sizing`);
581
860
  } else {
582
- let scratch = paths.scratch;
583
- let unresolvable = null;
584
- if (scratch === null) {
585
- try {
586
- scratch = defaultScratch();
587
- } catch (e) {
588
- if (!(e instanceof FleetDirUnresolvable)) throw e;
589
- unresolvable = e.message;
590
- }
591
- }
592
- const sized = unresolvable === null ? sizingFromReport(scratch, ticket) : { sizing: "", source: null, warning: `no scratch root to look in: ${unresolvable}` };
861
+ const sized = scratch.unresolvable === undefined ? sizingFromReport(scratch.root, ticket) : { sizing: "", source: null, warning: `no scratch root to look in: ${scratch.unresolvable}` };
593
862
  fields.sizing = sized.sizing;
594
863
  if (sized.warning) console.error(`${NAME}: WARNING sizing left blank for PR #${pr} (ticket #${ticket}): ${sized.warning}`);
595
864
  else console.error(`${NAME}: sizing=${sized.sizing} from ${sized.source}`);