@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.
@@ -0,0 +1,104 @@
1
+ import type { PluginSetupContext, Store } from '@aivi/plugin';
2
+ export declare const probeWindowMs: () => number;
3
+ /** The events GitHub posts when a person installs the app or approves its
4
+ * grant — the `aivi add` probe's signal, replayed from the delivery history
5
+ * (an older install posts nothing while the installer watches). */
6
+ export declare const INSTALLATION_EVENTS: Set<string>;
7
+ /** One payload as the live line says it, in GitHub's own words, naming the
8
+ * ticket it is about — during a test several issues can be in play. The
9
+ * status our side answered with is left out on purpose: with the module not
10
+ * yet loaded nothing can answer 2xx, and the delivery arriving is the point. */
11
+ /** A webhook payload as the probe reads it: like tracker.ts, payloads are
12
+ * `unknown` until narrowed — the probe only ever asks for these fields.
13
+ * Exported because the waits' signatures name it (fallow private-type-leak). */
14
+ export type ProbePayload = {
15
+ action?: string;
16
+ changes?: {
17
+ field_name?: string;
18
+ field_value?: {
19
+ field_name?: string;
20
+ };
21
+ };
22
+ projects_v2_item?: {
23
+ content_node_id?: string;
24
+ };
25
+ repository?: {
26
+ full_name?: string;
27
+ };
28
+ issue?: {
29
+ number?: number;
30
+ };
31
+ };
32
+ /** Whether the live aivi answers its health endpoint — the same test Linear's
33
+ * installer and the CLI make: `GET /health` on the **listen** URL, whatever
34
+ * its version. A raw-read config.json can lack the schema defaults, so the
35
+ * defaults are filled here: bind 127.0.0.1, port 4100. Pure of prompts, and
36
+ * `up` is re-asked on every retry. */
37
+ export declare function hostIsUp(fetch: (url: string, init?: RequestInit) => Promise<Response>, host: {
38
+ bind?: string | undefined;
39
+ port?: number | undefined;
40
+ } | undefined): Promise<boolean>;
41
+ /** The health gate as a choice (ruled 2026-10-07): silence is not a stop, it
42
+ * is a fork. Retry re-asks; continue skips the waits and says so — the
43
+ * installer still writes its configuration, with UNVERIFIED in the summary. */
44
+ export declare function gateHost(prompts: PluginSetupContext['prompts'], up: () => Promise<boolean>): Promise<boolean>;
45
+ /** The newest diary row id right now. Whatever exists before a test starts is
46
+ * nobody's business; anything the test draws lands with a larger id. */
47
+ export declare const latestId: (store: Store) => number;
48
+ /** What one wait saw in the diary. */
49
+ export interface Diaried {
50
+ /** Verified deliveries this wait accepts — its proof. */
51
+ mine: number;
52
+ /** Verified deliveries about anything else: the URL and secret work; the
53
+ * events ticked on the webhook decide what arrives. */
54
+ others: number;
55
+ /** The diary cursor to hand to the next wait. */
56
+ last: number;
57
+ }
58
+ /**
59
+ * Watch the diary until one verified delivery `accepts` lands, `about` names
60
+ * the thing under test, or `ms` has passed. A wait that passes is not made to
61
+ * sit out its window; the window is what a failure needs. Every verified
62
+ * delivery is said the moment it lands, whatever it names.
63
+ */
64
+ export declare function waitDiary(store: Store, watch: {
65
+ path: string;
66
+ secret: string;
67
+ windowMs: number;
68
+ accepts: (event: string, payload: ProbePayload) => boolean;
69
+ about: (event: string, payload: ProbePayload) => boolean;
70
+ }, cursor: number, say: (line: string) => void): Promise<Diaried>;
71
+ /** Watch for the board probe's signals on one issue until all `asked` have
72
+ * landed or the window passes. Each landing is said the moment it arrives —
73
+ * the live line names what just came in. A signal already landed is never
74
+ * counted twice; the window is what a missing signal needs. */
75
+ export declare function waitForSignals(store: Store, watch: {
76
+ path: string;
77
+ secret: string;
78
+ windowMs: number;
79
+ asked: string[];
80
+ about: (event: string, payload: ProbePayload) => boolean;
81
+ }, cursor: number, say: (line: string) => void): Promise<{
82
+ landed: Set<string>;
83
+ last: number;
84
+ }>;
85
+ /** Whether a delivery is about the probe issue. `issue_comment` names it by
86
+ * `issue.number`; an `issues` delivery carries its number inside `issue`
87
+ * too — GitHub answers no top-level one (the live ledger says so). A
88
+ * `projects_v2_item` delivery names neither repo nor number: its payload
89
+ * carries no `repository` at all (keys: action, changes, installation,
90
+ * organization, projects_v2_item, sender — the live ledger says so), so
91
+ * the content's globally unique node id is the whole referee. Requiring a
92
+ * repository there filtered every board delivery as foreign and the wait
93
+ * sat out its window on signals it already held. */
94
+ export declare const aboutIssue: (repoId: string, number: number, issueNodeId: string) => (event: string, payload: ProbePayload) => boolean;
95
+ /** The board probe's verdict, pure: which signals landed, which did not, and
96
+ * what the missing one means. A signal never paid for (a board with one open
97
+ * Status option cannot show a move) is said as skipped, not as a failure. */
98
+ export declare function boardProbeVerdict(landed: Set<string>, asked: string[], windowMs: number): {
99
+ pass: boolean;
100
+ detail: string;
101
+ };
102
+ /** The remedy line when the health gate's choice was made and the waits were
103
+ * skipped: named once, in the summary, in the same words both installers use. */
104
+ export declare const UNVERIFIED = "UNVERIFIED \u2014 no live aivi watched, so \u201CGitHub can reach this machine\u201D was not proved";
package/dist/probe.js ADDED
@@ -0,0 +1,312 @@
1
+ /** The probe machinery both installers share: the health gate with its choice,
2
+ * and the diary waits. The installer is a second process; it proves a webhook
3
+ * by watching what the **live aivi** records in its request diary — so a live
4
+ * aivi is needed for the waits, and the waits are what the choice is about:
5
+ *
6
+ * ```
7
+ * ● I started the server. Retry
8
+ * ○ Continue anyway — do not verify the webhook
9
+ * ```
10
+ *
11
+ * Continuing writes everything and says UNVERIFIED: the unverified fact is
12
+ * precisely “GitHub can reach this machine”, and it is said that way, never
13
+ * as a claimed success. Every delivery is verified with the module's own seal
14
+ * check before it counts: GitHub signs the raw bytes, and the diary stores
15
+ * what arrived. A delivery that fails the check ends the wait at once — the
16
+ * secret would be wrong for every delivery, so waiting cannot help.
17
+ */
18
+ import { hostUrl } from '@aivi/core';
19
+ import { verifyWebhookSignature } from '@aivi/github';
20
+ /** How long the diary is watched when a check cannot pass: silence is a
21
+ * result too, and only a waited-out silence proves it. Tests shorten the
22
+ * window (AIVI_PROBE_WINDOW_MS, shared with the Linear installer); people
23
+ * get the honest cap. */
24
+ const DEFAULT_WINDOW_MS = 90_000;
25
+ export const probeWindowMs = () => Number(process.env.AIVI_PROBE_WINDOW_MS ?? DEFAULT_WINDOW_MS);
26
+ /** The installer is a second process and file events do not report SQLite's WAL
27
+ * writes (macOS never mentions them, measured 2026-09-26), so the diary is
28
+ * re-read on this quiet interval — one query on an indexed column. */
29
+ const POLL_MS = 2_000;
30
+ /** The events GitHub posts when a person installs the app or approves its
31
+ * grant — the `aivi add` probe's signal, replayed from the delivery history
32
+ * (an older install posts nothing while the installer watches). */
33
+ export const INSTALLATION_EVENTS = new Set(['installation', 'installation_repositories']);
34
+ const describeDelivery = (event, payload) => {
35
+ const p = (payload ?? {});
36
+ const number = p.issue?.number;
37
+ const at = p.repository?.full_name && number !== undefined
38
+ ? `${p.repository.full_name}#${number}`
39
+ : 'something the payload did not name';
40
+ const action = typeof p.action === 'string' ? ` ${p.action}` : '';
41
+ return `${event}${action} on ${at}`;
42
+ };
43
+ /** Whether the live aivi answers its health endpoint — the same test Linear's
44
+ * installer and the CLI make: `GET /health` on the **listen** URL, whatever
45
+ * its version. A raw-read config.json can lack the schema defaults, so the
46
+ * defaults are filled here: bind 127.0.0.1, port 4100. Pure of prompts, and
47
+ * `up` is re-asked on every retry. */
48
+ export async function hostIsUp(fetch, host) {
49
+ const listen = hostUrl({ bind: host?.bind ?? '127.0.0.1', port: host?.port ?? 4100 });
50
+ return fetch(`${listen}/health`, { signal: AbortSignal.timeout(2_000) })
51
+ .then(response => response.ok)
52
+ .catch(() => false);
53
+ }
54
+ /** The health gate as a choice (ruled 2026-10-07): silence is not a stop, it
55
+ * is a fork. Retry re-asks; continue skips the waits and says so — the
56
+ * installer still writes its configuration, with UNVERIFIED in the summary. */
57
+ export async function gateHost(prompts, up) {
58
+ for (;;) {
59
+ if (await up())
60
+ return true;
61
+ const choice = await prompts.select({
62
+ message: 'No live aivi answers its health endpoint — the probe watches what a running aivi records',
63
+ options: [
64
+ { value: 'retry', label: 'I started the server. Retry' },
65
+ { value: 'skip', label: 'Continue anyway — do not verify the webhook' },
66
+ ],
67
+ });
68
+ if (prompts.isCancel(choice))
69
+ throw new Error('the setup was cancelled at the health gate');
70
+ if (choice === 'skip') {
71
+ await prompts.log.message('Continuing unverified: the configuration is written, but “GitHub can reach this aivi” stays unproved.');
72
+ return false;
73
+ }
74
+ }
75
+ }
76
+ /** The newest diary row id right now. Whatever exists before a test starts is
77
+ * nobody's business; anything the test draws lands with a larger id. */
78
+ export const latestId = (store) => {
79
+ let last = 0;
80
+ let rows = store.requests({ sinceId: last });
81
+ while (rows.length) {
82
+ last = rows[rows.length - 1].id;
83
+ rows = store.requests({ sinceId: last });
84
+ }
85
+ return last;
86
+ };
87
+ /** The seal failure line — same words wherever the seal fails: the secret
88
+ * aivi holds is not the secret GitHub sends, and waiting on that changes
89
+ * nothing, so every wait ends at once. */
90
+ const badSeal = (row) => new Error(`a delivery arrived but failed the module's own check: bad signature (HTTP ${row.status}).` +
91
+ ' The secret this aivi holds is not the one GitHub sends — compare <home>/.env with the Secret on the app’s settings page.');
92
+ const verifyRow = (secret, row) => verifyWebhookSignature(secret, Buffer.from(row.body ?? '', 'utf8'), row.headers['x-hub-signature-256'] ?? '');
93
+ /** The seal check every wait runs on every row before anything else. A
94
+ * delivery that fails it ends the wait; the payload is the caller's to
95
+ * parse. */
96
+ function verifiedRow(secret, row) {
97
+ if (!verifyRow(secret, row))
98
+ throw badSeal(row);
99
+ return JSON.parse(row.body ?? '{}');
100
+ }
101
+ /**
102
+ * Watch the diary until one verified delivery `accepts` lands, `about` names
103
+ * the thing under test, or `ms` has passed. A wait that passes is not made to
104
+ * sit out its window; the window is what a failure needs. Every verified
105
+ * delivery is said the moment it lands, whatever it names.
106
+ */
107
+ export async function waitDiary(store, watch, cursor, say) {
108
+ const { path, secret, windowMs, accepts, about } = watch;
109
+ let mine = 0;
110
+ let others = 0;
111
+ let last = cursor;
112
+ await new Promise((settledPromise, failed) => {
113
+ let done = false;
114
+ let poll;
115
+ let expiry;
116
+ const read = () => {
117
+ // `aivi host clear-logs` can empty the diary and restart its ids below
118
+ // our cursor; when that happens, follow the fresh diary, not the mark.
119
+ if (latestId(store) < last)
120
+ last = 0;
121
+ for (const row of store.requests({ method: 'POST', path, sinceId: last })) {
122
+ last = row.id;
123
+ if (row.truncated)
124
+ throw new Error(`a delivery arrived but its body is not readable JSON — the diary cut it off (row ${row.id}) — verification needs the whole body.`);
125
+ const payload = verifiedRow(secret, row);
126
+ const event = row.headers['x-github-event'] ?? '';
127
+ say(`Heard: ${describeDelivery(event, payload)}`); // every delivery, the moment it lands
128
+ if (!about(event, payload)) {
129
+ others++;
130
+ continue;
131
+ }
132
+ // The proof is in: a passed wait is nobody's waiting room, and the
133
+ // stop's own read finishes nothing this loop has left to say.
134
+ if (accepts(event, payload)) {
135
+ mine++;
136
+ stop();
137
+ return;
138
+ }
139
+ }
140
+ };
141
+ const stop = (error) => {
142
+ if (done)
143
+ return;
144
+ done = true;
145
+ if (poll !== undefined)
146
+ clearTimeout(poll);
147
+ if (expiry !== undefined)
148
+ clearTimeout(expiry);
149
+ if (error === undefined) {
150
+ try {
151
+ read(); // the deadline read: complete even if a tick was late
152
+ }
153
+ catch (cause) {
154
+ failed(cause);
155
+ return;
156
+ }
157
+ settledPromise();
158
+ }
159
+ else
160
+ failed(error);
161
+ };
162
+ const tick = () => {
163
+ try {
164
+ read();
165
+ }
166
+ catch (cause) {
167
+ stop(cause);
168
+ return;
169
+ }
170
+ // Self-rescheduling: one pending timer, always, and a slow read
171
+ // cannot pile up behind itself. And a read that ended the wait is
172
+ // never re-armed: no timer outlives the verdict.
173
+ if (done)
174
+ return;
175
+ poll = setTimeout(tick, POLL_MS);
176
+ };
177
+ poll = setTimeout(tick, POLL_MS); // the reliable arrival signal across processes: a re-read
178
+ expiry = setTimeout(() => stop(), windowMs); // the window's end: a known instant
179
+ try {
180
+ read(); // whatever already arrived before the watching began
181
+ }
182
+ catch (cause) {
183
+ stop(cause);
184
+ }
185
+ });
186
+ return { mine, others, last };
187
+ }
188
+ /** The board probe's five signals: the mutations the wizard pays, and what
189
+ * each one's delivery looks like. Names are verdict words; the wizard says
190
+ * which landed and which did not. */
191
+ const BOARD_SIGNALS = {
192
+ 'issue opened': (event, payload) => event === 'issues' && payload?.action === 'opened',
193
+ 'card added': (event, payload) => event === 'projects_v2_item' && payload?.action === 'created',
194
+ 'Status moved': (event, payload) => event === 'projects_v2_item' &&
195
+ payload?.action === 'edited' &&
196
+ (payload?.changes?.field_name ?? payload?.changes?.field_value?.field_name) === 'Status',
197
+ 'Agent set': (event, payload) => event === 'projects_v2_item' &&
198
+ payload?.action === 'edited' &&
199
+ (payload?.changes?.field_name ?? payload?.changes?.field_value?.field_name) === 'Agent',
200
+ 'comment posted': (event, payload) => event === 'issue_comment' && payload?.action === 'created',
201
+ };
202
+ /** Watch for the board probe's signals on one issue until all `asked` have
203
+ * landed or the window passes. Each landing is said the moment it arrives —
204
+ * the live line names what just came in. A signal already landed is never
205
+ * counted twice; the window is what a missing signal needs. */
206
+ export async function waitForSignals(store, watch, cursor, say) {
207
+ const { path, secret, windowMs, asked, about } = watch;
208
+ const landed = new Set();
209
+ let last = cursor;
210
+ await new Promise((settledPromise, failed) => {
211
+ let done = false;
212
+ let poll;
213
+ let expiry;
214
+ const read = () => {
215
+ if (latestId(store) < last)
216
+ last = 0;
217
+ const rows = store.requests({ method: 'POST', path, sinceId: last });
218
+ if (rows.length)
219
+ last = rows[rows.length - 1].id;
220
+ // Newest first: the probe's own deliveries are the freshest rows
221
+ // above the cursor. A truncated row is skipped before parsing — a
222
+ // cut body cannot be parsed, and its seal could never verify.
223
+ for (const row of rows.reverse()) {
224
+ if (row.truncated)
225
+ continue;
226
+ const payload = JSON.parse(row.body ?? '{}');
227
+ const event = row.headers['x-github-event'] ?? '';
228
+ if (!about(event, payload))
229
+ continue; // about another issue: this test is not about it
230
+ if (!verifyRow(secret, row))
231
+ throw badSeal(row);
232
+ say(`Heard: ${describeDelivery(event, payload)}`);
233
+ for (const name of asked) {
234
+ if (!landed.has(name) && BOARD_SIGNALS[name](event, payload)) {
235
+ landed.add(name);
236
+ if (landed.size === asked.length) {
237
+ stop();
238
+ return;
239
+ }
240
+ }
241
+ }
242
+ }
243
+ };
244
+ const stop = (error) => {
245
+ if (done)
246
+ return;
247
+ done = true;
248
+ if (poll !== undefined)
249
+ clearTimeout(poll);
250
+ if (expiry !== undefined)
251
+ clearTimeout(expiry);
252
+ if (error === undefined) {
253
+ try {
254
+ read();
255
+ }
256
+ catch (cause) {
257
+ failed(cause);
258
+ return;
259
+ }
260
+ settledPromise();
261
+ }
262
+ else
263
+ failed(error);
264
+ };
265
+ const tick = () => {
266
+ try {
267
+ read();
268
+ }
269
+ catch (cause) {
270
+ stop(cause);
271
+ return;
272
+ }
273
+ if (done)
274
+ return;
275
+ poll = setTimeout(tick, POLL_MS);
276
+ };
277
+ poll = setTimeout(tick, POLL_MS);
278
+ expiry = setTimeout(() => stop(), windowMs);
279
+ try {
280
+ read();
281
+ }
282
+ catch (cause) {
283
+ stop(cause);
284
+ }
285
+ });
286
+ return { landed, last };
287
+ }
288
+ /** Whether a delivery is about the probe issue. `issue_comment` names it by
289
+ * `issue.number`; an `issues` delivery carries its number inside `issue`
290
+ * too — GitHub answers no top-level one (the live ledger says so). A
291
+ * `projects_v2_item` delivery names neither repo nor number: its payload
292
+ * carries no `repository` at all (keys: action, changes, installation,
293
+ * organization, projects_v2_item, sender — the live ledger says so), so
294
+ * the content's globally unique node id is the whole referee. Requiring a
295
+ * repository there filtered every board delivery as foreign and the wait
296
+ * sat out its window on signals it already held. */
297
+ export const aboutIssue = (repoId, number, issueNodeId) => (event, payload) => event === 'projects_v2_item'
298
+ ? payload?.projects_v2_item?.content_node_id === issueNodeId
299
+ : payload?.repository?.full_name === repoId && payload?.issue?.number === number;
300
+ /** The board probe's verdict, pure: which signals landed, which did not, and
301
+ * what the missing one means. A signal never paid for (a board with one open
302
+ * Status option cannot show a move) is said as skipped, not as a failure. */
303
+ export function boardProbeVerdict(landed, asked, windowMs) {
304
+ const missing = asked.filter(name => !landed.has(name));
305
+ if (!missing.length)
306
+ return { pass: true, detail: `all ${asked.length} deliveries arrived and verified — every seal matched` };
307
+ const detail = `${missing.join(', ')} did not arrive in ${windowMs / 1000} s — the events ticked on the app decide what GitHub posts, and GitHub must reach this machine`;
308
+ return { pass: false, detail };
309
+ }
310
+ /** The remedy line when the health gate's choice was made and the waits were
311
+ * skipped: named once, in the summary, in the same words both installers use. */
312
+ export const UNVERIFIED = 'UNVERIFIED — no live aivi watched, so “GitHub can reach this machine” was not proved';
@@ -0,0 +1,41 @@
1
+ import { type LoadedConfig, type Project, type ProjectLaneInput } from '@aivi/core';
2
+ import type { ProjectGithub } from './config.ts';
3
+ /** The project's GitHub routing as it takes effect: which board speaks for it
4
+ * and which repositories feed it. */
5
+ export declare function projectGithub(loaded: LoadedConfig, projectId: string): ProjectGithub | undefined;
6
+ /** A checkout receives one issue stream, so a repository may feed exactly one
7
+ * project's board. Said for every collision, not just the first. */
8
+ export declare function githubRepoCollisions(loaded: LoadedConfig): string[];
9
+ /** A project checkout and the GitHub routing that claims issues for it. */
10
+ export interface RoutedProject {
11
+ project: Project;
12
+ github: ProjectGithub;
13
+ }
14
+ /** Which aivi project an issue belongs to: by its repository. The board is
15
+ * read off the project's own section, so a ticket on a board the project
16
+ * does not name is not this project's ticket — that question is the board's
17
+ * (`ticketLane`), not the repository's. */
18
+ export declare function projectForIssue(loaded: LoadedConfig, issue: {
19
+ repo: string;
20
+ }): RoutedProject | undefined;
21
+ /**
22
+ * Point a project at its board and repositories, and optionally (re)write its
23
+ * lanes: the section goes to `projects.<id>.tracker-github`, lanes to the
24
+ * project's **core** `lanes` array — the ordered workflow, which core
25
+ * validates and the orchestrator decides moves from. Nothing else in the file
26
+ * is touched; an existing `lanes` stays as it was when none is passed. The
27
+ * written file must hold: the core config must still load, the section must
28
+ * parse against this plugin's own schema, and no repository may feed two
29
+ * projects — if any check fails, the previous bytes are restored and the
30
+ * error stands.
31
+ */
32
+ export declare function writeProjectGithub(configPath: string, id: string, options: {
33
+ board: number;
34
+ repos: string[];
35
+ lanes?: ProjectLaneInput[];
36
+ }): Promise<{
37
+ id: string;
38
+ board: number;
39
+ repos: string[];
40
+ lanes?: ProjectLaneInput[];
41
+ }>;
@@ -0,0 +1,117 @@
1
+ /** The GitHub tracker's project sections: the write path the `aivi projects`
2
+ * wizard uses and the routing the module consults. Repositories are GitHub's
3
+ * own; the **lanes are core's** — this file writes them into the project's
4
+ * core `lanes` array and reads nothing back from it. One board per project,
5
+ * one tracker per project (D1): the orchestrator's registration guard is the
6
+ * fatal one; what is checked here is that a repository feeds one board only.
7
+ */
8
+ import { renameSync, rmSync } from 'node:fs';
9
+ import { readFile, writeFile } from 'node:fs/promises';
10
+ import { loadConfig, PROJECT_ID } from '@aivi/core';
11
+ import { githubProjectSchema } from './config.js';
12
+ /** The module id, which is also the key of this plugin's section under every
13
+ * project and under `projectDefaults`. */
14
+ const SECTION = 'tracker-github';
15
+ /** The `tracker-github` section of a project entry as written — typed, or
16
+ * undefined. Core passes the section through; reading it is this plugin's
17
+ * business. */
18
+ const writtenGithub = (loaded, projectId) => loaded.config.projects[projectId]?.[SECTION];
19
+ /** The `tracker-github` section of `projectDefaults` as written. */
20
+ const writtenDefaults = (loaded) => loaded.config.projectDefaults[SECTION];
21
+ /** The project's GitHub routing as it takes effect: which board speaks for it
22
+ * and which repositories feed it. */
23
+ export function projectGithub(loaded, projectId) {
24
+ const entry = writtenGithub(loaded, projectId);
25
+ if (!entry)
26
+ return undefined;
27
+ const createLane = entry.createLane ?? writtenDefaults(loaded)?.createLane;
28
+ return { board: entry.board, repos: entry.repos, ...(createLane ? { createLane } : {}) };
29
+ }
30
+ /** A checkout receives one issue stream, so a repository may feed exactly one
31
+ * project's board. Said for every collision, not just the first. */
32
+ export function githubRepoCollisions(loaded) {
33
+ const owners = new Map();
34
+ const collisions = [];
35
+ for (const project of Object.keys(loaded.config.projects))
36
+ for (const repo of writtenGithub(loaded, project)?.repos ?? []) {
37
+ const owner = owners.get(repo);
38
+ if (owner && owner !== project)
39
+ collisions.push(`projects.${project}.${SECTION}.repos: ${repo} already feeds project ${owner}; a repository feeds one board`);
40
+ owners.set(repo, project);
41
+ }
42
+ return collisions;
43
+ }
44
+ /** Which aivi project an issue belongs to: by its repository. The board is
45
+ * read off the project's own section, so a ticket on a board the project
46
+ * does not name is not this project's ticket — that question is the board's
47
+ * (`ticketLane`), not the repository's. */
48
+ export function projectForIssue(loaded, issue) {
49
+ for (const project of loaded.projects) {
50
+ if (project.removed)
51
+ continue;
52
+ const github = projectGithub(loaded, project.id);
53
+ if (github?.repos.includes(issue.repo))
54
+ return { project, github };
55
+ }
56
+ return undefined;
57
+ }
58
+ /**
59
+ * Point a project at its board and repositories, and optionally (re)write its
60
+ * lanes: the section goes to `projects.<id>.tracker-github`, lanes to the
61
+ * project's **core** `lanes` array — the ordered workflow, which core
62
+ * validates and the orchestrator decides moves from. Nothing else in the file
63
+ * is touched; an existing `lanes` stays as it was when none is passed. The
64
+ * written file must hold: the core config must still load, the section must
65
+ * parse against this plugin's own schema, and no repository may feed two
66
+ * projects — if any check fails, the previous bytes are restored and the
67
+ * error stands.
68
+ */
69
+ export async function writeProjectGithub(configPath, id, options) {
70
+ if (!PROJECT_ID.test(id))
71
+ throw new Error(`Project id "${id}" must match ${PROJECT_ID}`);
72
+ if (!options.repos.length)
73
+ throw new Error('Set at least one repository');
74
+ const before = await readFile(configPath, 'utf8');
75
+ const raw = JSON.parse(before);
76
+ const projects = (raw.projects ?? {});
77
+ raw.projects = projects;
78
+ const entry = (projects[id] ?? {});
79
+ projects[id] = entry;
80
+ const github = (entry[SECTION] ?? {});
81
+ entry[SECTION] = github;
82
+ github.board = options.board;
83
+ github.repos = options.repos;
84
+ if (options.lanes)
85
+ entry.lanes = options.lanes;
86
+ await writeWhole(configPath, `${JSON.stringify(raw, null, 2)}\n`);
87
+ try {
88
+ const loaded = await loadConfig(configPath);
89
+ githubProjectSchema.parse(github);
90
+ // A checkout receives one issue stream: the repositories just written may
91
+ // not feed another project's board too. Collisions between other projects
92
+ // are not this write's doing; the module's start check says those.
93
+ const mine = new Set(github.repos);
94
+ for (const other of Object.keys(loaded.config.projects)) {
95
+ if (other === id)
96
+ continue;
97
+ const shared = (writtenGithub(loaded, other)?.repos ?? []).filter(repo => mine.has(repo));
98
+ if (shared.length)
99
+ throw new Error(`Repository ${shared.join(', ')} already feeds project ${other}; a repository feeds one board`);
100
+ }
101
+ }
102
+ catch (error) {
103
+ await writeWhole(configPath, before);
104
+ throw error;
105
+ }
106
+ const lanes = entry.lanes;
107
+ return { id, board: options.board, repos: options.repos, ...(lanes ? { lanes } : {}) };
108
+ }
109
+ /** Bytes land whole or not at all: temp next door, rename over the live
110
+ * path. The restore-on-refusal pattern protects against a bad write; the
111
+ * temp-then-rename protects against a half one. */
112
+ async function writeWhole(path, contents) {
113
+ const temp = `${path}.aivi-tmp`;
114
+ rmSync(temp, { force: true });
115
+ await writeFile(temp, contents);
116
+ renameSync(temp, path);
117
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The worker run's **progress stream**: while a run works, its OpenCode
3
+ * session is mirrored into the ticket through the platform's transient
4
+ * progress surface (`Platform.progress`) — GitHub edits the run's status
5
+ * comment in place, the one line that says what the worker is doing right
6
+ * now (ruled 2026-10-02: ephemeral by contract — a replaced line must not
7
+ * linger, and GitHub has no ephemeral activity, so one comment edited is the
8
+ * honest equivalent). The reducer and the renderer are the host's own
9
+ * channel machinery, so the worker's line and a chat placeholder say the
10
+ * same thing in the same words.
11
+ *
12
+ * No polling and no interval: besides the throttle's trailing edge, the
13
+ * only timer waits for the next instant the rendered line changes on its
14
+ * own (the long-turn suffix, the idle notice, an elapsed refresh). The
15
+ * follower pauses while a question awaits a person — nothing happens then —
16
+ * and resumes when the answer lands.
17
+ */
18
+ import type { Logger } from '@aivi/core';
19
+ import { type ProgressClock } from '@aivi/host';
20
+ import type { SessionEvents } from '@aivi/plugin/module';
21
+ import type { Platform } from '@aivi/plugin/tracker';
22
+ export interface RunProgressOptions {
23
+ /** `status` shows the one line; `tools` lets a running tool speak in the line. */
24
+ mode: 'status' | 'tools';
25
+ events: SessionEvents;
26
+ log: Logger;
27
+ /** At most one edit per window: the first at once, later ones coalesced. */
28
+ throttleMs?: number;
29
+ clock?: Partial<ProgressClock>;
30
+ }
31
+ export declare class RunProgress {
32
+ private readonly platform;
33
+ private readonly conversation;
34
+ private readonly sessionId;
35
+ private readonly mode;
36
+ private readonly events;
37
+ private readonly log;
38
+ private readonly throttleMs;
39
+ private readonly clock;
40
+ private state;
41
+ private shown;
42
+ private lastPostAt;
43
+ private unwatch;
44
+ private trailing;
45
+ private change;
46
+ private inflight;
47
+ private following;
48
+ constructor(platform: Platform, conversation: string, sessionId: string, options: RunProgressOptions);
49
+ /** Follow the session: watch and arm. Idempotent; a stopped follower
50
+ * resumes with its history — the elapsed line keeps counting, because
51
+ * the worker did. */
52
+ start(): void;
53
+ /** Say nothing more: unwatch, disarm, let the in-flight edit land. */
54
+ stop(): Promise<void>;
55
+ /** Wait for the next instant the line changes by itself. */
56
+ private arm;
57
+ private schedule;
58
+ /** One edit per visible change — and only when the text actually
59
+ * changed, so the stream says each moment once. A running tool is
60
+ * offered as a tool; a `status`-mode follower keeps it inside the line. */
61
+ private flush;
62
+ }