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,339 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ PostToolUse hook - suggests /fresh-perspective when user appears stuck.
4
+
5
+ Detection patterns:
6
+ 1. Same error appearing 3+ times
7
+ 2. Repeated edits to same file without resolution
8
+ 3. Test failures after multiple fix attempts
9
+
10
+ Behavior: Suggests (doesn't force) running /fresh-perspective.
11
+ Non-blocking - always returns success.
12
+
13
+ Configuration (in _cc-native/plan-review.config.json):
14
+ "stuckDetection": {
15
+ "enabled": true, // Set to false to disable entirely
16
+ "errorThreshold": 3, // Errors before suggesting
17
+ "fileEditThreshold": 4, // Edits to same file before suggesting
18
+ "testFailureThreshold": 3, // Test failures before suggesting
19
+ "cooldown": 10, // Tool calls between suggestions
20
+ "maxSuggestions": 3 // Max suggestions per session
21
+ }
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+ import tempfile
29
+ from pathlib import Path
30
+ from typing import Any, Dict
31
+
32
+ # Add lib directory to path for imports
33
+ _hook_dir = Path(__file__).resolve().parent
34
+ _lib_dir = _hook_dir.parent / "lib"
35
+ sys.path.insert(0, str(_lib_dir))
36
+
37
+ from utils import eprint, sanitize_filename
38
+
39
+
40
+ # ---------------------------
41
+ # Configuration (defaults, overridden by config.json)
42
+ # ---------------------------
43
+
44
+ DEFAULT_CONFIG = {
45
+ "enabled": True,
46
+ "errorThreshold": 3,
47
+ "fileEditThreshold": 4,
48
+ "testFailureThreshold": 3,
49
+ "cooldown": 10,
50
+ "maxSuggestions": 3,
51
+ }
52
+
53
+
54
+ def _int_or_default(value: Any, default: int) -> int:
55
+ """Coerce value to int, return default if not possible.
56
+
57
+ Handles string numbers, floats, and invalid types gracefully.
58
+ """
59
+ if isinstance(value, int):
60
+ return value
61
+ if isinstance(value, float):
62
+ return int(value)
63
+ if isinstance(value, str):
64
+ try:
65
+ return int(value)
66
+ except ValueError:
67
+ return default
68
+ return default
69
+
70
+
71
+ def load_config(project_dir: Path) -> Dict[str, Any]:
72
+ """Load stuckDetection config from _cc-native/plan-review.config.json."""
73
+ config_path = project_dir / "_cc-native" / "plan-review.config.json"
74
+ if not config_path.exists():
75
+ return DEFAULT_CONFIG.copy()
76
+ try:
77
+ full_config = json.loads(config_path.read_text(encoding="utf-8"))
78
+ section = full_config.get("stuckDetection", {})
79
+ return {**DEFAULT_CONFIG, **section}
80
+ except Exception as e:
81
+ eprint(f"[suggest-fresh-perspective] Failed to load config: {e}")
82
+ return DEFAULT_CONFIG.copy()
83
+
84
+
85
+ def get_project_dir(payload: Dict[str, Any]) -> Path:
86
+ """Get project directory from payload or environment."""
87
+ p = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
88
+ return Path(p)
89
+
90
+
91
+ # ---------------------------
92
+ # Compiled patterns (performance optimization)
93
+ # ---------------------------
94
+
95
+ # Single combined pattern for error detection (case-insensitive)
96
+ _ERROR_PATTERN = re.compile(
97
+ r'(error:|failed|exception)',
98
+ re.IGNORECASE
99
+ )
100
+
101
+ # Combined pattern for test failures
102
+ _TEST_FAILURE_PATTERN = re.compile(
103
+ r'(\d+\s+failed|FAIL\s|✗|AssertionError|test.*failed|npm\s+ERR!.*test)',
104
+ re.IGNORECASE
105
+ )
106
+
107
+ # Pattern for normalizing error messages (line numbers)
108
+ _LINE_NUMBER_PATTERN = re.compile(r':\d+')
109
+ _MULTI_DIGIT_PATTERN = re.compile(r'\d{2,}')
110
+ _PATH_PATTERN = re.compile(r'[/\\][^\s/\\]+[/\\]')
111
+
112
+
113
+ # ---------------------------
114
+ # State management (session-scoped)
115
+ # ---------------------------
116
+
117
+ def get_state_path(session_id: str) -> Path:
118
+ """Get path to stuck-detection state file for this session."""
119
+ safe_id = sanitize_filename(str(session_id), max_len=32)
120
+ return Path(tempfile.gettempdir()) / f"cc-native-stuck-state-{safe_id}.json"
121
+
122
+
123
+ def load_state(session_id: str) -> Dict[str, Any]:
124
+ """Load stuck detection state for this session."""
125
+ state_path = get_state_path(session_id)
126
+ default_state = {
127
+ "error_hashes": {}, # hash -> count
128
+ "file_edits": {}, # file_path -> count
129
+ "test_failures": 0,
130
+ "tool_calls_since_suggestion": 0,
131
+ "suggestion_count": 0,
132
+ }
133
+ if not state_path.exists():
134
+ return default_state
135
+ try:
136
+ return json.loads(state_path.read_text(encoding="utf-8"))
137
+ except Exception:
138
+ return default_state
139
+
140
+
141
+ def save_state(session_id: str, state: Dict[str, Any]) -> None:
142
+ """Save stuck detection state for this session."""
143
+ state_path = get_state_path(session_id)
144
+ try:
145
+ state_path.write_text(json.dumps(state), encoding="utf-8")
146
+ except Exception as e:
147
+ eprint(f"[suggest-fresh-perspective] Warning: failed to save state: {e}")
148
+
149
+
150
+ # ---------------------------
151
+ # Detection logic
152
+ # ---------------------------
153
+
154
+ def hash_error(error_text: str) -> str:
155
+ """Create a simple hash of an error message for deduplication.
156
+
157
+ Normalizes by removing line numbers and multi-digit numbers,
158
+ but preserves enough context to distinguish different errors.
159
+ """
160
+ # Normalize: remove line numbers, preserve error type
161
+ normalized = _LINE_NUMBER_PATTERN.sub(':N', error_text)
162
+ normalized = _MULTI_DIGIT_PATTERN.sub('N', normalized)
163
+ # Simplify paths but keep some structure
164
+ normalized = _PATH_PATTERN.sub('.../', normalized)
165
+ # Take first 100 chars after normalization
166
+ return normalized[:100]
167
+
168
+
169
+ def detect_repeated_error(state: Dict[str, Any], tool_result: str, threshold: int) -> bool:
170
+ """Check if we're seeing the same error repeatedly.
171
+
172
+ Returns True if threshold reached, always updates state.
173
+ """
174
+ if not tool_result:
175
+ return False
176
+
177
+ if _ERROR_PATTERN.search(tool_result):
178
+ error_hash = hash_error(tool_result)
179
+ state["error_hashes"][error_hash] = state["error_hashes"].get(error_hash, 0) + 1
180
+ return state["error_hashes"][error_hash] >= threshold
181
+
182
+ return False
183
+
184
+
185
+ def detect_repeated_file_edits(state: Dict[str, Any], tool_name: str, tool_input: Dict[str, Any], threshold: int) -> bool:
186
+ """Check if we're editing the same file repeatedly.
187
+
188
+ Returns True if threshold reached, always updates state.
189
+ """
190
+ if tool_name != "Edit":
191
+ return False
192
+
193
+ # Validate tool_input is a dict
194
+ if not isinstance(tool_input, dict):
195
+ return False
196
+
197
+ file_path = tool_input.get("file_path", "")
198
+ if not file_path:
199
+ return False
200
+
201
+ state["file_edits"][file_path] = state["file_edits"].get(file_path, 0) + 1
202
+ return state["file_edits"][file_path] >= threshold
203
+
204
+
205
+ def detect_test_failures(state: Dict[str, Any], tool_name: str, tool_result: str, threshold: int) -> bool:
206
+ """Check for repeated test failures.
207
+
208
+ Returns True if threshold reached, always updates state.
209
+ """
210
+ if tool_name != "Bash":
211
+ return False
212
+
213
+ if _TEST_FAILURE_PATTERN.search(tool_result):
214
+ state["test_failures"] = state.get("test_failures", 0) + 1
215
+ return state["test_failures"] >= threshold
216
+
217
+ return False
218
+
219
+
220
+ # ---------------------------
221
+ # Main hook logic
222
+ # ---------------------------
223
+
224
+ def should_suggest(state: Dict[str, Any], cooldown: int) -> bool:
225
+ """Check if we're past the cooldown period."""
226
+ return state.get("tool_calls_since_suggestion", 0) >= cooldown
227
+
228
+
229
+ def create_suggestion() -> Dict[str, Any]:
230
+ """Create the suggestion output."""
231
+ return {
232
+ "hookSpecificOutput": {
233
+ "additionalContext": (
234
+ "\n---\n"
235
+ "**Stuck?** You've been working on similar issues for a while. "
236
+ "Consider running `/fresh-perspective` to get an unbiased view of the problem "
237
+ "without code context anchoring your thinking.\n"
238
+ "---\n"
239
+ )
240
+ }
241
+ }
242
+
243
+
244
+ def main() -> int:
245
+ # === FAST PATH: Cheap checks first, no I/O ===
246
+
247
+ try:
248
+ payload = json.load(sys.stdin)
249
+ except json.JSONDecodeError:
250
+ return 0 # Fail-safe
251
+
252
+ # 1. Check hook_type (cheap dict lookup)
253
+ if payload.get("hook_type") != "PostToolUse":
254
+ return 0
255
+
256
+ # 2. Check session_id exists (cheap dict lookup)
257
+ session_id = payload.get("session_id")
258
+ if not session_id:
259
+ return 0
260
+
261
+ # 3. Check tool_name is relevant (cheap dict lookup)
262
+ # We only care about Edit and Bash - skip everything else
263
+ tool_name = payload.get("tool_name", "")
264
+ if tool_name not in ("Edit", "Bash"):
265
+ return 0
266
+
267
+ # === SLOW PATH: Only reached for Edit/Bash tools ===
268
+
269
+ # Load configuration (file I/O)
270
+ project_dir = get_project_dir(payload)
271
+ config = load_config(project_dir)
272
+
273
+ # Check if feature is disabled
274
+ if not config.get("enabled", True):
275
+ return 0
276
+
277
+ tool_input = payload.get("tool_input", {})
278
+ tool_result = payload.get("tool_result", {})
279
+
280
+ # Validate tool_input type
281
+ if not isinstance(tool_input, dict):
282
+ tool_input = {}
283
+
284
+ # Extract result text
285
+ result_text = ""
286
+ if isinstance(tool_result, dict):
287
+ result_text = str(tool_result.get("output", "") or tool_result.get("content", ""))
288
+ elif isinstance(tool_result, str):
289
+ result_text = tool_result
290
+
291
+ # Load state (file I/O)
292
+ state = load_state(session_id)
293
+
294
+ # Increment tool call counter
295
+ state["tool_calls_since_suggestion"] = state.get("tool_calls_since_suggestion", 0) + 1
296
+
297
+ # Get thresholds from config (with type coercion for safety)
298
+ error_threshold = _int_or_default(config.get("errorThreshold"), 3)
299
+ file_edit_threshold = _int_or_default(config.get("fileEditThreshold"), 4)
300
+ test_failure_threshold = _int_or_default(config.get("testFailureThreshold"), 3)
301
+ cooldown = _int_or_default(config.get("cooldown"), 10)
302
+ max_suggestions = _int_or_default(config.get("maxSuggestions"), 3)
303
+
304
+ # Run ALL detections (don't short-circuit - each updates state)
305
+ error_detected = detect_repeated_error(state, result_text, error_threshold)
306
+ file_edit_detected = detect_repeated_file_edits(state, tool_name, tool_input, file_edit_threshold)
307
+ test_failure_detected = detect_test_failures(state, tool_name, result_text, test_failure_threshold)
308
+
309
+ # Save state AFTER all detections have run
310
+ save_state(session_id, state)
311
+
312
+ # Check if any detection triggered
313
+ is_stuck = error_detected or file_edit_detected or test_failure_detected
314
+
315
+ if is_stuck:
316
+ if error_detected:
317
+ eprint("[suggest-fresh-perspective] Detected repeated error pattern")
318
+ if file_edit_detected:
319
+ eprint("[suggest-fresh-perspective] Detected repeated file edits")
320
+ if test_failure_detected:
321
+ eprint("[suggest-fresh-perspective] Detected repeated test failures")
322
+
323
+ # Only suggest if stuck AND past cooldown
324
+ if is_stuck and should_suggest(state, cooldown):
325
+ # Reset cooldown
326
+ state["tool_calls_since_suggestion"] = 0
327
+ state["suggestion_count"] = state.get("suggestion_count", 0) + 1
328
+ save_state(session_id, state)
329
+
330
+ # Only suggest up to maxSuggestions times per session
331
+ if state["suggestion_count"] <= max_suggestions:
332
+ eprint(f"[suggest-fresh-perspective] Suggesting fresh perspective (suggestion #{state['suggestion_count']})")
333
+ print(json.dumps(create_suggestion(), ensure_ascii=False))
334
+
335
+ return 0
336
+
337
+
338
+ if __name__ == "__main__":
339
+ raise SystemExit(main())
@@ -0,0 +1,57 @@
1
+ """CC-Native shared library modules.
2
+
3
+ This package contains shared utilities for cc-native hooks:
4
+ - utils: Core utilities (eprint, sanitize, JSON parsing, artifact writing)
5
+ - state: Plan state file management and iteration tracking
6
+ - orchestrator: Plan complexity analysis and agent selection
7
+ - reviewers: CLI and agent-based plan review implementations
8
+ """
9
+
10
+ from .utils import (
11
+ eprint,
12
+ sanitize_filename,
13
+ sanitize_title,
14
+ extract_plan_title,
15
+ extract_task_from_context,
16
+ find_plan_file,
17
+ ReviewerResult,
18
+ OrchestratorResult,
19
+ CombinedReviewResult,
20
+ REVIEW_SCHEMA,
21
+ )
22
+
23
+ from .state import (
24
+ get_state_file_path,
25
+ load_state,
26
+ save_state,
27
+ delete_state,
28
+ get_iteration_state,
29
+ update_iteration_state,
30
+ should_continue_iterating,
31
+ DEFAULT_REVIEW_ITERATIONS,
32
+ )
33
+
34
+ __all__ = [
35
+ # Core utilities
36
+ "eprint",
37
+ "sanitize_filename",
38
+ "sanitize_title",
39
+ "extract_plan_title",
40
+ "extract_task_from_context",
41
+ "find_plan_file",
42
+ # Dataclasses
43
+ "ReviewerResult",
44
+ "OrchestratorResult",
45
+ "CombinedReviewResult",
46
+ # Constants
47
+ "REVIEW_SCHEMA",
48
+ "DEFAULT_REVIEW_ITERATIONS",
49
+ # State management
50
+ "get_state_file_path",
51
+ "load_state",
52
+ "save_state",
53
+ "delete_state",
54
+ "get_iteration_state",
55
+ "update_iteration_state",
56
+ "should_continue_iterating",
57
+ ]
@@ -0,0 +1,68 @@
1
+ """Async background archival to avoid blocking user workflow."""
2
+ import threading
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Dict, Any, Callable, Optional
6
+ try:
7
+ from .atomic_write import atomic_write
8
+ from .constants import ENABLE_ROBUST_PLAN_WRITES
9
+ except ImportError:
10
+ # When imported directly via sys.path (not as a package)
11
+ from atomic_write import atomic_write
12
+ from constants import ENABLE_ROBUST_PLAN_WRITES
13
+
14
+ def archive_plan_async(
15
+ out_path: Path,
16
+ header: str,
17
+ plan: str,
18
+ callback: Optional[Callable] = None
19
+ ) -> None:
20
+ """
21
+ Archive plan in background thread. Non-blocking.
22
+
23
+ Args:
24
+ out_path: Destination file path
25
+ header: Plan header with metadata
26
+ plan: Plan content
27
+ callback: Optional callback(success: bool, error: str) on completion
28
+ """
29
+ if not ENABLE_ROBUST_PLAN_WRITES:
30
+ # Legacy behavior - write directly
31
+ try:
32
+ out_path.write_text(header + plan + "\n", encoding="utf-8")
33
+ if callback:
34
+ callback(True, None)
35
+ except Exception as e:
36
+ if callback:
37
+ callback(False, str(e))
38
+ return
39
+
40
+ def _archive_worker():
41
+ success, error = atomic_write(out_path, header + plan + "\n")
42
+
43
+ if not success:
44
+ # Write sanitized error marker (no stack traces)
45
+ error_marker = out_path.with_suffix('.error')
46
+ error_content = f"Archive failed: {error}\n"
47
+
48
+ try:
49
+ # Use atomic write for error marker too
50
+ atomic_write(
51
+ error_marker,
52
+ error_content,
53
+ max_attempts=1 # Don't retry error marker
54
+ )
55
+ except Exception:
56
+ pass # Error marker is best-effort
57
+
58
+ if callback:
59
+ try:
60
+ callback(success, error)
61
+ except Exception as e:
62
+ # Log callback failures (daemon thread would otherwise swallow)
63
+ import sys
64
+ print(f"[async_archive] Callback failed: {e}", file=sys.stderr)
65
+
66
+ # Start background thread
67
+ thread = threading.Thread(target=_archive_worker, daemon=False)
68
+ thread.start()
@@ -0,0 +1,98 @@
1
+ """Cross-platform atomic file writes with security."""
2
+ import os
3
+ import sys
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ if sys.platform == 'win32':
9
+ import ctypes
10
+ from ctypes import wintypes
11
+
12
+ # Windows MoveFileEx flags
13
+ MOVEFILE_REPLACE_EXISTING = 0x1
14
+ MOVEFILE_WRITE_THROUGH = 0x8
15
+
16
+ def _atomic_replace_windows(src: Path, dst: Path) -> None:
17
+ """Atomic file replacement on Windows using MoveFileEx."""
18
+ kernel32 = ctypes.windll.kernel32
19
+
20
+ # Set proper function prototypes for 64-bit safety
21
+ kernel32.MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD]
22
+ kernel32.MoveFileExW.restype = wintypes.BOOL
23
+
24
+ result = kernel32.MoveFileExW(
25
+ str(src),
26
+ str(dst),
27
+ MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
28
+ )
29
+ if not result:
30
+ error_code = kernel32.GetLastError()
31
+ # Use ctypes.WinError for human-readable error messages
32
+ raise ctypes.WinError(error_code)
33
+
34
+ def atomic_write(
35
+ path: Path,
36
+ content: str,
37
+ max_attempts: int = 2,
38
+ backoff_ms: list = None
39
+ ) -> tuple:
40
+ """
41
+ Write file atomically with retry logic.
42
+
43
+ Returns:
44
+ (success: bool, error_message: Optional[str])
45
+ """
46
+ import time
47
+
48
+ if backoff_ms is None:
49
+ backoff_ms = [500, 1000]
50
+
51
+ for attempt in range(max_attempts):
52
+ try:
53
+ # Create temp file in same directory for atomic rename
54
+ temp_fd, temp_path_str = tempfile.mkstemp(
55
+ dir=path.parent,
56
+ prefix=f".{path.stem}_",
57
+ suffix=".tmp"
58
+ )
59
+ temp_path = Path(temp_path_str)
60
+
61
+ try:
62
+ # Write content to temp file
63
+ with os.fdopen(temp_fd, 'w', encoding='utf-8') as f:
64
+ f.write(content)
65
+ f.flush()
66
+ os.fsync(f.fileno()) # Force write to disk
67
+
68
+ # Set restrictive permissions before rename (chmod 600)
69
+ os.chmod(temp_path, 0o600)
70
+
71
+ # Platform-specific atomic rename
72
+ if sys.platform == 'win32':
73
+ _atomic_replace_windows(temp_path, path)
74
+ else:
75
+ temp_path.replace(path) # POSIX atomic
76
+
77
+ return (True, None)
78
+
79
+ except Exception as e:
80
+ # Clean up temp file on failure
81
+ try:
82
+ temp_path.unlink()
83
+ except Exception:
84
+ pass # Cleanup is best-effort
85
+ raise
86
+
87
+ except Exception as e:
88
+ if attempt < max_attempts - 1:
89
+ # Bounds-safe backoff indexing
90
+ wait_ms = backoff_ms[min(attempt, len(backoff_ms) - 1)]
91
+ time.sleep(wait_ms / 1000.0)
92
+ else:
93
+ # Sanitize error message (no paths, no stack trace)
94
+ error_type = type(e).__name__
95
+ error_msg = str(e).split('\n')[0][:200] # First line only, max 200 chars
96
+ return (False, f"{error_type}: {error_msg}")
97
+
98
+ return (False, "Max retry attempts exceeded")
@@ -0,0 +1,45 @@
1
+ """Security and configuration constants."""
2
+ from pathlib import Path
3
+ import os
4
+
5
+ # Feature flags
6
+ ENABLE_ROBUST_PLAN_WRITES = os.getenv('CC_NATIVE_ROBUST_WRITES', 'true').lower() == 'true'
7
+ ENABLE_PLAN_NOTIFICATIONS = os.getenv('CC_NATIVE_NOTIFICATIONS', 'false').lower() == 'true'
8
+
9
+ # Security constants
10
+ PLANS_DIR = Path.home() / ".claude" / "plans"
11
+ MAX_PLAN_PATH_LENGTH = 4096
12
+ MAX_ERROR_FILE_SIZE = 10 * 1024 # 10KB
13
+
14
+ # Performance constants
15
+ MAX_RETRY_ATTEMPTS = 2 # Fast-fail: 2 attempts max
16
+ RETRY_BACKOFF_MS = [500, 1000] # 0.5s, 1s (total 1.5s max)
17
+ MAX_TOTAL_RETRY_TIME_MS = 3000 # 3 seconds total, well under 5s hook timeout
18
+
19
+ def validate_plan_path(plan_path: str) -> Path:
20
+ """
21
+ Validate and sanitize plan path to prevent traversal attacks.
22
+
23
+ Raises:
24
+ ValueError: If path is invalid, too long, or outside allowed directory
25
+ """
26
+ # Input validation
27
+ if not plan_path or len(plan_path) > MAX_PLAN_PATH_LENGTH:
28
+ raise ValueError(f"Invalid plan path length: {len(plan_path) if plan_path else 0}")
29
+
30
+ if '\x00' in plan_path:
31
+ raise ValueError("Null bytes not allowed in path")
32
+
33
+ # Normalize and resolve to absolute canonical path
34
+ try:
35
+ resolved = Path(plan_path).resolve(strict=False)
36
+ except (OSError, RuntimeError) as e:
37
+ raise ValueError(f"Path resolution failed: {e}")
38
+
39
+ # Verify path is within allowed directory
40
+ try:
41
+ resolved.relative_to(PLANS_DIR)
42
+ except ValueError:
43
+ raise ValueError(f"Path outside allowed directory: {PLANS_DIR}")
44
+
45
+ return resolved