@open-everywhere/provider-github 0.0.0-stage → 0.1.0

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 (111) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +21 -0
  3. package/README.md +50 -2
  4. package/agent/channels/github-catch-up.ts +134 -0
  5. package/agent/channels/github-engagement.ts +381 -0
  6. package/agent/channels/github-identity.ts +376 -0
  7. package/agent/channels/github-installations.ts +152 -0
  8. package/agent/channels/github-message-ops.ts +62 -0
  9. package/agent/channels/github-origin-codec.ts +42 -0
  10. package/agent/channels/github-post-arm.ts +422 -0
  11. package/agent/channels/github-reaction-feedback.ts +489 -0
  12. package/agent/channels/github-reaction-scope.ts +312 -0
  13. package/agent/channels/github-read.ts +471 -0
  14. package/agent/channels/github-stop.ts +175 -0
  15. package/agent/channels/github-supervisor.ts +645 -0
  16. package/agent/channels/github-webhook-verifier.ts +56 -0
  17. package/agent/channels/github.ts +1458 -0
  18. package/agent/contributions.ts +31 -0
  19. package/agent/docs-generated.ts +15 -0
  20. package/agent/memory/memory.ts +265 -0
  21. package/agent/ops.ts +17 -0
  22. package/agent/provider-events.ts +114 -0
  23. package/agent/register.ts +130 -0
  24. package/agent/surface-parity.ts +116 -0
  25. package/dist/agent/channels/github-catch-up.d.ts +29 -0
  26. package/dist/agent/channels/github-catch-up.d.ts.map +1 -0
  27. package/dist/agent/channels/github-catch-up.js +105 -0
  28. package/dist/agent/channels/github-catch-up.js.map +1 -0
  29. package/dist/agent/channels/github-engagement.d.ts +223 -0
  30. package/dist/agent/channels/github-engagement.d.ts.map +1 -0
  31. package/dist/agent/channels/github-engagement.js +317 -0
  32. package/dist/agent/channels/github-engagement.js.map +1 -0
  33. package/dist/agent/channels/github-identity.d.ts +164 -0
  34. package/dist/agent/channels/github-identity.d.ts.map +1 -0
  35. package/dist/agent/channels/github-identity.js +295 -0
  36. package/dist/agent/channels/github-identity.js.map +1 -0
  37. package/dist/agent/channels/github-installations.d.ts +49 -0
  38. package/dist/agent/channels/github-installations.d.ts.map +1 -0
  39. package/dist/agent/channels/github-installations.js +114 -0
  40. package/dist/agent/channels/github-installations.js.map +1 -0
  41. package/dist/agent/channels/github-message-ops.d.ts +3 -0
  42. package/dist/agent/channels/github-message-ops.d.ts.map +1 -0
  43. package/dist/agent/channels/github-message-ops.js +58 -0
  44. package/dist/agent/channels/github-message-ops.js.map +1 -0
  45. package/dist/agent/channels/github-origin-codec.d.ts +7 -0
  46. package/dist/agent/channels/github-origin-codec.d.ts.map +1 -0
  47. package/dist/agent/channels/github-origin-codec.js +36 -0
  48. package/dist/agent/channels/github-origin-codec.js.map +1 -0
  49. package/dist/agent/channels/github-post-arm.d.ts +54 -0
  50. package/dist/agent/channels/github-post-arm.d.ts.map +1 -0
  51. package/dist/agent/channels/github-post-arm.js +339 -0
  52. package/dist/agent/channels/github-post-arm.js.map +1 -0
  53. package/dist/agent/channels/github-reaction-feedback.d.ts +97 -0
  54. package/dist/agent/channels/github-reaction-feedback.d.ts.map +1 -0
  55. package/dist/agent/channels/github-reaction-feedback.js +380 -0
  56. package/dist/agent/channels/github-reaction-feedback.js.map +1 -0
  57. package/dist/agent/channels/github-reaction-scope.d.ts +102 -0
  58. package/dist/agent/channels/github-reaction-scope.d.ts.map +1 -0
  59. package/dist/agent/channels/github-reaction-scope.js +296 -0
  60. package/dist/agent/channels/github-reaction-scope.js.map +1 -0
  61. package/dist/agent/channels/github-read.d.ts +135 -0
  62. package/dist/agent/channels/github-read.d.ts.map +1 -0
  63. package/dist/agent/channels/github-read.js +371 -0
  64. package/dist/agent/channels/github-read.js.map +1 -0
  65. package/dist/agent/channels/github-stop.d.ts +52 -0
  66. package/dist/agent/channels/github-stop.d.ts.map +1 -0
  67. package/dist/agent/channels/github-stop.js +145 -0
  68. package/dist/agent/channels/github-stop.js.map +1 -0
  69. package/dist/agent/channels/github-supervisor.d.ts +308 -0
  70. package/dist/agent/channels/github-supervisor.d.ts.map +1 -0
  71. package/dist/agent/channels/github-supervisor.js +339 -0
  72. package/dist/agent/channels/github-supervisor.js.map +1 -0
  73. package/dist/agent/channels/github-webhook-verifier.d.ts +5 -0
  74. package/dist/agent/channels/github-webhook-verifier.d.ts.map +1 -0
  75. package/dist/agent/channels/github-webhook-verifier.js +55 -0
  76. package/dist/agent/channels/github-webhook-verifier.js.map +1 -0
  77. package/dist/agent/channels/github.d.ts +139 -0
  78. package/dist/agent/channels/github.d.ts.map +1 -0
  79. package/dist/agent/channels/github.js +1232 -0
  80. package/dist/agent/channels/github.js.map +1 -0
  81. package/dist/agent/contributions.d.ts +12 -0
  82. package/dist/agent/contributions.d.ts.map +1 -0
  83. package/dist/agent/contributions.js +31 -0
  84. package/dist/agent/contributions.js.map +1 -0
  85. package/dist/agent/docs-generated.d.ts +3 -0
  86. package/dist/agent/docs-generated.d.ts.map +1 -0
  87. package/dist/agent/docs-generated.js +13 -0
  88. package/dist/agent/docs-generated.js.map +1 -0
  89. package/dist/agent/memory/memory.d.ts +54 -0
  90. package/dist/agent/memory/memory.d.ts.map +1 -0
  91. package/dist/agent/memory/memory.js +224 -0
  92. package/dist/agent/memory/memory.js.map +1 -0
  93. package/dist/agent/ops.d.ts +3 -0
  94. package/dist/agent/ops.d.ts.map +1 -0
  95. package/dist/agent/ops.js +16 -0
  96. package/dist/agent/ops.js.map +1 -0
  97. package/dist/agent/provider-events.d.ts +3 -0
  98. package/dist/agent/provider-events.d.ts.map +1 -0
  99. package/dist/agent/provider-events.js +112 -0
  100. package/dist/agent/provider-events.js.map +1 -0
  101. package/dist/agent/register.d.ts +4 -0
  102. package/dist/agent/register.d.ts.map +1 -0
  103. package/dist/agent/register.js +116 -0
  104. package/dist/agent/register.js.map +1 -0
  105. package/dist/agent/surface-parity.d.ts +3 -0
  106. package/dist/agent/surface-parity.d.ts.map +1 -0
  107. package/dist/agent/surface-parity.js +110 -0
  108. package/dist/agent/surface-parity.js.map +1 -0
  109. package/package.json +156 -4
  110. package/tsconfig.build.json +22 -0
  111. package/tsconfig.json +15 -0
