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
 
@@ -33,6 +33,7 @@ const requiredAgentFiles = [
33
33
  "goal_scout.toml",
34
34
  "goal_worker.toml",
35
35
  ];
36
+ const bundledCoreExtensionIds = new Set(["github-projects", "local-goal-board"]);
36
37
  const optionsWithValues = new Set([
37
38
  "--catalog",
38
39
  "--catalog-url",
@@ -459,9 +460,9 @@ function installPlugin() {
459
460
  console.log("Restart Codex, then use:");
460
461
  console.log(` $${canonicalSkillName}`);
461
462
  console.log("");
462
- console.log("Optional extensions:");
463
- console.log(` npx ${canonicalCliName} extend`);
464
- console.log(` npx ${canonicalCliName} extend install --all`);
463
+ console.log("Bundled visual boards:");
464
+ console.log(` npx ${canonicalCliName} board docs/goals/<slug>`);
465
+ console.log(` npx ${canonicalCliName} extend github-projects`);
465
466
  }
466
467
 
467
468
  function pluginCacheRoot(version) {
@@ -527,9 +528,12 @@ function codexGoalRuntimeStatus() {
527
528
  }
528
529
 
529
530
  function runCodex(args) {
530
- const result = spawnSync("codex", args, {
531
+ const env = { ...process.env, CODEX_HOME: codexHome() };
532
+ const command = codexSpawnCommand(args, env);
533
+ const result = spawnSync(command.file, command.args, {
531
534
  encoding: "utf8",
532
- env: { ...process.env, CODEX_HOME: codexHome() },
535
+ env,
536
+ shell: command.shell || false,
533
537
  });
534
538
  return {
535
539
  ok: result.status === 0,
@@ -539,6 +543,38 @@ function runCodex(args) {
539
543
  };
540
544
  }
541
545
 
546
+ function codexSpawnCommand(args, env) {
547
+ if (process.platform !== "win32") return { file: "codex", args };
548
+
549
+ const command = resolveWindowsCommand("codex", env);
550
+ if (!command) return { file: "codex", args };
551
+ if (/\.(?:cmd|bat)$/i.test(command)) {
552
+ const commandLine = [quoteWindowsCommandArg(command), ...args.map(quoteWindowsCommandArg)].join(" ");
553
+ return {
554
+ file: commandLine,
555
+ args: [],
556
+ shell: true,
557
+ };
558
+ }
559
+ return { file: command, args };
560
+ }
561
+
562
+ function resolveWindowsCommand(name, env) {
563
+ const systemWhere = env.SystemRoot ? join(env.SystemRoot, "System32", "where.exe") : "";
564
+ const whereCommand = systemWhere && existsSync(systemWhere) ? systemWhere : "where.exe";
565
+ const where = spawnSync(whereCommand, [name], { encoding: "utf8", env });
566
+ if (where.status !== 0) return "";
567
+ const candidates = where.stdout
568
+ .split(/\r?\n/)
569
+ .map((line) => line.trim())
570
+ .filter(Boolean);
571
+ return candidates.find((candidate) => /\.(?:exe|cmd|bat)$/i.test(candidate)) || "";
572
+ }
573
+
574
+ function quoteWindowsCommandArg(value) {
575
+ return `"${String(value).replace(/(["^&|<>()%])/g, "^$1")}"`;
576
+ }
577
+
542
578
  function parseGoalFeature(output) {
543
579
  const line = output.split(/\r?\n/).find((candidate) => candidate.trim().startsWith("goals"));
544
580
  if (!line) return { enabled: false, stage: "" };
@@ -788,6 +824,11 @@ async function extendInstall() {
788
824
  async function extendInstallAll(catalog) {
789
825
  const results = [];
790
826
  for (const extension of catalog.extensions) {
827
+ if (existsSync(extensionTarget(extension.id)) && !hasFlag("--force")) {
828
+ validateCatalogExtension(extension);
829
+ results.push({ extension, target: extensionTarget(extension.id), plan: installPlan(catalog, extension, extensionTarget(extension.id)), skipped: true });
830
+ continue;
831
+ }
791
832
  results.push(await installCatalogExtension(catalog, extension));
792
833
  }
793
834
 
@@ -815,11 +856,13 @@ async function extendInstallAll(catalog) {
815
856
  printJson({
816
857
  installed: true,
817
858
  count: results.length,
818
- extensions: results.map(({ extension, target }) => ({ id: extension.id, target })),
859
+ extensions: results.map(({ extension, target, skipped }) => ({ id: extension.id, target, skipped: Boolean(skipped) })),
819
860
  });
820
861
  } else {
821
- console.log(`Installed ${results.length} extensions`);
822
- for (const { extension, target } of results) console.log(` ${extension.id} -> ${target}`);
862
+ const installedCount = results.filter((result) => !result.skipped).length;
863
+ const skippedCount = results.length - installedCount;
864
+ console.log(`Installed ${installedCount} extensions${skippedCount ? `, skipped ${skippedCount} already installed` : ""}`);
865
+ for (const { extension, target, skipped } of results) console.log(` ${extension.id} -> ${target}${skipped ? " (already installed)" : ""}`);
823
866
  }
824
867
  }
825
868
 
@@ -1067,6 +1110,7 @@ function preserveInstalledExtensions(targets) {
1067
1110
  if (!existsSync(source)) continue;
1068
1111
  mkdirSync(tempPath, { recursive: true });
1069
1112
  for (const entry of readdirSync(source, { withFileTypes: true })) {
1113
+ if (bundledCoreExtensionIds.has(entry.name)) continue;
1070
1114
  const from = join(source, entry.name);
1071
1115
  const to = join(tempPath, entry.name);
1072
1116
  cpSync(from, to, { recursive: true, force: true });
@@ -1080,9 +1124,11 @@ function preserveInstalledExtensions(targets) {
1080
1124
 
1081
1125
  function restoreInstalledExtensions(target, tempPath) {
1082
1126
  if (!tempPath) return;
1083
- rmSync(join(target, "extend"), { recursive: true, force: true });
1084
- mkdirSync(target, { recursive: true });
1085
- cpSync(tempPath, join(target, "extend"), { recursive: true });
1127
+ const destinationRoot = join(target, "extend");
1128
+ mkdirSync(destinationRoot, { recursive: true });
1129
+ for (const entry of readdirSync(tempPath, { withFileTypes: true })) {
1130
+ cpSync(join(tempPath, entry.name), join(destinationRoot, entry.name), { recursive: true, force: true });
1131
+ }
1086
1132
  }
1087
1133
 
1088
1134
  function cleanupPreservedExtensions(paths) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "goalbuddy",
3
- "version": "0.2.21",
4
- "description": "Turn open-ended Codex goals into a GoalBuddy Scout/Judge/Worker board with receipts, verification, and optional extensions.",
3
+ "version": "0.2.22",
4
+ "description": "Turn open-ended Codex goals into GoalBuddy Scout/Judge/Worker boards with visual board surfaces, receipts, and verification.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "goalbuddy": "internal/cli/goal-maker.mjs",
@@ -18,6 +18,7 @@
18
18
  "goalbuddy/SKILL.md",
19
19
  "goalbuddy/agents",
20
20
  "goalbuddy/scripts",
21
+ "goalbuddy/extend",
21
22
  "goalbuddy/templates"
22
23
  ],
23
24
  "scripts": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "goalbuddy",
3
- "version": "0.2.21",
4
- "description": "Turn broad Codex work into verified GoalBuddy boards with Scout, Judge, Worker, receipts, and optional extensions.",
3
+ "version": "0.2.22",
4
+ "description": "Turn broad Codex work into verified GoalBuddy boards with Scout, Judge, Worker, visual boards, and receipts.",
5
5
  "author": {
6
6
  "name": "tolibear",
7
7
  "email": "support@tolibear.com",
@@ -24,7 +24,7 @@
24
24
  "interface": {
25
25
  "displayName": "GoalBuddy",
26
26
  "shortDescription": "Turn broad Codex work into verified Scout/Judge/Worker boards",
27
- "longDescription": "GoalBuddy packages a structured Codex goal workflow for broad, long-running, or ambiguous engineering work. It creates durable goal charters, task boards, receipts, verification gates, extension handoffs, and compatibility guidance for teams moving from goal-maker.",
27
+ "longDescription": "GoalBuddy packages a structured Codex goal workflow for broad, long-running, or ambiguous engineering work. It creates durable goal charters, task boards, visual board surfaces, receipts, verification gates, extension handoffs, and compatibility guidance for teams moving from goal-maker.",
28
28
  "developerName": "tolibear",
29
29
  "category": "Coding",
30
30
  "capabilities": [