@agent-relay/factory 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +146 -205
  2. package/dist/cli/fleet.d.ts +5 -1
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +86 -15
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/schema.d.ts +4 -4
  7. package/dist/config/schema.d.ts.map +1 -1
  8. package/dist/config/schema.js +13 -2
  9. package/dist/config/schema.js.map +1 -1
  10. package/dist/constants/linear.d.ts +2 -0
  11. package/dist/constants/linear.d.ts.map +1 -1
  12. package/dist/constants/linear.js +6 -0
  13. package/dist/constants/linear.js.map +1 -1
  14. package/dist/dispatch/templates.d.ts +18 -2
  15. package/dist/dispatch/templates.d.ts.map +1 -1
  16. package/dist/dispatch/templates.js +18 -10
  17. package/dist/dispatch/templates.js.map +1 -1
  18. package/dist/fleet/create-fleet.d.ts +3 -2
  19. package/dist/fleet/create-fleet.d.ts.map +1 -1
  20. package/dist/fleet/create-fleet.js +3 -2
  21. package/dist/fleet/create-fleet.js.map +1 -1
  22. package/dist/fleet/ensure-relay-broker.d.ts +24 -0
  23. package/dist/fleet/ensure-relay-broker.d.ts.map +1 -0
  24. package/dist/fleet/ensure-relay-broker.js +56 -0
  25. package/dist/fleet/ensure-relay-broker.js.map +1 -0
  26. package/dist/fleet/internal-fleet-client.d.ts +8 -3
  27. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  28. package/dist/fleet/internal-fleet-client.js +32 -5
  29. package/dist/fleet/internal-fleet-client.js.map +1 -1
  30. package/dist/fleet/relay-fleet-client.d.ts +90 -13
  31. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  32. package/dist/fleet/relay-fleet-client.js +496 -20
  33. package/dist/fleet/relay-fleet-client.js.map +1 -1
  34. package/dist/github/index.d.ts +4 -4
  35. package/dist/github/index.js +2 -2
  36. package/dist/github/probe-closer.d.ts +1 -1
  37. package/dist/github/probe-closer.js +1 -1
  38. package/dist/index.d.ts +33 -29
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +17 -15
  41. package/dist/index.js.map +1 -1
  42. package/dist/linear/state-resolver.d.ts +3 -2
  43. package/dist/linear/state-resolver.d.ts.map +1 -1
  44. package/dist/linear/state-resolver.js +74 -10
  45. package/dist/linear/state-resolver.js.map +1 -1
  46. package/dist/mount/local-mount-preflight.d.ts +1 -0
  47. package/dist/mount/local-mount-preflight.d.ts.map +1 -1
  48. package/dist/mount/local-mount-preflight.js +64 -12
  49. package/dist/mount/local-mount-preflight.js.map +1 -1
  50. package/dist/mount/relayfile-binary.d.ts +2 -0
  51. package/dist/mount/relayfile-binary.d.ts.map +1 -1
  52. package/dist/mount/relayfile-binary.js +39 -5
  53. package/dist/mount/relayfile-binary.js.map +1 -1
  54. package/dist/mount/relayfile-cloud-mount-client.d.ts +2 -2
  55. package/dist/mount/relayfile-cloud-mount-client.js +1 -1
  56. package/dist/node/factory-node.d.ts +59 -0
  57. package/dist/node/factory-node.d.ts.map +1 -0
  58. package/dist/node/factory-node.js +351 -0
  59. package/dist/node/factory-node.js.map +1 -0
  60. package/dist/node/factory.node.d.ts +3 -0
  61. package/dist/node/factory.node.d.ts.map +1 -0
  62. package/dist/node/factory.node.js +5 -0
  63. package/dist/node/factory.node.js.map +1 -0
  64. package/dist/orchestrator/batch-tracker.d.ts +2 -2
  65. package/dist/orchestrator/factory.d.ts +5 -2
  66. package/dist/orchestrator/factory.d.ts.map +1 -1
  67. package/dist/orchestrator/factory.js +413 -59
  68. package/dist/orchestrator/factory.js.map +1 -1
  69. package/dist/orchestrator/index.d.ts +4 -4
  70. package/dist/orchestrator/index.d.ts.map +1 -1
  71. package/dist/orchestrator/index.js +3 -3
  72. package/dist/orchestrator/index.js.map +1 -1
  73. package/dist/orchestrator/process-identity.d.ts +1 -1
  74. package/dist/orchestrator/reaper.d.ts +3 -3
  75. package/dist/orchestrator/reaper.js +3 -3
  76. package/dist/ports/fleet.d.ts +8 -2
  77. package/dist/ports/fleet.d.ts.map +1 -1
  78. package/dist/ports/index.d.ts +4 -4
  79. package/dist/ports/state.d.ts +3 -3
  80. package/dist/ports/writeback.d.ts +1 -1
  81. package/dist/safety/factory-scope.d.ts +2 -2
  82. package/dist/state/in-memory-state-store.d.ts +1 -1
  83. package/dist/state/in-memory-state-store.js +1 -1
  84. package/dist/subscriptions/event-client.d.ts +1 -1
  85. package/dist/subscriptions/event-client.js +2 -2
  86. package/dist/subscriptions/globs.js +1 -1
  87. package/dist/subscriptions/index.d.ts +7 -7
  88. package/dist/subscriptions/index.js +4 -4
  89. package/dist/subscriptions/linear-filter.d.ts +1 -1
  90. package/dist/subscriptions/linear-filter.js +1 -1
  91. package/dist/subscriptions/specs.d.ts +1 -1
  92. package/dist/subscriptions/specs.js +2 -2
  93. package/dist/testing/fakes.d.ts +1 -1
  94. package/dist/testing/fakes.js +1 -1
  95. package/dist/testing/fakes.js.map +1 -1
  96. package/dist/testing/index.d.ts +1 -1
  97. package/dist/testing/index.js +1 -1
  98. package/dist/triage/heuristic.d.ts +8 -4
  99. package/dist/triage/heuristic.d.ts.map +1 -1
  100. package/dist/triage/heuristic.js +70 -6
  101. package/dist/triage/heuristic.js.map +1 -1
  102. package/dist/triage/index.d.ts +6 -6
  103. package/dist/triage/index.d.ts.map +1 -1
  104. package/dist/triage/index.js +4 -4
  105. package/dist/triage/index.js.map +1 -1
  106. package/dist/triage/llm.d.ts +1 -1
  107. package/dist/triage/llm.d.ts.map +1 -1
  108. package/dist/triage/llm.js +5 -3
  109. package/dist/triage/llm.js.map +1 -1
  110. package/dist/triage/schema.d.ts +133 -29
  111. package/dist/triage/schema.d.ts.map +1 -1
  112. package/dist/triage/schema.js +6 -3
  113. package/dist/triage/schema.js.map +1 -1
  114. package/dist/triage/tiered.d.ts +1 -1
  115. package/dist/triage/tiered.d.ts.map +1 -1
  116. package/dist/triage/tiered.js +9 -2
  117. package/dist/triage/tiered.js.map +1 -1
  118. package/dist/types.d.ts +10 -9
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/writeback/github.d.ts +2 -2
  121. package/dist/writeback/github.js +1 -1
  122. package/dist/writeback/index.d.ts +5 -5
  123. package/dist/writeback/index.js +3 -3
  124. package/dist/writeback/linear.d.ts +3 -3
  125. package/dist/writeback/linear.d.ts.map +1 -1
  126. package/dist/writeback/linear.js +57 -14
  127. package/dist/writeback/linear.js.map +1 -1
  128. package/dist/writeback/slack.d.ts +1 -1
  129. package/dist/writeback/slack.js +2 -2
  130. package/package.json +10 -2
  131. package/scripts/com.agentrelay.factory-canary.plist.example +53 -0
  132. package/scripts/factory-canary.sh +89 -0
