From 2620175371cbb52f80948ae408e6fe2efff07158 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 14 Jun 2026 10:15:42 +0200 Subject: [PATCH 1/3] Add factory runbook docs, npm scripts, and `pear factory` passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The factory-sdk autonomous loop (issue #321) had no operator-facing docs and no convenient entrypoint — the only invocation was the verbose `node packages/factory-sdk/bin/fleet.mjs factory … --config `, and the `pear` binary (an Electron launcher) had no `factory` verb. - bin/pear.mjs: intercept `pear factory …` and forward verbatim to the factory-sdk `fleet` CLI in a plain Node process. Deliberately does NOT launch Electron — the daemon/reaper are headless by design. Exit codes and signals propagate. - package.json: add factory / factory:start / factory:run-once / factory:reap / factory:status npm scripts. - packages/factory-sdk/README.md: quick start, command table, the 2-process production model (daemon + same-`--config` reaper coupling), gh-auth precondition, minimal config + annotated fields, and fixture mode. No behavior change: mergePolicy still defaults to `never`; the autonomous merge-flip remains pending per #321. Co-Authored-By: Claude Opus 4.8 (1M context) --- bin/pear.mjs | 73 ++++++++++++++------ package.json | 5 ++ packages/factory-sdk/README.md | 119 +++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 packages/factory-sdk/README.md 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..aaa3c4a1 --- /dev/null +++ b/packages/factory-sdk/README.md @@ -0,0 +1,119 @@ +# @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 you invoke it through one of: + +```bash +# 1. The pear launcher passthrough (no Electron app is launched) +pear factory [options] + +# 2. A root npm script +npm run factory:start -- --config + +# 3. Directly +node packages/factory-sdk/bin/fleet.mjs factory --config +``` + +All three are equivalent; `pear factory …` simply forwards to `fleet factory …`. + +> **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 schema lives in +[`src/config/schema.ts`](src/config/schema.ts). Minimal config: + +```json +{ + "workspaceId": "your-workspace-id", + "repos": { + "byLabel": { "pear": "AgentWorkforce/pear" }, + "default": "AgentWorkforce/pear" + } +} +``` + +Notable fields (all optional unless noted, defaults in parentheses): + +- `workspaceId` *(required)* — relayfile cloud mount workspace. +- `repos.byLabel` *(required)* — map Linear label → `owner/repo`. Also + `repos.byProject`, `repos.keywordRules`, `repos.clonePaths`, `repos.default`. +- `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). +- `safety.requireTitlePrefix` (`[factory-e2e]`) + `safety.requireTeamKey` (`AR`) — + the scope gate. Issues outside scope are never dispatched. +- `loop.heartbeatPath` (`/tmp/factory-run/factory-loop-heartbeat.json`), + `loop.registryPath`, `loop.heartbeatStaleMs` (`60000`) — daemon/reaper coupling. +- `batchSize` (`5`), `dispatch.maxAttempts` (`2`), `models.{implementer,reviewer,triage}`, + `slack.channel`, `subscription.*`, `liveSubscription.*`. + +### 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. From 824302a5eedffd51efb9851a8943a56bbb85fac7 Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 14 Jun 2026 10:25:32 +0200 Subject: [PATCH 2/3] README: document `npm link` install + correct `pear factory` availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lead with the no-install forms (`npm run factory:*` / `node bin/fleet.mjs`), then document `npm link` as the standard way operators get the `pear` command — it is the normal local-Node-CLI install, not a workaround. Clarify that the packaged .app's "Install 'pear' command in PATH" installs the GUI launcher for `pear open` only; the factory-sdk is not bundled into the app, so `pear factory …` is operator-from-repo only. Avoids overstating that `pear factory` works out of the box. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/factory-sdk/README.md | 37 ++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/factory-sdk/README.md b/packages/factory-sdk/README.md index aaa3c4a1..267ef1f6 100644 --- a/packages/factory-sdk/README.md +++ b/packages/factory-sdk/README.md @@ -5,21 +5,46 @@ dispatches agents to implement fixes, opens PRs, drives them to completion throu 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 you invoke it through one of: +package, so from a repo checkout you invoke it through one of these — both work +with no install step: ```bash -# 1. The pear launcher passthrough (no Electron app is launched) -pear factory [options] - -# 2. A root npm script +# A root npm script (pass flags after `--`) npm run factory:start -- --config -# 3. Directly +# 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. From 24138853158636a2d15c3fbeb7793f1e2c3404dc Mon Sep 17 00:00:00 2001 From: Khaliq Date: Sun, 14 Jun 2026 10:28:52 +0200 Subject: [PATCH 3/3] README: complete the factory config instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config section only had a 4-line minimal example + a field list. Add: - a complete annotated config (subscription, repo routing, safety, models, loop) - where each value comes from (workspaceId, clonePaths, stateIds + the AR-team default UUIDs in src/constants/linear.ts) - the repo-routing precedence (byLabel → byProject → keywordRules → default) - the safety gate explained — title `[factory-e2e]` prefix AND team key AR — so it's clear why run-once can pull issues yet dispatch none Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/factory-sdk/README.md | 116 +++++++++++++++++++++++++++++---- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/packages/factory-sdk/README.md b/packages/factory-sdk/README.md index 267ef1f6..28ad9f0f 100644 --- a/packages/factory-sdk/README.md +++ b/packages/factory-sdk/README.md @@ -101,8 +101,18 @@ The factory runs as **two** coordinated processes (see issue #321 §4): ## Configuration -Pass a JSON file via `--config`. The schema lives in -[`src/config/schema.ts`](src/config/schema.ts). Minimal config: +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 { @@ -114,20 +124,102 @@ Pass a JSON file via `--config`. The schema lives in } ``` -Notable fields (all optional unless noted, defaults in parentheses): +### 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) -- `workspaceId` *(required)* — relayfile cloud mount workspace. -- `repos.byLabel` *(required)* — map Linear label → `owner/repo`. Also - `repos.byProject`, `repos.keywordRules`, `repos.clonePaths`, `repos.default`. - `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). -- `safety.requireTitlePrefix` (`[factory-e2e]`) + `safety.requireTeamKey` (`AR`) — - the scope gate. Issues outside scope are never dispatched. -- `loop.heartbeatPath` (`/tmp/factory-run/factory-loop-heartbeat.json`), - `loop.registryPath`, `loop.heartbeatStaleMs` (`60000`) — daemon/reaper coupling. -- `batchSize` (`5`), `dispatch.maxAttempts` (`2`), `models.{implementer,reviewer,triage}`, - `slack.channel`, `subscription.*`, `liveSubscription.*`. +- `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)