@agent-relay/factory 0.1.13 → 0.1.15

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 (67) hide show
  1. package/README.md +21 -12
  2. package/dist/cli/fleet.d.ts +1 -0
  3. package/dist/cli/fleet.d.ts.map +1 -1
  4. package/dist/cli/fleet.js +117 -12
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/schema.d.ts +26 -16
  7. package/dist/config/schema.d.ts.map +1 -1
  8. package/dist/config/schema.js +4 -0
  9. package/dist/config/schema.js.map +1 -1
  10. package/dist/dispatch/relayflow-registry.d.ts +61 -0
  11. package/dist/dispatch/relayflow-registry.d.ts.map +1 -0
  12. package/dist/dispatch/relayflow-registry.js +133 -0
  13. package/dist/dispatch/relayflow-registry.js.map +1 -0
  14. package/dist/fleet/create-fleet.d.ts +6 -0
  15. package/dist/fleet/create-fleet.d.ts.map +1 -1
  16. package/dist/fleet/create-fleet.js +21 -0
  17. package/dist/fleet/create-fleet.js.map +1 -1
  18. package/dist/fleet/internal-fleet-client.d.ts +1 -0
  19. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  20. package/dist/fleet/internal-fleet-client.js +297 -24
  21. package/dist/fleet/internal-fleet-client.js.map +1 -1
  22. package/dist/index.d.ts +10 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/node/factory-node.d.ts +6 -5
  27. package/dist/node/factory-node.d.ts.map +1 -1
  28. package/dist/node/factory-node.js +58 -43
  29. package/dist/node/factory-node.js.map +1 -1
  30. package/dist/orchestrator/factory.d.ts +3 -0
  31. package/dist/orchestrator/factory.d.ts.map +1 -1
  32. package/dist/orchestrator/factory.js +931 -87
  33. package/dist/orchestrator/factory.js.map +1 -1
  34. package/dist/orchestrator/index.d.ts +1 -1
  35. package/dist/orchestrator/index.d.ts.map +1 -1
  36. package/dist/orchestrator/index.js +1 -1
  37. package/dist/orchestrator/index.js.map +1 -1
  38. package/dist/ports/index.d.ts +1 -1
  39. package/dist/ports/index.d.ts.map +1 -1
  40. package/dist/ports/state.d.ts +23 -0
  41. package/dist/ports/state.d.ts.map +1 -1
  42. package/dist/ports/writeback.d.ts +6 -0
  43. package/dist/ports/writeback.d.ts.map +1 -1
  44. package/dist/state/file-state-store.d.ts +20 -0
  45. package/dist/state/file-state-store.d.ts.map +1 -0
  46. package/dist/state/file-state-store.js +132 -0
  47. package/dist/state/file-state-store.js.map +1 -0
  48. package/dist/state/in-memory-state-store.d.ts +4 -1
  49. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  50. package/dist/state/in-memory-state-store.js +30 -0
  51. package/dist/state/in-memory-state-store.js.map +1 -1
  52. package/dist/subscriptions/event-client.d.ts +1 -0
  53. package/dist/subscriptions/event-client.d.ts.map +1 -1
  54. package/dist/subscriptions/event-client.js +1 -0
  55. package/dist/subscriptions/event-client.js.map +1 -1
  56. package/dist/triage/schema.d.ts +44 -44
  57. package/dist/types.d.ts +8 -1
  58. package/dist/types.d.ts.map +1 -1
  59. package/dist/writeback/github.d.ts +19 -1
  60. package/dist/writeback/github.d.ts.map +1 -1
  61. package/dist/writeback/github.js +130 -0
  62. package/dist/writeback/github.js.map +1 -1
  63. package/dist/writeback/index.d.ts +2 -1
  64. package/dist/writeback/index.d.ts.map +1 -1
  65. package/dist/writeback/index.js +1 -1
  66. package/dist/writeback/index.js.map +1 -1
  67. package/package.json +10 -4
@@ -7,10 +7,11 @@ import { GithubMergeGate, closeProbePr } from '../github/index.js';
7
7
  import { InMemoryStateStore } from '../state/in-memory-state-store.js';
8
8
  import { containsExplicitIssueReference, containsIssueKey } from '../issue-key-match.js';
9
9
  import { isInFactoryScope } from '../safety/factory-scope.js';
10
+ import { dispatchRelayflowForChangeEvent } from '../dispatch/relayflow-registry.js';
10
11
  import { deriveDescriptorsFromMount, prescriptiveInstructions, } from '@agent-relay/integration-prompts';
11
12
  import { renderAgentTask } from '../dispatch/templates.js';
12
13
  import { HeuristicTriage, TieredTriage, babysitterSpec, isShapeLabel, scopeFromLabels } from '../triage/index.js';