package/README.md CHANGED
@@ -1,248 +1,189 @@
1
- # @agent-relay/factory
1
+ # Agent Workforce Factory
2
2
 
3
- The autonomous issue factory: a loop that discovers Linear issues, triages them,
4
- dispatches agents to implement fixes, opens PRs, drives them to completion through
5
- a merge gate, and closes the issues — all under a hard safety scope.
3
+ **Turn issues into reviewed pull requests, automatically.**
6
4
 
7
- The package publishes the **`factory`** CLI (`bin/factory.mjs`). From a repo
8
- checkout, build first and invoke the CLI through the package bin:
5
+ Point the factory at your ticketing system (Linear, GitHub, Shortcut, Asana etc) and it does the loop a human
6
+ otherwise babysits: it discovers the issues that are ready, decides how to tackle
7
+ each one, spawns coding agents to implement and review the change, opens a PR,
8
+ drives it through a merge gate, and closes the issue — all inside a safety scope
9
+ you define, so it only ever acts on work you've explicitly opted in.
9
10
 
10
- ```bash
11
- npm ci
12
- npm run build
11
+ ## Why use it
12
+
13
+ - **Clear the small-but-real backlog.** The well-scoped fixes and chores that pile
14
+ up get done without a person shepherding each one.
15
+ - **It only touches what you allow.** A safety gate (title prefix + team) means
16
+ the factory dispatches *exactly* the issues you mark for it and ignores
17
+ everything else — opt-in by construction.
18
+ - **Real PRs, not blind merges.** Every change goes through an implement → review
19
+ → merge-gate flow and lands as a normal PR. It defaults to *never* auto-merging
20
+ until you turn that on.
21
+ - **You stay in the loop.** It posts threaded status to Slack and can ask a human
22
+ for clarification mid-task when an issue is ambiguous.
23
+ - **Drop in by label.** Hand it new work just by labeling a Linear or GitHub
24
+ issue — no new tooling in your day-to-day.
25
+
26
+ A good fit when you have a steady stream of scoped issues and want them turned
27
+ into PRs without standing up your own agent orchestration.
28
+
29
+ ## How it works
13
30
 
14
- node bin/factory.mjs factory <action> --config <cfg>
15
31
  ```
32
+ discover ready issues → triage (how to do it) → dispatch agents (implement + review)
33
+ → open PR → merge gate → close issue
34
+ ```
35
+
36
+ Each step is gated by your config and the safety scope. Issues outside the scope
37
+ are pulled but never dispatched.
16
38
 
17
- Once installed from npm, the ergonomic form is:
39
+ ## Install
18
40
 
19
41
  ```bash
