aiwcli 0.9.8 → 0.10.1

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 (115) 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 +114 -60
  21. package/dist/templates/_shared/hooks/session_start.py +127 -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 +47 -81
  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 +207 -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 +317 -0
  45. package/dist/templates/_shared/lib/context/context_selector.py +508 -0
  46. package/dist/templates/_shared/lib/context/context_store.py +653 -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 +22 -37
  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 +31 -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 +37 -14
  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 +54 -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 +76 -89
  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_questions_early.py +81 -0
  77. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +26 -25
  78. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +6 -4
  79. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  80. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  81. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  82. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  83. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  84. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  85. package/dist/templates/cc-native/_cc-native/lib/debug.py +37 -22
  86. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +34 -29
  87. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  88. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  89. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  90. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  91. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  92. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +26 -21
  93. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +12 -7
  94. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +12 -7
  95. package/dist/templates/cc-native/_cc-native/lib/state.py +31 -16
  96. package/dist/templates/cc-native/_cc-native/lib/utils.py +207 -40
  97. package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -2
  98. package/oclif.manifest.json +1 -1
  99. package/package.json +1 -1
  100. package/dist/templates/_shared/hooks/context_enforcer.py +0 -625
  101. package/dist/templates/_shared/hooks/task_create_atomicity.py +0 -177
  102. package/dist/templates/_shared/lib/context/auto_state.py +0 -167
  103. package/dist/templates/_shared/lib/context/cache.py +0 -444
  104. package/dist/templates/_shared/lib/context/context_extractor.py +0 -115
  105. package/dist/templates/_shared/lib/context/context_manager.py +0 -1057
  106. package/dist/templates/_shared/lib/context/discovery.py +0 -554
  107. package/dist/templates/_shared/lib/context/event_log.py +0 -316
  108. package/dist/templates/_shared/lib/context/plan_archive.py +0 -101
  109. package/dist/templates/_shared/lib/context/task_sync.py +0 -407
  110. package/dist/templates/_shared/lib/templates/persona_questions.py +0 -113
  111. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  112. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
  113. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
  114. package/dist/templates/cc-native/_cc-native/lib/async_archive.py +0 -68
  115. package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +0 -98
@@ -26,11 +26,9 @@ SCRIPT_DIR = Path(__file__).resolve().parent
26
26
  SHARED_LIB = SCRIPT_DIR.parent / "lib"
27
27
  sys.path.insert(0, str(SHARED_LIB.parent))
28
28
 
29
- from lib.base.hook_utils import load_hook_input
30
- from lib.base.utils import eprint, project_dir
31
- from lib.context.context_manager import get_context_by_session_id
32
- from lib.context.event_log import EVENT_AUTO_STATE_SAVED, append_event
33
- from lib.context.auto_state import save_auto_state
29
+ from lib.base.hook_utils import load_hook_input, log_debug, log_info, log_error
30
+ from lib.base.utils import project_dir
31
+ from lib.context.context_store import get_context_by_session_id, save_state
34
32
 
35
33
 
36
34
  def main():
@@ -45,45 +43,62 @@ def main():
45
43
  project_root = project_dir(hook_input)
46
44
 
47
45
  if not session_id:
48
- eprint("[pre_compact] No session_id, skipping")
46
+ log_debug("pre_compact", "No session_id, skipping")
49
47
  return
50
48
 
51
- eprint(f"[pre_compact] Saving state before compaction: {session_id[:8]}...")
49
+ log_info("pre_compact", f"Saving state before compaction: {session_id[:8]}...")
52
50
 
53
51
  # Find context bound to this session
54
52
  context = get_context_by_session_id(session_id, project_root)
55
53
  if not context:
56
- eprint("[pre_compact] No context bound to this session, skipping")
54
+ log_debug("pre_compact", "No context bound to this session, skipping")
57
55
  return
58
56
 
