@polymorphism-tech/morph-spec 4.8.5 → 4.8.7

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 CHANGED
@@ -3,7 +3,7 @@
3
3
  > Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
4
4
 
5
5
  **Package:** `@polymorphism-tech/morph-spec`
6
- **Version:** 4.8.5
6
+ **Version:** 4.8.7
7
7
  **Requires:** Node.js 18+, Claude Code
8
8
 
9
9
  ---
@@ -376,4 +376,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
376
376
 
377
377
  ---
378
378
 
379
- *morph-spec v4.8.5 by [Polymorphism Tech](https://polymorphism.tech)*
379
+ *morph-spec v4.8.7 by [Polymorphism Tech](https://polymorphism.tech)*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "4.8.5",
3
+ "version": "4.8.7",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -200,4 +200,4 @@ These files are never edited directly. Use CLI commands or `morph-spec update` i
200
200
 
201
201
  ---
202
202
 
203
- *morph-spec v4.8.5 by Polymorphism Tech*
203
+ *morph-spec v4.8.7 by Polymorphism Tech*
@@ -203,4 +203,4 @@ morph-spec doctor
203
203
 
204
204
  ---
205
205
 
206
- *morph-spec v4.8.5 by Polymorphism Tech*
206
+ *morph-spec v4.8.7 by Polymorphism Tech*
@@ -10,6 +10,7 @@ import subprocess
10
10
  import time
11
11
  import re
12
12
  import hashlib
13
+ import traceback
13
14
  from pathlib import Path
14
15
  from datetime import datetime, timezone
15
16
 
@@ -29,11 +30,12 @@ BLUE = '\033[34m'
29
30
  GRAY = '\033[90m'
30
31
  WHITE = '\033[97m'
31
32
 
33
+ # Windows: flag to hide console window when spawning subprocesses
34
+ _CREATE_NO_WINDOW = 0x08000000 if os.name == 'nt' else 0
35
+
32
36
 
33
37
  # ── MORPH framework constants (derived from phases.json / trust-manager.js) ──
34
38
 
35
- # Ordered core phases for pipeline mini-map (5 positions, optional phases mapped)
36
- # uiux maps to position 2 (same slot as design — they're mutually exclusive in practice)
37
39
  PHASE_POSITIONS = {
38
40
  'proposal': 1, 'setup': 1,
39
41
  'uiux': 2, 'design': 2,
@@ -41,11 +43,15 @@ PHASE_POSITIONS = {
41
43
  'tasks': 4,
42
44
  'implement': 5, 'sync': 5,
43
45
  }
44
- PHASE_ABBREV = {
45
- 'proposal': 'prop', 'setup': 'setup',
46
- 'uiux': 'ui', 'design': 'design',
47
- 'clarify': 'clarify', 'tasks': 'tasks',
48
- 'implement': 'impl', 'sync': 'sync',
46
+ PHASE_LABELS = {
47
+ 'proposal': 'proposal',
48
+ 'setup': 'setup',
49
+ 'uiux': 'UI/UX',
50
+ 'design': 'design',
51
+ 'clarify': 'refinement',
52
+ 'tasks': 'planning',
53
+ 'implement': 'implement',
54
+ 'sync': 'sync',
49
55
  }
50
56
  PIPELINE_TOTAL = 5
51
57
 
@@ -72,10 +78,10 @@ CHECKPOINT_FREQUENCY = 3 # matches llm-interaction.json default
72
78
  # ── General helpers ──────────────────────────────────────────────────────────
73
79
 
74
80
  def ctx_color(pct):
75
- """Color based on context usage. 80% = Claude's auto-compact threshold."""
76
- if pct < 60:
81
+ """Color based on context usage. Thresholds from Claude Code docs: 70% / 90%."""
82
+ if pct < 70:
77
83
  return GREEN
78
- if pct < 80:
84
+ if pct < 90:
79
85
  return YELLOW
80
86
  return RED
81
87
 
@@ -94,36 +100,33 @@ def format_tokens(n):
94
100
  return str(n)
95
101
 
96
102
 
103
+ def safe_int(val, default=0):
104
+ """Safely convert value to int, handling None from JSON null."""
105
+ if val is None: return default
106
+ try: return int(val)
107
+ except (TypeError, ValueError): return default
108
+
109
+
110
+ def safe_float(val, default=0.0):
111
+ """Safely convert value to float, handling None from JSON null."""
112
+ if val is None: return default
113
+ try: return float(val)
114
+ except (TypeError, ValueError): return default
115
+
116
+
97
117
  # ── MORPH feature helpers ────────────────────────────────────────────────────
98
118
 
99
119
  def calculate_trust(checkpoints):
100
- """Return (level_str, color, badge, pass_rate) from checkpoint array."""
120
+ """Return (level_str, color, pass_rate) from checkpoint array."""
101
121
  if not checkpoints:
102
- return 'low', RED, '○○○○', 0.0
122
+ return 'none', GRAY, None # no data — silent
103
123
  total = len(checkpoints)
104
124
  passed = sum(1 for c in checkpoints if c.get('passed'))
105
125
  rate = passed / total
106
- for threshold, level, color, badge in TRUST_LEVELS:
126
+ for threshold, level, color, _badge in TRUST_LEVELS:
107
127
  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}"
128
+ return level, color, rate
129
+ return 'low', RED, 0.0
127
130
 
128
131
 
129
132
  def get_checkpoint_countdown(tasks_done):
@@ -134,56 +137,59 @@ def get_checkpoint_countdown(tasks_done):
134
137
  return 0 if remaining == CHECKPOINT_FREQUENCY else remaining
135
138
 
136
139
 
137
- def 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
140
+ def get_session_feature_names(features_dict, entries):
141
+ """Return only feature names mentioned in the current session's transcript.
142
+ If a feature name appears in the transcript (tool calls, messages, results),
143
+ this session is actively working on it. Prevents showing features from other sessions.
144
+ """
145
+ if not features_dict or not entries:
146
+ return set()
147
+ # Serialize last 500 entries for search (avoids cost on long sessions)
148
+ recent = entries[-500:] if len(entries) > 500 else entries
149
+ transcript_text = json.dumps(recent, ensure_ascii=False)
150
+ return {name for name in features_dict if name in transcript_text}
147
151
 
148
152
 
149
- def get_all_active_features(cwd):
150
- """Return list of all in_progress features with enriched MORPH metadata."""
153
+ def get_all_active_features(cwd, entries):
154
+ """Return in_progress features that are active in the current session."""
151
155
  state_path = Path(cwd) / '.morph' / 'state.json'
152
156
  if not state_path.exists():
153
157
  return []
154
158
  try:
155
- state = json.loads(state_path.read_text())
159
+ state = json.loads(state_path.read_text(encoding='utf-8'))
156
160
  features = state.get('features', {})
161
+
162
+ # Filter to features belonging to this session (mentioned in transcript)
163
+ session_names = get_session_feature_names(features, entries)
164
+ if not session_names:
165
+ return []
166
+
157
167
  result = []
158
168
  for name, feat in features.items():
159
169
  if feat.get('status') != 'in_progress':
160
170
  continue
161
- 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)
171
+ if name not in session_names:
172
+ continue
173
+ phase = feat.get('phase', '?')
174
+ tasks = feat.get('tasks') or {}
175
+ done = safe_int(tasks.get('completed'))
176
+ total = safe_int(tasks.get('total'))
177
+ gates = feat.get('approvalGates') or {}
178
+ checkpts = feat.get('checkpoints') or []
179
+
180
+ pending = [g for g, v in gates.items() if not v.get('approved')]
181
+ trust_lvl, trust_color, trust_rate = calculate_trust(checkpts)
182
+ countdown = get_checkpoint_countdown(done)
173
183
 
174
184
  result.append({
175
- 'name': 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,
185
+ 'name': name,
186
+ 'phase': phase,
187
+ 'tasks_done': done,
188
+ 'tasks_total': total,
189
+ 'pending': pending[0] if pending else None,
190
+ 'trust_level': trust_lvl,
191
+ 'trust_color': trust_color,
192
+ 'countdown': countdown,
187
193
  })
188
194
  return result
189
195
  except Exception:
@@ -192,50 +198,61 @@ def get_all_active_features(cwd):
192
198
 
193
199
  # ── Git helpers ───────────────────────────────────────────────────────────────
194
200
 
201
+ def _run_git(args, cwd):
202
+ """Run a git command safely on Windows (no console popup, with timeout)."""
203
+ return subprocess.check_output(
204
+ ['git'] + args,
205
+ cwd=cwd, stderr=subprocess.DEVNULL, timeout=3,
206
+ creationflags=_CREATE_NO_WINDOW,
207
+ ).decode().strip()
208
+
209
+
195
210
  def get_git_info(cwd):
196
- """Get git branch and diff stats. Uses a 5s file cache to avoid lag."""
211
+ """Get git branch, files changed, line diff, and ahead/behind remote."""
197
212
  try:
198
- cache_file = Path(cwd) / '.morph' / '.git-cache'
213
+ branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)
214
+
215
+ # Files with changes (staged + unstaged, no untracked)
216
+ files = 0
199
217
  try:
200
- if cache_file.exists():
201
- age = time.time() - cache_file.stat().st_mtime
202
- if age < 5:
203
- return cache_file.read_text().strip()
218
+ status_out = _run_git(['status', '--porcelain', '--untracked-files=no'], cwd)
219
+ files = len([l for l in status_out.splitlines() if l.strip()])
204
220
  except Exception:
205
221
  pass
206
222
 
207
- branch = subprocess.check_output(
208
- ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
209
- cwd=cwd, stderr=subprocess.DEVNULL, timeout=2
210
- ).decode().strip()
211
-
212
- # Diff stats: insertions/deletions from staged + unstaged changes
223
+ # Lines inserted/removed vs HEAD
213
224
  ins, dels = 0, 0
214
- 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
225
+ try:
226
+ out = _run_git(['diff', 'HEAD', '--shortstat'], cwd)
227
+ m = re.search(r'(\d+) insertion', out)
228
+ if m:
229
+ ins = int(m.group(1))
230
+ m = re.search(r'(\d+) deletion', out)
231
+ if m:
232
+ dels = int(m.group(1))
233
+ except Exception:
234
+ pass
235
+
236
+ # Commits ahead/behind remote
237
+ ahead, behind = 0, 0
238
+ try:
239
+ ab = _run_git(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], cwd)
240
+ ahead, behind = (int(x) for x in ab.split('\t'))
241
+ except Exception:
242
+ pass
227
243
 
