@feigi/fleet-ctl 3.17.1 → 3.17.3

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.
@@ -79,7 +79,7 @@ It cannot ask the user anything and cannot wait on CI — rebasing, watching che
79
79
  - **Do not trust the obvious contamination check.** `diff -rq` and `md5` against the mutating tree both returned *clean* — because the probe had been reverted between the two reads. A clean diff against a live tree is not evidence in either direction; only `snap-ro` and the object store settle it.
80
80
  - **`git archive` carries tracked files only.** No `node_modules`, no gitignored runner or config. Provision in the same step — symlink `node_modules`, copy in what the runner needs — or specialists silently have no runnable suite and reason from source instead of measuring. That failure is invisible: you get confident prose where you asked for a measurement.
81
81
  - **Initialize the copy before anyone measures in it — a bare `git archive` tree cannot validly run every suite.** It is not a git repository, so anything that asks git about the checkout breaks: `derive_workspace_id` shells out to `git rev-parse`, falls back to the directory basename and the hook tests fail on the *name* — naming the dir after the repo makes those pass **by coincidence, not correctness** — while every sweep that asks git what ships declines instead of running, which is the silent half (measured on this repo at `ffa9026`: 2401 tests, 2382 pass, 19 skipped in an extraction against 2401/2401/0 in a worktree, the totals identical). `git init && git add -A -f && git commit` in the extracted copy is what makes a count mean what it looks like; the review's snapshot agent does exactly that and verifies it by comparing the new commit's tree hash against the reviewed commit's (#1056), and a hand-cut tree needs the same before you read a count off it. Suites that need the real worktree for some *other* reason — the stack below — still do.
