@feigi/fleet-ctl 3.90.0 → 3.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.90.0",
3
+ "version": "3.91.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": {
@@ -429,6 +429,27 @@ export function rowWriteRefusal(before, after, oldLine, line, retired = []) {
429
429
  return null;
430
430
  }
431
431
 
432
+ /**
433
+ * The name of every member the ledger holds settled: a settled copy in
434
+ * `## Dispatched` or on a row settles it, except that a PR-bound member's
435
+ * settled copy counts only on its own PR's row. Malformed tokens are the
436
+ * caller's to have refused: one here is read as nothing.
437
+ */
438
+ export function settledOnLedger(rows, dispatched) {
439
+ const settled = new Set();
440
+ for (const e of dispatched) {
441
+ const t = parseToken(e);
442
+ if (t !== null && t.error === null && t.outcome !== null) settled.add(t.name);
443
+ }
444
+ for (const text of rows) {
445
+ const { pr } = rowNums(text);
446
+ for (const t of memberTokens(text)) {
447
+ if (t.error === null && t.outcome !== null && (t.bound !== "pr" || t.number === pr)) settled.add(t.name);
448
+ }
449
+ }
450
+ return settled;
451
+ }
452
+
432
453
  /**
433
454
  * What a ledger still holds live, and what must travel with it. `tokens` is
434
455
  * every unsettled member token and every open review launch, once each, in
@@ -442,25 +463,14 @@ export function rowWriteRefusal(before, after, oldLine, line, retired = []) {
442
463
  * keep a bare token from reading live again. `dispatched` is every
443
464
  * `## Dispatched` entry whose member is unsettled, plus every settled entry
444
465
  * naming a member a kept row carries, for the same reason. A member is
445
- * settled by a settled copy in `## Dispatched` or on a row, except that a
446
- * PR-bound member's settled copy counts only on its own PR's row — the reading
466
+ * settled as settledOnLedger() reads it — the reading
447
467
  * fleet-tick.mjs folds liveness by, so nothing that fold calls live is left
448
468
  * out. A launch is open while unanswered and not `=failed`, paired across its
449
469
  * PR's rows as `rowWriteRefusal` pairs them. Malformed tokens are the caller's
450
470
  * to have refused: one here is read as nothing.
451
471
  */
452
472
  export function liveOnLedger(rows, dispatched) {
453
- const settled = new Set();
454
- for (const e of dispatched) {
455
- const t = parseToken(e);
456
- if (t !== null && t.error === null && t.outcome !== null) settled.add(t.name);
457
- }
458
- for (const text of rows) {
459
- const { pr } = rowNums(text);
460
- for (const t of memberTokens(text)) {
461
- if (t.error === null && t.outcome !== null && (t.bound !== "pr" || t.number === pr)) settled.add(t.name);
462
- }
463
- }
473
+ const settled = settledOnLedger(rows, dispatched);
464
474
  const live = (t) => t !== null && t.error === null && t.outcome === null && !settled.has(t.name);
465
475
  const open = new Map();
466
476
  for (const [id, tokens] of streams(rows)) {
@@ -5,7 +5,8 @@
5
5
  // that.
6
6
  //
7
7
  // Rows are rewritten in place, one per ticket: `row` replaces a whole line
8
- // and `token` appends tokens to one. `filed` and `ruled` are
8
+ // and `token` appends tokens to one, or replaces its `tier=`, which it refuses
9
+ // while the row's implementer has not settled. `filed` and `ruled` are
9
10
  // append-only, because their whole purpose is to outlive the reasoning that
10
11
  // produced them; `filed` appends at most one row per issue.
11
12
  //
@@ -55,7 +56,7 @@ import { fileURLToPath } from "node:url";
55
56
  import { spawnSync, execFileSync } from "node:child_process";
56
57
  import { makeDie, isFlagLike, hasEqualsForm, isDigits, writeAll } from "./arg.mjs";
57
58
  import { fleetFile, FleetDirUnresolvable } from "./fleet-dir.mjs";
58
- import { parseMember, parseToken, memberTokens, nextMergeBot, nextRetry, liveOnLedger, retiredOnLedger, retiredName, readRetired, agentDefinition, 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, unescapeText, nextReviewerName } from "./ledger-grammar.mjs";
59
60
  import { deriveRun, LedgerError, labelOffMember, HELD, CONFLICT_HOLD, PARKED } from "./fleet-tick.mjs";
60
61
  import { stateFileIn, readState, writeState, assessBeat, stallsAt } from "./fleet-state.mjs";
61
62
  import { processSource, ancestry, controllerOf, judgeController, ProcUnreadable } from "./proc.mjs";
@@ -760,6 +761,13 @@ if (cmd === "read") {
760
761
  // only be refused later, by `dispatch`, after the claim.
761
762
  const badTier = tierValues(line).find((v) => !CELL.test(v));
762
763
  if (badTier !== undefined) die(`row ${key}: 'tier=${badTier}' is not tier=<cell> — a cell is <slow|task|smol>-<level>`);
764
+ // A Pull's cell binds its implementer until that member settles; see
765
+ // cellChangeRefusal(). A new row binds nothing yet.
766
+ const i = data.rows.findIndex((r) => r.split(/\s/)[0] === key);
767
+ const created = i === -1;
768
+ const oldRow = created ? null : data.rows[i];
769
+ const cellWhy = cellChangeRefusal(oldRow, line);
770
+ if (cellWhy !== null) die(`row ${key}: ${cellWhy}`);
763
771
  // `label-off=<attempt>` tells the tick a missing `ready-to-merge` is
764
772
  // the controller's own removal, not a finisher's miss — so one naming no
765
773
  // finisher-pr member this run has would mask a real miss behind a record of
@@ -768,9 +776,6 @@ if (cmd === "read") {
768
776
  if (badOff !== undefined) {
769
777
  die(`row ${key}: '${badOff}' ${LABEL_OFF_UNKNOWN}`);
770
778
  }
771
- const i = data.rows.findIndex((r) => r.split(/\s/)[0] === key);
772
- const created = i === -1;
773
- const oldRow = created ? null : data.rows[i];
774
779
  const after = created ? [...data.rows, line] : data.rows.with(i, line);
775
780
  // Every review token is `review`'s to write, onto the PR's row in write
776
781
  // order, so `row` adds none; one the row already holds is carried.
@@ -2845,17 +2850,77 @@ function badLabelOff(words) {
2845
2850
  });
2846
2851
  }
2847
2852
 
2848
- // Why `token` may not write `tok` onto a row whose text is `rowText` (the row
2849
- // as it stands plus the call's earlier tokens), or null when it may. A token
2850
- // is one word: an empty argument, one with a space in it (prose, which would
2851
- // split into words the row's own words then dedupe against), and the row's
2852
- // ` · ` separator are no token. A member token is `dispatch`'s or `settle`'s,
2853
- // a review token `review`'s. A word shaped like a member that the grammar
2854
- // cannot parse, a `tier=` that is not a cell, a `tier=` naming a second tier
2855
- // on a row that already has one, a `held-behind`, `conflict-hold` or `parked`
2856
- // token the tick cannot read, and a lone `→` — an arrow with no PR beside it
2857
- // points at nothing — are refused. Every other word is a freeform token.
2858
- function tokenRefusal(tok, rowText) {
2853
+ // The cell a row's implementer runs at — its `tier=`, or POLICY_CELL on a row
2854
+ // with none — read the way `dispatch` reads it: null for a row whose `tier=`
2855
+ // values conflict or name no agents/<definition>.agent.md, which `dispatch`
2856
+ // refuses, so such a row has no draw to bind.
2857
+ function runnableCell(rowText) {
2858
+ let definition;
2859
+ try {
2860
+ definition = expectedDefinition(rowText);
2861
+ } catch {
2862
+ return null;
2863
+ }
2864
+ if (!existsSync(join(AGENTS_DIR, `${definition}.agent.md`))) return null;
2865
+ return tierValues(rowText)[0] ?? POLICY_CELL;
2866
+ }
2867
+
2868
+ // The member a row's cell binds, while it has not settled, else null: the
2869
+ // newest implementer for the row's ticket, on the row or in `## Dispatched`,
2870
+ // newest by retry suffix as nextRetry() orders them. That is the Pull's first
2871
+ // implementer on an ordinary row, and a resumed or re-Pulled row's latest.
2872
+ // Not settled is live and not yet dispatched alike: a bare token on the row
2873
+ // binds as a bare `## Dispatched` entry does.
2874
+ function unsettledImplementer(rowText) {
2875
+ const ticket = Number(rowKey(rowText).slice(1));
2876
+ const newest = [...memberTokens(rowText), ...data.dispatched.map(parseToken)]
2877
+ .filter((t) => t !== null && t.error === null && t.family === "impl" && t.number === ticket)
2878
+ .map((t) => ({ name: t.name, attempt: t.retry ?? "a" }))
2879
+ .sort((a, b) => (a.attempt < b.attempt ? -1 : a.attempt > b.attempt ? 1 : 0))
2880
+ .at(-1);
2881
+ if (newest === undefined || settledOnLedger(data.rows, data.dispatched).has(newest.name)) return null;
2882
+ return newest.name;
2883
+ }
2884
+
2885
+ // Why a write turning row `oldLine` into `line` may not land, or null when it
2886
+ // may: a Pull's cell binds its implementer, so a write that changes the
2887
+ // row's cell is refused until that member has settled, when a replacement
2888
+ // may run at another cell. A new row, a row with no runnable definition and
2889
+ // a write that leaves the cell as it was bind nothing.
2890
+ function cellChangeRefusal(oldLine, line) {
2891
+ if (oldLine === null) return null;
2892
+ const cell = runnableCell(oldLine);
2893
+ if (cell === null) return null;
2894
+ const tiers = [...new Set(tierValues(line))];
2895
+ const to = tiers.length === 0 ? POLICY_CELL : tiers.join(" and ");
2896
+ if (to === cell) return null;
2897
+ const member = unsettledImplementer(oldLine);
2898
+ if (member === null) return null;
2899
+ return `changes the Pull's cell from ${cell} to ${to} while ${member} has not settled — the cell binds its implementer; once ${member} has settled, \`ledger.mjs token ${rowKey(oldLine).slice(1)} tier=<cell>\` sets a replacement's cell`;
2900
+ }
2901
+
2902
+ // `line` with `tok` in place of its first `tier=` word, every other `tier=`
2903
+ // word dropped with the ` · ` before it.
2904
+ function withTier(line, tok) {
2905
+ let placed = false;
2906
+ return line.replace(/(\s+·)?\s+tier=\S*/g, (m) => {
2907
+ if (placed) return "";
2908
+ placed = true;
2909
+ return m.replace(/tier=\S*$/, () => tok);
2910
+ });
2911
+ }
2912
+
2913
+ // Why `token` may not write `tok`, or null when it may; `earlier` is the
2914
+ // call's tokens before it. A token is one word: an empty argument, one with
2915
+ // a space in it (prose, which would split into words the row's own words
2916
+ // then dedupe against), and the row's ` · ` separator are no token. A member
2917
+ // token is `dispatch`'s or `settle`'s, a review token `review`'s. A word
2918
+ // shaped like a member that the grammar cannot parse, a `tier=` that is not
2919
+ // a cell, a `tier=` naming a second tier in one call, a `held-behind`,
2920
+ // `conflict-hold` or `parked` token the tick cannot read, and a lone `→` — an
2921
+ // arrow with no PR beside it points at nothing — are refused. Every other
2922
+ // word is a freeform token.
2923
+ function tokenRefusal(tok, earlier) {
2859
2924
  if (tok === "" || /\s/.test(tok)) {
2860
2925
  return "is not one token — pass each token as its own argument, with no space in it";
2861
2926
  }
@@ -2881,9 +2946,9 @@ function tokenRefusal(tok, rowText) {
2881
2946
  const [tier] = tierValues(tok);
2882
2947
  if (tier !== undefined) {
2883
2948
  if (!CELL.test(tier)) return "is not tier=<cell> — a cell is <slow|task|smol>-<level>";
2884
- const other = tierValues(rowText).find((v) => v !== tier);
2949
+ const other = tierValues(earlier).find((v) => v !== tier);
2885
2950
  if (other !== undefined) {
2886
- return `names a second tier — the row already carries tier=${other}, and a row names one; replacing it takes ledger.mjs row, which rewrites the whole line`;
2951
+ return `names a second tier — this call already names tier=${other}, and a row names one`;
2887
2952
  }
2888
2953
  }
2889
2954
  if (tok === "→") return "is the row's arrow, which is written with the PR it points at — ledger.mjs row writes the whole line";
@@ -2895,8 +2960,11 @@ function tokenRefusal(tok, rowText) {
2895
2960
  // — the PR's row for a PR key, the ticket row once `settle impl-<N>=PR#M` has
2896
2961
  // run, else `#M`, created when neither exists: `review`'s lookup — keeping
2897
2962
  // every token already there, in order, as ` · <token>`. A token the row
2898
- // already carries is a no-op. Every refusal is judged before anything is
2899
- // written, whole call alike, under the lock.
2963
+ // already carries is a no-op. A `tier=` replaces the row's: in place of its
2964
+ // first `tier=`, dropping any other, refused by cellChangeRefusal() while the
2965
+ // row's implementer has not settled; it is a no-op only on a row whose one
2966
+ // tier it already is. Every refusal is judged before anything is written,
2967
+ // whole call alike, under the lock.
2900
2968
  function runToken() {
2901
2969
  const usage = "usage: ledger.mjs token <ticket|pr> <token>…";
2902
2970
  const [keyArg, ...tokens] = rest;
@@ -2905,20 +2973,23 @@ function runToken() {
2905
2973
  const i = prRowIndex(num);
2906
2974
  const created = i === -1;
2907
2975
  const oldLine = created ? null : data.rows[i];
2908
- let carried = oldLine ?? "";
2976
+ let earlier = "";
2909
2977
  for (const tok of tokens) {
2910
- const why = tokenRefusal(tok, carried);
2978
+ const why = tokenRefusal(tok, earlier);
2911
2979
  if (why !== null) die(`token #${num}: '${tok}' ${why}`);
2912
- carried += ` ${tok}`;
2980
+ earlier += ` ${tok}`;
2913
2981
  }
2914
2982
  const key = created ? `#${num}` : rowKey(oldLine);
2915
2983
  const have = new Set(created ? [] : rowTokens(oldLine));
2984
+ let tiers = [...new Set(tierValues(oldLine))];
2916
2985
  const appended = [];
2917
2986
  for (const tok of tokens) {
2918
- if (have.has(tok)) {
2987
+ const [tier] = tierValues(tok);
2988
+ if (tier === undefined ? have.has(tok) : tiers.length === 1 && tiers[0] === tier) {
2919
2989
  console.error(` already present on row ${key}: ${tok}`);
2920
2990
  } else {
2921
- have.add(tok);
2991
+ if (tier === undefined) have.add(tok);
2992
+ else tiers = [tier];
2922
2993
  appended.push(tok);
2923
2994
  }
2924
2995
  }
@@ -2926,7 +2997,16 @@ function runToken() {
2926
2997
  console.log(JSON.stringify({ key, line: oldLine, added: appended }));
2927
2998
  return;
2928
2999
  }
2929
- const line = created ? `${key} ${appended.join(" · ")}` : `${oldLine} · ${appended.join(" · ")}`;
3000
+ const tierTok = appended.find((tok) => tierValues(tok).length > 0);
3001
+ const replaced = tierTok === undefined ? [] : tierValues(oldLine).map((v) => `tier=${v}`);
3002
+ // `appended` holds at most one `tier=`: tokenRefusal() refuses a second
3003
+ // tier in one call, and the loop above drops a repeat of it.
3004
+ const others = replaced.length > 0 ? appended.filter((tok) => tok !== tierTok) : appended;
3005
+ const line = created
3006
+ ? `${key} ${appended.join(" · ")}`
3007
+ : [replaced.length > 0 ? withTier(oldLine, tierTok) : oldLine, ...others].join(" · ");
3008
+ const cellWhy = cellChangeRefusal(oldLine, line);
3009
+ if (cellWhy !== null) die(`token ${key}: ${cellWhy}`);
2930
3010
  const after = created ? [...data.rows, line] : data.rows.with(i, line);
2931
3011
  // A freeform token can still move the PR a row maps to (a `PR#<n>` mention
2932
3012
  // on a row with no implementer), or split one PR across two rows.
@@ -2940,6 +3020,7 @@ function runToken() {
2940
3020
  if (refusal !== null) die(`token ${key}: ${refusal}`);
2941
3021
  data.rows = after;
2942
3022
  save(data);
2943
- console.error(` appended ${appended.join(" ")} to row ${key}`);
3023
+ if (replaced.length > 0) console.error(` replaced ${replaced.join(" ")} with ${tierTok} on row ${key}`);
3024
+ if (others.length > 0) console.error(` appended ${others.join(" ")} to row ${key}`);
2944
3025
  console.log(JSON.stringify({ key, line, added: appended }));
2945
3026
  }
@@ -62,7 +62,7 @@ One git-ignored `.fleet/ledger.md` per run is updated at every state change thro
62
62
 
63
63
  ## One line per ticket
64
64
 
65
- One line per ticket, rewritten in place (`ledger.mjs row <ticket> <text>`; `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row):
65
+ One line per ticket, rewritten in place (`ledger.mjs row <ticket> <text>`; `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row, and replaces the row's `tier=`, refused while its implementer has not settled):
66
66
 
67
67
  ```
68
68
  #<t1> impl-<t1>=PR#<p1> → PR#<p1> → MERGED 73b356de
@@ -108,4 +108,4 @@ The ledger half reports its own readability as its own field, `ledger.ok`, the p
108
108
 
109
109
  ## Adding a token
110
110
 
111
- Token additions go through `ledger.mjs token`. `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row. Re-typing the row with `row` replaces the whole line, so it drops the tokens you do not restate.
111
+ Token additions go through `ledger.mjs token`. `ledger.mjs token <key> <token>…` adds a token and keeps every token already on the row. A `tier=` replaces the row's `tier=` instead and keeps every other token; while the row's implementer has not settled, `token` and `row` both refuse a write that changes the row's cell. Re-typing the row with `row` replaces the whole line, so it drops the tokens you do not restate.
@@ -434,7 +434,11 @@ true every Pull draws, over every cell, the policy cell included. An
434
434
  Exploration Pull records `tier=<cell>` in the row, and a replacement inherits the row's tier —
435
435
  written at the Pull's row step, ahead of `ledger.mjs dispatch`, which reads it
436
436
  and prints `fleet-implementer-<cell>` as the `agent` to dispatch; a row with
437
- no `tier=` runs the policy cell. The
437
+ no `tier=` runs the policy cell. The Pull's cell binds the row's implementer
438
+ (its newest, on a resumed or re-Pulled row):
439
+ `ledger.mjs row` and `ledger.mjs token` refuse a write that changes it until that implementer has
440
+ settled. Once it has, a replacement may run at another cell, set with
441
+ `ledger.mjs token <N> tier=<cell>`. The
438
442
  assignment rolls to the next Pull when another open ticket sequences after
439
443
  it — a ticket the rest of the run
440
444
  depends on; `class` is not read. Do not tell the member it is a control: a member that