goalbuddy 0.3.2 → 0.3.6

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 (55) hide show
  1. package/README.md +55 -5
  2. package/RELEASE-0.3.5.md +324 -0
  3. package/goalbuddy/SKILL.md +40 -13
  4. package/goalbuddy/agents/README.md +1 -1
  5. package/goalbuddy/agents/goal_judge.toml +33 -17
  6. package/goalbuddy/agents/goal_scout.toml +34 -14
  7. package/goalbuddy/agents/goal_worker.toml +36 -16
  8. package/goalbuddy/extend/local-goal-board/README.md +8 -4
  9. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  10. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  11. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  12. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  13. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  14. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  15. package/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  16. package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
  17. package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  18. package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
  19. package/goalbuddy/scripts/check-goal-state.mjs +192 -6
  20. package/goalbuddy/scripts/parallel-plan.mjs +191 -0
  21. package/goalbuddy/scripts/render-task-prompt.mjs +305 -0
  22. package/goalbuddy/templates/agents.md +5 -4
  23. package/goalbuddy/templates/goal.md +18 -4
  24. package/goalbuddy/templates/state.yaml +14 -1
  25. package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
  26. package/internal/cli/goal-maker.mjs +172 -9
  27. package/package.json +3 -2
  28. package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
  29. package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
  30. package/plugins/goalbuddy/README.md +5 -3
  31. package/plugins/goalbuddy/agents/goal-judge.md +35 -16
  32. package/plugins/goalbuddy/agents/goal-scout.md +38 -13
  33. package/plugins/goalbuddy/agents/goal-worker.md +37 -14
  34. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +40 -13
  35. package/plugins/goalbuddy/skills/goalbuddy/agents/README.md +1 -1
  36. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +33 -17
  37. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
  38. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +36 -16
  39. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
  40. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  41. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  42. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  43. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  44. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  45. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  46. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  47. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
  48. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  49. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
  50. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +192 -6
  51. package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
  52. package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +305 -0
  53. package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +5 -4
  54. package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +18 -4
  55. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +14 -1
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
- import { basename, dirname, join } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { basename, dirname, join, resolve, sep } from "node:path";
4
5
 
5
6
  const statePath = process.argv[2];
7
+ const isChildCheck = process.argv.includes("--child");
6
8
 
7
9
  if (!statePath) {
8
10
  console.error("Usage: node scripts/check-goal-state.mjs docs/goals/<slug>/state.yaml");
@@ -98,6 +100,7 @@ function parseTasks() {
98
100
  verify: taskList(task, "verify"),
99
101
  stopIf: taskList(task, "stop_if"),
100
102
  receipt: taskReceipt(task),
103
+ subgoal: taskSubgoal(task),
101
104
  }));
102
105
  }
103
106
 
@@ -149,6 +152,32 @@ function taskReceipt(task) {
149
152
  };
150
153
  }
151
154
 