228
244
  parts = [f"{BLUE} {branch}{R}"]
245
+ if ahead or behind:
246
+ ab_str = ''
247
+ if ahead: ab_str += f"{GREEN}↑{ahead}{R}"
248
+ if behind: ab_str += f"{RED}↓{behind}{R}"
249
+ parts.append(ab_str)
250
+ if files:
251
+ parts.append(f"{GRAY}{files} files{R}")
229
252
  if ins or dels:
230
253
  parts.append(f"{GREEN}+{ins}{R}{GRAY},{R}{RED}-{dels}{R}")
231
254
 
232
- result = ' '.join(parts)
233
- try:
234
- cache_file.parent.mkdir(parents=True, exist_ok=True)
235
- cache_file.write_text(result)
236
- except Exception:
237
- pass
238
- return result
255
+ return ' '.join(parts)
239
256
  except Exception:
240
257
  return ""
241
258
 
@@ -243,10 +260,7 @@ def get_git_info(cwd):
243
260
  def get_worktree_info(cwd):
244
261
  """Detect if running in a git worktree (not the main worktree)."""
245
262
  try:
246
- out = subprocess.check_output(
247
- ['git', 'worktree', 'list', '--porcelain'],
248
- cwd=cwd, stderr=subprocess.DEVNULL, timeout=2
249
- ).decode()
263
+ out = _run_git(['worktree', 'list', '--porcelain'], cwd)
250
264
  entries, current = [], {}
