codebyplan 1.13.66 → 1.13.68

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.
@@ -16,7 +16,7 @@ export interface GenerateOpts {
16
16
  projectDir?: string;
17
17
  /** Print the would-be content to stdout, write nothing. */
18
18
  dryRun?: boolean;
19
- /** Exit non-zero when AGENTS.md is missing or drifted. Read-only — writes nothing. */
19
+ /** Exit non-zero when AGENTS.md or structure.md is missing or drifted. Read-only — writes nothing. */
20
20
  check?: boolean;
21
21
  }
22
22
  export declare function runGenerate(opts: GenerateOpts | Record<string, unknown>): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../../../src/cli/claude/generate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAqBH,MAAM,WAAW,YAAY;IAC3B,gDAAgD;IAChD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,sFAAsF;IACtF,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAyDD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3C,OAAO,CAAC,IAAI,CAAC,CA4Of"}
1
+ {"version":3,"file":"generate.d.ts","sourceRoot":"","sources":["../../../src/cli/claude/generate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAqBH,MAAM,WAAW,YAAY;IAC3B,gDAAgD;IAChD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,sGAAsG;IACtG,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAyDD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC3C,OAAO,CAAC,IAAI,CAAC,CAiOf"}
@@ -1 +1 @@
1
- {"version":3,"file":"readme.d.ts","sourceRoot":"","sources":["../../../src/cli/claude/readme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AA4BH,MAAM,WAAW,UAAU;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AA4KD,wBAAsB,SAAS,CAC7B,IAAI,EAAE,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACzC,OAAO,CAAC,IAAI,CAAC,CAkLf;AAmCD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAoBpE"}
1
+ {"version":3,"file":"readme.d.ts","sourceRoot":"","sources":["../../../src/cli/claude/readme.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AA4BH,MAAM,WAAW,UAAU;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AA4ID,wBAAsB,SAAS,CAC7B,IAAI,EAAE,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACzC,OAAO,CAAC,IAAI,CAAC,CAkLf;AAmCD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAoBpE"}
@@ -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.68";
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);
@@ -41244,6 +41334,45 @@ var init_verify_parity2 = __esm({
41244
41334
  }
41245
41335
  });
41246
41336
 
41337
+ // src/lib/tech-stack-render.ts
41338
+ function buildTechStackDisplay(flat) {
41339
+ const categoryMap = {};
41340
+ for (const entry of flat) {
41341
+ if (entry.name === SYNTHETIC_CARRIER_NAME) continue;
41342
+ if (!categoryMap[entry.category]) {
41343
+ categoryMap[entry.category] = [];
41344
+ }
41345
+ categoryMap[entry.category].push(entry.name);
41346
+ }
41347
+ if ((categoryMap["mobile"]?.length ?? 0) > 0) {
41348
+ const frameworkEntries = categoryMap["framework"] ?? [];
41349
+ const filtered = frameworkEntries.filter((n) => n !== "React");
41350
+ if (filtered.length > 0) {
41351
+ categoryMap["framework"] = filtered;
41352
+ } else {
41353
+ delete categoryMap["framework"];
41354
+ }
41355
+ }
41356
+ const techStack = {};
41357
+ for (const [cat, names] of Object.entries(categoryMap)) {
41358
+ const label = CATEGORY_LABELS[cat] ?? cat.charAt(0).toUpperCase() + cat.slice(1);
41359
+ techStack[label] = [...names].sort().join(" + ");
41360
+ }
41361
+ return { categoryMap, techStack };
41362
+ }
41363
+ var CATEGORY_LABELS;
41364
+ var init_tech_stack_render = __esm({
41365
+ "src/lib/tech-stack-render.ts"() {
41366
+ "use strict";
41367
+ init_tech_detect();
41368
+ CATEGORY_LABELS = {
41369
+ "component-lib": "Component Library",
41370
+ graphql: "GraphQL",
41371
+ quality: "Code Quality"
41372
+ };
41373
+ }
41374
+ });
41375
+
41247
41376
  // src/lib/structure-generator.ts
41248
41377
  function renderTechStack(techStack) {
41249
41378
  const entries = Object.entries(techStack).filter(([, value]) => value.trim().length > 0).sort(([a], [b]) => a.localeCompare(b));
@@ -41731,28 +41860,7 @@ async function runGenerate(opts) {
41731
41860
  });
41732
41861
  }
