@feigi/fleet-ctl 3.101.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.101.0",
3
+ "version": "3.102.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": {
@@ -35,8 +35,8 @@
35
35
  // step. At exit 0 each `WARNING PR #<n> carries reviewed=` on
36
36
  // its stderr is a PR the ledger marks reviewed with no row:
37
37
  // `NEED tier-outcomes append <n>`, and the step is pending.
38
- // The verdicts are the controller's; this script never
39
- // writes one.
38
+ // The verdicts come from `tier-outcomes.mjs append`, which
39
+ // works them out from the Charges; this script never writes one.
40
40
  // metrics `metrics-refresh.mjs`. Any non-zero exit is a failed step.
41
41
  // commit-and-pr `git switch -c <branch>` (`--branch`, default
42
42
  // `chore/run-artifacts-<UTC date>`, suffixed `b`, `c`… while
@@ -192,7 +192,9 @@ const LEDGER_SCRIPT = join(SCRIPT_DIR, "ledger.mjs");
192
192
 
193
193
  // Two spellings of one commit: either head a prefix of the other, the way a
194
194
  // record's head (7-40 hex) is matched against its review's full head.
195
- const sameHead = (a, b) => a.startsWith(b) || b.startsWith(a);
195
+ // Exported: tier-outcomes.mjs matches a review file's head to its ledger
196
+ // token's the same way.
197
+ export const sameHead = (a, b) => a.startsWith(b) || b.startsWith(a);
196
198
 
197
199
  // ---------------------------------------------------------------------------
198
200
  // pure core — no filesystem or process access until main()
@@ -425,7 +427,9 @@ function shapeErrors(e) {
425
427
  // Whether an entry's finding stands: a survived finding not deferred
426
428
  // false-rationale, or a refuted one — an entry in that bucket exists only for
427
429
  // a refutation the fix-applier reversed. An unverified finding never stands.
428
- const stands = (bucket, e) => bucket === "refuted"
430
+ // Exported: tier-outcomes.mjs works a Ruling out from the Charges on exactly
431
+ // these findings.
432
+ export const stands = (bucket, e) => bucket === "refuted"
429
433
  || (bucket === "survived" && !(e.disposition === "defer" && e.reason === "false-rationale"));
430
434
 
431
435
  // Why an entry's Charge breaks a rule, or null when it holds. `required`: the
@@ -1917,8 +1917,8 @@ async function rulingOf(pr) {
1917
1917
  if (ruling !== undefined) return { tickets, ruling, tsv, refusal: null };
1918
1918
  }
1919
1919
  return { tickets, ruling: null, refusal: `PR #${pr}, the implementer PR of ${tickets.length === 1 ? "ticket" : "tickets"} ${tickets.map((n) => `#${n}`).join(", ")}, has no ruling — `
1920
- + `${tsv} has no row for it; rule its review with \`~/.fleet/bin/fleet-run tier-outcomes.mjs append ${pr} --closed-own-ticket yes|no `
1921
- + `--minted-false-claim yes|no --note "<text>"\`, then dispatch the finisher again` };
1920
+ + `${tsv} has no row for it; rule its review with \`~/.fleet/bin/fleet-run tier-outcomes.mjs append ${pr}\`, `
1921
+ + "then dispatch the finisher again" };
1922
1922
  }
1923
1923
 
1924
1924
  // The reads a check makes of GitHub and git, shared by settle --from-report
@@ -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}`);
@@ -197,7 +197,7 @@ owes beyond its call:
197
197
  then records `fleet-heartbeat.mjs --stop` last, because every reader takes
198
198
  a recorded stop as the end of the run. A `NEED tier-outcomes append <pr>`
199
199
  is a reviewed PR with no verdict row — for an implementer's, one that never
200
- reached its finisher: append it with the verdict you rule (**Guard: accumulate per PR**) and run `finish` again.
200
+ reached its finisher: run `append <pr>` (**Guard: accumulate per PR**) and run `finish` again.
201
201
  A PR closing none or several: a NEED if a settled `impl-<N>=PR#<pr>` token
202
202
  names it, else a note. A step it fails is explained in
203
203
  references/close-out.md. Exit 0 is the run closed.
@@ -281,8 +281,8 @@ digest asks of you:
281
281
 
282
282
  **Guard: accumulate per PR, never conclude inside one run.** **Append one row to
283
283
  `docs/metrics/tier-outcomes.tsv` when you rule each PR's review, before its finisher, with
284
- `~/.fleet/bin/fleet-run tier-outcomes.mjs append <pr> --closed-own-ticket yes|no --minted-false-claim yes|no --note "<text>"`,
285
- never by hand** (that file's header carries the column meanings). The script
284
+ `~/.fleet/bin/fleet-run tier-outcomes.mjs append <pr>`, never by hand** (that file's header carries the column meanings).
285
+ The script works both verdicts out from the Charges on the PR's first review. The script
286
286
  fills `tier` itself; never type one in. `ledger.mjs dispatch` refuses the finisher until the row exists. That append is the whole duty. See references/pull.md.
287
287
 
288
288
  **A verdict is a measurement only if it post-dates the run that produced it.** See references/reviewers.md.
@@ -25,8 +25,7 @@
25
25
  stop as the end of the run. A `NEED tier-outcomes append <pr>` is a
26
26
  reviewed PR with no verdict row — for an implementer's PR, one that never
27
27
  reached its finisher, since `ledger.mjs dispatch` refuses that finisher
28
- until the row exists: append it with the verdict
29
- you rule (**Guard: accumulate per PR**) and run `finish` again. A reviewed
28
+ until the row exists: run `append <pr>` (**Guard: accumulate per PR**) and run `finish` again. A reviewed
30
29
  PR that closes no issue, or several, with no settled `impl-<N>=PR#<pr>`
31
30
  token naming it takes no row: the check names it in a `note` line and
32
31
  never asks for one. With such a token it is a NEED: `append` takes the
@@ -477,6 +477,12 @@ member-outcomes row (a `tier-unverifiable=` member has no ledger verdict, so
477
477
  its one member-outcomes row decides), and BLANK with a WARNING naming why
478
478
  when neither settles
479
479
  it; never type a tier in over that.
480
+ It also works `closed_own_ticket` and `minted_false_claim` out from the Charges on
481
+ the PR's first review and composes `note` from them, so you type no verdict. A PR whose
482
+ record has no Charge on a standing finding, or whose review file or record is missing,
483
+ is refused, naming what is missing: check the record again, or dispatch the fix-applier
484
+ again as a dispositions retry. Verdicts given by hand, with `--note`, are for a merged
485
+ PR whose evidence is reclaimed, and `append` refuses them on any other.
480
486
 
481
487
  **Why not decide inside one run.** A run holds 2-3 implementer PRs, and ticket
482
488
  difficulty swamps the tier effect — a gojq parity harness and a two-statement