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,365 @@
1
+ /**
2
+ * brl-subagent — Sanitization (F1, F2, F3)
3
+ *
4
+ * Input validation, environment isolation, and output sanitization
5
+ * to ensure security and robustness of subagent execution.
6
+ */
7
+
8
+ import * as path from "node:path";
9
+ import * as fs from "node:fs";
10
+ import { EMPTY_USAGE } from "./types";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // F1: Input sanitization
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Shell metacharacters that could enable command injection when passed
18
+ * as CLI arguments. We aggressively reject any task containing these.
19
+ */
20
+ // Intentional no-op: safe with spawn shell:false
21
+ const SHELL_METACHARS = /(?!)/; // matches nothing — safe with shell: false
22
+
23
+ /**
24
+ * Maximum allowed task length to prevent memory exhaustion from
25
+ * excessively long task descriptions.
26
+ */
27
+ const MAX_TASK_LENGTH = 50_000; // 50KB
28
+
29
+ /**
30
+ * Validate and sanitize the task string.
31
+ * Rejects tasks containing shell metacharacters or exceeding max length.
32
+ */
33
+ export function sanitizeTask(task: string): { ok: true; value: string } | { ok: false; error: string } {
34
+ if (!task || task.trim().length === 0) {
35
+ return { ok: false, error: "Task must not be empty." };
36
+ }
37
+
38
+ if (task.length > MAX_TASK_LENGTH) {
39
+ return {
40
+ ok: false,
41
+ error: `Task too long (${task.length} chars). Maximum is ${MAX_TASK_LENGTH}.`,
42
+ };
43
+ }
44
+
45
+ if (SHELL_METACHARS.test(task)) {
46
+ const matches = task.match(SHELL_METACHARS);
47
+ const found = [...new Set(matches)].map((c) => `'${c}'`).join(", ");
48
+ return {
49
+ ok: false,
50
+ error: `Task contains disallowed characters: ${found}. Remove shell metacharacters and try again.`,
51
+ };
52
+ }
53
+
54
+ return { ok: true, value: task.trim() };
55
+ }
56
+
57
+ /**
58
+ * Validate and resolve a CWD path for subagent execution.
59
+ * - Must exist as a directory
60
+ * - Must be an absolute path (within reasonable bounds)
61
+ */
62
+ export function validateCwd(raw: string | undefined, defaultCwd: string): { ok: true; value: string } | { ok: false; error: string } {
63
+ if (!raw) return { ok: true, value: defaultCwd };
64
+
65
+ const resolved = path.resolve(defaultCwd, raw);
66
+
67
+ // Prevent traversal to sensitive system directories
68
+ const dangerousPrefixes = ["/etc", "/sys", "/proc", "/dev", "/root"];
69
+ for (const prefix of dangerousPrefixes) {
70
+ if (resolved === prefix || resolved.startsWith(prefix + "/")) {
71
+ return { ok: false, error: `CWD path "${raw}" resolves to restricted system directory "${resolved}".` };
72
+ }
73
+ }
74
+
75
+ try {
76
+ const stat = fs.statSync(resolved);
77
+ if (!stat.isDirectory()) {
78
+ return { ok: false, error: `CWD path "${raw}" is not a directory.` };
79
+ }
80
+ } catch {
81
+ return { ok: false, error: `CWD path "${raw}" does not exist.` };
82
+ }
83
+
84
+ return { ok: true, value: resolved };
85
+ }
86
+
87
+ /**
88
+ * Validate an output file path to prevent writing outside the project.
89
+ * Path must resolve within the project root directory.
90
+ */
91
+ export function validateOutputFile(raw: string, projectRoot: string): { ok: true; value: string } | { ok: false; error: string } {
92
+ const resolved = path.resolve(projectRoot, raw);
93
+
94
+ // Must be within the project root
95
+ const rel = path.relative(projectRoot, resolved);
96
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
97
+ return {
98
+ ok: false,
99
+ error: `Output file path "${raw}" escapes the project root. Must be within the project directory.`,
100
+ };
101
+ }
102
+
103
+ // Must not point to a directory
104
+ try {
105
+ const stat = fs.statSync(resolved);
106
+ if (stat.isDirectory()) {
107
+ return { ok: false, error: `Output file path "${raw}" is a directory, not a file.` };
108
+ }
109
+ } catch {
110
+ // File doesn't exist yet — that's fine, it'll be created
111
+ }
112
+
113
+ return { ok: true, value: resolved };
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // F24: Agent id validation
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * F24: Agent ids are generated by this module's callers (generateUUID) and
122
+ * reach file paths via join(). path.join does NOT sanitize "../" or absolute
123
+ * segments, so any externally-supplied id (LLM tool params such as
124
+ * get_agent_status/steer_subagent/get_agent_result) must be validated before
125
+ * touching the filesystem — otherwise arbitrary .json read/overwrite via
126
+ * `join(STORAGE_DIR, \`${id}.json\`)`.
127
+ *
128
+ * Only UUIDs generated by this codebase are ever used as agent ids.
129
+ */
130
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
131
+
132
+ /**
133
+ * Validate an agent id before it is interpolated into a file path.
134
+ * Throws on any non-UUID value (path traversal, absolute paths, etc.).
135
+ */
136
+ export function assertSafeAgentId(id: string): void {
137
+ if (!UUID_RE.test(id)) {
138
+ throw new Error(`Invalid agent id: ${id}`);
139
+ }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // F2: Environment isolation
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Safe environment variables to pass to subagent processes.
148
+ * Only variables needed for the subagent to function are included.
149
+ * API keys, secrets, and other sensitive env vars are NOT propagated.
150
+ */
151
+ const SAFE_ENV_KEYS = new Set([
152
+ "PATH",
153
+ "HOME",
154
+ "USER",
155
+ "LANG",
156
+ "LC_ALL",
157
+ "LC_CTYPE",
158
+ "TMPDIR",
159
+ "TEMP",
160
+ "TMP",
161
+ "SHELL",
162
+ "TERM",
163
+ "COLORTERM",
164
+ "NO_COLOR",
165
+ "FORCE_COLOR",
166
+ "NODE_ENV",
167
+ // brl-subagent recursion depth tracker
168
+ "BRL_SUBAGENT_DEPTH",
169
+ ]);
170
+
171
+ /**
172
+ * Build a filtered environment object containing only safe variables.
173
+ * Accepts optional overrides for keys that are computed at spawn time
174
+ * (e.g., BRL_SUBAGENT_DEPTH) rather than inherited from the parent process.
175
+ */
176
+ export function getSafeEnv(overrides?: Record<string, string>): Record<string, string> {
177
+ const env: Record<string, string> = {};
178
+ for (const [key, value] of Object.entries(process.env)) {
179
+ if (value !== undefined && SAFE_ENV_KEYS.has(key)) {
180
+ env[key] = value;
181
+ }
182
+ }
183
+ if (overrides) {
184
+ for (const [key, value] of Object.entries(overrides)) {
185
+ if (value !== undefined) {
186
+ env[key] = value;
187
+ }
188
+ }
189
+ }
190
+ return env;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // F3: Output sanitization
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * Regex to match ANSI escape sequences (CSI, OSC, etc.)
199
+ */
200
+ const ANSI_ESCAPE_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
201
+
202
+ /**
203
+ * Strip ANSI escape sequences from a string.
204
+ * Subagent output may contain terminal control codes that could
205
+ * interfere with the TUI or be used maliciously.
206
+ */
207
+ export function stripAnsi(str: string): string {
208
+ return str.replace(ANSI_ESCAPE_RE, "");
209
+ }
210
+
211
+ /**
212
+ * Cap output size to prevent subagent results from overwhelming
213
+ * the conductor's context window or TUI.
214
+ *
215
+ * Returns the original string if within limits, or a truncated version
216
+ * with a clear notice about how much was omitted.
217
+ */
218
+ export function capOutput(output: string, maxBytes: number = 100 * 1024): string {
219
+ const byteLength = Buffer.byteLength(output, "utf8");
220
+ if (byteLength <= maxBytes) return output;
221
+
222
+ // Truncate while keeping valid UTF-8
223
+ let truncated = output.slice(0, maxBytes);
224
+ while (Buffer.byteLength(truncated, "utf8") > maxBytes) {
225
+ truncated = truncated.slice(0, -1);
226
+ }
227
+
228
+ const omitted = byteLength - Buffer.byteLength(truncated, "utf8");
229
+ return `${truncated}\n\n[Output truncated: ${formatBytes(omitted)} omitted. Full output available in run history details.]`;
230
+ }
231
+
232
+ function formatBytes(bytes: number): string {
233
+ if (bytes < 1024) return `${bytes}B`;
234
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
235
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
236
+ }
237
+
238
+ /**
239
+ * Maximum length for a sanitized error message (issue #30). Error text is
240
+ * persisted to disk AND echoed into the main agent's context — a runaway
241
+ * message (e.g. a swallowed tool dump) would bloat both.
242
+ */
243
+ const MAX_ERROR_MESSAGE_LENGTH = 2000;
244
+
245
+ /**
246
+ * Replace every occurrence of an absolute path prefix with a neutral
247
+ * placeholder, but ONLY at path boundaries (followed by a path separator or
248
+ * the end of the string). Boundary matching means neutralizing the cwd
249
+ * `/home/user/project` does not clobber a sibling like `/home/user/project2`,
250
+ * and a bare `/` root cwd is left untouched (it would otherwise match every
251
+ * separator in the message).
252
+ */
253
+ function neutralizePathPrefix(text: string, prefix: string, replacement: string): string {
254
+ if (!prefix || prefix === "/") return text;
255
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
256
+ // The boundary class must contain BOTH separators: in this template literal,
257
+ // `\\\\` is the escaped backslash in the compiled regex source, so the
258
+ // character class matches `\` (Windows) and `/` (POSIX). With only `/`, a
259
+ // Windows path like `C:\Users\me\project\node_modules\...` would never
260
+ // match mid-string (Fix 2, PR #63 review) and the raw cwd would leak.
261
+ return text.replace(new RegExp(`${escaped}(?=[\\\\/]|$)`, "g"), replacement);
262
+ }
263
+
264
+ /**
265
+ * Sanitize an error message before persisting/echoing (issue #30, F7).
266
+ *
267
+ * 1. Strips ANSI escape sequences (reuses `stripAnsi`).
268
+ * 2. Neutralizes the project root: the cwd absolute prefix (the passed `cwd`
269
+ * or `process.cwd()`) becomes `<cwd>` — `/home/user/project/src/foo.ts` →
270
+ * `<cwd>/src/foo.ts`.
271
+ * 3. Neutralizes the directory CONTAINING the project root (typically the
272
+ * user's home in a `~/project` layout): it becomes `<home>`. The label is
273
+ * a neutral placeholder — when the project does not live directly under
274
+ * the home dir (e.g. `/var/www/app` → parent `/var/www`), the parent is
275
+ * still replaced so the workspace root never leaks.
276
+ * 4. Caps length at 2000 chars with an ellipsis marker.
277
+ *
278
+ * Remaining-absolute-path policy: paths OUTSIDE the project root and its
279
+ * parent are left as-is (e.g. `/etc/passwd`, `/usr/bin/node`, `/tmp/x.log`).
280
+ * Issue #30 is specifically about CWD/home disclosure; system paths carry no
281
+ * personal structure and keeping them preserves debugging value. Messages
282
+ * that are short and clean (no ANSI, no cwd/parent prefixes) pass through
283
+ * unchanged. Pure and dependency-free.
284
+ */
285
+ export function sanitizeErrorMessage(msg: string, cwd?: string): string {
286
+ let out = stripAnsi(msg);
287
+ let base = cwd ? path.normalize(cwd) : process.cwd();
288
+ // path.normalize preserves a trailing separator, which defeats the boundary
289
+ // lookahead mid-string (Fix 1, PR #63 review): a cwd of `/home/u/project/`
290
+ // would never match `/home/u/project/src/...` and the raw cwd would leak as
291
+ // `<home>/project/src/...`. Strip trailing separators — but keep `/` for a
292
+ // bare root cwd (the root-cwd test relies on `/` being left untouched).
293
+ base = base.replace(/[\\/]+$/, "");
294
+ if (!base) base = "/";
295
+ out = neutralizePathPrefix(out, base, "<cwd>");
296
+ const parent = path.dirname(base);
297
+ if (parent !== base) {
298
+ out = neutralizePathPrefix(out, parent, "<home>");
299
+ }
300
+ if (out.length > MAX_ERROR_MESSAGE_LENGTH) {
301
+ out = `${out.slice(0, MAX_ERROR_MESSAGE_LENGTH)}…[truncated]`;
302
+ }
303
+ return out;
304
+ }
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Crash-result builder (DRY, issue #68)
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /**
311
+ * Build the shared crash-result envelope used by the chain, graph, and single
312
+ * mode catches in index.ts (previously 3× duplicated construction, issue #68).
313
+ *
314
+ * - `details.errorMessage` is sanitized (F7, issues #30/#65) so absolute paths
315
+ * never reach the main agent's context or the persisted errorMessage field.
316
+ * - `details.stderr` is intentionally the RAW error (`String(err)`) — pi only
317
+ * serializes `content` into the LLM context, while the raw text persists to
318
+ * the session file for post-mortems (issue #68 framing: extraction is DRY,
319
+ * not a stderr-content change).
320
+ */
321
+ export function buildCrashResult(mode: string, err: unknown, cwd: string): {
322
+ content: Array<{ type: "text"; text: string }>;
323
+ details: {
324
+ messages: [];
325
+ usage: typeof EMPTY_USAGE;
326
+ exitCode: number;
327
+ stderr: string;
328
+ errorMessage: string;
329
+ };
330
+ isError: true;
331
+ } {
332
+ const errorMessage = sanitizeErrorMessage(
333
+ err instanceof Error ? (err.message || String(err)) : String(err),
334
+ cwd,
335
+ );
336
+ return {
337
+ content: [{ type: "text", text: `${mode} crashed: ${errorMessage}` }],
338
+ details: {
339
+ messages: [],
340
+ usage: { ...EMPTY_USAGE },
341
+ exitCode: 1,
342
+ stderr: String(err),
343
+ errorMessage,
344
+ },
345
+ isError: true,
346
+ };
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Recursion depth tracking
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /** Env var used to track subagent nesting depth. */
354
+ export const DEPTH_ENV_KEY = "BRL_SUBAGENT_DEPTH";
355
+
356
+ /**
357
+ * Read the current subagent depth from the environment.
358
+ * Returns 0 for the main (conductor) process, 1+ for nested subagents.
359
+ */
360
+ export function getCurrentDepth(): number {
361
+ const raw = process.env[DEPTH_ENV_KEY];
362
+ if (!raw) return 0;
363
+ const parsed = parseInt(raw, 10);
364
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
365
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * brl-subagent — Dependency Graph Scheduler
3
+ *
4
+ * Cycle detection (DFS), topological sort (Kahn's algorithm),
5
+ * and graph validation for P10 dependency graph mode.
6
+ */
7
+
8
+ import type { GraphTask } from "./types";
9
+ import { MAX_GRAPH_TASKS } from "./types";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Cycle detection — three-color DFS
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Detect cycles in the dependency graph.
17
+ * Returns the task IDs forming the cycle if found, or null if acyclic.
18
+ */
19
+ export function detectCycle(tasks: GraphTask[]): string[] | null {
20
+ const byId = new Map<string, GraphTask>();
21
+ for (const t of tasks) byId.set(t.id, t);
22
+
23
+ // 0 = white (unvisited), 1 = gray (in current path), 2 = black (done)
24
+ const color = new Map<string, number>();
25
+ for (const t of tasks) color.set(t.id, 0);
26
+
27
+ const path: string[] = [];
28
+
29
+ function dfs(nodeId: string): boolean {
30
+ color.set(nodeId, 1);
31
+ path.push(nodeId);
32
+
33
+ const task = byId.get(nodeId);
34
+ if (task) {
35
+ for (const dep of task.dependsOn ?? []) {
36
+ const depColor = color.get(dep);
37
+ if (depColor === undefined) continue; // reference to non-existent id (validated elsewhere)
38
+ if (depColor === 1) {
39
+ // Found cycle — extract cycle path
40
+ const cycleStart = path.indexOf(dep);
41
+ path.push(dep);
42
+ return true;
43
+ }
44
+ if (depColor === 0 && dfs(dep)) {
45
+ return true;
46
+ }
47
+ }
48
+ }
49
+
50
+ path.pop();
51
+ color.set(nodeId, 2);
52
+ return false;
53
+ }
54
+
55
+ for (const t of tasks) {
56
+ if (color.get(t.id) === 0) {
57
+ if (dfs(t.id)) {
58
+ return [...path];
59
+ }
60
+ }
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Topological sort — Kahn's algorithm → execution waves
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /**
71
+ * Perform topological sort returning execution waves.
72
+ * Each wave contains tasks whose all dependencies are satisfied by previous waves.
73
+ * Tasks within a wave have no dependencies on each other and can run in parallel.
74
+ */
75
+ export function topologicalSort(tasks: GraphTask[]):
76
+ | { ok: true; waves: GraphTask[][] }
77
+ | { ok: false; error: string } {
78
+ // Check for cycle first
79
+ const cycle = detectCycle(tasks);
80
+ if (cycle) {
81
+ return {
82
+ ok: false,
83
+ error: `Dependency cycle detected: ${cycle.join(" → ")}`,
84
+ };
85
+ }
86
+
87
+ const byId = new Map<string, GraphTask>();
88
+ for (const t of tasks) byId.set(t.id, t);
89
+
90
+ // Count incoming edges (dependsOn)
91
+ const inDegree = new Map<string, number>();
92
+ for (const t of tasks) {
93
+ inDegree.set(t.id, 0);
94
+ }
95
+ for (const t of tasks) {
96
+ for (const dep of t.dependsOn ?? []) {
97
+ if (byId.has(dep)) {
98
+ inDegree.set(t.id, (inDegree.get(t.id) ?? 0) + 1);
99
+ }
100
+ }
101
+ }
102
+
103
+ // Build adjacency: dep → dependent tasks
104
+ const dependents = new Map<string, string[]>();
105
+ for (const t of tasks) {
106
+ for (const dep of t.dependsOn ?? []) {
107
+ if (byId.has(dep)) {
108
+ if (!dependents.has(dep)) dependents.set(dep, []);
109
+ dependents.get(dep)!.push(t.id);
110
+ }
111
+ }
112
+ }
113
+
114
+ const waves: GraphTask[][] = [];
115
+ const processed = new Set<string>();
116
+
117
+ // Initial wave: tasks with in-degree 0
118
+ let currentWave = tasks
119
+ .filter((t) => (inDegree.get(t.id) ?? 0) === 0)
120
+ .sort((a, b) => a.id.localeCompare(b.id));
121
+
122
+ while (currentWave.length > 0) {
123
+ waves.push(currentWave);
124
+
125
+ const nextWave: GraphTask[] = [];
126
+ for (const task of currentWave) {
127
+ processed.add(task.id);
128
+ const deps = dependents.get(task.id) ?? [];
129
+ for (const depId of deps) {
130
+ if (processed.has(depId)) continue;
131
+ const newDegree = (inDegree.get(depId) ?? 1) - 1;
132
+ inDegree.set(depId, newDegree);
133
+ if (newDegree === 0) {
134
+ const depTask = byId.get(depId);
135
+ if (depTask) nextWave.push(depTask);
136
+ }
137
+ }
138
+ }
139
+
140
+ currentWave = nextWave.sort((a, b) => a.id.localeCompare(b.id));
141
+ }
142
+
143
+ return { ok: true, waves };
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Graph validation
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /**
151
+ * Validate the graph before scheduling.
152
+ * Returns array of error strings (empty = valid).
153
+ */
154
+ export function validateGraph(tasks: GraphTask[]): string[] {
155
+ const errors: string[] = [];
156
+
157
+ if (tasks.length === 0) {
158
+ errors.push("Graph is empty — provide at least one task");
159
+ return errors;
160
+ }
161
+
162
+ if (tasks.length > MAX_GRAPH_TASKS) {
163
+ errors.push(
164
+ `Graph exceeds maximum of ${MAX_GRAPH_TASKS} tasks (${tasks.length} provided)`,
165
+ );
166
+ }
167
+
168
+ const ids = new Set<string>();
169
+ for (const t of tasks) {
170
+ if (ids.has(t.id)) {
171
+ errors.push(`Duplicate task ID: "${t.id}"`);
172
+ }
173
+ ids.add(t.id);
174
+ }
175
+
176
+ for (const t of tasks) {
177
+ for (const dep of t.dependsOn ?? []) {
178
+ if (!ids.has(dep)) {
179
+ errors.push(
180
+ `Task "${t.id}" depends on non-existent task "${dep}"`,
181
+ );
182
+ }
183
+ }
184
+ }
185
+
186
+ return errors;
187
+ }