@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.
Files changed (141) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -3
  3. package/pennyfarthing-dist/agents/README.md +1 -1
  4. package/pennyfarthing-dist/agents/dev.md +1 -1
  5. package/pennyfarthing-dist/agents/handoff.md +1 -1
  6. package/pennyfarthing-dist/agents/reviewer-preflight.md +1 -1
  7. package/pennyfarthing-dist/agents/reviewer.md +21 -4
  8. package/pennyfarthing-dist/agents/sm-setup.md +3 -3
  9. package/pennyfarthing-dist/agents/sm.md +11 -1
  10. package/pennyfarthing-dist/agents/tea.md +1 -1
  11. package/pennyfarthing-dist/agents/testing-runner.md +3 -3
  12. package/pennyfarthing-dist/commands/architect.md +3 -1
  13. package/pennyfarthing-dist/commands/continue-session.md +2 -2
  14. package/pennyfarthing-dist/commands/dev.md +3 -1
  15. package/pennyfarthing-dist/commands/devops.md +3 -1
  16. package/pennyfarthing-dist/commands/health-check.md +3 -1
  17. package/pennyfarthing-dist/commands/new-work.md +23 -0
  18. package/pennyfarthing-dist/commands/orchestrator.md +3 -1
  19. package/pennyfarthing-dist/commands/parallel-work.md +6 -4
  20. package/pennyfarthing-dist/commands/pm.md +3 -1
  21. package/pennyfarthing-dist/commands/prime.md +18 -22
  22. package/pennyfarthing-dist/commands/reviewer.md +3 -1
  23. package/pennyfarthing-dist/commands/set-theme.md +1 -1
  24. package/pennyfarthing-dist/commands/sm.md +3 -1
  25. package/pennyfarthing-dist/commands/sprint.md +13 -4
  26. package/pennyfarthing-dist/commands/tea.md +3 -1
  27. package/pennyfarthing-dist/commands/tech-writer.md +3 -1
  28. package/pennyfarthing-dist/commands/ux-designer.md +3 -1
  29. package/pennyfarthing-dist/commands/work.md +4 -2
  30. package/pennyfarthing-dist/guides/agent-behavior.md +36 -257
  31. package/pennyfarthing-dist/personas/themes/rome.yaml +11 -11
  32. package/pennyfarthing-dist/scripts/core/agent-session.sh +7 -0
  33. package/pennyfarthing-dist/scripts/core/check-context.sh +140 -226
  34. package/pennyfarthing-dist/scripts/core/handoff-marker.sh +13 -2
  35. package/pennyfarthing-dist/scripts/git/worktree-manager.sh +4 -1
  36. package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -7
  37. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +4 -11
  38. package/pennyfarthing-dist/scripts/hooks/pre-commit.sh +3 -8
  39. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +3 -3
  40. package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -7
  41. package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +2 -8
  42. package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +2 -8
  43. package/pennyfarthing-dist/scripts/lib/find-root.sh +17 -45
  44. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -7
  45. package/pennyfarthing-dist/scripts/sprint/archive-story.sh +2 -8
  46. package/pennyfarthing-dist/scripts/sprint/available-stories.sh +2 -8
  47. package/pennyfarthing-dist/scripts/sprint/check-story.sh +2 -8
  48. package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +2 -8
  49. package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +2 -8
  50. package/pennyfarthing-dist/scripts/sprint/list-future.sh +2 -8
  51. package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +2 -8
  52. package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +2 -8
  53. package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +2 -8
  54. package/pennyfarthing-dist/scripts/tests/test-character-voice.sh +2 -1
  55. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +4 -9
  56. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +2 -8
  57. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +2 -8
  58. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -7
  59. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +2 -8
  60. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +2 -8
  61. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +2 -8
  62. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +2 -8
  63. package/pennyfarthing-dist/skills/dev-patterns/SKILL.md +1 -1
  64. package/pennyfarthing-dist/skills/jira/SKILL.md +48 -24
  65. package/pennyfarthing-dist/skills/sprint/scripts/sync-epic-jira.sh +7 -0
  66. package/pennyfarthing-dist/skills/sprint/skill.md +30 -30
  67. package/pennyfarthing-dist/workflows/patch.yaml +68 -0
  68. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  69. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  70. package/pennyfarthing_scripts/cli.py +168 -0
  71. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  73. package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
  74. package/pennyfarthing_scripts/context.py +414 -0
  75. package/pennyfarthing_scripts/patch_mode.py +449 -0
  76. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  79. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  80. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  81. package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
  82. package/pennyfarthing_scripts/prime/__pycache__/tiers.cpython-314.pyc +0 -0
  83. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/prime/cli.py +209 -1
  85. package/pennyfarthing_scripts/prime/models.py +9 -0
  86. package/pennyfarthing_scripts/prime/persona.py +41 -0
  87. package/pennyfarthing_scripts/prime/tiers.py +201 -0
  88. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  89. package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
  90. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  91. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  92. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/sprint/cli.py +144 -84
  96. package/pennyfarthing_scripts/tests/test_patch_mode.py +830 -0
  97. package/pennyfarthing_scripts/tests/test_tiers.py +1090 -0
  98. package/pennyfarthing_scripts/tests/test_token_counting.py +559 -0
  99. package/pennyfarthing_scripts/tests/test_workflow_check.py +341 -0
  100. package/pennyfarthing_scripts/workflow.py +104 -0
  101. package/pennyfarthing-dist/scripts/hooks/__pycache__/question_reflector_check.cpython-314.pyc +0 -0
  102. package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  103. package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
  104. package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
  105. package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
  106. package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
  107. package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
  108. package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
  109. package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
  110. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  111. package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
  112. package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
  113. package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
  114. package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
  115. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  116. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  117. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  118. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  121. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  122. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  123. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  124. package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
  125. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  126. package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
  127. package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
  131. package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  136. package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  137. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  138. package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
  139. package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  140. package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
  141. 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()