@cat-factory/executor-harness 1.62.0 → 1.64.2

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.
package/src/progress.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { isObject } from './claude-stream.js'
2
- import type { TodoProgress } from './pi.js'
2
+ import type { TodoItem, TodoProgress } from './pi.js'
3
3
 
4
- // The parent agent's own PLAN, as progress counts. This is one of the two redundant views a
5
- // pr-reviewer run produces (the other is the parallel-subagent dispatch view in
6
- // `subagents.ts`); {@link pickProgress} reconciles them.
4
+ // The parent agent's own PLAN, as progress counts. This is one of the two views a pr-reviewer
5
+ // run produces of the same slicing (the other is the parallel-subagent dispatch view in
6
+ // `subagents.ts`). The plan is the INVENTORY, the dispatches are the live STATUS, and
7
+ // {@link mergeProgress} folds them into the one list the board renders.
7
8
  //
8
9
  // The Claude Code CLI exposes the plan through TWO different tool vocabularies, and which one
9
10
  // a run uses depends on the CLI build, not on anything the harness controls:
@@ -207,17 +208,14 @@ export function createTaskPlanTracker(): TaskPlanTracker {
207
208
  }
208
209
 
209
210
  /**
210
- * Reconcile the redundant views of the same work into the one to surface (ADR 0027 Defect B).
211
- * A pr-reviewer run has BOTH a parent plan (`TodoWrite` or `TaskCreate`/`TaskUpdate`) and the
212
- * `SliceTracker`'s subagent-dispatch view. The sequential shape advances the plan; the parallel
213
- * shape advances ONLY the slice tracker (the reviewer writes its plan once and the parallel
214
- * subagents report in-flight/complete). Neither alone covers both shapes, and gating the slice
215
- * tracker off whenever a plan exists (the original behaviour) pinned parallel runs at 0%.
216
- *
217
- * So prefer whichever view is further along: more `completed`, then more `inProgress` (an
218
- * all-pending plan must not beat live in-flight slices), then more `total` (the richer view — a
219
- * plan can carry an extra "aggregate" entry), else the plan. Pure + total; returns whichever
211
+ * Reconcile the parent's TWO plan vocabularies (`TodoWrite` snapshots vs the incremental
212
+ * `TaskCreate`/`TaskUpdate` pair) into one plan. A run uses one or the other, so this is a
213
+ * genuine either/or: prefer whichever is further along more `completed`, then more
214
+ * `inProgress`, then more `total` else the `TodoWrite` view. Pure + total; returns whichever
220
215
  * single input is present when only one is.
216
+ *
217
+ * This is NOT how the plan reconciles with the parallel-subagent view — those describe the same
218
+ * slices from two angles and are MERGED, see {@link mergeProgress}.
221
219
  */
