@erclx/aitk 3.43.2 → 3.45.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 (60) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/{toolkit-cli → aitk-cli}/REQUIREMENT.md +4 -4
  3. package/claude/skills/{toolkit-cli → aitk-cli}/SKILL.md +1 -1
  4. package/claude/skills/{toolkit-feedback → aitk-feedback-file}/REQUIREMENT.md +3 -3
  5. package/claude/skills/{toolkit-feedback → aitk-feedback-file}/SKILL.md +2 -2
  6. package/claude/skills/{toolkit-triage → aitk-feedback-triage}/REQUIREMENT.md +3 -3
  7. package/claude/skills/{toolkit-triage → aitk-feedback-triage}/SKILL.md +3 -3
  8. package/claude/skills/{toolkit-operator → aitk-operator}/REQUIREMENT.md +3 -3
  9. package/claude/skills/{toolkit-operator → aitk-operator}/SKILL.md +3 -3
  10. package/claude/skills/aitk-rollout/REQUIREMENT.md +59 -0
  11. package/claude/skills/aitk-rollout/SKILL.md +147 -0
  12. package/claude/skills/{claude-screencast → aitk-screencast}/REQUIREMENT.md +3 -3
  13. package/claude/skills/{claude-screencast → aitk-screencast}/SKILL.md +2 -2
  14. package/claude/skills/{claude-slides-draft → aitk-slides-draft}/REQUIREMENT.md +3 -3
  15. package/claude/skills/{claude-slides-draft → aitk-slides-draft}/SKILL.md +1 -1
  16. package/claude/skills/{cli-script → bash-cli-script}/REQUIREMENT.md +2 -2
  17. package/claude/skills/{cli-script → bash-cli-script}/SKILL.md +2 -2
  18. package/claude/skills/bash-script/REQUIREMENT.md +2 -2
  19. package/claude/skills/bash-script/SKILL.md +2 -2
  20. package/claude/skills/ci-workflow/REQUIREMENT.md +1 -1
  21. package/claude/skills/claude-memory-review/SKILL.md +2 -2
  22. package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
  23. package/claude/skills/claude-seed-sync/REQUIREMENT.md +1 -1
  24. package/claude/skills/claude-seed-sync/SKILL.md +1 -1
  25. package/claude/skills/git-issue/REQUIREMENT.md +2 -2
  26. package/claude/skills/git-issue/SKILL.md +1 -1
  27. package/claude/skills/{restate → restate-plainly}/REQUIREMENT.md +2 -2
  28. package/claude/skills/{restate → restate-plainly}/SKILL.md +2 -2
  29. package/claude/skills/setup-init/REQUIREMENT.md +1 -1
  30. package/claude/skills/setup-init/SKILL.md +1 -1
  31. package/claude/skills/write-human/REQUIREMENT.md +1 -1
  32. package/claude/skills/write-human/SKILL.md +1 -1
  33. package/docs/agents/demo.md +1 -1
  34. package/docs/agents/index.md +1 -0
  35. package/docs/agents/overview.md +2 -2
  36. package/docs/agents/scripting.md +1 -1
  37. package/docs/agents/sessions.md +11 -5
  38. package/docs/agents/targets.md +83 -0
  39. package/docs/ai-workflow.md +17 -16
  40. package/docs/target-projects.md +1 -1
  41. package/governance/rules/lang/120-bash.md +1 -1
  42. package/package.json +1 -1
  43. package/scripts/core/regen-tooling-paths.sh +1 -1
  44. package/scripts/core/verify.sh +1 -1
  45. package/src/claude/cases/authoring.ts +2 -2
  46. package/src/claude/cases/claude-workflow.ts +2 -2
  47. package/src/claude/cases/setup.ts +14 -6
  48. package/src/cli.ts +3 -0
  49. package/src/commands/demo.ts +1 -1
  50. package/src/commands/sessions.ts +24 -8
  51. package/src/commands/targets.ts +319 -0
  52. package/src/demo/beats.ts +1 -1
  53. package/src/sessions/claim.ts +7 -0
  54. package/src/sync/stamp.ts +9 -0
  55. package/src/targets/pulls.ts +250 -0
  56. package/src/targets/registry.ts +161 -0
  57. package/src/targets/resolve.ts +145 -0
  58. package/src/targets/sweep.ts +246 -0
  59. package/standards/issue.md +1 -1
  60. /package/claude/skills/{cli-script → bash-cli-script}/references/template.md +0 -0
