goalbuddy 0.3.1 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +58 -180
  2. package/RELEASE-0.3.5.md +324 -0
  3. package/goalbuddy/SKILL.md +8 -2
  4. package/goalbuddy/agents/goal_judge.toml +29 -17
  5. package/goalbuddy/agents/goal_scout.toml +34 -14
  6. package/goalbuddy/agents/goal_worker.toml +32 -15
  7. package/goalbuddy/extend/local-goal-board/README.md +8 -4
  8. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  9. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  10. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  11. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  12. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  13. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  14. package/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  15. package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +940 -24
  16. package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  17. package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +420 -4
  18. package/goalbuddy/scripts/check-goal-state.mjs +116 -6
  19. package/goalbuddy/scripts/parallel-plan.mjs +191 -0
  20. package/goalbuddy/scripts/render-task-prompt.mjs +248 -0
  21. package/goalbuddy/templates/agents.md +2 -2
  22. package/goalbuddy/templates/state.yaml +8 -0
  23. package/internal/assets/goalbuddy-v0.3.0-release.png +0 -0
  24. package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
  25. package/internal/cli/goal-maker.mjs +70 -2
  26. package/package.json +3 -2
  27. package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
  28. package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
  29. package/plugins/goalbuddy/README.md +5 -3
  30. package/plugins/goalbuddy/agents/goal-judge.md +31 -16
  31. package/plugins/goalbuddy/agents/goal-scout.md +38 -13
  32. package/plugins/goalbuddy/agents/goal-worker.md +35 -14
  33. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +8 -2
  34. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +29 -17
  35. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
  36. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +32 -15
  37. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
  38. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  39. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  40. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  41. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  42. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  43. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  44. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  45. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +940 -24
  46. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  47. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +420 -4
  48. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +116 -6
  49. package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
  50. package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +248 -0
  51. package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +2 -2
  52. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +8 -0
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { createServer } from "node:http";
3
- import { existsSync, readFileSync, realpathSync, watch } from "node:fs";
4
- import { join, resolve } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, watch, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, join, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { createBoardPayload, writeBoardApp } from "./lib/goal-board.mjs";
7
8
 
@@ -13,6 +14,26 @@ const textTypes = {
13
14
  ".png": "image/png",
14
15
  };
15
16
 
