aiwcli 0.9.8 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/bin/run.js +5 -2
  2. package/dist/lib/claude-settings-types.d.ts +2 -0
  3. package/dist/templates/CLAUDE.md +3 -3
  4. package/dist/templates/_shared/.claude/settings.json +4 -0
  5. package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  7. package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
  8. package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
  9. package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
  10. package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
  11. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  12. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  13. package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
  14. package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
  15. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  16. package/dist/templates/_shared/hooks/archive_plan.py +87 -178
  17. package/dist/templates/_shared/hooks/context_monitor.py +104 -247
  18. package/dist/templates/_shared/hooks/file-suggestion.py +26 -23
  19. package/dist/templates/_shared/hooks/pre_compact.py +47 -32
  20. package/dist/templates/_shared/hooks/session_end.py +103 -60
  21. package/dist/templates/_shared/hooks/session_start.py +110 -81
  22. package/dist/templates/_shared/hooks/task_create_capture.py +26 -50
  23. package/dist/templates/_shared/hooks/task_update_capture.py +42 -115
  24. package/dist/templates/_shared/hooks/user_prompt_submit.py +61 -61
  25. package/dist/templates/_shared/lib/base/__init__.py +16 -0
  26. package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
  28. package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
  29. package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
  30. package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
  31. package/dist/templates/_shared/lib/base/hook_utils.py +199 -11
  32. package/dist/templates/_shared/lib/base/inference.py +121 -0
  33. package/dist/templates/_shared/lib/base/logger.py +291 -0
  34. package/dist/templates/_shared/lib/base/utils.py +42 -9
  35. package/dist/templates/_shared/lib/context/__init__.py +72 -80
  36. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  38. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  39. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  40. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  41. package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
  42. package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
  43. package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
  44. package/dist/templates/_shared/lib/context/context_formatter.py +316 -0
  45. package/dist/templates/_shared/lib/context/context_selector.py +491 -0
  46. package/dist/templates/_shared/lib/context/context_store.py +636 -0
  47. package/dist/templates/_shared/lib/context/plan_manager.py +204 -0
  48. package/dist/templates/_shared/lib/context/task_tracker.py +188 -0
  49. package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
  50. package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
  51. package/dist/templates/_shared/lib/handoff/document_generator.py +14 -40
  52. package/dist/templates/_shared/lib/templates/README.md +5 -13
  53. package/dist/templates/_shared/lib/templates/__init__.py +2 -6
  54. package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
  56. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  57. package/dist/templates/_shared/lib/templates/plan_context.py +1 -38
  58. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  59. package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
  60. package/dist/templates/_shared/scripts/save_handoff.py +39 -19
  61. package/dist/templates/_shared/scripts/status_line.py +701 -0
  62. package/dist/templates/_shared/workflows/handoff.md +9 -3
  63. package/dist/templates/cc-native/.claude/settings.json +41 -8
  64. package/dist/templates/cc-native/CC-NATIVE-README.md +25 -28
  65. package/dist/templates/cc-native/MIGRATION.md +1 -1
  66. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +14 -39
  67. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +49 -21
  68. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  69. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  70. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
  71. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
  72. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
  73. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
  74. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +57 -55
  75. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +163 -131
  76. package/dist/templates/cc-native/_cc-native/hooks/plan_accepted.py +127 -0
  77. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +81 -0
  78. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +26 -25
  79. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +6 -4
  80. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  82. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  83. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  84. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  85. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  86. package/dist/templates/cc-native/_cc-native/lib/debug.py +37 -22
  87. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +34 -29
  88. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  89. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  90. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  91. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  92. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  93. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +26 -21
  94. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +12 -7
  95. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +12 -7
  96. package/dist/templates/cc-native/_cc-native/lib/state.py +31 -16
  97. package/dist/templates/cc-native/_cc-native/lib/utils.py +207 -40
  98. package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -2
  99. package/oclif.manifest.json +1 -1
  100. package/package.json +1 -1
  101. package/dist/templates/_shared/hooks/context_enforcer.py +0 -625
  102. package/dist/templates/_shared/hooks/task_create_atomicity.py +0 -177
  103. package/dist/templates/_shared/lib/context/auto_state.py +0 -167
  104. package/dist/templates/_shared/lib/context/cache.py +0 -444
  105. package/dist/templates/_shared/lib/context/context_extractor.py +0 -115
  106. package/dist/templates/_shared/lib/context/context_manager.py +0 -1057
  107. package/dist/templates/_shared/lib/context/discovery.py +0 -554
  108. package/dist/templates/_shared/lib/context/event_log.py +0 -316
  109. package/dist/templates/_shared/lib/context/plan_archive.py +0 -101
  110. package/dist/templates/_shared/lib/context/task_sync.py +0 -407
  111. package/dist/templates/_shared/lib/templates/persona_questions.py +0 -113
  112. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  113. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
  114. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
  115. package/dist/templates/cc-native/_cc-native/lib/async_archive.py +0 -68
  116. package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +0 -98
@@ -0,0 +1,491 @@
1
+ """Context selection module - determines which context a prompt belongs to.
2
+
3
+ Single entry point: determine_context(prompt, session_id, project_root)
4
+ Returns (context_id, method, output_text).
5
+
6
+ Selection priority:
7
+ 1. session_match - session_id found in index.json sessions map
8
+ 2. caret_command - prompt starts with ^ -> parse and execute
9
+ 3. plan_content_match - FALLBACK: match against has_plan contexts via hash/signature
10
+ 4. default - create new context
11
+
12
+ Note: The primary plan restore path is now session_start.py which handles
13
+ SessionStart(source=clear). It finds has_plan contexts and binds the new
14
+ session before UserPromptSubmit fires. Case 3 here is a fallback for edge
15
+ cases where session_start didn't consume the has_plan state (e.g., startup/resume).
16
+ """
17
+ import hashlib
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import List, Optional, Tuple
22
+
23
+ from .context_store import (
24
+ ContextState,
25
+ get_context,
26
+ get_all_contexts,
27
+ get_context_by_session_id,
28
+ create_context_from_prompt,
29
+ create_context,
30
+ complete_context,
31
+ bind_session,
32
+ update_mode,
33
+ )
34
+ from .context_formatter import (
35
+ format_active_context_reminder,
36
+ format_context_created,
37
+ format_context_picker_stderr,
38
+ format_command_feedback,
39
+ format_handoff_continuation,
40
+ format_plan_continuation,
41
+ format_active_continuation,
42
+ )
43
+ from .plan_manager import normalize_plan_content
44
+ from ..base.subprocess_utils import is_internal_call
45
+ from ..base.logger import log_debug, log_info, log_warn, log_error
46
+
47
+ # Minimum characters required for new context description
48
+ MIN_NEW_CONTEXT_CHARS = 10
49
+
50
+
51
+ class BlockRequest(Exception):
52
+ """Raised when the request should be blocked with a message to user."""
53
+ def __init__(self, message: str):
54
+ self.message = message
55
+ super().__init__(message)
56
+
57
+
58
+ @dataclass
59
+ class CaretCommand:
60
+ """Parsed caret command result."""
61
+ ends: List[str] # Context IDs to end (race-safe)
62
+ select: Optional[str] # Context ID to select (race-safe)
63
+ new_context_desc: Optional[str] # Description for new context (^0)
64
+ remaining_prompt: str # The remaining prompt after the command
65
+
66
+
67
+ def resolve_context_by_prefix(query: str, contexts: List[ContextState]) -> Tuple[Optional[int], Optional[str]]:
68
+ """Resolve a context ID query to an index (1-based) using tiered matching.
69
+
70
+ Match priority: exact > prefix > substring (all case-insensitive).
71
+ Returns (index, None) on unique match, (None, error) on 0 or 2+ matches.
72
+ """
73
+ q = query.lower()
74
+ available = ', '.join(c.id for c in contexts)
75
+
76
+ # Tier 1: Exact match
77
+ exact = [(i, ctx) for i, ctx in enumerate(contexts, 1) if ctx.id.lower() == q]
78
+ if len(exact) == 1:
79
+ return exact[0][0], None
80
+
81
+ # Tier 2: Prefix match
82
+ prefix = [(i, ctx) for i, ctx in enumerate(contexts, 1) if ctx.id.lower().startswith(q)]
83
+ if len(prefix) == 1:
84
+ return prefix[0][0], None
85
+ if len(prefix) > 1:
86
+ return None, f"Ambiguous match '{query}' — {len(prefix)} prefix matches: {', '.join(c.id for _, c in prefix)}. Be more specific."
87
+
88
+ # Tier 3: Substring match
89
+ substr = [(i, ctx) for i, ctx in enumerate(contexts, 1) if q in ctx.id.lower()]
90
+ if len(substr) == 1:
91
+ return substr[0][0], None
92
+ if len(substr) > 1:
93
+ return None, f"Ambiguous match '{query}' — {len(substr)} substring matches: {', '.join(c.id for _, c in substr)}. Be more specific."
94
+
95
+ return None, f"No context matches '{query}'. Available: {available}"
96
+
97
+
98
+ def parse_chained_caret(prompt: str, contexts: List[ContextState]) -> Tuple[Optional[CaretCommand], Optional[str]]:
99
+ """Parse chained caret commands from user prompt.
100
+
101
+ Syntax:
102
+ - ^E<N>: End context N
103
+ - ^E<N>+: End context N and all after
104
+ - ^E*: End ALL contexts
105
+ - ^S<N>: Select context N
106
+ - ^0 <desc>: Create new context
107
+ - ^<N>: Shorthand for ^S<N>
108
+ - ^E:query / ^S:query: End/select by ID prefix match (race-safe)
109
+ - Chain: ^E1E2S3 means end 1, end 2, select 3
110
+ """
111
+ if not prompt.startswith("^"):
112
+ return None, None
113
+
114
+ match = re.match(r'^\^(\S+)(?:\s+(.*))?$', prompt, re.DOTALL)
115
+ if not match:
116
+ return None, "Invalid prefix. Use ^E<N> to end, ^S<N> to select, or ^0 <desc> for new context."
117
+
118
+ command_str = match.group(1)
119
+ remaining = (match.group(2) or "").strip()
120
+
121
+ # ^N shorthand
122
+ if command_str.isdigit():
123
+ num = int(command_str)
124
+ if num == 0:
125
+ if len(remaining) < MIN_NEW_CONTEXT_CHARS:
126
+ return None, (
127
+ f"Please provide a longer description for your new context.\n"
128
+ f"Your description '{remaining}' is only {len(remaining)} characters.\n"
129
+ f"Minimum required: {MIN_NEW_CONTEXT_CHARS} characters.\n"
130
+ f"Example: ^0 implement user authentication with JWT tokens"
131
+ )
132
+ return CaretCommand(ends=[], select=None, new_context_desc=remaining, remaining_prompt=""), None
133
+ else:
134
+ if num < 1 or num > len(contexts):
135
+ if not contexts:
136
+ return None, "No existing contexts. Use ^0 <description> to create a new one."
137
+ return None, f"Invalid selection. Choose 1-{len(contexts)} for existing contexts, or ^0 for new."
138
+ ctx = contexts[num - 1]
139
+ return CaretCommand(ends=[], select=ctx.id, new_context_desc=None, remaining_prompt=remaining), None
140
+
141
+ # Parse chained commands
142
+ ends = []
143
+ select = None
144
+ pos = 0
145
+
146
+ while pos < len(command_str):
147
+ if command_str[pos].upper() == 'E':
148
+ pos += 1
149
+ if pos < len(command_str) and command_str[pos] == '*':
150
+ pos += 1
151
+ if not contexts:
152
+ return None, "No contexts to end."
153
+ for ctx in contexts:
154
+ if ctx.id not in ends:
155
+ ends.append(ctx.id)
156
+ elif pos < len(command_str) and command_str[pos] == ':':
157
+ pos += 1
158
+ prefix_start = pos
159
+ while pos < len(command_str) and command_str[pos] not in ('E', 'S', 'e', 's'):
160
+ pos += 1
161
+ pfx = command_str[prefix_start:pos]
162
+ if not pfx:
163
+ return None, "Expected ID query after 'E:'"
164
+ idx, err = resolve_context_by_prefix(pfx, contexts)
165
+ if err:
166
+ return None, err
167
+ ctx = contexts[idx - 1]
168
+ if ctx.id not in ends:
169
+ ends.append(ctx.id)
170
+ else:
171
+ num_start = pos
172
+ while pos < len(command_str) and command_str[pos].isdigit():
173
+ pos += 1
174
+ if num_start == pos:
175
+ return None, f"Expected number, '*', or ':prefix' after 'E' at position {num_start + 1}"
176
+ num = int(command_str[num_start:pos])
177
+ if num < 1 or num > len(contexts):
178
+ if not contexts:
179
+ return None, "No contexts to end."
180
+ return None, f"Context ^E{num} invalid. Choose 1-{len(contexts)}."
181
+ if pos < len(command_str) and command_str[pos] == '+':
182
+ pos += 1
183
+ for i in range(num, len(contexts) + 1):
184
+ ctx = contexts[i - 1]
185
+ if ctx.id not in ends:
186
+ ends.append(ctx.id)
187
+ else:
188
+ ctx = contexts[num - 1]
189
+ if ctx.id not in ends:
190
+ ends.append(ctx.id)
191
+
192
+ elif command_str[pos].upper() == 'S':
193
+ pos += 1
194
+ if pos < len(command_str) and command_str[pos] == ':':
195
+ pos += 1
196
+ prefix_start = pos
197
+ while pos < len(command_str) and command_str[pos] not in ('E', 'S', 'e', 's'):
198
+ pos += 1
199
+ pfx = command_str[prefix_start:pos]
200
+ if not pfx:
201
+ return None, "Expected ID query after 'S:'"
202
+ idx, err = resolve_context_by_prefix(pfx, contexts)
203
+ if err:
204
+ return None, err
205
+ ctx = contexts[idx - 1]
206
+ else:
207
+ num_start = pos
208
+ while pos < len(command_str) and command_str[pos].isdigit():
209
+ pos += 1
210
+ if num_start == pos:
211
+ return None, f"Expected number or ':prefix' after 'S' at position {num_start + 1}"
212
+ num = int(command_str[num_start:pos])
213
+ if num < 1 or num > len(contexts):
214
+ if not contexts:
215
+ return None, "No contexts to select."
216
+ return None, f"Context ^S{num} invalid. Choose 1-{len(contexts)}."
217
+ ctx = contexts[num - 1]
218
+ if select is None:
219
+ select = ctx.id
220
+
221
+ else:
222
+ return None, (
223
+ f"Invalid command '{command_str[pos]}' at position {pos + 1}.\n"
224
+ f"Use E<N> to end, E<N>+ to end N and after, E* to end all, S<N> to select.\n"
225
+ f"Example: ^E1S2 (end 1, select 2), ^E2+ (end 2 and older), ^E* (end all)"
226
+ )
227
+
228
+ if select is not None and select in ends:
229
+ return None, f"Cannot select context '{select}' because it's being ended."
230
+
231
+ return CaretCommand(ends=ends, select=select, new_context_desc=None, remaining_prompt=remaining), None
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Plan content matching (fallback — primary path is session_start.py)
236
+ # ---------------------------------------------------------------------------
237
+
238
+ def _match_plan_content(prompt: str, has_plan_contexts: List[ContextState]) -> Optional[ContextState]:
239
+ """Fallback plan matching for edge cases where session_start didn't consume has_plan.
240
+
241
+ The primary plan restore path is session_start.py (SessionStart source=clear).
242
+ This fallback handles cases like startup/resume where has_plan persisted.
243
+
244
+ Tiers (cascading):
245
+ 1. Embedded plan-id (HTML comment)
246
+ 2. Normalized content hash
247
+ 3. Multi-anchor signature
248
+ 4. Legacy signature fallback
249
+ """
250
+ if not has_plan_contexts:
251
+ return None
252
+
253
+ # Tier 1: Plan ID match (most reliable)
254
+ id_match = re.search(r'<!-- plan-id: ([a-f0-9]+) -->', prompt)
255
+ if id_match:
256
+ found_id = id_match.group(1)
257
+ for ctx in has_plan_contexts:
258
+ if getattr(ctx, 'plan_id', None) == found_id:
259
+ log_debug("context_selector", f"Tier 1 plan-id match: {ctx.id} (id: {found_id})")
260
+ return ctx
261
+
262
+ # Tier 2: Normalized hash match
263
+ normalized = normalize_plan_content(prompt)
264
+ norm_hash = hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:12]
265
+ for ctx in has_plan_contexts:
266
+ if ctx.plan_hash and ctx.plan_hash == norm_hash:
267
+ log_debug("context_selector", f"Tier 2 normalized hash match: {ctx.id} (hash: {norm_hash})")
268
+ return ctx
269
+
270
+ # Tier 3: Multi-anchor signature match
271
+ for ctx in has_plan_contexts:
272
+ anchors = getattr(ctx, 'plan_anchors', [])
273
+ if anchors:
274
+ hits = sum(1 for a in anchors if a in prompt)
275
+ if hits >= 2 and hits >= len(anchors) // 2:
276
+ log_debug("context_selector", f"Tier 3 anchor match: {ctx.id} ({hits}/{len(anchors)} anchors)")
277
+ return ctx
278
+
279
+ # Tier 4 (legacy fallback): Signature match for pre-upgrade contexts
280
+ prompt_head = prompt[:500]
281
+ for ctx in has_plan_contexts:
282
+ if ctx.plan_signature and ctx.plan_signature in prompt_head:
283
+ log_debug("context_selector", f"Tier 4 legacy signature match: {ctx.id}")
284
+ return ctx
285
+
286
+ # No match — let caller fall through to new context creation
287
+ return None
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Context creation helper
292
+ # ---------------------------------------------------------------------------
293
+
294
+ def _create_new_context(prompt: str, project_root: Path) -> Tuple[Optional[str], str, Optional[str]]:
295
+ """Create a new context from the user's prompt (case 5: default)."""
296
+ try:
297
+ new_ctx = create_context_from_prompt(prompt, project_root)
298
+ update_mode(new_ctx.id, "active", project_root=project_root)
299
+ new_ctx.mode = "active"
300
+ log_info("context_selector", f"Auto-created context: {new_ctx.id}")
301
+ return (new_ctx.id, "auto_created", format_context_created(new_ctx))
302
+ except Exception as e:
303
+ log_error("context_selector", f"Primary context creation failed: {e}")
304
+ try:
305
+ from datetime import datetime
306
+ fallback_id = datetime.now().strftime("%y%m%d-%H%M") + "-context"
307
+ new_ctx = create_context(
308
+ context_id=fallback_id,
309
+ summary=prompt.strip()[:200] or "New context",
310
+ method="auto-created-fallback",
311
+ tags=["auto-created", "fallback"],
312
+ project_root=project_root,
313
+ )
314
+ update_mode(new_ctx.id, "active", project_root=project_root)
315
+ new_ctx.mode = "active"
316
+ log_info("context_selector", f"Fallback context created: {new_ctx.id}")
317
+ return (new_ctx.id, "auto_created_fallback", format_context_created(new_ctx))
318
+ except Exception as e2:
319
+ log_error("context_selector", f"ALL context creation failed: {e2}")
320
+ return (None, "creation_failed", None)
321
+
322
+
323
+ # ---------------------------------------------------------------------------
324
+ # Caret command handler
325
+ # ---------------------------------------------------------------------------
326
+
327
+ def _handle_caret_command(
328
+ prompt: str,
329
+ contexts: List[ContextState],
330
+ project_root: Path,
331
+ ) -> Tuple[Optional[str], str, Optional[str]]:
332
+ """Handle explicit caret commands (^E, ^S, ^0, ^N).
333
+
334
+ Raises:
335
+ BlockRequest: When command is invalid or selection needed
336
+ """
337
+ if not contexts:
338
+ match = re.match(r'^\^(\S+)(?:\s+(.*))?$', prompt, re.DOTALL)
339
+ if not match:
340
+ raise BlockRequest(
341
+ "Invalid prefix. Use ^0 <description> to create a new context.\n"
342
+ "Example: ^0 implement user authentication system"
343
+ )
344
+ prefix_value = match.group(1)
345
+ remaining = match.group(2) or ""
346
+ if not prefix_value.isdigit() or int(prefix_value) != 0:
347
+ raise BlockRequest(
348
+ "No existing contexts to select. Use ^0 <description> to create a new context.\n"
349
+ "Example: ^0 implement user authentication system"
350
+ )
351
+ description = remaining.strip()
352
+ if len(description) < MIN_NEW_CONTEXT_CHARS:
353
+ raise BlockRequest(
354
+ f"Please provide a longer description for your new context.\n"
355
+ f"Your description '{description}' is only {len(description)} characters.\n"
356
+ f"Minimum required: {MIN_NEW_CONTEXT_CHARS} characters.\n"
357
+ f"Example: ^0 implement user authentication with JWT tokens"
358
+ )
359
+ return _create_new_context(description, project_root)
360
+
361
+ cmd, error = parse_chained_caret(prompt, contexts)
362
+ if error:
363
+ raise BlockRequest(error + "\n" + format_context_picker_stderr(contexts))
364
+ if not cmd:
365
+ raise BlockRequest(format_context_picker_stderr(contexts))
366
+
367
+ ended_contexts = []
368
+ for ctx_id in cmd.ends:
369
+ ctx_to_end = next((c for c in contexts if c.id == ctx_id), None)
370
+ if ctx_to_end is None:
371
+ raise BlockRequest(f"Context '{ctx_id}' no longer exists.\n" + format_context_picker_stderr(contexts))
372
+ complete_context(ctx_to_end.id, project_root)
373
+ ended_contexts.append(ctx_to_end)
374
+ log_info("context_selector", f"Ended context: {ctx_to_end.id}")
375
+
376
+ if cmd.new_context_desc:
377
+ ctx_id, method, output = _create_new_context(cmd.new_context_desc, project_root)
378
+ if ctx_id and ended_contexts:
379
+ new_ctx = get_context(ctx_id, project_root)
380
+ output = format_command_feedback(ended_contexts, new_ctx)
381
+ return (ctx_id, "caret_new" if method != "creation_failed" else method, output)
382
+
383
+ if cmd.select:
384
+ selected_ctx = next((c for c in contexts if c.id == cmd.select), None)
385
+ if selected_ctx is None:
386
+ raise BlockRequest(f"Context '{cmd.select}' no longer exists.\n" + format_context_picker_stderr(contexts))
387
+ log_info("context_selector", f"Caret-selected context: {selected_ctx.id}")
388
+ return (selected_ctx.id, "caret_select", format_command_feedback(ended_contexts, selected_ctx))
389
+
390
+ if ended_contexts:
391
+ remaining_contexts = get_all_contexts(status="active", project_root=project_root)
392
+ feedback = format_command_feedback(ended_contexts, None)
393
+ if not remaining_contexts:
394
+ raise BlockRequest(
395
+ feedback + "\nAll contexts have been ended. No context selected.\n\n"
396
+ "Just type your task to start a new context.\n"
397
+ "Example: implement user authentication system"
398
+ )
399
+ raise BlockRequest(
400
+ feedback + "\nNo context selected.\n\nSelect a context to continue:\n" +
401
+ format_context_picker_stderr(remaining_contexts)
402
+ )
403
+
404
+ raise BlockRequest(format_context_picker_stderr(contexts))
405
+
406
+
407
+ # ---------------------------------------------------------------------------
408
+ # Main entry point
409
+ # ---------------------------------------------------------------------------
410
+
411
+ def determine_context(
412
+ prompt: str,
413
+ session_id: str = None,
414
+ project_root: Path = None,
415
+ ) -> Tuple[Optional[str], str, Optional[str]]:
416
+ """Determine which context this prompt belongs to.
417
+
418
+ Selection priority (4 cases):
419
+ 1. session_match - session_id already bound to a context
420
+ 2. caret_command - prompt starts with ^, parse and execute
421
+ 3. plan_content_match - FALLBACK: match has_plan contexts via hash/signature
422
+ 4. default - create new context
423
+
424
+ Note: The primary plan restore is handled by session_start.py on
425
+ SessionStart(source=clear), which binds the session before this runs.
426
+ Case 3 is a fallback for edge cases.
427
+
428
+ Returns:
429
+ (context_id, method, output_text)
430
+
431
+ Raises:
432
+ BlockRequest: When request should be blocked to show picker
433
+ """
434
+ if is_internal_call():
435
+ log_debug("context_selector", "Skipping: internal subprocess call")
436
+ return (None, "skip_internal", None)
437
+
438
+ # --- Case 1: session_match ---
439
+ if session_id:
440
+ session_context = get_context_by_session_id(session_id, project_root)
441
+ if session_context:
442
+ log_info("context_selector", f"Session match: {session_context.id}")
443
+ return (
444
+ session_context.id,
445
+ "session_match",
446
+ format_active_context_reminder(session_context, project_root),
447
+ )
448
+
449
+ # --- Case 2: caret_command ---
450
+ if prompt.strip() == "^":
451
+ contexts = get_all_contexts(status="active", project_root=project_root)
452
+ if not contexts:
453
+ raise BlockRequest(
454
+ "No contexts exist.\n\nJust type your task to start a new context.\n"
455
+ "Example: implement user authentication system"
456
+ )
457
+ raise BlockRequest(format_context_picker_stderr(contexts))
458
+
459
+ if prompt.startswith("^"):
460
+ contexts = get_all_contexts(status="active", project_root=project_root)
461
+ return _handle_caret_command(prompt, contexts, project_root)
462
+
463
+ # --- Case 3: plan_content_match (fallback — primary path is session_start.py) ---
464
+ has_plan_contexts = [
465
+ c for c in get_all_contexts(status="active", project_root=project_root)
466
+ if c.mode == "has_plan"
467
+ ]
468
+
469
+ if has_plan_contexts:
470
+ matched = _match_plan_content(prompt, has_plan_contexts)
471
+ if matched:
472
+ if session_id:
473
+ bind_session(matched.id, session_id, project_root)
474
+
475
+ update_mode(matched.id, "active", project_root=project_root)
476
+ matched.mode = "active"
477
+
478
+ log_info("context_selector", f"Plan match (fallback): {matched.id}")
479
+ return (matched.id, "plan_content_match", format_plan_continuation(matched, project_root))
480
+
481
+ # --- Case 4: default ---
482
+ return _create_new_context(prompt, project_root)
483
+
484
+
485
+ __all__ = [
486
+ "determine_context",
487
+ "BlockRequest",
488
+ "CaretCommand",
489
+ "resolve_context_by_prefix",
490
+ "parse_chained_caret",
491
+ ]