@agent-relay/factory 0.1.14 → 0.1.16

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 (66) hide show
  1. package/README.md +13 -6
  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 +44 -4
  5. package/dist/cli/fleet.js.map +1 -1
  6. package/dist/config/schema.d.ts +1 -0
  7. package/dist/config/schema.d.ts.map +1 -1
  8. package/dist/config/schema.js +1 -0
  9. package/dist/config/schema.js.map +1 -1
  10. package/dist/dispatch/templates.js +3 -3
  11. package/dist/dispatch/templates.js.map +1 -1
  12. package/dist/fleet/create-fleet.d.ts +6 -0
  13. package/dist/fleet/create-fleet.d.ts.map +1 -1
  14. package/dist/fleet/create-fleet.js +21 -0
  15. package/dist/fleet/create-fleet.js.map +1 -1
  16. package/dist/fleet/internal-fleet-client.d.ts +1 -0
  17. package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
  18. package/dist/fleet/internal-fleet-client.js +139 -8
  19. package/dist/fleet/internal-fleet-client.js.map +1 -1
  20. package/dist/github/merge-gate.d.ts.map +1 -1
  21. package/dist/github/merge-gate.js +2 -0
  22. package/dist/github/merge-gate.js.map +1 -1
  23. package/dist/github/probe-closer.d.ts +3 -1
  24. package/dist/github/probe-closer.d.ts.map +1 -1
  25. package/dist/github/probe-closer.js +8 -8
  26. package/dist/github/probe-closer.js.map +1 -1
  27. package/dist/index.d.ts +6 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +3 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/mount/relayfile-cloud-mount-client.d.ts +3 -2
  32. package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
  33. package/dist/mount/relayfile-cloud-mount-client.js +6 -1
  34. package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
  35. package/dist/mount/relayfile-github-connection-write.d.ts +26 -0
  36. package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -0
  37. package/dist/mount/relayfile-github-connection-write.js +127 -0
  38. package/dist/mount/relayfile-github-connection-write.js.map +1 -0
  39. package/dist/orchestrator/factory.d.ts +1 -0
  40. package/dist/orchestrator/factory.d.ts.map +1 -1
  41. package/dist/orchestrator/factory.js +724 -15
  42. package/dist/orchestrator/factory.js.map +1 -1
  43. package/dist/ports/index.d.ts +1 -1
  44. package/dist/ports/index.d.ts.map +1 -1
  45. package/dist/ports/mount.d.ts +27 -0
  46. package/dist/ports/mount.d.ts.map +1 -1
  47. package/dist/ports/state.d.ts +23 -0
  48. package/dist/ports/state.d.ts.map +1 -1
  49. package/dist/state/file-state-store.d.ts +20 -0
  50. package/dist/state/file-state-store.d.ts.map +1 -0
  51. package/dist/state/file-state-store.js +132 -0
  52. package/dist/state/file-state-store.js.map +1 -0
  53. package/dist/state/in-memory-state-store.d.ts +4 -1
  54. package/dist/state/in-memory-state-store.d.ts.map +1 -1
  55. package/dist/state/in-memory-state-store.js +30 -0
  56. package/dist/state/in-memory-state-store.js.map +1 -1
  57. package/dist/testing/fakes.d.ts +3 -2
  58. package/dist/testing/fakes.d.ts.map +1 -1
  59. package/dist/testing/fakes.js +3 -1
  60. package/dist/testing/fakes.js.map +1 -1
  61. package/dist/triage/schema.d.ts +44 -44
  62. package/dist/writeback/github.d.ts +2 -0
  63. package/dist/writeback/github.d.ts.map +1 -1
  64. package/dist/writeback/github.js +2 -0
  65. package/dist/writeback/github.js.map +1 -1
  66. package/package.json +3 -1
@@ -41,6 +41,7 @@ const SLACK_REPLY_POLL_INTERVAL_MS = 5_000;
41
41
  const AGENT_QUESTION_DEDUPE_LIMIT = 500;
42
42
  const AGENT_NEEDS_INPUT_MARKER = '[factory-needs-input]';
43
43
  const LEGACY_AGENT_NEEDS_INPUT_MARKER = 'FACTORY_NEEDS_INPUT';
44
+ const GITHUB_ESCALATION_MARKER_PREFIX = 'factory-escalation:';
44
45
  // The babysitter DMs `factory` with this marker once it believes the PR is
45
46
  // green; the orchestrator confirms readiness with one authoritative gh read
46
47
  // before transitioning the issue to Human Review.
