@feigi/fleet-ctl 3.100.3 → 3.101.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.
@@ -12,6 +12,7 @@ argument-hint: [pr-number]
12
12
  See **Specialists** — those constraints are not optional.
13
13
  2. Plan the actions. Split explicitly into **apply now** / **defer**. A finding is deferred, never dropped. **Every `suggestion` — the review's `severity`, the plugin's *Suggestions* bucket — arrives unchecked: the review budgets that band 0 refuters, so nothing has looked at it yet.** Checking it is now your job, not nobody's. So split it by SCOPE first, and check it in either scope. Out of the PR's ticket scope → defer and file, but **the claim bar runs first:** a suggestion whose claim is about shape — step 5's worth-a-claim bar, recorded `claimKind: shape` — gets no refuter and goes to the `PR #<pr> review: the suggestion band, checked` record, and every other out-of-scope suggestion gets the same refuter an in-scope one does, dispatched the same way, before it is filed. In scope → dispatch one refuter against it, biased to refuse, and hand it this instruction verbatim with one substitution: resolve `<scratch>/pr<N>/<finding>/` in it to an absolute path under your own run root — `mktemp -d` once per round for `<scratch>/pr<N>/fix-XXXXXXXX/`, never bare `pr<N>`, since finding ids restart at 1 each round too — and write that absolute path into the refuter's prompt in its place; a refuter never derives its own path, and tell it your step-3 `testCmd`, which its rules call `<testCmd>`: *Try to REFUTE this finding. Default to refuted=true if uncertain. Verify by RUNNING something — compile it, run the test, apply the mutation. Do not reason your way to agreement. Observe that run synchronously — run the command, wait for it, read its exit code. Never poll a log file for a completion marker: prefer ONE blocking run to a poll loop, and treat its return as permission to look, never as the answer. Reading a log the run has already finished writing is fine; waiting on one is not. If you match a test reporter's own output, accepting both `ℹ` and `#` is necessary but NOT sufficient — strip SGR escapes first as well. node's prefix moves with the node version and with whether stdout is a TTY, and color wraps the whole line so it begins with ESC and no prefix anchor matches at all, which returns empty at exit 0 — indistinguishable from a hung run and from a run of zero tests. For an uncolored baseline use `env -u FORCE_COLOR`; `FORCE_COLOR=` empty still enables color, so it is not a control. State your search scope AND what your pattern would have missed. A grep over one ref does not support a claim about history; a pattern built from the token a diff removed does not support a claim that the category is empty. When your check scores a mutant killed or a test red, run only in your own copy of the tree, and these rules decide it and nothing else: Valid red: a run that completed — the command returned on its own, with no signal and no timeout, deadline or kill having fired — in which the test the claim names is reported failing. An exit ≠ 0 with no failing test is not red, and a different test failing is not red for this claim. A red in the full suite only triggers a narrowed run; it is never evidence on its own. Narrowed run: the Test entrypoint with arguments appended, `<testCmd> <args>`. That appended arguments reach the Test entrypoint is all that is promised about them, so a narrowed run counts only if it reports `tests` > 0 and its output names the claimed test. If no narrowing can be proven, run the full `<testCmd>` and read the named test's own result from its output. Kill: the mutant produced a valid red in a narrowed run, and the same narrowed command, in the same tree with the mutant reverted, ran a valid green with the named test passing — completed, exit 0, `tests` > 0, `cancelled` 0. A completed exit ≠ 0 with the named test not failing is not a kill, and no kill stands without that green run on the unmutated tree. The result is inconclusive, never a kill, when the red does not reproduce in the narrowed run, when the unmutated tree is red too, or when no usable run exists. Evidence line: every kill and every valid red you claim carries one line in the claim's own free-text field: `red: <cmd> → exit N, fail K incl <test>; baseline: <cmd> → exit 0, <test> pass`. A failing-test finding filed off the review's shared run carries the `red:` half alone. This refuter has no abstain: a run that could not decide — neither a valid red nor a valid green — is no kill and no refutation, so name the runs in `reason`, each with its evidence line, and leave `refuted` true, so the suggestion defers on the record instead of applying on a check that decided nothing. A failure injection with no positive control has produced NO result, never a negative one. Before you read an injected fault — an env var, an argv word, a mutant — as having had no effect, prove the injection reached the child: one run whose output differs with it present versus absent, or the child echoing the injected value back. Uncontrolled, the cell is unrun — say so in your verdict instead of reporting a no-effect result. Build such an invocation as an array expanded braced and quoted — `cfg=(SETB=1 BADJ=1); env "${cfg[@]}" sh ./probe.sh` — or inline the assignments literally — `env SETB=1 BADJ=1 sh ./probe.sh`; NEVER from an unquoted scalar — `cfg="SETB=1 BADJ=1"; env $cfg sh ./probe.sh` — which under zsh passes ONE argument, sets a variable literally named `SETB` to `1 BADJ=1`, never sets `BADJ` at all, and still exits 0. `env $cfg[@]` is not the portable spelling either: measured, bash word-splits it into `SETB=1` and `BADJ=1[@]`, so the injection variable is set to a corrupted value, while zsh behaves exactly as with the bare `$cfg` — `BADJ` never set, exit 0. Everything you write — mutants, fixtures, scratch repos — goes under `<scratch>/pr<N>/<finding>/` and nowhere else; the checkout and any worktree are never write targets, though `git show`/`git archive` at a pinned ref read fine anywhere. Chain the directory change into the command, `cd "$D" && git …`, never `cd "$D"; git …`, so a failed `cd` cannot leave a `git` command running in the checkout — and bracket a fixture's own git with `git rev-parse --show-toplevel`: before `git init` it must NOT resolve to the host repository, and `fatal: not a git repository` (exit 128) is the usual pass, not a failure — an outer repository whose ignore rules cover your scratch dir is a pass too; before any `git commit` it must resolve to your scratch path, which catches a forgotten `git init` under any enclosing repository — compare resolved forms (`realpath`), since `--show-toplevel` can report a symlinked parent such as `/tmp` or a symlinked `$HOME` under its resolved name.* That last clause is the whole mechanism — a refuter left to reason its way to agreement rubber-stamps, and "apply only what survives" silently degrades into "apply everything in scope". Apply only what survives; a refuted one defers with the refutation in its body. **Out of scope the refuter decides only where the suggestion is filed — it defers either way, never applied.** Refuted → the `PR #<pr> review: the suggestion band, checked` record, never an open issue. Survived, or its refuter crashed or abstained → its own open issue labelled `needs-triage`, citing the verdict, or the run that crashed, in the body. There is no cap on these refuters; your final verdict (step 4) says how many you dispatched against out-of-scope suggestions. "In scope" means the ticket's scope, not merely the same files — and applying one still never licenses editing a file the ticket has no business in. **A finding in `unverified` whose refuters ran and crashed or abstained always defers, whatever its severity** — and which it is, you read off the finding's **`refutersDispatched`** and **`refutersInconclusive`**, never off severity and never off an empty vote list: `refutersDispatched` above zero with nothing decided means every refuter dispatched against it died or abstained, and `refutersInconclusive` counts the ones that abstained. Only a `survived` finding has been checked; severity says how much a finding would matter if true, never whether anything looked. **Deferring a crash is your move, and re-running it is not** — the review already re-dispatched every crashed refuter pair once, in-run, so a crash that reaches you crashed twice, and the result's `resume` field names the one move left, which is not yours: nothing replays a review that ran in a runner's kernel — it is reported, not acted on. So say *in the deferral* that its refuters crashed rather than that it went unchecked, and re-run nothing for it. **An abstention is not a crash** — a refuter that abstained ran and could not decide: defer and file the finding, citing the inconclusive runs each abstaining refuter named in its `reason`, and never describe its refuters as crashed. **A `suggestion` is also in `unverified`, for a different reason — `refutersDispatched` of zero, the 0-refuter budget the workflow gives that band by policy, so nothing looked *there* — and the scope split above, not this rule, covers it.**
