goalbuddy 0.2.21 → 0.2.22

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 (41) hide show
  1. package/README.md +10 -18
  2. package/goalbuddy/SKILL.md +40 -10
  3. package/goalbuddy/extend/github-projects/README.md +105 -0
  4. package/goalbuddy/extend/github-projects/examples/goal-board-sync/state.yaml +63 -0
  5. package/goalbuddy/extend/github-projects/extension.yaml +43 -0
  6. package/goalbuddy/extend/github-projects/scripts/lib/github-projects.mjs +728 -0
  7. package/goalbuddy/extend/github-projects/scripts/lib/goal-state.mjs +362 -0
  8. package/goalbuddy/extend/github-projects/scripts/sync-github-project.mjs +193 -0
  9. package/goalbuddy/extend/github-projects/test/github-projects.test.mjs +267 -0
  10. package/goalbuddy/extend/local-goal-board/README.md +75 -0
  11. package/goalbuddy/extend/local-goal-board/assets/goalbuddy-mark.png +0 -0
  12. package/goalbuddy/extend/local-goal-board/examples/sample-goal/notes/T001-scout.md +3 -0
  13. package/goalbuddy/extend/local-goal-board/examples/sample-goal/state.yaml +124 -0
  14. package/goalbuddy/extend/local-goal-board/extension.yaml +37 -0
  15. package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1225 -0
  16. package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +258 -0
  17. package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +146 -0
  18. package/goalbuddy/scripts/check-goal-state.mjs +24 -9
  19. package/goalbuddy/templates/state.yaml +18 -3
  20. package/internal/cli/goal-maker.mjs +57 -11
  21. package/package.json +3 -2
  22. package/plugins/goalbuddy/.codex-plugin/plugin.json +3 -3
  23. package/plugins/goalbuddy/README.md +1 -5
  24. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +40 -10
  25. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/README.md +105 -0
  26. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/examples/goal-board-sync/state.yaml +63 -0
  27. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/extension.yaml +43 -0
  28. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/lib/github-projects.mjs +728 -0
  29. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/lib/goal-state.mjs +362 -0
  30. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/sync-github-project.mjs +193 -0
  31. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/test/github-projects.test.mjs +267 -0
  32. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +75 -0
  33. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/assets/goalbuddy-mark.png +0 -0
  34. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/sample-goal/notes/T001-scout.md +3 -0
  35. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/sample-goal/state.yaml +124 -0
  36. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +37 -0
  37. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1225 -0
  38. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +258 -0
  39. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +146 -0
  40. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +24 -9
  41. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +18 -3
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "node:http";
3
+ import { existsSync, readFileSync, realpathSync, watch } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createBoardPayload, writeBoardApp } from "./lib/goal-board.mjs";
7
+
8
+ const textTypes = {
9
+ ".html": "text/html; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".js": "text/javascript; charset=utf-8",
12
+ ".svg": "image/svg+xml; charset=utf-8",
13
+ ".png": "image/png",
14
+ };
15
+
16
+ if (isDirectRun()) {
17
+ main().catch((error) => {
18
+ console.error(error.message);
19
+ process.exitCode = 1;
20
+ });
21
+ }
22
+
23
+ function isDirectRun() {
24
+ if (!process.argv[1]) return false;
25
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
26
+ }
27
+
28
+ export async function main() {
29
+ const options = parseArgs(process.argv.slice(2));
30
+ const goalDir = resolve(options.goal || "");
31
+ if (!options.goal) throw new Error("Missing --goal docs/goals/<slug>");
32
+ if (!existsSync(join(goalDir, "state.yaml"))) {
33
+ throw new Error(`Missing state.yaml in ${goalDir}`);
34
+ }
35
+
36
+ const appDir = writeBoardApp(goalDir);
37
+ const board = createBoardPayload(goalDir);
38
+
39
+ if (options.once) {
40
+ if (options.json) {
41
+ console.log(JSON.stringify({ goalDir, appDir, board }, null, 2));
42
+ } else {
43
+ console.log(`Generated GoalBuddy board app at ${appDir}`);
44
+ }
45
+ return { goalDir, appDir, board };
46
+ }
47
+
48
+ const server = await startBoardServer({
49
+ goalDir,
50
+ appDir,
51
+ host: options.host,
52
+ port: options.port,
53
+ });
54
+
55
+ if (options.json) {
56
+ console.log(JSON.stringify({ goalDir, appDir, url: server.url }, null, 2));
57
+ } else {
58
+ console.log(`GoalBuddy local board: ${server.url}`);
59
+ console.log(`Watching: ${join(goalDir, "state.yaml")}`);
60
+ console.log("Press Ctrl-C to stop.");
61
+ }
62
+
63
+ return server;
64
+ }
65
+
66
+ export function parseArgs(args) {
67
+ const options = {
68
+ goal: "",
69
+ host: "127.0.0.1",
70
+ port: 41737,
71
+ once: false,
72
+ json: false,
73
+ };
74
+
75
+ for (let index = 0; index < args.length; index += 1) {
76
+ const arg = args[index];
77
+ if (arg === "--goal") {
78
+ options.goal = args[++index] || "";
79
+ } else if (arg.startsWith("--goal=")) {
80
+ options.goal = arg.slice("--goal=".length);
81
+ } else if (arg === "--host") {
82
+ options.host = args[++index] || options.host;
83
+ } else if (arg.startsWith("--host=")) {
84
+ options.host = arg.slice("--host=".length);
85
+ } else if (arg === "--port") {
86
+ options.port = Number(args[++index] || options.port);
87
+ } else if (arg.startsWith("--port=")) {
88
+ options.port = Number(arg.slice("--port=".length));
89
+ } else if (arg === "--once") {
90
+ options.once = true;
91
+ } else if (arg === "--json") {
92
+ options.json = true;
93
+ } else if (arg === "--help" || arg === "-h") {
94
+ usage();
95
+ process.exit(0);
96
+ } else {
97
+ throw new Error(`Unknown argument: ${arg}`);
98
+ }
99
+ }
100
+
101
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
102
+ throw new Error(`Invalid --port: ${options.port}`);
103
+ }
104
+
105
+ return options;
106
+ }
107
+
108
+ export async function startBoardServer({ goalDir, appDir = writeBoardApp(goalDir), host = "127.0.0.1", port = 0 }) {
109
+ const root = resolve(goalDir);
110
+ const clients = new Set();
111
+ let lastPayload = safePayload(root);
112
+
113
+ const notify = () => {
114
+ lastPayload = safePayload(root);
115
+ for (const client of clients) sendEvent(client, lastPayload);
116
+ };
117
+
118
+ const watcher = watchGoal(root, notify);
119
+ const server = createServer((request, response) => {
120
+ const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
121
+ if (url.pathname === "/api/board") {
122
+ sendJson(response, safePayload(root));
123
+ return;
124
+ }
125
+ if (url.pathname === "/events") {
126
+ response.writeHead(200, {
127
+ "Content-Type": "text/event-stream; charset=utf-8",
128
+ "Cache-Control": "no-cache, no-transform",
129
+ "Connection": "keep-alive",
130
+ "X-Accel-Buffering": "no",
131
+ });
132
+ response.write("retry: 1000\n\n");
133
+ clients.add(response);
134
+ sendEvent(response, lastPayload);
135
+ request.on("close", () => clients.delete(response));
136
+ return;
137
+ }
138
+
139
+ serveStatic(appDir, url.pathname, response);
140
+ });
141
+
142
+ await new Promise((resolveListen, rejectListen) => {
143
+ server.once("error", rejectListen);
144
+ server.listen(port, host, () => {
145
+ server.off("error", rejectListen);
146
+ resolveListen();
147
+ });
148
+ });
149
+
150
+ const address = server.address();
151
+ const actualPort = typeof address === "object" && address ? address.port : port;
152
+
153
+ return {
154
+ url: `http://${host}:${actualPort}/`,
155
+ close: () => new Promise((resolveClose, rejectClose) => {
156
+ watcher.close();
157
+ for (const client of clients) client.end();
158
+ server.close((error) => error ? rejectClose(error) : resolveClose());
159
+ }),
160
+ };
161
+ }
162
+
163
+ function watchGoal(goalDir, onChange) {
164
+ const watchers = [];
165
+ const schedule = debounce(onChange, 80);
166
+ watchers.push(watch(goalDir, { persistent: true }, (_event, filename) => {
167
+ if (!filename) return schedule();
168
+ if (filename === "state.yaml" || filename === "notes") schedule();
169
+ }));
170
+ const notesDir = join(goalDir, "notes");
171
+ if (existsSync(notesDir)) {
172
+ watchers.push(watch(notesDir, { persistent: true }, schedule));
173
+ }
174
+ return {
175
+ close() {
176
+ for (const watcher of watchers) watcher.close();
177
+ },
178
+ };
179
+ }
180
+
181
+ function safePayload(goalDir) {
182
+ try {
183
+ return createBoardPayload(goalDir);
184
+ } catch (error) {
185
+ return {
186
+ generatedAt: new Date().toISOString(),
187
+ error: error.message,
188
+ goal: { title: "GoalBuddy Board", slug: "", status: "error", activeTask: "", tranche: "" },
189
+ columns: [
190
+ { id: "todo", title: "Todo", description: "Queued work ready to pull", tasks: [] },
191
+ { id: "in-progress", title: "In Progress", description: "The active task", tasks: [] },
192
+ { id: "blocked", title: "Blocked", description: "Needs unblock or a smaller slice", tasks: [] },
193
+ { id: "completed", title: "Completed", description: "Receipted work", tasks: [] },
194
+ ],
195
+ tasks: [],
196
+ notes: [],
197
+ };
198
+ }
199
+ }
200
+
201
+ function sendJson(response, payload) {
202
+ response.writeHead(200, {
203
+ "Content-Type": "application/json; charset=utf-8",
204
+ "Cache-Control": "no-store",
205
+ });
206
+ response.end(JSON.stringify(payload, null, 2));
207
+ }
208
+
209
+ function sendEvent(response, payload) {
210
+ response.write(`event: board\ndata: ${JSON.stringify(payload)}\n\n`);
211
+ }
212
+
213
+ function serveStatic(appDir, pathname, response) {
214
+ const cleanPath = pathname === "/" ? "/index.html" : pathname;
215
+ if (!/^\/[A-Za-z0-9_.-]+$/.test(cleanPath)) {
216
+ response.writeHead(404);
217
+ response.end("Not found");
218
+ return;
219
+ }
220
+
221
+ const file = join(appDir, cleanPath.slice(1));
222
+ if (!existsSync(file)) {
223
+ response.writeHead(404);
224
+ response.end("Not found");
225
+ return;
226
+ }
227
+
228
+ const extension = cleanPath.match(/\.[^.]+$/)?.[0] || "";
229
+ response.writeHead(200, {
230
+ "Content-Type": textTypes[extension] || "application/octet-stream",
231
+ "Cache-Control": "no-store",
232
+ });
233
+ response.end(readFileSync(file));
234
+ }
235
+
236
+ function debounce(fn, delay) {
237
+ let timer = null;
238
+ return () => {
239
+ clearTimeout(timer);
240
+ timer = setTimeout(fn, delay);
241
+ };
242
+ }
243
+
244
+ function usage() {
245
+ console.log(`GoalBuddy Local Goal Board
246
+
247
+ Usage:
248
+ node extend/local-goal-board/scripts/local-goal-board.mjs --goal docs/goals/<slug>
249
+ node extend/local-goal-board/scripts/local-goal-board.mjs --goal docs/goals/<slug> --once --json
250
+
251
+ Options:
252
+ --goal <path> Goal directory containing state.yaml.
253
+ --host <host> Local server host. Default: 127.0.0.1.
254
+ --port <port> Local server port. Use 0 for an ephemeral port.
255
+ --once Generate .goalbuddy-board and exit.
256
+ --json Print structured output.
257
+ `);
258
+ }
@@ -0,0 +1,146 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { spawnSync } from "node:child_process";
5
+ import { tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { createBoardPayload, writeBoardApp } from "../scripts/lib/goal-board.mjs";
8
+ import { parseArgs, startBoardServer } from "../scripts/local-goal-board.mjs";
9
+
10
+ test("normalizes a dense goal into local board columns", () => {
11
+ const payload = createBoardPayload(resolve("extend/local-goal-board/examples/sample-goal"));
12
+
13
+ assert.equal(payload.goal.title, "Local Kanban Board Extension");
14
+ assert.equal(payload.goal.activeTask, "");
15
+ assert.equal(payload.counts.total, 14);
16
+ assert.equal(payload.counts.todo, 0);
17
+ assert.equal(payload.counts.inProgress, 0);
18
+ assert.equal(payload.counts.blocked, 5);
19
+ assert.equal(payload.counts.completed, 9);
20
+ assert.deepEqual(payload.columns.map((column) => column.title), ["Todo", "In Progress", "Blocked", "Completed"]);
21
+
22
+ const scout = payload.tasks.find((task) => task.id === "T001");
23
+ assert.equal(scout.receipt.summary, "T001 completed during the progressive board motion demo.");
24
+ });
25
+
26
+ test("writes a minimal GoalBuddy web app into the goal directory", () => {
27
+ const appDir = writeBoardApp(resolve("extend/local-goal-board/examples/sample-goal"));
28
+ const html = readFileSync(join(appDir, "index.html"), "utf8");
29
+ const css = readFileSync(join(appDir, "styles.css"), "utf8");
30
+ const js = readFileSync(join(appDir, "app.js"), "utf8");
31
+ const logo = readFileSync(join(appDir, "goalbuddy-mark.png"));
32
+
33
+ assert.match(html, /goalbuddy-mark\.png/);
34
+ assert.match(css, /--canvas: #f7f6f3/);
35
+ assert.doesNotMatch(css, /gradient/i);
36
+ assert.match(js, /new EventSource\("\.\/events"\)/);
37
+ assert.match(js, /animateCardMoves/);
38
+ assert.match(js, /card\.animate/);
39
+ assert.match(js, /highlightMovingCards/);
40
+ assert.match(js, /duration: changedColumn \? 980 : 520/);
41
+ assert.equal(logo.subarray(1, 4).toString("ascii"), "PNG");
42
+ });
43
+
44
+ test("parses CLI options", () => {
45
+ assert.deepEqual(parseArgs(["--goal", "docs/goals/demo", "--port", "0", "--once", "--json"]), {
46
+ goal: "docs/goals/demo",
47
+ host: "127.0.0.1",
48
+ port: 0,
49
+ once: true,
50
+ json: true,
51
+ });
52
+ });
53
+
54
+ test("runs when installed under a symlinked temp path", () => {
55
+ const root = mkdtempSync("/tmp/goalbuddy-local-board-direct-");
56
+ try {
57
+ cpSync("extend/local-goal-board/scripts", join(root, "scripts"), { recursive: true });
58
+ cpSync("extend/local-goal-board/assets", join(root, "assets"), { recursive: true });
59
+
60
+ const result = spawnSync(process.execPath, [
61
+ join(root, "scripts", "local-goal-board.mjs"),
62
+ "--goal",
63
+ resolve("extend/local-goal-board/examples/sample-goal"),
64
+ "--once",
65
+ "--json",
66
+ ], { encoding: "utf8" });
67
+
68
+ assert.equal(result.status, 0, result.stderr || result.stdout);
69
+ const report = JSON.parse(result.stdout);
70
+ assert.equal(report.board.goal.title, "Local Kanban Board Extension");
71
+ } finally {
72
+ rmSync(root, { recursive: true, force: true });
73
+ }
74
+ });
75
+
76
+ test("serves board JSON and streams live state changes over SSE", async () => {
77
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-"));
78
+ const goalDir = join(root, "demo-goal");
79
+ try {
80
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
81
+ writeFileSync(join(goalDir, "state.yaml"), stateYaml("active"));
82
+ writeFileSync(join(goalDir, "notes", "T001-note.md"), "# Live Note\n\nInitial note.\n");
83
+
84
+ const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
85
+ try {
86
+ const boardResponse = await fetch(`${server.url}api/board`);
87
+ assert.equal(boardResponse.status, 200);
88
+ const board = await boardResponse.json();
89
+ assert.equal(board.tasks[0].status, "active");
90
+
91
+ const controller = new AbortController();
92
+ const events = await fetch(`${server.url}events`, { signal: controller.signal });
93
+ assert.equal(events.status, 200);
94
+ const reader = events.body.getReader();
95
+
96
+ await readUntil(reader, /"status":"active"/);
97
+ writeFileSync(join(goalDir, "state.yaml"), stateYaml("blocked"));
98
+ const update = await readUntil(reader, /"status":"blocked"/);
99
+ assert.match(update, /"title":"Blocked"/);
100
+
101
+ controller.abort();
102
+ await reader.cancel().catch(() => {});
103
+ } finally {
104
+ await server.close();
105
+ }
106
+ } finally {
107
+ rmSync(root, { recursive: true, force: true });
108
+ }
109
+ });
110
+
111
+ async function readUntil(reader, pattern) {
112
+ const decoder = new TextDecoder();
113
+ let text = "";
114
+ const deadline = Date.now() + 3000;
115
+
116
+ while (Date.now() < deadline) {
117
+ const { done, value } = await reader.read();
118
+ if (done) break;
119
+ text += decoder.decode(value, { stream: true });
120
+ if (pattern.test(text)) return text;
121
+ }
122
+
123
+ assert.fail(`Timed out waiting for ${pattern}. Received:\n${text}`);
124
+ }
125
+
126
+ function stateYaml(status) {
127
+ return `version: 2
128
+ goal:
129
+ title: "Live board"
130
+ slug: "live-board"
131
+ kind: specific
132
+ tranche: "Verify live updates."
133
+ status: active
134
+ active_task: T001
135
+ tasks:
136
+ - id: T001
137
+ type: worker
138
+ assignee: Worker
139
+ status: ${status}
140
+ objective: "Render live changes."
141
+ receipt:
142
+ result: done
143
+ summary: "Rendered safely."
144
+ note: notes/T001-note.md
145
+ `;
146
+ }
@@ -169,16 +169,16 @@ function receiptCommandStatuses(raw) {
169
169
  }
170
170
 
171
171
  function rootEntryErrors() {
172
- const allowed = new Set(["goal.md", "state.yaml", "notes"]);
172
+ const allowed = new Set(["goal.md", "state.yaml", "notes", ".goalbuddy-board"]);
173
173
  const unexpected = [];
174
174
  for (const entry of readdirSync(root).filter((item) => item !== ".DS_Store")) {
175
175
  const path = join(root, entry);
176
176
  const stats = statSync(path);
177
177
  if (!allowed.has(entry)) {
178
178
  unexpected.push(entry);
179
- } else if (entry === "notes" && !stats.isDirectory()) {
180
- unexpected.push("notes (must be a directory)");
181
- } else if (entry !== "notes" && !stats.isFile()) {
179
+ } else if ((entry === "notes" || entry === ".goalbuddy-board") && !stats.isDirectory()) {
180
+ unexpected.push(`${entry} (must be a directory)`);
181
+ } else if (entry !== "notes" && entry !== ".goalbuddy-board" && !stats.isFile()) {
182
182
  unexpected.push(`${entry} (must be a file)`);
183
183
  }
184
184
  }
@@ -188,10 +188,11 @@ function rootEntryErrors() {
188
188
  const version = topScalar("version");
189
189
  const goalStatus = nestedScalar("goal", "status");
190
190
  const activeTask = topScalar("active_task");
191
- const installedAgents = ["scout", "worker", "judge"].map((agent) => ({
191
+ const agentStatuses = ["scout", "worker", "judge"].map((agent) => ({
192
192
  agent,
193
193
  status: nestedScalar("agents", agent),
194
194
  }));
195
+ const allowedAgentStatuses = new Set(["installed", "bundled_not_installed", "missing", "unknown"]);
195
196
  const continuousUntilFullOutcome = nestedScalar("rules", "continuous_until_full_outcome") === true;
196
197
  const missingInputOrCredentialsDoNotStopGoal =
197
198
  nestedScalar("rules", "missing_input_or_credentials_do_not_stop_goal") === true;
@@ -214,9 +215,22 @@ if (!["active", "blocked", "done"].includes(goalStatus)) {
214
215
  errors.push(`goal.status must be active, blocked, or done; got ${goalStatus || "<missing>"}`);
215
216
  }
216
217
 
217
- for (const { agent, status } of installedAgents) {
218
- if (status !== "installed") {
219
- errors.push(`agents.${agent} must be installed; got ${status || "<missing>"}`);
218
+ function agentStatusWarning(agent, status) {
219
+ const agentLabel = agent[0].toUpperCase() + agent.slice(1);
220
+ if (status === "bundled_not_installed") {
221
+ return `agents.${agent} is bundled_not_installed; /goal can continue through PM fallback, but dedicated ${agentLabel} delegation is unavailable until installed. If dedicated agents are required before /goal, run: npx goalbuddy agents`;
222
+ }
223
+ if (status === "missing") {
224
+ return `agents.${agent} is missing; /goal can continue through PM fallback, but dedicated ${agentLabel} delegation is unavailable. If dedicated agents are required before /goal, run: npx goalbuddy install`;
225
+ }
226
+ return `agents.${agent} is unknown; /goal can continue through PM fallback, but dedicated ${agentLabel} delegation was not verified. To check before /goal, run: npx goalbuddy doctor`;
227
+ }
228
+
229
+ for (const { agent, status } of agentStatuses) {
230
+ if (!allowedAgentStatuses.has(status)) {
231
+ errors.push(`agents.${agent} must be one of installed, bundled_not_installed, missing, or unknown; got ${status || "<missing>"}`);
232
+ } else if (status !== "installed") {
233
+ warnings.push(agentStatusWarning(agent, status));
220
234
  }
221
235
  }
222
236
 
@@ -227,7 +241,7 @@ if (!existsSync(join(root, "notes")) || !statSync(join(root, "notes")).isDirecto
227
241
 
228
242
  const unexpected = rootEntryErrors();
229
243
  if (unexpected.length > 0) {
230
- errors.push(`unexpected root entries; v2 goal roots may contain only goal.md, state.yaml, and notes/: ${unexpected.join(", ")}`);
244
+ errors.push(`unexpected root entries; v2 goal roots may contain only goal.md, state.yaml, notes/, and .goalbuddy-board/: ${unexpected.join(", ")}`);
231
245
  }
232
246
 
233
247
  const tasks = parseTasks();
@@ -361,6 +375,7 @@ const result = {
361
375
  state_path: statePath,
362
376
  goal_status: goalStatus,
363
377
  active_task: activeTask,
378
+ agent_statuses: Object.fromEntries(agentStatuses.map(({ agent, status }) => [agent, status])),
364
379
  task_count: tasks.length,
365
380
  errors,
366
381
  warnings,
@@ -35,9 +35,24 @@ rules:
35
35
  intake_misfire_must_be_audited: true
36
36
 
37
37
  agents:
38
- scout: installed
39
- worker: installed
40
- judge: installed
38
+ # installed | bundled_not_installed | missing | unknown
39
+ # If non-installed, /goal can continue through PM fallback; install agents before /goal only when dedicated delegation is required.
40
+ scout: unknown
41
+ worker: unknown
42
+ judge: unknown
43
+
44
+ visual_board:
45
+ # none | local | github_projects | both | unknown
46
+ selected: unknown
47
+ local:
48
+ status: not_requested # not_requested | starting | live | generated | blocked
49
+ url: null
50
+ command: "npx goalbuddy board docs/goals/<goal-slug>"
51
+ github_projects:
52
+ status: not_requested # not_requested | needs_approval | dry_run_ready | synced | blocked
53
+ url: null
54
+ command: "npx goalbuddy extend github-projects"
55
+ missing: []
41
56
 
42
57
  active_task: T001
43
58