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.
- package/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
package/src/unit-run.ts
ADDED
|
@@ -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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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,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.
|