251
265
  for line in out.splitlines():
252
266
  if line.startswith('worktree '):
@@ -314,95 +328,101 @@ def parse_timestamp(ts):
314
328
  return None
315
329
 
316
330
 
317
- def 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
331
+ def get_session_start(cwd, transcript_path, entries):
332
+ """Return Unix timestamp for the start of the current session.
345
333
 
334
+ Uses two caches:
335
+ - sess-tp-* : keyed by transcript_path — persists the start for this
336
+ session even after long absences (wall-clock elapsed)
337
+ - sess-cwd-* : keyed by cwd, 2-min window — detects plan→accept
338
+ (new transcript created seconds after the previous one)
346
339
 
347
- 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
- # Use platform-appropriate cache directory
340
+ Each Claude Code window gets its own timer (per transcript).
341
+ """
351
342
  if os.name == 'nt':
352
343
  base = Path(os.environ.get('LOCALAPPDATA', str(Path.home() / 'AppData' / 'Local')))
353
344
  cache_dir = base / 'morph-spec' / 'cache'
354
345
  else:
355
346
  cache_dir = Path.home() / '.cache' / 'morph-spec'
356
- cache_file = cache_dir / f'block-{h}.json'
357
- now = time.time()
358
- block_s = 5 * 3600
359
347
 
348
+ now = time.time()
349
+
350
+ if transcript_path:
351
+ tp_h = hashlib.sha256(transcript_path.encode()).hexdigest()[:16]
352
+ tp_cache = cache_dir / f'sess-tp-{tp_h}.json'
353
+ else:
354
+ tp_cache = None
355
+
356
+ cwd_h = hashlib.sha256(cwd.encode()).hexdigest()[:16]
357
+ cwd_cache = cache_dir / f'sess-cwd-{cwd_h}.json'
358
+
359
+ # 1. This transcript already has a recorded start?
360
+ session_start = None
360
361
  try:
361
- if cache_file.exists():
362
- cached = json.loads(cache_file.read_text())
363
- start = cached.get('block_start')
364
- if start and (now - start) < block_s:
365
- return start
362
+ if tp_cache and tp_cache.exists():
363
+ session_start = json.loads(tp_cache.read_text()).get('session_start')
366
364
  except Exception:
367
365
  pass
368
366
 
