@agent-relay/factory 0.1.13 → 0.1.14

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 (50) hide show
  1. package/README.md +17 -10
  2. package/dist/cli/fleet.d.ts.map +1 -1
  3. package/dist/cli/fleet.js +87 -9
  4. package/dist/cli/fleet.js.map +1 -1
  5. package/dist/config/schema.d.ts +26 -16
  6. package/dist/config/schema.d.ts.map +1 -1
  7. package/dist/config/schema.js +4 -0
  8. package/dist/config/schema.js.map +1 -1
  9. package/dist/dispatch/relayflow-registry.d.ts +61 -0
  10. package/dist/dispatch/relayflow-registry.d.ts.map +1 -0
  11. package/dist/dispatch/relayflow-registry.js +133 -0
  12. package/dist/dispatch/relayflow-registry.js.map +1 -0
  13. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  14. package/dist/fleet/internal-fleet-client.js +159 -17
  15. package/dist/fleet/internal-fleet-client.js.map +1 -1
  16. package/dist/index.d.ts +7 -5
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +3 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/node/factory-node.d.ts +6 -5
  21. package/dist/node/factory-node.d.ts.map +1 -1
  22. package/dist/node/factory-node.js +58 -43
  23. package/dist/node/factory-node.js.map +1 -1
  24. package/dist/orchestrator/factory.d.ts +2 -0
  25. package/dist/orchestrator/factory.d.ts.map +1 -1
  26. package/dist/orchestrator/factory.js +316 -72
  27. package/dist/orchestrator/factory.js.map +1 -1
  28. package/dist/orchestrator/index.d.ts +1 -1
  29. package/dist/orchestrator/index.d.ts.map +1 -1
  30. package/dist/orchestrator/index.js +1 -1
  31. package/dist/orchestrator/index.js.map +1 -1
  32. package/dist/ports/index.d.ts +1 -1
  33. package/dist/ports/index.d.ts.map +1 -1
  34. package/dist/ports/writeback.d.ts +6 -0
  35. package/dist/ports/writeback.d.ts.map +1 -1
  36. package/dist/subscriptions/event-client.d.ts +1 -0
  37. package/dist/subscriptions/event-client.d.ts.map +1 -1
  38. package/dist/subscriptions/event-client.js +1 -0
  39. package/dist/subscriptions/event-client.js.map +1 -1
  40. package/dist/types.d.ts +8 -1
  41. package/dist/types.d.ts.map +1 -1
  42. package/dist/writeback/github.d.ts +19 -1
  43. package/dist/writeback/github.d.ts.map +1 -1
  44. package/dist/writeback/github.js +130 -0
  45. package/dist/writeback/github.js.map +1 -1
  46. package/dist/writeback/index.d.ts +2 -1
  47. package/dist/writeback/index.d.ts.map +1 -1
  48. package/dist/writeback/index.js +1 -1
  49. package/dist/writeback/index.js.map +1 -1
  50. package/package.json +8 -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
@@ -58,6 +60,7 @@ const DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS = 15_000;
58
60
  const REMOTE_OPERATION_PROGRESS_INTERVAL_MS = 15_000;
59
61
  const REMOTE_OPERATION_SLOW_WARN_MS = 30_000;
60
62
  const GITHUB_FACTORY_LABEL = 'factory';
63
+ const GITHUB_LIFECYCLE_LABELS = new Set(['factory:in-progress', 'factory:human-review']);
61
64
  const GITHUB_MIRROR_TITLE_PREFIX = '[factory]';
62
65
  const GITHUB_MIRROR_SOURCE_PREFIX = 'Source: ';
63
66
  export const DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH = '/tmp/factory-run/factory-loop-heartbeat.json';
