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,1171 @@
1
+ """Context manager for AIW CLI templates.
2
+
3
+ Provides CRUD operations for contexts with event sourcing.
4
+ All operations append events to events.jsonl (source of truth)
5
+ and update cache files (context.json, index.json).
6
+
7
+ Data hierarchy:
8
+ events.jsonl (source of truth) - append only
9
+ → context.json (L1 cache) - updated in place
10
+ → index.json (L2 cache) - updated in place
11
+ """
12
+ import json
13
+ from dataclasses import dataclass, asdict
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ import shutil
18
+
19
+ from ..base.atomic_write import atomic_write, atomic_append
20
+ from ..base.constants import (
21
+ get_context_dir,
22
+ get_contexts_dir,
23
+ get_context_file_path,
24
+ get_events_file_path,
25
+ get_index_path,
26
+ get_archive_dir,
27
+ get_archive_context_dir,
28
+ get_archive_index_path,
29
+ validate_context_id,
30
+ )
31
+ from ..base.utils import eprint, now_iso, generate_context_id
32
+ from .event_log import (
33
+ append_event,
34
+ get_current_state,
35
+ EVENT_CONTEXT_CREATED,
36
+ EVENT_CONTEXT_COMPLETED,
37
+ EVENT_CONTEXT_REOPENED,
38
+ EVENT_CONTEXT_ARCHIVED,
39
+ EVENT_METADATA_UPDATED,
40
+ EVENT_PLANNING_STARTED,
41
+ EVENT_PLAN_CREATED,
42
+ EVENT_PLAN_IMPLEMENTATION_STARTED,
43
+ EVENT_PLAN_COMPLETED,
44
+ EVENT_HANDOFF_CREATED,
45
+ EVENT_HANDOFF_CLEARED,
46
+ )
47
+
48
+
49
+ @dataclass
50
+ class InFlightState:
51
+ """In-flight work state (plan, research, etc.)."""
52
+ mode: str = "none" # none, planning, pending_implementation, implementing
53
+ artifact_path: Optional[str] = None
54
+ artifact_hash: Optional[str] = None
55
+ started_at: Optional[str] = None
56
+ session_ids: Optional[List[str]] = None # Set-like list of session IDs (no duplicates)
57
+ handoff_path: Optional[str] = None
58
+
59
+
60
+ @dataclass
61
+ class Context:
62
+ """Context metadata for display and indexing."""
63
+ id: str
64
+ status: str = "active" # active, completed
65
+ summary: str = ""
66
+ method: Optional[str] = None
67
+ tags: List[str] = None
68
+ created_at: Optional[str] = None
69
+ last_active: Optional[str] = None
70
+ folder: Optional[str] = None
71
+ in_flight: InFlightState = None
72
+
73
+ def __post_init__(self):
74
+ if self.tags is None:
75
+ self.tags = []
76
+ if self.in_flight is None:
77
+ self.in_flight = InFlightState()
78
+
79
+ def to_dict(self) -> Dict[str, Any]:
80
+ """Convert to dictionary for JSON serialization."""
81
+ return {
82
+ "id": self.id,
83
+ "status": self.status,
84
+ "summary": self.summary,
85
+ "method": self.method,
86
+ "tags": self.tags or [],
87
+ "created_at": self.created_at,
88
+ "last_active": self.last_active,
89
+ "in_flight": asdict(self.in_flight) if self.in_flight else {"mode": "none"}
90
+ }
91
+
92
+ def to_index_entry(self) -> Dict[str, Any]:
93
+ """Convert to index.json entry format."""
94
+ return {
95
+ "id": self.id,
96
+ "status": self.status,
97
+ "method": self.method,
98
+ "summary": self.summary,
99
+ "created_at": self.created_at,
100
+ "last_active": self.last_active,
101
+ "folder": self.folder,
102
+ "in_flight_mode": self.in_flight.mode if self.in_flight else "none"
103
+ }
104
+
105
+
106
+ def _write_context_cache(context: Context, project_root: Path = None) -> bool:
107
+ """
108
+ Write context.json cache file.
109
+
110
+ Args:
111
+ context: Context to write
112
+ project_root: Project root directory
113
+
114
+ Returns:
115
+ True if successful
116
+ """
117
+ context_file = get_context_file_path(context.id, project_root)
118
+ content = json.dumps(context.to_dict(), indent=2, ensure_ascii=False)
119
+ success, error = atomic_write(context_file, content)
120
+
121
+ if not success:
122
+ eprint(f"[context_manager] WARNING: Failed to write context cache: {error}")
123
+
124
+ return success
125
+
126
+
127
+ def _update_index_cache(context: Context, project_root: Path = None) -> bool:
128
+ """
129
+ Update index.json with context entry.
130
+
131
+ Args:
132
+ context: Context to add/update in index
133
+ project_root: Project root directory
134
+
135
+ Returns:
136
+ True if successful
137
+ """
138
+ index_path = get_index_path(project_root)
139
+
140
+ # Load existing index or create new
141
+ index = {"version": "2.0", "updated_at": now_iso(), "contexts": {}}
142
+
143
+ if index_path.exists():
144
+ try:
145
+ index = json.loads(index_path.read_text(encoding='utf-8'))
146
+ except Exception as e:
147
+ eprint(f"[context_manager] WARNING: Failed to read index, recreating: {e}")
148
+
149
+ # Update context entry
150
+ index["contexts"][context.id] = context.to_index_entry()
151
+ index["updated_at"] = now_iso()
152
+
153
+ # Write index
154
+ content = json.dumps(index, indent=2, ensure_ascii=False)
155
+ success, error = atomic_write(index_path, content)
156
+
157
+ if not success:
158
+ eprint(f"[context_manager] WARNING: Failed to write index cache: {error}")
159
+
160
+ return success
161
+
162
+
163
+ def _remove_from_index_cache(context_id: str, project_root: Path = None) -> bool:
164
+ """
165
+ Remove context from main index.json.
166
+
167
+ Args:
168
+ context_id: Context identifier to remove
169
+ project_root: Project root directory
170
+
171
+ Returns:
172
+ True if successful (or entry didn't exist)
173
+ """
174
+ index_path = get_index_path(project_root)
175
+
176
+ if not index_path.exists():
177
+ return True # Nothing to remove
178
+
179
+ try:
180
+ index = json.loads(index_path.read_text(encoding='utf-8'))
181
+ except Exception as e:
182
+ eprint(f"[context_manager] WARNING: Failed to read index: {e}")
183
+ return False
184
+
185
+ # Remove entry if exists
186
+ if context_id in index.get("contexts", {}):
187
+ del index["contexts"][context_id]
188
+ index["updated_at"] = now_iso()
189
+
190
+ # Write index
191
+ content = json.dumps(index, indent=2, ensure_ascii=False)
192
+ success, error = atomic_write(index_path, content)
193
+
194
+ if not success:
195
+ eprint(f"[context_manager] WARNING: Failed to write index: {error}")
196
+ return False
197
+
198
+ return True
199
+
200
+
201
+ def _update_archive_index_cache(context: Context, project_root: Path = None) -> bool:
202
+ """
203
+ Add context to archive/index.json.
204
+
205
+ Args:
206
+ context: Context to add to archive index
207
+ project_root: Project root directory
208
+
209
+ Returns:
210
+ True if successful
211
+ """
212
+ archive_dir = get_archive_dir(project_root)
213
+ archive_index_path = get_archive_index_path(project_root)
214
+
215
+ # Create archive dir if needed
216
+ archive_dir.mkdir(parents=True, exist_ok=True)
217
+
218
+ # Load existing archive index or create new
219
+ archive_index = {"version": "2.0", "updated_at": now_iso(), "contexts": {}}
220
+
221
+ if archive_index_path.exists():
222
+ try:
223
+ archive_index = json.loads(archive_index_path.read_text(encoding='utf-8'))
224
+ except Exception as e:
225
+ eprint(f"[context_manager] WARNING: Failed to read archive index, recreating: {e}")
226
+
227
+ # Add context entry
228
+ archive_index["contexts"][context.id] = context.to_index_entry()
229
+ archive_index["updated_at"] = now_iso()
230
+
231
+ # Write archive index
232
+ content = json.dumps(archive_index, indent=2, ensure_ascii=False)
233
+ success, error = atomic_write(archive_index_path, content)
234
+
235
+ if not success:
236
+ eprint(f"[context_manager] WARNING: Failed to write archive index: {error}")
237
+
238
+ return success
239
+
240
+
241
+ def _remove_from_archive_index_cache(context_id: str, project_root: Path = None) -> bool:
242
+ """
243
+ Remove context from archive/index.json.
244
+
245
+ Args:
246
+ context_id: Context identifier to remove
247
+ project_root: Project root directory
248
+
249
+ Returns:
250
+ True if successful (or entry didn't exist)
251
+ """
252
+ archive_index_path = get_archive_index_path(project_root)
253
+
254
+ if not archive_index_path.exists():
255
+ return True # Nothing to remove
256
+
257
+ try:
258
+ archive_index = json.loads(archive_index_path.read_text(encoding='utf-8'))
259
+ except Exception as e:
260
+ eprint(f"[context_manager] WARNING: Failed to read archive index: {e}")
261
+ return False
262
+
263
+ # Remove entry if exists
264
+ if context_id in archive_index.get("contexts", {}):
265
+ del archive_index["contexts"][context_id]
266
+ archive_index["updated_at"] = now_iso()
267
+
268
+ # Write archive index
269
+ content = json.dumps(archive_index, indent=2, ensure_ascii=False)
270
+ success, error = atomic_write(archive_index_path, content)
271
+
272
+ if not success:
273
+ eprint(f"[context_manager] WARNING: Failed to write archive index: {error}")
274
+ return False
275
+
276
+ return True
277
+
278
+
279
+ def archive_context(context_id: str, project_root: Path = None) -> Optional[Context]:
280
+ """
281
+ Move completed context to archive.
282
+
283
+ 1. Verify context exists and is completed
284
+ 2. Move folder to archive location
285
+ 3. Update context.folder to new path
286
+ 4. Append context_archived event
287
+ 5. Remove from main index
288
+ 6. Add to archive index
289
+
290
+ Args:
291
+ context_id: Context identifier
292
+ project_root: Project root directory
293
+
294
+ Returns:
295
+ Archived Context or None if archiving failed
296
+ """
297
+ # Get context (try active location first)
298
+ context = get_context(context_id, project_root)
299
+ if not context:
300
+ eprint(f"[context_manager] Cannot archive: context '{context_id}' not found")
301
+ return None
302
+
303
+ if context.status != "completed":
304
+ eprint(f"[context_manager] Cannot archive: context '{context_id}' not completed")
305
+ return None
306
+
307
+ # Get source and destination paths
308
+ source_dir = get_context_dir(context_id, project_root)
309
+ archive_dest = get_archive_context_dir(context_id, project_root)
310
+
311
+ # Check if already archived
312
+ if archive_dest.exists():
313
+ eprint(f"[context_manager] Cannot archive: archive folder already exists for '{context_id}'")
314
+ return None
315
+
316
+ # Create archive parent directory
317
+ archive_dest.parent.mkdir(parents=True, exist_ok=True)
318
+
319
+ # Move folder to archive
320
+ try:
321
+ shutil.move(str(source_dir), str(archive_dest))
322
+ except Exception as e:
323
+ eprint(f"[context_manager] ERROR: Failed to move context to archive: {e}")
324
+ return None
325
+
326
+ # Update context folder path
327
+ context.folder = str(archive_dest)
328
+
329
+ # Write context_archived event directly to archive location
330
+ # (Cannot use append_event as it resolves to active location)
331
+ archive_events_path = archive_dest / "events.jsonl"
332
+ event = {
333
+ "event": EVENT_CONTEXT_ARCHIVED,
334
+ "timestamp": now_iso(),
335
+ "archived_from": str(source_dir),
336
+ "archived_to": str(archive_dest)
337
+ }
338
+ event_json = json.dumps(event, ensure_ascii=False)
339
+ success, error = atomic_append(archive_events_path, event_json + "\n")
340
+ if not success:
341
+ eprint(f"[context_manager] WARNING: Failed to append archive event: {error}")
342
+
343
+ # Write context cache directly to archive location
344
+ # (Cannot use _write_context_cache as it resolves to active location)
345
+ archive_context_file = archive_dest / "context.json"
346
+ content = json.dumps(context.to_dict(), indent=2, ensure_ascii=False)
347
+ success, error = atomic_write(archive_context_file, content)
348
+ if not success:
349
+ eprint(f"[context_manager] WARNING: Failed to write archive context cache: {error}")
350
+
351
+ # Remove from main index, add to archive index
352
+ _remove_from_index_cache(context_id, project_root)
353
+ _update_archive_index_cache(context, project_root)
354
+
355
+ eprint(f"[context_manager] Archived context: {context_id}")
356
+ return context
357
+
358
+
359
+ def _load_context_from_cache(context_id: str, project_root: Path = None) -> Optional[Context]:
360
+ """
361
+ Load context from context.json cache file.
362
+
363
+ Args:
364
+ context_id: Context identifier
365
+ project_root: Project root directory
366
+
367
+ Returns:
368
+ Context or None if not found
369
+ """
370
+ context_file = get_context_file_path(context_id, project_root)
371
+
372
+ if not context_file.exists():
373
+ return None
374
+
375
+ try:
376
+ data = json.loads(context_file.read_text(encoding='utf-8'))
377
+ in_flight_data = data.get("in_flight", {})
378
+ return Context(
379
+ id=data["id"],
380
+ status=data.get("status", "active"),
381
+ summary=data.get("summary", ""),
382
+ method=data.get("method"),
383
+ tags=data.get("tags", []),
384
+ created_at=data.get("created_at"),
385
+ last_active=data.get("last_active"),
386
+ folder=str(get_context_dir(context_id, project_root)),
387
+ in_flight=InFlightState(
388
+ mode=in_flight_data.get("mode", "none"),
389
+ artifact_path=in_flight_data.get("artifact_path"),
390
+ artifact_hash=in_flight_data.get("artifact_hash"),
391
+ started_at=in_flight_data.get("started_at"),
392
+ session_ids=in_flight_data.get("session_ids") or (
393
+ [in_flight_data["session_id"]] if in_flight_data.get("session_id") else None
394
+ ), # Migrate from old session_id to session_ids
395
+ handoff_path=in_flight_data.get("handoff_path"),
396
+ )
397
+ )
398
+ except Exception as e:
399
+ eprint(f"[context_manager] WARNING: Failed to load context cache: {e}")
400
+ return None
401
+
402
+
403
+ def create_context(
404
+ context_id: Optional[str],
405
+ summary: str,
406
+ method: Optional[str] = None,
407
+ tags: Optional[List[str]] = None,
408
+ project_root: Path = None
409
+ ) -> Context:
410
+ """
411
+ Create a new context.
412
+
413
+ Actions:
414
+ 1. Validate/generate context ID
415
+ 2. Create folder: _output/contexts/{context_id}/
416
+ 3. Append context_created event to events.jsonl
417
+ 4. Write context.json cache
418
+ 5. Update index.json cache
419
+
420
+ Args:
421
+ context_id: Optional context ID (generated from summary if not provided)
422
+ summary: Context summary/description
423
+ method: Optional method that created this context (e.g., "cc-native")
424
+ tags: Optional list of tags
425
+ project_root: Project root directory
426
+
427
+ Returns:
428
+ Created Context object
429
+
430
+ Raises:
431
+ ValueError: If context already exists
432
+ """
433
+ # Generate context ID if not provided
434
+ if not context_id:
435
+ existing_ids = set()
436
+ contexts_dir = get_contexts_dir(project_root)
437
+ if contexts_dir.exists():
438
+ existing_ids = {d.name for d in contexts_dir.iterdir() if d.is_dir()}
439
+ context_id = generate_context_id(summary, existing_ids)
440
+
441
+ # Validate context ID
442
+ context_id = validate_context_id(context_id)
443
+
444
+ # Check if context already exists
445
+ context_dir = get_context_dir(context_id, project_root)
446
+ if context_dir.exists():
447
+ raise ValueError(f"Context '{context_id}' already exists")
448
+
449
+ # Create directory
450
+ context_dir.mkdir(parents=True, exist_ok=True)
451
+
452
+ # Create context object
453
+ now = now_iso()
454
+ context = Context(
455
+ id=context_id,
456
+ status="active",
457
+ summary=summary,
458
+ method=method,
459
+ tags=tags or [],
460
+ created_at=now,
461
+ last_active=now,
462
+ folder=str(context_dir),
463
+ in_flight=InFlightState()
464
+ )
465
+
466
+ # Append creation event (source of truth)
467
+ append_event(
468
+ context_id,
469
+ EVENT_CONTEXT_CREATED,
470
+ project_root,
471
+ summary=summary,
472
+ method=method,
473
+ tags=tags or []
474
+ )
475
+
476
+ # Write cache files
477
+ _write_context_cache(context, project_root)
478
+ _update_index_cache(context, project_root)
479
+
480
+ eprint(f"[context_manager] Created context: {context_id}")
481
+ return context
482
+
483
+
484
+ def get_context(context_id: str, project_root: Path = None) -> Optional[Context]:
485
+ """
486
+ Get a single context by ID.
487
+
488
+ Reads from cache file (context.json) for performance.
489
+ Falls back to rebuilding from events if cache is missing.
490
+
491
+ Args:
492
+ context_id: Context identifier
493
+ project_root: Project root directory
494
+
495
+ Returns:
496
+ Context or None if not found
497
+ """
498
+ try:
499
+ context_id = validate_context_id(context_id)
500
+ except ValueError:
501
+ return None
502
+
503
+ # Try cache first
504
+ context = _load_context_from_cache(context_id, project_root)
505
+ if context:
506
+ return context
507
+
508
+ # Check if events file exists (context exists but cache is missing)
509
+ events_path = get_events_file_path(context_id, project_root)
510
+ if not events_path.exists():
511
+ return None
512
+
513
+ # Rebuild from events
514
+ from .cache import rebuild_context_from_events
515
+ context_dir = get_context_dir(context_id, project_root)
516
+ context = rebuild_context_from_events(context_dir)
517
+
518
+ if context:
519
+ # Restore cache
520
+ _write_context_cache(context, project_root)
521
+ _update_index_cache(context, project_root)
522
+
523
+ return context
524
+
525
+
526
+ def get_all_contexts(
527
+ method: Optional[str] = None,
528
+ status: Optional[str] = None,
529
+ project_root: Path = None
530
+ ) -> List[Context]:
531
+ """
532
+ Get all contexts, optionally filtered.
533
+
534
+ Reads from index.json cache for performance.
535
+
536
+ Args:
537
+ method: Filter by method (e.g., "cc-native")
538
+ status: Filter by status ("active" or "completed")
539
+ project_root: Project root directory
540
+
541
+ Returns:
542
+ List of Context objects, sorted by last_active (most recent first)
543
+ """
544
+ contexts = []
545
+ contexts_dir = get_contexts_dir(project_root)
546
+
547
+ if not contexts_dir.exists():
548
+ return []
549
+
550
+ # Read from index cache if available
551
+ index_path = get_index_path(project_root)
552
+ if index_path.exists():
553
+ try:
554
+ index = json.loads(index_path.read_text(encoding='utf-8'))
555
+
556
+ # Validate contexts is a dict before iterating
557
+ contexts_data = index.get("contexts", {})
558
+ if not isinstance(contexts_data, dict):
559
+ eprint(f"[context_manager] WARNING: index['contexts'] is not a dict (type: {type(contexts_data).__name__}), treating as empty")
560
+ contexts_data = {}
561
+
562
+ for ctx_id, entry in contexts_data.items():
563
+ # Apply filters
564
+ if status and entry.get("status") != status:
565
+ continue
566
+ if method and entry.get("method") != method:
567
+ continue
568
+
569
+ # Load full context
570
+ context = get_context(ctx_id, project_root)
571
+ if context:
572
+ contexts.append(context)
573
+
574
+ except Exception as e:
575
+ eprint(f"[context_manager] WARNING: Index read failed, scanning folders: {e}")
576
+ # Fall through to folder scan
577
+
578
+ # Fallback: scan context folders if index failed or is missing
579
+ if not contexts:
580
+ for ctx_dir in contexts_dir.iterdir():
581
+ if not ctx_dir.is_dir():
582
+ continue
583
+
584
+ context = get_context(ctx_dir.name, project_root)
585
+ if not context:
586
+ continue
587
+
588
+ # Apply filters
589
+ if status and context.status != status:
590
+ continue
591
+ if method and context.method != method:
592
+ continue
593
+
594
+ contexts.append(context)
595
+
596
+ # Sort by last_active (most recent first)
597
+ contexts.sort(key=lambda c: c.last_active or "", reverse=True)
598
+
599
+ return contexts
600
+
601
+
602
+ def update_context(
603
+ context_id: str,
604
+ project_root: Path = None,
605
+ **updates
606
+ ) -> Optional[Context]:
607
+ """
608
+ Update context metadata.
609
+
610
+ Allowed updates: summary, tags, method
611
+
612
+ Args:
613
+ context_id: Context identifier
614
+ project_root: Project root directory
615
+ **updates: Fields to update
616
+
617
+ Returns:
618
+ Updated Context or None if not found
619
+ """
620
+ context = get_context(context_id, project_root)
621
+ if not context:
622
+ return None
623
+
624
+ # Filter to allowed update fields
625
+ allowed = {"summary", "tags", "method"}
626
+ event_updates = {k: v for k, v in updates.items() if k in allowed and v is not None}
627
+
628
+ if not event_updates:
629
+ return context # No valid updates
630
+
631
+ # Apply updates
632
+ if "summary" in event_updates:
633
+ context.summary = event_updates["summary"]
634
+ if "tags" in event_updates:
635
+ context.tags = event_updates["tags"]
636
+ if "method" in event_updates:
637
+ context.method = event_updates["method"]
638
+
639
+ context.last_active = now_iso()
640
+
641
+ # Append event
642
+ append_event(context_id, EVENT_METADATA_UPDATED, project_root, **event_updates)
643
+
644
+ # Update caches
645
+ _write_context_cache(context, project_root)
646
+ _update_index_cache(context, project_root)
647
+
648
+ return context
649
+
650
+
651
+ def complete_context(context_id: str, project_root: Path = None) -> Optional[Context]:
652
+ """
653
+ Mark a context as completed and archive it.
654
+
655
+ User-driven completion - AI should not auto-complete.
656
+ After marking completed, automatically archives to _output/contexts/archive/.
657
+
658
+ Args:
659
+ context_id: Context identifier
660
+ project_root: Project root directory
661
+
662
+ Returns:
663
+ Updated Context or None if not found
664
+ """
665
+ context = get_context(context_id, project_root)
666
+ if not context:
667
+ return None
668
+
669
+ if context.status == "completed":
670
+ eprint(f"[context_manager] Context '{context_id}' already completed")
671
+ return context
672
+
673
+ context.status = "completed"
674
+ context.last_active = now_iso()
675
+
676
+ # Append event
677
+ append_event(context_id, EVENT_CONTEXT_COMPLETED, project_root)
678
+
679
+ # Update caches
680
+ _write_context_cache(context, project_root)
681
+ _update_index_cache(context, project_root)
682
+
683
+ eprint(f"[context_manager] Completed context: {context_id}")
684
+
685
+ # Archive the completed context
686
+ archived = archive_context(context_id, project_root)
687
+ return archived if archived else context
688
+
689
+
690
+ def reopen_context(context_id: str, project_root: Path = None) -> Optional[Context]:
691
+ """
692
+ Reopen a completed context.
693
+
694
+ Rare operation - usually for fixing mistakes.
695
+ If context is archived, moves it back from archive to active location.
696
+
697
+ Args:
698
+ context_id: Context identifier
699
+ project_root: Project root directory
700
+
701
+ Returns:
702
+ Updated Context or None if not found
703
+ """
704
+ # First try to get from active location
705
+ context = get_context(context_id, project_root)
706
+
707
+ # If not found, check archive
708
+ if not context:
709
+ context = _get_archived_context(context_id, project_root)
710
+ if context:
711
+ # Restore from archive
712
+ context = _restore_from_archive(context_id, project_root)
713
+ if not context:
714
+ return None
715
+
716
+ if not context:
717
+ return None
718
+
719
+ if context.status == "active":
720
+ eprint(f"[context_manager] Context '{context_id}' already active")
721
+ return context
722
+
723
+ context.status = "active"
724
+ context.last_active = now_iso()
725
+
726
+ # Append event
727
+ append_event(context_id, EVENT_CONTEXT_REOPENED, project_root)
728
+
729
+ # Update caches
730
+ _write_context_cache(context, project_root)
731
+ _update_index_cache(context, project_root)
732
+
733
+ eprint(f"[context_manager] Reopened context: {context_id}")
734
+ return context
735
+
736
+
737
+ def _get_archived_context(context_id: str, project_root: Path = None) -> Optional[Context]:
738
+ """
739
+ Load context from archive location.
740
+
741
+ Args:
742
+ context_id: Context identifier
743
+ project_root: Project root directory
744
+
745
+ Returns:
746
+ Context or None if not found in archive
747
+ """
748
+ archive_dir = get_archive_context_dir(context_id, project_root)
749
+ context_file = archive_dir / "context.json"
750
+
751
+ if not context_file.exists():
752
+ return None
753
+
754
+ try:
755
+ data = json.loads(context_file.read_text(encoding='utf-8'))
756
+ in_flight_data = data.get("in_flight", {})
757
+ return Context(
758
+ id=data["id"],
759
+ status=data.get("status", "completed"),
760
+ summary=data.get("summary", ""),
761
+ method=data.get("method"),
762
+ tags=data.get("tags", []),
763
+ created_at=data.get("created_at"),
764
+ last_active=data.get("last_active"),
765
+ folder=str(archive_dir),
766
+ in_flight=InFlightState(
767
+ mode=in_flight_data.get("mode", "none"),
768
+ artifact_path=in_flight_data.get("artifact_path"),
769
+ artifact_hash=in_flight_data.get("artifact_hash"),
770
+ started_at=in_flight_data.get("started_at"),
771
+ session_ids=in_flight_data.get("session_ids"),
772
+ handoff_path=in_flight_data.get("handoff_path"),
773
+ )
774
+ )
775
+ except Exception as e:
776
+ eprint(f"[context_manager] WARNING: Failed to load archived context: {e}")
777
+ return None
778
+
779
+
780
+ def _restore_from_archive(context_id: str, project_root: Path = None) -> Optional[Context]:
781
+ """
782
+ Move context from archive back to active location.
783
+
784
+ Args:
785
+ context_id: Context identifier
786
+ project_root: Project root directory
787
+
788
+ Returns:
789
+ Restored Context or None if restore failed
790
+ """
791
+ archive_dir = get_archive_context_dir(context_id, project_root)
792
+ active_dir = get_context_dir(context_id, project_root)
793
+
794
+ if not archive_dir.exists():
795
+ eprint(f"[context_manager] Cannot restore: archive folder not found for '{context_id}'")
796
+ return None
797
+
798
+ if active_dir.exists():
799
+ eprint(f"[context_manager] Cannot restore: active folder already exists for '{context_id}'")
800
+ return None
801
+
802
+ # Move folder back to active location
803
+ try:
804
+ shutil.move(str(archive_dir), str(active_dir))
805
+ except Exception as e:
806
+ eprint(f"[context_manager] ERROR: Failed to restore context from archive: {e}")
807
+ return None
808
+
809
+ # Load context from new location
810
+ context = _load_context_from_cache(context_id, project_root)
811
+ if context:
812
+ context.folder = str(active_dir)
813
+
814
+ # Remove from archive index
815
+ _remove_from_archive_index_cache(context_id, project_root)
816
+
817
+ eprint(f"[context_manager] Restored context from archive: {context_id}")
818
+ return context
819
+
820
+
821
+ def update_plan_status(
822
+ context_id: str,
823
+ status: str,
824
+ path: Optional[str] = None,
825
+ hash: Optional[str] = None,
826
+ project_root: Path = None
827
+ ) -> Optional[Context]:
828
+ """
829
+ Update plan status in context's in_flight state.
830
+
831
+ Called by:
832
+ - archive_plan hook: status="pending_implementation"
833
+ - SessionStart hook: status="implementing"
834
+ - Plan completion: status="none"
835
+
836
+ Args:
837
+ context_id: Context identifier
838
+ status: Plan status (none, planning, pending_implementation, implementing)
839
+ path: Path to plan file (for pending_implementation)
840
+ hash: Plan content hash (for pending_implementation)
841
+ project_root: Project root directory
842
+
843
+ Returns:
844
+ Updated Context or None if not found
845
+ """
846
+ context = get_context(context_id, project_root)
847
+ if not context:
848
+ return None
849
+
850
+ now = now_iso()
851
+
852
+ # Update in_flight state
853
+ context.in_flight.mode = status
854
+ if status == "planning":
855
+ context.in_flight.started_at = now
856
+ # Append event
857
+ append_event(context_id, EVENT_PLANNING_STARTED, project_root)
858
+
859
+ elif status == "pending_implementation":
860
+ context.in_flight.artifact_path = path
861
+ context.in_flight.artifact_hash = hash
862
+ context.in_flight.started_at = now
863
+
864
+ # Append event
865
+ append_event(
866
+ context_id,
867
+ EVENT_PLAN_CREATED,
868
+ project_root,
869
+ path=path,
870
+ hash=hash
871
+ )
872
+
873
+ elif status == "implementing":
874
+ # Append event
875
+ append_event(context_id, EVENT_PLAN_IMPLEMENTATION_STARTED, project_root)
876
+
877
+ elif status == "none":
878
+ context.in_flight.artifact_path = None
879
+ context.in_flight.artifact_hash = None
880
+ context.in_flight.started_at = None
881
+
882
+ # Append event
883
+ append_event(context_id, EVENT_PLAN_COMPLETED, project_root)
884
+
885
+ context.last_active = now
886
+
887
+ # Update caches
888
+ _write_context_cache(context, project_root)
889
+ _update_index_cache(context, project_root)
890
+
891
+ return context
892
+
893
+
894
+ def get_context_with_pending_plan(project_root: Path = None) -> Optional[Context]:
895
+ """
896
+ Find context with plan.status = "pending_implementation".
897
+
898
+ Used by SessionStart to detect plan handoff scenario.
899
+
900
+ Args:
901
+ project_root: Project root directory
902
+
903
+ Returns:
904
+ Context with pending plan, or None if not found
905
+ """
906
+ contexts = get_all_contexts(status="active", project_root=project_root)
907
+
908
+ for context in contexts:
909
+ if context.in_flight and context.in_flight.mode == "pending_implementation":
910
+ return context
911
+
912
+ return None
913
+
914
+
915
+ def get_context_with_in_flight_work(project_root: Path = None) -> Optional[Context]:
916
+ """
917
+ Find context with any in-flight work (plan, handoff, etc.).
918
+
919
+ Used by SessionStart to detect if continuation is needed.
920
+
921
+ Args:
922
+ project_root: Project root directory
923
+
924
+ Returns:
925
+ Context with in-flight work, or None if not found
926
+ """
927
+ contexts = get_all_contexts(status="active", project_root=project_root)
928
+
929
+ for context in contexts:
930
+ if context.in_flight and context.in_flight.mode != "none":
931
+ return context
932
+
933
+ return None
934
+
935
+
936
+ def update_handoff_status(
937
+ context_id: str,
938
+ handoff_path: str,
939
+ project_root: Path = None
940
+ ) -> Optional[Context]:
941
+ """
942
+ Update context to indicate a handoff is pending.
943
+
944
+ Called by handoff document generator after creating handoff document.
945
+ Sets in_flight.mode = "handoff_pending" and in_flight.handoff_path.
946
+
947
+ Args:
948
+ context_id: Context identifier
949
+ handoff_path: Path to the handoff document
950
+ project_root: Project root directory
951
+
952
+ Returns:
953
+ Updated Context or None if not found
954
+ """
955
+ context = get_context(context_id, project_root)
956
+ if not context:
957
+ return None
958
+
959
+ now = now_iso()
960
+
961
+ # Update in_flight state
962
+ context.in_flight.mode = "handoff_pending"
963
+ context.in_flight.handoff_path = handoff_path
964
+ context.in_flight.started_at = now
965
+ context.last_active = now
966
+
967
+ # Append event (source of truth) - MUST happen before cache updates
968
+ append_event(
969
+ context_id,
970
+ EVENT_HANDOFF_CREATED,
971
+ project_root,
972
+ path=handoff_path
973
+ )
974
+
975
+ # Update caches
976
+ _write_context_cache(context, project_root)
977
+ _update_index_cache(context, project_root)
978
+
979
+ eprint(f"[context_manager] Set handoff pending for: {context_id}")
980
+ return context
981
+
982
+
983
+ def clear_handoff_status(context_id: str, project_root: Path = None) -> Optional[Context]:
984
+ """
985
+ Clear handoff pending status after resumption.
986
+
987
+ Called by SessionStart after successfully resuming from handoff.
988
+
989
+ Args:
990
+ context_id: Context identifier
991
+ project_root: Project root directory
992
+
993
+ Returns:
994
+ Updated Context or None if not found
995
+ """
996
+ context = get_context(context_id, project_root)
997
+ if not context:
998
+ return None
999
+
1000
+ if context.in_flight.mode != "handoff_pending":
1001
+ return context # Nothing to clear
1002
+
1003
+ now = now_iso()
1004
+
1005
+ # Clear handoff state but preserve any artifact path (plan being implemented)
1006
+ # If artifact_path exists, restore to "implementing" mode; otherwise "none"
1007
+ if context.in_flight.artifact_path:
1008
+ context.in_flight.mode = "implementing"
1009
+ else:
1010
+ context.in_flight.mode = "none"
1011
+ context.in_flight.handoff_path = None
1012
+ # Don't clear started_at if we're still implementing
1013
+ if not context.in_flight.artifact_path:
1014
+ context.in_flight.started_at = None
1015
+ context.last_active = now
1016
+
1017
+ # Append event (source of truth) - MUST happen before cache updates
1018
+ append_event(
1019
+ context_id,
1020
+ EVENT_HANDOFF_CLEARED,
1021
+ project_root,
1022
+ restored_mode=context.in_flight.mode
1023
+ )
1024
+
1025
+ # Update caches
1026
+ _write_context_cache(context, project_root)
1027
+ _update_index_cache(context, project_root)
1028
+
1029
+ eprint(f"[context_manager] Cleared handoff status for: {context_id}")
1030
+ return context
1031
+
1032
+
1033
+ def get_context_with_handoff_pending(project_root: Path = None) -> Optional[Context]:
1034
+ """
1035
+ Find context with handoff pending (highest priority for SessionStart).
1036
+
1037
+ Args:
1038
+ project_root: Project root directory
1039
+
1040
+ Returns:
1041
+ Context with handoff pending, or None if not found
1042
+ """
1043
+ contexts = get_all_contexts(status="active", project_root=project_root)
1044
+
1045
+ for context in contexts:
1046
+ if context.in_flight and context.in_flight.mode == "handoff_pending":
1047
+ return context
1048
+
1049
+ return None
1050
+
1051
+
1052
+ def get_all_in_flight_contexts(project_root: Path = None) -> List[Context]:
1053
+ """
1054
+ Return all contexts with truly in-flight work requiring attention.
1055
+
1056
+ In-flight modes (require continuation/action):
1057
+ - planning: Active planning session
1058
+ - pending_implementation: Plan created, awaiting implementation
1059
+ - handoff_pending: Handoff document created, awaiting pickup
1060
+
1061
+ NOT in-flight (normal working state):
1062
+ - implementing: Active work, but doesn't block new context creation
1063
+ - none: No active work
1064
+
1065
+ Used by context enforcer to determine auto-selection behavior:
1066
+ - 0 in-flight: auto-create new context
1067
+ - 1 in-flight: auto-select that context
1068
+ - Multiple: show picker
1069
+
1070
+ Args:
1071
+ project_root: Project root directory
1072
+
1073
+ Returns:
1074
+ List of contexts with in-flight work requiring attention
1075
+ """
1076
+ IN_FLIGHT_MODES = {"planning", "pending_implementation", "handoff_pending"}
1077
+ contexts = get_all_contexts(status="active", project_root=project_root)
1078
+ return [c for c in contexts if c.in_flight and c.in_flight.mode in IN_FLIGHT_MODES]
1079
+
1080
+
1081
+ def get_context_by_session_id(session_id: str, project_root: Path = None) -> Optional[Context]:
1082
+ """
1083
+ Find context that contains this session_id in its session_ids list.
1084
+
1085
+ Used by context enforcer to detect if current session already belongs
1086
+ to a context (session continuity across /clear).
1087
+
1088
+ Args:
1089
+ session_id: Session ID to search for
1090
+ project_root: Project root directory
1091
+
1092
+ Returns:
1093
+ Context containing this session_id, or None if not found
1094
+ """
1095
+ if not session_id or session_id == "unknown":
1096
+ return None
1097
+
1098
+ contexts = get_all_contexts(status="active", project_root=project_root)
1099
+
1100
+ for context in contexts:
1101
+ if context.in_flight and context.in_flight.session_ids:
1102
+ if session_id in context.in_flight.session_ids:
1103
+ return context
1104
+
1105
+ return None
1106
+
1107
+
1108
+ def create_context_from_prompt(user_prompt: str, project_root: Path = None) -> Context:
1109
+ """
1110
+ Auto-create a context from the user's prompt.
1111
+
1112
+ Used by the context enforcer hook when no context exists.
1113
+ Passes the full prompt (up to 2000 chars) for semantic summarization
1114
+ to generate a meaningful context ID.
1115
+
1116
+ Args:
1117
+ user_prompt: The user's prompt text
1118
+ project_root: Project root directory
1119
+
1120
+ Returns:
1121
+ Newly created Context object
1122
+ """
1123
+ # Pass full prompt for semantic summarization (inference.py truncates to 500 chars)
1124
+ # Store up to 2000 chars in summary field for context
1125
+ summary = user_prompt.strip()[:2000]
1126
+ if len(user_prompt.strip()) > 2000:
1127
+ summary += "..."
1128
+
1129
+ return create_context(
1130
+ context_id=None, # Auto-generate from summary via semantic summarization
1131
+ summary=summary,
1132
+ method="auto-created",
1133
+ tags=["auto-created"],
1134
+ project_root=project_root
1135
+ )
1136
+
1137
+
1138
+ def update_context_session_id(
1139
+ context_id: str,
1140
+ session_id: str,
1141
+ project_root: Path = None
1142
+ ) -> Optional[Context]:
1143
+ """
1144
+ Update only the session_id in context's in_flight state.
1145
+
1146
+ Args:
1147
+ context_id: Context identifier
1148
+ session_id: Session ID to store
1149
+ project_root: Project root directory
1150
+
1151
+ Returns:
1152
+ Updated Context or None if not found
1153
+ """
1154
+ context = get_context(context_id, project_root)
1155
+ if not context:
1156
+ return None
1157
+
1158
+ # Update in_flight.session_ids (set-like behavior - no duplicates)
1159
+ if not context.in_flight:
1160
+ context.in_flight = InFlightState()
1161
+ if context.in_flight.session_ids is None:
1162
+ context.in_flight.session_ids = []
1163
+ if session_id not in context.in_flight.session_ids:
1164
+ context.in_flight.session_ids.append(session_id)
1165
+
1166
+ # Write updated context
1167
+ context_file = get_context_file_path(context_id, project_root)
1168
+ content = json.dumps(context.to_dict(), indent=2, ensure_ascii=False)
1169
+ success, _ = atomic_write(context_file, content)
1170
+
1171
+ return context if success else None