@feigi/fleet-ctl 3.23.4 → 3.23.6

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.
@@ -116,8 +116,8 @@ itself but this rule.
116
116
  **A subagent you dispatch gets its own directory under yours,
117
117
  `<scratch>/impl-<N>/<childName>/`, and you write that path, absolute, into its
118
118
  prompt — it writes nowhere else, and never derives a path of its own.**
119
- `<childName>` is a slug of the name you dispatch it under (omp's task `name`,
120
- Claude Code's `description`) — `[a-z0-9-]` only, never the raw field itself,
119
+ `<childName>` is a slug of the name you dispatch it under (omp's task `name`) —
120
+ `[a-z0-9-]` only, never the raw field itself,
121
121
  which can carry spaces and `/` and would split or nest the path in an
122
122
  unquoted shell command — unique among your children and never one of your
123
123
  own entries (`probe`, `mutate`, or any other name you write under
@@ -116,8 +116,8 @@ itself but this rule.
116
116
  **A subagent you dispatch gets its own directory under yours,
117
117
  `<scratch>/impl-<N>/<childName>/`, and you write that path, absolute, into its
118
118
  prompt — it writes nowhere else, and never derives a path of its own.**
119
- `<childName>` is a slug of the name you dispatch it under (omp's task `name`,
120
- Claude Code's `description`) — `[a-z0-9-]` only, never the raw field itself,
119
+ `<childName>` is a slug of the name you dispatch it under (omp's task `name`) —
120
+ `[a-z0-9-]` only, never the raw field itself,
121
121
  which can carry spaces and `/` and would split or nest the path in an
122
122
  unquoted shell command — unique among your children and never one of your
123
123
  own entries (`probe`, `mutate`, or any other name you write under
@@ -116,8 +116,8 @@ itself but this rule.
116
116
  **A subagent you dispatch gets its own directory under yours,
117
117
  `<scratch>/impl-<N>/<childName>/`, and you write that path, absolute, into its
118
118
  prompt — it writes nowhere else, and never derives a path of its own.**
119
- `<childName>` is a slug of the name you dispatch it under (omp's task `name`,
120
- Claude Code's `description`) — `[a-z0-9-]` only, never the raw field itself,
119
+ `<childName>` is a slug of the name you dispatch it under (omp's task `name`) —
120
+ `[a-z0-9-]` only, never the raw field itself,
121
121
  which can carry spaces and `/` and would split or nest the path in an
122
122
  unquoted shell command — unique among your children and never one of your
123
123
  own entries (`probe`, `mutate`, or any other name you write under
@@ -116,8 +116,8 @@ itself but this rule.
116
116
  **A subagent you dispatch gets its own directory under yours,
117
117
  `<scratch>/impl-<N>/<childName>/`, and you write that path, absolute, into its
118
118
  prompt — it writes nowhere else, and never derives a path of its own.**
119
- `<childName>` is a slug of the name you dispatch it under (omp's task `name`,
120
- Claude Code's `description`) — `[a-z0-9-]` only, never the raw field itself,
119
+ `<childName>` is a slug of the name you dispatch it under (omp's task `name`) —
120
+ `[a-z0-9-]` only, never the raw field itself,
121
121
  which can carry spaces and `/` and would split or nest the path in an
122
122
  unquoted shell command — unique among your children and never one of your
123
123
  own entries (`probe`, `mutate`, or any other name you write under
@@ -116,8 +116,8 @@ itself but this rule.
116
116
  **A subagent you dispatch gets its own directory under yours,
117
117
  `<scratch>/impl-<N>/<childName>/`, and you write that path, absolute, into its
118
118
  prompt — it writes nowhere else, and never derives a path of its own.**
119
- `<childName>` is a slug of the name you dispatch it under (omp's task `name`,
120
- Claude Code's `description`) — `[a-z0-9-]` only, never the raw field itself,
119
+ `<childName>` is a slug of the name you dispatch it under (omp's task `name`) —
120
+ `[a-z0-9-]` only, never the raw field itself,
121
121
  which can carry spaces and `/` and would split or nest the path in an
122
122
  unquoted shell command — unique among your children and never one of your
123
123
  own entries (`probe`, `mutate`, or any other name you write under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.23.4",
3
+ "version": "3.23.6",
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": {
@@ -286,17 +286,19 @@ export function computeSpend({ agents = [], topN = 8 } = {}) {
286
286
  }
287
287
 
288
288
  // Per-TOOL attribution. Tokens are not billed per tool call, so this is a proxy
289
- // and is labelled as one everywhere it surfaces: a tool result arrives in a user
290
- // turn, and the NEXT assistant turn's cache_creation is the cost of writing that
291
- // result into the cache. When several results land before that turn, the cost is
292
- // split proportionally by result size, because that is what drove it.
289
+ // and is labelled as one everywhere it surfaces: a tool result arrives as a
290
+ // `toolResult` line, and the NEXT assistant turn's cache_creation is the cost
291
+ // of writing that result into the cache. When several results land before that
292
+ // turn, the cost is split proportionally by result size, because that is what
293
+ // drove it.
293
294
  //
294
- // Those results arrive as CONSECUTIVE user turns, not as one turn carrying
295
- // several blocks: across 45,062 real result-bearing turns, not one carried two
296
- // tool_results. Parallel tool calls show up as N single-result turns in a row
297
- // (4,087 occurrences). So `pending` must ACCUMULATE across consecutive result
298
- // turns — replacing it dropped every batch but the last, losing 9.1% of all
299
- // attributions, and left the proportional split below unreachable on real data.
295
+ // Those results arrive as CONSECUTIVE result lines, not as one turn carrying
296
+ // several blocks: in omp transcripts each tool result sits on its own line
297
+ // (measured on 176,027 results, recorded beside the reader in
298
+ // member-record.mjs). Parallel tool calls show up as N single-result lines in a
299
+ // row. So `pending` must ACCUMULATE across consecutive result lines —
300
+ // replacing it dropped every batch but the last, and left the proportional
301
+ // split below unreachable on real data.
300
302
  //
301
303
  // The proxy over-attributes slightly — that next turn also caches the assistant's
302
304
  // own preceding output — so treat these as shares, not absolutes. `resultChars`
@@ -44,12 +44,11 @@ const CONSECUTIVE = [
44
44
  ];
45
45
  const ORPHAN = [result(["orphan", 100]), { kind: "assistant", cacheWrite: 50, tools: [] }];
46
46
 
47
- test("CONSECUTIVE result turns accumulate — that, not the multi-block turn, is the real shape", () => {
48
- // Regression. Parallel tool calls do NOT arrive as one user turn carrying two
49
- // tool_result blocks: across 45,062 real result-bearing turns, none carried
50
- // two. They arrive as N single-result turns in a row (4,087 occurrences).
51
- // Replacing `pending` per result turn dropped every batch but the last —
52
- // 9.1% of all attributions — and made the proportional split above dead code.
47
+ test("CONSECUTIVE result entries accumulate — that, not one multi-result entry, is the real shape", () => {
48
+ // Regression: parallel tool calls arrive as N single-result entries in a row,
49
+ // not as one entry carrying several results (see the attributeTools comment).
50
+ // Replacing `pending` per result entry dropped every batch but the last, and
51
+ // made the proportional split unreachable.
53
52
  const tools = attributeTools(CONSECUTIVE);
54
53
  const by = Object.fromEntries(tools.map((t) => [t.tool, t.cacheWrite]));
55
54
  assert.equal(by.Read, 750);
@@ -358,8 +358,8 @@ const REGION_BLOCKS = [
358
358
  "**A subagent you dispatch gets its own directory under yours,",
359
359
  "`<scratch>/impl-<N>/<childName>/`, and you write that path, absolute, into its",
360
360
  "prompt — it writes nowhere else, and never derives a path of its own.**",
361
- "`<childName>` is a slug of the name you dispatch it under (omp's task `name`,",
362
- "Claude Code's `description`) — `[a-z0-9-]` only, never the raw field itself,",
361
+ "`<childName>` is a slug of the name you dispatch it under (omp's task `name`) —",
362
+ "`[a-z0-9-]` only, never the raw field itself,",
363
363
  "which can carry spaces and `/` and would split or nest the path in an",
364
364
  "unquoted shell command — unique among your children and never one of your",
365
365
  "own entries (`probe`, `mutate`, or any other name you write under",
@@ -373,7 +373,7 @@ test("the scratch-discipline block names the shared injected root, the per-membe
373
373
  // The name the path is keyed on, bound to what makes it collision-free.
374
374
  assert.match(
375
375
  b,
376
- phrase("`<childName>` is a slug of the name you dispatch it under (omp's task `name`, Claude Code's `description`) — `[a-z0-9-]` only, never the raw field itself, which can carry spaces and `/` and would split or nest the path in an unquoted shell command — unique among your children and never one of your own entries (`probe`, `mutate`, or any other name you write under `<scratch>/impl-<N>/` yourself), so no child shares a directory with a sibling or with you"),
376
+ phrase("`<childName>` is a slug of the name you dispatch it under (omp's task `name`) — `[a-z0-9-]` only, never the raw field itself, which can carry spaces and `/` and would split or nest the path in an unquoted shell command — unique among your children and never one of your own entries (`probe`, `mutate`, or any other name you write under `<scratch>/impl-<N>/` yourself), so no child shares a directory with a sibling or with you"),
377
377
  "`<childName>` is no longer defined as a slugged, own-entry-safe dispatch name unique among the parent's children",
378
378
  );
379
379
  });
@@ -0,0 +1,80 @@
1
+ // A reviewer that holds the dispatch tool still has to be TOLD the fan-out is
2
+ // requested work, or it declines to dispatch the specialists and the run
3
+ // silently downgrades to a thinner solo review. Two documents say so: run-team's
4
+ // "Authorize the fan-out explicitly" paragraph, and member-lifecycle.md's
5
+ // "Capability is not permission" section.
6
+ //
7
+ // What the rule rests on is the distinction itself — holding the tool is not
8
+ // authorization to use it — and not on any standing instruction an omp member
9
+ // inherits. The retired wording named such an instruction (`AgentTool`) and
10
+ // rested the rule on that inheritance; reverting either document to it left
11
+ // every other test green, because nothing read these two passages at all.
12
+ //
13
+ // Each passage is sliced to its own paragraph or section, never matched
14
+ // file-wide: both documents discuss the fan-out in many other places, and a
15
+ // file-wide match is satisfied by any of them while the rule's own wording is
16
+ // gutted. Every match goes through `phrase()`, which tolerates a rewrap of the
17
+ // same words — a pin that reds on a reflow discriminates nothing.
18
+ import { test } from "node:test";
19
+ import assert from "node:assert/strict";
20
+ import { readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { between, paragraph, phrase } from "./prose-pin.mjs";
23
+
24
+ const REPO = join(import.meta.dirname, "..");
25
+ const read = (...p) => readFileSync(join(REPO, ...p), "utf8");
26
+
27
+ const SKILL = read("skills", "run-team", "SKILL.md");
28
+ const LIFECYCLE = read("skills", "run-team", "references", "member-lifecycle.md");
29
+
30
+ // [label, passage, the sentence stating "holding the tool is not authorization",
31
+ // the sentence stating what the reviewer must be told]. The two documents word
32
+ // the same rule differently, so each carries its own spelling.
33
+ const PASSAGES = [
34
+ [
35
+ "run-team/SKILL.md's 'Authorize the fan-out explicitly' paragraph",
36
+ paragraph(SKILL, "Authorize the fan-out explicitly.", "run-team/SKILL.md"),
37
+ "holding the dispatch tool is not authorization to use it",
38
+ "State that the full specialist set IS the requested work",
39
+ ],
40
+ [
41
+ "member-lifecycle.md's 'Capability is not permission' section",
42
+ between(LIFECYCLE, "## Capability is not permission", "\n## ", "references/member-lifecycle.md"),
43
+ "Holding the dispatch tool does not authorize using it",
44
+ "reviewer prompt must state full specialist set IS requested work",
45
+ ],
46
+ ];
47
+
48
+ test("both fan-out passages say holding the dispatch tool is not authorization to use it", () => {
49
+ for (const [label, passage, notAuthorization] of PASSAGES) {
50
+ assert.match(
51
+ passage,
52
+ phrase(notAuthorization),
53
+ `${label} no longer says "${notAuthorization}" — without it a reviewer that holds the dispatch tool reads the fan-out as optional and silently downgrades to a thinner solo review`,
54
+ );
55
+ }
56
+ });
57
+
58
+ test("both fan-out passages tell the controller to state the full specialist set IS the requested work", () => {
59
+ for (const [label, passage, , mustState] of PASSAGES) {
60
+ assert.match(
61
+ passage,
62
+ phrase(mustState),
63
+ `${label} no longer says "${mustState}" — the controller is no longer told what the reviewer prompt must carry`,
64
+ );
65
+ }
66
+ });
67
+
68
+ test("neither fan-out passage rests the rule on a standing instruction the member inherits", () => {
69
+ // The retired premise, not a spelling of it: any restatement that grounds the
70
+ // rule in an inherited do-not-dispatch instruction is the claim this ticket
71
+ // removed, whichever tool name it uses. `AgentTool` and the verb `inherits`
72
+ // are the two ways it was written.
73
+ for (const [label, passage] of PASSAGES) {
74
+ assert.doesNotMatch(
75
+ passage,
76
+ /AgentTool|\binherit/i,
77
+ `${label} grounds the fan-out rule in an inherited standing instruction again — omp members inherit no such instruction; the rule is that holding the tool is not authorization to use it`,
78
+ );
79
+ }
80
+ });
@@ -60,7 +60,7 @@ test("both member-naming lists name every member, the finisher included", () =>
60
60
  assert.deepEqual(
61
61
  missing,
62
62
  [],
63
- `${label}'s member-naming list no longer names ${missing.join(", ")} — a controller reading it names that member something else, and a misnamed member loses the Agent tool with no error`,
63
+ `${label}'s member-naming list no longer names ${missing.join(", ")} — a controller reading it names that member something else, and a misnamed member is invisible to the ledger and unreachable by \`hub send\``,
64
64
  );
65
65
  }
66
66
  });
@@ -1,8 +1,8 @@
1
1
  // The member-telemetry adapter. ONE per-member
2
- // record shape, TWO readers a tree walk chooses by content, never by
3
- // caller-supplied config: board.mjs (the live spend panel) and
2
+ // record shape, ONE transcript reader (`foldOmpTranscript`, `readOmpMember`):
3
+ // board.mjs (the live spend panel) and
4
4
  // member-outcomes.mjs (the scraper) build on the primitives here rather than
5
- // each inlining a transcript layout, so the fold-back arithmetic, the cwd
5
+ // each inlining a transcript layout, so the per-turn fold, the cwd
6
6
  // encoder and the ticket/PR extraction each live in exactly one place.
7
7
  //
8
8
  // The record: harness, session, role, agent, model, thinking,
@@ -39,16 +39,16 @@ import { classifyRole, canonicalMemberName, CANONICAL_MEMBER_NAME_PREFIXES } fro
39
39
  // callers who need to encode a cwd that is not guaranteed to exist — every
40
40
  // caller but board.mjs's own live panel — pass their own resolver.
41
41
  //
42
- // Encodes the way the fleet's own tooling reads it back: every
43
- // non-alphanumeric character with `-`, so `-Users-x-claude` decodes to
44
- // `/Users/x/.claude`, not `-Users-x.claude`. Home-relative paths become
45
- // `~/...`-style (`-` under $HOME) joined by `-` with DOTS PRESERVED
46
- // (`.claude` -> `-.claude`); non-home paths are realpath-resolved (so `/tmp/x`,
42
+ // Encodes the way omp's own session directory naming does, by path SEGMENT:
43
+ // the segments are split on the path separator, empty ones dropped, and joined
44
+ // with `-`; every other character is kept, dots and underscores included.
45
+ // Home-relative paths become `-` plus their segments
46
+ // (`/Users/x/.claude/a_b` under home `/Users/x` -> `-.claude-a_b`);
47
+ // non-home paths are realpath-resolved (so `/tmp/x`,
47
48
  // a symlink to `/private/tmp/x` on macOS, encodes under the resolved name)
48
- // and double-dash-wrapped. Both forms are measured against real
49
- // `~/.omp/agent/sessions/*` directory names - a
50
- // `~/dev/fleet-plugin`, `--private-tmp-fx685-scratch--` all exist on disk
51
- // today.
49
+ // and wrapped in `--` (`/opt/a.b` -> `--opt-a.b--`). Both forms are measured
50
+ // against real `~/.omp/agent/sessions/*` directory names: `-dev-fleet-plugin`
51
+ // and `--private-tmp-fix685-scratch--` both exist on disk today.
52
52
  export function encodeProjectDir(cwd, { home = process.env.HOME, realpath = realpathSync } = {}) {
53
53
  const rel = relative(home, cwd);
54
54
  const isHome = !rel.startsWith("..") && !isAbsolute(rel);
@@ -188,7 +188,7 @@ function specialistPr(name) {
188
188
  // signals a corrupted or foreign file, not a harness to dispatch to.
189
189
  //
190
190
  // Both halves of that signature are checked, not just the blocklist half: a
191
- // line carrying neither Claude's keys NOR omp's own `type` field (e.g. a
191
+ // line carrying neither a `sessionId`/`parentUuid` key NOR omp's own `type` field (e.g. a
192
192
  // foreign/corrupted `{"foo":"bar"}`) used to sail past the blocklist-only
193
193
  // check below and fold into a fabricated all-null/zero member record instead
194
194
  // of the refusal this comment already promised. `type` is the one envelope
@@ -1271,12 +1271,10 @@ for d in "$HOME/.omp/agent/sessions/$PROJECT_DIR"/*/; do
1271
1271
  done
1272
1272
  ```
1273
1273
 
1274
- `encodeProjectDir` (`scripts/board.mjs`) encodes the cwd the way omp's own
1275
- session directory naming does — every non-alphanumeric character becomes
1276
- `-`, with a leading dot segment's dot PRESERVED rather than folded into the
1277
- dash run (`member-record.test.mjs`'s regression case: `.claude` becomes
1278
- `-.claude`, not `--claude`) — and hand-guessing that path is why the fleet's
1279
- own panel once rendered nothing here. The trailing `/` on the glob matters:
1274
+ `encodeProjectDir` (defined in `scripts/member-record.mjs`, re-exported by
1275
+ `scripts/board.mjs`) encodes the cwd exactly as omp names its session
1276
+ directories — see its comment for the rule — and hand-guessing that path is why
1277
+ the fleet's own panel once rendered nothing here. The trailing `/` on the glob matters:
1280
1278
  the same encoded-cwd directory also holds this cwd's own top-level session
1281
1279
  transcripts as loose FILES sibling to the per-session directories, and a
1282
1280
  glob without it would try to scrape one of those as if it were a session.
@@ -1737,10 +1735,9 @@ It blocks, then prints one line. Which line it is, is the whole protocol:
1737
1735
  Run the tick above **with `--fold-unchanged`** and act on what it prints.
1738
1736
  Then arm the beat again.
1739
1737
  - `… Ns of Ms remain → re-issue this command now, do not end your turn` — no
1740
- command blocks for a whole interval (omp backgrounds one at
1741
- 60s, which is why the
1742
- CI gate tells you to re-issue `gh run watch`). Issue it again. That is still
1743
- one blocking call per turn, never an idle turn.
1738
+ command blocks for a whole interval (omp backgrounds one at 60s, which is
1739
+ why the CI gate holds its wait in one `eval` cell polling `gh run view`).
1740
+ Issue it again. That is still one blocking call per turn, never an idle turn.
1744
1741
 
1745
1742
  The interval doubles while the fleet asks for nothing and stops at `--ceiling`,
1746
1743
  so a quiet night costs ~26 wakes instead of ~96. **The ceiling is a real bound,
@@ -2981,8 +2978,9 @@ its own survivors, so the zero is what tells the tick no fix-applier is owed —
2981
2978
  and it reaches a finisher through the same gate as any other PR.
2982
2979
 
2983
2980
  **Authorize the fan-out explicitly.** State that the full specialist set IS the
2984
- requested work — otherwise the reviewer inherits the standing "do not call the
2985
- AgentTool unless requested" and silently downgrades to a thinner solo review.
2981
+ requested work — holding the dispatch tool is not authorization to use it, and
2982
+ a reviewer not told so declines the fan-out and silently downgrades to a
2983
+ thinner solo review.
2986
2984
  Nothing computes the count here, so apply the heuristic yourself: two or three
2987
2985
  specialists for annotation-only or single-file, the full set for production code.
2988
2986
  See references/member-lifecycle.md.
@@ -1,6 +1,6 @@
1
1
  # Member lifecycle: naming, fresh context, recovery
2
2
 
3
- Why member name load-bearing, why every member single-use, how killed/idle/truncated member recovered. Assertions these justify live in SKILL.md's "Rules that fail silently", Phase 3, Reviewers, Failure handling sections; evidence here.
3
+ Why member name load-bearing, why every member single-use, how a member's job outcome (`completed`/`failed`/`cancelled`) and peer liveness (`running`/`idle`/`parked`) decide its recovery. Assertions these justify live in SKILL.md's "Rules that fail silently", Phase 3, Reviewers, Failure handling sections; evidence here.
4
4
 
5
5
  ## Every member is named
6
6
 
@@ -12,7 +12,7 @@ The name is the ledger token and the hub address (`hub send <name>`, `agent://<n
12
12
 
13
13
  ## Capability is not permission
14
14
 
15
- Members inherit standing *"Do not call the AgentTool unless the user requested it."* Else reviewer declines to dispatch specialists — correctly — you get thinner solo review, no error, no signal. Having tool (capability) not authorization to use it (permission); reviewer prompt must state full specialist set IS requested work.
15
+ Holding the dispatch tool does not authorize using it: a reviewer not told the fan-out is requested work declines to dispatch specialists, and you get thinner solo review, no error, no signal. Having tool (capability) not authorization to use it (permission); reviewer prompt must state full specialist set IS requested work.
16
16
 
17
17
  ## The coordination contract
18
18