aiwcli 0.9.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 (204) hide show
  1. package/README.md +1248 -0
  2. package/bin/dev.cmd +3 -0
  3. package/bin/dev.js +16 -0
  4. package/bin/run.cmd +3 -0
  5. package/bin/run.js +19 -0
  6. package/dist/commands/branch.d.ts +45 -0
  7. package/dist/commands/branch.js +488 -0
  8. package/dist/commands/clean.d.ts +34 -0
  9. package/dist/commands/clean.js +186 -0
  10. package/dist/commands/clear.d.ts +51 -0
  11. package/dist/commands/clear.js +835 -0
  12. package/dist/commands/init/index.d.ts +107 -0
  13. package/dist/commands/init/index.js +565 -0
  14. package/dist/commands/launch.d.ts +21 -0
  15. package/dist/commands/launch.js +108 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/lib/base-command.d.ts +114 -0
  19. package/dist/lib/base-command.js +153 -0
  20. package/dist/lib/bmad-installer.d.ts +38 -0
  21. package/dist/lib/bmad-installer.js +145 -0
  22. package/dist/lib/claude-settings-types.d.ts +102 -0
  23. package/dist/lib/claude-settings-types.js +5 -0
  24. package/dist/lib/config.d.ts +25 -0
  25. package/dist/lib/config.js +46 -0
  26. package/dist/lib/debug.d.ts +39 -0
  27. package/dist/lib/debug.js +74 -0
  28. package/dist/lib/env-compat.d.ts +26 -0
  29. package/dist/lib/env-compat.js +35 -0
  30. package/dist/lib/errors.d.ts +126 -0
  31. package/dist/lib/errors.js +145 -0
  32. package/dist/lib/generic-merge.d.ts +74 -0
  33. package/dist/lib/generic-merge.js +105 -0
  34. package/dist/lib/git/branch.d.ts +67 -0
  35. package/dist/lib/git/branch.js +155 -0
  36. package/dist/lib/git/index.d.ts +11 -0
  37. package/dist/lib/git/index.js +13 -0
  38. package/dist/lib/git/safety-checks.d.ts +44 -0
  39. package/dist/lib/git/safety-checks.js +102 -0
  40. package/dist/lib/git/types.d.ts +31 -0
  41. package/dist/lib/git/types.js +6 -0
  42. package/dist/lib/git/worktree.d.ts +67 -0
  43. package/dist/lib/git/worktree.js +220 -0
  44. package/dist/lib/gitignore-manager.d.ts +10 -0
  45. package/dist/lib/gitignore-manager.js +60 -0
  46. package/dist/lib/hooks-merger.d.ts +28 -0
  47. package/dist/lib/hooks-merger.js +94 -0
  48. package/dist/lib/ide-path-resolver.d.ts +102 -0
  49. package/dist/lib/ide-path-resolver.js +129 -0
  50. package/dist/lib/index.d.ts +13 -0
  51. package/dist/lib/index.js +22 -0
  52. package/dist/lib/output.d.ts +51 -0
  53. package/dist/lib/output.js +76 -0
  54. package/dist/lib/paths.d.ts +66 -0
  55. package/dist/lib/paths.js +136 -0
  56. package/dist/lib/quiet.d.ts +12 -0
  57. package/dist/lib/quiet.js +17 -0
  58. package/dist/lib/settings-hierarchy.d.ts +42 -0
  59. package/dist/lib/settings-hierarchy.js +105 -0
  60. package/dist/lib/spawn.d.ts +105 -0
  61. package/dist/lib/spawn.js +157 -0
  62. package/dist/lib/spinner.d.ts +19 -0
  63. package/dist/lib/spinner.js +34 -0
  64. package/dist/lib/stdin.d.ts +48 -0
  65. package/dist/lib/stdin.js +60 -0
  66. package/dist/lib/template-installer.d.ts +92 -0
  67. package/dist/lib/template-installer.js +375 -0
  68. package/dist/lib/template-linter.d.ts +49 -0
  69. package/dist/lib/template-linter.js +173 -0
  70. package/dist/lib/template-merger.d.ts +47 -0
  71. package/dist/lib/template-merger.js +173 -0
  72. package/dist/lib/template-resolver.d.ts +20 -0
  73. package/dist/lib/template-resolver.js +60 -0
  74. package/dist/lib/terminal.d.ts +102 -0
  75. package/dist/lib/terminal.js +245 -0
  76. package/dist/lib/tty-detection.d.ts +62 -0
  77. package/dist/lib/tty-detection.js +83 -0
  78. package/dist/lib/user-utils.d.ts +5 -0
  79. package/dist/lib/user-utils.js +23 -0
  80. package/dist/lib/version.d.ts +99 -0
  81. package/dist/lib/version.js +144 -0
  82. package/dist/lib/watch-templates.d.ts +6 -0
  83. package/dist/lib/watch-templates.js +73 -0
  84. package/dist/lib/windsurf-hooks-hierarchy.d.ts +30 -0
  85. package/dist/lib/windsurf-hooks-hierarchy.js +66 -0
  86. package/dist/lib/windsurf-hooks-merger.d.ts +26 -0
  87. package/dist/lib/windsurf-hooks-merger.js +53 -0
  88. package/dist/lib/windsurf-hooks-types.d.ts +33 -0
  89. package/dist/lib/windsurf-hooks-types.js +5 -0
  90. package/dist/templates/CLAUDE.md +174 -0
  91. package/dist/templates/_shared/.claude/commands/handoff.md +14 -0
  92. package/dist/templates/_shared/.claude/settings.json +61 -0
  93. package/dist/templates/_shared/.codex/workflows/handoff.md +14 -0
  94. package/dist/templates/_shared/.windsurf/workflows/handoff.md +14 -0
  95. package/dist/templates/_shared/hooks/__init__.py +16 -0
  96. package/dist/templates/_shared/hooks/archive_plan.py +270 -0
  97. package/dist/templates/_shared/hooks/context_enforcer.py +621 -0
  98. package/dist/templates/_shared/hooks/context_monitor.py +322 -0
  99. package/dist/templates/_shared/hooks/file-suggestion.py +188 -0
  100. package/dist/templates/_shared/hooks/task_create_capture.py +194 -0
  101. package/dist/templates/_shared/hooks/task_update_capture.py +254 -0
  102. package/dist/templates/_shared/hooks/user_prompt_submit.py +157 -0
  103. package/dist/templates/_shared/lib/__init__.py +1 -0
  104. package/dist/templates/_shared/lib/base/__init__.py +49 -0
  105. package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
  106. package/dist/templates/_shared/lib/base/atomic_write.py +180 -0
  107. package/dist/templates/_shared/lib/base/constants.py +299 -0
  108. package/dist/templates/_shared/lib/base/inference.py +189 -0
  109. package/dist/templates/_shared/lib/base/utils.py +216 -0
  110. package/dist/templates/_shared/lib/context/__init__.py +119 -0
  111. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  112. package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
  113. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  114. package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
  115. package/dist/templates/_shared/lib/context/cache.py +446 -0
  116. package/dist/templates/_shared/lib/context/context_manager.py +1171 -0
  117. package/dist/templates/_shared/lib/context/discovery.py +486 -0
  118. package/dist/templates/_shared/lib/context/event_log.py +308 -0
  119. package/dist/templates/_shared/lib/context/plan_archive.py +247 -0
  120. package/dist/templates/_shared/lib/context/task_sync.py +367 -0
  121. package/dist/templates/_shared/lib/handoff/__init__.py +22 -0
  122. package/dist/templates/_shared/lib/handoff/document_generator.py +307 -0
  123. package/dist/templates/_shared/lib/templates/README.md +215 -0
  124. package/dist/templates/_shared/lib/templates/__init__.py +40 -0
  125. package/dist/templates/_shared/lib/templates/formatters.py +147 -0
  126. package/dist/templates/_shared/lib/templates/plan_context.py +119 -0
  127. package/dist/templates/_shared/scripts/save_handoff.py +99 -0
  128. package/dist/templates/_shared/workflows/handoff.md +212 -0
  129. package/dist/templates/cc-native/.claude/agents/cc-native/ACCESSIBILITY-TESTER.md +80 -0
  130. package/dist/templates/cc-native/.claude/agents/cc-native/ARCHITECT-REVIEWER.md +75 -0
  131. package/dist/templates/cc-native/.claude/agents/cc-native/ASSUMPTION-CHAIN-TRACER.md +239 -0
  132. package/dist/templates/cc-native/.claude/agents/cc-native/CLARITY-AUDITOR.md +109 -0
  133. package/dist/templates/cc-native/.claude/agents/cc-native/CODE-REVIEWER.md +71 -0
  134. package/dist/templates/cc-native/.claude/agents/cc-native/COMPLETENESS-CHECKER.md +104 -0
  135. package/dist/templates/cc-native/.claude/agents/cc-native/CONTEXT-EXTRACTOR.md +93 -0
  136. package/dist/templates/cc-native/.claude/agents/cc-native/DEVILS-ADVOCATE.md +223 -0
  137. package/dist/templates/cc-native/.claude/agents/cc-native/DOCUMENTATION-REVIEWER.md +73 -0
  138. package/dist/templates/cc-native/.claude/agents/cc-native/FEASIBILITY-ANALYST.md +93 -0
  139. package/dist/templates/cc-native/.claude/agents/cc-native/FRESH-PERSPECTIVE.md +103 -0
  140. package/dist/templates/cc-native/.claude/agents/cc-native/HANDOFF-READINESS.md +145 -0
  141. package/dist/templates/cc-native/.claude/agents/cc-native/HIDDEN-COMPLEXITY-DETECTOR.md +248 -0
  142. package/dist/templates/cc-native/.claude/agents/cc-native/INCENTIVE-MAPPER.md +235 -0
  143. package/dist/templates/cc-native/.claude/agents/cc-native/PENETRATION-TESTER.md +80 -0
  144. package/dist/templates/cc-native/.claude/agents/cc-native/PERFORMANCE-ENGINEER.md +76 -0
  145. package/dist/templates/cc-native/.claude/agents/cc-native/PLAN-ORCHESTRATOR.md +141 -0
  146. package/dist/templates/cc-native/.claude/agents/cc-native/PRECEDENT-FINDER.md +240 -0
  147. package/dist/templates/cc-native/.claude/agents/cc-native/REVERSIBILITY-ANALYST.md +211 -0
  148. package/dist/templates/cc-native/.claude/agents/cc-native/RISK-ASSESSOR.md +101 -0
  149. package/dist/templates/cc-native/.claude/agents/cc-native/SECOND-ORDER-ANALYST.md +197 -0
  150. package/dist/templates/cc-native/.claude/agents/cc-native/SIMPLICITY-GUARDIAN.md +97 -0
  151. package/dist/templates/cc-native/.claude/agents/cc-native/SKEPTIC.md +349 -0
  152. package/dist/templates/cc-native/.claude/agents/cc-native/STAKEHOLDER-ADVOCATE.md +106 -0
  153. package/dist/templates/cc-native/.claude/agents/cc-native/TRADE-OFF-ILLUMINATOR.md +205 -0
  154. package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +8 -0
  155. package/dist/templates/cc-native/.claude/commands/cc-native/specdev.md +10 -0
  156. package/dist/templates/cc-native/.claude/settings.json +119 -0
  157. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fix.md +8 -0
  158. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +8 -0
  159. package/dist/templates/cc-native/.windsurf/workflows/cc-native/implement.md +8 -0
  160. package/dist/templates/cc-native/.windsurf/workflows/cc-native/research.md +8 -0
  161. package/dist/templates/cc-native/CC-NATIVE-README.md +192 -0
  162. package/dist/templates/cc-native/MIGRATION.md +86 -0
  163. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +331 -0
  164. package/dist/templates/cc-native/_cc-native/docs/PERMISSION_REQUEST_VERIFICATION.md +147 -0
  165. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  166. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  167. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
  168. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  169. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
  170. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +150 -0
  171. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +746 -0
  172. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +339 -0
  173. package/dist/templates/cc-native/_cc-native/lib/__init__.py +57 -0
  174. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  175. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  176. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  177. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  178. package/dist/templates/cc-native/_cc-native/lib/async_archive.py +68 -0
  179. package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +98 -0
  180. package/dist/templates/cc-native/_cc-native/lib/constants.py +45 -0
  181. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +273 -0
  182. package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +28 -0
  183. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  184. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  185. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  186. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  187. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  188. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +164 -0
  189. package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +89 -0
  190. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +119 -0
  191. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +103 -0
  192. package/dist/templates/cc-native/_cc-native/lib/state.py +251 -0
  193. package/dist/templates/cc-native/_cc-native/lib/utils.py +830 -0
  194. package/dist/templates/cc-native/_cc-native/plan-review.config.json +76 -0
  195. package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
  196. package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +151 -0
  197. package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +134 -0
  198. package/dist/templates/cc-native/_cc-native/workflows/specdev.md +9 -0
  199. package/dist/types/exit-codes.d.ts +11 -0
  200. package/dist/types/exit-codes.js +10 -0
  201. package/dist/types/index.d.ts +5 -0
  202. package/dist/types/index.js +7 -0
  203. package/oclif.manifest.json +405 -0
  204. package/package.json +109 -0
