@polymorphism-tech/morph-spec 9.7.0 → 10.0.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.
- package/CLAUDE.md +11 -11
- package/README.md +4 -4
- package/bin/morph-spec.js +41 -44
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +24 -30
- package/framework/MORPH.md +47 -123
- package/framework/agents.json +6 -374
- package/framework/commands/morph-apply.md +1 -1
- package/framework/commands/morph-archive.md +4 -3
- package/framework/commands/morph-hotfix.md +8 -30
- package/framework/commands/morph-infra.md +2 -2
- package/framework/commands/morph-media.md +10 -9
- package/framework/commands/morph-review.md +15 -0
- package/framework/commands/morph-status.md +1 -1
- package/framework/docs/CLI.md +100 -102
- package/framework/evals/review-checklist.md +46 -54
- package/framework/hooks/README.md +4 -4
- package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
- package/framework/hooks/claude-code/core/README.md +1 -1
- package/framework/hooks/claude-code/core/denylist.json +4 -4
- package/framework/hooks/claude-code/core/gate-guard.js +9 -0
- package/framework/hooks/claude-code/core/masked-exit.js +49 -0
- package/framework/hooks/claude-code/core/state-sync.js +4 -1
- package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
- package/framework/hooks/claude-code/core/test-budget.js +10 -70
- package/framework/hooks/claude-code/core/validate-completion.js +11 -24
- package/framework/hooks/claude-code/core/worktree-context.js +5 -22
- package/framework/hooks/claude-code/core/worktree-create.js +6 -45
- package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/gate-event.js +2 -2
- package/framework/hooks/shared/graph-refresh.js +20 -3
- package/framework/hooks/shared/heavy-command.js +32 -1
- package/framework/hooks/shared/heavy-config.js +1 -1
- package/framework/hooks/shared/heavy-slots.js +41 -4
- package/framework/hooks/shared/next-step.js +63 -24
- package/framework/hooks/shared/phase-utils.js +6 -9
- package/framework/hooks/shared/report-progress.js +86 -0
- package/framework/hooks/shared/state-reader.js +3 -2
- package/framework/hooks/statusline.js +332 -0
- package/framework/phases.json +32 -10
- package/framework/rules/ai-agents-standards.md +9 -1
- package/framework/rules/csharp-standards.md +4 -0
- package/framework/rules/ef-standards.md +20 -0
- package/framework/rules/frontend-standards.md +1 -1
- package/framework/rules/implement-report.md +25 -0
- package/framework/rules/infrastructure-standards.md +2 -0
- package/framework/rules/nextjs-standards.md +7 -0
- package/framework/rules/plan-grammar.md +53 -0
- package/framework/rules/ui-design.md +27 -0
- package/framework/schemas/config.schema.json +12 -19
- package/framework/schemas/feature.schema.json +7 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
- package/framework/skills/core/morph-apply/SKILL.md +38 -166
- package/framework/skills/core/morph-apply/references/debugging.md +8 -0
- package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
- package/framework/skills/core/morph-apply/references/machine.md +29 -0
- package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
- package/framework/skills/core/morph-apply/references/review.md +65 -0
- package/framework/skills/core/morph-plan/SKILL.md +42 -108
- package/framework/skills/core/morph-plan/references/critique.md +40 -0
- package/framework/skills/core/morph-plan/references/interview.md +25 -0
- package/framework/skills/core/morph-uiux/SKILL.md +47 -64
- package/framework/skills/core/morph-uiux/surfaces.json +32 -0
- package/framework/standards/STANDARDS.json +32 -5
- package/framework/standards/STANDARDS.meta.json +5 -1
- package/framework/standards/ai-agents/agent-session.md +2 -3
- package/framework/standards/ai-agents/context-providers.md +2 -2
- package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
- package/framework/standards/ai-agents/providers/model-registry.md +2 -2
- package/framework/standards/ai-agents/rag-custom-pgvector.md +6 -6
- package/framework/standards/ai-agents/setup.md +1 -1
- package/framework/standards/ai-agents/tool-adapters.md +2 -2
- package/framework/standards/architecture/stack-baseline.md +47 -0
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -4
- package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
- package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
- package/framework/standards/frontend/design-system/art-direction.md +2 -2
- package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
- package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
- package/framework/templates/.idea/morph-templates.xml +92 -0
- package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/REGISTRY.json +1 -2
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/context/ARCHITECTURE.md +1 -1
- package/framework/templates/docs/plan.md +12 -12
- package/framework/templates/docs/ui-mockups.md +17 -105
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +88 -23
- package/src/commands/graph/diff.js +74 -0
- package/src/commands/graph/status.js +1 -1
- package/src/commands/observability/cost.js +2 -4
- package/src/commands/observability/telemetry.js +1 -1
- package/src/commands/project/doctor.js +23 -39
- package/src/commands/project/fleet.js +5 -6
- package/src/commands/project/init.js +63 -36
- package/src/commands/project/stack-scan.js +3 -42
- package/src/commands/project/stack-set.js +26 -0
- package/src/commands/project/status.js +12 -13
- package/src/commands/project/update.js +39 -3
- package/src/commands/project/worktree.js +27 -16
- package/src/commands/state/advance.js +17 -17
- package/src/commands/state/approve.js +0 -6
- package/src/commands/state/archive.js +27 -1
- package/src/commands/state/auto-approval.js +1 -1
- package/src/commands/state/create.js +0 -5
- package/src/commands/state/finish.js +43 -1
- package/src/commands/state/gate-check.js +37 -30
- package/src/commands/state/gate-decision.js +3 -2
- package/src/commands/state/next.js +1 -0
- package/src/commands/state/recap.js +67 -0
- package/src/commands/state/report-check.js +39 -146
- package/src/commands/validation/plan-check.js +16 -11
- package/src/commands/validation/validate-feature.js +3 -3
- package/src/commands/validation/verify.js +6 -6
- package/src/core/paths/output-schema.js +3 -3
- package/src/core/state/state-manager.js +2 -4
- package/src/dashboard/app/components/CommandPalette.jsx +2 -5
- package/src/dashboard/app/components/GatePauses.jsx +116 -0
- package/src/dashboard/app/components/SessionMap.jsx +77 -0
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/Activity.jsx +4 -0
- package/src/dashboard/app/pages/AgentMap.jsx +1 -2
- package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
- package/src/dashboard/app/pages/Features.jsx +7 -7
- package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
- package/src/dashboard/app/pages/Portfolio.jsx +40 -5
- package/src/dashboard/scanner/activity-scanner.js +29 -0
- package/src/dashboard/scanner/agents-scanner.js +11 -14
- package/src/dashboard/scanner/features-scanner.js +45 -24
- package/src/dashboard/scanner/project-api.js +11 -2
- package/src/dashboard/scanner/skills-scanner.js +11 -1
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/e2e/applicability.js +3 -1
- package/src/lib/git/tree-state.js +1 -16
- package/src/lib/graph/diff-tree.js +127 -0
- package/src/lib/graph/graphify-runtime.js +12 -5
- package/src/lib/heavy/gate.js +22 -1
- package/src/lib/installers/memory-installer.js +2 -2
- package/src/lib/installers/rules-installer.js +57 -0
- package/src/lib/installers/stack-assets.js +17 -0
- package/src/lib/installers/stack-sync.js +219 -0
- package/src/lib/plan/checks.js +55 -110
- package/src/lib/plan/critique.js +28 -0
- package/src/lib/plan/media.js +42 -0
- package/src/lib/plan/plan-html.js +125 -0
- package/src/lib/plan/plan-parser.js +34 -45
- package/src/lib/recap/piper.js +36 -0
- package/src/lib/recap/recap.js +232 -0
- package/src/lib/review-reports.js +33 -7
- package/src/lib/risk-detector.js +90 -11
- package/src/lib/specs/screen-checklist.js +33 -48
- package/src/lib/stack/dotnet-build-ignores.js +8 -9
- package/src/lib/tasks/test-nodes.js +24 -6
- package/src/lib/tasks/test-runner.js +40 -1
- package/src/lib/telemetry/metrics.js +27 -29
- package/src/lib/validators/reuse/reuse-validator.js +1 -9
- package/src/lib/validators/verify-runner.js +50 -19
- package/src/lib/worktree/fleet-scanner.js +7 -20
- package/src/lib/worktree/worktree-audit.js +0 -37
- package/src/scripts/setup-infra.js +15 -50
- package/src/utils/agents-installer.js +27 -201
- package/src/utils/hooks-installer.js +11 -95
- package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
- package/framework/hooks/claude-code/statusline.py +0 -699
- package/framework/hooks/claude-code/statusline.sh +0 -11
- package/framework/hooks/shared/agent-reap.js +0 -74
- package/framework/hooks/shared/slice-progress.js +0 -110
- package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
- package/framework/skills/core/morph-machine/SKILL.md +0 -46
- package/framework/skills/core/morph-review/SKILL.md +0 -129
- package/framework/skills/core/morph-standards/SKILL.md +0 -52
- package/framework/standards/PROVENANCE.json +0 -119
- package/src/commands/project/worktree-reap.js +0 -34
- package/src/commands/state/handoff.js +0 -65
- package/src/commands/validation/mutate.js +0 -263
- package/src/commands/validation/scout-check.js +0 -28
- package/src/lib/generators/handoff-generator.js +0 -172
- package/src/lib/mutation/adapters.js +0 -79
- package/src/lib/mutation/commands.js +0 -61
- package/src/lib/mutation/dotnet-introspection.js +0 -281
- package/src/lib/mutation/manifest.js +0 -144
- package/src/lib/mutation/plan.js +0 -44
- package/src/lib/mutation/report.js +0 -68
- package/src/lib/mutation/runner.js +0 -301
- package/src/lib/mutation/schemata.js +0 -60
- package/src/lib/mutation/snapshot.js +0 -65
- package/src/lib/mutation/subset.js +0 -109
- package/src/lib/mutation/trx.js +0 -50
- package/src/lib/scout/scout-check.js +0 -293
- package/src/lib/validators/traceability-check.js +0 -103
- 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()
|