brl-subagent 2.3.8 → 2.4.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/src/tui.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: All TUI surfaces: config menus, preset/template managers, history, live monitor, and result rendering.
1
2
  /**
2
3
  * brl-subagent — TUI Components
3
4
  *
@@ -17,7 +18,6 @@ import { Container, type SelectItem, SelectList, Spacer, Text, Markdown } from "
17
18
  import type { Api, Model } from "@earendil-works/pi-ai";
18
19
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
19
20
  import type {
20
- SubagentPreset,
21
21
  TaskTemplate,
22
22
  SubagentResult,
23
23
  SubagentRun,
@@ -28,7 +28,6 @@ import type {
28
28
  GraphDetails,
29
29
  DelegateTaskDetails,
30
30
  GraphTask,
31
- SubTaskResult,
32
31
  ThinkingLevel,
33
32
  UsageStats,
34
33
  FileDiff,
@@ -42,8 +41,6 @@ import {
42
41
  COLLAPSED_OUTPUT_LINES,
43
42
  COLLAPSED_DIFF_FILES_PREVIEW,
44
43
  EXPANDED_HUNKS_PER_FILE,
45
- RESERVED_NAME_PATTERN,
46
- RESERVED_COMMAND_NAMES,
47
44
  formatTokens,
48
45
  formatUsageStats,
49
46
  formatModel,
@@ -54,9 +51,9 @@ import {
54
51
  isGraphDetails,
55
52
  } from "./types";
56
53
  import { buildFileAccessReport, buildSecretsExposureReport, generateComplianceSummary } from "./reports";
57
- import { extractParamNames, loadAllTemplates, validateTemplatePresetRefs } from "./templates";
54
+ import { extractParamNames } from "./templates";
58
55
  import { parseDiff } from "./diff";
59
- import { formatPresetSummary, getPreset, getAllPresets, writePresetFile, loadCustomPresets, parseFrontmatter } from "./presets";
56
+ import { formatPresetSummary, getPreset, loadCustomPresets } from "./presets";
60
57
  import { formatRunDuration } from "./history";
61
58
  import * as fs from "node:fs";
62
59
  import * as path from "node:path";
@@ -638,185 +635,6 @@ export async function showComplianceMenu(
638
635
 
639
636
  // Preset management UI
640
637
 
641
- export async function showAddPreset(
642
- ctx: ExtensionContext,
643
- state: SessionState,
644
- ): Promise<void> {
645
- // 1. Prompt for name
646
- const name = await ctx.ui.input("Preset name (e.g., read-only-audit):");
647
- if (!name?.trim()) return;
648
- const trimmedName = name.trim();
649
-
650
- // 2. Validate name (reserved patterns, existing presets)
651
- if (RESERVED_NAME_PATTERN.test(trimmedName)) {
652
- ctx.ui.notify("Names starting and ending with '__' are reserved.", "error");
653
- return;
654
- }
655
- if (RESERVED_COMMAND_NAMES.has(trimmedName)) {
656
- ctx.ui.notify("Name '" + trimmedName + "' is reserved for a command.", "error");
657
- return;
658
- }
659
- if (getPreset(trimmedName, state.builtinPresets, state.customPresets)) {
660
- ctx.ui.notify(`Preset "${trimmedName}" already exists.`, "error");
661
- return;
662
- }
663
-
664
- // 3. Description
665
- const description = await ctx.ui.input("Description (optional):");
666
-
667
- // 4. Thinking level
668
- const thinkingItems: SelectItem[] = [
669
- { value: "", label: "(not set — use conductor's choice)" },
670
- ...THINKING_LEVELS.map((level) => ({ value: level, label: level })),
671
- ];
672
- const thinkingResult = await showSelectList(ctx, "Default Thinking Level", thinkingItems, 8);
673
-
674
- // 5. Tool scope
675
- const scopeItems: SelectItem[] = [
676
- { value: "all", label: "All tools (default)" },
677
- { value: "readonly", label: "Read-only (read, grep, find, ls)" },
678
- { value: "custom", label: "Custom tool list..." },
679
- ];
680
- const scopeResult = await showSelectList(ctx, "Tool Scope", scopeItems, 5);
681
-
682
- let tools: string[] | undefined;
683
- let excludeTools: string[] | undefined;
684
- let noBuiltinTools: boolean | undefined;
685
-
686
- if (scopeResult === "readonly") {
687
- tools = ["read", "grep", "find", "ls"];
688
- excludeTools = ["write", "edit", "bash"];
689
- } else if (scopeResult === "custom") {
690
- const toolsStr = await ctx.ui.input("Tools (comma-separated):");
691
- if (toolsStr?.trim()) tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean);
692
- }
693
-
694
- // 6. Inheritance
695
- const inheritItems: SelectItem[] = [
696
- { value: "true", label: "Inherit system prompt (default)" },
697
- { value: "false", label: "No inheritance (standalone)" },
698
- ];
699
- const inheritResult = await showSelectList(ctx, "System Prompt Inheritance", inheritItems, 3);
700
-
701
- // 7. System prompt body
702
- const systemPrompt = await ctx.ui.input("System prompt (optional, multi-line not supported yet):");
703
-
704
- // 8. Build preset object
705
- const preset: SubagentPreset = {
706
- name: trimmedName,
707
- description: description?.trim() || undefined,
708
- thinkingLevel: thinkingResult || undefined,
709
- inheritSystemPrompt: inheritResult === "false" ? false : undefined,
710
- tools,
711
- excludeTools,
712
- noBuiltinTools,
713
- systemPrompt: systemPrompt?.trim() || undefined,
714
- };
715
-
716
- // 9. Ask where to save
717
- const locationItems: SelectItem[] = [
718
- { value: "project", label: "Project (.pi/brl-subagent/presets/)" },
719
- { value: "global", label: "Global (~/.pi/agent/brl-subagent/presets/)" },
720
- ];
721
- const location = await showSelectList(ctx, "Save Location", locationItems, 3);
722
- if (!location) return;
723
-
724
- const homedir = process.env.HOME || process.env.USERPROFILE || "";
725
- const targetDir = location === "project"
726
- ? path.join(ctx.cwd, ".pi", "brl-subagent", "presets")
727
- : path.join(homedir, ".pi", "agent", "brl-subagent", "presets");
728
-
729
- // 10. Write file
730
- try {
731
- writePresetFile(preset, targetDir);
732
- // 11. Refresh custom presets
733
- state.customPresets = loadCustomPresets(ctx.cwd, state.log);
734
- // Reload templates alongside presets — both are file-derived, and a
735
- // preset add/remove may fix (or break) a template's `preset:` reference.
736
- // Full stack: custom (project+global) merged over builtins.
737
- state.config.templates = loadAllTemplates(ctx.cwd, state.log);
738
- // Issue #81: re-check template `preset:` refs after the mutation — warn
739
- // (never skip) for dangling references instead of preset-less runs.
740
- validateTemplatePresetRefs(
741
- state.config.templates,
742
- getAllPresets(state.builtinPresets, state.customPresets),
743
- state.log,
744
- );
745
- ctx.ui.notify(`Preset "${trimmedName}" saved to ${location === "project" ? "project" : "global"} directory`, "info");
746
- } catch (err) {
747
- ctx.ui.notify(`Failed to save preset: ${(err as Error).message}`, "error");
748
- }
749
- }
750
-
751
- export async function showRemovePreset(
752
- ctx: ExtensionContext,
753
- state: SessionState,
754
- ): Promise<void> {
755
- const homedir = process.env.HOME || process.env.USERPROFILE || "";
756
- const projectDir = path.join(ctx.cwd, ".pi", "brl-subagent", "presets");
757
- const globalDir = path.join(homedir, ".pi", "agent", "brl-subagent", "presets");
758
-
759
- // Scan both directories for .md files
760
- const entries: Array<{ name: string; source: "project" | "global"; filePath: string }> = [];
761
-
762
- for (const dir of [projectDir, globalDir]) {
763
- const source = dir === projectDir ? "project" : "global";
764
- try {
765
- const files = fs.readdirSync(dir);
766
- for (const file of files) {
767
- if (!file.endsWith(".md")) continue;
768
- const filePath = path.join(dir, file);
769
- try {
770
- const content = fs.readFileSync(filePath, "utf-8");
771
- const { meta } = parseFrontmatter(content);
772
- if (meta.name && typeof meta.name === "string") {
773
- entries.push({ name: meta.name as string, source, filePath });
774
- }
775
- } catch {
776
- // Skip unparseable files
777
- }
778
- }
779
- } catch {
780
- // Directory doesn't exist — fine
781
- }
782
- }
783
-
784
- if (entries.length === 0) {
785
- ctx.ui.notify("No custom presets to remove.", "info");
786
- return;
787
- }
788
-
789
- // Show list with source indicators
790
- const items: SelectItem[] = entries.map((e) => ({
791
- value: e.filePath,
792
- label: `[${e.source === "project" ? "P" : "G"}] ${e.name}`,
793
- description: e.source === "project" ? "Project-local" : "Global",
794
- }));
795
-
796
- const result = await showSelectList(ctx, "Remove Preset", items, 10);
797
- if (!result) return;
798
-
799
- // Delete the file
800
- try {
801
- fs.unlinkSync(result);
802
- state.customPresets = loadCustomPresets(ctx.cwd, state.log);
803
- // Reload templates too — a removed preset may orphan a template's
804
- // `preset:` reference, so both file-derived collections refresh together.
805
- // Full stack: custom (project+global) merged over builtins.
806
- state.config.templates = loadAllTemplates(ctx.cwd, state.log);
807
- // Issue #81: re-check template `preset:` refs after the mutation — warn
808
- // (never skip) for dangling references instead of preset-less runs.
809
- validateTemplatePresetRefs(
810
- state.config.templates,
811
- getAllPresets(state.builtinPresets, state.customPresets),
812
- state.log,
813
- );
814
- ctx.ui.notify("Preset removed", "info");
815
- } catch (err) {
816
- ctx.ui.notify(`Failed to remove preset: ${(err as Error).message}`, "error");
817
- }
818
- }
819
-
820
638
  export async function showPresetManager(
821
639
  ctx: ExtensionContext,
822
640
  state: SessionState,
@@ -2179,29 +1997,6 @@ function renderCollapsedText(
2179
1997
  return text;
2180
1998
  }
2181
1999
 
2182
- // ---------------------------------------------------------------------------
2183
- // Chain / parallel helper: one-line subtask summary
2184
- // ---------------------------------------------------------------------------
2185
-
2186
- function renderSubTaskSummary(
2187
- result: SubTaskResult,
2188
- theme: {
2189
- fg: (color: string, text: string) => string;
2190
- },
2191
- maxLines: number,
2192
- ): string {
2193
- const isError = isSubagentError(result);
2194
- const icon = isError
2195
- ? theme.fg("error", "\u2717")
2196
- : theme.fg("success", "\u2713");
2197
- const label = result.label
2198
- ? theme.fg("accent", `${result.label}: `)
2199
- : "";
2200
- const output = getFinalOutput(result.messages);
2201
- const preview = output.split("\n").slice(0, maxLines).join("\n");
2202
- return `${icon} ${label}${theme.fg("toolOutput", preview)}`;
2203
- }
2204
-
2205
2000
  // ---------------------------------------------------------------------------
2206
2001
  // Chain / parallel collapsed rendering
2207
2002
  // ---------------------------------------------------------------------------
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Shared types, constants, and the error/termination classification taxonomy.
1
2
  /**
2
3
  * brl-subagent — Type Definitions
3
4
  *
@@ -6,6 +7,8 @@
6
7
 
7
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
9
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
10
+ import type { Static } from "typebox";
11
+ import type { delegateTaskParamsSchema } from "./schema";
9
12
  import type { TranscriptMessage } from "./transcript-tail";
10
13
 
11
14
  // ---------------------------------------------------------------------------
@@ -299,6 +302,14 @@ export interface SubagentRun {
299
302
  excludeTools?: string[];
300
303
  noBuiltinTools?: boolean;
301
304
  preset?: string;
305
+ // Issue #227: the execution-shape fields survive retries, so a retried
306
+ // background run stays background and a retried branch-mode run keeps its
307
+ // work branch, its approvalMode gating and its force override. Additive:
308
+ // records written before this change simply lack the keys.
309
+ background?: boolean;
310
+ gitMode?: string;
311
+ approvalMode?: string;
312
+ force?: boolean;
302
313
  errorCategory?: ErrorCategory;
303
314
  };
304
315
  }
@@ -450,7 +461,7 @@ export interface GraphTask {
450
461
  noBuiltinTools?: boolean;
451
462
  systemPrompt?: string;
452
463
  inheritSystemPrompt?: boolean;
453
- dependsOn: string[];
464
+ dependsOn?: string[];
454
465
  }
455
466
 
456
467
  /** One wave of tasks executed together in a graph. */
