@polymorphism-tech/morph-spec 8.1.0 → 8.1.1
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/validate.js +379 -379
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/commands/commit.md +166 -166
- package/framework/commands/morph-infra.md +215 -215
- package/framework/commands/morph-preflight.md +205 -205
- package/framework/commands/morph-troubleshoot.md +124 -124
- package/framework/hooks/claude-code/statusline.py +679 -679
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
- package/framework/hooks/git/pre-commit/agents.sh +25 -25
- package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
- package/framework/hooks/git/pre-commit/specs.sh +50 -50
- package/framework/hooks/git/pre-push/run-tests.sh +44 -44
- package/framework/hooks/shared/activity-logger.js +160 -160
- package/framework/hooks/shared/stdin-reader.js +26 -26
- package/framework/rubrics/backend.json +41 -41
- package/framework/rules/ai-agents-standards.md +45 -45
- package/framework/rules/dotnet-templates.md +86 -86
- package/framework/rules/frontend-standards.md +13 -13
- package/framework/rules/infrastructure-standards.md +27 -27
- package/framework/rules/neon-standards.md +25 -25
- package/framework/rules/nextjs-standards.md +20 -20
- package/framework/rules/nextjs-templates.md +40 -40
- 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/framework/standards/ai-agents/agent-session.md +160 -160
- package/framework/standards/ai-agents/batching.md +193 -193
- package/framework/standards/ai-agents/context-providers.md +188 -188
- package/framework/standards/ai-agents/mcp-server.md +250 -250
- package/framework/standards/ai-agents/mcp-tools.md +197 -197
- package/framework/standards/ai-agents/middleware-patterns.md +207 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
- package/framework/standards/ai-agents/modalities-vision.md +201 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
- package/framework/standards/ai-agents/production.md +275 -275
- package/framework/standards/ai-agents/prompts-in-db.md +214 -214
- package/framework/standards/ai-agents/providers/model-registry.md +158 -158
- package/framework/standards/ai-agents/service-tier-flex.md +135 -135
- package/framework/standards/ai-agents/structured-output.md +249 -249
- package/framework/standards/ai-agents/sweet-spot.md +109 -109
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
- package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
- package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
- package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
- package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
- package/framework/templates/code/dotnet/database/migration.cs +83 -83
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/code/typescript/contracts.ts +167 -167
- package/framework/templates/docs/clarifications.md +253 -253
- package/framework/templates/docs/schema-analysis.md +119 -119
- package/framework/templates/docs/ui-components.md +52 -52
- package/framework/templates/docs/ui-design-system.md +76 -76
- package/framework/templates/docs/ui-flows.md +167 -167
- package/framework/templates/docs/ui-mockups.md +98 -98
- package/framework/templates/feature/decisions.md +122 -122
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
- package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +50 -50
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/README.md +56 -56
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/integrations/asaas-client.cs +387 -387
- package/framework/templates/integrations/asaas-webhook.cs +351 -351
- package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
- package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
- package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
- package/framework/templates/project/validate.js +120 -120
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/dashboard/dashboard.js +53 -53
- package/src/commands/index.js +13 -13
- package/src/commands/mcp/mcp-setup.js +519 -519
- package/src/commands/project/checkpoint.js +209 -209
- package/src/commands/project/worktree.js +83 -1
- package/src/core/state/phase-state-machine.js +197 -197
- package/src/core/templates/template-renderer.js +477 -477
- package/src/dashboard/app/components/DetailPanel.jsx +11 -11
- package/src/dashboard/app/components/FlowGraph.jsx +17 -17
- package/src/dashboard/app/components/SearchFilter.jsx +22 -22
- package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/AgentMap.jsx +370 -370
- package/src/dashboard/app/pages/HookInspector.jsx +418 -418
- package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
- package/src/dashboard/app/pages/RulesMap.jsx +273 -273
- package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
- package/src/dashboard/app/pages/Standards.jsx +332 -332
- package/src/dashboard/app/styles/index.css +47 -47
- package/src/dashboard/index.html +12 -12
- package/src/dashboard/package.json +24 -24
- package/src/dashboard/scanner/hooks-scanner.js +41 -41
- package/src/dashboard/scanner/phases-scanner.js +15 -15
- package/src/dashboard/scanner/rubrics-scanner.js +12 -12
- package/src/dashboard/scanner/rules-scanner.js +37 -37
- package/src/dashboard/scanner/skills-scanner.js +43 -43
- package/src/dashboard/scanner/squads-scanner.js +13 -13
- package/src/dashboard/scanner/standards-scanner.js +26 -26
- package/src/dashboard/vite-plugin-morph-data.js +17 -17
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/agents/metrics-aggregator.js +56 -56
- package/src/lib/agents/metrics-calculator.js +125 -125
- package/src/lib/agents/output-collector.js +73 -73
- package/src/lib/detectors/claude-config-detector.js +152 -152
- package/src/lib/detectors/design-system-detector.js +231 -231
- package/src/lib/detectors/index.js +217 -217
- package/src/lib/generators/architecture-scanner.js +639 -639
- package/src/lib/generators/design-system-generator.js +508 -508
- package/src/lib/generators/index.js +13 -13
- package/src/lib/generators/settings-generator.js +209 -209
- package/src/lib/hooks/hook-executor.js +167 -167
- package/src/lib/installers/mcp-installer.js +719 -719
- package/src/lib/tasks/task-classifier.js +30 -30
- package/src/lib/tasks/task-parser.js +209 -209
- package/src/lib/validators/architecture/architecture-validator.js +259 -259
- package/src/lib/validators/checkpoint-persistence.js +39 -39
- package/src/lib/validators/contracts/contract-compliance-validator.js +274 -274
- package/src/lib/validators/css/css-validator.js +340 -340
- package/src/lib/validators/packages/package-validator.js +347 -347
- package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
- package/src/lib/validators/shared/issue-counter.js +14 -14
- package/src/lib/validators/validation-runner.js +349 -349
- package/src/utils/agent-id-mapper.js +11 -11
- package/src/utils/claude-settings-manager.js +300 -300
- package/src/utils/skills-installer.js +174 -174
- package/framework/templates/.idea/morph-templates.xml +0 -92
- package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
|
@@ -1,679 +1,679 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
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
|
|
5
|
-
|
|
6
|
-
import sys
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import subprocess
|
|
10
|
-
import time
|
|
11
|
-
import re
|
|
12
|
-
import hashlib
|
|
13
|
-
import traceback
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from datetime import datetime, timezone
|
|
16
|
-
|
|
17
|
-
# Ensure UTF-8 output on Windows (stdout defaults to CP1252 otherwise)
|
|
18
|
-
if hasattr(sys.stdout, 'reconfigure'):
|
|
19
|
-
sys.stdout.reconfigure(encoding='utf-8')
|
|
20
|
-
|
|
21
|
-
# ANSI colors
|
|
22
|
-
R = '\033[0m' # Reset
|
|
23
|
-
BOLD = '\033[1m'
|
|
24
|
-
CYAN = '\033[36m'
|
|
25
|
-
MAGENTA = '\033[35m'
|
|
26
|
-
GREEN = '\033[32m'
|
|
27
|
-
YELLOW = '\033[33m'
|
|
28
|
-
RED = '\033[31m'
|
|
29
|
-
BLUE = '\033[34m'
|
|
30
|
-
GRAY = '\033[90m'
|
|
31
|
-
WHITE = '\033[97m'
|
|
32
|
-
|
|
33
|
-
# Windows: flag to hide console window when spawning subprocesses
|
|
34
|
-
_CREATE_NO_WINDOW = 0x08000000 if os.name == 'nt' else 0
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# ── MORPH framework constants (derived from phases.json / trust-manager.js) ──
|
|
38
|
-
|
|
39
|
-
PHASE_POSITIONS = {
|
|
40
|
-
'proposal': 1,
|
|
41
|
-
'uiux': 2, 'design': 2,
|
|
42
|
-
'plan': 3,
|
|
43
|
-
'tasks': 4,
|
|
44
|
-
'implement': 5, 'sync': 5,
|
|
45
|
-
}
|
|
46
|
-
PHASE_LABELS = {
|
|
47
|
-
'proposal': 'proposal',
|
|
48
|
-
'uiux': 'UI/UX',
|
|
49
|
-
'design': 'design',
|
|
50
|
-
'plan': 'planning',
|
|
51
|
-
'tasks': 'tasks',
|
|
52
|
-
'implement': 'implement',
|
|
53
|
-
'sync': 'sync',
|
|
54
|
-
}
|
|
55
|
-
PIPELINE_TOTAL = 5
|
|
56
|
-
|
|
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
|
-
}
|
|
64
|
-
|
|
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
|
-
|
|
74
|
-
CHECKPOINT_FREQUENCY = 3 # matches llm-interaction.json default
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# ── General helpers ──────────────────────────────────────────────────────────
|
|
78
|
-
|
|
79
|
-
def ctx_color(pct):
|
|
80
|
-
"""Color based on context usage. Thresholds from Claude Code docs: 70% / 90%."""
|
|
81
|
-
if pct < 70:
|
|
82
|
-
return GREEN
|
|
83
|
-
if pct < 90:
|
|
84
|
-
return YELLOW
|
|
85
|
-
return RED
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def progress_bar(pct, width=8):
|
|
89
|
-
filled = int(pct / 100 * width)
|
|
90
|
-
empty = width - filled
|
|
91
|
-
return f"{'█' * filled}{'░' * empty}"
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def format_tokens(n):
|
|
95
|
-
if n >= 1_000_000:
|
|
96
|
-
return f"{n / 1_000_000:.1f}m"
|
|
97
|
-
if n >= 1000:
|
|
98
|
-
return f"{n // 1000}k"
|
|
99
|
-
return str(n)
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
# ── MORPH feature helpers ────────────────────────────────────────────────────
|
|
117
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
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}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def get_all_active_features(cwd, entries):
|
|
153
|
-
"""Return in_progress features that are active in the current session.
|
|
154
|
-
|
|
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 []
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
# ── Activity log helpers ──────────────────────────────────────────────────────
|
|
203
|
-
|
|
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.
|
|
207
|
-
"""
|
|
208
|
-
activity_path = Path(cwd) / '.morph' / 'logs' / 'activity.json'
|
|
209
|
-
if not activity_path.exists():
|
|
210
|
-
return None
|
|
211
|
-
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
|
-
}
|
|
245
|
-
except Exception:
|
|
246
|
-
return None
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
# ── Git helpers ───────────────────────────────────────────────────────────────
|
|
250
|
-
|
|
251
|
-
def _run_git(args, cwd):
|
|
252
|
-
"""Run a git command safely on Windows (no console popup, with timeout)."""
|
|
253
|
-
return subprocess.check_output(
|
|
254
|
-
['git'] + args,
|
|
255
|
-
cwd=cwd, stderr=subprocess.DEVNULL, timeout=3,
|
|
256
|
-
creationflags=_CREATE_NO_WINDOW,
|
|
257
|
-
).decode().strip()
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
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)
|
|
264
|
-
|
|
265
|
-
# Files with changes (staged + unstaged, no untracked)
|
|
266
|
-
files = 0
|
|
267
|
-
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()])
|
|
270
|
-
except Exception:
|
|
271
|
-
pass
|
|
272
|
-
|
|
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
|
|
285
|
-
|
|
286
|
-
# Commits ahead/behind remote
|
|
287
|
-
ahead, behind = 0, 0
|
|
288
|
-
try:
|
|
289
|
-
ab = _run_git(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], cwd)
|
|
290
|
-
ahead, behind = (int(x) for x in ab.split('\t'))
|
|
291
|
-
except Exception:
|
|
292
|
-
pass
|
|
293
|
-
|
|
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 ""
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def get_worktree_data(cwd):
|
|
311
|
-
"""Detect worktree status for cwd.
|
|
312
|
-
|
|
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.
|
|
317
|
-
"""
|
|
318
|
-
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}"
|
|
336
|
-
except Exception:
|
|
337
|
-
pass
|
|
338
|
-
return False, ""
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def get_recent_tool_calls(entries):
|
|
342
|
-
"""Scan the last 50 transcript entries for the most recent Skill and Agent tool calls.
|
|
343
|
-
|
|
344
|
-
Returns (last_skill_name, last_agent_name) — either may be None.
|
|
345
|
-
Looks at assistant messages containing tool_use blocks.
|
|
346
|
-
"""
|
|
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 = []
|
|
381
|
-
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
|
|
391
|
-
except Exception:
|
|
392
|
-
pass
|
|
393
|
-
return entries
|
|
394
|
-
|
|
395
|
-
|
|
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'):
|
|
401
|
-
continue
|
|
402
|
-
usage = (entry.get('message') or {}).get('usage') or {}
|
|
403
|
-
if not usage:
|
|
404
|
-
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."""
|
|
416
|
-
try:
|
|
417
|
-
return datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
|
|
418
|
-
except Exception:
|
|
419
|
-
return None
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
def get_session_start(cwd, transcript_path, entries):
|
|
423
|
-
"""Return Unix timestamp for the start of the current session.
|
|
424
|
-
|
|
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)
|
|
430
|
-
|
|
431
|
-
Each Claude Code window gets its own timer (per transcript).
|
|
432
|
-
"""
|
|
433
|
-
if os.name == 'nt':
|
|
434
|
-
base = Path(os.environ.get('LOCALAPPDATA', str(Path.home() / 'AppData' / 'Local')))
|
|
435
|
-
cache_dir = base / 'morph-spec' / 'cache'
|
|
436
|
-
else:
|
|
437
|
-
cache_dir = Path.home() / '.cache' / 'morph-spec'
|
|
438
|
-
|
|
439
|
-
now = time.time()
|
|
440
|
-
|
|
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
|
|
446
|
-
|
|
447
|
-
cwd_h = hashlib.sha256(cwd.encode()).hexdigest()[:16]
|
|
448
|
-
cwd_cache = cache_dir / f'sess-cwd-{cwd_h}.json'
|
|
449
|
-
|
|
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
|
|
457
|
-
|
|
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
|
-
|
|
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
|
|
481
|
-
|
|
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
|
|
491
|
-
|
|
492
|
-
return session_start
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
def get_session_duration(session_start):
|
|
496
|
-
"""Format elapsed time since session_start. Returns string or None."""
|
|
497
|
-
if session_start is None:
|
|
498
|
-
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"
|
|
508
|
-
|
|
509
|
-
|
|
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
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
520
|
-
|
|
521
|
-
def main():
|
|
522
|
-
try:
|
|
523
|
-
raw = sys.stdin.read()
|
|
524
|
-
if not raw.strip():
|
|
525
|
-
sys.exit(0)
|
|
526
|
-
data = json.loads(raw)
|
|
527
|
-
except Exception:
|
|
528
|
-
sys.exit(0)
|
|
529
|
-
|
|
530
|
-
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))
|
|
672
|
-
|
|
673
|
-
except Exception:
|
|
674
|
-
err = traceback.format_exc().splitlines()[-1]
|
|
675
|
-
print(f"{RED}ERRO: {err}{R}")
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if __name__ == '__main__':
|
|
679
|
-
main()
|
|
1
|
+
#!/usr/bin/env python3
|
|
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
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
import re
|
|
12
|
+
import hashlib
|
|
13
|
+
import traceback
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
# Ensure UTF-8 output on Windows (stdout defaults to CP1252 otherwise)
|
|
18
|
+
if hasattr(sys.stdout, 'reconfigure'):
|
|
19
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
20
|
+
|
|
21
|
+
# ANSI colors
|
|
22
|
+
R = '\033[0m' # Reset
|
|
23
|
+
BOLD = '\033[1m'
|
|
24
|
+
CYAN = '\033[36m'
|
|
25
|
+
MAGENTA = '\033[35m'
|
|
26
|
+
GREEN = '\033[32m'
|
|
27
|
+
YELLOW = '\033[33m'
|
|
28
|
+
RED = '\033[31m'
|
|
29
|
+
BLUE = '\033[34m'
|
|
30
|
+
GRAY = '\033[90m'
|
|
31
|
+
WHITE = '\033[97m'
|
|
32
|
+
|
|
33
|
+
# Windows: flag to hide console window when spawning subprocesses
|
|
34
|
+
_CREATE_NO_WINDOW = 0x08000000 if os.name == 'nt' else 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── MORPH framework constants (derived from phases.json / trust-manager.js) ──
|
|
38
|
+
|
|
39
|
+
PHASE_POSITIONS = {
|
|
40
|
+
'proposal': 1,
|
|
41
|
+
'uiux': 2, 'design': 2,
|
|
42
|
+
'plan': 3,
|
|
43
|
+
'tasks': 4,
|
|
44
|
+
'implement': 5, 'sync': 5,
|
|
45
|
+
}
|
|
46
|
+
PHASE_LABELS = {
|
|
47
|
+
'proposal': 'proposal',
|
|
48
|
+
'uiux': 'UI/UX',
|
|
49
|
+
'design': 'design',
|
|
50
|
+
'plan': 'planning',
|
|
51
|
+
'tasks': 'tasks',
|
|
52
|
+
'implement': 'implement',
|
|
53
|
+
'sync': 'sync',
|
|
54
|
+
}
|
|
55
|
+
PIPELINE_TOTAL = 5
|
|
56
|
+
|
|
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
|
+
}
|
|
64
|
+
|
|
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
|
+
|
|
74
|
+
CHECKPOINT_FREQUENCY = 3 # matches llm-interaction.json default
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── General helpers ──────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
def ctx_color(pct):
|
|
80
|
+
"""Color based on context usage. Thresholds from Claude Code docs: 70% / 90%."""
|
|
81
|
+
if pct < 70:
|
|
82
|
+
return GREEN
|
|
83
|
+
if pct < 90:
|
|
84
|
+
return YELLOW
|
|
85
|
+
return RED
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def progress_bar(pct, width=8):
|
|
89
|
+
filled = int(pct / 100 * width)
|
|
90
|
+
empty = width - filled
|
|
91
|
+
return f"{'█' * filled}{'░' * empty}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def format_tokens(n):
|
|
95
|
+
if n >= 1_000_000:
|
|
96
|
+
return f"{n / 1_000_000:.1f}m"
|
|
97
|
+
if n >= 1000:
|
|
98
|
+
return f"{n // 1000}k"
|
|
99
|
+
return str(n)
|
|
100
|
+
|
|
101
|
+
|
|
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
|
|
107
|
+
|
|
108
|
+
|
|
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
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── MORPH feature helpers ────────────────────────────────────────────────────
|
|
117
|
+
|
|
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
|
|
129
|
+
|
|
130
|
+
|
|
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
|
|
137
|
+
|
|
138
|
+
|
|
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}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_all_active_features(cwd, entries):
|
|
153
|
+
"""Return in_progress features that are active in the current session.
|
|
154
|
+
|
|
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 []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ── Activity log helpers ──────────────────────────────────────────────────────
|
|
203
|
+
|
|
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.
|
|
207
|
+
"""
|
|
208
|
+
activity_path = Path(cwd) / '.morph' / 'logs' / 'activity.json'
|
|
209
|
+
if not activity_path.exists():
|
|
210
|
+
return None
|
|
211
|
+
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
|
+
}
|
|
245
|
+
except Exception:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── Git helpers ───────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
def _run_git(args, cwd):
|
|
252
|
+
"""Run a git command safely on Windows (no console popup, with timeout)."""
|
|
253
|
+
return subprocess.check_output(
|
|
254
|
+
['git'] + args,
|
|
255
|
+
cwd=cwd, stderr=subprocess.DEVNULL, timeout=3,
|
|
256
|
+
creationflags=_CREATE_NO_WINDOW,
|
|
257
|
+
).decode().strip()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
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)
|
|
264
|
+
|
|
265
|
+
# Files with changes (staged + unstaged, no untracked)
|
|
266
|
+
files = 0
|
|
267
|
+
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()])
|
|
270
|
+
except Exception:
|
|
271
|
+
pass
|
|
272
|
+
|
|
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
|
|
285
|
+
|
|
286
|
+
# Commits ahead/behind remote
|
|
287
|
+
ahead, behind = 0, 0
|
|
288
|
+
try:
|
|
289
|
+
ab = _run_git(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], cwd)
|
|
290
|
+
ahead, behind = (int(x) for x in ab.split('\t'))
|
|
291
|
+
except Exception:
|
|
292
|
+
pass
|
|
293
|
+
|
|
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 ""
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def get_worktree_data(cwd):
|
|
311
|
+
"""Detect worktree status for cwd.
|
|
312
|
+
|
|
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.
|
|
317
|
+
"""
|
|
318
|
+
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}"
|
|
336
|
+
except Exception:
|
|
337
|
+
pass
|
|
338
|
+
return False, ""
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_recent_tool_calls(entries):
|
|
342
|
+
"""Scan the last 50 transcript entries for the most recent Skill and Agent tool calls.
|
|
343
|
+
|
|
344
|
+
Returns (last_skill_name, last_agent_name) — either may be None.
|
|
345
|
+
Looks at assistant messages containing tool_use blocks.
|
|
346
|
+
"""
|
|
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 = []
|
|
381
|
+
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
|
|
391
|
+
except Exception:
|
|
392
|
+
pass
|
|
393
|
+
return entries
|
|
394
|
+
|
|
395
|
+
|
|
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'):
|
|
401
|
+
continue
|
|
402
|
+
usage = (entry.get('message') or {}).get('usage') or {}
|
|
403
|
+
if not usage:
|
|
404
|
+
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."""
|
|
416
|
+
try:
|
|
417
|
+
return datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
|
|
418
|
+
except Exception:
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def get_session_start(cwd, transcript_path, entries):
|
|
423
|
+
"""Return Unix timestamp for the start of the current session.
|
|
424
|
+
|
|
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)
|
|
430
|
+
|
|
431
|
+
Each Claude Code window gets its own timer (per transcript).
|
|
432
|
+
"""
|
|
433
|
+
if os.name == 'nt':
|
|
434
|
+
base = Path(os.environ.get('LOCALAPPDATA', str(Path.home() / 'AppData' / 'Local')))
|
|
435
|
+
cache_dir = base / 'morph-spec' / 'cache'
|
|
436
|
+
else:
|
|
437
|
+
cache_dir = Path.home() / '.cache' / 'morph-spec'
|
|
438
|
+
|
|
439
|
+
now = time.time()
|
|
440
|
+
|
|
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
|
|
446
|
+
|
|
447
|
+
cwd_h = hashlib.sha256(cwd.encode()).hexdigest()[:16]
|
|
448
|
+
cwd_cache = cache_dir / f'sess-cwd-{cwd_h}.json'
|
|
449
|
+
|
|
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
|
|
457
|
+
|
|
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
|
+
|
|
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
|
|
481
|
+
|
|
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
|
|
491
|
+
|
|
492
|
+
return session_start
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def get_session_duration(session_start):
|
|
496
|
+
"""Format elapsed time since session_start. Returns string or None."""
|
|
497
|
+
if session_start is None:
|
|
498
|
+
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"
|
|
508
|
+
|
|
509
|
+
|
|
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
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
520
|
+
|
|
521
|
+
def main():
|
|
522
|
+
try:
|
|
523
|
+
raw = sys.stdin.read()
|
|
524
|
+
if not raw.strip():
|
|
525
|
+
sys.exit(0)
|
|
526
|
+
data = json.loads(raw)
|
|
527
|
+
except Exception:
|
|
528
|
+
sys.exit(0)
|
|
529
|
+
|
|
530
|
+
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))
|
|
672
|
+
|
|
673
|
+
except Exception:
|
|
674
|
+
err = traceback.format_exc().splitlines()[-1]
|
|
675
|
+
print(f"{RED}ERRO: {err}{R}")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
if __name__ == '__main__':
|
|
679
|
+
main()
|