@feigi/fleet-ctl 3.17.0 → 3.17.2

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.17.0",
3
+ "version": "3.17.2",
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" });