diff --git a/.claude/agents/hunter.md b/.claude/agents/hunter.md new file mode 100644 index 0000000..9ef1dd7 --- /dev/null +++ b/.claude/agents/hunter.md @@ -0,0 +1,23 @@ +--- +name: hunter +description: Sweep one assigned scope for defects and report ranked findings without changes. +--- + + + +You sweep the assigned package or scope for real defects. Read the full assigned scope before you +judge it. Report several ranked findings when the evidence supports them. Change nothing: do not +edit code, commit, push, or open a pull request. + +You may run the build or tests to check a finding. Read the complete output and report the real +result. Do not hide failures with a pipe. State only checks you actually ran. The primary's IDE +tools are not yours. A mounted forge tool may use a blocked credential and fail by design. + +Do only the assigned scope. Note anything outside it in one line and do not investigate it further. +Use `fleet_ask{question}` only when a decision belongs to the lead, such as an unclear requirement +or two defensible fixes. Do not ask about something you can decide by reading more code. + +Your handoff must name the files you read, each ranked finding or `NO FINDINGS`, the checks you ran, +and any caveat for review. + +The launcher provides the required bridge reply instructions for every member. diff --git a/CLAUDE.md b/CLAUDE.md index 0aee690..c57ce6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -284,6 +284,8 @@ must obey belongs in the charter, not here. it for a multi-finding sweep hands the worker two contradictory output contracts. That has already cost three workers' turns: each wrote a good report to its terminal and ended the turn with no `fleet_reply`, and the scrape returned the tail of the brief instead. + Spawn `implementer` with role `dev`, `reviewer` with role `reviewer`, and `hunter` with role + `hunter`. - **Primary-side skills** (not delegation playbooks — a worker cannot use them): `port-to-opencode` (make an OpenCode session a participant in this workspace), `fleets-status` (report every fleet that shares one LavinMQ instance), diff --git a/fleetd/fleetd.example.yaml b/fleetd/fleetd.example.yaml index 2097fcf..ae16542 100644 --- a/fleetd/fleetd.example.yaml +++ b/fleetd/fleetd.example.yaml @@ -560,7 +560,7 @@ placement: weighted # older keys: `leaders:`, `members:`, `leadScan:` and `defaultProfile:`. # # A member is anything a lead spawns, and every member has two INDEPENDENT attributes: -# role — which contract: architect, dev or reviewer. It picks the launch charter, the role +# role — which contract: architect, dev, hunter or reviewer. It picks the launch charter, the role # file, the playbook skill and the authz row. # profile — which backend: one of the `profiles:` keys above (model, CLI adapter, cost). # They vary on their own. A reviewer may run on the same profile as the dev whose diff it reads, @@ -572,13 +572,13 @@ placement: weighted # # Each pool lists the profiles that role MAY run on — these are pools, not identities. That is also # what replaced `defaultProfile:`: an unqualified spawn names a role, and that role's pool supplies -# the candidates, in definition order. A dev and a reviewer staying anonymous is exactly compatible -# with being listed here; the entry key just names the entry. +# the candidates, in definition order. A dev, hunter and reviewer staying anonymous is exactly +# compatible with being listed here; the entry key just names the entry. fleet: # Optional launch-charter text, keyed only by the singular role wire names: architect, dev, - # reviewer. Changes are HOT and reach the next spawn without a daemon restart. Do not put secrets - # here: a later launch step writes this text to a world-readable temp file, and ${ENV} interpolation - # is deliberately not supported. + # hunter, reviewer. Changes are HOT and reach the next spawn without a daemon restart. Do not put + # secrets here: a later launch step writes this text to a world-readable temp file, and ${ENV} + # interpolation is deliberately not supported. charters: architect: |- You are an architect in this fleet. You refine work before anyone builds it: @@ -589,6 +589,9 @@ fleet: dev: |- You implement the one unit you were given, and nothing else. You test it, commit it, and open your own pull request. You never merge. + hunter: |- + You sweep the assigned scope for real defects. You may run the build or tests + to check a finding. You change nothing, and report several ranked findings. reviewer: |- You review the diff you were given. You report bugs, risks and missing tests. You do not change code. @@ -666,6 +669,9 @@ fleet: developers: gx10: profile: gx10 + # hunters: + # gx10: + # profile: gx10 # a hunt may run checks, but never changes code # reviewers: # gx10: # profile: gx10 # the same backend may serve two roles; that is the point diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java b/fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java index 8658cc4..300678b 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/CallerResolver.java @@ -221,7 +221,7 @@ public final class CallerResolver { // The config/live binding names this pane as an architect slot's own. Same // unforgeable pane mapping; the live binding, never a request argument, decides. // Check the slot role too: this defence in depth prevents a bad lifecycle bind from - // escalating a dev or reviewer into an architect. Checked before the worker fallback. + // escalating a dev, hunter, or reviewer into an architect. Checked before the worker fallback. return Principal.architect(memberSlotNames.apply(slot), c.terminal(), c.pid()); } return Principal.worker(c.terminal(), c.pid()); // unforgeable; never token-gated diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java index e81f925..af93b8c 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberLifecycle.java @@ -42,7 +42,7 @@ public interface MemberLifecycle { * Try to bind a newly spawned {@code terminal} into the role it was granted. * * @return the role this session actually holds: {@code role} unchanged for a role with no - * live slot-binding semantics (dev, reviewer), or when the bind succeeded; a fallback + * live slot-binding semantics (dev, hunter, reviewer), or when the bind succeeded; a fallback * role — never {@code role} — when a slot-bound role (architect) could not be bound. * Callers must record THIS value on the session, never the requested {@code role}, so * a later roster read never reports a role the session does not hold (CB-619). In diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java index 5adb19e..200858e 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/MemberRegistry.java @@ -20,7 +20,8 @@ import java.util.function.Supplier; * *
Two halves, split by who owns each: *
Only the four pools directly under the top-level {@code fleet:} are considered, + *
Only the five pools directly under the top-level {@code fleet:} are considered,
* and only their direct child keys (the slot names). A nested field elsewhere, even one also
* named {@code developers:}, is ignored, so parsing of the rest of the config is unaffected.
*
@@ -2050,7 +2060,7 @@ public record FleetConfig(
+ " and that role's pool supplies the candidate profiles",
"architects", "'fleet.architects'",
"members", "a role pool under 'fleet:' — 'fleet.architects', 'fleet.developers' or"
- + " 'fleet.reviewers'; the role is the containing key, not a 'role:' field",
+ + " 'fleet.hunters' or 'fleet.reviewers'; the role is the containing key, not a 'role:' field",
"leaders", "'fleet.leaders'",
"leadScan", "'fleet.leaders. Reads the repo and writes analysis. Never commits code and never opens a pull request —
* an architect that starts implementing has stopped doing the job that makes it useful.
*
- * Architects are the one member kind declared in config, because a lead addresses the same
- * slots across many tickets and needs a stable name for them.
+ * Architects are the one member kind with live slot binding, because a lead addresses the
+ * same slots across many tickets and needs a stable name for them.
*/
ARCHITECT,
@@ -43,6 +43,14 @@ public enum MemberRole {
*/
DEV,
+ /**
+ * Sweeps an assigned package for defects and reports several ranked findings.
+ *
+ * Never changes code, commits, or opens a pull request. A hunt gathers evidence, which can
+ * include running the build, but leaves every fix to a later implementation unit.
+ */
+ HUNTER,
+
/**
* Reviews a diff it did not write and reports one structured finding.
*
@@ -59,7 +67,7 @@ public enum MemberRole {
/**
* The {@code fleet:} block that holds this role's pool — {@code architects},
- * {@code developers}, {@code reviewers}.
+ * {@code developers}, {@code hunters}, {@code reviewers}.
*
* Plural, and not always the wire name: the pool of things a {@code dev} may run on reads
* naturally as {@code developers:}. The wire name stays the singular {@code dev}, because that
@@ -69,6 +77,7 @@ public enum MemberRole {
return switch (this) {
case ARCHITECT -> "architects";
case DEV -> "developers";
+ case HUNTER -> "hunters";
case REVIEWER -> "reviewers";
};
}
diff --git a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
index 0e2ad93..4cfbf64 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/config/FleetConfigTest.java
@@ -345,7 +345,7 @@ class FleetConfigTest {
IllegalStateException unknownError = assertThrows(IllegalStateException.class,
() -> FleetConfig.load(unknown).validateCharters());
assertTrue(unknownError.getMessage().contains("architetc"));
- assertTrue(unknownError.getMessage().contains("[architect, dev, reviewer]"));
+ assertTrue(unknownError.getMessage().contains("[architect, dev, hunter, reviewer]"));
}
/**
@@ -812,13 +812,17 @@ class FleetConfigTest {
reviewers:
b:
profile: sonnet
+ hunters:
+ c:
+ profile: sonnet
""");
FleetConfig cfg = FleetConfig.load(f);
assertEquals(List.of("sonnet"), cfg.fleet().profilesFor(MemberRole.DEV));
+ assertEquals(List.of("sonnet"), cfg.fleet().profilesFor(MemberRole.HUNTER));
assertEquals(List.of("sonnet"), cfg.fleet().profilesFor(MemberRole.REVIEWER));
assertTrue(cfg.fleet().profilesFor(MemberRole.ARCHITECT).isEmpty());
- assertEquals(List.of(MemberRole.DEV, MemberRole.REVIEWER), cfg.fleet().rolesConfigured());
+ assertEquals(List.of(MemberRole.DEV, MemberRole.HUNTER, MemberRole.REVIEWER), cfg.fleet().rolesConfigured());
}
/** The case the two axes exist for: one backend, two roles, and neither is a duplicate. */
diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java
index 63e3097..c0263d7 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpTest.java
@@ -1937,7 +1937,7 @@ class FleetMcpTest {
null, null, null, null, null, null);
assertEquals(Boolean.TRUE, res.isError());
- assertTrue(textOf(res).contains("architect, dev, reviewer"), textOf(res));
+ assertTrue(textOf(res).contains("architect, dev, hunter, reviewer"), textOf(res));
}
// ── CB-619 / fleetd #123: a spawn asking for a role its profile has no slot for must be
diff --git a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
index 592344a..efc8ffd 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/member/ClaudeCodeLauncherTest.java
@@ -556,6 +556,31 @@ class ClaudeCodeLauncherTest {
"no --agent flag when the role has no agent-definition file");
}
+ @Test
+ void hunterRoleUsesItsAgentFileAndStopsUsingItWhenRemoved(@TempDir Path cwd) throws Exception {
+ FakeHerdr herdr = new FakeHerdr();
+ Path agentFile = Files.createDirectories(cwd.resolve(".claude/agents")).resolve("hunter.md");
+ Files.writeString(agentFile, "---\nname: hunter\n---\nSweep for defects.");
+ FleetConfig.Profile cfg = new FleetConfig.Profile(
+ "sonnet", "http://gx00.gw:8000", null, null, "FLEETD_WORKER_TOKEN",
+ List.of("claude"), "tab", "fleetd-workers", "w #{n}", null, null, null);
+ ClaudeCodeLauncher svc = new ClaudeCodeLauncher(new AgentControl(herdr), new WorkspaceControl(herdr),
+ new SubscriptionGuard(Set.of("gx00.gw")), Map.of(cfg.profile(), cfg), cfg.profile(), _ -> null);
+
+ svc.spawn(new SpawnRequest("sonnet", cwd.toString(), null, null, null, MemberRole.HUNTER));
+
+ List