20
- factory factory <action> [options]
42
+ npm install @agent-relay/factory
21
43
  ```
22
44
 
23
- > **Heads-up:** every `factory` action requires `--config <path>` (see
24
- > [Configuration](#configuration)). Commands fail fast with
25
- > `factory commands require --config <path>` if it's missing.
45
+ The factory talks to a relay broker through the **`agent-relay`** sidecar; install
46
+ and sign in to that separately (it's a peer of this package). Once installed, the
47
+ CLI is available as `factory`:
26
48
 
27
- ---
49
+ ```bash
50
+ factory factory run-once --config ./factory.config.json --dry-run
51
+ ```
52
+
53
+ > Yes, `factory factory` — the `factory` CLI groups its commands under a `factory`
54
+ > subcommand. From a source checkout instead of an npm install, run
55
+ > `npm ci && npm run build` first, then `node bin/factory.mjs factory <action> …`.
28
56
 
29
57
  ## Quick start
30
58
 
31
- ```bash
32
- # One-shot batch — discover + triage + dispatch one cycle, then exit.
33
- # Add --dry-run to plan without writing or spawning anything.
34
- factory factory run-once --config ./factory.config.json --dry-run
59
+ 1. **Write a minimal config** (`factory.config.json`). Only `workspaceId` and a
60
+ repo route are required:
35
61
 
36
- # Live daemon — subscription-driven; runs until SIGINT/SIGTERM.
37
- factory factory start --mode live --config ./factory.config.json
38
- ```
62
+ ```json
63
+ {
64
+ "workspaceId": "your-workspace-id",
65
+ "repos": {
66
+ "byLabel": { "pear": "AgentWorkforce/pear" },
67
+ "clonePaths": { "AgentWorkforce/pear": "/path/to/your/pear/checkout" },
68
+ "default": "AgentWorkforce/pear"
69
+ }
70
+ }
71
+ ```
72
+
73
+ `workspaceId` is your relay workspace; `repos.byLabel` maps a Linear label to a
74
+ repo; `clonePaths` tells the agent where that repo lives locally so it has
75
+ somewhere to make changes.
76
+
77
+ 2. **Plan a cycle without touching anything** — `--dry-run` discovers and triages
78
+ but writes nothing and spawns no agents:
79
+
80
+ ```bash
81
+ factory factory run-once --config ./factory.config.json --dry-run
82
+ ```
83
+
84
+ 3. **Let it work for real:**
85
+
86
+ ```bash
87
+ # One discovery→dispatch cycle, then exit.
88
+ factory factory run-once --config ./factory.config.json
89
+
90
+ # Or run continuously as a daemon (the production form).
91
+ factory factory start --mode live --config ./factory.config.json
92
+ ```
93
+
94
+ > **Pulled some issues but dispatched none?** That's the safety gate doing its
95
+ > job — the issues are real but outside your scope. See
96
+ > [Tell it what to work on](#tell-it-what-to-work-on).
39
97
 
40
98
  ## Commands
41
99
 
42
100
  | Command | What it does |
43
101
  |---|---|
44
- | `factory run-once` | One discovery→triage→dispatch cycle, then exit. Honors `--dry-run`. |
45
- | `factory loop` | Bounded multi-iteration loop (`loop.maxIterations`), then exit. |
46
- | `factory start --mode live` | Long-lived daemon. Subscription-driven; writes + refreshes a loop heartbeat. The production entrypoint. |
47
- | `factory status` | Print the in-memory factory status as JSON. |
48
- | `factory loop-status` | Read the heartbeat file and report liveness (stale vs. alive). |
49
- | `factory kill-loop` | Send SIGTERM to the daemon PID recorded in the heartbeat. |
50
- | `factory reap-orphans` | Crash backstop: reap orphaned agent pairs whose heartbeat is stale. Run as a scheduled job. |
51
- | `factory triage <KEY\|path>` | Triage a single issue and print the decision. |
52
- | `factory dispatch <KEY\|path>` | Triage + dispatch a single issue. Honors `--dry-run`. |
53
- | `factory close-probe <PR#> --repo <owner/repo> --issue <KEY>` | Manually close a synthetic E2E probe PR. |
54
-
55
- Global options (accepted anywhere in the args): `--config <path>`,
56
- `--dry-run`, `--backend <internal\|relay>`.
102
+ | `factory run-once` | One discover→triage→dispatch cycle, then exit. Honors `--dry-run`. |
103
+ | `factory loop` | A bounded multi-iteration loop, then exit. |
104
+ | `factory start --mode live` | Long-lived daemon the production entrypoint. Runs until you stop it. |
105
+ | `factory status` | Print current factory status as JSON. |
106
+ | `factory triage <KEY\|path>` | Triage one issue and print the decision. |
107
+ | `factory dispatch <KEY\|path>` | Triage + dispatch one issue. Honors `--dry-run`. |
108
+ | `factory canary <KEY\|path>` | Assert a known "Ready for Agent" issue is dispatch-ready by the real dry-run triage path. Prints `{ok,issue,status,reason}`; exits non-zero (with the skip reason) if it isn't. |
57
109
 
58
- ## The 2-process production model
110
+ Global options work anywhere in the args: `--config <path>`, `--dry-run`,
111
+ `--backend <internal|relay>`. The internal backend reuses a relay broker that's
112
+ already running for your workspace, and starts one if none is.
59
113
 
