goalbuddy 0.2.20 → 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.
Files changed (47) hide show
  1. package/README.md +16 -9
  2. package/goalbuddy/SKILL.md +77 -10
  3. package/goalbuddy/extend/github-projects/README.md +105 -0
  4. package/goalbuddy/extend/github-projects/examples/goal-board-sync/state.yaml +63 -0
  5. package/goalbuddy/extend/github-projects/extension.yaml +43 -0
  6. package/goalbuddy/extend/github-projects/scripts/lib/github-projects.mjs +728 -0
  7. package/goalbuddy/extend/github-projects/scripts/lib/goal-state.mjs +362 -0
  8. package/goalbuddy/extend/github-projects/scripts/sync-github-project.mjs +193 -0
  9. package/goalbuddy/extend/github-projects/test/github-projects.test.mjs +267 -0
  10. package/goalbuddy/extend/local-goal-board/README.md +75 -0
  11. package/goalbuddy/extend/local-goal-board/assets/goalbuddy-mark.png +0 -0
  12. package/goalbuddy/extend/local-goal-board/examples/sample-goal/notes/T001-scout.md +3 -0
  13. package/goalbuddy/extend/local-goal-board/examples/sample-goal/state.yaml +124 -0
  14. package/goalbuddy/extend/local-goal-board/extension.yaml +37 -0
  15. package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1225 -0
  16. package/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +258 -0
  17. package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +146 -0
  18. package/goalbuddy/scripts/check-goal-state.mjs +24 -9
  19. package/goalbuddy/scripts/check-update.mjs +102 -0
  20. package/goalbuddy/templates/goal.md +12 -8
  21. package/goalbuddy/templates/state.yaml +18 -3
  22. package/internal/assets/goalbuddy-og.png +0 -0
  23. package/internal/assets/goalbuddy-readme-hero.png +0 -0
  24. package/internal/cli/goal-maker.mjs +191 -13
  25. package/package.json +3 -2
  26. package/plugins/goalbuddy/.codex-plugin/plugin.json +3 -3
  27. package/plugins/goalbuddy/README.md +2 -5
  28. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +77 -10
  29. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/README.md +105 -0
  30. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/examples/goal-board-sync/state.yaml +63 -0
  31. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/extension.yaml +43 -0
  32. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/lib/github-projects.mjs +728 -0
  33. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/lib/goal-state.mjs +362 -0
  34. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/scripts/sync-github-project.mjs +193 -0
  35. package/plugins/goalbuddy/skills/goalbuddy/extend/github-projects/test/github-projects.test.mjs +267 -0
  36. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/README.md +75 -0
  37. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/assets/goalbuddy-mark.png +0 -0
  38. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/sample-goal/notes/T001-scout.md +3 -0
  39. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/examples/sample-goal/state.yaml +124 -0
  40. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/extension.yaml +37 -0
  41. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +1225 -0
  42. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/local-goal-board.mjs +258 -0
  43. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +146 -0
  44. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +24 -9
  45. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-update.mjs +102 -0
  46. package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +12 -8
  47. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +18 -3
