@miroshnik/elves 0.0.0 → 2026.1018.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 (68) hide show
  1. package/AGENTS.md +310 -0
  2. package/README.md +344 -0
  3. package/bin/ai-dev.ts +7 -0
  4. package/bin/elves.ts +2847 -0
  5. package/bin/shims/timeout +113 -0
  6. package/claude/CLAUDE.md +71 -0
  7. package/dist/github/scripts/github.js +3428 -0
  8. package/dist/spec/scripts/architecture.js +628 -0
  9. package/dist/spec/scripts/eslint-config.js +54 -0
  10. package/dist/spec/scripts/harness-qzb932c8.js +959 -0
  11. package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
  12. package/dist/spec/scripts/harness.js +53 -0
  13. package/dist/spec/scripts/knip-hints.cjs +34 -0
  14. package/docs/ci-concurrency.md +82 -0
  15. package/docs/cloud-sessions.md +203 -0
  16. package/docs/judgment.md +81 -0
  17. package/docs/parallel-checkouts.md +220 -0
  18. package/docs/pr-checks.md +117 -0
  19. package/docs/testing.md +304 -0
  20. package/package.json +70 -1
  21. package/skills/ci-wait/SKILL.md +180 -0
  22. package/skills/ci-wait/scripts/wait-ci.sh +377 -0
  23. package/skills/dashboard/SKILL.md +212 -0
  24. package/skills/dashboard/scripts/dashboard.ts +912 -0
  25. package/skills/dashboard/scripts/sessions.ts +772 -0
  26. package/skills/est/SKILL.md +221 -0
  27. package/skills/est/reference.md +381 -0
  28. package/skills/est/scripts/est.ts +4854 -0
  29. package/skills/github/SKILL.md +206 -0
  30. package/skills/github/locales/en.json +449 -0
  31. package/skills/github/locales/ru.json +449 -0
  32. package/skills/github/reference.md +1073 -0
  33. package/skills/github/scripts/github.ts +4390 -0
  34. package/skills/github/scripts/locale.ts +495 -0
  35. package/skills/github/scripts/package.json +3 -0
  36. package/skills/slot/SKILL.md +145 -0
  37. package/skills/slot/scripts/package.json +3 -0
  38. package/skills/slot/scripts/slot.ts +279 -0
  39. package/skills/spec/SKILL.md +247 -0
  40. package/skills/spec/canon.md +219 -0
  41. package/skills/spec/checks.md +709 -0
  42. package/skills/spec/ci.md +378 -0
  43. package/skills/spec/migration.md +125 -0
  44. package/skills/spec/reference.md +889 -0
  45. package/skills/spec/scripts/architecture.ts +839 -0
  46. package/skills/spec/scripts/c4.ts +184 -0
  47. package/skills/spec/scripts/eslint-config.ts +82 -0
  48. package/skills/spec/scripts/harness.ts +1483 -0
  49. package/skills/spec/scripts/knip-hints.cjs +34 -0
  50. package/skills/spec/scripts/knip-hints.d.cts +17 -0
  51. package/skills/spec/scripts/package-run.ts +22 -0
  52. package/skills/spec/scripts/package.json +3 -0
  53. package/skills/spec/scripts/spec-break.ts +232 -0
  54. package/skills/spec/scripts/spec-claims.ts +198 -0
  55. package/skills/spec/scripts/spec-diff.ts +1111 -0
  56. package/skills/spec/scripts/spec-doc.ts +744 -0
  57. package/skills/spec/scripts/spec-exceptions.ts +233 -0
  58. package/skills/spec/scripts/spec-publish.ts +214 -0
  59. package/skills/spec/scripts/spec-run.ts +235 -0
  60. package/skills/spec/scripts/spec-shard.ts +267 -0
  61. package/skills/spec/scripts/speclib.ts +826 -0
  62. package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
  63. package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
  64. package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
  65. package/skills/task-env/SKILL.md +231 -0
  66. package/skills/task-env/proxy/compose.yaml +51 -0
  67. package/skills/task-env/reference.md +254 -0
  68. package/skills/task-env/scripts/task-env.ts +1017 -0
