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.
- package/README.md +55 -5
- package/RELEASE-0.3.5.md +324 -0
- package/goalbuddy/SKILL.md +40 -13
- package/goalbuddy/agents/README.md +1 -1
- package/goalbuddy/agents/goal_judge.toml +33 -17
- package/goalbuddy/agents/goal_scout.toml +34 -14
- package/goalbuddy/agents/goal_worker.toml +36 -16
- package/goalbuddy/extend/local-goal-board/README.md +8 -4
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
- package/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
- package/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
- package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
- package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
- package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
- package/goalbuddy/scripts/check-goal-state.mjs +192 -6
- package/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/goalbuddy/scripts/render-task-prompt.mjs +305 -0
- package/goalbuddy/templates/agents.md +5 -4
- package/goalbuddy/templates/goal.md +18 -4
- package/goalbuddy/templates/state.yaml +14 -1
- package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
- package/internal/cli/goal-maker.mjs +172 -9
- package/package.json +3 -2
- package/plugins/goalbuddy/.claude-plugin/plugin.json +2 -2
- package/plugins/goalbuddy/.codex-plugin/plugin.json +4 -4
- package/plugins/goalbuddy/README.md +5 -3
- package/plugins/goalbuddy/agents/goal-judge.md +35 -16
- package/plugins/goalbuddy/agents/goal-scout.md +38 -13
- package/plugins/goalbuddy/agents/goal-worker.md +37 -14
- package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +40 -13
- package/plugins/goalbuddy/skills/goalbuddy/agents/README.md +1 -1
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +33 -17
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +36 -16
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +8 -4
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/goal.md +3 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/notes/.gitkeep +1 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/state.yaml +60 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/goal.md +3 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/notes/.gitkeep +1 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/subgoal-parent/subgoals/T004-board-view/state.yaml +52 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +6 -4
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1188 -31
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +389 -54
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +479 -5
- package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +192 -6
- package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +305 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +5 -4
- package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +18 -4
- 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.
|
|
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("
|
|
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
|
|
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: "
|
|
130
|
-
slug: "
|
|
603
|
+
title: "${title}"
|
|
604
|
+
slug: "${slug}"
|
|
131
605
|
kind: specific
|
|
132
606
|
tranche: "Verify live updates."
|
|
133
607
|
status: active
|