@feigi/fleet-ctl 3.23.5 → 3.23.7
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/agents/fleet-implementer-slow-high.agent.md +2 -2
- package/agents/fleet-implementer-slow-medium.agent.md +2 -2
- package/agents/fleet-implementer-smol-high.agent.md +2 -2
- package/agents/fleet-implementer-task-high.agent.md +2 -2
- package/agents/fleet-implementer-task-max.agent.md +2 -2
- package/package.json +1 -1
- package/scripts/compute-spend.mjs +12 -10
- package/scripts/compute-spend.test.mjs +5 -6
- package/scripts/dispatch-block-golden-prose.test.mjs +2 -2
- package/scripts/dispatch-block-pins-prose.test.mjs +1 -1
- package/scripts/fan-out-authorization-prose.test.mjs +80 -0
- package/scripts/finisher-name-prose.test.mjs +1 -1
- package/scripts/member-record.mjs +13 -13
- package/scripts/reaping-reap-authorizers-prose.test.mjs +112 -0
- package/skills/run-team/SKILL.md +10 -12
- package/skills/run-team/references/member-lifecycle.md +2 -2
- package/skills/run-team/references/reaping.md +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
290
|
-
//
|
|
291
|
-
// result into the cache. When several results land before that
|
|
292
|
-
// split proportionally by result size, because that is what
|
|
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
|
|
295
|
-
// several blocks:
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
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
|
|
48
|
-
// Regression
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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,
|
|
3
|
-
//
|
|
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
|
|
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
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// (
|
|
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
|
|
49
|
-
// `~/.omp/agent/sessions/*` directory names -
|
|
50
|
-
//
|
|
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
|
|
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
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// reaping.md's reap section said reap.sh's `git update-ref -d` is
|
|
2
|
+
// "authorized by that cherry check and only by it". The script halts the
|
|
3
|
+
// delete on a second thing too: a fresh worktree check (`wt_holding`) over a
|
|
4
|
+
// listing re-read right before it, which keeps a `[gone]` branch the cherry
|
|
5
|
+
// check cleared whenever a worktree holds it or cannot be resolved. A sentence
|
|
6
|
+
// naming the cherry check as the only authorizer is false as written.
|
|
7
|
+
//
|
|
8
|
+
// The reap-side twin of reaping-release-authorizers-prose.test.mjs, which pins
|
|
9
|
+
// the release section's authorizer list. Kept apart so each section's pin can
|
|
10
|
+
// be edited independently.
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { between, bullet, phrase, unemphasized } from "./prose-pin.mjs";
|
|
16
|
+
|
|
17
|
+
const REPO = join(import.meta.dirname, "..");
|
|
18
|
+
const REAPING = readFileSync(
|
|
19
|
+
join(REPO, "skills", "run-team", "references", "reaping.md"),
|
|
20
|
+
"utf8",
|
|
21
|
+
);
|
|
22
|
+
const SCRIPT = readFileSync(join(REPO, "scripts", "reap.sh"), "utf8");
|
|
23
|
+
|
|
24
|
+
const reapSection = () =>
|
|
25
|
+
between(
|
|
26
|
+
REAPING,
|
|
27
|
+
"## Why the reap is shaped the way it is",
|
|
28
|
+
"## Why a second sweep, over worktrees rather than branches",
|
|
29
|
+
"reaping.md",
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// The bullet that names the delete, bounded to its own list item: it ends at
|
|
33
|
+
// the next list marker, so a sibling bullet (the holder-check bullet sits
|
|
34
|
+
// right after it) cannot supply the words, and rewording that sibling does
|
|
35
|
+
// not move the bound.
|
|
36
|
+
const deleteBullet = () =>
|
|
37
|
+
bullet(
|
|
38
|
+
reapSection(),
|
|
39
|
+
"- **The delete is `git update-ref -d`'s compare-and-swap",
|
|
40
|
+
"\n- ",
|
|
41
|
+
"reaping.md's reap section",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// The clause that lists the authorizers: from "authorized by" to the
|
|
45
|
+
// sentence after the bold lead, which explains the compare-and-swap. Sliced
|
|
46
|
+
// emphasis-tolerantly, and every assertion below reads it through
|
|
47
|
+
// `unemphasized()`, so a `**` moved onto or into the clause stays green: the
|
|
48
|
+
// slice only locates the clause, and `phrase()` is matched against raw text.
|
|
49
|
+
const authorizers = () =>
|
|
50
|
+
between(
|
|
51
|
+
deleteBullet(),
|
|
52
|
+
"authorized by that cherry check",
|
|
53
|
+
"Tip read before the cherry",
|
|
54
|
+
"reaping.md's reap delete bullet",
|
|
55
|
+
{ emphasisTolerant: true },
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
test("reaping.md: the reap delete's authorizer clause names the fresh worktree-holds-branch check", () => {
|
|
59
|
+
// ONE contiguous span, not keywords: "fresh" and the negation each
|
|
60
|
+
// satisfied somewhere in the slice pass a clause with either one gutted
|
|
61
|
+
// ("a check that no worktree holds the branch" has no freshness; "a fresh
|
|
62
|
+
// check ... that a worktree holds the branch" has the opposite meaning).
|
|
63
|
+
// Only a span binds the two to each other.
|
|
64
|
+
assert.match(
|
|
65
|
+
unemphasized(authorizers()),
|
|
66
|
+
phrase("a fresh check, re-read immediately before the delete, that no worktree holds the branch"),
|
|
67
|
+
"reaping.md's reap section names what authorizes the `update-ref` delete but not the fresh worktree check — reap.sh keeps a branch the cherry check cleared whenever a worktree holds it",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("reaping.md: the reap delete's authorizer clause does not make any one check the only authorizer", () => {
|
|
72
|
+
// The exclusivity the defect carried, in the spellings of it a reword can
|
|
73
|
+
// restore after the new words: "only by it", "only by them", "nothing
|
|
74
|
+
// else", "solely", "alone". Pinned on its own because the presence pin
|
|
75
|
+
// above stays green with any of them restored. A word list, so a spelling
|
|
76
|
+
// outside it goes unseen; `\s+` keeps a reflow between its words green.
|
|
77
|
+
assert.doesNotMatch(
|
|
78
|
+
unemphasized(authorizers()),
|
|
79
|
+
/\bonly\s+by\b|\bnothing\s+else\b|\bsolely\b|\balone\b/,
|
|
80
|
+
"reaping.md's reap section says one check alone authorizes the `update-ref` delete — reap.sh's worktree check stops that delete too",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("reaping.md: the reap delete's authorizer clause still names the cherry check", () => {
|
|
85
|
+
// Input the new assertions must ACCEPT: naming the worktree check must not
|
|
86
|
+
// cost the clause the check it already had.
|
|
87
|
+
assert.match(unemphasized(deleteBullet()), phrase("authorized by that cherry check"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// reap.sh's executable lines, comments and `echo` diagnostics dropped. The
|
|
91
|
+
// check's name and the delete command both recur in comments: an indexOf over
|
|
92
|
+
// the raw text lands on either and reads as the code, so a real delete moved
|
|
93
|
+
// above the check, or the check commented out, stays green. Only whole-line
|
|
94
|
+
// comments drop here, so each pattern below is anchored at the start of its
|
|
95
|
+
// statement: a command spelled after a `#` inside a live line is not live.
|
|
96
|
+
const liveLines = () => SCRIPT.split("\n").filter((l) => !/^\s*(?:#|echo\b)/.test(l));
|
|
97
|
+
|
|
98
|
+
// The one live line matching `re`. Exactly one: a first-hit search would
|
|
99
|
+
// bind the pin to whichever copy comes first.
|
|
100
|
+
const liveLineOf = (re, what) => {
|
|
101
|
+
const hits = liveLines().flatMap((l, i) => (re.test(l) ? [i] : []));
|
|
102
|
+
assert.equal(hits.length, 1, `reap.sh: expected exactly one live line that ${what}, found ${hits.length}`);
|
|
103
|
+
return hits[0];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
test("reap.sh: the worktree check the prose names sits before the delete", () => {
|
|
107
|
+
// The prose names a check the script runs; pin that the script still does,
|
|
108
|
+
// and runs it before the delete, so the prose cannot outlive the check.
|
|
109
|
+
const check = liveLineOf(/^\s*if\s+wt_holding\s+"refs\/heads\/\$b"/, "runs wt_holding on the branch");
|
|
110
|
+
const del = liveLineOf(/^\s*if\s+!\s+err=\$\(git\s+update-ref\s+--no-deref\s+-d\s+"refs\/heads\/\$b"\s+"\$tip"/, "runs `git update-ref -d` on the branch at its tip");
|
|
111
|
+
assert.ok(check < del, "reap.sh runs its worktree check after the delete, not before it");
|
|
112
|
+
});
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -1271,12 +1271,10 @@ for d in "$HOME/.omp/agent/sessions/$PROJECT_DIR"/*/; do
|
|
|
1271
1271
|
done
|
|
1272
1272
|
```
|
|
1273
1273
|
|
|
1274
|
-
`encodeProjectDir` (`scripts/
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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
|
-
|
|
1742
|
-
|
|
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 —
|
|
2985
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -22,7 +22,7 @@ Every precondition recomputed **inside** same command as delete:
|
|
|
22
22
|
- **`for-each-ref`, not `git branch | grep`.** `%(upstream:track)` emits exactly `[gone]` as own field. Nothing to pattern-match, no `-v`/`-vv` trap.
|
|
23
23
|
- **Recompute per branch, in this command.** Branch list from earlier tool call already false: one observed run listed 28 gone branches, two calls later 27 reaped by concurrent session. Benign direction is no-op; dangerous one is worktree that gained work *after* check.
|
|
24
24
|
- **`git cherry origin/main`, not `git diff main..`.** Against `origin/main` — local `main` you never fast-forwarded reads every merged branch as unmerged. Any `+` line is commit that exists nowhere else.
|
|
25
|
-
- **The delete is `git update-ref -d`'s compare-and-swap on a tip read once (ADR 0018), authorized by that cherry check and
|
|
25
|
+
- **The delete is `git update-ref -d`'s compare-and-swap on a tip read once (ADR 0018), authorized by that cherry check and by a fresh check, re-read immediately before the delete, that no worktree holds the branch.** Tip read before the cherry, cherry runs against that SHA, delete refuses unless ref still equals it — commit landing between check and delete is kept with git's own `cannot lock ref`, never force-deleted, as it was under `git branch -D` until #2219. `--no-deref`, so symref branch deletes the symref, not its target (measured). Branch's config section removed after, as `-D` did — left behind, re-created branch of same name inherits dead upstream and reads `[gone]` at once. `git branch -d` would refuse everything here: upstream gone, so falls back to comparing against `HEAD`, a possibly-behind local `main`. Never delete branch whose cherry output you did not just read.
|
|
26
26
|
- **A branch any worktree holds is a KEEP, checked right before the delete.** `update-ref` consults no worktree; `-D` did. So listing is re-read and worktree.sh's `wt_holding` asks: held by a `branch` line, or by detached worktree stopped mid `rebase -i` / mid `bisect` on it (admin dir's `head-name` / `BISECT_START`) — both routes `-D` refused on (#2218). Worktree it cannot resolve, or listing that will not re-read, is a KEEP. `--apply` only, like the delete: dry run cannot predict this refusal — not the same limitation `-D`'s dry run had, since an unrelated worktree elsewhere in the listing that this check cannot resolve keeps every `[gone]` branch in the pass (see below), a repo-wide case `-D` never produced.
|
|
27
27
|
- **A cherry that cannot answer is a KEEP.** Capture git's output; never pipe git itself: `cmd | grep -q` takes grep's exit status, never cmd's, so a `git cherry` that dies (rc 128 — one unreadable loose object is enough) prints nothing, grep exits 1, and a dead probe reads identical to "nothing unmerged" (verified, git 2.50.1). Unanswerable probe authorizes no delete — same fail-closed shape as `worktree remove` below. Read the `+` anchored, too: the capture folds stderr in, so an unanchored match reads a `+` in a diagnostic as a commit. Pinned by a test.
|
|
28
28
|
- **A worktree registry that cannot be read is a KEEP, for every `[gone]` branch in that pass.** The lookup decides whether the dirty, ignored-files and removal checks run at all; an unread registry reads identical to "no worktree", and `-D`, the delete then, needed no answer from the registry to delete a branch that has none — so failing open force-deleted merged branches blind (#622). The holder check before the delete asks only whether a worktree holds the branch, never those other questions, so the lookup still fails closed on its own. The listing is re-read per branch, so one repo-level failure keeps every `[gone]` branch in the pass, each with git's own cause. Reap runs after every merge pass, so the next one retries: nothing is lost, nothing is reaped blind.
|