@polymorphism-tech/morph-spec 8.7.0 → 8.9.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/README.md +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/statusline.py +545 -521
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +153 -105
- package/src/commands/state/approve.js +25 -0
- package/src/core/paths/output-schema.js +0 -18
- package/src/utils/hooks-installer.js +7 -1
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
# framework/hooks/claude-code/statusline.py
|
|
3
|
-
# Claude Code statusline for morph-spec
|
|
4
|
-
# Receives JSON via stdin from Claude Code after each response
|
|
3
|
+
# Claude Code statusline for morph-spec.
|
|
4
|
+
# Receives JSON via stdin from Claude Code after each response.
|
|
5
|
+
#
|
|
6
|
+
# DESIGN RULE — the payload is the source of truth.
|
|
7
|
+
# Anything Claude Code already computes (context %, rate limits, worktree,
|
|
8
|
+
# repo, PR) is READ from stdin, never recomputed. Recomputing is what made
|
|
9
|
+
# previous versions drift away from /context and /usage. Only two things come
|
|
10
|
+
# from outside the payload: git diff stats and the MORPH feature state.
|
|
11
|
+
#
|
|
12
|
+
# ONE LINE — the layout is deliberately a single row:
|
|
13
|
+
#
|
|
14
|
+
# <model·effort> │ <context bar ↻ tokens-to-compact> │ <5h> │ <7d> │ <branch> │ <PR>
|
|
15
|
+
#
|
|
16
|
+
# The only second line is the MORPH block, and only inside a secondary
|
|
17
|
+
# worktree. Everything else that used to occupy rows — the session name, the
|
|
18
|
+
# session clock, the last Skill/Agent, the output style — was cut as noise.
|
|
19
|
+
# Dropping the Skill/Agent widgets is also what lets this script stop parsing
|
|
20
|
+
# the transcript entirely.
|
|
21
|
+
#
|
|
22
|
+
# The statusline is STATELESS — it never writes to disk. An earlier version
|
|
23
|
+
# cached session start times per-cwd and per-transcript; the cwd cache leaked
|
|
24
|
+
# start times between concurrent windows and the directory grew to thousands
|
|
25
|
+
# of files. Removing the clock removed the need for any of it.
|
|
5
26
|
|
|
6
27
|
import sys
|
|
7
28
|
import json
|
|
8
29
|
import os
|
|
9
30
|
import subprocess
|
|
10
|
-
import time
|
|
11
31
|
import re
|
|
12
|
-
import
|
|
32
|
+
import time
|
|
13
33
|
import traceback
|
|
14
34
|
from pathlib import Path
|
|
15
|
-
from datetime import datetime, timezone
|
|
16
35
|
|
|
17
36
|
# Ensure UTF-8 output on Windows (stdout defaults to CP1252 otherwise)
|
|
18
37
|
if hasattr(sys.stdout, 'reconfigure'):
|
|
@@ -22,7 +41,6 @@ if hasattr(sys.stdout, 'reconfigure'):
|
|
|
22
41
|
R = '\033[0m' # Reset
|
|
23
42
|
BOLD = '\033[1m'
|
|
24
43
|
CYAN = '\033[36m'
|
|
25
|
-
MAGENTA = '\033[35m'
|
|
26
44
|
GREEN = '\033[32m'
|
|
27
45
|
YELLOW = '\033[33m'
|
|
28
46
|
RED = '\033[31m'
|
|
@@ -33,51 +51,79 @@ WHITE = '\033[97m'
|
|
|
33
51
|
# Windows: flag to hide console window when spawning subprocesses
|
|
34
52
|
_CREATE_NO_WINDOW = 0x08000000 if os.name == 'nt' else 0
|
|
35
53
|
|
|
54
|
+
SEP = f" {GRAY}│{R} "
|
|
36
55
|
|
|
37
|
-
# ── MORPH framework constants (derived from phases.json / trust-manager.js) ──
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
# ── MORPH framework constants ────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
# Mirrors derivePhase() in src/core/state/state-manager.js — the first phase
|
|
60
|
+
# (scanning backwards) whose CANONICAL artifact exists wins. An empty folder
|
|
61
|
+
# is not enough.
|
|
62
|
+
PHASE_MAP = [
|
|
63
|
+
('4-review', 'review', 'review-report.md'),
|
|
64
|
+
('3-implement', 'implement', 'recap.md'),
|
|
65
|
+
('2-plan', 'plan', 'spec.md'),
|
|
66
|
+
('1-design', 'uiux', 'design-system.md'),
|
|
67
|
+
('0-proposal', 'proposal', 'proposal.md'),
|
|
68
|
+
]
|
|
69
|
+
PHASE_INDEX = {'proposal': 0, 'uiux': 1, 'plan': 2, 'implement': 3, 'review': 4}
|
|
46
70
|
PHASE_LABELS = {
|
|
47
|
-
'proposal':
|
|
48
|
-
'uiux':
|
|
49
|
-
'
|
|
50
|
-
'plan': 'planning',
|
|
51
|
-
'tasks': 'tasks',
|
|
71
|
+
'proposal': 'proposal',
|
|
72
|
+
'uiux': 'UI/UX',
|
|
73
|
+
'plan': 'planning',
|
|
52
74
|
'implement': 'implement',
|
|
53
|
-
'
|
|
75
|
+
'review': 'review',
|
|
54
76
|
}
|
|
55
77
|
PIPELINE_TOTAL = 5
|
|
56
78
|
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
# Gates that can block; `implement` has none. Mirrors feature.schema.json.
|
|
80
|
+
GATE_PHASES = ('proposal', 'uiux', 'plan', 'review')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── Auto-compact threshold ───────────────────────────────────────────────────
|
|
84
|
+
#
|
|
85
|
+
# Mirrors claude.exe v2.1.220. The context bar's headline number is the
|
|
86
|
+
# payload's own `used_percentage` (a fraction of the MODEL window), but
|
|
87
|
+
# auto-compact fires much earlier, against a different budget:
|
|
88
|
+
#
|
|
89
|
+
# l7(model, cfg).window = min(modelWindow, autoCompactWindow) [settings]
|
|
90
|
+
# HSe(model, cfg) = window - min(maxOutputTokens, 20000)
|
|
91
|
+
# wfo(e) = e - 13000
|
|
92
|
+
# threshold = wfo(HSe(...))
|
|
93
|
+
#
|
|
94
|
+
# maxOutputTokens is >= 20000 on every current model, so the output reserve is
|
|
95
|
+
# a flat 20000. This is only reproducible when `autoCompactWindow` is set
|
|
96
|
+
# explicitly in settings (source "settings"); every other source (clientdata,
|
|
97
|
+
# experiment, model-default, auto) is server-driven and cannot be mirrored
|
|
98
|
+
# here, so the widget hides itself instead of guessing.
|
|
99
|
+
COMPACT_OUTPUT_RESERVE = 20000
|
|
100
|
+
COMPACT_HEADROOM = 13000
|
|
64
101
|
|
|
65
|
-
# Trust level thresholds and badges (from trust-manager.js)
|
|
66
|
-
# passRate >= threshold → level
|
|
67
|
-
TRUST_LEVELS = [
|
|
68
|
-
(0.95, 'maximum', GREEN + BOLD, '◆◆◆◆'),
|
|
69
|
-
(0.90, 'high', GREEN, '◆◆◆○'),
|
|
70
|
-
(0.80, 'medium', YELLOW, '◆◆○○'),
|
|
71
|
-
(0.00, 'low', RED, '◆○○○'),
|
|
72
|
-
]
|
|
73
102
|
|
|
74
|
-
|
|
103
|
+
# ── General helpers ──────────────────────────────────────────────────────────
|
|
75
104
|
|
|
105
|
+
def safe_int(val, default=0):
|
|
106
|
+
"""Safely convert value to int, handling None from JSON null."""
|
|
107
|
+
if val is None:
|
|
108
|
+
return default
|
|
109
|
+
try:
|
|
110
|
+
return int(val)
|
|
111
|
+
except (TypeError, ValueError):
|
|
112
|
+
return default
|
|
76
113
|
|
|
77
|
-
# ── General helpers ──────────────────────────────────────────────────────────
|
|
78
114
|
|
|
79
|
-
def
|
|
80
|
-
"""
|
|
115
|
+
def safe_float(val, default=0.0):
|
|
116
|
+
"""Safely convert value to float, handling None from JSON null."""
|
|
117
|
+
if val is None:
|
|
118
|
+
return default
|
|
119
|
+
try:
|
|
120
|
+
return float(val)
|
|
121
|
+
except (TypeError, ValueError):
|
|
122
|
+
return default
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def usage_color(pct):
|
|
126
|
+
"""Color for a 0-100 usage gauge. Thresholds from the Claude Code docs."""
|
|
81
127
|
if pct < 70:
|
|
82
128
|
return GREEN
|
|
83
129
|
if pct < 90:
|
|
@@ -85,165 +131,115 @@ def ctx_color(pct):
|
|
|
85
131
|
return RED
|
|
86
132
|
|
|
87
133
|
|
|
134
|
+
_EIGHTHS = '▏▎▍▌▋▊▉'
|
|
135
|
+
|
|
136
|
+
|
|
88
137
|
def progress_bar(pct, width=8):
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
138
|
+
"""Gauge with sub-cell resolution.
|
|
139
|
+
|
|
140
|
+
Whole blocks alone are too coarse for the 5-wide rate-limit gauges: at
|
|
141
|
+
width 5 a real 13% would render completely empty. The eighth-block glyphs
|
|
142
|
+
keep small-but-nonzero values visible.
|
|
143
|
+
"""
|
|
144
|
+
pct = max(0.0, min(100.0, pct))
|
|
145
|
+
exact = pct / 100 * width
|
|
146
|
+
filled = int(exact)
|
|
147
|
+
bar = '█' * filled
|
|
148
|
+
if filled < width:
|
|
149
|
+
remainder = int((exact - filled) * 8)
|
|
150
|
+
if remainder:
|
|
151
|
+
bar += _EIGHTHS[remainder - 1]
|
|
152
|
+
return bar + '░' * (width - len(bar))
|
|
92
153
|
|
|
93
154
|
|
|
94
155
|
def format_tokens(n):
|
|
95
156
|
if n >= 1_000_000:
|
|
96
|
-
|
|
157
|
+
value = f"{n / 1_000_000:.1f}"
|
|
158
|
+
return f"{value[:-2] if value.endswith('.0') else value}M"
|
|
97
159
|
if n >= 1000:
|
|
98
160
|
return f"{n // 1000}k"
|
|
99
161
|
return str(n)
|
|
100
162
|
|
|
101
163
|
|
|
102
|
-
def
|
|
103
|
-
"""
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
164
|
+
def format_countdown(epoch):
|
|
165
|
+
"""Time until a Unix epoch: 47m, 1h48, 6d16. None when already past."""
|
|
166
|
+
remaining = epoch - time.time()
|
|
167
|
+
if remaining <= 0:
|
|
168
|
+
return None
|
|
169
|
+
days, rest = int(remaining // 86400), int(remaining % 86400)
|
|
170
|
+
hours, minutes = rest // 3600, (rest % 3600) // 60
|
|
171
|
+
if days:
|
|
172
|
+
return f"{days}d{hours:02d}"
|
|
173
|
+
if hours:
|
|
174
|
+
return f"{hours}h{minutes:02d}"
|
|
175
|
+
return f"{minutes}m"
|
|
107
176
|
|
|
108
177
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
except (TypeError, ValueError): return default
|
|
178
|
+
# Both CSI colour codes and OSC sequences. The OSC branch matters: an OSC 8
|
|
179
|
+
# hyperlink carries the whole URL as an invisible payload, and counting it
|
|
180
|
+
# would make a 9-character PR badge measure the length of its GitHub URL.
|
|
181
|
+
_ANSI_RE = re.compile(r'\033\][^\a\033]*(?:\a|\033\\)|\033\[[0-9;]*m')
|
|
114
182
|
|
|
183
|
+
# Glyphs terminals render double-width. Deliberately narrow: the block and
|
|
184
|
+
# arrow glyphs this script draws gauges with (█ ░ ▏▎▍▌▋▊▉ ↑ ↓ ↻ ► ✓ ✗) are all
|
|
185
|
+
# SINGLE width, and treating the whole U+2100+ range as wide overcounted the
|
|
186
|
+
# budget line by ~20 columns, dropping widgets that would have fit.
|
|
187
|
+
#
|
|
188
|
+
# Any new glyph below U+1F300 must be listed here or it is measured as 1.
|
|
189
|
+
# ⏳ (U+23F3) is the live case: it has Emoji_Presentation=Yes so it renders
|
|
190
|
+
# double, unlike its lookalike ⏱ (U+23F1), which renders as narrow text.
|
|
191
|
+
_WIDE_SINGLES = frozenset('⚡⛔⬛⬜✅❌⏳')
|
|
115
192
|
|
|
116
|
-
# ── MORPH feature helpers ────────────────────────────────────────────────────
|
|
117
193
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
if
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
passed = sum(1 for c in checkpoints if c.get('passed'))
|
|
124
|
-
rate = passed / total
|
|
125
|
-
for threshold, level, color, _badge in TRUST_LEVELS:
|
|
126
|
-
if rate >= threshold:
|
|
127
|
-
return level, color, rate
|
|
128
|
-
return 'low', RED, 0.0
|
|
194
|
+
def char_width(ch):
|
|
195
|
+
code = ord(ch)
|
|
196
|
+
if 0x1F300 <= code <= 0x1FAFF: # emoji planes
|
|
197
|
+
return 2
|
|
198
|
+
return 2 if ch in _WIDE_SINGLES else 1
|
|
129
199
|
|
|
130
200
|
|
|
131
|
-
def
|
|
132
|
-
"""
|
|
133
|
-
|
|
134
|
-
return None
|
|
135
|
-
remaining = CHECKPOINT_FREQUENCY - (tasks_done % CHECKPOINT_FREQUENCY)
|
|
136
|
-
return 0 if remaining == CHECKPOINT_FREQUENCY else remaining
|
|
201
|
+
def visible_width(text):
|
|
202
|
+
"""Rendered width: escape sequences stripped, emoji counted double."""
|
|
203
|
+
return sum(char_width(ch) for ch in _ANSI_RE.sub('', text))
|
|
137
204
|
|
|
138
205
|
|
|
139
|
-
def
|
|
140
|
-
"""
|
|
141
|
-
If a feature name appears in the transcript (tool calls, messages, results),
|
|
142
|
-
this session is actively working on it. Prevents showing features from other sessions.
|
|
143
|
-
"""
|
|
144
|
-
if not features_dict or not entries:
|
|
145
|
-
return set()
|
|
146
|
-
# Serialize last 500 entries for search (avoids cost on long sessions)
|
|
147
|
-
recent = entries[-500:] if len(entries) > 500 else entries
|
|
148
|
-
transcript_text = json.dumps(recent, ensure_ascii=False)
|
|
149
|
-
return {name for name in features_dict if name in transcript_text}
|
|
206
|
+
def join_to_width(parts, columns):
|
|
207
|
+
"""Join `parts` with SEP, dropping the lowest-priority ones until it fits.
|
|
150
208
|
|
|
209
|
+
`parts` is a list of (priority, text); lower priority number = keep longer.
|
|
210
|
+
`columns` comes from the COLUMNS env var Claude Code exports before running
|
|
211
|
+
the status line (tput does not work — stdout is captured, not a terminal).
|
|
212
|
+
"""
|
|
213
|
+
kept = [p for p in parts if p[1]]
|
|
214
|
+
if not kept:
|
|
215
|
+
return ''
|
|
216
|
+
sep_width = visible_width(SEP)
|
|
151
217
|
|
|
152
|
-
def
|
|
153
|
-
|
|
218
|
+
def total(items):
|
|
219
|
+
return (sum(visible_width(text) for _, text in items)
|
|
220
|
+
+ sep_width * max(0, len(items) - 1))
|
|
154
221
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
state = json.loads(state_path.read_text(encoding='utf-8'))
|
|
162
|
-
features = state.get('features', {})
|
|
163
|
-
|
|
164
|
-
# Filter to features belonging to this session (mentioned in transcript)
|
|
165
|
-
session_names = get_session_feature_names(features, entries)
|
|
166
|
-
|
|
167
|
-
if not session_names:
|
|
168
|
-
return []
|
|
169
|
-
|
|
170
|
-
result = []
|
|
171
|
-
for name, feat in features.items():
|
|
172
|
-
if feat.get('status') != 'in_progress':
|
|
173
|
-
continue
|
|
174
|
-
if name not in session_names:
|
|
175
|
-
continue
|
|
176
|
-
phase = feat.get('phase', '?')
|
|
177
|
-
tasks = feat.get('tasks') or {}
|
|
178
|
-
done = safe_int(tasks.get('completed'))
|
|
179
|
-
total = safe_int(tasks.get('total'))
|
|
180
|
-
gates = feat.get('approvalGates') or {}
|
|
181
|
-
checkpts = feat.get('checkpoints') or []
|
|
182
|
-
|
|
183
|
-
pending = [g for g, v in gates.items() if not v.get('approved')]
|
|
184
|
-
trust_lvl, trust_color, trust_rate = calculate_trust(checkpts)
|
|
185
|
-
countdown = get_checkpoint_countdown(done)
|
|
186
|
-
|
|
187
|
-
result.append({
|
|
188
|
-
'name': name,
|
|
189
|
-
'phase': phase,
|
|
190
|
-
'tasks_done': done,
|
|
191
|
-
'tasks_total': total,
|
|
192
|
-
'pending': pending[0] if pending else None,
|
|
193
|
-
'trust_level': trust_lvl,
|
|
194
|
-
'trust_color': trust_color,
|
|
195
|
-
'countdown': countdown,
|
|
196
|
-
})
|
|
197
|
-
return result
|
|
198
|
-
except Exception:
|
|
199
|
-
return []
|
|
222
|
+
if columns:
|
|
223
|
+
# Drop the worst priority first, but never drop the single best item.
|
|
224
|
+
while len(kept) > 1 and total(kept) > columns:
|
|
225
|
+
worst = max(range(len(kept)), key=lambda i: kept[i][0])
|
|
226
|
+
kept.pop(worst)
|
|
227
|
+
return SEP.join(text for _, text in kept)
|
|
200
228
|
|
|
201
229
|
|
|
202
|
-
|
|
230
|
+
def read_user_settings():
|
|
231
|
+
"""Read the user settings (for autoCompactWindow). Never raises.
|
|
203
232
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
Returns None if file is missing or unreadable.
|
|
233
|
+
Honours CLAUDE_CONFIG_DIR, the same override Claude Code itself uses to
|
|
234
|
+
relocate ~/.claude.
|
|
207
235
|
"""
|
|
208
|
-
activity_path = Path(cwd) / '.morph' / 'logs' / 'activity.json'
|
|
209
|
-
if not activity_path.exists():
|
|
210
|
-
return None
|
|
211
236
|
try:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
last_hook = None
|
|
217
|
-
last_hook_age = None
|
|
218
|
-
if hooks:
|
|
219
|
-
last = hooks[-1]
|
|
220
|
-
last_hook = last.get('name', '')
|
|
221
|
-
# Try to compute age from timestamp (HH:MM:SS)
|
|
222
|
-
try:
|
|
223
|
-
ts = last.get('ts', '')
|
|
224
|
-
if ts and len(ts) == 8:
|
|
225
|
-
now = datetime.now()
|
|
226
|
-
hook_t = datetime.strptime(ts, '%H:%M:%S').replace(
|
|
227
|
-
year=now.year, month=now.month, day=now.day
|
|
228
|
-
)
|
|
229
|
-
diff_s = (now - hook_t).total_seconds()
|
|
230
|
-
if diff_s < 0:
|
|
231
|
-
diff_s = 0
|
|
232
|
-
if diff_s < 60:
|
|
233
|
-
last_hook_age = f"{int(diff_s)}s"
|
|
234
|
-
else:
|
|
235
|
-
last_hook_age = f"{int(diff_s // 60)}m"
|
|
236
|
-
except Exception:
|
|
237
|
-
pass
|
|
238
|
-
|
|
239
|
-
return {
|
|
240
|
-
'hook_count': len(hooks),
|
|
241
|
-
'skill_count': len(skills),
|
|
242
|
-
'last_hook': last_hook,
|
|
243
|
-
'last_hook_age': last_hook_age,
|
|
244
|
-
}
|
|
237
|
+
base = os.environ.get('CLAUDE_CONFIG_DIR')
|
|
238
|
+
path = (Path(base) if base else Path.home() / '.claude') / 'settings.json'
|
|
239
|
+
data = json.loads(path.read_text(encoding='utf-8'))
|
|
240
|
+
return data if isinstance(data, dict) else {}
|
|
245
241
|
except Exception:
|
|
246
|
-
return
|
|
242
|
+
return {}
|
|
247
243
|
|
|
248
244
|
|
|
249
245
|
# ── Git helpers ───────────────────────────────────────────────────────────────
|
|
@@ -254,266 +250,413 @@ def _run_git(args, cwd):
|
|
|
254
250
|
['git'] + args,
|
|
255
251
|
cwd=cwd, stderr=subprocess.DEVNULL, timeout=3,
|
|
256
252
|
creationflags=_CREATE_NO_WINDOW,
|
|
257
|
-
).decode().strip()
|
|
253
|
+
).decode('utf-8', 'replace').strip()
|
|
258
254
|
|
|
259
255
|
|
|
260
256
|
def get_git_info(cwd):
|
|
261
|
-
"""
|
|
262
|
-
try:
|
|
263
|
-
branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)
|
|
257
|
+
"""Branch, ahead/behind, changed files and line diff.
|
|
264
258
|
|
|
265
|
-
|
|
266
|
-
|
|
259
|
+
A single `status --porcelain=v2 --branch -uno` covers branch, upstream
|
|
260
|
+
divergence and the file count. `-uno` matters: v2 lists untracked entries
|
|
261
|
+
by default, which would silently change what the file count means. The
|
|
262
|
+
shortstat call is skipped entirely on a clean tree.
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
out = _run_git(['status', '--porcelain=v2', '--branch', '-uno'], cwd)
|
|
266
|
+
except Exception:
|
|
267
|
+
return ''
|
|
268
|
+
|
|
269
|
+
branch, ahead, behind, files = '', 0, 0, 0
|
|
270
|
+
for line in out.splitlines():
|
|
271
|
+
if line.startswith('# branch.head '):
|
|
272
|
+
branch = line[len('# branch.head '):].strip()
|
|
273
|
+
elif line.startswith('# branch.ab '):
|
|
274
|
+
# Only present when an upstream is configured.
|
|
275
|
+
match = re.match(r'# branch\.ab \+(\d+) -(\d+)', line)
|
|
276
|
+
if match:
|
|
277
|
+
ahead, behind = int(match.group(1)), int(match.group(2))
|
|
278
|
+
elif line[:1] in ('1', '2', 'u'):
|
|
279
|
+
files += 1
|
|
280
|
+
|
|
281
|
+
if not branch:
|
|
282
|
+
return ''
|
|
283
|
+
|
|
284
|
+
ins, dels = 0, 0
|
|
285
|
+
if files:
|
|
267
286
|
try:
|
|
268
|
-
|
|
269
|
-
|
|
287
|
+
stat = _run_git(['diff', 'HEAD', '--shortstat'], cwd)
|
|
288
|
+
match = re.search(r'(\d+) insertion', stat)
|
|
289
|
+
if match:
|
|
290
|
+
ins = int(match.group(1))
|
|
291
|
+
match = re.search(r'(\d+) deletion', stat)
|
|
292
|
+
if match:
|
|
293
|
+
dels = int(match.group(1))
|
|
270
294
|
except Exception:
|
|
271
295
|
pass
|
|
272
296
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
297
|
+
parts = [f"{BLUE} {branch}{R}"]
|
|
298
|
+
if ahead or behind:
|
|
299
|
+
divergence = ''
|
|
300
|
+
if ahead:
|
|
301
|
+
divergence += f"{GREEN}↑{ahead}{R}"
|
|
302
|
+
if behind:
|
|
303
|
+
divergence += f"{RED}↓{behind}{R}"
|
|
304
|
+
parts.append(divergence)
|
|
305
|
+
if ins or dels:
|
|
306
|
+
parts.append(f"{GREEN}+{ins}{RED}-{dels}{R}")
|
|
307
|
+
return ' '.join(parts)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ── Transcript helpers ────────────────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
def get_worktree_label(payload, cwd):
|
|
313
|
+
"""Worktree name for this session, or None when in the primary worktree.
|
|
314
|
+
|
|
315
|
+
Cascade: the payload's own `worktree` (--worktree sessions), then
|
|
316
|
+
`workspace.git_worktree` (any linked worktree), then — only when both are
|
|
317
|
+
absent AND this looks like a morph project — the git subprocess earlier
|
|
318
|
+
versions always paid for. The fallback keeps behaviour identical on builds
|
|
319
|
+
or worktree layouts where those fields do not appear.
|
|
320
|
+
"""
|
|
321
|
+
worktree = payload.get('worktree') or {}
|
|
322
|
+
if worktree.get('name'):
|
|
323
|
+
branch = (worktree.get('branch') or '').replace('refs/heads/', '')
|
|
324
|
+
return branch or worktree['name']
|
|
285
325
|
|
|
286
|
-
|
|
287
|
-
|
|
326
|
+
git_worktree = (payload.get('workspace') or {}).get('git_worktree')
|
|
327
|
+
if git_worktree:
|
|
328
|
+
return git_worktree
|
|
329
|
+
|
|
330
|
+
if not (Path(cwd) / '.morph' / 'features').is_dir():
|
|
331
|
+
return None
|
|
332
|
+
try:
|
|
333
|
+
out = _run_git(['worktree', 'list', '--porcelain'], cwd)
|
|
334
|
+
except Exception:
|
|
335
|
+
return None
|
|
336
|
+
entries, current = [], {}
|
|
337
|
+
for line in out.splitlines():
|
|
338
|
+
if line.startswith('worktree '):
|
|
339
|
+
if current:
|
|
340
|
+
entries.append(current)
|
|
341
|
+
current = {'path': line.split(' ', 1)[1]}
|
|
342
|
+
elif line.startswith('branch '):
|
|
343
|
+
current['branch'] = line.split(' ', 1)[1]
|
|
344
|
+
if current:
|
|
345
|
+
entries.append(current)
|
|
346
|
+
if len(entries) > 1:
|
|
288
347
|
try:
|
|
289
|
-
|
|
290
|
-
ahead, behind = (int(x) for x in ab.split('\t'))
|
|
348
|
+
cwd_resolved = str(Path(cwd).resolve())
|
|
291
349
|
except Exception:
|
|
292
|
-
|
|
350
|
+
return None
|
|
351
|
+
for entry in entries[1:]: # entry 0 is always the primary worktree
|
|
352
|
+
try:
|
|
353
|
+
same = str(Path(entry.get('path', '')).resolve()) == cwd_resolved
|
|
354
|
+
except Exception:
|
|
355
|
+
same = False
|
|
356
|
+
if same:
|
|
357
|
+
return entry.get('branch', '').replace('refs/heads/', '') or None
|
|
358
|
+
return None
|
|
293
359
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
360
|
+
|
|
361
|
+
def _gate_implied_phase(gates):
|
|
362
|
+
"""Phase implied purely by approved gates (mirrors state-manager.js)."""
|
|
363
|
+
def approved(gate):
|
|
364
|
+
return (gates.get(gate) or {}).get('approved') is True
|
|
365
|
+
if approved('review'):
|
|
366
|
+
return 'review'
|
|
367
|
+
if approved('plan'):
|
|
368
|
+
return 'implement'
|
|
369
|
+
if approved('uiux'):
|
|
370
|
+
return 'plan'
|
|
371
|
+
return 'proposal'
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def derive_phase(feature_dir, gates):
|
|
375
|
+
"""Phase from the filesystem, bumped by approved gates."""
|
|
376
|
+
artifact_phase = 'proposal'
|
|
377
|
+
for folder, phase, canonical in PHASE_MAP:
|
|
378
|
+
if (feature_dir / folder / canonical).exists():
|
|
379
|
+
artifact_phase = phase
|
|
380
|
+
break
|
|
381
|
+
implied = _gate_implied_phase(gates)
|
|
382
|
+
if PHASE_INDEX[implied] > PHASE_INDEX[artifact_phase]:
|
|
383
|
+
return implied
|
|
384
|
+
return artifact_phase
|
|
308
385
|
|
|
309
386
|
|
|
310
|
-
def
|
|
311
|
-
"""
|
|
387
|
+
def count_tasks(feature_dir, feature_json):
|
|
388
|
+
"""(done, total) from 2-plan/tasks.json — the declared source of truth.
|
|
312
389
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
Called once in main() to avoid duplicate git subprocess.
|
|
390
|
+
`skipped` counts as resolved: it will never be worked on, and leaving it
|
|
391
|
+
out would pin the bar below 100%. Falls back to feature.json's aggregate
|
|
392
|
+
counters, which the schema marks as informational.
|
|
317
393
|
"""
|
|
318
394
|
try:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if
|
|
323
|
-
|
|
324
|
-
entries.append(current)
|
|
325
|
-
current = {'path': line.split(' ', 1)[1]}
|
|
326
|
-
elif line.startswith('branch '):
|
|
327
|
-
current['branch'] = line.split(' ', 1)[1]
|
|
328
|
-
if current:
|
|
329
|
-
entries.append(current)
|
|
330
|
-
if len(entries) > 1:
|
|
331
|
-
cwd_r = str(Path(cwd).resolve())
|
|
332
|
-
for entry in entries[1:]:
|
|
333
|
-
if str(Path(entry.get('path', '')).resolve()) == cwd_r:
|
|
334
|
-
branch = entry.get('branch', '').replace('refs/heads/', '')
|
|
335
|
-
return True, f"{MAGENTA}worktree:{branch}{R}"
|
|
395
|
+
raw = (feature_dir / '2-plan' / 'tasks.json').read_text(encoding='utf-8')
|
|
396
|
+
tasks = json.loads(raw).get('tasks')
|
|
397
|
+
if isinstance(tasks, list) and tasks:
|
|
398
|
+
done = sum(1 for task in tasks if task.get('status') in ('done', 'skipped'))
|
|
399
|
+
return done, len(tasks)
|
|
336
400
|
except Exception:
|
|
337
401
|
pass
|
|
338
|
-
|
|
402
|
+
aggregate = feature_json.get('tasks') or {}
|
|
403
|
+
return safe_int(aggregate.get('completed')), safe_int(aggregate.get('total'))
|
|
339
404
|
|
|
340
405
|
|
|
341
|
-
def
|
|
342
|
-
"""
|
|
406
|
+
def get_active_features(cwd):
|
|
407
|
+
"""in_progress features from the authoritative per-feature state.
|
|
343
408
|
|
|
344
|
-
|
|
345
|
-
|
|
409
|
+
Reads .morph/features/*/feature.json. The root .morph/state.json is a thin,
|
|
410
|
+
gitignored, derivable index and must not be trusted here.
|
|
346
411
|
"""
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
for entry in reversed(recent):
|
|
351
|
-
if entry.get('isSidechain') or entry.get('isApiErrorMessage'):
|
|
352
|
-
continue
|
|
353
|
-
msg = entry.get('message') or {}
|
|
354
|
-
content = msg.get('content') or []
|
|
355
|
-
if not isinstance(content, list):
|
|
356
|
-
continue
|
|
357
|
-
# Within each entry, scan content from last to first to get most-recent call
|
|
358
|
-
for item in reversed(content):
|
|
359
|
-
if not isinstance(item, dict) or item.get('type') != 'tool_use':
|
|
360
|
-
continue
|
|
361
|
-
tool = item.get('name', '')
|
|
362
|
-
inp = item.get('input') or {}
|
|
363
|
-
if tool == 'Skill' and last_skill is None:
|
|
364
|
-
last_skill = inp.get('skill') or ''
|
|
365
|
-
elif tool == 'Agent' and last_agent is None:
|
|
366
|
-
agent_name = inp.get('subagent_type') or ''
|
|
367
|
-
if not agent_name:
|
|
368
|
-
desc = inp.get('description') or inp.get('prompt') or ''
|
|
369
|
-
agent_name = (desc[:22] + '…') if len(desc) > 25 else desc
|
|
370
|
-
last_agent = agent_name
|
|
371
|
-
if last_skill is not None and last_agent is not None:
|
|
372
|
-
break
|
|
373
|
-
return last_skill or None, last_agent or None
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
# ── Transcript / JSONL helpers ────────────────────────────────────────────────
|
|
377
|
-
|
|
378
|
-
def read_transcript_jsonl(path):
|
|
379
|
-
"""Read and parse JSONL transcript file. Returns list of parsed entries."""
|
|
380
|
-
entries = []
|
|
412
|
+
base = Path(cwd) / '.morph' / 'features'
|
|
413
|
+
if not base.is_dir():
|
|
414
|
+
return []
|
|
381
415
|
try:
|
|
382
|
-
|
|
383
|
-
for line in f:
|
|
384
|
-
line = line.strip()
|
|
385
|
-
if not line:
|
|
386
|
-
continue
|
|
387
|
-
try:
|
|
388
|
-
entries.append(json.loads(line))
|
|
389
|
-
except Exception:
|
|
390
|
-
pass
|
|
416
|
+
feature_files = sorted(base.glob('*/feature.json'))
|
|
391
417
|
except Exception:
|
|
392
|
-
|
|
393
|
-
return entries
|
|
394
|
-
|
|
418
|
+
return []
|
|
395
419
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
420
|
+
result = []
|
|
421
|
+
for path in feature_files:
|
|
422
|
+
try:
|
|
423
|
+
data = json.loads(path.read_text(encoding='utf-8'))
|
|
424
|
+
except Exception:
|
|
401
425
|
continue
|
|
402
|
-
|
|
403
|
-
if not usage:
|
|
426
|
+
if not isinstance(data, dict) or data.get('status') != 'in_progress':
|
|
404
427
|
continue
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
428
|
+
feature_dir = path.parent
|
|
429
|
+
gates = data.get('approvalGates') or {}
|
|
430
|
+
phase = derive_phase(feature_dir, gates)
|
|
431
|
+
done, total = count_tasks(feature_dir, data)
|
|
432
|
+
pending = None
|
|
433
|
+
if phase in GATE_PHASES and (gates.get(phase) or {}).get('approved') is not True:
|
|
434
|
+
pending = phase
|
|
435
|
+
result.append({
|
|
436
|
+
'name': feature_dir.name,
|
|
437
|
+
'phase': phase,
|
|
438
|
+
'tasks_done': done,
|
|
439
|
+
'tasks_total': total,
|
|
440
|
+
'pending': pending,
|
|
441
|
+
})
|
|
442
|
+
return result
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def get_last_hook(cwd):
|
|
446
|
+
"""Name of the most recent hook from .morph/logs/activity.json.
|
|
447
|
+
|
|
448
|
+
The age is deliberately not shown: activity-logger.js only records
|
|
449
|
+
HH:MM:SS, so any age computed from it is wrong across midnight.
|
|
450
|
+
"""
|
|
416
451
|
try:
|
|
417
|
-
|
|
452
|
+
raw = (Path(cwd) / '.morph' / 'logs' / 'activity.json').read_text(encoding='utf-8')
|
|
453
|
+
hooks = json.loads(raw).get('hooks') or []
|
|
454
|
+
return hooks[-1].get('name') or None if hooks else None
|
|
418
455
|
except Exception:
|
|
419
456
|
return None
|
|
420
457
|
|
|
421
458
|
|
|
422
|
-
|
|
423
|
-
"""Return Unix timestamp for the start of the current session.
|
|
459
|
+
# ── Line renderers ────────────────────────────────────────────────────────────
|
|
424
460
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
session even after long absences (wall-clock elapsed)
|
|
428
|
-
- sess-cwd-* : keyed by cwd, 2-min window — detects plan→accept
|
|
429
|
-
(new transcript created seconds after the previous one)
|
|
461
|
+
def render_morph_line(feature, hook_name, columns):
|
|
462
|
+
"""Line 0 — the active MORPH feature.
|
|
430
463
|
|
|
431
|
-
|
|
464
|
+
Goes through join_to_width like the status line: a feature name plus phase,
|
|
465
|
+
task bar, gate and hook is the longest thing this script prints, and a
|
|
466
|
+
wrapping line 0 would defeat the whole point of the single-row layout.
|
|
432
467
|
"""
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
468
|
+
parts = [(0, f"{CYAN}{BOLD}► {feature['name']}{R}")]
|
|
469
|
+
|
|
470
|
+
phase = feature['phase']
|
|
471
|
+
label = PHASE_LABELS.get(phase, phase)
|
|
472
|
+
position = PHASE_INDEX.get(phase)
|
|
473
|
+
if position is None:
|
|
474
|
+
parts.append((1, f"{CYAN}{label}{R}"))
|
|
436
475
|
else:
|
|
437
|
-
|
|
476
|
+
parts.append((1, f"{CYAN}{label} {position + 1}/{PIPELINE_TOTAL}{R}"))
|
|
438
477
|
|
|
439
|
-
|
|
478
|
+
if feature['tasks_total'] > 0:
|
|
479
|
+
pct = feature['tasks_done'] / feature['tasks_total'] * 100
|
|
480
|
+
parts.append((
|
|
481
|
+
3,
|
|
482
|
+
f"{GREEN}{progress_bar(pct, 6)} "
|
|
483
|
+
f"{feature['tasks_done']}/{feature['tasks_total']} tasks{R}"
|
|
484
|
+
))
|
|
440
485
|
|
|
441
|
-
if
|
|
442
|
-
|
|
443
|
-
tp_cache = cache_dir / f'sess-tp-{tp_h}.json'
|
|
444
|
-
else:
|
|
445
|
-
tp_cache = None
|
|
486
|
+
if feature['pending']:
|
|
487
|
+
parts.append((2, f"{RED}⛔ gate: {feature['pending']}{R}"))
|
|
446
488
|
|
|
447
|
-
|
|
448
|
-
|
|
489
|
+
if hook_name:
|
|
490
|
+
parts.append((4, f"{GRAY}🪝 {hook_name}{R}"))
|
|
449
491
|
|
|
450
|
-
|
|
451
|
-
session_start = None
|
|
452
|
-
try:
|
|
453
|
-
if tp_cache and tp_cache.exists():
|
|
454
|
-
session_start = json.loads(tp_cache.read_text()).get('session_start')
|
|
455
|
-
except Exception:
|
|
456
|
-
pass
|
|
492
|
+
return join_to_width(parts, columns)
|
|
457
493
|
|
|
458
|
-
# 2. New transcript — continuation from plan→accept? (cwd gap < 2 min)
|
|
459
|
-
if session_start is None:
|
|
460
|
-
try:
|
|
461
|
-
if cwd_cache.exists():
|
|
462
|
-
cwd_data = json.loads(cwd_cache.read_text())
|
|
463
|
-
if (now - cwd_data.get('last_seen', 0)) < 120:
|
|
464
|
-
session_start = cwd_data.get('session_start')
|
|
465
|
-
except Exception:
|
|
466
|
-
pass
|
|
467
494
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
for entry in entries:
|
|
471
|
-
ts = entry.get('timestamp')
|
|
472
|
-
if not ts:
|
|
473
|
-
continue
|
|
474
|
-
t = parse_timestamp(ts)
|
|
475
|
-
if t is not None:
|
|
476
|
-
session_start = t
|
|
477
|
-
break
|
|
478
|
-
|
|
479
|
-
if session_start is None:
|
|
480
|
-
return None
|
|
495
|
+
def effective_window(payload, settings):
|
|
496
|
+
"""The window /context measures against, or None when not reproducible.
|
|
481
497
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
payload
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
cwd_cache.write_text(payload)
|
|
489
|
-
except Exception:
|
|
490
|
-
pass
|
|
498
|
+
NOT the model window. Measured against Claude Code 2.1.220 with
|
|
499
|
+
`autoCompactWindow: 680000`:
|
|
500
|
+
|
|
501
|
+
payload total_input_tokens 289681, context_window_size 1000000,
|
|
502
|
+
used_percentage 29 (= 289681 / 1000000)
|
|
503
|
+
/context "289.7k/680k tokens (43%)" (= 289681 / 680000)
|
|
491
504
|
|
|
492
|
-
|
|
505
|
+
So the payload's own `used_percentage` divides by the MODEL window while
|
|
506
|
+
the app divides by the effective one. Taking `used_percentage` verbatim —
|
|
507
|
+
which is what this script did — is a 1.47x understatement at these
|
|
508
|
+
settings, and it is the divergence the whole rewrite set out to kill.
|
|
509
|
+
"""
|
|
510
|
+
model_window = safe_int((payload.get('context_window') or {}).get('context_window_size'))
|
|
511
|
+
if model_window <= 0:
|
|
512
|
+
return None
|
|
513
|
+
if settings.get('autoCompactEnabled') is False:
|
|
514
|
+
return model_window
|
|
515
|
+
configured = safe_int(settings.get('autoCompactWindow'))
|
|
516
|
+
# Every other source of the window (clientdata, experiment, model-default,
|
|
517
|
+
# auto) is server-driven and cannot be mirrored here.
|
|
518
|
+
return min(model_window, configured) if configured > 0 else None
|
|
493
519
|
|
|
494
520
|
|
|
495
|
-
def
|
|
496
|
-
"""
|
|
497
|
-
if
|
|
521
|
+
def compact_threshold(payload, settings):
|
|
522
|
+
"""Tokens at which auto-compact fires, or None when not reproducible."""
|
|
523
|
+
if settings.get('autoCompactEnabled') is False:
|
|
498
524
|
return None
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
if
|
|
503
|
-
return
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
525
|
+
if safe_int(settings.get('autoCompactWindow')) <= 0:
|
|
526
|
+
return None
|
|
527
|
+
window = effective_window(payload, settings)
|
|
528
|
+
if not window:
|
|
529
|
+
return None
|
|
530
|
+
# /context calls this the "Autocompact buffer" and reports it as 33k —
|
|
531
|
+
# exactly COMPACT_OUTPUT_RESERVE + COMPACT_HEADROOM.
|
|
532
|
+
threshold = window - COMPACT_OUTPUT_RESERVE - COMPACT_HEADROOM
|
|
533
|
+
return threshold if threshold > 0 else None
|
|
508
534
|
|
|
509
535
|
|
|
510
|
-
def
|
|
511
|
-
"""
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
536
|
+
def render_status_line(payload, cwd, settings, columns):
|
|
537
|
+
"""The single status line — where you are and what you have left.
|
|
538
|
+
|
|
539
|
+
One bar only, on the context: it is the budget that moves fastest and the
|
|
540
|
+
one you act on. The account windows are numbers, coloured by the same
|
|
541
|
+
thresholds. Priority governs what a narrow terminal drops — the context
|
|
542
|
+
never goes, the weekly window goes first.
|
|
516
543
|
|
|
544
|
+
Deliberately NOT rendered, by the user's call: the session name, the
|
|
545
|
+
session clock, the last Skill/Agent, and the output style. Dropping the
|
|
546
|
+
last two is also what lets this script stop reading the transcript at all.
|
|
547
|
+
"""
|
|
548
|
+
parts = [] # (priority, text) — the highest priority number is dropped first
|
|
549
|
+
|
|
550
|
+
model = payload.get('model') or {}
|
|
551
|
+
model_name = (model.get('display_name') or model.get('id') or ''
|
|
552
|
+
if isinstance(model, dict) else str(model or ''))
|
|
553
|
+
if model_name:
|
|
554
|
+
short = (model_name
|
|
555
|
+
.replace('Claude ', '')
|
|
556
|
+
.replace('claude-', '')
|
|
557
|
+
.replace(' (1M context)', ' 1M')
|
|
558
|
+
.replace(' (claude.ai)', ''))
|
|
559
|
+
# Only surface state that is NOT the default: thinking is normally on,
|
|
560
|
+
# fast mode normally off. `permission_mode` is deliberately absent —
|
|
561
|
+
# Claude Code never sends it to the status line, only to hooks.
|
|
562
|
+
state = []
|
|
563
|
+
effort = (payload.get('effort') or {}).get('level')
|
|
564
|
+
if effort:
|
|
565
|
+
state.append(effort)
|
|
566
|
+
if (payload.get('thinking') or {}).get('enabled') is False:
|
|
567
|
+
state.append('no-think')
|
|
568
|
+
if payload.get('fast_mode'):
|
|
569
|
+
state.append('fast')
|
|
570
|
+
suffix = f"{GRAY}·{chr(183).join(state)}{R}" if state else ''
|
|
571
|
+
parts.append((3, f"{WHITE}{short}{R}{suffix}"))
|
|
572
|
+
|
|
573
|
+
context = payload.get('context_window') or {}
|
|
574
|
+
if context:
|
|
575
|
+
model_window = safe_int(context.get('context_window_size'))
|
|
576
|
+
tokens = safe_int(context.get('total_input_tokens'))
|
|
577
|
+
if not tokens:
|
|
578
|
+
# Null right after /compact and before the first API response.
|
|
579
|
+
# Recompute with the app's own input-only formula — output_tokens
|
|
580
|
+
# is NOT part of it, and adding it is how earlier versions drifted.
|
|
581
|
+
usage = context.get('current_usage')
|
|
582
|
+
if isinstance(usage, dict):
|
|
583
|
+
tokens = (safe_int(usage.get('input_tokens'))
|
|
584
|
+
+ safe_int(usage.get('cache_creation_input_tokens'))
|
|
585
|
+
+ safe_int(usage.get('cache_read_input_tokens')))
|
|
586
|
+
|
|
587
|
+
# Divide by the same window /context does. Only when that window is
|
|
588
|
+
# not reproducible do we fall back to the payload's own percentage,
|
|
589
|
+
# which is measured against the model window instead.
|
|
590
|
+
window = effective_window(payload, settings)
|
|
591
|
+
pct = None
|
|
592
|
+
if window and tokens:
|
|
593
|
+
pct = tokens / window * 100
|
|
594
|
+
elif context.get('used_percentage') is not None:
|
|
595
|
+
pct = safe_float(context.get('used_percentage'))
|
|
596
|
+
elif model_window and tokens:
|
|
597
|
+
pct = tokens / model_window * 100
|
|
598
|
+
|
|
599
|
+
if pct is not None:
|
|
600
|
+
chunk = f"{usage_color(pct)}{progress_bar(pct, 8)} {pct:.0f}%{R}"
|
|
601
|
+
# How much still fits before auto-compact fires — /context reports
|
|
602
|
+
# the same number as "Free space". Same ↻ as the account windows:
|
|
603
|
+
# "how much is left until the next reset", each in its own unit —
|
|
604
|
+
# tokens here, time there.
|
|
605
|
+
threshold = compact_threshold(payload, settings)
|
|
606
|
+
if threshold and tokens:
|
|
607
|
+
left = threshold - tokens
|
|
608
|
+
if left <= 0:
|
|
609
|
+
chunk += f" {RED}⚠ compactando{R}"
|
|
610
|
+
else:
|
|
611
|
+
left_pct = left / threshold * 100
|
|
612
|
+
color = GRAY if left_pct > 25 else (YELLOW if left_pct > 10 else RED)
|
|
613
|
+
chunk += f" {color}↻ {format_tokens(left)}{R}"
|
|
614
|
+
parts.append((0, chunk))
|
|
615
|
+
|
|
616
|
+
# Claude.ai subscription windows. Present only for Pro/Max subscribers
|
|
617
|
+
# after the first API response; each window may be absent on its own.
|
|
618
|
+
#
|
|
619
|
+
# ⏳ is /usage's "Current session" and 📅 its "Current week (all models)".
|
|
620
|
+
# The icons cost the same two columns the "5h"/"7d" labels did, and they
|
|
621
|
+
# sidestep a real ambiguity: the app calls the short one a "session", but
|
|
622
|
+
# it is a 5-hour billing window that resets on its own clock, unrelated to
|
|
623
|
+
# the Claude Code session.
|
|
624
|
+
rate_limits = payload.get('rate_limits') or {}
|
|
625
|
+
for key, label, priority, countdown in (('five_hour', '⏳', 2, True),
|
|
626
|
+
('seven_day', '📅', 5, False)):
|
|
627
|
+
window = rate_limits.get(key) or {}
|
|
628
|
+
raw_pct = window.get('used_percentage')
|
|
629
|
+
if raw_pct is None:
|
|
630
|
+
continue
|
|
631
|
+
pct = safe_float(raw_pct)
|
|
632
|
+
chunk = f"{GRAY}{label}{R} {usage_color(pct)}{pct:.0f}%{R}"
|
|
633
|
+
resets_at = window.get('resets_at') if countdown else None
|
|
634
|
+
if resets_at:
|
|
635
|
+
until = format_countdown(safe_float(resets_at))
|
|
636
|
+
if until:
|
|
637
|
+
chunk += f" {GRAY}↻ {until}{R}"
|
|
638
|
+
parts.append((priority, chunk))
|
|
639
|
+
|
|
640
|
+
git_info = get_git_info(cwd)
|
|
641
|
+
if git_info:
|
|
642
|
+
parts.append((1, git_info))
|
|
643
|
+
|
|
644
|
+
pull_request = payload.get('pr') or {}
|
|
645
|
+
if pull_request.get('number'):
|
|
646
|
+
review_state = pull_request.get('review_state')
|
|
647
|
+
mark = {'approved': '✓', 'changes_requested': '✗',
|
|
648
|
+
'draft': '·', 'pending': '…'}.get(review_state, '')
|
|
649
|
+
color = {'approved': GREEN, 'changes_requested': RED,
|
|
650
|
+
'draft': GRAY}.get(review_state, YELLOW)
|
|
651
|
+
label = f"PR #{pull_request['number']} {mark}".strip()
|
|
652
|
+
url = pull_request.get('url')
|
|
653
|
+
if url:
|
|
654
|
+
# OSC 8 hyperlink — honoured by Windows Terminal, iTerm2, Kitty.
|
|
655
|
+
# visible_width() strips the OSC payload, so the URL costs no columns.
|
|
656
|
+
label = f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
|
|
657
|
+
parts.append((4, f"{color}{label}{R}"))
|
|
658
|
+
|
|
659
|
+
return join_to_width(parts, columns)
|
|
517
660
|
|
|
518
661
|
|
|
519
662
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
@@ -523,152 +666,33 @@ def main():
|
|
|
523
666
|
raw = sys.stdin.read()
|
|
524
667
|
if not raw.strip():
|
|
525
668
|
sys.exit(0)
|
|
526
|
-
|
|
669
|
+
payload = json.loads(raw)
|
|
670
|
+
if not isinstance(payload, dict):
|
|
671
|
+
sys.exit(0)
|
|
527
672
|
except Exception:
|
|
528
673
|
sys.exit(0)
|
|
529
674
|
|
|
530
675
|
try:
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
#
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
#
|
|
540
|
-
#
|
|
541
|
-
#
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
phase = feat['phase']
|
|
552
|
-
pos = PHASE_POSITIONS.get(phase)
|
|
553
|
-
label = PHASE_LABELS.get(phase, phase)
|
|
554
|
-
if pos:
|
|
555
|
-
parts.append(f"{CYAN}{label} {pos}/{PIPELINE_TOTAL}{R}")
|
|
556
|
-
elif phase != '?':
|
|
557
|
-
parts.append(f"{CYAN}{label}{R}")
|
|
558
|
-
|
|
559
|
-
# Task progress (only if tasks exist)
|
|
560
|
-
if feat['tasks_total'] > 0:
|
|
561
|
-
pct = feat['tasks_done'] / feat['tasks_total'] * 100
|
|
562
|
-
bar = progress_bar(pct, 6)
|
|
563
|
-
parts.append(f"{GREEN}{bar} {feat['tasks_done']}/{feat['tasks_total']} tasks{R}")
|
|
564
|
-
|
|
565
|
-
# Checkpoint: only show when imminent (0 = now, 1 = next task)
|
|
566
|
-
if feat['countdown'] == 0:
|
|
567
|
-
parts.append(f"{GREEN}💾 checkpoint!{R}")
|
|
568
|
-
elif feat['countdown'] == 1:
|
|
569
|
-
parts.append(f"{YELLOW}💾 in 1 task{R}")
|
|
570
|
-
|
|
571
|
-
# Trust: silent when good, alert only if problematic
|
|
572
|
-
if feat['trust_level'] == 'low':
|
|
573
|
-
parts.append(f"{RED}⚠ low trust{R}")
|
|
574
|
-
elif feat['trust_level'] == 'medium':
|
|
575
|
-
parts.append(f"{YELLOW}⚠ medium trust{R}")
|
|
576
|
-
|
|
577
|
-
# Blocking approval gate
|
|
578
|
-
if feat['pending']:
|
|
579
|
-
parts.append(f"{RED}⛔ approval required{R}")
|
|
580
|
-
|
|
581
|
-
print(' | '.join(parts))
|
|
582
|
-
|
|
583
|
-
# ── Activity info line (hooks; only shown when a feature is active) ───────
|
|
584
|
-
if features:
|
|
585
|
-
activity = get_activity_info(cwd)
|
|
586
|
-
if activity and activity['hook_count'] > 0:
|
|
587
|
-
hook_label = activity['last_hook'] or '?'
|
|
588
|
-
age_str = f"({activity['last_hook_age']})" if activity['last_hook_age'] else ''
|
|
589
|
-
hook_str = f"{BLUE}🪝 {hook_label} {age_str}{R}".strip()
|
|
590
|
-
print(f" {GRAY}└{R} {hook_str}")
|
|
591
|
-
|
|
592
|
-
# ── Session info line (always shown) ─────────────────────────────────────
|
|
593
|
-
parts2 = []
|
|
594
|
-
|
|
595
|
-
# Session name (set via /rename)
|
|
596
|
-
if entries:
|
|
597
|
-
session_name = get_session_name(entries)
|
|
598
|
-
if session_name:
|
|
599
|
-
parts2.append(f"{CYAN}{BOLD}📌 {session_name}{R}")
|
|
600
|
-
|
|
601
|
-
# Model
|
|
602
|
-
model = data.get('model') or ''
|
|
603
|
-
if isinstance(model, dict):
|
|
604
|
-
model_name = model.get('display_name') or model.get('id') or ''
|
|
605
|
-
else:
|
|
606
|
-
model_name = str(model) if model else ''
|
|
607
|
-
if model_name:
|
|
608
|
-
short = model_name.replace('Claude ', '').replace('claude-', '').replace(' (claude.ai)', '')
|
|
609
|
-
parts2.append(f"{WHITE}{BOLD}🤖 {short}{R}")
|
|
610
|
-
|
|
611
|
-
# Permission mode (only shown when non-default)
|
|
612
|
-
perm = data.get('permission_mode') or ''
|
|
613
|
-
if perm and perm != 'default':
|
|
614
|
-
_perm_labels = {
|
|
615
|
-
'plan': '📋 plan',
|
|
616
|
-
'acceptEdits': '✏ accept edits',
|
|
617
|
-
'bypassPermissions': '⚠ bypass',
|
|
618
|
-
'dontAsk': '🔕 auto',
|
|
619
|
-
}
|
|
620
|
-
parts2.append(f"{YELLOW}{_perm_labels.get(perm, perm)}{R}")
|
|
621
|
-
|
|
622
|
-
# Last skill and agent invoked (parsed from transcript tool_use blocks)
|
|
623
|
-
if entries:
|
|
624
|
-
last_skill, last_agent = get_recent_tool_calls(entries)
|
|
625
|
-
if last_skill:
|
|
626
|
-
parts2.append(f"{YELLOW}🎯 {last_skill}{R}")
|
|
627
|
-
if last_agent:
|
|
628
|
-
parts2.append(f"{MAGENTA}⚡ {last_agent}{R}")
|
|
629
|
-
|
|
630
|
-
# Session clock (elapsed time since session start, survives transcript transitions)
|
|
631
|
-
session_start = get_session_start(cwd, transcript_path, entries)
|
|
632
|
-
duration = get_session_duration(session_start)
|
|
633
|
-
if duration:
|
|
634
|
-
parts2.append(f"{YELLOW}⏱ {duration}{R}")
|
|
635
|
-
|
|
636
|
-
# Context window
|
|
637
|
-
# used_percentage excludes output_tokens — underestimates real usage in long sessions.
|
|
638
|
-
# Compute (input + cache_creation + cache_read + output) / context_window_size,
|
|
639
|
-
# which reflects true context consumption for the next turn.
|
|
640
|
-
ctx = data.get('context_window', {})
|
|
641
|
-
if ctx:
|
|
642
|
-
cur_usage = ctx.get('current_usage') or {}
|
|
643
|
-
total_ctx = safe_int(ctx.get('context_window_size'))
|
|
644
|
-
if isinstance(cur_usage, dict) and total_ctx > 0:
|
|
645
|
-
tokens = (safe_int(cur_usage.get('input_tokens'))
|
|
646
|
-
+ safe_int(cur_usage.get('cache_creation_input_tokens'))
|
|
647
|
-
+ safe_int(cur_usage.get('cache_read_input_tokens'))
|
|
648
|
-
+ safe_int(cur_usage.get('output_tokens')))
|
|
649
|
-
used_pct = tokens / total_ctx * 100 if tokens > 0 else safe_float(ctx.get('used_percentage'))
|
|
650
|
-
else:
|
|
651
|
-
used_pct = safe_float(ctx.get('used_percentage'))
|
|
652
|
-
color = ctx_color(used_pct)
|
|
653
|
-
bar = progress_bar(used_pct, 8)
|
|
654
|
-
toks = f"{format_tokens(tokens)}/{format_tokens(total_ctx)}" if total_ctx and isinstance(cur_usage, dict) else ""
|
|
655
|
-
suffix = f" {RED}~cmpct{R}" if used_pct >= 90 else ""
|
|
656
|
-
line = f"{color}{bar} {used_pct:.0f}%{R}"
|
|
657
|
-
if toks:
|
|
658
|
-
line += f" ({toks})"
|
|
659
|
-
parts2.append(line + suffix)
|
|
660
|
-
|
|
661
|
-
# Git info (branch + diff stats)
|
|
662
|
-
git = get_git_info(cwd)
|
|
663
|
-
if git:
|
|
664
|
-
parts2.append(git)
|
|
665
|
-
|
|
666
|
-
# Worktree label (already computed above — reuse result)
|
|
667
|
-
if wt_display:
|
|
668
|
-
parts2.append(wt_display)
|
|
669
|
-
|
|
670
|
-
if parts2:
|
|
671
|
-
print(' | '.join(parts2))
|
|
676
|
+
workspace = payload.get('workspace') or {}
|
|
677
|
+
cwd = workspace.get('current_dir') or payload.get('cwd') or os.getcwd()
|
|
678
|
+
settings = read_user_settings()
|
|
679
|
+
|
|
680
|
+
# Claude Code exports COLUMNS before running the command; `tput cols`
|
|
681
|
+
# does not work here because stdout is captured, not a terminal.
|
|
682
|
+
columns = safe_int(os.environ.get('COLUMNS'), 0)
|
|
683
|
+
|
|
684
|
+
# The MORPH line is the ONLY second line, and only inside a secondary
|
|
685
|
+
# worktree. In the primary tree it resurrects stale feature names
|
|
686
|
+
# during unrelated work, so there the status line is a single row.
|
|
687
|
+
if get_worktree_label(payload, cwd):
|
|
688
|
+
hook_name = get_last_hook(cwd)
|
|
689
|
+
for feature in get_active_features(cwd):
|
|
690
|
+
print(render_morph_line(feature, hook_name, columns))
|
|
691
|
+
hook_name = None # annotate the first line only
|
|
692
|
+
|
|
693
|
+
status_line = render_status_line(payload, cwd, settings, columns)
|
|
694
|
+
if status_line:
|
|
695
|
+
print(status_line)
|
|
672
696
|
|
|
673
697
|
except Exception:
|
|
674
698
|
err = traceback.format_exc().splitlines()[-1]
|