@@ -484,6 +495,24 @@ export type DelegateTaskDetails =
484
495
  | GraphDetails
485
496
  | undefined;
486
497
 
498
+ /**
499
+ * The delegate_task handler's params (issue #229 item 4) — the type the
500
+ * handler's inline block used to spell out, hoisted here so it is nameable.
501
+ *
502
+ * Why it matters: `approvalMode` is in the registered schema and in
503
+ * KNOWN_DELEGATE_KEYS, and reaches execute() at runtime through the spread —
504
+ * but a reader of `params` inside the handler could not see it. The key-set
505
+ * is compile-time ratcheted in src/index.ts against KNOWN_DELEGATE_KEYS
506
+ * (schema ↔ KNOWN_DELEGATE_KEYS ↔ this type); the missing key is invisible at
507
+ * runtime, so only the compiler can catch its removal.
508
+ *
509
+ * A type ALIAS, not an interface: the handler passes `params` to
510
+ * findUnknownParams/resolveSubagentParams (Record<string, unknown> params),
511
+ * and only a type alias gets the implicit index signature an interface
512
+ * lacks.
513
+ */
514
+ export type DelegateTaskParams = Static<typeof delegateTaskParamsSchema>;
515
+
487
516
  // ---------------------------------------------------------------------------
488
517
  // Resolved params (after preset merging + validation)
