From c6e689fbfd14d8328fc381f59e87df932a298aef Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 17 Apr 2026 07:21:00 -0400 Subject: [PATCH 1/8] Add agent-workforce CLI and make it executable Introduce a new CLI (packages/workload-router/src/cli.ts) to run personas in interactive or one-shot modes and spawn external LLM harness CLIs. Add a package bin entry (agent-workforce -> dist/cli.js) and update the build script to chmod +x the generated CLI. Also update package-lock.json to reflect the new dependencies and dev-deps required by the CLI. --- package-lock.json | 82 +++++++++ packages/workload-router/package.json | 5 +- packages/workload-router/src/cli.ts | 231 ++++++++++++++++++++++++++ 3 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 packages/workload-router/src/cli.ts diff --git a/package-lock.json b/package-lock.json index 8ef9ad7..01ea519 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "devDependencies": { "@agent-relay/sdk": "^4.0.5", "@types/node": "^22.18.0", + "agent-trajectories": "^0.5.3", "typescript": "^5.9.2" } }, @@ -73,6 +74,45 @@ } } }, + "node_modules/@clack/core": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.3.5.tgz", + "integrity": "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.7.0.tgz", + "integrity": "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA==", + "bundleDependencies": [ + "is-unicode-supported" + ], + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "^0.3.3", + "is-unicode-supported": "*", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts/node_modules/is-unicode-supported": { + "version": "1.3.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -152,6 +192,24 @@ "undici-types": "~6.21.0" } }, + "node_modules/agent-trajectories": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/agent-trajectories/-/agent-trajectories-0.5.6.tgz", + "integrity": "sha512-KVZTW1ThJZ5wYnf8Rah7oUfOD87GO/UBaWe/gxWX8kRRlRzPRChPMdS3hnKMtqx3VRbqo7IWXYtUl+WwTpSeGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.7.0", + "commander": "^12.0.0", + "zod": "^3.23.0" + }, + "bin": { + "trail": "dist/cli/index.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -277,6 +335,16 @@ "dev": true, "license": "MIT" }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -508,6 +576,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -545,6 +620,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slice-ansi": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", diff --git a/packages/workload-router/package.json b/packages/workload-router/package.json index 346a334..26643ce 100644 --- a/packages/workload-router/package.json +++ b/packages/workload-router/package.json @@ -11,6 +11,9 @@ "default": "./dist/index.js" } }, + "bin": { + "agent-workforce": "dist/cli.js" + }, "files": [ "dist", "routing-profiles", @@ -29,7 +32,7 @@ }, "scripts": { "generate:personas": "node ./scripts/generate-personas.mjs", - "build": "npm run generate:personas && tsc -p tsconfig.json", + "build": "npm run generate:personas && tsc -p tsconfig.json && chmod +x dist/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run generate:personas && tsc -p tsconfig.json && node --test dist/index.test.js", "lint": "npm run generate:personas && tsc -p tsconfig.json --noEmit" diff --git a/packages/workload-router/src/cli.ts b/packages/workload-router/src/cli.ts new file mode 100644 index 0000000..01619c6 --- /dev/null +++ b/packages/workload-router/src/cli.ts @@ -0,0 +1,231 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from 'node:child_process'; +import { constants } from 'node:os'; + +import { + PERSONA_TIERS, + personaCatalog, + usePersona, + type Harness, + type PersonaIntent, + type PersonaTier, + type PersonaSpec +} from './index.js'; + +const USAGE = `Usage: agent-workforce agent [@] [task...] + + persona id or intent (e.g. npm-provenance-publisher or npm-provenance) + ${PERSONA_TIERS.join(' | ')} (default: best-value) + [task] if provided, runs one-shot non-interactively; + otherwise drops into an interactive harness session + +Examples: + agent-workforce agent npm-provenance-publisher@best + agent-workforce agent review@best-value "look at the diff on this branch" +`; + +function die(msg: string, withUsage = true): never { + process.stderr.write(`${msg}\n`); + if (withUsage) process.stderr.write(`\n${USAGE}`); + process.exit(1); +} + +function resolveIntent(key: string): PersonaIntent { + if (Object.prototype.hasOwnProperty.call(personaCatalog, key)) { + return key as PersonaIntent; + } + const specs = Object.values(personaCatalog) as PersonaSpec[]; + const byId = specs.find((p) => p.id === key); + if (byId) return byId.intent; + const listing = specs + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + .map((p) => ` ${p.id} (intent: ${p.intent})`) + .join('\n'); + die(`Unknown persona "${key}". Known personas:\n${listing}`, false); +} + +function parseSelector(sel: string): { intent: PersonaIntent; tier: PersonaTier } { + const at = sel.indexOf('@'); + const id = at === -1 ? sel : sel.slice(0, at); + const tierRaw = at === -1 ? undefined : sel.slice(at + 1); + if (!id) die('Missing persona name before "@"'); + const tier = (tierRaw ?? 'best-value') as PersonaTier; + if (tierRaw !== undefined && !PERSONA_TIERS.includes(tier)) { + die(`Invalid tier "${tierRaw}". Must be one of: ${PERSONA_TIERS.join(', ')}`); + } + return { intent: resolveIntent(id), tier }; +} + +function stripProviderPrefix(model: string): string { + const idx = model.indexOf('/'); + return idx >= 0 ? model.slice(idx + 1) : model; +} + +type InteractiveSpec = { + bin: string; + args: readonly string[]; + initialPrompt: string | null; +}; + +function buildInteractiveSpec( + harness: Harness, + model: string, + systemPrompt: string +): InteractiveSpec { + switch (harness) { + case 'claude': + return { + bin: 'claude', + args: ['--model', model, '--append-system-prompt', systemPrompt], + initialPrompt: null + }; + case 'codex': + return { + bin: 'codex', + args: ['-m', stripProviderPrefix(model)], + initialPrompt: systemPrompt + }; + case 'opencode': + return { + bin: 'opencode', + args: ['--model', stripProviderPrefix(model)], + initialPrompt: systemPrompt + }; + } +} + +async function runOneShot( + intent: PersonaIntent, + tier: PersonaTier, + task: string +): Promise { + const ctx = usePersona(intent, { tier }); + const { personaId, runtime } = ctx.selection; + process.stderr.write( + `→ ${personaId} [${tier}] via ${runtime.harness} (${runtime.model})\n` + ); + const execution = ctx.sendMessage(task, { + onProgress: ({ stream, text }) => { + (stream === 'stderr' ? process.stderr : process.stdout).write(text); + } + }); + try { + const result = await execution; + process.exit(result.exitCode ?? 0); + } catch (err) { + const typed = err as Error & { + result?: { exitCode: number | null; status: string; stderr?: string }; + }; + const status = typed.result?.status ?? 'failed'; + process.stderr.write(`\n[${status}] ${typed.message}\n`); + process.exit(typed.result?.exitCode ?? 1); + } +} + +function signalExitCode(signal: NodeJS.Signals | null): number { + if (!signal) return 0; + const num = (constants.signals as Record)[signal]; + return 128 + (num ?? 1); +} + +function runInstall(command: readonly string[], label: string): void { + const [bin, ...args] = command; + if (!bin) return; + process.stderr.write(`• ${label}\n`); + const res = spawnSync(bin, args, { stdio: 'inherit', shell: false }); + if (res.status !== 0) { + const code = res.status ?? 1; + process.stderr.write(`${label} failed (exit ${code}). Aborting.\n`); + process.exit(code); + } +} + +function runCleanup(command: readonly string[], commandString: string): void { + if (commandString === ':') return; + const [bin, ...args] = command; + if (!bin) return; + spawnSync(bin, args, { stdio: 'inherit', shell: false }); +} + +function runInteractive(intent: PersonaIntent, tier: PersonaTier): Promise { + const ctx = usePersona(intent, { tier }); + const { personaId, runtime } = ctx.selection; + const { install } = ctx; + process.stderr.write( + `→ ${personaId} [${tier}] via ${runtime.harness} (${runtime.model})\n` + ); + + if (install.plan.installs.length > 0) { + const skillIds = install.plan.installs.map((i) => i.skillId).join(', '); + runInstall(install.command, `Installing skills: ${skillIds}`); + } + + const spec = buildInteractiveSpec(runtime.harness, runtime.model, runtime.systemPrompt); + const finalArgs = spec.initialPrompt ? [...spec.args, spec.initialPrompt] : [...spec.args]; + const promptNote = spec.initialPrompt ? ' ' : ''; + process.stderr.write(`• spawning: ${spec.bin} ${spec.args.join(' ')}${promptNote}\n`); + + return new Promise((resolve) => { + let settled = false; + const finish = (code: number) => { + if (settled) return; + settled = true; + runCleanup(install.cleanupCommand, install.cleanupCommandString); + resolve(code); + }; + + const child = spawn(spec.bin, finalArgs, { stdio: 'inherit' }); + + const forward = (signal: NodeJS.Signals) => { + if (!child.killed) child.kill(signal); + }; + process.on('SIGINT', () => forward('SIGINT')); + process.on('SIGTERM', () => forward('SIGTERM')); + + child.on('exit', (code, signal) => { + finish(code ?? signalExitCode(signal)); + }); + child.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + process.stderr.write( + `Failed to spawn "${spec.bin}": binary not found on PATH. Install the ${runtime.harness} CLI and retry.\n` + ); + } else { + process.stderr.write(`Failed to spawn "${spec.bin}": ${err.message}\n`); + } + finish(127); + }); + }); +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const [subcommand, ...rest] = argv; + + if (!subcommand || subcommand === '-h' || subcommand === '--help') { + process.stdout.write(USAGE); + process.exit(subcommand ? 0 : 1); + } + + if (subcommand !== 'agent') { + die(`Unknown subcommand "${subcommand}".`); + } + + const [selector, ...taskParts] = rest; + if (!selector) die('agent: missing persona selector.'); + + const { intent, tier } = parseSelector(selector); + + if (taskParts.length > 0) { + await runOneShot(intent, tier, taskParts.join(' ')); + } else { + const code = await runInteractive(intent, tier); + process.exit(code); + } +} + +main().catch((err) => { + process.stderr.write(`${(err as Error)?.stack ?? String(err)}\n`); + process.exit(1); +}); From 5cef03cf0c0d97b0778db765360ceef4e575fcd5 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 17 Apr 2026 11:46:08 -0400 Subject: [PATCH 2/8] Add agent-workforce CLI and persona tooling Introduce a new @agentworkforce/cli package with a command-line front end (agent-workforce). Adds package.json, README, and a TypeScript implementation (src/cli.ts) that resolves persona selectors across pwd/home/library layers, supports interactive and one-shot modes, installs skills, and spawns harness CLIs (claude/codex/opencode). Also add env-ref utilities (src/env-refs.ts) with unit tests to resolve and leniently handle $VAR / ${VAR} references, and local persona loader tests (src/local-personas.test.ts) to validate cascade/extends behavior. Include a built-in persona (personas/posthog.json) and update the top-level README to document the CLI usage and local override semantics. Minor workload-router changes coordinate the new CLI package and generated personas. --- README.md | 77 +++ packages/cli/README.md | 381 +++++++++++++++ packages/cli/package.json | 31 ++ packages/cli/src/cli.ts | 450 ++++++++++++++++++ packages/cli/src/env-refs.test.ts | 131 +++++ packages/cli/src/env-refs.ts | 138 ++++++ packages/cli/src/local-personas.test.ts | 238 +++++++++ packages/cli/src/local-personas.ts | 281 +++++++++++ packages/cli/tsconfig.json | 8 + packages/workload-router/package.json | 5 +- .../routing-profiles/default.json | 4 + .../scripts/generate-personas.mjs | 3 +- packages/workload-router/src/cli.ts | 231 --------- .../workload-router/src/generated/personas.ts | 42 ++ packages/workload-router/src/index.test.ts | 4 + packages/workload-router/src/index.ts | 200 +++++++- personas/posthog.json | 41 ++ pnpm-lock.yaml | 6 + 18 files changed, 2028 insertions(+), 243 deletions(-) create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/cli.ts create mode 100644 packages/cli/src/env-refs.test.ts create mode 100644 packages/cli/src/env-refs.ts create mode 100644 packages/cli/src/local-personas.test.ts create mode 100644 packages/cli/src/local-personas.ts create mode 100644 packages/cli/tsconfig.json delete mode 100644 packages/workload-router/src/cli.ts create mode 100644 personas/posthog.json diff --git a/README.md b/README.md index 1a33c0e..2c69e64 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,84 @@ All tiers should enforce the same correctness/safety standards; lower tiers shou A **routing profile** is policy-only. It does not carry runtime fields; it only selects which persona tier to use per intent and explains why. +## CLI + +The `agent-workforce` binary (published as `@agentworkforce/cli`) is the +fastest way to actually *run* a persona. It resolves the persona from the +built-in catalog or your local overrides, installs any declared skills, +and execs the harness CLI (`claude`, `codex`, or `opencode`) with the right +model, system prompt, env vars, MCP servers, and permissions wired up. + +### Install + +From the monorepo checkout: + +```bash +corepack pnpm -r build +corepack pnpm --filter @agentworkforce/cli link --global +``` + +`agent-workforce` is now on your PATH. (Or run the built bin directly: +`./packages/cli/dist/cli.js …`.) + +### Usage + +``` +agent-workforce agent [@] [task...] +``` + +- **No task** → drops you into an interactive harness session. +- **Task string** → runs one-shot via `usePersona().sendMessage()` and + streams output. +- `` is `best` | `best-value` | `minimum` (default: `best-value`). +- `` resolves across three layers, highest first: + 1. `./.agent-workforce/*.json` — project-local + 2. `~/.agent-workforce/*.json` — user-local + 3. Built-in personas in `/personas/` + +Each local layer is a *partial overlay* — only the fields you set replace +the value from the next lower layer; everything else cascades through. + +### Examples + +```bash +# One-shot against the built-in code reviewer +agent-workforce agent review@best-value "look at the diff on this branch" + +# Interactive PostHog session — the built-in persona ships with the PostHog +# MCP server wired up and its tools auto-approved. +export POSTHOG_API_KEY=phx_… +agent-workforce agent posthog@best +``` + +### Local persona override + +Project-local `./.agent-workforce/my-posthog.json`: + +```json +{ + "id": "my-posthog", + "extends": "posthog", + "env": { "POSTHOG_API_KEY": "$POSTHOG_API_KEY" }, + "permissions": { + "allow": ["mcp__posthog__insights-list", "mcp__posthog__events-query"] + } +} +``` + +`agent-workforce agent my-posthog@best` inherits everything from the built-in +`posthog` persona, layers your env var and narrower allow list on top, and +launches claude against the PostHog MCP server with only the two named tools +auto-approved. + +The full docs — cascade rules, `${VAR}` interpolation, MCP transport +options, permission grammar, troubleshooting — live in +**[packages/cli/README.md](./packages/cli/README.md)**. + ## Packages - `packages/workload-router` — TypeScript SDK for typed persona + routing profile resolution. +- `packages/cli` — `agent-workforce` command-line front end: spawn a persona's harness (claude/codex/opencode) from the shell, interactively or one-shot. See **[packages/cli/README.md](./packages/cli/README.md)** for the full docs, and the [CLI](#cli) section below for a quick tour. ## Personas @@ -42,6 +117,7 @@ A **routing profile** is policy-only. It does not carry runtime fields; it only - `personas/flake-hunter.json` - `personas/opencode-workflow-specialist.json` - `personas/npm-provenance-publisher.json` +- `personas/posthog.json` ## Routing profiles @@ -158,6 +234,7 @@ run-id observability for free. - `flake-investigation` - `opencode-workflow-correctness` - `npm-provenance` + - `posthog` 2. Call `usePersona(intent, { profile? })` to resolve the persona and receive the selected persona, grouped install metadata, and a `sendMessage()` closure bound to its runtime (harness, model, settings, diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..bda65d3 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,381 @@ +# agent-workforce CLI + +A thin command-line front end for the workload-router. Spawns the harness CLI +(`claude`, `codex`, `opencode`) configured by a selected **persona** — either a +built-in one from `/personas/`, or a user-local one that extends a built-in. + +``` +agent-workforce agent [@] [task...] +``` + +- No `task` → drops you into an interactive session with the harness. +- `task...` given → runs one-shot (via `usePersona().sendMessage()`) and streams + output to stdout/stderr. + +## Install + +The CLI ships as a `bin` in `@agentworkforce/workload-router`. From the repo +checkout: + +```sh +corepack pnpm -r build +corepack pnpm --filter @agentworkforce/workload-router link --global +``` + +That puts `agent-workforce` on your PATH. + +## Selectors + +``` +agent-workforce agent [@] [task...] +``` + +- `` — matches, in order: + 1. A **pwd-local** id (files in `/.agent-workforce/*.json`) + 2. A **home-local** id (files in `~/.agent-workforce/*.json`) + 3. A **library** persona — by intent first (e.g. `review`), then by id + (e.g. `code-reviewer`) +- `` — `best` | `best-value` | `minimum`. Defaults to `best-value`. + +Unknown persona prints the full catalog with each entry's origin. + +### Examples + +```sh +# One-shot against the built-in code reviewer +agent-workforce agent review@best-value "look at the diff on this branch" + +# Interactive PostHog session (library persona, needs POSTHOG_API_KEY) +agent-workforce agent posthog@best + +# Interactive against a local override +agent-workforce agent my-posthog@best +``` + +## Personas + +A persona is a JSON object describing *what harness runs, which model, with +what system prompt, what skills to install, what env vars to inject, and which +MCP servers to attach*. Full library shape: + +```jsonc +{ + "id": "posthog", + "intent": "posthog", + "description": "…", + "skills": [], + "env": { "POSTHOG_API_KEY": "$POSTHOG_API_KEY" }, + "mcpServers": { + "posthog": { + "type": "http", + "url": "https://mcp.posthog.com/mcp", + "headers": { "Authorization": "Bearer $POSTHOG_API_KEY" } + } + }, + "tiers": { + "best": { "harness": "claude", "model": "claude-opus-4-6", "systemPrompt": "…", "harnessSettings": { "reasoning": "high", "timeoutSeconds": 900 } }, + "best-value": { "harness": "claude", "model": "claude-sonnet-4-6", "systemPrompt": "…", "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 600 } }, + "minimum": { "harness": "claude", "model": "claude-haiku-4-5-20251001", "systemPrompt": "…", "harnessSettings": { "reasoning": "low", "timeoutSeconds": 300 } } + } +} +``` + +See `/personas/*.json` for all built-ins. + +## Local personas & the cascade + +Local persona files layer on top of the library. Resolution precedence (highest +wins): + +1. `/.agent-workforce/*.json` — **pwd** +2. `~/.agent-workforce/*.json` — **home** (override path via + `AGENT_WORKFORCE_CONFIG_DIR`) +3. Built-in personas in `/personas/` — **library** + +Local files are **partial overlays**: only the fields you set replace the +inherited value. Everything else cascades through from below. + +### Minimal override: add your API key + +`~/.agent-workforce/my-posthog.json`: + +```json +{ + "id": "my-posthog", + "extends": "posthog", + "env": { "POSTHOG_API_KEY": "$POSTHOG_API_KEY" } +} +``` + +That inherits every field from the library `posthog` persona, then layers your +`env` on top. `agent-workforce agent my-posthog@best` now works as long as +`POSTHOG_API_KEY` is exported in your shell. + +### Same-id override (implicit extends) + +If your file's `id` matches a persona in a lower layer and you omit `extends`, +the loader implicitly inherits from that same-id base: + +`/.agent-workforce/posthog.json`: + +```json +{ + "id": "posthog", + "env": { "POSTHOG_API_KEY": "$POSTHOG_API_KEY" } +} +``` + +Resolving `posthog` now hits this pwd override first; it inherits the rest +(MCP, tiers, description, etc.) from the library `posthog`. + +### Cascade chain + +A pwd file can extend a home file, which extends the library: + +``` +~/.agent-workforce/ph-base.json: +{ "id": "ph-base", "extends": "posthog", "env": { "POSTHOG_ORG": "acme" } } + +/.agent-workforce/ph-prod.json: +{ "id": "ph-prod", "extends": "ph-base", "env": { "POSTHOG_API_KEY": "$PROD_KEY" } } +``` + +Resolving `ph-prod`: + +- Start with library `posthog` (MCP, tiers, prompt, …) +- Layer home `ph-base` on top (adds `POSTHOG_ORG=acme`) +- Layer pwd `ph-prod` on top (adds `POSTHOG_API_KEY`) + +`extends` is resolved **strictly against lower layers** — pwd extends home or +library, home extends library, library has no `extends`. + +### Override shape (all fields except `id` optional) + +```jsonc +{ + "id": "my-agent", // required + "extends": "posthog", // optional; implicit same-id if omitted + "description": "…", // replaces base description + "skills": [ … ], // replaces entire skills array + "env": { … }, // union, local wins per key + "mcpServers": { … }, // union by server name, local wins per key + "permissions": { // allow/deny union (dedup), mode replaces + "allow": ["…"], "deny": ["…"], "mode": "default" + }, + "systemPrompt": "…", // replaces systemPrompt on every inherited tier + "tiers": { // per-tier partial override + "best": { "model": "claude-sonnet-4-6" } + // other tiers inherited untouched + } +} +``` + +**Per-tier partial merge.** If you set `tiers.best.model`, only `model` +changes — `systemPrompt`, `harness`, and `harnessSettings` still come from the +base. Use top-level `systemPrompt` if you want to replace the prompt +uniformly across all tiers. + +## Env references & secrets + +Any `env` value or `mcpServers.*.{headers,env,args,url,command}` value can be +either a literal string or an env reference. Two forms: + +| Form | Meaning | Example | +| ---- | ------- | ------- | +| `"$VAR"` | Whole-string reference — the entire value is the env var. | `"POSTHOG_API_KEY": "$POSTHOG_API_KEY"` | +| `"prefix ${VAR}"` | Braced interpolation — each `${VAR}` is replaced in place, anywhere in the string. | `"Authorization": "Bearer ${POSTHOG_API_KEY}"` | + +- Both forms resolve against the shell `process.env` at **spawn time** (not + load time). +- **Unbraced `$VAR` mid-string stays literal** — `"prefix-$FOO"` is NOT + interpolated. Use `${FOO}` if you want interpolation there. This prevents a + stray `$` in a JSON string from accidentally getting eaten. +- An unset or empty referenced var is a **warning, not a fatal error**. The + CLI drops the referring entry and proceeds. So a persona that references + `$POSTHOG_API_KEY` in both `env` and an `Authorization` header will, if the + var isn't set, launch without either — and the agent can still authenticate + interactively (e.g. via Claude Code's MCP OAuth flow). Example warning: + + ``` + warning: env.POSTHOG_API_KEY dropped (env var POSTHOG_API_KEY is not set). + warning: mcpServers.posthog.headers.Authorization dropped (env var POSTHOG_API_KEY is not set). + (referenced env vars were not set — proceeding without those values; + if the agent relies on them it may need to authenticate + interactively, e.g. via OAuth.) + ``` + +- An MCP server whose **structural** field (`url`, `command`, or any `arg`) + references a missing var is dropped entirely, since the server couldn't be + launched without that value. The warning names the server and the refs that + were unset. + +Secrets therefore stay in your shell/keychain, not in files on disk — local +persona JSON remains commit-safe as long as you only use references. + +## Permissions + +A persona can declare which tool calls the harness should auto-approve, block, +or gate via a permission mode. Skip the approval prompts for trusted tools +(e.g. a persona's own MCP server); keep them on for anything you want to +eyeball. + +```jsonc +{ + "permissions": { + "allow": ["mcp__posthog", "Bash(git *)"], // auto-approve + "deny": ["Bash(rm -rf *)"], // always block + "mode": "default" // default | acceptEdits | bypassPermissions | plan + } +} +``` + +- **Tool patterns** are passed through verbatim; use the harness's native + grammar. For Claude Code: `Bash()`, `Edit()`, + `mcp__` (all tools from that server), `mcp____` + (specific tool). +- **Harness support today:** only `claude` is wired (flags: `--allowedTools`, + `--disallowedTools`, `--permission-mode`). codex and opencode emit a + warning and fall back to their defaults when `permissions` is set. +- **Cascade merge:** `allow` and `deny` are unions across layers (deduped on + merge); `mode` is replaced by the topmost layer that sets it. So the + library can declare the minimum-viable allow list, home can layer on + project-wide denies, and pwd can add per-project patterns — they all + compose. + +### Example: PostHog with auto-approve + +The built-in `posthog` persona declares `permissions.allow = ["mcp__posthog"]` +so that once you've authenticated (either by passing `POSTHOG_API_KEY` up +front or via Claude's OAuth flow), subsequent analytics tool calls don't +prompt. To narrow the auto-approval to read-only tools, override in a local +persona: + +```json +{ + "id": "my-posthog", + "extends": "posthog", + "permissions": { + "allow": [ + "mcp__posthog__projects-get", + "mcp__posthog__insights-list", + "mcp__posthog__events-query" + ] + } +} +``` + +Because `allow` is a union, the base's `"mcp__posthog"` would still be in the +merged list. If you want to *shrink* the allow list in a local override, +include a comment explaining why — there's currently no "replace" knob, only +union. (File an issue if you need one.) + +## MCP servers + +The `mcpServers` block mirrors Claude Code's `--mcp-config` JSON shape +verbatim. Three transport types: + +```jsonc +// Remote HTTP / streamable-http +{ "type": "http", "url": "https://…", "headers": { "Authorization": "Bearer ${TOKEN}" } } + +// Remote SSE (deprecated by most servers but still supported) +{ "type": "sse", "url": "https://…", "headers": { … } } + +// Stdio — long-running local MCP server +{ "type": "stdio", "command": "npx", "args": ["-y", "…"], "env": { "API_KEY": "$API_KEY" } } +``` + +### Harness support + +| Harness | Interactive MCP | One-shot MCP | +| -------- | --------------- | ------------ | +| claude | yes (via `--mcp-config` + `--strict-mcp-config`) | not yet — SDK workflow path doesn't thread MCP | +| codex | not yet — warns and proceeds without MCP | not yet | +| opencode | not yet — warns and proceeds without MCP | not yet | + +For a persona that needs MCP today, pick `claude` as the harness on every tier +and use interactive mode. + +### MCP isolation + +For the `claude` harness the CLI always spawns with `--strict-mcp-config`, +paired with an explicit `--mcp-config` payload (the persona's `mcpServers`, or +`{"mcpServers":{}}` if none). That means **only the servers declared on the +persona are loaded** — your user-level `~/.claude.json` MCPs and any +project-level MCP sources are ignored inside the session. This keeps each +persona session self-contained and prevents cross-contamination with the +agents you normally run. If you need one of your personal MCPs inside a +persona session, add it to the persona's `mcpServers` block. + +## Interactive vs one-shot + +### Interactive + +```sh +agent-workforce agent [@] +``` + +1. Resolves the persona, walks the cascade, resolves `$VAR` refs. +2. Runs skill install (`prpm install …`) if the persona declares any skills. +3. Execs the harness binary with stdio inherited: + - `claude`: `claude --model --append-system-prompt + --mcp-config '' --strict-mcp-config`. Both flags are always passed + so the session only sees the persona's declared MCP servers — see + **MCP isolation** above. + - `codex`: `codex -m ` with the system prompt as the initial + positional `[PROMPT]`. (codex has no `--system-prompt` flag today.) + - `opencode`: `opencode --model ` with the system prompt as the + initial argument. +4. Runs the skill cleanup command on exit, regardless of exit status. +5. Propagates the harness's exit code. + +Signals (SIGINT, SIGTERM) are forwarded to the child. + +### One-shot + +```sh +agent-workforce agent [@] "" +``` + +Non-interactive. Delegates to `usePersona(intent, { tier }).sendMessage(task)` +from the workload-router SDK, which spawns an ad-hoc single-step workflow: +install skills → run agent → cleanup. Stdout/stderr stream live; exit code +matches the agent's. + +Env from the persona is passed through via `ExecuteOptions.env`. `mcpServers` +is currently **ignored with a warning** in one-shot mode — the SDK workflow +path doesn't thread MCP config yet. + +## Selecting a harness per tier + +A persona's three tiers can use different harnesses. The built-in +`npm-provenance-publisher` has `best` on codex and `best-value` on opencode, +for example. + +If a persona uses MCP, keep every tier on `claude` — only the claude harness +wires MCP at spawn time today. + +## Troubleshooting + +- **`Unknown persona "X".`** — The CLI prints the full catalog. If your local + file should be listed, check for a warning on the preceding line — parse + errors and bad `extends` references are reported but non-fatal. + +- **`warning: dropped (env var X is not set)`** — Informational. The + CLI skipped that value and is launching without it. Export the variable if + you want the agent to have it up-front; otherwise the harness may handle + auth interactively (e.g. Claude Code's MCP OAuth flow). + +- **`Failed to spawn "claude": binary not found on PATH.`** — Install the + harness CLI (`claude`, `codex`, or `opencode`) and ensure it's on your PATH. + +- **`warning: persona declares mcpServers but the codex harness is not yet + wired …`** — Either switch the tier's `harness` to `claude`, or drop the MCP + requirement. + +- **`extends cycle detected through …`** — A local persona extends itself + transitively. Break the chain or point one link at the library directly. + +- **Local file silently missing from the list** — Scroll up for a + `warning: [layer] file.json: …` line. Common causes: invalid JSON, `id` + missing, or `extends` pointing at something that isn't in a lower layer. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..b0f3cb9 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,31 @@ +{ + "name": "@agentworkforce/cli", + "version": "0.1.0", + "private": false, + "type": "module", + "bin": { + "agent-workforce": "dist/cli.js" + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "dependencies": { + "@agentworkforce/workload-router": "workspace:*" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/workforce", + "directory": "packages/cli" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json && chmod +x dist/cli.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsc -p tsconfig.json && node --test dist/*.test.js", + "lint": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..022bb02 --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,450 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from 'node:child_process'; +import { constants } from 'node:os'; + +import { + PERSONA_TIERS, + personaCatalog, + useSelection, + type Harness, + type McpServerSpec, + type PersonaPermissions, + type PersonaSelection, + type PersonaSpec, + type PersonaTier +} from '@agentworkforce/workload-router'; +import { loadLocalPersonas } from './local-personas.js'; +import { + makeLenientResolver, + resolveStringMapLenient, + type DroppedRef +} from './env-refs.js'; + +const USAGE = `Usage: agent-workforce agent [@] [task...] + + repo persona id, repo intent, or local persona id + ${PERSONA_TIERS.join(' | ')} (default: best-value) + [task] if provided, runs one-shot non-interactively; + otherwise drops into an interactive harness session + +Local personas cascade: /.agent-workforce/*.json → ~/.agent-workforce/*.json → repo library. +Each layer only needs to specify fields it overrides; everything else inherits +from the next lower layer. "extends" explicitly names a base; omit it and the +loader implicitly inherits from the same-id persona below. (Override the home +layer path via AGENT_WORKFORCE_CONFIG_DIR.) + +Examples: + agent-workforce agent npm-provenance-publisher@best + agent-workforce agent my-posthog@best + agent-workforce agent review@best-value "look at the diff on this branch" +`; + +function die(msg: string, withUsage = true): never { + process.stderr.write(`${msg}\n`); + if (withUsage) process.stderr.write(`\n${USAGE}`); + process.exit(1); +} + +const local = loadLocalPersonas(); +for (const warning of local.warnings) { + process.stderr.write(`warning: ${warning}\n`); +} + +type ResolvedTarget = + | { kind: 'repo'; spec: PersonaSpec; tier: PersonaTier } + | { kind: 'local'; spec: PersonaSpec; tier: PersonaTier }; + +function resolveSpec(key: string): ResolvedTarget['spec'] | { error: string } { + const localSpec = local.byId.get(key); + if (localSpec) return localSpec; + const catalogAsIntent = (personaCatalog as Record)[key]; + if (catalogAsIntent) return catalogAsIntent; + const byId = Object.values(personaCatalog).find((p) => p.id === key); + if (byId) return byId; + + const repoListing = Object.values(personaCatalog) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + .map((p) => ` ${p.id} (intent: ${p.intent})`); + const localListing = [...local.byId.values()] + .sort((a, b) => a.id.localeCompare(b.id)) + .map((p) => ` ${p.id} (${local.sources.get(p.id) ?? 'local'})`); + const listing = [...repoListing, ...localListing].join('\n'); + return { error: `Unknown persona "${key}". Known personas:\n${listing}` }; +} + +function parseSelector(sel: string): ResolvedTarget { + const at = sel.indexOf('@'); + const key = at === -1 ? sel : sel.slice(0, at); + const tierRaw = at === -1 ? undefined : sel.slice(at + 1); + if (!key) die('Missing persona name before "@"'); + const tier = (tierRaw ?? 'best-value') as PersonaTier; + if (tierRaw !== undefined && !PERSONA_TIERS.includes(tier)) { + die(`Invalid tier "${tierRaw}". Must be one of: ${PERSONA_TIERS.join(', ')}`); + } + const result = resolveSpec(key); + if ('error' in result) die(result.error, false); + const kind = local.byId.has(key) ? 'local' : 'repo'; + return { kind, spec: result, tier }; +} + +function buildSelection(spec: PersonaSpec, tier: PersonaTier, kind: 'repo' | 'local'): PersonaSelection { + return { + personaId: spec.id, + tier, + runtime: spec.tiers[tier], + skills: spec.skills, + rationale: kind === 'local' ? `local-override: ${spec.id}` : `cli-tier-override: ${tier}`, + ...(spec.env ? { env: spec.env } : {}), + ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}), + ...(spec.permissions ? { permissions: spec.permissions } : {}) + }; +} + +function stripProviderPrefix(model: string): string { + const idx = model.indexOf('/'); + return idx >= 0 ? model.slice(idx + 1) : model; +} + +interface McpResolution { + servers: Record | undefined; + /** Entries dropped because a referenced env var was not set. */ + dropped: DroppedRef[]; + /** + * Servers dropped entirely because a structural field (`url`, `command`, + * any `arg`) couldn't be resolved — the config for those servers would be + * unusable without the missing value. + */ + droppedServers: { name: string; refs: string[] }[]; +} + +function resolveMcpServersLenient( + servers: Record | undefined, + processEnv: NodeJS.ProcessEnv +): McpResolution { + if (!servers) return { servers: undefined, dropped: [], droppedServers: [] }; + const resolve = makeLenientResolver(processEnv); + const out: Record = {}; + const dropped: DroppedRef[] = []; + const droppedServers: { name: string; refs: string[] }[] = []; + + for (const [name, spec] of Object.entries(servers)) { + const field = `mcpServers.${name}`; + const fatalRefs: string[] = []; + + const resolveFatal = (value: string, subfield: string): string | undefined => { + const r = resolve(value, subfield); + if (r.ok) return r.value; + fatalRefs.push(r.ref); + return undefined; + }; + + if (spec.type === 'stdio') { + const command = resolveFatal(spec.command, `${field}.command`); + const args = spec.args?.map((a, i) => resolveFatal(a, `${field}.args[${i}]`)); + if (!command || (args && args.some((a) => a === undefined))) { + droppedServers.push({ name, refs: fatalRefs }); + continue; + } + const envResolution = resolveStringMapLenient(spec.env, processEnv, `${field}.env`); + dropped.push(...envResolution.dropped); + out[name] = { + type: 'stdio', + command, + ...(args ? { args: args as string[] } : {}), + ...(envResolution.value ? { env: envResolution.value } : {}) + }; + } else { + const url = resolveFatal(spec.url, `${field}.url`); + if (!url) { + droppedServers.push({ name, refs: fatalRefs }); + continue; + } + const headersResolution = resolveStringMapLenient( + spec.headers, + processEnv, + `${field}.headers` + ); + dropped.push(...headersResolution.dropped); + out[name] = { + type: spec.type, + url, + ...(headersResolution.value ? { headers: headersResolution.value } : {}) + }; + } + } + + return { + servers: Object.keys(out).length > 0 ? out : undefined, + dropped, + droppedServers + }; +} + +function formatDropWarnings( + envDrops: DroppedRef[], + mcpDrops: DroppedRef[], + mcpServerDrops: { name: string; refs: string[] }[] +): string[] { + const lines: string[] = []; + for (const d of envDrops) { + lines.push(`${d.field} dropped (env var ${d.ref} is not set).`); + } + for (const d of mcpDrops) { + lines.push(`${d.field} dropped (env var ${d.ref} is not set).`); + } + for (const d of mcpServerDrops) { + lines.push( + `mcpServers.${d.name} dropped entirely (required fields referenced unset env vars: ${d.refs.join(', ')}).` + ); + } + return lines; +} + +type InteractiveSpec = { + bin: string; + args: readonly string[]; + initialPrompt: string | null; +}; + +function buildInteractiveSpec( + harness: Harness, + model: string, + systemPrompt: string, + resolvedMcp: Record | undefined, + permissions: PersonaPermissions | undefined +): InteractiveSpec { + switch (harness) { + case 'claude': { + // Always isolate MCP: pair --mcp-config with --strict-mcp-config so + // only the persona's declared servers load. Without --strict, Claude + // merges our config with ~/.claude.json and project-level MCP sources, + // pulling in whatever the user has configured elsewhere. + const mcpPayload = JSON.stringify({ mcpServers: resolvedMcp ?? {} }); + const base: string[] = [ + '--model', + model, + '--append-system-prompt', + systemPrompt, + '--mcp-config', + mcpPayload, + '--strict-mcp-config' + ]; + if (permissions?.allow && permissions.allow.length > 0) { + base.push('--allowedTools', ...permissions.allow); + } + if (permissions?.deny && permissions.deny.length > 0) { + base.push('--disallowedTools', ...permissions.deny); + } + if (permissions?.mode) { + base.push('--permission-mode', permissions.mode); + } + return { bin: 'claude', args: base, initialPrompt: null }; + } + case 'codex': + if (resolvedMcp && Object.keys(resolvedMcp).length > 0) { + process.stderr.write( + `warning: persona declares mcpServers but the codex harness is not yet wired for runtime MCP injection; proceeding without MCP.\n` + ); + } + if (permissions && (permissions.allow?.length || permissions.deny?.length || permissions.mode)) { + process.stderr.write( + `warning: persona declares permissions but the codex harness is not yet wired for runtime permission injection; proceeding with codex defaults.\n` + ); + } + return { + bin: 'codex', + args: ['-m', stripProviderPrefix(model)], + initialPrompt: systemPrompt + }; + case 'opencode': + if (resolvedMcp && Object.keys(resolvedMcp).length > 0) { + process.stderr.write( + `warning: persona declares mcpServers but the opencode harness is not yet wired for runtime MCP injection; proceeding without MCP.\n` + ); + } + if (permissions && (permissions.allow?.length || permissions.deny?.length || permissions.mode)) { + process.stderr.write( + `warning: persona declares permissions but the opencode harness is not yet wired for runtime permission injection; proceeding with opencode defaults.\n` + ); + } + return { + bin: 'opencode', + args: ['--model', stripProviderPrefix(model)], + initialPrompt: systemPrompt + }; + } +} + +function emitDropWarnings(lines: string[]): void { + if (lines.length === 0) return; + for (const line of lines) process.stderr.write(`warning: ${line}\n`); + process.stderr.write( + ` (referenced env vars were not set — proceeding without those values; if the agent relies on them it may need to authenticate interactively, e.g. via OAuth.)\n` + ); +} + +async function runOneShot( + selection: PersonaSelection, + task: string +): Promise { + const { runtime } = selection; + process.stderr.write( + `→ ${selection.personaId} [${selection.tier}] via ${runtime.harness} (${runtime.model})\n` + ); + + const envResolution = resolveStringMapLenient(selection.env, process.env, 'env'); + emitDropWarnings(formatDropWarnings(envResolution.dropped, [], [])); + + if (selection.mcpServers && Object.keys(selection.mcpServers).length > 0) { + process.stderr.write( + `warning: mcpServers are not yet wired through the one-shot (sendMessage) path; the agent will run without MCP. Use interactive mode for MCP access.\n` + ); + } + + const ctx = useSelection(selection); + const execution = ctx.sendMessage(task, { + env: envResolution.value, + onProgress: ({ stream, text }) => { + (stream === 'stderr' ? process.stderr : process.stdout).write(text); + } + }); + try { + const result = await execution; + process.exit(result.exitCode ?? 0); + } catch (err) { + const typed = err as Error & { + result?: { exitCode: number | null; status: string; stderr?: string }; + }; + const status = typed.result?.status ?? 'failed'; + process.stderr.write(`\n[${status}] ${typed.message}\n`); + process.exit(typed.result?.exitCode ?? 1); + } +} + +function signalExitCode(signal: NodeJS.Signals | null): number { + if (!signal) return 0; + const num = (constants.signals as Record)[signal]; + return 128 + (num ?? 1); +} + +function runInstall(command: readonly string[], label: string): void { + const [bin, ...args] = command; + if (!bin) return; + process.stderr.write(`• ${label}\n`); + const res = spawnSync(bin, args, { stdio: 'inherit', shell: false }); + if (res.status !== 0) { + const code = res.status ?? 1; + process.stderr.write(`${label} failed (exit ${code}). Aborting.\n`); + process.exit(code); + } +} + +function runCleanup(command: readonly string[], commandString: string): void { + if (commandString === ':') return; + const [bin, ...args] = command; + if (!bin) return; + spawnSync(bin, args, { stdio: 'inherit', shell: false }); +} + +function runInteractive(selection: PersonaSelection): Promise { + const ctx = useSelection(selection); + const { runtime, personaId, tier } = selection; + const { install } = ctx; + process.stderr.write(`→ ${personaId} [${tier}] via ${runtime.harness} (${runtime.model})\n`); + + const envResolution = resolveStringMapLenient(selection.env, process.env, 'env'); + const mcpResolution = resolveMcpServersLenient(selection.mcpServers, process.env); + emitDropWarnings( + formatDropWarnings(envResolution.dropped, mcpResolution.dropped, mcpResolution.droppedServers) + ); + const resolvedEnv = envResolution.value; + const resolvedMcp = mcpResolution.servers; + + if (install.plan.installs.length > 0) { + const skillIds = install.plan.installs.map((i) => i.skillId).join(', '); + runInstall(install.command, `Installing skills: ${skillIds}`); + } + + const spec = buildInteractiveSpec( + runtime.harness, + runtime.model, + runtime.systemPrompt, + resolvedMcp, + selection.permissions + ); + const finalArgs = spec.initialPrompt ? [...spec.args, spec.initialPrompt] : [...spec.args]; + const promptNote = spec.initialPrompt ? ' ' : ''; + const mcpNote = + runtime.harness === 'claude' + ? ` [mcp-strict: ${Object.keys(resolvedMcp ?? {}).join(', ') || '(none)'}]` + : ''; + process.stderr.write(`• spawning: ${spec.bin} ${spec.args.join(' ')}${promptNote}${mcpNote}\n`); + + return new Promise((resolve) => { + let settled = false; + const finish = (code: number) => { + if (settled) return; + settled = true; + runCleanup(install.cleanupCommand, install.cleanupCommandString); + resolve(code); + }; + + const child = spawn(spec.bin, finalArgs, { + stdio: 'inherit', + env: resolvedEnv ? { ...process.env, ...resolvedEnv } : process.env + }); + + const forward = (signal: NodeJS.Signals) => { + if (!child.killed) child.kill(signal); + }; + process.on('SIGINT', () => forward('SIGINT')); + process.on('SIGTERM', () => forward('SIGTERM')); + + child.on('exit', (code, signal) => { + finish(code ?? signalExitCode(signal)); + }); + child.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + process.stderr.write( + `Failed to spawn "${spec.bin}": binary not found on PATH. Install the ${runtime.harness} CLI and retry.\n` + ); + } else { + process.stderr.write(`Failed to spawn "${spec.bin}": ${err.message}\n`); + } + finish(127); + }); + }); +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const [subcommand, ...rest] = argv; + + if (!subcommand || subcommand === '-h' || subcommand === '--help') { + process.stdout.write(USAGE); + process.exit(subcommand ? 0 : 1); + } + + if (subcommand !== 'agent') { + die(`Unknown subcommand "${subcommand}".`); + } + + const [selector, ...taskParts] = rest; + if (!selector) die('agent: missing persona selector.'); + + const target = parseSelector(selector); + const selection = buildSelection(target.spec, target.tier, target.kind); + + if (taskParts.length > 0) { + await runOneShot(selection, taskParts.join(' ')); + } else { + const code = await runInteractive(selection); + process.exit(code); + } +} + +main().catch((err) => { + process.stderr.write(`${(err as Error)?.stack ?? String(err)}\n`); + process.exit(1); +}); diff --git a/packages/cli/src/env-refs.test.ts b/packages/cli/src/env-refs.test.ts new file mode 100644 index 0000000..974ebfc --- /dev/null +++ b/packages/cli/src/env-refs.test.ts @@ -0,0 +1,131 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MissingEnvRefError, + makeEnvRefResolver, + makeLenientResolver, + resolveStringMap, + resolveStringMapLenient +} from './env-refs.js'; + +test('resolves $VAR references against the provided env', () => { + const resolve = makeEnvRefResolver({ FOO: 'hello' }); + assert.equal(resolve('$FOO', 'x'), 'hello'); +}); + +test('passes literal strings through untouched', () => { + const resolve = makeEnvRefResolver({ FOO: 'hello' }); + assert.equal(resolve('plain literal', 'x'), 'plain literal'); + assert.equal(resolve('Bearer hello-world', 'x'), 'Bearer hello-world'); +}); + +test('refuses un-braced partial interpolation — prefix-$VAR stays literal', () => { + // Unbraced `$VAR` mid-string is kept as-is so a stray `$` in a JSON value + // doesn't get eaten by accident. Use ${VAR} for partial interpolation. + const resolve = makeEnvRefResolver({ FOO: 'hello' }); + assert.equal(resolve('prefix-$FOO', 'x'), 'prefix-$FOO'); +}); + +test('resolves braced ${VAR} interpolation anywhere in a string', () => { + const resolve = makeEnvRefResolver({ POSTHOG_API_KEY: 'phx_abc' }); + assert.equal(resolve('Bearer ${POSTHOG_API_KEY}', 'auth'), 'Bearer phx_abc'); + assert.equal(resolve('${POSTHOG_API_KEY}-suffix', 'x'), 'phx_abc-suffix'); + assert.equal(resolve('${POSTHOG_API_KEY}', 'x'), 'phx_abc'); +}); + +test('interpolates multiple ${VAR} occurrences in the same string', () => { + const resolve = makeEnvRefResolver({ A: 'one', B: 'two' }); + assert.equal(resolve('${A}-${B}-${A}', 'x'), 'one-two-one'); +}); + +test('missing ${VAR} inside a longer string errors with the field and var', () => { + const resolve = makeEnvRefResolver({ FOO: 'ok' }); + assert.throws( + () => resolve('Bearer ${MISSING_KEY}', 'headers.Authorization'), + (err: unknown) => + err instanceof MissingEnvRefError && + err.ref === 'MISSING_KEY' && + err.referencedBy === 'headers.Authorization' + ); +}); + +test('throws MissingEnvRefError with the referenced name and field', () => { + const resolve = makeEnvRefResolver({}); + assert.throws( + () => resolve('$POSTHOG_API_KEY', 'env.POSTHOG_API_KEY'), + (err: unknown) => + err instanceof MissingEnvRefError && + err.ref === 'POSTHOG_API_KEY' && + err.referencedBy === 'env.POSTHOG_API_KEY' + ); +}); + +test('treats empty-string env vars as missing (explicit unset)', () => { + const resolve = makeEnvRefResolver({ FOO: '' }); + assert.throws(() => resolve('$FOO', 'x'), MissingEnvRefError); +}); + +test('resolveStringMap walks every value and reports the originating field', () => { + const result = resolveStringMap( + { A: '$FOO', B: 'literal' }, + { FOO: 'ok' }, + 'env' + ); + assert.deepEqual(result, { A: 'ok', B: 'literal' }); + + assert.throws( + () => resolveStringMap({ X: '$MISSING' }, {}, 'env'), + (err: unknown) => + err instanceof MissingEnvRefError && + err.ref === 'MISSING' && + err.referencedBy === 'env.X' + ); +}); + +test('resolveStringMap returns undefined for undefined input', () => { + assert.equal(resolveStringMap(undefined, {}, 'env'), undefined); +}); + +test('lenient resolver reports missing refs instead of throwing', () => { + const resolve = makeLenientResolver({ FOO: 'ok' }); + assert.deepEqual(resolve('$FOO', 'x'), { ok: true, value: 'ok' }); + assert.deepEqual(resolve('$MISSING', 'env.K'), { + ok: false, + field: 'env.K', + ref: 'MISSING' + }); + assert.deepEqual(resolve('Bearer ${MISSING_KEY}', 'headers.Auth'), { + ok: false, + field: 'headers.Auth', + ref: 'MISSING_KEY' + }); +}); + +test('resolveStringMapLenient drops missing entries and reports them', () => { + const result = resolveStringMapLenient( + { + PRESENT: '$FOO', + MISSING: '$NOPE', + LITERAL: 'plain', + PARTIAL_MISSING: 'Bearer ${NO_KEY}' + }, + { FOO: 'value' }, + 'env' + ); + assert.deepEqual(result.value, { PRESENT: 'value', LITERAL: 'plain' }); + assert.deepEqual( + result.dropped.map((d) => `${d.field}:${d.ref}`).sort(), + ['env.MISSING:NOPE', 'env.PARTIAL_MISSING:NO_KEY'] + ); +}); + +test('resolveStringMapLenient returns value=undefined when all entries dropped', () => { + const result = resolveStringMapLenient( + { A: '$MISSING' }, + {}, + 'env' + ); + assert.equal(result.value, undefined); + assert.equal(result.dropped.length, 1); +}); diff --git a/packages/cli/src/env-refs.ts b/packages/cli/src/env-refs.ts new file mode 100644 index 0000000..d532c75 --- /dev/null +++ b/packages/cli/src/env-refs.ts @@ -0,0 +1,138 @@ +/** + * Resolve env references in persona `env` / `mcpServers` values against the + * caller's process environment. Two reference forms are supported: + * + * "$VAR" whole-string reference; replaced by the env var value. + * "Bearer ${VAR}" braced reference(s), interpolated anywhere in the + * string. Useful for header prefixes like `Bearer …`. + * + * Bare `$VAR` that appears *inside* a longer string (without braces) is kept + * as a literal — we only interpolate when the intent is unambiguous, so a + * literal `$` in a JSON string doesn't accidentally get eaten. + * + * A missing env var is a hard error naming both the referenced variable and + * the persona field that asked for it. + */ + +export type EnvRefResolver = (value: string) => string; + +const WHOLE_REF = /^\$([A-Z_][A-Z0-9_]*)$/; +const BRACED_REF = /\$\{([A-Z_][A-Z0-9_]*)\}/g; + +export class MissingEnvRefError extends Error { + readonly ref: string; + readonly referencedBy: string; + constructor(ref: string, referencedBy: string) { + super( + `Environment variable ${ref} is required by persona field \`${referencedBy}\` but is not set in the current shell. Export it and retry.` + ); + this.name = 'MissingEnvRefError'; + this.ref = ref; + this.referencedBy = referencedBy; + } +} + +export function makeEnvRefResolver(processEnv: NodeJS.ProcessEnv): ( + value: string, + field: string +) => string { + return (value, field) => { + const whole = WHOLE_REF.exec(value); + if (whole) { + const name = whole[1]; + const resolved = processEnv[name]; + if (resolved === undefined || resolved === '') { + throw new MissingEnvRefError(name, field); + } + return resolved; + } + + if (!value.includes('${')) return value; + + return value.replace(BRACED_REF, (_, name: string) => { + const resolved = processEnv[name]; + if (resolved === undefined || resolved === '') { + throw new MissingEnvRefError(name, field); + } + return resolved; + }); + }; +} + +export function resolveStringMap( + map: Record | undefined, + processEnv: NodeJS.ProcessEnv, + fieldPrefix: string +): Record | undefined { + if (!map) return undefined; + const resolve = makeEnvRefResolver(processEnv); + const out: Record = {}; + for (const [key, value] of Object.entries(map)) { + out[key] = resolve(value, `${fieldPrefix}.${key}`); + } + return out; +} + +/** + * Like {@link makeEnvRefResolver} but never throws on missing refs. Returns + * a result object the caller can inspect to decide whether a missing ref is + * fatal (e.g. a `url` / `command`) or droppable (e.g. a specific header). + */ +export type LenientResult = + | { ok: true; value: string } + | { ok: false; field: string; ref: string }; + +export function makeLenientResolver( + processEnv: NodeJS.ProcessEnv +): (value: string, field: string) => LenientResult { + const strict = makeEnvRefResolver(processEnv); + return (value, field) => { + try { + return { ok: true, value: strict(value, field) }; + } catch (err) { + if (err instanceof MissingEnvRefError) { + return { ok: false, field, ref: err.ref }; + } + throw err; + } + }; +} + +export interface DroppedRef { + field: string; + ref: string; +} + +/** + * Walk a `Record`, resolving each value leniently. Entries + * whose value referenced an unset env var are dropped from the result and + * reported via `dropped`. Literal strings and successfully-resolved refs + * pass through to `value`. + * + * Returns `value: undefined` when every entry was either dropped or the map + * itself was undefined — callers can use that to decide whether to emit a + * flag at all. + */ +export function resolveStringMapLenient( + map: Record | undefined, + processEnv: NodeJS.ProcessEnv, + fieldPrefix: string +): { value: Record | undefined; dropped: DroppedRef[] } { + if (!map) return { value: undefined, dropped: [] }; + const resolve = makeLenientResolver(processEnv); + const out: Record = {}; + const dropped: DroppedRef[] = []; + for (const [key, raw] of Object.entries(map)) { + const field = `${fieldPrefix}.${key}`; + const result = resolve(raw, field); + if (result.ok) { + out[key] = result.value; + } else { + dropped.push({ field: result.field, ref: result.ref }); + } + } + return { + value: Object.keys(out).length > 0 ? out : undefined, + dropped + }; +} diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts new file mode 100644 index 0000000..9b7345b --- /dev/null +++ b/packages/cli/src/local-personas.test.ts @@ -0,0 +1,238 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { loadLocalPersonas } from './local-personas.js'; + +type Dirs = { cwd: string; home: string; pwdDir: string; homeDir: string }; + +function withLayers(fn: (dirs: Dirs) => T): T { + const root = mkdtempSync(join(tmpdir(), 'agentworkforce-cascade-')); + const cwd = join(root, 'project'); + const home = join(root, 'home'); + const pwdDir = join(cwd, '.agent-workforce'); + const homeDir = join(home, '.agent-workforce'); + mkdirSync(pwdDir, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + try { + return fn({ cwd, home, pwdDir, homeDir }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function writeJson(path: string, value: unknown): void { + writeFileSync(path, JSON.stringify(value)); +} + +test('home layer extends library and merges env', () => { + withLayers(({ cwd, homeDir }) => { + writeJson(join(homeDir, 'my-posthog.json'), { + id: 'my-posthog', + extends: 'posthog', + env: { POSTHOG_API_KEY: '$POSTHOG_API_KEY', EXTRA: 'literal' } + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + assert.deepEqual(loaded.warnings, []); + const spec = loaded.byId.get('my-posthog'); + assert.ok(spec); + assert.equal(loaded.sources.get('my-posthog'), 'home'); + assert.equal(spec.intent, 'posthog'); + assert.equal(spec.env?.POSTHOG_API_KEY, '$POSTHOG_API_KEY'); + assert.equal(spec.env?.EXTRA, 'literal'); + assert.ok(spec.mcpServers?.posthog); + }); +}); + +test('pwd layer overrides home layer for the same id', () => { + withLayers(({ cwd, homeDir, pwdDir }) => { + writeJson(join(homeDir, 'ph.json'), { + id: 'ph', + extends: 'posthog', + env: { POSTHOG_API_KEY: 'home-value', FROM_HOME: 'yes' } + }); + writeJson(join(pwdDir, 'ph.json'), { + id: 'ph', + extends: 'posthog', + env: { POSTHOG_API_KEY: 'pwd-value' } + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + assert.deepEqual(loaded.warnings, []); + const spec = loaded.byId.get('ph'); + assert.equal(loaded.sources.get('ph'), 'pwd'); + // pwd's env wins; note home is NOT layered here (pwd overrides home as a whole, + // not merges). Base is library/posthog directly via pwd's own `extends`. + assert.equal(spec?.env?.POSTHOG_API_KEY, 'pwd-value'); + assert.equal(spec?.env?.FROM_HOME, undefined); + }); +}); + +test('implicit same-id extends: pwd file with id=posthog inherits from library posthog', () => { + withLayers(({ cwd, homeDir, pwdDir }) => { + writeJson(join(pwdDir, 'posthog.json'), { + id: 'posthog', + env: { POSTHOG_API_KEY: '$POSTHOG_API_KEY' } + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + assert.deepEqual(loaded.warnings, []); + const spec = loaded.byId.get('posthog'); + assert.ok(spec); + assert.equal(loaded.sources.get('posthog'), 'pwd'); + // Library fields still flow through (mcpServers, tiers, description). + assert.ok(spec.mcpServers?.posthog); + assert.equal(spec.tiers.best.harness, 'claude'); + assert.equal(spec.env?.POSTHOG_API_KEY, '$POSTHOG_API_KEY'); + }); +}); + +test('cascade chain: pwd extends home extends library', () => { + withLayers(({ cwd, homeDir, pwdDir }) => { + // home defines a mid-layer override that adds a default env key. + writeJson(join(homeDir, 'ph-base.json'), { + id: 'ph-base', + extends: 'posthog', + env: { DEFAULT_ORG: 'acme' } + }); + // pwd extends the home persona (not the library directly). + writeJson(join(pwdDir, 'ph-prod.json'), { + id: 'ph-prod', + extends: 'ph-base', + env: { POSTHOG_API_KEY: '$PROD_KEY' } + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + assert.deepEqual(loaded.warnings, []); + const prod = loaded.byId.get('ph-prod'); + assert.ok(prod); + // Both env keys flow through the chain. + assert.equal(prod.env?.DEFAULT_ORG, 'acme'); + assert.equal(prod.env?.POSTHOG_API_KEY, '$PROD_KEY'); + // MCP from library is preserved. + assert.ok(prod.mcpServers?.posthog); + }); +}); + +test('per-tier override only replaces the named tier, leaving others untouched', () => { + withLayers(({ cwd, homeDir }) => { + writeJson(join(homeDir, 'ph.json'), { + id: 'ph', + extends: 'posthog', + tiers: { + best: { model: 'claude-sonnet-4-6' } + } + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + const spec = loaded.byId.get('ph'); + assert.equal(spec?.tiers.best.model, 'claude-sonnet-4-6'); + // systemPrompt is inherited on the overridden tier too (partial per-tier merge). + assert.match(spec?.tiers.best.systemPrompt ?? '', /PostHog/); + // Other tiers untouched. + assert.equal(spec?.tiers['best-value'].model, 'claude-sonnet-4-6'); + assert.equal(spec?.tiers.minimum.model, 'claude-haiku-4-5-20251001'); + }); +}); + +test('top-level systemPrompt replaces prompt across all inherited tiers', () => { + withLayers(({ cwd, homeDir }) => { + writeJson(join(homeDir, 'ph.json'), { + id: 'ph', + extends: 'posthog', + systemPrompt: 'You answer only yes or no.' + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + const spec = loaded.byId.get('ph'); + assert.equal(spec?.tiers.best.systemPrompt, 'You answer only yes or no.'); + assert.equal(spec?.tiers['best-value'].systemPrompt, 'You answer only yes or no.'); + assert.equal(spec?.tiers.minimum.systemPrompt, 'You answer only yes or no.'); + }); +}); + +test('warns when extends base does not exist in lower layers', () => { + withLayers(({ cwd, homeDir }) => { + writeJson(join(homeDir, 'broken.json'), { + id: 'broken', + extends: 'does-not-exist' + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + assert.equal(loaded.byId.size, 0); + assert.equal(loaded.warnings.length, 1); + assert.match(loaded.warnings[0], /does-not-exist/); + }); +}); + +test('warns on duplicate ids within a single layer', () => { + withLayers(({ cwd, homeDir }) => { + writeJson(join(homeDir, 'a.json'), { id: 'dup', extends: 'posthog' }); + writeJson(join(homeDir, 'b.json'), { id: 'dup', extends: 'posthog' }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + assert.equal(loaded.byId.size, 1); + assert.equal(loaded.warnings.length, 1); + assert.match(loaded.warnings[0], /duplicate id "dup"/); + }); +}); + +test('returns empty result when neither layer exists', () => { + const loaded = loadLocalPersonas({ + cwd: '/tmp/agentworkforce-nonexistent-pwd-zzz', + homeDir: '/tmp/agentworkforce-nonexistent-home-zzz' + }); + assert.equal(loaded.byId.size, 0); + assert.deepEqual(loaded.warnings, []); +}); + +test('permissions merge: allow/deny union dedup, mode overrides', () => { + withLayers(({ cwd, homeDir, pwdDir }) => { + // Base posthog already has permissions.allow = ["mcp__posthog"] in the + // library file. Home adds a Bash deny + sets default mode; pwd adds + // another allow and overrides the mode. + writeJson(join(homeDir, 'ph.json'), { + id: 'ph', + extends: 'posthog', + permissions: { + deny: ['Bash(rm -rf *)'], + mode: 'default' + } + }); + writeJson(join(pwdDir, 'ph.json'), { + id: 'ph', + extends: 'ph', + permissions: { + allow: ['Bash(git *)'], + mode: 'acceptEdits' + } + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + assert.deepEqual(loaded.warnings, []); + const spec = loaded.byId.get('ph'); + assert.deepEqual( + spec?.permissions?.allow?.slice().sort(), + ['Bash(git *)', 'mcp__posthog'].sort() + ); + assert.deepEqual(spec?.permissions?.deny, ['Bash(rm -rf *)']); + assert.equal(spec?.permissions?.mode, 'acceptEdits'); + }); +}); + +test('permissions allow list dedupes across layers', () => { + withLayers(({ cwd, homeDir }) => { + writeJson(join(homeDir, 'ph.json'), { + id: 'ph', + extends: 'posthog', + permissions: { allow: ['mcp__posthog'] } + }); + const loaded = loadLocalPersonas({ cwd, homeDir }); + const spec = loaded.byId.get('ph'); + assert.deepEqual(spec?.permissions?.allow, ['mcp__posthog']); + }); +}); + +test('surfaces parse errors as per-file warnings without throwing', () => { + withLayers(({ cwd, homeDir }) => { + writeFileSync(join(homeDir, 'bad.json'), '{ not valid json'); + const loaded = loadLocalPersonas({ cwd, homeDir }); + assert.equal(loaded.byId.size, 0); + assert.equal(loaded.warnings.length, 1); + assert.match(loaded.warnings[0], /bad\.json/); + }); +}); diff --git a/packages/cli/src/local-personas.ts b/packages/cli/src/local-personas.ts new file mode 100644 index 0000000..c0ff60e --- /dev/null +++ b/packages/cli/src/local-personas.ts @@ -0,0 +1,281 @@ +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { + personaCatalog, + PERSONA_TIERS, + type McpServerSpec, + type PersonaPermissions, + type PersonaRuntime, + type PersonaSpec, + type PersonaTier +} from '@agentworkforce/workload-router'; + +/** + * User-defined persona override. Local files are partial overlays — only the + * fields you specify replace the inherited base; everything else cascades down + * through pwd → home → library. + * + * `extends` names the base explicitly by id or intent. If omitted, the loader + * implicitly inherits from the same-id persona found in the next lower layer. + */ +export interface LocalPersonaOverride { + id: string; + extends?: string; + description?: string; + skills?: PersonaSpec['skills']; + env?: Record; + mcpServers?: Record; + /** + * Permission policy. `allow` and `deny` append to the base's lists (dedup + * on merge); `mode` replaces the base's mode when set. + */ + permissions?: PersonaPermissions; + /** Convenience: replaces systemPrompt on every inherited tier. Ignored if `tiers` is also set. */ + systemPrompt?: string; + /** Per-tier overrides. If a tier is set here, it replaces the inherited tier wholesale. */ + tiers?: Partial>>; +} + +type Layer = 'pwd' | 'home'; +const LAYER_ORDER: Layer[] = ['pwd', 'home']; + +export type PersonaSource = Layer | 'library'; + +export interface LoadedLocalPersonas { + /** Final resolved specs by id, with the cascade applied (pwd wins over home wins over library). */ + byId: Map; + /** Where each id in `byId` was defined (top-most layer that declared it). */ + sources: Map; + warnings: string[]; +} + +export interface LoadOptions { + cwd?: string; + homeDir?: string; +} + +function defaultHomeDir(): string { + const override = process.env.AGENT_WORKFORCE_CONFIG_DIR; + if (override && override.trim()) return override; + return join(homedir(), '.agent-workforce'); +} + +function defaultPwdDir(cwd: string): string { + return join(cwd, '.agent-workforce'); +} + +function readLayerDir( + dir: string, + layer: Layer, + warnings: string[] +): Map { + const out = new Map(); + if (!existsSync(dir)) return out; + + let entries: string[]; + try { + entries = readdirSync(dir).filter((n) => n.endsWith('.json')); + } catch (err) { + warnings.push(`[${layer}] could not read ${dir}: ${(err as Error).message}`); + return out; + } + + for (const file of entries) { + const path = join(dir, file); + try { + const raw = readFileSync(path, 'utf8'); + const parsed = parseOverride(JSON.parse(raw), `[${layer}] ${file}`); + if (out.has(parsed.id)) { + warnings.push(`[${layer}] ${file}: duplicate id "${parsed.id}" within layer; skipping.`); + continue; + } + out.set(parsed.id, parsed); + } catch (err) { + warnings.push(`[${layer}] ${file}: ${(err as Error).message}`); + } + } + return out; +} + +function parseOverride(value: unknown, context: string): LocalPersonaOverride { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${context} must be a JSON object`); + } + const raw = value as Record; + if (typeof raw.id !== 'string' || !raw.id.trim()) { + throw new Error(`${context}.id must be a non-empty string`); + } + if (raw.extends !== undefined && (typeof raw.extends !== 'string' || !raw.extends.trim())) { + throw new Error(`${context}.extends must be a non-empty string if provided`); + } + if (raw.systemPrompt !== undefined && typeof raw.systemPrompt !== 'string') { + throw new Error(`${context}.systemPrompt must be a string if provided`); + } + if (raw.description !== undefined && typeof raw.description !== 'string') { + throw new Error(`${context}.description must be a string if provided`); + } + return { + id: raw.id, + extends: raw.extends as string | undefined, + description: raw.description as string | undefined, + skills: raw.skills as PersonaSpec['skills'] | undefined, + env: raw.env as LocalPersonaOverride['env'], + mcpServers: raw.mcpServers as LocalPersonaOverride['mcpServers'], + permissions: raw.permissions as LocalPersonaOverride['permissions'], + systemPrompt: raw.systemPrompt as string | undefined, + tiers: raw.tiers as LocalPersonaOverride['tiers'] + }; +} + +function findInLibrary(key: string): PersonaSpec | undefined { + const byIntent = (personaCatalog as Record)[key]; + if (byIntent) return byIntent; + for (const spec of Object.values(personaCatalog)) { + if (spec.id === key) return spec; + } + return undefined; +} + +/** + * Mutual-recursion with resolveInLayer: given a base key, walk strictly-lower + * layers until we find a persona with that id (local layers) or an id/intent + * match in the library. Returns a fully-merged PersonaSpec or undefined. + */ +function findInLowerLayers( + key: string, + startLayerIdx: number, + overrides: Record>, + resolving: Set +): PersonaSpec | undefined { + for (let i = startLayerIdx; i < LAYER_ORDER.length; i++) { + const layer = LAYER_ORDER[i]; + if (overrides[layer].has(key)) { + return resolveInLayer(key, layer, overrides, resolving); + } + } + return findInLibrary(key); +} + +function resolveInLayer( + id: string, + layer: Layer, + overrides: Record>, + resolving: Set +): PersonaSpec { + const key = `${layer}:${id}`; + if (resolving.has(key)) { + throw new Error(`extends cycle detected through ${[...resolving, key].join(' -> ')}`); + } + resolving.add(key); + try { + const override = overrides[layer].get(id); + if (!override) { + throw new Error(`internal: resolveInLayer called for missing ${key}`); + } + const baseKey = override.extends ?? override.id; + const layerIdx = LAYER_ORDER.indexOf(layer); + const base = findInLowerLayers(baseKey, layerIdx + 1, overrides, resolving); + if (!base) { + const hint = override.extends + ? `extends "${override.extends}" does not match any persona in lower layers (home, library)` + : `no lower-layer persona with id "${override.id}" to implicitly inherit from; add extends or define the persona in a lower layer`; + throw new Error(hint); + } + return mergeOverride(base, override); + } finally { + resolving.delete(key); + } +} + +function mergeOverride(base: PersonaSpec, override: LocalPersonaOverride): PersonaSpec { + const tiers = {} as Record; + for (const tier of PERSONA_TIERS) { + const baseRuntime = base.tiers[tier]; + const tierOverride = override.tiers?.[tier]; + let merged: PersonaRuntime = tierOverride + ? { + ...baseRuntime, + ...tierOverride, + harnessSettings: { + ...baseRuntime.harnessSettings, + ...(tierOverride.harnessSettings ?? {}) + } + } + : baseRuntime; + if (override.systemPrompt && !tierOverride?.systemPrompt) { + merged = { ...merged, systemPrompt: override.systemPrompt }; + } + tiers[tier] = merged; + } + + const env = + override.env || base.env + ? { ...(base.env ?? {}), ...(override.env ?? {}) } + : undefined; + const mcpServers = + override.mcpServers || base.mcpServers + ? { ...(base.mcpServers ?? {}), ...(override.mcpServers ?? {}) } + : undefined; + const permissions = mergePermissions(base.permissions, override.permissions); + + return { + id: override.id, + intent: base.intent, + description: override.description ?? base.description, + skills: override.skills ?? base.skills, + tiers, + ...(env ? { env } : {}), + ...(mcpServers ? { mcpServers } : {}), + ...(permissions ? { permissions } : {}) + }; +} + +function mergePermissions( + base: PersonaPermissions | undefined, + override: PersonaPermissions | undefined +): PersonaPermissions | undefined { + if (!base && !override) return undefined; + const allow = dedupe([...(base?.allow ?? []), ...(override?.allow ?? [])]); + const deny = dedupe([...(base?.deny ?? []), ...(override?.deny ?? [])]); + const mode = override?.mode ?? base?.mode; + const out: PersonaPermissions = {}; + if (allow.length > 0) out.allow = allow; + if (deny.length > 0) out.deny = deny; + if (mode) out.mode = mode; + return Object.keys(out).length > 0 ? out : undefined; +} + +function dedupe(values: string[]): string[] { + return [...new Set(values)]; +} + +export function loadLocalPersonas(options: LoadOptions = {}): LoadedLocalPersonas { + const cwd = options.cwd ?? process.cwd(); + const homeDir = options.homeDir ?? defaultHomeDir(); + const warnings: string[] = []; + + const overrides: Record> = { + pwd: readLayerDir(defaultPwdDir(cwd), 'pwd', warnings), + home: readLayerDir(homeDir, 'home', warnings) + }; + + const byId = new Map(); + const sources = new Map(); + + for (const layer of LAYER_ORDER) { + for (const id of overrides[layer].keys()) { + if (byId.has(id)) continue; // higher-layer already won + try { + const resolved = resolveInLayer(id, layer, overrides, new Set()); + byId.set(id, resolved); + sources.set(id, layer); + } catch (err) { + warnings.push(`[${layer}] ${id}: ${(err as Error).message}`); + } + } + } + + return { byId, sources, warnings }; +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/workload-router/package.json b/packages/workload-router/package.json index 26643ce..346a334 100644 --- a/packages/workload-router/package.json +++ b/packages/workload-router/package.json @@ -11,9 +11,6 @@ "default": "./dist/index.js" } }, - "bin": { - "agent-workforce": "dist/cli.js" - }, "files": [ "dist", "routing-profiles", @@ -32,7 +29,7 @@ }, "scripts": { "generate:personas": "node ./scripts/generate-personas.mjs", - "build": "npm run generate:personas && tsc -p tsconfig.json && chmod +x dist/cli.js", + "build": "npm run generate:personas && tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "npm run generate:personas && tsc -p tsconfig.json && node --test dist/index.test.js", "lint": "npm run generate:personas && tsc -p tsconfig.json --noEmit" diff --git a/packages/workload-router/routing-profiles/default.json b/packages/workload-router/routing-profiles/default.json index 996e379..91f0d81 100644 --- a/packages/workload-router/routing-profiles/default.json +++ b/packages/workload-router/routing-profiles/default.json @@ -78,6 +78,10 @@ "capability-discovery": { "tier": "best-value", "rationale": "Searching skill.sh and prpm.dev for existing skills, agents, and hooks is lightweight research; the balanced default is sufficient when guided by the skill.sh/find-skills and @prpm/self-improving skills." + }, + "posthog": { + "tier": "best-value", + "rationale": "PostHog queries are interactive analytics lookups; best-value is sufficient and keeps latency low when chatting with the MCP server." } } } diff --git a/packages/workload-router/scripts/generate-personas.mjs b/packages/workload-router/scripts/generate-personas.mjs index c7216e8..5c3fc7c 100644 --- a/packages/workload-router/scripts/generate-personas.mjs +++ b/packages/workload-router/scripts/generate-personas.mjs @@ -27,7 +27,8 @@ const exportNameMap = new Map([ ['sage-proactive-rewirer', 'sageProactiveRewirer'], ['cloud-slack-proxy-guard', 'cloudSlackProxyGuard'], ['agent-relay-e2e-conductor', 'agentRelayE2eConductor'], - ['capability-discoverer', 'capabilityDiscoverer'] + ['capability-discoverer', 'capabilityDiscoverer'], + ['posthog', 'posthogAgent'] ]); const files = (await fs.readdir(personasDir)).filter((n) => n.endsWith('.json')).sort(); diff --git a/packages/workload-router/src/cli.ts b/packages/workload-router/src/cli.ts deleted file mode 100644 index 01619c6..0000000 --- a/packages/workload-router/src/cli.ts +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env node -import { spawn, spawnSync } from 'node:child_process'; -import { constants } from 'node:os'; - -import { - PERSONA_TIERS, - personaCatalog, - usePersona, - type Harness, - type PersonaIntent, - type PersonaTier, - type PersonaSpec -} from './index.js'; - -const USAGE = `Usage: agent-workforce agent [@] [task...] - - persona id or intent (e.g. npm-provenance-publisher or npm-provenance) - ${PERSONA_TIERS.join(' | ')} (default: best-value) - [task] if provided, runs one-shot non-interactively; - otherwise drops into an interactive harness session - -Examples: - agent-workforce agent npm-provenance-publisher@best - agent-workforce agent review@best-value "look at the diff on this branch" -`; - -function die(msg: string, withUsage = true): never { - process.stderr.write(`${msg}\n`); - if (withUsage) process.stderr.write(`\n${USAGE}`); - process.exit(1); -} - -function resolveIntent(key: string): PersonaIntent { - if (Object.prototype.hasOwnProperty.call(personaCatalog, key)) { - return key as PersonaIntent; - } - const specs = Object.values(personaCatalog) as PersonaSpec[]; - const byId = specs.find((p) => p.id === key); - if (byId) return byId.intent; - const listing = specs - .slice() - .sort((a, b) => a.id.localeCompare(b.id)) - .map((p) => ` ${p.id} (intent: ${p.intent})`) - .join('\n'); - die(`Unknown persona "${key}". Known personas:\n${listing}`, false); -} - -function parseSelector(sel: string): { intent: PersonaIntent; tier: PersonaTier } { - const at = sel.indexOf('@'); - const id = at === -1 ? sel : sel.slice(0, at); - const tierRaw = at === -1 ? undefined : sel.slice(at + 1); - if (!id) die('Missing persona name before "@"'); - const tier = (tierRaw ?? 'best-value') as PersonaTier; - if (tierRaw !== undefined && !PERSONA_TIERS.includes(tier)) { - die(`Invalid tier "${tierRaw}". Must be one of: ${PERSONA_TIERS.join(', ')}`); - } - return { intent: resolveIntent(id), tier }; -} - -function stripProviderPrefix(model: string): string { - const idx = model.indexOf('/'); - return idx >= 0 ? model.slice(idx + 1) : model; -} - -type InteractiveSpec = { - bin: string; - args: readonly string[]; - initialPrompt: string | null; -}; - -function buildInteractiveSpec( - harness: Harness, - model: string, - systemPrompt: string -): InteractiveSpec { - switch (harness) { - case 'claude': - return { - bin: 'claude', - args: ['--model', model, '--append-system-prompt', systemPrompt], - initialPrompt: null - }; - case 'codex': - return { - bin: 'codex', - args: ['-m', stripProviderPrefix(model)], - initialPrompt: systemPrompt - }; - case 'opencode': - return { - bin: 'opencode', - args: ['--model', stripProviderPrefix(model)], - initialPrompt: systemPrompt - }; - } -} - -async function runOneShot( - intent: PersonaIntent, - tier: PersonaTier, - task: string -): Promise { - const ctx = usePersona(intent, { tier }); - const { personaId, runtime } = ctx.selection; - process.stderr.write( - `→ ${personaId} [${tier}] via ${runtime.harness} (${runtime.model})\n` - ); - const execution = ctx.sendMessage(task, { - onProgress: ({ stream, text }) => { - (stream === 'stderr' ? process.stderr : process.stdout).write(text); - } - }); - try { - const result = await execution; - process.exit(result.exitCode ?? 0); - } catch (err) { - const typed = err as Error & { - result?: { exitCode: number | null; status: string; stderr?: string }; - }; - const status = typed.result?.status ?? 'failed'; - process.stderr.write(`\n[${status}] ${typed.message}\n`); - process.exit(typed.result?.exitCode ?? 1); - } -} - -function signalExitCode(signal: NodeJS.Signals | null): number { - if (!signal) return 0; - const num = (constants.signals as Record)[signal]; - return 128 + (num ?? 1); -} - -function runInstall(command: readonly string[], label: string): void { - const [bin, ...args] = command; - if (!bin) return; - process.stderr.write(`• ${label}\n`); - const res = spawnSync(bin, args, { stdio: 'inherit', shell: false }); - if (res.status !== 0) { - const code = res.status ?? 1; - process.stderr.write(`${label} failed (exit ${code}). Aborting.\n`); - process.exit(code); - } -} - -function runCleanup(command: readonly string[], commandString: string): void { - if (commandString === ':') return; - const [bin, ...args] = command; - if (!bin) return; - spawnSync(bin, args, { stdio: 'inherit', shell: false }); -} - -function runInteractive(intent: PersonaIntent, tier: PersonaTier): Promise { - const ctx = usePersona(intent, { tier }); - const { personaId, runtime } = ctx.selection; - const { install } = ctx; - process.stderr.write( - `→ ${personaId} [${tier}] via ${runtime.harness} (${runtime.model})\n` - ); - - if (install.plan.installs.length > 0) { - const skillIds = install.plan.installs.map((i) => i.skillId).join(', '); - runInstall(install.command, `Installing skills: ${skillIds}`); - } - - const spec = buildInteractiveSpec(runtime.harness, runtime.model, runtime.systemPrompt); - const finalArgs = spec.initialPrompt ? [...spec.args, spec.initialPrompt] : [...spec.args]; - const promptNote = spec.initialPrompt ? ' ' : ''; - process.stderr.write(`• spawning: ${spec.bin} ${spec.args.join(' ')}${promptNote}\n`); - - return new Promise((resolve) => { - let settled = false; - const finish = (code: number) => { - if (settled) return; - settled = true; - runCleanup(install.cleanupCommand, install.cleanupCommandString); - resolve(code); - }; - - const child = spawn(spec.bin, finalArgs, { stdio: 'inherit' }); - - const forward = (signal: NodeJS.Signals) => { - if (!child.killed) child.kill(signal); - }; - process.on('SIGINT', () => forward('SIGINT')); - process.on('SIGTERM', () => forward('SIGTERM')); - - child.on('exit', (code, signal) => { - finish(code ?? signalExitCode(signal)); - }); - child.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'ENOENT') { - process.stderr.write( - `Failed to spawn "${spec.bin}": binary not found on PATH. Install the ${runtime.harness} CLI and retry.\n` - ); - } else { - process.stderr.write(`Failed to spawn "${spec.bin}": ${err.message}\n`); - } - finish(127); - }); - }); -} - -async function main(): Promise { - const argv = process.argv.slice(2); - const [subcommand, ...rest] = argv; - - if (!subcommand || subcommand === '-h' || subcommand === '--help') { - process.stdout.write(USAGE); - process.exit(subcommand ? 0 : 1); - } - - if (subcommand !== 'agent') { - die(`Unknown subcommand "${subcommand}".`); - } - - const [selector, ...taskParts] = rest; - if (!selector) die('agent: missing persona selector.'); - - const { intent, tier } = parseSelector(selector); - - if (taskParts.length > 0) { - await runOneShot(intent, tier, taskParts.join(' ')); - } else { - const code = await runInteractive(intent, tier); - process.exit(code); - } -} - -main().catch((err) => { - process.stderr.write(`${(err as Error)?.stack ?? String(err)}\n`); - process.exit(1); -}); diff --git a/packages/workload-router/src/generated/personas.ts b/packages/workload-router/src/generated/personas.ts index 14f6ac5..9d40218 100644 --- a/packages/workload-router/src/generated/personas.ts +++ b/packages/workload-router/src/generated/personas.ts @@ -315,6 +315,48 @@ export const opencodeWorkflowSpecialist = { } } as const; +export const posthogAgent = { + "id": "posthog", + "intent": "posthog", + "description": "Narrow PostHog assistant wired to the PostHog MCP server. Answers product-analytics questions, inspects events/insights/feature flags, and navigates the configured PostHog project. Carries no other skills — intended as a base for user-extended personas that supply their own API key.", + "skills": [], + "env": { + "POSTHOG_API_KEY": "$POSTHOG_API_KEY" + }, + "mcpServers": { + "posthog": { + "type": "http", + "url": "https://mcp.posthog.com/mcp", + "headers": { + "Authorization": "Bearer ${POSTHOG_API_KEY}" + } + } + }, + "permissions": { + "allow": ["mcp__posthog"] + }, + "tiers": { + "best": { + "harness": "claude", + "model": "claude-opus-4-6", + "systemPrompt": "You are a PostHog product-analytics assistant with access to the PostHog MCP server. Use the MCP tools to answer questions about events, insights, dashboards, feature flags, cohorts, and session recordings in the user's configured project. Prefer PostHog query tools over speculation; cite insight/dashboard ids when referencing specific objects. If an action would modify PostHog state (creating insights, flipping flags, deleting data), summarize the change and confirm before calling the mutating tool. Be concise and show concrete numbers.", + "harnessSettings": { "reasoning": "high", "timeoutSeconds": 900 } + }, + "best-value": { + "harness": "claude", + "model": "claude-sonnet-4-6", + "systemPrompt": "You are a PostHog product-analytics assistant with access to the PostHog MCP server. Use the MCP tools to answer questions about events, insights, dashboards, feature flags, cohorts, and session recordings in the user's configured project. Prefer PostHog query tools over speculation; cite insight/dashboard ids when referencing specific objects. If an action would modify PostHog state, summarize the change and confirm before calling the mutating tool. Be concise.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 600 } + }, + "minimum": { + "harness": "claude", + "model": "claude-haiku-4-5-20251001", + "systemPrompt": "You are a PostHog product-analytics assistant in concise mode with access to the PostHog MCP server. Use MCP tools to read events/insights/flags/cohorts. Confirm before any state mutation. Keep answers short.", + "harnessSettings": { "reasoning": "low", "timeoutSeconds": 300 } + } + } +} as const; + export const requirementsAnalyst = { "id": "requirements-analyst", "intent": "requirements-analysis", diff --git a/packages/workload-router/src/index.test.ts b/packages/workload-router/src/index.test.ts index 461b2a1..edab1ac 100644 --- a/packages/workload-router/src/index.test.ts +++ b/packages/workload-router/src/index.test.ts @@ -118,6 +118,10 @@ test('resolves review from custom routing profile rule', () => { 'capability-discovery': { tier: 'best-value', rationale: 'lightweight discovery work' + }, + posthog: { + tier: 'best-value', + rationale: 'analytics lookups via MCP' } } }); diff --git a/packages/workload-router/src/index.ts b/packages/workload-router/src/index.ts index 57e20de..64e6eda 100644 --- a/packages/workload-router/src/index.ts +++ b/packages/workload-router/src/index.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { resolve as resolvePath } from 'node:path'; import type { RunnerStepExecutor, WorkflowRunRow } from '@agent-relay/sdk/workflows'; -import { frontendImplementer, codeReviewer, architecturePlanner, requirementsAnalyst, debuggerPersona, securityReviewer, technicalWriter, verifierPersona, testStrategist, tddGuard, flakeHunter, opencodeWorkflowSpecialist, npmProvenancePublisher, cloudSandboxInfra, sageSlackEgressMigrator, sageProactiveRewirer, cloudSlackProxyGuard, agentRelayE2eConductor, capabilityDiscoverer } from './generated/personas.js'; +import { frontendImplementer, codeReviewer, architecturePlanner, requirementsAnalyst, debuggerPersona, securityReviewer, technicalWriter, verifierPersona, testStrategist, tddGuard, flakeHunter, opencodeWorkflowSpecialist, npmProvenancePublisher, cloudSandboxInfra, sageSlackEgressMigrator, sageProactiveRewirer, cloudSlackProxyGuard, agentRelayE2eConductor, capabilityDiscoverer, posthogAgent } from './generated/personas.js'; import defaultRoutingProfileJson from '../routing-profiles/default.json' with { type: 'json' }; export const HARNESS_VALUES = ['opencode', 'codex', 'claude'] as const; @@ -26,7 +26,8 @@ export const PERSONA_INTENTS = [ 'sage-proactive-rewire', 'cloud-slack-proxy-guard', 'sage-cloud-e2e-conduction', - 'capability-discovery' + 'capability-discovery', + 'posthog' ] as const; export type Harness = (typeof HARNESS_VALUES)[number]; @@ -56,12 +57,72 @@ export interface PersonaSkill { description: string; } +export const PERMISSION_MODES = [ + 'default', + 'acceptEdits', + 'bypassPermissions', + 'plan' +] as const; +export type PermissionMode = (typeof PERMISSION_MODES)[number]; + +/** + * Persona-level permission policy for the harness session. Translates to the + * harness's native allow/deny/mode flags at spawn time. Tool-pattern syntax is + * passed through verbatim — `"mcp__posthog"` to allow every posthog MCP tool, + * `"mcp__posthog__projects-get"` for a specific one, `"Bash(git *)"` for a + * shell pattern. See the target harness's docs for the exact grammar. + */ +export interface PersonaPermissions { + /** Tool names/patterns to auto-approve. */ + allow?: string[]; + /** Tool names/patterns to always block. */ + deny?: string[]; + /** Permission mode for the session. */ + mode?: PermissionMode; +} + +/** + * MCP server config, structured to match Claude Code's `--mcp-config` JSON + * verbatim so the whole object can be passed through untouched. Values inside + * `headers` / `env` may be literal strings or `$VAR` references resolved from + * `process.env` at spawn time (see resolveEnvRef). + */ +export type McpServerSpec = + | { + type: 'http' | 'sse'; + url: string; + headers?: Record; + } + | { + type: 'stdio'; + command: string; + args?: string[]; + env?: Record; + }; + export interface PersonaSpec { id: string; intent: PersonaIntent; description: string; skills: PersonaSkill[]; tiers: Record; + /** + * Environment variables injected into the harness child process. + * Values may be literal strings or `$VAR` references resolved from the + * caller's environment at spawn time. + */ + env?: Record; + /** + * MCP servers to attach to the harness session. Only wired for `claude` + * today (via `--mcp-config`); other harnesses warn and skip. + */ + mcpServers?: Record; + /** + * Permission policy (allow/deny lists, mode) for the harness session. + * Only wired for `claude` today (via `--allowedTools`, `--disallowedTools`, + * `--permission-mode`); other harnesses warn and skip. + */ + permissions?: PersonaPermissions; } export interface RoutingProfileRule { @@ -81,6 +142,9 @@ export interface PersonaSelection { runtime: PersonaRuntime; skills: PersonaSkill[]; rationale: string; + env?: Record; + mcpServers?: Record; + permissions?: PersonaPermissions; } // --------------------------------------------------------------------------- @@ -1043,7 +1107,7 @@ function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): Person throw new Error(`persona[${expectedIntent}] must be an object`); } - const { id, intent, description, tiers, skills } = value; + const { id, intent, description, tiers, skills, env, mcpServers, permissions } = value; if (typeof id !== 'string' || !id.trim()) { throw new Error(`persona[${expectedIntent}].id must be a non-empty string`); @@ -1067,16 +1131,118 @@ function parsePersonaSpec(value: unknown, expectedIntent: PersonaIntent): Person } const parsedSkills = parseSkills(skills, `persona[${expectedIntent}].skills`); + const parsedEnv = parseStringMap(env, `persona[${expectedIntent}].env`); + const parsedMcpServers = parseMcpServers(mcpServers, `persona[${expectedIntent}].mcpServers`); + const parsedPermissions = parsePermissions( + permissions, + `persona[${expectedIntent}].permissions` + ); return { id, intent, description, skills: parsedSkills, - tiers: parsedTiers + tiers: parsedTiers, + ...(parsedEnv ? { env: parsedEnv } : {}), + ...(parsedMcpServers ? { mcpServers: parsedMcpServers } : {}), + ...(parsedPermissions ? { permissions: parsedPermissions } : {}) }; } +function parsePermissions( + value: unknown, + context: string +): PersonaPermissions | undefined { + if (value === undefined) return undefined; + if (!isObject(value)) { + throw new Error(`${context} must be an object if provided`); + } + const out: PersonaPermissions = {}; + const { allow, deny, mode } = value; + if (allow !== undefined) { + if (!Array.isArray(allow) || allow.some((s) => typeof s !== 'string' || !s.trim())) { + throw new Error(`${context}.allow must be an array of non-empty strings`); + } + out.allow = allow as string[]; + } + if (deny !== undefined) { + if (!Array.isArray(deny) || deny.some((s) => typeof s !== 'string' || !s.trim())) { + throw new Error(`${context}.deny must be an array of non-empty strings`); + } + out.deny = deny as string[]; + } + if (mode !== undefined) { + if (!PERMISSION_MODES.includes(mode as PermissionMode)) { + throw new Error( + `${context}.mode must be one of: ${PERMISSION_MODES.join(', ')}` + ); + } + out.mode = mode as PermissionMode; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseStringMap( + value: unknown, + context: string +): Record | undefined { + if (value === undefined) return undefined; + if (!isObject(value)) { + throw new Error(`${context} must be an object if provided`); + } + const out: Record = {}; + for (const [key, v] of Object.entries(value)) { + if (typeof v !== 'string') { + throw new Error(`${context}.${key} must be a string`); + } + out[key] = v; + } + return out; +} + +function parseMcpServers( + value: unknown, + context: string +): Record | undefined { + if (value === undefined) return undefined; + if (!isObject(value)) { + throw new Error(`${context} must be an object if provided`); + } + const out: Record = {}; + for (const [name, raw] of Object.entries(value)) { + if (!isObject(raw)) { + throw new Error(`${context}.${name} must be an object`); + } + const type = raw.type; + if (type === 'http' || type === 'sse') { + if (typeof raw.url !== 'string' || !raw.url.trim()) { + throw new Error(`${context}.${name}.url must be a non-empty string for type=${type}`); + } + const headers = parseStringMap(raw.headers, `${context}.${name}.headers`); + out[name] = { type, url: raw.url, ...(headers ? { headers } : {}) }; + } else if (type === 'stdio') { + if (typeof raw.command !== 'string' || !raw.command.trim()) { + throw new Error(`${context}.${name}.command must be a non-empty string for type=stdio`); + } + const args = raw.args; + if (args !== undefined && (!Array.isArray(args) || args.some((a) => typeof a !== 'string'))) { + throw new Error(`${context}.${name}.args must be an array of strings`); + } + const env = parseStringMap(raw.env, `${context}.${name}.env`); + out[name] = { + type: 'stdio', + command: raw.command, + ...(args ? { args: args as string[] } : {}), + ...(env ? { env } : {}) + }; + } else { + throw new Error(`${context}.${name}.type must be one of: http, sse, stdio`); + } + } + return out; +} + function parseRoutingProfile(value: unknown, context: string): RoutingProfile { if (!isObject(value)) { throw new Error(`${context} must be an object`); @@ -1144,7 +1310,8 @@ export const personaCatalog: Record = { agentRelayE2eConductor, 'sage-cloud-e2e-conduction' ), - 'capability-discovery': parsePersonaSpec(capabilityDiscoverer, 'capability-discovery') + 'capability-discovery': parsePersonaSpec(capabilityDiscoverer, 'capability-discovery'), + posthog: parsePersonaSpec(posthogAgent, 'posthog') }; export const routingProfiles = { @@ -1163,7 +1330,10 @@ export function resolvePersona(intent: PersonaIntent, profile: RoutingProfile | tier: rule.tier, runtime: spec.tiers[rule.tier], skills: spec.skills, - rationale: `${profileSpec.id}: ${rule.rationale}` + rationale: `${profileSpec.id}: ${rule.rationale}`, + ...(spec.env ? { env: spec.env } : {}), + ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}), + ...(spec.permissions ? { permissions: spec.permissions } : {}) }; } @@ -1178,7 +1348,10 @@ export function resolvePersonaByTier(intent: PersonaIntent, tier: PersonaTier = tier, runtime: spec.tiers[tier], skills: spec.skills, - rationale: `legacy-tier-override: ${tier}` + rationale: `legacy-tier-override: ${tier}`, + ...(spec.env ? { env: spec.env } : {}), + ...(spec.mcpServers ? { mcpServers: spec.mcpServers } : {}), + ...(spec.permissions ? { permissions: spec.permissions } : {}) }; } @@ -1266,6 +1439,19 @@ export function usePersona( ? resolvePersonaByTier(intent, options.tier) : resolvePersona(intent, options.profile ?? 'default'); + return useSelection(baseSelection, { harness: options.harness }); +} + +/** + * Same as {@link usePersona}, but takes a pre-resolved {@link PersonaSelection} + * instead of an intent. Use this when you have a selection produced outside + * the standard repo catalog — for example, a user-local persona override + * loaded from disk — and want the same install/sendMessage surface. + */ +export function useSelection( + baseSelection: PersonaSelection, + options: { harness?: Harness } = {} +): PersonaContext { const effectiveHarness = options.harness ?? baseSelection.runtime.harness; const selection = effectiveHarness === baseSelection.runtime.harness diff --git a/personas/posthog.json b/personas/posthog.json new file mode 100644 index 0000000..3c790cb --- /dev/null +++ b/personas/posthog.json @@ -0,0 +1,41 @@ +{ + "id": "posthog", + "intent": "posthog", + "description": "Narrow PostHog assistant wired to the PostHog MCP server. Answers product-analytics questions, inspects events/insights/feature flags, and navigates the configured PostHog project. Carries no other skills — intended as a base for user-extended personas that supply their own API key.", + "skills": [], + "env": { + "POSTHOG_API_KEY": "$POSTHOG_API_KEY" + }, + "mcpServers": { + "posthog": { + "type": "http", + "url": "https://mcp.posthog.com/mcp", + "headers": { + "Authorization": "Bearer ${POSTHOG_API_KEY}" + } + } + }, + "permissions": { + "allow": ["mcp__posthog"] + }, + "tiers": { + "best": { + "harness": "claude", + "model": "claude-opus-4-6", + "systemPrompt": "You are a PostHog product-analytics assistant with access to the PostHog MCP server. Use the MCP tools to answer questions about events, insights, dashboards, feature flags, cohorts, and session recordings in the user's configured project. Prefer PostHog query tools over speculation; cite insight/dashboard ids when referencing specific objects. If an action would modify PostHog state (creating insights, flipping flags, deleting data), summarize the change and confirm before calling the mutating tool. Be concise and show concrete numbers.", + "harnessSettings": { "reasoning": "high", "timeoutSeconds": 900 } + }, + "best-value": { + "harness": "claude", + "model": "claude-sonnet-4-6", + "systemPrompt": "You are a PostHog product-analytics assistant with access to the PostHog MCP server. Use the MCP tools to answer questions about events, insights, dashboards, feature flags, cohorts, and session recordings in the user's configured project. Prefer PostHog query tools over speculation; cite insight/dashboard ids when referencing specific objects. If an action would modify PostHog state, summarize the change and confirm before calling the mutating tool. Be concise.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 600 } + }, + "minimum": { + "harness": "claude", + "model": "claude-haiku-4-5-20251001", + "systemPrompt": "You are a PostHog product-analytics assistant in concise mode with access to the PostHog MCP server. Use MCP tools to read events/insights/flags/cohorts. Confirm before any state mutation. Keep answers short.", + "harnessSettings": { "reasoning": "low", "timeoutSeconds": 300 } + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ffde08..d215fb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,12 @@ importers: specifier: ^5.9.2 version: 5.9.3 + packages/cli: + dependencies: + '@agentworkforce/workload-router': + specifier: workspace:* + version: link:../workload-router + packages/workload-router: dependencies: '@agent-relay/sdk': From ba72fb0bbc40eb281449e1a50be22874aa5dcaa7 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 17 Apr 2026 12:32:28 -0400 Subject: [PATCH 3/8] ci: build workspace before lint/typecheck/test The CLI package's tsc --noEmit resolves types for `@agentworkforce/workload-router` via its published `types` entry (`dist/index.d.ts`). On a fresh CI checkout nothing is built, so lint fails with "Cannot find module" and cascades into dozens of implicit-any errors downstream. Run `pnpm -r run build` before the checks so the router's .d.ts files exist when CLI lint runs. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cc128a..c68915e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,12 @@ jobs: - name: Install deps run: pnpm install --frozen-lockfile + # Build first so downstream packages can resolve `@agentworkforce/*` + # types from the built `dist/` — the CLI's typecheck/lint depend on + # the router's emitted .d.ts files. + - name: Build + run: pnpm -r run build + - name: Lint run: pnpm run lint From 5fa62b785c4f95479ff31e54772ab2d99d67f6b9 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 17 Apr 2026 12:50:40 -0400 Subject: [PATCH 4/8] address copilot pr review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - redact spawn debug line — stop printing the resolved --mcp-config payload and full system prompt to stderr; print a sanitized summary (harness, model, MCP server names, permission rule counts, mode) so Bearer tokens can no longer leak into terminal or CI logs - validate optional fields in local-personas parseOverride — skills/env/mcpServers/permissions/tiers were cast blindly, letting malformed JSON (e.g. env as an array, tiers.best as a string) reach downstream spread/merge code. Added targeted shape checks so bad files surface a clear per-file warning instead - add tests covering env/mcpServers/permissions propagation through resolvePersona and resolvePersonaByTier, plus a negative case on a persona that declares none of them - docs: packages/cli/README.md install now references @agentworkforce/cli (not the router); bearer header example uses braced ${POSTHOG_API_KEY} so it actually interpolates; fix stale resolveEnvRef reference in McpServerSpec docstring Copilot #3 (trailing comma in root README.md:88) is a false positive — the JSON block parses cleanly via python json.tool; leaving that block unchanged and replying on the PR. --- packages/cli/README.md | 7 +- packages/cli/src/cli.ts | 29 ++++-- packages/cli/src/local-personas.ts | 105 ++++++++++++++++++++- packages/workload-router/src/index.test.ts | 43 +++++++++ packages/workload-router/src/index.ts | 5 +- 5 files changed, 176 insertions(+), 13 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index bda65d3..50a42e5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14,12 +14,13 @@ agent-workforce agent [@] [task...] ## Install -The CLI ships as a `bin` in `@agentworkforce/workload-router`. From the repo +The CLI ships as a `bin` in `@agentworkforce/cli` (which depends on +`@agentworkforce/workload-router` via the pnpm workspace). From the repo checkout: ```sh corepack pnpm -r build -corepack pnpm --filter @agentworkforce/workload-router link --global +corepack pnpm --filter @agentworkforce/cli link --global ``` That puts `agent-workforce` on your PATH. @@ -69,7 +70,7 @@ MCP servers to attach*. Full library shape: "posthog": { "type": "http", "url": "https://mcp.posthog.com/mcp", - "headers": { "Authorization": "Bearer $POSTHOG_API_KEY" } + "headers": { "Authorization": "Bearer ${POSTHOG_API_KEY}" } } }, "tiers": { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 022bb02..f8486ff 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -374,12 +374,29 @@ function runInteractive(selection: PersonaSelection): Promise { selection.permissions ); const finalArgs = spec.initialPrompt ? [...spec.args, spec.initialPrompt] : [...spec.args]; - const promptNote = spec.initialPrompt ? ' ' : ''; - const mcpNote = - runtime.harness === 'claude' - ? ` [mcp-strict: ${Object.keys(resolvedMcp ?? {}).join(', ') || '(none)'}]` - : ''; - process.stderr.write(`• spawning: ${spec.bin} ${spec.args.join(' ')}${promptNote}${mcpNote}\n`); + + // Print a sanitized summary rather than raw argv: spec.args for the claude + // harness contains the resolved --mcp-config JSON and the full system + // prompt, either of which can carry secrets (Bearer tokens, API keys) once + // env refs are interpolated. We show the bin, model, and the *names* of + // the servers / permission fields so the user can verify the shape without + // leaking credentials to stderr or CI logs. + const summary: string[] = [`model=${runtime.model}`]; + if (runtime.harness === 'claude') { + const servers = Object.keys(resolvedMcp ?? {}); + summary.push(`mcp-strict=${servers.length ? servers.join(',') : '(none)'}`); + if (selection.permissions?.allow?.length) { + summary.push(`allow=${selection.permissions.allow.length} rule(s)`); + } + if (selection.permissions?.deny?.length) { + summary.push(`deny=${selection.permissions.deny.length} rule(s)`); + } + if (selection.permissions?.mode) { + summary.push(`mode=${selection.permissions.mode}`); + } + } + if (spec.initialPrompt) summary.push('initial-prompt='); + process.stderr.write(`• spawning ${spec.bin} (${summary.join(', ')})\n`); return new Promise((resolve) => { let settled = false; diff --git a/packages/cli/src/local-personas.ts b/packages/cli/src/local-personas.ts index c0ff60e..e71f703 100644 --- a/packages/cli/src/local-personas.ts +++ b/packages/cli/src/local-personas.ts @@ -99,11 +99,15 @@ function readLayerDir( return out; } +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + function parseOverride(value: unknown, context: string): LocalPersonaOverride { - if (!value || typeof value !== 'object' || Array.isArray(value)) { + if (!isPlainObject(value)) { throw new Error(`${context} must be a JSON object`); } - const raw = value as Record; + const raw = value; if (typeof raw.id !== 'string' || !raw.id.trim()) { throw new Error(`${context}.id must be a non-empty string`); } @@ -116,6 +120,15 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride { if (raw.description !== undefined && typeof raw.description !== 'string') { throw new Error(`${context}.description must be a string if provided`); } + + if (raw.skills !== undefined && !Array.isArray(raw.skills)) { + throw new Error(`${context}.skills must be an array if provided`); + } + assertStringMap(raw.env, `${context}.env`); + assertMcpServersShape(raw.mcpServers, `${context}.mcpServers`); + assertPermissionsShape(raw.permissions, `${context}.permissions`); + assertTiersShape(raw.tiers, `${context}.tiers`); + return { id: raw.id, extends: raw.extends as string | undefined, @@ -129,6 +142,94 @@ function parseOverride(value: unknown, context: string): LocalPersonaOverride { }; } +function assertStringMap(value: unknown, context: string): void { + if (value === undefined) return; + if (!isPlainObject(value)) { + throw new Error(`${context} must be an object if provided`); + } + for (const [k, v] of Object.entries(value)) { + if (typeof v !== 'string') { + throw new Error(`${context}.${k} must be a string`); + } + } +} + +function assertMcpServersShape(value: unknown, context: string): void { + if (value === undefined) return; + if (!isPlainObject(value)) { + throw new Error(`${context} must be an object if provided`); + } + for (const [name, spec] of Object.entries(value)) { + const path = `${context}.${name}`; + if (!isPlainObject(spec)) { + throw new Error(`${path} must be an object`); + } + const type = spec.type; + if (type !== 'http' && type !== 'sse' && type !== 'stdio') { + throw new Error(`${path}.type must be one of: http, sse, stdio`); + } + if (type === 'stdio') { + if (typeof spec.command !== 'string' || !spec.command.trim()) { + throw new Error(`${path}.command must be a non-empty string`); + } + if (spec.args !== undefined) { + if (!Array.isArray(spec.args) || spec.args.some((a) => typeof a !== 'string')) { + throw new Error(`${path}.args must be an array of strings`); + } + } + assertStringMap(spec.env, `${path}.env`); + } else { + if (typeof spec.url !== 'string' || !spec.url.trim()) { + throw new Error(`${path}.url must be a non-empty string`); + } + assertStringMap(spec.headers, `${path}.headers`); + } + } +} + +function assertPermissionsShape(value: unknown, context: string): void { + if (value === undefined) return; + if (!isPlainObject(value)) { + throw new Error(`${context} must be an object if provided`); + } + for (const key of ['allow', 'deny'] as const) { + const list = value[key]; + if (list === undefined) continue; + if (!Array.isArray(list) || list.some((s) => typeof s !== 'string' || !s.trim())) { + throw new Error(`${context}.${key} must be an array of non-empty strings`); + } + } + const mode = value.mode; + if (mode !== undefined && typeof mode !== 'string') { + throw new Error(`${context}.mode must be a string if provided`); + } +} + +function assertTiersShape(value: unknown, context: string): void { + if (value === undefined) return; + if (!isPlainObject(value)) { + throw new Error(`${context} must be an object if provided`); + } + for (const [tierName, runtime] of Object.entries(value)) { + const path = `${context}.${tierName}`; + if (!isPlainObject(runtime)) { + throw new Error(`${path} must be an object`); + } + if (runtime.model !== undefined && typeof runtime.model !== 'string') { + throw new Error(`${path}.model must be a string`); + } + if (runtime.harness !== undefined && typeof runtime.harness !== 'string') { + throw new Error(`${path}.harness must be a string`); + } + if (runtime.systemPrompt !== undefined && typeof runtime.systemPrompt !== 'string') { + throw new Error(`${path}.systemPrompt must be a string`); + } + if (runtime.harnessSettings !== undefined && !isPlainObject(runtime.harnessSettings)) { + throw new Error(`${path}.harnessSettings must be an object`); + } + } +} + function findInLibrary(key: string): PersonaSpec | undefined { const byIntent = (personaCatalog as Record)[key]; if (byIntent) return byIntent; diff --git a/packages/workload-router/src/index.test.ts b/packages/workload-router/src/index.test.ts index edab1ac..2b1e638 100644 --- a/packages/workload-router/src/index.test.ts +++ b/packages/workload-router/src/index.test.ts @@ -138,6 +138,49 @@ test('legacy tier override remains available via resolvePersonaByTier', () => { assert.match(result.rationale, /legacy-tier-override/); }); +test('resolvePersona propagates env, mcpServers, and permissions to the selection', () => { + // posthog is the library's canonical carrier for all three optional fields: + // env.POSTHOG_API_KEY, mcpServers.posthog (http transport with bearer + // header), and permissions.allow auto-approving posthog MCP tools. + const selection = resolvePersona('posthog'); + assert.equal(selection.personaId, 'posthog'); + + // env is exposed on the selection and still holds the literal $VAR form — + // interpolation is the runner's job, not resolvePersona's. + assert.equal(selection.env?.POSTHOG_API_KEY, '$POSTHOG_API_KEY'); + + // mcpServers carry through including headers (runner interpolates later). + const posthogServer = selection.mcpServers?.posthog; + assert.ok(posthogServer, 'expected mcpServers.posthog on the selection'); + assert.equal(posthogServer.type, 'http'); + if (posthogServer.type === 'http') { + assert.equal(posthogServer.url, 'https://mcp.posthog.com/mcp'); + assert.equal( + posthogServer.headers?.Authorization, + 'Bearer ${POSTHOG_API_KEY}' + ); + } + + // permissions.allow is carried without modification. + assert.deepEqual(selection.permissions?.allow, ['mcp__posthog']); +}); + +test('resolvePersonaByTier also propagates env / mcpServers / permissions', () => { + const selection = resolvePersonaByTier('posthog', 'minimum'); + assert.equal(selection.tier, 'minimum'); + assert.ok(selection.env, 'env should flow through tier override resolver'); + assert.ok(selection.mcpServers, 'mcpServers should flow through tier override resolver'); + assert.ok(selection.permissions, 'permissions should flow through tier override resolver'); +}); + +test('personas with no optional fields keep them undefined on the selection', () => { + // code-reviewer has no env/mcpServers/permissions in its JSON. + const selection = resolvePersona('review'); + assert.equal(selection.env, undefined); + assert.equal(selection.mcpServers, undefined); + assert.equal(selection.permissions, undefined); +}); + test('resolves testing personas from the default routing profile', () => { const testStrategy = resolvePersona('test-strategy'); assert.equal(testStrategy.personaId, 'test-strategist'); diff --git a/packages/workload-router/src/index.ts b/packages/workload-router/src/index.ts index 64e6eda..6b97493 100644 --- a/packages/workload-router/src/index.ts +++ b/packages/workload-router/src/index.ts @@ -84,8 +84,9 @@ export interface PersonaPermissions { /** * MCP server config, structured to match Claude Code's `--mcp-config` JSON * verbatim so the whole object can be passed through untouched. Values inside - * `headers` / `env` may be literal strings or `$VAR` references resolved from - * `process.env` at spawn time (see resolveEnvRef). + * `headers` / `env` / `args` / `url` / `command` may be literal strings or + * `$VAR` / `${VAR}` references. Resolution happens in the runner/CLI at spawn + * time — this package only defines the shape, not the interpolation policy. */ export type McpServerSpec = | { From 23da0bfe3ee96c18e62492c1ae9b069c130e7b80 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 17 Apr 2026 13:14:12 -0400 Subject: [PATCH 5/8] fix: trim AGENT_WORKFORCE_CONFIG_DIR before use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defaultHomeDir checked override.trim() for truthiness but returned the original untrimmed string. A value like " /some/path " would then be passed to existsSync / readdirSync as an untrimmed path — the filesystem wouldn't find it, and the home layer would silently load zero personas with no warning. Trim once at the boundary. Regression test covers the exact failing input. --- packages/cli/src/local-personas.test.ts | 23 +++++++++++++++++++++++ packages/cli/src/local-personas.ts | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/local-personas.test.ts b/packages/cli/src/local-personas.test.ts index 9b7345b..b92fb59 100644 --- a/packages/cli/src/local-personas.test.ts +++ b/packages/cli/src/local-personas.test.ts @@ -172,6 +172,29 @@ test('warns on duplicate ids within a single layer', () => { }); }); +test('AGENT_WORKFORCE_CONFIG_DIR is trimmed before use (whitespace tolerated)', () => { + withLayers(({ cwd, homeDir }) => { + writeJson(join(homeDir, 'my-posthog.json'), { + id: 'my-posthog', + extends: 'posthog' + }); + const prev = process.env.AGENT_WORKFORCE_CONFIG_DIR; + process.env.AGENT_WORKFORCE_CONFIG_DIR = ` ${homeDir} `; + try { + // Don't pass homeDir — force the loader to fall back to the env var, + // which is the code path that used to return the untrimmed value. + const loaded = loadLocalPersonas({ cwd }); + assert.ok( + loaded.byId.has('my-posthog'), + 'persona should load despite whitespace in AGENT_WORKFORCE_CONFIG_DIR' + ); + } finally { + if (prev === undefined) delete process.env.AGENT_WORKFORCE_CONFIG_DIR; + else process.env.AGENT_WORKFORCE_CONFIG_DIR = prev; + } + }); +}); + test('returns empty result when neither layer exists', () => { const loaded = loadLocalPersonas({ cwd: '/tmp/agentworkforce-nonexistent-pwd-zzz', diff --git a/packages/cli/src/local-personas.ts b/packages/cli/src/local-personas.ts index e71f703..2b9846a 100644 --- a/packages/cli/src/local-personas.ts +++ b/packages/cli/src/local-personas.ts @@ -57,8 +57,8 @@ export interface LoadOptions { } function defaultHomeDir(): string { - const override = process.env.AGENT_WORKFORCE_CONFIG_DIR; - if (override && override.trim()) return override; + const override = process.env.AGENT_WORKFORCE_CONFIG_DIR?.trim(); + if (override) return override; return join(homedir(), '.agent-workforce'); } From 8dcaa70b610b781b911e779fc566c9d24d02db96 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 17 Apr 2026 13:30:05 -0400 Subject: [PATCH 6/8] extract harness spawning primitives into @agentworkforce/harness-kit New library package containing the three things any persona spawner needs: env-ref resolution ($VAR and ${VAR}), MCP server config resolution, and per-harness argv translation. CLI now depends on this package instead of carrying its own copies. External orchestrators on top of workload-router can compose these helpers directly without reimplementing them. API split: - workload-router: models what a persona is (shape-only, harness-agnostic) - harness-kit: models how to launch it on a given harness - cli: argv parsing, local-persona cascade, process management Notable change during extraction: buildInteractiveSpec now *returns* warnings instead of writing to stderr, so library consumers route I/O themselves. CLI emits them to stderr as before. Tests: harness-kit ships with 21 tests (13 env-refs inherited from cli + 8 new covering buildInteractiveSpec's claude / codex / opencode branches and the "warnings returned, not printed" contract). --- README.md | 3 +- packages/cli/package.json | 1 + packages/cli/src/cli.ts | 203 ++---------------- packages/harness-kit/README.md | 169 +++++++++++++++ packages/harness-kit/package.json | 36 ++++ .../{cli => harness-kit}/src/env-refs.test.ts | 0 packages/{cli => harness-kit}/src/env-refs.ts | 0 packages/harness-kit/src/harness.test.ts | 132 ++++++++++++ packages/harness-kit/src/harness.ts | 125 +++++++++++ packages/harness-kit/src/index.ts | 23 ++ packages/harness-kit/src/mcp.ts | 117 ++++++++++ packages/harness-kit/tsconfig.json | 8 + pnpm-lock.yaml | 9 + 13 files changed, 636 insertions(+), 190 deletions(-) create mode 100644 packages/harness-kit/README.md create mode 100644 packages/harness-kit/package.json rename packages/{cli => harness-kit}/src/env-refs.test.ts (100%) rename packages/{cli => harness-kit}/src/env-refs.ts (100%) create mode 100644 packages/harness-kit/src/harness.test.ts create mode 100644 packages/harness-kit/src/harness.ts create mode 100644 packages/harness-kit/src/index.ts create mode 100644 packages/harness-kit/src/mcp.ts create mode 100644 packages/harness-kit/tsconfig.json diff --git a/README.md b/README.md index 2c69e64..1989fd9 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,8 @@ options, permission grammar, troubleshooting — live in ## Packages -- `packages/workload-router` — TypeScript SDK for typed persona + routing profile resolution. +- `packages/workload-router` — TypeScript SDK for typed persona + routing profile resolution (harness-agnostic). +- `packages/harness-kit` — Composable primitives for launching a persona's harness: env-ref resolution, MCP server translation, per-harness argv building. The layer the CLI sits on top of. Depend on this directly if you're building your own orchestrator on top of `@agentworkforce/workload-router` and want the same behaviors. - `packages/cli` — `agent-workforce` command-line front end: spawn a persona's harness (claude/codex/opencode) from the shell, interactively or one-shot. See **[packages/cli/README.md](./packages/cli/README.md)** for the full docs, and the [CLI](#cli) section below for a quick tour. ## Personas diff --git a/packages/cli/package.json b/packages/cli/package.json index b0f3cb9..cebaf91 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,6 +12,7 @@ "package.json" ], "dependencies": { + "@agentworkforce/harness-kit": "workspace:*", "@agentworkforce/workload-router": "workspace:*" }, "repository": { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f8486ff..b212fe9 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,19 +6,18 @@ import { PERSONA_TIERS, personaCatalog, useSelection, - type Harness, - type McpServerSpec, - type PersonaPermissions, type PersonaSelection, type PersonaSpec, type PersonaTier } from '@agentworkforce/workload-router'; -import { loadLocalPersonas } from './local-personas.js'; import { - makeLenientResolver, + buildInteractiveSpec, + formatDropWarnings, + resolveMcpServersLenient, resolveStringMapLenient, - type DroppedRef -} from './env-refs.js'; + type InteractiveSpec +} from '@agentworkforce/harness-kit'; +import { loadLocalPersonas } from './local-personas.js'; const USAGE = `Usage: agent-workforce agent [@] [task...] @@ -101,181 +100,6 @@ function buildSelection(spec: PersonaSpec, tier: PersonaTier, kind: 'repo' | 'lo }; } -function stripProviderPrefix(model: string): string { - const idx = model.indexOf('/'); - return idx >= 0 ? model.slice(idx + 1) : model; -} - -interface McpResolution { - servers: Record | undefined; - /** Entries dropped because a referenced env var was not set. */ - dropped: DroppedRef[]; - /** - * Servers dropped entirely because a structural field (`url`, `command`, - * any `arg`) couldn't be resolved — the config for those servers would be - * unusable without the missing value. - */ - droppedServers: { name: string; refs: string[] }[]; -} - -function resolveMcpServersLenient( - servers: Record | undefined, - processEnv: NodeJS.ProcessEnv -): McpResolution { - if (!servers) return { servers: undefined, dropped: [], droppedServers: [] }; - const resolve = makeLenientResolver(processEnv); - const out: Record = {}; - const dropped: DroppedRef[] = []; - const droppedServers: { name: string; refs: string[] }[] = []; - - for (const [name, spec] of Object.entries(servers)) { - const field = `mcpServers.${name}`; - const fatalRefs: string[] = []; - - const resolveFatal = (value: string, subfield: string): string | undefined => { - const r = resolve(value, subfield); - if (r.ok) return r.value; - fatalRefs.push(r.ref); - return undefined; - }; - - if (spec.type === 'stdio') { - const command = resolveFatal(spec.command, `${field}.command`); - const args = spec.args?.map((a, i) => resolveFatal(a, `${field}.args[${i}]`)); - if (!command || (args && args.some((a) => a === undefined))) { - droppedServers.push({ name, refs: fatalRefs }); - continue; - } - const envResolution = resolveStringMapLenient(spec.env, processEnv, `${field}.env`); - dropped.push(...envResolution.dropped); - out[name] = { - type: 'stdio', - command, - ...(args ? { args: args as string[] } : {}), - ...(envResolution.value ? { env: envResolution.value } : {}) - }; - } else { - const url = resolveFatal(spec.url, `${field}.url`); - if (!url) { - droppedServers.push({ name, refs: fatalRefs }); - continue; - } - const headersResolution = resolveStringMapLenient( - spec.headers, - processEnv, - `${field}.headers` - ); - dropped.push(...headersResolution.dropped); - out[name] = { - type: spec.type, - url, - ...(headersResolution.value ? { headers: headersResolution.value } : {}) - }; - } - } - - return { - servers: Object.keys(out).length > 0 ? out : undefined, - dropped, - droppedServers - }; -} - -function formatDropWarnings( - envDrops: DroppedRef[], - mcpDrops: DroppedRef[], - mcpServerDrops: { name: string; refs: string[] }[] -): string[] { - const lines: string[] = []; - for (const d of envDrops) { - lines.push(`${d.field} dropped (env var ${d.ref} is not set).`); - } - for (const d of mcpDrops) { - lines.push(`${d.field} dropped (env var ${d.ref} is not set).`); - } - for (const d of mcpServerDrops) { - lines.push( - `mcpServers.${d.name} dropped entirely (required fields referenced unset env vars: ${d.refs.join(', ')}).` - ); - } - return lines; -} - -type InteractiveSpec = { - bin: string; - args: readonly string[]; - initialPrompt: string | null; -}; - -function buildInteractiveSpec( - harness: Harness, - model: string, - systemPrompt: string, - resolvedMcp: Record | undefined, - permissions: PersonaPermissions | undefined -): InteractiveSpec { - switch (harness) { - case 'claude': { - // Always isolate MCP: pair --mcp-config with --strict-mcp-config so - // only the persona's declared servers load. Without --strict, Claude - // merges our config with ~/.claude.json and project-level MCP sources, - // pulling in whatever the user has configured elsewhere. - const mcpPayload = JSON.stringify({ mcpServers: resolvedMcp ?? {} }); - const base: string[] = [ - '--model', - model, - '--append-system-prompt', - systemPrompt, - '--mcp-config', - mcpPayload, - '--strict-mcp-config' - ]; - if (permissions?.allow && permissions.allow.length > 0) { - base.push('--allowedTools', ...permissions.allow); - } - if (permissions?.deny && permissions.deny.length > 0) { - base.push('--disallowedTools', ...permissions.deny); - } - if (permissions?.mode) { - base.push('--permission-mode', permissions.mode); - } - return { bin: 'claude', args: base, initialPrompt: null }; - } - case 'codex': - if (resolvedMcp && Object.keys(resolvedMcp).length > 0) { - process.stderr.write( - `warning: persona declares mcpServers but the codex harness is not yet wired for runtime MCP injection; proceeding without MCP.\n` - ); - } - if (permissions && (permissions.allow?.length || permissions.deny?.length || permissions.mode)) { - process.stderr.write( - `warning: persona declares permissions but the codex harness is not yet wired for runtime permission injection; proceeding with codex defaults.\n` - ); - } - return { - bin: 'codex', - args: ['-m', stripProviderPrefix(model)], - initialPrompt: systemPrompt - }; - case 'opencode': - if (resolvedMcp && Object.keys(resolvedMcp).length > 0) { - process.stderr.write( - `warning: persona declares mcpServers but the opencode harness is not yet wired for runtime MCP injection; proceeding without MCP.\n` - ); - } - if (permissions && (permissions.allow?.length || permissions.deny?.length || permissions.mode)) { - process.stderr.write( - `warning: persona declares permissions but the opencode harness is not yet wired for runtime permission injection; proceeding with opencode defaults.\n` - ); - } - return { - bin: 'opencode', - args: ['--model', stripProviderPrefix(model)], - initialPrompt: systemPrompt - }; - } -} - function emitDropWarnings(lines: string[]): void { if (lines.length === 0) return; for (const line of lines) process.stderr.write(`warning: ${line}\n`); @@ -366,13 +190,14 @@ function runInteractive(selection: PersonaSelection): Promise { runInstall(install.command, `Installing skills: ${skillIds}`); } - const spec = buildInteractiveSpec( - runtime.harness, - runtime.model, - runtime.systemPrompt, - resolvedMcp, - selection.permissions - ); + const spec = buildInteractiveSpec({ + harness: runtime.harness, + model: runtime.model, + systemPrompt: runtime.systemPrompt, + mcpServers: resolvedMcp, + permissions: selection.permissions + }); + for (const w of spec.warnings) process.stderr.write(`warning: ${w}\n`); const finalArgs = spec.initialPrompt ? [...spec.args, spec.initialPrompt] : [...spec.args]; // Print a sanitized summary rather than raw argv: spec.args for the claude diff --git a/packages/harness-kit/README.md b/packages/harness-kit/README.md new file mode 100644 index 0000000..bfbe253 --- /dev/null +++ b/packages/harness-kit/README.md @@ -0,0 +1,169 @@ +# @agentworkforce/harness-kit + +Composable primitives for spawning a persona's harness (claude, codex, +opencode) with its MCP servers, env vars, and permissions wired up correctly. + +This is the layer that `@agentworkforce/cli` sits on top of. If you're +building your own orchestrator on top of `@agentworkforce/workload-router` +and want the same behaviors the CLI provides — env-ref resolution, MCP +isolation, permission flag translation — depend on this package rather than +reimplementing them. + +> The router (`@agentworkforce/workload-router`) models **what** a persona +> is. This kit models **how to launch it** on a given harness. Both are +> harness-agnostic on their own; the per-harness knowledge lives here. + +## Install + +```sh +pnpm add @agentworkforce/harness-kit @agentworkforce/workload-router +``` + +## What's in the box + +### `buildInteractiveSpec(input)` — translate a persona to a spawnable argv + +Takes the fields off a `PersonaSelection` (harness, model, systemPrompt, +mcpServers, permissions) and returns `{bin, args, initialPrompt, warnings}`. +Pure — no I/O, no stderr writes. Warnings are returned so your caller routes +them wherever makes sense. + +```ts +import { resolvePersona } from '@agentworkforce/workload-router'; +import { + buildInteractiveSpec, + resolveMcpServersLenient, + resolveStringMapLenient, + formatDropWarnings +} from '@agentworkforce/harness-kit'; +import { spawn } from 'node:child_process'; + +const selection = resolvePersona('posthog'); + +// Resolve env + MCP refs against the current process environment. Missing +// refs don't throw — they come back on `.dropped` so you can warn the user. +const envResolution = resolveStringMapLenient(selection.env, process.env, 'env'); +const mcpResolution = resolveMcpServersLenient(selection.mcpServers, process.env); + +const warnings = formatDropWarnings( + envResolution.dropped, + mcpResolution.dropped, + mcpResolution.droppedServers +); +for (const w of warnings) console.warn(w); + +// Build the exec spec. +const spec = buildInteractiveSpec({ + harness: selection.runtime.harness, + model: selection.runtime.model, + systemPrompt: selection.runtime.systemPrompt, + mcpServers: mcpResolution.servers, + permissions: selection.permissions +}); +for (const w of spec.warnings) console.warn(w); + +// Spawn the harness. +const args = spec.initialPrompt ? [...spec.args, spec.initialPrompt] : [...spec.args]; +spawn(spec.bin, args, { + stdio: 'inherit', + env: { ...process.env, ...(envResolution.value ?? {}) } +}); +``` + +### Claude harness guarantees + +When `harness === 'claude'`, `buildInteractiveSpec` **always** emits both: + +- `--mcp-config '{"mcpServers": …}'` — even if empty +- `--strict-mcp-config` — forces Claude Code to ignore user/project MCP sources + +This means a persona session only sees MCP servers the persona itself +declares. Your `~/.claude.json` and any project `.claude/` MCP config are +invisible inside the session. That's the whole point of persona isolation; +if you want a personal MCP in the session, declare it on the persona. + +### Codex / opencode + +Current state: these harnesses don't expose runtime MCP injection or +permission controls on their CLIs. `buildInteractiveSpec` carries the +system prompt as the initial positional `[PROMPT]` argument (since +neither has a `--system-prompt` flag) and returns a warning string if the +persona declares `mcpServers` or `permissions`. The caller decides whether +to print, fail, or continue. + +## Env reference resolution + +The kit supports two forms of env references inside persona JSON: + +| Form | Semantics | +| ---- | --------- | +| `"$VAR"` | Whole-string reference. The entire value is replaced. | +| `"Bearer ${VAR}"` | Braced; each `${VAR}` is interpolated in place. | + +Unbraced `$VAR` *mid-string* is kept as a literal — this prevents a stray +`$` in a JSON value from accidentally being treated as a reference, and it +keeps missing-var errors pointed at a specific field name. + +### Two resolution policies + +Pick the one that matches your error-handling preference: + +| Function | Missing ref → | Use when | +| -------- | ------------- | -------- | +| `makeEnvRefResolver(env)` / `resolveStringMap(map, env, prefix)` | throws `MissingEnvRefError` | You want fail-fast — e.g. CI scripts where a missing secret is a configuration bug. | +| `makeLenientResolver(env)` / `resolveStringMapLenient(map, env, prefix)` | returns `{ok:false, field, ref}` (or drops the entry on the `Lenient` map helper) | You want graceful fallback — e.g. letting an MCP server authenticate via OAuth if the Bearer token isn't set. | + +The CLI uses the lenient path; it drops missing env entries and unset MCP +headers with a warning, and only aborts if a *structural* field (`url`, +`command`, any `arg`) can't be resolved. + +## API surface + +```ts +// Env refs +export class MissingEnvRefError extends Error { ref: string; referencedBy: string } +export function makeEnvRefResolver(env): (value, field) => string +export function makeLenientResolver(env): (value, field) => LenientResult +export function resolveStringMap(map, env, prefix): Record | undefined +export function resolveStringMapLenient(map, env, prefix): { value, dropped: DroppedRef[] } +export type LenientResult = { ok: true; value: string } | { ok: false; field: string; ref: string } +export interface DroppedRef { field: string; ref: string } + +// MCP +export function resolveMcpServersLenient(servers, env): McpResolution +export function formatDropWarnings(envDrops, mcpDrops, mcpServerDrops): string[] +export interface McpResolution { + servers: Record | undefined; + dropped: DroppedRef[]; + droppedServers: DroppedMcpServer[]; +} +export interface DroppedMcpServer { name: string; refs: string[] } + +// Harness +export function buildInteractiveSpec(input: BuildInteractiveSpecInput): InteractiveSpec +export interface BuildInteractiveSpecInput { + harness: Harness; + model: string; + systemPrompt: string; + mcpServers?: Record; + permissions?: PersonaPermissions; +} +export interface InteractiveSpec { + bin: string; + args: readonly string[]; + initialPrompt: string | null; + warnings: string[]; +} +``` + +## Status + +Small, stable surface focused on the three things a harness spawner needs: +resolve env refs, resolve MCP config, build argv. It does **not** spawn +processes, manage stdio, install skills, or talk to the filesystem — those +live in the CLI because different consumers will want different behaviors +there (CI pipelines, IDE plugins, test harnesses, etc.). + +A future `useSelection().run()` convenience in the router may wrap these +helpers into a one-liner — if that happens, it'll be built on top of this +package, not a replacement for it. diff --git a/packages/harness-kit/package.json b/packages/harness-kit/package.json new file mode 100644 index 0000000..c61ad1f --- /dev/null +++ b/packages/harness-kit/package.json @@ -0,0 +1,36 @@ +{ + "name": "@agentworkforce/harness-kit", + "version": "0.1.0", + "private": false, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "dependencies": { + "@agentworkforce/workload-router": "workspace:*" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/workforce", + "directory": "packages/harness-kit" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsc -p tsconfig.json && node --test dist/*.test.js", + "lint": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/cli/src/env-refs.test.ts b/packages/harness-kit/src/env-refs.test.ts similarity index 100% rename from packages/cli/src/env-refs.test.ts rename to packages/harness-kit/src/env-refs.test.ts diff --git a/packages/cli/src/env-refs.ts b/packages/harness-kit/src/env-refs.ts similarity index 100% rename from packages/cli/src/env-refs.ts rename to packages/harness-kit/src/env-refs.ts diff --git a/packages/harness-kit/src/harness.test.ts b/packages/harness-kit/src/harness.test.ts new file mode 100644 index 0000000..0e02b91 --- /dev/null +++ b/packages/harness-kit/src/harness.test.ts @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildInteractiveSpec } from './harness.js'; + +test('claude branch always emits --mcp-config + --strict-mcp-config', () => { + const result = buildInteractiveSpec({ + harness: 'claude', + model: 'claude-opus-4-6', + systemPrompt: 'you are a test' + }); + assert.equal(result.bin, 'claude'); + assert.equal(result.initialPrompt, null); + assert.deepEqual(result.warnings, []); + // Both flags present even with no mcpServers — forces isolation from + // user/project Claude Code config. + const args = result.args; + const mcpIdx = args.indexOf('--mcp-config'); + assert.ok(mcpIdx >= 0, 'expected --mcp-config'); + assert.equal(args[mcpIdx + 1], '{"mcpServers":{}}'); + assert.ok(args.includes('--strict-mcp-config')); + assert.ok(args.includes('--model')); + assert.ok(args.includes('--append-system-prompt')); +}); + +test('claude branch serializes resolved mcpServers into the --mcp-config payload', () => { + const result = buildInteractiveSpec({ + harness: 'claude', + model: 'claude-sonnet-4-6', + systemPrompt: 'x', + mcpServers: { + posthog: { + type: 'http', + url: 'https://mcp.posthog.com/mcp', + headers: { Authorization: 'Bearer phx_real' } + } + } + }); + const mcpIdx = result.args.indexOf('--mcp-config'); + const payload = JSON.parse(result.args[mcpIdx + 1]); + assert.deepEqual(payload, { + mcpServers: { + posthog: { + type: 'http', + url: 'https://mcp.posthog.com/mcp', + headers: { Authorization: 'Bearer phx_real' } + } + } + }); +}); + +test('claude branch translates permissions to flags', () => { + const result = buildInteractiveSpec({ + harness: 'claude', + model: 'claude-opus-4-6', + systemPrompt: 'x', + permissions: { + allow: ['mcp__posthog', 'Bash(git *)'], + deny: ['Bash(rm -rf *)'], + mode: 'acceptEdits' + } + }); + const args = result.args; + const allowIdx = args.indexOf('--allowedTools'); + const denyIdx = args.indexOf('--disallowedTools'); + const modeIdx = args.indexOf('--permission-mode'); + assert.ok(allowIdx >= 0 && denyIdx >= 0 && modeIdx >= 0); + assert.equal(args[allowIdx + 1], 'mcp__posthog'); + assert.equal(args[allowIdx + 2], 'Bash(git *)'); + assert.equal(args[denyIdx + 1], 'Bash(rm -rf *)'); + assert.equal(args[modeIdx + 1], 'acceptEdits'); +}); + +test('claude branch omits permission flags when unset or empty', () => { + const result = buildInteractiveSpec({ + harness: 'claude', + model: 'claude-opus-4-6', + systemPrompt: 'x', + permissions: { allow: [], deny: [] } + }); + assert.ok(!result.args.includes('--allowedTools')); + assert.ok(!result.args.includes('--disallowedTools')); + assert.ok(!result.args.includes('--permission-mode')); +}); + +test('codex carries system prompt as initial positional; strips provider prefix from model', () => { + const result = buildInteractiveSpec({ + harness: 'codex', + model: 'openai-codex/gpt-5.3-codex', + systemPrompt: 'system-directive' + }); + assert.equal(result.bin, 'codex'); + assert.deepEqual(result.args, ['-m', 'gpt-5.3-codex']); + assert.equal(result.initialPrompt, 'system-directive'); +}); + +test('codex warns when mcpServers / permissions are declared', () => { + const result = buildInteractiveSpec({ + harness: 'codex', + model: 'openai-codex/gpt-5.3-codex', + systemPrompt: 'x', + mcpServers: { foo: { type: 'http', url: 'https://example.com' } }, + permissions: { allow: ['mcp__foo'] } + }); + assert.equal(result.warnings.length, 2); + assert.match(result.warnings[0], /codex harness is not yet wired for runtime MCP/); + assert.match(result.warnings[1], /codex harness is not yet wired for runtime permission/); +}); + +test('opencode carries system prompt and strips provider prefix', () => { + const result = buildInteractiveSpec({ + harness: 'opencode', + model: 'opencode/minimax-m2.5', + systemPrompt: 'x' + }); + assert.equal(result.bin, 'opencode'); + assert.deepEqual(result.args, ['--model', 'minimax-m2.5']); + assert.equal(result.initialPrompt, 'x'); +}); + +test('warnings are returned, not printed — library consumers route I/O themselves', () => { + // Ensure no side effects on stderr: if the function wrote to stderr, this + // test would leak output into the test runner. We just assert the shape. + const result = buildInteractiveSpec({ + harness: 'codex', + model: 'x', + systemPrompt: 'x', + mcpServers: { a: { type: 'http', url: 'https://x' } } + }); + assert.ok(Array.isArray(result.warnings)); + assert.equal(result.warnings.length, 1); +}); diff --git a/packages/harness-kit/src/harness.ts b/packages/harness-kit/src/harness.ts new file mode 100644 index 0000000..76bb789 --- /dev/null +++ b/packages/harness-kit/src/harness.ts @@ -0,0 +1,125 @@ +import type { + Harness, + McpServerSpec, + PersonaPermissions +} from '@agentworkforce/workload-router'; + +/** Result of translating a persona's runtime into a spawnable command. */ +export interface InteractiveSpec { + /** Binary to exec (e.g. `claude`, `codex`, `opencode`). */ + bin: string; + /** Argv for the binary, in order. Callers should `spawn(bin, args)`. */ + args: readonly string[]; + /** + * If set, the caller should append this as the final positional argument + * — used by harnesses that don't support a separate system-prompt flag + * (codex, opencode) to carry the persona's system prompt as the initial + * user prompt. + */ + initialPrompt: string | null; + /** + * Non-fatal warnings produced during translation — e.g. "codex doesn't + * support MCP yet, ignoring". Callers decide whether to print them. + */ + warnings: string[]; +} + +export interface BuildInteractiveSpecInput { + harness: Harness; + model: string; + systemPrompt: string; + /** Env-resolved MCP servers (pass the output of `resolveMcpServersLenient().servers`). */ + mcpServers?: Record; + permissions?: PersonaPermissions; +} + +function stripProviderPrefix(model: string): string { + const idx = model.indexOf('/'); + return idx >= 0 ? model.slice(idx + 1) : model; +} + +function hasAnyPermission(p: PersonaPermissions | undefined): boolean { + if (!p) return false; + return Boolean(p.allow?.length || p.deny?.length || p.mode); +} + +/** + * Translate a persona's runtime fields into a concrete `{bin, args}` for + * spawning an interactive harness session. Pure — no I/O, no side effects. + * + * The claude branch always emits `--mcp-config` + `--strict-mcp-config` + * (with an empty `mcpServers: {}` payload if the persona declares none), + * so the spawned session only sees the persona's declared MCP servers, + * never the user's or project's Claude Code config. + * + * codex / opencode branches carry the system prompt as the initial + * positional `[PROMPT]` because neither CLI supports a separate + * system-prompt flag today. They emit a warning if the persona declares + * mcpServers or permissions — those features aren't wired for those + * harnesses yet. + */ +export function buildInteractiveSpec(input: BuildInteractiveSpecInput): InteractiveSpec { + const { harness, model, systemPrompt, mcpServers, permissions } = input; + const warnings: string[] = []; + + switch (harness) { + case 'claude': { + const mcpPayload = JSON.stringify({ mcpServers: mcpServers ?? {} }); + const args: string[] = [ + '--model', + model, + '--append-system-prompt', + systemPrompt, + '--mcp-config', + mcpPayload, + '--strict-mcp-config' + ]; + if (permissions?.allow && permissions.allow.length > 0) { + args.push('--allowedTools', ...permissions.allow); + } + if (permissions?.deny && permissions.deny.length > 0) { + args.push('--disallowedTools', ...permissions.deny); + } + if (permissions?.mode) { + args.push('--permission-mode', permissions.mode); + } + return { bin: 'claude', args, initialPrompt: null, warnings }; + } + case 'codex': { + if (mcpServers && Object.keys(mcpServers).length > 0) { + warnings.push( + 'persona declares mcpServers but the codex harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ); + } + if (hasAnyPermission(permissions)) { + warnings.push( + 'persona declares permissions but the codex harness is not yet wired for runtime permission injection; proceeding with codex defaults.' + ); + } + return { + bin: 'codex', + args: ['-m', stripProviderPrefix(model)], + initialPrompt: systemPrompt, + warnings + }; + } + case 'opencode': { + if (mcpServers && Object.keys(mcpServers).length > 0) { + warnings.push( + 'persona declares mcpServers but the opencode harness is not yet wired for runtime MCP injection; proceeding without MCP.' + ); + } + if (hasAnyPermission(permissions)) { + warnings.push( + 'persona declares permissions but the opencode harness is not yet wired for runtime permission injection; proceeding with opencode defaults.' + ); + } + return { + bin: 'opencode', + args: ['--model', stripProviderPrefix(model)], + initialPrompt: systemPrompt, + warnings + }; + } + } +} diff --git a/packages/harness-kit/src/index.ts b/packages/harness-kit/src/index.ts new file mode 100644 index 0000000..5dff4bf --- /dev/null +++ b/packages/harness-kit/src/index.ts @@ -0,0 +1,23 @@ +export { + MissingEnvRefError, + makeEnvRefResolver, + makeLenientResolver, + resolveStringMap, + resolveStringMapLenient, + type DroppedRef, + type EnvRefResolver, + type LenientResult +} from './env-refs.js'; + +export { + formatDropWarnings, + resolveMcpServersLenient, + type DroppedMcpServer, + type McpResolution +} from './mcp.js'; + +export { + buildInteractiveSpec, + type BuildInteractiveSpecInput, + type InteractiveSpec +} from './harness.js'; diff --git a/packages/harness-kit/src/mcp.ts b/packages/harness-kit/src/mcp.ts new file mode 100644 index 0000000..cdea47f --- /dev/null +++ b/packages/harness-kit/src/mcp.ts @@ -0,0 +1,117 @@ +import type { McpServerSpec } from '@agentworkforce/workload-router'; +import { type DroppedRef, makeLenientResolver, resolveStringMapLenient } from './env-refs.js'; + +export interface DroppedMcpServer { + name: string; + /** Env var names that couldn't be resolved on the server's structural fields. */ + refs: string[]; +} + +export interface McpResolution { + /** Servers that resolved cleanly (or with only droppable-field losses). */ + servers: Record | undefined; + /** Non-fatal drops — specific headers / env / args entries whose ref was unset. */ + dropped: DroppedRef[]; + /** Whole servers dropped because a structural field (url, command, any arg) had an unset ref. */ + droppedServers: DroppedMcpServer[]; +} + +/** + * Resolve env-var references inside an `mcpServers` block, policy: lenient. + * + * - Headers / env / args entries with an unresolved ref are **dropped** from + * the result; they surface in `dropped`. + * - A server whose `url`, `command`, or any `arg` references an unresolved + * ref is dropped **entirely**; it surfaces in `droppedServers`. Structural + * fields can't be silently skipped — the server wouldn't launch without + * them. + * + * Literal strings pass through untouched. + */ +export function resolveMcpServersLenient( + servers: Record | undefined, + processEnv: NodeJS.ProcessEnv +): McpResolution { + if (!servers) return { servers: undefined, dropped: [], droppedServers: [] }; + const resolve = makeLenientResolver(processEnv); + const out: Record = {}; + const dropped: DroppedRef[] = []; + const droppedServers: DroppedMcpServer[] = []; + + for (const [name, spec] of Object.entries(servers)) { + const field = `mcpServers.${name}`; + const fatalRefs: string[] = []; + + const resolveFatal = (value: string, subfield: string): string | undefined => { + const r = resolve(value, subfield); + if (r.ok) return r.value; + fatalRefs.push(r.ref); + return undefined; + }; + + if (spec.type === 'stdio') { + const command = resolveFatal(spec.command, `${field}.command`); + const args = spec.args?.map((a, i) => resolveFatal(a, `${field}.args[${i}]`)); + if (!command || (args && args.some((a) => a === undefined))) { + droppedServers.push({ name, refs: fatalRefs }); + continue; + } + const envResolution = resolveStringMapLenient(spec.env, processEnv, `${field}.env`); + dropped.push(...envResolution.dropped); + out[name] = { + type: 'stdio', + command, + ...(args ? { args: args as string[] } : {}), + ...(envResolution.value ? { env: envResolution.value } : {}) + }; + } else { + const url = resolveFatal(spec.url, `${field}.url`); + if (!url) { + droppedServers.push({ name, refs: fatalRefs }); + continue; + } + const headersResolution = resolveStringMapLenient( + spec.headers, + processEnv, + `${field}.headers` + ); + dropped.push(...headersResolution.dropped); + out[name] = { + type: spec.type, + url, + ...(headersResolution.value ? { headers: headersResolution.value } : {}) + }; + } + } + + return { + servers: Object.keys(out).length > 0 ? out : undefined, + dropped, + droppedServers + }; +} + +/** + * Format the dropped-ref tracking from env + MCP resolution into flat, + * human-readable lines. Callers print them wherever they want — stderr, + * a logger, a UI toast — the helper itself has no I/O. + */ +export function formatDropWarnings( + envDrops: DroppedRef[], + mcpDrops: DroppedRef[], + mcpServerDrops: DroppedMcpServer[] +): string[] { + const lines: string[] = []; + for (const d of envDrops) { + lines.push(`${d.field} dropped (env var ${d.ref} is not set).`); + } + for (const d of mcpDrops) { + lines.push(`${d.field} dropped (env var ${d.ref} is not set).`); + } + for (const d of mcpServerDrops) { + lines.push( + `mcpServers.${d.name} dropped entirely (required fields referenced unset env vars: ${d.refs.join(', ')}).` + ); + } + return lines; +} diff --git a/packages/harness-kit/tsconfig.json b/packages/harness-kit/tsconfig.json new file mode 100644 index 0000000..df59da5 --- /dev/null +++ b/packages/harness-kit/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d215fb7..1822095 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,15 @@ importers: version: 5.9.3 packages/cli: + dependencies: + '@agentworkforce/harness-kit': + specifier: workspace:* + version: link:../harness-kit + '@agentworkforce/workload-router': + specifier: workspace:* + version: link:../workload-router + + packages/harness-kit: dependencies: '@agentworkforce/workload-router': specifier: workspace:* From b37d00d93123bf71621c4609ea34c9b09b47dbae Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 17 Apr 2026 16:16:35 -0400 Subject: [PATCH 7/8] ci: selector-based multi-package publish + verify workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish.yml rewritten from router-only to workspace-wide: - package input (all | workload-router | harness-kit | cli) - version input adds pre* bumps and a `none` option for first-time or re-publish scenarios, plus optional custom_version and prerelease_id - uses `pnpm publish` to rewrite workspace:* → concrete versions at pack time (the CLI depends on harness-kit via workspace:*, so the previous `npm publish` path would have shipped a broken dep spec) - when package=all, publishes in dependency order: router → harness-kit → cli - commits all version bumps in a single chore(release): line and tags each package individually (`-v`) verify-publish.yml is new: a follow-up workflow that installs the just- published artifact from the registry and smoke tests it. - CLI: installs globally, asserts `agent-workforce --help` exits 0 with the usage block, bare invocation exits non-zero, unknown persona exits non-zero. - Libraries: installs into a clean tmp project, `import()`s the package, asserts the exports surface is non-empty. Run sequence: trigger Publish Package → trigger Verify Publish with the same package + version. --- .github/workflows/publish.yml | 170 +++++++++++++++++++++------ .github/workflows/verify-publish.yml | 110 +++++++++++++++++ 2 files changed, 245 insertions(+), 35 deletions(-) create mode 100644 .github/workflows/verify-publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2355c5e..71cd6ad 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,8 +3,18 @@ name: Publish Package on: workflow_dispatch: inputs: + package: + description: 'Which package(s) to publish' + required: true + default: cli + type: choice + options: + - all + - workload-router + - harness-kit + - cli version: - description: 'Version bump type' + description: 'Version bump type (ignored if custom_version is set)' required: true default: patch type: choice @@ -12,7 +22,18 @@ on: - patch - minor - major + - prepatch + - preminor + - premajor - prerelease + - none + custom_version: + description: 'Exact version (e.g. 0.1.0). Overrides version type when set.' + required: false + prerelease_id: + description: 'Prerelease identifier for pre* bumps (e.g. "next", "beta")' + required: false + default: next tag: description: 'npm dist-tag' required: true @@ -22,8 +43,9 @@ on: - latest - next - beta + - alpha dry_run: - description: 'Dry run only (no publish)' + description: 'Dry run (no actual publish, no version commit, no git tag)' required: true default: false type: boolean @@ -32,70 +54,148 @@ permissions: contents: write id-token: write +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false + jobs: - publish-workload-router: + publish: runs-on: ubuntu-latest - steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node uses: actions/setup-node@v4 with: node-version: '22.14.0' registry-url: 'https://registry.npmjs.org' - - - name: Setup pnpm - uses: pnpm/action-setup@v4 + cache: 'pnpm' - name: Install deps run: pnpm install --frozen-lockfile - - name: Build package - run: pnpm --filter @agentworkforce/workload-router build + # Build + test everything regardless of which package is being released — + # the CLI depends on harness-kit which depends on workload-router via + # workspace:*. `pnpm publish` rewrites those specs to concrete versions + # at pack time, and we want every package's dist/ to be fresh when that + # happens. + - name: Build workspace + run: pnpm -r run build - - name: Test package - run: pnpm --filter @agentworkforce/workload-router test + - name: Run tests + run: pnpm -r run test - - name: Bump version + - name: Resolve target packages (dep order) + id: targets + run: | + case "${{ github.event.inputs.package }}" in + all) + # Must be in dependency order: router → harness-kit → cli. + echo "packages=workload-router harness-kit cli" >> "$GITHUB_OUTPUT" + ;; + workload-router|harness-kit|cli) + echo "packages=${{ github.event.inputs.package }}" >> "$GITHUB_OUTPUT" + ;; + *) + echo "Unknown package: ${{ github.event.inputs.package }}" >&2 + exit 1 + ;; + esac + + - name: Bump versions id: bump - working-directory: packages/workload-router run: | - npm version ${{ github.event.inputs.version }} --no-git-tag-version - NEW_VERSION=$(node -p "require('./package.json').version") - echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - - - name: Commit version bump + VERSIONS="" + CUSTOM='${{ github.event.inputs.custom_version }}' + BUMP='${{ github.event.inputs.version }}' + PREID='${{ github.event.inputs.prerelease_id }}' + for pkg in ${{ steps.targets.outputs.packages }}; do + pushd "packages/$pkg" > /dev/null + if [ -n "$CUSTOM" ]; then + npm version "$CUSTOM" --no-git-tag-version --allow-same-version + elif [ "$BUMP" = "none" ]; then + : # keep existing version (useful for first publish or re-publish) + elif [[ "$BUMP" == pre* ]]; then + npm version "$BUMP" --no-git-tag-version --preid="$PREID" + else + npm version "$BUMP" --no-git-tag-version + fi + NEW=$(node -p "require('./package.json').version") + VERSIONS+=" $pkg:$NEW" + popd > /dev/null + done + echo "versions=${VERSIONS# }" >> "$GITHUB_OUTPUT" + + - name: Commit version bumps + if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add packages/workload-router/package.json - git commit -m "chore(release): @agentworkforce/workload-router v${{ steps.bump.outputs.version }}" + git add packages/*/package.json + if git diff --cached --quiet; then + echo "No version changes to commit." + else + MSG="chore(release):" + for entry in ${{ steps.bump.outputs.versions }}; do + pkg="${entry%%:*}" + version="${entry##*:}" + MSG+=" @agentworkforce/$pkg@$version" + done + git commit -m "$MSG" + fi - name: Install latest npm run: npm install -g npm@latest - - name: Publish (dry run) - if: ${{ github.event.inputs.dry_run == 'true' }} - working-directory: packages/workload-router - run: npm publish --dry-run --access public --tag ${{ github.event.inputs.tag }} - - - name: Publish to npm - if: ${{ github.event.inputs.dry_run != 'true' }} - working-directory: packages/workload-router - run: npm publish --provenance --access public --tag ${{ github.event.inputs.tag }} + # pnpm publish rewrites workspace:* to the concrete currently-published + # version, so harness-kit ships depending on @agentworkforce/workload-router@^x.y.z + # rather than a literal "workspace:*" string that would fail on install. + - name: Publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + FLAGS="--access public --tag ${{ github.event.inputs.tag }} --no-git-checks" + if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then + FLAGS+=" --dry-run" + else + FLAGS+=" --provenance" + fi + for pkg in ${{ steps.targets.outputs.packages }}; do + echo "==> Publishing @agentworkforce/$pkg $FLAGS" + pnpm --filter "@agentworkforce/$pkg" publish $FLAGS + done - name: Tag + push - if: ${{ github.event.inputs.dry_run != 'true' }} + if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} run: | - git tag workload-router-v${{ steps.bump.outputs.version }} - git push origin main --follow-tags + for entry in ${{ steps.bump.outputs.versions }}; do + pkg="${entry%%:*}" + version="${entry##*:}" + git tag "$pkg-v$version" + done + git push origin HEAD --follow-tags - name: Summary run: | - echo "Published: @agentworkforce/workload-router@${{ steps.bump.outputs.version }}" >> $GITHUB_STEP_SUMMARY - echo "Tag: ${{ github.event.inputs.tag }}" >> $GITHUB_STEP_SUMMARY - echo "Dry run: ${{ github.event.inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + { + echo "### Published" + echo "" + for entry in ${{ steps.bump.outputs.versions }}; do + pkg="${entry%%:*}" + version="${entry##*:}" + echo "- \`@agentworkforce/$pkg@$version\`" + done + echo "" + echo "- **dist-tag**: \`${{ github.event.inputs.tag }}\`" + echo "- **dry run**: \`${{ github.event.inputs.dry_run }}\`" + echo "" + if [ "${{ github.event.inputs.dry_run }}" != "true" ]; then + echo "Next step: verify the published artifact by running the \`Verify Publish\` workflow." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml new file mode 100644 index 0000000..c966145 --- /dev/null +++ b/.github/workflows/verify-publish.yml @@ -0,0 +1,110 @@ +name: Verify Publish + +# Manually triggered smoke test against the npm registry — run after a Publish +# Package workflow completes. Installs the just-published package into a clean +# environment and exercises it enough to catch "published but broken" failures +# (bin missing +x, workspace:* deps not rewritten, missing exports, etc.). + +on: + workflow_dispatch: + inputs: + package: + description: 'Package to verify' + required: true + default: '@agentworkforce/cli' + type: choice + options: + - '@agentworkforce/cli' + - '@agentworkforce/harness-kit' + - '@agentworkforce/workload-router' + version: + description: 'Version to verify (defaults to the "latest" dist-tag)' + required: false + default: latest + tag: + description: 'Alternate dist-tag to pull from if `version` is left as default' + required: false + default: latest + +permissions: + contents: read + +jobs: + verify: + name: Install + smoke test + runs-on: ubuntu-latest + steps: + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22.14.0' + registry-url: 'https://registry.npmjs.org' + + - name: Resolve version + id: resolve + run: | + INPUT='${{ github.event.inputs.version }}' + TAG='${{ github.event.inputs.tag }}' + if [ -z "$INPUT" ] || [ "$INPUT" = "latest" ]; then + RESOLVED=$(npm view "${{ github.event.inputs.package }}@$TAG" version) + else + RESOLVED="$INPUT" + fi + echo "version=$RESOLVED" >> "$GITHUB_OUTPUT" + echo "Verifying ${{ github.event.inputs.package }}@$RESOLVED" + + - name: CLI smoke test + if: ${{ github.event.inputs.package == '@agentworkforce/cli' }} + run: | + set -euo pipefail + npm install -g "${{ github.event.inputs.package }}@${{ steps.resolve.outputs.version }}" + # --help should exit 0 and print the usage block. + output=$(agent-workforce --help) + echo "$output" + echo "$output" | grep -q "Usage: agent-workforce agent" || { + echo "::error::--help output did not include expected usage block" >&2 + exit 1 + } + # Bare invocation should exit non-zero with the usage text. + if agent-workforce > /dev/null 2>&1; then + echo "::error::bare agent-workforce should exit non-zero (missing subcommand)" >&2 + exit 1 + fi + # Unknown persona should exit non-zero and list the catalog. + if agent-workforce agent __definitely_not_a_persona__ > /dev/null 2>&1; then + echo "::error::unknown persona should exit non-zero" >&2 + exit 1 + fi + echo "CLI smoke test passed." + + - name: Library smoke test (ESM import + exports surface) + if: ${{ github.event.inputs.package != '@agentworkforce/cli' }} + run: | + set -euo pipefail + WORKDIR=$(mktemp -d) + cd "$WORKDIR" + npm init -y > /dev/null + node -e "require('fs').writeFileSync('package.json', JSON.stringify({...require('./package.json'), type:'module'}, null, 2))" + npm install "${{ github.event.inputs.package }}@${{ steps.resolve.outputs.version }}" + cat > check.mjs <<'EOF' + const mod = await import(process.argv[2]); + const exports = Object.keys(mod).sort(); + if (exports.length === 0) { + console.error('no exports from package'); + process.exit(1); + } + console.log('exports:', exports.join(', ')); + EOF + node check.mjs "${{ github.event.inputs.package }}" + echo "Library smoke test passed." + + - name: Summary + if: always() + run: | + { + echo "### Verify Publish" + echo "" + echo "- **package**: \`${{ github.event.inputs.package }}\`" + echo "- **version**: \`${{ steps.resolve.outputs.version }}\`" + echo "- **result**: ${{ job.status }}" + } >> "$GITHUB_STEP_SUMMARY" From 3d9427c99ba0e2a44772aa582b92d876043fff11 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Fri, 17 Apr 2026 16:34:30 -0400 Subject: [PATCH 8/8] ci: publish via OIDC trusted publisher, drop NPM_TOKEN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm's OIDC trusted-publisher flow exchanges the GitHub workflow's OIDC identity (the `id-token: write` permission) for a short-lived publish token — no long-lived NPM_TOKEN secret required. Matches the agent-relay publish pattern. Flow: `pnpm pack --pack-destination $PACK_DIR` rewrites workspace:* deps to concrete versions inside the tarball's package.json, then `npm publish --provenance` uploads via npm's native auth. This decouples workspace-aware packing from publish-time auth (pnpm's publish would re-implement auth rather than delegate to npm). First-publish requirement: each of @agentworkforce/{workload-router, harness-kit,cli} needs to be registered as a trusted publisher on npmjs.com pointing at AgentWorkforce/workforce + this workflow path. --- .github/workflows/publish.yml | 42 ++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 71cd6ad..85f2540 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -150,25 +150,45 @@ jobs: git commit -m "$MSG" fi + # npm >= 11.5.1 is required for the OIDC trusted-publisher flow. - name: Install latest npm run: npm install -g npm@latest - # pnpm publish rewrites workspace:* to the concrete currently-published - # version, so harness-kit ships depending on @agentworkforce/workload-router@^x.y.z - # rather than a literal "workspace:*" string that would fail on install. - - name: Publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # Authentication note: this workflow does NOT use an NPM_TOKEN. It relies + # on npm's OIDC trusted-publisher flow — the `id-token: write` permission + # above lets `npm publish --provenance` exchange the GitHub workflow's + # OIDC identity for a short-lived publish token. Each package must be + # registered as a trusted publisher on npmjs.com under this + # repo/workflow path for the first publish. + # + # Pipeline: `pnpm pack` rewrites workspace:* deps to concrete versions + # inside the tarball's package.json, then `npm publish ` + # uploads it using npm's native auth. This decouples workspace-aware + # packing from publish-time auth and matches the agent-relay pattern. + - name: Pack + publish run: | - FLAGS="--access public --tag ${{ github.event.inputs.tag }} --no-git-checks" + set -euo pipefail + PACK_DIR="$RUNNER_TEMP/packs" + mkdir -p "$PACK_DIR" + + COMMON_FLAGS="--access public --tag ${{ github.event.inputs.tag }}" if [ "${{ github.event.inputs.dry_run }}" = "true" ]; then - FLAGS+=" --dry-run" + COMMON_FLAGS+=" --dry-run" else - FLAGS+=" --provenance" + COMMON_FLAGS+=" --provenance" fi + for pkg in ${{ steps.targets.outputs.packages }}; do - echo "==> Publishing @agentworkforce/$pkg $FLAGS" - pnpm --filter "@agentworkforce/$pkg" publish $FLAGS + echo "==> Packing @agentworkforce/$pkg" + pnpm --filter "@agentworkforce/$pkg" pack --pack-destination "$PACK_DIR" + TARBALL=$(ls -1t "$PACK_DIR"/agentworkforce-$pkg-*.tgz | head -n1) + if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then + echo "::error::could not find packed tarball for $pkg in $PACK_DIR" >&2 + ls -la "$PACK_DIR" >&2 || true + exit 1 + fi + echo "==> Publishing $TARBALL $COMMON_FLAGS" + npm publish "$TARBALL" $COMMON_FLAGS done - name: Tag + push