@cat-factory/executor-harness 1.76.2 → 1.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,366 @@
1
+ import { readdir, readFile, realpath } from 'node:fs/promises'
2
+ import { join, sep } from 'node:path'
3
+ import { fencedOutput } from './captured-command.js'
4
+ import type { RepoSpec } from './job.js'
5
+ import type { Logger } from './logger.js'
6
+ import { MAX_PR_BODY_CHARS, PR_DESCRIPTION_FILE } from './pr-description.js'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // THE REPOSITORY'S OWN PULL-REQUEST TEMPLATE.
10
+ //
11
+ // A repo that ships `.github/PULL_REQUEST_TEMPLATE.md` (or GitLab's
12
+ // `.gitlab/merge_request_templates/Default.md`) is stating the shape every pull request against
13
+ // it must take — the sections its reviewers read, the checklist its process requires. A platform
14
+ // that opens PRs there and ignores that is a bad citizen: its pull requests are the only ones on
15
+ // the repo missing the structure everyone else follows.
16
+ //
17
+ // The trap this module exists for is that the template is NOT applied for us. Both hosts
18
+ // interpolate the template only into the WEB form a human opens; a PR created through the REST
19
+ // API gets exactly the body the caller sends. So nothing anywhere fails or warns — the platform's
20
+ // pull requests simply, quietly, don't follow the repo's own convention.
21
+ //
22
+ // The template is FILLED IN BY THE AGENT, not by the platform. Mechanically stuffing the
23
+ // briefing under the first heading would produce a document with the template's shape and none
24
+ // of its meaning: the sections are questions ("what is the risk?", "how was this tested?") that
25
+ // only whoever did the work can answer. So the harness discovers the template and hands it to the
26
+ // agent that just did the work, in the same prompt that already asks it for a briefing — and the
27
+ // agent answers the template's questions instead of writing a free-form one. Zero extra model
28
+ // calls, and the answers come from the run's full context rather than a summary of it.
29
+ //
30
+ // Discovery is HARNESS-side, deliberately, and reads from the checkout on disk. The backend could
31
+ // instead resolve the template through the `RepoFiles` port at dispatch, but that is an HTTP round
32
+ // trip per dispatch to answer a question the container can answer for free — and every dispatch
33
+ // that opens a pull request has a checkout by definition.
34
+ //
35
+ // The filled text goes back out through `readPrDescription`, so it crosses `redactSecrets` and
36
+ // `host-markdown.ts` on the way to the PR exactly as a free-form briefing does. Nothing here
37
+ // widens that boundary: a template is repo-committed text on the way IN, and what the agent
38
+ // writes is model-authored text on the way OUT either way. What it DOES change on the way out is
39
+ // the title heuristic: the headings are now the repo's, so the caller reads the sentinel with
40
+ // `titleFromHeading: false` (see `ReadPrDescriptionOptions`).
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** The VCS providers a repo can live on. Bound to `RepoSpec` so the two cannot drift. */
44
+ type ProviderName = NonNullable<RepoSpec['provider']>
45
+
46
+ /**
47
+ * How much template text is inlined into the agent's prompt.
48
+ *
49
+ * Over this, the template is NAMED rather than inlined and the agent is told to read it from the
50
+ * checkout — which it can, because the file is on disk. That is strictly better than the
51
+ * alternatives: truncating a template would have the agent fill a structure whose tail it never
52
+ * saw (silently dropping the repo's last sections), and skipping it entirely would abandon the
53
+ * feature on exactly the repos with the most demanding process.
54
+ */
55
+ export const MAX_INLINE_PR_TEMPLATE_CHARS = 8_000
56
+
57
+ /**
58
+ * The shared inline budget across a multi-repo run's legs. Each repo's template competes for it in
59
+ * leg order, and a leg that does not fit is NAMED rather than inlined (as above) — so a workspace
60
+ * of four template-carrying repos cannot quietly consume 32k of the agent's prompt.
61
+ */
62
+ export const MAX_TOTAL_INLINE_PR_TEMPLATE_CHARS = 12_000
63
+
64
+ /** Extensions a template file may carry. Both hosts also accept an extensionless file. */
65
+ const TEMPLATE_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
66
+
67
+ /** GitHub's single-file template basename, matched case-insensitively as GitHub itself does. */
68
+ const GITHUB_TEMPLATE_STEM = 'pull_request_template'
69
+
70
+ /**
71
+ * Where a template can live, in each host's OWN precedence order. A `stem` entry is a single file
72
+ * in that directory; a `pick` entry is a directory of templates (see {@link chooseFromDirectory}).
73
+ */
74
+ const GITHUB_LOCATIONS: TemplateLocation[] = [
75
+ { dir: '.github', stem: GITHUB_TEMPLATE_STEM },
76
+ { dir: '', stem: GITHUB_TEMPLATE_STEM },
77
+ { dir: 'docs', stem: GITHUB_TEMPLATE_STEM },
78
+ { dir: '.github/PULL_REQUEST_TEMPLATE', pick: true },
79
+ ]
80
+
81
+ /**
82
+ * GitLab keeps merge-request templates only in a directory — there is no root single-file
83
+ * convention to probe, so none is invented here.
84
+ */
85
+ const GITLAB_LOCATIONS: TemplateLocation[] = [
86
+ { dir: '.gitlab/merge_request_templates', pick: true },
87
+ ]
88
+
89
+ interface TemplateLocation {
90
+ /** Repo-root-relative directory ('' = the root). */
91
+ dir: string
92
+ /** Match a single file with this basename (case-insensitive). */
93
+ stem?: string
94
+ /** Treat the directory as a set of templates and pick one. */
95
+ pick?: boolean
96
+ }
97
+
98
+ /** A discovered pull-request template. */
99
+ export interface PrTemplate {
100
+ /** Repo-root-relative path, forward-slashed (it is prose an agent reads). */
101
+ path: string
102
+ /**
103
+ * The template's size. ALWAYS the real one, including when {@link text} is absent: an over-budget
104
+ * template reporting `chars: 0` would read in the log exactly like an empty file, which is the
105
+ * one thing discovery treats as "no template at all".
106
+ */
107
+ chars: number
108
+ /** The template text. Absent ⇒ over budget, so the agent is told to read {@link path} itself. */
109
+ text?: string
110
+ }
111
+
112
+ /** One checkout to look for a template in. */
113
+ export interface PrTemplateTarget {
114
+ /** The repository checkout root (NOT a monorepo service subtree — a template is a repo fact). */
115
+ repoDir: string
116
+ provider?: ProviderName
117
+ /** Names this repo in the note. Omit when the run has a single checkout ("this repository"). */
118
+ repoLabel?: string
119
+ }
120
+
121
+ /** What {@link resolvePrTemplateNote} tells the run about the templates it found. */
122
+ export interface PrTemplateResolution {
123
+ /** The prompt note, or absent when no target ships a template (which is most repos). */
124
+ note?: string
125
+ /**
126
+ * The `repoDir`s whose briefing is a FILLED TEMPLATE. The push phase reads those sentinels with
127
+ * `titleFromHeading: false`, because the headings in them are the repo's — see
128
+ * `ReadPrDescriptionOptions.titleFromHeading`. A SET keyed by directory rather than a boolean
129
+ * because a multi-repo run's legs need not all ship a template.
130
+ */
131
+ templated: ReadonlySet<string>
132
+ }
133
+
134
+ /** Why a discovered template was named rather than inlined — the two are not the same fix. */
135
+ type NotInlinedReason = 'over-file-budget' | 'over-shared-budget'
136
+
137
+ /**
138
+ * THE entry point: find each target's pull-request template and build the prompt note that asks
139
+ * the agent to fill it, plus the set of checkouts whose sentinel will therefore hold a filled
140
+ * template rather than a free-form briefing (see {@link PrTemplateResolution}).
141
+ *
142
+ * Pass NO targets for a dispatch that opens no pull request — an in-place fixer amending someone
143
+ * else's PR, a read-only explore run. Asking such a run to fill a template would be asking for a
144
+ * document nothing publishes.
145
+ *
146
+ * Never rejects: a template is an improvement to a PR body, so no failure reading one may cost a
147
+ * run that otherwise succeeded. An unreadable or empty template is simply no template.
148
+ */
149
+ export async function resolvePrTemplateNote(args: {
150
+ targets: PrTemplateTarget[]
151
+ logger: Logger
152
+ }): Promise<PrTemplateResolution> {
153
+ const { targets, logger } = args
154
+ const notes: string[] = []
155
+ const templated = new Set<string>()
156
+ let inlineBudget = MAX_TOTAL_INLINE_PR_TEMPLATE_CHARS
157
+ for (const target of targets) {
158
+ const found = await discoverPrTemplate(target.repoDir, target.provider)
159
+ if (!found) continue
160
+ // Spend the shared budget in leg order; a template that no longer fits is named, not cut. The
161
+ // per-file budget was already spent inside discovery, so an absent `text` means THAT ceiling —
162
+ // distinguished in the log because the two want different fixes (a smaller template vs a
163
+ // workspace carrying more template text than one prompt should hold).
164
+ const text = found.text
165
+ const inline = text !== undefined && text.length <= inlineBudget
166
+ if (inline) inlineBudget -= text.length
167
+ const reason: NotInlinedReason | undefined = inline
168
+ ? undefined
169
+ : text === undefined
170
+ ? 'over-file-budget'
171
+ : 'over-shared-budget'
172
+ logger.info('pr template: found', {
173
+ path: found.path,
174
+ chars: found.chars,
175
+ inlined: inline,
176
+ ...(reason ? { reason } : {}),
177
+ ...(target.repoLabel ? { repo: target.repoLabel } : {}),
178
+ })
179
+ templated.add(target.repoDir)
180
+ notes.push(
181
+ buildPrTemplateNote(
182
+ inline ? found : { path: found.path, chars: found.chars },
183
+ target.repoLabel,
184
+ ),
185
+ )
186
+ }
187
+ return { ...(notes.length > 0 ? { note: notes.join('\n\n') } : {}), templated }
188
+ }
189
+
190
+ /**
191
+ * Locate the template in `repoDir`, probing the repo's OWN host convention first and the other
192
+ * host's second — a repo mirrored across both, or one whose provider the dispatcher did not set,
193
+ * still gets its template respected rather than falling to whichever list happened to be first.
194
+ */
195
+ export async function discoverPrTemplate(
196
+ repoDir: string,
197
+ provider?: ProviderName,
198
+ ): Promise<PrTemplate | undefined> {
199
+ const locations =
200
+ provider === 'gitlab'
201
+ ? [...GITLAB_LOCATIONS, ...GITHUB_LOCATIONS]
202
+ : [...GITHUB_LOCATIONS, ...GITLAB_LOCATIONS]
203
+ for (const location of locations) {
204
+ const entries = await listDirectory(join(repoDir, location.dir))
205
+ if (entries.length === 0) continue
206
+ const name = location.pick ? chooseFromDirectory(entries) : chooseSingleFile(entries, location)
207
+ if (!name) continue
208
+ const path = location.dir ? `${location.dir}/${name}` : name
209
+ const text = await readTemplate(join(repoDir, location.dir, name), repoDir)
210
+ // An empty (or unreadable) file imposes no structure, so keep probing: a repo can carry a
211
+ // placeholder at one location and its real template at another.
212
+ if (!text) continue
213
+ const chars = text.length
214
+ return chars <= MAX_INLINE_PR_TEMPLATE_CHARS ? { path, chars, text } : { path, chars }
215
+ }
216
+ return undefined
217
+ }
218
+
219
+ /** Directory entries with their kind, or `[]` for a directory that is absent or unreadable. */
220
+ async function listDirectory(path: string): Promise<{ name: string; directory: boolean }[]> {
221
+ try {
222
+ const entries = await readdir(path, { withFileTypes: true })
223
+ // Sorted so a repo carrying several matches always yields the SAME one: readdir order is
224
+ // filesystem-defined, and a PR body that changed shape between two runs of the same repo
225
+ // would be a genuinely baffling thing to debug.
226
+ return entries
227
+ .map((entry) => ({ name: entry.name, directory: entry.isDirectory() }))
228
+ .sort((a, b) => a.name.localeCompare(b.name))
229
+ } catch {
230
+ return []
231
+ }
232
+ }
233
+
234
+ /**
235
+ * The single-file match. Case-insensitive on both the stem and the extension, and extensionless
236
+ * is allowed because both hosts accept it — which is why the DIRECTORY check matters here:
237
+ * `.github/PULL_REQUEST_TEMPLATE/` is itself an extensionless match for the stem, and reading a
238
+ * directory as a template would produce nothing but a swallowed EISDIR.
239
+ */
240
+ function chooseSingleFile(
241
+ entries: { name: string; directory: boolean }[],
242
+ location: TemplateLocation,
243
+ ): string | undefined {
244
+ return entries.find((entry) => {
245
+ if (entry.directory) return false
246
+ const { stem, extension } = splitName(entry.name)
247
+ return stem === location.stem && (extension === '' || TEMPLATE_EXTENSIONS.has(extension))
248
+ })?.name
249
+ }
250
+
251
+ /**
252
+ * Pick from a directory of templates — GitHub's `.github/PULL_REQUEST_TEMPLATE/`, GitLab's
253
+ * `.gitlab/merge_request_templates/`.
254
+ *
255
+ * A `default` template wins (GitLab applies `Default.md` by itself, so it is unambiguously the
256
+ * one meant for a PR that names none). Failing that, a lone template is taken: a repo with
257
+ * exactly one has expressed exactly one convention.
258
+ *
259
+ * SEVERAL templates with no default yields NOTHING, deliberately. That directory exists so a
260
+ * HUMAN can choose per pull request — "bug report" vs "release" vs "RFC" — and the choice is
261
+ * usually not inferable from a diff. Picking one arbitrarily would file every run's work under
262
+ * whichever name sorts first, which is worse than the free-form briefing: it looks like a
263
+ * deliberate categorisation and is not.
264
+ */
265
+ function chooseFromDirectory(entries: { name: string; directory: boolean }[]): string | undefined {
266
+ const usable = entries.filter(
267
+ (entry) => !entry.directory && TEMPLATE_EXTENSIONS.has(splitName(entry.name).extension),
268
+ )
269
+ const fallback = usable.find((entry) => splitName(entry.name).stem === 'default')
270
+ if (fallback) return fallback.name
271
+ return usable.length === 1 ? usable[0]!.name : undefined
272
+ }
273
+
274
+ /** Lower-cased stem + extension ('' when the name carries none). */
275
+ function splitName(name: string): { stem: string; extension: string } {
276
+ const lower = name.toLowerCase()
277
+ const dot = lower.lastIndexOf('.')
278
+ if (dot <= 0) return { stem: lower, extension: '' }
279
+ return { stem: lower.slice(0, dot), extension: lower.slice(dot) }
280
+ }
281
+
282
+ /**
283
+ * The template's text, or undefined when it is unreadable or carries nothing.
284
+ *
285
+ * A checkout is REPO-AUTHORED, symlinks included, and this is the one read the harness performs on
286
+ * a repo-chosen path without the agent asking for it — so the resolved target must stay inside
287
+ * `repoDir`. A link out of the tree would inline an arbitrary container file (the run's own env,
288
+ * a sibling checkout) into the prompt, and from there into a body only `redactSecrets` stands in
289
+ * front of. Containment rather than a blanket symlink refusal, because a monorepo pointing
290
+ * `.github/PULL_REQUEST_TEMPLATE.md` at a doc it keeps elsewhere in the repo is a real and
291
+ * legitimate layout.
292
+ */
293
+ async function readTemplate(path: string, repoDir: string): Promise<string | undefined> {
294
+ try {
295
+ // Both sides resolved, so the comparison is between two canonical paths — `repoDir` itself is
296
+ // routinely reached through a symlinked temp dir (macOS `/tmp`), which a raw prefix test on the
297
+ // unresolved root would read as an escape.
298
+ const [target, root] = await Promise.all([realpath(path), realpath(repoDir)])
299
+ if (target !== root && !target.startsWith(root.endsWith(sep) ? root : root + sep)) {
300
+ return undefined
301
+ }
302
+ return (await readFile(target, 'utf8')).trim() || undefined
303
+ } catch {
304
+ return undefined
305
+ }
306
+ }
307
+
308
+ /**
309
+ * The prompt note. It has to do more than show the template, because the agent has already been
310
+ * told (by the backend-composed `PR_DESCRIPTION_GUIDANCE`) to write a free-form briefing, and the
311
+ * two genuinely conflict: a template that asks for a test plan or a checklist is asking for
312
+ * exactly the "restated diff" that guidance rules out. So the note states which wins, and states
313
+ * why the template is not already applied — an agent that believes the host will merge the
314
+ * template with its text has no reason to reproduce the structure itself.
315
+ *
316
+ * The template is delimited with `fencedOutput`, the same helper every other captured-text-into-a-
317
+ * prompt path uses. Templates routinely carry fenced blocks of their own, and a fixed three-tick
318
+ * wrapper closes on the first of them — spilling the rest of the template, and the instructions
319
+ * after it, into the prompt as prose. `fencedOutput` sizes the fence one tick longer than the
320
+ * longest run in the body, which is what CommonMark specifies for exactly this, so no template can
321
+ * break out of its own block. A plain `--- BEGIN/END ---` rule would read more nicely and is
322
+ * trivially forgeable by the template's own content, which is the whole thing being defended.
323
+ */
324
+ export function buildPrTemplateNote(template: PrTemplate, repoLabel?: string): string {
325
+ const subject = repoLabel ? `The \`${repoLabel}\` repository` : 'This repository'
326
+ const lead =
327
+ `PULL REQUEST TEMPLATE — ${subject} ships a pull request template at \`${template.path}\` ` +
328
+ '(relative to the repository root). The platform opens the pull request through the host API, ' +
329
+ 'and neither GitHub nor GitLab applies a template to an API-created pull request — that only ' +
330
+ 'happens for a human opening one in the web form. So following it is on you.'
331
+ const instructions =
332
+ `Write \`${PR_DESCRIPTION_FILE}\` as that template, FILLED IN${
333
+ repoLabel ? ` (in the \`${repoLabel}\` checkout)` : ''
334
+ }: keep its headings, their order, and any structure it defines; answer every section from ` +
335
+ 'the work you actually did; delete its instructional HTML comments and any placeholder text; ' +
336
+ 'and complete checklists honestly, ticking only what is true. Leave a section that genuinely ' +
337
+ 'does not apply in place with a brief "n/a" and why, rather than deleting the heading — a ' +
338
+ 'reviewer looking for it needs to see it was considered. Where the template asks for ' +
339
+ 'something the general description guidance does not, the TEMPLATE wins; where it leaves room ' +
340
+ 'for prose, brief it as that guidance describes. The platform rules still hold either way: no ' +
341
+ 'secrets, and no issue/PR numbers, @-mentions, or issue-closing wording. Do NOT put a title ' +
342
+ "line above the template — the platform titles this pull request itself, so the template's " +
343
+ 'own first heading stays the first line of the file. Keep the finished file under ' +
344
+ `${MAX_PR_BODY_CHARS.toLocaleString('en-US')} characters: the platform truncates a longer ` +
345
+ "body, which would cut the template's last sections."
346
+ if (template.text === undefined) {
347
+ // Reached both when the file alone exceeds the inline budget and when a multi-repo run's
348
+ // earlier legs spent the shared one, so the wording states the size and stays true of both.
349
+ return (
350
+ `${lead} ${instructions} The template (${template.chars} characters) is not reproduced ` +
351
+ 'here — read it from the checkout.'
352
+ )
353
+ }
354
+ return `${lead} ${instructions}\n\nThe template follows, in a fenced block that is NOT part of it:\n${fencedOutput(template.text)}`
355
+ }
356
+
357
+ /**
358
+ * Fold the note into a prompt. The sibling of `withDependencyNote`, and deliberately not inlined
359
+ * for the same reason: it rides EVERY agent pass, including the validation and reproduction
360
+ * REPAIR passes. Those start a fresh agent that still carries the description guidance in its
361
+ * system prompt, so one that is not also told about the template would rewrite the briefing
362
+ * free-form and undo the filled template the first pass produced.
363
+ */
364
+ export function withPrTemplateNote(userPrompt: string, note: string | undefined): string {
365
+ return note ? `${userPrompt}\n\n${note}` : userPrompt
366
+ }
package/src/runner.ts CHANGED
@@ -2,6 +2,7 @@ import { redactSecrets } from './redact.js'
2
2
  import type { FollowUpLine } from './follow-ups.js'
3
3
  import type { ValidationReport } from './validation-checks.js'
4
4
  import type { ReproductionReport } from './reproduction-proof.js'
5
+ import type { SliceReview } from './subagents.js'
5
6
  import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js'
6
7
  import { log, type Logger } from './logger.js'
7
8
  import {
@@ -47,6 +48,14 @@ export interface RunOptions {
47
48
  * attempt is final, and the loop republishes a whole new one — with a fresh `at` — per round.
48
49
  */
49
50
  onReproductionProof?: (report: ReproductionReport) => void
51
+ /**
52
+ * Receives the full set of per-slice reviews a parallel review has captured, republished each
53
+ * time a slice's subagent returns. Latest-wins (NOT a drain buffer), for the same reason as
54
+ * {@link onValidationReport} but with more at stake: these carry the slices' actual review work,
55
+ * and a review whose aggregation never finishes is recoverable ONLY from what the backend
56
+ * already persisted. Absent for a job that dispatched no subagents.
57
+ */
58
+ onSliceReviews?: (reviews: SliceReview[]) => void
50
59
  /**
51
60
  * Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
52
61
  * run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
@@ -203,6 +212,18 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
203
212
  * that carried no reproduction declaration.
204
213
  */
205
214
  reproductionReport?: ReproductionReport
215
+ /**
216
+ * The per-slice reviews captured so far on a parallel (subagent-fanned) review — each slice's
217
+ * label, whether its subagent returned, and its verbatim report. A whole-value latest publish
218
+ * like {@link validationReport}, not drain-on-read.
219
+ *
220
+ * This is the durable half of a PR review. The reviewer returns `slices`/`findings` only in its
221
+ * TERMINAL structured output, so before this existed a review killed mid-run (or one whose
222
+ * aggregation pass wedged) lost every finished slice and could only be re-run from zero. The
223
+ * backend persists these onto the step as they arrive, which is what a manual resume re-aggregates
224
+ * from. Absent for a job that dispatched no subagents.
225
+ */
226
+ sliceReviews?: SliceReview[]
206
227
  }
207
228
 
208
229
  interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
@@ -473,6 +494,9 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
473
494
  onValidationReport: (report) => {
474
495
  entry.validationReport = report
475
496
  },
497
+ onSliceReviews: (reviews) => {
498
+ entry.sliceReviews = reviews
499
+ },
476
500
  onReproductionProof: (report) => {
477
501
  entry.reproductionReport = report
478
502
  },
package/src/subagents.ts CHANGED
@@ -4,6 +4,7 @@ import { basename, join } from 'node:path'
4
4
  import {
5
5
  claudeAssistantContent,
6
6
  claudeCallUsage,
7
+ claudeToolResultText,
7
8
  isObject,
8
9
  redactBody,
9
10
  SUBAGENT_TOOL_NAMES,
@@ -54,20 +55,52 @@ import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './
54
55
  // Slice / progress tracking off the PARENT stream (D2.1)
55
56
  // ---------------------------------------------------------------------------
56
57
 
58
+ /**
59
+ * How much of one slice's terminal report is kept. A slice review is prose (findings for a handful
60
+ * of files), not a transcript, so this is far above a real report while still bounding what a
61
+ * runaway subagent can push onto the step — the reports ride the job view on every poll and are
62
+ * persisted on the run.
63
+ */
64
+ export const SLICE_REPORT_MAX_CHARS = 24_000
65
+
57
66
  interface TrackedSlice {
58
67
  /** The dispatch's tool_use id, used to pair the terminal tool_result. */
59
68
  toolUseId: string
60
69
  /** The subagent's description (`Review <slice> slice`), rendered as the progress label. */
61
70
  description: string
62
71
  done: boolean
72
+ /**
73
+ * The subagent's verbatim terminal report, captured from the paired `tool_result`; undefined
74
+ * until it lands. This is the slice's actual review work — the reason a stuck aggregation no
75
+ * longer costs the whole run (see `prReviewSliceReviewSchema`). Truncated to
76
+ * {@link SLICE_REPORT_MAX_CHARS} and scrubbed of leased credentials before it leaves here.
77
+ */
78
+ report?: string
79
+ }
80
+
81
+ /** One slice's live review, as published on the job view. Mirrors `prReviewSliceReviewSchema`. */
82
+ export interface SliceReview {
83
+ label: string
84
+ status: 'in_progress' | 'completed'
85
+ report?: string | null
63
86
  }
64
87
 
65
88
  /** Tracks parallel subagents seen on the parent stream to derive slice progress. */
66
89
  export interface SliceTracker {
67
90
  /** Feed an `assistant` message's content blocks: registers any subagent dispatches. */
68
91
  onAssistant(content: unknown[]): void
69
- /** Feed a `user` message's content blocks: marks the paired subagent(s) complete. */
92
+ /**
93
+ * Feed a `user` message's content blocks: marks the paired subagent(s) complete AND captures
94
+ * each one's terminal report (see {@link SliceTracker.sliceReviews}).
95
+ */
70
96
  onUser(content: unknown[]): void
97
+ /**
98
+ * Every dispatched slice with its status and captured report, in dispatch order — the durable
99
+ * half of this tracker. Published as a whole value on each poll (NOT drain-on-read): the set
100
+ * only grows, and a dropped poll response must never permanently lose a finished slice's
101
+ * review, which is the entire point of capturing it. Empty when nothing was dispatched.
102
+ */
103
+ sliceReviews(): SliceReview[]
71
104
  /** Whether any `Task` subagent has been dispatched (⇒ this run parallelised). */
72
105
  hasSlices(): boolean
73
106
  /**
@@ -80,7 +113,12 @@ export interface SliceTracker {
80
113
  progress(): TodoProgress | undefined
81
114
  }
82
115
 
83
- export function createSliceTracker(): SliceTracker {
116
+ /**
117
+ * @param secrets Leased-credential strings scrubbed from every captured report. A subagent can
118
+ * echo a token it saw in the checkout, and these reports are persisted on the run, so they are
119
+ * redacted on the way in rather than trusting each consumer to do it.
120
+ */
121
+ export function createSliceTracker(secrets: string[] = []): SliceTracker {
84
122
  // Insertion-ordered so the progress `items` render in dispatch order.
85
123
  const slices = new Map<string, TrackedSlice>()
86
124
 
@@ -106,9 +144,25 @@ export function createSliceTracker(): SliceTracker {
106
144
  if (!isObject(block) || block.type !== 'tool_result') continue
107
145
  const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
108
146
  const slice = id ? slices.get(id) : undefined
109
- if (slice) slice.done = true
147
+ if (!slice) continue
148
+ slice.done = true
149
+ // The report is captured here or nowhere: this `tool_result` is the only place the
150
+ // subagent's findings appear on the parent stream, and the next poll may be the last one
151
+ // this job ever answers.
152
+ const report = redactBody(claudeToolResultText(block), secrets).trim()
153
+ if (report) slice.report = report.slice(0, SLICE_REPORT_MAX_CHARS)
110
154
  }
111
155
  },
156
+ sliceReviews() {
157
+ return [...slices.values()].map((s) => ({
158
+ label: s.description,
159
+ status: (s.done ? 'completed' : 'in_progress') as 'completed' | 'in_progress',
160
+ // A slice that finished but whose result carried no readable text is reported as
161
+ // completed with a null report rather than being dropped: a resume must still know it
162
+ // does not need re-reviewing, and silently omitting it would send it round again.
163
+ report: s.report ?? null,
164
+ }))
165
+ },
112
166
  hasSlices() {
113
167
  return slices.size > 0
114
168
  },