goalbuddy 0.3.7 → 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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/CHANGELOG.md +70 -0
  3. package/CONTRIBUTING.md +2 -2
  4. package/README.md +11 -3
  5. package/{RELEASE-0.3.7.md → docs/releases/0.3.7.md} +2 -0
  6. package/docs/releases/0.3.8.md +40 -0
  7. package/docs/releases/0.3.9.md +46 -0
  8. package/docs/releases/README.md +84 -0
  9. package/goalbuddy/SKILL.md +26 -8
  10. package/goalbuddy/scripts/check-goal-state.mjs +22 -4
  11. package/goalbuddy/scripts/check-update.mjs +18 -1
  12. package/goalbuddy/scripts/render-task-prompt.mjs +17 -3
  13. package/goalbuddy/surfaces/local-goal-board/scripts/lib/goal-board.mjs +16 -15
  14. package/goalbuddy/surfaces/local-goal-board/scripts/local-goal-board.mjs +25 -3
  15. package/goalbuddy/surfaces/local-goal-board/test/local-goal-board.test.mjs +189 -4
  16. package/goalbuddy/templates/goal.md +12 -0
  17. package/goalbuddy/templates/state.yaml +2 -1
  18. package/internal/cli/goal-maker.mjs +186 -7
  19. package/package.json +6 -6
  20. package/plugins/goalbuddy/.claude-plugin/plugin.json +1 -1
  21. package/plugins/goalbuddy/.codex-plugin/plugin.json +1 -1
  22. package/plugins/goalbuddy/README.md +1 -1
  23. package/plugins/goalbuddy/skills/goalbuddy/SKILL.md +26 -8
  24. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-goal-state.mjs +22 -4
  25. package/plugins/goalbuddy/skills/goalbuddy/scripts/check-update.mjs +18 -1
  26. package/plugins/goalbuddy/skills/goalbuddy/scripts/render-task-prompt.mjs +17 -3
  27. package/plugins/goalbuddy/skills/goalbuddy/surfaces/local-goal-board/scripts/lib/goal-board.mjs +1 -4
  28. package/plugins/goalbuddy/skills/goalbuddy/surfaces/local-goal-board/scripts/local-goal-board.mjs +25 -3
  29. package/plugins/goalbuddy/skills/goalbuddy/surfaces/local-goal-board/test/local-goal-board.test.mjs +27 -0
  30. package/plugins/goalbuddy/skills/goalbuddy/templates/goal.md +12 -0
  31. package/plugins/goalbuddy/skills/goalbuddy/templates/state.yaml +2 -1
  32. package/examples/improve-goal-maker/goal.md +0 -51
  33. package/examples/improve-goal-maker/notes/T001-repo-map.md +0 -59
  34. package/examples/improve-goal-maker/notes/T002-risk-map.md +0 -37
  35. package/examples/improve-goal-maker/state.yaml +0 -224
  36. /package/{RELEASE-0.3.5.md → docs/releases/0.3.5.md} +0 -0