82
- - **Give specialists a stack-free test command, not the worktree's runner, and their own scratch dir** — `<scratch>/pr<N>/fleet-review-<key>/`, resolved to an absolute path that you write into that specialist's prompt; a specialist never derives its own. Specialists inherit no environment, and one falling back to the default config runs a `globalSetup` that brings the shared compose stack up and tears it down, recreating the DB mid-run for every sibling. **The snapshot does not cover this** — the compose project name comes from the environment, not the working directory, so three agents on three copies still collide. But `./agent-test` does not fix it either: it exports **one** `TEST_COMPOSE_PROJECT`/port triple per *worktree* (`ab-<issue>`), so N concurrent specialists sharing it tear down each other's postgres mid-run. Observed: a probe returned `No test files found / No such container` and would have read as a test failure. **That war story is from another repo, and the config-swap escape it calls for does not exist here** — `feigi/claude-config` has no compose file, no `globalSetup`, and no vitest, so there is no `vitest.ci.config.ts` to point anyone at. (It does now have CI — `.github/workflows/ci.yml` — but that runs `node --test`, not a stack.) Naming one sends every specialist into `Cannot find module`, which reads as a broken tree rather than a bad instruction. In *this* repo the collision is inert for a different reason: the `export TEST_COMPOSE_PROJECT=…` line `claim-ticket.sh` writes into the runner sets `TEST_COMPOSE_PROJECT`/`TEST_POSTGRES_PORT`/`TEST_OLLAMA_PORT` and **nothing reads them** — the suite is `node --test` throughout. So tell specialists `node --test plugin/scripts/*.test.mjs` directly, glob form — `review-core.mjs` derives the same effective command for this repo now rather than defaulting to it (#142), keeping this bullet's manual command in agreement with it — and keep `./agent-test` for your own verification. **Hand that command with its reading rule: `tests 0` is a failed run, not a pass.** A glob is not self-checking. Where it matches nothing, bash passes the pattern through literally, node globs it itself and finds no files, and you get `ℹ tests 0` / exit 0 — a green having run nothing. Read the count under either reporter: `node --test` prints `ℹ tests 0` to a terminal and `# tests 0` to a pipe on older node, so a rule that greps only for `ℹ` finds nothing in a CI log and cannot tell a zero-test run from a full one. Do not count on the shell to catch it: zsh errors (`no matches found`, exit 1) and bash does not, so the guard has to be the reading rule. Node v26.5.0 has no flag that fails a zero-test run. **And zero is not the only no-work count.** **0 passes with no failures** is everything skipped, and a count **materially below the full suite** is a partial copy of the tree — which is the *documented* behavior, since the prompt tells specialists to run their probe work inside their own copy of the snapshot and nothing anchors an expected count for them. `review-core.mjs` classifies the first itself, in `unrunReason`; the second it structurally cannot, because that function is kept pure and so never learns how many tests the tree has. Handing the rule over with the command is the only guard the partial-copy case has. Carry the rule, not the command: in a repo that *does* have a stack, N concurrent specialists sharing one runner still collide, and the escape is whatever that repo's stack-free config is.
82
+ - **Give specialists a stack-free test command, not the worktree's runner, and their own scratch dir** — `<scratch>/pr<N>/fleet-review-<key>/`, resolved to an absolute path that you write into that specialist's prompt; a specialist never derives its own. Specialists inherit no environment, and one falling back to the default config runs a `globalSetup` that brings the shared compose stack up and tears it down, recreating the DB mid-run for every sibling. **The snapshot does not cover this** — the compose project name comes from the environment, not the working directory, so three agents on three copies still collide. But `./agent-test` does not fix it either: it exports **one** `TEST_COMPOSE_PROJECT`/port triple per *worktree* (`ab-<issue>`), so N concurrent specialists sharing it tear down each other's postgres mid-run. Observed: a probe returned `No test files found / No such container` and would have read as a test failure. **That war story is from another repo, and the config-swap escape it calls for does not exist here** — `feigi/claude-config` has no compose file, no `globalSetup`, and no vitest, so there is no `vitest.ci.config.ts` to point anyone at. (It does now have CI — `.github/workflows/ci.yml` — but that runs `node --test`, not a stack.) Naming one sends every specialist into `Cannot find module`, which reads as a broken tree rather than a bad instruction. In *this* repo the collision is inert for a different reason: the `export TEST_COMPOSE_PROJECT=…` line `claim-ticket.sh` writes into the runner sets `TEST_COMPOSE_PROJECT`/`TEST_POSTGRES_PORT`/`TEST_OLLAMA_PORT` and **nothing reads them** — the suite is `node --test` throughout. So tell specialists this repo's test command directly — read off the Recipe cache the way step 3 reads it (`derive-testcmd.sh <worktree or main checkout> test` prints it; the snapshot carries no `.fleet/` cache, so pointing it at the snapshot refuses), here in glob form `node --test plugin/scripts/*.test.mjs` as this repo's example — and keep `./agent-test` for your own verification. **Hand that command with its reading rule: `tests 0` is a failed run, not a pass.** A glob is not self-checking. Where it matches nothing, bash passes the pattern through literally, node globs it itself and finds no files, and you get `ℹ tests 0` / exit 0 — a green having run nothing. Read the count under either reporter: `node --test` prints `ℹ tests 0` to a terminal and `# tests 0` to a pipe on older node, so a rule that greps only for `ℹ` finds nothing in a CI log and cannot tell a zero-test run from a full one. Do not count on the shell to catch it: zsh errors (`no matches found`, exit 1) and bash does not, so the guard has to be the reading rule. Node v26.5.0 has no flag that fails a zero-test run. **And zero is not the only no-work count.** **0 passes with no failures** is everything skipped, and a count **materially below the full suite** is a partial copy of the tree — which is the *documented* behavior, since the prompt tells specialists to run their probe work inside their own copy of the snapshot and nothing anchors an expected count for them. `review-core.mjs` classifies the first itself, in `unrunReason`; the second it structurally cannot, because that function is kept pure and so never learns how many tests the tree has. Handing the rule over with the command is the only guard the partial-copy case has. Carry the rule, not the command: in a repo that *does* have a stack, N concurrent specialists sharing one runner still collide, and the escape is whatever that repo's stack-free config is.
83
83
  - **Completion is not delivery — but you can reach a specialist, so never wait idle.** A specialist you dispatched reports when it settles; if a report is missing, `hub send` it by name and ask for the report. Judge delivery by whether a report came out. **Do not settle a dimension you hold no report for**: ask the specialist, then the controller by name; only when neither answers, rule and name that dimension **unrun** — a killed specialist never reports, so waiting on one is unbounded. Never ship "dispatched, never delivered" as settled; a ruling citing a report you do not hold gets verified from source, never applied on trust.
84
84
  - **Apply only once the fan-out is settled — every report either in hand or accounted for.** Collection is not guaranteed; a report that never arrived is an open dimension, not a smaller set. And editing while they read is the same defect as probing — one specialist reviewed uncommitted code that was never in the PR diff.
85
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.17.1",
3
+ "version": "3.17.3",
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": {
@@ -31,7 +31,10 @@
31
31
  // flagged as a notice — legal, but every `slow-*` cell then measures
32
32
  // the same model as its `task-*` twin;
33
33
  // (f) the agents directory holds at least one `*.agent.md` definition —
34
- // with none, (a), (b) and (d) have nothing to check.
34
+ // with none, (a), (b) and (d) have nothing to check; an agents
35
+ // directory that cannot be read (missing, not a directory) or a
36
+ // definition that cannot be read (a directory named `*.agent.md`)
37
+ // fails too.
35
38
 
36
39
  import { readFileSync, readdirSync } from "node:fs";
37
40
  import { execFileSync } from "node:child_process";