13
- import { MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
14
+ import { GhCliGithubWriteback, MountGithubRead, MountLinearWriteback, MountSlackWriteback, slackChannelAliases, slackChannelSegment } from '../writeback/index.js';
14
15
  import { asRecord, parseJsonContent, stableHash, wrappedPayload } from '../writeback/shared.js';
15
16
  import { issueKey } from './batch-tracker.js';
16
17
  import { findAgentProcessByName, readProcessIdentity } from './process-identity.js';
@@ -19,6 +20,7 @@ const ISSUE_ROOT = '/linear/issues';
19
20
  const GITHUB_ISSUE_ROOT = '/github/repos';
20
21
  const READY_EVENTS_LIMIT = 100;
21
22
  const LIVE_ISSUE_GLOB = `${ISSUE_ROOT}/**`;
23
+ const LIVE_RELAYFLOW_GLOB = '/**';
22
24
  // Subscribe broadly under /github/repos and let isGithubIssueFilePath() /
23
25
  // githubPullPathParts() re-validate the exact shape in the callback.
24
26
  // globMatchesPath() treats a non-terminal `**` as a single-segment wildcard, so
@@ -39,6 +41,7 @@ const SLACK_REPLY_POLL_INTERVAL_MS = 5_000;
39
41
  const AGENT_QUESTION_DEDUPE_LIMIT = 500;
40
42
  const AGENT_NEEDS_INPUT_MARKER = '[factory-needs-input]';
41
43
  const LEGACY_AGENT_NEEDS_INPUT_MARKER = 'FACTORY_NEEDS_INPUT';
44
+ const GITHUB_ESCALATION_MARKER_PREFIX = 'factory-escalation:';
42
45
  // The babysitter DMs `factory` with this marker once it believes the PR is
43
46
  // green; the orchestrator confirms readiness with one authoritative gh read
44
47
  // before transitioning the issue to Human Review.
@@ -58,6 +61,7 @@ const DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS = 15_000;
58
61
  const REMOTE_OPERATION_PROGRESS_INTERVAL_MS = 15_000;
59
62
  const REMOTE_OPERATION_SLOW_WARN_MS = 30_000;
60
63
  const GITHUB_FACTORY_LABEL = 'factory';
64
+ const GITHUB_LIFECYCLE_LABELS = new Set(['factory:in-progress', 'factory:human-review']);
61
65
  const GITHUB_MIRROR_TITLE_PREFIX = '[factory]';
62
66
  const GITHUB_MIRROR_SOURCE_PREFIX = 'Source: ';
63
67
  export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
@@ -76,6 +80,7 @@ export class FactoryLoop {
76
80
  #fleet;
77
81
  #triage;
78
82
  #linear;
83
+ #githubWriteback;
79
84
  #slack;
80
85
  #mergeGate;
81
86
  #probeCloser;
@@ -91,6 +96,7 @@ export class FactoryLoop {
91
96
  #terminationGraceMs;
92
97
  #state;
93
98
  #workspaceId;
99
+ #relayflows;
94
100
  #batchView;
95
101
  #batchReady;
96
102
  #listeners = new Map();
@@ -99,6 +105,9 @@ export class FactoryLoop {
99
105
  #dispatchInFlight = new Map();
100
106
  #slackWatchers = new Map();
101
107
  #slackWatcherStarts = new Map();
108
+ #githubIssueCommentWatchers = new Map();
109
+ #githubIssueCommentWatchStates = new Map();
110
+ #githubIssueCommentQueues = new Map();
102
111
  #resolvedSlackChannelDir;
103
112
  #slackChannelDirRefresh;
104
113
  // Agents we've already logged an ambiguous-PID-lookup warning for, so the
@@ -110,6 +119,7 @@ export class FactoryLoop {
110
119
  // same notice every cycle. Cleared once the issue dispatches successfully.
111
120
  #labelDispatchFailures = new Map();
112
121
  #pendingSlackClarifications = new Map();
122
+ #pendingGithubClarifications = new Map();
113
123
  #postMergeDoneAdvances = new Set();
114
124
  #slackDegraded = false;
115
125
  #slackDegradedReason;
@@ -158,6 +168,7 @@ export class FactoryLoop {
158
168
  // undefined = readiness not yet probed; resolved lazily so the standalone
159
169
  // runOnce() path (which skips #start) still ingests when the mount is present.
160
170
  #githubIngestionEnabled;
171
+ #resolvedIssueSource;
161
172
  #integrationInstructions;
162
173
  #integrationInstructionsRefresh;
163
174
  #starting;
@@ -177,6 +188,7 @@ export class FactoryLoop {
177
188
  this.#linear = ports.linear ?? MountLinearWriteback(ports.mount, {
178
189
  safety: config.safety,
179
190
  });
191
+ this.#githubWriteback = ports.githubWriteback ?? new GhCliGithubWriteback();
180
192
  this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
181
193
  void (ports.github ?? MountGithubRead(ports.mount));
182
194
  this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
@@ -195,6 +207,7 @@ export class FactoryLoop {
195
207
  this.#readChildPids = ports.readChildPids;
196
208
  this.#terminationGraceMs = ports.terminationGraceMs;
197
209
  this.#workspaceId = config.workspaceId ?? 'default';
210
+ this.#relayflows = ports.relayflows;
198
211
  this.#state = ports.stateStore ?? new InMemoryStateStore({
199
212
  batchSize: config.batchSize,
200
213
  agentQuestionDedupeLimit: AGENT_QUESTION_DEDUPE_LIMIT,
@@ -287,18 +300,26 @@ export class FactoryLoop {
287
300
  }
288
301
  async #start(opts) {
289
302
  this.#stopping = false;
290
- const ready = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
291
- if (ready !== 'ready') {
292
- this.#error(new Error(`${ISSUE_ROOT} sub-root is not mounted`));
303
+ const issueSource = await this.#issueSource();
304
+ if (issueSource === 'linear') {
305
+ const ready = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
306
+ if (ready !== 'ready') {
307
+ this.#error(new Error(`${ISSUE_ROOT} sub-root is not mounted`));
308
+ return;
309
+ }
310
+ }
311
+ const githubReady = await this.#ensureGithubIngestionReady();
312
+ if (issueSource === 'github' && !githubReady) {
313
+ this.#error(new Error(`${GITHUB_ISSUE_ROOT} sub-root is not mounted`));
293
314
  return;
294
315
  }
295
- await this.#ensureGithubIngestionReady();
296
316
  this.#wireFleetEvents();
297
317
  if ((opts.mode ?? 'live') === 'live') {
298
318
  this.#started = true;
299
319
  try {
300
320
  await this.#startLiveSubscription(opts.liveSubscription);
301
321
  await this.#rearmSlackReplyWatchers();
322
+ await this.#rearmGithubIssueCommentWatchers();
302
323
  this.#scheduleCompletionSweep(0);
303
324
  return;
304
325
  }
@@ -309,7 +330,8 @@ export class FactoryLoop {
309
330
  }
310
331
  }
311
332
  await this.#backfillReadyIssues();
312
- this.#subscription = this.#mount.subscribe([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB], (event) => {
333
+ this.#subscription = this.#mount.subscribe(this.#subscriptionGlobs([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB]), (event) => {
334
+ void this.#dispatchRelayflowEvent(event);
313
335
  // The SDK types `resource` as always-present, but the polling fallback and
314
336
  // degraded-sync paths can deliver events without it. Skip those rather
315
337
  // than throwing (which would otherwise crash the subscription handler).
@@ -329,6 +351,7 @@ export class FactoryLoop {
329
351
  });
330
352
  this.#started = true;
331
353
  await this.#rearmSlackReplyWatchers();
354
+ await this.#rearmGithubIssueCommentWatchers();
332
355
  this.#scheduleCompletionSweep(0);
333
356
  }
334
357
  async stop() {
@@ -353,6 +376,10 @@ export class FactoryLoop {
353
376
  await this.#boundedStopTeardown('factory subscription unsubscribe', () => subscription?.unsubscribe());
354
377
  await Promise.all([...this.#slackWatchers.values()].map((watcher) => watcher.stop()));
355
378
  this.#slackWatchers.clear();
379
+ await Promise.all([...this.#githubIssueCommentWatchers.values()].map((watcher) => watcher.stop()));
380
+ this.#githubIssueCommentWatchers.clear();
381
+ this.#githubIssueCommentWatchStates.clear();
382
+ this.#githubIssueCommentQueues.clear();
356
383
  await this.#state.clearSlackThreads(this.#workspaceId);
357
384
  this.#slackWatcherStarts.clear();
358
385
  this.#offAgentExit?.();
@@ -415,7 +442,7 @@ export class FactoryLoop {
415
442
  // LIVE_GITHUB_ISSUE_GLOB is a terminal `${GITHUB_ISSUE_ROOT}/**`, so it
416
443
  // already covers the PR change events the babysitter consumes; pull-event
417
444
  // *processing* is gated on babysitter.enabled in #prepareLiveEventForDrain.
418
- this.#subscription = this.#mount.subscribe([LIVE_ISSUE_GLOB, LIVE_GITHUB_ISSUE_GLOB], (event) => {
445
+ this.#subscription = this.#mount.subscribe(this.#subscriptionGlobs([LIVE_ISSUE_GLOB, LIVE_GITHUB_ISSUE_GLOB]), (event) => {
419
446
  this.#enqueueLiveEvent(event);
420
447
  }, { from: 'now', coalesce: 'none' });
421
448
  }
@@ -618,9 +645,12 @@ export class FactoryLoop {
618
645
  const batch = events.splice(0, LIVE_EVENT_DRAIN_BATCH_SIZE);
619
646
  const paths = [];
620
647
  for (const event of batch) {
621
- const path = await this.#prepareLiveEventForDrain(event, seenIssueKeys);
622
- if (path) {
623
- paths.push(path);
648
+ const prepared = await this.#prepareLiveEventForDrain(event, seenIssueKeys);
649
+ if (prepared.dispatchRelayflow) {
650
+ void this.#dispatchRelayflowEvent(event);
651
+ }
652
+ if (prepared.path) {
653
+ paths.push(prepared.path);
624
654
  }
625
655
  }
626
656
  await Promise.all(paths.map((path) => this.#handlePreparedLiveChange(path)));
@@ -652,14 +682,18 @@ export class FactoryLoop {
652
682
  return;
653
683
  await this.#refreshLiveHeartbeat();
654
684
  }
685
+ #subscriptionGlobs(factoryGlobs) {
686
+ return this.#relayflows ? [LIVE_RELAYFLOW_GLOB] : factoryGlobs;
687
+ }
655
688
  async #prepareLiveEventForDrain(event, seenIssueKeys) {
656
689
  const path = changeEventPath(event);
657
690
  if (!path) {
658
- return undefined;
691
+ return { dispatchRelayflow: false };
659
692
  }
660
693
  const isPullPath = isGithubPullFilePath(path);
661
- if (!isIssueFilePath(path) && !isGithubIssueFilePath(path) && !isPullPath) {
662
- return undefined;
694
+ const isFactoryPath = isIssueFilePath(path) || isGithubIssueFilePath(path) || isPullPath;
695
+ if (!isFactoryPath && !this.#relayflows) {
696
+ return { dispatchRelayflow: false };
663
697
  }
664
698
  if (isBeforeLiveCutoff(event.occurredAt, this.#liveConnectStartedAtMs, this.#liveReplaySkewMarginMs)) {
665
699
  this.#increment('liveReplayEventsSuppressed');
@@ -671,7 +705,7 @@ export class FactoryLoop {
671
705
  connectStartedAt: new Date(this.#liveConnectStartedAtMs).toISOString(),
672
706
  replaySkewMarginMs: this.#liveReplaySkewMarginMs,
673
707
  });
674
- return undefined;
708
+ return { dispatchRelayflow: false };
675
709
  }
676
710
  if (isAtOrBeforeHighWatermark(event.id, this.#liveEventHighWatermark)) {
677
711
  this.#increment('liveReplayEventsSuppressed');
@@ -681,7 +715,7 @@ export class FactoryLoop {
681
715
  highWatermark: this.#liveEventHighWatermark,
682
716
  path,
683
717
  });
684
- return undefined;
718
+ return { dispatchRelayflow: false };
685
719
  }
686
720
  const dedupeKey = liveEventDedupeKey(event);
687
721
  if (dedupeKey) {
@@ -691,7 +725,7 @@ export class FactoryLoop {
691
725
  id: event.id,
692
726
  path,
693
727
  });
694
- return undefined;
728
+ return { dispatchRelayflow: false };
695
729
  }
696
730
  rememberLiveEvent(this.#seenLiveEvents, dedupeKey);
697
731
  }
@@ -699,6 +733,9 @@ export class FactoryLoop {
699
733
  this.#increment('liveEventsMissingIdentity');
700
734
  this.#logger.warn?.('[factory] live issue event missing stable identity', { path });
701
735
  }
736
+ if (!isFactoryPath) {
737
+ return { dispatchRelayflow: true };
738
+ }
702
739
  if (isGithubIssueFilePath(path)) {
703
740
  const sourceKey = `github:${path}`;
704
741
  if (seenIssueKeys.has(sourceKey)) {
@@ -707,11 +744,11 @@ export class FactoryLoop {
707
744
  id: event.id,
708
745
  path,
709
746
  });
710
- return undefined;
747
+ return { dispatchRelayflow: false };
711
748
  }
712
749
  seenIssueKeys.add(sourceKey);
713
750
  this.#recordArrivalLatency(event);
714
- return path;
751
+ return { path, dispatchRelayflow: true };
715
752
  }
716
753
  if (isPullPath) {
717
754
  // Dedupe PR change events by path within a drain; the babysitter routing
@@ -719,11 +756,11 @@ export class FactoryLoop {
719
756
  const sourceKey = `pull:${path}`;
720
757
  if (seenIssueKeys.has(sourceKey)) {
721
758
  this.#increment('liveDuplicatePrEventsSuppressed');
722
- return undefined;
759
+ return { dispatchRelayflow: false };
723
760
  }
724
761
  seenIssueKeys.add(sourceKey);
725
762
  this.#recordArrivalLatency(event);
726
- return path;
763
+ return { path, dispatchRelayflow: true };
727
764
  }
728
765
  const issueKey = keyFromPath(path);
729
766
  if (seenIssueKeys.has(issueKey)) {
@@ -733,7 +770,7 @@ export class FactoryLoop {
733
770
  path,
734
771
  issue: issueKey,
735
772
  });
736
- return undefined;
773
+ return { dispatchRelayflow: false };
737
774
  }
738
775
  const issueRef = { key: issueKey, uuid: uuidFromPath(path) ?? issueKey, path };
739
776
  const batch = await this.#batch();
@@ -744,11 +781,40 @@ export class FactoryLoop {
744
781
  path,
745
782
  issue: issueKey,
746
783
  });
747
- return undefined;
784
+ return { dispatchRelayflow: false };
748
785
  }
749
786
  seenIssueKeys.add(issueKey);
750
787
  this.#recordArrivalLatency(event);
751
- return path;
788
+ return { path, dispatchRelayflow: true };
789
+ }
790
+ async #dispatchRelayflowEvent(event) {
791
+ const relayflows = this.#relayflows;
792
+ if (!relayflows)
793
+ return;
794
+ try {
795
+ const result = await dispatchRelayflowForChangeEvent(event, relayflows.registry, {
796
+ cwd: relayflows.cwd ?? process.cwd(),
797
+ mountRoot: relayflows.mountRoot ?? this.#integrationsMountRoot(),
798
+ workflowRunner: relayflows.workflowRunner,
799
+ });
800
+ if (!result)
801
+ return;
802
+ this.#increment('relayflowEventsDispatched');
803
+ this.#logger.info?.('[factory] relayflow dispatched for integration event', {
804
+ trigger: result.trigger,
805
+ templatePath: result.templatePath,
806
+ runId: result.runId,
807
+ status: result.status,
808
+ });
809
+ }
810
+ catch (error) {
811
+ this.#increment('relayflowDispatchErrors');
812
+ this.#logger.warn?.('[factory] relayflow dispatch failed for integration event', {
813
+ eventId: event.id,
814
+ path: changeEventPath(event),
815
+ error,
816
+ });
817
+ }
752
818
  }
753
819
  async #handlePreparedLiveChange(path) {
754
820
  if (isGithubPullFilePath(path)) {
@@ -888,7 +954,13 @@ export class FactoryLoop {
888
954
  this.#logger.info?.('[factory] run-once started', { dryRun });
889
955
  let report;
890
956
  try {
891
- await this.#ingestGithubIssues({ dryRun });
957
+ const issueSource = await this.#issueSource();
958
+ if (issueSource === 'linear') {
959
+ await this.#ingestGithubIssues({ dryRun });
960
+ }
961
+ else {
962
+ await this.#ensureGithubIngestionReady();
963
+ }
892
964
  const paths = await this.#readyIssuePaths();
893
965
  const pulled = [];
894
966
  const triaged = [];
@@ -900,7 +972,7 @@ export class FactoryLoop {
900
972
  const issue = await this.#readIssue(path);
901
973
  readyIssueReads += 1;
902
974
  lastReadyReadProgressAtMs = this.#logTimedProgress('[factory] Linear ready issue read progress', startedAtMs, lastReadyReadProgressAtMs, { read: readyIssueReads, total: paths.length, path });
903
- if (issue) {
975
+ if (issue && issueSource === 'linear') {
904
976
  await this.#recordCanonicalIssueState(issue);
905
977
  }
906
978
  if (!issue) {
@@ -917,7 +989,7 @@ export class FactoryLoop {
917
989
  skipped.push({ issue: issueRef(issue), reason: 'already tracked' });
918
990
  continue;
919
991
  }
920
- if (!this.#states.isRole(issue.stateId, 'readyForAgent')) {
992
+ if (!this.#isIssueReady(issue)) {
921
993
  skipped.push({ issue: issueRef(issue), reason: 'live state is not ready-for-agent' });
922
994
  continue;
923
995
  }
@@ -925,7 +997,7 @@ export class FactoryLoop {
925
997
  skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
926
998
  continue;
927
999
  }
928
- if (!isRealLinearIssue(issue)) {
1000
+ if (!isDispatchableIssue(issue)) {
929
1001
  skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
930
1002
  continue;
931
1003
  }
@@ -1153,14 +1225,14 @@ export class FactoryLoop {
1153
1225
  this.#error(error, decision.issue);
1154
1226
  throw error;
1155
1227
  }
1156
- if (!isRealLinearIssue(liveIssue)) {
1228
+ if (!isDispatchableIssue(liveIssue)) {
1157
1229
  const error = new Error(`Refusing to dispatch ${decision.issue.key}: not reconciled real Linear issue`);
1158
1230
  this.#error(error, decision.issue);
1159
1231
  throw error;
1160
1232
  }
1161
1233
  const escalationReason = triageEscalationReason(decision);
1162
1234
  if (escalationReason) {
1163
- const replayedResult = await this.#escalateTriageToSlack(decision, escalationReason, dryRun);
1235
+ const replayedResult = await this.#escalateTriage(decision, escalationReason, dryRun);
1164
1236
  this.#recordTriageEscalation(decision, escalationReason);
1165
1237
  return replayedResult ?? { issue: decision.issue, agents: [], dryRun };
1166
1238
  }
@@ -1179,7 +1251,7 @@ export class FactoryLoop {
1179
1251
  const signature = labelDispatchFailureSignature(labelDispatch);
1180
1252
  if (this.#labelDispatchFailures.get(decision.issue.key) !== signature) {
1181
1253
  try {
1182
- await this.#linear.postComment(liveIssue, comment);
1254
+ await this.#postIssueComment(liveIssue, comment);
1183
1255
  // Record only after a successful post so a failed writeback retries
1184
1256
  // next cycle rather than being suppressed as already-notified.
1185
1257
  this.#labelDispatchFailures.set(decision.issue.key, signature);
@@ -1228,17 +1300,22 @@ export class FactoryLoop {
1228
1300
  let implementingStateId;
1229
1301
  if (!dryRun) {
1230
1302
  const issue = await this.#readIssue(dispatchDecision.issue.path);
1231
- if (!issue || !this.#states.isRole(issue.stateId, 'readyForAgent')) {
1303
+ if (!issue || !this.#isIssueReady(issue)) {
1232
1304
  throw new Error(`Live state changed before writeback for ${dispatchDecision.issue.key}`);
1233
1305
  }
1234
- implementingStateId = this.#states.idFor(issue.team, 'agentImplementing');
1235
1306
  try {
1236
- await this.#linear.postComment(issue, comment);
1307
+ await this.#postIssueComment(issue, comment);
1237
1308
  }
1238
1309
  catch (error) {
1239
1310
  this.#logger.warn?.('[factory] comment writeback skipped', error);
1240
1311
  }
1241
- await this.#linear.setState(issue, implementingStateId);
1312
+ if (isGithubIssue(issue)) {
1313
+ await this.#githubWriteback.setStatus(issue, 'in-progress');
1314
+ }
1315
+ else {
1316
+ implementingStateId = this.#states.idFor(issue.team, 'agentImplementing');
1317
+ await this.#linear.setState(issue, implementingStateId);
1318
+ }
1242
1319
  this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
1243
1320
  }
1244
1321
  const result = {
@@ -1256,6 +1333,7 @@ export class FactoryLoop {
1256
1333
  await this.#sendImplementerTask(record);
1257
1334
  await this.#sendCriticalReviewerMessage(record);
1258
1335
  await this.#injectPendingSlackClarification(record);
1336
+ await this.#injectPendingGithubClarification(record);
1259
1337
  }
1260
1338
  return result;
1261
1339
  }
@@ -1320,24 +1398,24 @@ export class FactoryLoop {
1320
1398
  }
1321
1399
  }
1322
1400
  async #handleChange(path, opts = {}) {
1323
- if (!isIssueFilePath(path)) {
1401
+ if (!isIssueFilePath(path) && !isGithubIssueFilePath(path)) {
1324
1402
  return;
1325
1403
  }
1326
1404
  try {
1327
1405
  const issue = await this.#readIssue(path);
1328
- if (issue) {
1406
+ if (issue && !isGithubIssue(issue)) {
1329
1407
  await this.#recordCanonicalIssueState(issue);
1330
1408
  }
1331
- if (!issue || !this.#states.isRole(issue.stateId, 'readyForAgent')) {
1409
+ if (!issue || !this.#isIssueReady(issue)) {
1332
1410
  return;
1333
1411
  }
1334
- if (opts.requireRealIssue && !isRealLinearIssue(issue)) {
1412
+ if (opts.requireRealIssue && !isDispatchableIssue(issue)) {
1335
1413
  return;
1336
1414
  }
1337
1415
  if (!isInFactoryScope(issue, this.#config.safety)) {
1338
1416
  return;
1339
1417
  }
1340
- if (!isRealLinearIssue(issue)) {
1418
+ if (!isDispatchableIssue(issue)) {
1341
1419
  return;
1342
1420
  }
1343
1421
  const batch = await this.#batch();
@@ -1350,7 +1428,7 @@ export class FactoryLoop {
1350
1428
  const decision = await this.triageIssue(issue);
1351
1429
  const escalationReason = triageEscalationReason(decision);
1352
1430
  if (escalationReason) {
1353
- await this.#escalateTriageToSlack(decision, escalationReason, this.#config.dryRun);
1431
+ await this.#escalateTriage(decision, escalationReason, this.#config.dryRun);
1354
1432
  this.#recordTriageEscalation(decision, escalationReason);
1355
1433
  return;
1356
1434
  }
@@ -1367,6 +1445,42 @@ export class FactoryLoop {
1367
1445
  this.#logger.error?.('[factory] failed to handle issue change', error);
1368
1446
  }
1369
1447
  }
1448
+ async #issueSource() {
1449
+ if (this.#resolvedIssueSource) {
1450
+ return this.#resolvedIssueSource;
1451
+ }
1452
+ if (this.#config.issueSource) {
1453
+ this.#resolvedIssueSource = this.#config.issueSource;
1454
+ return this.#resolvedIssueSource;
1455
+ }
1456
+ const linearReady = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
1457
+ this.#resolvedIssueSource = linearReady === 'ready' ? 'linear' : 'github';
1458
+ if (this.#resolvedIssueSource === 'github') {
1459
+ this.#logger.info?.('[factory] Linear issue source is not connected; using GitHub issues');
1460
+ }
1461
+ return this.#resolvedIssueSource;
1462
+ }
1463
+ #isIssueReady(issue) {
1464
+ if (!isGithubIssue(issue)) {
1465
+ return this.#states.isRole(issue.stateId, 'readyForAgent');
1466
+ }
1467
+ const githubState = (issue.state?.name ?? stringValue(wrappedPayload(issue.raw).state) ?? '').trim().toLowerCase();
1468
+ if (githubState === 'closed') {
1469
+ return false;
1470
+ }
1471
+ if (issue.labels.some((label) => GITHUB_LIFECYCLE_LABELS.has(label.trim().toLowerCase()))) {
1472
+ return false;
1473
+ }
1474
+ const required = this.#config.safety.requireLabel.trim().toLowerCase();
1475
+ return Boolean(required) && issue.labels.some((label) => label.trim().toLowerCase() === required);
1476
+ }
1477
+ async #postIssueComment(issue, body) {
1478
+ if (isGithubIssue(issue)) {
1479
+ await this.#githubWriteback.postComment(issue, body);
1480
+ return;
1481
+ }
1482
+ await this.#linear.postComment(issue, body);
1483
+ }
1370
1484
  // Probes the GitHub issue sub-root at most once and caches the verdict so
1371
1485
  // repeated iterations skip listTree calls when the mount is absent.
1372
1486
  async #ensureGithubIngestionReady() {
@@ -1481,6 +1595,12 @@ export class FactoryLoop {
1481
1595
  if (!ghIssue) {
1482
1596
  return;
1483
1597
  }
1598
+ if (await this.#issueSource() === 'github') {
1599
+ if (!githubIssueIsClosed(ghIssue) && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
1600
+ await this.#handleChange(ghIssue.path);
1601
+ }
1602
+ return;
1603
+ }
1484
1604
  if (githubIssueIsClosed(ghIssue)) {
1485
1605
  const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
1486
1606
  if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
@@ -1497,7 +1617,7 @@ export class FactoryLoop {
1497
1617
  }
1498
1618
  return;
1499
1619
  }
1500
- if (!githubIssueHasFactoryLabel(ghIssue)) {
1620
+ if (!githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
1501
1621
  return;
1502
1622
  }
1503
1623
  const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
@@ -1775,6 +1895,9 @@ export class FactoryLoop {
1775
1895
  }
1776
1896
  }
1777
1897
  async #readyIssuePaths() {
1898
+ if (await this.#issueSource() === 'github') {
1899
+ return this.#githubIssuePaths();
1900
+ }
1778
1901
  const pathsByKey = new Map();
1779
1902
  const canonicalPathsByKey = new Map();
1780
1903
  for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'Linear ready issue canonical discovery')) {
@@ -1800,6 +1923,10 @@ export class FactoryLoop {
1800
1923
  }
1801
1924
  async #readIssue(path) {
1802
1925
  try {
1926
+ if (isGithubIssueFilePath(path)) {
1927
+ const githubIssue = await this.#readGithubIssue(path);
1928
+ return githubIssue ? githubIssueAsFactoryIssue(githubIssue) : undefined;
1929
+ }
1803
1930
  // Newly-synced issues land as a change-event STUB at the primary
1804
1931
  // /linear/issues/<key>__<uuid>.json path (no state/url/team); the full
1805
1932
  // record lands at the by-id / by-uuid aliases. Read the canonical sibling
@@ -2291,15 +2418,11 @@ export class FactoryLoop {
2291
2418
  issue: record.issue.key,
2292
2419
  });
2293
2420
  }
2294
- await this.#postAgentQuestionToSlack(record, question);
2421
+ await this.#postAgentQuestion(record, question);
2295
2422
  }
2296
- async #postAgentQuestionToSlack(record, question) {
2423
+ async #postAgentQuestion(record, question) {
2297
2424
  if (!this.#slack || !this.#config.slack) {
2298
- this.#increment('agentQuestionsSkippedNoSlack');
2299
- this.#logger.warn?.('[factory] agent question has no Slack channel configured', {
2300
- issue: record.issue,
2301
- from: question.agentName,
2302
- });
2425
+ await this.#postAgentQuestionToGithub(record, question);
2303
2426
  return;
2304
2427
  }
2305
2428
  if (await this.#shouldSkipSlackWriteback('agent-question')) {
@@ -2332,6 +2455,401 @@ export class FactoryLoop {
2332
2455
  this.#logger.warn?.(`[factory] failed to post agent question for ${record.issue.key}`, error);
2333
2456
  }
2334
2457
  }
2458
+ async #postAgentQuestionToGithub(record, question) {
2459
+ const correlationId = githubEscalationCorrelationId('agent-question', record.issue, question.question);
2460
+ const issue = await this.#readIssue(record.issue.path);
2461
+ const source = issue ? githubIssueSourceRef(issue) : undefined;
2462
+ const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
2463
+ if (!issue || !source || !authorizedAuthor) {
2464
+ this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
2465
+ return;
2466
+ }
2467
+ await this.#addGithubIssueCommentWatch(record.issue, source, {
2468
+ correlationId,
2469
+ kind: 'agent-question',
2470
+ authorizedAuthor,
2471
+ });
2472
+ try {
2473
+ await this.#githubWriteback.postComment(issue, [
2474
+ `${record.issue.key}: ${question.agentName} needs input.`,
2475
+ `Question: ${question.question}`,
2476
+ `Authorized responder: @${authorizedAuthor} (the issue reporter).`,
2477
+ `Reply with a comment that starts with \`${githubReplyPrefix(correlationId)}\`.`,
2478
+ '',
2479
+ githubEscalationMarker(correlationId),
2480
+ ].join('\n'));
2481
+ this.#increment('agentQuestionsPostedToGithub');
2482
+ }
2483
+ catch (error) {
2484
+ await this.#removeGithubIssueCommentPending(source, correlationId);
2485
+ this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'GitHub issue comment writeback failed', error);
2486
+ }
2487
+ }
2488
+ async #addGithubIssueCommentWatch(issue, source, pending) {
2489
+ const key = githubIssueSourceKey(source);
2490
+ let watch = this.#githubIssueCommentWatchStates.get(key);
2491
+ if (!watch) {
2492
+ const persisted = await this.#state.listGithubIssueCommentWatches(this.#workspaceId);
2493
+ watch = persisted.find(([persistedKey]) => persistedKey === key)?.[1];
2494
+ }
2495
+ if (!watch) {
2496
+ const sinceCommentId = await this.#latestGithubIssueCommentId(source);
2497
+ watch = {
2498
+ issue,
2499
+ source,
2500
+ pending: [],
2501
+ sinceCommentId,
2502
+ lastSeenCommentId: sinceCommentId,
2503
+ processedCommentIds: [],
2504
+ };
2505
+ }
2506
+ const added = !watch.pending.some((candidate) => candidate.correlationId === pending.correlationId);
2507
+ if (added) {
2508
+ watch.pending.push(pending);
2509
+ }
2510
+ await this.#watchGithubIssueComments(watch);
2511
+ return added;
2512
+ }
2513
+ async #watchGithubIssueComments(watch) {
2514
+ watch = normalizeGithubIssueCommentWatch(watch);
2515
+ const key = githubIssueSourceKey(watch.source);
2516
+ this.#githubIssueCommentWatchStates.set(key, watch);
2517
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
2518
+ if (this.#githubIssueCommentWatchers.has(key)) {
2519
+ await this.#replayGithubIssueComments(key);
2520
+ return false;
2521
+ }
2522
+ const source = watch.source;
2523
+ let stopped = false;
2524
+ const handle = async (event) => {
2525
+ const path = changeEventPath(event);
2526
+ const parts = path ? githubIssueCommentPathParts(path) : undefined;
2527
+ if (stopped ||
2528
+ !path ||
2529
+ !parts ||
2530
+ parts.owner.toLowerCase() !== source.owner.toLowerCase() ||
2531
+ parts.repo.toLowerCase() !== source.repo.toLowerCase() ||
2532
+ parts.number !== source.number) {
2533
+ return;
2534
+ }
2535
+ try {
2536
+ await this.#enqueueGithubIssueComment(key, path);
2537
+ }
2538
+ catch (error) {
2539
+ this.#logger.error?.('[factory] failed to handle GitHub issue comment reply', {
2540
+ issue: watch.issue,
2541
+ path,
2542
+ error,
2543
+ });
2544
+ this.#increment('githubIssueCommentReplyErrors');
2545
+ }
2546
+ };
2547
+ let subscription;
2548
+ try {
2549
+ subscription = this.#mount.subscribe(githubIssueCommentGlobs(source), (event) => {
2550
+ void handle(event);
2551
+ });
2552
+ }
2553
+ catch (error) {
2554
+ this.#githubIssueCommentWatchStates.delete(key);
2555
+ this.#surfaceEscalationDeliveryFailure('github-reply-watcher', watch.issue, githubEscalationCorrelationId('reply-watcher', watch.issue, key), 'GitHub issue comment reply subscription failed', error);
2556
+ return false;
2557
+ }
2558
+ this.#githubIssueCommentWatchers.set(key, {
2559
+ stop: async () => {
2560
+ stopped = true;
2561
+ await this.#boundedStopTeardown('GitHub issue comment subscription unsubscribe', () => subscription.unsubscribe());
2562
+ },
2563
+ });
2564
+ await this.#replayGithubIssueComments(key);
2565
+ return true;
2566
+ }
2567
+ async #stopGithubIssueCommentWatcher(source, clearPersisted = true) {
2568
+ const key = githubIssueSourceKey(source);
2569
+ const watcher = this.#githubIssueCommentWatchers.get(key);
2570
+ this.#githubIssueCommentWatchers.delete(key);
2571
+ this.#githubIssueCommentWatchStates.delete(key);
2572
+ if (clearPersisted) {
2573
+ await this.#state.clearGithubIssueCommentWatch(this.#workspaceId, key);
2574
+ }
2575
+ await watcher?.stop();
2576
+ }
2577
+ async #stopGithubIssueCommentWatcherForIssue(issue) {
2578
+ const keysToStop = [];
2579
+ for (const [key, watch] of this.#githubIssueCommentWatchStates) {
2580
+ if (watch.issue.uuid === issue.uuid || watch.issue.path === issue.path) {
2581
+ keysToStop.push(key);
2582
+ }
2583
+ }
2584
+ for (const key of keysToStop) {
2585
+ const watcher = this.#githubIssueCommentWatchers.get(key);
2586
+ this.#githubIssueCommentWatchers.delete(key);
2587
+ this.#githubIssueCommentWatchStates.delete(key);
2588
+ await this.#state.clearGithubIssueCommentWatch(this.#workspaceId, key);
2589
+ await watcher?.stop();
2590
+ }
2591
+ }
2592
+ async #removeGithubIssueCommentPending(source, correlationId) {
2593
+ const key = githubIssueSourceKey(source);
2594
+ const watch = this.#githubIssueCommentWatchStates.get(key);
2595
+ if (!watch)
2596
+ return;
2597
+ watch.pending = watch.pending.filter((pending) => pending.correlationId !== correlationId);
2598
+ if (watch.pending.length === 0) {
2599
+ await this.#stopGithubIssueCommentWatcher(source);
2600
+ return;
2601
+ }
2602
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
2603
+ }
2604
+ async #rearmGithubIssueCommentWatchers() {
2605
+ for (const [, watch] of await this.#state.listGithubIssueCommentWatches(this.#workspaceId)) {
2606
+ if (watch.pending.length === 0)
2607
+ continue;
2608
+ try {
2609
+ await this.#watchGithubIssueComments(watch);
2610
+ this.#increment('githubIssueCommentWatchersRearmed');
2611
+ }
2612
+ catch (error) {
2613
+ this.#logger.warn?.('[factory] failed to re-arm GitHub issue comment watcher', {
2614
+ issue: watch.issue,
2615
+ error,
2616
+ });
2617
+ }
2618
+ }
2619
+ }
2620
+ async #replayGithubIssueComments(key) {
2621
+ const watch = this.#githubIssueCommentWatchStates.get(key);
2622
+ if (!watch)
2623
+ return;
2624
+ const comments = [];
2625
+ const sinceCommentId = githubCommentNumericId(watch.sinceCommentId ?? watch.lastSeenCommentId);
2626
+ const processedCommentIds = new Set(watch.processedCommentIds ?? []);
2627
+ for (const path of await this.#githubIssueCommentPaths(watch.source)) {
2628
+ const parts = githubIssueCommentPathParts(path);
2629
+ const id = parts ? githubCommentNumericId(parts.commentId) : undefined;
2630
+ if (id !== undefined && id > sinceCommentId && !processedCommentIds.has(String(id))) {
2631
+ comments.push({ path, id });
2632
+ }
2633
+ }
2634
+ comments.sort((a, b) => a.id - b.id);
2635
+ for (const comment of comments) {
2636
+ await this.#enqueueGithubIssueComment(key, comment.path);
2637
+ }
2638
+ }
2639
+ async #enqueueGithubIssueComment(key, path) {
2640
+ const previous = this.#githubIssueCommentQueues.get(key) ?? Promise.resolve();
2641
+ const current = previous.catch(() => undefined).then(async () => {
2642
+ await this.#handleGithubIssueComment(key, path);
2643
+ });
2644
+ this.#githubIssueCommentQueues.set(key, current);
2645
+ try {
2646
+ await current;
2647
+ }
2648
+ finally {
2649
+ if (this.#githubIssueCommentQueues.get(key) === current) {
2650
+ this.#githubIssueCommentQueues.delete(key);
2651
+ }
2652
+ }
2653
+ }
2654
+ async #githubIssueCommentPaths(source) {
2655
+ const paths = new Set();
2656
+ const owner = encodeURIComponent(source.owner);
2657
+ const repo = encodeURIComponent(source.repo);
2658
+ for (const prefix of [
2659
+ `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues`,
2660
+ `${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues`,
2661
+ ]) {
2662
+ try {
2663
+ for (const path of await this.#mount.listTree(prefix)) {
2664
+ const parts = githubIssueCommentPathParts(path);
2665
+ if (parts &&
2666
+ parts.owner.toLowerCase() === source.owner.toLowerCase() &&
2667
+ parts.repo.toLowerCase() === source.repo.toLowerCase() &&
2668
+ parts.number === source.number) {
2669
+ paths.add(path);
2670
+ }
2671
+ }
2672
+ }
2673
+ catch (error) {
2674
+ this.#logger.warn?.('[factory] unable to list GitHub issue comments for replay', { prefix, error });
2675
+ }
2676
+ }
2677
+ return [...paths];
2678
+ }
2679
+ async #latestGithubIssueCommentId(source) {
2680
+ let latest = 0;
2681
+ for (const path of await this.#githubIssueCommentPaths(source)) {
2682
+ const id = githubCommentNumericId(githubIssueCommentPathParts(path)?.commentId);
2683
+ if (id > latest)
2684
+ latest = id;
2685
+ }
2686
+ return latest > 0 ? String(latest) : undefined;
2687
+ }
2688
+ async #readGithubIssueComment(path) {
2689
+ try {
2690
+ const { content } = await this.#mount.readFile(path);
2691
+ return parseGithubIssueComment(path, content);
2692
+ }
2693
+ catch (error) {
2694
+ this.#logger.warn?.('[factory] unable to read GitHub issue comment reply', { path, error });
2695
+ return undefined;
2696
+ }
2697
+ }
2698
+ async #handleGithubIssueComment(key, path) {
2699
+ const watch = this.#githubIssueCommentWatchStates.get(key);
2700
+ if (!watch)
2701
+ return;
2702
+ const comment = await this.#readGithubIssueComment(path);
2703
+ if (!comment)
2704
+ return;
2705
+ const commentId = githubCommentNumericId(comment.commentId);
2706
+ const normalizedCommentId = String(commentId);
2707
+ const processedCommentIds = new Set(watch.processedCommentIds ?? []);
2708
+ if (commentId === 0 ||
2709
+ commentId <= githubCommentNumericId(watch.sinceCommentId) ||
2710
+ processedCommentIds.has(normalizedCommentId)) {
2711
+ this.#increment('githubIssueCommentDuplicatesSuppressed');
2712
+ return;
2713
+ }
2714
+ const reply = githubCorrelatedReply(comment.body);
2715
+ const pending = reply
2716
+ ? watch.pending.find((candidate) => candidate.correlationId === reply.correlationId)
2717
+ : undefined;
2718
+ let resolved = false;
2719
+ let discardClaimedPending = false;
2720
+ if (pending?.claimedByCommentId === normalizedCommentId) {
2721
+ this.#increment('githubAnswersClaimedReplaySuppressed');
2722
+ discardClaimedPending = true;
2723
+ }
2724
+ else if (pending && comment.isBot) {
2725
+ this.#increment('githubAnswersIgnoredBot');
2726
+ this.#logger.info?.('[factory] ignored GitHub issue answer from a bot', {
2727
+ issue: watch.issue,
2728
+ commentId: normalizedCommentId,
2729
+ correlationId: pending.correlationId,
2730
+ });
2731
+ }
2732
+ else if (pending && comment.author?.toLowerCase() === pending.authorizedAuthor.toLowerCase()) {
2733
+ pending.claimedByCommentId = normalizedCommentId;
2734
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
2735
+ resolved = await this.#routeGithubAnswerToImplementers(watch, pending, comment, reply.text);
2736
+ if (!resolved) {
2737
+ delete pending.claimedByCommentId;
2738
+ }
2739
+ }
2740
+ else if (pending) {
2741
+ this.#increment('githubAnswersIgnoredUnauthorizedAuthor');
2742
+ this.#logger.info?.('[factory] ignored GitHub issue answer from unauthorized author', {
2743
+ issue: watch.issue,
2744
+ commentId: normalizedCommentId,
2745
+ correlationId: pending.correlationId,
2746
+ author: comment.author,
2747
+ authorizedAuthor: pending.authorizedAuthor,
2748
+ });
2749
+ }
2750
+ else if (reply) {
2751
+ this.#increment('githubAnswersIgnoredUnknownCorrelation');
2752
+ this.#logger.info?.('[factory] ignored GitHub issue answer with unknown correlation', {
2753
+ issue: watch.issue,
2754
+ commentId: normalizedCommentId,
2755
+ correlationId: reply.correlationId,
2756
+ });
2757
+ }
2758
+ else if (!reply && comment.body.includes('[factory-reply:')) {
2759
+ this.#increment('githubAnswersIgnoredMissingCorrelationPrefix');
2760
+ }
2761
+ processedCommentIds.add(normalizedCommentId);
2762
+ watch.processedCommentIds = [...processedCommentIds];
2763
+ watch.lastSeenCommentId = String(Math.max(commentId, githubCommentNumericId(watch.lastSeenCommentId)));
2764
+ if ((resolved || discardClaimedPending) && pending) {
2765
+ watch.pending = watch.pending.filter((candidate) => candidate.correlationId !== pending.correlationId);
2766
+ }
2767
+ if (watch.pending.length === 0) {
2768
+ await this.#stopGithubIssueCommentWatcher(watch.source);
2769
+ }
2770
+ else {
2771
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
2772
+ }
2773
+ }
2774
+ async #routeGithubAnswerToImplementers(watch, pending, comment, text) {
2775
+ if (pending.kind === 'triage' && pending.decision) {
2776
+ return await this.#handleTriageEscalationGithubAnswer(escalationWatchRecord(pending.decision), text);
2777
+ }
2778
+ const liveRecord = (await this.#batch()).getIssue(watch.issue);
2779
+ if (!liveRecord || liveRecord.dryRun) {
2780
+ this.#increment('githubAnswersIgnoredNoInFlight');
2781
+ return false;
2782
+ }
2783
+ if (!this.#fleet.sendInput)
2784
+ return false;
2785
+ const recipients = [...liveRecord.agents.values()]
2786
+ .filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
2787
+ .map((agent) => agent.result?.name ?? agent.spec.name)
2788
+ .filter((name) => Boolean(name));
2789
+ if (recipients.length === 0) {
2790
+ this.#increment('githubAnswersIgnoredNoImplementer');
2791
+ return false;
2792
+ }
2793
+ for (const recipient of new Set(recipients)) {
2794
+ await this.#fleet.sendInput(recipient, githubReplyEvent(liveRecord.issue, text, comment.author));
2795
+ this.#increment('githubAnswersInjected');
2796
+ }
2797
+ return true;
2798
+ }
2799
+ async #handleTriageEscalationGithubAnswer(record, text) {
2800
+ const issue = await this.#readIssue(record.issue.path);
2801
+ if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
2802
+ this.#increment('githubTriageAnswersIgnoredIssueUnavailable');
2803
+ return false;
2804
+ }
2805
+ if (!this.#isIssueReady(issue)) {
2806
+ this.#increment('githubTriageAnswersIgnoredIssueNotReady');
2807
+ return false;
2808
+ }
2809
+ const batch = await this.#batch();
2810
+ if (batch.isInFlight(record.issue) || batch.isQueued(record.issue)) {
2811
+ this.#increment('githubTriageAnswersIgnoredAlreadyActive');
2812
+ return false;
2813
+ }
2814
+ if (await this.#dispatchBlockReason(record.issue)) {
2815
+ this.#increment('githubTriageAnswersIgnoredBlocked');
2816
+ return false;
2817
+ }
2818
+ const clarifiedIssue = issueWithGithubClarification(issue, text);
2819
+ const decision = await this.#triage.triage(clarifiedIssue, {
2820
+ config: this.#config,
2821
+ repoMap: repoMapFromConfig(this.#config),
2822
+ });
2823
+ const escalationReason = triageEscalationReason(decision);
2824
+ if (escalationReason) {
2825
+ if (hasDispatchableRoute(decision)) {
2826
+ this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
2827
+ const result = await this.#startOrQueueGithubClarifiedDecision(dispatchAfterGithubClarification(decision, escalationReason));
2828
+ this.#increment('githubTriageAnswersDispatchedWithRemainingEscalation');
2829
+ return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
2830
+ }
2831
+ this.#increment('githubTriageAnswersStillEscalated');
2832
+ this.#logger.warn?.('[factory] GitHub triage answer still leaves issue escalated', {
2833
+ issue: record.issue,
2834
+ reason: escalationReason,
2835
+ });
2836
+ return false;
2837
+ }
2838
+ this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
2839
+ const result = await this.#startOrQueueGithubClarifiedDecision(decision);
2840
+ this.#increment('githubTriageAnswersDispatched');
2841
+ return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
2842
+ }
2843
+ async #startOrQueueGithubClarifiedDecision(decision) {
2844
+ const batch = await this.#batch();
2845
+ if (batch.canStart()) {
2846
+ return await this.dispatch(decision, { dryRun: this.#config.dryRun });
2847
+ }
2848
+ if (batch.queue(decision, this.#config.dryRun)) {
2849
+ this.#increment('githubTriageAnswersQueued');
2850
+ this.#emit('issue-queued', { issue: decision.issue });
2851
+ }
2852
+ }
2335
2853
  #recordArrivalLatency(event) {
2336
2854
  const occurredAt = Date.parse(event.occurredAt);
2337
2855
  if (!Number.isFinite(occurredAt))
@@ -2525,9 +3043,15 @@ export class FactoryLoop {
2525
3043
  }
2526
3044
  this.#postMergeDoneAdvances.add(advanceKey);
2527
3045
  try {
2528
- const doneStateId = this.#states.idFor(issue.team, 'done');
2529
- await this.#linear.setState(issue, doneStateId);
2530
- await this.#recordCanonicalIssueState({ key: issue.key, stateId: doneStateId });
3046
+ const githubIssue = isGithubIssue(issue);
3047
+ if (githubIssue) {
3048
+ await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.`);
3049
+ }
3050
+ else {
3051
+ const doneStateId = this.#states.idFor(issue.team, 'done');
3052
+ await this.#linear.setState(issue, doneStateId);
3053
+ await this.#recordCanonicalIssueState({ key: issue.key, stateId: doneStateId });
3054
+ }
2531
3055
  this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
2532
3056
  this.#increment('mergedPrAdvancedDone');
2533
3057
  this.#increment('done');
@@ -2540,11 +3064,12 @@ export class FactoryLoop {
2540
3064
  try {
2541
3065
  const channel = await this.#slackChannelDir();
2542
3066
  if (channel) {
3067
+ const systemOfRecord = githubIssue ? 'GitHub issue closed' : 'Linear state set to Done';
2543
3068
  const root = await this.#slack.postThread({
2544
3069
  channel,
2545
- text: `${issue.key}: PR merged; Linear state set to Done.`,
3070
+ text: `${issue.key}: PR merged; ${systemOfRecord}.`,
2546
3071
  });
2547
- await this.#slack.reply(root.threadId, `${issue.key}: Linear state set to Done.`);
3072
+ await this.#slack.reply(root.threadId, `${issue.key}: ${systemOfRecord}.`);
2548
3073
  this.#recordSlackWritebackSuccess('merge-done-thread');
2549
3074
  }
2550
3075
  }
@@ -2571,15 +3096,19 @@ export class FactoryLoop {
2571
3096
  // This no-record path runs after agents are released, so there is no
2572
3097
  // tracked PR identity left. Keep the scan simple and prefer branch identity
2573
3098
  // over title/body references to avoid "related to AR-N" body false positives.
2574
- for (const path of await this.#mount.listTree(ISSUE_ROOT)) {
2575
- if (!isIssueFilePath(path)) {
3099
+ const githubSource = await this.#issueSource() === 'github';
3100
+ const paths = githubSource ? await this.#githubIssuePaths() : await this.#mount.listTree(ISSUE_ROOT);
3101
+ for (const path of paths) {
3102
+ if (githubSource ? !isGithubIssueFilePath(path) : !isIssueFilePath(path)) {
2576
3103
  continue;
2577
3104
  }
2578
3105
  const issue = await this.#readIssue(path);
2579
- if (!issue || !isUpstreamState(issue.stateId)) {
3106
+ if (!issue || (githubSource
3107
+ ? issue.state?.name?.trim().toLowerCase() === 'closed'
3108
+ : !isUpstreamState(issue.stateId))) {
2580
3109
  continue;
2581
3110
  }
2582
- if (!isRealLinearIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) {
3111
+ if (!isDispatchableIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) {
2583
3112
  continue;
2584
3113
  }
2585
3114
  const score = prSnapshotIssueMatchScore(snapshot, issue.key);
@@ -2587,7 +3116,7 @@ export class FactoryLoop {
2587
3116
  best = { issue, score };
2588
3117
  }
2589
3118
  }
2590
- this.#logger.debug?.('[factory] scanned Linear issues for merged PR advance', {
3119
+ this.#logger.debug?.(`[factory] scanned ${githubSource ? 'GitHub' : 'Linear'} issues for merged PR advance`, {
2591
3120
  prNumber: snapshot.number,
2592
3121
  durationMs: this.#clock.now() - scanStartedAtMs,
2593
3122
  matchedIssue: best?.issue.key,
@@ -2752,6 +3281,28 @@ export class FactoryLoop {
2752
3281
  }
2753
3282
  return found;
2754
3283
  }
3284
+ async #githubPrObservedMerged(record, issue) {
3285
+ const babysatSnapshot = await this.#readBabysatPrSnapshot(record);
3286
+ if (babysatSnapshot && prMetaShowsMerged(babysatSnapshot)) {
3287
+ return true;
3288
+ }
3289
+ const pr = this.#babysitterPr.get(record.issue.key) ?? await this.#completionPrForIssue(issue);
3290
+ if (!pr) {
3291
+ return false;
3292
+ }
3293
+ for (const path of await this.#pullMetaPathsFor(pr.repo, pr.prNumber)) {
3294
+ try {
3295
+ const snapshot = parsePullSnapshot((await this.#mount.readFile(path)).content, pr.prNumber);
3296
+ if (snapshot && prMetaShowsMerged(snapshot)) {
3297
+ return true;
3298
+ }
3299
+ }
3300
+ catch {
3301
+ // A concurrent mount refresh may remove an alternate path; keep scanning.
3302
+ }
3303
+ }
3304
+ return false;
3305
+ }
2755
3306
  async #completeIssue(record, opts = {}) {
2756
3307
  const completionKey = issueKey(record.issue);
2757
3308
  if (this.#completionInFlight.has(completionKey)) {
@@ -2766,16 +3317,44 @@ export class FactoryLoop {
2766
3317
  // (the legacy terminal) so the issue never gets stuck on an unconfigured
2767
3318
  // state.
2768
3319
  const issueTeam = issue?.team;
2769
- const humanReview = opts.targetState !== 'done' &&
3320
+ const githubIssue = issue ? isGithubIssue(issue) : false;
3321
+ const configuredHumanReview = opts.targetState !== 'done' &&
2770
3322
  this.#config.terminalState === 'human-review' &&
2771
- this.#states.hasHumanReview(issueTeam);
2772
- const targetState = humanReview
2773
- ? this.#states.idFor(issueTeam, 'humanReview')
2774
- : this.#states.idFor(issueTeam, 'done');
3323
+ (githubIssue || this.#states.hasHumanReview(issueTeam));
3324
+ let githubMerged = githubIssue && opts.completionReason === 'pr-merged';
3325
+ if (issue && githubIssue && !configuredHumanReview && !githubMerged) {
3326
+ githubMerged = await this.#githubPrObservedMerged(record, issue);
3327
+ if (!githubMerged && opts.runMergeGate !== false) {
3328
+ const mergeCommandAccepted = await this.#runCompletionMergeGate(issue);
3329
+ // A successful merge command can mean queued/auto-merge rather than
3330
+ // merged. Only mounted PR state or a merged webhook may prove merge.
3331
+ if (mergeCommandAccepted) {
3332
+ githubMerged = await this.#githubPrObservedMerged(record, issue);
3333
+ }
3334
+ }
3335
+ }
3336
+ // A GitHub issue only closes after its PR merges. If a configured done
3337
+ // path cannot merge (including mergePolicy: never), park it for a human
3338
+ // instead of closing the issue while its PR remains open.
3339
+ const humanReview = configuredHumanReview || (githubIssue && !githubMerged);
2775
3340
  const statusLabel = humanReview ? 'In Human Review' : 'Done';
2776
3341
  if (issue) {
2777
- await this.#linear.setState(issue, targetState);
2778
- await this.#recordCanonicalIssueState({ key: issue.key, stateId: targetState });
3342
+ if (githubIssue) {
3343
+ if (humanReview) {
3344
+ await this.#githubWriteback.setStatus(issue, 'human-review');
3345
+ await this.#githubWriteback.postComment(issue, `Factory agents completed; this issue is awaiting human review. The pull request remains open.\n\nMerge policy: ${this.#config.mergePolicy}`);
3346
+ }
3347
+ else {
3348
+ await this.#githubWriteback.closeIssue(issue, 'Factory observed the linked pull request merge and completed this issue.');
3349
+ }
3350
+ }
3351
+ else {
3352
+ const targetState = humanReview
3353
+ ? this.#states.idFor(issueTeam, 'humanReview')
3354
+ : this.#states.idFor(issueTeam, 'done');
3355
+ await this.#linear.setState(issue, targetState);
3356
+ await this.#recordCanonicalIssueState({ key: issue.key, stateId: targetState });
3357
+ }
2779
3358
  this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
2780
3359
  }
2781
3360
  if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('completion-thread')) {
@@ -2783,14 +3362,15 @@ export class FactoryLoop {
2783
3362
  const channel = await this.#slackChannelDir();
2784
3363
  if (channel) {
2785
3364
  const merged = opts.completionReason === 'pr-merged';
3365
+ const systemOfRecord = githubIssue ? 'GitHub status' : 'Linear state';
2786
3366
  const completionText = merged
2787
- ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
3367
+ ? `${record.issue.key}: PR merged; ${systemOfRecord} set to ${statusLabel}.`
2788
3368
  : `${record.issue.key}: factory agents completed${humanReview ? '; awaiting human review' : ''}.\nStatus: ${statusLabel}\nMerge policy: ${this.#config.mergePolicy}`;
2789
3369
  const stateText = merged
2790
- ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
3370
+ ? `${record.issue.key}: ${systemOfRecord} set to ${statusLabel}.`
2791
3371
  : humanReview
2792
- ? `${record.issue.key}: awaiting human review; Linear state set to ${statusLabel}.`
2793
- : `${record.issue.key}: Linear state set to ${statusLabel}.`;
3372
+ ? `${record.issue.key}: awaiting human review; ${systemOfRecord} set to ${statusLabel}.`
3373
+ : `${record.issue.key}: ${systemOfRecord} set to ${statusLabel}.`;
2794
3374
  const root = await this.#slack.postThread({
2795
3375
  channel,
2796
3376
  text: completionText,
@@ -2806,13 +3386,14 @@ export class FactoryLoop {
2806
3386
  // Only auto-merge on the `done` terminal path. Human Review parks the PR
2807
3387
  // for an operator — the merge gate (which requires an APPROVED review)
2808
3388
  // would refuse anyway, and we must not merge before the human has looked.
2809
- if (issue && !humanReview && opts.runMergeGate !== false) {
3389
+ if (issue && !githubIssue && !humanReview && opts.runMergeGate !== false) {
2810
3390
  await this.#runCompletionMergeGate(issue);
2811
3391
  }
2812
3392
  await this.#releaseAndTerminateAgents([...record.agents], humanReview ? 'issue-human-review' : 'issue-done', 'completion');
2813
3393
  this.#increment(humanReview ? 'humanReview' : 'done');
2814
3394
  this.#emit('issue-done', { issue: record.issue });
2815
3395
  await this.#stopSlackWatcher(record.issue);
3396
+ await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
2816
3397
  await this.#recordDispatchTerminal(record.issue);
2817
3398
  const next = (await this.#batch()).complete(record.issue);
2818
3399
  if (next) {
@@ -2841,6 +3422,22 @@ export class FactoryLoop {
2841
3422
  this.#logger.error?.('[factory] error', error);
2842
3423
  this.#emit('error', { error, ...describeError(error), issue });
2843
3424
  }
3425
+ #surfaceEscalationDeliveryFailure(kind, issue, correlationId, reason, cause) {
3426
+ const error = new Error(`${reason} (${correlationId})`);
3427
+ if (cause !== undefined) {
3428
+ Object.assign(error, { cause });
3429
+ }
3430
+ this.#increment('errors');
3431
+ this.#increment('escalationDeliveryFailures');
3432
+ this.#logger.error?.('[factory] escalation delivery failed', {
3433
+ kind,
3434
+ correlationId,
3435
+ issue,
3436
+ reason,
3437
+ cause,
3438
+ });
3439
+ this.#emit('error', { error, ...describeError(error), issue });
3440
+ }
2844
3441
  #increment(name) {
2845
3442
  this.#counters[name] = (this.#counters[name] ?? 0) + 1;
2846
3443
  }
@@ -3064,16 +3661,12 @@ export class FactoryLoop {
3064
3661
  await this.#watchSlackThread(record, root.threadId);
3065
3662
  this.#recordSlackWritebackSuccess('dispatch-thread');
3066
3663
  }
3067
- async #escalateTriageToSlack(decision, reason, dryRun) {
3664
+ async #escalateTriage(decision, reason, dryRun) {
3068
3665
  if (dryRun) {
3069
3666
  return;
3070
3667
  }
3071
3668
  if (!this.#slack || !this.#config.slack) {
3072
- this.#logger.warn?.('[factory] triage escalation has no Slack channel configured', {
3073
- issue: decision.issue,
3074
- reason,
3075
- });
3076
- return;
3669
+ return await this.#escalateTriageToGithub(decision, reason);
3077
3670
  }
3078
3671
  if (await this.#shouldSkipSlackWriteback('triage-escalation')) {
3079
3672
  return;
@@ -3111,6 +3704,42 @@ export class FactoryLoop {
3111
3704
  this.#slackWatcherStarts.delete(key);
3112
3705
  }
3113
3706
  }
3707
+ async #escalateTriageToGithub(decision, reason) {
3708
+ const question = triageEscalationQuestion(decision);
3709
+ const correlationId = githubEscalationCorrelationId('triage', decision.issue, question);
3710
+ const issue = await this.#readIssue(decision.issue.path);
3711
+ const source = issue ? githubIssueSourceRef(issue) : undefined;
3712
+ const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
3713
+ if (!issue || !source || !authorizedAuthor) {
3714
+ this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
3715
+ return;
3716
+ }
3717
+ const pendingAdded = await this.#addGithubIssueCommentWatch(decision.issue, source, {
3718
+ correlationId,
3719
+ kind: 'triage',
3720
+ authorizedAuthor,
3721
+ decision,
3722
+ });
3723
+ if (!pendingAdded) {
3724
+ return;
3725
+ }
3726
+ try {
3727
+ await this.#githubWriteback.postComment(issue, [
3728
+ `Factory needs clarification before dispatching ${decision.issue.key}.`,
3729
+ `Reason: ${reason}`,
3730
+ `Question: ${question}`,
3731
+ `Authorized responder: @${authorizedAuthor} (the issue reporter).`,
3732
+ `Reply with a comment that starts with \`${githubReplyPrefix(correlationId)}\`.`,
3733
+ '',
3734
+ githubEscalationMarker(correlationId),
3735
+ ].join('\n'));
3736
+ this.#increment('triageEscalationsPostedToGithub');
3737
+ }
3738
+ catch (error) {
3739
+ await this.#removeGithubIssueCommentPending(source, correlationId);
3740
+ this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'GitHub issue comment writeback failed', error);
3741
+ }
3742
+ }
3114
3743
  async #postAndWatchSlackEscalationThread(decision, reason) {
