# pa launch — agent launcher

Identity: `@stravica-ai/engineering-pa-cli` › `launch` — boot a scoped agent instance bound to an identity and task.

## Status

**Shipped.** Handles single-agent single-task execution against any operator repo with roster and schedule files. Dry-run mode. Audit log per tooling standard §15. Per-task concurrency lock, default-on (see "Concurrency lock" below).

## Install / invoke

```bash
# Via pa top-level CLI
pnpm exec pa launch --agent=<name> --task=<id> [--dry-run]

# Via standalone bin
pnpm exec launch --agent=<name> --task=<id> [--dry-run]
```

## Commands

### `launch --agent=<name> --task=<id> [--dry-run]`

Boots a scoped agent instance:

1. Resolves the operator repo root (`DAVE_REPO_ROOT` / `PA_REPO_ROOT` env var, or `.git` walk from `cwd`).
2. Loads identity from `operator/knowledge/hr/roster/<name>.json`.
3. Loads task definition from `schedules/<id>.json`.
4. Builds a `LaunchPlan` (pure — no I/O).
5. On `--dry-run`: prints the plan as JSON, exits 0.
6. Otherwise: via the chokepoint runner — creates intake folder, spawns task command, writes audit record.

**Flags:**

| Flag | Required | Description |
|---|---|---|
| `--agent` | yes | Agent name — maps to `operator/knowledge/hr/roster/<name>.json` |
| `--task` | yes | Task ID — maps to `schedules/<id>.json` |
| `--dry-run` | no | Print LaunchPlan as JSON; skip execution and audit write |
| `-h`, `--help` | no | Show usage |

## Identity file schema

Loaded from `operator/knowledge/hr/roster/<name>.json`. **Format: JSON.** No YAML/frontmatter parser — the roster is already JSON across the platform.

Required fields:

| Field | Type | Description |
|---|---|---|
| `id` | string | Agent identifier (must match filename stem) |
| `name` | string | Display name |
| `roleId` | string | Role identifier (e.g. `system-admin`) |
| `runFolder` | string | Path relative to repo root (e.g. `agents/ian`) |
| `status` | string | Agent status (`active` \| other) |

Optional fields (`currentTeam`, `createdBy`, `createdDate`, `history`) are preserved but not read by the launcher.

## Task definition schema

Loaded from `schedules/<id>.json`. **Format: JSON.**

Required fields:

| Field | Type | Description |
|---|---|---|
| `id` | string | Task identifier (must match filename stem) |
| `command` | string | Command to execute — split on whitespace, passed to `spawn` without a shell. **Must not contain `'` or `"`** (no shell-quoting — see below) |

Optional fields:

| Field | Type | Description |
|---|---|---|
| `workingDirectory` | string | cwd for spawn; supports `${DAVE_ROOT}`, `${PA_REPO_ROOT}`, `${DAVE_REPO_ROOT}` tokens |
| `name` | string | Display name |
| `description` | string | Human-readable description |
| `schedule` | string | Cron expression (read by cron — not used by launcher) |
| `enabled` | boolean | Read by scheduler — not used by launcher |
| `lockStaleAfterMinutes` | number | Per-task staleness override for the concurrency lock (see below). Default: the core lock's 2-hour threshold |

## `command` is not a shell expression

The runner spawns the command **directly** — no shell, no expansion, no operators. The string is split on whitespace and handed to `child_process.spawn()` as `(bin, args[])`.

If the command contains `'` or `"`, `task-loader` rejects it at load time with exit code 2 (a `--dry-run` also fails). This is a deliberate guard: silently splitting `bash -c "a && b"` on whitespace shreds it to `spawn('bash', ['-c', '"a', '&&', 'b"'])`, bash exits 2 with `unexpected EOF`, and cron mails the operator every interval until someone notices.

Shell operators are refused the same way, at load time and in the runner: a command with `;`, `&&`, `||`, `|`, `>`, `<`, `$(`, a backtick or `&` exits 2 with a message naming the schedule id and the offending token. Without a shell the operator would reach the program as a literal argument (`pnpm exec estate-sync run; node ...` handed estate-sync the subcommand `run;`).

If you need shell features, you have two options:

1. **Wrap the work in a script file** and put the script path in `command`: `command: "/path/to/wrapper.sh"`. The runner spawns the script; the script does whatever it likes.
2. **Move the shell wrapper to the cron entry**, not the task command. The scheduler installer can take a quoted bash invocation in the cron crontab; the launcher's `command` field cannot.

## Concurrency lock (default-on)

Two runs of the **same task** must not overlap — a second concurrent run
consumes the same inputs (staging areas) and produces duplicate effects and
git races. Every non-dry-run launch therefore acquires a per-task lock via
the shared core utility (`@stravica-ai/engineering-pa-core` › `acquireLock` —
full contract in `packages/core/docs/lock.md`) before spawning. No opt-in
flag; protection is the default.

- **Lock path:** `<repoRoot>/.cache/launch/<taskId>.lock` — per task, so two
  *different* tasks overlap freely. `.cache/` is the gitignored
  operational-state area (tooling standard §16).