@@ -202,19 +205,35 @@ function kindOf(v) {
202
205
  // definitions need. `violations` stop a run (ADR 0014); `notices` never do —
203
206
  // they flag a hazard (two roles' cells measuring one model) that is legal
204
207
  // configuration, just probably not what the operator meant.
205
- export function checkRoutes({ agentsDir, modelRoles, overrides, catalog }) {
208
+ export function checkRoutes({ agentsDir, agentsSource = `--agents ${agentsDir}`, modelRoles, overrides, catalog }) {
206
209
  const violations = [];
207
210
  const notices = [];
208
211
  const usedBy = {}; // role -> [{ file, level }, ...]
209
212
 
210
- const files = readdirSync(agentsDir).filter((f) => f.endsWith(".agent.md")).sort();
211
- // A directory with no definitions has nothing to route, so every
212
- // per-definition check below (the model route, the role's resolution, the
213
- // level) would pass over it — a wrong `--agents` path that exists must not
214
- // read as a healthy install.
215
- if (files.length === 0) violations.push(`no *.agent.md definition found in ${agentsDir}`);
213
+ // A path `readdirSync`/`readFileSync` cannot read (missing, a regular file,
214
+ // a directory named `*.agent.md`, no permission) is a wrong agents
215
+ // directory, not a crash: name the path and where it came from
216
+ // (`agentsSource` — `--agents <dir>`, or the built-in default).
217
+ const unreadable = (what, e) => `${agentsSource}: cannot read ${what} (${e.code ?? e.message})`;
218
+ let files = [];
219
+ try {
220
+ files = readdirSync(agentsDir).filter((f) => f.endsWith(".agent.md")).sort();
221
+ // A directory with no definitions has nothing to route, so every
222
+ // per-definition check below (the model route, the role's resolution, the
223
+ // level) would pass over it — a wrong `--agents` path that exists must not
224
+ // read as a healthy install.
225
+ if (files.length === 0) violations.push(`no *.agent.md definition found in ${agentsDir}`);
226
+ } catch (e) {
227
+ violations.push(unreadable("the agents directory", e));
228
+ }
216
229
  for (const file of files) {
217
- const text = readFileSync(join(agentsDir, file), "utf8");
230
+ let text;
231
+ try {
232
+ text = readFileSync(join(agentsDir, file), "utf8");
233
+ } catch (e) {
234
+ violations.push(unreadable(file, e));
235
+ continue;
236
+ }
218
237
  const fm = parseFrontmatter(text);
219
238
  if (!fm.role || !fm.level) {
220
239
  violations.push(`${file}: model ${JSON.stringify(fm.model)} is not @<role>:<level>`);
@@ -317,13 +336,20 @@ function main() {
317
336
  const check = has("check");
318
337
  if (!check) die("--check is required (usage: tier-roles.mjs --check [--agents <dir>] [--model-roles <path>] [--overrides <path>] [--catalog <path>])");
319
338
 
320
- const agentsDir = arg("agents") ?? join(SCRIPT_DIR, "..", "agents");
339
+ const agentsFlag = arg("agents");
340
+ const agentsDir = agentsFlag ?? join(SCRIPT_DIR, "..", "agents");
321
341
  const config = (key) => [() => readOmpConfigValue(key), `omp config get ${key} --json`];
322
342
  const overrides = loadJsonObject(arg("overrides"), ...config("task.agentModelOverrides"), "overrides");
323
343
  const modelRoles = loadJsonObject(arg("model-roles"), ...config("modelRoles"), "model-roles");
324
344
  const catalog = loadJsonObject(arg("catalog"), readOmpModelCatalog, "omp models --json", "catalog");
325
345
 
326
- const { violations, notices } = checkRoutes({ agentsDir, modelRoles, overrides, catalog });
346
+ const { violations, notices } = checkRoutes({
347
+ agentsDir,
348
+ agentsSource: agentsFlag === null ? `the default agents directory ${agentsDir}` : undefined,
349
+ modelRoles,
350
+ overrides,
351
+ catalog,
352
+ });
327
353
 
328
354
  if (violations.length === 0) {
329
355
  console.log("tier-roles: every definition routes to a resolvable model at a level it runs, no shadowing override");
@@ -1,7 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { spawnSync } from "node:child_process";
4
- import { chmodSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, copyFileSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
5
5
  import { tempDir } from "./temp-dir.mjs";
6
6
  import { delimiter, join } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -12,6 +12,7 @@ import {
12
12
  } from "./tier-roles.mjs";
13
13
 
14
14
  const SCRIPT = fileURLToPath(new URL("./tier-roles.mjs", import.meta.url));
15
+ const SCRIPTS_DIR = fileURLToPath(new URL(".", import.meta.url));
15
16
  const REPO_AGENTS = fileURLToPath(new URL("../agents", import.meta.url));
16
17
 
17
18
  function dir() {
@@ -211,6 +212,32 @@ test("checkRoutes: an empty agents directory still reports the other violations
211
212
  assert.match(violations.join("\n"), /task\.agentModelOverrides\.fleet-a shadows/);
212
213
  });
213
214
 
215
+ test("checkRoutes: an --agents path that is missing, or a regular file, is a violation naming the flag and path, not a crash", () => {
216
+ const d = dir();
217
+ const missing = join(d, "nope");
218
+ const file = join(d, "plain.txt");
219
+ writeFileSync(file, "x\n");
220
+ for (const [path, code] of [[missing, "ENOENT"], [file, "ENOTDIR"]]) {
221
+ const { violations } = checkRoutes({ agentsDir: path, modelRoles: MODEL_ROLES, overrides: {}, catalog: CATALOG });
222
+ assert.deepEqual(violations, [`--agents ${path}: cannot read the agents directory (${code})`]);
223
+ }
224
+ });
225
+
226
+ test("checkRoutes: a directory named *.agent.md is a violation naming the entry, and the readable definitions after it are still checked", () => {
227
+ const agents = agentsDir({ "fleet-a": "@slow:xhigh", "fleet-b": "@smol:max" });
228
+ mkdirSync(join(agents, "a.agent.md"));
229
+ const { violations } = checkRoutes({ agentsDir: agents, modelRoles: MODEL_ROLES, overrides: {}, catalog: CATALOG });
230
+ assert.equal(violations.length, 2);
231
+ assert.equal(violations[0], `--agents ${agents}: cannot read a.agent.md (EISDIR)`);
232
+ assert.match(violations[1], /^fleet-b\.agent\.md: level max is not one modelRoles\.smol's target/);
233
+ });
234
+
235
+ test("checkRoutes: an unreadable agents directory names the source the caller gives, not --agents", () => {
236
+ const missing = join(dir(), "nope");
237
+ const { violations } = checkRoutes({ agentsDir: missing, agentsSource: "the default agents directory X", modelRoles: MODEL_ROLES, overrides: {}, catalog: CATALOG });
238
+ assert.deepEqual(violations, ["the default agents directory X: cannot read the agents directory (ENOENT)"]);
239
+ });
240
+
214
241
  test("checkRoutes (a): a definition whose model: is not a route is a violation naming the file", () => {
215
242
  const { violations } = routes({ "fleet-a": "opus" });
216
243
  assert.equal(violations.length, 1);
@@ -358,6 +385,33 @@ test("CLI: --check on an agents directory with no definitions exits 1 naming the
358
385
  assert.doesNotMatch(r.stdout, /every definition routes/);
359
386
  });
360
387
 
388
+ test("CLI: --check on an --agents path that is missing, a regular file, or holds a directory named *.agent.md exits 1 with a refusal naming the cause, no stack trace", () => {
389
+ const d = dir();
390
+ const file = join(d, "plain.txt");
391
+ writeFileSync(file, "x\n");
392
+ const trap = agentsDir({ "fleet-a": "@slow:xhigh" });
393
+ mkdirSync(join(trap, "x.agent.md"));
394
+ for (const [path, code] of [[join(d, "nope"), "ENOENT"], [file, "ENOTDIR"], [trap, "EISDIR"]]) {
395
+ const r = runCli(["--check", "--agents", path, ...installFlags(d)], d);
396
+ assert.equal(r.status, 1, path);
397
+ assert.match(r.stderr, new RegExp(`^tier-roles: --agents ${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}: cannot read .*\\(${code}\\)`), path);
398
+ assert.doesNotMatch(r.stderr, /\n\s+at /, path);
399
+ assert.doesNotMatch(r.stdout, /every definition routes/);
400
+ }
401
+ });
402
+
403
+ test("CLI: --check with no --agents and an unreadable built-in agents directory refuses without naming a flag it was not given", () => {
404
+ const d = dir();
405
+ const scripts = join(d, "scripts");
406
+ mkdirSync(scripts);
407
+ for (const f of ["tier-roles.mjs", "arg.mjs", "is-cli.mjs"]) copyFileSync(join(SCRIPTS_DIR, f), join(scripts, f));
408
+ const r = spawnSync(process.execPath, [join(scripts, "tier-roles.mjs"), "--check", ...installFlags(d)], { cwd: d, encoding: "utf8" });
409
+ assert.equal(r.status, 1, r.stderr);
410
+ assert.match(r.stderr, /^tier-roles: the default agents directory .*agents: cannot read the agents directory \(ENOENT\)/);
411
+ assert.doesNotMatch(r.stderr, /--agents/);
412
+ assert.doesNotMatch(r.stderr, /\n\s+at /);
413
+ });
414
+
361
415
  test("CLI: --check with a non-route model: exits 1 naming the file", () => {
362
416
  const d = dir();
363
417
  const agents = agentsDir({ "fleet-a": "opus" });