aiwcli 0.9.8 → 0.10.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 (116) hide show
  1. package/bin/run.js +5 -2
  2. package/dist/lib/claude-settings-types.d.ts +2 -0
  3. package/dist/templates/CLAUDE.md +3 -3
  4. package/dist/templates/_shared/.claude/settings.json +4 -0
  5. package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  7. package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
  8. package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
  9. package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
  10. package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
  11. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  12. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  13. package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
  14. package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
  15. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  16. package/dist/templates/_shared/hooks/archive_plan.py +87 -178
  17. package/dist/templates/_shared/hooks/context_monitor.py +104 -247
  18. package/dist/templates/_shared/hooks/file-suggestion.py +26 -23
  19. package/dist/templates/_shared/hooks/pre_compact.py +47 -32
  20. package/dist/templates/_shared/hooks/session_end.py +103 -60
  21. package/dist/templates/_shared/hooks/session_start.py +110 -81
  22. package/dist/templates/_shared/hooks/task_create_capture.py +26 -50
  23. package/dist/templates/_shared/hooks/task_update_capture.py +42 -115
  24. package/dist/templates/_shared/hooks/user_prompt_submit.py +61 -61
  25. package/dist/templates/_shared/lib/base/__init__.py +16 -0
  26. package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
  28. package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
  29. package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
  30. package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
  31. package/dist/templates/_shared/lib/base/hook_utils.py +199 -11
  32. package/dist/templates/_shared/lib/base/inference.py +121 -0
  33. package/dist/templates/_shared/lib/base/logger.py +291 -0
  34. package/dist/templates/_shared/lib/base/utils.py +42 -9
  35. package/dist/templates/_shared/lib/context/__init__.py +72 -80
  36. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  38. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  39. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  40. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  41. package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
  42. package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
  43. package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
  44. package/dist/templates/_shared/lib/context/context_formatter.py +316 -0
  45. package/dist/templates/_shared/lib/context/context_selector.py +491 -0
  46. package/dist/templates/_shared/lib/context/context_store.py +636 -0
  47. package/dist/templates/_shared/lib/context/plan_manager.py +204 -0
  48. package/dist/templates/_shared/lib/context/task_tracker.py +188 -0
  49. package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
  50. package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
  51. package/dist/templates/_shared/lib/handoff/document_generator.py +14 -40
  52. package/dist/templates/_shared/lib/templates/README.md +5 -13
  53. package/dist/templates/_shared/lib/templates/__init__.py +2 -6
  54. package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
  56. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  57. package/dist/templates/_shared/lib/templates/plan_context.py +1 -38
  58. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  59. package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
  60. package/dist/templates/_shared/scripts/save_handoff.py +39 -19
  61. package/dist/templates/_shared/scripts/status_line.py +701 -0
  62. package/dist/templates/_shared/workflows/handoff.md +9 -3
  63. package/dist/templates/cc-native/.claude/settings.json +41 -8
  64. package/dist/templates/cc-native/CC-NATIVE-README.md +25 -28
  65. package/dist/templates/cc-native/MIGRATION.md +1 -1
  66. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +14 -39
  67. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +49 -21
  68. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  69. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  70. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
  71. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
  72. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
  73. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
  74. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +57 -55
  75. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +163 -131
  76. package/dist/templates/cc-native/_cc-native/hooks/plan_accepted.py +127 -0
  77. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +81 -0
  78. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +26 -25
  79. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +6 -4
  80. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  82. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  83. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  84. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  85. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  86. package/dist/templates/cc-native/_cc-native/lib/debug.py +37 -22
  87. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +34 -29
  88. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  89. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  90. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  91. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  92. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  93. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +26 -21
  94. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +12 -7
  95. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +12 -7
  96. package/dist/templates/cc-native/_cc-native/lib/state.py +31 -16
  97. package/dist/templates/cc-native/_cc-native/lib/utils.py +207 -40
  98. package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -2
  99. package/oclif.manifest.json +1 -1
  100. package/package.json +1 -1
  101. package/dist/templates/_shared/hooks/context_enforcer.py +0 -625
  102. package/dist/templates/_shared/hooks/task_create_atomicity.py +0 -177
  103. package/dist/templates/_shared/lib/context/auto_state.py +0 -167
  104. package/dist/templates/_shared/lib/context/cache.py +0 -444
  105. package/dist/templates/_shared/lib/context/context_extractor.py +0 -115
  106. package/dist/templates/_shared/lib/context/context_manager.py +0 -1057
  107. package/dist/templates/_shared/lib/context/discovery.py +0 -554
  108. package/dist/templates/_shared/lib/context/event_log.py +0 -316
  109. package/dist/templates/_shared/lib/context/plan_archive.py +0 -101
  110. package/dist/templates/_shared/lib/context/task_sync.py +0 -407
  111. package/dist/templates/_shared/lib/templates/persona_questions.py +0 -113
  112. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  113. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
  114. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
  115. package/dist/templates/cc-native/_cc-native/lib/async_archive.py +0 -68
  116. package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +0 -98
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook for ExitPlanMode — assigns plan fields to state.json.
3
+
4
+ This hook fires when the user ACCEPTS the plan (ExitPlanMode succeeds).
5
+ PostToolUse fires ONLY on success, so this reliably indicates acceptance.
6
+
7
+ The plan was already archived by archive_plan.py (PermissionRequest).
8
+ This hook finds the archived plan, computes hash + signature, and assigns
9
+ plan_hash, plan_signature, plan_path to state.json — without changing mode.
10
+
11
+ Separation of concerns:
12
+ - archive_plan.py (PermissionRequest) -> archives file only, no state.json changes
13
+ - plan_accepted.py (PostToolUse) -> assigns plan fields (hash/signature/path)
14
+ - session_end.py (SessionEnd) -> transitions active -> has_plan when plan is assigned
15
+ - context_selector.py -> matches plan content, transitions has_plan -> active
16
+
17
+ Usage in .claude/settings.json:
18
+ {
19
+ "hooks": {
20
+ "PostToolUse": [{
21
+ "matcher": "ExitPlanMode",
22
+ "hooks": [{
23
+ "type": "command",
24
+ "command": "python .aiwcli/_cc-native/hooks/plan_accepted.py",
25
+ "timeout": 5000
26
+ }]
27
+ }]
28
+ }
29
+ }
30
+ """
31
+ import hashlib
32
+ import sys
33
+ from pathlib import Path
34
+
35
+ # Add parent directories to path for imports
36
+ SCRIPT_DIR = Path(__file__).resolve().parent
37
+ SHARED_LIB = SCRIPT_DIR.parent.parent / "_shared" / "lib"
38
+ sys.path.insert(0, str(SHARED_LIB.parent))
39
+
40
+ from lib.base.hook_utils import load_hook_input, log_hook_error
41
+ from lib.base.logger import log_info, log_debug, log_warn, log_error
42
+ from lib.base.utils import project_dir
43
+ from lib.context.context_store import get_context_by_session_id, update_mode
44
+ from lib.context.plan_manager import (
45
+ find_latest_plan,
46
+ extract_plan_path_from_result,
47
+ generate_plan_id,
48
+ normalize_plan_content,
49
+ extract_plan_anchors,
50
+ )
51
+
52
+
53
+ def main():
54
+ """Assign plan fields (hash/signature/path) to state.json on plan acceptance."""
55
+ hook_input = load_hook_input()
56
+ if not hook_input:
57
+ log_warn("plan_accepted", "EXIT: no hook_input (stdin empty or invalid JSON)")
58
+ return
59
+
60
+ hook_event = hook_input.get("hook_event_name", "")
61
+ tool_name = hook_input.get("tool_name", "")
62
+ session_id = hook_input.get("session_id", "MISSING")
63
+
64
+ if not (hook_event == "PostToolUse" and tool_name == "ExitPlanMode"):
65
+ log_debug("plan_accepted", f"Skipping: {hook_event}/{tool_name}")
66
+ return
67
+
68
+ project_root = project_dir(hook_input)
69
+ state = get_context_by_session_id(session_id, project_root)
70
+ if not state:
71
+ log_warn("plan_accepted", f"No context for session {session_id}")
72
+ return
73
+
74
+ log_debug("plan_accepted", f"Found context: {state.id}, mode: {state.mode}")
75
+
76
+ # Find the latest archived plan
77
+ plan_path = find_latest_plan(state.id, project_root)
78
+ if not plan_path:
79
+ log_warn("plan_accepted", f"No archived plan found for {state.id}")
80
+ return
81
+
82
+ # Find the original plan file (in ~/.claude/plans/) to inject plan-id there too
83
+ original_plan_path = extract_plan_path_from_result(hook_input.get("tool_result", ""))
84
+ if not original_plan_path:
85
+ claude_plans_dir = Path.home() / ".claude" / "plans"
86
+ if claude_plans_dir.exists():
87
+ plans = sorted(claude_plans_dir.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
88
+ if plans:
89
+ original_plan_path = str(plans[0])
90
+
91
+ # Generate plan ID and inject into plan files
92
+ plan_id = generate_plan_id()
93
+ id_marker = f"<!-- plan-id: {plan_id} -->\n"
94
+
95
+ for file_path in [plan_path, original_plan_path]:
96
+ if file_path and Path(file_path).exists():
97
+ file_content = Path(file_path).read_text(encoding="utf-8")
98
+ if "<!-- plan-id:" not in file_content:
99
+ Path(file_path).write_text(id_marker + file_content, encoding="utf-8")
100
+
101
+ # Read the modified content (with plan ID) for hashing
102
+ content = Path(plan_path).read_text(encoding="utf-8")
103
+
104
+ # Compute normalized hash (Tier 2)
105
+ normalized = normalize_plan_content(content)
106
+ plan_hash = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]
107
+ plan_signature = content[:200] # Keep for backward compat
108
+
109
+ # Extract structural anchors (Tier 3)
110
+ plan_anchors = extract_plan_anchors(content)
111
+
112
+ # Assign plan to context (no mode change — keep current mode)
113
+ update_mode(
114
+ state.id, state.mode,
115
+ project_root=project_root,
116
+ plan_path=plan_path,
117
+ plan_hash=plan_hash,
118
+ plan_signature=plan_signature,
119
+ plan_id=plan_id,
120
+ plan_anchors=plan_anchors,
121
+ )
122
+ log_info("plan_accepted", f"Assigned plan to {state.id} (id: {plan_id}, hash: {plan_hash}, anchors: {len(plan_anchors)})")
123
+
124
+
125
+ if __name__ == "__main__":
126
+ from lib.base.hook_utils import run_hook
127
+ run_hook(main, "plan_accepted")
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env python3
2
+ """UserPromptSubmit hook - injects Phase A clarification prompt in plan mode.
3
+
4
+ On the first prompt in plan mode (before any code exploration), injects
5
+ a system-reminder telling Claude to ask clarification questions via
6
+ AskUserQuestion before exploring the codebase.
7
+
8
+ Skips if questions were already asked this session.
9
+ """
10
+ import json
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ _hook_dir = Path(__file__).resolve().parent
15
+ _cc_native_lib_dir = _hook_dir.parent / "lib"
16
+ _shared_lib_dir = _hook_dir.parent.parent / "_shared" / "lib"
17
+ sys.path.insert(0, str(_cc_native_lib_dir))
18
+ sys.path.insert(0, str(_shared_lib_dir))
19
+
20
+ from utils import was_questions_asked
21
+ from base.hook_utils import load_hook_input
22
+ from base.logger import log_debug, log_info, log_warn, log_error
23
+
24
+
25
+ PHASE_A_PROMPT = """
26
+ ## Plan Mode: Clarify Before Exploring
27
+
28
+ Use AskUserQuestion now — one call, 3-4 questions — before reading any code.
29
+
30
+ ### Why This Matters
31
+ Once you explore the codebase, you anchor on what you find. Questions asked after exploration confirm your assumptions instead of challenging them. Ask now, while your interpretation is still flexible.
32
+
33
+ ### What to Ask About
34
+ Only ask about things you cannot discover from code — the user's intent, constraints, history, and priorities:
35
+
36
+ - **Ambiguity:** If you can read this request two different ways, ask which interpretation is correct. Provide your top 2-3 readings as options.
37
+ - **Invisible context:** What does the user assume "everyone knows" about this system that isn't documented? What's obvious to them but hidden to you?
38
+ - **Success criteria:** What does "done well" look like beyond the literal request? What would make them rate this a 10?
39
+ - **Constraints and history:** Has this been attempted before? Are there parts of the system that are off-limits or sensitive?
40
+
41
+ ### How to Select Questions
42
+ 1. Generate 5+ candidate questions across the lenses above
43
+ 2. For each, evaluate: "If they answered A vs B, would I explore different files or take a different approach?" If no — discard it.
44
+ 3. Keep the 3-4 where different answers lead to meaningfully different exploration strategies
45
+ 4. Frame each with 2-3 concrete options so the user can react rather than generate from scratch
46
+ """.strip()
47
+
48
+
49
+ def main() -> int:
50
+ try:
51
+ payload = load_hook_input()
52
+ if not payload:
53
+ return 0
54
+
55
+ permission_mode = payload.get("permission_mode", "")
56
+ if permission_mode != "plan":
57
+ return 0
58
+
59
+ session_id = str(payload.get("session_id", ""))
60
+ if not session_id:
61
+ log_debug("plan_questions_early", "No session_id, skipping")
62
+ return 0
63
+
64
+ if was_questions_asked(session_id):
65
+ log_debug("plan_questions_early", "Questions already asked, skipping")
66
+ return 0
67
+
68
+ log_info("plan_questions_early", "Plan mode detected, injecting Phase A prompt")
69
+ print(f"<system-reminder>{PHASE_A_PROMPT}</system-reminder>")
70
+
71
+ except Exception as e:
72
+ from base.hook_utils import log_hook_error
73
+ log_hook_error("plan_questions_early", e, "UserPromptSubmit")
74
+ log_error("plan_questions_early", str(e))
75
+
76
+ return 0
77
+
78
+
79
+ if __name__ == "__main__":
80
+ from base.hook_utils import run_hook
81
+ run_hook(main, "plan_questions_early")
@@ -29,12 +29,16 @@ import tempfile
29
29
  from pathlib import Path
30
30
  from typing import Any, Dict
31
31
 
32
- # Add lib directory to path for imports
32
+ # Add lib directories to path for imports
33
33
  _hook_dir = Path(__file__).resolve().parent
34
34
  _lib_dir = _hook_dir.parent / "lib"
35
+ _shared_lib = _hook_dir.parent.parent / "_shared" / "lib"
35
36
  sys.path.insert(0, str(_lib_dir))
37
+ sys.path.insert(0, str(_shared_lib))
36
38
 
37
- from utils import eprint, sanitize_filename
39
+ from base.hook_utils import emit_context
40
+ from base.logger import log_debug, log_info, log_warn, log_error
41
+ from utils import sanitize_filename
38
42
 
39
43
 
40
44
  # ---------------------------
@@ -78,7 +82,7 @@ def load_config(project_dir: Path) -> Dict[str, Any]:
78
82
  section = full_config.get("stuckDetection", {})
79
83
  return {**DEFAULT_CONFIG, **section}
80
84
  except Exception as e:
81
- eprint(f"[suggest-fresh-perspective] Failed to load config: {e}")
85
+ log_warn("suggest-fresh-perspective", f"Failed to load config: {e}")
82
86
  return DEFAULT_CONFIG.copy()
83
87
 
84
88
 
@@ -144,7 +148,7 @@ def save_state(session_id: str, state: Dict[str, Any]) -> None:
144
148
  try:
145
149
  state_path.write_text(json.dumps(state), encoding="utf-8")
146
150
  except Exception as e:
147
- eprint(f"[suggest-fresh-perspective] Warning: failed to save state: {e}")
151
+ log_warn("suggest-fresh-perspective", f"Failed to save state: {e}")
148
152
 
149
153
 
150
154
  # ---------------------------
@@ -226,19 +230,15 @@ def should_suggest(state: Dict[str, Any], cooldown: int) -> bool:
226
230
  return state.get("tool_calls_since_suggestion", 0) >= cooldown
227
231
 
228
232
 
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
- }
233
+ def create_suggestion() -> None:
234
+ """Emit the suggestion via hook utility."""
235
+ emit_context(
236
+ "\n---\n"
237
+ "**Stuck?** You've been working on similar issues for a while. "
238
+ "Consider running `/fresh-perspective` to get an unbiased view of the problem "
239
+ "without code context anchoring your thinking.\n"
240
+ "---\n"
241
+ )
242
242
 
243
243
 
244
244
  def main() -> int:
@@ -249,8 +249,8 @@ def main() -> int:
249
249
  except json.JSONDecodeError:
250
250
  return 0 # Fail-safe
251
251
 
252
- # 1. Check hook_type (cheap dict lookup)
253
- if payload.get("hook_type") != "PostToolUse":
252
+ # 1. Check hook_event_name (cheap dict lookup)
253
+ if payload.get("hook_event_name") != "PostToolUse":
254
254
  return 0
255
255
 
256
256
  # 2. Check session_id exists (cheap dict lookup)
@@ -314,11 +314,11 @@ def main() -> int:
314
314
 
315
315
  if is_stuck:
316
316
  if error_detected:
317
- eprint("[suggest-fresh-perspective] Detected repeated error pattern")
317
+ log_info("suggest-fresh-perspective", "Detected repeated error pattern")
318
318
  if file_edit_detected:
319
- eprint("[suggest-fresh-perspective] Detected repeated file edits")
319
+ log_info("suggest-fresh-perspective", "Detected repeated file edits")
320
320
  if test_failure_detected:
321
- eprint("[suggest-fresh-perspective] Detected repeated test failures")
321
+ log_info("suggest-fresh-perspective", "Detected repeated test failures")
322
322
 
323
323
  # Only suggest if stuck AND past cooldown
324
324
  if is_stuck and should_suggest(state, cooldown):
@@ -329,11 +329,12 @@ def main() -> int:
329
329
 
330
330
  # Only suggest up to maxSuggestions times per session
331
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))
332
+ log_info("suggest-fresh-perspective", f"Suggesting fresh perspective (suggestion #{state['suggestion_count']})")
333
+ create_suggestion()
334
334
 
335
335
  return 0
336
336
 
337
337
 
338
338
  if __name__ == "__main__":
339
- raise SystemExit(main())
339
+ from base.hook_utils import run_hook
340
+ run_hook(main, "suggest_fresh_perspective")
@@ -12,7 +12,6 @@
12
12
  | `state.py` | Plan state file management and iteration tracking |
13
13
  | `orchestrator.py` | Plan complexity analysis and agent selection |
14
14
  | `reviewers/` | Plan review implementations (package — see below) |
15
- | `atomic_write.py` | Atomic file writes for crash safety |
16
15
  | `constants.py` | Shared constants and feature flags (e.g., `ENABLE_ROBUST_PLAN_WRITES`) |
17
16
  | `debug.py` | Permanent debug logging to context folder (`CCNATIVE_DEBUG_DISABLE=1` to disable) |
18
17
  | `__init__.py` | Package exports |
@@ -35,8 +34,8 @@
35
34
  Hooks (cc-native-plan-review.py, etc.)
36
35
 
37
36
  ├── lib/utils.py (core utilities)
38
- │ └── lib/atomic_write.py
39
37
  │ └── lib/constants.py
38
+ │ └── _shared/lib/base/atomic_write.py
40
39
 
41
40
  ├── lib/state.py (state management)
42
41
  │ └── lib/utils.py (eprint)
@@ -135,7 +134,8 @@ This is a recurring issue. Any path string comparison must handle both separator
135
134
  For critical files (state, reviews), use atomic writes to prevent corruption on crash:
136
135
 
137
136
  ```python
138
- from atomic_write import atomic_write
137
+ # Import from shared lib (canonical location)
138
+ from _shared.lib.base.atomic_write import atomic_write
139
139
 
140
140
  # CORRECT - atomic write
141
141
  success, error = atomic_write(path, content)
@@ -245,7 +245,7 @@ content = path.read_text() # May use cp1252 on Windows
245
245
  These are reminders based on past issues. Not enforcement rules.
246
246
 
247
247
  - **Don't import from `_cc-native/lib/` in `_shared/lib/`** - wrong direction, creates circular deps
248
- - **Don't use `print()` for debugging** - use `eprint()` to avoid corrupting stdout
248
+ - **Don't use `print()` for debugging** - use `log_debug/log_info/log_warn/log_error` from `_shared/lib/base/logger.py` (writes to stderr + `_output/hook-log.jsonl`)
249
249
  - **Don't modify data class fields** without updating all consumers (hooks, formatters, tests)
250
250
  - **Don't hardcode paths** - use `Path(__file__)`, env vars, or config
251
251
  - **Don't forget `encoding="utf-8"`** on file operations - Windows defaults are unsafe
@@ -260,4 +260,6 @@ These are reminders based on past issues. Not enforcement rules.
260
260
 
261
261
  | Date | Change |
262
262
  |------|--------|
263
+ | 2026-02-07 | Unified logger: all diagnostic logging uses `_shared/lib/base/logger.py` instead of eprint/print-to-stderr |
264
+ | 2026-02-06 | Remove duplicate `atomic_write.py` — consolidated to `_shared/lib/base/atomic_write.py` |
263
265
  | 2026-02-03 | Initial creation |
@@ -1,20 +1,35 @@
1
1
  """
