@agent-relay/factory 0.1.18 → 0.1.20

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 (95) hide show
  1. package/README.md +52 -0
  2. package/dist/cli/fleet.d.ts +10 -0
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +333 -23
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/local-clone-paths.d.ts +18 -0
  7. package/dist/config/local-clone-paths.d.ts.map +1 -0
  8. package/dist/config/local-clone-paths.js +188 -0
  9. package/dist/config/local-clone-paths.js.map +1 -0
  10. package/dist/config/schema.d.ts +84 -2
  11. package/dist/config/schema.d.ts.map +1 -1
  12. package/dist/config/schema.js +71 -5
  13. package/dist/config/schema.js.map +1 -1
  14. package/dist/dispatch/templates.d.ts +16 -0
  15. package/dist/dispatch/templates.d.ts.map +1 -1
  16. package/dist/dispatch/templates.js +81 -17
  17. package/dist/dispatch/templates.js.map +1 -1
  18. package/dist/fleet/internal-fleet-client.d.ts +4 -0
  19. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  20. package/dist/fleet/internal-fleet-client.js +6 -1
  21. package/dist/fleet/internal-fleet-client.js.map +1 -1
  22. package/dist/fleet/relay-fleet-client.d.ts +3 -0
  23. package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
  24. package/dist/fleet/relay-fleet-client.js +17 -4
  25. package/dist/fleet/relay-fleet-client.js.map +1 -1
  26. package/dist/github/index.d.ts +2 -0
  27. package/dist/github/index.d.ts.map +1 -1
  28. package/dist/github/index.js +1 -0
  29. package/dist/github/index.js.map +1 -1
  30. package/dist/github/standalone-babysitter.d.ts +33 -0
  31. package/dist/github/standalone-babysitter.d.ts.map +1 -0
  32. package/dist/github/standalone-babysitter.js +214 -0
  33. package/dist/github/standalone-babysitter.js.map +1 -0
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/logging.d.ts +17 -0
  39. package/dist/logging.d.ts.map +1 -0
  40. package/dist/logging.js +347 -0
  41. package/dist/logging.js.map +1 -0
  42. package/dist/mount/relayfile-cloud-mount-client.js +10 -0
  43. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  44. package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
  45. package/dist/mount/relayfile-github-connection-write.js +19 -7
  46. package/dist/mount/relayfile-github-connection-write.js.map +1 -1
  47. package/dist/orchestrator/batch-tracker.d.ts +3 -0
  48. package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
  49. package/dist/orchestrator/batch-tracker.js +28 -0
  50. package/dist/orchestrator/batch-tracker.js.map +1 -1
  51. package/dist/orchestrator/factory.d.ts +2 -1
  52. package/dist/orchestrator/factory.d.ts.map +1 -1
  53. package/dist/orchestrator/factory.js +3022 -176
  54. package/dist/orchestrator/factory.js.map +1 -1
  55. package/dist/orchestrator/reaper.d.ts.map +1 -1
  56. package/dist/orchestrator/reaper.js +6 -4
  57. package/dist/orchestrator/reaper.js.map +1 -1
  58. package/dist/ports/fleet.d.ts +24 -0
  59. package/dist/ports/fleet.d.ts.map +1 -1
  60. package/dist/ports/mount.d.ts +7 -1
  61. package/dist/ports/mount.d.ts.map +1 -1
  62. package/dist/ports/state.d.ts +114 -0
  63. package/dist/ports/state.d.ts.map +1 -1
  64. package/dist/ports/writeback.d.ts +2 -0
  65. package/dist/ports/writeback.d.ts.map +1 -1
  66. package/dist/state/file-state-store.d.ts +33 -2
  67. package/dist/state/file-state-store.d.ts.map +1 -1
  68. package/dist/state/file-state-store.js +520 -10
  69. package/dist/state/file-state-store.js.map +1 -1
  70. package/dist/state/in-memory-state-store.d.ts +31 -1
  71. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  72. package/dist/state/in-memory-state-store.js +245 -0
  73. package/dist/state/in-memory-state-store.js.map +1 -1
  74. package/dist/testing/fakes.d.ts +7 -0
  75. package/dist/testing/fakes.d.ts.map +1 -1
  76. package/dist/testing/fakes.js +5 -0
  77. package/dist/testing/fakes.js.map +1 -1
  78. package/dist/triage/agent-names.d.ts +13 -0
  79. package/dist/triage/agent-names.d.ts.map +1 -0
  80. package/dist/triage/agent-names.js +54 -0
  81. package/dist/triage/agent-names.js.map +1 -0
  82. package/dist/triage/heuristic.d.ts.map +1 -1
  83. package/dist/triage/heuristic.js +16 -23
  84. package/dist/triage/heuristic.js.map +1 -1
  85. package/dist/triage/llm.d.ts.map +1 -1
  86. package/dist/triage/llm.js +3 -2
  87. package/dist/triage/llm.js.map +1 -1
  88. package/dist/triage/schema.d.ts +26 -26
  89. package/dist/types.d.ts +2 -1
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/writeback/github.d.ts +1 -0
  92. package/dist/writeback/github.d.ts.map +1 -1
  93. package/dist/writeback/github.js +11 -0
  94. package/dist/writeback/github.js.map +1 -1
  95. package/package.json +1 -1