489
518
  // ---------------------------------------------------------------------------
@@ -504,6 +533,9 @@ export interface ResolvedParams {
504
533
  toolOptions: SubagentToolOptions | undefined;
505
534
  /** Set only when autoRoutePreset chose the preset (not explicitly requested). */
506
535
  autoRoutedPreset: SubagentPreset | undefined;
536
+ /** Set only when auto-route chose the preset: the keyword that matched —
537
+ * surfaced in the dispatch result as the auto-route evidence. */
538
+ autoRouteKeyword?: string;
507
539
  }
508
540
 
509
541
  // ---------------------------------------------------------------------------
@@ -625,7 +657,7 @@ export const RESERVED_COMMAND_NAMES = new Set([
625
657
  "model", "thinking", "concurrency", "depth", "history", "monitor",
626
658
  "preset", "retry", "reset", "priority", "templates", "dashboard", "approval",
627
659
  "costlimit", "historyentries", "sla", "completionnotify",
628
- "graph", "sla-stats",
660
+ "graph", "sla-stats", "compliance",
629
661
  ]);
630
662
 
631
663
  // ---------------------------------------------------------------------------
package/src/unit-run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Per-unit run-entry helpers shared by chain, parallel, and graph modes.
1
2
  /**
2
3
  * Per-unit run-entry helpers extracted from src/index.ts (issue #132).
3
4
  *
@@ -122,6 +123,10 @@ export function createUnitRun(
122
123
  // same priority the run entry itself carried.
123
124
  priority: source.priority ?? priorityFloor,
124
125
  startedAt: new Date().toISOString(),
126
+ // The execution-shape fields (background/gitMode/approvalMode/force) are
127
+ // deliberately omitted: a fan-out unit retries as a SINGLE run, so it must
128
+ // not inherit the fan-out's background-ness. A retry that wants background
129
+ // passes background: true explicitly.
125
130
  originalParams: snapshotOriginalParams({
126
131
  systemPrompt: source.customSP,
127
132
  inheritSystemPrompt: source.inheritSP,
package/src/validate.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: H1 pre-task validation: deterministic tool/thinking/git checks and failure post-mortems.
1
2
  /**
2
3
  * H1: Pre-task validation
3
4
  *
@@ -17,6 +18,15 @@ export interface ValidateConfig {
17
18
  thinkingLevel?: ThinkingLevel;
18
19
  gitMode?: string;
19
20
  outputFile?: string;
21
+ /**
22
+ * delegate_task's `force` param (default false). When true, capability
23
+ * ERRORS (the high-confidence capability-critical mismatches below)
24
+ * degrade to warning-class: they still surface in `warnings`, but
25
+ * `valid` stays true and dispatch proceeds. The outputFile hard
26
+ * conflict is NOT suppressible — a report the subagent cannot write
27
+ * can never succeed, so force does not apply to it.
28
+ */
29
+ force?: boolean;
20
30
  }
