@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,4390 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * github — the repository's GitHub project and tasks by the canon (AGENTS.md, "Task tracking"; the full rules are in
4
+ * reference.md next to SKILL.md: types, decision labels, epics, the project, milestones, epic refresh).
5
+ *
6
+ * Subcommands:
7
+ * github project check — a check against the canon, item by item ✅/❌
8
+ * github project fix — bring it to the canon: API, UI steps with links, deleting and renaming — with --confirm
9
+ * github task new — a task in one command: type or label, project and Backlog, Priority, epic, blocked by,
10
+ * milestone, decision labels (the decision's name, the kind by color), the questions label
11
+ * (an open question in the description) and the estimate (est)
12
+ * github task show — the task in one output: the description, Status, estimate, the epic, blockers, PRs and
13
+ * their checks, open questions
14
+ * github task answer — ✅ and the answer at a question of the questions section; none open — the label goes
15
+ * github task status — Status in the project (and the epic's "In progress" once its first subtask is taken);
16
+ * "In progress" pins the task to the session — another task in the same session is refused
17
+ * with the question whether to queue it
18
+ * github task drop — close without doing it and remove from the project
19
+ * github task actualize — after the merge, in parallel with the deploy: the epic refresh's mechanics and the
20
+ * subagent's prompt (or "not needed"); --assignment — the subagent's whole assignment
21
+ * github task close — after the merge and the deploy: the actual, "Done", the epic and the milestone, the task's
22
+ * stacks (task-env) and the task's merged branches removed
23
+ * github task queue — the dispatcher's ready tasks with the prompts of their executors (subagents);
24
+ * --assignment — an executor's whole assignment
25
+ * github pr labels — decision labels for the task from "Closes #N" by the PR diff, for its epic — the union
26
+ * github pr premerge — before the merge: the default branch moved after the PR's CI — typecheck and test:spec on
27
+ * the merge with it
28
+ *
29
+ * Runs on Bun (`bun github.ts …`), only `node:` APIs + the `gh` CLI. Check and fix are one function, `analyze`: each
30
+ * discrepancy carries its own fix step, so `check` and `fix` don't diverge.
31
+ * Decision labels are checked against the spec tree of the default branch; the architecture model is read by a
32
+ * separate process of the same runtime (`modelModules`). Merging into the default branch — the `elves` ruleset:
33
+ * required checks, green on the last merged PRs; the merge method — rebase only (a repository setting, REST).
34
+ */
35
+
36
+ import { spawnSync } from "node:child_process";
37
+ import { appendFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
38
+ import os from "node:os";
39
+ import path from "node:path";
40
+ import { parseArgs } from "node:util";
41
+
42
+ import locale from "./locale.ts";
43
+ import type { Names } from "./locale.ts";
44
+
45
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
+ type Any = any;
47
+
48
+ // ----------------------------------------------------------------------------
49
+ // Canon
50
+ // ----------------------------------------------------------------------------
51
+
52
+ /**
53
+ * Canon names in the project language (`locales/<language>.json`, the `locale.ts` module): views, Status options,
54
+ * fields, issue types, label markers, sections, comments. The language is the one of the project of the `io.cwd`
55
+ * directory, `main` sets it; parsing — by the names of any dictionary language (`locale.every`).
56
+ */
57
+ let N: Names = locale.names(locale.projectLanguage());
58
+
59
+ const canonViews = () => [
60
+ { name: N.views.board, layout: "BOARD_LAYOUT" },
61
+ { name: N.views.table, layout: "TABLE_LAYOUT" },
62
+ { name: N.views.roadmap, layout: "ROADMAP_LAYOUT" },
63
+ ];
64
+ export const STATUS = "Status";
65
+ const statusOptions = () => [N.status.backlog, N.status.inProgress, N.status.done];
66
+ const numberFields = () => [N.fields.estimate, N.fields.actual, N.fields.tokens, N.fields.cost];
67
+ const issueTypes = () => [N.issueTypes.task, N.issueTypes.bug, N.issueTypes.epic];
68
+ export const PRIORITY = "Priority";
69
+ /** Priority options from the most urgent; a new task gets Medium, an epic — no lower than its most urgent subtask. */
70
+ export const PRIORITIES = ["Urgent", "High", "Medium", "Low"] as const;
71
+ export const DEFAULT_PRIORITY = "Medium";
72
+ /** A personal account has no issue types — an epic is marked with a label, the only exception. */
73
+ export const EPIC_LABEL = "epic";
74
+ const epicLabel = () => ({ name: EPIC_LABEL, color: "8250DF", description: N.labels.epicDescription });
75
+ /** Open questions in the task description: `task new` sets it (creates it if missing), the last answer removes it. */
76
+ const questionsLabel = () => ({ name: N.labels.questions, color: "FBCA04", description: N.labels.questionsDescription });
77
+ /**
78
+ * Decision labels: the name is the decision's name (a spec tree folder or a model module), the kind is the color.
79
+ * Decisions of different kinds with one name are one label: the color of the senior kind (the order here), all of them
80
+ * in the description. For architecture a decision is the rule `tests/architecture/<name>` or a module of the model
81
+ * `tests/architecture/model.ts`.
82
+ */
83
+ export const DECISIONS = {
84
+ capability: { dir: "capabilities", color: "0E8A16" },
85
+ standard: { dir: "standards", color: "1D76DB" },
86
+ // not FBCA04: the "questions" label has it, and a decision label's kind shows only by color
87
+ architecture: { dir: "architecture", color: "D93F0B" },
88
+ } as const;
89
+ export type DecisionKind = keyof typeof DECISIONS;
90
+ const DECISION_KINDS = Object.keys(DECISIONS) as DecisionKind[];
91
+ const MODEL_PATH = "tests/architecture/model.ts";
92
+ /**
93
+ * GitHub puts at most this many labels on an issue: `addLabelsToLabelable` past it fails as a whole (#569). An issue's
94
+ * labels are read all at once (`IssueRef`) — otherwise `pr labels` couldn't count the room.
95
+ */
96
+ const ISSUE_LABEL_LIMIT = 100;
97
+ export const WORKFLOW_ADDED = "Item added to project";
98
+ export const WORKFLOW_CLOSED = "Item closed";
99
+ export const WORKFLOW_AUTO_ADD = "Auto-add to project";
100
+ /** The template: a new project is its copy (views, fields, configured workflows except auto-add). */
101
+ export const DEFAULT_TEMPLATE = "miroshnik/6";
102
+ /**
103
+ * The default-branch ruleset that fix sets: GitHub doesn't let a branch with a red required check be merged. It doesn't
104
+ * require the branch to be up to date (strict is off): the next PR's CI shows that two green PRs are incompatible.
105
+ */
106
+ export const RULESET = "elves";
107
+ /**
108
+ * The name `RULESET` had before the flow was renamed to Elves: a repository set up by an earlier fix has its ruleset
109
+ * under it. Check finds it as its own (`RULESET` first), fix renames it with the same edit — not a second ruleset.
110
+ */
111
+ const FORMER_RULESET = "ai-dev";
112
+ /** A required check is green on each of this many last merged PRs with checks. */
113
+ export const STABLE_PRS = 10;
114
+ /** The repository admin role (`repositoryRoleDatabaseId`) bypasses the ruleset: direct push, `gh pr merge --admin`. */
115
+ const ADMIN_ROLE = 5;
116
+
117
+ /**
118
+ * Status options of a new project and names in the dictionary's other languages → the name in the project language:
119
+ * renaming with the same id keeps the tasks' values and the target of the "Item closed" workflow (replacing all the
120
+ * options gives new ids).
121
+ */
122
+ function statusAliases(): Record<string, string> {
123
+ const out: Record<string, string> = { todo: N.status.backlog, "to do": N.status.backlog, doing: N.status.inProgress };
124
+ for (const n of locale.everyNames()) {
125
+ out[n.status.backlog.toLowerCase()] = N.status.backlog;
126
+ out[n.status.inProgress.toLowerCase()] = N.status.inProgress;
127
+ out[n.status.done.toLowerCase()] = N.status.done;
128
+ }
129
+ return out;
130
+ }
131
+ /** Issue types in any dictionary language (and `task`, `bug`, `epic`) → the type in the project language. */
132
+ function typeAliases(): Record<string, string> {
133
+ const out: Record<string, string> = {};
134
+ for (const n of locale.everyNames()) {
135
+ out[n.issueTypes.task.toLowerCase()] = N.issueTypes.task;
136
+ out[n.issueTypes.bug.toLowerCase()] = N.issueTypes.bug;
137
+ out[n.issueTypes.epic.toLowerCase()] = N.issueTypes.epic;
138
+ }
139
+ return out;
140
+ }
141
+ /** Project fields in any dictionary language → the field in the project language. */
142
+ function fieldAliases(): Record<string, string> {
143
+ const out: Record<string, string> = {};
144
+ for (const n of locale.everyNames()) {
145
+ out[n.fields.estimate] = N.fields.estimate;
146
+ out[n.fields.actual] = N.fields.actual;
147
+ out[n.fields.tokens] = N.fields.tokens;
148
+ out[n.fields.cost] = N.fields.cost;
149
+ }
150
+ return out;
151
+ }
152
+ // Built-in project fields; custom ones are everything else (TEXT, NUMBER, DATE, SINGLE_SELECT, ITERATION…).
153
+ const BUILTIN_FIELDS = new Set(["TITLE", "ASSIGNEES", "LABELS", "LINKED_PULL_REQUESTS", "MILESTONE", "REPOSITORY", "REVIEWERS", "PARENT_ISSUE", "SUB_ISSUES_PROGRESS", "CREATED", "UPDATED", "CLOSED", "TRACKS", "TRACKED_BY", "ISSUE_TYPE"]);
154
+ const NOT_DONE_REASONS = new Set(["NOT_PLANNED", "DUPLICATE"]);
155
+
156
+ // ----------------------------------------------------------------------------
157
+ // Model
158
+ // ----------------------------------------------------------------------------
159
+
160
+ export interface Io {
161
+ /** `gh` with arguments and stdin → stdout; an error is an exception. An external edge: replaced in tests. */
162
+ gh: (args: string[], stdin?: string) => string;
163
+ out: (line: string) => void;
164
+ err: (line: string) => void;
165
+ env: Record<string, string | undefined>;
166
+ /** A pause before re-reading (GitHub doesn't show additions right away); in tests — no waiting. */
167
+ sleep?: (ms: number) => void;
168
+ /** A subprocess (`est`, `git` of `task close`) — an external edge: replaced in tests. */
169
+ run?: (cmd: string, args: string[], cwd?: string) => RunResult;
170
+ /**
171
+ * The project checkout: its language (`.agents/elves.json`) is the language of names, `task close` runs its git
172
+ * steps in it; the current directory by default.
173
+ */
174
+ cwd?: string;
175
+ }
176
+
177
+ export interface RunResult {
178
+ status: number | null;
179
+ stdout: string;
180
+ stderr: string;
181
+ }
182
+
183
+ export function realRun(cmd: string, args: string[], cwd?: string): RunResult {
184
+ const r = spawnSync(cmd, args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
185
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? "", stderr: r.error ? String(r.error.message) : (r.stderr ?? "") };
186
+ }
187
+
188
+ const pause = (ms: number) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
189
+
190
+ export interface ProjectRef {
191
+ id: string;
192
+ number: number;
193
+ title: string;
194
+ url: string;
195
+ closed: boolean;
196
+ }
197
+ interface View {
198
+ id: string;
199
+ number: number;
200
+ name: string;
201
+ layout: string;
202
+ filter: string | null;
203
+ sortBy: string[];
204
+ columnsBy: string[];
205
+ }
206
+ interface Option {
207
+ id: string;
208
+ name: string;
209
+ color: string;
210
+ description: string;
211
+ }
212
+ interface Field {
213
+ id: string;
214
+ name: string;
215
+ dataType: string;
216
+ isIssueField: boolean;
217
+ options: Option[] | null;
218
+ }
219
+ interface Workflow {
220
+ id: string;
221
+ number: number;
222
+ /** The workflow address in the UI uses it, not number: `<project>/workflows/<fullDatabaseId>`. */
223
+ fullDatabaseId: string | number;
224
+ name: string;
225
+ enabled: boolean;
226
+ }
227
+ interface Item {
228
+ id: string;
229
+ issue: { id: string; number: number; state: string; stateReason: string | null } | null;
230
+ status: string | null;
231
+ }
232
+ interface Project extends ProjectRef {
233
+ views: View[];
234
+ fields: Field[];
235
+ workflows: Workflow[];
236
+ items: Item[];
237
+ }
238
+ interface Owner {
239
+ org: boolean;
240
+ id: string;
241
+ login: string;
242
+ issueTypes: { id: string; name: string; isEnabled: boolean }[];
243
+ issueFields: { id: string; name: string; options: { id: string; name: string }[] }[];
244
+ }
245
+ interface Label {
246
+ id: string;
247
+ name: string;
248
+ color: string;
249
+ description: string | null;
250
+ /** Open tasks with the label: how many in total and the numbers of the first OPEN_SHOWN. */
251
+ open: { total: number; numbers: number[] };
252
+ }
253
+ /** Default-branch decisions by kind; the model didn't load — the reason, and then `architecture` has no modules. */
254
+ export interface Decisions {
255
+ names: Record<DecisionKind, string[]>;
256
+ modelError: string | null;
257
+ }
258
+ /**
259
+ * A PR head's check state — the buckets of `gh pr checks`: green, skipped or neutral, still running, cancelled, red
260
+ * (`checkStates`).
261
+ */
262
+ type CheckBucket = "pass" | "skipping" | "pending" | "cancel" | "fail";
263
+ interface Head {
264
+ number: number;
265
+ checks: Record<string, CheckBucket>;
266
+ }
267
+ interface Ruleset {
268
+ id: string;
269
+ /** `RULESET`, or `FORMER_RULESET` until fix renames it. */
270
+ name: string;
271
+ enforcement: string;
272
+ include: string[];
273
+ strict: boolean;
274
+ contexts: string[];
275
+ bypassAdmin: boolean;
276
+ }
277
+ /** Merging into the default branch: its active rules with required checks and the checks of PR heads into it. */
278
+ export interface Merge {
279
+ branch: string;
280
+ /** Branch rules are unavailable on the plan (a private repository on Free) — the reason from GitHub. */
281
+ unavailable: string | null;
282
+ /** Active rules with required checks: a ruleset of any level and classic branch protection. */
283
+ rules: { source: string; contexts: string[] }[];
284
+ /** Our own ruleset — this repository's `RULESET` (otherwise `FORMER_RULESET`), in any state. */
285
+ own: Ruleset | null;
286
+ /** The last `STABLE_PRS` merged PRs with checks, oldest to newest. */
287
+ merged: Head[];
288
+ /** Open PRs whose checks have all finished: a check they don't have won't come. */
289
+ open: Head[];
290
+ /** Allowed PR merge methods (a repository setting): merge commit, squash, rebase. */
291
+ methods: { merge: boolean; squash: boolean; rebase: boolean };
292
+ }
293
+ export interface State {
294
+ repo: { id: string; name: string; nameWithOwner: string; owner: Owner; linked: ProjectRef[] };
295
+ project: Project | null;
296
+ openIssues: { id: string; number: number }[];
297
+ labels: Label[];
298
+ /** Tasks of old `kind:name` labels that merge into an already taken name: by label id. */
299
+ labelIssues: Record<string, { id: string; number: number }[]>;
300
+ decisions: Decisions;
301
+ /** An empty repository (no default branch) — null. */
302
+ merge: Merge | null;
303
+ }
304
+
305
+ interface Mutation {
306
+ op: string;
307
+ input: Record<string, unknown>;
308
+ }
309
+ /**
310
+ * A fix step: `api` — fix does it; `confirm` — only with --confirm (deleting, renaming, the organization);
311
+ * `ui` — by hand via the link. `sticky` — a UI step stays in the list even when fix has already removed the symptom.
312
+ */
313
+ export interface Step {
314
+ kind: "api" | "confirm" | "ui";
315
+ text: string;
316
+ mutations?: Mutation[];
317
+ url?: string;
318
+ sticky?: boolean;
319
+ }
320
+ export interface Check {
321
+ key: string;
322
+ title: string;
323
+ problems: { text: string; steps: Step[] }[];
324
+ /** Not a discrepancy but the state of things (a new decision's label): an `○` line, the item stays ✅. */
325
+ notes: string[];
326
+ /** The item doesn't apply (plan, no checks): a `➖` line with the reason instead of ✅/❌. */
327
+ skip?: string;
328
+ }
329
+
330
+ export class GhError extends Error {}
331
+
332
+ // ----------------------------------------------------------------------------
333
+ // GraphQL
334
+ // ----------------------------------------------------------------------------
335
+
336
+ const REF = "id number title url closed";
337
+ /** Numbers of the label's open tasks in the "new decision" line; the rest as a count. */
338
+ const OPEN_SHOWN = 10;
339
+ /**
340
+ * How many recent PRs task close looks at when the PR isn't linked to the task: it's called right after the merge, so
341
+ * the PR is among them.
342
+ */
343
+ const RECENT_PRS = 50;
344
+ /**
345
+ * A PR head's checks for `checkStates` — the one selection of every query that reads them (`PR_SHOW`, `MergeRules`):
346
+ * without the run's id and its app reruns can't be told apart (`tests/standards/check-run-state`).
347
+ */
348
+ export const HEAD_CHECKS = `commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: 100) { nodes { __typename ... on CheckRun { name status conclusion databaseId checkSuite { app { databaseId } } } ... on StatusContext { context state } } } } } } }`;
349
+ /** A PR for task show: its state and the checks of its head. */
350
+ const PR_SHOW = `number state merged isDraft headRefName
351
+ ${HEAD_CHECKS}`;
352
+ export const Q = {
353
+ /** The repository's current name: GitHub answers a renamed repository's former name with it (`canonicalRepo`). */
354
+ RepoName: `query RepoName($owner: String!, $name: String!) {
355
+ repository(owner: $owner, name: $name) { nameWithOwner }
356
+ }`,
357
+ RepoState: `query RepoState($owner: String!, $name: String!) {
358
+ repository(owner: $owner, name: $name) {
359
+ id name nameWithOwner
360
+ owner {
361
+ __typename id login
362
+ ... on Organization {
363
+ issueTypes(first: 50) { nodes { id name isEnabled } }
364
+ issueFields(first: 50) { nodes { __typename ... on IssueFieldSingleSelect { id name options { id name } } } }
365
+ }
366
+ }
367
+ projectsV2(first: 20) { nodes { ${REF} } }
368
+ }
369
+ }`,
370
+ RepoIssues: `query RepoIssues($owner: String!, $name: String!, $after: String) {
371
+ repository(owner: $owner, name: $name) {
372
+ issues(states: OPEN, first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id number } }
373
+ }
374
+ }`,
375
+ OwnerProjects: `query OwnerProjects($login: String!, $query: String!) {
376
+ repositoryOwner(login: $login) { ... on ProjectV2Owner { projectsV2(first: 20, query: $query) { nodes { ${REF} } } } }
377
+ }`,
378
+ TemplateProject: `query TemplateProject($login: String!, $number: Int!) {
379
+ repositoryOwner(login: $login) { ... on ProjectV2Owner { projectV2(number: $number) { ${REF} } } }
380
+ }`,
381
+ ProjectState: `query ProjectState($id: ID!) {
382
+ node(id: $id) {
383
+ ... on ProjectV2 {
384
+ ${REF}
385
+ views(first: 50) {
386
+ nodes {
387
+ id number name layout filter
388
+ sortByFields(first: 5) { nodes { direction field { ... on ProjectV2FieldCommon { name } } } }
389
+ verticalGroupByFields(first: 5) { nodes { ... on ProjectV2FieldCommon { name } } }
390
+ }
391
+ }
392
+ fields(first: 100) {
393
+ nodes {
394
+ __typename
395
+ ... on ProjectV2FieldCommon { id name dataType }
396
+ ... on ProjectV2Field { isIssueField }
397
+ ... on ProjectV2SingleSelectField { isIssueField options { id name color description } }
398
+ }
399
+ }
400
+ workflows(first: 50) { nodes { id number fullDatabaseId name enabled } }
401
+ }
402
+ }
403
+ }`,
404
+ IssueRef: `query IssueRef($owner: String!, $name: String!, $number: Int!) {
405
+ repository(owner: $owner, name: $name) {
406
+ issue(number: $number) {
407
+ id number title state stateReason url
408
+ issueType { name }
409
+ labels(first: ${ISSUE_LABEL_LIMIT}) { nodes { name } }
410
+ parent { id number }
411
+ subIssues(first: 100) { nodes { number state title } }
412
+ blockedBy(first: 50) { nodes { number state repository { nameWithOwner } } }
413
+ milestone { id number title issues(states: OPEN) { totalCount } }
414
+ closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { nodes { number state merged headRefName baseRefName } }
415
+ projectItems(first: 20) { nodes { id project { id } status: fieldValueByName(name: "${STATUS}") { ... on ProjectV2ItemFieldSingleSelectValue { name } } } }
416
+ issueFieldValues(first: 20) { nodes { __typename ... on IssueFieldSingleSelectValue { name field { ... on IssueFieldSingleSelect { name } } } } }
417
+ }
418
+ }
419
+ }`,
420
+ // The epic refresh (task actualize): the text, links and milestone of the epic and of each of its open tasks; task
421
+ // close — the refresh's report among the epic's comments
422
+ TaskText: `query TaskText($owner: String!, $name: String!, $number: Int!) {
423
+ repository(owner: $owner, name: $name) {
424
+ issue(number: $number) {
425
+ id number title state body
426
+ labels(first: 20) { nodes { id name } }
427
+ milestone { number title state issues(states: OPEN) { totalCount } }
428
+ blockedBy(first: 50) { nodes { id number state repository { nameWithOwner } } }
429
+ comments(last: 50) { nodes { body url } }
430
+ subIssues(first: 100) { nodes { number title state milestone { number title state issues(states: OPEN) { totalCount } } } }
431
+ }
432
+ }
433
+ }`,
434
+ TaskContext: `query TaskContext($owner: String!, $name: String!) {
435
+ repository(owner: $owner, name: $name) {
436
+ label(name: "${EPIC_LABEL}") { id }
437
+ milestones(first: 100, states: OPEN) { nodes { id title } }
438
+ }
439
+ }`,
440
+ // HEAD is the default branch: labels are checked against what's already merged
441
+ SpecDecisions: `query SpecDecisions($owner: String!, $name: String!) {
442
+ repository(owner: $owner, name: $name) {
443
+ capabilities: object(expression: "HEAD:tests/capabilities") { ... on Tree { entries { name type } } }
444
+ standards: object(expression: "HEAD:tests/standards") { ... on Tree { entries { name type } } }
445
+ architecture: object(expression: "HEAD:tests/architecture") { ... on Tree { entries { name type } } }
446
+ model: object(expression: "HEAD:${MODEL_PATH}") { ... on Blob { text } }
447
+ }
448
+ }`,
449
+ LabelIssues: `query LabelIssues($owner: String!, $name: String!, $label: String!, $after: String) {
450
+ repository(owner: $owner, name: $name) {
451
+ label(name: $label) { issues(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id number } } }
452
+ }
453
+ }`,
454
+ RepoLabels: `query RepoLabels($owner: String!, $name: String!, $after: String) {
455
+ repository(owner: $owner, name: $name) {
456
+ labels(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id name color description issues(states: OPEN, first: ${OPEN_SHOWN}) { totalCount nodes { number } } } }
457
+ }
458
+ }`,
459
+ PrChange: `query PrChange($owner: String!, $name: String!, $number: Int!, $after: String) {
460
+ repository(owner: $owner, name: $name) {
461
+ pullRequest(number: $number) {
462
+ number headRefOid body
463
+ closingIssuesReferences(first: 20) { nodes { number } }
464
+ files(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { path changeType additions deletions } }
465
+ }
466
+ }
467
+ }`,
468
+ ModelAt: `query ModelAt($owner: String!, $name: String!, $expression: String!) {
469
+ repository(owner: $owner, name: $name) { object(expression: $expression) { ... on Blob { text } } }
470
+ }`,
471
+ // Ref.rules — the rules of active rulesets (of the repository and the organization) that GitHub applies to the branch
472
+ MergeRules: `query MergeRules($owner: String!, $name: String!) {
473
+ repository(owner: $owner, name: $name) {
474
+ isPrivate mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed
475
+ defaultBranchRef {
476
+ name
477
+ branchProtectionRule { requiredStatusCheckContexts }
478
+ rules(first: 100) { nodes { type repositoryRuleset { name } parameters { ...StatusChecks } } }
479
+ }
480
+ rulesets(first: 100) {
481
+ nodes {
482
+ id name enforcement
483
+ conditions { refName { include } }
484
+ bypassActors(first: 50) { nodes { bypassMode repositoryRoleDatabaseId } }
485
+ rules(first: 100) { nodes { type parameters { ...StatusChecks } } }
486
+ }
487
+ }
488
+ merged: pullRequests(states: MERGED, last: 30) { nodes { ...Head } }
489
+ open: pullRequests(states: OPEN, last: 30) { nodes { ...Head } }
490
+ }
491
+ }
492
+ fragment StatusChecks on RuleParameters { ... on RequiredStatusChecksParameters { strictRequiredStatusChecksPolicy requiredStatusChecks { context } } }
493
+ fragment Head on PullRequest {
494
+ number baseRefName
495
+ ${HEAD_CHECKS}
496
+ }`,
497
+ // The task's PR that GitHub didn't link to it (task close): the latest open and merged ones, newest first
498
+ RecentPrs: `query RecentPrs($owner: String!, $name: String!) {
499
+ repository(owner: $owner, name: $name) {
500
+ defaultBranchRef { name }
501
+ pullRequests(states: [OPEN, MERGED], first: ${RECENT_PRS}, orderBy: { field: UPDATED_AT, direction: DESC }) { nodes { number state merged headRefName baseRefName body } }
502
+ }
503
+ }`,
504
+ // task show and task answer: the description, the epic with its description and subtasks, blocked by, the PRs with
505
+ // their head's checks, Status and number fields of the project
506
+ IssueShow: `query IssueShow($owner: String!, $name: String!, $number: Int!) {
507
+ repository(owner: $owner, name: $name) {
508
+ issue(number: $number) {
509
+ id number title state stateReason url body
510
+ issueType { name }
511
+ labels(first: 20) { nodes { id name } }
512
+ milestone { title }
513
+ parent { number title body subIssues(first: 100) { nodes { number state title } } }
514
+ subIssues(first: 100) { nodes { number state title } }
515
+ blockedBy(first: 20) { nodes { number state title repository { nameWithOwner } } }
516
+ closedByPullRequestsReferences(first: 20, includeClosedPrs: true) { nodes { ${PR_SHOW} } }
517
+ projectItems(first: 20) {
518
+ nodes {
519
+ project { title }
520
+ fieldValues(first: 50) {
521
+ nodes {
522
+ __typename
523
+ ... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2FieldCommon { name } } }
524
+ ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2FieldCommon { name } } }
525
+ }
526
+ }
527
+ }
528
+ }
529
+ issueFieldValues(first: 20) { nodes { __typename ... on IssueFieldSingleSelectValue { name field { ... on IssueFieldSingleSelect { name } } } } }
530
+ }
531
+ }
532
+ }`,
533
+ // a PR that GitHub didn't link to the task yet (task show)
534
+ PrShow: `query PrShow($owner: String!, $name: String!, $number: Int!) {
535
+ repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${PR_SHOW} } }
536
+ }`,
537
+ IssueSearch: `query IssueSearch($q: String!) {
538
+ search(query: $q, type: ISSUE, first: 100) { nodes { ... on Issue { number title state } } }
539
+ }`,
540
+ ProjectItems: `query ProjectItems($id: ID!, $after: String) {
541
+ node(id: $id) {
542
+ ... on ProjectV2 {
543
+ items(first: 100, after: $after) {
544
+ pageInfo { hasNextPage endCursor }
545
+ nodes {
546
+ id
547
+ content { __typename ... on Issue { id number state stateReason } }
548
+ status: fieldValueByName(name: "${STATUS}") { ... on ProjectV2ItemFieldSingleSelectValue { name } }
549
+ }
550
+ }
551
+ }
552
+ }
553
+ }`,
554
+ } as const;
555
+
556
+ // Mutations take one $input parameter: input has only the given keys (GitHub reads null in input as "erase").
557
+ const M: Record<string, [field: string, inputType: string, select: string]> = {
558
+ LinkProject: ["linkProjectV2ToRepository", "LinkProjectV2ToRepositoryInput", "repository { id }"],
559
+ UnlinkProject: ["unlinkProjectV2FromRepository", "UnlinkProjectV2FromRepositoryInput", "repository { id }"],
560
+ CopyProject: ["copyProjectV2", "CopyProjectV2Input", `projectV2 { ${REF} }`],
561
+ CreateProject: ["createProjectV2", "CreateProjectV2Input", `projectV2 { ${REF} }`],
562
+ RenameProject: ["updateProjectV2", "UpdateProjectV2Input", "projectV2 { id }"],
563
+ CreateView: ["createProjectV2View", "CreateProjectV2ViewInput", "projectV2View { id }"],
564
+ UpdateView: ["updateProjectV2View", "UpdateProjectV2ViewInput", "projectV2View { id }"],
565
+ DeleteView: ["deleteProjectV2View", "DeleteProjectV2ViewInput", "clientMutationId"],
566
+ CreateField: ["createProjectV2Field", "CreateProjectV2FieldInput", "clientMutationId"],
567
+ UpdateField: ["updateProjectV2Field", "UpdateProjectV2FieldInput", "clientMutationId"],
568
+ DeleteField: ["deleteProjectV2Field", "DeleteProjectV2FieldInput", "clientMutationId"],
569
+ AddIssueField: ["createProjectV2IssueField", "CreateProjectV2IssueFieldInput", "clientMutationId"],
570
+ SetItemStatus: ["updateProjectV2ItemFieldValue", "UpdateProjectV2ItemFieldValueInput", "projectV2Item { id }"],
571
+ AddItem: ["addProjectV2ItemById", "AddProjectV2ItemByIdInput", "item { id }"],
572
+ DeleteItem: ["deleteProjectV2Item", "DeleteProjectV2ItemInput", "deletedItemId"],
573
+ CreateIssueType: ["createIssueType", "CreateIssueTypeInput", "issueType { id }"],
574
+ UpdateIssueType: ["updateIssueType", "UpdateIssueTypeInput", "issueType { id }"],
575
+ CreateIssue: ["createIssue", "CreateIssueInput", "issue { id number title url projectItems(first: 10) { nodes { id project { id } } } }"],
576
+ CreateLabel: ["createLabel", "CreateLabelInput", "label { id }"],
577
+ UpdateLabel: ["updateLabel", "UpdateLabelInput", "label { id }"],
578
+ DeleteLabel: ["deleteLabel", "DeleteLabelInput", "clientMutationId"],
579
+ AddLabels: ["addLabelsToLabelable", "AddLabelsToLabelableInput", "clientMutationId"],
580
+ RemoveLabels: ["removeLabelsFromLabelable", "RemoveLabelsFromLabelableInput", "clientMutationId"],
581
+ AddBlockedBy: ["addBlockedBy", "AddBlockedByInput", "issue { id }"],
582
+ RemoveBlockedBy: ["removeBlockedBy", "RemoveBlockedByInput", "issue { id }"],
583
+ SetIssueField: ["setIssueFieldValue", "SetIssueFieldValueInput", "issue { id }"],
584
+ CloseIssue: ["closeIssue", "CloseIssueInput", "issue { id state }"],
585
+ UpdateIssue: ["updateIssue", "UpdateIssueInput", "issue { id }"],
586
+ AddComment: ["addComment", "AddCommentInput", "subject { id }"],
587
+ CreateRuleset: ["createRepositoryRuleset", "CreateRepositoryRulesetInput", "ruleset { id }"],
588
+ UpdateRuleset: ["updateRepositoryRuleset", "UpdateRepositoryRulesetInput", "ruleset { id }"],
589
+ };
590
+
591
+ /**
592
+ * REST mutations — what GraphQL lacks: the repository's merge settings (`UpdateRepositoryInput` doesn't know them).
593
+ * Input — `repo` and the request fields; `gh api -F` sends true/false as booleans.
594
+ */
595
+ const REST: Record<string, (input: Record<string, unknown>) => string[]> = {
596
+ UpdateRepository: ({ repo, ...fields }) => ["api", "-X", "PATCH", `repos/${repo}`, ...Object.entries(fields).flatMap(([k, v]) => ["-F", `${k}=${v}`])],
597
+ };
598
+
599
+ function mutationQuery(op: string): string {
600
+ const spec = M[op];
601
+ if (!spec) throw new GhError(`unknown mutation ${op}`);
602
+ const [field, inputType, select] = spec;
603
+ return `mutation ${op}($input: ${inputType}!) { ${field}(input: $input) { ${select} } }`;
604
+ }
605
+
606
+ /** A GraphQL query; partial data with errors — a warning, but for a mutation (strict) an error: no result received. */
607
+ export function graphql(io: Io, query: string, variables: Record<string, unknown>, strict = false): Any {
608
+ const out = io.gh(["api", "graphql", "--input", "-"], JSON.stringify({ query, variables }));
609
+ let res: Any;
610
+ try {
611
+ res = JSON.parse(out);
612
+ } catch {
613
+ throw new GhError(`gh api graphql: the response is not JSON: ${out.slice(0, 200)}`);
614
+ }
615
+ const errors: string[] = (res.errors ?? []).map((e: Any) => e.message ?? String(e));
616
+ if (errors.length && (!res.data || strict)) throw new GhError(`GraphQL: ${errors.join("; ")}`);
617
+ if (errors.length) io.err(`GraphQL warning: ${errors.join("; ")}`);
618
+ return res.data;
619
+ }
620
+
621
+ function mutate(io: Io, m: Mutation): Any {
622
+ const rest = REST[m.op];
623
+ return rest ? restCall(io, rest(m.input)) : graphql(io, mutationQuery(m.op), { input: m.input }, true);
624
+ }
625
+
626
+ /** A REST request; an error response (realGh returns JSON even with a code ≠ 0) is an exception with its message. */
627
+ function restCall(io: Io, args: string[]): Any {
628
+ // `-X PATCH repos/…` or GET `repos/…`
629
+ const what = args.slice(1).filter((a) => !a.startsWith("-")).slice(0, 2).join(" ");
630
+ const out = io.gh(args);
631
+ let res: Any;
632
+ try {
633
+ res = JSON.parse(out);
634
+ } catch {
635
+ throw new GhError(`${what}: the response is not JSON: ${out.slice(0, 200)}`);
636
+ }
637
+ if (res?.message && res.id === undefined) throw new GhError(`${what}: ${res.message}`);
638
+ return res;
639
+ }
640
+
641
+ /**
642
+ * A Claude Code cloud session (docs/cloud-sessions.md): its proxy closes GraphQL, `search/…` and Projects v2 — only the
643
+ * REST of `repos/{owner}/{repo}/…` of the session's repositories.
644
+ */
645
+ const inCloud = (io: Io) => io.env.CLAUDE_CODE_REMOTE === "true";
646
+ /** The commands with a REST path in the cloud (#603): the rest need GraphQL or the project and refuse there. */
647
+ const CLOUD_COMMANDS = ["task show", "task answer", "task new"];
648
+
649
+ /**
650
+ * A REST request of the cloud's path: the payload — JSON on stdin (a description doesn't fit an argument); an error
651
+ * response is an exception with its message, a missing resource (404) with `missing` — null; an empty answer (204) —
652
+ * null.
653
+ */
654
+ function rest(io: Io, route: string, o: { method?: string; payload?: unknown; missing?: boolean } = {}): Any {
655
+ const what = `${o.method ?? "GET"} ${route}`;
656
+ const out = io.gh(["api", ...(o.method ? ["-X", o.method] : []), route, ...(o.payload === undefined ? [] : ["--input", "-"])], o.payload === undefined ? undefined : JSON.stringify(o.payload));
657
+ let res: Any;
658
+ try {
659
+ res = out.trim() ? JSON.parse(out) : null;
660
+ } catch {
661
+ throw new GhError(`${what}: the response is not JSON: ${out.slice(0, 200)}`);
662
+ }
663
+ if (res && !Array.isArray(res) && typeof res.message === "string" && res.id === undefined) {
664
+ if (o.missing && String(res.status) === "404") return null;
665
+ throw new GhError(`${what}: ${res.message}`);
666
+ }
667
+ return res;
668
+ }
669
+
670
+ /** A whole REST list: pages of 100 until a shorter one. */
671
+ function restList(io: Io, route: string): Any[] {
672
+ const out: Any[] = [];
673
+ for (let page = 1; page <= 100; page++) {
674
+ const rows: Any[] = rest(io, `${route}${route.includes("?") ? "&" : "?"}per_page=100&page=${page}`) ?? [];
675
+ out.push(...rows);
676
+ if (rows.length < 100) break;
677
+ }
678
+ return out;
679
+ }
680
+
681
+ /** `owner/repo` of a REST `repository_url` (`https://api.github.com/repos/owner/repo`). */
682
+ const repoOfUrl = (url: string | undefined, slug: string) => /\/repos\/([^/]+\/[^/]+)$/.exec(url ?? "")?.[1] ?? slug;
683
+
684
+ /** A REST issue in the shape of the GraphQL answer: the state and its reason in upper case, labels with ids. */
685
+ function restIssueNode(i: Any): Any {
686
+ return {
687
+ id: i.node_id,
688
+ databaseId: i.id,
689
+ number: i.number,
690
+ title: i.title,
691
+ state: String(i.state).toUpperCase(),
692
+ stateReason: i.state_reason ? String(i.state_reason).toUpperCase() : null,
693
+ url: i.html_url,
694
+ body: i.body ?? "",
695
+ issueType: i.type?.name ? { name: i.type.name } : null,
696
+ labels: { nodes: (i.labels ?? []).map((l: Any) => ({ id: l.node_id, name: l.name })) },
697
+ milestone: i.milestone ? { id: i.milestone.node_id, number: i.milestone.number, title: i.milestone.title, open: i.milestone.open_issues ?? 0 } : null,
698
+ };
699
+ }
700
+
701
+ /** The issue over REST (#603); not an issue (no such number, a PR) — null. */
702
+ function restIssue(io: Io, slug: string, number: number): Any {
703
+ const i = rest(io, `repos/${slug}/issues/${number}`, { missing: true });
704
+ return i && !i.pull_request ? restIssueNode(i) : null;
705
+ }
706
+
707
+ /** An issue in a REST list (sub-issues, blocked by) in the shape of the GraphQL answer. */
708
+ const restBrief = (x: Any, slug: string) => ({ id: x.node_id, databaseId: x.id, number: x.number, state: String(x.state).toUpperCase(), title: x.title, repository: { nameWithOwner: repoOfUrl(x.repository_url, slug) } });
709
+ const restSubIssues = (io: Io, slug: string, number: number) => ({ nodes: restList(io, `repos/${slug}/issues/${number}/sub_issues`).map((x) => restBrief(x, slug)) });
710
+
711
+ /** The issue's sub-issues, blocked by and epic over REST, in the shape of the GraphQL answer. */
712
+ function restLinks(io: Io, slug: string, number: number): { subIssues: Any; blockedBy: Any; parent: Any } {
713
+ const parent = rest(io, `repos/${slug}/issues/${number}/parent`, { missing: true });
714
+ return {
715
+ subIssues: restSubIssues(io, slug, number),
716
+ blockedBy: { nodes: restList(io, `repos/${slug}/issues/${number}/dependencies/blocked_by`).map((x) => restBrief(x, slug)) },
717
+ parent: parent && restIssueNode(parent),
718
+ };
719
+ }
720
+
721
+ /** The real `gh`: an exit code ≠ 0 is an exception with stderr. */
722
+ export function realGh(args: string[], stdin?: string): string {
723
+ const r = spawnSync("gh", args, { input: stdin, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
724
+ if (r.error) {
725
+ if ((r.error as NodeJS.ErrnoException).code === "ENOENT") throw new GhError("gh command not found; the gh CLI must be installed");
726
+ throw new GhError(`gh ${args.slice(0, 2).join(" ")}: ${r.error.message}`);
727
+ }
728
+ // gh api graphql exits with 1 on GraphQL errors, but the JSON with errors is in stdout — let graphql() parse it
729
+ if (r.status !== 0 && !r.stdout.trim().startsWith("{")) throw new GhError(`gh ${args.slice(0, 2).join(" ")}: ${(r.stderr || "").trim().slice(0, 500)}`);
730
+ return r.stdout;
731
+ }
732
+
733
+ // ----------------------------------------------------------------------------
734
+ // Reading the state
735
+ // ----------------------------------------------------------------------------
736
+
737
+ function pages<T>(fetch: (after: string | null) => { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: T[] }): T[] {
738
+ const out: T[] = [];
739
+ let after: string | null = null;
740
+ for (let i = 0; i < 100; i++) {
741
+ const page = fetch(after);
742
+ out.push(...page.nodes);
743
+ if (!page.pageInfo.hasNextPage) break;
744
+ after = page.pageInfo.endCursor;
745
+ }
746
+ return out;
747
+ }
748
+
749
+ const ref = (p: Any): ProjectRef => ({ id: p.id, number: p.number, title: p.title, url: p.url, closed: !!p.closed });
750
+
751
+ function loadRepo(io: Io, slug: string): State["repo"] {
752
+ const [owner, name] = slug.split("/") as [string, string];
753
+ const data = graphql(io, Q.RepoState, { owner, name });
754
+ const r = data?.repository;
755
+ if (!r) throw new GhError(`repository ${slug} not found or no access`);
756
+ const o = r.owner;
757
+ return {
758
+ id: r.id,
759
+ name: r.name,
760
+ nameWithOwner: r.nameWithOwner,
761
+ owner: {
762
+ org: o.__typename === "Organization",
763
+ id: o.id,
764
+ login: o.login,
765
+ issueTypes: (o.issueTypes?.nodes ?? []).filter(Boolean),
766
+ issueFields: (o.issueFields?.nodes ?? []).filter((f: Any) => f && f.name).map((f: Any) => ({ id: f.id, name: f.name, options: f.options ?? [] })),
767
+ },
768
+ linked: (r.projectsV2?.nodes ?? []).filter(Boolean).map(ref),
769
+ };
770
+ }
771
+
772
+ /** The repository's project: a linked one titled with the repository name, otherwise the first open linked one. */
773
+ const mainProject = (repo: State["repo"]) => repo.linked.find((p) => p.title === repo.name && !p.closed) ?? repo.linked.find((p) => !p.closed) ?? null;
774
+
775
+ export function loadState(io: Io, slug: string): State {
776
+ const [owner, name] = slug.split("/") as [string, string];
777
+ const repo = loadRepo(io, slug);
778
+ const openIssues = pages<{ id: string; number: number }>((after) => graphql(io, Q.RepoIssues, { owner, name, after }).repository.issues);
779
+ const main = mainProject(repo);
780
+ const labels = loadLabels(io, slug);
781
+ return { repo, project: main ? loadProject(io, main.id) : null, openIssues, labels, labelIssues: mergedIssues(io, slug, labels), decisions: loadDecisions(io, slug), merge: loadMerge(io, slug) };
782
+ }
783
+
784
+ const statusChecks = (rule: Any): { strict: boolean; contexts: string[] } | null =>
785
+ rule?.type === "REQUIRED_STATUS_CHECKS" ? { strict: !!rule.parameters?.strictRequiredStatusChecksPolicy, contexts: (rule.parameters?.requiredStatusChecks ?? []).map((c: Any) => c.context) } : null;
786
+
787
+ /** From the worst: a name reported by several apps gets the worst of their states. */
788
+ const WORST: CheckBucket[] = ["fail", "cancel", "pending", "skipping", "pass"];
789
+
790
+ /**
791
+ * The state of each check of a PR head by name, from the `statusCheckRollup` contexts (`HEAD_CHECKS`) — the one rule
792
+ * of `task show` and `project check`. A check-run: not completed — pending; success — pass; neutral, skipped —
793
+ * skipping; cancelled — cancel; any other conclusion — fail. Reruns of one name — the last check-run of each app (the
794
+ * largest `databaseId`), not the best one and not the order of the response; several apps — the worst of their states.
795
+ * A commit status — GitHub already keeps the latest of a context: success — pass, pending and expected — pending,
796
+ * otherwise fail. The copy — `CHECK_STATES` in ci-wait's `wait-ci.sh` (REST check-runs): both answer by one table,
797
+ * `tests/standards/check-run-state`.
798
+ */
799
+ export function checkStates(nodes: Any[]): Map<string, CheckBucket> {
800
+ const app = (c: Any) => JSON.stringify([c.name, c.checkSuite?.app?.databaseId ?? null]);
801
+ const last = new Map<string, Any>();
802
+ for (const c of nodes) {
803
+ if (c?.__typename === "CheckRun" && (!last.has(app(c)) || (last.get(app(c)).databaseId ?? 0) <= (c.databaseId ?? 0))) last.set(app(c), c);
804
+ }
805
+ const out = new Map<string, CheckBucket>();
806
+ const put = (name: string, state: CheckBucket) => {
807
+ const was = out.get(name);
808
+ if (!was || WORST.indexOf(state) < WORST.indexOf(was)) out.set(name, state);
809
+ };
810
+ // in the order of the response: task show lists the names as GitHub gives them
811
+ for (const c of nodes) {
812
+ if (c?.__typename === "StatusContext") put(c.context, c.state === "SUCCESS" ? "pass" : c.state === "PENDING" || c.state === "EXPECTED" ? "pending" : "fail");
813
+ if (c?.__typename !== "CheckRun" || last.get(app(c)) !== c) continue;
814
+ const s = c.status !== "COMPLETED" ? "pending" : c.conclusion === "SUCCESS" ? "pass" : c.conclusion === "NEUTRAL" || c.conclusion === "SKIPPED" ? "skipping" : c.conclusion === "CANCELLED" ? "cancel" : "fail";
815
+ put(c.name, s);
816
+ }
817
+ return out;
818
+ }
819
+
820
+ const rollupOf = (pr: Any): Any[] => pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts?.nodes ?? [];
821
+
822
+ /** A private repository on Free: GraphQL silently returns empty rules, REST — 403 "Upgrade to GitHub Pro…". */
823
+ function rulesetsUnavailable(io: Io, slug: string): string | null {
824
+ const out = io.gh(["api", `repos/${slug}/rulesets`]);
825
+ let res: Any;
826
+ try {
827
+ res = JSON.parse(out);
828
+ } catch {
829
+ throw new GhError(`gh api repos/${slug}/rulesets: the response is not JSON: ${out.slice(0, 200)}`);
830
+ }
831
+ return !Array.isArray(res) && /upgrade/i.test(res?.message ?? "") ? String(res.message) : null;
832
+ }
833
+
834
+ function loadMerge(io: Io, slug: string): Merge | null {
835
+ const [owner, name] = slug.split("/") as [string, string];
836
+ const r = graphql(io, Q.MergeRules, { owner, name })?.repository;
837
+ const b = r?.defaultBranchRef;
838
+ if (!b) return null;
839
+ const heads = (nodes: Any[]): Head[] =>
840
+ (nodes ?? []).filter((p) => p && p.baseRefName === b.name).map((p) => ({ number: p.number, checks: Object.fromEntries(checkStates(rollupOf(p))) })).filter((h) => Object.keys(h.checks).length);
841
+ const bp = b.branchProtectionRule;
842
+ const rules = (b.rules?.nodes ?? []).flatMap((x: Any) => {
843
+ const sc = statusChecks(x);
844
+ return sc ? [{ source: `ruleset ${q(x.repositoryRuleset?.name ?? "?")}`, contexts: sc.contexts }] : [];
845
+ });
846
+ if (bp?.requiredStatusCheckContexts?.length) rules.push({ source: "branch protection", contexts: bp.requiredStatusCheckContexts });
847
+ const sets: Any[] = r.rulesets?.nodes ?? [];
848
+ const o = sets.find((x) => x?.name === RULESET) ?? sets.find((x) => x?.name === FORMER_RULESET);
849
+ const osc = o ? ((o.rules?.nodes ?? []).map(statusChecks).find(Boolean) ?? { strict: false, contexts: [] }) : null;
850
+ return {
851
+ branch: b.name,
852
+ unavailable: r.isPrivate ? rulesetsUnavailable(io, slug) : null,
853
+ rules,
854
+ own: o
855
+ ? {
856
+ id: o.id,
857
+ name: o.name,
858
+ enforcement: o.enforcement,
859
+ include: o.conditions?.refName?.include ?? [],
860
+ ...osc!,
861
+ bypassAdmin: (o.bypassActors?.nodes ?? []).some((a: Any) => a?.repositoryRoleDatabaseId === ADMIN_ROLE && a.bypassMode === "ALWAYS"),
862
+ }
863
+ : null,
864
+ merged: heads(r.merged?.nodes).slice(-STABLE_PRS),
865
+ open: heads(r.open?.nodes).filter((h) => Object.values(h.checks).every((c) => c !== "pending")),
866
+ methods: { merge: !!r.mergeCommitAllowed, squash: !!r.squashMergeAllowed, rebase: !!r.rebaseMergeAllowed },
867
+ };
868
+ }
869
+
870
+ /** Tasks of old labels whose name is taken (by another label or an old label earlier in the list) — fix merges them. */
871
+ function mergedIssues(io: Io, slug: string, labels: Label[]): State["labelIssues"] {
872
+ const [owner, name] = slug.split("/") as [string, string];
873
+ const seen = new Set(labels.filter((l) => !legacyDecision(l.name)).map((l) => l.name));
874
+ const out: State["labelIssues"] = {};
875
+ for (const l of labels) {
876
+ const old = legacyDecision(l.name);
877
+ if (!old) continue;
878
+ if (seen.has(old.name)) out[l.id] = pages<{ id: string; number: number }>((after) => graphql(io, Q.LabelIssues, { owner, name, label: l.name, after }).repository.label.issues);
879
+ seen.add(old.name);
880
+ }
881
+ return out;
882
+ }
883
+
884
+ function loadLabels(io: Io, slug: string): Label[] {
885
+ // the cloud (#603) — REST: the labels without their open tasks (task new doesn't need them)
886
+ if (inCloud(io)) return restList(io, `repos/${slug}/labels`).map((l) => ({ id: l.node_id, name: l.name, color: l.color, description: l.description ?? null, open: { total: 0, numbers: [] } }));
887
+ const [owner, name] = slug.split("/") as [string, string];
888
+ return pages<Any>((after) => graphql(io, Q.RepoLabels, { owner, name, after }).repository.labels).map(({ issues, ...l }) => ({
889
+ ...l,
890
+ open: { total: issues?.totalCount ?? 0, numbers: (issues?.nodes ?? []).map((i: Any) => i.number) },
891
+ }));
892
+ }
893
+
894
+ /**
895
+ * Architecture model modules and their directories: the model text goes to a temporary file, `modules` is printed by
896
+ * a separate process of the same runtime (Bun strips types; the model doesn't need the spec skill's `import type`).
897
+ * Didn't load — the reason as a string.
898
+ */
899
+ export function modelModules(text: string): Record<string, string[]> | string {
900
+ const dir = mkdtempSync(path.join(os.tmpdir(), "github-model-"));
901
+ try {
902
+ writeFileSync(path.join(dir, "model.ts"), text);
903
+ writeFileSync(
904
+ path.join(dir, "modules.mjs"),
905
+ 'const m = await import("./model.ts");\n' +
906
+ "const paths = (p) => (Array.isArray(p) ? p : [p]).map((x) => String(x).replace(/\\/+$/, \"\"));\n" +
907
+ "console.log(JSON.stringify(Object.fromEntries(Object.entries(m.default?.modules ?? {}).map(([k, v]) => [k, paths(v?.path ?? [])]))));\n",
908
+ );
909
+ const r = spawnSync(process.execPath, ["--no-env-file", path.join(dir, "modules.mjs")], { encoding: "utf8", timeout: 30_000 });
910
+ if (r.status !== 0) return ((r.stderr || r.error?.message || "").trim().split("\n").find((l) => /error/i.test(l)) ?? "the process exited with an error").trim().slice(0, 200);
911
+ return JSON.parse(r.stdout) as Record<string, string[]>;
912
+ } catch (e) {
913
+ return (e as Error).message;
914
+ } finally {
915
+ rmSync(dir, { recursive: true, force: true });
916
+ }
917
+ }
918
+
919
+ /** Default-branch decisions: spec tree folders by kind, and model modules for architecture. */
920
+ export function loadDecisions(io: Io, slug: string): Decisions {
921
+ const [owner, name] = slug.split("/") as [string, string];
922
+ const r = inCloud(io) ? restSpecTree(io, slug) : (graphql(io, Q.SpecDecisions, { owner, name })?.repository ?? {});
923
+ const dirs = (t: Any): string[] => (t?.entries ?? []).filter((e: Any) => e.type === "tree").map((e: Any) => e.name as string);
924
+ const names = { capability: dirs(r.capabilities), standard: dirs(r.standards), architecture: dirs(r.architecture) };
925
+ let modelError: string | null = null;
926
+ if (r.model) {
927
+ const mods = modelModules(r.model.text ?? "");
928
+ if (typeof mods === "string") modelError = mods;
929
+ else names.architecture.push(...Object.keys(mods));
930
+ }
931
+ for (const k of DECISION_KINDS) names[k] = [...new Set(names[k])].sort();
932
+ return { names, modelError };
933
+ }
934
+
935
+ /** SpecDecisions over the REST contents of the default branch — the cloud (#603): folders as `tree` entries, the model's text. */
936
+ function restSpecTree(io: Io, slug: string): Any {
937
+ const dir = (kind: DecisionKind) => {
938
+ const entries = rest(io, `repos/${slug}/contents/tests/${DECISIONS[kind].dir}`, { missing: true });
939
+ return Array.isArray(entries) ? { entries: entries.map((e: Any) => ({ name: e.name, type: e.type === "dir" ? "tree" : "blob" })) } : null;
940
+ };
941
+ const model = rest(io, `repos/${slug}/contents/${MODEL_PATH}`, { missing: true });
942
+ return { capabilities: dir("capability"), standards: dir("standard"), architecture: dir("architecture"), model: model?.content ? { text: Buffer.from(model.content, "base64").toString("utf8") } : null };
943
+ }
944
+
945
+ /**
946
+ * `capability:billing` → the decision's kind and name; anything else — null. An old-form label or the kind of a new
947
+ * decision in `--labels`.
948
+ */
949
+ export function legacyDecision(label: string): { kind: DecisionKind; name: string } | null {
950
+ const i = label.indexOf(":");
951
+ const kind = label.slice(0, i) as DecisionKind;
952
+ return i > 0 && DECISION_KINDS.includes(kind) && label.length > i + 1 ? { kind, name: label.slice(i + 1) } : null;
953
+ }
954
+
955
+ /**
956
+ * A decision label is marked by its description `<marker> <paths>`: a regular label's color may match, and so may its
957
+ * name. The marker is written in the project language and recognized in any dictionary language: fix rewrites a
958
+ * description in another language rather than taking the label for a regular one.
959
+ */
960
+ const decisionMark = () => `${N.labelMarkers.decision} `;
961
+ const decisionPath = (kind: DecisionKind, name: string) => (kind === "architecture" ? locale.fill(N.labelMarkers.architecturePath, { name }) : `tests/${DECISIONS[kind].dir}/${name}`);
962
+ /** The marker of any dictionary language the description starts with; none — null. */
963
+ const markOf = (description: string | null | undefined, marks: string[]) => marks.map((m) => `${m} `).find((m) => description?.startsWith(m)) ?? null;
964
+
965
+ /**
966
+ * A decision label: the senior kind's color, the paths of all kinds in the description; GitHub won't accept one longer
967
+ * than 100 characters — then the kinds.
968
+ */
969
+ export function decisionLabel(name: string, kinds: DecisionKind[]): { name: string; color: string; description: string } {
970
+ const ks = DECISION_KINDS.filter((k) => kinds.includes(k));
971
+ const full = decisionMark() + ks.map((k) => decisionPath(k, name)).join("; ");
972
+ return { name, color: DECISIONS[ks[0]!].color, description: full.length <= 100 ? full : decisionMark() + ks.join(", ") };
973
+ }
974
+
975
+ /** A decision label's kinds — by its description; a regular label — null. */
976
+ export function labelKinds(description: string | null | undefined): DecisionKind[] | null {
977
+ const mark = markOf(description, locale.every((n) => n.labelMarkers.decision));
978
+ if (!mark) return null;
979
+ const parts = description!.slice(mark.length).split(/; |, /);
980
+ return DECISION_KINDS.filter((k) => parts.some((p) => p === k || p.startsWith(`tests/${DECISIONS[k].dir}`)));
981
+ }
982
+
983
+ /**
984
+ * A slice label is marked by its description `<slice marker> <slice>[ — note]` (the marker is `labelMarkers.slice` of
985
+ * any dictionary language): a slice of the project's tasks (apps, teams), not a decision, though its name may match a
986
+ * model module. The slice lives with the label, not in `.agents/elves.json`: `install` and `update` rewrite the
987
+ * install manifest, and `check` reads everything through the API.
988
+ */
989
+ function labelDimension(description: string | null | undefined): string | null {
990
+ const mark = markOf(description, locale.every((n) => n.labelMarkers.slice));
991
+ if (!mark) return null;
992
+ return description!.slice(mark.length).split(" — ")[0]!.trim() || null;
993
+ }
994
+
995
+ /** Default-branch decisions by name: name → its kinds (the senior one first). */
996
+ function decisionsByName(d: Decisions): Map<string, DecisionKind[]> {
997
+ const out = new Map<string, DecisionKind[]>();
998
+ for (const k of DECISION_KINDS) for (const n of d.names[k]) out.set(n, [...(out.get(n) ?? []), k]);
999
+ return out;
1000
+ }
1001
+
1002
+ function loadProject(io: Io, id: string, withItems = true): Project {
1003
+ const p = graphql(io, Q.ProjectState, { id }).node;
1004
+ if (!p) throw new GhError(`project ${id} not found or no access (needs the project scope: gh auth refresh -s project)`);
1005
+ const items = withItems ? pages<Any>((after) => graphql(io, Q.ProjectItems, { id, after }).node.items) : [];
1006
+ return {
1007
+ ...ref(p),
1008
+ views: p.views.nodes.map((v: Any) => ({
1009
+ id: v.id,
1010
+ number: v.number,
1011
+ name: v.name,
1012
+ layout: v.layout,
1013
+ filter: v.filter ?? null,
1014
+ sortBy: (v.sortByFields?.nodes ?? []).map((s: Any) => s.field?.name).filter(Boolean),
1015
+ columnsBy: (v.verticalGroupByFields?.nodes ?? []).map((f: Any) => f?.name).filter(Boolean),
1016
+ })),
1017
+ fields: p.fields.nodes
1018
+ .filter((f: Any) => f && f.id)
1019
+ .map((f: Any) => ({ id: f.id, name: f.name, dataType: f.dataType, isIssueField: !!f.isIssueField, options: f.options ?? null })),
1020
+ workflows: p.workflows.nodes.filter(Boolean),
1021
+ items: items.map((it: Any) => ({
1022
+ id: it.id,
1023
+ issue: it.content?.__typename === "Issue" ? { id: it.content.id, number: it.content.number, state: it.content.state, stateReason: it.content.stateReason ?? null } : null,
1024
+ status: it.status?.name ?? null,
1025
+ })),
1026
+ };
1027
+ }
1028
+
1029
+ // ----------------------------------------------------------------------------
1030
+ // Checking against the canon
1031
+ // ----------------------------------------------------------------------------
1032
+
1033
+ const nums = (xs: { number: number }[]) => xs.map((x) => `#${x.number}`).join(", ");
1034
+ const q = (s: string) => `«${s}»`;
1035
+
1036
+ /** All discrepancies with the canon by item; each has fix steps. No project — only the linking item. */
1037
+ export function analyze(s: State): Check[] {
1038
+ const p = s.project;
1039
+ const checks: Check[] = [];
1040
+ const add = (key: string, title: string) => {
1041
+ const c: Check = { key, title, problems: [], notes: [] };
1042
+ checks.push(c);
1043
+ return Object.assign((text: string, ...steps: Step[]) => c.problems.push({ text, steps }), { note: (text: string) => c.notes.push(text), skip: (why: string) => void (c.skip = why) });
1044
+ };
1045
+
1046
+ const link = add("link", `Project linked to the repository and named ${q(s.repo.name)}`);
1047
+ // the default-branch rule and the merge method don't depend on the project
1048
+ const merge = () => {
1049
+ if (!s.merge) return;
1050
+ analyzeMerge(s.merge, s.repo.id, add("merge", `Merge into ${s.merge.branch} — only with green required checks`));
1051
+ analyzeMethod(s.merge, s.repo.nameWithOwner, add("method", `PR merge method — rebase only`));
1052
+ };
1053
+ if (!p) {
1054
+ link("no open project is linked to the repository", { kind: "api", text: `find, copy from the template or create project ${q(s.repo.name)} and link it to ${s.repo.nameWithOwner}` });
1055
+ merge();
1056
+ return checks;
1057
+ }
1058
+ const used = p.items.length > 0;
1059
+ const guarded = (text: string, ...mutations: Mutation[]): Step => ({ kind: used ? "confirm" : "api", text, mutations });
1060
+ const api = (text: string, ...mutations: Mutation[]): Step => ({ kind: "api", text, mutations });
1061
+ const ui = (text: string, url: string): Step => ({ kind: "ui", text, url });
1062
+
1063
+ if (p.title !== s.repo.name) link(`title ${q(p.title)}`, guarded(`rename project ${q(p.title)} → ${q(s.repo.name)}`, { op: "RenameProject", input: { projectId: p.id, title: s.repo.name } }));
1064
+ for (const other of s.repo.linked.filter((x) => x.id !== p.id)) {
1065
+ link(`another project is linked: ${q(other.title)} (${other.url})`, { kind: "confirm", text: `unlink project ${q(other.title)} from the repository`, mutations: [{ op: "UnlinkProject", input: { projectId: other.id, repositoryId: s.repo.id } }] });
1066
+ }
1067
+
1068
+ // Views: by name; missing — rename an extra one of the same layout or create one; extra ones — delete.
1069
+ const views = add("views", `Views ${canonViews().map((v) => q(v.name)).join(", ")} — the right layout, no filter, no others`);
1070
+ const canonNames = new Set<string>(canonViews().map((v) => v.name));
1071
+ const spare = p.views.filter((v) => !canonNames.has(v.name));
1072
+ for (const c of canonViews()) {
1073
+ const v = p.views.find((x) => x.name === c.name);
1074
+ if (!v) {
1075
+ const cand = spare.find((x) => x.layout === c.layout);
1076
+ if (cand) {
1077
+ spare.splice(spare.indexOf(cand), 1);
1078
+ views(`missing ${q(c.name)}`, guarded(`rename view ${q(cand.name)} → ${q(c.name)}`, { op: "UpdateView", input: { viewId: cand.id, name: c.name } }));
1079
+ } else {
1080
+ views(`missing ${q(c.name)}`, api(`create view ${q(c.name)} (${c.layout})`, { op: "CreateView", input: { projectId: p.id, name: c.name, layout: c.layout } }));
1081
+ }
1082
+ continue;
1083
+ }
1084
+ if (v.layout !== c.layout) views(`${q(c.name)} — ${v.layout}`, api(`${q(c.name)}: layout ${c.layout}`, { op: "UpdateView", input: { viewId: v.id, layout: c.layout } }));
1085
+ if (v.filter) views(`${q(c.name)} has filter ${q(v.filter)}`, api(`${q(c.name)}: remove filter ${q(v.filter)}`, { op: "UpdateView", input: { viewId: v.id, filter: "" } }));
1086
+ if (c.layout === "BOARD_LAYOUT" && v.layout === c.layout && v.columnsBy[0] !== STATUS) {
1087
+ views(`${q(c.name)} columns not by Status`, ui(`${q(c.name)}: View options → Column by → Status → Save`, `${p.url}/views/${v.number}`));
1088
+ }
1089
+ }
1090
+ for (const v of spare) views(`extra ${q(v.name)}`, guarded(`delete view ${q(v.name)}`, { op: "DeleteView", input: { viewId: v.id } }));
1091
+
1092
+ // Status: options by name or by a known English name — with the same id; missing ones — new.
1093
+ const status = add("status", `${STATUS}: ${statusOptions().join(" → ")}`);
1094
+ const sf = p.fields.find((f) => f.name === STATUS && f.options);
1095
+ if (!sf) {
1096
+ status(`no ${STATUS} field`, api(`create field ${STATUS}`, { op: "CreateField", input: { projectId: p.id, dataType: "SINGLE_SELECT", name: STATUS, singleSelectOptions: statusOptions().map((name) => ({ name, color: "GRAY", description: "" })) } }));
1097
+ } else if (sf.options!.map((o) => o.name).join("\n") !== statusOptions().join("\n")) {
1098
+ const left = [...sf.options!];
1099
+ const take = (pred: (o: Option) => boolean) => {
1100
+ const i = left.findIndex(pred);
1101
+ return i < 0 ? null : left.splice(i, 1)[0]!;
1102
+ };
1103
+ const aliases = statusAliases();
1104
+ const next = statusOptions().map((name) => {
1105
+ const o = take((x) => x.name === name) ?? take((x) => aliases[x.name.toLowerCase()] === name);
1106
+ return o ? { id: o.id, name, color: o.color, description: o.description ?? "" } : { name, color: "GRAY", description: "" };
1107
+ });
1108
+ const drop = left.length ? `; delete ${left.map((o) => q(o.name)).join(", ")}` : "";
1109
+ status(`options: ${sf.options!.map((o) => o.name).join(", ") || "none"}`, guarded(`${STATUS}: ${statusOptions().join(", ")} (rename the existing ones keeping the id${drop})`, { op: "UpdateField", input: { fieldId: sf.id, singleSelectOptions: next } }));
1110
+ }
1111
+
1112
+ // Fields: four numeric ones; a field in another dictionary language is renamed with the same id (the tasks' values are
1113
+ // kept); no custom fields beyond the canon; in an organization Priority is an issue field.
1114
+ const fields = add("fields", `Fields ${numberFields().map(q).join(", ")} (Number), no other custom ones`);
1115
+ const fieldAlias = fieldAliases();
1116
+ const renamed = new Set<Field>();
1117
+ for (const name of numberFields()) {
1118
+ const f = p.fields.find((x) => x.name === name);
1119
+ const create: Mutation = { op: "CreateField", input: { projectId: p.id, dataType: "NUMBER", name } };
1120
+ const other = f ? undefined : p.fields.find((x) => x.dataType === "NUMBER" && !renamed.has(x) && fieldAlias[x.name] === name);
1121
+ if (other) {
1122
+ renamed.add(other);
1123
+ fields(`missing ${q(name)}`, guarded(`rename field ${q(other.name)} → ${q(name)} (task values are kept)`, { op: "UpdateField", input: { fieldId: other.id, name } }));
1124
+ } else if (!f) fields(`missing ${q(name)}`, api(`create field ${q(name)} (Number)`, create));
1125
+ else if (f.dataType !== "NUMBER") fields(`${q(name)} — ${f.dataType}`, { kind: "confirm", text: `recreate field ${q(name)} as Number (the field's values will be lost)`, mutations: [{ op: "DeleteField", input: { fieldId: f.id } }, create] });
1126
+ }
1127
+ const orgPriority = s.repo.owner.org ? s.repo.owner.issueFields.find((f) => f.name === PRIORITY) : undefined;
1128
+ const isCanon = (f: Field) => BUILTIN_FIELDS.has(f.dataType) || f.name === STATUS || numberFields().includes(f.name) || (s.repo.owner.org && f.isIssueField && f.name === PRIORITY);
1129
+ const ownPriority = p.fields.find((f) => f.name === PRIORITY && !f.isIssueField);
1130
+ for (const f of p.fields.filter((x) => !isCanon(x) && x !== ownPriority && !renamed.has(x))) {
1131
+ fields(`extra ${q(f.name)} (${f.dataType}${f.isIssueField ? ", issue field" : ""})`, guarded(`delete field ${q(f.name)}`, { op: "DeleteField", input: { fieldId: f.id } }));
1132
+ }
1133
+ if (ownPriority && !s.repo.owner.org) fields(`extra ${q(PRIORITY)} — a personal account keeps no priority`, guarded(`delete field ${q(PRIORITY)}`, { op: "DeleteField", input: { fieldId: ownPriority.id } }));
1134
+
1135
+ if (s.repo.owner.org) {
1136
+ const org = s.repo.owner.login;
1137
+ const prio = add("priority", `${PRIORITY} — the organization's issue field in the project, ${q(N.views.table)} and ${q(N.views.board)} are sorted by it`);
1138
+ const connected = p.fields.some((f) => f.isIssueField && f.name === PRIORITY);
1139
+ if (!connected) {
1140
+ const connect: Mutation[] = orgPriority ? [{ op: "AddIssueField", input: { projectId: p.id, issueFieldId: orgPriority.id } }] : [];
1141
+ if (ownPriority) {
1142
+ prio(`own project field ${q(PRIORITY)} instead of the issue field`, { kind: "confirm", text: `delete the own field ${q(PRIORITY)} and connect the issue field ${PRIORITY}`, mutations: [{ op: "DeleteField", input: { fieldId: ownPriority.id } }, ...connect] });
1143
+ } else if (orgPriority) {
1144
+ prio(`issue field ${PRIORITY} not connected`, api(`connect the issue field ${PRIORITY}`, ...connect));
1145
+ }
1146
+ if (!orgPriority) prio(`the organization has no issue field ${PRIORITY}`, ui(`create the issue field ${PRIORITY} (single select: Urgent, High, Medium, Low), then fix again`, `https://github.com/organizations/${org}/settings/issue-fields`));
1147
+ }
1148
+ for (const name of [N.views.table, N.views.board]) {
1149
+ const v = p.views.find((x) => x.name === name);
1150
+ if (v && v.sortBy[0] !== PRIORITY) prio(`${q(name)} not sorted by ${PRIORITY}`, ui(`${q(name)}: View options → Sort by → ${PRIORITY} → Save`, `${p.url}/views/${v.number}`));
1151
+ }
1152
+
1153
+ const types = add("types", `Issue types of the organization: ${issueTypes().join(", ")} — and only these`);
1154
+ const t = s.repo.owner.issueTypes;
1155
+ const url = `https://github.com/organizations/${org}/settings/issue-types`;
1156
+ const claimed = new Set<string>();
1157
+ const aliases = typeAliases();
1158
+ for (const name of issueTypes()) {
1159
+ const exact = t.find((x) => x.name === name);
1160
+ if (exact) {
1161
+ claimed.add(exact.id);
1162
+ if (!exact.isEnabled) types(`${q(name)} disabled`, { kind: "confirm", text: `enable type ${q(name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: exact.id, isEnabled: true } }] });
1163
+ continue;
1164
+ }
1165
+ const alias = t.find((x) => !claimed.has(x.id) && aliases[x.name.toLowerCase()] === name);
1166
+ if (alias) {
1167
+ claimed.add(alias.id);
1168
+ types(`missing ${q(name)}`, { kind: "confirm", text: `rename type ${q(alias.name)} → ${q(name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: alias.id, name, isEnabled: true } }] });
1169
+ } else {
1170
+ types(`missing ${q(name)}`, { kind: "confirm", text: `create type ${q(name)}`, url, mutations: [{ op: "CreateIssueType", input: { ownerId: s.repo.owner.id, name, isEnabled: true, color: name === N.issueTypes.epic ? "PURPLE" : name === N.issueTypes.bug ? "RED" : "GRAY" } }] });
1171
+ }
1172
+ }
1173
+ for (const x of t.filter((x) => x.isEnabled && !claimed.has(x.id))) {
1174
+ types(`extra ${q(x.name)}`, { kind: "confirm", text: `disable type ${q(x.name)}`, url, mutations: [{ op: "UpdateIssueType", input: { issueTypeId: x.id, isEnabled: false } }] });
1175
+ }
1176
+ }
1177
+
1178
+ // Workflows: the API doesn't turn them on or show the target — only the UI. An unconfigured one isn't in the list.
1179
+ const wf = add("workflows", `Workflows ${[WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD].map(q).join(", ")} are on`);
1180
+ const how: Record<string, string> = {
1181
+ [WORKFLOW_ADDED]: `Edit → Set value → ${STATUS}: ${N.status.backlog}`,
1182
+ [WORKFLOW_CLOSED]: `Edit → Set value → ${STATUS}: ${N.status.done}`,
1183
+ // is:open — otherwise an updated closed task (not planned too) comes back into the project
1184
+ [WORKFLOW_AUTO_ADD]: `Edit → repository ${s.repo.name}, filter is:issue is:open (Enter)`,
1185
+ };
1186
+ for (const name of [WORKFLOW_ADDED, WORKFLOW_CLOSED, WORKFLOW_AUTO_ADD]) {
1187
+ const w = p.workflows.find((x) => x.name === name);
1188
+ if (w?.enabled) continue;
1189
+ // an unconfigured workflow isn't in the API — a link to the list, it's in the menu on the left there
1190
+ wf(`${q(name)} ${w ? "disabled" : "not configured"}`, ui(`${q(name)}: ${how[name]} → Save and turn on workflow`, `${p.url}/workflows${w ? `/${w.fullDatabaseId}` : ""}`));
1191
+ }
1192
+
1193
+ // Tasks: statuses by the canon. Closed ones not in "Done" — a symptom of "Item closed" on a deleted Status option.
1194
+ const opt = (name: string) => sf?.options?.find((o) => o.name === name)?.id;
1195
+ const setStatus = (it: Item, name: string): Mutation => ({ op: "SetItemStatus", input: { projectId: p.id, itemId: it.id, fieldId: sf!.id, value: { singleSelectOptionId: opt(name) } } });
1196
+ const statusReady = !!(opt(N.status.backlog) && opt(N.status.done));
1197
+ const closedAdd = add("closed", `Closed tasks — in ${q(N.status.done)}, closed without being done — outside the project`);
1198
+ const issues = p.items.filter((it) => it.issue);
1199
+ // "Done" in another dictionary language is the same option: the Status item renames it, no need to move tasks
1200
+ const doneAlias = statusAliases();
1201
+ const notDone = issues.filter((it) => it.issue!.state === "CLOSED" && !NOT_DONE_REASONS.has(it.issue!.stateReason ?? "") && doneAlias[(it.status ?? "").toLowerCase()] !== N.status.done);
1202
+ if (notDone.length) {
1203
+ const steps: Step[] = [];
1204
+ if (statusReady) steps.push(api(`${STATUS} ${q(N.status.done)}: ${nums(notDone.map((it) => it.issue!))}`, ...notDone.map((it) => setStatus(it, N.status.done))));
1205
+ const closed = p.workflows.find((x) => x.name === WORKFLOW_CLOSED);
1206
+ // fix sets the statuses itself, and the workflow's target isn't visible from the API — check it in the UI, even
1207
+ // when the symptom is gone
1208
+ if (closed?.enabled) steps.push({ ...ui(`${q(WORKFLOW_CLOSED)}: check Set value → ${STATUS}: ${N.status.done}, otherwise Edit → ${N.status.done} → Save`, `${p.url}/workflows/${closed.fullDatabaseId}`), sticky: true });
1209
+ closedAdd(`not in ${q(N.status.done)}: ${nums(notDone.map((it) => it.issue!))}`, ...steps);
1210
+ }
1211
+ const dropped = issues.filter((it) => it.issue!.state === "CLOSED" && NOT_DONE_REASONS.has(it.issue!.stateReason ?? ""));
1212
+ if (dropped.length) closedAdd(`closed without being done, but in the project: ${nums(dropped.map((it) => it.issue!))}`, api(`remove from the project ${nums(dropped.map((it) => it.issue!))}`, ...dropped.map((it) => ({ op: "DeleteItem", input: { projectId: p.id, itemId: it.id } }))));
1213
+
1214
+ const open = add("open", `Open tasks of the repository — in the project, with ${STATUS}`);
1215
+ const inProject = new Set(issues.map((it) => it.issue!.id));
1216
+ const missing = s.openIssues.filter((i) => !inProject.has(i.id));
1217
+ if (missing.length) open(`not in the project: ${nums(missing)}`, api(`add to the project ${nums(missing)}`, ...missing.map((i) => ({ op: "AddItem", input: { projectId: p.id, contentId: i.id } }))));
1218
+ const noStatus = issues.filter((it) => it.issue!.state === "OPEN" && !it.status);
1219
+ if (noStatus.length) open(`without ${STATUS}: ${nums(noStatus.map((it) => it.issue!))}`, ...(statusReady ? [api(`${STATUS} ${q(N.status.backlog)}: ${nums(noStatus.map((it) => it.issue!))}`, ...noStatus.map((it) => setStatus(it, N.status.backlog)))] : []));
1220
+
1221
+ // Decision labels: by the spec tree and the model of the default branch. Deleting, renaming by a guess, merging, making
1222
+ // a regular label a decision one — only with --confirm: the label is on tasks. One extra and one missing of the same
1223
+ // kind — a renamed folder, not two decisions. The old form `kind:name` → name — fix does it itself, decision or not:
1224
+ // tasks keep the label, there's no guess. A label without a decision on an open task is a new decision (task new sets
1225
+ // it before the PR): an ○ line, not deleted and not renamed by a guess — that would take it away from the task. A
1226
+ // slice label with a decision's name is a different slice of tasks: an ○ line, fix doesn't touch it, and the decision
1227
+ // stays without a label (the name is taken).
1228
+ const labels = add("labels", "Decision labels — the decision's name, the kind by color; by the spec tree and the model of the default branch");
1229
+ const d = s.decisions;
1230
+ if (d.modelError) labels(`model ${MODEL_PATH} doesn't load — module labels aren't checked: ${d.modelError}`);
1231
+ const want = decisionsByName(d);
1232
+ // the model wasn't read — it's unknown which modules exist: architecture labels aren't deleted or re-described
1233
+ const unsure = (kinds: DecisionKind[]) => !!d.modelError && kinds.includes("architecture");
1234
+ const taken = new Map(s.labels.filter((l) => !legacyDecision(l.name)).map((l) => [l.name, l]));
1235
+ const orphans: { label: Label; kinds: DecisionKind[] }[] = [];
1236
+ const dimensions = new Map<string, string[]>();
1237
+ for (const l of s.labels) {
1238
+ const old = legacyDecision(l.name);
1239
+ if (!old) continue;
1240
+ const to = decisionLabel(old.name, want.get(old.name) ?? [old.kind]);
1241
+ const into = taken.get(old.name);
1242
+ if (!into) {
1243
+ taken.set(old.name, { ...l, ...to });
1244
+ labels(`label ${q(l.name)} of the old form \`kind:name\``, api(`rename label ${q(l.name)} → ${q(old.name)} (tasks keep it)`, { op: "UpdateLabel", input: { id: l.id, ...to } }));
1245
+ continue;
1246
+ }
1247
+ const issues = s.labelIssues[l.id] ?? [];
1248
+ labels(`label ${q(l.name)} of the old form, label ${q(old.name)} already exists`, {
1249
+ kind: "confirm",
1250
+ text: `merge label ${q(l.name)} into ${q(old.name)}: ${issues.length ? `${nums(issues)} get ${q(old.name)}, ` : ""}the old one is deleted`,
1251
+ mutations: [...issues.map((i) => ({ op: "AddLabels", input: { labelableId: i.id, labelIds: [into.id] } })), { op: "DeleteLabel", input: { id: l.id } }],
1252
+ });
1253
+ }
1254
+ for (const l of s.labels) {
1255
+ if (legacyDecision(l.name)) continue;
1256
+ const kinds = labelKinds(l.description);
1257
+ const w = want.get(l.name);
1258
+ if (!w) {
1259
+ if (kinds) orphans.push({ label: l, kinds });
1260
+ continue;
1261
+ }
1262
+ const dim = labelDimension(l.description);
1263
+ if (dim) {
1264
+ dimensions.set(dim, [...(dimensions.get(dim) ?? []), l.name]);
1265
+ continue;
1266
+ }
1267
+ const to = decisionLabel(l.name, w);
1268
+ if (!kinds) {
1269
+ labels(`label ${q(l.name)} isn't a decision label, but decision ${l.name} is in the default branch`, { kind: "confirm", text: `make ${q(l.name)} a decision label: color ${to.color}, description ${q(to.description)}`, mutations: [{ op: "UpdateLabel", input: { id: l.id, color: to.color, description: to.description } }] });
1270
+ continue;
1271
+ }
1272
+ const recolor = l.color.toLowerCase() !== to.color.toLowerCase();
1273
+ if (!recolor && (l.description === to.description || unsure(kinds))) continue;
1274
+ const text = recolor ? `label ${q(l.name)} is colored ${l.color}, not ${to.color}` : `label ${q(l.name)}: wrong decision kinds in the description`;
1275
+ labels(text, api(`${recolor ? "recolor" : "rewrite the description of"} label ${q(l.name)}: ${to.color}, ${q(to.description)}`, { op: "UpdateLabel", input: { id: l.id, color: to.color, description: to.description } }));
1276
+ }
1277
+ for (const [dim, names] of dimensions) {
1278
+ labels.note(`slice ${q(dim)}: labels ${names.map(q).join(", ")} aren't decision labels, decisions of the same name aren't labeled`);
1279
+ }
1280
+ const unlabeled = [...want.keys()].filter((n) => !taken.has(n));
1281
+ const sure = orphans.filter((o) => !unsure(o.kinds));
1282
+ for (const { label: l } of sure.filter((o) => o.label.open.total)) {
1283
+ const more = l.open.total - l.open.numbers.length;
1284
+ labels.note(`new decision ${q(l.name)}: not in the default branch yet, the label is on open tasks ${l.open.numbers.map((n) => `#${n}`).join(", ")}${more > 0 ? ` and ${more} more` : ""}`);
1285
+ }
1286
+ const live = sure.filter((o) => !o.label.open.total);
1287
+ const paired = new Set<string>();
1288
+ for (const kind of DECISION_KINDS) {
1289
+ const os = live.filter((o) => o.kinds.includes(kind) && !paired.has(o.label.id));
1290
+ const ms = unlabeled.filter((n) => want.get(n)!.includes(kind) && !paired.has(n));
1291
+ if (os.length !== 1 || ms.length !== 1) continue;
1292
+ const o = os[0]!.label;
1293
+ const to = decisionLabel(ms[0]!, want.get(ms[0]!)!);
1294
+ paired.add(o.id).add(to.name);
1295
+ labels(`label ${q(o.name)} without a decision, decision ${to.name} without a label`, { kind: "confirm", text: `rename label ${q(o.name)} → ${q(to.name)} (tasks keep it)`, mutations: [{ op: "UpdateLabel", input: { id: o.id, ...to } }] });
1296
+ }
1297
+ for (const { label: o } of live.filter((x) => !paired.has(x.label.id))) {
1298
+ labels(`label ${q(o.name)} without a decision in the default branch, no open tasks have it`, { kind: "confirm", text: `delete label ${q(o.name)}`, mutations: [{ op: "DeleteLabel", input: { id: o.id } }] });
1299
+ }
1300
+ const fresh = unlabeled.filter((n) => !paired.has(n)).map((n) => decisionLabel(n, want.get(n)!));
1301
+ if (fresh.length) {
1302
+ labels(`decisions without a label: ${fresh.map((x) => x.name).join(", ")}`, api(`create labels ${fresh.map((x) => q(x.name)).join(", ")}`, ...fresh.map((x) => ({ op: "CreateLabel", input: { repositoryId: s.repo.id, ...x } }))));
1303
+ }
1304
+
1305
+ merge();
1306
+ return checks;
1307
+ }
1308
+
1309
+ /**
1310
+ * The `RULESET` ruleset: the default branch, required checks without strict (the field is mandatory in the API),
1311
+ * bypass — admin (the owner's direct push).
1312
+ */
1313
+ function rulesetInput(contexts: string[]): Record<string, unknown> {
1314
+ return {
1315
+ name: RULESET,
1316
+ target: "BRANCH",
1317
+ enforcement: "ACTIVE",
1318
+ conditions: { refName: { include: ["~DEFAULT_BRANCH"], exclude: [] } },
1319
+ rules: [{ type: "REQUIRED_STATUS_CHECKS", parameters: { requiredStatusChecks: { requiredStatusChecks: contexts.map((context) => ({ context })), strictRequiredStatusChecksPolicy: false } } }],
1320
+ bypassActors: [{ repositoryRoleDatabaseId: ADMIN_ROLE, bypassMode: "ALWAYS" }],
1321
+ };
1322
+ }
1323
+
1324
+ /**
1325
+ * Merge: the canon rule "merge only with green checks" is held by GitHub, not by the agent's memory. Required checks
1326
+ * are those green on each of the last merged PRs; a check missing from a fresh head (the last merged PR, open ones with
1327
+ * finished checks) would hold the PR forever — it leaves the list rather than gets into it. Someone else's rule with
1328
+ * checks isn't duplicated; our own ruleset with strict is fixed: GitHub must merge a branch that fell behind.
1329
+ */
1330
+ function analyzeMerge(m: Merge, repositoryId: string, merge: ((text: string, ...steps: Step[]) => void) & { skip: (why: string) => void }): void {
1331
+ if (m.unavailable) return merge.skip(`branch rules are unavailable: ${m.unavailable}`);
1332
+ const fresh = [...m.merged.slice(-1), ...m.open];
1333
+ const gone = (c: string) => fresh.find((h) => !(c in h.checks));
1334
+ const stable = Object.keys(m.merged[0]?.checks ?? {})
1335
+ // stricter than task show: a check skipped by a condition or neutral checked nothing — only pass makes it required
1336
+ .filter((c) => m.merged.every((h) => h.checks[c] === "pass") && !gone(c))
1337
+ .sort();
1338
+ const enforced = m.rules.some((r) => r.contexts.length);
1339
+ const list = (cs: string[]) => cs.map(q).join(", ");
1340
+ const none = `the last merged PRs have no common green check — nothing to make required`;
1341
+ const own = m.own;
1342
+ if (!own) {
1343
+ if (enforced) return;
1344
+ if (!stable.length) return merge.skip(none);
1345
+ const text = `create ruleset ${q(RULESET)} on ${m.branch}: required ${list(stable)}, bypass — admin`;
1346
+ const step: Step = { kind: "api", text, mutations: [{ op: "CreateRuleset", input: { sourceId: repositoryId, ...rulesetInput(stable) } }] };
1347
+ return merge(`no rule with required checks: a branch with a red check can be merged`, step);
1348
+ }
1349
+ const want = [...new Set([...own.contexts.filter((c) => !gone(c)), ...stable])].sort();
1350
+ // the input carries `name: RULESET` — the same edit renames a ruleset under the former name
1351
+ const named = own.name === RULESET ? q(RULESET) : `${q(own.name)} → ${q(RULESET)}`;
1352
+ const step: Step = { kind: "api", text: `ruleset ${named}: default branch, required ${list(want) || "—"}, bypass — admin`, mutations: [{ op: "UpdateRuleset", input: { repositoryRulesetId: own.id, ...rulesetInput(want) } }] };
1353
+ const why: string[] = [];
1354
+ if (own.name !== RULESET) why.push(`ruleset ${q(own.name)} — the flow's former name: fix renames it to ${q(RULESET)}`);
1355
+ if (own.enforcement !== "ACTIVE") why.push(`ruleset ${q(own.name)} — ${own.enforcement}, not in effect`);
1356
+ if (!own.include.includes("~DEFAULT_BRANCH")) why.push(`ruleset ${q(own.name)} is not on the default branch: ${own.include.join(", ") || "—"}`);
1357
+ if (own.strict) why.push(`ruleset ${q(own.name)}: strict is on — GitHub won't merge a branch that fell behind`);
1358
+ if (!own.bypassAdmin) why.push(`ruleset ${q(own.name)}: admin can't bypass — no direct push to ${m.branch}`);
1359
+ for (const c of own.contexts.filter((x) => gone(x))) why.push(`required ${q(c)} didn't come to PR #${gone(c)!.number} — the PR would wait for it forever`);
1360
+ const add = stable.filter((c) => !own.contexts.includes(c));
1361
+ if (add.length) why.push(`green on the last ${m.merged.length} merged PRs, but not required: ${list(add)}`);
1362
+ for (const w of why) merge(w, step);
1363
+ if (!why.length && !enforced && !want.length) merge.skip(none);
1364
+ }
1365
+
1366
+ /**
1367
+ * The merge method is rebase only (the canon, "PR"): the merge rule is the same for all repositories —
1368
+ * `gh pr merge <N> --rebase`, the branch's own commits go into the default branch. The repository setting keeps the
1369
+ * rule constant: a PR can't be merged another way, and rebase isn't turned off. All causes — one request.
1370
+ */
1371
+ function analyzeMethod(m: Merge, slug: string, method: (text: string, ...steps: Step[]) => void): void {
1372
+ const step: Step = {
1373
+ kind: "api",
1374
+ text: "keep only rebase: turn off merge commit and squash, turn on rebase",
1375
+ url: `https://github.com/${slug}/settings`,
1376
+ mutations: [{ op: "UpdateRepository", input: { repo: slug, allow_merge_commit: false, allow_squash_merge: false, allow_rebase_merge: true } }],
1377
+ };
1378
+ if (!m.methods.rebase) method("rebase is off — GitHub will refuse gh pr merge --rebase", step);
1379
+ if (m.methods.merge) method("merge commit is allowed", step);
1380
+ if (m.methods.squash) method("squash is allowed", step);
1381
+ }
1382
+
1383
+ // ----------------------------------------------------------------------------
1384
+ // Commands
1385
+ // ----------------------------------------------------------------------------
1386
+
1387
+ function header(io: Io, s: State): void {
1388
+ io.out(`Project ${s.repo.nameWithOwner}: ${s.project ? s.project.url : "none"}`);
1389
+ }
1390
+
1391
+ function report(io: Io, s: State, checks: Check[]): boolean {
1392
+ for (const c of checks) {
1393
+ if (c.skip) {
1394
+ io.out(`➖ ${c.title} — ${c.skip}`);
1395
+ continue;
1396
+ }
1397
+ io.out(`${c.problems.length ? "❌" : "✅"} ${c.title}`);
1398
+ for (const pr of c.problems) io.out(` · ${pr.text}`);
1399
+ for (const n of c.notes) io.out(` ○ ${n}`);
1400
+ }
1401
+ if (s.project && !s.repo.owner.org) io.out(`➖ ${PRIORITY} and issue types — a personal account has none`);
1402
+ return checks.every((c) => !c.problems.length);
1403
+ }
1404
+
1405
+ export function cmdCheck(io: Io, slug: string): number {
1406
+ const s = loadState(io, slug);
1407
+ header(io, s);
1408
+ return report(io, s, analyze(s)) ? 0 : 1;
1409
+ }
1410
+
1411
+ /** No project: link the owner's project of the same name, otherwise copy the template, otherwise create one. */
1412
+ function ensureProject(io: Io, s: State, template: string | null): string {
1413
+ const found = graphql(io, Q.OwnerProjects, { login: s.repo.owner.login, query: s.repo.name })?.repositoryOwner?.projectsV2?.nodes ?? [];
1414
+ const same = found.find((p: Any) => p && p.title === s.repo.name && !p.closed);
1415
+ const link = (p: ProjectRef) => mutate(io, { op: "LinkProject", input: { projectId: p.id, repositoryId: s.repo.id } });
1416
+ if (same) {
1417
+ link(same);
1418
+ return `linked project ${q(same.title)} (${same.url})`;
1419
+ }
1420
+ if (template) {
1421
+ const [login, number] = template.split("/");
1422
+ let tpl: Any = null;
1423
+ try {
1424
+ tpl = graphql(io, Q.TemplateProject, { login, number: Number(number) })?.repositoryOwner?.projectV2 ?? null;
1425
+ } catch (e) {
1426
+ io.err(`template ${template} is unavailable: ${(e as Error).message}`);
1427
+ }
1428
+ if (tpl) {
1429
+ const copy = mutate(io, { op: "CopyProject", input: { projectId: tpl.id, ownerId: s.repo.owner.id, title: s.repo.name, includeDraftIssues: false } }).copyProjectV2.projectV2;
1430
+ link(copy);
1431
+ return `project ${q(s.repo.name)} — a copy of template ${template} (${copy.url}), linked`;
1432
+ }
1433
+ io.err(`template ${template} not found — the project is created from scratch`);
1434
+ }
1435
+ const created = mutate(io, { op: "CreateProject", input: { ownerId: s.repo.owner.id, title: s.repo.name, repositoryId: s.repo.id } }).createProjectV2.projectV2;
1436
+ return `created project ${q(s.repo.name)} (${created.url}), linked`;
1437
+ }
1438
+
1439
+ export function cmdFix(io: Io, slug: string, opts: { confirm: boolean; template: string | null }): number {
1440
+ const done: string[] = [];
1441
+ const failed: string[] = [];
1442
+ const tried = new Set<string>();
1443
+ const sticky: Step[] = [];
1444
+ let s = loadState(io, slug);
1445
+ // Rounds: a step may open the next one (a new Status field → the tasks' statuses); a step isn't done twice.
1446
+ for (let round = 0; round < 5; round++) {
1447
+ if (!s.project) {
1448
+ try {
1449
+ done.push(ensureProject(io, s, opts.template));
1450
+ } catch (e) {
1451
+ failed.push(`project: ${(e as Error).message}`);
1452
+ break;
1453
+ }
1454
+ s = loadState(io, slug);
1455
+ if (!s.project) break;
1456
+ continue;
1457
+ }
1458
+ const all = analyze(s).flatMap((c) => c.problems.flatMap((pr) => pr.steps));
1459
+ sticky.push(...all.filter((st) => st.sticky));
1460
+ const steps = all
1461
+ .filter((st) => st.mutations?.length && (st.kind === "api" || (st.kind === "confirm" && opts.confirm)) && !tried.has(st.text));
1462
+ if (!steps.length) break;
1463
+ for (const st of steps) {
1464
+ // several discrepancies may share one step (editing the whole ruleset) — it's done once
1465
+ if (tried.has(st.text)) continue;
1466
+ tried.add(st.text);
1467
+ try {
1468
+ for (const m of st.mutations!) mutate(io, m);
1469
+ done.push(st.text);
1470
+ } catch (e) {
1471
+ failed.push(`${st.text}: ${(e as Error).message}${st.url ? ` — in the UI: ${st.url}` : ""}`);
1472
+ }
1473
+ }
1474
+ s = loadState(io, slug);
1475
+ }
1476
+
1477
+ header(io, s);
1478
+ for (const d of done) io.out(`+ ${d}`);
1479
+ for (const f of failed) io.out(`! ${f}`);
1480
+ const checks = analyze(s);
1481
+ const left = [...sticky, ...checks.flatMap((c) => c.problems.flatMap((pr) => pr.steps))];
1482
+ const uniq = (xs: Step[]) => xs.filter((x, i) => xs.findIndex((y) => y.text === x.text) === i);
1483
+ const ui = uniq(left.filter((st) => st.kind === "ui"));
1484
+ const confirm = uniq(left.filter((st) => st.kind === "confirm" && !opts.confirm));
1485
+ if (ui.length) {
1486
+ io.out("UI steps — in the session's browser, without a browser — for the user; then check again:");
1487
+ ui.forEach((st, i) => io.out(`${i + 1}. ${st.text} — ${st.url}`));
1488
+ }
1489
+ if (confirm.length) {
1490
+ io.out("Confirmation needed from the user — then fix --confirm:");
1491
+ confirm.forEach((st, i) => io.out(`${i + 1}. ${st.text}${st.url ? ` (or in the UI: ${st.url})` : ""}`));
1492
+ }
1493
+ io.out("Summary:");
1494
+ const ok = report(io, s, checks);
1495
+ return ok && !failed.length && !ui.length && !confirm.length ? 0 : 1;
1496
+ }
1497
+
1498
+ // ----------------------------------------------------------------------------
1499
+ // Tasks
1500
+ // ----------------------------------------------------------------------------
1501
+
1502
+ interface Issue {
1503
+ id: string;
1504
+ /** Read in the cloud (#603): the REST id (sub-issues and blocked by take it) and the issue's route — its writes go there. */
1505
+ databaseId?: number;
1506
+ rest?: string;
1507
+ number: number;
1508
+ title: string;
1509
+ state: string;
1510
+ stateReason: string | null;
1511
+ url: string;
1512
+ type: string | null;
1513
+ labels: string[];
1514
+ parent: { id: string; number: number } | null;
1515
+ subIssues: { number: number; state: string; title: string }[];
1516
+ /** The issues blocking this one (blocked by), closed ones and other repositories' included. */
1517
+ blockers: Blocker[];
1518
+ /** The task's milestone and the number of open tasks in it (including this one if it's open). */
1519
+ milestone: { id: string; number: number; title: string; open: number } | null;
1520
+ /** PRs that close the task ("Closes #N"), closed ones included: whether merged, head and base. */
1521
+ prs: { number: number; state: string; merged: boolean; head: string; base: string }[];
1522
+ items: { id: string; projectId: string; status: string | null }[];
1523
+ priority: string | null;
1524
+ }
1525
+
1526
+ /**
1527
+ * A blocker (blocked by): GitHub links an issue of another repository too, of another owner as well — an external
1528
+ * blocker, its number alone would name a task of this repository.
1529
+ */
1530
+ interface Blocker {
1531
+ number: number;
1532
+ state: string;
1533
+ repo: string;
1534
+ }
1535
+
1536
+ /** A blocker in a line: of this repository — `#N`, of another — `owner/repo#N`. */
1537
+ const blockerRef = (slug: string, b: { number: number; repo: string }) => (b.repo.toLowerCase() === slug.toLowerCase() ? `#${b.number}` : `${b.repo}#${b.number}`);
1538
+
1539
+ function loadIssue(io: Io, slug: string, number: number): Issue {
1540
+ if (inCloud(io)) return restLoadIssue(io, slug, number);
1541
+ const [owner, name] = slug.split("/");
1542
+ const i = graphql(io, Q.IssueRef, { owner, name, number })?.repository?.issue;
1543
+ if (!i) throw new GhError(`no issue #${number} in ${slug}`);
1544
+ return {
1545
+ id: i.id,
1546
+ number: i.number,
1547
+ title: i.title,
1548
+ state: i.state,
1549
+ stateReason: i.stateReason ?? null,
1550
+ url: i.url,
1551
+ type: i.issueType?.name ?? null,
1552
+ labels: (i.labels?.nodes ?? []).map((l: Any) => l.name),
1553
+ parent: i.parent ? { id: i.parent.id, number: i.parent.number } : null,
1554
+ subIssues: i.subIssues?.nodes ?? [],
1555
+ blockers: (i.blockedBy?.nodes ?? []).map((b: Any) => ({ number: b.number, state: b.state, repo: b.repository?.nameWithOwner ?? slug })),
1556
+ milestone: i.milestone ? { id: i.milestone.id, number: i.milestone.number, title: i.milestone.title, open: i.milestone.issues?.totalCount ?? 0 } : null,
1557
+ prs: (i.closedByPullRequestsReferences?.nodes ?? []).map((p: Any) => ({ number: p.number, state: p.state ?? "", merged: Boolean(p.merged), head: p.headRefName ?? "", base: p.baseRefName ?? "main" })),
1558
+ items: (i.projectItems?.nodes ?? []).map((it: Any) => ({ id: it.id, projectId: it.project.id, status: it.status?.name ?? null })),
1559
+ priority: (i.issueFieldValues?.nodes ?? []).find((v: Any) => v?.field?.name === PRIORITY)?.name ?? null,
1560
+ };
1561
+ }
1562
+
1563
+ /** loadIssue in the cloud (#603) — the repository's REST: no project items, PRs and Priority (an issue field). */
1564
+ function restLoadIssue(io: Io, slug: string, number: number): Issue {
1565
+ const i = restIssue(io, slug, number);
1566
+ if (!i) throw new GhError(`no issue #${number} in ${slug}`);
1567
+ const l = restLinks(io, slug, number);
1568
+ return {
1569
+ id: i.id,
1570
+ databaseId: i.databaseId,
1571
+ rest: `repos/${slug}/issues/${number}`,
1572
+ number: i.number,
1573
+ title: i.title,
1574
+ state: i.state,
1575
+ stateReason: i.stateReason,
1576
+ url: i.url,
1577
+ type: i.issueType?.name ?? null,
1578
+ labels: i.labels.nodes.map((x: Any) => x.name),
1579
+ parent: l.parent ? { id: l.parent.id, number: l.parent.number } : null,
1580
+ subIssues: l.subIssues.nodes.map((x: Any) => ({ number: x.number, state: x.state, title: x.title })),
1581
+ blockers: l.blockedBy.nodes.map((b: Any) => ({ number: b.number, state: b.state, repo: b.repository.nameWithOwner })),
1582
+ milestone: i.milestone,
1583
+ prs: [],
1584
+ items: [],
1585
+ priority: null,
1586
+ };
1587
+ }
1588
+
1589
+ interface TaskCtx {
1590
+ slug: string;
1591
+ repo: State["repo"];
1592
+ project: Project;
1593
+ status: Field;
1594
+ }
1595
+
1596
+ /**
1597
+ * The repository and its project with Status by the canon: without them a task can't be created by the canon — run
1598
+ * project fix first. If the Status options are not in the project language but in another dictionary language (the
1599
+ * script wasn't called from the project checkout; the language changed and there was no fix yet), the task is handled
1600
+ * in the board's language: `project check` checks the language, not every command.
1601
+ */
1602
+ function taskContext(io: Io, slug: string): TaskCtx {
1603
+ const repo = loadRepo(io, slug);
1604
+ const main = mainProject(repo);
1605
+ if (!main) throw new GhError(`no project is linked to ${slug} — github project fix first`);
1606
+ const project = loadProject(io, main.id, false);
1607
+ const status = project.fields.find((f) => f.name === STATUS && f.options);
1608
+ const onBoard = (n: Names) => !!status && [n.status.backlog, n.status.inProgress, n.status.done].every((name) => status.options!.some((o) => o.name === name));
1609
+ const board = onBoard(N) ? N : locale.everyNames().find(onBoard);
1610
+ if (!status || !board) throw new GhError(`the project's ${STATUS} is not per the canon — github project fix first`);
1611
+ N = board;
1612
+ return { slug, repo, project, status };
1613
+ }
1614
+
1615
+ const isEpic = (i: Issue, org: boolean) => (org ? locale.every((n) => n.issueTypes.epic).includes(i.type ?? "") : i.labels.includes(EPIC_LABEL));
1616
+ const itemIn = (ctx: TaskCtx, i: Pick<Issue, "items">) => i.items.find((it) => it.projectId === ctx.project.id) ?? null;
1617
+
1618
+ function setStatus(io: Io, ctx: TaskCtx, itemId: string, name: string): void {
1619
+ const optionId = ctx.status.options!.find((o) => o.name === name)!.id;
1620
+ mutate(io, { op: "SetItemStatus", input: { projectId: ctx.project.id, itemId, fieldId: ctx.status.id, value: { singleSelectOptionId: optionId } } });
1621
+ }
1622
+
1623
+ /**
1624
+ * The task's item in the project; none — add it. The project may have added the task itself (projectV2Ids in
1625
+ * createIssue, auto-add) while reading doesn't show it yet: addProjectV2ItemById then answers "Content already
1626
+ * exists" — re-read after a pause.
1627
+ */
1628
+ function ensureItem(io: Io, ctx: TaskCtx, i: Pick<Issue, "id" | "number" | "items">): { id: string; added: boolean } {
1629
+ const it = itemIn(ctx, i);
1630
+ if (it) return { id: it.id, added: false };
1631
+ try {
1632
+ return { id: mutate(io, { op: "AddItem", input: { projectId: ctx.project.id, contentId: i.id } }).addProjectV2ItemById.item.id, added: true };
1633
+ } catch (e) {
1634
+ if (!/already exists/i.test((e as Error).message)) throw e;
1635
+ }
1636
+ for (let k = 0; k < 5; k++) {
1637
+ (io.sleep ?? pause)(1000);
1638
+ const again = itemIn(ctx, loadIssue(io, ctx.slug, i.number));
1639
+ if (again) return { id: again.id, added: false };
1640
+ }
1641
+ throw new GhError(`#${i.number} is already in the project, but its item isn't visible — retry: github task status ${i.number} <status>`);
1642
+ }
1643
+
1644
+ /**
1645
+ * The personal configuration directory — `ELVES_CONFIG_DIR`, otherwise `~/.config/elves`: one expression for all
1646
+ * scripts (the Elves `config-dir` standard) rather than a shared module — github imports no other skill
1647
+ * (tests/standards/skill-imports). The flow's former names are read for the transition: `AI_DEV_CONFIG_DIR` — a
1648
+ * variable the owner may have set in the profile; `~/.config/ai-dev` — the directory of a machine before `update -g`
1649
+ * moved it, while the new one doesn't exist; a clean machine gets the new one. est
1650
+ * moves the old `~/.claude/est` into the directory only while the directory doesn't exist (est.ts, `ensureConfigDir`):
1651
+ * creating it here first would leave est without its registry and prices. Before the move the configuration is the
1652
+ * old directory: the pins move along with it.
1653
+ */
1654
+ function configDir(env: Io["env"]): string {
1655
+ const home = env.HOME || os.homedir();
1656
+ const dir = env.ELVES_CONFIG_DIR || env.AI_DEV_CONFIG_DIR || [path.join(home, ".config", "elves"), path.join(home, ".config", "ai-dev")].find((d) => existsSync(d)) || path.join(home, ".config", "elves");
1657
+ if (existsSync(dir)) return dir;
1658
+ const legacy = path.join(home, ".claude", "est");
1659
+ // lstat: a symlink in place of the old directory — the move has already happened
1660
+ return lstatSync(legacy, { throwIfNoEntry: false })?.isDirectory() ? legacy : dir;
1661
+ }
1662
+
1663
+ /** The session pin file — `<config directory>/sessions/<session id>`; the session isn't identified — null. */
1664
+ function pinFile(io: Io): string | null {
1665
+ const id = io.env.CLAUDE_CODE_SESSION_ID;
1666
+ return id ? path.join(configDir(io.env), "sessions", id) : null;
1667
+ }
1668
+
1669
+ /**
1670
+ * The session pin: `owner/repo#N` — the task, anything else — not a pin. est has the same parse: github imports no other
1671
+ * skill (tests/standards/skill-imports, tests/standards/session-pin).
1672
+ */
1673
+ export function sessionPin(text: string): { slug: string; number: number } | null {
1674
+ const m = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(text.trim());
1675
+ return m ? { slug: m[1]!, number: Number(m[2]) } : null;
1676
+ }
1677
+
1678
+ /** The line `task status` writes into the pin file — sessionPin of every skill reads it back as the same task. */
1679
+ export function pinLine(slug: string, number: number): string {
1680
+ return `${slug}#${number}\n`;
1681
+ }
1682
+
1683
+ /**
1684
+ * The session pin: its file, the line and the task in it — the line isn't `owner/repo#N`, task null: not a pin; no file
1685
+ * or the session isn't identified — null.
1686
+ */
1687
+ function pinnedTask(io: Io): { file: string; line: string; task: { slug: string; number: number } | null } | null {
1688
+ const file = pinFile(io);
1689
+ if (!file || !existsSync(file)) return null;
1690
+ const line = readFileSync(file, "utf8").trim();
1691
+ return { file, line, task: sessionPin(line) };
1692
+ }
1693
+
1694
+ /**
1695
+ * Another task isn't taken in a session with a task (#546): the question to the owner whether to queue it, and its "yes"
1696
+ * — a background executor this session launches right away, with the prompt `executorPrompt`. Not a new session: a
1697
+ * session card waited for the owner's click, and its work fell out of this session's sight.
1698
+ */
1699
+ function queueQuestion(slug: string, number: number, epic: number | null): string {
1700
+ return `the question to the owner «queue #${number}?» — yes: a background subagent in its own worktree, its prompt: ${executorPrompt(slug, number, epic)}`;
1701
+ }
1702
+
1703
+ /** The refusal of another task in a session with a pinned one: the pinned task, the queue question, the pin's file. */
1704
+ function anotherTask(runs: string, file: string, slug: string, number: number, epic: number | null): GhError {
1705
+ return new GhError(`the session runs ${runs}; #${number} isn't taken here — ${queueQuestion(slug, number, epic)} (pin: ${file} — a mistaken one is removed by a human)`);
1706
+ }
1707
+
1708
+ /**
1709
+ * A session runs one task: the first one taken "In progress" is pinned to the session, another is refused before any
1710
+ * change. The key is the session id (a subagent has the same one, /clear changes it), not the checkout: session
1711
+ * worktrees are reused. No command removes the pin — a human removes a mistaken one by deleting the file. A task handed
1712
+ * out to an executor of this session (`handedOutFile`) isn't pinned and isn't refused: the executor is a subagent with
1713
+ * the session's id — in a dispatcher session and, since #546, in one with its own task.
1714
+ */
1715
+ function pinTask(io: Io, slug: string, number: number, epic: number | null): void {
1716
+ const file = pinFile(io);
1717
+ if (!file) return io.out("○ session not recognized (no CLAUDE_CODE_SESSION_ID) — the task isn't pinned");
1718
+ const task = `${slug}#${number}`;
1719
+ const held = pinnedTask(io)?.task;
1720
+ const runs = held ? `${held.slug}#${held.number}` : null;
1721
+ if (runs === task) return;
1722
+ if (handedOut(io, slug).some((h) => h.number === number)) {
1723
+ return io.out(`○ #${number} is handed out to an executor of this session — not pinned: the executor is a subagent with the session's id, est links the work by «${N.subagentPrompt.task} #${number}» in the assignment`);
1724
+ }
1725
+ if (runs) throw anotherTask(runs, file, slug, number, epic);
1726
+ if (isDispatcher(io)) {
1727
+ return io.out(`○ dispatcher session: #${number} isn't pinned — its executor is a subagent with the session's id, est links the work by «${N.subagentPrompt.task} #${number}» in the assignment`);
1728
+ }
1729
+ const created = (f: string, text: string) => {
1730
+ try {
1731
+ writeFileSync(f, text, { flag: "wx" });
1732
+ return true;
1733
+ } catch (e) {
1734
+ if ((e as Any)?.code !== "EEXIST") throw e;
1735
+ return false;
1736
+ }
1737
+ };
1738
+ mkdirSync(path.dirname(file), { recursive: true });
1739
+ // the configuration directory may be a personal git checkout (ELVES_PRIVATE) — pins aren't committed into it
1740
+ created(path.join(path.dirname(file), ".gitignore"), "*\n");
1741
+ if (created(file, pinLine(slug, number))) return;
1742
+ const now = pinnedTask(io)!;
1743
+ // not a pin — the session is free for est and the dashboard too: the file takes the task
1744
+ if (!now.task) {
1745
+ writeFileSync(file, pinLine(slug, number));
1746
+ return io.out(`○ the session pin ${file} held «${now.line}», not owner/repo#N — replaced with ${task}`);
1747
+ }
1748
+ // pinned by a parallel call meanwhile
1749
+ const pinnedNow = `${now.task.slug}#${now.task.number}`;
1750
+ if (pinnedNow !== task) throw anotherTask(pinnedNow, file, slug, number, epic);
1751
+ }
1752
+
1753
+ /**
1754
+ * The dispatcher marker — `<config directory>/dispatchers/<session id>` with the queue command (`task queue`): the
1755
+ * session hands tasks out to background subagents, and they share its id — a pin would refuse the second executor and
1756
+ * give est the whole session as one task. The session isn't identified — null.
1757
+ */
1758
+ function dispatcherFile(io: Io): string | null {
1759
+ const id = io.env.CLAUDE_CODE_SESSION_ID;
1760
+ return id ? path.join(configDir(io.env), "dispatchers", id) : null;
1761
+ }
1762
+
1763
+ const isDispatcher = (io: Io) => {
1764
+ const file = dispatcherFile(io);
1765
+ return !!file && existsSync(file);
1766
+ };
1767
+
1768
+ /**
1769
+ * The tasks handed out to the dispatcher's executors — `<marker>.tasks`, a line `owner/repo#N <worktree>` per `task
1770
+ * queue --assignment N` in the session (#513): the executor's first call, In progress comes later, after the task's
1771
+ * questions, and till then the queue gave the task out as ready. A separate file: executors append to it in parallel,
1772
+ * the queue rewrites the marker. It lives for the session: after the app restarts, the stopped executors are continued
1773
+ * with `SendMessage` (`task resume`) — dropped, it would make a task not yet In progress ready for a second executor.
1774
+ * The worktree — the checkout the executor called its assignment in (#533): the queue keeps it while the task is open. A
1775
+ * cloud executor's (`--assignment N --cloud`, #609) — `CLOUD_HANDOUT` in its place: the dispatcher printed its brief,
1776
+ * there is no worktree, and the queue names the wait for the task's closing.
1777
+ */
1778
+ const handedOutFile = (io: Io) => {
1779
+ const file = dispatcherFile(io);
1780
+ return file && `${file}.tasks`;
1781
+ };
1782
+
1783
+ /** A hand-out: the task and its executor's worktree; the assignment called outside a checkout or a cloud one — null. */
1784
+ interface HandOut {
1785
+ number: number;
1786
+ worktree: string | null;
1787
+ /** A cloud executor's (#609): the dispatcher waits for the task's closing. */
1788
+ cloud: boolean;
1789
+ }
1790
+
1791
+ /** A cloud executor's mark in place of the worktree: a worktree is an absolute path. */
1792
+ const CLOUD_HANDOUT = "@cloud";
1793
+
1794
+ /** The repository's tasks handed out to this dispatcher's executors (`handedOutFile`); a line without a worktree — `null`. */
1795
+ function handedOut(io: Io, slug: string): HandOut[] {
1796
+ const file = handedOutFile(io);
1797
+ return file ? handOutsIn(file, slug) : [];
1798
+ }
1799
+
1800
+ /** The repository's hand-outs in a `<session id>.tasks` file; no file — none. */
1801
+ function handOutsIn(file: string, slug: string): HandOut[] {
1802
+ if (!existsSync(file)) return [];
1803
+ const lines = readFileSync(file, "utf8").split("\n").map((l) => l.trim());
1804
+ return lines
1805
+ .filter((l) => l.startsWith(`${slug}#`))
1806
+ .map((l) => {
1807
+ const [number, ...rest] = l.slice(slug.length + 1).split(" ");
1808
+ const worktree = rest.join(" ").trim();
1809
+ return { number: Number(number), worktree: worktree === CLOUD_HANDOUT ? null : worktree || null, cloud: worktree === CLOUD_HANDOUT };
1810
+ })
1811
+ .filter((h) => Number.isInteger(h.number) && h.number > 0);
1812
+ }
1813
+
1814
+ /** A path without symbolic links (`/var` → `/private/var` on macOS) — a worktree's and a hand-out's compare so; gone — as is. */
1815
+ function realPath(p: string): string {
1816
+ try {
1817
+ return realpathSync(p);
1818
+ } catch {
1819
+ return path.resolve(p);
1820
+ }
1821
+ }
1822
+
1823
+ /** The epic prefix — the part of the title before " · ": the titles of the epic and all its subtasks start with it. */
1824
+ export function epicPrefix(title: string): string | null {
1825
+ const i = title.indexOf(" · ");
1826
+ return i > 0 ? title.slice(0, i) : null;
1827
+ }
1828
+
1829
+ export interface NewTask {
1830
+ title: string;
1831
+ body: string;
1832
+ type: string;
1833
+ epic?: number;
1834
+ milestone?: string;
1835
+ blockedBy: number[];
1836
+ priority?: string;
1837
+ /**
1838
+ * Labels: a decision by name or a new decision `kind:name` (no label — it's created with the kind's color), regular
1839
+ * ones — existing.
1840
+ */
1841
+ labels?: string[];
1842
+ /** The branch type (feat, fix, …) — also the estimate's type; a bug without it is fix, an epic has none. */
1843
+ branchType?: string;
1844
+ }
1845
+
1846
+ /**
1847
+ * The task description skeleton without `--body` — canon sections in the project language: scenarios (an epic has
1848
+ * none — it has no code) and questions.
1849
+ */
1850
+ export const taskTemplate = (type: string): string =>
1851
+ type === N.issueTypes.epic ? `${N.sections.questions}\n` : `${N.sections.scenarios}\n\n${N.sections.questions}\n`;
1852
+
1853
+ /**
1854
+ * Titles of the same task as `title`: itself and, for the project's flow update task (`updateTask` of a dictionary
1855
+ * language), its title before the flow's rename (`formerly.updateTask`) — an open update task of an earlier release is
1856
+ * the same task (#544).
1857
+ */
1858
+ const sameTitles = (title: string): string[] => [title, ...locale.everyNames().filter((n) => n.updateTask === title).map((n) => n.formerly.updateTask)];
1859
+
1860
+ export function cmdTaskNew(io: Io, slug: string, task: NewTask): number {
1861
+ const [owner, name] = slug.split("/");
1862
+ // the cloud (#603): the repository's REST without the project — what it can't set, a line for a local session names
1863
+ const cloud = inCloud(io);
1864
+ const ctx = cloud ? null : taskContext(io, slug);
1865
+ const org = ctx ? ctx.repo.owner.org : rest(io, `repos/${slug}`).owner?.type === "Organization";
1866
+
1867
+ // All checks come before creating: a task mustn't appear half-made. The type — a name in any dictionary language.
1868
+ const type = typeAliases()[task.type.toLowerCase()];
1869
+ if (!type) throw new GhError(`--type: ${issueTypes().join(", ")}`);
1870
+ // the estimate needs the branch type: a bug's branch is fix/<N>-…, an epic has no branch and no estimate of its own
1871
+ const branchType = task.branchType?.trim().toLowerCase() || (type === N.issueTypes.bug ? "fix" : "");
1872
+ if (type === N.issueTypes.epic) {
1873
+ if (branchType) throw new GhError("an epic has no branch and isn't estimated — --branch-type is for a task or a bug");
1874
+ } else if (!branchType) throw new GhError(`--branch-type is required for a task: ${TASK_TYPES.join(", ")} — the type of its branch and of its estimate`);
1875
+ else if (!TASK_TYPES.includes(branchType)) throw new GhError(`--branch-type: ${TASK_TYPES.join(", ")}`);
1876
+ const o = { ...task, type, body: task.body.trim() ? task.body : taskTemplate(type) };
1877
+ // in the cloud the organization's types aren't readable (`orgs/…` — 403): the type goes by name, GitHub checks it
1878
+ const issueType = org && ctx ? ctx.repo.owner.issueTypes.find((t) => t.name === o.type && t.isEnabled) : undefined;
1879
+ if (org && ctx && !issueType) throw new GhError(`the organization has no enabled type ${q(o.type)} — github project fix`);
1880
+ if (!org && o.priority) throw new GhError("a personal account has no issue fields — no priority is kept, --priority isn't needed");
1881
+ if (org && !ctx && o.priority && !(PRIORITIES as readonly string[]).includes(o.priority)) throw new GhError(`--priority: ${PRIORITIES.join(", ")}`);
1882
+ let priority: { fieldId: string; optionId: string; name: string } | null = null;
1883
+ if (org && ctx) {
1884
+ const f = ctx.repo.owner.issueFields.find((x) => x.name === PRIORITY);
1885
+ if (!f) throw new GhError(`the organization has no issue field ${PRIORITY} — github project fix`);
1886
+ const want = o.priority ?? DEFAULT_PRIORITY;
1887
+ const opt = f.options.find((x) => x.name === want);
1888
+ if (!opt) throw new GhError(`--priority: ${f.options.map((x) => x.name).join(", ")}`);
1889
+ priority = { fieldId: f.id, optionId: opt.id, name: want };
1890
+ }
1891
+ let title = o.title.trim();
1892
+ const epic = o.epic === undefined ? null : loadIssue(io, slug, o.epic);
1893
+ if (epic) {
1894
+ if (!isEpic(epic, org)) throw new GhError(`#${epic.number} is not an epic (${org ? `the type is not ${q(N.issueTypes.epic)}` : `no ${EPIC_LABEL} label`})`);
1895
+ if (epic.state !== "OPEN") throw new GhError(`epic #${epic.number} is closed`);
1896
+ const prefix = epicPrefix(epic.title);
1897
+ if (prefix && !title.startsWith(`${prefix} · `)) title = `${prefix} · ${title}`;
1898
+ }
1899
+ const blockers = o.blockedBy.map((n) => loadIssue(io, slug, n));
1900
+ const needCtx = !!o.milestone || (!org && o.type === N.issueTypes.epic);
1901
+ const restCtx = () => {
1902
+ const label = rest(io, `repos/${slug}/labels/${EPIC_LABEL}`, { missing: true });
1903
+ return { label: label && { id: label.node_id }, milestones: { nodes: restList(io, `repos/${slug}/milestones?state=open`).map((m) => ({ id: m.node_id, number: m.number, title: m.title })) } };
1904
+ };
1905
+ const repoCtx = !needCtx ? null : cloud ? restCtx() : graphql(io, Q.TaskContext, { owner, name }).repository;
1906
+ const milestone = o.milestone ? (repoCtx.milestones.nodes as { id: string; number?: number; title: string }[]).find((m) => m.title === o.milestone) : undefined;
1907
+ if (o.milestone && !milestone) throw new GhError(`no open milestone ${q(o.milestone)}`);
1908
+ // `search/issues` in the cloud is 403 («sessions are bound to their configured repositories») — a listing of the open ones
1909
+ const openIssues: Any[] | null = cloud ? restList(io, `repos/${slug}/issues?state=open`).filter((i) => !i.pull_request) : null;
1910
+ for (const same of sameTitles(title)) {
1911
+ const found: Any[] = openIssues ?? graphql(io, Q.IssueSearch, { q: `repo:${slug} is:issue is:open in:title "${same.replaceAll('"', "")}"` }).search.nodes;
1912
+ const dup = found.find((x) => x?.title === same);
1913
+ if (dup) throw new GhError(`an open task with this title already exists: #${dup.number}${same === title ? "" : ` (${q(same)} — its title before the flow's rename)`}`);
1914
+ }
1915
+ const wanted = [...new Set(o.labels ?? [])];
1916
+ // open questions in the description — the questions label: the dashboard and the queue find the task by it
1917
+ const open = questionItems(o.body).filter((x) => !x.answered).map((x) => x.k);
1918
+ const repoLabels = wanted.length || open.length ? loadLabels(io, slug) : [];
1919
+ const has = (n: string) => repoLabels.find((x) => x.name === n);
1920
+ // a decision by name comes from the default branch; `kind:name` — also a new one that isn't there yet
1921
+ const decisions = wanted.some((l) => legacyDecision(l) || !has(l)) ? loadDecisions(io, slug) : null;
1922
+ const inMain = decisions ? decisionsByName(decisions) : new Map<string, DecisionKind[]>();
1923
+ const plan = wanted.map((l) => {
1924
+ const dec = legacyDecision(l);
1925
+ const name = dec?.name ?? l;
1926
+ const label = has(name);
1927
+ const main = inMain.get(name) ?? [];
1928
+ const kinds = dec && !main.includes(dec.kind) ? [...main, dec.kind] : main;
1929
+ const dim = dec && label ? labelDimension(label.description) : null;
1930
+ if (dim) throw new GhError(`label ${q(name)} is slice ${q(dim)}, not a decision label: decision ${name} isn't labeled; the slice label — --labels ${name}`);
1931
+ if (!label && !kinds.length) {
1932
+ throw new GhError(`no label ${q(l)} in the repository and no decision ${l} in the default branch; a new decision — kind:name (${DECISION_KINDS.map((k) => `${k}:${l}`).join(", ")})`);
1933
+ }
1934
+ return { name, label, kinds, decision: dec !== null || (label ? labelKinds(label.description) !== null : true), fresh: dec && !main.includes(dec.kind) ? `${name} — new decision: ${decisionPath(dec.kind, name)} is not in the default branch yet` : null };
1935
+ });
1936
+
1937
+ const done: string[] = [];
1938
+ // a new label of the repository — its id; in the cloud over REST
1939
+ const createLabel = (label: { name: string; color: string; description: string }): string =>
1940
+ ctx ? (mutate(io, { op: "CreateLabel", input: { repositoryId: ctx.repo.id, ...label } }).createLabel.label.id as string) : rest(io, `repos/${slug}/labels`, { method: "POST", payload: label }).node_id;
1941
+ // the issue's labels: GraphQL takes their ids, REST — their names
1942
+ const labels: { id: string; name: string }[] = [];
1943
+ if (issueType || (org && !ctx)) done.push(`type ${q(o.type)}`);
1944
+ if (!org && o.type === N.issueTypes.epic) {
1945
+ let labelId: string | undefined = repoCtx.label?.id;
1946
+ if (!labelId) {
1947
+ labelId = createLabel(epicLabel());
1948
+ done.push(`created label ${q(EPIC_LABEL)}`);
1949
+ }
1950
+ labels.push({ id: labelId, name: EPIC_LABEL });
1951
+ done.push(`label ${q(EPIC_LABEL)} — the type in a personal account`);
1952
+ }
1953
+ if (plan.length) {
1954
+ for (const x of plan) {
1955
+ let id = x.label?.id;
1956
+ if (!id) {
1957
+ id = createLabel(decisionLabel(x.name, x.kinds));
1958
+ done.push(`created label ${q(x.name)}`);
1959
+ }
1960
+ labels.push({ id, name: x.name });
1961
+ }
1962
+ // the decision will appear in this task's PR — the label says so instead of failing
1963
+ done.push(`labels: ${plan.map((x) => x.name).join(", ")}`, ...plan.flatMap((x) => (x.fresh ? [x.fresh] : [])));
1964
+ }
1965
+ if (open.length) {
1966
+ let id = has(N.labels.questions)?.id;
1967
+ if (!id) {
1968
+ id = createLabel(questionsLabel());
1969
+ done.push(`created label ${q(N.labels.questions)}`);
1970
+ }
1971
+ // `--labels` may name it too
1972
+ if (!labels.some((l) => l.id === id)) labels.push({ id, name: N.labels.questions });
1973
+ done.push(`label ${q(N.labels.questions)} — open questions in the description: ${open.join(", ")}`);
1974
+ }
1975
+ let created: { id: string; databaseId?: number; number: number; title: string; url: string; projectItems?: Any };
1976
+ if (ctx) {
1977
+ const input: Record<string, unknown> = { repositoryId: ctx.repo.id, title, body: o.body, projectV2Ids: [ctx.project.id] };
1978
+ if (issueType) input.issueTypeId = issueType.id;
1979
+ if (labels.length) input.labelIds = labels.map((l) => l.id);
1980
+ if (milestone) input.milestoneId = milestone.id;
1981
+ if (epic) input.parentIssueId = epic.id;
1982
+ if (priority) input.issueFields = [{ fieldId: priority.fieldId, singleSelectOptionId: priority.optionId }];
1983
+ created = mutate(io, { op: "CreateIssue", input }).createIssue.issue;
1984
+ // We set Status ourselves, without waiting for the "Item added to project" workflow.
1985
+ const listed = (created.projectItems?.nodes ?? []).map((it: Any) => ({ id: it.id, projectId: it.project.id, status: null }));
1986
+ const item = ensureItem(io, ctx, { id: created.id, number: created.number, items: listed }).id;
1987
+ setStatus(io, ctx, item, N.status.backlog);
1988
+ done.push(`project ${q(ctx.project.title)}: ${STATUS} ${q(N.status.backlog)}`);
1989
+ } else {
1990
+ const payload: Record<string, unknown> = { title, body: o.body };
1991
+ if (org) payload.type = o.type;
1992
+ if (labels.length) payload.labels = labels.map((l) => l.name);
1993
+ if (milestone) payload.milestone = milestone.number;
1994
+ const i = rest(io, `repos/${slug}/issues`, { method: "POST", payload });
1995
+ created = { id: i.node_id, databaseId: i.id, number: i.number, title: i.title, url: i.html_url };
1996
+ // REST has no parent in the issue's creation — the epic takes it as its sub-issue by the REST id
1997
+ if (epic) rest(io, `repos/${slug}/issues/${epic.number}/sub_issues`, { method: "POST", payload: { sub_issue_id: created.databaseId } });
1998
+ }
1999
+ if (priority) done.push(`${PRIORITY} ${q(priority.name)}`);
2000
+ if (epic) done.push(`subtask of epic #${epic.number}`);
2001
+ for (const b of blockers) {
2002
+ if (b.state !== "OPEN") {
2003
+ done.push(`#${b.number} is closed — doesn't block, skipped`);
2004
+ continue;
2005
+ }
2006
+ if (ctx) mutate(io, { op: "AddBlockedBy", input: { issueId: created.id, blockingIssueId: b.id } });
2007
+ else rest(io, `repos/${slug}/issues/${created.number}/dependencies/blocked_by`, { method: "POST", payload: { issue_id: b.databaseId } });
2008
+ done.push(`blocked by #${b.number}`);
2009
+ }
2010
+ if (milestone) done.push(`milestone ${q(milestone.title)}`);
2011
+ // the epic accumulates its subtasks' decision labels — up to GitHub's limit: the task is already created, a failure
2012
+ // here would leave the epic without its labels and without a line about them (#574)
2013
+ const decided = plan.filter((x) => x.decision).map((x) => x.name);
2014
+ const epicLabels = epic && decided.length ? putLabels(io, epic, `epic #${epic.number}`, decided, null, loadLabels(io, slug)) : [];
2015
+ // the epic — no lower than its most urgent open subtask
2016
+ if (epic && priority) {
2017
+ const rank = (p: string | null) => (p === null ? PRIORITIES.length : (PRIORITIES as readonly string[]).indexOf(p));
2018
+ if (rank(priority.name) < rank(epic.priority)) {
2019
+ mutate(io, { op: "SetIssueField", input: { issueId: epic.id, issueFields: [{ fieldId: priority.fieldId, singleSelectOptionId: priority.optionId }] } });
2020
+ done.push(`${PRIORITY} of epic #${epic.number}: ${epic.priority ?? "—"} → ${priority.name}`);
2021
+ }
2022
+ }
2023
+
2024
+ io.out(`Created #${created.number} ${created.title} — ${created.url}`);
2025
+ for (const d of done) io.out(`+ ${d}`);
2026
+ for (const l of epicLabels) io.out(l);
2027
+ const isEpicType = o.type === N.issueTypes.epic;
2028
+ if (!ctx) {
2029
+ // the project and the estimate (est reads the analogs' actuals from the project, it refuses in the cloud) — a local
2030
+ // session's commands; Priority — an issue field, GraphQL only
2031
+ const n = created.number;
2032
+ const local = [`github task status ${n} "${N.status.backlog}" (the project, ${STATUS})`, ...(isEpicType ? [] : [`est estimate ${n} --type ${branchType} --write (${q(N.fields.estimate)})`])];
2033
+ const want = org ? (o.priority ?? DEFAULT_PRIORITY) : null;
2034
+ if (want) local.push(`${PRIORITY} ${q(want)}${epic ? ` and no lower at epic #${epic.number}` : ""} — on the issue page`);
2035
+ io.out(`Next: the cloud has no GitHub Projects (docs/cloud-sessions.md) — a local session: ${local.join("; ")}.`);
2036
+ }
2037
+ if (isEpicType) {
2038
+ io.out(`Next: an epic isn't estimated — its ${q(N.fields.estimate)} is the sum of its subtasks' estimates.`);
2039
+ // an epic's work goes to the dispatcher on the owner's "yes" (#546), not to a session card per subtask
2040
+ io.out(
2041
+ `Next: once its subtasks are created — the question to the owner «run the dispatcher on epic #${created.number}?»; ` +
2042
+ `yes — bun --no-env-file ${import.meta.path} task queue --epic ${created.number} --repo ${slug} in this session, its executors — background subagents.`,
2043
+ );
2044
+ return 0;
2045
+ }
2046
+ if (ctx) runEstimate(io, slug, created.number, branchType);
2047
+ if (epic) io.out(`Next: ${q(N.fields.estimate)} of epic #${epic.number} — recompute as the sum of its subtasks' estimates.`);
2048
+ // a session with a pinned task won't take a new one into work (pinTask): the owner decides whether to queue it
2049
+ const held = pinnedTask(io)?.task;
2050
+ if (held) io.out(`Next: the session runs ${held.slug}#${held.number}; #${created.number} isn't taken here — ${queueQuestion(slug, created.number, epic?.number ?? null)}`);
2051
+ return 0;
2052
+ }
2053
+
2054
+ export function cmdTaskStatus(io: Io, slug: string, number: number, name: string, branch?: string): number {
2055
+ // status: a name in any dictionary language; checked before reading the project; the option is in the board's language
2056
+ if (!statusAliases()[name.toLowerCase()]) throw new GhError(`status: ${statusOptions().join(", ")}`);
2057
+ if (branch !== undefined && statusAliases()[name.toLowerCase()] !== N.status.inProgress) throw new GhError(`--branch — only with ${q(N.status.inProgress)}: the branch is renamed when the task is taken into work`);
2058
+ if (branch !== undefined && (taskBranch(branch)?.issue !== number || !TASK_SLUG_RE.test(branch))) {
2059
+ throw new GhError(`--branch ${q(branch)}: expected a branch of #${number} by the canon — [<area>/]<type>/${number}-<slug>, type ${TASK_TYPES.join("|")}, slug — Latin kebab-case`);
2060
+ }
2061
+ const ctx = taskContext(io, slug);
2062
+ const status = statusAliases()[name.toLowerCase()]!;
2063
+ const issue = loadIssue(io, slug, number);
2064
+ const epicTask = isEpic(issue, ctx.repo.owner.org);
2065
+ if (branch !== undefined && epicTask) throw new GhError(`--branch: #${number} is an epic — an epic has no branch`);
2066
+ // an epic isn't pinned: its session plans and hands out work, and the first subtask also sets the epic "In progress"
2067
+ if (status === N.status.inProgress && !epicTask) pinTask(io, slug, number, issue.parent?.number ?? null);
2068
+ const item = ensureItem(io, ctx, issue);
2069
+ setStatus(io, ctx, item.id, status);
2070
+ io.out(`+ #${issue.number}: ${STATUS} ${q(status)}${item.added ? " (added to the project)" : ""}`);
2071
+ const code = branch === undefined || renameBranch(io, slug, branch) ? 0 : 1;
2072
+ if (!issue.parent) return code;
2073
+ const epic = loadIssue(io, slug, issue.parent.number);
2074
+ const epicStatus = itemIn(ctx, epic)?.status ?? null;
2075
+ if (status === N.status.inProgress && epic.state === "OPEN" && (epicStatus === null || epicStatus === N.status.backlog)) {
2076
+ setStatus(io, ctx, ensureItem(io, ctx, epic).id, N.status.inProgress);
2077
+ io.out(`+ epic #${epic.number}: ${STATUS} ${q(N.status.inProgress)} — the first subtask is taken`);
2078
+ }
2079
+ if (status === N.status.inProgress && epic.state === "CLOSED") io.out(`Next: epic #${epic.number} is closed, but a subtask is in work again — reopen it (and its milestone, if closed).`);
2080
+ if (status === N.status.done && epic.state === "OPEN" && epic.subIssues.length && epic.subIssues.every((x) => x.state === "CLOSED")) {
2081
+ io.out(`Next: all subtasks of epic #${epic.number} are closed — close the epic and set it to ${q(N.status.done)}.`);
2082
+ }
2083
+ return code;
2084
+ }
2085
+
2086
+ export function cmdTaskDrop(io: Io, slug: string, number: number, duplicateOf?: number): number {
2087
+ const ctx = taskContext(io, slug);
2088
+ const issue = loadIssue(io, slug, number);
2089
+ if (issue.state === "CLOSED" && issue.stateReason === "COMPLETED") throw new GhError(`#${number} is closed as completed — drop is only for tasks not done`);
2090
+ const dup = duplicateOf === undefined ? null : loadIssue(io, slug, duplicateOf);
2091
+ if (issue.state === "OPEN") {
2092
+ mutate(io, { op: "CloseIssue", input: { issueId: issue.id, stateReason: dup ? "DUPLICATE" : "NOT_PLANNED", ...(dup ? { duplicateIssueId: dup.id } : {}) } });
2093
+ io.out(`+ #${number} closed: ${dup ? `duplicate of #${dup.number}` : "not planned"}`);
2094
+ }
2095
+ // "Item closed" would put a task closed without doing it into "Done" — it has no place in the project
2096
+ const item = itemIn(ctx, issue);
2097
+ if (item) {
2098
+ mutate(io, { op: "DeleteItem", input: { projectId: ctx.project.id, itemId: item.id } });
2099
+ io.out(`+ #${number} removed from project ${q(ctx.project.title)}`);
2100
+ }
2101
+ if (issue.parent) io.out(`Next: ${q(N.fields.estimate)} of epic #${issue.parent.number} — recompute as the sum of the remaining subtasks.`);
2102
+ return 0;
2103
+ }
2104
+
2105
+ // ----------------------------------------------------------------------------
2106
+ // Reading a task and answering its questions
2107
+ // ----------------------------------------------------------------------------
2108
+
2109
+ /** A task's PR for task show: its state and its head's checks (none yet — null); `why` — what an unlinked one was found by. */
2110
+ interface ShownPr {
2111
+ number: number;
2112
+ state: string;
2113
+ merged: boolean;
2114
+ draft: boolean;
2115
+ checks: { green: number; pending: string[]; red: string[] } | null;
2116
+ why?: string;
2117
+ }
2118
+
2119
+ /** The head's checks by name (`checkStates`): green (pass, skipping), still running, red (cancel, fail). */
2120
+ function shownPr(p: Any, why?: string): ShownPr {
2121
+ const by = [...checkStates(rollupOf(p))];
2122
+ const of = (...states: CheckBucket[]) => by.filter(([, v]) => states.includes(v)).map(([k]) => k);
2123
+ return { number: p.number, state: p.state ?? "", merged: Boolean(p.merged), draft: Boolean(p.isDraft), checks: by.length ? { green: of("pass", "skipping").length, pending: of("pending"), red: of("cancel", "fail") } : null, why };
2124
+ }
2125
+
2126
+ function prLine(p: ShownPr): string {
2127
+ const done = p.merged || p.state === "CLOSED";
2128
+ const state = p.merged ? "merged" : p.state === "CLOSED" ? "closed without a merge" : p.draft ? "open (draft)" : "open";
2129
+ const c = p.checks;
2130
+ const checks = done
2131
+ ? ""
2132
+ : !c
2133
+ ? " — no checks yet"
2134
+ : ` — checks: ${[c.green ? `${c.green} ✅` : "", c.pending.length ? `${c.pending.length} ⏳ (${c.pending.join(", ")})` : "", c.red.length ? `${c.red.length} ❌ (${c.red.join(", ")})` : ""].filter(Boolean).join(", ")}`;
2135
+ return `PR #${p.number} ${state}${checks}${p.why ? ` (not linked: ${p.why})` : ""}`;
2136
+ }
2137
+
2138
+ /**
2139
+ * IssueShow in the cloud (#603) — the repository's REST in the shape of the GraphQL answer: the issue, its epic with the
2140
+ * epic's tasks, sub-issues, blocked by; no project (Projects v2 — 403) and no Priority (an issue field, GraphQL only).
2141
+ */
2142
+ function restIssueShow(io: Io, slug: string, number: number): Any {
2143
+ const i = restIssue(io, slug, number);
2144
+ if (!i) return null;
2145
+ const l = restLinks(io, slug, number);
2146
+ return { ...i, ...l, parent: l.parent && { ...l.parent, subIssues: restSubIssues(io, slug, l.parent.number) } };
2147
+ }
2148
+
2149
+ /**
2150
+ * The task's PRs in the cloud (#603): GraphQL's links to the task aren't there — the latest open and merged PRs into the
2151
+ * default branch by the task branch or «Closes #N» in the body, as `unlinkedPrs`; an open one's head checks — REST
2152
+ * check-runs and commit statuses in the shape of `HEAD_CHECKS`, for `checkStates`.
2153
+ */
2154
+ function restPrs(io: Io, slug: string, number: number): Any[] {
2155
+ const prs: Any[] = rest(io, `repos/${slug}/pulls?state=all&sort=updated&direction=desc&per_page=${RECENT_PRS}`) ?? [];
2156
+ const mine = prs.filter((p) => p.base?.ref === p.base?.repo?.default_branch && (p.state === "open" || p.merged_at) && (isTaskBranch(p.head?.ref ?? "", number) || closingInBody(p.body ?? "").includes(number)));
2157
+ return mine.map((p) => {
2158
+ const open = p.state === "open";
2159
+ const runs: Any[] = open ? (rest(io, `repos/${slug}/commits/${p.head.sha}/check-runs?per_page=100`)?.check_runs ?? []) : [];
2160
+ const statuses: Any[] = open ? (rest(io, `repos/${slug}/commits/${p.head.sha}/status`)?.statuses ?? []) : [];
2161
+ const up = (v: unknown) => (v ? String(v).toUpperCase() : null);
2162
+ const nodes = [
2163
+ ...runs.map((c) => ({ __typename: "CheckRun", name: c.name, status: up(c.status), conclusion: up(c.conclusion), databaseId: c.id, checkSuite: { app: { databaseId: c.app?.id ?? null } } })),
2164
+ ...statuses.map((c) => ({ __typename: "StatusContext", context: c.context, state: up(c.state) })),
2165
+ ];
2166
+ return { number: p.number, state: open ? "OPEN" : "MERGED", merged: !open, isDraft: Boolean(p.draft), headRefName: p.head?.ref ?? "", commits: { nodes: [{ commit: { statusCheckRollup: nodes.length ? { contexts: { nodes } } : null } }] } };
2167
+ });
2168
+ }
2169
+
2170
+ /**
2171
+ * Everything needed to take a task into work in one output, instead of `gh issue view` per piece: the title, type,
2172
+ * state, Status and estimate, the epic with its open questions and other open tasks, subtasks, open blockers, the PRs
2173
+ * with their checks (GitHub didn't link one yet — found by the branch or «Closes #N», as task close does), the
2174
+ * questions — open and answered, and the description as is. Read-only.
2175
+ */
2176
+ export function cmdTaskShow(io: Io, slug: string, number: number): number {
2177
+ const [owner, name] = slug.split("/");
2178
+ const cloud = inCloud(io);
2179
+ const i = cloud ? restIssueShow(io, slug, number) : graphql(io, Q.IssueShow, { owner, name, number })?.repository?.issue;
2180
+ if (!i) throw new GhError(`no issue #${number} in ${slug}`);
2181
+ const labels: string[] = (i.labels?.nodes ?? []).map((l: Any) => l.name);
2182
+ // a personal account has no issue types: an epic is the epic label
2183
+ const type: string | null = i.issueType?.name ?? (labels.includes(EPIC_LABEL) ? N.issueTypes.epic : null);
2184
+ const epic = type !== null && locale.every((n) => n.issueTypes.epic).includes(type);
2185
+ const items: Any[] = i.projectItems?.nodes ?? [];
2186
+ const values: Any[] = (items.find((it) => it.project?.title === name) ?? items[0])?.fieldValues?.nodes ?? [];
2187
+ const num = (field: (n: Names) => string) => values.find((v) => v?.__typename === "ProjectV2ItemFieldNumberValue" && locale.every(field).includes(v.field?.name));
2188
+ const status: string | null = values.find((v) => v?.field?.name === STATUS && v.name)?.name ?? null;
2189
+ const estimate = num((n) => n.fields.estimate);
2190
+ const actual = num((n) => n.fields.actual);
2191
+ const priority: string | null = (i.issueFieldValues?.nodes ?? []).find((v: Any) => v?.field?.name === PRIORITY)?.name ?? null;
2192
+ const reason: Record<string, string> = { NOT_PLANNED: ": not planned", DUPLICATE: ": duplicate" };
2193
+ const shownLabels = labels.filter((l) => !(l === EPIC_LABEL && !i.issueType));
2194
+ const facts = [
2195
+ type,
2196
+ i.state === "CLOSED" ? `closed${reason[i.stateReason] ?? ""}` : null,
2197
+ cloud ? "no project in the cloud (docs/cloud-sessions.md)" : !items.length ? "not in the project" : status ? `${STATUS} ${q(status)}` : `no ${STATUS}`,
2198
+ cloud ? null : estimate ? `${q(estimate.field.name)} ${estimate.number}` : `no ${q(N.fields.estimate)}`,
2199
+ actual ? `${q(actual.field.name)} ${actual.number}` : null,
2200
+ priority ? `${PRIORITY} ${priority}` : null,
2201
+ i.milestone ? `milestone ${q(i.milestone.title)}` : null,
2202
+ shownLabels.length ? `labels: ${shownLabels.join(", ")}` : null,
2203
+ ];
2204
+ const out = [`#${i.number} ${i.title} — ${i.url}`, facts.filter(Boolean).join(" · ")];
2205
+ if (i.parent) {
2206
+ const p = i.parent;
2207
+ const open = questionItems(p.body ?? "").filter((x) => !x.answered);
2208
+ const tasks: Any[] = (p.subIssues?.nodes ?? []).filter((x: Any) => x.state === "OPEN" && x.number !== number);
2209
+ out.push(`Epic #${p.number} ${q(p.title)} — open questions: ${open.length}, other open tasks: ${tasks.length}`);
2210
+ out.push(...open.map((x) => `- question ${x.text}`), ...tasks.map((x) => `- task #${x.number} ${q(x.title)}`));
2211
+ }
2212
+ const subs: Any[] = i.subIssues?.nodes ?? [];
2213
+ if (subs.length) {
2214
+ const open = subs.filter((x) => x.state === "OPEN");
2215
+ out.push(`Subtasks: open ${open.length} of ${subs.length}`, ...open.map((x) => `- task #${x.number} ${q(x.title)}`));
2216
+ }
2217
+ const blockers: Any[] = (i.blockedBy?.nodes ?? []).filter((x: Any) => x.state === "OPEN");
2218
+ if (blockers.length) out.push(`Blocked by: ${blockers.map((x) => `${blockerRef(slug, { number: x.number, repo: x.repository?.nameWithOwner ?? slug })} ${q(x.title)}`).join(", ")}`);
2219
+ // an epic has no PR of its own
2220
+ if (!epic) {
2221
+ let prs: ShownPr[] = (cloud ? restPrs(io, slug, number) : (i.closedByPullRequestsReferences?.nodes ?? []).filter(Boolean)).map((p: Any) => shownPr(p));
2222
+ if (!cloud && !prs.length && i.state === "OPEN") {
2223
+ for (const p of unlinkedPrs(io, slug, number).prs) {
2224
+ const pr = graphql(io, Q.PrShow, { owner, name, number: p.number })?.repository?.pullRequest;
2225
+ if (pr) prs.push(shownPr(pr, p.why));
2226
+ }
2227
+ }
2228
+ out.push(...(prs.length ? prs.map(prLine) : ["PR: none"]));
2229
+ }
2230
+ const lines = (i.body ?? "").trimEnd().split(/\r?\n/);
2231
+ const questions = questionItems(i.body ?? "");
2232
+ const open = questions.filter((x) => !x.answered).map((x) => x.k);
2233
+ const answered = questions.filter((x) => x.answered).map((x) => x.k);
2234
+ out.push(
2235
+ !questions.length
2236
+ ? "Questions: none"
2237
+ : !open.length
2238
+ ? `Questions: all answered (${answered.join(", ")})`
2239
+ : `Questions: open ${open.join(", ")}${answered.length ? `; answered ${answered.join(", ")}` : ""} — task answer ${number} <k> "<answer>" [--auto]`,
2240
+ );
2241
+ if (lines.join("").trim()) out.push("", ...lines);
2242
+ for (const line of out) io.out(line);
2243
+ return 0;
2244
+ }
2245
+
2246
+ /**
2247
+ * The answer at question k of the questions section, in place of rewriting the description by hand: ✅ (with --auto —
2248
+ * `autoAnswer`) after the item's number and «**Answer:** …» (`est.answer`: est strips it from the description size) at
2249
+ * its end — on the same line for a one-line item, otherwise on a line of its own; the numbering doesn't change, the
2250
+ * rest of the description stays as is. No open questions left — the questions label is removed. An answered question
2251
+ * is refused: a changed answer is edited by hand.
2252
+ */
2253
+ export function cmdTaskAnswer(io: Io, slug: string, number: number, k: number, answer: string, auto: boolean): number {
2254
+ const text = answer.trim().replace(/\s*\r?\n\s*/g, " ");
2255
+ if (!text) throw new GhError("task answer: expected the answer text after the question number");
2256
+ const [owner, name] = slug.split("/");
2257
+ const cloud = inCloud(io);
2258
+ const i = cloud ? restIssue(io, slug, number) : graphql(io, Q.IssueShow, { owner, name, number })?.repository?.issue;
2259
+ if (!i) throw new GhError(`no issue #${number} in ${slug}`);
2260
+ const body: string = i.body ?? "";
2261
+ const lines = body.split(/\r?\n/);
2262
+ const questions = questionItems(body);
2263
+ if (!questions.length) throw new GhError(`#${number}: no ${q(N.sections.questions)} section with numbered questions`);
2264
+ const question = questions.find((x) => x.k === k);
2265
+ if (!question) throw new GhError(`#${number}: no question ${k} in ${q(N.sections.questions)}; questions: ${questions.map((x) => x.k).join(", ")}`);
2266
+ if (question.answered) throw new GhError(`#${number}: question ${k} is already answered (✅) — a changed answer is edited by hand`);
2267
+ const reply = `**${N.est.answer}:** ${text}`;
2268
+ lines[question.first] = lines[question.first]!.replace(/^(\d+\.\s+)/, `$1${auto ? N.autoAnswer : "✅"} `);
2269
+ if (question.first === question.last) lines[question.first] += ` ${reply}`;
2270
+ else lines.splice(question.last + 1, 0, `${" ".repeat(`${k}. `.length)}${reply}`);
2271
+ const next = lines.join(body.includes("\r\n") ? "\r\n" : "\n");
2272
+ if (cloud) rest(io, `repos/${slug}/issues/${number}`, { method: "PATCH", payload: { body: next } });
2273
+ else mutate(io, { op: "UpdateIssue", input: { id: i.id, body: next } });
2274
+ io.out(`+ #${number}: question ${k} — ✅ and the answer in the description`);
2275
+ const open = questions.filter((x) => !x.answered && x !== question).map((x) => x.k);
2276
+ if (open.length) return io.out(`○ #${number}: open questions left: ${open.join(", ")}`), 0;
2277
+ const label: Any[] = (i.labels?.nodes ?? []).filter((l: Any) => locale.every((n) => n.labels.questions).includes(l.name));
2278
+ if (label.length) {
2279
+ if (cloud) for (const l of label) rest(io, `repos/${slug}/issues/${number}/labels/${encodeURIComponent(l.name)}`, { method: "DELETE" });
2280
+ else mutate(io, { op: "RemoveLabels", input: { labelableId: i.id, labelIds: label.map((l) => l.id) } });
2281
+ io.out(`+ #${number}: label ${label.map((l) => q(l.name)).join(", ")} removed — no open questions left`);
2282
+ }
2283
+ return 0;
2284
+ }
2285
+
2286
+ // ----------------------------------------------------------------------------
2287
+ // Closing a task in one command
2288
+ // ----------------------------------------------------------------------------
2289
+
2290
+ /**
2291
+ * The five epic-refresh checks — from the skill's reference.md, the one place of the canon: the list between
2292
+ * "Five checks:" and "What was fixed", an item per check.
2293
+ */
2294
+ /** The dispatcher's selection: the epic's tasks or the repository's open ones, narrowed by type and Priority. */
2295
+ export interface QueueFilter {
2296
+ epic?: number;
2297
+ type?: string;
2298
+ priority?: string[];
2299
+ }
2300
+
2301
+ /** How many executors run at once by default: `slot` lets two heavy runs through, a third executor writes code meanwhile. */
2302
+ export const QUEUE_EXECUTORS = 3;
2303
+
2304
+ const priorityRank = (p: string | null) => PRIORITIES.indexOf((p ?? DEFAULT_PRIORITY) as (typeof PRIORITIES)[number]);
2305
+
2306
+ /**
2307
+ * The reason of an external blocker that isn't an issue (a release, a PR, another tracker — GitHub links only issues):
2308
+ * the label and the first line of the task's latest comment starting with `comments.externalBlocker` of any dictionary
2309
+ * language. The comments are read only for a labeled task.
2310
+ */
2311
+ function externalBlocker(io: Io, slug: string, number: number, label: string): string {
2312
+ const marks = locale.every((n) => n.comments.externalBlocker);
2313
+ const comment = loadTaskText(io, slug, number)
2314
+ .comments.map((c) => c.body.trim())
2315
+ .filter((b) => marks.some((m) => b.startsWith(m)))
2316
+ .at(-1);
2317
+ if (comment === undefined) return `label ${q(label)}, no comment ${q(`${N.comments.externalBlocker} <reason>`)}`;
2318
+ const mark = marks.find((m) => comment.startsWith(m))!;
2319
+ return `label ${q(label)}: ${clip(comment.slice(mark.length).split(/\r?\n/)[0]!.trim())}`;
2320
+ }
2321
+
2322
+ /** The executor's prompt: the task first (`est` links the subagent's work by it), the whole assignment — from the script. */
2323
+ function executorPrompt(slug: string, number: number, epic: number | null): string {
2324
+ return locale.fill(N.queue.prompt, {
2325
+ task: N.subagentPrompt.task,
2326
+ number,
2327
+ repo: slug,
2328
+ epic: epic === null ? "" : locale.fill(N.queue.epic, { epicWord: N.subagentPrompt.epic, epic }),
2329
+ command: `bun --no-env-file ${import.meta.path} task queue --assignment ${number} --repo ${slug}`,
2330
+ });
2331
+ }
2332
+
2333
+ /**
2334
+ * A cloud executor's brief and the dispatcher's steps after it (`task queue --assignment N --cloud`, #609). The brief —
2335
+ * without local paths and `#` but at its task: numbers in the title and the epic go without it.
2336
+ */
2337
+ function cloudBrief(io: Io, slug: string, issue: Issue): void {
2338
+ const number = issue.number;
2339
+ const vars = {
2340
+ task: N.subagentPrompt.task,
2341
+ number,
2342
+ title: issue.title.replace(/#(\d)/g, "$1"),
2343
+ repo: slug,
2344
+ epic: issue.parent ? locale.fill(N.queue.cloudEpic, { epicWord: N.subagentPrompt.epic, epic: issue.parent.number }) : "",
2345
+ actual: N.est.cloud.actual.replace(/:$/, ""),
2346
+ state: N.comments.state,
2347
+ questions: N.sections.questions,
2348
+ };
2349
+ for (const line of [N.queue.cloud, N.queue.cloudDoc, N.queue.cloudClose, N.queue.cloudDispatcher, N.queue.cloudQuestion]) io.out(locale.fill(line, vars));
2350
+ io.out(
2351
+ `Next: the lines above — the cloud session's first prompt as is, the session started from the project folder (the cloud starts from main); ` +
2352
+ `then as a background command: bash ${waitCi()} issue ${number} — a cloud session can't message a local one (docs/cloud-sessions.md), the task's closing is the signal: ` +
2353
+ `PASS — bun --no-env-file ${import.meta.path} task close ${number} (the actual with --write, ${STATUS}, the merged branch), then archive the executor's session where the agent has the tool; ` +
2354
+ `TIMEOUT — the task's ${q(N.sections.questions)} and its ${q(N.comments.state)} comment, the executor's session, then the same wait again`,
2355
+ );
2356
+ }
2357
+
2358
+ /**
2359
+ * The dispatcher's queue: its ready tasks go to background executors (subagents), K at a time; the queue is re-read
2360
+ * after each answer, so tasks that appeared meanwhile are picked up. A session with its own task dispatches too (#546:
2361
+ * the owner's "yes" to an epic's dispatcher becomes work at once, without a new session) — its pin stays, its task is
2362
+ * in work. Ready — open, not an epic, not in work, no open blockers (other repositories' too), no external blocker
2363
+ * label; order — Priority, then the number. In work (`=`) — "In progress", an open PR (linked, or by the task branch and
2364
+ * "Closes #N" among recent PRs) named with its link, or handed out to an executor of this session (`handedOutFile`).
2365
+ * Marks the session as a dispatcher (`dispatcherFile`). Before the queue — finished agents' worktrees
2366
+ * (`pruneAgentWorktrees`) but those of executors whose handed-out tasks are open and those a subagent still works in.
2367
+ */
2368
+ export function cmdTaskQueue(io: Io, slug: string, o: QueueFilter): number {
2369
+ const want = o.type === undefined ? undefined : typeAliases()[o.type.toLowerCase()];
2370
+ if (o.type !== undefined && (!want || want === N.issueTypes.epic)) throw new GhError(`--type: ${N.issueTypes.task} or ${N.issueTypes.bug}`);
2371
+ const bad = (o.priority ?? []).filter((p) => !(PRIORITIES as readonly string[]).includes(p));
2372
+ if (bad.length) throw new GhError(`--priority: ${PRIORITIES.join(", ")}, not ${bad.join(", ")}`);
2373
+ const ctx = taskContext(io, slug);
2374
+ const org = ctx.repo.owner.org;
2375
+ if (!org && (o.type !== undefined || o.priority?.length)) throw new GhError("--type and --priority — only in an organization: a personal account has no issue types and no Priority");
2376
+ let numbers: number[];
2377
+ if (o.epic !== undefined) {
2378
+ const epic = loadIssue(io, slug, o.epic);
2379
+ if (!isEpic(epic, org)) throw new GhError(`#${o.epic} is not an epic — --epic takes an epic`);
2380
+ if (epic.state !== "OPEN") throw new GhError(`epic #${o.epic} is closed`);
2381
+ numbers = epic.subIssues.filter((x) => x.state === "OPEN").map((x) => x.number);
2382
+ } else {
2383
+ const found = graphql(io, Q.IssueSearch, { q: `repo:${slug} is:issue is:open` }).search.nodes as Any[];
2384
+ if (found.length >= 100) io.out("○ the search gave 100 open tasks — the queue sees only them: narrow it with --epic");
2385
+ numbers = found.filter((x) => x?.state === "OPEN").map((x) => x.number);
2386
+ }
2387
+ const [owner, name] = slug.split("/");
2388
+ const recent = (graphql(io, Q.RecentPrs, { owner, name })?.repository?.pullRequests?.nodes ?? []).filter((p: Any) => p.state === "OPEN");
2389
+ const openPr = (i: Issue) =>
2390
+ i.prs.find((p) => p.state === "OPEN")?.number ?? recent.find((p: Any) => isTaskBranch(p.headRefName ?? "", i.number) || closingInBody(p.body ?? "").includes(i.number))?.number;
2391
+ const handed = handedOut(io, slug);
2392
+ /** Issue states the queue has read; a hand-out's task outside the queue is read when its worktree is up for removal. */
2393
+ const states = new Map<number, string>();
2394
+ const isOpen = (n: number) => {
2395
+ if (!states.has(n)) states.set(n, loadIssue(io, slug, n).state);
2396
+ return states.get(n) === "OPEN";
2397
+ };
2398
+ const ready: Issue[] = [];
2399
+ const running: string[] = [];
2400
+ const waiting: string[] = [];
2401
+ for (const n of numbers.sort((a, b) => a - b)) {
2402
+ const i = loadIssue(io, slug, n);
2403
+ states.set(i.number, i.state);
2404
+ if (i.state !== "OPEN") continue;
2405
+ const label = `#${i.number} ${q(i.title)}`;
2406
+ if (isEpic(i, org)) {
2407
+ waiting.push(`○ ${label} — an epic: its tasks — task queue --epic ${i.number}`);
2408
+ continue;
2409
+ }
2410
+ if (want && typeAliases()[(i.type ?? "").toLowerCase()] !== want) continue;
2411
+ if (o.priority?.length && !o.priority.includes(i.priority ?? DEFAULT_PRIORITY)) continue;
2412
+ const status = itemIn(ctx, i)?.status ?? null;
2413
+ // the task is in work: In progress, an open PR (the executor waits for its CI — #513: it was ○ and the dispatcher
2414
+ // lost it from both lists), or handed out to an executor of this session that hasn't set In progress yet
2415
+ const pr = openPr(i);
2416
+ const work = [status === N.status.inProgress ? q(N.status.inProgress) : "", pr === undefined ? "" : `open PR #${pr} https://github.com/${slug}/pull/${pr}`].filter(Boolean);
2417
+ const hand = handed.find((h) => h.number === i.number);
2418
+ // a cloud executor's wait for the task's closing dies with the app's process: the line names it, task resume — the queue
2419
+ if (!work.length && hand?.cloud) work.push(`handed out to a cloud executor of this session, its wait — a background command: bash ${waitCi()} issue ${i.number}`);
2420
+ else if (!work.length && hand) work.push("handed out to an executor of this session");
2421
+ if (work.length) {
2422
+ running.push(`= ${label} — ${work.join(", ")}`);
2423
+ continue;
2424
+ }
2425
+ const blockers = i.blockers.filter((b) => b.state === "OPEN").map((b) => blockerRef(slug, b));
2426
+ const external = i.labels.find((l) => locale.every((n) => n.labels.externalBlocker).includes(l));
2427
+ if (status === N.status.done) waiting.push(`○ ${label} — ${STATUS} ${q(status)}, but the task is open`);
2428
+ else if (blockers.length) waiting.push(`○ ${label} — blocked by ${blockers.join(", ")}`);
2429
+ else if (external) waiting.push(`○ ${label} — ${externalBlocker(io, slug, i.number, external)}`);
2430
+ else ready.push(i);
2431
+ }
2432
+ ready.sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority) || a.number - b.number);
2433
+ const args = [o.epic !== undefined && `--epic ${o.epic}`, o.type !== undefined && `--type ${o.type}`, o.priority?.length && `--priority ${o.priority.join(",")}`].filter(Boolean).join(" ");
2434
+ const file = dispatcherFile(io);
2435
+ if (file) {
2436
+ mkdirSync(path.dirname(file), { recursive: true });
2437
+ // the configuration directory may be a personal git checkout (ELVES_PRIVATE) — markers aren't committed into it
2438
+ writeFileSync(path.join(path.dirname(file), ".gitignore"), "*\n");
2439
+ writeFileSync(file, `bun --no-env-file ${import.meta.path} task queue ${args ? `${args} ` : ""}--repo ${slug}\n`);
2440
+ } else io.out("○ session not recognized (no CLAUDE_CODE_SESSION_ID) — not marked as a dispatcher: task status in the executors would pin their tasks");
2441
+ appWorktreeWarning(io);
2442
+ // the executor the dispatcher continues with SendMessage — an answered question, a restart — has no lock (#533)
2443
+ const executors = handed.flatMap((h) => (h.worktree === null ? [] : [{ number: h.number, worktree: realPath(h.worktree) }]));
2444
+ pruneAgentWorktrees(io, slug, (dir) => executors.some((h) => h.worktree === realPath(dir) && isOpen(h.number)));
2445
+ const scope = [o.epic !== undefined ? `epic #${o.epic}` : "open tasks", want && `type ${q(want)}`, o.priority?.length && `Priority ${o.priority.join("|")}`].filter(Boolean).join(", ");
2446
+ io.out(`Queue ${slug} (${scope}): ready ${ready.length}, ${q(N.status.inProgress)} ${running.length}, not ready ${waiting.length}`);
2447
+ for (const i of ready) {
2448
+ io.out(`+ #${i.number} ${q(i.title)}${org ? ` (${i.priority ?? DEFAULT_PRIORITY})` : ""}`);
2449
+ io.out(` ${executorPrompt(slug, i.number, i.parent?.number ?? null)}`);
2450
+ }
2451
+ for (const line of running) io.out(line);
2452
+ for (const line of waiting) io.out(line);
2453
+ if (ready.length) {
2454
+ io.out(
2455
+ `Next: each ready task above — a background subagent in its own worktree while fewer than ${QUEUE_EXECUTORS} run (a number from the request replaces it); ` +
2456
+ "the indented line is its whole prompt. After each executor's answer — task queue again: new tasks get in.",
2457
+ );
2458
+ } else if (running.length) io.out("Next: no ready tasks — wait for the executors' answers; after each — task queue again.");
2459
+ else io.out(`Queue is empty: nothing ready, nothing ${q(N.status.inProgress)} — the dispatcher's summary (merged, questions, the not ready ones above) and the last line; a session with its own task — after it.`);
2460
+ return 0;
2461
+ }
2462
+
2463
+ /** A Claude Code agent's worktree (`isolation: "worktree"`): `<checkout>/.claude/worktrees/agent-<id>`, branch `worktree-agent-<id>`. */
2464
+ const AGENT_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]agent-[^\\/]+$/;
2465
+ const AGENT_BRANCH = /^worktree-agent-/;
2466
+ /** A session's worktree of the app: `<checkout>/.claude/worktrees/<name>`, not an agent's. */
2467
+ const APP_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/](?!agent-)[^\\/]+$/;
2468
+
2469
+ /**
2470
+ * The app starts a cloud session from the branch in the starting session's record (`get_session` → `branch`), not from
2471
+ * git (#588): a session from the app's worktree has its `claude/<slug>-<hash>` there, never pushed — the clone fails, and
2472
+ * a detached checkout doesn't help, the record keeps the branch; a session in the project folder has none — `main`. The
2473
+ * dispatcher gets a `!` line, the queue still goes: subagent executors branch from origin/main in their own worktrees.
2474
+ */
2475
+ function appWorktreeWarning(io: Io): void {
2476
+ const top = (io.run ?? realRun)("git", ["rev-parse", "--show-toplevel"], io.cwd).stdout.trim();
2477
+ if (!APP_WORKTREE.test(top)) return;
2478
+ const project = top.replace(/[\\/]\.claude[\\/]worktrees[\\/][^\\/]+$/, "");
2479
+ io.out(
2480
+ `! session in an app worktree ${top}: the app starts its cloud sessions from the branch in the session's record, not from main — ` +
2481
+ `cloud executors from it fail with «The requested branch or commit was not found»; a dispatcher with cloud executors — ` +
2482
+ `a session in the project folder ${project} without a worktree; subagent executors aren't affected`,
2483
+ );
2484
+ }
2485
+
2486
+ interface Worktree {
2487
+ path: string;
2488
+ head: string;
2489
+ /** The checked out branch; detached — null. */
2490
+ branch: string | null;
2491
+ /** The lock's reason ("" — locked without one); not locked — null. */
2492
+ locked: string | null;
2493
+ /** The directory is gone — `git worktree prune` business, not ours. */
2494
+ prunable: boolean;
2495
+ }
2496
+
2497
+ /** `git worktree list --porcelain`: a block per worktree, an attribute per line (`worktree <path>`, `HEAD`, `branch`, `locked [reason]`). */
2498
+ function worktreeList(text: string): Worktree[] {
2499
+ return text
2500
+ .split(/\n\s*\n/)
2501
+ .map((block) => {
2502
+ const lines = block.split("\n");
2503
+ const attr = (key: string) => {
2504
+ const line = lines.find((l) => l === key || l.startsWith(`${key} `));
2505
+ return line === undefined ? null : line.slice(key.length + 1);
2506
+ };
2507
+ return { path: attr("worktree") ?? "", head: attr("HEAD") ?? "", branch: attr("branch")?.replace(/^refs\/heads\//, "") ?? null, locked: attr("locked"), prunable: attr("prunable") !== null };
2508
+ })
2509
+ .filter((w) => w.path);
2510
+ }
2511
+
2512
+ /**
2513
+ * Whether a lock still holds: Claude Code locks a running agent's worktree — `claude agent agent-<id> (pid N start …)`
2514
+ * — and unlocks it when the agent finishes. Its process is gone (the app restarted, the agent died with it) — the lock
2515
+ * is stale. A lock without a pid is someone else's — it holds.
2516
+ */
2517
+ function lockHolds(reason: string): boolean {
2518
+ const pid = /\bpid (\d+)\b/.exec(reason)?.[1];
2519
+ if (!pid) return true;
2520
+ try {
2521
+ process.kill(Number(pid), 0);
2522
+ return true;
2523
+ } catch (e) {
2524
+ return (e as NodeJS.ErrnoException).code !== "ESRCH";
2525
+ }
2526
+ }
2527
+
2528
+ const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
2529
+
2530
+ /**
2531
+ * Executors' worktrees go away with the dispatcher's next queue (#515): an executor can't remove its own — it works in it,
2532
+ * and `task close` leaves it detached on origin/<base>; its subagents leave theirs on `worktree-agent-*` branches.
2533
+ * Only agents' worktrees (`AGENT_WORKTREE`), detached or on a `worktree-agent-*` branch, not locked by a live process
2534
+ * (a running agent): clean and without commits outside origin/<base> (`git cherry` — a rebase merge gives new SHAs) —
2535
+ * removed with their branch, a `+` line each; uncommitted changes or unmerged commits — left, a `○` line with the
2536
+ * reason. A `worktree-agent-*` branch without a worktree — the same by its commits. A session's worktree, an agent's on
2537
+ * a task branch (its task isn't closed), an executor's whose handed-out task is open (`kept`, #533: Claude Code unlocks
2538
+ * an agent's worktree when it ends its answer — a question too — and doesn't lock it again when the agent is continued
2539
+ * with `SendMessage` after a restart) — not touched, no line. A worktree a subagent of its agent works in without one
2540
+ * of its own (`guestWhy`, #571: the executor answered, its epic refresh still ran in its directory) — left, a `○` line
2541
+ * naming the subagent. The directory isn't a checkout of the repository — nothing.
2542
+ */
2543
+ function pruneAgentWorktrees(io: Io, slug: string, kept: (dir: string) => boolean): void {
2544
+ if (!checkoutOfRepo(io, originUrl(io), slug)) return;
2545
+ const git = (cwd: string | undefined, ...args: string[]) => (io.run ?? realRun)("git", args, cwd);
2546
+ const listed = git(io.cwd, "worktree", "list", "--porcelain");
2547
+ if (listed.status !== 0) return;
2548
+ const all = worktreeList(listed.stdout);
2549
+ const here = git(io.cwd, "rev-parse", "--show-toplevel").stdout.trim();
2550
+ const base = git(io.cwd, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").stdout.trim().replace(/^origin\//, "") || "main";
2551
+ const upstream = `origin/${base}`;
2552
+ const why = (r: RunResult) => r.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${r.status}`;
2553
+ /** Commits of the ref outside origin/<base> by patch (`git cherry`), or why they couldn't be counted. */
2554
+ const unmerged = (ref: string): string | null => {
2555
+ const r = git(io.cwd, "cherry", upstream, ref);
2556
+ if (r.status !== 0) return `git cherry ${upstream}: ${why(r)}`;
2557
+ const n = r.stdout.split("\n").filter((l) => l.startsWith("+")).length;
2558
+ return n ? `${plural(n, "commit", "commits")} not in ${upstream}` : null;
2559
+ };
2560
+ const deleteBranch = (branch: string) => {
2561
+ const d = git(io.cwd, "branch", "-D", branch);
2562
+ if (d.status !== 0) io.out(`! git branch -D ${branch}: ${why(d)}`);
2563
+ return d.status === 0;
2564
+ };
2565
+ const candidates = all
2566
+ .filter((w) => AGENT_WORKTREE.test(w.path) && w.path !== here && !w.prunable && (w.branch === null || AGENT_BRANCH.test(w.branch)))
2567
+ .filter((w) => (w.locked === null || !lockHolds(w.locked)) && !kept(w.path))
2568
+ .sort((a, b) => a.path.localeCompare(b.path));
2569
+ // the subagents of this session and of the machine's other sessions with hand-outs: their executors' worktrees are here too
2570
+ const agents = candidates.length ? subagentsOf(io, [io.env.CLAUDE_CODE_SESSION_ID ?? "", ...handOutFiles(io).map((h) => h.session)].filter(Boolean)) : new Map<string, Subagent>();
2571
+ for (const w of candidates) {
2572
+ const guest = guestWhy(agents, agentIdOf(w.path)!);
2573
+ if (guest) {
2574
+ io.out(`○ worktree ${w.path} left: ${guest}`);
2575
+ continue;
2576
+ }
2577
+ const status = git(w.path, "status", "--porcelain");
2578
+ if (status.status !== 0) {
2579
+ io.out(`! worktree ${w.path}: git status: ${why(status)}`);
2580
+ continue;
2581
+ }
2582
+ const dirty = status.stdout.split("\n").filter((l) => l.trim()).length;
2583
+ const reasons = [dirty ? `${plural(dirty, "uncommitted file", "uncommitted files")}` : null, unmerged(w.head)].filter((r): r is string => r !== null);
2584
+ if (reasons.length) {
2585
+ io.out(`○ worktree ${w.path} left: ${reasons.join(", ")}`);
2586
+ continue;
2587
+ }
2588
+ // a stale lock: its process is gone, and a locked worktree isn't removed without double force — which would drop the dirty check too
2589
+ if (w.locked !== null) git(io.cwd, "worktree", "unlock", w.path);
2590
+ const removed = git(io.cwd, "worktree", "remove", w.path);
2591
+ if (removed.status !== 0) {
2592
+ io.out(`! git worktree remove ${w.path}: ${why(removed)}`);
2593
+ continue;
2594
+ }
2595
+ const withBranch = w.branch !== null && deleteBranch(w.branch) ? ` with branch ${w.branch}` : "";
2596
+ io.out(`+ worktree ${w.path} removed${withBranch}: clean, nothing outside ${upstream}`);
2597
+ }
2598
+ // a branch checked out anywhere — a locked worktree's included — isn't loose
2599
+ const checkedOut = new Set(all.map((w) => w.branch).filter(Boolean));
2600
+ const loose = git(io.cwd, "for-each-ref", "--format=%(refname:short)", "refs/heads/worktree-agent-*")
2601
+ .stdout.split("\n")
2602
+ .map((b) => b.trim())
2603
+ .filter((b) => b && !checkedOut.has(b))
2604
+ .sort();
2605
+ for (const branch of loose) {
2606
+ const reason = unmerged(branch);
2607
+ if (reason) io.out(`○ branch ${branch} left: ${reason}`);
2608
+ else if (deleteBranch(branch)) io.out(`+ branch ${branch} deleted: nothing outside ${upstream}`);
2609
+ }
2610
+ }
2611
+
2612
+ /**
2613
+ * A worktree whose HEAD moved (a commit, a switch, a rebase) this recently still has an agent at work: Claude Code
2614
+ * doesn't lock the worktree of an agent continued after its answer (#533) — woken by its subagents' answers, the
2615
+ * executor of #545 worked unlocked (#565). The wait for CI is a Bash call of up to 9 minutes without commits.
2616
+ */
2617
+ const QUIET_MINUTES = 15;
2618
+
2619
+ /** The agent id of an agent's worktree (`agent-<id>`); another directory — null. */
2620
+ const agentIdOf = (dir: string) => (AGENT_WORKTREE.test(dir) ? path.basename(dir).slice("agent-".length) : null);
2621
+
2622
+ /** A Claude Code subagent by its metadata (`subagentsOf`). */
2623
+ interface Subagent {
2624
+ /** The agent that launched it; one the session itself launched — null. */
2625
+ parent: string | null;
2626
+ /** It has a worktree of its own (`isolation: "worktree"`); without one it works in its parent's directory. */
2627
+ own: boolean;
2628
+ /** The last write of its transcript, ms; no transcript — 0. */
2629
+ written: number;
2630
+ }
2631
+
2632
+ /**
2633
+ * The sessions' Claude Code subagents, by id (`agent-<id>` — its worktree's name too), from the subagent metadata
2634
+ * `<Claude Code configuration>/projects/<directory>/<session id>/subagents/agent-<id>.meta.json`: `parentAgentId` — the
2635
+ * agent that launched it, `worktreePath` named `agent-<id>` — its own worktree; the transcript `agent-<id>.jsonl` next to
2636
+ * it. No metadata — an empty map.
2637
+ */
2638
+ function subagentsOf(io: Io, sessions: string[]): Map<string, Subagent> {
2639
+ const agents = new Map<string, Subagent>();
2640
+ const projects = path.join(io.env.CLAUDE_CONFIG_DIR || path.join(io.env.HOME || os.homedir(), ".claude"), "projects");
2641
+ let dirs: string[];
2642
+ try {
2643
+ dirs = readdirSync(projects);
2644
+ } catch {
2645
+ return agents;
2646
+ }
2647
+ for (const dir of dirs) {
2648
+ for (const session of new Set(sessions)) {
2649
+ const sub = path.join(projects, dir, session, "subagents");
2650
+ let names: string[];
2651
+ try {
2652
+ names = readdirSync(sub);
2653
+ } catch {
2654
+ continue;
2655
+ }
2656
+ for (const name of names) {
2657
+ const id = /^agent-(.+)\.meta\.json$/.exec(name)?.[1];
2658
+ if (!id) continue;
2659
+ try {
2660
+ const meta = JSON.parse(readFileSync(path.join(sub, name), "utf8"));
2661
+ const parent = meta?.parentAgentId;
2662
+ const worktree = meta?.worktreePath;
2663
+ agents.set(id, {
2664
+ parent: typeof parent === "string" && parent ? parent : null,
2665
+ own: typeof worktree === "string" && path.basename(worktree) === `agent-${id}`,
2666
+ written: statSync(path.join(sub, `agent-${id}.jsonl`), { throwIfNoEntry: false })?.mtimeMs ?? 0,
2667
+ });
2668
+ } catch {
2669
+ // the app is writing it — the agent has only just started, its worktree is locked
2670
+ }
2671
+ }
2672
+ }
2673
+ }
2674
+ return agents;
2675
+ }
2676
+
2677
+ /**
2678
+ * A subagent at work in an agent's worktree without one of its own (#571): Claude Code runs a subagent launched without
2679
+ * `isolation: "worktree"` in its parent's directory — an executor's epic refresh, in the executor's worktree. The host
2680
+ * agent's descendants through such subagents, at any depth; at work — its transcript written less than `QUIET_MINUTES`
2681
+ * ago (a Bash call of up to 9 minutes writes nothing). The freshest is named; none — null.
2682
+ */
2683
+ function guestWhy(agents: Map<string, Subagent>, host: string): string | null {
2684
+ const inside = new Set([host]);
2685
+ let fresh: { id: string; minutes: number } | null = null;
2686
+ for (let grew = true; grew; ) {
2687
+ grew = false;
2688
+ for (const [id, a] of agents) {
2689
+ if (a.own || a.parent === null || !inside.has(a.parent) || inside.has(id)) continue;
2690
+ inside.add(id);
2691
+ grew = true;
2692
+ const minutes = Math.max(0, Math.floor((Date.now() - a.written) / 60_000));
2693
+ if (a.written > 0 && minutes < QUIET_MINUTES && (!fresh || minutes < fresh.minutes)) fresh = { id, minutes };
2694
+ }
2695
+ }
2696
+ return fresh && `subagent agent-${fresh.id} works in it without a worktree of its own — its transcript written ${fresh.minutes} min ago, quiet after ${QUIET_MINUTES}`;
2697
+ }
2698
+
2699
+ /**
2700
+ * Why a worktree is alive: a lock held by a live process, a subagent of its agent at work in it (`guestWhy`), or HEAD
2701
+ * moved less than `QUIET_MINUTES` ago; quiet — null.
2702
+ */
2703
+ function aliveWhy(io: Io, w: Worktree, agents: Map<string, Subagent>): string | null {
2704
+ if (w.locked !== null && lockHolds(w.locked)) return `locked by a live process — ${w.locked || "no reason"}`;
2705
+ const id = agentIdOf(w.path);
2706
+ const guest = id === null ? null : guestWhy(agents, id);
2707
+ if (guest) return guest;
2708
+ // the worktree's own HEAD log: the time of its latest entry (`HEAD@{<unix time>}`)
2709
+ const r = (io.run ?? realRun)("git", ["reflog", "-1", "--date=unix", "--format=%gd"], w.path);
2710
+ const at = Number(/@\{(\d+)\}/.exec(r.stdout)?.[1]);
2711
+ const minutes = Math.floor((Date.now() / 1000 - at) / 60);
2712
+ return r.status === 0 && at > 0 && minutes < QUIET_MINUTES ? `HEAD moved ${Math.max(0, minutes)} min ago (a commit or a switch), quiet after ${QUIET_MINUTES}` : null;
2713
+ }
2714
+
2715
+ /** The sessions' hand-out files on the machine — `dispatchers/<session id>.tasks` in the configuration directory (`handedOutFile`). */
2716
+ function handOutFiles(io: Io): { session: string; file: string }[] {
2717
+ const dir = path.join(configDir(io.env), "dispatchers");
2718
+ try {
2719
+ return readdirSync(dir)
2720
+ .filter((f) => f.endsWith(".tasks"))
2721
+ .map((f) => ({ session: f.slice(0, -".tasks".length), file: path.join(dir, f) }));
2722
+ } catch {
2723
+ return [];
2724
+ }
2725
+ }
2726
+
2727
+ /**
2728
+ * The task's previous executor and its subagents still alive (#565): the `!` lines with each live worktree. The
2729
+ * executor of #545 answered «failed» while two of its background subagents were still working; their answers woke it,
2730
+ * and it merged the task — while a second executor, launched on the owner's restart, worked on the same branch. The
2731
+ * previous executor's worktrees — its hand-outs of the task in any dispatcher session (`dispatchers/*.tasks`) and a
2732
+ * worktree on the task branch, the caller's own aside; its subagents' — by Claude Code's metadata (`subagentsOf`), at any
2733
+ * depth. Alive — `aliveWhy`: a subagent without a worktree of its own keeps alive the worktree it works in. The directory
2734
+ * isn't a checkout of the repository — none: the script doesn't see the worktrees.
2735
+ */
2736
+ function livePredecessors(io: Io, slug: string, number: number, own: string): string[] {
2737
+ if (!checkoutOfRepo(io, originUrl(io), slug)) return [];
2738
+ const listed = (io.run ?? realRun)("git", ["worktree", "list", "--porcelain"], io.cwd);
2739
+ if (listed.status !== 0) return [];
2740
+ const mine = realPath(own);
2741
+ const all = worktreeList(listed.stdout).filter((w) => !w.prunable && realPath(w.path) !== mine);
2742
+ const handOuts = handOutFiles(io).flatMap(({ session, file }) =>
2743
+ handOutsIn(file, slug)
2744
+ .filter((h) => h.number === number && h.worktree !== null)
2745
+ .map((h) => ({ session, worktree: realPath(h.worktree!) })),
2746
+ );
2747
+ const executors = all.filter((w) => handOuts.some((h) => h.worktree === realPath(w.path)) || (w.branch !== null && isTaskBranch(w.branch, number)));
2748
+ if (!executors.length) return [];
2749
+ const agents = subagentsOf(io, [...handOuts.map((h) => h.session), io.env.CLAUDE_CODE_SESSION_ID ?? ""].filter(Boolean));
2750
+ // the executors' agents and everything they launched
2751
+ const family = new Set(executors.map((w) => agentIdOf(w.path)).filter((id): id is string => id !== null));
2752
+ for (let grew = true; grew; ) {
2753
+ grew = false;
2754
+ for (const [id, { parent }] of agents) {
2755
+ if (parent !== null && family.has(parent) && !family.has(id)) {
2756
+ family.add(id);
2757
+ grew = true;
2758
+ }
2759
+ }
2760
+ }
2761
+ const lines: string[] = [];
2762
+ for (const w of all) {
2763
+ const parent = agents.get(agentIdOf(w.path) ?? "")?.parent;
2764
+ const role = executors.includes(w) ? "the previous executor" : parent && family.has(parent) ? `a subagent of agent-${parent}` : null;
2765
+ const why = role && aliveWhy(io, w, agents);
2766
+ if (why) lines.push(`! worktree ${w.path} (${role}): ${why}`);
2767
+ }
2768
+ return lines;
2769
+ }
2770
+
2771
+ /**
2772
+ * The task's branches `[<area>/]<type>/<N>-<slug>`: local ones of the current directory's checkout and those on its origin
2773
+ * (`git ls-remote`: the executor's fresh worktree hasn't fetched a branch its predecessor pushed). The directory isn't a
2774
+ * checkout of the repository — none: the script doesn't see the branches and claims nothing.
2775
+ */
2776
+ function taskBranchesHere(io: Io, slug: string, number: number): string[] {
2777
+ if (!checkoutOfRepo(io, originUrl(io), slug)) return [];
2778
+ const git = (...args: string[]) => (io.run ?? realRun)("git", args, io.cwd).stdout.split("\n");
2779
+ const refs = [...git("for-each-ref", "--format=%(refname)", "refs/heads/"), ...git("ls-remote", "--heads", "origin").map((l) => l.split("\t")[1] ?? "")];
2780
+ return [...new Set(refs.map((r) => r.trim().replace(/^refs\/heads\//, "")).filter((b) => isTaskBranch(b, number)))].sort();
2781
+ }
2782
+
2783
+ /**
2784
+ * The executor's whole assignment (`task queue --assignment N`): the task by the canon, what a subagent does otherwise, the
2785
+ * answer form. The restart line — only when it is true: the task is In progress and has a branch `<type>/N-<slug>`
2786
+ * (#511); printed always, it misled the executor of a Backlog task, who argued with it in the answer. The task is
2787
+ * recorded as handed out with the executor's worktree (`handedOutFile`): the queue no longer gives it out as ready and
2788
+ * keeps the worktree while the task is open, and `task status` in the executor doesn't pin it. In any identified
2789
+ * session, not only a dispatcher's (#546): a session with its own task, or none yet, launches an executor on the
2790
+ * owner's "yes" — unrecorded, its "In progress" would be refused by the session's pin or would pin the session to it.
2791
+ * A session pinned to this very task records nothing: the task is its own. A restart while the previous executor or its
2792
+ * subagent is alive (`livePredecessors`, #565) — no assignment and no hand-out: the `!` lines and the answer that
2793
+ * makes the dispatcher wait, code 1. `cloud` — the brief of a cloud executor (#609), printed by the dispatcher: whole, `task
2794
+ * queue` refuses in the cloud; `#` only at its task (`est` links a cloud session by the only `#N` of its first
2795
+ * prompt — docs/cloud-sessions.md, "The brief"); no restart line — the cloud assigns its own branch. A cloud session
2796
+ * can't message a local one, so `Next:` gives the dispatcher the wait for the task's closing and the local close.
2797
+ */
2798
+ export function cmdTaskAssignment(io: Io, slug: string, number: number, o: { cloud: boolean } = { cloud: false }): number {
2799
+ const issue = loadIssue(io, slug, number);
2800
+ const file = handedOutFile(io);
2801
+ const own = pinnedTask(io)?.task;
2802
+ // the executor's worktree — the checkout it calls the assignment in: the queue keeps it while the task is open (#533)
2803
+ const top = (io.run ?? realRun)("git", ["rev-parse", "--show-toplevel"], io.cwd);
2804
+ const worktree = top.status === 0 ? top.stdout.trim() : "";
2805
+ const alive = worktree ? livePredecessors(io, slug, number, worktree) : [];
2806
+ if (alive.length) {
2807
+ for (const line of alive) io.out(line);
2808
+ io.out(locale.fill(N.queue.alive, { task: N.subagentPrompt.task, number, repo: slug }));
2809
+ return 1;
2810
+ }
2811
+ if (file && !(own?.slug === slug && own.number === number)) {
2812
+ mkdirSync(path.dirname(file), { recursive: true });
2813
+ // the configuration directory may be a personal git checkout (ELVES_PRIVATE) — hand-outs aren't committed into it
2814
+ writeFileSync(path.join(path.dirname(file), ".gitignore"), "*\n");
2815
+ const where = o.cloud ? CLOUD_HANDOUT : worktree;
2816
+ appendFileSync(file, `${slug}#${number}${where ? ` ${where}` : ""}\n`);
2817
+ }
2818
+ if (o.cloud) return cloudBrief(io, slug, issue), 0;
2819
+ const restart = itemIn(taskContext(io, slug), issue)?.status === N.status.inProgress ? taskBranchesHere(io, slug, number) : [];
2820
+ const vars = {
2821
+ task: N.subagentPrompt.task,
2822
+ number,
2823
+ title: issue.title,
2824
+ repo: slug,
2825
+ epic: issue.parent ? locale.fill(N.queue.epic, { epicWord: N.subagentPrompt.epic, epic: issue.parent.number }) : "",
2826
+ script: `bun --no-env-file ${import.meta.path}`,
2827
+ waitCi: `bash ${waitCi()}`,
2828
+ inProgress: N.status.inProgress,
2829
+ done: N.lastLine.done,
2830
+ questions: N.sections.questions,
2831
+ branches: restart.map((b) => `\`${b}\``).join(", "),
2832
+ };
2833
+ const lines = [N.queue.lead, ...(restart.length ? [N.queue.resume] : []), N.queue.subagent, N.queue.session, N.queue.wait, N.queue.git, N.queue.subagents, N.queue.live, N.queue.found, N.queue.question, N.queue.answer];
2834
+ for (const line of lines) io.out(locale.fill(line, vars));
2835
+ return 0;
2836
+ }
2837
+
2838
+ function actualizeChecks(): string[] {
2839
+ const file = path.resolve(import.meta.dir, "../reference.md");
2840
+ const m = existsSync(file) ? /Five checks:\n\n([\s\S]*?)\n\nWhat was fixed/.exec(readFileSync(file, "utf8")) : null;
2841
+ const items = m ? m[1]!.split(/\n(?=\d\. )/) : [];
2842
+ if (items.length !== 5) throw new GhError(`the five epic-refresh checks not found in ${file}: expected a list of five between «Five checks:» and «What was fixed»`);
2843
+ return items;
2844
+ }
2845
+
2846
+ /** The checks by meaning — the subagent's: 1 (whether done), 3, 5; the mechanics of 1, 2 and 4 is the script's. */
2847
+ const MEANING_CHECKS = [0, 2, 4];
2848
+
2849
+ /**
2850
+ * ✅ that answers a question — at the start of its answer: after the item's number (`task answer`), at the start of an
2851
+ * item's line, after the end of the question or a sentence (`?`, `.`, `!`, a dash; closing quotes and markup included),
2852
+ * as answers by hand put it. ✅ inside the text — after a word, in parentheses, quotes or code (a recommendation naming
2853
+ * «✅ auto») — doesn't answer (#603).
2854
+ */
2855
+ const ANSWER_MARK_RE = /(?:^\s*(?:\d+\.\s+)?|[?.!—–][»"'*_)`]*\s+)[*_(]*✅/;
2856
+ /** A thematic break ends the questions section: the cloud proxy's signature after `---` isn't the last question's (#603). */
2857
+ const THEMATIC_BREAK_RE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
2858
+
2859
+ /**
2860
+ * The items of the issue's questions section ("## Questions" in any language of the dictionary, up to the next section
2861
+ * or a thematic break): the number, the first line, all the item's non-empty lines and whether it is answered — ✅ at
2862
+ * the start of its answer (`ANSWER_MARK_RE`); `first` and `last` — the indexes of the item's first and last non-empty
2863
+ * line in the description's lines. The dashboard lists the open ones and the answers marked auto, `task actualize`
2864
+ * removes the questions label from a task without open ones, `task show` names them, `task answer` puts the answer
2865
+ * into the item.
2866
+ */
2867
+ export function questionItems(body: string): { k: number; text: string; lines: string[]; answered: boolean; first: number; last: number }[] {
2868
+ const section = new RegExp(`^(?:${locale.every((n) => n.sections.questions).map(locale.escapeRe).join("|")})\\s*$`);
2869
+ const lines = body.split(/\r?\n/);
2870
+ const from = lines.findIndex((l) => section.test(l));
2871
+ if (from < 0) return [];
2872
+ const items: { first: number; last: number }[] = [];
2873
+ for (let i = from + 1; i < lines.length && !/^##\s/.test(lines[i]!) && !THEMATIC_BREAK_RE.test(lines[i]!); i++) {
2874
+ if (/^\d+\.\s/.test(lines[i]!)) items.push({ first: i, last: i });
2875
+ else if (items.length && lines[i]!.trim()) items.at(-1)!.last = i;
2876
+ }
2877
+ return items.map(({ first, last }) => {
2878
+ const item = lines.slice(first, last + 1).filter((l) => l.trim());
2879
+ return { k: parseInt(lines[first]!, 10), text: lines[first]!.trim(), lines: item, answered: item.some((l) => ANSWER_MARK_RE.test(l)), first, last };
2880
+ });
2881
+ }
2882
+
2883
+ interface MilestoneRef {
2884
+ number: number;
2885
+ title: string;
2886
+ state: string;
2887
+ /** Open tasks in it. */
2888
+ open: number;
2889
+ }
2890
+
2891
+ /** An issue as the epic refresh reads it: the text, labels with ids, blocked by, comments, sub-issues with milestones. */
2892
+ interface TaskText {
2893
+ id: string;
2894
+ number: number;
2895
+ title: string;
2896
+ state: string;
2897
+ body: string;
2898
+ labels: { id: string; name: string }[];
2899
+ milestone: MilestoneRef | null;
2900
+ blockedBy: (Blocker & { id: string })[];
2901
+ comments: { body: string; url: string }[];
2902
+ subIssues: { number: number; title: string; state: string; milestone: MilestoneRef | null }[];
2903
+ }
2904
+
2905
+ function loadTaskText(io: Io, slug: string, number: number): TaskText {
2906
+ const [owner, name] = slug.split("/");
2907
+ const i = graphql(io, Q.TaskText, { owner, name, number })?.repository?.issue;
2908
+ if (!i) throw new GhError(`no issue #${number} in ${slug}`);
2909
+ const milestone = (m: Any): MilestoneRef | null => (m ? { number: m.number, title: m.title, state: m.state, open: m.issues?.totalCount ?? 0 } : null);
2910
+ return {
2911
+ id: i.id,
2912
+ number: i.number,
2913
+ title: i.title,
2914
+ state: i.state,
2915
+ // the GitHub UI saves a description with CRLF
2916
+ body: (i.body ?? "").replace(/\r\n/g, "\n").trim(),
2917
+ labels: i.labels?.nodes ?? [],
2918
+ milestone: milestone(i.milestone),
2919
+ blockedBy: (i.blockedBy?.nodes ?? []).map((b: Any) => ({ id: b.id, number: b.number, state: b.state, repo: b.repository?.nameWithOwner ?? slug })),
2920
+ comments: i.comments?.nodes ?? [],
2921
+ subIssues: (i.subIssues?.nodes ?? []).map((x: Any) => ({ number: x.number, title: x.title, state: x.state, milestone: milestone(x.milestone) })),
2922
+ };
2923
+ }
2924
+
2925
+ /**
2926
+ * The refresh's mechanics — what needs no judgment, so the subagent doesn't collect it (#435): blocked by on done tasks
2927
+ * (closed ones and this task, whose PR is merged) is removed — check 4; the questions label without open questions in
2928
+ * the section — check 2; a milestone of the epic's tasks without open tasks is closed — check 1, except the task's and
2929
+ * the epic's own: `task close` closes them, the task may still be open. The fixes — one line in a comment on the epic.
2930
+ */
2931
+ function refreshMechanics(io: Io, slug: string, r: { number: number; merged: boolean; epic: TaskText; open: TaskText[] }): void {
2932
+ const fixes: string[] = [];
2933
+ const tasks = [r.epic, ...r.open];
2934
+ for (const t of tasks) {
2935
+ // an external blocker is done only when closed: the merged task is of this repository
2936
+ const done = t.blockedBy.filter((b) => b.state === "CLOSED" || (r.merged && blockerRef(slug, b) === `#${r.number}`));
2937
+ if (!done.length) continue;
2938
+ for (const b of done) mutate(io, { op: "RemoveBlockedBy", input: { issueId: t.id, blockingIssueId: b.id } });
2939
+ io.out(`+ #${t.number}: blocked by ${done.map((b) => `${blockerRef(slug, b)} (${b.state === "CLOSED" ? "closed" : "merged"})`).join(", ")} removed`);
2940
+ fixes.push(locale.fill(N.actualize.unblocked, { task: t.number, blockers: done.map((b) => blockerRef(slug, b)).join(", ") }));
2941
+ }
2942
+ const questionLabels = locale.every((n) => n.labels.questions);
2943
+ for (const t of tasks) {
2944
+ const labels = t.labels.filter((l) => questionLabels.includes(l.name));
2945
+ if (!labels.length || questionItems(t.body).some((x) => !x.answered)) continue;
2946
+ mutate(io, { op: "RemoveLabels", input: { labelableId: t.id, labelIds: labels.map((l) => l.id) } });
2947
+ for (const l of labels) {
2948
+ io.out(`+ #${t.number}: label ${q(l.name)} removed — no open questions`);
2949
+ fixes.push(locale.fill(N.actualize.unlabeled, { task: t.number, label: l.name }));
2950
+ }
2951
+ }
2952
+ const own = r.epic.subIssues.find((x) => x.number === r.number)?.milestone?.number;
2953
+ const seen = new Set([own, r.epic.milestone?.number]);
2954
+ for (const { milestone: m } of r.epic.subIssues) {
2955
+ if (!m || seen.has(m.number)) continue;
2956
+ seen.add(m.number);
2957
+ if (m.state !== "OPEN" || m.open > 0) continue;
2958
+ io.gh(["api", "-X", "PATCH", `repos/${slug}/milestones/${m.number}`, "-f", "state=closed"]);
2959
+ io.out(`+ milestone ${q(m.title)} closed: no open tasks left`);
2960
+ fixes.push(locale.fill(N.actualize.milestone, { title: m.title }));
2961
+ }
2962
+ if (!fixes.length) return;
2963
+ mutate(io, { op: "AddComment", input: { subjectId: r.epic.id, body: locale.fill(N.actualize.mechanics, { number: r.number, fixes: fixes.join("; ") }) } });
2964
+ io.out(`+ epic #${r.epic.number}: a comment with the script's fixes`);
2965
+ }
2966
+
2967
+ /** A quoted line of an issue or a comment in the assignment: one line, at most 200 characters. */
2968
+ const clip = (line: string) => (line.length > 200 ? `${line.slice(0, 199)}…` : line);
2969
+
2970
+ /**
2971
+ * Copies of links in text (check 4): in the epic — a list item that starts with the number of its sub-issue; in the
2972
+ * epic and its open tasks — "⛔" and a dependency phrase before `#N` (`linkWords` of any language). Named, not edited:
2973
+ * a line may carry meaning besides the copy.
2974
+ */
2975
+ function linkCopies(epic: TaskText, open: TaskText[]): { number: number; line: string }[] {
2976
+ const subs = new Set(epic.subIssues.map((x) => x.number));
2977
+ const words = locale.every((n) => n.actualize.linkWords).flatMap((w) => w.split("|")).map((w) => w.trim()).filter(Boolean).map(locale.escapeRe);
2978
+ const phrase = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${words.join("|")})\\s*:?\\s*#\\d+`, "iu");
2979
+ const item = /^\s*(?:[-*+]|\d+\.)\s+(?:\[[ xX]\]\s+)?#(\d+)(?!\d)/;
2980
+ const out: { number: number; line: string }[] = [];
2981
+ for (const t of [epic, ...open]) {
2982
+ for (const raw of t.body.split("\n")) {
2983
+ const listed = t === epic ? item.exec(raw) : null;
2984
+ if ((listed && subs.has(Number(listed[1]))) || raw.includes("⛔") || phrase.test(raw)) out.push({ number: t.number, line: clip(raw.trim()) });
2985
+ }
2986
+ }
2987
+ return out;
2988
+ }
2989
+
2990
+ /**
2991
+ * Answers to open questions in comments (check 2): an item `k. ✅ …` of a comment for the open question k, the latest
2992
+ * wins. Named, not moved: a comment's numbering may be its own (answers to a concept, not to the section).
2993
+ */
2994
+ function commentAnswers(tasks: TaskText[]): { number: number; k: number; text: string; url: string }[] {
2995
+ const out: { number: number; k: number; text: string; url: string }[] = [];
2996
+ for (const t of tasks) {
2997
+ const open = new Set(questionItems(t.body).filter((x) => !x.answered).map((x) => x.k));
2998
+ if (!open.size) continue;
2999
+ const found = new Map<number, { text: string; url: string }>();
3000
+ for (const c of t.comments) for (const m of c.body.matchAll(/^\s*(\d+)\.\s*✅\s*(.+)$/gm)) if (open.has(Number(m[1]))) found.set(Number(m[1]), { text: clip(m[2]!.trim()), url: c.url });
3001
+ for (const [k, a] of [...found].sort(([x], [y]) => x - y)) out.push({ number: t.number, k, ...a });
3002
+ }
3003
+ return out;
3004
+ }
3005
+
3006
+ /** A changed PR file in the assignment: the path, the kind of change unless it's an edit, the line counts. */
3007
+ const fileLine = (f: PrFile) => `- ${f.path} (${f.changeType === "MODIFIED" ? "" : `${f.changeType.toLowerCase()}, `}+${f.additions} −${f.deletions})`;
3008
+ /** An issue's description in the assignment — in a fence: its headings don't mix with the assignment's. */
3009
+ const textBlock = (body: string) => (body ? `~~~~markdown\n${body}\n~~~~` : N.actualize.noBody);
3010
+
3011
+ /**
3012
+ * The epic refresh — right after the merge, in parallel with the deploy: the refresh doesn't depend on the deploy. The
3013
+ * command does the mechanics itself (`refreshMechanics`) and prints the subagent's prompt — two lines: «Task #N (epic
3014
+ * #M)» (`subagentPrompt.task` and the number: est ties the subagent's work to the task by it) and the command of its
3015
+ * whole assignment. The assignment (`--assignment`, read-only) is the subagent's first call: the texts of the epic and
3016
+ * its open tasks and the PR files run to tens of KB, and the session at peak context would copy them into the subagent
3017
+ * call as output tokens. In it — the task and its PR with its files, the epic and its open tasks with their texts, the
3018
+ * copies of links and the answers in comments the script found, the checks by meaning quoted from reference.md as is,
3019
+ * what may be changed and the answer form; in the project language (the `actualize` keys). No epic, or no open tasks in
3020
+ * it besides this one — the checks by meaning have nothing to go through: a "not needed" line, no subagent.
3021
+ */
3022
+ export function cmdTaskActualize(io: Io, slug: string, number: number, o: { assignment: boolean } = { assignment: false }): number {
3023
+ const issue = loadIssue(io, slug, number);
3024
+ const later = `Next: task close ${number} — after the deploy, where the merge deploys.`;
3025
+ if (!issue.parent) return io.out(`○ refresh not needed: #${number} has no epic. ${later}`), 0;
3026
+ const linked = issue.prs.filter((p) => p.merged);
3027
+ const prs = linked.length ? linked : unlinkedPrs(io, slug, number).prs.filter((p) => p.merged);
3028
+ const epic = loadTaskText(io, slug, issue.parent.number);
3029
+ const open = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).map((x) => loadTaskText(io, slug, x.number));
3030
+ if (!o.assignment) refreshMechanics(io, slug, { number, merged: prs.length > 0, epic, open });
3031
+ if (!open.length) return io.out(`○ refresh not needed: epic #${epic.number} has no open tasks besides #${number}. ${later}`), 0;
3032
+ const vars = { task: N.subagentPrompt.task, epicWord: N.subagentPrompt.epic, number, epic: epic.number, repo: slug };
3033
+ const lead = locale.fill(N.actualize.lead, vars);
3034
+ if (!o.assignment) {
3035
+ io.out("Next: a subagent in the background — the two lines below are its whole prompt, pass them as is.");
3036
+ io.out(lead);
3037
+ io.out(locale.fill(N.actualize.fetch, { command: `bun --no-env-file ${import.meta.path} task actualize ${number} --assignment --repo ${slug}` }));
3038
+ return 0;
3039
+ }
3040
+ const checks = actualizeChecks();
3041
+ const shipped = prs.length
3042
+ ? locale.fill(N.actualize.prs, { list: prs.map((p) => `#${p.number} (\`gh pr view ${p.number}\`, \`gh pr diff ${p.number}\`)`).join(", ") })
3043
+ : locale.fill(N.actualize.noPr, { command: `\`git log origin/main --grep '#${number}'\`` });
3044
+ const lines = [lead, locale.fill(N.actualize.shipped, { ...vars, title: issue.title, shipped })];
3045
+ for (const p of prs) {
3046
+ const files = loadPrChange(io, slug, p.number).files;
3047
+ if (files.length) lines.push(locale.fill(N.actualize.files, { pr: p.number }), ...files.map(fileLine));
3048
+ }
3049
+ lines.push("", locale.fill(N.actualize.epic, { ...vars, title: epic.title }), textBlock(epic.body), "", N.actualize.openTasks);
3050
+ for (const t of open) lines.push("", locale.fill(N.actualize.openTask, { number: t.number, title: t.title }), textBlock(t.body));
3051
+ const copies = linkCopies(epic, open);
3052
+ if (copies.length) lines.push("", N.actualize.copies, ...copies.map((c) => locale.fill(N.actualize.copy, c)));
3053
+ const answers = commentAnswers([epic, ...open]);
3054
+ if (answers.length) lines.push("", locale.fill(N.actualize.answers, { questions: N.sections.questions }), ...answers.map((a) => locale.fill(N.actualize.commentAnswer, a)));
3055
+ lines.push(
3056
+ "",
3057
+ N.actualize.checks,
3058
+ "",
3059
+ MEANING_CHECKS.map((i) => checks[i]!).join("\n"),
3060
+ "",
3061
+ locale.fill(N.actualize.scope, { ...vars, questions: N.sections.questions, script: `bun --no-env-file ${import.meta.path}` }),
3062
+ locale.fill(N.actualize.comment, { ...vars, report: locale.fill(N.actualize.report, { number, text: "…" }) }),
3063
+ N.actualize.answer,
3064
+ );
3065
+ for (const line of lines) io.out(line);
3066
+ return 0;
3067
+ }
3068
+
3069
+ /**
3070
+ * The epic refresh's report (#571): the refresh subagent ends with a comment on the epic that starts with `report` —
3071
+ * «Refresh after #N, result: …» in any language of the dictionary, case and markup aside; the script's own comment
3072
+ * (`mechanics`) comes before the subagent and isn't it.
3073
+ */
3074
+ function refreshReported(epic: TaskText, number: number): boolean {
3075
+ const heads = locale.every((n) => n.actualize.report).map((t) => locale.fill(t, { number, text: "" }).trim().toLowerCase());
3076
+ return epic.comments.some((c) => heads.some((h) => c.body.toLowerCase().includes(h)));
3077
+ }
3078
+
3079
+ /**
3080
+ * The closing ritual after a PR merge — the actual, Status "Done", the epic, the milestone, taking down the task's
3081
+ * stacks, cleaning up the merged branch — in one call: the session did it one step per turn at its largest context.
3082
+ * The epic refresh (the canon's five checks) isn't here: it runs before close, in parallel with the deploy, by a
3083
+ * subagent with the `task actualize` assignment. Where the epic has open tasks besides this one, close refuses before
3084
+ * any change until the subagent's report is on the epic (`refreshReported`, #571): an executor closed its task and
3085
+ * answered while its refresh subagent still worked in its worktree — the dispatcher's next queue removed the worktree,
3086
+ * and the subagent was left without a directory. Close names the three lines, one of which ends the session's answer,
3087
+ * and archiving the session in auto mode — except a session with questions for the owner.
3088
+ */
3089
+ export function cmdTaskClose(io: Io, slug: string, number: number, o: { git: boolean }): number {
3090
+ const ctx = taskContext(io, slug);
3091
+ const issue = loadIssue(io, slug, number);
3092
+ const linked = issue.prs.filter((p) => p.merged);
3093
+ const closedNoPr = !issue.prs.length && issue.state === "CLOSED" && issue.stateReason === "COMPLETED";
3094
+ // GitHub may not link a merged PR to the task for hours: the task is open, no merged PR among the linked ones — we
3095
+ // look for the task's PR ourselves
3096
+ const found = !linked.length && issue.state === "OPEN" ? unlinkedPrs(io, slug, number) : null;
3097
+ const fresh = (found?.prs ?? []).filter((p) => !issue.prs.some((x) => x.number === p.number));
3098
+ const unlinked = fresh.filter((p) => p.merged);
3099
+ const merged = linked.length ? linked : unlinked;
3100
+ if (!merged.length && !closedNoPr) {
3101
+ const prs = [...issue.prs, ...fresh].map((p) => `#${p.number} ${p.state === "OPEN" ? "open" : "closed without a merge"}`).join(", ");
3102
+ const what = prs ? `PR ${prs}` : `the task is ${issue.state === "OPEN" ? "open" : "closed without being done"}, no merged PR`;
3103
+ const checked = found ? `; checked: the PR–task link, the branch <type>/${number}-<slug> and «Closes #${number}» in the body of the last ${RECENT_PRS} PRs into ${found.base}` : "";
3104
+ throw new GhError(`#${number} is too early to close: ${what}${checked} — close after the PR is merged or the task is closed`);
3105
+ }
3106
+ if (issue.parent) {
3107
+ const epic = loadTaskText(io, slug, issue.parent.number);
3108
+ const others = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).length;
3109
+ if (others && !refreshReported(epic, number)) {
3110
+ const head = locale.fill(N.actualize.report, { number, text: "…" });
3111
+ throw new GhError(
3112
+ `#${number} is too early to close: the epic refresh hasn't reported — epic #${epic.number} has open tasks besides #${number} (${others}), and no comment «${head}» on it. ` +
3113
+ `The refresh subagent is at work — wait for its answer, then task close ${number} again; it wasn't launched — task actualize ${number} and the subagent with its prompt; ` +
3114
+ `it answered without the comment — post its result: gh issue comment ${epic.number} --repo ${slug} --body "${head}"`,
3115
+ );
3116
+ }
3117
+ }
3118
+ const wasOpen = issue.state === "OPEN";
3119
+ if (wasOpen) {
3120
+ // without the link the task doesn't see its PR — a comment names it
3121
+ if (unlinked.length) mutate(io, { op: "AddComment", input: { subjectId: issue.id, body: locale.fill(N.comments.closedByPr, { prs: unlinked.map((p) => `#${p.number}`).join(", ") }) } });
3122
+ mutate(io, { op: "CloseIssue", input: { issueId: issue.id, stateReason: "COMPLETED" } });
3123
+ io.out(
3124
+ unlinked.length
3125
+ ? `+ #${number} closed by PR ${unlinked.map((p) => `#${p.number} (${p.why})`).join(", ")}: GitHub didn't link the PR to the task`
3126
+ : `+ #${number} closed: PR ${merged.map((p) => `#${p.number}`).join(", ")} merged`,
3127
+ );
3128
+ }
3129
+ freshClone(io, slug);
3130
+ runFact(io, slug, number);
3131
+ const item = ensureItem(io, ctx, issue);
3132
+ setStatus(io, ctx, item.id, N.status.done);
3133
+ io.out(`+ #${number}: ${STATUS} ${q(N.status.done)}${item.added ? " (added to the project)" : ""}`);
3134
+ closeMilestone(io, slug, issue, wasOpen);
3135
+ if (issue.parent) {
3136
+ const epic = loadIssue(io, slug, issue.parent.number);
3137
+ const open = epic.subIssues.filter((x) => x.state === "OPEN" && x.number !== number).length;
3138
+ if (epic.state === "OPEN" && epic.subIssues.length && !open) {
3139
+ mutate(io, { op: "CloseIssue", input: { issueId: epic.id, stateReason: "COMPLETED" } });
3140
+ setStatus(io, ctx, ensureItem(io, ctx, epic).id, N.status.done);
3141
+ io.out(`+ epic #${epic.number} closed and in ${q(N.status.done)}: all subtasks are closed`);
3142
+ closeMilestone(io, slug, epic, true);
3143
+ } else if (epic.state === "OPEN") io.out(`○ epic #${epic.number}: open subtasks ${open}`);
3144
+ }
3145
+ // stacks of task-env and branches are named by the checkout's origin: from someone else's checkout — another project's
3146
+ if (!checkoutOfRepo(io, originUrl(io), slug)) io.out(`○ ${o.git ? "branches and task stacks" : "task stacks"} left alone: the current directory is not a checkout of ${slug}`);
3147
+ else {
3148
+ takeDownStacks(io, number);
3149
+ if (o.git) {
3150
+ // a follow-up fix to a merged PR is the same task, and GitHub doesn't link it (Refs, not Closes): the branches are
3151
+ // those of every merged PR of the task — the linked ones and the ones found by the branch or «Closes #N»
3152
+ const more = (found ?? unlinkedPrs(io, slug, number)).prs.filter((p) => p.merged && !merged.some((x) => x.number === p.number));
3153
+ cleanupBranches(io, number, [...merged, ...more]);
3154
+ }
3155
+ }
3156
+ // three lines — in the project language: dashboard parses them
3157
+ io.out(
3158
+ `Next: the last line of the answer is one of three: ${q(N.lastLine.done)}; ` +
3159
+ `the owner has something to decide — ${q(`${N.lastLine.questions} …`)} with the questions themselves, without ${q(N.lastLine.done)}; ` +
3160
+ `something remains — ${q(`${N.lastLine.remaining} …`)}. ` +
3161
+ // the command doesn't read the mode (auto) or the session's background tasks: the session knows them, the step is spelled out in words
3162
+ `In auto mode after ${q(N.lastLine.done)} — archive the session where the agent has the tool, ` +
3163
+ `first stopping its own background tasks still running (a wait, a watcher over its output, a subagent): a live one blocks archiving; ` +
3164
+ `after the other two lines — don't. ` +
3165
+ `Information without a decision (an observation, a deadline, a warning) isn't a question: the line and archiving are as with ${q(N.lastLine.done)}`,
3166
+ );
3167
+ return 0;
3168
+ }
3169
+
3170
+ /**
3171
+ * What a resumed session restarts: a background wait (ci-wait) dies with the app's process — at a restart four sessions
3172
+ * lost their waits in one minute, and the owner wrote "continue" 17 times (#433). The SessionStart hook runs this
3173
+ * command: the task pinned to the session (its repository is in the pin, not in the checkout) and its PRs name the
3174
+ * wait — an open PR's checks; a merged PR of an open task — the deploy and `task close`. The PR GitHub didn't link — by
3175
+ * the task branch, as in `task close`. No pin — no line and no request: the session has no task. Read-only on GitHub.
3176
+ * A dispatcher's executors stopped with the app's process, but their transcripts are intact (#528): each is continued
3177
+ * with `SendMessage` — its context and worktree, where the task branch and uncommitted work are; a new executor would get
3178
+ * a fresh worktree, and git won't give it the branch. So the hand-outs stay (`handedOutFile`): their tasks aren't ready.
3179
+ * A session with its own task and executors (#546) — the same lines, without the queue command if it ran none, and
3180
+ * then its own task's wait.
3181
+ */
3182
+ export function cmdTaskResume(io: Io): number {
3183
+ const dispatcher = dispatcherFile(io);
3184
+ const queued = !!dispatcher && existsSync(dispatcher);
3185
+ if (queued || (dispatcher && existsSync(`${dispatcher}.tasks`))) {
3186
+ io.out(
3187
+ "Dispatcher session: its executors (background subagents) stopped with the app's process, their transcripts are intact — " +
3188
+ "continue each with SendMessage by its id from the app's notification, not a new executor: it goes on with its context in its own worktree" +
3189
+ (queued ? `; then re-read the queue: ${readFileSync(dispatcher!, "utf8").trim()} — their tasks stay = in it` : ""),
3190
+ );
3191
+ io.out(
3192
+ "Dispatcher session: a new executor — only for one that can't be continued, with the same prompt; " +
3193
+ `a task ${q(N.status.inProgress)} it continues in the worktree of the task's branch (\`git worktree list\`), not in a new one — its assignment says so: ` +
3194
+ "git won't give the branch to a second worktree, and uncommitted work stays in the old one",
3195
+ );
3196
+ }
3197
+ const pin = pinnedTask(io);
3198
+ if (!pin) return 0;
3199
+ // not a pin — the session has no task, as for est and the dashboard; the line names the file for a human
3200
+ if (!pin.task) return io.out(`○ the session pin ${pin.file} — «${pin.line}», not owner/repo#N: the session has no task`), 0;
3201
+ const { slug, number } = pin.task;
3202
+ const task = `session task ${slug}#${number}`;
3203
+ const issue = loadIssue(io, slug, number);
3204
+ if (issue.state !== "OPEN") return io.out(`○ ${task} is closed — no wait to restart`), 0;
3205
+ const live = (prs: Issue["prs"]) => prs.filter((p) => p.state === "OPEN" || p.merged);
3206
+ const prs = live(issue.prs).length ? live(issue.prs) : live(unlinkedPrs(io, slug, number).prs);
3207
+ const open = prs.filter((p) => p.state === "OPEN");
3208
+ // the task isn't done while a PR of it is open: a follow-up fix after a merge waits for its own checks
3209
+ for (const p of open) io.out(`Session task ${slug}#${number}: PR #${p.number} is open — a background wait dies with the app's process; restart it as a background command: bash ${waitCi()} pr ${p.number}`);
3210
+ const merged = prs.filter((p) => p.merged).sort((a, b) => b.number - a.number)[0];
3211
+ if (!open.length && merged)
3212
+ io.out(
3213
+ `Session task ${slug}#${number}: PR #${merged.number} is merged, the task is open — where the merge deploys, the deploy wait as a background command: bash ${waitCi()} merged ${merged.number}; ` +
3214
+ `then close the task: bun --no-env-file ${import.meta.path} task close ${number}`,
3215
+ );
3216
+ if (!prs.length) io.out(`○ ${task}: no PR — no wait to restart`);
3217
+ return 0;
3218
+ }
3219
+
3220
+ // canon task types — a copy of EST_TYPES and BRANCH_CONV_RE from skills/est/scripts/est.ts (and in skills/task-env/scripts/task-env.ts):
3221
+ // no shared module: github imports no other skill (tests/standards/skill-imports); the tests/standards/task-branch
3222
+ // standard keeps the parsing identical
3223
+ const TASK_TYPES = ["feat", "fix", "docs", "refactor", "perf", "test", "chore", "ci", "build", "research"];
3224
+ const TASK_BRANCH_RE = new RegExp("^(?:[\\p{L}\\p{N}_.-]+/)?(" + TASK_TYPES.join("|") + ")/(\\d{1,6})-", "iu");
3225
+
3226
+ /** A task branch by the canon — `[<area>/]<type>/<N>-<slug>`: the type (lowercased) and the number, otherwise null. */
3227
+ export function taskBranch(branch: string): { type: string; issue: number } | null {
3228
+ const m = TASK_BRANCH_RE.exec(branch);
3229
+ return m ? { type: m[1]!.toLowerCase(), issue: parseInt(m[2]!, 10) } : null;
3230
+ }
3231
+ const isTaskBranch = (branch: string, number: number) => taskBranch(branch)?.issue === number;
3232
+ /** The slug of a branch `task status --branch` creates: Latin kebab-case after `<N>-` (AGENTS.md, "Session and branch"). */
3233
+ const TASK_SLUG_RE = /\/\d{1,6}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
3234
+
3235
+ /** Attempts to write the shared `.git/config` while another checkout's git holds its lock: 20 with 0.5 s between — ~10 s. */
3236
+ const CONFIG_LOCK_TRIES = 20;
3237
+ const CONFIG_LOCK_PAUSE_MS = 500;
3238
+ const CONFIG_LOCKED = /could not lock config file (.+)$/m;
3239
+
3240
+ /**
3241
+ * The worktree tool's branch (`claude/<slug>-<hash>`) becomes the task branch — `task status N "In progress" --branch`.
3242
+ * `git branch -m` renames the ref, then the branch's section in `.git/config` (upstream): the config is shared by all
3243
+ * worktrees of the repository, another checkout's git holds its lock — the ref is renamed, the section is not, code 128,
3244
+ * and a repeated `git branch -m` no longer finds the old name (#438). So the step that failed on the lock is repeated —
3245
+ * the whole rename or, once the ref is renamed, `git config --rename-section`; still locked — the command that finishes
3246
+ * it, the lock isn't removed: another process holds it. A branch already on origin (main, a pushed one) is renamed
3247
+ * together with its PR, by hand — left alone. Returns false when something is left undone.
3248
+ */
3249
+ function renameBranch(io: Io, slug: string, target: string): boolean {
3250
+ const git = (...args: string[]) => (io.run ?? realRun)("git", args, io.cwd);
3251
+ const has = (ref: string) => git("rev-parse", "--verify", "--quiet", ref).status === 0;
3252
+ if (!checkoutOfRepo(io, originUrl(io), slug)) return (io.out(`○ branch left alone: the current directory is not a checkout of ${slug}`), true);
3253
+ const current = git("branch", "--show-current").stdout.trim();
3254
+ if (!current) {
3255
+ const base = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").stdout.trim() || "origin/main";
3256
+ return (io.out(`○ branch left alone: HEAD is detached — a task branch from a fresh ${base}: git fetch origin && git switch -c ${target} ${base} --no-track`), true);
3257
+ }
3258
+ if (current === target) return (io.out(`= branch ${target}`), true);
3259
+ if (has(`refs/remotes/origin/${current}`)) return (io.out(`○ branch ${current} is already on origin — left alone: a pushed branch is renamed together with its PR, by hand`), true);
3260
+ let renamed = false;
3261
+ let lock = "";
3262
+ for (let k = 0; k < CONFIG_LOCK_TRIES; k++) {
3263
+ if (k) (io.sleep ?? pause)(CONFIG_LOCK_PAUSE_MS);
3264
+ const args = renamed ? ["config", "--rename-section", `branch.${current}`, `branch.${target}`] : ["branch", "-m", current, target];
3265
+ const r = git(...args);
3266
+ // a branch without a section (no upstream) — nothing to rename in the config; est reads the `+ branch` line as a
3267
+ // switch of the checkout's branch (gitBranchesIn in skills/est/scripts/est.ts) — the format is shared
3268
+ if (r.status === 0 || (renamed && /no such section/.test(r.stderr))) {
3269
+ return (io.out(`+ branch ${current} → ${target}${k ? ` (.git/config was locked: ${k} retries)` : ""}`), true);
3270
+ }
3271
+ const locked = CONFIG_LOCKED.exec(r.stderr);
3272
+ if (!locked) return (io.out(`! git ${args.join(" ")}: ${r.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${r.status}`}`), false);
3273
+ lock = `${path.resolve(io.cwd ?? process.cwd(), locked[1]!.trim())}.lock`;
3274
+ renamed ||= !has(`refs/heads/${current}`) && has(`refs/heads/${target}`);
3275
+ }
3276
+ let age = "";
3277
+ try {
3278
+ age = ` (${Math.round((Date.now() - statSync(lock).mtimeMs) / 1000)} s)`;
3279
+ } catch {
3280
+ // released right after the last attempt — the command below finishes the rename anyway
3281
+ }
3282
+ const what = renamed ? `renamed to ${target}, its section in .git/config is not` : `${current} is not renamed`;
3283
+ const finish = renamed ? `git config --rename-section branch.${current} branch.${target}` : `git branch -m ${current} ${target}`;
3284
+ io.out(`! branch ${what}: ${lock} is held by another git process${age} — not removed; finish: ${finish}`);
3285
+ return false;
3286
+ }
3287
+
3288
+ /**
3289
+ * The task's PRs that GitHub didn't link to it: among the latest open and merged PRs into the default branch — the
3290
+ * task's branch or "Closes #N" in the body (as `pr labels` does before the link appears). `why` — what it was found by.
3291
+ */
3292
+ function unlinkedPrs(io: Io, slug: string, number: number): { base: string; prs: (Issue["prs"][number] & { why: string })[] } {
3293
+ const [owner, name] = slug.split("/");
3294
+ const r = graphql(io, Q.RecentPrs, { owner, name })?.repository;
3295
+ const base: string = r?.defaultBranchRef?.name ?? "main";
3296
+ const prs: (Issue["prs"][number] & { why: string })[] = [];
3297
+ for (const p of r?.pullRequests?.nodes ?? []) {
3298
+ if (p.baseRefName !== base) continue;
3299
+ const head: string = p.headRefName ?? "";
3300
+ const why = isTaskBranch(head, number) ? `branch ${head}` : closingInBody(p.body ?? "").includes(number) ? `«Closes #${number}» in the body` : null;
3301
+ if (why) prs.push({ number: p.number, state: p.state ?? "", merged: Boolean(p.merged), head, base, why });
3302
+ }
3303
+ return { base, prs };
3304
+ }
3305
+
3306
+ /**
3307
+ * The est script next to the skill (`../est/scripts/est.ts`: the project copy, `~/.agents/skills` and the clone are
3308
+ * laid out this way).
3309
+ */
3310
+ const estScript = (io: Io) => io.env.ELVES_EST ?? path.resolve(import.meta.dir, "../../est/scripts/est.ts");
3311
+
3312
+ /** The origin URL of the current directory's checkout; not a checkout or no origin — "". */
3313
+ function originUrl(io: Io): string {
3314
+ const remote = (io.run ?? realRun)("git", ["remote", "get-url", "origin"], io.cwd);
3315
+ return remote.status === 0 ? remote.stdout : "";
3316
+ }
3317
+
3318
+ /** The task-env script next to the skill — laid out like est (`../task-env/scripts/task-env.ts`). */
3319
+ const taskEnvScript = (io: Io) => io.env.ELVES_TASK_ENV ?? path.resolve(import.meta.dir, "../../task-env/scripts/task-env.ts");
3320
+
3321
+ /**
3322
+ * The task's stacks (task-env, epic #392) live until someone takes them down — `task close` does: `task-env down <N>`
3323
+ * in the checkout takes the dev and e2e stacks down with their volumes and prints a line per stack (`+`, `○`, `!`) —
3324
+ * printed as is; it failed without a line — its error as a `!` line. A failure doesn't stop the close: a stack left
3325
+ * behind is found by `task-env prune`. Docker doesn't work in the Claude Code sandbox — task close is an unsandboxed
3326
+ * call (SKILL.md).
3327
+ */
3328
+ function takeDownStacks(io: Io, number: number): void {
3329
+ const script = taskEnvScript(io);
3330
+ if (!existsSync(script)) return io.out(`○ task stacks: task-env is not installed (${script})`);
3331
+ const r = (io.run ?? realRun)("bun", ["--no-env-file", script, "down", String(number)], io.cwd);
3332
+ const lines = r.stdout.split("\n").filter((l) => l.trim());
3333
+ for (const line of lines) io.out(line);
3334
+ if (r.status !== 0 && !lines.length) io.out(`! task-env down: ${r.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${r.status}`}`);
3335
+ }
3336
+
3337
+ /** est with arguments: its output without the marker is printed as is, a failure — its last error line as a `!` line. */
3338
+ function runEst(io: Io, est: string, args: string[]): void {
3339
+ const r = (io.run ?? realRun)("bun", ["--no-env-file", est, ...args], io.cwd);
3340
+ for (const line of r.stdout.split("\n")) if (line.trim() && !line.startsWith("<!--")) io.out(line);
3341
+ if (r.status !== 0) io.out(`! est ${args[0]}: ${r.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${r.status}`}`);
3342
+ }
3343
+
3344
+ /**
3345
+ * The scripts' clone catches up with the merged main before the actual (#517): a dispatcher's executors call the
3346
+ * scripts of the machine's clone, and nothing updates the clone during a session — a later executor's actual was
3347
+ * computed by the est from before the fixes earlier executors had merged. Only for a task of the scripts' own
3348
+ * repository: another project's merge doesn't change the scripts. The clone is the checkout the est script is in (an
3349
+ * installed copy isn't a checkout — nothing to do, no line); it moves only fast-forward, on the default branch and
3350
+ * without uncommitted changes to tracked files — another branch or an edit is someone's work in progress. Untracked
3351
+ * files don't count: the clone holds the sessions' worktrees, and a fast-forward that would overwrite one refuses itself.
3352
+ */
3353
+ function freshClone(io: Io, slug: string): void {
3354
+ const est = estScript(io);
3355
+ if (!existsSync(est)) return;
3356
+ const run = io.run ?? realRun;
3357
+ const top = run("git", ["rev-parse", "--show-toplevel"], path.dirname(est));
3358
+ if (top.status !== 0) return;
3359
+ const dir = top.stdout.trim();
3360
+ const git = (...args: string[]) => run("git", args, dir);
3361
+ const origin = git("remote", "get-url", "origin");
3362
+ if (origin.status !== 0 || !checkoutOfRepo(io, origin.stdout, slug)) return;
3363
+ const base = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD").stdout.trim().replace(/^origin\//, "") || "main";
3364
+ const branch = git("branch", "--show-current").stdout.trim();
3365
+ const left = (why: string) => io.out(`○ clone ${dir} left alone: ${why} — the actual is by its code`);
3366
+ if (branch !== base) return left(branch ? `on branch ${branch}, not ${base}` : `HEAD is detached, not on ${base}`);
3367
+ const status = git("status", "--porcelain", "--untracked-files=no");
3368
+ if (status.status !== 0 || status.stdout.trim()) return left("uncommitted changes");
3369
+ const head = () => git("rev-parse", "--short", "HEAD").stdout.trim();
3370
+ const before = head();
3371
+ const pull = git("pull", "--ff-only", "--no-rebase", "--quiet", "origin", base);
3372
+ if (pull.status !== 0) {
3373
+ const why = pull.stderr.trim().split("\n").filter(Boolean).pop() ?? `code ${pull.status}`;
3374
+ return io.out(`! clone ${dir}: git pull --ff-only origin ${base}: ${why} — the actual is by its code`);
3375
+ }
3376
+ const after = head();
3377
+ io.out(after === before ? `= clone ${dir} ${after}` : `+ clone ${dir} ${before} → ${after}`);
3378
+ }
3379
+
3380
+ /** The actual — `task close`. */
3381
+ function runFact(io: Io, slug: string, number: number): void {
3382
+ const est = estScript(io);
3383
+ if (!existsSync(est)) return io.out(`○ actual unavailable: est is not installed (${est}) — est fact ${number} --write after installing it`);
3384
+ runEst(io, est, ["fact", String(number), "--repo", slug, "--write"]);
3385
+ }
3386
+
3387
+ /**
3388
+ * The estimate — `task new`, like the actual in `task close`: the script picks the analogs, the agent has no step of
3389
+ * its own. Fewer than two analogs in the repository — est prints "No estimate" and the task stays without one.
3390
+ */
3391
+ function runEstimate(io: Io, slug: string, number: number, type: string): void {
3392
+ const est = estScript(io);
3393
+ if (!existsSync(est)) return io.out(`○ no estimate: est is not installed (${est})`);
3394
+ runEst(io, est, ["estimate", String(number), "--repo", slug, "--type", type, "--write"]);
3395
+ }
3396
+
3397
+ /** GraphQL has no milestone mutation — REST. wasOpen — the task was open when read: the open count includes it. */
3398
+ function closeMilestone(io: Io, slug: string, issue: Issue, wasOpen: boolean): void {
3399
+ const m = issue.milestone;
3400
+ if (!m) return;
3401
+ const open = m.open - (wasOpen ? 1 : 0);
3402
+ if (open > 0) return io.out(`○ milestone ${q(m.title)}: open tasks ${open}`);
3403
+ io.gh(["api", "-X", "PATCH", `repos/${slug}/milestones/${m.number}`, "-f", "state=closed"]);
3404
+ io.out(`+ milestone ${q(m.title)} closed: no open tasks left`);
3405
+ }
3406
+
3407
+ /**
3408
+ * Merged branches go away (SKILL.md, "Git, PRs and merging — mechanics", the "Right after a PR merge" item): the branch
3409
+ * of each merged PR of the task — a task may have two, its PR and a follow-up fix to it (#473) — and the checkout's task
3410
+ * branch; the checkout moves off a removed branch to origin/<base> detached, the local and remote branches are deleted.
3411
+ * "Merged" — the PR is merged; a branch without a merged PR — `git cherry`: another merged PR of the task doesn't make
3412
+ * it merged. Not merged or someone else's — left alone. That the directory is the repository's checkout, the caller
3413
+ * checks.
3414
+ */
3415
+ function cleanupBranches(io: Io, number: number, prs: { head: string; base: string }[]): void {
3416
+ const git = (...args: string[]) => (io.run ?? realRun)("git", args, io.cwd);
3417
+ const current = git("branch", "--show-current").stdout.trim();
3418
+ // a merged branch → the base it went into: each merged PR's head once
3419
+ const merged = new Map<string, string>();
3420
+ for (const p of prs) if (p.head && !merged.has(p.head)) merged.set(p.head, p.base || "main");
3421
+ const loose = current && !merged.has(current) && isTaskBranch(current, number) ? current : "";
3422
+ if (!merged.size && !loose) return io.out("○ the task branch not found — branches left alone");
3423
+ git("fetch", "--prune", "origin");
3424
+ if (loose) {
3425
+ const base = prs[0]?.base || "main";
3426
+ const cherry = git("cherry", `origin/${base}`, loose);
3427
+ if (cherry.status !== 0 || cherry.stdout.split("\n").some((l) => l.startsWith("+"))) io.out(`○ branch ${loose} is not merged into origin/${base} — left alone`);
3428
+ else merged.set(loose, base);
3429
+ }
3430
+ if (!merged.size) return;
3431
+ for (const [branch, base] of merged) {
3432
+ if (current === branch) {
3433
+ const sw = git("switch", "--detach", `origin/${base}`);
3434
+ if (sw.status !== 0) {
3435
+ io.out(`! git switch --detach origin/${base}: ${sw.stderr.trim()}`);
3436
+ continue;
3437
+ }
3438
+ io.out(`+ checkout — on origin/${base} (detached)`);
3439
+ }
3440
+ if (git("branch", "--list", branch).stdout.trim()) {
3441
+ const d = git("branch", "-D", branch);
3442
+ io.out(d.status === 0 ? `+ local branch ${branch} deleted` : `! git branch -D ${branch}: ${d.stderr.trim()}`);
3443
+ }
3444
+ if (git("ls-remote", "--heads", "origin", branch).stdout.trim()) {
3445
+ const d = git("push", "origin", "--delete", branch);
3446
+ io.out(d.status === 0 ? `+ remote branch ${branch} deleted` : `! git push origin --delete ${branch}: ${d.stderr.trim()}`);
3447
+ } else io.out(`○ remote branch ${branch} is already gone`);
3448
+ }
3449
+ const top = git("rev-parse", "--show-toplevel").stdout.trim();
3450
+ const common = git("rev-parse", "--git-common-dir").stdout.trim();
3451
+ if (!top || !common || path.resolve(io.cwd ?? process.cwd(), common) === path.join(top, ".git")) return;
3452
+ // an agent's worktree — an executor's: it can't remove the worktree it works in, the dispatcher's queue does (#515)
3453
+ if (AGENT_WORKTREE.test(top)) io.out(`○ agent worktree ${top}: the dispatcher's next task queue removes it`);
3454
+ else io.out(`Next: remove worktree ${top} from the main checkout: git worktree remove ${top}`);
3455
+ }
3456
+
3457
+ // ----------------------------------------------------------------------------
3458
+ // Decision labels by PR
3459
+ // ----------------------------------------------------------------------------
3460
+
3461
+ /** The labels the task doesn't have — in one mutation; returns them comma-separated (nothing to add — empty). */
3462
+ function addLabels(io: Io, issue: Pick<Issue, "id" | "labels" | "rest">, names: string[], all: Label[]): string {
3463
+ const missing = names.filter((n) => !issue.labels.includes(n));
3464
+ if (!missing.length) return "";
3465
+ if (issue.rest) rest(io, `${issue.rest}/labels`, { method: "POST", payload: { labels: missing } });
3466
+ else mutate(io, { op: "AddLabels", input: { labelableId: issue.id, labelIds: missing.map((n) => all.find((l) => l.name === n)!.id) } });
3467
+ issue.labels.push(...missing);
3468
+ return missing.join(", ");
3469
+ }
3470
+
3471
+ /** A changed PR file (`PullRequestChangedFile`): the kind of change and the line counts. */
3472
+ export interface PrFile {
3473
+ path: string;
3474
+ changeType: string;
3475
+ additions: number;
3476
+ deletions: number;
3477
+ }
3478
+
3479
+ // the former exceptions format — an array in one file: the harness's exceptions.ts
3480
+ const LEGACY_EXCEPTIONS = /^exceptions\.(ts|mts|js|mjs|json)$/;
3481
+
3482
+ /**
3483
+ * Which decision folder's exceptions file is it? A copy of the spec skill's `exceptionFile` rule (`speclib.ts`):
3484
+ * github imports no other skill (tests/standards/skill-imports) and works without spec; the
3485
+ * `tests/standards/exception-files` standard checks both copies against one table. The `exceptions/` directory of a
3486
+ * `tests/<kind>/<name>` folder, a `.json` file — the folder's exception; another file in the directory and a directory
3487
+ * in a subfolder — an error; `exceptions.*` in a folder or a subfolder — the former format; outside decision folders
3488
+ * and hidden ones — null.
3489
+ */
3490
+ export function exceptionFile(p: string): { folder: string; dir: string; legacy: boolean; error?: string } | null {
3491
+ const parts = p.split("/");
3492
+ if (parts[0] !== "tests" || parts.length < 4 || !DECISION_KINDS.some((k) => DECISIONS[k].dir === parts[1])) return null;
3493
+ const folder = parts.slice(0, 3).join("/");
3494
+ const inner = parts.slice(3);
3495
+ if (inner.some((s) => s.startsWith("."))) return null;
3496
+ const at = inner.findIndex((s, i) => (i < inner.length - 1 ? s === "exceptions" : LEGACY_EXCEPTIONS.test(s)));
3497
+ if (at < 0) return null;
3498
+ const dir = "exceptions";
3499
+ if (at === inner.length - 1) return { folder, dir, legacy: true };
3500
+ if (at > 0) return { folder, dir, legacy: false, error: "the exceptions directory is in a subfolder" };
3501
+ if (inner.length > 2 || !p.endsWith(".json")) return { folder, dir, legacy: false, error: "an exception is not a .json file" };
3502
+ return { folder, dir, legacy: false };
3503
+ }
3504
+
3505
+ /**
3506
+ * A mechanical edit doesn't change a decision: the file is deleted, renamed without content changes, or it's an
3507
+ * exceptions file of a decision folder that spec reads as an exception (`exceptionFile` without an error): a file in
3508
+ * `exceptions/`, the former `exceptions.ts` (the exceptions ratchet). It gives no decision label — otherwise with mass
3509
+ * edits a task gets labels of decisions it didn't change.
3510
+ */
3511
+ function mechanical(f: PrFile): boolean {
3512
+ if (f.changeType === "DELETED") return true;
3513
+ if (f.changeType === "RENAMED" && f.additions === 0 && f.deletions === 0) return true;
3514
+ const x = exceptionFile(f.path);
3515
+ return !!x && !x.error;
3516
+ }
3517
+
3518
+ /**
3519
+ * Decisions the diff touches: a spec tree folder is the decision itself, a code file — the model module with the
3520
+ * longest matching directory (a nested module is more precise than the enclosing one). `tests/lib` and files outside
3521
+ * modules aren't decisions. `changed` — decision name → its kinds in the diff, at least one of the decision's files
3522
+ * edited in content; `mechanical` — decisions touched only by a mechanical edit. Names in alphabetical order.
3523
+ */
3524
+ export function decisionsOfFiles(files: PrFile[], modules: Record<string, string[]>): { changed: Map<string, DecisionKind[]>; mechanical: string[] } {
3525
+ const out = new Map<string, { kinds: Set<DecisionKind>; changed: boolean }>();
3526
+ const add = (name: string, kind: DecisionKind, f: PrFile) => {
3527
+ const d = out.get(name) ?? { kinds: new Set(), changed: false };
3528
+ d.kinds.add(kind);
3529
+ d.changed ||= !mechanical(f);
3530
+ out.set(name, d);
3531
+ };
3532
+ const byDir = Object.fromEntries(DECISION_KINDS.map((k) => [DECISIONS[k].dir, k])) as Record<string, DecisionKind>;
3533
+ for (const f of files) {
3534
+ const parts = f.path.split("/");
3535
+ if (parts[0] === "tests") {
3536
+ const kind = byDir[parts[1] ?? ""];
3537
+ if (kind && parts.length > 3) add(parts[2]!, kind, f);
3538
+ continue;
3539
+ }
3540
+ let best: [string, number] | null = null;
3541
+ for (const [m, dirs] of Object.entries(modules)) {
3542
+ for (const d of dirs) if ((f.path === d || f.path.startsWith(d + "/")) && (!best || d.length > best[1])) best = [m, d.length];
3543
+ }
3544
+ if (best) add(best[0], "architecture", f);
3545
+ }
3546
+ const names = [...out.keys()].sort();
3547
+ return {
3548
+ changed: new Map(names.filter((n) => out.get(n)!.changed).map((n) => [n, DECISION_KINDS.filter((k) => out.get(n)!.kinds.has(k))])),
3549
+ mechanical: names.filter((n) => !out.get(n)!.changed),
3550
+ };
3551
+ }
3552
+
3553
+ /**
3554
+ * Decisions whose statements the PR changes — who gets a label when not all of them fit on an issue (#569): a codemod
3555
+ * over every test (a test runner change) rewrites the bodies and changes no statement, and a decision label means "the
3556
+ * task changes the decision". A statement is the decision folder's `<name>.md` edited in content, or the folder named in
3557
+ * the spec section of the PR body (spec-diff output): a list line or a summary row starting with the folder — removed,
3558
+ * changed and added tests, the move into the tree, harness checks, scenarios that became tests. Exceptions aren't
3559
+ * statements (`mechanical`): their blocks are skipped. The section heading and block titles — in any dictionary
3560
+ * language: the body is in the project's language, and the script may run from another project with --repo.
3561
+ */
3562
+ function statementDecisions(files: PrFile[], body: string): Set<string> {
3563
+ const dirs = new Set<string>(DECISION_KINDS.map((k) => DECISIONS[k].dir));
3564
+ const out = new Set<string>();
3565
+ for (const f of files) {
3566
+ const [root, dir, name, file, ...rest] = f.path.split("/");
3567
+ if (root === "tests" && dirs.has(dir!) && file === `${name}.md` && !rest.length && !mechanical(f)) out.add(name!);
3568
+ }
3569
+ const heads = locale.every((n) => n.prSpecSection);
3570
+ const exceptions = locale.every((n) => n.spec.diff.exceptionsTitle).map((t) => `**${t}`);
3571
+ let inSection = false;
3572
+ let skip = false;
3573
+ for (const raw of body.split(/\r?\n/)) {
3574
+ const line = raw.trim();
3575
+ if (heads.includes(line)) [inSection, skip] = [true, false];
3576
+ else if (/^##\s/.test(line)) inSection = false;
3577
+ else if (!inSection) continue;
3578
+ else if (/^#{3,}\s/.test(line)) skip = false;
3579
+ else if (line.startsWith("**")) skip = exceptions.some((t) => line.startsWith(t));
3580
+ else if (!skip) {
3581
+ const m = /^(?:[-*]\s+(?:✅\s+)?|\|\s*)`tests\/([^`/]+)\/([^`/]+)[^`]*`/.exec(line);
3582
+ if (m && dirs.has(m[1]!)) out.add(m[2]!);
3583
+ }
3584
+ }
3585
+ return out;
3586
+ }
3587
+
3588
+ /**
3589
+ * The decision labels an issue doesn't have, as many as fit (`ISSUE_LABEL_LIMIT`): all of them fit — all; otherwise only
3590
+ * the decisions whose statements the PR changes (`statementDecisions`), in alphabetical order, as many as there's room
3591
+ * for; no PR yet (`task new --epic`, `statements` null) — any of them, alphabetically (#574). What isn't set — `○` lines:
3592
+ * a mutation past the limit would fail and leave the issue without a single label. Returns the lines to print.
3593
+ */
3594
+ function putLabels(io: Io, issue: Issue, who: string, want: string[], statements: Set<string> | null, all: Label[]): string[] {
3595
+ const lines: string[] = [];
3596
+ const missing = want.filter((n) => !issue.labels.includes(n));
3597
+ const room = Math.max(0, ISSUE_LABEL_LIMIT - issue.labels.length);
3598
+ let add = missing;
3599
+ if (missing.length > room) {
3600
+ const named = statements ? missing.filter((n) => statements.has(n)) : [...missing].sort();
3601
+ const bodies = statements ? missing.filter((n) => !statements.has(n)) : [];
3602
+ add = named.slice(0, room);
3603
+ const which = statements ? "only decisions whose <folder>.md or test names the PR changes" : "alphabetically, as many as fit";
3604
+ const without = bodies.length ? `; test bodies and code only, no label: ${bodies.join(", ")}` : "";
3605
+ lines.push(`○ ${who}: ${missing.length} new decision labels, room for ${room} (GitHub puts at most ${ISSUE_LABEL_LIMIT} on an issue, it has ${issue.labels.length}) — ${which}${without}`);
3606
+ if (named.length > room) lines.push(`○ ${who}: no room — not set: ${named.slice(room).join(", ")}`);
3607
+ }
3608
+ const added = addLabels(io, issue, add, all);
3609
+ if (added) lines.push(`+ ${who}: ${added}`);
3610
+ return lines;
3611
+ }
3612
+
3613
+ /**
3614
+ * Tasks the PR closes, by its body — as GitHub understands them: a keyword (close, fix, resolve in any form) before
3615
+ * each `#N`; "Closes #1, #2" closes only #1.
3616
+ */
3617
+ function closingInBody(body: string): number[] {
3618
+ const out: number[] = [];
3619
+ for (const m of body.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s+#(\d+)\b/gi)) if (!out.includes(Number(m[1]))) out.push(Number(m[1]));
3620
+ return out;
3621
+ }
3622
+
3623
+ /** The PR and all its changed files (`PrChange`, page by page): `pr labels` and the epic refresh's assignment. */
3624
+ function loadPrChange(io: Io, slug: string, number: number): { head: Any; files: PrFile[] } {
3625
+ const [owner, name] = slug.split("/") as [string, string];
3626
+ let head: Any = null;
3627
+ const files = pages<PrFile>((after) => {
3628
+ const pr = graphql(io, Q.PrChange, { owner, name, number, after })?.repository?.pullRequest;
3629
+ if (!pr) throw new GhError(`no PR #${number} in ${slug}`);
3630
+ head ??= pr;
3631
+ return pr.files;
3632
+ });
3633
+ return { head, files };
3634
+ }
3635
+
3636
+ export function cmdPrLabels(io: Io, slug: string, number: number): number {
3637
+ const [owner, name] = slug.split("/") as [string, string];
3638
+ const { head, files } = loadPrChange(io, slug, number);
3639
+ let closes: number[] = (head.closingIssuesReferences?.nodes ?? []).map((x: Any) => x.number);
3640
+ // right after the PR is created GitHub hasn't linked it to the task yet — the same keywords in the body
3641
+ if (!closes.length) closes = closingInBody(head.body ?? "");
3642
+ if (!closes.length) throw new GhError(`PR #${number} has no «Closes #N» — nowhere to put the labels`);
3643
+ // modules come from the PR head: the PR may add a module or move its directory
3644
+ const model = graphql(io, Q.ModelAt, { owner, name, expression: `${head.headRefOid}:${MODEL_PATH}` })?.repository?.object;
3645
+ let modules: Record<string, string[]> = {};
3646
+ if (model) {
3647
+ const m = modelModules(model.text ?? "");
3648
+ if (typeof m === "string") io.err(`warning: model ${MODEL_PATH} of the PR head doesn't load — modules not counted: ${m}`);
3649
+ else modules = m;
3650
+ }
3651
+ const { changed: decided, mechanical: skipped } = decisionsOfFiles(files, modules);
3652
+ const all = decided.size ? loadLabels(io, slug) : [];
3653
+ // a slice label with a decision's name is a different slice of tasks: it isn't set as a decision label
3654
+ const dimensions = all.flatMap((l) => {
3655
+ const dim = decided.has(l.name) ? labelDimension(l.description) : null;
3656
+ return dim ? [{ name: l.name, dim }] : [];
3657
+ });
3658
+ const want = [...decided.keys()].filter((n) => !dimensions.some((x) => x.name === n));
3659
+ io.out(`PR #${number} → ${closes.map((n) => `#${n}`).join(", ")}: ${want.join(", ") || "no decisions in the diff"}`);
3660
+ for (const l of skipped) io.out(`○ ${l} — only a mechanical edit (a deletion, a rename without changes, exceptions): no label`);
3661
+ for (const x of dimensions) io.out(`○ ${x.name} — label ${q(x.name)} is slice ${q(x.dim)}, not a decision label: not set`);
3662
+ if (!want.length) return 0;
3663
+
3664
+ const fresh = want.filter((l) => !all.some((x) => x.name === l));
3665
+ const repoId = fresh.length ? loadRepo(io, slug).id : "";
3666
+ for (const l of fresh) {
3667
+ const label = decisionLabel(l, decided.get(l)!);
3668
+ const created = mutate(io, { op: "CreateLabel", input: { repositoryId: repoId, ...label } }).createLabel.label;
3669
+ all.push({ id: created.id, ...label, open: { total: 0, numbers: [] } });
3670
+ io.out(`+ created label ${q(l)}`);
3671
+ }
3672
+ const isDecision = (n: string) => legacyDecision(n) !== null || labelKinds(all.find((x) => x.name === n)?.description) !== null;
3673
+ const statements = statementDecisions(files, head.body ?? "");
3674
+ const epics = new Map<number, Issue>();
3675
+ for (const n of closes) {
3676
+ const issue = loadIssue(io, slug, n);
3677
+ for (const l of putLabels(io, issue, `#${n}`, want, statements, all)) io.out(l);
3678
+ // earlier labels aren't removed: the task may have touched the decision in another PR too
3679
+ for (const l of issue.labels.filter((x) => isDecision(x) && !want.includes(x))) io.out(`= #${n}: ${l} — the decision is not in the PR diff, the label stays`);
3680
+ if (issue.parent && !epics.has(issue.parent.number)) epics.set(issue.parent.number, loadIssue(io, slug, issue.parent.number));
3681
+ }
3682
+ for (const epic of epics.values()) for (const l of putLabels(io, epic, `epic #${epic.number}`, want, statements, all)) io.out(l);
3683
+ return 0;
3684
+ }
3685
+
3686
+ // ----------------------------------------------------------------------------
3687
+ // Checking the merge before merging
3688
+ // ----------------------------------------------------------------------------
3689
+
3690
+ /**
3691
+ * The project's fast checks — these `package.json` scripts, those that exist, in CI order: types (a semantic conflict
3692
+ * of two PRs, seconds), then standards and architecture — minutes, not the whole `test`.
3693
+ */
3694
+ export const PREMERGE_SCRIPTS = ["typecheck", "test:spec"];
3695
+ /**
3696
+ * Spec roots whose checks `test:spec` runs — standards and architecture (the spec skill): there two green PRs break
3697
+ * main. A tree with them and without the script — premerge names what isn't checked on the merge, the project's
3698
+ * `check` (bin/elves.ts) requires the script (#442).
3699
+ */
3700
+ export const SPEC_ROOTS = ["tests/standards", "tests/architecture"];
3701
+ /** The spec roots the tree in dir has. */
3702
+ export const specRoots = (dir: string) => SPEC_ROOTS.filter((d) => statSync(path.join(dir, d), { throwIfNoEntry: false })?.isDirectory());
3703
+ /**
3704
+ * GitHub builds the merge-ref before the event that creates the check suite: main moving within this minute counts as
3705
+ * having moved.
3706
+ */
3707
+ const MERGE_REF_SLACK_MS = 60_000;
3708
+ const INSTALL: Record<PackageManager["name"], string[]> = { bun: ["bun", "install", "--frozen-lockfile"], pnpm: ["pnpm", "install", "--frozen-lockfile"], yarn: ["yarn", "install", "--frozen-lockfile"], npm: ["npm", "ci"] };
3709
+ /**
3710
+ * Failure lines: a test — bun `(fail)`, Vitest `FAIL` and `×`, Jest `●`, Playwright `✘`; a compiler error —
3711
+ * `error TS2304:`.
3712
+ */
3713
+ const FAILED_LINE = /^\s*(\(fail\)|FAIL\s|×|✗|✘|●\s)|\berror TS\d+:/;
3714
+ /**
3715
+ * A `bun check` error under an agent (`CLAUDECODE=1`) — a block `<error file="a.ts" line="3" column="5" code="TS2304">`
3716
+ * with the text on the next line; it is brought to a tsc line without a TTY, `a.ts(3,5): error TS2304: …`.
3717
+ */
3718
+ const BUN_CHECK_ERROR = /<error file="([^"]+)" line="(\d+)" column="(\d+)" code="(TS\d+)">\n([^\n]*)/g;
3719
+ const asTsc = (out: string) => out.replace(BUN_CHECK_ERROR, "$1($2,$3): error $4: $5");
3720
+
3721
+ /** The project's package manager: its command, Yarn 2+ and the lockfile dependencies are installed from. */
3722
+ export interface PackageManager {
3723
+ name: "bun" | "pnpm" | "yarn" | "npm";
3724
+ /** Yarn 2+: package.json scripts run in its own shell (`@yarnpkg/shell`) — without `.`, `eval` and `export`. */
3725
+ berry: boolean;
3726
+ /** The manager's own lockfile, otherwise the first one in order; none — null. */
3727
+ lockfile: string | null;
3728
+ /** The version `packageManager` declares (`bun@1.4.3` — `1.4.3`); no field or no version — null. */
3729
+ version: string | null;
3730
+ }
3731
+
3732
+ /**
3733
+ * The project's package manager in dir — the one answer of the flow: `pr premerge` (`projectCommands`), the slot
3734
+ * wrapper and the `test:spec` runner of the project's `check` (bin/elves.ts) take it here,
3735
+ * `tests/standards/package-manager`. The manager — `packageManager` in package.json, otherwise the lockfile in the
3736
+ * order below, otherwise `.yarnrc.yml` — Yarn, otherwise npm. Yarn 2+ — `yarn@2+` in `packageManager`, Yarn by the
3737
+ * files — with `.yarnrc.yml`: `packageManager` decides the version too.
3738
+ */
3739
+ export function packageManager(dir: string): PackageManager {
3740
+ const lockfiles = [["bun.lock", "bun"], ["bun.lockb", "bun"], ["pnpm-lock.yaml", "pnpm"], ["yarn.lock", "yarn"], ["package-lock.json", "npm"]] as const;
3741
+ const has = (file: string) => existsSync(path.join(dir, file));
3742
+ let field = "";
3743
+ try {
3744
+ field = String(JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"))?.packageManager ?? "");
3745
+ } catch {
3746
+ // no package.json or not JSON — no field
3747
+ }
3748
+ const declared = /^(bun|pnpm|yarn|npm)@((\d*)(?:\.\d+)*)/.exec(field);
3749
+ const found = lockfiles.filter(([file]) => has(file));
3750
+ const yarnrc = has(".yarnrc.yml");
3751
+ const name = (declared?.[1] as PackageManager["name"] | undefined) ?? found[0]?.[1] ?? (yarnrc ? "yarn" : "npm");
3752
+ const berry = name === "yarn" && (declared ? Number(declared[3]) >= 2 : yarnrc);
3753
+ return { name, berry, lockfile: (found.find(([, pm]) => pm === name) ?? found[0])?.[0] ?? null, version: declared?.[2] || null };
3754
+ }
3755
+
3756
+ /**
3757
+ * The project's commands in a directory: the package manager — `packageManager`; dependencies are installed from the
3758
+ * lockfile without changing it, no lockfile — no install.
3759
+ */
3760
+ export function projectCommands(dir: string): { install: string[] | null; run: (script: string) => string[] } {
3761
+ const pm = packageManager(dir);
3762
+ return { install: pm.lockfile ? INSTALL[pm.name] : null, run: (script) => [pm.name, "run", script] };
3763
+ }
3764
+
3765
+ const tail = (r: RunResult, n = 20) => `${r.stdout}\n${r.stderr}`.split("\n").filter((l) => l.trim()).slice(-n);
3766
+ /**
3767
+ * A failure line without the run time (bun `[1.2ms]`, Vitest `12ms`) and the compiler error position (`a.ts(3,5):` —
3768
+ * tsc without a TTY; a PR edit higher up in the file shifts it) — one failure on the merge and on main.
3769
+ */
3770
+ const failure = (l: string) => l.trim().replace(/\s*\[[\d.]+\s*m?s\]$/, "").replace(/\s+[\d.]+\s*m?s$/, "").replace(/^(\S+?)\(\d+,\d+\)(?=:)/, "$1");
3771
+
3772
+ /**
3773
+ * Waiting for the bug to close — the ci-wait script next to it (`../ci-wait`: the project copy, `~/.agents/skills` and
3774
+ * the clone are laid out this way). A function, not a value at load: `import.meta.dir` is Bun's, and a project's test
3775
+ * loads this module from the package under Node and Vitest too (`exports` of the Elves package.json).
3776
+ */
3777
+ const waitCi = () => path.resolve(import.meta.dir, "../../ci-wait/scripts/wait-ci.sh");
3778
+ // a red check outcome: cancelled (a rerun, a new commit) and waiting for a human's decision aren't red
3779
+ const RED_RUN = new Set(["failure", "timed_out", "startup_failure"]);
3780
+ const RED_STATUS = new Set(["failure", "error"]);
3781
+
3782
+ /**
3783
+ * The spec publish job (the `spec` skill, `ci.md`; `<job> / spec-publish` — the same job in a reusable workflow) —
3784
+ * publishing the documentation, not a check of the branch: its red isn't a red main (#443). A copy — in ci-wait's
3785
+ * `wait-ci.sh` (`SPEC_PUBLISH`): both answer by one table, `tests/standards/spec-publish-job`.
3786
+ */
3787
+ export const SPEC_PUBLISH = /^(?:.+ \/ )?spec-publish$/;
3788
+
3789
+ /**
3790
+ * Red checks of the default branch's last commit — check-runs (the last run of each name) and hosting statuses;
3791
+ * `publish` — the red spec publish jobs, apart.
3792
+ */
3793
+ function redChecksOf(io: Io, slug: string, base: string): { sha: string; names: string[]; publish: string[] } {
3794
+ const ref = encodeURIComponent(base);
3795
+ const latest = new Map<string, Any>();
3796
+ for (const r of restCall(io, ["api", `repos/${slug}/commits/${ref}/check-runs?per_page=100`]).check_runs ?? []) {
3797
+ if (!latest.has(r.name) || latest.get(r.name).id < r.id) latest.set(r.name, r);
3798
+ }
3799
+ const status = restCall(io, ["api", `repos/${slug}/commits/${ref}/status`]);
3800
+ const names = [
3801
+ ...[...latest.values()].filter((r) => r.status === "completed" && RED_RUN.has(r.conclusion)).map((r) => r.name as string),
3802
+ ...(status.statuses ?? []).filter((x: Any) => RED_STATUS.has(x.state)).map((x: Any) => x.context as string),
3803
+ ];
3804
+ const red = [...new Set(names)].sort();
3805
+ return { sha: String(status.sha ?? ""), names: red.filter((n) => !SPEC_PUBLISH.test(n)), publish: red.filter((n) => SPEC_PUBLISH.test(n)) };
3806
+ }
3807
+
3808
+ /**
3809
+ * Title prefixes of a bug about a red default branch — `mainRed.title` of each dictionary language, the project
3810
+ * language first.
3811
+ */
3812
+ const mainRedPrefixes = (base: string) => [...new Set([N, ...locale.everyNames()].map((n) => locale.fill(n.mainRed.title, { base })))];
3813
+
3814
+ /**
3815
+ * Open bugs about a red default branch — the title "<base> is red…" in any dictionary language (a search for each),
3816
+ * by number: the search lags, and a duplicate happens.
3817
+ */
3818
+ function mainBugsOf(io: Io, slug: string, base: string): { number: number; title: string }[] {
3819
+ const prefixes = mainRedPrefixes(base);
3820
+ const found = new Map<number, Any>();
3821
+ for (const prefix of prefixes) {
3822
+ const q = `repo:${slug} is:issue is:open in:title "${prefix}"`;
3823
+ for (const i of restCall(io, ["api", `search/issues?q=${encodeURIComponent(q)}&per_page=20`]).items ?? []) found.set(i.number, i);
3824
+ }
3825
+ return [...found.values()].filter((i) => !i.pull_request && prefixes.some((p) => String(i.title).startsWith(p))).sort((a, b) => a.number - b.number);
3826
+ }
3827
+
3828
+ /**
3829
+ * The bug title "<base> is red…" names the check — the whole name after the prefix, not part of another one: `test`
3830
+ * isn't found in "test:spec", nor `main` in the prefix.
3831
+ */
3832
+ function bugNames(base: string, title: string, check: string): boolean {
3833
+ const prefix = mainRedPrefixes(base).find((p) => title.startsWith(p)) ?? "";
3834
+ return new RegExp(`(?:^|[\\s,«"(])${locale.escapeRe(check)}(?=$|[\\s,;»")])`, "u").test(title.slice(prefix.length));
3835
+ }
3836
+
3837
+ /**
3838
+ * Before merging a green PR: main moved after its CI — the PR head merged with a fresh main in a temporary worktree and
3839
+ * the fast checks (`typecheck`, then `test:spec`, as in CI); didn't move — the CI checked exactly this merge. GitHub
3840
+ * doesn't keep what the CI checked (the merge-ref is rebuilt), so by time: the head's earliest check suite against the
3841
+ * last event of the default branch (REST: the GraphQL quota is shared by all sessions). The unknown is checked. A red
3842
+ * main isn't this PR: a red check on main's last commit or a merge red by the same thing as bare main. Code 0 —
3843
+ * merge, 1 — don't merge (the PR breaks it), 3 — main is red.
3844
+ */
3845
+ export function cmdPrPremerge(io: Io, slug: string, number: number): number {
3846
+ const pr = restCall(io, ["api", `repos/${slug}/pulls/${number}`]);
3847
+ if (pr.state !== "open") throw new GhError(`PR #${number} is not open — nothing to merge`);
3848
+ const head: string = pr.head.sha;
3849
+ const base: string = pr.base.ref;
3850
+ const closes = closingInBody(pr.body ?? "");
3851
+
3852
+ /**
3853
+ * A red main: don't merge and don't fix it in your PR — one bug for everyone, the others wait for it to close.
3854
+ * `red` — the failed checks (or the script), `what` — how to name them in the bug title. A PR that closes this bug is
3855
+ * the fix: null, the check goes as usual.
3856
+ */
3857
+ const mainRed = (lead: string, why: string, red: string[], what: (names: string[]) => string, say = what): number | null => {
3858
+ let bugs: { number: number; title: string }[] = [];
3859
+ try {
3860
+ bugs = mainBugsOf(io, slug, base);
3861
+ } catch (e) {
3862
+ if (!(e instanceof GhError)) throw e;
3863
+ io.out(`○ open bug on red ${base} not found by search: ${e.message}`);
3864
+ }
3865
+ // the fix is a PR that closes any of the open bugs, without matching the failed check (unlike the wait below): one
3866
+ // red main is named sometimes by the script (test:spec on bare main), sometimes by a CI check — matching would
3867
+ // keep the fix from merging
3868
+ const fix = bugs.find((b) => closes.includes(b.number));
3869
+ if (fix) {
3870
+ io.out(`○ ${base} is red — ${why}; the PR closes bug #${fix.number} about it — checking the PR as usual`);
3871
+ return null;
3872
+ }
3873
+ io.out(lead);
3874
+ // wait for a bug whose title names the failed check (unlike the fix above): a bug about another check won't make
3875
+ // main green
3876
+ const named = (b: { title: string }) => red.some((n) => bugNames(base, b.title, n));
3877
+ for (const b of bugs.filter((b) => !named(b))) io.out(`○ #${b.number} «${b.title}» — about another check: don't wait`);
3878
+ const wait = (n: string) => `wait for it to close — bash ${waitCi()} issue ${n} as a background command, then pr premerge ${number} again`;
3879
+ const bug = bugs.find(named);
3880
+ if (bug) io.out(`bug on red ${base} — #${bug.number} «${bug.title}»: ${wait(String(bug.number))}`);
3881
+ const unnamed = red.filter((n) => !bugs.some((b) => bugNames(base, b.title, n)));
3882
+ if (unnamed.length) {
3883
+ const priority = pr.base?.repo?.owner?.type === "Organization" ? " --priority Urgent" : "";
3884
+ const title = `${locale.fill(N.mainRed.title, { base })}: ${what(unnamed)}`;
3885
+ io.out(`no bug on red ${base} about ${say(unnamed)} — find an open one on the failed test or file one (label — the failed test's decision) and ask the owner whether to queue it: bun --no-env-file ${import.meta.path} task new --type ${N.issueTypes.bug}${priority} --title ${JSON.stringify(title)}; ${wait("<N>")}`);
3886
+ }
3887
+ io.out(`⛔ don't merge: ${base} is red, not this PR — don't fix it in your PR`);
3888
+ return 3;
3889
+ };
3890
+
3891
+ try {
3892
+ const red = redChecksOf(io, slug, base);
3893
+ if (red.publish.length) {
3894
+ io.out(`○ ${red.publish.join(", ")} red on the last commit of ${base} (${red.sha.slice(0, 7)}) — publishing the documentation, not a check of ${base}: not counted`);
3895
+ }
3896
+ if (red.names.length) {
3897
+ const checks = (names: string[]) => `${names.length > 1 ? "checks" : "check"} ${names.join(", ")}`;
3898
+ // in the bug title — in the project language
3899
+ const titled = (names: string[]) => locale.fill(names.length > 1 ? N.mainRed.checks : N.mainRed.check, { names: names.join(", ") });
3900
+ const why = `${checks(red.names)} of the last commit of ${base} (${red.sha.slice(0, 7)})`;
3901
+ const code = mainRed(`${base} is red: ${why}`, why, red.names, titled, checks);
3902
+ if (code !== null) return code;
3903
+ }
3904
+ } catch (e) {
3905
+ if (!(e instanceof GhError)) throw e;
3906
+ io.out(`○ checks of the last commit of ${base} not read (${e.message}) — whether ${base} is red is unknown from the checks`);
3907
+ }
3908
+
3909
+ const suites: string[] = (restCall(io, ["api", `repos/${slug}/commits/${head}/check-suites?per_page=100`]).check_suites ?? []).map((s: Any) => s.created_at).filter(Boolean);
3910
+ const ci = suites.sort()[0];
3911
+ let moved: string | null = null;
3912
+ let unread: string | null = null;
3913
+ try {
3914
+ moved = restCall(io, ["api", `repos/${slug}/activity?ref=${encodeURIComponent(`refs/heads/${base}`)}&per_page=1`])[0]?.timestamp ?? null;
3915
+ } catch (e) {
3916
+ if (!(e instanceof GhError)) throw e;
3917
+ unread = e.message;
3918
+ }
3919
+ const check = `checking the merge with origin/${base}`;
3920
+ if (!ci) io.out(`the head of PR #${number} has no checks — what CI checked is unknown: ${check}`);
3921
+ else if (unread) io.out(`○ movement of ${base} not read (${unread}) — ${check}`);
3922
+ else if (!moved || Date.parse(moved) <= Date.parse(ci) - MERGE_REF_SLACK_MS) {
3923
+ io.out(`✅ merge: ${base} hasn't moved since the CI of PR #${number} (CI since ${ci}, last movement of ${base} — ${moved ?? "none"}) — CI checked this merge`);
3924
+ return 0;
3925
+ } else io.out(`${base} moved after the CI of PR #${number} (CI since ${ci}, ${base} — ${moved}) — ${check}`);
3926
+
3927
+ const run = io.run ?? realRun;
3928
+ const git = (cwd: string | undefined, ...args: string[]) => run("git", args, cwd);
3929
+ if (!checkoutOfRepo(io, originUrl(io), slug)) throw new GhError(`the merge check runs from a checkout of ${slug}: the current directory is not one`);
3930
+ const fetched = git(io.cwd, "fetch", "-q", "origin", base);
3931
+ if (fetched.status !== 0) throw new GhError(`git fetch origin ${base}: ${fetched.stderr.trim()}`);
3932
+ // the head — by its SHA, not refs/pull/<N>/head: GitHub creates that ref up to minutes after the PR, when CI has
3933
+ // already passed (#505), while a reachable commit (the head branch, refs/pull/<N>/merge) it gives by SHA
3934
+ const got = git(io.cwd, "fetch", "-q", "origin", head);
3935
+ if (got.status !== 0) throw new GhError(`PR head ${head} not fetched from origin (${got.stderr.trim().split("\n").at(-1)}) — was the PR updated? ci-wait again`);
3936
+
3937
+ const dirs: string[] = [];
3938
+ /** A temporary worktree at origin/<base>: always removed. */
3939
+ const worktree = (): string => {
3940
+ const dir = mkdtempSync(path.join(os.tmpdir(), "elves-premerge-"));
3941
+ dirs.push(dir);
3942
+ const add = git(io.cwd, "worktree", "add", "-q", "--detach", dir, `origin/${base}`);
3943
+ if (add.status !== 0) throw new GhError(`git worktree add: ${add.stderr.trim()}`);
3944
+ return dir;
3945
+ };
3946
+ const ON_MERGE = "on the merge";
3947
+ /** The tree's package.json scripts; no package.json — none. */
3948
+ const scriptsOf = (dir: string): Record<string, unknown> => {
3949
+ const pkg = path.join(dir, "package.json");
3950
+ return existsSync(pkg) ? (JSON.parse(readFileSync(pkg, "utf8")).scripts ?? {}) : {};
3951
+ };
3952
+ type Red = { script: string; cmd: string; result: RunResult; failed: string[] };
3953
+ /**
3954
+ * The tree's fast checks (`scripts` — those that exist) with dependencies from its lockfile, up to the first red one;
3955
+ * none — null.
3956
+ */
3957
+ const checks = (dir: string, where: string, scripts: string[]): { ran: string[]; red: Red | null } | null => {
3958
+ const defined = scriptsOf(dir);
3959
+ const present = scripts.filter((s) => defined[s]);
3960
+ if (!present.length) return null;
3961
+ const cmds = projectCommands(dir);
3962
+ if (cmds.install) {
3963
+ const r = run(cmds.install[0]!, cmds.install.slice(1), dir);
3964
+ if (r.status !== 0) throw new GhError(`installing dependencies ${where} failed (${cmds.install.join(" ")}): ${tail(r, 5).join(" / ")}`);
3965
+ io.out(`+ dependencies${where === ON_MERGE ? "" : ` ${where}`}: ${cmds.install.join(" ")}`);
3966
+ }
3967
+ const ran: string[] = [];
3968
+ for (const script of present) {
3969
+ const cmd = cmds.run(script);
3970
+ const result = run(cmd[0]!, cmd.slice(1), dir);
3971
+ ran.push(script);
3972
+ if (result.status === 0) continue;
3973
+ const failed = [...new Set(asTsc(`${result.stdout}\n${result.stderr}`).split("\n").filter((l) => FAILED_LINE.test(l)).map((l) => l.trim()))];
3974
+ return { ran, red: { script, cmd: cmd.join(" "), result, failed } };
3975
+ }
3976
+ return { ran, red: null };
3977
+ };
3978
+ const breaks = (script: string) => `❌ don't merge: ${script} is red on the merge with origin/${base} — fix the root cause in the PR: rebase onto origin/${base}, edit, push --force-with-lease, ci-wait again`;
3979
+ try {
3980
+ const dir = worktree();
3981
+ const short = (ref: string) => git(dir, "rev-parse", "--short=7", ref).stdout.trim();
3982
+ // like GitHub's merge-ref; a rebase merge without conflicts gives the same tree. A temporary merge doesn't need the
3983
+ // project's hooks
3984
+ const merge = git(dir, "-c", "user.name=elves", "-c", "user.email=elves@localhost", "-c", "commit.gpgsign=false", "merge", "-q", "--no-ff", "--no-verify", "--no-edit", head);
3985
+ if (merge.status !== 0) {
3986
+ const files = git(dir, "diff", "--name-only", "--diff-filter=U").stdout.split("\n").filter(Boolean);
3987
+ if (!files.length) throw new GhError(`git merge: ${merge.stderr.trim()}`);
3988
+ io.out(`❌ don't merge: a merge conflict with origin/${base} — ${files.join(", ")}; rebase onto origin/${base}, push --force-with-lease, ci-wait again`);
3989
+ return 1;
3990
+ }
3991
+ io.out(`+ merge of ${head.slice(0, 7)} with origin/${base} ${short("HEAD^1")} — no conflicts`);
3992
+ // standards without test:spec don't stop the merge: the project's check holds the script, here — that they weren't run
3993
+ const roots = specRoots(dir);
3994
+ if (roots.length && !scriptsOf(dir)["test:spec"]) io.out(`○ ${roots.join(", ")} aren't checked on the merge: the merge's package.json has no test:spec script — the project's check names the line to add (bunx github:miroshnik/elves check)`);
3995
+ const merged = checks(dir, ON_MERGE, PREMERGE_SCRIPTS);
3996
+ if (!merged) {
3997
+ io.out(`○ no fast checks: the merge's package.json has no ${PREMERGE_SCRIPTS.join(" or ")} scripts — step skipped`);
3998
+ io.out(`✅ merge: the merge with origin/${base} has no conflicts`);
3999
+ return 0;
4000
+ }
4001
+ const red = merged.red;
4002
+ if (!red) {
4003
+ io.out(`✅ merge: ${merged.ran.join(" and ")} ${merged.ran.length > 1 ? "are" : "is"} green on the merge with origin/${base}`);
4004
+ return 0;
4005
+ }
4006
+ io.out(`${red.cmd} failed:`);
4007
+ for (const l of (red.failed.length ? red.failed.slice(0, 20) : tail(red.result))) io.out(` ${l.trim()}`);
4008
+
4009
+ // a red merge — is bare main red by the same thing: then it's not this PR that breaks it
4010
+ io.out(`checking bare origin/${base}: is it red by itself`);
4011
+ const bare = checks(worktree(), `on origin/${base}`, [red.script])?.red;
4012
+ if (!bare) {
4013
+ io.out(breaks(red.script));
4014
+ return 1;
4015
+ }
4016
+ const onMain = new Set(bare.failed.map(failure));
4017
+ const own = red.failed.filter((l) => !onMain.has(failure(l)));
4018
+ // can't tell what failed — can't tell them apart: main is red, after its fix premerge checks the PR again
4019
+ if (own.length && bare.failed.length) {
4020
+ io.out(`${base} is red by itself, the PR breaks beyond it:`);
4021
+ for (const l of own.slice(0, 20)) io.out(` ${l}`);
4022
+ io.out(breaks(red.script));
4023
+ return 1;
4024
+ }
4025
+ const why = `${red.script} is red on bare origin/${base} too, with the same failures`;
4026
+ const code = mainRed(`${base} is red by itself: ${why}`, why, [red.script], (names) => names.join(", "));
4027
+ if (code !== null) return code;
4028
+ io.out(breaks(red.script));
4029
+ return 1;
4030
+ } finally {
4031
+ for (const dir of dirs) {
4032
+ git(io.cwd, "worktree", "remove", "--force", dir);
4033
+ rmSync(dir, { recursive: true, force: true });
4034
+ }
4035
+ git(io.cwd, "worktree", "prune");
4036
+ }
4037
+ }
4038
+
4039
+ // ----------------------------------------------------------------------------
4040
+ // CLI
4041
+ // ----------------------------------------------------------------------------
4042
+
4043
+ /**
4044
+ * Help — `--help` or `-h` anywhere in the arguments (tests/standards/cli-help): a command's synopsis (the first
4045
+ * paragraph), what it does and examples; `github --help` — the synopses of all. The dispatcher takes only the commands
4046
+ * from here: a command without help can't be called.
4047
+ */
4048
+ const HELP: Record<string, string> = {
4049
+ "project check": `github project check [--repo owner/repo]
4050
+
4051
+ Items ✅/❌; code 0 — everything per the canon, 1 — there are ❌. Decision labels — by the default branch's spec tree; a
4052
+ new decision's label on an open task and a slice label («Slice: …») with a decision's name — an ○ line, not ❌.
4053
+
4054
+ Examples:
4055
+ github project check
4056
+ github project check --repo acme/shop`,
4057
+ "project fix": `github project fix [--repo owner/repo] [--confirm] [--template owner/N | none]
4058
+
4059
+ Fixes via the API and prints UI steps with links; deleting and renaming in a project with tasks and organization
4060
+ settings — only with --confirm (after the user's "yes"). No project — links the one of the same name, otherwise copies
4061
+ the template (${DEFAULT_TEMPLATE}), otherwise creates one.
4062
+
4063
+ Examples:
4064
+ github project fix
4065
+ github project fix --confirm --template none`,
4066
+ "task new": `github task new --title "…" --branch-type <type> [--body "…" | --body-file F] [--type Task|Bug|Epic] [--epic N]
4067
+ [--milestone "…"] [--blocked-by N,N] [--priority Urgent|High|Medium|Low]
4068
+ [--labels <decision>,<kind>:<new decision>,<label>] [--repo owner/repo]
4069
+
4070
+ A task by the canon: type, project and Backlog, Priority, epic, blocked by, milestone, decision labels, the questions
4071
+ label when the description has a question without ✅ (created if missing); sets the estimate itself (est estimate
4072
+ --write with --branch-type: feat, fix, docs, refactor, perf, test, chore, ci, build, research; a bug without it is
4073
+ fix, an epic isn't estimated) and prints what was created and the next step. A Claude Code cloud session — over the
4074
+ repository's REST, without the project: Status, the estimate and an organization's Priority — a line for a local session.
4075
+
4076
+ Examples:
4077
+ github task new --title "Billing · Export invoices to PDF" --branch-type feat --epic 40 --body-file task.md
4078
+ github task new --title "Login fails on Safari" --type Bug --priority High --body "Steps: …"`,
4079
+ "task show": `github task show <N> [--repo owner/repo]
4080
+
4081
+ The task in one output: title, type, Status, estimate, the epic with its open questions and tasks, subtasks, open
4082
+ blockers, the PRs with their checks, open and answered questions, the description; read-only. A cloud session — over the
4083
+ repository's REST, without the project's fields; the PRs — by the task branch or «Closes #N».
4084
+
4085
+ Examples:
4086
+ github task show 42`,
4087
+ "task answer": `github task answer <N> <k> "<answer>" [--auto] [--repo owner/repo]
4088
+
4089
+ ✅ and «**Answer:** …» at question k of the questions section (--auto — the auto mark), the numbering unchanged; no open
4090
+ questions left — the questions label is removed; an answered question is refused. A question is answered by ✅ at the
4091
+ start of its answer, not by ✅ in its text. A cloud session — over the repository's REST.
4092
+
4093
+ Examples:
4094
+ github task answer 42 1 "Only PDF, no CSV yet"
4095
+ github task answer 42 2 "Medium, by recommendation" --auto`,
4096
+ "task status": `github task status <N> <Backlog|In progress|Done> [--branch <type>/<N>-<slug>] [--repo owner/repo]
4097
+
4098
+ Status in the project (and the epic's In progress once its first subtask is taken). In progress pins the task to the
4099
+ session (file sessions/<CLAUDE_CODE_SESSION_ID> in ~/.config/elves): another task in the same session — a refusal
4100
+ (code 2) with the question to the owner whether to queue it and its executor's prompt; a task handed out to an
4101
+ executor of this session (task queue --assignment) isn't pinned; a human removes the pin. --branch — the worktree
4102
+ tool's branch becomes the task branch (git branch -m with its .git/config section); a .git/config locked by another
4103
+ checkout — retried ~10 s, then a ! line with the command that finishes it (code 1).
4104
+
4105
+ Examples:
4106
+ github task status 42 "In progress" --branch feat/42-invoice-export
4107
+ github task status 42 Backlog`,
4108
+ "task drop": `github task drop <N> [--duplicate-of M] [--repo owner/repo]
4109
+
4110
+ Closes the task without doing it (not planned) and removes it from the project.
4111
+
4112
+ Examples:
4113
+ github task drop 42
4114
+ github task drop 42 --duplicate-of 17`,
4115
+ "task actualize": `github task actualize <N> [--assignment] [--repo owner/repo]
4116
+
4117
+ Right after the merge, in parallel with the deploy: the epic refresh's mechanics (blocked by on done tasks, the questions
4118
+ label without open questions, milestones without open tasks; a comment on the epic) and the subagent's prompt;
4119
+ --assignment (read-only, the subagent's first call) — its whole assignment: the texts of the epic and its open tasks,
4120
+ the PR files, copies of links and answers in comments, the checks by meaning; no epic or no open tasks in it —
4121
+ "refresh not needed".
4122
+
4123
+ Examples:
4124
+ github task actualize 42
4125
+ github task actualize 42 --assignment`,
4126
+ "task resume": `github task resume
4127
+
4128
+ The SessionStart hook runs it: the wait to restart for the task pinned to the session — an open PR's checks, a merged PR
4129
+ of an open task — the deploy and task close; no pin — nothing (code 0). Read-only on GitHub; a session with executors —
4130
+ its stopped executors continued with SendMessage, then the queue command (a dispatcher's); a new executor only for one
4131
+ that can't be continued, in the worktree of its task's branch.
4132
+
4133
+ Examples:
4134
+ github task resume`,
4135
+ "task queue": `github task queue [--epic N] [--type Task|Bug] [--priority Urgent,High] [--repo owner/repo]
4136
+ github task queue --assignment <N> [--cloud] [--repo owner/repo]
4137
+
4138
+ The dispatcher session's queue: ready tasks (open, not an epic, not in work, no open blockers — other repositories'
4139
+ too —, no external blocker label) by Priority, then number, each with its executor's prompt; the rest — in work (=: In
4140
+ progress, an open PR with its link, handed out to an executor of this session) and ○ lines with the reason (the
4141
+ label's — from the latest «External blocker: …» comment). Marks the session as a dispatcher
4142
+ (dispatchers/<CLAUDE_CODE_SESSION_ID> in ~/.config/elves): task status in it doesn't pin — the executors are subagents
4143
+ with the session's id; a session with a pinned task keeps it. Before the queue, in the repository's checkout —
4144
+ finished agents' worktrees (.claude/worktrees/agent-*, detached or on worktree-agent-*, not locked by a live process,
4145
+ not an executor's whose handed-out task is open): clean and merged (git cherry) — removed with the branch (+),
4146
+ otherwise left (○ with the reason), and so is one a subagent works in without a worktree of its own (its transcript
4147
+ written in the last ${QUIET_MINUTES} min); loose worktree-agent-* branches — the same. --type and --priority — in an
4148
+ organization. --assignment N (the executor's first call; read-only on GitHub) — its whole assignment; in a session
4149
+ (not one pinned to N) it records the task as handed out with the executor's worktree (dispatchers/<id>.tasks). A
4150
+ restart while the task's previous executor or its subagent is alive — a worktree of it locked by a live process, with
4151
+ such a subagent at work or HEAD moved in the last ${QUIET_MINUTES} min — code 1: ! lines and the answer that makes the
4152
+ dispatcher wait, no hand-out. --cloud — printed by the dispatcher: the brief of a cloud executor (claude.ai/code) —
4153
+ whole, # only at its task — and the dispatcher's steps: a cloud session can't message a local one, so the dispatcher
4154
+ waits for the task's closing (ci-wait issue N, background) and closes it locally; the hand-out has no worktree, and the
4155
+ queue's = line names that wait.
4156
+
4157
+ Examples:
4158
+ github task queue --epic 40
4159
+ github task queue --type Bug --priority Urgent,High
4160
+ github task queue --assignment 42
4161
+ github task queue --assignment 42 --cloud`,
4162
+ "task close": `github task close <N> [--no-git] [--repo owner/repo]
4163
+
4164
+ After the PR merge and the deploy (or a close without a PR) in one call: the actual (est fact --write), Status Done, the
4165
+ epic and the milestone, the task's stacks taken down with their volumes (task-env down), the merged branches removed
4166
+ (--no-git — without git). The epic has other open tasks and no report of the refresh subagent on it («Refresh after
4167
+ #N, result: …») — a refusal before any changes. A task of the scripts' own repository: before the actual the scripts'
4168
+ clone catches up with origin (pull --ff-only; only on the default branch, without uncommitted changes). Docker and gh
4169
+ don't work in an agent's sandbox — run it outside.
4170
+
4171
+ Examples:
4172
+ github task close 42
4173
+ github task close 42 --no-git`,
4174
+ "pr labels": `github pr labels <N> [--repo owner/repo]
4175
+
4176
+ Decision labels from the PR diff for the tasks from «Closes #N» and their epics; doesn't remove earlier ones. A decision
4177
+ touched only mechanically (a deletion, a rename without changes, exceptions) and a decision with a slice label's name —
4178
+ an ○ line, no label. More than fit on an issue (GitHub allows ${ISSUE_LABEL_LIMIT}) — only decisions whose <folder>.md
4179
+ or test names (the PR body's spec section) the PR changes, as many as fit; the rest — ○ lines.
4180
+
4181
+ Examples:
4182
+ github pr labels 57`,
4183
+ "pr premerge": `github pr premerge <N> [--repo owner/repo]
4184
+
4185
+ Before gh pr merge, after ci-wait PASS: the default branch moved after the PR's CI — the PR head merged with it in a
4186
+ temporary worktree and ${PREMERGE_SCRIPTS.join(", then ")} from package.json (those that exist, as in CI). Code 0 —
4187
+ merge, 1 — don't merge (red, conflict), 2 — couldn't check, 3 — main is red by itself (the same check on bare main, a
4188
+ red check on its last commit): don't merge, one bug for it — wait for it to close.
4189
+
4190
+ Examples:
4191
+ github pr premerge 57`,
4192
+ };
4193
+
4194
+ /** `github --help`: what the script is and the synopsis of each command. */
4195
+ const OVERVIEW = [
4196
+ "github — the repository's GitHub project and tasks by the AGENTS.md canon",
4197
+ "",
4198
+ ...Object.values(HELP).map((h) => h.split("\n\n")[0]!.replace(/^/gm, " ")),
4199
+ "",
4200
+ "github <command> --help — the command's details and examples",
4201
+ ].join("\n");
4202
+
4203
+ /** The help the arguments ask for (`--help`, `-h`): of the command they start with, otherwise the overview; none — undefined. */
4204
+ function helpOf(argv: string[]): string | undefined {
4205
+ if (!argv.some((a) => a === "--help" || a === "-h")) return undefined;
4206
+ const words = `${argv.join(" ")} `;
4207
+ const cmd = Object.keys(HELP).find((c) => words.startsWith(`${c} `));
4208
+ return cmd ? HELP[cmd] : OVERVIEW;
4209
+ }
4210
+
4211
+ const issueNumber = (s: string | undefined, what: string): number => {
4212
+ const m = /^#?(\d+)$/.exec((s ?? "").trim());
4213
+ if (!m) throw new GhError(`${what}: expected an issue number, not «${s ?? ""}»`);
4214
+ return Number(m[1]);
4215
+ };
4216
+
4217
+ // The checkout's GitHub repository by its origin — a copy of originRepo from skills/est/scripts/est.ts and skills/task-env/scripts/task-env.ts:
4218
+ // no shared module: github imports no other skill (tests/standards/skill-imports); the tests/standards/origin-repo
4219
+ // standard keeps the parsing identical
4220
+ const ORIGIN_RE = /^(?:[a-z][a-z\d+.-]*:\/\/(?:[^@/\s]+@)?(?:ssh\.)?github\.com(?::\d+)?\/|(?:[^@/\s:]+@)?(?:ssh\.)?github\.com:)([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/i;
4221
+
4222
+ /** `owner/repo` of a GitHub origin URL (host github.com or ssh.github.com, any case, with a port), otherwise null. */
4223
+ export function originRepo(url: string): string | null {
4224
+ const m = ORIGIN_RE.exec(url.trim());
4225
+ return m ? `${m[1]}/${m[2]}` : null;
4226
+ }
4227
+
4228
+ /** A checkout with this origin URL is a checkout of `repo` (owner/name): the parsed origin equals it — GitHub ignores case. */
4229
+ export function checkoutOf(origin: string, repo: string): boolean {
4230
+ return originRepo(origin)?.toLowerCase() === repo.toLowerCase();
4231
+ }
4232
+
4233
+ function detectRepo(): string {
4234
+ const r = spawnSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" });
4235
+ const repo = r.status === 0 ? originRepo(r.stdout ?? "") : null;
4236
+ if (!repo) throw new GhError("couldn't determine the repository from git remote origin; pass --repo owner/repo");
4237
+ return repo;
4238
+ }
4239
+
4240
+ /** Names already resolved by `canonicalRepo`, per Io: lowercase name → the current one. */
4241
+ const canonicalNames = new WeakMap<Io, Map<string, string>>();
4242
+
4243
+ /**
4244
+ * The repository's current name `owner/repo` (#560): GitHub answers a renamed repository's former name with it. Reads by
4245
+ * the former name work — GraphQL and a REST GET follow the rename — but a REST write gets HTTP 307, which `gh` doesn't
4246
+ * repeat for a non-GET, and a hint with the former name sends the next command there: the commands work under the
4247
+ * current name. No such repository or no access — the name as given: the command's own reads say so.
4248
+ */
4249
+ function canonicalRepo(io: Io, slug: string): string {
4250
+ let known = canonicalNames.get(io);
4251
+ if (!known) canonicalNames.set(io, (known = new Map()));
4252
+ const key = slug.toLowerCase();
4253
+ if (!known.has(key)) {
4254
+ const [owner, name] = slug.split("/");
4255
+ const out = io.gh(["api", "graphql", "--input", "-"], JSON.stringify({ query: Q.RepoName, variables: { owner, name } }));
4256
+ let current: unknown = null;
4257
+ try {
4258
+ current = JSON.parse(out)?.data?.repository?.nameWithOwner;
4259
+ } catch {
4260
+ current = null;
4261
+ }
4262
+ known.set(key, typeof current === "string" && current ? current : slug);
4263
+ }
4264
+ return known.get(key)!;
4265
+ }
4266
+
4267
+ /**
4268
+ * The checkout with this origin is a checkout of `slug` (the current name): by the origin's name, and a clone made before
4269
+ * the repository's rename — by the name GitHub resolves its origin's name to (#560).
4270
+ */
4271
+ function checkoutOfRepo(io: Io, origin: string, slug: string): boolean {
4272
+ if (checkoutOf(origin, slug)) return true;
4273
+ const repo = originRepo(origin);
4274
+ return repo !== null && canonicalRepo(io, repo).toLowerCase() === slug.toLowerCase();
4275
+ }
4276
+
4277
+ export function main(argv: string[], io: Io): number {
4278
+ const help = helpOf(argv);
4279
+ if (help) return io.out(help), 0;
4280
+ N = locale.names(locale.projectLanguage(io.cwd ?? process.cwd()));
4281
+ try {
4282
+ const [group, cmd, ...rest] = argv;
4283
+ if (!group) return io.out(OVERVIEW), 2;
4284
+ if (!Object.hasOwn(HELP, `${group} ${cmd ?? ""}`)) {
4285
+ io.err(`unknown command «${argv.slice(0, 2).join(" ")}»; expected ${Object.keys(HELP).join(", ")} (github --help)`);
4286
+ return 2;
4287
+ }
4288
+ if (inCloud(io) && group === "task" && cmd === "close") {
4289
+ const n = /^#?(\d+)$/.exec(rest.find((a) => !a.startsWith("--")) ?? "")?.[1] ?? "<N>";
4290
+ io.err(`cloud session: task close is unavailable to it — no GitHub Projects in the cloud (docs/cloud-sessions.md). The actual — the ${q(N.est.cloud.actual.replace(/:$/, ""))} comment printed by est fact ${n}; ${q(N.status.done)} is set by the project's workflow ${q(WORKFLOW_CLOSED)}; the cloud's branch and container are temporary, nothing to clean up.`);
4291
+ return 2;
4292
+ }
4293
+ if (inCloud(io) && !CLOUD_COMMANDS.includes(`${group} ${cmd}`)) {
4294
+ io.err(`cloud session: GitHub GraphQL and Projects are unavailable to it (403, docs/cloud-sessions.md) — ${group} ${cmd} runs in a local session; in the cloud — ${CLOUD_COMMANDS.join(", ")} over the repository's REST`);
4295
+ return 2;
4296
+ }
4297
+ // the repository — under its current name: --repo or origin may still name it as it was before a rename (#560); the
4298
+ // cloud has no GraphQL, and its session is bound to its repositories by their names
4299
+ const repoOf = (v: string | undefined) => {
4300
+ const slug = v ?? detectRepo();
4301
+ if (!/^[\w.-]+\/[\w.-]+$/.test(slug)) throw new GhError(`invalid --repo «${slug}», expected owner/repo`);
4302
+ return inCloud(io) ? slug : canonicalRepo(io, slug);
4303
+ };
4304
+ if (group === "pr") {
4305
+ const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { repo: { type: "string" } } });
4306
+ if (positionals.length > 1) throw new GhError(`extra arguments: ${positionals.slice(1).join(" ")}`);
4307
+ const m = /^#?(\d+)$/.exec((positionals[0] ?? "").trim());
4308
+ if (!m) throw new GhError(`pr ${cmd}: expected a PR number, not «${positionals[0] ?? ""}»`);
4309
+ return (cmd === "premerge" ? cmdPrPremerge : cmdPrLabels)(io, repoOf(values.repo), Number(m[1]));
4310
+ }
4311
+ if (group === "task") {
4312
+ const { values, positionals } = parseArgs({
4313
+ args: rest,
4314
+ allowPositionals: true,
4315
+ options: {
4316
+ repo: { type: "string" },
4317
+ title: { type: "string" },
4318
+ body: { type: "string" },
4319
+ "body-file": { type: "string" },
4320
+ type: { type: "string" },
4321
+ epic: { type: "string" },
4322
+ milestone: { type: "string" },
4323
+ "blocked-by": { type: "string" },
4324
+ priority: { type: "string" },
4325
+ "duplicate-of": { type: "string" },
4326
+ labels: { type: "string" },
4327
+ "branch-type": { type: "string" },
4328
+ branch: { type: "string" },
4329
+ "no-git": { type: "boolean", default: false },
4330
+ assignment: { type: "boolean", default: false },
4331
+ cloud: { type: "boolean", default: false },
4332
+ auto: { type: "boolean", default: false },
4333
+ },
4334
+ });
4335
+ // the task and its repository — from the session pin: the hook runs it from any directory
4336
+ if (cmd === "resume") {
4337
+ if (positionals.length) throw new GhError(`extra arguments: ${positionals.join(" ")} — task resume takes the task from the session pin`);
4338
+ return cmdTaskResume(io);
4339
+ }
4340
+ const slug = repoOf(values.repo);
4341
+ if (cmd === "new") {
4342
+ if (positionals.length) throw new GhError(`extra arguments: ${positionals.join(" ")}`);
4343
+ if (!values.title?.trim()) throw new GhError("--title is required");
4344
+ if (values.body !== undefined && values["body-file"]) throw new GhError("--body or --body-file, not both");
4345
+ const body = values["body-file"] ? readFileSync(values["body-file"], "utf8") : (values.body ?? "");
4346
+ const blockedBy = (values["blocked-by"] ?? "").split(",").filter((x) => x.trim()).map((x) => issueNumber(x, "--blocked-by"));
4347
+ const epic = values.epic === undefined ? undefined : issueNumber(values.epic, "--epic");
4348
+ const labels = (values.labels ?? "").split(",").map((x) => x.trim()).filter(Boolean);
4349
+ return cmdTaskNew(io, slug, { title: values.title, body, type: values.type ?? N.issueTypes.task, epic, milestone: values.milestone, blockedBy, priority: values.priority, labels, branchType: values["branch-type"] });
4350
+ }
4351
+ if (cmd === "queue") {
4352
+ if (values.cloud && !values.assignment) throw new GhError("--cloud — only with --assignment <N>: the brief of one task's cloud executor");
4353
+ if (values.assignment) {
4354
+ if (positionals.length !== 1) throw new GhError("task queue --assignment <N>: one task number");
4355
+ return cmdTaskAssignment(io, slug, issueNumber(positionals[0], "task queue --assignment"), { cloud: values.cloud! });
4356
+ }
4357
+ if (positionals.length) throw new GhError(`extra arguments: ${positionals.join(" ")} — the queue is chosen by --epic, --type and --priority`);
4358
+ const priority = values.priority === undefined ? undefined : values.priority.split(",").map((x) => x.trim()).filter(Boolean);
4359
+ return cmdTaskQueue(io, slug, { epic: values.epic === undefined ? undefined : issueNumber(values.epic, "--epic"), type: values.type, priority });
4360
+ }
4361
+ const number = issueNumber(positionals[0], `task ${cmd}`);
4362
+ if (cmd === "status") return cmdTaskStatus(io, slug, number, positionals.slice(1).join(" "), values.branch);
4363
+ if (cmd === "answer") {
4364
+ const k = /^\d+$/.exec((positionals[1] ?? "").trim());
4365
+ if (!k) throw new GhError(`task answer: expected a question number, not «${positionals[1] ?? ""}»`);
4366
+ return cmdTaskAnswer(io, slug, number, Number(k[0]), positionals.slice(2).join(" "), values.auto!);
4367
+ }
4368
+ if (positionals.length > 1) throw new GhError(`extra arguments: ${positionals.slice(1).join(" ")}`);
4369
+ if (cmd === "show") return cmdTaskShow(io, slug, number);
4370
+ if (cmd === "close") return cmdTaskClose(io, slug, number, { git: !values["no-git"] });
4371
+ if (cmd === "actualize") return cmdTaskActualize(io, slug, number, { assignment: values.assignment! });
4372
+ return cmdTaskDrop(io, slug, number, values["duplicate-of"] === undefined ? undefined : issueNumber(values["duplicate-of"], "--duplicate-of"));
4373
+ }
4374
+ const { values } = parseArgs({ args: rest, options: { repo: { type: "string" }, confirm: { type: "boolean", default: false }, template: { type: "string", default: DEFAULT_TEMPLATE } } });
4375
+ const slug = repoOf(values.repo);
4376
+ const template = values.template === "none" ? null : values.template!;
4377
+ if (template && !/^[\w.-]+\/\d+$/.test(template)) throw new GhError(`invalid --template «${template}», expected owner/N or none`);
4378
+ return cmd === "check" ? cmdCheck(io, slug) : cmdFix(io, slug, { confirm: values.confirm!, template });
4379
+ } catch (e) {
4380
+ if (e instanceof GhError || (e as Any)?.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
4381
+ io.err(`error: ${(e as Error).message}`);
4382
+ return 2;
4383
+ }
4384
+ throw e;
4385
+ }
4386
+ }
4387
+
4388
+ if (import.meta.main) {
4389
+ process.exitCode = main(process.argv.slice(2), { gh: realGh, out: (l) => console.log(l), err: (l) => console.error(l), env: process.env });
4390
+ }