goalbuddy 0.3.2 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +55 -5
  2. package/RELEASE-0.3.5.md +324 -0
  3. package/goalbuddy/SKILL.md +40 -13
  4. package/goalbuddy/agents/README.md +1 -1
  5. package/goalbuddy/agents/goal_judge.toml +33 -17
  6. package/goalbuddy/agents/goal_scout.toml +34 -14
  7. package/goalbuddy/agents/goal_worker.toml +36 -16
  8. package/goalbuddy/extend/local-goal-board/README.md +8 -4
  9. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  10. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  11. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  12. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  13. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  14. package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  15. package/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  16. package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
  17. package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  18. package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
  19. package/goalbuddy/scripts/check-goal-state.mjs +192 -6
  20. package/goalbuddy/scripts/parallel-plan.mjs +191 -0
  21. package/goalbuddy/scripts/render-task-prompt.mjs +305 -0
  22. package/goalbuddy/templates/agents.md +5 -4
  23. package/goalbuddy/templates/goal.md +18 -4
  24. package/goalbuddy/templates/state.yaml +14 -1
  25. package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
  26. package/internal/cli/goal-maker.mjs +172 -9
  27. package/package.json +3 -2
  28. package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
  29. package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
  30. package/plugins/goalbuddy/README.md +5 -3
  31. package/plugins/goalbuddy/agents/goal-judge.md +35 -16
  32. package/plugins/goalbuddy/agents/goal-scout.md +38 -13
  33. package/plugins/goalbuddy/agents/goal-worker.md +37 -14
  34. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +40 -13
  35. package/plugins/goalbuddy/skills/goalbuddy/agents/README.md +1 -1
  36. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +33 -17
  37. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
  38. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +36 -16
  39. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
  40. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  41. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  42. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  43. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  44. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  45. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  46. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  47. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
  48. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  49. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
  50. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +192 -6
  51. package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
  52. package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +305 -0
  53. package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +5 -4
  54. package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +18 -4
  55. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +14 -1
@@ -23,6 +23,211 @@ test("normalizes a dense goal into local board columns", () => {
23
23
  assert.equal(scout.receipt.summary, "T001 completed during the progressive board motion demo.");
24
24
  });
25
25
 