14
14
  **Write every ruling to `<scratch>/dispositions-<your name>.json`, `<your name>` the `fix-pr-<pr>[-x]` you were dispatched as (standalone, `fix-pr-<pr>`) — the disposition record `dispositions-check.mjs` judges against the review result file before any finisher is dispatched.** It is `{"head": "<the review file's head>", "run": "<the run root's name in the review path>", "entries": [ … ]}` — `run` the `<run>` of the review path your prompt names, which the check holds against the one the PR's latest `reviewed=` names, so a record written against an earlier review of the same PR, a same-head one included, is a mismatch (standalone, with no ledger naming a run, leave it out): one entry per `survived` finding and per `unverified` finding, plus one per `refuted` finding you reverse. Each entry carries `bucket` (`survived|unverified|refuted`), `index` (its position in that bucket of the review file — stable, since a round's file never changes), `scope` (`in|out`), `claimKind` (`behavior|shape`) and `disposition` (`apply|defer`), and where they apply `reason`, `issue` (the number you filed it to or commented on), `verdictPath` (a suggestion's refuter verdict file, in scope and, above the claim bar, out of scope) and `remedyFiles` (the files its remedy names). A `refuted` entry carries `scope`, `claimKind` and `disposition` like every other entry, and a non-empty `reason` holding the evidence that reverses it; a missing or blank `reason` is a mismatch. The check reads scope off the diff before it reads your entry: a finding with no `line`, or on a line `git diff` touches from the PR's merge-base with `origin/main` to the review's `head`, is in scope whatever `scope` says, and only otherwise does the declared one stand. An in-scope `survived` finding deferred passes only with `reason` `false-rationale`, `mutual-exclusion`, `remedy-worse` or `remedy-outside-diff`. `remedy-outside-diff` — the remedy would edit a file the PR's diff does not touch — also needs `remedyFiles` to name at least one file absent from `git diff` from that merge-base to the review's `head`: none named, or every one in that diff, is a mismatch. A `critical` or `important` finding deferred for it goes to a human instead of passing: the script exits 1 with verdict `escalate`, `ledger.mjs dispatch` refuses the PR's finisher, and nothing retries it; a `suggestion` deferred for it passes. Any other reason, none, or a `survived` or `unverified` finding with no entry is a mismatch: the script exits 1 naming each violating entry's bucket, index and rule, and `ledger.mjs dispatch` refuses the PR's finisher. `disposition` has no `dismiss`: an in-scope `survived` finding verified correct, needing no change, is deferred with `reason` `false-rationale` and filed on the suggestion-band record — step 5's `PR #<pr> review: the suggestion band, checked`.
15
+ **Give every entry whose finding stands a Charge:** `charge` — `false-claim`, `unmet`, `both` or `none` — and, with `none` only, a `chargeReason`: `defect-outside-acceptance` (a defect outside the ticket's acceptance criteria, which no text the implementer wrote claims is otherwise), `not-the-implementers-text` (the false text predates the PR, or comes from the ticket) or `claim-about-untouched-code` (a claim about code the change does not touch). A `survived` finding not deferred `false-rationale` stands, and so does every `refuted` one you reverse; an `unverified` one never does. Charge the work as submitted — a finding you apply is charged as if it were still there — by the definitions of `closed_own_ticket` and `minted_false_claim` in the header of `docs/metrics/tier-outcomes.tsv`, against the ticket as its implementer was dispatched with it; an entry scoped `out` is charged `none`. The check requires a Charge on every standing entry of the record answering the PR's first review — the first that counted no survived and no unverified finding, or else the first a fix-applier answered — and a record answering a later review, or one checked with no ledger, needs none. Wherever a `charge` or `chargeReason` appears it is judged: an unknown value, `none` with no `chargeReason`, a `chargeReason` with any other `charge` or with none, and an out-of-scope entry charged anything but `none` are each a mismatch.
15
16
  3. **Run `checkCmd` and `testCmd` before you commit**, from the worktree, each copied verbatim — not a runner you picked. `checkCmd` is the Recipe's Check step and `testCmd` is the command your dispatcher handed you; **standalone, with no dispatcher to hand you them, they are the repo's own Check step and Test entrypoint**, read off the repo's Recipe cache (`derive-testcmd.sh <repo> check` prints the first, `derive-testcmd.sh <repo> test` the second). Read them off the repo, never invent one: "not a runner you picked" is what this step forbids, and on the standalone path nothing else defines the referent. With no usable Recipe cache (`derive-testcmd.sh` refuses), run the Recipe derivation step first — dispatch a `task` member, agent `fleet-recipe-deriver`, its prompt naming the main checkout's absolute path as `<repo>` and a scratch directory of its own; it proves all three commands in a throwaway worktree and writes the cache only on proof, and its `RECIPE NOT PROVEN` is a stop, reported with its reason. Never fall back to a runner of your own choosing. `tests 0` is a FAILED run, not a pass: a glob matching nothing still exits 0. **Zero is not the only no-work count.** A run reporting **0 passes with no failures** skipped everything it collected, and a count **materially below the whole suite** means you ran a partial tree — this suite reported `tests 937` when this line was written, so compare what you get against a full run rather than against zero. The number drifts; that is the safe direction, because a stale one produces a loud false red while its absence produces a silent false green. Red, zero-test, or either no-work count → fix it or move the finding to defer; never commit over it. Then commit and push — `~/.fleet/bin/fleet-run push-sync.mjs --branch <branch>`, which pushes the explicit refspec `HEAD:refs/heads/<branch>` and then moves the worktree holding `<branch>` to the pushed SHA, or names why it did not and exits 3 with the push landed: this step also runs from a `--detach` worktree (`run-team/SKILL.md`'s worktree-recreation form), where a bare `git push` exits 128 (`fatal: You are not currently on a branch`), any push whose destination is `HEAD` rather than a full or branch refname (`-u` or not, forced or not) exits 1 (`error: The destination you provided is not a full refname`), and a refspec push alone leaves the claim worktree's `refs/heads/<branch>` at its old tip — and report the test result alongside the SHA. **Then reconcile the PR body with what you actually did.** It merges into the permanent record and nothing downstream re-derives it: your commit messages are yours to fix and usually get fixed, the body is neither. Removing a change, or reversing one, leaves the body asserting work the diff no longer contains. Measured twice in one run — one body kept asserting a calibration its own fix had just deleted as false, another kept claiming a hunk the fix had removed as "this diff owns it" — both caught by a finisher's reading rather than by any rule, and both would otherwise have merged intact. `gh pr edit <pr> --body-file <path>`, and re-read the body against the final diff rather than against your memory of it. **Never `--no-verify` on a commit you push, or on the push itself.** The fleet runs in repos it knows nothing about: it must not assume a pre-commit hook exists, and must never bypass one that does on what reaches the branch — a hook that fails is a finding to report, not an obstacle. Nothing re-reviews what you commit here, so this run is the only gate between your edit and a merge.
16
17
  **Under the fleet, run the two through `recipe-check.mjs`, not one by one:** from the worktree, after `git add` and before the commit, `~/.fleet/bin/fleet-run recipe-check.mjs --member <your name> --testCmd "<testCmd>" --reports <scratch>/reports` — `<your name>` the `fix-pr-<pr>[-x]` you were dispatched as, and `<testCmd>` the whole command you were handed, one quoted argument. It runs the Recipe's Check step, which is `checkCmd`, then `<testCmd>` exactly as handed, against the staged tree, and records both results beside your report. The record is keyed to the tree, so run it again after any content change or rebase — a message-only amend keeps it valid — and the controller refuses an `applied` report whose head's tree has no green record. After the commit, and before you push or report, compare `git rev-parse 'HEAD^{tree}'` with the `tree` field of your record, `<scratch>/reports/<your name>.check.json`. A commit hook that rewrote and re-staged files after the record was written makes the two differ. When they do, list the paths it changed, `git diff --name-only "$(jq -r .tree <scratch>/reports/<your name>.check.json)" HEAD`. If the PR changes every listed path anyway, run `recipe-check.mjs` again with the same arguments: right after the commit the index holds the committed tree, so the re-run runs both commands on that tree and records it, leaving the head's sha as it is. A listed path the PR does not otherwise change is a stale base the hook staged rather than left unstaged, and a re-run would record it green: `git fetch origin && git rebase origin/main`, take the commit back with `git reset --soft HEAD^`, put each such path alone back with `git restore --source=HEAD --staged --worktree -- <that path>`, run `recipe-check.mjs`, then `git commit -C ORIG_HEAD` and compare again. A hook that stages such a path again on the rebased base → push nothing and report that cause. Equal trees need no re-run. A non-zero exit is red, save two that are no test result: exit 75 — no slot was free and nothing ran — so run it again, and exit 143, 130 or 129 — a SIGTERM, SIGINT or SIGHUP stopped it and nothing was recorded — so run it again with `timeout: 0` on the call. **Standalone, with no report to settle against,** run `checkCmd` and `testCmd` yourself, each copied verbatim.
17
18
  4. **Under the fleet, do not hold this wait.** The controller owns a persistent CI Monitor and dispatches a finisher once your report has arrived and CI is green — so push, then report to the controller a **final verdict** (every specialist you dispatched + its outcome + your apply/defer adjudication + how many refuters you dispatched against out-of-scope suggestions + the pushed SHA) and stop. A specialist that has not reported to you is not a verdict line — `hub send` it by name for its report, then ask the controller, before ruling; see **Specialists**. The controller keys "review complete" on that verdict, **never on you merely going idle** — a slow specialist can still be running when your turn ends, and finishing off idle-alone ships a premature label (observed: a PR labelled while its contract-test specialist was still running and about to surface a vacuous pin). A turn-based reviewer re-reading `gh pr checks` each idle cycle rebuilds its whole context for nothing the Monitor lacks. **Standalone (no controller): watch** the run until the diff-validating jobs are green (bind via `ci-state.mjs` below); fix a **genuine** failure and repeat from 3. **Staleness is not a failure:** a `rebase-check` red or heavy jobs `skipped` **solely from a non-zero behind-count** never clears by waiting and must not be rebased away here — see **`skipped` is not `passed`** and **Do NOT rebase to label** below for what it looks like and why. Stop watching and go to 6. A turn-based reviewer that "waits for green" on a behind PR stalls forever and ends its turn without labelling. **`ci-state.mjs` returns `verdict: "no-ci"`** → there is no run to watch, so don't: go straight to 6 and let that step's `--declare-no-ci` branch decide.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.100.3",
3
+ "version": "3.101.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": {
@@ -22,7 +22,8 @@
22
22
  // EXIT. 0 rendered. 2 refused, with the reason on stderr and nothing on
23
23
  // stdout: a missing, unknown or relative argument, a template that carries a
24
24
  // placeholder it never declared or no text at all for the kind, a leftover
25
- // declared placeholder in the output, or instruments.sh answering anything
25
+ // declared placeholder in the output, a ticket the ledger names whose saved
26
+ // issue file is missing or unreadable, or instruments.sh answering anything
26
27
  // but 0.
27
28
  //
28
29
  // INSTRUMENTS. After the command line has parsed and before anything is
@@ -40,12 +41,15 @@
40
41
  // arg <flag> <placeholder> the argument `--<flag>` fills `<placeholder>`
41
42
  // path <flag> that argument must be an absolute path
42
43
  // literal <text> `<text>` is carried to the member as written
44
+ // ticket <placeholder> the renderer fills `<placeholder>` with the
45
+ // ticket of the PR `--pr` names (THE TICKET)
43
46
  // A placeholder is `<` + a letter + anything but `<`, `>` and a newline +
44
47
  // `>`, matched by its inner text exactly; it may hold spaces. No flag and
45
48
  // no placeholder is declared twice, and --member, --json and --kind are
46
49
  // reserved.
47
- // - Every placeholder the selected text carries must be declared by `arg` or
48
- // `literal`; an undeclared one is a template defect and refuses, naming it.
50
+ // - Every placeholder the selected text carries must be declared by `arg`,
51
+ // `literal` or `ticket`; an undeclared one is a template defect and
52
+ // refuses, naming it.
49
53
  // - An argument is required exactly when its placeholder occurs in the text
50
54
  // selected for the kind. A flag the selected text never uses is refused.
51
55
  // - The body is plain text. Lines `## kind: <k>[, <k>…]` open a section that
@@ -59,6 +63,20 @@
59
63
  // only a caller passing a value like `<branch>` can cause — refuses, naming
60
64
  // it.
61
65
  //
66
+ // THE TICKET. A `ticket` placeholder in the selected text is filled with the
67
+ // ticket the PR's implementer was dispatched with, which the renderer finds
68
+ // itself: the ledger's settled `impl-<N>=PR#<pr>` token names ticket N, `<pr>`
69
+ // the `--pr` value, and the Pull's `<scratch>/impl-<N>/issue.json` holds it,
70
+ // `<scratch>` the `--scratch` value — so a template declaring `ticket`
71
+ // declares `--pr` and `--scratch` too, and a kind carrying the placeholder
72
+ // carries theirs. It is composed by ticket-text.mjs's ticketText(), as the
73
+ // implementer's was, and defanged as the implementer's was, by the
74
+ // implementer template's argument placeholders and this template's own. Two
75
+ // tickets naming one PR are each rendered, in row order. An issue file that
76
+ // is missing or unreadable refuses, naming its path; nothing asks the tracker
77
+ // in its place. No ledger, or no settled token naming the PR, fills it with a
78
+ // line saying no ticket is on hand.
79
+ //
62
80
  // The text every member of a role shares lives in that role's agent body; a
63
81
  // template carries only what varies between dispatches of the role.
64
82
 
@@ -70,7 +88,9 @@ import { fileURLToPath } from "node:url";
70
88
  import { makeDie, defineFlags, writeAll } from "./arg.mjs";
71
89
  import { FleetDirUnresolvable, fleetFile } from "./fleet-dir.mjs";
72
90
  import { gitEnv } from "./git-env.mjs";
91
+ import { ROWS, ledgerSection, memberTokens } from "./ledger-grammar.mjs";
73
92
  import { runnerScratchRefusal } from "./review-core.mjs";
93
+ import { defang, ticketText } from "./ticket-text.mjs";
74
94
 
75
95
  const NAME = "dispatch-prompt";
76
96
  const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
@@ -96,13 +116,17 @@ function parseTemplate(role, text) {
96
116
  const args = [];
97
117
  const paths = new Set();
98
118
  const literals = new Set();
119
+ let ticket = null;
99
120
  for (const line of lines.slice(1, close)) {
100
- const m = /^(arg|path|literal) (.+)$/.exec(line);
121
+ const m = /^(arg|path|literal|ticket) (.+)$/.exec(line);
101
122
  if (m === null) refuse(`has a frontmatter line that is no directive: ${line}`);
102
123
  if (m[1] === "literal") {
103
124
  literals.add(m[2]);
104
125
  } else if (m[1] === "path") {
105
126
  paths.add(m[2]);
127
+ } else if (m[1] === "ticket") {
128
+ if (ticket !== null) refuse(`declares a ticket placeholder twice: <${ticket}> and <${m[2]}>`);
129
+ ticket = m[2];
106
130
  } else {
107
131
  const split = /^(\S+) (.+)$/.exec(m[2]);
108
132
  if (split === null) refuse(`has an arg line without a placeholder: ${line}`);
@@ -114,6 +138,9 @@ function parseTemplate(role, text) {
114
138
  }
115
139
  for (const flag of paths) if (!args.some((a) => a.flag === flag)) refuse(`marks --${flag} as a path but never declares it`);
116
140
  for (const a of args) if (literals.has(a.placeholder)) refuse(`declares <${a.placeholder}> as both an argument and a literal`);
141
+ if (ticket !== null && (literals.has(ticket) || args.some((a) => a.placeholder === ticket))) {
142
+ refuse(`declares <${ticket}> as the ticket and as an argument or a literal`);
143
+ }
117
144
 
118
145
  const sections = [];
119
146
  let current = { kinds: null, lines: [] };
@@ -130,8 +157,9 @@ function parseTemplate(role, text) {
130
157
  const kinds = [...new Set(sections.flatMap((s) => s.kinds ?? []))];
131
158
 
132
159
  const whole = sections.flatMap((s) => s.lines).join("\n");
133
- for (const a of args) if (!whole.includes(`<${a.placeholder}>`)) refuse(`declares <${a.placeholder}> but never carries it`);
134
- return { args, paths, literals, sections, kinds };
160
+ for (const p of [...args.map((a) => a.placeholder), ...(ticket === null ? [] : [ticket])]) if (!whole.includes(`<${p}>`)) refuse(`declares <${p}> but never carries it`);
161
+ if (ticket !== null) for (const flag of TICKET_FLAGS) if (!args.some((a) => a.flag === flag)) refuse(`declares the ticket <${ticket}> but no --${flag} to find it by`);
162
+ return { args, paths, literals, ticket, sections, kinds };
135
163
  }
136
164
 
137
165
  function loadTemplate(role) {
@@ -144,6 +172,52 @@ function loadTemplate(role) {
144
172
  return parseTemplate(role, text);
145
173
  }
146
174
 
175
+ // --- the ticket --------------------------------------------------------------
176
+
177
+ // The arguments THE TICKET is found by: the PR, and the scratch root the Pull
178
+ // saved the ticket under.
179
+ const TICKET_FLAGS = ["pr", "scratch"];
180
+
181
+ // The text a `ticket` placeholder is filled with, for PR `pr` under scratch
182
+ // root `scratch` (THE TICKET above): each ticket a settled `impl-<N>=PR#<pr>`
183
+ // token on the ledger's rows names, once, in row order, composed by
184
+ // ticketText() and defanged by `names`.
185
+ function ticketFor(pr, scratch, names) {
186
+ let ledger;
187
+ try {
188
+ ledger = fleetFile("ledger.md");
189
+ } catch (err) {
190
+ if (err instanceof FleetDirUnresolvable) die(`cannot find the ledger naming PR #${pr}'s ticket: ${err.message}`);
191
+ throw err;
192
+ }
193
+ let text = "";
194
+ try {
195
+ text = readFileSync(ledger, "utf8");
196
+ } catch (err) {
197
+ if (err.code !== "ENOENT") die(`cannot read the ledger ${ledger} for PR #${pr}'s ticket: ${err.message}`);
198
+ }
199
+ const tickets = [];
200
+ for (const row of ledgerSection(text, ROWS)) {
201
+ for (const t of memberTokens(row)) {
202
+ if (t.family === "impl" && t.error === null && t.outcome === `PR#${pr}` && !tickets.includes(t.number)) tickets.push(t.number);
203
+ }
204
+ }
205
+ if (tickets.length === 0) return `No settled impl-<N>=PR#${pr} token is on this run's ledger, so no ticket is on hand for PR #${pr}.`;
206
+ return tickets.map((n) => {
207
+ const path = join(scratch, `impl-${n}`, "issue.json");
208
+ let issue;
209
+ try {
210
+ issue = JSON.parse(readFileSync(path, "utf8"));
211
+ } catch (err) {
212
+ return die(`cannot read ticket #${n}, the ticket PR #${pr}'s implementer was dispatched with, from ${path}: ${err.message}`);
213
+ }
214
+ if (typeof issue?.title !== "string") {
215
+ return die(`cannot read ticket #${n}, the ticket PR #${pr}'s implementer was dispatched with, from ${path}: it holds no issue with a title`);
216
+ }
217
+ return defang(ticketText(issue), names);
218
+ }).join("\n\n");
219
+ }
220
+
147
221
  // --- the instrument check ---------------------------------------------------
148
222
 
149
223
  function runInstruments() {
@@ -199,7 +273,7 @@ const selected = template.sections.filter((s) => s.kinds === null || s.kinds.inc
199
273
  const body = selected.join("\n");
200
274
  if (body.trim() === "") die(`template ${role}.md carries no text for ${kind === null ? "the role" : `kind ${kind}`}`);
201
275
 
202
- const declared = new Set(template.args.map((a) => a.placeholder));
276
+ const declared = new Set([...template.args.map((a) => a.placeholder), ...(template.ticket === null ? [] : [template.ticket])]);
203
277
  for (const [, inner] of body.matchAll(PLACEHOLDER)) {
204
278
  if (!declared.has(inner) && !template.literals.has(inner)) die(`template ${role}.md carries the undeclared placeholder <${inner}>`);
205
279
  }
@@ -223,9 +297,23 @@ for (const a of required) {
223
297
  values.set(a.placeholder, value);
224
298
  }
225
299
 
300
+ // THE TICKET, once every argument has passed: only for a kind whose text
301
+ // carries the placeholder, found by that kind's own --pr and --scratch.
302
+ const carriesTicket = template.ticket !== null && body.includes(`<${template.ticket}>`);
303
+ if (carriesTicket) {
304
+ const by = TICKET_FLAGS.map((flag) => required.find((a) => a.flag === flag));
305
+ if (by.includes(undefined)) {
306
+ die(`template ${role}.md carries the ticket <${template.ticket}> in kind ${kind} without ${TICKET_FLAGS.map((f) => `--${f}`).join(" and ")} to find it by`);
307
+ }
308
+ const [pr, scratch] = by.map((a) => values.get(a.placeholder));
309
+ const names = [...loadTemplate("implementer").args, ...template.args].map((a) => a.placeholder).concat(template.ticket);
310
+ values.set(template.ticket, ticketFor(pr, scratch, names));
311
+ }
312
+
226
313
  const brief = `${body.replace(PLACEHOLDER, (whole, inner) => (values.has(inner) ? values.get(inner) : whole))}\n`;
227
- for (const a of required) {
228
- if (brief.includes(`<${a.placeholder}>`)) die(`the rendered brief still carries <${a.placeholder}> — an argument value spells it`);
314
+ const filled = [...required.map((a) => a.placeholder), ...(carriesTicket ? [template.ticket] : [])];
315
+ for (const p of filled) {
316
+ if (brief.includes(`<${p}>`)) die(`the rendered brief still carries <${p}> — an argument value spells it`);
229
317
  }
230
318
 
231
319
  const payload = {
@@ -35,7 +35,12 @@
35
35
  // "reason": "<string>", required, non-empty, on a `refuted` entry; else optional
36
36
  // "issue": <number>, optional: filed or commented to
37
37
  // "verdictPath": "<path>", optional: a suggestion's refuter verdict
38
- // "remedyFiles": ["<path>", …] optional: files the remedy names
38
+ // "remedyFiles": ["<path>", …], optional: files the remedy names
39
+ // "charge": "false-claim" | "unmet" | "both" | "none",
40
+ // the Charge, on an entry whose finding stands
41
+ // "chargeReason": "defect-outside-acceptance" | "not-the-implementers-text"
42
+ // | "claim-about-untouched-code"
43
+ // required with charge none, and only with it
39
44
  // }, … ] }
40
45
  //
41
46
  // One entry per `survived` finding and per `unverified` finding, plus one per
@@ -63,6 +68,18 @@
63
68
  // here. A finding whose `file` and `line` sit on a touched line is in
64
69
  // scope whatever the entry declares, and a finding with no `line` (or no
65
70
  // `file`) is in scope. Otherwise the declared `scope` stands.
71
+ // - Charge: a finding stands when it is `survived` and not deferred with
72
+ // `reason` `false-rationale`, or `refuted` — an entry there is a reversal.
73
+ // An `unverified` finding never stands. On the record answering the PR's
74
+ // first review — firstReview() in ledger-grammar.mjs, this record's run
75
+ // counted as answered — every entry whose finding stands carries a
76
+ // `charge`; a record answering a later review, or one checked with no
77
+ // ledger, needs none. A `charge` or `chargeReason` an entry carries is
78
+ // judged wherever it appears: a value outside the ones above, a `charge`
79
+ // of `none` with no `chargeReason`, a `chargeReason` with any other
80
+ // `charge` or with no `charge`, and an entry declared `out` that the
81
+ // presumption above leaves out of scope charged anything but `none` are
82
+ // each a mismatch.
66
83
  // - An in-scope `survived` finding deferred passes only with `reason` one
67
84
  // of ALLOWED_DEFER. Any other reason, or none, is a mismatch.
68
85
  // `disposition` has no `dismiss`: an in-scope `survived` finding verified
@@ -165,8 +182,8 @@ import { makeDie, defineFlags } from "./arg.mjs";
165
182
  import { isCLI } from "./is-cli.mjs";
166
183
  import { gitEnv } from "./git-env.mjs";
167
184
  import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
168
- import { parseMember, memberTokens, rowNums } from "./ledger-grammar.mjs";
169
- import { deriveRun, dispositionsToken, LedgerError } from "./fleet-tick.mjs";
185
+ import { parseMember, memberTokens, rowNums, dispositionsToken, firstReview, prTokens } from "./ledger-grammar.mjs";
186
+ import { deriveRun, LedgerError } from "./fleet-tick.mjs";
170
187
  import { VERDICT_SCHEMA } from "./review-core.mjs";
171
188
 
172
189
  const NAME = "dispositions-check";
@@ -187,6 +204,11 @@ const COVERED = ["survived", "unverified"];
187
204
  const BUCKETS = [...COVERED, "refuted"];
188
205
  const ENUMS = { scope: ["in", "out"], claimKind: ["behavior", "shape"], disposition: ["apply", "defer"] };
189
206
 
207
+ // A Charge: what a finding that stands on a PR's first review says about the
208
+ // work as submitted. `none` names one of CHARGE_REASONS; no other Charge does.
209
+ export const CHARGES = Object.freeze(["false-claim", "unmet", "both", "none"]);
210
+ export const CHARGE_REASONS = Object.freeze(["defect-outside-acceptance", "not-the-implementers-text", "claim-about-untouched-code"]);
211
+
190
212
  // The filing table: a deferral's home by its finding's state. `record` is the
191
213
  // suggestion-band record; `open` the weakest triage label an open home may carry.
192
214
  export const FILING_ROWS = Object.freeze({
@@ -393,9 +415,39 @@ function shapeErrors(e) {
393
415
  if (e.remedyFiles !== undefined && !(Array.isArray(e.remedyFiles) && e.remedyFiles.every((f) => typeof f === "string"))) {
394
416
  errors.push("remedyFiles is not an array of paths");
395
417
  }
418
+ if (e.charge !== undefined && !CHARGES.includes(e.charge)) errors.push(`charge ${JSON.stringify(e.charge)} is not one of ${CHARGES.join("|")}`);
419
+ if (e.chargeReason !== undefined && !CHARGE_REASONS.includes(e.chargeReason)) {
420
+ errors.push(`chargeReason ${JSON.stringify(e.chargeReason)} is not one of ${CHARGE_REASONS.join("|")}`);
421
+ }
396
422
  return errors;
397
423
  }
398
424
 
425
+ // Whether an entry's finding stands: a survived finding not deferred
426
+ // false-rationale, or a refuted one — an entry in that bucket exists only for
427
+ // a refutation the fix-applier reversed. An unverified finding never stands.
428
+ const stands = (bucket, e) => bucket === "refuted"
429
+ || (bucket === "survived" && !(e.disposition === "defer" && e.reason === "false-rationale"));
430
+
431
+ // Why an entry's Charge breaks a rule, or null when it holds. `required`: the
432
+ // record answers the PR's first review and the entry's finding stands, so the
433
+ // entry carries a Charge. Anywhere else a Charge is optional, and judged only
434
+ // when present.
435
+ function chargeProblem(e, { inScope, required }) {
436
+ if (e.charge === undefined) {
437
+ if (e.chargeReason !== undefined) return "chargeReason gives the reason for a Charge of none, and this entry carries no charge";
438
+ if (required) {
439
+ return `a finding that stands on the PR's first review carries a Charge, and this entry carries none — charge it ${CHARGES.filter((c) => c !== "none").join(", ")}, or none with a chargeReason`;
440
+ }
441
+ return null;
442
+ }
443
+ if (e.charge === "none") {
444
+ return e.chargeReason === undefined ? `a Charge of none names its chargeReason, one of ${CHARGE_REASONS.join(", ")}, and this one names none` : null;
445
+ }
446
+ if (e.chargeReason !== undefined) return `chargeReason gives the reason for a Charge of none, and this entry is charged ${e.charge}`;
447
+ if (!inScope) return `an out-of-scope finding counts for nothing, so it is charged none, and this one is charged ${e.charge}`;
448
+ return null;
449
+ }
450
+
399
451
  /**
400
452
  * Every rule the record breaks against the review, as `violations` of
401
453
  * `{bucket, index, rule}` — `bucket`/`index` null for a rule about the record
@@ -415,10 +467,13 @@ function shapeErrors(e) {
415
467
  * `{problem}` when the tracker cannot say; `verdict` answers `{refuted}` for
416
468
  * a refuter verdict path or `{problem}` when it is no usable evidence, with
417
469
  * `undecided: true` when the path is a refuter's own location holding no decision.
470
+ * `charges` is whether the record answers the PR's first review (firstReview()
471
+ * in ledger-grammar.mjs), where every entry whose finding stands carries a
472
+ * Charge; a Charge an entry carries anywhere is judged all the same.
418
473
  * `review` must pass reviewProblem() — a bucket missing or holding a
419
474
  * non-object is a TypeError here, not a violation.
420
475
  */
421
- export function checkDispositions({ review, record, recordProblem = null, run = null, touched, diffFiles = null, roots = [], filing = null }) {
476
+ export function checkDispositions({ review, record, recordProblem = null, run = null, touched, diffFiles = null, roots = [], filing = null, charges = false }) {
422
477
  const violations = [];
423
478
  const escalations = [];
424
479
  const unchecked = [];
@@ -471,14 +526,16 @@ export function checkDispositions({ review, record, recordProblem = null, run =
471
526
  at(bucket, index, `malformed entry — ${errors.join("; ")}`);
472
527
  return;
473
528
  }
529
+ const finding = review[bucket][index];
530
+ const presumed = presumedInScope(finding, touched, roots);
531
+ const inScope = presumed !== null || e.scope === "in";
532
+ const charge = chargeProblem(e, { inScope, required: charges && stands(bucket, e) });
533
+ if (charge !== null) at(bucket, index, charge);
474
534
  if (bucket === "refuted" && !e.reason?.trim()) {
475
535
  at(bucket, index, "a reversed refutation names its evidence in reason, and this one names none");
476
536
  return;
477
537
  }
478
538
  if (bucket !== "unverified" && e.disposition !== "defer") return;
479
- const finding = review[bucket][index];
480
- const presumed = presumedInScope(finding, touched, roots);
481
- const inScope = presumed !== null || e.scope === "in";
482
539
  const why = presumed === null ? "its entry declares scope in" : `${presumed}, so it is in scope whatever its entry declares`;
483
540
  const crashed = bucket === "unverified" && finding.refutersDispatched > 0;
484
541
  const shape = e.claimKind === "shape";
@@ -776,7 +833,7 @@ function main() {
776
833
  // same per-PR fold `ledger.mjs dispatch` gates the finisher by, so the check
777
834
  // and the gate never judge two different reviews. Without one nothing names
778
835
  // it, so `--review` must.
779
- let data = null, row = null, run = null, reviewPath;
836
+ let data = null, row = null, run = null, charges = false, reviewPath;
780
837
  if (ledgerFile === null) {
781
838
  reviewPath = arg("review");
782
839
  if (reviewPath === null) die(`no ledger names PR #${pr}'s review — pass --review <path> for the review file ${member.name}'s record answers`);
@@ -797,6 +854,9 @@ function main() {
797
854
  }
798
855
  if (latest === undefined) die(`PR #${pr} has no reviewed= on the ledger — no review for ${member.name}'s record to answer`);
799
856
  run = latest.run;
857
+ // Charges are required only on the record answering the PR's first
858
+ // review; this record answers `run`, so `run` counts as answered.
859
+ charges = firstReview(prTokens(data.rows, pr), [run])?.run === run;
800
860
  reviewPath = join(scratch, `pr${pr}`, run, "review.json");
801
861
  }
802
862
  let review;
@@ -843,7 +903,7 @@ function main() {
843
903
  `list the files of ${base}..${head}`).split("\0").filter(Boolean);
844
904
 
845
905
  const { violations, escalations, unchecked } = checkDispositions({
846
- review, record, recordProblem, run, touched: touchedLines(diff), diffFiles, roots: [review.snapshot, top],
906
+ review, record, recordProblem, run, touched: touchedLines(diff), diffFiles, roots: [review.snapshot, top], charges,
847
907
  filing: { pr, issue: issueReader(repo), verdict: verdictReader(scratch, pr) },
848
908
  });
849
909
  // No ledger, no row: the exit status below is the verdict, and with no
@@ -194,8 +194,8 @@ import { makeDie, defineFlags, isDigits, writeAll } from "./arg.mjs";
194
194
  import { FleetDirUnresolvable, fleetFile } from "./fleet-dir.mjs";
195
195
  import { gitEnv } from "./git-env.mjs";
196
196
  import { isCLI } from "./is-cli.mjs";
197
- import { BRIEFS, REVIEW, REVIEWED, REVIEWER, memberTokens, nextReviewerName, parseDispatchedEntry, parseMember, parseToken, readRetired, rowNums } from "./ledger-grammar.mjs";
198
- import { NO_TRANSCRIPT_AFTER_DEFAULT, PARKS, controllerState, dispositionsToken, foldLedger, parkedToken } from "./fleet-tick.mjs";
197
+ import { BRIEFS, REVIEW, REVIEWED, REVIEWER, dispositionsToken, memberTokens, nextReviewerName, parseDispatchedEntry, parseMember, parseToken, readRetired, rowNums } from "./ledger-grammar.mjs";
198
+ import { NO_TRANSCRIPT_AFTER_DEFAULT, PARKS, controllerState, foldLedger, parkedToken } from "./fleet-tick.mjs";
199
199
  import { phase0Gate, readRunReceipt, tickCaps } from "./phase0-receipt.mjs";
200
200
  import { defaultScratch, readReportIn, reportsDir } from "./report-files.mjs";
201
201
  import { readState, statePath } from "./fleet-state.mjs";
@@ -146,7 +146,7 @@
146
146
  // the ledger; main() does the I/O. Split so the guard table and the reading are
147
147
  // both unit-testable without a network.
148
148
 
149
- import { parseMember, parseToken, premisesOf, rowNums, nextRetry, readRetired, pullEnded, REVIEW, REVIEWED } from "./ledger-grammar.mjs";
149
+ import { parseMember, parseToken, premisesOf, rowNums, nextRetry, readRetired, pullEnded, dispositionsToken, REVIEW, REVIEWED } from "./ledger-grammar.mjs";
150
150
 
151
151
  // The implementer and reviewer rows' TARGET is the In-flight bound; the merge
152
152
  // bot's is 1. Availability of work belongs in the ACTION, not the target: a
@@ -616,26 +616,6 @@ function parkedReason(encoded) {
616
616
  // or `tier-mismatch=<member>:<def>` — or `tier-unverifiable=<member>:no-transcript`
617
617
  // for a settled member whose transcript was never written.
618
618
  const TIER_VERDICT = /^tier-(ok|mismatch|unverifiable)=([^:\s]+):\S+$/;
619
- // dispositions-check.mjs's verdict on a review fix-applier's disposition
620
- // record: `dispositions-ok=fix-pr-<M>[-x]:<run>`,
621
- // `dispositions-mismatch=…`, `dispositions-escalate=…` (a critical or
622
- // important deferral, or a second mismatch on one review, that a human rules
623
- // on) or `dispositions-unchecked=…` (no rule broke, but where a deferral was
624
- // filed could not be read), `<run>` the run of the review the record
625
- // answers, as its `reviewed=` names it. `ledger.mjs dispatch` refuses a
626
- // finisher on anything but an ok. A
627
- // mismatch returns the PR to fixDue for one retry; an escalate is a hold only
628
- // a human answers, so the tick never re-offers the PR for it; an unchecked is
629
- // answered by running the check again, so the tick never offers a
630
- // fix-applier for it either. A token outside this shape is no verdict at all,
631
- // which the gate reads as unchecked: fail closed.
632
- export function dispositionsToken(tok) {
633
- const m = /^dispositions-(ok|mismatch|escalate|unchecked)=([^:\s]+):(run-[A-Za-z0-9]{8})$/i.exec(tok);
634
- if (!m) return null;
635
- const member = parseMember(m[2]);
636
- if (member === null || member.family !== "fix-pr") return null;
637
- return { verdict: m[1].toLowerCase(), member, run: m[3] };
638
- }
639
619
 
640
620
  // Which verdict outranks which when one fix-applier carries more than one for
641
621
  // a review: the stricter reading wins.
@@ -221,6 +221,56 @@ function streams(rows) {
221
221
  return out;
222
222
  }
223
223
 
224
+ /** Every token of PR `pr`'s stream, in ledger row order: the tokens firstReview() reads. */
225
+ export const prTokens = (rows, pr) => streams(rows).get(pr) ?? [];
226
+
227
+ // dispositions-check.mjs's verdict on a review fix-applier's disposition
228
+ // record: `dispositions-ok=fix-pr-<M>[-x]:<run>`,
229
+ // `dispositions-mismatch=…`, `dispositions-escalate=…` (a critical or
230
+ // important deferral, or a second mismatch on one review, that a human rules
231
+ // on) or `dispositions-unchecked=…` (no rule broke, but where a deferral was
232
+ // filed could not be read), `<run>` the run of the review the record
233
+ // answers, as its `reviewed=` names it. `ledger.mjs dispatch` refuses a
234
+ // finisher on anything but an ok. A
235
+ // mismatch returns the PR to fixDue for one retry; an escalate is a hold only
236
+ // a human answers, so the tick never re-offers the PR for it; an unchecked is
237
+ // answered by running the check again, so the tick never offers a
238
+ // fix-applier for it either. A token outside this shape is no verdict at all,
239
+ // which the gate reads as unchecked: fail closed.
240
+ export function dispositionsToken(tok) {
241
+ const m = /^dispositions-(ok|mismatch|escalate|unchecked)=([^:\s]+):(run-[A-Za-z0-9]{8})$/i.exec(tok);
242
+ if (!m) return null;
243
+ const member = parseMember(m[2]);
244
+ if (member === null || member.family !== "fix-pr") return null;
245
+ return { verdict: m[1].toLowerCase(), member, run: m[3] };
246
+ }
247
+
248
+ /**
249
+ * A PR's first review, read off its `tokens` (prTokens()): walking its
250
+ * `reviewed=` tokens in ledger order, the first that counted no survived and
251
+ * no unverified finding, or that a fix-applier answered — a `dispositions-*`
252
+ * verdict anywhere on the stream names its run. A review with findings no
253
+ * fix-applier answered was superseded, and is skipped; a failed review wrote
254
+ * no `reviewed=`, and is no review. `answered` names runs answered on top of
255
+ * the ledger's: dispositions-check.mjs passes the run of the record it is
256
+ * checking, whose verdict is not written yet. `{head, survived, refuted,
257
+ * unverified, run}`, or null when no review qualifies.
258
+ */
259
+ export function firstReview(tokens, answered = []) {
260
+ const runs = new Set(answered);
261
+ for (const tok of tokens) {
262
+ const d = dispositionsToken(tok);
263
+ if (d !== null) runs.add(d.run);
264
+ }
265
+ for (const tok of tokens) {
266
+ const m = REVIEWED.exec(tok);
267
+ if (m === null) continue;
268
+ const review = { head: m[1].toLowerCase(), survived: Number(m[2]), refuted: Number(m[3]), unverified: Number(m[4]), run: m[5] };
269
+ if ((review.survived === 0 && review.unverified === 0) || runs.has(review.run)) return review;
270
+ }
271
+ return null;
272
+ }
273
+
224
274
  const isOpen = (launch) => !launch.failed && !launch.answered;
225
275
 
226
276
  // One stream's review record, read in order. A `reviewed=` answers the
@@ -672,6 +722,21 @@ export function unescapeText(s) {
672
722
  return s.replace(/\\(\\|n)/g, (_, c) => (c === "n" ? "\n" : "\\"));
673
723
  }
674
724
 
725
+ // A section's entries, unescaped, in file order — none when the header is
726
+ // absent. Lenient by design: it refuses nothing, so ledger.mjs's rotate reads
727
+ // a ledger its load() refuses through the same slicing, and
728
+ // dispatch-prompt.mjs reads `## Rows` without loading the ledger whole.
729
+ export function ledgerSection(text, name) {
730
+ const m = headerRe(name).exec(text);
731
+ if (!m) return [];
732
+ const start = m.index + m[1].length + name.length;
733
+ const after = text.slice(start);
734
+ const end = after.search(/\n## /);
735
+ return (end === -1 ? after : after.slice(0, end))
736
+ .split("\n").map((l) => l.trim()).filter((l) => l.startsWith("- "))
737
+ .map((l) => unescapeText(l.slice(2)));
738
+ }
739
+
675
740
  /** The name the next merge bot takes: 1 + the highest `merge-bot-<n>` in
676
741
  * `## Dispatched`, settled ones included, or among `names` — the other names
677
742
  * the session holds, a retired bot's and a settled token on a row — the
@@ -56,7 +56,7 @@ import { fileURLToPath } from "node:url";
56
56
  import { spawnSync, execFileSync } from "node:child_process";
57
57
  import { makeDie, isFlagLike, hasEqualsForm, isDigits, writeAll } from "./arg.mjs";
58
58
  import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
59
- import { parseMember, parseToken, memberTokens, nextMergeBot, nextRetry, liveOnLedger, settledOnLedger, retiredOnLedger, retiredName, readRetired, agentDefinition, expectedDefinition, CELL, POLICY_CELL, tierValues, rowPr, rowWriteRefusal, isReviewToken, addedReviewToken, newSplitRow, splitRowClause, added, premisesOf, MEMBER_FAMILIES, HALT_CAUSES, ROWS, DISPATCHED, FILED, RULED, DRAIN, RETIRED, RECEIPT, archiveStamp, receiptArchiveName, CLOSEOUT_RECEIPT, closeoutArchiveName, BRIEFS, briefsArchiveName, memberRole, parseDispatchedEntry, dispatchedEntry, headerRe, escapeText, unescapeText, nextReviewerName } from "./ledger-grammar.mjs";
59
+ import { parseMember, parseToken, memberTokens, nextMergeBot, nextRetry, liveOnLedger, settledOnLedger, retiredOnLedger, retiredName, readRetired, agentDefinition, expectedDefinition, CELL, POLICY_CELL, tierValues, rowPr, rowWriteRefusal, isReviewToken, addedReviewToken, newSplitRow, splitRowClause, added, premisesOf, MEMBER_FAMILIES, HALT_CAUSES, ROWS, DISPATCHED, FILED, RULED, DRAIN, RETIRED, RECEIPT, archiveStamp, receiptArchiveName, CLOSEOUT_RECEIPT, closeoutArchiveName, BRIEFS, briefsArchiveName, memberRole, parseDispatchedEntry, dispatchedEntry, headerRe, escapeText, ledgerSection, nextReviewerName } from "./ledger-grammar.mjs";
60
60
  import { deriveRun, LedgerError, labelOffMember, HELD, CONFLICT_HOLD, PARKED } from "./fleet-tick.mjs";
61
61
  import { stateFileIn, readState, writeState, assessBeat, stallsAt } from "./fleet-state.mjs";
62
62
  import { processSource, ancestry, controllerOf, judgeController, ProcUnreadable } from "./proc.mjs";
@@ -486,20 +486,6 @@ function refuseStrayInId(value, what) {
486
486
  // report what the parse saw rather than what a later stat() guesses.
487
487
  let ledgerParsed = false;
488
488
 
489
- // A section's entries, unescaped, in file order — none when the header is
490
- // absent. Lenient by design: it refuses nothing, so rotate reads a ledger
491
- // load() refuses through the same slicing.
492
- function sectionOf(text, name) {
493
- const m = headerRe(name).exec(text);
494
- if (!m) return [];
495
- const start = m.index + m[1].length + name.length;
496
- const after = text.slice(start);
497
- const end = after.search(/\n## /);
498
- return (end === -1 ? after : after.slice(0, end))
499
- .split("\n").map((l) => l.trim()).filter((l) => l.startsWith("- "))
500
- .map((l) => unescapeText(l.slice(2)));
501
- }
502
-
503
489
  function load() {
504
490
  if (!existsSync(file)) return { rows: [], filed: [], ruled: [], dispatched: [], briefs: {}, queued: {}, drain: null, retired: [] };
505
491
  let text;
@@ -508,7 +494,7 @@ function load() {
508
494
  } catch (e) {
509
495
  die(`cannot read ${file}: ${e.message}`);
510
496
  }
511
- const section = (name) => sectionOf(text, name);
497
+ const section = (name) => ledgerSection(text, name);
512
498
  // Set here, past the read and the early return, so it can only be true of a
513
499
  // file this function actually opened and recognised as a ledger.
514
500
  ledgerParsed = headerRe(FILED).test(text);
@@ -1756,7 +1742,7 @@ function runRotate() {
1756
1742
  function carryOf(text) {
1757
1743
  const skipped = [];
1758
1744
  const skippedRows = [];
1759
- const rows = sectionOf(text, ROWS).filter((r) => {
1745
+ const rows = ledgerSection(text, ROWS).filter((r) => {
1760
1746
  try {
1761
1747
  deriveRun({ rows: [r], dispatched: [], drain: null }, []);
1762
1748
  return true;
@@ -1771,7 +1757,7 @@ function carryOf(text) {
1771
1757
  // beside the token it writes, so a carried member's record still names the
1772
1758
  // brief it was dispatched with — and a carried merge bot the queue it is
1773
1759
  // judged against — and a dropped member's does not.
1774
- const { dispatched: allDispatched, briefs: allBriefs, queued: allQueued } = dispatchedOf(sectionOf(text, DISPATCHED));
1760
+ const { dispatched: allDispatched, briefs: allBriefs, queued: allQueued } = dispatchedOf(ledgerSection(text, DISPATCHED));
1775
1761
  const dispatched = allDispatched.filter((e) => {
1776
1762
  const t = parseToken(e);
1777
1763
  if (t !== null && t.error === null) return true;
@@ -1783,7 +1769,7 @@ function carryOf(text) {
1783
1769
  // bare — but its name retires, and one naming no member or reviewer is
1784
1770
  // skipped and retires nothing.
1785
1771
  const archivedRetired = [];
1786
- for (const line of sectionOf(text, RETIRED)) {
1772
+ for (const line of ledgerSection(text, RETIRED)) {
1787
1773
  const name = retiredName(line);
1788
1774
  if (name === null) skipped.push({ line, why: "names no member or reviewer" });
1789
1775
  else {
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
+ }
@@ -11,6 +11,7 @@ arg violations violations
11
11
  path worktree
12
12
  path scratch
13
13
  path mismatched-record
14
+ ticket ticket
14
15
  literal your name
15
16
  literal outcome
16
17
  literal that path
@@ -64,6 +65,16 @@ claim, never your patch.
64
65
  **Before you report, write every ruling to `<scratch>/dispositions-<your name>.json`**,
65
66
  `<your name>` the `fix-pr-<pr>[-x]` you were dispatched as — the record the controller checks with
66
67
  `dispositions-check.mjs` before it dispatches any finisher.
68
+
69
+ **Charge every standing entry of that record:** which entries stand, and the
70
+ `charge` and `chargeReason` fields, are in `review-and-fix.md`'s step 2. Read
71
+ the definitions of `closed_own_ticket` and `minted_false_claim` in the header
72
+ of `docs/metrics/tier-outcomes.tsv`, and charge each standing entry by them,
73
+ against the ticket below as its implementer was dispatched with it.
74
+
75
+ The ticket:
76
+
77
+ <ticket>
67
78
  ## kind: conflict
68
79
  Bring `<branch>` current with `origin/main`, resolving every conflict by the
69
80
  **No-undo audit** in `$(~/.fleet/bin/fleet-run --root)/commands/run-merge-bot.md`.