diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f6d217f..53592b3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -85,11 +85,11 @@ jobs: - name: Resolve target packages id: targets run: | - # Dependency order: persona-kit → workload-router → harness-kit → cli → agentworkforce. - # persona-kit is a leaf dep consumed by workload-router, harness-kit, and cli, - # so it must publish first. The top-level `agentworkforce` wrapper depends on + # Dependency order: persona-kit → workload-router → cli → agentworkforce. + # persona-kit is a leaf dep consumed by workload-router and cli, so it + # must publish first. The top-level `agentworkforce` wrapper depends on # `@agentworkforce/cli`, so it must publish last. - echo "packages=persona-kit workload-router harness-kit cli agentworkforce" >> "$GITHUB_OUTPUT" + echo "packages=persona-kit workload-router cli agentworkforce" >> "$GITHUB_OUTPUT" # Lockstep baseline heal. The workspace publishes every package at the # same version, so if any package's local version lags either its own @@ -689,7 +689,7 @@ jobs: // didn't publish an umbrella stamp (e.g. version: none re-runs). const releaseVersion = process.env.RELEASE_VERSION || canonicalVersion; - const packageOrder = ['workload-router', 'harness-kit', 'cli', 'agentworkforce']; + const packageOrder = ['persona-kit', 'workload-router', 'cli', 'agentworkforce']; const entries = versionsRaw.trim().split(/\s+/).filter(Boolean).map((entry) => { const idx = entry.indexOf(':'); return { pkg: entry.slice(0, idx), ver: entry.slice(idx + 1) }; diff --git a/.github/workflows/verify-publish.yml b/.github/workflows/verify-publish.yml index 7b36ff8..45546b5 100644 --- a/.github/workflows/verify-publish.yml +++ b/.github/workflows/verify-publish.yml @@ -16,7 +16,6 @@ on: options: - '@agentworkforce/cli' - 'agentworkforce' - - '@agentworkforce/harness-kit' - '@agentworkforce/workload-router' - '@agentworkforce/persona-kit' version: diff --git a/README.md b/README.md index 9828480..1dbdf8e 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,7 @@ for the full mount layout and semantics. ## Packages - `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/persona-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` — command-line implementation used by the `agentworkforce` wrapper: spawn a persona's harness (claude/codex/opencode) from the shell. See **[packages/cli/README.md](./packages/cli/README.md)** for the full docs, and the [CLI](#cli) section below for a quick tour. ## Personas @@ -602,7 +602,7 @@ This runs minimal guardrails across the workspace: ## Developing -For iterating on the CLI, harness-kit, workload-router, or internal system persona JSON files, +For iterating on the CLI, persona-kit, workload-router, or internal system persona JSON files, use the watch-mode dev loop instead of rebuilding by hand. **Terminal 1 — start the watchers (leave running):** @@ -633,4 +633,4 @@ Edit → save → re-run in terminal 2. TypeScript errors show up in terminal 1. **Per-package dev:** if you only want to watch one package, run `corepack pnpm --filter @agentworkforce/ run dev` (where `` is -`cli`, `harness-kit`, or `workload-router`). +`cli`, `persona-kit`, or `workload-router`). diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 844f298..946983a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -612,11 +612,11 @@ function sessionMountDir(sessionRoot: string): string { * launching opencode without a persona-specific agent selection. * * Strips all occurrences rather than just the first — the current producer - * (harness-kit's opencode branch) emits exactly one pair, so both behaviors - * are equivalent today, but "remove all" is idempotent and safer if a future - * caller ever appends a second `--agent` for any reason. A trailing `--agent` - * with no following value is preserved so the malformed argv surfaces at the - * harness rather than getting silently swallowed here. + * (the opencode branch in persona-kit) emits exactly one pair, so both + * behaviors are equivalent today, but "remove all" is idempotent and safer if + * a future caller ever appends a second `--agent` for any reason. A trailing + * `--agent` with no following value is preserved so the malformed argv + * surfaces at the harness rather than getting silently swallowed here. */ export function stripAgentFlag(args: readonly string[]): string[] { const out: string[] = []; @@ -1008,7 +1008,7 @@ function runDryRun(selection: PersonaSelection): number { `✓ sidecar: ${sidecarLookup.sidecar ? sidecarLookup.sidecar.mountFile : '(none)'}\n` ); - // Check 2: harness-kit translation. buildInteractiveSpec validates + // Check 2: persona-kit translation. buildInteractiveSpec validates // permissions shape, mcpServers shape, and required runtime fields. // We resolve env + mcp leniently (same as the live launch path) so // the spec call sees the same inputs it would at runtime. @@ -3244,8 +3244,7 @@ async function runPersonaImprover(args: { stderrBuf += chunk; }); // SIGTERM first; if the harness traps or ignores it, escalate to - // SIGKILL after a 1s grace so the timeout is actually enforced - // (matches the previous spawnCapture behavior in harness-kit). + // SIGKILL after a 1s grace so the timeout is actually enforced. const timeout = timeoutMs !== undefined ? setTimeout(() => { diff --git a/packages/harness-kit/CHANGELOG.md b/packages/harness-kit/CHANGELOG.md deleted file mode 100644 index 5ef4c35..0000000 --- a/packages/harness-kit/CHANGELOG.md +++ /dev/null @@ -1,61 +0,0 @@ -# Changelog - -All notable changes to `@agentworkforce/harness-kit` will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [0.15.1] - 2026-05-08 - -### Added - -- Add persona input resolution/rendering helpers and resolve declared inputs - before non-interactive runner launches. - -## [0.15.0] - 2026-05-08 - -### Released - -- v0.15.0 - -## [0.13.0] - 2026-05-08 - -### Released - -- v0.13.0 - -## [0.8.0] - 2026-05-07 - -### Added - -- **Add persona create mode** - -## [0.6.1] - 2026-05-06 - -### Fixed - -- Publish packages in lockstep - -## [0.5.5] - 2026-05-02 - -### Fixed - -- Publish harness-kit with a compatible workload-router dependency range so npm can resolve the released packages together. - -### Added - -- Add a `useRunnablePersona` / `useRunnableSelection` bridge that launches the selected harness non-interactively and returns captured execution results. - -## [0.5.4] - 2026-05-02 - -### Dependencies - -- Sync package versions to 0.5.4 - -## [0.2.1] - 2026-04-29 - -### Released - -- v0.2.1 diff --git a/packages/harness-kit/README.md b/packages/harness-kit/README.md deleted file mode 100644 index 217393c..0000000 --- a/packages/harness-kit/README.md +++ /dev/null @@ -1,270 +0,0 @@ -# @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 an interactive argv - -Takes the fields off a `PersonaSelection` (harness, model, systemPrompt, -harnessSettings, 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('persona-authoring'); - -// 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, - personaId: selection.personaId, - model: selection.runtime.model, - systemPrompt: selection.runtime.systemPrompt, - harnessSettings: selection.runtime.harnessSettings, - 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 ?? {}) } -}); -``` - -### `useRunnablePersona(intent)` — run a persona non-interactively - -For orchestrators that need a programmatic `sendMessage()` surface, the kit -also exposes a thin runner around the same router + harness translation path. -It resolves the persona, launches the selected harness in non-interactive -mode, captures stdout/stderr, reports progress chunks, supports cancellation -and timeouts, and returns a stable execution result. - -`useRunnablePersona` follows the router's internal built-in resolver. For -optional pack/local personas, resolve a `PersonaSelection` through your source -cascade and call `useRunnableSelection(selection)`. - -```ts -import { useRunnablePersona } from '@agentworkforce/harness-kit'; - -const persona = useRunnablePersona('persona-authoring'); -const run = persona.sendMessage('Draft a persona for workflow artifact writing.', { - workingDirectory: process.cwd(), - name: 'persona-author', - timeoutSeconds: persona.selection.runtime.harnessSettings.timeoutSeconds, - inputs: { - TARGET_DIR: '.agentworkforce/workforce/personas', - CREATE_MODE: 'local', - TASK_DESCRIPTION: 'Write a workflow artifact as structured JSON.' - }, - onProgress: (chunk) => process.stderr.write(chunk.text) -}); - -const result = await run; -if (result.status !== 'completed') { - throw new Error(result.stderr || `persona run failed: ${result.status}`); -} -console.log(result.output); -``` - -If the selected persona declares `inputs`, `sendMessage(..., { inputs })` -resolves those values before spawn, substitutes `$NAME` / `${NAME}` in the -system prompt, and injects the resolved values into the child process env. -Resolution uses explicit `inputs`, then `process.env[spec.env ?? NAME]`, then -`default`, and throws when a required input is still unset. - -The runner maps harnesses to their non-interactive command shapes: -`claude --print`, `codex exec`, and `opencode run`. It writes generated -config files such as `opencode.json` only for the duration of the child -process and restores or removes them afterward. Skill installation is opt-in -with `installSkills: true`; callers that need stronger filesystem isolation -should keep using a mount/sandbox layer around the runner. - -### 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. - -## Persona input rendering - -Persona inputs are distinct from env references. Inputs are prompt-visible -runtime values declared on a persona, such as `TARGET_DIR`, `PACKAGE_NAME`, or -`CREATE_MODE`. Use them for non-secret launch context, not API keys. - -```ts -import { renderPersonaInputs, resolvePersonaInputs } from '@agentworkforce/harness-kit'; - -const { values } = resolvePersonaInputs( - { - TARGET_DIR: { env: 'MY_TARGET_DIR', default: './out' }, - CREATE_MODE: { default: 'local' } - }, - { TARGET_DIR: '/tmp/personas' }, - process.env -); - -const systemPrompt = renderPersonaInputs( - 'Write to $TARGET_DIR using ${CREATE_MODE} mode.', - values -); -``` - -`resolvePersonaInputs` fails hard for missing required inputs. That is -intentional: unlike secret env refs, an input is usually structural context the -persona needs to follow its contract. - -## API surface - -```ts -// Persona inputs -export class MissingPersonaInputError extends Error { input: string; env: string } -export function resolvePersonaInputs(inputs, provided, processEnv): PersonaInputResolution -export function renderPersonaInputs(systemPrompt, values): string -export interface PersonaInputResolution { values: Record } -export type PersonaInputValues = Record - -// 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; - personaId: string; - model: string; - systemPrompt: string; - harnessSettings?: HarnessSettings; - mcpServers?: Record; - permissions?: PersonaPermissions; -} -export interface InteractiveSpec { - bin: string; - args: readonly string[]; - initialPrompt: string | null; - warnings: string[]; -} - -// Runnable personas -export function useRunnablePersona(intent, options?): RunnablePersonaContext -export function useRunnableSelection(selection, options?): RunnablePersonaContext -export interface RunnablePersonaContext { - selection: PersonaSelection; - install: PersonaInstallContext; - sendMessage(task, options?): PersonaExecution; -} -export interface PersonaExecutionResult { - status: 'completed' | 'failed' | 'cancelled' | 'timeout'; - output: string; - stderr: string; - exitCode: number | null; - durationMs: number; -} -``` - -## Status - -Small, stable surface focused on the three things a harness spawner needs: -resolve env refs, resolve MCP config, and build argv. The default exports are -still pure when you use `buildInteractiveSpec` directly. The -`useRunnablePersona` convenience is intentionally the small side-effecting -layer for consumers that want the same harness knowledge plus a captured -non-interactive child process. diff --git a/packages/harness-kit/package.json b/packages/harness-kit/package.json deleted file mode 100644 index ef7e27a..0000000 --- a/packages/harness-kit/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@agentworkforce/harness-kit", - "version": "0.19.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", - "CHANGELOG.md", - "package.json" - ], - "dependencies": { - "@agentworkforce/persona-kit": "workspace:*", - "@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", - "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput", - "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/harness-kit/src/index.ts b/packages/harness-kit/src/index.ts deleted file mode 100644 index 4e20923..0000000 --- a/packages/harness-kit/src/index.ts +++ /dev/null @@ -1,59 +0,0 @@ -// harness-kit's surface is persona-shaped. The canonical implementations live -// in @agentworkforce/persona-kit; this file is a re-export shim until 6/8 -// removes harness-kit entirely. The runner functions (useRunnablePersona, -// makeRunnablePersonaContext, etc.) bridge persona-kit + workload-router and -// stay co-located here for now to avoid a workspace dep cycle between -// persona-kit and workload-router. - -export { - MissingEnvRefError, - makeEnvRefResolver, - makeLenientResolver, - resolveStringMap, - resolveStringMapLenient, - type DroppedRef, - type EnvRefResolver, - type LenientResult -} from '@agentworkforce/persona-kit'; - -export { - formatDropWarnings, - resolveMcpServersLenient, - type DroppedMcpServer, - type McpResolution -} from '@agentworkforce/persona-kit'; - -export { - MissingPersonaInputError, - renderPersonaInputs, - resolvePersonaInputs, - type PersonaInputResolution, - type PersonaInputValues -} from '@agentworkforce/persona-kit'; - -export { - buildInteractiveSpec, - type BuildInteractiveSpecInput, - type InteractiveConfigFile, - type InteractiveSpec -} from '@agentworkforce/persona-kit'; - -export { - detectHarness, - detectHarnesses, - type HarnessAvailability -} from '@agentworkforce/persona-kit'; - -export { - buildNonInteractiveSpec, - makeRunnablePersonaContext, - useRunnablePersona, - useRunnableSelection, - type NonInteractiveSpec, - type PersonaExecution, - type PersonaExecutionResult, - type PersonaSendOptions, - type RunnablePersonaContext, - type RunnablePersonaOptions, - type RunnableSelectionOptions -} from './runner.js'; diff --git a/packages/harness-kit/src/runner.test.ts b/packages/harness-kit/src/runner.test.ts deleted file mode 100644 index 0ba437a..0000000 --- a/packages/harness-kit/src/runner.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { chmodSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import type { PersonaSelection } from '@agentworkforce/persona-kit'; -import { - buildNonInteractiveSpec, - useRunnableSelection -} from './runner.js'; - -function fakeSelection(overrides: Partial = {}): PersonaSelection { - return { - personaId: 'test-persona', - tier: 'best', - runtime: { - harness: 'codex', - model: 'openai-codex/gpt-5.3-codex', - systemPrompt: 'You are a test persona.', - harnessSettings: { - reasoning: 'high', - timeoutSeconds: 30 - } - }, - skills: [], - rationale: 'test', - ...overrides - }; -} - -function writeHarness( - dir: string, - source: string -): string { - const path = join(dir, 'fake-harness.js'); - writeFileSync(path, source, 'utf8'); - chmodSync(path, 0o755); - return path; -} - -test('buildNonInteractiveSpec translates harnesses to non-interactive commands', () => { - const claude = buildNonInteractiveSpec({ - harness: 'claude', - personaId: 'p', - model: 'claude-sonnet-4-6', - systemPrompt: 'system', - task: 'task', - name: 'run-name' - }); - assert.equal(claude.bin, 'claude'); - assert.ok(claude.args.includes('--print')); - assert.ok(claude.args.includes('--output-format')); - assert.ok(claude.args.includes('--name')); - assert.equal(claude.args.at(-1), 'task'); - - const codex = buildNonInteractiveSpec({ - harness: 'codex', - personaId: 'p', - model: 'openai-codex/gpt-5.3-codex', - systemPrompt: 'system', - task: 'task' - }); - assert.deepEqual(codex.args.slice(0, 4), ['exec', '-m', 'gpt-5.3-codex', '--skip-git-repo-check']); - assert.match(String(codex.args.at(-1)), /system\n\nUser task:\ntask/); - - const opencode = buildNonInteractiveSpec({ - harness: 'opencode', - personaId: 'p', - model: 'opencode/gpt-5-nano', - systemPrompt: 'system', - task: 'task', - workingDirectory: '/tmp/project' - }); - assert.deepEqual(opencode.args.slice(0, 7), [ - 'run', - '--agent', - 'p', - '--model', - 'opencode/gpt-5-nano', - '--format', - 'default' - ]); - assert.ok(opencode.args.includes('--dir')); - assert.equal(opencode.configFiles.length, 1); -}); - -test('useRunnableSelection spawns the harness, captures output, and passes inputs/env', async () => { - const dir = mkdtempSync(join(tmpdir(), 'aw-runner-')); - try { - const harness = writeHarness( - dir, - `#!/usr/bin/env node -const payload = { - argv: process.argv.slice(2), - cwd: process.cwd(), - envValue: process.env.TEST_PERSONA_ENV -}; -process.stdout.write(JSON.stringify(payload)); -` - ); - const context = useRunnableSelection(fakeSelection(), { - commandOverrides: { codex: harness } - }); - const progress: string[] = []; - const result = await context.sendMessage('write a workflow', { - workingDirectory: dir, - inputs: { answer: 42 }, - env: { TEST_PERSONA_ENV: 'from-env' }, - onProgress: (chunk) => progress.push(chunk.text) - }); - - assert.equal(result.status, 'completed'); - assert.equal(result.exitCode, 0); - assert.equal(progress.join(''), result.output); - const payload = JSON.parse(result.output); - assert.equal(payload.cwd, realpathSync(dir)); - assert.equal(payload.envValue, 'from-env'); - assert.equal(payload.argv[0], 'exec'); - assert.match(payload.argv.at(-1), /write a workflow/); - assert.match(payload.argv.at(-1), /"answer": 42/); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('useRunnableSelection resolves declared persona inputs into the system prompt and env', async () => { - const dir = mkdtempSync(join(tmpdir(), 'aw-runner-inputs-')); - try { - const harness = writeHarness( - dir, - `#!/usr/bin/env node -const payload = { - prompt: process.argv.at(-1), - envValue: process.env.TARGET_DIR -}; -process.stdout.write(JSON.stringify(payload)); -` - ); - const context = useRunnableSelection( - fakeSelection({ - inputs: { - TARGET_DIR: { - default: '/default/personas' - } - }, - runtime: { - harness: 'codex', - model: 'openai-codex/gpt-5.3-codex', - systemPrompt: 'Write to $TARGET_DIR/.json', - harnessSettings: { - reasoning: 'high', - timeoutSeconds: 30 - } - } - }), - { commandOverrides: { codex: harness } } - ); - - const result = await context.sendMessage('task', { - workingDirectory: dir, - inputs: { TARGET_DIR: '/explicit/personas' } - }); - - assert.equal(result.status, 'completed'); - const payload = JSON.parse(result.output); - assert.match(payload.prompt, /Write to \/explicit\/personas\/\.json/); - assert.equal(payload.envValue, '/explicit/personas'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('useRunnableSelection reports non-zero harness exits as failed', async () => { - const dir = mkdtempSync(join(tmpdir(), 'aw-runner-fail-')); - try { - const harness = writeHarness( - dir, - `#!/usr/bin/env node -process.stderr.write('boom'); -process.exit(7); -` - ); - const context = useRunnableSelection(fakeSelection(), { - commandOverrides: { codex: harness } - }); - const result = await context.sendMessage('task', { workingDirectory: dir }); - - assert.equal(result.status, 'failed'); - assert.equal(result.exitCode, 7); - assert.equal(result.stderr, 'boom'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('useRunnableSelection does not spawn when called with an already-aborted signal', async () => { - const dir = mkdtempSync(join(tmpdir(), 'aw-runner-aborted-')); - try { - const marker = join(dir, 'spawned'); - const harness = writeHarness( - dir, - `#!/usr/bin/env node -require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned'); -process.stdout.write('should-not-run'); -` - ); - const controller = new AbortController(); - controller.abort('cancel-before-start'); - const context = useRunnableSelection(fakeSelection(), { - commandOverrides: { codex: harness } - }); - const result = await context.sendMessage('task', { - workingDirectory: dir, - signal: controller.signal - }); - - assert.equal(result.status, 'cancelled'); - assert.equal(result.output, ''); - assert.match(result.stderr, /cancel-before-start/); - assert.equal(existsSync(marker), false); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('useRunnableSelection force-kills a child that ignores timeout SIGTERM', async () => { - const dir = mkdtempSync(join(tmpdir(), 'aw-runner-timeout-')); - try { - const harness = writeHarness( - dir, - `#!/usr/bin/env node -process.on('SIGTERM', () => {}); -setInterval(() => {}, 1000); -` - ); - const context = useRunnableSelection(fakeSelection(), { - commandOverrides: { codex: harness } - }); - const result = await context.sendMessage('task', { - workingDirectory: dir, - timeoutSeconds: 1 - }); - - assert.equal(result.status, 'timeout'); - assert.equal(result.exitCode, null); - assert.ok(result.durationMs >= 1_800, 'expected timeout to wait for the forced kill grace period'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('useRunnableSelection materializes and removes opencode config files around the run', async () => { - const dir = mkdtempSync(join(tmpdir(), 'aw-runner-opencode-')); - try { - const harness = writeHarness( - dir, - `#!/usr/bin/env node -const fs = require('node:fs'); -process.stdout.write(fs.readFileSync('opencode.json', 'utf8')); -` - ); - const context = useRunnableSelection( - fakeSelection({ - runtime: { - harness: 'opencode', - model: 'opencode/gpt-5-nano', - systemPrompt: 'You are an opencode persona.', - harnessSettings: { - reasoning: 'medium', - timeoutSeconds: 30 - } - } - }), - { commandOverrides: { opencode: harness } } - ); - const result = await context.sendMessage('task', { workingDirectory: dir }); - - assert.equal(result.status, 'completed'); - const config = JSON.parse(result.output); - assert.equal(config.agent['test-persona'].prompt, 'You are an opencode persona.'); - assert.throws(() => readFileSync(join(dir, 'opencode.json'), 'utf8')); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/packages/harness-kit/src/runner.ts b/packages/harness-kit/src/runner.ts deleted file mode 100644 index bd998f7..0000000 --- a/packages/harness-kit/src/runner.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { spawn } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { dirname, isAbsolute, join, normalize, sep } from 'node:path'; -import { randomUUID } from 'node:crypto'; - -import { - usePersona, - useSelection, - type RoutingProfile, - type RoutingProfileId -} from '@agentworkforce/workload-router'; - -import { - buildInteractiveSpec, - formatDropWarnings, - renderPersonaInputs, - resolveMcpServersLenient, - resolvePersonaInputs, - resolveStringMapLenient, - type BuildInteractiveSpecInput, - type Harness, - type PersonaContext, - type PersonaIntent, - type PersonaSelection, - type PersonaTier -} from '@agentworkforce/persona-kit'; - -export interface PersonaSendOptions { - workingDirectory?: string; - name?: string; - timeoutSeconds?: number; - inputs?: Record; - installSkills?: boolean; - env?: NodeJS.ProcessEnv; - signal?: AbortSignal; - onProgress?: (chunk: { stream: 'stdout' | 'stderr'; text: string }) => void; -} - -export interface PersonaExecutionResult { - status: 'completed' | 'failed' | 'cancelled' | 'timeout'; - output: string; - stderr: string; - exitCode: number | null; - durationMs: number; - workflowRunId?: string; - stepName?: string; -} - -export interface PersonaExecution extends Promise { - cancel(reason?: string): void; - readonly runId: Promise; -} - -export interface RunnablePersonaContext { - readonly selection: PersonaSelection; - readonly install: PersonaContext['install']; - sendMessage(task: string, options?: PersonaSendOptions): PersonaExecution; -} - -export interface RunnablePersonaOptions { - harness?: Harness; - tier?: PersonaTier; - profile?: RoutingProfile | RoutingProfileId; - installRoot?: string; - commandOverrides?: Partial>; -} - -export interface RunnableSelectionOptions { - harness?: Harness; - installRoot?: string; - commandOverrides?: Partial>; -} - -export interface NonInteractiveSpec { - bin: string; - args: readonly string[]; - configFiles: readonly { path: string; contents: string }[]; - warnings: readonly string[]; -} - -interface SpawnCaptureResult { - stdout: string; - stderr: string; - exitCode: number | null; - status: PersonaExecutionResult['status']; -} - -interface ConfigWrite { - path: string; - existed: boolean; - previous?: string; -} - -const FORCE_KILL_GRACE_MS = 1_000; - -export function useRunnablePersona( - intent: PersonaIntent, - options: RunnablePersonaOptions = {} -): RunnablePersonaContext { - const context = usePersona(intent, { - harness: options.harness, - tier: options.tier, - profile: options.profile, - installRoot: options.installRoot - }); - return makeRunnablePersonaContext(context, { - commandOverrides: options.commandOverrides - }); -} - -export function useRunnableSelection( - selection: PersonaSelection, - options: RunnableSelectionOptions = {} -): RunnablePersonaContext { - const context = useSelection(selection, { - harness: options.harness, - installRoot: options.installRoot - }); - return makeRunnablePersonaContext(context, { - commandOverrides: options.commandOverrides - }); -} - -export function makeRunnablePersonaContext( - context: PersonaContext, - options: { commandOverrides?: Partial> } = {} -): RunnablePersonaContext { - const sendMessage = (task: string, sendOptions: PersonaSendOptions = {}) => { - const runId = randomUUID(); - const controller = new AbortController(); - const startedAt = Date.now(); - let cancelReason = ''; - - const cancel = (reason = 'cancelled') => { - cancelReason = reason; - controller.abort(); - }; - - const promise = (async (): Promise => { - const cwd = sendOptions.workingDirectory ?? process.cwd(); - const callerEnv = sendOptions.env ? { ...process.env, ...sendOptions.env } : process.env; - const inputResolution = resolvePersonaInputs( - context.selection.inputs, - sendOptions.inputs, - callerEnv - ); - const inputEnv = inputResolution.values; - const envWithInputs = { ...callerEnv, ...inputEnv }; - const envResolution = resolveStringMapLenient(context.selection.env, envWithInputs, 'env'); - const mcpResolution = resolveMcpServersLenient(context.selection.mcpServers, envWithInputs); - const dropWarnings = formatDropWarnings( - envResolution.dropped, - mcpResolution.dropped, - mcpResolution.droppedServers - ); - const spec = buildNonInteractiveSpec({ - harness: context.selection.runtime.harness, - personaId: context.selection.personaId, - model: context.selection.runtime.model, - systemPrompt: renderPersonaInputs( - context.selection.runtime.systemPrompt, - inputResolution.values - ), - harnessSettings: context.selection.runtime.harnessSettings, - mcpServers: mcpResolution.servers, - permissions: context.selection.permissions, - task: withInputs(task, sendOptions.inputs), - name: sendOptions.name, - workingDirectory: cwd - }); - const warnings = [...dropWarnings, ...spec.warnings]; - const warningText = warnings.length ? warnings.map((w) => `warning: ${w}\n`).join('') : ''; - if (warningText) { - sendOptions.onProgress?.({ stream: 'stderr', text: warningText }); - } - - const env = { - ...callerEnv, - ...(envResolution.value ?? {}), - ...inputEnv - }; - const bin = options.commandOverrides?.[context.selection.runtime.harness] ?? spec.bin; - const signal = anySignal([controller.signal, sendOptions.signal]); - if (signal?.aborted) { - return { - status: 'cancelled', - output: '', - stderr: warningText + abortReason(signal, cancelReason), - exitCode: null, - durationMs: Date.now() - startedAt - }; - } - const configWrites = materializeConfigFiles(cwd, spec.configFiles); - try { - if (sendOptions.installSkills === true && context.install.commandString !== ':') { - const install = await spawnCapture( - context.install.command[0], - context.install.command.slice(1), - { cwd, env, signal, timeoutSeconds: sendOptions.timeoutSeconds, onProgress: sendOptions.onProgress } - ); - if (install.status !== 'completed' || install.exitCode !== 0) { - return { - status: install.status === 'completed' ? 'failed' : install.status, - output: install.stdout, - stderr: warningText + install.stderr, - exitCode: install.exitCode, - durationMs: Date.now() - startedAt - }; - } - } - - const result = await spawnCapture(bin, spec.args, { - cwd, - env, - signal, - timeoutSeconds: sendOptions.timeoutSeconds, - onProgress: sendOptions.onProgress - }); - const status = - result.status === 'completed' && result.exitCode !== 0 ? 'failed' : result.status; - return { - status, - output: result.stdout, - stderr: warningText + result.stderr + (cancelReason ? `\n${cancelReason}` : ''), - exitCode: result.exitCode, - durationMs: Date.now() - startedAt - }; - } finally { - restoreConfigFiles(configWrites); - if (sendOptions.installSkills === true && context.install.cleanupCommandString !== ':') { - await spawnCapture(context.install.cleanupCommand[0], context.install.cleanupCommand.slice(1), { - cwd, - env, - signal: undefined, - timeoutSeconds: 30 - }); - } - } - })(); - - const execution = promise as PersonaExecution; - Object.defineProperties(execution, { - cancel: { value: cancel }, - runId: { value: Promise.resolve(runId) } - }); - return execution; - }; - - return Object.freeze({ - selection: context.selection, - install: context.install, - sendMessage - }); -} - -export function buildNonInteractiveSpec( - input: BuildInteractiveSpecInput & { - task: string; - name?: string; - workingDirectory?: string; - } -): NonInteractiveSpec { - const interactive = buildInteractiveSpec(input); - switch (input.harness) { - case 'claude': { - const args = [...interactive.args, '--print', '--output-format', 'text']; - if (input.name) args.push('--name', input.name); - args.push(input.task); - return { - bin: interactive.bin, - args, - configFiles: interactive.configFiles, - warnings: interactive.warnings - }; - } - case 'codex': { - const prompt = interactive.initialPrompt - ? `${interactive.initialPrompt}\n\nUser task:\n${input.task}` - : input.task; - return { - bin: interactive.bin, - args: ['exec', ...interactive.args, '--skip-git-repo-check', prompt], - configFiles: interactive.configFiles, - warnings: interactive.warnings - }; - } - case 'opencode': { - const args = ['run', ...interactive.args, '--model', input.model, '--format', 'default']; - if (input.workingDirectory) args.push('--dir', input.workingDirectory); - if (input.name) args.push('--title', input.name); - args.push(input.task); - return { - bin: interactive.bin, - args, - configFiles: interactive.configFiles, - warnings: interactive.warnings - }; - } - default: { - const _exhaustive: never = input.harness; - throw new Error(`Unhandled harness: ${String(_exhaustive)}`); - } - } -} - -function withInputs(task: string, inputs: PersonaSendOptions['inputs']): string { - if (!inputs || Object.keys(inputs).length === 0) return task; - return `${task}\n\nRun inputs:\n${JSON.stringify(inputs, null, 2)}`; -} - -function assertSafeRelativePath(path: string): void { - if (!path) throw new Error('config file path must be non-empty'); - if (isAbsolute(path)) throw new Error(`config file path must be relative: ${path}`); - const normalized = normalize(path); - if (normalized === '..' || normalized.startsWith(`..${sep}`)) { - throw new Error(`config file path must not escape the working directory: ${path}`); - } -} - -function materializeConfigFiles( - cwd: string, - files: readonly { path: string; contents: string }[] -): ConfigWrite[] { - const writes: ConfigWrite[] = []; - for (const file of files) { - assertSafeRelativePath(file.path); - const target = join(cwd, file.path); - const existed = existsSync(target); - const previous = existed ? readFileSync(target, 'utf8') : undefined; - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, file.contents, 'utf8'); - writes.push({ path: target, existed, previous }); - } - return writes; -} - -function restoreConfigFiles(writes: readonly ConfigWrite[]): void { - for (const write of [...writes].reverse()) { - if (write.existed) { - writeFileSync(write.path, write.previous ?? '', 'utf8'); - } else { - rmSync(write.path, { force: true }); - } - } -} - -async function spawnCapture( - bin: string | undefined, - args: readonly string[], - options: { - cwd: string; - env: NodeJS.ProcessEnv; - signal?: AbortSignal; - timeoutSeconds?: number; - onProgress?: PersonaSendOptions['onProgress']; - } -): Promise { - if (!bin) { - return { stdout: '', stderr: 'missing command\n', exitCode: 127, status: 'failed' }; - } - if (options.signal?.aborted) { - return { stdout: '', stderr: abortReason(options.signal), exitCode: null, status: 'cancelled' }; - } - - return await new Promise((resolve) => { - let stdout = ''; - let stderr = ''; - let settled = false; - let timedOut = false; - let cancelled = false; - let forceKillTimeout: NodeJS.Timeout | undefined; - const child = spawn(bin, [...args], { - cwd: options.cwd, - env: options.env, - stdio: ['ignore', 'pipe', 'pipe'] - }); - const timeout = - options.timeoutSeconds && options.timeoutSeconds > 0 - ? setTimeout(() => { - timedOut = true; - child.kill('SIGTERM'); - forceKillTimeout = setTimeout(() => { - if (!settled) child.kill('SIGKILL'); - }, FORCE_KILL_GRACE_MS); - }, options.timeoutSeconds * 1000) - : undefined; - const abort = () => { - cancelled = true; - child.kill('SIGTERM'); - }; - options.signal?.addEventListener('abort', abort, { once: true }); - - const finish = (exitCode: number | null, status?: PersonaExecutionResult['status']) => { - if (settled) return; - settled = true; - if (timeout) clearTimeout(timeout); - if (forceKillTimeout) clearTimeout(forceKillTimeout); - options.signal?.removeEventListener('abort', abort); - resolve({ - stdout, - stderr, - exitCode, - status: status ?? (timedOut ? 'timeout' : cancelled ? 'cancelled' : 'completed') - }); - }; - - child.stdout.on('data', (buf: Buffer) => { - const text = buf.toString(); - stdout += text; - options.onProgress?.({ stream: 'stdout', text }); - }); - child.stderr.on('data', (buf: Buffer) => { - const text = buf.toString(); - stderr += text; - options.onProgress?.({ stream: 'stderr', text }); - }); - child.on('exit', (code) => finish(code)); - child.on('error', (err: NodeJS.ErrnoException) => { - stderr += err.message; - finish(err.code === 'ENOENT' ? 127 : 1, 'failed'); - }); - }); -} - -function abortReason(signal: AbortSignal, fallback = 'cancelled'): string { - return signal.reason instanceof Error - ? signal.reason.message - : typeof signal.reason === 'string' - ? signal.reason - : fallback; -} - -function anySignal(signals: Array): AbortSignal | undefined { - const active = signals.filter((signal): signal is AbortSignal => signal !== undefined); - if (active.length === 0) return undefined; - if (active.length === 1) return active[0]; - const controller = new AbortController(); - for (const signal of active) { - if (signal.aborted) { - controller.abort(signal.reason); - break; - } - signal.addEventListener('abort', () => controller.abort(signal.reason), { once: true }); - } - return controller.signal; -} diff --git a/packages/harness-kit/tsconfig.json b/packages/harness-kit/tsconfig.json deleted file mode 100644 index df59da5..0000000 --- a/packages/harness-kit/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "dist" - }, - "include": ["src/**/*.ts"] -} diff --git a/packages/persona-kit/src/interactive-spec.ts b/packages/persona-kit/src/interactive-spec.ts index 1f88405..c7744d4 100644 --- a/packages/persona-kit/src/interactive-spec.ts +++ b/packages/persona-kit/src/interactive-spec.ts @@ -258,8 +258,8 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact // of autosync, and callers who want a read-only persona (e.g. a code // reviewer) can override this in a follow-up PR that threads a // richer permission spec through the persona config — the current - // harness-kit PersonaPermissions shape is claude-specific and - // already warned about for opencode. + // PersonaPermissions shape is claude-specific and already warned about + // for opencode. // // The bare-string form `permission: 'allow'` was valid in older // opencode versions but is rejected by 1.14.x: the agent decoder diff --git a/packages/workload-router/README.md b/packages/workload-router/README.md index 85543fc..c3a5da9 100644 --- a/packages/workload-router/README.md +++ b/packages/workload-router/README.md @@ -94,13 +94,13 @@ Personas may declare prompt-visible runtime inputs: Input keys must be env-style uppercase names. The router validates and carries the declarations through `PersonaSpec` and `PersonaSelection`; launchers decide -how to resolve and render them. The standard harness-kit policy is explicit +how to resolve and render them. The standard persona-kit policy is explicit value, env var, default, then fail. Codex runtimes may also set `harnessSettings.sandboxMode`, `harnessSettings.approvalPolicy`, `harnessSettings.workspaceWriteNetworkAccess`, and -`harnessSettings.webSearch`; harness-kit maps those to Codex launch flags. +`harnessSettings.webSearch`; persona-kit maps those to Codex launch flags. ## Development diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index faed203..47347ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,15 +42,6 @@ importers: specifier: ^9.4.0 version: 9.4.0 - packages/harness-kit: - dependencies: - '@agentworkforce/persona-kit': - specifier: workspace:* - version: link:../persona-kit - '@agentworkforce/workload-router': - specifier: workspace:* - version: link:../workload-router - packages/persona-kit: dependencies: '@relayfile/local-mount':