brl-subagent 2.3.4

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/package.json +49 -0
  4. package/presets/code-reviewer.md +37 -0
  5. package/presets/data-analyst.md +33 -0
  6. package/presets/debugger.md +36 -0
  7. package/presets/dev-agent.md +30 -0
  8. package/presets/rapid-prototyper.md +25 -0
  9. package/presets/refactorer.md +34 -0
  10. package/presets/security-auditor.md +39 -0
  11. package/presets/tech-writer.md +33 -0
  12. package/presets/test-engineer.md +34 -0
  13. package/src/concurrency.ts +186 -0
  14. package/src/diff.ts +102 -0
  15. package/src/event-bus.ts +86 -0
  16. package/src/git.ts +187 -0
  17. package/src/history.ts +177 -0
  18. package/src/index.ts +3824 -0
  19. package/src/logging.ts +135 -0
  20. package/src/messaging.ts +164 -0
  21. package/src/metrics.ts +221 -0
  22. package/src/model-availability.ts +73 -0
  23. package/src/notify-completion.ts +207 -0
  24. package/src/params.ts +225 -0
  25. package/src/preflight.ts +150 -0
  26. package/src/presets.ts +425 -0
  27. package/src/prompt.ts +169 -0
  28. package/src/reports.ts +325 -0
  29. package/src/router.ts +133 -0
  30. package/src/runner.ts +877 -0
  31. package/src/sanitize.ts +365 -0
  32. package/src/scheduler.ts +187 -0
  33. package/src/session-manager.ts +1065 -0
  34. package/src/state.ts +590 -0
  35. package/src/templates.ts +469 -0
  36. package/src/transcript-tail.ts +327 -0
  37. package/src/transcript.ts +119 -0
  38. package/src/tui-format.ts +51 -0
  39. package/src/tui.ts +3103 -0
  40. package/src/types.ts +803 -0
  41. package/src/unit-run.ts +269 -0
  42. package/src/update.ts +109 -0
  43. package/src/validate.ts +302 -0
  44. package/templates/analyze-data.md +9 -0
  45. package/templates/code-review.md +10 -0
  46. package/templates/debug-issue.md +9 -0
  47. package/templates/implement-feature.md +9 -0
  48. package/templates/prototype.md +9 -0
  49. package/templates/refactor.md +9 -0
  50. package/templates/security-audit.md +10 -0
  51. package/templates/write-docs.md +9 -0
  52. package/templates/write-tests.md +10 -0
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Per-unit run-entry helpers extracted from src/index.ts (issue #132).
3
+ *
4
+ * Chain, parallel and graph modes each build a per-unit SubagentRun entry,
5
+ * wrap onUpdate for live-monitor progress, and finalize the entry on the
6
+ * success path. Those blocks were duplicated 4x/3x/3x; these helpers make the
7
+ * behavior single-source. This is a behavior-identical refactor (the one
8
+ * intentional delta: the mode-specific finalize debug line is replaced by a
9
+ * single generic log inside finalizeUnitRun).
10
+ *
11
+ * The crash paths (buildCrashResult + finalizeLiveSubagent + finalizeRunRecord
12
+ * + classify + persistRun + rethrow) are extracted as finalizeUnitRunCrash;
13
+ * the per-unit live registration and the history prune are extracted as
14
+ * registerLiveRun and pruneHistoryIfNeeded. Single mode's record creation
15
+ * block and the background/retry registration paths remain in index.ts.
16
+ */
17
+
18
+ import type {
19
+ AgentToolResult,
20
+ AgentToolUpdateCallback,
21
+ } from "@earendil-works/pi-agent-core";
22
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
23
+
24
+ import type {
25
+ SubagentResult,
26
+ SubagentRun,
27
+ SubagentToolOptions,
28
+ Priority,
29
+ } from "./types";
30
+ import { getFinalOutput, classifyError } from "./types";
31
+ import { snapshotOriginalParams } from "./params";
32
+ import { capOutput, stripAnsi, buildCrashResult } from "./sanitize";
33
+ import { finalizeRunRecord, pruneSessionRuns } from "./history";
34
+ import type { Logger } from "./logging";
35
+ import { SessionState } from "./state";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Structural source for a per-unit run entry
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * The per-unit fields shared by chain/parallel/graph modes (`merged` from
43
+ * mergeSubTaskParams). Kept structural so the call sites verify by shape —
44
+ * mergeSubTaskParams itself is not imported (it lives in index.ts).
45
+ */
46
+ export interface UnitRunSource {
47
+ task: string;
48
+ label?: string;
49
+ customSP?: string;
50
+ inheritSP: boolean;
51
+ model?: string;
52
+ thinkingLevel: string;
53
+ priority?: Priority;
54
+ outputFile?: string;
55
+ timeout?: number;
56
+ effectiveCwd: string;
57
+ toolOptions?: SubagentToolOptions;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // makeLiveOnUpdate
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Wrap a foreground `onUpdate` callback so partial results also stream into
66
+ * the live monitor for this run's `runId`. Returns undefined when `onUpdate`
67
+ * is undefined (the callers then pass the unwrapped value through).
68
+ */
69
+ export function makeLiveOnUpdate(
70
+ state: SessionState,
71
+ runId: string,
72
+ onUpdate?: AgentToolUpdateCallback<SubagentResult>,
73
+ ): AgentToolUpdateCallback<SubagentResult> | undefined {
74
+ return onUpdate
75
+ ? (partial: AgentToolResult<SubagentResult>) => {
76
+ onUpdate(partial);
77
+ if (partial.details) {
78
+ state.updateLiveSubagent(
79
+ runId,
80
+ getFinalOutput(partial.details.messages),
81
+ partial.details.usage.input,
82
+ partial.details.usage.output,
83
+ partial.details.liveTranscript,
84
+ );
85
+ }
86
+ }
87
+ : undefined;
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // createUnitRun
92
+ // ---------------------------------------------------------------------------
93
+
94
+ /**
95
+ * Create the per-unit SubagentRun entry for a chain/parallel/graph unit and
96
+ * return it with a fresh runId (the runId doubles as the live id, so
97
+ * sweepStaleLiveSubagents finds a 'running' record for every foreground live
98
+ * entry). `priorityFloor` is the call-level floor for this mode; `source.priority`
99
+ * wins when present. `presetName` feeds snapshotOriginalParams (issue #114
100
+ * retry-snapshot visibility).
101
+ */
102
+ export function createUnitRun(
103
+ source: UnitRunSource,
104
+ stepModel: { provider: string; id: string },
105
+ priorityFloor: Priority,
106
+ presetName?: string,
107
+ ): { runId: string; run: SubagentRun } {
108
+ const runId = crypto.randomUUID();
109
+ const run: SubagentRun = {
110
+ id: runId,
111
+ task: source.task,
112
+ label: source.label,
113
+ // Issue #98 symmetry: the background entry carries the caller
114
+ // label as `description` — kept separate from `label`.
115
+ description: source.label,
116
+ status: "running",
117
+ model: `${stepModel.provider}/${stepModel.id}`,
118
+ thinkingLevel: source.thinkingLevel,
119
+ // Issue #114: per-unit priority wins; the call-level floor is the
120
+ // fallback when the unit declares none. Issue #119 R3: snapshot the
121
+ // RESOLVED priority so a retry of a fallback-priority unit restores the
122
+ // same priority the run entry itself carried.
123
+ priority: source.priority ?? priorityFloor,
124
+ startedAt: new Date().toISOString(),
125
+ originalParams: snapshotOriginalParams({
126
+ systemPrompt: source.customSP,
127
+ inheritSystemPrompt: source.inheritSP,
128
+ model: source.model,
129
+ thinkingLevel: source.thinkingLevel,
130
+ priority: source.priority ?? priorityFloor,
131
+ outputFile: source.outputFile,
132
+ timeout: source.timeout,
133
+ cwd: source.effectiveCwd,
134
+ tools: source.toolOptions?.tools,
135
+ excludeTools: source.toolOptions?.excludeTools,
136
+ noBuiltinTools: source.toolOptions?.noBuiltinTools,
137
+ preset: presetName,
138
+ }),
139
+ };
140
+ return { runId, run };
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // finalizeUnitRun
145
+ // ---------------------------------------------------------------------------
146
+
147
+ /**
148
+ * Finalize a per-unit run entry on the success path: status/duration/cost/
149
+ * tokens/sanitized output land on the entry, the errorCategory is classified
150
+ * onto originalParams, the entry is persisted, run history is pruned past
151
+ * maxHistoryEntries, and the live entry is released. `runId` is `run.id` (the
152
+ * run entry carries the same id used as the live id).
153
+ */
154
+ export function finalizeUnitRun(
155
+ state: SessionState,
156
+ pi: ExtensionAPI,
157
+ ctx: ExtensionContext,
158
+ run: SubagentRun,
159
+ result: SubagentResult,
160
+ log: Logger,
161
+ ): void {
162
+ const finalOutput = capOutput(stripAnsi(getFinalOutput(result.messages)));
163
+ finalizeRunRecord(run, result, finalOutput, new Date(run.startedAt).getTime());
164
+ run.originalParams = { ...run.originalParams, errorCategory: result.errorCategory };
165
+ state.persistRun(pi, run);
166
+ pruneHistoryIfNeeded(state, ctx, log);
167
+ state.finalizeLiveSubagent(run.id);
168
+ log.debug("Unit run finalized", { runId: run.id, status: run.status });
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // finalizeUnitRunCrash
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /**
176
+ * Finalize a per-unit run entry on the crash path and rethrow the original
177
+ * error (returns `never`). Single-source for the near-identical crash blocks
178
+ * that chain/parallel/graph modes each duplicated: build the crash result,
179
+ * release the live entry, land the failure + classified errorCategory on the
180
+ * record, persist it, log the mode-specific failure, and rethrow so the
181
+ * caller's outer catch/`finally` semantics are preserved.
182
+ *
183
+ * `modeLabel` is the human label ("Chain step", "Parallel subtask", "Graph
184
+ * node"); `logContext` carries the mode-specific context fields (chain passes
185
+ * the step index, parallel passes the task index, graph passes the node id)
186
+ * spread into the log call alongside runId/error.
187
+ */
188
+ export function finalizeUnitRunCrash(
189
+ state: SessionState,
190
+ pi: ExtensionAPI,
191
+ run: SubagentRun,
192
+ err: unknown,
193
+ modeLabel: string,
194
+ resolvedCwd: string,
195
+ log: Logger,
196
+ logContext?: Record<string, unknown>,
197
+ ): never {
198
+ const crash = buildCrashResult(modeLabel, err, resolvedCwd);
199
+ state.finalizeLiveSubagent(run.id);
200
+ const crashOutput = state.subagentSessions.get(run.id)?.liveOutput ?? "";
201
+ finalizeRunRecord(run, crash.details, crashOutput, new Date(run.startedAt).getTime());
202
+ // buildCrashResult.details carries no errorCategory — classify it
203
+ // so the entry follows the completion path's errorCategory
204
+ // convention (issue #114 retry-snapshot visibility).
205
+ run.originalParams = {
206
+ ...run.originalParams,
207
+ errorCategory: classifyError(crash.details),
208
+ };
209
+ state.persistRun(pi, run);
210
+ log.error(`${modeLabel} crashed`, {
211
+ ...logContext,
212
+ runId: run.id,
213
+ error: crash.details.errorMessage,
214
+ });
215
+ throw err;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // pruneHistoryIfNeeded
220
+ // ---------------------------------------------------------------------------
221
+
222
+ /**
223
+ * Prune persisted run history when `maxHistoryEntries` is configured, logging
224
+ * the count when any entries are removed. Single-source for the identical
225
+ * prune block that chain/parallel/graph/single modes each repeated.
226
+ */
227
+ export function pruneHistoryIfNeeded(
228
+ state: SessionState,
229
+ ctx: ExtensionContext,
230
+ log: Logger,
231
+ ): void {
232
+ if (state.config.maxHistoryEntries > 0) {
233
+ const p = pruneSessionRuns(ctx, state.config.maxHistoryEntries);
234
+ if (p > 0) log.debug("Run history pruned", { pruned: p });
235
+ }
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // registerLiveRun
240
+ // ---------------------------------------------------------------------------
241
+
242
+ /**
243
+ * Register a per-unit run in the live monitor from its `SubagentRun` entry.
244
+ * label/task/model/thinkingLevel/priority and startedAt (converted to epoch ms
245
+ * for the LiveSubagent shape) all derive from the record, so the registration
246
+ * is single-source. `run.startedAt` is used as the live timestamp (more
247
+ * correct than `Date.now()` at registration; display-identical).
248
+ *
249
+ * `labelOverride` supplies the live entry's label when the caller wants one
250
+ * that differs from `run.label` (e.g. chain's unlabeled-step fallback).
251
+ * When omitted the live label defaults to `run.label`.
252
+ */
253
+ export function registerLiveRun(
254
+ state: SessionState,
255
+ run: SubagentRun,
256
+ ctx: ExtensionContext,
257
+ labelOverride?: string,
258
+ ): void {
259
+ state.registerLiveSubagent(run.id, {
260
+ id: run.id,
261
+ label: labelOverride ?? run.label,
262
+ task: run.task,
263
+ model: run.model,
264
+ thinkingLevel: run.thinkingLevel,
265
+ priority: run.priority,
266
+ startedAt: new Date(run.startedAt).getTime(),
267
+ ctx,
268
+ });
269
+ }
package/src/update.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * brl-subagent — Version Update Notifier
3
+ *
4
+ * Checks GitHub releases for newer versions of brl-subagent.
5
+ * Runs non-blocking with a 5-second timeout on session start.
6
+ */
7
+
8
+ import type { Logger } from "./logging";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Types
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export interface UpdateCheckResult {
15
+ available: boolean;
16
+ version?: string;
17
+ url?: string;
18
+ }
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Semver comparison
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Compare two semver strings (e.g., "2.1.0" vs "2.0.1").
26
+ * Returns 1 if a > b, -1 if a < b, 0 if equal.
27
+ */
28
+ function compareSemver(a: string, b: string): number {
29
+ const aParts = a.split(".").map(Number);
30
+ const bParts = b.split(".").map(Number);
31
+ const len = Math.max(aParts.length, bParts.length);
32
+
33
+ for (let i = 0; i < len; i++) {
34
+ const aVal = aParts[i] ?? 0;
35
+ const bVal = bParts[i] ?? 0;
36
+ if (aVal > bVal) return 1;
37
+ if (aVal < bVal) return -1;
38
+ }
39
+ return 0;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Main export
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * Check GitHub for a newer release of brl-subagent.
48
+ *
49
+ * @param currentVersion - The current version string (e.g., "2.0.1")
50
+ * @param log - Optional logger for debug output
51
+ * @returns Update check result, or null on any error (never throws)
52
+ */
53
+ export async function checkForUpdates(
54
+ currentVersion: string,
55
+ log?: Logger,
56
+ ): Promise<UpdateCheckResult | null> {
57
+ try {
58
+ const controller = new AbortController();
59
+ const timeoutId = setTimeout(() => controller.abort(), 5_000);
60
+
61
+ const response = await fetch(
62
+ "https://api.github.com/repos/azmeenafandi/brl-subagent/releases/latest",
63
+ {
64
+ headers: {
65
+ Accept: "application/vnd.github+json",
66
+ "User-Agent": "brl-subagent",
67
+ },
68
+ signal: controller.signal,
69
+ },
70
+ );
71
+
72
+ clearTimeout(timeoutId);
73
+
74
+ if (!response.ok) {
75
+ log?.debug("Update check: non-OK response", { status: response.status });
76
+ return null;
77
+ }
78
+
79
+ const data = (await response.json()) as {
80
+ tag_name?: string;
81
+ html_url?: string;
82
+ };
83
+
84
+ if (!data.tag_name || !data.html_url) {
85
+ log?.debug("Update check: missing tag_name or html_url in response");
86
+ return null;
87
+ }
88
+
89
+ // Strip "v" prefix from tag_name (e.g., "v2.1.0" → "2.1.0")
90
+ const latestVersion = data.tag_name.replace(/^v/, "");
91
+
92
+ const cmp = compareSemver(latestVersion, currentVersion);
93
+
94
+ if (cmp > 0) {
95
+ return {
96
+ available: true,
97
+ version: latestVersion,
98
+ url: data.html_url,
99
+ };
100
+ }
101
+
102
+ return { available: false };
103
+ } catch (err) {
104
+ log?.debug("Update check failed", {
105
+ error: err instanceof Error ? err.message : String(err),
106
+ });
107
+ return null;
108
+ }
109
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * H1: Pre-task validation
3
+ *
4
+ * Deterministic checks that validate a subagent's tool configuration
5
+ * and thinking level match the task description. Runs in code (not
6
+ * LLM context), so it's consistent regardless of conductor state.
7
+ */
8
+
9
+ import type { ThinkingLevel, SubagentToolOptions } from './types';
10
+ import { THINKING_LEVELS } from './types';
11
+
12
+ // ── Types ────────────────────────────────────────────────────────────
13
+
14
+ export interface ValidateConfig {
15
+ task: string;
16
+ toolOptions?: SubagentToolOptions;
17
+ thinkingLevel?: ThinkingLevel;
18
+ gitMode?: string;
19
+ outputFile?: string;
20
+ }
21
+
22
+ export interface ValidateResult {
23
+ valid: boolean;
24
+ warnings: string[];
25
+ errors: string[];
26
+ }
27
+
28
+ // ── Keyword patterns ─────────────────────────────────────────────────
29
+
30
+ interface ToolRequirement {
31
+ patterns: RegExp[];
32
+ requiredTools: string[];
33
+ description: string;
34
+ }
35
+
36
+ const TOOL_REQUIREMENTS: ToolRequirement[] = [
37
+ {
38
+ patterns: [/\b(write|create|generate|build|implement|add|insert|append)\b/i],
39
+ requiredTools: ['write', 'edit'],
40
+ description: 'task involves writing/creating files',
41
+ },
42
+ {
43
+ patterns: [/\b(edit|modify|update|change|fix|patch|refactor|rename)\b/i],
44
+ requiredTools: ['write', 'edit'],
45
+ description: 'task involves editing files',
46
+ },
47
+ {
48
+ patterns: [/\b(run|execute|test|benchmark|compile|install|deploy|npm|yarn|pnpm|cargo|pip|vitest)\b/i],
49
+ requiredTools: ['bash'],
50
+ description: 'task involves running commands',
51
+ },
52
+ {
53
+ patterns: [/\b(delete|remove|clean|prune|uninstall|rm)\b/i],
54
+ requiredTools: ['write', 'bash'],
55
+ description: 'task involves deleting files',
56
+ },
57
+ {
58
+ patterns: [/\b(commit|push|pull|merge|rebase|checkout|branch)\b/i],
59
+ requiredTools: ['bash'],
60
+ description: 'task involves git operations',
61
+ },
62
+ {
63
+ patterns: [/\b(audit|review|analyze|examine|check|inspect|scan|read)\b/i],
64
+ requiredTools: ['read'],
65
+ description: 'task involves reading/analyzing files',
66
+ },
67
+ ];
68
+
69
+ interface ThinkingRequirement {
70
+ patterns: RegExp[];
71
+ minLevel: ThinkingLevel;
72
+ description: string;
73
+ }
74
+
75
+ const THINKING_REQUIREMENTS: ThinkingRequirement[] = [
76
+ {
77
+ patterns: [/\b(security|vulnerability|exploit|injection|attack|penetration)\b/i],
78
+ minLevel: 'high',
79
+ description: 'security analysis',
80
+ },
81
+ {
82
+ patterns: [/\b(debug|diagnose|root.cause|race.condition|deadlock|memory.leak)\b/i],
83
+ minLevel: 'high',
84
+ description: 'complex debugging',
85
+ },
86
+ {
87
+ patterns: [/\b(architecture|design|refactor|migration|breaking.change)\b/i],
88
+ minLevel: 'medium',
89
+ description: 'architectural work',
90
+ },
91
+ {
92
+ patterns: [/\b(implement|develop|build|feature)\b/i],
93
+ minLevel: 'medium',
94
+ description: 'implementation work',
95
+ },
96
+ ];
97
+
98
+ // ── Helpers ──────────────────────────────────────────────────────────
99
+
100
+ function thinkingLevelIndex(level: ThinkingLevel): number {
101
+ return THINKING_LEVELS.indexOf(level);
102
+ }
103
+
104
+ function isToolAvailable(
105
+ toolName: string,
106
+ toolOptions?: SubagentToolOptions,
107
+ ): boolean {
108
+ // noBuiltinTools disables ALL built-in tools (read, bash, edit, write, ...)
109
+ // while keeping extension/custom tools — so no built-in is available.
110
+ if (toolOptions?.noBuiltinTools && isBuiltinTool(toolName)) return false;
111
+ if (toolOptions?.excludeTools?.includes(toolName)) return false;
112
+ if (toolOptions?.tools && !toolOptions.tools.includes(toolName)) return false;
113
+ return true;
114
+ }
115
+
116
+ // Built-in tool names pi ships (runner.ts maps noBuiltinTools → --no-builtin-tools).
117
+ const BUILTIN_TOOLS = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']);
118
+
119
+ function isBuiltinTool(toolName: string): boolean {
120
+ return BUILTIN_TOOLS.has(toolName);
121
+ }
122
+
123
+ // ── Post-mortem diagnostics (H3) ─────────────────────────────────────
124
+
125
+ export interface DiagnoseConfig {
126
+ task: string;
127
+ toolOptions?: SubagentToolOptions;
128
+ thinkingLevel?: ThinkingLevel;
129
+ gitMode?: string;
130
+ errorMessage?: string;
131
+ exitCode?: number;
132
+ timeout?: number;
133
+ }
134
+
135
+ export function diagnoseFailure(config: DiagnoseConfig): string[] {
136
+ const suggestions: string[] = [];
137
+ const taskText = (config.task || '').toLowerCase();
138
+ const errMsg = (config.errorMessage || '').toLowerCase();
139
+
140
+ // Rule 1: Git mode mismatch — task needs git but gitMode is 'none'
141
+ const gitPatterns = /\b(commit|push|merge|branch)\b/i;
142
+ if (gitPatterns.test(taskText) && config.gitMode === 'none') {
143
+ suggestions.push(
144
+ "Set gitMode to 'branch' or 'auto' to enable git operations",
145
+ );
146
+ }
147
+
148
+ // Rule 2: Thinking level too low for security
149
+ const securityPatterns = /\b(security|vulnerability|audit)\b/i;
150
+ if (
151
+ securityPatterns.test(taskText) &&
152
+ (config.thinkingLevel === 'off' || config.thinkingLevel === 'low')
153
+ ) {
154
+ suggestions.push(
155
+ "Set thinkingLevel to 'high' or 'xhigh' for security analysis",
156
+ );
157
+ }
158
+
159
+ // Rule 3: Thinking level too low for debugging
160
+ const debugPatterns = /\b(debug|diagnose|root.cause)\b/i;
161
+ if (
162
+ debugPatterns.test(taskText) &&
163
+ (config.thinkingLevel === 'off' || config.thinkingLevel === 'low')
164
+ ) {
165
+ suggestions.push(
166
+ "Set thinkingLevel to 'high' for complex debugging",
167
+ );
168
+ }
169
+
170
+ // Rule 4: Write/edit tool excluded but task needs file creation/editing
171
+ const writePatterns = /\b(write|create|edit|implement)\b/i;
172
+ if (writePatterns.test(taskText)) {
173
+ const excluded = config.toolOptions?.excludeTools ?? [];
174
+ if (excluded.includes('write') || excluded.includes('edit')) {
175
+ suggestions.push(
176
+ "Remove 'write' and 'edit' from excludeTools",
177
+ );
178
+ }
179
+ }
180
+
181
+ // Rule 5: Bash tool excluded but task needs command execution
182
+ const runPatterns = /\b(run|execute|test|vitest)\b/i;
183
+ if (runPatterns.test(taskText)) {
184
+ const excluded = config.toolOptions?.excludeTools ?? [];
185
+ if (excluded.includes('bash')) {
186
+ suggestions.push(
187
+ "Remove 'bash' from excludeTools to enable command execution",
188
+ );
189
+ }
190
+ }
191
+
192
+ // Rule 6: Timeout with xhigh thinking
193
+ if (
194
+ errMsg.includes('timed out') &&
195
+ config.thinkingLevel === 'xhigh'
196
+ ) {
197
+ suggestions.push(
198
+ "xhigh thinking is expensive — try 'high' or increase timeout",
199
+ );
200
+ }
201
+
202
+ // Rule 7: Very low timeout
203
+ if (errMsg.includes('timed out') && config.timeout !== undefined && config.timeout < 30000) {
204
+ suggestions.push(
205
+ "Timeout is very low — consider increasing timeout",
206
+ );
207
+ }
208
+
209
+ return suggestions;
210
+ }
211
+
212
+ // ── Main validation ──────────────────────────────────────────────────
213
+
214
+ export function validatePreTask(config: ValidateConfig): ValidateResult {
215
+ const warnings: string[] = [];
216
+ const errors: string[] = [];
217
+ const taskText = config.task || '';
218
+
219
+ // Skip validation for empty tasks (chain/parallel/graph don't always have
220
+ // task). Exception: an outputFile is a HARD conflict check (block C below)
221
+ // that must run even with empty top-level task text — chain/parallel/graph
222
+ // validate the mode-level outputFile at mode entry (issue #34). Empty text
223
+ // matches no keyword patterns, so no warnings can false-fire from this path.
224
+ if (!taskText.trim() && !config.outputFile) {
225
+ return { valid: true, warnings: [], errors: [] };
226
+ }
227
+
228
+ // Check tool requirements (warnings, not errors — conductor can override)
229
+ for (const req of TOOL_REQUIREMENTS) {
230
+ const matches = req.patterns.some(p => p.test(taskText));
231
+ if (!matches) continue;
232
+
233
+ for (const tool of req.requiredTools) {
234
+ if (!isToolAvailable(tool, config.toolOptions)) {
235
+ warnings.push(
236
+ `Task ${req.description} but '${tool}' is not available (tools=${config.toolOptions?.tools?.join(',') ?? 'all'}, excludeTools=${config.toolOptions?.excludeTools?.join(',') ?? 'none'})`,
237
+ );
238
+ }
239
+ }
240
+ }
241
+
242
+ // Check git mode
243
+ const gitPatterns = [/\b(commit|push|pull|merge|rebase|checkout|branch|pr|pull.request)\b/i];
244
+ if (gitPatterns.some(p => p.test(taskText)) && config.gitMode === 'none') {
245
+ warnings.push(
246
+ `Task involves git operations but gitMode is 'none' — subagent cannot create commits or branches`,
247
+ );
248
+ }
249
+
250
+ // Check thinking level recommendations
251
+ for (const req of THINKING_REQUIREMENTS) {
252
+ const matches = req.patterns.some(p => p.test(taskText));
253
+ if (!matches) continue;
254
+
255
+ const taskLevel = config.thinkingLevel ? thinkingLevelIndex(config.thinkingLevel) : -1;
256
+ const minLevel = thinkingLevelIndex(req.minLevel);
257
+
258
+ if (taskLevel >= 0 && taskLevel < minLevel) {
259
+ warnings.push(
260
+ `Task involves ${req.description} but thinkingLevel is '${config.thinkingLevel}' — consider '${req.minLevel}' or higher`,
261
+ );
262
+ }
263
+ }
264
+
265
+ // C: outputFile requires the write tool — a hard conflict, not a warning.
266
+ // The extension instructs the subagent to write findings to outputFile,
267
+ // but a preset's excludeTools is a hard constraint nothing can override.
268
+ // Reject before any tokens are spent (loud failure).
269
+ if (config.outputFile && !isToolAvailable('write', config.toolOptions)) {
270
+ errors.push(
271
+ `outputFile is set but the 'write' tool is not available ` +
272
+ `(tools=${config.toolOptions?.tools?.join(',') ?? 'all'}, excludeTools=${config.toolOptions?.excludeTools?.join(',') ?? 'none'}). ` +
273
+ `The subagent cannot write the report. Remove outputFile or adjust the preset/tool restrictions.`
274
+ );
275
+ }
276
+
277
+ return {
278
+ valid: errors.length === 0, // Errors are hard conflicts; warnings are informational
279
+ warnings,
280
+ errors,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Normalize a user-supplied timeout to a safe deadline value, or undefined for
286
+ * "no timeout". Guards the setTimeout boundary — Node fires `setTimeout(fn, 0)`,
287
+ * negative, NaN, and >=2^31-1 delays immediately (~1ms), so an unvalidated
288
+ * timeout is an instant kill, not a generous deadline (issue #28, PR #49
289
+ * review M1/m1).
290
+ *
291
+ * 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).
294
+ */
295
+ export function normalizeTimeout(timeout: number | undefined): number | undefined {
296
+ if (timeout === undefined) return undefined;
297
+ if (!Number.isFinite(timeout)) return undefined;
298
+ if (timeout <= 0) return undefined;
299
+ // Node's setTimeout overflows to ~1ms at 2^31-1 (TimeoutOverflowWarning).
300
+ if (timeout >= 2 ** 31 - 1) return undefined;
301
+ return timeout;
302
+ }
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: analyze-data
3
+ description: Data analysis with patterns and recommendations
4
+ preset: data-analyst
5
+ thinkingLevel: medium
6
+ ---
7
+ Analyze ${dataset}. Find patterns, outliers, and trends.
8
+
9
+ Summarize with concrete numbers and a recommendation.
@@ -0,0 +1,10 @@
1
+ ---
2
+ name: code-review
3
+ description: Review code for correctness, security, and style
4
+ preset: code-reviewer
5
+ thinkingLevel: medium
6
+ ---
7
+ Review ${target} for correctness, security, and style.
8
+
9
+ Report findings as a numbered list with file:line references,
10
+ prioritized blocker → nit. Read-only — no edits.