@polymorphism-tech/morph-spec 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.morph/.morphversion +3 -3
  2. package/.morph/analytics/threads-log.jsonl +6 -44
  3. package/.morph/config/config.json +1 -1
  4. package/.morph/framework/standards/frontend/nextjs/nextjs-patterns.md +17 -0
  5. package/.morph/framework/templates/docs/user-stories.md +34 -0
  6. package/.morph/logs/tool-failures.log +7 -51
  7. package/.morph/memory/{pre-compact-2026-02-22T17-01-01-658Z.json → pre-compact-2026-02-23T15-43-03-521Z.json} +1 -1
  8. package/CLAUDE.md +77 -56
  9. package/framework/{skills/level-2-domains → agents}/ai-agents/ai-system-architect.md +1 -4
  10. package/framework/{skills/level-2-domains → agents}/architecture/po-pm-advisor.md +1 -2
  11. package/framework/{skills/level-2-domains → agents}/architecture/prompt-engineer.md +1 -2
  12. package/framework/{skills/level-2-domains → agents}/architecture/seo-growth-hacker.md +1 -2
  13. package/framework/{skills/level-2-domains → agents}/architecture/standards-architect.md +1 -4
  14. package/framework/agents/backend/api-designer.md +103 -0
  15. package/framework/{skills/level-2-domains → agents}/backend/dotnet-senior.md +1 -2
  16. package/framework/agents/backend/ef-modeler.md +119 -0
  17. package/framework/{skills/level-2-domains → agents}/backend/hangfire-orchestrator.md +1 -4
  18. package/framework/{skills/level-2-domains → agents}/backend/ms-agent-expert.md +1 -4
  19. package/framework/{skills/level-2-domains → agents}/frontend/blazor-builder.md +1 -4
  20. package/framework/{skills/level-2-domains → agents}/frontend/nextjs-expert.md +1 -4
  21. package/framework/{skills/level-2-domains → agents}/frontend/ui-ux-designer.md +1 -2
  22. package/framework/{skills/level-2-domains → agents}/infrastructure/azure-architect.md +1 -2
  23. package/framework/{skills/level-2-domains → agents}/infrastructure/azure-deploy-specialist.md +1 -2
  24. package/framework/{skills/level-2-domains → agents}/infrastructure/bicep-architect.md +1 -4
  25. package/framework/{skills/level-2-domains → agents}/infrastructure/container-specialist.md +1 -4
  26. package/framework/{skills/level-2-domains → agents}/infrastructure/devops-engineer.md +1 -4
  27. package/framework/{skills/level-2-domains → agents}/integrations/asaas-financial.md +1 -4
  28. package/framework/{skills/level-2-domains → agents}/integrations/azure-identity.md +1 -4
  29. package/framework/{skills/level-2-domains → agents}/integrations/clerk-auth.md +1 -4
  30. package/framework/{skills/level-2-domains → agents}/integrations/hangfire-integration.md +1 -2
  31. package/framework/{skills/level-2-domains → agents}/integrations/resend-email.md +1 -4
  32. package/framework/{skills/level-2-domains → agents}/quality/code-analyzer.md +1 -4
  33. package/framework/{skills/level-2-domains → agents}/quality/testing-specialist.md +1 -4
  34. package/framework/hooks/claude-code/statusline.py +384 -85
  35. package/framework/hooks/shared/phase-utils.js +129 -129
  36. package/framework/skills/README.md +66 -0
  37. package/framework/skills/level-0-meta/{brainstorming.md → brainstorming/SKILL.md} +3 -1
  38. package/framework/skills/level-0-meta/brainstorming/references/proposal-example.md +138 -0
  39. package/framework/skills/level-0-meta/{code-review.md → code-review/SKILL.md} +3 -2
  40. package/framework/skills/level-0-meta/code-review/references/review-example.md +164 -0
  41. package/framework/skills/level-0-meta/code-review/scripts/scan-csharp.mjs +121 -0
  42. package/framework/skills/level-0-meta/{morph-checklist.md → morph-checklist/SKILL.md} +2 -5
  43. package/framework/skills/{level-1-workflows/morph-replicate.md → level-0-meta/morph-replicate/SKILL.md} +6 -7
  44. package/framework/skills/level-0-meta/{simulation-checklist.md → simulation-checklist/SKILL.md} +3 -6
  45. package/framework/skills/level-0-meta/{tool-usage-guide.md → tool-usage-guide/SKILL.md} +1 -2
  46. package/framework/skills/level-0-meta/{verification-before-completion.md → verification-before-completion/SKILL.md} +3 -1
  47. package/framework/skills/level-0-meta/verification-before-completion/scripts/check-phase-outputs.mjs +110 -0
  48. package/framework/skills/level-1-workflows/{phase-clarify.md → phase-clarify/SKILL.md} +3 -3
  49. package/framework/skills/level-1-workflows/phase-clarify/references/clarifications-example.md +117 -0
  50. package/framework/skills/level-1-workflows/{phase-codebase-analysis.md → phase-codebase-analysis/SKILL.md} +2 -3
  51. package/framework/skills/level-1-workflows/{phase-design.md → phase-design/SKILL.md} +13 -185
  52. package/framework/skills/level-1-workflows/phase-design/references/spec-example.md +253 -0
  53. package/framework/skills/level-1-workflows/{phase-implement.md → phase-implement/SKILL.md} +3 -3
  54. package/framework/skills/level-1-workflows/phase-implement/references/recap-example.md +132 -0
  55. package/framework/skills/level-1-workflows/{phase-setup.md → phase-setup/SKILL.md} +2 -3
  56. package/framework/skills/level-1-workflows/{phase-tasks.md → phase-tasks/SKILL.md} +4 -3
  57. package/framework/skills/level-1-workflows/phase-tasks/references/tasks-example.md +231 -0
  58. package/framework/skills/level-1-workflows/phase-tasks/scripts/validate-tasks.mjs +112 -0
  59. package/framework/skills/level-1-workflows/{phase-uiux.md → phase-uiux/SKILL.md} +2 -3
  60. package/package.json +1 -1
  61. package/src/commands/project/init.js +4 -64
  62. package/src/commands/project/update.js +1 -62
  63. package/src/lib/detectors/claude-config-detector.js +1 -3
  64. package/src/utils/agents-installer.js +2 -2
  65. package/src/utils/skills-installer.js +59 -15
  66. package/.morph/context/README.md +0 -17
  67. package/framework/skills/level-2-domains/backend/api-designer.md +0 -66
  68. package/framework/skills/level-2-domains/backend/ef-modeler.md +0 -65
  69. package/framework/skills/level-3-technologies/README.md +0 -7
  70. package/framework/skills/level-4-patterns/README.md +0 -7
  71. /package/framework/{skills/level-2-domains → agents}/README.md +0 -0