21
31
 
22
32
  export interface ValidateResult {
@@ -29,40 +39,109 @@ export interface ValidateResult {
29
39
 
30
40
  interface ToolRequirement {
31
41
  patterns: RegExp[];
42
+ /** ALL-of: every listed tool must be available (default semantics). */
32
43
  requiredTools: string[];
44
+ /**
45
+ * ANY-of: when set, ONE available tool satisfies the requirement and
46
+ * `requiredTools` is ignored — directory exploration is a single
47
+ * capability (the browse built-ins find/ls/grep and bash are
48
+ * interchangeable ways to do it), not a conjunction of all of them.
49
+ */
50
+ anyOfTools?: string[];
51
+ /**
52
+ * Warning lead, rendered as `Task ${description} but …` (and, under a
53
+ * unit prefix, `Task 1 ("deploy"): Task ${description} …`). Starts with
54
+ * the verb ("involves …") so the fixed `Task ` template reads naturally
55
+ * standalone AND prefixed — do not add a leading "task" (issue #220).
56
+ */
33
57
  description: string;
58
+ /**
59
+ * High-confidence, capability-critical subset of `patterns`: a task
60
+ * matching any of these with the capability ABSENT is promoted from
61
+ * warning to ERROR — dispatch does not spawn — unless `force` is set,
62
+ * which degrades the mismatch back to warning class. Only the two
63
+ * capability-critical shapes carry this: a run/execute/test/compile/
64
+ * benchmark task with no bash, and an exploration task with none of
65
+ * find/ls/grep/bash. Entry-less requirements never block (warnings only).
66
+ */
67
+ blockingPatterns?: RegExp[];
68
+ /**
69
+ * Builds the block message: names the missing capability, the resolved
70
+ * toolset, and the exact fix — including the force override. Receives
71
+ * the rendered `tools=…, excludeTools=…` toolset string.
72
+ */
73
+ blockMessage?: (toolset: string) => string;
34
74
  }
35
75
 
76
+ /*
77
+ * COVERAGE BOUNDARY — which wording evades this table (every guard states
78
+ * its coverage). These are word-boundary keyword matches against the raw
79
+ * task text, nothing more. EVADED by: paraphrases that use none of the
80
+ * listed keywords ("hunt for drafts", "browse the repository", "see which
81
+ * files mention X"), inflected forms the patterns do not list ("running",
82
+ * "tests", "searching", "listing" — \btest\b does not match "tests"), and
83
+ * domain phrasing that never names the verb ("kick off CI", "smoke the
84
+ * pipeline"). The opposite failure is equally real: documentation/writing
85
+ * tasks that merely MENTION a keyword ("document how to run the tests")
86
+ * match too — which is why only the two high-confidence capability blocks
87
+ * (blockingPatterns below) escalate to errors, while install/deploy/npm/
88
+ * yarn/pnpm/cargo/pip stay warning-class (docs can name them without
89
+ * needing them). Widening the table is deliberate work: add the keyword
90
+ * here AND update this note.
91
+ */
92
+
36
93
  const TOOL_REQUIREMENTS: ToolRequirement[] = [
37
94
  {
38
95
  patterns: [/\b(write|create|generate|build|implement|add|insert|append)\b/i],
39
96
  requiredTools: ['write', 'edit'],
40
- description: 'task involves writing/creating files',
97
+ description: 'involves writing/creating files',
41
98
  },
42
99
  {
43
100
  patterns: [/\b(edit|modify|update|change|fix|patch|refactor|rename)\b/i],
44
101
  requiredTools: ['write', 'edit'],
45
- description: 'task involves editing files',
102
+ description: 'involves editing files',
46
103
  },
47
104
  {
48
105
  patterns: [/\b(run|execute|test|benchmark|compile|install|deploy|npm|yarn|pnpm|cargo|pip|vitest)\b/i],
49
106
  requiredTools: ['bash'],
50
- description: 'task involves running commands',
107
+ description: 'involves running commands',
108
+ // Blocking subset: only the verbs that unambiguously DEMAND a shell.
109
+ // install/deploy/npm/yarn/pnpm/cargo/pip deliberately stay warning-class
110
+ // — documentation tasks can mention them without executing them.
111
+ blockingPatterns: [/\b(run|execute|test|compile|benchmark)\b/i],
112
+ blockMessage: (toolset) =>
113
+ `Cannot delegate: the task requires the 'bash' tool (running commands) but it is not available ` +
114
+ `in the resolved toolset (${toolset}). Fix: add 'bash' to this dispatch's tools (or remove it ` +
115
+ `from excludeTools) and re-dispatch, or pass force: true to dispatch anyway.`,
116
+ },
117
+ {
118
+ // Directory exploration is ONE capability, satisfied by ANY of the
119
+ // browse built-ins or bash — anyOfTools, not a conjunction.
120
+ patterns: [/\b(search|grep|find|list|locate|glob)\b/i],
121
+ requiredTools: [],
122
+ anyOfTools: ['find', 'ls', 'grep', 'bash'],
123
+ description: 'involves directory exploration',
124
+ blockingPatterns: [/\b(search|grep|find|list|locate|glob)\b/i],
125
+ blockMessage: (toolset) =>
126
+ `Cannot delegate: the task requires directory exploration, satisfied by any of 'find', 'ls', ` +
127
+ `'grep' or 'bash', but none is available in the resolved toolset (${toolset}). Fix: add any of ` +
128
+ `'find', 'ls', 'grep' or 'bash' to this dispatch's tools (or remove it from excludeTools) and ` +
129
+ `re-dispatch, or pass force: true to dispatch anyway.`,
51
130
  },
52
131
  {
53
132
  patterns: [/\b(delete|remove|clean|prune|uninstall|rm)\b/i],
54
133
  requiredTools: ['write', 'bash'],
55
- description: 'task involves deleting files',
134
+ description: 'involves deleting files',
56
135
  },
57
136
  {
58
137
  patterns: [/\b(commit|push|pull|merge|rebase|checkout|branch)\b/i],
59
138
  requiredTools: ['bash'],
60
- description: 'task involves git operations',
139
+ description: 'involves git operations',
61
140
  },
62
141
  {
63
142
  patterns: [/\b(audit|review|analyze|examine|check|inspect|scan|read)\b/i],
64
143
  requiredTools: ['read'],
65
- description: 'task involves reading/analyzing files',
144
+ description: 'involves reading/analyzing files',
66
145
  },
67
146
  ];
68
147
 
@@ -225,15 +304,38 @@ export function validatePreTask(config: ValidateConfig): ValidateResult {
225
304
  return { valid: true, warnings: [], errors: [] };
226
305
  }
227
306
 
228
- // Check tool requirements (warnings, not errors — conductor can override)
307
+ // Check tool requirements. A missing capability is warning-class by
308
+ // default (conductor can judge); the high-confidence capability-critical
309
+ // cases (blockingPatterns) are promoted to ERRORS unless `force` is set.
310
+ const toolset = `tools=${config.toolOptions?.tools?.join(',') ?? 'all'}, excludeTools=${config.toolOptions?.excludeTools?.join(',') ?? 'none'}`;
229
311
  for (const req of TOOL_REQUIREMENTS) {
230
312
  const matches = req.patterns.some(p => p.test(taskText));
231
313
  if (!matches) continue;
232
314
 
233
- for (const tool of req.requiredTools) {
234
- if (!isToolAvailable(tool, config.toolOptions)) {
315
+ const missing = req.anyOfTools
316
+ ? (req.anyOfTools.some(t => isToolAvailable(t, config.toolOptions)) ? [] : [...req.anyOfTools])
317
+ : req.requiredTools.filter(t => !isToolAvailable(t, config.toolOptions));
318
+ if (missing.length === 0) continue;
319
+
320
+ // Capability-critical block: high-confidence pattern matched AND the
321
+ // capability is absent. `force` degrades it to warning class below.
322
+ const capabilityBlock =
323
+ !config.force &&
324
+ req.blockMessage !== undefined &&
325
+ (req.blockingPatterns ?? []).some(p => p.test(taskText));
326
+ if (capabilityBlock) {
327
+ errors.push(req.blockMessage!(toolset));
328
+ continue;
329
+ }
330
+
331
+ if (req.anyOfTools) {
332
+ warnings.push(
333
+ `Task ${req.description} but none of ${req.anyOfTools.map(t => `'${t}'`).join(', ')} is available (${toolset})`,
334
+ );
335
+ } else {
336
+ for (const tool of missing) {
235
337
  warnings.push(
236
- `Task ${req.description} but '${tool}' is not available (tools=${config.toolOptions?.tools?.join(',') ?? 'all'}, excludeTools=${config.toolOptions?.excludeTools?.join(',') ?? 'none'})`,
338
+ `Task ${req.description} but '${tool}' is not available (${toolset})`,
237
339
  );
238
340
  }
239
341
  }
@@ -281,6 +383,17 @@ export function validatePreTask(config: ValidateConfig): ValidateResult {
281
383
  };
282
384
  }
283
385
 
386
+ /**
387
+ * Default deadline for a BACKGROUND run that specifies no explicit `timeout`
388
+ * (issue #28 orphan protection). This is a DEFAULT, not a ceiling: an explicit
389
+ * timeout is honored verbatim (after normalizeTimeout), however long it is
390
+ * (issue #240). Callers must NOT treat this as an upper bound.
391
+ *
392
+ * Kept next to normalizeTimeout (same setTimeout-boundary concern) and shared
393
+ * by the session-manager W3 timer and the index.ts hard-cap timer.
394
+ */
395
+ export const DEFAULT_BACKGROUND_DEADLINE_MS = 30 * 60 * 1000;
396
+
284
397
  /**
285
398
  * Normalize a user-supplied timeout to a safe deadline value, or undefined for
286
399
  * "no timeout". Guards the setTimeout boundary — Node fires `setTimeout(fn, 0)`,
@@ -289,8 +402,10 @@ export function validatePreTask(config: ValidateConfig): ValidateResult {
289
402
  * review M1/m1).
290
403
  *
291
404
  * Returns undefined for: undefined, 0, negative, NaN, Infinity, >=2^31-1.
292
- * Returns the value unchanged otherwise (foreground permits >30min timeouts;
293
- * the background hard cap applies its own 30min Math.min).
405
+ * Returns the value unchanged otherwise. A background run with no explicit
406
+ * timeout (undefined here) falls back to DEFAULT_BACKGROUND_DEADLINE_MS; an
407
+ * explicit timeout is honored verbatim — the 30m default is not a ceiling
408
+ * (issue #240).
294
409
  */
295
410
  export function normalizeTimeout(timeout: number | undefined): number | undefined {
296
411
  if (timeout === undefined) return undefined;
@@ -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.