brl-subagent 2.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
package/src/templates.ts
ADDED
|
@@ -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
|
+
}
|