@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.
Files changed (162) hide show
  1. package/README.md +2 -2
  2. package/bin/validate.js +379 -379
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/commands/commit.md +166 -166
  7. package/framework/commands/morph-infra.md +215 -215
  8. package/framework/commands/morph-preflight.md +205 -205
  9. package/framework/commands/morph-troubleshoot.md +124 -124
  10. package/framework/hooks/claude-code/statusline.py +679 -679
  11. package/framework/hooks/claude-code/statusline.sh +11 -11
  12. package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
  13. package/framework/hooks/git/pre-commit/agents.sh +25 -25
  14. package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
  15. package/framework/hooks/git/pre-commit/specs.sh +50 -50
  16. package/framework/hooks/git/pre-push/run-tests.sh +44 -44
  17. package/framework/hooks/shared/activity-logger.js +160 -160
  18. package/framework/hooks/shared/stdin-reader.js +26 -26
  19. package/framework/rubrics/backend.json +41 -41
  20. package/framework/rules/ai-agents-standards.md +45 -45
  21. package/framework/rules/dotnet-templates.md +86 -86
  22. package/framework/rules/frontend-standards.md +13 -13
  23. package/framework/rules/infrastructure-standards.md +27 -27
  24. package/framework/rules/neon-standards.md +25 -25
  25. package/framework/rules/nextjs-standards.md +20 -20
  26. package/framework/rules/nextjs-templates.md +40 -40
  27. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  28. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  29. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  30. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  31. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  32. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  33. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  34. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  35. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  36. package/framework/skills/core/morph-review/SKILL.md +1 -1
  37. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  38. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  39. package/framework/standards/ai-agents/agent-session.md +160 -160
  40. package/framework/standards/ai-agents/batching.md +193 -193
  41. package/framework/standards/ai-agents/context-providers.md +188 -188
  42. package/framework/standards/ai-agents/mcp-server.md +250 -250
  43. package/framework/standards/ai-agents/mcp-tools.md +197 -197
  44. package/framework/standards/ai-agents/middleware-patterns.md +207 -207
  45. package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
  46. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
  47. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
  48. package/framework/standards/ai-agents/modalities-vision.md +201 -201
  49. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
  50. package/framework/standards/ai-agents/production.md +275 -275
  51. package/framework/standards/ai-agents/prompts-in-db.md +214 -214
  52. package/framework/standards/ai-agents/providers/model-registry.md +158 -158
  53. package/framework/standards/ai-agents/service-tier-flex.md +135 -135
  54. package/framework/standards/ai-agents/structured-output.md +249 -249
  55. package/framework/standards/ai-agents/sweet-spot.md +109 -109
  56. package/framework/templates/IDE-SNIPPETS.md +262 -262
  57. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  58. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
  59. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
  60. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
  61. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  62. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
  63. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
  64. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
  65. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
  66. package/framework/templates/code/dotnet/database/migration.cs +83 -83
  67. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
  68. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  69. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  70. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
  71. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  72. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  73. package/framework/templates/code/typescript/contracts.ts +167 -167
  74. package/framework/templates/docs/clarifications.md +253 -253
  75. package/framework/templates/docs/schema-analysis.md +119 -119
  76. package/framework/templates/docs/ui-components.md +52 -52
  77. package/framework/templates/docs/ui-design-system.md +76 -76
  78. package/framework/templates/docs/ui-flows.md +167 -167
  79. package/framework/templates/docs/ui-mockups.md +98 -98
  80. package/framework/templates/feature/decisions.md +122 -122
  81. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
  82. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  83. package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
  84. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  85. package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
  86. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  87. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
  88. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  89. package/framework/templates/infrastructure/docker/docker-compose.template.yml +50 -50
  90. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  91. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  92. package/framework/templates/infrastructure/github/README.md +56 -56
  93. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  94. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  95. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  96. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  97. package/framework/templates/integrations/asaas-client.cs +387 -387
  98. package/framework/templates/integrations/asaas-webhook.cs +351 -351
  99. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  100. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  101. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
  102. package/framework/templates/project/validate.js +120 -120
  103. package/framework/templates/state.template.json +17 -17
  104. package/framework/templates/ui/design-system.css +226 -226
  105. package/package.json +1 -1
  106. package/src/commands/dashboard/dashboard.js +53 -53
  107. package/src/commands/index.js +13 -13
  108. package/src/commands/mcp/mcp-setup.js +519 -519
  109. package/src/commands/project/checkpoint.js +209 -209
  110. package/src/commands/project/worktree.js +83 -1
  111. package/src/core/state/phase-state-machine.js +197 -197
  112. package/src/core/templates/template-renderer.js +477 -477
  113. package/src/dashboard/app/components/DetailPanel.jsx +11 -11
  114. package/src/dashboard/app/components/FlowGraph.jsx +17 -17
  115. package/src/dashboard/app/components/SearchFilter.jsx +22 -22
  116. package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
  117. package/src/dashboard/app/main.jsx +13 -13
  118. package/src/dashboard/app/pages/AgentMap.jsx +370 -370
  119. package/src/dashboard/app/pages/HookInspector.jsx +418 -418
  120. package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
  121. package/src/dashboard/app/pages/RulesMap.jsx +273 -273
  122. package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
  123. package/src/dashboard/app/pages/Standards.jsx +332 -332
  124. package/src/dashboard/app/styles/index.css +47 -47
  125. package/src/dashboard/index.html +12 -12
  126. package/src/dashboard/package.json +24 -24
  127. package/src/dashboard/scanner/hooks-scanner.js +41 -41
  128. package/src/dashboard/scanner/phases-scanner.js +15 -15
  129. package/src/dashboard/scanner/rubrics-scanner.js +12 -12
  130. package/src/dashboard/scanner/rules-scanner.js +37 -37
  131. package/src/dashboard/scanner/skills-scanner.js +43 -43
  132. package/src/dashboard/scanner/squads-scanner.js +13 -13
  133. package/src/dashboard/scanner/standards-scanner.js +26 -26
  134. package/src/dashboard/vite-plugin-morph-data.js +17 -17
  135. package/src/dashboard/vite.config.js +24 -24
  136. package/src/lib/agents/metrics-aggregator.js +56 -56
  137. package/src/lib/agents/metrics-calculator.js +125 -125
  138. package/src/lib/agents/output-collector.js +73 -73
  139. package/src/lib/detectors/claude-config-detector.js +152 -152
  140. package/src/lib/detectors/design-system-detector.js +231 -231
  141. package/src/lib/detectors/index.js +217 -217
  142. package/src/lib/generators/architecture-scanner.js +639 -639
  143. package/src/lib/generators/design-system-generator.js +508 -508
  144. package/src/lib/generators/index.js +13 -13
  145. package/src/lib/generators/settings-generator.js +209 -209
  146. package/src/lib/hooks/hook-executor.js +167 -167
  147. package/src/lib/installers/mcp-installer.js +719 -719
  148. package/src/lib/tasks/task-classifier.js +30 -30
  149. package/src/lib/tasks/task-parser.js +209 -209
  150. package/src/lib/validators/architecture/architecture-validator.js +259 -259
  151. package/src/lib/validators/checkpoint-persistence.js +39 -39
  152. package/src/lib/validators/contracts/contract-compliance-validator.js +274 -274
  153. package/src/lib/validators/css/css-validator.js +340 -340
  154. package/src/lib/validators/packages/package-validator.js +347 -347
  155. package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
  156. package/src/lib/validators/shared/issue-counter.js +14 -14
  157. package/src/lib/validators/validation-runner.js +349 -349
  158. package/src/utils/agent-id-mapper.js +11 -11
  159. package/src/utils/claude-settings-manager.js +300 -300
  160. package/src/utils/skills-installer.js +174 -174
  161. package/framework/templates/.idea/morph-templates.xml +0 -92
  162. 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()