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.
- package/README.md +28 -3
- package/RELEASE-0.3.5.md +324 -0
- package/goalbuddy/SKILL.md +8 -2
- package/goalbuddy/agents/goal_judge.toml +29 -17
- package/goalbuddy/agents/goal_scout.toml +34 -14
- package/goalbuddy/agents/goal_worker.toml +32 -15
- 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 +940 -24
- 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 +420 -4
- package/goalbuddy/scripts/check-goal-state.mjs +116 -6
- package/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/goalbuddy/scripts/render-task-prompt.mjs +248 -0
- package/goalbuddy/templates/agents.md +2 -2
- package/goalbuddy/templates/state.yaml +8 -0
- package/internal/assets/goalbuddy-v0.3.5-release.png +0 -0
- package/internal/cli/goal-maker.mjs +64 -1
- 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 +31 -16
- package/plugins/goalbuddy/agents/goal-scout.md +38 -13
- package/plugins/goalbuddy/agents/goal-worker.md +35 -14
- package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +8 -2
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +29 -17
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_scout.toml +34 -14
- package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +32 -15
- 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 +940 -24
- 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 +420 -4
- package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +116 -6
- package/plugins/goalbuddy/skills/goalbuddy/scripts/parallel-plan.mjs +191 -0
- package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +248 -0
- package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +2 -2
- package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +8 -0
package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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: "
|
|
130
|
-
slug: "
|
|
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 {
|
|
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 (
|
|
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
|
|
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;
|