goalbuddy 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +29 -4
  2. package/goalbuddy/SKILL.md +34 -13
  3. package/goalbuddy/agents/README.md +1 -1
  4. package/goalbuddy/agents/goal_judge.toml +8 -4
  5. package/goalbuddy/agents/goal_worker.toml +8 -5
  6. package/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +248 -7
  7. package/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +59 -1
  8. package/goalbuddy/scripts/check-goal-state.mjs +76 -0
  9. package/goalbuddy/scripts/render-task-prompt.mjs +61 -4
  10. package/goalbuddy/templates/agents.md +3 -2
  11. package/goalbuddy/templates/goal.md +18 -4
  12. package/goalbuddy/templates/state.yaml +6 -1
  13. package/internal/cli/goal-maker.mjs +108 -8
  14. package/package.json +1 -1
  15. package/plugins/goalbuddy/.claude-plugin/plugin.json +1 -1
  16. package/plugins/goalbuddy/.codex-plugin/plugin.json +1 -1
  17. package/plugins/goalbuddy/agents/goal-judge.md +8 -4
  18. package/plugins/goalbuddy/agents/goal-worker.md +6 -4
  19. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +34 -13
  20. package/plugins/goalbuddy/skills/goalbuddy/agents/README.md +1 -1
  21. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_judge.toml +8 -4
  22. package/plugins/goalbuddy/skills/goalbuddy/agents/goal_worker.toml +8 -5
  23. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/scripts/lib/goal-board.mjs +248 -7
  24. package/plugins/goalbuddy/skills/goalbuddy/extend/local-goal-board/test/local-goal-board.test.mjs +59 -1
  25. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +76 -0
  26. package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +61 -4
  27. package/plugins/goalbuddy/skills/goalbuddy/templates/agents.md +3 -2
  28. package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +18 -4
  29. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +6 -1
package/README.md CHANGED
@@ -44,6 +44,25 @@ In Claude Code, use:
44
44
 
45
45
  Goal Prep creates the board and prints the exact `/goal` command to run next. That is the whole path.
46
46
 
47
+ ## Codex Install Model
48
+
49
+ For Codex, the canonical install is the native plugin plus bundled agents:
50
+
51
+ ```text
52
+ ~/.codex/plugins/cache/goalbuddy/goalbuddy/<version>/
53
+ ~/.codex/agents/goal_judge.toml
54
+ ~/.codex/agents/goal_scout.toml
55
+ ~/.codex/agents/goal_worker.toml
56
+ ```
57
+
58
+ The Codex plugin bundles `$goal-prep`; a clean Codex install should not need personal `~/.codex/skills/goalbuddy` or `~/.codex/skills/goal-maker` folders. Native Codex `/goal` is a separate OpenAI-gated feature. GoalBuddy prepares local boards and handoff prompts for it, but it does not enable or replace native `/goal`.
59
+
60
+ To verify a Codex install:
61
+
62
+ ```bash
63
+ npx goalbuddy doctor --target codex --goal-ready
64
+ ```
65
+
47
66
  ## What It Creates
48
67
 