@@ -0,0 +1,319 @@
1
+ import type { Command } from 'commander'
2
+ import { readPullsAcross, type TargetPulls } from '@/targets/pulls'
3
+ import {
4
+ type KnownTarget,
5
+ type ResolvedTargets,
6
+ resolveTargets,
7
+ } from '@/targets/resolve'
8
+ import { DEFAULT_DEPTH, type SweepBound } from '@/targets/sweep'
9
+ import {
10
+ intro,
11
+ logInfo,
12
+ logStep,
13
+ logWarn,
14
+ outro,
15
+ pipeOutput,
16
+ plural,
17
+ } from '@/ui'
18
+
19
+ interface ListOptions {
20
+ readonly json?: boolean
21
+ readonly sweep?: string[]
22
+ readonly depth?: string
23
+ }
24
+
25
+ interface PullsOptions extends ListOptions {}
26
+
27
+ /**
28
+ * Reads the depth a sweep is bounded by, or null when the value is not one.
29
+ *
30
+ * `Number('abc')` is `NaN` and every `level >= NaN` test is false, so an
31
+ * unchecked value walks to the bottom of whatever root it was given while
32
+ * the bound reports a depth of `NaN`. Both halves of the guarantee go at once,
33
+ * which is why this refuses rather than falling back to the default.
34
+ */
35
+ function readDepth(value: string | undefined): number | null {
36
+ const depth = Number(value ?? DEFAULT_DEPTH)
37
+
38
+ return Number.isInteger(depth) && depth >= 0 ? depth : null
39
+ }
40
+
41
+ const REFUSALS: Record<string, string> = {
42
+ 'not-a-directory': 'the path is not a directory, so nothing was read there',
43
+ 'gh-unavailable': 'gh is not on the path, so no pull request could be read',
44
+ 'list-failed':
45
+ 'the open pull request list could not be read, so this is not a target with no work',
46
+ }
47
+
48
+ export function register(program: Command): void {
49
+ const targets = program
50
+ .command('targets')
51
+ .description('Report the projects this toolkit has installed into')
52
+ .helpOption('-h, --help', 'Show this help message')
53
+
54
+ targets
55
+ .command('list')
56
+ .description('Report every known target with where the answer came from')
57
+ .helpOption('-h, --help', 'Show this help message')
58
+ .option('--json', 'Add a machine-readable record on stdout')
59
+ .option(
60
+ '--sweep <path...>',
61
+ 'Also walk these roots for targets the record never held',
62
+ )
63
+ .option(
64
+ '--depth <n>',
65
+ 'How deep below each swept root to walk',
66
+ String(DEFAULT_DEPTH),
67
+ )
68
+ .addHelpText('after', LIST_HELP)
69
+ .action(async (opts: ListOptions) => {
70
+ process.exitCode = await runList(opts)
71
+ })
72
+
73
+ targets
74
+ .command('pulls')
75
+ .description(
76
+ 'Report the open pull request, checks, and review heading per target',
77
+ )
78
+ .argument('[path...]', 'Targets to read, defaulting to every known one')
79
+ .helpOption('-h, --help', 'Show this help message')
80
+ .option('--json', 'Add a machine-readable record on stdout')
81
+ .option('--sweep <path...>', 'Also walk these roots when no path is given')
82
+ .option(
83
+ '--depth <n>',
84
+ 'How deep below each swept root to walk',
85
+ String(DEFAULT_DEPTH),
86
+ )
87
+ .addHelpText('after', PULLS_HELP)
88
+ .action(async (paths: string[], opts: PullsOptions) => {
89
+ process.exitCode = await runPulls(paths, opts)
90
+ })
91
+ }
92
+
93
+ const LIST_HELP = [
94
+ '',
95
+ 'Sources:',
96
+ ' Every sync that stamps a target records it in a machine-level index, so',
97
+ ' a project installed since that shipped is known without being named.',
98
+ ' --sweep walks the roots given for anything installed before it, and a',
99
+ ' target found in two clones is reported once with both paths.',
100
+ '',
101
+ 'Exit codes:',
102
+ ' 0 the population was read',
103
+ ' 1 refused, with the reason on stderr',
104
+ '',
105
+ 'An absent index is reported as unknown rather than as no targets. Nothing',
106
+ 'was read in that case, so a count of zero would be a confident wrong answer.',
107
+ '',
108
+ 'The JSON carries a "bound" object whenever a sweep ran, naming the roots',
109
+ 'walked, the depth, the folders the walk stopped at, the roots it could not',
110
+ 'read, and the directory symlinks the walk did not follow. A sweep cannot',
111
+ 'see another machine or a clone under a path nobody named, so read the bound',
112
+ 'before treating the count as the population.',
113
+ '',
114
+ 'An exit code says nothing about a call made from a session, since a shell',
115
+ 'profile may wrap the binary in a function taking its status from a later',
116
+ 'command. Read the record rather than the exit when a skill consumes this.',
117
+ '',
118
+ 'Examples:',
119
+ ' aitk targets list',
120
+ ' aitk targets list --json',
121
+ ' aitk targets list --sweep ~/repos --json',
122
+ '',
123
+ ].join('\n')
124
+
125
+ const PULLS_HELP = [
126
+ '',
127
+ 'Reads, per target, every open pull request with its checks and the heading',
128
+ 'its newest review pass carries. Naming paths reads those and looks up',
129
+ 'nothing; naming none reads every target `aitk targets list` reports.',
130
+ '',
131
+ 'Exit codes:',
132
+ ' 0 at least one target was read',
133
+ ' 1 refused, or every target refused',
134
+ '',
135
+ 'A target that could not be read carries a "reason" rather than an empty',
136
+ 'pull list, since reading a failed query as no open work is what reports a',
137
+ 'target as done having read nothing.',
138
+ '',
139
+ '"checks" is null when GitHub reported no check at all, which is not the',
140
+ 'same answer as passing. "review" is "open" while the newest pass carries',
141
+ '## Review, "closed" once one carries ## Review closed, and null when no',
142
+ 'pass has landed. "reviewReadable" is false when that query failed, which',
143
+ 'leaves "review" covering nothing.',
144
+ '',
145
+ 'Examples:',
146
+ ' aitk targets pulls',
147
+ ' aitk targets pulls ../caret ../stackr --json',
148
+ '',
149
+ ].join('\n')
150
+
151
+ async function runList(opts: ListOptions): Promise<number> {
152
+ intro('aitk targets list')
153
+
154
+ const depth = readDepth(opts.depth)
155
+
156
+ if (depth === null) return refuseDepth(opts)
157
+
158
+ const resolved = await resolveTargets({ sweep: opts.sweep, depth })
159
+
160
+ const unknown = reportTargets(resolved)
161
+ if (resolved.bound) reportBound(resolved.bound)
162
+ outro()
163
+
164
+ if (opts.json) {
165
+ process.stdout.write(
166
+ `${JSON.stringify({
167
+ registry: resolved.registry?.path ?? null,
168
+ known: resolved.registry?.kind !== 'absent',
169
+ targets: resolved.targets,
170
+ bound: resolved.bound,
171
+ })}\n`,
172
+ )
173
+ }
174
+
175
+ return unknown ? 1 : 0
176
+ }
177
+
178
+ async function runPulls(paths: string[], opts: PullsOptions): Promise<number> {
179
+ intro('aitk targets pulls')
180
+
181
+ const depth = readDepth(opts.depth)
182
+
183
+ if (depth === null) return refuseDepth(opts)
184
+
185
+ const resolved = await resolveTargets({ paths, sweep: opts.sweep, depth })
186
+
187
+ if (resolved.targets.length === 0) {
188
+ logStep('Refused')
189
+ logWarn(
190
+ resolved.registry?.kind === 'absent'
191
+ ? `No target index at ${resolved.registry.path}, and no path was given. Name the targets, or sweep for them with --sweep.`
192
+ : 'No target resolved, so nothing was read.',
193
+ )
194
+ outro()
195
+
196
+ if (opts.json) {
197
+ process.stdout.write(
198
+ `${JSON.stringify({ reason: 'no-targets', targets: [] })}\n`,
199
+ )
200
+ }
201
+
202
+ return 1
203
+ }
204
+
205
+ // One clone per project. Two checkouts sharing an origin answer the same
206
+ // query, so reading both spends the rate limit to print one answer twice.
207
+ const reports = await readPullsAcross(
208
+ resolved.targets.map((target) => target.paths[0] ?? ''),
209
+ )
210
+
211
+ reportPulls(reports)
212
+ outro()
213
+
214
+ if (opts.json) {
215
+ process.stdout.write(`${JSON.stringify({ targets: reports })}\n`)
216
+ }
217
+
218
+ return reports.every((report) => report.kind === 'refused') ? 1 : 0
219
+ }
220
+
221
+ function refuseDepth(opts: ListOptions): number {
222
+ logStep('Refused')
223
+ logWarn(
224
+ `--depth takes a whole number of levels and was given ${opts.depth}. A value that is not one leaves the walk with no cap at all.`,
225
+ )
226
+ outro()
227
+
228
+ if (opts.json) {
229
+ process.stdout.write(
230
+ `${JSON.stringify({ reason: 'bad-depth', depth: opts.depth, targets: [] })}\n`,
231
+ )
232
+ }
233
+
234
+ return 1
235
+ }
236
+
237
+ /** Returns whether the population is unknown, which is the one refusal this read has. */
238
+ function reportTargets(resolved: ResolvedTargets): boolean {
239
+ logStep('Targets')
240
+
241
+ if (resolved.registry?.kind === 'absent' && resolved.bound === null) {
242
+ logWarn(
243
+ `No target index at ${resolved.registry.path}. Nothing was read, so this is not a machine with no targets. Sweep for them with --sweep, or run a sync in a target to record it.`,
244
+ )
245
+ return true
246
+ }
247
+
248
+ if (resolved.targets.length === 0) {
249
+ logInfo('No target found.')
250
+ return false
251
+ }
252
+
253
+ logInfo(plural(resolved.targets.length, 'target'))
254
+ pipeOutput(resolved.targets.map(describe).join('\n'))
255
+ return false
256
+ }
257
+
258
+ function describe(target: KnownTarget): string {
259
+ const flags = [target.source, ...(target.legacy ? ['legacy stamp'] : [])]
260
+ const clones =
261
+ target.paths.length > 1 ? `\n ${target.paths.slice(1).join('\n ')}` : ''
262
+
263
+ return `${target.paths[0]} ${flags.join(', ')}${clones}`
264
+ }
265
+
266
+ function reportBound(bound: SweepBound): void {
267
+ logStep('Bound')
268
+ logInfo(
269
+ `Walked ${plural(bound.roots.length, 'root')} to depth ${bound.depth} on this machine alone.`,
270
+ )
271
+
272
+ if (bound.truncated.length > 0) {
273
+ logWarn(
274
+ `${plural(bound.truncated.length, 'folder')} hit the depth cap, so a target below one is unseen.`,
275
+ )
276
+ }
277
+
278
+ if (bound.unreadable.length > 0) {
279
+ logWarn(
280
+ `${plural(bound.unreadable.length, 'root')} could not be read: ${bound.unreadable.join(', ')}`,
281
+ )
282
+ }
283
+
284
+ if (bound.symlinks.length > 0) {
285
+ const verb = bound.symlinks.length === 1 ? 'was' : 'were'
286
+ logWarn(
287
+ `${plural(bound.symlinks.length, 'symlink')} to a directory ${verb} not followed: ${bound.symlinks.join(', ')}`,
288
+ )
289
+ }
290
+ }
291
+
292
+ function reportPulls(reports: readonly TargetPulls[]): void {
293
+ logStep('Pull requests')
294
+
295
+ for (const report of reports) {
296
+ if (report.kind === 'refused') {
297
+ logWarn(`${report.path}: ${REFUSALS[report.reason] ?? report.reason}`)
298
+ continue
299
+ }
300
+
301
+ if (report.pulls.length === 0) {
302
+ logInfo(`${report.path}: nothing open.`)
303
+ continue
304
+ }
305
+
306
+ logInfo(`${report.path}`)
307
+ pipeOutput(
308
+ report.pulls
309
+ .map((pull) => {
310
+ const checks = pull.checks ?? 'no checks'
311
+ const review = pull.reviewReadable
312
+ ? (pull.review ?? 'no pass')
313
+ : 'review unreadable'
314
+ return ` #${pull.number} ${checks} ${review} ${pull.url}`
315
+ })
316
+ .join('\n'),
317
+ )
318
+ }
319
+ }
package/src/demo/beats.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Reads the human-facing draft `claude-screencast` writes. Nothing here knows
2
+ * Reads the human-facing draft `aitk-screencast` writes. Nothing here knows
3
3
  * about a browser: the draft is prose aimed at a person, and turning it into
