@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.
- package/commands/review-and-fix.md +1 -0
- package/package.json +1 -1
- package/scripts/dispatch-prompt.mjs +97 -9
- package/scripts/dispositions-check.mjs +69 -9
- package/scripts/fleet-event.mjs +2 -2
- package/scripts/fleet-tick.mjs +1 -21
- package/scripts/ledger-grammar.mjs +65 -0
- package/scripts/ledger.mjs +5 -19
- package/scripts/pull.mjs +2 -16
- package/scripts/ticket-router.mjs +1 -70
- package/scripts/ticket-text.mjs +94 -0
- package/skills/run-team/briefs/fix-applier.md +11 -0
|
@@ -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
|
@@ -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,
|
|
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
|
|
48
|
-
// `literal`; an undeclared one is a template defect and
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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>", …]
|
|
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,
|
|
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
|
package/scripts/fleet-event.mjs
CHANGED
|
@@ -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,
|
|
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";
|
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -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
|
package/scripts/ledger.mjs
CHANGED
|
@@ -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,
|
|
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) =>
|
|
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 =
|
|
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(
|
|
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
|
|
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-
|
|
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.
|
|
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`.
|