- **On contention** (live same-task lock): the run **exits 0** with a
  greppable skip line on stdout —
  `[launch] <agent>/<task>: skipped — lock held (held by pid <pid> since <ts>; lock <path>)`
  — writes a `noop-locked` audit record, and spawns nothing. Exit 0 (not 2)
  because an overlap skip is a healthy no-op on a cron schedule, matching
  `sync`'s `noop-locked` precedent; exit 2 would generate cron mail for
  expected behaviour. Automation that needs to detect skips reads the audit
  record or greps the log line.
- **Stale locks are broken automatically** with a
  `[launch] warning: broke stale lock ...` line on stderr, and the run
  proceeds. Staleness: dead pid (primary — recovers crashed runs
  immediately), or age past the threshold (backstop for pid reuse /
  unreadable lock files). Threshold: the core default of **2 hours**
  (sized so legitimately long sweeps — observed 1288+ seconds — are never
  stolen mid-run), overridable per task via `lockStaleAfterMinutes` in
  `schedules/<id>.json`.
- **Release:** on every exit path (success, task failure, spawn error). A
  crashed run leaves a dead-pid lock that the next tick breaks.
- **First run:** no lock file or `.cache/launch/` directory needed; the
  empty state is the common case.

## LaunchPlan object

The planner produces a `LaunchPlan` (JSON-serialisable, no I/O):

```jsonc
{
  "repoRoot": "/abs/path/to/operator-repo",
  "agent": { "id": "ian", "name": "Ian", "roleId": "system-admin", "runFolder": "agents/ian" },
  "task": { "id": "upstream-triage", "command": "node ...", "workingDirectory": null, "description": null },
  "intakeFolderPath": "/abs/path/to/agents/ian/staging",
  "auditLogPath": "/abs/path/to/agents/ian/runs.jsonl",
  "lockPath": "/abs/path/to/.cache/launch/upstream-triage.lock",
  "lockStaleAfterMs": null // null = core default (2 h); set via task lockStaleAfterMinutes
}
```

## Chokepoint discipline

`runner.mjs` is the **sole module** that:
- Creates directories (`mkdir`)
- Appends audit records (`appendFile`)
- Acquires/releases the per-task concurrency lock
- Spawns child processes (`spawn`)

The lock lives in the chokepoint so programmatic callers cannot bypass it:
a plan handed to `execute()` without a `lockPath` still gets a derived
per-task lock.

All other modules (`identity.mjs`, `task-loader.mjs`, `planner.mjs`) are pure — no I/O, no spawning. Tests inject a fake spawner into the runner; no mock libraries.

Launched agents **must not** do git operations. They write files to disk; the sync tool (`pa sync`) picks up changes on its tick.

## Exit codes (tooling standard §6)

| Code | Meaning |
|---|---|
| `0` | OK |
| `1` | Usage error (missing flags, unknown flag) |
| `2` | Logical failure (agent not found, task not found, missing required field) |
| `3` | Transport / IO failure (file unreadable, spawn failed) |

## Audit record contract (tooling standard §15)

Every non-dry-run exit path appends one JSON line to `<runFolder>/runs.jsonl`:

```jsonc
{ "ts": "2026-05-17T12:00:00Z", "status": "ok", "agentId": "ian", "taskId": "upstream-triage", "exitCode": 0 }
```

`status` vocabulary: `ok` | `noop-locked` | `fail-exit-<N>` | `fail-spawn`

`noop-locked` records carry a `reason` field naming the lock holder
(`"lock contention: held by pid <pid> since <ts>"`). Per §15 monitors treat
`noop-*` as healthy/fresh.

`runs.jsonl` is an operational artefact — it must be gitignored in the operator repo. It is never committed.

## Project layout

```
packages/cli/src/launch/
  README.md             this file — the contract
  cli.mjs               arg parsing, flag validation, exit-code translation
  commands/
    launch.mjs          thin glue: load → plan → execute
  identity.mjs          pure: load + validate agent identity from roster JSON
  task-loader.mjs       pure: load + validate task definition from schedules JSON
  planner.mjs           pure: build LaunchPlan object (no I/O)
  repo-root.mjs         pure: operator repo root discovery (env var + .git walk)
  runner.mjs            CHOKEPOINT: all file writes + process spawn
packages/cli/test/launch/
  cli.test.mjs          end-to-end: --help, --dry-run, live execution, error paths
  identity.test.mjs     unit: happy path, missing agent, invalid JSON, missing field
  task-loader.test.mjs  unit: happy path, missing task, invalid JSON, missing field
  planner.test.mjs      unit: output shape, null defaults, purity
  runner.test.mjs       unit: ok/fail/spawn-error paths via fake spawner
  fixtures/             synthetic roster + schedule for tests
```

## Tests

```bash
# Run launch tests only
node --test 'packages/cli/test/launch/**/*.test.mjs'

# Run all CLI package tests
pnpm --filter @stravica-ai/engineering-pa-cli test
```

## Standards

Follows the [tooling standard](../../../../core/knowledge/standards/tooling.md): Node.js only, zero npm deps, `node:test`, `node:util.parseArgs` with `strict: true`, camelCase JSON, chokepoint pattern (§7), audit record contract (§15), gitignore operational artefacts (§16).