3115
3744
  if (!this.#slack || !this.#config.slack) {
3116
3745
  return;
@@ -3373,11 +4002,11 @@ export class FactoryLoop {
3373
4002
  }
3374
4003
  async #handleTriageEscalationSlackAnswer(record, text) {
3375
4004
  const issue = await this.#readIssue(record.issue.path);
3376
- if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isRealLinearIssue(issue)) {
4005
+ if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
3377
4006
  this.#increment('slackTriageAnswersIgnoredIssueUnavailable');
3378
4007
  return;
3379
4008
  }
3380
- if (!this.#states.isRole(issue.stateId, 'readyForAgent')) {
4009
+ if (!this.#isIssueReady(issue)) {
3381
4010
  this.#increment('slackTriageAnswersIgnoredIssueNotReady');
3382
4011
  return;
3383
4012
  }
@@ -3452,6 +4081,33 @@ export class FactoryLoop {
3452
4081
  }
3453
4082
  this.#pendingSlackClarifications.delete(key);
3454
4083
  }
4084
+ async #injectPendingGithubClarification(record) {
4085
+ const key = issueKey(record.issue);
4086
+ const text = this.#pendingGithubClarifications.get(key);
4087
+ if (!text || !this.#fleet.sendInput) {
4088
+ return;
4089
+ }
4090
+ const recipients = [...record.agents.values()]
4091
+ .filter((agent) => agent.spec.role === 'implementer' ||
4092
+ agent.spec.role === 'workflow' ||
4093
+ agent.spec.role === 'babysitter')
4094
+ .map((agent) => agent.result?.name ?? agent.spec.name)
4095
+ .filter((name) => Boolean(name));
4096
+ for (const recipient of new Set(recipients)) {
4097
+ try {
4098
+ await this.#fleet.sendInput(recipient, githubReplyEvent(record.issue, text));
4099
+ this.#increment('githubTriageAnswersInjectedToAgents');
4100
+ }
4101
+ catch (error) {
4102
+ this.#logger.warn?.('[factory] failed to inject GitHub triage clarification into agent', {
4103
+ issue: record.issue.key,
4104
+ recipient,
4105
+ error,
4106
+ });
4107
+ }
4108
+ }
4109
+ this.#pendingGithubClarifications.delete(key);
4110
+ }
3455
4111
  // Inject the human's Slack reply into the agent framed as the