26
+ test("loads depth-1 subgoal boards into parent task payloads", () => {
27
+ const payload = createBoardPayload(resolve("goalbuddy/extend/local-goal-board/examples/subgoal-parent"));
28
+ const parentTask = payload.tasks.find((task) => task.id === "T004");
29
+
30
+ assert.equal(parentTask.subgoal.status, "active");
31
+ assert.equal(parentTask.subgoal.path, "subgoals/T004-board-view/state.yaml");
32
+ assert.equal(parentTask.subgoal.depth, 1);
33
+ assert.equal(parentTask.subgoal.board.goal.title, "T004 Board View Subgoal");
34
+ assert.equal(parentTask.subgoal.board.goal.activeTask, "T002");
35
+ assert.equal(parentTask.subgoal.board.counts.total, 3);
36
+ assert.equal(parentTask.subgoal.board.tasks.find((task) => task.id === "T002").active, true);
37
+ assert.equal(parentTask.subgoal.board.tasks.find((task) => task.id === "T002").subgoal, null);
38
+ });
39
+
40
+ test("uses compact card titles while preserving full objectives", () => {
41
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-compact-titles-"));
42
+ try {
43
+ const goalDir = join(root, "compact-titles");
44
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
45
+ writeFileSync(join(goalDir, "state.yaml"), `version: 2
46
+ goal:
47
+ title: "Compact titles"
48
+ slug: "compact-titles"
49
+ kind: specific
50
+ tranche: "Title display."
51
+ status: active
52
+ active_task: T001
53
+ tasks:
54
+ - id: T001
55
+ type: worker
56
+ assignee: Worker
57
+ status: active
58
+ objective: "Implement a read-only fixture-backed /admin/enrichment-qa queue slice. Use only admin_seed_metrics.enrichment_qa plus existing contacts, companies, users, evidence_items, and facts. Do not create new APIs."
59
+ receipt: null
60
+ - id: T002
61
+ type: worker
62
+ assignee: Worker
63
+ status: blocked
64
+ objective: "Implement the read-only fixture-backed /contacts/con_aaron_keller route as the next first-milestone slice. Add a clickable path from the Coinbase chat answer matched contact row/name to Aaron Keller's profile."
65
+ receipt: null
66
+ - id: T003
67
+ title: "Human-friendly release title"
68
+ type: pm
69
+ assignee: PM
70
+ status: queued
71
+ objective: "This objective can stay much more detailed because it belongs in the modal, not on the card face."
72
+ receipt: null
73
+ `);
74
+
75
+ const payload = createBoardPayload(goalDir);
76
+ assert.equal(payload.tasks.find((task) => task.id === "T001").title, "Implement /admin/enrichment-qa queue slice");
77
+ assert.equal(payload.tasks.find((task) => task.id === "T001").objective.includes("admin_seed_metrics.enrichment_qa"), true);
78
+ assert.equal(payload.tasks.find((task) => task.id === "T002").title, "Implement /contacts/con_aaron_keller route");
79
+ assert.equal(payload.tasks.find((task) => task.id === "T003").title, "Human-friendly release title");
80
+ } finally {
81
+ rmSync(root, { recursive: true, force: true });
82
+ }
83
+ });
84
+
85
+ test("keeps board rendering when deep receipt YAML is malformed", () => {
86
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-board-subset-parser-"));
87
+ try {
88
+ const goalDir = join(root, "subset-parser");
89
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
90
+ writeFileSync(join(goalDir, "state.yaml"), `version: 2
91
+ goal:
92
+ title: "Subset parser"
93
+ slug: "subset-parser"
94
+ kind: specific
95
+ tranche: "Recover shallow board fields."
96
+ status: active
97
+ active_task: T003
98
+ checks:
99
+ last_verification:
100
+ status: pass
101
+ raw:
102
+ malformed nested checker output
103
+ tasks:
104
+ - id: T001
105
+ type: worker
106
+ assignee: Worker
107
+ status: completed
108
+ objective: "Ship a completed worker slice."
109
+ receipt:
110
+ result: done
111
+ summary: "Worker finished."
112
+ raw:
113
+ malformed nested receipt output
114
+ - id: T002
115
+ type: judge
116
+ assignee: Judge
117
+ status: complete
118
+ objective: "Approve the result."
119
+ receipt: null
120
+ - id: T003
121
+ type: scout
122
+ assignee: Scout
123
+ status: active
124
+ objective: "Inspect what is left."
125
+ receipt: null
126
+ `);
127
+
128
+ const payload = createBoardPayload(goalDir);
129
+ assert.equal(payload.goal.title, "Subset parser");
130
+ assert.equal(payload.goal.activeTask, "T003");
131
+ assert.equal(payload.counts.completed, 2);
132
+ assert.equal(payload.counts.inProgress, 1);
133
+ assert.equal(payload.tasks.find((task) => task.id === "T001").status, "done");
134
+ assert.equal(payload.tasks.find((task) => task.id === "T002").status, "done");
135
+ assert.equal(payload.tasks.find((task) => task.id === "T001").receipt.summary, "Worker finished.");
136
+ } finally {
137
+ rmSync(root, { recursive: true, force: true });
138
+ }
139
+ });
140
+
141
+ test("fails loudly when a linked subgoal state file is missing", () => {
142
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-missing-subgoal-"));
143
+ try {
144
+ const goalDir = join(root, "parent");
145
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
146
+ writeFileSync(join(goalDir, "state.yaml"), `version: 2
147
+ goal:
148
+ title: "Missing child"
149
+ slug: "missing-child"
150
+ kind: specific
151
+ tranche: "Missing child."
152
+ status: active
153
+ active_task: T001
154
+ tasks:
155
+ - id: T001
156
+ type: worker
157
+ assignee: Worker
158
+ status: active
159
+ objective: "Render child."
160
+ subgoal:
161
+ status: active
162
+ path: subgoals/missing/state.yaml
163
+ owner: Worker
164
+ depth: 1
165
+ receipt: null
166
+ `);
167
+
168
+ assert.throws(
169
+ () => createBoardPayload(goalDir),
170
+ /Missing sub-goal state for T001: subgoals\/missing\/state\.yaml/,
171
+ );
172
+ } finally {
173
+ rmSync(root, { recursive: true, force: true });
174
+ }
175
+ });
176
+
177
+ test("refuses to render subgoal boards outside the parent goal root", () => {
178
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-outside-subgoal-"));
179
+ try {
180
+ const goalDir = join(root, "parent");
181
+ const outsideDir = join(root, "outside");
182
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
183
+ mkdirSync(outsideDir, { recursive: true });
184
+ writeFileSync(join(outsideDir, "state.yaml"), `version: 2
185
+ goal:
186
+ title: "Outside child"
187
+ slug: "outside-child"
188
+ kind: specific
189
+ tranche: "Outside."
190
+ status: active
191
+ active_task: T001
192
+ tasks:
193
+ - id: T001
194
+ type: scout
195
+ assignee: Scout
196
+ status: active
197
+ objective: "Read."
198
+ receipt: null
199
+ `);
200
+ writeFileSync(join(goalDir, "state.yaml"), `version: 2
201
+ goal:
202
+ title: "Outside child parent"
203
+ slug: "outside-child-parent"
204
+ kind: specific
205
+ tranche: "Reject outside child."
206
+ status: active
207
+ active_task: T001
208
+ tasks:
209
+ - id: T001
210
+ type: worker
211
+ assignee: Worker
212
+ status: active
213
+ objective: "Render child."
214
+ subgoal:
215
+ status: active
216
+ path: ../outside/state.yaml
217
+ owner: Worker
218
+ depth: 1
219
+ receipt: null
220
+ `);
221
+
222
+ assert.throws(
223
+ () => createBoardPayload(goalDir),
224
+ /Invalid sub-goal path for T001: \.\.\/outside\/state\.yaml must stay inside the goal root/,
225
+ );
226
+ } finally {
227
+ rmSync(root, { recursive: true, force: true });
228
+ }
229
+ });
230
+
26
231
  test("writes a minimal GoalBuddy web app into the goal directory", () => {
27
232
  const appDir = writeBoardApp(resolve("extend/local-goal-board/examples/sample-goal"));
28
233
  const html = readFileSync(join(appDir, "index.html"), "utf8");
@@ -31,28 +236,162 @@ test("writes a minimal GoalBuddy web app into the goal directory", () => {
31
236
  const logo = readFileSync(join(appDir, "goalbuddy-mark.png"));
32
237
 
33
238
  assert.match(html, /goalbuddy-mark\.png/);
239
+ assert.match(html, /class="topbar-primary"/);
240
+ assert.match(html, /class="board-switcher is-empty"/);
241
+ assert.match(html, /class="github-stars"/);
242
+ assert.match(html, /id="settings-button"/);
243
+ assert.match(html, /id="settings-popover"/);
34
244
  assert.match(css, /--canvas: #f7f6f3/);
35
- assert.doesNotMatch(css, /gradient/i);
245
+ assert.match(css, /\.topbar-primary/);
246
+ assert.match(css, /\.board-switcher\.is-empty \{\n display: none;/);
247
+ assert.match(css, /active-card-orbit/);
248
+ assert.match(css, /:root\[data-motion="reduce"\] \.task-card\.is-active::before/);
249
+ assert.match(css, /:root\[data-theme="dark"\]/);
250
+ assert.match(css, /:root\[data-density="compact"\] \.task-card/);
251
+ assert.match(css, /:root\[data-completed-visibility="collapse"\]/);
252
+ assert.match(css, /\.subgoal-board/);
253
+ assert.match(css, /\.board-error/);
36
254
  assert.match(js, /new EventSource\("\.\/events"\)/);
255
+ assert.match(js, /fetch\("\.\.\/api\/boards"/);
256
+ assert.match(js, /fetch\("\.\.\/api\/settings"/);
257
+ assert.match(js, /fetch\("https:\/\/api\.github\.com\/repos\/tolibear\/goalbuddy"/);
258
+ assert.match(js, /goalbuddy\.localBoardSettings\.v1/);
259
+ assert.match(js, /document\.documentElement\.dataset\.theme/);
260
+ assert.match(js, /rememberCurrentBoard/);
261
+ assert.match(js, /settingsButtonEl\.setAttribute\("aria-label"/);
37
262
  assert.match(js, /animateCardMoves/);
38
263
  assert.match(js, /card\.animate/);
39
264
  assert.match(js, /highlightMovingCards/);
265
+ assert.match(js, /renderSubgoal/);
266
+ assert.match(js, /renderBoardError/);
267
+ assert.match(js, /boardOptionLabel/);
40
268
  assert.match(js, /duration: changedColumn \? 980 : 520/);
41
269
  assert.equal(logo.subarray(1, 4).toString("ascii"), "PNG");
42
270
  });
43
271
 
272
+ test("serves global local board settings with defensive normalization", async () => {
273
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-settings-"));
274
+ const goalDir = join(root, "settings-goal");
275
+ const settingsPath = join(root, "settings.json");
276
+ const previousSettingsPath = process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH;
277
+ try {
278
+ process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH = settingsPath;
279
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
280
+ writeFileSync(join(goalDir, "state.yaml"), stateYaml("active", { title: "Settings Goal", slug: "settings-goal" }));
281
+
282
+ const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
283
+ try {
284
+ const initialResponse = await fetch(`${server.hubUrl}api/settings`);
285
+ assert.equal(initialResponse.status, 200);
286
+ assert.deepEqual((await initialResponse.json()).settings, {
287
+ theme: "system",
288
+ density: "comfortable",
289
+ completedVisibility: "show",
290
+ boardOpenBehavior: "last",
291
+ motion: "system",
292
+ lastBoardPath: "",
293
+ });
294
+
295
+ const updateResponse = await fetch(`${server.hubUrl}api/settings`, {
296
+ method: "PUT",
297
+ headers: { "Content-Type": "application/json" },
298
+ body: JSON.stringify({
299
+ settings: {
300
+ theme: "dark",
301
+ density: "compact",
302
+ completedVisibility: "collapse",
303
+ boardOpenBehavior: "newest",
304
+ motion: "reduce",
305
+ lastBoardPath: "/settings-goal/",
306
+ unexpected: "ignored",
307
+ },
308
+ }),
309
+ });
310
+ assert.equal(updateResponse.status, 200);
311
+ assert.deepEqual((await updateResponse.json()).settings, {
312
+ theme: "dark",
313
+ density: "compact",
314
+ completedVisibility: "collapse",
315
+ boardOpenBehavior: "newest",
316
+ motion: "reduce",
317
+ lastBoardPath: "/settings-goal/",
318
+ });
319
+ assert.match(readFileSync(settingsPath, "utf8"), /"theme": "dark"/);
320
+
321
+ const invalidResponse = await fetch(`${server.hubUrl}api/settings`, {
322
+ method: "PUT",
323
+ headers: { "Content-Type": "application/json" },
324
+ body: JSON.stringify({ settings: { theme: "neon", density: "tiny", motion: "allow" } }),
325
+ });
326
+ assert.equal(invalidResponse.status, 200);
327
+ assert.deepEqual((await invalidResponse.json()).settings, {
328
+ theme: "system",
329
+ density: "comfortable",
330
+ completedVisibility: "show",
331
+ boardOpenBehavior: "last",
332
+ motion: "allow",
333
+ lastBoardPath: "",
334
+ });
335
+ } finally {
336
+ await server.close();
337
+ }
338
+ } finally {
339
+ if (previousSettingsPath === undefined) {
340
+ delete process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH;
341
+ } else {
342
+ process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH = previousSettingsPath;
343
+ }
344
+ rmSync(root, { recursive: true, force: true });
345
+ }
346
+ });
347
+
44
348
  test("parses CLI options", () => {
349
+ assert.equal(parseArgs(["--goal", "docs/goals/demo"]).port, 41737);
350
+ assert.equal(parseArgs(["--goal", "docs/goals/demo"]).host, "127.0.0.1");
351
+ assert.equal(parseArgs(["--goal", "docs/goals/demo"]).publicHost, "goalbuddy.localhost");
45
352
  assert.deepEqual(parseArgs(["--goal", "docs/goals/demo", "--port", "0", "--once", "--json"]), {
46
353
  goal: "docs/goals/demo",
47
354
  host: "127.0.0.1",
355
+ publicHost: "goalbuddy.localhost",
48
356
  port: 0,
49
357
  once: true,
50
358
  json: true,
51
359
  });
360
+ assert.deepEqual(parseArgs(["--goal", "docs/goals/demo", "--host", "localhost"]), {
361
+ goal: "docs/goals/demo",
362
+ host: "localhost",
363
+ publicHost: "localhost",
364
+ port: 41737,
365
+ once: false,
366
+ json: false,
367
+ });
368
+ });
369
+
370
+ test("advertises goalbuddy.localhost while binding to loopback", async () => {
371
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-public-host-"));
372
+ const goalDir = join(root, "goal");
373
+ try {
374
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
375
+ writeFileSync(join(goalDir, "state.yaml"), stateYaml("active", { title: "Public Host Goal", slug: "public-host-goal" }));
376
+
377
+ const server = await startBoardServer({ goalDir, port: 0 });
378
+ try {
379
+ const url = new URL(server.url);
380
+ assert.equal(url.hostname, "goalbuddy.localhost");
381
+ assert.equal(url.pathname, "/public-host-goal/");
382
+
383
+ const loopbackResponse = await fetch(`http://127.0.0.1:${url.port}/api/boards`);
384
+ assert.equal(loopbackResponse.status, 200);
385
+ } finally {
386
+ await server.close();
387
+ }
388
+ } finally {
389
+ rmSync(root, { recursive: true, force: true });
390
+ }
52
391
  });
53
392
 
54
393
  test("runs when installed under a symlinked temp path", () => {
55
- const root = mkdtempSync("/tmp/goalbuddy-local-board-direct-");
394
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-direct-"));
56
395
  try {
57
396
  cpSync("extend/local-goal-board/scripts", join(root, "scripts"), { recursive: true });
58
397
  cpSync("extend/local-goal-board/assets", join(root, "assets"), { recursive: true });
@@ -83,6 +422,7 @@ test("serves board JSON and streams live state changes over SSE", async () => {
83
422
 
84
423
  const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
85
424
  try {
425
+ assert.match(server.url, /\/live-board\/$/);
86
426
  const boardResponse = await fetch(`${server.url}api/board`);
87
427
  assert.equal(boardResponse.status, 200);
88
428
  const board = await boardResponse.json();
@@ -108,6 +448,116 @@ test("serves board JSON and streams live state changes over SSE", async () => {
108
448
  }
109
449
  });
110
450
 
451
+ test("streams parent board updates when linked child subgoal state changes", async () => {
452
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-subgoal-live-"));
453
+ const goalDir = join(root, "parent-goal");
454
+ const childDir = join(goalDir, "subgoals", "T001-child");
455
+ try {
456
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
457
+ mkdirSync(join(childDir, "notes"), { recursive: true });
458
+ writeFileSync(join(goalDir, "state.yaml"), parentWithSubgoalYaml());
459
+ writeFileSync(join(childDir, "state.yaml"), stateYaml("active", { title: "Child Goal", slug: "child-goal" }));
460
+
461
+ const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
462
+ try {
463
+ const controller = new AbortController();
464
+ const events = await fetch(`${server.url}events`, { signal: controller.signal });
465
+ assert.equal(events.status, 200);
466
+ const reader = events.body.getReader();
467
+
468
+ await readUntil(reader, /"title":"Child Goal"/);
469
+ writeFileSync(join(childDir, "state.yaml"), stateYaml("blocked", { title: "Child Goal", slug: "child-goal" }));
470
+ const update = await readUntil(reader, /"status":"blocked"/);
471
+ assert.match(update, /"Child Goal"/);
472
+
473
+ controller.abort();
474
+ await reader.cancel().catch(() => {});
475
+ } finally {
476
+ await server.close();
477
+ }
478
+ } finally {
479
+ rmSync(root, { recursive: true, force: true });
480
+ }
481
+ });
482
+
483
+ test("serves multiple local boards from one shared hub URL", async () => {
484
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-hub-"));
485
+ const firstGoalDir = join(root, "first-goal");
486
+ const secondGoalDir = join(root, "second-goal");
487
+ const previousSettingsPath = process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH;
488
+ try {
489
+ process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH = join(root, "hub-settings.json");
490
+ mkdirSync(join(firstGoalDir, "notes"), { recursive: true });
491
+ mkdirSync(join(secondGoalDir, "notes"), { recursive: true });
492
+ writeFileSync(join(firstGoalDir, "state.yaml"), stateYaml("active", { title: "First Goal", slug: "first-goal" }));
493
+ writeFileSync(join(secondGoalDir, "state.yaml"), stateYaml("blocked", { title: "Second Goal", slug: "second-goal" }));
494
+
495
+ const server = await startBoardServer({ goalDir: firstGoalDir, host: "127.0.0.1", port: 0 });
496
+ try {
497
+ const registerResponse = await fetch(server.apiUrl, {
498
+ method: "POST",
499
+ headers: { "Content-Type": "application/json" },
500
+ body: JSON.stringify({ goalDir: secondGoalDir }),
501
+ });
502
+ assert.equal(registerResponse.status, 200);
503
+ const second = await registerResponse.json();
504
+
505
+ const firstUrl = new URL(server.url);
506
+ const secondUrl = new URL(second.url);
507
+ assert.equal(secondUrl.origin, firstUrl.origin);
508
+ assert.equal(firstUrl.pathname, "/first-goal/");
509
+ assert.equal(secondUrl.pathname, "/second-goal/");
510
+
511
+ const hubResponse = await fetch(server.hubUrl, { redirect: "manual" });
512
+ assert.equal(hubResponse.status, 302);
513
+ assert.equal(hubResponse.headers.get("location"), `${firstUrl.origin}/first-goal/`);
514
+
515
+ const noSlashResponse = await fetch(`${secondUrl.origin}/second-goal`, { redirect: "manual" });
516
+ assert.equal(noSlashResponse.status, 302);
517
+ assert.equal(noSlashResponse.headers.get("location"), `${secondUrl.origin}/second-goal/`);
518
+
519
+ const boardsResponse = await fetch(server.apiUrl);
520
+ assert.equal(boardsResponse.status, 200);
521
+ const boards = await boardsResponse.json();
522
+ assert.deepEqual(boards.boards.map((board) => board.title), ["First Goal", "Second Goal"]);
523
+
524
+ const newestSettingsResponse = await fetch(`${firstUrl.origin}/api/settings`, {
525
+ method: "PUT",
526
+ headers: { "Content-Type": "application/json" },
527
+ body: JSON.stringify({ settings: { boardOpenBehavior: "newest" } }),
528
+ });
529
+ assert.equal(newestSettingsResponse.status, 200);
530
+ const newestHubResponse = await fetch(server.hubUrl, { redirect: "manual" });
531
+ assert.equal(newestHubResponse.status, 302);
532
+ assert.equal(newestHubResponse.headers.get("location"), `${firstUrl.origin}/second-goal/`);
533
+
534
+ const lastSettingsResponse = await fetch(`${firstUrl.origin}/api/settings`, {
535
+ method: "PUT",
536
+ headers: { "Content-Type": "application/json" },
537
+ body: JSON.stringify({ settings: { boardOpenBehavior: "last", lastBoardPath: "/first-goal/" } }),
538
+ });
539
+ assert.equal(lastSettingsResponse.status, 200);
540
+ const lastHubResponse = await fetch(server.hubUrl, { redirect: "manual" });
541
+ assert.equal(lastHubResponse.status, 302);
542
+ assert.equal(lastHubResponse.headers.get("location"), `${firstUrl.origin}/first-goal/`);
543
+
544
+ const secondBoardResponse = await fetch(`${second.url}api/board`);
545
+ assert.equal(secondBoardResponse.status, 200);
546
+ const secondBoard = await secondBoardResponse.json();
547
+ assert.equal(secondBoard.goal.title, "Second Goal");
548
+ } finally {
549
+ await server.close();
550
+ }
551
+ } finally {
552
+ if (previousSettingsPath === undefined) {
553
+ delete process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH;
554
+ } else {
555
+ process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH = previousSettingsPath;
556
+ }
557
+ rmSync(root, { recursive: true, force: true });
558
+ }
559
+ });
560
+
111
561
  async function readUntil(reader, pattern) {
112
562
  const decoder = new TextDecoder();
113
563
  let text = "";
@@ -123,11 +573,35 @@ async function readUntil(reader, pattern) {
123
573
  assert.fail(`Timed out waiting for ${pattern}. Received:\n${text}`);
124
574
  }
125
575
 
126
- function stateYaml(status) {
576
+ function parentWithSubgoalYaml() {
577
+ return `version: 2
578
+ goal:
579
+ title: "Parent Goal"
580
+ slug: "parent-goal"
581
+ kind: specific
582
+ tranche: "Verify child live updates."
583
+ status: active
584
+ active_task: T001
585
+ tasks:
586
+ - id: T001
587
+ type: worker
588
+ assignee: Worker
589
+ status: active
590
+ objective: "Watch child state."
591
+ subgoal:
592
+ status: active
593
+ path: subgoals/T001-child/state.yaml
594
+ owner: Worker
595
+ depth: 1
596
+ receipt: null
597
+ `;
598
+ }
599
+
600
+ function stateYaml(status, { title = "Live board", slug = "live-board" } = {}) {
127
601
  return `version: 2
128
602
  goal:
129
- title: "Live board"
130
- slug: "live-board"
603
+ title: "${title}"
604
+ slug: "${slug}"
131
605
  kind: specific
132
606
  tranche: "Verify live updates."
133
607
  status: active