@@ -1,14 +1,11 @@
1
1
  ---
2
2
  name: resend-email
3
- description: >
4
- Resend Email specialist for transactional email, email templates, and Resend API integration. Activates for keywords: email, resend, smtp, transactional, notifications, email templates.
3
+ description: Resend Email specialist for transactional email, HTML/React email templates, Resend API integration, and notification systems in .NET and Next.js. Use when implementing email notifications, integrating Resend API, creating transactional email templates, or configuring email delivery in MORPH-SPEC projects.
5
4
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
6
5
  ---
7
6
 
8
7
  # Resend Email
9
8
 
10
- > **Layer:** 2 | **Load:** on-keyword | **Keywords:** resend, email, envio, transactional, notification, send email, template
11
-
12
9
  Transactional email via Resend for .NET. REST API, no official SDK. Simple, developer-friendly.
13
10
 
14
11
  ## Setup
@@ -1,14 +1,11 @@
1
1
  ---
2
2
  name: code-analyzer
3
- description: >
4
- Code Analyzer specialist for refactoring, complexity analysis, code review, and technical debt assessment. Activates for keywords: refactor, analysis, code review, complexity, technical debt.
3
+ description: Code Analyzer specialist for refactoring, complexity analysis, technical debt assessment, and identifying code smells in .NET/TypeScript codebases. Use when analyzing code quality, identifying refactoring opportunities, assessing technical debt, or reviewing code for duplication and architecture violations.
5
4
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
6
5
  ---
7
6
 
8
7
  # Code Analyzer
9
8
 
10
- > **Layer:** 2 | **Load:** on-keyword | **Keywords:** analyze, review, refactor, clean, smell, duplicate, architecture, quality, debt, unused, dead code, code review, code quality
11
-
12
9
  Deep code analysis specialist. Automates architecture review, clean code checks, and duplication detection.