41733
41862
  const techResult = await detectTechStack(rootDir);
41734
- const CATEGORY_LABELS = {
41735
- "component-lib": "Component Library",
41736
- graphql: "GraphQL",
41737
- quality: "Code Quality"
41738
- };
41739
- const categoryMap = {};
41740
- for (const entry of techResult.flat) {
41741
- if (entry.name === SYNTHETIC_CARRIER_NAME) continue;
41742
- if (!categoryMap[entry.category]) {
41743
- categoryMap[entry.category] = [];
41744
- }
41745
- categoryMap[entry.category].push(entry.name);
41746
- }
41747
- if ((categoryMap["mobile"]?.length ?? 0) > 0) {
41748
- const frameworkEntries = categoryMap["framework"] ?? [];
41749
- const filtered = frameworkEntries.filter((n) => n !== "React");
41750
- if (filtered.length > 0) {
41751
- categoryMap["framework"] = filtered;
41752
- } else {
41753
- delete categoryMap["framework"];
41754
- }
41755
- }
41863
+ const { categoryMap, techStack } = buildTechStackDisplay(techResult.flat);
41756
41864
  const hasNextjs = (categoryMap["framework"] ?? []).some(
41757
41865
  (n) => n.toLowerCase().includes("next")
41758
41866
  );
@@ -41760,11 +41868,6 @@ async function runGenerate(opts) {
41760
41868
  if (p.server_type === "nextjs" && !hasNextjs) return false;
41761
41869
  return true;
41762
41870
  });
