@feigi/fleet-ctl 3.102.4 → 3.102.5

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.102.4",
3
+ "version": "3.102.5",
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": {
@@ -120,34 +120,42 @@ export function memberRole(family) {
120
120
  // the first 8 hex of the sha256 the brief's sidecar carries. A merge bot's
121
121
  // entry then carries `queued=<pr>,<pr>…` — `queued=none` for an empty queue —
122
122
  // the open `ready-to-merge` PRs `dispatch` read when it dispatched the bot,
123
- // which its report is judged against at `settle --from-report`. `settle`
124
- // rewrites the token and keeps the words beside it.
125
- const DISPATCHED_ENTRY = /^([^ ]*) brief=([0-9a-f]{8})(?: queued=(none|[1-9][0-9]*(?:,[1-9][0-9]*)*))?$/;
123
+ // which its report is judged against at `settle --from-report`. An
124
+ // implementer's entry ends with `agent=<definition>`, the definition
125
+ // `dispatch` printed for it, which tier-check judges it against. The value
126
+ // becomes a file name under `agents/`, so it is held to `[a-z0-9]` words
127
+ // joined by `-`, the shape of a `tier=` value. `settle` rewrites the token
128
+ // and keeps the words beside it.
129
+ const DISPATCHED_ENTRY = /^([^ ]*) brief=([0-9a-f]{8})(?: queued=(none|[1-9][0-9]*(?:,[1-9][0-9]*)*))?(?: agent=([a-z0-9]+(?:-[a-z0-9]+)*))?$/;
126
130
 
127
131
  /**
128
- * `{token, brief, queued}` for one `## Dispatched` entry: the member token,
129
- * the brief's hash or null, and the PR numbers of its `queued=` word or null
130
- * for an entry without one. An entry that is not a member token plus a
132
+ * `{token, brief, queued, agent}` for one `## Dispatched` entry: the member
133
+ * token, the brief's hash or null, the PR numbers of its `queued=` word or
134
+ * null for an entry without one, and the definition of its `agent=` word or
135
+ * null for an entry without one. An entry that is not a member token plus a
131
136
  * well-formed `brief=` word, optionally followed by a well-formed `queued=`
132
- * word, is returned whole as `token`, so a reader parsing it with
133
- * parseToken() refuses it exactly as it refuses any other entry this grammar
134
- * cannot parse.
137
+ * word and a well-formed `agent=` word, is returned whole as `token`, so a
138
+ * reader parsing it with parseToken() refuses it exactly as it refuses any
139
+ * other entry this grammar cannot parse.
135
140
  */
136
141
  export function parseDispatchedEntry(entry) {
137
142
  const m = DISPATCHED_ENTRY.exec(entry);
138
- if (m === null || parseToken(m[1]) === null) return { token: entry, brief: null, queued: null };
143
+ if (m === null || parseToken(m[1]) === null) return { token: entry, brief: null, queued: null, agent: null };
139
144
  const queued = m[3] === undefined ? null : m[3] === "none" ? [] : m[3].split(",").map(Number);
140
- return { token: m[1], brief: m[2], queued };
145
+ return { token: m[1], brief: m[2], queued, agent: m[4] ?? null };
141
146
  }
142
147
 
143
148
  /**
144
149
  * The `## Dispatched` entry for `token`, with `brief=<brief>` unless brief is
145
- * null and, after it, `queued=…` unless queued is null.
150
+ * null and, after it, `queued=…` unless queued is null and `agent=<agent>`
151
+ * unless agent is null.
146
152
  */
147
- export function dispatchedEntry(token, brief, queued = null) {
153
+ export function dispatchedEntry(token, brief, queued = null, agent = null) {
148
154
  if (brief === null) return token;
149
- const entry = `${token} brief=${brief}`;
150
- return queued === null ? entry : `${entry} queued=${queued.join(",") || "none"}`;
155
+ let entry = `${token} brief=${brief}`;
156
+ if (queued !== null) entry += ` queued=${queued.join(",") || "none"}`;
157
+ if (agent !== null) entry += ` agent=${agent}`;
158
+ return entry;
151
159
  }
152
160
 
153
161
  // `PR#<n>` as a word of a row's text, `PR #<n>` too.
@@ -794,7 +802,7 @@ export function drawCell({ session, ticket, policyCell, cells }) {
794
802
  return { cell: E[k - 1], k, K };
795
803
  }
796
804
 
797
- // The definition an implementer should run under, off its ticket
805
+ // The definition an implementer is dispatched under, off its ticket
798
806
  // row's `tier=` token — none means `fleet-implementer-<POLICY_CELL>`,
799
807
  // `tier=<x>` means `fleet-implementer-<x>`. The value becomes a file
800
808
  // name under `agents/`, so it is held to `[a-z0-9]` words joined by `-`
@@ -804,10 +812,11 @@ export function drawCell({ session, ticket, policyCell, cells }) {
804
812
  // `ledger.mjs dispatch`'s to refuse: it checks the file exists.
805
813
  //
806
814
  // Here, not in tier-check.mjs, because two readers must agree on it:
807
- // `ledger.mjs dispatch` prints the definition before the call, and
808
- // tier-check.mjs judges the member against it after. Two copies could name
809
- // different definitions for one row and fail a dispatch made exactly as
810
- // printed.
815
+ // `ledger.mjs dispatch` prints the definition before the call and records
816
+ // it on the member's `## Dispatched` entry, and tier-check.mjs judges the
817
+ // member against that record — against this function's answer for the row
818
+ // only for an entry that holds none. Two copies could name different
819
+ // definitions for one row and fail a dispatch made exactly as printed.
811
820
  const TIER_SUFFIX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
812
821
 
813
822
  /** Every `tier=` value a row's text carries, in order, duplicates kept. */
@@ -14,9 +14,11 @@
14
14
  // every liveness count from this file alone, rather than from row text a
15
15
  // controller typed by hand: `dispatch` writes a member's live token onto its
16
16
  // row and appends it to `## Dispatched` with `brief=<hash>`, the brief it
17
- // refuses to dispatch without — and, for a merge bot, `queued=<pr>,…`, the
17
+ // refuses to dispatch without — for a merge bot, `queued=<pr>,…`, the
18
18
  // open `ready-to-merge` PRs gh lists at dispatch, which its report must give
19
- // a verdict each; when that brief's sidecar names an absolute
19
+ // a verdict each, and for an implementer, `agent=<definition>`, the
20
+ // definition it prints, which tier-check judges the implementer against —
21
+ // when that brief's sidecar names an absolute
20
22
  // scratch, it also clears the member's report file there, so a prior run's
21
23
  // report is not read as this run's; `settle` rewrites that token to
22
24
  // `<member>=<outcome>` in both places (an implementer's settle to `PR#M` also
@@ -487,7 +489,7 @@ function refuseStrayInId(value, what) {
487
489
  let ledgerParsed = false;
488
490
 
489
491
  function load() {
490
- if (!existsSync(file)) return { rows: [], filed: [], ruled: [], dispatched: [], briefs: {}, queued: {}, drain: null, retired: [] };
492
+ if (!existsSync(file)) return { rows: [], filed: [], ruled: [], ...dispatchedOf([]), drain: null, retired: [] };
491
493
  let text;
492
494
  try {
493
495
  text = readFileSync(file, "utf8");
@@ -519,14 +521,15 @@ function load() {
519
521
  }
520
522
 
521
523
  // `## Dispatched` held as its tokens, which is what every reader of `read`
522
- // parses, `briefs`, each member's `brief=` hash by name, and `queued`, each
523
- // merge bot's `queued=` PR numbers by name: one token per member name for
524
- // the session, so a settle that rewrites the token keeps the name its brief
525
- // and its queue are filed under. save() joins them back.
524
+ // parses, `briefs`, each member's `brief=` hash by name, `queued`, each
525
+ // merge bot's `queued=` PR numbers by name, and `agents`, each implementer's
526
+ // `agent=` definition by name: one token per member name for
527
+ // the session, so a settle that rewrites the token keeps the name its brief,
528
+ // its queue and its definition are filed under. save() joins them back.
526
529
  function dispatchedOf(entries) {
527
530
  const parsed = entries.map(parseDispatchedEntry);
528
531
  const byName = (field) => Object.fromEntries(parsed.filter((p) => p[field] !== null).map((p) => [parseToken(p.token).name, p[field]]));
529
- return { dispatched: parsed.map((p) => p.token), briefs: byName("brief"), queued: byName("queued") };
532
+ return { dispatched: parsed.map((p) => p.token), briefs: byName("brief"), queued: byName("queued"), agents: byName("agent") };
530
533
  }
531
534
 
532
535
  // `## Drain` is written only once there is a marker to hold: an empty section
@@ -541,7 +544,7 @@ function save(d) {
541
544
  `# Fleet run ledger\n\n${ROWS}\n\n` + list(d.rows) +
542
545
  `\n\n${DISPATCHED}\n\n` + list(d.dispatched.map((t) => {
543
546
  const name = parseToken(t)?.name;
544
- return dispatchedEntry(t, d.briefs[name] ?? null, d.queued[name] ?? null);
547
+ return dispatchedEntry(t, d.briefs[name] ?? null, d.queued[name] ?? null, d.agents[name] ?? null);
545
548
  })) +
546
549
  `\n\n${FILED}\n\n` + list(d.filed) +
547
550
  `\n\n${RULED}\n\n` + list(d.ruled) +
@@ -1632,7 +1635,7 @@ function runRotate() {
1632
1635
  }
1633
1636
  // Read before anything moves: a ledger whose live tokens cannot be read is
1634
1637
  // refused with nothing moved, never archived with those tokens dropped.
1635
- let carry = { rows: [], dispatched: [], briefs: {}, queued: {}, retired: [], skipped: [] };
1638
+ let carry = { rows: [], dispatched: [], retired: [], skipped: [] };
1636
1639
  if (prior === "ancestor" && exists) {
1637
1640
  let text;
1638
1641
  try {
@@ -1753,11 +1756,12 @@ function carryOf(text) {
1753
1756
  return false;
1754
1757
  }
1755
1758
  });
1756
- // An entry's `brief=` and `queued=` travel with it: save() writes them only
1757
- // beside the token it writes, so a carried member's record still names the
1758
- // brief it was dispatched with — and a carried merge bot the queue it is
1759
- // judged against — and a dropped member's does not.
1760
- const { dispatched: allDispatched, briefs: allBriefs, queued: allQueued } = dispatchedOf(ledgerSection(text, DISPATCHED));
1759
+ // An entry's `brief=`, `queued=` and `agent=` travel with it: save() writes
1760
+ // them only beside the token it writes, so a carried member's record still
1761
+ // names the brief it was dispatched with — a carried merge bot the queue it
1762
+ // is judged against, a carried implementer the definition it is judged
1763
+ // against — and a dropped member's does not.
1764
+ const { dispatched: allDispatched, briefs: allBriefs, queued: allQueued, agents: allAgents } = dispatchedOf(ledgerSection(text, DISPATCHED));
1761
1765
  const dispatched = allDispatched.filter((e) => {
1762
1766
  const t = parseToken(e);
1763
1767
  if (t !== null && t.error === null) return true;
@@ -1780,7 +1784,7 @@ function carryOf(text) {
1780
1784
  const live = liveOnLedger(rows, dispatched);
1781
1785
  const carried = { rows: live.rows.map((i) => rows[i]), dispatched: live.dispatched };
1782
1786
  const retired = retiredOnLedger([...rows, ...skippedRows], allDispatched, archivedRetired, carried);
1783
- return { ...carried, briefs: allBriefs, queued: allQueued, retired, skipped };
1787
+ return { ...carried, briefs: allBriefs, queued: allQueued, agents: allAgents, retired, skipped };
1784
1788
  }
1785
1789
 
1786
1790
  // ── dispatch / settle ────────────────────────────────────────────────────────
@@ -2220,11 +2224,13 @@ async function runDispatch() {
2220
2224
  // The definition the `task` call names, printed so the controller reads it
2221
2225
  // off this output rather than recalling it from prose a compaction drops.
2222
2226
  // An implementer's comes off its row's `tier=`, the same function
2223
- // tier-check.mjs judges it by — so a row that names no single definition,
2224
- // or names one with no file under `agents/` (a well-formed `tier=` whose
2225
- // definition has not shipped: a `task` call naming it cannot resolve), is
2226
- // refused here, before the member is live, not discovered by the check
2227
- // after the call.
2227
+ // tier-check.mjs falls back to for an entry with no record, and `dispatch`
2228
+ // records what it prints on the member's `## Dispatched` entry, which
2229
+ // tier-check judges the member against — so a row that names no single
2230
+ // definition, or names one with no file under `agents/` (a well-formed
2231
+ // `tier=` whose definition has not shipped: a `task` call naming it cannot
2232
+ // resolve), is refused here, before the member is live, not discovered by
2233
+ // the check after the call.
2228
2234
  //
2229
2235
  // A fix-applier is `fleet-fix-applier` for every kind of work — review,
2230
2236
  // conflict hold, dispositions retry — so its definition reads nothing off
@@ -2319,6 +2325,10 @@ async function runDispatch() {
2319
2325
  // A merge bot's queue is recorded here, beside its brief, where nothing in
2320
2326
  // its report can reach: `settle --from-report` judges the report against it.
2321
2327
  if (QUEUED !== null) data.queued[member.name] = QUEUED;
2328
+ // An implementer's definition is recorded here too: tier-check judges the
2329
+ // member against it, and a `tier=` replaced after the member settled does
2330
+ // not change what it was dispatched under.
2331
+ if (member.family === "impl") data.agents[member.name] = agent;
2322
2332
  save(data);
2323
2333
  console.error(` dispatched ${member.name}`);
2324
2334
  console.log(JSON.stringify({
@@ -56,9 +56,11 @@
56
56
  //
57
57
  // An implementer (`impl-<N>`, ledger-grammar.mjs's name) is judged on the
58
58
  // ledger's terms, not the caller's: the definition it should have
59
- // run under is derived from its ticket row's `tier=` token
60
- // (ledger-grammar.mjs's `expectedDefinition` — the same function
61
- // `ledger.mjs dispatch` prints the definition from), the agent type
59
+ // run under is the one its `## Dispatched` entry recorded at dispatch
60
+ // (`agent=`, read through `ledger.mjs read`), falling back for an entry
61
+ // without one to its ticket row's `tier=` token (ledger-grammar.mjs's
62
+ // `expectedDefinition` — the same function `ledger.mjs dispatch` prints the
63
+ // definition from), the agent type
62
64
  // it was actually dispatched as is
63
65
  // read off its own transcript and must BE that definition — a generic `task`
64
66
  // dispatch fails even when its model happens to match — and the verdict
@@ -468,8 +470,8 @@ function main() {
468
470
  }
469
471
 
470
472
  // The ledger is read at most once for evaluation: every implementer's
471
- // expected definition comes off its row, and nothing this loop does
472
- // writes a `tier=` token.
473
+ // expected definition comes off its `## Dispatched` entry or its row, and
474
+ // nothing this loop does writes either.
473
475
  let ledgerData = null;
474
476
  let receiptSession;
475
477
  const results = entries.map((raw) => {
@@ -479,7 +481,7 @@ function main() {
479
481
  die(`batch entry missing member/agentFile: ${JSON.stringify(raw)}`);
480
482
  }
481
483
  if (impl && raw.agentFile) {
482
- die(`${raw.member}: an impl- entry carries no agentFile — its definition is derived from the ledger row's tier= token`);
484
+ die(`${raw.member}: an impl- entry carries no agentFile — its definition is the one its ## Dispatched entry recorded at dispatch, else the one its ledger row's tier= token names`);
483
485
  }
484
486
  const hasJobRecord = raw.resolvedModel && raw.resolvedThinkingLevel;
485
487
  let frontmatter = null, transcriptText = null, ticket = null, definition = null;
@@ -487,7 +489,13 @@ function main() {
487
489
  if (impl) {
488
490
  ticket = parsed.number;
489
491
  ledgerData ??= readLedger(ledgerFile);
490
- definition = expectedDefinition(rowText(ledgerData, ticket));
492
+ // A `## Dispatched` entry for this member that the grammar cannot
493
+ // read, a malformed `agent=` among them, refuses: the definition it
494
+ // recorded is unreadable, and the row's `tier=` may have been
495
+ // replaced since.
496
+ const malformed = (ledgerData.dispatched ?? []).find((t) => t.split(/[\s=]/)[0] === raw.member && parseToken(t)?.error !== null);
497
+ if (malformed !== undefined) throw new Error(`## Dispatched entry '${malformed}' is malformed, so the definition it recorded cannot be read`);
498
+ definition = ledgerData.agents?.[raw.member] ?? expectedDefinition(rowText(ledgerData, ticket));
491
499
  const definitionFile = join(repoRoot, "agents", `${definition}.agent.md`);
492
500
  if (existsSync(definitionFile)) frontmatter = parseFrontmatter(readFileSync(definitionFile, "utf8"));
493
501
  } else {
@@ -78,6 +78,8 @@ A member that dies between spawn and write is invisible — and members die in b
78
78
 
79
79
  `dispatch merge-bot` also records the queue the bot is dispatched against: its `## Dispatched` entry carries `queued=<pr>,<pr>…` (`queued=none` for an empty queue), the open `ready-to-merge` PRs gh lists at that moment. A gh that cannot answer refuses the dispatch, nothing written. `settle merge-bot-<n> --from-report` judges the bot's report against that queue, not against anything the report names: a PR from it that is still open and carries the label, with no verdict in `merged`, `held`, `conflictHold` or `refused`, fails the settle at exit 3, naming the PR, and an entry without `queued=` cannot be judged (exit 2) — settle that bot by hand.
80
80
 
81
+ `dispatch impl-<N>` also records the definition it prints: the implementer's `## Dispatched` entry ends with `agent=<definition>` (`agent=fleet-implementer-<cell>`), and `ledger.mjs read` prints it under `agents`, by member name. tier-check judges the implementer against that record, falling back to the row's `tier=` for an entry without one, so a `tier=` replaced after the implementer settled does not change what it is judged against. `settle` and `rotate` keep the word with its entry. A value that is not `[a-z0-9]` words joined by `-` makes the entry malformed, and the ledger is refused.
82
+
81
83
  ## filed and ruled
82
84
 
83
85
  `filed` is checked with `ledger.mjs check <subject>` before every `gh issue create`, so a finding already recorded this run is not filed twice. That is not a guarantee against duplicates at large; the tracker query, described under `check`'s exits and its `verdict` above, is what covers those.