brl-subagent 2.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +257 -0
  3. package/package.json +49 -0
  4. package/presets/code-reviewer.md +37 -0
  5. package/presets/data-analyst.md +33 -0
  6. package/presets/debugger.md +36 -0
  7. package/presets/dev-agent.md +30 -0
  8. package/presets/rapid-prototyper.md +25 -0
  9. package/presets/refactorer.md +34 -0
  10. package/presets/security-auditor.md +39 -0
  11. package/presets/tech-writer.md +33 -0
  12. package/presets/test-engineer.md +34 -0
  13. package/src/concurrency.ts +186 -0
  14. package/src/diff.ts +102 -0
  15. package/src/event-bus.ts +86 -0
  16. package/src/git.ts +187 -0
  17. package/src/history.ts +177 -0
  18. package/src/index.ts +3824 -0
  19. package/src/logging.ts +135 -0
  20. package/src/messaging.ts +164 -0
  21. package/src/metrics.ts +221 -0
  22. package/src/model-availability.ts +73 -0
  23. package/src/notify-completion.ts +207 -0
  24. package/src/params.ts +225 -0
  25. package/src/preflight.ts +150 -0
  26. package/src/presets.ts +425 -0
  27. package/src/prompt.ts +169 -0
  28. package/src/reports.ts +325 -0
  29. package/src/router.ts +133 -0
  30. package/src/runner.ts +877 -0
  31. package/src/sanitize.ts +365 -0
  32. package/src/scheduler.ts +187 -0
  33. package/src/session-manager.ts +1065 -0
  34. package/src/state.ts +590 -0
  35. package/src/templates.ts +469 -0
  36. package/src/transcript-tail.ts +327 -0
  37. package/src/transcript.ts +119 -0
  38. package/src/tui-format.ts +51 -0
  39. package/src/tui.ts +3103 -0
  40. package/src/types.ts +803 -0
  41. package/src/unit-run.ts +269 -0
  42. package/src/update.ts +109 -0
  43. package/src/validate.ts +302 -0
  44. package/templates/analyze-data.md +9 -0
  45. package/templates/code-review.md +10 -0
  46. package/templates/debug-issue.md +9 -0
  47. package/templates/implement-feature.md +9 -0
  48. package/templates/prototype.md +9 -0
  49. package/templates/refactor.md +9 -0
  50. package/templates/security-audit.md +10 -0
  51. package/templates/write-docs.md +9 -0
  52. package/templates/write-tests.md +10 -0
