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,621 @@
1
+ #!/usr/bin/env python3
2
+ """Context enforcer hook - ensures all work happens within a named context.
3
+
4
+ This hook runs on UserPromptSubmit to determine the active context.
5
+ It enforces that every user interaction happens within a tracked context.
6
+
7
+ Context selection priority:
8
+ 1. Session already in context -> Continue in that context (highest priority - prevents switching)
9
+ 2. Bare "^" -> Show context picker
10
+ 3. Explicit caret commands (^E, ^S, ^0, ^N) -> Process as specified
11
+ 4. No caret prefix:
12
+ - 0 in-flight contexts -> Auto-create new context from prompt
13
+ - 1 in-flight context -> Auto-select that context
14
+ - Multiple in-flight contexts -> Block and show picker
15
+
16
+ In-flight modes: planning, pending_implementation, implementing, handoff_pending
17
+
18
+ Prefix syntax:
19
+ - ^: Show context picker (bare caret)
20
+ - ^0 <description>: Create new context (description requires 10+ chars)
21
+ - ^1, ^2, etc: Select existing context by number (shorthand for ^S1, ^S2)
22
+ - ^E<N>: End/complete context N (removes from active list)
23
+ - ^E*: End/complete ALL active contexts
24
+ - ^S<N>: Select context N for this session
25
+ - Chaining: ^E1E2S3 means end contexts 1 and 2, then select context 3
26
+
27
+ Hook input (from Claude Code):
28
+ {
29
+ "hook_type": "UserPromptSubmit",
30
+ "prompt": "user's message text",
31
+ "session_id": "abc123",
32
+ ...
33
+ }
34
+
35
+ Hook output:
36
+ - Exit 0 + stdout: Context selected, continues with system reminder
37
+ - Exit 2 + stderr: Block request, show context picker to user
38
+ """
39
+ import json
40
+ import re
41
+ import sys
42
+ from dataclasses import dataclass
43
+ from pathlib import Path
44
+ from typing import List, Optional, Tuple
45
+
46
+ # Add parent directories to path for imports
47
+ SCRIPT_DIR = Path(__file__).resolve().parent
48
+ SHARED_LIB = SCRIPT_DIR.parent / "lib"
49
+ sys.path.insert(0, str(SHARED_LIB.parent))
50
+
51
+ from lib.context.context_manager import (
52
+ Context,
53
+ get_all_contexts,
54
+ get_all_in_flight_contexts,
55
+ create_context_from_prompt,
56
+ get_context_by_session_id,
57
+ complete_context,
58
+ update_plan_status,
59
+ )
60
+ from lib.context.discovery import (
61
+ get_in_flight_context,
62
+ format_active_context_reminder,
63
+ format_context_created,
64
+ format_handoff_continuation,
65
+ format_pending_plan_continuation,
66
+ format_implementation_continuation,
67
+ _format_relative_time,
68
+ )
69
+ from lib.templates.formatters import get_mode_display
70
+ from lib.base.utils import eprint, project_dir
71
+
72
+ # Minimum characters required for new context description
73
+ MIN_NEW_CONTEXT_CHARS = 10
74
+
75
+
76
+ @dataclass
77
+ class CaretCommand:
78
+ """Parsed caret command result."""
79
+ ends: List[int] # Context numbers to end (1-indexed)
80
+ select: Optional[int] # Context number to select (1-indexed), None if not specified
81
+ new_context_desc: Optional[str] # Description for new context (^0)
82
+ remaining_prompt: str # The remaining prompt after the command
83
+
84
+
85
+ def parse_chained_caret(prompt: str, contexts: List["Context"]) -> Tuple[Optional[CaretCommand], Optional[str]]:
86
+ """
87
+ Parse chained caret commands from user prompt.
88
+
89
+ Syntax:
90
+ - ^E<N>: End context N
91
+ - ^E<N>+: End context N and all after (e.g., ^E2+ ends 2, 3, 4, ...)
92
+ - ^E*: End ALL contexts
93
+ - ^S<N>: Select context N
94
+ - ^0 <desc>: Create new context (special case)
95
+ - ^<N>: Shorthand for ^S<N> (backwards compat)
96
+ - Chain: ^E1E2S3 means end 1, end 2, select 3
97
+
98
+ Returns:
99
+ Tuple of:
100
+ - CaretCommand with parsed actions, or None if no caret prefix
101
+ - Error message if syntax is invalid, or None
102
+ """
103
+ if not prompt.startswith("^"):
104
+ return None, None
105
+
106
+ # Find where the command ends and the remaining prompt begins
107
+ # Command is everything until first whitespace after ^
108
+ match = re.match(r'^\^(\S+)(?:\s+(.*))?$', prompt, re.DOTALL)
109
+ if not match:
110
+ return None, "Invalid prefix. Use ^E<N> to end, ^S<N> to select, or ^0 <desc> for new context."
111
+
112
+ command_str = match.group(1)
113
+ remaining = (match.group(2) or "").strip()
114
+
115
+ # Handle backwards compat: ^N where N is just a number (shorthand for ^SN)
116
+ if command_str.isdigit():
117
+ num = int(command_str)
118
+ if num == 0:
119
+ # ^0 <description> - create new context
120
+ if len(remaining) < MIN_NEW_CONTEXT_CHARS:
121
+ return None, (
122
+ f"Please provide a longer description for your new context.\n"
123
+ f"Your description '{remaining}' is only {len(remaining)} characters.\n"
124
+ f"Minimum required: {MIN_NEW_CONTEXT_CHARS} characters.\n"
125
+ f"Example: ^0 implement user authentication with JWT tokens"
126
+ )
127
+ return CaretCommand(ends=[], select=None, new_context_desc=remaining, remaining_prompt=""), None
128
+ else:
129
+ # ^N - shorthand for select context N
130
+ if num < 1 or num > len(contexts):
131
+ if len(contexts) == 0:
132
+ return None, "No existing contexts. Use ^0 <description> to create a new one."
133
+ return None, f"Invalid selection. Choose 1-{len(contexts)} for existing contexts, or ^0 for new."
134
+ # Validate context is in "implementing" mode
135
+ ctx = contexts[num - 1]
136
+ if not ctx.in_flight or ctx.in_flight.mode != "implementing":
137
+ mode = ctx.in_flight.mode if ctx.in_flight else "none"
138
+ return None, (
139
+ f"Cannot select context {num} ({ctx.id}) - mode is '{mode}'.\n"
140
+ f"Only contexts in 'implementing' mode can be selected.\n"
141
+ f"Use ^E{num} to end this context, or ^0 <desc> to create a new one."
142
+ )
143
+ return CaretCommand(ends=[], select=num, new_context_desc=None, remaining_prompt=remaining), None
144
+
145
+ # Parse chained commands: E<N>, S<N>, etc.
146
+ ends = []
147
+ select = None
148
+ pos = 0
149
+
150
+ while pos < len(command_str):
151
+ if command_str[pos].upper() == 'E':
152
+ # End command
153
+ pos += 1
154
+ # Check for wildcard (E*) - end all contexts
155
+ if pos < len(command_str) and command_str[pos] == '*':
156
+ pos += 1
157
+ if len(contexts) == 0:
158
+ return None, "No contexts to end."
159
+ # Add all context numbers to ends list
160
+ for i in range(1, len(contexts) + 1):
161
+ if i not in ends:
162
+ ends.append(i)
163
+ else:
164
+ # Read number
165
+ num_start = pos
166
+ while pos < len(command_str) and command_str[pos].isdigit():
167
+ pos += 1
168
+ if num_start == pos:
169
+ return None, f"Expected number or '*' after 'E' at position {num_start + 1}"
170
+ num = int(command_str[num_start:pos])
171
+ if num < 1 or num > len(contexts):
172
+ if len(contexts) == 0:
173
+ return None, "No contexts to end."
174
+ return None, f"Context ^E{num} invalid. Choose 1-{len(contexts)}."
175
+
176
+ # Check for + suffix meaning "this and all after"
177
+ if pos < len(command_str) and command_str[pos] == '+':
178
+ pos += 1
179
+ # Add num and all higher numbers (older contexts)
180
+ for i in range(num, len(contexts) + 1):
181
+ if i not in ends:
182
+ ends.append(i)
183
+ else:
184
+ ends.append(num)
185
+
186
+ elif command_str[pos].upper() == 'S':
187
+ # Select command
188
+ pos += 1
189
+ # Read number
190
+ num_start = pos
191
+ while pos < len(command_str) and command_str[pos].isdigit():
192
+ pos += 1
193
+ if num_start == pos:
194
+ return None, f"Expected number after 'S' at position {num_start + 1}"
195
+ num = int(command_str[num_start:pos])
196
+ if num < 1 or num > len(contexts):
197
+ if len(contexts) == 0:
198
+ return None, "No contexts to select."
199
+ return None, f"Context ^S{num} invalid. Choose 1-{len(contexts)}."
200
+ # Validate context is in "implementing" mode
201
+ ctx = contexts[num - 1]
202
+ if not ctx.in_flight or ctx.in_flight.mode != "implementing":
203
+ mode = ctx.in_flight.mode if ctx.in_flight else "none"
204
+ return None, (
205
+ f"Cannot select context {num} ({ctx.id}) - mode is '{mode}'.\n"
206
+ f"Only contexts in 'implementing' mode can be selected.\n"
207
+ f"Use ^E{num} to end this context, or ^0 <desc> to create a new one."
208
+ )
209
+ # Only first S counts
210
+ if select is None:
211
+ select = num
212
+
213
+ else:
214
+ return None, (
215
+ f"Invalid command '{command_str[pos]}' at position {pos + 1}.\n"
216
+ f"Use E<N> to end, E<N>+ to end N and after, E* to end all, S<N> to select.\n"
217
+ f"Example: ^E1S2 (end 1, select 2), ^E2+ (end 2 and older), ^E* (end all)"
218
+ )
219
+
220
+ # Validate: can't select a context that's being ended
221
+ if select is not None and select in ends:
222
+ return None, f"Cannot select context {select} because it's being ended."
223
+
224
+ return CaretCommand(ends=ends, select=select, new_context_desc=None, remaining_prompt=remaining), None
225
+
226
+
227
+ class BlockRequest(Exception):
228
+ """Raised when the request should be blocked with a message to the user."""
229
+ def __init__(self, message: str):
230
+ self.message = message
231
+ super().__init__(message)
232
+
233
+
234
+ def format_context_picker_stderr(contexts: List[Context]) -> str:
235
+ """
236
+ Format context picker for stderr output (visible to user when blocking).
237
+
238
+ Args:
239
+ contexts: Available contexts to choose from
240
+
241
+ Returns:
242
+ Formatted picker message
243
+ """
244
+ lines = [
245
+ "",
246
+ "+----------------------------------------------------------------+",
247
+ "| CONTEXT SELECTION REQUIRED |",
248
+ "+----------------------------------------------------------------+",
249
+ ]
250
+
251
+ implementing_count = 0
252
+ for i, ctx in enumerate(contexts, 1):
253
+ time_str = _format_relative_time(ctx.last_active)
254
+
255
+ # Check if context is in implementing mode (selectable)
256
+ is_implementing = ctx.in_flight and ctx.in_flight.mode == "implementing"
257
+ if is_implementing:
258
+ implementing_count += 1
259
+
260
+ # Add status indicator for in-flight work
261
+ status = ""
262
+ if ctx.in_flight and ctx.in_flight.mode != "none":
263
+ mode_display = get_mode_display(ctx.in_flight.mode)
264
+ if mode_display:
265
+ status = f" {mode_display}"
266
+
267
+ # Truncate summary for display
268
+ summary = ctx.summary[:45] + "..." if len(ctx.summary) > 48 else ctx.summary
269
+
270
+ # Show selectable indicator
271
+ selectable = " [selectable]" if is_implementing else " [end only]"
272
+ lines.append(f"| ^{i} {ctx.id}{status}{selectable}")
273
+ lines.append(f"| {summary}")
274
+ lines.append(f"| [{time_str}]")
275
+ lines.append("|")
276
+
277
+ lines.extend([
278
+ "+----------------------------------------------------------------+",
279
+ "| Usage: |",
280
+ "| ^S<N> - Select context (implementing only) |",
281
+ "| ^E<N> - End/complete context |",
282
+ "| ^E<N>+ - End context N and all after |",
283
+ "| ^E* - End ALL contexts |",
284
+ "| ^E1E2S3 - End #1 and #2, select #3 |",
285
+ "| ^0 work description - Create new context (10+ chars) |",
286
+ "+----------------------------------------------------------------+",
287
+ ])
288
+
289
+ if implementing_count == 0:
290
+ lines.extend([
291
+ "| NOTE: No contexts in 'implementing' mode. |",
292
+ "| Use ^E<N> to end old contexts, then ^0 to create new. |",
293
+ "+----------------------------------------------------------------+",
294
+ ])
295
+
296
+ lines.append("")
297
+
298
+ return "\n".join(lines)
299
+
300
+
301
+ def format_command_feedback(ended_contexts: List[Context], selected_context: Optional[Context]) -> str:
302
+ """
303
+ Format feedback about what context operations were performed.
304
+
305
+ Args:
306
+ ended_contexts: Contexts that were ended/completed
307
+ selected_context: Context that was selected (if any)
308
+
309
+ Returns:
310
+ Formatted feedback message
311
+ """
312
+ lines = []
313
+
314
+ if ended_contexts:
315
+ lines.append("## Contexts Ended")
316
+ lines.append("")
317
+ for ctx in ended_contexts:
318
+ lines.append(f"- **{ctx.id}**: {ctx.summary[:50]}{'...' if len(ctx.summary) > 50 else ''}")
319
+ lines.append("")
320
+
321
+ if selected_context:
322
+ lines.append(f"## Active Context: {selected_context.id}")
323
+ lines.append("")
324
+ lines.append(f"**Summary:** {selected_context.summary}")
325
+
326
+ # Build mode display
327
+ mode_display = "Active"
328
+ if selected_context.in_flight and selected_context.in_flight.mode != "none":
329
+ mode_str = get_mode_display(selected_context.in_flight.mode)
330
+ if mode_str:
331
+ mode_display = mode_str.strip("[]")
332
+
333
+ time_str = _format_relative_time(selected_context.last_active)
334
+ lines.append(f"**Mode:** {mode_display}")
335
+ lines.append(f"**Last Active:** {time_str}")
336
+ lines.append("")
337
+ lines.append(f'All work belongs to context "{selected_context.id}".')
338
+ lines.append("Tasks created with TaskCreate will be persisted to this context.")
339
+
340
+ return "\n".join(lines)
341
+
342
+
343
+ def determine_context(
344
+ user_prompt: str,
345
+ project_root: Path = None,
346
+ session_id: str = None
347
+ ) -> Tuple[Optional[str], str, Optional[str]]:
348
+ """
349
+ Determine which context this prompt belongs to.
350
+
351
+ Returns:
352
+ Tuple of:
353
+ - context_id: Context ID or None if selection needed
354
+ - method: How context was determined (session_match, in_flight, caret_select,
355
+ auto_created, single_context, blocked)
356
+ - output: System reminder to inject, or None
357
+
358
+ Raises:
359
+ BlockRequest: When request should be blocked to show picker to user
360
+ """
361
+ # 1. Check if session already belongs to a context (HIGHEST PRIORITY)
362
+ # This prevents context switching on subsequent prompts - one context per session
363
+ if session_id:
364
+ session_context = get_context_by_session_id(session_id, project_root)
365
+ if session_context:
366
+ eprint(f"[context_enforcer] Session already in context: {session_context.id}")
367
+ return (
368
+ session_context.id,
369
+ "session_match",
370
+ format_active_context_reminder(session_context)
371
+ )
372
+
373
+ # 2. Check for bare "^" - show context picker
374
+ if user_prompt.strip() == "^":
375
+ contexts = get_all_contexts(status="active", project_root=project_root)
376
+ if not contexts:
377
+ raise BlockRequest(
378
+ "No contexts exist.\n\n"
379
+ "Just type your task to start a new context.\n"
380
+ "Example: implement user authentication system"
381
+ )
382
+ raise BlockRequest(format_context_picker_stderr(contexts))
383
+
384
+ # 3. Check for explicit caret commands (^E, ^S, ^0, ^N)
385
+ if user_prompt.startswith("^"):
386
+ contexts = get_all_contexts(status="active", project_root=project_root)
387
+ return _handle_caret_command(user_prompt, contexts, project_root)
388
+
389
+ # 4. No caret prefix - check in-flight contexts for auto-selection
390
+ in_flight_contexts = get_all_in_flight_contexts(project_root)
391
+
392
+ if len(in_flight_contexts) == 0:
393
+ # No in-flight work - auto-create new context from prompt
394
+
395
+ # Skip auto-creation for certain prompts that don't represent work
396
+ skip_patterns = [
397
+ "/help", "/clear", "/status", "hello", "hi", "hey",
398
+ "thanks", "thank you", "bye", "goodbye"
399
+ ]
400
+ prompt_lower = user_prompt.lower().strip()
401
+
402
+ # Don't auto-create for greetings or help commands
403
+ if any(prompt_lower.startswith(p) or prompt_lower == p for p in skip_patterns):
404
+ return (None, "no_context_needed", None)
405
+
406
+ # Auto-create context from prompt
407
+ try:
408
+ new_context = create_context_from_prompt(user_prompt, project_root)
409
+ # Set to implementing mode so it can be selected
410
+ update_plan_status(new_context.id, "implementing", project_root=project_root)
411
+ new_context.in_flight.mode = "implementing" # Update local copy for display
412
+ eprint(f"[context_enforcer] Auto-created new context: {new_context.id}")
413
+ return (
414
+ new_context.id,
415
+ "auto_created",
416
+ format_context_created(new_context)
417
+ )
418
+ except Exception as e:
419
+ eprint(f"[context_enforcer] Failed to create context: {e}")
420
+ return (None, "creation_failed", None)
421
+
422
+ elif len(in_flight_contexts) == 1:
423
+ # Single in-flight context - auto-select it
424
+ ctx = in_flight_contexts[0]
425
+ mode = ctx.in_flight.mode if ctx.in_flight else "none"
426
+ eprint(f"[context_enforcer] Auto-selected single in-flight context: {ctx.id} (mode={mode})")
427
+
428
+ # Use mode-specific formatter for better continuation context
429
+ if mode == "handoff_pending":
430
+ output = format_handoff_continuation(ctx)
431
+ elif mode == "pending_implementation":
432
+ output = format_pending_plan_continuation(ctx)
433
+ elif mode == "implementing":
434
+ output = format_implementation_continuation(ctx)
435
+ else:
436
+ output = format_active_context_reminder(ctx)
437
+
438
+ return (ctx.id, "auto_selected", output)
439
+
440
+ else:
441
+ # Multiple in-flight contexts - block and show picker
442
+ eprint(f"[context_enforcer] Multiple in-flight contexts ({len(in_flight_contexts)}), showing picker")
443
+ raise BlockRequest(
444
+ f"Multiple contexts have in-flight work ({len(in_flight_contexts)} active).\n"
445
+ "Select one to continue, or use ^ to see all contexts:\n" +
446
+ format_context_picker_stderr(in_flight_contexts)
447
+ )
448
+
449
+
450
+ def _handle_caret_command(
451
+ user_prompt: str,
452
+ contexts: List[Context],
453
+ project_root: Path
454
+ ) -> Tuple[Optional[str], str, Optional[str]]:
455
+ """
456
+ Handle explicit caret commands (^E, ^S, ^0, ^N).
457
+
458
+ Args:
459
+ user_prompt: User's prompt starting with ^
460
+ contexts: List of active contexts
461
+ project_root: Project root directory
462
+
463
+ Returns:
464
+ Tuple of (context_id, method, output)
465
+
466
+ Raises:
467
+ BlockRequest: When command is invalid or selection needed
468
+ """
469
+ # No contexts case - only ^0 is valid
470
+ if not contexts:
471
+ match = re.match(r'^\^(\S+)(?:\s+(.*))?$', user_prompt, re.DOTALL)
472
+ if not match:
473
+ raise BlockRequest(
474
+ "Invalid prefix. Use ^0 <description> to create a new context.\n"
475
+ "Example: ^0 implement user authentication system"
476
+ )
477
+
478
+ prefix_value = match.group(1)
479
+ remaining = match.group(2) or ""
480
+
481
+ # Must be ^0 for new context
482
+ if not prefix_value.isdigit() or int(prefix_value) != 0:
483
+ raise BlockRequest(
484
+ f"No existing contexts to select. Use ^0 <description> to create a new context.\n"
485
+ f"Example: ^0 implement user authentication system"
486
+ )
487
+
488
+ description = remaining.strip()
489
+ if len(description) < MIN_NEW_CONTEXT_CHARS:
490
+ raise BlockRequest(
491
+ f"Please provide a longer description for your new context.\n"
492
+ f"Your description '{description}' is only {len(description)} characters.\n"
493
+ f"Minimum required: {MIN_NEW_CONTEXT_CHARS} characters.\n"
494
+ f"Example: ^0 implement user authentication with JWT tokens"
495
+ )
496
+ try:
497
+ new_context = create_context_from_prompt(description, project_root)
498
+ update_plan_status(new_context.id, "implementing", project_root=project_root)
499
+ new_context.in_flight.mode = "implementing"
500
+ eprint(f"[context_enforcer] Created context from ^0: {new_context.id}")
501
+ return (
502
+ new_context.id,
503
+ "caret_new",
504
+ format_context_created(new_context)
505
+ )
506
+ except Exception as e:
507
+ eprint(f"[context_enforcer] Failed to create context: {e}")
508
+ raise BlockRequest(f"Failed to create context: {e}")
509
+
510
+ # Parse caret commands
511
+ cmd, error = parse_chained_caret(user_prompt, contexts)
512
+
513
+ if error:
514
+ raise BlockRequest(error + "\n" + format_context_picker_stderr(contexts))
515
+
516
+ if not cmd:
517
+ # Should not happen - user_prompt starts with ^ but didn't parse
518
+ raise BlockRequest(format_context_picker_stderr(contexts))
519
+
520
+ # Process chained commands
521
+ ended_contexts = []
522
+
523
+ # End specified contexts
524
+ for end_num in cmd.ends:
525
+ ctx_to_end = contexts[end_num - 1] # 1-indexed
526
+ complete_context(ctx_to_end.id, project_root)
527
+ ended_contexts.append(ctx_to_end)
528
+ eprint(f"[context_enforcer] Ended context: {ctx_to_end.id}")
529
+
530
+ # Handle new context creation
531
+ if cmd.new_context_desc:
532
+ try:
533
+ new_context = create_context_from_prompt(cmd.new_context_desc, project_root)
534
+ update_plan_status(new_context.id, "implementing", project_root=project_root)
535
+ new_context.in_flight.mode = "implementing"
536
+ eprint(f"[context_enforcer] Created context from ^0: {new_context.id}")
537
+ output = format_command_feedback(ended_contexts, new_context)
538
+ return (
539
+ new_context.id,
540
+ "caret_new",
541
+ output
542
+ )
543
+ except Exception as e:
544
+ eprint(f"[context_enforcer] Failed to create context: {e}")
545
+ raise BlockRequest(f"Failed to create context: {e}")
546
+
547
+ # Handle context selection
548
+ if cmd.select:
549
+ selected_ctx = contexts[cmd.select - 1] # 1-indexed
550
+ eprint(f"[context_enforcer] Caret-selected context: {selected_ctx.id}")
551
+ output = format_command_feedback(ended_contexts, selected_ctx)
552
+ return (
553
+ selected_ctx.id,
554
+ "caret_select",
555
+ output
556
+ )
557
+
558
+ # Only ended contexts, no selection - refresh context list and block
559
+ if ended_contexts:
560
+ remaining_contexts = get_all_contexts(status="active", project_root=project_root)
561
+ feedback = format_command_feedback(ended_contexts, None)
562
+ if not remaining_contexts:
563
+ raise BlockRequest(
564
+ feedback + "\n" +
565
+ "All contexts have been ended. No context selected.\n\n"
566
+ "Just type your task to start a new context.\n"
567
+ "Example: implement user authentication system"
568
+ )
569
+ raise BlockRequest(
570
+ feedback + "\n" +
571
+ "No context selected.\n\n" +
572
+ "Select a context to continue:\n" +
573
+ format_context_picker_stderr(remaining_contexts)
574
+ )
575
+
576
+ # Parsed but nothing to do - shouldn't happen
577
+ raise BlockRequest(format_context_picker_stderr(contexts))
578
+
579
+
580
+ def main():
581
+ """
582
+ Standalone entry point for testing.
583
+
584
+ In production, use user_prompt_submit.py as the unified entry point.
585
+ """
586
+ try:
587
+ input_data = sys.stdin.read().strip()
588
+ if not input_data:
589
+ return
590
+
591
+ hook_input = json.loads(input_data)
592
+ user_prompt = hook_input.get("prompt", "")
593
+ if not user_prompt:
594
+ return
595
+
596
+ project_root = project_dir(hook_input)
597
+
598
+ try:
599
+ context_id, method, output = determine_context(user_prompt, project_root)
600
+ eprint(f"[context_enforcer] Method: {method}, Context: {context_id}")
601
+
602
+ if output:
603
+ print(output)
604
+
605
+ except BlockRequest as e:
606
+ # Block the request - print to stderr and exit with code 2
607
+ print(e.message, file=sys.stderr)
608
+ sys.exit(2)
609
+
610
+ except Exception as e:
611
+ eprint(f"[context_enforcer] ERROR: {e}")
612
+ import traceback
613
+ eprint(traceback.format_exc())
614
+
615
+
616
+ if __name__ == "__main__":
617
+ main()
618
+
619
+
620
+ # Export for use by unified hook
621
+ __all__ = ["determine_context", "BlockRequest"]