codebyplan 1.13.30 → 1.13.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ var VERSION, PACKAGE_NAME;
14
14
  var init_version = __esm({
15
15
  "src/lib/version.ts"() {
16
16
  "use strict";
17
- VERSION = "1.13.30";
17
+ VERSION = "1.13.32";
18
18
  PACKAGE_NAME = "codebyplan";
19
19
  }
20
20
  });
@@ -1493,6 +1493,23 @@ function mergeBaseSettingsIntoSettings(settings, base) {
1493
1493
  if (base.permissions.skipDangerousModePermissionPrompt !== void 0 && existing.skipDangerousModePermissionPrompt === void 0) {
1494
1494
  existing.skipDangerousModePermissionPrompt = base.permissions.skipDangerousModePermissionPrompt;
1495
1495
  }
1496
+ const baseEntryTier = /* @__PURE__ */ new Map();
1497
+ for (const key of ["deny", "ask", "allow"]) {
1498
+ const baseArr = base.permissions[key];
1499
+ if (!baseArr) continue;
1500
+ for (const entry of baseArr) {
1501
+ baseEntryTier.set(entry, key);
1502
+ }
1503
+ }
1504
+ if (baseEntryTier.size > 0) {
1505
+ for (const key of ["deny", "ask", "allow"]) {
1506
+ const current = existing[key];
1507
+ if (!current) continue;
1508
+ existing[key] = current.filter(
1509
+ (entry) => !baseEntryTier.has(entry) || baseEntryTier.get(entry) === key
1510
+ );
1511
+ }
1512
+ }
1496
1513
  for (const key of ["deny", "ask", "allow"]) {
1497
1514
  const incoming = base.permissions[key];
1498
1515
  if (!incoming || incoming.length === 0) continue;
@@ -1507,6 +1524,11 @@ function mergeBaseSettingsIntoSettings(settings, base) {
1507
1524
  }
1508
1525
  existing[key] = merged;
1509
1526
  }
1527
+ for (const key of ["deny", "ask", "allow"]) {
1528
+ if (existing[key]?.length === 0) {
1529
+ delete existing[key];
1530
+ }
1531
+ }
1510
1532
  if (settings.permissions !== void 0 || Object.keys(existing).length > 0) {
1511
1533
  settings.permissions = existing;
1512
1534
  }
@@ -1550,17 +1572,20 @@ function stripBaseSettingsFromSettings(settings, base) {
1550
1572
  if (base.permissions.skipDangerousModePermissionPrompt !== void 0 && perms.skipDangerousModePermissionPrompt === base.permissions.skipDangerousModePermissionPrompt) {
1551
1573
  delete perms.skipDangerousModePermissionPrompt;
1552
1574
  }
1575
+ const baseOwned = /* @__PURE__ */ new Set();
1553
1576
  for (const key of ["deny", "ask", "allow"]) {
1554
- const baseList = base.permissions[key];
1555
- if (!baseList || baseList.length === 0) continue;
1556
- const current = perms[key];
1557
- if (!current) continue;
1558
- const baseSet = new Set(baseList);
1559
- const filtered = current.filter((x) => !baseSet.has(x));
1560
- if (filtered.length === 0) {
1561
- delete perms[key];
1562
- } else {
1563
- perms[key] = filtered;
1577
+ for (const entry of base.permissions[key] ?? []) baseOwned.add(entry);
1578
+ }
1579
+ if (baseOwned.size > 0) {
1580
+ for (const key of ["deny", "ask", "allow"]) {
1581
+ const current = perms[key];
1582
+ if (!current) continue;
1583
+ const filtered = current.filter((x) => !baseOwned.has(x));
1584
+ if (filtered.length === 0) {
1585
+ delete perms[key];
1586
+ } else {
1587
+ perms[key] = filtered;
1588
+ }
1564
1589
  }
1565
1590
  }
1566
1591
  if (Object.keys(perms).length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebyplan",
3
- "version": "1.13.30",
3
+ "version": "1.13.32",
4
4
  "description": "CLI for CodeByPlan — AI-powered development planning and tracking",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,79 @@
1
+ #!/bin/bash
2
+ # @scope: org-shared
3
+ # @hook: PreToolUse mcp__codebyplan__(update_checkpoint|complete_checkpoint|update_task|complete_task|add_round|update_round|complete_round|create_standalone_task|update_standalone_task|complete_standalone_task|add_standalone_round|update_standalone_round|complete_standalone_round|update_standalone_file_change)
4
+ # Hook: PreToolUse for MCP write tools
5
+ #
6
+ # Purpose: Inject caller_worktree_id into MCP mutation tool inputs when the
7
+ # field is absent. Reads the worktree.local.json branch-keyed cache
8
+ # first (fast path); falls back to `codebyplan resolve-worktree --cache`.
9
+ #
10
+ # Fail-open: ALL exit paths exit 0. A hook failure must never block a tool call.
11
+ # Use explicit guards rather than set -euo pipefail (which would exit
12
+ # non-zero on the first failing command before the final exit 0).
13
+
14
+ # C0 — require jq; if absent, emit nothing and exit 0 (fail-open).
15
+ if ! command -v jq > /dev/null 2>&1; then
16
+ exit 0
17
+ fi
18
+
19
+ # Read stdin once into a variable.
20
+ INPUT=$(cat)
21
+
22
+ # C6 — if caller_worktree_id is already a non-empty string, do not overwrite.
23
+ # (jq '// empty' already maps JSON null to an empty string, so a plain -n test suffices.)
24
+ EXISTING=$(echo "$INPUT" | jq -r '.tool_input.caller_worktree_id // empty' 2>/dev/null)
25
+ if [ -n "$EXISTING" ]; then
26
+ # Already populated — plain allow (exit 0 with no output).
27
+ exit 0
28
+ fi
29
+
30
+ # C5 — resolve worktree id, fast path first.
31
+ RESOLVED_WT=""
32
+
33
+ # Determine repo root: prefer $CLAUDE_PROJECT_DIR, fall back to PWD.
34
+ REPO_ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
35
+ CACHE_FILE="$REPO_ROOT/.codebyplan/worktree.local.json"
36
+
37
+ if [ -f "$CACHE_FILE" ]; then
38
+ CACHED_WT=$(jq -r '.worktree_id // empty' "$CACHE_FILE" 2>/dev/null)
39
+ CACHED_BRANCH=$(jq -r '.branch // empty' "$CACHE_FILE" 2>/dev/null)
40
+
41
+ if [ -n "$CACHED_WT" ] && [ "$CACHED_WT" != "null" ] && \
42
+ [ -n "$CACHED_BRANCH" ] && [ "$CACHED_BRANCH" != "null" ]; then
43
+ # Validate branch matches current git branch.
44
+ CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
45
+ if [ -n "$CURRENT_BRANCH" ] && [ "$CURRENT_BRANCH" = "$CACHED_BRANCH" ]; then
46
+ RESOLVED_WT="$CACHED_WT"
47
+ fi
48
+ fi
49
+ fi
50
+
51
+ # Fallback to CLI resolution if cache miss or branch mismatch.
52
+ if [ -z "$RESOLVED_WT" ]; then
53
+ RESOLVED_WT=$(codebyplan resolve-worktree --cache 2>/dev/null \
54
+ || npx --no-install codebyplan resolve-worktree --cache 2>/dev/null \
55
+ || true)
56
+ fi
57
+
58
+ # UUID guard — accept only a canonical UUID (8-4-4-4-12 hex).
59
+ UUID_PATTERN='^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
60
+ if [ -z "$RESOLVED_WT" ] || ! echo "$RESOLVED_WT" | grep -qE "$UUID_PATTERN"; then
61
+ # Unresolved or invalid — plain allow, no updatedInput.
62
+ exit 0
63
+ fi
64
+
65
+ # C3 — emit updatedInput as the FULL tool_input with caller_worktree_id added.
66
+ # Claude Code's PreToolUse updatedInput REPLACES tool_input wholesale (it is not a
67
+ # partial merge), so we must echo back every existing field merged with the new
68
+ # caller_worktree_id — otherwise the tool loses round_id/duration_minutes/etc.
69
+ echo "$INPUT" | jq \
70
+ --arg wt "$RESOLVED_WT" \
71
+ '{
72
+ hookSpecificOutput: {
73
+ hookEventName: "PreToolUse",
74
+ permissionDecision: "allow",
75
+ updatedInput: (.tool_input + { caller_worktree_id: $wt })
76
+ }
77
+ }'
78
+
79
+ exit 0
@@ -374,6 +374,87 @@ fi
374
374
 
375
375
  echo ""
376
376
 
377
+ # ===== HOOK SMOKE TESTS — cbp-mcp-caller-worktree-inject =====
378
+ echo "## Hook Smoke Tests — cbp-mcp-caller-worktree-inject (CHK-198)"
379
+
380
+ INJECT_HOOK="$HOOKS_DIR/cbp-mcp-caller-worktree-inject.sh"
381
+ # Absolute path — the fail-open test runs the hook from a temp cwd (to isolate it
382
+ # from this repo's git context), where the relative "$HOOKS_DIR" no longer resolves.
383
+ INJECT_HOOK_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd)/cbp-mcp-caller-worktree-inject.sh"
384
+
385
+ if [ ! -f "$INJECT_HOOK" ]; then
386
+ test_result "cbp-mcp-caller-worktree-inject.sh present" "passed" "missing"
387
+ else
388
+ test_result "cbp-mcp-caller-worktree-inject.sh present" "passed" "passed"
389
+
390
+ FIRST_LINE=$(head -1 "$INJECT_HOOK")
391
+ if echo "$FIRST_LINE" | grep -q '^#!/'; then
392
+ test_result "cbp-mcp-caller-worktree-inject.sh has shebang" "passed" "passed"
393
+ else
394
+ test_result "cbp-mcp-caller-worktree-inject.sh has shebang" "passed" "missing"
395
+ fi
396
+
397
+ if grep -q '@scope: org-shared' "$INJECT_HOOK"; then
398
+ test_result "cbp-mcp-caller-worktree-inject.sh has @scope: org-shared" "passed" "passed"
399
+ else
400
+ test_result "cbp-mcp-caller-worktree-inject.sh has @scope: org-shared" "passed" "missing"
401
+ fi
402
+
403
+ # Fail-open: run from a non-repo temp dir with no worktree cache and no
404
+ # CLAUDE_PROJECT_DIR — neither the cache nor the CLI fallback can resolve a
405
+ # worktree, so the hook must exit 0 with empty stdout (no updatedInput).
406
+ ISO=$(mktemp -d)
407
+ OUTPUT=$( (cd "$ISO" && env -u CLAUDE_PROJECT_DIR bash "$INJECT_HOOK_ABS" <<< '{"tool_input":{"task_id":"x"}}') 2>/dev/null )
408
+ EXIT_CODE=$?
409
+ if [ "$EXIT_CODE" = "0" ] && [ -z "$OUTPUT" ]; then
410
+ test_result "cbp-mcp-caller-worktree-inject.sh fail-open (unresolvable) exits 0 + empty stdout" "passed" "passed"
411
+ else
412
+ test_result "cbp-mcp-caller-worktree-inject.sh fail-open (unresolvable) exits 0 + empty stdout" "passed" "failed (exit=$EXIT_CODE)"
413
+ fi
414
+ rm -rf "$ISO"
415
+
416
+ # C6 — input already carries a non-empty caller_worktree_id → never overwrite;
417
+ # early-return with exit 0 and empty stdout (no resolution attempted).
418
+ OUTPUT=$(echo '{"tool_input":{"caller_worktree_id":"11111111-1111-1111-1111-111111111111"}}' | bash "$INJECT_HOOK" 2>/dev/null)
419
+ EXIT_CODE=$?
420
+ if [ "$EXIT_CODE" = "0" ] && [ -z "$OUTPUT" ]; then
421
+ test_result "cbp-mcp-caller-worktree-inject.sh C6 keeps existing caller_worktree_id (exit 0 + empty stdout)" "passed" "passed"
422
+ else
423
+ test_result "cbp-mcp-caller-worktree-inject.sh C6 keeps existing caller_worktree_id (exit 0 + empty stdout)" "passed" "failed (exit=$EXIT_CODE)"
424
+ fi
425
+
426
+ # Injection — a worktree.local.json whose .branch matches the current git branch
427
+ # makes the cache fast-path resolve. Use a synthetic UUID so the assertion proves
428
+ # the cache value (not the live CLI) was injected. Skipped when no concrete git
429
+ # branch resolves (detached HEAD / non-git checkout) or jq is unavailable.
430
+ CUR_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
431
+ if [ -n "$CUR_BRANCH" ] && [ "$CUR_BRANCH" != "HEAD" ] && command -v jq >/dev/null 2>&1; then
432
+ ISO=$(mktemp -d)
433
+ mkdir -p "$ISO/.codebyplan"
434
+ FAKE_WT="abcdef01-2345-6789-abcd-ef0123456789"
435
+ jq -n --arg b "$CUR_BRANCH" --arg w "$FAKE_WT" \
436
+ '{worktree_id:$w, branch:$b}' > "$ISO/.codebyplan/worktree.local.json"
437
+ OUTPUT=$(CLAUDE_PROJECT_DIR="$ISO" bash "$INJECT_HOOK" <<< '{"tool_input":{"task_id":"x"}}' 2>/dev/null)
438
+ EXIT_CODE=$?
439
+ INJECTED=$(echo "$OUTPUT" | jq -r '.hookSpecificOutput.updatedInput.caller_worktree_id // empty' 2>/dev/null)
440
+ # Sibling-key survival — CC's updatedInput REPLACES tool_input wholesale (it is
441
+ # not a partial merge), so the hook must echo back every original field merged
442
+ # with caller_worktree_id. Assert the non-target sibling key (task_id) survives;
443
+ # this is the assertion gap that let the replace-vs-merge bug ship in round 2.
444
+ PRESERVED=$(echo "$OUTPUT" | jq -r '.hookSpecificOutput.updatedInput.task_id // empty' 2>/dev/null)
445
+ if [ "$EXIT_CODE" = "0" ] && [ "$INJECTED" = "$FAKE_WT" ] && [ "$PRESERVED" = "x" ]; then
446
+ test_result "cbp-mcp-caller-worktree-inject.sh injects caller_worktree_id AND preserves sibling keys" "passed" "passed"
447
+ else
448
+ test_result "cbp-mcp-caller-worktree-inject.sh injects caller_worktree_id AND preserves sibling keys" "passed" "failed (exit=$EXIT_CODE injected=$INJECTED preserved=$PRESERVED)"
449
+ fi
450
+ rm -rf "$ISO"
451
+ else
452
+ test_result "cbp-mcp-caller-worktree-inject.sh injection test (no branch resolvable — skipped)" "passed" "passed"
453
+ fi
454
+ fi
455
+
456
+ echo ""
457
+
377
458
  # ===== SUMMARY =====
378
459
  echo "=== TEST SUMMARY ==="
379
460
  echo -e "Passed: ${GREEN}$PASSED${NC}"
@@ -45,6 +45,15 @@
45
45
  "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/cbp-mcp-migration-guard.sh"
46
46
  }
47
47
  ]
