goalbuddy 0.3.8 → 0.3.9

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.
@@ -0,0 +1,16 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/claude-code-marketplace.json",
3
+ "name": "goalbuddy",
4
+ "owner": {
5
+ "name": "tolibear",
6
+ "email": "support@tolibear.com"
7
+ },
8
+ "description": "GoalBuddy /goal-prep skill plus Scout/Judge/Worker subagents for pressured /goal runs.",
9
+ "plugins": [
10
+ {
11
+ "name": "goalbuddy",
12
+ "source": "./plugins/goalbuddy",
13
+ "description": "GoalBuddy: turn broad work into pressured /goal runs with oracles, local boards, receipts, and verification."
14
+ }
15
+ ]
16
+ }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.9 — Marketplace and Board Runtime Polish (2026-06-23)
4
+
5
+ - **Made Claude marketplace install discoverable.** The repo now ships a root `.claude-plugin/marketplace.json`, keeps it in the npm package allowlist, and validates marketplace install flow alongside the existing plugin manifest checks.
6
+ - **Made `/goal-prep` install-channel agnostic.** Model-invoked board, prompt, and parallel-plan commands now use bundled skill scripts instead of assuming a global `goalbuddy` or `npx goalbuddy` binary. Update and agent guidance now points users back to their actual install channel.
7
+ - **Stopped local-board flicker during task transitions.** The board watcher now coalesces rapid `state.yaml` writes before streaming updates, avoiding transient “more than one active task” errors during normal multi-step transitions.
8
+ - **Let the board render valid parallel work.** The local board now renders multiple active tasks in the In Progress column instead of refusing to parse the whole board, while the stricter `check-goal-state` invariant remains available for board validation.
9
+ - **Added exact-approval wait guidance.** GoalBuddy now has a terminal waiting shape for exact human approval gates: ask once, preserve the required reply, set `waiting_for_user_approval: true`, and stop until the user replies.
10
+ - **Added PM-owned board health stewardship.** Goal Prep now explains the safe steward model: use the bundled checker and live board API to repair GoalBuddy control files only, without introducing an always-on implementation actor.
11
+
3
12
  ## 0.3.8 — Board Hub Guardrails (2026-05-29)
4
13
 
5
14
  - **Clarified multi-board hub recovery.** Unregistered board URLs now explain that a `/slug/` 404 does not mean the `41737` process is stale; agents should verify `/api/boards` and register the new goal on the same hub before stopping any process. Release checks now include the local board surface tests.
package/README.md CHANGED
@@ -152,7 +152,7 @@ Multiple local boards reuse one readable `goalbuddy.localhost` hub with an in-he
152
152
 
153
153
  Custom external integrations should be built as ordinary repo work with a concrete implementation plan, not installed from a GoalBuddy catalog.
154
154
 
155
- See [GoalBuddy 0.3.8: Board Hub Guardrails](docs/releases/0.3.8.md) for the latest release notes.
155
+ See [GoalBuddy 0.3.9: Marketplace and Board Runtime Polish](docs/releases/0.3.9.md) for the latest release notes.
156
156
 
157
157
  <p align="center">
158
158
  <img src="internal/assets/goalbuddy-live-board.jpg" alt="GoalBuddy local live board open next to Codex while Scout, Judge, and Worker tasks populate." width="100%">
@@ -0,0 +1,46 @@
1
+ # GoalBuddy 0.3.9: Marketplace and Board Runtime Polish
2
+
3
+ GoalBuddy 0.3.9 prepares the package for cleaner Claude marketplace installs and tightens the runtime behavior around local boards, approval gates, and board-health stewardship.
4
+
5
+ ## Highlights
6
+
7
+ - **Claude marketplace wrapper.** The repo now includes a root `.claude-plugin/marketplace.json` so Claude Code can discover the existing `plugins/goalbuddy` plugin through marketplace install flow.
8
+ - **Install-channel agnostic Goal Prep.** `/goal-prep` no longer assumes a global `goalbuddy` or `npx goalbuddy` binary for model-invoked board, prompt, and parallel-plan commands. It uses bundled skill scripts from `<skill-path>` instead.
9
+ - **Better update and agent advice.** `check-update` detects common install channels and falls back to “use the install channel that installed GoalBuddy” instead of guessing wrong. Agent availability warnings now use the same channel-neutral language.
10
+ - **Less board flicker.** Local board file watching now waits for rapid `state.yaml` transition writes to settle before rebuilding the streamed payload.
11
+ - **Parallel active tasks render.** The local board can display multiple active tasks in the In Progress column, which keeps parallel/disjoint work visible instead of failing the entire board render.
12
+ - **Exact approval wait state.** When the only remaining action is an exact human approval phrase, GoalBuddy now has an explicit terminal wait shape: ask once, preserve `required_reply`, set `waiting_for_user_approval: true`, and stop until the user replies.
13
+ - **Board health stewardship.** The PM-owned stewardship contract is now documented: run the bundled state checker, compare the live board API when available, and repair only GoalBuddy control files unless an active task explicitly allows product-file edits.
14
+
15
+ ## Why This Release Matters
16
+
17
+ The Claude marketplace wrapper from 0.3.9 makes marketplace installs viable, but marketplace installs do not guarantee a shell-level `goalbuddy` binary. This release closes that gap by moving model-run commands onto the bundled scripts that ship inside the installed skill.
18
+
19
+ The board changes also make GoalBuddy more honest during real runs: transient task switches do not flash false errors, and valid parallel work stays visible.
20
+
21
+ ## Verification
22
+
23
+ - `npm run check`
24
+ - `git diff --check`
25
+ - `node --check plugins/goalbuddy/skills/goalbuddy/scripts/check-update.mjs plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs`
26
+ - `GOALBUDDY_TEST_NPM_LATEST_VERSION=99.0.0 GOALBUDDY_TEST_UPDATE_COMMAND='/plugin update goalbuddy@goalbuddy' node plugins/goalbuddy/skills/goalbuddy/scripts/check-update.mjs --json`
27
+ - `npm pack --dry-run --json`
28
+
29
+ ## Package Metadata
30
+
31
+ - npm package version: `0.3.9`
32
+ - Codex plugin version: `0.3.9`
33
+ - Claude Code plugin version: `0.3.9`
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ npx goalbuddy
39
+ ```
40
+
41
+ For Claude Code marketplace installs:
42
+
43
+ ```text
44
+ /plugin marketplace add tolibear/goalbuddy
45
+ /plugin install goalbuddy@goalbuddy
46
+ ```
@@ -2,6 +2,7 @@
2
2
 
3
3
  Historical release notes live next to this process doc:
4
4
 
5
+ - [0.3.9: Marketplace and Board Runtime Polish](0.3.9.md)
5
6
  - [0.3.8: Board Hub Guardrails](0.3.8.md)
6
7
  - [0.3.7: Goalmaxxed](0.3.7.md)
7
8
  - [0.3.5: Subgoals, Parallel Agents, and Dark Mode](0.3.5.md)
@@ -58,7 +58,7 @@ node <skill-path>/scripts/check-update.mjs --json
58
58
  If the checker reports `update_available: true`, tell the user once before continuing:
59
59
 
60
60
  ```text
