pi-harness-delegate 0.2.2 → 0.3.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.
package/README.md CHANGED
@@ -39,18 +39,34 @@ Only the prompt is required. A **harness as first word** and/or **mode as next w
39
39
  Some modes have **default tasks** when the prompt is omitted:
40
40
  `/delegate review` reviews the current git diff (`scope: diff`), `/delegate security-audit` audits the repo. Modes without a default (`plan`, `implement`, `docs`, `general`) print a hint asking for a prompt.
41
41
 
42
- The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff, `pr` = PR diff, path list, or whole repo), `model`, `maxBudgetUsd`, `allowDangerous`, `sessionId`, `pr`.
42
+ The `delegate` tool takes: `harness`, `task`, `mode`, `scope` (`diff` = git diff, `pr` = PR diff, path list, or whole repo), `model`, `maxBudgetUsd`, `allowDangerous`, `sessionId`, `pr`. (`verify` is deliberately *not* a tool parameter — see below.)
43
43
 
44
44
  `claude_delegate` remains as a deprecated alias for `delegate{harness:claude}`.
45
45
 
46
+ ### Fan out to multiple harnesses
47
+
48
+ `harness` also accepts `all` or a comma-separated list — the same task runs on every harness, sequentially, and comes back as one comparison report instead of one report per harness:
49
+
50
+ ```bash
51
+ /delegate all review the auth flow # every *detected* harness
52
+ /delegate claude,codex plan the migration # just these two
53
+ delegate({ harness: "all", mode: "review", scope: "diff" }) # tool call form
54
+ ```
55
+
56
+ - `all` resolves to whatever's actually installed (`detectAll()`) — an uninstalled harness is skipped and named in the report, it doesn't fail the run. An explicit list is validated the same way; an unknown name is also reported rather than aborting the rest.
57
+ - Each harness's run goes through the same `delegate()` engine as a single-harness call and writes its own transcript to its own `~/.pi/agent/delegate/outputs/<harness>/`; a fan-out costs roughly N× a single run (respects `maxBudgetUsd` per run) and always runs sequentially, respecting `maxConcurrent`.
58
+ - The synthesized report groups each harness's metrics + output and a total spend line (unknown-cost runs called out separately, same as `/delegate status`) — it's assembled mechanically, not by asking a model to summarize.
59
+ - A single-harness call (`harness: "claude"`, or omitted) behaves exactly as before — fan-out is opt-in by typing `all`/a list.
60
+ - `/delegate all …` batches successful completions into one notification instead of one per harness; a failure is never delayed or folded into the batch — it surfaces immediately.
61
+
46
62
  ## Harnesses
47
63
 
48
64
  | Harness | Binary | Permission mapping | Notes |
49
65
  | --- | --- | --- | --- |