@@ -104,6 +105,9 @@ export class FactoryLoop {
104
105
  #dispatchInFlight = new Map();
105
106
  #slackWatchers = new Map();
106
107
  #slackWatcherStarts = new Map();
108
+ #githubIssueCommentWatchers = new Map();
109
+ #githubIssueCommentWatchStates = new Map();
110
+ #githubIssueCommentQueues = new Map();
107
111
  #resolvedSlackChannelDir;
108
112
  #slackChannelDirRefresh;
109
113
  // Agents we've already logged an ambiguous-PID-lookup warning for, so the
@@ -115,6 +119,7 @@ export class FactoryLoop {
115
119
  // same notice every cycle. Cleared once the issue dispatches successfully.
116
120
  #labelDispatchFailures = new Map();
117
121
  #pendingSlackClarifications = new Map();
122
+ #pendingGithubClarifications = new Map();
118
123
  #postMergeDoneAdvances = new Set();
119
124
  #slackDegraded = false;
120
125
  #slackDegradedReason;
@@ -151,6 +156,7 @@ export class FactoryLoop {
151
156
  // Issue key -> the open PR the babysitter is shepherding, including the
152
157
  // webhook-fed mount path so readiness can re-read PR meta without a gh call.
153
158
  #babysitterPr = new Map();
159
+ #publishedPullRequests = new Set();
154
160
  #probePrGhBackoffUntilMs = new Map();
155
161
  #probePrResolvedCache = new Map();
156
162
  // GitHub issue mirror-id -> resolved Linear mirror path, so repeat ingestion
@@ -314,6 +320,7 @@ export class FactoryLoop {
314
320
  try {
315
321
  await this.#startLiveSubscription(opts.liveSubscription);
316
322
  await this.#rearmSlackReplyWatchers();
323
+ await this.#rearmGithubIssueCommentWatchers();
317
324
  this.#scheduleCompletionSweep(0);
318
325
  return;
319
326
  }
@@ -345,6 +352,7 @@ export class FactoryLoop {
345
352
  });
346
353
  this.#started = true;
347
354
  await this.#rearmSlackReplyWatchers();
355
+ await this.#rearmGithubIssueCommentWatchers();
348
356
  this.#scheduleCompletionSweep(0);
349
357
  }
350
358
  async stop() {
@@ -369,6 +377,10 @@ export class FactoryLoop {
369
377
  await this.#boundedStopTeardown('factory subscription unsubscribe', () => subscription?.unsubscribe());
370
378
  await Promise.all([...this.#slackWatchers.values()].map((watcher) => watcher.stop()));
371
379
  this.#slackWatchers.clear();
380
+ await Promise.all([...this.#githubIssueCommentWatchers.values()].map((watcher) => watcher.stop()));
381
+ this.#githubIssueCommentWatchers.clear();
382
+ this.#githubIssueCommentWatchStates.clear();
383
+ this.#githubIssueCommentQueues.clear();
372
384
  await this.#state.clearSlackThreads(this.#workspaceId);
373
385
  this.#slackWatcherStarts.clear();
374
386
  this.#offAgentExit?.();
@@ -1221,7 +1233,7 @@ export class FactoryLoop {
1221
1233
  }
1222
1234
  const escalationReason = triageEscalationReason(decision);
1223
1235
  if (escalationReason) {
1224
- const replayedResult = await this.#escalateTriageToSlack(decision, escalationReason, dryRun);
1236
+ const replayedResult = await this.#escalateTriage(decision, escalationReason, dryRun);
1225
1237
  this.#recordTriageEscalation(decision, escalationReason);
1226
1238
  return replayedResult ?? { issue: decision.issue, agents: [], dryRun };
1227
1239
  }
@@ -1322,6 +1334,7 @@ export class FactoryLoop {
1322
1334
  await this.#sendImplementerTask(record);
1323
1335
  await this.#sendCriticalReviewerMessage(record);
1324
1336
  await this.#injectPendingSlackClarification(record);
1337
+ await this.#injectPendingGithubClarification(record);
1325
1338
  }
1326
1339
  return result;
1327
1340
  }
@@ -1416,7 +1429,7 @@ export class FactoryLoop {
1416
1429
  const decision = await this.triageIssue(issue);
1417
1430
  const escalationReason = triageEscalationReason(decision);
1418
1431
  if (escalationReason) {
1419
- await this.#escalateTriageToSlack(decision, escalationReason, this.#config.dryRun);
1432
+ await this.#escalateTriage(decision, escalationReason, this.#config.dryRun);
1420
1433
  this.#recordTriageEscalation(decision, escalationReason);
1421
1434
  return;
1422
1435
  }
@@ -2183,6 +2196,19 @@ export class FactoryLoop {
2183
2196
  }
2184
2197
  const exiting = record.agents.get(name);
2185
2198
  if (isCompletionReason(reason)) {
2199
+ let publishedPr;
2200
+ if (exiting?.spec.role === 'implementer' &&
2201
+ !record.dryRun &&
2202
+ (this.#mount.githubWrite || this.#mount.writebackTransport === 'relayfile-cloud')) {
2203
+ try {
2204
+ publishedPr = await this.#publishImplementerPullRequest(record, exiting);
2205
+ }
2206
+ catch (error) {
2207
+ this.#increment('githubPullRequestPublishFailures');
2208
+ this.#error(error, record.issue);
2209
+ return;
2210
+ }
2211
+ }
2186
2212
  if (this.#config.babysitter.enabled && !record.dryRun) {
2187
2213
  // Babysitter path: an implementer/reviewer finishing does NOT mark the
2188
2214
  // issue done — it hands the open PR to the babysitter. The babysitter
@@ -2191,6 +2217,13 @@ export class FactoryLoop {
2191
2217
  if (exiting?.spec.role === 'babysitter') {
2192
2218
  await this.#maybeAdvanceToHumanReview(record);
2193
2219
  }
2220
+ else if (publishedPr) {
2221
+ await this.#ensureBabysitter(record, {
2222
+ repo: publishedPr.repo,
2223
+ prNumber: publishedPr.number,
2224
+ url: publishedPr.url,
2225
+ });
2226
+ }
2194
2227
  else {
2195
2228
  await this.#ensureBabysitterForIssue(record);
2196
2229
  }
@@ -2279,6 +2312,69 @@ export class FactoryLoop {
2279
2312
  this.#error(error, record.issue);
2280
2313
  }
2281
2314
  }
2315
+ async #publishImplementerPullRequest(record, implementer) {
2316
+ const key = `${issueKey(record.issue)}:${implementer.spec.repo}`;
2317
+ if (this.#publishedPullRequests.has(key))
2318
+ return undefined;
2319
+ const githubWrite = this.#mount.githubWrite;
2320
+ if (!githubWrite) {
2321
+ throw new Error('GitHub write path not available on this mount — connect GitHub to your workspace');
2322
+ }
2323
+ if (!implementer.spec.clonePath) {
2324
+ throw new Error(`GitHub PR publication requires a configured clone path for ${implementer.spec.repo}`);
2325
+ }
2326
+ const issue = await this.#readIssue(record.issue.path);
2327
+ if (!issue) {
2328
+ throw new Error(`Unable to publish GitHub PR: issue ${record.issue.key} is no longer readable`);
2329
+ }
2330
+ const sourceRepo = githubMirrorRepoForIssue(issue);
2331
+ const sourceRepoParts = sourceRepo ? githubRepoParts(sourceRepo) : undefined;
2332
+ const bareRepoName = implementer.spec.repo.includes('/') ? undefined : implementer.spec.repo;
2333
+ const sourceOwner = bareRepoName && sourceRepoParts?.repo === bareRepoName
2334
+ ? sourceRepoParts.owner
2335
+ : undefined;
2336
+ const repo = normalizeGithubRepo(implementer.spec.repo, this.#config.repos.org ?? sourceOwner);
2337
+ const baseRef = await this.#githubDefaultBranch(repo);
2338
+ const result = await githubWrite.publishPullRequest({
2339
+ repo,
2340
+ clonePath: implementer.spec.clonePath,
2341
+ baseRef,
2342
+ title: `${issue.key}: ${issue.title}`,
2343
+ body: githubPullRequestBody(issue),
2344
+ });
2345
+ this.#publishedPullRequests.add(key);
2346
+ this.#increment('githubPullRequestsPublished');
2347
+ this.#logger.info?.('[factory] published PR through workspace GitHub connection', {
2348
+ issue: issue.key,
2349
+ repo: result.repo,
2350
+ prNumber: result.number,
2351
+ url: result.url,
2352
+ });
2353
+ return result;
2354
+ }
2355
+ async #githubDefaultBranch(repo) {
2356
+ const parts = githubRepoParts(repo);
2357
+ if (!parts) {
2358
+ throw new Error(`GitHub repo must be owner/repo before resolving its default branch: ${repo}`);
2359
+ }
2360
+ const path = `/github/repos/${encodeURIComponent(parts.owner)}/${encodeURIComponent(parts.repo)}/meta.json`;
2361
+ let payload;
2362
+ try {
2363
+ payload = wrappedPayload((await this.#mount.readFile(path)).content);
2364
+ }
2365
+ catch (error) {
2366
+ throw new Error(`Unable to resolve the default branch for ${repo} from ${path}: ${describeError(error).errorMessage}`);
2367
+ }
2368
+ const repository = asRecord(payload.repository);
2369
+ const defaultBranch = (stringValue(payload.defaultBranch) ??
2370
+ stringValue(payload.default_branch) ??
2371
+ stringValue(repository?.defaultBranch) ??
2372
+ stringValue(repository?.default_branch))?.trim();
2373
+ if (!defaultBranch) {
2374
+ throw new Error(`GitHub repository metadata for ${repo} does not include a default branch`);
2375
+ }
2376
+ return defaultBranch;
2377
+ }
2282
2378
  // An implementer that exited, was resumed once, and STILL produced no PR is
2283
2379
  // not making progress. Surface it (counter + a best-effort Slack note to the
2284
2380
  // dispatch thread) so a human can step in, instead of the issue sitting
@@ -2406,15 +2502,11 @@ export class FactoryLoop {
2406
2502
  issue: record.issue.key,
2407
2503
  });
2408
2504
  }
2409
- await this.#postAgentQuestionToSlack(record, question);
2505
+ await this.#postAgentQuestion(record, question);
2410
2506
  }
2411
- async #postAgentQuestionToSlack(record, question) {
2507
+ async #postAgentQuestion(record, question) {
2412
2508
  if (!this.#slack || !this.#config.slack) {
2413
- this.#increment('agentQuestionsSkippedNoSlack');
2414
- this.#logger.warn?.('[factory] agent question has no Slack channel configured', {
2415
- issue: record.issue,
2416
- from: question.agentName,
2417
- });
2509
+ await this.#postAgentQuestionToGithub(record, question);
2418
2510
  return;
2419
2511
  }
2420
2512
  if (await this.#shouldSkipSlackWriteback('agent-question')) {
@@ -2447,6 +2539,401 @@ export class FactoryLoop {
2447
2539
  this.#logger.warn?.(`[factory] failed to post agent question for ${record.issue.key}`, error);
2448
2540
  }
2449
2541
  }
2542
+ async #postAgentQuestionToGithub(record, question) {
2543
+ const correlationId = githubEscalationCorrelationId('agent-question', record.issue, question.question);
2544
+ const issue = await this.#readIssue(record.issue.path);
2545
+ const source = issue ? githubIssueSourceRef(issue) : undefined;
2546
+ const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
2547
+ if (!issue || !source || !authorizedAuthor) {
2548
+ this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
2549
+ return;
2550
+ }
2551
+ await this.#addGithubIssueCommentWatch(record.issue, source, {
2552
+ correlationId,
2553
+ kind: 'agent-question',
2554
+ authorizedAuthor,
2555
+ });
2556
+ try {
2557
+ await this.#githubWriteback.postComment(issue, [
2558
+ `${record.issue.key}: ${question.agentName} needs input.`,
2559
+ `Question: ${question.question}`,
2560
+ `Authorized responder: @${authorizedAuthor} (the issue reporter).`,
2561
+ `Reply with a comment that starts with \`${githubReplyPrefix(correlationId)}\`.`,
2562
+ '',
2563
+ githubEscalationMarker(correlationId),
2564
+ ].join('\n'));
2565
+ this.#increment('agentQuestionsPostedToGithub');
2566
+ }
2567
+ catch (error) {
2568
+ await this.#removeGithubIssueCommentPending(source, correlationId);
2569
+ this.#surfaceEscalationDeliveryFailure('agent-question', record.issue, correlationId, 'GitHub issue comment writeback failed', error);
2570
+ }
2571
+ }
2572
+ async #addGithubIssueCommentWatch(issue, source, pending) {
2573
+ const key = githubIssueSourceKey(source);
2574
+ let watch = this.#githubIssueCommentWatchStates.get(key);
2575
+ if (!watch) {
2576
+ const persisted = await this.#state.listGithubIssueCommentWatches(this.#workspaceId);
2577
+ watch = persisted.find(([persistedKey]) => persistedKey === key)?.[1];
2578
+ }
2579
+ if (!watch) {
2580
+ const sinceCommentId = await this.#latestGithubIssueCommentId(source);
2581
+ watch = {
2582
+ issue,
2583
+ source,
2584
+ pending: [],
2585
+ sinceCommentId,
2586
+ lastSeenCommentId: sinceCommentId,
2587
+ processedCommentIds: [],
2588
+ };
2589
+ }
2590
+ const added = !watch.pending.some((candidate) => candidate.correlationId === pending.correlationId);
2591
+ if (added) {
2592
+ watch.pending.push(pending);
2593
+ }
2594
+ await this.#watchGithubIssueComments(watch);
2595
+ return added;
2596
+ }
2597
+ async #watchGithubIssueComments(watch) {
2598
+ watch = normalizeGithubIssueCommentWatch(watch);
2599
+ const key = githubIssueSourceKey(watch.source);
2600
+ this.#githubIssueCommentWatchStates.set(key, watch);
2601
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
2602
+ if (this.#githubIssueCommentWatchers.has(key)) {
2603
+ await this.#replayGithubIssueComments(key);
2604
+ return false;
2605
+ }
2606
+ const source = watch.source;
2607
+ let stopped = false;
2608
+ const handle = async (event) => {
2609
+ const path = changeEventPath(event);
2610
+ const parts = path ? githubIssueCommentPathParts(path) : undefined;
2611
+ if (stopped ||
2612
+ !path ||
2613
+ !parts ||
2614
+ parts.owner.toLowerCase() !== source.owner.toLowerCase() ||
2615
+ parts.repo.toLowerCase() !== source.repo.toLowerCase() ||
2616
+ parts.number !== source.number) {
2617
+ return;
2618
+ }
2619
+ try {
2620
+ await this.#enqueueGithubIssueComment(key, path);
2621
+ }
2622
+ catch (error) {
2623
+ this.#logger.error?.('[factory] failed to handle GitHub issue comment reply', {
2624
+ issue: watch.issue,
2625
+ path,
2626
+ error,
2627
+ });
2628
+ this.#increment('githubIssueCommentReplyErrors');
2629
+ }
2630
+ };
2631
+ let subscription;
2632
+ try {
2633
+ subscription = this.#mount.subscribe(githubIssueCommentGlobs(source), (event) => {
2634
+ void handle(event);
2635
+ });
2636
+ }
2637
+ catch (error) {
2638
+ this.#githubIssueCommentWatchStates.delete(key);
2639
+ this.#surfaceEscalationDeliveryFailure('github-reply-watcher', watch.issue, githubEscalationCorrelationId('reply-watcher', watch.issue, key), 'GitHub issue comment reply subscription failed', error);
2640
+ return false;
2641
+ }
2642
+ this.#githubIssueCommentWatchers.set(key, {
2643
+ stop: async () => {
2644
+ stopped = true;
2645
+ await this.#boundedStopTeardown('GitHub issue comment subscription unsubscribe', () => subscription.unsubscribe());
2646
+ },
2647
+ });
2648
+ await this.#replayGithubIssueComments(key);
2649
+ return true;
2650
+ }
2651
+ async #stopGithubIssueCommentWatcher(source, clearPersisted = true) {
2652
+ const key = githubIssueSourceKey(source);
2653
+ const watcher = this.#githubIssueCommentWatchers.get(key);
2654
+ this.#githubIssueCommentWatchers.delete(key);
2655
+ this.#githubIssueCommentWatchStates.delete(key);
2656
+ if (clearPersisted) {
2657
+ await this.#state.clearGithubIssueCommentWatch(this.#workspaceId, key);
2658
+ }
2659
+ await watcher?.stop();
2660
+ }
2661
+ async #stopGithubIssueCommentWatcherForIssue(issue) {
2662
+ const keysToStop = [];
2663
+ for (const [key, watch] of this.#githubIssueCommentWatchStates) {
2664
+ if (watch.issue.uuid === issue.uuid || watch.issue.path === issue.path) {
2665
+ keysToStop.push(key);
2666
+ }
2667
+ }
2668
+ for (const key of keysToStop) {
2669
+ const watcher = this.#githubIssueCommentWatchers.get(key);
2670
+ this.#githubIssueCommentWatchers.delete(key);
2671
+ this.#githubIssueCommentWatchStates.delete(key);
2672
+ await this.#state.clearGithubIssueCommentWatch(this.#workspaceId, key);
2673
+ await watcher?.stop();
2674
+ }
2675
+ }
2676
+ async #removeGithubIssueCommentPending(source, correlationId) {
2677
+ const key = githubIssueSourceKey(source);
2678
+ const watch = this.#githubIssueCommentWatchStates.get(key);
2679
+ if (!watch)
2680
+ return;
2681
+ watch.pending = watch.pending.filter((pending) => pending.correlationId !== correlationId);
2682
+ if (watch.pending.length === 0) {
2683
+ await this.#stopGithubIssueCommentWatcher(source);
2684
+ return;
2685
+ }
2686
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
2687
+ }
2688
+ async #rearmGithubIssueCommentWatchers() {
2689
+ for (const [, watch] of await this.#state.listGithubIssueCommentWatches(this.#workspaceId)) {
2690
+ if (watch.pending.length === 0)
2691
+ continue;
2692
+ try {
2693
+ await this.#watchGithubIssueComments(watch);
2694
+ this.#increment('githubIssueCommentWatchersRearmed');
2695
+ }
2696
+ catch (error) {
2697
+ this.#logger.warn?.('[factory] failed to re-arm GitHub issue comment watcher', {
2698
+ issue: watch.issue,
2699
+ error,
2700
+ });
2701
+ }
2702
+ }
2703
+ }
2704
+ async #replayGithubIssueComments(key) {
2705
+ const watch = this.#githubIssueCommentWatchStates.get(key);
2706
+ if (!watch)
2707
+ return;
2708
+ const comments = [];
2709
+ const sinceCommentId = githubCommentNumericId(watch.sinceCommentId ?? watch.lastSeenCommentId);
2710
+ const processedCommentIds = new Set(watch.processedCommentIds ?? []);
2711
+ for (const path of await this.#githubIssueCommentPaths(watch.source)) {
2712
+ const parts = githubIssueCommentPathParts(path);
2713
+ const id = parts ? githubCommentNumericId(parts.commentId) : undefined;
2714
+ if (id !== undefined && id > sinceCommentId && !processedCommentIds.has(String(id))) {
2715
+ comments.push({ path, id });
2716
+ }
2717
+ }
2718
+ comments.sort((a, b) => a.id - b.id);
2719
+ for (const comment of comments) {
2720
+ await this.#enqueueGithubIssueComment(key, comment.path);
2721
+ }
2722
+ }
2723
+ async #enqueueGithubIssueComment(key, path) {
2724
+ const previous = this.#githubIssueCommentQueues.get(key) ?? Promise.resolve();
2725
+ const current = previous.catch(() => undefined).then(async () => {
2726
+ await this.#handleGithubIssueComment(key, path);
2727
+ });
2728
+ this.#githubIssueCommentQueues.set(key, current);
2729
+ try {
2730
+ await current;
2731
+ }
2732
+ finally {
2733
+ if (this.#githubIssueCommentQueues.get(key) === current) {
2734
+ this.#githubIssueCommentQueues.delete(key);
2735
+ }
2736
+ }
2737
+ }
2738
+ async #githubIssueCommentPaths(source) {
2739
+ const paths = new Set();
2740
+ const owner = encodeURIComponent(source.owner);
2741
+ const repo = encodeURIComponent(source.repo);
2742
+ for (const prefix of [
2743
+ `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues`,
2744
+ `${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues`,
2745
+ ]) {
2746
+ try {
2747
+ for (const path of await this.#mount.listTree(prefix)) {
2748
+ const parts = githubIssueCommentPathParts(path);
2749
+ if (parts &&
2750
+ parts.owner.toLowerCase() === source.owner.toLowerCase() &&
2751
+ parts.repo.toLowerCase() === source.repo.toLowerCase() &&
2752
+ parts.number === source.number) {
2753
+ paths.add(path);
2754
+ }
2755
+ }
2756
+ }
2757
+ catch (error) {
2758
+ this.#logger.warn?.('[factory] unable to list GitHub issue comments for replay', { prefix, error });
2759
+ }
2760
+ }
2761
+ return [...paths];
2762
+ }
2763
+ async #latestGithubIssueCommentId(source) {
2764
+ let latest = 0;
2765
+ for (const path of await this.#githubIssueCommentPaths(source)) {
2766
+ const id = githubCommentNumericId(githubIssueCommentPathParts(path)?.commentId);
2767
+ if (id > latest)
2768
+ latest = id;
2769
+ }
2770
+ return latest > 0 ? String(latest) : undefined;
2771
+ }
2772
+ async #readGithubIssueComment(path) {
2773
+ try {
2774
+ const { content } = await this.#mount.readFile(path);
2775
+ return parseGithubIssueComment(path, content);
2776
+ }
2777
+ catch (error) {
2778
+ this.#logger.warn?.('[factory] unable to read GitHub issue comment reply', { path, error });
2779
+ return undefined;
2780
+ }
2781
+ }
2782
+ async #handleGithubIssueComment(key, path) {
2783
+ const watch = this.#githubIssueCommentWatchStates.get(key);
2784
+ if (!watch)
2785
+ return;
2786
+ const comment = await this.#readGithubIssueComment(path);
2787
+ if (!comment)
2788
+ return;
2789
+ const commentId = githubCommentNumericId(comment.commentId);
2790
+ const normalizedCommentId = String(commentId);
2791
+ const processedCommentIds = new Set(watch.processedCommentIds ?? []);
2792
+ if (commentId === 0 ||
2793
+ commentId <= githubCommentNumericId(watch.sinceCommentId) ||
2794
+ processedCommentIds.has(normalizedCommentId)) {
2795
+ this.#increment('githubIssueCommentDuplicatesSuppressed');
2796
+ return;
2797
+ }
2798
+ const reply = githubCorrelatedReply(comment.body);
2799
+ const pending = reply
2800
+ ? watch.pending.find((candidate) => candidate.correlationId === reply.correlationId)
2801
+ : undefined;
2802
+ let resolved = false;
2803
+ let discardClaimedPending = false;
2804
+ if (pending?.claimedByCommentId === normalizedCommentId) {
2805
+ this.#increment('githubAnswersClaimedReplaySuppressed');
2806
+ discardClaimedPending = true;
2807
+ }
2808
+ else if (pending && comment.isBot) {
2809
+ this.#increment('githubAnswersIgnoredBot');
2810
+ this.#logger.info?.('[factory] ignored GitHub issue answer from a bot', {
2811
+ issue: watch.issue,
2812
+ commentId: normalizedCommentId,
2813
+ correlationId: pending.correlationId,
2814
+ });
2815
+ }
2816
+ else if (pending && comment.author?.toLowerCase() === pending.authorizedAuthor.toLowerCase()) {
2817
+ pending.claimedByCommentId = normalizedCommentId;
2818
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
2819
+ resolved = await this.#routeGithubAnswerToImplementers(watch, pending, comment, reply.text);
2820
+ if (!resolved) {
2821
+ delete pending.claimedByCommentId;
2822
+ }
2823
+ }
2824
+ else if (pending) {
2825
+ this.#increment('githubAnswersIgnoredUnauthorizedAuthor');
2826
+ this.#logger.info?.('[factory] ignored GitHub issue answer from unauthorized author', {
2827
+ issue: watch.issue,
2828
+ commentId: normalizedCommentId,
2829
+ correlationId: pending.correlationId,
2830
+ author: comment.author,
2831
+ authorizedAuthor: pending.authorizedAuthor,
2832
+ });
2833
+ }
2834
+ else if (reply) {
2835
+ this.#increment('githubAnswersIgnoredUnknownCorrelation');
2836
+ this.#logger.info?.('[factory] ignored GitHub issue answer with unknown correlation', {
2837
+ issue: watch.issue,
2838
+ commentId: normalizedCommentId,
2839
+ correlationId: reply.correlationId,
2840
+ });
2841
+ }
2842
+ else if (!reply && comment.body.includes('[factory-reply:')) {
2843
+ this.#increment('githubAnswersIgnoredMissingCorrelationPrefix');
2844
+ }
2845
+ processedCommentIds.add(normalizedCommentId);
2846
+ watch.processedCommentIds = [...processedCommentIds];
2847
+ watch.lastSeenCommentId = String(Math.max(commentId, githubCommentNumericId(watch.lastSeenCommentId)));
2848
+ if ((resolved || discardClaimedPending) && pending) {
2849
+ watch.pending = watch.pending.filter((candidate) => candidate.correlationId !== pending.correlationId);
2850
+ }
2851
+ if (watch.pending.length === 0) {
2852
+ await this.#stopGithubIssueCommentWatcher(watch.source);
2853
+ }
2854
+ else {
2855
+ await this.#state.setGithubIssueCommentWatch(this.#workspaceId, key, watch);
2856
+ }
2857
+ }
2858
+ async #routeGithubAnswerToImplementers(watch, pending, comment, text) {
2859
+ if (pending.kind === 'triage' && pending.decision) {
2860
+ return await this.#handleTriageEscalationGithubAnswer(escalationWatchRecord(pending.decision), text);
2861
+ }
2862
+ const liveRecord = (await this.#batch()).getIssue(watch.issue);
2863
+ if (!liveRecord || liveRecord.dryRun) {
2864
+ this.#increment('githubAnswersIgnoredNoInFlight');
2865
+ return false;
2866
+ }
2867
+ if (!this.#fleet.sendInput)
2868
+ return false;
2869
+ const recipients = [...liveRecord.agents.values()]
2870
+ .filter((agent) => agent.spec.role === 'implementer' || agent.spec.role === 'babysitter')
2871
+ .map((agent) => agent.result?.name ?? agent.spec.name)
2872
+ .filter((name) => Boolean(name));
2873
+ if (recipients.length === 0) {
2874
+ this.#increment('githubAnswersIgnoredNoImplementer');
2875
+ return false;
2876
+ }
2877
+ for (const recipient of new Set(recipients)) {
2878
+ await this.#fleet.sendInput(recipient, githubReplyEvent(liveRecord.issue, text, comment.author));
2879
+ this.#increment('githubAnswersInjected');
2880
+ }
2881
+ return true;
2882
+ }
2883
+ async #handleTriageEscalationGithubAnswer(record, text) {
2884
+ const issue = await this.#readIssue(record.issue.path);
2885
+ if (!issue || !isInFactoryScope(issue, this.#config.safety) || !isDispatchableIssue(issue)) {
2886
+ this.#increment('githubTriageAnswersIgnoredIssueUnavailable');
2887
+ return false;
2888
+ }
2889
+ if (!this.#isIssueReady(issue)) {
2890
+ this.#increment('githubTriageAnswersIgnoredIssueNotReady');
2891
+ return false;
2892
+ }
2893
+ const batch = await this.#batch();
2894
+ if (batch.isInFlight(record.issue) || batch.isQueued(record.issue)) {
2895
+ this.#increment('githubTriageAnswersIgnoredAlreadyActive');
2896
+ return false;
2897
+ }
2898
+ if (await this.#dispatchBlockReason(record.issue)) {
2899
+ this.#increment('githubTriageAnswersIgnoredBlocked');
2900
+ return false;
2901
+ }
2902
+ const clarifiedIssue = issueWithGithubClarification(issue, text);
2903
+ const decision = await this.#triage.triage(clarifiedIssue, {
2904
+ config: this.#config,
2905
+ repoMap: repoMapFromConfig(this.#config),
2906
+ });
2907
+ const escalationReason = triageEscalationReason(decision);
2908
+ if (escalationReason) {
2909
+ if (hasDispatchableRoute(decision)) {
2910
+ this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
2911
+ const result = await this.#startOrQueueGithubClarifiedDecision(dispatchAfterGithubClarification(decision, escalationReason));
2912
+ this.#increment('githubTriageAnswersDispatchedWithRemainingEscalation');
2913
+ return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
2914
+ }
2915
+ this.#increment('githubTriageAnswersStillEscalated');
2916
+ this.#logger.warn?.('[factory] GitHub triage answer still leaves issue escalated', {
2917
+ issue: record.issue,
2918
+ reason: escalationReason,
2919
+ });
2920
+ return false;
2921
+ }
2922
+ this.#pendingGithubClarifications.set(issueKey(decision.issue), text);
2923
+ const result = await this.#startOrQueueGithubClarifiedDecision(decision);
2924
+ this.#increment('githubTriageAnswersDispatched');
2925
+ return Boolean(result) || (await this.#batch()).isQueued(decision.issue);
2926
+ }
2927
+ async #startOrQueueGithubClarifiedDecision(decision) {
2928
+ const batch = await this.#batch();
2929
+ if (batch.canStart()) {
2930
+ return await this.dispatch(decision, { dryRun: this.#config.dryRun });
2931
+ }
2932
+ if (batch.queue(decision, this.#config.dryRun)) {
2933
+ this.#increment('githubTriageAnswersQueued');
2934
+ this.#emit('issue-queued', { issue: decision.issue });
2935
+ }
2936
+ }
2450
2937
  #recordArrivalLatency(event) {
2451
2938
  const occurredAt = Date.parse(event.occurredAt);
2452
2939
  if (!Number.isFinite(occurredAt))
@@ -2990,6 +3477,7 @@ export class FactoryLoop {
2990
3477
  this.#increment(humanReview ? 'humanReview' : 'done');
2991
3478
  this.#emit('issue-done', { issue: record.issue });
2992
3479
  await this.#stopSlackWatcher(record.issue);
3480
+ await this.#stopGithubIssueCommentWatcherForIssue(record.issue);
2993
3481
  await this.#recordDispatchTerminal(record.issue);
2994
3482
  const next = (await this.#batch()).complete(record.issue);
2995
3483
  if (next) {
@@ -3006,6 +3494,10 @@ export class FactoryLoop {
3006
3494
  this.#probePrResolvedCache.delete(completionKey);
3007
3495
  this.#babysitterSpawned.delete(record.issue.key);
3008
3496
  this.#babysitterPr.delete(record.issue.key);
3497
+ for (const publishedKey of this.#publishedPullRequests) {
3498
+ if (publishedKey.startsWith(`${record.issue.key}:`))
3499
+ this.#publishedPullRequests.delete(publishedKey);
3500
+ }
3009
3501
  }
3010
3502
  }
3011
3503
  #emit(event, payload) {
@@ -3018,6 +3510,22 @@ export class FactoryLoop {
3018
3510
  this.#logger.error?.('[factory] error', error);
3019
3511
  this.#emit('error', { error, ...describeError(error), issue });
3020
3512
  }
3513
+ #surfaceEscalationDeliveryFailure(kind, issue, correlationId, reason, cause) {
3514
+ const error = new Error(`${reason} (${correlationId})`);
3515
+ if (cause !== undefined) {
3516
+ Object.assign(error, { cause });
3517
+ }
3518
+ this.#increment('errors');
3519
+ this.#increment('escalationDeliveryFailures');
3520
+ this.#logger.error?.('[factory] escalation delivery failed', {
3521
+ kind,
3522
+ correlationId,
3523
+ issue,
3524
+ reason,
3525
+ cause,
3526
+ });
3527
+ this.#emit('error', { error, ...describeError(error), issue });
3528
+ }
3021
3529
  #increment(name) {
3022
3530
  this.#counters[name] = (this.#counters[name] ?? 0) + 1;
3023
3531
  }