@@ -0,0 +1,299 @@
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_archive_dir(project_root: Path = None) -> Path:
259
+ """
260
+ Get the archive directory path.
261
+
262
+ Args:
263
+ project_root: Project root directory (default: cwd)
264
+
265
+ Returns:
266
+ Path to _output/contexts/archive/
267
+ """
268
+ return get_contexts_dir(project_root) / ARCHIVE_DIR
269
+
270
+
271
+ def get_archive_context_dir(context_id: str, project_root: Path = None) -> Path:
272
+ """
273
+ Get the archive directory for a specific context.
274
+
275
+ Args:
276
+ context_id: Context identifier
277
+ project_root: Project root directory (default: cwd)
278
+
279
+ Returns:
280
+ Path to _output/contexts/archive/{context_id}/
281
+
282
+ Raises:
283
+ ValueError: If context_id is invalid
284
+ """
285
+ validated_id = validate_context_id(context_id)
286
+ return get_archive_dir(project_root) / validated_id
287
+
288
+
289
+ def get_archive_index_path(project_root: Path = None) -> Path:
290
+ """
291
+ Get the archive index file path.
292
+
293
+ Args:
294
+ project_root: Project root directory (default: cwd)
295
+
296
+ Returns:
297
+ Path to _output/contexts/archive/index.json
298
+ """
299
+ return get_archive_dir(project_root) / INDEX_FILENAME
@@ -0,0 +1,189 @@
1
+ """Inference utility for AI-powered text processing.
2
+
3
+ Provides a unified interface for Claude API calls using the claude CLI.
4
+ Supports multiple model tiers: fast (Haiku), standard (Sonnet), smart (Opus).
5
+ """
6
+ import subprocess
7
+ import sys
8
+ import os
9
+ from typing import Optional
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class InferenceResult:
15
+ """Result from an inference call."""
16
+ success: bool
17
+ output: str
18
+ error: Optional[str] = None
19
+ latency_ms: int = 0
20
+
21
+
22
+ # Model configurations
23
+ MODELS = {
24
+ "fast": "claude-3-haiku-20240307",
25
+ "standard": "claude-sonnet-4-20250514",
26
+ "smart": "claude-opus-4-20250514",
27
+ }
28
+
29
+ TIMEOUTS = {
30
+ "fast": 15, # 15 seconds
31
+ "standard": 30, # 30 seconds
32
+ "smart": 90, # 90 seconds
33
+ }
34
+
35
+
36
+ def inference(
37
+ system_prompt: str,
38
+ user_prompt: str,
39
+ level: str = "fast",
40
+ timeout: Optional[int] = None,
41
+ ) -> InferenceResult:
42
+ """
43
+ Run inference using the claude CLI.
44
+
45
+ Args:
46
+ system_prompt: System instructions for the model
47
+ user_prompt: User message to process
48
+ level: Model level - "fast" (Haiku), "standard" (Sonnet), "smart" (Opus)
49
+ timeout: Custom timeout in seconds (uses level default if not specified)
50
+
51
+ Returns:
52
+ InferenceResult with success status, output, and any error
53
+ """
54
+ import time
55
+ start_time = time.time()
56
+
57
+ model = MODELS.get(level, MODELS["fast"])
58
+ timeout_sec = timeout or TIMEOUTS.get(level, TIMEOUTS["fast"])
59
+
60
+ # Combine prompts
61
+ full_prompt = f"{system_prompt}\n\n{user_prompt}"
62
+
63
+ # Build command
64
+ cmd = [
65
+ "claude",
66
+ "--model", model,
67
+ "--print",
68
+ "--no-hooks",
69
+ "-p", full_prompt,
70
+ ]
71
+
72
+ # Remove ANTHROPIC_API_KEY to force subscription auth
73
+ env = os.environ.copy()
74
+ env.pop("ANTHROPIC_API_KEY", None)
75
+
76
+ try:
77
+ result = subprocess.run(
78
+ cmd,
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=timeout_sec,
82
+ env=env,
83
+ # Windows needs shell=True for command resolution
84
+ shell=(sys.platform == "win32"),
85
+ )
86
+
87
+ latency_ms = int((time.time() - start_time) * 1000)
88
+
89
+ if result.returncode != 0:
90
+ return InferenceResult(
91
+ success=False,
92
+ output=result.stdout.strip() if result.stdout else "",
93
+ error=result.stderr.strip() if result.stderr else f"Exit code: {result.returncode}",
94
+ latency_ms=latency_ms,
95
+ )
96
+
97
+ return InferenceResult(
98
+ success=True,
99
+ output=result.stdout.strip(),
100
+ latency_ms=latency_ms,
101
+ )
102
+
103
+ except subprocess.TimeoutExpired:
104
+ latency_ms = int((time.time() - start_time) * 1000)
105
+ return InferenceResult(
106
+ success=False,
107
+ output="",
108
+ error=f"Timeout after {timeout_sec}s",
109
+ latency_ms=latency_ms,
110
+ )
111
+ except FileNotFoundError:
112
+ latency_ms = int((time.time() - start_time) * 1000)
113
+ return InferenceResult(
114
+ success=False,
115
+ output="",
116
+ error="claude CLI not found",
117
+ latency_ms=latency_ms,
118
+ )
119
+ except Exception as e:
120
+ latency_ms = int((time.time() - start_time) * 1000)
121
+ return InferenceResult(
122
+ success=False,
123
+ output="",
124
+ error=str(e),
125
+ latency_ms=latency_ms,
126
+ )
127
+
128
+
129
+ # System prompt for generating context ID summaries
130
+ CONTEXT_ID_SYSTEM_PROMPT = """Generate a 10-word summary of what the user wants to do. Start with a gerund (verb ending in -ing).
131
+
132
+ Rules:
133
+ - Exactly 10 words
134
+ - Start with gerund: Creating, Fixing, Adding, Updating, Implementing, etc.
135
+ - Be specific about the task
136
+ - No punctuation
137
+ - No quotes
138
+
139
+ Examples:
140
+ - "I want to add user authentication" -> "Adding user authentication with JWT tokens to the web app"
141
+ - "Fix the bug in the login flow" -> "Fixing critical bug in user login flow validation logic"
142
+ - "Can you help me refactor this code" -> "Refactoring legacy code for better maintainability and cleaner architecture"
143
+ - "Update the README with new instructions" -> "Updating README with new setup instructions and configuration examples"
144
+
145
+ Output ONLY the 10-word summary, nothing else."""
146
+
147
+
148
+ def generate_semantic_summary(prompt: str, timeout: int = 15) -> Optional[str]:
149
+ """
150
+ Generate a semantic 10-word summary of a user prompt.
151
+
152
+ Uses Sonnet for quality inference. Returns None if inference fails.
153
+
154
+ Args:
155
+ prompt: User prompt to summarize
156
+ timeout: Timeout in seconds (default 15)
157
+
158
+ Returns:
159
+ 10-word summary string or None if failed
160
+ """
161
+ # Pass full prompt - AI can summarize any length into 10 words
162
+ result = inference(
163
+ system_prompt=CONTEXT_ID_SYSTEM_PROMPT,
164
+ user_prompt=prompt,
165
+ level="standard",
166
+ timeout=timeout,
167
+ )
168
+
169
+ if not result.success or not result.output:
170
+ return None
171
+
172
+ # Clean up the output
173
+ summary = result.output.strip()
174
+ # Remove any quotes
175
+ summary = summary.strip('"\'')
176
+ # Remove trailing punctuation
177
+ summary = summary.rstrip('.!?')
178
+
179
+ # Validate it starts with a gerund (capital letter + letters + "ing")
180
+ import re
181
+ if not re.match(r'^[A-Z][a-z]*ing\b', summary):
182
+ return None
183
+
184
+ # Validate roughly 10 words (allow 8-12 for flexibility)
185
+ words = summary.split()
186
+ if len(words) < 8 or len(words) > 12:
187
+ return None
188
+
189
+ return summary
@@ -0,0 +1,216 @@
1
+ """Core utilities for shared context management.
2
+
3
+ Provides common functions used across all shared modules:
4
+ - eprint: Print to stderr
5
+ - now_local: Get current local datetime
6
+ - project_dir: Get project directory from environment
7
+ - sanitize_filename: Sanitize string for use in filenames
8
+ - generate_context_id: Generate a slug from summary text
9
+ """
10
+ import os
11
+ import re
12
+ import sys
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Optional
16
+
17
+
18
+ def eprint(*args: Any) -> None:
19
+ """Print to stderr."""
20
+ print(*args, file=sys.stderr)
21
+
22
+
23
+ def now_local() -> datetime:
24
+ """Get current local datetime."""
25
+ return datetime.now()
26
+
27
+
28
+ def now_iso() -> str:
29
+ """Get current time as ISO 8601 string."""
30
+ return datetime.now().isoformat()
31
+
32
+
33
+ def project_dir(payload: Optional[Dict[str, Any]] = None) -> Path:
34
+ """
35
+ Get project directory from payload or environment.
36
+
37
+ Priority:
38
+ 1. CLAUDE_PROJECT_DIR environment variable
39
+ 2. 'cwd' from payload (if provided)
40
+ 3. Current working directory
41
+
42
+ Args:
43
+ payload: Optional hook payload with 'cwd' field
44
+
45
+ Returns:
46
+ Path to project directory
47
+
48
+ Note:
49
+ CLAUDE_PROJECT_DIR is validated to be an absolute path when provided.
50
+ """
51
+ p = os.environ.get("CLAUDE_PROJECT_DIR")
52
+ if p:
53
+ # Validate that CLAUDE_PROJECT_DIR is an absolute path
54
+ path = Path(p)
55
+ if not path.is_absolute():
56
+ eprint(f"[utils] WARNING: CLAUDE_PROJECT_DIR is not absolute, using cwd instead")
57
+ p = None
58
+ else:
59
+ # Check for suspicious patterns
60
+ if '..' in str(path):
61
+ eprint(f"[utils] WARNING: CLAUDE_PROJECT_DIR contains '..' pattern, using cwd instead")
62
+ p = None
63
+
64
+ if not p and payload:
65
+ p = payload.get("cwd")
66
+ if not p:
67
+ p = os.getcwd()
68
+ return Path(p)
69
+
70
+
71
+ # Windows reserved filenames that should be blocked
72
+ _WINDOWS_RESERVED = frozenset([
73
+ 'CON', 'PRN', 'AUX', 'NUL',
74
+ 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
75
+ 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
76
+ ])
77
+
78
+
79
+ def sanitize_filename(s: str, max_len: int = 32, allow_leading_dot: bool = False) -> str:
80
+ """
81
+ Sanitize string for use in filename.
82
+
83
+ Replaces non-alphanumeric characters (except ._-) with underscores,
84
+ strips leading/trailing special characters, and truncates.
85
+
86
+ Args:
87
+ s: Input string
88
+ max_len: Maximum length (default: 32)
89
+ allow_leading_dot: Whether to allow leading dots (default: False for security)
90
+
91
+ Returns:
92
+ Sanitized filename-safe string
93
+ """
94
+ s = re.sub(r"[^A-Za-z0-9._-]+", "_", s)
95
+ result = s.strip("._-")[:max_len] or "unknown"
96
+
97
+ # Remove leading dots unless explicitly allowed (prevents hidden files)
98
+ if not allow_leading_dot:
99
+ result = result.lstrip('.')
100
+
101
+ # Check for Windows reserved names
102
+ base_name = result.split('.')[0].upper()
103
+ if base_name in _WINDOWS_RESERVED:
104
+ result = f"_{result}"
105
+
106
+ return result or "unknown"
107
+
108
+
109
+ def sanitize_title(s: str, max_len: int = 50) -> str:
110
+ """
111
+ Sanitize title for use in context ID or filename.
112
+
113
+ Converts spaces to hyphens, replaces special characters,
114
+ and normalizes to lowercase.
115
+
116
+ Args:
117
+ s: Input string
118
+ max_len: Maximum length (default: 50)
119
+
120
+ Returns:
121
+ Sanitized slug-like string
122
+ """
123
+ s = s.lower().strip()
124
+ s = s.replace(' ', '-')
125
+ s = re.sub(r"[^a-z0-9._-]+", "_", s)
126
+ s = re.sub(r"[-_]+", "-", s)
127
+ result = s.strip("._-")[:max_len] or "unknown"
128
+
129
+ # Check for Windows reserved names
130
+ base_name = result.split('.')[0].upper()
131
+ if base_name in _WINDOWS_RESERVED:
132
+ result = f"_{result}"
133
+
134
+ return result or "unknown"
135
+
136
+
137
+ def generate_context_id(summary: str, existing_ids: Optional[set] = None) -> str:
138
+ """
139
+ Generate a context ID from a summary string.
140
+
141
+ Uses AI inference to create a semantic 10-word summary, then slugifies it.
142
+ Falls back to truncate-and-slugify if inference fails.
143
+
144
+ Args:
145
+ summary: Context summary text
146
+ existing_ids: Optional set of existing context IDs to avoid
147
+
148
+ Returns:
149
+ Unique context ID string
150
+ """
151
+ if not summary or not summary.strip():
152
+ base_id = "context"
153
+ else:
154
+ # Try AI-powered semantic summary first
155
+ base_id = None
156
+ try:
157
+ from .inference import generate_semantic_summary
158
+ semantic = generate_semantic_summary(summary)
159
+ if semantic:
160
+ # Slugify the semantic summary
161
+ base_id = sanitize_title(semantic, max_len=60)
162
+ eprint(f"[utils] Semantic context ID: {base_id}")
163
+ except Exception as e:
164
+ eprint(f"[utils] Inference failed, using fallback: {e}")
165
+
166
+ # Fallback to old method if inference failed
167
+ if not base_id:
168
+ base_id = sanitize_title(summary[:50])
169
+
170
+ if not existing_ids:
171
+ return base_id
172
+
173
+ # Ensure uniqueness by appending counter if needed
174
+ if base_id not in existing_ids:
175
+ return base_id
176
+
177
+ counter = 2
178
+ while f"{base_id}-{counter}" in existing_ids:
179
+ counter += 1
180
+
181
+ return f"{base_id}-{counter}"
182
+
183
+
184
+ def format_timestamp(dt: Optional[datetime] = None) -> str:
185
+ """
186
+ Format datetime for display.
187
+
188
+ Args:
189
+ dt: Datetime to format (default: now)
190
+
191
+ Returns:
192
+ Formatted string like "2026-01-25 10:30:00"
193
+ """
194
+ if dt is None:
195
+ dt = now_local()
196
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
197
+
198
+
199
+ def parse_iso_timestamp(iso_str: str) -> Optional[datetime]:
200
+ """
201
+ Parse ISO 8601 timestamp string.
202
+
203
+ Args:
204
+ iso_str: ISO format timestamp
205
+
206
+ Returns:
207
+ datetime object or None if parsing fails
208
+ """
209
+ try:
210
+ # Handle both with and without microseconds
211
+ if '.' in iso_str:
212
+ return datetime.fromisoformat(iso_str)
213
+ else:
214
+ return datetime.fromisoformat(iso_str.replace('Z', '+00:00'))
215
+ except (ValueError, TypeError):
216
+ return None