mirror of
https://github.com/amElnagdy/delegate-skills.git
synced 2026-09-18 21:13:06 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,13 @@
|
||||
"cursor-delegate",
|
||||
"pi-delegate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Setup",
|
||||
"description": "Configure fleet lanes: which implementer handles which kind of work.",
|
||||
"skills": [
|
||||
"delegate-setup"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
(`<skill-dir>` is this skill’s 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 scope’s 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 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 <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 lane’s `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.
|
||||
@@ -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));
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user