2
2
  Permanent debug logging for cc-native hooks.
3
3
 
4
- Logs are written to context folder: _output/contexts/<context-id>/debug/<session-name>.log
4
+ Thin delegation layer over the unified logger (_shared/lib/base/logger.py).
5
+ Logs are written to context folder: _output/contexts/<context-id>/debug/hook-log.jsonl
5
6
  Append-only, cleaned up when context is archived.
6
7
  Can be disabled via CCNATIVE_DEBUG_DISABLE=1 environment variable.
7
8
  """
8
9
 
9
- import json
10
10
  import os
11
- from datetime import datetime
12
11
  from pathlib import Path
13
12
  from typing import Any, Optional
14
13
 
15
14
  # Feature flag - set CCNATIVE_DEBUG_DISABLE=1 to turn off
16
15
  DEBUG_ENABLED = os.environ.get("CCNATIVE_DEBUG_DISABLE", "").lower() not in ("1", "true", "yes")
17
16
 
17
+ # Import unified logger
18
+ try:
19
+ from _shared.lib.base.logger import hook_log
20
+ except ImportError:
21
+ # Fallback: try relative import path used by hooks
22
+ try:
23
+ import sys
24
+ _shared = Path(__file__).parent.parent.parent.parent / "_shared"
25
+ if str(_shared) not in sys.path:
26
+ sys.path.insert(0, str(_shared))
27
+ from lib.base.logger import hook_log
28
+ except ImportError:
29
+ # Last resort: no-op
30
+ def hook_log(*args, **kwargs):
31
+ pass
32
+
18
33
 
19
34
  def get_debug_dir(context_path: Path) -> Path:
20
35
  """Get or create debug directory within context folder.