61
- GoalBuddy <latest_version> is available. After this turn, update with: npx goalbuddy
61
+ GoalBuddy <latest_version> is available. After this turn, update through the channel that installed GoalBuddy: `/plugin update goalbuddy@goalbuddy`, `npx goalbuddy@latest`, `npm i -g goalbuddy`, `pnpm update -g goalbuddy`, `bun update -g goalbuddy`, or `mise upgrade npm:goalbuddy`.
62
62
  ```
63
63
 
64
64
  Do not block intake or board creation on update checking. If the checker is missing, cannot find npm, or network access fails, continue silently unless the user asked about updates.
@@ -93,9 +93,9 @@ Recommended options:
93
93
  1. Local live board (Recommended) - starts immediately, requires no credentials, and lets the user watch tasks populate inside Codex or Claude Code.
94
94
  2. No visual board - best for quick or private goals where the file board is enough.
95
95
 
96
- 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.
96
+ 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 `node <skill-path>/surfaces/local-goal-board/scripts/local-goal-board.mjs --goal 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.
97
97
 
98
- If `http://goalbuddy.localhost:41737/<slug>/` returns 404, do not assume the existing process is stale and do not stop it. First check `http://127.0.0.1:41737/api/boards`. If that endpoint returns board JSON, the port is the shared multi-board hub; rerun `npx goalbuddy board docs/goals/<slug>` with the absolute goal path if needed so the new goal registers on the same port. Only stop a specific process on 41737 when `/api/boards` is missing, returns 404, or otherwise proves the listener is not a current GoalBuddy multi-board hub.
98
+ If `http://goalbuddy.localhost:41737/<slug>/` returns 404, do not assume the existing process is stale and do not stop it. First check `http://127.0.0.1:41737/api/boards`. If that endpoint returns board JSON, the port is the shared multi-board hub; rerun `node <skill-path>/surfaces/local-goal-board/scripts/local-goal-board.mjs --goal <absolute-goal-path>` if needed so the new goal registers on the same port. Only stop a specific process on 41737 when `/api/boards` is missing, returns 404, or otherwise proves the listener is not a current GoalBuddy multi-board hub.
99
99
 
100
100
  If the user wants an external board, GitHub sync, Slack digest, Linear handoff, or any other custom integration, do not install a GoalBuddy catalog item. Treat it as normal implementation work: create a concrete task that designs and verifies that integration inside the target repo or asks the operator for the required credentials and scope.
101
101
 
@@ -474,6 +474,22 @@ Blocked tasks do not necessarily block the goal. The PM should keep doing safe l
474
474
 
475
475
  Avoid setting `goal.status: blocked` for missing input, credentials, production access, destructive-operation permission, or policy decisions. Block the specific task instead, record the missing requirement, and continue with every safe local workaround or adjacent slice.
476
476
 
477
+ Exception: if an exact human approval phrase is the only remaining blocker and no safe local work remains, ask once, preserve the exact phrase, and stop. Set `goal.status: blocked`, set `active_task: null`, mark every unfinished task `blocked`, and write a receipt with `result: blocked`, `waiting_for_user_approval: true`, and `required_reply: "<exact phrase>"`. Do not rephrase, retry, spawn follow-up work, or post another approval prompt until the user replies.
478
+
479
+ ## Board Health Stewardship
480
+
481
+ The PM owns board health. Do not auto-spawn a separate always-on steward by default.
482
+
483
+ When the board looks stale, misleading, offline, Not Found, or inconsistent, run the bundled checker:
484
+
485
+ ```bash
486
+ node <skill-path>/scripts/check-goal-state.mjs docs/goals/<slug>
487
+ ```
488
+
489
+ If a local board server is running, compare `state.yaml` with `http://127.0.0.1:41737/<slug>/api/board` or `http://127.0.0.1:41737/api/boards`. Repair only GoalBuddy control files: `goal.md`, `state.yaml`, `notes/`, depth-1 `subgoals/`, and `.goalbuddy-board/`. Never edit product implementation files during board-health work unless there is an active Worker or PM task with explicit `allowed_files`.
490
+
491
+ Board-health work should verify these truths: `active_task` matches live task status, done and blocked tasks have receipts, human-blocked work is in the blocked column, future work stays queued, and the live board/API reflects `state.yaml`.
492
+
477
493
  ## Operator Escalation
478
494
 
479
495
  When Scout, Judge, Worker, or PM discovers a problem, improvement opportunity, product suggestion, follow-up repair, or tool limitation that should not be fixed inside the current active task, do not let it disappear in chat.
@@ -528,9 +544,9 @@ Use these `state.yaml` values:
528
544
  | State | Meaning | Next action |
529
545
  |---|---|---|
530
546
  | `installed` | Matching Scout/Worker/Judge agent configs were found in the expected user or project agent location. | Continue. |