package/README.md CHANGED
@@ -116,6 +116,7 @@ From a source checkout instead of an npm install, run
116
116
  | `factory status` | Print current factory status as JSON. |
117
117
  | `factory triage <KEY\|path>` | Triage one issue and print the decision. |
118
118
  | `factory dispatch <KEY\|path>` | Triage + dispatch one issue. Honors `--dry-run`. |
119
+ | `factory babysit <PR\|PR-URL>` | Spawn a one-shot babysitter for an existing open PR, even when it was not created by Factory. |
119
120
  | `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. |
120
121
 
121
122
  Global options work anywhere in the args: `--config <path>`, `--dry-run`,
@@ -127,6 +128,15 @@ to 30 minutes and can also be set with `FACTORY_AGENT_EXIT_TIMEOUT_MS`.
127
128
  (There are a few more operational commands — `loop-status`, `kill-loop`,
128
129
  `reap-orphans`, `close-probe` — for running the daemon in production.)
129
130
 
131
+ `factory babysit 10` uses `repos.default`; a full URL such as
132
+ `factory babysit https://github.com/org/repo/pull/10` supplies the repository
133
+ directly. The explicit command is opt-in on its own and does not require
134
+ `babysitter.enabled`. It rejects closed, merged, and draft PRs, uses a linked
135
+ issue spec when one can be resolved (otherwise the PR title/body), fixes the
136
+ existing PR branch, and always leaves the final review and merge to a human.
137
+ The command prints a spawn receipt and returns; the PR-keyed task-exit worker
138
+ continues on the relay broker and reports completion or access blockers there.
139
+
130
140
  ### Scheduled sync-fidelity canary
131
141
 
132
142
  `factory canary` is the regression detector for upstream sync drift: if a synced
@@ -202,6 +212,23 @@ required capability (a named `node` target passes through), the node runs the
202
212
  agent in its mapped checkout, and the orchestrator detects exits by reconciling
203
213
  its tracked agents against the engine roster.
204
214
 
215
+ Relay dispatch is lifecycle-owned, not fire-and-forget. A one-shot `factory
216
+ dispatch --backend relay` keeps a small publisher runtime alive until the
217
+ remote branch has produced a PR, terminal issue writeback is acknowledged, and
218
+ remote agents are released. The lifecycle (including a per-run branch,
219
+ placement results, PR receipt, and a fenced owner lease) is persisted beside
220
+ the configured loop registry so `factory start` or a replacement dispatch
221
+ process on the same control-plane host can take over after a crash. Execution
222
+ nodes never need access to that state file, and remote PIDs are never signalled
223
+ as local processes.
224
+
225
+ The supported topology is one Factory control-plane host per workspace, with
226
+ any number of relay execution nodes. Multiple Factory processes on that host
227
+ are fenced through the shared `FileStateStore` lock/lease. Active/active Factory
228
+ control planes on different hosts are intentionally unsupported: the current
229
+ Relayfile and Relay messaging APIs do not expose a shared compare-and-set lease,
230
+ so separate local state files cannot provide a truthful cross-host fence.
231
+
205
232
  Tokens involved — set only the first one on the orchestrator host:
206
233
 
207
234
  | Token | Prefix | Who holds it |
@@ -225,6 +252,31 @@ an invalid config fails fast with a field-level error. See
225
252
  and [`test/fixtures/factory.config.json`](test/fixtures/factory.config.json) for a
226
253
  worked example (including offline fixture mode).
227
254
 
255
+ `cloneRoot` and every `clonePaths` value accept `~` or a leading `~/`, expanded
256
+ against the current user's home directory. Named-user forms such as `~alice`
257
+ are rejected because Node cannot expand them portably.
258
+
259
+ For a local, single-repository run, the checkout mapping can be omitted:
260
+
261
+ ```json
262
+ {
263
+ "repos": {
264
+ "org": "your-org",
265
+ "names": ["your-repo"],
266
+ "default": "your-org/your-repo"
267
+ }
268
+ }
269
+ ```
270
+
271
+ Run Factory from that repository (or one of its subdirectories). When exactly
272
+ one `repos.names` entry is configured and no `cloneRoot` or `clonePaths` field is
273
+ supplied, Factory resolves the checkout's git top-level and uses it only if a
274
+ GitHub remote matches the resolved `org/name`. The inference is logged. Missing,
275
+ unparseable, or mismatched remotes fail with a config-oriented error instead of
276
+ silently dispatching in the wrong directory. Explicit local clone paths are also
277
+ preflighted before commands that can dispatch through the internal backend;
278
+ relay-backend paths are left for their worker nodes to validate.
279
+
228
280
  ## Notes