@@ -0,0 +1,1458 @@
1
+ // GitHub — a PROPER conversational surface (not just an event pipe). This adopts eve's
2
+ // native `githubChannel`: an @mention of the bot in an issue, PR, or review comment wakes
3
+ // an agent session, with the repo checked out into the sandbox and the PR diff already in
4
+ // context; the reply comes back as a comment (plus an `eyes` reaction on the triggering
5
+ // comment). It takes GitHub App webhooks at POST /eve/v1/github, verifies the signature,
6
+ // and binds REAL GitHub identity (owner=workspace, repo=surface, issue/PR=anchor) via the
7
+ // onComment hook (agent/channels/github-identity.ts → bindGitHub in identity/binding.ts),
8
+ // so memory + access + routines key off the actual repo/actor/visibility.
9
+ //
10
+ // THREE legs share the one POST /eve/v1/github route:
11
+ // 1. The NATIVE @mention leg (eve's githubChannel — session, checkout, PR diff, reply).
12
+ // 2. The event→Slack routine FAN-OUT (PR opened, CI failed, …) — the pre-native behaviour,
13
+ // preserved via route-handler composition (see the wrapper note below).
14
+ // 3. The AMBIENT-SUPERVISOR leg (NEW): every NON-mention issue/PR-timeline comment on an
15
+ // opted-in (ambientSupervisor domain flag), positively-PRIVATE repo is judged by a
16
+ // standing per-repo supervisor session under a reserved `github:<owner>/<repo>:oe-sup:g<n>`
17
+ // token — the GitHub adopter of the surface-general ambient pattern (github-supervisor.ts
18
+ // is the tested core; this file is the thin wiring, mirroring notion.ts). The classifier
19
+ // DROPS mentions (leg 1 owns them — the legs partition every comment) and fails closed on
20
+ // public/unknown-privacy repos, an unset slug, and bot/self authors.
21
+ //
22
+ // ── Preserving the Phase-3 event→Slack fan-out (the reconciliation) ──────────────────
23
+ // Before this, github.ts was a CUSTOM defineChannel route that only fanned GitHub events
24
+ // (PR opened, CI failed, issue labelled, …) out to Slack as `event`-routine dispatches —
25
+ // the agent didn't LIVE on GitHub. The native channel wants the SAME path
26
+ // (POST /eve/v1/github), so we RECONCILE rather than drop one.
27
+ //
28
+ // WHY a route WRAPPER and not the native onIssue/onPullRequest/onCheckSuite hooks (the
29
+ // obvious "move the fan-out into the hooks" shape): those inbound hooks are handed only
30
+ // { conversation, delivery, github, repository, sender, thread } — they have NO
31
+ // cross-channel `receive`, so they physically cannot start a Slack session (verified
32
+ // against eve's dispatch internals: buildInboundContext passes no receive/send to the
33
+ // handler). The cross-channel `to` primitive only exists on the ROUTE handler's args. So we keep
34
+ // the native channel intact and wrap its single POST route: on each delivery we run the fan-out
35
+ // FIRST (we have `args.to` here → Slack), then delegate the untouched request to the
36
+ // native handler for the @mention/session/checkout behaviour. Both legs run on the one
37
+ // route. The channel's adapter (kindHint "github", events, context, state) and its
38
+ // `receive` (proactive github sessions) are preserved by spreading the native object and
39
+ // swapping only the route handler — eve still treats this as the github channel, so
40
+ // `send` inside the native handler dispatches a real github turn.
41
+ //
42
+ // ── Supervisor-aware events (the eve #623 axis, used SAFELY) ──────────────────────────
43
+ // eve installs default handlers for turn.started (eyes reaction + repo checkout),
44
+ // message.completed (posts the reply), and turn.failed/session.failed (error comment); an
45
+ // authored `events` entry REPLACES the default for that key (#623's destructive axis). We
46
+ // author exactly those four keys, but each handler forks on the SESSION KIND and delegates
47
+ // every NON-supervisor turn to the default via a PRISTINE second githubChannel instance's
48
+ // compiled adapter (same config ⇒ byte-identical defaults) — so the mention path keeps its
49
+ // checkout/reply/error behaviour untouched. Supervisor turns take the OE arm instead:
50
+ // • turn.started — NO eyes reaction (an ambient judge silently reading every comment must
51
+ // not telegraph 👀 on each one), NO checkout (a judge turn doesn't need the working tree);
52
+ // snapshot the per-turn reply target from the delivery's auth attributes (eve rewrites
53
+ // session.auth.current per delivery — the same T1.4 pattern slack.ts/notion.ts use,
54
+ // because a resumed session's send-state seed is NOT reapplied).
55
+ // • message.completed — finalMessageActionForTurn (NO_REPLY sentinel + audited no_reply
56
+ // TOOL both suppress); judged silence writes a github.no_reply audit row (parity with
57
+ // Slack/Notion); a real reply posts host-side to the SNAPSHOT target via
58
+ // channel.github.request (installation-token REST), wrapped in postOnce (crash-resume
59
+ // idempotency, mirroring slack.ts's supervisor-reply). FAIL SAFE: no target ⇒ post
60
+ // NOTHING, never into a guessed issue.
61
+ // • turn.failed / session.failed — audit + log, NO error comment (a failed ambient judge
62
+ // turn must not spam the repo; the reserved posture).
63
+ //
64
+ // The fan-out core (normalize/match/prompt/gate) is the tested logic in
65
+ // agent/routines/events.ts, unchanged. The supervisor core (classify/extract/wake/rotate)
66
+ // is the tested logic in agent/channels/github-supervisor.ts. This route is
67
+ // operator-validated live; the pure cores are unit-tested.
68
+ //
69
+ // 4. The INSTALLATION leg (installationDelivery below): the App's `installation` /
70
+ // `installation_repositories` events register (or revoke) the owner→installation_id entry the
71
+ // SCM plane mints tokens with, so a new org installing the App is self-service end-to-end
72
+ // instead of needing an OE_GITHUB_INSTALLATIONS env edit + redeploy. The env map stays
73
+ // AUTHORITATIVE; discovered installs only augment it (scm/installations.ts).
74
+ //
75
+ // Point the GitHub App webhook at POST /eve/v1/github (content-type application/json),
76
+ // secret = GITHUB_WEBHOOK_SECRET (REQUIRED — unset rejects every delivery 401, fail-closed;
77
+ // the `webhooks.allowUnverifiedGithub` setting is the dev-only fan-out escape hatch). Subscribe to
78
+ // issue_comment + pull_request_review_comment for @mention turns (issue_comment also feeds
79
+ // the ambient leg), issues / pull_request / check_suite / check_run / workflow_run for
80
+ // the routine fan-out, and installation + installation_repositories for the installation leg.
81
+ //
82
+ // Live e2e: scripts/e2e/scenarios.ts J-GH (capability "github" — self-skips without creds). It
83
+ // posts a real @mention (checkout + reply), verifies the mention gate drops non-mentions, audited
84
+ // silence (github.no_reply over GET /control/v1/audit), and reply-targeting across two issues on
85
+ // one repo supervisor session. Still needs a live opted-in repo + App install + Tommy for
86
+ // provisioning.
87
+
88
+ import type { HttpRouteDefinition, RouteHandlerArgs } from "eve/channels";
89
+ import { githubChannel, type GitHubChannel, type GitHubChannelState, type GitHubInboundContext, type GitHubComment, type GitHubInboundResult } from "eve/channels/github";
90
+ import { buildGitHubAuth, githubAuthorFacts, githubBotMentioned } from "./github-identity.js";
91
+ import {
92
+ githubEngagedAdmits,
93
+ githubEngagedFollowUpReach,
94
+ githubMachineAuthored,
95
+ githubWakeAddressing,
96
+ markGitHubEngaged,
97
+ resolveGitHubEngagement,
98
+ type GitHubEngagementVerdict,
99
+ type GitHubSeamFacts,
100
+ } from "./github-engagement.js";
101
+ import { parseGitHubStopComment, routeGitHubStopComment } from "./github-stop.js";
102
+ import { liveGitHubReactionFeedbackDeps, parseGitHubReactionPollTarget, routeGitHubReactionFeedback } from "./github-reaction-feedback.js";
103
+ import {
104
+ classifyGitHubAmbientComment,
105
+ extractGitHubAmbientEvent,
106
+ githubDispatchAuditDetail,
107
+ githubSupervisorToken,
108
+ handleGitHubSupervisorDispatch,
109
+ shouldRotate,
110
+ type GitHubAmbientEvent,
111
+ type GitHubSupervisorDeps,
112
+ } from "./github-supervisor.js";
113
+ // Everything core rides the ONE adapter-api barrel (boundary rule 4). The event→Slack routine
114
+ // fan-out (fanOutGitHubDelivery) + the webhook signature gate (gateGithubDelivery) are core-owned
115
+ // routines-plane helpers this bundle calls; the fan-out's slack-channel/routines internals never
116
+ // cross the barrel (see routines/github-fanout.ts).
117
+ import { extractGitHubInstallationEvent, handleGitHubInstallationDelivery } from "./github-installations.js";
118
+ import {
119
+ recordGitHubInstallation,
120
+ forgetGitHubInstallation,
121
+ supervisorGrainFor,
122
+ mintRotationToken,
123
+ claimRuntimeRotation,
124
+ stampMintedRuntime,
125
+ grainUsesUnifiedWindow,
126
+ mintWakeId,
127
+ vendorInstantEventTime,
128
+ spreadVendorTs,
129
+ singleWindowMentionPolicy,
130
+ isVouchedInternal,
131
+ divertInboundToUnifiedWindow,
132
+ finalMessageActionForTurn,
133
+ completeSupervisorTurn,
134
+ consumeSupervisorReplyMark,
135
+ dropClosingProse,
136
+ resolvePartition,
137
+ type ChatAuditPartition,
138
+ auditSupervisorFailure,
139
+ healPrunedDeploymentStrand,
140
+ capabilitiesForPlatform,
141
+ appendAudit,
142
+ ingressSpendGate,
143
+ auditAmbientSkip,
144
+ deploymentOrg,
145
+ resolveWakeTrustFromContext,
146
+ getConfig,
147
+ ambientSupervisorEnabled,
148
+ engagementTierFor,
149
+ getOperationalStore,
150
+ postOnce,
151
+ gateGithubDelivery,
152
+ getSettings,
153
+ fanOutGitHubDelivery,
154
+ resolveExternalPolicy,
155
+ externalPolicyOverrideFor,
156
+ roomIsSilenced,
157
+ withholdsBody,
158
+ EXTERNAL_MENTION_ACK,
159
+ owesSuppressedMentionAck,
160
+ suppressionRendering,
161
+ stampNonMemberChatAuth,
162
+ type IngestedChatMessage,
163
+ type SupervisorGrain,
164
+ type UnifiedWindowWake,
165
+ type EngagementTier,
166
+ type ExternalPolicy,
167
+ carryTailForward,
168
+ type WakeMessageInput,
169
+ recordWakeTail,
170
+ seatCatchUp,
171
+ seatAdmissionFor,
172
+ ENGAGED_FOLLOW_UP_CONTEXT,
173
+ OE_ADDRESSED_ATTR,
174
+ dispatchNativeWakeWithRetry,
175
+ } from "@open-everywhere/core/agent/adapter-api.js";
176
+
177
+ const ROUTE = "/eve/v1/github";
178
+ const BOT_NAME = process.env.GITHUB_APP_SLUG;
179
+
180
+ function log(msg: string): void {
181
+ console.log(`[open-everywhere:routines] ${msg}`);
182
+ }
183
+
184
+ function slog(msg: string): void {
185
+ console.log(`[open-everywhere:github-supervisor] ${msg}`);
186
+ }
187
+
188
+ function ilog(msg: string): void {
189
+ console.log(`[open-everywhere:github-installations] ${msg}`);
190
+ }
191
+
192
+ function str(v: unknown): string | undefined {
193
+ return typeof v === "string" && v.length > 0 ? v : undefined;
194
+ }
195
+
196
+ /** A raw webhook field read as a POSITIVE integer id, else undefined (OPE-958). GitHub sends
197
+ * `in_reply_to_id` as a JSON number and `null` on a thread-opening comment, so `null`, `0` and a
198
+ * non-number all have to answer the same "no id here". */
199
+ function num(v: unknown): number | undefined {
200
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined;
201
+ }
202
+
203
+ // The event→Slack routine FAN-OUT (fanOut/fanOutDelivery) moved to core's routines/github-fanout.ts
204
+ // (fanOutGitHubDelivery) in OPE-114 stage 4 — it is a routines-subsystem bridge, not the chat
205
+ // surface, and owns the slack-channel + routines internals a bundle must not import. wrapRoute
206
+ // below calls the barrel-exported fanOutGitHubDelivery as its pre-native pre-step.
207
+
208
+ // --- the ambient-supervisor leg -------------------------------------------------------
209
+
210
+ /** The supervisor session's durable state: a valid GitHubChannelState (so eve's context
211
+ * factory rebuilds cleanly) + the OE marker the event forks key on + the per-TURN reply
212
+ * target (turn.started snapshot — the send-state seed is creation-only, see the header).
213
+ * `oeUnified` marks a session CREATED as the UNIFIED (org, domain) session (grain fixed for
214
+ * the session's lifetime); on that path the reply target ALSO carries the per-wake owner/repo
215
+ * so a multi-repo domain session answers into the repo that woke it, never the seed repo. */
216
+ type GitHubSupervisorSessionState = GitHubChannelState & {
217
+ oeSupervisor?: boolean;
218
+ oeUnified?: boolean;
219
+ oeReplyTarget?: { issueNumber: number; owner?: string; repo?: string };
220
+ };
221
+
222
+ function isSupervisorGitHubState(state: unknown): state is GitHubSupervisorSessionState {
223
+ return (state as Record<string, unknown> | undefined)?.["oeSupervisor"] === true;
224
+ }
225
+
226
+ /** Seed state for a supervisor session: repo-scoped, no conversation anchor (the per-turn
227
+ * snapshot supplies the reply target), every checkout field null (supervisor turns never
228
+ * check out). Satisfies GitHubChannelState so rebuildGitHubContext / the github handle work.
229
+ * `unified` stamps `oeUnified` (a session's grain is fixed for its lifetime — eve applies the
230
+ * seed at creation only); absent on the per-repo path so flag-off state is byte-identical. */
231
+ function supervisorSeedState(ev: GitHubAmbientEvent, unified: boolean): GitHubSupervisorSessionState {
232
+ return {
233
+ ...(unified ? { oeUnified: true } : {}),
234
+ baseRef: null,
235
+ baseSha: null,
236
+ checkoutPath: null,
237
+ conversationKind: "issue",
238
+ defaultBranch: null,
239
+ headRef: null,
240
+ headSha: null,
241
+ installationId: ev.installationId ?? null,
242
+ issueNumber: null,
243
+ owner: ev.owner,
244
+ pullRequestNumber: null,
245
+ repo: ev.repo,
246
+ repositoryId: ev.repositoryId ?? 0,
247
+ reviewCommentId: null,
248
+ reviewThreadRootCommentId: null,
249
+ triggeringCommentId: null,
250
+ triggeringUserLogin: null,
251
+ oeSupervisor: true,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Does the AMBIENT leg keep this comment — the second half of the two-leg partition (OPE-1695)?
257
+ * 🔴 THE CHATTINESS FLAG IS BELOW THE CLASSIFIER NOW, AND THAT IS WHAT KEEPS THE LEGS COMPLEMENTARY.
258
+ * It used to gate the whole leg, which was safe only while a seat REQUIRED `ambientSupervisor`: the
259
+ * native leg stood down (`shouldSuppressNativeMention`) exactly when this leg was live. Seats are
260
+ * universal since OPE-1695, so with the flag gating the whole leg a VOUCHED repo whose domain is
261
+ * addressed-only suppressed the native session AND returned here — and the @mention reached NOBODY.
262
+ * An ADDRESSED comment is an address, and addressing never depended on the flag; only ambient talk
263
+ * does, and a refused ambient comment still records its skip row exactly as before.
264
+ *
265
+ * 🔴 AN ENGAGED CONTAINER'S PLAIN FOLLOW-UP IS AN ADDRESS TOO (OPE-1740/OPE-1745), AND THAT IS THE
266
+ * THIRD INPUT. Moving the flag below the classifier fixed the mention and left the follow-up behind:
267
+ * on the SAME seated, vouched, addressed-only repo the native leg still stands down for every
268
+ * comment (`shouldSuppressNativeMention` asks `singleWindowMentionPolicy` with `isMention` pinned
269
+ * true), so the engaged follow-up came here — and a `thread-follow` addressing with the flag off was
270
+ * refused `domain-refused` before the seam was ever read. Measured on deploy #201: the plain
271
+ * follow-up in an ENGAGED issue of `oe-e2e-addressed-github` produced
272
+ * `github.ambient_skipped {reason: "domain-refused"}` three seconds after the mention's reply, and
273
+ * NO leg answered it. An @mention ENROLLED the container, so the enrolment is what addresses the
274
+ * comment; the domain opt-in governs UNINVITED talk and has never governed an address.
275
+ *
276
+ * Pure, so the partition is a truth table a test can walk: `drop` is the classifier's own refusal
277
+ * (a bot author, an un-vouched public repo, a mention the native leg owns), and nothing re-opens it.
278
+ * `engagedFollowUp` is REQUIRED rather than defaulted — a caller that omitted it would silently
279
+ * restore the refusal this parameter exists to close. {@link githubAmbientLegAdmission} is the one
280
+ * place that computes it.
281
+ */
282
+ export function githubAmbientLegKeeps(addressing: string, ambientEnabled: boolean, engagedFollowUp: boolean): boolean {
283
+ if (addressing === "drop") return false;
284
+ return addressing === "direct" || engagedFollowUp || ambientEnabled;
285
+ }
286
+
287
+ /**
288
+ * The AMBIENT leg's whole admission for one comment: read the §2.5 seam, THEN apply the chattiness
289
+ * gate with the enrolment fact in hand. One call, so the order cannot drift back.
290
+ *
291
+ * 🔴 THE ORDER IS THE FIX. `resolveGitHubEngagement` runs FIRST and its verdict feeds the gate, so an
292
+ * engaged container's plain follow-up is admitted on an addressed-only domain. Before this, the gate
293
+ * decided alone and the seam below it never got to speak.
294
+ *
295
+ * 🔴 THE COST, STATED. Every kept-by-the-classifier comment now pays ONE engagement-registry read
296
+ * here, where an ambient-off domain used to return on the gate. The read is the seam — a container's
297
+ * enrolment cannot be known without it — and it is the SAME read the leg already paid on every
298
+ * ambient-ON domain. `resolveGitHubEngagement` settles a mention with no round-trip at all.
299
+ *
300
+ * `engagedFollowUp` is floored on {@link githubEngagedFollowUpReach}, so a public repo and an inline
301
+ * review comment are refused exactly as they are on the native leg — this seam never widens an
302
+ * uninvited turn's reach, it only stops the domain flag from closing a door enrolment opened.
303
+ *
304
+ * Exported for the wiring test: `ambientDelivery` is module-private and only runs behind the route's
305
+ * `waitUntil`, so this is the furthest out a test can drive the decision. That the LEG calls it is
306
+ * covered by the `J-ENGAGED-github` e2e journey and by nothing offline. It takes NO injected deps —
307
+ * the wiring test drives it over the REAL operational store and a REAL declared domain, which is the
308
+ * point of that file.
309
+ */
310
+ export async function githubAmbientLegAdmission(
311
+ addressing: string,
312
+ surfaceKey: string,
313
+ f: GitHubSeamFacts,
314
+ ): Promise<{ readonly keep: boolean; readonly seam: GitHubEngagementVerdict; readonly engagedFollowUp: boolean }> {
315
+ const seam = await resolveGitHubEngagement(f);
316
+ const engagedFollowUp = seam.kind === "deliver" && githubEngagedFollowUpReach(f).reachable;
317
+ return { keep: githubAmbientLegKeeps(addressing, githubAmbientEnabled(surfaceKey), engagedFollowUp), seam, engagedFollowUp };
318
+ }
319
+
320
+ // Is the ambient supervisor enabled for a repo's surfaceKey? Domain-level opt-in
321
+ // (ambientSupervisorEnabled, default OFF). Fail-closed: any config-load error → OFF.
322
+ function githubAmbientEnabled(surfaceKey: string): boolean {
323
+ try {
324
+ return ambientSupervisorEnabled(surfaceKey, getConfig().loadDomains());
325
+ } catch {
326
+ return false;
327
+ }
328
+ }
329
+
330
+ // The calibration dial (GitHub platform-defaults to `reserved`). Fail-soft: neutral.
331
+ function githubEngagementTier(surfaceKey: string): EngagementTier | undefined {
332
+ try {
333
+ return engagementTierFor(surfaceKey, getConfig().loadDomains());
334
+ } catch {
335
+ return undefined;
336
+ }
337
+ }
338
+
339
+ // UNIFIED cross-surface grain (channels/unified-supervisor.ts) — the one hop the session deps +
340
+ // wake stamp share, mirroring slack-events.ts liveSupervisorDeps' `grainFor`. FAIL-SOFT to the
341
+ // per-container grain (the byte-identical path): a config-load error must degrade to today's
342
+ // behaviour, never take the repo's supervisor down or mis-key its session.
343
+ function githubGrainFor(surfaceKey: string): SupervisorGrain {
344
+ try {
345
+ return supervisorGrainFor(surfaceKey, getConfig().loadDomains());
346
+ } catch {
347
+ return { unified: false, registryKey: surfaceKey };
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Suppress the NATIVE @mention session for a SINGLE-WINDOW VOUCHED repo? On a crossSurfaceWindow repo
353
+ * a @mention answered by the native per-conversation session would reply WITHOUT the shared
354
+ * cross-surface context — that session is a separate eve run under a per-conversation token and can
355
+ * never join the single window. So we
356
+ * suppress it and let the ambient/unified leg answer the mention as an addressed `direct` wake
357
+ * instead. Gated on `singleWindow && the operator VOUCHED the repo internal` — the EXACT predicate the
358
+ * ambient classifier's `treatMentionAsDirect`/`vouched` keys on, so native-suppress and ambient-keep are
359
+ * perfectly complementary (never a double reply, never a gap): an un-vouched single-window repo keeps
360
+ * its native mention agent, because the ambient leg still drops mentions there. Pure, so the gate is
361
+ * unit-testable without a live config.
362
+ *
363
+ * A thin GitHub shim over the SHARED seam (unified-window.ts singleWindowMentionPolicy): the
364
+ * caller (onComment) consults it only AFTER githubBotMentioned confirmed the comment IS a
365
+ * mention, so `isMention` is pinned true here; `privacyOk` is the operator-VOUCH floor
366
+ * (isVouchedInternal / OE_TRUST — resolved by the caller), fail-closed on un-vouched/unknown. The
367
+ * repo's own `repository.private` flag is NOT consulted here — it is a logged defense-in-depth
368
+ * signal at the call site, not the window gate.
369
+ */
370
+ export function shouldSuppressNativeMention(singleWindow: boolean, vouched: boolean): boolean {
371
+ return singleWindowMentionPolicy(singleWindow, true, vouched).suppressNative;
372
+ }
373
+
374
+ /** The operator-VOUCH floor for a repo's cross-surface WINDOW admission (mirrors notion.ts): the
375
+ * SAME per-surface allowlist that gates the shared memory — isVouchedInternal on the workspaceKey
376
+ * `github/<owner>` OR the surfaceKey `github/<owner>/<repo>`, or the OE_TRUST=internal blanket.
377
+ * Synchronous, no network. Fail-closed: an un-vouched repo returns false. */
378
+ function githubRepoVouched(owner: string, repo: string): boolean {
379
+ return isVouchedInternal(`github/${owner}`, `github/${owner}/${repo}`) || process.env.OE_TRUST === "internal";
380
+ }
381
+
382
+ /**
383
+ * THE EXTERNAL-PARTICIPANT VERDICT for one GitHub comment — the AUTHOR grain (OPE-533 / OPE-507)
384
+ * and the ROOM grain (OPE-529 / OPE-504) as one pure decision, so both outcomes are tested without
385
+ * a webhook, a store or a live repo.
386
+ *
387
+ * The two values that are not `proceed` name WHY the agent stays quiet. GitHub owes the same act
388
+ * for both — see the notice note below — but the audit row must still say which question answered:
389
+ * `withheld-author` — the comment's author holds no access grant on the repo.
390
+ * `silent-room` — the room itself is classified external and the operator asked for silence.
391
+ *
392
+ * 🔴 THE AUTHOR GRAIN IS TESTED FIRST, mirroring Discord. An excluded author in a silenced room is
393
+ * an author verdict: the two grains are independent knobs, and a row that named the room would hide
394
+ * the fact that the person, not the place, decided it.
395
+ *
396
+ * THE ROOM LEG NOW READS THE AUTHOR AS EVIDENCE. `ingested` carries the comment's author, so
397
+ * `roomIsExternalClass`'s GUEST-PRESENT leg answers on GitHub the way it already does on Discord.
398
+ * `vendorExternal` is still not passed: GitHub's own room signal is `repository.private`, which is
399
+ * already the window-admission floor read elsewhere (`githubRepoVouched`) — reusing it here would
400
+ * fold a DIFFERENT question (may this repo join the shared memory window?) into this one (should
401
+ * the agent stay quiet here?).
402
+ */
403
+ export type GitHubExternalVerdict = "proceed" | "withheld-author" | "silent-room";
404
+
405
+ export function githubExternalVerdict(input: {
406
+ readonly policy: ExternalPolicy;
407
+ readonly author: IngestedChatMessage;
408
+ readonly surfaceKey: string;
409
+ }): GitHubExternalVerdict {
410
+ if (withholdsBody(input.author, input.policy)) return "withheld-author";
411
+ return roomIsSilenced({ room: { surfaceKey: input.surfaceKey }, ingested: [input.author] }, input.policy) ? "silent-room" : "proceed";
412
+ }
413
+
414
+ /**
415
+ * Is this wake suppressed by the external-participant policy? Resolves the policy, applies
416
+ * {@link githubExternalVerdict}, and records the suppression.
417
+ *
418
+ * POSITION IS LOAD-BEARING, mirroring Discord/Teams (OPE-525): every caller checks this BEFORE
419
+ * the ambient-enabled gate, the spend gate, and any affordance — a suppressed wake must cost
420
+ * nothing and post nothing.
421
+ *
422
+ * THE PRIVATE NOTICE OF BEHAVIOUR 1 IS NOT POSTED, unlike Slack/Discord/Teams, and that holds for
423
+ * BOTH verdicts. GitHub has no private channel back to a commenter (Notion's own behaviour-1
424
+ * reasoning), and that notice's text names the organisation, so putting it in a public repo's
425
+ * timeline would tell the world that this person is classified as an outsider.
426
+ *
427
+ * 🔴 AN EXPLICIT @MENTION STILL GETS THE FIXED ACK (OPE-885). That is a DIFFERENT string —
428
+ * `EXTERNAL_MENTION_ACK` names no person, no organisation, no setting and no verdict, and it is
429
+ * byte-identical for both verdicts, so a reader cannot tell which grain fired or that one fired at
430
+ * all. `addressed` is what separates the two cases here, and it is not the addressed/ambient split
431
+ * Discord makes on its verdict: GitHub's verdict is unchanged, and only the RENDERING keys on it.
432
+ * An AMBIENT (non-mention) comment stays a fully silent drop, which is the ruling.
433
+ *
434
+ * FAIL-SOFT on a config error (mirrors discord-gateway.ts `externalPolicyForRoom`): an unreadable
435
+ * domain registry degrades to the org default rather than throwing, so a broken config never
436
+ * takes an otherwise-healthy repo off the air.
437
+ */
438
+ async function githubExternalSuppressed(
439
+ surfaceKey: string,
440
+ owner: string,
441
+ principalId: string | undefined,
442
+ author: IngestedChatMessage,
443
+ /**
444
+ * THE ACK POSTER — present ONLY where the wake is an explicit @mention. `githubOnComment` passes
445
+ * it on its mention branch and OMITS it on an engaged plain follow-up (OPE-1501: that leg no
446
+ * longer returns early on a non-mention comment, so the "by construction" it used to rely on is
447
+ * gone and the caller states the condition instead). ABSENT on the ambient leg, which runs for
448
+ * every timeline comment and has not classified addressing yet.
449
+ *
450
+ * 🔴 THE TWO LEGS BOTH SEE A MENTION, SO ONLY ONE MAY ACK. A mention comment reaches
451
+ * `githubOnComment` AND `ambientDelivery` on the same delivery — that is the documented shape the
452
+ * single-window redirect relies on. Giving the ambient leg a poster too would post the ack twice
453
+ * for one mention.
454
+ *
455
+ * It is eve's own `ctx.thread.post`, which already holds the installation token and already knows
456
+ * whether this conversation takes a timeline comment or a review-thread reply. A hand-rolled REST
457
+ * call here would have to re-derive both, and would get the review-thread case wrong.
458
+ */
459
+ ackPost?: (message: string) => Promise<unknown>,
460
+ ): Promise<boolean> {
461
+ let policy: ExternalPolicy;
462
+ try {
463
+ policy = resolveExternalPolicy(externalPolicyOverrideFor(surfaceKey, getConfig().loadDomains()));
464
+ } catch {
465
+ policy = resolveExternalPolicy();
466
+ }
467
+ const verdict = githubExternalVerdict({ policy, author, surfaceKey });
468
+ if (verdict === "proceed") return false;
469
+ // GitHub reaches no one privately, so `alreadyAnswered` is always false here.
470
+ // GitHub reaches no one privately and has no room announcement, so nothing can have answered
471
+ // this mention already — `alreadyAnswered` is a constant false here.
472
+ const acked =
473
+ owesSuppressedMentionAck({ addressed: ackPost !== undefined, alreadyAnswered: false }) &&
474
+ (await ackPost!(EXTERNAL_MENTION_ACK).then(
475
+ () => true,
476
+ () => false,
477
+ ));
478
+ await appendAudit({
479
+ org: deploymentOrg(),
480
+ platform: "github",
481
+ workspace: owner,
482
+ surface: surfaceKey,
483
+ ...(principalId ? { principalId } : {}),
484
+ action: "github.external_policy_suppressed",
485
+ detail: { verdict, ...suppressionRendering(acked) },
486
+ }).catch(() => undefined);
487
+ slog(`suppressed ${surfaceKey}: externalPolicy verdict "${verdict}"${acked ? " — mention ack posted" : ""}`);
488
+ return true;
489
+ }
490
+
491
+ /**
492
+ * The dispatch shape OE's GitHub ambient/supervisor legs have always used. It was
493
+ * `SendFn<GitHubChannelState>` until eve 0.31 removed the public `SendFn` (the address moved from
494
+ * the send options onto `from(address)`); declaring it HERE keeps the whole leg below unchanged and
495
+ * confines the version-shaped difference to {@link githubSendFrom}.
496
+ */
497
+ type GitHubSend = (
498
+ payload: { readonly message: string; readonly context?: readonly string[] },
499
+ options: { readonly auth: unknown; readonly continuationToken: string; readonly state?: GitHubChannelState },
500
+ ) => Promise<unknown>;
501
+
502
+ /** Adapt eve 0.31's `from(address).send(message, options)` to {@link GitHubSend}. `context` moved
503
+ * from the payload into the send options in 0.31; omitted when empty so a no-context wake is
504
+ * byte-identical to the pre-0.31 payload. */
505
+ function githubSendFrom(from: RouteHandlerArgs<GitHubChannelState>["from"]): GitHubSend {
506
+ return (payload, options) =>
507
+ from(options.continuationToken).send(payload.message, {
508
+ auth: options.auth,
509
+ state: options.state,
510
+ ...(payload.context && payload.context.length > 0 ? { context: payload.context } : {}),
511
+ } as never);
512
+ }
513
+
514
+ /**
515
+ * Live supervisor deps (mirrors notion.ts liveNotionSupervisorDeps): create-or-fetch / rotate /
516
+ * force-rotate over the channel_session registry with the reserved :oe-sup:g token, and a `wake`
517
+ * that continues the session under that token with the supervisor seed state. Rotation reuses
518
+ * shouldRotate AS-IS; a rotation carries the OUTGOING generation's recorded TAIL forward (OPE-794).
519
+ *
520
+ * UNIFIED re-key (the Slack reference's exact shape, slack-events.ts liveSupervisorDeps): when the
521
+ * repo's domain has opted in, the registry ROW is the synthetic `oe-domain::<name>` key and the
522
+ * token is `oe-dom:<name>:oe-sup:g<gen>` — one row + one token per generation for the WHOLE team,
523
+ * whichever surface a wake arrives on. Flag-off, `githubGrainFor` returns
524
+ * `{unified:false, registryKey:surfaceKey}` and the per-repo token below — byte-identical.
525
+ */
526
+ // Exported (not just internal to ambientDelivery) so a test can drive the `wake` leg directly — the
527
+ // SAME reason liveNotionSupervisorDeps/liveGitHubSupervisorDeps's Slack/Teams siblings are testable
528
+ // without a live webhook. `ambientDelivery` itself stays module-private (see
529
+ // scripts/github-external-policy-room-test.ts's note); this seam is what OPE-970's own test uses.
530
+ export function liveGitHubSupervisorDeps(send: GitHubSend, ev: GitHubAmbientEvent, to?: RouteHandlerArgs["to"]): GitHubSupervisorDeps {
531
+ const org = deploymentOrg();
532
+ const store = getOperationalStore();
533
+ return {
534
+ async getOrCreateSession(surfaceKey, owner, repo, atIso) {
535
+ const grain = githubGrainFor(surfaceKey);
536
+ // OPE-599: claim the runtime-change rotation BEFORE the row read, and unconditionally (the
537
+ // mint arm must consume it too). Why, and why here: channels/runtime-rotation.ts.
538
+ const runtimeChanged = await claimRuntimeRotation({ org, registryKey: grain.registryKey, surfaceKey, domainName: grain.domainName, atIso });
539
+ const existing = await store.getChannelSession(org, grain.registryKey);
540
+ if (!existing) {
541
+ const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, 1, () => githubSupervisorToken(owner, repo, 1)), atIso);
542
+ // OPE-1821: stamp the pair onto the row this build just minted. Without it the seat's next
543
+ // wake reads NULL, takes the ADOPTED arm and never rotates for the deploy it crossed.
544
+ await stampMintedRuntime({ org, registryKey: grain.registryKey, surfaceKey, domainName: grain.domainName, atIso });
545
+ return { token: row.token, generation: row.generation, freshGeneration: true, tail: [] as readonly WakeMessageInput[] };
546
+ }
547
+ if (shouldRotate(existing, atIso, { runtimeChanged })) {
548
+ const gen = existing.generation + 1;
549
+ // OPE-794: read the OUTGOING generation's tail BEFORE minting its successor. A store read,
550
+ // fail-soft to empty — a rotation never waits on continuity and never fails for want of it.
551
+ const tail = await carryTailForward(org, grain.registryKey, existing.token);
552
+ const row = await store.rotateChannelSession(org, grain.registryKey, mintRotationToken(grain, gen, () => githubSupervisorToken(owner, repo, gen)), atIso);
553
+ return { token: row.token, generation: row.generation, freshGeneration: true, tail };
554
+ }
555
+ // A resume whose first wake never delivered (first_wake_at NULL) is still "fresh" (the H7c
556
+ // self-heal). Its tail is empty: it was carried forward and cleared at the rotation that
557
+ // minted this row.
558
+ // `lastWakeAt` rides out on THIS arm alone: it is the catch-up read's lower bound, and a fresh
559
+ // or just-rotated generation has none (rotation resets the column), which is exactly the
560
+ // bootstrap case the two arms above already report through `freshGeneration`.
561
+ return {
562
+ token: existing.token,
563
+ generation: existing.generation,
564
+ freshGeneration: existing.firstWakeAt == null,
565
+ tail: [] as readonly WakeMessageInput[],
566
+ lastWakeAt: existing.lastWakeAt ?? null,
567
+ };
568
+ },
569
+ touchSession: (surfaceKey, atIso) => store.touchChannelSession(org, githubGrainFor(surfaceKey).registryKey, atIso),
570
+ // THE SINCE-LAST-WAKE CATCH-UP (OPE-1726), through the shared path core holds. GitHub's own
571
+ // fetch is registered at bundle registration; the admission policy is the DOMAIN's, resolved by
572
+ // the shared road itself from the request's own channel key (OPE-1857). No `trustFor`: this
573
+ // surface has no synchronous roster probe for a comment author, so the shared core's FAIL-CLOSED
574
+ // per-sender default applies — a human sibling reads `unauth` and stays fenced, which is the
575
+ // honest floor for an author the deployment cannot vouch at wake time.
576
+ catchUp: (req) => seatCatchUp("github", req),
577
+ // OPE-794 — file this wake on the live generation's tail ring. Same key derivation as the
578
+ // rotation read and the spend counter, so all three target one row.
579
+ recordWake: (entry, token, surfaceKey) => recordWakeTail(org, githubGrainFor(surfaceKey).registryKey, token, entry),
580
+ now: () => new Date().toISOString(),
581
+ async wake({ token, auth, message, context, window }) {
582
+ const surfaceKey = `github/${ev.owner}/${ev.repo}`;
583
+ // SINGLE-WINDOW diversion (channels/unified-window.ts): a window-carrying wake goes
584
+ // through eve's cross-channel `to(...).send(...)` into the synthetic `unified` channel — the SAME
585
+ // primitive this file's fan-out leg already uses toward Slack (fanOut) — so its effective
586
+ // token is `unified:<raw>`, shared with the domain's Slack wakes: ONE context window.
587
+ // The unified channel's receive registers the wakeId→origin map entry (durably, before
588
+ // the send) and re-checks the gates fail-closed. A failure propagates to ambientDelivery's
589
+ // catch (fail-closed: never delivered without a routable reply target).
590
+ if (window && to) {
591
+ try {
592
+ // THE OWNED-THREAD INGRESS SEAM (§2.6, OPE-1719) — the INBOUND variant, the one Slack's
593
+ // front route has always used. A wake landing in a container a live task worker or thread
594
+ // seat OWNS is dispatched to that owner's token instead of the standing seat's. On GitHub
595
+ // the container and the thread coincide (an issue or pull request IS its own thread), so
596
+ // every wake on this leg consults ownership. Flag-off, and on every unowned container,
597
+ // this is a byte-identical passthrough to `divertToUnifiedWindow`.
598
+ await divertInboundToUnifiedWindow(to, {
599
+ message,
600
+ context: [...context],
601
+ token,
602
+ window,
603
+ surfaceKey,
604
+ auth: auth as unknown as { attributes?: Record<string, unknown> },
605
+ });
606
+ } catch (err) {
607
+ // OPE-970: until now this leg's ONLY trace of a divert failure was `slog("ambient leg
608
+ // failed: …")` at the outer `ambientDelivery(...).catch` — a console line, no audit row,
609
+ // nothing a control-plane query or an e2e classifier (FAILED_ACTIONS) could see.
610
+ // `auditSupervisorFailure` writes the SAME `surface.wake_failed` row Slack/Teams already
611
+ // write for their own wake failures, then rethrows unchanged.
612
+ await auditSupervisorFailure(
613
+ { org: deploymentOrg(), platform: "github", workspace: ev.owner, surface: surfaceKey },
614
+ "surface.wake_failed",
615
+ { message: String(err instanceof Error ? err.message : err).slice(0, 500), wakeId: window.wakeId },
616
+ );
617
+ throw err;
618
+ }
619
+ return;
620
+ }
621
+ // eve's SessionAuthContext is a structural superset of OpenEverywhereGitHubAuth (extra
622
+ // issuer/subject fields optional) — same cast the onComment hook relies on. The unified
623
+ // seed marker rides the CREATING wake's state (creation-only, grain fixed per session).
624
+ const unified = githubGrainFor(surfaceKey).unified;
625
+ try {
626
+ await dispatchNativeWakeWithRetry(
627
+ () =>
628
+ send(
629
+ { message, context: [...context] },
630
+ { auth: auth as unknown, continuationToken: token, state: supervisorSeedState(ev, unified) },
631
+ ),
632
+ { platform: "github", workspace: ev.owner, surfaceKey },
633
+ );
634
+ } catch (err) {
635
+ // OPE-971: the NATIVE (flag-off) send has no window and no divert — this repo's own
636
+ // domain has not opted into windowing, so a pruned-deployment strand here was completely
637
+ // unwrapped. `githubGrainFor(surfaceKey).registryKey === surfaceKey` flag-off (the exact
638
+ // row `getOrCreateSession` above already rotates), so it's passed straight through.
639
+ await healPrunedDeploymentStrand({ token, registryKey: surfaceKey, surfaceKey, workspace: ev.owner, platform: "github", atIso: new Date().toISOString(), err });
640
+ throw err;
641
+ }
642
+ },
643
+ };
644
+ }
645
+
646
+ /**
647
+ * The ambient leg for one delivery: extract → domain gate → classify → dispatch. Every exit
648
+ * short of dispatch is silent-and-cheap (this runs for EVERY webhook delivery on the route).
649
+ * Fail-closed at each step: no ambient candidate / no opt-in / classifier drop → nothing.
650
+ */
651
+ async function ambientDelivery(body: string, eventType: string, send: GitHubSend, to?: RouteHandlerArgs["to"]): Promise<void> {
652
+ let payload: Record<string, unknown>;
653
+ try {
654
+ payload = JSON.parse(body) as Record<string, unknown>;
655
+ } catch {
656
+ return;
657
+ }
658
+ const ev = extractGitHubAmbientEvent(eventType, payload);
659
+ if (!ev) return;
660
+ const surfaceKey = `github/${ev.owner}/${ev.repo}`;
661
+ // The comment author, in the CORE's own vocabulary — read once, then used by the
662
+ // external-participant policy below AND by the OPE-466 taint carriage handed to the dispatch.
663
+ const authorFacts = githubAuthorFacts({ authorAssociation: ev.comment.authorAssociation, actorType: ev.comment.authorType });
664
+ // THE EXTERNAL-PARTICIPANT POLICY (OPE-529 room grain, OPE-533 author grain): checked first,
665
+ // above the ambient-enabled gate, the classifier and the spend gate — a suppressed wake must cost
666
+ // nothing and show nothing. The author evidence is `author_association`, free on this payload.
667
+ // NO ACK POSTER (OPE-885): this leg has not classified addressing yet, and a mention comment also
668
+ // reaches `githubOnComment`, which owns the ack. An ambient comment is owed nothing.
669
+ if (await githubExternalSuppressed(surfaceKey, ev.owner, ev.comment.authorLogin, authorFacts)) {
670
+ return;
671
+ }
672
+ // SEATED REPO: on a repo with a standing seat, onComment suppressed the native mention session, so
673
+ // the ambient leg KEEPS a @mention (on an operator-VOUCHED repo) as an ADDRESSED `direct` wake the
674
+ // seat answers; otherwise mentions still DROP to the native path. Same fail-soft grain the native
675
+ // suppression reads — the two legs share one predicate, so they never double-handle or gap.
676
+ const singleWindow = grainUsesUnifiedWindow(githubGrainFor(surfaceKey)); // cross-surface, group OR solo (byte-identical flag-off)
677
+ // WINDOW-ADMISSION FLOOR = the operator VOUCH (identity/trust.ts — the SAME allowlist that gates
678
+ // the shared memory), NOT the repo-private flag. `ev.repoPrivate` is now a logged SIGNAL only.
679
+ const vouched = githubRepoVouched(ev.owner, ev.repo);
680
+ const cls = classifyGitHubAmbientComment(ev.comment, BOT_NAME, { treatMentionAsDirect: singleWindow, vouched });
681
+ if (cls.addressing === "drop") {
682
+ slog(`comment on ${surfaceKey}#${ev.issueNumber} dropped: ${cls.reason} (private=${ev.repoPrivate === true}, vouched=${vouched})`);
683
+ return;
684
+ }
685
+ // 🔴 THE §2.5 ENGAGED SEAM'S THREE OBLIGATIONS ON THIS LEG (OPE-1501, OPE-1740). All three are
686
+ // about a container an @mention ENROLLED, and the registry read that answers them runs FIRST —
687
+ // above the chattiness gate, because the first obligation IS that gate's third input.
688
+ //
689
+ // Machine-authored comments never reach here (`classifyGitHubAmbientComment` drops them), so the
690
+ // registry read below is only ever paid for a real person's comment.
691
+ const seamFacts: GitHubSeamFacts = {
692
+ owner: ev.owner,
693
+ repo: ev.repo,
694
+ number: ev.issueNumber,
695
+ isPrivate: ev.repoPrivate,
696
+ actorLogin: ev.comment.authorLogin ?? "unknown",
697
+ isMention: cls.addressing === "direct",
698
+ authorIsBot: false, // settled by the classifier's own bot/self drops above
699
+ // This leg only ever sees `issue_comment` deliveries (`extractGitHubAmbientEvent`), so a review
700
+ // thread never arrives here — the fact is still stated rather than left to a default, because
701
+ // the drop guard below reads it.
702
+ conversationKind: ev.isPullRequest ? "pull_request" : "issue",
703
+ };
704
+ const admission = await githubAmbientLegAdmission(cls.addressing, surfaceKey, seamFacts);
705
+ const seam = admission.seam;
706
+ // (1) THE CHATTINESS GATE, applied through the shared predicate so the two legs cannot drift apart
707
+ // ({@link githubAmbientLegKeeps} states the whole rule, and {@link githubAmbientLegAdmission} the
708
+ // order). An ENGAGED container's plain follow-up passes it with the domain flag off; ordinary
709
+ // ambient talk in an un-enrolled container is refused and audited exactly as before.
710
+ if (!admission.keep) {
711
+ // Was a bare `return` — the delivery vanished with no log and no row, which is the one
712
+ // outcome a deployer cannot debug (OPE-205). Recording it changes nothing about the gate.
713
+ await auditAmbientSkip({ platform: "github", workspace: ev.owner, surfaceKey, log: slog });
714
+ return;
715
+ }
716
+ if (seam.kind === "hold" && seam.stopped) {
717
+ // (2) THE STOP. `stand_down` marked this container, and the ambient leg is the one that would
718
+ // otherwise keep answering — so the suppression has to live here as well as on the native leg,
719
+ // or the verb would be a stop the loudest path ignores. An @mention clears the flag, which is
720
+ // why a `direct` comment never reaches this branch: it resolves `engage`, not `hold`.
721
+ //
722
+ // 🔴 IT STILL RUNS BELOW THE GATE, and that costs nothing: a stood-down row is never `deliver`,
723
+ // so `admission.engagedFollowUp` is false for it and the gate refuses a stood-down container's
724
+ // ambient talk on an addressed-only domain exactly as it did before the seam moved up.
725
+ slog(`comment on ${surfaceKey}#${ev.issueNumber} suppressed: stood down in ${seam.anchor} — @mention to pull the agent back in`);
726
+ return;
727
+ }
728
+ // `engagedFollowUp` already implies `deliver`; the `kind` test beside it is what narrows `seam` so
729
+ // the log line below can name the anchor.
730
+ if (seam.kind === "deliver" && admission.engagedFollowUp && !shouldSuppressNativeMention(singleWindow, vouched)) {
731
+ // (3) NO DOUBLE HANDLING. On a repo whose mentions the NATIVE leg owns, that leg now answers an
732
+ // engaged container's plain comments too (`githubOnComment`), so this leg must let it — exactly
733
+ // as `classifyGitHubAmbientComment` already drops a plain @mention there.
734
+ //
735
+ // 🔴 THE GUARD IS THE SHARED PREDICATE, NOT A BARE `singleWindow`. `suppressNative` is
736
+ // `singleWindow && vouched && isMention` (unified-window.ts), so a single-window repo the
737
+ // operator has NOT vouched keeps its native session — and a `!singleWindow` test would leave
738
+ // both legs answering the same comment there. Asking the same function the native suppression
739
+ // asks is what keeps the two legs partitioned on every combination, exactly as the mention
740
+ // split already does. Where the native leg IS suppressed, the unified window owns every comment
741
+ // and dropping here would be a gap rather than a de-dupe.
742
+ //
743
+ // 🔴 AND THE DROP IS FLOORED ON THE SAME REACH THE NATIVE ARM IS. `githubEngagedFollowUpReach`
744
+ // refuses a public or unknown-privacy repo, and the native leg refuses an engaged follow-up
745
+ // there — so dropping it here as "owned by the native path" would silence a comment neither leg
746
+ // then answers. On a VOUCHED public repo with ambient on it is the supervisor's to judge,
747
+ // exactly as it was before this seam existed.
748
+ slog(`comment on ${surfaceKey}#${ev.issueNumber} dropped: engaged container ${seam.anchor} — owned by the native githubChannel path`);
749
+ return;
750
+ }
751
+ const directMention = cls.addressing === "direct";
752
+ // The wake's addressing stamp derives from the SHARED seam (unified-window.ts
753
+ // singleWindowMentionPolicy) — the same pure decision the native suppression and the
754
+ // classifier's keep-as-direct branch consult. Feeding the classifier's verdict back as the
755
+ // seam's `isMention` is exact: `direct` is only ever returned for a real @mention, and every
756
+ // reachable non-drop mention on a single-window VOUCHED repo IS `direct`. Floor = the vouch.
757
+ const mentionPolicy = singleWindowMentionPolicy(singleWindow, directMention, vouched);
758
+ // Mint ONCE and carry the same id into both the window and github.dispatched. That makes the
759
+ // commentId/issueNumber -> wakeId -> ingress/session join exact; GitHub's whole-second vendor
760
+ // timestamp is latency evidence and is too coarse to serve as identity.
761
+ const wakeId = singleWindow && vouched ? mintWakeId() : undefined;
762
+ // Identity: the SAME auth capture the mention path uses (bindGitHub resolves real
763
+ // visibility/trust from it), so the supervisor session binds/spends/remembers identically.
764
+ const auth = buildGitHubAuth({
765
+ owner: ev.owner,
766
+ repo: ev.repo,
767
+ repositoryFullName: ev.repositoryFullName,
768
+ repositoryId: ev.repositoryId,
769
+ isPrivate: ev.repoPrivate,
770
+ conversationKind: ev.isPullRequest ? "pull_request" : "issue",
771
+ issueNumber: ev.isPullRequest ? undefined : ev.issueNumber,
772
+ pullRequestNumber: ev.isPullRequest ? ev.issueNumber : undefined,
773
+ installationId: ev.installationId,
774
+ actorLogin: ev.comment.authorLogin ?? "unknown",
775
+ actorId: ev.comment.authorId,
776
+ actorType: ev.comment.authorType,
777
+ authorAssociation: ev.comment.authorAssociation,
778
+ });
779
+ // PER-WAKE TRUST (identity/wake-trust.ts — audit-wedge F3): resolved off the SAME auth
780
+ // capture the session binds with (the canonical gate — mapper verdict + vouch register +
781
+ // OE_TRUST, fail-closed external) + the config-as-code admin rosters. Bot authors were
782
+ // already dropped in classify; the authorType fact still rides in as belt-and-braces.
783
+ const wakeTrust = resolveWakeTrustFromContext(
784
+ { channel: { kind: "github" }, session: { auth: { current: auth as unknown as { attributes?: Readonly<Record<string, string>> } } } },
785
+ { id: ev.comment.authorLogin, isBot: ev.comment.authorType === "Bot" },
786
+ { principalFallback: true }, // TRIGGER: the comment author IS the bound principal.
787
+ );
788
+ await handleGitHubSupervisorDispatch(
789
+ {
790
+ surfaceKey,
791
+ owner: ev.owner,
792
+ repo: ev.repo,
793
+ issueNumber: ev.issueNumber,
794
+ issueTitle: ev.issueTitle,
795
+ auth,
796
+ // THE DOMAIN'S ADMISSION POLICY over the wake's context-line slot (OPE-1800), resolved from
797
+ // the SAME channel key the catch-up dep above resolves its own from — so the seat's two reads
798
+ // admit an author identically and can never disagree about one message.
799
+ admit: seatAdmissionFor(surfaceKey),
800
+ // OPE-466 CARRIAGE (wired by OPE-494) — the ONE comment whose bytes this wake renders. GitHub
801
+ // hydrates no backscroll siblings into the envelope, so the trigger IS the ingested set.
802
+ ingested: [authorFacts],
803
+ trigger: {
804
+ id: ev.comment.id ?? "unknown",
805
+ authorLogin: ev.comment.authorLogin,
806
+ sentAt: ev.comment.createdAt ?? new Date().toISOString(),
807
+ text: ev.comment.body ?? "(the comment had no text)",
808
+ trust: wakeTrust,
809
+ },
810
+ engagementTier: githubEngagementTier(surfaceKey),
811
+ // Unified grain for the WAKE stamp (platform address + P5 disclosure). The registry/token
812
+ // re-key lives in the session deps; both consult the same fail-soft grain resolution.
813
+ unified: githubGrainFor(surfaceKey).unified,
814
+ // SINGLE-WINDOW addressed @mention: render `direct-mention` (addressed preamble, envelope +
815
+ // wake-id kept) so the shared-context supervisor answers it instead of watching silently.
816
+ directMention,
817
+ // THE ENROLMENT FACT for the envelope (OPE-2093): the SAME seam verdict the audit row below
818
+ // promotes to `thread-follow`, so the row and the wake can never disagree about it.
819
+ threadEngaged: admission.engagedFollowUp,
820
+ // SINGLE-WINDOW plumbing (channels/unified-window.ts): mint the per-wake id + reply-target
821
+ // origin when the grain says so — the core renders the id into the envelope and the wake
822
+ // dep diverts the delivery into the unified channel (one context window with Slack). A
823
+ // `direct` @mention rides the SAME window path (it's only `direct` when singleWindow holds).
824
+ // Floored on the operator VOUCH — an un-vouched repo never diverts into the shared window
825
+ // (it stays in the memory-mediated domain session); the vouch is the sole window admission floor.
826
+ window: wakeId
827
+ ? ({
828
+ wakeId,
829
+ origin: {
830
+ platform: "github",
831
+ owner: ev.owner,
832
+ repo: ev.repo,
833
+ issueNumber: ev.issueNumber,
834
+ ...(ev.installationId !== undefined ? { installationId: ev.installationId } : {}),
835
+ },
836
+ // ADDRESSED @mention ⇒ stamp `oe_addressing:"direct-mention"` (via divertToUnifiedWindow
837
+ // → stampUnifiedWindowAuth) so the unified completion handler bridges the answer to this
838
+ // wake if the model writes prose instead of calling `reply`. A channel-watch wake omits
839
+ // it (ambient prose stays hard-dropped).
840
+ ...(mentionPolicy.addressing !== undefined ? { addressing: mentionPolicy.addressing } : {}),
841
+ // `vendorTs` (OPE-1716): the comment's own `createdAt`, GitHub's stamp on the event the
842
+ // webhook reports — the first latency anchor. Absent on a delivery that carried none,
843
+ // and the ingress row then records no vendor time rather than a clock of ours.
844
+ ...spreadVendorTs(vendorInstantEventTime(ev.comment.createdAt)),
845
+ } satisfies UnifiedWindowWake)
846
+ : undefined,
847
+ },
848
+ liveGitHubSupervisorDeps(send, ev, to),
849
+ );
850
+ await appendAudit({
851
+ org: deploymentOrg(),
852
+ platform: "github",
853
+ workspace: ev.owner,
854
+ surface: surfaceKey,
855
+ principalId: ev.comment.authorLogin,
856
+ action: "github.dispatched",
857
+ // 🔴 THE ROW CARRIES THE SEAM'S VERDICT, NOT THE CLASSIFIER'S WORD (OPE-1752). The classifier
858
+ // calls an engaged follow-up and unowned issue chatter alike `channel-watch`, and this row is
859
+ // the one place those two must not read alike. `githubWakeAddressing` promotes the engaged one
860
+ // to `thread-follow`, the same mapping Notion (OPE-1510) and Linear (OPE-1578) already make.
861
+ // It changes the ROW only: `github-supervisor.ts` derives the wake envelope's own `addressing`
862
+ // from `directMention`, and the unified-window wake takes `mentionPolicy.addressing` above. The
863
+ // envelope states the enrolment as `follow-up="thread-engaged"` from `threadEngaged` above.
864
+ // repoPrivate rides as a defense-in-depth SIGNAL (no longer the window gate — the vouch is).
865
+ detail: githubDispatchAuditDetail({
866
+ addressing: githubWakeAddressing(cls.addressing, admission.engagedFollowUp),
867
+ issueNumber: ev.issueNumber,
868
+ commentId: ev.comment.id,
869
+ wakeId,
870
+ repoPrivate: ev.repoPrivate === true,
871
+ vouched,
872
+ }),
873
+ });
874
+ slog(`comment on ${surfaceKey}#${ev.issueNumber} → judged wake dispatched`);
875
+ }
876
+
877
+ // --- the installation leg (self-registering owner→installation map) --------------------
878
+
879
+ /**
880
+ * The INSTALLATION leg for one delivery: normalize → learn/revoke → audit. Runs for every webhook
881
+ * delivery on the route (the extractor returns null for all the non-installation ones, cheaply).
882
+ * The App's shared-secret gate upstream in wrapRoute already authenticated the body — this leg
883
+ * adds no second verification path.
884
+ *
885
+ * The core (classify + apply) is the tested logic in github-installations.ts; this is the live
886
+ * wiring onto the core SCM plane's durable map (scm/installations.ts, via the adapter-api barrel).
887
+ */
888
+ async function installationDelivery(body: string, eventType: string): Promise<void> {
889
+ let payload: Record<string, unknown>;
890
+ try {
891
+ payload = JSON.parse(body) as Record<string, unknown>;
892
+ } catch {
893
+ return;
894
+ }
895
+ const ev = extractGitHubInstallationEvent(eventType, payload);
896
+ if (!ev) return;
897
+ await handleGitHubInstallationDelivery(ev, {
898
+ record: (rec) => recordGitHubInstallation(rec),
899
+ forget: (owner) => forgetGitHubInstallation(owner),
900
+ audit: (action, detail, workspace) => appendAudit({ org: deploymentOrg(), platform: "github", workspace, action, detail }),
901
+ log: (m) => ilog(m),
902
+ });
903
+ }
904
+
905
+ // --- supervisor-aware events (delegating every non-supervisor turn to eve's defaults) --
906
+
907
+ // A PRISTINE second githubChannel instance (same config axes that shape the default events:
908
+ // none — api/credentials/progress all unset, matching the real channel below) is the source of
909
+ // eve's DEFAULT handlers. defineChannel compiles authored events into `adapter[<event>]`
910
+ // entries of shape `(data, adapterCtx) => authored(data, enriched(adapterCtx), callbackCtx)`;
911
+ // calling one from inside OUR authored handler (same ALS step scope) re-runs the enrichment
912
+ // harmlessly and rebuilds the callback context — i.e. the default runs EXACTLY as if we had
913
+ // never authored the key. This is how the #623 destructive-replacement axis is used safely:
914
+ // the mention path's checkout/reply/error behaviour is eve's own code, not a replica.
915
+ const pristine = githubChannel({ botName: BOT_NAME });
916
+ type AdapterEventFn = (data: unknown, adapterCtx: unknown) => unknown;
917
+ const defaultEvent = (name: string): AdapterEventFn | undefined =>
918
+ (pristine as unknown as { adapter?: Record<string, AdapterEventFn> }).adapter?.[name];
919
+
920
+ /**
921
+ * Record a NATIVE (@mention) turn that judged SILENCE (OPE-883). Nothing is posted and there is no
922
+ * session to close, so this row is the ONLY witness that the wake ended.
923
+ *
924
+ * `replied` separates the two silences, and they are different facts. A turn that posted through the
925
+ * `reply` tool and then closed with `no_reply` is the taught, correct shape — `finalMessageActionForTurn`
926
+ * reads the FINAL message, so that turn arrives here looking silent. A turn with `replied:false`
927
+ * answered an addressed @mention with nothing at all.
928
+ *
929
+ * THE ROW IS NOT GATED BEHIND `audit.silences`, for the reason channels/supervisor-complete.ts states
930
+ * about its own `github.no_reply` sibling: that flag covers the ambient-CHAT judged-silence rows, the
931
+ * ~99% majority on a watched room. A native session opens because a member ADDRESSED this app, so
932
+ * every one of these rows is the rare case an operator hunts for.
933
+ */
934
+ async function auditNativeGitHubSilence(state: GitHubChannelState, reason: string, replied: boolean, sessionId?: string): Promise<void> {
935
+ slog(`native turn judged silence (${reason}) on ${state.owner}/${state.repo}#${state.issueNumber ?? "?"} — nothing commented`);
936
+ // Taint-clean by construction: ids, an enum-shaped reason and booleans only. The dropped text is
937
+ // never recorded — on the sentinel path it is the model's private rationale.
938
+ await appendAudit({
939
+ org: deploymentOrg(),
940
+ platform: "github",
941
+ workspace: state.owner,
942
+ surface: `github/${state.owner}/${state.repo}`,
943
+ action: "github.no_reply",
944
+ detail: { issueNumber: state.issueNumber ?? null, reason, replied, route: "native", ...(sessionId ? { sessionId } : {}) },
945
+ }).catch(() => undefined);
946
+ }
947
+
948
+ /**
949
+ * The audit partition a NATIVE completion stamps on its DROP row, rebuilt from the LIVE session
950
+ * state the way discord.ts and slack.ts rebuild theirs: a completion handler holds no auth ctx, so
951
+ * the partition comes from the state through the REAL identity seam — owner → workspace, repo →
952
+ * surface. That is what puts the row on the same partition the `reply` TOOL's own rows land on,
953
+ * which matters because `reply.post` carries the BARE surface segment (the repo) and a reader
954
+ * scoped to it must match the drop row and the reply row alike.
955
+ * 🔴 IT STOPPED SERVING A POST IN OPE-2006. This arm posted the turn's closing prose through
956
+ * `postTerminalProse` as a `route:"terminal"` `reply.post` row; that plane is deleted, so the
957
+ * partition now files `turn.supervisor_prose_dropped` instead. What it must match is unchanged.
958
+ *
959
+ * `actorLogin` is empty here on purpose: the partition does not read it — `buildGitHubAuth` puts it
960
+ * in `principalId` and `user_login` only. WHO the turn answered is carried on the audit struct
961
+ * instead, from `state.triggeringUserLogin`, the way slack.ts, discord.ts and teams.ts carry theirs.
962
+ *
963
+ * Null on a state that cannot place itself; the caller then falls back to eve's own poster.
964
+ */
965
+ function nativeGitHubAuditPartition(state: GitHubChannelState): ChatAuditPartition | null {
966
+ if (!state.owner || !state.repo) return null;
967
+ try {
968
+ return resolvePartition({
969
+ channel: { kind: "github" },
970
+ session: {
971
+ auth: {
972
+ current: buildGitHubAuth({
973
+ owner: state.owner,
974
+ repo: state.repo,
975
+ actorLogin: "",
976
+ repositoryId: state.repositoryId,
977
+ conversationKind: state.conversationKind,
978
+ }),
979
+ },
980
+ },
981
+ });
982
+ } catch {
983
+ return null;
984
+ }
985
+ }
986
+
987
+ // The native GitHub conversational channel: @mention → session, sandbox checkout, PR
988
+ // diff in context, comment reply. `onComment` REPLACES eve's default @mention gate so we
989
+ // can (a) keep the SAME mention semantics (githubBotMentioned mirrors eve's gate) and
990
+ // (b) enrich the dispatched auth with the repo owner/name/privacy + actor association the
991
+ // identity mapper needs — eve's own defaultGitHubAuth omits repo.private, which would peg
992
+ // every surface to fail-closed private/external. botName + credentials fall back to env
993
+ // (GITHUB_APP_SLUG / GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY / GITHUB_WEBHOOK_SECRET).
994
+ // Exported for the provider integration test (drives the REAL ingress handler directly with a
995
+ // seeded over-budget scope). The channel below wires it as `onComment`.
996
+ export const githubOnComment = async (ctx: GitHubInboundContext, comment: GitHubComment): Promise<GitHubInboundResult> => {
997
+ // Same wake rule as eve's built-in gate (that helper isn't publicly exported).
998
+ const isMention = githubBotMentioned(comment.body, BOT_NAME, comment.author);
999
+ // 🔴 THE §2.5 ENGAGED SEAM (OPE-1501) IS THE SECOND DOOR OF THIS GATE. Before it, a comment that
1000
+ // was not an @mention returned here and the native leg answered exactly once per mention. Now a
1001
+ // PLAIN comment in a container an @mention already ENGAGED is admitted too, and the seam's own
1002
+ // fail-closed read decides — `channels/github-engagement.ts` owns the policy, this line owns the
1003
+ // wiring. The facts are the same ones the auth below is built from.
1004
+ //
1005
+ // The MARK is further down, after the external-participant policy: an author the operator
1006
+ // excluded must not be able to enrol a container by @mentioning the app in it.
1007
+ const seamFacts: GitHubSeamFacts = {
1008
+ owner: ctx.repository.owner,
1009
+ repo: ctx.repository.name,
1010
+ number: ctx.conversation.issueNumber ?? ctx.conversation.pullRequestNumber ?? undefined,
1011
+ isPrivate: ctx.repository.private,
1012
+ actorLogin: comment.author?.login ?? ctx.sender.login,
1013
+ isMention,
1014
+ // The author gate, in the seam's own vocabulary. `githubBotMentioned` already refuses a
1015
+ // self-authored mention; a PLAIN comment reaches the seam with no such check, so an engaged
1016
+ // container would otherwise admit the app's own reply and loop on itself.
1017
+ authorIsBot: githubMachineAuthored(comment.author?.login ?? ctx.sender.login, comment.author?.type ?? ctx.sender.type, BOT_NAME),
1018
+ // 🔴 THE REACH FACT (see `githubEngagedFollowUpReach`): an INLINE review comment shares its
1019
+ // pull request's anchor, so the non-mention arm refuses one. A mention inside a review thread
1020
+ // still wakes — it never reaches that arm.
1021
+ conversationKind: ctx.conversation.kind,
1022
+ };
1023
+ // THE COST, STATED: every non-mention comment on a PRIVATE repo now pays ONE engagement-registry
1024
+ // read here, where it used to return on the line above. That read is the seam — a container's
1025
+ // engagement cannot be known without it — and it short-circuits the common case (an un-engaged
1026
+ // container) before any config lookup, any policy call or any spend probe below. A public repo
1027
+ // and an inline review comment pay nothing: the reach floors inside `githubEngagedAdmits` refuse
1028
+ // them above the read.
1029
+ if (!isMention && !(await githubEngagedAdmits(seamFacts))) return null;
1030
+ // SINGLE-WINDOW SUPPRESSION (routing-fix §4 touch-point 1): on a crossSurfaceWindow VOUCHED repo,
1031
+ // hand the @mention to the unified window (via the ambient leg's addressed `direct` wake) instead
1032
+ // of the contextless native session. STRICTLY gated — a non-single-window (or un-vouched) repo
1033
+ // is byte-identical (native mention agent intact). Floor = the operator VOUCH (NOT the repo-private
1034
+ // flag). Fail-soft: githubGrainFor degrades to {singleWindow: unset} on a config error ⇒ not
1035
+ // suppressed ⇒ today's native behaviour.
1036
+ const surfaceKey = `github/${ctx.repository.owner}/${ctx.repository.name}`;
1037
+ // The comment author, in the CORE's own vocabulary — read once, then used by the
1038
+ // external-participant policy below AND by the OPE-466 taint carriage stamped onto the auth.
1039
+ const authorFacts = githubAuthorFacts({
1040
+ authorAssociation: str(comment.raw["author_association"]),
1041
+ actorType: comment.author?.type ?? ctx.sender.type,
1042
+ });
1043
+ // THE EXTERNAL-PARTICIPANT POLICY (OPE-529 room grain, OPE-533 author grain): checked first,
1044
+ // above the single-window redirect, the spend gate and every affordance — a suppressed wake
1045
+ // must cost nothing and show nothing regardless of which leg would otherwise have owned this
1046
+ // @mention. An excluded author gets no PRIVATE notice; see githubExternalSuppressed for why.
1047
+ // THE ACK ANCHOR (OPE-885) IS PASSED ONLY FOR AN EXPLICIT @MENTION. It used to be passed
1048
+ // unconditionally, and that was correct while this leg returned early on every non-mention
1049
+ // comment. 🔴 THE ENGAGED SEAM BROKE THAT PRECONDITION: a plain follow-up now reaches this line
1050
+ // too, and posting the fixed ack there would announce an excluded author's classification on a
1051
+ // comment nobody addressed to the agent — exactly what the ambient leg refuses to do, for the
1052
+ // reason its own comment states ("an ambient comment is owed nothing"). A person who ADDRESSED
1053
+ // the app is still owed the one line rather than silence, so the anchor rides `isMention`.
1054
+ if (
1055
+ await githubExternalSuppressed(
1056
+ surfaceKey,
1057
+ ctx.repository.owner,
1058
+ comment.author?.login ?? ctx.sender.login,
1059
+ authorFacts,
1060
+ isMention ? (message) => ctx.thread.post(message) : undefined,
1061
+ )
1062
+ ) {
1063
+ return null;
1064
+ }
1065
+ // 🔴 THE §2.5 MARK (OPE-1501) — an @mention ENROLS this container, and it is written HERE, below
1066
+ // the external-participant policy and ABOVE the single-window suppression. Both positions are
1067
+ // load-bearing:
1068
+ // • BELOW the policy, because an author the operator excluded must not be able to enrol a
1069
+ // container by @mentioning the app in it — the suppressed wake costs nothing and writes
1070
+ // nothing, exactly as its own comment promises.
1071
+ // • ABOVE the suppression, because a single-window vouched repo answers the mention through the
1072
+ // unified window instead of this leg. The row is still what `stand_down` marks and what the
1073
+ // ambient leg's stop check reads there, so skipping the mark would leave the verb with no row
1074
+ // on precisely the repos that talk the most.
1075
+ // `participating: true` (inside `markGitHubEngaged`) is the re-invitation: an @mention clears a
1076
+ // previous stand-down, which is the documented way back in on every surface with this seam.
1077
+ // Best-effort — the mark never fails a turn.
1078
+ if (isMention) await markGitHubEngaged(seamFacts, comment.id);
1079
+ if (shouldSuppressNativeMention(grainUsesUnifiedWindow(githubGrainFor(surfaceKey)), githubRepoVouched(ctx.repository.owner, ctx.repository.name))) {
1080
+ return null;
1081
+ }
1082
+ // OPE-466 CARRIAGE (wired by OPE-494) — stamped at the BUILD, not at the return, so every
1083
+ // downstream reader of this auth sees the same bag eve will dispatch. The native leg renders one
1084
+ // comment and hydrates no siblings, so the trigger IS the ingested set. The room leg reads the
1085
+ // OPERATOR's trust declaration for the repo. An unmarked wake gets the input object BY
1086
+ // REFERENCE, so the dispatched auth is byte-identical to before this existed.
1087
+ const auth = stampNonMemberChatAuth(
1088
+ buildGitHubAuth({
1089
+ owner: ctx.repository.owner,
1090
+ repo: ctx.repository.name,
1091
+ repositoryFullName: ctx.repository.fullName,
1092
+ repositoryId: ctx.repository.id,
1093
+ isPrivate: ctx.repository.private,
1094
+ conversationKind: ctx.conversation.kind,
1095
+ issueNumber: ctx.conversation.issueNumber ?? undefined,
1096
+ pullRequestNumber: ctx.conversation.pullRequestNumber ?? undefined,
1097
+ // OPE-958 — the review thread's ROOT comment id, so `read_thread` on a GitHub seat can name
1098
+ // the thread this turn arrived in. GitHub omits `in_reply_to_id` on the comment that OPENED
1099
+ // the thread, so the root of a root comment is its own id. Derived ONLY on a review-thread
1100
+ // conversation: an issue or PR timeline comment is not in a review thread, and stamping a
1101
+ // root there would let `read_thread` claim a thread that does not exist.
1102
+ reviewThreadRootCommentId:
1103
+ ctx.conversation.kind === "review_thread" ? (num(comment.raw["in_reply_to_id"]) ?? comment.id) : undefined,
1104
+ installationId: ctx.github.installationId,
1105
+ actorLogin: comment.author?.login ?? ctx.sender.login,
1106
+ actorId: comment.author?.id ?? ctx.sender.id,
1107
+ actorType: comment.author?.type ?? ctx.sender.type,
1108
+ authorAssociation: str(comment.raw["author_association"]),
1109
+ }),
1110
+ [authorFacts],
1111
+ { surfaceKey },
1112
+ );
1113
+
1114
+ // SPEND CAP (pre-flight — the ONE shared gate, spend/ingress-gate.ts, the same function every
1115
+ // other surface and the cross-surface divert call): an over-budget scope DECLINES the turn with
1116
+ // a VISIBLE refusal, never a silent drop, before the model turn. CHECK fails OPEN; a definite
1117
+ // OVER fails CLOSED unless the sender is an admin over this scope.
1118
+ //
1119
+ // POSITION: this is the NATIVE mention leg only. On a single-window VOUCHED repo the mention is
1120
+ // SUPPRESSED above (`shouldSuppressNativeMention` → `return null`) and re-enters through the
1121
+ // ambient leg's window divert, which never reaches this line — that path is gated at the
1122
+ // divert's own choke point (channels/unified.ts windowSpendGate, door "window"). The two are
1123
+ // complementary, exactly like the suppression itself: whichever leg owns the mention gates it.
1124
+ if ((await ingressSpendGate({
1125
+ bindable: { channel: { kind: "github" as const }, session: { auth: { current: auth as unknown as { attributes?: Readonly<Record<string, string>> } } } },
1126
+ door: "mention",
1127
+ principalId: comment.author?.login ?? ctx.sender.login,
1128
+ post: async (text) => { await ctx.thread.post(text); },
1129
+ })) === "declined") {
1130
+ return null; // drop — no model turn
1131
+ }
1132
+
1133
+ // eve's SessionAuthContext is a superset of our capture shape (the extra issuer/subject
1134
+ // fields are optional); we dispatch the enriched auth so bindGitHub resolves real
1135
+ // visibility/trust instead of eve's stock defaultGitHubAuth, which omits repo.private.
1136
+ //
1137
+ // 🔴 AN ENGAGED FOLLOW-UP CARRIES THE ENROLMENT FACT (OPE-1740), AND THIS IS THE ONLY SEAM IT
1138
+ // HAS ON THIS LEG. A non-mention comment reaches this line ONLY past `githubEngagedAdmits`, so
1139
+ // `!isMention` here IS "the §2.5 seam admitted this as a follow-up in a container an @mention
1140
+ // engaged". Every other surface states that fact as `follow-up="thread-engaged"` on its `<wake>`
1141
+ // envelope; the native `githubChannel` session has no envelope at all — eve renders the comment
1142
+ // body and nothing else — so the fact rode on nothing, and on deploy #200 the seat read the
1143
+ // follow-up as somebody else's and answered `no_reply reason:"addressed_to_other"`. The inbound
1144
+ // hook's own `context` array is eve's supported seam for exactly this (`GitHubInboundResult`):
1145
+ // eve merges it with the PR context it builds and delivers it beside the comment.
1146
+ //
1147
+ // A MENTION IS UNCHANGED, and deliberately: it is addressed, so it is owed an answer whatever the
1148
+ // container's enrolment says — the same exclusion every enveloped surface makes.
1149
+ //
1150
+ // AUTHORITATIVE addressing stamp for the link-claim hook (OPE-1788, identity/binding.ts
1151
+ // `currentTurnAddressed`), the same one providers/notion stamps at notion-webhook.ts. `isMention`
1152
+ // is the host-set verdict this leg already computed from the comment's own trigger token — it is
1153
+ // the SAME addressing notion the single-window leg stamps at `singleWindowMentionPolicy`, not a
1154
+ // second one, so the two legs now agree instead of disagreeing on a capability switch.
1155
+ // 🔴 WITHOUT THIS STAMP THE NATIVE GITHUB CLAIM PATH IS UNREACHABLE. eve DELETES the `@bot` token
1156
+ // from the body before delivery (`extractGitHubCommentTrigger` in eve's github/inbound.js), so an
1157
+ // addressed `@bot link <code>` comment arrives as a BARE `link <code>` line. Unstamped, the claim
1158
+ // gate would refuse it and tell the person to @mention — which they just did, and which the strip
1159
+ // makes invisible. That is a dead end, and the native leg is the shipped default: `window.topology`
1160
+ // is `shipped: false`, so a stock deployment never reaches the single-window stamp at all.
1161
+ // Stamped ONLY on a mention: an engaged FOLLOW-UP is not addressed, and it must stay refused.
1162
+ if (isMention) auth.attributes[OE_ADDRESSED_ATTR] = "1";
1163
+ return isMention ? { auth } : { auth, context: [ENGAGED_FOLLOW_UP_CONTEXT] };
1164
+ };
1165
+
1166
+ const native: GitHubChannel = githubChannel({
1167
+ botName: BOT_NAME,
1168
+ // 🔴 QUEUE, NEVER STEER (OPE-412) — eve's default `steer` cancels the turn in flight when a
1169
+ // message arrives. Same reasoning as the Slack channel; see slack.ts for the full note.
1170
+ turnPolicy: "queue",
1171
+ onComment: githubOnComment,
1172
+ events: {
1173
+ // Supervisor turns: silent (no 👀, no checkout) + the per-turn reply-target snapshot.
1174
+ // Everything else: eve's default (eyes reaction + repo checkout), via the pristine adapter.
1175
+ async "turn.started"(data, channel, ctx) {
1176
+ if (!isSupervisorGitHubState(channel.state)) {
1177
+ await defaultEvent("turn.started")?.(data, channel);
1178
+ return;
1179
+ }
1180
+ const st = channel.state as GitHubSupervisorSessionState;
1181
+ const attrs = ctx?.session?.auth?.current?.attributes as Record<string, unknown> | undefined;
1182
+ // buildGitHubAuth stows issue_number for issues and pull_request_number for PR timeline
1183
+ // comments; both anchor the SAME reply endpoint (/issues/{n}/comments covers PRs too).
1184
+ const anchor = str(attrs?.issue_number) ?? str(attrs?.pull_request_number);
1185
+ const n = anchor !== undefined ? Number(anchor) : NaN;
1186
+ if (!Number.isFinite(n) || n <= 0) {
1187
+ // Missing/odd anchor CLEARS the target (never carry a stale one forward) —
1188
+ // message.completed then fails safe and posts nothing.
1189
+ st.oeReplyTarget = undefined;
1190
+ return;
1191
+ }
1192
+ if (st.oeUnified === true) {
1193
+ // UNIFIED (org, domain) session: the seed state's owner/repo is whichever repo CREATED the
1194
+ // session, but a domain can span many repos — so the reply target must ride THIS wake's
1195
+ // delivery auth (buildGitHubAuth stows owner/repo on every dispatch). Either missing
1196
+ // CLEARS the target: a cross-repo post to a guessed repo is the exact misdelivery the
1197
+ // per-wake snapshot exists to make impossible (P6), so fail safe instead.
1198
+ const owner = str(attrs?.owner);
1199
+ const repo = str(attrs?.repo);
1200
+ st.oeReplyTarget = owner && repo ? { issueNumber: n, owner, repo } : undefined;
1201
+ return;
1202
+ }
1203
+ st.oeReplyTarget = { issueNumber: n };
1204
+ },
1205
+ // Supervisor turns: the terminal outcome is audited and NOTHING is posted here — the visible
1206
+ // reply came from the `reply` tool mid-turn (OPE-811). Everything else: eve's default
1207
+ // reply-poster.
1208
+ async "message.completed"(data, channel, ctx) {
1209
+ if (!isSupervisorGitHubState(channel.state)) {
1210
+ // 🔴 THE NATIVE ARM OWNS THE SILENCE DECISION TOO (OPE-883). eve's default reply-poster
1211
+ // comments the terminal completion VERBATIM, so every silence this deployment knows about
1212
+ // has to be decided HERE, above the delegation, or it reaches the issue as text. Linear's
1213
+ // twin arm was measured doing exactly that on deploy #102 — a turn that called `no_reply`
1214
+ // closed with the literal string "NO_REPLY" and the surface carried it as a comment. This
1215
+ // arm had the identical gap: the sentinel is only ever swallowed by a handler that consults
1216
+ // it, and the old pre-check consulted the silent ROUTINE only.
1217
+ //
1218
+ // The whole silence vocabulary now runs — `no_reply`, the `NO_REPLY` sentinel, an empty
1219
+ // completion, a silent routine — exactly as the supervisor arm below and every chat surface
1220
+ // already do. It also CONSUMES the turn-keyed `no_reply` mark, which this arm never did.
1221
+ //
1222
+ // A tool-calls step is NOT terminal and never comments, so it delegates untouched — and it
1223
+ // must delegate BEFORE the consume, since the tool about to run may BE the `no_reply` call.
1224
+ //
1225
+ // GitHub has NO session to close (unlike Linear's agent session, whose spinner would hang):
1226
+ // the wake is a webhook, so the drop is the whole of the silence. What it does leave is the
1227
+ // audit row — see auditNativeGitHubSilence.
1228
+ if (data.finishReason === "tool-calls") {
1229
+ await defaultEvent("message.completed")?.(data, channel);
1230
+ return;
1231
+ }
1232
+ // OPE-1647: the turn id, so the stand-down silence mark can prove it belongs to THIS turn.
1233
+ // 🔴 THIS ARM DROPS ITS CLOSING PROSE SINCE OPE-2006, so the turn id no longer guards a post
1234
+ // — it decides WHICH ROW the turn files. A stood-down turn resolves `silent` and files
1235
+ // `auditNativeGitHubSilence` under the stand-down reason; without the id the mark is
1236
+ // unprovable, the turn reads as an ordinary drop, and the stand-down leaves no record of
1237
+ // itself. The posting hazard OPE-1647 was filed for — a stood-down conversation getting the
1238
+ // acknowledgement and then the trailing prose, the measured deploy #187 shape — survives on
1239
+ // ONE door only, and it is not this one: Linear's native arm, which hands its ending text to
1240
+ // the agent framework's own poster. (The supervisor arm below passes no id and needs none:
1241
+ // its terminal prose was already dropped under message-tool-only.)
1242
+ const nativeTurnId = (ctx as unknown as { session?: { turn?: { id?: string } } })?.session?.turn?.id;
1243
+ const nativeAction = finalMessageActionForTurn(data.finishReason, data.message, nativeTurnId);
1244
+ // The turn-keyed reply-tool mark, read-and-reset once per TERMINAL completion (the same
1245
+ // destructive read the supervisor arm below does, below its own non-terminal return).
1246
+ const nativeReplied = consumeSupervisorReplyMark(ctx);
1247
+ if (nativeAction.kind === "silent") {
1248
+ await auditNativeGitHubSilence(channel.state, nativeAction.reason, nativeReplied, ctx?.session?.id);
1249
+ return;
1250
+ }
1251
+ // 🔴 THE CLOSING PROSE IS DROPPED, NOT POSTED (OPE-2006). This arm used to post it through
1252
+ // `postTerminalProse` as a `route:"terminal"` `reply.post` row (OPE-1681), with a delegation
1253
+ // to eve's own poster as the fallback. Both are gone, and the DELEGATION goes with the choke
1254
+ // point rather than surviving it: the `reply` tool is the only channel a seat's words leave
1255
+ // by, so leaving one door that still comments the model's ending text would make the drop
1256
+ // untrue on the very surface whose seats read that it holds.
1257
+ // 🔴 THAT COSTS THE REVIEW THREAD ITS INLINE REPLY, AND THE TRADE IS STATED. eve's poster
1258
+ // branched on `conversationKind === "review_thread"` to the inline endpoint
1259
+ // (`/pulls/{n}/comments/{id}/replies`), which this deployment has no arm of its own for. A
1260
+ // review-thread turn must now answer through `reply`, whose GitHub arm posts an issue/PR
1261
+ // TIMELINE comment (`/issues/{n}/comments`) — the answer reaches the pull request rather than
1262
+ // the thread's own line context. That is a PLACEMENT loss, not a delivery loss, and it is the
1263
+ // honest side of the trade: the alternative is a seat told its prose is discarded on a surface
1264
+ // where one door still posts it. An inline-reply arm would close it, and this change has none.
1265
+ // WHAT TELLS THE MODEL is the seat prompt: GitHub declares `closingProseDropped`, so a
1266
+ // conversational seat here reads the `reply-only` delivery arm — inside its persona when the
1267
+ // token is supervisor-shaped, and through the `delivery` slot when it is not
1268
+ // (instructions/access.ts `renderDeliverySlot`).
1269
+ const nativePartition = nativeGitHubAuditPartition(channel.state);
1270
+ // Either number anchors the SAME endpoint (`/issues/{n}/comments` covers PR timelines).
1271
+ const nativeAnchor = channel.state.issueNumber ?? channel.state.pullRequestNumber;
1272
+ // `kind === "post"` is the only remaining case — the `tool-calls` early return above already
1273
+ // sent `buffer-tool-status` to eve's default, and the `silent` verdict returned above — but
1274
+ // the compiler cannot see the first of those, so the guard is what carries `action.text`.
1275
+ if (nativeAction.kind !== "post") return;
1276
+ if (nativePartition !== null) {
1277
+ await dropClosingProse({
1278
+ row: {
1279
+ org: nativePartition.org,
1280
+ platform: nativePartition.platform,
1281
+ workspace: nativePartition.workspace,
1282
+ surface: nativePartition.surface,
1283
+ // WHO this turn answered — the login eve stamped on the state from the triggering
1284
+ // comment's sender. An absent one MEANS "the delivery authenticated nobody", so
1285
+ // leaving it off would make every GitHub native row say something false.
1286
+ ...(channel.state.triggeringUserLogin ? { principalId: channel.state.triggeringUserLogin } : {}),
1287
+ },
1288
+ chars: nativeAction.text.length,
1289
+ ...(ctx?.session?.id ? { sessionId: ctx.session.id } : {}),
1290
+ anchor: { issueNumber: nativeAnchor, conversationKind: channel.state.conversationKind },
1291
+ log: (m) => log(`${m} (${channel.state.owner}/${channel.state.repo}#${nativeAnchor ?? "?"})`),
1292
+ });
1293
+ return;
1294
+ }
1295
+ // No resolvable partition, so there is no row to file it under — the drop still happens and
1296
+ // the log line is the whole record.
1297
+ log(
1298
+ `closing prose DROPPED (${nativeAction.text.length}c) — nothing posted, and no partition to audit it under (${channel.state.owner}/${channel.state.repo}#${nativeAnchor ?? "?"})`,
1299
+ );
1300
+ return;
1301
+ }
1302
+ const sessionId = ctx?.session?.id; // stamped into the terminal audit rows → joins to the transcript
1303
+ const action = finalMessageActionForTurn(data.finishReason, data.message);
1304
+ if (action.kind === "buffer-tool-status") return; // not terminal — check BEFORE consuming below
1305
+ const st = channel.state as GitHubSupervisorSessionState;
1306
+ const target = st.oeReplyTarget;
1307
+ const org = deploymentOrg();
1308
+ // OPE-811: the turn-keyed reply-tool mark, read-and-reset on every TERMINAL completion so it can
1309
+ // never bleed into the next turn — notion.ts's consume-at-top, and slack.ts's before it. It must
1310
+ // sit BELOW the buffer-tool-status return: the tool about to run on an intermediate step might
1311
+ // be the `reply` call itself, so consuming there would erase a mark that is not set yet.
1312
+ const repliedViaTool = consumeSupervisorReplyMark(ctx);
1313
+ // The POST anchor: per-repo sessions post to the seed repo (byte-identical); a UNIFIED
1314
+ // session posts to the repo the per-wake snapshot captured — the seed repo is just
1315
+ // "whichever repo created the session" and would misdeliver across containers.
1316
+ const postOwner = st.oeUnified === true ? target?.owner : st.owner;
1317
+ const postRepo = st.oeUnified === true ? target?.repo : st.repo;
1318
+ // Audit labels prefer the snapshot's container on the unified path (fall back to the seed
1319
+ // for a cleared-target silence row — context, not a post target).
1320
+ const auditOwner = (st.oeUnified === true ? target?.owner : undefined) ?? st.owner;
1321
+ const auditRepo = (st.oeUnified === true ? target?.repo : undefined) ?? st.repo;
1322
+ // The SHARED ticketing completion arm (channels/supervisor-complete.ts) owns the terminal
1323
+ // record. It POSTS NOTHING since OPE-811: GitHub's own post arm now carries a mid-turn route
1324
+ // (github-post-arm.ts — the installation-token issue/PR-timeline comment), so the manifest
1325
+ // declares midTurnPost, the `reply` tool mounts on this seat, and the message-tool-only gate is
1326
+ // LIVE — the turn's closing prose never posts. The github.dispatched row (above) +
1327
+ // replied/no_reply/turn.supervisor_prose_dropped = the "a row closes a row" family.
1328
+ await completeSupervisorTurn({
1329
+ action,
1330
+ ...(sessionId ? { sessionId } : {}),
1331
+ repliedViaTool,
1332
+ midTurnPost: capabilitiesForPlatform("github").midTurnPost,
1333
+ noReplyAction: "github.no_reply",
1334
+ repliedAction: "github.replied",
1335
+ contextRow: { org, platform: "github", workspace: auditOwner, surface: `github/${auditOwner}/${auditRepo}` },
1336
+ repliedRow: { org, platform: "github", workspace: postOwner ?? auditOwner, surface: `github/${postOwner ?? auditOwner}/${postRepo ?? auditRepo}` },
1337
+ silentAnchor: { issueNumber: target?.issueNumber ?? null },
1338
+ droppedAnchor: { issueNumber: target?.issueNumber },
1339
+ repliedAnchor: { issueNumber: target?.issueNumber },
1340
+ log: (m) => slog(m),
1341
+ });
1342
+ },
1343
+ // Supervisor turns: a failed judge turn must NOT spam the repo with an error comment —
1344
+ // audit + log instead. Everything else: eve's default error comment.
1345
+ async "turn.failed"(data, channel) {
1346
+ if (!isSupervisorGitHubState(channel.state)) {
1347
+ await defaultEvent("turn.failed")?.(data, channel);
1348
+ return;
1349
+ }
1350
+ const st = channel.state as GitHubSupervisorSessionState;
1351
+ slog(`supervisor turn failed on ${st.owner}/${st.repo} (audited, no comment posted)`);
1352
+ await auditSupervisorFailure(
1353
+ { org: deploymentOrg(), platform: "github", workspace: st.owner, surface: `github/${st.owner}/${st.repo}` },
1354
+ "github.supervisor_turn_failed",
1355
+ {},
1356
+ );
1357
+ },
1358
+ async "session.failed"(data, channel) {
1359
+ if (!isSupervisorGitHubState(channel.state)) {
1360
+ await defaultEvent("session.failed")?.(data, channel);
1361
+ return;
1362
+ }
1363
+ const st = channel.state as GitHubSupervisorSessionState;
1364
+ slog(`supervisor session failed on ${st.owner}/${st.repo} (audited, no comment posted)`);
1365
+ await auditSupervisorFailure(
1366
+ { org: deploymentOrg(), platform: "github", workspace: st.owner, surface: `github/${st.owner}/${st.repo}` },
1367
+ "github.supervisor_session_failed",
1368
+ {},
1369
+ );
1370
+ },
1371
+ },
1372
+ });
1373
+
1374
+ // Wrap the native POST /eve/v1/github handler: run the event→Slack fan-out AND the ambient-
1375
+ // supervisor leg first (we have cross-channel `receive` + this channel's `send` here), then
1376
+ // delegate the request UNCHANGED to the native handler for the @mention/session behaviour.
1377
+ // The request body can only be read once, so we buffer it and hand the native handler a fresh
1378
+ // Request carrying the same body + headers (it re-verifies the signature itself over that
1379
+ // body — same GITHUB_WEBHOOK_SECRET).
1380
+ type HttpHandler = HttpRouteDefinition<GitHubChannelState>["handler"];
1381
+ function wrapRoute(inner: HttpHandler): HttpHandler {
1382
+ return async (req, args) => {
1383
+ const body = await req.text();
1384
+ // Fan-out gate: preserve the pre-native fail-closed behaviour EXACTLY — an
1385
+ // unauthenticated delivery is rejected for the whole route (the native handler would
1386
+ // 401 it too). The dev escape hatch keeps the fan-out testable without a secret.
1387
+ const rejection = gateGithubDelivery(
1388
+ process.env.GITHUB_WEBHOOK_SECRET,
1389
+ body,
1390
+ req.headers.get("x-hub-signature-256"),
1391
+ getSettings().webhooks.allowUnverifiedGithub,
1392
+ );
1393
+ if (rejection) {
1394
+ log(`webhook rejected: ${rejection}`);
1395
+ return new Response(rejection, { status: 401 });
1396
+ }
1397
+ const eventType = req.headers.get("x-github-event") ?? "";
1398
+ // Ack fast; both OE legs run in the background (their sessions are async), same as before.
1399
+ // The x-github-delivery header is GitHub's own idempotency key — passed through so the fan-out
1400
+ // can drop a REDELIVERY (GitHub retries on any non-2xx) without re-firing every matched event
1401
+ // routine. Passed only AFTER the signature gate above, never before it (OPE-139).
1402
+ args.waitUntil(fanOutGitHubDelivery(body, eventType, args.to, req.headers.get("x-github-delivery")));
1403
+ // ─── THE STOP LEG (OPE-898) ───────────────────────────────────────────────────────────────
1404
+ // GitHub sends no reaction webhook, so the stop affordance on this surface is a bare `stop`
1405
+ // comment read HERE, in the ingress — before any session is minted. See channels/github-stop.ts
1406
+ // for the vendor evidence and the grammar.
1407
+ //
1408
+ // 🔴 IT SUPPRESSES THE AMBIENT LEG FOR ITS OWN DELIVERY. A stop directive must cancel and nothing
1409
+ // else: waking the supervisor on it would start a turn in the room the person just asked the
1410
+ // agent to leave alone. The native @mention leg needs no such guard — `isStopDirective` refuses
1411
+ // every mention spelling, so eve's channel never sees one.
1412
+ const stop = parseGitHubStopComment(body, eventType);
1413
+ if (stop) {
1414
+ args.waitUntil(
1415
+ routeGitHubStopComment(stop, { attachSession: args.attachSession }).then(
1416
+ (o) => slog(`stop comment on ${stop.owner}/${stop.repo}#${stop.number}: ${o.stopped ? o.outcome : o.reason}`),
1417
+ (err) => slog(`stop leg failed: ${String(err)}`),
1418
+ ),
1419
+ );
1420
+ } else {
1421
+ args.waitUntil(ambientDelivery(body, eventType, githubSendFrom(args.from), args.to).catch((err) => slog(`ambient leg failed: ${String(err)}`)));
1422
+ }
1423
+ args.waitUntil(installationDelivery(body, eventType).catch((err) => ilog(`installation leg failed: ${String(err)}`)));
1424
+ // ─── THE REACTION-FEEDBACK LEG (OPE-1502) ─────────────────────────────────────────────────
1425
+ // GitHub pushes no reaction webhook, so a reaction on the agent's own comment is READ rather
1426
+ // than delivered — this leg polls the conversation the delivery names. It runs beside the stop
1427
+ // leg rather than in its `else`, because a stop directive is still an occasion to look. See
1428
+ // channels/github-reaction-feedback.ts for the vendor evidence, the dedupe and the latency.
1429
+ const pollTarget = parseGitHubReactionPollTarget(body, eventType);
1430
+ if (pollTarget) {
1431
+ args.waitUntil(
1432
+ routeGitHubReactionFeedback(pollTarget, liveGitHubReactionFeedbackDeps(BOT_NAME)).then(
1433
+ (outcomes) => {
1434
+ if (outcomes.length > 0) slog(`reaction feedback on ${pollTarget.owner}/${pollTarget.repo}#${pollTarget.number}: ${outcomes.length} recorded`);
1435
+ },
1436
+ (err) => slog(`reaction feedback leg failed: ${String(err)}`),
1437
+ ),
1438
+ );
1439
+ }
1440
+ // Delegate to the native handler with a replayable request (body consumed above).
1441
+ const cloned = new Request(req.url, { method: "POST", headers: req.headers, body });
1442
+ return inner(cloned, args);
1443
+ };
1444
+ }
1445
+
1446
+ // Spread the native channel and swap only its POST /eve/v1/github route handler. Everything
1447
+ // that makes it the github channel — the adapter (kindHint/events/context/state), `receive`
1448
+ // (proactive github sessions), cors, the channel sentinel — is preserved, so eve dispatches
1449
+ // github turns exactly as the native channel would.
1450
+ const routes: GitHubChannel["routes"] = native.routes.map((r) =>
1451
+ r.transport !== "websocket" && r.method === "POST" && r.path === ROUTE
1452
+ ? { ...r, handler: wrapRoute(r.handler) }
1453
+ : r,
1454
+ );
1455
+
1456
+ const channel: GitHubChannel = { ...native, routes };
1457
+
1458
+ export default channel;