49
68
  ```text
@@ -70,12 +89,18 @@ rough idea -> goal prep -> /goal -> scout -> judge -> worker -> receipt -> verif
70
89
 
71
90
  Scout maps the repo.
72
91
 
73
- Judge chooses the next bounded slice.
92
+ Judge chooses the largest safe useful slice.
74
93
 
75
- Worker changes code and leaves a receipt.
94
+ Worker completes the whole assigned slice and leaves a receipt.
76
95
 
77
96
  `/goal` keeps the loop honest until the original goal is actually done.
78
97
 
98
+ ## Slice Sizing
99
+
100
+ Safe does not mean small. Safe means bounded, explicit, verified, and reversible.
101
+
102
+ GoalBuddy should not optimize for tiny safe tasks. It should optimize for the largest safe useful slice: a working screen, working API path, data pipeline step, backend vertical slice, real bug fix, or milestone review. The board warns when it sees safe-looking work that keeps adding helpers, contracts, proof files, or doc notes without moving the outcome.
103
+
79
104
  ## Subgoals, Parallel Agents, and Dark Mode
80
105
 
81
106
  GoalBuddy keeps the model small:
@@ -92,7 +117,7 @@ Use subgoals for bounded child work that belongs to a parent task. Use multiple
92
117
 
93
118
  GoalBuddy can prepare safe parallel work; it does not run a parallel org chart.
94
119
 
95
- Use `goalbuddy prompt docs/goals/<slug>` to render a compact prompt for the active task without dumping the whole state file. Use `goalbuddy parallel-plan docs/goals/<slug>` to inspect read-only or disjoint write-scope work that can be handed to native Codex or Claude Code agent flows. The command reports recommendations only; it does not mutate state or spawn agents.
120
+ Use `goalbuddy prompt docs/goals/<slug>` to render a compact prompt for the active task without dumping the whole state file. The prompt includes a mandatory `required_spawn_agent_type`; Codex PMs should use that exact GoalBuddy agent (`goal_scout`, `goal_worker`, or `goal_judge`) instead of a generic role agent. Use `goalbuddy parallel-plan docs/goals/<slug>` to inspect read-only or disjoint write-scope work that can be handed to native Codex or Claude Code agent flows. The command reports recommendations only; it does not mutate state or spawn agents.
96
121
 
97
122
  ## Update
98
123
 
@@ -108,7 +133,7 @@ That updates both Codex and Claude Code.
108
133
 
109
134
  GoalBuddy can open a local board while the work is running, so you can see the plan, active task, receipts, subgoals, and verification status without digging through the chat.
110
135
 
111
- Multiple local boards reuse one readable `goalbuddy.localhost` hub with an in-header board switcher. The viewer also supports dark mode, compact mode, completed-task collapse, active-work motion, and reduced-motion handling.
136
+ Multiple local boards reuse one readable `goalbuddy.localhost` hub with an in-header board switcher. When sharing a board in chat or docs, use a real Markdown link such as `[Open GoalBuddy board](http://goalbuddy.localhost:41737/<slug>/)` so the URL is clickable. The viewer also supports dark mode, compact mode, completed-task collapse, active-work motion, and reduced-motion handling.
112
137
 
113
138
  See [GoalBuddy 0.3.5: Subgoals, Parallel Agents, and Dark Mode](RELEASE-0.3.5.md) for the release notes.
114
139
 
@@ -85,7 +85,7 @@ Recommended options:
85
85
  2. GitHub Projects - best when stakeholders need a shared external board and the user can approve GitHub credentials/project details.
86
86
  3. No visual board - best for quick or private goals where the file board is enough.
87
87
 
88
- If the user chooses the local live board, create the goal directory, `notes/`, and an initial minimal `state.yaml` as soon as the slug is known, then run `npx goalbuddy board docs/goals/<slug>` and open the printed local URL in the AI coding agent's in-app browser (the Codex in-app Browser, the Claude Code preview, or the user's regular browser). The default local hub is `http://goalbuddy.localhost:41737/`, and board URLs normally look like `http://goalbuddy.localhost:41737/<slug>/`. In short: start the local board before filling the task list so the board pops up right away and cards populate live as `state.yaml` changes. Include the printed board URL in the final prep response as the operator fallback, but do not make the URL the primary experience.
88
+ If the user chooses the local live board, create the goal directory, `notes/`, and an initial minimal `state.yaml` as soon as the slug is known, then run `npx goalbuddy board docs/goals/<slug>` and open the printed local URL in the AI coding agent's in-app browser (the Codex in-app Browser, the Claude Code preview, or the user's regular browser). The default local hub is `http://goalbuddy.localhost:41737/`, and board URLs normally look like `http://goalbuddy.localhost:41737/<slug>/`. In short: start the local board before filling the task list so the board pops up right away and cards populate live as `state.yaml` changes. Include the printed board URL in the final prep response as an actual clickable Markdown link, for example `[Open GoalBuddy board](http://goalbuddy.localhost:41737/<slug>/)`. Do not put the board URL only in a code block, quote, HTML comment, or prose that the UI cannot click.
89
89
 
90
90
  If the user chooses GitHub Projects, ask for approval and the required project target before any live write. Create or sync the GitHub Project at the same early point as the local board: after the goal root and skeleton `state.yaml` exist, before the detailed task list is finished, then sync again as tasks populate. Run a dry-run sync first when possible. Missing GitHub credentials or project details should not block local board creation or goal prep; record the missing requirement in `visual_board.github_projects` and seed a PM setup task.
91
91
 
@@ -203,15 +203,31 @@ Planning, Scout findings, Judge decisions, and a queued Worker task are not term
203
203
  For execution goals, the default run is continuous:
204
204
 
205
205
  ```text
206
- Discover enough evidence, choose a safe implementation slice, implement it, verify it, audit it, then immediately choose and execute the next safe slice until the full original outcome is complete.
206
+ Discover enough evidence, choose the largest reversible local work package, implement it, verify it, review only at risk or phase boundaries, then immediately choose and execute the next work package until the full original outcome is complete.
207
207
  ```
208
208
 
209
209
  If the first `/goal` run reaches a Judge decision that names a safe Worker task with `allowed_files`, `verify`, and `stop_if`, the PM should activate that Worker and continue in the same run unless a stop condition applies.
210
210
 
211
- After a verified Worker slice and audit, do not mark the thread goal complete merely because that slice passed. A slice audit is a checkpoint. For broad automation or product goals, continue by reopening or advancing the board to the next safe Worker task until the full owner outcome is complete.
211
+ After a verified Worker package, do not mark the thread goal complete merely because that package passed. For broad automation or product goals, continue by reopening or advancing the board to the next safe Worker package until the full owner outcome is complete.
212
212
 
213
213
  Missing owner input, credentials, production access, destructive-operation permission, or policy decisions are blockers for specific tasks, not stopping conditions for the whole goal. When a slice hits one of those blockers, mark that exact task blocked with a receipt, create a safe follow-up or workaround task, and keep doing local, non-destructive work that advances the full outcome.
214
214
 
