aiwcli 0.9.8 → 0.10.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/bin/run.js +5 -2
- package/dist/lib/claude-settings-types.d.ts +2 -0
- package/dist/templates/CLAUDE.md +3 -3
- package/dist/templates/_shared/.claude/settings.json +4 -0
- package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/archive_plan.py +87 -178
- package/dist/templates/_shared/hooks/context_monitor.py +104 -247
- package/dist/templates/_shared/hooks/file-suggestion.py +26 -23
- package/dist/templates/_shared/hooks/pre_compact.py +47 -32
- package/dist/templates/_shared/hooks/session_end.py +103 -60
- package/dist/templates/_shared/hooks/session_start.py +110 -81
- package/dist/templates/_shared/hooks/task_create_capture.py +26 -50
- package/dist/templates/_shared/hooks/task_update_capture.py +42 -115
- package/dist/templates/_shared/hooks/user_prompt_submit.py +61 -61
- package/dist/templates/_shared/lib/base/__init__.py +16 -0
- package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/hook_utils.py +199 -11
- package/dist/templates/_shared/lib/base/inference.py +121 -0
- package/dist/templates/_shared/lib/base/logger.py +291 -0
- package/dist/templates/_shared/lib/base/utils.py +42 -9
- package/dist/templates/_shared/lib/context/__init__.py +72 -80
- package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/context_formatter.py +316 -0
- package/dist/templates/_shared/lib/context/context_selector.py +491 -0
- package/dist/templates/_shared/lib/context/context_store.py +636 -0
- package/dist/templates/_shared/lib/context/plan_manager.py +204 -0
- package/dist/templates/_shared/lib/context/task_tracker.py +188 -0
- package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/handoff/document_generator.py +14 -40
- package/dist/templates/_shared/lib/templates/README.md +5 -13
- package/dist/templates/_shared/lib/templates/__init__.py +2 -6
- package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/plan_context.py +1 -38
- package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
- package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
- package/dist/templates/_shared/scripts/save_handoff.py +39 -19
- package/dist/templates/_shared/scripts/status_line.py +701 -0
- package/dist/templates/_shared/workflows/handoff.md +9 -3
- package/dist/templates/cc-native/.claude/settings.json +41 -8
- package/dist/templates/cc-native/CC-NATIVE-README.md +25 -28
- package/dist/templates/cc-native/MIGRATION.md +1 -1
- package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +14 -39
- package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +49 -21
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +57 -55
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +163 -131
- package/dist/templates/cc-native/_cc-native/hooks/plan_accepted.py +127 -0
- package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +81 -0
- package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +26 -25
- package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +6 -4
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/debug.py +37 -22
- package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +34 -29
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +26 -21
- package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +12 -7
- package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +12 -7
- package/dist/templates/cc-native/_cc-native/lib/state.py +31 -16
- package/dist/templates/cc-native/_cc-native/lib/utils.py +207 -40
- package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
- package/dist/templates/_shared/hooks/context_enforcer.py +0 -625
- package/dist/templates/_shared/hooks/task_create_atomicity.py +0 -177
- package/dist/templates/_shared/lib/context/auto_state.py +0 -167
- package/dist/templates/_shared/lib/context/cache.py +0 -444
- package/dist/templates/_shared/lib/context/context_extractor.py +0 -115
- package/dist/templates/_shared/lib/context/context_manager.py +0 -1057
- package/dist/templates/_shared/lib/context/discovery.py +0 -554
- package/dist/templates/_shared/lib/context/event_log.py +0 -316
- package/dist/templates/_shared/lib/context/plan_archive.py +0 -101
- package/dist/templates/_shared/lib/context/task_sync.py +0 -407
- package/dist/templates/_shared/lib/templates/persona_questions.py +0 -113
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/async_archive.py +0 -68
- package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +0 -98
|
@@ -8,16 +8,90 @@ Provides standardized boilerplate for:
|
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import os
|
|
11
12
|
import sys
|
|
13
|
+
from datetime import datetime, timezone
|
|
12
14
|
from functools import wraps
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
15
17
|
|
|
16
|
-
from .
|
|
18
|
+
from .logger import log_hook_error, hook_log, log_debug, log_info, log_warn, log_error, log_diagnostic, set_context_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Context window baseline: tokens not visible in hook data
|
|
22
|
+
# (system prompt, tools, MCP tokens)
|
|
23
|
+
# See: https://github.com/anthropics/claude-code/issues/13783
|
|
24
|
+
CONTEXT_BASELINE_TOKENS = 22_600
|
|
25
|
+
DEFAULT_CONTEXT_WINDOW_SIZE = 200_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_context_window(hook_input: dict) -> tuple:
|
|
29
|
+
"""Parse context window from hook input.
|
|
30
|
+
|
|
31
|
+
Returns (tokens_used, max_tokens) or (None, None).
|
|
32
|
+
tokens_used includes baseline offset for system prompt/tools.
|
|
33
|
+
"""
|
|
34
|
+
context_window = hook_input.get("context_window")
|
|
35
|
+
if not context_window:
|
|
36
|
+
return None, None
|
|
37
|
+
current_usage = context_window.get("current_usage")
|
|
38
|
+
if not current_usage:
|
|
39
|
+
return None, None
|
|
40
|
+
cache_read = current_usage.get("cache_read_input_tokens", 0) or 0
|
|
41
|
+
input_tokens = current_usage.get("input_tokens", 0) or 0
|
|
42
|
+
cache_creation = current_usage.get("cache_creation_input_tokens", 0) or 0
|
|
43
|
+
output_tokens = current_usage.get("output_tokens", 0) or 0
|
|
44
|
+
content_tokens = cache_read + input_tokens + cache_creation + output_tokens
|
|
45
|
+
tokens_used = content_tokens + CONTEXT_BASELINE_TOKENS
|
|
46
|
+
max_tokens = context_window.get("context_window_size") or DEFAULT_CONTEXT_WINDOW_SIZE
|
|
47
|
+
return tokens_used, max_tokens
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_context_percent_remaining(hook_input: dict) -> tuple:
|
|
51
|
+
"""Get context percentage remaining with context.json fallback.
|
|
52
|
+
|
|
53
|
+
Tries two sources in order:
|
|
54
|
+
1. Hook input context_window data (most accurate, real-time)
|
|
55
|
+
2. context.json remaining_percentage (written by status_line.py)
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
(percent_remaining, tokens_used, max_tokens) where tokens_used and
|
|
59
|
+
max_tokens may be None if data came from context.json fallback.
|
|
60
|
+
Returns (None, None, None) if no data available from either source.
|
|
61
|
+
"""
|
|
62
|
+
# Source 1: Hook input (most accurate)
|
|
63
|
+
tokens_used, max_tokens = parse_context_window(hook_input)
|
|
64
|
+
if tokens_used is not None and max_tokens is not None and max_tokens > 0:
|
|
65
|
+
remaining = max_tokens - tokens_used
|
|
66
|
+
percent_remaining = max(0, min(100, int((remaining / max_tokens) * 100)))
|
|
67
|
+
return percent_remaining, tokens_used, max_tokens
|
|
68
|
+
|
|
69
|
+
# Source 2: context.json fallback (written by status_line.py)
|
|
70
|
+
try:
|
|
71
|
+
from .utils import project_dir
|
|
72
|
+
from ..context.context_store import get_context_by_session_id
|
|
73
|
+
|
|
74
|
+
session_id = hook_input.get("session_id")
|
|
75
|
+
if session_id:
|
|
76
|
+
project_root = project_dir(hook_input)
|
|
77
|
+
context = get_context_by_session_id(session_id, project_root)
|
|
78
|
+
if context and context.last_session:
|
|
79
|
+
pct = context.last_session.get("context_remaining_pct")
|
|
80
|
+
if pct is not None:
|
|
81
|
+
return pct, None, None
|
|
82
|
+
except Exception:
|
|
83
|
+
pass # Fallback failed — degrade gracefully
|
|
84
|
+
|
|
85
|
+
return None, None, None
|
|
86
|
+
|
|
17
87
|
|
|
18
88
|
# Type variable for generic decorators
|
|
19
89
|
F = TypeVar('F', bound=Callable[..., Any])
|
|
20
90
|
|
|
91
|
+
# Event metadata stash — populated by load_hook_input(), read by run_hook()
|
|
92
|
+
_last_hook_event: Optional[str] = None
|
|
93
|
+
_last_tool_name: Optional[str] = None
|
|
94
|
+
|
|
21
95
|
|
|
22
96
|
def load_hook_input() -> Optional[Dict[str, Any]]:
|
|
23
97
|
"""
|
|
@@ -26,11 +100,16 @@ def load_hook_input() -> Optional[Dict[str, Any]]:
|
|
|
26
100
|
Returns:
|
|
27
101
|
Parsed JSON dict, or None if stdin is empty or invalid JSON
|
|
28
102
|
"""
|
|
103
|
+
global _last_hook_event, _last_tool_name
|
|
29
104
|
try:
|
|
30
105
|
input_data = sys.stdin.read().strip()
|
|
31
106
|
if not input_data:
|
|
32
107
|
return None
|
|
33
|
-
|
|
108
|
+
result = json.loads(input_data)
|
|
109
|
+
if isinstance(result, dict):
|
|
110
|
+
_last_hook_event = result.get("hook_event_name")
|
|
111
|
+
_last_tool_name = result.get("tool_name")
|
|
112
|
+
return result
|
|
34
113
|
except json.JSONDecodeError:
|
|
35
114
|
return None
|
|
36
115
|
|
|
@@ -89,7 +168,7 @@ def check_skip_persistence(payload: Dict[str, Any], hook_name: str = "hook") ->
|
|
|
89
168
|
|
|
90
169
|
metadata = tool_input.get("metadata", {})
|
|
91
170
|
if isinstance(metadata, dict) and metadata.get("skip_persistence"):
|
|
92
|
-
|
|
171
|
+
log_debug(hook_name, "Skipping persistence (skip_persistence flag set)")
|
|
93
172
|
return True
|
|
94
173
|
return False
|
|
95
174
|
|
|
@@ -118,28 +197,137 @@ def safe_hook_main(hook_name: str) -> Callable[[F], F]:
|
|
|
118
197
|
try:
|
|
119
198
|
return func(*args, **kwargs)
|
|
120
199
|
except json.JSONDecodeError as e:
|
|
121
|
-
|
|
200
|
+
import traceback
|
|
201
|
+
tb = traceback.format_exc()
|
|
202
|
+
log_hook_error(hook_name, e, traceback_str=tb)
|
|
203
|
+
log_error(hook_name, f"JSON decode error: {e}")
|
|
122
204
|
return 0
|
|
123
205
|
except Exception as e:
|
|
124
|
-
eprint(f"[{hook_name}] Unexpected error: {e}")
|
|
125
206
|
import traceback
|
|
126
|
-
|
|
207
|
+
tb = traceback.format_exc()
|
|
208
|
+
log_hook_error(hook_name, e, traceback_str=tb)
|
|
209
|
+
log_error(hook_name, f"Unexpected error: {e}", traceback_str=tb)
|
|
127
210
|
return 0
|
|
128
211
|
return wrapper # type: ignore
|
|
129
212
|
return decorator
|
|
130
213
|
|
|
131
214
|
|
|
132
|
-
def
|
|
215
|
+
def emit_context(additional_context: str, ensure_ascii: bool = False) -> None:
|
|
216
|
+
"""Emit hookSpecificOutput with additionalContext to stdout.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
additional_context: Context string to inject into Claude's context
|
|
220
|
+
ensure_ascii: If True, escape non-ASCII characters in JSON output
|
|
133
221
|
"""
|
|
134
|
-
|
|
222
|
+
out = {
|
|
223
|
+
"hookSpecificOutput": {
|
|
224
|
+
"additionalContext": additional_context,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
print(json.dumps(out, ensure_ascii=ensure_ascii))
|
|
228
|
+
|
|
135
229
|
|
|
136
|
-
|
|
230
|
+
def emit_context_and_block(
|
|
231
|
+
additional_context: str,
|
|
232
|
+
reason: str,
|
|
233
|
+
ensure_ascii: bool = True,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Emit hookSpecificOutput that denies the tool call with context and reason.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
additional_context: Context string to inject into Claude's context
|
|
239
|
+
reason: Reason shown to Claude for why the tool call was denied
|
|
240
|
+
ensure_ascii: If True, escape non-ASCII characters in JSON output
|
|
241
|
+
"""
|
|
242
|
+
out = {
|
|
243
|
+
"hookSpecificOutput": {
|
|
244
|
+
"additionalContext": additional_context,
|
|
245
|
+
"permissionDecision": "deny",
|
|
246
|
+
"permissionDecisionReason": reason,
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
print(json.dumps(out, ensure_ascii=ensure_ascii))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _detect_template(script_path: str = "") -> str:
|
|
253
|
+
"""Auto-detect template origin from the hook script path.
|
|
254
|
+
|
|
255
|
+
Returns "shared", a template name (e.g., "cc-native"), or "unknown".
|
|
256
|
+
"""
|
|
257
|
+
import re
|
|
258
|
+
path = (script_path or (sys.argv[0] if sys.argv else "")).replace("\\", "/")
|
|
259
|
+
if "/_shared/hooks/" in path or path.startswith("_shared/hooks/"):
|
|
260
|
+
return "shared"
|
|
261
|
+
match = re.search(r'_([a-z][a-z0-9-]*)/hooks/', path)
|
|
262
|
+
if match:
|
|
263
|
+
return match.group(1) # e.g., "cc-native"
|
|
264
|
+
return "unknown"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def run_hook(main_func: Callable[[], int], hook_name: str = "unknown") -> None:
|
|
268
|
+
"""
|
|
269
|
+
Standard hook entry point wrapper with lifecycle logging.
|
|
270
|
+
|
|
271
|
+
Logs HOOK_START before calling main, HOOK_END after completion.
|
|
272
|
+
Catches unhandled exceptions and logs them before exiting cleanly.
|
|
137
273
|
|
|
138
274
|
Args:
|
|
139
275
|
main_func: Hook main function that returns exit code
|
|
276
|
+
hook_name: Name of the hook for error logging
|
|
140
277
|
|
|
141
278
|
Example:
|
|
142
279
|
if __name__ == "__main__":
|
|
143
|
-
run_hook(main)
|
|
280
|
+
run_hook(main, "my_hook")
|
|
144
281
|
"""
|
|
145
|
-
|
|
282
|
+
import time
|
|
283
|
+
start_time = time.monotonic()
|
|
284
|
+
template = _detect_template()
|
|
285
|
+
event = _last_hook_event or "unknown"
|
|
286
|
+
tool = _last_tool_name
|
|
287
|
+
|
|
288
|
+
# HOOK_START
|
|
289
|
+
start_data: Dict[str, Any] = {"lifecycle": "start", "template": template, "event": event}
|
|
290
|
+
if tool:
|
|
291
|
+
start_data["tool"] = tool
|
|
292
|
+
log_info(hook_name, "HOOK_START", data=start_data)
|
|
293
|
+
|
|
294
|
+
exit_code = 0
|
|
295
|
+
status = "success"
|
|
296
|
+
error_info = None
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
result = main_func()
|
|
300
|
+
exit_code = result if isinstance(result, int) else 0
|
|
301
|
+
status = "blocked" if exit_code != 0 else "success"
|
|
302
|
+
except SystemExit as e:
|
|
303
|
+
exit_code = e.code if isinstance(e.code, int) else (1 if e.code else 0)
|
|
304
|
+
status = "blocked" if exit_code != 0 else "success"
|
|
305
|
+
except Exception as e:
|
|
306
|
+
import traceback
|
|
307
|
+
exit_code = 0 # Non-blocking
|
|
308
|
+
status = "error"
|
|
309
|
+
error_info = (e, traceback.format_exc())
|
|
310
|
+
|
|
311
|
+
# HOOK_END
|
|
312
|
+
duration_ms = round((time.monotonic() - start_time) * 1000, 1)
|
|
313
|
+
end_data: Dict[str, Any] = {
|
|
314
|
+
"lifecycle": "end", "status": status,
|
|
315
|
+
"duration_ms": duration_ms, "exit_code": exit_code,
|
|
316
|
+
"template": template,
|
|
317
|
+
}
|
|
318
|
+
end_event = _last_hook_event or event # Re-read after main() populated it
|
|
319
|
+
end_tool = _last_tool_name or tool
|
|
320
|
+
end_data["event"] = end_event
|
|
321
|
+
if end_tool:
|
|
322
|
+
end_data["tool"] = end_tool
|
|
323
|
+
if error_info:
|
|
324
|
+
e, tb = error_info
|
|
325
|
+
end_data["error_type"] = type(e).__name__
|
|
326
|
+
log_hook_error(hook_name, e, traceback_str=tb)
|
|
327
|
+
log_error(hook_name, f"HOOK_END: {e}", data=end_data, traceback_str=tb)
|
|
328
|
+
elif status == "blocked":
|
|
329
|
+
log_warn(hook_name, "HOOK_END", data=end_data)
|
|
330
|
+
else:
|
|
331
|
+
log_info(hook_name, "HOOK_END", data=end_data)
|
|
332
|
+
|
|
333
|
+
raise SystemExit(exit_code)
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
Provides a unified interface for Claude API calls using the claude CLI.
|
|
4
4
|
Supports multiple model tiers: fast (Haiku), standard (Sonnet), smart (Opus).
|
|
5
5
|
"""
|
|
6
|
+
import re
|
|
6
7
|
import subprocess
|
|
7
8
|
import sys
|
|
8
9
|
import os
|
|
9
10
|
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from .logger import log_debug, log_info, log_warn, log_error
|
|
10
13
|
from dataclasses import dataclass
|
|
11
14
|
|
|
12
15
|
|
|
@@ -195,3 +198,121 @@ def generate_semantic_summary(prompt: str, timeout: int = 15) -> Optional[str]:
|
|
|
195
198
|
return None
|
|
196
199
|
|
|
197
200
|
return summary
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# System prompt for generating context ID slugs (3-12 keyword tags for folder names)
|
|
204
|
+
CONTEXT_ID_SLUG_PROMPT = """You are extracting keyword tags from a user's request to create a **folder name** for a work session.
|
|
205
|
+
|
|
206
|
+
## Why This Matters
|
|
207
|
+
|
|
208
|
+
These tags become part of a context ID like `260206-1959-refactor-webhook-retry-logic`. Users scan lists of 50-100+ such folder names to find past work sessions. Your job is to extract the 3-12 words that make THIS session instantly recognizable among hundreds of others.
|
|
209
|
+
|
|
210
|
+
Think: "If someone had 100 folders and needed to find this one by scanning names, which words would make it jump out?"
|
|
211
|
+
|
|
212
|
+
## First Word: Action Verb (REQUIRED)
|
|
213
|
+
|
|
214
|
+
The first word MUST be a specific action verb. Choose the most precise verb available:
|
|
215
|
+
|
|
216
|
+
Common: fix, add, implement, refactor, update, create, remove, replace, optimize, debug
|
|
217
|
+
Specific (preferred when they fit): scaffold, instrument, serialize, throttle, migrate, integrate, extract, redesign, restructure, decouple, consolidate, parallelize, configure, deploy, benchmark, normalize, validate, document, deprecate, upstream
|
|
218
|
+
|
|
219
|
+
## Word Selection: Rarity = Quality
|
|
220
|
+
|
|
221
|
+
The less common a word is in everyday language, the better it is as an identifier. Apply this mental filter:
|
|
222
|
+
|
|
223
|
+
BEST (+3): Proper nouns, brand names, unique identifiers (PostgreSQL, Webpack, OAuth, JWT, CICD)
|
|
224
|
+
GOOD (+2): Domain-specific technical terms (middleware, pooling, webhook, serialization, throttle)
|
|
225
|
+
OKAY (+1): Specific common nouns (authentication, redirect, navbar, pagination, dropdown)
|
|
226
|
+
WEAK (-1): Generic nouns that appear in most prompts (code, file, app, feature, issue, project, stuff, thing)
|
|
227
|
+
BANNED: See banned list below — these must NEVER appear in output
|
|
228
|
+
|
|
229
|
+
Select the 3-12 highest-value words. When choosing between two words that mean similar things, always pick the rarer/more specific one.
|
|
230
|
+
|
|
231
|
+
## Banned Words (NEVER include these)
|
|
232
|
+
|
|
233
|
+
Greetings/social: sure, okay, ok, hi, hello, hey, thanks, yeah, yes, no, well, right, um, uh
|
|
234
|
+
Pronouns/articles: I, my, me, we, our, you, your, he, she, it, they, the, a, an, this, that, these, those
|
|
235
|
+
Auxiliaries/modals: is, are, was, were, be, been, being, can, could, would, should, will, shall, may, might, must, do, does, did, have, has, had
|
|
236
|
+
Prepositions: to, with, for, in, of, on, at, by, from, into, about, through, between, after, before, during
|
|
237
|
+
Conjunctions: and, or, but, so, because, if, when, while, although, since
|
|
238
|
+
Filler/hedging: want, need, help, try, think, know, look, going, trying, looking, basically, actually, really, just, some, also, please, maybe, probably, kind, sort, pretty, very, quite, like
|
|
239
|
+
Generic: code, file, app, stuff, thing, feature, issue, problem, way, part, bit, lot, something
|
|
240
|
+
|
|
241
|
+
## Bad vs Good Examples
|
|
242
|
+
|
|
243
|
+
BAD: sure help refactor code cleanup -> starts with filler, "code" is generic
|
|
244
|
+
GOOD: refactor database queries connection pooling
|
|
245
|
+
|
|
246
|
+
BAD: want add feature authentication -> "want" is filler, "feature" is noise
|
|
247
|
+
GOOD: add authentication Express JWT middleware
|
|
248
|
+
|
|
249
|
+
BAD: looking fix issue login page -> gerund opener, "issue" is generic
|
|
250
|
+
GOOD: fix login redirect blank page session
|
|
251
|
+
|
|
252
|
+
BAD: improve things make better setup -> vague nouns, no technical specificity
|
|
253
|
+
GOOD: improve CI pipeline GitHub Actions caching
|
|
254
|
+
|
|
255
|
+
BAD: help update documentation readme stuff -> "help" opener, "stuff" is noise
|
|
256
|
+
GOOD: update README API endpoints documentation
|
|
257
|
+
|
|
258
|
+
BAD: thinking about maybe changing prompt -> hedging words, no specificity
|
|
259
|
+
GOOD: optimize context ID extraction prompting Opus
|
|
260
|
+
|
|
261
|
+
## Output
|
|
262
|
+
|
|
263
|
+
Output ONLY the keyword tags separated by spaces. Nothing else. No reasoning, no labels, no punctuation, no quotes, no hyphens, no markdown."""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def generate_context_id_slug(prompt: str, timeout: int = 3) -> Optional[str]:
|
|
267
|
+
"""
|
|
268
|
+
Generate a 3-12 word context ID slug from a user prompt using AI inference.
|
|
269
|
+
|
|
270
|
+
Uses Haiku (fast tier) for low-latency keyword extraction within hook timeout budgets.
|
|
271
|
+
Returns a cleaned, validated slug or None on failure.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
prompt: Raw user prompt to extract keywords from
|
|
275
|
+
timeout: Timeout in seconds (default 3, fits within 5-10s hook budget)
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Space-separated keyword slug (3-12 words) or None if failed
|
|
279
|
+
"""
|
|
280
|
+
# Truncate input to 500 chars to keep inference fast
|
|
281
|
+
truncated = prompt[:500] if len(prompt) > 500 else prompt
|
|
282
|
+
|
|
283
|
+
result = inference(
|
|
284
|
+
system_prompt=CONTEXT_ID_SLUG_PROMPT,
|
|
285
|
+
user_prompt=truncated,
|
|
286
|
+
level="fast",
|
|
287
|
+
timeout=timeout,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
if not result.success or not result.output:
|
|
291
|
+
log_warn("inference", f"Context ID slug inference failed: {result.error}")
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
slug = result.output.strip()
|
|
295
|
+
|
|
296
|
+
# Clean: strip quotes, punctuation, hyphens
|
|
297
|
+
slug = slug.strip('"\'`')
|
|
298
|
+
slug = slug.rstrip('.!?')
|
|
299
|
+
slug = slug.replace('-', ' ')
|
|
300
|
+
|
|
301
|
+
# Remove non-alphanumeric chars (except spaces)
|
|
302
|
+
slug = re.sub(r'[^a-zA-Z0-9 ]', '', slug)
|
|
303
|
+
|
|
304
|
+
# Normalize whitespace
|
|
305
|
+
slug = re.sub(r'\s+', ' ', slug).strip()
|
|
306
|
+
|
|
307
|
+
words = slug.split()
|
|
308
|
+
|
|
309
|
+
# Validate word count: truncate if over 12, reject if under 3
|
|
310
|
+
if len(words) > 12:
|
|
311
|
+
words = words[:12]
|
|
312
|
+
if len(words) < 3:
|
|
313
|
+
log_debug("inference", f"Context ID slug too short ({len(words)} words): '{slug}'")
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
result_slug = ' '.join(words)
|
|
317
|
+
log_debug("inference", f"Generated context ID slug: '{result_slug}' ({result.latency_ms}ms)")
|
|
318
|
+
return result_slug
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Unified logging for all hooks and libraries.
|
|
2
|
+
|
|
3
|
+
Provides a single logging interface that replaces:
|
|
4
|
+
- log_hook_error() from hook_utils.py (error-only, plain text)
|
|
5
|
+
- debug.py from cc-native (per-context, plain text)
|
|
6
|
+
- eprint() for diagnostic output (stderr-only, no persistence)
|
|
7
|
+
|
|
8
|
+
Log format: JSONL (one JSON object per line)
|
|
9
|
+
Log locations:
|
|
10
|
+
- With context: _output/contexts/<context-id>/debug/hook-log.jsonl
|
|
11
|
+
- Without context (fallback): _output/hook-log.jsonl
|
|
12
|
+
|
|
13
|
+
Environment variables:
|
|
14
|
+
- HOOK_LOG_DISABLE=1: Disable all file logging
|
|
15
|
+
- HOOK_LOG_LEVEL=warn: Minimum level to log (default: debug)
|
|
16
|
+
- HOOK_ERROR_LOG_DISABLE=1: Legacy alias for HOOK_LOG_DISABLE
|
|
17
|
+
|
|
18
|
+
Never raises — all errors silently swallowed.
|
|
19
|
+
No buffering — each call is one open+write+close.
|
|
20
|
+
Stdlib only — json, os, sys, datetime, pathlib.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, Optional
|
|
29
|
+
|
|
30
|
+
_LEVELS = {"debug": 0, "info": 1, "warn": 2, "error": 3}
|
|
31
|
+
|
|
32
|
+
_MAX_LOG_SIZE = 1024 * 1024 # 1MB
|
|
33
|
+
_TRUNCATE_TO = 512 * 1024 # 512KB
|
|
34
|
+
|
|
35
|
+
# Module-level context path cache.
|
|
36
|
+
# Set once per hook process via set_context_path() or auto-resolved on first use.
|
|
37
|
+
_cached_context_path: Optional[Path] = None
|
|
38
|
+
_context_resolved: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_context_path(path: Optional[Path]) -> None:
|
|
42
|
+
"""Set the context path for this process. All subsequent log calls use it.
|
|
43
|
+
|
|
44
|
+
Call this once in your hook after resolving the context:
|
|
45
|
+
from lib.base.logger import set_context_path
|
|
46
|
+
set_context_path(get_context_dir(context_id, project_root))
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
path: Path to context folder (e.g., _output/contexts/<context-id>/)
|
|
50
|
+
or None to force global-only logging.
|
|
51
|
+
"""
|
|
52
|
+
global _cached_context_path, _context_resolved
|
|
53
|
+
_cached_context_path = path
|
|
54
|
+
_context_resolved = True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _auto_resolve_context_path() -> Optional[Path]:
|
|
58
|
+
"""Try to auto-resolve context path from session_id. Called once per process.
|
|
59
|
+
|
|
60
|
+
Uses the context store to look up which context owns this session,
|
|
61
|
+
then returns its directory path. Falls back to None (global log).
|
|
62
|
+
"""
|
|
63
|
+
global _cached_context_path, _context_resolved
|
|
64
|
+
_context_resolved = True # Don't retry on failure
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
from ..context.context_store import get_context_by_session_id
|
|
68
|
+
from .constants import get_context_dir
|
|
69
|
+
|
|
70
|
+
# Hook input isn't available here, but we can check if a recent
|
|
71
|
+
# context dir exists by scanning _output/contexts/ for one that
|
|
72
|
+
# has a state.json with a matching session
|
|
73
|
+
# This is too expensive for a logger. Instead, rely on set_context_path().
|
|
74
|
+
except Exception:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _get_context_path() -> Optional[Path]:
|
|
81
|
+
"""Get the cached context path, auto-resolving on first call."""
|
|
82
|
+
global _context_resolved
|
|
83
|
+
if not _context_resolved:
|
|
84
|
+
_auto_resolve_context_path()
|
|
85
|
+
return _cached_context_path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_min_level() -> int:
|
|
89
|
+
"""Get minimum log level from environment."""
|
|
90
|
+
env = os.environ.get("HOOK_LOG_LEVEL", "debug").lower()
|
|
91
|
+
return _LEVELS.get(env, 0)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_disabled() -> bool:
|
|
95
|
+
"""Check if file logging is disabled."""
|
|
96
|
+
if os.environ.get("HOOK_LOG_DISABLE") == "1":
|
|
97
|
+
return True
|
|
98
|
+
if os.environ.get("HOOK_ERROR_LOG_DISABLE") == "1":
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_project_root() -> Path:
|
|
104
|
+
"""Get project root from environment or cwd."""
|
|
105
|
+
env_dir = os.environ.get("CLAUDE_PROJECT_DIR", "")
|
|
106
|
+
return Path(env_dir) if env_dir else Path.cwd()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def hook_log(
|
|
110
|
+
level: str,
|
|
111
|
+
hook_name: str,
|
|
112
|
+
message: str,
|
|
113
|
+
*,
|
|
114
|
+
component: str = "",
|
|
115
|
+
data: Any = None,
|
|
116
|
+
traceback_str: str = "",
|
|
117
|
+
context_path: Optional[Path] = None,
|
|
118
|
+
stderr: bool = True,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Write a structured log entry.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
level: "debug" | "info" | "warn" | "error"
|
|
124
|
+
hook_name: Hook or module name (e.g., "session_end")
|
|
125
|
+
message: Log message
|
|
126
|
+
component: Sub-component (e.g., "git", "parse")
|
|
127
|
+
data: Optional structured data (must be JSON-serializable)
|
|
128
|
+
traceback_str: Optional traceback string
|
|
129
|
+
context_path: If provided, logs to context debug dir
|
|
130
|
+
stderr: Also write to stderr (default: True)
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
level_lower = level.lower()
|
|
134
|
+
level_num = _LEVELS.get(level_lower, 0)
|
|
135
|
+
|
|
136
|
+
# Write to stderr if requested
|
|
137
|
+
if stderr:
|
|
138
|
+
prefix = f"[{hook_name}]"
|
|
139
|
+
if component:
|
|
140
|
+
prefix = f"[{hook_name}:{component}]"
|
|
141
|
+
print(f"{prefix} {message}", file=sys.stderr)
|
|
142
|
+
if traceback_str:
|
|
143
|
+
print(traceback_str, file=sys.stderr)
|
|
144
|
+
|
|
145
|
+
# Check if file logging is enabled
|
|
146
|
+
if _is_disabled():
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# Check minimum level
|
|
150
|
+
if level_num < _get_min_level():
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
# Build JSONL entry
|
|
154
|
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
|
|
155
|
+
entry = {
|
|
156
|
+
"ts": ts,
|
|
157
|
+
"level": level_lower,
|
|
158
|
+
"hook": hook_name,
|
|
159
|
+
"msg": message,
|
|
160
|
+
}
|
|
161
|
+
if component:
|
|
162
|
+
entry["component"] = component
|
|
163
|
+
if data is not None:
|
|
164
|
+
try:
|
|
165
|
+
json.dumps(data, default=str) # Validate serializable
|
|
166
|
+
entry["data"] = data
|
|
167
|
+
except (TypeError, ValueError):
|
|
168
|
+
entry["data"] = str(data)
|
|
169
|
+
if traceback_str:
|
|
170
|
+
entry["tb"] = traceback_str.rstrip()
|
|
171
|
+
|
|
172
|
+
line = json.dumps(entry, ensure_ascii=True, default=str) + "\n"
|
|
173
|
+
|
|
174
|
+
# Determine log path: explicit > cached > global
|
|
175
|
+
resolved_ctx = context_path or _get_context_path()
|
|
176
|
+
if resolved_ctx and resolved_ctx.exists():
|
|
177
|
+
log_path = resolved_ctx / "debug" / "hook-log.jsonl"
|
|
178
|
+
else:
|
|
179
|
+
project_root = _get_project_root()
|
|
180
|
+
log_path = project_root / "_output" / "hook-log.jsonl"
|
|
181
|
+
|
|
182
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
|
|
184
|
+
# Size guard (global log only, not per-context)
|
|
185
|
+
if not resolved_ctx and log_path.exists():
|
|
186
|
+
try:
|
|
187
|
+
if log_path.stat().st_size > _MAX_LOG_SIZE:
|
|
188
|
+
file_data = log_path.read_bytes()
|
|
189
|
+
log_path.write_bytes(file_data[-_TRUNCATE_TO:])
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
194
|
+
f.write(line)
|
|
195
|
+
|
|
196
|
+
except Exception:
|
|
197
|
+
pass # Never crash
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def log_debug(hook_name: str, message: str, **kwargs: Any) -> None:
|
|
201
|
+
"""Log a debug-level message."""
|
|
202
|
+
hook_log("debug", hook_name, message, **kwargs)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def log_info(hook_name: str, message: str, **kwargs: Any) -> None:
|
|
206
|
+
"""Log an info-level message."""
|
|
207
|
+
hook_log("info", hook_name, message, **kwargs)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def log_warn(hook_name: str, message: str, **kwargs: Any) -> None:
|
|
211
|
+
"""Log a warn-level message."""
|
|
212
|
+
hook_log("warn", hook_name, message, **kwargs)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def log_error(hook_name: str, message: str, **kwargs: Any) -> None:
|
|
216
|
+
"""Log an error-level message."""
|
|
217
|
+
hook_log("error", hook_name, message, **kwargs)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def log_diagnostic(
|
|
221
|
+
hook_name: str,
|
|
222
|
+
phase: str,
|
|
223
|
+
summary: str,
|
|
224
|
+
*,
|
|
225
|
+
inputs: Any = None,
|
|
226
|
+
decision: Any = None,
|
|
227
|
+
reasoning: Any = None,
|
|
228
|
+
component: str = "diag",
|
|
229
|
+
data: Any = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Log a structured diagnostic entry at a hook decision point.
|
|
232
|
+
|
|
233
|
+
Emits a debug-level JSONL entry with tagged, filterable data.
|
|
234
|
+
Use at key decision points: receive (what came in), decide (what was chosen),
|
|
235
|
+
result (what happened).
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
hook_name: Hook or module name (e.g., "session_start")
|
|
239
|
+
phase: Decision phase — "receive", "decide", or "result"
|
|
240
|
+
summary: One-line description (e.g., "source=clear, session=a1b2c3d4")
|
|
241
|
+
inputs: Input data relevant to this phase
|
|
242
|
+
decision: The decision made (for "decide" phase)
|
|
243
|
+
reasoning: Why this decision was made
|
|
244
|
+
component: Log component tag (default: "diag")
|
|
245
|
+
data: Extra data to merge into the structured entry
|
|
246
|
+
"""
|
|
247
|
+
diag_data: Dict[str, Any] = {"phase": phase}
|
|
248
|
+
if inputs is not None:
|
|
249
|
+
diag_data["inputs"] = inputs
|
|
250
|
+
if decision is not None:
|
|
251
|
+
diag_data["decision"] = decision
|
|
252
|
+
if reasoning is not None:
|
|
253
|
+
diag_data["reasoning"] = reasoning
|
|
254
|
+
if data is not None and isinstance(data, dict):
|
|
255
|
+
diag_data.update(data)
|
|
256
|
+
hook_log(
|
|
257
|
+
"debug",
|
|
258
|
+
hook_name,
|
|
259
|
+
f"[DIAG:{phase}] {summary}",
|
|
260
|
+
component=component,
|
|
261
|
+
data=diag_data,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def log_hook_error(
|
|
266
|
+
hook_name: str,
|
|
267
|
+
error: Exception,
|
|
268
|
+
hook_event: str = "unknown",
|
|
269
|
+
traceback_str: str = "",
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Backward-compatible wrapper matching the old hook_utils.log_hook_error signature.
|
|
272
|
+
|
|
273
|
+
Delegates to hook_log("error", ...) with the same behavior:
|
|
274
|
+
- Message capped at 200 chars, newlines stripped
|
|
275
|
+
- Never raises
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
hook_name: Name of the hook
|
|
279
|
+
error: The exception that occurred
|
|
280
|
+
hook_event: Hook event type (e.g., "PreToolUse")
|
|
281
|
+
traceback_str: Optional formatted traceback
|
|
282
|
+
"""
|
|
283
|
+
msg = str(error).replace("\n", " ").replace("\r", "")[:200]
|
|
284
|
+
err_type = type(error).__name__
|
|
285
|
+
hook_log(
|
|
286
|
+
"error",
|
|
287
|
+
hook_name,
|
|
288
|
+
f"[{hook_event}] {err_type}: {msg}",
|
|
289
|
+
traceback_str=traceback_str,
|
|
290
|
+
stderr=True,
|
|
291
|
+
)
|