155
+ function taskSubgoal(task) {
156
+ const lines = task.raw.split(/\r?\n/);
157
+ const start = lines.findIndex((line) => /^\s{4}subgoal:\s*/.test(line));
158
+ if (start === -1) return { present: false };
159
+
160
+ const subgoalLines = [];
161
+ for (let i = start + 1; i < lines.length; i += 1) {
162
+ if (/^\s{4}\S/.test(lines[i])) break;
163
+ subgoalLines.push(lines[i]);
164
+ }
165
+ const raw = subgoalLines.join("\n");
166
+ const scalar = (key) => {
167
+ const match = raw.match(new RegExp(`^\\s{6}${key}:\\s*(.*?)\\s*$`, "m"));
168
+ return match ? clean(match[1]) : null;
169
+ };
170
+
171
+ return {
172
+ present: true,
173
+ raw,
174
+ status: scalar("status"),
175
+ path: scalar("path"),
176
+ owner: scalar("owner"),
177
+ depth: scalar("depth"),
178
+ };
179
+ }
180
+
152
181
  function receiptList(raw, key) {
153
182
  const lines = raw.split(/\r?\n/);
154
183
  const start = lines.findIndex((line) => new RegExp(`^\\s{6}${key}:\\s*$`).test(line));
@@ -169,16 +198,16 @@ function receiptCommandStatuses(raw) {
169
198
  }
170
199
 
171
200
  function rootEntryErrors() {
172
- const allowed = new Set(["goal.md", "state.yaml", "notes", ".goalbuddy-board"]);
201
+ const allowed = new Set(["goal.md", "state.yaml", "notes", ".goalbuddy-board", "subgoals"]);
173
202
  const unexpected = [];
174
203
  for (const entry of readdirSync(root).filter((item) => item !== ".DS_Store")) {
175
204
  const path = join(root, entry);
176
205
  const stats = statSync(path);
177
206
  if (!allowed.has(entry)) {
178
207
  unexpected.push(entry);
179
- } else if ((entry === "notes" || entry === ".goalbuddy-board") && !stats.isDirectory()) {
208
+ } else if ((entry === "notes" || entry === ".goalbuddy-board" || entry === "subgoals") && !stats.isDirectory()) {
180
209
  unexpected.push(`${entry} (must be a directory)`);
181
- } else if (entry !== "notes" && entry !== ".goalbuddy-board" && !stats.isFile()) {
210
+ } else if (!["notes", ".goalbuddy-board", "subgoals"].includes(entry) && !stats.isFile()) {
182
211
  unexpected.push(`${entry} (must be a file)`);
183
212
  }
184
213
  }
@@ -241,7 +270,7 @@ if (!existsSync(join(root, "notes")) || !statSync(join(root, "notes")).isDirecto
241
270
 
242
271
  const unexpected = rootEntryErrors();
243
272
  if (unexpected.length > 0) {
244
- errors.push(`unexpected root entries; v2 goal roots may contain only goal.md, state.yaml, notes/, and .goalbuddy-board/: ${unexpected.join(", ")}`);
273
+ errors.push(`unexpected root entries; v2 goal roots may contain only goal.md, state.yaml, notes/, subgoals/, and .goalbuddy-board/: ${unexpected.join(", ")}`);
245
274
  }
246
275
 
247
276
  const tasks = parseTasks();
@@ -298,6 +327,10 @@ if (activeTasks.length === 1 && activeTask !== activeTasks[0].id) {
298
327
  if (activeTask && !ids.has(activeTask)) errors.push(`active_task points to unknown task: ${activeTask}`);
299
328
 
300
329
  for (const task of tasks) {
330
+ if (task.subgoal.present) {
331
+ validateSubgoal(task);
332
+ }
333
+
301
334
  const hasReceipt = task.receipt.present && task.receipt.value !== null;
302
335
  const receiptResult = hasReceipt ? task.receipt.scalar("result") : null;
303
336
  if (task.status === "done" && !hasReceipt) {
@@ -319,8 +352,11 @@ for (const task of tasks) {
319
352
  if (!task.receipt.has(key)) errors.push(`Worker receipt for ${task.id} missing ${key}`);
320
353
  }
321
354
  const changedFiles = task.receipt.list("changed_files");
355
+ if (changedFiles.length === 0) {
356
+ errors.push(`Worker receipt for ${task.id} changed_files must list at least one file`);
357
+ }
322
358
  for (const changedFile of changedFiles) {
323
- if (!task.allowedFiles.includes(changedFile)) {
359
+ if (!matchesAllowedFile(changedFile, task.allowedFiles)) {
324
360
  errors.push(`Worker receipt for ${task.id} changed file outside allowed_files: ${changedFile}`);
325
361
  }
326
362
  }
@@ -333,6 +369,9 @@ for (const task of tasks) {
333
369
  errors.push(`Worker receipt for ${task.id} has non-passing command status: ${status}`);
334
370
  }
335
371
  }
372
+ if (task.receipt.scalar("needs_judge") === true) {
373
+ warnings.push(`Worker receipt for ${task.id} requests legacy needs_judge; GoalBuddy now lets the PM continue by default and reviews only at phase, risk, ambiguity, rejected-verification, or final-completion boundaries`);
374
+ }
336
375
  }
337
376
  if (task.type === "scout" && task.status === "done" && hasReceipt) {
338
377
  if (!task.receipt.has("summary")) errors.push(`Scout receipt for ${task.id} missing summary`);
@@ -345,6 +384,153 @@ for (const task of tasks) {
345
384
  }
346
385
  }
347
386
 
387
+ warnings.push(...microSliceWarnings(tasks, activeTask, goalStatus));
388
+
389
+ function validateSubgoal(task) {
390
+ if (isChildCheck) {
391
+ errors.push(`child task ${task.id} must not contain a nested subgoal`);
392
+ return;
393
+ }
394
+
395
+ if (!["active", "blocked", "done"].includes(task.subgoal.status)) {
396
+ errors.push(`task ${task.id} subgoal.status must be active, blocked, or done; got ${task.subgoal.status || "<missing>"}`);
397
+ }
398
+ if (task.subgoal.depth !== 1) {
399
+ errors.push(`task ${task.id} subgoal.depth must be 1; got ${task.subgoal.depth || "<missing>"}`);
400
+ }
401
+ if (!task.subgoal.path) {
402
+ errors.push(`task ${task.id} subgoal.path is required`);
403
+ return;
404
+ }
405
+
406
+ const rootPath = resolve(root);
407
+ const childStatePath = resolve(rootPath, task.subgoal.path);
408
+ if (childStatePath !== rootPath && !childStatePath.startsWith(`${rootPath}${sep}`)) {
409
+ errors.push(`task ${task.id} subgoal.path must stay inside the goal root: ${task.subgoal.path}`);
410
+ return;
411
+ }
412
+ if (basename(childStatePath) !== "state.yaml") {
413
+ errors.push(`task ${task.id} subgoal.path must point to a state.yaml file`);
414
+ return;
415
+ }
416
+ if (!existsSync(childStatePath)) {
417
+ errors.push(`task ${task.id} subgoal state file not found: ${task.subgoal.path}`);
418
+ return;
419
+ }
420
+
421
+ const result = spawnSync(process.execPath, [process.argv[1], childStatePath, "--child"], {
422
+ encoding: "utf8",
423
+ });
424
+ let report = null;
425
+ try {
426
+ report = JSON.parse(result.stdout);
427
+ } catch {
428
+ errors.push(`task ${task.id} subgoal checker produced invalid output for ${task.subgoal.path}`);
429
+ return;
430
+ }
431
+ if (result.status !== 0 || !report.ok) {
432
+ for (const childError of report.errors || ["unknown child state error"]) {
433
+ errors.push(`task ${task.id} subgoal invalid: ${childError}`);
434
+ }
435
+ }
436
+ }
437
+
438
+ function microSliceWarnings(tasks, activeTaskId, goalStatus) {
439
+ const found = [];
440
+ const guidance = "Board may be micro-slicing. Prefer the largest safe useful slice.";
441
+ const doneTasks = tasks.filter((task) => task.status === "done");
442
+ const workerTasks = tasks.filter((task) => task.type === "worker");
443
+ const recentTinyWorkers = workerTasks.slice(-5).filter((task) => isTinyTask(task));
444
+ const firstMilestoneComplete = nestedScalar("goal", "first_milestone_complete") === true;
445
+
446
+ if (recentTinyWorkers.length >= 3) {
447
+ found.push(`${guidance} Three recent Worker tasks look tiny.`);
448
+ }
449
+
450
+ for (const task of tasks) {
451
+ if (task.type === "judge" && /pick small reviewable work|select one narrow next task/i.test(task.raw)) {
452
+ found.push(`${guidance} Judge instructions still ask for small or narrow work.`);
453
+ break;
454
+ }
455
+ }
456
+
457
+ if (goalStatus !== "active" || !activeTaskId) return [...new Set(found)];
458
+ const activeIndex = tasks.findIndex((task) => task.id === activeTaskId);
459
+ if (activeIndex === -1) return [...new Set(found)];
460
+ const active = tasks[activeIndex];
461
+ if (active.type === "worker") {
462
+ if (doneTasks.length >= 10 && active.allowedFiles.length > 0 && active.allowedFiles.length <= 2) {
463
+ found.push(`${guidance} Active Worker ${active.id} has only ${active.allowedFiles.length} allowed_files after ${doneTasks.length} completed tasks.`);
464
+ }
465
+ if (firstMilestoneComplete && isTinyTask(active)) {
466
+ found.push(`${guidance} The first milestone is complete, so the active Worker should move toward the next real milestone.`);
467
+ }
468
+ if (isMicroWorkerTask(active)) {
469
+ found.push(`${guidance} Active Worker ${active.id} looks like another helper-sized slice.`);
470
+ }
471
+ }
472
+ if (active.type !== "judge") return [...new Set(found)];
473
+
474
+ let pairs = 0;
475
+ for (let index = activeIndex; index > 0; index -= 2) {
476
+ const judge = tasks[index];
477
+ const worker = tasks[index - 1];
478
+ if (!isMicroJudgeForWorker(judge, worker)) break;
479
+ pairs += 1;
480
+ }
481
+ if (pairs >= 2) {
482
+ found.push(`${guidance} Micro Worker/Judge loop detected ending at ${active.id}.`);
483
+ }
484
+ return [...new Set(found)];
485
+ }
486
+
487
+ function isMicroJudgeForWorker(judge, worker) {
488
+ if (!judge || !worker) return false;
489
+ if (judge.type !== "judge" || worker.type !== "worker") return false;
490
+ if (!["active", "queued", "done"].includes(judge.status) || worker.status !== "done") return false;
491
+ const objective = String(judge.objective || "").toLowerCase();
492
+ return objective.includes(worker.id.toLowerCase()) && /audit|review|approve/.test(objective) && isMicroWorkerTask(worker);
493
+ }
494
+
495
+ function isMicroWorkerTask(task) {
496
+ if (!task || task.type !== "worker") return false;
497
+ const objective = String(task.objective || "").toLowerCase();
498
+ if (/collapsed|batch|package|tranche/.test(objective)) return false;
499
+ return /one narrow|single helper|one helper|per[- ]helper|per[- ]table|projection helper/.test(objective);
500
+ }
501
+
502
+ function isTinyTask(task) {
503
+ if (!task) return false;
504
+ const text = [task.objective, task.raw, task.receipt?.raw].join(" ").toLowerCase();
505
+ if (/collapsed|batch|package|tranche|vertical slice|milestone/.test(text)) return false;
506
+ return /\b(tiny|narrow|single helper|one helper|projection helper|projection function|contract file|read-only proof|doc note|validator|validation wrapper|pure helper|caller-input)\b/.test(text);
507
+ }
508
+
509
+ function matchesAllowedFile(file, allowedFiles) {
510
+ return allowedFiles.some((pattern) => globMatch(pattern, file));
511
+ }
512
+
513
+ function globMatch(pattern, file) {
514
+ const normalizedPattern = normalizePathPattern(pattern);
515
+ const normalizedFile = normalizePathPattern(file);
516
+ if (normalizedPattern === normalizedFile) return true;
517
+ const token = "__GOALBUDDY_GLOBSTAR__";
518
+ const regexSource = escapeRegExp(normalizedPattern)
519
+ .replace(/\*\*/g, token)
520
+ .replace(/\*/g, "[^/]*")
521
+ .replace(new RegExp(token, "g"), ".*");
522
+ const regex = new RegExp(`^${regexSource}$`);
523
+ return regex.test(normalizedFile);
524
+ }
525
+
526
+ function normalizePathPattern(value) {
527
+ return String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
528
+ }
529
+
530
+ function escapeRegExp(value) {
531
+ return String(value).replace(/[.+^${}()|[\]\\]/g, "\\$&");
532
+ }
533
+
348
534
  if (goalStatus === "done") {
349
535
  const finalAudit = tasks.some((task) => {
350
536
  if (!["judge", "pm"].includes(task.type) || task.status !== "done") return false;
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { relative, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { childBoardPaths, loadBoard, parseArgs, resolveBoardPath, selectTask } from "./render-task-prompt.mjs";
6
+
7
+ if (isDirectRun()) {
8
+ try {
9
+ const options = parseArgs(process.argv.slice(2));
10
+ const plan = createParallelPlan(options);
11
+ if (options.json) {
12
+ console.log(JSON.stringify(plan, null, 2));
13
+ } else {
14
+ console.log(formatPlan(plan));
15
+ }
16
+ } catch (error) {
17
+ console.error(error.message);
18
+ process.exitCode = 1;
19
+ }
20
+ }
21
+
22
+ export function createParallelPlan(options) {
23
+ const rootBoardPath = resolveBoardPath(options);
24
+ const boards = [loadBoard(rootBoardPath)];
25
+ for (const childPath of childBoardPaths(boards[0])) {
26
+ if (existsSync(childPath)) boards.push(loadBoard(childPath));
27
+ }
28
+
29
+ const candidates = boards.map((board) => candidateForBoard(board));
30
+ const workerCandidates = candidates.filter((candidate) => candidate.role === "worker");
31
+ return {
32
+ root_board_path: rootBoardPath,
33
+ mutated: false,
34
+ spawned_agents: false,
35
+ candidates: candidates.map((candidate) => ({
36
+ ...candidate,
37
+ safe_to_parallelize: isSafeCandidate(candidate, workerCandidates),
38
+ reason: safetyReason(candidate, workerCandidates),
39
+ render_prompt_command: promptCommand(candidate),
40
+ })),
41
+ };
42
+ }
43
+
44
+ function candidateForBoard(board) {
45
+ const task = selectTask(board);
46
+ const role = normalizeRole(task.type);
47
+ return {
48
+ board_path: board.path,
49
+ task_id: task.id,
50
+ role,
51
+ recommended_agent: role === "scout" ? "goal_scout" : role === "judge" ? "goal_judge" : role === "worker" ? "goal_worker" : "PM",
52
+ reasoning_hint: reasoningHint(task, role),
53
+ allowed_files: Array.isArray(task.allowed_files) ? task.allowed_files.map(String) : [],
54
+ };
55
+ }
56
+
57
+ function isSafeCandidate(candidate, workers) {
58
+ if (candidate.role === "scout" || candidate.role === "judge") return true;
59
+ if (candidate.role !== "worker") return false;
60
+ if (workers.length < 2) return false;
61
+ if (candidate.allowed_files.length === 0) return false;
62
+ return workers
63
+ .filter((worker) => worker !== candidate)
64
+ .every((worker) => worker.allowed_files.length > 0 && areDisjoint(candidate.allowed_files, worker.allowed_files));
65
+ }
66
+
67
+ function safetyReason(candidate, workers) {
68
+ if (candidate.role === "scout") return "Scout is read-only.";
69
+ if (candidate.role === "judge") return "Judge is read-only.";
70
+ if (candidate.role !== "worker") return "PM tasks mutate board truth and should stay serial.";
71
+ if (candidate.allowed_files.length === 0) return "Worker has no allowed_files, so write scope is unknown.";
72
+ const overlapping = workers
73
+ .filter((worker) => worker !== candidate)
74
+ .filter((worker) => worker.allowed_files.length === 0 || !areDisjoint(candidate.allowed_files, worker.allowed_files));
75
+ if (overlapping.length === 0) return workers.length > 1 ? "Worker write scope is disjoint from other active Workers." : "Only one active Worker candidate; parallel Worker safety needs a disjoint peer.";
76
+ return `Worker write scope overlaps or cannot be compared with ${overlapping.map((worker) => `${relative(process.cwd(), worker.board_path)}:${worker.task_id}`).join(", ")}.`;
77
+ }
78
+
79
+ function promptCommand(candidate) {
80
+ return `goalbuddy prompt --board ${quote(candidate.board_path)} --task ${candidate.task_id}`;
81
+ }
82
+
83
+ function areDisjoint(left, right) {
84
+ return left.every((leftPattern) => right.every((rightPattern) => !patternsOverlap(leftPattern, rightPattern)));
85
+ }
86
+
87
+ function patternsOverlap(left, right) {
88
+ const a = normalizePattern(left);
89
+ const b = normalizePattern(right);
90
+ const aHasGlob = hasGlob(a);
91
+ const bHasGlob = hasGlob(b);
92
+ if (a === b) return true;
93
+ if (a.endsWith("/**") && b.startsWith(a.slice(0, -3))) return true;
94
+ if (b.endsWith("/**") && a.startsWith(b.slice(0, -3))) return true;
95
+ if (!aHasGlob && !bHasGlob) return false;
96
+ if (!aHasGlob) return globToRegExp(b).test(a);
97
+ if (!bHasGlob) return globToRegExp(a).test(b);
98
+ if (hasUnsupportedGlob(a) || hasUnsupportedGlob(b)) return literalPrefixesMayOverlap(a, b);
99
+ return literalPrefixesMayOverlap(a, b);
100
+ }
101
+
102
+ function literalPrefixesMayOverlap(left, right) {
103
+ const a = literalPrefix(left);
104
+ const b = literalPrefix(right);
105
+ if (!a || !b) return true;
106
+ return a.startsWith(b) || b.startsWith(a);
107
+ }
108
+
109
+ function literalPrefix(pattern) {
110
+ const match = /[*?[\]]/.exec(pattern);
111
+ return match ? pattern.slice(0, match.index) : pattern;
112
+ }
113
+
114
+ function hasUnsupportedGlob(pattern) {
115
+ return /[\[\]]/.test(pattern);
116
+ }
117
+
118
+ function globToRegExp(pattern) {
119
+ let source = "";
120
+ for (let index = 0; index < pattern.length; index += 1) {
121
+ const char = pattern[index];
122
+ const next = pattern[index + 1];
123
+ if (char === "*" && next === "*") {
124
+ source += ".*";
125
+ index += 1;
126
+ } else if (char === "*") {
127
+ source += "[^/]*";
128
+ } else if (char === "?") {
129
+ source += "[^/]";
130
+ } else {
131
+ source += escapeRegExp(char);
132
+ }
133
+ }
134
+ return new RegExp(`^${source}$`);
135
+ }
136
+
137
+ function escapeRegExp(value) {
138
+ return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
139
+ }
140
+
141
+ function hasGlob(pattern) {
142
+ return /[*?[\]]/.test(pattern);
143
+ }
144
+
145
+ function normalizePattern(pattern) {
146
+ return String(pattern || "").replace(/\\/g, "/").replace(/^\.\//, "");
147
+ }
148
+
149
+ function normalizeRole(value) {
150
+ const role = String(value || "pm").toLowerCase();
151
+ return ["scout", "judge", "worker", "pm"].includes(role) ? role : "pm";
152
+ }
153
+
154
+ function reasoningHint(task, role) {
155
+ const hint = String(task.reasoning_hint || "").toLowerCase();
156
+ if (["low", "medium", "high", "xhigh"].includes(hint)) return hint;
157
+ if (role === "judge") return "high";
158
+ return "low";
159
+ }
160
+
161
+ function quote(value) {
162
+ return JSON.stringify(resolve(value));
163
+ }
164
+
165
+ function formatPlan(plan) {
166
+ const lines = [
167
+ "GoalBuddy parallel plan",
168
+ "",
169
+ `Root board: ${plan.root_board_path}`,
170
+ "Mutates state: no",
171
+ "Spawns agents: no",
172
+ "",
173
+ ];
174
+ for (const candidate of plan.candidates) {
175
+ lines.push(
176
+ `${candidate.board_path}:${candidate.task_id}`,
177
+ `- role: ${candidate.role}`,
178
+ `- recommended_agent: ${candidate.recommended_agent}`,
179
+ `- reasoning_hint: ${candidate.reasoning_hint}`,
180
+ `- safe_to_parallelize: ${candidate.safe_to_parallelize}`,
181
+ `- reason: ${candidate.reason}`,
182
+ `- render_prompt_command: ${candidate.render_prompt_command}`,
183
+ "",
184
+ );
185
+ }
186
+ return lines.join("\n").trimEnd();
187
+ }
188
+
189
+ function isDirectRun() {
190
+ return process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
191
+ }