41763
- const techStack = {};
41764
- for (const [cat, names] of Object.entries(categoryMap)) {
41765
- const label = CATEGORY_LABELS[cat] ?? cat.charAt(0).toUpperCase() + cat.slice(1);
41766
- techStack[label] = [...names].sort().join(" + ");
41767
- }
41768
41871
  const config = {
41769
41872
  techStack: Object.keys(techStack).length > 0 ? techStack : void 0,
41770
41873
  packageManager,
@@ -41799,6 +41902,25 @@ async function runGenerate(opts) {
41799
41902
  `);
41800
41903
  }
41801
41904
  }
41905
+ const structurePath = join56(rootDir, ".claude", "generated", "structure.md");
41906
+ let existingStructureCheck = null;
41907
+ try {
41908
+ existingStructureCheck = await readFile30(structurePath, "utf-8");
41909
+ } catch {
41910
+ existingStructureCheck = null;
41911
+ }
41912
+ if (existingStructureCheck === null) {
41913
+ process.stdout.write(`structure.md missing
41914
+ `);
41915
+ process.exitCode = 1;
41916
+ } else if (existingStructureCheck !== structureMdContent) {
41917
+ process.stdout.write(`structure.md drifted
41918
+ `);
41919
+ process.exitCode = 1;
41920
+ } else {
41921
+ process.stdout.write(`.claude/generated/structure.md up to date
41922
+ `);
41923
+ }
41802
41924
  return;
41803
41925
  }
41804
41926
  if (dryRun) {
@@ -41818,9 +41940,20 @@ async function runGenerate(opts) {
41818
41940
  const outputDir = join56(rootDir, ".claude", "generated");
41819
41941
  await mkdir14(outputDir, { recursive: true });
41820
41942
  const outputPath = join56(outputDir, "structure.md");
41821
- await writeFile20(outputPath, structureMdContent, "utf-8");
41822
- process.stdout.write(`Wrote: .claude/generated/structure.md
41943
+ let existingStructure = null;
41944
+ try {
41945
+ existingStructure = await readFile30(outputPath, "utf-8");
41946
+ } catch {
41947
+ existingStructure = null;
41948
+ }
41949
+ if (existingStructure !== null && existingStructure === structureMdContent) {
41950
+ process.stdout.write(`Up to date: .claude/generated/structure.md
41951
+ `);
41952
+ } else {
41953
+ await writeFile20(outputPath, structureMdContent, "utf-8");
41954
+ process.stdout.write(`Wrote: .claude/generated/structure.md
41823
41955
  `);
41956
+ }
41824
41957
  const agentsMdPath = join56(rootDir, "AGENTS.md");
41825
41958
  let existingAgentsContent = null;
41826
41959
  try {
@@ -41841,6 +41974,7 @@ var init_generate = __esm({
41841
41974
  "src/cli/claude/generate.ts"() {
41842
41975
  "use strict";
41843
41976
  init_tech_detect();
41977
+ init_tech_stack_render();
41844
41978
  init_structure_generator();
41845
41979
  init_agents_generator();
41846
41980
  }
@@ -41866,34 +42000,9 @@ async function buildConfig(absPath, isRoot, allPackages, rootPkgJson, pkgJson) {
41866
42000
  let techStack;
41867
42001
  try {
41868
42002
  const techResult = await detectTechStack(absPath);
41869
- const CATEGORY_LABELS = {
41870
- "component-lib": "Component Library",
41871
- graphql: "GraphQL",
41872
- quality: "Code Quality"
41873
- };
41874
- const categoryMap = {};
41875
- for (const entry of techResult.flat) {
41876
- if (entry.name === SYNTHETIC_CARRIER_NAME) continue;
41877
- if (!categoryMap[entry.category]) {
41878
- categoryMap[entry.category] = [];
41879
- }
41880
- categoryMap[entry.category].push(entry.name);
41881
- }
41882
- if ((categoryMap["mobile"]?.length ?? 0) > 0) {
41883
- const frameworkEntries = categoryMap["framework"] ?? [];
41884
- const filtered = frameworkEntries.filter((n) => n !== "React");
41885
- if (filtered.length > 0) {
41886
- categoryMap["framework"] = filtered;
41887
- } else {
41888
- delete categoryMap["framework"];
41889
- }
41890
- }
41891
- if (Object.keys(categoryMap).length > 0) {
41892
- techStack = {};
41893
- for (const [cat, names] of Object.entries(categoryMap)) {
41894
- const label = CATEGORY_LABELS[cat] ?? cat.charAt(0).toUpperCase() + cat.slice(1);
41895
- techStack[label] = [...names].sort().join(" + ");
41896
- }
42003
+ const { techStack: ts } = buildTechStackDisplay(techResult.flat);
42004
+ if (Object.keys(ts).length > 0) {
42005
+ techStack = ts;
41897
42006
  }
41898
42007
  } catch {
41899
42008
  }
@@ -42128,6 +42237,7 @@ var init_readme = __esm({
42128
42237
  "src/cli/claude/readme.ts"() {
42129
42238
  "use strict";
42130
42239
  init_tech_detect();
42240
+ init_tech_stack_render();
42131
42241
  init_readme_generator();
42132
42242
  }
42133
42243
  });
@@ -42152,7 +42262,7 @@ import {
42152
42262
  readdir as readdir9
42153
42263
  } from "node:fs/promises";
42154
42264
  import { existsSync as existsSync21 } from "node:fs";
42155
- import { join as join58, resolve as resolve15, dirname as dirname17, sep as sep4 } from "node:path";
42265
+ import { join as join58, resolve as resolve15, dirname as dirname17, sep as sep5 } from "node:path";
42156
42266
  import { homedir as homedir8 } from "node:os";
42157
42267
  function encodeProjectPath(absPath) {
42158
42268
  return resolve15(absPath).replace(/[/\\]/g, "-");
@@ -42320,7 +42430,7 @@ async function applyPlan(plan, opts) {
42320
42430
  const relPath = entry.suggested_target.slice("nested:".length);
42321
42431
  const targetDir = resolve15(join58(projectDir, relPath));
42322
42432
  const targetFile = join58(targetDir, "CLAUDE.md");
42323
- if (!targetDir.startsWith(resolve15(projectDir) + sep4)) {
42433
+ if (!targetDir.startsWith(resolve15(projectDir) + sep5)) {
42324
42434
  process.stderr.write(
42325
42435
  `migrate-memory: skipping unsafe suggested_target "${entry.suggested_target}" \u2014 resolves outside projectDir
42326
42436
  `
@@ -42339,7 +42449,7 @@ ${anchor}
42339
42449
  process.stdout.write(`[dry-run] Would create/append: ${targetFile}
42340
42450
  `);
42341
42451
  if (resolve15(entry.source_path).startsWith(
42342
- resolve15(plan.auto_memory_dir) + sep4
42452
+ resolve15(plan.auto_memory_dir) + sep5
42343
42453
  )) {
42344
42454
  process.stdout.write(
42345
42455
  `[dry-run] Would delete migrated keep source: ${entry.source_path}
@@ -42357,7 +42467,7 @@ ${anchor}
42357
42467
  if (!existing.includes(anchor)) {
42358
42468
  await writeFile22(targetFile, existing + appendContent, "utf-8");
42359
42469
  }
42360
- if (resolve15(entry.source_path).startsWith(resolve15(plan.auto_memory_dir) + sep4)) {
42470
+ if (resolve15(entry.source_path).startsWith(resolve15(plan.auto_memory_dir) + sep5)) {
42361
42471
  try {
42362
42472
  await unlink8(entry.source_path);
42363
42473
  } catch {
@@ -42395,7 +42505,7 @@ ${IMPORT_LINE}
42395
42505
  for (const entry of plan.entries) {
42396
42506
  if (entry.suggested_action !== "drop") continue;
42397
42507
  if (!resolve15(entry.source_path).startsWith(
42398
- resolve15(plan.auto_memory_dir) + sep4
42508
+ resolve15(plan.auto_memory_dir) + sep5
42399
42509
  )) {
42400
42510
  process.stderr.write(
42401
42511
  `migrate-memory: skipping delete of "${entry.source_path}" \u2014 resolves outside auto_memory_dir
@@ -42419,7 +42529,7 @@ ${IMPORT_LINE}
42419
42529
  process.stdout.write(`[dry-run] Would delete MEMORY.md: ${memoryMd}
42420
42530
  `);
42421
42531
  } else {
42422
- if (resolve15(plan.auto_memory_dir).startsWith(safeRmdirBase + sep4)) {
42532
+ if (resolve15(plan.auto_memory_dir).startsWith(safeRmdirBase + sep5)) {
42423
42533
  try {
42424
42534
  await unlink8(memoryMd);
42425
42535
  } catch {
@@ -42437,7 +42547,7 @@ ${IMPORT_LINE}
42437
42547
  `
42438
42548
  );
42439
42549
  } else {
42440
- if (!resolve15(plan.auto_memory_dir).startsWith(safeRmdirBase + sep4)) {
42550
+ if (!resolve15(plan.auto_memory_dir).startsWith(safeRmdirBase + sep5)) {
42441
42551
  process.stderr.write(
42442
42552
  `migrate-memory: skipping rmdir of "${plan.auto_memory_dir}" \u2014 not under ~/.claude/projects
42443
42553
  `
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure, I/O-free helper for rendering a flat TechStackEntry[] into the
3
+ * display structures used by `codebyplan claude generate` and
4
+ * `codebyplan claude readme`.
5
+ *
6
+ * Both callers previously contained identical copies of this logic.
7
+ * Extracted here so changes propagate uniformly and the logic can be
8
+ * unit-tested in isolation.
9
+ */
10
+ import type { TechStackEntry } from "./types.js";
11
+ /** Human-readable labels for multi-word category keys. */
12
+ export declare const CATEGORY_LABELS: Record<string, string>;
13
+ export interface TechStackRenderResult {
14
+ /** Per-category name lists, post-F5 mobile guard. */
15
+ categoryMap: Record<string, string[]>;
16
+ /** Display-ready map: label → sorted joined names (e.g. "Framework" → "Next.js + React"). */
17
+ techStack: Record<string, string>;
18
+ }
19
+ /**
20
+ * Build category and display maps from a flat TechStackEntry array.
21
+ *
22
+ * Behaviour:
23
+ * 1. Iterates `flat`, skipping entries where `entry.name === SYNTHETIC_CARRIER_NAME`.
24
+ * 2. F5 mobile guard: when `categoryMap["mobile"]` is non-empty, strips "React"
25
+ * from `categoryMap["framework"]`; deletes the key entirely if that leaves it empty.
26
+ * 3. Builds `techStack` by joining sorted names per category under a human-readable label.
27
+ * 4. Empty input → `{ categoryMap: {}, techStack: {} }`.
28
+ */
29
+ export declare function buildTechStackDisplay(flat: TechStackEntry[]): TechStackRenderResult;
30
+ //# sourceMappingURL=tech-stack-render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tech-stack-render.d.ts","sourceRoot":"","sources":["../../src/lib/tech-stack-render.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAEjD,0DAA0D;AAC1D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAIlD,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACpC,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IACtC,6FAA6F;IAC7F,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACnC;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,cAAc,EAAE,GACrB,qBAAqB,CAgCvB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebyplan",
3
- "version": "1.13.66",
3
+ "version": "1.13.68",
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)