codebyplan 1.10.2 → 1.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ var VERSION, PACKAGE_NAME;
14
14
  var init_version = __esm({
15
15
  "src/lib/version.ts"() {
16
16
  "use strict";
17
- VERSION = "1.10.2";
17
+ VERSION = "1.10.3";
18
18
  PACKAGE_NAME = "codebyplan";
19
19
  }
20
20
  });
@@ -22,13 +22,16 @@ var init_version = __esm({
22
22
  // src/lib/local-config.ts
23
23
  import { execSync } from "node:child_process";
24
24
  import { createHash } from "node:crypto";
25
- import { mkdir, readFile, writeFile } from "node:fs/promises";
25
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
26
26
  import { hostname } from "node:os";
27
27
  import { dirname, join } from "node:path";
28
28
  function localConfigPath(projectPath) {
29
29
  return join(projectPath, ".codebyplan", "device.local.json");
30
30
  }
31
- async function readLocalConfig(projectPath) {
31
+ function legacyLocalConfigPath(projectPath) {
32
+ return join(projectPath, ".codebyplan.local.json");
33
+ }
34
+ async function readLocalConfig(projectPath, onMigrationNotice) {
32
35
  try {
33
36
  const raw = await readFile(localConfigPath(projectPath), "utf-8");
34
37
  const parsed = JSON.parse(raw);
@@ -38,22 +41,81 @@ async function readLocalConfig(projectPath) {
38
41
  console.error("Failed to read local config: invalid shape");
39
42
  return null;
40
43
  } catch (err) {
41
- console.error(
42
- `Failed to read local config: ${err instanceof Error ? err.message : String(err)}`
43
- );
44
+ const code = err.code;
45
+ if (code === "ENOENT") {
46
+ } else if (code === "ENOTDIR") {
47
+ try {
48
+ const dirPath = dirname(localConfigPath(projectPath));
49
+ const st = await stat(dirPath);
50
+ if (!st.isDirectory()) {
51
+ throw Object.assign(
52
+ new Error(`${dirPath} is a file, expected directory`),
53
+ { code: "LEGACY_FILE_BLOCKS_DIR" }
54
+ );
55
+ }
56
+ } catch (statErr) {
57
+ if (statErr.code === "LEGACY_FILE_BLOCKS_DIR")
58
+ throw statErr;
59
+ }
60
+ throw err;
61
+ } else if (typeof code === "string") {
62
+ throw err;
63
+ } else {
64
+ console.error(
65
+ `Failed to read local config: ${err instanceof Error ? err.message : String(err)}`
66
+ );
67
+ return null;
68
+ }
69
+ }
70
+ try {
71
+ const raw = await readFile(legacyLocalConfigPath(projectPath), "utf-8");
72
+ const parsed = JSON.parse(raw);
73
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) && typeof parsed.device_id === "string") {
74
+ onMigrationNotice?.(
75
+ legacyLocalConfigPath(projectPath),
76
+ localConfigPath(projectPath)
77
+ );
78
+ return parsed;
79
+ }
44
80
  return null;
81
+ } catch (err) {
82
+ const code = err.code;
83
+ if (code === "ENOENT") {
84
+ return null;
85
+ }
86
+ throw err;
45
87
  }
46
88
  }
47
89
  async function writeLocalConfig(projectPath, config) {
48
90
  const content = { device_id: config.device_id };
49
91
  const path6 = localConfigPath(projectPath);
92
+ const dirPath = dirname(path6);
93
+ let phase = "stat config directory";
50
94
  try {
51
- await mkdir(dirname(path6), { recursive: true });
95
+ try {
96
+ const st = await stat(dirPath);
97
+ if (!st.isDirectory()) {
98
+ const err = Object.assign(
99
+ new Error(`${dirPath} is a file, expected directory`),
100
+ { code: "LEGACY_FILE_BLOCKS_DIR" }
101
+ );
102
+ throw err;
103
+ }
104
+ } catch (statErr) {
105
+ const code = statErr.code;
106
+ if (code === "LEGACY_FILE_BLOCKS_DIR") throw statErr;
107
+ if (code !== "ENOENT") throw statErr;
108
+ }
109
+ phase = "create config directory";
110
+ await mkdir(dirPath, { recursive: true });
111
+ phase = "write local config";
52
112
  await writeFile(path6, JSON.stringify(content, null, 2) + "\n", "utf-8");
53
113
  } catch (err) {
54
- console.error(
55
- `Failed to write local config: ${err instanceof Error ? err.message : String(err)}`
56
- );
114
+ const code = err.code;
115
+ if (code === "LEGACY_FILE_BLOCKS_DIR") {
116
+ throw err;
117
+ }
118
+ console.error(`Failed to ${phase}: ${err.message}`);
57
119
  throw err;
58
120
  }
59
121
  }
@@ -73,8 +135,8 @@ async function resolveMachineSeed() {
73
135
  }
74
136
  return hostname();
75
137
  }
76
- async function getOrCreateDeviceId(projectPath) {
77
- const existing = await readLocalConfig(projectPath);
138
+ async function getOrCreateDeviceId(projectPath, onMigrationNotice) {
139
+ const existing = await readLocalConfig(projectPath, onMigrationNotice);
78
140
  if (existing?.device_id) {
79
141
  return existing.device_id;
80
142
  }
@@ -561,7 +623,8 @@ async function resolveAndCacheWorktreeId(repoId, projectPath, options) {
561
623
  async function resolveWorktreeByBranch({
562
624
  repoId,
563
625
  deviceId,
564
- branch
626
+ branch,
627
+ onError
565
628
  }) {
566
629
  if (!deviceId || !branch) {
567
630
  return null;
@@ -588,6 +651,10 @@ async function resolveWorktreeByBranch({
588
651
  console.warn(
589
652
  `Worktree self-heal skipped (non-fatal): branch worktree resolve failed: ${err instanceof Error ? err.message : String(err)}. Continuing.`
590
653
  );
654
+ onError?.(
655
+ "api_failed",
656
+ err instanceof Error ? err : new Error(String(err))
657
+ );
591
658
  return null;
592
659
  }
593
660
  }
@@ -595,7 +662,8 @@ async function resolveWorktreeId({
595
662
  repoId,
596
663
  repoPath,
597
664
  branch,
598
- deviceId
665
+ deviceId,
666
+ onError
599
667
  }) {
600
668
  try {
601
669
  const res = await apiPost(
@@ -607,6 +675,10 @@ async function resolveWorktreeId({
607
675
  console.warn(
608
676
  `Worktree self-heal skipped (non-fatal): ${err instanceof Error ? err.message : String(err)}. Continuing \u2014 run \`codebyplan config\` again later to retry.`
609
677
  );
678
+ onError?.(
679
+ "api_failed",
680
+ err instanceof Error ? err : new Error(String(err))
681
+ );
610
682
  return null;
611
683
  }
612
684
  }
@@ -3806,63 +3878,145 @@ var init_ship2 = __esm({
3806
3878
  // src/cli/resolve-worktree.ts
3807
3879
  var resolve_worktree_exports = {};
3808
3880
  __export(resolve_worktree_exports, {
3881
+ ProcessExitSignal: () => ProcessExitSignal,
3809
3882
  runResolveWorktree: () => runResolveWorktree
3810
3883
  });
3811
3884
  import { execSync as execSync5 } from "node:child_process";
3885
+ function distress(kind, message, jsonMode) {
3886
+ if (jsonMode) return;
3887
+ process.stderr.write(`resolve-worktree: ${kind}: ${message}
3888
+ `);
3889
+ }
3812
3890
  async function runResolveWorktree() {
3891
+ const jsonMode = hasFlag("json", 3);
3892
+ let errorContext = null;
3893
+ const migrationNoticeCallback = (legacyPath, primaryPath) => {
3894
+ if (!jsonMode) {
3895
+ process.stderr.write(
3896
+ `resolve-worktree: local_config_migration: ${legacyPath} is the legacy flat config; move device_id to ${primaryPath}
3897
+ `
3898
+ );
3899
+ }
3900
+ };
3813
3901
  try {
3814
3902
  const projectPath = process.cwd();
3815
3903
  const found = await findCodebyplanConfig(projectPath);
3816
3904
  if (!found?.contents.repo_id) {
3817
- process.exit(0);
3905
+ emitAndExit(null, null, jsonMode);
3818
3906
  }
3819
3907
  const repoId = found.contents.repo_id;
3820
- const deviceId = await getOrCreateDeviceId(projectPath);
3908
+ try {
3909
+ await readLocalConfig(projectPath);
3910
+ } catch (readErr) {
3911
+ const readErrCode = readErr.code;
3912
+ errorContext = {
3913
+ kind: readErrCode === "LEGACY_FILE_BLOCKS_DIR" ? "legacy_file_blocks_dir" : "local_config_read_failed",
3914
+ message: readErr instanceof Error ? readErr.message : String(readErr)
3915
+ };
3916
+ }
3917
+ let deviceId;
3918
+ try {
3919
+ deviceId = await getOrCreateDeviceId(
3920
+ projectPath,
3921
+ migrationNoticeCallback
3922
+ );
3923
+ } catch (deviceErr) {
3924
+ const code = deviceErr.code;
3925
+ if (code === "LEGACY_FILE_BLOCKS_DIR") {
3926
+ errorContext = {
3927
+ kind: "legacy_file_blocks_dir",
3928
+ message: deviceErr instanceof Error ? deviceErr.message : String(deviceErr)
3929
+ };
3930
+ } else if (errorContext === null || errorContext.kind !== "local_config_read_failed" && errorContext.kind !== "legacy_file_blocks_dir") {
3931
+ errorContext = {
3932
+ kind: "local_config_write_failed",
3933
+ message: deviceErr instanceof Error ? deviceErr.message : String(deviceErr)
3934
+ };
3935
+ }
3936
+ emitAndExit(null, errorContext, jsonMode);
3937
+ }
3821
3938
  let branch = "";
3822
3939
  try {
3823
3940
  branch = execSync5("git symbolic-ref --short HEAD", {
3824
3941
  cwd: projectPath,
3825
3942
  encoding: "utf-8"
3826
3943
  }).trim();
3827
- } catch {
3944
+ } catch (gitErr) {
3945
+ if (errorContext === null) {
3946
+ errorContext = {
3947
+ kind: "git_failed",
3948
+ message: gitErr instanceof Error ? gitErr.message : String(gitErr)
3949
+ };
3950
+ }
3828
3951
  }
3952
+ const onResolverError = (kind, err) => {
3953
+ if (errorContext === null) {
3954
+ errorContext = { kind, message: err.message };
3955
+ }
3956
+ };
3829
3957
  const worktreeId = await resolveWorktreeId({
3830
3958
  repoId,
3831
3959
  repoPath: projectPath,
3832
3960
  branch,
3833
- deviceId
3961
+ deviceId,
3962
+ onError: onResolverError
3834
3963
  });
3835
3964
  if (worktreeId) {
3836
- process.stdout.write(worktreeId);
3837
- process.exit(0);
3965
+ emitAndExit(worktreeId, errorContext, jsonMode);
3838
3966
  }
3839
3967
  const useFallback = hasFlag("fallback-from-branch", 3);
3840
3968
  if (useFallback) {
3841
3969
  const fallbackId = await resolveWorktreeByBranch({
3842
3970
  repoId,
3843
3971
  deviceId,
3844
- branch
3972
+ branch,
3973
+ onError: onResolverError
3845
3974
  });
3846
3975
  if (fallbackId) {
3847
- process.stdout.write(fallbackId);
3976
+ emitAndExit(fallbackId, errorContext, jsonMode);
3848
3977
  }
3849
3978
  }
3850
- process.exit(0);
3979
+ emitAndExit(null, errorContext, jsonMode);
3851
3980
  } catch (err) {
3852
- if (process.env.CODEBYPLAN_DEBUG === "1") {
3853
- const msg = err instanceof Error ? err.message : String(err);
3854
- process.stderr.write(`resolve-worktree: ${msg}
3855
- `);
3981
+ if (err instanceof ProcessExitSignal) throw err;
3982
+ const msg = err instanceof Error ? err.message : String(err);
3983
+ errorContext = { kind: "unhandled", message: msg };
3984
+ emitAndExit(null, errorContext, jsonMode);
3985
+ }
3986
+ }
3987
+ function emitAndExit(worktreeId, errorContext, jsonMode) {
3988
+ if (jsonMode) {
3989
+ const errorKind = errorContext?.kind ?? (worktreeId === null ? "tuple_miss" : null);
3990
+ process.stdout.write(
3991
+ JSON.stringify({ worktree_id: worktreeId, error_kind: errorKind }) + "\n"
3992
+ );
3993
+ } else {
3994
+ if (worktreeId !== null) {
3995
+ process.stdout.write(worktreeId);
3996
+ }
3997
+ if (errorContext !== null) {
3998
+ if (errorContext.kind !== "unhandled" || process.env.CODEBYPLAN_DEBUG === "1") {
3999
+ distress(errorContext.kind, errorContext.message, jsonMode);
4000
+ }
3856
4001
  }
3857
- process.exit(0);
3858
4002
  }
4003
+ process.exit(0);
3859
4004
  }
4005
+ var ProcessExitSignal;
3860
4006
  var init_resolve_worktree2 = __esm({
3861
4007
  "src/cli/resolve-worktree.ts"() {
3862
4008
  "use strict";
3863
4009
  init_flags();
3864
4010
  init_local_config();
3865
4011
  init_resolve_worktree();
4012
+ ProcessExitSignal = class extends Error {
4013
+ code;
4014
+ constructor(code) {
4015
+ super(`process.exit(${code})`);
4016
+ this.name = "ProcessExitSignal";
4017
+ this.code = code;
4018
+ }
4019
+ };
3866
4020
  }
3867
4021
  });
3868
4022
 
@@ -3882,9 +4036,9 @@ function sentinelPath(projectPath) {
3882
4036
  return join12(projectPath, ".codebyplan", "repo.json");
3883
4037
  }
3884
4038
  async function statSafe(p) {
3885
- const { stat } = await import("node:fs/promises");
4039
+ const { stat: stat2 } = await import("node:fs/promises");
3886
4040
  try {
3887
- return await stat(p);
4041
+ return await stat2(p);
3888
4042
  } catch {
3889
4043
  return null;
3890
4044
  }
@@ -6010,8 +6164,8 @@ function pruneEmptyManagedDirs(projectDir) {
6010
6164
  }
6011
6165
  function pruneLeafFirst(dir) {
6012
6166
  if (!fs5.existsSync(dir)) return;
6013
- const stat = fs5.statSync(dir);
6014
- if (!stat.isDirectory()) return;
6167
+ const stat2 = fs5.statSync(dir);
6168
+ if (!stat2.isDirectory()) return;
6015
6169
  for (const entry of fs5.readdirSync(dir, { withFileTypes: true })) {
6016
6170
  if (entry.isDirectory()) {
6017
6171
  pruneLeafFirst(path5.join(dir, entry.name));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebyplan",
3
- "version": "1.10.2",
3
+ "version": "1.10.3",
4
4
  "description": "CLI for CodeByPlan — AI-powered development planning and tracking",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@ Activate the session, open a fresh session log, and surface the previous log's p
12
12
 
13
13
  ## Instructions
14
14
 
15
- Do Steps 0–5 and Step 4.5 silently (no intermediate output). Step 1.4 may surface a one-line fast-forward note or warning, and Step 5.7 may surface an approval gate. Produce ONE output block at Step 6, then auto-trigger `/cbp-todo`.
15
+ Run Steps 0 through 5.8 silently (no intermediate output) — except Step 1.4 may surface a one-line fast-forward note or warning, and Step 5.7 may surface an approval gate. (Step numbers are organizational labels; execution order is 0 → 1 → 1.4 → 2 → 3 → 4 → 4.5 → 5 → 5.7 → 5.8 → 6 → 7.) Produce ONE output block at Step 6, then auto-trigger or stop per Step 7.
16
16
 
17
17
  ### Step 0: MCP Health Check
18
18
 
@@ -40,15 +40,19 @@ Read per-concern config files from the project root. Single load point for the s
40
40
  - `server_port`, `server_type`, `auto_push_enabled` — from `.codebyplan/server.json`
41
41
  - `git_branch` — from `.codebyplan/git.json`
42
42
 
43
- Resolve `worktree_id` at runtime (CHK-108: never read from `.codebyplan/repo.json`):
43
+ Resolve `worktree_id` at runtime using the structured JSON form:
44
44
 
45
45
  ```bash
46
- WORKTREE_ID=$(npx codebyplan resolve-worktree 2>/dev/null)
46
+ RESOLVE_JSON=$(npx codebyplan resolve-worktree --json)
47
+ # → {"worktree_id":"<uuid>|null","error_kind":null|"<kind>"}
47
48
  ```
48
49
 
49
- Pass `WORKTREE_ID` to MCP tools that support it. Empty output means the (device, path, branch) tuple is unregistered — see fallback note below.
50
+ Extract `worktree_id` and `error_kind` from the JSON output.
50
51
 
51
- **If `worktree_id` resolves to empty** (the (device, path, branch) tuple does not match any registered worktree row): the session continues, but downstream MCP calls treat this caller as untagged — every hard-lock pre-guard sees a NULL `caller_worktree_id` and may reject mutations on assigned rows. Surface a one-line note in the Step 6 output instructing the user to run `npx codebyplan setup` from this directory to register the worktree.
52
+ - `error_kind` is `null` or `"tuple_miss"` healthy. `WORKTREE_ID` = `worktree_id` (may be `null`: a legitimate main-repo or unregistered-worktree case proceed normally; downstream hard-lock pre-guards may reject mutations on assigned rows).
53
+ - `error_kind` is `local_config_read_failed`, `local_config_write_failed`, `legacy_file_blocks_dir`, `api_failed`, `git_failed`, or `unhandled` → **broken local state**. Hold the `error_kind` for Step 6 to display as a distress warning. Session continues (non-blocking — unlike `/cbp-todo`, session-start does NOT hard-stop on a non-tuple-miss distress).
54
+
55
+ Pass `WORKTREE_ID` to MCP tools that support it. Null `WORKTREE_ID` means the (device, path, branch) tuple is unregistered — note this for Step 6.
52
56
 
53
57
  ### Step 1.4: Home-Branch Fast-Forward
54
58
 
@@ -123,6 +127,7 @@ Probe the most-recent closed session log for a structured handoff payload (per `
123
127
  - `round_id` → `get_rounds({ task_id: handoff.context.task_id })` → find entry where `entry.id === handoff.context.round_id`
124
128
  Then compare `entry.updated_at > handoff.captured_at` → stale on any inequality.
125
129
  - Entity lookup fails OR the matching `id` is not present in the returned array → stale (referenced entity gone or moved out of reach).
130
+ - `handoff.context.checkpoint_id` resolves to a checkpoint whose `worktree_id` is non-null AND (caller `WORKTREE_ID` is `null` OR differs from `checkpoint.worktree_id`) → stale (a fresh handoff for another worktree's work — or for assigned work this caller cannot confirm ownership of — must not auto-resume here). Mirrors the cbp-todo Step 1.5 ownership rule.
126
131
  4. **On stale OR any defensive gate hit**: fall through silently to Step 7 (existing `/cbp-todo` trigger).
127
132
  5. **On fresh hit**: trigger `handoff.command` directly with `handoff.context` / `handoff.state` in the trigger arguments. The downstream skill self-loads its full context — do NOT duplicate `/cbp-todo` Step 2's context-loading matrix here. Skip Step 5.7, Step 6 output, and Step 7.
128
133
 
@@ -152,27 +157,50 @@ Clean the working tree of leftover infra before the session begins. Only commit
152
157
 
153
158
  Non-blocking — session start proceeds either way.
154
159
 
160
+ ### Step 5.8: Resolve Ownership
161
+
162
+ Call MCP `get_checkpoints({ repo_id, status: 'active' })`. Partition results into:
163
+
164
+ - `owned[]` — entries where `checkpoint.worktree_id === WORKTREE_ID`, OR both are `null`
165
+ - `cross_worktree[]` — entries where `checkpoint.worktree_id` is non-null AND differs from `WORKTREE_ID` (includes the case where caller `WORKTREE_ID` is `null` but the target has a non-null `worktree_id`)
166
+
167
+ Hold `owned_count = owned.length`, `total_count = owned.length + cross_worktree.length`, `owned_names` (CHK-NNN + title for each owned entry), and `cross_names` (CHK-NNN + name for each cross-worktree entry). These values are consumed by Step 6 and Step 7 — single MCP call, no duplicate round-trips.
168
+
155
169
  ### Step 6: Output
156
170
 
157
171
  ```
158
- Session active | Worktree: [worktree_id or "main"]
172
+ Session active | Worktree: [worktree_id or "unregistered"]
173
+
174
+ [⚠ resolve-worktree: <error_kind> — local state is broken; routing may be unreliable. Run `npx codebyplan setup` to repair. — only when error_kind is non-null and not tuple_miss]
159
175
 
160
176
  Previous session: [title or "none"]
161
177
  Pending: [pending items from previous log, or "—"]
162
178
 
179
+ Ownership: [total_count] active CHK(s), [owned_count] owned by this worktree
180
+ [Owned: CHK-NNN (title), … — only when owned_count > 0]
181
+ [Cross-worktree: CHK-ZZZ (name), … — only when total_count > owned_count]
182
+
163
183
  [⚠ Dev server not running — start via desktop app — only if applicable]
184
+ [⚠ Worktree unregistered — run `npx codebyplan setup` to register — only when WORKTREE_ID is null and no resolver distress was already shown]
164
185
  ```
165
186
 
166
- ### Step 7: Auto-trigger Todo
187
+ READ-ONLY this block never proposes reassignment, release, or lock transfer of cross-worktree checkpoints.
188
+
189
+ ### Step 7: Auto-trigger
190
+
191
+ Three-branch gate using `owned_count` and `total_count` from Step 5.8:
167
192
 
168
- Trigger `/cbp-todo` to determine what to work on.
193
+ - **`owned_count >= 1`**: trigger `/cbp-todo` (owns active work proceed as today).
194
+ - **`total_count >= 1` AND `owned_count === 0`**: **STOP** — do NOT auto-trigger `/cbp-todo`. The Ownership block shown in Step 6 already communicates the situation; the user must switch to the owning worktree or start new work explicitly.
195
+ - **`total_count === 0`** (no active checkpoints anywhere): trigger `/cbp-todo` (idle path — leads to checkpoint-create or session-end).
169
196
 
170
197
  ## Integration
171
198
 
172
199
  - **Triggered by**: user invocation, `/clear` recovery
173
- - **Reads**: `.codebyplan/repo.json`, `.codebyplan/git.json` (`branch_config.production` for the Step 1.4 home-branch fast-forward), MCP `get_session_logs` (worktree-filtered, limit 1 — single call shared by Step 4 and Step 4.5), MCP `health_check`, MCP `get_current_task`, MCP `get_rounds`, MCP `get_checkpoints` / `get_tasks` / `get_rounds` for freshness probe (Step 4.5)
200
+ - **Resolves**: `npx codebyplan resolve-worktree --json` (worktree id + distress signal; non-tuple-miss distress is non-blocking at session-start)
201
+ - **Reads**: `.codebyplan/repo.json`, `.codebyplan/git.json` (`branch_config.production` for the Step 1.4 home-branch fast-forward), MCP `get_session_logs` (worktree-filtered, limit 1 — single call shared by Step 4 and Step 4.5), MCP `health_check`, MCP `get_current_task`, MCP `get_rounds`, MCP `get_checkpoints` (two calls: `{ repo_id, status: 'active' }` for the Step 5.8 ownership partition; `{ repo_id }` unfiltered for the Step 4.5 freshness probe, which may resolve a non-active checkpoint), MCP `get_tasks` / `get_rounds` for the Step 4.5 freshness probe
174
202
  - **Writes**: MCP `create_session_log` (new, possibly empty), MCP `update_session_state` (activate)
175
203
  - **Spawns**: none
176
- - **Triggers**: `/cbp-git-commit` (conditional, on user approval), `handoff.command` (on fresh handoff hit at Step 4.5), `/cbp-todo` (auto fall-through)
204
+ - **Triggers**: `/cbp-git-commit` (conditional, on user approval), `handoff.command` (on fresh handoff hit at Step 4.5), `/cbp-todo` (auto fall-through when owned_count >= 1 or total_count === 0; STOPS with no trigger when total_count >= 1 AND owned_count === 0)
177
205
  - **Paired with**: `/cbp-session-end`
178
206
  - **Pairs with**: `.claude/rules/session-resume.md` (handoff payload shape + freshness gate contract)
@@ -0,0 +1,51 @@
1
+ ---
2
+ scope: org-shared
3
+ name: cbp-session-start-qa-regression
4
+ description: Manual regression procedure for the cbp-session-start worktree-ownership awareness + resolve-worktree distress channel (CHK-137 TASK-3)
5
+ ---
6
+
7
+ # cbp-session-start — Worktree-Ownership Regression
8
+
9
+ Manual procedure verifying that `/cbp-session-start` correctly resolves the caller's worktree identity, gates Step 7 auto-trigger on ownership, and surfaces distress signals non-blocking. No automated harness exists for markdown skills; run these by hand (or exercise the MCP calls directly) whenever Step 1, Step 4.5, Step 5.8, Step 6, or Step 7 of `SKILL.md` changes.
10
+
11
+ Repo under test: `2ff6d405-39c5-47b8-a6d1-59f998ac0537`.
12
+
13
+ ## Preconditions
14
+
15
+ - Step 1 uses `resolve-worktree --json` (not the legacy `2>/dev/null` form) — confirm with `grep -n 'resolve-worktree' SKILL.md` → line contains `--json`.
16
+ - Step 5.8 calls `get_checkpoints({ repo_id, status: 'active' })` — confirm no Step 7 auto-trigger bypasses this gate.
17
+ - Step 6 Ownership block is READ-ONLY — confirm no "reassign", "release_assignment", or "transfer" language appears in SKILL.md: `grep -n 'reassign\|release_assignment\|transfer' SKILL.md` → no hits.
18
+ - Step 4.5 freshness gate includes the cross-worktree stale bullet (checkpoint `worktree_id` non-null and differs from caller).
19
+
20
+ ## Scenario A — caller owns an active CHK → auto-trigger
21
+
22
+ 1. Run from a worktree whose `WORKTREE_ID` matches the active checkpoint's `worktree_id` (or both are `null`).
23
+ 2. `get_checkpoints({ repo_id, status: 'active' })` returns at least one entry whose `worktree_id === WORKTREE_ID` (or both null).
24
+ 3. **Expected**: Step 5.8 sets `owned_count >= 1`. Step 6 shows `Ownership: N active CHK(s), N owned by this worktree`. Step 7 first branch fires: `/cbp-todo` is auto-triggered.
25
+
26
+ ## Scenario B — active CHK(s) exist but none owned by caller → Ownership block + STOP
27
+
28
+ Repro: caller worktree is `codebyplan-claude-2` (`38cd7dfa`). The only active checkpoint is CHK-136, assigned to `codebyplan-cli` (`016bd7f2`).
29
+
30
+ 1. `resolve-worktree --json` returns `{"worktree_id":"38cd7dfa-...","error_kind":null}`.
31
+ 2. `get_checkpoints({ repo_id, status: 'active' })` returns CHK-136 with `worktree_id = "016bd7f2-..."`.
32
+ 3. Step 5.8: `owned_count = 0`, `total_count = 1`, `cross_worktree = [CHK-136]`.
33
+ 4. **Expected**: Step 6 shows `Ownership: 1 active CHK(s), 0 owned by this worktree` and `[Cross-worktree: CHK-136 (…)]`. Step 7 second branch fires: Ownership block is displayed (already in Step 6) and skill **STOPS** — `/cbp-todo` is NOT auto-triggered. No reassignment language appears.
34
+
35
+ ## Scenario C — no active CHKs anywhere → idle /cbp-todo trigger
36
+
37
+ 1. `get_checkpoints({ repo_id, status: 'active' })` returns `[]`.
38
+ 2. Step 5.8: `owned_count = 0`, `total_count = 0`.
39
+ 3. **Expected**: Step 6 shows `Ownership: 0 active CHK(s), 0 owned by this worktree`. Step 7 third branch fires: `/cbp-todo` is auto-triggered (idle path → checkpoint-create or session-end suggestion).
40
+
41
+ ## Scenario D — cross-worktree handoff → Step 4.5 marks stale, falls through
42
+
43
+ 1. The most-recent closed session log contains a handoff payload whose `context.checkpoint_id` resolves to a checkpoint with `worktree_id = "016bd7f2-..."` (a different worktree).
44
+ 2. Caller `WORKTREE_ID = "38cd7dfa-..."`.
45
+ 3. **Expected**: Step 4.5 freshness gate hits the cross-worktree stale bullet (`checkpoint.worktree_id` non-null AND differs from caller) → marks stale → falls through silently to Step 7. The mismatched handoff is NOT auto-resumed. Ownership output from Step 5.8 / Step 6 / Step 7 proceeds normally.
46
+
47
+ ## Scenario E — resolver distress (non-tuple-miss) → warning line above Ownership block, session proceeds
48
+
49
+ 1. `resolve-worktree --json` returns `{"worktree_id":null,"error_kind":"local_config_read_failed"}` (or any other non-null, non-tuple-miss `error_kind`).
50
+ 2. **Expected**: Step 1 holds the `error_kind`; session continues (non-blocking). Step 6 surfaces `⚠ resolve-worktree: local_config_read_failed — local state is broken; routing may be unreliable. Run \`npx codebyplan setup\` to repair.` ABOVE the Ownership block. All subsequent steps still run (Steps 2–5.8). Step 7 proceeds per the `owned_count`/`total_count` values from Step 5.8 as normal — the skill does NOT hard-stop the way `/cbp-todo` does on the same distress kinds.
51
+ 3. **Compound case** (distress typically leaves `WORKTREE_ID` null): Step 5.8 then classifies every checkpoint with a non-null `worktree_id` as `cross_worktree[]` (only truly-null-`worktree_id` checkpoints land in `owned[]`). So if all active checkpoints are assigned, `owned_count = 0` and Step 7's second branch STOPS — the distress warning + Ownership block are the only output. The session log created in Step 5 records `worktree_id: null` (the resolver could not read local state); this is expected, not a failure.
@@ -7,47 +7,95 @@ effort: low
7
7
 
8
8
  # Todo Command
9
9
 
10
- Single MCP call to determine next action, then load appropriate context before triggering the command. This is the **sole entry point** after `/clear` it ensures Claude always has full context before any command runs.
10
+ Single entry point after `/clear`: read the pure-read todo queue, gate on worktree ownership + checkpoint planning, load context, then trigger the next command. It ensures Claude always has full context — and never auto-routes into work locked to another worktree — before any command runs.
11
11
 
12
12
  ## Instructions
13
13
 
14
- ### Step 0: Resolve Caller Worktree
14
+ ### Step 0: Resolve Caller Identity (worktree + user)
15
15
 
16
- Before any MCP call, derive the caller's `worktree_id` at runtime via `npx codebyplan resolve-worktree` so downstream calls can identify this worktree for hard-lock enforcement (CHK-104 TASK-2). The CLI resolves the tuple (device_id, repo path, current branch) against the worktrees table; CHK-108 removed the legacy `worktree_id` field from `.codebyplan/repo.json`.
16
+ Before any MCP call, resolve who and where the caller is.
17
+
18
+ **Worktree** — derive the caller `worktree_id` and any distress signal in one structured read:
17
19
 
18
20
  ```bash
19
- WORKTREE_ID=$(npx codebyplan resolve-worktree 2>/dev/null)
21
+ npx codebyplan resolve-worktree --json # → {"worktree_id":"<uuid>|null","error_kind":null|"<kind>"}
22
+ ```
23
+
24
+ - `error_kind` is `null` or `"tuple_miss"` → healthy. `WORKTREE_ID` = `worktree_id` (may be `null`: a legitimate main-repo or unregistered-worktree case — proceed to read the queue, but downstream hard-locks reject mutations on assigned rows).
25
+ - `error_kind` is `local_config_read_failed`, `local_config_write_failed`, `legacy_file_blocks_dir`, `api_failed`, `git_failed`, or `unhandled` → **broken local state**. Surface the distress line and STOP (skip Steps 1–4) — routing is unreliable when the resolver cannot read local state:
26
+
27
+ ```
28
+ ⚠ resolve-worktree: <error_kind> — local state is broken; routing is unreliable.
29
+ Run `npx codebyplan setup` from this directory to register/repair, then re-run /cbp-todo.
20
30
  ```
21
31
 
22
- - **`WORKTREE_ID` non-empty** forward as `worktree_id` to the `get_next_action` call in Step 1.
23
- - **`WORKTREE_ID` empty** → the current repo path + branch did not match any worktree row. The MCP server's hard-lock will reject mutations on assigned rows; surface a one-line note to the user that the worktree may need registration via `npx codebyplan setup` from this directory.
32
+ **User** `get_todos` (Step 1) requires a `user_id`:
33
+
34
+ ```bash
35
+ npx codebyplan whoami --json # → {"user_id":"<uuid>","email":"…"} or null
36
+ ```
37
+
38
+ - A `user_id` is returned → use it in Step 1.
39
+ - `null` (CLI keychain empty — common in OAuth-MCP-only environments) → the queue cannot be scoped by user; skip Step 1 and use the Step 3 fallback. The Step 1.5 ownership gate still applies.
40
+
41
+ ### Step 1: Read the Todo Queue (pure-read)
42
+
43
+ With `USER_ID` resolved, call MCP `get_todos({ repo_id, user_id, worktree_id })` (omit `worktree_id` when `WORKTREE_ID` is `null` or unresolved — that returns only unscoped rows). Take **`rows[0]`** as the queue head (ordered by `sort_order`).
24
44
 
25
- ### Step 1: Get Next Action
45
+ - The head carries `command`, `instructions`, `state`, `metadata`, `worktree_id`, `checkpoint_id`, `task_id`.
46
+ - The routing context (checkpoint/task) lives in **`rows[0].metadata`**.
47
+ - `get_todos` is **pure-read** — `apps/todo-worker` is the sole regen authority. NEVER call `get_next_action` or `regenerate_todos_for_repo`.
48
+ - Empty array, or `USER_ID` unavailable → go to Step 3 (empty-queue fallback).
26
49
 
27
- Use MCP `get_next_action` with `repo_id` and `worktree_id` (if present from Step 0).
50
+ Queue `command` values may use the `/codebyplan:<name>` plugin-namespace form (e.g. `/codebyplan:round-start`); treat each as the matching `/cbp-<name>` skill for the Step 2 matrix.
28
51
 
29
- ### Step 1.5: Checkpoint Planning Gate
52
+ ### Step 1.5: Resolve Target + Worktree-Ownership Gate
30
53
 
31
- Before honoring the command from Step 1, gate on the resolved active/next checkpoint's planning + activation state. This keeps work from starting on a half-baked or un-activated checkpoint. Resolve the checkpoint from the `get_next_action` response context (or MCP `get_current_task`), then load its `plan` + `status` via MCP `get_checkpoints` and its task count via MCP `get_tasks(checkpoint_id)`.
54
+ Resolve the routing target's checkpoint and gate on ownership BEFORE any auto-trigger including the Step 1.6 planning hand-offs. Refuse to route into, plan, or start work locked to a different worktree.
32
55
 
33
- Evaluate two rules in order (Rule A wins if both could match):
56
+ Resolve the checkpoint from `rows[0].metadata` (or MCP `get_current_task`), then load its `worktree_id` + `plan` + `status` via MCP `get_checkpoints` and its task count via MCP `get_tasks(checkpoint_id)`. This single load is reused by the Step 1.6 planning gate. Skip this gate when the routing target has no checkpoint (idle — see Step 3) or the command is `/cbp-session-start`.
34
57
 
35
- - **RULE A — unplanned**: empty `plan.steps[]` **AND** zero tasks → the checkpoint has not been planned. Suppress the Step-1 command; surface `Now planning CHK-NNN… handing off to /cbp-checkpoint-plan` and auto-trigger `/cbp-checkpoint-plan {NNN}`.
36
- - **RULE B — planned-but-pending**: has tasks (or non-empty `plan.steps[]`) **BUT** `status === "pending"` (not yet activated) → the checkpoint is planned but not started. Suppress the Step-1 command; surface `Now starting CHK-NNN… handing off to /cbp-checkpoint-start` and auto-trigger `/cbp-checkpoint-start {NNN}` (a planned checkpoint must be started + claimed before task work).
37
- - **Neither** (planned AND `active`) → fall through to Step 2 unchanged. No regression to the existing flow.
58
+ Two ownership signals:
38
59
 
39
- Skip this gate when `get_next_action` returns no checkpoint (idle see Step 3) or the command is `/cbp-session-start`.
60
+ 1. **Server-detected conflict** `rows[0].state === "worktree_conflict"` (command `/codebyplan:release-or-switch`): the todo-worker already detected the lock conflict. The owner is in `rows[0].metadata.conflicting_worktree_name` / `.conflicting_worktree_id`. Surface the mismatch message below and **STOP**. Do NOT auto-trigger the release-or-switch command and do NOT call `release_assignment` — switch worktrees rather than reassigning a lock to the caller.
61
+ 2. **Checkpoint-ownership check** — take the target checkpoint's `worktree_id` (loaded above, keyed by `rows[0].checkpoint_id` / `metadata.checkpoint.id`) and compare with caller `WORKTREE_ID`:
62
+ - target `worktree_id` is `null` → **allow** (unassigned — any worktree may pick it up; covers the both-null main-repo case).
63
+ - target `worktree_id` === caller `WORKTREE_ID` → **allow**.
64
+ - target `worktree_id` non-null **AND** caller `WORKTREE_ID` is `null`/unresolved → **block** (deliberate safety: identity cannot be confirmed. This does not contradict Step 0 — reading the queue is fine, auto-triggering INTO assigned work is not. Run `npx codebyplan setup` to register this worktree).
65
+ - target `worktree_id` non-null and differs from a non-null caller → **block**.
66
+
67
+ On block, resolve the owning worktree's `name` + `path` via MCP `get_worktrees({ repo_id })` (match by id), then emit and STOP:
68
+
69
+ ```
70
+ ⚠ Work mismatch: CHK-<NNN> TASK-<N> is assigned to worktree <name> (<short-uuid>), not this one (<this-name> / <this-short-uuid>).
71
+ Options:
72
+ A) Switch to <owning-worktree-path> and resume there
73
+ B) Work on something else here — run /cbp-checkpoint-create or /cbp-task-create
74
+ C) /cbp-session-end
75
+ ```
76
+
77
+ `<short-uuid>` = first 8 chars of the worktree UUID. Caller unresolved → render "this one" as `(unresolved / —)`. Name lookup miss → show the UUID alone. Wait for the user. NEVER propose reassigning the checkpoint to the caller worktree.
78
+
79
+ ### Step 1.6: Checkpoint Planning Gate
80
+
81
+ Ownership passed (Step 1.5). Now gate on the checkpoint's planning + activation state — reusing the `plan` + `status` + task count loaded in Step 1.5 — so work never starts on a half-baked or un-activated checkpoint. Evaluate two rules in order (Rule A wins if both could match):
82
+
83
+ - **RULE A — unplanned**: empty `plan.steps[]` **AND** zero tasks → not planned. Suppress the head command; surface `Now planning CHK-NNN… handing off to /cbp-checkpoint-plan` and auto-trigger `/cbp-checkpoint-plan {NNN}`.
84
+ - **RULE B — planned-but-pending**: has tasks (or non-empty `plan.steps[]`) **BUT** `status === "pending"` (not yet activated) → planned, not started. Suppress the head command; surface `Now starting CHK-NNN… handing off to /cbp-checkpoint-start` and auto-trigger `/cbp-checkpoint-start {NNN}` (a planned checkpoint must be started + claimed before task work).
85
+ - **Neither** (planned AND `active`) → fall through to Step 2.
86
+
87
+ Skip this gate when the routing target has no checkpoint (idle — see Step 3) or the command is `/cbp-session-start`.
40
88
 
41
89
  ### Step 2: Load Context Based on Command
42
90
 
43
- Before triggering the command, load the context it needs. This ensures `/clear` + `/cbp-todo` reliably restores full working context.
91
+ Once the gates pass, load the context the head command needs. This ensures `/clear` + `/cbp-todo` reliably restores full working context.
44
92
 
45
- **Use the context loading matrix below.** Match the `command` from the response to determine what to load.
93
+ **Use the context loading matrix below.** Match the `command` (in its `/cbp-<name>` form) to determine what to load.
46
94
 
47
95
  | Command Pattern | Context to Load |
48
96
  |----------------|-----------------|
49
97
  | `/cbp-session-start` | None — `/cbp-session-start` handles its own loading |
50
- | `/cbp-checkpoint-create` | If checkpoint exists in response context: load checkpoint via MCP `get_checkpoints` (filter by number). Display checkpoint title, goal, ideas summary |
98
+ | `/cbp-checkpoint-create` | If checkpoint exists in `rows[0].metadata`: load checkpoint via MCP `get_checkpoints` (filter by number). Display checkpoint title, goal, ideas summary |
51
99
  | `/cbp-checkpoint-plan` | Load checkpoint via MCP `get_checkpoints` (filter by number) + `get_tasks(checkpoint_id)`. Display checkpoint title, goal, ideas, existing task count |
52
100
  | `/cbp-checkpoint-start` | Load checkpoint via MCP `get_checkpoints` + `get_tasks(checkpoint_id)`. Display checkpoint title, status, claim state, first pending task |
53
101
  | `/cbp-task-start [N]` | Load via MCP `get_current_task`. Display checkpoint title + task title/requirements summary |
@@ -87,9 +135,13 @@ Display a brief context summary:
87
135
  [Brief: what was done, what passed/failed]
88
136
  ```
89
137
 
90
- ### Step 3: Suggest Session End When Idle
138
+ ### Step 3: Empty Queue Fallback, then Idle
139
+
140
+ Reached when `get_todos` returns `[]` or `USER_ID` was unavailable.
91
141
 
92
- If `get_next_action` returns no actionable `command` (null, empty, or explicit idle/done signal) i.e. no active checkpoint with pending work **do not auto-trigger anything**. Instead, suggest ending the session:
142
+ 1. **Fallback discovery** (worktree-scoped): MCP `get_current_task({ repo_id, worktree_id })` and `get_checkpoints({ repo_id, worktree_id, status: 'active' })` to discover whether actionable work exists for this caller.
143
+ 2. **Actionable work found** → treat the discovered checkpoint as the routing target and apply BOTH the Step 1.5 ownership gate and the Step 1.6 planning gate to it, using the `worktree_id` + `plan` + `status` returned by `get_checkpoints` (the fallback has no `rows[0]` — substitute the discovered checkpoint). If both gates pass, route via Step 2 → Step 4.
144
+ 3. **Nothing actionable** → suggest ending the session:
93
145
 
94
146
  ```
95
147
  No work remaining in the current checkpoint.
@@ -98,14 +150,15 @@ Run `/cbp-session-end` to finalize the session log and close out.
98
150
  Or run `/cbp-checkpoint-create` to start new work.
99
151
  ```
100
152
 
101
- Wait for the user. Do not auto-trigger `/cbp-session-end` — session wrap-up is a user-driven decision.
153
+ Wait for the user. Do not auto-trigger `/cbp-session-end` — session wrap-up is a user-driven decision. NEVER call `regenerate_todos_for_repo`; an empty queue is a legitimate "nothing scheduled" state (the todo-worker has not regenerated yet, or there is no actionable work).
102
154
 
103
155
  ### Step 4: Display and Auto-trigger
104
156
 
105
- If Step 3 found actionable work, show `instructions` from the get_next_action response (gives user a chance to see what's happening), then auto-trigger the `command`.
157
+ Reached only when the Step 1.5 ownership gate allowed routing to continue and the Step 1.6 planning gate fell through (no hand-off). Show `rows[0].instructions` (so the user sees what is happening), then auto-trigger `rows[0].command` (its `/cbp-<name>` form).
106
158
 
107
159
  ## Integration
108
160
 
109
161
  - **Called by**: `/cbp-session-start`, `/cbp-task-complete`, `/cbp-checkpoint-complete`, manual, after `/clear`
110
- - **Reads**: MCP `get_next_action`, `get_current_task`, `get_rounds`, `get_checkpoints`, `get_tasks`
111
- - **Triggers**: `command` from response (auto); Step 1.5 gate overrides to `/cbp-checkpoint-plan` (unplanned) or `/cbp-checkpoint-start` (planned-but-pending)
162
+ - **Resolves**: `npx codebyplan resolve-worktree --json` (worktree id + distress signal), `npx codebyplan whoami --json` (user id)
163
+ - **Reads**: MCP `get_todos`, `get_current_task`, `get_rounds`, `get_checkpoints`, `get_tasks`, `get_worktrees`
164
+ - **Triggers**: `rows[0].command` (auto, after the Step 1.5 ownership gate); Step 1.6 overrides to `/cbp-checkpoint-plan` (unplanned) or `/cbp-checkpoint-start` (planned-but-pending)
@@ -0,0 +1,45 @@
1
+ ---
2
+ scope: org-shared
3
+ name: cbp-todo-qa-regression
4
+ description: Manual regression procedure for the cbp-todo worktree-ownership gate + get_todos switch (CHK-137 TASK-2)
5
+ ---
6
+
7
+ # cbp-todo — Worktree-Ownership Regression
8
+
9
+ Manual procedure verifying that `/cbp-todo` reads the pure-read `get_todos` queue and refuses to auto-route into work locked to another worktree. No automated harness exists for markdown skills; run these by hand (or read the queue with the MCP tools) whenever Step 0/1/1.5 of `SKILL.md` changes.
10
+
11
+ Repo under test: `2ff6d405-39c5-47b8-a6d1-59f998ac0537`. Resolve a real `user_id` with `npx codebyplan whoami --json`, or harvest a non-null `assigned_user_id` from `get_checkpoints`.
12
+
13
+ ## Preconditions
14
+
15
+ - `get_todos` is the only Step 1 read — confirm no `get_next_action` / `regenerate_todos_for_repo` call remains (`grep -n 'get_next_action\|regenerate_todos_for_repo' SKILL.md` → no hits).
16
+ - Step 0 uses `resolve-worktree --json` and `whoami --json`.
17
+
18
+ ## Scenario A — caller owns the work → auto-trigger
19
+
20
+ 1. From a worktree that owns the active checkpoint (caller `WORKTREE_ID` === target checkpoint `worktree_id`, or target `worktree_id` is `null`).
21
+ 2. `get_todos({ repo_id, user_id, worktree_id })` returns a head whose target checkpoint is owned by the caller (or unscoped).
22
+ 3. **Expected**: Step 1.5 ownership gate allows, Step 1.6 planning gate falls through, Step 4 auto-triggers `rows[0].command` (mapped to its `/cbp-<name>` form).
23
+
24
+ ## Scenario B1 — server-generated conflict head → halt
25
+
26
+ 1. Caller `codebyplan-claude-2` (`38cd7dfa`). Active work assigned to `codebyplan-cli` (`016bd7f2`).
27
+ 2. `get_todos` head is the server conflict todo: `state === "worktree_conflict"`, `command "/codebyplan:release-or-switch"`, `metadata.conflicting_worktree_name === "codebyplan-cli"`.
28
+ 3. **MCP calls expected**: `get_todos`; `get_worktrees` (to resolve `016bd7f2` path for the message). NO `get_checkpoints` needed — the server already resolved the conflict.
29
+ 4. **Expected**: Step 1.5 server-conflict branch blocks and STOPS. The "Work mismatch" message names `codebyplan-cli (016bd7f2)` vs `codebyplan-claude-2 (38cd7dfa)`, offers Switch (`/Users/merilyviks/codebyplan-cli`) / other-work / session-end. NO auto-trigger; NO `release_assignment` call (never reassign the lock to the caller).
30
+
31
+ ## Scenario B2 — normal head, mismatched checkpoint owner → halt
32
+
33
+ 1. Caller `codebyplan-claude-2` (`38cd7dfa`). `get_todos` head is a normal routing todo whose target checkpoint `worktree_id` is `016bd7f2`.
34
+ 2. **MCP calls expected**: `get_todos`; `get_checkpoints` (Step 1.5 load, reads the target `worktree_id`); `get_worktrees` (resolve `016bd7f2` name + path).
35
+ 3. **Expected**: Step 1.5 checkpoint-ownership branch blocks and STOPS with the same "Work mismatch" message. NO auto-trigger; the Step 1.6 planning gate never runs (ownership precedes it).
36
+
37
+ ## Scenario C — empty queue + cross-worktree current task → halt
38
+
39
+ 1. `get_todos` returns `[]` (or `whoami` returned `null`, so Step 1 was skipped).
40
+ 2. Step 3 fallback: `get_current_task({ repo_id, worktree_id })` / `get_checkpoints({ repo_id, worktree_id, status: 'active' })` surface a checkpoint whose `worktree_id` differs from the caller.
41
+ 3. **Expected**: the Step 1.5 ownership gate (applied to the fallback target) blocks the discovered work with the same "Work mismatch" message. NO auto-trigger. `regenerate_todos_for_repo` is never called.
42
+
43
+ ## Edge — both null
44
+
45
+ Caller `WORKTREE_ID` empty AND target checkpoint `worktree_id` `null` → legitimate main-repo / unassigned work → auto-trigger allowed.