codebyplan 1.13.66 → 1.13.67

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.
@@ -1,18 +1,20 @@
1
1
  /**
2
- * CLI entry point for `codebyplan worktree remove <name|CHK-NNN>`.
2
+ * CLI entry point for `codebyplan worktree remove <name|CHK-NNN|abs-path>`.
3
3
  *
4
4
  * Usage: codebyplan worktree remove <name> (legacy name-based path)
5
5
  * codebyplan worktree remove <CHK-NNN> (checkpoint path — git rm + Supabase teardown)
6
6
  * codebyplan worktree remove <bare-number> (same as CHK-NNN)
7
+ * codebyplan worktree remove <abs-path> (direct path — same logic as CHK-NNN without number computation)
7
8
  *
8
9
  * Legacy path: deregistration retired in CHK-225 TASK-3; returns { mcp_deregistered: false } with no MCP call.
9
- * CHK-NNN path: performs git worktree remove and Supabase preview-branch teardown; deregistration removed.
10
+ * CHK-NNN / abs-path: performs git worktree remove and Supabase preview-branch teardown; deregistration removed.
10
11
  *
11
12
  * Output JSON:
12
13
  * Legacy: { mcp_deregistered: boolean, warn? }
13
- * CHK-NNN: { mcp_deregistered: boolean, git_removed: boolean, supabase_torn_down: boolean, warn? }
14
+ * CHK-NNN / abs-path: { mcp_deregistered: boolean, git_removed: boolean, supabase_torn_down: boolean,
15
+ * remote_branch_deleted?: boolean, local_branch_deleted?: boolean, warn? }
14
16
  *
15
- * Exit: 0 on success or non-fatal failure; 1 on usage error.
17
+ * Exit: 0 on success or non-fatal failure; 1 on usage error; 2 when cwd is inside the target worktree.
16
18
  */