@@ -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) => {
@@ -228,8 +229,7 @@ export async function startBoardServer(options = {}) {
228
229
 
229
230
  const route = routeBoardRequest(url.pathname, boards, initialBoard);
230
231
  if (!route.board) {
231
- response.writeHead(404);
232
- response.end("Not found");
232
+ sendUnregisteredBoardPath(response, url.pathname, boards, baseUrl);
233
233
  return;
234
234
  }
235
235
  if (route.pathname === "/api/board") {
@@ -400,6 +400,28 @@ function routeBoardRequest(pathname, boards, initialBoard) {
400
400
  return matches[0] || { board: null, pathname };
401
401
  }
402
402
 
403
+ function sendUnregisteredBoardPath(response, pathname, boards, baseUrl) {
404
+ response.writeHead(404, {
405
+ "Content-Type": "text/plain; charset=utf-8",
406
+ "Cache-Control": "no-store",
407
+ });
408
+ const registeredBoards = [...boards.values()].map((board) => {
409
+ const summary = boardSummary(board, baseUrl);
410
+ return `- ${summary.title}: ${summary.url}`;
411
+ });
412
+ response.end([
413
+ `GoalBuddy board path is not registered in this local hub: ${pathname}`,
414
+ "",
415
+ "This server is the GoalBuddy multi-board hub. Do not stop it just because a /<slug>/ board URL returned 404.",
416
+ "Start or rerun `npx goalbuddy board <goal-dir>` to register that goal on this same port, then open the printed /<slug>/ URL.",
417
+ "",
418
+ "Registered boards:",
419
+ registeredBoards.length ? registeredBoards.join("\n") : "- none",
420
+ "",
421
+ `Hub API: ${baseUrl}/api/boards`,
422
+ ].join("\n"));
423
+ }
424
+
403
425
  function stripBoardPathPrefix(pathname, boardPath) {
404
426
  const prefix = boardPath.endsWith("/") ? boardPath.slice(0, -1) : boardPath;
405
427
  if (pathname === prefix) return "/";
@@ -420,7 +442,7 @@ async function readJsonRequest(request) {
420
442
 
421
443
  function watchGoal(goalDir, onChange) {
422
444
  const watchers = [];
423
- const schedule = debounce(onChange, 80);
445
+ const schedule = debounce(onChange, STATE_CHANGE_SETTLE_MS);
424
446
  let watchedDirs = new Set();
425
447
 
426
448
  const rebuild = () => {
@@ -4,7 +4,7 @@ import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } f
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join, resolve } from "node:path";
7
- import { createBoardPayload, writeBoardApp } from "../scripts/lib/goal-board.mjs";
7
+ import { buildColumns, createBoardPayload, writeBoardApp } from "../scripts/lib/goal-board.mjs";
8
8
  import { parseArgs, startBoardServer } from "../scripts/local-goal-board.mjs";
9
9
 
10
10
  test("normalizes a dense goal into local board columns", () => {
@@ -23,6 +23,71 @@ test("normalizes a dense goal into local board columns", () => {
23
23
  assert.equal(scout.receipt.summary, "T001 completed during the progressive board motion demo.");
24
24
  });
25
25
 
26
+ test("orders completed cards newest first while preserving queued order", () => {
27
+ const columns = buildColumns([
28
+ { id: "T001", column: "completed", status: "done" },
29
+ { id: "T002", column: "todo", status: "queued" },
30
+ { id: "T003", column: "completed", status: "done" },
31
+ { id: "T004", column: "todo", status: "queued" },
32
+ ]);
33
+
34
+ assert.deepEqual(columns.find((column) => column.id === "todo").tasks.map((task) => task.id), ["T002", "T004"]);
35
+ assert.deepEqual(columns.find((column) => column.id === "completed").tasks.map((task) => task.id), ["T003", "T001"]);
36
+ });
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
+
26
91
  test("loads depth-1 subgoal boards into parent task payloads", () => {
27
92
  const payload = createBoardPayload(resolve("goalbuddy/surfaces/local-goal-board/examples/subgoal-parent"));
28
93
  const parentTask = payload.tasks.find((task) => task.id === "T004");
@@ -37,10 +102,10 @@ test("loads depth-1 subgoal boards into parent task payloads", () => {
37
102
  assert.equal(parentTask.subgoal.board.tasks.find((task) => task.id === "T002").subgoal, null);
38
103
  });
39
104
 
40
- test("uses compact card titles while preserving full objectives", () => {
41
- const root = mkdtempSync(join(tmpdir(), "goalbuddy-compact-titles-"));
105
+ test("uses readable card titles while preserving full objectives", () => {
106
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-readable-titles-"));
42
107
  try {
43
- const goalDir = join(root, "compact-titles");
108
+ const goalDir = join(root, "readable-titles");
44
109
  mkdirSync(join(goalDir, "notes"), { recursive: true });
45
110
  writeFileSync(join(goalDir, "state.yaml"), `version: 2
46
111
  goal:
@@ -70,6 +135,13 @@ tasks:
70
135
  status: queued
71
136
  objective: "This objective can stay much more detailed because it belongs in the modal, not on the card face."
72
137
  receipt: null
138
+ - id: T004
139
+ title: "Run installed-Cursor runtime proof for a named model request through the local BYOK bridge"
140
+ type: worker
141
+ assignee: Worker
142
+ status: queued
143
+ objective: "Run installed-Cursor runtime proof for a named model request through the local BYOK bridge."
144
+ receipt: null
73
145
  `);
74
146
 
75
147
  const payload = createBoardPayload(goalDir);
@@ -77,6 +149,10 @@ tasks:
77
149
  assert.equal(payload.tasks.find((task) => task.id === "T001").objective.includes("admin_seed_metrics.enrichment_qa"), true);
78
150
  assert.equal(payload.tasks.find((task) => task.id === "T002").title, "Implement /contacts/con_aaron_keller route");
79
151
  assert.equal(payload.tasks.find((task) => task.id === "T003").title, "Human-friendly release title");
152
+ assert.equal(
153
+ payload.tasks.find((task) => task.id === "T004").title,
154
+ "Run installed-Cursor runtime proof for a named model request through the local BYOK bridge",
155
+ );
80
156
  } finally {
81
157
  rmSync(root, { recursive: true, force: true });
82
158
  }
@@ -249,6 +325,7 @@ test("writes a minimal GoalBuddy web app into the goal directory", () => {
249
325
  assert.match(css, /:root\[data-theme="dark"\]/);
250
326
  assert.match(css, /:root\[data-density="compact"\] \.task-card/);
251
327
  assert.match(css, /:root\[data-completed-visibility="collapse"\]/);
328
+ assert.match(css, /-webkit-line-clamp: 5/);
252
329
  assert.match(css, /\.subgoal-board/);
253
330
  assert.match(css, /\.board-error/);
254
331
  assert.match(js, /new EventSource\("\.\/events"\)/);
@@ -448,6 +525,50 @@ test("serves board JSON and streams live state changes over SSE", async () => {
448
525
  }
449
526
  });
450
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
+
451
572
  test("streams parent board updates when linked child subgoal state changes", async () => {
452
573
  const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-subgoal-live-"));
453
574
  const goalDir = join(root, "parent-goal");
@@ -558,6 +679,33 @@ test("serves multiple local boards from one shared hub URL", async () => {
558
679
  }
559
680
  });
560
681
 
682
+ test("unregistered board paths explain hub reuse instead of stale-port cleanup", async () => {
683
+ const root = mkdtempSync(join(tmpdir(), "goalbuddy-local-board-unregistered-"));
684
+ const goalDir = join(root, "first-goal");
685
+ try {
686
+ mkdirSync(join(goalDir, "notes"), { recursive: true });
687
+ writeFileSync(join(goalDir, "state.yaml"), stateYaml("active", { title: "First Goal", slug: "first-goal" }));
688
+
689
+ const server = await startBoardServer({ goalDir, host: "127.0.0.1", port: 0 });
690
+ try {
691
+ const baseUrl = new URL(server.url).origin;
692
+ const missingResponse = await fetch(`${baseUrl}/rinova-client-revision-redesign/`);
693
+ assert.equal(missingResponse.status, 404);
694
+ const message = await missingResponse.text();
695
+ assert.match(message, /board path is not registered/i);
696
+ assert.match(message, /multi-board hub/i);
697
+ assert.match(message, /Do not stop it just because a \/<slug>\/ board URL returned 404/);
698
+ assert.match(message, /npx goalbuddy board <goal-dir>/);
699
+ assert.match(message, /First Goal/);
700
+ assert.match(message, /\/api\/boards/);
701
+ } finally {
702
+ await server.close();
703
+ }
704
+ } finally {
705
+ rmSync(root, { recursive: true, force: true });
706
+ }
707
+ });
708
+
561
709
  async function readUntil(reader, pattern) {
562
710
  const decoder = new TextDecoder();
563
711
  let text = "";
@@ -573,6 +721,10 @@ async function readUntil(reader, pattern) {
573
721
  assert.fail(`Timed out waiting for ${pattern}. Received:\n${text}`);
574
722
  }
575
723
 
724
+ function delay(ms) {
725
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
726
+ }
727
+
576
728
  function parentWithSubgoalYaml() {
577
729
  return `version: 2
578
730
  goal:
@@ -597,6 +749,39 @@ tasks:
597
749
  `;
598
750
  }
599
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
+
600
785
  function stateYaml(status, { title = "Live board", slug = "live-board" } = {}) {
601
786
  return `version: 2
602
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
 
@@ -49,6 +49,7 @@ const optionsWithValues = new Set([
49
49
  "--task",
50
50
  "--board",
51
51
  ]);
52
+ const pathOptions = new Set(["--board", "--goal"]);
52
53
 
53
54
  const args = process.argv.slice(2);
54
55
  const command = args[0] === "--help" || args[0] === "-h"
@@ -111,6 +112,17 @@ async function main() {
111
112
  doctorClaude();
112
113
  }
113
114
  break;
115
+ case "reset":
116
+ if (wantsHelp()) {
117
+ usage();
118
+ break;
119
+ }
120
+ if (targetMode() !== "codex") {
121
+ console.error("Reset currently supports --target codex only.");
122
+ process.exit(2);
123
+ }
124
+ resetCodex();
125
+ break;
114
126
  case "check-update":
115
127
  case "update-check":
116
128
  checkUpdate();
@@ -193,6 +205,35 @@ function positionalArgs() {
193
205
  return values;
194
206
  }
195
207
 
208
+ /**
209
+ * Resolve goal-related paths in raw args to absolute paths.
210
+ * Child processes spawned with cwd=packageRoot cannot resolve
211
+ * relative goal paths from the user's working directory.
212
+ */
213
+ function resolveChildGoalArgs(rawArgs) {
214
+ const out = [];
215
+ for (let index = 0; index < rawArgs.length; index += 1) {
216
+ const arg = rawArgs[index];
217
+ const joinedMatch = [...pathOptions].find((opt) => arg.startsWith(opt + "="));
218
+ if (joinedMatch) {
219
+ const value = arg.slice(joinedMatch.length + 1);
220
+ out.push(`${joinedMatch}=${value ? resolve(value) : value}`);
221
+ } else if (pathOptions.has(arg)) {
222
+ out.push(arg);
223
+ const value = rawArgs[++index] || "";
224
+ out.push(value ? resolve(value) : value);
225
+ } else if (optionsWithValues.has(arg)) {
226
+ out.push(arg);
227
+ out.push(rawArgs[++index] || "");
228
+ } else if (!arg.startsWith("-")) {
229
+ out.push(resolve(arg));
230
+ } else {
231
+ out.push(arg);
232
+ }
233
+ }
234
+ return out;
235
+ }
236
+
196
237
  function usage() {
197
238
  console.log(`${canonicalProductName} for Claude Code and Codex
198
239
 
@@ -203,6 +244,7 @@ Usage:
203
244
  ${canonicalCliName} update [--target claude|codex] [--claude-home <path>] [--codex-home <path>] [--json]
204
245
  ${canonicalCliName} agents [--target claude|codex] [--claude-home <path>] [--codex-home <path>] [--force]
205
246
  ${canonicalCliName} doctor [--target claude|codex] [--claude-home <path>] [--codex-home <path>] [--goal-ready]
247
+ ${canonicalCliName} reset --target codex [--codex-home <path>] [--json]
206
248
  ${canonicalCliName} check-update [--json]
207
249
  ${canonicalCliName} board <docs/goals/slug> [--host <host>] [--port <port>] [--once] [--json]
208
250
  ${canonicalCliName} prompt <docs/goals/slug> [--task T###] [--board <path/to/state.yaml>] [--json]
@@ -576,20 +618,36 @@ function doctor() {
576
618
  const agents = existsSync(agentsPath)
577
619
  ? readdirSync(agentsPath).filter((file) => file.startsWith("goal_") && file.endsWith(".toml"))
578
620
  : [];
579
- const missingAgents = requiredAgentFiles.filter((file) => !agents.includes(file));
621
+ const installSurfacePresent = plugin.skill_installed || installed || legacyInstalled;
622
+ const residualAgents = installSurfacePresent ? [] : agents.filter((file) => requiredAgentFiles.includes(file));
623
+ const missingAgents = installSurfacePresent || residualAgents.length > 0
624
+ ? requiredAgentFiles.filter((file) => !agents.includes(file))
625
+ : [];
580
626
  const staleAgents = requiredAgentFiles.filter((file) => {
581
627
  const installedAgent = join(agentsPath, file);
582
628
  const bundledAgent = join(skillSource, "agents", file);
583
629
  if (!existsSync(installedAgent) || !existsSync(bundledAgent)) return false;
584
630
  return sha256(readFileSync(installedAgent)) !== sha256(readFileSync(bundledAgent));
585
631
  });
632
+ const runtimeState = codexInstallState({
633
+ plugin,
634
+ installed,
635
+ legacyInstalled,
636
+ residualAgents,
637
+ missingAgents,
638
+ staleAgents,
639
+ });
586
640
  const goalRuntime = codexGoalRuntimeStatus();
587
641
  const warnings = [];
588
642
  const errors = [];
589
643
  if (!goalRuntime.ready) {
590
644
  warnings.push("native Codex /goal runtime is not ready; run `codex login` and `codex features enable goals` before using /goal.");
591
645
  }
592
- if (!plugin.skill_installed && !installed) {
646
+ if (runtimeState === "fully-removed") {
647
+ errors.push("Codex GoalBuddy is fully removed; run `npx goalbuddy --target codex` to install.");
648
+ } else if (runtimeState === "residual-agents-only") {
649
+ errors.push(`Residual GoalBuddy Codex agents remain without plugin cache/config: ${residualAgents.join(", ")}; run a GoalBuddy reset/cleanup before treating it as removed.`);
650
+ } else if (!plugin.skill_installed && !installed) {
593
651
  errors.push("Codex GoalBuddy plugin is not installed; run `npx goalbuddy --target codex`.");
594
652
  }
595
653
  if (plugin.skill_installed && !plugin.enabled) {
@@ -621,7 +679,9 @@ function doctor() {
621
679
  skill_path: skillPath,
622
680
  compatibility_skill_installed: legacyInstalled,
623
681
  compatibility_skill_path: legacySkillPath,
682
+ runtime_state: runtimeState,
624
683
  installed_agents: agents,
684
+ residual_agents: residualAgents,
625
685
  missing_agents: missingAgents,
626
686
  stale_agents: staleAgents,
627
687
  goal_runtime: goalRuntime,
@@ -636,6 +696,20 @@ function doctor() {
636
696
  process.exit(installOk && goalReadyOk && errors.length === 0 ? 0 : 1);
637
697
  }
638
698
 
699
+ function codexInstallState({ plugin, installed, legacyInstalled, residualAgents, missingAgents, staleAgents }) {
700
+ if (residualAgents.length > 0 && !plugin.skill_installed && !installed && !legacyInstalled) {
701
+ return "residual-agents-only";
702
+ }
703
+ if (!plugin.skill_installed && !installed && !legacyInstalled) {
704
+ return "fully-removed";
705
+ }
706
+ if (staleAgents.length > 0) return "stale-agents";
707
+ if (missingAgents.length > 0) return "incomplete";
708
+ if (plugin.skill_installed && !plugin.enabled) return "disabled";
709
+ if ((plugin.skill_installed && plugin.enabled) || installed) return "installed";
710
+ return "incomplete";
711
+ }
712
+
639
713
  function checkUpdate() {
640
714
  const report = updateReport();
641
715
 
@@ -661,7 +735,7 @@ function updateReport() {
661
735
  latest_version: null,
662
736
  update_available: false,
663
737
  check_status: "unknown",
664
- update_command: `npx ${canonicalCliName}`,
738
+ update_command: detectUpdateCommand(),
665
739
  };
666
740
 
667
741
  try {
@@ -676,6 +750,23 @@ function updateReport() {
676
750
  return report;
677
751
  }
678
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
+
679
770
  function plugin() {
680
771
  const subcommand = positional(1) || "";
681
772
  if (wantsHelp()) {
@@ -782,8 +873,95 @@ function cleanupLegacyCodexSkills() {
782
873
  return removed;
783
874
  }
784
875
 
876
+ function resetCodex() {
877
+ const configPath = join(codexHome(), "config.toml");
878
+ const removedConfigSections = [];
879
+ if (existsSync(configPath)) {
880
+ const existing = readFileSync(configPath, "utf8");
881
+ let updated = existing;
882
+ for (const header of [`[plugins."${pluginName}@${pluginName}"]`, `[marketplaces.${pluginName}]`]) {
883
+ const next = removeTomlTable(updated, header);
884
+ if (next !== updated) {
885
+ removedConfigSections.push(header);
886
+ updated = next;
887
+ }
888
+ }
889
+ if (updated !== existing) writeFileSync(configPath, updated);
890
+ }
891
+
892
+ const removedPluginCachePaths = [];
893
+ const cacheRoot = pluginCacheOwnerRoot();
894
+ if (existsSync(cacheRoot)) {
895
+ rmSync(cacheRoot, { recursive: true, force: true });
896
+ removedPluginCachePaths.push(cacheRoot);
897
+ }
898
+
899
+ const removedAgents = [];
900
+ const agentsRoot = join(codexHome(), "agents");
901
+ for (const file of requiredAgentFiles) {
902
+ const path = join(agentsRoot, file);
903
+ if (!existsSync(path)) continue;
904
+ rmSync(path, { recursive: true, force: true });
905
+ removedAgents.push(path);
906
+ }
907
+
908
+ const removedLegacySkillPaths = cleanupLegacyCodexSkills();
909
+ const report = {
910
+ reset: true,
911
+ target: "codex",
912
+ codex_home: codexHome(),
913
+ config_path: configPath,
914
+ removed_config_sections: removedConfigSections,
915
+ removed_plugin_cache_paths: removedPluginCachePaths,
916
+ removed_agents: removedAgents,
917
+ removed_legacy_skill_paths: removedLegacySkillPaths,
918
+ };
919
+
920
+ if (hasFlag("--json")) {
921
+ printJson(report);
922
+ return report;
923
+ }
924
+
925
+ console.log(`Reset ${canonicalProductName} Codex-owned runtime files`);
926
+ console.log(`Config sections: ${removedConfigSections.length ? removedConfigSections.join(", ") : "none"}`);
927
+ console.log(`Plugin cache: ${removedPluginCachePaths.length ? removedPluginCachePaths.join(", ") : "none"}`);
928
+ console.log(`Agents: ${removedAgents.length ? removedAgents.join(", ") : "none"}`);
929
+ console.log(`Legacy personal skills: ${removedLegacySkillPaths.length ? removedLegacySkillPaths.join(", ") : "none"}`);
930
+ return report;
931
+ }
932
+
933
+ function removeTomlTable(text, header) {
934
+ const normalized = text.endsWith("\n") || text.length === 0 ? text : `${text}\n`;
935
+ const lines = normalized.split("\n");
936
+ const output = [];
937
+ let skipping = false;
938
+ let removed = false;
939
+ const descendantPrefix = `${header.slice(0, -1)}.`;
940
+
941
+ for (const line of lines) {
942
+ const trimmed = line.trim();
943
+ if (trimmed === header || trimmed.startsWith(descendantPrefix)) {
944
+ skipping = true;
945
+ removed = true;
946
+ continue;
947
+ }
948
+ if (skipping && /^\s*\[/.test(line)) {
949
+ skipping = trimmed.startsWith(descendantPrefix);
950
+ if (skipping) continue;
951
+ }
952
+ if (!skipping) output.push(line);
953
+ }
954
+
955
+ if (!removed) return text;
956
+ return output.join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
957
+ }
958
+
959
+ function pluginCacheOwnerRoot() {
960
+ return join(codexHome(), "plugins", "cache", pluginName);
961
+ }
962
+
785
963
  function pluginCacheRoot(version) {
786
- return join(codexHome(), "plugins", "cache", pluginName, pluginName, version);
964
+ return join(pluginCacheOwnerRoot(), pluginName, version);
787
965
  }
788
966
 
789
967
  function enablePluginConfig() {
@@ -913,8 +1091,9 @@ async function board() {
913
1091
  process.exit(2);
914
1092
  }
915
1093
 
1094
+ const absoluteGoal = resolve(goal);
916
1095
  const script = ensureLocalBoardSurface();
917
- const scriptArgs = [script, "--goal", goal];
1096
+ const scriptArgs = [script, "--goal", absoluteGoal];
918
1097
  for (const option of ["--host", "--port"]) {
919
1098
  const value = optionValue(option);
920
1099
  if (value) scriptArgs.push(option, value);
@@ -945,7 +1124,7 @@ async function prompt() {
945
1124
  }
946
1125
 
947
1126
  const script = join(skillSource, "scripts", "render-task-prompt.mjs");
948
- const scriptArgs = [script, ...args.slice(1)];
1127
+ const scriptArgs = [script, ...resolveChildGoalArgs(args.slice(1))];
949
1128
  const result = spawnSync(process.execPath, scriptArgs, {
950
1129
  cwd: packageRoot,
951
1130
  encoding: "utf8",
@@ -959,7 +1138,7 @@ async function prompt() {
959
1138
 
960
1139
  async function parallelPlan() {
961
1140
  const script = join(skillSource, "scripts", "parallel-plan.mjs");
962
- const scriptArgs = [script, ...args.slice(1).filter((arg) => arg !== "--parallel-plan")];
1141
+ const scriptArgs = [script, ...resolveChildGoalArgs(args.slice(1).filter((arg) => arg !== "--parallel-plan"))];
963
1142
  const result = spawnSync(process.execPath, scriptArgs, {
964
1143
  cwd: packageRoot,
965
1144
  encoding: "utf8",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "goalbuddy",
3
- "version": "0.3.7",
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,11 +9,11 @@
9
9
  },
10
10
  "files": [
11
11
  ".agents/plugins/marketplace.json",
12
+ ".claude-plugin/marketplace.json",
13
+ "CHANGELOG.md",
12
14
  "README.md",
13
- "RELEASE-0.3.5.md",
14
- "RELEASE-0.3.7.md",
15
15
  "CONTRIBUTING.md",
16
- "examples",
16
+ "docs/releases",
17
17
  "plugins/goalbuddy",
18
18
  "internal/assets",
19
19
  "internal/cli",
@@ -24,8 +24,8 @@
24
24
  "goalbuddy/templates"
25
25
  ],
26
26
  "scripts": {
27
- "check": "node --check internal/cli/*.mjs goalbuddy/scripts/*.mjs && node --test internal/test/*.test.mjs",
28
- "test": "node --test internal/test/*.test.mjs",
27
+ "check": "node --check internal/cli/*.mjs goalbuddy/scripts/*.mjs goalbuddy/surfaces/local-goal-board/scripts/*.mjs goalbuddy/surfaces/local-goal-board/scripts/lib/*.mjs && node --test internal/test/*.test.mjs goalbuddy/surfaces/local-goal-board/test/*.test.mjs",
28
+ "test": "node --test internal/test/*.test.mjs goalbuddy/surfaces/local-goal-board/test/*.test.mjs",
29
29
  "pack:dry-run": "npm pack --dry-run",
30
30
  "postinstall": "node internal/cli/postinstall.mjs",
31
31
  "publish:check": "node internal/cli/check-publish-version.mjs && npm run pack:dry-run",