brl-subagent 2.3.8 → 2.3.9

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/params.ts CHANGED
@@ -23,7 +23,7 @@ import type {
23
23
  } from "./types";
24
24
  import { resolveThinkingLevel } from "./types";
25
25
  import { getAllPresets, getPreset } from "./presets";
26
- import { autoRoutePreset } from "./router";
26
+ import { classifyTask } from "./router";
27
27
  import { normalizeTimeout } from "./validate";
28
28
  import type { SessionState } from "./state";
29
29
  import type { Logger } from "./logging";
@@ -49,7 +49,7 @@ export const KNOWN_DELEGATE_KEYS = new Set([
49
49
  "outputFile", "label", "model", "timeout", "cwd", "tools",
50
50
  "excludeTools", "noBuiltinTools", "preset", "template", "params",
51
51
  "retryRunId", "gitMode", "retryOnTimeout", "approvalMode", "background",
52
- "priority", "chain", "tasks", "graph",
52
+ "priority", "chain", "tasks", "graph", "force",
53
53
  ] as const);
54
54
 
55
55
  /**
@@ -76,6 +76,11 @@ export function findUnknownParams(
76
76
  * degrade retries of that path.
77
77
  * NOTE: the schema's `params` (template slots) key is intentionally NOT
78
78
  * snapshotted — it is a template-resolution input, not a retry override.
79
+ * The execution-shape keys (background, gitMode, approvalMode, force) ARE
80
+ * snapshotted: omitting them is the known failure mode of this design, since
81
+ * resolveRetryParams rebuilds a fresh literal and a key missing here is both
82
+ * unrestored AND discarded when passed explicitly on a retry call. Records
83
+ * written before this change simply lack the keys (additive, undefined-safe).
79
84
  */