60
- The factory runs as **two** coordinated processes (see issue #321 §4):
114
+ (There are a few more operational commands `loop-status`, `kill-loop`,
115
+ `reap-orphans`, `close-probe` — for running the daemon in production.)
61
116
 
62
- 1. **Live daemon** — `factory factory start --mode live --config <live>`. Drives the
63
- loop and refreshes a heartbeat file.
64
- 2. **External reaper** — a *scheduled* `factory factory reap-orphans --config <live>`
65
- that acts as a crash backstop, cleaning up orphaned agent pairs if the daemon dies.
117
+ ### Scheduled sync-fidelity canary
66
118
 
67
- > **HARD precondition:** the reaper **must** use the **same `--config`** as the
68
- > live daemon. A mismatched config reaps nothing *and* leaves the backstop broken
69
- > (the coupling is load-bearing see §7 of issue #321).
119
+ `factory canary` is the regression detector for upstream sync drift: if a synced
120
+ issue stops carrying enough state to be dispatchable (e.g. the Linear sync
121
+ regresses to records without `state.id`), a known-good issue flips from
122
+ dispatch-ready to skipped. Run it on a schedule against a standing "Ready for
123
+ Agent" canary issue and alert on failure.
70
124
 
71
- ### Other operating preconditions (issue #321 §7)
125
+ `scripts/factory-canary.sh` wraps the command for cron/launchd: it runs from your
126
+ deployment dir (reusing the running relay broker), bounds a hung run, and posts a
127
+ Slack alert via `FACTORY_CANARY_SLACK_WEBHOOK` on failure. See
128
+ `scripts/com.agentrelay.factory-canary.plist.example` for an every-6h launchd
129
+ template.
72
130
 
73
- - **`gh`-authenticated environment.** The gh-resolver is completion-load-bearing
74
- while the cloud GitHub→mount PR-sync is degraded; a `gh` auth drop halts completion.
75
- - Run the real binary path (`factory` / `node bin/factory.mjs`), not a shim.
131
+ ## Tell it what to work on
76
132
 
77
- ## Configuration
133
+ Two ways to hand the factory an issue — both are just labeling/titling, nothing
134
+ to install:
78
135
 
79
- Pass a JSON file via `--config`. The full schema (with every default) lives in
80
- [`src/config/schema.ts`](src/config/schema.ts), validated by Zod at load time —
81
- an invalid config fails fast with a field-level error.
136
+ | Source | What you do | Result |
137
+ |---|---|---|
138
+ | **Linear** | Title it `[factory] <task>`, set the team + a repo label, move it to **Ready for Agent** | dispatched directly |
139
+ | **GitHub** | Add the **`factory`** label to the issue | mirrored into a `[factory]` Linear issue, then dispatched |
82
140
 
83
- The file is either a bare config object, or a `{ "factoryConfig": { … } }`
84
- wrapper (the wrapper form also allows a sibling `fixtureFiles` map see
85
- [Fixture mode](#fixture-mode-offline-testing)).
141
+ The **safety gate** is what keeps this opt-in: by default the factory only
142
+ dispatches an issue whose **title starts with your configured prefix** *and* whose
143
+ **team matches** your configured team. Everything else is ignored. Loosen it
144
+ deliberately — it's the main guardrail.
86
145
 
87
- ### Minimal config
146
+ > Tip: `[factory-e2e]` is reserved for the factory's own self-test soak (its PRs
147
+ > auto-close). For real work you want to keep, use the `[factory]` prefix.
88
148
 
89
- Only two fields are required `workspaceId` and `repos.byLabel`. Everything else
90
- has a default:
149
+ ## Run it as a fleet node (optional)
91
150
 
92
- ```json
93
- {
94
- "workspaceId": "your-workspace-id",
95
- "repos": {
96
- "byLabel": { "pear": "AgentWorkforce/pear" },
97
- "default": "AgentWorkforce/pear"
98
- }
99
- }
100
- ```
151
+ The package also ships a fleet **node definition** so a machine can advertise
152
+ `spawn:claude` / `spawn:codex` / `workflow:run` to the cloud and run agents in the
153
+ checkouts it owns:
101
154
 
102
- ### Complete annotated config
103
-
104
- A realistic live config, showing the fields you'll actually want to set (comments
105
- are illustrative — strip them; JSON has no comments):
106
-
107
- ```jsonc
108
- {
109
- // relayfile cloud mount workspace id — the workspace whose /linear and /slack
110
- // trees the factory reads. Same id the Pear app uses for this workspace.
111
- "workspaceId": "ws_abc123",
112
-
113
- // Which Linear issues to pull. Empty arrays = no filter on that dimension.
114
- // Values are Linear team keys / project names / label names / assignee ids.
115
- "subscription": {
116
- "teams": ["AR"],
117
- "labels": ["pear"],
118
- "projects": [],
119
- "assignees": []
120
- },
121
-
122
- // Issue → repo routing. Precedence (first match wins):
123
- // byLabel → byProject → keywordRules (regex on title/desc) → default
124
- // No match and no default → the issue is escalated, never dispatched.
125
- "repos": {
126
- "byLabel": { "pear": "AgentWorkforce/pear", "cloud": "AgentWorkforce/cloud" },
127
- "byProject": { "Pear": "AgentWorkforce/pear" },
128
- "keywordRules": [{ "pattern": "relayfile|mount", "repo": "AgentWorkforce/relayfile" }],
129
- // Where each repo is checked out locally for the agent to work in.
130
- "clonePaths": { "AgentWorkforce/pear": "/Users/you/Projects/pear" },
131
- "default": "AgentWorkforce/pear"
132
- },
133
-
134
- // SAFETY GATE — an issue is only dispatched if BOTH hold (see below):
135
- "safety": {
136
- "requireTitlePrefix": "[factory-e2e]",
137
- "requireTeamKey": "AR"
138
- },
139
-
140
- "batchSize": 5, // max issues dispatched per cycle
141
- "dispatch": { "maxAttempts": 2, "errorCooldownMs": 60000 },
142
- "mergePolicy": "never", // see mergePolicy note below
143
- "models": { // optional per-role model overrides
144
- "implementer": "claude-opus-4-8",
145
- "reviewer": "claude-opus-4-8",
146
- "triage": "claude-haiku-4-5-20251001"
147
- },
148
- "slack": { "channel": "C0B902XR6PN" }, // optional threaded status updates
149
- "loop": {
150
- "heartbeatPath": "/tmp/factory-run/factory-loop-heartbeat.json",
151
- "registryPath": "/tmp/factory-run/factory-loop-registry.json",
152
- "heartbeatStaleMs": 60000
153
- }
154
- }
155
+ ```bash
156
+ agent-relay fleet serve @agent-relay/factory/node
155
157
  ```
156
158
 
157
- ### Where the values come from
158
-
159
- - **`workspaceId`** *(required)* — the relayfile cloud mount workspace id for this
160
- workspace (the same one the Pear app mounts `/linear` and `/slack` under).
161
- - **`repos.byLabel`** *(required)* — map a Linear label → `owner/repo`. The other
162
- routing maps are optional; **precedence is `byLabel` → `byProject` →
163
- `keywordRules` → `default`**, else the issue is escalated (never dispatched).
164
- - **`repos.clonePaths`** — `owner/repo` → local working-tree path. Without an entry
165
- the agent has nowhere to apply changes for that repo, so set it for every repo
166
- you actually dispatch to.
167
- - **`stateIds`** — the Linear workflow-state UUIDs for `readyForAgent`,
168
- `agentImplementing`, `done`, `inPlanning`, and optionally `humanReview`.
169
- Defaults cover the **AR team's** core states (see
170
- [`src/constants/linear.ts`](src/constants/linear.ts)). `humanReview` is
171
- intentionally omitted from the schema default so legacy configs fall back to
172
- direct-to-Done behavior unless they opt into a review state. If you run against
173
- a different Linear team, override the state UUIDs with that team's values (read
174
- them from the Linear API / the issue JSON's `state.id`).
175
- - **`safety`** — the scope gate (below). Defaults `[factory-e2e]` + team `AR`;
176
- GitHub issue mirrors created by the factory use `[factory]` and are accepted
177
- by the same gate.
178
-
179
- ### The safety gate (what actually gets dispatched)
180
-
181
- `isInFactoryScope` ([`src/safety/factory-scope.ts`](src/safety/factory-scope.ts))
182
- dispatches an issue only when **both** are true:
183
-
184
- 1. The issue **title starts with `safety.requireTitlePrefix`** — exactly
185
- `[factory-e2e]`, or `[factory-e2e] <rest>` by default. Factory-created
186
- GitHub mirrors titled `[factory] <GitHub title>` are also accepted. Anything
187
- else is out of scope.
188
- 2. The issue's **team key equals `safety.requireTeamKey`** (`AR`).
189
-
190
- This is why `factory run-once` may pull issues but dispatch none — they're real
191
- issues that fall outside the gate. Loosen the gate deliberately; it's the primary
192
- guardrail against the factory acting on issues it shouldn't.
193
-
194
- > **⚠️ `[factory-e2e]` is the synthetic self-test prefix — its PRs auto-close.**
195
- > The default `requireTitlePrefix` is `[factory-e2e]`, which is *also* the marker
196
- > the factory uses to identify synthetic E2E soak issues. A `[factory-e2e]`-titled
197
- > issue's PR is **auto-closed, never merged** (`#isSyntheticProbeIssue`) — great
198
- > for self-test soaks, wrong for real work. **For real issues you want to keep,
199
- > set `requireTitlePrefix` to `[factory]`** and title issues `[factory] <task>`;
200
- > reserve `[factory-e2e]` for the soak. The synthetic check matches `[factory-e2e]`
201
- > exactly, so `[factory]` issues are not auto-closed. (Note: `[factory-e2e]` does
202
- > **not** satisfy a `[factory]` gate — the prefixes differ at the `]`/`-`, so don't
203
- > mix them.)
204
-
205
- ### Ingesting GitHub issues (label `factory`)
206
-
207
- The factory also picks up **GitHub issues labeled `factory`** (case-insensitive),
208
- read from the mounted `/github/repos/**` tree (pushed in by the GitHub→relayfile
209
- sync — no API polling). For each, it creates one Linear mirror titled
210
- `[factory] <GitHub title>` in **Ready for Agent**, routes it via `repos.byLabel`,
211
- then dispatches it like any other Linear issue. Mirrors are deduped (one per
212
- GitHub issue) and set to `done` when the GitHub issue closes.
213
-
214
- So to hand the factory a GitHub issue you **just add the `factory` label** — you
215
- do **not** put `[factory]` in the GitHub title (the factory adds that to the
216
- Linear mirror). This is the cross-repo path: label, say, a `relay` repo issue
217
- `factory` and it flows in, routed to `AgentWorkforce/relay`.
159
+ It reads its node config from `./factory.node.json` (or `$FACTORY_NODE_CONFIG`).
160
+ Prefer to build the definition yourself?
218
161
 
219
- | Source | What you do | Result |
220
- |---|---|---|
221
- | Linear (direct) | title `[factory] <task>`, team AR, label = repo, Ready for Agent | dispatched directly |
222
- | GitHub (any configured repo) | add the **`factory` label** | mirrored to a `[factory]` Linear issue, then dispatched |
162
+ ```ts
163
+ import { createFactoryNodeDefinition, readFactoryNodeConfigSync } from '@agent-relay/factory'
223
164
 
224
- ### Other notable fields (defaults in parentheses)
165
+ export default createFactoryNodeDefinition({ config: readFactoryNodeConfigSync() })
166
+ ```
225
167
 
226
- - `mergePolicy` (`never`) — `never` keeps PRs open; `on-green-with-review` enables
227
- autonomous merge on green + approved review. **Stays `never` until the flip is
228
- thrown** (issue #321 §6).
229
- - `loop.heartbeatPath` / `loop.registryPath` / `loop.heartbeatStaleMs` (`60000`) —
230
- daemon/reaper coupling (the reaper must point at the same paths via the same
231
- `--config`).
232
- - `batchSize` (`5`), `dispatch.maxAttempts` (`2`), `dispatch.errorCooldownMs`
233
- (`60000`), `models.{implementer,reviewer,triage}`, `slack.channel`,
234
- `subscription.*`, `liveSubscription.*`, `stateIds.*`.
168
+ ## Configuration
235
169
 
236
- ### Fixture mode (offline testing)
170
+ Pass a JSON file via `--config`. Beyond the two required fields above, useful
171
+ knobs include issue **routing** (`repos.byLabel` / `byProject` / `keywordRules` /
172
+ `default`), the **safety gate** (`safety.requireTitlePrefix`, `safety.requireTeamKey`),
173
+ `mergePolicy` (defaults to `never`), per-role **model** overrides, and an optional
174
+ **Slack** channel for status threads.
237
175
 
238
- If the config (or a `{ "factoryConfig": …, "fixtureFiles": }` wrapper) includes a
239
- `fixtureFiles` map, the CLI swaps in fake fleet + mount clients backed by those files —
240
- no cloud, no real agents. See [`test/fixtures/factory.config.json`](test/fixtures/factory.config.json).
176
+ The full schema every field and default is validated by Zod at load time, so
177
+ an invalid config fails fast with a field-level error. See
178
+ [`src/config/schema.ts`](src/config/schema.ts) for the authoritative reference,
179
+ and [`test/fixtures/factory.config.json`](test/fixtures/factory.config.json) for a
180
+ worked example (including offline fixture mode).
241
181
 
242
182
  ## Notes
243
183
 
244
- - The daemon is headless by design. Pear consumes this package and may expose its
245
- own `pear factory ...` passthrough, but this repository's published CLI is
246
- `factory`.
247
- - Build output lives in `dist/`; `npm pack --dry-run` should include only
248
- `dist/`, `bin/factory.mjs`, `package.json`, and `README.md`.
184
+ - The daemon is headless by design; tools like Pear can consume this package and
185
+ wrap it, but the published CLI is `factory`.
186
+ - The published `dist/` is plain ESM, runnable directly by Node (`node bin/factory.mjs`)
187
+ and importable by ESM consumers.
188
+ - For production operation (the live-daemon + reaper backstop model, heartbeats,
189
+ and `gh`-auth preconditions), see the operations notes alongside the config schema.
@@ -1,9 +1,10 @@
1
- import { createFactory, createFleet, type Capability, type Factory, type FactoryConfig, type FleetBackend, type FleetClient, type GhRunner, type FactoryStateResolution, type MountClient, type ProbeCloser, type RelayfileCloudMountClientConfig, type ResolvedFactoryWorkspace } from '../index';
1
+ import { createFactory, createFleet, ensureRelayBroker, type Capability, type Factory, type FactoryConfig, type FleetBackend, type FleetClient, type GhRunner, type FactoryStateResolution, type MountClient, type ProbeCloser, type RelayfileCloudMountClientConfig, type ResolvedFactoryWorkspace } from '../index.js';
2
2
  interface FleetCliDeps {
3
3
  fleet?: FleetClient;
4
4
  mount?: MountClient;
5
5
  createFactory?: typeof createFactory;
6
6
  createFleet?: typeof createFleet;
7
+ ensureRelayBroker?: typeof ensureRelayBroker;
7
8
  cloudMountFromConfig?: (config?: RelayfileCloudMountClientConfig) => Promise<MountClient>;
8
9
  resolveWorkspace?: () => Promise<ResolvedFactoryWorkspace>;
9
10
  resolveStates?: (mount: MountClient, config: FactoryConfig) => Promise<FactoryStateResolution>;
@@ -49,6 +50,9 @@ type ParsedCommand = {
49
50
  kind: 'factory';
50
51
  action: 'start';
51
52
  mode?: 'live';
53
+ } | {
54
+ kind: 'factory-canary';
55
+ issue: string;
52
56
  } | {
53
57
  kind: 'factory-triage';
54
58
  issue: string;
@@ -1 +1 @@
1
- {"version":3,"file":"fleet.d.ts","sourceRoot":"","sources":["../../src/cli/fleet.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"fleet.d.ts","sourceRoot":"","sources":["../../src/cli/fleet.ts"],"names":[],"mappings":"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"}
package/dist/cli/fleet.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { dirname, join, resolve } from 'node:path';
4
- import { ensureLocalMount } from '../mount/local-mount-preflight';
5
- import { FactoryConfigSchema, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, defaultGhRunner, isInFactoryScope, parseLinearIssue, reapFactoryOrphansOnce, readFactoryLoopHeartbeat, resolveFactoryStates, resolveFactoryWorkspace, } from '../index';
6
- import { FakeFleetClient, FakeMountClient } from '../testing';
4
+ import { ensureLocalMount } from '../mount/local-mount-preflight.js';
5
+ import { FactoryConfigSchema, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, ensureRelayBroker, defaultGhRunner, isInFactoryScope, readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, readFactoryLoopHeartbeat, resolveFactoryStates, resolveFactoryWorkspace, } from '../index.js';
6
+ import { FakeFleetClient, FakeMountClient } from '../testing/index.js';
7
7
  export async function runFleetCli(argv, deps = {}) {
8
8
  const out = deps.stdout ?? process.stdout;
9
9
  const err = deps.stderr ?? process.stderr;
@@ -53,6 +53,7 @@ export async function runFleetCli(argv, deps = {}) {
53
53
  writeJson(out, { released: command.name });
54
54
  return 0;
55
55
  case 'factory':
56
+ case 'factory-canary':
56
57
  case 'factory-triage':
57
58
  case 'factory-dispatch': {
58
59
  if (!loaded)
@@ -112,7 +113,7 @@ export function parseFleetCommand(args) {
112
113
  if (verb === 'spawn') {
113
114
  const [capability, ...flags] = rest;
114
115
  if (!isCapability(capability)) {
115
- throw new Error('fleet spawn requires capability spawn:codex or spawn:claude');
116
+ throw new Error('fleet spawn requires capability spawn:codex, spawn:claude, or workflow:run');
116
117
  }
117
118
  const parsed = parseFlags(flags);
118
119
  return {
@@ -246,6 +247,12 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
246
247
  }
247
248
  return 0;
248
249
  }
250
+ if (command.kind === 'factory-canary') {
251
+ const report = await factory.runOnce({ dryRun: true });
252
+ const result = evaluateFactoryCanary(report, command.issue);
253
+ writeJson(out, result);
254
+ return result.ok ? 0 : 1;
255
+ }
249
256
  const issue = await readIssueArg(mount, command.issue);
250
257
  const decision = await factory.triageIssue(issue);
251
258
  if (command.kind === 'factory-triage') {
@@ -263,6 +270,11 @@ function parseFactoryCommand(args) {
263
270
  if (action === 'run-once' || action === 'loop' || action === 'status' || action === 'loop-status' || action === 'kill-loop' || action === 'reap-orphans') {
264
271
  return { kind: 'factory', action };
265
272
  }
273
+ if (action === 'canary') {
274
+ if (!issueOrPr)
275
+ throw new Error('fleet factory canary requires an issue key or path');
276
+ return { kind: 'factory-canary', issue: issueOrPr };
277
+ }
266
278
  if (action === 'triage') {
267
279
  if (!issueOrPr)
268
280
  throw new Error('fleet factory triage requires an issue key or path');
@@ -284,6 +296,41 @@ function parseFactoryCommand(args) {
284
296
  }
285
297
  throw new Error(`Unknown fleet factory action: ${action ?? ''}`);
286
298
  }
299
+ // Canary: assert a known "Ready for Agent" issue is classified dispatch-ready
300
+ // by the REAL triage path against the live mount. This is the regression
301
+ // detector for sync-fidelity drift (sparse records / stub primaries) — if it
302
+ // flips to skipped, the adapter/sync contract broke. Exits non-zero with the
303
+ // offending skip reason so CI/cron can alert.
304
+ function evaluateFactoryCanary(report, issueArg) {
305
+ const wantKey = issueArg.startsWith('/')
306
+ ? (issueArg.split('/').at(-1) ?? '').replace(/\.json$/u, '').split('__')[0]
307
+ : issueArg;
308
+ const matches = (ref) => ref.key === wantKey;
309
+ if (report.dispatched.some((d) => matches(d.issue))) {
310
+ return { ok: true, issue: wantKey, status: 'dispatched' };
311
+ }
312
+ if (report.triaged.some((t) => matches(t.issue))) {
313
+ return { ok: true, issue: wantKey, status: 'triaged' };
314
+ }
315
+ const skipped = report.skipped.find((s) => matches(s.issue));
316
+ if (skipped) {
317
+ return { ok: false, issue: wantKey, status: 'skipped', reason: skipped.reason };
318
+ }
319
+ if (!report.pulled.some(matches)) {
320
+ return {
321
+ ok: false,
322
+ issue: wantKey,
323
+ status: 'not-found',
324
+ reason: 'issue was not enumerated from the mount (sync may be missing it or it is in-flight)',
325
+ };
326
+ }
327
+ return {
328
+ ok: false,
329
+ issue: wantKey,
330
+ status: 'unknown',
331
+ reason: 'issue pulled but neither dispatched, triaged, nor skipped',
332
+ };
333
+ }
287
334
  function parseFactoryStartFlags(args) {
288
335
  let mode;
289
336
  const flags = args.filter((arg) => Boolean(arg));
@@ -315,11 +362,36 @@ async function buildFleet(globals, loaded, deps) {
315
362
  return deps.fleet;
316
363
  if (globals.backend === 'internal' && hasExplicitFixtureFiles(loaded))
317
364
  return new FakeFleetClient();
318
- return (deps.createFleet ?? createFleet)({
319
- backend: globals.backend,
320
- cwd: process.cwd(),
321
- connectionPath: resolveBrokerConnectionPath(process.cwd()),
322
- });
365
+ const cwd = process.cwd();
366
+ const connectionPath = resolveBrokerConnectionPath(cwd);
367
+ // An injected createFleet owns fleet construction entirely (tests), so skip the
368
+ // real broker bootstrap.
369
+ if (deps.createFleet) {
370
+ return deps.createFleet({ backend: globals.backend, cwd, connectionPath });
371
+ }
372
+ // The internal backend talks to a relay broker. Reuse the one already running
373
+ // for this workspace, or start one if none is up, so `factory <run-once|loop>`
374
+ // works without a separately-started broker. The relay backend manages its own
375
+ // connection and needs no local broker.
376
+ if (globals.backend === 'internal') {
377
+ const stderr = deps.stderr ?? process.stderr;
378
+ const logger = {
379
+ info: (message, ...args) => stderr.write(`${message}${formatLogArgs(args)}\n`),
380
+ };
381
+ const { client, started } = await (deps.ensureRelayBroker ?? ensureRelayBroker)({ cwd, connectionPath, logger });
382
+ return createFleet({ backend: 'internal', cwd, connectionPath }, { harnessClient: client, ownsBroker: started });
383
+ }
384
+ return createFleet({ backend: globals.backend, cwd, connectionPath });
385
+ }
386
+ function formatLogArgs(args) {
387
+ if (args.length === 0)
388
+ return '';
389
+ try {
390
+ return ` ${args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')}`;
391
+ }
392
+ catch {
393
+ return '';
394
+ }
323
395
  }
324
396
  export function resolveBrokerConnectionPath(startCwd = process.cwd()) {
325
397
  let current = resolve(startCwd);
@@ -365,7 +437,7 @@ async function isAllowedFactoryDraft(path, content, opts, mount, config) {
365
437
  if (nestedComment) {
366
438
  const issuePath = `/linear/issues/${nestedComment[1]}.json`;
367
439
  try {
368
- const issue = parseLinearIssue(issuePath, (await mount.readFile(issuePath)).content);
440
+ const issue = await readLinearIssueWithCanonicalFallback(mount, issuePath);
369
441
  return isInFactoryScope(issue, config.safety);
370
442
  }
371
443
  catch {
@@ -376,7 +448,7 @@ async function isAllowedFactoryDraft(path, content, opts, mount, config) {
376
448
  if (isInFactoryScope(scopeIssueFromDraftContent(content), config.safety))
377
449
  return true;
378
450
  try {
379
- const issue = parseLinearIssue(path, (await mount.readFile(path)).content);
451
+ const issue = await readLinearIssueWithCanonicalFallback(mount, path);
380
452
  return isInFactoryScope(issue, config.safety);
381
453
  }
382
454
  catch {
@@ -397,8 +469,7 @@ const scopeIssueFromDraftContent = (content) => ({
397
469
  });
398
470
  async function readIssueArg(mount, issueArg) {
399
471
  const path = issueArg.startsWith('/') ? issueArg : await findIssuePath(mount, issueArg);
400
- const { content } = await mount.readFile(path);
401
- return parseLinearIssue(path, content);
472
+ return readLinearIssueWithCanonicalFallback(mount, path);
402
473
  }
403
474
  async function findIssuePath(mount, key) {
404
475
  const matches = (await mount.listTree('/linear/issues/'))
@@ -443,10 +514,10 @@ function flushWritable(stream) {
443
514
  });
444
515
  }
445
516
  function isCapability(value) {
446
- return value === 'spawn:codex' || value === 'spawn:claude';
517
+ return value === 'spawn:codex' || value === 'spawn:claude' || value === 'workflow:run';
447
518
  }
448
519
  function defaultAgentName(capability, now) {
449
- return `fleet-${capability.replace('spawn:', '')}-${now}`;
520
+ return `fleet-${capability.replace('spawn:', '').replace(':', '-')}-${now}`;
450
521
  }
451
522
  export function installFactoryStopSignalHandlers(factory, opts = {}) {
452
523
  const exit = opts.exit ?? ((code) => process.exit(code));