17
19
  export interface GitRunResult {
18
20
  status: number | null;
@@ -40,21 +42,51 @@ export interface RemoveWorktreeDeps {
40
42
  * Idempotent: not-found is success ({ deleted: true }).
41
43
  */
42
44
  deleteSupabaseBranch?: (branchName: string, cwd: string) => Promise<SupabaseTeardownResult>;
45
+ /**
46
+ * Guard: returns true when cwd is the worktree being removed or a child of it.
47
+ * When true, removal is aborted with exit code 2.
48
+ * Default: cwd === worktreePath || cwd.startsWith(worktreePath + sep)
49
+ */
50
+ isInsideWorktreePath?: (worktreePath: string, cwd: string) => boolean;
51
+ /**
52
+ * Verify the remote branch exists (ls-remote --exit-code) and delete it when found.
53
+ * Non-fatal: failures emit warnings and never change the exit code.
54
+ * Returns { remote_branch_deleted: boolean, warn?: string }.
55
+ */
56
+ verifyAndDeleteRemoteBranch?: (branchName: string, cwd: string) => Promise<{
57
+ remote_branch_deleted: boolean;
58
+ warn?: string;
59
+ }>;
60
+ /**
61
+ * Delete the local tracking branch after the worktree has been removed.
62
+ * Attempts `git branch -d` first; escalates to `git branch -D` only when the
63
+ * remote is confirmed gone (remote_is_gone === true).
64
+ * Non-fatal: failures emit warnings and never change the exit code.
65
+ * Returns { local_branch_deleted: boolean, warn?: string }.
66
+ */
67
+ deleteFeatBranch?: (branchName: string, cwd: string, remote_is_gone: boolean) => Promise<{
68
+ local_branch_deleted: boolean;
69
+ warn?: string;
70
+ }>;
43
71
  }
44
72
  export interface RemoveWorktreeResult {
45
73
  mcp_deregistered: boolean;
46
- /** CHK-NNN path only: whether git worktree remove succeeded. */
74
+ /** CHK-NNN / abs-path: whether git worktree remove succeeded. */
47
75
  git_removed?: boolean;
48
- /** CHK-NNN path only: whether the Supabase preview branch was deleted. */
76
+ /** CHK-NNN / abs-path: whether the Supabase preview branch was deleted. */
49
77
  supabase_torn_down?: boolean;
78
+ /** CHK-NNN / abs-path: whether the remote git branch was deleted. */
79
+ remote_branch_deleted?: boolean;
80
+ /** CHK-NNN / abs-path: whether the local git branch was deleted. */
81
+ local_branch_deleted?: boolean;
50
82
  warn?: string;
51
83
  }
52
84
  /**
53
85
  * Run `codebyplan worktree remove`.
54
86
  *
55
- * @param args Remaining args after "remove" was stripped — e.g. ["my-branch"] or ["CHK-207"]
87
+ * @param args Remaining args after "remove" was stripped — e.g. ["my-branch"], ["CHK-207"], or ["/abs/path"]
56
88
  * @param deps Injectable deps for testing.
57
- * @returns Exit code: 0 success / non-fatal, 1 usage error.
89
+ * @returns Exit code: 0 success / non-fatal, 1 usage error, 2 cwd-inside guard.
58
90
  */
59
91
  export declare function runWorktreeRemove(args: string[], deps?: RemoveWorktreeDeps): Promise<number>;
60
92
  //# sourceMappingURL=remove.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"remove.d.ts","sourceRoot":"","sources":["../../../src/cli/worktree/remove.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAUH,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;IAIb,gEAAgE;IAChE,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,KAAK,YAAY,CAAC;IACvD;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAChE;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,CACrB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,KACR,OAAO,CAAC,sBAAsB,CAAC,CAAC;CACtC;AAiJD,MAAM,WAAW,oBAAoB;IACnC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,gEAAgE;IAChE,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,0EAA0E;IAC1E,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAgGD;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,GAAE,kBAAuB,GAC5B,OAAO,CAAC,MAAM,CAAC,CA2BjB"}
1
+ {"version":3,"file":"remove.d.ts","sourceRoot":"","sources":["../../../src/cli/worktree/remove.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAWH,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;IAIb,gEAAgE;IAChE,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,KAAK,YAAY,CAAC;IACvD;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAChE;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,CACrB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,KACR,OAAO,CAAC,sBAAsB,CAAC,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IACtE;;;;OAIG;IACH,2BAA2B,CAAC,EAAE,CAC5B,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,KACR,OAAO,CAAC;QAAE,qBAAqB,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAChE;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,CACjB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,OAAO,KACpB,OAAO,CAAC;QAAE,oBAAoB,EAAE,OAAO,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChE;AAoOD,MAAM,WAAW,oBAAoB;IACnC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,iEAAiE;IACjE,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,2EAA2E;IAC3E,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,qEAAqE;IACrE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,oEAAoE;IACpE,oBAAoB,CAAC,EAAE,OAAO,CAAC;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AA4LD;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,MAAM,EAAE,EACd,IAAI,GAAE,kBAAuB,GAC5B,OAAO,CAAC,MAAM,CAAC,CAgCjB"}
package/dist/cli.js CHANGED
@@ -48,7 +48,7 @@ var VERSION, PACKAGE_NAME;
48
48
  var init_version = __esm({
49
49
  "src/lib/version.ts"() {
50
50
  "use strict";
51
- VERSION = "1.13.66";
51
+ VERSION = "1.13.67";
52
52
  PACKAGE_NAME = "codebyplan";
53
53
  }
54
54
  });
@@ -39272,6 +39272,7 @@ var init_create = __esm({
39272
39272
  });
39273
39273
 
39274
39274
  // src/cli/worktree/remove.ts
39275
+ import { isAbsolute as isAbsolute4, sep as sep3 } from "node:path";
39275
39276
  import { spawnSync as spawnSync21 } from "node:child_process";
39276
39277
  function defaultGitRun2(args, cwd) {
39277
39278
  const result = spawnSync21("git", args, {
@@ -39387,6 +39388,51 @@ async function defaultDeleteSupabaseBranch(branchName, cwd) {
39387
39388
  };
39388
39389
  }
39389
39390
  }
39391
+ function defaultIsInsideWorktreePath(worktreePath, cwd) {
39392
+ return cwd === worktreePath || cwd.startsWith(worktreePath + sep3);
39393
+ }
39394
+ function defaultVerifyAndDeleteRemoteBranch(branchName, cwd) {
39395
+ const gitRun = defaultGitRun2;
39396
+ const lsResult = gitRun(
39397
+ ["ls-remote", "--exit-code", "origin", branchName],
39398
+ cwd
39399
+ );
39400
+ if (lsResult.status !== 0) {
39401
+ return Promise.resolve({ remote_branch_deleted: false });
39402
+ }
39403
+ const pushResult = gitRun(["push", "origin", "--delete", branchName], cwd);
39404
+ if (pushResult.status !== 0) {
39405
+ const errMsg = pushResult.stderr.trim() || pushResult.error?.message || "unknown error";
39406
+ return Promise.resolve({
39407
+ remote_branch_deleted: false,
39408
+ warn: `Remote branch delete failed (non-fatal): git push origin --delete ${branchName}: ${errMsg}`
39409
+ });
39410
+ }
39411
+ return Promise.resolve({ remote_branch_deleted: true });
39412
+ }
39413
+ function defaultDeleteFeatBranch(branchName, cwd, remote_is_gone) {
39414
+ const gitRun = defaultGitRun2;
39415
+ const safeResult = gitRun(["branch", "-d", branchName], cwd);
39416
+ if (safeResult.status === 0) {
39417
+ return Promise.resolve({ local_branch_deleted: true });
39418
+ }
39419
+ const safeErrMsg = safeResult.stderr.trim() || safeResult.error?.message || "unknown error";
39420
+ if (remote_is_gone) {
39421
+ const forceResult = gitRun(["branch", "-D", branchName], cwd);
39422
+ if (forceResult.status === 0) {
39423
+ return Promise.resolve({ local_branch_deleted: true });
39424
+ }
39425
+ const forceErrMsg = forceResult.stderr.trim() || forceResult.error?.message || "unknown error";
39426
+ return Promise.resolve({
39427
+ local_branch_deleted: false,
39428
+ warn: `Local branch force-delete failed (non-fatal): git branch -D ${branchName}: ${forceErrMsg}`
39429
+ });
39430
+ }
39431
+ return Promise.resolve({
39432
+ local_branch_deleted: false,
39433
+ warn: `Local branch delete failed (non-fatal, remote still present \u2014 skipping force-delete): git branch -d ${branchName}: ${safeErrMsg}`
39434
+ });
39435
+ }
39390
39436
  function parseChkNumber(name) {
39391
39437
  const chkMatch = /^CHK-(\d+)$/i.exec(name);
39392
39438
  if (chkMatch?.[1]) {
@@ -39399,12 +39445,23 @@ function parseChkNumber(name) {
39399
39445
  }
39400
39446
  return null;
39401
39447
  }
39402
- async function runWorktreeRemoveChk(checkpointNumber, deps) {
39448
+ async function runWorktreeRemoveAtPath(worktreePath, deps) {
39403
39449
  const cwd = deps.cwd ?? process.cwd();
39404
39450
  const gitRun = deps.gitRun ?? defaultGitRun2;
39405
39451
  const getWorktreeBranchName = deps.getWorktreeBranchName ?? defaultGetWorktreeBranchName;
39406
39452
  const deleteSupabaseBranch = deps.deleteSupabaseBranch ?? defaultDeleteSupabaseBranch;
39407
- const worktreePath = computeWorktreePath(cwd, checkpointNumber);
39453
+ const isInsideWorktreePath = deps.isInsideWorktreePath ?? defaultIsInsideWorktreePath;
39454
+ const verifyAndDeleteRemoteBranch = deps.verifyAndDeleteRemoteBranch ?? defaultVerifyAndDeleteRemoteBranch;
39455
+ const deleteFeatBranch = deps.deleteFeatBranch ?? defaultDeleteFeatBranch;
39456
+ if (isInsideWorktreePath(worktreePath, cwd)) {
39457
+ process.stderr.write(
39458
+ JSON.stringify({
39459
+ error: "session is inside the target worktree",
39460
+ directive: `Switch to your main checkout, then re-run: codebyplan worktree remove ${worktreePath}`
39461
+ }) + "\n"
39462
+ );
39463
+ return 2;
39464
+ }
39408
39465
  const warnings = [];
39409
39466
  const branchName = getWorktreeBranchName(worktreePath);
39410
39467
  const removeResult = gitRun(["worktree", "remove", worktreePath], cwd);
@@ -39425,15 +39482,45 @@ async function runWorktreeRemoveChk(checkpointNumber, deps) {
39425
39482
  `Could not read the branch name for '${worktreePath}' (directory may already be deleted) \u2014 the Supabase preview branch was NOT torn down. Delete it manually in the Supabase dashboard if it still exists.`
39426
39483
  );
39427
39484
  }
39485
+ let remote_branch_deleted;
39486
+ let local_branch_deleted;
39487
+ let remote_is_gone = false;
39488
+ if (branchName && git_removed) {
39489
+ const remoteResult = await verifyAndDeleteRemoteBranch(branchName, cwd);
39490
+ remote_branch_deleted = remoteResult.remote_branch_deleted;
39491
+ if (remoteResult.warn) {
39492
+ warnings.push(remoteResult.warn);
39493
+ }
39494
+ remote_is_gone = remote_branch_deleted === true || remote_branch_deleted === false && !remoteResult.warn;
39495
+ const localResult = await deleteFeatBranch(branchName, cwd, remote_is_gone);
39496
+ local_branch_deleted = localResult.local_branch_deleted;
39497
+ if (localResult.warn) {
39498
+ warnings.push(localResult.warn);
39499
+ }
39500
+ } else if (branchName && !git_removed) {
39501
+ warnings.push(
39502
+ `Skipped remote/local branch cleanup for '${branchName}': the worktree at '${worktreePath}' was not removed.`
39503
+ );
39504
+ }
39428
39505
  const result = {
39429
39506
  mcp_deregistered: false,
39430
39507
  git_removed,
39431
39508
  supabase_torn_down,
39509
+ ...remote_branch_deleted !== void 0 ? { remote_branch_deleted } : {},
39510
+ ...local_branch_deleted !== void 0 ? { local_branch_deleted } : {},
39432
39511
  ...warnings.length > 0 ? { warn: warnings.join("; ") } : {}
39433
39512
  };
39434
39513
  process.stdout.write(JSON.stringify(result) + "\n");
39435
39514
  return 0;
39436
39515
  }
39516
+ async function runWorktreeRemoveChk(checkpointNumber, deps) {
39517
+ const cwd = deps.cwd ?? process.cwd();
39518
+ const worktreePath = computeWorktreePath(cwd, checkpointNumber);
39519
+ return runWorktreeRemoveAtPath(worktreePath, deps);
39520
+ }
39521
+ async function runWorktreeRemoveByPath(worktreePath, deps) {
39522
+ return runWorktreeRemoveAtPath(worktreePath, deps);
39523
+ }
39437
39524
  async function runWorktreeRemove(args, deps = {}) {
39438
39525
  const name = args.find((a) => !a.startsWith("--"));
39439
39526
  if (!name) {
@@ -39444,6 +39531,9 @@ async function runWorktreeRemove(args, deps = {}) {
39444
39531
  );
39445
39532
  return 1;
39446
39533
  }
39534
+ if (isAbsolute4(name)) {
39535
+ return runWorktreeRemoveByPath(name, deps);
39536
+ }
39447
39537
  const chkNumber = parseChkNumber(name);
39448
39538
  if (chkNumber !== null) {
39449
39539
  return runWorktreeRemoveChk(chkNumber, deps);
@@ -42152,7 +42242,7 @@ import {
42152
42242
  readdir as readdir9
42153
42243
  } from "node:fs/promises";
42154
42244
  import { existsSync as existsSync21 } from "node:fs";
42155
- import { join as join58, resolve as resolve15, dirname as dirname17, sep as sep4 } from "node:path";
42245
+ import { join as join58, resolve as resolve15, dirname as dirname17, sep as sep5 } from "node:path";
42156
42246
  import { homedir as homedir8 } from "node:os";
42157
42247
  function encodeProjectPath(absPath) {
42158
42248
  return resolve15(absPath).replace(/[/\\]/g, "-");
@@ -42320,7 +42410,7 @@ async function applyPlan(plan, opts) {
42320
42410
  const relPath = entry.suggested_target.slice("nested:".length);
42321
42411
  const targetDir = resolve15(join58(projectDir, relPath));
42322
42412
  const targetFile = join58(targetDir, "CLAUDE.md");
42323
- if (!targetDir.startsWith(resolve15(projectDir) + sep4)) {
42413
+ if (!targetDir.startsWith(resolve15(projectDir) + sep5)) {
42324
42414
  process.stderr.write(
42325
42415
  `migrate-memory: skipping unsafe suggested_target "${entry.suggested_target}" \u2014 resolves outside projectDir
42326
42416
  `
@@ -42339,7 +42429,7 @@ ${anchor}
42339
42429
  process.stdout.write(`[dry-run] Would create/append: ${targetFile}
42340
42430
  `);
42341
42431
  if (resolve15(entry.source_path).startsWith(
42342
- resolve15(plan.auto_memory_dir) + sep4
42432
+ resolve15(plan.auto_memory_dir) + sep5
42343
42433
  )) {
42344
42434
  process.stdout.write(
42345
42435
  `[dry-run] Would delete migrated keep source: ${entry.source_path}
@@ -42357,7 +42447,7 @@ ${anchor}
42357
42447
  if (!existing.includes(anchor)) {
42358
42448
  await writeFile22(targetFile, existing + appendContent, "utf-8");
42359
42449
  }
42360
- if (resolve15(entry.source_path).startsWith(resolve15(plan.auto_memory_dir) + sep4)) {
42450
+ if (resolve15(entry.source_path).startsWith(resolve15(plan.auto_memory_dir) + sep5)) {
42361
42451
  try {
42362
42452
  await unlink8(entry.source_path);
42363
42453
  } catch {
@@ -42395,7 +42485,7 @@ ${IMPORT_LINE}
42395
42485
  for (const entry of plan.entries) {
42396
42486
  if (entry.suggested_action !== "drop") continue;
42397
42487
  if (!resolve15(entry.source_path).startsWith(
42398
- resolve15(plan.auto_memory_dir) + sep4
42488
+ resolve15(plan.auto_memory_dir) + sep5
42399
42489
  )) {
42400
42490
  process.stderr.write(
42401
42491
  `migrate-memory: skipping delete of "${entry.source_path}" \u2014 resolves outside auto_memory_dir
@@ -42419,7 +42509,7 @@ ${IMPORT_LINE}
42419
42509
  process.stdout.write(`[dry-run] Would delete MEMORY.md: ${memoryMd}
42420
42510
  `);
42421
42511
  } else {
42422
- if (resolve15(plan.auto_memory_dir).startsWith(safeRmdirBase + sep4)) {
42512
+ if (resolve15(plan.auto_memory_dir).startsWith(safeRmdirBase + sep5)) {
42423
42513
  try {
42424
42514
  await unlink8(memoryMd);
42425
42515
  } catch {
@@ -42437,7 +42527,7 @@ ${IMPORT_LINE}
42437
42527
  `
42438
42528
  );
42439
42529
  } else {
42440
- if (!resolve15(plan.auto_memory_dir).startsWith(safeRmdirBase + sep4)) {
42530
+ if (!resolve15(plan.auto_memory_dir).startsWith(safeRmdirBase + sep5)) {
42441
42531
  process.stderr.write(
42442
42532
  `migrate-memory: skipping rmdir of "${plan.auto_memory_dir}" \u2014 not under ~/.claude/projects
42443
42533
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebyplan",
3
- "version": "1.13.66",
3
+ "version": "1.13.67",
4
4
  "description": "CLI for CodeByPlan — AI-powered development planning and tracking",
5
5
  "type": "module",
6
6
  "bin": {
@@ -374,87 +374,6 @@ 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
-
458
377
  # ===== HOOK SMOKE TESTS — cbp-session-start-hook =====
459
378
  echo "## Hook Smoke Tests — cbp-session-start-hook (CHK-178)"
460
379
 
@@ -25,11 +25,13 @@ SHARED tooling behavior only — repo-specific gotchas belong in that repo's own
25
25
  clobbers existing `decisions` / `discoveries` / `check_results`. Always read the current row,
26
26
  merge your change into the full object/array, then write the whole thing back.
27
27
 
28
- - **`resolve-worktree` empty output = a NULL `(device, path, branch)` tuple, not a broken
29
- resolver.** When identity is unresolved the server can collapse the caller to the repo's main
30
- worktree, so feat-locked writes get rejected. Pass `caller_worktree_id` on every MCP mutation,
31
- and confirm ownership by matching the row's repo path + branch to the current directory before
32
- mutating.
28
+ - **User-level locks are invisible until a mutation they block.** `get_checkpoints` /
29
+ `get_tasks` succeed even when another user holds the assignment; the 403 fires only on
30
+ `update_*` / `complete_*`. The lock keys on the JWT user (`ctx.userId`) vs the row's
31
+ `assigned_user_id` (null = open). `caller_worktree_id` / `worktree_id` params are
32
+ accepted-and-ignored — do not thread them. Verify `assigned_user_id` matches
33
+ `npx codebyplan whoami` before mutating; recover a stale assignment with
34
+ `release_assignment` (maintainer).
33
35
 
34
36
  - **Full-repo lint/type baselines are often pre-existing red.** A round must gate on the files
35
37
  it changed, not the whole-repo baseline — scope lint/tsc checks to the round's changed set so a
@@ -42,14 +44,10 @@ SHARED tooling behavior only — repo-specific gotchas belong in that repo's own
42
44
  files or carried directory-slash round artifacts, `complete_task` can hard-fail "N files not
43
45
  approved"; fix by re-writing each affected round's `files_changed` via `update_round`.
44
46
 
45
- - **CLI transport uses REST (reads) and OAuth+MCP (writes) — a 502 from `codebyplan round sync-approvals` is transient MCP churn, not an outage.** The CLI exits 0 with a warning and MCP tools still work. A missing `CODEBYPLAN_API_KEY` surfaces as an `ApiError`, not a 502. `sync-approvals` can also drag untracked per-device dirs into `files_changed` — run it from the repo root or pass `--caller-worktree-id`.
47
+ - **CLI transport uses REST (reads) and OAuth+MCP (writes) — a 502 from `codebyplan round sync-approvals` is transient MCP churn, not an outage.** The CLI exits 0 with a warning and MCP tools still work. A missing `CODEBYPLAN_API_KEY` surfaces as an `ApiError`, not a 502. `sync-approvals` can also drag untracked per-device dirs into `files_changed` — run it from the repo root.
46
48
 
47
49
  - **`codebyplan claude update` requires a TTY.** On non-TTY stdin (CI, piped) it half-applies then errors. Re-run with `--yes` to accept defaults non-interactively.
48
50
 
49
- - **Checkpoint locks are invisible until a mutation they block.** `get_checkpoints` / `get_tasks` succeed even when another worktree holds the lock; the 403 fires only on `update_*` / `complete_*`. Verify the row's `worktree_id` matches the caller before mutating. A null-`worktree_id` checkpoint can still be actively shipped by whichever worktree physically holds its feat branch — check `git worktree list` first.
50
-
51
- - **`update_task` accepts `caller_worktree_id` for lock-verify only — it does NOT assign ownership.** Ownership assignment goes through the web UI or the dedicated assignment path. Don't conflate `caller_worktree_id` with `assigned_worktree_id`.
52
-
53
51
  - **Re-run config-driven gates after merging main into a feat branch.** A merge can add or change `.codebyplan/shipment.json`, ports, branch config, `e2e.json`, and `eslint.json` — treat the post-merge state as a fresh baseline before continuing.
54
52
 
55
53
  - **MCP write calls can return 403 via Cloudflare WAF when the JSONB payload contains DDL
@@ -20,8 +20,8 @@ Defined in `supabase/migrations/20260511211900_chk111_workflow_invariants.sql` (
20
20
  | 2 | `trg_enforce_standalone_task_workflow_invariants` | A standalone task cannot be moved to `in_progress` without `assigned_user_id` (CHK-225: was `assigned_worktree_id`) |
21
21
  | 3 | `trg_enforce_task_workflow_invariants` | ≤ 1 `in_progress` task per checkpoint |
22
22
  | 4 | `trg_enforce_single_in_progress_round_per_task` | ≤ 1 `in_progress` round per task |
23
- | 5 | `trg_enforce_single_active_scope_per_worktree` | ≤ 1 active (checkpoint OR standalone task) per `assigned_user_id` (CHK-225: was per `worktree_id`) |
24
- | 6 | `trg_enforce_standalone_task_scope_per_worktree` | ≤ 1 `in_progress` standalone task per `assigned_user_id` (CHK-225: was per `assigned_worktree_id`) |
23
+ | 5 | `trg_enforce_single_active_scope_per_worktree` | ≤ 1 active (checkpoint OR standalone task) per `branch_name` (CHK-235: was per `assigned_user_id`) |
24
+ | 6 | `trg_enforce_standalone_task_scope_per_worktree` | ≤ 1 `in_progress` standalone task per `branch_name` (CHK-235: was per `assigned_user_id`) |
25
25
 
26
26
  The worker is a passive cross-checker (`apps/todo-worker/src/invariants/check.ts`) — if its check disagrees with the DB, the DB wins.
27
27
 
@@ -109,6 +109,8 @@ CHK-111 shipped the original todos queue as Postgres triggers + a 583-LOC `regen
109
109
 
110
110
  CHK-225 updated the invariant triggers from worktree-scoped to user-scoped (`assigned_user_id`). The trigger names were preserved for continuity; only the function bodies changed. Migration: `20260612000000_chk225_task1_user_locks.sql`.
111
111
 
112
+ CHK-235 re-scoped the same two trigger functions from per-`assigned_user_id` to per-`branch_name`, restoring multi-worktree concurrency (one active scope per feat branch). Migration: `20260622000000_chk235_task1_active_scope_per_branch.sql`.
113
+
112
114
  ## 8. Deployment — Railway
113
115
 
114
116
  `apps/todo-worker` runs as a Railway service alongside `apps/backend`. Setup:
@@ -24,15 +24,15 @@ Precedence is `deny > ask > allow`; arrays union across scopes (managed/user/pro
24
24
 
25
25
  - **Non-lifecycle, non-shipment `/cbp-*` skills** — authoring (`cbp-build-cc-*`), frontend (`cbp-frontend-*`), git (`cbp-git-*`, `cbp-merge-main`, `cbp-refresh-infra`), round work (`cbp-round-plan`, `cbp-verify` — `cbp-verify` is the autonomous verify stage that runs deterministic gates, proves execution, spawns the fresh-context reviewer, and routes to `cbp-round-complete` or `cbp-round-plan`, so it runs without a prompt), setup/configure (`cbp-setup-*`, `cbp-ship-configure`, `cbp-supabase-*`), task prep (`cbp-task-create`/`-start`, `cbp-standalone-task-check`/`-testing`), planning (`cbp-checkpoint-plan`/`-update`), plus `cbp-session-start` and `cbp-todo`. Invoking a skill is the intended mode of operation; the gated side effects happen inside via the Bash/MCP tools the skill calls, which carry their own tiering. The lifecycle/state-transition and plan-approval skills are the exception — they live in `ask` (next section).
26
26
  - **All `mcp__codebyplan__*` reads** (`get_*`, `list_*`, `search_*`, `health_check`, `lookup_symbol`, `resolve_library_id`, `get_chunk`).
27
- - **Routine workflow-write MCP tools** the pipeline calls many times per task: create/update/complete checkpoint, task, and round; session log + session-state writes; `create_worktree`, `add_library`, `flag_stale_chunk`, `update_server_config`, `update_eslint_repo_config`. Gating these with `ask` would make the autonomous workflow unusable.
28
- - **Read/safe CLI commands** (both `codebyplan X` and `npx codebyplan X`): `whoami`, `resolve-worktree`, `statusline`, `ports`, `tech-stack`, `eslint`, `round`, `help`, `--version`.
27
+ - **Routine workflow-write MCP tools** the pipeline calls many times per task: create/update/complete checkpoint, task, and round; session log + session-state writes; `add_library`, `flag_stale_chunk`, `update_server_config`, `update_eslint_repo_config`. Gating these with `ask` would make the autonomous workflow unusable.
28
+ - **Read/safe CLI commands** (both `codebyplan X` and `npx codebyplan X`): `whoami`, `docs`, `statusline`, `ports`, `tech-stack`, `eslint`, `round`, `help`, `--version`.
29
29
 
30
30
  ### `ask` — the deliberate confirm-gate
31
31
 
32
32
  - **Production-shipment skills**: `cbp-ship`, `cbp-ship-main`, `cbp-checkpoint-end` — these promote/deploy to production, so they prompt even in an otherwise auto-allowed setup.
33
33
  - **Lifecycle / state-transition skills**: `cbp-checkpoint-start`, `cbp-checkpoint-create`, `cbp-checkpoint-check`, `cbp-checkpoint-complete`, `cbp-round-complete`, `cbp-session-end`, `cbp-finalize`, `cbp-standalone-task-create`, `cbp-standalone-task-start`, `cbp-standalone-task-complete` — these open or close checkpoints, tasks, rounds, and sessions (advancing workflow state in the database), so they stop for explicit confirmation rather than running autonomously. `cbp-round-complete` is the permission-gated round finalizer (reconciles the user's `git add`s, completes the round, routes onward); its `ask` prompt is the human gate downstream of `cbp-verify` — the autonomous, `allow`-tier verify stage whose triage routes here.
34
34
  - **Plan-approval gate**: `cbp-round-build` — the round plan is approved by confirming this `ask` prompt rather than via an in-skill AskUserQuestion. `cbp-round-plan` runs its planning Q&A, then hands off to `cbp-round-build`; the permission prompt is the user's go/no-go on the plan.
35
- - **Destructive / admin MCP tools**: `delete_session_log`, `delete_worktree`, `create_repo`, `release_assignment`. (The launch and member-admin tools were dropped from the MCP surface in CHK-180 — those concerns are web-app only now.)
35
+ - **Destructive / admin MCP tools**: `delete_session_log`, `create_repo`, `release_assignment`. (The launch and member-admin tools were dropped from the MCP surface in CHK-180 — those concerns are web-app only now.)
36
36
  - **Mutating / external / clobber-risk CLI commands** (both prefixes): `setup`, `login`, `logout`, `upgrade-auth`, `config` (can overwrite committed `.codebyplan/` files), `branch` (rewrites branch config), `ship`, `claude` (`install`/`update`/`uninstall` overwrite `.claude/`).
37
37
 
38
38
  ### `deny` — unchanged
@@ -280,15 +280,21 @@ Scan the output for any `worktree` entry whose `branch` field matches `refs/head
280
280
  **Case A — the current session is NOT inside that worktree path:**
281
281
 
282
282
  ```bash
283
- codebyplan worktree remove <path>
283
+ codebyplan worktree remove <abs-path> # pass the absolute path from worktree list
284
284
  git worktree prune
285
285
  ```
286
286
 
287
- Record the removed path in `WORKTREES_REMOVED[]`. If `codebyplan worktree remove` exits
288
- non-zero, emit a non-blocking warning and continue a failed removal does not halt shipment.
287
+ `codebyplan worktree remove <abs-path>` handles both the git worktree removal and the
288
+ remote feat-branch deletion (non-fatal). No separate `git push origin --delete` is needed
289
+ here. Parse the JSON output; record `{ path, removed: true, remote_branch_deleted }` in
290
+ `WORKTREES_REMOVED[]`. On any non-zero exit (except exit code 2 — see Case B), emit a
291
+ non-blocking warning and continue — a failed removal does not halt shipment.
289
292
 
290
- **Case B — the current session IS inside that worktree path (i.e. `$PWD` starts with
291
- `<path>`):**
293
+ **Case B — the current session IS inside that worktree path:**
294
+
295
+ The CLI self-cwd guard fires and exits with **code 2** when `codebyplan worktree remove`
296
+ is called from inside the target worktree. Detect this via exit code 2, OR pre-check that
297
+ `$PWD` starts with `<path>` before calling the CLI.
292
298
 
293
299
  Do NOT self-remove. Surface a single directive (no A/B/C menu):
294
300
 
@@ -169,7 +169,7 @@ Skip the push only when nothing was committed in Step 5 AND `/cbp-merge-main` re
169
169
 
170
170
  ### Step 7: Complete Task
171
171
 
172
- MCP `complete_task(task_id)` kept on MCP because the CLI `codebyplan task complete` sends an empty POST body and cannot forward `caller_worktree_id`, which the server uses to enforce the mutate-lock. `caller_worktree_id` 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.)
172
+ MCP `complete_task(task_id)`. The server keys on the JWT user (`ctx.userId`) no worktree param is needed. The server auto-clears `assigned_user_id` on the task; if this was the last sibling task, it also clears the parent checkpoint's assignment.
173
173
 
174
174
  ### Step 8: Run Cleanup + Migration (inline)
175
175
 
@@ -226,7 +226,7 @@ direct you to run `/cbp-clear-prep` first; otherwise checkpoint-check starts on
226
226
  - **Triggered by**: `/cbp-verify` (auto, scope=task, when it writes `verify_verdict.verdict === 'READY'`)
227
227
  - **Chain**: `/cbp-verify` (scope=task READY) → `/cbp-finalize`
228
228
  - **Reads**: `.codebyplan/state/checkpoints/*.json`, `checkpoints/<id>/tasks/*.json`, `checkpoints/<id>/tasks/<id>/rounds/*.json`, `todos.json` (local-first; `npx codebyplan sync` on miss; MCP `get_current_task`/`get_rounds`/`get_tasks` break-glass) — including each round's `verify_manifest` and `task.context.verify_verdict`
229
- - **Writes**: `codebyplan task update` for `files_changed` (CLI write-through; MCP `update_task` break-glass); MCP `complete_task` for task completion (kept MCP — CLI cannot forward `caller_worktree_id`)
229
+ - **Writes**: `codebyplan task update` for `files_changed` (CLI write-through; MCP `update_task` break-glass); MCP `complete_task` for task completion
230
230
  - **Uses skills (inline, no sub-agent)**: `cleanup` (if deletions), `migration` (if exports renamed)
231
231
  - **Triggers**: Same-context transitions auto-trigger via the Skill tool (next task in checkpoint → `cbp-task-start {N}`, `allow`-tier, fires silently). Checkpoint-done → auto-triggers `cbp-checkpoint-check` via Skill tool (`ask`-tier, permission prompt IS the human gate). No-task-anywhere fallback → directive `Next: Run /clear, then /cbp-session-end.`
232
232
  - **Checkpoint-bound only** — for standalone tasks use `/cbp-standalone-task-complete`
@@ -249,6 +249,45 @@ When `branch_deleted === true` in the ship JSON:
249
249
  - If the `list_branches` call itself fails (network, auth, or non-success response): emit a non-blocking warning that the Supabase preview branch for `FEAT_BRANCH` may still exist and should be verified in the dashboard. Never treat an API failure as a not-found success.
250
250
  - Never delete the branch where `is_default` is true in the `list_branches` response (the production/parent project branch) or any other persistent/long-lived branch.
251
251
 
252
+ #### Step 7.4 — Git-worktree cleanup (defensive)
253
+
254
+ Read `FEAT_BRANCH` from the `feat_branch` field in the ship JSON (same source as Step 7.3).
255
+
256
+ ```bash
257
+ git worktree list --porcelain
258
+ ```
259
+
260
+ Scan for any `worktree` entry whose `branch` field matches `refs/heads/$FEAT_BRANCH`. If NO
261
+ entry matches, skip this step silently — most standalone tasks have no dedicated worktree,
262
+ so a no-match is the normal case.
263
+
264
+ If a match exists, obtain its absolute path and apply the same Case A / Case B logic:
265
+
266
+ **Case A — session cwd is NOT inside that worktree path:**
267
+
268
+ ```bash
269
+ codebyplan worktree remove <abs-path> # absolute path from worktree list
270
+ git worktree prune
271
+ ```
272
+
273
+ The CLI handles both the git removal and remote feat-branch deletion (non-fatal). Parse
274
+ the JSON output; record `{ path, removed: true, remote_branch_deleted }` in
275
+ `WORKTREES_REMOVED[]`. On any non-zero exit other than exit code 2, emit a non-blocking
276
+ warning and continue.
277
+
278
+ **Case B — session cwd IS inside that worktree path (CLI exits with code 2, or pre-check
279
+ detects `$PWD` starts with `<path>`):**
280
+
281
+ Do NOT self-remove. Surface a single directive:
282
+
283
+ > "This session is inside the worktree at `<path>`. After switching to your main checkout,
284
+ > run `codebyplan worktree remove <path>` to clean it up."
285
+
286
+ Record `{ path, status: 'pending_manual_cleanup' }` in `WORKTREES_REMOVED[]`.
287
+
288
+ In either case (and when no worktree matched — `WORKTREES_REMOVED` stays empty), include
289
+ `worktrees_removed: WORKTREES_REMOVED` in the Step 9 summary.
290
+
252
291
  ### Step 7.5: Complete Standalone Task
253
292
 
254
293
  Note: completion is called only after `codebyplan ship` succeeds (no `checks_failed`) — the DB completion record reflects work that has landed in production.
@@ -280,6 +319,7 @@ Apply the `cleanup` skill inline to remove orphan references to deleted/modified
280
319
  **Version bumps**: [<name>: <current> → <next> per package, or "none"]
281
320
  **Export**: [EXPORT_RESULT.path from Step 5.6, or "skipped: <reason>"]
282
321
  **Warnings**: [any QA / file-approval warnings from Step 3, or "none"]
322
+ **Git worktrees cleaned**: [paths from WORKTREES_REMOVED (Step 7.4), or "none"]
283
323
  ```
284
324
 
285
325
  #### Route (single directive — never a menu)