@@ -76,6 +79,7 @@ export class FactoryLoop {
76
79
  #fleet;
77
80
  #triage;
78
81
  #linear;
82
+ #githubWriteback;
79
83
  #slack;
80
84
  #mergeGate;
81
85
  #probeCloser;
@@ -91,6 +95,7 @@ export class FactoryLoop {
91
95
  #terminationGraceMs;
92
96
  #state;
93
97
  #workspaceId;
98
+ #relayflows;
94
99
  #batchView;
95
100
  #batchReady;
96
101
  #listeners = new Map();
@@ -158,6 +163,7 @@ export class FactoryLoop {
158
163
  // undefined = readiness not yet probed; resolved lazily so the standalone
159
164
  // runOnce() path (which skips #start) still ingests when the mount is present.
160
165
  #githubIngestionEnabled;
166
+ #resolvedIssueSource;
161
167
  #integrationInstructions;
162
168
  #integrationInstructionsRefresh;
163
169
  #starting;
@@ -177,6 +183,7 @@ export class FactoryLoop {
177
183
  this.#linear = ports.linear ?? MountLinearWriteback(ports.mount, {
178
184
  safety: config.safety,
179
185
  });
186
+ this.#githubWriteback = ports.githubWriteback ?? new GhCliGithubWriteback();
180
187
  this.#slack = config.slack ? MountSlackWriteback(ports.mount, config.slack) : ports.slack;
181
188
  void (ports.github ?? MountGithubRead(ports.mount));
182
189
  this.#mergeGate = ports.mergeGate ?? new GithubMergeGate();
@@ -195,6 +202,7 @@ export class FactoryLoop {
195
202
  this.#readChildPids = ports.readChildPids;
196
203
  this.#terminationGraceMs = ports.terminationGraceMs;
197
204
  this.#workspaceId = config.workspaceId ?? 'default';
205
+ this.#relayflows = ports.relayflows;
198
206
  this.#state = ports.stateStore ?? new InMemoryStateStore({
199
207
  batchSize: config.batchSize,
200
208
  agentQuestionDedupeLimit: AGENT_QUESTION_DEDUPE_LIMIT,
@@ -287,12 +295,19 @@ export class FactoryLoop {
287
295
  }
288
296
  async #start(opts) {
289
297
  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`));
298
+ const issueSource = await this.#issueSource();
299
+ if (issueSource === 'linear') {
300
+ const ready = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
301
+ if (ready !== 'ready') {
302
+ this.#error(new Error(`${ISSUE_ROOT} sub-root is not mounted`));
303
+ return;
304
+ }
305
+ }
306
+ const githubReady = await this.#ensureGithubIngestionReady();
307
+ if (issueSource === 'github' && !githubReady) {
308
+ this.#error(new Error(`${GITHUB_ISSUE_ROOT} sub-root is not mounted`));
293
309
  return;
294
310
  }
295
- await this.#ensureGithubIngestionReady();
296
311
  this.#wireFleetEvents();
297
312
  if ((opts.mode ?? 'live') === 'live') {
298
313
  this.#started = true;
@@ -309,7 +324,8 @@ export class FactoryLoop {
309
324
  }
310
325
  }
311
326
  await this.#backfillReadyIssues();
312
- this.#subscription = this.#mount.subscribe([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB], (event) => {
327
+ this.#subscription = this.#mount.subscribe(this.#subscriptionGlobs([`${ISSUE_ROOT}/**/*.json`, LIVE_GITHUB_ISSUE_GLOB]), (event) => {
328
+ void this.#dispatchRelayflowEvent(event);
313
329
  // The SDK types `resource` as always-present, but the polling fallback and
314
330
  // degraded-sync paths can deliver events without it. Skip those rather
315
331
  // than throwing (which would otherwise crash the subscription handler).
@@ -415,7 +431,7 @@ export class FactoryLoop {
415
431
  // LIVE_GITHUB_ISSUE_GLOB is a terminal `${GITHUB_ISSUE_ROOT}/**`, so it
416
432
  // already covers the PR change events the babysitter consumes; pull-event
417
433
  // *processing* is gated on babysitter.enabled in #prepareLiveEventForDrain.
418
- this.#subscription = this.#mount.subscribe([LIVE_ISSUE_GLOB, LIVE_GITHUB_ISSUE_GLOB], (event) => {
434
+ this.#subscription = this.#mount.subscribe(this.#subscriptionGlobs([LIVE_ISSUE_GLOB, LIVE_GITHUB_ISSUE_GLOB]), (event) => {
419
435
  this.#enqueueLiveEvent(event);
420
436
  }, { from: 'now', coalesce: 'none' });
421
437
  }
@@ -618,9 +634,12 @@ export class FactoryLoop {
618
634
  const batch = events.splice(0, LIVE_EVENT_DRAIN_BATCH_SIZE);
619
635
  const paths = [];
620
636
  for (const event of batch) {
621
- const path = await this.#prepareLiveEventForDrain(event, seenIssueKeys);
622
- if (path) {
623
- paths.push(path);
637
+ const prepared = await this.#prepareLiveEventForDrain(event, seenIssueKeys);
638
+ if (prepared.dispatchRelayflow) {
639
+ void this.#dispatchRelayflowEvent(event);
640
+ }
641
+ if (prepared.path) {
642
+ paths.push(prepared.path);
624
643
  }
625
644
  }
626
645
  await Promise.all(paths.map((path) => this.#handlePreparedLiveChange(path)));
@@ -652,14 +671,18 @@ export class FactoryLoop {
652
671
  return;
653
672
  await this.#refreshLiveHeartbeat();
654
673
  }
674
+ #subscriptionGlobs(factoryGlobs) {
675
+ return this.#relayflows ? [LIVE_RELAYFLOW_GLOB] : factoryGlobs;
676
+ }
655
677
  async #prepareLiveEventForDrain(event, seenIssueKeys) {
656
678
  const path = changeEventPath(event);
657
679
  if (!path) {
658
- return undefined;
680
+ return { dispatchRelayflow: false };
659
681
  }
660
682
  const isPullPath = isGithubPullFilePath(path);
661
- if (!isIssueFilePath(path) && !isGithubIssueFilePath(path) && !isPullPath) {
662
- return undefined;
683
+ const isFactoryPath = isIssueFilePath(path) || isGithubIssueFilePath(path) || isPullPath;
684
+ if (!isFactoryPath && !this.#relayflows) {
685
+ return { dispatchRelayflow: false };
663
686
  }
664
687
  if (isBeforeLiveCutoff(event.occurredAt, this.#liveConnectStartedAtMs, this.#liveReplaySkewMarginMs)) {
665
688
  this.#increment('liveReplayEventsSuppressed');
@@ -671,7 +694,7 @@ export class FactoryLoop {
671
694
  connectStartedAt: new Date(this.#liveConnectStartedAtMs).toISOString(),
672
695
  replaySkewMarginMs: this.#liveReplaySkewMarginMs,
673
696
  });
674
- return undefined;
697
+ return { dispatchRelayflow: false };
675
698
  }
676
699
  if (isAtOrBeforeHighWatermark(event.id, this.#liveEventHighWatermark)) {
677
700
  this.#increment('liveReplayEventsSuppressed');
@@ -681,7 +704,7 @@ export class FactoryLoop {
681
704
  highWatermark: this.#liveEventHighWatermark,
682
705
  path,
683
706
  });
684
- return undefined;
707
+ return { dispatchRelayflow: false };
685
708
  }
686
709
  const dedupeKey = liveEventDedupeKey(event);
687
710
  if (dedupeKey) {
@@ -691,7 +714,7 @@ export class FactoryLoop {
691
714
  id: event.id,
692
715
  path,
693
716
  });
694
- return undefined;
717
+ return { dispatchRelayflow: false };
695
718
  }
696
719
  rememberLiveEvent(this.#seenLiveEvents, dedupeKey);
697
720
  }
@@ -699,6 +722,9 @@ export class FactoryLoop {
699
722
  this.#increment('liveEventsMissingIdentity');
700
723
  this.#logger.warn?.('[factory] live issue event missing stable identity', { path });
701
724
  }
725
+ if (!isFactoryPath) {
726
+ return { dispatchRelayflow: true };
727
+ }
702
728
  if (isGithubIssueFilePath(path)) {
703
729
  const sourceKey = `github:${path}`;
704
730
  if (seenIssueKeys.has(sourceKey)) {
@@ -707,11 +733,11 @@ export class FactoryLoop {
707
733
  id: event.id,
708
734
  path,
709
735
  });
710
- return undefined;
736
+ return { dispatchRelayflow: false };
711
737
  }
712
738
  seenIssueKeys.add(sourceKey);
713
739
  this.#recordArrivalLatency(event);
714
- return path;
740
+ return { path, dispatchRelayflow: true };
715
741
  }
716
742
  if (isPullPath) {
717
743
  // Dedupe PR change events by path within a drain; the babysitter routing
@@ -719,11 +745,11 @@ export class FactoryLoop {
719
745
  const sourceKey = `pull:${path}`;
720
746
  if (seenIssueKeys.has(sourceKey)) {
721
747
  this.#increment('liveDuplicatePrEventsSuppressed');
722
- return undefined;
748
+ return { dispatchRelayflow: false };
723
749
  }
724
750
  seenIssueKeys.add(sourceKey);
725
751
  this.#recordArrivalLatency(event);
726
- return path;
752
+ return { path, dispatchRelayflow: true };
727
753
  }
728
754
  const issueKey = keyFromPath(path);
729
755
  if (seenIssueKeys.has(issueKey)) {
@@ -733,7 +759,7 @@ export class FactoryLoop {
733
759
  path,
734
760
  issue: issueKey,
735
761
  });
736
- return undefined;
762
+ return { dispatchRelayflow: false };
737
763
  }
738
764
  const issueRef = { key: issueKey, uuid: uuidFromPath(path) ?? issueKey, path };
739
765
  const batch = await this.#batch();
@@ -744,11 +770,40 @@ export class FactoryLoop {
744
770
  path,
745
771
  issue: issueKey,
746
772
  });
747
- return undefined;
773
+ return { dispatchRelayflow: false };
748
774
  }
749
775
  seenIssueKeys.add(issueKey);
750
776
  this.#recordArrivalLatency(event);
751
- return path;
777
+ return { path, dispatchRelayflow: true };
778
+ }
779
+ async #dispatchRelayflowEvent(event) {
780
+ const relayflows = this.#relayflows;
781
+ if (!relayflows)
782
+ return;
783
+ try {
784
+ const result = await dispatchRelayflowForChangeEvent(event, relayflows.registry, {
785
+ cwd: relayflows.cwd ?? process.cwd(),
786
+ mountRoot: relayflows.mountRoot ?? this.#integrationsMountRoot(),
787
+ workflowRunner: relayflows.workflowRunner,
788
+ });
789
+ if (!result)
790
+ return;
791
+ this.#increment('relayflowEventsDispatched');
792
+ this.#logger.info?.('[factory] relayflow dispatched for integration event', {
793
+ trigger: result.trigger,
794
+ templatePath: result.templatePath,
795
+ runId: result.runId,
796
+ status: result.status,
797
+ });
798
+ }
799
+ catch (error) {
800
+ this.#increment('relayflowDispatchErrors');
801
+ this.#logger.warn?.('[factory] relayflow dispatch failed for integration event', {
802
+ eventId: event.id,
803
+ path: changeEventPath(event),
804
+ error,
805
+ });
806
+ }
752
807
  }
753
808
  async #handlePreparedLiveChange(path) {
754
809
  if (isGithubPullFilePath(path)) {
@@ -888,7 +943,13 @@ export class FactoryLoop {
888
943
  this.#logger.info?.('[factory] run-once started', { dryRun });
889
944
  let report;
890
945
  try {
891
- await this.#ingestGithubIssues({ dryRun });
946
+ const issueSource = await this.#issueSource();
947
+ if (issueSource === 'linear') {
948
+ await this.#ingestGithubIssues({ dryRun });
949
+ }
950
+ else {
951
+ await this.#ensureGithubIngestionReady();
952
+ }
892
953
  const paths = await this.#readyIssuePaths();
893
954
  const pulled = [];
894
955
  const triaged = [];
@@ -900,7 +961,7 @@ export class FactoryLoop {
900
961
  const issue = await this.#readIssue(path);
901
962
  readyIssueReads += 1;
902
963
  lastReadyReadProgressAtMs = this.#logTimedProgress('[factory] Linear ready issue read progress', startedAtMs, lastReadyReadProgressAtMs, { read: readyIssueReads, total: paths.length, path });
903
- if (issue) {
964
+ if (issue && issueSource === 'linear') {
904
965
  await this.#recordCanonicalIssueState(issue);
905
966
  }
906
967
  if (!issue) {
@@ -917,7 +978,7 @@ export class FactoryLoop {
917
978
  skipped.push({ issue: issueRef(issue), reason: 'already tracked' });
918
979
  continue;
919
980
  }
920
- if (!this.#states.isRole(issue.stateId, 'readyForAgent')) {
981
+ if (!this.#isIssueReady(issue)) {
921
982
  skipped.push({ issue: issueRef(issue), reason: 'live state is not ready-for-agent' });
922
983
  continue;
923
984
  }
@@ -925,7 +986,7 @@ export class FactoryLoop {
925
986
  skipped.push({ issue: issueRef(issue), reason: 'not factory-e2e scope' });
926
987
  continue;
927
988
  }
928
- if (!isRealLinearIssue(issue)) {
989
+ if (!isDispatchableIssue(issue)) {
929
990
  skipped.push({ issue: issueRef(issue), reason: 'not reconciled real Linear issue' });
930
991
  continue;
931
992
  }
@@ -1153,7 +1214,7 @@ export class FactoryLoop {
1153
1214
  this.#error(error, decision.issue);
1154
1215
  throw error;
1155
1216
  }
1156
- if (!isRealLinearIssue(liveIssue)) {
1217
+ if (!isDispatchableIssue(liveIssue)) {
1157
1218
  const error = new Error(`Refusing to dispatch ${decision.issue.key}: not reconciled real Linear issue`);
1158
1219
  this.#error(error, decision.issue);
1159
1220
  throw error;
@@ -1179,7 +1240,7 @@ export class FactoryLoop {
1179
1240
  const signature = labelDispatchFailureSignature(labelDispatch);
1180
1241
  if (this.#labelDispatchFailures.get(decision.issue.key) !== signature) {
1181
1242
  try {
1182
- await this.#linear.postComment(liveIssue, comment);
1243
+ await this.#postIssueComment(liveIssue, comment);
1183
1244
  // Record only after a successful post so a failed writeback retries
1184
1245
  // next cycle rather than being suppressed as already-notified.
1185
1246
  this.#labelDispatchFailures.set(decision.issue.key, signature);
@@ -1228,17 +1289,22 @@ export class FactoryLoop {
1228
1289
  let implementingStateId;
1229
1290
  if (!dryRun) {
1230
1291
  const issue = await this.#readIssue(dispatchDecision.issue.path);
1231
- if (!issue || !this.#states.isRole(issue.stateId, 'readyForAgent')) {
1292
+ if (!issue || !this.#isIssueReady(issue)) {
1232
1293
  throw new Error(`Live state changed before writeback for ${dispatchDecision.issue.key}`);
1233
1294
  }
1234
- implementingStateId = this.#states.idFor(issue.team, 'agentImplementing');
1235
1295
  try {
1236
- await this.#linear.postComment(issue, comment);
1296
+ await this.#postIssueComment(issue, comment);
1237
1297
  }
1238
1298
  catch (error) {
1239
1299
  this.#logger.warn?.('[factory] comment writeback skipped', error);
1240
1300
  }
1241
- await this.#linear.setState(issue, implementingStateId);
1301
+ if (isGithubIssue(issue)) {
1302
+ await this.#githubWriteback.setStatus(issue, 'in-progress');
1303
+ }
1304
+ else {
1305
+ implementingStateId = this.#states.idFor(issue.team, 'agentImplementing');
1306
+ await this.#linear.setState(issue, implementingStateId);
1307
+ }
1242
1308
  this.#emit('writeback-verified', { issue: dispatchDecision.issue, path: issue.path });
1243
1309
  }
1244
1310
  const result = {
@@ -1320,24 +1386,24 @@ export class FactoryLoop {
1320
1386
  }
1321
1387
  }
1322
1388
  async #handleChange(path, opts = {}) {
1323
- if (!isIssueFilePath(path)) {
1389
+ if (!isIssueFilePath(path) && !isGithubIssueFilePath(path)) {
1324
1390
  return;
1325
1391
  }
1326
1392
  try {
1327
1393
  const issue = await this.#readIssue(path);
1328
- if (issue) {
1394
+ if (issue && !isGithubIssue(issue)) {
1329
1395
  await this.#recordCanonicalIssueState(issue);
1330
1396
  }
1331
- if (!issue || !this.#states.isRole(issue.stateId, 'readyForAgent')) {
1397
+ if (!issue || !this.#isIssueReady(issue)) {
1332
1398
  return;
1333
1399
  }
1334
- if (opts.requireRealIssue && !isRealLinearIssue(issue)) {
1400
+ if (opts.requireRealIssue && !isDispatchableIssue(issue)) {
1335
1401
  return;
1336
1402
  }
1337
1403
  if (!isInFactoryScope(issue, this.#config.safety)) {
1338
1404
  return;
1339
1405
  }
1340
- if (!isRealLinearIssue(issue)) {
1406
+ if (!isDispatchableIssue(issue)) {
1341
1407
  return;
1342
1408
  }
1343
1409
  const batch = await this.#batch();
@@ -1367,6 +1433,42 @@ export class FactoryLoop {
1367
1433
  this.#logger.error?.('[factory] failed to handle issue change', error);
1368
1434
  }
1369
1435
  }
1436
+ async #issueSource() {
1437
+ if (this.#resolvedIssueSource) {
1438
+ return this.#resolvedIssueSource;
1439
+ }
1440
+ if (this.#config.issueSource) {
1441
+ this.#resolvedIssueSource = this.#config.issueSource;
1442
+ return this.#resolvedIssueSource;
1443
+ }
1444
+ const linearReady = await this.#mount.ensureSubRoot(ISSUE_ROOT, { timeoutMs: 90_000 });
1445
+ this.#resolvedIssueSource = linearReady === 'ready' ? 'linear' : 'github';
1446
+ if (this.#resolvedIssueSource === 'github') {
1447
+ this.#logger.info?.('[factory] Linear issue source is not connected; using GitHub issues');
1448
+ }
1449
+ return this.#resolvedIssueSource;
1450
+ }
1451
+ #isIssueReady(issue) {
1452
+ if (!isGithubIssue(issue)) {
1453
+ return this.#states.isRole(issue.stateId, 'readyForAgent');
1454
+ }
1455
+ const githubState = (issue.state?.name ?? stringValue(wrappedPayload(issue.raw).state) ?? '').trim().toLowerCase();
1456
+ if (githubState === 'closed') {
1457
+ return false;
1458
+ }
1459
+ if (issue.labels.some((label) => GITHUB_LIFECYCLE_LABELS.has(label.trim().toLowerCase()))) {
1460
+ return false;
1461
+ }
1462
+ const required = this.#config.safety.requireLabel.trim().toLowerCase();
1463
+ return Boolean(required) && issue.labels.some((label) => label.trim().toLowerCase() === required);
1464
+ }
1465
+ async #postIssueComment(issue, body) {
1466
+ if (isGithubIssue(issue)) {
1467
+ await this.#githubWriteback.postComment(issue, body);
1468
+ return;
1469
+ }
1470
+ await this.#linear.postComment(issue, body);
1471
+ }
1370
1472
  // Probes the GitHub issue sub-root at most once and caches the verdict so
1371
1473
  // repeated iterations skip listTree calls when the mount is absent.
1372
1474
  async #ensureGithubIngestionReady() {
@@ -1481,6 +1583,12 @@ export class FactoryLoop {
1481
1583
  if (!ghIssue) {
1482
1584
  return;
1483
1585
  }
1586
+ if (await this.#issueSource() === 'github') {
1587
+ if (!githubIssueIsClosed(ghIssue) && githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
1588
+ await this.#handleChange(ghIssue.path);
1589
+ }
1590
+ return;
1591
+ }
1484
1592
  if (githubIssueIsClosed(ghIssue)) {
1485
1593
  const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
1486
1594
  if (mirror && !this.#states.isRole(mirror.stateId, 'done')) {
@@ -1497,7 +1605,7 @@ export class FactoryLoop {
1497
1605
  }
1498
1606
  return;
1499
1607
  }
1500
- if (!githubIssueHasFactoryLabel(ghIssue)) {
1608
+ if (!githubIssueHasFactoryLabel(ghIssue, this.#config.safety.requireLabel)) {
1501
1609
  return;
1502
1610
  }
1503
1611
  const mirror = await this.#findGithubIssueMirror(ghIssue, opts.candidates);
@@ -1775,6 +1883,9 @@ export class FactoryLoop {
1775
1883
  }
1776
1884
  }
1777
1885
  async #readyIssuePaths() {
1886
+ if (await this.#issueSource() === 'github') {
1887
+ return this.#githubIssuePaths();
1888
+ }
1778
1889
  const pathsByKey = new Map();
1779
1890
  const canonicalPathsByKey = new Map();
1780
1891
  for (const path of await this.#listRelayfileTree(ISSUE_ROOT, 'Linear ready issue canonical discovery')) {
@@ -1800,6 +1911,10 @@ export class FactoryLoop {
1800
1911
  }
1801
1912
  async #readIssue(path) {
1802
1913
  try {
1914
+ if (isGithubIssueFilePath(path)) {
1915
+ const githubIssue = await this.#readGithubIssue(path);
1916
+ return githubIssue ? githubIssueAsFactoryIssue(githubIssue) : undefined;
1917
+ }
1803
1918
  // Newly-synced issues land as a change-event STUB at the primary
1804
1919
  // /linear/issues/<key>__<uuid>.json path (no state/url/team); the full
1805
1920
  // record lands at the by-id / by-uuid aliases. Read the canonical sibling
@@ -2525,9 +2640,15 @@ export class FactoryLoop {
2525
2640
  }
2526
2641
  this.#postMergeDoneAdvances.add(advanceKey);
2527
2642
  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 });
2643
+ const githubIssue = isGithubIssue(issue);
2644
+ if (githubIssue) {
2645
+ await this.#githubWriteback.closeIssue(issue, `Factory observed pull request #${snapshot.number} merge and completed this issue.`);
2646
+ }
2647
+ else {
2648
+ const doneStateId = this.#states.idFor(issue.team, 'done');
2649
+ await this.#linear.setState(issue, doneStateId);
2650
+ await this.#recordCanonicalIssueState({ key: issue.key, stateId: doneStateId });
2651
+ }
2531
2652
  this.#emit('writeback-verified', { issue: issueRef(issue), path: issue.path });
2532
2653
  this.#increment('mergedPrAdvancedDone');
2533
2654
  this.#increment('done');
@@ -2540,11 +2661,12 @@ export class FactoryLoop {
2540
2661
  try {
2541
2662
  const channel = await this.#slackChannelDir();
2542
2663
  if (channel) {
2664
+ const systemOfRecord = githubIssue ? 'GitHub issue closed' : 'Linear state set to Done';
2543
2665
  const root = await this.#slack.postThread({
2544
2666
  channel,
2545
- text: `${issue.key}: PR merged; Linear state set to Done.`,
2667
+ text: `${issue.key}: PR merged; ${systemOfRecord}.`,
2546
2668
  });
2547
- await this.#slack.reply(root.threadId, `${issue.key}: Linear state set to Done.`);
2669
+ await this.#slack.reply(root.threadId, `${issue.key}: ${systemOfRecord}.`);
2548
2670
  this.#recordSlackWritebackSuccess('merge-done-thread');
2549
2671
  }
2550
2672
  }
@@ -2571,15 +2693,19 @@ export class FactoryLoop {
2571
2693
  // This no-record path runs after agents are released, so there is no
2572
2694
  // tracked PR identity left. Keep the scan simple and prefer branch identity
2573
2695
  // 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)) {
2696
+ const githubSource = await this.#issueSource() === 'github';
2697
+ const paths = githubSource ? await this.#githubIssuePaths() : await this.#mount.listTree(ISSUE_ROOT);
2698
+ for (const path of paths) {
2699
+ if (githubSource ? !isGithubIssueFilePath(path) : !isIssueFilePath(path)) {
2576
2700
  continue;
2577
2701
  }
2578
2702
  const issue = await this.#readIssue(path);
2579
- if (!issue || !isUpstreamState(issue.stateId)) {
2703
+ if (!issue || (githubSource
2704
+ ? issue.state?.name?.trim().toLowerCase() === 'closed'
2705
+ : !isUpstreamState(issue.stateId))) {
2580
2706
  continue;
2581
2707
  }
2582
- if (!isRealLinearIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) {
2708
+ if (!isDispatchableIssue(issue) || !isInFactoryScope(issue, this.#config.safety)) {
2583
2709
  continue;
2584
2710
  }
2585
2711
  const score = prSnapshotIssueMatchScore(snapshot, issue.key);
@@ -2587,7 +2713,7 @@ export class FactoryLoop {
2587
2713
  best = { issue, score };
2588
2714
  }
2589
2715
  }
2590
- this.#logger.debug?.('[factory] scanned Linear issues for merged PR advance', {
2716
+ this.#logger.debug?.(`[factory] scanned ${githubSource ? 'GitHub' : 'Linear'} issues for merged PR advance`, {
2591
2717
  prNumber: snapshot.number,
2592
2718
  durationMs: this.#clock.now() - scanStartedAtMs,
2593
2719
  matchedIssue: best?.issue.key,
@@ -2752,6 +2878,28 @@ export class FactoryLoop {
2752
2878
  }
2753
2879
  return found;
2754
2880
  }
2881
+ async #githubPrObservedMerged(record, issue) {
2882
+ const babysatSnapshot = await this.#readBabysatPrSnapshot(record);
2883
+ if (babysatSnapshot && prMetaShowsMerged(babysatSnapshot)) {
2884
+ return true;
2885
+ }
2886
+ const pr = this.#babysitterPr.get(record.issue.key) ?? await this.#completionPrForIssue(issue);
2887
+ if (!pr) {
2888
+ return false;
2889
+ }
2890
+ for (const path of await this.#pullMetaPathsFor(pr.repo, pr.prNumber)) {
2891
+ try {
2892
+ const snapshot = parsePullSnapshot((await this.#mount.readFile(path)).content, pr.prNumber);
2893
+ if (snapshot && prMetaShowsMerged(snapshot)) {
2894
+ return true;
2895
+ }
2896
+ }
2897
+ catch {
2898
+ // A concurrent mount refresh may remove an alternate path; keep scanning.
2899
+ }
2900
+ }
2901
+ return false;
2902
+ }
2755
2903
  async #completeIssue(record, opts = {}) {
2756
2904
  const completionKey = issueKey(record.issue);
2757
2905
  if (this.#completionInFlight.has(completionKey)) {
@@ -2766,16 +2914,44 @@ export class FactoryLoop {
2766
2914
  // (the legacy terminal) so the issue never gets stuck on an unconfigured
2767
2915
  // state.
2768
2916
  const issueTeam = issue?.team;
2769
- const humanReview = opts.targetState !== 'done' &&
2917
+ const githubIssue = issue ? isGithubIssue(issue) : false;
2918
+ const configuredHumanReview = opts.targetState !== 'done' &&
2770
2919
  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');
2920
+ (githubIssue || this.#states.hasHumanReview(issueTeam));
2921
+ let githubMerged = githubIssue && opts.completionReason === 'pr-merged';
2922
+ if (issue && githubIssue && !configuredHumanReview && !githubMerged) {
2923
+ githubMerged = await this.#githubPrObservedMerged(record, issue);
2924
+ if (!githubMerged && opts.runMergeGate !== false) {
2925
+ const mergeCommandAccepted = await this.#runCompletionMergeGate(issue);
2926
+ // A successful merge command can mean queued/auto-merge rather than
2927
+ // merged. Only mounted PR state or a merged webhook may prove merge.
2928
+ if (mergeCommandAccepted) {
2929
+ githubMerged = await this.#githubPrObservedMerged(record, issue);
2930
+ }
2931
+ }
2932
+ }
2933
+ // A GitHub issue only closes after its PR merges. If a configured done
2934
+ // path cannot merge (including mergePolicy: never), park it for a human
2935
+ // instead of closing the issue while its PR remains open.
2936
+ const humanReview = configuredHumanReview || (githubIssue && !githubMerged);
2775
2937
  const statusLabel = humanReview ? 'In Human Review' : 'Done';
2776
2938
  if (issue) {
2777
- await this.#linear.setState(issue, targetState);
2778
- await this.#recordCanonicalIssueState({ key: issue.key, stateId: targetState });
2939
+ if (githubIssue) {
2940
+ if (humanReview) {
2941
+ await this.#githubWriteback.setStatus(issue, 'human-review');
2942
+ 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}`);
2943
+ }
2944
+ else {
2945
+ await this.#githubWriteback.closeIssue(issue, 'Factory observed the linked pull request merge and completed this issue.');
2946
+ }
2947
+ }
2948
+ else {
2949
+ const targetState = humanReview
2950
+ ? this.#states.idFor(issueTeam, 'humanReview')
2951
+ : this.#states.idFor(issueTeam, 'done');
2952
+ await this.#linear.setState(issue, targetState);
2953
+ await this.#recordCanonicalIssueState({ key: issue.key, stateId: targetState });
2954
+ }
2779
2955
  this.#emit('writeback-verified', { issue: record.issue, path: issue.path });
2780
2956
  }
2781
2957
  if (this.#slack && this.#config.slack && !await this.#shouldSkipSlackWriteback('completion-thread')) {
@@ -2783,14 +2959,15 @@ export class FactoryLoop {
2783
2959
  const channel = await this.#slackChannelDir();
2784
2960
  if (channel) {
2785
2961
  const merged = opts.completionReason === 'pr-merged';
2962
+ const systemOfRecord = githubIssue ? 'GitHub status' : 'Linear state';
2786
2963
  const completionText = merged
2787
- ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
2964
+ ? `${record.issue.key}: PR merged; ${systemOfRecord} set to ${statusLabel}.`
2788
2965
  : `${record.issue.key}: factory agents completed${humanReview ? '; awaiting human review' : ''}.\nStatus: ${statusLabel}\nMerge policy: ${this.#config.mergePolicy}`;
2789
2966
  const stateText = merged
2790
- ? `${record.issue.key}: PR merged; Linear state set to ${statusLabel}.`
2967
+ ? `${record.issue.key}: ${systemOfRecord} set to ${statusLabel}.`
2791
2968
  : humanReview
2792
- ? `${record.issue.key}: awaiting human review; Linear state set to ${statusLabel}.`
2793
- : `${record.issue.key}: Linear state set to ${statusLabel}.`;
2969
+ ? `${record.issue.key}: awaiting human review; ${systemOfRecord} set to ${statusLabel}.`
2970
+ : `${record.issue.key}: ${systemOfRecord} set to ${statusLabel}.`;
2794
2971
  const root = await this.#slack.postThread({
2795
2972
  channel,
2796
2973
  text: completionText,
@@ -2806,7 +2983,7 @@ export class FactoryLoop {
2806
2983
  // Only auto-merge on the `done` terminal path. Human Review parks the PR
2807
2984
  // for an operator — the merge gate (which requires an APPROVED review)
2808
2985
  // would refuse anyway, and we must not merge before the human has looked.
2809
- if (issue && !humanReview && opts.runMergeGate !== false) {
2986
+ if (issue && !githubIssue && !humanReview && opts.runMergeGate !== false) {
2810
2987
  await this.#runCompletionMergeGate(issue);
2811
2988
  }
2812
2989
  await this.#releaseAndTerminateAgents([...record.agents], humanReview ? 'issue-human-review' : 'issue-done', 'completion');
@@ -3373,11 +3550,11 @@ export class FactoryLoop {
3373
3550
  }
3374
3551
  async #handleTriageEscalationSlackAnswer(record, text) {
3375
3552
  const issue = await this.#readIssue(record.issue.path);
3376
- if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isRealLinearIssue(issue)) {
3553
+ if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
3377
3554
  this.#increment('slackTriageAnswersIgnoredIssueUnavailable');
3378
3555
  return;
3379
3556
  }
3380
- if (!this.#states.isRole(issue.stateId, 'readyForAgent')) {
3557
+ if (!this.#isIssueReady(issue)) {
3381
3558
  this.#increment('slackTriageAnswersIgnoredIssueNotReady');
3382
3559
  return;
3383
3560
  }
@@ -3544,27 +3721,27 @@ export class FactoryLoop {
3544
3721
  async #runCompletionMergeGate(issue) {
3545
3722
  if (this.#isSyntheticProbeIssue(issue)) {
3546
3723
  await this.#closeSyntheticProbeIfPresent(issue);
3547
- return;
3724
+ return false;
3548
3725
  }
3549
- if (!isRealLinearIssue(issue)) {
3726
+ if (!isDispatchableIssue(issue)) {
3550
3727
  this.#logger.warn?.('[factory] merge gate skipped non-real Linear issue', { issue: issue.key });
3551
3728
  this.#increment('mergeGateSkippedNonReal');
3552
- return;
3729
+ return false;
3553
3730
  }
3554
3731
  if (this.#config.mergePolicy !== 'on-green-with-review') {
3555
- return;
3732
+ return false;
3556
3733
  }
3557
3734
  const pr = await this.#probePrResolver(issue);
3558
3735
  if (!pr) {
3559
3736
  this.#logger.warn?.('[factory] merge gate found no PR for real issue', { issue: issue.key });
3560
3737
  this.#increment('mergeGateMissingPr');
3561
- return;
3738
+ return false;
3562
3739
  }
3563
3740
  const ready = await this.#waitForMergeReady(pr);
3564
3741
  const headSha = ready?.live.headRefOid;
3565
3742
  if (!ready || !headSha) {
3566
3743
  this.#increment('mergeGateNotMerged');
3567
- return;
3744
+ return false;
3568
3745
  }
3569
3746
  const result = await this.#mergeGate.merge({
3570
3747
  repo: pr.repo,
@@ -3580,7 +3757,7 @@ export class FactoryLoop {
3580
3757
  reason: result.reason,
3581
3758
  });
3582
3759
  this.#increment('mergeGateMergeAborted');
3583
- return;
3760
+ return false;
3584
3761
  }
3585
3762
  this.#logger.info?.('[factory] merge gate merged PR', {
3586
3763
  issue: issue.key,
@@ -3589,6 +3766,7 @@ export class FactoryLoop {
3589
3766
  headSha,
3590
3767
  });
3591
3768
  this.#increment('mergeGateMerged');
3769
+ return true;
3592
3770
  }
3593
3771
  async #closeSyntheticProbeIfPresent(issue) {
3594
3772
  const probe = this.#customProbePrResolver
@@ -3732,6 +3910,48 @@ export function parseGithubIssue(path, content) {
3732
3910
  raw: asRecord(parsed) ?? payload,
3733
3911
  };
3734
3912
  }
3913
+ export function parseGithubFactoryIssue(path, content) {
3914
+ return githubIssueAsFactoryIssue(parseGithubIssue(path, content));
3915
+ }
3916
+ const githubIssueAsFactoryIssue = (issue) => {
3917
+ const wrapper = asRecord(issue.raw) ?? {};
3918
+ const payload = wrappedPayload(wrapper);
3919
+ const objectId = stringValue(wrapper.objectId);
3920
+ const stableId = `${issue.repo}#${objectId ?? issue.number}`;
3921
+ return {
3922
+ uuid: stableId,
3923
+ key: String(issue.number),
3924
+ title: issue.title,
3925
+ description: issue.body,
3926
+ stateId: '',
3927
+ state: issue.state ? { name: issue.state } : undefined,
3928
+ labels: issue.labels,
3929
+ path: issue.path,
3930
+ raw: {
3931
+ ...wrapper,
3932
+ provider: 'github',
3933
+ objectType: 'issue',
3934
+ objectId: stableId,
3935
+ payload: {
3936
+ ...payload,
3937
+ id: stableId,
3938
+ number: issue.number,
3939
+ title: issue.title,
3940
+ description: issue.body,
3941
+ url: issue.url,
3942
+ source: {
3943
+ provider: 'github',
3944
+ id: stableId,
3945
+ owner: issue.owner,
3946
+ repo: issue.repoName,
3947
+ number: issue.number,
3948
+ url: issue.url,
3949
+ path: issue.path,
3950
+ },
3951
+ },
3952
+ },
3953
+ };
3954
+ };
3735
3955
  export async function readFactoryLoopHeartbeat(path = DEFAULT_FACTORY_LOOP_HEARTBEAT_PATH) {
3736
3956
  try {
3737
3957
  return parseJsonContent(await readFile(path, 'utf8'));
@@ -3764,6 +3984,27 @@ export function isRealLinearIssue(issue) {
3764
3984
  typeof payload.url === 'string' &&
3765
3985
  payload.url.length > 0;
3766
3986
  }
3987
+ export function isDispatchableIssue(issue) {
3988
+ if (isRealLinearIssue(issue)) {
3989
+ return true;
3990
+ }
3991
+ if (!isGithubIssue(issue)) {
3992
+ return false;
3993
+ }
3994
+ const payload = wrappedPayload(issue.raw);
3995
+ const source = asRecord(payload.source);
3996
+ const sourceId = stringValue(source?.id);
3997
+ const number = numberValue(source?.number) ?? numberValue(payload.number);
3998
+ const url = stringValue(source?.url) ?? stringValue(payload.url);
3999
+ const hasStableIdentity = Boolean(sourceId?.trim()) || (Number.isInteger(number) && (number ?? 0) > 0);
4000
+ return Boolean(issue.uuid.trim() && issue.key.trim() && hasStableIdentity && url?.trim());
4001
+ }
4002
+ const isGithubIssue = (issue) => {
4003
+ const wrapper = asRecord(issue.raw);
4004
+ const source = asRecord(wrappedPayload(issue.raw).source);
4005
+ return stringValue(source?.provider)?.toLowerCase() === 'github' &&
4006
+ (stringValue(wrapper?.provider)?.toLowerCase() === 'github' || isGithubIssueFilePath(issue.path));
4007
+ };
3767
4008
  const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
3768
4009
  const pidsFromSpawnResult = (result) => {
3769
4010
  const pids = new Set();
@@ -3943,7 +4184,10 @@ function githubUrlCandidates(value) {
3943
4184
  return [...candidates];
3944
4185
  }
3945
4186
  function labelRoutesForIssue(issue, config) {
3946
- const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label));
4187
+ const githubReadinessLabel = isGithubIssue(issue) ? config.safety.requireLabel.trim().toLowerCase() : undefined;
4188
+ const labels = uniqueNormalizedLabels(issue.labels).filter((label) => !isShapeLabel(label) &&
4189
+ label.toLowerCase() !== githubReadinessLabel &&
4190
+ (!isGithubIssue(issue) || !GITHUB_LIFECYCLE_LABELS.has(label.toLowerCase())));
3947
4191
  const routes = [];
3948
4192
  const offendingLabels = [];
3949
4193
  const seenRepos = new Set();
@@ -4171,7 +4415,7 @@ const githubIssueDirectoryPathParts = (path) => {
4171
4415
  slug: match[6],
4172
4416
  };
4173
4417
  };
4174
- const githubIssueHasFactoryLabel = (issue) => issue.labels.some((label) => label.toLowerCase() === GITHUB_FACTORY_LABEL);
4418
+ const githubIssueHasFactoryLabel = (issue, requiredLabel = GITHUB_FACTORY_LABEL) => issue.labels.some((label) => label.trim().toLowerCase() === requiredLabel.trim().toLowerCase());
4175
4419
  const githubIssueIsClosed = (issue) => issue.state === 'closed';
4176
4420
  const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => {
4177
4421
  const teamId = config.linear.teamIds[config.safety.requireTeamKey];