Skip to content

Commit 8c666ab

Browse files
Merge pull request #496 from corbitsdev/cl-6630-exec-and-capability-evals-can-run-as-a-chosen-primary
Add an exec flag to run as a chosen director
2 parents addf42f + 321c481 commit 8c666ab

8 files changed

Lines changed: 202 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
4545
write-free. Shell file-writes stay denied. Spawn is a judgment call,
4646
not a tool ban.
4747

48+
- **Exec and capability evals can run as a chosen primary director.**
49+
`corbits exec --director <id>` (and eval `--director`) overlays that
50+
package's system prompt and tool allowlist on the product exec path.
51+
Omit / skywalker keep the default Skywalker session. Directors that
52+
cannot spawn (for example implement) do not mount `task`. This is an
53+
exec/eval/CI override, not a TUI or single-agent mode.
54+
4855
## [0.2.99] - 2026-08-21
4956

5057
Skywalker is the primary orchestrator over a closed director fleet: product write tools stay off the primary, and you cannot spawn Skywalker as a task leaf. Workers are not done until they return the four-heading report. First-party action skills ship as slashes; eval runners require an explicit provider/model pair; the style skill no longer refuses non-git folders.

evals/capability/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ bun run eval:capability -- --provider <name> --model <id> \
132132
bun run eval:capability -- --provider <name> --model <id> --repeats 5 \
133133
--out evals/capability/results/candidate.json \
134134
--baseline evals/capability/results/baseline-0286.json
135+
136+
# Overlay a closed-fleet director on the product exec path (eval/CI override,
137+
# not single-agent mode). Omit / skywalker keep the default Skywalker session.
138+
bun run eval:capability -- --provider <name> --model <id> --director implement
135139
```
136140

137141
## Confirmation gate for behavior changes
@@ -170,6 +174,7 @@ Flags:
170174
| `--repeats <n>` | Runs per case×variant cell (default `1`; gate runs use `5`, baseline freezes `3`). Results record every repeat plus per-cell aggregates |
171175
| `--concurrency <n>` | Independent case×variant×repeat cells in parallel (default `1`, env `CORBITS_EVAL_CONCURRENCY`). Each cell still uses its own temp workdir. Use `--concurrency 4` (or similar) to run a live matrix faster |
172176
| `--dry-run` | Load cases × variants and print plan; no inference. Still requires `--provider`/`--model` or `--matrix` |
177+
| `--director <id>` | Exec overlay: run the product `corbits exec` path as this closed-fleet director (default: skywalker). Eval/CI override, not single-agent mode. Directors that cannot spawn (for example `implement`) do not mount `task`. |
173178

174179
## Case format
175180

scripts/eval-capability.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,22 @@ describe("parseArgs", () => {
118118
/CORBITS_EVAL_CONCURRENCY must be a positive integer/,
119119
);
120120
});
121+
122+
test("--director implement is parsed", () => {
123+
const opts = parseArgs(["--provider", "foo", "--model", "bar", "--director", "implement"]);
124+
expect(opts.director).toBe("implement");
125+
});
126+
127+
test("omitted --director stays undefined", () => {
128+
const opts = parseArgs(["--provider", "foo", "--model", "bar"]);
129+
expect(opts.director).toBeUndefined();
130+
});
131+
132+
test("--director without a value throws", () => {
133+
expect(() => parseArgs(["--provider", "foo", "--model", "bar", "--director"])).toThrow(
134+
"--director requires a value",
135+
);
136+
});
121137
});
122138

123139
describe("mapPool", () => {
@@ -150,6 +166,7 @@ describe("mapPool", () => {
150166
test("rejects non-positive concurrency", async () => {
151167
await expect(mapPool([1], 0, async (item) => item)).rejects.toThrow(/positive integer/);
152168
});
169+
153170
});
154171

155172
describe("initEvalGitRepo", () => {

scripts/eval-capability.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ type CliOptions = {
7777
* differs from what was requested, instead of hard-failing.
7878
*/
7979
allowProviderFallback: boolean;
80+
/**
81+
* Exec overlay: run the product path as this closed-fleet director.
82+
* Eval/CI override, not single-agent mode. Omitted = skywalker default.
83+
*/
84+
director?: string;
8085
};
8186

8287
function printUsage(): void {
@@ -99,6 +104,8 @@ function printUsage(): void {
99104
--dry-run List cases × variants only (still requires --provider/--model or --matrix)
100105
--allow-provider-fallback Allow resolved provider/model to differ from
101106
what was requested (default: hard-fail)
107+
--director <id> Exec overlay: run as this director (default: skywalker).
108+
Eval/CI override, not single-agent mode
102109
-h, --help Show help
103110
`);
104111
}
@@ -230,6 +237,9 @@ export function parseArgs(argv: readonly string[]): CliOptions {
230237
case "--allow-provider-fallback":
231238
opts.allowProviderFallback = true;
232239
break;
240+
case "--director":
241+
opts.director = next();
242+
break;
233243
default:
234244
throw new Error(`Unknown argument: ${a}`);
235245
}
@@ -608,6 +618,7 @@ async function runCase(
608618
if (opts.configPath !== undefined) argv.push("--config", opts.configPath);
609619
if (opts.skipPermissions) argv.push("--dangerously-skip-permissions");
610620
argv.push("--force");
621+
if (opts.director !== undefined) argv.push("--director", opts.director);
611622

612623
const maxTurns = opts.maxTurnsOverride ?? caseDef.maxTurns ?? null;
613624
// maxTurns is a soft post-run budget (case fails if exceeded). It does not

src/config.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

66
import { buildBifrostSource, buildOpenAISource, buildXaiSource, buildProviderCatalog, catalogEntryAsProviderSettings, CliHelpError, CLI_HELP_TEXT, KEYLESS_API_KEY, loadConfig, providerCatalogToSettings, runtimeSettingsWithCatalog, SOURCE_MAX_TOKENS } from "./config/index.js";
7+
import { DIRECTOR_IDS } from "./agent/directors/types.js";
78
import type { Config, UnconfiguredConfig } from "./config/index.js";
89
import { mergeProviderIntoSettings, type ResolvedProvider, type Settings } from "./config/settings.js";
910
import { OPENCODE_GO_BASE_URL } from "../packages/opencode-go/src/index.js";
@@ -183,6 +184,55 @@ describe("loadConfig", () => {
183184
}
184185
});
185186