@@ -3241,16 +3749,12 @@ export class FactoryLoop {
3241
3749
  await this.#watchSlackThread(record, root.threadId);
3242
3750
  this.#recordSlackWritebackSuccess('dispatch-thread');
3243
3751
  }
3244
- async #escalateTriageToSlack(decision, reason, dryRun) {
3752
+ async #escalateTriage(decision, reason, dryRun) {
3245
3753
  if (dryRun) {
3246
3754
  return;
3247
3755
  }
3248
3756
  if (!this.#slack || !this.#config.slack) {
3249
- this.#logger.warn?.('[factory] triage escalation has no Slack channel configured', {
3250
- issue: decision.issue,
3251
- reason,
3252
- });
3253
- return;
3757
+ return await this.#escalateTriageToGithub(decision, reason);
3254
3758
  }
3255
3759
  if (await this.#shouldSkipSlackWriteback('triage-escalation')) {
3256
3760
  return;
@@ -3288,6 +3792,42 @@ export class FactoryLoop {
3288
3792
  this.#slackWatcherStarts.delete(key);
3289
3793
  }
3290
3794
  }
3795
+ async #escalateTriageToGithub(decision, reason) {
3796
+ const question = triageEscalationQuestion(decision);
3797
+ const correlationId = githubEscalationCorrelationId('triage', decision.issue, question);
3798
+ const issue = await this.#readIssue(decision.issue.path);
3799
+ const source = issue ? githubIssueSourceRef(issue) : undefined;
3800
+ const authorizedAuthor = issue ? githubIssueAuthor(issue) : undefined;
3801
+ if (!issue || !source || !authorizedAuthor) {
3802
+ this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'no Slack channel or GitHub issue write path with an identifiable issue reporter is available');
3803
+ return;
3804
+ }
3805
+ const pendingAdded = await this.#addGithubIssueCommentWatch(decision.issue, source, {
3806
+ correlationId,
3807
+ kind: 'triage',
3808
+ authorizedAuthor,
3809
+ decision,
3810
+ });
3811
+ if (!pendingAdded) {
3812
+ return;
3813
+ }
3814
+ try {
3815
+ await this.#githubWriteback.postComment(issue, [
3816
+ `Factory needs clarification before dispatching ${decision.issue.key}.`,
3817
+ `Reason: ${reason}`,
3818
+ `Question: ${question}`,
3819
+ `Authorized responder: @${authorizedAuthor} (the issue reporter).`,
3820
+ `Reply with a comment that starts with \`${githubReplyPrefix(correlationId)}\`.`,
3821
+ '',
3822
+ githubEscalationMarker(correlationId),
3823
+ ].join('\n'));
3824
+ this.#increment('triageEscalationsPostedToGithub');
3825
+ }
3826
+ catch (error) {
3827
+ await this.#removeGithubIssueCommentPending(source, correlationId);
3828
+ this.#surfaceEscalationDeliveryFailure('triage', decision.issue, correlationId, 'GitHub issue comment writeback failed', error);
3829
+ }
3830
+ }
3291
3831
  async #postAndWatchSlackEscalationThread(decision, reason) {
