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
|
@@ -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
|
+
}
|
|
@@ -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
|
|
|
@@ -33,6 +33,7 @@ const requiredAgentFiles = [
|
|
|
33
33
|
"goal_scout.toml",
|
|
34
34
|
"goal_worker.toml",
|
|
35
35
|
];
|
|
36
|
+
const bundledCoreExtensionIds = new Set(["github-projects", "local-goal-board"]);
|
|
36
37
|
const optionsWithValues = new Set([
|
|
37
38
|
"--catalog",
|
|
38
39
|
"--catalog-url",
|
|
@@ -459,9 +460,9 @@ function installPlugin() {
|
|
|
459
460
|
console.log("Restart Codex, then use:");
|
|
460
461
|
console.log(` $${canonicalSkillName}`);
|
|
461
462
|
console.log("");
|
|
462
|
-
console.log("
|
|
463
|
-
console.log(` npx ${canonicalCliName}
|
|
464
|
-
console.log(` npx ${canonicalCliName} extend
|
|
463
|
+
console.log("Bundled visual boards:");
|
|
464
|
+
console.log(` npx ${canonicalCliName} board docs/goals/<slug>`);
|
|
465
|
+
console.log(` npx ${canonicalCliName} extend github-projects`);
|
|
465
466
|
}
|
|
466
467
|
|
|
467
468
|
function pluginCacheRoot(version) {
|
|
@@ -527,9 +528,12 @@ function codexGoalRuntimeStatus() {
|
|
|
527
528
|
}
|
|
528
529
|
|
|
529
530
|
function runCodex(args) {
|
|
530
|
-
const
|
|
531
|
+
const env = { ...process.env, CODEX_HOME: codexHome() };
|
|
532
|
+
const command = codexSpawnCommand(args, env);
|
|
533
|
+
const result = spawnSync(command.file, command.args, {
|
|
531
534
|
encoding: "utf8",
|
|
532
|
-
env
|
|
535
|
+
env,
|
|
536
|
+
shell: command.shell || false,
|
|
533
537
|
});
|
|
534
538
|
return {
|
|
535
539
|
ok: result.status === 0,
|
|
@@ -539,6 +543,38 @@ function runCodex(args) {
|
|
|
539
543
|
};
|
|
540
544
|
}
|
|
541
545
|
|
|
546
|
+
function codexSpawnCommand(args, env) {
|
|
547
|
+
if (process.platform !== "win32") return { file: "codex", args };
|
|
548
|
+
|
|
549
|
+
const command = resolveWindowsCommand("codex", env);
|
|
550
|
+
if (!command) return { file: "codex", args };
|
|
551
|
+
if (/\.(?:cmd|bat)$/i.test(command)) {
|
|
552
|
+
const commandLine = [quoteWindowsCommandArg(command), ...args.map(quoteWindowsCommandArg)].join(" ");
|
|
553
|
+
return {
|
|
554
|
+
file: commandLine,
|
|
555
|
+
args: [],
|
|
556
|
+
shell: true,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
return { file: command, args };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function resolveWindowsCommand(name, env) {
|
|
563
|
+
const systemWhere = env.SystemRoot ? join(env.SystemRoot, "System32", "where.exe") : "";
|
|
564
|
+
const whereCommand = systemWhere && existsSync(systemWhere) ? systemWhere : "where.exe";
|
|
565
|
+
const where = spawnSync(whereCommand, [name], { encoding: "utf8", env });
|
|
566
|
+
if (where.status !== 0) return "";
|
|
567
|
+
const candidates = where.stdout
|
|
568
|
+
.split(/\r?\n/)
|
|
569
|
+
.map((line) => line.trim())
|
|
570
|
+
.filter(Boolean);
|
|
571
|
+
return candidates.find((candidate) => /\.(?:exe|cmd|bat)$/i.test(candidate)) || "";
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function quoteWindowsCommandArg(value) {
|
|
575
|
+
return `"${String(value).replace(/(["^&|<>()%])/g, "^$1")}"`;
|
|
576
|
+
}
|
|
577
|
+
|
|
542
578
|
function parseGoalFeature(output) {
|
|
543
579
|
const line = output.split(/\r?\n/).find((candidate) => candidate.trim().startsWith("goals"));
|
|
544
580
|
if (!line) return { enabled: false, stage: "" };
|
|
@@ -788,6 +824,11 @@ async function extendInstall() {
|
|
|
788
824
|
async function extendInstallAll(catalog) {
|
|
789
825
|
const results = [];
|
|
790
826
|
for (const extension of catalog.extensions) {
|
|
827
|
+
if (existsSync(extensionTarget(extension.id)) && !hasFlag("--force")) {
|
|
828
|
+
validateCatalogExtension(extension);
|
|
829
|
+
results.push({ extension, target: extensionTarget(extension.id), plan: installPlan(catalog, extension, extensionTarget(extension.id)), skipped: true });
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
791
832
|
results.push(await installCatalogExtension(catalog, extension));
|
|
792
833
|
}
|
|
793
834
|
|
|
@@ -815,11 +856,13 @@ async function extendInstallAll(catalog) {
|
|
|
815
856
|
printJson({
|
|
816
857
|
installed: true,
|
|
817
858
|
count: results.length,
|
|
818
|
-
extensions: results.map(({ extension, target }) => ({ id: extension.id, target })),
|
|
859
|
+
extensions: results.map(({ extension, target, skipped }) => ({ id: extension.id, target, skipped: Boolean(skipped) })),
|
|
819
860
|
});
|
|
820
861
|
} else {
|
|
821
|
-
|
|
822
|
-
|
|
862
|
+
const installedCount = results.filter((result) => !result.skipped).length;
|
|
863
|
+
const skippedCount = results.length - installedCount;
|
|
864
|
+
console.log(`Installed ${installedCount} extensions${skippedCount ? `, skipped ${skippedCount} already installed` : ""}`);
|
|
865
|
+
for (const { extension, target, skipped } of results) console.log(` ${extension.id} -> ${target}${skipped ? " (already installed)" : ""}`);
|
|
823
866
|
}
|
|
824
867
|
}
|
|
825
868
|
|
|
@@ -1067,6 +1110,7 @@ function preserveInstalledExtensions(targets) {
|
|
|
1067
1110
|
if (!existsSync(source)) continue;
|
|
1068
1111
|
mkdirSync(tempPath, { recursive: true });
|
|
1069
1112
|
for (const entry of readdirSync(source, { withFileTypes: true })) {
|
|
1113
|
+
if (bundledCoreExtensionIds.has(entry.name)) continue;
|
|
1070
1114
|
const from = join(source, entry.name);
|
|
1071
1115
|
const to = join(tempPath, entry.name);
|
|
1072
1116
|
cpSync(from, to, { recursive: true, force: true });
|
|
@@ -1080,9 +1124,11 @@ function preserveInstalledExtensions(targets) {
|
|
|
1080
1124
|
|
|
1081
1125
|
function restoreInstalledExtensions(target, tempPath) {
|
|
1082
1126
|
if (!tempPath) return;
|
|
1083
|
-
|
|
1084
|
-
mkdirSync(
|
|
1085
|
-
|
|
1127
|
+
const destinationRoot = join(target, "extend");
|
|
1128
|
+
mkdirSync(destinationRoot, { recursive: true });
|
|
1129
|
+
for (const entry of readdirSync(tempPath, { withFileTypes: true })) {
|
|
1130
|
+
cpSync(join(tempPath, entry.name), join(destinationRoot, entry.name), { recursive: true, force: true });
|
|
1131
|
+
}
|
|
1086
1132
|
}
|
|
1087
1133
|
|
|
1088
1134
|
function cleanupPreservedExtensions(paths) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "goalbuddy",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Turn open-ended Codex goals into
|
|
3
|
+
"version": "0.2.22",
|
|
4
|
+
"description": "Turn open-ended Codex goals into GoalBuddy Scout/Judge/Worker boards with visual board surfaces, receipts, and verification.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"goalbuddy": "internal/cli/goal-maker.mjs",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"goalbuddy/SKILL.md",
|
|
19
19
|
"goalbuddy/agents",
|
|
20
20
|
"goalbuddy/scripts",
|
|
21
|
+
"goalbuddy/extend",
|
|
21
22
|
"goalbuddy/templates"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "goalbuddy",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Turn broad Codex work into verified GoalBuddy boards with Scout, Judge, Worker,
|
|
3
|
+
"version": "0.2.22",
|
|
4
|
+
"description": "Turn broad Codex work into verified GoalBuddy boards with Scout, Judge, Worker, visual boards, and receipts.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tolibear",
|
|
7
7
|
"email": "support@tolibear.com",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"interface": {
|
|
25
25
|
"displayName": "GoalBuddy",
|
|
26
26
|
"shortDescription": "Turn broad Codex work into verified Scout/Judge/Worker boards",
|
|
27
|
-
"longDescription": "GoalBuddy packages a structured Codex goal workflow for broad, long-running, or ambiguous engineering work. It creates durable goal charters, task boards, receipts, verification gates, extension handoffs, and compatibility guidance for teams moving from goal-maker.",
|
|
27
|
+
"longDescription": "GoalBuddy packages a structured Codex goal workflow for broad, long-running, or ambiguous engineering work. It creates durable goal charters, task boards, visual board surfaces, receipts, verification gates, extension handoffs, and compatibility guidance for teams moving from goal-maker.",
|
|
28
28
|
"developerName": "tolibear",
|
|
29
29
|
"category": "Coding",
|
|
30
30
|
"capabilities": [
|