@agent-relay/factory 0.1.57 → 0.1.60

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 (125) hide show
  1. package/README.md +152 -1
  2. package/dist/cli/fleet.d.ts +20 -1
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +550 -106
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/schema.d.ts +1778 -515
  7. package/dist/config/schema.d.ts.map +1 -1
  8. package/dist/config/schema.js +104 -6
  9. package/dist/config/schema.js.map +1 -1
  10. package/dist/delivery/ticket-dispatch.d.ts +15 -0
  11. package/dist/delivery/ticket-dispatch.d.ts.map +1 -0
  12. package/dist/delivery/ticket-dispatch.js +20 -0
  13. package/dist/delivery/ticket-dispatch.js.map +1 -0
  14. package/dist/feature-guardian/conversation.d.ts +251 -0
  15. package/dist/feature-guardian/conversation.d.ts.map +1 -0
  16. package/dist/feature-guardian/conversation.js +1893 -0
  17. package/dist/feature-guardian/conversation.js.map +1 -0
  18. package/dist/feature-guardian/index.d.ts +2 -0
  19. package/dist/feature-guardian/index.d.ts.map +1 -0
  20. package/dist/feature-guardian/index.js +2 -0
  21. package/dist/feature-guardian/index.js.map +1 -0
  22. package/dist/git/agent-worktree.d.ts.map +1 -1
  23. package/dist/git/agent-worktree.js +4 -0
  24. package/dist/git/agent-worktree.js.map +1 -1
  25. package/dist/github/index.d.ts +2 -0
  26. package/dist/github/index.d.ts.map +1 -1
  27. package/dist/github/index.js +1 -0
  28. package/dist/github/index.js.map +1 -1
  29. package/dist/github/routed-pr-babysitter.d.ts +48 -0
  30. package/dist/github/routed-pr-babysitter.d.ts.map +1 -0
  31. package/dist/github/routed-pr-babysitter.js +197 -0
  32. package/dist/github/routed-pr-babysitter.js.map +1 -0
  33. package/dist/index.d.ts +5 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/intake/index.d.ts +4 -0
  38. package/dist/intake/index.d.ts.map +1 -0
  39. package/dist/intake/index.js +4 -0
  40. package/dist/intake/index.js.map +1 -0
  41. package/dist/intake/notion-relay-claim.d.ts +32 -0
  42. package/dist/intake/notion-relay-claim.d.ts.map +1 -0
  43. package/dist/intake/notion-relay-claim.js +173 -0
  44. package/dist/intake/notion-relay-claim.js.map +1 -0
  45. package/dist/intake/notion-relay-contract.d.ts +33 -0
  46. package/dist/intake/notion-relay-contract.d.ts.map +1 -0
  47. package/dist/intake/notion-relay-contract.js +163 -0
  48. package/dist/intake/notion-relay-contract.js.map +1 -0
  49. package/dist/intake/notion.d.ts +375 -0
  50. package/dist/intake/notion.d.ts.map +1 -0
  51. package/dist/intake/notion.js +773 -0
  52. package/dist/intake/notion.js.map +1 -0
  53. package/dist/issue-key-match.d.ts +6 -0
  54. package/dist/issue-key-match.d.ts.map +1 -1
  55. package/dist/issue-key-match.js +14 -0
  56. package/dist/issue-key-match.js.map +1 -1
  57. package/dist/mount/github-api-issue-read.d.ts +28 -0
  58. package/dist/mount/github-api-issue-read.d.ts.map +1 -0
  59. package/dist/mount/github-api-issue-read.js +163 -0
  60. package/dist/mount/github-api-issue-read.js.map +1 -0
  61. package/dist/mount/local-mount-preflight.d.ts +2 -0
  62. package/dist/mount/local-mount-preflight.d.ts.map +1 -1
  63. package/dist/mount/local-mount-preflight.js +59 -9
  64. package/dist/mount/local-mount-preflight.js.map +1 -1
  65. package/dist/mount/mount-auth-error.d.ts +41 -0
  66. package/dist/mount/mount-auth-error.d.ts.map +1 -0
  67. package/dist/mount/mount-auth-error.js +94 -0
  68. package/dist/mount/mount-auth-error.js.map +1 -0
  69. package/dist/mount/relayfile-binary.d.ts +5 -0
  70. package/dist/mount/relayfile-binary.d.ts.map +1 -1
  71. package/dist/mount/relayfile-binary.js +16 -2
  72. package/dist/mount/relayfile-binary.js.map +1 -1
  73. package/dist/mount/relayfile-cloud-mount-client.d.ts +18 -3
  74. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  75. package/dist/mount/relayfile-cloud-mount-client.js +157 -13
  76. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  77. package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
  78. package/dist/mount/relayfile-github-connection-write.js +6 -3
  79. package/dist/mount/relayfile-github-connection-write.js.map +1 -1
  80. package/dist/mount/workspace-mirror.d.ts +13 -0
  81. package/dist/mount/workspace-mirror.d.ts.map +1 -0
  82. package/dist/mount/workspace-mirror.js +94 -0
  83. package/dist/mount/workspace-mirror.js.map +1 -0
  84. package/dist/observability/events.d.ts +32 -32
  85. package/dist/orchestrator/batch-tracker.d.ts +2 -1
  86. package/dist/orchestrator/batch-tracker.d.ts.map +1 -1
  87. package/dist/orchestrator/batch-tracker.js.map +1 -1
  88. package/dist/orchestrator/factory.d.ts +51 -0
  89. package/dist/orchestrator/factory.d.ts.map +1 -1
  90. package/dist/orchestrator/factory.js +1736 -116
  91. package/dist/orchestrator/factory.js.map +1 -1
  92. package/dist/ports/fleet.d.ts +4 -0
  93. package/dist/ports/fleet.d.ts.map +1 -1
  94. package/dist/ports/index.d.ts +1 -1
  95. package/dist/ports/index.d.ts.map +1 -1
  96. package/dist/ports/mount.d.ts +60 -1
  97. package/dist/ports/mount.d.ts.map +1 -1
  98. package/dist/ports/state.d.ts +66 -1
  99. package/dist/ports/state.d.ts.map +1 -1
  100. package/dist/state/file-state-store.d.ts +15 -1
  101. package/dist/state/file-state-store.d.ts.map +1 -1
  102. package/dist/state/file-state-store.js +210 -2
  103. package/dist/state/file-state-store.js.map +1 -1
  104. package/dist/state/in-memory-state-store.d.ts +15 -1
  105. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  106. package/dist/state/in-memory-state-store.js +105 -0
  107. package/dist/state/in-memory-state-store.js.map +1 -1
  108. package/dist/testing/fakes.d.ts +4 -0
  109. package/dist/testing/fakes.d.ts.map +1 -1
  110. package/dist/testing/fakes.js +6 -0
  111. package/dist/testing/fakes.js.map +1 -1
  112. package/dist/triage/schema.d.ts +169 -0
  113. package/dist/triage/schema.d.ts.map +1 -1
  114. package/dist/triage/schema.js +21 -0
  115. package/dist/triage/schema.js.map +1 -1
  116. package/dist/types.d.ts +61 -0
  117. package/dist/types.d.ts.map +1 -1
  118. package/dist/version-info.d.ts +21 -0
  119. package/dist/version-info.d.ts.map +1 -0
  120. package/dist/version-info.js +146 -0
  121. package/dist/version-info.js.map +1 -0
  122. package/dist/writeback/github.d.ts.map +1 -1
  123. package/dist/writeback/github.js +51 -0
  124. package/dist/writeback/github.js.map +1 -1
  125. package/package.json +10 -1
