@aivi/tracker-github 0.0.0-stage → 0.0.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.
package/dist/setup.js ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The setup `aivi add @aivi/tracker-github` runs: what the GitHub tracker
3
+ * specifically lives on lives in this file.
4
+ *
5
+ * The app's identity is the home's (D3), and the guide and its two questions
6
+ * are `@aivi/github`'s (`appIdentity`), shared with the forge's installer:
7
+ * when `<home>/.env` already holds the id and key — the forge was installed
8
+ * first, or this installer wrote them — nothing is asked, because the forge
9
+ * and the tracker are **one app**.
10
+ *
11
+ * Before anything is written, the truth is read at GitHub with the app's own
12
+ * JWT: that the grant allows Issues and Projects (the desk and the board),
13
+ * that the webhook points at this machine and subscribes to `issue_comment`,
14
+ * `issues` and `projects_v2_item`, and what the last delivery was answered.
15
+ * Then the webhook secret — the home's when it holds one, else pasted — is
16
+ * proved by one wait: a delivery must land at this aivi's route and verify
17
+ * against it. The trigger is a replay of the history's newest `installation`
18
+ * delivery — GitHub posts that event when a person installs or approves the
19
+ * app. When the history holds none — the app was installed before this
20
+ * webhook pointed here — or GitHub refuses the replay, **no wait runs**: a
21
+ * trigger that cannot come is never sat out (ruled 2026-10-20). The person
22
+ * reinstalls the app and retries, or skips the wait, and UNVERIFIED is said.
23
+ *
24
+ * The health gate is a choice, not a stop: no live aivi, the person picks
25
+ * "I started the server. Retry" or "Continue anyway". Continuing writes
26
+ * everything with UNVERIFIED said in the summary — the unverified fact is
27
+ * precisely "GitHub can reach this machine". This installer **writes nothing
28
+ * at GitHub**; the board probe belongs to `aivi projects add`, where the board
29
+ * id is in hand. Performing a restart never belongs to this flow — the CLI
30
+ * owns that, and reports it as it happens.
31
+ */
32
+ import { configSchema, errorMessage, getLogger } from '@aivi/core';
33
+ import { appIdentity, GITHUB_WEBHOOK_SECRET_ENV, GitHubApp } from '@aivi/github';
34
+ import { settled } from '@aivi/plugin';
35
+ import { MODULE_ID } from './config.js';
36
+ import { gateHost, hostIsUp, INSTALLATION_EVENTS, latestId, probeWindowMs, waitDiary } from './probe.js';
37
+ import { GITHUB_WEBHOOK_PATH } from './tracker.js';
38
+ /** What the app's webhook must subscribe to, each with what the tracker would
39
+ * never hear again without it. */
40
+ const REQUIRED_EVENTS = {
41
+ issue_comment: 'so a person’s comment can reach aivi',
42
+ issues: 'so aivi can hear about ticket changes',
43
+ projects_v2_item: 'so aivi can hear about board moves',
44
+ };
45
+ /** How far back the replay hunt looks for an installation delivery: one cheap
46
+ * request, and the grace an app installed a while ago deserves — the newest
47
+ * 10 were often all traffic since the install, and the install itself had
48
+ * scrolled out (ruled 2026-10-20). */
49
+ const WEBHOOK_HISTORY = 25;
50
+ /** Two webhook URLs compared as what they point at: GitHub stores the string
51
+ * a person typed, and a trailing slash is nobody's intent. */
52
+ const trimSlash = (url) => url.replace(/\/+$/, '');
53
+ const setup = async (ctx) => {
54
+ const log = getLogger(['aivi', MODULE_ID]);
55
+ const config = configSchema.parse(ctx.config);
56
+ const existingBlock = ctx.config.plugins?.[MODULE_ID];
57
+ // 1. The live aivi, asked as a choice. The waits below watch what a running
58
+ // aivi records; with none running the person retries or continues
59
+ // unverified — the installer's own configuration still gets written.
60
+ const verify = await gateHost(ctx.prompts, () => hostIsUp(ctx.fetch, config.host));
61
+ // 2. The URL GitHub can reach this aivi at, asked once and saved.
62
+ let base = config.host.public;
63
+ if (!base) {
64
+ ctx.prompts.note([
65
+ 'GitHub posts webhooks to this aivi, so it needs a URL GitHub can reach.',
66
+ 'A tunnel is the usual answer — a Tailscale Funnel or similar pointed at aivi.',
67
+ 'aivi cannot discover this URL itself, so the setup asks once and saves it;',
68
+ 'later runs will not ask.',
69
+ ].join('\n'), 'GitHub reaches aivi');
70
+ base = settled(ctx, await ctx.prompts.text({
71
+ message: 'The public URL GitHub can reach this aivi at',
72
+ placeholder: 'e.g. https://my-machine.ts.net',
73
+ validate: value => value !== undefined && /^https?:\/\//.test(value)
74
+ ? value.endsWith('/')
75
+ ? 'no trailing /'
76
+ : undefined
77
+ : 'an http(s) URL, like https://my-machine.ts.net',
78
+ }));
79
+ }
80
+ const webhookUrl = `${base}${GITHUB_WEBHOOK_PATH}`;
81
+ // 3. The app's identity — the home's, shared with the forge (D3). The guide
82
+ // and its two questions are @aivi/github's, so one app is asked for once
83
+ // however the two plugins were installed; when the home already holds the
84
+ // identity, nothing is asked.
85
+ ctx.prompts.note([
86
+ 'aivi works on GitHub as its own app, never as you: it comments,',
87
+ 'moves board items, and labels as the bot, so the work in front of',
88
+ 'a reviewer is aivi’s and the credit is yours.',
89
+ ].join('\n'), 'The GitHub app aivi works as');
90
+ const credentials = await appIdentity(ctx, {
91
+ webhook: { url: webhookUrl, events: 'Issue comment, Issues, and Projects v2 item' },
92
+ });
93
+ // The proof below runs in this process, which read `.env` before any key was
94
+ // in it: hand the credential to the connection directly, so what is tested
95
+ // here is the bytes that were just written.
96
+ const app = await GitHubApp.connect({ log, credentials });
97
+ await ctx.prompts.log.message(`The key is app ${app.appSlug}’s, installed on ${app.accountLogin} as installation #${app.installationId}.`);
98
+ // 4. What the grant says it allows, read with the app's own JWT. A tracker
99
+ // that went on past a missing permission would fail at the first lane
100
+ // move with someone else's error message.
101
+ const grant = await app.grantFacts();
102
+ const lacking = [];
103
+ if (grant.permissions.issues !== 'write')
104
+ lacking.push(`Issues: ${grant.permissions.issues ?? 'no access'} (aivi needs Read and write)`);
105
+ if (grant.permissions.organization_projects !== 'write')
106
+ lacking.push(`Projects: ${grant.permissions.organization_projects ?? 'no access'} (aivi needs Read and write)`);
107
+ if (lacking.length)
108
+ throw new Error(`installation #${app.installationId} cannot run the tracker: ${lacking.join('; ')}. ` +
109
+ 'GitHub sends an email asking you to approve new permissions; you can also open the organization’s Settings, choose "GitHub Apps" under "Third-party Access", press Configure, and approve there. Saving new permissions on the app page alone keeps the old grant.');
110
+ const unheard = Object.keys(REQUIRED_EVENTS).filter(event => !grant.events.includes(event) && !grant.events.includes('*'));
111
+ if (unheard.length)
112
+ throw new Error(`the app’s webhook does not subscribe ${unheard.map(event => `${event} (${REQUIRED_EVENTS[event]})`).join('; ')}. ` +
113
+ 'Tick them under "Subscribe to events" on the app’s settings page.');
114
+ // 5. Where the hook points — also the app's own JWT. A URL that points at
115
+ // another machine is said now, not waited out there.
116
+ const hook = await app.webhookConfig();
117
+ if (!hook.url)
118
+ throw new Error(`the app posts nowhere: put ${webhookUrl} in the "Webhook URL" on the app’s settings page, with the events above, and run this again.`);
119
+ if (trimSlash(hook.url) !== trimSlash(webhookUrl))
120
+ throw new Error(`the app posts to ${hook.url}, not ${webhookUrl}. Deliveries to a different address are not aivi’s to read: point the Webhook URL here and run this again.`);
121
+ if (hook.insecureSsl)
122
+ await ctx.prompts.log.message('GitHub downgraded the webhook to SSL — deliveries skip full TLS verification. Check the certificate on the public URL.');
123
+ const history = await app.webhookDeliveries(WEBHOOK_HISTORY);
124
+ const last = history[0];
125
+ if (!last)
126
+ await ctx.prompts.log.message('The webhook has delivered nothing yet — the wait below is its first.');
127
+ else if (last.statusCode >= 200 && last.statusCode < 300)
128
+ await ctx.prompts.log.message(`The last delivery (${last.event}) was answered ${last.statusCode}.`);
129
+ else
130
+ await ctx.prompts.log.message(`The last delivery (${last.event}) was answered ${last.statusCode} — before a restart the route is not listening yet, so this is expected; after one it is the thing to chase.`);
131
+ // 6. The secret — the home's when it holds one (D3's rule again: the module
132
+ // reads this variable from the environment anyway, so asking again would
133
+ // only invite a paste that disagrees with the bytes the server verifies
134
+ // with). The wait below proves it either way, pasted or held.
135
+ const heldSecret = process.env[GITHUB_WEBHOOK_SECRET_ENV];
136
+ let webhookSecret;
137
+ if (heldSecret) {
138
+ webhookSecret = heldSecret;
139
+ await ctx.prompts.log.message('The home already holds the webhook’s secret — nothing is asked, and the wait below proves it.');
140
+ }
141
+ else {
142
+ webhookSecret = settled(ctx, await ctx.prompts.password({
143
+ message: 'The webhook’s Secret, from the app’s settings page',
144
+ validate: value => (value ? undefined : 'Paste the Secret from the webhook section'),
145
+ }));
146
+ }
147
+ let probe;
148
+ if (verify)
149
+ probe = await probeInstallation(ctx, app, webhookUrl, webhookSecret, history);
150
+ // 7. Only now is anything written: `host.public` if the installer asked for
151
+ // it, the webhook secret when the home did not hold one, and the
152
+ // `tracker-github` block — empty by design, its fields all have defaults;
153
+ // the byte only marks the tracker as configured. Nothing was written at
154
+ // GitHub: the board probe lives in `aivi projects add`, where the board
155
+ // id is in hand.
156
+ if (!config.host.public)
157
+ await ctx.writeConfigBlock(['host', 'public'], base);
158
+ if (!heldSecret)
159
+ await ctx.writeSecret(GITHUB_WEBHOOK_SECRET_ENV, webhookSecret);
160
+ await ctx.writeConfigBlock(['plugins', MODULE_ID], { ...existingBlock });
161
+ const outcome = probe
162
+ ? probe.pass
163
+ ? `${probe.detail}. Restart aivi to load the tracker`
164
+ : `FAILED — ${probe.detail}. Fix that and run the install again`
165
+ : `UNVERIFIED — ${verify ? 'the webhook wait was skipped' : 'no live aivi watched'}, so “GitHub can reach this machine” was not proved. Restart aivi to load the tracker`;
166
+ return {
167
+ module: MODULE_ID,
168
+ summary: `The GitHub tracker is configured: ${outcome}.`,
169
+ };
170
+ };
171
+ /** The fork when no trigger can come: the person reinstalls the app (the
172
+ * install posts `installation`; lighter, toggling a repository on the
173
+ * installation page posts `installation_repositories`) and retries, or skips
174
+ * the wait — the configuration is still written, with UNVERIFIED said in the
175
+ * summary. Cancelled is no answer: the flow stops, as at the health gate. */
176
+ async function forkWebhook(ctx, message) {
177
+ const choice = await ctx.prompts.select({
178
+ message,
179
+ options: [
180
+ { value: 'retry', label: 'I reinstalled the app. Try again.' },
181
+ { value: 'skip', label: 'Skip webhook verification' },
182
+ ],
183
+ });
184
+ if (ctx.prompts.isCancel(choice))
185
+ throw new Error('the setup was cancelled at the webhook fork');
186
+ return choice === 'skip' ? 'skip' : 'retry';
187
+ }
188
+ /** The one wait: a delivery must land at this aivi's route and verify against
189
+ * the secret aivi holds. The trigger is a replay of the history's newest
190
+ * installation delivery (the replay carries the app's current secret, so a
191
+ * replay that verifies proves the held secret). When the history holds none,
192
+ * or GitHub refuses the replay, **no wait starts**: an app installed before
193
+ * this webhook pointed here would sit the window on a delivery that can
194
+ * never land (ruled 2026-10-20) — the person forks instead. A delivery that
195
+ * arrives about anything else while the wait runs still proves the URL and
196
+ * the secret, and is said as the verdict too — silence is the only failure
197
+ * that is waited out. `undefined` is the skip: no verdict, because nothing
198
+ * was watched. */
199
+ async function probeInstallation(ctx, app, webhookUrl, webhookSecret, history) {
200
+ const windowMs = probeWindowMs();
201
+ let deliveries = history;
202
+ for (;;) {
203
+ const replay = deliveries.find(delivery => INSTALLATION_EVENTS.has(delivery.event));
204
+ if (!replay) {
205
+ const choice = await forkWebhook(ctx, 'The GitHub app was not installed recently.');
206
+ if (choice === 'skip')
207
+ return undefined;
208
+ // The reinstall posted its own delivery — the hunt re-pulls the history.
209
+ deliveries = await app.webhookDeliveries(WEBHOOK_HISTORY);
210
+ continue;
211
+ }
212
+ const spinner = ctx.prompts.spinner();
213
+ spinner.start('webhooks: waiting for a delivery to land…');
214
+ // `undefined`: GitHub refused the replay, so no wait started — the fork
215
+ // below decides, because waiting out the window on a refused replay only
216
+ // re-asks a question GitHub already answered.
217
+ let refused;
218
+ const posted = await ctx.withStore(async (store) => {
219
+ // The cursor is taken **before** the trigger: a replay that lands in the
220
+ // seconds before the store opens must still be ahead of the mark, and
221
+ // whatever the diary already held is nobody's business.
222
+ const cursor = latestId(store);
223
+ try {
224
+ await app.redeliverWebhook(replay.id);
225
+ spinner.message('Re-asked GitHub for its own installation delivery — waiting for the replay to land…');
226
+ }
227
+ catch (error) {
228
+ refused = errorMessage(error);
229
+ return undefined;
230
+ }
231
+ return waitDiary(store, {
232
+ path: GITHUB_WEBHOOK_PATH,
233
+ secret: webhookSecret,
234
+ windowMs,
235
+ accepts: (event, payload) => INSTALLATION_EVENTS.has(event) && payload?.action !== 'deleted',
236
+ about: () => true, // every delivery about this URL proves reach and secret; names are for the live line
237
+ }, cursor, line => spinner.message(line));
238
+ });
239
+ if (posted === undefined) {
240
+ spinner.cancel(`webhooks — the replay was refused (${refused})`);
241
+ const choice = await forkWebhook(ctx, `GitHub refused to replay its installation delivery (${refused}).`);
242
+ if (choice === 'skip')
243
+ return undefined;
244
+ deliveries = await app.webhookDeliveries(WEBHOOK_HISTORY);
245
+ continue;
246
+ }
247
+ if (posted.mine) {
248
+ const detail = 'the installation delivery arrived and verified';
249
+ spinner.stop(`webhooks — ${detail}`);
250
+ return { name: 'webhooks', pass: true, detail };
251
+ }
252
+ if (posted.others) {
253
+ const detail = 'deliveries arrived and verified — the URL and the secret are proved, though no installation event among them';
254
+ spinner.stop(`webhooks — ${detail}`);
255
+ return { name: 'webhooks', pass: true, detail };
256
+ }
257
+ const detail = `nothing arrived in ${windowMs / 1000} s — the Webhook URL on the app is not ${webhookUrl}, the events are off, or GitHub cannot reach this machine`;
258
+ spinner.cancel(`webhooks — ${detail}`);
259
+ return { name: 'webhooks', pass: false, detail };
260
+ }
261
+ }
262
+ export default setup;
@@ -0,0 +1,174 @@
1
+ /** GitHub's **platform**: the `Platform` contract of `@aivi/plugin/tracker`
2
+ * spelled in GitHub's words. It owns everything GitHub-shaped below the
3
+ * neutral surface — the one app and its credential (through `@aivi/github`),
4
+ * the webhook route and its seal, the board reads and the `Agent` field, the
5
+ * status comment and its hidden state (D6, D7) — and translates at the
6
+ * border: deliveries become `TrackerEvent`s, neutral updates become GitHub
7
+ * mutations. The module's decision code imports **no** GitHub types through
8
+ * this file.
9
+ *
10
+ * **Conversations** are the adapter's own affair, and GitHub's are the
11
+ * ticket and its status comment (no agent session exists for an app — D5):
12
+ *
13
+ * - `github` — the app's own feed: desk calls and notices, no session.
14
+ * - `github:owner/repo#47` — a person brought the ticket: the **assistant's**
15
+ * conversation, the ticket itself.
16
+ * - `github:owner/repo#47:c123` — a **run's** conversation: the status
17
+ * comment `startSession` created (D6). Its body carries the machine state
18
+ * as a hidden JSON comment (D7); visible words, plan checklist, and the
19
+ * hidden line are all one edited comment.
20
+ */
21
+ import { type LoadedConfig, type Logger } from '@aivi/core';
22
+ import type { PublicRequest, PublicRoutes } from '@aivi/plugin';
23
+ import type { RunPlan, RunQuestion } from '@aivi/plugin/run';
24
+ import type { Platform, TrackerChange, TrackerComment, TrackerCommentKind, TrackerEvent, TrackerIssue, TrackerProgressLine, TrackerState, TrackerUpdate } from '@aivi/plugin/tracker';
25
+ import type { GithubClient } from './client.ts';
26
+ import { type GithubTrust } from './webhook.ts';
27
+ /** The app's own feed: desk calls and notices speak here — there is no
28
+ * session to name, and one app speaks for the home. */
29
+ export declare const GITHUB_FEED = "github";
30
+ export interface StatusBody {
31
+ head: string;
32
+ planLines: string[];
33
+ meta: Record<string, unknown>;
34
+ }
35
+ /** Read a status comment's body into its parts. A body with no hidden line
36
+ * (a person edited it away) still reads: the state defaults to a standing
37
+ * status, and the words are whatever stands. */
38
+ export declare function parseStatus(body: string): StatusBody;
39
+ /** The whole body from its parts: head line, plan section when there is
40
+ * one, and the hidden line last. */
41
+ export declare function renderStatus(head: string, planLines: string[], meta: Record<string, unknown>): string;
42
+ export declare class GithubPlatform implements Platform {
43
+ readonly id = "tracker-github";
44
+ private readonly loaded;
45
+ readonly client: GithubClient;
46
+ private readonly webhookSecret;
47
+ private readonly log;
48
+ private readonly deliveries;
49
+ private readonly sinks;
50
+ /** The divider line for comments (ruled 2026-10-07): who GitHub says may
51
+ * talk. Read per delivery from `author_association`; an untrusted comment
52
+ * is nothing — no event, no reply, no log line. */
53
+ private readonly trust;
54
+ /** Which board node ids aivi speaks, by board number — warmed at start.
55
+ * A `projects_v2_item` delivery names its board by node id and nothing
56
+ * else; a board nobody configured is noise and says nothing. */
57
+ private readonly boardNodes;
58
+ constructor(loaded: LoadedConfig, client: GithubClient, webhookSecret: string, log?: Logger, trust?: GithubTrust);
59
+ /** Resolve every configured board's fields once, at module start: it says
60
+ * of a missing `Status` or `Agent` field before any walk asks, and it
61
+ * teaches the delivery filter which boards are ours. */
62
+ warm(): Promise<void>;
63
+ /** Which board speaks for a repository's issues, and for a ticket by way
64
+ * of its repository. */
65
+ private boardOfRepo;
66
+ private boardOf;
67
+ laneStates(_conversation: string, teamId: string): Promise<TrackerState[]>;
68
+ /** `createStates` is deliberately absent (D4): lanes map onto the board's
69
+ * own Status options, and aivi never creates or renames one — the human's
70
+ * board is not aivi's to edit. */
71
+ apply(_conversation: string, issueId: string, update: TrackerUpdate): Promise<void>;
72
+ issue(_conversation: string, issueId: string): Promise<TrackerIssue>;
73
+ assign(_conversation: string, issueId: string, userId: string): Promise<void>;
74
+ unassign(_conversation: string, issueId: string): Promise<void>;
75
+ /** Open the run's conversation (D6): one status comment, the first word
76
+ * visible and the machine state hidden under it. Its id, with the ticket
77
+ * on it, is the conversation handle the module records. */
78
+ startSession(_conversation: string, issueId: string): Promise<string | null>;
79
+ idFor(sessionId: string): string;
80
+ parts(conversation: string): {
81
+ app: string;
82
+ session: string;
83
+ };
84
+ orgOf(_conversation: string): Promise<string>;
85
+ /** Who aivi is on a ticket: the `Agent` field's option named for the app
86
+ * (D5) — the same word on every board; per-board option ids are the
87
+ * client's resolution detail. */
88
+ ownerOf(_conversation: string): string;
89
+ /** One message into a conversation, and the conversation decides what a
90
+ * transient word **is**: inside a run it edits the status comment (no
91
+ * second comment may linger — transient by contract); on a ticket it is
92
+ * the assistant's placeholder, a real comment the engine may edit by id
93
+ * and delete when the answer lands. */
94
+ comment(conversation: string, text: string, kind: TrackerCommentKind): Promise<string | undefined>;
95
+ /** Replace the status comment's line — the only edit a progress word may
96
+ * make. A frozen comment (the closing landed) is left as it stands: the
97
+ * closing is the last word, and a trailing in-flight edit never speaks
98
+ * after it. */
99
+ progress(conversation: string, line: TrackerProgressLine): Promise<void>;
100
+ /** A run that died before it had a conversation still speaks on the
101
+ * ticket: silence is not an answer (ruled 2026-10-02). */
102
+ notify(_conversation: string, issueId: string, text: string): Promise<void>;
103
+ /** Has this run's result been rendered? GitHub keeps no session to ask:
104
+ * the answer is a comment on the ticket whose hidden line says
105
+ * `kind: closing` **and names this run's status comment** — so a catch-up
106
+ * says each run's result once, and a second run on the same ticket is a
107
+ * different conversation with its own result. */
108
+ resultShown(conversation: string): Promise<boolean>;
109
+ /** Put the worker's question to the people (required of every tracker):
110
+ * GitHub Issues renders a numbered comment; the answer arrives as an
111
+ * ordinary comment and the open OpenCode form tells it from chatter. */
112
+ ask(conversation: string, question: RunQuestion): Promise<void>;
113
+ /** Show the worker's plan: the whole checklist, replacing what stood, in
114
+ * the status comment's own plan section. */
115
+ plan(conversation: string, plan: RunPlan): Promise<void>;
116
+ ticketComments(_conversation: string, issueId: string): Promise<TrackerComment[]>;
117
+ addComment(_conversation: string, issueId: string, text: string): Promise<void>;
118
+ editIssue(_conversation: string, issueId: string, changes: {
119
+ title?: string;
120
+ description?: string;
121
+ }): Promise<TrackerIssue>;
122
+ createIssue(_conversation: string, input: {
123
+ teamId: string;
124
+ title: string;
125
+ description?: string;
126
+ lane?: string;
127
+ }): Promise<TrackerIssue>;
128
+ teamLabels(_conversation: string, teamId: string): Promise<string[]>;
129
+ events(sink: (event: TrackerEvent) => Promise<void>): () => void;
130
+ /** The run's final word: a comment of its own, tied to its status comment
131
+ * so `resultShown` can swear each run said its result once. */
132
+ markClosing(conversation: string, text: string, success: boolean): Promise<void>;
133
+ /** Freeze the status comment: the working line goes, the terminal word
134
+ * stands. Idempotent — a retry lands on an already-frozen comment as
135
+ * nothing. */
136
+ freezeStatus(conversation: string, head: string): Promise<void>;
137
+ /** One delivery, whole: the seal first (bytes, not re-serialized JSON),
138
+ * the redelivery filter second, then the acknowledgement — GitHub retries
139
+ * anything but a 200, and a handler that fails later must not invite a
140
+ * replay of what already ran. */
141
+ deliver(request: PublicRequest): Promise<{
142
+ status: number;
143
+ body: unknown;
144
+ }>;
145
+ /** Translate one delivery into normalized events — or into nothing.
146
+ * `ping`, `installation`, board reorderings, bot comments (aivi answering
147
+ * itself), and every action outside the routing set are noise (live,
148
+ * 2026-10-07). */
149
+ private dispatch;
150
+ private emit;
151
+ /** A GitHub issue in neutral words. The lane is the ticket's Status on the
152
+ * board its repository feeds; a repository outside every project has no
153
+ * lane and no delegate, and routing says so rather than inventing one. */
154
+ private neutralIssue;
155
+ /** The ticket and status-comment id behind a run conversation, or the
156
+ * plain error that this conversation is not one. */
157
+ runOf(conversation: string): {
158
+ ref: string;
159
+ commentId: number;
160
+ };
161
+ /** A ticket conversation's ref, if the conversation is one. */
162
+ ticketOf(conversation: string): string | undefined;
163
+ }
164
+ /** The neutral changes an `issues` action carries, or none. `assigned` and
165
+ * `unassigned` are **assignees** — a person's word on GitHub, not the
166
+ * delegate, which lives in the board's `Agent` field (D5). */
167
+ export declare function issueChanges(action: string): TrackerChange[];
168
+ /** The public route GitHub is pointed at — no `/app` tail: the seal and the
169
+ * one app make a second path meaningless. */
170
+ export declare const GITHUB_WEBHOOK_PATH = "/github/webhooks";
171
+ /** Own the route; the platform owns the seal, the dedupe and the words.
172
+ * Failures after the acknowledgement never ride the status: a retry would
173
+ * only replay what already ran. */
174
+ export declare function registerWebhookRoute(routes: PublicRoutes, platform: GithubPlatform, _log?: Logger): () => void;