@feigi/fleet-ctl 3.86.1 → 3.87.1

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.
@@ -237,6 +237,8 @@ For each labeled PR clearing the hold rule, lowest first:
237
237
 
238
238
  - **`head-moved-after-label`** — the PR head is neither `pre` nor `post`, nor a head whose net change the gate proved identical to `pre`'s (`rebaseCarry`, which names both). **Any third SHA is a push that landed while you waited on CI, and no CI check can see it**: `ci-state.mjs` selects the run whose `headSha` equals the *current* PR head (`scripts/ci-state.mjs`, the `r.headSha === prHead` filter), so a member's push plus its own green run satisfies every CI check while the audit behind the label belongs to a tree that is gone. A carry is proven only for the head `gh pr view` read; a different head at `ci-state`'s own read is still this row. The remedy is a fresh finisher against the new head, label untouched.
239
239
  - **`main-gain-removed:<path>`** — merging this head would remove lines `main` gained after the PR's work began: lines a later merge landed, deleted by a branch commit that every rebase since has replayed without a conflict, so no conflict preview and no `origin/main...HEAD` read is sure to show them. `mainGain.hits[]` names each file, the landing key (`#<n>` for a merged PR, else the landing commit's 12-character SHA), the removed lines, and the `ack` line to copy. Skip the PR, leave the label alone, and report `main-gain-removed:<path>-#<pr>` with the hit list and the keys. Never restore the lines yourself — the removal may be deliberate. It clears when the PR stops removing them or its body carries `main-gain-removal: <path> <key> - <why>` for every hit; the gate reads the body each run, so editing it is enough. An acknowledged removal is listed in `mainGain.acknowledged[]` and the gate passes it — name each one, with its reason, in your report. Your report's `refused[]` entry for the PR carries the gate's `reason` as it printed it, `main-gain-removed:<path>`, and the gate's `--out` file above stays where the gate wrote it: the controller parks the PR off that entry, at the `head` and `bodyDigest` that file records the gate refused, and no merge bot is dispatched on the PR's account until its head or its body changes — though a bot dispatched for another queued PR may still evaluate it again.
240
+
241
+ Each hit line carries a `kind`: `modified` when the hunk that removed it also adds lines, none of which claimed it as an in-place edit, or `deleted` when the hunk only removes lines. A removed line edited in place that keeps every token of its text is not a hit; the gate lists it in `mainGain.edited[]` and passes it.
240
242
  - **`main-gain-unanswerable`** — the main-gain check could not answer: a head object the main checkout does not hold after `git fetch origin`, a merge that does not resolve cleanly, a blame that failed. `mainGain.reason` says which. Stop and report `main-gain-unanswerable-#<pr>` with it, label untouched.
241
243
  - **`behind:<n>`** — you are the only place currency is proven, and a sibling merge since your rebase expires it (the staleness below).
242
244
  - **`instrument-set-changed` / `instruments-unanswerable`** — the instrument re-check the controller runs before its own gates (`run-team/SKILL.md`, **You read your instruments out of a tree every member can write to**), taken at this seat because nothing dispatches a merge bot to read that rule. Report `<reason>-#<pr>` with what the gate printed, leave the label alone, and do **not** re-run the gate.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.86.1",
3
+ "version": "3.87.1",
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": {
@@ -29,12 +29,16 @@
29
29
  // Removed lines are read off the merge the head would actually produce:
30
30
  // `git merge-tree --write-tree <base> <head>`, diffed against `<base>` with
31
31
  // rename detection on. That is right for a head that is behind `<base>` too,