222
220
  export function pickProgress(
223
221
  todo: TodoProgress | undefined,
@@ -230,3 +228,129 @@ export function pickProgress(
230
228
  if (slice.total !== todo.total) return slice.total > todo.total ? slice : todo
231
229
  return todo
232
230
  }
231
+
232
+ /** Status ordering, so a merge can only ever ADVANCE an entry, never walk it back. */
233
+ const STATUS_RANK: Record<ReturnType<typeof normalizeStatus>, number> = {
234
+ pending: 0,
235
+ in_progress: 1,
236
+ completed: 2,
237
+ }
238
+
239
+ /**
240
+ * Words that carry no identity in a slice label, so `Review identity/auth slice` (the subagent
241
+ * description) and `identity/auth` (the plan entry's subject) compare equal.
242
+ */
243
+ const LABEL_FILLER = new Set([
244
+ 'a',
245
+ 'agent',
246
+ 'an',
247
+ 'and',
248
+ 'chunk',
249
+ 'chunks',
250
+ 'for',
251
+ 'of',
252
+ 'pass',
253
+ 'review',
254
+ 'reviewing',
255
+ 'slice',
256
+ 'slices',
257
+ 'subagent',
258
+ 'the',
259
+ ])
260
+
261
+ /**
262
+ * A slice label reduced to its identifying words, for pairing a plan entry with the subagent
263
+ * dispatched to review it. Case, punctuation and the boilerplate around the slice name all
264
+ * differ between the two vocabularies; the slice NAME does not.
265
+ */
266
+ export function sliceLabelKey(label: string): string {
267
+ return label
268
+ .toLowerCase()
269
+ .replace(/[^a-z0-9]+/g, ' ')
270
+ .split(' ')
271
+ .filter((w) => w.length > 0 && !LABEL_FILLER.has(w))
272
+ .join(' ')
273
+ }
274
+
275
+ interface MergeEntry {
276
+ label: string
277
+ status: ReturnType<typeof normalizeStatus>
278
+ key: string
279
+ /** A dispatched subagent has already been paired to this entry. */
280
+ paired: boolean
281
+ }
282
+
283
+ /** Advance an entry to the stronger of its current status and the dispatch's. */
284
+ function advance(entry: MergeEntry, status: ReturnType<typeof normalizeStatus>): void {
285
+ if (STATUS_RANK[status] > STATUS_RANK[entry.status]) entry.status = status
286
+ entry.paired = true
287
+ }
288
+
289
+ /**
290
+ * MERGE the parent's plan with the `SliceTracker`'s subagent-dispatch view into the single list
291
+ * the board renders (ADR 0027 Defect B, corrected).
292
+ *
293
+ * The two are not competing answers, they are two halves of one: the plan is the INVENTORY (it
294
+ * names every slice, including the ones not dispatched yet, which is the only place a `pending`
295
+ * slice exists at all), and the dispatch view is the live STATUS (the plan advances only when
296
+ * the agent remembers to update it, which it does unreliably). Picking whichever looked "further
297
+ * along" — the previous behaviour — made the rendered list SHRINK the moment the first subagent
298
+ * returned: the dispatch view won on `completed`, and it only knows the slices dispatched so far,
299
+ * so every queued slice vanished from the window and reappeared one at a time as it was dispatched.
300
+ *
301
+ * Pairing is by normalised label ({@link sliceLabelKey}) — exact first, then containment — and
302
+ * finally positionally into the leftover pending entries, in dispatch order (the agent dispatches
303
+ * in plan order). A dispatch that pairs with nothing is APPENDED rather than dropped, so the list
304
+ * is at worst a union and can never lose a slice. Statuses only ever advance, so a plan entry the
305
+ * agent already marked done is not walked back by a re-dispatch.
306
+ *
307
+ * Pure + total. Falls back to {@link pickProgress} when either side carries counts but no items
308
+ * (nothing to merge onto).
309
+ */
310
+ export function mergeProgress(
311
+ plan: TodoProgress | undefined,
312
+ slice: TodoProgress | undefined,
313
+ ): TodoProgress | undefined {
314
+ if (!plan) return slice
315
+ if (!slice) return plan
316
+ const planItems = plan.items ?? []
317
+ const sliceItems = slice.items ?? []
318
+ if (planItems.length === 0 || sliceItems.length === 0) return pickProgress(plan, slice)
319
+
320
+ const entries: MergeEntry[] = planItems.map((i) => ({
321
+ label: i.label,
322
+ status: normalizeStatus(i.status),
323
+ key: sliceLabelKey(i.label),
324
+ paired: false,
325
+ }))
326
+ const take = (match: (e: MergeEntry) => boolean): MergeEntry | undefined =>
327
+ entries.find((e) => !e.paired && match(e))
328
+
329
+ // Pass 1 — the same slice named the same way.
330
+ // Pass 2 — one label contains the other (a dispatch description often expands the plan's short
331
+ // name). Length-guarded so a one-word residue can't match everything.
332
+ // Pass 3 — no words in common at all (renamed between planning and dispatch): absorb into the
333
+ // still-untouched pending entries in dispatch order.
334
+ // Anything still unpaired is a slice the plan never mentioned, so it JOINS the list.
335
+ const matchers: ((key: string) => (e: MergeEntry) => boolean)[] = [
336
+ (key) => (e) => key.length > 0 && e.key === key,
337
+ (key) => (e) =>
338
+ key.length >= 3 && e.key.length >= 3 && (e.key.includes(key) || key.includes(e.key)),
339
+ () => (e) => e.status === 'pending',
340
+ ]
341
+ let unpaired: TodoItem[] = sliceItems
342
+ for (const matcher of matchers) {
343
+ const rest: TodoItem[] = []
344
+ for (const item of unpaired) {
345
+ const hit = take(matcher(sliceLabelKey(item.label)))
346
+ if (hit) advance(hit, normalizeStatus(item.status))
347
+ else rest.push(item)
348
+ }
349
+ unpaired = rest
350
+ }
351
+
352
+ return toProgress([
353
+ ...entries.map((e) => ({ label: e.label, status: e.status })),
354
+ ...unpaired.map((i) => ({ label: i.label, status: normalizeStatus(i.status) })),
355
+ ])
356
+ }
package/src/subagents.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { readdir, stat } from 'node:fs/promises'
2
2
  import { createReadStream, type Dirent } from 'node:fs'
3
3
  import { basename, join } from 'node:path'
4
- import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js'
4
+ import {
5
+ claudeAssistantContent,
6
+ claudeCallUsage,
7
+ isObject,
8
+ redactBody,
9
+ SUBAGENT_TOOL_NAMES,
10
+ } from './claude-stream.js'
5
11
  import type { Logger } from './logger.js'
6
12
  import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './pi.js'
7
13
 
@@ -42,21 +48,6 @@ import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './
42
48
  // Slice / progress tracking off the PARENT stream (D2.1)
43
49
  // ---------------------------------------------------------------------------
44
50
 
45
- /**
46
- * The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
47
- * shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
48
- * `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
49
- * harness runs against whatever CLI the image happens to bundle, and matching only the old name
50
- * is what left a CLI 2.1.x pr-review reporting no slices at all.
51
- *
52
- * Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
53
- * FALSE signal rather than merely no signal — if a future build were to name a plain task-list
54
- * tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
55
- * build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
56
- * `progress.ts`), and dropping legacy coverage is the more likely regression.
57
- */
58
- const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task'])
59
-
60
51
  interface TrackedSlice {
61
52
  /** The dispatch's tool_use id, used to pair the terminal tool_result. */
62
53
  toolUseId: string