From fdca81f9846bad173594fec0fae010f490288f47 Mon Sep 17 00:00:00 2001 From: Ahmed Mohammed <7650147+amElnagdy@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:32:32 +0300 Subject: [PATCH] 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 --- .gitignore | 3 + AGENTS.md | 34 ++- CONTRIBUTING.md | 17 ++ README.md | 27 +- skills.sh.json | 7 + skills/delegate-setup/SKILL.md | 114 ++++++++ skills/delegate-setup/references/schema.md | 70 +++++ .../references/setup-dialogue.md | 50 ++++ skills/delegate-setup/scripts/config.mjs | 246 ++++++++++++++++++ skills/delegate-setup/scripts/discover.mjs | 197 ++++++++++++++ .../delegate-setup/scripts/implementers.mjs | 146 +++++++++++ test/relay-smoke.mjs | 177 ++++++++++++- 12 files changed, 1064 insertions(+), 24 deletions(-) create mode 100644 skills/delegate-setup/SKILL.md create mode 100644 skills/delegate-setup/references/schema.md create mode 100644 skills/delegate-setup/references/setup-dialogue.md create mode 100644 skills/delegate-setup/scripts/config.mjs create mode 100644 skills/delegate-setup/scripts/discover.mjs create mode 100644 skills/delegate-setup/scripts/implementers.mjs diff --git a/.gitignore b/.gitignore index 865d645..94da690 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index fe1fab7..e579165 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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//`, 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 `-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 `-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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e331818..dfa029b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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//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 diff --git a/README.md b/README.md index f59b817..802bc98 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/skills.sh.json b/skills.sh.json index e39f7fe..67c4058 100644 --- a/skills.sh.json +++ b/skills.sh.json @@ -16,6 +16,13 @@ "cursor-delegate", "pi-delegate" ] + }, + { + "title": "Setup", + "description": "Configure fleet lanes: which implementer handles which kind of work.", + "skills": [ + "delegate-setup" + ] } ] } diff --git a/skills/delegate-setup/SKILL.md b/skills/delegate-setup/SKILL.md new file mode 100644 index 0000000..d911db3 --- /dev/null +++ b/skills/delegate-setup/SKILL.md @@ -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 3–5 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. + +(`` is this skill’s install directory — the folder that contains this `SKILL.md`.) + +## Flow + +### 1. Discover + +```bash +node "/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 "/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 "/scripts/config.mjs" validate /tmp/lanes.json +node "/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 scope’s file. +Reinstalling the skills package must not rewrite these files — they live outside the package. diff --git a/skills/delegate-setup/references/schema.md b/skills/delegate-setup/references/schema.md new file mode 100644 index 0000000..dab122b --- /dev/null +++ b/skills/delegate-setup/references/schema.md @@ -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 | `/.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 /scripts/discover.mjs +node /scripts/config.mjs load [--cwd ] +node /scripts/config.mjs validate +node /scripts/config.mjs write --scope global|project [--cwd ] +``` + +`load` prints the **effective** map (each lane includes a `source` of `global` or `project`). diff --git a/skills/delegate-setup/references/setup-dialogue.md b/skills/delegate-setup/references/setup-dialogue.md new file mode 100644 index 0000000..2998785 --- /dev/null +++ b/skills/delegate-setup/references/setup-dialogue.md @@ -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 /scripts/config.mjs load --cwd ` (use the user’s 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 /scripts/config.mjs write --scope global /tmp/lanes.json + +# project +node /scripts/config.mjs write --scope project --cwd /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 lane’s `implementer` and pass `--lane ` 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. diff --git a/skills/delegate-setup/scripts/config.mjs b/skills/delegate-setup/scripts/config.mjs new file mode 100644 index 0000000..994883a --- /dev/null +++ b/skills/delegate-setup/scripts/config.mjs @@ -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 ] + * node config.mjs validate + * node config.mjs write --scope global|project [--cwd ] + * 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 ] + node config.mjs validate + node config.mjs write --scope global|project [--cwd ] + node config.mjs --help + +Paths: + global ~/.config/delegate-skills/config.json + project /.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} */ + 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)); diff --git a/skills/delegate-setup/scripts/discover.mjs b/skills/delegate-setup/scripts/discover.mjs new file mode 100644 index 0000000..aa0ae34 --- /dev/null +++ b/skills/delegate-setup/scripts/discover.mjs @@ -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)); diff --git a/skills/delegate-setup/scripts/implementers.mjs b/skills/delegate-setup/scripts/implementers.mjs new file mode 100644 index 0000000..20d86de --- /dev/null +++ b/skills/delegate-setup/scripts/implementers.mjs @@ -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", +]); diff --git a/test/relay-smoke.mjs b/test/relay-smoke.mjs index 22e7b9d..c05dfde 100644 --- a/test/relay-smoke.mjs +++ b/test/relay-smoke.mjs @@ -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);