@@ -0,0 +1,362 @@
1
+ import { readFile } from "node:fs/promises";
2
+
3
+ const VALID_STATUSES = new Set(["queued", "active", "blocked", "done"]);
4
+ const VALID_PRIORITIES = new Set(["P0", "P1", "P2", "P3"]);
5
+
6
+ export class GoalStateError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "GoalStateError";
10
+ }
11
+ }
12
+
13
+ export async function loadGoalBoard(path) {
14
+ const text = await readFile(path, "utf8");
15
+ return normalizeGoalBoard(parseGoalStateText(text), path);
16
+ }
17
+
18
+ export function parseGoalStateText(text) {
19
+ const lines = tokenizeYaml(text);
20
+ if (!lines.length) {
21
+ throw new GoalStateError("Goal state is empty.");
22
+ }
23
+
24
+ const [value, nextIndex] = parseBlock(lines, 0, lines[0].indent);
25
+ if (nextIndex < lines.length) {
26
+ throw new GoalStateError(`Could not parse line ${lines[nextIndex].number}.`);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ export function normalizeGoalBoard(document, sourcePath = "<memory>") {
32
+ if (!document || typeof document !== "object" || Array.isArray(document)) {
33
+ throw new GoalStateError("Goal state must be a YAML mapping.");
34
+ }
35
+ if (Number(document.version) !== 2) {
36
+ throw new GoalStateError("Only GoalBuddy v2 state.yaml files are supported.");
37
+ }
38
+ if (!document.goal || typeof document.goal !== "object") {
39
+ throw new GoalStateError("Missing goal metadata.");
40
+ }
41
+ if (!Array.isArray(document.tasks) || document.tasks.length === 0) {
42
+ throw new GoalStateError("Missing non-empty tasks list.");
43
+ }
44
+
45
+ const goal = document.goal;
46
+ const tasks = document.tasks.map((task, index) => normalizeTask(task, index));
47
+ const activeTasks = tasks.filter((task) => task.status === "active");
48
+ if (activeTasks.length > 1) {
49
+ throw new GoalStateError("Goal state has more than one active task.");
50
+ }
51
+
52
+ return {
53
+ sourcePath,
54
+ title: cleanText(goal.title || "Untitled goal"),
55
+ slug: cleanText(goal.slug || "untitled-goal"),
56
+ kind: cleanText(goal.kind || "open_ended"),
57
+ tranche: cleanText(goal.tranche || ""),
58
+ status: cleanText(goal.status || "active"),
59
+ activeTask: cleanText(document.active_task || activeTasks[0]?.id || ""),
60
+ tasks,
61
+ };
62
+ }
63
+
64
+ function normalizeTask(task, index) {
65
+ if (!task || typeof task !== "object" || Array.isArray(task)) {
66
+ throw new GoalStateError(`Task ${index + 1} must be a mapping.`);
67
+ }
68
+
69
+ const id = cleanText(task.id);
70
+ const status = cleanText(task.status);
71
+ if (!id) {
72
+ throw new GoalStateError(`Task ${index + 1} is missing id.`);
73
+ }
74
+ if (!VALID_STATUSES.has(status)) {
75
+ throw new GoalStateError(`Task ${id} has unsupported status "${status}".`);
76
+ }
77
+
78
+ return {
79
+ id,
80
+ title: titleForTask(task),
81
+ objective: cleanText(task.objective || ""),
82
+ status,
83
+ priority: normalizePriority(task.priority, status),
84
+ type: cleanText(task.type || "pm"),
85
+ assignee: cleanText(task.assignee || ""),
86
+ goalRole: goalRoleForTask(task),
87
+ agentResponsible: cleanText(task.assignee || ""),
88
+ credentialGate: credentialGateForTask(task),
89
+ receiptSummary: summarizeReceipt(task.receipt),
90
+ allowedFiles: normalizeStringList(task.allowed_files),
91
+ verify: normalizeStringList(task.verify),
92
+ parentId: cleanText(task.parent || task.parent_id || ""),
93
+ dependsOn: normalizeStringList(task.depends_on || task.blocked_by),
94
+ updatedLabel: task.receipt ? `receipt:${cleanText(task.receipt.result || "present")}` : "receipt:none",
95
+ };
96
+ }
97
+
98
+ function goalRoleForTask(task) {
99
+ const assignee = cleanText(task.assignee);
100
+ if (assignee) return assignee;
101
+
102
+ const type = cleanText(task.type).toLowerCase();
103
+ if (type === "scout") return "Scout";
104
+ if (type === "judge") return "Judge";
105
+ if (type === "worker") return "Worker";
106
+ return "PM";
107
+ }
108
+
109
+ function credentialGateForTask(task) {
110
+ const text = [
111
+ ...normalizeStringList(task.inputs),
112
+ ...normalizeStringList(task.constraints),
113
+ ...normalizeStringList(task.stop_if),
114
+ cleanText(task.objective),
115
+ ].join(" ").toLowerCase();
116
+
117
+ if (/slack/.test(text)) return "Slack token";
118
+ if (/linear/.test(text)) return "Linear API key";
119
+ if (/credential|token|api key|secret/.test(text)) return "Credentials";
120
+ if (/approval|approve/.test(text)) return "Approval";
121
+ if (/external|production|github|project/.test(text)) return "External access";
122
+ return "None";
123
+ }
124
+
125
+ function normalizePriority(priority, status) {
126
+ const normalized = cleanText(priority).toUpperCase();
127
+ if (normalized) {
128
+ if (!VALID_PRIORITIES.has(normalized)) {
129
+ throw new GoalStateError(`Unsupported priority "${priority}". Use P0, P1, P2, or P3.`);
130
+ }
131
+ return normalized;
132
+ }
133
+ if (status === "blocked") return "P0";
134
+ if (status === "active") return "P1";
135
+ if (status === "done") return "P3";
136
+ return "P2";
137
+ }
138
+
139
+ function titleForTask(task) {
140
+ const objective = cleanText(task.objective || "Untitled task");
141
+ return objective.replace(/\.$/, "");
142
+ }
143
+
144
+ function summarizeReceipt(receipt) {
145
+ if (!receipt) return "";
146
+ if (typeof receipt === "string") return cleanText(receipt);
147
+ if (Array.isArray(receipt) || typeof receipt !== "object") return cleanText(receipt);
148
+
149
+ if (receipt.summary) return cleanText(receipt.summary);
150
+ if (receipt.decision) return cleanText(receipt.decision);
151
+ if (receipt.note) return `See ${cleanText(receipt.note)}`;
152
+ if (receipt.result) return `Result: ${cleanText(receipt.result)}`;
153
+ return "";
154
+ }
155
+
156
+ function normalizeStringList(value) {
157
+ if (!value) return [];
158
+ if (Array.isArray(value)) return value.map(cleanText).filter(Boolean);
159
+ return [cleanText(value)].filter(Boolean);
160
+ }
161
+
162
+ function cleanText(value) {
163
+ return String(value ?? "").trim();
164
+ }
165
+
166
+ function tokenizeYaml(text) {
167
+ return text
168
+ .replace(/\r\n/g, "\n")
169
+ .split("\n")
170
+ .map((raw, index) => {
171
+ const withoutComments = stripComment(raw).replace(/\s+$/, "");
172
+ if (!withoutComments.trim()) return null;
173
+ const indent = withoutComments.match(/^ */)[0].length;
174
+ if (indent % 2 !== 0) {
175
+ throw new GoalStateError(`Unsupported odd indentation at line ${index + 1}.`);
176
+ }
177
+ return {
178
+ number: index + 1,
179
+ indent,
180
+ text: withoutComments.trimStart(),
181
+ };
182
+ })
183
+ .filter(Boolean);
184
+ }
185
+
186
+ function stripComment(line) {
187
+ let quote = null;
188
+ for (let index = 0; index < line.length; index += 1) {
189
+ const char = line[index];
190
+ const previous = line[index - 1];
191
+ if ((char === "\"" || char === "'") && previous !== "\\") {
192
+ quote = quote === char ? null : quote || char;
193
+ continue;
194
+ }
195
+ if (char === "#" && !quote && (index === 0 || /\s/.test(previous))) {
196
+ return line.slice(0, index);
197
+ }
198
+ }
199
+ return line;
200
+ }
201
+
202
+ function parseBlock(lines, index, indent) {
203
+ if (index >= lines.length) return [{}, index];
204
+ if (lines[index].indent < indent) return [{}, index];
205
+ if (lines[index].text.startsWith("- ")) {
206
+ return parseArray(lines, index, indent);
207
+ }
208
+ return parseObject(lines, index, indent);
209
+ }
210
+
211
+ function parseObject(lines, index, indent) {
212
+ const object = {};
213
+
214
+ while (index < lines.length) {
215
+ const line = lines[index];
216
+ if (line.indent < indent) break;
217
+ if (line.indent !== indent || line.text.startsWith("- ")) break;
218
+
219
+ const { key, valueText } = splitKeyValue(line);
220
+ index += 1;
221
+
222
+ if (valueText === "") {
223
+ if (index < lines.length && lines[index].indent > indent) {
224
+ const [child, nextIndex] = parseBlock(lines, index, lines[index].indent);
225
+ object[key] = child;
226
+ index = nextIndex;
227
+ } else {
228
+ object[key] = {};
229
+ }
230
+ } else {
231
+ object[key] = parseScalar(valueText);
232
+ }
233
+ }
234
+
235
+ return [object, index];
236
+ }
237
+
238
+ function parseArray(lines, index, indent) {
239
+ const array = [];
240
+
241
+ while (index < lines.length) {
242
+ const line = lines[index];
243
+ if (line.indent !== indent || !line.text.startsWith("- ")) break;
244
+
245
+ const content = line.text.slice(2).trim();
246
+ index += 1;
247
+
248
+ if (content === "") {
249
+ if (index < lines.length && lines[index].indent > indent) {
250
+ const [child, nextIndex] = parseBlock(lines, index, lines[index].indent);
251
+ array.push(child);
252
+ index = nextIndex;
253
+ } else {
254
+ array.push(null);
255
+ }
256
+ continue;
257
+ }
258
+
259
+ if (isInlineMapping(content)) {
260
+ const object = {};
261
+ const { key, valueText } = splitKeyValue({ text: content, number: line.number });
262
+ if (valueText === "") {
263
+ if (index < lines.length && lines[index].indent > indent) {
264
+ const [child, nextIndex] = parseBlock(lines, index, lines[index].indent);
265
+ object[key] = child;
266
+ index = nextIndex;
267
+ } else {
268
+ object[key] = {};
269
+ }
270
+ } else {
271
+ object[key] = parseScalar(valueText);
272
+ }
273
+
274
+ if (index < lines.length && lines[index].indent > indent) {
275
+ const [child, nextIndex] = parseBlock(lines, index, lines[index].indent);
276
+ if (child && typeof child === "object" && !Array.isArray(child)) {
277
+ Object.assign(object, child);
278
+ } else {
279
+ throw new GoalStateError(`Expected mapping below line ${line.number}.`);
280
+ }
281
+ index = nextIndex;
282
+ }
283
+ array.push(object);
284
+ } else {
285
+ array.push(parseScalar(content));
286
+ }
287
+ }
288
+
289
+ return [array, index];
290
+ }
291
+
292
+ function splitKeyValue(line) {
293
+ const separator = line.text.indexOf(":");
294
+ if (separator <= 0) {
295
+ throw new GoalStateError(`Expected key/value pair at line ${line.number}.`);
296
+ }
297
+ return {
298
+ key: line.text.slice(0, separator).trim(),
299
+ valueText: line.text.slice(separator + 1).trim(),
300
+ };
301
+ }
302
+
303
+ function isInlineMapping(text) {
304
+ return /^[A-Za-z0-9_.-]+:\s*/.test(text);
305
+ }
306
+
307
+ function parseScalar(text) {
308
+ if (text === "[]") return [];
309
+ if (text === "{}") return {};
310
+ if (text === "null" || text === "~") return null;
311
+ if (text === "true") return true;
312
+ if (text === "false") return false;
313
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
314
+ if (text.startsWith("[") && text.endsWith("]")) {
315
+ const inner = text.slice(1, -1).trim();
316
+ if (!inner) return [];
317
+ return splitInlineArray(inner).map(parseScalar);
318
+ }
319
+ if (
320
+ (text.startsWith("\"") && text.endsWith("\"")) ||
321
+ (text.startsWith("'") && text.endsWith("'"))
322
+ ) {
323
+ return unquote(text);
324
+ }
325
+ if (text === "|" || text === ">") {
326
+ throw new GoalStateError("Block scalar YAML is not supported by this lightweight parser.");
327
+ }
328
+ return text;
329
+ }
330
+
331
+ function unquote(text) {
332
+ if (text.startsWith("'")) {
333
+ return text.slice(1, -1).replace(/''/g, "'");
334
+ }
335
+ return text
336
+ .slice(1, -1)
337
+ .replace(/\\"/g, "\"")
338
+ .replace(/\\n/g, "\n")
339
+ .replace(/\\\\/g, "\\");
340
+ }
341
+
342
+ function splitInlineArray(text) {
343
+ const values = [];
344
+ let quote = null;
345
+ let start = 0;
346
+
347
+ for (let index = 0; index < text.length; index += 1) {
348
+ const char = text[index];
349
+ const previous = text[index - 1];
350
+ if ((char === "\"" || char === "'") && previous !== "\\") {
351
+ quote = quote === char ? null : quote || char;
352
+ continue;
353
+ }
354
+ if (char === "," && !quote) {
355
+ values.push(text.slice(start, index).trim());
356
+ start = index + 1;
357
+ }
358
+ }
359
+
360
+ values.push(text.slice(start).trim());
361
+ return values;
362
+ }
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env node
2
+ import { resolve } from "node:path";
3
+ import { loadGoalBoard } from "./lib/goal-state.mjs";
4
+ import {
5
+ GITHUB_PROJECT_FIELDS,
6
+ GITHUB_PROJECT_VIEWS,
7
+ GitHubProjectsClient,
8
+ dryRunGitHubOperations,
9
+ ensureGoalProjectFields,
10
+ ensureGoalProjectViews,
11
+ executeGitHubProjectSync,
12
+ loadProject,
13
+ } from "./lib/github-projects.mjs";
14
+
15
+ main().catch((error) => {
16
+ console.error(`GitHub project sync failed: ${error.message}`);
17
+ process.exitCode = 1;
18
+ });
19
+
20
+ async function main() {
21
+ const options = parseArgs(process.argv.slice(2));
22
+ if (options.help) {
23
+ printUsage();
24
+ return;
25
+ }
26
+ if (!options.state) {
27
+ throw new Error("Missing --state docs/goals/<slug>/state.yaml.");
28
+ }
29
+
30
+ const board = await loadGoalBoard(resolve(options.state));
31
+ board.sourcePath = options.state;
32
+ board.json = options.json;
33
+
34
+ if (options.dryRun) {
35
+ printDryRun(board);
36
+ return;
37
+ }
38
+
39
+ const projectRef = resolveProjectRef(options);
40
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
41
+ if (!token) {
42
+ throw new Error("Missing GITHUB_TOKEN or GH_TOKEN. Use --dry-run to validate without GitHub credentials.");
43
+ }
44
+
45
+ const client = new GitHubProjectsClient({ token });
46
+ const project = await loadProject({
47
+ client,
48
+ projectId: projectRef.projectId,
49
+ owner: projectRef.owner,
50
+ number: projectRef.number,
51
+ });
52
+ const fields = await ensureGoalProjectFields(client, project);
53
+ const projectViews = await ensureGoalProjectViews({ client, project, fields });
54
+ const operations = await executeGitHubProjectSync({
55
+ client,
56
+ project,
57
+ fields,
58
+ tasks: board.tasks,
59
+ board,
60
+ });
61
+
62
+ const created = operations.filter((operation) => operation.type === "create").length;
63
+ const updated = operations.filter((operation) => operation.type === "update").length;
64
+ console.log(`Synced ${board.tasks.length} tasks to GitHub Project "${project.title}": ${created} created, ${updated} updated.`);
65
+ for (const [key, view] of Object.entries(projectViews)) {
66
+ const spec = GITHUB_PROJECT_VIEWS[key];
67
+ const viewUrl = view.html_url || (project.url && view.number ? `${project.url}/views/${view.number}` : "");
68
+ if (viewUrl) {
69
+ console.log(`${spec.name} view: ${viewUrl}`);
70
+ }
71
+ }
72
+ if (project.url) {
73
+ console.log(project.url);
74
+ }
75
+ }
76
+
77
+ function parseArgs(args) {
78
+ const options = {
79
+ state: "",
80
+ projectId: process.env.GITHUB_PROJECT_ID || "",
81
+ owner: process.env.GITHUB_PROJECT_OWNER || "",
82
+ number: process.env.GITHUB_PROJECT_NUMBER || "",
83
+ dryRun: false,
84
+ json: false,
85
+ help: false,
86
+ };
87
+
88
+ for (let index = 0; index < args.length; index += 1) {
89
+ const arg = args[index];
90
+ if (arg === "--help" || arg === "-h") {
91
+ options.help = true;
92
+ } else if (arg === "--dry-run") {
93
+ options.dryRun = true;
94
+ } else if (arg === "--json") {
95
+ options.json = true;
96
+ } else if (arg === "--state") {
97
+ options.state = args[index + 1] || "";
98
+ index += 1;
99
+ } else if (arg.startsWith("--state=")) {
100
+ options.state = arg.slice("--state=".length);
101
+ } else if (arg === "--project-id") {
102
+ options.projectId = args[index + 1] || "";
103
+ index += 1;
104
+ } else if (arg.startsWith("--project-id=")) {
105
+ options.projectId = arg.slice("--project-id=".length);
106
+ } else if (arg === "--owner") {
107
+ options.owner = args[index + 1] || "";
108
+ index += 1;
109
+ } else if (arg.startsWith("--owner=")) {
110
+ options.owner = arg.slice("--owner=".length);
111
+ } else if (arg === "--project-number") {
112
+ options.number = args[index + 1] || "";
113
+ index += 1;
114
+ } else if (arg.startsWith("--project-number=")) {
115
+ options.number = arg.slice("--project-number=".length);
116
+ } else {
117
+ throw new Error(`Unknown argument: ${arg}`);
118
+ }
119
+ }
120
+
121
+ return options;
122
+ }
123
+
124
+ function resolveProjectRef(options) {
125
+ if (options.projectId) {
126
+ return { projectId: options.projectId };
127
+ }
128
+
129
+ if (!options.owner || !options.number) {
130
+ throw new Error("Missing GitHub Project target. Set --project-id or --owner plus --project-number.");
131
+ }
132
+
133
+ const number = Number(options.number);
134
+ if (!Number.isInteger(number) || number <= 0) {
135
+ throw new Error("--project-number must be a positive integer.");
136
+ }
137
+
138
+ return {
139
+ owner: options.owner,
140
+ number,
141
+ };
142
+ }
143
+
144
+ function printUsage() {
145
+ console.log(`Usage:
146
+ node extend/github-projects/scripts/sync-github-project.mjs --state docs/goals/<slug>/state.yaml --owner <login> --project-number <number>
147
+ node extend/github-projects/scripts/sync-github-project.mjs --state docs/goals/<slug>/state.yaml --project-id <project-node-id>
148
+ node extend/github-projects/scripts/sync-github-project.mjs --state docs/goals/<slug>/state.yaml --dry-run
149
+ node extend/github-projects/scripts/sync-github-project.mjs --state docs/goals/<slug>/state.yaml --dry-run --json
150
+
151
+ Environment:
152
+ GITHUB_TOKEN or GH_TOKEN Required unless --dry-run is used.
153
+ GITHUB_PROJECT_ID Optional ProjectV2 node ID.
154
+ GITHUB_PROJECT_OWNER Optional user/org login.
155
+ GITHUB_PROJECT_NUMBER Optional project number.
156
+ `);
157
+ }
158
+
159
+ function printDryRun(board) {
160
+ if (board.json) {
161
+ console.log(JSON.stringify({
162
+ dry_run: true,
163
+ source: board.sourcePath,
164
+ title: board.title,
165
+ slug: board.slug,
166
+ fields: Object.values(GITHUB_PROJECT_FIELDS),
167
+ views: Object.values(GITHUB_PROJECT_VIEWS).map((view) => ({
168
+ name: view.name,
169
+ layout: view.layout,
170
+ visible_fields: view.fields.map((field) => GITHUB_PROJECT_FIELDS[field]),
171
+ })),
172
+ view: "Goal Board",
173
+ status_mapping: {
174
+ queued: "Todo",
175
+ active: "In Progress",
176
+ blocked: "Blocked",
177
+ done: "Done",
178
+ },
179
+ operations: dryRunGitHubOperations(board),
180
+ }, null, 2));
181
+ return;
182
+ }
183
+
184
+ console.log(`Dry run for ${board.title} (${board.slug})`);
185
+ console.log(`Source: ${board.sourcePath}`);
186
+ console.log("GitHub Project fields that will be ensured: Task ID, Status, Priority, Work Type, Owner, Goal Role, Agent Responsible, Agent Lane, Credential Gate, Parent ID, Depends On, Receipt Summary, Verify, Allowed Files, Goal Updated");
187
+ console.log("GitHub Project views that will be ensured: Goal Board (Board layout)");
188
+ console.log("Status mapping: queued -> Todo, active -> In Progress, blocked -> Blocked, done -> Done");
189
+ for (const operation of dryRunGitHubOperations(board)) {
190
+ console.log(`UPSERT ${operation.taskId} ${operation.status} -> ${operation.projectStatus} ${operation.priority} ${operation.typeLabel} ${operation.title}`);
191
+ }
192
+ console.log(`Planned ${board.tasks.length} draft issue upserts. GitHub was not called.`);
193
+ }