package/dist/cli/fleet.js CHANGED
@@ -1,13 +1,13 @@
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 { randomUUID } from 'node:crypto';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
5
  import readline from 'node:readline/promises';
6
6
  import { ensureCloudSession } from '@agent-relay/cloud';
7
7
  import { stringifyLogValue } from '../logging.js';
8
8
  import { resolveLocalFactoryConfig } from '../config/local-clone-paths.js';
9
9
  import { initializeFactory } from './init.js';
10
- import { FileStateStore, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, createFactoryCloudEventV1, ensureRelayBroker, FactoryCloudReporter, FileFactoryCloudEventOutbox, defaultGhRunner, explicitLinkedIssueKey, githubIssuePathParts, githubWatchStatePath, isInFactoryScope, parseGithubFactoryIssue, publishFactoryMountHealth, parseOwnedBrokerAgentExitTimeoutMs, parseStandaloneBabysitTarget, readStandalonePullRequest, readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, reapFactoryEnvironmentsOnce, readFactoryLoopHeartbeat, resolveFactoryStates, stateResolutionFromIds, standaloneBabysitterAgentName, renderAgentTask, resolveFactoryWorkspace, } from '../index.js';
10
+ import { FileStateStore, RelayfileCloudMountClient, checkFactoryLoopLiveness, closeProbePr, createFactory, createFleet, createFactoryCloudEventV1, ensureRelayBroker, FactoryCloudReporter, FileFactoryCloudEventOutbox, defaultGhRunner, explicitLinkedIssueKey, githubIssuePathParts, githubWatchStatePath, isInFactoryScope, parseGithubFactoryIssue, publishFactoryMountHealth, parseOwnedBrokerAgentExitTimeoutMs, parseStandaloneBabysitTarget, readStandalonePullRequest, readLinearIssueWithCanonicalFallback, reapFactoryOrphansOnce, reapFactoryEnvironmentsOnce, readFactoryLoopHeartbeat, readFactoryInFlightRegistry, resolveFactoryStates, stateResolutionFromIds, standaloneBabysitterAgentName, renderAgentTask, resolveFactoryWorkspace, } from '../index.js';
11
11
  import { resolveTestGuidance } from '../dispatch/test-guidance.js';
12
12
  import { FakeFleetClient, FakeMountClient } from '../testing/index.js';
13
13
  import { GitAgentWorktreeManager } from '../git/agent-worktree.js';
@@ -15,14 +15,19 @@ import { checkFeatureMap } from '../featuremap/index.js';
15
15
  import { loadOrCreateFactoryInstanceId, resolveFactoryInstanceName } from '../observability/instance-identity.js';
16
16
  import { ensureFactoryIntegrations, inspectFactoryIntegration, openIntegrationUrl, } from '../mount/relayfile-integration-preflight.js';
17
17
  import { checkMountStaleness } from '../mount/relayfile-binary.js';
18
+ import { MountAuthScopeError } from '../mount/mount-auth-error.js';
19
+ import { resolveRelayWorkspaceKey } from '../fleet/relay-workspace-key.js';
20
+ import { isMeaningfullyBehind, readFactoryVersionInfo, } from '../version-info.js';
21
+ import { GhCliIssuePublisher, RelayChannelNotionClaimStore, RelayChannelNotionContractPublisher, loadNotionIntakeManifest, runNotionIntake, } from '../intake/index.js';
18
22
  const autoDetectedIssueSources = new WeakSet();
