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/AGENT.md +13 -6
- package/README.md +34 -7
- package/package.json +1 -1
- package/presets/test-engineer.md +2 -0
- package/src/git.ts +15 -0
- package/src/history.ts +17 -0
- package/src/index.ts +393 -334
- package/src/params.ts +31 -6
- package/src/prelude.ts +206 -0
- package/src/prompt.ts +1 -1
- package/src/router.ts +26 -5
- package/src/runner.ts +7 -1
- package/src/session-manager.ts +10 -5
- package/src/types.ts +11 -0
- package/src/unit-run.ts +4 -0
- package/src/validate.ts +111 -10
- package/templates/code-review.md +1 -1
- package/templates/debug-issue.md +2 -1
- package/templates/write-tests.md +1 -1
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 {
|
|
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
|
|
150
|
-
if (
|
|
151
|
-
resolvedPreset =
|
|
172
|
+
const matched = classifyTask(params.task, allPresets);
|
|
173
|
+
if (matched) {
|
|
174
|
+
resolvedPreset = matched.preset;
|
|
152
175
|
wasAutoRouted = true;
|
|
153
|
-
|
|
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}/{
|
|
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
|
|
121
|
+
* Returns the first match, or undefined if no rule matches.
|
|
112
122
|
*/
|
|
113
|
-
export function
|
|
123
|
+
export function classifyTask(
|
|
114
124
|
task: string,
|
|
115
125
|
presets: SubagentPreset[],
|
|
116
|
-
):
|
|
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
|
-
|
|
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
|
|
package/src/session-manager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
|
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
|
-
|
|
234
|
-
|
|
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 (
|
|
337
|
+
`Task ${req.description} but '${tool}' is not available (${toolset})`,
|
|
237
338
|
);
|
|
238
339
|
}
|
|
239
340
|
}
|
package/templates/code-review.md
CHANGED
|
@@ -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.
|
package/templates/debug-issue.md
CHANGED
|
@@ -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.
|
package/templates/write-tests.md
CHANGED
|
@@ -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.
|