3456
4112
  // <integration-event> the spawn prompt tells it to expect (not an ambiguous
3457
4113
  // "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
@@ -3544,27 +4200,27 @@ export class FactoryLoop {
3544
4200
  async #runCompletionMergeGate(issue) {
3545
4201
  if (this.#isSyntheticProbeIssue(issue)) {
3546
4202
  await this.#closeSyntheticProbeIfPresent(issue);
3547
- return;
4203
+ return false;
3548
4204
  }
3549
- if (!isRealLinearIssue(issue)) {
4205
+ if (!isDispatchableIssue(issue)) {
3550
4206
  this.#logger.warn?.('[factory] merge gate skipped non-real Linear issue', { issue: issue.key });
3551
4207
  this.#increment('mergeGateSkippedNonReal');
3552
- return;
4208
+ return false;
3553
4209
  }
3554
4210
  if (this.#config.mergePolicy !== 'on-green-with-review') {
3555
- return;
4211
+ return false;
3556
4212
  }
3557
4213
  const pr = await this.#probePrResolver(issue);
3558
4214
  if (!pr) {
3559
4215
  this.#logger.warn?.('[factory] merge gate found no PR for real issue', { issue: issue.key });
3560
4216
  this.#increment('mergeGateMissingPr');
3561
- return;
4217
+ return false;
3562
4218
  }
3563
4219
  const ready = await this.#waitForMergeReady(pr);
3564
4220
  const headSha = ready?.live.headRefOid;
3565
4221
  if (!ready || !headSha) {
3566
4222
  this.#increment('mergeGateNotMerged');
3567
- return;
4223
+ return false;
3568
4224
  }
3569
4225
  const result = await this.#mergeGate.merge({
3570
4226
  repo: pr.repo,
@@ -3580,7 +4236,7 @@ export class FactoryLoop {
3580
4236
  reason: result.reason,
3581
4237
  });
3582
4238
  this.#increment('mergeGateMergeAborted');
3583
- return;
4239
+ return false;
3584
4240
  }