3292
3832
  if (!this.#slack || !this.#config.slack) {
3293
3833
  return;
@@ -3629,6 +4169,33 @@ export class FactoryLoop {
3629
4169
  }
3630
4170
  this.#pendingSlackClarifications.delete(key);
3631
4171
  }
4172
+ async #injectPendingGithubClarification(record) {
4173
+ const key = issueKey(record.issue);
4174
+ const text = this.#pendingGithubClarifications.get(key);
4175
+ if (!text || !this.#fleet.sendInput) {
4176
+ return;
4177
+ }
4178
+ const recipients = [...record.agents.values()]
4179
+ .filter((agent) => agent.spec.role === 'implementer' ||
4180
+ agent.spec.role === 'workflow' ||
4181
+ agent.spec.role === 'babysitter')
4182
+ .map((agent) => agent.result?.name ?? agent.spec.name)
4183
+ .filter((name) => Boolean(name));
4184
+ for (const recipient of new Set(recipients)) {
4185
+ try {
4186
+ await this.#fleet.sendInput(recipient, githubReplyEvent(record.issue, text));
4187
+ this.#increment('githubTriageAnswersInjectedToAgents');
4188
+ }
4189
+ catch (error) {
4190
+ this.#logger.warn?.('[factory] failed to inject GitHub triage clarification into agent', {
4191
+ issue: record.issue.key,
4192
+ recipient,
4193
+ error,
4194
+ });
4195
+ }
4196
+ }
4197
+ this.#pendingGithubClarifications.delete(key);
4198
+ }
3632
4199
  // Inject the human's Slack reply into the agent framed as the