@@ -0,0 +1,469 @@
1
+ /**
2
+ * brl-subagent — Task Templates
3
+ *
4
+ * Task templates are user-saved delegate_task configurations with ${param}
5
+ * placeholder slots. They are FILE-BACKED: each template is a .md file with
6
+ * YAML frontmatter (name, description, preset, thinkingLevel, outputFile,
7
+ * timeout, tools, excludeTools, noBuiltinTools, inheritSystemPrompt) whose
8
+ * body IS the task — multiline by construction. This mirrors the proven
9
+ * custom-preset pattern (issue #66): the old TUI add flow used single-line
10
+ * input, which is unusable for a task body.
11
+ *
12
+ * Built-in templates ship with the extension (templates/ dir) as thin
13
+ * teaching examples — one companion per builtin preset, VERB-form names,
14
+ * with ${param} slots shown in action. Custom templates override builtins
15
+ * with the same name (PROJECT > USER > BUILTIN), mirroring the preset tier
16
+ * added in issue #84.
17
+ *
18
+ * Usage:
19
+ * const resolved = resolveTemplate(template, { file: "src/main.ts" });
20
+ * if (!resolved.ok) { /* handle error *\/ }
21
+ * // resolved.value.task now has placeholders filled
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import type { TaskTemplate, ThinkingLevel, SubagentPreset } from "./types";
27
+ import { TEMPLATE_PARAM_RE, THINKING_LEVELS } from "./types";
28
+ import type { Logger } from "./logging";
29
+ import { parseFrontmatter, sanitizeFileName } from "./presets";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Param extraction
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Extract all unique ${paramName} names from a text string.
37
+ * Used for validation and TUI hints.
38
+ *
39
+ * @param text - The text to scan for ${param} placeholders
40
+ * @returns A sorted array of unique param names
41
+ */
42
+ export function extractParamNames(text: string): string[] {
43
+ const names = new Set<string>();
44
+ let match: RegExpExecArray | null;
45
+ const re = new RegExp(TEMPLATE_PARAM_RE.source, "g");
46
+ while ((match = re.exec(text)) !== null) {
47
+ names.add(match[1]);
48
+ }
49
+ return [...names].sort();
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Template resolution
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Resolve a TaskTemplate by replacing all ${param} placeholders with
58
+ * the provided parameter values.
59
+ *
60
+ * Before substitution, checks that all required params are provided.
61
+ * Extra params (in the object but not in the template) are silently ignored.
62
+ *
63
+ * @param template - The template with ${param} placeholders
64
+ * @param params - Key-value map of parameter names to substitution strings
65
+ * @returns Ok with resolved template, or Err with a descriptive error message
66
+ */
67
+ export function resolveTemplate(
68
+ template: TaskTemplate,
69
+ params: Record<string, string>,
70
+ ): { ok: true; value: TaskTemplate } | { ok: false; error: string } {
71
+ // Collect all unique param names used in task and outputFile
72
+ const namesInTask = extractParamNames(template.task);
73
+ const namesInOutput = template.outputFile ? extractParamNames(template.outputFile) : [];
74
+ const allNames = [...new Set([...namesInTask, ...namesInOutput])];
75
+
76
+ // Check for missing params
77
+ const missing = allNames.filter((name) => !(name in params));
78
+ if (missing.length > 0) {
79
+ return {
80
+ ok: false,
81
+ error: `Missing params: ${missing.join(", ")}`,
82
+ };
83
+ }
84
+
85
+ // Build a replacer function that replaces all ${param} occurrences
86
+ const replaceAll = (text: string): string => {
87
+ return text.replace(TEMPLATE_PARAM_RE, (_match, name: string) => {
88
+ // All params are guaranteed to exist at this point
89
+ return name in params ? params[name] : _match;
90
+ });
91
+ };
92
+
93
+ const resolved: TaskTemplate = {
94
+ ...template,
95
+ task: replaceAll(template.task),
96
+ outputFile: template.outputFile ? replaceAll(template.outputFile) : undefined,
97
+ };
98
+
99
+ return { ok: true, value: resolved };
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Template validation
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Validate that a parsed template has the required name field and valid
108
+ * option values. Returns validation errors, or empty array if valid.
109
+ *
110
+ * Mirrors validatePreset (src/presets.ts) plus template-specific fields:
111
+ * timeout must be numeric, tools/excludeTools must be lists, preset must be
112
+ * a string.
113
+ */
114
+ export function validateTemplate(meta: Record<string, unknown>, fileName: string): string[] {
115
+ const errors: string[] = [];
116
+
117
+ if (!meta.name || typeof meta.name !== "string") {
118
+ errors.push(`Template "${fileName}" is missing required "name" field.`);
119
+ return errors;
120
+ }
121
+
122
+ if (meta.thinkingLevel !== undefined) {
123
+ const level = meta.thinkingLevel as string;
124
+ if (!THINKING_LEVELS.includes(level as ThinkingLevel)) {
125
+ errors.push(
126
+ `Template "${meta.name}" (${fileName}): invalid thinkingLevel "${level}". ` +
127
+ `Must be one of: ${THINKING_LEVELS.join(", ")}.`,
128
+ );
129
+ }
130
+ }
131
+
132
+ if (meta.inheritSystemPrompt !== undefined) {
133
+ const val = meta.inheritSystemPrompt;
134
+ if (val !== "true" && val !== "false") {
135
+ errors.push(
136
+ `Template "${meta.name}" (${fileName}): inheritSystemPrompt must be "true" or "false", got "${val}".`,
137
+ );
138
+ }
139
+ }
140
+
141
+ if (meta.noBuiltinTools !== undefined) {
142
+ const val = meta.noBuiltinTools;
143
+ if (val !== "true" && val !== "false") {
144
+ errors.push(
145
+ `Template "${meta.name}" (${fileName}): noBuiltinTools must be "true" or "false", got "${val}".`,
146
+ );
147
+ }
148
+ }
149
+
150
+ if (meta.timeout !== undefined) {
151
+ const val = Number(meta.timeout);
152
+ if (Number.isNaN(val)) {
153
+ errors.push(
154
+ `Template "${meta.name}" (${fileName}): timeout must be numeric, got "${meta.timeout}".`,
155
+ );
156
+ }
157
+ }
158
+
159
+ if (meta.tools !== undefined && !Array.isArray(meta.tools)) {
160
+ errors.push(`Template "${meta.name}" (${fileName}): tools must be a list.`);
161
+ }
162
+
163
+ if (meta.excludeTools !== undefined && !Array.isArray(meta.excludeTools)) {
164
+ errors.push(`Template "${meta.name}" (${fileName}): excludeTools must be a list.`);
165
+ }
166
+
167
+ if (meta.preset !== undefined && typeof meta.preset !== "string") {
168
+ errors.push(`Template "${meta.name}" (${fileName}): preset must be a string.`);
169
+ }
170
+
171
+ return errors;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Loading
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Load custom task templates from user directories. Templates are
180
+ * file-backed, mirroring the custom-preset pattern (issue #66) — the TUI
181
+ * add/remove flows were removed because single-line input cannot express a
182
+ * task body.
183
+ *
184
+ * Precedence: PROJECT-LOCAL > USER-GLOBAL. The project dir is scanned first
185
+ * and duplicates by name are skipped, so a project-local template always
186
+ * wins over a user-global one with the same name. The custom > builtin
187
+ * override happens in getAllTemplates/getTemplate, not here.
188
+ *
189
+ * Searches:
190
+ * 1. .pi/brl-subagent/templates/ (project-local, highest priority)
191
+ * 2. ~/.pi/agent/brl-subagent/templates/ (global)
192
+ *
193
+ * The markdown body IS the task (multiline by construction). Invalid files
194
+ * (including empty/whitespace-only bodies) are skipped with log warnings;
195
+ * missing directories are skipped silently.
196
+ */
197
+ export function loadCustomTemplates(cwd: string, log?: Logger): TaskTemplate[] {
198
+ const templates: TaskTemplate[] = [];
199
+ const seenNames = new Set<string>();
200
+ const homedir = process.env.HOME || process.env.USERPROFILE || "";
201
+
202
+ const dirs = [
203
+ path.join(cwd, ".pi", "brl-subagent", "templates"),
204
+ path.join(homedir, ".pi", "agent", "brl-subagent", "templates"),
205
+ ];
206
+
207
+ for (const dir of dirs) {
208
+ try {
209
+ const files = fs.readdirSync(dir);
210
+ for (const file of files) {
211
+ if (!file.endsWith(".md")) continue;
212
+ try {
213
+ const filePath = path.join(dir, file);
214
+ const content = fs.readFileSync(filePath, "utf-8");
215
+ const { meta, body } = parseFrontmatter(content);
216
+
217
+ const errors = validateTemplate(meta, file);
218
+ if (errors.length > 0) {
219
+ for (const err of errors) {
220
+ log?.warn("Custom template validation failed", { file, error: err });
221
+ }
222
+ continue;
223
+ }
224
+
225
+ // Reject empty/whitespace-only bodies at load: shipping task: ""
226
+ // would later trip mode-detection with the confusing "Provide
227
+ // exactly one of: task/chain/..." error instead of a load-time
228
+ // warn-and-skip (parseFrontmatter already trims the body).
229
+ if (!body.trim()) {
230
+ log?.warn("Custom template validation failed", {
231
+ file,
232
+ error: `Template "${meta.name}" has empty task body`,
233
+ });
234
+ continue;
235
+ }
236
+
237
+ const name = meta.name as string;
238
+ // Dedup by name: the first occurrence (project-local, scanned
239
+ // first) wins; user-global duplicates are skipped.
240
+ if (seenNames.has(name)) continue;
241
+ seenNames.add(name);
242
+ templates.push({
243
+ name,
244
+ description: (meta.description as string) || undefined,
245
+ task: body,
246
+ preset: (meta.preset as string) || undefined,
247
+ thinkingLevel: (meta.thinkingLevel as string) || undefined,
248
+ outputFile: (meta.outputFile as string) || undefined,
249
+ timeout: meta.timeout !== undefined ? Number(meta.timeout) : undefined,
250
+ tools: Array.isArray(meta.tools) ? (meta.tools as string[]) : undefined,
251
+ excludeTools: Array.isArray(meta.excludeTools) ? (meta.excludeTools as string[]) : undefined,
252
+ noBuiltinTools: meta.noBuiltinTools === "true" ? true : undefined,
253
+ inheritSystemPrompt: meta.inheritSystemPrompt === "false" ? false : undefined,
254
+ });
255
+ } catch (err) {
256
+ log?.warn("Failed to load custom template file", { file, error: (err as Error).message });
257
+ }
258
+ }
259
+ } catch {
260
+ // Directory doesn't exist — that's fine, no custom templates
261
+ }
262
+ }
263
+
264
+ return templates;
265
+ }
266
+
267
+ /**
268
+ * Load built-in task templates from a directory of markdown files. Files
269
+ * must have YAML frontmatter with at least a `name` field. Invalid files
270
+ * (including empty/whitespace-only bodies, kept for consistency with
271
+ * loadCustomTemplates) are skipped with log warnings. Mirrors
272
+ * loadBuiltinPresets (src/presets.ts).
273
+ */
274
+ export function loadBuiltinTemplates(templatesDir: string, log?: Logger): TaskTemplate[] {
275
+ const templates: TaskTemplate[] = [];
276
+
277
+ try {
278
+ const files = fs.readdirSync(templatesDir);
279
+ for (const file of files) {
280
+ if (!file.endsWith(".md")) continue;
281
+
282
+ try {
283
+ const filePath = path.join(templatesDir, file);
284
+ const content = fs.readFileSync(filePath, "utf-8");
285
+ const { meta, body } = parseFrontmatter(content);
286
+
287
+ const errors = validateTemplate(meta, file);
288
+ if (errors.length > 0) {
289
+ for (const err of errors) {
290
+ log?.warn("Builtin template validation failed", { file, error: err });
291
+ }
292
+ continue;
293
+ }
294
+
295
+ // Same empty-body guard as loadCustomTemplates: shipped templates
296
+ // are authored non-empty, but keep the check so a regressed copy
297
+ // warns at load time instead of tripping mode-detection later.
298
+ if (!body.trim()) {
299
+ log?.warn("Builtin template validation failed", {
300
+ file,
301
+ error: `Template "${meta.name}" has empty task body`,
302
+ });
303
+ continue;
304
+ }
305
+
306
+ const name = meta.name as string;
307
+
308
+ templates.push({
309
+ name,
310
+ description: (meta.description as string) || undefined,
311
+ task: body,
312
+ preset: (meta.preset as string) || undefined,
313
+ thinkingLevel: (meta.thinkingLevel as string) || undefined,
314
+ outputFile: (meta.outputFile as string) || undefined,
315
+ timeout: meta.timeout !== undefined ? Number(meta.timeout) : undefined,
316
+ tools: Array.isArray(meta.tools) ? (meta.tools as string[]) : undefined,
317
+ excludeTools: Array.isArray(meta.excludeTools) ? (meta.excludeTools as string[]) : undefined,
318
+ noBuiltinTools: meta.noBuiltinTools === "true" ? true : undefined,
319
+ inheritSystemPrompt: meta.inheritSystemPrompt === "false" ? false : undefined,
320
+ });
321
+ } catch (err) {
322
+ log?.warn("Failed to load builtin template file", { file, error: (err as Error).message });
323
+ }
324
+ }
325
+ } catch {
326
+ // Templates directory doesn't exist or can't be read — no built-in templates
327
+ log?.info("No built-in templates directory found", { dir: templatesDir });
328
+ }
329
+
330
+ return templates;
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Template lookup
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * Look up a template by name. Custom (user) templates take precedence over
339
+ * built-ins, so users can override any built-in template with the same name.
340
+ * Mirrors getPreset (src/presets.ts).
341
+ */
342
+ export function getTemplate(
343
+ name: string,
344
+ builtinTemplates: TaskTemplate[],
345
+ customTemplates: TaskTemplate[],
346
+ ): TaskTemplate | undefined {
347
+ return customTemplates.find((t) => t.name === name) || builtinTemplates.find((t) => t.name === name);
348
+ }
349
+
350
+ /**
351
+ * Combine built-in and custom templates into a single array.
352
+ * Custom (user) templates override built-ins with the same name.
353
+ * Mirrors getAllPresets (src/presets.ts).
354
+ */
355
+ export function getAllTemplates(
356
+ builtinTemplates: TaskTemplate[],
357
+ customTemplates: TaskTemplate[],
358
+ ): TaskTemplate[] {
359
+ const seen = new Set(customTemplates.map((t) => t.name));
360
+ const filteredBuiltins = builtinTemplates.filter((t) => !seen.has(t.name));
361
+ return [...customTemplates, ...filteredBuiltins];
362
+ }
363
+
364
+ /**
365
+ * Load and merge the FULL template stack in one call: built-in templates
366
+ * (shipped in the extension's templates/ dir) plus custom templates from the
367
+ * project and user-global directories. Custom overrides builtin by name, so
368
+ * precedence is PROJECT > USER > BUILTIN (issue #84 + builtin tier).
369
+ *
370
+ * This is the single entry point used by index.ts, tui.ts, and resetState so
371
+ * every reload site stays in sync. `templatesDir` overrides the builtin
372
+ * location (used by tests); it defaults to the shipped templates/ dir next to
373
+ * the extension's presets/ dir.
374
+ */
375
+ export function loadAllTemplates(cwd: string, log?: Logger, templatesDir?: string): TaskTemplate[] {
376
+ const builtinDir = templatesDir ?? path.join(__dirname, "..", "templates");
377
+ return getAllTemplates(loadBuiltinTemplates(builtinDir, log), loadCustomTemplates(cwd, log));
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Cross-check: template preset references vs. the preset universe
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /**
385
+ * Cross-check every loaded template's `preset:` reference against the full
386
+ * preset universe (builtin + custom). Warns (never skips) for each template
387
+ * whose preset: names a preset that doesn't exist — the delegation would
388
+ * otherwise run preset-less silently with auto-route suppressed (issue #81).
389
+ * Must run AFTER both preset and template loads complete (order-independent
390
+ * by design — it takes both universes as parameters).
391
+ *
392
+ * @param templates - the FULL merged template stack (state.config.templates)
393
+ * @param allPresets - the FULL merged preset universe (getAllPresets result)
394
+ * @param log - logger for the warn; omitted → no-op
395
+ * @returns the count of templates with dangling preset references (0 = clean)
396
+ */
397
+ export function validateTemplatePresetRefs(
398
+ templates: TaskTemplate[],
399
+ allPresets: SubagentPreset[],
400
+ log?: Logger,
401
+ ): number {
402
+ let dangling = 0;
403
+ for (const template of templates) {
404
+ if (!template.preset) continue;
405
+ if (!allPresets.some((p) => p.name === template.preset)) {
406
+ dangling++;
407
+ log?.warn(
408
+ `Template "${template.name}" references preset "${template.preset}" which does not exist ` +
409
+ "— delegation will run preset-less with auto-route suppressed (issue #81)",
410
+ { template: template.name, preset: template.preset },
411
+ );
412
+ }
413
+ }
414
+ return dangling;
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // File helpers
419
+ // ---------------------------------------------------------------------------
420
+
421
+ /**
422
+ * Build just the YAML frontmatter string (without the "---" markers)
423
+ * from a TaskTemplate object. Mirrors buildFrontmatter's key format
424
+ * (src/presets.ts) exactly: scalars unquoted, description quoted,
425
+ * boolean-only fields quoted, lists as indented "- item" lines.
426
+ */
427
+ export function buildTemplateFrontmatter(template: TaskTemplate): string {
428
+ const lines: string[] = [];
429
+ lines.push(`name: ${template.name}`);
430
+ if (template.description) lines.push(`description: "${template.description}"`);
431
+ if (template.preset) lines.push(`preset: ${template.preset}`);
432
+ if (template.thinkingLevel) lines.push(`thinkingLevel: ${template.thinkingLevel}`);
433
+ if (template.outputFile) lines.push(`outputFile: ${template.outputFile}`);
434
+ if (template.timeout !== undefined) lines.push(`timeout: ${template.timeout}`);
435
+ if (template.noBuiltinTools) lines.push(`noBuiltinTools: "true"`);
436
+ if (template.inheritSystemPrompt === false) lines.push(`inheritSystemPrompt: "false"`);
437
+ if (template.tools?.length) {
438
+ lines.push("tools:");
439
+ for (const t of template.tools) lines.push(` - ${t}`);
440
+ }
441
+ if (template.excludeTools?.length) {
442
+ lines.push("excludeTools:");
443
+ for (const t of template.excludeTools) lines.push(` - ${t}`);
444
+ }
445
+ return lines.join("\n");
446
+ }
447
+
448
+ /**
449
+ * Build a complete markdown string with YAML frontmatter from a TaskTemplate.
450
+ * The body is the task, so multiline tasks are natural.
451
+ */
452
+ export function buildTemplateMarkdown(template: TaskTemplate): string {
453
+ const frontmatter = buildTemplateFrontmatter(template);
454
+ return `---\n${frontmatter}\n---\n${template.task}\n`;
455
+ }
456
+
457
+ /**
458
+ * Write a template to a .md file in the specified directory.
459
+ * Creates the directory if it doesn't exist.
460
+ * Returns the full path to the written file.
461
+ */
462
+ export function writeTemplateFile(template: TaskTemplate, dir: string): string {
463
+ fs.mkdirSync(dir, { recursive: true });
464
+ const fileName = sanitizeFileName(template.name) + ".md";
465
+ const filePath = path.join(dir, fileName);
466
+ const content = buildTemplateMarkdown(template);
467
+ fs.writeFileSync(filePath, content, "utf-8");
468
+ return filePath;
469
+ }