80
85
  export function snapshotOriginalParams(params: {
81
86
  systemPrompt?: string;
@@ -90,6 +95,10 @@ export function snapshotOriginalParams(params: {
90
95
  excludeTools?: string[];
91
96
  noBuiltinTools?: boolean;
92
97
  preset?: string;
98
+ background?: boolean;
99
+ gitMode?: string;
100
+ approvalMode?: string;
101
+ force?: boolean;
93
102
  }): Record<string, unknown> {
94
103
  return {
95
104
  systemPrompt: params.systemPrompt,
@@ -104,6 +113,10 @@ export function snapshotOriginalParams(params: {
104
113
  excludeTools: params.excludeTools,
105
114
  noBuiltinTools: params.noBuiltinTools,
106
115
  preset: params.preset,
116
+ background: params.background,
117
+ gitMode: params.gitMode,
118
+ approvalMode: params.approvalMode,
119
+ force: params.force,
107
120
  };
108
121
  }
109
122
 
@@ -124,6 +137,12 @@ export function resolveSubagentParams(
124
137
  gitMode?: string;
125
138
  approvalMode?: string;
126
139
  template?: string;
140
+ /**
141
+ * Capability-block override: carried here so the validation call sites
142
+ * (which receive the resolved params) can forward it to validatePreTask.
143
+ * Resolution itself ignores it.
144
+ */
145
+ force?: boolean;
127
146
  },
128
147
  state: SessionState,
129
148
  ctx: ExtensionContext,
@@ -133,6 +152,9 @@ export function resolveSubagentParams(
133
152
  resolvedApprovalMode: ApprovalMode;
134
153
  resolvedPreset?: SubagentPreset;
135
154
  autoRoutedPreset?: SubagentPreset; // set only when autoRoutePreset chose it
155
+ /** Set only when auto-route chose the preset: the keyword that matched —
156
+ * the evidence the dispatch result surfaces (auto-route transparency). */
157
+ autoRouteKeyword?: string;
136
158
  } {
137
159
  // E2: Auto-route to best preset only when the conductor expressed NO
138
160
  // explicit preference — an explicit preset, template, or tool
@@ -140,17 +162,19 @@ export function resolveSubagentParams(
140
162
  // that must win over keyword-based routing (issue #57).
141
163
  let resolvedPreset = params.preset;
142
164
  let wasAutoRouted = false;
165
+ let routeKeyword: string | undefined;
143
166
  const hasExplicitToolPreference =
144
167
  params.tools !== undefined ||
145
168
  params.excludeTools !== undefined ||
146
169
  params.noBuiltinTools !== undefined;
147
170
  if (!resolvedPreset && !params.template && !hasExplicitToolPreference) {
148
171
  const allPresets = getAllPresets(state.builtinPresets, state.customPresets);
149
- const suggested = autoRoutePreset(params.task, allPresets);
150
- if (suggested) {
151
- resolvedPreset = suggested;
172
+ const matched = classifyTask(params.task, allPresets);
173
+ if (matched) {
174
+ resolvedPreset = matched.preset;
152
175
  wasAutoRouted = true;
153
- log.info("Auto-routed task to preset", { preset: suggested });
176
+ routeKeyword = matched.keyword;
177
+ log.info("Auto-routed task to preset", { preset: matched.preset, keyword: matched.keyword });
154
178
  }
155
179
  }
156
180
 
@@ -221,5 +245,6 @@ export function resolveSubagentParams(
221
245
  resolvedApprovalMode,
222
246
  resolvedPreset: preset,
223
247
  autoRoutedPreset,
248
+ autoRouteKeyword: autoRoutedPreset ? routeKeyword : undefined,
224
249
  };
225
250
  }
package/src/prelude.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Shared guard/validation prelude for delegation modes (issue #201).
3
+ *
4
+ * delegate_task's mode handlers each open with the same prelude shape:
5
+ *
6
+ * 1. session cost gate — perTaskEstimate fallback ($0.05) →
7
+ * checkCostLimit → the verbatim "Cannot delegate: session cost limit
8
+ * reached …" rejection;
9
+ * 2. approvalMode 'always' rejection (background paths only) — the verbatim
10
+ * "Cannot spawn background agent with approvalMode 'always' …";
11
+ * 3. cwd → outputFile → H1 pre-task validation — the same "Invalid cwd:" /
12
+ * "Invalid outputFile:" / joined-errors messages, optionally
13
+ * unit-prefixed (`Task 2 ("two"): …`) for per-unit loops.
14
+ *
15
+ * Each helper returns the rejection ToolResult when the gate trips and
16
+ * `undefined` when it passes, so call sites keep their original gate order:
17
+ *
18
+ * const err = gateSessionCost({ … });
19
+ * if (err) return err;
20
+ *
21
+ * Exception: runPreTaskValidation returns a { error?, warnings } outcome —
22
+ * `error` follows the same `if (outcome.error) return outcome.error` shape,
23
+ * and `warnings` must be surfaced in the returned tool result:
24
+ *
25
+ * const pre = runPreTaskValidation({ … });
26
+ * if (pre.error) return pre.error;
27
+ *
28
+ * Messages are byte-identical to the pre-extraction copies (the suites pin
29
+ * them); the parameters absorb only the real differences between sites — the
30
+ * cost-gate log label and unit count, and the H1 log label / unit prefix /
31
+ * extra log fields.
32
+ */
33
+
34
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
35
+ import type { ApprovalMode, ToolResult } from "./types";
36
+ import type { SessionState } from "./state";
37
+ import type { Logger } from "./logging";
38
+ import { validateCwd, validateOutputFile } from "./sanitize";
39
+ import { validatePreTask, type ValidateConfig } from "./validate";
40
+
41
+ /** The standard rejection result shape shared by these gates. */
42
+ function rejection(text: string): ToolResult<undefined> {
43
+ return {
44
+ content: [{ type: "text" as const, text }],
45
+ details: undefined,
46
+ isError: true,
47
+ };
48
+ }
49
+
50
+ // ── Gate 1: session cost ────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Session cost gate (R5 / W6): falls back to the $0.05 per-unit estimate,
54
+ * checks the whole dispatch (units × estimate) against the session limit, and
55
+ * returns the verbatim rejection result when it would be exceeded.
56
+ *
57
+ * This module holds the codebase's SINGLE `checkCostLimit(perTaskEstimate * …)`
58
+ * site — the duplication that shape caused a mutation-target-drift incident
59
+ * (issue #201).
60
+ */
61
+ export function gateSessionCost(input: {
62
+ state: SessionState;
63
+ ctx: ExtensionContext;
64
+ log: Logger;
65
+ /** Number of units this dispatch would spawn (1 for single-task modes). */
66
+ units: number;
67
+ /** e.g. "Parallel delegation" → log line "Parallel delegation rejected: session cost limit reached". */
68
+ label: string;
69
+ }): ToolResult<undefined> | undefined {
70
+ const { state, ctx, log, units, label } = input;
71
+ const perTaskEstimate =
72
+ state.config.perTaskCostEstimate > 0
73
+ ? state.config.perTaskCostEstimate
74
+ : 0.05;
75
+ if (state.checkCostLimit(perTaskEstimate * units, ctx)) {
76
+ const currentTotal = state.getSessionTotalCost(ctx);
77
+ const limit = state.config.sessionCostLimit;
78
+ log.warn(`${label}: session cost limit reached`, {
79
+ currentTotal,
80
+ estimatedCost: perTaskEstimate * units,
81
+ limit,
82
+ });
83
+ return rejection(
84
+ `Cannot delegate: session cost limit reached ` +
85
+ `($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
86
+ `Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
87
+ );
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ // ── Gate 2: approvalMode 'always' in background ─────────────────────────
93
+
94
+ /**
95
+ * Background paths (single spawn and fan-out) reject approvalMode 'always'
96
+ * before spawning: there is no interactive dialog to approve the diff, so
97
+ * 'always' cannot work — reject loudly instead of running unattended with
98
+ * write access (W5, issue #28). Verbatim message, pinned by the suites.
99
+ */
100
+ export function rejectApprovalAlwaysInBackground(
101
+ approvalMode: ApprovalMode,
102
+ ): ToolResult<undefined> | undefined {
103
+ if (approvalMode !== "always") return undefined;
104
+ return rejection(
105
+ `Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
106
+ `and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`,
107
+ );
108
+ }
109
+
110
+ // ── Gate 3a: cwd (+ outputFile) target validation ───────────────────────
111
+
112
+ export type DelegationTargetsResult =
113
+ | { ok: true; cwd: string; outputFile: string | undefined }
114
+ | { ok: false; error: ToolResult<undefined> };
115
+
116
+ /**
117
+ * Validate the dispatch's target cwd, then — when `outputFile` is given — the
118
+ * outputFile path (F1), returning both resolved values on success. `prefix`
119
+ * (a per-unit label like `Task 2 ("two")`) is prepended as `${prefix}: ` to
120
+ * the rejection messages; omit it for the unprefixed single-unit messages.
121
+ *
122
+ * Note: the foreground parallel mode historically validates only the cwd
123
+ * here (no outputFile step) — it simply passes no outputFile.
124
+ */
125
+ export function validateDelegationTargets(input: {
126
+ ctx: ExtensionContext;
127
+ effectiveCwd: string;
128
+ /** Raw outputFile to path-validate; the step is skipped when omitted. */
129
+ outputFile?: string;
130
+ /** Unit prefix for the rejection messages, e.g. `Task 2 ("two")`. */
131
+ prefix?: string;
132
+ }): DelegationTargetsResult {
133
+ const prefix = input.prefix ? `${input.prefix}: ` : "";
134
+
135
+ const cwdResult = validateCwd(input.effectiveCwd, input.ctx.cwd);
136
+ if (!cwdResult.ok) {
137
+ return { ok: false, error: rejection(`${prefix}Invalid cwd: ${cwdResult.error}`) };
138
+ }
139
+
140
+ let outputFile: string | undefined;
141
+ if (input.outputFile) {
142
+ const ofResult = validateOutputFile(input.outputFile, cwdResult.value);
143
+ if (!ofResult.ok) {
144
+ return { ok: false, error: rejection(`${prefix}Invalid outputFile: ${ofResult.error}`) };
145
+ }
146
+ outputFile = ofResult.value;
147
+ }
148
+
149
+ return { ok: true, cwd: cwdResult.value, outputFile };
150
+ }
151
+
152
+ // ── Gate 3b: H1 pre-task validation ─────────────────────────────────────
153
+
154
+ /** Outcome of one pre-task validation: the rejection (hard failure) and/or
155
+ * the warnings the caller must surface in the tool result. */
156
+ export interface PreTaskValidationOutcome {
157
+ /** Rejection result when validation failed (hard errors); undefined when it passed. */
158
+ error?: ToolResult<undefined>;
159
+ /** Validation warnings — surfaced in the returned tool result, not just logged. */
160
+ warnings: string[];
161
+ }
162
+
163
+ /**
164
+ * Render validation warnings for a tool result: one labelled, bulleted
165
+ * block. Empty input renders the empty string so results stay
166
+ * byte-identical when there is nothing to say.
167
+ */
168
+ export function formatValidationWarnings(warnings: string[]): string {
169
+ if (warnings.length === 0) return "";
170
+ return `\n\n[pre-task validation warnings]\n` + warnings.map((w) => `- ${w}`).join("\n");
171
+ }
172
+
173
+ /**
174
+ * Run validatePreTask (H1) with the site's exact input, warn on warnings,
175
+ * and return the rejection outcome (joined errors) on hard failure.
176
+ * `label` names the site in the warn logs ("Parallel pre-task validation
177
+ * warnings" / "Background fan-out …" / "Background …"); `prefix` is
178
+ * prepended as `${prefix}: ` to the hard-failure message; `logContext` adds
179
+ * site fields to both warn entries (the fan-out adds the task index).
180
+ * The returned warnings are for RESULT surfacing — the log.warn calls stay
181
+ * regardless (a warning must reach both the log and the conductor).
182
+ */
183
+ export function runPreTaskValidation(input: {
184
+ log: Logger;
185
+ preTask: ValidateConfig;
186
+ label: string;
187
+ /** Unit prefix for the rejection message, e.g. `Task 2 ("two")`. */
188
+ prefix?: string;
189
+ /** Extra structured fields merged into the warn entries. */
190
+ logContext?: Record<string, unknown>;
191
+ }): PreTaskValidationOutcome {
192
+ const { log, preTask, label, prefix, logContext } = input;
193
+ const validation = validatePreTask(preTask);
194
+ if (validation.warnings.length > 0) {
195
+ log.warn(`${label} pre-task validation warnings`, { ...logContext, warnings: validation.warnings });
196
+ }
197
+ if (!validation.valid) {
198
+ const errText = validation.errors.join("; ");
199
+ log.warn(`${label} pre-task validation failed`, { ...logContext, errors: validation.errors });
200
+ return {
201
+ error: rejection(prefix ? `${prefix}: ${errText}` : errText),
202
+ warnings: validation.warnings,
203
+ };
204
+ }
205
+ return { warnings: validation.warnings };
206
+ }
package/src/prompt.ts CHANGED
@@ -52,7 +52,7 @@ export const SUBAGENT_INSTRUCTIONS =
52
52
  * - runner.ts (foreground subprocess: `-p` argument)
53
53
  * - session-manager.ts (background session: `session.prompt`)
54
54
  *
55
- * Deliberately applied AFTER {previous}/{otherId} substitution so substituted
55
+ * Deliberately applied AFTER {previous}/{<nodeId>} substitution so substituted
56
56
  * output (which may itself contain untrusted text) lands inside the fence.
57
57
  *
58
58
  * FIDELITY TRADEOFF (accepted, documented per PR #46 review M2): embedded
package/src/router.ts CHANGED
@@ -105,15 +105,25 @@ function escapeRegExp(s: string): string {
105
105
  // Router
106
106
  // ---------------------------------------------------------------------------
107
107
 
108
+ /** One auto-route decision: the selected preset AND the keyword that
109
+ * matched — the evidence the dispatch result must show (transparency). */
110
+ export interface AutoRouteDecision {
111
+ /** The preset name selected. */
112
+ preset: string;
113
+ /** The classification keyword whose match selected the preset. */
114
+ keyword: string;
115
+ }
116
+
108
117
  /**
109
- * Auto-classify a task description to the best preset personality.
118
+ * Auto-classify a task description to the best preset personality, returning
119
+ * the preset AND the matched keyword (evidence for the dispatch result).
110
120
  * Uses case-insensitive keyword matching, checked in priority order.
111
- * Returns the first matching preset name, or undefined if no match.
121
+ * Returns the first match, or undefined if no rule matches.
112
122
  */
113
- export function autoRoutePreset(
123
+ export function classifyTask(
114
124
  task: string,
115
125
  presets: SubagentPreset[],
116
- ): string | undefined {
126
+ ): AutoRouteDecision | undefined {
117
127
  if (!task || task.trim().length === 0) return undefined;
118
128
 
119
129
  const lower = task.toLowerCase();
@@ -124,10 +134,21 @@ export function autoRoutePreset(
124
134
 
125
135
  for (const keyword of rule.keywords) {
126
136
  if (keywordMatches(lower, keyword)) {
127
- return rule.preset;
137
+ return { preset: rule.preset, keyword };
128
138
  }
129
139
  }
130
140
  }
131
141
 
132
142
  return undefined;
133
143
  }
144
+
145
+ /**
146
+ * Auto-classify a task description to the best preset personality.
147
+ * Thin wrapper over classifyTask — preset name only, no evidence.
148
+ */
149
+ export function autoRoutePreset(
150
+ task: string,
151
+ presets: SubagentPreset[],
152
+ ): string | undefined {
153
+ return classifyTask(task, presets)?.preset;
154
+ }
package/src/runner.ts CHANGED
@@ -245,7 +245,13 @@ function emitSubagentUpdate(
245
245
  text: getFinalOutputFn(result.messages) || "(running...)",
246
246
  },
247
247
  ],
248
- details: { ...result },
248
+ // Issue #206: this is a STREAMING partial, not a verdict —
249
+ // isSubagentError is a settled-result predicate (a mid-run stopReason
250
+ // such as "toolUse" is not an error until the run actually ends), so
251
+ // stamping the unsettled sentinel (exitCode: -1) keeps the renderer on
252
+ // its running branch until the real result lands. The accumulator
253
+ // (`result` itself) is untouched — its exitCode stays the real code.
254
+ details: { ...result, exitCode: -1 },
249
255
  });
250
256
  }
251
257
 
@@ -10,7 +10,7 @@ import { createEvent } from './event-bus';
10
10
  import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
11
11
  import { wrapTask } from './prompt';
12
12
  import { createLogger } from './logging';
13
- import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, commitAll, captureWorkingDiff } from './git';
13
+ import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
14
14
 
15
15
  const log = createLogger('brl-subagent');
16
16
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
@@ -20,7 +20,10 @@ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-a
20
20
  var spawnQueue: Promise<void> = Promise.resolve();
21
21
 
22
22
  // C2: per-repo locks serializing the FULL gitMode=branch lifecycle (setup →
23
- // settle → teardown). Keyed by cwd because different repos don't conflict.
23
+ // settle → teardown). Keyed by the resolved repository root, falling back to
24
+ // the cwd path for directories that are not inside a repository — different
25
+ // repos don't conflict, but different directories of one repo DO share a
26
+ // working tree and must serialize (issue #224).
24
27
  const gitBranchLocks = new Map<string, Promise<void>>();
25
28
 
26
29
  /**
@@ -724,16 +727,18 @@ export async function spawnBackgroundSession(
724
727
  // would read the first's work branch as its base and both teardowns would
725
728
  // fight over the shared working tree (stranding the repo on an orphan
726
729
  // branch). The lock is released in cleanupWorkBranch (or on setup throw).
727
- const prev = gitBranchLocks.get(gitCwd) ?? Promise.resolve();
730
+ // Key: resolved repository root, cwd fallback for non-repo paths (#224).
731
+ const lockKey = getRepoRoot(gitCwd) ?? gitCwd;
732
+ const prev = gitBranchLocks.get(lockKey) ?? Promise.resolve();
728
733
  let release!: () => void;
729
734
  const gate = new Promise<void>(r => { release = r; });
730
735
  const entry = prev.then(() => gate);
731
- gitBranchLocks.set(gitCwd, entry);
736
+ gitBranchLocks.set(lockKey, entry);
732
737
  await prev;
733
738
  releaseGitLock = () => {
734
739
  // Delete the map entry ONLY if we are still the head of the chain — a
735
740
  // queued spawn behind us replaced the entry with its own.
736
- if (gitBranchLocks.get(gitCwd) === entry) gitBranchLocks.delete(gitCwd);
741
+ if (gitBranchLocks.get(lockKey) === entry) gitBranchLocks.delete(lockKey);
737
742
  release();
738
743
  };
739
744
 
package/src/types.ts CHANGED
@@ -299,6 +299,14 @@ export interface SubagentRun {
299
299
  excludeTools?: string[];
300
300
  noBuiltinTools?: boolean;
301
301
  preset?: string;
302
+ // Issue #227: the execution-shape fields survive retries, so a retried
303
+ // background run stays background and a retried branch-mode run keeps its
304
+ // work branch, its approvalMode gating and its force override. Additive:
305
+ // records written before this change simply lack the keys.
306
+ background?: boolean;
307
+ gitMode?: string;
308
+ approvalMode?: string;
309
+ force?: boolean;
302
310
  errorCategory?: ErrorCategory;
303
311
  };
304
312
  }
@@ -504,6 +512,9 @@ export interface ResolvedParams {
504
512
  toolOptions: SubagentToolOptions | undefined;
505
513
  /** Set only when autoRoutePreset chose the preset (not explicitly requested). */
506
514
  autoRoutedPreset: SubagentPreset | undefined;
515
+ /** Set only when auto-route chose the preset: the keyword that matched —
516
+ * surfaced in the dispatch result as the auto-route evidence. */
517
+ autoRouteKeyword?: string;
507
518
  }
508
519
 
509
520
  // ---------------------------------------------------------------------------
package/src/unit-run.ts CHANGED
@@ -122,6 +122,10 @@ export function createUnitRun(
122
122
  // same priority the run entry itself carried.
123
123
  priority: source.priority ?? priorityFloor,
124
124
  startedAt: new Date().toISOString(),
125
+ // The execution-shape fields (background/gitMode/approvalMode/force) are
126
+ // deliberately omitted: a fan-out unit retries as a SINGLE run, so it must
127
+ // not inherit the fan-out's background-ness. A retry that wants background
128
+ // passes background: true explicitly.
125
129
  originalParams: snapshotOriginalParams({
126
130
  systemPrompt: source.customSP,
127
131
  inheritSystemPrompt: source.inheritSP,
package/src/validate.ts CHANGED
@@ -17,6 +17,15 @@ export interface ValidateConfig {
17
17
  thinkingLevel?: ThinkingLevel;
18
18
  gitMode?: string;
19
19
  outputFile?: string;
20
+ /**
21
+ * delegate_task's `force` param (default false). When true, capability
22
+ * ERRORS (the high-confidence capability-critical mismatches below)
23
+ * degrade to warning-class: they still surface in `warnings`, but
24
+ * `valid` stays true and dispatch proceeds. The outputFile hard
25
+ * conflict is NOT suppressible — a report the subagent cannot write
26
+ * can never succeed, so force does not apply to it.
27
+ */
28
+ force?: boolean;
20
29
  }
21
30
 
22
31
  export interface ValidateResult {
@@ -29,40 +38,109 @@ export interface ValidateResult {
29
38
 
30
39
  interface ToolRequirement {
31
40
  patterns: RegExp[];
41
+ /** ALL-of: every listed tool must be available (default semantics). */
32
42
  requiredTools: string[];
43
+ /**
44
+ * ANY-of: when set, ONE available tool satisfies the requirement and
45
+ * `requiredTools` is ignored — directory exploration is a single
46
+ * capability (the browse built-ins find/ls/grep and bash are
47
+ * interchangeable ways to do it), not a conjunction of all of them.
48
+ */
49
+ anyOfTools?: string[];
50
+ /**
51
+ * Warning lead, rendered as `Task ${description} but …` (and, under a
52
+ * unit prefix, `Task 1 ("deploy"): Task ${description} …`). Starts with
53
+ * the verb ("involves …") so the fixed `Task ` template reads naturally
54
+ * standalone AND prefixed — do not add a leading "task" (issue #220).
55
+ */
33
56
  description: string;
57
+ /**
58
+ * High-confidence, capability-critical subset of `patterns`: a task
59
+ * matching any of these with the capability ABSENT is promoted from
60
+ * warning to ERROR — dispatch does not spawn — unless `force` is set,
61
+ * which degrades the mismatch back to warning class. Only the two
62
+ * capability-critical shapes carry this: a run/execute/test/compile/
63
+ * benchmark task with no bash, and an exploration task with none of
64
+ * find/ls/grep/bash. Entry-less requirements never block (warnings only).
65
+ */
66
+ blockingPatterns?: RegExp[];
67
+ /**
68
+ * Builds the block message: names the missing capability, the resolved
69
+ * toolset, and the exact fix — including the force override. Receives
70
+ * the rendered `tools=…, excludeTools=…` toolset string.
71
+ */
72
+ blockMessage?: (toolset: string) => string;
34
73
  }
35
74
 
75
+ /*
76
+ * COVERAGE BOUNDARY — which wording evades this table (every guard states
77
+ * its coverage). These are word-boundary keyword matches against the raw
78
+ * task text, nothing more. EVADED by: paraphrases that use none of the
79
+ * listed keywords ("hunt for drafts", "browse the repository", "see which
80
+ * files mention X"), inflected forms the patterns do not list ("running",
81
+ * "tests", "searching", "listing" — \btest\b does not match "tests"), and
82
+ * domain phrasing that never names the verb ("kick off CI", "smoke the
83
+ * pipeline"). The opposite failure is equally real: documentation/writing
84
+ * tasks that merely MENTION a keyword ("document how to run the tests")
85
+ * match too — which is why only the two high-confidence capability blocks
86
+ * (blockingPatterns below) escalate to errors, while install/deploy/npm/
87
+ * yarn/pnpm/cargo/pip stay warning-class (docs can name them without
88
+ * needing them). Widening the table is deliberate work: add the keyword
89
+ * here AND update this note.
90
+ */
91
+
36
92
  const TOOL_REQUIREMENTS: ToolRequirement[] = [
37
93
  {
38
94
  patterns: [/\b(write|create|generate|build|implement|add|insert|append)\b/i],
39
95
  requiredTools: ['write', 'edit'],
40
- description: 'task involves writing/creating files',
96
+ description: 'involves writing/creating files',
41
97
  },
42
98
  {
43
99
  patterns: [/\b(edit|modify|update|change|fix|patch|refactor|rename)\b/i],
44
100
  requiredTools: ['write', 'edit'],
45
- description: 'task involves editing files',
101
+ description: 'involves editing files',
46
102
  },
47
103
  {
48
104
  patterns: [/\b(run|execute|test|benchmark|compile|install|deploy|npm|yarn|pnpm|cargo|pip|vitest)\b/i],
49
105
  requiredTools: ['bash'],
50
- description: 'task involves running commands',
106
+ description: 'involves running commands',
107
+ // Blocking subset: only the verbs that unambiguously DEMAND a shell.
108
+ // install/deploy/npm/yarn/pnpm/cargo/pip deliberately stay warning-class
109
+ // — documentation tasks can mention them without executing them.
110
+ blockingPatterns: [/\b(run|execute|test|compile|benchmark)\b/i],
111
+ blockMessage: (toolset) =>
112
+ `Cannot delegate: the task requires the 'bash' tool (running commands) but it is not available ` +
113
+ `in the resolved toolset (${toolset}). Fix: add 'bash' to this dispatch's tools (or remove it ` +
114
+ `from excludeTools) and re-dispatch, or pass force: true to dispatch anyway.`,
115
+ },
116
+ {
117
+ // Directory exploration is ONE capability, satisfied by ANY of the
118
+ // browse built-ins or bash — anyOfTools, not a conjunction.
119
+ patterns: [/\b(search|grep|find|list|locate|glob)\b/i],
120
+ requiredTools: [],
121
+ anyOfTools: ['find', 'ls', 'grep', 'bash'],
122
+ description: 'involves directory exploration',
123
+ blockingPatterns: [/\b(search|grep|find|list|locate|glob)\b/i],
124
+ blockMessage: (toolset) =>
125
+ `Cannot delegate: the task requires directory exploration, satisfied by any of 'find', 'ls', ` +
126
+ `'grep' or 'bash', but none is available in the resolved toolset (${toolset}). Fix: add any of ` +
127
+ `'find', 'ls', 'grep' or 'bash' to this dispatch's tools (or remove it from excludeTools) and ` +
128
+ `re-dispatch, or pass force: true to dispatch anyway.`,
51
129
  },
52
130
  {
53
131
  patterns: [/\b(delete|remove|clean|prune|uninstall|rm)\b/i],
54
132
  requiredTools: ['write', 'bash'],
55
- description: 'task involves deleting files',
133
+ description: 'involves deleting files',
56
134
  },
57
135
  {
58
136
  patterns: [/\b(commit|push|pull|merge|rebase|checkout|branch)\b/i],
59
137
  requiredTools: ['bash'],
60
- description: 'task involves git operations',
138
+ description: 'involves git operations',
61
139
  },
62
140
  {
63
141
  patterns: [/\b(audit|review|analyze|examine|check|inspect|scan|read)\b/i],
64
142
  requiredTools: ['read'],
65
- description: 'task involves reading/analyzing files',
143
+ description: 'involves reading/analyzing files',
66
144
  },
67
145
  ];
68
146
 
@@ -225,15 +303,38 @@ export function validatePreTask(config: ValidateConfig): ValidateResult {
225
303
  return { valid: true, warnings: [], errors: [] };
226
304
  }
227
305
 
228
- // Check tool requirements (warnings, not errors — conductor can override)
306
+ // Check tool requirements. A missing capability is warning-class by
307
+ // default (conductor can judge); the high-confidence capability-critical
308
+ // cases (blockingPatterns) are promoted to ERRORS unless `force` is set.
309
+ const toolset = `tools=${config.toolOptions?.tools?.join(',') ?? 'all'}, excludeTools=${config.toolOptions?.excludeTools?.join(',') ?? 'none'}`;
229
310
  for (const req of TOOL_REQUIREMENTS) {
230
311
  const matches = req.patterns.some(p => p.test(taskText));
231
312
  if (!matches) continue;
232
313
 
233
- for (const tool of req.requiredTools) {
234
- if (!isToolAvailable(tool, config.toolOptions)) {
314
+ const missing = req.anyOfTools
315
+ ? (req.anyOfTools.some(t => isToolAvailable(t, config.toolOptions)) ? [] : [...req.anyOfTools])
316
+ : req.requiredTools.filter(t => !isToolAvailable(t, config.toolOptions));
317
+ if (missing.length === 0) continue;
318
+
319
+ // Capability-critical block: high-confidence pattern matched AND the
320
+ // capability is absent. `force` degrades it to warning class below.
321
+ const capabilityBlock =
322
+ !config.force &&
323
+ req.blockMessage !== undefined &&
324
+ (req.blockingPatterns ?? []).some(p => p.test(taskText));
325
+ if (capabilityBlock) {
326
+ errors.push(req.blockMessage!(toolset));
327
+ continue;
328
+ }
329
+
330
+ if (req.anyOfTools) {
331
+ warnings.push(
332
+ `Task ${req.description} but none of ${req.anyOfTools.map(t => `'${t}'`).join(', ')} is available (${toolset})`,
333
+ );
334
+ } else {
335
+ for (const tool of missing) {
235
336
  warnings.push(
236
- `Task ${req.description} but '${tool}' is not available (tools=${config.toolOptions?.tools?.join(',') ?? 'all'}, excludeTools=${config.toolOptions?.excludeTools?.join(',') ?? 'none'})`,
337
+ `Task ${req.description} but '${tool}' is not available (${toolset})`,
237
338
  );
238
339
  }
239
340
  }
@@ -7,4 +7,4 @@ thinkingLevel: medium
7
7
  Review ${target} for correctness, security, and style.
8
8
 
9
9
  Report findings as a numbered list with file:line references,
10
- prioritized blocker → nit. Read-only — no edits.
10
+ prioritized blocker → nit. Read-only — no edits. Any finding that rests on a search or a read states its coverage boundary: the query or command used, its count or range, and how it reconciles with what you expected to cover.
@@ -6,4 +6,5 @@ thinkingLevel: medium
6
6
  ---
7
7
  Debug ${symptom}. Reproduce it first, then isolate the root cause.
8
8
 
9
- Show the failing input, the root cause, and the minimal fix.
9
+ Show the failing input, the root cause, and the minimal fix. Final report: end with a Coverage line — the exact command or query used, the count or range it printed, and how it reconciles with the corpus you expected; show an empty result's size before treating it as absence.
10
+ Before running any probe or reproduction, name its target by unique surrounding context and state the expected failure signature; when it runs tests, scope the run to the specific test file — a passing probe is first evidence the target was wrong, not that the behavior is fine.
@@ -7,4 +7,4 @@ thinkingLevel: medium
7
7
  Write tests for ${file} covering the happy path and edge cases.
8
8
 
9
9
  Include the test file path in your report and note any
10
- untestable code paths you skipped.
10
+ untestable code paths you skipped. When designing a mutation to prove a test fails on the defect, name the target by unique surrounding context and state the expected failure first.