229
281
 
230
282
  - The daemon is headless by design; tools like Pear can consume this package and
@@ -1,3 +1,4 @@
1
+ import { type LocalClonePathOptions } from '../config/local-clone-paths.js';
1
2
  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
3
  interface FleetCliDeps {
3
4
  fleet?: FleetClient;
@@ -18,10 +19,13 @@ interface FleetCliDeps {
18
19
  stderr?: Pick<NodeJS.WriteStream, 'write'>;
19
20
  probeCloser?: ProbeCloser;
20
21
  probePrGhRunner?: GhRunner;
22
+ babysitPrGhRunner?: GhRunner;
21
23
  now?: () => number;
22
24
  stopSignalProcessLike?: Pick<NodeJS.Process, 'once' | 'off'>;
23
25
  daemonExit?: (code: number) => void;
24
26
  flushDaemonOutput?: () => Promise<void>;
27
+ /** Hermetic local-checkout probes for CLI integration tests. */
28
+ localClonePathOptions?: LocalClonePathOptions;
25
29
  }
26
30
  interface GlobalOptions {
27
31
  backend: FleetBackend;
@@ -62,6 +66,11 @@ type ParsedCommand = {
62
66
  } | {
63
67
  kind: 'factory-dispatch';
64
68
  issue: string;
69
+ } | {
70
+ kind: 'factory-babysit';
71
+ prNumber: number;
72
+ repo?: string;
73
+ url?: string;
65
74
  } | {
66
75
  kind: 'factory-close-probe';
67
76
  prNumber: number;
@@ -74,6 +83,7 @@ export declare function parseGlobalOptions(argv: string[]): {
74
83
  globals: GlobalOptions;
75
84
  args: string[];
76
85
  };
86
+ export declare function formatLogArgs(args: unknown[]): string;
77
87
  export declare function resolveBrokerConnectionPath(startCwd?: string): string | undefined;
78
88
  export declare function installFactoryStopSignalHandlers(factory: Factory, opts?: {
79
89
  exit?: (code: number) => void;
@@ -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,12 @@
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 { stringifyLogValue } from '../logging.js';
4
5
  import { ensureLocalMount } from '../mount/local-mount-preflight.js';
5
- import { FactoryConfigSchema, FileStateStore, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, ensureRelayBroker, defaultGhRunner, githubIssuePathParts, githubWatchStatePath, isInFactoryScope, parseGithubFactoryIssue, parseOwnedBrokerAgentExitTimeoutMs, readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, readFactoryLoopHeartbeat, resolveFactoryStates, stateResolutionFromIds, resolveFactoryWorkspace, } from '../index.js';
6
+ import { resolveLocalFactoryConfig } from '../config/local-clone-paths.js';
7
+ import { FileStateStore, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, ensureRelayBroker, defaultGhRunner, explicitLinkedIssueKey, githubIssuePathParts, githubWatchStatePath, isInFactoryScope, parseGithubFactoryIssue, parseOwnedBrokerAgentExitTimeoutMs, parseStandaloneBabysitTarget, readStandalonePullRequest, readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, readFactoryLoopHeartbeat, resolveFactoryStates, stateResolutionFromIds, standaloneBabysitterAgentName, renderAgentTask, resolveFactoryWorkspace, } from '../index.js';
6
8
  import { FakeFleetClient, FakeMountClient } from '../testing/index.js';
9
+ const autoDetectedIssueSources = new WeakSet();
7
10
  export async function runFleetCli(argv, deps = {}) {
8
11
  const out = deps.stdout ?? process.stdout;
9
12
  const err = deps.stderr ?? process.stderr;
@@ -33,7 +36,15 @@ export async function runFleetCli(argv, deps = {}) {
33
36
  writeJson(out, result);
34
37
  return 0;
35
38
  }
36
- const loaded = command.kind.startsWith('factory') ? await loadConfig(globals.config) : undefined;
39
+ const resolvesLocalClonePaths = globals.backend === 'internal' && commandUsesLocalCheckout(command);
40
+ const loaded = command.kind.startsWith('factory')
41
+ ? await loadConfig(globals.config, {
42
+ ...deps.localClonePathOptions,
43
+ inferFromCwd: resolvesLocalClonePaths,
44
+ logger: streamLogger(err),
45
+ validateConfiguredCheckouts: resolvesLocalClonePaths && (deps.localClonePathOptions?.validateConfiguredCheckouts ?? !hasInjectedFactoryRuntime(deps)),
46
+ })
47
+ : undefined;
37
48
  fleet = await buildFleet(globals, loaded, deps);
38
49
  switch (command.kind) {
39
50
  case 'spawn': {
@@ -67,7 +78,8 @@ export async function runFleetCli(argv, deps = {}) {
67
78
  case 'factory':
68
79
  case 'factory-canary':
69
80
  case 'factory-triage':
70
- case 'factory-dispatch': {
81
+ case 'factory-dispatch':
82
+ case 'factory-babysit': {
71
83
  if (!loaded)
72
84
  throw new Error('factory command requires config');
73
85
  if (command.kind === 'factory' && command.action === 'reap-orphans') {
@@ -94,6 +106,9 @@ export async function runFleetCli(argv, deps = {}) {
94
106
  if (!workspaceId)
95
107
  throw new Error('factory command could not resolve a workspaceId');
96
108
  const mount = await buildMount(loaded, deps);
109
+ if (command.kind === 'factory-babysit') {
110
+ return await runStandaloneBabysitCommand(command, mount, fleet, loaded.config, globals, out, deps, workspaceId, acceptableMountIds);
111
+ }
97
112
  // Resolve Linear workflow states only when Linear is the issue source.
98
113
  // A GitHub-only workspace has no /linear/states catalog and uses labels
99
114
  // for lifecycle state, so it must not depend on that provider at startup.
@@ -304,9 +319,160 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
304
319
  writeJson(out, decision);
305
320
  return 0;
306
321
  }
322
+ if (command.kind === 'factory-dispatch' && globals.backend === 'relay' && !globals.dryRun) {
323
+ // A relay dispatch is not placement-only. This process becomes the durable
324
+ // lifecycle publisher (or attaches to the current owner's durable row),
325
+ // and stays through takeover/publication/writeback/release to terminal.
326
+ await factory.start({ mode: 'dispatch-owner' });
327
+ try {
328
+ const result = await factory.dispatch(decision, { dryRun: false });
329
+ writeJson(out, result);
330
+ await factory.waitForDispatchTerminal(result.issue);
331
+ return 0;
332
+ }
333
+ finally {
334
+ await factory.stop();
335
+ }
336
+ }
307
337
  writeJson(out, await factory.dispatch(decision, { dryRun: globals.dryRun }));
308
338
  return 0;
309
339
  }
340
+ async function runStandaloneBabysitCommand(command, mount, fleet, config, globals, out, deps, workspaceId, acceptableMountIds) {
341
+ const repo = resolveStandaloneBabysitRepo(command.repo, config);
342
+ const clonePath = standaloneBabysitClonePath(repo, config);
343
+ const mountFn = deps.ensureLocalMount ?? ensureLocalMount;
344
+ const mountOpts = { acceptableWorkspaceIds: acceptableMountIds };
345
+ await ensureStandaloneBabysitMount(mountFn, workspaceId, process.cwd(), mountOpts, deps.stderr);
346
+ if (clonePath && resolve(clonePath) !== resolve(process.cwd())) {
347
+ await ensureStandaloneBabysitMount(mountFn, workspaceId, clonePath, mountOpts, deps.stderr);
348
+ }
349
+ const pr = await readStandalonePullRequest(mount, { repo, prNumber: command.prNumber, url: command.url }, deps.babysitPrGhRunner ?? defaultGhRunner);
350
+ const state = pr.state?.trim().toUpperCase();
351
+ if (pr.merged || state === 'MERGED') {
352
+ throw new Error(`factory babysit requires an open PR; ${repo} PR #${pr.number} is merged`);
353
+ }
354
+ if (state !== 'OPEN') {
355
+ throw new Error(`factory babysit requires an open PR; ${repo} PR #${pr.number} state is ${state ?? 'UNKNOWN'}`);
356
+ }
357
+ if (pr.draft) {
358
+ throw new Error(`factory babysit skips draft PRs; ${repo} PR #${pr.number} is a draft`);
359
+ }
360
+ if (pr.crossRepository) {
361
+ throw new Error(`factory babysit refuses cross-repository PRs by default; ${repo} PR #${pr.number} head is ${pr.headRepo ?? 'unknown'}`);
362
+ }
363
+ const linkedIssueKey = explicitLinkedIssueKey(pr.body);
364
+ let issue = {
365
+ key: `${repo}#${pr.number}`,
366
+ title: pr.title,
367
+ description: pr.body || '(No PR description was provided.)',
368
+ };
369
+ let specSource = 'pull-request';
370
+ if (linkedIssueKey) {
371
+ try {
372
+ const linked = await readIssueArg(mount, linkedIssueKey, config);
373
+ issue = {
374
+ key: linked.key,
375
+ title: linked.title,
376
+ description: linked.description,
377
+ };
378
+ specSource = 'linked-issue';
379
+ }
380
+ catch {
381
+ // The PR remains independently babysittable when its referenced issue is
382
+ // not connected to this workspace. Its own title/body become the spec.
383
+ }
384
+ }
385
+ const agentName = standaloneBabysitterAgentName(repo, pr.number);
386
+ const task = renderAgentTask({
387
+ issue,
388
+ route: { repo, clonePath },
389
+ role: 'babysitter',
390
+ config: { mergePolicy: config.mergePolicy, terminalState: config.terminalState },
391
+ reviewerName: '',
392
+ pr: {
393
+ number: pr.number,
394
+ url: pr.url,
395
+ headRef: pr.headRef,
396
+ headSha: pr.headSha,
397
+ baseRef: pr.baseRef,
398
+ headRepo: pr.headRepo,
399
+ crossRepository: pr.crossRepository,
400
+ maintainerCanModify: pr.maintainerCanModify,
401
+ },
402
+ standaloneBabysitter: { specSource },
403
+ integrationsMountRoot: resolve(process.cwd(), '.integrations'),
404
+ });
405
+ const receiptBase = {
406
+ agent: agentName,
407
+ repo,
408
+ prNumber: pr.number,
409
+ url: pr.url,
410
+ source: pr.source,
411
+ specSource,
412
+ ...(specSource === 'linked-issue' ? { linkedIssue: issue.key } : {}),
413
+ };
414
+ if (globals.dryRun) {
415
+ writeJson(out, { status: 'dry-run', ...receiptBase });
416
+ return 0;
417
+ }
418
+ const roster = await fleet.roster();
419
+ if (roster.agents.some((agent) => agent.name === agentName)) {
420
+ writeJson(out, { status: 'already-running', ...receiptBase });
421
+ return 0;
422
+ }
423
+ const spawned = await fleet.spawn({
424
+ name: agentName,
425
+ capability: config.agentCapabilities.babysitter,
426
+ node: 'self',
427
+ repo,
428
+ clonePath,
429
+ task,
430
+ model: config.models.babysitter,
431
+ cwd: clonePath,
432
+ invocationId: `factory-babysit:${repo}#${pr.number}`,
433
+ });
434
+ fleet.preserveInfrastructureOnDispose?.();
435
+ writeJson(out, { status: 'spawned', ...receiptBase, agent: spawned.name });
436
+ return 0;
437
+ }
438
+ async function ensureStandaloneBabysitMount(mountFn, workspaceId, startDir, options, stderr = process.stderr) {
439
+ try {
440
+ await mountFn(workspaceId, startDir, options);
441
+ }
442
+ catch (error) {
443
+ const message = error instanceof Error ? error.message : String(error);
444
+ stderr.write(`[factory] warning: could not start relayfile mount for standalone babysitter at ${resolve(startDir)}; ` +
445
+ `the agent will use the GitHub CLI fallback: ${message}\n`);
446
+ }
447
+ }
448
+ function resolveStandaloneBabysitRepo(repo, config) {
449
+ const configured = repo ?? config.repos.default;
450
+ if (!configured) {
451
+ throw new Error('factory babysit <PR-number> requires repos.default; pass a full GitHub PR URL instead');
452
+ }
453
+ if (configured.includes('/'))
454
+ return configured;
455
+ const exactRoute = config.repos.byLabel[configured];
456
+ if (exactRoute)
457
+ return exactRoute;
458
+ const routed = [...new Set(Object.values(config.repos.byLabel).filter((candidate) => candidate === configured || candidate.endsWith(`/${configured}`)))];
459
+ if (routed.length === 1)
460
+ return routed[0];
461
+ if (routed.length > 1) {
462
+ throw new Error(`Unable to resolve repository ${configured}: matches multiple configured repos (${routed.sort().join(', ')}); ` +
463
+ 'set repos.default to owner/repo or pass a full GitHub PR URL instead');
464
+ }
465
+ if (config.repos.org)
466
+ return `${config.repos.org}/${configured}`;
467
+ throw new Error(`Unable to resolve repository ${configured} to owner/repo; pass a full GitHub PR URL instead`);
468
+ }
469
+ function standaloneBabysitClonePath(repo, config) {
470
+ const configured = Object.entries(config.clonePaths ?? {}).find(([candidate]) => candidate.toLowerCase() === repo.toLowerCase())?.[1]
471
+ ?? Object.entries(config.repos.clonePaths ?? {}).find(([candidate]) => candidate.toLowerCase() === repo.toLowerCase())?.[1];
472
+ if (configured && existsSync(configured))
473
+ return configured;
474
+ return undefined;
475
+ }
310
476
  /**
311
477
  * Ensures the relayfile mount is running at each configured clone path so
312
478
  * spawned agents can resolve `.integrations` relative to their working
@@ -354,6 +520,11 @@ function parseFactoryCommand(args) {
354
520
  throw new Error('factory dispatch requires an issue key or path');
355
521
  return { kind: 'factory-dispatch', issue: issueOrPr };
356
522
  }
523
+ if (action === 'babysit') {
524
+ if (flags.length > 0)
525
+ throw new Error(`Unexpected argument ${flags[0]}`);
526
+ return { kind: 'factory-babysit', ...parseStandaloneBabysitTarget(issueOrPr) };
527
+ }
357
528
  if (action === 'close-probe') {
358
529
  const prNumber = Number(issueOrPr);
359
530
  if (!Number.isInteger(prNumber) || prNumber <= 0)
@@ -416,15 +587,36 @@ function parseFactoryStartFlags(args) {
416
587
  }
417
588
  return { mode };
418
589
  }
419
- async function loadConfig(path) {
590
+ async function loadConfig(path, options = {}) {
420
591
  const configPath = path ?? resolve(process.cwd(), 'factory.config.json');
421
592
  const raw = JSON.parse(await readFile(configPath, 'utf8'));
422
593
  const record = asRecord(raw);
423
594
  return {
424
- config: FactoryConfigSchema.parse(record.factoryConfig ?? record),
595
+ config: await resolveLocalFactoryConfig(raw, {
596
+ ...options,
597
+ // fixtureFiles describe a hermetic in-memory run; their checkout paths
598
+ // are intentionally synthetic and must not be preflighted on the host.
599
+ validateConfiguredCheckouts: options.validateConfiguredCheckouts && !record.fixtureFiles,
600
+ }),
425
601
  fixtureFiles: record.fixtureFiles ? asRecord(record.fixtureFiles) : undefined,
426
602
  };
427
603
  }
604
+ function commandUsesLocalCheckout(command) {
605
+ switch (command.kind) {
606
+ case 'factory':
607
+ return command.action === 'run-once' || command.action === 'loop' || command.action === 'start';
608
+ case 'factory-canary':
609
+ case 'factory-triage':
610
+ case 'factory-dispatch':
611
+ case 'factory-babysit':
612
+ return true;
613
+ default:
614
+ return false;
615
+ }
616
+ }
617
+ function hasInjectedFactoryRuntime(deps) {
618
+ return Boolean(deps.fleet || deps.mount || deps.createFactory || deps.createFleet || deps.cloudMountFromConfig);
619
+ }
428
620
  async function buildFleet(globals, loaded, deps) {
429
621
  if (deps.fleet)
430
622
  return deps.fleet;
@@ -466,15 +658,10 @@ function streamLogger(stream) {
466
658
  error: (message, ...args) => write(message, args),
467
659
  };
468
660
  }
469
- function formatLogArgs(args) {
661
+ export function formatLogArgs(args) {
470
662
  if (args.length === 0)
471
663
  return '';
472
- try {
473
- return ` ${args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' ')}`;
474
- }
475
- catch {
476
- return '';
477
- }
664
+ return ` ${args.map((arg) => (typeof arg === 'string' ? arg : stringifyLogValue(arg))).join(' ')}`;
478
665
  }
479
666
  export function resolveBrokerConnectionPath(startCwd = process.cwd()) {
480
667
  let current = resolve(startCwd);
@@ -503,15 +690,37 @@ async function resolveStatesForIssueSource(mount, config, resolveStates) {
503
690
  return stateResolutionFromIds(config.stateIds, config.linear.states);
504
691
  }
505
692
  if (!config.issueSource) {
506
- const linearReady = await mount.ensureSubRoot('/linear/issues', { timeoutMs: 90_000 });
507
- if (linearReady !== 'ready') {
693
+ autoDetectedIssueSources.add(config);
694
+ if (shouldAutoDetectGithubSource(config)) {
508
695
  config.issueSource = 'github';
509
696
  return stateResolutionFromIds(config.stateIds, config.linear.states);
510
697
  }
511
- config.issueSource = 'linear';
698
+ const linearReady = await mount.ensureSubRoot('/linear/issues', { timeoutMs: 90_000 });
699
+ config.issueSource = linearReady === 'ready' ? 'linear' : 'github';
700
+ if (config.issueSource === 'github') {
701
+ return stateResolutionFromIds(config.stateIds, config.linear.states);
702
+ }
512
703
  }
513
704
  return (resolveStates ?? defaultResolveStates)(mount, config);
514
705
  }
706
+ function shouldAutoDetectGithubSource(config) {
707
+ if (!config.repos.org || configuredGithubIssueRepos(config).length === 0)
708
+ return false;
709
+ return Object.keys(config.stateIds).length === 0 &&
710
+ hasDefaultLinearStateNames(config.linear.states) &&
711
+ Object.keys(config.linear.statesByTeam).length === 0 &&
712
+ Object.keys(config.linear.teamIds).length === 0 &&
713
+ config.subscription.teams.length === 0 &&
714
+ config.subscription.projects.length === 0 &&
715
+ config.subscription.assignees.length === 0;
716
+ }
717
+ function hasDefaultLinearStateNames(states) {
718
+ return states.readyForAgent === 'Ready for Agent' &&
719
+ states.agentImplementing === 'Agent Implementing' &&
720
+ states.inPlanning === 'In Planning' &&
721
+ states.done === 'Done' &&
722
+ states.humanReview === 'In Human Review';
723
+ }
515
724
  async function buildMount(loaded, deps) {
516
725
  if (deps.mount)
517
726
  return deps.mount;
@@ -580,20 +789,33 @@ async function findIssuePath(mount, key, config) {
580
789
  if (config.issueSource === 'github') {
581
790
  const number = Number(key.replace(/^#/, ''));
582
791
  if (!Number.isInteger(number) || number <= 0) {
583
- throw new Error(`Unable to resolve GitHub issue ${key}: expected a positive issue number`);
792
+ throw new Error(`${githubIssueResolutionError(config, key)}: expected a positive issue number`);
793
+ }
794
+ const configuredRepos = configuredGithubIssueRepos(config);
795
+ if (configuredRepos.length === 0 && hasConfiguredGithubIssueRoutes(config)) {
796
+ throw new Error(`${githubIssueResolutionError(config, key)}: configured repository routes do not resolve to owner/repo; ` +
797
+ 'set repos.default to owner/repo or map its label in repos.byLabel');
798
+ }
799
+ if (configuredRepos.length === 1) {
800
+ const directPath = await findDirectGithubIssuePath(mount, configuredRepos[0], number);
801
+ if (directPath)
802
+ return directPath;
584
803
  }
585
- const defaultRepo = config.repos.default?.toLowerCase();
586
- const matches = (await mount.listTree('/github/repos'))
804
+ const roots = configuredRepos.length > 0
805
+ ? configuredRepos.flatMap(githubIssueRoots)
806
+ : ['/github/repos'];
807
+ const matches = (await Promise.all([...new Set(roots)].map((root) => mount.listTree(root))))
808
+ .flat()
587
809
  .filter((path) => path.endsWith('.json'))
588
810
  .filter((path) => {
589
811
  const parts = githubIssuePathParts(path);
590
812
  if (!parts || parts.number !== number)
591
813
  return false;
592
- return !defaultRepo || `${parts.owner}/${parts.repo}`.toLowerCase() === defaultRepo;
814
+ return configuredRepos.length === 0 || configuredRepos.some((repo) => repo.toLowerCase() === `${parts.owner}/${parts.repo}`.toLowerCase());
593
815
  })
594
816
  .sort((left, right) => githubIssuePathPreference(left) - githubIssuePathPreference(right) || left.localeCompare(right));
595
817
  if (matches.length === 0) {
596
- throw new Error(`Unable to resolve GitHub issue ${key}: found 0 matches`);
818
+ throw new Error(`${githubIssueResolutionError(config, key)}: found 0 matches`);
597
819
  }
598
820
  const matchesByRepo = new Map();
599
821
  for (const path of matches) {
@@ -603,9 +825,9 @@ async function findIssuePath(mount, key, config) {
603
825
  matchesByRepo.set(repo.toLowerCase(), { repo, path });
604
826
  }
605
827
  }
606
- if (!defaultRepo && matchesByRepo.size > 1) {
828
+ if (!config.repos.default && matchesByRepo.size > 1) {
607
829
  const repos = [...matchesByRepo.values()].map((match) => match.repo).sort((left, right) => left.localeCompare(right));
608
- throw new Error(`Unable to resolve GitHub issue ${key}: matches multiple repositories (${repos.join(', ')}); ` +
830
+ throw new Error(`${githubIssueResolutionError(config, key)}: matches multiple repositories (${repos.join(', ')}); ` +
609
831
  'set repos.default or pass a repo-qualified argument');
610
832
  }
611
833
  return matchesByRepo.values().next().value.path;
@@ -613,10 +835,96 @@ async function findIssuePath(mount, key, config) {
613
835
  const matches = (await mount.listTree('/linear/issues/'))
614
836
  .filter((path) => path.startsWith(`/linear/issues/${key}__`) || path === `/linear/issues/${key}.json`);
615
837
  if (matches.length !== 1) {
616
- throw new Error(`Unable to resolve issue ${key}: found ${matches.length} matches`);
838
+ const detected = autoDetectedIssueSources.has(config) ? ', auto-detected' : '';
839
+ throw new Error(`Unable to resolve issue ${key} in Linear (issueSource=linear${detected}): found ${matches.length} matches; ` +
840
+ `set issueSource: 'github' if these are GitHub issues`);
617
841
  }
618
842
  return matches[0];
619
843
  }
844
+ function configuredGithubIssueRepos(config) {
845
+ const candidates = config.repos.default
846
+ ? [config.repos.default]
847
+ : [
848
+ ...Object.values(config.repos.byLabel),
849
+ ...Object.values(config.repos.byProject),
850
+ ...config.repos.keywordRules.map((rule) => rule.repo),
851
+ ];
852
+ const repos = new Map();
853
+ const routedRepos = [
854
+ ...Object.values(config.repos.byLabel),
855
+ ...Object.values(config.repos.byProject),
856
+ ...config.repos.keywordRules.map((rule) => rule.repo),
857
+ ];
858
+ for (const candidate of candidates) {
859
+ const mapped = Object.entries(config.repos.byLabel).find(([label]) => label.toLowerCase() === candidate.toLowerCase())?.[1];
860
+ const canonicalRoute = routedRepos.find((route) => route.includes('/') && route.toLowerCase() === candidate.toLowerCase());
861
+ const repo = candidate.includes('/')
862
+ ? canonicalRoute ?? candidate
863
+ : mapped?.includes('/')
864
+ ? mapped
865
+ : config.repos.org ? `${config.repos.org}/${mapped ?? candidate}` : undefined;
866
+ if (!repo || !/^[^/]+\/[^/]+$/u.test(repo))
867
+ continue;
868
+ repos.set(repo.toLowerCase(), repo);
869
+ }
870
+ return [...repos.values()];
871
+ }
872
+ function hasConfiguredGithubIssueRoutes(config) {
873
+ return Boolean(config.repos.default ||
874
+ Object.keys(config.repos.byLabel).length > 0 ||
875
+ Object.keys(config.repos.byProject).length > 0 ||
876
+ config.repos.keywordRules.length > 0);
877
+ }
878
+ async function findDirectGithubIssuePath(mount, repo, number) {
879
+ for (const path of githubIssueDirectPaths(repo, number)) {
880
+ try {
881
+ await mount.readFile(path);
882
+ return path;
883
+ }
884
+ catch (error) {
885
+ if (!isMountFileNotFound(error)) {
886
+ const message = error instanceof Error ? error.message : String(error);
887
+ throw new Error(`Unable to read GitHub issue candidate ${path}: ${message}`, { cause: error });
888
+ }
889
+ // Direct reads are an optimization. A scoped tree scan below handles
890
+ // slugged directories and older mount shapes when these paths are absent.
891
+ }
892
+ }
893
+ return undefined;
894
+ }
895
+ function isMountFileNotFound(error) {
896
+ const record = asRecord(error);
897
+ const response = asRecord(record.response);
898
+ const status = record.status ?? record.statusCode ?? response.status ?? response.statusCode;
899
+ const code = typeof record.code === 'string' ? record.code.toLowerCase() : undefined;
900
+ const message = error instanceof Error ? error.message : String(error);
901
+ return status === 404 || status === '404' ||
902
+ code === 'not_found' || code === 'file_not_found' ||
903
+ /(?:file\s+not\s+found|\b404\b)/iu.test(message);
904
+ }
905
+ function githubIssueDirectPaths(repo, number) {
906
+ const [owner, name] = repo.split('/');
907
+ const roots = [
908
+ `/github/repos/${owner}__${name}/issues`,
909
+ `/github/repos/${owner}/${name}/issues`,
910
+ ];
911
+ return [
912
+ ...roots.map((root) => `${root}/${number}/meta.json`),
913
+ ...roots.map((root) => `${root}/by-id/${number}.json`),
914
+ ...roots.map((root) => `${root}/${number}.json`),
915
+ ];
916
+ }
917
+ function githubIssueRoots(repo) {
918
+ const [owner, name] = repo.split('/');
919
+ return [
920
+ `/github/repos/${owner}__${name}/issues`,
921
+ `/github/repos/${owner}/${name}/issues`,
922
+ ];
923
+ }
924
+ function githubIssueResolutionError(config, key) {
925
+ const detected = autoDetectedIssueSources.has(config) ? ', auto-detected' : '';
926
+ return `Unable to resolve GitHub issue ${key} (issueSource=github${detected})`;
927
+ }
620
928
  const githubIssuePathPreference = (path) => path.endsWith('/meta.json') ? 0 : path.includes('/by-id/') ? 1 : 2;
621
929
  function parseFlags(args) {
622
930
  const flags = {};
@@ -666,6 +974,7 @@ function isFactoryAction(value) {
666
974
  value === 'canary' ||
667
975
  value === 'triage' ||
668
976
  value === 'dispatch' ||
977
+ value === 'babysit' ||
669
978
  value === 'close-probe';
670
979
  }
671
980
  function defaultAgentName(capability, now) {
@@ -730,6 +1039,7 @@ Commands:
730
1039
  canary <KEY|path> Check that a known issue is dispatch-ready
731
1040
  triage <KEY|path> Triage one issue and print the decision
732
1041
  dispatch <KEY|path> Triage and dispatch one issue
1042
+ babysit <PR|URL> Shepherd an existing open PR to green
733
1043
  close-probe <PR> Probe/close a PR for an issue
734
1044
  fleet <command> Low-level fleet commands: spawn, roster, release
735
1045