3633
4200
  // <integration-event> the spawn prompt tells it to expect (not an ambiguous
3634
4201
  // "Slack reply for ..." keystroke), so the agent recognizes it as the awaited
@@ -3782,6 +4349,7 @@ export class FactoryLoop {
3782
4349
  prNumber: probe.prNumber,
3783
4350
  expectedIssueKey: issue.key,
3784
4351
  requireTitleMarker: false,
4352
+ ...(this.#mount.githubWrite ? { githubWrite: this.#mount.githubWrite } : {}),
3785
4353
  });
3786
4354
  this.#increment('mergeGateSyntheticClosed');
3787
4355
  }
@@ -3906,6 +4474,7 @@ export function parseGithubIssue(path, content) {
3906
4474
  labels: Array.isArray(payload.labels)
3907
4475
  ? payload.labels.map(labelName).filter((label) => Boolean(label))
3908
4476
  : [],
4477
+ author: githubAuthorLogin(payload),
3909
4478
  path,
3910
4479
  raw: asRecord(parsed) ?? payload,
3911
4480
  };
@@ -3947,6 +4516,7 @@ const githubIssueAsFactoryIssue = (issue) => {
3947
4516
  number: issue.number,
3948
4517
  url: issue.url,
3949
4518
  path: issue.path,
4519
+ ...(issue.author ? { author: issue.author, reporter: issue.author } : {}),
3950
4520
  },