19
- const CLONE_MOUNT_PREFLIGHT_CONCURRENCY = 4;
20
23
  export async function runFleetCli(argv, deps = {}) {
21
24
  const out = deps.stdout ?? process.stdout;
22
25
  const err = deps.stderr ?? process.stderr;
23
26
  let fleet;
24
27
  let mount;
25
28
  let reporter;
29
+ let notionContracts;
30
+ let notionClaims;
26
31
  try {
27
32
  if (argv.some(isHelpFlag)) {
28
33
  out.write(helpText());
@@ -46,6 +51,81 @@ export async function runFleetCli(argv, deps = {}) {
46
51
  await initializeFactory({ repo: command.repo, workspaceId: command.workspaceId, stdout: out, stderr: err });
47
52
  return 0;
48
53
  }
54
+ if (command.kind === 'notion-intake') {
55
+ const manifest = await loadNotionIntakeManifest(command.manifestPath);
56
+ if (!globals.dryRun) {
57
+ notionClaims = deps.notionClaims;
58
+ notionContracts = deps.notionContracts;
59
+ const needsWorkspaceKey = !notionClaims ||
60
+ (manifest.workerMountTransport.kind === 'relay-channel' && !notionContracts);
61
+ const workspaceKey = needsWorkspaceKey
62
+ ? resolveRelayWorkspaceKey({
63
+ env: deps.env ?? process.env,
64
+ ...(deps.env ? { activeWorkspaceKey: () => undefined } : {}),
65
+ })
66
+ : undefined;
67
+ if (needsWorkspaceKey && !workspaceKey) {
68
+ throw new Error('Notion dispatch requires an active Agent Relay workspace for its durable shared claim');
69
+ }
70
+ notionClaims ??= new RelayChannelNotionClaimStore({ workspaceKey: workspaceKey });
71
+ if (manifest.workerMountTransport.kind === 'relay-channel') {
72
+ notionContracts ??= new RelayChannelNotionContractPublisher({ workspaceKey: workspaceKey });
73
+ }
74
+ }
75
+ const workspace = {
76
+ find: async (name) => {
77
+ fleet ??= await buildFleet(globals, undefined, deps);
78
+ const running = (await fleet.roster()).agents.find((agent) => agent.name === name);
79
+ return running ? { agent: running.name, node: running.node } : undefined;
80
+ },
81
+ dispatch: async (task) => {
82
+ fleet ??= await buildFleet(globals, undefined, deps);
83
+ const running = (await fleet.roster()).agents.find((agent) => agent.name === task.name);
84
+ if (running)
85
+ return { agent: running.name, node: running.node, status: 'already-running' };
86
+ const spawned = await fleet.spawn({
87
+ name: task.name,
88
+ capability: 'spawn:codex',
89
+ node: task.node ?? 'self',
90
+ task: task.task,
91
+ cwd: task.projectPath,
92
+ invocationId: task.invocationId,
93
+ });
94
+ fleet.preserveInfrastructureOnDispose?.();
95
+ return { agent: spawned.name, node: spawned.node, status: 'spawned' };
96
+ },
97
+ redispatch: async (task) => {
98
+ fleet ??= await buildFleet(globals, undefined, deps);
99
+ const running = (await fleet.roster()).agents.find((agent) => agent.name === task.name);
100
+ if (running) {
101
+ await fleet.sendMessage({ to: `@${running.name}`, text: task.task, mode: 'steer' });
102
+ return { agent: running.name, node: running.node, status: 'updated-running' };
103
+ }
104
+ const spawned = await fleet.spawn({
105
+ name: task.name,
106
+ capability: 'spawn:codex',
107
+ node: task.node ?? 'self',
108
+ task: task.task,
109
+ cwd: task.projectPath,
110
+ invocationId: task.invocationId,
111
+ });
112
+ fleet.preserveInfrastructureOnDispose?.();
113
+ return { agent: spawned.name, node: spawned.node, status: 'respawned' };
114
+ },
115
+ };
116
+ const report = await runNotionIntake({
117
+ manifest,
118
+ dispatch: !globals.dryRun,
119
+ ...(!globals.dryRun ? {
120
+ github: new GhCliIssuePublisher(),
121
+ workspace,
122
+ ...(notionClaims ? { claims: notionClaims } : {}),
123
+ ...(notionContracts ? { contracts: notionContracts } : {}),
124
+ } : {}),
125
+ });
126
+ writeJson(out, report);
127
+ return report.ok ? 0 : 1;
128
+ }
49
129
  if (command.kind === 'factory-close-probe') {
50
130
  // Manual close-probe remains strict; the daemon relaxes the title marker only after issue-synthetic classification.
51
131
  let githubWrite;
@@ -238,27 +318,47 @@ export async function runFleetCli(argv, deps = {}) {
238
318
  }
239
319
  finally {
240
320
  try {
241
- await mount?.dispose?.();
321
+ try {
322
+ await notionClaims?.dispose?.();
323
+ }
324
+ catch {
325
+ err.write('[factory] warning: Notion claim store failed during shutdown\n');
326
+ }
242
327
  }
243
328
  finally {
244
329
  try {
245
- await fleet?.dispose();
330
+ try {
331
+ await notionContracts?.dispose?.();
332
+ }
333
+ catch {
334
+ err.write('[factory] warning: Notion contract publisher failed during shutdown\n');
335
+ }
246
336
  }
247
337
  finally {
248
- if (reporter) {
338
+ try {
339
+ await mount?.dispose?.();
340
+ }
341
+ finally {
249
342
  try {
250
- await reporter.report(createFactoryCloudEventV1({
251
- type: 'instance.stopping',
252
- attributes: { component: 'cli', operation: 'stop' },
253
- }));
254
- await reporter.report(createFactoryCloudEventV1({
255
- type: 'instance.stopped',
256
- attributes: { component: 'cli', operation: 'stop' },
257
- }));
258
- await reporter.close?.({ deadlineMs: 2_000 });
343
+ await fleet?.dispose();
259
344
  }
260
- catch {
261
- err.write('[factory] warning: Cloud progress reporter failed during shutdown\n');
345
+ finally {
346
+ if (reporter) {
347
+ try {
348
+ await reporter.report(createFactoryCloudEventV1({
349
+ type: 'instance.stopping',
350
+ attributes: { component: 'cli', operation: 'stop' },
351
+ }));
352
+ await reporter.report(createFactoryCloudEventV1({
353
+ type: 'instance.stopped',
354
+ attributes: { component: 'cli', operation: 'stop' },
355
+ }));
356
+ await reporter.close?.({ deadlineMs: 2_000 });
357
+ }
358
+ catch {
359
+ err.write('[factory] warning: Cloud progress reporter failed during shutdown\n');
360
+ }
361
+ }
262
362
  }
263
363
  }
264
364
  }
@@ -279,8 +379,21 @@ export function parseFleetCommand(args) {
279
379
  if (verb === 'featuremap') {
280
380
  return parseFeatureMapCommand(rest);
281
381
  }
382
+ if (verb === 'intake') {
383
+ return parseIntakeCommand(rest);
384
+ }
282
385
  throw new Error(`Unknown factory command: ${verb}`);
283
386
  }
387
+ function parseIntakeCommand(args) {
388
+ const [source, manifestPath, ...rest] = args;
389
+ if (source !== 'notion')
390
+ throw new Error('factory intake currently requires the notion source');
391
+ if (!manifestPath)
392
+ throw new Error('factory intake notion requires a manifest path');
393
+ if (rest.length > 0)
394
+ throw new Error(`Unexpected factory intake argument: ${rest[0]}`);
395
+ return { kind: 'notion-intake', manifestPath };
396
+ }
284
397
  function parseFeatureMapCommand(args) {
285
398
  const [action, ...flags] = args;
286
399
  if (action !== 'check') {
@@ -377,17 +490,20 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
377
490
  const debugMountRefreshes = (deps.env ?? process.env).FACTORY_LOG_LEVEL?.toLowerCase() === 'debug';
378
491
  if (command.kind === 'factory') {
379
492
  if (command.action === 'start') {
380
- // Local mirrors are a writeback aid, not the source of truth for remote
381
- // issue discovery. Start their SDK-backed supervisors immediately, but
382
- // do not serialize durable recovery behind a stale checkout's readiness
383
- // timeout. The mount client reports degradation and keeps retrying.
384
- void warmStartPathMounts(mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes)
385
- .catch((error) => {
386
- const message = error instanceof Error ? error.message : String(error);
387
- mountStderr.write(`[factory] warning: background relayfile mount warmup failed: ${message}\n`);
388
- });
389
493
  const waiter = createStopSignalWaiter();
390
494
  let stoppedBySignal = false;
495
+ const logger = streamLogger(mountStderr);
496
+ const installedVersion = await safeFactoryVersionInfo(deps.versionInfo, false);
497
+ logger.info?.('[factory] daemon starting', installedVersion);
498
+ // The registry lookup is deliberately detached from the startup path.
499
+ // stop() only joins the bounded in-flight lookup during shutdown.
500
+ const versionMonitor = deps.versionInfo || !hasInjectedFactoryRuntime(deps)
501
+ ? startFactoryVersionMonitor({
502
+ versionInfo: deps.versionInfo,
503
+ intervalMs: deps.versionCheckIntervalMs,
504
+ logger,
505
+ })
506
+ : { stop: async () => { } };
391
507
  const flushAndResolve = async (code) => {
392
508
  try {
393
509
  await (deps.flushDaemonOutput ?? flushProcessOutput)();
@@ -396,6 +512,27 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
396
512
  waiter.resolve(code);
397
513
  }
398
514
  };
515
+ // Once a workspace mirror is known, retain background stale-mount
516
+ // supervision so durable recovery is not serialized behind a refresh.
517
+ // If there is no registered root yet, however, wait for the single
518
+ // mount/admission fallback before Factory can dispatch: agents must not
519
+ // receive a provisional checkout-local `.integrations` path.
520
+ //
521
+ // A MountAuthScopeError is the exception: it is terminal (the cloud
522
+ // session lacks the filesystem scope the mount needs), so limping on
523
+ // would only spawn agents against a read-denied mirror. Fail fast with the
524
+ // remediation and resolve the command with a non-zero code.
525
+ const warmMount = () => warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes);
526
+ const handleWarmMountError = (error) => {
527
+ if (error instanceof MountAuthScopeError) {
528
+ mountStderr.write(`${error.message}\n`);
529
+ mountStderr.write('[factory] aborting startup: local mount cannot obtain its filesystem scopes.\n');
530
+ void flushAndResolve(1);
531
+ return;
532
+ }
533
+ const message = error instanceof Error ? error.message : String(error);
534
+ mountStderr.write(`[factory] warning: background relayfile mount warmup failed: ${message}\n`);
535
+ };
399
536
  const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
400
537
  exit: (code) => {
401
538
  stoppedBySignal = true;
@@ -407,11 +544,34 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
407
544
  processLike: deps.stopSignalProcessLike,
408
545
  });
409
546
  try {
547
+ if (mount.getLocalMountRoot?.() === undefined) {
548
+ try {
549
+ const result = await warmMount();
550
+ if (!result.mounted) {
551
+ mountStderr.write('[factory] aborting startup: Relayfile workspace mirror could not be resolved.\n');
552
+ if (stoppedBySignal)
553
+ return await waiter.promise;
554
+ return 1;
555
+ }
556
+ }
557
+ catch (error) {
558
+ handleWarmMountError(error);
559
+ if (stoppedBySignal)
560
+ return await waiter.promise;
561
+ return 1;
562
+ }
563
+ }
564
+ else {
565
+ void warmMount().catch(handleWarmMountError);
566
+ }
567
+ if (stoppedBySignal)
568
+ return await waiter.promise;
410
569
  await factory.start({ mode: command.mode });
411
570
  const code = await (deps.waitForStopSignal?.() ?? waiter.promise);
412
571
  return typeof code === 'number' ? code : 0;
413
572
  }
414
573
  finally {
574
+ await versionMonitor.stop();
415
575
  removeSignalHandlers();
416
576
  if (!stoppedBySignal) {
417
577
  await factory.stop();
@@ -419,12 +579,13 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
419
579
  }
420
580
  }
421
581
  if (command.action === 'run-once') {
422
- await ensureClonePathMounts(mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes);
582
+ await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, mountStderr);
423
583
  writeJson(out, await factory.runOnce({ dryRun: globals.dryRun }));
424
584
  return 0;
425
585
  }
426
586
  if (command.action === 'status') {
427
- writeJson(out, factory.status());
587
+ const versionInfo = await safeFactoryVersionInfo(deps.versionInfo, Boolean(deps.versionInfo) || !hasInjectedFactoryRuntime(deps));
588
+ writeJson(out, await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs, versionInfo));
428
589
  return 0;
429
590
  }
430
591
  if (command.action === 'loop-status') {
@@ -441,13 +602,16 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
441
602
  writeJson(out, { killed: heartbeat.pid, signal: 'SIGTERM' });
442
603
  return 0;
443
604
  }
444
- await ensureClonePathMounts(mountFn, workspaceId, config, acceptableMountIds, mountStderr, debugMountRefreshes);
605
+ await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, mountStderr);
445
606
  const removeSignalHandlers = installFactoryStopSignalHandlers(factory, {
446
607
  processLike: deps.stopSignalProcessLike,
447
608
  });
448
609
  try {
449
610
  const reports = await factory.runLoop({ dryRun: globals.dryRun });
450
- writeJson(out, { reports, status: factory.status() });
611
+ writeJson(out, {
612
+ reports,
613
+ status: await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs),
614
+ });
451
615
  }
452
616
  finally {
453
617
  removeSignalHandlers();
@@ -461,8 +625,11 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
461
625
  writeJson(out, result);
462
626
  return result.ok ? 0 : 1;
463
627
  }
464
- const issue = await readIssueArg(mount, command.issue, config);
465
- const decision = await factory.triageIssue(issue);
628
+ const resolved = await resolveIssueArg(mount, command.issue, config, async () => issueProjectionStatus(factory, mount, config));
629
+ const decision = {
630
+ ...await factory.triageIssue(resolved.issue),
631
+ issueResolution: resolved.resolution,
632
+ };
466
633
  if (command.kind === 'factory-triage') {
467
634
  writeJson(out, decision);
468
635
  return 0;
@@ -485,23 +652,18 @@ async function runFactoryCommand(command, factory, mount, fleet, config, globals
485
652
  writeJson(out, await factory.dispatch(decision, { dryRun: globals.dryRun }));
486
653
  return 0;
487
654
  }
488
- async function warmStartPathMounts(mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug') {
489
- const mountOpts = { acceptableWorkspaceIds: acceptableMountIds };
490
- const [daemonRefresh, cloneRefreshes] = await Promise.all([
491
- ensureMountPath(mountFn, workspaceId, process.cwd(), mountOpts, stderr),
492
- ensureClonePathMounts(mountFn, workspaceId, config, acceptableMountIds, stderr, debug, false),
493
- ]);
494
- writeMountRefreshSummary([...(daemonRefresh ? [daemonRefresh] : []), ...cloneRefreshes], stderr, debug);
655
+ async function warmStartPathMounts(mount, mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug') {
656
+ const result = await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, stderr);
657
+ writeMountRefreshSummary(result.refreshed ? [result.refreshed] : [], stderr, debug);
658
+ stderr.write(`[factory] Relayfile workspace mirror preflight: mounted=${result.mounted ? 1 : 0} ` +
659
+ `failed=${result.mounted ? 0 : 1} routedRepos=${new Set(Object.values(config.repos.byLabel)).size}\n`);
660
+ return result;
495
661
  }
496
662
  async function runStandaloneBabysitCommand(command, mount, fleet, config, globals, out, deps, workspaceId, acceptableMountIds) {
497
663
  const repo = resolveStandaloneBabysitRepo(command.repo, config);
498
664
  const clonePath = standaloneBabysitClonePath(repo, config);
499
665
  const mountFn = resolveLocalMountFn(deps, mount);
500
- const mountOpts = { acceptableWorkspaceIds: acceptableMountIds };
501
- await ensureStandaloneBabysitMount(mountFn, workspaceId, process.cwd(), mountOpts, deps.stderr);
502
- if (clonePath && resolve(clonePath) !== resolve(process.cwd())) {
503
- await ensureStandaloneBabysitMount(mountFn, workspaceId, clonePath, mountOpts, deps.stderr);
504
- }
666
+ await ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, deps.stderr);
505
667
  const pr = await readStandalonePullRequest(mount, { repo, prNumber: command.prNumber, url: command.url }, deps.babysitPrGhRunner ?? defaultGhRunner);
506
668
  const state = pr.state?.trim().toUpperCase();
507
669
  if (pr.merged || state === 'MERGED') {
@@ -561,7 +723,7 @@ async function runStandaloneBabysitCommand(command, mount, fleet, config, global
561
723
  maintainerCanModify: pr.maintainerCanModify,
562
724
  },
563
725
  standaloneBabysitter: { specSource },
564
- integrationsMountRoot: resolve(process.cwd(), '.integrations'),
726
+ integrationsMountRoot: resolveIntegrationsMountRoot(mount),
565
727
  testGuidance,
566
728
  });
567
729
  const receiptBase = {
@@ -597,16 +759,6 @@ async function runStandaloneBabysitCommand(command, mount, fleet, config, global
597
759
  writeJson(out, { status: 'spawned', ...receiptBase, agent: spawned.name });
598
760
  return 0;
599
761
  }
600
- async function ensureStandaloneBabysitMount(mountFn, workspaceId, startDir, options, stderr = process.stderr) {
601
- try {
602
- await mountFn(workspaceId, startDir, options);
603
- }
604
- catch (error) {
605
- const message = error instanceof Error ? error.message : String(error);
606
- stderr.write(`[factory] warning: could not start relayfile mount for standalone babysitter at ${resolve(startDir)}; ` +
607
- `the agent will use the GitHub CLI fallback: ${message}\n`);
608
- }
609
- }
610
762
  function resolveStandaloneBabysitRepo(repo, config) {
611
763
  const configured = repo ?? config.repos.default;
612
764
  if (!configured) {
@@ -636,50 +788,157 @@ function standaloneBabysitClonePath(repo, config) {
636
788
  return undefined;
637
789
  }
638
790
  /**
639
- * Ensures the relayfile mount is running at each configured clone path so
640
- * spawned agents can resolve `.integrations` relative to their working
641
- * directory (the checkout path). The mount daemon started at the daemon CWD
642
- * is not automatically accessible from a different directory, and agents need
643
- * these paths for integration writebacks (Slack, GitHub, etc.).
791
+ * Ensures exactly one Relayfile mirror for the workspace. Agents receive this
792
+ * absolute path in their tasks, so routing more repositories never asks
793
+ * Relayfile to re-home the mirror into each checkout.
644
794
  */
645
- async function ensureClonePathMounts(mountFn, workspaceId, config, acceptableMountIds, stderr = process.stderr, debug = process.env.FACTORY_LOG_LEVEL?.toLowerCase() === 'debug', reportSummary = true) {
795
+ async function ensureWorkspaceMount(mount, mountFn, workspaceId, acceptableMountIds, stderr = process.stderr) {
646
796
  const mountOpts = { acceptableWorkspaceIds: acceptableMountIds };
647
- const daemonCwd = resolve(process.cwd());
648
- const clonePaths = [...new Set(Object.values(config.clonePaths ?? {}).map((clonePath) => resolve(clonePath)))]
649
- .filter((clonePath) => clonePath !== daemonCwd);
650
- const refreshedStaleMounts = [];
651
- let nextIndex = 0;
652
- const mountNext = async () => {
653
- while (nextIndex < clonePaths.length) {
654
- const resolved = clonePaths[nextIndex++];
655
- const refreshed = await ensureMountPath(mountFn, workspaceId, resolved, mountOpts, stderr);
656
- if (refreshed)
657
- refreshedStaleMounts.push(refreshed);
658
- }
659
- };
660
- await Promise.all(Array.from({ length: Math.min(CLONE_MOUNT_PREFLIGHT_CONCURRENCY, clonePaths.length) }, mountNext));
661
- if (reportSummary)
662
- writeMountRefreshSummary(refreshedStaleMounts, stderr, debug);
663
- return refreshedStaleMounts;
664
- }
665
- async function ensureMountPath(mountFn, workspaceId, path, mountOpts, stderr) {
666
- const resolved = resolve(path);
667
- const statePath = join(resolved, '.integrations', '.relay', 'state.json');
797
+ const localDir = mount.getLocalMountRoot?.();
798
+ return ensureMountPath(mountFn, workspaceId, localDir ? dirname(localDir) : process.cwd(), mountOpts, stderr, localDir);
799
+ }
800
+ async function ensureMountPath(mountFn, workspaceId, path, mountOpts, stderr, localDir = join(resolve(path), '.integrations')) {
801
+ const statePath = join(localDir, '.relay', 'state.json');
668
802
  const staleBefore = checkMountStaleness(statePath, workspaceId, mountOpts.acceptableWorkspaceIds);
669
803
  try {
670
- await mountFn(workspaceId, resolved, {
804
+ await mountFn(workspaceId, resolve(path), {
671
805
  ...mountOpts,
672
806
  ...(staleBefore.stale ? { suppressStaleRefreshLogs: true } : {}),
673
807
  });
674
808
  if (staleBefore.stale && !checkMountStaleness(statePath, workspaceId, mountOpts.acceptableWorkspaceIds).stale) {
675
- return { path: resolved, reason: staleBefore.reason };
809
+ return { mounted: true, refreshed: { path: localDir, reason: staleBefore.reason } };
676
810
  }
811
+ return { mounted: true };
677
812
  }
678
813
  catch (error) {
814
+ // A scope shortfall is terminal and identical across every clone path;
815
+ // propagate it so startup fails fast with one remediation instead of
816
+ // logging the same unfixable warning per path.
817
+ if (error instanceof MountAuthScopeError)
818
+ throw error;
679
819
  const message = error instanceof Error ? error.message : String(error);
680
- stderr.write(`[factory] warning: could not start relayfile mount at ${resolved}: ${message}\n`);
820
+ stderr.write(`[factory] warning: could not start Relayfile workspace mirror at ${localDir}: ${message}\n`);
681
821
  }
682
- return undefined;
822
+ return { mounted: false };
823
+ }
824
+ function resolveIntegrationsMountRoot(mount) {
825
+ return mount.getLocalMountRoot?.() ?? resolve(process.cwd(), '.integrations');
826
+ }
827
+ async function factoryStatusWithMountHealth(factory, mount, heartbeatPath, registryPath, heartbeatStaleMs, versionInfo) {
828
+ const status = factory.status();
829
+ const [heartbeat, registry] = await Promise.all([
830
+ readFactoryLoopHeartbeat(heartbeatPath),
831
+ readFactoryInFlightRegistry(registryPath),
832
+ ]);
833
+ const registryDispatches = registry?.heartbeatPath && registry.heartbeatPath !== heartbeatPath
834
+ ? []
835
+ : inFlightDispatchesFromRegistry(registry);
836
+ const observableStatus = registryDispatches.length > 0
837
+ ? { ...status, inFlightDispatches: registryDispatches }
838
+ : status;
839
+ const liveness = checkFactoryLoopLiveness(heartbeat, { staleMs: heartbeatStaleMs });
840
+ const eventListener = liveness.ok
841
+ ? heartbeat?.eventListener ?? {
842
+ state: 'unknown',
843
+ reason: 'running daemon heartbeat does not report event listener state',
844
+ }
845
+ : {
846
+ state: 'not-listening',
847
+ reason: liveness.reason,
848
+ };
849
+ const health = mount.getLocalMountHealth?.();
850
+ if (!health)
851
+ return { ...observableStatus, ...versionInfo, eventListener };
852
+ return {
853
+ ...observableStatus,
854
+ ...versionInfo,
855
+ eventListener,
856
+ localMountDegraded: health.degraded,
857
+ ...(health.reason ? { localMountDegradedReason: health.reason } : {}),
858
+ ...(health.localDir ? { localMountRoot: health.localDir } : {}),
859
+ localMountEventFeed: {
860
+ state: health.degraded ? 'degraded' : 'healthy',
861
+ livenessSignal: '.integrations/.relay/state.json',
862
+ ...(health.reason ? { reason: health.reason } : {}),
863
+ ...(health.localDir ? { root: health.localDir } : {}),
864
+ },
865
+ };
866
+ }
867
+ function inFlightDispatchesFromRegistry(registry) {
868
+ if (!registry)
869
+ return [];
870
+ const grouped = new Map();
871
+ const claimPriority = { verified: 0, pending: 1, degraded: 2 };
872
+ for (const agent of registry.agents) {
873
+ if (!agent.issue)
874
+ continue;
875
+ const key = `${agent.issue.key}\u0000${agent.issue.uuid}\u0000${agent.issue.path}`;
876
+ const claim = agent.dispatchClaim ?? {
877
+ state: 'pending',
878
+ updatedAtMs: registry.updatedAtMs,
879
+ };
880
+ const existing = grouped.get(key);
881
+ const entry = existing ?? {
882
+ issue: { ...agent.issue },
883
+ agents: [],
884
+ claim: { ...claim },
885
+ };
886
+ if (claimPriority[claim.state] > claimPriority[entry.claim.state]) {
887
+ entry.claim = { ...claim };
888
+ }
889
+ if (!entry.agents.some((candidate) => candidate.name === agent.name)) {
890
+ entry.agents.push({
891
+ name: agent.name,
892
+ ...(agent.role ? { role: agent.role } : {}),
893
+ ...(agent.sessionRef ? { sessionRef: agent.sessionRef } : {}),
894
+ ...(agent.invocationId ? { invocationId: agent.invocationId } : {}),
895
+ ...(agent.node ? { node: agent.node } : {}),
896
+ });
897
+ }
898
+ grouped.set(key, entry);
899
+ }
900
+ return [...grouped.values()]
901
+ .map((entry) => ({
902
+ ...entry,
903
+ agents: entry.agents.sort((left, right) => left.name.localeCompare(right.name)),
904
+ }))
905
+ .sort((left, right) => left.issue.key.localeCompare(right.issue.key));
906
+ }
907
+ const DEFAULT_VERSION_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
908
+ async function safeFactoryVersionInfo(provider, includeRegistry) {
909
+ try {
910
+ return await (provider ?? readFactoryVersionInfo)({ includeRegistry });
911
+ }
912
+ catch {
913
+ return { version: 'unknown' };
914
+ }
915
+ }
916
+ export async function reportFactoryVersionDrift(provider, logger) {
917
+ const info = await safeFactoryVersionInfo(provider, true);
918
+ if (!isMeaningfullyBehind(info) || !info.latestVersion)
919
+ return;
920
+ logger.warn?.(`[factory] version drift detected: running ${info.version}; published latest ${info.latestVersion}`, {
921
+ ...(info.versionsBehind !== undefined ? { versionsBehind: info.versionsBehind } : {}),
922
+ ...(info.installedAt ? { installedAt: info.installedAt } : {}),
923
+ });
924
+ }
925
+ function startFactoryVersionMonitor(input) {
926
+ let pending;
927
+ const check = () => {
928
+ if (pending)
929
+ return;
930
+ pending = reportFactoryVersionDrift(input.versionInfo, input.logger)
931
+ .finally(() => { pending = undefined; });
932
+ };
933
+ check();
934
+ const interval = setInterval(check, input.intervalMs ?? DEFAULT_VERSION_CHECK_INTERVAL_MS);
935
+ interval.unref?.();
936
+ return {
937
+ stop: async () => {
938
+ clearInterval(interval);
939
+ await pending;
940
+ },
941
+ };
683
942
  }
684
943
  function writeMountRefreshSummary(refreshedStaleMounts, stderr, debug) {
685
944
  if (debug) {
@@ -1018,9 +1277,28 @@ async function prepareFactoryIntegrations(command, mount, config, globals, deps,
1018
1277
  if (providers.length === 0)
1019
1278
  return;
1020
1279
  const dryRun = globals.dryRun || command.kind === 'factory-canary';
1280
+ const providersToEnsure = [];
1281
+ for (const provider of providers) {
1282
+ const observation = observed.get(provider) ?? await inspectFactoryIntegration(connections, provider);
1283
+ observed.set(provider, observation);
1284
+ if (provider === 'github' &&
1285
+ observation.kind === 'connected-not-ready' &&
1286
+ mount.githubRead &&
1287
+ (command.kind === 'factory-triage' || command.kind === 'factory-dispatch')) {
1288
+ const details = [observation.state, observation.initialSyncState]
1289
+ .filter((value) => Boolean(value))
1290
+ .join(', ');
1291
+ err.write(`[factory] warning: GitHub projection is not ready${details ? ` (${details})` : ''}; ` +
1292
+ 'targeted resolution remains projection-first and will use the GitHub API fallback only for a miss.\n');
1293
+ continue;
1294
+ }
1295
+ providersToEnsure.push(provider);
1296
+ }
1297
+ if (providersToEnsure.length === 0)
1298
+ return;
1021
1299
  await ensureFactoryIntegrations({
1022
1300
  connections,
1023
- providers,
1301
+ providers: providersToEnsure,
1024
1302
  workspaceId,
1025
1303
  interactive: !dryRun && (deps.isInteractive?.() ?? Boolean(process.stdin.isTTY && process.stderr.isTTY)),
1026
1304
  dryRun,
@@ -1114,10 +1392,11 @@ async function buildMount(loaded, deps, observability = {}) {
1114
1392
  if (deps.mount)
1115
1393
  return deps.mount;
1116
1394
  if (hasExplicitFixtureFiles(loaded))
1117
- return new FakeMountClient(loaded.fixtureFiles);
1395
+ return new FixtureMountClient(loaded.fixtureFiles);
1118
1396
  let mount;
1119
1397
  mount = await (deps.cloudMountFromConfig ?? RelayfileCloudMountClient.fromConfig)({
1120
1398
  workspaceId: loaded.config.workspaceId,
1399
+ localMountRoot: loaded.config.localMountRoot,
1121
1400
  logger: observability.logger,
1122
1401
  onLocalMountHealth: observability.onLocalMountHealth,
1123
1402
  isAllowedDraft: (path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, loaded.config),
@@ -1125,6 +1404,21 @@ async function buildMount(loaded, deps, observability = {}) {
1125
1404
  return mount;
1126
1405
  }
1127
1406
  const hasExplicitFixtureFiles = (loaded) => loaded?.fixtureFiles !== undefined;
1407
+ /** Hermetic fixture runs have no server event feed, so expose the in-memory
1408
+ * tree shape as their change watermark. The discovery tree cache only
1409
+ * remembers which paths exist under a prefix, not their content, so the
1410
+ * watermark must track path membership only. Hashing full file entries
1411
+ * (as an earlier version of this did) also hashes each file's `revision`,
1412
+ * which FakeMountClient.writeFile bumps on every write — so any factory
1413
+ * writeback mid-sweep (a draft, a confirmed write) would change the digest
1414
+ * with no fixture change-event to explain the gap, and the checkpoint
1415
+ * would never persist across a sweep that actually dispatched anything. */
1416
+ class FixtureMountClient extends FakeMountClient {
1417
+ async getEventHighWatermark() {
1418
+ const paths = [...this.files.keys()].sort();
1419
+ return `fixture:${createHash('sha256').update(JSON.stringify(paths)).digest('hex')}`;
1420
+ }
1421
+ }
1128
1422
  async function isAllowedFactoryDraft(path, content, opts, mount, config) {
1129
1423
  if (!opts?.guarded)
1130
1424
  return false;
@@ -1170,19 +1464,52 @@ const scopeIssueFromDraftContent = (content) => ({
1170
1464
  raw: asRecord(content),
1171
1465
  });
1172
1466
  async function readIssueArg(mount, issueArg, config) {
1173
- const path = issueArg.startsWith('/') ? issueArg : await findIssuePath(mount, issueArg, config);
1174
- if (githubIssuePathParts(path)) {
1175
- return parseGithubFactoryIssue(path, (await mount.readFile(path)).content);
1467
+ return (await resolveIssueArg(mount, issueArg, config)).issue;
1468
+ }
1469
+ async function resolveIssueArg(mount, issueArg, config, projectionStatus) {
1470
+ const explicitPath = issueArg.startsWith('/');
1471
+ const path = explicitPath ? issueArg : await findIssuePath(mount, issueArg, config);
1472
+ if (path) {
1473
+ const issue = githubIssuePathParts(path)
1474
+ ? parseGithubFactoryIssue(path, (await mount.readFile(path)).content)
1475
+ : await readLinearIssueWithCanonicalFallback(mount, path);
1476
+ return {
1477
+ issue,
1478
+ resolution: {
1479
+ source: 'relayfile-projection',
1480
+ detail: 'Resolved from the preferred Relayfile projection.',
1481
+ projection: { outcome: 'matched' },
1482
+ },
1483
+ };
1484
+ }
1485
+ const selector = parseGithubIssueSelector(issueArg, config);
1486
+ const projection = projectionStatus
1487
+ ? await projectionStatus()
1488
+ : projectionStatusFromMount(mount);
1489
+ const unavailableReason = githubProjectionUnavailableReason(projection);
1490
+ if (!unavailableReason) {
1491
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: found 0 matches in the healthy Relayfile projection; ` +
1492
+ 'the GitHub API fallback was not used');
1176
1493
  }
1177
- return readLinearIssueWithCanonicalFallback(mount, path);
1494
+ const fallback = await findGithubIssueThroughConnection(mount, selector, config, issueArg);
1495
+ if (!fallback) {
1496
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: found 0 matches in the projection and GitHub API`);
1497
+ }
1498
+ return {
1499
+ issue: parseGithubFactoryIssue(fallback.path, fallback.content),
1500
+ resolution: {
1501
+ source: 'github-api-fallback',
1502
+ repo: fallback.repo,
1503
+ detail: `Relayfile projection could not answer (${unavailableReason}); resolved authoritatively through the GitHub API fallback.`,
1504
+ projection,
1505
+ },
1506
+ };
1178
1507
  }
1179
1508
  async function findIssuePath(mount, key, config) {
1180
1509
  if (config.issueSource === 'github') {
1181
- const number = Number(key.replace(/^#/, ''));
1182
- if (!Number.isInteger(number) || number <= 0) {
1183
- throw new Error(`${githubIssueResolutionError(config, key)}: expected a positive issue number`);
1184
- }
1185
- const configuredRepos = configuredGithubIssueRepos(config);
1510
+ const selector = parseGithubIssueSelector(key, config);
1511
+ const number = selector.number;
1512
+ const configuredRepos = selector.repo ? [selector.repo] : configuredGithubIssueRepos(config);
1186
1513
  if (configuredRepos.length === 0 && hasConfiguredGithubIssueRoutes(config)) {
1187
1514
  throw new Error(`${githubIssueResolutionError(config, key)}: configured repository routes do not resolve to owner/repo; ` +
1188
1515
  'set repos.default to owner/repo or map its label in repos.byLabel');
@@ -1206,7 +1533,7 @@ async function findIssuePath(mount, key, config) {
1206
1533
  })
1207
1534
  .sort((left, right) => githubIssuePathPreference(left) - githubIssuePathPreference(right) || left.localeCompare(right));
1208
1535
  if (matches.length === 0) {
1209
- throw new Error(`${githubIssueResolutionError(config, key)}: found 0 matches`);
1536
+ return undefined;
1210
1537
  }
1211
1538
  const matchesByRepo = new Map();
1212
1539
  for (const path of matches) {
@@ -1232,14 +1559,106 @@ async function findIssuePath(mount, key, config) {
1232
1559
  }
1233
1560
  return matches[0];
1234
1561
  }
1235
- function configuredGithubIssueRepos(config) {
1236
- const candidates = config.repos.default
1237
- ? [config.repos.default]
1238
- : [
1239
- ...Object.values(config.repos.byLabel),
1240
- ...Object.values(config.repos.byProject),
1241
- ...config.repos.keywordRules.map((rule) => rule.repo),
1242
- ];
1562
+ async function findGithubIssueThroughConnection(mount, selector, config, issueArg) {
1563
+ const github = mount.githubRead;
1564
+ if (!github) {
1565
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: projection cannot answer and the GitHub API fallback is unavailable`);
1566
+ }
1567
+ const repos = selector.repo ? [selector.repo] : configuredGithubIssueRepos(config);
1568
+ if (repos.length === 0) {
1569
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: projection cannot answer and no configured owner/repo is available for the GitHub API fallback`);
1570
+ }
1571
+ const lookups = await Promise.all(repos.map((repo) => github.getIssue(repo, selector.number)));
1572
+ const found = lookups.filter((lookup) => lookup.outcome === 'found');
1573
+ const indeterminate = lookups.some((lookup) => lookup.outcome === 'indeterminate');
1574
+ if (found.length === 0) {
1575
+ if (indeterminate) {
1576
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: the projection could not answer and the GitHub API fallback ` +
1577
+ 'could not determine whether the issue exists (one or more configured repositories are not visible without authentication)');
1578
+ }
1579
+ return undefined;
1580
+ }
1581
+ if (!selector.repo && !config.repos.default) {
1582
+ if (found.length > 1) {
1583
+ const matchedRepos = found.map((match) => match.issue.repo).sort((left, right) => left.localeCompare(right));
1584
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: GitHub API matches multiple repositories (${matchedRepos.join(', ')}); ` +
1585
+ 'set repos.default or pass a repo-qualified argument');
1586
+ }
1587
+ if (indeterminate) {
1588
+ // A single confirmed match is not the same as a unique one: at least
1589
+ // one other configured repository could not be checked, so a same-
1590
+ // numbered issue could exist there too. Refuse rather than silently
1591
+ // dispatch to whichever repo happened to answer.
1592
+ throw new Error(`${githubIssueResolutionError(config, issueArg)}: GitHub API found a match in ${found[0].issue.repo} but could not ` +
1593
+ 'confirm it is unique because one or more other configured repositories could not be checked without authentication; ' +
1594
+ 'set repos.default or pass a repo-qualified argument');
1595
+ }
1596
+ }
1597
+ return found[0].issue;
1598
+ }
1599
+ async function issueProjectionStatus(factory, mount, config) {
1600
+ const status = await factoryStatusWithMountHealth(factory, mount, config.loop.heartbeatPath, config.loop.registryPath, config.loop.heartbeatStaleMs);
1601
+ const githubConnection = mount.integrationConnections
1602
+ ? await mount.integrationConnections.getStatus('github')
1603
+ : undefined;
1604
+ return {
1605
+ outcome: 'no-match',
1606
+ ...(status.localMountDegraded !== undefined ? { localMountDegraded: status.localMountDegraded } : {}),
1607
+ ...(status.localMountDegradedReason ? { localMountDegradedReason: status.localMountDegradedReason } : {}),
1608
+ ...(status.eventListener ? { eventListener: status.eventListener } : {}),
1609
+ ...(githubConnection ? { githubConnection } : {}),
1610
+ };
1611
+ }
1612
+ function projectionStatusFromMount(mount) {
1613
+ const health = mount.getLocalMountHealth?.();
1614
+ return {
1615
+ outcome: 'no-match',
1616
+ ...(health ? { localMountDegraded: health.degraded } : {}),
1617
+ ...(health?.reason ? { localMountDegradedReason: health.reason } : {}),
1618
+ };
1619
+ }
1620
+ function githubProjectionUnavailableReason(projection) {
1621
+ if (projection.githubConnection && !projection.githubConnection.ready) {
1622
+ const detail = [projection.githubConnection.state, projection.githubConnection.initialSyncState]
1623
+ .filter((value) => Boolean(value))
1624
+ .join(', ');
1625
+ return `GitHub projection connection is not ready${detail ? ` (${detail})` : ''}`;
1626
+ }
1627
+ if (projection.localMountDegraded) {
1628
+ return projection.localMountDegradedReason ?? 'local mount is degraded';
1629
+ }
1630
+ if (projection.eventListener && !['subscribed', 'polling'].includes(projection.eventListener.state)) {
1631
+ return projection.eventListener.reason ?? `event listener is ${projection.eventListener.state}`;
1632
+ }
1633
+ return undefined;
1634
+ }
1635
+ export function parseGithubIssueSelector(key, config) {
1636
+ const qualified = key.match(/^([^#]+)#([1-9]\d*)$/u);
1637
+ const bare = key.match(/^#?([1-9]\d*)$/u);
1638
+ const number = Number(qualified?.[2] ?? bare?.[1]);
1639
+ if (!Number.isSafeInteger(number) || number <= 0) {
1640
+ throw new Error(`${githubIssueResolutionError(config, key)}: expected a positive issue number or repo-qualified reference (repo#number)`);
1641
+ }
1642
+ if (!qualified)
1643
+ return { number };
1644
+ const requested = qualified[1];
1645
+ const configured = allConfiguredGithubIssueRepos(config);
1646
+ // Resolve what the caller typed through the exact same canonicalization
1647
+ // every configured route already goes through (label mapping, org
1648
+ // prefixing, canonical-route lookup) instead of a second, ad-hoc
1649
+ // expansion — three review rounds each found a different normalization
1650
+ // gap (default-only, org-only, bare-label-vs-normalized) in a hand-rolled
1651
+ // comparison here. Comparison is only ever normalized against normalized.
1652
+ const [resolved] = resolveGithubIssueRepoCandidates(config, [requested]);
1653
+ const repo = resolved
1654
+ ? configured.find((candidate) => candidate.toLowerCase() === resolved.toLowerCase())
1655
+ : undefined;
1656
+ if (!repo) {
1657
+ throw new Error(`${githubIssueResolutionError(config, key)}: repository ${requested} is not one of the configured Factory routes`);
1658
+ }
1659
+ return { number, repo };
1660
+ }
1661
+ function resolveGithubIssueRepoCandidates(config, candidates) {
1243
1662
  const repos = new Map();
1244
1663
  const routedRepos = [
1245
1664
  ...Object.values(config.repos.byLabel),
@@ -1260,6 +1679,30 @@ function configuredGithubIssueRepos(config) {
1260
1679
  }
1261
1680
  return [...repos.values()];
1262
1681
  }
1682
+ /** Bare-number resolution stays default-only: when repos.default is set, a
1683
+ * bare issue number resolves against that single repo, not every route. */
1684
+ function configuredGithubIssueRepos(config) {
1685
+ const candidates = config.repos.default
1686
+ ? [config.repos.default]
1687
+ : [
1688
+ ...Object.values(config.repos.byLabel),
1689
+ ...Object.values(config.repos.byProject),
1690
+ ...config.repos.keywordRules.map((rule) => rule.repo),
1691
+ ];
1692
+ return resolveGithubIssueRepoCandidates(config, candidates);
1693
+ }
1694
+ /** Qualified `repo#number` selectors must validate against every configured
1695
+ * route, not just repos.default — a route reachable only through byLabel,
1696
+ * byProject, or keywordRules is still a valid dispatch target. */
1697
+ function allConfiguredGithubIssueRepos(config) {
1698
+ const candidates = [
1699
+ ...(config.repos.default ? [config.repos.default] : []),
1700
+ ...Object.values(config.repos.byLabel),
1701
+ ...Object.values(config.repos.byProject),
1702
+ ...config.repos.keywordRules.map((rule) => rule.repo),
1703
+ ];
1704
+ return resolveGithubIssueRepoCandidates(config, candidates);
1705
+ }
1263
1706
  function hasConfiguredGithubIssueRoutes(config) {
1264
1707
  return Boolean(config.repos.default ||
1265
1708
  Object.keys(config.repos.byLabel).length > 0 ||
@@ -1449,6 +1892,7 @@ Commands:
1449
1892
  babysit <PR|URL> Shepherd an existing open PR to green
1450
1893
  close-probe <PR> Probe/close a PR for an issue
1451
1894
  featuremap check Validate .agentworkforce/features/manifest.yaml
1895
+ intake notion <file> Normalize mounted Notion specs into Factory work
1452
1896
  fleet <command> Low-level fleet commands: spawn, roster, release
1453
1897
 
1454
1898
  Options: