@polymorphism-tech/morph-spec 8.6.0 → 8.8.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.
Files changed (47) hide show
  1. package/README.md +4 -4
  2. package/bin/morph-spec.js +23 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +2 -2
  5. package/docs/QUICKSTART.md +2 -2
  6. package/framework/CLAUDE.md +6 -3
  7. package/framework/MORPH.md +28 -3
  8. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  9. package/framework/hooks/claude-code/core/telemetry-log.js +15 -0
  10. package/framework/hooks/claude-code/core/worktree-context.js +41 -5
  11. package/framework/hooks/claude-code/statusline.py +545 -521
  12. package/framework/hooks/shared/claim-registry.js +175 -0
  13. package/framework/hooks/shared/state-reader.js +68 -25
  14. package/framework/rules/morph-task-tracking.md +14 -8
  15. package/framework/rules/morph-workflow.md +34 -1
  16. package/framework/schemas/feature.schema.json +2 -1
  17. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  18. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  19. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  20. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  21. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  22. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  23. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  24. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  25. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  26. package/framework/skills/core/morph-review/SKILL.md +7 -1
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/package.json +1 -1
  30. package/src/commands/project/claim.js +91 -0
  31. package/src/commands/project/doctor.js +65 -0
  32. package/src/commands/project/fleet.js +37 -2
  33. package/src/commands/project/worktree.js +70 -7
  34. package/src/commands/state/advance.js +60 -1
  35. package/src/commands/state/approve.js +125 -2
  36. package/src/commands/state/create.js +3 -3
  37. package/src/commands/state/finish.js +88 -3
  38. package/src/commands/state/list.js +9 -1
  39. package/src/commands/state/report-check.js +202 -0
  40. package/src/core/state/lifecycle-audit.js +57 -0
  41. package/src/dashboard/scanner/state-scanner.js +11 -3
  42. package/src/lib/generators/architecture-scanner.js +1 -1
  43. package/src/lib/tasks/tasks-json-parser.js +8 -0
  44. package/src/lib/worktree/fleet-scanner.js +224 -15
  45. package/src/lib/worktree/worktree-audit.js +181 -0
  46. package/src/utils/file-copier.js +5 -1
  47. 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 hashlib
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
- PHASE_POSITIONS = {
40
- 'proposal': 1,
41
- 'uiux': 2, 'design': 2,
42
- 'plan': 3,
43
- 'tasks': 4,
44
- 'implement': 5, 'sync': 5,
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': 'proposal',
48
- 'uiux': 'UI/UX',
49
- 'design': 'design',
50
- 'plan': 'planning',
51
- 'tasks': 'tasks',
71
+ 'proposal': 'proposal',
72
+ 'uiux': 'UI/UX',
73
+ 'plan': 'planning',
52
74
  'implement': 'implement',
53
- 'sync': 'sync',
75
+ 'review': 'review',
54
76
  }
55
77
  PIPELINE_TOTAL = 5
56
78
 
