Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,22 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename

## [Unreleased]

### Agent

- **Fleet authority tiers are now runtime-enforced, not documented in a prompt.**
Every director package carries a required `tier` (`orchestrator` /
`nested-orchestrator` / `leaf`): skywalker gets full fleet control, greybeard
(and any package with `spawn.maySpawn`) is scoped to its own subtree, and every
other director gets no fleet verbs at all. The gate lives in code
(`src/subagent/authority.ts`, wired into `runSubAgent`'s tool-mount point) and
fails closed: a caller whose tier cannot be resolved — including a
project-local or plugin agent profile with `orchestrator: true` that has not
explicitly opted in via `fleetTier: "nested-orchestrator"` — is denied
`task`/`search_agents` rather than silently trusted. This is the foundation
the next fleet-control verbs (spawn/list/steer a live agent) land against;
the subtree-scoping rule for those is written and tested but not yet wired to
a live call site. `task()` is unchanged and still the only spawn verb.

## [0.2.107] - 2026-08-24

### Agent
Expand Down
18 changes: 17 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,25 @@ When profiles exist (local `.agents/agents/` and/or enabled **`kind: "agent"`**

Profiles with `orchestrator: true` may themselves call `task` (one hop only): nested dispatch installs `task` + `search_agents` with `allowOrchestrator: false` so the tree bottoms out. Unknown `agent` ids fail closed.

#### Fleet authority tiers (`src/subagent/authority.ts`) (CL-6941)

Every director package carries a required `tier: SubagentTier` field (`src/agent/directors/types.ts`) — data on the package, never a prompt instruction:

| Tier | Who | Fleet surface |
| ------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 1 — `orchestrator` | skywalker (primary) | Full fleet control over the whole tree. |
| 2 — `nested-orchestrator` | greybeard, or any package with `spawn.maySpawn` | Same fleet surface, scoped to its own subtree: may manage only its own descendants, never a sibling or ancestor. |
| 3 — `leaf` | every other director | No fleet verbs at all. |

Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing:

- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier`, or an explicit non-leaf `AgentProfile.fleetTier` opt-in for a profile-sourced orchestrator — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing `task` / `search_agents`, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — it must also declare `fleetTier: "nested-orchestrator"`, or the mount throws `FleetAuthorityError`. (`AgentProfile.fleetTier` is deliberately not named `tier` — that name is already used, ad hoc, by some profiles for an unrelated model-speed selector; reusing it would have silently broken schema validation for those profiles, which is exactly what happened during review and was caught by the full test suite, not by inspection.) `FLEET_VERBS` in `authority.ts` also names the not-yet-implemented verbs (`spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `followup_task`) so their future mount sites inherit the same gate.
- **Subtree authority — a seam, not yet wired.** `assertCanTargetAgent(actor, targetId, nodes)` (`src/subagent/authority.ts`) implements the "root owns its tree; a child manages only its own descendants" rule (Tier 1 may target anyone, Tier 2 may target only its own descendants over the same `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks, Tier 3 always fails closed) — but **it has no production call site yet**. No verb today lets one live agent address another (`task` only spawns), so this rule is exercised only by `authority.test.ts` and is not enforced at runtime in this PR. It exists so CL-6942 (split spawn from wait) and CL-6944 (`send_input` steering) — the first verbs that make an agent addressable by another — can call it from day one instead of each inventing its own check. Treat it as unenforced until one of those wires a call site.
- `task()` is unaffected and remains the only spawn verb until the new verbs land beside it (deprecated-not-deleted per the CL-6940 epic). Its argument schema and wire contract are unchanged; the tier check only gates which packages may have it mounted at all.

#### Closed director fleet (`src/agent/directors/`)

Every shipped specialist is a **director package** — a prompt-first `DirectorPackage` (system prompt, tool envelope, spawn rights, nudge budget, report contract, `modelRole`) registered in a **closed** set of 16 ids. There is no catch-all worker: `task` without `agent` or non-general `intent`, and `task(intent="general")`, fail closed so the primary reclassifies. Nested directors with a spawn allowlist reject off-list children at `createTaskTool` (not prompt-only). Skywalker is the primary session identity: `task(agent="skywalker")` is refused, and `directorProfiles()` omits it from the spawn catalog.
Every shipped specialist is a **director package** — a prompt-first `DirectorPackage` (system prompt, tool envelope, spawn rights, nudge budget, report contract, `modelRole`, fleet authority `tier`) registered in a **closed** set of 16 ids. There is no catch-all worker: `task` without `agent` or non-general `intent`, and `task(intent="general")`, fail closed so the primary reclassifies. Nested directors with a spawn allowlist reject off-list children at `createTaskTool` (not prompt-only). Skywalker is the primary session identity: `task(agent="skywalker")` is refused, and `directorProfiles()` omits it from the spawn catalog.

**Primary**

Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/brand-reviewer/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const brandReviewerPackage: DirectorPackage = {
description: "DESIGN.md brand gate",
tools: { allow: DOCS_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 40 },
modelRole: "docs",
systemPrompt: `You are BrandReviewerDirector, a specialist in Corbits Code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/bruckheimer/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const bruckheimerPackage: DirectorPackage = {
description: "Product discovery specialist — user/product shape docs, not code",
tools: { allow: DOCS_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 40 },
modelRole: "docs",
systemPrompt: `You are BruckheimerDirector, a specialist in Corbits Code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/build/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const buildDirectorPackage: DirectorPackage = {
optionalSkills: ["style", "philosophy", "typescript"],
tools: { allow: BUILD_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 60 },
modelRole: "implement",
systemPrompt: `You are BuildDirector, a specialist in Corbits Code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/critique/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const critiquePackage: DirectorPackage = {
optionalSkills: ["style", "philosophy"],
tools: { allow: REVIEW_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 45 },
modelRole: "review",
systemPrompt: `You are CritiqueDirector, a specialist in Corbits Code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/draper/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const draperPackage: DirectorPackage = {
// Read-only critique — product write tools not mounted.
tools: { allow: REVIEW_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 40 },
modelRole: "review",
systemPrompt: `You are DraperDirector, a specialist in Corbits Code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/emil/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const emilPackage: DirectorPackage = {
// Critique only — write tools not mounted.
tools: { allow: REVIEW_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 40 },
modelRole: "review",
systemPrompt: `You are EmilDirector, a specialist in Corbits Code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/explore/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ FINDINGS SHAPE: Findings must be a scannable map — key paths, symbols, call fl
OUT OF LANE → report Blockers naming the right director: build, plan, critique, greybeard, intern.`,
tools: { allow: READ_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 35 },
modelRole: "explore",
};
1 change: 1 addition & 0 deletions src/agent/directors/gaasbot/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const gaasbotPackage: DirectorPackage = {
optionalSkills: ["philosophy"],
tools: { allow: REVIEW_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 35 },
modelRole: "plan",
systemPrompt: `You are GaasbotDirector, a specialist in Corbits Code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/greybeard/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const greybeardPackage: DirectorPackage = {
},
nudge: { maxTurns: 50 },
modelRole: "review",
tier: "nested-orchestrator",
systemPrompt: `You are GreybeardDirector, a specialist in Corbits Code.

PRIMARY INTENT: architecture review. Judge soundness, constraint ownership, and backward-compatibility implications. Do not fix or ship product code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/intern/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const internPackage: DirectorPackage = {
optionalSkills: [],
tools: { allow: INTERN_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 20 },
modelRole: "implement",
systemPrompt: `You are InternDirector, a specialist in Corbits Code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/neckbeard/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const neckbeardPackage: DirectorPackage = {
optionalSkills: ["style", "philosophy"],
tools: { allow: REVIEW_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 40 },
modelRole: "review",
systemPrompt: `You are NeckbeardDirector, a specialist in Corbits Code.
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/plan/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const planPackage: DirectorPackage = {
optionalSkills: ["style", "philosophy", "interview"],
tools: { allow: REVIEW_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 40 },
modelRole: "plan",
systemPrompt: `You are PlanDirector, a specialist in Corbits Code.
Expand Down
15 changes: 15 additions & 0 deletions src/agent/directors/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
listDirectors,
packageToProfile,
resolveDirector,
tierForDirectorId,
} from "./registry.js";

describe("director registry", () => {
Expand Down Expand Up @@ -170,6 +171,20 @@ describe("director registry", () => {
expect(s.spawn.allowlist).toHaveLength(15);
});

// CL-6941: tier and spawn.maySpawn independently encode "may this package
// spawn", hand-set across 16 files. This pins their agreement so drift
// (adding maySpawn: true without bumping tier, or vice versa) fails a test
// instead of surfacing as an unexplained FleetAuthorityError at dispatch.
test("tier agrees with spawn.maySpawn for every director", () => {
for (const id of DIRECTOR_IDS) {
const pkg = DIRECTOR_REGISTRY[id];
expect(pkg.tier !== "leaf").toBe(pkg.spawn.maySpawn);
expect(tierForDirectorId(id)).toBe(pkg.tier);
}
expect(DIRECTOR_REGISTRY.skywalker.tier).toBe("orchestrator");
expect(DIRECTOR_REGISTRY.greybeard.tier).toBe("nested-orchestrator");
});

test("every director profile declares matching agent id in system prompt", () => {
for (const id of DIRECTOR_IDS) {
const profile = packageToProfile(DIRECTOR_REGISTRY[id]);
Expand Down
6 changes: 6 additions & 0 deletions src/agent/directors/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type DirectorPackage,
type ResolveDirectorInput,
type ResolveDirectorResult,
type SubagentTier,
type TaskIntent,
} from "./types.js";

Expand Down Expand Up @@ -61,6 +62,11 @@ export function isDirectorId(value: unknown): value is DirectorId {
return typeof value === "string" && (DIRECTOR_IDS as readonly string[]).includes(value);
}

/** Fleet authority tier for a closed director id, or undefined for non-director profiles. */
export function tierForDirectorId(id: string): SubagentTier | undefined {
return isDirectorId(id) ? DIRECTOR_REGISTRY[id].tier : undefined;
}

export function listDirectors(): readonly DirectorPackage[] {
return DIRECTOR_IDS.map((id) => DIRECTOR_REGISTRY[id]);
}
Expand Down
1 change: 1 addition & 0 deletions src/agent/directors/shakespeare/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const shakespearePackage: DirectorPackage = {
optionalSkills: ["style", "philosophy"],
tools: { allow: DOCS_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 50 },
modelRole: "docs",
};
1 change: 1 addition & 0 deletions src/agent/directors/skywalker/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,5 @@ export const skywalkerPackage: DirectorPackage = {
},
nudge: { maxTurns: 100 },
modelRole: "orchestrator",
tier: "orchestrator",
};
1 change: 1 addition & 0 deletions src/agent/directors/tester/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ If tests fail: document failures, suspected area, and blockers. Suggest a re-dis
OUT OF LANE: fixing product code, "just quickly" fixing, redesigning the whole suite as Testsmith's primary job, fleet orchestration.`,
tools: { allow: READ_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 40 },
modelRole: "test",
};
1 change: 1 addition & 0 deletions src/agent/directors/testsmith/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ OUT OF LANE: fixing production code, becoming the implementer, running the full
Read and search the codebase to ground the design; you have no product-mutation tools.`,
tools: { allow: READ_TOOLS },
spawn: { maySpawn: false },
tier: "leaf",
nudge: { maxTurns: 40 },
modelRole: "test",
};
14 changes: 14 additions & 0 deletions src/agent/directors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ export type DirectorId = (typeof DIRECTOR_IDS)[number];

export type TaskIntent = "explore" | "implement" | "plan" | "review" | "general";

/**
* Fleet authority tier (CL-6941). Runtime-enforced at the tool-mount point in
* subagent/run.ts and by subagent/authority.ts — never by prompt wording.
*
* - "orchestrator": Tier 1, primary (skywalker). Full fleet control over the
* whole tree.
* - "nested-orchestrator": Tier 2, scoped to its own subtree (e.g. greybeard).
* May manage only its own descendants, never siblings or ancestors.
* - "leaf": Tier 3, worker bee. No fleet verbs at all.
*/
export type SubagentTier = "orchestrator" | "nested-orchestrator" | "leaf";

/** Static model-role tag for CL-5816 stub resolution (not a full package yet). */
export type ModelRole =
"orchestrator" | "implement" | "explore" | "review" | "plan" | "docs" | "test";
Expand Down Expand Up @@ -67,6 +79,8 @@ export interface DirectorPackage {
readonly spawn: SpawnRights;
readonly nudge?: NudgePolicy;
readonly modelRole: ModelRole;
/** Fleet authority tier — data on the package, gated at mount, not prose. */
readonly tier: SubagentTier;
}

export interface ResolveDirectorInput {
Expand Down
12 changes: 12 additions & 0 deletions src/agent/profile-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ export interface AgentProfile {
// coordinators (e.g. a planning agent that fans work out to specialists);
// leaf-task agents should leave this unset.
orchestrator?: boolean;
// Fleet authority tier (CL-6941) for a profile-sourced orchestrator. Only
// meaningful alongside orchestrator: true. Named `fleetTier`, not `tier` —
// `tier` is already an established profile field for model speed selection
// ("fast" | "standard" | "clever", resolved via task(tier=...)); reusing
// the name silently broke schema validation for profiles that set it. A
// profile is outside the closed director set, so it is NOT trusted with
// fleet verbs by default even when orchestrator: true is set — this must
// be declared explicitly as "nested-orchestrator" to opt in.
// Runtime-enforced at the tool-mount point (src/subagent/run.ts /
// src/subagent/authority.ts): an orchestrator=true profile with no
// fleetTier (or fleetTier: "leaf") is denied task/search_agents, fail-closed.
fleetTier?: "orchestrator" | "nested-orchestrator" | "leaf";
// Optional inference-turn budget when this profile is dispatched via task(agent=...).
// Floor-sanitized (≥1) at dispatch time; task(maxTurns) overrides when set.
maxTurns?: number;
Expand Down
1 change: 1 addition & 0 deletions src/agent/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const AgentProfileSchema = type({
"systemPromptRole?": "string",
"systemPromptPath?": "string",
"orchestrator?": "boolean",
"fleetTier?": "'orchestrator' | 'nested-orchestrator' | 'leaf'",
"maxTurns?": "number",
});

Expand Down
78 changes: 78 additions & 0 deletions src/subagent/authority.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test";
import {
assertCanTargetAgent,
assertTierMayMountFleetVerb,
FleetAuthorityError,
isFleetVerb,
} from "./authority.js";

describe("assertTierMayMountFleetVerb", () => {
test("a Tier 3 leaf cannot obtain a fleet verb", () => {
expect(() => assertTierMayMountFleetVerb("leaf", "task")).toThrow(FleetAuthorityError);
expect(() => assertTierMayMountFleetVerb("leaf", "search_agents")).toThrow(FleetAuthorityError);
expect(() => assertTierMayMountFleetVerb("leaf", "spawn_agent")).toThrow(FleetAuthorityError);
});

test("leaves may still mount non-fleet tools", () => {
expect(() => assertTierMayMountFleetVerb("leaf", "read_file")).not.toThrow();
});

test("Tier 1 and Tier 2 may mount fleet verbs", () => {
expect(() => assertTierMayMountFleetVerb("orchestrator", "task")).not.toThrow();
expect(() => assertTierMayMountFleetVerb("nested-orchestrator", "task")).not.toThrow();
});

test("isFleetVerb matches the same set used for the gate", () => {
expect(isFleetVerb("task")).toBe(true);
expect(isFleetVerb("write_file")).toBe(false);
});
});

describe("assertCanTargetAgent", () => {
// Tree: skywalker(root) -> greybeard -> intern
// -> build (sibling of greybeard)
const nodes = [
{ id: "skywalker-session" },
{ id: "greybeard-session", parentSessionId: "skywalker-session" },
{ id: "intern-session", parentSessionId: "greybeard-session" },
{ id: "build-session", parentSessionId: "skywalker-session" },
];

test("Tier 1 primary orchestrator can target anyone in the tree", () => {
const skywalker = { id: "skywalker-session", tier: "orchestrator" as const };
expect(() => assertCanTargetAgent(skywalker, "greybeard-session", nodes)).not.toThrow();
expect(() => assertCanTargetAgent(skywalker, "intern-session", nodes)).not.toThrow();
expect(() => assertCanTargetAgent(skywalker, "build-session", nodes)).not.toThrow();
});

test("Tier 2 nested orchestrator can target its own descendant", () => {
const greybeard = { id: "greybeard-session", tier: "nested-orchestrator" as const };
expect(() => assertCanTargetAgent(greybeard, "intern-session", nodes)).not.toThrow();
});

test("Tier 2 nested orchestrator can target itself", () => {
const greybeard = { id: "greybeard-session", tier: "nested-orchestrator" as const };
expect(() => assertCanTargetAgent(greybeard, "greybeard-session", nodes)).not.toThrow();
});

test("Tier 2 nested orchestrator cannot target a sibling", () => {
const greybeard = { id: "greybeard-session", tier: "nested-orchestrator" as const };
expect(() => assertCanTargetAgent(greybeard, "build-session", nodes)).toThrow(
FleetAuthorityError,
);
});

test("Tier 2 nested orchestrator cannot target an ancestor", () => {
const greybeard = { id: "greybeard-session", tier: "nested-orchestrator" as const };
expect(() => assertCanTargetAgent(greybeard, "skywalker-session", nodes)).toThrow(
FleetAuthorityError,
);
});

test("Tier 3 leaf cannot target any agent, even itself", () => {
const intern = { id: "intern-session", tier: "leaf" as const };
expect(() => assertCanTargetAgent(intern, "intern-session", nodes)).toThrow(
FleetAuthorityError,
);
});
});
Loading
Loading