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.
- package/README.md +1248 -0
- package/bin/dev.cmd +3 -0
- package/bin/dev.js +16 -0
- package/bin/run.cmd +3 -0
- package/bin/run.js +19 -0
- package/dist/commands/branch.d.ts +45 -0
- package/dist/commands/branch.js +488 -0
- package/dist/commands/clean.d.ts +34 -0
- package/dist/commands/clean.js +186 -0
- package/dist/commands/clear.d.ts +51 -0
- package/dist/commands/clear.js +835 -0
- package/dist/commands/init/index.d.ts +107 -0
- package/dist/commands/init/index.js +565 -0
- package/dist/commands/launch.d.ts +21 -0
- package/dist/commands/launch.js +108 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lib/base-command.d.ts +114 -0
- package/dist/lib/base-command.js +153 -0
- package/dist/lib/bmad-installer.d.ts +38 -0
- package/dist/lib/bmad-installer.js +145 -0
- package/dist/lib/claude-settings-types.d.ts +102 -0
- package/dist/lib/claude-settings-types.js +5 -0
- package/dist/lib/config.d.ts +25 -0
- package/dist/lib/config.js +46 -0
- package/dist/lib/debug.d.ts +39 -0
- package/dist/lib/debug.js +74 -0
- package/dist/lib/env-compat.d.ts +26 -0
- package/dist/lib/env-compat.js +35 -0
- package/dist/lib/errors.d.ts +126 -0
- package/dist/lib/errors.js +145 -0
- package/dist/lib/generic-merge.d.ts +74 -0
- package/dist/lib/generic-merge.js +105 -0
- package/dist/lib/git/branch.d.ts +67 -0
- package/dist/lib/git/branch.js +155 -0
- package/dist/lib/git/index.d.ts +11 -0
- package/dist/lib/git/index.js +13 -0
- package/dist/lib/git/safety-checks.d.ts +44 -0
- package/dist/lib/git/safety-checks.js +102 -0
- package/dist/lib/git/types.d.ts +31 -0
- package/dist/lib/git/types.js +6 -0
- package/dist/lib/git/worktree.d.ts +67 -0
- package/dist/lib/git/worktree.js +220 -0
- package/dist/lib/gitignore-manager.d.ts +10 -0
- package/dist/lib/gitignore-manager.js +60 -0
- package/dist/lib/hooks-merger.d.ts +28 -0
- package/dist/lib/hooks-merger.js +94 -0
- package/dist/lib/ide-path-resolver.d.ts +102 -0
- package/dist/lib/ide-path-resolver.js +129 -0
- package/dist/lib/index.d.ts +13 -0
- package/dist/lib/index.js +22 -0
- package/dist/lib/output.d.ts +51 -0
- package/dist/lib/output.js +76 -0
- package/dist/lib/paths.d.ts +66 -0
- package/dist/lib/paths.js +136 -0
- package/dist/lib/quiet.d.ts +12 -0
- package/dist/lib/quiet.js +17 -0
- package/dist/lib/settings-hierarchy.d.ts +42 -0
- package/dist/lib/settings-hierarchy.js +105 -0
- package/dist/lib/spawn.d.ts +105 -0
- package/dist/lib/spawn.js +157 -0
- package/dist/lib/spinner.d.ts +19 -0
- package/dist/lib/spinner.js +34 -0
- package/dist/lib/stdin.d.ts +48 -0
- package/dist/lib/stdin.js +60 -0
- package/dist/lib/template-installer.d.ts +92 -0
- package/dist/lib/template-installer.js +375 -0
- package/dist/lib/template-linter.d.ts +49 -0
- package/dist/lib/template-linter.js +173 -0
- package/dist/lib/template-merger.d.ts +47 -0
- package/dist/lib/template-merger.js +173 -0
- package/dist/lib/template-resolver.d.ts +20 -0
- package/dist/lib/template-resolver.js +60 -0
- package/dist/lib/terminal.d.ts +102 -0
- package/dist/lib/terminal.js +245 -0
- package/dist/lib/tty-detection.d.ts +62 -0
- package/dist/lib/tty-detection.js +83 -0
- package/dist/lib/user-utils.d.ts +5 -0
- package/dist/lib/user-utils.js +23 -0
- package/dist/lib/version.d.ts +99 -0
- package/dist/lib/version.js +144 -0
- package/dist/lib/watch-templates.d.ts +6 -0
- package/dist/lib/watch-templates.js +73 -0
- package/dist/lib/windsurf-hooks-hierarchy.d.ts +30 -0
- package/dist/lib/windsurf-hooks-hierarchy.js +66 -0
- package/dist/lib/windsurf-hooks-merger.d.ts +26 -0
- package/dist/lib/windsurf-hooks-merger.js +53 -0
- package/dist/lib/windsurf-hooks-types.d.ts +33 -0
- package/dist/lib/windsurf-hooks-types.js +5 -0
- package/dist/templates/CLAUDE.md +174 -0
- package/dist/templates/_shared/.claude/commands/handoff.md +14 -0
- package/dist/templates/_shared/.claude/settings.json +61 -0
- package/dist/templates/_shared/.codex/workflows/handoff.md +14 -0
- package/dist/templates/_shared/.windsurf/workflows/handoff.md +14 -0
- package/dist/templates/_shared/hooks/__init__.py +16 -0
- package/dist/templates/_shared/hooks/archive_plan.py +270 -0
- package/dist/templates/_shared/hooks/context_enforcer.py +621 -0
- package/dist/templates/_shared/hooks/context_monitor.py +322 -0
- package/dist/templates/_shared/hooks/file-suggestion.py +188 -0
- package/dist/templates/_shared/hooks/task_create_capture.py +194 -0
- package/dist/templates/_shared/hooks/task_update_capture.py +254 -0
- package/dist/templates/_shared/hooks/user_prompt_submit.py +157 -0
- package/dist/templates/_shared/lib/__init__.py +1 -0
- package/dist/templates/_shared/lib/base/__init__.py +49 -0
- package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/atomic_write.py +180 -0
- package/dist/templates/_shared/lib/base/constants.py +299 -0
- package/dist/templates/_shared/lib/base/inference.py +189 -0
- package/dist/templates/_shared/lib/base/utils.py +216 -0
- package/dist/templates/_shared/lib/context/__init__.py +119 -0
- package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/cache.py +446 -0
- package/dist/templates/_shared/lib/context/context_manager.py +1171 -0
- package/dist/templates/_shared/lib/context/discovery.py +486 -0
- package/dist/templates/_shared/lib/context/event_log.py +308 -0
- package/dist/templates/_shared/lib/context/plan_archive.py +247 -0
- package/dist/templates/_shared/lib/context/task_sync.py +367 -0
- package/dist/templates/_shared/lib/handoff/__init__.py +22 -0
- package/dist/templates/_shared/lib/handoff/document_generator.py +307 -0
- package/dist/templates/_shared/lib/templates/README.md +215 -0
- package/dist/templates/_shared/lib/templates/__init__.py +40 -0
- package/dist/templates/_shared/lib/templates/formatters.py +147 -0
- package/dist/templates/_shared/lib/templates/plan_context.py +119 -0
- package/dist/templates/_shared/scripts/save_handoff.py +99 -0
- package/dist/templates/_shared/workflows/handoff.md +212 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/ACCESSIBILITY-TESTER.md +80 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/ARCHITECT-REVIEWER.md +75 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/ASSUMPTION-CHAIN-TRACER.md +239 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/CLARITY-AUDITOR.md +109 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/CODE-REVIEWER.md +71 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/COMPLETENESS-CHECKER.md +104 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/CONTEXT-EXTRACTOR.md +93 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/DEVILS-ADVOCATE.md +223 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/DOCUMENTATION-REVIEWER.md +73 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/FEASIBILITY-ANALYST.md +93 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/FRESH-PERSPECTIVE.md +103 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/HANDOFF-READINESS.md +145 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/HIDDEN-COMPLEXITY-DETECTOR.md +248 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/INCENTIVE-MAPPER.md +235 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PENETRATION-TESTER.md +80 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PERFORMANCE-ENGINEER.md +76 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PLAN-ORCHESTRATOR.md +141 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/PRECEDENT-FINDER.md +240 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/REVERSIBILITY-ANALYST.md +211 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/RISK-ASSESSOR.md +101 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/SECOND-ORDER-ANALYST.md +197 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/SIMPLICITY-GUARDIAN.md +97 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/SKEPTIC.md +349 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/STAKEHOLDER-ADVOCATE.md +106 -0
- package/dist/templates/cc-native/.claude/agents/cc-native/TRADE-OFF-ILLUMINATOR.md +205 -0
- package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +8 -0
- package/dist/templates/cc-native/.claude/commands/cc-native/specdev.md +10 -0
- package/dist/templates/cc-native/.claude/settings.json +119 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/fix.md +8 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +8 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/implement.md +8 -0
- package/dist/templates/cc-native/.windsurf/workflows/cc-native/research.md +8 -0
- package/dist/templates/cc-native/CC-NATIVE-README.md +192 -0
- package/dist/templates/cc-native/MIGRATION.md +86 -0
- package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +331 -0
- package/dist/templates/cc-native/_cc-native/docs/PERMISSION_REQUEST_VERIFICATION.md +147 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +150 -0
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +746 -0
- package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +339 -0
- package/dist/templates/cc-native/_cc-native/lib/__init__.py +57 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/async_archive.py +68 -0
- package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +98 -0
- package/dist/templates/cc-native/_cc-native/lib/constants.py +45 -0
- package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +273 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +28 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +164 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +89 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +119 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +103 -0
- package/dist/templates/cc-native/_cc-native/lib/state.py +251 -0
- package/dist/templates/cc-native/_cc-native/lib/utils.py +830 -0
- package/dist/templates/cc-native/_cc-native/plan-review.config.json +76 -0
- package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +151 -0
- package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +134 -0
- package/dist/templates/cc-native/_cc-native/workflows/specdev.md +9 -0
- package/dist/types/exit-codes.d.ts +11 -0
- package/dist/types/exit-codes.js +10 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +7 -0
- package/oclif.manifest.json +405 -0
- 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
|
+
]
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|