57
- # Approval gates per phase (from phases.json pausePoints)
58
- PHASE_GATES = {
59
- 'proposal': 'proposal',
60
- 'uiux': 'uiux',
61
- 'design': 'design',
62
- 'tasks': 'tasks',
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
- CHECKPOINT_FREQUENCY = 3 # matches llm-interaction.json default
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 ctx_color(pct):
80
- """Color based on context usage. Thresholds from Claude Code docs: 70% / 90%."""
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
- filled = int(pct / 100 * width)
90
- empty = width - filled
91
- return f"{'█' * filled}{'░' * empty}"
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
- return f"{n / 1_000_000:.1f}m"
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 safe_int(val, default=0):
103
- """Safely convert value to int, handling None from JSON null."""
104
- if val is None: return default
105
- try: return int(val)
106
- except (TypeError, ValueError): return default
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
- def safe_float(val, default=0.0):
110
- """Safely convert value to float, handling None from JSON null."""
111
- if val is None: return default
112
- try: return float(val)
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 calculate_trust(checkpoints):
119
- """Return (level_str, color, pass_rate) from checkpoint array."""
120
- if not checkpoints:
121
- return 'none', GRAY, None # no data — silent
122
- total = len(checkpoints)
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 get_checkpoint_countdown(tasks_done):
132
- """Tasks remaining until next checkpoint fires (frequency=3)."""
133
- if tasks_done <= 0:
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 get_session_feature_names(features_dict, entries):
140
- """Return only feature names mentioned in the current session's transcript.
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 get_all_active_features(cwd, entries):
153
- """Return in_progress features that are active in the current session.
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
- Caller is responsible for only calling this when inside a secondary worktree.
156
- """
157
- state_path = Path(cwd) / '.morph' / 'state.json'
158
- if not state_path.exists():
159
- return []
160
- try:
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
- # ── Activity log helpers ──────────────────────────────────────────────────────
230
+ def read_user_settings():
231
+ """Read the user settings (for autoCompactWindow). Never raises.
203
232
 
204
- def get_activity_info(cwd):
205
- """Read .morph/logs/activity.json and return compact summary dict.
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
- data = json.loads(activity_path.read_text(encoding='utf-8'))
213
- hooks = data.get('hooks') or []
214
- skills = data.get('skills') or []
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 None
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
- """Get git branch, files changed, line diff, and ahead/behind remote."""
262
- try:
263
- branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)
257
+ """Branch, ahead/behind, changed files and line diff.
264
258
 
265
- # Files with changes (staged + unstaged, no untracked)
266
- files = 0
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
- status_out = _run_git(['status', '--porcelain', '--untracked-files=no'], cwd)
269
- files = len([l for l in status_out.splitlines() if l.strip()])
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
- # Lines inserted/removed vs HEAD
274
- ins, dels = 0, 0
275
- try:
276
- out = _run_git(['diff', 'HEAD', '--shortstat'], cwd)
277
- m = re.search(r'(\d+) insertion', out)
278
- if m:
279
- ins = int(m.group(1))
280
- m = re.search(r'(\d+) deletion', out)
281
- if m:
282
- dels = int(m.group(1))
283
- except Exception:
284
- pass
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
- # Commits ahead/behind remote
287
- ahead, behind = 0, 0
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
- ab = _run_git(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], cwd)
290
- ahead, behind = (int(x) for x in ab.split('\t'))
348
+ cwd_resolved = str(Path(cwd).resolve())
291
349
  except Exception:
292
- pass
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
- parts = [f"{BLUE} {branch}{R}"]
295
- if ahead or behind:
296
- ab_str = ''
297
- if ahead: ab_str += f"{GREEN}↑{ahead}{R}"
298
- if behind: ab_str += f"{RED}↓{behind}{R}"
299
- parts.append(ab_str)
300
- if files:
301
- parts.append(f"{GRAY}{files} files{R}")
302
- if ins or dels:
303
- parts.append(f"{GREEN}+{ins}{R}{GRAY},{R}{RED}-{dels}{R}")
304
-
305
- return ' '.join(parts)
306
- except Exception:
307
- return ""
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 get_worktree_data(cwd):
311
- """Detect worktree status for cwd.
387
+ def count_tasks(feature_dir, feature_json):
388
+ """(done, total) from 2-plan/tasks.json — the declared source of truth.
312
389
 
313
- Returns (is_secondary: bool, display_str: str).
314
- is_secondary is True only when cwd is a non-primary git worktree.
315
- display_str is the formatted label (non-empty only when is_secondary).
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
- out = _run_git(['worktree', 'list', '--porcelain'], cwd)
320
- entries, current = [], {}
321
- for line in out.splitlines():
322
- if line.startswith('worktree '):
323
- if current:
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
- return False, ""
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 get_recent_tool_calls(entries):
342
- """Scan the last 50 transcript entries for the most recent Skill and Agent tool calls.
406
+ def get_active_features(cwd):
407
+ """in_progress features from the authoritative per-feature state.
343
408
 
344
- Returns (last_skill_name, last_agent_name) — either may be None.
345
- Looks at assistant messages containing tool_use blocks.
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
- last_skill = None
348
- last_agent = None
349
- recent = entries[-50:] if len(entries) > 50 else entries
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
- with open(path, 'r', encoding='utf-8') as f:
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
- pass
393
- return entries
394
-
418
+ return []
395
419
 
