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.
- package/README.md +55 -5
- package/RELEASE-0.3.5.md +324 -0
- package/goalbuddy/SKILL.md +40 -13
- package/goalbuddy/agents/README.md +1 -1
- package/goalbuddy/agents/goal_judge.toml +33 -17
- package/goalbuddy/agents/goal_scout.toml +34 -14
- package/goalbuddy/agents/goal_worker.toml +36 -16
- package/goalbuddy/extend/local-goal-board/README.md +8 -4
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
- package/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
- package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
- package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
- package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
- package/goalbuddy/scripts/check-goal-state.mjs +192 -6
- package/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/goalbuddy/scripts/render-task-prompt.mjs +305 -0
- package/goalbuddy/templates/agents.md +5 -4
- package/goalbuddy/templates/goal.md +18 -4
- package/goalbuddy/templates/state.yaml +14 -1
- package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
- package/internal/cli/goal-maker.mjs +172 -9
- package/package.json +3 -2
- package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
- package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
- package/plugins/goalbuddy/README.md +5 -3
- package/plugins/goalbuddy/agents/goal-judge.md +35 -16
- package/plugins/goalbuddy/agents/goal-scout.md +38 -13
- package/plugins/goalbuddy/agents/goal-worker.md +37 -14
- package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +40 -13
- package/plugins/goalbuddy/skills/goalbuddy/agents/README.md +1 -1
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +33 -17
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +36 -16
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
- package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +192 -6
- package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +305 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +5 -4
- package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +18 -4
- 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 {
|
|
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 (
|
|
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
|
|
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
|
+
}
|