50
- | `claude` | `claude` | `readonly→plan`, `edit→acceptEdits`, `danger→bypassPermissions` | Full stream-json, cost + context% |
51
- | `codex` | `codex` | `readonly→read-only`, `edit→workspace-write`, `danger→danger-full-access` | `codex exec --json`, best-effort JSONL |
52
- | `opencode` | `opencode` | `readonly→read-only`, `edit→allow-edit`, `danger→danger` | `opencode run --format json` |
53
- | `amp` | `amp` (`omp` alias) | `readonly→read-only`, `edit→workspace`, `danger→danger` | `amp --output jsonl` |
66
+ | `claude` | `claude` | `readonly→plan`, `edit→acceptEdits`, `danger→bypassPermissions` | Full stream-json, cost + context%. Schema-verified against Claude Code 2.1.247. |
67
+ | `codex` | `codex` | `readonly→read-only`, `edit→workspace-write`, `danger→danger-full-access` | `codex exec --json`. Schema-verified against codex-cli 0.149.1; cost is always unmeasured (`null`) on ChatGPT-plan auth. |
68
+ | `opencode` | `opencode` | `readonly→read-only`, `edit→allow-edit`, `danger→danger` | `opencode run --format json`. Schema-verified against opencode 1.18.16. |
69
+ | `amp` | `amp` (`omp` alias) | `readonly→read-only`, `edit→workspace`, `danger→danger` | `<binary> -p --mode json`, resolves whichever of `amp`/`omp` is actually on `PATH`. Schema-verified against omp 17.2.9 (Sourcegraph's real Amp CLI is unverified). |
54
70
 
55
71
  Detect availability: `delegate` checks `harness --version` at startup; missing harnesses hint install instructions.
56
72
 
@@ -69,19 +85,24 @@ Each mode is a markdown template with frontmatter:
69
85
 
70
86
  ```yaml
71
87
  ---
72
- name: review
73
- description: Code review of a scope. Read-only.
74
- permission: readonly # normalized: readonly | edit | danger
88
+ name: implement
89
+ description: Implement a task with file edits. Runs checks.
90
+ permission: edit # normalized: readonly | edit | danger
75
91
  model: sonnet # or gpt-5 for codex, etc. Aliases resolved via modelAliases
76
- defaultTask: Review the current git diff
77
- defaultScope: diff
92
+ verify: bun test # optional — host-run check after the harness exits
78
93
  ---
79
- You are a senior code reviewer delegated by the pi coding agent.
94
+ You are a senior engineer delegated by the pi coding agent.
80
95
  ...
81
96
  ```
82
97
 
83
98
  **Native escape hatch:** if you need a harness-specific permission not covered by the normalized set, use the native key (`permissionMode: dontAsk`, `sandbox: ...`) — it overrides `permission` for that harness.
84
99
 
100
+ **Verify:** `verify` is a shell command run **on the host** (never handed to the harness) right after it exits — e.g. `verify: bun test` on an `implement`/`docs`/`general` template turns "the harness says it's done" into an actual pass/fail. It's report-only: a failing verify is appended as its own section in the transcript and injected report, and surfaced in the tool result's `details.verify` (`{command, exitCode, ok}`), but it never changes whether the run itself is reported as an error — that stays whatever the harness reported. No template ships one by default — there's no universally-correct check command, so nothing is invented for you.
101
+
102
+ - **Sources, deliberately limited:** a verify command can only come from a template's `verify:` frontmatter, or a human typing `/delegate --verify="<cmd>"` (quotes needed for multi-word commands) — the call-level value wins over the template's. **It is not a parameter on the `delegate` tool** — that's on purpose, not an oversight: a tool param is set by the model, and the model's context includes repo content and delegated-harness output, both of which an attacker could influence, so a model-settable verify command would be a prompt-injection → arbitrary-host-command path. A model that wants verification simply picks a template that declares one.
103
+ - **Never runs on a `readonly` template.** `readonly` (`review`/`plan`/`security-audit`) guarantees no execution or modification — a verify command riding along on one would quietly break that guarantee. If a `readonly` template (or override) has a `verify` configured, it's recorded as skipped (`### Verify: \`cmd\`` / `⊘ skipped (readonly run)`) rather than run, and never silently dropped.
104
+ - A project-local template's `verify` command is gated by the same trust check (`PI_TRUSTED=1` / `.pi/trusted`) as the rest of the template.
105
+
85
106
  **Template sources (later wins):**
86
107
 
87
108
  - `templates/shared/*.md` — portable prompt bodies
@@ -141,7 +162,7 @@ In `~/.pi/agent/settings.json`:
141
162
  Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (deprecated).
142
163
 
143
164
  - `modelAliases` — templates may use `economy|balanced|max` or any alias; resolution: call → template → harness → global.
144
- - `maxConcurrent` — cap overlapping runs (default 1 global; may be `{global:1, perHarness:{claude:1}}`).
165
+ - `maxConcurrent` — cap overlapping runs (default 1 global; may be `{global:1, perHarness:{claude:1}}`). Enforced across pi processes, not just the current one — a file-based registry under `~/.pi/agent/delegate/runs/` tracks active runs.
145
166
  - `maxTranscripts` — oldest transcripts pruned beyond this count per harness (`0` disables).
146
167
 
147
168
  `autoDelegateHints` is off by default — no system-prompt bias. When `true`, explicit markers (`@harness`, `with codex`, `delegate … to claude`) and imperative review/plan phrasing append a hint.
@@ -150,6 +171,8 @@ Legacy `claudeDelegate` is auto-migrated into `delegate.harnesses.claude` (depre
150
171
 
151
172
  Every run records in details + transcript: harness, mode, permission (normalized + native), cost, tokens (input/output/cache), context% (prompt ÷ window), model, turns, duration, TTFT, stop reason, session id. Token + cost feed pi's `Usage`.
152
173
 
174
+ Claude reports turns and cost on every run; Codex/OpenCode/Amp don't always. An unmeasured turn count or cost renders as `—`/`n/a` (never `0`/`$0.000`) everywhere it's shown — the transcript header, `formatMetrics`, tool results, and `/delegate history` — so an unmeasured run is never mistaken for a free one. `/delegate status` shows a per-harness spend rollup (e.g. `$1.234 over 12 run(s) (3 unknown)`); runs with unknown cost are counted separately rather than folded into the total as `$0`.
175
+
153
176
  ## Security model
154
177
 
155
178
  - `readonly` — no edits (e.g. Claude `plan`, Codex `read-only`).
@@ -1,6 +1,6 @@
1
1
  import { readdirSync, rmSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import type { ActivityEvent } from './harnesses/types.ts';
3
+ import type { ActivityEvent, NormalizedPermission } from './harnesses/types.ts';
4
4
 
5
5
  function truncate(s: string, max: number): string {
6
6
  return s.length > max ? `${s.slice(0, max - 1)}…` : s;
@@ -13,18 +13,18 @@ export function safeSegmentName(name: string): string {
13
13
  }
14
14
 
15
15
  export interface MetricsInput {
16
- numTurns: number;
17
- totalCostUsd: number;
16
+ numTurns: number | null;
17
+ totalCostUsd: number | null;
18
18
  promptTokens: number;
19
19
  contextPercent: number | null;
20
20
  durationMs: number | null;
21
21
  }
22
22
 
23
- /** Compact run summary: `3 turn(s) · $0.54 · 62k tok · 6.2% ctx · 12s`. */
23
+ /** Compact run summary: `3 turn(s) · $0.54 · 62k tok · 6.2% ctx · 12s`. Unknown numTurns/cost render as `—`. */
24
24
  export function formatMetrics(m: MetricsInput): string {
25
25
  const parts: Array<string | null> = [
26
- `${m.numTurns} turn(s)`,
27
- `$${m.totalCostUsd.toFixed(3)}`,
26
+ m.numTurns !== null ? `${m.numTurns} turn(s)` : '— turn(s)',
27
+ m.totalCostUsd !== null ? `$${m.totalCostUsd.toFixed(3)}` : '$—',
28
28
  m.promptTokens > 0 ? `${Math.round(m.promptTokens / 1000)}k tok` : null,
29
29
  typeof m.contextPercent === 'number' ? `${m.contextPercent.toFixed(1)}% ctx` : null,
30
30
  typeof m.durationMs === 'number' && m.durationMs !== null ? `${(m.durationMs / 1000).toFixed(0)}s` : null,
@@ -32,15 +32,15 @@ export function formatMetrics(m: MetricsInput): string {
32
32
  return parts.filter((p): p is string => Boolean(p)).join(' · ');
33
33
  }
34
34
 
35
- /** Parse the metadata header of a transcript file (without loading the whole body). */
35
+ /** Parse the metadata header of a transcript file (without loading the whole body). `cost` is null when unknown. */
36
36
  export function parseTranscriptMeta(head: string): {
37
37
  mode: string;
38
- cost: number;
38
+ cost: number | null;
39
39
  sessionId: string | null;
40
40
  harness: string | null;
41
41
  } {
42
42
  let mode = 'delegate';
43
- let cost = 0;
43
+ let cost: number | null = null;
44
44
  let sessionId: string | null = null;
45
45
  let harness: string | null = null;
46
46
  const mm = /^# Delegated (?:Claude|Harness) run — (.+)$/m.exec(head);
@@ -58,6 +58,117 @@ export function parseTranscriptMeta(head: string): {
58
58
  return { mode, cost, sessionId, harness };
59
59
  }
60
60
 
61
+ /** One history entry's harness + cost, for spend aggregation. */
62
+ export interface SpendEntry {
63
+ harness: string;
64
+ cost: number | null;
65
+ }
66
+
67
+ /** Total cost, run count and unknown-cost run count — either per harness or overall. */
68
+ export interface HarnessSpend {
69
+ totalCostUsd: number;
70
+ runs: number;
71
+ unknownRuns: number;
72
+ }
73
+
74
+ /** Roll up cost across history entries, per harness and overall. Runs with unknown cost are counted
75
+ * separately rather than silently treated as $0. Pure — testable without the TUI. */
76
+ export function aggregateSpend(entries: SpendEntry[]): {
77
+ byHarness: Record<string, HarnessSpend>;
78
+ total: HarnessSpend;
79
+ } {
80
+ const byHarness: Record<string, HarnessSpend> = {};
81
+ const total: HarnessSpend = { totalCostUsd: 0, runs: 0, unknownRuns: 0 };
82
+ for (const e of entries) {
83
+ if (!byHarness[e.harness]) byHarness[e.harness] = { totalCostUsd: 0, runs: 0, unknownRuns: 0 };
84
+ const h = byHarness[e.harness];
85
+ h.runs++;
86
+ total.runs++;
87
+ if (e.cost === null) {
88
+ h.unknownRuns++;
89
+ total.unknownRuns++;
90
+ } else {
91
+ h.totalCostUsd += e.cost;
92
+ total.totalCostUsd += e.cost;
93
+ }
94
+ }
95
+ return { byHarness, total };
96
+ }
97
+
98
+ /** Format a `HarnessSpend` as `$1.234 over 12 run(s) (3 unknown)`. */
99
+ export function formatSpend(s: HarnessSpend): string {
100
+ const unknown = s.unknownRuns > 0 ? ` (${s.unknownRuns} unknown)` : '';
101
+ return `$${s.totalCostUsd.toFixed(3)} over ${s.runs} run(s)${unknown}`;
102
+ }
103
+
104
+ /**
105
+ * Host-run verification (e.g. `bun test`) result — evidence only, never flips a run's `isError`.
106
+ * `skipped` is set (with a human-readable reason) when a verify command was configured but
107
+ * deliberately not executed — e.g. a `readonly` permission tier — instead of `exitCode`/`ok`
108
+ * describing a real run. Never silently dropped: a skip is still recorded and reported.
109
+ */
110
+ export interface VerifyResult {
111
+ command: string;
112
+ /** `null` when the command was never run (see `skipped`). */
113
+ exitCode: number | null;
114
+ ok: boolean;
115
+ /** Combined stdout+stderr, capped to the last `maxTail` chars. Empty when skipped. */
116
+ outputTail: string;
117
+ /** Reason the command was not run, e.g. `"readonly run"`. Absent when it actually ran. */
118
+ skipped?: string;
119
+ }
120
+
121
+ const VERIFY_TAIL_MAX = 2000;
122
+
123
+ /** Which verify command applies for a run: an explicit per-call override wins over the template's. */
124
+ export function resolveVerifyCommand(
125
+ callOverride: string | undefined,
126
+ templateVerify: string | undefined,
127
+ ): string | undefined {
128
+ return callOverride || templateVerify || undefined;
129
+ }
130
+
131
+ /**
132
+ * Resolve the verify command AND decide whether it may actually run, given the run's effective
133
+ * permission. `readonly` templates guarantee no execution/modification — a verify command must
134
+ * never ride along on one (see AGENTS.md's Conventions), so `skip` is true whenever `permission`
135
+ * is `readonly`, regardless of who configured the command. Pure — testable without `pi.exec`.
136
+ */
137
+ export function resolveVerifyPlan(
138
+ callOverride: string | undefined,
139
+ templateVerify: string | undefined,
140
+ permission: NormalizedPermission,
141
+ ): { command: string; skip: boolean } | undefined {
142
+ const command = resolveVerifyCommand(callOverride, templateVerify);
143
+ if (!command) return undefined;
144
+ return { command, skip: permission === 'readonly' };
145
+ }
146
+
147
+ /** Build a `VerifyResult` from a raw exit code + combined output, capping the tail. Pure. */
148
+ export function buildVerifyResult(
149
+ command: string,
150
+ exitCode: number,
151
+ output: string,
152
+ maxTail = VERIFY_TAIL_MAX,
153
+ ): VerifyResult {
154
+ const outputTail = output.length > maxTail ? output.slice(-maxTail) : output;
155
+ return { command, exitCode, ok: exitCode === 0, outputTail };
156
+ }
157
+
158
+ /** A `VerifyResult` recording that a configured verify command was deliberately not run. */
159
+ export function skipVerifyResult(command: string, reason: string): VerifyResult {
160
+ return { command, exitCode: null, ok: false, outputTail: '', skipped: reason };
161
+ }
162
+
163
+ /** Markdown section for a verify result — report-only evidence, distinct from the harness's own isError. */
164
+ export function formatVerifySection(v: VerifyResult): string {
165
+ if (v.skipped) return `### Verify: \`${v.command}\`\n⊘ skipped (${v.skipped})`;
166
+ const lines = [`### Verify: \`${v.command}\``, v.ok ? `✓ exit ${v.exitCode}` : `✗ exit ${v.exitCode}`];
167
+ const tail = v.outputTail.trim();
168
+ if (tail) lines.push(...tail.split('\n').map(l => ` ${l}`));
169
+ return lines.join('\n');
170
+ }
171
+
61
172
  /** Build the markdown report content injected into the session on the next turn. */
62
173
  export function buildReportContent(opts: {
63
174
  harness?: string;
@@ -66,6 +177,7 @@ export function buildReportContent(opts: {
66
177
  body: string;
67
178
  file?: string;
68
179
  sessionId?: string;
180
+ verify?: VerifyResult;
69
181
  }): string {
70
182
  const harness = opts.harness ?? 'claude';
71
183
  const header = `## ${harness} ${opts.mode} (${opts.metrics})`;
@@ -75,7 +187,50 @@ export function buildReportContent(opts: {
75
187
  foot.push(
76
188
  `resume: \`/delegate --harness=${opts.harness} --resume=${opts.sessionId} <prompt>\` (or /${opts.harness} --resume=${opts.sessionId})`,
77
189
  );
78
- return [header, '', opts.body, foot.length > 0 ? `\n_${foot.join(' · ')}_` : ''].join('\n');
190
+ const verifySection = opts.verify ? `\n${formatVerifySection(opts.verify)}\n` : '';
191
+ return [header, '', opts.body, verifySection, foot.length > 0 ? `\n_${foot.join(' · ')}_` : ''].join('\n');
192
+ }
193
+
194
+ /** One harness's outcome within a fan-out, for the synthesized comparison report. */
195
+ export interface FanoutRunSummary {
196
+ harness: string;
197
+ ok: boolean;
198
+ /** `formatMetrics` output — omitted (and `error` used instead) when the run failed to complete. */
199
+ metrics?: string;
200
+ cost: number | null;
201
+ body?: string;
202
+ file?: string;
203
+ sessionId?: string;
204
+ error?: string;
205
+ verify?: VerifyResult;
206
+ }
207
+
208
+ /**
209
+ * Mechanically assemble one comparison report across all fan-out runs — no second model call.
210
+ * Groups per-harness metrics/output and rolls up total spend via `aggregateSpend`/`formatSpend`.
211
+ * Header-free — callers wrap this body with their own `## <label> (...)` header.
212
+ */
213
+ export function buildFanoutReport(opts: { runs: FanoutRunSummary[]; skipped: string[]; unknown: string[] }): string {
214
+ const lines: string[] = [];
215
+ if (opts.unknown.length > 0) lines.push(`_unknown harness(es), skipped: ${opts.unknown.join(', ')}_`);
216
+ if (opts.skipped.length > 0) lines.push(`_not installed, skipped: ${opts.skipped.join(', ')}_`);
217
+ if (opts.unknown.length > 0 || opts.skipped.length > 0) lines.push('');
218
+
219
+ const spend = aggregateSpend(opts.runs.map(r => ({ harness: r.harness, cost: r.cost })));
220
+ lines.push(`**Total spend:** ${formatSpend(spend.total)}`, '');
221
+
222
+ for (const r of opts.runs) {
223
+ lines.push(`### ${r.harness}${r.ok ? '' : ' — failed'}`);
224
+ if (r.ok) {
225
+ lines.push(r.metrics ?? '', '', r.body ?? '(empty)');
226
+ } else {
227
+ lines.push(`error: ${r.error ?? 'unknown error'}`);
228
+ }
229
+ if (r.verify) lines.push('', formatVerifySection(r.verify));
230
+ if (r.file) lines.push('', `_transcript: ${r.file}_`);
231
+ lines.push('');
232
+ }
233
+ return lines.join('\n').trimEnd();
79
234
  }
80
235
 
81
236
  /** Legacy wrapper for compat */
@@ -124,16 +279,54 @@ export function formatToolUse(name: string, input: Record<string, unknown>): str
124
279
  return first ? `${name}: ${truncate(first, 90)}` : name;
125
280
  }
126
281
 
282
+ /**
283
+ * Tracks pending `tool_input` array indices by id, so a later `tool_result` can be matched to
284
+ * the row it actually belongs to instead of always landing on the last row.
285
+ *
286
+ * A harness emits N `tool_input` events followed by N `tool_result` events for a parallel
287
+ * tool-call batch, so "attach the result to the last entry" stamps every mark on the last row.
288
+ * Harnesses that don't carry an id fall back to that last-entry behavior via `resolve`.
289
+ * Shared by the transcript log builder and the live-feed builders (which also splice old
290
+ * entries off the front — `shift` keeps pending indices correct after that).
291
+ */
292
+ export class ToolCallIndex {
293
+ private pending = new Map<string, number>();
294
+
295
+ set(id: string | undefined, index: number): void {
296
+ if (id) this.pending.set(id, index);
297
+ }
298
+
299
+ /** Index to mark, or -1 when an id was given but has no matching pending entry (don't mis-attribute). */
300
+ resolve(id: string | undefined, fallbackIndex: number): number {
301
+ if (id === undefined) return fallbackIndex;
302
+ const idx = this.pending.get(id);
303
+ if (idx === undefined) return -1;
304
+ this.pending.delete(id);
305
+ return idx;
306
+ }
307
+
308
+ /** Adjust pending indices after removing `count` entries from the front of the backing array. */
309
+ shift(count: number): void {
310
+ for (const [id, idx] of this.pending) {
311
+ const next = idx - count;
312
+ if (next < 0) this.pending.delete(id);
313
+ else this.pending.set(id, next);
314
+ }
315
+ }
316
+ }
317
+
127
318
  /** Compact per-line activity log for the transcript (tool_input + results only). */
128
319
  export function collectActivityLog(events: ActivityEvent[]): string[] {
129
320
  const log: string[] = [];
321
+ const index = new ToolCallIndex();
130
322
  for (const ev of events) {
131
323
  if (ev.kind === 'tool_input') {
132
324
  log.push(`▶ ${formatToolUse(ev.name, ev.input)}`);
325
+ index.set(ev.id, log.length - 1);
133
326
  } else if (ev.kind === 'tool_result') {
134
- const last = log.length - 1;
135
- if (last >= 0 && log[last].startsWith('▶')) {
136
- log[last] += ev.isError ? ' ✗ error' : ' ✓';
327
+ const idx = index.resolve(ev.id, log.length - 1);
328
+ if (idx >= 0 && log[idx].startsWith('▶')) {
329
+ log[idx] += ev.isError ? ' ✗ error' : ' ✓';
137
330
  }
138
331
  }
139
332
  }
@@ -152,8 +345,8 @@ export function buildTranscript(
152
345
  cwd: string;
153
346
  sessionId: string | null;
154
347
  resumed: boolean;
155
- numTurns: number;
156
- totalCostUsd: number;
348
+ numTurns: number | null;
349
+ totalCostUsd: number | null;
157
350
  isError: boolean;
158
351
  stopReason: string | null;
159
352
  durationMs: number | null;
@@ -167,6 +360,8 @@ export function buildTranscript(
167
360
  contextWindow: number | null;
168
361
  activityLog: string[];
169
362
  output: string;
363
+ /** Host-run verification result, when a `verify` command was configured for this run. */
364
+ verify?: VerifyResult;
170
365
  } & Record<string, unknown>,
171
366
  ): string {
172
367
  const harness = (opts.harness as string | undefined) ?? 'claude';
@@ -215,7 +410,7 @@ export function buildTranscript(
215
410
  `- model: ${opts.model ?? 'default'}`,
216
411
  `- cwd: ${opts.cwd}`,
217
412
  `- session: ${opts.sessionId ?? 'n/a'}${opts.resumed ? ' (resumed)' : ''}`,
218
- `- turns: ${opts.numTurns} · cost: $${opts.totalCostUsd.toFixed(4)} · isError: ${opts.isError}`,
413
+ `- turns: ${opts.numTurns ?? 'n/a'} · cost: ${opts.totalCostUsd !== null ? `$${opts.totalCostUsd.toFixed(4)}` : 'n/a'} · isError: ${opts.isError}`,
219
414
  `- tokens: ${tokens ?? 'n/a'}`,
220
415
  `- context: ${context ?? 'n/a'}`,
221
416
  `- duration: ${duration ?? 'n/a'}`,
@@ -227,6 +422,7 @@ export function buildTranscript(
227
422
  '## Output',
228
423
  opts.output || '(empty)',
229
424
  '',
425
+ ...(opts.verify ? [formatVerifySection(opts.verify), ''] : []),
230
426
  ].join('\n');
231
427
  }
232
428
 
@@ -16,32 +16,47 @@ export interface DelegateCommandArgs {
16
16
  sessionId?: string;
17
17
  /** GitHub PR number/URL to review (--pr=). */
18
18
  pr?: string;
19
+ /** Host-run verification command override (--verify=); takes precedence over the template's. */
20
+ verify?: string;
19
21
  }
20
22
 
21
23
  export type ClaudeCommandArgs = DelegateCommandArgs;
22
24
 
23
25
  const KNOWN_HARNESSES = new Set(['claude', 'codex', 'opencode', 'amp', 'omp']);
24
26
 
27
+ /** True when `word` is `all`, a single known harness/alias, or a comma-separated list of them. */
28
+ function looksLikeHarnessSpec(word: string, knownHarnesses: ReadonlySet<string>): boolean {
29
+ const lower = word.toLowerCase();
30
+ if (lower === 'all' || knownHarnesses.has(lower)) return true;
31
+ const parts = lower.split(',').filter(Boolean);
32
+ return parts.length > 1 && parts.every(p => knownHarnesses.has(p));
33
+ }
34
+
25
35
  export function parseDelegateCommand(
26
36
  raw: string,
27
37
  knownModes: ReadonlySet<string>,
28
38
  knownHarnesses: ReadonlySet<string> = KNOWN_HARNESSES,
29
39
  ): DelegateCommandArgs {
30
40
  const flags: Record<string, string> = {};
31
- const rest = raw.replace(/--([a-zA-Z-]+)=(\S+)/g, (_m, k: string, v: string) => {
32
- flags[k] = v;
33
- return '';
34
- });
41
+ // supports quoted values ("…"/'…') so multi-word flags like --verify="bun test" survive intact
42
+ const rest = raw.replace(
43
+ /--([a-zA-Z-]+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
44
+ (_m, k: string, dq: string | undefined, sq: string | undefined, bare: string | undefined) => {
45
+ flags[k] = dq ?? sq ?? bare ?? '';
46
+ return '';
47
+ },
48
+ );
35
49
 
36
50
  let harness = flags.harness?.toLowerCase();
37
51
  let mode = flags.mode;
38
52
  let task = rest.trim();
39
53
 
40
- // First word handling: harness, mode, or both
54
+ // First word handling: harness (single, `all`, or comma list), mode, or both
41
55
  const words = task.split(/\s+/).filter(Boolean);
42
56
  let idx = 0;
43
- if (!harness && words[idx] && knownHarnesses.has(words[idx].toLowerCase())) {
57
+ if (!harness && words[idx] && looksLikeHarnessSpec(words[idx], knownHarnesses)) {
44
58
  harness = words[idx].toLowerCase();
59
+ // single-name alias normalization only — a list/`all` is resolved later by resolveHarnessList
45
60
  if (harness === 'omp') harness = 'amp';
46
61
  idx++;
47
62
  }
@@ -62,6 +77,7 @@ export function parseDelegateCommand(
62
77
  }
63
78
  if (flags.resume) out.sessionId = flags.resume;
64
79
  if (flags.pr) out.pr = flags.pr;
80
+ if (flags.verify) out.verify = flags.verify;
65
81
  return out;
66
82
  }
67
83
 
@@ -69,6 +85,67 @@ export function parseClaudeCommand(raw: string, knownModes: ReadonlySet<string>)
69
85
  return parseDelegateCommand(raw, knownModes);
70
86
  }
71
87
 
88
+ /** True when a `harness` field selects more than one harness: `all` or a comma-separated list. */
89
+ export function isFanoutSpec(harness: string | undefined): boolean {
90
+ if (!harness) return false;
91
+ const lower = harness.trim().toLowerCase();
92
+ return lower === 'all' || lower.includes(',');
93
+ }
94
+
95
+ export interface HarnessListResolution {
96
+ /** Canonical harness names to run, in request order, deduped. */
97
+ resolved: string[];
98
+ /** Requested names that don't match any known harness or alias. */
99
+ unknown: string[];
100
+ /** Known harnesses that were requested/selected by `all` but aren't detected as installed. */
101
+ skipped: string[];
102
+ }
103
+
104
+ /**
105
+ * Resolve a `harness` field (`all` or a comma-separated list of names/aliases) into the
106
+ * canonical harness names a fan-out should actually run. Pure — detection results and the
107
+ * known-harness/alias lookups are passed in, no I/O happens here.
108
+ *
109
+ * `all` resolves to every *detected* harness (skipping uninstalled ones). An explicit list is
110
+ * validated against `isKnown`/`aliasOf` and also filtered by detection, so a named-but-uninstalled
111
+ * harness is reported (via `skipped`) instead of failing the whole run.
112
+ */
113
+ export function resolveHarnessList(
114
+ spec: string,
115
+ opts: {
116
+ knownHarnesses: readonly string[];
117
+ aliasOf: (name: string) => string;
118
+ isKnown: (name: string) => boolean;
119
+ detection: Readonly<Record<string, { ok: boolean }>>;
120
+ },
121
+ ): HarnessListResolution {
122
+ const lower = spec.trim().toLowerCase();
123
+ const isAll = lower === 'all';
124
+ const requested = isAll
125
+ ? opts.knownHarnesses
126
+ : lower
127
+ .split(',')
128
+ .map(s => s.trim())
129
+ .filter(Boolean);
130
+
131
+ const resolved: string[] = [];
132
+ const unknown: string[] = [];
133
+ const skipped: string[] = [];
134
+ const seen = new Set<string>();
135
+ for (const raw of requested) {
136
+ if (!isAll && !opts.isKnown(raw)) {
137
+ unknown.push(raw);
138
+ continue;
139
+ }
140
+ const canon = opts.aliasOf(raw);
141
+ if (seen.has(canon)) continue;
142
+ seen.add(canon);
143
+ if (opts.detection[canon]?.ok) resolved.push(canon);
144
+ else skipped.push(canon);
145
+ }
146
+ return { resolved, unknown, skipped };
147
+ }
148
+
72
149
  /**
73
150
  * Apply template defaults when the prompt is empty.
74
151
  */