3585
4241
  this.#logger.info?.('[factory] merge gate merged PR', {
3586
4242
  issue: issue.key,
@@ -3589,6 +4245,7 @@ export class FactoryLoop {
3589
4245
  headSha,
3590
4246
  });
3591
4247
  this.#increment('mergeGateMerged');
4248
+ return true;
3592
4249
  }
3593
4250
  async #closeSyntheticProbeIfPresent(issue) {
3594
4251
  const probe = this.#customProbePrResolver
@@ -3728,10 +4385,54 @@ export function parseGithubIssue(path, content) {
3728
4385
  labels: Array.isArray(payload.labels)
3729
4386
  ? payload.labels.map(labelName).filter((label) => Boolean(label))
3730
4387
  : [],
4388
+ author: githubAuthorLogin(payload),
3731
4389
  path,
3732
4390
  raw: asRecord(parsed) ?? payload,
3733
4391
  };
3734
4392
  }
4393
+ export function parseGithubFactoryIssue(path, content) {
4394
+ return githubIssueAsFactoryIssue(parseGithubIssue(path, content));
4395
+ }
4396
+ const githubIssueAsFactoryIssue = (issue) => {
4397
+ const wrapper = asRecord(issue.raw) ?? {};
4398
+ const payload = wrappedPayload(wrapper);
4399
+ const objectId = stringValue(wrapper.objectId);
4400
+ const stableId = `${issue.repo}#${objectId ?? issue.number}`;
4401
+ return {
4402
+ uuid: stableId,
4403
+ key: String(issue.number),
4404
+ title: issue.title,
4405
+ description: issue.body,
4406
+ stateId: '',
4407
+ state: issue.state ? { name: issue.state } : undefined,
4408
+ labels: issue.labels,
4409
+ path: issue.path,
4410
+ raw: {
4411
+ ...wrapper,
4412
+ provider: 'github',
4413
+ objectType: 'issue',
4414
+ objectId: stableId,
4415
+ payload: {
4416
+ ...payload,
4417
+ id: stableId,
4418
+ number: issue.number,
4419
+ title: issue.title,
4420
+ description: issue.body,
4421
+ url: issue.url,
4422
+ source: {
4423
+ provider: 'github',
4424
+ id: stableId,
4425
+ owner: issue.owner,
4426
+ repo: issue.repoName,
4427
+ number: issue.number,
4428
+ url: issue.url,
4429
+ path: issue.path,
4430
+ ...(issue.author ? { author: issue.author, reporter: issue.author } : {}),
4431
+ },
4432
+ },
4433
+ },
4434
+ };
4435
+ };
3735
4436
  export async function readFactoryLoopHeartbeat(path = DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH) {
3736
4437
  try {
3737
4438
  return parseJsonContent(await readFile(path, 'utf8'));
@@ -3764,6 +4465,49 @@ export function isRealLinearIssue(issue) {
3764
4465
  typeof payload.url === 'string' &&
3765
4466
  payload.url.length > 0;
3766
4467
  }
4468
+ export function isDispatchableIssue(issue) {
4469
+ if (isRealLinearIssue(issue)) {
4470
+ return true;
4471
+ }
4472
+ if (!isGithubIssue(issue)) {
4473
+ return false;
4474
+ }
4475
+ const payload = wrappedPayload(issue.raw);
4476
+ const source = asRecord(payload.source);
4477
+ const sourceId = stringValue(source?.id);
4478
+ const number = numberValue(source?.number) ?? numberValue(payload.number);
4479
+ const url = stringValue(source?.url) ?? stringValue(payload.url);
4480
+ const hasStableIdentity = Boolean(sourceId?.trim()) || (Number.isInteger(number) && (number ?? 0) > 0);
4481
+ return Boolean(issue.uuid.trim() && issue.key.trim() && hasStableIdentity && url?.trim());
4482
+ }
4483
+ const isGithubIssue = (issue) => {
4484
+ const wrapper = asRecord(issue.raw);
4485
+ const source = asRecord(wrappedPayload(issue.raw).source);
4486
+ return stringValue(source?.provider)?.toLowerCase() === 'github' &&
4487
+ (stringValue(wrapper?.provider)?.toLowerCase() === 'github' || isGithubIssueFilePath(issue.path));
4488
+ };
4489
+ const githubIssueSourceRef = (issue) => {
4490
+ const source = asRecord(wrappedPayload(issue.raw).source);
4491
+ if (stringValue(source?.provider)?.toLowerCase() !== 'github') {
4492
+ return undefined;
4493
+ }
4494
+ const owner = stringValue(source?.owner)?.trim();
4495
+ const repo = stringValue(source?.repo)?.trim();
4496
+ const number = numberValue(source?.number);
4497
+ const url = stringValue(source?.url)?.trim();
4498
+ if (!owner || !repo || !Number.isInteger(number) || (number ?? 0) <= 0 || !url) {
4499
+ return undefined;
4500
+ }
4501
+ return { owner, repo, number: number, url };
4502
+ };
4503
+ const githubIssueAuthor = (issue) => {
4504
+ const payload = wrappedPayload(issue.raw);
4505
+ const source = asRecord(payload.source);
4506
+ if (stringValue(source?.provider)?.toLowerCase() === 'github') {
4507
+ return githubAuthorLogin(source ?? {})?.trim() || undefined;
4508
+ }
4509
+ return source ? undefined : githubAuthorLogin(payload)?.trim() || undefined;
4510
+ };
3767
4511
  const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
3768
4512
  const pidsFromSpawnResult = (result) => {
3769
4513
  const pids = new Set();
@@ -3943,7 +4687,10 @@ function githubUrlCandidates(value) {
3943
4687
  return [...candidates];
3944
4688
  }
3945
4689
  function labelRoutesForIssue(issue, config) {
3946
- const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label));
4690
+ const githubReadinessLabel = isGithubIssue(issue) ? config.safety.requireLabel.trim().toLowerCase() : undefined;
4691
+ const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label) &&
4692
+ label.toLowerCase() !== githubReadinessLabel &&
4693
+ (!isGithubIssue(issue) || !GITHUB_LIFECYCLE_LABELS.has(label.toLowerCase())));
3947
4694
  const routes = [];