396
- def get_token_metrics(entries):
397
- """Sum token usage from all non-sidechain JSONL entries."""
398
- total_input, total_output, total_cached = 0, 0, 0
399
- for entry in entries:
400
- if entry.get('isSidechain') or entry.get('isApiErrorMessage'):
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
- usage = (entry.get('message') or {}).get('usage') or {}
403
- if not usage:
426
+ if not isinstance(data, dict) or data.get('status') != 'in_progress':
404
427
  continue
405
- total_input += usage.get('input_tokens', 0)
406
- total_output += usage.get('output_tokens', 0)
407
- total_cached += (
408
- usage.get('cache_creation_input_tokens', 0) +
409
- usage.get('cache_read_input_tokens', 0)
410
- )
411
- return {'input': total_input, 'output': total_output, 'cached': total_cached}
412
-
413
-
414
- def parse_timestamp(ts):
415
- """Parse ISO 8601 timestamp to Unix float. Returns None on error."""
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
- return datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
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
- def get_session_start(cwd, transcript_path, entries):
423
- """Return Unix timestamp for the start of the current session.
459
+ # ── Line renderers ────────────────────────────────────────────────────────────
424
460
 
425
- Uses two caches:
426
- - sess-tp-* : keyed by transcript_path — persists the start for this
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
- Each Claude Code window gets its own timer (per transcript).
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
- if os.name == 'nt':
434
- base = Path(os.environ.get('LOCALAPPDATA', str(Path.home() / 'AppData' / 'Local')))
435
- cache_dir = base / 'morph-spec' / 'cache'
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
- cache_dir = Path.home() / '.cache' / 'morph-spec'
476
+ parts.append((1, f"{CYAN}{label} {position + 1}/{PIPELINE_TOTAL}{R}"))
438
477
 
439
- now = time.time()
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 transcript_path:
442
- tp_h = hashlib.sha256(transcript_path.encode()).hexdigest()[:16]
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
- cwd_h = hashlib.sha256(cwd.encode()).hexdigest()[:16]
448
- cwd_cache = cache_dir / f'sess-cwd-{cwd_h}.json'
489
+ if hook_name:
490
+ parts.append((4, f"{GRAY}🪝 {hook_name}{R}"))
449
491
 
450
- # 1. This transcript already has a recorded start?
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
- # 3. Genuinely new session — use first entry from current transcript
469
- if session_start is None:
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
- # Persist both caches
483
- try:
484
- cache_dir.mkdir(parents=True, exist_ok=True)
485
- payload = json.dumps({'session_start': session_start, 'last_seen': now})
486
- if tp_cache:
487
- tp_cache.write_text(payload)
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
- return session_start
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 get_session_duration(session_start):
496
- """Format elapsed time since session_start. Returns string or None."""
497
- if session_start is None:
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
- elapsed_s = time.time() - session_start
500
- hours = int(elapsed_s // 3600)
501
- minutes = int((elapsed_s % 3600) // 60)
502
- if hours == 0:
503
- return f"{minutes}m"
504
- elif minutes == 0:
505
- return f"{hours}hr"
506
- else:
507
- return f"{hours}hr {minutes}m"
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 get_session_name(entries):
511
- """Find the most recent /rename title. Returns string or None."""
512
- for entry in reversed(entries):
513
- if entry.get('type') == 'custom-title' and entry.get('customTitle'):
514
- return entry['customTitle']
515
- return None
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
- data = json.loads(raw)
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
- cwd = data.get('cwd', os.getcwd())
532
- transcript_path = data.get('transcript_path')
533
-
534
- # Read JSONL transcript once — shared by session clock, token metrics,
535
- # session name, and skill/agent detection.
536
- entries = read_transcript_jsonl(transcript_path) if transcript_path else []
537
-
538
- # ── Worktree detection (single git call) ─────────────────────────────────
539
- # Feature lines are only shown when inside a secondary git worktree.
540
- # This prevents stale feature names showing up during unrelated work
541
- # in the main worktree.
542
- is_worktree, wt_display = get_worktree_data(cwd)
543
-
544
- # ── MORPH feature lines (one line per active feature) ────────────────────
545
- features = get_all_active_features(cwd, entries) if is_worktree else []
546
- for feat in features:
547
- # Feature name with visual prefix
548
- parts = [f"{CYAN}{BOLD}► {feat['name']}{R}"]
549
-
550
- # Phase: human-readable label + position in pipeline (e.g. "implement 4/5")
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]