3951
4521
  },
3952
4522
  },
@@ -4005,6 +4575,28 @@ const isGithubIssue = (issue) => {
4005
4575
  return stringValue(source?.provider)?.toLowerCase() === 'github' &&
4006
4576
  (stringValue(wrapper?.provider)?.toLowerCase() === 'github' || isGithubIssueFilePath(issue.path));
4007
4577
  };
4578
+ const githubIssueSourceRef = (issue) => {
4579
+ const source = asRecord(wrappedPayload(issue.raw).source);
4580
+ if (stringValue(source?.provider)?.toLowerCase() !== 'github') {
4581
+ return undefined;
4582
+ }
4583
+ const owner = stringValue(source?.owner)?.trim();
4584
+ const repo = stringValue(source?.repo)?.trim();
4585
+ const number = numberValue(source?.number);
4586
+ const url = stringValue(source?.url)?.trim();
4587
+ if (!owner || !repo || !Number.isInteger(number) || (number ?? 0) <= 0 || !url) {
4588
+ return undefined;
4589
+ }
4590
+ return { owner, repo, number: number, url };
4591
+ };
4592
+ const githubIssueAuthor = (issue) => {
4593
+ const payload = wrappedPayload(issue.raw);
4594
+ const source = asRecord(payload.source);
4595
+ if (stringValue(source?.provider)?.toLowerCase() === 'github') {
4596
+ return githubAuthorLogin(source ?? {})?.trim() || undefined;
4597
+ }
4598
+ return source ? undefined : githubAuthorLogin(payload)?.trim() || undefined;
4599
+ };
4008
4600
  const issueRef = (issue) => ({ uuid: issue.uuid, key: issue.key, path: issue.path });