3948
4695
  const offendingLabels = [];
3949
4696
  const seenRepos = new Set();
@@ -4134,6 +4881,73 @@ export const githubIssuePathParts = (path) => {
4134
4881
  slug: match[7],
4135
4882
  };
4136
4883
  };
4884
+ const githubIssueCommentPathParts = (path) => {
4885
+ const match = path.match(/^\/github\/repos\/(?:([^/]+)\/([^/]+)|([A-Za-z0-9-]+)__([^/]+))\/issues\/(\d+)(?:__[^/]*)?\/comments\/([^/]+?)(?:\.json|\/(?:meta|metadata)\.json)$/u);
4886
+ const owner = match?.[1] ?? match?.[3];
4887
+ const repo = match?.[2] ?? match?.[4];
4888
+ const number = Number(match?.[5]);
4889
+ const commentId = match?.[6];
4890
+ if (!owner || !repo || !Number.isInteger(number) || number <= 0 || !commentId) {
4891
+ return undefined;
4892
+ }
4893
+ return { owner, repo, number, commentId };
4894
+ };
4895
+ const parseGithubIssueComment = (path, content) => {
4896
+ const parts = githubIssueCommentPathParts(path);
4897
+ if (!parts) {
4898
+ return undefined;
4899
+ }
4900
+ const raw = asRecord(parseJsonContent(content)) ?? {};
4901
+ const payload = wrappedPayload(raw);
4902
+ const comment = asRecord(payload.comment) ?? payload;
4903
+ const author = asRecord(comment.author) ?? asRecord(comment.user);
4904
+ const authorLogin = stringValue(author?.login);
4905
+ const authorType = stringValue(author?.type)?.toLowerCase();
4906
+ return {
4907
+ owner: parts.owner,
4908
+ repo: parts.repo,
4909
+ issueNumber: parts.number,
4910
+ commentId: String(numberValue(comment.id) ?? parts.commentId),
4911
+ body: stringValue(comment.body) ?? '',
4912
+ author: authorLogin,
4913
+ isBot: authorType === 'bot' || Boolean(authorLogin?.toLowerCase().endsWith('[bot]')),
4914
+ raw,
4915
+ };
4916
+ };
4917
+ const githubIssueSourceKey = (source) => `${source.owner.toLowerCase()}/${source.repo.toLowerCase()}#${source.number}`;
4918
+ const githubIssueCommentGlobs = (source) => {
4919
+ const owner = encodeURIComponent(source.owner);
4920
+ const repo = encodeURIComponent(source.repo);
4921
+ const issueSegment = `${source.number}*`;
4922
+ return [
4923
+ `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/${issueSegment}/comments/**`,
4924
+ `${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/${issueSegment}/comments/**`,
4925
+ ];
4926
+ };
4927
+ const githubEscalationCorrelationId = (kind, issue, text) => `factory-${kind}-${stableHash(`${issue.uuid}:${issue.path}:${text}`)}`;
4928
+ const githubEscalationMarker = (correlationId) => `<!-- ${GITHUB_ESCALATION_MARKER_PREFIX}${correlationId} -->`;
4929
+ const githubReplyPrefix = (correlationId) => `[factory-reply:${correlationId}]`;
4930
+ const githubCorrelatedReply = (body) => {
4931
+ if (!body.startsWith('[factory-reply:'))
4932
+ return undefined;
4933
+ const markerEnd = body.indexOf(']');
4934
+ if (markerEnd < 0)
4935
+ return undefined;
4936
+ const correlationId = body.slice('[factory-reply:'.length, markerEnd);
4937
+ if (!/^factory-(?:triage|agent-question)-[a-z0-9]+$/u.test(correlationId))
4938
+ return undefined;
4939
+ const text = body.slice(markerEnd + 1).replace(/^\s*:?\s*/u, '').trim();
4940
+ return text ? { correlationId, text } : undefined;
4941
+ };
4942
+ const githubCommentNumericId = (value) => {
4943
+ const parsed = Number(value);
4944
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
4945
+ };
4946
+ const normalizeGithubIssueCommentWatch = (watch) => ({
4947
+ ...watch,
4948
+ sinceCommentId: watch.sinceCommentId ?? watch.lastSeenCommentId,
4949
+ processedCommentIds: [...new Set(watch.processedCommentIds ?? [])],
4950
+ });
4137
4951
  const githubIssueReadCandidatePaths = (path) => {
4138
4952
  if (path.endsWith('/')) {
4139
4953
  return [`${path}meta.json`, `${path}metadata.json`];
@@ -4171,10 +4985,11 @@ const githubIssueDirectoryPathParts = (path) => {
4171
4985
  slug: match[6],
4172
4986
  };
4173
4987
  };
4174
- const githubIssueHasFactoryLabel = (issue) => issue.labels.some((label) => label.toLowerCase() === GITHUB_FACTORY_LABEL);
4988
+ const githubIssueHasFactoryLabel = (issue, requiredLabel = GITHUB_FACTORY_LABEL) => issue.labels.some((label) => label.trim().toLowerCase() === requiredLabel.trim().toLowerCase());
4175
4989
  const githubIssueIsClosed = (issue) => issue.state === 'closed';
4176
4990
  const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => {
4177
4991
  const teamId = config.linear.teamIds[config.safety.requireTeamKey];
4992
+ const reporter = issue.author;
4178
4993
  return {
4179
4994
  id: githubIssueMirrorId(issue),
4180
4995
  title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(),
@@ -4190,6 +5005,7 @@ const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId
4190
5005
  number: issue.number,
4191
5006
  url: issue.url,
4192
5007
  path: issue.path,
5008
+ ...(reporter ? { author: reporter, reporter } : {}),
4193
5009
  },
4194
5010
  };
4195
5011
  };
