goalbuddy 0.3.2 → 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 (51) hide show
  1. package/README.md +28 -3
  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.5-release.png +0 -0
  24. package/internal/cli/goal-maker.mjs +64 -1
  25. package/package.json +3 -2
  26. package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
  27. package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
  28. package/plugins/goalbuddy/README.md +5 -3
  29. package/plugins/goalbuddy/agents/goal-judge.md +31 -16
  30. package/plugins/goalbuddy/agents/goal-scout.md +38 -13
  31. package/plugins/goalbuddy/agents/goal-worker.md +35 -14
  32. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +8 -2
  33. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +29 -17
  34. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
  35. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +32 -15
  36. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
  37. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
  38. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
  39. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
  40. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
  41. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
  42. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
  43. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
  44. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +940 -24
  45. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
  46. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +420 -4
  47. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +116 -6
  48. package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
  49. package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +248 -0
  50. package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +2 -2
  51. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +8 -0
@@ -23,6 +23,155 @@ 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("fails loudly when a linked subgoal state file is missing", () => {
86
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-missing-subgoal-"));
87
+ try {
88
+ const goalDir = join(root, "parent");
89
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
90
+ writeFileSync(join(goalDir, "state.yaml"), `version: 2
91
+ goal:
92
+ title: "Missing child"
93
+ slug: "missing-child"
94
+ kind: specific
95
+ tranche: "Missing child."
96
+ status: active
97
+ active_task: T001
98
+ tasks:
99
+ - id: T001
100
+ type: worker
101
+ assignee: Worker
102
+ status: active
103
+ objective: "Render child."
104
+ subgoal:
105
+ status: active
106
+ path: subgoals/missing/state.yaml
107
+ owner: Worker
108
+ depth: 1
109
+ receipt: null
110
+ `);
111
+
112
+ assert.throws(
113
+ () => createBoardPayload(goalDir),
114
+ /Missing sub-goal state for T001: subgoals\/missing\/state\.yaml/,
115
+ );
116
+ } finally {
117
+ rmSync(root, { recursive: true, force: true });
118
+ }
119
+ });
120
+
121
+ test("refuses to render subgoal boards outside the parent goal root", () => {
122
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-outside-subgoal-"));
123
+ try {
124
+ const goalDir = join(root, "parent");
125
+ const outsideDir = join(root, "outside");
126
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
127
+ mkdirSync(outsideDir, { recursive: true });
128
+ writeFileSync(join(outsideDir, "state.yaml"), `version: 2
129
+ goal:
130
+ title: "Outside child"
131
+ slug: "outside-child"
132
+ kind: specific
133
+ tranche: "Outside."
134
+ status: active
135
+ active_task: T001
136
+ tasks:
137
+ - id: T001
138
+ type: scout
139
+ assignee: Scout
140
+ status: active
141
+ objective: "Read."
142
+ receipt: null
143
+ `);
144
+ writeFileSync(join(goalDir, "state.yaml"), `version: 2
145
+ goal:
146
+ title: "Outside child parent"
147
+ slug: "outside-child-parent"
148
+ kind: specific
149
+ tranche: "Reject outside child."
150
+ status: active
151
+ active_task: T001
152
+ tasks:
153
+ - id: T001
154
+ type: worker
155
+ assignee: Worker
156
+ status: active
157
+ objective: "Render child."
158
+ subgoal:
159
+ status: active
160
+ path: ../outside/state.yaml
161
+ owner: Worker
162
+ depth: 1
163
+ receipt: null
164
+ `);
165
+
166
+ assert.throws(
167
+ () => createBoardPayload(goalDir),
168
+ /Invalid sub-goal path for T001: \.\.\/outside\/state\.yaml must stay inside the goal root/,
169
+ );
170
+ } finally {
171
+ rmSync(root, { recursive: true, force: true });
172
+ }
173
+ });
174
+
26
175
  test("writes a minimal GoalBuddy web app into the goal directory", () => {
27
176
  const appDir = writeBoardApp(resolve("extend/local-goal-board/examples/sample-goal"));
28
177
  const html = readFileSync(join(appDir, "index.html"), "utf8");
@@ -31,24 +180,156 @@ test("writes a minimal GoalBuddy web app into the goal directory", () => {
31
180
  const logo = readFileSync(join(appDir, "goalbuddy-mark.png"));
32
181
 
33
182
  assert.match(html, /goalbuddy-mark\.png/);
183
+ assert.match(html, /class="topbar-primary"/);
184
+ assert.match(html, /class="board-switcher is-empty"/);
185
+ assert.match(html, /class="github-stars"/);
186
+ assert.match(html, /id="settings-button"/);
187
+ assert.match(html, /id="settings-popover"/);
34
188
  assert.match(css, /--canvas: #f7f6f3/);
35
- assert.doesNotMatch(css, /gradient/i);
189
+ assert.match(css, /\.topbar-primary/);
190
+ assert.match(css, /\.board-switcher\.is-empty \{\n display: none;/);
191
+ assert.match(css, /active-card-orbit/);
192
+ assert.match(css, /:root\[data-motion="reduce"\] \.task-card\.is-active::before/);
193
+ assert.match(css, /:root\[data-theme="dark"\]/);
194
+ assert.match(css, /:root\[data-density="compact"\] \.task-card/);
195
+ assert.match(css, /:root\[data-completed-visibility="collapse"\]/);
196
+ assert.match(css, /\.subgoal-board/);
36
197
  assert.match(js, /new EventSource\("\.\/events"\)/);
198
+ assert.match(js, /fetch\("\.\.\/api\/boards"/);
199
+ assert.match(js, /fetch\("\.\.\/api\/settings"/);
200
+ assert.match(js, /fetch\("https:\/\/api\.github\.com\/repos\/tolibear\/goalbuddy"/);
201
+ assert.match(js, /goalbuddy\.localBoardSettings\.v1/);
202
+ assert.match(js, /document\.documentElement\.dataset\.theme/);
203
+ assert.match(js, /rememberCurrentBoard/);
204
+ assert.match(js, /settingsButtonEl\.setAttribute\("aria-label"/);
37
205
  assert.match(js, /animateCardMoves/);
38
206
  assert.match(js, /card\.animate/);
39
207
  assert.match(js, /highlightMovingCards/);
208
+ assert.match(js, /renderSubgoal/);
209
+ assert.match(js, /boardOptionLabel/);
40
210
  assert.match(js, /duration: changedColumn \? 980 : 520/);
41
211
  assert.equal(logo.subarray(1, 4).toString("ascii"), "PNG");
42
212
  });
43
213
 
214
+ test("serves global local board settings with defensive normalization", async () => {
215
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-settings-"));
216
+ const goalDir = join(root, "settings-goal");
217
+ const settingsPath = join(root, "settings.json");
218
+ const previousSettingsPath = process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH;
219
+ try {
220
+ process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH = settingsPath;
221
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
222
+ writeFileSync(join(goalDir, "state.yaml"), stateYaml("active", { title: "Settings Goal", slug: "settings-goal" }));
223
+
224
+ const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
225
+ try {
226
+ const initialResponse = await fetch(`${server.hubUrl}api/settings`);
227
+ assert.equal(initialResponse.status, 200);
228
+ assert.deepEqual((await initialResponse.json()).settings, {
229
+ theme: "system",
230
+ density: "comfortable",
231
+ completedVisibility: "show",
232
+ boardOpenBehavior: "last",
233
+ motion: "system",
234
+ lastBoardPath: "",
235
+ });
236
+
237
+ const updateResponse = await fetch(`${server.hubUrl}api/settings`, {
238
+ method: "PUT",
239
+ headers: { "Content-Type": "application/json" },
240
+ body: JSON.stringify({
241
+ settings: {
242
+ theme: "dark",
243
+ density: "compact",
244
+ completedVisibility: "collapse",
245
+ boardOpenBehavior: "newest",
246
+ motion: "reduce",
247
+ lastBoardPath: "/settings-goal/",
248
+ unexpected: "ignored",
249
+ },
250
+ }),
251
+ });
252
+ assert.equal(updateResponse.status, 200);
253
+ assert.deepEqual((await updateResponse.json()).settings, {
254
+ theme: "dark",
255
+ density: "compact",
256
+ completedVisibility: "collapse",
257
+ boardOpenBehavior: "newest",
258
+ motion: "reduce",
259
+ lastBoardPath: "/settings-goal/",
260
+ });
261
+ assert.match(readFileSync(settingsPath, "utf8"), /"theme": "dark"/);
262
+
263
+ const invalidResponse = await fetch(`${server.hubUrl}api/settings`, {
264
+ method: "PUT",
265
+ headers: { "Content-Type": "application/json" },
266
+ body: JSON.stringify({ settings: { theme: "neon", density: "tiny", motion: "allow" } }),
267
+ });
268
+ assert.equal(invalidResponse.status, 200);
269
+ assert.deepEqual((await invalidResponse.json()).settings, {
270
+ theme: "system",
271
+ density: "comfortable",
272
+ completedVisibility: "show",
273
+ boardOpenBehavior: "last",
274
+ motion: "allow",
275
+ lastBoardPath: "",
276
+ });
277
+ } finally {
278
+ await server.close();
279
+ }
280
+ } finally {
281
+ if (previousSettingsPath === undefined) {
282
+ delete process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH;
283
+ } else {
284
+ process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH = previousSettingsPath;
285
+ }
286
+ rmSync(root, { recursive: true, force: true });
287
+ }
288
+ });
289
+
44
290
  test("parses CLI options", () => {
291
+ assert.equal(parseArgs(["--goal", "docs/goals/demo"]).port, 41737);
292
+ assert.equal(parseArgs(["--goal", "docs/goals/demo"]).host, "127.0.0.1");
293
+ assert.equal(parseArgs(["--goal", "docs/goals/demo"]).publicHost, "goalbuddy.localhost");
45
294
  assert.deepEqual(parseArgs(["--goal", "docs/goals/demo", "--port", "0", "--once", "--json"]), {
46
295
  goal: "docs/goals/demo",
47
296
  host: "127.0.0.1",
297
+ publicHost: "goalbuddy.localhost",
48
298
  port: 0,
49
299
  once: true,
50
300
  json: true,
51
301
  });
302
+ assert.deepEqual(parseArgs(["--goal", "docs/goals/demo", "--host", "localhost"]), {
303
+ goal: "docs/goals/demo",
304
+ host: "localhost",
305
+ publicHost: "localhost",
306
+ port: 41737,
307
+ once: false,
308
+ json: false,
309
+ });
310
+ });
311
+
312
+ test("advertises goalbuddy.localhost while binding to loopback", async () => {
313
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-public-host-"));
314
+ const goalDir = join(root, "goal");
315
+ try {
316
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
317
+ writeFileSync(join(goalDir, "state.yaml"), stateYaml("active", { title: "Public Host Goal", slug: "public-host-goal" }));
318
+
319
+ const server = await startBoardServer({ goalDir, port: 0 });
320
+ try {
321
+ const url = new URL(server.url);
322
+ assert.equal(url.hostname, "goalbuddy.localhost");
323
+ assert.equal(url.pathname, "/public-host-goal/");
324
+
325
+ const loopbackResponse = await fetch(`http://127.0.0.1:${url.port}/api/boards`);
326
+ assert.equal(loopbackResponse.status, 200);
327
+ } finally {
328
+ await server.close();
329
+ }
330
+ } finally {
331
+ rmSync(root, { recursive: true, force: true });
332
+ }
52
333
  });
53
334
 
54
335
  test("runs when installed under a symlinked temp path", () => {
@@ -83,6 +364,7 @@ test("serves board JSON and streams live state changes over SSE", async () => {
83
364
 
84
365
  const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
85
366
  try {
367
+ assert.match(server.url, /\/live-board\/$/);
86
368
  const boardResponse = await fetch(`${server.url}api/board`);
87
369
  assert.equal(boardResponse.status, 200);
88
370
  const board = await boardResponse.json();
@@ -108,6 +390,116 @@ test("serves board JSON and streams live state changes over SSE", async () => {
108
390
  }
109
391
  });
110
392
 
393
+ test("streams parent board updates when linked child subgoal state changes", async () => {
394
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-subgoal-live-"));
395
+ const goalDir = join(root, "parent-goal");
396
+ const childDir = join(goalDir, "subgoals", "T001-child");
397
+ try {
398
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
399
+ mkdirSync(join(childDir, "notes"), { recursive: true });
400
+ writeFileSync(join(goalDir, "state.yaml"), parentWithSubgoalYaml());
401
+ writeFileSync(join(childDir, "state.yaml"), stateYaml("active", { title: "Child Goal", slug: "child-goal" }));
402
+
403
+ const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
404
+ try {
405
+ const controller = new AbortController();
406
+ const events = await fetch(`${server.url}events`, { signal: controller.signal });
407
+ assert.equal(events.status, 200);
408
+ const reader = events.body.getReader();
409
+
410
+ await readUntil(reader, /"title":"Child Goal"/);
411
+ writeFileSync(join(childDir, "state.yaml"), stateYaml("blocked", { title: "Child Goal", slug: "child-goal" }));
412
+ const update = await readUntil(reader, /"status":"blocked"/);
413
+ assert.match(update, /"Child Goal"/);
414
+
415
+ controller.abort();
416
+ await reader.cancel().catch(() => {});
417
+ } finally {
418
+ await server.close();
419
+ }
420
+ } finally {
421
+ rmSync(root, { recursive: true, force: true });
422
+ }
423
+ });
424
+
425
+ test("serves multiple local boards from one shared hub URL", async () => {
426
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-hub-"));
427
+ const firstGoalDir = join(root, "first-goal");
428
+ const secondGoalDir = join(root, "second-goal");
429
+ const previousSettingsPath = process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH;
430
+ try {
431
+ process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH = join(root, "hub-settings.json");
432
+ mkdirSync(join(firstGoalDir, "notes"), { recursive: true });
433
+ mkdirSync(join(secondGoalDir, "notes"), { recursive: true });
434
+ writeFileSync(join(firstGoalDir, "state.yaml"), stateYaml("active", { title: "First Goal", slug: "first-goal" }));
435
+ writeFileSync(join(secondGoalDir, "state.yaml"), stateYaml("blocked", { title: "Second Goal", slug: "second-goal" }));
436
+
437
+ const server = await startBoardServer({ goalDir: firstGoalDir, host: "127.0.0.1", port: 0 });
438
+ try {
439
+ const registerResponse = await fetch(server.apiUrl, {
440
+ method: "POST",
441
+ headers: { "Content-Type": "application/json" },
442
+ body: JSON.stringify({ goalDir: secondGoalDir }),
443
+ });
444
+ assert.equal(registerResponse.status, 200);
445
+ const second = await registerResponse.json();
446
+
447
+ const firstUrl = new URL(server.url);
448
+ const secondUrl = new URL(second.url);
449
+ assert.equal(secondUrl.origin, firstUrl.origin);
450
+ assert.equal(firstUrl.pathname, "/first-goal/");
451
+ assert.equal(secondUrl.pathname, "/second-goal/");
452
+
453
+ const hubResponse = await fetch(server.hubUrl, { redirect: "manual" });
454
+ assert.equal(hubResponse.status, 302);
455
+ assert.equal(hubResponse.headers.get("location"), `${firstUrl.origin}/first-goal/`);
456
+
457
+ const noSlashResponse = await fetch(`${secondUrl.origin}/second-goal`, { redirect: "manual" });
458
+ assert.equal(noSlashResponse.status, 302);
459
+ assert.equal(noSlashResponse.headers.get("location"), `${secondUrl.origin}/second-goal/`);
460
+
461
+ const boardsResponse = await fetch(server.apiUrl);
462
+ assert.equal(boardsResponse.status, 200);
463
+ const boards = await boardsResponse.json();
464
+ assert.deepEqual(boards.boards.map((board) => board.title), ["First Goal", "Second Goal"]);
465
+
466
+ const newestSettingsResponse = await fetch(`${firstUrl.origin}/api/settings`, {
467
+ method: "PUT",
468
+ headers: { "Content-Type": "application/json" },
469
+ body: JSON.stringify({ settings: { boardOpenBehavior: "newest" } }),
470
+ });
471
+ assert.equal(newestSettingsResponse.status, 200);
472
+ const newestHubResponse = await fetch(server.hubUrl, { redirect: "manual" });
473
+ assert.equal(newestHubResponse.status, 302);
474
+ assert.equal(newestHubResponse.headers.get("location"), `${firstUrl.origin}/second-goal/`);
475
+
476
+ const lastSettingsResponse = await fetch(`${firstUrl.origin}/api/settings`, {
477
+ method: "PUT",
478
+ headers: { "Content-Type": "application/json" },
479
+ body: JSON.stringify({ settings: { boardOpenBehavior: "last", lastBoardPath: "/first-goal/" } }),
480
+ });
481
+ assert.equal(lastSettingsResponse.status, 200);
482
+ const lastHubResponse = await fetch(server.hubUrl, { redirect: "manual" });
483
+ assert.equal(lastHubResponse.status, 302);
484
+ assert.equal(lastHubResponse.headers.get("location"), `${firstUrl.origin}/first-goal/`);
485
+
486
+ const secondBoardResponse = await fetch(`${second.url}api/board`);
487
+ assert.equal(secondBoardResponse.status, 200);
488
+ const secondBoard = await secondBoardResponse.json();
489
+ assert.equal(secondBoard.goal.title, "Second Goal");
490
+ } finally {
491
+ await server.close();
492
+ }
493
+ } finally {
494
+ if (previousSettingsPath === undefined) {
495
+ delete process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH;
496
+ } else {
497
+ process.env.GOALBUDDY_LOCAL_BOARD_SETTINGS_PATH = previousSettingsPath;
498
+ }
499
+ rmSync(root, { recursive: true, force: true });
500
+ }
501
+ });
502
+
111
503
  async function readUntil(reader, pattern) {
112
504
  const decoder = new TextDecoder();
113
505
  let text = "";
@@ -123,11 +515,35 @@ async function readUntil(reader, pattern) {
123
515
  assert.fail(`Timed out waiting for ${pattern}. Received:\n${text}`);
124
516
  }
125
517
 
126
- function stateYaml(status) {
518
+ function parentWithSubgoalYaml() {
519
+ return `version: 2
520
+ goal:
521
+ title: "Parent Goal"
522
+ slug: "parent-goal"
523
+ kind: specific
524
+ tranche: "Verify child live updates."
525
+ status: active
526
+ active_task: T001
527
+ tasks:
528
+ - id: T001
529
+ type: worker
530
+ assignee: Worker
531
+ status: active
532
+ objective: "Watch child state."
533
+ subgoal:
534
+ status: active
535
+ path: subgoals/T001-child/state.yaml
536
+ owner: Worker
537
+ depth: 1
538
+ receipt: null
539
+ `;
540
+ }
541
+
542
+ function stateYaml(status, { title = "Live board", slug = "live-board" } = {}) {
127
543
  return `version: 2
128
544
  goal:
129
- title: "Live board"
130
- slug: "live-board"
545
+ title: "${title}"
546
+ slug: "${slug}"
131
547
  kind: specific
132
548
  tranche: "Verify live updates."
133
549
  status: active
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
- import { basename, dirname, join } from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { basename, dirname, join, resolve, sep } from "node:path";
4
5
 
5
6
  const statePath = process.argv[2];
7
+ const isChildCheck = process.argv.includes("--child");
6
8
 
7
9
  if (!statePath) {
8
10
  console.error("Usage: node scripts/check-goal-state.mjs docs/goals/<slug>/state.yaml");
@@ -98,6 +100,7 @@ function parseTasks() {
98
100
  verify: taskList(task, "verify"),
99
101
  stopIf: taskList(task, "stop_if"),
100
102
  receipt: taskReceipt(task),
103
+ subgoal: taskSubgoal(task),
101
104
  }));
102
105
  }
103
106
 
@@ -149,6 +152,32 @@ function taskReceipt(task) {
149
152
  };
150
153
  }
151
154
 
155
+ function taskSubgoal(task) {
156
+ const lines = task.raw.split(/\r?\n/);
157
+ const start = lines.findIndex((line) => /^\s{4}subgoal:\s*/.test(line));
158
+ if (start === -1) return { present: false };
159
+
160
+ const subgoalLines = [];
161
+ for (let i = start + 1; i < lines.length; i += 1) {
162
+ if (/^\s{4}\S/.test(lines[i])) break;
163
+ subgoalLines.push(lines[i]);
164
+ }
165
+ const raw = subgoalLines.join("\n");
166
+ const scalar = (key) => {
167
+ const match = raw.match(new RegExp(`^\\s{6}${key}:\\s*(.*?)\\s*$`, "m"));
168
+ return match ? clean(match[1]) : null;
169
+ };
170
+
171
+ return {
172
+ present: true,
173
+ raw,
174
+ status: scalar("status"),
175
+ path: scalar("path"),
176
+ owner: scalar("owner"),
177
+ depth: scalar("depth"),
178
+ };
179
+ }
180
+
152
181
  function receiptList(raw, key) {
153
182
  const lines = raw.split(/\r?\n/);
154
183
  const start = lines.findIndex((line) => new RegExp(`^\\s{6}${key}:\\s*$`).test(line));
@@ -169,16 +198,16 @@ function receiptCommandStatuses(raw) {
169
198
  }
170
199
 
171
200
  function rootEntryErrors() {
172
- const allowed = new Set(["goal.md", "state.yaml", "notes", ".goalbuddy-board"]);
201
+ const allowed = new Set(["goal.md", "state.yaml", "notes", ".goalbuddy-board", "subgoals"]);
173
202
  const unexpected = [];
174
203
  for (const entry of readdirSync(root).filter((item) => item !== ".DS_Store")) {
175
204
  const path = join(root, entry);
176
205
  const stats = statSync(path);
177
206
  if (!allowed.has(entry)) {
178
207
  unexpected.push(entry);
179
- } else if ((entry === "notes" || entry === ".goalbuddy-board") && !stats.isDirectory()) {
208
+ } else if ((entry === "notes" || entry === ".goalbuddy-board" || entry === "subgoals") && !stats.isDirectory()) {
180
209
  unexpected.push(`${entry} (must be a directory)`);
181
- } else if (entry !== "notes" && entry !== ".goalbuddy-board" && !stats.isFile()) {
210
+ } else if (!["notes", ".goalbuddy-board", "subgoals"].includes(entry) && !stats.isFile()) {
182
211
  unexpected.push(`${entry} (must be a file)`);
183
212
  }
184
213
  }
@@ -241,7 +270,7 @@ if (!existsSync(join(root, "notes")) || !statSync(join(root, "notes")).isDirecto
241
270
 
242
271
  const unexpected = rootEntryErrors();
243
272
  if (unexpected.length > 0) {
244
- errors.push(`unexpected root entries; v2 goal roots may contain only goal.md, state.yaml, notes/, and .goalbuddy-board/: ${unexpected.join(", ")}`);
273
+ errors.push(`unexpected root entries; v2 goal roots may contain only goal.md, state.yaml, notes/, subgoals/, and .goalbuddy-board/: ${unexpected.join(", ")}`);
245
274
  }
246
275
 
247
276
  const tasks = parseTasks();
@@ -298,6 +327,10 @@ if (activeTasks.length === 1 && activeTask !== activeTasks[0].id) {
298
327
  if (activeTask && !ids.has(activeTask)) errors.push(`active_task points to unknown task: ${activeTask}`);
299
328
 
300
329
  for (const task of tasks) {
330
+ if (task.subgoal.present) {
331
+ validateSubgoal(task);
332
+ }
333
+
301
334
  const hasReceipt = task.receipt.present && task.receipt.value !== null;
302
335
  const receiptResult = hasReceipt ? task.receipt.scalar("result") : null;
303
336
  if (task.status === "done" && !hasReceipt) {
@@ -319,8 +352,11 @@ for (const task of tasks) {
319
352
  if (!task.receipt.has(key)) errors.push(`Worker receipt for ${task.id} missing ${key}`);
320
353
  }
321
354
  const changedFiles = task.receipt.list("changed_files");
355
+ if (changedFiles.length === 0) {
356
+ errors.push(`Worker receipt for ${task.id} changed_files must list at least one file`);
357
+ }
322
358
  for (const changedFile of changedFiles) {
323
- if (!task.allowedFiles.includes(changedFile)) {
359
+ if (!matchesAllowedFile(changedFile, task.allowedFiles)) {
324
360
  errors.push(`Worker receipt for ${task.id} changed file outside allowed_files: ${changedFile}`);
325
361
  }
326
362
  }
@@ -345,6 +381,80 @@ for (const task of tasks) {
345
381
  }
346
382
  }
347
383
 
384
+ function validateSubgoal(task) {
385
+ if (isChildCheck) {
386
+ errors.push(`child task ${task.id} must not contain a nested subgoal`);
387
+ return;
388
+ }
389
+
390
+ if (!["active", "blocked", "done"].includes(task.subgoal.status)) {
391
+ errors.push(`task ${task.id} subgoal.status must be active, blocked, or done; got ${task.subgoal.status || "<missing>"}`);
392
+ }
393
+ if (task.subgoal.depth !== 1) {
394
+ errors.push(`task ${task.id} subgoal.depth must be 1; got ${task.subgoal.depth || "<missing>"}`);
395
+ }
396
+ if (!task.subgoal.path) {
397
+ errors.push(`task ${task.id} subgoal.path is required`);
398
+ return;
399
+ }
400
+
401
+ const rootPath = resolve(root);
402
+ const childStatePath = resolve(rootPath, task.subgoal.path);
403
+ if (childStatePath !== rootPath && !childStatePath.startsWith(`${rootPath}${sep}`)) {
404
+ errors.push(`task ${task.id} subgoal.path must stay inside the goal root: ${task.subgoal.path}`);
405
+ return;
406
+ }
407
+ if (basename(childStatePath) !== "state.yaml") {
408
+ errors.push(`task ${task.id} subgoal.path must point to a state.yaml file`);
409
+ return;
410
+ }
411
+ if (!existsSync(childStatePath)) {
412
+ errors.push(`task ${task.id} subgoal state file not found: ${task.subgoal.path}`);
413
+ return;
414
+ }
415
+
416
+ const result = spawnSync(process.execPath, [process.argv[1], childStatePath, "--child"], {
417
+ encoding: "utf8",
418
+ });
419
+ let report = null;
420
+ try {
421
+ report = JSON.parse(result.stdout);
422
+ } catch {
423
+ errors.push(`task ${task.id} subgoal checker produced invalid output for ${task.subgoal.path}`);
424
+ return;
425
+ }
426
+ if (result.status !== 0 || !report.ok) {
427
+ for (const childError of report.errors || ["unknown child state error"]) {
428
+ errors.push(`task ${task.id} subgoal invalid: ${childError}`);
429
+ }
430
+ }
431
+ }
432
+
433
+ function matchesAllowedFile(file, allowedFiles) {
434
+ return allowedFiles.some((pattern) => globMatch(pattern, file));
435
+ }
436
+
437
+ function globMatch(pattern, file) {
438
+ const normalizedPattern = normalizePathPattern(pattern);
439
+ const normalizedFile = normalizePathPattern(file);
440
+ if (normalizedPattern === normalizedFile) return true;
441
+ const token = "__GOALBUDDY_GLOBSTAR__";
442
+ const regexSource = escapeRegExp(normalizedPattern)
443
+ .replace(/\*\*/g, token)
444
+ .replace(/\*/g, "[^/]*")
445
+ .replace(new RegExp(token, "g"), ".*");
446
+ const regex = new RegExp(`^${regexSource}$`);
447
+ return regex.test(normalizedFile);
448
+ }
449
+
450
+ function normalizePathPattern(value) {
451
+ return String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
452
+ }
453
+
454
+ function escapeRegExp(value) {
455
+ return String(value).replace(/[.+^${}()|[\]\\]/g, "\\$&");
456
+ }
457
+
348
458
  if (goalStatus === "done") {
349
459
  const finalAudit = tasks.some((task) => {
350
460
  if (!["judge", "pm"].includes(task.type) || task.status !== "done") return false;