215
+ ## Slice Sizing Policy
216
+
217
+ A good task is the largest safe useful slice.
218
+
219
+ Small is not the goal. Useful is the goal.
220
+
221
+ Safe does not mean small. Safe means bounded, explicit, verified, and reversible.
222
+
223
+ A good Worker task usually produces a working screen, a working API path, a working data pipeline step, a working backend vertical slice, a real bug fix, or a milestone review. A bad Worker task is one more tiny helper, projection function, contract file, read-only proof, or doc note unless that tiny task is truly blocking progress.
224
+
225
+ Judge picks the largest safe useful next slice. Worker completes the whole assigned slice. Judge reviews the whole slice.
226
+
227
+ After two tiny tasks in a row, PM or Judge should reorient the board. If a demo milestone is complete, the next task should move toward the next real milestone.
228
+
229
+ Tiny tasks are allowed when the failure is isolated, the risk is high, the scope is unknown, or the tiny task unlocks a larger slice. Tiny tasks are bad when they keep happening, do not change behavior, only add wrappers/contracts/proof files, or avoid the real milestone.
230
+
215
231
  ## When To Use
216
232
 
217
233
  Use this skill for goals that are broad, multi-hour, ambiguous, high-risk, already planned, already stale, already red, or likely to need Scout/Judge/Worker delegation.
@@ -267,7 +283,7 @@ What counts as enough for the current tranche?
267
283
  Avoid forever goals. A broad goal should define an execution tranche, for example:
268
284
 
269
285
  ```text
270
- Discover the highest-leverage local improvements, complete successive safe verified implementation slices, audit each slice against the original user outcome, and keep advancing until the full outcome is complete.
286
+ Discover the highest-leverage local improvements, complete successive safe verified work packages, review only at risk or phase boundaries, and keep advancing until the full outcome is complete.
271
287
  ```
272
288
 
273
289
  ## Board
@@ -390,8 +406,9 @@ Judge receipt:
390
406
  ```yaml
391
407
  receipt:
392
408
  result: done
393
- decision: "Do router coverage first; defer auth flake because it is not reproducible locally."
394
- next_allowed_task: T004
409
+ decision: "approved"
410
+ full_outcome_complete: false
411
+ rationale: "Router coverage is verified; continue with the next PM-selected work package."
395
412
  blocked_tasks:
396
413
  - T005
397
414
  ```
@@ -440,7 +457,7 @@ Blocked tasks do not necessarily block the goal. The PM should keep doing safe l
440
457
 
441
458
  - create a Scout task to improve evidence;
442
459
  - create a Judge task to resolve ambiguity;
443
- - create a Worker task for a smaller safe slice;
460
+ - create a Worker task for the largest reversible local work package that can proceed;
444
461
  - write or update a note for handoff;
445
462
  - update receipts and verification freshness.
446
463
 
@@ -471,9 +488,11 @@ After a task completes, immediately write its receipt and select the next active
471
488
 
472
489
  - a final audit proves the full original owner outcome is complete.
473
490
 
474
- Do not stop at "ready for implementation" when a safe Worker task exists. Activate the Worker, execute it, verify it, and then run the audit task.
491
+ Do not stop at "ready for implementation" when a safe Worker task exists. Activate the Worker, execute it, verify it, and keep going.
475
492
 
476
- Do not stop after one verified implementation slice when the broader owner outcome still has safe local follow-up slices. Treat a slice audit as permission to advance the board, not as permission to finish, unless the audit explicitly proves the full original outcome is complete.
493
+ Do not stop after one verified work package when the broader owner outcome still has safe local follow-up work. Advance the board to the next work package unless a risk boundary or final audit is due.
494
+
495
+ Do not create a Judge task after every Worker by default. Use Judge only for phase boundaries, high-risk changes, unclear scope, rejected verification, or final completion. Repeated same-shape work belongs in one Worker package.
477
496
 
478
497
  Do not stop because the current slice needs owner input, credentials, production access, destructive operations, or policy decisions. Mark that slice blocked, spawn or activate the smallest safe local task that can proceed around the blocker, and continue.
479
498
 