48
+ },
49
+ {
50
+ "matcher": "mcp__codebyplan__(update_checkpoint|complete_checkpoint|update_task|complete_task|add_round|update_round|complete_round|create_standalone_task|update_standalone_task|complete_standalone_task|add_standalone_round|update_standalone_round|complete_standalone_round|update_standalone_file_change)",
51
+ "hooks": [
52
+ {
53
+ "type": "command",
54
+ "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/cbp-mcp-caller-worktree-inject.sh"
55
+ }
56
+ ]
48
57
  }
49
58
  ],
50
59
  "PostToolUse": [
@@ -57,7 +57,7 @@ This mirrors the CHK-104 hard-lock model — never wrest a checkpoint from a liv
57
57
 
58
58
  If the checkpoint is already `active` AND `worktree_id` already equals `CALLER_WT` (the Step 3 no-op row), skip this step entirely and proceed to Step 5 — nothing to write.
59
59
 
60
- Otherwise set the checkpoint `active` via MCP `update_checkpoint(checkpoint_id, status: "active"`, plus `worktree_id: CALLER_WT` when claiming per Step 3. The server resolves the caller's worktree identity from the JWT/ctx (CHK-140 TASK-3 `caller_worktree_id` input field removed). If the checkpoint was already `active` but a claim is still needed, skip the status write and only write `worktree_id`.
60
+ Otherwise set the checkpoint `active` via MCP `update_checkpoint(checkpoint_id, status: "active"`, plus `worktree_id: CALLER_WT` when claiming per Step 3. `caller_worktree_id` (CHK-140 TASK-7) identifies the calling worktree and is auto-injected by the `cbp-mcp-caller-worktree-inject.sh` PreToolUse hook (CHK-198 TASK-2); the server falls back to the repo `main` worktree only when it is absent. If the checkpoint was already `active` but a claim is still needed, skip the status write and only write `worktree_id`.
61
61
 
62
62
  ### Step 5: Route
63
63
 
@@ -78,7 +78,7 @@ Show a one-line confirmation before routing:
78
78
  ## Integration
79
79
 
80
80
  - **Reads**: MCP `get_checkpoints`, `get_tasks`; `npx codebyplan resolve-worktree`
81
- - **Writes**: MCP `update_checkpoint` (status + worktree_id; server resolves caller worktree from JWT/ctx)
81
+ - **Writes**: MCP `update_checkpoint` (status + worktree_id; `caller_worktree_id` auto-injected by the cbp-mcp-caller-worktree-inject.sh hook, CHK-198 TASK-2; server falls back to repo `main` only when absent)
82
82
  - **Triggered by**: `/cbp-checkpoint-plan` (auto when claimed at create), `/cbp-todo` (planned-but-pending gate), or user directly
83
83
  - **Triggers**: `/cbp-task-start` (auto when claimed), or `/cbp-checkpoint-plan` (when the checkpoint is unplanned)
84
84
  - **Never**: plans or creates tasks — that is `/cbp-checkpoint-plan`
@@ -136,7 +136,7 @@ Skip the push only when nothing was committed in Step 5 AND `/cbp-merge-main` re
136
136
 
137
137
  ### Step 7: Complete Task
138
138
 
139
- Call `complete_task(task_id)`. The server resolves the caller's worktree identity from the JWT/ctx and enforces the mutate-lock (CHK-140 TASK-3 `caller_worktree_id` input field removed). The server auto-clears `assigned_user_id` + `assigned_worktree_id` on the task; if this was the last sibling task, it also clears the parent checkpoint's assignment. (Per CHK-104 hard-lock.)
139
+ Call `complete_task(task_id)`. `caller_worktree_id` (CHK-140 TASK-7) identifies the calling worktree and is auto-injected by the `cbp-mcp-caller-worktree-inject.sh` PreToolUse hook (CHK-198 TASK-2); the server falls back to the repo `main` worktree only when it is absent, then enforces the mutate-lock. The server auto-clears `assigned_user_id` + `assigned_worktree_id` on the task; if this was the last sibling task, it also clears the parent checkpoint's assignment. (Per CHK-104 hard-lock.)
140
140
 
141
141
  ### Step 8: Run Cleanup + Migration (inline)
142
142
 
@@ -171,7 +171,7 @@ See `dependency-vulnerability-fixes.md` "Audit Sweep at Task Start" for the sour
171
171
 
172
172
  ### Step 3.5: Worktree-Match Verification
173
173
 
174
- Before activating the task, verify the caller's worktree matches the assigned worktree on the target row. (Per CHK-104 — DB-level hard-lock prevents cross-worktree mutations; the server resolves caller identity from the JWT/ctx.)
174
+ Before activating the task, verify the caller's worktree matches the assigned worktree on the target row. (Per CHK-104 — DB-level hard-lock prevents cross-worktree mutations.)
175
175
 
176
176
  1. Read caller worktree: `CALLER_WT=$(npx codebyplan resolve-worktree 2>/dev/null)`.
177
177
  2. Determine target worktree:
@@ -232,7 +232,7 @@ Display context summary:
232
232
 
233
233
  Use MCP `update_task(task_id, status: "in_progress")`.
234
234
 
235
- If worktree_id present, include `claim_worktree_id` to auto-claim the checkpoint. The server resolves the caller's worktree identity from the JWT/ctx (CHK-140 TASK-3 `caller_worktree_id` input field removed).
235
+ If worktree_id present, include `claim_worktree_id` to auto-claim the checkpoint. `caller_worktree_id` (CHK-140 TASK-7) identifies the calling worktree and is auto-injected by the `cbp-mcp-caller-worktree-inject.sh` PreToolUse hook (CHK-198 TASK-2); the server falls back to the repo `main` worktree only when it is absent.
236
236
 
237
237
  ### Step 6: Auto-trigger Round Start
238
238