187+
test("parses exec --director implement", async () => {
188+
const cwd = await emptyCwd();
189+
try {
190+
const globalPath = await writeGlobalSettings(cwd);
191+
const config = await loadConfig(["exec", "--cwd", cwd, "--director", "implement", "ship it"], {
192+
globalSettingsPath: globalPath,
193+
});
194+
assertConfigured(config);
195+
expect(config.command).toBe("exec");
196+
expect(config.director).toBe("implement");
197+
expect(config.task).toBe("ship it");
198+
} finally {
199+
await rm(cwd, { recursive: true, force: true });
200+
}
201+
});
202+
203+
test("omits director as undefined (skywalker default)", async () => {
204+
const cwd = await emptyCwd();
205+
try {
206+
const globalPath = await writeGlobalSettings(cwd);
207+
const config = await loadConfig(["exec", "--cwd", cwd, "ship it"], {
208+
globalSettingsPath: globalPath,
209+
});
210+
assertConfigured(config);
211+
expect(config.command).toBe("exec");
212+
expect(config.director).toBeUndefined();
213+
} finally {
214+
await rm(cwd, { recursive: true, force: true });
215+
}
216+
});
217+
218+
test("unknown --director id errors listing DIRECTOR_IDS", async () => {
219+
await expect(
220+
loadConfig(["exec", "--director", "nope", "ship it"], { globalSettingsPath: NO_SETTINGS }),
221+
).rejects.toThrow(new RegExp(`Unknown director "nope".*${DIRECTOR_IDS.join(", ")}`));
222+
});
223+
224+
test("--director without a value errors", async () => {
225+
await expect(loadConfig(["exec", "--director"], { globalSettingsPath: NO_SETTINGS })).rejects.toThrow(
226+
"--director requires a value",
227+
);
228+
});
229+
230+
test("--director without exec/run is rejected", async () => {
231+
await expect(
232+
loadConfig(["--director", "implement", "ship it"], { globalSettingsPath: NO_SETTINGS }),
233+
).rejects.toThrow("--director is only available in exec mode");
234+
});
235+
186236
test("resume --pick opens the session picker without requiring prior sessions", async () => {
187237
const cwd = await emptyCwd();
188238
try {

src/config/index.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { generateSessionId, isSessionId, migrateLegacySessionIfNeeded } from "..
55
import { loadState } from "../session/state.js";
66

77

8+
import { isDirectorId } from "../agent/directors/registry.js";
9+
import { DIRECTOR_IDS, type DirectorId } from "../agent/directors/types.js";
810
import { validateEffort, type ReasoningEffort } from "../provider/reasoning-effort.js";
911
import { bootstrapPricingMetadata } from "../cost/pricing-metadata.js";
1012
import { defaultPricingCachePath, type PricingFetcherOptions } from "../cost/pricing-fetcher.js";
@@ -278,6 +280,11 @@ export type Config = {
278280
force: boolean;
279281
dangerouslySkipPermissions: boolean;
280282
auto: boolean;
283+
/**
284+
* Exec-only chosen primary director. Omitted = Skywalker (product default).
285+
* `--director` is rejected in TUI mode.
286+
*/
287+
director?: DirectorId;
281288
/**
282289
* Entry mode. `"tui"` is the interactive Ink shell; `"exec"` is the non-TUI
283290
* product agent path (`corbits exec "prompt"`). Same directors/tools/permissions.
@@ -343,6 +350,8 @@ export type UnconfiguredConfig = {
343350
dangerouslySkipPermissions: boolean;
344351
auto: boolean;
345352
command: "tui" | "exec";
353+
/** Exec-only chosen primary. Omitted on the unconfigured path too. */
354+
director?: DirectorId;
346355
// Path where the onboarding flow should write the new settings.
347356
globalSettingsPath: string;
348357
// The original error message, used for non-TUI (exec) error output.
@@ -377,6 +386,7 @@ Flags:
377386
--profile <name> settings profile
378387
--resume interactive session picker
379388
--force override an existing run state
389+
--director <id> exec-only: run as this director (default: skywalker)
380390
--dangerously-skip-permissions
381391
--auto / --no-auto auto mode on/off
382392
--help, -h show this help
@@ -474,6 +484,7 @@ export async function loadConfig(
474484
// to ask-on-every-write. There is currently no in-session key to toggle auto;
475485
// Shift+Tab in the TUI cycles reasoning effort instead.
476486
let auto = true;
487+
let director: DirectorId | undefined;
477488
let configPath: string | undefined;
478489
let provider: string | undefined;
479490
let model: string | undefined;
@@ -515,6 +526,17 @@ export async function loadConfig(
515526
force = true;
516527
continue;
517528
}
529+
if (arg === "--director") {
530+
const value = requireValue("--director", args[++i]);
531+
if (command !== "exec") {
532+
throw new Error("--director is only available in exec mode");
533+
}
534+
if (!isDirectorId(value)) {
535+
throw new Error(`Unknown director "${value}". Use one of: ${DIRECTOR_IDS.join(", ")}.`);
536+
}
537+
director = value;
538+
continue;
539+
}
518540

519541
if (arg === "--dangerously-skip-permissions") {
520542
dangerouslySkipPermissions = true;
@@ -633,6 +655,7 @@ export async function loadConfig(
633655
dangerouslySkipPermissions,
634656
auto,
635657
command,
658+
...(director !== undefined ? { director } : {}),
636659
globalSettingsPath: effectiveSettingsPath,
637660
providerError: err instanceof Error ? err.message : String(err),
638661
// Keep diagnostics even when provider setup fails early so junk local
@@ -684,6 +707,7 @@ export async function loadConfig(
684707
dangerouslySkipPermissions,
685708
auto,
686709
command,
710+
...(director !== undefined ? { director } : {}),
687711
globalSettingsPath: effectiveSettingsPath,
688712
sessionId,
689713
noWorkflow,

src/exec/runner.ts

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import {
2727
} from "../config/settings.js";
2828
import { codexProfileFromProviderName } from "../config/codex-providers.js";
2929
import { xaiProfileFromProviderName } from "../config/xai-providers.js";
30+
import { formatDirectorSystemPrompt } from "../agent/directors/identity.js";
31+
import { DIRECTOR_REGISTRY } from "../agent/directors/registry.js";
32+
import type { DirectorId } from "../agent/directors/types.js";
3033
import { createInferenceDependencies } from "../provider/inference-dependencies.js";
3134
import { getValidCodexToken } from "../auth/codex/session.js";
3235
import { getValidXaiToken } from "../auth/xai/session.js";
@@ -111,6 +114,40 @@ export function formatCaughtError(err: unknown): string {
111114
return err instanceof Error ? err.message : String(err);
112115
}
113116

117+
/**
118+
* Exec-primary director overlay. Omit / skywalker keep the product default
119+
* (`loadSessionChatPrompt` + advertised session tools). Any other closed-fleet
120+
* id uses the package prompt and allowlist. Worker effort/nudge are not applied.
121+
*/
122+
export type ExecDirectorOverlay = {
123+
/** Package system prompt; omitted on the skywalker default path. */
124+
systemPrompt?: string;
125+
/** `pkg.tools.allow` (task stripped when `maySpawn` is false). */
126+
advertisedAllow?: readonly string[];
127+
mountTask: boolean;
128+
};
129+
130+
export function resolveExecDirectorOverlay(
131+
director: DirectorId | undefined,
132+
): ExecDirectorOverlay {
133+
if (director === undefined || director === "skywalker") {
134+
return { mountTask: true };
135+
}
136+
const pkg = DIRECTOR_REGISTRY[director];
137+
const allow = pkg.tools?.allow;
138+
const advertisedAllow =
139+
allow !== undefined && allow.length > 0
140+
? pkg.spawn.maySpawn
141+
? [...allow]
142+
: allow.filter((name) => name !== "task")
143+
: undefined;
144+
return {
145+
systemPrompt: formatDirectorSystemPrompt(pkg),
146+
...(advertisedAllow !== undefined ? { advertisedAllow } : {}),
147+
mountTask: pkg.spawn.maySpawn,
148+
};
149+
}
150+
114151
/** Content-less inbound used after compact so the reactor re-enters (matches TUI). */
115152
export function buildCompactionContinuationMessage(): InboundMessage {
116153
return {
@@ -348,6 +385,8 @@ export async function runExec(config: Config): Promise<ExecResult> {
348385

349386
let currentAgent: Agent | null = null;
350387

388+
const overlay = resolveExecDirectorOverlay(config.director);
389+
351390
const agentToolset = await createAgentToolset({
352391
cwd: config.cwd,
353392
permissionGate,
@@ -384,30 +423,39 @@ export async function runExec(config: Config): Promise<ExecResult> {
384423
);
385424
return result.kind === "option" && result.index === 0;
386425
},
387-
subAgent: {
388-
provider: () => liveSubAgentProvider.current,
389-
sessions: subAgentSessions,
390-
getWorkdirBase: () => sessionDir(config.cwd, sessionId),
391-
onProgress: () => undefined,
392-
...(config.settings !== undefined ? { settings: () => config.settings! } : {}),
393-
catalog: () => config.providers,
394-
profiles: () => liveAgentProfiles,
395-
},
426+
...(overlay.mountTask
427+
? {
428+
subAgent: {
429+
provider: () => liveSubAgentProvider.current,
430+
sessions: subAgentSessions,
431+
getWorkdirBase: () => sessionDir(config.cwd, sessionId),
432+
onProgress: () => undefined,
433+
...(config.settings !== undefined ? { settings: () => config.settings! } : {}),
434+
catalog: () => config.providers,
435+
profiles: () => liveAgentProfiles,
436+
},
437+
}
438+
: {}),
396439
...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}),
397440
});
398441
toolset = agentToolset;
399442

400-
const { systemPrompt } = await loadSessionChatPrompt({
401-
cwd: config.cwd,
402-
skillDirs,
403-
...(config.systemPromptExtensions !== undefined
404-
? { systemPromptExtensions: config.systemPromptExtensions }
405-
: {}),
406-
sessionMode,
407-
toolAvailability,
408-
});
443+
const systemPrompt =
444+
overlay.systemPrompt ??
445+
(
446+
await loadSessionChatPrompt({
447+
cwd: config.cwd,
448+
skillDirs,
449+
...(config.systemPromptExtensions !== undefined
450+
? { systemPromptExtensions: config.systemPromptExtensions }
451+
: {}),
452+
sessionMode,
453+
toolAvailability,
454+
})
455+
).systemPrompt;
409456

410-
const advertisedBuiltInPrefix = advertisedToolNamesForSessionMode(sessionMode, toolAvailability);
457+
const advertisedBuiltInPrefix =
458+
overlay.advertisedAllow ?? advertisedToolNamesForSessionMode(sessionMode, toolAvailability);
411459
const activatedToolNames = createActivatedToolTracker();
412460
// Advertise then family-gate wire schemas (kimi gets a non-recursive present).
413461
const computeAdvertised = (all: readonly ToolDefinition[]): ToolDefinition[] =>

tests/unit/exec/runner.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22
import type { Config } from "../../../src/config/index.js";
3-
import { formatCaughtError, runExec } from "../../../src/exec/runner.js";
3+
import { formatCaughtError, resolveExecDirectorOverlay, runExec } from "../../../src/exec/runner.js";
4+
import { IMPLEMENT_TOOLS } from "../../../src/agent/directors/tool-sets.js";
45

56
function bareConfig(task: string): Config {
67
// Minimal unconfigured-shaped object is not enough — runExec only needs
@@ -48,3 +49,22 @@ describe("runExec", () => {
4849
}
4950
});
5051
});
52+
53+
describe("resolveExecDirectorOverlay", () => {
54+
test("implement exec primary does not mount task", () => {
55+
const overlay = resolveExecDirectorOverlay("implement");
56+
expect(overlay.mountTask).toBe(false);
57+
expect(overlay.advertisedAllow).toBeDefined();
58+
expect(overlay.advertisedAllow).not.toContain("task");
59+
expect(overlay.advertisedAllow).toEqual([...IMPLEMENT_TOOLS]);
60+
expect(overlay.systemPrompt).toContain("ImplementDirector");
61+
});
62+
63+
test("skywalker default still can mount task", () => {
64+
expect(resolveExecDirectorOverlay(undefined).mountTask).toBe(true);
65+
expect(resolveExecDirectorOverlay(undefined).systemPrompt).toBeUndefined();
66+
expect(resolveExecDirectorOverlay(undefined).advertisedAllow).toBeUndefined();
67+
expect(resolveExecDirectorOverlay("skywalker").mountTask).toBe(true);
68+
expect(resolveExecDirectorOverlay("skywalker").systemPrompt).toBeUndefined();
69+
});
70+
});

0 commit comments

Comments
 (0)