13
10
 
14
11
  > **Ref:** `framework/standards/core/coding.md` for naming conventions.
@@ -1,14 +1,11 @@
1
1
  ---
2
2
  name: testing-specialist
3
- description: >
4
- Testing Specialist for test strategy, coverage, TDD/BDD, xUnit, Vitest, Jest, and quality assurance. Activates for keywords: tests, coverage, tdd, bdd, vitest, jest, xunit, testing.
3
+ description: Testing Specialist for test strategy, TDD/BDD implementation, unit and integration tests using xUnit, Vitest, and Jest, and quality assurance automation. Use when designing test strategies, implementing TDD cycles, writing unit/integration tests, or improving test coverage in .NET and TypeScript projects.
5
4
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
6
5
  ---
7
6
 
8
7
  # Testing Specialist
9
8
 
10
- > **Layer:** 2 | **Load:** on-keyword | **Keywords:** test, testing, mock, fake, simulation, unit test, integration test
11
-
12
9
  Especialista em testes, mocking e simulação para projetos .NET.
13
10
 
14
11
  ## Strategy
@@ -8,7 +8,10 @@ import json
8
8
  import os
9
9
  import subprocess
10
10
  import time
11
+ import re
12
+ import hashlib
11
13
  from pathlib import Path
14
+ from datetime import datetime, timezone
12
15
 
13
16
  # Ensure UTF-8 output on Windows (stdout defaults to CP1252 otherwise)
14
17
  if hasattr(sys.stdout, 'reconfigure'):
@@ -27,10 +30,52 @@ GRAY = '\033[90m'
27
30
  WHITE = '\033[97m'
28
31
 
29
32
 
33
+ # ── MORPH framework constants (derived from phases.json / trust-manager.js) ──
34
+
35
+ # Ordered core phases for pipeline mini-map (5 positions, optional phases mapped)
36
+ # uiux maps to position 2 (same slot as design — they're mutually exclusive in practice)
37
+ PHASE_POSITIONS = {
38
+ 'proposal': 1, 'setup': 1,
39
+ 'uiux': 2, 'design': 2,
40
+ 'clarify': 3,
41
+ 'tasks': 4,
42
+ 'implement': 5, 'sync': 5,
43
+ }
44
+ PHASE_ABBREV = {
45
+ 'proposal': 'prop', 'setup': 'setup',
46
+ 'uiux': 'ui', 'design': 'design',
47
+ 'clarify': 'clarify', 'tasks': 'tasks',
48
+ 'implement': 'impl', 'sync': 'sync',
49
+ }
50
+ PIPELINE_TOTAL = 5
51
+
52
+ # Approval gates per phase (from phases.json pausePoints)
53
+ PHASE_GATES = {
54
+ 'proposal': 'proposal',
55
+ 'uiux': 'uiux',
56
+ 'design': 'design',
57
+ 'tasks': 'tasks',
58
+ }
59
+
60
+ # Trust level thresholds and badges (from trust-manager.js)
61
+ # passRate >= threshold → level
62
+ TRUST_LEVELS = [
63
+ (0.95, 'maximum', GREEN + BOLD, '◆◆◆◆'),
64
+ (0.90, 'high', GREEN, '◆◆◆○'),
65
+ (0.80, 'medium', YELLOW, '◆◆○○'),
66
+ (0.00, 'low', RED, '◆○○○'),
67
+ ]
68
+
69
+ CHECKPOINT_FREQUENCY = 3 # matches llm-interaction.json default
70
+
71
+
72
+ # ── General helpers ──────────────────────────────────────────────────────────
73
+
30
74
  def ctx_color(pct):
31
- if pct < 70:
75
+ """Color based on context usage. 80% = Claude's auto-compact threshold."""
76
+ if pct < 60:
32
77
  return GREEN
33
- if pct < 90:
78
+ if pct < 80:
34
79
  return YELLOW
35
80
  return RED
36
81
 
@@ -42,13 +87,113 @@ def progress_bar(pct, width=8):
42
87
 
43
88
 
44
89
  def format_tokens(n):
90
+ if n >= 1_000_000:
91
+ return f"{n / 1_000_000:.1f}m"
45
92
  if n >= 1000:
46
93
  return f"{n // 1000}k"
47
94
  return str(n)
48
95
 
49
96
 
97
+ # ── MORPH feature helpers ────────────────────────────────────────────────────
98
+
99
+ def calculate_trust(checkpoints):
100
+ """Return (level_str, color, badge, pass_rate) from checkpoint array."""
101
+ if not checkpoints:
102
+ return 'low', RED, '○○○○', 0.0
103
+ total = len(checkpoints)
104
+ passed = sum(1 for c in checkpoints if c.get('passed'))
105
+ rate = passed / total
106
+ for threshold, level, color, badge in TRUST_LEVELS:
107
+ if rate >= threshold:
108
+ return level, color, badge, rate
109
+ return 'low', RED, '○○○○', rate
110
+
111
+
112
+ def get_phase_minimap(phase):
113
+ """Return colored dot strip + phase abbrev, e.g. '●►○○○ design'."""
114
+ pos = PHASE_POSITIONS.get(phase)
115
+ if pos is None:
116
+ return None
117
+ dots = ''
118
+ for i in range(1, PIPELINE_TOTAL + 1):
119
+ if i < pos:
120
+ dots += f"{GREEN}●{R}"
121
+ elif i == pos:
122
+ dots += f"{CYAN}►{R}"
123
+ else:
124
+ dots += f"{GRAY}○{R}"
125
+ abbrev = PHASE_ABBREV.get(phase, phase)
126
+ return f"{dots} {CYAN}{abbrev}{R}"
127
+
128
+
129
+ def get_checkpoint_countdown(tasks_done):
130
+ """Tasks remaining until next checkpoint fires (frequency=3)."""
131
+ if tasks_done <= 0:
132
+ return None
133
+ remaining = CHECKPOINT_FREQUENCY - (tasks_done % CHECKPOINT_FREQUENCY)
134
+ return 0 if remaining == CHECKPOINT_FREQUENCY else remaining
135
+
136
+
137
+ def get_next_gate(phase, approval_gates):
138
+ """Return the upcoming gate for this phase if not yet triggered in state."""
139
+ gate_id = PHASE_GATES.get(phase)
140
+ if not gate_id:
141
+ return None
142
+ # If the gate already appears in approvalGates (approved or pending),
143
+ # it's either done or already shown as pending — don't duplicate.
144
+ if gate_id in (approval_gates or {}):
145
+ return None
146
+ return gate_id
147
+
148
+
149
+ def get_all_active_features(cwd):
150
+ """Return list of all in_progress features with enriched MORPH metadata."""
151
+ state_path = Path(cwd) / '.morph' / 'state.json'
152
+ if not state_path.exists():
153
+ return []
154
+ try:
155
+ state = json.loads(state_path.read_text())
156
+ features = state.get('features', {})
157
+ result = []
158
+ for name, feat in features.items():
159
+ if feat.get('status') != 'in_progress':
160
+ continue
161
+ phase = feat.get('phase', '?')
162
+ tasks = feat.get('tasks', {})
163
+ done = tasks.get('completed', 0)
164
+ total = tasks.get('total', 0)
165
+ gates = feat.get('approvalGates', {})
166
+ checkpts = feat.get('checkpoints', [])
167
+
168
+ pending = [g for g, v in gates.items() if not v.get('approved')]
169
+ trust_lvl, trust_color, trust_badge, trust_rate = calculate_trust(checkpts)
170
+ countdown = get_checkpoint_countdown(done)
171
+ next_gate = get_next_gate(phase, gates)
172
+ minimap = get_phase_minimap(phase)
173
+
174
+ result.append({
175
+ 'name': name,
176
+ 'phase': phase,
177
+ 'tasks_done': done,
178
+ 'tasks_total': total,
179
+ 'pending': pending[0] if pending else None,
180
+ 'trust_level': trust_lvl,
181
+ 'trust_color': trust_color,
182
+ 'trust_badge': trust_badge,
183
+ 'trust_rate': trust_rate,
184
+ 'countdown': countdown,
185
+ 'next_gate': next_gate,
186
+ 'minimap': minimap,
187
+ })
188
+ return result
189
+ except Exception:
190
+ return []
191
+
192
+
193
+ # ── Git helpers ───────────────────────────────────────────────────────────────
194
+
50
195
  def get_git_info(cwd):
51
- """Get git branch and file stats. Uses a 5s file cache to avoid lag."""
196
+ """Get git branch and diff stats. Uses a 5s file cache to avoid lag."""
52
197
  try:
53
198
  cache_file = Path(cwd) / '.morph' / '.git-cache'
54
199
  try:
@@ -64,31 +209,32 @@ def get_git_info(cwd):
64
209
  cwd=cwd, stderr=subprocess.DEVNULL, timeout=2
65
210
  ).decode().strip()
66
211
 
67
- status_out = subprocess.check_output(
68
- ['git', 'status', '--porcelain'],
69
- cwd=cwd, stderr=subprocess.DEVNULL, timeout=2
70
- ).decode()
71
-
72
- staged = sum(1 for l in status_out.splitlines() if l and l[0] in 'MADRC')
73
- modified = sum(1 for l in status_out.splitlines() if l and l[1] in 'MD')
74
- untracked = sum(1 for l in status_out.splitlines() if l.startswith('??'))
212
+ # Diff stats: insertions/deletions from staged + unstaged changes
213
+ ins, dels = 0, 0
214
+ for cmd in [['diff', '--shortstat'], ['diff', '--cached', '--shortstat']]:
215
+ try:
216
+ out = subprocess.check_output(
217
+ ['git'] + cmd, cwd=cwd, stderr=subprocess.DEVNULL, timeout=2
218
+ ).decode()
219
+ m = re.search(r'(\d+) insertion', out)
220
+ if m:
221
+ ins += int(m.group(1))
222
+ m = re.search(r'(\d+) deletion', out)
223
+ if m:
224
+ dels += int(m.group(1))
225
+ except Exception:
226
+ pass
75
227
 
76
228
  parts = [f"{BLUE} {branch}{R}"]
77
- if staged:
78
- parts.append(f"{GREEN}+{staged}{R}")
79
- if modified:
80
- parts.append(f"{YELLOW}~{modified}{R}")
81
- if untracked:
82
- parts.append(f"{GRAY}?{untracked}{R}")
229
+ if ins or dels:
230
+ parts.append(f"{GREEN}+{ins}{R}{GRAY},{R}{RED}-{dels}{R}")
83
231
 
84
232
  result = ' '.join(parts)
85
-
86
233
  try:
87
234
  cache_file.parent.mkdir(parents=True, exist_ok=True)
88
235
  cache_file.write_text(result)
89
236
  except Exception:
90
237
  pass
91
-
92
238
  return result
93
239
  except Exception:
94
240
  return ""
@@ -97,71 +243,164 @@ def get_git_info(cwd):
97
243
  def get_worktree_info(cwd):
98
244
  """Detect if running in a git worktree (not the main worktree)."""
99
245
  try:
100
- worktrees_out = subprocess.check_output(
246
+ out = subprocess.check_output(
101
247
  ['git', 'worktree', 'list', '--porcelain'],
102
248
  cwd=cwd, stderr=subprocess.DEVNULL, timeout=2
103
249
  ).decode()
104
-
105
- entries = []
106
- current = {}
107
- for line in worktrees_out.splitlines():
250
+ entries, current = [], {}
251
+ for line in out.splitlines():
108
252
  if line.startswith('worktree '):
109
253
  if current:
110
254
  entries.append(current)
111
255
  current = {'path': line.split(' ', 1)[1]}
112
256
  elif line.startswith('branch '):
113
257
  current['branch'] = line.split(' ', 1)[1]
114
-
115
258
  if current:
116
259
  entries.append(current)
117
-
118
260
  if len(entries) > 1:
119
- cwd_resolved = str(Path(cwd).resolve())
261
+ cwd_r = str(Path(cwd).resolve())
120
262
  for entry in entries[1:]:
121
- entry_path = str(Path(entry.get('path', '')).resolve())
122
- if cwd_resolved == entry_path:
123
- branch_ref = entry.get('branch', '')
124
- branch = branch_ref.replace('refs/heads/', '')
263
+ if str(Path(entry.get('path', '')).resolve()) == cwd_r:
264
+ branch = entry.get('branch', '').replace('refs/heads/', '')
125
265
  return f"{MAGENTA}worktree:{branch}{R}"
126
266
  except Exception:
127
267
  pass
128
268
  return ""
129
269
 
130
270
 
131
- def get_morph_info(cwd):
132
- """Read active feature info from .morph/state.json."""
133
- state_path = Path(cwd) / '.morph' / 'state.json'
134
- if not state_path.exists():
271
+ # ── Transcript / JSONL helpers ────────────────────────────────────────────────
272
+
273
+ def read_transcript_jsonl(path):
274
+ """Read and parse JSONL transcript file. Returns list of parsed entries."""
275
+ entries = []
276
+ try:
277
+ with open(path, 'r', encoding='utf-8') as f:
278
+ for line in f:
279
+ line = line.strip()
280
+ if not line:
281
+ continue
282
+ try:
283
+ entries.append(json.loads(line))
284
+ except Exception:
285
+ pass
286
+ except Exception:
287
+ pass
288
+ return entries
289
+
290
+
291
+ def get_token_metrics(entries):
292
+ """Sum token usage from all non-sidechain JSONL entries."""
293
+ total_input, total_output, total_cached = 0, 0, 0
294
+ for entry in entries:
295
+ if entry.get('isSidechain') or entry.get('isApiErrorMessage'):
296
+ continue
297
+ usage = (entry.get('message') or {}).get('usage') or {}
298
+ if not usage:
299
+ continue
300
+ total_input += usage.get('input_tokens', 0)
301
+ total_output += usage.get('output_tokens', 0)
302
+ total_cached += (
303
+ usage.get('cache_creation_input_tokens', 0) +
304
+ usage.get('cache_read_input_tokens', 0)
305
+ )
306
+ return {'input': total_input, 'output': total_output, 'cached': total_cached}
307
+
308
+
309
+ def parse_timestamp(ts):
310
+ """Parse ISO 8601 timestamp to Unix float. Returns None on error."""
311
+ try:
312
+ return datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
313
+ except Exception:
135
314
  return None
315
+
316
+
317
+ def get_session_duration(entries):
318
+ """Elapsed time since first message. Returns 'Xhr Ym' or None."""
319
+ now = time.time()
320
+ for entry in entries:
321
+ ts = entry.get('timestamp')
322
+ if not ts:
323
+ continue
324
+ t = parse_timestamp(ts)
325
+ if t is None:
326
+ continue
327
+ elapsed_s = now - t
328
+ hours = int(elapsed_s // 3600)
329
+ minutes = int((elapsed_s % 3600) // 60)
330
+ if hours == 0:
331
+ return f"{minutes}m"
332
+ elif minutes == 0:
333
+ return f"{hours}hr"
334
+ else:
335
+ return f"{hours}hr {minutes}m"
336
+ return None
337
+
338
+
339
+ def get_session_name(entries):
340
+ """Find the most recent /rename title. Returns string or None."""
341
+ for entry in reversed(entries):
342
+ if entry.get('type') == 'custom-title' and entry.get('customTitle'):
343
+ return entry['customTitle']
344
+ return None
345
+
346
+
347
+ def get_block_start(transcript_path, entries):
348
+ """Find start of current 5-hour billing block. Cached per transcript."""
349
+ h = hashlib.sha256(transcript_path.encode()).hexdigest()[:16]
350
+ cache_dir = Path.home() / '.cache' / 'morph-spec'
351
+ cache_file = cache_dir / f'block-{h}.json'
352
+ now = time.time()
353
+ block_s = 5 * 3600
354
+
136
355
  try:
137
- state = json.loads(state_path.read_text())
138
- features = state.get('features', {})
139
- active = [
140
- (name, feat)
141
- for name, feat in features.items()
142
- if feat.get('status') == 'in_progress'
143
- ]
144
- if not active:
145
- return None
146
-
147
- name, feat = active[0]
148
- phase = feat.get('phase', '?')
149
- tasks = feat.get('tasks', {})
150
- done = tasks.get('completed', 0)
151
- total = tasks.get('total', 0)
152
- gates = feat.get('approvalGates', {})
153
- pending = [g for g, v in gates.items() if not v.get('approved')]
154
-
155
- return {
156
- 'name': name,
157
- 'phase': phase,
158
- 'tasks_done': done,
159
- 'tasks_total': total,
160
- 'pending_approval': pending[0] if pending else None,
161
- }
356
+ if cache_file.exists():
357
+ cached = json.loads(cache_file.read_text())
358
+ start = cached.get('block_start')
359
+ if start and (now - start) < block_s:
360
+ return start
162
361
  except Exception:
362
+ pass
363
+
364
+ start = None
365
+ for entry in entries:
366
+ ts = entry.get('timestamp')
367
+ if not ts:
368
+ continue
369
+ t = parse_timestamp(ts)
370
+ if t is None:
371
+ continue
372
+ if (now - t) <= block_s:
373
+ start = t
374
+ break
375
+
376
+ if start is None:
163
377
  return None
164
378
 
379
+ try:
380
+ cache_dir.mkdir(parents=True, exist_ok=True)
381
+ cache_file.write_text(json.dumps({'block_start': start}))
382
+ except Exception:
383
+ pass
384
+ return start
385
+
386
+
387
+ def format_block_timer(block_start):
388
+ """Format block timer as 'bar Xhr Ym' relative to 5-hour window."""
389
+ now = time.time()
390
+ elapsed_s = max(0.0, now - block_start)
391
+ pct = min(elapsed_s / (5 * 3600) * 100, 100)
392
+ hours = int(elapsed_s // 3600)
393
+ minutes = int((elapsed_s % 3600) // 60)
394
+ if hours == 0:
395
+ time_str = f"{minutes}m"
396
+ elif minutes == 0:
397
+ time_str = f"{hours}hr"
398
+ else:
399
+ time_str = f"{hours}hr {minutes}m"
400
+ return f"{progress_bar(pct, 6)} {time_str}"
401
+
402
+
403
+ # ── Main ──────────────────────────────────────────────────────────────────────
165
404
 
166
405
  def main():
167
406
  try:
@@ -172,43 +411,103 @@ def main():
172
411
  except Exception:
173
412
  sys.exit(0)
174
413
 
175
- cwd = data.get('cwd', os.getcwd())
176
-
177
- # === LINE 1: Morph status (only if morph project has active feature) ===
178
- morph = get_morph_info(cwd)
179
- if morph:
180
- parts1 = [
181
- f"{CYAN}{BOLD}{morph['name']}{R}",
182
- f"{MAGENTA}phase:{morph['phase']}{R}",
183
- ]
184
- if morph['tasks_total'] > 0:
185
- pct = morph['tasks_done'] / morph['tasks_total'] * 100
414
+ cwd = data.get('cwd', os.getcwd())
415
+ transcript_path = data.get('transcript_path')
416
+
417
+ # Read JSONL transcript once — shared by session clock, block timer,
418
+ # token metrics, and session name.
419
+ entries = read_transcript_jsonl(transcript_path) if transcript_path else []
420
+
421
+ # ── MORPH feature lines (one line per active feature) ────────────────────
422
+ features = get_all_active_features(cwd)
423
+ for feat in features:
424
+ parts = [f"{CYAN}{BOLD}{feat['name']}{R}"]
425
+
426
+ # Phase pipeline mini-map: ●●►○○ design
427
+ if feat['minimap']:
428
+ parts.append(feat['minimap'])
429
+
430
+ # Task progress bar
431
+ if feat['tasks_total'] > 0:
432
+ pct = feat['tasks_done'] / feat['tasks_total'] * 100
186
433
  bar = progress_bar(pct, 6)
187
- parts1.append(f"{GREEN}{bar} {morph['tasks_done']}/{morph['tasks_total']}{R}")
188
- if morph['pending_approval']:
189
- parts1.append(f"{YELLOW}⏳ {morph['pending_approval']} pending{R}")
190
- print(' | '.join(parts1))
434
+ parts.append(f"{GREEN}{bar} {feat['tasks_done']}/{feat['tasks_total']}{R}")
191
435
 
192
- # === LINE 2: Session info (always shown) ===
436
+ # Checkpoint countdown: how many tasks until next validation fires
437
+ if feat['countdown'] is not None:
438
+ if feat['countdown'] == 0:
439
+ parts.append(f"{GREEN}ckpt!{R}") # just hit checkpoint
440
+ elif feat['countdown'] == 1:
441
+ parts.append(f"{YELLOW}ckpt:1{R}") # 1 task away — heads up
442
+ else:
443
+ parts.append(f"{GRAY}ckpt:{feat['countdown']}{R}")
444
+
445
+ # Trust level badge: ◆◆◆○ high
446
+ tc = feat['trust_color']
447
+ parts.append(f"{tc}{feat['trust_badge']}{R}")
448
+
449
+ # Pending approval gate (blocking — already triggered, not yet approved)
450
+ if feat['pending']:
451
+ parts.append(f"{YELLOW}⏳ {feat['pending']} pending{R}")
452
+
453
+ # Upcoming gate (not yet triggered — reminds what comes at end of phase)
454
+ if feat['next_gate']:
455
+ parts.append(f"{GRAY}→gate:{feat['next_gate']}{R}")
456
+
457
+ print(' | '.join(parts))
458
+
459
+ # ── Session info line (always shown) ─────────────────────────────────────
193
460
  parts2 = []
194
461
 
462
+ # Session name (set via /rename)
463
+ if entries:
464
+ session_name = get_session_name(entries)
465
+ if session_name:
466
+ parts2.append(f"{CYAN}{BOLD}📌 {session_name}{R}")
467
+
195
468
  # Model
196
- model = data.get('model', {})
469
+ model = data.get('model', {})
197
470
  model_name = model.get('display_name', model.get('id', ''))
198
471
  if model_name:
199
472
  short = model_name.replace('Claude ', '').replace(' (claude.ai)', '')
200
473
  parts2.append(f"{WHITE}{BOLD}🤖 {short}{R}")
201
474
 
202
- # Context window
475
+ # Session clock (elapsed time since first message)
476
+ if entries:
477
+ duration = get_session_duration(entries)
478
+ if duration:
479
+ parts2.append(f"{YELLOW}⏱ {duration}{R}")
480
+
481
+ # Block timer (progress through current 5-hour billing window)
482
+ if entries and transcript_path:
483
+ block_start = get_block_start(transcript_path, entries)
484
+ if block_start is not None:
485
+ parts2.append(f"{YELLOW}blk:{format_block_timer(block_start)}{R}")
486
+
487
+ # Context window (60% = yellow, 80% = red/auto-compact threshold)
203
488
  ctx = data.get('context_window', {})
204
489
  if ctx:
205
- used_pct = ctx.get('used_percentage', 0)
206
- cur = ctx.get('current_usage', 0)
490
+ used_pct = ctx.get('used_percentage', 0)
491
+ cur = ctx.get('current_usage', 0)
207
492
  total_ctx = ctx.get('context_window_size', 0)
208
- color = ctx_color(used_pct)
209
- bar = progress_bar(used_pct, 8)
210
- toks = f"{format_tokens(cur)}/{format_tokens(total_ctx)}"
211
- parts2.append(f"{color}{bar} {used_pct:.0f}% ({toks} tok){R}")
493
+ color = ctx_color(used_pct)
494
+ bar = progress_bar(used_pct, 8)
495
+ toks = f"{format_tokens(cur)}/{format_tokens(total_ctx)}"
496
+ suffix = f" {RED}~cmpct{R}" if used_pct >= 80 else ""
497
+ parts2.append(f"{color}{bar} {used_pct:.0f}%{R} ({toks}){suffix}")
498
+
499
+ # Token breakdown from JSONL (session totals: input / output / cached)
500
+ if entries:
501
+ tok = get_token_metrics(entries)
502
+ tok_parts = []
503
+ if tok['input']:
504
+ tok_parts.append(f"in:{format_tokens(tok['input'])}")
505
+ if tok['output']:
506
+ tok_parts.append(f"out:{format_tokens(tok['output'])}")
507
+ if tok['cached']:
508
+ tok_parts.append(f"↩{format_tokens(tok['cached'])}")
509
+ if tok_parts:
510
+ parts2.append(f"{GRAY}{' '.join(tok_parts)}{R}")
212
511
 
213
512
  # Cost
214
513
  cost = data.get('cost', {})
@@ -221,7 +520,7 @@ def main():
221
520
  if agent.get('name'):
222
521
  parts2.append(f"{BLUE}agent:{agent['name']}{R}")
223
522
 
224
- # Git info (cached, non-blocking)
523
+ # Git info (branch + diff stats, 5s cached)
225
524
  git = get_git_info(cwd)
226
525
  if git:
227
526
  parts2.append(git)