32
- // where a three-dot diff is not. Every removed line is a candidate, including
33
- // one replaced by other text, and every line of a deleted file is. A removed
34
- // line whose exact text is added back in the same file is a move, not a
35
- // candidate. Binary files cannot be read line by line and are listed in
36
- // `unchecked[]` instead of blocking. A merge-tree that reports conflicts is a
37
- // head that cannot merge as it stands, which is no answer about its content.
32
+ // where a three-dot diff is not. Every removed line is a candidate, and every
33
+ // line of a deleted file is, except a move: a removed line whose exact text is
34
+ // added back in the same file. A candidate edited in place is not a hit: an
35
+ // added line of the same hunk that still holds every token of it claims it, and
36
+ // each added line claims at most one. A token is a run of letters, digits or
37
+ // underscores, or else one other character that is not whitespace, so
38
+ // appending a name to an import is an in-place edit and dropping one is not.
39
+ // Binary files cannot be read line by line and are listed in `unchecked[]`
40
+ // instead of blocking. A merge-tree that reports conflicts is a head that
41
+ // cannot merge as it stands, which is no answer about its content.
38
42
  //
39
43
  // Each hit carries a landing key: `#<n>` when the landing commit's subject is
40
44
  // GitHub's merge subject for PR n, otherwise that commit's 12-character
@@ -43,10 +47,15 @@
43
47
  // with an empty reason does not count. Acknowledged hits move from `hits[]`
44
48
  // to `acknowledged[]` and both are reported.
45
49
  //
46
- // Stdout: one JSON line, `{head, base, since, hits, acknowledged, unchecked,
47
- // reason}`, `since` being R as an ISO timestamp. Each hit is `{path, key,
48
- // landed, lines: [{line, text}], ack}`, `path` the file's name on `<base>` and
49
- // `ack` the body line to copy, reason left for the author to write.
50
+ // Stdout: one JSON line, `{head, base, since, hits, acknowledged, edited,
51
+ // unchecked, reason}`, `since` being R as an ISO timestamp. Each hit is `{path,
52
+ // key, landed, lines: [{line, text, kind}], ack}`, `path` the file's name on
53
+ // `<base>` and `ack` the body line to copy, reason left for the author to
54
+ // write. `kind` is `modified` when the hunk that removed the line also adds
55
+ // lines, and `deleted` when it only removes them. `edited` lists the gained
56
+ // lines an in-place edit kept, as `{path, line, text, replacement}`, the
57
+ // `replacement` being the added line that holds their tokens. An edited line is
58
+ // not a hit and needs no acknowledgement.
50
59
  //
51
60
  // 0 clean, or every hit acknowledged
52
61
  // 1 at least one unacknowledged hit reason main-gain-removed:<first path>
@@ -130,33 +139,72 @@ function changedPaths(tree) {
130
139
  return out;
131
140
  }
132
141
 