@@ -4464,6 +5280,15 @@ const uuidFromPath = (path) => path.split('__')[1]?.replace(/\.json$/, '');
4464
5280
  const stringValue = (value) => typeof value === 'string' ? value : undefined;
4465
5281
  const booleanValue = (value) => typeof value === 'boolean' ? value : undefined;
4466
5282
  const numberValue = (value) => typeof value === 'number' ? value : undefined;
5283
+ const githubAuthorLogin = (payload) => {
5284
+ for (const value of [payload.author, payload.user, payload.reporter]) {
5285
+ const login = (stringValue(value) ??
5286
+ stringValue(asRecord(value)?.login))?.trim();
5287
+ if (login)
5288
+ return login;
5289
+ }
5290
+ return undefined;
5291
+ };
4467
5292
  const liveHeartbeatIntervalMs = (staleMs) => Math.min(DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS, Math.max(500, Math.floor(staleMs / 4)));
4468
5293
  const installFactoryDraftPredicate = (mount, config) => {
4469
5294
  mount.setDefaultAllowedDraftPredicate?.((path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, config));
@@ -4826,6 +5651,15 @@ const dispatchAfterSlackClarification = (decision, escalationReason) => ({
4826
5651
  `Human answered the Slack triage escalation (${escalationReason}); dispatching to the matched agent so it can acknowledge the answer and ask follow-up questions if needed.`,
4827
5652
  ].filter(Boolean).join(' '),
4828
5653
  });
