brl-subagent 2.3.8 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT.md +18 -6
- package/README.md +49 -10
- package/package.json +11 -9
- package/presets/test-engineer.md +2 -0
- package/src/concurrency.ts +1 -0
- package/src/diff.ts +1 -0
- package/src/event-bus.ts +1 -0
- package/src/git.ts +16 -0
- package/src/history.ts +18 -0
- package/src/index.ts +423 -678
- package/src/logging.ts +21 -1
- package/src/messaging.ts +1 -0
- package/src/metrics.ts +1 -0
- package/src/model-availability.ts +1 -0
- package/src/notify-completion.ts +1 -0
- package/src/params.ts +32 -6
- package/src/paths.ts +1 -0
- package/src/preflight.ts +1 -0
- package/src/prelude.ts +207 -0
- package/src/presets.ts +1 -0
- package/src/prompt.ts +2 -1
- package/src/reports.ts +64 -8
- package/src/router.ts +27 -5
- package/src/runner.ts +8 -1
- package/src/sanitize.ts +1 -0
- package/src/scheduler.ts +1 -0
- package/src/schema.ts +254 -0
- package/src/session-manager.ts +54 -101
- package/src/state.ts +1 -0
- package/src/templates.ts +1 -0
- package/src/transcript-tail.ts +1 -0
- package/src/transcript.ts +1 -0
- package/src/tui-format.ts +1 -0
- package/src/tui.ts +3 -208
- package/src/types.ts +34 -2
- package/src/unit-run.ts +5 -0
- package/src/validate.ts +127 -12
- package/templates/code-review.md +1 -1
- package/templates/debug-issue.md +2 -1
- package/templates/write-tests.md +1 -1
package/src/logging.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Purpose: Leveled structured logging with file output and rotation under `.pi/subagent-logs/`.
|
|
1
2
|
/**
|
|
2
3
|
* brl-subagent — Structured Logging (F10)
|
|
3
4
|
*
|
|
@@ -41,8 +42,27 @@ const LOG_LEVELS: Record<LogLevel, number> = {
|
|
|
41
42
|
error: 3,
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
/** The level names BRL_LOG_LEVEL accepts (the keys of LOG_LEVELS). */
|
|
46
|
+
const LOG_LEVEL_NAMES: readonly LogLevel[] = ["debug", "info", "warn", "error"];
|
|
47
|
+
|
|
44
48
|
/** Current minimum log level — can be changed at runtime */
|
|
45
|
-
let minLevel: LogLevel =
|
|
49
|
+
let minLevel: LogLevel = resolveInitialLogLevel();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Issue #235: the log verbosity knob was never wired, so the three `log.debug()`
|
|
53
|
+
* call sites were permanently suppressed with no way to turn them on.
|
|
54
|
+
* `BRL_LOG_LEVEL` is read ONCE at module load (logger call sites are spread
|
|
55
|
+
* across the codebase, and re-reading env per call would be wasteful and
|
|
56
|
+
* non-deterministic mid-run). `setLogLevel()` remains the programmatic override.
|
|
57
|
+
* Anything outside debug|info|warn|error falls back to "info".
|
|
58
|
+
*/
|
|
59
|
+
function resolveInitialLogLevel(): LogLevel {
|
|
60
|
+
const raw = process.env.BRL_LOG_LEVEL?.trim().toLowerCase();
|
|
61
|
+
if (raw && (LOG_LEVEL_NAMES as readonly string[]).includes(raw)) {
|
|
62
|
+
return raw as LogLevel;
|
|
63
|
+
}
|
|
64
|
+
return "info";
|
|
65
|
+
}
|
|
46
66
|
|
|
47
67
|
// ---------------------------------------------------------------------------
|
|
48
68
|
// Logger
|
package/src/messaging.ts
CHANGED
package/src/metrics.ts
CHANGED
package/src/notify-completion.ts
CHANGED
package/src/params.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Purpose: Param resolution: per-call + preset merge, thinking caps, timeout normalization, tool and git/approval resolution, auto-routing.
|
|
1
2
|
/**
|
|
2
3
|
* brl-subagent — Parameter Resolution (issue #59)
|
|
3
4
|
*
|
|
@@ -23,7 +24,7 @@ import type {
|
|
|
23
24
|
} from "./types";
|
|
24
25
|
import { resolveThinkingLevel } from "./types";
|
|
25
26
|
import { getAllPresets, getPreset } from "./presets";
|
|
26
|
-
import {
|
|
27
|
+
import { classifyTask } from "./router";
|
|
27
28
|
import { normalizeTimeout } from "./validate";
|
|
28
29
|
import type { SessionState } from "./state";
|
|
29
30
|
import type { Logger } from "./logging";
|
|
@@ -49,7 +50,7 @@ export const KNOWN_DELEGATE_KEYS = new Set([
|
|
|
49
50
|
"outputFile", "label", "model", "timeout", "cwd", "tools",
|
|
50
51
|
"excludeTools", "noBuiltinTools", "preset", "template", "params",
|
|
51
52
|
"retryRunId", "gitMode", "retryOnTimeout", "approvalMode", "background",
|
|
52
|
-
"priority", "chain", "tasks", "graph",
|
|
53
|
+
"priority", "chain", "tasks", "graph", "force",
|
|
53
54
|
] as const);
|
|
54
55
|
|
|
55
56
|
/**
|
|
@@ -76,6 +77,11 @@ export function findUnknownParams(
|
|
|
76
77
|
* degrade retries of that path.
|
|
77
78
|
* NOTE: the schema's `params` (template slots) key is intentionally NOT
|
|
78
79
|
* snapshotted — it is a template-resolution input, not a retry override.
|
|
80
|
+
* The execution-shape keys (background, gitMode, approvalMode, force) ARE
|
|
81
|
+
* snapshotted: omitting them is the known failure mode of this design, since
|
|
82
|
+
* resolveRetryParams rebuilds a fresh literal and a key missing here is both
|
|
83
|
+
* unrestored AND discarded when passed explicitly on a retry call. Records
|
|
84
|
+
* written before this change simply lack the keys (additive, undefined-safe).
|
|
79
85
|
*/
|
|
80
86
|
export function snapshotOriginalParams(params: {
|
|
81
87
|
systemPrompt?: string;
|
|
@@ -90,6 +96,10 @@ export function snapshotOriginalParams(params: {
|
|
|
90
96
|
excludeTools?: string[];
|
|
91
97
|
noBuiltinTools?: boolean;
|
|
92
98
|
preset?: string;
|
|
99
|
+
background?: boolean;
|
|
100
|
+
gitMode?: string;
|
|
101
|
+
approvalMode?: string;
|
|
102
|
+
force?: boolean;
|
|
93
103
|
}): Record<string, unknown> {
|
|
94
104
|
return {
|
|
95
105
|
systemPrompt: params.systemPrompt,
|
|
@@ -104,6 +114,10 @@ export function snapshotOriginalParams(params: {
|
|
|
104
114
|
excludeTools: params.excludeTools,
|
|
105
115
|
noBuiltinTools: params.noBuiltinTools,
|
|
106
116
|
preset: params.preset,
|
|
117
|
+
background: params.background,
|
|
118
|
+
gitMode: params.gitMode,
|
|
119
|
+
approvalMode: params.approvalMode,
|
|
120
|
+
force: params.force,
|
|
107
121
|
};
|
|
108
122
|
}
|
|
109
123
|
|
|
@@ -124,6 +138,12 @@ export function resolveSubagentParams(
|
|
|
124
138
|
gitMode?: string;
|
|
125
139
|
approvalMode?: string;
|
|
126
140
|
template?: string;
|
|
141
|
+
/**
|
|
142
|
+
* Capability-block override: carried here so the validation call sites
|
|
143
|
+
* (which receive the resolved params) can forward it to validatePreTask.
|
|
144
|
+
* Resolution itself ignores it.
|
|
145
|
+
*/
|
|
146
|
+
force?: boolean;
|
|
127
147
|
},
|
|
128
148
|
state: SessionState,
|
|
129
149
|
ctx: ExtensionContext,
|
|
@@ -133,6 +153,9 @@ export function resolveSubagentParams(
|
|
|
133
153
|
resolvedApprovalMode: ApprovalMode;
|
|
134
154
|
resolvedPreset?: SubagentPreset;
|
|
135
155
|
autoRoutedPreset?: SubagentPreset; // set only when autoRoutePreset chose it
|
|
156
|
+
/** Set only when auto-route chose the preset: the keyword that matched —
|
|
157
|
+
* the evidence the dispatch result surfaces (auto-route transparency). */
|
|
158
|
+
autoRouteKeyword?: string;
|
|
136
159
|
} {
|
|
137
160
|
// E2: Auto-route to best preset only when the conductor expressed NO
|
|
138
161
|
// explicit preference — an explicit preset, template, or tool
|
|
@@ -140,17 +163,19 @@ export function resolveSubagentParams(
|
|
|
140
163
|
// that must win over keyword-based routing (issue #57).
|
|
141
164
|
let resolvedPreset = params.preset;
|
|
142
165
|
let wasAutoRouted = false;
|
|
166
|
+
let routeKeyword: string | undefined;
|
|
143
167
|
const hasExplicitToolPreference =
|
|
144
168
|
params.tools !== undefined ||
|
|
145
169
|
params.excludeTools !== undefined ||
|
|
146
170
|
params.noBuiltinTools !== undefined;
|
|
147
171
|
if (!resolvedPreset && !params.template && !hasExplicitToolPreference) {
|
|
148
172
|
const allPresets = getAllPresets(state.builtinPresets, state.customPresets);
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
151
|
-
resolvedPreset =
|
|
173
|
+
const matched = classifyTask(params.task, allPresets);
|
|
174
|
+
if (matched) {
|
|
175
|
+
resolvedPreset = matched.preset;
|
|
152
176
|
wasAutoRouted = true;
|
|
153
|
-
|
|
177
|
+
routeKeyword = matched.keyword;
|
|
178
|
+
log.info("Auto-routed task to preset", { preset: matched.preset, keyword: matched.keyword });
|
|
154
179
|
}
|
|
155
180
|
}
|
|
156
181
|
|
|
@@ -221,5 +246,6 @@ export function resolveSubagentParams(
|
|
|
221
246
|
resolvedApprovalMode,
|
|
222
247
|
resolvedPreset: preset,
|
|
223
248
|
autoRoutedPreset,
|
|
249
|
+
autoRouteKeyword: autoRoutedPreset ? routeKeyword : undefined,
|
|
224
250
|
};
|
|
225
251
|
}
|
package/src/paths.ts
CHANGED
package/src/preflight.ts
CHANGED
package/src/prelude.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Purpose: Shared guard/validation prelude opening every delegation mode (cost gate, approval, H1 validation, dispatch guards).
|
|
2
|
+
/**
|
|
3
|
+
* Shared guard/validation prelude for delegation modes (issue #201).
|
|
4
|
+
*
|
|
5
|
+
* delegate_task's mode handlers each open with the same prelude shape:
|
|
6
|
+
*
|
|
7
|
+
* 1. session cost gate — perTaskEstimate fallback ($0.05) →
|
|
8
|
+
* checkCostLimit → the verbatim "Cannot delegate: session cost limit
|
|
9
|
+
* reached …" rejection;
|
|
10
|
+
* 2. approvalMode 'always' rejection (background paths only) — the verbatim
|
|
11
|
+
* "Cannot spawn background agent with approvalMode 'always' …";
|
|
12
|
+
* 3. cwd → outputFile → H1 pre-task validation — the same "Invalid cwd:" /
|
|
13
|
+
* "Invalid outputFile:" / joined-errors messages, optionally
|
|
14
|
+
* unit-prefixed (`Task 2 ("two"): …`) for per-unit loops.
|
|
15
|
+
*
|
|
16
|
+
* Each helper returns the rejection ToolResult when the gate trips and
|
|
17
|
+
* `undefined` when it passes, so call sites keep their original gate order:
|
|
18
|
+
*
|
|
19
|
+
* const err = gateSessionCost({ … });
|
|
20
|
+
* if (err) return err;
|
|
21
|
+
*
|
|
22
|
+
* Exception: runPreTaskValidation returns a { error?, warnings } outcome —
|
|
23
|
+
* `error` follows the same `if (outcome.error) return outcome.error` shape,
|
|
24
|
+
* and `warnings` must be surfaced in the returned tool result:
|
|
25
|
+
*
|
|
26
|
+
* const pre = runPreTaskValidation({ … });
|
|
27
|
+
* if (pre.error) return pre.error;
|
|
28
|
+
*
|
|
29
|
+
* Messages are byte-identical to the pre-extraction copies (the suites pin
|
|
30
|
+
* them); the parameters absorb only the real differences between sites — the
|
|
31
|
+
* cost-gate log label and unit count, and the H1 log label / unit prefix /
|
|
32
|
+
* extra log fields.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import type { ApprovalMode, ToolResult } from "./types";
|
|
37
|
+
import type { SessionState } from "./state";
|
|
38
|
+
import type { Logger } from "./logging";
|
|
39
|
+
import { validateCwd, validateOutputFile } from "./sanitize";
|
|
40
|
+
import { validatePreTask, type ValidateConfig } from "./validate";
|
|
41
|
+
|
|
42
|
+
/** The standard rejection result shape shared by these gates. */
|
|
43
|
+
function rejection(text: string): ToolResult<undefined> {
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text" as const, text }],
|
|
46
|
+
details: undefined,
|
|
47
|
+
isError: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Gate 1: session cost ────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Session cost gate (R5 / W6): falls back to the $0.05 per-unit estimate,
|
|
55
|
+
* checks the whole dispatch (units × estimate) against the session limit, and
|
|
56
|
+
* returns the verbatim rejection result when it would be exceeded.
|
|
57
|
+
*
|
|
58
|
+
* This module holds the codebase's SINGLE `checkCostLimit(perTaskEstimate * …)`
|
|
59
|
+
* site — the duplication that shape caused a mutation-target-drift incident
|
|
60
|
+
* (issue #201).
|
|
61
|
+
*/
|
|
62
|
+
export function gateSessionCost(input: {
|
|
63
|
+
state: SessionState;
|
|
64
|
+
ctx: ExtensionContext;
|
|
65
|
+
log: Logger;
|
|
66
|
+
/** Number of units this dispatch would spawn (1 for single-task modes). */
|
|
67
|
+
units: number;
|
|
68
|
+
/** e.g. "Parallel delegation" → log line "Parallel delegation rejected: session cost limit reached". */
|
|
69
|
+
label: string;
|
|
70
|
+
}): ToolResult<undefined> | undefined {
|
|
71
|
+
const { state, ctx, log, units, label } = input;
|
|
72
|
+
const perTaskEstimate =
|
|
73
|
+
state.config.perTaskCostEstimate > 0
|
|
74
|
+
? state.config.perTaskCostEstimate
|
|
75
|
+
: 0.05;
|
|
76
|
+
if (state.checkCostLimit(perTaskEstimate * units, ctx)) {
|
|
77
|
+
const currentTotal = state.getSessionTotalCost(ctx);
|
|
78
|
+
const limit = state.config.sessionCostLimit;
|
|
79
|
+
log.warn(`${label}: session cost limit reached`, {
|
|
80
|
+
currentTotal,
|
|
81
|
+
estimatedCost: perTaskEstimate * units,
|
|
82
|
+
limit,
|
|
83
|
+
});
|
|
84
|
+
return rejection(
|
|
85
|
+
`Cannot delegate: session cost limit reached ` +
|
|
86
|
+
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
87
|
+
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Gate 2: approvalMode 'always' in background ─────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Background paths (single spawn and fan-out) reject approvalMode 'always'
|
|
97
|
+
* before spawning: there is no interactive dialog to approve the diff, so
|
|
98
|
+
* 'always' cannot work — reject loudly instead of running unattended with
|
|
99
|
+
* write access (W5, issue #28). Verbatim message, pinned by the suites.
|
|
100
|
+
*/
|
|
101
|
+
export function rejectApprovalAlwaysInBackground(
|
|
102
|
+
approvalMode: ApprovalMode,
|
|
103
|
+
): ToolResult<undefined> | undefined {
|
|
104
|
+
if (approvalMode !== "always") return undefined;
|
|
105
|
+
return rejection(
|
|
106
|
+
`Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
|
|
107
|
+
`and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Gate 3a: cwd (+ outputFile) target validation ───────────────────────
|
|
112
|
+
|
|
113
|
+
export type DelegationTargetsResult =
|
|
114
|
+
| { ok: true; cwd: string; outputFile: string | undefined }
|
|
115
|
+
| { ok: false; error: ToolResult<undefined> };
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validate the dispatch's target cwd, then — when `outputFile` is given — the
|
|
119
|
+
* outputFile path (F1), returning both resolved values on success. `prefix`
|
|
120
|
+
* (a per-unit label like `Task 2 ("two")`) is prepended as `${prefix}: ` to
|
|
121
|
+
* the rejection messages; omit it for the unprefixed single-unit messages.
|
|
122
|
+
*
|
|
123
|
+
* Note: the foreground parallel mode historically validates only the cwd
|
|
124
|
+
* here (no outputFile step) — it simply passes no outputFile.
|
|
125
|
+
*/
|
|
126
|
+
export function validateDelegationTargets(input: {
|
|
127
|
+
ctx: ExtensionContext;
|
|
128
|
+
effectiveCwd: string;
|
|
129
|
+
/** Raw outputFile to path-validate; the step is skipped when omitted. */
|
|
130
|
+
outputFile?: string;
|
|
131
|
+
/** Unit prefix for the rejection messages, e.g. `Task 2 ("two")`. */
|
|
132
|
+
prefix?: string;
|
|
133
|
+
}): DelegationTargetsResult {
|
|
134
|
+
const prefix = input.prefix ? `${input.prefix}: ` : "";
|
|
135
|
+
|
|
136
|
+
const cwdResult = validateCwd(input.effectiveCwd, input.ctx.cwd);
|
|
137
|
+
if (!cwdResult.ok) {
|
|
138
|
+
return { ok: false, error: rejection(`${prefix}Invalid cwd: ${cwdResult.error}`) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let outputFile: string | undefined;
|
|
142
|
+
if (input.outputFile) {
|
|
143
|
+
const ofResult = validateOutputFile(input.outputFile, cwdResult.value);
|
|
144
|
+
if (!ofResult.ok) {
|
|
145
|
+
return { ok: false, error: rejection(`${prefix}Invalid outputFile: ${ofResult.error}`) };
|
|
146
|
+
}
|
|
147
|
+
outputFile = ofResult.value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { ok: true, cwd: cwdResult.value, outputFile };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Gate 3b: H1 pre-task validation ─────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/** Outcome of one pre-task validation: the rejection (hard failure) and/or
|
|
156
|
+
* the warnings the caller must surface in the tool result. */
|
|
157
|
+
export interface PreTaskValidationOutcome {
|
|
158
|
+
/** Rejection result when validation failed (hard errors); undefined when it passed. */
|
|
159
|
+
error?: ToolResult<undefined>;
|
|
160
|
+
/** Validation warnings — surfaced in the returned tool result, not just logged. */
|
|
161
|
+
warnings: string[];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Render validation warnings for a tool result: one labelled, bulleted
|
|
166
|
+
* block. Empty input renders the empty string so results stay
|
|
167
|
+
* byte-identical when there is nothing to say.
|
|
168
|
+
*/
|
|
169
|
+
export function formatValidationWarnings(warnings: string[]): string {
|
|
170
|
+
if (warnings.length === 0) return "";
|
|
171
|
+
return `\n\n[pre-task validation warnings]\n` + warnings.map((w) => `- ${w}`).join("\n");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Run validatePreTask (H1) with the site's exact input, warn on warnings,
|
|
176
|
+
* and return the rejection outcome (joined errors) on hard failure.
|
|
177
|
+
* `label` names the site in the warn logs ("Parallel pre-task validation
|
|
178
|
+
* warnings" / "Background fan-out …" / "Background …"); `prefix` is
|
|
179
|
+
* prepended as `${prefix}: ` to the hard-failure message; `logContext` adds
|
|
180
|
+
* site fields to both warn entries (the fan-out adds the task index).
|
|
181
|
+
* The returned warnings are for RESULT surfacing — the log.warn calls stay
|
|
182
|
+
* regardless (a warning must reach both the log and the conductor).
|
|
183
|
+
*/
|
|
184
|
+
export function runPreTaskValidation(input: {
|
|
185
|
+
log: Logger;
|
|
186
|
+
preTask: ValidateConfig;
|
|
187
|
+
label: string;
|
|
188
|
+
/** Unit prefix for the rejection message, e.g. `Task 2 ("two")`. */
|
|
189
|
+
prefix?: string;
|
|
190
|
+
/** Extra structured fields merged into the warn entries. */
|
|
191
|
+
logContext?: Record<string, unknown>;
|
|
192
|
+
}): PreTaskValidationOutcome {
|
|
193
|
+
const { log, preTask, label, prefix, logContext } = input;
|
|
194
|
+
const validation = validatePreTask(preTask);
|
|
195
|
+
if (validation.warnings.length > 0) {
|
|
196
|
+
log.warn(`${label} pre-task validation warnings`, { ...logContext, warnings: validation.warnings });
|
|
197
|
+
}
|
|
198
|
+
if (!validation.valid) {
|
|
199
|
+
const errText = validation.errors.join("; ");
|
|
200
|
+
log.warn(`${label} pre-task validation failed`, { ...logContext, errors: validation.errors });
|
|
201
|
+
return {
|
|
202
|
+
error: rejection(prefix ? `${prefix}: ${errText}` : errText),
|
|
203
|
+
warnings: validation.warnings,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return { warnings: validation.warnings };
|
|
207
|
+
}
|
package/src/presets.ts
CHANGED
package/src/prompt.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Purpose: Builds the subagent system prompt, including the task fence and inherited-instruction handling.
|
|
1
2
|
/**
|
|
2
3
|
* brl-subagent — Prompt Builder
|
|
3
4
|
*
|
|
@@ -52,7 +53,7 @@ export const SUBAGENT_INSTRUCTIONS =
|
|
|
52
53
|
* - runner.ts (foreground subprocess: `-p` argument)
|
|
53
54
|
* - session-manager.ts (background session: `session.prompt`)
|
|
54
55
|
*
|
|
55
|
-
* Deliberately applied AFTER {previous}/{
|
|
56
|
+
* Deliberately applied AFTER {previous}/{<nodeId>} substitution so substituted
|
|
56
57
|
* output (which may itself contain untrusted text) lands inside the fence.
|
|
57
58
|
*
|
|
58
59
|
* FIDELITY TRADEOFF (accepted, documented per PR #46 review M2): embedded
|
package/src/reports.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Purpose: Compliance reporting: file-access records and secrets-exposure detection.
|
|
1
2
|
/**
|
|
2
3
|
* brl-subagent — Compliance Reports (E5)
|
|
3
4
|
*
|
|
@@ -128,6 +129,11 @@ function extractFilesFromOutputSummary(outputSummary: string): string[] {
|
|
|
128
129
|
|
|
129
130
|
/**
|
|
130
131
|
* Check if a file path matches any sensitive file pattern.
|
|
132
|
+
*
|
|
133
|
+
* This is the PATH-based detector for the secrets exposure report: it answers
|
|
134
|
+
* "did this run touch a sensitive FILE?" rather than "did the output text
|
|
135
|
+
* happen to mention one?". It was previously unreachable dead code; keep it in
|
|
136
|
+
* sync with SENSITIVE_FILE_PATTERNS / FILE_SEVERITY_MAP above.
|
|
131
137
|
*/
|
|
132
138
|
function isSensitiveFile(filePath: string): { match: boolean; pattern?: string; severity?: "high" | "medium" | "low" } {
|
|
133
139
|
const normalizedPath = filePath.toLowerCase();
|
|
@@ -221,20 +227,40 @@ export function buildFileAccessReport(runs: SubagentRun[]): FileAccessReport {
|
|
|
221
227
|
*/
|
|
222
228
|
export function buildSecretsExposureReport(runs: SubagentRun[]): SecretsExposureReport {
|
|
223
229
|
const exposures: SecretsExposureEntry[] = [];
|
|
230
|
+
// Dedupe key set: one exposure per (file, runId) regardless of whether the
|
|
231
|
+
// finding came from the text scan or the path scan.
|
|
232
|
+
const seen = new Set<string>();
|
|
224
233
|
|
|
225
234
|
for (const run of runs) {
|
|
226
235
|
// Check task description for sensitive keywords
|
|
227
236
|
const taskCheck = hasSensitiveTaskKeywords(run.task);
|
|
228
237
|
if (taskCheck.match && taskCheck.keyword) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
238
|
+
const file = `task:${taskCheck.keyword}`;
|
|
239
|
+
const key = `${file}\x00${run.id}`;
|
|
240
|
+
if (!seen.has(key)) {
|
|
241
|
+
seen.add(key);
|
|
242
|
+
exposures.push({
|
|
243
|
+
file,
|
|
244
|
+
runId: run.id,
|
|
245
|
+
runLabel: run.label,
|
|
246
|
+
severity: "medium",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Files this run touched (mirrors buildFileAccessReport). Computed BEFORE
|
|
252
|
+
// the text scan so a text hit that is only a FRAGMENT of a touched path
|
|
253
|
+
// (e.g. ".env" inside "config/prod/.env") is not double-reported — the
|
|
254
|
+
// path scan below reports the real, full path instead.
|
|
255
|
+
const touched: string[] = [];
|
|
256
|
+
if (run.fullOutput && run.fullOutput.includes("diff --git")) {
|
|
257
|
+
touched.push(...extractFilesFromGitDiff(run.fullOutput));
|
|
258
|
+
}
|
|
259
|
+
if (touched.length === 0 && run.outputSummary) {
|
|
260
|
+
touched.push(...extractFilesFromOutputSummary(run.outputSummary));
|
|
235
261
|
}
|
|
236
262
|
|
|
237
|
-
// Check output for sensitive file
|
|
263
|
+
// Check output text for sensitive file mentions.
|
|
238
264
|
const outputToCheck = [
|
|
239
265
|
run.outputSummary,
|
|
240
266
|
run.fullOutput,
|
|
@@ -254,8 +280,18 @@ export function buildSecretsExposureReport(runs: SubagentRun[]): SecretsExposure
|
|
|
254
280
|
}
|
|
255
281
|
}
|
|
256
282
|
|
|
283
|
+
// Report the text that actually matched, not the regex
|
|
284
|
+
// literal — the `file` field is what a reader acts on.
|
|
285
|
+
const matched = outputToCheck.match(pattern)?.[0] ?? patternStr;
|
|
286
|
+
// Fragment of a touched file? The path scan below reports the full
|
|
287
|
+
// path — skip this fragment so one file yields ONE entry.
|
|
288
|
+
if (touched.some((p) => p.toLowerCase().includes(matched.toLowerCase()))) continue;
|
|
289
|
+
const dedupeKey = `${matched}\x00${run.id}`;
|
|
290
|
+
if (seen.has(dedupeKey)) continue;
|
|
291
|
+
seen.add(dedupeKey);
|
|
292
|
+
|
|
257
293
|
exposures.push({
|
|
258
|
-
file:
|
|
294
|
+
file: matched,
|
|
259
295
|
runId: run.id,
|
|
260
296
|
runLabel: run.label,
|
|
261
297
|
severity,
|
|
@@ -263,6 +299,26 @@ export function buildSecretsExposureReport(runs: SubagentRun[]): SecretsExposure
|
|
|
263
299
|
}
|
|
264
300
|
}
|
|
265
301
|
}
|
|
302
|
+
|
|
303
|
+
// Path-based detection: flag the sensitive FILES this run actually
|
|
304
|
+
// touched — the full path, even when the output text never names it (and
|
|
305
|
+
// even when it names it in a different case: the path is normalized
|
|
306
|
+
// before matching).
|
|
307
|
+
for (const file of touched) {
|
|
308
|
+
const check = isSensitiveFile(file);
|
|
309
|
+
if (!check.match) continue;
|
|
310
|
+
|
|
311
|
+
const dedupeKey = `${file}\x00${run.id}`;
|
|
312
|
+
if (seen.has(dedupeKey)) continue;
|
|
313
|
+
seen.add(dedupeKey);
|
|
314
|
+
|
|
315
|
+
exposures.push({
|
|
316
|
+
file,
|
|
317
|
+
runId: run.id,
|
|
318
|
+
runLabel: run.label,
|
|
319
|
+
severity: check.severity ?? "high",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
266
322
|
}
|
|
267
323
|
|
|
268
324
|
return { exposures };
|
package/src/router.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Purpose: Auto-route: keyword classification of a task description to the best preset.
|
|
1
2
|
/**
|
|
2
3
|
* brl-subagent — Skill-based Routing
|
|
3
4
|
*
|
|
@@ -105,15 +106,25 @@ function escapeRegExp(s: string): string {
|
|
|
105
106
|
// Router
|
|
106
107
|
// ---------------------------------------------------------------------------
|
|
107
108
|
|
|
109
|
+
/** One auto-route decision: the selected preset AND the keyword that
|
|
110
|
+
* matched — the evidence the dispatch result must show (transparency). */
|
|
111
|
+
export interface AutoRouteDecision {
|
|
112
|
+
/** The preset name selected. */
|
|
113
|
+
preset: string;
|
|
114
|
+
/** The classification keyword whose match selected the preset. */
|
|
115
|
+
keyword: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
/**
|
|
109
|
-
* Auto-classify a task description to the best preset personality
|
|
119
|
+
* Auto-classify a task description to the best preset personality, returning
|
|
120
|
+
* the preset AND the matched keyword (evidence for the dispatch result).
|
|
110
121
|
* Uses case-insensitive keyword matching, checked in priority order.
|
|
111
|
-
* Returns the first
|
|
122
|
+
* Returns the first match, or undefined if no rule matches.
|
|
112
123
|
*/
|
|
113
|
-
export function
|
|
124
|
+
export function classifyTask(
|
|
114
125
|
task: string,
|
|
115
126
|
presets: SubagentPreset[],
|
|
116
|
-
):
|
|
127
|
+
): AutoRouteDecision | undefined {
|
|
117
128
|
if (!task || task.trim().length === 0) return undefined;
|
|
118
129
|
|
|
119
130
|
const lower = task.toLowerCase();
|
|
@@ -124,10 +135,21 @@ export function autoRoutePreset(
|
|
|
124
135
|
|
|
125
136
|
for (const keyword of rule.keywords) {
|
|
126
137
|
if (keywordMatches(lower, keyword)) {
|
|
127
|
-
return rule.preset;
|
|
138
|
+
return { preset: rule.preset, keyword };
|
|
128
139
|
}
|
|
129
140
|
}
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
return undefined;
|
|
133
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Auto-classify a task description to the best preset personality.
|
|
148
|
+
* Thin wrapper over classifyTask — preset name only, no evidence.
|
|
149
|
+
*/
|
|
150
|
+
export function autoRoutePreset(
|
|
151
|
+
task: string,
|
|
152
|
+
presets: SubagentPreset[],
|
|
153
|
+
): string | undefined {
|
|
154
|
+
return classifyTask(task, presets)?.preset;
|
|
155
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Purpose: Foreground execution: spawns the `pi` subprocess, parses its JSON-line stream, and folds usage.
|
|
1
2
|
/**
|
|
2
3
|
* brl-subagent — Process Runner
|
|
3
4
|
*
|
|
@@ -245,7 +246,13 @@ function emitSubagentUpdate(
|
|
|
245
246
|
text: getFinalOutputFn(result.messages) || "(running...)",
|
|
246
247
|
},
|
|
247
248
|
],
|
|
248
|
-
|
|
249
|
+
// Issue #206: this is a STREAMING partial, not a verdict —
|
|
250
|
+
// isSubagentError is a settled-result predicate (a mid-run stopReason
|
|
251
|
+
// such as "toolUse" is not an error until the run actually ends), so
|
|
252
|
+
// stamping the unsettled sentinel (exitCode: -1) keeps the renderer on
|
|
253
|
+
// its running branch until the real result lands. The accumulator
|
|
254
|
+
// (`result` itself) is untouched — its exitCode stays the real code.
|
|
255
|
+
details: { ...result, exitCode: -1 },
|
|
249
256
|
});
|
|
250
257
|
}
|
|
251
258
|
|
package/src/sanitize.ts
CHANGED
package/src/scheduler.ts
CHANGED