brl-subagent 2.3.8 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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 = "info";
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
@@ -1,3 +1,4 @@
1
+ // Purpose: Inter-subagent messaging: the Intercom channel and `[TO:id]` output parsing.
1
2
  /**
2
3
  * brl-subagent — Subagent-to-Subagent Messaging (E10)
3
4
  *
package/src/metrics.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: SLA metrics over run history: p50/p95/p99 latency, success and cost rates, degradation detection.
1
2
  /**
2
3
  * brl-subagent — SLA Metrics (E4)
3
4
  *
@@ -1,3 +1,4 @@
1
+ // Purpose: Auth-aware model availability: distinguishes catalog presence from configured provider auth.
1
2
  /**
2
3
  * Auth-aware model availability check (issue #4).
3
4
  *
@@ -1,3 +1,4 @@
1
+ // Purpose: Builds and sends the completion-push wake message for terminal background runs.
1
2
  /**
2
3
  * brl-subagent — Completion-push wake (issue #147)
3
4
  *
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 { autoRoutePreset } from "./router";
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 suggested = autoRoutePreset(params.task, allPresets);
150
- if (suggested) {
151
- resolvedPreset = suggested;
173
+ const matched = classifyTask(params.task, allPresets);
174
+ if (matched) {
175
+ resolvedPreset = matched.preset;
152
176
  wasAutoRouted = true;
153
- log.info("Auto-routed task to preset", { preset: suggested });
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
@@ -1,3 +1,4 @@
1
+ // Purpose: Package-root path resolution (`pkgPath()`) so shipped assets resolve from the extension directory.
1
2
  /**
2
3
  * brl-subagent — Package-root path resolution
3
4
  *
package/src/preflight.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Pre-spawn environment checks: pi binary, temp-dir writability, cwd readability.
1
2
  /**
2
3
  * brl-subagent — Pre-flight Checks (R3)
3
4
  *
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
@@ -1,3 +1,4 @@
1
+ // Purpose: Preset loading, parsing, validation, and the file-backed custom-preset tier.
1
2
  /**
2
3
  * brl-subagent — Presets
3
4
  *
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}/{otherId} substitution so substituted
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
- exposures.push({
230
- file: `task:${taskCheck.keyword}`,
231
- runId: run.id,
232
- runLabel: run.label,
233
- severity: "medium",
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 access
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: patternStr,
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 matching preset name, or undefined if no match.
122
+ * Returns the first match, or undefined if no rule matches.
112
123
  */
113
- export function autoRoutePreset(
124
+ export function classifyTask(
114
125
  task: string,
115
126
  presets: SubagentPreset[],
116
- ): string | undefined {
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
- details: { ...result },
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
@@ -1,3 +1,4 @@
1
+ // Purpose: Input validation, environment allowlisting, and output sanitization (task, cwd, outputFile, agent ids).
1
2
  /**
2
3
  * brl-subagent — Sanitization (F1, F2, F3)
3
4
  *
package/src/scheduler.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Dependency-graph scheduler: cycle detection, topological waves, and graph validation.
1
2
  /**
2
3
  * brl-subagent — Dependency Graph Scheduler
3
4
  *