@@ -51,7 +66,7 @@ def debug_log(
51
66
  message: str,
52
67
  data: Optional[Any] = None
53
68
  ) -> None:
54
- """Write a debug log entry (append-only).
69
+ """Write a debug log entry. Delegates to unified logger.
55
70
 
56
71
  Args:
57
72
  context_path: Path to context folder
@@ -63,22 +78,15 @@ def debug_log(
63
78
  if not DEBUG_ENABLED:
64
79
  return
65
80
 
66
- try:
67
- log_path = get_log_path(context_path, session_name)
68
- timestamp = datetime.now().isoformat()
69
-
70
- entry = f"[{timestamp}] [{component}] {message}"
71
- if data is not None:
72
- try:
73
- data_str = json.dumps(data, indent=2, ensure_ascii=True, default=str)
74
- entry += f"\n{data_str}"
75
- except Exception:
76
- entry += f"\n<data serialization failed: {type(data)}>"
77
-
78
- with open(log_path, "a", encoding="utf-8") as f:
79
- f.write(entry + "\n\n")
80
- except Exception:
81
- pass # Never fail on debug logging
81
+ hook_log(
82
+ "debug",
83
+ session_name,
84
+ message,
85
+ component=component,
86
+ data=data,
87
+ context_path=context_path,
88
+ stderr=False,
89
+ )
82
90
 
83
91
 
84
92
  def debug_raw(
@@ -89,7 +97,7 @@ def debug_raw(
89
97
  raw: str,
90
98
  max_len: int = 10000
91
99
  ) -> None:
92
- """Log raw output (stdout, stderr, etc).
100
+ """Log raw output (stdout, stderr, etc). Delegates to unified logger.
93
101
 
94
102
  Args:
95
103
  context_path: Path to context folder
@@ -104,7 +112,14 @@ def debug_raw(
104
112
 
105
113
  truncated = raw[:max_len] if len(raw) > max_len else raw
106
114
  suffix = f" [TRUNCATED from {len(raw)} chars]" if len(raw) > max_len else ""
107
- debug_log(context_path, session_name, component, f"{label}{suffix}:", truncated)
115
+ hook_log(
116
+ "debug",
117
+ session_name,
118
+ f"{label}{suffix}: {truncated}",
119
+ component=component,
120
+ context_path=context_path,
121
+ stderr=False,
122
+ )
108
123
 
109
124
 
110
125
  def cleanup_debug_folder(context_path: Path) -> None:
@@ -15,9 +15,14 @@ from typing import Any, Dict, List, Optional
15
15
  _lib_dir = Path(__file__).resolve().parent
16
16
  sys.path.insert(0, str(_lib_dir))
17
17
 
18
- from utils import OrchestratorResult, eprint, parse_json_maybe
18
+ from utils import OrchestratorResult, parse_json_maybe
19
19
  from reviewers.base import AgentConfig, OrchestratorConfig
20
20
 
21
+ # Import logger
22
+ _shared_logger = Path(__file__).resolve().parent.parent.parent / "_shared" / "lib"
23
+ sys.path.insert(0, str(_shared_logger))
24
+ from base.logger import log_debug, log_info, log_warn, log_error
25
+
21
26
  # Import shared subprocess utilities
22
27
  _shared_lib = Path(__file__).resolve().parent.parent.parent / "_shared" / "lib" / "base"
23
28
  sys.path.insert(0, str(_shared_lib))
@@ -119,18 +124,18 @@ def _parse_claude_output(raw: str) -> Optional[Dict[str, Any]]:
119
124
  result = json.loads(raw)
120
125
  if isinstance(result, dict):
121
126
  if "structured_output" in result:
122
- eprint("[orchestrator:parse] Found structured_output in root dict")
127
+ log_debug("orchestrator", "Found structured_output in root dict", component="parse")
123
128
  return result["structured_output"]
124
129
  if result.get("type") == "assistant":
125
130
  message = result.get("message", {})
126
131
  content = message.get("content", [])
127
132
  for item in content:
128
133
  if isinstance(item, dict) and item.get("name") == "StructuredOutput":
129
- eprint("[orchestrator:parse] Found StructuredOutput in assistant message content")
134
+ log_debug("orchestrator", "Found StructuredOutput in assistant message content", component="parse")
130
135
  return item.get("input", {})
131
- eprint("[orchestrator:parse] Assistant message found but no StructuredOutput tool use in content")
136
+ log_debug("orchestrator", "Assistant message found but no StructuredOutput tool use in content", component="parse")
132
137
  elif isinstance(result, list):
133
- eprint(f"[orchestrator:parse] Received list of {len(result)} events, searching for assistant message")
138
+ log_debug("orchestrator", f"Received list of {len(result)} events, searching for assistant message", component="parse")
134
139
  for i, event in enumerate(result):
135
140
  if not isinstance(event, dict):
136
141
  continue
@@ -139,16 +144,16 @@ def _parse_claude_output(raw: str) -> Optional[Dict[str, Any]]:
139
144
  content = message.get("content", [])
140
145
  for item in content:
141
146
  if isinstance(item, dict) and item.get("name") == "StructuredOutput":
142
- eprint(f"[orchestrator:parse] Found StructuredOutput in event[{i}] assistant message")
147
+ log_debug("orchestrator", f"Found StructuredOutput in event[{i}] assistant message", component="parse")
143
148
  return item.get("input", {})
144
- eprint("[orchestrator:parse] No StructuredOutput found in any assistant message in event list")
149
+ log_debug("orchestrator", "No StructuredOutput found in any assistant message in event list", component="parse")
145
150
  except json.JSONDecodeError as e:
146
- eprint(f"[orchestrator:parse] JSON decode error: {e}")
151
+ log_warn("orchestrator", f"JSON decode error: {e}", component="parse")
147
152
  except Exception as e:
148
- eprint(f"[orchestrator:parse] Unexpected error during structured parsing: {e}")
153
+ log_error("orchestrator", f"Unexpected error during structured parsing: {e}", component="parse")
149
154
 
150
155
  # Fallback to heuristic extraction
151
- eprint("[orchestrator:parse] No structured output found, falling back to heuristic JSON extraction")
156
+ log_debug("orchestrator", "No structured output found, falling back to heuristic JSON extraction", component="parse")
152
157
  return parse_json_maybe(raw)
153
158
 
154
159
 
@@ -175,7 +180,7 @@ def run_orchestrator(
175
180
  Returns:
176
181
  OrchestratorResult with complexity, category, and selected agents
177
182
  """
178
- eprint("[orchestrator] Starting plan analysis...")
183
+ log_info("orchestrator", "Starting plan analysis...")
179
184
 
180
185
  if mandatory_names is None:
181
186
  mandatory_names = set()
@@ -188,12 +193,12 @@ def run_orchestrator(
188
193
  non_mandatory = [a for a in agent_library if a.enabled and a.name not in mandatory_names]
189
194
  valid_names = [a.name for a in non_mandatory]
190
195
 
191
- eprint(f"[orchestrator] Mandatory agents (always run): {sorted(mandatory_names)}")
192
- eprint(f"[orchestrator] Non-mandatory agents for selection: {valid_names}")
196
+ log_debug("orchestrator", f"Mandatory agents (always run): {sorted(mandatory_names)}")
197
+ log_debug("orchestrator", f"Non-mandatory agents for selection: {valid_names}")
193
198
 
194
199
  claude_path = shutil.which("claude")
195
200
  if claude_path is None:
196
- eprint("[orchestrator] Claude CLI not found on PATH, falling back to medium complexity")
201
+ log_warn("orchestrator", "Claude CLI not found on PATH, falling back to medium complexity")
197
202
  return OrchestratorResult(
198
203
  complexity="medium",
199
204
  category="code",
@@ -202,7 +207,7 @@ def run_orchestrator(
202
207
  error="claude CLI not found on PATH",
203
208
  )
204
209
 
205
- eprint(f"[orchestrator] Found Claude CLI at: {claude_path}")
210
+ log_debug("orchestrator", f"Found Claude CLI at: {claude_path}")
206
211
 
207
212
  # Build agent list from non-mandatory agents only
208
213
  agent_list = "\n".join([
@@ -266,7 +271,7 @@ Call StructuredOutput now with: complexity, category, selectedAgents, reasoning"
266
271
  "--system-prompt", system_prompt,
267
272
  ]
268
273
 
269
- eprint(f"[orchestrator] Running with model: {config.model}, timeout: {config.timeout}s")
274
+ log_info("orchestrator", f"Running with model: {config.model}, timeout: {config.timeout}s")
270
275
 
271
276
  # Get environment for internal subprocess (bypasses hooks)
272
277
  env = get_internal_subprocess_env()
@@ -283,7 +288,7 @@ Call StructuredOutput now with: complexity, category, selectedAgents, reasoning"
283
288
  env=env,
284
289
  )
285
290
  except subprocess.TimeoutExpired:
286
- eprint(f"[orchestrator] TIMEOUT after {config.timeout}s, falling back to medium complexity")
291
+ log_warn("orchestrator", f"TIMEOUT after {config.timeout}s, falling back to medium complexity")
287
292
  return OrchestratorResult(
288
293
  complexity="medium",
289
294
  category="code",
@@ -292,7 +297,7 @@ Call StructuredOutput now with: complexity, category, selectedAgents, reasoning"
292
297
  error=f"Orchestrator timed out after {config.timeout}s",
293
298
  )
294
299
  except Exception as ex:
295
- eprint(f"[orchestrator] EXCEPTION: {ex}, falling back to medium complexity")
300
+ log_error("orchestrator", f"Exception: {ex}, falling back to medium complexity")
296
301
  return OrchestratorResult(
297
302
  complexity="medium",
298
303
  category="code",
@@ -301,26 +306,26 @@ Call StructuredOutput now with: complexity, category, selectedAgents, reasoning"
301
306
  error=str(ex),
302
307
  )
303
308
 
304
- eprint(f"[orchestrator] Exit code: {p.returncode}")
309
+ log_debug("orchestrator", f"Exit code: {p.returncode}")
305
310
 
306
311
  raw = (p.stdout or "").strip()
307
312
  if p.stderr:
308
- eprint(f"[orchestrator] stderr: {p.stderr[:300]}")
313
+ log_debug("orchestrator", f"stderr: {p.stderr[:300]}")
309
314
 
310
315
  obj = _parse_claude_output(raw)
311
316
 
312
317
  # Debug logging to diagnose empty selectedAgents issue
313
- eprint(f"[orchestrator:debug] Raw output length: {len(raw)} chars")
318
+ log_debug("orchestrator", f"Raw output length: {len(raw)} chars")
314
319
  if raw:
315
- eprint(f"[orchestrator:debug] Raw output (first 500 chars): {raw[:500]}")
316
- eprint(f"[orchestrator:debug] Parsed obj: {obj}")
320
+ log_debug("orchestrator", f"Raw output (first 500 chars): {raw[:500]}")
321
+ log_debug("orchestrator", f"Parsed obj: {obj}")
317
322
  if obj:
318
- eprint(f"[orchestrator:debug] obj keys: {list(obj.keys())}")
319
- eprint(f"[orchestrator:debug] selectedAgents value: {obj.get('selectedAgents', 'MISSING')}")
320
- eprint(f"[orchestrator:debug] reasoning value: {obj.get('reasoning', 'MISSING')}")
323
+ log_debug("orchestrator", f"obj keys: {list(obj.keys())}")
324
+ log_debug("orchestrator", f"selectedAgents value: {obj.get('selectedAgents', 'MISSING')}")
325
+ log_debug("orchestrator", f"reasoning value: {obj.get('reasoning', 'MISSING')}")
321
326
 
322
327
  if not obj:
323
- eprint("[orchestrator] Failed to parse output, falling back to medium complexity")
328
+ log_warn("orchestrator", "Failed to parse output, falling back to medium complexity")
324
329
  return OrchestratorResult(
325
330
  complexity="medium",
326
331
  category="code",
@@ -345,8 +350,8 @@ Call StructuredOutput now with: complexity, category, selectedAgents, reasoning"
345
350
  reasoning = str(obj.get("reasoning", "")).strip() or "No reasoning provided"
346
351
  skip_reason = obj.get("skipReason")
347
352
 
348
- eprint(f"[orchestrator] Result: complexity={complexity}, category={category}, agents={selected_agents}")
349
- eprint(f"[orchestrator] Reasoning: {reasoning}")
353
+ log_info("orchestrator", f"Result: complexity={complexity}, category={category}, agents={selected_agents}")
354
+ log_debug("orchestrator", f"Reasoning: {reasoning}")
350
355
 
351
356
  return OrchestratorResult(
352
357
  complexity=complexity,