531
- | `bundled_not_installed` | The bundled `goal_*.toml` template exists with the skill, but no matching installed agent config was verified. | `/goal` can proceed through PM fallback. If dedicated agents are required before `/goal`, run `npx goalbuddy agents`. |
532
- | `missing` | Neither an installed config nor the bundled template was verified. | `/goal` can proceed through PM fallback. If dedicated agents are required before `/goal`, run `npx goalbuddy install`. |
533
- | `unknown` | Agent availability could not be checked. | `/goal` can proceed through PM fallback. To check before `/goal`, run `npx goalbuddy doctor`. |
547
+ | `bundled_not_installed` | The bundled `goal_*.toml` template exists with the skill, but no matching installed agent config was verified. | `/goal` can proceed through PM fallback. If dedicated agents are required before `/goal`, run the GoalBuddy CLI through the user's install channel with `agents`. |
548
+ | `missing` | Neither an installed config nor the bundled template was verified. | `/goal` can proceed through PM fallback. If dedicated agents are required before `/goal`, run the GoalBuddy CLI through the user's install channel with `install`. |
549
+ | `unknown` | Agent availability could not be checked. | `/goal` can proceed through PM fallback. To check before `/goal`, run the GoalBuddy CLI through the user's install channel with `doctor`. |
534
550
 
535
551
  Non-`installed` states are warnings, not false failures, because the main `/goal` PM can perform Scout/Judge/Worker-shaped tasks directly when dedicated agents are unavailable.
536
552
 
@@ -570,11 +586,11 @@ Treat `reasoning_hint` as PM guidance. It does not override task scope, write pe
570
586
 
571
587
  ## Execution Quality Commands
572
588
 
573
- 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.
589
+ Use `node <skill-path>/scripts/render-task-prompt.mjs 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.
574
590
 
575
- 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.
591
+ 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 ask the operator to run the GoalBuddy CLI through their install channel with `agents` or `install`. After one `wait_agent` timeout with no visible allowed-file changes, stop waiting, record the timeout, and recover deterministically instead of waiting forever.
576
592
 
577
- 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.
593
+ Use `node <skill-path>/scripts/parallel-plan.mjs 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.
578
594
 
579
595
  ## Completion
580
596
 
