@pennyfarthing/core 8.0.4 → 9.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/README.md +3 -3
- package/package.json +3 -3
- package/pennyfarthing-dist/agents/README.md +1 -1
- package/pennyfarthing-dist/agents/dev.md +1 -1
- package/pennyfarthing-dist/agents/handoff.md +1 -1
- package/pennyfarthing-dist/agents/reviewer-preflight.md +1 -1
- package/pennyfarthing-dist/agents/reviewer.md +21 -4
- package/pennyfarthing-dist/agents/sm-setup.md +3 -3
- package/pennyfarthing-dist/agents/sm.md +11 -1
- package/pennyfarthing-dist/agents/tea.md +1 -1
- package/pennyfarthing-dist/agents/testing-runner.md +3 -3
- package/pennyfarthing-dist/commands/architect.md +3 -1
- package/pennyfarthing-dist/commands/continue-session.md +2 -2
- package/pennyfarthing-dist/commands/dev.md +3 -1
- package/pennyfarthing-dist/commands/devops.md +3 -1
- package/pennyfarthing-dist/commands/health-check.md +3 -1
- package/pennyfarthing-dist/commands/new-work.md +23 -0
- package/pennyfarthing-dist/commands/orchestrator.md +3 -1
- package/pennyfarthing-dist/commands/parallel-work.md +6 -4
- package/pennyfarthing-dist/commands/pm.md +3 -1
- package/pennyfarthing-dist/commands/prime.md +18 -22
- package/pennyfarthing-dist/commands/reviewer.md +3 -1
- package/pennyfarthing-dist/commands/set-theme.md +1 -1
- package/pennyfarthing-dist/commands/sm.md +3 -1
- package/pennyfarthing-dist/commands/sprint.md +13 -4
- package/pennyfarthing-dist/commands/tea.md +3 -1
- package/pennyfarthing-dist/commands/tech-writer.md +3 -1
- package/pennyfarthing-dist/commands/ux-designer.md +3 -1
- package/pennyfarthing-dist/commands/work.md +4 -2
- package/pennyfarthing-dist/guides/agent-behavior.md +36 -257
- package/pennyfarthing-dist/personas/themes/rome.yaml +11 -11
- package/pennyfarthing-dist/scripts/core/agent-session.sh +7 -0
- package/pennyfarthing-dist/scripts/core/check-context.sh +140 -226
- package/pennyfarthing-dist/scripts/core/handoff-marker.sh +13 -2
- package/pennyfarthing-dist/scripts/git/worktree-manager.sh +4 -1
- package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -7
- package/pennyfarthing-dist/scripts/hooks/post-merge.sh +4 -11
- package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +3 -8
- package/pennyfarthing-dist/scripts/hooks/pre-push.sh +3 -3
- package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -7
- package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +2 -8
- package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +2 -8
- package/pennyfarthing-dist/scripts/lib/find-root.sh +17 -45
- package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -7
- package/pennyfarthing-dist/scripts/sprint/archive-story.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/available-stories.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/check-story.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/list-future.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +2 -8
- package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +2 -8
- package/pennyfarthing-dist/scripts/tests/test-character-voice.sh +2 -1
- package/pennyfarthing-dist/scripts/workflow/finish-story.sh +4 -9
- package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -7
- package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +2 -8
- package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +2 -8
- package/pennyfarthing-dist/skills/dev-patterns/SKILL.md +1 -1
- package/pennyfarthing-dist/skills/jira/SKILL.md +48 -24
- package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +7 -0
- package/pennyfarthing-dist/skills/sprint/skill.md +30 -30
- package/pennyfarthing-dist/workflows/patch.yaml +68 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/cli.py +168 -0
- package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/context.py +414 -0
- package/pennyfarthing_scripts/patch_mode.py +449 -0
- package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/cli.py +209 -1
- package/pennyfarthing_scripts/prime/models.py +9 -0
- package/pennyfarthing_scripts/prime/persona.py +41 -0
- package/pennyfarthing_scripts/prime/tiers.py +201 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/cli.py +144 -84
- package/pennyfarthing_scripts/tests/test_patch_mode.py +830 -0
- package/pennyfarthing_scripts/tests/test_tiers.py +1090 -0
- package/pennyfarthing_scripts/tests/test_token_counting.py +559 -0
- package/pennyfarthing_scripts/tests/test_workflow_check.py +341 -0
- package/pennyfarthing_scripts/workflow.py +104 -0
- package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Context checking for Claude Code sessions.
|
|
2
|
+
|
|
3
|
+
Calculates context window usage from Claude Code transcript files.
|
|
4
|
+
Handles stale SESSION_ID detection after /clear commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import yaml
|
|
16
|
+
HAS_YAML = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
HAS_YAML = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ContextConfig:
|
|
23
|
+
"""Configuration for context thresholds."""
|
|
24
|
+
imminent_threshold: int = 65
|
|
25
|
+
warning_threshold: int = 60
|
|
26
|
+
critical_threshold: int = 85
|
|
27
|
+
max_tokens: int = 200000
|
|
28
|
+
tirepump_threshold: int = 60
|
|
29
|
+
permission_mode: str = "manual"
|
|
30
|
+
relay_mode: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ContextResult:
|
|
35
|
+
"""Result of context check."""
|
|
36
|
+
# Token counts
|
|
37
|
+
tokens: int = 0
|
|
38
|
+
baseline: int = 0
|
|
39
|
+
usable_tokens: int = 0
|
|
40
|
+
available: int = 0
|
|
41
|
+
|
|
42
|
+
# Percentages
|
|
43
|
+
percent: int = 0
|
|
44
|
+
usable_percent: int = 0
|
|
45
|
+
|
|
46
|
+
# Status
|
|
47
|
+
status: str = "OK" # OK, HIGH
|
|
48
|
+
warning: Optional[str] = None # None, High, Critical
|
|
49
|
+
recommendation: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
# Mode settings
|
|
52
|
+
permission_mode: str = "manual"
|
|
53
|
+
relay_mode: bool = False
|
|
54
|
+
handoff_mode: str = "ask" # ask, auto
|
|
55
|
+
use_tirepump: bool = False
|
|
56
|
+
is_cyclist: bool = False
|
|
57
|
+
|
|
58
|
+
# Error state
|
|
59
|
+
error: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
def to_env_vars(self) -> str:
|
|
62
|
+
"""Output as shell environment variables."""
|
|
63
|
+
if self.error:
|
|
64
|
+
return f"CONTEXT_ERROR={self.error}"
|
|
65
|
+
|
|
66
|
+
lines = [
|
|
67
|
+
f"CONTEXT_TOKENS={self.tokens}",
|
|
68
|
+
f"CONTEXT_PERCENT={self.percent}",
|
|
69
|
+
f"CONTEXT_BASELINE={self.baseline}",
|
|
70
|
+
f"CONTEXT_USABLE_TOKENS={self.usable_tokens}",
|
|
71
|
+
f"CONTEXT_USABLE_PERCENT={self.usable_percent}",
|
|
72
|
+
f"CONTEXT_AVAILABLE={self.available}",
|
|
73
|
+
f"CONTEXT_STATUS={self.status}",
|
|
74
|
+
f"PERMISSION_MODE={self.permission_mode}",
|
|
75
|
+
f"RELAY_MODE={str(self.relay_mode).lower()}",
|
|
76
|
+
f"HANDOFF_MODE={self.handoff_mode}",
|
|
77
|
+
f"USE_TIREPUMP={str(self.use_tirepump).lower()}",
|
|
78
|
+
f"IS_CYCLIST={str(self.is_cyclist).lower()}",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
if self.warning:
|
|
82
|
+
lines.append(f"CONTEXT_WARNING={self.warning}")
|
|
83
|
+
if self.recommendation:
|
|
84
|
+
lines.append(f"CONTEXT_RECOMMENDATION='{self.recommendation}'")
|
|
85
|
+
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
def to_human(self) -> str:
|
|
89
|
+
"""Output as human-readable string."""
|
|
90
|
+
if self.error:
|
|
91
|
+
return f"⚠️ Context: unknown ({self.error})"
|
|
92
|
+
|
|
93
|
+
if self.use_tirepump:
|
|
94
|
+
status_line = f"🔄 Context: {self.usable_percent}% used ({self.usable_tokens} of {self.available} available) - TIREPUMP (clear + next agent)"
|
|
95
|
+
elif self.status == "HIGH":
|
|
96
|
+
status_line = f"⚠️ Context: {self.usable_percent}% used ({self.usable_tokens} of {self.available} available) - AUTO-HANDOFF"
|
|
97
|
+
else:
|
|
98
|
+
status_line = f"✅ Context: {self.usable_percent}% used ({self.usable_tokens} of {self.available} available)"
|
|
99
|
+
|
|
100
|
+
lines = [
|
|
101
|
+
status_line,
|
|
102
|
+
f" Overhead: {self.baseline} tokens (system prompt + tools)",
|
|
103
|
+
f" Mode: {self.permission_mode}",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
if self.warning == "Critical":
|
|
107
|
+
lines.append(f"CONTEXT_WARNING: Critical ({self.usable_percent}%) - checkpoint and handoff recommended")
|
|
108
|
+
elif self.warning == "High":
|
|
109
|
+
lines.append(f"CONTEXT_WARNING: High ({self.usable_percent}%) - consider handoff soon")
|
|
110
|
+
|
|
111
|
+
return "\n".join(lines)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_config(project_dir: Optional[str] = None) -> ContextConfig:
|
|
115
|
+
"""Load context configuration from config files.
|
|
116
|
+
|
|
117
|
+
Checks .pennyfarthing/config.local.yaml first, falls back to
|
|
118
|
+
.claude/settings.local.json for legacy support.
|
|
119
|
+
"""
|
|
120
|
+
config = ContextConfig()
|
|
121
|
+
project_dir = (
|
|
122
|
+
project_dir or
|
|
123
|
+
os.environ.get("CLAUDE_PROJECT_DIR") or
|
|
124
|
+
os.environ.get("PROJECT_ROOT") or
|
|
125
|
+
os.getcwd()
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Try .pennyfarthing/config.local.yaml first
|
|
129
|
+
yaml_path = Path(project_dir) / ".pennyfarthing" / "config.local.yaml"
|
|
130
|
+
if HAS_YAML and yaml_path.exists():
|
|
131
|
+
try:
|
|
132
|
+
with open(yaml_path) as f:
|
|
133
|
+
data = yaml.safe_load(f)
|
|
134
|
+
if data:
|
|
135
|
+
_apply_config(config, data)
|
|
136
|
+
return config
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
# Fallback to .claude/settings.local.json
|
|
141
|
+
json_path = Path(project_dir) / ".claude" / "settings.local.json"
|
|
142
|
+
if json_path.exists():
|
|
143
|
+
try:
|
|
144
|
+
with open(json_path) as f:
|
|
145
|
+
data = json.load(f)
|
|
146
|
+
_apply_config(config, data)
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
return config
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _apply_config(config: ContextConfig, data: dict) -> None:
|
|
154
|
+
"""Apply configuration data to config object."""
|
|
155
|
+
if "context_budget" in data:
|
|
156
|
+
cb = data["context_budget"]
|
|
157
|
+
config.imminent_threshold = cb.get("imminent_threshold", config.imminent_threshold)
|
|
158
|
+
config.warning_threshold = cb.get("warning_threshold", config.warning_threshold)
|
|
159
|
+
config.critical_threshold = cb.get("critical_threshold", config.critical_threshold)
|
|
160
|
+
config.max_tokens = cb.get("max_tokens", config.max_tokens)
|
|
161
|
+
config.tirepump_threshold = cb.get("tirepump_threshold", config.tirepump_threshold)
|
|
162
|
+
|
|
163
|
+
if "workflow" in data:
|
|
164
|
+
wf = data["workflow"]
|
|
165
|
+
config.permission_mode = wf.get("permission_mode", config.permission_mode)
|
|
166
|
+
config.relay_mode = wf.get("relay_mode", False) is True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_claude_project_path(project_dir: Optional[str] = None) -> Path:
|
|
170
|
+
"""Get the Claude Code project path for transcripts.
|
|
171
|
+
|
|
172
|
+
Claude Code stores transcripts at ~/.claude/projects/<path-with-dashes>
|
|
173
|
+
The path format is: -Users-name-Projects-project (leading dash, slashes become dashes)
|
|
174
|
+
"""
|
|
175
|
+
project_dir = (
|
|
176
|
+
project_dir or
|
|
177
|
+
os.environ.get("CLAUDE_PROJECT_DIR") or
|
|
178
|
+
os.environ.get("PROJECT_ROOT") or
|
|
179
|
+
os.getcwd()
|
|
180
|
+
)
|
|
181
|
+
path_with_dashes = project_dir.replace("/", "-")
|
|
182
|
+
return Path.home() / ".claude" / "projects" / path_with_dashes
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def find_transcript(
|
|
186
|
+
project_path: Path,
|
|
187
|
+
explicit_session: Optional[str] = None,
|
|
188
|
+
session_id_env: Optional[str] = None,
|
|
189
|
+
stale_threshold_seconds: int = 60,
|
|
190
|
+
) -> Optional[Path]:
|
|
191
|
+
"""Find the appropriate transcript file.
|
|
192
|
+
|
|
193
|
+
Priority:
|
|
194
|
+
1. Explicit session ID (from --session flag)
|
|
195
|
+
2. SESSION_ID env var if transcript is fresh (modified within threshold)
|
|
196
|
+
3. Most recently modified transcript
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
project_path: Claude project path (~/.claude/projects/...)
|
|
200
|
+
explicit_session: Explicit session ID from --session flag
|
|
201
|
+
session_id_env: SESSION_ID from environment variable
|
|
202
|
+
stale_threshold_seconds: How old a transcript can be before considered stale
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Path to transcript file, or None if not found
|
|
206
|
+
"""
|
|
207
|
+
if not project_path.exists():
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
# Helper to find most recent transcript
|
|
211
|
+
def most_recent() -> Optional[Path]:
|
|
212
|
+
transcripts = sorted(
|
|
213
|
+
[f for f in project_path.glob("*.jsonl") if "agent-" not in f.name],
|
|
214
|
+
key=lambda f: f.stat().st_mtime,
|
|
215
|
+
reverse=True,
|
|
216
|
+
)
|
|
217
|
+
return transcripts[0] if transcripts else None
|
|
218
|
+
|
|
219
|
+
# 1. Explicit session ID takes precedence
|
|
220
|
+
if explicit_session:
|
|
221
|
+
candidate = project_path / f"{explicit_session}.jsonl"
|
|
222
|
+
return candidate if candidate.exists() else None
|
|
223
|
+
|
|
224
|
+
# 2. Check SESSION_ID env var with freshness validation
|
|
225
|
+
if session_id_env:
|
|
226
|
+
candidate = project_path / f"{session_id_env}.jsonl"
|
|
227
|
+
if candidate.exists():
|
|
228
|
+
age = time.time() - candidate.stat().st_mtime
|
|
229
|
+
if age < stale_threshold_seconds:
|
|
230
|
+
return candidate
|
|
231
|
+
# Stale - fall through to most recent
|
|
232
|
+
# Doesn't exist or stale - use most recent
|
|
233
|
+
return most_recent()
|
|
234
|
+
|
|
235
|
+
# 3. No session info - use most recent
|
|
236
|
+
return most_recent()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def parse_transcript(transcript_path: Path) -> tuple[Optional[int], Optional[int]]:
|
|
240
|
+
"""Parse transcript for first and last usage totals.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Tuple of (first_total, last_total) token counts
|
|
244
|
+
"""
|
|
245
|
+
first_total = None
|
|
246
|
+
last_total = None
|
|
247
|
+
|
|
248
|
+
with open(transcript_path) as f:
|
|
249
|
+
for line in f:
|
|
250
|
+
try:
|
|
251
|
+
data = json.loads(line.strip())
|
|
252
|
+
if "message" in data and "usage" in data["message"]:
|
|
253
|
+
usage = data["message"]["usage"]
|
|
254
|
+
total = (
|
|
255
|
+
usage.get("input_tokens", 0) +
|
|
256
|
+
usage.get("cache_read_input_tokens", 0) +
|
|
257
|
+
usage.get("cache_creation_input_tokens", 0)
|
|
258
|
+
)
|
|
259
|
+
if first_total is None:
|
|
260
|
+
first_total = total
|
|
261
|
+
last_total = total
|
|
262
|
+
except (json.JSONDecodeError, KeyError):
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
return first_total, last_total
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def detect_cyclist(project_dir: Optional[str] = None) -> bool:
|
|
269
|
+
"""Detect if running inside Cyclist.
|
|
270
|
+
|
|
271
|
+
Checks:
|
|
272
|
+
1. CYCLIST env var set to '1' (Electron mode - definitive)
|
|
273
|
+
2. .cyclist-port file exists AND port is responding (Web mode)
|
|
274
|
+
"""
|
|
275
|
+
# Env var is definitive - set by Cyclist when it spawns Claude
|
|
276
|
+
if os.environ.get("CYCLIST") == "1":
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
# Port file check - verify Cyclist is actually running
|
|
280
|
+
project_dir = (
|
|
281
|
+
project_dir or
|
|
282
|
+
os.environ.get("CYCLIST_PROJECT_DIR") or
|
|
283
|
+
os.environ.get("PROJECT_ROOT") or
|
|
284
|
+
os.getcwd()
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
port_files = [
|
|
288
|
+
Path(project_dir) / "packages" / "cyclist" / ".cyclist-port",
|
|
289
|
+
Path(os.getcwd()) / ".cyclist-port",
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
for port_file in port_files:
|
|
293
|
+
if port_file.exists():
|
|
294
|
+
try:
|
|
295
|
+
port = int(port_file.read_text().strip())
|
|
296
|
+
# Quick check if port is responding
|
|
297
|
+
import socket
|
|
298
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
299
|
+
s.settimeout(0.5)
|
|
300
|
+
result = s.connect_ex(("127.0.0.1", port))
|
|
301
|
+
if result == 0:
|
|
302
|
+
return True
|
|
303
|
+
except (ValueError, OSError, socket.error):
|
|
304
|
+
# Port file invalid or port not responding
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def check_context(
|
|
311
|
+
explicit_session: Optional[str] = None,
|
|
312
|
+
project_dir: Optional[str] = None,
|
|
313
|
+
) -> ContextResult:
|
|
314
|
+
"""Check current context usage.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
explicit_session: Explicit session ID (from --session flag)
|
|
318
|
+
project_dir: Project directory (defaults to cwd)
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
ContextResult with all context information
|
|
322
|
+
"""
|
|
323
|
+
result = ContextResult()
|
|
324
|
+
|
|
325
|
+
# Load configuration
|
|
326
|
+
config = load_config(project_dir)
|
|
327
|
+
result.permission_mode = config.permission_mode
|
|
328
|
+
result.relay_mode = config.relay_mode
|
|
329
|
+
|
|
330
|
+
# Find transcript
|
|
331
|
+
project_path = get_claude_project_path(project_dir)
|
|
332
|
+
session_id_env = os.environ.get("SESSION_ID")
|
|
333
|
+
|
|
334
|
+
transcript = find_transcript(
|
|
335
|
+
project_path,
|
|
336
|
+
explicit_session=explicit_session,
|
|
337
|
+
session_id_env=session_id_env,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if not transcript:
|
|
341
|
+
result.error = "no_transcript"
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
# Parse transcript
|
|
345
|
+
first_total, last_total = parse_transcript(transcript)
|
|
346
|
+
|
|
347
|
+
if last_total is None:
|
|
348
|
+
result.error = "no_usage_data"
|
|
349
|
+
return result
|
|
350
|
+
|
|
351
|
+
# Calculate metrics
|
|
352
|
+
baseline = first_total or 0
|
|
353
|
+
usable_tokens = last_total - baseline
|
|
354
|
+
available = config.max_tokens - baseline
|
|
355
|
+
usable_pct = int((usable_tokens / available * 100) if available > 0 else 0)
|
|
356
|
+
total_pct = int((last_total / config.max_tokens) * 100)
|
|
357
|
+
|
|
358
|
+
result.tokens = last_total
|
|
359
|
+
result.baseline = baseline
|
|
360
|
+
result.usable_tokens = usable_tokens
|
|
361
|
+
result.available = available
|
|
362
|
+
result.percent = total_pct
|
|
363
|
+
result.usable_percent = usable_pct
|
|
364
|
+
|
|
365
|
+
# Status
|
|
366
|
+
if usable_pct > config.warning_threshold:
|
|
367
|
+
result.status = "HIGH"
|
|
368
|
+
|
|
369
|
+
# Handoff mode
|
|
370
|
+
result.handoff_mode = "auto" if config.relay_mode else "ask"
|
|
371
|
+
|
|
372
|
+
# TirePump
|
|
373
|
+
result.use_tirepump = (
|
|
374
|
+
(config.relay_mode or config.permission_mode == "turbo") and
|
|
375
|
+
usable_pct > config.tirepump_threshold
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# Cyclist detection
|
|
379
|
+
result.is_cyclist = detect_cyclist(project_dir)
|
|
380
|
+
|
|
381
|
+
# Warnings
|
|
382
|
+
if usable_pct >= config.critical_threshold:
|
|
383
|
+
result.warning = "Critical"
|
|
384
|
+
result.recommendation = "checkpoint and handoff recommended"
|
|
385
|
+
elif usable_pct >= config.warning_threshold:
|
|
386
|
+
result.warning = "High"
|
|
387
|
+
result.recommendation = "consider handoff soon"
|
|
388
|
+
|
|
389
|
+
return result
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def main() -> None:
|
|
393
|
+
"""CLI entry point."""
|
|
394
|
+
import argparse
|
|
395
|
+
|
|
396
|
+
parser = argparse.ArgumentParser(description="Check Claude Code context usage")
|
|
397
|
+
parser.add_argument("--human", action="store_true", help="Human-readable output")
|
|
398
|
+
parser.add_argument("--session", dest="session_id", help="Explicit session ID")
|
|
399
|
+
parser.add_argument("--project-dir", help="Project directory")
|
|
400
|
+
args = parser.parse_args()
|
|
401
|
+
|
|
402
|
+
result = check_context(
|
|
403
|
+
explicit_session=args.session_id,
|
|
404
|
+
project_dir=args.project_dir,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
if args.human:
|
|
408
|
+
print(result.to_human())
|
|
409
|
+
else:
|
|
410
|
+
print(result.to_env_vars())
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
if __name__ == "__main__":
|
|
414
|
+
main()
|