add delegate-setup utility for fleet lane configuration

Introduce a setup-only skill that discovers implementer CLIs and writes
delegate-fleet.v1 lane maps (implementer + dials) to global or project
config after approval, without dispatching work or touching agent instruction files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ahmed Mohammed
2026-07-31 18:32:32 +03:00
parent 347f2fa221
commit fdca81f984
12 changed files with 1064 additions and 24 deletions
+3
View File
@@ -8,5 +8,8 @@ node_modules/
# relay.mjs run artifacts, if a user points --out-dir back into the repo
.delegate/
# smoke fixtures for delegate-setup (created under the repo tree)
.tmp-fleet-smoke-*/
# local agent config symlink (see AGENTS.md)
CLAUDE.md
+20 -14
View File
@@ -2,11 +2,12 @@
This repo is a [Skills CLI](https://github.com/vercel-labs/skills) package of **delegation skills**
skills that let an orchestrating agent drive a separate CLI coding agent as an implementer, then review
and land the result. Ten skills ship today: `claude-delegate` (Claude Code), `codex-delegate` (OpenAI
Codex), `opencode-delegate` (OpenCode), `agy-delegate` (Google Antigravity), `grok-delegate` (Grok
Build), `kimi-delegate` (Kimi Code), `qoder-delegate` (Qoder CLI), `vibe-delegate` (Mistral Vibe),
`cursor-delegate` (Cursor Agent CLI), and `pi-delegate` (Pi CLI); siblings like `gemini-delegate` can
be added later without renaming the repo.
and land the result. Ten implementer skills ship today: `claude-delegate` (Claude Code),
`codex-delegate` (OpenAI Codex), `opencode-delegate` (OpenCode), `agy-delegate` (Google Antigravity),
`grok-delegate` (Grok Build), `kimi-delegate` (Kimi Code), `qoder-delegate` (Qoder CLI),
`vibe-delegate` (Mistral Vibe), `cursor-delegate` (Cursor Agent CLI), and `pi-delegate` (Pi CLI);
siblings like `gemini-delegate` can be added later without renaming the repo. One **utility** skill
ships alongside them: `delegate-setup` (configure fleet lanes — setup only, never dispatches).
## Vocabulary
@@ -23,6 +24,9 @@ jargon. Use these terms; don't invent synonyms.
| **dispatch** | sending the brief to the implementer | "fire off", "kick off" |
| **land** | commit the verified work yourself | — |
| **relay** / `relay.mjs` | the dispatch **script** only | never a *category* of skills |
| **lane** | a named fleet binding: implementer + optional dials (`model`, `effort` / `variant`, …) | "route", "profile" |
| **fleet** | the user's set of lanes (which CLI handles which kind of work) | — |
| **setup skill** / `delegate-setup` | utility that discovers CLIs and writes the lane map after approval | a `*-delegate` skill |
| `exec`, `sandbox`, `resume`, `session` | Codex's own terms — use verbatim | don't paraphrase them |
| `run`, `agent` (`build`/`plan`), `session` | OpenCode's own terms — use verbatim | "sandbox" (OpenCode has no sandbox enum; autonomy is the agent) |
| `project`, `conversation`, `model`, `permissions`, `sandbox`, `TUI`, `tasks`, `subagents` | Antigravity's own terms — use verbatim when discussing `agy` | don't use `subagents` as a generic synonym for implementer |
@@ -46,8 +50,13 @@ the skill's `relay.mjs`.
## Conventions
- **One skill per directory** under `skills/<name>/`, each with a `SKILL.md` plus optional
`references/` and `scripts/`. The verb is the repo (`delegate`); the target agent is the skill name
(`codex-delegate`), mirroring `guard-skills``clean-code-guard`.
`references/` and `scripts/`. Implementer skills are named `<cli>-delegate` (the verb is the repo;
the target agent is the skill name), mirroring `guard-skills``clean-code-guard`.
- **Utility skills** (today: `delegate-setup`) are the exception to the implementer shape: they are
not `<cli>-delegate`, they do not ship `scripts/relay.mjs` or the four brief/dispatch/review/queue
references, and they never dispatch coding work. They still use Node built-ins only, no network of
their own, no credentials, no telemetry. Document any new utility in `CONTRIBUTING.md` and register
it in `skills.sh.json` and the smoke suite's utility carve-out.
- **`SKILL.md` frontmatter:** `name` (must equal the directory), `description`, and optionally
`license`, `compatibility`, `metadata.version`, `allowed-tools`. The **`description` is the only
triggering signal** — keep it to what the skill does and when to use it, phrased to trigger reliably.
@@ -56,13 +65,10 @@ the skill's `relay.mjs`.
the skill otherwise.
- **Progressive disclosure:** keep `SKILL.md` lean; push depth into `references/*.md` that load only
when needed.
- **Executables:** keep them minimal and inspectable. Today there is one per skill — a
`scripts/relay.mjs` under each of `skills/claude-delegate/`, `skills/codex-delegate/`,
`skills/opencode-delegate/`, `skills/agy-delegate/`, `skills/grok-delegate/`,
`skills/kimi-delegate/`, `skills/qoder-delegate/`, `skills/vibe-delegate/`, and
`skills/cursor-delegate/`, and `skills/pi-delegate/` — each Node built-ins only, no dependencies,
no network calls of its own, no credentials, no telemetry. New scripts must hold the same line,
and the README's trust section must stay accurate.
- **Executables:** keep them minimal and inspectable. Each `*-delegate` skill has one
`scripts/relay.mjs`. Utility skills may ship other scripts (e.g. `discover.mjs`, `config.mjs`) under
the same trust line: Node built-ins only, no dependencies, no network calls of their own, no
credentials, no telemetry. The README's trust section must stay accurate.
## Before publishing a change
+17
View File
@@ -38,6 +38,23 @@ Four invariants hold for every skill here, and they are the bar for a new one:
- [ ] A verification line in the README's **Verification status** list. Claim only what you ran —
"contract-tested, live run pending" is a mergeable answer. "Verified" without a run is not.
## Utility skills (exception)
`delegate-setup` is a **utility** skill: it configures fleet **lanes** (discover → propose → approve →
write). It is not an implementer skill.
Utility checklist (instead of the four-references + `relay.mjs` bar above):
- [ ] `skills/<name>/SKILL.md` with a `description` that triggers on setup/configure — **not** on
ordinary delegation.
- [ ] Scripts under `scripts/` stay Node built-ins only (same trust line as relays).
- [ ] No `relay.mjs`; the skill must not dispatch coding work to an implementer.
- [ ] Registered in `skills.sh.json` (Setup grouping is fine).
- [ ] Listed in the smoke suite's **utility** carve-out (not the `*-delegate` timeout/abort matrix).
- [ ] A short README mention and vocabulary in `AGENTS.md` (`lane`, `fleet`, setup skill).
Do not invent a second utility that duplicates lane setup. Extend `delegate-setup` instead.
## Everything else
Fixes to a relay, a reference, or the README need no claim. Keep the diff to one concern, run
+22 -5
View File
@@ -45,6 +45,17 @@ Each skill name links to its `SKILL.md`, which owns that implementer's prerequis
caveats. Building one for another CLI? [Claim it first](../../issues?q=is%3Aissue+label%3Aimplementer),
then see [CONTRIBUTING.md](CONTRIBUTING.md).
### Setup (utility)
| Skill | Job |
| --- | --- |
| [`delegate-setup`](skills/delegate-setup/SKILL.md) | Discover installed CLIs, propose **fleet lanes** (implementer + model/effort dials), write global or project config after you approve. Never dispatches work. |
A **lane** is a named binding such as “feature → OpenCode, model `grok`, variant `high`.” Config lives at
`~/.config/delegate-skills/config.json` (global) or `.delegate/config.json` (opt-in per repo). Relays
do not yet consume `--lane`; until they do, use the map as orchestrator guidance and pass dials as
flags.
## Install
Browse first:
@@ -139,11 +150,14 @@ Full checklist: [CONTRIBUTING.md](CONTRIBUTING.md).
This package is intentionally inspectable:
- All skill content is Markdown, plus exactly **one** executable per skill — each a `scripts/relay.mjs`.
- Each `relay.mjs` makes no network calls, reads or writes no credentials, sends no telemetry, and has
no dependencies (Node built-ins only). It launches its implementer CLI and `git`, plus the platform
process launcher/termination utility where a Windows shim or process-tree kill requires one. The
implementer CLI authenticates exactly as you do at the terminal. Read the script before you run it.
- All skill content is Markdown, plus small Node scripts. Each `*-delegate` skill has exactly one
`scripts/relay.mjs`. The `delegate-setup` utility ships `discover.mjs` / `config.mjs` (and a shared
implementer table) instead of a relay — it never dispatches coding work.
- Those scripts make no network calls of their own, read or write no credentials, send no telemetry, and
have no dependencies (Node built-ins only). Relays launch an implementer CLI and `git`, plus the
platform process launcher/termination utility where a Windows shim or process-tree kill requires one.
Discover may invoke installed CLIs for `--version` / model list probes (those CLIs may contact their
own services). Read the script before you run it.
- None of the relays ever commit — committing is always the orchestrator's job, after review.
**Verification status** — claims here are backed by runs, not assumptions.
@@ -177,6 +191,9 @@ Per skill — platform, CLI version, and what the run exercised:
- `codex-delegate`, `opencode-delegate`, `vibe-delegate` — contract-tested only: argument validation,
bounded version preflight, missing binary, result parsing, and whole-process-tree timeout/abort
cleanup. No end-to-end run is recorded here.
- `delegate-setup` — contract-tested: discover JSON shape, config validate/write/load, whole-lane
project overlay, global write without creating `.delegate/`. Live discover against installed CLIs
runs in smoke (versions vary by machine). Native Windows discover smoke not yet claimed.
Not yet verified: native Windows launches for `agy`, `claude`, `grok`, `kimi`, `pi`, `qoder`, and
`vibe` (the `codex`/`opencode`/`grok` `.cmd` shim handling is in place and quoted; Cursor serializes a
+7
View File
@@ -16,6 +16,13 @@
"cursor-delegate",
"pi-delegate"
]
},
{
"title": "Setup",
"description": "Configure fleet lanes: which implementer handles which kind of work.",
"skills": [
"delegate-setup"
]
}
]
}
+114
View File
@@ -0,0 +1,114 @@
---
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,
fleet routing, or which agent 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.1.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 `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. Prefer 35 useful lanes over a kitchen-sink map.
8. Never edit `AGENTS.md`, `CLAUDE.md`, or other user agent-instruction files.
9. Never run a `*-delegate` relay from this skill.
(`<skill-dir>` is this skills install directory — the folder that contains this `SKILL.md`.)
## Flow
### 1. Discover
```bash
node "<skill-dir>/scripts/discover.mjs"
```
Summarize installed vs missing, auth (`true` / `false` / `null` = unknown), and whether models were
`reported`, `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.
Details: [references/setup-dialogue.md](references/setup-dialogue.md).
### 3. Propose
Propose a compact set of lanes from discovery + what the user cares about. Starters when useful:
`feature`, `tests`, `ui`, `fast`, `complex` — only for installed implementers.
Show:
| Lane | Implementer | Model | Effort / variant | Source (if updating) |
| --- | --- | --- | --- | --- |
| feature | opencode | grok | variant: high | — |
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 path (validate first):
```bash
# build /tmp/lanes.json with the approved document (no "source" fields), then:
node "<skill-dir>/scripts/config.mjs" validate /tmp/lanes.json
node "<skill-dir>/scripts/config.mjs" write --scope global /tmp/lanes.json
# or: write --scope project --cwd /path/to/repo /tmp/lanes.json
```
Confirm the path written and the active lane names. On update, a short before/after is enough.
### 6. Hand-off
Stop after confirming. Tell the user the map is ready. Until relays grow `--lane` (follow-up), use
the map as guidance: pick the matching `*-delegate` skill and pass `--model` / `--effort` /
`--variant` from the lane. Do not start a delegate task unless they ask.
## Reconfigure
Same flow. Show the effective current map, propose changes, approve, write one scopes file.
Reinstalling the skills package must not rewrite these files — they live outside the package.
@@ -0,0 +1,70 @@
# 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": "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 | `~/.config/delegate-skills/config.json` |
| Project | `<git-root>/.delegate/config.json` |
Project overlays global by **whole-lane replace** (same lane name in project fully replaces the global lane).
## Implementer keys and dials
| Key | Skill | Binary | Supported dials |
| --- | --- | --- | --- |
| `claude` | claude-delegate | `claude` | model, effort, timeout, readOnly |
| `codex` | codex-delegate | `codex` | model, effort, sandbox, timeout, readOnly |
| `opencode` | opencode-delegate | `opencode` | model, **variant**, timeout, readOnly |
| `agy` | agy-delegate | `agy` | model, timeout |
| `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, sandbox, force, timeout, readOnly |
| `pi` | pi-delegate | `pi` | provider, model, timeout, readOnly |
OpenCode uses `variant` for reasoning intensity, not `effort`. Do not write `effort` on an `opencode` lane.
Boolean dials: `readOnly`, `force`. All other dials are non-empty strings. Duration strings for `timeout` use `h`/`m`/`s` (e.g. `30m`) when relays consume them in Phase 2.
## 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>
```
`load` prints the **effective** map (each lane includes a `source` of `global` or `project`).
@@ -0,0 +1,50 @@
# Setup dialogue details
Load this when running a configure / reconfigure session. The `SKILL.md` flow is authoritative; this page expands edge cases.
## Effective map (do not dump both files)
When loading existing config:
1. Run `node <skill-dir>/scripts/config.mjs load --cwd <dir>` (use the users cwd, or omit `--cwd`).
2. If `globalPresent` and `projectPresent` are both false → say “No lanes configured yet.”
3. Otherwise show a **table of effective lanes** from the `lanes` object. Include a Source column (`global` / `project`).
4. Paste both raw JSON files only if the user asks.
## Scope
| User said / situation | Scope |
| --- | --- |
| “global”, “all projects”, “outside the repo/project” | `global` — do not re-ask |
| Not inside a git repository | Default `global` and say so |
| Inside a git repo, scope unspecified | Ask once: global vs this repo only |
| “this repo only” / “project” | `project` |
Never create `.delegate/config.json` merely because cwd is a git repo.
## Writing
1. Build the JSON document (`version` + `lanes` only — strip any `source` fields from the effective view).
2. Validate dials against [schema.md](schema.md) (or `config.mjs validate`).
3. Show table + full JSON again after every tweak.
4. On explicit approval, write via:
```bash
# global
node <skill-dir>/scripts/config.mjs write --scope global /tmp/lanes.json
# project
node <skill-dir>/scripts/config.mjs write --scope project --cwd <repo> /tmp/lanes.json
```
Prefer writing a temp JSON file then calling `write`, or write atomically yourself with the same schema checks. Re-read with `load` and confirm the path.
## Auth and models
- `authenticated: null` means unknown, not “logged out.”
- Prefer not binding a lane to a CLI discover reports as `authenticated: false`.
- Do not invent model ids. 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).
## After write
Tell the user the path and active lane names. Remind them: later, pick the `*-delegate` skill matching the lanes `implementer` and pass `--lane <name>` once relays support it (Phase 2). Until then, use the map as orchestrator guidance and pass `--model` / `--effort` / `--variant` explicitly. Do not start a delegate task unless they ask.
+246
View File
@@ -0,0 +1,246 @@
#!/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,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import {
ALL_DIALS,
CONFIG_VERSION,
IMPLEMENTER_BY_KEY,
LANE_NAME,
} from "./implementers.mjs";
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() {
return join(homedir(), ".config", "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 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]) {
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`;
}
} else if (typeof lane[field] !== "string" || lane[field].length === 0) {
return `${label}: lane ${name}.${field} must be a non-empty string`;
}
}
return null;
}
export function readConfigFile(path) {
if (!path || !existsSync(path)) return null;
const parsed = parseConfigDocument(readFileSync(path, "utf8"), path);
if (!parsed.ok) fail(parsed.error);
return { path, document: parsed.document };
}
/**
* 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 effective = effectiveLanes(globalFile?.document, projectFile?.document);
return {
version: CONFIG_VERSION,
globalPath,
projectPath,
globalPresent: Boolean(globalFile),
projectPresent: Boolean(projectFile),
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) fail(parsed.error);
mkdirSync(dirname(targetPath), { recursive: true });
const dir = mkdtempSync(join(tmpdir(), "delegate-fleet-"));
const tmp = join(dir, "config.json");
try {
writeFileSync(tmp, `${JSON.stringify(parsed.document, null, 2)}\n`, "utf8");
renameSync(tmp, targetPath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
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];
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 !== "--cwd" && 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 target =
scope === "global" ? globalConfigPath() : projectConfigPath(cwd);
if (!target) fail("project scope requires a git repository (--cwd)");
const parsed = parseConfigDocument(readFileSync(resolve(file), "utf8"), file);
if (!parsed.ok) fail(parsed.error);
writeAtomic(target, parsed.document);
process.stdout.write(`${JSON.stringify({ ok: true, path: target, lanes: Object.keys(parsed.document.lanes) }, null, 2)}\n`);
return;
}
fail(`unknown command ${JSON.stringify(cmd)}. Use --help.`);
}
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) main(process.argv.slice(2));
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env node
/**
* discover.mjs — probe PATH for installed implementer CLIs.
*
* Usage:
* node discover.mjs JSON report on stdout
* 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 } from "node:child_process";
import { accessSync, constants as fsConstants, statSync } from "node:fs";
import { delimiter, join, resolve } 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 --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 | unsupported | failed.
`;
function resolveBinary(binary) {
const pathValue = process.env.PATH || process.env.Path || "";
if (!pathValue) return null;
const pathEntries = pathValue
.split(delimiter)
.map((entry) => entry.replace(/^"(.*)"$/, "$1"));
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;
}
function needsWindowsShell(impl, binaryPath) {
return process.platform === "win32" && (impl.winShell || /\.(?:cmd|bat)$/i.test(binaryPath));
}
function runProbe(binaryPath, args, useShell) {
return execFileSync(binaryPath, args, {
encoding: "utf8",
timeout: PROBE_TIMEOUT_MS,
stdio: ["pipe", "pipe", "pipe"],
shell: useShell,
});
}
function probeVersion(impl, binaryPath) {
const useShell = needsWindowsShell(impl, binaryPath);
const tryArgs = (versionArgs) => {
try {
const raw = runProbe(binaryPath, versionArgs, useShell);
return raw.trim().split("\n")[0].trim() || null;
} catch {
return null;
}
};
let version = tryArgs(impl.versionArgs);
if (version === null && impl.versionFallbackArgs) {
version = tryArgs(impl.versionFallbackArgs);
}
return version;
}
function probeAuth(impl, binaryPath) {
if (!impl.authProbe) return null;
const useShell = needsWindowsShell(impl, binaryPath);
try {
const raw = runProbe(binaryPath, impl.authProbe.args, useShell);
if (impl.authProbe.jsonField) {
return JSON.parse(raw)[impl.authProbe.jsonField] === true;
}
return true;
} catch (error) {
if (impl.authProbe.jsonField) {
try {
const combined = `${error.stdout || ""}${error.stderr || ""}`;
if (combined.trim()) {
return JSON.parse(combined)[impl.authProbe.jsonField] === true;
}
} catch {
// fall through
}
}
return false;
}
}
function parseModelLines(raw, format) {
const lines = raw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
const identifiers =
format === "cursor"
? lines
.filter((line) => line !== "Available models" && !line.startsWith("Tip:"))
.map((line) => line.split(/\s+-\s+/, 1)[0])
: lines;
const unique = [...new Set(identifiers)].filter(Boolean);
return {
status: "reported",
values: unique.slice(0, 200),
truncated: unique.length > 200,
};
}
function probeModels(impl, binaryPath) {
if (!impl.modelProbe) {
return { status: "unsupported", values: [], truncated: false };
}
try {
const raw = runProbe(
binaryPath,
impl.modelProbe.args,
needsWindowsShell(impl, binaryPath),
);
return parseModelLines(raw, impl.modelProbe.format);
} catch {
return { status: "failed", values: [], truncated: false };
}
}
function main(argv) {
if (argv.includes("--help") || argv.includes("-h")) {
process.stdout.write(HELP);
process.exit(0);
}
if (argv.length > 0) {
process.stderr.write("discover.mjs: unexpected arguments. Use --help.\n");
process.exit(2);
}
const discovered = [];
const missing = [];
for (const impl of IMPLEMENTERS) {
const binaryPath = resolveBinary(impl.binary);
if (!binaryPath) {
missing.push({ key: impl.key, binary: impl.binary, skill: impl.skill });
continue;
}
discovered.push({
key: impl.key,
skill: impl.skill,
binary: impl.binary,
version: probeVersion(impl, binaryPath),
path: binaryPath,
authenticated: probeAuth(impl, binaryPath),
supports: [...impl.supports],
models: probeModels(impl, binaryPath),
});
}
process.stdout.write(
`${JSON.stringify({ version: "delegate-discover.v1", discovered, missing }, null, 2)}\n`,
);
}
main(process.argv.slice(2));
@@ -0,0 +1,146 @@
/**
* 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.
*
* 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[],
* authProbe: null | { args: string[], jsonField?: string },
* modelProbe: null | { args: string[], format: "lines"|"cursor" },
* supports: Dial[],
* winShell: boolean,
* }[]}
*/
export const IMPLEMENTERS = Object.freeze([
{
key: "claude",
skill: "claude-delegate",
binary: "claude",
versionArgs: ["--version"],
authProbe: { args: ["auth", "status"], jsonField: "loggedIn" },
modelProbe: null,
supports: ["model", "effort", "timeout", "readOnly"],
winShell: true,
},
{
key: "codex",
skill: "codex-delegate",
binary: "codex",
versionArgs: ["--version"],
authProbe: null,
modelProbe: null,
supports: ["model", "effort", "sandbox", "timeout", "readOnly"],
winShell: true,
},
{
key: "opencode",
skill: "opencode-delegate",
binary: "opencode",
versionArgs: ["--version"],
authProbe: null,
modelProbe: { args: ["models"], format: "lines" },
// OpenCode reasoning intensity is --variant, not --effort.
supports: ["model", "variant", "timeout", "readOnly"],
winShell: true,
},
{
key: "agy",
skill: "agy-delegate",
binary: "agy",
versionArgs: ["changelog"],
authProbe: null,
modelProbe: { args: ["models"], format: "lines" },
supports: ["model", "timeout"],
winShell: false,
},
{
key: "grok",
skill: "grok-delegate",
binary: "grok",
versionArgs: ["version"],
versionFallbackArgs: ["--version"],
authProbe: null,
modelProbe: null,
supports: ["model", "effort", "sandbox", "timeout", "readOnly"],
winShell: true,
},
{
key: "kimi",
skill: "kimi-delegate",
binary: "kimi",
versionArgs: ["--version"],
authProbe: null,
modelProbe: null,
supports: ["model", "timeout"],
winShell: false,
},
{
key: "qoder",
skill: "qoder-delegate",
binary: "qodercli",
versionArgs: ["--version"],
authProbe: null,
modelProbe: null,
supports: ["model", "permissionMode", "timeout", "readOnly"],
winShell: false,
},
{
key: "vibe",
skill: "vibe-delegate",
binary: "vibe",
versionArgs: ["--version"],
authProbe: null,
modelProbe: null,
supports: ["timeout", "readOnly"],
winShell: false,
},
{
key: "cursor",
skill: "cursor-delegate",
binary: "cursor-agent",
versionArgs: ["--version"],
authProbe: null,
modelProbe: { args: ["--list-models"], format: "cursor" },
supports: ["model", "sandbox", "force", "timeout", "readOnly"],
winShell: true,
},
{
key: "pi",
skill: "pi-delegate",
binary: "pi",
versionArgs: ["--version"],
authProbe: null,
modelProbe: null,
supports: ["provider", "model", "timeout", "readOnly"],
winShell: true,
},
]);
export const IMPLEMENTER_BY_KEY = Object.freeze(
Object.fromEntries(IMPLEMENTERS.map((impl) => [impl.key, impl])),
);
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",
]);
+172 -5
View File
@@ -48,6 +48,8 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const SKILLS = ["claude", "codex", "opencode", "agy", "grok", "kimi", "qoder", "vibe", "cursor", "pi"];
/** Utility skills: not *-delegate, no relay.mjs / four-reference contract. */
const UTILITY_SKILLS = ["delegate-setup"];
const binaryName = (skill) => skill === "qoder" ? "qodercli" : skill === "cursor" ? "cursor-agent" : skill;
const relayPath = (skill) => join(here, "..", "skills", `${skill}-delegate`, "scripts", "relay.mjs");
const WIN = process.platform === "win32";
@@ -85,15 +87,16 @@ const alive = (pid) => {
// the one thing a hard-coded list cannot catch is its own omission.
{
const skillsDir = join(here, "..", "skills");
const onDisk = readdirSync(skillsDir).filter((d) => d.endsWith("-delegate")).sort();
const onDiskDelegate = readdirSync(skillsDir).filter((d) => d.endsWith("-delegate")).sort();
const onDiskUtility = readdirSync(skillsDir).filter((d) => UTILITY_SKILLS.includes(d)).sort();
const registered = new Set(
JSON.parse(readFileSync(join(here, "..", "skills.sh.json"), "utf8"))
.groupings.flatMap((g) => g.skills),
);
const REFERENCES = ["writing-the-brief", "dispatch-and-poll", "review-and-land", "multi-task-queues"];
check("skills/ is not empty", onDisk.length > 0);
for (const dir of onDisk) {
check("skills/ is not empty", onDiskDelegate.length > 0);
for (const dir of onDiskDelegate) {
const name = dir.replace(/-delegate$/, "");
check(`${name}: in the smoke matrix`, SKILLS.includes(name));
check(`${name}: SKILL.md`, existsSync(join(skillsDir, dir, "SKILL.md")));
@@ -105,8 +108,24 @@ const alive = (pid) => {
);
check(`${name}: listed in skills.sh.json`, registered.has(dir));
}
check("smoke matrix has no entry without a directory", SKILLS.every((s) => onDisk.includes(`${s}-delegate`)));
check("skills.sh.json has no entry without a directory", [...registered].every((s) => onDisk.includes(s)));
for (const dir of UTILITY_SKILLS) {
check(`${dir}: directory present`, existsSync(join(skillsDir, dir)));
check(`${dir}: SKILL.md`, existsSync(join(skillsDir, dir, "SKILL.md")));
check(`${dir}: no relay.mjs`, !existsSync(join(skillsDir, dir, "scripts", "relay.mjs")));
check(`${dir}: listed in skills.sh.json`, registered.has(dir));
check(`${dir}: in the utility carve-out`, onDiskUtility.includes(dir));
}
check("smoke matrix has no entry without a directory", SKILLS.every((s) => onDiskDelegate.includes(`${s}-delegate`)));
check(
"skills.sh.json has no entry without a directory",
[...registered].every((s) => existsSync(join(skillsDir, s))),
);
check(
"no unexpected skill directories",
readdirSync(skillsDir).every(
(d) => d.endsWith("-delegate") || UTILITY_SKILLS.includes(d),
),
);
}
// ---- every relay must at least parse ----
@@ -1526,6 +1545,154 @@ if (WIN) {
grandPid !== null && await until(() => !alive(grandPid), 5000));
}
// ---- delegate-setup: discover + fleet (fleet lanes) ----
{
const setupDir = join(here, "..", "skills", "delegate-setup", "scripts");
for (const script of ["discover.mjs", "config.mjs", "implementers.mjs"]) {
const c = spawnSync(process.execPath, ["--check", join(setupDir, script)], { encoding: "utf8" });
check(`syntax: delegate-setup/scripts/${script}`, c.status === 0);
}
const help = spawnSync(process.execPath, [join(setupDir, "discover.mjs"), "--help"], { encoding: "utf8" });
check("discover --help exits 0", help.status === 0 && /discover\.mjs/.test(help.stdout));
const discover = spawnSync(process.execPath, [join(setupDir, "discover.mjs")], {
encoding: "utf8",
timeout: 120_000,
});
check("discover exits 0", discover.status === 0);
let report = null;
try {
report = JSON.parse(discover.stdout);
} catch {
report = null;
}
check("discover reports version", report?.version === "delegate-discover.v1");
check("discover lists discovered or missing", Array.isArray(report?.discovered) && Array.isArray(report?.missing));
check(
"discover covers all ten implementers",
report && report.discovered.length + report.missing.length === SKILLS.length,
);
// Keep fixtures inside the repo tree so sandboxed CI/dev runs can write; seed a
// minimal .git without `git init` (hooks/config writes are often blocked).
const fleetRoot = mkdtempSync(join(here, "..", ".tmp-fleet-smoke-"));
const cfgHome = join(fleetRoot, "home");
const cfgRepo = join(fleetRoot, "repo");
const bare = join(fleetRoot, "bare");
mkdirSync(cfgHome);
mkdirSync(cfgRepo);
mkdirSync(bare);
mkdirSync(join(cfgRepo, ".git", "objects"), { recursive: true });
mkdirSync(join(cfgRepo, ".git", "refs", "heads"), { recursive: true });
writeFileSync(join(cfgRepo, ".git", "HEAD"), "ref: refs/heads/master\n");
writeFileSync(
join(cfgRepo, ".git", "config"),
"[core]\n\trepositoryformatversion = 0\n\tfilemode = true\n\tbare = false\n",
);
const prevHome = process.env.HOME;
const prevXdg = process.env.XDG_CONFIG_HOME;
process.env.HOME = cfgHome;
delete process.env.XDG_CONFIG_HOME;
const good = {
version: "delegate-fleet.v1",
lanes: {
feature: { implementer: "opencode", model: "grok", variant: "high" },
tests: { implementer: "grok", effort: "medium" },
},
};
const goodFile = join(cfgRepo, "lanes.json");
writeFileSync(goodFile, `${JSON.stringify(good, null, 2)}\n`);
const validate = spawnSync(process.execPath, [join(setupDir, "config.mjs"), "validate", goodFile], {
encoding: "utf8",
env: process.env,
});
check("config validate accepts a good map", validate.status === 0);
const badEffort = {
version: "delegate-fleet.v1",
lanes: { feature: { implementer: "opencode", model: "grok", effort: "high" } },
};
const badFile = join(cfgRepo, "bad.json");
writeFileSync(badFile, `${JSON.stringify(badEffort)}\n`);
const rejectEffort = spawnSync(process.execPath, [join(setupDir, "config.mjs"), "validate", badFile], {
encoding: "utf8",
env: process.env,
});
check("config validate rejects effort on opencode", rejectEffort.status === 2);
const writeGlobal = spawnSync(
process.execPath,
[join(setupDir, "config.mjs"), "write", "--scope", "global", goodFile],
{ encoding: "utf8", env: process.env },
);
check("config write --scope global", writeGlobal.status === 0);
const globalPath = join(cfgHome, ".config", "delegate-skills", "config.json");
check("global config file created", existsSync(globalPath));
const projectOnly = {
version: "delegate-fleet.v1",
lanes: {
feature: { implementer: "claude", effort: "high" },
},
};
const projectFile = join(cfgRepo, "project-lanes.json");
writeFileSync(projectFile, `${JSON.stringify(projectOnly, null, 2)}\n`);
const writeProject = spawnSync(
process.execPath,
[join(setupDir, "config.mjs"), "write", "--scope", "project", "--cwd", cfgRepo, projectFile],
{ encoding: "utf8", env: process.env },
);
check("config write --scope project", writeProject.status === 0);
check("project config file created", existsSync(join(cfgRepo, ".delegate", "config.json")));
const load = spawnSync(
process.execPath,
[join(setupDir, "config.mjs"), "load", "--cwd", cfgRepo],
{ encoding: "utf8", env: process.env },
);
check("config load exits 0", load.status === 0);
let effective = null;
try {
effective = JSON.parse(load.stdout);
} catch {
effective = null;
}
check(
"effective feature lane is project (whole-lane replace)",
effective?.lanes?.feature?.implementer === "claude" &&
effective?.lanes?.feature?.source === "project" &&
effective?.lanes?.feature?.effort === "high",
);
check(
"effective tests lane falls through to global",
effective?.lanes?.tests?.implementer === "grok" && effective?.lanes?.tests?.source === "global",
);
// Outside a project: load with cwd = non-git dir still sees global.
const loadBare = spawnSync(
process.execPath,
[join(setupDir, "config.mjs"), "load", "--cwd", bare],
{ encoding: "utf8", env: process.env },
);
let bareEff = null;
try {
bareEff = JSON.parse(loadBare.stdout);
} catch {
bareEff = null;
}
check("load outside git uses global only", loadBare.status === 0 && bareEff?.lanes?.feature?.source === "global");
check("load outside git does not invent .delegate", !existsSync(join(bare, ".delegate")));
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = prevXdg;
rmSync(fleetRoot, { recursive: true, force: true });
}
rmSync(scratch, { recursive: true, force: true });
console.log(failed ? `\n${failed} FAILED` : "\nrelay smoke: all green");
process.exit(failed ? 1 : 0);