goalbuddy 0.2.21 → 0.2.22
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 +10 -18
- package/goalbuddy/SKILL.md +40 -10
- package/goalbuddy/extend/github-projects/README.md +105 -0
- package/goalbuddy/extend/github-projects/examples/goal-board-sync/state.yaml +63 -0
- package/goalbuddy/extend/github-projects/extension.yaml +43 -0
- package/goalbuddy/extend/github-projects/scripts/lib/github-projects.mjs +728 -0
- package/goalbuddy/extend/github-projects/scripts/lib/goal-state.mjs +362 -0
- package/goalbuddy/extend/github-projects/scripts/sync-github-project.mjs +193 -0
- package/goalbuddy/extend/github-projects/test/github-projects.test.mjs +267 -0
- package/goalbuddy/extend/local-goal-board/README.md +75 -0
- package/goalbuddy/extend/local-goal-board/assets/goalbuddy-mark.png +0 -0
- package/goalbuddy/extend/local-goal-board/examples/sample-goal/notes/T001-scout.md +3 -0
- package/goalbuddy/extend/local-goal-board/examples/sample-goal/state.yaml +124 -0
- package/goalbuddy/extend/local-goal-board/extension.yaml +37 -0
- package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1225 -0
- package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +258 -0
- package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +146 -0
- package/goalbuddy/scripts/check-goal-state.mjs +24 -9
- package/goalbuddy/templates/state.yaml +18 -3
- package/internal/cli/goal-maker.mjs +57 -11
- package/package.json +3 -2
- package/plugins/goalbuddy/.codex-plugin/plugin.json +3 -3
- package/plugins/goalbuddy/README.md +1 -5
- package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +40 -10
- package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/README.md +105 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/examples/goal-board-sync/state.yaml +63 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/extension.yaml +43 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/lib/github-projects.mjs +728 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/lib/goal-state.mjs +362 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/sync-github-project.mjs +193 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/test/github-projects.test.mjs +267 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +75 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/assets/goalbuddy-mark.png +0 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/sample-goal/notes/T001-scout.md +3 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/sample-goal/state.yaml +124 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +37 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1225 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +258 -0
- package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +146 -0
- package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +24 -9
- package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +18 -3
package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, watch } from "node:fs";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createBoardPayload, writeBoardApp } from "./lib/goal-board.mjs";
|
|
7
|
+
|
|
8
|
+
const textTypes = {
|
|
9
|
+
".html": "text/html; charset=utf-8",
|
|
10
|
+
".css": "text/css; charset=utf-8",
|
|
11
|
+
".js": "text/javascript; charset=utf-8",
|
|
12
|
+
".svg": "image/svg+xml; charset=utf-8",
|
|
13
|
+
".png": "image/png",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (isDirectRun()) {
|
|
17
|
+
main().catch((error) => {
|
|
18
|
+
console.error(error.message);
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isDirectRun() {
|
|
24
|
+
if (!process.argv[1]) return false;
|
|
25
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function main() {
|
|
29
|
+
const options = parseArgs(process.argv.slice(2));
|
|
30
|
+
const goalDir = resolve(options.goal || "");
|
|
31
|
+
if (!options.goal) throw new Error("Missing --goal docs/goals/<slug>");
|
|
32
|
+
if (!existsSync(join(goalDir, "state.yaml"))) {
|
|
33
|
+
throw new Error(`Missing state.yaml in ${goalDir}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const appDir = writeBoardApp(goalDir);
|
|
37
|
+
const board = createBoardPayload(goalDir);
|
|
38
|
+
|
|
39
|
+
if (options.once) {
|
|
40
|
+
if (options.json) {
|
|
41
|
+
console.log(JSON.stringify({ goalDir, appDir, board }, null, 2));
|
|
42
|
+
} else {
|
|
43
|
+
console.log(`Generated GoalBuddy board app at ${appDir}`);
|
|
44
|
+
}
|
|
45
|
+
return { goalDir, appDir, board };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const server = await startBoardServer({
|
|
49
|
+
goalDir,
|
|
50
|
+
appDir,
|
|
51
|
+
host: options.host,
|
|
52
|
+
port: options.port,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (options.json) {
|
|
56
|
+
console.log(JSON.stringify({ goalDir, appDir, url: server.url }, null, 2));
|
|
57
|
+
} else {
|
|
58
|
+
console.log(`GoalBuddy local board: ${server.url}`);
|
|
59
|
+
console.log(`Watching: ${join(goalDir, "state.yaml")}`);
|
|
60
|
+
console.log("Press Ctrl-C to stop.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return server;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parseArgs(args) {
|
|
67
|
+
const options = {
|
|
68
|
+
goal: "",
|
|
69
|
+
host: "127.0.0.1",
|
|
70
|
+
port: 41737,
|
|
71
|
+
once: false,
|
|
72
|
+
json: false,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
76
|
+
const arg = args[index];
|
|
77
|
+
if (arg === "--goal") {
|
|
78
|
+
options.goal = args[++index] || "";
|
|
79
|
+
} else if (arg.startsWith("--goal=")) {
|
|
80
|
+
options.goal = arg.slice("--goal=".length);
|
|
81
|
+
} else if (arg === "--host") {
|
|
82
|
+
options.host = args[++index] || options.host;
|
|
83
|
+
} else if (arg.startsWith("--host=")) {
|
|
84
|
+
options.host = arg.slice("--host=".length);
|
|
85
|
+
} else if (arg === "--port") {
|
|
86
|
+
options.port = Number(args[++index] || options.port);
|
|
87
|
+
} else if (arg.startsWith("--port=")) {
|
|
88
|
+
options.port = Number(arg.slice("--port=".length));
|
|
89
|
+
} else if (arg === "--once") {
|
|
90
|
+
options.once = true;
|
|
91
|
+
} else if (arg === "--json") {
|
|
92
|
+
options.json = true;
|
|
93
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
94
|
+
usage();
|
|
95
|
+
process.exit(0);
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
|
|
102
|
+
throw new Error(`Invalid --port: ${options.port}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return options;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function startBoardServer({ goalDir, appDir = writeBoardApp(goalDir), host = "127.0.0.1", port = 0 }) {
|
|
109
|
+
const root = resolve(goalDir);
|
|
110
|
+
const clients = new Set();
|
|
111
|
+
let lastPayload = safePayload(root);
|
|
112
|
+
|
|
113
|
+
const notify = () => {
|
|
114
|
+
lastPayload = safePayload(root);
|
|
115
|
+
for (const client of clients) sendEvent(client, lastPayload);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const watcher = watchGoal(root, notify);
|
|
119
|
+
const server = createServer((request, response) => {
|
|
120
|
+
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
|
|
121
|
+
if (url.pathname === "/api/board") {
|
|
122
|
+
sendJson(response, safePayload(root));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (url.pathname === "/events") {
|
|
126
|
+
response.writeHead(200, {
|
|
127
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
128
|
+
"Cache-Control": "no-cache, no-transform",
|
|
129
|
+
"Connection": "keep-alive",
|
|
130
|
+
"X-Accel-Buffering": "no",
|
|
131
|
+
});
|
|
132
|
+
response.write("retry: 1000\n\n");
|
|
133
|
+
clients.add(response);
|
|
134
|
+
sendEvent(response, lastPayload);
|
|
135
|
+
request.on("close", () => clients.delete(response));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
serveStatic(appDir, url.pathname, response);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
143
|
+
server.once("error", rejectListen);
|
|
144
|
+
server.listen(port, host, () => {
|
|
145
|
+
server.off("error", rejectListen);
|
|
146
|
+
resolveListen();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const address = server.address();
|
|
151
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
url: `http://${host}:${actualPort}/`,
|
|
155
|
+
close: () => new Promise((resolveClose, rejectClose) => {
|
|
156
|
+
watcher.close();
|
|
157
|
+
for (const client of clients) client.end();
|
|
158
|
+
server.close((error) => error ? rejectClose(error) : resolveClose());
|
|
159
|
+
}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function watchGoal(goalDir, onChange) {
|
|
164
|
+
const watchers = [];
|
|
165
|
+
const schedule = debounce(onChange, 80);
|
|
166
|
+
watchers.push(watch(goalDir, { persistent: true }, (_event, filename) => {
|
|
167
|
+
if (!filename) return schedule();
|
|
168
|
+
if (filename === "state.yaml" || filename === "notes") schedule();
|
|
169
|
+
}));
|
|
170
|
+
const notesDir = join(goalDir, "notes");
|
|
171
|
+
if (existsSync(notesDir)) {
|
|
172
|
+
watchers.push(watch(notesDir, { persistent: true }, schedule));
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
close() {
|
|
176
|
+
for (const watcher of watchers) watcher.close();
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function safePayload(goalDir) {
|
|
182
|
+
try {
|
|
183
|
+
return createBoardPayload(goalDir);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return {
|
|
186
|
+
generatedAt: new Date().toISOString(),
|
|
187
|
+
error: error.message,
|
|
188
|
+
goal: { title: "GoalBuddy Board", slug: "", status: "error", activeTask: "", tranche: "" },
|
|
189
|
+
columns: [
|
|
190
|
+
{ id: "todo", title: "Todo", description: "Queued work ready to pull", tasks: [] },
|
|
191
|
+
{ id: "in-progress", title: "In Progress", description: "The active task", tasks: [] },
|
|
192
|
+
{ id: "blocked", title: "Blocked", description: "Needs unblock or a smaller slice", tasks: [] },
|
|
193
|
+
{ id: "completed", title: "Completed", description: "Receipted work", tasks: [] },
|
|
194
|
+
],
|
|
195
|
+
tasks: [],
|
|
196
|
+
notes: [],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sendJson(response, payload) {
|
|
202
|
+
response.writeHead(200, {
|
|
203
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
204
|
+
"Cache-Control": "no-store",
|
|
205
|
+
});
|
|
206
|
+
response.end(JSON.stringify(payload, null, 2));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function sendEvent(response, payload) {
|
|
210
|
+
response.write(`event: board\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function serveStatic(appDir, pathname, response) {
|
|
214
|
+
const cleanPath = pathname === "/" ? "/index.html" : pathname;
|
|
215
|
+
if (!/^\/[A-Za-z0-9_.-]+$/.test(cleanPath)) {
|
|
216
|
+
response.writeHead(404);
|
|
217
|
+
response.end("Not found");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const file = join(appDir, cleanPath.slice(1));
|
|
222
|
+
if (!existsSync(file)) {
|
|
223
|
+
response.writeHead(404);
|
|
224
|
+
response.end("Not found");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const extension = cleanPath.match(/\.[^.]+$/)?.[0] || "";
|
|
229
|
+
response.writeHead(200, {
|
|
230
|
+
"Content-Type": textTypes[extension] || "application/octet-stream",
|
|
231
|
+
"Cache-Control": "no-store",
|
|
232
|
+
});
|
|
233
|
+
response.end(readFileSync(file));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function debounce(fn, delay) {
|
|
237
|
+
let timer = null;
|
|
238
|
+
return () => {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
timer = setTimeout(fn, delay);
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function usage() {
|
|
245
|
+
console.log(`GoalBuddy Local Goal Board
|
|
246
|
+
|
|
247
|
+
Usage:
|
|
248
|
+
node extend/local-goal-board/scripts/local-goal-board.mjs --goal docs/goals/<slug>
|
|
249
|
+
node extend/local-goal-board/scripts/local-goal-board.mjs --goal docs/goals/<slug> --once --json
|
|
250
|
+
|
|
251
|
+
Options:
|
|
252
|
+
--goal <path> Goal directory containing state.yaml.
|
|
253
|
+
--host <host> Local server host. Default: 127.0.0.1.
|
|
254
|
+
--port <port> Local server port. Use 0 for an ephemeral port.
|
|
255
|
+
--once Generate .goalbuddy-board and exit.
|
|
256
|
+
--json Print structured output.
|
|
257
|
+
`);
|
|
258
|
+
}
|
package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { createBoardPayload, writeBoardApp } from "../scripts/lib/goal-board.mjs";
|
|
8
|
+
import { parseArgs, startBoardServer } from "../scripts/local-goal-board.mjs";
|
|
9
|
+
|
|
10
|
+
test("normalizes a dense goal into local board columns", () => {
|
|
11
|
+
const payload = createBoardPayload(resolve("extend/local-goal-board/examples/sample-goal"));
|
|
12
|
+
|
|
13
|
+
assert.equal(payload.goal.title, "Local Kanban Board Extension");
|
|
14
|
+
assert.equal(payload.goal.activeTask, "");
|
|
15
|
+
assert.equal(payload.counts.total, 14);
|
|
16
|
+
assert.equal(payload.counts.todo, 0);
|
|
17
|
+
assert.equal(payload.counts.inProgress, 0);
|
|
18
|
+
assert.equal(payload.counts.blocked, 5);
|
|
19
|
+
assert.equal(payload.counts.completed, 9);
|
|
20
|
+
assert.deepEqual(payload.columns.map((column) => column.title), ["Todo", "In Progress", "Blocked", "Completed"]);
|
|
21
|
+
|
|
22
|
+
const scout = payload.tasks.find((task) => task.id === "T001");
|
|
23
|
+
assert.equal(scout.receipt.summary, "T001 completed during the progressive board motion demo.");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("writes a minimal GoalBuddy web app into the goal directory", () => {
|
|
27
|
+
const appDir = writeBoardApp(resolve("extend/local-goal-board/examples/sample-goal"));
|
|
28
|
+
const html = readFileSync(join(appDir, "index.html"), "utf8");
|
|
29
|
+
const css = readFileSync(join(appDir, "styles.css"), "utf8");
|
|
30
|
+
const js = readFileSync(join(appDir, "app.js"), "utf8");
|
|
31
|
+
const logo = readFileSync(join(appDir, "goalbuddy-mark.png"));
|
|
32
|
+
|
|
33
|
+
assert.match(html, /goalbuddy-mark\.png/);
|
|
34
|
+
assert.match(css, /--canvas: #f7f6f3/);
|
|
35
|
+
assert.doesNotMatch(css, /gradient/i);
|
|
36
|
+
assert.match(js, /new EventSource\("\.\/events"\)/);
|
|
37
|
+
assert.match(js, /animateCardMoves/);
|
|
38
|
+
assert.match(js, /card\.animate/);
|
|
39
|
+
assert.match(js, /highlightMovingCards/);
|
|
40
|
+
assert.match(js, /duration: changedColumn \? 980 : 520/);
|
|
41
|
+
assert.equal(logo.subarray(1, 4).toString("ascii"), "PNG");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("parses CLI options", () => {
|
|
45
|
+
assert.deepEqual(parseArgs(["--goal", "docs/goals/demo", "--port", "0", "--once", "--json"]), {
|
|
46
|
+
goal: "docs/goals/demo",
|
|
47
|
+
host: "127.0.0.1",
|
|
48
|
+
port: 0,
|
|
49
|
+
once: true,
|
|
50
|
+
json: true,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("runs when installed under a symlinked temp path", () => {
|
|
55
|
+
const root = mkdtempSync("/tmp/goalbuddy-local-board-direct-");
|
|
56
|
+
try {
|
|
57
|
+
cpSync("extend/local-goal-board/scripts", join(root, "scripts"), { recursive: true });
|
|
58
|
+
cpSync("extend/local-goal-board/assets", join(root, "assets"), { recursive: true });
|
|
59
|
+
|
|
60
|
+
const result = spawnSync(process.execPath, [
|
|
61
|
+
join(root, "scripts", "local-goal-board.mjs"),
|
|
62
|
+
"--goal",
|
|
63
|
+
resolve("extend/local-goal-board/examples/sample-goal"),
|
|
64
|
+
"--once",
|
|
65
|
+
"--json",
|
|
66
|
+
], { encoding: "utf8" });
|
|
67
|
+
|
|
68
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
69
|
+
const report = JSON.parse(result.stdout);
|
|
70
|
+
assert.equal(report.board.goal.title, "Local Kanban Board Extension");
|
|
71
|
+
} finally {
|
|
72
|
+
rmSync(root, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("serves board JSON and streams live state changes over SSE", async () => {
|
|
77
|
+
const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-"));
|
|
78
|
+
const goalDir = join(root, "demo-goal");
|
|
79
|
+
try {
|
|
80
|
+
mkdirSync(join(goalDir, "notes"), { recursive: true });
|
|
81
|
+
writeFileSync(join(goalDir, "state.yaml"), stateYaml("active"));
|
|
82
|
+
writeFileSync(join(goalDir, "notes", "T001-note.md"), "# Live Note\n\nInitial note.\n");
|
|
83
|
+
|
|
84
|
+
const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
|
|
85
|
+
try {
|
|
86
|
+
const boardResponse = await fetch(`${server.url}api/board`);
|
|
87
|
+
assert.equal(boardResponse.status, 200);
|
|
88
|
+
const board = await boardResponse.json();
|
|
89
|
+
assert.equal(board.tasks[0].status, "active");
|
|
90
|
+
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const events = await fetch(`${server.url}events`, { signal: controller.signal });
|
|
93
|
+
assert.equal(events.status, 200);
|
|
94
|
+
const reader = events.body.getReader();
|
|
95
|
+
|
|
96
|
+
await readUntil(reader, /"status":"active"/);
|
|
97
|
+
writeFileSync(join(goalDir, "state.yaml"), stateYaml("blocked"));
|
|
98
|
+
const update = await readUntil(reader, /"status":"blocked"/);
|
|
99
|
+
assert.match(update, /"title":"Blocked"/);
|
|
100
|
+
|
|
101
|
+
controller.abort();
|
|
102
|
+
await reader.cancel().catch(() => {});
|
|
103
|
+
} finally {
|
|
104
|
+
await server.close();
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
rmSync(root, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
async function readUntil(reader, pattern) {
|
|
112
|
+
const decoder = new TextDecoder();
|
|
113
|
+
let text = "";
|
|
114
|
+
const deadline = Date.now() + 3000;
|
|
115
|
+
|
|
116
|
+
while (Date.now() < deadline) {
|
|
117
|
+
const { done, value } = await reader.read();
|
|
118
|
+
if (done) break;
|
|
119
|
+
text += decoder.decode(value, { stream: true });
|
|
120
|
+
if (pattern.test(text)) return text;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
assert.fail(`Timed out waiting for ${pattern}. Received:\n${text}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stateYaml(status) {
|
|
127
|
+
return `version: 2
|
|
128
|
+
goal:
|
|
129
|
+
title: "Live board"
|
|
130
|
+
slug: "live-board"
|
|
131
|
+
kind: specific
|
|
132
|
+
tranche: "Verify live updates."
|
|
133
|
+
status: active
|
|
134
|
+
active_task: T001
|
|
135
|
+
tasks:
|
|
136
|
+
- id: T001
|
|
137
|
+
type: worker
|
|
138
|
+
assignee: Worker
|
|
139
|
+
status: ${status}
|
|
140
|
+
objective: "Render live changes."
|
|
141
|
+
receipt:
|
|
142
|
+
result: done
|
|
143
|
+
summary: "Rendered safely."
|
|
144
|
+
note: notes/T001-note.md
|
|
145
|
+
`;
|
|
146
|
+
}
|
|
@@ -169,16 +169,16 @@ function receiptCommandStatuses(raw) {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
function rootEntryErrors() {
|
|
172
|
-
const allowed = new Set(["goal.md", "state.yaml", "notes"]);
|
|
172
|
+
const allowed = new Set(["goal.md", "state.yaml", "notes", ".goalbuddy-board"]);
|
|
173
173
|
const unexpected = [];
|
|
174
174
|
for (const entry of readdirSync(root).filter((item) => item !== ".DS_Store")) {
|
|
175
175
|
const path = join(root, entry);
|
|
176
176
|
const stats = statSync(path);
|
|
177
177
|
if (!allowed.has(entry)) {
|
|
178
178
|
unexpected.push(entry);
|
|
179
|
-
} else if (entry === "notes" && !stats.isDirectory()) {
|
|
180
|
-
unexpected.push(
|
|
181
|
-
} else if (entry !== "notes" && !stats.isFile()) {
|
|
179
|
+
} else if ((entry === "notes" || entry === ".goalbuddy-board") && !stats.isDirectory()) {
|
|
180
|
+
unexpected.push(`${entry} (must be a directory)`);
|
|
181
|
+
} else if (entry !== "notes" && entry !== ".goalbuddy-board" && !stats.isFile()) {
|
|
182
182
|
unexpected.push(`${entry} (must be a file)`);
|
|
183
183
|
}
|
|
184
184
|
}
|
|
@@ -188,10 +188,11 @@ function rootEntryErrors() {
|
|
|
188
188
|
const version = topScalar("version");
|
|
189
189
|
const goalStatus = nestedScalar("goal", "status");
|
|
190
190
|
const activeTask = topScalar("active_task");
|
|
191
|
-
const
|
|
191
|
+
const agentStatuses = ["scout", "worker", "judge"].map((agent) => ({
|
|
192
192
|
agent,
|
|
193
193
|
status: nestedScalar("agents", agent),
|
|
194
194
|
}));
|
|
195
|
+
const allowedAgentStatuses = new Set(["installed", "bundled_not_installed", "missing", "unknown"]);
|
|
195
196
|
const continuousUntilFullOutcome = nestedScalar("rules", "continuous_until_full_outcome") === true;
|
|
196
197
|
const missingInputOrCredentialsDoNotStopGoal =
|
|
197
198
|
nestedScalar("rules", "missing_input_or_credentials_do_not_stop_goal") === true;
|
|
@@ -214,9 +215,22 @@ if (!["active", "blocked", "done"].includes(goalStatus)) {
|
|
|
214
215
|
errors.push(`goal.status must be active, blocked, or done; got ${goalStatus || "<missing>"}`);
|
|
215
216
|
}
|
|
216
217
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
218
|
+
function agentStatusWarning(agent, status) {
|
|
219
|
+
const agentLabel = agent[0].toUpperCase() + agent.slice(1);
|
|
220
|
+
if (status === "bundled_not_installed") {
|
|
221
|
+
return `agents.${agent} is bundled_not_installed; /goal can continue through PM fallback, but dedicated ${agentLabel} delegation is unavailable until installed. If dedicated agents are required before /goal, run: npx goalbuddy agents`;
|
|
222
|
+
}
|
|
223
|
+
if (status === "missing") {
|
|
224
|
+
return `agents.${agent} is missing; /goal can continue through PM fallback, but dedicated ${agentLabel} delegation is unavailable. If dedicated agents are required before /goal, run: npx goalbuddy install`;
|
|
225
|
+
}
|
|
226
|
+
return `agents.${agent} is unknown; /goal can continue through PM fallback, but dedicated ${agentLabel} delegation was not verified. To check before /goal, run: npx goalbuddy doctor`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const { agent, status } of agentStatuses) {
|
|
230
|
+
if (!allowedAgentStatuses.has(status)) {
|
|
231
|
+
errors.push(`agents.${agent} must be one of installed, bundled_not_installed, missing, or unknown; got ${status || "<missing>"}`);
|
|
232
|
+
} else if (status !== "installed") {
|
|
233
|
+
warnings.push(agentStatusWarning(agent, status));
|
|
220
234
|
}
|
|
221
235
|
}
|
|
222
236
|
|
|
@@ -227,7 +241,7 @@ if (!existsSync(join(root, "notes")) || !statSync(join(root, "notes")).isDirecto
|
|
|
227
241
|
|
|
228
242
|
const unexpected = rootEntryErrors();
|
|
229
243
|
if (unexpected.length > 0) {
|
|
230
|
-
errors.push(`unexpected root entries; v2 goal roots may contain only goal.md, state.yaml, and
|
|
244
|
+
errors.push(`unexpected root entries; v2 goal roots may contain only goal.md, state.yaml, notes/, and .goalbuddy-board/: ${unexpected.join(", ")}`);
|
|
231
245
|
}
|
|
232
246
|
|
|
233
247
|
const tasks = parseTasks();
|
|
@@ -361,6 +375,7 @@ const result = {
|
|
|
361
375
|
state_path: statePath,
|
|
362
376
|
goal_status: goalStatus,
|
|
363
377
|
active_task: activeTask,
|
|
378
|
+
agent_statuses: Object.fromEntries(agentStatuses.map(({ agent, status }) => [agent, status])),
|
|
364
379
|
task_count: tasks.length,
|
|
365
380
|
errors,
|
|
366
381
|
warnings,
|
|
@@ -35,9 +35,24 @@ rules:
|
|
|
35
35
|
intake_misfire_must_be_audited: true
|
|
36
36
|
|
|
37
37
|
agents:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
# installed | bundled_not_installed | missing | unknown
|
|
39
|
+
# If non-installed, /goal can continue through PM fallback; install agents before /goal only when dedicated delegation is required.
|
|
40
|
+
scout: unknown
|
|
41
|
+
worker: unknown
|
|
42
|
+
judge: unknown
|
|
43
|
+
|
|
44
|
+
visual_board:
|
|
45
|
+
# none | local | github_projects | both | unknown
|
|
46
|
+
selected: unknown
|
|
47
|
+
local:
|
|
48
|
+
status: not_requested # not_requested | starting | live | generated | blocked
|
|
49
|
+
url: null
|
|
50
|
+
command: "npx goalbuddy board docs/goals/<goal-slug>"
|
|
51
|
+
github_projects:
|
|
52
|
+
status: not_requested # not_requested | needs_approval | dry_run_ready | synced | blocked
|
|
53
|
+
url: null
|
|
54
|
+
command: "npx goalbuddy extend github-projects"
|
|
55
|
+
missing: []
|
|
41
56
|
|
|
42
57
|
active_task: T001
|
|
43
58
|
|