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.
Files changed (189) hide show
  1. package/bin/run.js +1 -1
  2. package/dist/commands/clear.js +28 -131
  3. package/dist/commands/init/index.js +3 -3
  4. package/dist/lib/gitignore-manager.d.ts +32 -0
  5. package/dist/lib/gitignore-manager.js +141 -2
  6. package/dist/templates/CLAUDE.md +8 -8
  7. package/dist/templates/_shared/.claude/commands/handoff-resume.md +64 -0
  8. package/dist/templates/_shared/.claude/commands/handoff.md +16 -10
  9. package/dist/templates/_shared/.claude/settings.json +7 -7
  10. package/dist/templates/_shared/hooks-ts/_utils/git-state.ts +2 -0
  11. package/dist/templates/_shared/hooks-ts/archive_plan.ts +159 -0
  12. package/dist/templates/_shared/hooks-ts/context_monitor.ts +147 -0
  13. package/dist/templates/_shared/hooks-ts/file-suggestion.ts +130 -0
  14. package/dist/templates/_shared/hooks-ts/pre_compact.ts +49 -0
  15. package/dist/templates/_shared/hooks-ts/session_end.ts +104 -0
  16. package/dist/templates/_shared/hooks-ts/session_start.ts +144 -0
  17. package/dist/templates/_shared/hooks-ts/task_create_capture.ts +48 -0
  18. package/dist/templates/_shared/hooks-ts/task_update_capture.ts +74 -0
  19. package/dist/templates/_shared/hooks-ts/user_prompt_submit.ts +83 -0
  20. package/dist/templates/_shared/lib-ts/CLAUDE.md +318 -0
  21. package/dist/templates/_shared/lib-ts/base/atomic-write.ts +12 -12
  22. package/dist/templates/_shared/lib-ts/base/constants.ts +22 -15
  23. package/dist/templates/_shared/lib-ts/base/hook-utils.ts +129 -50
  24. package/dist/templates/_shared/lib-ts/base/inference.ts +28 -21
  25. package/dist/templates/_shared/lib-ts/base/logger.ts +31 -15
  26. package/dist/templates/_shared/lib-ts/base/state-io.ts +9 -7
  27. package/dist/templates/_shared/lib-ts/base/stop-words.ts +131 -131
  28. package/dist/templates/_shared/lib-ts/base/subprocess-utils.ts +139 -0
  29. package/dist/templates/_shared/lib-ts/base/utils.ts +69 -69
  30. package/dist/templates/_shared/lib-ts/context/context-formatter.ts +30 -24
  31. package/dist/templates/_shared/lib-ts/context/context-selector.ts +50 -32
  32. package/dist/templates/_shared/lib-ts/context/context-store.ts +76 -48
  33. package/dist/templates/_shared/lib-ts/context/plan-manager.ts +61 -37
  34. package/dist/templates/_shared/lib-ts/context/task-tracker.ts +10 -6
  35. package/dist/templates/_shared/lib-ts/handoff/document-generator.ts +11 -10
  36. package/dist/templates/_shared/lib-ts/handoff/handoff-reader.ts +159 -0
  37. package/dist/templates/_shared/lib-ts/templates/formatters.ts +6 -4
  38. package/dist/templates/_shared/lib-ts/types.ts +68 -55
  39. package/dist/templates/_shared/scripts/resolve_context.ts +24 -0
  40. package/dist/templates/_shared/scripts/resume_handoff.ts +321 -0
  41. package/dist/templates/_shared/scripts/save_handoff.ts +21 -21
  42. package/dist/templates/_shared/scripts/status_line.ts +733 -0
  43. package/dist/templates/cc-native/.claude/settings.json +175 -185
  44. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +15 -17
  45. package/dist/templates/cc-native/_cc-native/agents/CLAUDE.md +0 -2
  46. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +109 -135
  47. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.ts +119 -0
  48. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.ts +921 -0
  49. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.ts +61 -0
  50. package/dist/templates/cc-native/_cc-native/lib-ts/aggregate-agents.ts +157 -0
  51. package/dist/templates/cc-native/_cc-native/lib-ts/artifacts.ts +709 -0
  52. package/dist/templates/cc-native/_cc-native/lib-ts/cc-native-state.ts +199 -0
  53. package/dist/templates/cc-native/_cc-native/lib-ts/cli-output-parser.ts +124 -0
  54. package/dist/templates/cc-native/_cc-native/lib-ts/config.ts +57 -0
  55. package/dist/templates/cc-native/_cc-native/lib-ts/constants.ts +83 -0
  56. package/dist/templates/cc-native/_cc-native/lib-ts/debug.ts +80 -0
  57. package/dist/templates/cc-native/_cc-native/lib-ts/index.ts +119 -0
  58. package/dist/templates/cc-native/_cc-native/lib-ts/json-parser.ts +162 -0
  59. package/dist/templates/cc-native/_cc-native/lib-ts/nul +3 -0
  60. package/dist/templates/cc-native/_cc-native/lib-ts/orchestrator.ts +249 -0
  61. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/agent.ts +155 -0
  62. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/codex.ts +130 -0
  63. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/gemini.ts +106 -0
  64. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/index.ts +10 -0
  65. package/dist/templates/cc-native/_cc-native/lib-ts/reviewers/types.ts +23 -0
  66. package/dist/templates/cc-native/_cc-native/lib-ts/state.ts +243 -0
  67. package/dist/templates/cc-native/_cc-native/lib-ts/tsconfig.json +18 -0
  68. package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +310 -0
  69. package/dist/templates/cc-native/_cc-native/lib-ts/verdict.ts +72 -0
  70. package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -9
  71. package/oclif.manifest.json +1 -1
  72. package/package.json +1 -1
  73. package/dist/templates/_shared/hooks/__init__.py +0 -16
  74. package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  75. package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  76. package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
  77. package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
  78. package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
  79. package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
  80. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  81. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  82. package/dist/templates/_shared/hooks/__pycache__/task_create_atomicity.cpython-313.pyc +0 -0
  83. package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
  84. package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
  85. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  86. package/dist/templates/_shared/hooks/archive_plan.py +0 -177
  87. package/dist/templates/_shared/hooks/context_monitor.py +0 -270
  88. package/dist/templates/_shared/hooks/file-suggestion.py +0 -215
  89. package/dist/templates/_shared/hooks/pre_compact.py +0 -104
  90. package/dist/templates/_shared/hooks/session_end.py +0 -173
  91. package/dist/templates/_shared/hooks/session_start.py +0 -206
  92. package/dist/templates/_shared/hooks/task_create_capture.py +0 -108
  93. package/dist/templates/_shared/hooks/task_update_capture.py +0 -145
  94. package/dist/templates/_shared/hooks/user_prompt_submit.py +0 -139
  95. package/dist/templates/_shared/lib/__init__.py +0 -1
  96. package/dist/templates/_shared/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  97. package/dist/templates/_shared/lib/base/__init__.py +0 -65
  98. package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
  99. package/dist/templates/_shared/lib/base/__pycache__/atomic_write.cpython-313.pyc +0 -0
  100. package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
  101. package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
  102. package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
  103. package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
  104. package/dist/templates/_shared/lib/base/__pycache__/stop_words.cpython-313.pyc +0 -0
  105. package/dist/templates/_shared/lib/base/__pycache__/subprocess_utils.cpython-313.pyc +0 -0
  106. package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
  107. package/dist/templates/_shared/lib/base/atomic_write.py +0 -180
  108. package/dist/templates/_shared/lib/base/constants.py +0 -358
  109. package/dist/templates/_shared/lib/base/hook_utils.py +0 -339
  110. package/dist/templates/_shared/lib/base/inference.py +0 -307
  111. package/dist/templates/_shared/lib/base/logger.py +0 -305
  112. package/dist/templates/_shared/lib/base/stop_words.py +0 -221
  113. package/dist/templates/_shared/lib/base/subprocess_utils.py +0 -46
  114. package/dist/templates/_shared/lib/base/utils.py +0 -263
  115. package/dist/templates/_shared/lib/context/__init__.py +0 -102
  116. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  117. package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
  118. package/dist/templates/_shared/lib/context/__pycache__/context_extractor.cpython-313.pyc +0 -0
  119. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  120. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  121. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  122. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  123. package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
  124. package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
  125. package/dist/templates/_shared/lib/context/__pycache__/plan_archive.cpython-313.pyc +0 -0
  126. package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
  127. package/dist/templates/_shared/lib/context/__pycache__/task_sync.cpython-313.pyc +0 -0
  128. package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
  129. package/dist/templates/_shared/lib/context/context_formatter.py +0 -317
  130. package/dist/templates/_shared/lib/context/context_selector.py +0 -508
  131. package/dist/templates/_shared/lib/context/context_store.py +0 -653
  132. package/dist/templates/_shared/lib/context/plan_manager.py +0 -303
  133. package/dist/templates/_shared/lib/context/task_tracker.py +0 -188
  134. package/dist/templates/_shared/lib/handoff/__init__.py +0 -22
  135. package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
  136. package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
  137. package/dist/templates/_shared/lib/handoff/document_generator.py +0 -278
  138. package/dist/templates/_shared/lib/templates/README.md +0 -206
  139. package/dist/templates/_shared/lib/templates/__init__.py +0 -36
  140. package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
  141. package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
  142. package/dist/templates/_shared/lib/templates/__pycache__/persona_questions.cpython-313.pyc +0 -0
  143. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  144. package/dist/templates/_shared/lib/templates/formatters.py +0 -146
  145. package/dist/templates/_shared/lib/templates/plan_context.py +0 -73
  146. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  147. package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
  148. package/dist/templates/_shared/scripts/save_handoff.py +0 -357
  149. package/dist/templates/_shared/scripts/status_line.py +0 -716
  150. package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +0 -8
  151. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +0 -8
  152. package/dist/templates/cc-native/MIGRATION.md +0 -86
  153. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  154. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  155. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
  156. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
  157. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
  158. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
  159. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +0 -130
  160. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +0 -954
  161. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +0 -81
  162. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +0 -340
  163. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +0 -265
  164. package/dist/templates/cc-native/_cc-native/lib/__init__.py +0 -53
  165. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  166. package/dist/templates/cc-native/_cc-native/lib/__pycache__/atomic_write.cpython-313.pyc +0 -0
  167. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  168. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  169. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  170. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  171. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  172. package/dist/templates/cc-native/_cc-native/lib/constants.py +0 -45
  173. package/dist/templates/cc-native/_cc-native/lib/debug.py +0 -139
  174. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +0 -362
  175. package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +0 -28
  176. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  177. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  178. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  179. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  180. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  181. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +0 -215
  182. package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +0 -88
  183. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +0 -124
  184. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +0 -108
  185. package/dist/templates/cc-native/_cc-native/lib/state.py +0 -268
  186. package/dist/templates/cc-native/_cc-native/lib/utils.py +0 -1071
  187. package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
  188. package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +0 -168
  189. 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)