133
- // Removed lines `[{line, text}]` and added texts, or `binary`, for one path.
142
+ // The hunks of one path, `{removed: [{line, text}], added: [text]}` each, in
143
+ // diff order, or `binary`. Removed lines are numbered on `<base>`.
134
144
  function fileDiff(tree, oldPath, newPath) {
135
145
  const paths = oldPath === newPath ? [oldPath] : [oldPath, newPath];
136
146
  const r = git(["diff-tree", "-r", "-M", "-p", "-U0", "--no-color", "--no-ext-diff", "--no-textconv", baseRev, tree, "--", ...paths]);
137
147
  if (r === null) unanswerable(`diff-unreadable:${oldPath}`);
138
- const removed = [];
139
- const added = [];
140
- let inHunk = false;
148
+ const hunks = [];
149
+ let hunk = null;
141
150
  let oldLine = 0;
142
151
  for (const line of r.stdout.split("\n")) {
143
152
  if (line.startsWith("diff --git ")) {
144
- inHunk = false;
153
+ hunk = null;
145
154
  continue;
146
155
  }
147
156
  const h = /^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/.exec(line);
148
157
  if (h) {
149
- inHunk = true;
158
+ hunk = { removed: [], added: [] };
159
+ hunks.push(hunk);
150
160
  oldLine = Number(h[1]);
151
- } else if (!inHunk) {
161
+ } else if (hunk === null) {
152
162
  if (line.startsWith("Binary files ")) return { binary: true };
153
163
  } else if (line.startsWith("-")) {
154
- removed.push({ line: oldLine++, text: line.slice(1) });
164
+ hunk.removed.push({ line: oldLine++, text: line.slice(1) });
155
165
  } else if (line.startsWith("+")) {
156
- added.push(line.slice(1));
166
+ hunk.added.push(line.slice(1));
167
+ }
168
+ }
169
+ return { binary: false, hunks };
170
+ }
171
+
172
+ // The tokens an in-place edit keeps: a run of letters, digits or underscores is
173
+ // one token, and each other character that is not whitespace is one.
174
+ const TOKEN = /[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu;
175
+ function tokensOf(text) {
176
+ const counts = new Map();
177
+ for (const t of text.match(TOKEN) ?? []) counts.set(t, (counts.get(t) ?? 0) + 1);
178
+ return counts;
179
+ }
180
+
181
+ // Each unclaimed added line of one hunk claims at most one removed line of that
182
+ // hunk. Removed lines choose in turn, most tokens first so a short line does not
183
+ // take the added line a long one needs, and each takes the first unclaimed added
184
+ // line still holding every token of it. That is greedy: a removed line can stay
185
+ // a hit where another pairing would have matched every line, an error toward
186
+ // blocking. The rarest token of a removed line bounds the added lines tried for
187
+ // it.
188
+ function claimInPlace(removed, added) {
189
+ const index = new Map();
190
+ for (const a of added) {
191
+ if (a.claimed) continue;
192
+ a.tokens = tokensOf(a.text);
193
+ for (const t of a.tokens.keys()) {
194
+ if (!index.has(t)) index.set(t, []);
195
+ index.get(t).push(a);
196
+ }
197
+ }
198
+ const size = (counts) => [...counts.values()].reduce((s, n) => s + n, 0);
199
+ for (const c of [...removed].sort((x, y) => size(y.tokens) - size(x.tokens))) {
200
+ if (c.tokens.size === 0) continue;
201
+ const pool = [...c.tokens.keys()].map((t) => index.get(t) ?? []).reduce((best, l) => (l.length < best.length ? l : best));
202
+ const a = pool.find((x) => !x.claimed && [...c.tokens].every(([t, n]) => (x.tokens.get(t) ?? 0) >= n));
203
+ if (a) {
204
+ a.claimed = true;
205
+ c.replacement = a.text;
157
206
  }
158
207
  }
159
- return { binary: false, removed, added };
160
208
  }
161
209
 
162
210
  // line number on `<base>` → {sha, time, summary} for the requested lines,
@@ -221,6 +269,7 @@ function check() {
221
269
 
222
270
  const hits = [];
223
271
  const unchecked = [];
272
+ const edited = [];
224
273
  // No commits of its own: the head is already on `<base>` and merges nothing.
225
274
  if (since !== null) {
226
275
  for (const { oldMode, status, oldPath, newPath } of changedPaths(tree)) {
@@ -234,25 +283,47 @@ function check() {
234
283
  unchecked.push(oldPath);
235
284
  continue;
236
285
  }
237
- const addedBack = new Map();
238
- for (const t of d.added) addedBack.set(t, (addedBack.get(t) ?? 0) + 1);
239
- const candidates = d.removed.filter(({ text }) => {
240
- const n = addedBack.get(text) ?? 0;
241
- if (n === 0) return true;
242
- addedBack.set(text, n - 1);
243
- return false;
286
+ // A removed line whose exact text is added back in the same file is a
287
+ // move, and the added line is spent on it.
288
+ const addsByHunk = d.hunks.map(() => []);
289
+ const twins = new Map();
290
+ d.hunks.forEach((h, hunk) => {
291
+ for (const text of h.added) {
292
+ const a = { text, claimed: false, tokens: null };
293
+ addsByHunk[hunk].push(a);
294
+ if (!twins.has(text)) twins.set(text, []);
295
+ twins.get(text).push(a);
296
+ }
244
297
  });
245
- if (candidates.length === 0) continue;
246
- const blamed = blame(oldPath, candidates);
298
+ const candsByHunk = d.hunks.map(() => []);
299
+ d.hunks.forEach((h, hunk) => {
300
+ for (const { line, text } of h.removed) {
301
+ const spare = twins.get(text);
302
+ if (spare?.length) {
303
+ spare.pop().claimed = true;
304
+ continue;
305
+ }
306
+ const kind = h.added.length > 0 ? "modified" : "deleted";
307
+ candsByHunk[hunk].push({ line, text, kind, tokens: tokensOf(text), replacement: null });
308
+ }
309
+ });
310
+ d.hunks.forEach((_, hunk) => claimInPlace(candsByHunk[hunk], addsByHunk[hunk]));
311
+ const cands = candsByHunk.flat();
312
+ if (cands.length === 0) continue;
313
+ const blamed = blame(oldPath, cands);
247
314
  const byKey = new Map();
248
- for (const c of candidates) {
315
+ for (const c of cands) {
249
316
  const b = blamed.get(c.line);
250
317
  if (b.time <= since) continue;
318
+ if (c.replacement !== null) {
319
+ edited.push({ path: oldPath, line: c.line, text: c.text, replacement: c.replacement });
320
+ continue;
321
+ }
251
322
  const key = landingKey(b.sha, b.summary);
252
323
  if (!byKey.has(key)) {
253
324
  byKey.set(key, { path: oldPath, key, landed: b.sha, lines: [], ack: `main-gain-removal: ${oldPath} ${key} - <why>` });
254
325
  }
255
- byKey.get(key).lines.push(c);
326
+ byKey.get(key).lines.push({ line: c.line, text: c.text, kind: c.kind });
256
327
  }
257
328
  hits.push(...byKey.values());
258
329
  }
@@ -270,6 +341,7 @@ function check() {
270
341
  since: since === null ? null : new Date(since * 1000).toISOString(),
271
342
  hits: open,
272
343
  acknowledged,
344
+ edited,
273
345
  unchecked,
274
346
  reason: open.length ? `main-gain-removed:${open[0].path}` : null,
275
347
  };
@@ -282,7 +354,7 @@ try {
282
354
  result = check();
283
355
  } catch (e) {
284
356
  if (!(e instanceof Unanswerable)) die(`check failed: ${e.code ?? e.message} — could not evaluate`);
285
- result = { since: null, hits: [], acknowledged: [], unchecked: [], reason: e.message, unknown: true };
357
+ result = { since: null, hits: [], acknowledged: [], edited: [], unchecked: [], reason: e.message, unknown: true };
286
358
  }
287
359
  const { unknown, ...payload } = result;
288
360
  writeAll(1, `${JSON.stringify({ head, base, ...payload })}\n`);
@@ -31,7 +31,7 @@
31
31
  // `bodyDigest`), or null with `head` when gh could not be read, `rebaseCarry`
32
32
  // naming the labelled head and the head accepted as carrying it (`{labelled,
33
33
  // accepted}`), or null when no carry was proven, `mainGain` echoing
34
- // main-gain.mjs's own payload — hits, acknowledged removals and unchecked
34
+ // main-gain.mjs's own payload — hits, acknowledged removals, edited lines and unchecked
35
35
  // files — or null when it did not run or printed no JSON object; an unusable
36
36
  // payload is still echoed. Stderr: the children's own diagnostics, passed
37
37
  // straight through and never folded into stdout.
@@ -298,7 +298,7 @@ function readMainGain(root, prView) {
298
298
  const payload = isObject(v) ? v : null;
299
299
  const read = (usable) => ({ usable, exit: r.status, payload });
300
300
  if (r.error || r.signal || payload === null || payload.head !== prView.headRefOid) return read(false);
301
- if (![payload.hits, payload.acknowledged, payload.unchecked].every(Array.isArray)) return read(false);
301
+ if (![payload.hits, payload.acknowledged, payload.edited, payload.unchecked].every(Array.isArray)) return read(false);
302
302
  if (!payload.hits.every((h) => isObject(h) && typeof h.path === "string" && h.path !== "")) return read(false);
303
303
  return read((r.status === 0 && payload.hits.length === 0) || (r.status === 1 && payload.hits.length > 0));
304
304
  }
package/scripts/reap.sh CHANGED
@@ -1436,8 +1436,9 @@ for b in $gone_branches; do
1436
1436
  # delete would refuse a branch checked out in a surviving worktree anyway.
1437
1437
  #
1438
1438
  # Ancestor match, not exact equality: a worktree nested inside `$wt`
1439
- # (a real, documented shape — SKILL.md names a member committing from a
1440
- # nested worktree) has `$wt` as an ancestor on disk, so removing `$wt`
1439
+ # (a real, documented shape — run-team references/failure-handling.md,
1440
+ # **Verify every reported SHA.**, names a member committing in a nested
1441
+ # worktree) has `$wt` as an ancestor on disk, so removing `$wt`
1441
1442
  # removes the nested one's files too — taking any uncommitted work in it
1442
1443
  # along, the same cwd deletion this guard exists for, one path further
1443
1444
  # out — even though `$wt` itself never equals `$self_wt` in that shape.
@@ -831,7 +831,7 @@ and flag the PR for a human. A new review of the PR counts again from none.
831
831
 
832
832
  | Failure | Response |
833
833
  |---|---|
834
- | Merge bot's gate finds the merge would remove lines `main` gained after the PR's work began (`main-gain-removed:<path>`, exit 1) | Relay `main-gain-removed:<path>-#<pr>` to a human with the hit list and the acknowledgement keys the bot printed. PR stays queued, label untouched, and no member is dispatched to restore the lines — the removal may be deliberate. `fleet-event.mjs merge-bot-report` parks it: a `parked=<head>:<body digest>:<reason>` token on its row, at the head and body digest the bot's gate reading `<scratch>/pr<pr>/merge-bot-<n>/ci.json` records it refused, and no bot is dispatched on its account — when nothing else queued is dispatchable the tick prints `HOLD (parked #<pr> <reason>)`, though a bot dispatched for another queued PR may still evaluate it again; a new head or an edited body lifts the park with no write. It clears when the PR stops removing them or its body carries `main-gain-removal: <path> <key> - <why>` for each hit |
834
+ | Merge bot's gate finds the merge would remove lines `main` gained after the PR's work began (`main-gain-removed:<path>`, exit 1) | Relay `main-gain-removed:<path>-#<pr>` to a human with the hit list and the acknowledgement keys the bot printed. PR stays queued, label untouched, and no member is dispatched to restore the lines — the removal may be deliberate. `fleet-event.mjs merge-bot-report` parks it: a `parked=<head>:<body digest>:<reason>` token on its row, at the head and body digest the bot's gate reading `<scratch>/pr<pr>/merge-bot-<n>/ci.json` records it refused, and no bot is dispatched on its account — when nothing else queued is dispatchable the tick prints `HOLD (parked #<pr> <reason>)`, though a bot dispatched for another queued PR may still evaluate it again; a new head or an edited body lifts the park with no write. A hit line's `kind` is `modified` when its hunk also adds lines, `deleted` when it only removes lines; a gained line edited in place keeping every token is listed in `mainGain.edited[]`, not a hit. It clears when the PR stops removing them or its body carries `main-gain-removal: <path> <key> - <why>` for each hit |
835
835
  | Merge bot's gate cannot answer the main-gain check (`main-gain-unanswerable`, exit 2) | Report `main-gain-unanswerable-#<pr>` with what it printed, PR stays queued, label untouched |
836
836
 
837
837
  ## Failure handling