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/reports.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Compliance Reports (E5)
|
|
3
|
+
*
|
|
4
|
+
* Generates compliance reports showing which subagents touched which files
|
|
5
|
+
* and what secrets may have been accessed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
SubagentRun,
|
|
10
|
+
FileAccessReport,
|
|
11
|
+
SecretsExposureEntry,
|
|
12
|
+
SecretsExposureReport,
|
|
13
|
+
ComplianceSummary,
|
|
14
|
+
} from "./types";
|
|
15
|
+
import { computeSLAMetrics } from "./metrics";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Constants
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** Sensitive file patterns that should be flagged in compliance reports */
|
|
22
|
+
const SENSITIVE_FILE_PATTERNS = [
|
|
23
|
+
/\.env\b/,
|
|
24
|
+
/\.env\./,
|
|
25
|
+
/credentials\.json/,
|
|
26
|
+
/auth\.json/,
|
|
27
|
+
/\.pem\b/,
|
|
28
|
+
/\.key\b/,
|
|
29
|
+
/id_rsa/,
|
|
30
|
+
/\.secrets\b/,
|
|
31
|
+
/secrets\.yml/,
|
|
32
|
+
/config\/credentials/,
|
|
33
|
+
/private\//,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Sensitive keywords in task descriptions that should be flagged */
|
|
37
|
+
const SENSITIVE_TASK_KEYWORDS = [
|
|
38
|
+
"password",
|
|
39
|
+
"secret",
|
|
40
|
+
"token",
|
|
41
|
+
"api key",
|
|
42
|
+
"credential",
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Severity mapping for sensitive file patterns */
|
|
46
|
+
const FILE_SEVERITY_MAP: Record<string, "high" | "medium" | "low"> = {
|
|
47
|
+
".env": "high",
|
|
48
|
+
".env.*": "high",
|
|
49
|
+
"credentials.json": "high",
|
|
50
|
+
"auth.json": "high",
|
|
51
|
+
".pem": "high",
|
|
52
|
+
".key": "high",
|
|
53
|
+
"id_rsa*": "high",
|
|
54
|
+
".secrets": "high",
|
|
55
|
+
"secrets.yml": "high",
|
|
56
|
+
"config/credentials": "high",
|
|
57
|
+
"private/": "medium",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Helper functions
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extract file paths from a git diff string.
|
|
66
|
+
* Parses diff headers to find file paths.
|
|
67
|
+
*/
|
|
68
|
+
function extractFilesFromGitDiff(gitDiff: string): string[] {
|
|
69
|
+
const files: string[] = [];
|
|
70
|
+
const lines = gitDiff.split("\n");
|
|
71
|
+
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
// Match diff --git a/path b/path
|
|
74
|
+
const diffMatch = line.match(/^diff --git a\/(.+?) b\//);
|
|
75
|
+
if (diffMatch) {
|
|
76
|
+
files.push(diffMatch[1]);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Match --- a/path or +++ b/path
|
|
81
|
+
const fileMatch = line.match(/^(?:---|\+\+\+) [ab]\/(.+?)$/);
|
|
82
|
+
if (fileMatch) {
|
|
83
|
+
files.push(fileMatch[1]);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Deduplicate
|
|
88
|
+
return [...new Set(files)];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract file paths from output summary using heuristics.
|
|
93
|
+
* Looks for patterns like "src/..." or "Modified: file1.ts".
|
|
94
|
+
*/
|
|
95
|
+
function extractFilesFromOutputSummary(outputSummary: string): string[] {
|
|
96
|
+
const files: string[] = [];
|
|
97
|
+
|
|
98
|
+
// Pattern 1: "Modified: filename" or "Created: filename" or "Deleted: filename"
|
|
99
|
+
const actionPatterns = outputSummary.match(/(?:Modified|Created|Deleted|Changed|Updated):\s*([^\n,]+)/gi);
|
|
100
|
+
if (actionPatterns) {
|
|
101
|
+
for (const pattern of actionPatterns) {
|
|
102
|
+
const fileMatch = pattern.match(/(?:Modified|Created|Deleted|Changed|Updated):\s*(.+)/i);
|
|
103
|
+
if (fileMatch) {
|
|
104
|
+
files.push(fileMatch[1].trim());
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Pattern 2: File paths with common extensions
|
|
110
|
+
const pathPattern = /(?:src|lib|test|tests|spec|specs|config|docs|examples|scripts|utils|helpers|components|services|models|views|controllers|routes|types)\/[\w\-\/]+\.(?:ts|js|tsx|jsx|json|md|yml|yaml)/g;
|
|
111
|
+
const pathMatches = outputSummary.match(pathPattern);
|
|
112
|
+
if (pathMatches) {
|
|
113
|
+
files.push(...pathMatches);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Pattern 3: Files ending with common extensions
|
|
117
|
+
const filePattern = /(?:^|\s)([\w\-\/]+\.(?:ts|js|tsx|jsx|json|md|yml|yaml))(?:\s|$)/g;
|
|
118
|
+
let match;
|
|
119
|
+
while ((match = filePattern.exec(outputSummary)) !== null) {
|
|
120
|
+
if (match[1] && !files.includes(match[1])) {
|
|
121
|
+
files.push(match[1]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Deduplicate
|
|
126
|
+
return [...new Set(files)];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if a file path matches any sensitive file pattern.
|
|
131
|
+
*/
|
|
132
|
+
function isSensitiveFile(filePath: string): { match: boolean; pattern?: string; severity?: "high" | "medium" | "low" } {
|
|
133
|
+
const normalizedPath = filePath.toLowerCase();
|
|
134
|
+
|
|
135
|
+
for (const pattern of SENSITIVE_FILE_PATTERNS) {
|
|
136
|
+
if (pattern.test(normalizedPath)) {
|
|
137
|
+
// Find the severity
|
|
138
|
+
const patternStr = pattern.toString();
|
|
139
|
+
for (const [key, severity] of Object.entries(FILE_SEVERITY_MAP)) {
|
|
140
|
+
if (patternStr.includes(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) {
|
|
141
|
+
return { match: true, pattern: key, severity };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Default to high if no specific severity found
|
|
145
|
+
return { match: true, pattern: patternStr, severity: "high" };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { match: false };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if a task description contains sensitive keywords.
|
|
154
|
+
*/
|
|
155
|
+
function hasSensitiveTaskKeywords(task: string): { match: boolean; keyword?: string } {
|
|
156
|
+
const lowerTask = task.toLowerCase();
|
|
157
|
+
|
|
158
|
+
for (const keyword of SENSITIVE_TASK_KEYWORDS) {
|
|
159
|
+
if (lowerTask.includes(keyword)) {
|
|
160
|
+
return { match: true, keyword };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { match: false };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Report generators
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Generates a report mapping files to subagent runs that touched them.
|
|
173
|
+
*
|
|
174
|
+
* Parses gitDiff from each run's result (if available) to extract file paths.
|
|
175
|
+
* If gitDiff is not available, uses outputSummary to heuristically find file paths.
|
|
176
|
+
*
|
|
177
|
+
* @param runs - Array of SubagentRun records
|
|
178
|
+
* @returns FileAccessReport mapping file path to array of run IDs that touched it
|
|
179
|
+
*/
|
|
180
|
+
export function buildFileAccessReport(runs: SubagentRun[]): FileAccessReport {
|
|
181
|
+
const files: Record<string, string[]> = {};
|
|
182
|
+
|
|
183
|
+
for (const run of runs) {
|
|
184
|
+
const runFiles: string[] = [];
|
|
185
|
+
|
|
186
|
+
// Try to extract from gitDiff first (stored in fullOutput for completed runs)
|
|
187
|
+
if (run.fullOutput) {
|
|
188
|
+
// Check if fullOutput contains git diff content
|
|
189
|
+
if (run.fullOutput.includes("diff --git")) {
|
|
190
|
+
runFiles.push(...extractFilesFromGitDiff(run.fullOutput));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Fall back to outputSummary heuristics
|
|
195
|
+
if (runFiles.length === 0 && run.outputSummary) {
|
|
196
|
+
runFiles.push(...extractFilesFromOutputSummary(run.outputSummary));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Add run ID to each file's list
|
|
200
|
+
for (const file of runFiles) {
|
|
201
|
+
if (!files[file]) {
|
|
202
|
+
files[file] = [];
|
|
203
|
+
}
|
|
204
|
+
if (!files[file].includes(run.id)) {
|
|
205
|
+
files[file].push(run.id);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { files };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Checks if any subagent runs accessed sensitive files.
|
|
215
|
+
*
|
|
216
|
+
* Checks run task descriptions for suspicious patterns and run outputs
|
|
217
|
+
* for sensitive file access.
|
|
218
|
+
*
|
|
219
|
+
* @param runs - Array of SubagentRun records
|
|
220
|
+
* @returns SecretsExposureReport with any findings
|
|
221
|
+
*/
|
|
222
|
+
export function buildSecretsExposureReport(runs: SubagentRun[]): SecretsExposureReport {
|
|
223
|
+
const exposures: SecretsExposureEntry[] = [];
|
|
224
|
+
|
|
225
|
+
for (const run of runs) {
|
|
226
|
+
// Check task description for sensitive keywords
|
|
227
|
+
const taskCheck = hasSensitiveTaskKeywords(run.task);
|
|
228
|
+
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
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Check output for sensitive file access
|
|
238
|
+
const outputToCheck = [
|
|
239
|
+
run.outputSummary,
|
|
240
|
+
run.fullOutput,
|
|
241
|
+
].filter(Boolean).join("\n");
|
|
242
|
+
|
|
243
|
+
if (outputToCheck) {
|
|
244
|
+
// Check for direct file access mentions
|
|
245
|
+
for (const pattern of SENSITIVE_FILE_PATTERNS) {
|
|
246
|
+
const patternStr = pattern.toString();
|
|
247
|
+
if (pattern.test(outputToCheck)) {
|
|
248
|
+
// Determine severity based on the pattern
|
|
249
|
+
let severity: "high" | "medium" | "low" = "medium";
|
|
250
|
+
for (const [key, sev] of Object.entries(FILE_SEVERITY_MAP)) {
|
|
251
|
+
if (patternStr.includes(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) {
|
|
252
|
+
severity = sev;
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
exposures.push({
|
|
258
|
+
file: patternStr,
|
|
259
|
+
runId: run.id,
|
|
260
|
+
runLabel: run.label,
|
|
261
|
+
severity,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return { exposures };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Combines metrics, file access, and secrets exposure into a single summary.
|
|
273
|
+
*
|
|
274
|
+
* @param runs - Array of SubagentRun records
|
|
275
|
+
* @returns ComplianceSummary with aggregated data
|
|
276
|
+
*/
|
|
277
|
+
export function generateComplianceSummary(runs: SubagentRun[]): ComplianceSummary {
|
|
278
|
+
if (runs.length === 0) {
|
|
279
|
+
return {
|
|
280
|
+
totalRuns: 0,
|
|
281
|
+
dateRange: {
|
|
282
|
+
earliest: "",
|
|
283
|
+
latest: "",
|
|
284
|
+
},
|
|
285
|
+
metrics: computeSLAMetrics([]),
|
|
286
|
+
filesAccessed: 0,
|
|
287
|
+
sensitiveFilesTouched: 0,
|
|
288
|
+
highSeverityFindings: 0,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Calculate date range
|
|
293
|
+
const dates = runs
|
|
294
|
+
.map((r) => new Date(r.startedAt).getTime())
|
|
295
|
+
.filter((d) => !isNaN(d));
|
|
296
|
+
|
|
297
|
+
const earliest = dates.length > 0 ? new Date(Math.min(...dates)).toISOString() : "";
|
|
298
|
+
const latest = dates.length > 0 ? new Date(Math.max(...dates)).toISOString() : "";
|
|
299
|
+
|
|
300
|
+
// Compute SLA metrics
|
|
301
|
+
const metrics = computeSLAMetrics(runs);
|
|
302
|
+
|
|
303
|
+
// Get file access report
|
|
304
|
+
const fileReport = buildFileAccessReport(runs);
|
|
305
|
+
const filesAccessed = Object.keys(fileReport.files).length;
|
|
306
|
+
|
|
307
|
+
// Get secrets exposure report
|
|
308
|
+
const secretsReport = buildSecretsExposureReport(runs);
|
|
309
|
+
const sensitiveFilesTouched = secretsReport.exposures.length;
|
|
310
|
+
const highSeverityFindings = secretsReport.exposures.filter(
|
|
311
|
+
(e) => e.severity === "high"
|
|
312
|
+
).length;
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
totalRuns: runs.length,
|
|
316
|
+
dateRange: {
|
|
317
|
+
earliest,
|
|
318
|
+
latest,
|
|
319
|
+
},
|
|
320
|
+
metrics,
|
|
321
|
+
filesAccessed,
|
|
322
|
+
sensitiveFilesTouched,
|
|
323
|
+
highSeverityFindings,
|
|
324
|
+
};
|
|
325
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — Skill-based Routing
|
|
3
|
+
*
|
|
4
|
+
* Auto-classify a task description to the best preset personality
|
|
5
|
+
* using keyword matching.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SubagentPreset } from "./types";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Classification rules — ordered by priority
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
interface ClassificationRule {
|
|
15
|
+
preset: string;
|
|
16
|
+
keywords: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CLASSIFICATION_RULES: ClassificationRule[] = [
|
|
20
|
+
{
|
|
21
|
+
preset: "security-auditor",
|
|
22
|
+
keywords: [
|
|
23
|
+
"audit", "security", "vulnerability", "owasp", "cve",
|
|
24
|
+
"exploit", "threat", "penetration", "injection", "xss",
|
|
25
|
+
"csrf", "auth bypass",
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
preset: "code-reviewer",
|
|
30
|
+
keywords: [
|
|
31
|
+
"review", "code review", "pr", "pull request", "quality",
|
|
32
|
+
"best practice", "anti-pattern",
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
preset: "test-engineer",
|
|
37
|
+
keywords: [
|
|
38
|
+
"test", "unit test", "integration test", "coverage", "mock",
|
|
39
|
+
"assertion", "spec", "tdd", "bdd",
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
preset: "tech-writer",
|
|
44
|
+
keywords: [
|
|
45
|
+
"document", "readme", "api doc", "manual", "guide",
|
|
46
|
+
"tutorial", "write docs",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
preset: "debugger",
|
|
51
|
+
keywords: [
|
|
52
|
+
"debug", "bug", "fix", "crash", "error", "trace",
|
|
53
|
+
"diagnose", "root cause", "reproduce",
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
preset: "refactorer",
|
|
58
|
+
keywords: [
|
|
59
|
+
"refactor", "restructure", "clean up", "dry", "extract",
|
|
60
|
+
"simplify", "rename", "reorganize",
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
preset: "data-analyst",
|
|
65
|
+
keywords: [
|
|
66
|
+
"analyze", "data", "metrics", "statistics", "chart",
|
|
67
|
+
"visualize", "report", "query", "aggregate",
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
preset: "rapid-prototyper",
|
|
72
|
+
keywords: [
|
|
73
|
+
"prototype", "quick", "fast", "hack", "poc", "spike",
|
|
74
|
+
"experiment",
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
preset: "dev-agent",
|
|
79
|
+
keywords: [
|
|
80
|
+
"implement", "build", "develop", "code", "write", "create",
|
|
81
|
+
"add", "change", "modify", "edit",
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check if a keyword matches within a task string.
|
|
88
|
+
* Uses word-boundary matching for short keywords (<=3 chars) to avoid
|
|
89
|
+
* false positives (e.g., "pr" matching inside "prototype").
|
|
90
|
+
*/
|
|
91
|
+
function keywordMatches(lower: string, keyword: string): boolean {
|
|
92
|
+
if (keyword.length <= 3) {
|
|
93
|
+
const re = new RegExp(`\\b${escapeRegExp(keyword)}\\b`);
|
|
94
|
+
return re.test(lower);
|
|
95
|
+
}
|
|
96
|
+
return lower.includes(keyword);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Escape special regex characters in a string. */
|
|
100
|
+
function escapeRegExp(s: string): string {
|
|
101
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Router
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Auto-classify a task description to the best preset personality.
|
|
110
|
+
* Uses case-insensitive keyword matching, checked in priority order.
|
|
111
|
+
* Returns the first matching preset name, or undefined if no match.
|
|
112
|
+
*/
|
|
113
|
+
export function autoRoutePreset(
|
|
114
|
+
task: string,
|
|
115
|
+
presets: SubagentPreset[],
|
|
116
|
+
): string | undefined {
|
|
117
|
+
if (!task || task.trim().length === 0) return undefined;
|
|
118
|
+
|
|
119
|
+
const lower = task.toLowerCase();
|
|
120
|
+
|
|
121
|
+
for (const rule of CLASSIFICATION_RULES) {
|
|
122
|
+
// Only match if the preset actually exists in the available presets
|
|
123
|
+
if (!presets.some((p) => p.name === rule.preset)) continue;
|
|
124
|
+
|
|
125
|
+
for (const keyword of rule.keywords) {
|
|
126
|
+
if (keywordMatches(lower, keyword)) {
|
|
127
|
+
return rule.preset;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|