@polymorphism-tech/morph-spec 4.8.6 → 4.8.8
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/bin/morph-spec.js +22 -1
- package/bin/task-manager.cjs +120 -16
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/agents.json +1854 -1815
- package/framework/hooks/claude-code/pre-compact/save-morph-context.js +141 -23
- package/framework/hooks/claude-code/statusline.py +304 -280
- package/framework/hooks/claude-code/statusline.sh +6 -2
- package/framework/hooks/claude-code/stop/validate-completion.js +70 -23
- package/framework/hooks/dev/guard-version-numbers.js +1 -1
- package/framework/skills/level-0-meta/morph-init/SKILL.md +44 -6
- package/framework/skills/level-0-meta/tool-usage-guide/SKILL.md +67 -16
- package/framework/skills/level-1-workflows/phase-clarify/SKILL.md +1 -1
- package/framework/skills/level-1-workflows/phase-codebase-analysis/SKILL.md +77 -7
- package/framework/skills/level-1-workflows/phase-design/SKILL.md +114 -50
- package/framework/skills/level-1-workflows/phase-implement/SKILL.md +139 -1
- package/framework/skills/level-1-workflows/phase-setup/SKILL.md +29 -6
- package/framework/skills/level-1-workflows/phase-tasks/SKILL.md +4 -3
- package/framework/skills/level-1-workflows/phase-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +944 -933
- package/framework/standards/architecture/vertical-slice/vertical-slice.md +429 -0
- package/framework/templates/REGISTRY.json +1909 -1888
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +282 -0
- package/package.json +1 -1
- package/src/commands/agents/dispatch-agents.js +430 -0
- package/src/commands/agents/index.js +2 -1
- package/src/commands/project/doctor.js +137 -2
- package/src/commands/state/state.js +20 -4
- package/src/commands/templates/generate-contracts.js +445 -0
- package/src/commands/templates/index.js +1 -0
- package/src/lib/validators/validation-runner.js +19 -7
|
@@ -10,6 +10,7 @@ import subprocess
|
|
|
10
10
|
import time
|
|
11
11
|
import re
|
|
12
12
|
import hashlib
|
|
13
|
+
import traceback
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from datetime import datetime, timezone
|
|
15
16
|
|
|
@@ -29,11 +30,12 @@ BLUE = '\033[34m'
|
|
|
29
30
|
GRAY = '\033[90m'
|
|
30
31
|
WHITE = '\033[97m'
|
|
31
32
|
|
|
33
|
+
# Windows: flag to hide console window when spawning subprocesses
|
|
34
|
+
_CREATE_NO_WINDOW = 0x08000000 if os.name == 'nt' else 0
|
|
35
|
+
|
|
32
36
|
|
|
33
37
|
# ── MORPH framework constants (derived from phases.json / trust-manager.js) ──
|
|
34
38
|
|
|
35
|
-
# Ordered core phases for pipeline mini-map (5 positions, optional phases mapped)
|
|
36
|
-
# uiux maps to position 2 (same slot as design — they're mutually exclusive in practice)
|
|
37
39
|
PHASE_POSITIONS = {
|
|
38
40
|
'proposal': 1, 'setup': 1,
|
|
39
41
|
'uiux': 2, 'design': 2,
|
|
@@ -41,11 +43,15 @@ PHASE_POSITIONS = {
|
|
|
41
43
|
'tasks': 4,
|
|
42
44
|
'implement': 5, 'sync': 5,
|
|
43
45
|
}
|
|
44
|
-
|
|
45
|
-
'proposal': '
|
|
46
|
-
'
|
|
47
|
-
'
|
|
48
|
-
'
|
|
46
|
+
PHASE_LABELS = {
|
|
47
|
+
'proposal': 'proposal',
|
|
48
|
+
'setup': 'setup',
|
|
49
|
+
'uiux': 'UI/UX',
|
|
50
|
+
'design': 'design',
|
|
51
|
+
'clarify': 'refinement',
|
|
52
|
+
'tasks': 'planning',
|
|
53
|
+
'implement': 'implement',
|
|
54
|
+
'sync': 'sync',
|
|
49
55
|
}
|
|
50
56
|
PIPELINE_TOTAL = 5
|
|
51
57
|
|
|
@@ -72,10 +78,10 @@ CHECKPOINT_FREQUENCY = 3 # matches llm-interaction.json default
|
|
|
72
78
|
# ── General helpers ──────────────────────────────────────────────────────────
|
|
73
79
|
|
|
74
80
|
def ctx_color(pct):
|
|
75
|
-
"""Color based on context usage.
|
|
76
|
-
if pct <
|
|
81
|
+
"""Color based on context usage. Thresholds from Claude Code docs: 70% / 90%."""
|
|
82
|
+
if pct < 70:
|
|
77
83
|
return GREEN
|
|
78
|
-
if pct <
|
|
84
|
+
if pct < 90:
|
|
79
85
|
return YELLOW
|
|
80
86
|
return RED
|
|
81
87
|
|
|
@@ -94,36 +100,33 @@ def format_tokens(n):
|
|
|
94
100
|
return str(n)
|
|
95
101
|
|
|
96
102
|
|
|
103
|
+
def safe_int(val, default=0):
|
|
104
|
+
"""Safely convert value to int, handling None from JSON null."""
|
|
105
|
+
if val is None: return default
|
|
106
|
+
try: return int(val)
|
|
107
|
+
except (TypeError, ValueError): return default
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def safe_float(val, default=0.0):
|
|
111
|
+
"""Safely convert value to float, handling None from JSON null."""
|
|
112
|
+
if val is None: return default
|
|
113
|
+
try: return float(val)
|
|
114
|
+
except (TypeError, ValueError): return default
|
|
115
|
+
|
|
116
|
+
|
|
97
117
|
# ── MORPH feature helpers ────────────────────────────────────────────────────
|
|
98
118
|
|
|
99
119
|
def calculate_trust(checkpoints):
|
|
100
|
-
"""Return (level_str, color,
|
|
120
|
+
"""Return (level_str, color, pass_rate) from checkpoint array."""
|
|
101
121
|
if not checkpoints:
|
|
102
|
-
return '
|
|
122
|
+
return 'none', GRAY, None # no data — silent
|
|
103
123
|
total = len(checkpoints)
|
|
104
124
|
passed = sum(1 for c in checkpoints if c.get('passed'))
|
|
105
125
|
rate = passed / total
|
|
106
|
-
for threshold, level, color,
|
|
126
|
+
for threshold, level, color, _badge in TRUST_LEVELS:
|
|
107
127
|
if rate >= threshold:
|
|
108
|
-
return level, color,
|
|
109
|
-
return 'low', RED,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def get_phase_minimap(phase):
|
|
113
|
-
"""Return colored dot strip + phase abbrev, e.g. '●►○○○ design'."""
|
|
114
|
-
pos = PHASE_POSITIONS.get(phase)
|
|
115
|
-
if pos is None:
|
|
116
|
-
return None
|
|
117
|
-
dots = ''
|
|
118
|
-
for i in range(1, PIPELINE_TOTAL + 1):
|
|
119
|
-
if i < pos:
|
|
120
|
-
dots += f"{GREEN}●{R}"
|
|
121
|
-
elif i == pos:
|
|
122
|
-
dots += f"{CYAN}►{R}"
|
|
123
|
-
else:
|
|
124
|
-
dots += f"{GRAY}○{R}"
|
|
125
|
-
abbrev = PHASE_ABBREV.get(phase, phase)
|
|
126
|
-
return f"{dots} {CYAN}{abbrev}{R}"
|
|
128
|
+
return level, color, rate
|
|
129
|
+
return 'low', RED, 0.0
|
|
127
130
|
|
|
128
131
|
|
|
129
132
|
def get_checkpoint_countdown(tasks_done):
|
|
@@ -134,56 +137,59 @@ def get_checkpoint_countdown(tasks_done):
|
|
|
134
137
|
return 0 if remaining == CHECKPOINT_FREQUENCY else remaining
|
|
135
138
|
|
|
136
139
|
|
|
137
|
-
def
|
|
138
|
-
"""Return
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
140
|
+
def get_session_feature_names(features_dict, entries):
|
|
141
|
+
"""Return only feature names mentioned in the current session's transcript.
|
|
142
|
+
If a feature name appears in the transcript (tool calls, messages, results),
|
|
143
|
+
this session is actively working on it. Prevents showing features from other sessions.
|
|
144
|
+
"""
|
|
145
|
+
if not features_dict or not entries:
|
|
146
|
+
return set()
|
|
147
|
+
# Serialize last 500 entries for search (avoids cost on long sessions)
|
|
148
|
+
recent = entries[-500:] if len(entries) > 500 else entries
|
|
149
|
+
transcript_text = json.dumps(recent, ensure_ascii=False)
|
|
150
|
+
return {name for name in features_dict if name in transcript_text}
|
|
147
151
|
|
|
148
152
|
|
|
149
|
-
def get_all_active_features(cwd):
|
|
150
|
-
"""Return
|
|
153
|
+
def get_all_active_features(cwd, entries):
|
|
154
|
+
"""Return in_progress features that are active in the current session."""
|
|
151
155
|
state_path = Path(cwd) / '.morph' / 'state.json'
|
|
152
156
|
if not state_path.exists():
|
|
153
157
|
return []
|
|
154
158
|
try:
|
|
155
|
-
state = json.loads(state_path.read_text())
|
|
159
|
+
state = json.loads(state_path.read_text(encoding='utf-8'))
|
|
156
160
|
features = state.get('features', {})
|
|
161
|
+
|
|
162
|
+
# Filter to features belonging to this session (mentioned in transcript)
|
|
163
|
+
session_names = get_session_feature_names(features, entries)
|
|
164
|
+
if not session_names:
|
|
165
|
+
return []
|
|
166
|
+
|
|
157
167
|
result = []
|
|
158
168
|
for name, feat in features.items():
|
|
159
169
|
if feat.get('status') != 'in_progress':
|
|
160
170
|
continue
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
if name not in session_names:
|
|
172
|
+
continue
|
|
173
|
+
phase = feat.get('phase', '?')
|
|
174
|
+
tasks = feat.get('tasks') or {}
|
|
175
|
+
done = safe_int(tasks.get('completed'))
|
|
176
|
+
total = safe_int(tasks.get('total'))
|
|
177
|
+
gates = feat.get('approvalGates') or {}
|
|
178
|
+
checkpts = feat.get('checkpoints') or []
|
|
179
|
+
|
|
180
|
+
pending = [g for g, v in gates.items() if not v.get('approved')]
|
|
181
|
+
trust_lvl, trust_color, trust_rate = calculate_trust(checkpts)
|
|
182
|
+
countdown = get_checkpoint_countdown(done)
|
|
173
183
|
|
|
174
184
|
result.append({
|
|
175
|
-
'name':
|
|
176
|
-
'phase':
|
|
177
|
-
'tasks_done':
|
|
178
|
-
'tasks_total':
|
|
179
|
-
'pending':
|
|
180
|
-
'trust_level':
|
|
181
|
-
'trust_color':
|
|
182
|
-
'
|
|
183
|
-
'trust_rate': trust_rate,
|
|
184
|
-
'countdown': countdown,
|
|
185
|
-
'next_gate': next_gate,
|
|
186
|
-
'minimap': minimap,
|
|
185
|
+
'name': name,
|
|
186
|
+
'phase': phase,
|
|
187
|
+
'tasks_done': done,
|
|
188
|
+
'tasks_total': total,
|
|
189
|
+
'pending': pending[0] if pending else None,
|
|
190
|
+
'trust_level': trust_lvl,
|
|
191
|
+
'trust_color': trust_color,
|
|
192
|
+
'countdown': countdown,
|
|
187
193
|
})
|
|
188
194
|
return result
|
|
189
195
|
except Exception:
|
|
@@ -192,50 +198,61 @@ def get_all_active_features(cwd):
|
|
|
192
198
|
|
|
193
199
|
# ── Git helpers ───────────────────────────────────────────────────────────────
|
|
194
200
|
|
|
201
|
+
def _run_git(args, cwd):
|
|
202
|
+
"""Run a git command safely on Windows (no console popup, with timeout)."""
|
|
203
|
+
return subprocess.check_output(
|
|
204
|
+
['git'] + args,
|
|
205
|
+
cwd=cwd, stderr=subprocess.DEVNULL, timeout=3,
|
|
206
|
+
creationflags=_CREATE_NO_WINDOW,
|
|
207
|
+
).decode().strip()
|
|
208
|
+
|
|
209
|
+
|
|
195
210
|
def get_git_info(cwd):
|
|
196
|
-
"""Get git branch
|
|
211
|
+
"""Get git branch, files changed, line diff, and ahead/behind remote."""
|
|
197
212
|
try:
|
|
198
|
-
|
|
213
|
+
branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)
|
|
214
|
+
|
|
215
|
+
# Files with changes (staged + unstaged, no untracked)
|
|
216
|
+
files = 0
|
|
199
217
|
try:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if age < 5:
|
|
203
|
-
return cache_file.read_text().strip()
|
|
218
|
+
status_out = _run_git(['status', '--porcelain', '--untracked-files=no'], cwd)
|
|
219
|
+
files = len([l for l in status_out.splitlines() if l.strip()])
|
|
204
220
|
except Exception:
|
|
205
221
|
pass
|
|
206
222
|
|
|
207
|
-
|
|
208
|
-
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
209
|
-
cwd=cwd, stderr=subprocess.DEVNULL, timeout=2
|
|
210
|
-
).decode().strip()
|
|
211
|
-
|
|
212
|
-
# Diff stats: insertions/deletions from staged + unstaged changes
|
|
223
|
+
# Lines inserted/removed vs HEAD
|
|
213
224
|
ins, dels = 0, 0
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
try:
|
|
226
|
+
out = _run_git(['diff', 'HEAD', '--shortstat'], cwd)
|
|
227
|
+
m = re.search(r'(\d+) insertion', out)
|
|
228
|
+
if m:
|
|
229
|
+
ins = int(m.group(1))
|
|
230
|
+
m = re.search(r'(\d+) deletion', out)
|
|
231
|
+
if m:
|
|
232
|
+
dels = int(m.group(1))
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
# Commits ahead/behind remote
|
|
237
|
+
ahead, behind = 0, 0
|
|
238
|
+
try:
|
|
239
|
+
ab = _run_git(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], cwd)
|
|
240
|
+
ahead, behind = (int(x) for x in ab.split('\t'))
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
227
243
|
|
|
228
244
|
parts = [f"{BLUE} {branch}{R}"]
|
|
245
|
+
if ahead or behind:
|
|
246
|
+
ab_str = ''
|
|
247
|
+
if ahead: ab_str += f"{GREEN}↑{ahead}{R}"
|
|
248
|
+
if behind: ab_str += f"{RED}↓{behind}{R}"
|
|
249
|
+
parts.append(ab_str)
|
|
250
|
+
if files:
|
|
251
|
+
parts.append(f"{GRAY}{files} files{R}")
|
|
229
252
|
if ins or dels:
|
|
230
253
|
parts.append(f"{GREEN}+{ins}{R}{GRAY},{R}{RED}-{dels}{R}")
|
|
231
254
|
|
|
232
|
-
|
|
233
|
-
try:
|
|
234
|
-
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
-
cache_file.write_text(result)
|
|
236
|
-
except Exception:
|
|
237
|
-
pass
|
|
238
|
-
return result
|
|
255
|
+
return ' '.join(parts)
|
|
239
256
|
except Exception:
|
|
240
257
|
return ""
|
|
241
258
|
|
|
@@ -243,10 +260,7 @@ def get_git_info(cwd):
|
|
|
243
260
|
def get_worktree_info(cwd):
|
|
244
261
|
"""Detect if running in a git worktree (not the main worktree)."""
|
|
245
262
|
try:
|
|
246
|
-
out =
|
|
247
|
-
['git', 'worktree', 'list', '--porcelain'],
|
|
248
|
-
cwd=cwd, stderr=subprocess.DEVNULL, timeout=2
|
|
249
|
-
).decode()
|
|
263
|
+
out = _run_git(['worktree', 'list', '--porcelain'], cwd)
|
|
250
264
|
entries, current = [], {}
|
|
251
265
|
for line in out.splitlines():
|
|
252
266
|
if line.startswith('worktree '):
|
|
@@ -314,95 +328,101 @@ def parse_timestamp(ts):
|
|
|
314
328
|
return None
|
|
315
329
|
|
|
316
330
|
|
|
317
|
-
def
|
|
318
|
-
"""
|
|
319
|
-
now = time.time()
|
|
320
|
-
for entry in entries:
|
|
321
|
-
ts = entry.get('timestamp')
|
|
322
|
-
if not ts:
|
|
323
|
-
continue
|
|
324
|
-
t = parse_timestamp(ts)
|
|
325
|
-
if t is None:
|
|
326
|
-
continue
|
|
327
|
-
elapsed_s = now - t
|
|
328
|
-
hours = int(elapsed_s // 3600)
|
|
329
|
-
minutes = int((elapsed_s % 3600) // 60)
|
|
330
|
-
if hours == 0:
|
|
331
|
-
return f"{minutes}m"
|
|
332
|
-
elif minutes == 0:
|
|
333
|
-
return f"{hours}hr"
|
|
334
|
-
else:
|
|
335
|
-
return f"{hours}hr {minutes}m"
|
|
336
|
-
return None
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
def get_session_name(entries):
|
|
340
|
-
"""Find the most recent /rename title. Returns string or None."""
|
|
341
|
-
for entry in reversed(entries):
|
|
342
|
-
if entry.get('type') == 'custom-title' and entry.get('customTitle'):
|
|
343
|
-
return entry['customTitle']
|
|
344
|
-
return None
|
|
331
|
+
def get_session_start(cwd, transcript_path, entries):
|
|
332
|
+
"""Return Unix timestamp for the start of the current session.
|
|
345
333
|
|
|
334
|
+
Uses two caches:
|
|
335
|
+
- sess-tp-* : keyed by transcript_path — persists the start for this
|
|
336
|
+
session even after long absences (wall-clock elapsed)
|
|
337
|
+
- sess-cwd-* : keyed by cwd, 2-min window — detects plan→accept
|
|
338
|
+
(new transcript created seconds after the previous one)
|
|
346
339
|
|
|
347
|
-
|
|
348
|
-
"""
|
|
349
|
-
h = hashlib.sha256(transcript_path.encode()).hexdigest()[:16]
|
|
350
|
-
# Use platform-appropriate cache directory
|
|
340
|
+
Each Claude Code window gets its own timer (per transcript).
|
|
341
|
+
"""
|
|
351
342
|
if os.name == 'nt':
|
|
352
343
|
base = Path(os.environ.get('LOCALAPPDATA', str(Path.home() / 'AppData' / 'Local')))
|
|
353
344
|
cache_dir = base / 'morph-spec' / 'cache'
|
|
354
345
|
else:
|
|
355
346
|
cache_dir = Path.home() / '.cache' / 'morph-spec'
|
|
356
|
-
cache_file = cache_dir / f'block-{h}.json'
|
|
357
|
-
now = time.time()
|
|
358
|
-
block_s = 5 * 3600
|
|
359
347
|
|
|
348
|
+
now = time.time()
|
|
349
|
+
|
|
350
|
+
if transcript_path:
|
|
351
|
+
tp_h = hashlib.sha256(transcript_path.encode()).hexdigest()[:16]
|
|
352
|
+
tp_cache = cache_dir / f'sess-tp-{tp_h}.json'
|
|
353
|
+
else:
|
|
354
|
+
tp_cache = None
|
|
355
|
+
|
|
356
|
+
cwd_h = hashlib.sha256(cwd.encode()).hexdigest()[:16]
|
|
357
|
+
cwd_cache = cache_dir / f'sess-cwd-{cwd_h}.json'
|
|
358
|
+
|
|
359
|
+
# 1. This transcript already has a recorded start?
|
|
360
|
+
session_start = None
|
|
360
361
|
try:
|
|
361
|
-
if
|
|
362
|
-
|
|
363
|
-
start = cached.get('block_start')
|
|
364
|
-
if start and (now - start) < block_s:
|
|
365
|
-
return start
|
|
362
|
+
if tp_cache and tp_cache.exists():
|
|
363
|
+
session_start = json.loads(tp_cache.read_text()).get('session_start')
|
|
366
364
|
except Exception:
|
|
367
365
|
pass
|
|
368
366
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
367
|
+
# 2. New transcript — continuation from plan→accept? (cwd gap < 2 min)
|
|
368
|
+
if session_start is None:
|
|
369
|
+
try:
|
|
370
|
+
if cwd_cache.exists():
|
|
371
|
+
cwd_data = json.loads(cwd_cache.read_text())
|
|
372
|
+
if (now - cwd_data.get('last_seen', 0)) < 120:
|
|
373
|
+
session_start = cwd_data.get('session_start')
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
# 3. Genuinely new session — use first entry from current transcript
|
|
378
|
+
if session_start is None:
|
|
379
|
+
for entry in entries:
|
|
380
|
+
ts = entry.get('timestamp')
|
|
381
|
+
if not ts:
|
|
382
|
+
continue
|
|
383
|
+
t = parse_timestamp(ts)
|
|
384
|
+
if t is not None:
|
|
385
|
+
session_start = t
|
|
386
|
+
break
|
|
380
387
|
|
|
381
|
-
if
|
|
388
|
+
if session_start is None:
|
|
382
389
|
return None
|
|
383
390
|
|
|
391
|
+
# Persist both caches
|
|
384
392
|
try:
|
|
385
393
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
386
|
-
|
|
394
|
+
payload = json.dumps({'session_start': session_start, 'last_seen': now})
|
|
395
|
+
if tp_cache:
|
|
396
|
+
tp_cache.write_text(payload)
|
|
397
|
+
cwd_cache.write_text(payload)
|
|
387
398
|
except Exception:
|
|
388
399
|
pass
|
|
389
|
-
|
|
400
|
+
|
|
401
|
+
return session_start
|
|
390
402
|
|
|
391
403
|
|
|
392
|
-
def
|
|
393
|
-
"""Format
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
hours
|
|
398
|
-
minutes
|
|
404
|
+
def get_session_duration(session_start):
|
|
405
|
+
"""Format elapsed time since session_start. Returns string or None."""
|
|
406
|
+
if session_start is None:
|
|
407
|
+
return None
|
|
408
|
+
elapsed_s = time.time() - session_start
|
|
409
|
+
hours = int(elapsed_s // 3600)
|
|
410
|
+
minutes = int((elapsed_s % 3600) // 60)
|
|
399
411
|
if hours == 0:
|
|
400
|
-
|
|
412
|
+
return f"{minutes}m"
|
|
401
413
|
elif minutes == 0:
|
|
402
|
-
|
|
414
|
+
return f"{hours}hr"
|
|
403
415
|
else:
|
|
404
|
-
|
|
405
|
-
|
|
416
|
+
return f"{hours}hr {minutes}m"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def get_session_name(entries):
|
|
420
|
+
"""Find the most recent /rename title. Returns string or None."""
|
|
421
|
+
for entry in reversed(entries):
|
|
422
|
+
if entry.get('type') == 'custom-title' and entry.get('customTitle'):
|
|
423
|
+
return entry['customTitle']
|
|
424
|
+
return None
|
|
425
|
+
|
|
406
426
|
|
|
407
427
|
|
|
408
428
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
@@ -416,127 +436,131 @@ def main():
|
|
|
416
436
|
except Exception:
|
|
417
437
|
sys.exit(0)
|
|
418
438
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
439
|
+
try:
|
|
440
|
+
cwd = data.get('cwd', os.getcwd())
|
|
441
|
+
transcript_path = data.get('transcript_path')
|
|
442
|
+
|
|
443
|
+
# Read JSONL transcript once — shared by session clock, block timer,
|
|
444
|
+
# token metrics, and session name.
|
|
445
|
+
entries = read_transcript_jsonl(transcript_path) if transcript_path else []
|
|
446
|
+
|
|
447
|
+
# ── MORPH feature lines (one line per active feature) ────────────────────
|
|
448
|
+
features = get_all_active_features(cwd, entries)
|
|
449
|
+
for feat in features:
|
|
450
|
+
# Feature name with visual prefix
|
|
451
|
+
parts = [f"{CYAN}{BOLD}► {feat['name']}{R}"]
|
|
452
|
+
|
|
453
|
+
# Phase: human-readable label + position in pipeline (e.g. "implement 4/5")
|
|
454
|
+
phase = feat['phase']
|
|
455
|
+
pos = PHASE_POSITIONS.get(phase)
|
|
456
|
+
label = PHASE_LABELS.get(phase, phase)
|
|
457
|
+
if pos:
|
|
458
|
+
parts.append(f"{CYAN}{label} {pos}/{PIPELINE_TOTAL}{R}")
|
|
459
|
+
elif phase != '?':
|
|
460
|
+
parts.append(f"{CYAN}{label}{R}")
|
|
461
|
+
|
|
462
|
+
# Task progress (only if tasks exist)
|
|
463
|
+
if feat['tasks_total'] > 0:
|
|
464
|
+
pct = feat['tasks_done'] / feat['tasks_total'] * 100
|
|
465
|
+
bar = progress_bar(pct, 6)
|
|
466
|
+
parts.append(f"{GREEN}{bar} {feat['tasks_done']}/{feat['tasks_total']} tasks{R}")
|
|
467
|
+
|
|
468
|
+
# Checkpoint: only show when imminent (0 = now, 1 = next task)
|
|
443
469
|
if feat['countdown'] == 0:
|
|
444
|
-
parts.append(f"{GREEN}
|
|
470
|
+
parts.append(f"{GREEN}💾 checkpoint!{R}")
|
|
445
471
|
elif feat['countdown'] == 1:
|
|
446
|
-
parts.append(f"{YELLOW}
|
|
447
|
-
else:
|
|
448
|
-
parts.append(f"{GRAY}ckpt:{feat['countdown']}{R}")
|
|
449
|
-
|
|
450
|
-
# Trust level badge: ◆◆◆○ high
|
|
451
|
-
tc = feat['trust_color']
|
|
452
|
-
parts.append(f"{tc}{feat['trust_badge']}{R}")
|
|
472
|
+
parts.append(f"{YELLOW}💾 in 1 task{R}")
|
|
453
473
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
474
|
+
# Trust: silent when good, alert only if problematic
|
|
475
|
+
if feat['trust_level'] == 'low':
|
|
476
|
+
parts.append(f"{RED}⚠ low trust{R}")
|
|
477
|
+
elif feat['trust_level'] == 'medium':
|
|
478
|
+
parts.append(f"{YELLOW}⚠ medium trust{R}")
|
|
457
479
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
480
|
+
# Blocking approval gate
|
|
481
|
+
if feat['pending']:
|
|
482
|
+
parts.append(f"{RED}⛔ approval required{R}")
|
|
461
483
|
|
|
462
|
-
|
|
484
|
+
print(' | '.join(parts))
|
|
463
485
|
|
|
464
|
-
|
|
465
|
-
|
|
486
|
+
# ── Session info line (always shown) ─────────────────────────────────────
|
|
487
|
+
parts2 = []
|
|
466
488
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
489
|
+
# Session name (set via /rename)
|
|
490
|
+
if entries:
|
|
491
|
+
session_name = get_session_name(entries)
|
|
492
|
+
if session_name:
|
|
493
|
+
parts2.append(f"{CYAN}{BOLD}📌 {session_name}{R}")
|
|
472
494
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
495
|
+
# Model
|
|
496
|
+
model = data.get('model') or ''
|
|
497
|
+
if isinstance(model, dict):
|
|
498
|
+
model_name = model.get('display_name') or model.get('id') or ''
|
|
499
|
+
else:
|
|
500
|
+
model_name = str(model) if model else ''
|
|
501
|
+
if model_name:
|
|
502
|
+
short = model_name.replace('Claude ', '').replace('claude-', '').replace(' (claude.ai)', '')
|
|
503
|
+
parts2.append(f"{WHITE}{BOLD}🤖 {short}{R}")
|
|
504
|
+
|
|
505
|
+
# Permission mode (only shown when non-default)
|
|
506
|
+
perm = data.get('permission_mode') or ''
|
|
507
|
+
if perm and perm != 'default':
|
|
508
|
+
_perm_labels = {
|
|
509
|
+
'plan': '📋 plan',
|
|
510
|
+
'acceptEdits': '✏ accept edits',
|
|
511
|
+
'bypassPermissions': '⚠ bypass',
|
|
512
|
+
'dontAsk': '🔕 auto',
|
|
513
|
+
}
|
|
514
|
+
parts2.append(f"{YELLOW}{_perm_labels.get(perm, perm)}{R}")
|
|
515
|
+
|
|
516
|
+
# Session clock (elapsed time since session start, survives transcript transitions)
|
|
517
|
+
session_start = get_session_start(cwd, transcript_path, entries)
|
|
518
|
+
duration = get_session_duration(session_start)
|
|
483
519
|
if duration:
|
|
484
520
|
parts2.append(f"{YELLOW}⏱ {duration}{R}")
|
|
485
521
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
if
|
|
515
|
-
parts2.append(
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
# Git info (branch + diff stats, 5s cached)
|
|
529
|
-
git = get_git_info(cwd)
|
|
530
|
-
if git:
|
|
531
|
-
parts2.append(git)
|
|
532
|
-
|
|
533
|
-
# Worktree info
|
|
534
|
-
wt = get_worktree_info(cwd)
|
|
535
|
-
if wt:
|
|
536
|
-
parts2.append(wt)
|
|
537
|
-
|
|
538
|
-
if parts2:
|
|
539
|
-
print(' | '.join(parts2))
|
|
522
|
+
# Context window
|
|
523
|
+
# used_percentage excludes output_tokens — underestimates real usage in long sessions.
|
|
524
|
+
# Compute (input + cache_creation + cache_read + output) / context_window_size,
|
|
525
|
+
# which reflects true context consumption for the next turn.
|
|
526
|
+
ctx = data.get('context_window', {})
|
|
527
|
+
if ctx:
|
|
528
|
+
cur_usage = ctx.get('current_usage') or {}
|
|
529
|
+
total_ctx = safe_int(ctx.get('context_window_size'))
|
|
530
|
+
if isinstance(cur_usage, dict) and total_ctx > 0:
|
|
531
|
+
tokens = (safe_int(cur_usage.get('input_tokens'))
|
|
532
|
+
+ safe_int(cur_usage.get('cache_creation_input_tokens'))
|
|
533
|
+
+ safe_int(cur_usage.get('cache_read_input_tokens'))
|
|
534
|
+
+ safe_int(cur_usage.get('output_tokens')))
|
|
535
|
+
used_pct = tokens / total_ctx * 100 if tokens > 0 else safe_float(ctx.get('used_percentage'))
|
|
536
|
+
else:
|
|
537
|
+
used_pct = safe_float(ctx.get('used_percentage'))
|
|
538
|
+
color = ctx_color(used_pct)
|
|
539
|
+
bar = progress_bar(used_pct, 8)
|
|
540
|
+
toks = f"{format_tokens(tokens)}/{format_tokens(total_ctx)}" if total_ctx and isinstance(cur_usage, dict) else ""
|
|
541
|
+
suffix = f" {RED}~cmpct{R}" if used_pct >= 90 else ""
|
|
542
|
+
line = f"{color}{bar} {used_pct:.0f}%{R}"
|
|
543
|
+
if toks:
|
|
544
|
+
line += f" ({toks})"
|
|
545
|
+
parts2.append(line + suffix)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
# Git info (branch + diff stats)
|
|
549
|
+
git = get_git_info(cwd)
|
|
550
|
+
if git:
|
|
551
|
+
parts2.append(git)
|
|
552
|
+
|
|
553
|
+
# Worktree info
|
|
554
|
+
wt = get_worktree_info(cwd)
|
|
555
|
+
if wt:
|
|
556
|
+
parts2.append(wt)
|
|
557
|
+
|
|
558
|
+
if parts2:
|
|
559
|
+
print(' | '.join(parts2))
|
|
560
|
+
|
|
561
|
+
except Exception:
|
|
562
|
+
err = traceback.format_exc().splitlines()[-1]
|
|
563
|
+
print(f"{RED}ERRO: {err}{R}")
|
|
540
564
|
|
|
541
565
|
|
|
542
566
|
if __name__ == '__main__':
|