@@ -0,0 +1,772 @@
1
+ /**
2
+ * The dashboard's "Sessions" tab — "In progress" tasks, their Claude Code sessions and what each one waits for.
3
+ *
4
+ * A task is a GitHub project item with the "In progress" status, its session is the `github task status` pin file
5
+ * (`<configuration directory>/sessions/<session id>` with an `owner/repo#N` line), the session's state is its
6
+ * transcript (`~/.claude/projects/<directory>/<id>.jsonl`), the task's PR and the tasks' questions come from GitHub, the
7
+ * addresses of its services — from the routes of the task-env proxy. Writes nothing to GitHub.
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import { readdirSync, readFileSync, statSync } from "node:fs";
12
+ import path from "node:path";
13
+
14
+ import { questionItems } from "../../github/scripts/github.ts";
15
+ import locale from "../../github/scripts/locale.ts";
16
+ import { branchIssueNumber, cloudSessionsIn, encodePath, EstError, loadPins, PROJECTS_DIR } from "../../est/scripts/est.ts";
17
+ import { addressUrl, hostProject, taskAddress } from "../../task-env/scripts/task-env.ts";
18
+
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- #280 schemaless JSON from transcripts and GitHub
20
+ type Any = any;
21
+
22
+ /** A session is silent if it has had no records for more than 15 min and nothing runs in the background. */
23
+ export const SILENCE = 15 * 60;
24
+ /**
25
+ * A background task without a completion notification runs no longer than 2 h — the Claude Code background command
26
+ * cap; after that it is lost.
27
+ */
28
+ export const BG_MAX = 2 * 3600;
29
+ /** A session without an "In progress" task gets into "Waiting for the owner" if it wrote within the last day. */
30
+ export const RECENT = 86400;
31
+ /** "Decided auto" — answers of tasks whose description changed within the last day. */
32
+ export const AUTO_WINDOW = 86400;
33
+ /** The tab refreshes itself: seconds. */
34
+ export const REFRESH = 30;
35
+ /**
36
+ * The dashboard is shared by all repositories of the machine: labels are in English, and the names it parses (the
37
+ * "In progress" status, the questions section and label, the session's last line) — in any language of the github
38
+ * skill's locales dictionary.
39
+ */
40
+ const IN_WORK = locale.every((n) => n.status.inProgress);
41
+
42
+ // ----------------------------------------------------------------------------
43
+ // Transcript
44
+ // ----------------------------------------------------------------------------
45
+
46
+ /** A session's background task: a command, a subagent or a monitor; `at` — the start, epoch seconds. */
47
+ export interface Bg {
48
+ id: string;
49
+ at: number;
50
+ what: string;
51
+ }
52
+
53
+ /** What the session's transcript tells about it. */
54
+ export interface Transcript {
55
+ id: string;
56
+ /** The session's latest name; not renamed — null. */
57
+ title: string | null;
58
+ /** Time of the last record with a timestamp (and of subagent records), epoch seconds; no records — null. */
59
+ last: number | null;
60
+ /** Background tasks started without a completion notification, in start order. */
61
+ bg: Bg[];
62
+ /** The last turn is a model answer without a tool call: its text; otherwise null. */
63
+ answer: string | null;
64
+ /** The last record is a tool call without a result: the tool's name; otherwise null. */
65
+ pendingTool: string | null;
66
+ /** The session archived itself with its last turn. */
67
+ archived: boolean;
68
+ /** A routine: the session was started by a schedule prompt `<scheduled-task …>`, there's no human in it. */
69
+ routine: boolean;
70
+ }
71
+
72
+ const parse = (line: string): Any => {
73
+ try {
74
+ return JSON.parse(line);
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+ const tsOf = (r: Any): number | null => {
80
+ const t = Date.parse(r?.timestamp ?? "");
81
+ return Number.isFinite(t) ? t / 1000 : null;
82
+ };
83
+ const blocks = (r: Any): Any[] => (Array.isArray(r?.message?.content) ? r.message.content.filter((b: Any) => b && typeof b === "object") : []);
84
+ const textOf = (c: unknown): string => (typeof c === "string" ? c : Array.isArray(c) ? c.filter((b) => b?.type === "text").map((b) => String(b.text ?? "")).join("\n") : "");
85
+ const isToolResult = (r: Any) => blocks(r).some((b) => b.type === "tool_result");
86
+ const TASK_ID_RE = /<task-id>([^<\s]+)<\/task-id>/g;
87
+
88
+ /**
89
+ * Parsing a Claude Code transcript. A background task is a tool result with `backgroundTaskId` (a command), `agentId`
90
+ * and `isAsync` (a subagent) or `taskId` (Monitor); its completion is a `<task-notification>` record with the same id
91
+ * or a TaskStop result. The last turn is the records from the end back to the prompt it started with. Lines are parsed
92
+ * only if they contain what's needed: a transcript can be tens of megabytes, and the tab re-reads it every half
93
+ * minute.
94
+ */
95
+ export function parseTranscript(id: string, text: string): Transcript {
96
+ const lines = text.split("\n");
97
+ let title: string | null = null;
98
+ let first: string | null = null; // the session's first prompt
99
+ const launched: (Bg & { tool: string })[] = [];
100
+ const finished = new Set<string>();
101
+ for (const line of lines) {
102
+ if (first === null && line.includes('"type":"user"')) {
103
+ const r = parse(line);
104
+ if (r?.type === "user" && !r.isMeta && !r.isSidechain && !isToolResult(r)) first = textOf(r.message?.content);
105
+ }
106
+ if (line.includes('"custom-title"')) {
107
+ const r = parse(line);
108
+ if (r?.type === "custom-title" && r.customTitle) title = String(r.customTitle);
109
+ } else if (line.includes("<task-notification>")) {
110
+ const r = parse(line);
111
+ // a notification is a session prompt or one still queued for it; a tool's text that merely mentions it is not
112
+ const body = r?.type === "user" ? textOf(r.message?.content) : r?.type === "queue-operation" ? String(r.content ?? "") : "";
113
+ if (body.trimStart().startsWith("<task-notification>")) for (const m of body.matchAll(TASK_ID_RE)) finished.add(m[1]!);
114
+ } else if (line.includes("Successfully stopped task")) {
115
+ // a background task stopped by TaskStop sends no notification: its completion is the stop's result
116
+ const u = parse(line)?.toolUseResult;
117
+ const task = u?.task_id ?? u?.shell_id;
118
+ if (typeof task === "string" && /^Successfully stopped task/.test(String(u.message ?? ""))) finished.add(task);
119
+ } else if (line.includes('"toolUseResult"') && /"(backgroundTaskId|agentId|taskId)"/.test(line)) {
120
+ const r = parse(line);
121
+ const u = r?.type === "user" ? r.toolUseResult : null;
122
+ const task = u?.backgroundTaskId ?? (u?.isAsync ? u.agentId : null) ?? u?.taskId;
123
+ const at = tsOf(r);
124
+ const tool = blocks(r).find((b) => b.type === "tool_result")?.tool_use_id;
125
+ if (typeof task === "string" && at !== null) launched.push({ id: task, at, what: String(u.description ?? ""), tool: String(tool ?? "") });
126
+ }
127
+ }
128
+ // what a background task does — the description of its call
129
+ const want = new Set(launched.filter((b) => b.tool).map((b) => b.tool));
130
+ const what = new Map<string, string>();
131
+ if (want.size)
132
+ for (const line of lines) {
133
+ if (!line.includes('"tool_use"') || ![...want].some((t) => line.includes(t))) continue;
134
+ for (const b of blocks(parse(line))) if (b.type === "tool_use" && want.has(b.id)) what.set(b.id, String(b.input?.description || b.input?.command || b.name));
135
+ }
136
+ const bg = launched.filter((b) => !finished.has(b.id)).map((b) => ({ id: b.id, at: b.at, what: what.get(b.tool) || b.what || "background task" }));
137
+
138
+ let last: number | null = null;
139
+ const turn: Any[] = []; // records of the last turn, from the end
140
+ for (let i = lines.length - 1; i >= 0; i--) {
141
+ const line = lines[i]!;
142
+ if (!line || (last !== null && !line.includes('"type":"user"') && !line.includes('"type":"assistant"'))) continue;
143
+ const r = parse(line);
144
+ if (!r) continue;
145
+ last ??= tsOf(r);
146
+ if ((r.type !== "user" && r.type !== "assistant") || r.isSidechain) continue;
147
+ turn.push(r);
148
+ // a turn starts with a prompt: a human's or a notification; a meta insert (isMeta) and a tool result are inside it
149
+ if (r.type === "user" && !isToolResult(r) && !r.isMeta) break;
150
+ }
151
+
152
+ let answer: string | null = null;
153
+ let pendingTool: string | null = null;
154
+ const end = turn[0];
155
+ if (end?.type === "assistant") {
156
+ const calls = blocks(end).filter((b) => b.type === "tool_use");
157
+ if (calls.length) pendingTool = String(calls[calls.length - 1].name);
158
+ else if (end.message?.stop_reason !== "tool_use") {
159
+ // the answer is the text of all records of the model's last message: the app writes a message's blocks one record
160
+ // per block
161
+ const texts: string[] = [];
162
+ for (const r of turn) {
163
+ if (r.type !== "assistant" || r.message?.id !== end.message?.id) break;
164
+ texts.unshift(...blocks(r).filter((b) => b.type === "text").map((b) => String(b.text ?? "")));
165
+ }
166
+ answer = texts.join("\n\n").trim();
167
+ }
168
+ } else if (end?.type === "user" && /^\[Request interrupted by user/.test(textOf(end.message?.content))) answer = "turn interrupted by a human";
169
+ const archived = turn.some((r) => r.type === "assistant" && blocks(r).some((b) => b.type === "tool_use" && /archive_session$/.test(String(b.name)) && b.input?.session_id === "self"));
170
+ return { id, title, last, bg, answer, pendingTool, archived, routine: (first ?? "").trimStart().startsWith("<scheduled-task") };
171
+ }
172
+
173
+ // ----------------------------------------------------------------------------
174
+ // Questions
175
+ // ----------------------------------------------------------------------------
176
+
177
+ // session end lines per the canon (AGENTS.md, "Epic refresh on task close") in any language of the dictionary,
178
+ // markup included
179
+ const alt = (xs: string[]) => xs.map((x) => locale.escapeRe(x.replace(/[:.]$/, ""))).join("|");
180
+ const ASK_RE = new RegExp(`^[\\s*_>]*(?:${alt([...locale.every((n) => n.lastLine.questions), ...locale.every((n) => n.lastLine.remaining)])})`);
181
+ const DONE_RE = new RegExp(`(?:${alt(locale.every((n) => n.lastLine.done))})\\.?\\s*$`);
182
+
183
+ /**
184
+ * A question to the owner in the session's answer: from the line "All done, but there are questions…" or
185
+ * "Remaining: …" to the end of the answer; without them — the last paragraph. "All done. The session can be closed."
186
+ * isn't a question: null. `strict` — for a session without an "In progress" task: its last answer is often just an
187
+ * answer, and without these lines a question is only a paragraph that ends with "?".
188
+ */
189
+ export function questionOf(answer: string, strict = false): string | null {
190
+ const text = answer.trim();
191
+ const lines = text.split("\n");
192
+ const i = lines.findIndex((l) => ASK_RE.test(l));
193
+ if (i >= 0) return lines.slice(i).join("\n").trim();
194
+ if (DONE_RE.test(text.replace(/[*_]/g, ""))) return null;
195
+ const paras = text.split(/\n\s*\n/);
196
+ const last = paras[paras.length - 1]!.trim();
197
+ return strict && !/\?[\s*_)»"]*$/.test(last) ? null : last;
198
+ }
199
+
200
+ /**
201
+ * A task's open questions — the first lines of the items of the questions section ("## Questions" in any language of
202
+ * the dictionary) without ✅; the section is parsed by the github skill, as `task actualize` parses it.
203
+ */
204
+ export function openQuestions(body: string): string[] {
205
+ return questionItems(body).filter((x) => !x.answered).map((x) => x.text);
206
+ }
207
+
208
+ /** A question the session answered itself in auto mode, and its answer. */
209
+ export interface AutoAnswer {
210
+ question: string;
211
+ answer: string;
212
+ }
213
+
214
+ /**
215
+ * The auto-answer mark — `autoAnswer` of any language of the dictionary, with markup around it: `task answer --auto`
216
+ * puts it before the question, by hand it was put after the question and on a continuation line, and «✅ auto» in a
217
+ * project of another language too.
218
+ */
219
+ const AUTO_RE = new RegExp(`[*_]*(?:${alt(locale.every((n) => n.autoAnswer)).replace(/ /g, "\\s*")})(?![\\p{L}\\p{N}])[*_:.]*`, "iu");
220
+ /** The end of a question: "?" at the end of a word, closing markup included. */
221
+ const QMARK_RE = /\?[»"'*_)]*(?=\s|$)/;
222
+ /** A bold label before the answer — `**Answer:**` of the `task answer` format, `**Decision:**` by hand. */
223
+ const LABEL_RE = /\*\*[^*\n]{1,40}:\*\*/g;
224
+
225
+ /** A dash between the question and the answer: «Question? — answer». */
226
+ const dashes = { start: /^[\s—–]+/, end: /[\s—–]+$/ };
227
+
228
+ /**
229
+ * The question and the answer of an item text with the mark at its start or end: before a bold label, otherwise up to
230
+ * the first "?", otherwise the first line is the question, and a single line — up to the first « — ».
231
+ */
232
+ function splitAnswer(text: string): AutoAnswer {
233
+ const cut = (at: number, skip: number) => ({ question: text.slice(0, at).replace(dashes.end, ""), answer: text.slice(at + skip).replace(dashes.start, "") });
234
+ const label = [...text.matchAll(LABEL_RE)].find((l) => text.slice(0, l.index).trim());
235
+ if (label) return cut(label.index, label[0].length);
236
+ const q = QMARK_RE.exec(text);
237
+ if (q) return cut(q.index + q[0].length, 0);
238
+ const nl = text.indexOf("\n");
239
+ if (nl >= 0) return cut(nl, 1);
240
+ const dash = text.indexOf(" — ");
241
+ return dash > 0 ? cut(dash, 3) : { question: text, answer: "" };
242
+ }
243
+
244
+ /**
245
+ * Answers marked `autoAnswer` in the questions section. The mark between text is itself the separator: before it is
246
+ * the question (a recommendation stays with it), after it — the answer; a bold label in the answer doesn't cut it.
247
+ * The mark at the start or end — the question and the answer are split by the text (`splitAnswer`).
248
+ */
249
+ export function autoAnswers(body: string): AutoAnswer[] {
250
+ return questionItems(body).flatMap((item) => {
251
+ const text = item.lines.map((l) => l.trim()).join("\n").replace(/^\d+\.\s*/, "");
252
+ const m = AUTO_RE.exec(text);
253
+ if (!m) return [];
254
+ const before = text.slice(0, m.index).replace(dashes.end, "");
255
+ const after = text.slice(m.index + m[0].length).replace(dashes.start, "");
256
+ if (before && after) return [{ question: before, answer: after }];
257
+ return before || after ? [splitAnswer(before || after)] : [];
258
+ });
259
+ }
260
+
261
+ // ----------------------------------------------------------------------------
262
+ // What a session waits for
263
+ // ----------------------------------------------------------------------------
264
+
265
+ /** Checks state: running, red, green; no checks — null. */
266
+ export type Checks = "pending" | "failure" | "success" | null;
267
+
268
+ /** The task's PR: open or merged, its checks and conflict; `deploy` — the merge commit's checks. */
269
+ export interface PrInfo {
270
+ number: number;
271
+ url: string;
272
+ branch: string;
273
+ /** Tasks the PR closes (`Closes #N`). */
274
+ closes: number[];
275
+ merged: boolean;
276
+ checks: Checks;
277
+ conflict: boolean;
278
+ deploy: Checks;
279
+ /** The commits have the cloud session trailer `Claude-Session`. */
280
+ cloud: boolean;
281
+ }
282
+
283
+ export type WaitKey = "human" | "merge" | "silent" | "ci-red" | "ci" | "deploy" | "working" | "done" | "none" | "cloud";
284
+
285
+ export interface Wait {
286
+ key: WaitKey;
287
+ label: string;
288
+ detail: string;
289
+ }
290
+
291
+ const LABEL: Record<WaitKey, string> = {
292
+ human: "human reply",
293
+ merge: "merge",
294
+ silent: "silence",
295
+ "ci-red": "CI red",
296
+ ci: "CI running",
297
+ deploy: "deploy",
298
+ working: "working",
299
+ done: "can be closed",
300
+ none: "no session",
301
+ cloud: "cloud, state not visible",
302
+ };
303
+
304
+ /**
305
+ * What a task's session waits for. A running background task wakes the session itself — it outranks the answer: what
306
+ * it waits for shows from the PR (open and checks running — CI, merged and the merge commit's checks running —
307
+ * deploy). A model answer without a tool call — the human's turn; if the PR is green and mergeable meanwhile, it
308
+ * waits for the merge. Neither, and no records for longer than the threshold — silence.
309
+ */
310
+ export function waitOf(s: Transcript | null, pr: PrInfo | null, now: number): Wait {
311
+ const w = (key: WaitKey, detail = ""): Wait => ({ key, label: LABEL[key], detail });
312
+ // a cloud session doesn't pin its task: GitHub projects are unreachable for it, its state isn't visible from here
313
+ if (!s) return pr?.cloud ? w("cloud") : w("none");
314
+ if (s.archived) return w("done", "session archived");
315
+ const bg = s.bg.filter((b) => now - b.at <= BG_MAX);
316
+ if (bg.length) {
317
+ const what = `in background: ${bg.map((b) => b.what).join("; ")}`;
318
+ if (pr && !pr.merged && pr.checks === "pending") return w("ci", what);
319
+ if (pr && !pr.merged && pr.checks === "failure") return w("ci-red", what);
320
+ if (pr?.merged && pr.deploy === "pending") return w("deploy", what);
321
+ return w("working", what);
322
+ }
323
+ if (s.answer !== null) {
324
+ const q = questionOf(s.answer);
325
+ if (q === null) return w("done");
326
+ const first = q.split("\n")[0]!;
327
+ return pr && !pr.merged && pr.checks === "success" && !pr.conflict ? w("merge", first) : w("human", first);
328
+ }
329
+ if (s.last === null || now - s.last > SILENCE) return w("silent", s.pendingTool ? `call ${s.pendingTool} without a result — waiting for permission or stuck` : "no records, nothing in background");
330
+ return w("working");
331
+ }
332
+
333
+ // ----------------------------------------------------------------------------
334
+ // Tab
335
+ // ----------------------------------------------------------------------------
336
+
337
+ /** An "In progress" task and its epic. */
338
+ export interface Task {
339
+ number: number;
340
+ title: string;
341
+ parent: { number: number; title: string } | null;
342
+ }
343
+
344
+ /**
345
+ * What GitHub knows about a repository: "In progress" tasks (without epics), PRs, tasks with open questions, tasks
346
+ * changed within the last day — `edited` is when their description was edited (never — created), epoch seconds.
347
+ */
348
+ export interface Live {
349
+ tasks: Task[];
350
+ prs: PrInfo[];
351
+ questions: { number: number; title: string; body: string }[];
352
+ recent: { number: number; title: string; body: string; edited: number }[];
353
+ }
354
+
355
+ export interface SessionsInput {
356
+ /** Pins: `owner/repo#N` → session ids. */
357
+ pins: Record<string, string[]>;
358
+ /** Transcripts of pinned sessions by id. */
359
+ transcripts: Record<string, Transcript>;
360
+ /** GitHub by repository. */
361
+ live: Record<string, Live>;
362
+ /** Recent sessions of the repository's directories — their questions, if they have no "In progress" task. */
363
+ others: Record<string, Transcript[]>;
364
+ /** Hosts of the task-env proxy's routes; the proxy isn't running — none. */
365
+ hosts: string[];
366
+ /** The proxy's port: the address URL carries it unless it is 80. */
367
+ port: string;
368
+ }
369
+
370
+ /** A link to a service of the task's stack. */
371
+ export interface Address {
372
+ service: string;
373
+ url: string;
374
+ }
375
+
376
+ export interface SessionRow {
377
+ repo: string;
378
+ number: number;
379
+ title: string;
380
+ url: string;
381
+ epic: { number: number; title: string; url: string } | null;
382
+ session: Transcript | null;
383
+ pr: PrInfo | null;
384
+ wait: Wait;
385
+ /** Addresses of the task stack's services; no stack — none. */
386
+ addresses: Address[];
387
+ }
388
+
389
+ /**
390
+ * A question to the owner: from a task (its session's answer), from a session without an "In progress" task or from
391
+ * a task with the "questions" label.
392
+ */
393
+ export interface Ask {
394
+ /** `issue` — an "In progress" task with the "questions" label, `backlog` — the other tasks with the label. */
395
+ kind: "task" | "session" | "issue" | "backlog";
396
+ repo: string;
397
+ head: string;
398
+ url: string | null;
399
+ session: Transcript | null;
400
+ lines: string[];
401
+ }
402
+
403
+ /** A task whose description got answers marked `autoAnswer` within the last day. */
404
+ export interface Decided {
405
+ repo: string;
406
+ number: number;
407
+ title: string;
408
+ url: string;
409
+ edited: number;
410
+ answers: AutoAnswer[];
411
+ }
412
+
413
+ export interface SessionsView {
414
+ rows: SessionRow[];
415
+ asks: Ask[];
416
+ autos: Decided[];
417
+ }
418
+
419
+ const issueUrl = (repo: string, n: number) => `https://github.com/${repo}/issues/${n}`;
420
+ const OWNER = new Set<WaitKey>(["human", "merge"]);
421
+ // on top — those waiting for the owner, then silent and red ones: they're looked at first
422
+ const RANK: Partial<Record<WaitKey, number>> = { human: 0, merge: 0, silent: 1, "ci-red": 1 };
423
+
424
+ /** The task's PR: an open one, otherwise the last merged; the link is `Closes #N` or the branch `<type>/<N>-…`. */
425
+ function prFor(live: Live, n: number): PrInfo | null {
426
+ const mine = live.prs.filter((p) => p.closes.includes(n) || branchIssueNumber(p.branch) === n);
427
+ return mine.find((p) => !p.merged) ?? mine[0] ?? null;
428
+ }
429
+
430
+ /**
431
+ * Addresses of a task's services — the proxy's hosts `<service>.<N>.<project>.localhost` with its number and its
432
+ * repository as the project (task-env). e2e addresses aren't shown: that stack is recreated on every run, a link to it
433
+ * lives minutes (#396, question 4).
434
+ */
435
+ function addressesOf(inp: SessionsInput, repo: string, n: number): Address[] {
436
+ const project = hostProject(repo.slice(repo.indexOf("/") + 1));
437
+ return inp.hosts
438
+ .flatMap((host) => {
439
+ const a = taskAddress(host);
440
+ return a && !a.e2e && a.task === n && a.project === project ? [{ service: a.service, url: addressUrl(host, inp.port) }] : [];
441
+ })
442
+ .sort((a, b) => a.service.localeCompare(b.service));
443
+ }
444
+
445
+ /** "In progress" task lines and questions to the owner. */
446
+ export function sessionsView(inp: SessionsInput, now: number): SessionsView {
447
+ const pinned = new Set<string>();
448
+ const rows: SessionRow[] = Object.entries(inp.live).flatMap(([repo, live]) =>
449
+ live.tasks.map((t) => {
450
+ const ids = inp.pins[`${repo}#${t.number}`] ?? [];
451
+ ids.forEach((id) => pinned.add(id));
452
+ // after /clear the task has a new session and the old pin stays: the one that wrote last is taken
453
+ const session = ids.map((id) => inp.transcripts[id]).filter((s): s is Transcript => !!s).sort((a, b) => (b.last ?? 0) - (a.last ?? 0))[0] ?? null;
454
+ const pr = prFor(live, t.number);
455
+ return {
456
+ repo, number: t.number, title: t.title, url: issueUrl(repo, t.number), session, pr, wait: waitOf(session, pr, now),
457
+ epic: t.parent ? { ...t.parent, url: issueUrl(repo, t.parent.number) } : null, addresses: addressesOf(inp, repo, t.number),
458
+ };
459
+ }),
460
+ );
461
+ rows.sort((a, b) => (RANK[a.wait.key] ?? 2) - (RANK[b.wait.key] ?? 2) || a.repo.localeCompare(b.repo) || a.number - b.number);
462
+
463
+ const asks: Ask[] = rows.filter((r) => OWNER.has(r.wait.key)).map((r) => ({ kind: "task", repo: r.repo, head: `#${r.number} ${r.title}`, url: r.url, session: r.session, lines: questionOf(r.session!.answer ?? "")!.split("\n") }));
464
+ for (const [repo, list] of Object.entries(inp.others)) {
465
+ // a routine and an answer without an explicit question aren't for the owner: otherwise the block drowns in
466
+ // scheduled reports
467
+ const recent = list.flatMap((s) => {
468
+ const q = !pinned.has(s.id) && !s.routine && s.last !== null && now - s.last <= RECENT && waitOf(s, null, now).key === "human" ? questionOf(s.answer!, true) : null;
469
+ return q === null ? [] : [{ s, q }];
470
+ });
471
+ for (const { s, q } of recent.sort((a, b) => b.s.last! - a.s.last!)) asks.push({ kind: "session", repo, head: s.title ?? `session ${s.id.slice(0, 8)}`, url: null, session: s, lines: q.split("\n") });
472
+ }
473
+ const issues = Object.entries(inp.live).flatMap(([repo, live]) =>
474
+ live.questions.map((q): Ask => {
475
+ const inWork = live.tasks.some((t) => t.number === q.number);
476
+ return { kind: inWork ? "issue" : "backlog", repo, head: `#${q.number} ${q.title}`, url: issueUrl(repo, q.number), session: null, lines: openQuestions(q.body) };
477
+ }),
478
+ );
479
+ asks.push(...issues.filter((a) => a.kind === "issue"), ...issues.filter((a) => a.kind === "backlog"));
480
+ // the window is by the description's edit, not by the task's update: a comment or a close would bring back old answers
481
+ const autos = Object.entries(inp.live).flatMap(([repo, live]) =>
482
+ live.recent.flatMap((i): Decided[] => {
483
+ const answers = now - i.edited <= AUTO_WINDOW ? autoAnswers(i.body) : [];
484
+ return answers.length ? [{ repo, number: i.number, title: i.title, url: issueUrl(repo, i.number), edited: i.edited, answers }] : [];
485
+ }),
486
+ );
487
+ autos.sort((a, b) => b.edited - a.edited || a.repo.localeCompare(b.repo) || a.number - b.number);
488
+ return { rows, asks, autos };
489
+ }
490
+
491
+ const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]!);
492
+ const link = (url: string, text: string) => `<a href="${esc(url)}">${esc(text)}</a>`;
493
+
494
+ /** Time elapsed: "4 min ago", "2 h 5 min ago", "3 d ago". */
495
+ function ago(sec: number): string {
496
+ const m = Math.floor(sec / 60);
497
+ if (m < 1) return "just now";
498
+ if (m < 60) return `${m} min ago`;
499
+ const h = Math.floor(m / 60);
500
+ if (h < 24) return `${h} h${m % 60 ? ` ${m % 60} min` : ""} ago`;
501
+ return `${Math.floor(h / 24)} d ago`;
502
+ }
503
+
504
+ const when = (s: Transcript, now: number) => (s.last === null ? "no records" : ago(now - s.last));
505
+ /** The session name, if it doesn't repeat the task: by the canon a session is named "#N <task name>". */
506
+ const ownTitle = (s: Transcript, task: string) => (s.title === null ? `untitled, ${s.id.slice(0, 8)}` : s.title === task ? null : s.title);
507
+ const CHECKS: Record<string, string> = { pending: "checks running", failure: "checks red", success: "checks green", null: "no checks" };
508
+ const DEPLOY: Record<string, string> = { pending: " · deploy running", failure: " · deploy red", success: "", null: "" };
509
+
510
+ function prCell(p: PrInfo | null): string {
511
+ if (!p) return "—";
512
+ const state = p.merged ? `merged${DEPLOY[String(p.deploy)]}` : `${CHECKS[String(p.checks)]}${p.conflict ? " · conflict" : ""}`;
513
+ return `${link(p.url, `#${p.number}`)} ${esc(state)}`;
514
+ }
515
+
516
+ function asksBlock(asks: Ask[], now: number, many: boolean): string {
517
+ const item = (a: Ask) => {
518
+ const head = `${many ? a.repo : ""}${a.head}`;
519
+ const top = (sub: string) => `<div class="ask-head">${a.url ? link(a.url, head) : esc(head)} <span class="minor">${esc(sub)}</span></div>`;
520
+ const text = (lines: string[]) => `<div class="ask-text">${lines.map(esc).join("<br>")}</div>`;
521
+ // tasks with questions number in the dozens: as a line, the questions themselves — under a disclosure
522
+ if (a.kind === "issue" || a.kind === "backlog")
523
+ return `<li>${top(a.lines.length ? `questions label · open: ${a.lines.length}` : "questions label")}${a.lines.length ? `<details data-key="${esc(`${a.repo}${a.head}`)}"><summary>questions</summary>${text(a.lines)}</details>` : ""}</li>`;
524
+ const name = a.kind === "task" ? ownTitle(a.session!, a.head) : null;
525
+ return `<li>${top(a.kind === "task" ? `session${name ? ` «${name}»` : ""} · ${when(a.session!, now)}` : `session without a task in progress · ${when(a.session!, now)}`)}${text(a.lines)}</li>`;
526
+ };
527
+ const live = asks.filter((a) => a.kind !== "backlog");
528
+ const backlog = asks.filter((a) => a.kind === "backlog");
529
+ // backlog questions wait until the task is taken: collapsed so as not to obscure the sessions that are stuck now
530
+ const rest = backlog.length ? `<details class="backlog" data-key="backlog"><summary>tasks with questions, not in progress: ${backlog.length}</summary><ul>${backlog.map(item).join("")}</ul></details>` : "";
531
+ return `<section class="asks card"><h2>Waiting for the owner</h2>${live.length ? `<ul>${live.map(item).join("")}</ul>` : `<p class="note">no questions</p>`}${rest}</section>`;
532
+ }
533
+
534
+ function rowsTable(rows: SessionRow[], now: number, many: boolean): string {
535
+ if (!rows.length) return `<p class="card">No tasks in progress.</p>`;
536
+ const body = rows.map((r) => {
537
+ const addresses = r.addresses.length ? `<div class="minor addresses">services: ${r.addresses.map((a) => `<a href="${esc(a.url)}" title="${esc(a.url)}">${esc(a.service)}</a>`).join(" · ")}</div>` : "";
538
+ const task = `${link(r.url, `${many ? r.repo : ""}#${r.number}`)} ${esc(r.title)}${r.epic ? `<div class="minor">epic ${link(r.epic.url, `#${r.epic.number} ${r.epic.title}`)}</div>` : ""}${addresses}`;
539
+ const name = r.session && ownTitle(r.session, `#${r.number} ${r.title}`);
540
+ const session = r.session ? `${esc(when(r.session, now))}${name ? `<div class="minor">${esc(name)}</div>` : ""}` : "—";
541
+ const wait = `<span class="wait ${r.wait.key}">${esc(r.wait.label)}</span>${r.wait.detail ? `<div class="minor">${esc(r.wait.detail)}</div>` : ""}`;
542
+ return `<tr data-task="${esc(`${r.repo}#${r.number}`)}"><td>${task}</td><td>${session}</td><td>${wait}</td><td>${prCell(r.pr)}</td></tr>`;
543
+ });
544
+ return `<section class="card scroll"><table class="sessions"><thead><tr><th>Task</th><th>Session</th><th>Waiting for</th><th>PR</th></tr></thead><tbody>${body.join("")}</tbody></table></section>`;
545
+ }
546
+
547
+ /** "Decided auto": a line per answer — the task (once for its answers), the question, the answer; none — no block. */
548
+ function autosBlock(autos: Decided[], now: number, many: boolean): string {
549
+ if (!autos.length) return "";
550
+ const lines = (s: string) => s.split("\n").map(esc).join("<br>");
551
+ const body = autos.flatMap((d) =>
552
+ d.answers.map((a, i) => {
553
+ const task = i ? "" : `<td rowspan="${d.answers.length}">${link(d.url, `${many ? d.repo : ""}#${d.number} ${d.title}`)}<div class="minor">edited ${esc(ago(now - d.edited))}</div></td>`;
554
+ return `<tr>${task}<td>${lines(a.question)}</td><td>${lines(a.answer)}</td></tr>`;
555
+ }),
556
+ );
557
+ return `<section class="autos card scroll"><h2>Decided auto</h2><p class="note">answers marked ✅ auto in tasks whose description changed within a day</p><table class="autos"><thead><tr><th>Task</th><th>Question</th><th>Answer</th></tr></thead><tbody>${body.join("")}</tbody></table></section>`;
558
+ }
559
+
560
+ /**
561
+ * The tab's content: "Waiting for the owner" on top, below it — a line per "In progress" task, at the bottom — what
562
+ * sessions decided themselves: it's looked at after the fact.
563
+ */
564
+ export function sessionsBody(v: SessionsView, now: number, many: boolean): string {
565
+ return asksBlock(v.asks, now, many) + rowsTable(v.rows, now, many) + autosBlock(v.autos, now, many);
566
+ }
567
+
568
+ /**
569
+ * The tab's self-refresh: every `sec` seconds and on returning to the tab — the page afresh and `<main>` replaced,
570
+ * what's expanded stays expanded; a hidden tab doesn't refresh. A reload (meta refresh) would collapse expanded
571
+ * questions every half minute.
572
+ */
573
+ export function refreshScript(sec: number): string {
574
+ return `<script>async function refreshMain() {
575
+ let html;
576
+ try { const r = await fetch(location.href, { cache: "no-store" }); if (!r.ok) return; html = await r.text(); } catch { return; }
577
+ const next = new DOMParser().parseFromString(html, "text/html").querySelector("main"); if (!next) return;
578
+ const open = new Set([...document.querySelectorAll("details[open][data-key]")].map((d) => d.dataset.key));
579
+ document.querySelector("main").replaceWith(next);
580
+ next.querySelectorAll("details[data-key]").forEach((d) => { if (open.has(d.dataset.key)) d.open = true; });
581
+ }
582
+ setInterval(() => document.hidden || refreshMain(), ${sec * 1000});
583
+ document.addEventListener("visibilitychange", () => document.hidden || refreshMain());</script>`;
584
+ }
585
+
586
+ /** The tab's styles; colors are the page's theme variables. */
587
+ export const SESSIONS_CSS = `
588
+ h2{font-size:16px;margin:0 0 8px}
589
+ .asks ul{list-style:none;margin:0;padding:0}
590
+ .asks li{padding:8px 0;border-top:1px solid var(--grid)}
591
+ .asks li:first-child{border-top:0;padding-top:0}
592
+ .ask-head{font-weight:600}.ask-head .minor{font-weight:400}
593
+ .ask-text{margin-top:2px;white-space:pre-wrap}
594
+ .asks details{margin-top:2px}.asks summary{cursor:pointer;color:var(--ink2);font-size:13px}
595
+ .asks details.backlog{margin-top:8px}.asks details.backlog>summary{font-size:14px}.asks details.backlog ul{margin-top:8px}
596
+ .minor{color:var(--ink2);font-size:13px}
597
+ table.sessions td{text-align:left;white-space:normal;vertical-align:top}
598
+ table.sessions td:nth-child(1){min-width:220px}
599
+ table.sessions td:nth-child(2){min-width:0;white-space:nowrap}
600
+ table.sessions td:nth-child(3){min-width:160px}
601
+ table.sessions th{text-align:left}
602
+ .autos .note{margin:0 0 8px}
603
+ table.autos th,table.autos td{text-align:left;white-space:normal;vertical-align:top}
604
+ table.autos td:first-child{min-width:200px}
605
+ table.autos td:not(:first-child){min-width:220px}
606
+ .wait{display:inline-block;padding:1px 8px;border-radius:999px;font-size:13px;border:1px solid var(--border);white-space:nowrap}
607
+ .wait.human,.wait.merge{background:var(--warn);color:#000;border-color:transparent}
608
+ .wait.silent,.wait.ci-red{background:var(--bad);color:#fff;border-color:transparent}
609
+ .wait.ci,.wait.deploy,.wait.working{color:var(--fact)}
610
+ .wait.done{color:var(--good)}
611
+ .wait.none,.wait.cloud{color:var(--muted)}
612
+ `;
613
+
614
+ // ----------------------------------------------------------------------------
615
+ // Reading: pins, transcripts, GitHub
616
+ // ----------------------------------------------------------------------------
617
+
618
+ /**
619
+ * Pins of tasks to sessions: `owner/repo#N` → session ids — est reads the `sessions/<id>` files of the configuration
620
+ * directory (loadPins): the dashboard imports est anyway, its own parse would be a third copy (tests/standards/session-pin).
621
+ */
622
+ export function readPins(dir?: string): Record<string, string[]> {
623
+ const out: Record<string, string[]> = {};
624
+ for (const [id, task] of loadPins(dir)) (out[task] ??= []).push(id);
625
+ return out;
626
+ }
627
+
628
+ const parsed = new Map<string, { key: string; t: Transcript }>();
629
+
630
+ /** A file's transcript; re-parsed only if the file changed. Subagent records are a sign of the session's life too. */
631
+ function readTranscript(file: string): Transcript {
632
+ const st = statSync(file);
633
+ const key = `${st.size}:${st.mtimeMs}`;
634
+ let t = parsed.get(file)?.key === key ? parsed.get(file)!.t : null;
635
+ if (!t) {
636
+ t = parseTranscript(path.basename(file, ".jsonl"), readFileSync(file, "utf8"));
637
+ parsed.set(file, { key, t });
638
+ }
639
+ // a subagent writes to its own transcript (<id>/subagents/), and the session waits for it without records
640
+ const sub = path.join(file.slice(0, -".jsonl".length), "subagents");
641
+ let subAt = 0;
642
+ try {
643
+ for (const f of readdirSync(sub)) subAt = Math.max(subAt, statSync(path.join(sub, f)).mtimeMs / 1000);
644
+ } catch {
645
+ // no subagents
646
+ }
647
+ return subAt > (t.last ?? 0) ? { ...t, last: subAt } : t;
648
+ }
649
+
650
+ /** Transcript files `<directory>/<name>.jsonl` of the `projectsDir` directories that match `match`. */
651
+ function transcriptFiles(match: (dir: string, name: string) => boolean, projectsDir: string): string[] {
652
+ let dirs: string[];
653
+ try {
654
+ dirs = readdirSync(projectsDir);
655
+ } catch {
656
+ return [];
657
+ }
658
+ const out: string[] = [];
659
+ for (const d of dirs) {
660
+ let names: string[];
661
+ try {
662
+ names = readdirSync(path.join(projectsDir, d));
663
+ } catch {
664
+ continue;
665
+ }
666
+ for (const n of names) if (n.endsWith(".jsonl") && match(d, n)) out.push(path.join(projectsDir, d, n));
667
+ }
668
+ return out;
669
+ }
670
+
671
+ /** Session transcripts by id — in any project's directory: a session also works from someone else's directory. */
672
+ export function findTranscripts(ids: string[], projectsDir = PROJECTS_DIR): Record<string, Transcript> {
673
+ const want = new Set(ids.map((id) => `${id}.jsonl`));
674
+ const out: Record<string, Transcript> = {};
675
+ if (!want.size) return out;
676
+ for (const f of transcriptFiles((_, n) => want.has(n), projectsDir)) {
677
+ const t = readTranscript(f);
678
+ // one id in two directories (the app moved the transcript) — the fresh one
679
+ if ((t.last ?? 0) >= (out[t.id]?.last ?? 0)) out[t.id] = t;
680
+ }
681
+ return out;
682
+ }
683
+
684
+ /**
685
+ * Transcripts of the repository's directories (`paths` of the est registry and their worktrees) modified no earlier
686
+ * than `since`.
687
+ */
688
+ export function repoTranscripts(paths: string[], since: number, projectsDir = PROJECTS_DIR): Transcript[] {
689
+ const encs = paths.map(encodePath);
690
+ if (!encs.length) return [];
691
+ const files = transcriptFiles((d) => encs.some((e) => d.startsWith(e)), projectsDir);
692
+ return files.filter((f) => statSync(f).mtimeMs / 1000 >= since).map(readTranscript);
693
+ }
694
+
695
+ const ROLLUP: Record<string, Checks> = { SUCCESS: "success", FAILURE: "failure", ERROR: "failure", PENDING: "pending", EXPECTED: "pending" };
696
+ const rollup = (state: unknown): Checks => ROLLUP[String(state)] ?? null;
697
+ const EPIC_TYPES = locale.every((n) => n.issueTypes.epic).map((t) => t.toLowerCase());
698
+ const isEpic = (i: Any) => (i.labels?.nodes ?? []).some((l: Any) => l.name === "epic") || EPIC_TYPES.includes(String(i.issueType?.name ?? "").toLowerCase());
699
+ const PR_FIELDS = "number url headRefName mergeable closingIssuesReferences(first:10){nodes{number}} commits(last:20){nodes{commit{messageBody statusCheckRollup{state}}}}";
700
+
701
+ /**
702
+ * "In progress" tasks, PRs and tasks with the "questions" label from the GitHub response; an epic isn't a line: its
703
+ * session plans rather than does.
704
+ */
705
+ function packLive(data: Any): Live {
706
+ const pack = (p: Any, merged: boolean): PrInfo => {
707
+ const commits: Any[] = p.commits?.nodes ?? [];
708
+ return {
709
+ number: p.number, url: p.url, branch: p.headRefName ?? "", closes: (p.closingIssuesReferences?.nodes ?? []).map((n: Any) => n.number), merged,
710
+ checks: rollup(commits[commits.length - 1]?.commit?.statusCheckRollup?.state), conflict: p.mergeable === "CONFLICTING",
711
+ deploy: merged ? rollup(p.mergeCommit?.statusCheckRollup?.state) : null, cloud: commits.some((c) => cloudSessionsIn(c.commit?.messageBody ?? "").length > 0),
712
+ };
713
+ };
714
+ const issues = (data?.project?.items?.nodes ?? []).map((it: Any) => it?.content).filter((c: Any) => c?.__typename === "Issue" && c.state === "OPEN" && !isEpic(c));
715
+ const repo = data?.repository;
716
+ return {
717
+ tasks: issues.map((c: Any) => ({ number: c.number, title: c.title, parent: c.parent ? { number: c.parent.number, title: c.parent.title } : null })).sort((a: Task, b: Task) => a.number - b.number),
718
+ prs: [...(repo?.open?.nodes ?? []).map((p: Any) => pack(p, false)), ...(repo?.merged?.nodes ?? []).map((p: Any) => pack(p, true))],
719
+ questions: (repo?.questions?.nodes ?? []).map((q: Any) => ({ number: q.number, title: q.title, body: q.body ?? "" })),
720
+ recent: (repo?.recent?.nodes ?? [])
721
+ .map((i: Any) => ({ number: i.number, title: i.title, body: i.body ?? "", edited: Date.parse(i.lastEditedAt ?? i.createdAt ?? "") / 1000 }))
722
+ .filter((i: { edited: number }) => Number.isFinite(i.edited)),
723
+ };
724
+ }
725
+
726
+ /** `gh api graphql` without blocking the server: the tab answers from the cache while GitHub gathers fresh data. */
727
+ function graphql(query: string, variables: Record<string, unknown>): Promise<Any> {
728
+ return new Promise((resolve, reject) => {
729
+ const p = spawn("gh", ["api", "graphql", "--input", "-"], { stdio: ["pipe", "pipe", "pipe"] });
730
+ let out = "";
731
+ let err = "";
732
+ p.stdout.on("data", (d) => (out += d));
733
+ p.stderr.on("data", (d) => (err += d));
734
+ p.on("error", (e) => reject(new EstError((e as NodeJS.ErrnoException).code === "ENOENT" ? "command 'gh' not found; the gh CLI must be installed" : `gh api graphql: ${e.message}`)));
735
+ p.on("close", (code) => {
736
+ if (code !== 0) return reject(new EstError(`command gh api graphql… exited with code ${code}: ${err.trim().slice(0, 500)}`));
737
+ let data: Any;
738
+ try {
739
+ data = JSON.parse(out);
740
+ } catch {
741
+ return reject(new EstError(`gh api graphql: the response isn't JSON: ${out.slice(0, 200)}`));
742
+ }
743
+ // a partial response (no access to one task) — there is data, take it
744
+ if (data.errors?.length && !data.data) return reject(new EstError(`GraphQL: ${data.errors.map((e: Any) => e.message ?? "").join("; ")}`));
745
+ resolve(data.data);
746
+ });
747
+ p.stdin.end(JSON.stringify({ query, variables }));
748
+ });
749
+ }
750
+
751
+ /**
752
+ * One request per repository: "In progress" tasks with their epic — by a project filter on GitHub's side (whole
753
+ * project rows, with comments, as for the "Estimate and actual" tab, take tens of seconds), open and recently merged
754
+ * PRs, tasks with the "questions" label, tasks updated within the last day — GitHub filters by any update, the window
755
+ * by the description's edit is applied by `sessionsView`.
756
+ */
757
+ export async function liveQuery(full: string, projectId: string): Promise<Live> {
758
+ // a cloud session has Projects v2 closed to it; dashboard exits earlier, this is for a call that bypasses it
759
+ if (process.env.CLAUDE_CODE_REMOTE === "true") throw new EstError("Claude Code cloud session: GitHub projects aren't reachable from here (docs/cloud-sessions.md in Elves)");
760
+ const [o, r] = full.split("/");
761
+ const q = `query($o:String!,$r:String!,$p:ID!,$q:String!,$since:DateTime!){
762
+ project: node(id:$p){ ... on ProjectV2{ items(first:100, query:$q){ nodes{ content{ __typename
763
+ ... on Issue{ number title state issueType{name} labels(first:15){nodes{name}} parent{ number title } } } } } } }
764
+ repository(owner:$o,name:$r){
765
+ open: pullRequests(states:OPEN, first:50, orderBy:{field:UPDATED_AT,direction:DESC}){ nodes{ ${PR_FIELDS} } }
766
+ merged: pullRequests(states:MERGED, first:30, orderBy:{field:UPDATED_AT,direction:DESC}){ nodes{ ${PR_FIELDS} mergeCommit{ statusCheckRollup{state} } } }
767
+ questions: issues(states:OPEN, labels:${JSON.stringify(locale.every((n) => n.labels.questions))}, first:50){ nodes{ number title body } }
768
+ recent: issues(filterBy:{since:$since}, first:100, orderBy:{field:UPDATED_AT,direction:DESC}){ nodes{ number title body createdAt lastEditedAt } } } }`;
769
+ const since = new Date(Date.now() - AUTO_WINDOW * 1000).toISOString();
770
+ // the project filter: comma-separated values — any of them
771
+ return packLive(await graphql(q, { o, r, p: projectId, q: `status:${IN_WORK.map((s) => JSON.stringify(s)).join(",")}`, since }));
772
+ }