@polymorphism-tech/morph-spec 9.8.0 → 10.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 (202) hide show
  1. package/CLAUDE.md +12 -10
  2. package/README.md +4 -4
  3. package/bin/morph-spec.js +40 -43
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +28 -36
  6. package/docs/QUICKSTART.md +31 -191
  7. package/framework/CLAUDE.md +24 -30
  8. package/framework/MORPH.md +47 -123
  9. package/framework/agents.json +6 -374
  10. package/framework/commands/morph-apply.md +1 -1
  11. package/framework/commands/morph-archive.md +4 -3
  12. package/framework/commands/morph-hotfix.md +8 -30
  13. package/framework/commands/morph-infra.md +2 -2
  14. package/framework/commands/morph-media.md +10 -9
  15. package/framework/commands/morph-preflight.md +3 -3
  16. package/framework/commands/morph-review.md +15 -0
  17. package/framework/commands/morph-status.md +1 -1
  18. package/framework/docs/CLI.md +84 -95
  19. package/framework/evals/review-checklist.md +45 -53
  20. package/framework/hooks/README.md +4 -4
  21. package/framework/hooks/claude-code/core/README.md +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +4 -4
  23. package/framework/hooks/claude-code/core/gate-guard.js +9 -0
  24. package/framework/hooks/claude-code/core/masked-exit.js +49 -0
  25. package/framework/hooks/claude-code/core/state-sync.js +4 -1
  26. package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
  27. package/framework/hooks/claude-code/core/test-budget.js +10 -70
  28. package/framework/hooks/claude-code/core/validate-completion.js +11 -24
  29. package/framework/hooks/claude-code/core/worktree-context.js +5 -22
  30. package/framework/hooks/claude-code/core/worktree-create.js +6 -45
  31. package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
  32. package/framework/hooks/shared/e2e-check.js +0 -0
  33. package/framework/hooks/shared/gate-event.js +2 -2
  34. package/framework/hooks/shared/graph-refresh.js +20 -3
  35. package/framework/hooks/shared/heavy-command.js +32 -1
  36. package/framework/hooks/shared/heavy-config.js +1 -1
  37. package/framework/hooks/shared/next-step.js +63 -24
  38. package/framework/hooks/shared/phase-utils.js +6 -9
  39. package/framework/hooks/shared/report-progress.js +86 -0
  40. package/framework/hooks/shared/state-reader.js +3 -2
  41. package/framework/hooks/statusline.js +332 -0
  42. package/framework/mcp/registry.json +4 -4
  43. package/framework/phases.json +32 -10
  44. package/framework/rules/ai-agents-standards.md +10 -2
  45. package/framework/rules/csharp-standards.md +4 -0
  46. package/framework/rules/data-standards.md +1 -1
  47. package/framework/rules/ef-standards.md +20 -0
  48. package/framework/rules/frontend-standards.md +1 -1
  49. package/framework/rules/hangfire-standards.md +1 -1
  50. package/framework/rules/implement-report.md +25 -0
  51. package/framework/rules/infrastructure-standards.md +2 -0
  52. package/framework/rules/neon-auth-standards.md +1 -1
  53. package/framework/rules/neon-standards.md +3 -3
  54. package/framework/rules/nextjs-standards.md +7 -0
  55. package/framework/rules/plan-grammar.md +53 -0
  56. package/framework/rules/ui-design.md +27 -0
  57. package/framework/schemas/config.schema.json +11 -18
  58. package/framework/schemas/feature.schema.json +7 -1
  59. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  60. package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
  61. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
  62. package/framework/skills/core/morph-apply/SKILL.md +39 -166
  63. package/framework/skills/core/morph-apply/references/debugging.md +8 -0
  64. package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
  65. package/framework/skills/core/morph-apply/references/machine.md +29 -0
  66. package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
  67. package/framework/skills/core/morph-apply/references/review.md +65 -0
  68. package/framework/skills/core/morph-plan/SKILL.md +49 -109
  69. package/framework/skills/core/morph-plan/references/critique.md +43 -0
  70. package/framework/skills/core/morph-plan/references/interview.md +37 -0
  71. package/framework/skills/core/morph-uiux/SKILL.md +47 -64
  72. package/framework/skills/core/morph-uiux/surfaces.json +32 -0
  73. package/framework/standards/STANDARDS.json +51 -28
  74. package/framework/standards/STANDARDS.meta.json +16 -19
  75. package/framework/standards/ai-agents/agent-session.md +2 -3
  76. package/framework/standards/ai-agents/context-providers.md +2 -2
  77. package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
  78. package/framework/standards/ai-agents/providers/model-registry.md +2 -2
  79. package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -6
  80. package/framework/standards/ai-agents/setup.md +1 -1
  81. package/framework/standards/ai-agents/tool-adapters.md +2 -2
  82. package/framework/standards/architecture/stack-baseline.md +70 -0
  83. package/framework/standards/backend/database/postgresql/database.md +22 -1
  84. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +33 -1
  85. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +2 -1
  86. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +12 -13
  87. package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
  88. package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
  89. package/framework/standards/frontend/design-system/art-direction.md +2 -2
  90. package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
  91. package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
  92. package/framework/standards/infrastructure/docker/local-compose-isolation.md +2 -2
  93. package/framework/standards/infrastructure/neon/neon-pgvector.md +62 -20
  94. package/framework/standards/infrastructure/neon/neon-platform.md +61 -0
  95. package/framework/standards/infrastructure/neon/neon-setup.md +14 -13
  96. package/framework/standards/integration/mcp/mcp-tools.md +20 -20
  97. package/framework/templates/REGISTRY.json +1 -2
  98. package/framework/templates/context/ARCHITECTURE.md +1 -1
  99. package/framework/templates/docs/plan.md +15 -12
  100. package/framework/templates/docs/ui-mockups.md +17 -105
  101. package/framework/templates/infrastructure/docker/docker-compose.template.yml +1 -1
  102. package/package.json +1 -1
  103. package/src/commands/e2e/e2e.js +88 -23
  104. package/src/commands/graph/diff.js +74 -0
  105. package/src/commands/graph/status.js +1 -1
  106. package/src/commands/observability/cost.js +2 -4
  107. package/src/commands/observability/telemetry.js +1 -1
  108. package/src/commands/project/doctor.js +23 -39
  109. package/src/commands/project/fleet.js +5 -6
  110. package/src/commands/project/init.js +63 -36
  111. package/src/commands/project/stack-scan.js +3 -42
  112. package/src/commands/project/stack-set.js +26 -0
  113. package/src/commands/project/status.js +12 -13
  114. package/src/commands/project/update.js +39 -3
  115. package/src/commands/project/worktree.js +3 -14
  116. package/src/commands/state/advance.js +17 -17
  117. package/src/commands/state/approve.js +0 -6
  118. package/src/commands/state/archive.js +27 -1
  119. package/src/commands/state/auto-approval.js +2 -2
  120. package/src/commands/state/create.js +1 -6
  121. package/src/commands/state/gate-check.js +38 -31
  122. package/src/commands/state/gate-decision.js +3 -2
  123. package/src/commands/state/next.js +1 -0
  124. package/src/commands/state/recap.js +67 -0
  125. package/src/commands/state/report-check.js +36 -178
  126. package/src/commands/validation/plan-check.js +16 -11
  127. package/src/commands/validation/validate-feature.js +3 -3
  128. package/src/commands/validation/verify.js +3 -6
  129. package/src/core/paths/output-schema.js +3 -3
  130. package/src/core/state/state-manager.js +2 -4
  131. package/src/dashboard/app/components/CommandPalette.jsx +2 -5
  132. package/src/dashboard/app/components/GatePauses.jsx +116 -0
  133. package/src/dashboard/app/components/SessionMap.jsx +77 -0
  134. package/src/dashboard/app/pages/Activity.jsx +4 -0
  135. package/src/dashboard/app/pages/AgentMap.jsx +1 -2
  136. package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
  137. package/src/dashboard/app/pages/Features.jsx +7 -7
  138. package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
  139. package/src/dashboard/app/pages/Portfolio.jsx +40 -5
  140. package/src/dashboard/scanner/activity-scanner.js +29 -0
  141. package/src/dashboard/scanner/agents-scanner.js +11 -14
  142. package/src/dashboard/scanner/features-scanner.js +45 -24
  143. package/src/dashboard/scanner/project-api.js +11 -2
  144. package/src/dashboard/scanner/skills-scanner.js +11 -1
  145. package/src/lib/detectors/index.js +1 -1
  146. package/src/lib/e2e/applicability.js +3 -1
  147. package/src/lib/git/tree-state.js +1 -16
  148. package/src/lib/graph/diff-tree.js +127 -0
  149. package/src/lib/graph/graphify-runtime.js +12 -5
  150. package/src/lib/installers/memory-installer.js +3 -3
  151. package/src/lib/installers/rules-installer.js +57 -0
  152. package/src/lib/installers/stack-assets.js +17 -0
  153. package/src/lib/installers/stack-sync.js +219 -0
  154. package/src/lib/plan/checks.js +61 -109
  155. package/src/lib/plan/critique.js +28 -0
  156. package/src/lib/plan/media.js +42 -0
  157. package/src/lib/plan/plan-html.js +125 -0
  158. package/src/lib/plan/plan-parser.js +34 -45
  159. package/src/lib/recap/piper.js +36 -0
  160. package/src/lib/recap/recap.js +232 -0
  161. package/src/lib/review-reports.js +28 -4
  162. package/src/lib/risk-detector.js +90 -11
  163. package/src/lib/specs/screen-checklist.js +33 -48
  164. package/src/lib/stack/dotnet-build-ignores.js +8 -9
  165. package/src/lib/telemetry/metrics.js +27 -29
  166. package/src/lib/validators/reuse/reuse-validator.js +1 -9
  167. package/src/lib/validators/verify-runner.js +0 -7
  168. package/src/lib/worktree/fleet-scanner.js +7 -20
  169. package/src/lib/worktree/worktree-audit.js +0 -37
  170. package/src/scripts/setup-infra.js +15 -50
  171. package/src/utils/agents-installer.js +27 -201
  172. package/src/utils/hooks-installer.js +11 -95
  173. package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
  174. package/framework/hooks/claude-code/statusline.py +0 -699
  175. package/framework/hooks/claude-code/statusline.sh +0 -11
  176. package/framework/hooks/shared/agent-reap.js +0 -74
  177. package/framework/hooks/shared/slice-progress.js +0 -110
  178. package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
  179. package/framework/skills/core/morph-machine/SKILL.md +0 -46
  180. package/framework/skills/core/morph-review/SKILL.md +0 -129
  181. package/framework/skills/core/morph-standards/SKILL.md +0 -52
  182. package/framework/standards/PROVENANCE.json +0 -119
  183. package/framework/standards/infrastructure/neon/neon-pgrag.md +0 -243
  184. package/src/commands/project/worktree-reap.js +0 -34
  185. package/src/commands/state/handoff.js +0 -65
  186. package/src/commands/validation/mutate.js +0 -263
  187. package/src/commands/validation/scout-check.js +0 -28
  188. package/src/lib/generators/handoff-generator.js +0 -172
  189. package/src/lib/mutation/adapters.js +0 -79
  190. package/src/lib/mutation/commands.js +0 -61
  191. package/src/lib/mutation/dotnet-introspection.js +0 -281
  192. package/src/lib/mutation/manifest.js +0 -144
  193. package/src/lib/mutation/plan.js +0 -44
  194. package/src/lib/mutation/report.js +0 -68
  195. package/src/lib/mutation/runner.js +0 -301
  196. package/src/lib/mutation/schemata.js +0 -60
  197. package/src/lib/mutation/snapshot.js +0 -65
  198. package/src/lib/mutation/subset.js +0 -109
  199. package/src/lib/mutation/trx.js +0 -50
  200. package/src/lib/scout/scout-check.js +0 -293
  201. package/src/lib/validators/traceability-check.js +0 -103
  202. package/src/lib/worktree/agent-reaper.js +0 -247
