diff --git a/bin/pear.mjs b/bin/pear.mjs
index eff17b1c..68f003cf 100755
--- a/bin/pear.mjs
+++ b/bin/pear.mjs
@@ -9,6 +9,9 @@
* The Electron main process (src/main/index.ts) parses the `open
` verb,
* reuses or creates a project rooted at that directory, and routes the request
* to an already-running instance via the single-instance lock.
+ *
+ * The one exception is `pear factory …`, which is a headless passthrough to the
+ * factory-sdk CLI (`fleet factory …`) and does NOT launch Electron — see below.
*/
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
@@ -21,29 +24,55 @@ const require = createRequire(import.meta.url)
const here = dirname(fileURLToPath(import.meta.url))
const appRoot = join(here, '..')
const appMain = join(appRoot, 'out', 'main', 'index.js')
+const args = process.argv.slice(2)
-if (!existsSync(appMain)) {
- console.error(`Pear is not built yet — run \`npm run build\` first (missing ${appMain}).`)
- process.exit(1)
-}
+// `pear factory …` is a headless passthrough to the factory-sdk CLI
+// (`fleet factory …`). It deliberately does NOT launch the Electron app: the
+// factory daemon/reaper run in a plain Node process. Everything after `factory`
+// is forwarded verbatim, so `pear factory start --mode live --config `
+// behaves identically to `node packages/factory-sdk/bin/fleet.mjs factory …`.
+if (args[0] === 'factory') {
+ const fleetBin = join(appRoot, 'packages', 'factory-sdk', 'bin', 'fleet.mjs')
+ if (!existsSync(fleetBin)) {
+ console.error(`factory-sdk CLI not found (missing ${fleetBin}).`)
+ process.exit(1)
+ }
+ const child = spawn(process.execPath, [fleetBin, ...args], { stdio: 'inherit' })
+ child.on('close', (code, signal) => {
+ if (signal) {
+ const signalNumber = typeof os.constants.signals[signal] === 'number' ? os.constants.signals[signal] : 0
+ process.exit(128 + signalNumber)
+ }
+ process.exit(code ?? 0)
+ })
+ child.on('error', (error) => {
+ console.error('Failed to launch factory CLI:', error instanceof Error ? error.message : String(error))
+ process.exit(1)
+ })
+} else {
+ if (!existsSync(appMain)) {
+ console.error(`Pear is not built yet — run \`npm run build\` first (missing ${appMain}).`)
+ process.exit(1)
+ }
-// In a Node context, requiring electron yields the path to its executable.
-const electronPath = require('electron')
+ // In a Node context, requiring electron yields the path to its executable.
+ const electronPath = require('electron')
-const child = spawn(electronPath, [appMain, ...process.argv.slice(2)], {
- stdio: 'inherit'
-})
+ const child = spawn(electronPath, [appMain, ...args], {
+ stdio: 'inherit'
+ })
-child.on('close', (code, signal) => {
- // A signal-terminated child reports code === null; surface that as a failure
- // (128 + signal number, per shell convention) so calling scripts can detect it.
- if (signal) {
- const signalNumber = typeof os.constants.signals[signal] === 'number' ? os.constants.signals[signal] : 0
- process.exit(128 + signalNumber)
- }
- process.exit(code ?? 0)
-})
-child.on('error', (error) => {
- console.error('Failed to launch Pear:', error instanceof Error ? error.message : String(error))
- process.exit(1)
-})
+ child.on('close', (code, signal) => {
+ // A signal-terminated child reports code === null; surface that as a failure
+ // (128 + signal number, per shell convention) so calling scripts can detect it.
+ if (signal) {
+ const signalNumber = typeof os.constants.signals[signal] === 'number' ? os.constants.signals[signal] : 0
+ process.exit(128 + signalNumber)
+ }
+ process.exit(code ?? 0)
+ })
+ child.on('error', (error) => {
+ console.error('Failed to launch Pear:', error instanceof Error ? error.message : String(error))
+ process.exit(1)
+ })
+}
diff --git a/package.json b/package.json
index e3c19b1e..f2b480ac 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,11 @@
"preview": "electron-vite preview",
"dist:mac": "npm run icons:macos && npm run build && electron-builder --mac --publish never",
"verify:mcp-spawn": "node scripts/verify-mcp-spawn.mjs",
+ "factory": "node packages/factory-sdk/bin/fleet.mjs factory",
+ "factory:start": "node packages/factory-sdk/bin/fleet.mjs factory start --mode live",
+ "factory:run-once": "node packages/factory-sdk/bin/fleet.mjs factory run-once",
+ "factory:reap": "node packages/factory-sdk/bin/fleet.mjs factory reap-orphans",
+ "factory:status": "node packages/factory-sdk/bin/fleet.mjs factory loop-status",
"release:mac": "npm run icons:macos && npm run build && electron-builder --mac --publish always",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json",
diff --git a/packages/factory-sdk/README.md b/packages/factory-sdk/README.md
new file mode 100644
index 00000000..28ad9f0f
--- /dev/null
+++ b/packages/factory-sdk/README.md
@@ -0,0 +1,236 @@
+# @pear/factory-sdk
+
+The autonomous issue factory: a loop that discovers Linear issues, triages them,
+dispatches agents to implement fixes, opens PRs, drives them to completion through
+a merge gate, and closes the issues — all under a hard safety scope.
+
+The CLI binary is **`fleet`** (`bin/fleet.mjs`). It is an unpublished workspace
+package, so from a repo checkout you invoke it through one of these — both work
+with no install step:
+
+```bash
+# A root npm script (pass flags after `--`)
+npm run factory:start -- --config
+
+# Or directly
+node packages/factory-sdk/bin/fleet.mjs factory --config
+```
+
+Once you've installed the `pear` command (see below), the ergonomic form is:
+
+```bash
+pear factory [options]
+```
+
+All three are equivalent; `pear factory …` simply forwards to `fleet factory …`.
+
+### Installing the `pear` command (operators)
+
+The factory is an operator tool, run from a repo checkout. `pear` is declared as
+this repo's `bin` (`bin/pear.mjs`), but npm does not put a package's own bin on
+your PATH automatically — so link it once. This is the standard way to install a
+local Node CLI for development, not a workaround:
+
+```bash
+npm link # from the repo root — creates a global `pear` → bin/pear.mjs
+# now, from anywhere:
+pear factory start --mode live --config ./factory.config.json
+```
+
+To remove it later: `npm unlink -g pear-by-agent-relay`.
+
+> **Note:** the packaged Pear `.app` also offers *"Install 'pear' command in
+> PATH"* (Pear menu), but that installs the **GUI** launcher for `pear open `
+> only — the factory CLI is not bundled into the app. `pear factory …` is for
+> operators running from the repo via `npm link` (or the `node`/`npm run` forms
+> above).
+
+> **Heads-up:** every `factory` action requires `--config ` (see
+> [Configuration](#configuration)). Commands fail fast with
+> `factory commands require --config ` if it's missing.
+
+---
+
+## Quick start
+
+```bash
+# One-shot batch — discover + triage + dispatch one cycle, then exit.
+# Add --dry-run to plan without writing or spawning anything.
+pear factory run-once --config ./factory.config.json --dry-run
+
+# Live daemon — subscription-driven; runs until SIGINT/SIGTERM.
+pear factory start --mode live --config ./factory.config.json
+```
+
+## Commands
+
+| Command | What it does |
+|---|---|
+| `factory run-once` | One discovery→triage→dispatch cycle, then exit. Honors `--dry-run`. |
+| `factory loop` | Bounded multi-iteration loop (`loop.maxIterations`), then exit. |
+| `factory start --mode live` | Long-lived daemon. Subscription-driven; writes + refreshes a loop heartbeat. The production entrypoint. |
+| `factory status` | Print the in-memory factory status as JSON. |
+| `factory loop-status` | Read the heartbeat file and report liveness (stale vs. alive). |
+| `factory kill-loop` | Send SIGTERM to the daemon PID recorded in the heartbeat. |
+| `factory reap-orphans` | Crash backstop: reap orphaned agent pairs whose heartbeat is stale. Run as a scheduled job. |
+| `factory triage ` | Triage a single issue and print the decision. |
+| `factory dispatch ` | Triage + dispatch a single issue. Honors `--dry-run`. |
+| `factory close-probe --repo --issue ` | Manually close a synthetic E2E probe PR. |
+
+Global options (accepted anywhere in the args): `--config `,
+`--dry-run`, `--backend `.
+
+## The 2-process production model
+
+The factory runs as **two** coordinated processes (see issue #321 §4):
+
+1. **Live daemon** — `pear factory start --mode live --config `. Drives the
+ loop and refreshes a heartbeat file.
+2. **External reaper** — a *scheduled* `pear factory reap-orphans --config `
+ that acts as a crash backstop, cleaning up orphaned agent pairs if the daemon dies.
+
+> **HARD precondition:** the reaper **must** use the **same `--config`** as the
+> live daemon. A mismatched config reaps nothing *and* leaves the backstop broken
+> (the coupling is load-bearing — see §7 of issue #321).
+
+### Other operating preconditions (issue #321 §7)
+
+- **`gh`-authenticated environment.** The gh-resolver is completion-load-bearing
+ while the cloud GitHub→mount PR-sync is degraded; a `gh` auth drop halts completion.
+- Run the real binary path (`node bin/fleet.mjs` / `pear factory`), not a shim.
+
+## Configuration
+
+Pass a JSON file via `--config`. The full schema (with every default) lives in
+[`src/config/schema.ts`](src/config/schema.ts), validated by Zod at load time —
+an invalid config fails fast with a field-level error.
+
+The file is either a bare config object, or a `{ "factoryConfig": { … } }`
+wrapper (the wrapper form also allows a sibling `fixtureFiles` map — see
+[Fixture mode](#fixture-mode-offline-testing)).
+
+### Minimal config
+
+Only two fields are required — `workspaceId` and `repos.byLabel`. Everything else
+has a default:
+
+```json
+{
+ "workspaceId": "your-workspace-id",
+ "repos": {
+ "byLabel": { "pear": "AgentWorkforce/pear" },
+ "default": "AgentWorkforce/pear"
+ }
+}
+```
+
+### Complete annotated config
+
+A realistic live config, showing the fields you'll actually want to set (comments
+are illustrative — strip them; JSON has no comments):
+
+```jsonc
+{
+ // relayfile cloud mount workspace id — the workspace whose /linear and /slack
+ // trees the factory reads. Same id the Pear app uses for this workspace.
+ "workspaceId": "ws_abc123",
+
+ // Which Linear issues to pull. Empty arrays = no filter on that dimension.
+ // Values are Linear team keys / project names / label names / assignee ids.
+ "subscription": {
+ "teams": ["AR"],
+ "labels": ["pear"],
+ "projects": [],
+ "assignees": []
+ },
+
+ // Issue → repo routing. Precedence (first match wins):
+ // byLabel → byProject → keywordRules (regex on title/desc) → default
+ // No match and no default → the issue is escalated, never dispatched.
+ "repos": {
+ "byLabel": { "pear": "AgentWorkforce/pear", "cloud": "AgentWorkforce/cloud" },
+ "byProject": { "Pear": "AgentWorkforce/pear" },
+ "keywordRules": [{ "pattern": "relayfile|mount", "repo": "AgentWorkforce/relayfile" }],
+ // Where each repo is checked out locally for the agent to work in.
+ "clonePaths": { "AgentWorkforce/pear": "/Users/you/Projects/pear" },
+ "default": "AgentWorkforce/pear"
+ },
+
+ // SAFETY GATE — an issue is only dispatched if BOTH hold (see below):
+ "safety": {
+ "requireTitlePrefix": "[factory-e2e]",
+ "requireTeamKey": "AR"
+ },
+
+ "batchSize": 5, // max issues dispatched per cycle
+ "dispatch": { "maxAttempts": 2, "errorCooldownMs": 60000 },
+ "mergePolicy": "never", // see mergePolicy note below
+ "models": { // optional per-role model overrides
+ "implementer": "claude-opus-4-8",
+ "reviewer": "claude-opus-4-8",
+ "triage": "claude-haiku-4-5-20251001"
+ },
+ "slack": { "channel": "C0B902XR6PN" }, // optional threaded status updates
+ "loop": {
+ "heartbeatPath": "/tmp/factory-run/factory-loop-heartbeat.json",
+ "registryPath": "/tmp/factory-run/factory-loop-registry.json",
+ "heartbeatStaleMs": 60000
+ }
+}
+```
+
+### Where the values come from
+
+- **`workspaceId`** *(required)* — the relayfile cloud mount workspace id for this
+ workspace (the same one the Pear app mounts `/linear` and `/slack` under).
+- **`repos.byLabel`** *(required)* — map a Linear label → `owner/repo`. The other
+ routing maps are optional; **precedence is `byLabel` → `byProject` →
+ `keywordRules` → `default`**, else the issue is escalated (never dispatched).
+- **`repos.clonePaths`** — `owner/repo` → local working-tree path. Without an entry
+ the agent has nowhere to apply changes for that repo, so set it for every repo
+ you actually dispatch to.
+- **`stateIds`** — the Linear workflow-state UUIDs for `readyForAgent`,
+ `agentImplementing`, `done`, `inPlanning`. Defaults are the **AR team's** states
+ (see [`src/constants/linear.ts`](src/constants/linear.ts)). If you run against a
+ different Linear team, override all four with that team's state UUIDs (read them
+ from the Linear API / the issue JSON's `state.id`).
+- **`safety`** — the scope gate (below). Defaults `[factory-e2e]` + team `AR`.
+
+### The safety gate (what actually gets dispatched)
+
+`isInFactoryScope` ([`src/safety/factory-scope.ts`](src/safety/factory-scope.ts))
+dispatches an issue only when **both** are true:
+
+1. The issue **title starts with `safety.requireTitlePrefix`** — exactly
+ `[factory-e2e]`, or `[factory-e2e] `. Anything else is out of scope.
+2. The issue's **team key equals `safety.requireTeamKey`** (`AR`).
+
+This is why `factory run-once` may pull issues but dispatch none — they're real
+issues that fall outside the gate. Loosen the gate deliberately; it's the primary
+guardrail against the factory acting on issues it shouldn't.
+
+### Other notable fields (defaults in parentheses)
+
+- `mergePolicy` (`never`) — `never` keeps PRs open; `on-green-with-review` enables
+ autonomous merge on green + approved review. **Stays `never` until the flip is
+ thrown** (issue #321 §6).
+- `loop.heartbeatPath` / `loop.registryPath` / `loop.heartbeatStaleMs` (`60000`) —
+ daemon/reaper coupling (the reaper must point at the same paths via the same
+ `--config`).
+- `batchSize` (`5`), `dispatch.maxAttempts` (`2`), `dispatch.errorCooldownMs`
+ (`60000`), `models.{implementer,reviewer,triage}`, `slack.channel`,
+ `subscription.*`, `liveSubscription.*`, `stateIds.*`.
+
+### Fixture mode (offline testing)
+
+If the config (or a `{ "factoryConfig": …, "fixtureFiles": … }` wrapper) includes a
+`fixtureFiles` map, the CLI swaps in fake fleet + mount clients backed by those files —
+no cloud, no real agents. See [`test/fixtures/factory.config.json`](test/fixtures/factory.config.json).
+
+## Notes
+
+- **`pear factory …` does not launch the Electron app.** It detects the `factory`
+ verb in `bin/pear.mjs` and shells out to `fleet.mjs` in a plain Node process;
+ the daemon is headless by design.
+- First run builds the CLI with esbuild into `node_modules/.cache/pear-factory-sdk`;
+ pass `--rebuild` (or `FLEET_FORCE_BUILD=1`) to force a rebuild.