aiwcli 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/README.md +1248 -0
  2. package/bin/dev.cmd +3 -0
  3. package/bin/dev.js +16 -0
  4. package/bin/run.cmd +3 -0
  5. package/bin/run.js +19 -0
  6. package/dist/commands/branch.d.ts +45 -0
  7. package/dist/commands/branch.js +488 -0
  8. package/dist/commands/clean.d.ts +34 -0
  9. package/dist/commands/clean.js +186 -0
  10. package/dist/commands/clear.d.ts +51 -0
  11. package/dist/commands/clear.js +835 -0
  12. package/dist/commands/init/index.d.ts +107 -0
  13. package/dist/commands/init/index.js +565 -0
  14. package/dist/commands/launch.d.ts +21 -0
  15. package/dist/commands/launch.js +108 -0
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/lib/base-command.d.ts +114 -0
  19. package/dist/lib/base-command.js +153 -0
  20. package/dist/lib/bmad-installer.d.ts +38 -0
  21. package/dist/lib/bmad-installer.js +145 -0
  22. package/dist/lib/claude-settings-types.d.ts +102 -0
  23. package/dist/lib/claude-settings-types.js +5 -0
  24. package/dist/lib/config.d.ts +25 -0
  25. package/dist/lib/config.js +46 -0
  26. package/dist/lib/debug.d.ts +39 -0
  27. package/dist/lib/debug.js +74 -0
  28. package/dist/lib/env-compat.d.ts +26 -0
  29. package/dist/lib/env-compat.js +35 -0
  30. package/dist/lib/errors.d.ts +126 -0
  31. package/dist/lib/errors.js +145 -0
  32. package/dist/lib/generic-merge.d.ts +74 -0
  33. package/dist/lib/generic-merge.js +105 -0
  34. package/dist/lib/git/branch.d.ts +67 -0
  35. package/dist/lib/git/branch.js +155 -0
  36. package/dist/lib/git/index.d.ts +11 -0
  37. package/dist/lib/git/index.js +13 -0
  38. package/dist/lib/git/safety-checks.d.ts +44 -0
  39. package/dist/lib/git/safety-checks.js +102 -0
  40. package/dist/lib/git/types.d.ts +31 -0
  41. package/dist/lib/git/types.js +6 -0
  42. package/dist/lib/git/worktree.d.ts +67 -0
  43. package/dist/lib/git/worktree.js +220 -0
  44. package/dist/lib/gitignore-manager.d.ts +10 -0
  45. package/dist/lib/gitignore-manager.js +60 -0
  46. package/dist/lib/hooks-merger.d.ts +28 -0
  47. package/dist/lib/hooks-merger.js +94 -0
  48. package/dist/lib/ide-path-resolver.d.ts +102 -0
  49. package/dist/lib/ide-path-resolver.js +129 -0
  50. package/dist/lib/index.d.ts +13 -0
  51. package/dist/lib/index.js +22 -0
  52. package/dist/lib/output.d.ts +51 -0
  53. package/dist/lib/output.js +76 -0
  54. package/dist/lib/paths.d.ts +66 -0
  55. package/dist/lib/paths.js +136 -0
  56. package/dist/lib/quiet.d.ts +12 -0
  57. package/dist/lib/quiet.js +17 -0
  58. package/dist/lib/settings-hierarchy.d.ts +42 -0
  59. package/dist/lib/settings-hierarchy.js +105 -0
  60. package/dist/lib/spawn.d.ts +105 -0
  61. package/dist/lib/spawn.js +157 -0
  62. package/dist/lib/spinner.d.ts +19 -0
  63. package/dist/lib/spinner.js +34 -0
  64. package/dist/lib/stdin.d.ts +48 -0
  65. package/dist/lib/stdin.js +60 -0
  66. package/dist/lib/template-installer.d.ts +92 -0
  67. package/dist/lib/template-installer.js +375 -0
  68. package/dist/lib/template-linter.d.ts +49 -0
  69. package/dist/lib/template-linter.js +173 -0
  70. package/dist/lib/template-merger.d.ts +47 -0
  71. package/dist/lib/template-merger.js +173 -0
  72. package/dist/lib/template-resolver.d.ts +20 -0
  73. package/dist/lib/template-resolver.js +60 -0
  74. package/dist/lib/terminal.d.ts +102 -0
  75. package/dist/lib/terminal.js +245 -0
  76. package/dist/lib/tty-detection.d.ts +62 -0
  77. package/dist/lib/tty-detection.js +83 -0
  78. package/dist/lib/user-utils.d.ts +5 -0
  79. package/dist/lib/user-utils.js +23 -0
  80. package/dist/lib/version.d.ts +99 -0
  81. package/dist/lib/version.js +144 -0
  82. package/dist/lib/watch-templates.d.ts +6 -0
  83. package/dist/lib/watch-templates.js +73 -0
  84. package/dist/lib/windsurf-hooks-hierarchy.d.ts +30 -0
  85. package/dist/lib/windsurf-hooks-hierarchy.js +66 -0
  86. package/dist/lib/windsurf-hooks-merger.d.ts +26 -0
  87. package/dist/lib/windsurf-hooks-merger.js +53 -0
  88. package/dist/lib/windsurf-hooks-types.d.ts +33 -0
  89. package/dist/lib/windsurf-hooks-types.js +5 -0
  90. package/dist/templates/CLAUDE.md +174 -0
  91. package/dist/templates/_shared/.claude/commands/handoff.md +14 -0
  92. package/dist/templates/_shared/.claude/settings.json +61 -0
  93. package/dist/templates/_shared/.codex/workflows/handoff.md +14 -0
  94. package/dist/templates/_shared/.windsurf/workflows/handoff.md +14 -0
  95. package/dist/templates/_shared/hooks/__init__.py +16 -0
  96. package/dist/templates/_shared/hooks/archive_plan.py +270 -0
  97. package/dist/templates/_shared/hooks/context_enforcer.py +621 -0
  98. package/dist/templates/_shared/hooks/context_monitor.py +322 -0
  99. package/dist/templates/_shared/hooks/file-suggestion.py +188 -0
  100. package/dist/templates/_shared/hooks/task_create_capture.py +194 -0
  101. package/dist/templates/_shared/hooks/task_update_capture.py +254 -0
  102. package/dist/templates/_shared/hooks/user_prompt_submit.py +157 -0
  103. package/dist/templates/_shared/lib/__init__.py +1 -0
  104. package/dist/templates/_shared/lib/base/__init__.py +49 -0
  105. package/dist/templates/_shared/lib/base/__pycache__/constants.cpython-313.pyc +0 -0
  106. package/dist/templates/_shared/lib/base/atomic_write.py +180 -0
  107. package/dist/templates/_shared/lib/base/constants.py +299 -0
  108. package/dist/templates/_shared/lib/base/inference.py +189 -0
  109. package/dist/templates/_shared/lib/base/utils.py +216 -0
  110. package/dist/templates/_shared/lib/context/__init__.py +119 -0
  111. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  112. package/dist/templates/_shared/lib/context/__pycache__/cache.cpython-313.pyc +0 -0
  113. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  114. package/dist/templates/_shared/lib/context/__pycache__/event_log.cpython-313.pyc +0 -0
  115. package/dist/templates/_shared/lib/context/cache.py +446 -0
  116. package/dist/templates/_shared/lib/context/context_manager.py +1171 -0
  117. package/dist/templates/_shared/lib/context/discovery.py +486 -0
  118. package/dist/templates/_shared/lib/context/event_log.py +308 -0
  119. package/dist/templates/_shared/lib/context/plan_archive.py +247 -0
  120. package/dist/templates/_shared/lib/context/task_sync.py +367 -0
  121. package/dist/templates/_shared/lib/handoff/__init__.py +22 -0
  122. package/dist/templates/_shared/lib/handoff/document_generator.py +307 -0
  123. package/dist/templates/_shared/lib/templates/README.md +215 -0
  124. package/dist/templates/_shared/lib/templates/__init__.py +40 -0
  125. package/dist/templates/_shared/lib/templates/formatters.py +147 -0
  126. package/dist/templates/_shared/lib/templates/plan_context.py +119 -0
  127. package/dist/templates/_shared/scripts/save_handoff.py +99 -0
  128. package/dist/templates/_shared/workflows/handoff.md +212 -0
  129. package/dist/templates/cc-native/.claude/agents/cc-native/ACCESSIBILITY-TESTER.md +80 -0
  130. package/dist/templates/cc-native/.claude/agents/cc-native/ARCHITECT-REVIEWER.md +75 -0
  131. package/dist/templates/cc-native/.claude/agents/cc-native/ASSUMPTION-CHAIN-TRACER.md +239 -0
  132. package/dist/templates/cc-native/.claude/agents/cc-native/CLARITY-AUDITOR.md +109 -0
  133. package/dist/templates/cc-native/.claude/agents/cc-native/CODE-REVIEWER.md +71 -0
  134. package/dist/templates/cc-native/.claude/agents/cc-native/COMPLETENESS-CHECKER.md +104 -0
  135. package/dist/templates/cc-native/.claude/agents/cc-native/CONTEXT-EXTRACTOR.md +93 -0
  136. package/dist/templates/cc-native/.claude/agents/cc-native/DEVILS-ADVOCATE.md +223 -0
  137. package/dist/templates/cc-native/.claude/agents/cc-native/DOCUMENTATION-REVIEWER.md +73 -0
  138. package/dist/templates/cc-native/.claude/agents/cc-native/FEASIBILITY-ANALYST.md +93 -0
  139. package/dist/templates/cc-native/.claude/agents/cc-native/FRESH-PERSPECTIVE.md +103 -0
  140. package/dist/templates/cc-native/.claude/agents/cc-native/HANDOFF-READINESS.md +145 -0
  141. package/dist/templates/cc-native/.claude/agents/cc-native/HIDDEN-COMPLEXITY-DETECTOR.md +248 -0
  142. package/dist/templates/cc-native/.claude/agents/cc-native/INCENTIVE-MAPPER.md +235 -0
  143. package/dist/templates/cc-native/.claude/agents/cc-native/PENETRATION-TESTER.md +80 -0
  144. package/dist/templates/cc-native/.claude/agents/cc-native/PERFORMANCE-ENGINEER.md +76 -0
  145. package/dist/templates/cc-native/.claude/agents/cc-native/PLAN-ORCHESTRATOR.md +141 -0
  146. package/dist/templates/cc-native/.claude/agents/cc-native/PRECEDENT-FINDER.md +240 -0
  147. package/dist/templates/cc-native/.claude/agents/cc-native/REVERSIBILITY-ANALYST.md +211 -0
  148. package/dist/templates/cc-native/.claude/agents/cc-native/RISK-ASSESSOR.md +101 -0
  149. package/dist/templates/cc-native/.claude/agents/cc-native/SECOND-ORDER-ANALYST.md +197 -0
  150. package/dist/templates/cc-native/.claude/agents/cc-native/SIMPLICITY-GUARDIAN.md +97 -0
  151. package/dist/templates/cc-native/.claude/agents/cc-native/SKEPTIC.md +349 -0
  152. package/dist/templates/cc-native/.claude/agents/cc-native/STAKEHOLDER-ADVOCATE.md +106 -0
  153. package/dist/templates/cc-native/.claude/agents/cc-native/TRADE-OFF-ILLUMINATOR.md +205 -0
  154. package/dist/templates/cc-native/.claude/commands/cc-native/fresh-perspective.md +8 -0
  155. package/dist/templates/cc-native/.claude/commands/cc-native/specdev.md +10 -0
  156. package/dist/templates/cc-native/.claude/settings.json +119 -0
  157. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fix.md +8 -0
  158. package/dist/templates/cc-native/.windsurf/workflows/cc-native/fresh-perspective.md +8 -0
  159. package/dist/templates/cc-native/.windsurf/workflows/cc-native/implement.md +8 -0
  160. package/dist/templates/cc-native/.windsurf/workflows/cc-native/research.md +8 -0
  161. package/dist/templates/cc-native/CC-NATIVE-README.md +192 -0
  162. package/dist/templates/cc-native/MIGRATION.md +86 -0
  163. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +331 -0
  164. package/dist/templates/cc-native/_cc-native/docs/PERMISSION_REQUEST_VERIFICATION.md +147 -0
  165. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  166. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  167. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
  168. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  169. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
  170. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +150 -0
  171. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +746 -0
  172. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +339 -0
  173. package/dist/templates/cc-native/_cc-native/lib/__init__.py +57 -0
  174. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  175. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  176. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  177. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  178. package/dist/templates/cc-native/_cc-native/lib/async_archive.py +68 -0
  179. package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +98 -0
  180. package/dist/templates/cc-native/_cc-native/lib/constants.py +45 -0
  181. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +273 -0
  182. package/dist/templates/cc-native/_cc-native/lib/reviewers/__init__.py +28 -0
  183. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  184. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  185. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  186. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  187. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  188. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +164 -0
  189. package/dist/templates/cc-native/_cc-native/lib/reviewers/base.py +89 -0
  190. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +119 -0
  191. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +103 -0
  192. package/dist/templates/cc-native/_cc-native/lib/state.py +251 -0
  193. package/dist/templates/cc-native/_cc-native/lib/utils.py +830 -0
  194. package/dist/templates/cc-native/_cc-native/plan-review.config.json +76 -0
  195. package/dist/templates/cc-native/_cc-native/scripts/__pycache__/aggregate_agents.cpython-313.pyc +0 -0
  196. package/dist/templates/cc-native/_cc-native/scripts/aggregate_agents.py +151 -0
  197. package/dist/templates/cc-native/_cc-native/workflows/fresh-perspective.md +134 -0
  198. package/dist/templates/cc-native/_cc-native/workflows/specdev.md +9 -0
  199. package/dist/types/exit-codes.d.ts +11 -0
  200. package/dist/types/exit-codes.js +10 -0
  201. package/dist/types/index.d.ts +5 -0
  202. package/dist/types/index.js +7 -0
  203. package/oclif.manifest.json +405 -0
  204. package/package.json +109 -0
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse hook - captures TaskUpdate operations for persistence.
3
+
4
+ This hook runs after Claude uses the TaskUpdate tool and automatically
5
+ records the appropriate event in the context's events.jsonl based on the
6
+ status change.
7
+
8
+ Status mappings:
9
+ - status: "in_progress" -> record_task_started()
10
+ - status: "completed" -> record_task_completed()
11
+ - blockedBy added -> record_task_blocked()
12
+
13
+ Hook input (from Claude Code):
14
+ {
15
+ "hook_event_name": "PostToolUse",
16
+ "tool_name": "TaskUpdate",
17
+ "tool_input": {
18
+ "taskId": "1",
19
+ "status": "completed",
20
+ "metadata": {"evidence": "...", "work_summary": "...", ...},
21
+ "addBlockedBy": ["2"],
22
+ ...
23
+ },
24
+ "tool_response": {...},
25
+ "session_id": "abc123",
26
+ "cwd": "/path/to/project"
27
+ }
28
+
29
+ Hook output:
30
+ - Silent on success (no stdout output)
31
+ - Logs to stderr for debugging
32
+ """
33
+ import json
34
+ import sys
35
+ from pathlib import Path
36
+ from typing import Optional, Dict, Any
37
+
38
+ # Add parent directories to path for imports
39
+ SCRIPT_DIR = Path(__file__).resolve().parent
40
+ SHARED_LIB = SCRIPT_DIR.parent / "lib"
41
+ sys.path.insert(0, str(SHARED_LIB.parent))
42
+
43
+ from lib.context.task_sync import (
44
+ record_task_started,
45
+ record_task_completed,
46
+ record_task_blocked,
47
+ )
48
+ from lib.context.context_manager import get_all_contexts, get_context_by_session_id
49
+ from lib.base.utils import eprint, project_dir
50
+
51
+
52
+ def extract_context_id(
53
+ tool_input: Dict[str, Any],
54
+ project_root: Path,
55
+ session_id: Optional[str] = None
56
+ ) -> Optional[str]:
57
+ """
58
+ Extract context ID from tool input metadata, session, or active contexts.
59
+
60
+ Priority:
61
+ 1. metadata.context field
62
+ 2. Session ID lookup (session bound to context)
63
+ 3. Single active context
64
+ 4. None (skips persistence)
65
+
66
+ Args:
67
+ tool_input: Tool input from TaskUpdate
68
+ project_root: Project root directory
69
+ session_id: Session ID from hook payload
70
+
71
+ Returns:
72
+ Context ID or None if cannot determine
73
+ """
74
+ # Check metadata.context field
75
+ metadata = tool_input.get("metadata", {})
76
+ if isinstance(metadata, dict):
77
+ context = metadata.get("context")
78
+ if context:
79
+ return context
80
+
81
+ # Check session ID - session may be bound to a context
82
+ if session_id:
83
+ try:
84
+ session_context = get_context_by_session_id(session_id, project_root)
85
+ if session_context:
86
+ eprint(f"[task_update_capture] Found context via session_id: {session_context.id}")
87
+ return session_context.id
88
+ except Exception as e:
89
+ eprint(f"[task_update_capture] Failed to lookup context by session: {e}")
90
+
91
+ # Check for single active context
92
+ try:
93
+ contexts = get_all_contexts(status="active", project_root=project_root)
94
+ if len(contexts) == 1:
95
+ return contexts[0].id
96
+ except Exception as e:
97
+ eprint(f"[task_update_capture] Failed to get active contexts: {e}")
98
+
99
+ return None
100
+
101
+
102
+ def get_persistent_task_id(
103
+ claude_task_id: str,
104
+ tool_input: Dict[str, Any]
105
+ ) -> str:
106
+ """
107
+ Convert Claude's ephemeral task ID to persistent task ID.
108
+
109
+ If metadata.persistent_id exists, use that.
110
+ Otherwise, assume format "aiw-{claude_task_id}".
111
+
112
+ Args:
113
+ claude_task_id: Task ID from Claude (e.g., "1", "2")
114
+ tool_input: Tool input dict
115
+
116
+ Returns:
117
+ Persistent task ID (e.g., "aiw-1")
118
+ """
119
+ metadata = tool_input.get("metadata", {})
120
+ if isinstance(metadata, dict):
121
+ persistent_id = metadata.get("persistent_id")
122
+ if persistent_id:
123
+ return persistent_id
124
+
125
+ # Default: aiw-{id}
126
+ return f"aiw-{claude_task_id}"
127
+
128
+
129
+ def main() -> int:
130
+ """
131
+ Main hook entry point.
132
+
133
+ Returns:
134
+ 0 on success, non-zero on failure (but hook is non-blocking)
135
+ """
136
+ try:
137
+ # Parse hook input
138
+ payload = json.load(sys.stdin)
139
+
140
+ # Validate hook type
141
+ if payload.get("hook_event_name") != "PostToolUse":
142
+ return 0
143
+
144
+ # Validate tool name
145
+ if payload.get("tool_name") != "TaskUpdate":
146
+ return 0
147
+
148
+ # Extract tool input
149
+ tool_input = payload.get("tool_input", {})
150
+ if not isinstance(tool_input, dict):
151
+ eprint("[task_update_capture] Invalid tool_input: not a dict")
152
+ return 0
153
+
154
+ # Check for skip_persistence flag (used during hydration to avoid duplicates)
155
+ metadata = tool_input.get("metadata", {})
156
+ if isinstance(metadata, dict) and metadata.get("skip_persistence"):
157
+ eprint("[task_update_capture] Skipping persistence (hydration mode)")
158
+ return 0
159
+
160
+ # Get project root and session ID
161
+ project_root = project_dir(payload)
162
+ session_id = payload.get("session_id")
163
+
164
+ # Extract context ID
165
+ context_id = extract_context_id(tool_input, project_root, session_id)
166
+ if not context_id:
167
+ eprint("[task_update_capture] No context available - skipping persistence")
168
+ return 0
169
+
170
+ # Extract task ID
171
+ claude_task_id = tool_input.get("taskId")
172
+ if not claude_task_id:
173
+ eprint("[task_update_capture] Missing required field: taskId")
174
+ return 0
175
+
176
+ # Get persistent task ID
177
+ persistent_task_id = get_persistent_task_id(claude_task_id, tool_input)
178
+
179
+ # Check for status change
180
+ status = tool_input.get("status")
181
+ metadata = tool_input.get("metadata", {})
182
+ add_blocked_by = tool_input.get("addBlockedBy", [])
183
+
184
+ # Handle different update types
185
+ events_recorded = []
186
+
187
+ # Status: in_progress
188
+ if status == "in_progress":
189
+ success = record_task_started(
190
+ context_id=context_id,
191
+ task_id=persistent_task_id,
192
+ project_root=project_root
193
+ )
194
+ if success:
195
+ events_recorded.append("task_started")
196
+
197
+ # Status: completed
198
+ elif status == "completed":
199
+ # Extract rich completion context from metadata
200
+ if isinstance(metadata, dict):
201
+ evidence = metadata.get("evidence", "Task marked completed")
202
+ work_summary = metadata.get("work_summary", "")
203
+ files_changed = metadata.get("files_changed", [])
204
+ commit_ref = metadata.get("commit_ref", "")
205
+ else:
206
+ evidence = "Task marked completed"
207
+ work_summary = ""
208
+ files_changed = []
209
+ commit_ref = ""
210
+
211
+ success = record_task_completed(
212
+ context_id=context_id,
213
+ task_id=persistent_task_id,
214
+ evidence=evidence,
215
+ work_summary=work_summary,
216
+ files_changed=files_changed if isinstance(files_changed, list) else [],
217
+ commit_ref=commit_ref,
218
+ project_root=project_root
219
+ )
220
+ if success:
221
+ events_recorded.append("task_completed")
222
+
223
+ # Blocked by tasks
224
+ if add_blocked_by and isinstance(add_blocked_by, list) and len(add_blocked_by) > 0:
225
+ blocked_reason = f"Blocked by tasks: {', '.join(add_blocked_by)}"
226
+ success = record_task_blocked(
227
+ context_id=context_id,
228
+ task_id=persistent_task_id,
229
+ reason=blocked_reason,
230
+ project_root=project_root
231
+ )
232
+ if success:
233
+ events_recorded.append("task_blocked")
234
+
235
+ if events_recorded:
236
+ eprint(f"[task_update_capture] Recorded {', '.join(events_recorded)} for {persistent_task_id} in {context_id}")
237
+ else:
238
+ eprint(f"[task_update_capture] No relevant status changes detected for {persistent_task_id}")
239
+
240
+ # Silent success (no stdout output)
241
+ return 0
242
+
243
+ except json.JSONDecodeError as e:
244
+ eprint(f"[task_update_capture] JSON decode error: {e}")
245
+ return 0 # Non-blocking
246
+ except Exception as e:
247
+ eprint(f"[task_update_capture] Unexpected error: {e}")
248
+ import traceback
249
+ eprint(traceback.format_exc())
250
+ return 0 # Non-blocking
251
+
252
+
253
+ if __name__ == "__main__":
254
+ raise SystemExit(main())
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """Unified UserPromptSubmit hook entry point.
3
+
4
+ This hook runs on every UserPromptSubmit and handles:
5
+ - Context enforcement - ensures all work happens in a tracked context
6
+
7
+ Note: Context monitoring (handoff warnings) is handled separately by
8
+ context_monitor.py on PostToolUse events, which fires during Claude's
9
+ work rather than waiting for user input.
10
+
11
+ Hook input (from Claude Code):
12
+ {
13
+ "hook_type": "UserPromptSubmit",
14
+ "prompt": "user's message text",
15
+ "session_id": "abc123",
16
+ ...
17
+ }
18
+
19
+ Hook output:
20
+ - Prints system reminders to stdout for context enforcement
21
+ """
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+ from typing import List
26
+
27
+ # Add parent directories to path for imports
28
+ SCRIPT_DIR = Path(__file__).resolve().parent
29
+ SHARED_LIB = SCRIPT_DIR.parent / "lib"
30
+ sys.path.insert(0, str(SHARED_LIB.parent))
31
+
32
+ from lib.base.utils import eprint, project_dir
33
+ from lib.context.context_manager import (
34
+ update_context_session_id,
35
+ update_plan_status,
36
+ clear_handoff_status,
37
+ get_context,
38
+ get_context_by_session_id,
39
+ )
40
+ from lib.context.task_sync import generate_hydration_instructions
41
+
42
+ # Import the enforcement module
43
+ from hooks.context_enforcer import determine_context, BlockRequest
44
+
45
+
46
+ def _update_in_flight_status(context_id: str, hook_input: dict, project_root: Path) -> None:
47
+ """
48
+ Update context in-flight status based on permission mode.
49
+
50
+ - If handoff_pending: clear it (handoff has been consumed by this session)
51
+ - If permission_mode == "plan": set to "planning"
52
+ - If permission_mode in ["acceptEdits", "bypassPermissions"]: set to "implementing"
53
+ """
54
+ context = get_context(context_id, project_root)
55
+ if not context or not context.in_flight:
56
+ return
57
+
58
+ current_mode = context.in_flight.mode
59
+ permission_mode = hook_input.get("permission_mode", "default")
60
+ eprint(f"[user_prompt_submit] Current mode: {current_mode}, permission_mode: {permission_mode}")
61
+
62
+ # Clear handoff_pending if set (session resumption clears the handoff)
63
+ if current_mode == "handoff_pending":
64
+ clear_handoff_status(context_id, project_root)
65
+ eprint(f"[user_prompt_submit] Cleared handoff_pending status")
66
+ # Refresh context after clearing
67
+ context = get_context(context_id, project_root)
68
+ current_mode = context.in_flight.mode if context and context.in_flight else "none"
69
+
70
+ # Set status based on permission mode
71
+ if permission_mode == "plan":
72
+ if current_mode != "planning":
73
+ update_plan_status(context_id, "planning", project_root=project_root)
74
+ eprint(f"[user_prompt_submit] Set status to 'planning'")
75
+ elif permission_mode in ["acceptEdits", "bypassPermissions"]:
76
+ # Only transition to implementing if we have pending work
77
+ if current_mode in ["pending_implementation", "planning"]:
78
+ update_plan_status(context_id, "implementing", project_root=project_root)
79
+ eprint(f"[user_prompt_submit] Set status to 'implementing'")
80
+
81
+
82
+ def main():
83
+ """
84
+ Main entry point for UserPromptSubmit hook.
85
+
86
+ Handles context enforcement for all user prompts.
87
+ Uses session_id to detect first prompt vs subsequent prompts.
88
+ """
89
+ try:
90
+ # Read hook input from stdin
91
+ input_data = sys.stdin.read().strip()
92
+
93
+ if not input_data:
94
+ return
95
+
96
+ try:
97
+ hook_input = json.loads(input_data)
98
+ except json.JSONDecodeError:
99
+ return
100
+
101
+ # Get user prompt and project root
102
+ user_prompt = hook_input.get("prompt", "")
103
+ project_root = project_dir(hook_input)
104
+ session_id = hook_input.get("session_id", "unknown")
105
+
106
+ outputs: List[str] = []
107
+
108
+ # First-prompt detection: check if session_id is already bound to a context
109
+ existing_context = get_context_by_session_id(session_id, project_root)
110
+
111
+ if existing_context:
112
+ # NOT first prompt - session already bound to context
113
+ # Skip expensive context detection and task hydration
114
+ eprint(f"[user_prompt_submit] Session {session_id[:8]}... already bound to {existing_context.id}")
115
+ # Still update in-flight status based on permission mode
116
+ _update_in_flight_status(existing_context.id, hook_input, project_root)
117
+ elif user_prompt:
118
+ # FIRST prompt - need context detection and potentially task hydration
119
+ try:
120
+ context_id, method, context_output = determine_context(user_prompt, project_root, session_id)
121
+ eprint(f"[user_prompt_submit] Context: {method} -> {context_id}")
122
+
123
+ if context_id:
124
+ # Bind session to context
125
+ update_context_session_id(context_id, session_id, project_root)
126
+ eprint(f"[user_prompt_submit] Bound session {session_id[:8]}... to context '{context_id}'")
127
+
128
+ # Update in-flight status based on permission mode
129
+ _update_in_flight_status(context_id, hook_input, project_root)
130
+
131
+ # Task hydration - restore pending tasks from events.jsonl
132
+ hydration_instructions = generate_hydration_instructions(context_id, project_root)
133
+ if hydration_instructions and "No pending tasks" not in hydration_instructions:
134
+ outputs.append(hydration_instructions)
135
+ eprint(f"[user_prompt_submit] Generated task hydration instructions")
136
+
137
+ if context_output:
138
+ outputs.append(context_output)
139
+
140
+ except BlockRequest as e:
141
+ # Block the request - print to stderr and exit with code 2
142
+ # This shows the context picker to the user
143
+ print(e.message, file=sys.stderr)
144
+ sys.exit(2)
145
+
146
+ # Print output
147
+ if outputs:
148
+ print("\n\n".join(outputs))
149
+
150
+ except Exception as e:
151
+ eprint(f"[user_prompt_submit] ERROR: {e}")
152
+ import traceback
153
+ eprint(traceback.format_exc())
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()
@@ -0,0 +1 @@
1
+ """Shared library for AIW CLI templates."""
@@ -0,0 +1,49 @@
1
+ """Base utilities for shared context management."""
2
+ from .atomic_write import atomic_write, atomic_append
3
+ from .constants import (
4
+ OUTPUT_DIR,
5
+ CONTEXTS_DIR,
6
+ INDEX_FILENAME,
7
+ validate_context_id,
8
+ get_output_dir,
9
+ get_contexts_dir,
10
+ get_context_dir,
11
+ get_context_plans_dir,
12
+ get_context_handoffs_dir,
13
+ get_index_path,
14
+ get_context_file_path,
15
+ get_events_file_path,
16
+ )
17
+ from .utils import (
18
+ eprint,
19
+ now_local,
20
+ now_iso,
21
+ project_dir,
22
+ sanitize_filename,
23
+ sanitize_title,
24
+ generate_context_id,
25
+ )
26
+
27
+ __all__ = [
28
+ "atomic_write",
29
+ "atomic_append",
30
+ "OUTPUT_DIR",
31
+ "CONTEXTS_DIR",
32
+ "INDEX_FILENAME",
33
+ "validate_context_id",
34
+ "get_output_dir",
35
+ "get_contexts_dir",
36
+ "get_context_dir",
37
+ "get_context_plans_dir",
38
+ "get_context_handoffs_dir",
39
+ "get_index_path",
40
+ "get_context_file_path",
41
+ "get_events_file_path",
42
+ "eprint",
43
+ "now_local",
44
+ "now_iso",
45
+ "project_dir",
46
+ "sanitize_filename",
47
+ "sanitize_title",
48
+ "generate_context_id",
49
+ ]
@@ -0,0 +1,180 @@
1
+ """Cross-platform atomic file writes with security.
2
+
3
+ Provides crash-safe file writes by writing to a temp file first,
4
+ then atomically replacing the target. This prevents corrupted files
5
+ if the process crashes mid-write.
6
+
7
+ Note: This is for crash-safety, NOT for concurrent access.
8
+ The shared context system assumes single-session-per-context.
9
+ """
10
+ import os
11
+ import sys
12
+ import tempfile
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Optional, Tuple
16
+
17
+ if sys.platform == 'win32':
18
+ import ctypes
19
+ from ctypes import wintypes
20
+
21
+ # Windows MoveFileEx flags
22
+ MOVEFILE_REPLACE_EXISTING = 0x1
23
+ MOVEFILE_WRITE_THROUGH = 0x8
24
+
25
+ def _atomic_replace_windows(src: Path, dst: Path) -> None:
26
+ """Atomic file replacement on Windows using MoveFileEx."""
27
+ kernel32 = ctypes.windll.kernel32
28
+
29
+ # Set proper function prototypes for 64-bit safety
30
+ kernel32.MoveFileExW.argtypes = [wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.DWORD]
31
+ kernel32.MoveFileExW.restype = wintypes.BOOL
32
+
33
+ result = kernel32.MoveFileExW(
34
+ str(src),
35
+ str(dst),
36
+ MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
37
+ )
38
+ if not result:
39
+ error_code = kernel32.GetLastError()
40
+ raise ctypes.WinError(error_code)
41
+
42
+
43
+ def atomic_write(
44
+ path: Path,
45
+ content: str,
46
+ max_attempts: int = 2,
47
+ backoff_ms: Optional[list] = None
48
+ ) -> Tuple[bool, Optional[str]]:
49
+ """
50
+ Write file atomically with retry logic.
51
+
52
+ Creates a temp file in the same directory, writes content,
53
+ then atomically replaces the target file. This ensures the
54
+ file is never left in a corrupted state.
55
+
56
+ Args:
57
+ path: Target file path
58
+ content: Content to write
59
+ max_attempts: Maximum retry attempts (default: 2)
60
+ backoff_ms: Retry backoff in milliseconds (default: [500, 1000])
61
+
62
+ Returns:
63
+ Tuple of (success: bool, error_message: Optional[str])
64
+ """
65
+ if backoff_ms is None:
66
+ backoff_ms = [500, 1000]
67
+
68
+ # Ensure parent directory exists
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+
71
+ for attempt in range(max_attempts):
72
+ try:
73
+ # Create temp file in same directory for atomic rename
74
+ temp_fd, temp_path_str = tempfile.mkstemp(
75
+ dir=path.parent,
76
+ prefix=f".{path.stem}_",
77
+ suffix=".tmp"
78
+ )
79
+ temp_path = Path(temp_path_str)
80
+
81
+ try:
82
+ # Write content to temp file
83
+ with os.fdopen(temp_fd, 'w', encoding='utf-8') as f:
84
+ f.write(content)
85
+ f.flush()
86
+ os.fsync(f.fileno()) # Force write to disk
87
+
88
+ # Set restrictive permissions before rename (chmod 600)
89
+ try:
90
+ os.chmod(temp_path, 0o600)
91
+ except OSError:
92
+ pass # chmod may fail on some filesystems
93
+
94
+ # Platform-specific atomic rename
95
+ if sys.platform == 'win32':
96
+ _atomic_replace_windows(temp_path, path)
97
+ else:
98
+ temp_path.replace(path) # POSIX atomic
99
+
100
+ return (True, None)
101
+
102
+ except Exception:
103
+ # Clean up temp file on failure
104
+ try:
105
+ temp_path.unlink()
106
+ except Exception:
107
+ pass # Cleanup is best-effort
108
+ raise
109
+
110
+ except Exception as e:
111
+ if attempt < max_attempts - 1:
112
+ # Bounds-safe backoff indexing
113
+ wait_ms = backoff_ms[min(attempt, len(backoff_ms) - 1)]
114
+ time.sleep(wait_ms / 1000.0)
115
+ else:
116
+ # Sanitize error message (no paths, no stack trace)
117
+ error_type = type(e).__name__
118
+ error_msg = str(e).split('\n')[0][:200] # First line only, max 200 chars
119
+ return (False, f"{error_type}: {error_msg}")
120
+
121
+ return (False, "Max retry attempts exceeded")
122
+
123
+
124
+ def atomic_append(
125
+ path: Path,
126
+ content: str,
127
+ max_attempts: int = 2,
128
+ backoff_ms: Optional[list] = None
129
+ ) -> Tuple[bool, Optional[str]]:
130
+ """
131
+ Append to file atomically with retry logic.
132
+
133
+ For JSONL files, this is safe because each line is independent.
134
+ If process crashes mid-append, only the last partial line is lost,
135
+ which read_events() handles gracefully.
136
+
137
+ Args:
138
+ path: Target file path
139
+ content: Content to append (should include newline if needed)
140
+ max_attempts: Maximum retry attempts (default: 2)
141
+ backoff_ms: Retry backoff in milliseconds (default: [500, 1000])
142
+
143
+ Returns:
144
+ Tuple of (success: bool, error_message: Optional[str])
145
+ """
146
+ if backoff_ms is None:
147
+ backoff_ms = [500, 1000]
148
+
149
+ # Ensure parent directory exists
150
+ path.parent.mkdir(parents=True, exist_ok=True)
151
+
152
+ # Check if file is being created (for permission setting)
153
+ is_new_file = not path.exists()
154
+
155
+ for attempt in range(max_attempts):
156
+ try:
157
+ with open(path, 'a', encoding='utf-8') as f:
158
+ f.write(content)
159
+ f.flush()
160
+ os.fsync(f.fileno()) # Force write to disk
161
+
162
+ # Set restrictive permissions on newly created files (chmod 600)
163
+ if is_new_file:
164
+ try:
165
+ os.chmod(path, 0o600)
166
+ except OSError:
167
+ pass # chmod may fail on some filesystems
168
+
169
+ return (True, None)
170
+
171
+ except Exception as e:
172
+ if attempt < max_attempts - 1:
173
+ wait_ms = backoff_ms[min(attempt, len(backoff_ms) - 1)]
174
+ time.sleep(wait_ms / 1000.0)
175
+ else:
176
+ error_type = type(e).__name__
177
+ error_msg = str(e).split('\n')[0][:200]
178
+ return (False, f"{error_type}: {error_msg}")
179
+
180
+ return (False, "Max retry attempts exceeded")