@@ -507,8 +526,8 @@ Non-`installed` states are warnings, not false failures, because the main `/goal
507
526
  | Agent | Thinking level | Write access | Use for |
508
527
  |---|---:|---:|---|
509
528
  | Scout | low | no | targeted source/spec/repo evidence mapping |
510
- | Worker | low | yes, bounded | one exact implementation or recovery task |
511
- | Judge | high | no | strategic review, ambiguity, scope, completion skepticism |
529
+ | Worker | medium | yes, bounded | one coherent bounded useful slice |
530
+ | Judge | high | no | phase/risk/final review, ambiguity, scope, completion skepticism |
512
531
 
513
532
  A task's `assignee` determines the agent. The task card is the order. The receipt is the return format.
514
533
 
@@ -540,7 +559,9 @@ Treat `reasoning_hint` as PM guidance. It does not override task scope, write pe
540
559
 
541
560
  ## Execution Quality Commands
542
561
 
543
- Use `goalbuddy prompt docs/goals/<slug>` to render a compact prompt for the active task. The prompt includes only task-specific material, safe agent metadata, and the expected receipt shape. It should not include broad chat history or dump the whole state file.
562
+ Use `goalbuddy prompt docs/goals/<slug>` to render a compact prompt for the active task. The prompt includes only task-specific material, safe agent metadata, continuation warnings, and the expected receipt shape. It should not include broad chat history or dump the whole state file.
563
+
564
+ When dispatching Codex subagents from a GoalBuddy prompt, the `required_spawn_agent_type` is mandatory. Use that exact `spawn_agent` `agent_type` (`goal_scout`, `goal_worker`, or `goal_judge`). Do not substitute generic `scout`, `worker`, or `judge` agents; if the required GoalBuddy agent is unavailable, stop spawning and continue as PM fallback or run `npx goalbuddy agents`/`npx goalbuddy install`. After one `wait_agent` timeout with no visible allowed-file changes, stop waiting, record the timeout, and recover deterministically instead of waiting forever.
544
565
 
545
566
  Use `goalbuddy parallel-plan docs/goals/<slug>` when the user explicitly asks for parallel agent work. It is read-only: it recommends safe Scout/Judge handoffs and Worker handoffs only when write scopes are known and disjoint. It does not mutate `state.yaml`, create sub-goals, apply receipts, or spawn agents.
546
567
 
@@ -552,7 +573,7 @@ Completion is a Judge or PM audit task. The goal is done only when a final done
552
573
 
553
574
  For execution goals, completion also requires implementation evidence. A final audit cannot call the goal done if the only completed work is planning, discovery, or task selection.
554
575
 
555
- For continuous execution goals, the final audit receipt must include `full_outcome_complete: true`. If the receipt only proves that the current slice or tranche is complete, keep the goal active and queue or activate the next safe Worker/Judge/PM task.
576
+ For continuous execution goals, the final audit receipt must include `full_outcome_complete: true`. If the receipt only proves that the current work package or tranche is complete, keep the goal active and queue or activate the next safe Worker/PM task. Add a Judge only when the next decision is a phase, risk, ambiguity, rejected verification, or final completion review.
556
577
 
557
578
  Queued or active Worker tasks block `goal.status: done`. If a Worker is no longer required, mark it blocked with a receipt explaining why, remove it during PM board maintenance, or replace it with the actual required Worker task before completion.
558
579
 
@@ -13,7 +13,7 @@ This directory contains skill metadata and bundled agent definitions for Codex a
13
13
  | Agent | Codex file | Claude Code file | Reasoning effort | Write scope |
14
14
  |---|---|---|---:|---|
15
15
  | Scout | `goal_scout.toml` | `goal-scout.md` | medium | read-only |
16
- | Worker | `goal_worker.toml` | `goal-worker.md` | low | workspace-write |
16
+ | Worker | `goal_worker.toml` | `goal-worker.md` | medium | workspace-write |
17
17
  | Judge | `goal_judge.toml` | `goal-judge.md` | high | read-only |
18
18
 
19
19
  ## Recommended Codex Config
@@ -13,11 +13,16 @@ Hard contract:
13
13
  - Read only. Do not edit, stage, install, or implement.
14
14
  - Read state receipts before raw files. Then read only the inputs named in the Judge task.
15
15
  - Be skeptical of progress. Lots of files, docs, or tests are not completion.
16
- - A safe Worker task must include exact objective, allowed_files, verify commands, and stop_if.
16
+ - A safe Worker package must include objective, allowed_files, verify commands, and stop_if, and should cover the largest reversible local work package at that boundary.
17
+ - Choose the largest safe useful slice: bounded, explicit, verified, reversible, and outcome-moving. Safety does not mean tiny.
18
+ - Judge a whole useful slice, not one helper at a time.
19
+ - Detect micro-slice loops. Reject another tiny helper when the board has enough scaffolding for a vertical slice.
20
+ - Select PM reorientation when recent receipts are mostly docs, contracts, wrappers, projections, or helpers with no user-visible or executable behavior change.
21
+ - Prefer milestone reviews over helper reviews.
17
22
  - A safe child board must be depth 1, inside subgoals/, non-recursive, and linked from exactly one parent task.
18
23
  - Parallel Worker work is safe only with provably disjoint allowed_files. Separate boards alone are not proof.
19
24
  - Reject completion unless the full original outcome is mapped to receipts and current verification.
20
- - Do not choose the active task or mutate state.
25
+ - Do not generate routine next tasks, choose the active task, or mutate state. The PM owns continuation after your review.
21
26
 
22
27
  Return exactly one parseable JSON receipt object:
23
28
 
@@ -26,11 +31,10 @@ Return exactly one parseable JSON receipt object:
26
31
  "result": "done | blocked",
27
32
  "task_id": "<T###>",
28
33
  "board_path": "<path to state.yaml>",
29
- "decision": "approve_next | reject_next | approve_subgoal | reject_subgoal | not_complete | complete",
34
+ "decision": "approved | rejected | approve_subgoal | reject_subgoal | not_complete | complete",
30
35
  "full_outcome_complete": false,
31
36
  "rationale": "<=120 words>",
32
37
  "evidence": [],
33
- "next_allowed_task": null,
34
38
  "subgoal_contract": null,
35
39
  "parallel_safety": null,
36
40
  "blocked_tasks": [],
@@ -1,13 +1,13 @@
1
1
  name = "goal_worker"
2
- description = "GoalBuddy Worker. Bounded writer for exactly one active Worker task. Edits only allowed_files, runs verify, returns receipt."
3
- model_reasoning_effort = "low"
2
+ description = "GoalBuddy Worker. Bounded writer for one coherent reversible Worker work package. Edits only allowed_files, runs verify, returns receipt."
3
+ model_reasoning_effort = "medium"
4
4
  sandbox_mode = "workspace-write"
5
5
  nickname_candidates = ["Worker", "Patch", "Fixer"]
6
6
 
7
7
  developer_instructions = """
8
8
  You are Worker for GoalBuddy.
9
9
 
10
- Default effort: low. Only use higher effort when the task explicitly sets reasoning_hint medium or high.
10
+ Default effort: medium for implementation tasks. Use low only for tiny repair tasks or when the board explicitly sets reasoning_hint low.
11
11
 
12
12
  Hard contract:
13
13
  - Execute exactly one Worker task on exactly one board.
@@ -18,7 +18,11 @@ Hard contract:
18
18
  - Do not create child sub-goals unless the task explicitly allows it.
19
19
  - Run the verify commands exactly as listed after edits. You may make at most two fix attempts.
20
20
  - Stop immediately if required evidence is missing, a file outside allowed_files is needed, source/product/tests conflict, or verification still fails after two attempts.
21
- - Keep the diff minimal and reversible.
21
+ - Do not request a Judge just because the package is done. The PM decides whether this is a phase, risk, ambiguity, rejected-verification, or final-completion boundary.
22
+ - Keep the diff coherent, bounded, and reversible. Do not shrink the assigned work below the largest safe useful slice.
23
+ - Complete the whole assigned slice. Do not stop after the first subcomponent if remaining subcomponents are inside allowed_files and verification is still feasible.
24
+ - If the task asks for a vertical slice, complete the vertical slice.
25
+ - Do not under-implement to avoid verification.
22
26
 
23
27
  Parallel safety:
24
28
  - Do not assume parallel Worker safety.
@@ -37,7 +41,6 @@ Return exactly one parseable JSON receipt object:
37
41
  "commands": [],
38
42
  "summary": "<=120 words>",
39
43
  "remaining_blockers": [],
40
- "needs_judge": false,
41
44
  "verification_attempts": 1,
42
45
  "stopped_because": null
43
46
  }
@@ -110,7 +110,7 @@ export function normalizeTask(task, index) {
110
110
  }
111
111
 
112
112
  const id = cleanText(task.id);
113
- const status = cleanText(task.status);
113
+ const status = normalizeTaskStatus(task.status);
114
114
  if (!id) throw new GoalBoardError(`Task ${index + 1} is missing id.`);
115
115
  if (!VALID_STATUSES.has(status)) {
116
116
  throw new GoalBoardError(`Task ${id} has unsupported status "${status}".`);
@@ -332,14 +332,222 @@ function cleanText(value) {
332
332
  return String(value ?? "").trim();
333
333
  }
334
334
 
335
+ function normalizeTaskStatus(value) {
336
+ const status = cleanText(value);
337
+ if (status === "complete" || status === "completed") return "done";
338
+ return status;
339
+ }
340
+
335
341
  export function parseGoalStateText(text) {
336
- const lines = tokenizeYaml(text);
337
- if (!lines.length) throw new GoalBoardError("Goal state is empty.");
338
- const [value, nextIndex] = parseBlock(lines, 0, lines[0].indent);
339
- if (nextIndex < lines.length) {
340
- throw new GoalBoardError(`Could not parse line ${lines[nextIndex].number}.`);
342
+ try {
343
+ const lines = tokenizeYaml(text);
344
+ if (!lines.length) throw new GoalBoardError("Goal state is empty.");
345
+ const [value, nextIndex] = parseBlock(lines, 0, lines[0].indent);
346
+ if (nextIndex < lines.length) {
347
+ throw new GoalBoardError(`Could not parse line ${lines[nextIndex].number}.`);
348
+ }
349
+ return value;
350
+ } catch (error) {
351
+ if (error instanceof GoalBoardError && canRecoverBoardSubset(error)) {
352
+ return parseGoalBoardSubset(text);
353
+ }
354
+ throw error;
341
355
  }
342
- return value;
356
+ }
357
+
358
+ function canRecoverBoardSubset(error) {
359
+ return /Could not parse line|Expected key\/value pair|Expected mapping|Block scalar YAML/.test(error.message);
360
+ }
361
+
362
+ function parseGoalBoardSubset(text) {
363
+ const tasks = parseTaskSubsets(text);
364
+ if (!tasks.length) throw new GoalBoardError("Missing non-empty tasks list.");
365
+ return {
366
+ version: parseYamlScalar(findTopLevelScalar(text, "version") || "2"),
367
+ goal: {
368
+ title: parseYamlScalar(findNestedScalar(text, "goal", "title") || "Untitled goal"),
369
+ slug: parseYamlScalar(findNestedScalar(text, "goal", "slug") || "untitled-goal"),
370
+ kind: parseYamlScalar(findNestedScalar(text, "goal", "kind") || "open_ended"),
371
+ tranche: parseYamlScalar(findNestedScalar(text, "goal", "tranche") || ""),
372
+ status: parseYamlScalar(findNestedScalar(text, "goal", "status") || "active"),
373
+ },
374
+ active_task: parseYamlScalar(findTopLevelScalar(text, "active_task") || ""),
375
+ tasks,
376
+ };
377
+ }
378
+
379
+ function parseTaskSubsets(text) {
380
+ const tasksText = findTopLevelSection(text, "tasks");
381
+ if (!tasksText) return [];
382
+ const taskBlocks = [];
383
+ let current = [];
384
+ for (const line of tasksText.split("\n")) {
385
+ if (/^ - id:/.test(line)) {
386
+ if (current.length) taskBlocks.push(current.join("\n"));
387
+ current = [line];
388
+ } else if (current.length) {
389
+ current.push(line);
390
+ }
391
+ }
392
+ if (current.length) taskBlocks.push(current.join("\n"));
393
+ return taskBlocks.map((block) => ({
394
+ id: parseYamlScalar(findTaskScalar(block, "id") || ""),
395
+ type: parseYamlScalar(findTaskScalar(block, "type") || "pm"),
396
+ assignee: parseYamlScalar(findTaskScalar(block, "assignee") || ""),
397
+ status: parseYamlScalar(findTaskScalar(block, "status") || "queued"),
398
+ title: parseYamlScalar(findTaskScalar(block, "title") || ""),
399
+ objective: parseYamlScalar(findTaskScalar(block, "objective") || ""),
400
+ inputs: findTaskList(block, "inputs"),
401
+ constraints: findTaskList(block, "constraints"),
402
+ expected_output: findTaskList(block, "expected_output"),
403
+ allowed_files: findTaskList(block, "allowed_files"),
404
+ verify: findTaskList(block, "verify"),
405
+ stop_if: findTaskList(block, "stop_if"),
406
+ subgoal: findTaskSubgoal(block),
407
+ receipt: findTaskReceipt(block),
408
+ }));
409
+ }
410
+
411
+ function findTopLevelScalar(text, key) {
412
+ return findScalar(text, new RegExp(`^${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"));
413
+ }
414
+
415
+ function findNestedScalar(text, section, key) {
416
+ return findScalar(findTopLevelSection(text, section), new RegExp(`^ ${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"));
417
+ }
418
+
419
+ function findTaskScalar(text, key) {
420
+ if (key === "id") return findScalar(text, /^ - id:\s*(.*?)\s*$/m);
421
+ return findScalar(text, new RegExp(`^ ${escapeRegExp(key)}:\\s*(.*?)\\s*$`, "m"));
422
+ }
423
+
424
+ function findScalar(text, pattern) {
425
+ const match = String(text || "").match(pattern);
426
+ return match ? match[1] : "";
427
+ }
428
+
429
+ function findTopLevelSection(text, key) {
430
+ const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
431
+ const start = lines.findIndex((line) => line.trim() === `${key}:`);
432
+ if (start === -1) return "";
433
+ const section = [];
434
+ for (let index = start + 1; index < lines.length; index += 1) {
435
+ const line = lines[index];
436
+ if (/^\S/.test(line)) break;
437
+ section.push(line);
438
+ }
439
+ return section.join("\n");
440
+ }
441
+
442
+ function findIndentedSection(text, key, indent) {
443
+ const lines = String(text || "").replace(/\r\n/g, "\n").split("\n");
444
+ const prefix = " ".repeat(indent);
445
+ const start = lines.findIndex((line) => line.trim() === `${key}:` && line.startsWith(prefix));
446
+ if (start === -1) return "";
447
+ const section = [];
448
+ for (let index = start + 1; index < lines.length; index += 1) {
449
+ const line = lines[index];
450
+ if (line.trim() && !line.startsWith(`${prefix} `)) break;
451
+ section.push(line);
452
+ }
453
+ return section.join("\n");
454
+ }
455
+
456
+ function findTaskList(text, key) {
457
+ const inline = findTaskScalar(text, key);
458
+ if (inline) {
459
+ const parsed = parseYamlScalar(inline);
460
+ if (Array.isArray(parsed)) return parsed.map(cleanText).filter(Boolean);
461
+ return cleanText(parsed) ? [cleanText(parsed)] : [];
462
+ }
463
+ const section = findIndentedSection(text, key, 4);
464
+ return section
465
+ .split("\n")
466
+ .map((line) => line.match(/^ -\s*(.*?)\s*$/)?.[1] || "")
467
+ .map(parseYamlScalar)
468
+ .map(cleanText)
469
+ .filter(Boolean);
470
+ }
471
+
472
+ function findTaskSubgoal(text) {
473
+ const inline = findTaskScalar(text, "subgoal");
474
+ if (inline && parseYamlScalar(inline) === null) return null;
475
+ const section = findIndentedSection(text, "subgoal", 4);
476
+ if (!section) return null;
477
+ return {
478
+ status: parseYamlScalar(findScalar(section, /^ status:\s*(.*?)\s*$/m) || "active"),
479
+ path: parseYamlScalar(findScalar(section, /^ path:\s*(.*?)\s*$/m) || ""),
480
+ owner: parseYamlScalar(findScalar(section, /^ owner:\s*(.*?)\s*$/m) || ""),
481
+ created_from: parseYamlScalar(findScalar(section, /^ created_from:\s*(.*?)\s*$/m) || ""),
482
+ depth: parseYamlScalar(findScalar(section, /^ depth:\s*(.*?)\s*$/m) || "1"),
483
+ rollup_receipt: parseYamlScalar(findScalar(section, /^ rollup_receipt:\s*(.*?)\s*$/m) || "null"),
484
+ };
485
+ }
486
+
487
+ function findTaskReceipt(text) {
488
+ const inline = findTaskScalar(text, "receipt");
489
+ if (inline && parseYamlScalar(inline) === null) return null;
490
+ const section = findIndentedSection(text, "receipt", 4);
491
+ if (!section) return null;
492
+ return {
493
+ result: parseYamlScalar(findScalar(section, /^ result:\s*(.*?)\s*$/m) || ""),
494
+ summary: parseYamlScalar(findScalar(section, /^ summary:\s*(.*?)\s*$/m) || ""),
495
+ decision: parseYamlScalar(findScalar(section, /^ decision:\s*(.*?)\s*$/m) || ""),
496
+ note: parseYamlScalar(findScalar(section, /^ note:\s*(.*?)\s*$/m) || ""),
497
+ changed_files: findReceiptList(section, "changed_files"),
498
+ commands: findReceiptCommands(section),
499
+ evidence: [],
500
+ };
501
+ }
502
+
503
+ function findReceiptList(text, key) {
504
+ const section = findIndentedSection(text, key, 6);
505
+ return section
506
+ .split("\n")
507
+ .map((line) => line.match(/^ -\s*(.*?)\s*$/)?.[1] || "")
508
+ .map(parseYamlScalar)
509
+ .map(cleanText)
510
+ .filter(Boolean);
511
+ }
512
+
513
+ function findReceiptCommands(text) {
514
+ const section = findIndentedSection(text, "commands", 6);
515
+ const blocks = [];
516
+ let current = [];
517
+ for (const line of section.split("\n")) {
518
+ if (/^ - cmd:/.test(line)) {
519
+ if (current.length) blocks.push(current.join("\n"));
520
+ current = [line];
521
+ } else if (current.length) {
522
+ current.push(line);
523
+ }
524
+ }
525
+ if (current.length) blocks.push(current.join("\n"));
526
+ return blocks.map((block) => ({
527
+ cmd: parseYamlScalar(findScalar(block, /^ - cmd:\s*(.*?)\s*$/m) || ""),
528
+ status: parseYamlScalar(findScalar(block, /^ status:\s*(.*?)\s*$/m) || ""),
529
+ note: parseYamlScalar(findScalar(block, /^ note:\s*(.*?)\s*$/m) || ""),
530
+ }));
531
+ }
532
+
533
+ function parseYamlScalar(value) {
534
+ const text = stripComment(String(value ?? "")).trim();
535
+ if (!text) return "";
536
+ try {
537
+ return parseScalar(text);
538
+ } catch {
539
+ if (
540
+ (text.startsWith("\"") && text.endsWith("\"")) ||
541
+ (text.startsWith("'") && text.endsWith("'"))
542
+ ) {
543
+ return text.slice(1, -1);
544
+ }
545
+ return text;
546
+ }
547
+ }
548
+
549
+ function escapeRegExp(value) {
550
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
343
551
  }
344
552
 
345
553
  function tokenizeYaml(text) {
@@ -1326,6 +1534,25 @@ h1 {
1326
1534
  font-size: 14px;
1327
1535
  }
1328
1536
 
1537
+ .board-error {
1538
+ grid-column: 1 / -1;
1539
+ padding: 18px;
1540
+ border: 1px solid var(--red-border);
1541
+ border-radius: 8px;
1542
+ background: var(--red-bg);
1543
+ color: var(--text);
1544
+ }
1545
+
1546
+ .board-error h2 {
1547
+ margin: 0 0 8px;
1548
+ font-size: 16px;
1549
+ }
1550
+
1551
+ .board-error p {
1552
+ margin: 0;
1553
+ color: var(--muted);
1554
+ }
1555
+
1329
1556
  @media (prefers-reduced-motion: reduce) {
1330
1557
  .github-stars,
1331
1558
  .settings-button,
@@ -1825,6 +2052,11 @@ function renderBoard(board) {
1825
2052
  document.getElementById("goal-active").textContent = board.goal.activeTask || "None";
1826
2053
  document.getElementById("goal-updated").textContent = new Date(board.generatedAt).toLocaleTimeString();
1827
2054
 
2055
+ if (board.error) {
2056
+ boardEl.replaceChildren(renderBoardError(board.error));
2057
+ return;
2058
+ }
2059
+
1828
2060
  const delay = movingTaskIds.size ? 260 : 0;
1829
2061
  window.setTimeout(() => {
1830
2062
  boardEl.replaceChildren(...board.columns.map(renderColumn));
@@ -1832,6 +2064,15 @@ function renderBoard(board) {
1832
2064
  }, delay);
1833
2065
  }
1834
2066
 
2067
+ function renderBoardError(message) {
2068
+ const node = el("section", "board-error");
2069
+ node.append(
2070
+ el("h2", "", "GoalBuddy could not parse this board"),
2071
+ el("p", "", message),
2072
+ );
2073
+ return node;
2074
+ }
2075
+
1835
2076
  function renderBoardSwitcher(boards) {
1836
2077
  boardSwitcherEl.closest(".board-switcher").classList.toggle("is-empty", boards.length <= 1);
1837
2078
  const currentPath = normalizePath(window.location.pathname);
@@ -82,6 +82,62 @@ tasks:
82
82
  }
83
83
  });
84
84
 
85
+ test("keeps board rendering when deep receipt YAML is malformed", () => {
86
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-board-subset-parser-"));
87
+ try {
88
+ const goalDir = join(root, "subset-parser");
89
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
90
+ writeFileSync(join(goalDir, "state.yaml"), `version: 2
91
+ goal:
92
+ title: "Subset parser"
93
+ slug: "subset-parser"
94
+ kind: specific
95
+ tranche: "Recover shallow board fields."
96
+ status: active
97
+ active_task: T003
98
+ checks:
99
+ last_verification:
100
+ status: pass
101
+ raw:
102
+ malformed nested checker output
103
+ tasks:
104
+ - id: T001
105
+ type: worker
106
+ assignee: Worker
107
+ status: completed
108
+ objective: "Ship a completed worker slice."
109
+ receipt:
110
+ result: done
111
+ summary: "Worker finished."
112
+ raw:
113
+ malformed nested receipt output
114
+ - id: T002
115
+ type: judge
116
+ assignee: Judge
117
+ status: complete
118
+ objective: "Approve the result."
119
+ receipt: null
120
+ - id: T003
121
+ type: scout
122
+ assignee: Scout
123
+ status: active
124
+ objective: "Inspect what is left."
125
+ receipt: null
126
+ `);
127
+
128
+ const payload = createBoardPayload(goalDir);
129
+ assert.equal(payload.goal.title, "Subset parser");
130
+ assert.equal(payload.goal.activeTask, "T003");
131
+ assert.equal(payload.counts.completed, 2);
132
+ assert.equal(payload.counts.inProgress, 1);
133
+ assert.equal(payload.tasks.find((task) => task.id === "T001").status, "done");
134
+ assert.equal(payload.tasks.find((task) => task.id === "T002").status, "done");
135
+ assert.equal(payload.tasks.find((task) => task.id === "T001").receipt.summary, "Worker finished.");
136
+ } finally {
137
+ rmSync(root, { recursive: true, force: true });
138
+ }
139
+ });
140
+
85
141
  test("fails loudly when a linked subgoal state file is missing", () => {
86
142
  const root = mkdtempSync(join(tmpdir(), "goalbuddy-missing-subgoal-"));
87
143
  try {
@@ -194,6 +250,7 @@ test("writes a minimal GoalBuddy web app into the goal directory", () => {
194
250
  assert.match(css, /:root\[data-density="compact"\] \.task-card/);
195
251
  assert.match(css, /:root\[data-completed-visibility="collapse"\]/);
196
252
  assert.match(css, /\.subgoal-board/);
253
+ assert.match(css, /\.board-error/);
197
254
  assert.match(js, /new EventSource\("\.\/events"\)/);
198
255
  assert.match(js, /fetch\("\.\.\/api\/boards"/);
199
256
  assert.match(js, /fetch\("\.\.\/api\/settings"/);
@@ -206,6 +263,7 @@ test("writes a minimal GoalBuddy web app into the goal directory", () => {
206
263
  assert.match(js, /card\.animate/);
207
264
  assert.match(js, /highlightMovingCards/);
208
265
  assert.match(js, /renderSubgoal/);
266
+ assert.match(js, /renderBoardError/);
209
267
  assert.match(js, /boardOptionLabel/);
210
268
  assert.match(js, /duration: changedColumn \? 980 : 520/);
211
269
  assert.equal(logo.subarray(1, 4).toString("ascii"), "PNG");
@@ -333,7 +391,7 @@ test("advertises goalbuddy.localhost while binding to loopback", async () => {
333
391
  });
334
392
 
335
393
  test("runs when installed under a symlinked temp path", () => {
336
- const root = mkdtempSync("/tmp/goalbuddy-local-board-direct-");
394
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-direct-"));
337
395
  try {
338
396
  cpSync("extend/local-goal-board/scripts", join(root, "scripts"), { recursive: true });
339
397
  cpSync("extend/local-goal-board/assets", join(root, "assets"), { recursive: true });