amelnagdy/delegate-skillsReview behavior before running
SKILL DETAIL
delegate-setup
amelnagdy/delegate-skills/delegate-setup
>-
Installs · 119View source
Installation
npx skills add https://github.com/amelnagdy/delegate-skills --skill delegate-setup
Skill files
SKILL.md
Last synced · Aug 30, 2026
references/schema.md›
# Fleet schema (`delegate-fleet.v1`)
One concept: **lanes**. A lane names an implementer and optional dials.
## Document
```json
{
"version": "delegate-fleet.v1",
"lanes": {
"feature": {
"implementer": "opencode",
"model": "opencode/grok",
"variant": "high"
},
"tests": {
"implementer": "grok",
"effort": "medium"
},
"complex": {
"implementer": "claude",
"effort": "high"
}
}
}
```
- `version` must be `delegate-fleet.v1`.
- `lanes` is an object keyed by lane name (`[A-Za-z0-9][A-Za-z0-9._-]*`).
- Every lane **requires** `implementer` (a key from the registry below).
- Other fields are dials; only dials listed for that implementer are allowed.
## Paths
| Scope | Path |
| --- | --- |
| Global | `$XDG_CONFIG_HOME/delegate-skills/config.json` when `XDG_CONFIG_HOME` is set; otherwise `~/.config/delegate-skills/config.json` (`os.homedir()` → `HOME` / `USERPROFILE`) |
| Project | `<git-root>/.delegate/config.json` |
Project overlays global by **whole-lane replace** (same lane name in project fully replaces the global lane).
Relays apply a project lane only when its exact config content matches the approval hash written under
that worktree's Git metadata by an explicitly approved `config.mjs write --scope project`. Cloned or
later-edited project config fails closed until it is reviewed and written again through `delegate-setup`.
## Implementer keys and dials
| Key | Skill | Binary | Supported dials |
| --- | --- | --- | --- |
| `claude` | claude-delegate | `claude` | model, effort, timeout, readOnly |
| `cline` | cline-delegate | `cline` | provider, model, timeout |
| `codex` | codex-delegate | `codex` | model, effort, sandbox, timeout, readOnly |
| `commandcode` | commandcode-delegate | `cmd` | model, effort, timeout, readOnly |
| `opencode` | opencode-delegate | `opencode` | model, **variant**, timeout, readOnly |
| `agy` | agy-delegate | `agy` | model, effort, timeout, readOnly |
| `grok` | grok-delegate | `grok` | model, effort, sandbox, timeout, readOnly |
| `kimi` | kimi-delegate | `kimi` | model, timeout |
| `qoder` | qoder-delegate | `qodercli` | model, permissionMode, timeout, readOnly |
| `vibe` | vibe-delegate | `vibe` | timeout, readOnly |
| `cursor` | cursor-delegate | `cursor-agent` | model, force, timeout, readOnly |
| `pi` | pi-delegate | `pi` | provider, model, timeout, readOnly |
| `omp` | omp-delegate | `omp` | provider, model, **effort** (`--thinking`), timeout, readOnly |
| `aider` | aider-delegate | `aider` | model, timeout, readOnly |
| `copilot` | copilot-delegate | `copilot` | model, effort, timeout, readOnly |
| `warp` | warp-delegate | `oz` | model, timeout |
| `zcode` | zcode-delegate | `zcode` | permissionMode, timeout, readOnly |
ZCode carries its `--mode` as `permissionMode`, and only `plan` and `yolo` are accepted: ZCode also
documents `build` and `edit`, but a headless run has no permission client, so those two block every
write tool and exit 0 having changed nothing. ZCode has no `--model` flag — the model is chosen in
the CLI's own config file — so `model` is not a dial for `zcode` lanes. ZCode also ships its CLI
inside the desktop app rather than on PATH, so discovery falls back to the installed app bundle.
OpenCode uses `variant` for reasoning intensity, not `effort`. Do not write `effort` on an `opencode` lane.
Oh My Pi (`omp`) uses the lane `effort` dial for omp's `--thinking` (`off`, `auto`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`). Do not write `thinking` as a lane field.
OpenCode lanes **require** `model` in `provider/model` form, with a non-empty provider before the first
`/` and at least one non-`/` character after it. Cline accepts `provider` and `model` as separate
dials and does not impose that shape.
Boolean dials: `readOnly`, `force`. All other dials are non-empty strings. Duration strings for
`timeout` use `h`/`m`/`s` (e.g. `30m`) and must fit the relay watchdog ceiling (~24.8 days).
Do not combine `readOnly: true` with a write-capable `sandbox` / `permissionMode` / `force`.
`model` / `provider` / OpenCode `variant` must match the bound relay’s token rules (e.g. Claude
rejects spaces; Grok/Pi/Oh My Pi/OpenCode/Codex/Command Code use a shell-safe token set — Windows `shell:true`
launches, and Oh My Pi's flag-injection defense even without a shell).
## Helpers
```bash
node <skill-dir>/scripts/discover.mjs
node <skill-dir>/scripts/config.mjs load [--cwd <dir>]
node <skill-dir>/scripts/config.mjs validate <file>
node <skill-dir>/scripts/config.mjs write --scope global|project [--cwd <dir>] <file>
node <skill-dir>/scripts/lane.mjs resolve --cwd <dir> --lane <name> --implementer <key>
```
`load` prints the **effective** map (each lane includes a `source` of `global` or `project`) and
`projectTrusted`, which reports whether the current project content matches its local approval hash.
`lane.mjs resolve` is what `*-delegate` relays call for `--lane`: it fails loud on a missing
lane, untrusted project config, or implementer mismatch, and prints relay-native dials
(e.g. grok `sandbox` → `autonomy`).
references/setup-dialogue.md›
# Setup dialogue details
Load this when running a configure / reconfigure session. `SKILL.md` is authoritative and states each
rule once; this page expands the dialogue itself — the interview, how to ask it, how to read a usage
scan, and how to label what you propose.
## Grounding the lane map
The grounding menu is `SKILL.md` step 3. When the user picks the interview:
### The four interview questions
Allocation policy only. Users cannot rank model IDs — that is your job, not theirs.
| # | Ask | What it settles |
| --- | --- | --- |
| 1 | What kind of work do you delegate most? | The main lane — and its **name**. “migrations”, “bug triage”, “release-prep” beat the canned `feature`/`tests`/`ui`/`fast`/`complex` five |
| 2 | Which paid subscriptions should I burn, and which should I spare? | Quota economics. Discovery cannot see plans, limits, or what a run costs — only the user knows |
| 3 | Any CLI you already trust — and for what kind of work? Any that has burned you? | Lived experience outranks your priors about the underlying models. Trust is scoped: an answer qualifies the CLI for work like the work that earned it — follow up on the scope before it qualifies a high-stakes lane. It is never an order to assign work: "trusted + spared" means qualified but normally excluded (the spare rule is in `SKILL.md` step 3) |
| 4 | Default to fast and cheap, or slow and thorough? | Effort / variant dials, and who gets the `complex` lane |
Stop at four.
### How to ask them
The questions are conversation, not a survey — cramming all four into one cold multiple-choice
form loses exactly what they exist to collect.
- **One medium per round.** Every question you ask in a turn goes through the same channel: all
prose, or all in one form. Never mix the two — submitting a form ends the turn, so any prose
question asked beside it is simply lost, and the silence that follows is not an answer to it.
- **Question 1 is open-ended.** You want the user's words — they become lane names. If your harness
forces options, derive them from evidence (repo, usage scan, what the user has said), keep them one
genre (kinds of work — never a mix of domains and task types, which overlap), and treat a selection
as a draft lane name, not a category.
- **Questions 2 and 3 are set-valued.** The answer is a mapping across CLIs, so options must span the
*discovered* CLIs — never an arbitrary subset — with both directions expressible: burn *and* spare,
trust *and* burned-by. Multi-select if the harness has it; otherwise ask in prose.
- **Question 4 may be a closed choice.** Do not pre-mark an option as recommended — recommendations
belong in the proposal, after the answers.
- **Lead with question 1.** Its answer usually reshapes or removes the others — fewer than four is
better.
- **Silence shrinks the map** — the consent rule is in `SKILL.md` step 3; here is the phrasing for
it. Name the axis in one line — “nobody told me which subscriptions to spare, so I set no effort
dials; each CLI will use the default you configured” — and add that the answer is welcome anytime.
### Reading a usage scan
`node <skill-dir>/scripts/discover.mjs --usage` adds `usage` to each discovered CLI:
`{ "sessions": <int>, "lastUsed": <ISO-8601 | null> }`, or `null`.
- What to tell the user before running it: it counts session files and reads their timestamps. It
never opens one, so no conversation content is read.
- `usage: null` also covers a CLI with no local state directory — never report it as zero.
- Large disparities show where activity occurs — not what role the CLI played in it. 1600 codex
sessions against 20 pi sessions cannot tell you whether the user works inside codex (and would
orchestrate from it) or sends delegated work to it. Treat a meaningful disparity as a signal to
**ask**, never to assign: "codex has the most local sessions, but the scan cannot tell whether
you work inside it or delegate work to it — should I protect its quota, burn it as an
implementer, or treat it as mixed?" Combine the answer with the burn/spare interview answer when
the interview also ran; in a scan-only session the role answer is your only quota evidence —
propose conservatively and name the map quota-blind (the shrink rule in `SKILL.md` step 3).
- Low usage alone is not evidence of task fit. A surplus CLI still needs to be installed,
authenticated, and reliable to earn a lane.
- Small differences are noise. 97 against 61 decides nothing — fall back to the interview or to your
opinion, and label it as such.
- `lastUsed` weighs as much as the count. A big count that stopped months ago is a CLI the user moved
off; a recent date on a small count is one they are adopting.
- Counts are lifetime totals for that machine, not “this month”, and only cover sessions the CLI still
keeps on disk.
### Labelling the basis
The Basis values, and the rule that every lane carries one, are in `SKILL.md`. What lives here is the
split label and the addendum.
Label the parts separately when they differ. A lane whose implementer came from the usage scan but
whose model you chose is `usage + my opinion (model)`, never a flat `usage data` — session counts
say where the user works, not which model or effort level to buy for them. That split only exists in
quick-defaults mode, or when the user asked you for a dial: the evidence modes gate every dial
(`SKILL.md` rule 7), so there is no opinion-dial left to label.
In the evidence modes, unsolicited opinions about dials travel as an **addendum**, never as a
pre-filled field (quick-defaults proposals may include opinion-labeled dials — the user hired that
opinion). Show the table and the JSON first, then, in a separate paragraph after it: “If you want my
picks for models and effort levels, say the word and I’ll add them.” Then wait. A dial the user
asked for is theirs; the same dial sitting inside the JSON they are about to approve spent their
quota on your say-so.
## Auth and models
- `authenticated: null` (unknown) usually means no auth probe is wired for that CLI — currently `agy`
and `pi`, which expose no status command. Say that, rather than implying the login failed.
- Prefer not binding a lane to a CLI discover reports as `authenticated: false`.
- For claude specifically, `authenticated: false` can be a Keychain artifact when discovery itself
ran inside a sandbox: on macOS the live credentials sit in the login Keychain, and a sandbox that
blocks Keychain access makes the probe fall back to a possibly stale credentials file. Verify with
`claude auth status` outside the sandbox before treating the CLI as unauthenticated.
- Never invent model ids (rule 6): use `models.values` when `status` is `reported`, or ask the user,
or omit `model` when the CLI has a safe default (OpenCode does **not** — require a model for
opencode lanes).
scripts/config.mjs›
#!/usr/bin/env node
/**
* config.mjs — load, merge, validate, and write delegate-fleet.v1 lane maps.
*
* Usage:
* node config.mjs load [--cwd <dir>]
* node config.mjs validate <file>
* node config.mjs write --scope global|project [--cwd <dir>] <file>
* node config.mjs --help
*
* Node built-ins only. No network, credentials, or telemetry.
*/
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
AGY_EFFORT,
ALL_DIALS,
CLAUDE_EFFORT,
COPILOT_EFFORT,
OMP_THINKING,
CODEX_SANDBOX,
CONFIG_VERSION,
GROK_SANDBOX,
IMPLEMENTER_BY_KEY,
LANE_NAME,
MODEL_TOKEN,
QODER_PERMISSION,
TIMEOUT_RE,
ZCODE_MODE,
} from "./implementers.mjs";
/** Same ceiling relays use for --timeout (Node setTimeout max ~24.8 days). */
const MAX_TIMER_MS = 2_147_483_647;
const HELP = `config.mjs — load / validate / write delegate-fleet.v1 lane maps
Usage:
node config.mjs load [--cwd <dir>]
node config.mjs validate <file>
node config.mjs write --scope global|project [--cwd <dir>] <file>
node config.mjs --help
Paths:
global ~/.config/delegate-skills/config.json
project <git-root>/.delegate/config.json (requires a git repo)
load prints the effective lane map (project whole-lane replaces global) as JSON.
`;
export function globalConfigPath() {
// Prefer XDG when set; otherwise ~/.config (homedir() → HOME / USERPROFILE).
const base = process.env.XDG_CONFIG_HOME
? process.env.XDG_CONFIG_HOME
: join(homedir(), ".config");
return join(base, "delegate-skills", "config.json");
}
export function findGitRoot(cwd) {
const r = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (r.status !== 0) return null;
const root = (r.stdout || "").trim();
return root || null;
}
export function projectConfigPath(cwd) {
const root = findGitRoot(cwd);
if (!root) return null;
return join(root, ".delegate", "config.json");
}
function projectTrustPath(cwd) {
const r = spawnSync("git", ["-C", cwd, "rev-parse", "--absolute-git-dir"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (r.status !== 0) return null;
const gitDir = (r.stdout || "").trim();
return gitDir ? join(gitDir, "delegate-skills", "project-config.sha256") : null;
}
function fail(message) {
process.stderr.write(`config.mjs: ${message}\n`);
process.exit(2);
}
/**
* @returns {{ ok: true, document: object } | { ok: false, error: string }}
*/
export function parseConfigDocument(raw, label = "config") {
let document;
try {
document = JSON.parse(raw);
} catch (error) {
return { ok: false, error: `${label}: invalid JSON (${error.message})` };
}
if (!document || typeof document !== "object" || Array.isArray(document)) {
return { ok: false, error: `${label}: expected a JSON object` };
}
if (document.version !== CONFIG_VERSION) {
return {
ok: false,
error: `${label}: unsupported version ${JSON.stringify(document.version)} (expected ${CONFIG_VERSION})`,
};
}
if (!document.lanes || typeof document.lanes !== "object" || Array.isArray(document.lanes)) {
return { ok: false, error: `${label}: lanes must be an object` };
}
for (const [name, lane] of Object.entries(document.lanes)) {
const laneError = validateLane(name, lane, label);
if (laneError) return { ok: false, error: laneError };
}
return { ok: true, document };
}
function validateLane(name, lane, label) {
if (!LANE_NAME.test(name)) {
return `${label}: invalid lane name ${JSON.stringify(name)}`;
}
if (!lane || typeof lane !== "object" || Array.isArray(lane)) {
return `${label}: lane ${name} must be an object`;
}
if (typeof lane.implementer !== "string" || IMPLEMENTER_BY_KEY[lane.implementer] == null) {
return `${label}: lane ${name} needs implementer (one of: ${Object.keys(IMPLEMENTER_BY_KEY).join(", ")})`;
}
const impl = IMPLEMENTER_BY_KEY[lane.implementer];
for (const field of Object.keys(lane)) {
if (field === "implementer") continue;
if (!ALL_DIALS.includes(field)) {
return `${label}: lane ${name} has unknown field ${JSON.stringify(field)}`;
}
if (!impl.supports.includes(field)) {
return `${label}: lane ${name}: ${impl.key} does not support ${field} (supports: ${impl.supports.join(", ") || "none"})`;
}
if (field === "readOnly" || field === "force") {
if (typeof lane[field] !== "boolean") {
return `${label}: lane ${name}.${field} must be a boolean`;
}
continue;
}
if (typeof lane[field] !== "string" || lane[field].length === 0) {
return `${label}: lane ${name}.${field} must be a non-empty string`;
}
const valueError = validateDialValue(impl.key, field, lane[field], name, label);
if (valueError) return valueError;
}
if (impl.key === "opencode") {
if (typeof lane.model !== "string" || !lane.model) {
return `${label}: lane ${name}: opencode requires model (provider/model)`;
}
const separator = lane.model.indexOf("/");
if (separator <= 0 || !/[^/]/.test(lane.model.slice(separator + 1))) {
return `${label}: lane ${name}.model must be provider/model (e.g. opencode/grok)`;
}
}
const autonomyError = validateAutonomyConsistency(impl.key, lane, name, label);
if (autonomyError) return autonomyError;
return null;
}
function validateAutonomyConsistency(implementer, lane, laneName, label) {
if (lane.readOnly !== true) return null;
if (implementer === "codex" && typeof lane.sandbox === "string" && lane.sandbox !== "read-only") {
return `${label}: lane ${laneName}: readOnly contradicts sandbox ${JSON.stringify(lane.sandbox)}`;
}
if (implementer === "grok" && typeof lane.sandbox === "string" && lane.sandbox !== "read-only") {
return `${label}: lane ${laneName}: readOnly contradicts sandbox ${JSON.stringify(lane.sandbox)}`;
}
if (
implementer === "qoder" &&
typeof lane.permissionMode === "string" &&
lane.permissionMode !== "plan"
) {
return `${label}: lane ${laneName}: readOnly contradicts permissionMode ${JSON.stringify(lane.permissionMode)}`;
}
if (implementer === "cursor" && lane.force === true) {
return `${label}: lane ${laneName}: readOnly contradicts force true`;
}
return null;
}
function parseTimeoutMs(value) {
const match = TIMEOUT_RE.exec(value);
if (!match || (!match[1] && !match[2] && !match[3])) return null;
try {
const seconds =
BigInt(match[1] || 0) * 3600n +
BigInt(match[2] || 0) * 60n +
BigInt(match[3] || 0);
const milliseconds = seconds * 1000n;
if (milliseconds <= 0n || milliseconds > BigInt(MAX_TIMER_MS)) return null;
return Number(milliseconds);
} catch {
return null;
}
}
function validateDialValue(implementer, field, value, laneName, label) {
if (field === "timeout") {
if (parseTimeoutMs(value) === null) {
return `${label}: lane ${laneName}.timeout must be a positive h/m/s duration no longer than about 24 days (e.g. 30m)`;
}
return null;
}
if (field === "effort") {
if (implementer === "agy" && !AGY_EFFORT.includes(value)) {
return `${label}: lane ${laneName}.effort must be one of: ${AGY_EFFORT.join(", ")}`;
}
if (implementer === "claude" && !CLAUDE_EFFORT.includes(value)) {
return `${label}: lane ${laneName}.effort must be one of: ${CLAUDE_EFFORT.join(", ")}`;
}
if (implementer === "copilot" && !COPILOT_EFFORT.includes(value)) {
return `${label}: lane ${laneName}.effort must be one of: ${COPILOT_EFFORT.join(", ")}`;
}
if (implementer === "omp" && !OMP_THINKING.includes(value)) {
return `${label}: lane ${laneName}.effort must be one of: ${OMP_THINKING.join(", ")}`;
}
if (
(implementer === "codex" || implementer === "grok" || implementer === "commandcode") &&
!/^[a-z][a-z0-9-]*$/i.test(value)
) {
return `${label}: lane ${laneName}.effort must be a bare token`;
}
return null;
}
if (field === "sandbox") {
if (implementer === "codex" && !CODEX_SANDBOX.includes(value)) {
return `${label}: lane ${laneName}.sandbox must be one of: ${CODEX_SANDBOX.join(", ")}`;
}
if (implementer === "grok" && !GROK_SANDBOX.includes(value)) {
return `${label}: lane ${laneName}.sandbox must be one of: ${GROK_SANDBOX.join(", ")}`;
}
return null;
}
if (field === "permissionMode" && implementer === "qoder" && !QODER_PERMISSION.includes(value)) {
return `${label}: lane ${laneName}.permissionMode must be one of: ${QODER_PERMISSION.join(", ")}`;
}
// ZCode carries --mode here. build/edit are excluded on purpose: headless runs
// have no permission client, so they change nothing and still exit 0.
if (field === "permissionMode" && implementer === "zcode" && !ZCODE_MODE.includes(value)) {
return `${label}: lane ${laneName}.permissionMode must be one of: ${ZCODE_MODE.join(", ")}`;
}
if (field === "variant") {
// OpenCode appends --variant on win32 shell:true; reject cmd metacharacters.
if (!MODEL_TOKEN.shellSafe.test(value)) {
return `${label}: lane ${laneName}.variant has unsupported characters (allowed: letters, digits, . _ : / -)`;
}
return null;
}
if (field === "model" || field === "provider") {
const modelError = validateModelOrProvider(implementer, field, value, laneName, label);
if (modelError) return modelError;
}
return null;
}
function validateModelOrProvider(implementer, field, value, laneName, label) {
if (implementer === "qoder" && !value.trim()) {
return `${label}: lane ${laneName}.${field} must not be empty`;
}
let pattern = null;
let hint = "";
if (implementer === "claude") {
pattern = MODEL_TOKEN.claude;
hint = "letters, digits, . _ : @ / [ ] -";
} else if (implementer === "cursor") {
pattern = MODEL_TOKEN.cursor;
hint = "letters, digits, . _ : @ / [ ] , = -";
} else if (
implementer === "grok" ||
implementer === "pi" ||
implementer === "omp" ||
implementer === "opencode" ||
implementer === "commandcode" ||
// codex (and any other win32 shell:true relay) must not accept cmd metacharacters in -m.
implementer === "codex" ||
IMPLEMENTER_BY_KEY[implementer]?.winShell
) {
pattern = MODEL_TOKEN.shellSafe;
hint = "letters, digits, . _ : / -";
}
if (pattern && !pattern.test(value)) {
return `${label}: lane ${laneName}.${field} has unsupported characters for ${implementer} (allowed: ${hint})`;
}
return null;
}
/**
* Refuse project writes that escape the git root via a symlinked `.delegate`.
*/
export function assertSafeProjectConfigPath(cwd) {
const root = findGitRoot(cwd);
if (!root) throw new Error("project scope requires a git repository (--cwd)");
const delegateDir = join(root, ".delegate");
if (existsSync(delegateDir) && lstatSync(delegateDir).isSymbolicLink()) {
throw new Error("refusing to write: .delegate is a symlink");
}
mkdirSync(delegateDir, { recursive: true });
if (lstatSync(delegateDir).isSymbolicLink()) {
throw new Error("refusing to write: .delegate is a symlink");
}
const realRoot = realpathSync(root);
const realDelegate = realpathSync(delegateDir);
const rel = relative(realRoot, realDelegate);
if (!rel || rel.startsWith("..") || isAbsolute(rel)) {
throw new Error("refusing to write: .delegate resolves outside the git repository");
}
if (rel !== ".delegate" && !rel.startsWith(`.delegate${sep}`)) {
throw new Error("refusing to write: unexpected .delegate path");
}
return join(delegateDir, "config.json");
}
export function readConfigFile(path) {
if (!path || !existsSync(path)) return null;
const raw = readFileSync(path);
const parsed = parseConfigDocument(raw.toString("utf8"), path);
if (!parsed.ok) throw new Error(parsed.error);
return { path, document: parsed.document, digest: configDigest(raw) };
}
function configDigest(raw) {
return createHash("sha256").update(raw).digest("hex");
}
function projectConfigTrusted(cwd, digest) {
const trustPath = projectTrustPath(cwd);
if (!digest || !trustPath || !existsSync(trustPath)) return false;
return readFileSync(trustPath, "utf8").trim() === digest;
}
function trustProjectConfig(cwd, digest) {
const trustPath = projectTrustPath(cwd);
if (!trustPath) throw new Error("project scope requires writable git metadata");
mkdirSync(dirname(trustPath), { recursive: true });
writeFileSync(trustPath, `${digest}\n`, "utf8");
}
/**
* Effective lanes: start from global, whole-lane replace from project.
*/
export function effectiveLanes(globalDoc, projectDoc) {
/** @type {Record<string, { lane: object, source: "global"|"project" }>} */
const out = {};
if (globalDoc?.lanes) {
for (const [name, lane] of Object.entries(globalDoc.lanes)) {
out[name] = { lane: { ...lane }, source: "global" };
}
}
if (projectDoc?.lanes) {
for (const [name, lane] of Object.entries(projectDoc.lanes)) {
out[name] = { lane: { ...lane }, source: "project" };
}
}
return out;
}
export function loadEffective(cwd = process.cwd()) {
const globalPath = globalConfigPath();
const projectPath = projectConfigPath(cwd);
const globalFile = readConfigFile(globalPath);
const projectFile = projectPath ? readConfigFile(projectPath) : null;
const projectTrusted = Boolean(projectFile && projectConfigTrusted(cwd, projectFile.digest));
const effective = effectiveLanes(globalFile?.document, projectFile?.document);
return {
version: CONFIG_VERSION,
globalPath,
projectPath,
globalPresent: Boolean(globalFile),
projectPresent: Boolean(projectFile),
projectTrusted,
lanes: Object.fromEntries(
Object.entries(effective).map(([name, { lane, source }]) => [
name,
{ ...lane, source },
]),
),
};
}
export function writeAtomic(targetPath, document) {
const parsed = parseConfigDocument(JSON.stringify(document), "write payload");
if (!parsed.ok) throw new Error(parsed.error);
mkdirSync(dirname(targetPath), { recursive: true });
// Temp file must live beside the target: renameSync across drives fails on Windows (EXDEV).
const tmp = join(
dirname(targetPath),
`.config.${process.pid}.${Date.now()}.tmp`,
);
const raw = `${JSON.stringify(parsed.document, null, 2)}\n`;
try {
writeFileSync(tmp, raw, "utf8");
renameSync(tmp, targetPath);
} catch (error) {
try {
rmSync(tmp, { force: true });
} catch {
// best-effort cleanup
}
throw error;
}
return configDigest(raw);
}
function main(argv) {
try {
if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
process.stdout.write(HELP);
process.exit(argv.length === 0 ? 2 : 0);
}
const cmd = argv[0];
let cwd = process.cwd();
const cwdIdx = argv.indexOf("--cwd");
if (cwdIdx !== -1) {
if (!argv[cwdIdx + 1]) fail("--cwd needs a directory");
cwd = resolve(argv[cwdIdx + 1]);
}
if (cmd === "load") {
process.stdout.write(`${JSON.stringify(loadEffective(cwd), null, 2)}\n`);
return;
}
if (cmd === "validate") {
const file = argv.find(
(a, i) => i > 0 && !a.startsWith("--") && argv[i - 1] !== "--cwd",
);
if (!file) fail("validate needs a file path");
const parsed = parseConfigDocument(readFileSync(resolve(file), "utf8"), file);
if (!parsed.ok) fail(parsed.error);
process.stdout.write(`${JSON.stringify({ ok: true, path: resolve(file), lanes: Object.keys(parsed.document.lanes) }, null, 2)}\n`);
return;
}
if (cmd === "write") {
const scopeIdx = argv.indexOf("--scope");
const scope = scopeIdx !== -1 ? argv[scopeIdx + 1] : null;
if (scope !== "global" && scope !== "project") fail("--scope must be global or project");
const file = argv.filter((a, i) => {
if (a.startsWith("--")) return false;
if (i > 0 && (argv[i - 1] === "--cwd" || argv[i - 1] === "--scope")) return false;
return i > 0;
}).at(-1);
if (!file) fail("write needs a JSON file path");
const parsed = parseConfigDocument(readFileSync(resolve(file), "utf8"), file);
if (!parsed.ok) fail(parsed.error);
const target =
scope === "global" ? globalConfigPath() : assertSafeProjectConfigPath(cwd);
const writtenDigest = writeAtomic(target, parsed.document);
if (scope === "project") trustProjectConfig(cwd, writtenDigest);
process.stdout.write(`${JSON.stringify({
ok: true,
path: target,
lanes: Object.keys(parsed.document.lanes),
...(scope === "project" ? { projectTrusted: true } : {}),
}, null, 2)}\n`);
return;
}
fail(`unknown command ${JSON.stringify(cmd)}. Use --help.`);
} catch (error) {
fail(error.message || String(error));
}
}
// realpath BOTH sides, not resolve: skill dirs are commonly symlinked (e.g.
// ~/.claude/skills → ~/.agents/skills) and a plain resolve() mismatch made this file
// silently no-op when run through a symlink. The module URL side needs it too —
// under --preserve-symlinks-main it keeps the symlink path.
const isMain = (() => {
if (!process.argv[1]) return false;
const toReal = (path) => {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
};
return toReal(process.argv[1]) === toReal(fileURLToPath(import.meta.url));
})();
if (isMain) main(process.argv.slice(2));
scripts/discover.mjs›
#!/usr/bin/env node
/**
* discover.mjs — probe PATH for installed implementer CLIs.
*
* Usage:
* node discover.mjs JSON report on stdout
* node discover.mjs --usage same report, plus a `usage` field per discovered CLI:
* { sessions, lastUsed } counted from that CLI's local
* session store, or null where no probe is wired.
* Metadata only — session entries are listed and stat'd,
* never opened, because they hold the user's conversations.
* node discover.mjs --help
*
* Exit 0 even when nothing is installed. Exit 2 on usage errors.
* Node built-ins only. Probed CLIs may contact their own services.
*/
import { execFileSync, spawnSync } from "node:child_process";
import { accessSync, constants as fsConstants, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, isAbsolute, join, resolve, sep } from "node:path";
import { IMPLEMENTERS } from "./implementers.mjs";
const PROBE_TIMEOUT_MS = 10_000;
const HELP = `discover.mjs — probe PATH for installed implementer CLIs
Usage:
node discover.mjs
node discover.mjs --usage
node discover.mjs --help
Prints JSON:
{
"version": "delegate-discover.v1",
"discovered": [ { key, skill, binary, version, path, authenticated, supports, models } ],
"missing": [ { key, binary, skill } ]
}
authenticated is true | false | null (null = unknown / no probe).
models.status is reported | aliases | unsupported | failed
(aliases = curated aliases from the registry, not a live listing).
--usage adds "usage" to each discovered entry:
{ "sessions": <int>, "lastUsed": <ISO-8601 | null> }, or null when no usage probe
is wired or the CLI has no local state directory (null = unknown, not zero).
It counts session entries and reads their mtimes; it never opens a session file.
`;
function resolveBinary(binary) {
const pathValue = process.env.PATH ?? process.env.Path;
// A set-but-empty PATH is one empty component — the current directory — in
// POSIX lookup, so spawn still resolves there and discovery must agree. Only
// an absent PATH (spawn falls back to the system default path, never the
// current directory) or an empty one on Windows reports nothing.
if (pathValue === undefined || (pathValue === "" && process.platform === "win32")) return null;
const pathEntries = pathValue
.split(delimiter)
.map((entry) => entry.replace(/^"(.*)"$/, "$1"))
// An empty component means the current directory in POSIX lookup, which is
// where a relay's own spawn would find the binary. Dropping it made
// discovery report a CLI as missing that dispatch can actually run.
.map((entry) => (entry.length === 0 && process.platform !== "win32" ? "." : entry))
.filter((entry) => entry.length > 0);
if (process.platform === "win32") {
const pathExt = (process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD")
.split(";")
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean);
for (const entry of pathEntries) {
const dir = resolve(entry);
for (const ext of pathExt) {
const candidate = join(dir, `${binary}${ext}`);
try {
if (statSync(candidate).isFile()) return candidate;
} catch {
// keep looking
}
}
}
return null;
}
for (const entry of pathEntries) {
const candidate = join(resolve(entry), binary);
try {
accessSync(candidate, fsConstants.X_OK);
if (statSync(candidate).isFile()) return candidate;
} catch {
// keep looking
}
}
return null;
}
/** Expand ~ and %VAR% in a registry `locate` candidate, then normalize separators. */
function expandCandidate(raw) {
const expanded = raw
.replace(/^~(?=\/)/, homedir())
.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (_, name) => process.env[name] || "");
return expanded.split("/").join(sep);
}
/**
* Resolve one implementer to a launch descriptor: PATH first, exactly as every
* sibling behaves, then the registry's `locate` candidates. The fallback exists
* for CLIs that ship inside a desktop app instead of on PATH (ZCode), and is
* consulted only when the binary is genuinely absent, so no existing
* implementer's behaviour changes. `prefixArgs` is empty for a plain binary and
* carries the bundle path when a launcher (node) runs it.
*/
function resolveLaunch(impl) {
if (impl.key === "commandcode" && process.env.COMMANDCODE_BIN) {
const configured = process.env.COMMANDCODE_BIN;
if (process.platform === "win32" && !isAbsolute(configured)) return null;
const command = /[\\/]/.test(configured) ? resolve(configured) : resolveBinary(configured);
if (!command) return null;
try {
const comspec = process.env.ComSpec || process.env.COMSPEC;
if (
process.platform === "win32" &&
comspec &&
realpathSync.native(command).toLowerCase() === realpathSync.native(comspec).toLowerCase()
) return null;
if (statSync(command).isFile()) return { path: command, command, prefixArgs: [] };
} catch {
// report it missing below
}
return null;
}
if (process.platform === "win32" && impl.key === "commandcode") {
const command = resolveBinary("cmdc");
return command ? { path: command, command, prefixArgs: [] } : null;
}
const onPath = resolveBinary(impl.binary);
if (onPath) return { path: onPath, command: onPath, prefixArgs: [] };
const candidates = impl.locate?.candidates?.[process.platform] ?? [];
for (const raw of candidates) {
const file = expandCandidate(raw);
try {
if (statSync(file).isFile()) {
return { path: file, command: process.execPath, prefixArgs: [file] };
}
} catch {
// keep looking
}
}
return null;
}
function needsWindowsShell(impl, launch) {
// A node-launched bundle is a real executable and never needs the shell, even
// for an implementer whose PATH binary would.
if (launch.prefixArgs.length > 0) return false;
return process.platform === "win32" && (impl.winShell || /\.(?:cmd|bat)$/i.test(launch.command));
}
/** Quote a path for cmd.exe when shell:true; reject metacharacters. */
function quoteForCmd(value) {
if (/[\r\n%!]/.test(value) || value.includes('"')) {
throw new Error(`unsafe path for Windows shell probe: ${value}`);
}
return `"${value}"`;
}
function runProbe(launch, args, useShell) {
const command = useShell ? quoteForCmd(launch.command) : launch.command;
return execFileSync(command, [...launch.prefixArgs, ...args], {
encoding: "utf8",
timeout: PROBE_TIMEOUT_MS,
stdio: ["pipe", "pipe", "pipe"],
shell: useShell,
});
}
/** Probe output is colorized (opencode); patterns match the plain text. */
function stripAnsi(text) {
return text.replace(/\x1b\[[0-9;]*m/g, "");
}
/** spawnSync, not runProbe: success-path stderr matters (codex prints login status there). */
function captureProbe(launch, args, useShell) {
try {
const command = useShell ? quoteForCmd(launch.command) : launch.command;
const result = spawnSync(command, [...launch.prefixArgs, ...args], {
encoding: "utf8",
timeout: PROBE_TIMEOUT_MS,
stdio: ["pipe", "pipe", "pipe"],
shell: useShell,
});
const stdout = result.stdout || "";
return {
ok: !result.error && result.status === 0,
stdout,
output: stripAnsi(`${stdout}${result.stderr || ""}`),
};
} catch {
return { ok: false, stdout: "", output: "" };
}
}
function probeVersion(impl, launch) {
const useShell = needsWindowsShell(impl, launch);
const tryArgs = (versionArgs) => {
try {
const raw = runProbe(launch, versionArgs, useShell);
const firstLine = raw.trim().split(/\r?\n/, 1)[0].trim();
if (!firstLine) return null;
if (impl.versionFormat !== "colon-prefix") return firstLine;
return /^([^:\s]+):/.exec(firstLine)?.[1] ?? firstLine;
} catch {
return null;
}
};
let version = tryArgs(impl.versionArgs);
if (version === null && impl.versionFallbackArgs) {
version = tryArgs(impl.versionFallbackArgs);
}
return version;
}
function readAuthField(raw, jsonField) {
try {
const field = JSON.parse(raw)[jsonField];
return typeof field === "boolean" ? field : null;
} catch {
return null;
}
}
function probeAuth(impl, launch, captured = null) {
if (!impl.authProbe) return null;
const { args, jsonField, successPattern, failPattern, missMeansFalse } = impl.authProbe;
const useShell = needsWindowsShell(impl, launch);
if (jsonField) {
try {
return readAuthField(runProbe(launch, args, useShell), jsonField);
} catch (error) {
const combined = `${error.stdout || ""}${error.stderr || ""}`;
if (combined.trim()) {
const parsed = readAuthField(combined, jsonField);
if (parsed !== null) return parsed;
}
return null;
}
}
const { ok, output } = captured || captureProbe(launch, args, useShell);
// failPattern first: "not logged in" also contains "logged in".
if (failPattern && failPattern.test(output)) return false;
if (successPattern) {
if (successPattern.test(output)) return true;
// Only a clean run can mean logged out, and only where the probe says absence is proof;
// elsewhere a reworded CLI would read as a confident false.
return ok && missMeansFalse ? false : null;
}
return ok ? true : null;
}
function modelResult(identifiers, status = "reported") {
const unique = [...new Set(identifiers)].filter(Boolean);
return {
status,
values: unique.slice(0, 200),
truncated: unique.length > 200,
};
}
function failedModels() {
return { status: "failed", values: [], truncated: false };
}
function parseModelLines(raw, format) {
const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
let identifiers;
if (format === "cursor") {
identifiers = lines
.filter((line) => line !== "Available models" && !line.startsWith("Tip:"))
.map((line) => line.split(/\s+-\s+/, 1)[0]);
} else if (format === "grok") {
identifiers = lines
.filter((line) => line.startsWith("* "))
.map((line) => line.slice(2).replace(/\s*\(default\)$/, "").trim());
} else if (format === "commandcode") {
// "vendor/name description" rows, grouped under plain-text section headers and a
// count line. A slash in the first column is what separates a model row from those.
identifiers = lines
.map((line) => line.split(/\s+/, 1)[0])
.filter((first) => first.includes("/"));
} else if (format === "table") {
identifiers = lines
.slice(1)
.map((line) => line.split(/\s+/))
.filter((columns) => columns.length >= 2)
.map((columns) => `${columns[0]}/${columns[1]}`);
} else {
identifiers = lines;
}
return modelResult(identifiers);
}
/** Parses the "codex-cache" file shape; only the slugs leave the parser. */
function parseModelCache(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return failedModels();
}
if (!Array.isArray(parsed?.models)) return failedModels();
return modelResult(
parsed.models.map((entry) => (typeof entry?.slug === "string" ? entry.slug : "")),
);
}
/** $CODEX_HOME-style override, else the subdirectory under the user's home. */
function modelFilePath(probe) {
const base = process.env[probe.envDir] || join(homedir(), probe.homeSubdir);
return join(base, probe.file);
}
function probeModels(impl, launch, captured = null) {
const probe = impl.modelProbe;
if (!probe) {
return { status: "unsupported", values: [], truncated: false };
}
if (probe.static) {
return modelResult(probe.static, "aliases");
}
if (probe.file) {
try {
return parseModelCache(readFileSync(modelFilePath(probe), "utf8"));
} catch {
// No cache until the CLI has run once; that is not a discovery failure worth throwing on.
return failedModels();
}
}
if (captured) {
return captured.ok ? parseModelLines(captured.stdout, probe.format) : failedModels();
}
try {
const raw = runProbe(launch, probe.args, needsWindowsShell(impl, launch));
return parseModelLines(raw, probe.format);
} catch {
return failedModels();
}
}
/** Session containers under `base`; "*" matches any directory at that level. */
function usageContainers(base, path) {
let dirs = [base];
for (const segment of path) {
const next = [];
for (const dir of dirs) {
for (const entry of readEntries(dir)) {
if (!entry.isDirectory()) continue;
if (segment !== "*" && entry.name !== segment) continue;
next.push(join(dir, entry.name));
}
}
dirs = next;
}
return dirs;
}
function readEntries(dir) {
try {
return readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
}
/**
* How much the user works in this CLI, from directory listings and mtimes only.
* Session files hold the user's conversations; none of them is ever opened.
*/
function probeUsage(impl) {
const probe = impl.usageProbe;
if (!probe) return null;
const base = (probe.envDir && process.env[probe.envDir]) || join(homedir(), probe.homeSubdir);
try {
if (!statSync(base).isDirectory()) return null;
} catch {
// No state directory at all: unknown, not zero.
return null;
}
let sessions = 0;
let lastUsed = 0;
for (const dir of usageContainers(base, probe.path)) {
for (const entry of readEntries(dir)) {
if (probe.entry !== "any" && entry.isDirectory() !== (probe.entry === "dir")) continue;
if (!probe.match.test(entry.name)) continue;
sessions += 1;
const entryPath = join(dir, entry.name);
try {
lastUsed = Math.max(lastUsed, statSync(entryPath).mtimeMs);
if (entry.isDirectory()) {
// A directory's mtime does not advance when a file inside it grows, so a
// resumed session would read as abandoned; stat the children too (never open them).
for (const child of readEntries(entryPath)) {
lastUsed = Math.max(lastUsed, statSync(join(entryPath, child.name)).mtimeMs);
}
}
} catch {
// Entry vanished mid-scan; the count still holds.
}
}
}
return { sessions, lastUsed: lastUsed > 0 ? new Date(lastUsed).toISOString() : null };
}
function main(argv) {
if (argv.includes("--help") || argv.includes("-h")) {
process.stdout.write(HELP);
process.exit(0);
}
const withUsage = argv.includes("--usage");
if (argv.some((arg) => arg !== "--usage")) {
process.stderr.write("discover.mjs: unexpected arguments. Use --help.\n");
process.exit(2);
}
const discovered = [];
const missing = [];
for (const impl of IMPLEMENTERS) {
const launch = resolveLaunch(impl);
if (!launch) {
missing.push({ key: impl.key, binary: impl.binary, skill: impl.skill });
continue;
}
const version = probeVersion(impl, launch);
const authArgs = impl.authProbe && !impl.authProbe.jsonField ? impl.authProbe.args : null;
const modelArgs = impl.modelProbe?.args;
const sharedCapture =
authArgs &&
modelArgs &&
authArgs.length === modelArgs.length &&
authArgs.every((arg, index) => arg === modelArgs[index])
? captureProbe(launch, authArgs, needsWindowsShell(impl, launch))
: null;
const entry = {
key: impl.key,
skill: impl.skill,
binary: impl.binary,
version,
path: launch.path,
authenticated: probeAuth(impl, launch, sharedCapture),
supports: [...impl.supports],
models: probeModels(impl, launch, sharedCapture),
};
if (withUsage) entry.usage = probeUsage(impl);
discovered.push(entry);
}
process.stdout.write(
`${JSON.stringify({ version: "delegate-discover.v1", discovered, missing }, null, 2)}\n`,
);
}
main(process.argv.slice(2));
scripts/implementers.mjs›
/**
* Canonical implementer registry for delegate-setup.
*
* One table: skill key → binary, launch hints, supported lane dials, probes.
* Consumed by discover.mjs and config.mjs. Relays will share this in Phase 2.
*
* `usageProbe.path` walks down from the CLI's state directory to the folders that
* hold session entries ("*" matches any directory at that level); `entry` + `match`
* say which names count as one session. Session files hold the user's conversations,
* so these are listed and stat'd only — never opened.
*
* Node built-ins only. No network, credentials, or telemetry.
*/
/** @typedef {"model"|"effort"|"variant"|"timeout"|"readOnly"|"sandbox"|"permissionMode"|"force"|"provider"} Dial */
/**
* @type {readonly {
* key: string,
* skill: string,
* binary: string,
* versionArgs: string[],
* versionFallbackArgs?: string[],
* versionFormat?: "colon-prefix",
* authProbe: null | {
* args: string[],
* jsonField?: string,
* successPattern?: RegExp,
* failPattern?: RegExp,
* missMeansFalse?: boolean,
* },
* modelProbe: null
* | { args: string[], format: "lines"|"cursor"|"grok"|"table"|"commandcode" }
* | { envDir: string, homeSubdir: string, file: string, format: "codex-cache" }
* | { static: readonly string[] },
* usageProbe: null | {
* envDir?: string,
* homeSubdir: string,
* path: readonly string[],
* entry: "file"|"dir"|"any",
* match: RegExp,
* },
* supports: Dial[],
* winShell: boolean,
* }[]}
*/
export const IMPLEMENTERS = Object.freeze([
{
key: "claude",
skill: "claude-delegate",
binary: "claude",
versionArgs: ["--version"],
authProbe: { args: ["auth", "status"], jsonField: "loggedIn" },
// No listing command; `--model` takes one of these aliases or a full model name.
modelProbe: { static: ["fable", "opus", "sonnet", "haiku"] },
// <config>/projects/<project-slug>/<session-uuid>.jsonl — one file per session.
usageProbe: {
envDir: "CLAUDE_CONFIG_DIR",
homeSubdir: ".claude",
path: ["projects", "*"],
entry: "file",
match: /\.jsonl$/,
},
supports: ["model", "effort", "timeout", "readOnly"],
winShell: true,
},
{
key: "cline",
skill: "cline-delegate",
binary: "cline",
versionArgs: ["--version"],
// No status command exposes login state; usage stays unknown rather than guessed —
// the CLI's conversations live under a profile that has no documented stable path.
authProbe: null,
modelProbe: null,
usageProbe: null,
// Plan mode is a dispatch flag (--plan), not a lane dial; readOnly is unsupported.
supports: ["provider", "model", "timeout"],
winShell: true,
},
{
key: "codex",
skill: "codex-delegate",
binary: "codex",
versionArgs: ["--version"],
// codex writes login status to stderr, not stdout.
authProbe: {
args: ["login", "status"],
successPattern: /logged in/i,
failPattern: /not logged in/i,
},
// Never spawn `codex models`: the positional word is read as a prompt and hits the API.
// The locally cached catalog is the only offline listing.
modelProbe: {
envDir: "CODEX_HOME",
homeSubdir: ".codex",
file: "models_cache.json",
format: "codex-cache",
},
// sessions/<yyyy>/<mm>/<dd>/rollout-*.jsonl. archived_sessions is a second
// store of the same shape; counting only live rollouts keeps one meaning per number.
usageProbe: {
envDir: "CODEX_HOME",
homeSubdir: ".codex",
path: ["sessions", "*", "*", "*"],
entry: "file",
match: /^rollout-.*\.jsonl$/,
},
supports: ["model", "effort", "sandbox", "timeout", "readOnly"],
winShell: true,
},
{
key: "commandcode",
skill: "commandcode-delegate",
// Command Code uses `cmdc` on Windows because `cmd` is the system shell.
// resolveLaunch owns that platform-specific lookup.
binary: "cmd",
versionArgs: ["--version"],
authProbe: { args: ["status"], successPattern: /Authenticated/i, failPattern: /not authenticated|logged out/i },
// Must stay a flag: `cmd models` would be read as a prompt. --list-models prints
// "vendor/name description" rows under section headers.
modelProbe: { args: ["--list-models"], format: "commandcode" },
// projects/<project-slug>/<session-uuid>.jsonl, with a sibling
// <session-uuid>.checkpoints.jsonl that must not count as a second session.
usageProbe: {
homeSubdir: ".commandcode",
path: ["projects", "*"],
entry: "file",
match: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl$/i,
},
// No sandbox or permissionMode dial: headless Command Code has only the withheld-tools
// default and --yolo, and --permission-mode does not change either.
supports: ["model", "effort", "timeout", "readOnly"],
winShell: false,
},
{
key: "opencode",
skill: "opencode-delegate",
binary: "opencode",
versionArgs: ["--version"],
// Exits 0 with an empty list too; a "●" row is the only auth signal, so a clean run
// without one is the logged-out state, not an ambiguous miss.
authProbe: { args: ["auth", "list"], successPattern: /^●\s/m, missMeansFalse: true },
modelProbe: { args: ["models"], format: "lines" },
// No usage probe: the session files under ~/.local/share/opencode/storage stopped
// being written when OpenCode moved to opencode.db, and a session count from that
// database would mean opening conversation content.
usageProbe: null,
// OpenCode reasoning intensity is --variant, not --effort.
supports: ["model", "variant", "timeout", "readOnly"],
winShell: true,
},
{
key: "agy",
skill: "agy-delegate",
binary: "agy",
versionArgs: ["changelog"],
versionFormat: "colon-prefix",
authProbe: null,
modelProbe: { args: ["models"], format: "lines" },
// Antigravity keeps CLI state under ~/.gemini, one .db per conversation.
usageProbe: {
homeSubdir: ".gemini/antigravity-cli",
path: ["conversations"],
entry: "file",
match: /\.db$/,
},
supports: ["model", "effort", "timeout", "readOnly"],
winShell: false,
},
{
key: "grok",
skill: "grok-delegate",
binary: "grok",
versionArgs: ["version"],
versionFallbackArgs: ["--version"],
authProbe: { args: ["models"], failPattern: /not authenticated/i },
modelProbe: { args: ["models"], format: "grok" },
// sessions/<url-encoded-cwd>/<session-uuid>/ — one directory per session; the
// prompt_history.jsonl sitting beside them is per-workspace, not per-session.
usageProbe: {
homeSubdir: ".grok",
path: ["sessions", "*"],
entry: "dir",
match: /^[0-9a-f]{8}-[0-9a-f]{4}-/i,
},
supports: ["model", "effort", "sandbox", "timeout", "readOnly"],
winShell: true,
},
{
key: "kimi",
skill: "kimi-delegate",
binary: "kimi",
versionArgs: ["--version"],
authProbe: { args: ["provider", "list"], successPattern: /source=(oauth|api)/ },
// No credential-free listing exists: the provider-list JSON and the config file behind it
// both inline provider api keys, and this script may not buffer credentials.
modelProbe: null,
// sessions/<wd_workspace>/session_<uuid>/ — one directory per session.
usageProbe: {
homeSubdir: ".kimi-code",
path: ["sessions", "*"],
entry: "dir",
match: /^session_/,
},
supports: ["model", "timeout"],
winShell: false,
},
{
key: "qoder",
skill: "qoder-delegate",
binary: "qodercli",
versionArgs: ["--version"],
authProbe: null,
modelProbe: null,
// No documented local session store; usage stays unknown rather than guessed.
usageProbe: null,
supports: ["model", "permissionMode", "timeout", "readOnly"],
winShell: false,
},
{
key: "vibe",
skill: "vibe-delegate",
binary: "vibe",
versionArgs: ["--version"],
authProbe: null,
modelProbe: null,
usageProbe: null,
supports: ["timeout", "readOnly"],
winShell: false,
},
{
key: "cursor",
skill: "cursor-delegate",
binary: "cursor-agent",
versionArgs: ["--version"],
authProbe: {
args: ["status"],
successPattern: /logged in as/i,
failPattern: /not logged in/i,
},
modelProbe: { args: ["models"], format: "cursor" },
// projects/<project-slug>/agent-transcripts/<chat-uuid>/ — what the CLI writes per
// chat; some layouts write flat <chat-uuid>.txt files instead, so accept either.
// ~/.cursor/chats holds only a handful of older IDE-side entries.
usageProbe: {
homeSubdir: ".cursor",
path: ["projects", "*", "agent-transcripts"],
entry: "any",
match: /^[0-9a-f]{8}-[0-9a-f]{4}-/i,
},
// Cursor sandbox is explicit per dispatch only; it is intentionally not a fleet dial.
supports: ["model", "force", "timeout", "readOnly"],
winShell: true,
},
{
key: "pi",
skill: "pi-delegate",
binary: "pi",
versionArgs: ["--version"],
authProbe: null,
// Must stay a flag: `pi models` is read as a prompt and hits the API.
modelProbe: { args: ["--list-models"], format: "table" },
// ~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl.
usageProbe: {
homeSubdir: ".pi/agent",
path: ["sessions", "*"],
entry: "file",
match: /\.jsonl$/,
},
supports: ["provider", "model", "timeout", "readOnly"],
winShell: true,
},
{
key: "omp",
skill: "omp-delegate",
binary: "omp",
versionArgs: ["--version"],
authProbe: null,
// Must stay the `models` subcommand: `omp --list-models` is a stale flag and a
// hard error. Catalog output is grouped/JSON, not a stable identifier list, so
// this script does not parse it — the skill documents `omp models` instead.
modelProbe: null,
// ~/.omp/agent/sessions/<encoded-cwd>/<timestamp>_<sessionId>.jsonl.
usageProbe: {
envDir: "PI_CODING_AGENT_DIR",
homeSubdir: ".omp/agent",
path: ["sessions", "*"],
entry: "file",
match: /\.jsonl$/,
},
supports: ["provider", "model", "effort", "timeout", "readOnly"],
winShell: false,
},
{
key: "aider",
skill: "aider-delegate",
binary: "aider",
versionArgs: ["--version"],
// Aider has no auth subcommand; it reads provider keys from the environment
// and its own config, and reports a failure only once a run reaches the model.
authProbe: null,
// `aider --list-models` needs a partial-name argument, so there is no listing
// that covers the catalog; leaving this null beats probing with a guessed query.
modelProbe: null,
// No global session store: Aider keeps its chat history in the repo itself
// (.aider.chat.history.md), so there is nothing per-user to count.
usageProbe: null,
supports: ["model", "timeout", "readOnly"],
winShell: false,
},
{
key: "copilot",
skill: "copilot-delegate",
binary: "copilot",
versionArgs: ["version"],
// No status command exposes login state; copilot login is interactive only
// and there is no `copilot auth status` equivalent.
authProbe: null,
// No credential-free model listing command exists.
modelProbe: null,
// No documented local session store path is verified.
usageProbe: null,
supports: ["model", "effort", "timeout", "readOnly"],
winShell: true,
},
{
key: "warp",
skill: "warp-delegate",
binary: "oz",
versionArgs: ["--version"],
// `--output-format text` prints one `type:id` line; the default `pretty` format
// spells the type out ("User ID: …") and so reads differently per principal. A
// headless host authenticated with WARP_API_KEY is a service account, not a
// user, so the probe must accept both or it reports CI as logged out.
authProbe: {
args: ["whoami", "--output-format", "text"],
successPattern: /^(?:user|service_account):\S/m,
},
// `oz model list` emits a JSON array of {id} objects, which none of the shared
// list formats parse; ids are read from the CLI directly instead of probed.
modelProbe: null,
// Conversations live server-side, so there is no local session directory to count.
usageProbe: null,
// `oz agent run` exposes no sandbox, permission-mode, or read-only flag, so
// those dials are deliberately absent — the relay refuses a lane that sets one.
supports: ["model", "timeout"],
winShell: false,
},
{
key: "zcode",
skill: "zcode-delegate",
binary: "zcode",
versionArgs: ["--version"],
// ZCode ships its CLI inside the desktop app rather than on PATH or npm, so
// discovery falls back to the installed bundle. Consulted only when `binary`
// is absent from PATH; the bundle is a Node bundle, hence the node launcher.
locate: {
launcher: "node",
candidates: {
win32: ["%LOCALAPPDATA%/Programs/ZCode/resources/glm/zcode.cjs"],
darwin: [
"/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs",
"~/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs",
],
// Linux ships an AppImage with no fixed install path: nothing honest to guess.
linux: [],
},
},
// No auth-status command exists, and `zcode login` fails with ZaiCliOAuthError
// (seen on 0.16.1 and 0.16.3), so auth stays unknown rather than guessed.
authProbe: null,
// No --model flag: the model is chosen in the CLI's own config file.
modelProbe: null,
// ~/.zcode/cli holds sess_* directories under several subdirectories, but their
// counts disagree, so none is proven one-per-session. Unknown, not guessed.
usageProbe: null,
supports: ["permissionMode", "timeout", "readOnly"],
winShell: false,
},
]);
/** Prototype-free map so names like "toString" cannot pass as implementers. */
export const IMPLEMENTER_BY_KEY = Object.freeze(
IMPLEMENTERS.reduce((map, impl) => {
map[impl.key] = impl;
return map;
}, Object.create(null)),
);
export const CLAUDE_EFFORT = Object.freeze(["low", "medium", "high", "xhigh", "max", "ultracode"]);
export const AGY_EFFORT = Object.freeze(["low", "medium", "high"]);
export const COPILOT_EFFORT = Object.freeze(["low", "medium", "high", "xhigh", "max"]);
export const OMP_THINKING = Object.freeze(["off", "auto", "minimal", "low", "medium", "high", "xhigh", "max"]);
export const CODEX_SANDBOX = Object.freeze(["read-only", "workspace-write", "danger-full-access"]);
export const GROK_SANDBOX = Object.freeze(["workspace", "read-only", "off"]);
export const QODER_PERMISSION = Object.freeze([
"default",
"accept_edits",
"auto",
"bypass_permissions",
"dont_ask",
"plan",
]);
/**
* ZCode's --mode, carried as the `permissionMode` dial. `build` and `edit` are
* deliberately absent: a headless run has no permission client, so they block
* every write tool and exit 0 having changed nothing. The relay rejects them for
* the same reason, so a lane must not be able to select one either.
*/
export const ZCODE_MODE = Object.freeze(["plan", "yolo"]);
/** Positive h/m/s duration, same shape relays accept. */
export const TIMEOUT_RE = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/;
/**
* Model/provider token shapes mirrored from each relay's parseArgs.
* Relays that do not constrain the string still get a non-empty check in config.mjs.
*/
export const MODEL_TOKEN = Object.freeze({
/** Keep in lockstep with claude-delegate SAFE_MODEL. */
claude: /^[A-Za-z0-9][A-Za-z0-9._:@\/\[\]-]*$/,
/** Keep in lockstep with cursor-delegate SAFE_MODEL. */
cursor: /^[A-Za-z0-9][A-Za-z0-9._:@\/\[\]\,=-]*$/,
/** Keep in lockstep with grok/pi/codex/commandcode shell-safe tokens (also used for opencode). */
shellSafe: /^[A-Za-z0-9][A-Za-z0-9._:\/-]*$/,
});
export const CONFIG_VERSION = "delegate-fleet.v1";
export const LANE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
export const ALL_DIALS = Object.freeze([
"model",
"effort",
"variant",
"timeout",
"readOnly",
"sandbox",
"permissionMode",
"force",
"provider",
]);
scripts/lane.mjs›
#!/usr/bin/env node
/**
* lane.mjs — resolve a fleet lane for a relay (or print help).
*
* Usage:
* node lane.mjs resolve --cwd <dir> --lane <name> --implementer <key>
* node lane.mjs --help
*
* On success, prints JSON:
* { "lane", "source", "implementer", "skill", "dials": { ...relay-native fields } }
*
* `dials` uses field names the target relay understands (e.g. grok gets `autonomy`,
* opencode gets `agent` for read-only). Relays apply dials only where a CLI flag
* did not already set the field.
*
* Node built-ins only.
*/
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolve as resolvePath } from "node:path";
import {
IMPLEMENTER_BY_KEY,
LANE_NAME,
} from "./implementers.mjs";
import { loadEffective } from "./config.mjs";
const HELP = `lane.mjs — resolve a delegate-fleet.v1 lane for a relay
Usage:
node lane.mjs resolve --cwd <dir> --lane <name> --implementer <key>
node lane.mjs --help
`;
function fail(message) {
process.stderr.write(`lane.mjs: ${message}\n`);
process.exit(2);
}
/**
* Map a stored lane onto dials the named relay can apply directly.
* @returns {{ lane: string, source: string, implementer: string, skill: string, dials: Record<string, unknown> }}
*/
export function resolveLaneForRelay(cwd, laneName, implementerKey) {
if (!LANE_NAME.test(laneName)) {
throw new Error(`invalid lane name ${JSON.stringify(laneName)}`);
}
if (IMPLEMENTER_BY_KEY[implementerKey] == null) {
throw new Error(`unknown implementer ${JSON.stringify(implementerKey)}`);
}
const effective = loadEffective(cwd);
const entry = effective.lanes[laneName];
if (!entry) {
throw new Error(`fleet lane not found: ${laneName}`);
}
const { source, implementer, ...rest } = entry;
if (source === "project" && !effective.projectTrusted) {
throw new Error(
"project fleet config is not trusted; review and approve it with delegate-setup before dispatch",
);
}
if (implementer !== implementerKey) {
const bound = IMPLEMENTER_BY_KEY[implementer];
throw new Error(
`fleet lane "${laneName}" → ${implementer} (use ${bound.skill}); this relay is ${implementerKey}`,
);
}
const dials = normalizeDials(implementerKey, rest);
return {
lane: laneName,
source,
implementer,
skill: IMPLEMENTER_BY_KEY[implementer].skill,
dials,
};
}
function normalizeDials(implementerKey, raw) {
const dials = { ...raw };
if (implementerKey === "grok") {
if (typeof dials.sandbox === "string") {
const map = {
workspace: "workspace-write",
"read-only": "read-only",
off: "full-access",
};
dials.autonomy = map[dials.sandbox] ?? dials.sandbox;
delete dials.sandbox;
}
// Grok relay consumes autonomy, not readOnly.
if (dials.readOnly === true) {
if (dials.autonomy === undefined) dials.autonomy = "read-only";
delete dials.readOnly;
}
}
if (implementerKey === "codex" && dials.readOnly === true) {
if (dials.sandbox === undefined) dials.sandbox = "read-only";
delete dials.readOnly;
}
if (implementerKey === "opencode" && dials.readOnly === true) {
dials.agent = "plan";
delete dials.readOnly;
}
if (implementerKey === "qoder" && dials.readOnly === true) {
if (dials.permissionMode === undefined) dials.permissionMode = "plan";
delete dials.readOnly;
}
if (implementerKey === "omp" && typeof dials.effort === "string") {
dials.thinking = dials.effort;
delete dials.effort;
}
if (implementerKey === "vibe" && dials.readOnly === true) {
dials.planOnly = true;
delete dials.readOnly;
}
// agy / claude / cursor / pi / omp keep readOnly as a boolean on opts
return dials;
}
/**
* Apply resolved dials onto relay opts. `flagged` lists fields set by CLI flags.
*/
export function mergeDials(opts, dials, flagged) {
for (const [field, value] of Object.entries(dials)) {
if (flagged.has(field)) continue;
// read-only / sandbox / agent / autonomy often share a flag surface
if (field === "sandbox" && (flagged.has("sandbox") || flagged.has("readOnly"))) continue;
if (field === "autonomy" && (flagged.has("autonomy") || flagged.has("sandbox") || flagged.has("readOnly"))) {
continue;
}
if (field === "agent" && (flagged.has("agent") || flagged.has("readOnly"))) continue;
if (field === "permissionMode" && (flagged.has("permissionMode") || flagged.has("readOnly"))) continue;
if (
field === "planOnly" &&
(flagged.has("planOnly") || flagged.has("readOnly") || flagged.has("fullAccess"))
) {
continue;
}
if (
field === "readOnly" &&
(flagged.has("readOnly") ||
flagged.has("dangerouslySkipPermissions") ||
flagged.has("fullAccess"))
) {
continue;
}
if (field === "force" && flagged.has("force")) continue;
if (field === "thinking" && flagged.has("thinking")) continue;
opts[field] = value;
}
}
function parseResolveArgs(argv) {
let cwd = process.cwd();
let lane = null;
let implementer = null;
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
const value = argv[++i];
if (value === undefined) fail(`${arg} requires a value`);
return value;
};
if (arg === "--cwd") cwd = resolvePath(next());
else if (arg === "--lane") lane = next();
else if (arg === "--implementer") implementer = next();
else fail(`unknown option: ${arg}`);
}
if (!lane) fail("resolve requires --lane");
if (!implementer) fail("resolve requires --implementer");
return { cwd, lane, implementer };
}
function main(argv) {
if (argv.includes("--help") || argv.includes("-h") || argv.length === 0) {
process.stdout.write(HELP);
process.exit(argv.length === 0 ? 2 : 0);
}
const cmd = argv[0];
if (cmd !== "resolve") fail(`unknown command ${JSON.stringify(cmd)}`);
try {
const { cwd, lane, implementer } = parseResolveArgs(argv.slice(1));
const result = resolveLaneForRelay(cwd, lane, implementer);
process.stdout.write(`${JSON.stringify(result)}\n`);
} catch (error) {
fail(error.message || String(error));
}
}
// realpath BOTH sides, not resolve: mirrors config.mjs — symlinked install paths
// otherwise make this silently no-op (including under --preserve-symlinks-main,
// where the module URL keeps the symlink path), and relays call it for every
// --lane dispatch.
const isMain = (() => {
if (!process.argv[1]) return false;
const toReal = (path) => {
try {
return realpathSync(path);
} catch {
return resolvePath(path);
}
};
return toReal(process.argv[1]) === toReal(fileURLToPath(import.meta.url));
})();
if (isMain) main(process.argv.slice(2));
SKILL.md›
---
name: delegate-setup
description: >-
Configure delegation fleet lanes: which implementer CLI handles which kind of work,
with optional model and effort (or variant) dials. Discovers installed CLIs, proposes
a lane map for user approval, and writes global or project config only after explicit
yes. Use when the user asks to set up, configure, or reconfigure delegation lanes,
a fleet of lanes, or which implementer handles feature/tests/ui work — not for
dispatching a coding task to an implementer.
license: MIT
compatibility: Requires Node 18+. No implementer CLIs are required — the skill discovers what is available.
metadata:
version: 0.5.0
---
# Delegate Setup
You are the **orchestrator** in **setup mode**. Discover installed implementer CLIs, propose a
**fleet of lanes**, and write configuration only after the user approves.
This skill does **not** dispatch coding work. It only authors the lane map.
One concept: **lanes**. Never say “routes.”
Example lane: **feature** → implementer `opencode`, model `opencode/grok`, variant `high`
(OpenCode uses `variant` for reasoning intensity, not `effort`).
## When NOT to use this
- The user wants a task implemented — use the matching `*-delegate` skill instead.
- A one-off model change on a single dispatch — pass `--model` / `--effort` / `--variant` on that relay.
## Hard rules
1. Every lane **must** include `implementer`.
2. Put dials on the same object (`model`, `effort` or `variant`, …) only if that implementer supports them — see [references/schema.md](references/schema.md).
3. Show a human-readable lane table **and** the full JSON before every write; re-show after every tweak.
4. Write **only** after an explicit approval (“yes”, “approve”, “write it”).
5. Ask scope unless already clear: **global** (all projects) vs **this repo only**. Never create a project file just because cwd is a git repo. If there is no git repo, default to global and say so.
6. Do not invent model identifiers.
7. In interview or usage-scan mode, never write **any** dial the user did not give you and the schema does not require — omit it, so the CLI’s or relay’s own default applies.
8. Prefer 3–5 useful lanes over a kitchen-sink map.
9. Never edit `AGENTS.md`, `CLAUDE.md`, or other user agent-instruction files.
10. Never run a `*-delegate` relay from this skill.
(`<skill-dir>` is this skill’s install directory — the folder that contains this `SKILL.md`.)
## Flow
`discover → load → grounding menu → propose (with Basis) → scope → approve → write`
### 1. Discover
```bash
node "<skill-dir>/scripts/discover.mjs"
```
Summarize installed vs missing, auth (`true` / `false` / `null` = unknown), and whether models were
`reported`, `aliases` (curated aliases in the registry, not live discovery — full model names also
work), `unsupported`, or `failed`.
### 2. Load existing (effective map)
```bash
node "<skill-dir>/scripts/config.mjs" load --cwd "$PWD"
```
- Neither present → “No lanes configured yet.”
- Otherwise → table of **effective** lanes with a Source column (`global` / `project`). Do not paste
both raw files unless asked.
- If `projectPresent` is true and `projectTrusted` is false, label the project lanes **untrusted**.
They cannot dispatch until the user reviews and approves a project write.
### 3. Propose
Discovery reports capability, never task fit. So ask **one** grounding question before proposing
anything — one question, three options, not a wizard:
> How should I pick the lanes? **(1) Quick defaults** — I decide, no questions.
> **(2) Interview** — about four questions on how you want work allocated.
> **(3) Usage scan** — I re-read your CLIs’ local session folders (counts and dates only, never the
> conversations) and let the numbers place your lanes — if one CLI dominates, expect one question
> about its role. Happy to do 2 and 3 together.
- **Quick defaults** → propose immediately.
- **Interview** → the four questions (allocation policy, never model rankings) and how to ask them
(one medium per round) live in [references/setup-dialogue.md](references/setup-dialogue.md) — read
it before you ask.
- **Usage scan** → `node "<skill-dir>/scripts/discover.mjs" --usage`. Tell the user it is metadata
only before running it. Each discovered CLI gains `usage: { sessions, lastUsed }`; `null` means no
probe is wired — unknown, not unused.
- **Both** → run the scan first, then ask only what the numbers cannot answer.
- Inside a git repo, repo signals (languages, test weight, frontend share) are a fourth source of
evidence. They do not change the menu; they feed the proposal and the `repo` basis.
**That menu is also the consent surface** — the option chosen sets how much of the map is yours to
decide:
- **Quick defaults** — the user hired your opinion. A full map is legitimate, dials included; label
every lane `my opinion`, say plainly that the map is your opinion, and keep it cheap to revise.
- **Interview / usage scan** — evidence modes, so **every** dial is gated (rule 7): set one only from
the user’s answer, or where the schema requires it (opencode lanes require `model`). Omitting is
always safe — every dial has a default the user already lives with, and a CLI’s configured default
is their standing choice, better evidence than your priors. Choosing which installed implementer
gets a lane is still yours — Basis `my opinion` — but a dial that raises spend is not: offer your
dial picks only as an addendum after the proposal, see
[references/setup-dialogue.md](references/setup-dialogue.md).
- **An unanswered question shrinks the map; it never licenses a substitution.** Propose fewer, more
conservative lanes, name the axis you are blind on (no quota answer → say the map is quota-blind),
and invite the answer anytime. Re-ask once at most; never backfill silence with priors.
**Delegation economics.** The orchestrator reviews and lands every result — the review is the
quality gate, so optimize total cost, not implementer prestige:
- Prefer capable, authenticated, burnable, **low-usage** CLIs for bounded, objectively gated work
(tests, mechanical refactors, straightforward fixes) when their reliability keeps review and
rework economical — lanes push token burn away from the subscriptions the user is protecting.
Low usage alone does not establish burnable: discovery cannot see plans, limits, or per-run
cost, and a rarely-used CLI may be metered or deliberately avoided. Burnable comes from the
user's quota answer — or, in quick defaults, from your labeled opinion.
- Avoid binding a lane to a CLI the user is protecting or orchestrates from, by default; bind it
only when the user asks for it or no acceptable alternative exists. Lanes are
**orchestrator-blind**: the same lane fires from every seat the user drives from, and from that
CLI's own seat it dispatches the CLI to itself.
- Surplus placement breaks down when rework and review cost exceed the savings; when the
implementer is flaky; when correctness rides on security, concurrency, migrations, or unstated
domain knowledge; and when the output **is** the product (debate, architecture, research) —
review limits damage, it does not manufacture a good first attempt. Bind those lanes to stronger
implementers.
- An explicit "spare X" answer removes X from proposed lanes by default, and overrides blanket
posture answers on any lane the user explicitly retains for X — ask whether the posture applies
there; omit the dial if unanswered. Never silently stretch one answer across an axis it
conflicts with.
Question phrasings for the burn/spare and trust interview live in
[references/setup-dialogue.md](references/setup-dialogue.md).
Then propose the lanes. Name them after the work the user described; fall back to `feature`, `tests`,
`ui`, `fast`, `complex`. Installed implementers only.
Show:
| Lane | Implementer | Model | Effort / variant | Basis | Source (if updating) |
| --- | --- | --- | --- | --- | --- |
| feature | opencode | opencode/grok | variant: high | your answer + schema requirement | — |
| tests | codex | — | — | usage data | — |
| ui | claude | — | — | my opinion (implementer) | — |
**Basis** is mandatory on every lane: `your answer` / `usage data` / `repo` / `my opinion` /
`schema requirement` (a dial the schema forces is neither evidence nor opinion — say so). A lane you
picked from model-quality priors is `my opinion` — never present it as something the tooling
determined, and “installed and authenticated” is capability, not evidence of fit. When a lane’s
implementer and its dials come from different places, split the label — see
[references/setup-dialogue.md](references/setup-dialogue.md).
Then the **complete** JSON (`version`: `delegate-fleet.v1`). One line of why per lane; flag auth or
model uncertainty.
Schema and dial table: [references/schema.md](references/schema.md).
### 4. Scope
- User said global / all projects / outside the project → `global`.
- No git repo → `global` (say so).
- Else ask once: global vs this repo only.
### 5. Approve and write
On explicit yes, write **only** the chosen scope (validate first). Build the payload from that
scope’s raw file (or an empty `lanes` object if new) — not from the effective merged `load` view,
or a project write will shadow global-only lanes and a global write will promote project-only ones.
Create a uniquely named file under the platform temporary directory (`$TMPDIR`, `%TEMP%`, or Node
`os.tmpdir()`; never hard-code `/tmp`, which breaks on native Windows), write the **exact approved
JSON** into it with the orchestrator's file-writing tool, and use that populated path as
`<lanes-json>` below. Never validate an empty temp file. Remove the temp file after the
validation/write attempt, whether it succeeds or fails.
```bash
node "<skill-dir>/scripts/config.mjs" validate "<lanes-json>"
node "<skill-dir>/scripts/config.mjs" write --scope global "<lanes-json>"
# or: write --scope project --cwd /path/to/repo "<lanes-json>"
```
Re-read with `load`, then confirm the path written and the active lane names. Project writes bind
approval to the exact config content; later changes fail closed until re-approved. On update, a short
before/after is enough.
### 6. Ready to delegate
Stop after confirming. Tell the user the map is ready. For later work: read the lane’s
`implementer`, load that `*-delegate` skill, and dispatch with `--lane <name>` (explicit
`--model` / `--effort` / `--variant` still win when passed). Do not start a delegate task
unless they ask.
## Reconfigure
Same flow. Show the effective current map, propose changes, approve, write one scope’s file.
Reinstalling the skills package must not rewrite these files — they live outside the package.