aiwcli 0.10.3 → 0.11.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 +1 -1
- package/dist/commands/clear.js +28 -131
- package/dist/commands/init/index.js +3 -3
- package/dist/lib/gitignore-manager.d.ts +32 -0
- package/dist/lib/gitignore-manager.js +141 -2
- package/dist/templates/CLAUDE.md +8 -8
- package/dist/templates/_shared/.claude/commands/handoff-resume.md +64 -0
- package/dist/templates/_shared/.claude/commands/handoff.md +16 -10
- package/dist/templates/_shared/.claude/settings.json +7 -7
- package/dist/templates/_shared/hooks-ts/_utils/git-state.ts +2 -0
- package/dist/templates/_shared/hooks-ts/archive_plan.ts +159 -0
- package/dist/templates/_shared/hooks-ts/context_monitor.ts +147 -0
- package/dist/templates/_shared/hooks-ts/file-suggestion.ts +130 -0
- package/dist/templates/_shared/hooks-ts/pre_compact.ts +49 -0
- package/dist/templates/_shared/hooks-ts/session_end.ts +104 -0
- package/dist/templates/_shared/hooks-ts/session_start.ts +144 -0
- package/dist/templates/_shared/hooks-ts/task_create_capture.ts +48 -0
- package/dist/templates/_shared/hooks-ts/task_update_capture.ts +74 -0
- package/dist/templates/_shared/hooks-ts/user_prompt_submit.ts +83 -0
- package/dist/templates/_shared/lib-ts/CLAUDE.md +318 -0
- package/dist/templates/_shared/lib-ts/base/atomic-write.ts +12 -12
- package/dist/templates/_shared/lib-ts/base/constants.ts +22 -15
- package/dist/templates/_shared/lib-ts/base/hook-utils.ts +129 -50
- package/dist/templates/_shared/lib-ts/base/inference.ts +28 -21
- package/dist/templates/_shared/lib-ts/base/logger.ts +31 -15
- package/dist/templates/_shared/lib-ts/base/state-io.ts +9 -7
- package/dist/templates/_shared/lib-ts/base/stop-words.ts +131 -131
- package/dist/templates/_shared/lib-ts/base/subprocess-utils.ts +139 -0
- package/dist/templates/_shared/lib-ts/base/utils.ts +69 -69
- package/dist/templates/_shared/lib-ts/context/context-formatter.ts +30 -24
- package/dist/templates/_shared/lib-ts/context/context-selector.ts +50 -32
- package/dist/templates/_shared/lib-ts/context/context-store.ts +76 -48
- package/dist/templates/_shared/lib-ts/context/plan-manager.ts +61 -37
- package/dist/templates/_shared/lib-ts/context/task-tracker.ts +10 -6
- package/dist/templates/_shared/lib-ts/handoff/document-generator.ts +11 -10
- package/dist/templates/_shared/lib-ts/handoff/handoff-reader.ts +159 -0
- package/dist/templates/_shared/lib-ts/templates/formatters.ts +6 -4
- package/dist/templates/_shared/lib-ts/types.ts +68 -55
- package/dist/templates/_shared/scripts/resolve_context.ts +24 -0
- package/dist/templates/_shared/scripts/resume_handoff.ts +321 -0
- package/dist/templates/_shared/scripts/save_handoff.ts +21 -21
- package/dist/templates/_shared/scripts/status_line.ts +733 -0
- package/dist/templates/cc-native/.claude/settings.json +175 -185
- package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +15 -17
- package/dist/templates/cc-native/_cc-native/agents/CLAUDE.md +0 -2
- package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +109 -135
- package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.ts +119 -0
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.ts +921 -0
- package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.ts +61 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/aggregate-agents.ts +157 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/artifacts.ts +709 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/cc-native-state.ts +199 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +124 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/config.ts +57 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/constants.ts +83 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/debug.ts +80 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/index.ts +119 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/json-parser.ts +162 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/nul +3 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/orchestrator.ts +249 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/agent.ts +155 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/codex.ts +130 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/gemini.ts +106 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/index.ts +10 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/types.ts +23 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +243 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/tsconfig.json +18 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +310 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/verdict.ts +72 -0
- package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -9
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
- package/dist/templates/_shared/hooks/__init__.py +0 -16
- 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_atomicity.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 +0 -177
- package/dist/templates/_shared/hooks/context_monitor.py +0 -270
- package/dist/templates/_shared/hooks/file-suggestion.py +0 -215
- package/dist/templates/_shared/hooks/pre_compact.py +0 -104
- package/dist/templates/_shared/hooks/session_end.py +0 -173
- package/dist/templates/_shared/hooks/session_start.py +0 -206
- package/dist/templates/_shared/hooks/task_create_capture.py +0 -108
- package/dist/templates/_shared/hooks/task_update_capture.py +0 -145
- package/dist/templates/_shared/hooks/user_prompt_submit.py +0 -139
- package/dist/templates/_shared/lib/__init__.py +0 -1
- package/dist/templates/_shared/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__init__.py +0 -65
- package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/atomic_write.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/constants.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__/stop_words.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/subprocess_utils.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/atomic_write.py +0 -180
- package/dist/templates/_shared/lib/base/constants.py +0 -358
- package/dist/templates/_shared/lib/base/hook_utils.py +0 -339
- package/dist/templates/_shared/lib/base/inference.py +0 -307
- package/dist/templates/_shared/lib/base/logger.py +0 -305
- package/dist/templates/_shared/lib/base/stop_words.py +0 -221
- package/dist/templates/_shared/lib/base/subprocess_utils.py +0 -46
- package/dist/templates/_shared/lib/base/utils.py +0 -263
- package/dist/templates/_shared/lib/context/__init__.py +0 -102
- package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_extractor.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__/event_log.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/plan_archive.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_sync.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 +0 -317
- package/dist/templates/_shared/lib/context/context_selector.py +0 -508
- package/dist/templates/_shared/lib/context/context_store.py +0 -653
- package/dist/templates/_shared/lib/context/plan_manager.py +0 -303
- package/dist/templates/_shared/lib/context/task_tracker.py +0 -188
- package/dist/templates/_shared/lib/handoff/__init__.py +0 -22
- 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 +0 -278
- package/dist/templates/_shared/lib/templates/README.md +0 -206
- package/dist/templates/_shared/lib/templates/__init__.py +0 -36
- 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__/persona_questions.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/formatters.py +0 -146
- package/dist/templates/_shared/lib/templates/plan_context.py +0 -73
- 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 +0 -357
- package/dist/templates/_shared/scripts/status_line.py +0 -716
- package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +0 -8
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +0 -8
- package/dist/templates/cc-native/MIGRATION.md +0 -86
- 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 +0 -130
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +0 -954
- package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +0 -81
- package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +0 -340
- package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +0 -265
- package/dist/templates/cc-native/_cc-native/lib/__init__.py +0 -53
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/atomic_write.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/constants.py +0 -45
- package/dist/templates/cc-native/_cc-native/lib/debug.py +0 -139
- package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +0 -362
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +0 -28
- 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 +0 -215
- package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +0 -88
- package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +0 -124
- package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +0 -108
- package/dist/templates/cc-native/_cc-native/lib/state.py +0 -268
- package/dist/templates/cc-native/_cc-native/lib/utils.py +0 -1071
- package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +0 -168
- package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +0 -134
|
@@ -1,358 +0,0 @@
|
|
|
1
|
-
"""Constants and path utilities for shared context management.
|
|
2
|
-
|
|
3
|
-
Data hierarchy:
|
|
4
|
-
events.jsonl (source of truth)
|
|
5
|
-
→ context.json (L1 cache)
|
|
6
|
-
→ index.json (L2 cache)
|
|
7
|
-
|
|
8
|
-
All data written to _output/contexts/ (method-agnostic).
|
|
9
|
-
No method subfolders - method is just metadata on the context.
|
|
10
|
-
"""
|
|
11
|
-
import os
|
|
12
|
-
import re
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
|
|
15
|
-
# Directory names (relative to project root)
|
|
16
|
-
OUTPUT_DIR = "_output"
|
|
17
|
-
CONTEXTS_DIR = "contexts"
|
|
18
|
-
ARCHIVE_DIR = "_archive"
|
|
19
|
-
INDEX_FILENAME = "index.json"
|
|
20
|
-
|
|
21
|
-
# Context ID validation
|
|
22
|
-
MAX_CONTEXT_ID_LENGTH = 64
|
|
23
|
-
VALID_CONTEXT_ID_PATTERN = re.compile(r'^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$')
|
|
24
|
-
|
|
25
|
-
# File size limits
|
|
26
|
-
MAX_EVENT_SIZE = 64 * 1024 # 64KB per event (reasonable limit)
|
|
27
|
-
MAX_INDEX_SIZE = 1024 * 1024 # 1MB for index.json
|
|
28
|
-
|
|
29
|
-
# Performance constants
|
|
30
|
-
MAX_RETRY_ATTEMPTS = 2
|
|
31
|
-
RETRY_BACKOFF_MS = [500, 1000]
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def sanitize_context_id(context_id: str) -> str:
|
|
35
|
-
"""
|
|
36
|
-
Sanitize a string into a valid context ID.
|
|
37
|
-
|
|
38
|
-
Performs these transformations:
|
|
39
|
-
- Convert to lowercase
|
|
40
|
-
- Replace invalid characters with hyphens
|
|
41
|
-
- Collapse consecutive hyphens/underscores
|
|
42
|
-
- Strip leading/trailing non-alphanumeric
|
|
43
|
-
- Truncate to MAX_CONTEXT_ID_LENGTH
|
|
44
|
-
|
|
45
|
-
Args:
|
|
46
|
-
context_id: Raw input string
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
Valid context ID string, or "context" if input is empty/invalid
|
|
50
|
-
"""
|
|
51
|
-
if not context_id:
|
|
52
|
-
return "context"
|
|
53
|
-
|
|
54
|
-
# Normalize to lowercase
|
|
55
|
-
result = context_id.lower()
|
|
56
|
-
|
|
57
|
-
# Replace any character that's not alphanumeric, hyphen, or underscore
|
|
58
|
-
result = re.sub(r'[^a-z0-9_-]', '-', result)
|
|
59
|
-
|
|
60
|
-
# Collapse consecutive hyphens/underscores into single hyphen
|
|
61
|
-
result = re.sub(r'[-_]+', '-', result)
|
|
62
|
-
|
|
63
|
-
# Strip leading/trailing non-alphanumeric
|
|
64
|
-
result = result.strip('-_')
|
|
65
|
-
|
|
66
|
-
# Truncate to max length
|
|
67
|
-
if len(result) > MAX_CONTEXT_ID_LENGTH:
|
|
68
|
-
result = result[:MAX_CONTEXT_ID_LENGTH].rstrip('-_')
|
|
69
|
-
|
|
70
|
-
# If nothing left, return default
|
|
71
|
-
return result if result else "context"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def validate_context_id(context_id: str) -> str:
|
|
75
|
-
"""
|
|
76
|
-
Validate and normalize context ID.
|
|
77
|
-
|
|
78
|
-
Auto-sanitizes invalid input instead of throwing errors for format issues.
|
|
79
|
-
Only throws for security violations (path traversal).
|
|
80
|
-
|
|
81
|
-
Valid context IDs:
|
|
82
|
-
- 1-64 characters
|
|
83
|
-
- lowercase alphanumeric, hyphens, underscores
|
|
84
|
-
- must start and end with alphanumeric
|
|
85
|
-
- no consecutive hyphens/underscores
|
|
86
|
-
|
|
87
|
-
Raises:
|
|
88
|
-
ValueError: Only for path traversal attempts
|
|
89
|
-
"""
|
|
90
|
-
if not context_id:
|
|
91
|
-
return "context"
|
|
92
|
-
|
|
93
|
-
# SECURITY: Check for path traversal BEFORE any normalization
|
|
94
|
-
# This prevents encoded or case-variant attacks
|
|
95
|
-
if '..' in context_id or '/' in context_id or '\\' in context_id:
|
|
96
|
-
raise ValueError(f"Invalid context ID '{context_id}': path traversal not allowed")
|
|
97
|
-
|
|
98
|
-
# Also check for URL-encoded variants
|
|
99
|
-
if '%2e' in context_id.lower() or '%2f' in context_id.lower() or '%5c' in context_id.lower():
|
|
100
|
-
raise ValueError(f"Invalid context ID '{context_id}': encoded path traversal not allowed")
|
|
101
|
-
|
|
102
|
-
# Sanitize instead of throwing for format issues
|
|
103
|
-
sanitized = sanitize_context_id(context_id)
|
|
104
|
-
|
|
105
|
-
return sanitized
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def get_output_dir(project_root: Path = None) -> Path:
|
|
109
|
-
"""
|
|
110
|
-
Get the output directory path.
|
|
111
|
-
|
|
112
|
-
Args:
|
|
113
|
-
project_root: Project root directory (default: cwd)
|
|
114
|
-
|
|
115
|
-
Returns:
|
|
116
|
-
Path to _output/
|
|
117
|
-
"""
|
|
118
|
-
if project_root is None:
|
|
119
|
-
project_root = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()))
|
|
120
|
-
return Path(project_root) / OUTPUT_DIR
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def get_contexts_dir(project_root: Path = None) -> Path:
|
|
124
|
-
"""
|
|
125
|
-
Get the contexts directory path.
|
|
126
|
-
|
|
127
|
-
Args:
|
|
128
|
-
project_root: Project root directory (default: cwd)
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
Path to _output/contexts/
|
|
132
|
-
"""
|
|
133
|
-
return get_output_dir(project_root) / CONTEXTS_DIR
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def get_context_dir(context_id: str, project_root: Path = None) -> Path:
|
|
137
|
-
"""
|
|
138
|
-
Get the directory path for a specific context.
|
|
139
|
-
|
|
140
|
-
Args:
|
|
141
|
-
context_id: Context identifier
|
|
142
|
-
project_root: Project root directory (default: cwd)
|
|
143
|
-
|
|
144
|
-
Returns:
|
|
145
|
-
Path to _output/contexts/{context_id}/
|
|
146
|
-
|
|
147
|
-
Raises:
|
|
148
|
-
ValueError: If context_id is invalid or path escapes expected directory
|
|
149
|
-
"""
|
|
150
|
-
validated_id = validate_context_id(context_id)
|
|
151
|
-
contexts_dir = get_contexts_dir(project_root)
|
|
152
|
-
result_path = contexts_dir / validated_id
|
|
153
|
-
|
|
154
|
-
# SECURITY: Verify resolved path stays within contexts directory
|
|
155
|
-
# This prevents symlink attacks and any path manipulation we might have missed
|
|
156
|
-
try:
|
|
157
|
-
resolved = result_path.resolve()
|
|
158
|
-
contexts_resolved = contexts_dir.resolve()
|
|
159
|
-
# Check that resolved path starts with the contexts directory
|
|
160
|
-
# Use os.path for cross-platform compatibility
|
|
161
|
-
import os
|
|
162
|
-
resolved_str = os.path.normcase(str(resolved))
|
|
163
|
-
contexts_str = os.path.normcase(str(contexts_resolved))
|
|
164
|
-
if not resolved_str.startswith(contexts_str):
|
|
165
|
-
raise ValueError(f"Invalid context ID '{context_id}': path escapes contexts directory")
|
|
166
|
-
except (OSError, ValueError) as e:
|
|
167
|
-
if isinstance(e, ValueError):
|
|
168
|
-
raise
|
|
169
|
-
# OSError can occur if path doesn't exist yet, which is fine for creation
|
|
170
|
-
pass
|
|
171
|
-
|
|
172
|
-
return result_path
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def get_context_plans_dir(context_id: str, project_root: Path = None) -> Path:
|
|
176
|
-
"""
|
|
177
|
-
Get the plans directory for a specific context.
|
|
178
|
-
|
|
179
|
-
Args:
|
|
180
|
-
context_id: Context identifier
|
|
181
|
-
project_root: Project root directory (default: cwd)
|
|
182
|
-
|
|
183
|
-
Returns:
|
|
184
|
-
Path to _output/contexts/{context_id}/plans/
|
|
185
|
-
"""
|
|
186
|
-
return get_context_dir(context_id, project_root) / "plans"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def get_context_handoffs_dir(context_id: str, project_root: Path = None) -> Path:
|
|
190
|
-
"""
|
|
191
|
-
Get the handoffs directory for a specific context.
|
|
192
|
-
|
|
193
|
-
Args:
|
|
194
|
-
context_id: Context identifier
|
|
195
|
-
project_root: Project root directory (default: cwd)
|
|
196
|
-
|
|
197
|
-
Returns:
|
|
198
|
-
Path to _output/contexts/{context_id}/handoffs/
|
|
199
|
-
"""
|
|
200
|
-
return get_context_dir(context_id, project_root) / "handoffs"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def get_context_reviews_dir(context_id: str, project_root: Path = None) -> Path:
|
|
204
|
-
"""
|
|
205
|
-
Get the reviews directory for a specific context.
|
|
206
|
-
|
|
207
|
-
Args:
|
|
208
|
-
context_id: Context identifier
|
|
209
|
-
project_root: Project root directory (default: cwd)
|
|
210
|
-
|
|
211
|
-
Returns:
|
|
212
|
-
Path to _output/contexts/{context_id}/reviews/
|
|
213
|
-
"""
|
|
214
|
-
return get_context_dir(context_id, project_root) / "reviews"
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def get_index_path(project_root: Path = None) -> Path:
|
|
218
|
-
"""
|
|
219
|
-
Get the global index file path.
|
|
220
|
-
|
|
221
|
-
Args:
|
|
222
|
-
project_root: Project root directory (default: cwd)
|
|
223
|
-
|
|
224
|
-
Returns:
|
|
225
|
-
Path to _output/index.json
|
|
226
|
-
"""
|
|
227
|
-
return get_output_dir(project_root) / INDEX_FILENAME
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def get_context_file_path(context_id: str, project_root: Path = None) -> Path:
|
|
231
|
-
"""
|
|
232
|
-
Get the context.json file path for a specific context.
|
|
233
|
-
|
|
234
|
-
Args:
|
|
235
|
-
context_id: Context identifier
|
|
236
|
-
project_root: Project root directory (default: cwd)
|
|
237
|
-
|
|
238
|
-
Returns:
|
|
239
|
-
Path to _output/contexts/{context_id}/context.json
|
|
240
|
-
"""
|
|
241
|
-
return get_context_dir(context_id, project_root) / "context.json"
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def get_events_file_path(context_id: str, project_root: Path = None) -> Path:
|
|
245
|
-
"""
|
|
246
|
-
Get the events.jsonl file path for a specific context.
|
|
247
|
-
|
|
248
|
-
Args:
|
|
249
|
-
context_id: Context identifier
|
|
250
|
-
project_root: Project root directory (default: cwd)
|
|
251
|
-
|
|
252
|
-
Returns:
|
|
253
|
-
Path to _output/contexts/{context_id}/events.jsonl
|
|
254
|
-
"""
|
|
255
|
-
return get_context_dir(context_id, project_root) / "events.jsonl"
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
def get_auto_state_path(context_id: str, project_root: Path = None) -> Path:
|
|
259
|
-
"""
|
|
260
|
-
Get the auto-state.json file path for a specific context.
|
|
261
|
-
|
|
262
|
-
Args:
|
|
263
|
-
context_id: Context identifier
|
|
264
|
-
project_root: Project root directory (default: cwd)
|
|
265
|
-
|
|
266
|
-
Returns:
|
|
267
|
-
Path to _output/contexts/{context_id}/auto-state.json
|
|
268
|
-
"""
|
|
269
|
-
return get_context_dir(context_id, project_root) / "auto-state.json"
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def get_archive_dir(project_root: Path = None) -> Path:
|
|
273
|
-
"""
|
|
274
|
-
Get the archive directory path.
|
|
275
|
-
|
|
276
|
-
Args:
|
|
277
|
-
project_root: Project root directory (default: cwd)
|
|
278
|
-
|
|
279
|
-
Returns:
|
|
280
|
-
Path to _output/contexts/_archive/
|
|
281
|
-
"""
|
|
282
|
-
return get_contexts_dir(project_root) / ARCHIVE_DIR
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def get_archive_context_dir(context_id: str, project_root: Path = None) -> Path:
|
|
286
|
-
"""
|
|
287
|
-
Get the archive directory for a specific context.
|
|
288
|
-
|
|
289
|
-
Args:
|
|
290
|
-
context_id: Context identifier
|
|
291
|
-
project_root: Project root directory (default: cwd)
|
|
292
|
-
|
|
293
|
-
Returns:
|
|
294
|
-
Path to _output/contexts/_archive/{context_id}/
|
|
295
|
-
|
|
296
|
-
Raises:
|
|
297
|
-
ValueError: If context_id is invalid
|
|
298
|
-
"""
|
|
299
|
-
validated_id = validate_context_id(context_id)
|
|
300
|
-
return get_archive_dir(project_root) / validated_id
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def get_archive_index_path(project_root: Path = None) -> Path:
|
|
304
|
-
"""
|
|
305
|
-
Get the archive index file path.
|
|
306
|
-
|
|
307
|
-
Args:
|
|
308
|
-
project_root: Project root directory (default: cwd)
|
|
309
|
-
|
|
310
|
-
Returns:
|
|
311
|
-
Path to _output/contexts/_archive/index.json
|
|
312
|
-
"""
|
|
313
|
-
return get_archive_dir(project_root) / INDEX_FILENAME
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def get_handoff_folder_path(context_id: str, project_root: Path = None) -> Path:
|
|
317
|
-
"""Get path for a new handoff folder with datetime naming.
|
|
318
|
-
|
|
319
|
-
Returns: _output/contexts/{context_id}/handoffs/{YYYY-MM-DD-HHMM}/
|
|
320
|
-
Handles collisions by appending -N suffix if folder exists.
|
|
321
|
-
|
|
322
|
-
Args:
|
|
323
|
-
context_id: Context identifier
|
|
324
|
-
project_root: Project root directory (default: cwd)
|
|
325
|
-
|
|
326
|
-
Returns:
|
|
327
|
-
Path to new handoff folder (not yet created)
|
|
328
|
-
"""
|
|
329
|
-
from datetime import datetime
|
|
330
|
-
handoffs_dir = get_context_handoffs_dir(context_id, project_root)
|
|
331
|
-
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M")
|
|
332
|
-
folder = handoffs_dir / timestamp
|
|
333
|
-
|
|
334
|
-
counter = 1
|
|
335
|
-
while folder.exists():
|
|
336
|
-
folder = handoffs_dir / f"{timestamp}-{counter}"
|
|
337
|
-
counter += 1
|
|
338
|
-
|
|
339
|
-
return folder
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def get_review_folder_path(context_id: str, iteration: int, project_root: Path = None) -> Path:
|
|
343
|
-
"""Get path for a new review folder with datetime and iteration naming.
|
|
344
|
-
|
|
345
|
-
Returns: _output/contexts/{context_id}/reviews/cc-native/{YYYY-MM-DD-HHMM-iteration-N}/
|
|
346
|
-
|
|
347
|
-
Args:
|
|
348
|
-
context_id: Context identifier
|
|
349
|
-
iteration: Iteration number (1-based)
|
|
350
|
-
project_root: Project root directory (default: cwd)
|
|
351
|
-
|
|
352
|
-
Returns:
|
|
353
|
-
Path to new review folder (not yet created)
|
|
354
|
-
"""
|
|
355
|
-
from datetime import datetime
|
|
356
|
-
reviews_dir = get_context_reviews_dir(context_id, project_root) / "cc-native"
|
|
357
|
-
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M")
|
|
358
|
-
return reviews_dir / f"{timestamp}-iteration-{iteration}"
|
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
"""Common utilities for hook scripts.
|
|
2
|
-
|
|
3
|
-
Provides standardized boilerplate for:
|
|
4
|
-
- Path setup for imports
|
|
5
|
-
- JSON parsing from stdin
|
|
6
|
-
- Hook payload validation
|
|
7
|
-
- Error handling decorators
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import json
|
|
11
|
-
import os
|
|
12
|
-
import sys
|
|
13
|
-
from datetime import datetime, timezone
|
|
14
|
-
from functools import wraps
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
17
|
-
|
|
18
|
-
from .logger import log_hook_error, hook_log, log_debug, log_info, log_warn, log_error, log_diagnostic, set_context_path, set_session_id
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
# Type variable for generic decorators
|
|
89
|
-
F = TypeVar('F', bound=Callable[..., Any])
|
|
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
|
-
_last_session_id: Optional[str] = None
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
def load_hook_input() -> Optional[Dict[str, Any]]:
|
|
98
|
-
"""
|
|
99
|
-
Load and parse JSON from stdin.
|
|
100
|
-
|
|
101
|
-
Returns:
|
|
102
|
-
Parsed JSON dict, or None if stdin is empty or invalid JSON
|
|
103
|
-
"""
|
|
104
|
-
global _last_hook_event, _last_tool_name, _last_session_id
|
|
105
|
-
try:
|
|
106
|
-
input_data = sys.stdin.read().strip()
|
|
107
|
-
if not input_data:
|
|
108
|
-
return None
|
|
109
|
-
result = json.loads(input_data)
|
|
110
|
-
if isinstance(result, dict):
|
|
111
|
-
_last_hook_event = result.get("hook_event_name")
|
|
112
|
-
_last_tool_name = result.get("tool_name")
|
|
113
|
-
_last_session_id = result.get("session_id")
|
|
114
|
-
return result
|
|
115
|
-
except json.JSONDecodeError:
|
|
116
|
-
return None
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def validate_hook_event(
|
|
120
|
-
payload: Dict[str, Any],
|
|
121
|
-
expected_event: str,
|
|
122
|
-
expected_tool: Optional[str] = None
|
|
123
|
-
) -> bool:
|
|
124
|
-
"""
|
|
125
|
-
Validate hook event type and optional tool name.
|
|
126
|
-
|
|
127
|
-
Args:
|
|
128
|
-
payload: Hook payload from stdin
|
|
129
|
-
expected_event: Expected hook_event_name (e.g., "PostToolUse", "PreToolUse")
|
|
130
|
-
expected_tool: Optional expected tool_name (e.g., "TaskCreate")
|
|
131
|
-
|
|
132
|
-
Returns:
|
|
133
|
-
True if payload matches expected event/tool, False otherwise
|
|
134
|
-
"""
|
|
135
|
-
if payload.get("hook_event_name") != expected_event:
|
|
136
|
-
return False
|
|
137
|
-
if expected_tool and payload.get("tool_name") != expected_tool:
|
|
138
|
-
return False
|
|
139
|
-
return True
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def get_tool_input(payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
143
|
-
"""
|
|
144
|
-
Extract and validate tool_input from payload.
|
|
145
|
-
|
|
146
|
-
Args:
|
|
147
|
-
payload: Hook payload from stdin
|
|
148
|
-
|
|
149
|
-
Returns:
|
|
150
|
-
tool_input dict, or None if missing/invalid
|
|
151
|
-
"""
|
|
152
|
-
tool_input = payload.get("tool_input", {})
|
|
153
|
-
return tool_input if isinstance(tool_input, dict) else None
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def check_skip_persistence(payload: Dict[str, Any], hook_name: str = "hook") -> bool:
|
|
157
|
-
"""
|
|
158
|
-
Check if persistence should be skipped based on metadata flags.
|
|
159
|
-
|
|
160
|
-
Args:
|
|
161
|
-
payload: Hook payload from stdin
|
|
162
|
-
hook_name: Name of hook for logging
|
|
163
|
-
|
|
164
|
-
Returns:
|
|
165
|
-
True if skip_persistence flag is set, False otherwise
|
|
166
|
-
"""
|
|
167
|
-
tool_input = get_tool_input(payload)
|
|
168
|
-
if not tool_input:
|
|
169
|
-
return False
|
|
170
|
-
|
|
171
|
-
metadata = tool_input.get("metadata", {})
|
|
172
|
-
if isinstance(metadata, dict) and metadata.get("skip_persistence"):
|
|
173
|
-
log_debug(hook_name, "Skipping persistence (skip_persistence flag set)")
|
|
174
|
-
return True
|
|
175
|
-
return False
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def safe_hook_main(hook_name: str) -> Callable[[F], F]:
|
|
179
|
-
"""
|
|
180
|
-
Decorator for hook main functions with standard error handling.
|
|
181
|
-
|
|
182
|
-
Catches exceptions, logs them to stderr, and returns 0 (non-blocking).
|
|
183
|
-
|
|
184
|
-
Args:
|
|
185
|
-
hook_name: Name of hook for error messages
|
|
186
|
-
|
|
187
|
-
Returns:
|
|
188
|
-
Decorator function
|
|
189
|
-
|
|
190
|
-
Example:
|
|
191
|
-
@safe_hook_main("my_hook")
|
|
192
|
-
def main() -> int:
|
|
193
|
-
# ... hook logic ...
|
|
194
|
-
return 0
|
|
195
|
-
"""
|
|
196
|
-
def decorator(func: F) -> F:
|
|
197
|
-
@wraps(func)
|
|
198
|
-
def wrapper(*args, **kwargs):
|
|
199
|
-
try:
|
|
200
|
-
return func(*args, **kwargs)
|
|
201
|
-
except json.JSONDecodeError as e:
|
|
202
|
-
import traceback
|
|
203
|
-
tb = traceback.format_exc()
|
|
204
|
-
log_hook_error(hook_name, e, traceback_str=tb)
|
|
205
|
-
log_error(hook_name, f"JSON decode error: {e}")
|
|
206
|
-
return 0
|
|
207
|
-
except Exception as e:
|
|
208
|
-
import traceback
|
|
209
|
-
tb = traceback.format_exc()
|
|
210
|
-
log_hook_error(hook_name, e, traceback_str=tb)
|
|
211
|
-
log_error(hook_name, f"Unexpected error: {e}", traceback_str=tb)
|
|
212
|
-
return 0
|
|
213
|
-
return wrapper # type: ignore
|
|
214
|
-
return decorator
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
def emit_context(additional_context: str, ensure_ascii: bool = False) -> None:
|
|
218
|
-
"""Emit hookSpecificOutput with additionalContext to stdout.
|
|
219
|
-
|
|
220
|
-
Args:
|
|
221
|
-
additional_context: Context string to inject into Claude's context
|
|
222
|
-
ensure_ascii: If True, escape non-ASCII characters in JSON output
|
|
223
|
-
"""
|
|
224
|
-
out = {
|
|
225
|
-
"hookSpecificOutput": {
|
|
226
|
-
"additionalContext": additional_context,
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
print(json.dumps(out, ensure_ascii=ensure_ascii))
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def emit_context_and_block(
|
|
233
|
-
additional_context: str,
|
|
234
|
-
reason: str,
|
|
235
|
-
ensure_ascii: bool = True,
|
|
236
|
-
) -> None:
|
|
237
|
-
"""Emit hookSpecificOutput that denies the tool call with context and reason.
|
|
238
|
-
|
|
239
|
-
Args:
|
|
240
|
-
additional_context: Context string to inject into Claude's context
|
|
241
|
-
reason: Reason shown to Claude for why the tool call was denied
|
|
242
|
-
ensure_ascii: If True, escape non-ASCII characters in JSON output
|
|
243
|
-
"""
|
|
244
|
-
out = {
|
|
245
|
-
"hookSpecificOutput": {
|
|
246
|
-
"additionalContext": additional_context,
|
|
247
|
-
"permissionDecision": "deny",
|
|
248
|
-
"permissionDecisionReason": reason,
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
print(json.dumps(out, ensure_ascii=ensure_ascii))
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def _detect_template(script_path: str = "") -> str:
|
|
255
|
-
"""Auto-detect template origin from the hook script path.
|
|
256
|
-
|
|
257
|
-
Returns "shared", a template name (e.g., "cc-native"), or "unknown".
|
|
258
|
-
"""
|
|
259
|
-
import re
|
|
260
|
-
path = (script_path or (sys.argv[0] if sys.argv else "")).replace("\\", "/")
|
|
261
|
-
if "/_shared/hooks/" in path or path.startswith("_shared/hooks/"):
|
|
262
|
-
return "shared"
|
|
263
|
-
match = re.search(r'_([a-z][a-z0-9-]*)/hooks/', path)
|
|
264
|
-
if match:
|
|
265
|
-
return match.group(1) # e.g., "cc-native"
|
|
266
|
-
return "unknown"
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def run_hook(main_func: Callable[[], int], hook_name: str = "unknown") -> None:
|
|
270
|
-
"""
|
|
271
|
-
Standard hook entry point wrapper with lifecycle logging.
|
|
272
|
-
|
|
273
|
-
Logs HOOK_START before calling main, HOOK_END after completion.
|
|
274
|
-
Catches unhandled exceptions and logs them before exiting cleanly.
|
|
275
|
-
|
|
276
|
-
Args:
|
|
277
|
-
main_func: Hook main function that returns exit code
|
|
278
|
-
hook_name: Name of the hook for error logging
|
|
279
|
-
|
|
280
|
-
Example:
|
|
281
|
-
if __name__ == "__main__":
|
|
282
|
-
run_hook(main, "my_hook")
|
|
283
|
-
"""
|
|
284
|
-
import time
|
|
285
|
-
start_time = time.monotonic()
|
|
286
|
-
template = _detect_template()
|
|
287
|
-
event = _last_hook_event or "unknown"
|
|
288
|
-
tool = _last_tool_name
|
|
289
|
-
|
|
290
|
-
# Wire session_id into logger so all log entries carry it
|
|
291
|
-
if _last_session_id:
|
|
292
|
-
set_session_id(_last_session_id)
|
|
293
|
-
|
|
294
|
-
# HOOK_START
|
|
295
|
-
start_data: Dict[str, Any] = {"lifecycle": "start", "template": template, "event": event}
|
|
296
|
-
if tool:
|
|
297
|
-
start_data["tool"] = tool
|
|
298
|
-
log_info(hook_name, "HOOK_START", data=start_data)
|
|
299
|
-
|
|
300
|
-
exit_code = 0
|
|
301
|
-
status = "success"
|
|
302
|
-
error_info = None
|
|
303
|
-
|
|
304
|
-
try:
|
|
305
|
-
result = main_func()
|
|
306
|
-
exit_code = result if isinstance(result, int) else 0
|
|
307
|
-
status = "blocked" if exit_code != 0 else "success"
|
|
308
|
-
except SystemExit as e:
|
|
309
|
-
exit_code = e.code if isinstance(e.code, int) else (1 if e.code else 0)
|
|
310
|
-
status = "blocked" if exit_code != 0 else "success"
|
|
311
|
-
except Exception as e:
|
|
312
|
-
import traceback
|
|
313
|
-
exit_code = 0 # Non-blocking
|
|
314
|
-
status = "error"
|
|
315
|
-
error_info = (e, traceback.format_exc())
|
|
316
|
-
|
|
317
|
-
# HOOK_END
|
|
318
|
-
duration_ms = round((time.monotonic() - start_time) * 1000, 1)
|
|
319
|
-
end_data: Dict[str, Any] = {
|
|
320
|
-
"lifecycle": "end", "status": status,
|
|
321
|
-
"duration_ms": duration_ms, "exit_code": exit_code,
|
|
322
|
-
"template": template,
|
|
323
|
-
}
|
|
324
|
-
end_event = _last_hook_event or event # Re-read after main() populated it
|
|
325
|
-
end_tool = _last_tool_name or tool
|
|
326
|
-
end_data["event"] = end_event
|
|
327
|
-
if end_tool:
|
|
328
|
-
end_data["tool"] = end_tool
|
|
329
|
-
if error_info:
|
|
330
|
-
e, tb = error_info
|
|
331
|
-
end_data["error_type"] = type(e).__name__
|
|
332
|
-
log_hook_error(hook_name, e, traceback_str=tb)
|
|
333
|
-
log_error(hook_name, f"HOOK_END: {e}", data=end_data, traceback_str=tb)
|
|
334
|
-
elif status == "blocked":
|
|
335
|
-
log_warn(hook_name, "HOOK_END", data=end_data)
|
|
336
|
-
else:
|
|
337
|
-
log_info(hook_name, "HOOK_END", data=end_data)
|
|
338
|
-
|
|
339
|
-
raise SystemExit(exit_code)
|