@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,645 @@
1
+ // The GitHub ambient supervisor — the eve-FREE tested core that turns every NON-mention
2
+ // issue/PR-timeline comment on an opted-in repo into a JUDGED wake to a standing per-REPO
3
+ // supervisor session. It is the SECOND non-Slack adopter of the surface-general
4
+ // ambient-supervision pattern (surface-adapter-port.md §"Ambient supervision everywhere"),
5
+ // deliberately THIN — it copies notion-supervisor.ts (the template) with the two GitHub-shaped
6
+ // differences called out below. Linear copies this pair next.
7
+ //
8
+ // WHAT'S REUSED WHOLESALE (zero GitHub-specific logic in any of it):
9
+ // • renderWake + the channel-watch preamble (the judge) — normalized-wake.ts (containerNoun
10
+ // "repo"; the engagement tier rides the same field — GitHub PLATFORM-DEFAULTS to `reserved`,
11
+ // memory/domain.ts engagementTierFor)
12
+ // • shouldRotate — the pure rotation policy — slack-events.ts
13
+ // • the (org, surfaceKey) channel_session registry + the :oe-sup:g spend counter — via deps
14
+ // • githubBotMentioned — the SAME mention gate the native path wakes on — github-identity.ts
15
+ //
16
+ // THE TWO GITHUB-SHAPED DIFFERENCES vs the Notion template:
17
+ // 1. MENTIONS ARE DROPPED, not `direct`. eve's native githubChannel already owns the @mention
18
+ // path (github.ts onComment → session + checkout + reply); a mention reaching the ambient
19
+ // leg too would double-handle the comment. classifyGitHubComment reuses githubBotMentioned
20
+ // — the EXACT gate the native path wakes on — so the two legs partition every comment
21
+ // between them with no overlap and no gap.
22
+ // 2. DRIVE-BY REPOS ARE DROPPED (fail closed). A repo ambient-wakes only when it is positively
23
+ // private (every commenter was invited by the org — the Notion/Slack membership analog) OR
24
+ // the operator has VOUCHED it internal (identity/trust.ts). On a public, un-vouched repo any
25
+ // drive-by stranger could wake judged turns — an uninvited spend + prompt-injection surface
26
+ // the design's "GitHub repos in a domain are ~always private" assumption deliberately
27
+ // excludes. The VOUCH additionally widens ambient to a vouched public repo (the operator's
28
+ // explicit "this surface is internal" assertion), and — separately — is the SOLE floor for
29
+ // the cross-surface single WINDOW (see classifyGitHubAmbientComment): a private flag never
30
+ // admits the shared window, only the vouch does.
31
+ //
32
+ // The repo-session is the container-session's ORCHESTRATION HALF ONLY (surface-adapter-port
33
+ // §"container session"): a repo-scoped standing session aware of the repo's issue/PR
34
+ // conversations, holding repo memory, that judges every comment and replies into the triggering
35
+ // issue. There is NO container-conversation half — a repo has no top-level chat to post to
36
+ // (containerConversation: false), so a judged reply always targets the triggering issue/PR.
37
+
38
+ import {
39
+ renderWake,
40
+ mkMessageTrigger,
41
+ shouldRotate,
42
+ singleWindowMentionPolicy,
43
+ stampNonMemberChatAuth,
44
+ seatCatchUpSinceMs,
45
+ admitContextLines,
46
+ engagedFollowUpContract,
47
+ type SeatAdmissionFn,
48
+ type SeatCatchUpRequest,
49
+ type SeatCatchUpResult,
50
+ type NormalizedWake,
51
+ type WakeEngagementTier,
52
+ type ContextLine,
53
+ type UnifiedWindowWake,
54
+ type WakeTrust,
55
+ type IngestedChatMessage,
56
+ type WakeMessageInput,
57
+ tailAuthorGrain,
58
+ type SessionTailWakeRow,
59
+ } from "@open-everywhere/core/agent/adapter-api.js";
60
+ import { githubBotMentioned, type OpenEverywhereGitHubAuth } from "./github-identity.js";
61
+
62
+ /** The catch-up's REACHBACK FLOOR (hours): how far back a seat that has been quiet for a long time
63
+ * reads. 7 days, the same floor every other surface's bootstrap window uses, so one number governs
64
+ * "how much history is a seat ever worth" across the fleet. */
65
+ const GITHUB_CATCH_UP_REACHBACK_HOURS = 168;
66
+
67
+ // --- supervisor token / sentinel (carry the :oe-sup:g infix — load-bearing) ----------
68
+ //
69
+ // hooks/spend.ts isSupervisorToken keys off the `:oe-sup:g` substring to (a) tag the usage-event
70
+ // `kind="supervisor"` and (b) drive the per-generation cumulative-token rotation counter, and
71
+ // model/routing.ts isSupervisorSession keys the cheap `supervisor` model role off the same
72
+ // `oe-sup:g<n>` suffix. Carry the infix and the ENTIRE cost/rotation/model-tier apparatus works
73
+ // for free. The token puts `owner/repo` in the channel-id slot, `github:`-namespaced (eve
74
+ // prefixes the channel name again on send — harmless, mirrors Notion).
75
+
76
+ /** The reserved pseudo-thread sentinel for a supervisor generation (mirrors Slack's/Notion's). */
77
+ export function githubSupervisorSentinel(generation: number): string {
78
+ return `oe-sup:g${generation}`;
79
+ }
80
+
81
+ /** The reserved continuation token for a repo's supervisor session at a given generation:
82
+ * `github:<owner>/<repo>:oe-sup:g<generation>`. A custom continuation token eve never
83
+ * re-anchors, carrying the `:oe-sup:g` infix hooks/spend.ts + model/routing.ts key on. */
84
+ export function githubSupervisorToken(owner: string, repo: string, generation: number): string {
85
+ return `github:${owner}/${repo}:${githubSupervisorSentinel(generation)}`;
86
+ }
87
+
88
+ // UNIFIED cross-surface dispatch (formerly TODO(unified-dispatch) — wired 2026-07-11), same shape
89
+ // as the Slack reference wiring (channels/slack-events.ts liveSupervisorDeps): when the repo's
90
+ // domain has opted in (both gates on), the LIVE deps in github.ts key the registry ROW + TOKEN by
91
+ // `(org, domain)` via channels/unified-supervisor.ts supervisorGrainFor/supervisorTokenForGrain, so
92
+ // an issue/PR comment dispatches into the domain's SHARED session, and this core stamps
93
+ // `<channel platform="github">` + the P5 disclosure on the wake (`unified` on the dispatch input).
94
+ // Gated → byte-identical off (grain `{unified:false}` ⇒ the per-repo registry key + token below and
95
+ // an unstamped wake). The cross-surface REPLY leg is per-wake by construction — github.ts's
96
+ // turn.started snapshots owner/repo/issue off THIS delivery's auth on the unified path, so a
97
+ // multi-repo domain session always answers into the repo that woke it (see unified-supervisor.ts's
98
+ // cross-surface-reply header for the whole model + the eve session-namespacing caveat).
99
+
100
+ // --- the classifier (pure) -----------------------------------------------------------
101
+
102
+ /** How the ambient leg treats one comment. By default a bot @mention is owned by eve's NATIVE
103
+ * githubChannel path (github.ts onComment), so the ambient leg `drop`s it and everything it keeps
104
+ * is `channel-watch` (judged by the repo supervisor). The `direct` value exists ONLY for the
105
+ * SINGLE-WINDOW path (github.ts passes `treatMentionAsDirect` for the GRAIN alone — the repo's
106
+ * domain has crossSurfaceWindow on — and passes the operator VOUCH beside it as the privacy floor,
107
+ * never the repo-private flag; see step 4 below): there the native mention session is suppressed,
108
+ * so the ambient leg keeps the @mention and routes it to the unified window as an ADDRESSED wake
109
+ * the shared-context supervisor answers. */
110
+ export type GitHubAmbientAddressing = "direct" | "channel-watch" | "drop";
111
+
112
+ export interface GitHubAmbientClassification {
113
+ readonly addressing: GitHubAmbientAddressing;
114
+ readonly reason: string;
115
+ }
116
+
117
+ /** The comment facts the classifier consumes — a narrow projection of the issue_comment
118
+ * webhook payload (the route leg extracts it; tests build it directly). */
119
+ export interface GitHubAmbientComment {
120
+ readonly body?: string;
121
+ readonly authorLogin?: string;
122
+ /** "User" | "Bot" | "Organization" — GitHub's `comment.user.type`. */
123
+ readonly authorType?: string;
124
+ /** Repo privacy, positively known (payload `repository.private`); absent → unknown. DEMOTED to
125
+ * a logged defense-in-depth SIGNAL: it no longer gates the cross-surface window (the operator
126
+ * vouch does) but still enables the per-repo ambient path (a private repo is invite-only). */
127
+ readonly repoPrivate?: boolean;
128
+ }
129
+
130
+ /**
131
+ * Decide how the ambient leg should treat one issue/PR-timeline comment, in cost order:
132
+ * 1. NO bot slug configured ⇒ DROP (fail closed) — self-identity is a precondition for the
133
+ * loop guard (the same hardening as Notion's unresolved-botUserId drop).
134
+ * 2. BOT-authored (`user.type === "Bot"`) ⇒ DROP — covers our own `<slug>[bot]` replies AND
135
+ * every other bot (prevents bot-to-bot loops).
136
+ * 3. Our own login (`<slug>[bot]`, belt-and-braces under a mislabelled type) ⇒ DROP.
137
+ * 4. @mentions the bot (githubBotMentioned — the native path's EXACT gate):
138
+ * • DEFAULT (per-repo) ⇒ DROP: the native githubChannel session owns mentions; the two legs
139
+ * partition, never overlap.
140
+ * • SINGLE-WINDOW (`treatMentionAsDirect`) on a VOUCHED repo ⇒ `direct`: github.ts
141
+ * suppresses the native session for exactly this case, so the ambient leg keeps the mention
142
+ * and the unified window answers it as an addressed wake. Still DROP on an un-vouched repo —
143
+ * the native path is NOT suppressed there (the gate on both sides is
144
+ * `singleWindow && vouched`, the OPERATOR VOUCH — NOT the repo-private flag), so letting
145
+ * ambient keep it would double-handle.
146
+ * 5. Repo neither positively private NOR operator-vouched ⇒ DROP (fail closed) — drive-by public
147
+ * repos never ambient-wake (strangers must not buy judged turns; see the header).
148
+ * 6. otherwise ⇒ channel-watch (judged by the standing repo supervisor).
149
+ *
150
+ * `opts.vouched` is the OPERATOR-VOUCH fact the caller resolves (github.ts: isVouchedInternal on
151
+ * `github/<owner>` OR `github/<owner>/<repo>`, or OE_TRUST=internal). It is the SOLE floor for the
152
+ * cross-surface WINDOW (step 4's `direct` divert) — the repo's own `repoPrivate` flag is a logged
153
+ * defense-in-depth SIGNAL, never a window gate. `repoPrivate` still enables the per-repo ambient
154
+ * path (step 5): a private repo is invite-only, so it ambient-wakes even un-vouched; the vouch
155
+ * WIDENS ambient to a vouched public repo, and never narrows an already-private one.
156
+ */
157
+ export function classifyGitHubAmbientComment(
158
+ c: GitHubAmbientComment,
159
+ botSlug: string | undefined,
160
+ opts?: { treatMentionAsDirect?: boolean; vouched?: boolean },
161
+ ): GitHubAmbientClassification {
162
+ const slug = botSlug?.trim();
163
+ if (!slug) return { addressing: "drop", reason: "no bot slug configured — fail-closed loop guard" };
164
+ if (c.authorType === "Bot") return { addressing: "drop", reason: "bot-authored comment (loop guard)" };
165
+ if (c.authorLogin && c.authorLogin.toLowerCase() === `${slug}[bot]`.toLowerCase()) {
166
+ return { addressing: "drop", reason: "self-authored comment (our own reply)" };
167
+ }
168
+ const vouched = opts?.vouched === true;
169
+ if (githubBotMentioned(c.body, slug, { login: c.authorLogin, type: c.authorType })) {
170
+ // Single-window VOUCHED repos: the native session is suppressed (github.ts) so the unified
171
+ // window owns the mention as an addressed wake. Every other repo → the native path owns it.
172
+ // The decision is the SHARED seam (unified-window.ts singleWindowMentionPolicy) — the exact
173
+ // predicate the native suppression consults, so the two legs can never double-handle or gap.
174
+ // `isMention` is pinned true here (this branch is inside the githubBotMentioned gate); the
175
+ // privacy floor is the operator VOUCH, NOT the repo-private flag.
176
+ if (singleWindowMentionPolicy(opts?.treatMentionAsDirect === true, true, vouched).addressing === "direct-mention") {
177
+ return { addressing: "direct", reason: "bot @mention on a single-window vouched repo — answered by the unified window" };
178
+ }
179
+ return { addressing: "drop", reason: "bot @mention — owned by the native githubChannel path" };
180
+ }
181
+ // Per-repo ambient enablement: a private repo is invite-only (the existing product rule) OR the
182
+ // operator vouched it internal (widens to a vouched public repo). repoPrivate is now a SIGNAL —
183
+ // it enables ambient but is no longer the window floor (the vouch is, above).
184
+ if (c.repoPrivate !== true && !vouched) {
185
+ return { addressing: "drop", reason: "repo neither positively private nor operator-vouched — fail-closed (no drive-by ambient)" };
186
+ }
187
+ return { addressing: "channel-watch", reason: `ambient comment — judged by the repo supervisor (private=${c.repoPrivate === true}, vouched=${vouched})` };
188
+ }
189
+
190
+ // --- the wake builder (pure) ---------------------------------------------------------
191
+
192
+ export interface GitHubSupervisorWakeInput {
193
+ readonly owner: string;
194
+ readonly repo: string;
195
+ /** The issue (or PR timeline) number the comment landed on — the reply anchor. */
196
+ readonly issueNumber: number;
197
+ /** The issue/PR title (untrusted, payload-supplied) — rendered as a wrapped context line so
198
+ * the judge knows WHAT conversation the comment belongs to. Absent ⇒ no context block. */
199
+ readonly issueTitle?: string;
200
+ /**
201
+ * THE DOMAIN'S ADMISSION POLICY over this wake's CONTEXT-LINE slot (OPE-1800), resolved by NAME by
202
+ * the caller (`seatAdmissionFor`, channels/seat-catch-up.ts) so this core stays config-free —
203
+ * exactly as `engagementTier` is. It judges the ONE line the slot holds: the issue/PR title.
204
+ * 🔴 THE SAME POLICY THE CATCH-UP READ ALREADY APPLIES, over the seat's OTHER read, off the SAME
205
+ * channel key. Since OPE-1857 the catch-up half does not pass a policy at all — `seatCatchUp`
206
+ * resolves the domain's own from `req.channelKey`, which is the key this caller spells here — so
207
+ * the two reads take one rule per domain from one resolver. The docs row promises that, and until
208
+ * OPE-1800 only the catch-up half delivered it.
209
+ * 🔴 THE TITLE LINE IS UNGRADED AND FLOORS AT `unauth`, by construction: this surface has no
210
+ * synchronous roster probe for the person who OPENED the issue, and the wake input carries no
211
+ * identity for them — the same reason `deps.catchUp` is wired with no `trustFor`. So a
212
+ * `members-only` domain reads the comment without the title, which is the fail-closed answer the
213
+ * seam's own header states; `wide` (the default) and `wide-with-bots` admit it unchanged.
214
+ * Absent ⇒ NO admission runs and the slot is byte-identical to the pre-OPE-1800 wake, which is
215
+ * what keeps an older test double compiling (the posture `catchUp` and `recordWake` take).
216
+ */
217
+ readonly admit?: SeatAdmissionFn;
218
+ readonly trigger: {
219
+ readonly id: string;
220
+ readonly authorLogin?: string;
221
+ readonly sentAt: string;
222
+ readonly text: string;
223
+ /**
224
+ * Resolved per-wake trust tier (identity/wake-trust.ts — the audit-wedge F3 assignment). The
225
+ * live dispatch (github.ts ambientDelivery) resolves it off the binding gate + admin rosters.
226
+ *
227
+ * 🔴 REQUIRED, NOT OPTIONAL (OPE-596) — see LinearSupervisorWakeInput.trigger.trust for the
228
+ * whole reason. An upward default is fail-OPEN, and since OPE-577 the tier decides the
229
+ * `<untrusted_external_data>` fence. A caller that cannot resolve a tier states the fail-closed
230
+ * one itself: `relay` for a machine author, `unauth` for an unvouchable human.
231
+ */
232
+ readonly trust: WakeTrust;
233
+ };
234
+ /** Engagement calibration (memory/domain.ts engagementTierFor — GitHub platform-defaults to
235
+ * `reserved`). Resolved by the caller; this core stays config-free. */
236
+ readonly engagementTier?: WakeEngagementTier;
237
+ /** The OUTGOING generation's recorded TAIL (OPE-794), rendered as this first wake's backscroll.
238
+ * Only a FRESH generation's first wake carries one; empty or omitted otherwise. */
239
+ readonly tail?: readonly WakeMessageInput[];
240
+ /** This surface could not read its own conversation since the seat's last wake (core
241
+ * channels/seat-catch-up.ts). Rendered as a trusted `<system-note>`; unset ⇒ no note. */
242
+ readonly catchUpUnavailable?: string;
243
+ // 🔴 NO `unified` (OPE-770). It existed to stamp `<channel platform>` and the P5 disclosure
244
+ // sentence, and both are gone: the render names no surface and the standing block states the
245
+ // cross-surface rule once per session. A GitHub wake is the same wake whether or not its session
246
+ // spans surfaces. The unified GRAIN decision is unaffected — it lives on the dispatch input.
247
+ /** Host-minted wake id (rendered `<wake wake-id=…>`) — SINGLE-WINDOW path only (channels/
248
+ * unified-window.ts): the id the model echoes to `reply` so the host resolves this wake's
249
+ * origin from the map. Unset ⇒ byte-identical (no attribute) — mirrors SlackWakeOpts.wakeId. */
250
+ readonly wakeId?: string;
251
+ /** SINGLE-WINDOW addressed @mention: render
252
+ * the wake as `direct-mention` — the full judged envelope (so the `wake-id` survives for the reply
253
+ * tool, which is the ONLY reply route on the unified channel) but with the ADDRESSED preamble that
254
+ * tells the supervisor it was @mentioned and owes a reply, instead of the silence-biased
255
+ * channel-watch preamble. Unset/false ⇒ the channel-watch ambient wake, byte-identical to today.
256
+ * Only github.ts's single-window direct path sets it (the classifier returned `direct`). */
257
+ readonly directMention?: boolean;
258
+ /**
259
+ * This issue/PR carries an ENGAGED registry row that reaches the seat — the seam's `deliver`
260
+ * verdict floored on `githubEngagedFollowUpReach` (github.ts `engagedFollowUp`). A JUDGED
261
+ * wake then states `follow-up="thread-engaged"`, the envelope's own words for "you are already in
262
+ * this conversation and `stand_down` is how you leave". Slack, Linear and Notion state the same
263
+ * fact through the same shared helper (channels/engaged-wake.ts).
264
+ *
265
+ * 🔴 THE AUDIT ROW ALREADY SAID IT AND THE ENVELOPE DID NOT (OPE-2093). `github.dispatched` reads
266
+ * `addressing=thread-follow` off this verdict (OPE-1752) while the seat read bare ambient
267
+ * activity, so it declined an engaged follow-up with `no_reply` (ship #237, J-ENGAGED-github).
268
+ * It does NOT touch `addressing`: an @mention stays `direct-mention` and carries no follow-up
269
+ * attribute, because it is owed an answer whatever the enrolment says. Unset/false ⇒ no attribute.
270
+ */
271
+ readonly threadEngaged?: boolean;
272
+ }
273
+
274
+ /**
275
+ * Build the NormalizedWake for one repo-supervisor turn: platform "github", containerNoun
276
+ * "repo", locus.channel = `owner/repo`, locus.thread = `#<issueNumber>` (the conversation the
277
+ * comment belongs to), the issue title as an untrusted-wrapped context line, and the comment as
278
+ * the trigger. `channel-watch` (judged) by default; `direct-mention` (addressed, enveloped) when
279
+ * `directMention` is set — the SINGLE-WINDOW @mention path, which keeps the wake-id envelope but
280
+ * swaps the silence-biased watch preamble for the addressed one (see the field doc). renderWake
281
+ * then emits the exact `{message, context}` every OE dispatch sends — the SAME renderer
282
+ * Slack/Notion use, with NO platform branch.
283
+ */
284
+ export function buildGitHubSupervisorWake(input: GitHubSupervisorWakeInput): NormalizedWake {
285
+ const directMention = input.directMention === true;
286
+ // THE SLOT, THEN THE DOMAIN'S ADMISSION POLICY OVER IT ({@link GitHubSupervisorWakeInput.admit} —
287
+ // OPE-1800). `thread` is the container position: on GitHub the issue/PR IS the conversation
288
+ // (locus.thread below), and this line describes it. Nothing admitted ⇒ no context block at all,
289
+ // never an empty one, which is the same `undefined` an absent title produces.
290
+ const titleLine: ContextLine[] | undefined = input.issueTitle
291
+ ? [{ senderType: "user", ts: `#${input.issueNumber}`, text: `issue/PR #${input.issueNumber}: ${input.issueTitle}` }]
292
+ : undefined;
293
+ const admittedTitle = titleLine && input.admit ? admitContextLines(titleLine, input.admit, "thread") : titleLine;
294
+ const contextLines: ContextLine[] | undefined = admittedTitle && admittedTitle.length > 0 ? admittedTitle : undefined;
295
+
296
+ return {
297
+ platform: "github",
298
+ // A direct @mention is reason "mention" (honest, and rides the envelope); an ambient comment is
299
+ // "channel-activity". Both keep the full envelope, so the wake-id survives on the single-window path.
300
+ reason: directMention ? "mention" : "channel-activity",
301
+ addressing: directMention ? "direct-mention" : "channel-watch",
302
+ containerNoun: "repo",
303
+ // engagementTier is the AMBIENT-watch calibration dial (it RAISES the reply bar for a
304
+ // `reserved` repo); an addressed @mention is owed an answer, so it never applies to a
305
+ // direct-mention wake — and is byte-identical (input.engagementTier) on the channel-watch path.
306
+ engagementTier: directMention ? undefined : input.engagementTier,
307
+ // The ENROLMENT fact ({@link GitHubSupervisorWakeInput.threadEngaged}), through the ONE shared
308
+ // helper every enveloped surface calls. The helper drops it on a `direct-mention` wake.
309
+ followUp: engagedFollowUpContract({
310
+ addressing: directMention ? "direct-mention" : "channel-watch",
311
+ threadEngaged: input.threadEngaged,
312
+ }),
313
+ // Single-window wake id — only the singleWindow dispatch passes it (byte-identical off).
314
+ wakeId: input.wakeId,
315
+ locus: {
316
+ channel: { id: `${input.owner}/${input.repo}` },
317
+ thread: { ts: `#${input.issueNumber}` },
318
+ },
319
+ contextLines,
320
+ contextSource: contextLines ? "the issue/PR this comment is on" : undefined,
321
+ backscroll: input.tail && input.tail.length > 0 ? input.tail : undefined,
322
+ ...(input.catchUpUnavailable !== undefined ? { catchUpUnavailable: input.catchUpUnavailable } : {}),
323
+ trigger: mkMessageTrigger({
324
+ from: "human", // self/bot already dropped in classify
325
+ author: input.trigger.authorLogin ?? "unknown",
326
+ authorId: input.trigger.authorLogin,
327
+ id: input.trigger.id,
328
+ sentAt: input.trigger.sentAt,
329
+ // The caller's stated per-person tier — no default (OPE-596). Directness is the `mention`
330
+ // flag below, never a trust tier.
331
+ trust: input.trigger.trust,
332
+ // The @mention response-obligation flag (rendered `mention="true"`). Unset on the ambient
333
+ // path ⇒ no attribute ⇒ byte-identical render.
334
+ mention: directMention ? true : undefined,
335
+ body: input.trigger.text,
336
+ }),
337
+ };
338
+ }
339
+
340
+ // --- the payload extractor (pure) ------------------------------------------------------
341
+
342
+ /** The narrow projection of an `issue_comment` webhook payload the ambient leg consumes —
343
+ * classifier facts + wake parts + identity facts, extracted defensively (GitHub payloads are
344
+ * external input; anything missing/odd → the fact stays undefined and the consumers fail
345
+ * closed). Review-thread inline comments (`pull_request_review_comment`) are deliberately NOT
346
+ * ambient v1 — a different reply endpoint/threading model; the extractor returns null. */
347
+ export interface GitHubAmbientEvent {
348
+ readonly owner: string;
349
+ readonly repo: string;
350
+ readonly repositoryFullName?: string;
351
+ readonly repositoryId?: number;
352
+ readonly repoPrivate?: boolean;
353
+ readonly installationId?: number;
354
+ readonly issueNumber: number;
355
+ readonly issueTitle?: string;
356
+ /** True when the timeline comment is on a PR (issue_comment fires for both). */
357
+ readonly isPullRequest: boolean;
358
+ readonly comment: GitHubAmbientComment & {
359
+ readonly id?: string;
360
+ readonly createdAt?: string;
361
+ readonly authorId?: number;
362
+ readonly authorAssociation?: string;
363
+ };
364
+ }
365
+
366
+ /** The taint-clean identity fields on one admitted ambient dispatch audit row. */
367
+ export function githubDispatchAuditDetail(input: {
368
+ readonly addressing: string;
369
+ readonly issueNumber: number;
370
+ readonly commentId?: string;
371
+ readonly wakeId?: string;
372
+ readonly repoPrivate: boolean;
373
+ readonly vouched: boolean;
374
+ }): Readonly<Record<string, unknown>> {
375
+ return {
376
+ addressing: input.addressing,
377
+ issueNumber: input.issueNumber,
378
+ ...(input.commentId ? { commentId: input.commentId } : {}),
379
+ ...(input.wakeId ? { wakeId: input.wakeId } : {}),
380
+ repoPrivate: input.repoPrivate,
381
+ vouched: input.vouched,
382
+ };
383
+ }
384
+
385
+ function pick(o: unknown, k: string): unknown {
386
+ return o && typeof o === "object" ? (o as Record<string, unknown>)[k] : undefined;
387
+ }
388
+ function str(v: unknown): string | undefined {
389
+ return typeof v === "string" && v.length > 0 ? v : undefined;
390
+ }
391
+ function num(v: unknown): number | undefined {
392
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
393
+ }
394
+
395
+ /**
396
+ * Extract the ambient-leg facts from one webhook delivery, or null when the delivery is not an
397
+ * ambient candidate at all: only `issue_comment` + `action: "created"` qualifies (edits/deletes
398
+ * are not conversational triggers v1; review-thread comments are out of scope v1 — see
399
+ * {@link GitHubAmbientEvent}). Null is "not for this leg", NOT an error — the native mention
400
+ * path and the routine fan-out see every delivery regardless.
401
+ */
402
+ export function extractGitHubAmbientEvent(eventType: string, payload: Record<string, unknown>): GitHubAmbientEvent | null {
403
+ if (eventType !== "issue_comment") return null;
404
+ if (str(pick(payload, "action")) !== "created") return null;
405
+ const repository = pick(payload, "repository");
406
+ const issue = pick(payload, "issue");
407
+ const comment = pick(payload, "comment");
408
+ const owner = str(pick(pick(repository, "owner"), "login"));
409
+ const repo = str(pick(repository, "name"));
410
+ const issueNumber = num(pick(issue, "number"));
411
+ if (!owner || !repo || issueNumber === undefined || !comment) return null;
412
+ const user = pick(comment, "user");
413
+ const isPrivate = pick(repository, "private");
414
+ const commentId = num(pick(comment, "id"));
415
+ return {
416
+ owner,
417
+ repo,
418
+ repositoryFullName: str(pick(repository, "full_name")),
419
+ repositoryId: num(pick(repository, "id")),
420
+ repoPrivate: typeof isPrivate === "boolean" ? isPrivate : undefined,
421
+ installationId: num(pick(pick(payload, "installation"), "id")),
422
+ issueNumber,
423
+ issueTitle: str(pick(issue, "title")),
424
+ isPullRequest: pick(issue, "pull_request") !== undefined,
425
+ comment: {
426
+ id: commentId !== undefined ? String(commentId) : undefined,
427
+ body: str(pick(comment, "body")),
428
+ authorLogin: str(pick(user, "login")),
429
+ authorType: str(pick(user, "type")),
430
+ authorId: num(pick(user, "id")),
431
+ authorAssociation: str(pick(comment, "author_association")),
432
+ createdAt: str(pick(comment, "created_at")),
433
+ repoPrivate: typeof isPrivate === "boolean" ? isPrivate : undefined,
434
+ },
435
+ };
436
+ }
437
+
438
+ // --- the dispatch core (session get-or-create → build → wake → terminal rotate) -------
439
+
440
+ /** The live supervisor session pointer for a repo, as the deps report it (mirrors
441
+ * NotionSupervisorSession). `freshGeneration` = brand-new OR just-rotated. */
442
+ export interface GitHubSupervisorSession {
443
+ readonly token: string;
444
+ readonly generation: number;
445
+ readonly freshGeneration: boolean;
446
+ /** ISO-8601 of the most recent wake this generation delivered (the registry row's `lastWakeAt`),
447
+ * or null on a freshly minted/rotated generation. The lower bound of the since-last-wake catch-up
448
+ * read (OPE-1695 (b), brought to this surface by OPE-1726); absent ⇒ no catch-up. */
449
+ readonly lastWakeAt?: string | null;
450
+ /** The OUTGOING generation's recorded tail, oldest-first — empty unless this is a fresh
451
+ * generation's first wake (OPE-794). */
452
+ readonly tail: readonly WakeMessageInput[];
453
+ }
454
+
455
+ /** Deps for {@link handleGitHubSupervisorDispatch} — mirror NotionSupervisorDeps, injected so
456
+ * the core is unit-testable over a mock store + a capture `wake`. The eve-typed session STATE
457
+ * is built by the wiring (github.ts), not here — this core stays eve-free. */
458
+ export interface GitHubSupervisorDeps {
459
+ /** Create-or-fetch the live supervisor session for this repo's surfaceKey: mint generation 1
460
+ * on first sighting, rotate to generation+1 when {@link shouldRotate} says it aged out, else
461
+ * return the current generation. */
462
+ readonly getOrCreateSession: (surfaceKey: string, owner: string, repo: string, atIso: string) => Promise<GitHubSupervisorSession>;
463
+ /** Record one wake on the current generation (lastWakeAt/turnCount). Best-effort. */
464
+ readonly touchSession?: (surfaceKey: string, atIso: string) => Promise<void>;
465
+ /**
466
+ * Record this delivered wake onto the LIVE generation's TAIL ring (OPE-794). Injected because the
467
+ * entry has to be filed under an ORG and a registry key, and this core deliberately holds neither
468
+ * — the live wiring does. Absent (a unit test) ⇒ nothing is recorded and the dispatch is
469
+ * unchanged, which is the same posture `touchSession` takes.
470
+ */
471
+ readonly recordWake?: (entry: SessionTailWakeRow, token: string, surfaceKey: string) => Promise<void>;
472
+
473
+ /** Start/continue the supervisor session under its reserved token, posting the rendered wake.
474
+ * The wiring closes over the channel `send` and builds the eve-typed supervisor state.
475
+ * `window` is present ONLY on the SINGLE-WINDOW path (dispatch input carried it): the wiring
476
+ * then DIVERTS the wake through eve's cross-channel `receive` into the synthetic `unified`
477
+ * channel (github.ts). Absent ⇒ this channel's own send, byte-identical. */
478
+ readonly wake: (input: {
479
+ token: string;
480
+ auth: OpenEverywhereGitHubAuth;
481
+ message: string;
482
+ context: readonly string[];
483
+ window?: UnifiedWindowWake;
484
+ }) => Promise<void>;
485
+ /**
486
+ * THE SINCE-LAST-WAKE CATCH-UP, through the ONE shared path every surface takes
487
+ * (channels/seat-catch-up.ts `seatCatchUp`). Optional so older test doubles keep compiling
488
+ * (absent ⇒ no catch-up, the pre-OPE-1726 behaviour); fail-soft by construction.
489
+ */
490
+ readonly catchUp?: (req: SeatCatchUpRequest) => Promise<SeatCatchUpResult>;
491
+ /** Clock seam (defaults to the real clock). */
492
+ readonly now?: () => string;
493
+ }
494
+
495
+ /** Everything github.ts hands the supervisor dispatch: the wake parts + identity/session facts.
496
+ * Built by the route leg AFTER the ambient gate + classifier kept the comment. */
497
+ export interface GitHubSupervisorDispatch {
498
+ readonly surfaceKey: string;
499
+ readonly owner: string;
500
+ readonly repo: string;
501
+ readonly issueNumber: number;
502
+ readonly issueTitle?: string;
503
+ readonly auth: OpenEverywhereGitHubAuth;
504
+ /**
505
+ * THE CHAT NON-MEMBER TAINT CARRIAGE input (OPE-494 / OPE-466): the author facts for every
506
+ * message whose bytes enter this wake's context. GitHub sends one comment per wake, so the route
507
+ * leg passes exactly one entry — `githubAuthorFacts` over the free `author_association`.
508
+ *
509
+ * 🔴 REQUIRED, NOT OPTIONAL, and for the reason `stampNonMemberChatAuth` makes its own `room`
510
+ * argument required: an optional field lets a new leg silently lose the author question, and a
511
+ * carriage that is absent looks identical to a carriage that answered "member". A leg with
512
+ * genuinely nothing to say passes `[]` and says so on purpose.
513
+ */
514
+ readonly ingested: readonly IngestedChatMessage[];
515
+ readonly trigger: GitHubSupervisorWakeInput["trigger"];
516
+ /**
517
+ * The domain's admission policy for this surface's context-line slot, resolved by the route leg
518
+ * off the SAME channel key `deps.catchUp` resolves its own from. See
519
+ * {@link GitHubSupervisorWakeInput.admit}.
520
+ * 🔴 REQUIRED, NOT OPTIONAL, and for the reason `ingested` above is: an optional field lets a
521
+ * SECOND route leg compile with the slot unpoliced, and an absent policy looks identical to a
522
+ * policy that admitted. The builder's own `admit` stays optional — that keeps this core
523
+ * config-free for a pure caller — so `required here, optional there` is the whole guard.
524
+ */
525
+ readonly admit: SeatAdmissionFn;
526
+ /** Engagement calibration tier (resolved by the route leg; fail-soft neutral). */
527
+ readonly engagementTier?: WakeEngagementTier;
528
+ /** UNIFIED cross-surface grain resolved by the route leg (supervisorGrainFor, fail-soft false) —
529
+ * stamps the wake's platform address + P5 disclosure. Unset ⇒ byte-identical per-repo wake. */
530
+ readonly unified?: boolean;
531
+ /** SINGLE-WINDOW wake plumbing, minted by the route leg when the grain says `singleWindow`
532
+ * (channels/unified-window.ts): the wakeId is rendered into the envelope by this core; the
533
+ * whole object is passed through to deps.wake, whose wiring registers the map entry and
534
+ * diverts into the unified channel. Unset ⇒ byte-identical. */
535
+ readonly window?: UnifiedWindowWake;
536
+ /** SINGLE-WINDOW addressed @mention (the classifier returned `direct`): render the wake as the
537
+ * addressed `direct-mention` variant (envelope kept, watch-preamble swapped for the addressed
538
+ * one) so the shared-context supervisor answers it. Unset ⇒ the channel-watch ambient wake,
539
+ * byte-identical. See {@link GitHubSupervisorWakeInput.directMention}. */
540
+ readonly directMention?: boolean;
541
+ /** The seam's enrolment verdict for this issue/PR (github.ts `admission.engagedFollowUp`) — the
542
+ * SAME value the `github.dispatched` row promotes to `thread-follow`. Rendered as
543
+ * `follow-up="thread-engaged"` on a judged wake. See {@link GitHubSupervisorWakeInput.threadEngaged}. */
544
+ readonly threadEngaged?: boolean;
545
+ }
546
+
547
+ /**
548
+ * Route one kept comment to the repo supervisor: get-or-create the session, build + render the
549
+ * wake for that generation, and deliver it.
550
+ *
551
+ * A DELIVERY failure (the send itself) propagates to the caller's catch. A session that dies at
552
+ * TURN time does not: nothing here observes it, so the trigger is lost SILENTLY, and no reactive
553
+ * arm can exist (OPE-174 removed the inert one; channels/session-resume.ts holds the measurement).
554
+ * The working remedy is PRE-DISPATCH — the live `getOrCreateSession` claims the OPE-599
555
+ * runtime-change rotation before this call and hands back a session the running eve build can read.
556
+ */
557
+ export async function handleGitHubSupervisorDispatch(d: GitHubSupervisorDispatch, deps: GitHubSupervisorDeps): Promise<void> {
558
+ const atIso = deps.now ? deps.now() : new Date().toISOString();
559
+ const session = await deps.getOrCreateSession(d.surfaceKey, d.owner, d.repo, atIso);
560
+
561
+ const doWake = async (token: string, generation: number, tail: readonly WakeMessageInput[], catchUpUnavailable?: string) => {
562
+ const nw = buildGitHubSupervisorWake({
563
+ owner: d.owner,
564
+ repo: d.repo,
565
+ issueNumber: d.issueNumber,
566
+ issueTitle: d.issueTitle,
567
+ admit: d.admit,
568
+ trigger: d.trigger,
569
+ engagementTier: d.engagementTier,
570
+ wakeId: d.window?.wakeId,
571
+ directMention: d.directMention,
572
+ threadEngaged: d.threadEngaged,
573
+ tail,
574
+ catchUpUnavailable,
575
+ });
576
+ const { message, context } = renderWake(nw, deps.now);
577
+ // OPE-466 CARRIAGE, wired here by OPE-494. The wake renders its envelope in the INGRESS process,
578
+ // outside any turn, so the taint half of `ingestUntrusted` no-ops and the reading turn would
579
+ // otherwise run with a CLEAN signal over an outside contributor's bytes. The stamp is the same
580
+ // host-set, model-unforgeable auth attribute Slack, Discord, Teams and Notion already carry.
581
+ //
582
+ // POSITION: inside `doWake`, so BOTH delivery routes get it — this channel's own `send` and the
583
+ // single-window divert (github.ts hands `deps.wake` the auth for either). The room leg reads the
584
+ // OPERATOR's trust declaration for the repo; the author leg reads `d.ingested`. On an unmarked
585
+ // wake the input auth returns BY REFERENCE, so no existing golden moves.
586
+ const auth = stampNonMemberChatAuth(d.auth, d.ingested, { surfaceKey: d.surfaceKey });
587
+ await deps.wake({ token, auth, message, context: context ?? [], window: d.window });
588
+ };
589
+
590
+ // A wake failure PROPAGATES to the caller's catch. (OPE-174 removed a terminal-session-death
591
+ // force-rotate branch here: it could never fire — a conversation-mode session PARKS at eve's
592
+ // token bound rather than failing, and a turn-time error cannot reject the awaited enqueue
593
+ // anyway. The reachable handler is core's agent/channels/session-park.ts.)
594
+ // 🔴 EVERY WAKE READS THE CONVERSATION SINCE THE SEAT LAST LOOKED (OPE-1695 (b), brought to GitHub
595
+ // by OPE-1726). A FRESH generation renders its carried tail, as it always did; an ONGOING
596
+ // generation reads the timeline comments this issue/PR collected since its own `lastWakeAt`,
597
+ // through the ONE shared catch-up path — same bounds, same admission policy, same cap as Slack's.
598
+ // FAIL-SOFT: the shared path never throws, and an unregistered fetch comes back as the UNAVAILABLE
599
+ // note rather than as a silent empty context.
600
+ const catchUp =
601
+ session.freshGeneration || !deps.catchUp
602
+ ? undefined
603
+ : await deps.catchUp({
604
+ container: `${d.owner}/${d.repo}#${d.issueNumber}`,
605
+ sinceMs: seatCatchUpSinceMs(session.lastWakeAt, Date.parse(atIso) || Date.now(), GITHUB_CATCH_UP_REACHBACK_HOURS),
606
+ untilMs: Date.parse(d.trigger.sentAt) || Date.parse(atIso) || Date.now(),
607
+ excludeId: d.trigger.id,
608
+ channelKey: d.surfaceKey,
609
+ });
610
+ await doWake(session.token, session.generation, session.freshGeneration ? session.tail : (catchUp?.messages ?? []), catchUp?.unavailable);
611
+ if (deps.touchSession) await deps.touchSession(d.surfaceKey, atIso);
612
+ if (deps.recordWake) {
613
+ // AFTER the wake landed, and only the TRIGGER — never the backscroll rendered beside it. That is
614
+ // what stops a tail inheriting a tail (channels/session-tail.ts).
615
+ await deps.recordWake(
616
+ {
617
+ kind: "wake",
618
+ at: atIso,
619
+ from: "human",
620
+ trust: d.trigger.trust,
621
+ // THE AUTHOR GRAIN (OPE-1747) — `d.ingested[0]` is the TRIGGER's own facts: GitHub sends one
622
+ // comment per wake, so the route leg passes exactly one entry (see `ingested`'s own header),
623
+ // and `stampNonMemberChatAuth` above joins over that same entry. Resolved through the one core
624
+ // road, so this row and the join cannot disagree. A leg that passed `[]` says so on purpose, and
625
+ // the row then records `refuted: false` — the SAME answer the live wake acted on, not the
626
+ // UNKNOWN an absent field means. UNKNOWN is reserved for a row persisted before this field
627
+ // existed: recording it here would withhold, in the next generation, a body this wake delivered.
628
+ ...tailAuthorGrain(d.ingested[0] ?? { trust: d.trigger.trust }),
629
+ author: d.trigger.authorLogin ?? "unknown",
630
+ ...(d.trigger.authorLogin ? { authorId: d.trigger.authorLogin } : {}),
631
+ id: d.trigger.id,
632
+ platform: "github",
633
+ container: d.surfaceKey,
634
+ threadRoot: `#${d.issueNumber}`,
635
+ text: d.trigger.text,
636
+ },
637
+ session.token,
638
+ d.surfaceKey,
639
+ );
640
+ }
641
+ }
642
+
643
+ // Re-export shouldRotate so the live wiring (github.ts) drives get-or-create/rotate with the
644
+ // SAME pure policy Slack/Notion use, without importing slack-events itself.
645
+ export { shouldRotate };