codebyplan 1.10.2 → 1.11.0

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.
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env python3
2
+ # @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked via the cbp-subagent-statusline.sh dispatcher)
3
+ # Claude Code Subagent Status Line — python renderer. Byte-identical output to the
4
+ # bash renderer in cbp-subagent-statusline.sh and the node renderer in
5
+ # cbp-subagent-statusline.mjs. Output protocol: one {"id":...,"content":...} JSON
6
+ # line per task. See the .sh file for the full option/env/seam contract.
7
+ # Never throws to stdout.
8
+
9
+ import json
10
+ import math
11
+ import os
12
+ import sys
13
+ import time
14
+
15
+
16
+ def main():
17
+ raw_input = sys.stdin.read()
18
+ try:
19
+ data = json.loads(raw_input)
20
+ except Exception:
21
+ data = {}
22
+ if not isinstance(data, dict):
23
+ data = {}
24
+
25
+ root = os.environ.get("CBP_STATUSLINE_ROOT") or (sys.argv[1] if len(sys.argv) > 1 else "")
26
+ if not root:
27
+ root = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", ".."))
28
+
29
+ try:
30
+ columns = math.trunc(float(data.get("columns") or 0))
31
+ except (TypeError, ValueError):
32
+ columns = 0
33
+ tasks = data.get("tasks")
34
+ if not isinstance(tasks, list):
35
+ tasks = []
36
+
37
+ # ---- Config: no_color from statusline.json -------------------------------
38
+ cfg_no_color = False
39
+ try:
40
+ with open(os.path.join(root, ".codebyplan", "statusline.json"), "r", encoding="utf-8") as fh:
41
+ parsed = json.load(fh)
42
+ if isinstance(parsed, dict) and isinstance(parsed.get("no_color"), bool):
43
+ cfg_no_color = parsed["no_color"]
44
+ except Exception:
45
+ pass
46
+
47
+ no_color = (
48
+ (os.environ.get("NO_COLOR") not in (None, ""))
49
+ or os.environ.get("CBP_SUBAGENT_STATUSLINE_NO_COLOR") == "1"
50
+ or cfg_no_color is True
51
+ )
52
+ if no_color:
53
+ RST = DIM = BOLD = GREEN = RED = BLUE = ""
54
+ else:
55
+ RST = "\x1b[0m"
56
+ DIM = "\x1b[2m"
57
+ BOLD = "\x1b[1m"
58
+ GREEN = "\x1b[32m"
59
+ RED = "\x1b[31m"
60
+ BLUE = "\x1b[34m"
61
+
62
+ def fmt_k(val):
63
+ v = float(val)
64
+ if v >= 1000000:
65
+ t = math.floor((v + 50000) / 100000)
66
+ return "%d.%dM" % (t // 10, t % 10)
67
+ elif v >= 1000:
68
+ t = math.floor((v + 50) / 100)
69
+ return "%d.%dK" % (t // 10, t % 10)
70
+ return str(int(v))
71
+
72
+ def cbp_now():
73
+ nv = os.environ.get("CBP_STATUSLINE_NOW")
74
+ if nv not in (None, ""):
75
+ return math.trunc(float(nv))
76
+ return math.floor(time.time())
77
+
78
+ def fmt_age_secs(start):
79
+ delta = cbp_now() - math.trunc(float(start))
80
+ if delta < 0:
81
+ delta = 0
82
+ if delta >= 3600:
83
+ return "%dh%dm" % (delta // 3600, (delta % 3600) // 60)
84
+ if delta >= 60:
85
+ return "%dm%ds" % (delta // 60, delta % 60)
86
+ return "%ds" % delta
87
+
88
+ def is_ascii_letter(b):
89
+ return (0x41 <= b <= 0x5A) or (0x61 <= b <= 0x7A)
90
+
91
+ def truncate(rendered):
92
+ # ANSI-aware byte-based truncation — mirrors the bash byte walk exactly.
93
+ if not (columns > 0):
94
+ return rendered
95
+ buf = rendered.encode("utf-8")
96
+ raw_len = len(buf)
97
+ target = columns - 3
98
+ if target < 0:
99
+ target = 0
100
+ vis = 0
101
+ i = 0
102
+ in_esc = False
103
+ while i < raw_len and vis < target:
104
+ b = buf[i]
105
+ if in_esc:
106
+ if is_ascii_letter(b):
107
+ in_esc = False
108
+ elif b == 0x1B:
109
+ in_esc = True
110
+ else:
111
+ vis += 1
112
+ i += 1
113
+ while i < raw_len:
114
+ b = buf[i]
115
+ if in_esc:
116
+ if is_ascii_letter(b):
117
+ in_esc = False
118
+ i += 1
119
+ elif b == 0x1B:
120
+ in_esc = True
121
+ i += 1
122
+ else:
123
+ break
124
+ if i < raw_len:
125
+ head = buf[:i]
126
+ tail = (RST + "...").encode("utf-8")
127
+ return (head + tail).decode("utf-8", errors="replace")
128
+ return rendered
129
+
130
+ out = []
131
+ for task in tasks:
132
+ if not isinstance(task, dict):
133
+ continue
134
+ t_id = "" if task.get("id") is None else str(task.get("id"))
135
+ if t_id == "":
136
+ continue
137
+ t_name = "" if task.get("name") is None else str(task.get("name"))
138
+ t_type = "" if task.get("type") is None else str(task.get("type"))
139
+ t_status = "" if task.get("status") is None else str(task.get("status"))
140
+ t_desc = "" if task.get("description") is None else str(task.get("description"))
141
+ try:
142
+ t_start = math.trunc(float(task.get("startTime") or 0))
143
+ except (TypeError, ValueError):
144
+ t_start = 0
145
+ try:
146
+ t_tokens = math.trunc(float(task.get("tokenCount") or 0))
147
+ except (TypeError, ValueError):
148
+ t_tokens = 0
149
+
150
+ if t_status == "running":
151
+ icon = "%s●%s" % (GREEN, RST)
152
+ elif t_status == "pending":
153
+ icon = "%s○%s" % (DIM, RST)
154
+ elif t_status == "completed":
155
+ icon = "%s✓%s" % (BLUE, RST)
156
+ elif t_status == "failed":
157
+ icon = "%s✗%s" % (RED, RST)
158
+ else:
159
+ icon = "%s•%s" % (DIM, RST)
160
+
161
+ row = "%s %s%s%s" % (icon, BOLD, t_name, RST)
162
+ if t_type:
163
+ row += " %s(%s)%s" % (DIM, t_type, RST)
164
+ if os.environ.get("CBP_SUBAGENT_STATUSLINE_HIDE_DESCRIPTION") != "1" and t_desc:
165
+ row += " %s—%s %s" % (DIM, RST, t_desc)
166
+ if os.environ.get("CBP_SUBAGENT_STATUSLINE_HIDE_TOKENS") != "1" and t_tokens > 0:
167
+ row += " %s·%s %stokens:%s%s" % (DIM, RST, DIM, RST, fmt_k(t_tokens))
168
+ if t_start > 0:
169
+ row += " %s·%s %sage:%s%s" % (DIM, RST, DIM, RST, fmt_age_secs(t_start))
170
+
171
+ rendered = truncate(row)
172
+ out.append(
173
+ '{"id":%s,"content":%s}'
174
+ % (json.dumps(t_id, ensure_ascii=False), json.dumps(rendered, ensure_ascii=False))
175
+ )
176
+
177
+ sys.stdout.write(("\n".join(out) + "\n") if out else "")
178
+
179
+
180
+ try:
181
+ main()
182
+ except Exception:
183
+ sys.exit(0)
@@ -1,22 +1,65 @@
1
1
  #!/bin/bash
2
2
  # Portability: bash 3.2+ (macOS default /bin/bash). UTF-8 multibyte chars in
3
3
  # user-controlled fields are byte-iterated under 3.2 (over-counts visible width
4
- # for non-ASCII content); acceptable safe-direction approximation.
5
- # @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked by settings.json subagentStatusLine.command)
6
- # Claude Code Subagent Status Line one JSON line per active subagent
7
- # Purpose: Renders one row per running subagent. Output protocol per upstream docs:
4
+ # for non-ASCII content); acceptable safe-direction approximation — and the node
5
+ # (.mjs) / python (.py) renderers iterate the SAME UTF-8 bytes so all three agree.
6
+ # @hook: NOT-A-HOOK (subagentStatusLine renderer, invoked via settings.json subagentStatusLine.command)
7
+ # Claude Code Subagent Status Line multi-runtime dispatcher + bash renderer.
8
+ # One JSON line per active subagent. Output protocol per upstream docs:
8
9
  # {"id": "<task_id>", "content": "<rendered_body>"}
9
10
  # - Omitted tasks keep their default Claude Code rendering
10
11
  # - Empty content strings hide the row
11
12
  # NOT a PreToolUse/PostToolUse/Notification hook — do NOT register in hooks[].
12
13
  #
13
- # ENV-VAR TOGGLES set to 1 to suppress a column / color
14
+ # RENDERER SELECTION (per-device, gitignored .codebyplan/statusline.local.json):
15
+ # { "renderer": "bash" | "node" | "python" } — default bash, falls back to bash
16
+ # when the chosen runtime is unavailable. Set via `codebyplan statusline …`.
17
+ #
18
+ # CONFIG: honours `no_color` from the committed .codebyplan/statusline.json. The six
19
+ # main-statusline line toggles do NOT apply to subagent rows.
20
+ #
21
+ # ENV-VAR OVERRIDES (env > config > default)
14
22
  # CBP_SUBAGENT_STATUSLINE_HIDE_DESCRIPTION=1 omit DESCRIPTION segment
15
23
  # CBP_SUBAGENT_STATUSLINE_HIDE_TOKENS=1 omit tokens:N segment
16
24
  # CBP_SUBAGENT_STATUSLINE_NO_COLOR=1 strip all ANSI codes (also honoured by $NO_COLOR)
25
+ #
26
+ # TEST SEAMS (no effect in normal use)
27
+ # CBP_STATUSLINE_ROOT=<dir> override the .codebyplan/ lookup root (default: repo root)
28
+ # CBP_STATUSLINE_NOW=<epoch> override "now" for age rendering (determinism)
29
+
30
+ # ============================================================
31
+ # DISPATCHER — resolve root + renderer, delegate when possible
32
+ # ============================================================
33
+ CBP_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || CBP_HOOK_DIR="."
34
+ CBP_ROOT="${CBP_STATUSLINE_ROOT:-}"
35
+ if [ -z "$CBP_ROOT" ]; then
36
+ CBP_ROOT="$(cd "$CBP_HOOK_DIR/../.." && pwd 2>/dev/null)" || CBP_ROOT="$PWD"
37
+ fi
38
+
39
+ CBP_RENDERER="bash"
40
+ CBP_LOCAL_CFG="$CBP_ROOT/.codebyplan/statusline.local.json"
41
+ if [ -f "$CBP_LOCAL_CFG" ] && command -v jq >/dev/null 2>&1; then
42
+ _r="$(jq -r '.renderer // empty' "$CBP_LOCAL_CFG" 2>/dev/null)"
43
+ case "$_r" in bash|node|python) CBP_RENDERER="$_r" ;; esac
44
+ fi
45
+
46
+ if [ "$CBP_RENDERER" = "node" ] && command -v node >/dev/null 2>&1 \
47
+ && [ -f "$CBP_HOOK_DIR/cbp-subagent-statusline.mjs" ]; then
48
+ CBP_STATUSLINE_ROOT="$CBP_ROOT" exec node "$CBP_HOOK_DIR/cbp-subagent-statusline.mjs"
49
+ fi
50
+ if [ "$CBP_RENDERER" = "python" ] && command -v python3 >/dev/null 2>&1 \
51
+ && [ -f "$CBP_HOOK_DIR/cbp-subagent-statusline.py" ]; then
52
+ CBP_STATUSLINE_ROOT="$CBP_ROOT" exec python3 "$CBP_HOOK_DIR/cbp-subagent-statusline.py"
53
+ fi
54
+ # Fall through → inline bash renderer (default + universal fallback).
17
55
 
18
56
  INPUT=$(cat)
19
57
 
58
+ # Byte-deterministic string ops: force the C locale so the truncation walk below
59
+ # counts UTF-8 BYTES (locale-independent), matching the .mjs/.py byte iteration.
60
+ # Output bytes are unaffected (multibyte content passes through verbatim).
61
+ export LC_ALL=C
62
+
20
63
  # One jq pass for base fields (cwd, session_id, columns)
21
64
  eval "$(echo "$INPUT" | jq -r '
22
65
  @sh "CWD=\(.cwd // "")",
@@ -24,9 +67,17 @@ eval "$(echo "$INPUT" | jq -r '
24
67
  @sh "COLUMNS=\(.columns // 0)"
25
68
  ')"
26
69
 
27
- # ---- Colour setup ----------------------------------------------------------
70
+ # ---- Config: no_color from .codebyplan/statusline.json -----------------------
71
+ CFG_NO_COLOR=false
72
+ CBP_CFG="$CBP_ROOT/.codebyplan/statusline.json"
73
+ if [ -f "$CBP_CFG" ] && command -v jq >/dev/null 2>&1; then
74
+ # `== true` (NOT jq `//`): only an explicit true enables no_color.
75
+ eval "$(jq -r '"CFG_NO_COLOR=\(.no_color == true)"' "$CBP_CFG" 2>/dev/null)"
76
+ fi
77
+
78
+ # ---- Colour setup (env > config) ---------------------------------------------
28
79
  # Only the colours referenced by the render block below are defined here.
29
- if [ -n "${NO_COLOR:-}" ] || [ "${CBP_SUBAGENT_STATUSLINE_NO_COLOR:-0}" = "1" ]; then
80
+ if [ -n "${NO_COLOR:-}" ] || [ "${CBP_SUBAGENT_STATUSLINE_NO_COLOR:-0}" = "1" ] || [ "$CFG_NO_COLOR" = "true" ]; then
30
81
  RST=''; DIM=''; BOLD=''; GREEN=''; RED=''; BLUE=''
31
82
  else
32
83
  RST='\033[0m'
@@ -37,25 +88,29 @@ else
37
88
  BLUE='\033[34m'
38
89
  fi
39
90
 
40
- # ---- Token/size formatter K / M (verbatim from cbp-statusline.sh L96-105) ---
91
+ # ---- Token/size formatter K / M integer round-half-up (cross-runtime) ------
41
92
  fmt_k() {
42
93
  local val=$1
43
94
  if [ "$val" -ge 1000000 ] 2>/dev/null; then
44
- printf "%.1fM" "$(echo "$val / 1000000" | bc -l)"
95
+ local t=$(( (val + 50000) / 100000 ))
96
+ printf "%d.%dM" $(( t / 10 )) $(( t % 10 ))
45
97
  elif [ "$val" -ge 1000 ] 2>/dev/null; then
46
- printf "%.1fK" "$(echo "$val / 1000" | bc -l)"
98
+ local t=$(( (val + 50) / 100 ))
99
+ printf "%d.%dK" $(( t / 10 )) $(( t % 10 ))
47
100
  else
48
101
  printf "%d" "$val"
49
102
  fi
50
103
  }
51
104
 
52
- # ---- Age formatter: epoch seconds → "Xs" / "XmYs" / "XhYm" -----------------
53
- # NEW helper — cannot reuse fmt_dur (which divides by 1000 for ms input).
54
- # startTime is a Unix epoch in seconds, same as rate_limits.*.resets_at.
105
+ # ---- "now" with test seam ----------------------------------------------------
106
+ cbp_now() {
107
+ if [ -n "${CBP_STATUSLINE_NOW:-}" ]; then printf '%s' "$CBP_STATUSLINE_NOW"; else date +%s; fi
108
+ }
109
+
110
+ # ---- Age formatter: epoch seconds → "Xs" / "XmYs" / "XhYm" ------------------
55
111
  fmt_age_secs() {
56
112
  local start=$1
57
- local now
58
- now=$(date +%s)
113
+ local now; now=$(cbp_now)
59
114
  local delta=$(( now - start ))
60
115
  [ "$delta" -lt 0 ] && delta=0
61
116
  if [ "$delta" -ge 3600 ]; then
@@ -67,18 +122,16 @@ fmt_age_secs() {
67
122
  fi
68
123
  }
69
124
 
70
- # ---- Strip ANSI codes for visible-length measurement (column truncation) ----
71
- strip_ansi() {
72
- printf '%s' "$1" | sed $'s/\033\\[[0-9;]*m//g'
73
- }
74
-
75
125
  # ---- JSON-string-escape a bash variable (embed in {"id":..., "content":...}) -
76
126
  json_escape() {
77
127
  printf '%s' "$1" | jq -Rs '.'
78
128
  }
79
129
 
80
- # ---- Per-task render: one jq TSV emission, looped in bash ------------------
81
- # Single jq pass for tasks[] (locked decision #3 two jq calls total).
130
+ # ---- Per-task render: one jq US-delimited emission, looped in bash ----------
131
+ # Join with the Unit Separator (U+001F, non-whitespace) rather than @tsv: a tab is
132
+ # IFS-whitespace, so `IFS=$'\t' read` collapses consecutive tabs and an EMPTY field
133
+ # (e.g. blank description) would shift every later field. US is non-whitespace, so
134
+ # empty fields are preserved — matching the .mjs/.py object iteration.
82
135
  echo "$INPUT" | jq -r '
83
136
  .tasks // [] | .[] | [
84
137
  .id // "",
@@ -88,8 +141,8 @@ echo "$INPUT" | jq -r '
88
141
  .description // "",
89
142
  (.startTime // 0 | tostring),
90
143
  (.tokenCount // 0 | tostring)
91
- ] | @tsv
92
- ' | while IFS=$'\t' read -r T_ID T_NAME T_TYPE T_STATUS T_DESC T_START T_TOKENS; do
144
+ ] | join("\u001f")
145
+ ' | while IFS=$'\037' read -r T_ID T_NAME T_TYPE T_STATUS T_DESC T_START T_TOKENS; do
93
146
  # Skip blank rows (empty tasks[] → no output)
94
147
  [ -z "$T_ID" ] && continue
95
148
 
@@ -123,14 +176,12 @@ echo "$INPUT" | jq -r '
123
176
  # Apply printf to interpret escape sequences, then truncate to $COLUMNS
124
177
  RENDERED=$(printf '%b' "$ROW")
125
178
  if [ "$COLUMNS" -gt 0 ] 2>/dev/null; then
126
- # ANSI-aware iterative truncation: walk RENDERED char-by-char treating
127
- # \033[...letter escape sequences as zero-width and ordinary chars as
179
+ # ANSI-aware iterative truncation: walk RENDERED byte-by-byte treating
180
+ # \033[...letter escape sequences as zero-width and ordinary bytes as
128
181
  # one-width. Stop when the visible counter reaches COLUMNS - 3, then
129
- # append RST + ellipsis. Correct under any ANSI-density distribution,
130
- # unlike a single OVERHEAD-add formula which assumes uniform spread.
131
- # Pure bash (no awk/perl subprocess); byte-based slicing means non-ASCII
132
- # multi-byte UTF-8 in user-controlled descriptions counts as multiple
133
- # visible units — acceptable approximation for the ASCII-dominant case.
182
+ # append RST + ellipsis. Byte-based slicing means non-ASCII multi-byte
183
+ # UTF-8 counts as multiple visible units the .mjs/.py renderers iterate
184
+ # the same UTF-8 bytes, so all three stay byte-identical.
134
185
  TARGET=$(( COLUMNS - 3 ))
135
186
  [ "$TARGET" -lt 0 ] && TARGET=0
136
187
  RAW_LEN=${#RENDERED}
@@ -150,12 +201,11 @@ echo "$INPUT" | jq -r '
150
201
  fi
151
202
  i=$(( i + 1 ))
152
203
  done
153
- # Post-loop drain: every rendered row ends with a trailing ${RST} from the
154
- # last segment. Without this drain, a row whose visible content fits within
155
- # COLUMNS still has `i < RAW_LEN` (pointing inside the trailing reset),
156
- # which would spuriously trigger truncation. Walk past any in-progress
157
- # escape and any further escape sequences until we hit a real visible byte
158
- # or end-of-string.
204
+ # Post-loop drain: every rendered row ends with a trailing ${RST}. Without
205
+ # this drain a row whose visible content fits within COLUMNS still has
206
+ # `i < RAW_LEN` (pointing inside the trailing reset), spuriously triggering
207
+ # truncation. Walk past any in-progress escape and any further escapes until
208
+ # a real visible byte or end-of-string.
159
209
  while [ $i -lt $RAW_LEN ]; do
160
210
  ch="${RENDERED:$i:1}"
161
211
  if [ "$in_esc" = "1" ]; then
@@ -171,8 +221,6 @@ echo "$INPUT" | jq -r '
171
221
  fi
172
222
  done
173
223
  if [ $i -lt $RAW_LEN ]; then
174
- # printf '%b' converts the literal-text RST (`\033[0m`) into an actual
175
- # ESC byte sequence, matching the byte semantics of RENDERED's prefix.
176
224
  RENDERED="${RENDERED:0:$i}$(printf '%b' "$RST")..."
177
225
  fi
178
226
  fi
@@ -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.