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.
Files changed (116) hide show
  1. package/bin/run.js +5 -2
  2. package/dist/lib/claude-settings-types.d.ts +2 -0
  3. package/dist/templates/CLAUDE.md +3 -3
  4. package/dist/templates/_shared/.claude/settings.json +4 -0
  5. package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  7. package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
  8. package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
  9. package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
  10. package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
  11. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  12. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  13. package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
  14. package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
  15. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  16. package/dist/templates/_shared/hooks/archive_plan.py +87 -178
  17. package/dist/templates/_shared/hooks/context_monitor.py +104 -247
  18. package/dist/templates/_shared/hooks/file-suggestion.py +26 -23
  19. package/dist/templates/_shared/hooks/pre_compact.py +47 -32
  20. package/dist/templates/_shared/hooks/session_end.py +103 -60
  21. package/dist/templates/_shared/hooks/session_start.py +110 -81
  22. package/dist/templates/_shared/hooks/task_create_capture.py +26 -50
  23. package/dist/templates/_shared/hooks/task_update_capture.py +42 -115
  24. package/dist/templates/_shared/hooks/user_prompt_submit.py +61 -61
  25. package/dist/templates/_shared/lib/base/__init__.py +16 -0
  26. package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
  28. package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
  29. package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
  30. package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
  31. package/dist/templates/_shared/lib/base/hook_utils.py +199 -11
  32. package/dist/templates/_shared/lib/base/inference.py +121 -0
  33. package/dist/templates/_shared/lib/base/logger.py +291 -0
  34. package/dist/templates/_shared/lib/base/utils.py +42 -9
  35. package/dist/templates/_shared/lib/context/__init__.py +72 -80
  36. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  38. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  39. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  40. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  41. package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
  42. package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
  43. package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
  44. package/dist/templates/_shared/lib/context/context_formatter.py +316 -0
  45. package/dist/templates/_shared/lib/context/context_selector.py +491 -0
  46. package/dist/templates/_shared/lib/context/context_store.py +636 -0
  47. package/dist/templates/_shared/lib/context/plan_manager.py +204 -0
  48. package/dist/templates/_shared/lib/context/task_tracker.py +188 -0
  49. package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
  50. package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
  51. package/dist/templates/_shared/lib/handoff/document_generator.py +14 -40
  52. package/dist/templates/_shared/lib/templates/README.md +5 -13
  53. package/dist/templates/_shared/lib/templates/__init__.py +2 -6
  54. package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
  56. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  57. package/dist/templates/_shared/lib/templates/plan_context.py +1 -38
  58. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  59. package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
  60. package/dist/templates/_shared/scripts/save_handoff.py +39 -19
  61. package/dist/templates/_shared/scripts/status_line.py +701 -0
  62. package/dist/templates/_shared/workflows/handoff.md +9 -3
  63. package/dist/templates/cc-native/.claude/settings.json +41 -8
  64. package/dist/templates/cc-native/CC-NATIVE-README.md +25 -28
  65. package/dist/templates/cc-native/MIGRATION.md +1 -1
  66. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +14 -39
  67. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +49 -21
  68. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  69. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  70. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
  71. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
  72. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
  73. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
  74. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +57 -55
  75. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +163 -131
  76. package/dist/templates/cc-native/_cc-native/hooks/plan_accepted.py +127 -0
  77. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +81 -0
  78. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +26 -25
  79. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +6 -4
  80. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  82. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  83. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  84. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  85. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  86. package/dist/templates/cc-native/_cc-native/lib/debug.py +37 -22
  87. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +34 -29
  88. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  89. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  90. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  91. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  92. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  93. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +26 -21
  94. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +12 -7
  95. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +12 -7
  96. package/dist/templates/cc-native/_cc-native/lib/state.py +31 -16
  97. package/dist/templates/cc-native/_cc-native/lib/utils.py +207 -40
  98. package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -2
  99. package/oclif.manifest.json +1 -1
  100. package/package.json +1 -1
  101. package/dist/templates/_shared/hooks/context_enforcer.py +0 -625
  102. package/dist/templates/_shared/hooks/task_create_atomicity.py +0 -177
  103. package/dist/templates/_shared/lib/context/auto_state.py +0 -167
  104. package/dist/templates/_shared/lib/context/cache.py +0 -444
  105. package/dist/templates/_shared/lib/context/context_extractor.py +0 -115
  106. package/dist/templates/_shared/lib/context/context_manager.py +0 -1057
  107. package/dist/templates/_shared/lib/context/discovery.py +0 -554
  108. package/dist/templates/_shared/lib/context/event_log.py +0 -316
  109. package/dist/templates/_shared/lib/context/plan_archive.py +0 -101
  110. package/dist/templates/_shared/lib/context/task_sync.py +0 -407
  111. package/dist/templates/_shared/lib/templates/persona_questions.py +0 -113
  112. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  113. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
  114. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
  115. package/dist/templates/cc-native/_cc-native/lib/async_archive.py +0 -68
  116. 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 .utils import eprint
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
- return json.loads(input_data)
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
- eprint(f"[{hook_name}] Skipping persistence (skip_persistence flag set)")
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
- eprint(f"[{hook_name}] JSON decode error: {e}")
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
- eprint(traceback.format_exc())
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 run_hook(main_func: Callable[[], int]) -> None:
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
- Standard hook entry point wrapper.
222
+ out = {
223
+ "hookSpecificOutput": {
224
+ "additionalContext": additional_context,
225
+ }
226
+ }
227
+ print(json.dumps(out, ensure_ascii=ensure_ascii))
228
+
135
229
 
136
- Calls main function and exits with its return code.
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
- raise SystemExit(main_func())
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
+ )