5654
+ const dispatchAfterGithubClarification = (decision, escalationReason) => ({
5655
+ ...decision,
5656
+ thin: false,
5657
+ confidence: 'high',
5658
+ rationale: [
5659
+ decision.rationale,
5660
+ `Human answered the GitHub triage escalation (${escalationReason}); dispatching to the matched agent so it can acknowledge the answer and ask follow-up questions if needed.`,
5661
+ ].filter(Boolean).join(' '),
5662
+ });
4829
5663
  const issueWithSlackClarification = (issue, text) => ({
4830
5664
  ...issue,
4831
5665
  description: [
@@ -4835,11 +5669,21 @@ const issueWithSlackClarification = (issue, text) => ({
4835
5669
  text,
4836
5670
  ].join('\n'),
4837
5671
  });
5672
+ const issueWithGithubClarification = (issue, text) => ({
5673
+ ...issue,
5674
+ description: [
5675
+ issue.description,
5676
+ '',
5677
+ 'Human clarification from GitHub:',
5678
+ text,
5679
+ ].join('\n'),
5680
+ });
4838
5681
  const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
4839
5682
  // The human's Slack-thread reply, framed as an <integration-event> the agent is
4840
5683
  // told (at spawn) to expect — a recognizable injected event, not an ambiguous
4841
5684
  // keystroke. Trailing CR submits it to the agent's PTY.
4842
5685
  const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
5686
+ const githubReplyEvent = (issue, text, author) => `<integration-event source="github" issue="${issue.key}">\nHuman reply${author ? ` from @${author}` : ''} on the GitHub issue:\n${text}\n</integration-event>\r`;
4843
5687
  const isFactoryQuestionTarget = (target) => {
4844
5688
  const normalized = target.trim().replace(/^@/u, '').toLowerCase();
4845
5689
  return normalized === 'broker' || normalized === 'factory';