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.
- package/dist/cli.js +719 -240
- package/package.json +1 -1
- package/templates/hooks/README.md +58 -16
- package/templates/hooks/cbp-statusline.mjs +385 -0
- package/templates/hooks/cbp-statusline.py +331 -0
- package/templates/hooks/cbp-statusline.sh +138 -82
- package/templates/hooks/cbp-subagent-statusline.mjs +200 -0
- package/templates/hooks/cbp-subagent-statusline.py +183 -0
- package/templates/hooks/cbp-subagent-statusline.sh +87 -39
- package/templates/skills/cbp-session-start/SKILL.md +38 -10
- package/templates/skills/cbp-session-start/qa-regression.md +51 -0
- package/templates/skills/cbp-todo/SKILL.md +77 -24
- package/templates/skills/cbp-todo/qa-regression.md +45 -0
|
@@ -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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
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
|
-
# ----
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
# ----
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
81
|
-
#
|
|
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
|
-
] |
|
|
92
|
-
' | while IFS=$'\
|
|
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
|
|
127
|
-
# \033[...letter escape sequences as zero-width and ordinary
|
|
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.
|
|
130
|
-
#
|
|
131
|
-
#
|
|
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}
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
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
|
-
|
|
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
|
|
43
|
+
Resolve `worktree_id` at runtime using the structured JSON form:
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
|
|
46
|
+
RESOLVE_JSON=$(npx codebyplan resolve-worktree --json)
|
|
47
|
+
# → {"worktree_id":"<uuid>|null","error_kind":null|"<kind>"}
|
|
47
48
|
```
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
Extract `worktree_id` and `error_kind` from the JSON output.
|
|
50
51
|
|
|
51
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
- **
|
|
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.
|