4
4
  * something executable is `@/demo/compile`'s job.
5
5
  */
@@ -24,6 +24,13 @@ export interface ClaimReport {
24
24
  }
25
25
 
26
26
  export interface ClaimOptions {
27
+ /**
28
+ * The repository the claim is answered about, not merely where the caller
29
+ * stands. Every reading below is taken against it, so handing another
30
+ * project's path asks about that project, which is what lets a dispatcher in
31
+ * one repository see a branch held in another. Defaults to the caller's own
32
+ * directory, so a call omitting it answers exactly as it always has.
33
+ */
27
34
  readonly cwd?: string
28
35
  readonly resolve?: () => Promise<SessionReport>
29
36
  readonly listWorktrees?: (cwd: string) => Promise<readonly WorktreeEntry[]>
package/src/sync/stamp.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { mkdir, writeFile } from 'node:fs/promises'
4
4
  import { dirname, join, sep } from 'node:path'
5
5
  import { execa } from 'execa'
6
+ import { recordTarget } from '@/targets/registry'
6
7
 
7
8
  /**
8
9
  * Domains the stamp can record. Governance attributes file by file through the
@@ -209,6 +210,14 @@ async function putDomain(
209
210
  const path = stampPath(target)
210
211
  await mkdir(dirname(path), { recursive: true })
211
212
  await writeFile(path, `${JSON.stringify(stamp, null, 2)}\n`)
213
+
214
+ // Every install and sync that stamps a target passes through here, which is
215
+ // what makes this the one place the machine-level index can be kept without
216
+ // each command remembering to. Its outcome is dropped rather than reported:
217
+ // the stamp just written is the authoritative record of this install, the
218
+ // index is a cache over every such stamp, and a state folder nobody can
219
+ // write is not a reason to fail a sync that already landed its files.
220
+ recordTarget(target, now)
212
221
  }
213
222
 
214
223
  const commitCache = new Map<string, Promise<string | undefined>>()
@@ -0,0 +1,250 @@
1
+ import { execa } from 'execa'
2
+ import { gitEnv } from '@/git-env'
3
+ import { isDirectory } from '@/target'
4
+
5
+ const GH_TIMEOUT_MS = 30_000
6
+
7
+ /**
8
+ * The two headings a review pass posts under.
9
+ *
10
+ * Owned by `claude-pr-review`, which states the full set once, and pinned here
11
+ * the way `claude-orchestrate/scripts/poll.sh` pins them. All three surfaces
12
+ * ship separately, so a heading added in that skill goes stale here with
13
+ * nothing comparing the copies.
14
+ */
15
+ const REVIEW_OPEN = '## Review'
16
+ const REVIEW_CLOSED = '## Review closed'
17
+
18
+ /** Why a target produced no reading, so an unreachable one never reads as having no work. */
19
+ export type TargetRefusal = 'not-a-directory' | 'gh-unavailable' | 'list-failed'
20
+
21
+ export type ChecksState = 'passing' | 'failing' | 'pending'
22
+
23
+ /** Whether the newest review pass left work owed. */
24
+ export type ReviewState = 'open' | 'closed'
25
+
26
+ export interface PullState {
27
+ readonly number: number
28
+ readonly title: string
29
+ readonly url: string
30
+ readonly head: string
31
+ /** Null when GitHub reported no check at all, which is not the same answer as passing. */
32
+ readonly checks: ChecksState | null
33
+ /** Null when no pass carrying a review heading has landed on the thread. */
34
+ readonly review: ReviewState | null
35
+ /** False when the review read failed, leaving `review` covering nothing. */
36
+ readonly reviewReadable: boolean
37
+ }
38
+
39
+ export type TargetPulls =
40
+ | {
41
+ readonly kind: 'refused'
42
+ readonly path: string
43
+ readonly reason: TargetRefusal
44
+ }
45
+ | {
46
+ readonly kind: 'read'
47
+ readonly path: string
48
+ readonly pulls: readonly PullState[]
49
+ }
50
+
51
+ /** Runs one `gh` invocation in a target and hands back its stdout, or null when it failed. */
52
+ export type GhRunner = (
53
+ cwd: string,
54
+ args: readonly string[],
55
+ ) => Promise<string | null>
56
+
57
+ export interface PullsOptions {
58
+ readonly run?: GhRunner
59
+ }
60
+
61
+ /**
62
+ * `gh` resolves its repository through the same environment variables git does
63
+ * and they beat `cwd`, so a run from inside a hook would read whichever
64
+ * repository that hook's environment names rather than the target handed here.
65
+ */
66
+ const runGh: GhRunner = async (cwd, args) => {
67
+ if (Bun.which('gh') === null) return null
68
+
69
+ try {
70
+ const result = await execa('gh', [...args], {
71
+ cwd,
72
+ timeout: GH_TIMEOUT_MS,
73
+ env: gitEnv(),
74
+ extendEnv: false,
75
+ })
76
+ return result.stdout
77
+ } catch {
78
+ return null
79
+ }
80
+ }
81
+
82
+ interface RawPull {
83
+ readonly number?: number
84
+ readonly title?: string
85
+ readonly url?: string
86
+ readonly headRefOid?: string
87
+ readonly statusCheckRollup?: readonly RawCheck[]
88
+ }
89
+
90
+ interface RawCheck {
91
+ readonly status?: string
92
+ readonly conclusion?: string
93
+ readonly state?: string
94
+ }
95
+
96
+ interface RawReview {
97
+ readonly body?: string
98
+ readonly submittedAt?: string
99
+ }
100
+
101
+ const FAILED = new Set([
102
+ 'FAILURE',
103
+ 'TIMED_OUT',
104
+ 'CANCELLED',
105
+ 'ACTION_REQUIRED',
106
+ 'STARTUP_FAILURE',
107
+ 'ERROR',
108
+ ])
109
+
110
+ /**
111
+ * Collapses every check on a head into one word.
112
+ *
113
+ * A failure outranks a pending one, because a run still going cannot clear a
114
+ * job that already failed and reporting the head as pending would invite a
115
+ * wait for an answer that has arrived.
116
+ */
117
+ export function rollup(checks: readonly RawCheck[]): ChecksState | null {
118
+ if (checks.length === 0) return null
119
+
120
+ const verdicts = checks.map((check) => check.conclusion ?? check.state ?? '')
121
+
122
+ if (verdicts.some((verdict) => FAILED.has(verdict))) return 'failing'
123
+
124
+ const running = checks.some(
125
+ (check) =>
126
+ (check.status !== undefined && check.status !== 'COMPLETED') ||
127
+ check.state === 'PENDING' ||
128
+ (check.conclusion === undefined && check.state === undefined),
129
+ )
130
+
131
+ return running ? 'pending' : 'passing'
132
+ }
133
+
134
+ /**
135
+ * Reads the heading of the newest pass carrying one, matching on the first line
136
+ * alone the way `poll.sh` does.
137
+ *
138
+ * The reviews arrive oldest first, so the last match is the current state of
139
+ * the thread. A pass carrying neither heading is somebody reviewing by hand and
140
+ * says nothing about whether the loop owes work.
141
+ */
142
+ export function latestReview(
143
+ reviews: readonly RawReview[],
144
+ ): ReviewState | null {
145
+ let state: ReviewState | null = null
146
+
147
+ for (const review of reviews) {
148
+ const first = (review.body ?? '').split('\n')[0]?.replace(/\r$/, '')
149
+ if (first === REVIEW_OPEN) state = 'open'
150
+ else if (first === REVIEW_CLOSED) state = 'closed'
151
+ }
152
+
153
+ return state
154
+ }
155
+
156
+ function parse<T>(text: string | null): T | null {
157
+ if (text === null) return null
158
+ try {
159
+ return JSON.parse(text) as T
160
+ } catch {
161
+ return null
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Reports the open pull requests in one target with their checks and the
167
+ * heading their newest review pass carries.
168
+ *
169
+ * A list that failed and a target with no open pull request are separated
170
+ * rather than collapsed, since reading the first as the second reports a target
171
+ * as done when nothing was read at all. That is the failure mode the shell loop
172
+ * this replaces had no way to surface.
173
+ */
174
+ export async function readTargetPulls(
175
+ path: string,
176
+ opts: PullsOptions = {},
177
+ ): Promise<TargetPulls> {
178
+ const run = opts.run ?? runGh
179
+
180
+ if (!isDirectory(path))
181
+ return { kind: 'refused', path, reason: 'not-a-directory' }
182
+
183
+ if (opts.run === undefined && Bun.which('gh') === null) {
184
+ return { kind: 'refused', path, reason: 'gh-unavailable' }
185
+ }
186
+
187
+ const listed = parse<readonly RawPull[]>(
188
+ await run(path, [
189
+ 'pr',
190
+ 'list',
191
+ '--state',
192
+ 'open',
193
+ '--json',
194
+ 'number,title,url,headRefOid,statusCheckRollup',
195
+ ]),
196
+ )
197
+
198
+ if (listed === null) return { kind: 'refused', path, reason: 'list-failed' }
199
+
200
+ const pulls = await Promise.all(
201
+ listed
202
+ .filter(
203
+ (raw): raw is RawPull & { number: number } =>
204
+ typeof raw.number === 'number',
205
+ )
206
+ .map(async (raw) => {
207
+ // One query per pull request, so a review read that failed surfaces on
208
+ // the thread it failed for rather than emptying the whole target.
209
+ const reviews = parse<{ reviews?: readonly RawReview[] }>(
210
+ await run(path, [
211
+ 'pr',
212
+ 'view',
213
+ String(raw.number),
214
+ '--json',
215
+ 'reviews',
216
+ ]),
217
+ )
218
+
219
+ return {
220
+ number: raw.number,
221
+ title: raw.title ?? '',
222
+ url: raw.url ?? '',
223
+ head: raw.headRefOid ?? '',
224
+ checks: rollup(raw.statusCheckRollup ?? []),
225
+ review: reviews === null ? null : latestReview(reviews.reviews ?? []),
226
+ reviewReadable: reviews !== null,
227
+ }
228
+ }),
229
+ )
230
+
231
+ return { kind: 'read', path, pulls }
232
+ }
233
+
234
+ /**
235
+ * Reads every target, one at a time.
236
+ *
237
+ * The reads are serial rather than batched because each one spends a GitHub API
238
+ * quota shared across all of them, and a wave running over a dozen targets that
239
+ * fired them together would meet the secondary rate limit rather than an answer.
240
+ */
241
+ export async function readPullsAcross(
242
+ paths: readonly string[],
243
+ opts: PullsOptions = {},
244
+ ): Promise<readonly TargetPulls[]> {
245
+ const reports: TargetPulls[] = []
246
+
247
+ for (const path of paths) reports.push(await readTargetPulls(path, opts))
248
+
249
+ return reports
250
+ }