369
- start = None
370
- for entry in entries:
371
- ts = entry.get('timestamp')
372
- if not ts:
373
- continue
374
- t = parse_timestamp(ts)
375
- if t is None:
376
- continue
377
- if (now - t) <= block_s:
378
- start = t
379
- break
367
+ # 2. New transcript — continuation from plan→accept? (cwd gap < 2 min)
368
+ if session_start is None:
369
+ try:
370
+ if cwd_cache.exists():
371
+ cwd_data = json.loads(cwd_cache.read_text())
372
+ if (now - cwd_data.get('last_seen', 0)) < 120:
373
+ session_start = cwd_data.get('session_start')
374
+ except Exception:
375
+ pass
376
+
377
+ # 3. Genuinely new session — use first entry from current transcript
378
+ if session_start is None:
379
+ for entry in entries:
380
+ ts = entry.get('timestamp')
381
+ if not ts:
382
+ continue
383
+ t = parse_timestamp(ts)
384
+ if t is not None:
385
+ session_start = t
386
+ break
380
387
 
381
- if start is None:
388
+ if session_start is None:
382
389
  return None
383
390
 
391
+ # Persist both caches
384
392
  try:
385
393
  cache_dir.mkdir(parents=True, exist_ok=True)
386
- cache_file.write_text(json.dumps({'block_start': start}))
394
+ payload = json.dumps({'session_start': session_start, 'last_seen': now})
395
+ if tp_cache:
396
+ tp_cache.write_text(payload)
397
+ cwd_cache.write_text(payload)
387
398
  except Exception:
388
399
  pass
389
- return start
400
+
401
+ return session_start
390
402
 
391
403
 