@@ -297,12 +297,12 @@ if (isWeakProof(completionProof)) {
297
297
  function agentStatusWarning(agent, status) {
298
298
  const agentLabel = agent[0].toUpperCase() + agent.slice(1);
299
299
  if (status === "bundled_not_installed") {
300
- 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`;
300
+ 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 the GoalBuddy CLI through the user's install channel with: agents`;
301
301
  }
302
302
  if (status === "missing") {
303
- 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`;
303
+ 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 the GoalBuddy CLI through the user's install channel with: install`;
304
304
  }
305
- 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`;
305
+ return `agents.${agent} is unknown; /goal can continue through PM fallback, but dedicated ${agentLabel} delegation was not verified. To check before /goal, run the GoalBuddy CLI through the user's install channel with: doctor`;
306
306
  }
307
307
 
308
308
  for (const { agent, status } of agentStatuses) {
@@ -353,6 +353,7 @@ for (const task of tasks) {
353
353
  if (tasks.length === 0) errors.push("tasks must contain at least one task");
354
354
 
355
355
  const activeTasks = tasks.filter((task) => task.status === "active");
356
+ const terminalApprovalWait = isTerminalApprovalWait(tasks, activeTasks, activeTask);
356
357
  if (goalStatus === "done") {
357
358
  if (activeTasks.length !== 0) errors.push("done goals must not have an active task");
358
359
  if (activeTask !== null) errors.push("done goals must set active_task: null");
@@ -364,7 +365,7 @@ if (goalStatus === "done") {
364
365
  }
365
366
  } else if (goalStatus === "blocked") {
366
367
  if (activeTasks.length > 1) errors.push("blocked goals may have at most one active task");
367
- if (continuousUntilFullOutcome && missingInputOrCredentialsDoNotStopGoal) {
368
+ if (continuousUntilFullOutcome && missingInputOrCredentialsDoNotStopGoal && !terminalApprovalWait) {
368
369
  errors.push("continuous goals must keep goal.status active; missing input or credentials should block specific tasks, not the whole goal");
369
370
  }
370
371
  } else if (activeTasks.length !== 1) {
@@ -436,6 +437,23 @@ for (const task of tasks) {
436
437
 
437
438
  warnings.push(...microSliceWarnings(tasks, activeTask, goalStatus));
438
439
 
440
+ function isTerminalApprovalWait(tasks, activeTasks, activeTask) {
441
+ if (goalStatus !== "blocked") return false;
442
+ if (activeTask !== null) return false;
443
+ if (activeTasks.length !== 0) return false;
444
+
445
+ const unfinishedTasks = tasks.filter((task) => task.status !== "done");
446
+ if (unfinishedTasks.length === 0) return false;
447
+ if (unfinishedTasks.some((task) => task.status !== "blocked")) return false;
448
+
449
+ return unfinishedTasks.some((task) => {
450
+ if (!task.receipt.present || task.receipt.value === null) return false;
451
+ return task.receipt.scalar("result") === "blocked"
452
+ && task.receipt.scalar("waiting_for_user_approval") === true
453
+ && Boolean(task.receipt.scalar("required_reply"));
454
+ });
455
+ }
456
+
439
457
  function validateSubgoal(task) {
440
458
  if (isChildCheck) {
441
459
  errors.push(`child task ${task.id} must not contain a nested subgoal`);
@@ -14,7 +14,7 @@ const report = {
14
14
  latest_version: null,
15
15
  update_available: false,
16
16
  check_status: "unknown",
17
- update_command: "npx goalbuddy",
17
+ update_command: detectUpdateCommand(),
18
18
  };
19
19
 
20
20
  try {
@@ -77,6 +77,23 @@ function latestPublishedVersion() {
77
77
  return normalizeVersion(result.stdout);
78
78
  }
79
79
 
80
+ function detectUpdateCommand() {
81
+ if (process.env.GOALBUDDY_TEST_UPDATE_COMMAND) return process.env.GOALBUDDY_TEST_UPDATE_COMMAND;
82
+ if (process.env.CLAUDE_PLUGIN_ROOT || normalizedPath(scriptDir).includes("/.claude/")) return "/plugin update goalbuddy@goalbuddy";
83
+
84
+ const userAgent = process.env.npm_config_user_agent || "";
85
+ if (/^pnpm\//.test(userAgent)) return "pnpm update -g goalbuddy";
86
+ if (/^bun\//.test(userAgent)) return "bun update -g goalbuddy";
87
+ if (process.env.MISE_EXE || process.env.MISE_SHELL || process.env.MISE_PROJECT_ROOT) return "mise upgrade npm:goalbuddy";
88
+ if (/^npm\//.test(userAgent)) return "npx goalbuddy@latest";
89
+
90
+ return "use the install channel that installed GoalBuddy";
91
+ }
92
+
93
+ function normalizedPath(path) {
94
+ return String(path).replace(/\\/g, "/");
95
+ }
96
+
80
97
  function readJson(path) {
81
98
  if (!existsSync(path)) return null;
82
99
  try {
@@ -88,9 +88,6 @@ export function normalizeGoalBoard(document, goalDir = "<memory>") {
88
88
 
89
89
  const tasks = document.tasks.map((task, index) => normalizeTask(task, index));
90
90
  const activeTasks = tasks.filter((task) => task.status === "active");
91
- if (activeTasks.length > 1) {
92
- throw new GoalBoardError("Goal state has more than one active task.");
93
- }
94
91
 
95
92
  return {
96
93
  goalDir,
@@ -148,7 +145,7 @@ export function buildColumns(tasks) {
148
145
 
149
146
  return [
150
147
  { id: "todo", title: "Todo", description: "Queued work ready to pull", tasks: byColumn.get("todo") },
151
- { id: "in-progress", title: "In Progress", description: "The active task", tasks: byColumn.get("in-progress") },
148
+ { id: "in-progress", title: "In Progress", description: "Active task work", tasks: byColumn.get("in-progress") },
152
149
  { id: "blocked", title: "Blocked", description: "Needs unblock or a smaller slice", tasks: byColumn.get("blocked") },
153
150
  { id: "completed", title: "Completed", description: "Receipted work", tasks: byColumn.get("completed") },
154
151
  ];
@@ -33,6 +33,7 @@ const SETTINGS_OPTIONS = {
33
33
  const DEFAULT_BIND_HOST = "127.0.0.1";
34
34
  const DEFAULT_PUBLIC_HOST = "goalbuddy.localhost";
35
35
  const DEFAULT_PORT = 41737;
36
+ const STATE_CHANGE_SETTLE_MS = 300;
36
37
 
37
38
  if (isDirectRun()) {
38
39
  main().catch((error) => {
@@ -441,7 +442,7 @@ async function readJsonRequest(request) {
441
442
 
442
443
  function watchGoal(goalDir, onChange) {
443
444
  const watchers = [];
444
- const schedule = debounce(onChange, 80);
445
+ const schedule = debounce(onChange, STATE_CHANGE_SETTLE_MS);
445
446
  let watchedDirs = new Set();
446
447
 
447
448
  const rebuild = () => {
@@ -35,6 +35,59 @@ test("orders completed cards newest first while preserving queued order", () =>
35
35
  assert.deepEqual(columns.find((column) => column.id === "completed").tasks.map((task) => task.id), ["T003", "T001"]);
36
36
  });
37
37
 
38
+ test("renders multiple active tasks in the in-progress column", () => {
39
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-multiple-active-"));
40
+ try {
41
+ const goalDir = join(root, "parallel-workers");
42
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
43
+ writeFileSync(join(goalDir, "state.yaml"), `version: 2
44
+ goal:
45
+ title: "Parallel workers"
46
+ slug: "parallel-workers"
47
+ kind: specific
48
+ tranche: "Render disjoint active workers."
49
+ status: active
50
+ active_task: T001
51
+ tasks:
52
+ - id: T001
53
+ type: worker
54
+ assignee: Worker A
55
+ status: active
56
+ objective: "Patch the board parser."
57
+ allowed_files:
58
+ - goalbuddy/surfaces/local-goal-board/scripts/lib/goal-board.mjs
59
+ verify:
60
+ - node --test goalbuddy/surfaces/local-goal-board/test/local-goal-board.test.mjs
61
+ stop_if:
62
+ - "Need files outside allowed_files."
63
+ receipt: null
64
+ - id: T002
65
+ type: worker
66
+ assignee: Worker B
67
+ status: active
68
+ objective: "Patch the board tests."
69
+ allowed_files:
70
+ - goalbuddy/surfaces/local-goal-board/test/local-goal-board.test.mjs
71
+ verify:
72
+ - node --test goalbuddy/surfaces/local-goal-board/test/local-goal-board.test.mjs
73
+ stop_if:
74
+ - "Need files outside allowed_files."
75
+ receipt: null
76
+ `);
77
+
78
+ const payload = createBoardPayload(goalDir);
79
+ assert.equal(payload.goal.activeTask, "T001");
80
+ assert.equal(payload.counts.inProgress, 2);
81
+ assert.deepEqual(
82
+ payload.columns.find((column) => column.id === "in-progress").tasks.map((task) => task.id),
83
+ ["T001", "T002"],
84
+ );
85
+ assert.deepEqual(payload.tasks.filter((task) => task.active).map((task) => task.id), ["T001", "T002"]);
86
+ } finally {
87
+ rmSync(root, { recursive: true, force: true });
88
+ }
89
+ });
90
+
38
91
  test("loads depth-1 subgoal boards into parent task payloads", () => {
39
92
  const payload = createBoardPayload(resolve("goalbuddy/surfaces/local-goal-board/examples/subgoal-parent"));
40
93
  const parentTask = payload.tasks.find((task) => task.id === "T004");
@@ -472,6 +525,50 @@ test("serves board JSON and streams live state changes over SSE", async () => {
472
525
  }
473
526
  });
474
527
 
528
+ test("coalesces transient active-task violations during multi-write transitions", async () => {
529
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-transition-"));
530
+ const goalDir = join(root, "transition-goal");
531
+ try {
532
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
533
+ writeFileSync(join(goalDir, "state.yaml"), transitionStateYaml({
534
+ activeTask: "T001",
535
+ firstStatus: "active",
536
+ secondStatus: "queued",
537
+ }));
538
+
539
+ const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
540
+ try {
541
+ const controller = new AbortController();
542
+ const events = await fetch(`${server.url}events`, { signal: controller.signal });
543
+ assert.equal(events.status, 200);
544
+ const reader = events.body.getReader();
545
+
546
+ await readUntil(reader, /"activeTask":"T001"/);
547
+ writeFileSync(join(goalDir, "state.yaml"), transitionStateYaml({
548
+ activeTask: "T002",
549
+ firstStatus: "active",
550
+ secondStatus: "active",
551
+ }));
552
+ await delay(120);
553
+ writeFileSync(join(goalDir, "state.yaml"), transitionStateYaml({
554
+ activeTask: "T002",
555
+ firstStatus: "done",
556
+ secondStatus: "active",
557
+ }));
558
+
559
+ const update = await readUntil(reader, /"activeTask":"T002"/);
560
+ assert.doesNotMatch(update, /more than one active task/i);
561
+
562
+ controller.abort();
563
+ await reader.cancel().catch(() => {});
564
+ } finally {
565
+ await server.close();
566
+ }
567
+ } finally {
568
+ rmSync(root, { recursive: true, force: true });
569
+ }
570
+ });
571
+
475
572
  test("streams parent board updates when linked child subgoal state changes", async () => {
476
573
  const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-subgoal-live-"));
477
574
  const goalDir = join(root, "parent-goal");
@@ -624,6 +721,10 @@ async function readUntil(reader, pattern) {
624
721
  assert.fail(`Timed out waiting for ${pattern}. Received:\n${text}`);
625
722
  }
626
723
 
724
+ function delay(ms) {
725
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
726
+ }
727
+
627
728
  function parentWithSubgoalYaml() {
628
729
  return `version: 2
629
730
  goal:
@@ -648,6 +749,39 @@ tasks:
648
749
  `;
649
750
  }
650
751
 
752
+ function transitionStateYaml({ activeTask, firstStatus, secondStatus }) {
753
+ return `version: 2
754
+ goal:
755
+ title: "Transition Goal"
756
+ slug: "transition-goal"
757
+ kind: specific
758
+ tranche: "Verify multi-write task transition."
759
+ status: active
760
+ active_task: ${activeTask}
761
+ tasks:
762
+ - id: T001
763
+ type: scout
764
+ assignee: Scout
765
+ status: ${firstStatus}
766
+ objective: "Map transition."
767
+ receipt:
768
+ result: done
769
+ summary: "Mapped transition."
770
+ - id: T002
771
+ type: worker
772
+ assignee: Worker
773
+ status: ${secondStatus}
774
+ objective: "Implement transition."
775
+ allowed_files:
776
+ - goalbuddy/surfaces/local-goal-board/**
777
+ verify:
778
+ - npm run check
779
+ stop_if:
780
+ - "Need files outside allowed_files."
781
+ receipt: null
782
+ `;
783
+ }
784
+
651
785
  function stateYaml(status, { title = "Live board", slug = "live-board" } = {}) {
652
786
  return `version: 2
653
787
  goal:
@@ -64,6 +64,18 @@ Tiny tasks are allowed when the failure is isolated, the risk is high, the scope
64
64
 
65
65
  Do not stop because a slice needs owner input, credentials, production access, destructive operations, or policy decisions. Mark that exact slice blocked with a receipt, create the smallest safe follow-up or workaround task, and continue all local, non-destructive work that can still move the goal toward the full outcome.
66
66
 
67
+ If an exact human approval phrase is the only remaining blocker and no safe local work remains, ask once and stop. Preserve the exact phrase in the blocked receipt as `required_reply`, set `waiting_for_user_approval: true`, set `goal.status: blocked`, and set `active_task: null`. Do not keep posting approval prompts until the user replies.
68
+
69
+ ## Board Health
70
+
71
+ The PM owns board health. If the board looks stale, misleading, offline, or inconsistent, run the bundled checker:
72
+
73
+ ```bash
74
+ node <skill-path>/scripts/check-goal-state.mjs docs/goals/<slug>
75
+ ```
76
+
77
+ If the local board is running, compare `state.yaml` to the live board API. Repair only GoalBuddy control files unless an active Worker or PM task explicitly allows product-file edits.
78
+
67
79
  ## Canonical Board
68
80
 
69
81
  Machine truth lives at:
@@ -35,6 +35,7 @@ rules:
35
35
  queued_required_worker_blocks_completion: true
36
36
  continuous_until_full_outcome: true
37
37
  missing_input_or_credentials_do_not_stop_goal: true
38
+ exact_human_approval_can_terminal_wait: true
38
39
  preserve_and_validate_existing_plan: true
39
40
  intake_misfire_must_be_audited: true
40
41
  goal_pressure_requires_oracle: true
@@ -58,7 +59,7 @@ visual_board:
58
59
  local:
59
60
  status: not_requested # not_requested | starting | live | generated | blocked
60
61
  url: null
61
- command: "npx goalbuddy board docs/goals/<goal-slug>"
62
+ command: "node <skill-path>/surfaces/local-goal-board/scripts/local-goal-board.mjs --goal docs/goals/<goal-slug>"
62
63
 
63
64
  active_task: T001
64
65
 
@@ -735,7 +735,7 @@ function updateReport() {
735
735
  latest_version: null,
736
736
  update_available: false,
737
737
  check_status: "unknown",
738
- update_command: `npx ${canonicalCliName}`,
738
+ update_command: detectUpdateCommand(),
739
739
  };
740
740
 
741
741
  try {
@@ -750,6 +750,23 @@ function updateReport() {
750
750
  return report;
751
751
  }
752
752
 
753
+ function detectUpdateCommand() {
754
+ if (process.env.GOALBUDDY_TEST_UPDATE_COMMAND) return process.env.GOALBUDDY_TEST_UPDATE_COMMAND;
755
+ if (process.env.CLAUDE_PLUGIN_ROOT || normalizedPath(__dirname).includes("/.claude/")) return `/plugin update ${pluginName}@${pluginName}`;
756
+
757
+ const userAgent = process.env.npm_config_user_agent || "";
758
+ if (/^pnpm\//.test(userAgent)) return `pnpm update -g ${canonicalCliName}`;
759
+ if (/^bun\//.test(userAgent)) return `bun update -g ${canonicalCliName}`;
760
+ if (process.env.MISE_EXE || process.env.MISE_SHELL || process.env.MISE_PROJECT_ROOT) return `mise upgrade npm:${canonicalCliName}`;
761
+ if (/^npm\//.test(userAgent)) return `npx ${canonicalCliName}@latest`;
762
+
763
+ return `use the install channel that installed ${canonicalProductName}`;
764
+ }
765
+
766
+ function normalizedPath(path) {
767
+ return String(path).replace(/\\/g, "/");
768
+ }
769
+
753
770
  function plugin() {
754
771
  const subcommand = positional(1) || "";
755
772
  if (wantsHelp()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goalbuddy",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "A /goal operating loop for Codex and Claude Code: goal oracles, local boards, receipts, and verification.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  ".agents/plugins/marketplace.json",
12
+ ".claude-plugin/marketplace.json",
12
13
  "CHANGELOG.md",
13
14
  "README.md",
14
15
  "CONTRIBUTING.md",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goalbuddy",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Turn broad Codex and Claude Code work into pressured /goal runs with oracles, local boards, receipts, and verification.",
5
5
  "author": {
6
6
  "name": "tolibear",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goalbuddy",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Turn broad Codex and Claude Code work into pressured /goal runs with oracles, local boards, receipts, and verification.",
5
5
  "author": {
6
6
  "name": "tolibear",
@@ -2,7 +2,7 @@
2
2
 
3
3
  GoalBuddy packages the canonical `goal-prep` skill as a plugin so teams can install the reusable workflow in **Codex** and **Claude Code**, while keeping the npm CLI for local setup, doctor checks, and the built-in local board surface.
4
4
 
5
- Version 0.3.8 keeps the Goalmaxxed core and adds board-hub guardrails so agents reuse the shared local board hub instead of stopping it after an unregistered board URL returns 404.
5
+ Version 0.3.9 keeps the Goalmaxxed core and hardens marketplace installs, local board rendering, and PM runtime guidance for approval waits and board-health checks.
6
6
 
7
7
  ## What It Contains
8
8
 
@@ -58,7 +58,7 @@ node <skill-path>/scripts/check-update.mjs --json
58
58
  If the checker reports `update_available: true`, tell the user once before continuing:
59
59
 
60
60
  ```text
61
- GoalBuddy <latest_version> is available. After this turn, update with: npx goalbuddy
61
+ GoalBuddy <latest_version> is available. After this turn, update through the channel that installed GoalBuddy: `/plugin update goalbuddy@goalbuddy`, `npx goalbuddy@latest`, `npm i -g goalbuddy`, `pnpm update -g goalbuddy`, `bun update -g goalbuddy`, or `mise upgrade npm:goalbuddy`.
62
62
  ```
63
63
 
64
64
  Do not block intake or board creation on update checking. If the checker is missing, cannot find npm, or network access fails, continue silently unless the user asked about updates.
@@ -93,9 +93,9 @@ Recommended options:
93
93
  1. Local live board (Recommended) - starts immediately, requires no credentials, and lets the user watch tasks populate inside Codex or Claude Code.
94
94
  2. No visual board - best for quick or private goals where the file board is enough.
95
95
 
96
- 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.
96
+ 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 `node <skill-path>/surfaces/local-goal-board/scripts/local-goal-board.mjs --goal 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.
97
97
 
98
- If `http://goalbuddy.localhost:41737/<slug>/` returns 404, do not assume the existing process is stale and do not stop it. First check `http://127.0.0.1:41737/api/boards`. If that endpoint returns board JSON, the port is the shared multi-board hub; rerun `npx goalbuddy board docs/goals/<slug>` with the absolute goal path if needed so the new goal registers on the same port. Only stop a specific process on 41737 when `/api/boards` is missing, returns 404, or otherwise proves the listener is not a current GoalBuddy multi-board hub.
98
+ If `http://goalbuddy.localhost:41737/<slug>/` returns 404, do not assume the existing process is stale and do not stop it. First check `http://127.0.0.1:41737/api/boards`. If that endpoint returns board JSON, the port is the shared multi-board hub; rerun `node <skill-path>/surfaces/local-goal-board/scripts/local-goal-board.mjs --goal <absolute-goal-path>` if needed so the new goal registers on the same port. Only stop a specific process on 41737 when `/api/boards` is missing, returns 404, or otherwise proves the listener is not a current GoalBuddy multi-board hub.
99
99
 
100
100
  If the user wants an external board, GitHub sync, Slack digest, Linear handoff, or any other custom integration, do not install a GoalBuddy catalog item. Treat it as normal implementation work: create a concrete task that designs and verifies that integration inside the target repo or asks the operator for the required credentials and scope.
101
101
 
@@ -474,6 +474,22 @@ Blocked tasks do not necessarily block the goal. The PM should keep doing safe l
474
474
 
475
475
  Avoid setting `goal.status: blocked` for missing input, credentials, production access, destructive-operation permission, or policy decisions. Block the specific task instead, record the missing requirement, and continue with every safe local workaround or adjacent slice.
476
476
 
477
+ Exception: if an exact human approval phrase is the only remaining blocker and no safe local work remains, ask once, preserve the exact phrase, and stop. Set `goal.status: blocked`, set `active_task: null`, mark every unfinished task `blocked`, and write a receipt with `result: blocked`, `waiting_for_user_approval: true`, and `required_reply: "<exact phrase>"`. Do not rephrase, retry, spawn follow-up work, or post another approval prompt until the user replies.
478
+
479
+ ## Board Health Stewardship
480
+
481
+ The PM owns board health. Do not auto-spawn a separate always-on steward by default.
482
+
483
+ When the board looks stale, misleading, offline, Not Found, or inconsistent, run the bundled checker:
484
+
485
+ ```bash
486
+ node <skill-path>/scripts/check-goal-state.mjs docs/goals/<slug>
487
+ ```
488
+
489
+ If a local board server is running, compare `state.yaml` with `http://127.0.0.1:41737/<slug>/api/board` or `http://127.0.0.1:41737/api/boards`. Repair only GoalBuddy control files: `goal.md`, `state.yaml`, `notes/`, depth-1 `subgoals/`, and `.goalbuddy-board/`. Never edit product implementation files during board-health work unless there is an active Worker or PM task with explicit `allowed_files`.
490
+
491
+ Board-health work should verify these truths: `active_task` matches live task status, done and blocked tasks have receipts, human-blocked work is in the blocked column, future work stays queued, and the live board/API reflects `state.yaml`.
492
+
477
493
  ## Operator Escalation
478
494
 
479
495
  When Scout, Judge, Worker, or PM discovers a problem, improvement opportunity, product suggestion, follow-up repair, or tool limitation that should not be fixed inside the current active task, do not let it disappear in chat.
@@ -528,9 +544,9 @@ Use these `state.yaml` values:
528
544
  | State | Meaning | Next action |
529
545
  |---|---|---|
530
546
  | `installed` | Matching Scout/Worker/Judge agent configs were found in the expected user or project agent location. | Continue. |
531
- | `bundled_not_installed` | The bundled `goal_*.toml` template exists with the skill, but no matching installed agent config was verified. | `/goal` can proceed through PM fallback. If dedicated agents are required before `/goal`, run `npx goalbuddy agents`. |
532
- | `missing` | Neither an installed config nor the bundled template was verified. | `/goal` can proceed through PM fallback. If dedicated agents are required before `/goal`, run `npx goalbuddy install`. |
533
- | `unknown` | Agent availability could not be checked. | `/goal` can proceed through PM fallback. To check before `/goal`, run `npx goalbuddy doctor`. |
547
+ | `bundled_not_installed` | The bundled `goal_*.toml` template exists with the skill, but no matching installed agent config was verified. | `/goal` can proceed through PM fallback. If dedicated agents are required before `/goal`, run the GoalBuddy CLI through the user's install channel with `agents`. |
548
+ | `missing` | Neither an installed config nor the bundled template was verified. | `/goal` can proceed through PM fallback. If dedicated agents are required before `/goal`, run the GoalBuddy CLI through the user's install channel with `install`. |
549
+ | `unknown` | Agent availability could not be checked. | `/goal` can proceed through PM fallback. To check before `/goal`, run the GoalBuddy CLI through the user's install channel with `doctor`. |
534
550
 
535
551
  Non-`installed` states are warnings, not false failures, because the main `/goal` PM can perform Scout/Judge/Worker-shaped tasks directly when dedicated agents are unavailable.
536
552
 
@@ -570,11 +586,11 @@ Treat `reasoning_hint` as PM guidance. It does not override task scope, write pe
570
586
 
571
587
  ## Execution Quality Commands
572
588
 
573
- 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.
589
+ Use `node <skill-path>/scripts/render-task-prompt.mjs 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.
574
590
 
575
- 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.
591
+ 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 ask the operator to run the GoalBuddy CLI through their install channel with `agents` or `install`. After one `wait_agent` timeout with no visible allowed-file changes, stop waiting, record the timeout, and recover deterministically instead of waiting forever.
576
592
 
577
- 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.
593
+ Use `node <skill-path>/scripts/parallel-plan.mjs 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.
578
594
 
579
595
  ## Completion
580
596
 
@@ -297,12 +297,12 @@ if (isWeakProof(completionProof)) {
297
297
  function agentStatusWarning(agent, status) {
298
298
  const agentLabel = agent[0].toUpperCase() + agent.slice(1);
299
299
  if (status === "bundled_not_installed") {
300
- 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`;
300
+ 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 the GoalBuddy CLI through the user's install channel with: agents`;
301
301
  }
302
302
  if (status === "missing") {
303
- 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`;
303
+ 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 the GoalBuddy CLI through the user's install channel with: install`;
304
304
  }
305
- 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`;
305
+ return `agents.${agent} is unknown; /goal can continue through PM fallback, but dedicated ${agentLabel} delegation was not verified. To check before /goal, run the GoalBuddy CLI through the user's install channel with: doctor`;
306
306
  }
307
307
 
308
308
  for (const { agent, status } of agentStatuses) {
@@ -353,6 +353,7 @@ for (const task of tasks) {
353
353
  if (tasks.length === 0) errors.push("tasks must contain at least one task");
354
354
 
355
355
  const activeTasks = tasks.filter((task) => task.status === "active");
356
+ const terminalApprovalWait = isTerminalApprovalWait(tasks, activeTasks, activeTask);
356
357
  if (goalStatus === "done") {
357
358
  if (activeTasks.length !== 0) errors.push("done goals must not have an active task");
358
359
  if (activeTask !== null) errors.push("done goals must set active_task: null");
@@ -364,7 +365,7 @@ if (goalStatus === "done") {
364
365
  }
365
366
  } else if (goalStatus === "blocked") {
366
367
  if (activeTasks.length > 1) errors.push("blocked goals may have at most one active task");
367
- if (continuousUntilFullOutcome && missingInputOrCredentialsDoNotStopGoal) {
368
+ if (continuousUntilFullOutcome && missingInputOrCredentialsDoNotStopGoal && !terminalApprovalWait) {
368
369
  errors.push("continuous goals must keep goal.status active; missing input or credentials should block specific tasks, not the whole goal");
369
370
  }
370
371
  } else if (activeTasks.length !== 1) {
@@ -436,6 +437,23 @@ for (const task of tasks) {
436
437
 
437
438
  warnings.push(...microSliceWarnings(tasks, activeTask, goalStatus));
438
439
 
440
+ function isTerminalApprovalWait(tasks, activeTasks, activeTask) {
441
+ if (goalStatus !== "blocked") return false;
442
+ if (activeTask !== null) return false;
443
+ if (activeTasks.length !== 0) return false;
444
+
445
+ const unfinishedTasks = tasks.filter((task) => task.status !== "done");
446
+ if (unfinishedTasks.length === 0) return false;
447
+ if (unfinishedTasks.some((task) => task.status !== "blocked")) return false;
448
+
449
+ return unfinishedTasks.some((task) => {
450
+ if (!task.receipt.present || task.receipt.value === null) return false;
451
+ return task.receipt.scalar("result") === "blocked"
452
+ && task.receipt.scalar("waiting_for_user_approval") === true
453
+ && Boolean(task.receipt.scalar("required_reply"));
454
+ });
455
+ }
456
+
439
457
  function validateSubgoal(task) {
440
458
  if (isChildCheck) {
441
459
  errors.push(`child task ${task.id} must not contain a nested subgoal`);
@@ -14,7 +14,7 @@ const report = {
14
14
  latest_version: null,
15
15
  update_available: false,
16
16
  check_status: "unknown",
17
- update_command: "npx goalbuddy",
17
+ update_command: detectUpdateCommand(),
18
18
  };
19
19
 
20
20
  try {
@@ -77,6 +77,23 @@ function latestPublishedVersion() {
77
77
  return normalizeVersion(result.stdout);
78
78
  }
79
79
 
80
+ function detectUpdateCommand() {
81
+ if (process.env.GOALBUDDY_TEST_UPDATE_COMMAND) return process.env.GOALBUDDY_TEST_UPDATE_COMMAND;
82
+ if (process.env.CLAUDE_PLUGIN_ROOT || normalizedPath(scriptDir).includes("/.claude/")) return "/plugin update goalbuddy@goalbuddy";
83
+
84
+ const userAgent = process.env.npm_config_user_agent || "";
85
+ if (/^pnpm\//.test(userAgent)) return "pnpm update -g goalbuddy";
86
+ if (/^bun\//.test(userAgent)) return "bun update -g goalbuddy";
87
+ if (process.env.MISE_EXE || process.env.MISE_SHELL || process.env.MISE_PROJECT_ROOT) return "mise upgrade npm:goalbuddy";
88
+ if (/^npm\//.test(userAgent)) return "npx goalbuddy@latest";
89
+
90
+ return "use the install channel that installed GoalBuddy";
91
+ }
92
+
93
+ function normalizedPath(path) {
94
+ return String(path).replace(/\\/g, "/");
95
+ }
96
+
80
97
  function readJson(path) {
81
98
  if (!existsSync(path)) return null;
82
99
  try {
@@ -88,9 +88,6 @@ export function normalizeGoalBoard(document, goalDir = "<memory>") {
88
88
 
89
89
  const tasks = document.tasks.map((task, index) => normalizeTask(task, index));
90
90
  const activeTasks = tasks.filter((task) => task.status === "active");
91
- if (activeTasks.length > 1) {
92
- throw new GoalBoardError("Goal state has more than one active task.");
93
- }
94
91
 
95
92
  return {
96
93
  goalDir,
@@ -148,7 +145,7 @@ export function buildColumns(tasks) {
148
145
 
149
146
  return [
150
147
  { id: "todo", title: "Todo", description: "Queued work ready to pull", tasks: byColumn.get("todo") },
151
- { id: "in-progress", title: "In Progress", description: "The active task", tasks: byColumn.get("in-progress") },
148
+ { id: "in-progress", title: "In Progress", description: "Active task work", tasks: byColumn.get("in-progress") },
152
149
  { id: "blocked", title: "Blocked", description: "Needs unblock or a smaller slice", tasks: byColumn.get("blocked") },
153
150
  { id: "completed", title: "Completed", description: "Receipted work", tasks: byColumn.get("completed") },
154
151
  ];
@@ -33,6 +33,7 @@ const SETTINGS_OPTIONS = {
33
33
  const DEFAULT_BIND_HOST = "127.0.0.1";
34
34
  const DEFAULT_PUBLIC_HOST = "goalbuddy.localhost";
35
35
  const DEFAULT_PORT = 41737;
36
+ const STATE_CHANGE_SETTLE_MS = 300;
36
37
 
37
38
  if (isDirectRun()) {
38
39
  main().catch((error) => {
@@ -441,7 +442,7 @@ async function readJsonRequest(request) {
441
442
 
442
443
  function watchGoal(goalDir, onChange) {
443
444
  const watchers = [];
444
- const schedule = debounce(onChange, 80);
445
+ const schedule = debounce(onChange, STATE_CHANGE_SETTLE_MS);
445
446
  let watchedDirs = new Set();
446
447
 
447
448
  const rebuild = () => {
@@ -64,6 +64,18 @@ Tiny tasks are allowed when the failure is isolated, the risk is high, the scope
64
64
 
65
65
  Do not stop because a slice needs owner input, credentials, production access, destructive operations, or policy decisions. Mark that exact slice blocked with a receipt, create the smallest safe follow-up or workaround task, and continue all local, non-destructive work that can still move the goal toward the full outcome.
66
66
 
67
+ If an exact human approval phrase is the only remaining blocker and no safe local work remains, ask once and stop. Preserve the exact phrase in the blocked receipt as `required_reply`, set `waiting_for_user_approval: true`, set `goal.status: blocked`, and set `active_task: null`. Do not keep posting approval prompts until the user replies.
68
+
69
+ ## Board Health
70
+
71
+ The PM owns board health. If the board looks stale, misleading, offline, or inconsistent, run the bundled checker:
72
+
73
+ ```bash
74
+ node <skill-path>/scripts/check-goal-state.mjs docs/goals/<slug>
75
+ ```
76
+
77
+ If the local board is running, compare `state.yaml` to the live board API. Repair only GoalBuddy control files unless an active Worker or PM task explicitly allows product-file edits.
78
+
67
79
  ## Canonical Board
68
80
 
69
81
  Machine truth lives at:
@@ -35,6 +35,7 @@ rules:
35
35
  queued_required_worker_blocks_completion: true
36
36
  continuous_until_full_outcome: true
37
37
  missing_input_or_credentials_do_not_stop_goal: true
38
+ exact_human_approval_can_terminal_wait: true
38
39
  preserve_and_validate_existing_plan: true
39
40
  intake_misfire_must_be_audited: true
40
41
  goal_pressure_requires_oracle: true
@@ -58,7 +59,7 @@ visual_board:
58
59
  local:
59
60
  status: not_requested # not_requested | starting | live | generated | blocked
60
61
  url: null
61
- command: "npx goalbuddy board docs/goals/<goal-slug>"
62
+ command: "node <skill-path>/surfaces/local-goal-board/scripts/local-goal-board.mjs --goal docs/goals/<goal-slug>"
62
63
 
63
64
  active_task: T001
64
65