17
+ const SETTINGS_VERSION = 1;
18
+ const SETTINGS_DEFAULTS = {
19
+ theme: "system",
20
+ density: "comfortable",
21
+ completedVisibility: "show",
22
+ boardOpenBehavior: "last",
23
+ motion: "system",
24
+ lastBoardPath: "",
25
+ };
26
+ const SETTINGS_OPTIONS = {
27
+ theme: new Set(["system", "light", "dark"]),
28
+ density: new Set(["comfortable", "compact"]),
29
+ completedVisibility: new Set(["show", "collapse"]),
30
+ boardOpenBehavior: new Set(["last", "newest"]),
31
+ motion: new Set(["system", "reduce", "allow"]),
32
+ };
33
+ const DEFAULT_BIND_HOST = "127.0.0.1";
34
+ const DEFAULT_PUBLIC_HOST = "goalbuddy.localhost";
35
+ const DEFAULT_PORT = 41737;
36
+
16
37
  if (isDirectRun()) {
17
38
  main().catch((error) => {
18
39
  console.error(error.message);
@@ -45,19 +66,35 @@ export async function main() {
45
66
  return { goalDir, appDir, board };
46
67
  }
47
68
 
48
- const server = await startBoardServer({
49
- goalDir,
50
- appDir,
51
- host: options.host,
52
- port: options.port,
53
- });
69
+ let server = null;
70
+ try {
71
+ server = await startBoardServer({
72
+ goalDir,
73
+ appDir,
74
+ host: options.host,
75
+ publicHost: options.publicHost,
76
+ port: options.port,
77
+ });
78
+ } catch (error) {
79
+ if (error.code !== "EADDRINUSE") throw error;
80
+ server = await registerWithBoardHub({
81
+ goalDir,
82
+ host: options.host,
83
+ port: options.port,
84
+ });
85
+ }
54
86
 
55
87
  if (options.json) {
56
- console.log(JSON.stringify({ goalDir, appDir, url: server.url }, null, 2));
88
+ console.log(JSON.stringify({ goalDir, appDir: server.appDir || appDir, url: server.url, hubUrl: server.hubUrl, apiUrl: server.apiUrl, registered: Boolean(server.registered) }, null, 2));
57
89
  } else {
58
90
  console.log(`GoalBuddy local board: ${server.url}`);
59
- console.log(`Watching: ${join(goalDir, "state.yaml")}`);
60
- console.log("Press Ctrl-C to stop.");
91
+ console.log(`GoalBuddy local hub: ${server.hubUrl}`);
92
+ if (server.registered) {
93
+ console.log("Registered with the existing GoalBuddy local board hub.");
94
+ } else {
95
+ console.log(`Watching: ${join(goalDir, "state.yaml")}`);
96
+ console.log("Press Ctrl-C to stop.");
97
+ }
61
98
  }
62
99
 
63
100
  return server;
@@ -66,8 +103,9 @@ export async function main() {
66
103
  export function parseArgs(args) {
67
104
  const options = {
68
105
  goal: "",
69
- host: "127.0.0.1",
70
- port: 41737,
106
+ host: DEFAULT_BIND_HOST,
107
+ publicHost: DEFAULT_PUBLIC_HOST,
108
+ port: DEFAULT_PORT,
71
109
  once: false,
72
110
  json: false,
73
111
  };
@@ -80,8 +118,10 @@ export function parseArgs(args) {
80
118
  options.goal = arg.slice("--goal=".length);
81
119
  } else if (arg === "--host") {
82
120
  options.host = args[++index] || options.host;
121
+ options.publicHost = options.host;
83
122
  } else if (arg.startsWith("--host=")) {
84
123
  options.host = arg.slice("--host=".length);
124
+ options.publicHost = options.host;
85
125
  } else if (arg === "--port") {
86
126
  options.port = Number(args[++index] || options.port);
87
127
  } else if (arg.startsWith("--port=")) {
@@ -105,38 +145,115 @@ export function parseArgs(args) {
105
145
  return options;
106
146
  }
107
147
 
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
- };
148
+ export async function startBoardServer(options = {}) {
149
+ const {
150
+ goalDir,
151
+ appDir = "",
152
+ host = DEFAULT_BIND_HOST,
153
+ publicHost = Object.hasOwn(options, "host") ? host : DEFAULT_PUBLIC_HOST,
154
+ port = DEFAULT_PORT,
155
+ } = options;
156
+ const boards = new Map();
157
+ let baseUrl = "";
158
+ let initialBoard = null;
117
159
 
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;
160
+ const addBoard = (candidateGoalDir, candidateAppDir = "") => {
161
+ const root = resolve(candidateGoalDir);
162
+ if (!existsSync(join(root, "state.yaml"))) {
163
+ throw new Error(`Missing state.yaml in ${root}`);
124
164
  }
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;
165
+
166
+ const existing = [...boards.values()].find((board) => board.root === root);
167
+ if (existing) {
168
+ existing.appDir = candidateAppDir || writeBoardApp(root);
169
+ existing.lastPayload = safePayload(root);
170
+ return boardSummary(existing, baseUrl);
137
171
  }
138
172
 
139
- serveStatic(appDir, url.pathname, response);
173
+ const payload = safePayload(root);
174
+ const board = {
175
+ root,
176
+ appDir: candidateAppDir || writeBoardApp(root),
177
+ boardPath: nextBoardPath(root, payload, boards),
178
+ clients: new Set(),
179
+ lastPayload: payload,
180
+ watcher: null,
181
+ startedAt: new Date().toISOString(),
182
+ };
183
+ board.watcher = watchGoal(root, () => {
184
+ board.lastPayload = safePayload(root);
185
+ for (const client of board.clients) sendEvent(client, board.lastPayload);
186
+ board.watcher.refresh();
187
+ });
188
+ boards.set(board.boardPath, board);
189
+ return boardSummary(board, baseUrl);
190
+ };
191
+
192
+ const server = createServer(async (request, response) => {
193
+ try {
194
+ const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
195
+ if (request.method === "POST" && url.pathname === "/api/boards") {
196
+ const payload = await readJsonRequest(request);
197
+ sendJson(response, addBoard(payload.goalDir || ""));
198
+ return;
199
+ }
200
+ if (url.pathname === "/" || url.pathname === "/boards") {
201
+ redirectToFirstBoard(response, boards, baseUrl, readBoardSettings());
202
+ return;
203
+ }
204
+ if (url.pathname === "/api/boards") {
205
+ sendJson(response, { boards: [...boards.values()].map((board) => boardSummary(board, baseUrl)) });
206
+ return;
207
+ }
208
+ if (url.pathname === "/api/settings") {
209
+ if (request.method === "GET") {
210
+ sendJson(response, { version: SETTINGS_VERSION, settings: readBoardSettings() });
211
+ return;
212
+ }
213
+ if (request.method === "PUT") {
214
+ const payload = await readJsonRequest(request);
215
+ sendJson(response, { version: SETTINGS_VERSION, settings: writeBoardSettings(payload.settings || payload) });
216
+ return;
217
+ }
218
+ response.writeHead(405, { "Allow": "GET, PUT" });
219
+ response.end("Method not allowed");
220
+ return;
221
+ }
222
+
223
+ const slashUrl = boardTrailingSlashUrl(url.pathname, boards, baseUrl);
224
+ if (slashUrl) {
225
+ redirect(response, slashUrl);
226
+ return;
227
+ }
228
+
229
+ const route = routeBoardRequest(url.pathname, boards, initialBoard);
230
+ if (!route.board) {
231
+ response.writeHead(404);
232
+ response.end("Not found");
233
+ return;
234
+ }
235
+ if (route.pathname === "/api/board") {
236
+ sendJson(response, safePayload(route.board.root));
237
+ return;
238
+ }
239
+ if (route.pathname === "/events") {
240
+ response.writeHead(200, {
241
+ "Content-Type": "text/event-stream; charset=utf-8",
242
+ "Cache-Control": "no-cache, no-transform",
243
+ "Connection": "keep-alive",
244
+ "X-Accel-Buffering": "no",
245
+ });
246
+ response.write("retry: 1000\n\n");
247
+ route.board.clients.add(response);
248
+ sendEvent(response, route.board.lastPayload);
249
+ request.on("close", () => route.board.clients.delete(response));
250
+ return;
251
+ }
252
+
253
+ serveStatic(route.board.appDir, route.pathname, response);
254
+ } catch (error) {
255
+ sendError(response, error);
256
+ }
140
257
  });
141
258
 
142
259
  await new Promise((resolveListen, rejectListen) => {
@@ -149,32 +266,184 @@ export async function startBoardServer({ goalDir, appDir = writeBoardApp(goalDir
149
266
 
150
267
  const address = server.address();
151
268
  const actualPort = typeof address === "object" && address ? address.port : port;
269
+ baseUrl = `http://${publicHost || host}:${actualPort}`;
270
+ const initialSummary = addBoard(goalDir, appDir);
271
+ initialBoard = boards.get(new URL(initialSummary.url).pathname);
152
272
 
153
273
  return {
154
- url: `http://${host}:${actualPort}/`,
274
+ ...initialSummary,
155
275
  close: () => new Promise((resolveClose, rejectClose) => {
156
- watcher.close();
157
- for (const client of clients) client.end();
276
+ for (const board of boards.values()) {
277
+ board.watcher.close();
278
+ for (const client of board.clients) client.end();
279
+ }
158
280
  server.close((error) => error ? rejectClose(error) : resolveClose());
159
281
  }),
160
282
  };
161
283
  }
162
284
 
285
+ async function registerWithBoardHub({ goalDir, host, port }) {
286
+ const response = await fetch(`http://${host}:${port}/api/boards`, {
287
+ method: "POST",
288
+ headers: { "Content-Type": "application/json" },
289
+ body: JSON.stringify({ goalDir }),
290
+ });
291
+ if (!response.ok) {
292
+ const message = await response.text();
293
+ if (response.status === 404) {
294
+ throw new Error(`Port ${port} is already in use, but it is not the GoalBuddy multi-board hub. Stop the existing local board process on ${host}:${port}, then retry.`);
295
+ }
296
+ throw new Error(`GoalBuddy local board hub rejected ${goalDir}: ${message}`);
297
+ }
298
+ return { ...(await response.json()), registered: true };
299
+ }
300
+
301
+ function redirectToFirstBoard(response, boards, baseUrl, settings = {}) {
302
+ const board = preferredBoard(boards, settings);
303
+ if (!board) {
304
+ response.writeHead(404, {
305
+ "Content-Type": "text/plain; charset=utf-8",
306
+ "Cache-Control": "no-store",
307
+ });
308
+ response.end("No GoalBuddy boards are registered.");
309
+ return;
310
+ }
311
+
312
+ redirect(response, `${baseUrl}${board.boardPath}`);
313
+ }
314
+
315
+ function preferredBoard(boards, settings = {}) {
316
+ const allBoards = [...boards.values()];
317
+ if (allBoards.length === 0) return null;
318
+ const normalized = normalizeSettings(settings);
319
+ if (normalized.boardOpenBehavior === "last" && normalized.lastBoardPath) {
320
+ const remembered = allBoards.find((board) => board.boardPath === normalized.lastBoardPath);
321
+ if (remembered) return remembered;
322
+ }
323
+ if (normalized.boardOpenBehavior === "newest") {
324
+ return allBoards
325
+ .slice()
326
+ .sort((left, right) => right.startedAt.localeCompare(left.startedAt))[0];
327
+ }
328
+ return allBoards[0];
329
+ }
330
+
331
+ function boardTrailingSlashUrl(pathname, boards, baseUrl) {
332
+ for (const board of boards.values()) {
333
+ const prefix = board.boardPath.endsWith("/") ? board.boardPath.slice(0, -1) : board.boardPath;
334
+ if (pathname === prefix) return `${baseUrl}${board.boardPath}`;
335
+ }
336
+ return "";
337
+ }
338
+
339
+ function redirect(response, location) {
340
+ response.writeHead(302, {
341
+ "Location": location,
342
+ "Cache-Control": "no-store",
343
+ });
344
+ response.end();
345
+ }
346
+
347
+ function boardPathFor(goalDir, payload) {
348
+ const slug = slugifyPathSegment(payload?.goal?.slug || basename(goalDir));
349
+ return `/${slug || "goal"}/`;
350
+ }
351
+
352
+ function nextBoardPath(goalDir, payload, boards) {
353
+ const existing = [...boards.values()].find((board) => board.root === goalDir);
354
+ if (existing) return existing.boardPath;
355
+
356
+ const basePath = boardPathFor(goalDir, payload);
357
+ if (!boards.has(basePath)) return basePath;
358
+
359
+ const prefix = basePath.slice(0, -1);
360
+ for (let index = 2; index < 1000; index += 1) {
361
+ const candidate = `${prefix}-${index}/`;
362
+ if (!boards.has(candidate)) return candidate;
363
+ }
364
+ throw new Error(`Could not allocate a board path for ${goalDir}`);
365
+ }
366
+
367
+ function boardSummary(board, baseUrl) {
368
+ const slug = slugifyPathSegment(board.lastPayload.goal?.slug || basename(board.root)) || "goal";
369
+ return {
370
+ goalDir: board.root,
371
+ appDir: board.appDir,
372
+ title: board.lastPayload.goal?.title || basename(board.root),
373
+ slug,
374
+ url: `${baseUrl}${board.boardPath}`,
375
+ hubUrl: `${baseUrl}/`,
376
+ indexUrl: `${baseUrl}/`,
377
+ apiUrl: `${baseUrl}/api/boards`,
378
+ startedAt: board.startedAt,
379
+ };
380
+ }
381
+
382
+ function slugifyPathSegment(value) {
383
+ return String(value || "")
384
+ .trim()
385
+ .toLowerCase()
386
+ .replace(/[^a-z0-9]+/g, "-")
387
+ .replace(/^-+|-+$/g, "");
388
+ }
389
+
390
+ function routeBoardRequest(pathname, boards, initialBoard) {
391
+ if ((pathname === "/api/board" || pathname === "/events") && initialBoard) {
392
+ return { board: initialBoard, pathname };
393
+ }
394
+
395
+ const matches = [...boards.values()]
396
+ .map((board) => ({ board, pathname: stripBoardPathPrefix(pathname, board.boardPath) }))
397
+ .filter((route) => route.pathname !== pathname || pathname === route.board.boardPath.slice(0, -1))
398
+ .sort((left, right) => right.board.boardPath.length - left.board.boardPath.length);
399
+
400
+ return matches[0] || { board: null, pathname };
401
+ }
402
+
403
+ function stripBoardPathPrefix(pathname, boardPath) {
404
+ const prefix = boardPath.endsWith("/") ? boardPath.slice(0, -1) : boardPath;
405
+ if (pathname === prefix) return "/";
406
+ if (pathname.startsWith(`${prefix}/`)) {
407
+ return pathname.slice(prefix.length) || "/";
408
+ }
409
+ return pathname;
410
+ }
411
+
412
+ async function readJsonRequest(request) {
413
+ let body = "";
414
+ for await (const chunk of request) {
415
+ body += chunk;
416
+ if (body.length > 1_000_000) throw new Error("Request body is too large.");
417
+ }
418
+ return JSON.parse(body || "{}");
419
+ }
420
+
163
421
  function watchGoal(goalDir, onChange) {
164
422
  const watchers = [];
165
423
  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
- }
424
+ let watchedDirs = new Set();
425
+
426
+ const rebuild = () => {
427
+ for (const watcher of watchers.splice(0)) watcher.close();
428
+ watchedDirs = goalDirsForPayload(goalDir);
429
+ for (const dir of watchedDirs) {
430
+ watchers.push(watch(dir, { persistent: true }, (_event, filename) => {
431
+ if (!filename || filename === "state.yaml" || filename === "notes") schedule();
432
+ }));
433
+ const notesDir = join(dir, "notes");
434
+ if (existsSync(notesDir)) watchers.push(watch(notesDir, { persistent: true }, schedule));
435
+ }
436
+ };
437
+
438
+ rebuild();
174
439
  return {
175
440
  close() {
176
441
  for (const watcher of watchers) watcher.close();
177
442
  },
443
+ refresh() {
444
+ const next = goalDirsForPayload(goalDir);
445
+ if (!sameSet(watchedDirs, next)) rebuild();
446
+ },
178
447
  };
179
448
  }
180
449
 
@@ -198,6 +467,31 @@ function safePayload(goalDir) {
198
467
  }
199
468
  }
200
469
 
470
+ function goalDirsForPayload(goalDir) {
471
+ const dirs = new Set([resolve(goalDir)]);
472
+ try {
473
+ collectPayloadGoalDirs(createBoardPayload(goalDir), dirs);
474
+ } catch {
475
+ // Keep watching the parent when the board is temporarily invalid.
476
+ }
477
+ return dirs;
478
+ }
479
+
480
+ function collectPayloadGoalDirs(payload, dirs) {
481
+ if (payload?.source?.goalDir) dirs.add(resolve(payload.source.goalDir));
482
+ for (const task of payload?.tasks || []) {
483
+ if (task.subgoal?.board) collectPayloadGoalDirs(task.subgoal.board, dirs);
484
+ }
485
+ }
486
+
487
+ function sameSet(left, right) {
488
+ if (left.size !== right.size) return false;
489
+ for (const value of left) {
490
+ if (!right.has(value)) return false;
491
+ }
492
+ return true;
493
+ }
494
+
201
495
  function sendJson(response, payload) {
202
496
  response.writeHead(200, {
203
497
  "Content-Type": "application/json; charset=utf-8",
@@ -206,6 +500,14 @@ function sendJson(response, payload) {
206
500
  response.end(JSON.stringify(payload, null, 2));
207
501
  }
208
502
 
503
+ function sendError(response, error) {
504
+ response.writeHead(400, {
505
+ "Content-Type": "text/plain; charset=utf-8",
506
+ "Cache-Control": "no-store",
507
+ });
508
+ response.end(error.message || "Request failed");
509
+ }
510
+
209
511
  function sendEvent(response, payload) {
210
512
  response.write(`event: board\ndata: ${JSON.stringify(payload)}\n\n`);
211
513
  }
@@ -241,6 +543,39 @@ function debounce(fn, delay) {
241
543
  };
242
544
  }
243
545
 
546
+ function readBoardSettings() {
547
+ try {
548
+ if (!existsSync(settingsPath())) return { ...SETTINGS_DEFAULTS };
549
+ return normalizeSettings(JSON.parse(readFileSync(settingsPath(), "utf8")));
550
+ } catch {
551
+ return { ...SETTINGS_DEFAULTS };
552
+ }
553
+ }
554
+
555
+ function writeBoardSettings(settings) {
556
+ const normalized = normalizeSettings(settings);
557
+ const path = settingsPath();
558
+ mkdirSync(dirname(path), { recursive: true });
559
+ writeFileSync(path, `${JSON.stringify(normalized, null, 2)}\n`);
560
+ return normalized;
561
+ }
562
+
563
+ function normalizeSettings(settings) {
564
+ const normalized = { ...SETTINGS_DEFAULTS };
565
+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return normalized;
566
+ for (const [key, allowed] of Object.entries(SETTINGS_OPTIONS)) {
567
+ if (allowed.has(settings[key])) normalized[key] = settings[key];
568
+ }
569
+ if (typeof settings.lastBoardPath === "string" && /^\/[a-z0-9][a-z0-9-]*\/$/.test(settings.lastBoardPath)) {
570
+ normalized.lastBoardPath = settings.lastBoardPath;
571
+ }
572
+ return normalized;
573
+ }
574
+
575
+ function settingsPath() {
576
+ return process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH || join(homedir(), ".goalbuddy", "local-board-settings.json");
577
+ }
578
+
244
579
  function usage() {
245
580
  console.log(`GoalBuddy Local Goal Board
246
581
 
@@ -250,8 +585,8 @@ Usage:
250
585
 
251
586
  Options:
252
587
  --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.
588
+ --host <host> Local server bind host. Default: 127.0.0.1, advertised as goalbuddy.localhost.
589
+ --port <port> Local server port. Default: 41737 shared board hub.
255
590
  --once Generate .goalbuddy-board and exit.
256
591
  --json Print structured output.
257
592
  `);