@@ -1,699 +0,0 @@
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
- # DESIGN RULE — the payload is the source of truth.
7
- # Anything Claude Code already computes (context %, rate limits, worktree,
8
- # repo, PR) is READ from stdin, never recomputed. Recomputing is what made
9
- # previous versions drift away from /context and /usage. Only two things come
10
- # from outside the payload: git diff stats and the MORPH feature state.
11
- #
12
- # ONE LINE — the layout is deliberately a single row:
13
- #
14
- # <model·effort> │ <context bar ↻ tokens-to-compact> │ <5h> │ <7d> │ <branch> │ <PR>
15
- #
16
- # The only second line is the MORPH block, and only inside a secondary
17
- # worktree. Everything else that used to occupy rows — the session name, the
18
- # session clock, the last Skill/Agent, the output style — was cut as noise.
19
- # Dropping the Skill/Agent widgets is also what lets this script stop parsing
20
- # the transcript entirely.
21
- #
22
- # The statusline is STATELESS — it never writes to disk. An earlier version
23
- # cached session start times per-cwd and per-transcript; the cwd cache leaked
24
- # start times between concurrent windows and the directory grew to thousands
25
- # of files. Removing the clock removed the need for any of it.
26
-
27
- import sys
28
- import json
29
- import os
30
- import subprocess
31
- import re
32
- import time
33
- import traceback
34
- from pathlib import Path
35
-
36
- # Ensure UTF-8 output on Windows (stdout defaults to CP1252 otherwise)
37
- if hasattr(sys.stdout, 'reconfigure'):
38
- sys.stdout.reconfigure(encoding='utf-8')
39
-
40
- # ANSI colors
41
- R = '\033[0m' # Reset
42
- BOLD = '\033[1m'
43
- CYAN = '\033[36m'
44
- GREEN = '\033[32m'
45
- YELLOW = '\033[33m'
46
- RED = '\033[31m'
47
- BLUE = '\033[34m'
48
- GRAY = '\033[90m'
49
- WHITE = '\033[97m'
50
-
51
- # Windows: flag to hide console window when spawning subprocesses
52
- _CREATE_NO_WINDOW = 0x08000000 if os.name == 'nt' else 0
53
-
54
- SEP = f" {GRAY}│{R} "
55
-
56
-
57
- # ── MORPH framework constants ────────────────────────────────────────────────
58
-
59
- # Mirrors derivePhaseFrom() in framework/hooks/shared/state-reader.js — the
60
- # first phase (scanning backwards) whose CANONICAL artifact exists wins. An
61
- # empty folder is not enough; a canonical ending in '/' is any .md under it.
62
- PHASE_MAP = [
63
- ('4-review', 'review', 'verifier.md'),
64
- ('3-implement', 'implement', 'reports/'),
65
- ('1-design', 'uiux', 'design-system.md'),
66
- ('2-plan', 'plan', 'plan.md'),
67
- ]
68
- PHASE_INDEX = {'plan': 0, 'uiux': 1, 'implement': 2, 'review': 3}
69
- PHASE_LABELS = {
70
- 'plan': 'planning',
71
- 'uiux': 'UI/UX',
72
- 'implement': 'implement',
73
- 'review': 'review',
74
- }
75
- PIPELINE_TOTAL = 4
76
-
77
- # Gates that can block; `implement` has none. Mirrors feature.schema.json.
78
- GATE_PHASES = ('plan', 'uiux', 'review')
79
-
80
-
81
- # ── Auto-compact threshold ───────────────────────────────────────────────────
82
- #
83
- # Mirrors claude.exe v2.1.220. The context bar's headline number is the
84
- # payload's own `used_percentage` (a fraction of the MODEL window), but
85
- # auto-compact fires much earlier, against a different budget:
86
- #
87
- # l7(model, cfg).window = min(modelWindow, autoCompactWindow) [settings]
88
- # HSe(model, cfg) = window - min(maxOutputTokens, 20000)
89
- # wfo(e) = e - 13000
90
- # threshold = wfo(HSe(...))
91
- #
92
- # maxOutputTokens is >= 20000 on every current model, so the output reserve is
93
- # a flat 20000. This is only reproducible when `autoCompactWindow` is set
94
- # explicitly in settings (source "settings"); every other source (clientdata,
95
- # experiment, model-default, auto) is server-driven and cannot be mirrored
96
- # here, so the widget hides itself instead of guessing.
97
- COMPACT_OUTPUT_RESERVE = 20000
98
- COMPACT_HEADROOM = 13000
99
-
100
-
101
- # ── General helpers ──────────────────────────────────────────────────────────
102
-
103
- def safe_int(val, default=0):
104
- """Safely convert value to int, handling None from JSON null."""
105
- if val is None:
106
- return default
107
- try:
108
- return int(val)
109
- except (TypeError, ValueError):
110
- return default
111
-
112
-
113
- def safe_float(val, default=0.0):
114
- """Safely convert value to float, handling None from JSON null."""
115
- if val is None:
116
- return default
117
- try:
118
- return float(val)
119
- except (TypeError, ValueError):
120
- return default
121
-
122
-
123
- def usage_color(pct):
124
- """Color for a 0-100 usage gauge. Thresholds from the Claude Code docs."""
125
- if pct < 70:
126
- return GREEN
127
- if pct < 90:
128
- return YELLOW
129
- return RED
130
-
131
-
132
- _EIGHTHS = '▏▎▍▌▋▊▉'
133
-
134
-
135
- def progress_bar(pct, width=8):
136
- """Gauge with sub-cell resolution.
137
-
138
- Whole blocks alone are too coarse for the 5-wide rate-limit gauges: at
139
- width 5 a real 13% would render completely empty. The eighth-block glyphs
140
- keep small-but-nonzero values visible.
141
- """
142
- pct = max(0.0, min(100.0, pct))
143
- exact = pct / 100 * width
144
- filled = int(exact)
145
- bar = '█' * filled
146
- if filled < width:
147
- remainder = int((exact - filled) * 8)
148
- if remainder:
149
- bar += _EIGHTHS[remainder - 1]
150
- return bar + '░' * (width - len(bar))
151
-
152
-
153
- def format_tokens(n):
154
- if n >= 1_000_000:
155
- value = f"{n / 1_000_000:.1f}"
156
- return f"{value[:-2] if value.endswith('.0') else value}M"
157
- if n >= 1000:
158
- return f"{n // 1000}k"
159
- return str(n)
160
-
161
-
162
- def format_countdown(epoch):
163
- """Time until a Unix epoch: 47m, 1h48, 6d16. None when already past."""
164
- remaining = epoch - time.time()
165
- if remaining <= 0:
166
- return None
167
- days, rest = int(remaining // 86400), int(remaining % 86400)
168
- hours, minutes = rest // 3600, (rest % 3600) // 60
169
- if days:
170
- return f"{days}d{hours:02d}"
171
- if hours:
172
- return f"{hours}h{minutes:02d}"
173
- return f"{minutes}m"
174
-
175
-
176
- # Both CSI colour codes and OSC sequences. The OSC branch matters: an OSC 8
177
- # hyperlink carries the whole URL as an invisible payload, and counting it
178
- # would make a 9-character PR badge measure the length of its GitHub URL.
179
- _ANSI_RE = re.compile(r'\033\][^\a\033]*(?:\a|\033\\)|\033\[[0-9;]*m')
180
-
181
- # Glyphs terminals render double-width. Deliberately narrow: the block and
182
- # arrow glyphs this script draws gauges with (█ ░ ▏▎▍▌▋▊▉ ↑ ↓ ↻ ► ✓ ✗) are all
183
- # SINGLE width, and treating the whole U+2100+ range as wide overcounted the
184
- # budget line by ~20 columns, dropping widgets that would have fit.
185
- #
186
- # Any new glyph below U+1F300 must be listed here or it is measured as 1.
187
- # ⏳ (U+23F3) is the live case: it has Emoji_Presentation=Yes so it renders
188
- # double, unlike its lookalike ⏱ (U+23F1), which renders as narrow text.
189
- _WIDE_SINGLES = frozenset('⚡⛔⬛⬜✅❌⏳')
190
-
191
-
192
- def char_width(ch):
193
- code = ord(ch)
194
- if 0x1F300 <= code <= 0x1FAFF: # emoji planes
195
- return 2
196
- return 2 if ch in _WIDE_SINGLES else 1
197
-
198
-
199
- def visible_width(text):
200
- """Rendered width: escape sequences stripped, emoji counted double."""
201
- return sum(char_width(ch) for ch in _ANSI_RE.sub('', text))
202
-
203
-
204
- def join_to_width(parts, columns):
205
- """Join `parts` with SEP, dropping the lowest-priority ones until it fits.
206
-
207
- `parts` is a list of (priority, text); lower priority number = keep longer.
208
- `columns` comes from the COLUMNS env var Claude Code exports before running
209
- the status line (tput does not work — stdout is captured, not a terminal).
210
- """
211
- kept = [p for p in parts if p[1]]
212
- if not kept:
213
- return ''
214
- sep_width = visible_width(SEP)
215
-
216
- def total(items):
217
- return (sum(visible_width(text) for _, text in items)
218
- + sep_width * max(0, len(items) - 1))
219
-
220
- if columns:
221
- # Drop the worst priority first, but never drop the single best item.
222
- while len(kept) > 1 and total(kept) > columns:
223
- worst = max(range(len(kept)), key=lambda i: kept[i][0])
224
- kept.pop(worst)
225
- return SEP.join(text for _, text in kept)
226
-
227
-
228
- def read_user_settings():
229
- """Read the user settings (for autoCompactWindow). Never raises.
230
-
231
- Honours CLAUDE_CONFIG_DIR, the same override Claude Code itself uses to
232
- relocate ~/.claude.
233
- """
234
- try:
235
- base = os.environ.get('CLAUDE_CONFIG_DIR')
236
- path = (Path(base) if base else Path.home() / '.claude') / 'settings.json'
237
- data = json.loads(path.read_text(encoding='utf-8'))
238
- return data if isinstance(data, dict) else {}
239
- except Exception:
240
- return {}
241
-
242
-
243
- # ── Git helpers ───────────────────────────────────────────────────────────────
244
-
245
- def _run_git(args, cwd):
246
- """Run a git command safely on Windows (no console popup, with timeout)."""
247
- return subprocess.check_output(
248
- ['git'] + args,
249
- cwd=cwd, stderr=subprocess.DEVNULL, timeout=3,
250
- creationflags=_CREATE_NO_WINDOW,
251
- ).decode('utf-8', 'replace').strip()
252
-
253
-
254
- def get_git_info(cwd):
255
- """Branch, ahead/behind, changed files and line diff.
256
-
257
- A single `status --porcelain=v2 --branch -uno` covers branch, upstream
258
- divergence and the file count. `-uno` matters: v2 lists untracked entries
259
- by default, which would silently change what the file count means. The
260
- shortstat call is skipped entirely on a clean tree.
261
- """
262
- try:
263
- out = _run_git(['status', '--porcelain=v2', '--branch', '-uno'], cwd)
264
- except Exception:
265
- return ''
266
-
267
- branch, ahead, behind, files = '', 0, 0, 0
268
- for line in out.splitlines():
269
- if line.startswith('# branch.head '):
270
- branch = line[len('# branch.head '):].strip()
271
- elif line.startswith('# branch.ab '):
272
- # Only present when an upstream is configured.
273
- match = re.match(r'# branch\.ab \+(\d+) -(\d+)', line)
274
- if match:
275
- ahead, behind = int(match.group(1)), int(match.group(2))
276
- elif line[:1] in ('1', '2', 'u'):
277
- files += 1
278
-
279
- if not branch:
280
- return ''
281
-
282
- ins, dels = 0, 0
283
- if files:
284
- try:
285
- stat = _run_git(['diff', 'HEAD', '--shortstat'], cwd)
286
- match = re.search(r'(\d+) insertion', stat)
287
- if match:
288
- ins = int(match.group(1))
289
- match = re.search(r'(\d+) deletion', stat)
290
- if match:
291
- dels = int(match.group(1))
292
- except Exception:
293
- pass
294
-
295
- parts = [f"{BLUE} {branch}{R}"]
296
- if ahead or behind:
297
- divergence = ''
298
- if ahead:
299
- divergence += f"{GREEN}↑{ahead}{R}"
300
- if behind:
301
- divergence += f"{RED}↓{behind}{R}"
302
- parts.append(divergence)
303
- if ins or dels:
304
- parts.append(f"{GREEN}+{ins}{RED}-{dels}{R}")
305
- return ' '.join(parts)
306
-
307
-
308
- # ── Transcript helpers ────────────────────────────────────────────────────────
309
-
310
- def get_worktree_label(payload, cwd):
311
- """Worktree name for this session, or None when in the primary worktree.
312
-
313
- Cascade: the payload's own `worktree` (--worktree sessions), then
314
- `workspace.git_worktree` (any linked worktree), then — only when both are
315
- absent AND this looks like a morph project — the git subprocess earlier
316
- versions always paid for. The fallback keeps behaviour identical on builds
317
- or worktree layouts where those fields do not appear.
318
- """
319
- worktree = payload.get('worktree') or {}
320
- if worktree.get('name'):
321
- branch = (worktree.get('branch') or '').replace('refs/heads/', '')
322
- return branch or worktree['name']
323
-
324
- git_worktree = (payload.get('workspace') or {}).get('git_worktree')
325
- if git_worktree:
326
- return git_worktree
327
-
328
- if not (Path(cwd) / '.morph' / 'features').is_dir():
329
- return None
330
- try:
331
- out = _run_git(['worktree', 'list', '--porcelain'], cwd)
332
- except Exception:
333
- return None
334
- entries, current = [], {}
335
- for line in out.splitlines():
336
- if line.startswith('worktree '):
337
- if current:
338
- entries.append(current)
339
- current = {'path': line.split(' ', 1)[1]}
340
- elif line.startswith('branch '):
341
- current['branch'] = line.split(' ', 1)[1]
342
- if current:
343
- entries.append(current)
344
- if len(entries) > 1:
345
- try:
346
- cwd_resolved = str(Path(cwd).resolve())
347
- except Exception:
348
- return None
349
- for entry in entries[1:]: # entry 0 is always the primary worktree
350
- try:
351
- same = str(Path(entry.get('path', '')).resolve()) == cwd_resolved
352
- except Exception:
353
- same = False
354
- if same:
355
- return entry.get('branch', '').replace('refs/heads/', '') or None
356
- return None
357
-
358
-
359
- def _gate_implied_phase(gates, design_started):
360
- """Phase implied purely by approved gates (mirrors state-reader.js)."""
361
- def approved(gate):
362
- return (gates.get(gate) or {}).get('approved') is True
363
- if approved('review'):
364
- return 'review'
365
- if approved('plan'):
366
- return 'uiux' if design_started and not approved('uiux') else 'implement'
367
- return 'plan'
368
-
369
-
370
- def _has_artifact(folder, canonical):
371
- if not canonical.endswith('/'):
372
- return (folder / canonical).exists()
373
- try:
374
- return any(p.is_file() for p in (folder / canonical).glob('*.md'))
375
- except OSError:
376
- return False
377
-
378
-
379
- def derive_phase(feature_dir, gates):
380
- """Phase from the filesystem, bumped by approved gates."""
381
- artifact_phase = 'plan'
382
- for folder, phase, canonical in PHASE_MAP:
383
- if _has_artifact(feature_dir / folder, canonical):
384
- artifact_phase = phase
385
- break
386
- implied = _gate_implied_phase(gates, (feature_dir / '1-design').exists())
387
- if PHASE_INDEX[implied] > PHASE_INDEX[artifact_phase]:
388
- return implied
389
- return artifact_phase
390
-
391
-
392
- PARTIAL_MARKER = re.compile(r'^[\s#>*_]*PARCIAL\b')
393
- BLOCKED_MARKER = re.compile(r'^[\s#>*_]*BLOQUEADO\b')
394
-
395
-
396
- def count_slices(feature_dir):
397
- """(done, total) of the slices in 2-plan/checks.json.
398
-
399
- Same rule as framework/hooks/shared/slice-progress.js: a slice is done when
400
- 3-implement/reports/{id}.md exists and its first line is neither PARCIAL nor BLOQUEADO.
401
- """
402
- try:
403
- slices = json.loads((feature_dir / '2-plan' / 'checks.json').read_text(encoding='utf-8')).get('slices')
404
- except Exception:
405
- return 0, 0
406
- if not isinstance(slices, list):
407
- return 0, 0
408
- ids = [s.get('id') for s in slices if isinstance(s, dict) and isinstance(s.get('id'), str)]
409
- done = 0
410
- for slice_id in ids:
411
- try:
412
- text = (feature_dir / '3-implement' / 'reports' / f'{slice_id}.md').read_text(encoding='utf-8')
413
- except Exception:
414
- continue
415
- first = next((line for line in text.splitlines() if line.strip()), '')
416
- if not PARTIAL_MARKER.match(first) and not BLOCKED_MARKER.match(first):
417
- done += 1
418
- return done, len(ids)
419
-
420
-
421
- def get_active_features(cwd):
422
- """in_progress features from the authoritative per-feature state.
423
-
424
- Reads .morph/features/*/feature.json. The root .morph/state.json is a thin,
425
- gitignored, derivable index and must not be trusted here.
426
- """
427
- base = Path(cwd) / '.morph' / 'features'
428
- if not base.is_dir():
429
- return []
430
- try:
431
- feature_files = sorted(base.glob('*/feature.json'))
432
- except Exception:
433
- return []
434
-
435
- result = []
436
- for path in feature_files:
437
- try:
438
- data = json.loads(path.read_text(encoding='utf-8'))
439
- except Exception:
440
- continue
441
- if not isinstance(data, dict) or data.get('status') != 'in_progress':
442
- continue
443
- feature_dir = path.parent
444
- gates = data.get('approvalGates') or {}
445
- phase = derive_phase(feature_dir, gates)
446
- done, total = count_slices(feature_dir)
447
- pending = None
448
- if phase in GATE_PHASES and (gates.get(phase) or {}).get('approved') is not True:
449
- pending = phase
450
- result.append({
451
- 'name': feature_dir.name,
452
- 'phase': phase,
453
- 'slices_done': done,
454
- 'slices_total': total,
455
- 'pending': pending,
456
- })
457
- return result
458
-
459
-
460
- # ── Line renderers ────────────────────────────────────────────────────────────
461
-
462
- def render_morph_line(feature, columns):
463
- """Line 0 — the active MORPH feature.
464
-
465
- Goes through join_to_width like the status line: a feature name plus phase,
466
- slice bar and gate is the longest thing this script prints, and a
467
- wrapping line 0 would defeat the whole point of the single-row layout.
468
- """
469
- parts = [(0, f"{CYAN}{BOLD}► {feature['name']}{R}")]
470
-
471
- phase = feature['phase']
472
- label = PHASE_LABELS.get(phase, phase)
473
- position = PHASE_INDEX.get(phase)
474
- if position is None:
475
- parts.append((1, f"{CYAN}{label}{R}"))
476
- else:
477
- parts.append((1, f"{CYAN}{label} {position + 1}/{PIPELINE_TOTAL}{R}"))
478
-
479
- if feature['slices_total'] > 0:
480
- pct = feature['slices_done'] / feature['slices_total'] * 100
481
- parts.append((
482
- 3,
483
- f"{GREEN}{progress_bar(pct, 6)} "
484
- f"{feature['slices_done']}/{feature['slices_total']} fatias{R}"
485
- ))
486
-
487
- if feature['pending']:
488
- parts.append((2, f"{RED}⛔ gate: {feature['pending']}{R}"))
489
-
490
- return join_to_width(parts, columns)
491
-
492
-
493
- def effective_window(payload, settings):
494
- """The window /context measures against, or None when not reproducible.
495
-
496
- NOT the model window. Measured against Claude Code 2.1.220 with
497
- `autoCompactWindow: 680000`:
498
-
499
- payload total_input_tokens 289681, context_window_size 1000000,
500
- used_percentage 29 (= 289681 / 1000000)
501
- /context "289.7k/680k tokens (43%)" (= 289681 / 680000)
502
-
503
- So the payload's own `used_percentage` divides by the MODEL window while
504
- the app divides by the effective one. Taking `used_percentage` verbatim —
505
- which is what this script did — is a 1.47x understatement at these
506
- settings, and it is the divergence the whole rewrite set out to kill.
507
- """
508
- model_window = safe_int((payload.get('context_window') or {}).get('context_window_size'))
509
- if model_window <= 0:
510
- return None
511
- if settings.get('autoCompactEnabled') is False:
512
- return model_window
513
- configured = safe_int(settings.get('autoCompactWindow'))
514
- # Every other source of the window (clientdata, experiment, model-default,
515
- # auto) is server-driven and cannot be mirrored here.
516
- return min(model_window, configured) if configured > 0 else None
517
-
518
-
519
- def compact_threshold(payload, settings):
520
- """Tokens at which auto-compact fires, or None when not reproducible."""
521
- if settings.get('autoCompactEnabled') is False:
522
- return None
523
- if safe_int(settings.get('autoCompactWindow')) <= 0:
524
- return None
525
- window = effective_window(payload, settings)
526
- if not window:
527
- return None
528
- # /context calls this the "Autocompact buffer" and reports it as 33k —
529
- # exactly COMPACT_OUTPUT_RESERVE + COMPACT_HEADROOM.
530
- threshold = window - COMPACT_OUTPUT_RESERVE - COMPACT_HEADROOM
531
- return threshold if threshold > 0 else None
532
-
533
-
534
- def render_status_line(payload, cwd, settings, columns):
535
- """The single status line — where you are and what you have left.
536
-
537
- One bar only, on the context: it is the budget that moves fastest and the
538
- one you act on. The account windows are numbers, coloured by the same
539
- thresholds. Priority governs what a narrow terminal drops — the context
540
- never goes, the weekly window goes first.
541
-
542
- Deliberately NOT rendered, by the user's call: the session name, the
543
- session clock, the last Skill/Agent, and the output style. Dropping the
544
- last two is also what lets this script stop reading the transcript at all.
545
- """
546
- parts = [] # (priority, text) — the highest priority number is dropped first
547
-
548
- model = payload.get('model') or {}
549
- model_name = (model.get('display_name') or model.get('id') or ''
550
- if isinstance(model, dict) else str(model or ''))
551
- if model_name:
552
- short = (model_name
553
- .replace('Claude ', '')
554
- .replace('claude-', '')
555
- .replace(' (1M context)', ' 1M')
556
- .replace(' (claude.ai)', ''))
557
- # Only surface state that is NOT the default: thinking is normally on,
558
- # fast mode normally off. `permission_mode` is deliberately absent —
559
- # Claude Code never sends it to the status line, only to hooks.
560
- state = []
561
- effort = (payload.get('effort') or {}).get('level')
562
- if effort:
563
- state.append(effort)
564
- if (payload.get('thinking') or {}).get('enabled') is False:
565
- state.append('no-think')
566
- if payload.get('fast_mode'):
567
- state.append('fast')
568
- suffix = f"{GRAY}·{chr(183).join(state)}{R}" if state else ''
569
- parts.append((3, f"{WHITE}{short}{R}{suffix}"))
570
-
571
- context = payload.get('context_window') or {}
572
- if context:
573
- model_window = safe_int(context.get('context_window_size'))
574
- tokens = safe_int(context.get('total_input_tokens'))
575
- if not tokens:
576
- # Null right after /compact and before the first API response.
577
- # Recompute with the app's own input-only formula — output_tokens
578
- # is NOT part of it, and adding it is how earlier versions drifted.
579
- usage = context.get('current_usage')
580
- if isinstance(usage, dict):
581
- tokens = (safe_int(usage.get('input_tokens'))
582
- + safe_int(usage.get('cache_creation_input_tokens'))
583
- + safe_int(usage.get('cache_read_input_tokens')))
584
-
585
- # Divide by the same window /context does. Only when that window is
586
- # not reproducible do we fall back to the payload's own percentage,
587
- # which is measured against the model window instead.
588
- window = effective_window(payload, settings)
589
- pct = None
590
- if window and tokens:
591
- pct = tokens / window * 100
592
- elif context.get('used_percentage') is not None:
593
- pct = safe_float(context.get('used_percentage'))
594
- elif model_window and tokens:
595
- pct = tokens / model_window * 100
596
-
597
- if pct is not None:
598
- chunk = f"{usage_color(pct)}{progress_bar(pct, 8)} {pct:.0f}%{R}"
599
- # How much still fits before auto-compact fires — /context reports
600
- # the same number as "Free space". Same ↻ as the account windows:
601
- # "how much is left until the next reset", each in its own unit —
602
- # tokens here, time there.
603
- threshold = compact_threshold(payload, settings)
604
- if threshold and tokens:
605
- left = threshold - tokens
606
- if left <= 0:
607
- chunk += f" {RED}⚠ compactando{R}"
608
- else:
609
- left_pct = left / threshold * 100
610
- color = GRAY if left_pct > 25 else (YELLOW if left_pct > 10 else RED)
611
- chunk += f" {color}↻ {format_tokens(left)}{R}"
612
- parts.append((0, chunk))
613
-
614
- # Claude.ai subscription windows. Present only for Pro/Max subscribers
615
- # after the first API response; each window may be absent on its own.
616
- #
617
- # ⏳ is /usage's "Current session" and 📅 its "Current week (all models)".
618
- # The icons cost the same two columns the "5h"/"7d" labels did, and they
619
- # sidestep a real ambiguity: the app calls the short one a "session", but
620
- # it is a 5-hour billing window that resets on its own clock, unrelated to
621
- # the Claude Code session.
622
- rate_limits = payload.get('rate_limits') or {}
623
- for key, label, priority, countdown in (('five_hour', '⏳', 2, True),
624
- ('seven_day', '📅', 5, False)):
625
- window = rate_limits.get(key) or {}
626
- raw_pct = window.get('used_percentage')
627
- if raw_pct is None:
628
- continue
629
- pct = safe_float(raw_pct)
630
- chunk = f"{GRAY}{label}{R} {usage_color(pct)}{pct:.0f}%{R}"
631
- resets_at = window.get('resets_at') if countdown else None
632
- if resets_at:
633
- until = format_countdown(safe_float(resets_at))
634
- if until:
635
- chunk += f" {GRAY}↻ {until}{R}"
636
- parts.append((priority, chunk))
637
-
638
- git_info = get_git_info(cwd)
639
- if git_info:
640
- parts.append((1, git_info))
641
-
642
- pull_request = payload.get('pr') or {}
643
- if pull_request.get('number'):
644
- review_state = pull_request.get('review_state')
645
- mark = {'approved': '✓', 'changes_requested': '✗',
646
- 'draft': '·', 'pending': '…'}.get(review_state, '')
647
- color = {'approved': GREEN, 'changes_requested': RED,
648
- 'draft': GRAY}.get(review_state, YELLOW)
649
- label = f"PR #{pull_request['number']} {mark}".strip()
650
- url = pull_request.get('url')
651
- if url:
652
- # OSC 8 hyperlink — honoured by Windows Terminal, iTerm2, Kitty.
653
- # visible_width() strips the OSC payload, so the URL costs no columns.
654
- label = f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
655
- parts.append((4, f"{color}{label}{R}"))
656
-
657
- return join_to_width(parts, columns)
658
-
659
-
660
- # ── Main ──────────────────────────────────────────────────────────────────────
661
-
662
- def main():
663
- try:
664
- raw = sys.stdin.read()
665
- if not raw.strip():
666
- sys.exit(0)
667
- payload = json.loads(raw)
668
- if not isinstance(payload, dict):
669
- sys.exit(0)
670
- except Exception:
671
- sys.exit(0)
672
-
673
- try:
674
- workspace = payload.get('workspace') or {}
675
- cwd = workspace.get('current_dir') or payload.get('cwd') or os.getcwd()
676
- settings = read_user_settings()
677
-
678
- # Claude Code exports COLUMNS before running the command; `tput cols`
679
- # does not work here because stdout is captured, not a terminal.
680
- columns = safe_int(os.environ.get('COLUMNS'), 0)
681
-
682
- # The MORPH line is the ONLY second line, and only inside a secondary
683
- # worktree. In the primary tree it resurrects stale feature names
684
- # during unrelated work, so there the status line is a single row.
685
- if get_worktree_label(payload, cwd):
686
- for feature in get_active_features(cwd):
687
- print(render_morph_line(feature, columns))
688
-
689
- status_line = render_status_line(payload, cwd, settings, columns)
690
- if status_line:
691
- print(status_line)
692
-
693
- except Exception:
694
- err = traceback.format_exc().splitlines()[-1]
695
- print(f"{RED}ERRO: {err}{R}")
696
-
697
-
698
- if __name__ == '__main__':
699
- main()