392
- def format_block_timer(block_start):
393
- """Format block timer as 'bar Xhr Ym' relative to 5-hour window."""
394
- now = time.time()
395
- elapsed_s = max(0.0, now - block_start)
396
- pct = min(elapsed_s / (5 * 3600) * 100, 100)
397
- hours = int(elapsed_s // 3600)
398
- minutes = int((elapsed_s % 3600) // 60)
404
+ def get_session_duration(session_start):
405
+ """Format elapsed time since session_start. Returns string or None."""
406
+ if session_start is None:
407
+ return None
408
+ elapsed_s = time.time() - session_start
409
+ hours = int(elapsed_s // 3600)
410
+ minutes = int((elapsed_s % 3600) // 60)
399
411
  if hours == 0:
400
- time_str = f"{minutes}m"
412
+ return f"{minutes}m"
401
413
  elif minutes == 0:
402
- time_str = f"{hours}hr"
414
+ return f"{hours}hr"
403
415
  else:
404
- time_str = f"{hours}hr {minutes}m"
405
- return f"{progress_bar(pct, 6)} {time_str}"
416
+ return f"{hours}hr {minutes}m"
417
+
418
+
419
+ def get_session_name(entries):
420
+ """Find the most recent /rename title. Returns string or None."""
421
+ for entry in reversed(entries):
422
+ if entry.get('type') == 'custom-title' and entry.get('customTitle'):
423
+ return entry['customTitle']
424
+ return None
425
+
406
426
 
407
427
 
408
428
  # ── Main ──────────────────────────────────────────────────────────────────────
@@ -416,127 +436,143 @@ def main():
416
436
  except Exception:
417
437
  sys.exit(0)
418
438
 
419
- cwd = data.get('cwd', os.getcwd())
420
- transcript_path = data.get('transcript_path')
421
-
422
- # Read JSONL transcript once — shared by session clock, block timer,
423
- # token metrics, and session name.
424
- entries = read_transcript_jsonl(transcript_path) if transcript_path else []
425
-
426
- # ── MORPH feature lines (one line per active feature) ────────────────────
427
- features = get_all_active_features(cwd)
428
- for feat in features:
429
- parts = [f"{CYAN}{BOLD}{feat['name']}{R}"]
430
-
431
- # Phase pipeline mini-map: ●●►○○ design
432
- if feat['minimap']:
433
- parts.append(feat['minimap'])
434
-
435
- # Task progress bar
436
- if feat['tasks_total'] > 0:
437
- pct = feat['tasks_done'] / feat['tasks_total'] * 100
438
- bar = progress_bar(pct, 6)
439
- parts.append(f"{GREEN}{bar} {feat['tasks_done']}/{feat['tasks_total']}{R}")
440
-
441
- # Checkpoint countdown: how many tasks until next validation fires
442
- if feat['countdown'] is not None:
439
+ try:
440
+ cwd = data.get('cwd', os.getcwd())
441
+ transcript_path = data.get('transcript_path')
442
+
443
+ # Read JSONL transcript once — shared by session clock, block timer,
444
+ # token metrics, and session name.
445
+ entries = read_transcript_jsonl(transcript_path) if transcript_path else []
446
+
447
+ # ── MORPH feature lines (one line per active feature) ────────────────────
448
+ features = get_all_active_features(cwd, entries)
449
+ for feat in features:
450
+ # Feature name with visual prefix
451
+ parts = [f"{CYAN}{BOLD}► {feat['name']}{R}"]
452
+
453
+ # Phase: human-readable label + position in pipeline (e.g. "implement 4/5")
454
+ phase = feat['phase']
455
+ pos = PHASE_POSITIONS.get(phase)
456
+ label = PHASE_LABELS.get(phase, phase)
457
+ if pos:
458
+ parts.append(f"{CYAN}{label} {pos}/{PIPELINE_TOTAL}{R}")
459
+ elif phase != '?':
460
+ parts.append(f"{CYAN}{label}{R}")
461
+
462
+ # Task progress (only if tasks exist)
463
+ if feat['tasks_total'] > 0:
464
+ pct = feat['tasks_done'] / feat['tasks_total'] * 100
465
+ bar = progress_bar(pct, 6)
466
+ parts.append(f"{GREEN}{bar} {feat['tasks_done']}/{feat['tasks_total']} tasks{R}")
467
+
468
+ # Checkpoint: only show when imminent (0 = now, 1 = next task)
443
469
  if feat['countdown'] == 0:
444
- parts.append(f"{GREEN}ckpt!{R}") # just hit checkpoint
470
+ parts.append(f"{GREEN}💾 checkpoint!{R}")
445
471
  elif feat['countdown'] == 1:
446
- parts.append(f"{YELLOW}ckpt:1{R}") # 1 task away — heads up
447
- else:
448
- parts.append(f"{GRAY}ckpt:{feat['countdown']}{R}")
449
-
450
- # Trust level badge: ◆◆◆○ high
451
- tc = feat['trust_color']
452
- parts.append(f"{tc}{feat['trust_badge']}{R}")
472
+ parts.append(f"{YELLOW}💾 in 1 task{R}")
453
473
 
454
- # Pending approval gate (blocking already triggered, not yet approved)
455
- if feat['pending']:
456
- parts.append(f"{YELLOW} {feat['pending']} pending{R}")
474
+ # Trust: silent when good, alert only if problematic
475
+ if feat['trust_level'] == 'low':
476
+ parts.append(f"{RED} low trust{R}")
477
+ elif feat['trust_level'] == 'medium':
478
+ parts.append(f"{YELLOW}⚠ medium trust{R}")
457
479
 
458
- # Upcoming gate (not yet triggered — reminds what comes at end of phase)
459
- if feat['next_gate']:
460
- parts.append(f"{GRAY}→gate:{feat['next_gate']}{R}")
480
+ # Blocking approval gate
481
+ if feat['pending']:
482
+ parts.append(f"{RED}⛔ approval required{R}")
461
483
 
462
- print(' | '.join(parts))
484
+ print(' | '.join(parts))
463
485
 
464
- # ── Session info line (always shown) ─────────────────────────────────────
465
- parts2 = []
486
+ # ── Session info line (always shown) ─────────────────────────────────────
487
+ parts2 = []
466
488
 
467
- # Session name (set via /rename)
468
- if entries:
469
- session_name = get_session_name(entries)
470
- if session_name:
471
- parts2.append(f"{CYAN}{BOLD}📌 {session_name}{R}")
489
+ # Session name (set via /rename)
490
+ if entries:
491
+ session_name = get_session_name(entries)
492
+ if session_name:
493
+ parts2.append(f"{CYAN}{BOLD}📌 {session_name}{R}")
472
494
 
473
- # Model
474
- model = data.get('model', {})
475
- model_name = model.get('display_name', model.get('id', ''))
476
- if model_name:
477
- short = model_name.replace('Claude ', '').replace(' (claude.ai)', '')
478
- parts2.append(f"{WHITE}{BOLD}🤖 {short}{R}")
479
-
480
- # Session clock (elapsed time since first message)
481
- if entries:
482
- duration = get_session_duration(entries)
495
+ # Model
496
+ model = data.get('model') or ''
497
+ if isinstance(model, dict):
498
+ model_name = model.get('display_name') or model.get('id') or ''
499
+ else:
500
+ model_name = str(model) if model else ''
501
+ if model_name:
502
+ short = model_name.replace('Claude ', '').replace('claude-', '').replace(' (claude.ai)', '')
503
+ parts2.append(f"{WHITE}{BOLD}🤖 {short}{R}")
504
+
505
+ # Permission mode (only shown when non-default)
506
+ perm = data.get('permission_mode') or ''
507
+ if perm and perm != 'default':
508
+ _perm_labels = {
509
+ 'plan': '📋 plan',
510
+ 'acceptEdits': '✏ accept edits',
511
+ 'bypassPermissions': '⚠ bypass',
512
+ 'dontAsk': '🔕 auto',
513
+ }
514
+ parts2.append(f"{YELLOW}{_perm_labels.get(perm, perm)}{R}")
515
+
516
+ # Session clock (elapsed time since session start, survives transcript transitions)
517
+ session_start = get_session_start(cwd, transcript_path, entries)
518
+ duration = get_session_duration(session_start)
483
519
  if duration:
484
520
  parts2.append(f"{YELLOW}⏱ {duration}{R}")
485
521
 
486
- # Block timer (progress through current 5-hour billing window)
487
- if entries and transcript_path:
488
- block_start = get_block_start(transcript_path, entries)
489
- if block_start is not None:
490
- parts2.append(f"{YELLOW}blk:{format_block_timer(block_start)}{R}")
491
-
492
- # Context window (60% = yellow, 80% = red/auto-compact threshold)
493
- ctx = data.get('context_window', {})
494
- if ctx:
495
- used_pct = ctx.get('used_percentage', 0)
496
- cur = ctx.get('current_usage', 0)
497
- total_ctx = ctx.get('context_window_size', 0)
498
- color = ctx_color(used_pct)
499
- bar = progress_bar(used_pct, 8)
500
- toks = f"{format_tokens(cur)}/{format_tokens(total_ctx)}"
501
- suffix = f" {RED}~cmpct{R}" if used_pct >= 80 else ""
502
- parts2.append(f"{color}{bar} {used_pct:.0f}%{R} ({toks}){suffix}")
503
-
504
- # Token breakdown from JSONL (session totals: input / output / cached)
505
- if entries:
506
- tok = get_token_metrics(entries)
507
- tok_parts = []
508
- if tok['input']:
509
- tok_parts.append(f"in:{format_tokens(tok['input'])}")
510
- if tok['output']:
511
- tok_parts.append(f"out:{format_tokens(tok['output'])}")
512
- if tok['cached']:
513
- tok_parts.append(f"↩{format_tokens(tok['cached'])}")
514
- if tok_parts:
515
- parts2.append(f"{GRAY}{' '.join(tok_parts)}{R}")
516
-
517
- # Cost
518
- cost = data.get('cost', {})
519
- if cost.get('total_cost_usd'):
520
- usd = cost['total_cost_usd']
521
- parts2.append(f"{GRAY}${usd:.3f}{R}")
522
-
523
- # Agent name (if running in agent mode)
524
- agent = data.get('agent', {})
525
- if agent.get('name'):
526
- parts2.append(f"{BLUE}agent:{agent['name']}{R}")
527
-
528
- # Git info (branch + diff stats, 5s cached)
529
- git = get_git_info(cwd)
530
- if git:
531
- parts2.append(git)
532
-
533
- # Worktree info
534
- wt = get_worktree_info(cwd)
535
- if wt:
536
- parts2.append(wt)
537
-
538
- if parts2:
539
- print(' | '.join(parts2))
522
+ # Context window
523
+ # used_percentage excludes output_tokens — underestimates real usage in long sessions.
524
+ # Compute (input + cache_creation + cache_read + output) / context_window_size,
525
+ # which reflects true context consumption for the next turn.
526
+ ctx = data.get('context_window', {})
527
+ if ctx:
528
+ cur_usage = ctx.get('current_usage') or {}
529
+ total_ctx = safe_int(ctx.get('context_window_size'))
530
+ if isinstance(cur_usage, dict) and total_ctx > 0:
531
+ tokens = (safe_int(cur_usage.get('input_tokens'))
532
+ + safe_int(cur_usage.get('cache_creation_input_tokens'))
533
+ + safe_int(cur_usage.get('cache_read_input_tokens'))
534
+ + safe_int(cur_usage.get('output_tokens')))
535
+ used_pct = tokens / total_ctx * 100 if tokens > 0 else safe_float(ctx.get('used_percentage'))
536
+ else:
537
+ used_pct = safe_float(ctx.get('used_percentage'))
538
+ color = ctx_color(used_pct)
539
+ bar = progress_bar(used_pct, 8)
540
+ toks = f"{format_tokens(tokens)}/{format_tokens(total_ctx)}" if total_ctx and isinstance(cur_usage, dict) else ""
541
+ suffix = f" {RED}~cmpct{R}" if used_pct >= 90 else ""
542
+ line = f"{color}{bar} {used_pct:.0f}%{R}"
543
+ if toks:
544
+ line += f" ({toks})"
545
+ parts2.append(line + suffix)
546
+
547
+ # Token breakdown from JSONL (session totals: input / output / cached)
548
+ if entries:
549
+ tok = get_token_metrics(entries)
550
+ tok_parts = []
551
+ if tok['input']:
552
+ tok_parts.append(f"in:{format_tokens(tok['input'])}")
553
+ if tok['output']:
554
+ tok_parts.append(f"out:{format_tokens(tok['output'])}")
555
+ if tok['cached']:
556
+ tok_parts.append(f"↩{format_tokens(tok['cached'])}")
557
+ if tok_parts:
558
+ parts2.append(f"{GRAY}{' '.join(tok_parts)}{R}")
559
+
560
+ # Git info (branch + diff stats)
561
+ git = get_git_info(cwd)
562
+ if git:
563
+ parts2.append(git)
564
+
565
+ # Worktree info
566
+ wt = get_worktree_info(cwd)
567
+ if wt:
568
+ parts2.append(wt)
569
+
570
+ if parts2:
571
+ print(' | '.join(parts2))
572
+
573
+ except Exception:
574
+ err = traceback.format_exc().splitlines()[-1]
575
+ print(f"{RED}ERRO: {err}{R}")
540
576
 
541
577
 
542
578
  if __name__ == '__main__':
@@ -7,7 +7,7 @@
7
7
  * Scope: Framework codebase only (dev hook)
8
8
  *
9
9
  * Blocks Write/Edit to files that contain hardcoded version patterns
10
- * like "MORPH-SPEC v4.8.5". Version should only be in package.json.
10
+ * like "MORPH-SPEC v4.8.7". Version should only be in package.json.
11
11
  *
12
12
  * Checked extensions: .md, .cs, .css, .js (covers templates + source)
13
13
  * Exceptions: CHANGELOG.md, node_modules/, test/, package.json, package-lock.json
@@ -14,12 +14,33 @@ allowed-tools: Read, Write, Edit, Bash, Glob, Grep
14
14
 
15
15
  # morph-init — LLM-Powered Project Initialization
16
16
 
17
- > Run once after `npm install -g @polymorphism-tech/morph-spec`.
17
+ > Run once after `npm install @polymorphism-tech/morph-spec`.
18
18
  > Re-run as `/morph-init refresh` when your project evolves.
19
19
 
20
20
  ---
21
21
 
22
- ## Step 0 — Required Plugins
22
+ ## Step 0 — Resolve CLI
23
+
24
+ Before running any `morph-spec` command, detect how to invoke the CLI:
25
+
26
+ ```bash
27
+ # Try local first (project dependency), then global
28
+ npx morph-spec --version
29
+ ```
30
+
31
+ - If `npx morph-spec --version` succeeds → use `npx morph-spec` as the CLI prefix for all commands.
32
+ - If it fails → try `morph-spec --version` (global install). If that works → use `morph-spec`.
33
+ - If both fail → **STOP** and show:
34
+ ```
35
+ morph-spec CLI not found. Install it first:
36
+ npm install @polymorphism-tech/morph-spec
37
+ ```
38
+
39
+ Use the resolved CLI prefix (e.g. `npx morph-spec`) for ALL bash commands in the following steps.
40
+
41
+ ---
42
+
43
+ ## Step 1 — Required Plugins
23
44
 
24
45
  Check for required Claude Code plugins: **superpowers** and **context7**.
25
46
 
@@ -30,8 +51,8 @@ Read `~/.claude/plugins/installed_plugins.json` and check for:
30
51
  For each missing plugin, run:
31
52
 
32
53
  ```bash
33
- morph-spec install-plugin superpowers
34
- morph-spec install-plugin context7
54
+ npx morph-spec install-plugin superpowers
55
+ npx morph-spec install-plugin context7
35
56
  ```
36
57
 
37
58
  **If the command succeeds:** `✓ {plugin} installed. Restart Claude Code after /morph-init completes.`
@@ -53,25 +74,30 @@ Both `superpowers` and `context7` are required. Do not continue if either is mis
53
74
 
54
75
  ---
55
76
 
56
- ## Step 1 — Infrastructure
77
+ ## Step 2 — Infrastructure
57
78
 
58
79
  Check if `.morph/` exists in the current directory.
59
80
 
60
81
  **If MISSING:**
61
82
  ```bash
62
- morph-spec setup-infra
83
+ npx morph-spec setup-infra
63
84
  ```
64
85
  Output confirms: `✓ MORPH-SPEC infrastructure installed.`
65
86
 
66
- **If EXISTS and argument is `refresh`:** Continue — will overwrite context and config.
87
+ **If EXISTS and argument is `refresh`:**
88
+ Re-run setup-infra to update framework files, then continue with context/config refresh:
89
+ ```bash
90
+ npx morph-spec setup-infra
91
+ ```
67
92
 
68
93
  **If EXISTS and no argument:**
69
94
  Ask: *"MORPH already initialized. Refresh context and config? (y/n)"*
95
+ If `y` → re-run `npx morph-spec setup-infra` then continue.
70
96
  If `n` → STOP.
71
97
 
72
98
  ---
73
99
 
74
- ## Step 2 — Analyze Project
100
+ ## Step 3 — Analyze Project
75
101
 
76
102
  Gather evidence to build a stack map. Run these in parallel:
77
103
 
@@ -104,7 +130,7 @@ backendPath: src/backend (if monorepo)
104
130
 
105
131
  ---
106
132
 
107
- ## Step 3 — Ask Targeted Questions
133
+ ## Step 4 — Ask Targeted Questions
108
134
 
109
135
  **Rule: only ask what cannot be inferred with ≥90% confidence from files.**
110
136
 
@@ -120,7 +146,7 @@ Do **not** ask about technology already confirmed by file evidence.
120
146
 
121
147
  ---
122
148
 
123
- ## Step 4 — Generate `context/README.md`
149
+ ## Step 5 — Generate `context/README.md`
124
150
 
125
151
  Write `.morph/context/README.md`:
126
152
 
@@ -148,7 +174,7 @@ Write `.morph/context/README.md`:
148
174
 
149
175
  ---
150
176
 
151
- ## Step 5 — Update `config.json`
177
+ ## Step 6 — Update `config.json`
152
178
 
153
179
  Read `.morph/config/config.json` and merge into `project`:
154
180
 
@@ -171,14 +197,14 @@ Read `.morph/config/config.json` and merge into `project`:
171
197
 
172
198
  ---
173
199
 
174
- ## Step 6 — Configure MCPs
200
+ ## Step 7 — Configure MCPs
175
201
 
176
202
  For each detected integration with an available MCP:
177
203
 
178
204
  **Supabase detected:**
179
205
  > *"Configure Supabase MCP now? I'll need `SUPABASE_URL` and `SERVICE_ROLE_KEY`."*
180
206
  - YES → collect credentials → add to `.claude/settings.local.json` under `mcpServers`
181
- - NO → show snippet + `morph-spec mcp setup supabase`
207
+ - NO → show snippet + `npx morph-spec mcp setup supabase`
182
208
 
183
209
  **GitHub:**
184
210
  > *"Configure GitHub MCP? I'll need a `GITHUB_PERSONAL_ACCESS_TOKEN`."*
@@ -188,7 +214,7 @@ Only offer Figma, Docker, Azure if explicitly detected in `.env.example`.
188
214
 
189
215
  ---
190
216
 
191
- ## Step 7 — Final Output
217
+ ## Step 8 — Final Output
192
218
 
193
219
  Before printing the summary, check `~/.claude/settings.local.json` for `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`. If set to `"1"`, mark Agent Teams as enabled; otherwise show the warning.
194
220
 
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 3 (Clarify). Reviews spec.md for ambiguities, gene
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
7
- cliVersion: "4.8.5"
7
+ cliVersion: "4.8.7"
8
8
  ---
9
9
 
10
10
  # MORPH Clarify - FASE 3
@@ -3,7 +3,7 @@ name: phase-codebase-analysis
3
3
  description: MORPH-SPEC Design sub-phase that analyzes existing codebase and database schema, producing schema-analysis.md with real column names, types, relationships, and field mismatches. Use at the start of Design phase before generating contracts.cs to prevent incorrect field names or types.
4
4
  user-invocable: false
5
5
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
6
- cliVersion: "4.8.5"
6
+ cliVersion: "4.8.7"
7
7
  ---
8
8
 
9
9
  # MORPH Codebase Analysis - Sub-fase de DESIGN
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 2 (Design). Analyzes codebase/schema, then produce
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
7
- cliVersion: "4.8.5"
7
+ cliVersion: "4.8.7"
8
8
  ---
9
9
 
10
10
  # MORPH Design - FASE 2
@@ -6,7 +6,7 @@ disable-model-invocation: true
6
6
  context: fork
7
7
  agent: general-purpose
8
8
  user-invocable: false
9
- cliVersion: "4.8.5"
9
+ cliVersion: "4.8.7"
10
10
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
11
11
  ---
12
12
 
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 1 (Setup). Reads project context, detects tech sta
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
7
- cliVersion: "4.8.5"
7
+ cliVersion: "4.8.7"
8
8
  ---
9
9
 
10
10
  # MORPH Setup - FASE 1
@@ -5,7 +5,7 @@ argument-hint: "[feature-name]"
5
5
  disable-model-invocation: true
6
6
  user-invocable: false
7
7
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
8
- cliVersion: "4.8.5"
8
+ cliVersion: "4.8.7"
9
9
  ---
10
10
 
11
11
  # MORPH Tasks - FASE 4
@@ -4,7 +4,7 @@ description: MORPH-SPEC Phase 1.5 (UI/UX). Creates design-system.md, mockups.md,
4
4
  argument-hint: "[feature-name]"
5
5
  user-invocable: false
6
6
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep
7
- cliVersion: "4.8.5"
7
+ cliVersion: "4.8.7"
8
8
  ---
9
9
 
10
10
  # MORPH UI/UX Design - FASE 1.5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "4.8.5",
3
+ "version": "4.8.7",
4
4
  "description": "MORPH-SPEC: AI-First development framework with validation pipeline and multi-stack support",
5
5
  "keywords": [
6
6
  "claude-code",