4009
4601
  const pidsFromSpawnResult = (result) => {
4010
4602
  const pids = new Set();
@@ -4378,6 +4970,73 @@ export const githubIssuePathParts = (path) => {
4378
4970
  slug: match[7],
4379
4971
  };
4380
4972
  };
4973
+ const githubIssueCommentPathParts = (path) => {
4974
+ const match = path.match(/^\/github\/repos\/(?:([^/]+)\/([^/]+)|([A-Za-z0-9-]+)__([^/]+))\/issues\/(\d+)(?:__[^/]*)?\/comments\/([^/]+?)(?:\.json|\/(?:meta|metadata)\.json)$/u);
4975
+ const owner = match?.[1] ?? match?.[3];
4976
+ const repo = match?.[2] ?? match?.[4];
4977
+ const number = Number(match?.[5]);
4978
+ const commentId = match?.[6];
4979
+ if (!owner || !repo || !Number.isInteger(number) || number <= 0 || !commentId) {
4980
+ return undefined;
4981
+ }
4982
+ return { owner, repo, number, commentId };
4983
+ };
4984
+ const parseGithubIssueComment = (path, content) => {
4985
+ const parts = githubIssueCommentPathParts(path);
4986
+ if (!parts) {
4987
+ return undefined;
4988
+ }
4989
+ const raw = asRecord(parseJsonContent(content)) ?? {};
4990
+ const payload = wrappedPayload(raw);
4991
+ const comment = asRecord(payload.comment) ?? payload;
4992
+ const author = asRecord(comment.author) ?? asRecord(comment.user);
4993
+ const authorLogin = stringValue(author?.login);
4994
+ const authorType = stringValue(author?.type)?.toLowerCase();
4995
+ return {
4996
+ owner: parts.owner,
4997
+ repo: parts.repo,
4998
+ issueNumber: parts.number,
4999
+ commentId: String(numberValue(comment.id) ?? parts.commentId),
5000
+ body: stringValue(comment.body) ?? '',
5001
+ author: authorLogin,
5002
+ isBot: authorType === 'bot' || Boolean(authorLogin?.toLowerCase().endsWith('[bot]')),
5003
+ raw,
5004
+ };
5005
+ };
5006
+ const githubIssueSourceKey = (source) => `${source.owner.toLowerCase()}/${source.repo.toLowerCase()}#${source.number}`;
5007
+ const githubIssueCommentGlobs = (source) => {
5008
+ const owner = encodeURIComponent(source.owner);
5009
+ const repo = encodeURIComponent(source.repo);
5010
+ const issueSegment = `${source.number}*`;
5011
+ return [
5012
+ `${GITHUB_ISSUE_ROOT}/${owner}/${repo}/issues/${issueSegment}/comments/**`,
5013
+ `${GITHUB_ISSUE_ROOT}/${owner}__${repo}/issues/${issueSegment}/comments/**`,
5014
+ ];
5015
+ };
5016
+ const githubEscalationCorrelationId = (kind, issue, text) => `factory-${kind}-${stableHash(`${issue.uuid}:${issue.path}:${text}`)}`;
5017
+ const githubEscalationMarker = (correlationId) => `<!-- ${GITHUB_ESCALATION_MARKER_PREFIX}${correlationId} -->`;
5018
+ const githubReplyPrefix = (correlationId) => `[factory-reply:${correlationId}]`;
5019
+ const githubCorrelatedReply = (body) => {
5020
+ if (!body.startsWith('[factory-reply:'))
5021
+ return undefined;
5022
+ const markerEnd = body.indexOf(']');
5023
+ if (markerEnd < 0)
5024
+ return undefined;
5025
+ const correlationId = body.slice('[factory-reply:'.length, markerEnd);
5026
+ if (!/^factory-(?:triage|agent-question)-[a-z0-9]+$/u.test(correlationId))
5027
+ return undefined;
5028
+ const text = body.slice(markerEnd + 1).replace(/^\s*:?\s*/u, '').trim();
5029
+ return text ? { correlationId, text } : undefined;
5030
+ };
5031
+ const githubCommentNumericId = (value) => {
5032
+ const parsed = Number(value);
5033
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
5034
+ };
5035
+ const normalizeGithubIssueCommentWatch = (watch) => ({
5036
+ ...watch,
5037
+ sinceCommentId: watch.sinceCommentId ?? watch.lastSeenCommentId,
5038
+ processedCommentIds: [...new Set(watch.processedCommentIds ?? [])],
5039
+ });
4381
5040
  const githubIssueReadCandidatePaths = (path) => {
4382
5041
  if (path.endsWith('/')) {
4383
5042
  return [`${path}meta.json`, `${path}metadata.json`];
@@ -4419,6 +5078,7 @@ const githubIssueHasFactoryLabel = (issue, requiredLabel = GITHUB_FACTORY_LABEL)
4419
5078
  const githubIssueIsClosed = (issue) => issue.state === 'closed';
4420
5079
  const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId) => {
4421
5080
  const teamId = config.linear.teamIds[config.safety.requireTeamKey];
5081
+ const reporter = issue.author;
4422
5082
  return {
4423
5083
  id: githubIssueMirrorId(issue),
4424
5084
  title: `${GITHUB_MIRROR_TITLE_PREFIX} ${issue.title}`.trim(),
@@ -4434,6 +5094,7 @@ const githubIssueMirrorPayload = (issue, repoLabel, config, readyForAgentStateId
4434
5094
  number: issue.number,
4435
5095
  url: issue.url,
4436
5096
  path: issue.path,
5097
+ ...(reporter ? { author: reporter, reporter } : {}),
4437
5098
  },
4438
5099
  };
4439
5100
  };
@@ -4708,6 +5369,15 @@ const uuidFromPath = (path) => path.split('__')[1]?.replace(/\.json$/, '');
4708
5369
  const stringValue = (value) => typeof value === 'string' ? value : undefined;
4709
5370
  const booleanValue = (value) => typeof value === 'boolean' ? value : undefined;
4710
5371
  const numberValue = (value) => typeof value === 'number' ? value : undefined;
5372
+ const githubAuthorLogin = (payload) => {
5373
+ for (const value of [payload.author, payload.user, payload.reporter]) {
5374
+ const login = (stringValue(value) ??
5375
+ stringValue(asRecord(value)?.login))?.trim();
5376
+ if (login)
5377
+ return login;
5378
+ }
5379
+ return undefined;
5380
+ };
4711
5381
  const liveHeartbeatIntervalMs = (staleMs) => Math.min(DEFAULT_LIVE_HEARTBEAT_INTERVAL_MS, Math.max(500, Math.floor(staleMs / 4)));
4712
5382
  const installFactoryDraftPredicate = (mount, config) => {
4713
5383
  mount.setDefaultAllowedDraftPredicate?.((path, content, opts) => isAllowedFactoryDraft(path, content, opts, mount, config));
@@ -4729,8 +5399,12 @@ const isAllowedFactoryDraft = async (path, content, opts, mount, config) => {
4729
5399
  if (/^\/slack\/channels\/[^/]+\/messages\/.+/u.test(path)) {
4730
5400
  return true;
4731
5401
  }
5402
+ if (isFactoryGithubWritebackPath(path)) {
5403
+ return true;
5404
+ }
4732
5405
  return false;
4733
5406
  };
5407
+ const isFactoryGithubWritebackPath = (path) => /^\/github\/repos\/[^/]+\/[^/]+\/(?:pull-requests\/factory-[^/]+\.json|refs\/refs%2Fheads%2F[^/]+\.json|pulls\/[1-9]\d*\/close\.json)$/iu.test(path);
4734
5408
  const isIssuePathInFactoryScope = async (mount, path, config) => {
4735
5409
  try {
4736
5410
  return isInFactoryScope(parseLinearIssue(path, (await mount.readFile(path)).content), config.safety);
@@ -4888,6 +5562,22 @@ const labelName = (value) => {
4888
5562
  return stringValue(record?.name);
4889
5563
  };
4890
5564
  const isCompletionReason = (reason) => reason === 'issue-done' || reason === 'done' || reason === 'completed';
5565
+ const normalizeGithubRepo = (repo, defaultOwner) => {
5566
+ if (repo.includes('/'))
5567
+ return repo;
5568
+ const owner = defaultOwner?.trim();
5569
+ if (!owner) {
5570
+ throw new Error(`GitHub repository owner is required for bare repo route: ${repo}; set repos.org or use owner/repo`);
5571
+ }
5572
+ return `${owner}/${repo}`;
5573
+ };
5574
+ const githubPullRequestBody = (issue) => [
5575
+ issue.description,
5576
+ '',
5577
+ isGithubIssue(issue) && /^\d+$/u.test(issue.key)
5578
+ ? `Fixes #${issue.key}`
5579
+ : `Factory issue ${issue.key}`,
5580
+ ].join('\n').trim();
4891
5581
  // The broker rejects re-registering a name it never released on exit
4892
5582
  // (relay#1116-family) with a 500 "agent '<name>' already exists". Detect it from
4893
5583
  // the structured payload or the message so resume can treat it as terminal
@@ -5070,6 +5760,15 @@ const dispatchAfterSlackClarification = (decision, escalationReason) => ({
5070
5760
  `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.`,
5071
5761
  ].filter(Boolean).join(' '),
5072
5762
  });
5763
+ const dispatchAfterGithubClarification = (decision, escalationReason) => ({
5764
+ ...decision,
5765
+ thin: false,
5766
+ confidence: 'high',
5767
+ rationale: [
5768
+ decision.rationale,
5769
+ `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.`,
5770
+ ].filter(Boolean).join(' '),
5771
+ });
5073
5772
  const issueWithSlackClarification = (issue, text) => ({
5074
5773
  ...issue,
5075
5774
  description: [
@@ -5079,11 +5778,21 @@ const issueWithSlackClarification = (issue, text) => ({
5079
5778
  text,
5080
5779
  ].join('\n'),
5081
5780
  });
5781
+ const issueWithGithubClarification = (issue, text) => ({
5782
+ ...issue,
5783
+ description: [
5784
+ issue.description,
5785
+ '',
5786
+ 'Human clarification from GitHub:',
5787
+ text,
5788
+ ].join('\n'),
5789
+ });
5082
5790
  const slackAnswerInput = (issue, text) => `Slack reply for ${issue.key}:\n${text}\r`;
5083
5791
  // The human's Slack-thread reply, framed as an <integration-event> the agent is
5084
5792
  // told (at spawn) to expect — a recognizable injected event, not an ambiguous
5085
5793
  // keystroke. Trailing CR submits it to the agent's PTY.
5086
5794
  const slackReplyEvent = (issue, text) => `<integration-event source="slack" issue="${issue.key}">\nHuman reply in the Slack thread:\n${text}\n</integration-event>\r`;
5795
+ 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`;
5087
5796
  const isFactoryQuestionTarget = (target) => {
5088
5797
  const normalized = target.trim().replace(/^@/u, '').toLowerCase();
5089
5798
  return normalized === 'broker' || normalized === 'factory';