59
- context_id = context.id
60
- in_flight_mode = context.in_flight.mode if context.in_flight else "none"
61
- plan_path = context.in_flight.artifact_path if context.in_flight else None
62
- handoff_path = context.in_flight.handoff_path if context.in_flight else None
63
-
64
- saved = save_auto_state(
65
- context_id=context_id,
66
- session_id=session_id,
67
- save_reason="pre_compact",
68
- project_root=project_root,
69
- in_flight_mode=in_flight_mode,
70
- plan_path=plan_path,
71
- handoff_path=handoff_path,
72
- transcript_path=transcript_path,
73
- )
74
-
75
- if saved:
76
- append_event(
77
- context_id, EVENT_AUTO_STATE_SAVED, project_root,
78
- session_id=session_id, save_reason="pre_compact",
57
+ # Save last_session snapshot directly to state.json
58
+ import subprocess
59
+ git_state = {}
60
+ try:
61
+ branch = subprocess.run(
62
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
63
+ capture_output=True, text=True, timeout=5
79
64
  )
80
- eprint(f"[pre_compact] Auto-state saved for {context_id}")
65
+ git_state["branch"] = branch.stdout.strip() if branch.returncode == 0 else "unknown"
66
+
67
+ status = subprocess.run(
68
+ ["git", "status", "--short"],
69
+ capture_output=True, text=True, timeout=5
70
+ )
71
+ if status.returncode == 0 and status.stdout.strip():
72
+ git_state["uncommitted_files"] = [
73
+ line.split(None, 1)[-1] for line in status.stdout.strip().split("\n")[:10]
74
+ ]
75
+
76
+ log = subprocess.run(
77
+ ["git", "log", "-1", "--format=%h %s"],
78
+ capture_output=True, text=True, timeout=5
79
+ )
80
+ if log.returncode == 0:
81
+ git_state["last_commit_short"] = log.stdout.strip()
82
+ except Exception:
83
+ pass
84
+
85
+ from lib.base.utils import now_iso
86
+ context.last_session = {
87
+ "session_id": session_id,
88
+ "saved_at": now_iso(),
89
+ "save_reason": "pre_compact",
90
+ "git_state": git_state,
91
+ }
92
+ save_state(context, project_root)
93
+ log_info("pre_compact", f"State saved for {context.id}")
81
94
 
82
95
  except Exception as e:
83
- eprint(f"[pre_compact] ERROR: {e}")
84
96
  import traceback
85
- eprint(traceback.format_exc())
97
+ tb = traceback.format_exc()
98
+ from lib.base.hook_utils import log_hook_error
99
+ log_hook_error("pre_compact", e, "PreCompact", traceback_str=tb)
86
100
 
87
101
 
88
102
  if __name__ == "__main__":
89
- main()
103
+ from lib.base.hook_utils import run_hook
104
+ run_hook(main, "pre_compact")
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env python3
2
- """SessionEnd hook - records session boundary and saves auto-state.
2
+ """SessionEnd hook - saves session state to state.json.
3
3
 
4
- Fires when session terminates (quit, /clear, logout). Creates a session
5
- boundary marker in events.jsonl and writes auto-state.json for restoration.
4
+ Fires when session terminates (quit, /clear, logout). Saves last_session
5
+ data directly to state.json for restoration on next session.
6
6
 
7
7
  Hook input (from Claude Code):
8
8
  {
@@ -18,6 +18,8 @@ Hook output:
18
18
  - Silent (no stdout output needed for SessionEnd)
19
19
  - Logs to stderr for debugging
20
20
  """
21
+ import hashlib
22
+ import subprocess
21
23
  import sys
22
24
  from pathlib import Path
23
25
 
@@ -26,16 +28,45 @@ SCRIPT_DIR = Path(__file__).resolve().parent
26
28
  SHARED_LIB = SCRIPT_DIR.parent / "lib"
27
29
  sys.path.insert(0, str(SHARED_LIB.parent))
28
30
 
29
- from lib.base.hook_utils import load_hook_input
30
- from lib.base.utils import eprint, project_dir
31
- from lib.context.context_manager import get_context_by_session_id
32
- from lib.context.event_log import get_current_state, EVENT_AUTO_STATE_SAVED, append_event
33
- from lib.context.task_sync import record_session_ended
34
- from lib.context.auto_state import save_auto_state
31
+ from lib.base.hook_utils import load_hook_input, log_debug, log_info, log_warn, log_error, log_diagnostic
32
+ from lib.base.utils import now_iso, project_dir
33
+ from lib.context.context_store import get_context_by_session_id, save_state
34
+ from lib.context.plan_manager import find_latest_plan, normalize_plan_content, generate_plan_id, extract_plan_anchors
35
+
36
+
37
+ def _get_git_state(project_root: Path) -> dict:
38
+ """Capture current git state for restoration."""
39
+ git_state = {}
40
+ try:
41
+ result = subprocess.run(
42
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
43
+ capture_output=True, text=True, cwd=str(project_root), timeout=5,
44
+ )
45
+ if result.returncode == 0:
46
+ git_state["branch"] = result.stdout.strip()
47
+
48
+ result = subprocess.run(
49
+ ["git", "diff", "--name-only"],
50
+ capture_output=True, text=True, cwd=str(project_root), timeout=5,
51
+ )
52
+ if result.returncode == 0:
53
+ files = [f for f in result.stdout.strip().split("\n") if f]
54
+ git_state["uncommitted_files"] = files
55
+
56
+ result = subprocess.run(
57
+ ["git", "log", "-1", "--oneline"],
58
+ capture_output=True, text=True, cwd=str(project_root), timeout=5,
59
+ )
60
+ if result.returncode == 0:
61
+ git_state["last_commit_short"] = result.stdout.strip()
62
+ except Exception as e:
63
+ log_warn("session_end", f"Git state capture error (non-fatal): {e}")
64
+
65
+ return git_state
35
66
 
36
67
 
37
68
  def main():
38
- """Record session boundary and save auto-state."""
69
+ """Save session state to state.json."""
39
70
  try:
40
71
  hook_input = load_hook_input()
41
72
  if not hook_input:
@@ -47,65 +78,88 @@ def main():
47
78
  project_root = project_dir(hook_input)
48
79
 
49
80
  if not session_id:
50
- eprint("[session_end] No session_id, skipping")
81
+ log_debug("session_end", "No session_id, skipping")
51
82
  return
52
83
 
53
- eprint(f"[session_end] Session ending: {session_id[:8]}... reason={source}")
84
+ log_info("session_end", f"Session ending: {session_id[:8]}... reason={source}")
85
+ log_diagnostic("session_end", "receive", f"session={session_id[:8]}, source={source}",
86
+ inputs={"session_id": session_id[:12], "source": source})
54
87
 
55
88
  # Find context bound to this session
56
- context = get_context_by_session_id(session_id, project_root)
57
- if not context:
58
- eprint("[session_end] No context bound to this session, skipping")
89
+ state = get_context_by_session_id(session_id, project_root)
90
+ if not state:
91
+ log_debug("session_end", "No context bound to this session, skipping")
59
92
  return
60
93
 
61
- context_id = context.id
62
- eprint(f"[session_end] Found context: {context_id}")
63
-
64
- # Get current task state for the session boundary marker
65
- state = get_current_state(context_id, project_root)
66
- active_tasks = [t.id for t in state.tasks if t.status == "in_progress"]
67
- pending_tasks = [t.id for t in state.tasks if t.status == "pending"]
68
-
69
- # Record session_ended event in events.jsonl
70
- record_session_ended(
71
- context_id=context_id,
72
- session_id=session_id,
73
- reason=source,
74
- active_tasks=active_tasks,
75
- pending_tasks=pending_tasks,
76
- project_root=project_root,
77
- )
78
- eprint(f"[session_end] Recorded session_ended: active={len(active_tasks)}, pending={len(pending_tasks)}")
79
-
80
- # Save auto-state.json
81
- in_flight_mode = context.in_flight.mode if context.in_flight else "none"
82
- plan_path = context.in_flight.artifact_path if context.in_flight else None
83
- handoff_path = context.in_flight.handoff_path if context.in_flight else None
84
-
85
- saved = save_auto_state(
86
- context_id=context_id,
87
- session_id=session_id,
88
- save_reason=source,
89
- project_root=project_root,
90
- in_flight_mode=in_flight_mode,
91
- plan_path=plan_path,
92
- handoff_path=handoff_path,
93
- transcript_path=transcript_path,
94
- )
95
-
96
- if saved:
97
- # Record auto_state_saved event
98
- append_event(
99
- context_id, EVENT_AUTO_STATE_SAVED, project_root,
100
- session_id=session_id, save_reason=source,
101
- )
102
- eprint(f"[session_end] Auto-state saved for {context_id}")
94
+ log_info("session_end", f"Found context: {state.id}")
95
+
96
+ # Capture git state
97
+ git_state = _get_git_state(project_root)
98
+
99
+ # Save last_session directly to state.json
100
+ state.last_session = {
101
+ "session_id": session_id,
102
+ "save_reason": source,
103
+ "saved_at": now_iso(),
104
+ "transcript_path": transcript_path,
105
+ "git_state": git_state,
106
+ }
107
+ state.last_active = now_iso()
108
+
109
+ # Fallback: assign plan fields if PostToolUse:ExitPlanMode didn't fire.
110
+ # When ExitPlanMode triggers /clear, the session terminates before PostToolUse
111
+ # hooks can run, so plan_accepted.py never fires. Detect this by checking
112
+ # for an archived plan that hasn't been assigned yet.
113
+ if not state.plan_hash:
114
+ latest_plan_path = find_latest_plan(state.id, project_root)
115
+ if latest_plan_path:
116
+ try:
117
+ content = Path(latest_plan_path).read_text(encoding="utf-8")
118
+ normalized = normalize_plan_content(content)
119
+ state.plan_hash = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:12]
120
+ state.plan_path = latest_plan_path
121
+ state.plan_signature = content[:200]
122
+ state.plan_id = generate_plan_id()
123
+ state.plan_anchors = extract_plan_anchors(content)
124
+ state.plan_consumed = False
125
+ log_info("session_end", f"Fallback: assigned archived plan for {state.id} (hash: {state.plan_hash})")
126
+ except Exception as e:
127
+ log_warn("session_end", f"Fallback plan assignment failed: {e}")
128
+
129
+ # If a plan is assigned, not yet consumed, and mode is active, stage it for next session
130
+ if state.plan_hash and state.mode == "active" and not state.plan_consumed:
131
+ state.mode = "has_plan"
132
+ log_info("session_end", f"Staged plan for next session: {state.id} -> has_plan")
133
+ elif state.plan_hash and state.mode == "active" and state.plan_consumed:
134
+ log_debug("session_end", f"Plan already consumed for {state.id}, not re-staging")
135
+ log_diagnostic("session_end", "decide", f"Skipping re-stage for {state.id}",
136
+ decision="skip_restage", reasoning="plan_hash exists but plan_consumed=True",
137
+ inputs={"plan_hash": state.plan_hash, "plan_consumed": True})
138
+
139
+ # Handoff staging (mirrors plan staging above)
140
+ # Note: if plan already set has_plan, mode != "active" so handoff check skips (plan takes priority)
141
+ if state.handoff_path and state.mode == "active" and not state.handoff_consumed:
142
+ state.mode = "has_handoff"
143
+ log_info("session_end", f"Staged handoff for next session: {state.id} -> has_handoff")
144
+ elif state.handoff_path and state.mode == "active" and state.handoff_consumed:
145
+ log_debug("session_end", f"Handoff already consumed for {state.id}, not re-staging")
146
+
147
+ if save_state(state, project_root):
148
+ log_info("session_end", f"Saved last_session for {state.id}")
149
+ log_diagnostic("session_end", "result", f"Saved state for {state.id}",
150
+ decision="saved", inputs={"context_id": state.id, "mode": state.mode,
151
+ "has_plan_hash": bool(state.plan_hash),
152
+ "git_files": len(git_state.get("uncommitted_files", []))})
153
+ else:
154
+ log_error("session_end", f"Failed to save state for {state.id}")
103
155
 
104
156
  except Exception as e:
105
- eprint(f"[session_end] ERROR: {e}")
106
157
  import traceback
107
- eprint(traceback.format_exc())
158
+ tb = traceback.format_exc()
159
+ from lib.base.hook_utils import log_hook_error
160
+ log_hook_error("session_end", e, "SessionEnd", traceback_str=tb)
108
161
 
109
162
 
110
163
  if __name__ == "__main__":
111
- main()
164
+ from lib.base.hook_utils import run_hook
165
+ run_hook(main, "session_end")
@@ -1,14 +1,17 @@
1
1
  #!/usr/bin/env python3
2
- """SessionStart hook for mode transitions and post-compaction restore.
2
+ """SessionStart hook for post-compaction and post-clear restore.
3
3
 
4
4
  This hook fires when a new session starts. It handles:
5
5
 
6
- 1. Mode transition from `pending_implementation` to `implementing` when
7
- a session starts after /clear with bypass permissions.
6
+ 1. Post-clear restore (source="clear"): After ExitPlanMode "clear context",
7
+ Claude Code runs /clear and auto-pastes the plan. The auto-paste bypasses
8
+ all hooks (UserPromptSubmit never fires), so this hook bridges the gap:
9
+ find the has_plan context (set by session_end moments ago), bind the new
10
+ session, transition has_plan → active, and inject restoration context.
8
11
 
9
- 2. Post-compaction restore: when source="compact", the session is already
10
- bound to a context. Load auto-state and inject rich restoration context
11
- so Claude can continue seamlessly after compaction.
12
+ 2. Post-compaction restore (source="compact"): The session is already bound
13
+ to a context. Load state and inject rich restoration context so Claude
14
+ can continue seamlessly after compaction.
12
15
 
13
16
  Hook input:
14
17
  {
@@ -20,7 +23,6 @@ Hook input:
20
23
  ...
21
24
  }
22
25
  """
23
- import json
24
26
  import sys
25
27
  from pathlib import Path
26
28
 
@@ -29,48 +31,10 @@ SCRIPT_DIR = Path(__file__).resolve().parent
29
31
  SHARED_LIB = SCRIPT_DIR.parent / "lib"
30
32
  sys.path.insert(0, str(SHARED_LIB.parent))
31
33
 
32
- from lib.base.hook_utils import load_hook_input
33
- from lib.base.utils import eprint, project_dir
34
- from lib.context.context_manager import (
35
- get_all_in_flight_contexts,
36
- get_context_by_session_id,
37
- update_plan_status,
38
- update_context_session_id,
39
- )
40
- from lib.context.auto_state import load_auto_state
41
- from lib.context.discovery import (
42
- _build_restore_sections,
43
- find_plan_path,
44
- format_relative_time,
45
- )
46
- from lib.context.task_sync import generate_task_summary
47
-
48
-
49
- def _handle_clear_transition(hook_input, session_id, project_root):
50
- """Handle /clear mode transitions (existing behavior)."""
51
- permission_mode = hook_input.get("permission_mode", "default")
52
-
53
- if permission_mode == "plan":
54
- eprint("[session_start] Skipping: permission_mode is 'plan' (in planning mode)")
55
- return
56
-
57
- in_flight_contexts = get_all_in_flight_contexts(project_root)
58
- if not in_flight_contexts:
59
- eprint("[session_start] No in-flight contexts found")
60
- return
61
-
62
- pending_contexts = [
63
- ctx for ctx in in_flight_contexts
64
- if ctx.in_flight and ctx.in_flight.mode == "pending_implementation"
65
- ]
66
- for ctx in pending_contexts:
67
- eprint(f"[session_start] Transitioning {ctx.id} from pending_implementation to implementing")
68
- update_plan_status(ctx.id, "implementing", project_root=project_root)
69
- update_context_session_id(ctx.id, session_id, project_root)
70
- eprint(f"[session_start] Bound session {session_id[:8]}... to context {ctx.id}")
71
-
72
- if pending_contexts:
73
- eprint(f"[session_start] Transitioned {len(pending_contexts)} context(s) to implementing")
34
+ from lib.base.hook_utils import emit_context, load_hook_input, log_debug, log_info, log_error, log_diagnostic
35
+ from lib.base.utils import project_dir
36
+ from lib.context.context_store import get_context_by_session_id, get_all_contexts, bind_session, update_mode
37
+ from lib.context.context_formatter import _build_restore_sections, format_handoff_continuation
74
38
 
75
39
 
76
40
  def _handle_compact_restore(hook_input, session_id, project_root):
@@ -78,30 +42,28 @@ def _handle_compact_restore(hook_input, session_id, project_root):
78
42
  Handle post-compaction restore.
79
43
 
80
44
  After compaction, the session is already bound to a context.
81
- Load auto-state and inject rich restoration context via additionalContext.
45
+ Load state and inject rich restoration context via additionalContext.
82
46
  """
83
- context = get_context_by_session_id(session_id, project_root)
84
- if not context:
85
- eprint("[session_start] No context bound to session after compact")
47
+ state = get_context_by_session_id(session_id, project_root)
48
+ if not state:
49
+ log_debug("session_start", "No context bound to session after compact")
86
50
  return
87
51
 
88
- context_id = context.id
89
- eprint(f"[session_start] Post-compaction restore for context: {context_id}")
52
+ log_info("session_start", f"Post-compaction restore for context: {state.id}")
90
53
 
91
54
  # Build restoration context
92
- mode_display = "Active"
93
- if context.in_flight and context.in_flight.mode != "none":
94
- mode_display = context.in_flight.mode.replace("_", " ").title()
55
+ mode_display = state.mode.replace("_", " ").title() if state.mode != "idle" else "Active"
95
56
 
96
57
  lines = [
97
- f"## Resuming Context After Compaction: {context_id}",
58
+ f"## Resuming Context After Compaction: {state.id}",
98
59
  "",
99
- f"**Summary:** {context.summary}",
60
+ f"**Summary:** {state.summary}",
100
61
  f"**Mode:** {mode_display}",
101
62
  ]
102
63
 
103
- # Add restore sections (auto-state, tasks, git)
104
- restore = _build_restore_sections(context, project_root)
64
+ # Add restore sections (tasks, git state, plan content)
65
+ # inline_plan=True because plan content is NOT auto-pasted after compaction
66
+ restore = _build_restore_sections(state, project_root, inline_plan=True)
105
67
  if restore:
106
68
  lines.append(restore)
107
69
 
@@ -116,21 +78,95 @@ def _handle_compact_restore(hook_input, session_id, project_root):
116
78
  ])
117
79
 
118
80
  restore_context = "\n".join(lines)
119
-
120
- # Output as additionalContext so Claude sees it
121
- output = {
122
- "hookSpecificOutput": {
123
- "additionalContext": restore_context
124
- }
125
- }
126
- print(json.dumps(output, ensure_ascii=False))
127
- eprint(f"[session_start] Injected post-compaction restore context for {context_id}")
81
+ emit_context(restore_context)
82
+ log_info("session_start", f"Injected post-compaction restore context for {state.id}")
83
+ log_diagnostic("session_start", "result", f"Compact restore complete for {state.id}",
84
+ decision="injected", inputs={"context_id": state.id, "mode": state.mode})
128
85
 
129
86
 
130
- def main():
87
+ def _handle_clear_restore(hook_input, session_id, project_root):
131
88
  """
132
- Handle mode transitions and post-compaction restore on session start.
89
+ Handle plan context restoration after /clear.
90
+
91
+ After ExitPlanMode "clear context", Claude Code auto-pastes the plan
92
+ but the auto-paste bypasses all hooks — UserPromptSubmit never fires.
93
+ This means the new session is never bound to a context.
94
+
95
+ Fix: find the has_plan context (set by session_end moments ago),
96
+ bind the new session to it, and inject restoration context.
133
97
  """
98
+ # Find has_plan contexts (sorted by last_active descending)
99
+ has_plan = [
100
+ c for c in get_all_contexts(status="active", project_root=project_root)
101
+ if c.mode == "has_plan"
102
+ ]
103
+
104
+ if not has_plan:
105
+ # Check for has_handoff contexts (mirrors plan logic)
106
+ has_handoff = [
107
+ c for c in get_all_contexts(status="active", project_root=project_root)
108
+ if c.mode == "has_handoff"
109
+ ]
110
+ if has_handoff:
111
+ target = has_handoff[0]
112
+ log_info("session_start", f"Found has_handoff context after /clear: {target.id}")
113
+ bind_session(target.id, session_id, project_root)
114
+ log_info("session_start", f"Bound session {session_id[:8]}... to {target.id}")
115
+ update_mode(target.id, "active", project_root=project_root, handoff_consumed=True)
116
+ log_info("session_start", f"Transitioned {target.id}: has_handoff -> active (handoff_consumed=True)")
117
+ restore_context = format_handoff_continuation(target, project_root)
118
+ emit_context(restore_context)
119
+ log_info("session_start", f"Injected handoff-restore context for {target.id}")
120
+ return
121
+
122
+ log_debug("session_start", "No has_plan or has_handoff contexts found after /clear")
123
+ return
124
+
125
+ # Pick the most recently active one (first in list, already sorted)
126
+ target = has_plan[0]
127
+ log_info("session_start", f"Found has_plan context after /clear: {target.id}")
128
+
129
+ # Bind new session to this context
130
+ bind_session(target.id, session_id, project_root)
131
+ log_info("session_start", f"Bound session {session_id[:8]}... to {target.id}")
132
+
133
+ # Transition has_plan → active (consume the transient state)
134
+ update_mode(target.id, "active", project_root=project_root, plan_consumed=True)
135
+ log_info("session_start", f"Transitioned {target.id}: has_plan -> active (plan_consumed=True)")
136
+
137
+ # Inject restoration context (tasks, git state, plan path reference)
138
+ # Plan CONTENT is not injected — Claude Code auto-pastes it after /clear
139
+ mode_display = "Active (Plan Restored)"
140
+ lines = [
141
+ f"## Resuming Context After Plan Clear: {target.id}",
142
+ "",
143
+ f"**Summary:** {target.summary}",
144
+ f"**Mode:** {mode_display}",
145
+ ]
146
+
147
+ restore = _build_restore_sections(target, project_root)
148
+ if restore:
149
+ lines.append(restore)
150
+
151
+ lines.extend([
152
+ "",
153
+ "---",
154
+ "",
155
+ "**Instructions:**",
156
+ "Context was cleared for plan implementation. Your plan content has been pasted above.",
157
+ "1. Review the plan content above",
158
+ "2. Implement the plan step by step",
159
+ ])
160
+
161
+ restore_context = "\n".join(lines)
162
+ emit_context(restore_context)
163
+ log_info("session_start", f"Injected clear-restore context for {target.id}")
164
+ log_diagnostic("session_start", "result", f"Clear restore complete for {target.id}",
165
+ decision="injected", inputs={"context_id": target.id, "mode_transition": "has_plan->active"})
166
+
167
+
168
+ def main():
169
+ """Handle post-compaction and post-clear restore on session start."""
134
170
  try:
135
171
  hook_input = load_hook_input()
136
172
  if not hook_input:
@@ -141,20 +177,30 @@ def main():
141
177
  session_id = hook_input.get("session_id", "unknown")
142
178
  project_root = project_dir(hook_input)
143
179
 
144
- eprint(f"[session_start] source={source}, permission_mode={permission_mode}, session={session_id[:8]}...")
180
+ log_info("session_start", f"source={source}, permission_mode={permission_mode}, session={session_id[:8]}...")
181
+ log_diagnostic("session_start", "receive", f"source={source}, session={session_id[:8]}",
182
+ inputs={"source": source, "session_id": session_id[:12], "permission_mode": permission_mode})
145
183
 
146
- if source == "clear":
147
- _handle_clear_transition(hook_input, session_id, project_root)
148
- elif source == "compact":
184
+ if source == "compact":
185
+ log_diagnostic("session_start", "decide", "Taking compact restore path",
186
+ decision="compact_restore", reasoning="source=compact")
149
187
  _handle_compact_restore(hook_input, session_id, project_root)
188
+ elif source == "clear":
189
+ log_diagnostic("session_start", "decide", "Taking clear restore path",
190
+ decision="clear_restore", reasoning="source=clear, looking for has_plan context")
191
+ _handle_clear_restore(hook_input, session_id, project_root)
150
192
  else:
151
- eprint(f"[session_start] No action for source='{source}'")
193
+ log_diagnostic("session_start", "decide", f"No action for source={source}",
194
+ decision="skip", reasoning=f"source={source} has no handler")
195
+ log_debug("session_start", f"No action for source='{source}'")
152
196
 
153
197
  except Exception as e:
154
- eprint(f"[session_start] ERROR: {e}")
155
198
  import traceback
156
- eprint(traceback.format_exc())
199
+ tb = traceback.format_exc()
200
+ from lib.base.hook_utils import log_hook_error
201
+ log_hook_error("session_start", e, "SessionStart", traceback_str=tb)
157
202
 
158
203
 
159
204
  if __name__ == "__main__":
160
- main()
205
+ from lib.base.hook_utils import run_hook
206
+ run_hook(main, "session_start")