aiwcli 0.9.8 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/bin/run.js +5 -2
  2. package/dist/lib/claude-settings-types.d.ts +2 -0
  3. package/dist/templates/CLAUDE.md +3 -3
  4. package/dist/templates/_shared/.claude/settings.json +4 -0
  5. package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  7. package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
  8. package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
  9. package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
  10. package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
  11. package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
  12. package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
  13. package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
  14. package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
  15. package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  16. package/dist/templates/_shared/hooks/archive_plan.py +87 -178
  17. package/dist/templates/_shared/hooks/context_monitor.py +104 -247
  18. package/dist/templates/_shared/hooks/file-suggestion.py +26 -23
  19. package/dist/templates/_shared/hooks/pre_compact.py +47 -32
  20. package/dist/templates/_shared/hooks/session_end.py +114 -60
  21. package/dist/templates/_shared/hooks/session_start.py +127 -81
  22. package/dist/templates/_shared/hooks/task_create_capture.py +26 -50
  23. package/dist/templates/_shared/hooks/task_update_capture.py +42 -115
  24. package/dist/templates/_shared/hooks/user_prompt_submit.py +47 -81
  25. package/dist/templates/_shared/lib/base/__init__.py +16 -0
  26. package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
  27. package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
  28. package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
  29. package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
  30. package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
  31. package/dist/templates/_shared/lib/base/hook_utils.py +207 -11
  32. package/dist/templates/_shared/lib/base/inference.py +121 -0
  33. package/dist/templates/_shared/lib/base/logger.py +291 -0
  34. package/dist/templates/_shared/lib/base/utils.py +42 -9
  35. package/dist/templates/_shared/lib/context/__init__.py +72 -80
  36. package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
  38. package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
  39. package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
  40. package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
  41. package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
  42. package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
  43. package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
  44. package/dist/templates/_shared/lib/context/context_formatter.py +317 -0
  45. package/dist/templates/_shared/lib/context/context_selector.py +508 -0
  46. package/dist/templates/_shared/lib/context/context_store.py +653 -0
  47. package/dist/templates/_shared/lib/context/plan_manager.py +204 -0
  48. package/dist/templates/_shared/lib/context/task_tracker.py +188 -0
  49. package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
  50. package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
  51. package/dist/templates/_shared/lib/handoff/document_generator.py +14 -40
  52. package/dist/templates/_shared/lib/templates/README.md +5 -13
  53. package/dist/templates/_shared/lib/templates/__init__.py +2 -6
  54. package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
  55. package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
  56. package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
  57. package/dist/templates/_shared/lib/templates/plan_context.py +22 -37
  58. package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
  59. package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
  60. package/dist/templates/_shared/scripts/save_handoff.py +31 -19
  61. package/dist/templates/_shared/scripts/status_line.py +701 -0
  62. package/dist/templates/_shared/workflows/handoff.md +9 -3
  63. package/dist/templates/cc-native/.claude/settings.json +37 -14
  64. package/dist/templates/cc-native/CC-NATIVE-README.md +25 -28
  65. package/dist/templates/cc-native/MIGRATION.md +1 -1
  66. package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +14 -39
  67. package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +54 -21
  68. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
  69. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
  70. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
  71. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
  72. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
  73. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
  74. package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +76 -89
  75. package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +163 -131
  76. package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +81 -0
  77. package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +26 -25
  78. package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +6 -4
  79. package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  80. package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
  81. package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
  82. package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
  83. package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
  84. package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
  85. package/dist/templates/cc-native/_cc-native/lib/debug.py +37 -22
  86. package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +34 -29
  87. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
  88. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
  89. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
  90. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
  91. package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
  92. package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +26 -21
  93. package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +12 -7
  94. package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +12 -7
  95. package/dist/templates/cc-native/_cc-native/lib/state.py +31 -16
  96. package/dist/templates/cc-native/_cc-native/lib/utils.py +207 -40
  97. package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -2
  98. package/oclif.manifest.json +1 -1
  99. package/package.json +1 -1
  100. package/dist/templates/_shared/hooks/context_enforcer.py +0 -625
  101. package/dist/templates/_shared/hooks/task_create_atomicity.py +0 -177
  102. package/dist/templates/_shared/lib/context/auto_state.py +0 -167
  103. package/dist/templates/_shared/lib/context/cache.py +0 -444
  104. package/dist/templates/_shared/lib/context/context_extractor.py +0 -115
  105. package/dist/templates/_shared/lib/context/context_manager.py +0 -1057
  106. package/dist/templates/_shared/lib/context/discovery.py +0 -554
  107. package/dist/templates/_shared/lib/context/event_log.py +0 -316
  108. package/dist/templates/_shared/lib/context/plan_archive.py +0 -101
  109. package/dist/templates/_shared/lib/context/task_sync.py +0 -407
  110. package/dist/templates/_shared/lib/templates/persona_questions.py +0 -113
  111. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
  112. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
  113. package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
  114. package/dist/templates/cc-native/_cc-native/lib/async_archive.py +0 -68
  115. package/dist/templates/cc-native/_cc-native/lib/atomic_write.py +0 -98
@@ -0,0 +1,653 @@
1
+ """Context store — 2-layer CRUD for context state management.
2
+
3
+ Replaces context_manager.py's 3-layer approach (events.jsonl + context.json + index.json)
4
+ with a simpler 2-layer model:
5
+
6
+ state.json (per context folder — SOURCE OF TRUTH)
7
+ index.json (at _output/ root — fast session->context lookup)
8
+
9
+ No event sourcing. No cache rebuilds. Direct read/write.
10
+ """
11
+ import json
12
+ import shutil
13
+ from dataclasses import dataclass, field, asdict
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from ..base.atomic_write import atomic_write
18
+ from ..base.constants import (
19
+ get_context_dir,
20
+ get_contexts_dir,
21
+ get_index_path,
22
+ get_archive_dir,
23
+ get_archive_context_dir,
24
+ get_archive_index_path,
25
+ validate_context_id,
26
+ )
27
+ from ..base.logger import log_debug, log_info, log_warn, log_error, set_context_path
28
+ from ..base.utils import now_iso, generate_context_id
29
+
30
+ # Mode mapping from old context_manager values to new values
31
+ _MODE_MIGRATION = {
32
+ "none": "idle",
33
+ "planning": "idle", # Inferred at runtime, not stored
34
+ "pending_implementation": "has_plan",
35
+ "implementing": "active",
36
+ }
37
+
38
+ INDEX_VERSION = "3.0"
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Data model
43
+ # ---------------------------------------------------------------------------
44
+
45
+ @dataclass
46
+ class ContextState:
47
+ """Flat, self-contained state for one context. Stored as state.json."""
48
+ id: str
49
+ status: str = "active" # active | completed
50
+ summary: str = ""
51
+ method: str = "" # auto-created | caret_new
52
+ tags: list = field(default_factory=list)
53
+ created_at: str = ""
54
+ last_active: str = ""
55
+ mode: str = "idle" # idle | has_plan | has_handoff | active
56
+ plan_path: str = None
57
+ plan_hash: str = None # Content hash for plan matching after /clear
58
+ plan_signature: str = None # First 200 chars for fallback matching
59
+ plan_id: str = None # Embedded UUID for reliable matching
60
+ plan_anchors: list = field(default_factory=list) # Structural anchors for fuzzy matching
61
+ plan_consumed: bool = False # True after plan has been delivered to a session
62
+ handoff_path: str = None
63
+ handoff_consumed: bool = False # True after handoff has been delivered to a session
64
+ session_ids: list = field(default_factory=list)
65
+ last_session: dict = None # {session_id, git_branch, uncommitted_files, last_commit}
66
+ tasks: list = field(default_factory=list)
67
+ # Each task: {id, subject, status, description, active_form,
68
+ # created_at, completed_at, evidence, work_summary, files_changed}
69
+
70
+ # -- serialisation helpers --
71
+
72
+ def to_dict(self) -> Dict[str, Any]:
73
+ """Serialise for state.json."""
74
+ return {k: v for k, v in asdict(self).items() if v is not None}
75
+
76
+ def to_index_entry(self) -> Dict[str, Any]:
77
+ """Lightweight summary for the contexts section of index.json."""
78
+ return {
79
+ "summary": self.summary,
80
+ "mode": self.mode,
81
+ "last_active": self.last_active,
82
+ }
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Internal helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def _state_path(context_id: str, project_root: Path = None) -> Path:
90
+ """Return path to _output/contexts/{context_id}/state.json."""
91
+ return get_context_dir(context_id, project_root) / "state.json"
92
+
93
+
94
+ def _load_index(project_root: Path = None) -> Dict[str, Any]:
95
+ """Load index.json or return a fresh skeleton."""
96
+ index_path = get_index_path(project_root)
97
+ if index_path.exists():
98
+ try:
99
+ return json.loads(index_path.read_text(encoding="utf-8"))
100
+ except Exception as e:
101
+ log_warn("context_store", f"Failed to read index, recreating: {e}")
102
+ return {"version": INDEX_VERSION, "updated_at": now_iso(), "sessions": {}, "contexts": {}}
103
+
104
+
105
+ def _save_index(index: Dict[str, Any], project_root: Path = None) -> bool:
106
+ """Atomically write index.json."""
107
+ index["updated_at"] = now_iso()
108
+ content = json.dumps(index, indent=2, ensure_ascii=False)
109
+ success, error = atomic_write(get_index_path(project_root), content)
110
+ if not success:
111
+ log_warn("context_store", f"Failed to write index: {error}")
112
+ return success
113
+
114
+
115
+ def _dict_to_state(data: Dict[str, Any]) -> ContextState:
116
+ """Construct a ContextState from a dict, migrating old mode names."""
117
+ mode = data.get("mode", "idle")
118
+ mode = _MODE_MIGRATION.get(mode, mode)
119
+ return ContextState(
120
+ id=data["id"],
121
+ status=data.get("status", "active"),
122
+ summary=data.get("summary", ""),
123
+ method=data.get("method", ""),
124
+ tags=data.get("tags", []),
125
+ created_at=data.get("created_at", ""),
126
+ last_active=data.get("last_active", ""),
127
+ mode=mode,
128
+ plan_path=data.get("plan_path"),
129
+ plan_hash=data.get("plan_hash"),
130
+ plan_signature=data.get("plan_signature"),
131
+ plan_id=data.get("plan_id"),
132
+ plan_anchors=data.get("plan_anchors", []),
133
+ plan_consumed=data.get("plan_consumed", False),
134
+ handoff_path=data.get("handoff_path"),
135
+ handoff_consumed=data.get("handoff_consumed", False),
136
+ session_ids=data.get("session_ids", []),
137
+ last_session=data.get("last_session"),
138
+ tasks=data.get("tasks", []),
139
+ )
140
+
141
+
142
+ def _migrate_context_json(context_id: str, project_root: Path = None) -> Optional[ContextState]:
143
+ """Backward compat: read legacy context.json and convert to ContextState."""
144
+ legacy_path = get_context_dir(context_id, project_root) / "context.json"
145
+ if not legacy_path.exists():
146
+ return None
147
+ try:
148
+ data = json.loads(legacy_path.read_text(encoding="utf-8"))
149
+ in_flight = data.get("in_flight", {})
150
+ old_mode = in_flight.get("mode", "none")
151
+ mode = _MODE_MIGRATION.get(old_mode, "idle")
152
+ return ContextState(
153
+ id=data.get("id", context_id),
154
+ status=data.get("status", "active"),
155
+ summary=data.get("summary", ""),
156
+ method=data.get("method", ""),
157
+ tags=data.get("tags", []),
158
+ created_at=data.get("created_at", ""),
159
+ last_active=data.get("last_active", ""),
160
+ mode=mode,
161
+ plan_path=in_flight.get("artifact_path"),
162
+ plan_hash=in_flight.get("artifact_hash"),
163
+ plan_signature=None,
164
+ handoff_path=in_flight.get("handoff_path"),
165
+ session_ids=in_flight.get("session_ids") or (
166
+ [in_flight["session_id"]] if in_flight.get("session_id") else []
167
+ ),
168
+ last_session=None,
169
+ tasks=[],
170
+ )
171
+ except Exception as e:
172
+ log_warn("context_store", f"Failed to migrate context.json for '{context_id}': {e}")
173
+ return None
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Core CRUD
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def load_state(context_id: str, project_root: Path = None) -> Optional[ContextState]:
181
+ """Read state.json for a context. Falls back to context.json for migration."""
182
+ sp = _state_path(context_id, project_root)
183
+ if sp.exists():
184
+ try:
185
+ data = json.loads(sp.read_text(encoding="utf-8"))
186
+ return _dict_to_state(data)
187
+ except Exception as e:
188
+ log_warn("context_store", f"Failed to read state.json for '{context_id}': {e}")
189
+ return None
190
+
191
+ # Backward compat: migrate from legacy context.json
192
+ return _migrate_context_json(context_id, project_root)
193
+
194
+
195
+ def save_state(state: ContextState, project_root: Path = None) -> bool:
196
+ """Atomically write state.json AND update index.json."""
197
+ # 1. Write state.json
198
+ sp = _state_path(state.id, project_root)
199
+ sp.parent.mkdir(parents=True, exist_ok=True)
200
+ content = json.dumps(state.to_dict(), indent=2, ensure_ascii=False)
201
+ success, error = atomic_write(sp, content)
202
+ if not success:
203
+ log_warn("context_store", f"Failed to write state.json for '{state.id}': {error}")
204
+ return False
205
+
206
+ # 2. Update index.json
207
+ index = _load_index(project_root)
208
+ index["contexts"][state.id] = state.to_index_entry()
209
+ # Keep session mappings in sync
210
+ for sid in state.session_ids:
211
+ index.setdefault("sessions", {})[sid] = state.id
212
+ return _save_index(index, project_root)
213
+
214
+
215
+ def create_context(
216
+ context_id: Optional[str],
217
+ summary: str,
218
+ method: str = "",
219
+ tags: Optional[List[str]] = None,
220
+ project_root: Path = None,
221
+ ) -> ContextState:
222
+ """Create a new context folder + state.json + index entry.
223
+
224
+ Raises:
225
+ ValueError: If context already exists.
226
+ """
227
+ # Generate ID if needed
228
+ if not context_id:
229
+ existing_ids = set()
230
+ contexts_dir = get_contexts_dir(project_root)
231
+ if contexts_dir.exists():
232
+ existing_ids = {d.name for d in contexts_dir.iterdir() if d.is_dir()}
233
+ context_id = generate_context_id(summary, existing_ids)
234
+
235
+ context_id = validate_context_id(context_id)
236
+ context_dir = get_context_dir(context_id, project_root)
237
+
238
+ if context_dir.exists():
239
+ raise ValueError(f"Context '{context_id}' already exists")
240
+
241
+ context_dir.mkdir(parents=True, exist_ok=True)
242
+
243
+ now = now_iso()
244
+ state = ContextState(
245
+ id=context_id,
246
+ status="active",
247
+ summary=summary,
248
+ method=method,
249
+ tags=tags or [],
250
+ created_at=now,
251
+ last_active=now,
252
+ )
253
+ save_state(state, project_root)
254
+ log_info("context_store", f"Created context: {context_id}")
255
+ return state
256
+
257
+
258
+ def get_context(context_id: str, project_root: Path = None) -> Optional[ContextState]:
259
+ """Load a single context by ID."""
260
+ try:
261
+ context_id = validate_context_id(context_id)
262
+ except ValueError:
263
+ return None
264
+ return load_state(context_id, project_root)
265
+
266
+
267
+ def get_all_contexts(
268
+ status: Optional[str] = None,
269
+ project_root: Path = None,
270
+ ) -> List[ContextState]:
271
+ """List contexts from index.json, loading each state.json.
272
+
273
+ Falls back to scanning context folders if the index is missing or corrupt.
274
+ Results are sorted by last_active descending (most recent first).
275
+ """
276
+ results: List[ContextState] = []
277
+ contexts_dir = get_contexts_dir(project_root)
278
+ if not contexts_dir.exists():
279
+ return []
280
+
281
+ # Try index-driven path first
282
+ index = _load_index(project_root)
283
+ ctx_map = index.get("contexts", {})
284
+
285
+ if isinstance(ctx_map, dict) and ctx_map:
286
+ for cid, entry in ctx_map.items():
287
+ if status and entry.get("status") and entry["status"] != status:
288
+ # Index may not store status; always load for definitive check
289
+ pass
290
+ state = load_state(cid, project_root)
291
+ if state and (not status or state.status == status):
292
+ results.append(state)
293
+ else:
294
+ # Fallback: scan folders
295
+ for ctx_dir in contexts_dir.iterdir():
296
+ if not ctx_dir.is_dir() or ctx_dir.name.startswith("_"):
297
+ continue
298
+ state = load_state(ctx_dir.name, project_root)
299
+ if state and (not status or state.status == status):
300
+ results.append(state)
301
+
302
+ results.sort(key=lambda s: s.last_active or "", reverse=True)
303
+ return results
304
+
305
+
306
+ def update_context(
307
+ context_id: str,
308
+ project_root: Path = None,
309
+ **updates,
310
+ ) -> Optional[ContextState]:
311
+ """Update allowed metadata fields (summary, tags, method) on a context."""
312
+ state = get_context(context_id, project_root)
313
+ if not state:
314
+ return None
315
+
316
+ allowed = {"summary", "tags", "method"}
317
+ changed = False
318
+ for key, value in updates.items():
319
+ if key in allowed and value is not None:
320
+ setattr(state, key, value)
321
+ changed = True
322
+
323
+ if not changed:
324
+ return state
325
+
326
+ state.last_active = now_iso()
327
+ save_state(state, project_root)
328
+ return state
329
+
330
+
331
+ def complete_context(context_id: str, project_root: Path = None) -> Optional[ContextState]:
332
+ """Mark context completed and archive it."""
333
+ state = get_context(context_id, project_root)
334
+ if not state:
335
+ return None
336
+
337
+ if state.status == "completed":
338
+ log_info("context_store", f"Context '{context_id}' already completed")
339
+ return state
340
+
341
+ state.status = "completed"
342
+ state.last_active = now_iso()
343
+ save_state(state, project_root)
344
+ log_info("context_store", f"Completed context: {context_id}")
345
+
346
+ archived = archive_context(context_id, project_root)
347
+ return archived if archived else state
348
+
349
+
350
+ def archive_context(context_id: str, project_root: Path = None) -> Optional[ContextState]:
351
+ """Move completed context folder to _archive/, update indices."""
352
+ state = get_context(context_id, project_root)
353
+ if not state:
354
+ log_warn("context_store", f"Cannot archive: context '{context_id}' not found")
355
+ return None
356
+ if state.status != "completed":
357
+ log_warn("context_store", f"Cannot archive: context '{context_id}' not completed")
358
+ return None
359
+
360
+ source_dir = get_context_dir(context_id, project_root)
361
+ archive_dest = get_archive_context_dir(context_id, project_root)
362
+
363
+ if archive_dest.exists():
364
+ log_warn("context_store", f"Cannot archive: archive folder already exists for '{context_id}'")
365
+ return None
366
+
367
+ archive_dest.parent.mkdir(parents=True, exist_ok=True)
368
+
369
+ try:
370
+ shutil.move(str(source_dir), str(archive_dest))
371
+ except Exception as e:
372
+ log_error("context_store", f"Failed to move context to archive: {e}")
373
+ return None
374
+
375
+ # Remove from main index (entry + session mappings)
376
+ index = _load_index(project_root)
377
+ index.get("contexts", {}).pop(context_id, None)
378
+ sessions = index.get("sessions", {})
379
+ stale_sids = [sid for sid, cid in sessions.items() if cid == context_id]
380
+ for sid in stale_sids:
381
+ del sessions[sid]
382
+ _save_index(index, project_root)
383
+
384
+ # Add to archive index
385
+ _update_archive_index(state, project_root)
386
+
387
+ log_info("context_store", f"Archived context: {context_id}")
388
+ return state
389
+
390
+
391
+ def reopen_context(context_id: str, project_root: Path = None) -> Optional[ContextState]:
392
+ """Reopen a completed/archived context."""
393
+ # Try active location first
394
+ state = get_context(context_id, project_root)
395
+
396
+ # If not found, check archive and restore
397
+ if not state:
398
+ state = _restore_from_archive(context_id, project_root)
399
+ if not state:
400
+ return None
401
+
402
+ if state.status == "active":
403
+ log_info("context_store", f"Context '{context_id}' already active")
404
+ return state
405
+
406
+ state.status = "active"
407
+ state.last_active = now_iso()
408
+ save_state(state, project_root)
409
+ log_info("context_store", f"Reopened context: {context_id}")
410
+ return state
411
+
412
+
413
+ # ---------------------------------------------------------------------------
414
+ # Session binding & mode updates
415
+ # ---------------------------------------------------------------------------
416
+
417
+ def get_context_by_session_id(
418
+ session_id: str,
419
+ project_root: Path = None,
420
+ ) -> Optional[ContextState]:
421
+ """O(1) lookup: check index.json sessions map first.
422
+
423
+ Side effect: sets the logger context path so all subsequent log calls
424
+ in this process write to the context's debug/hook-log.jsonl.
425
+ """
426
+ if not session_id or session_id == "unknown":
427
+ return None
428
+
429
+ index = _load_index(project_root)
430
+ cid = index.get("sessions", {}).get(session_id)
431
+ if cid:
432
+ state = load_state(cid, project_root)
433
+ if state:
434
+ _set_logger_context(state.id, project_root)
435
+ return state
436
+
437
+ # Fallback: scan all contexts (handles un-indexed sessions)
438
+ for state in get_all_contexts(status="active", project_root=project_root):
439
+ if session_id in state.session_ids:
440
+ _set_logger_context(state.id, project_root)
441
+ return state
442
+ return None
443
+
444
+
445
+ def _set_logger_context(context_id: str, project_root: Path = None) -> None:
446
+ """Set the logger's context path for per-context log routing."""
447
+ try:
448
+ ctx_dir = get_context_dir(context_id, project_root)
449
+ if ctx_dir.exists():
450
+ set_context_path(ctx_dir)
451
+ except Exception:
452
+ pass # Never crash on logging setup
453
+
454
+
455
+ def bind_session(
456
+ context_id: str,
457
+ session_id: str,
458
+ project_root: Path = None,
459
+ ) -> bool:
460
+ """Add session_id to both index.json sessions map and state.json session_ids."""
461
+ if not session_id or session_id == "unknown":
462
+ return False
463
+
464
+ state = get_context(context_id, project_root)
465
+ if not state:
466
+ return False
467
+
468
+ # Update state.json session_ids (set-like, no dupes)
469
+ if session_id not in state.session_ids:
470
+ state.session_ids.append(session_id)
471
+ state.last_active = now_iso()
472
+
473
+ return save_state(state, project_root)
474
+
475
+
476
+ def update_mode(
477
+ context_id: str,
478
+ mode: str,
479
+ project_root: Path = None,
480
+ plan_path: str = None,
481
+ plan_hash: str = None,
482
+ plan_signature: str = None,
483
+ plan_id: str = None,
484
+ plan_anchors: list = None,
485
+ plan_consumed: bool = None,
486
+ handoff_consumed: bool = None,
487
+ ) -> Optional[ContextState]:
488
+ """Change the mode field (idle | has_plan | has_handoff | active), optionally setting plan/handoff fields."""
489
+ state = get_context(context_id, project_root)
490
+ if not state:
491
+ return None
492
+
493
+ state.mode = mode
494
+ state.last_active = now_iso()
495
+
496
+ if plan_path is not None:
497
+ state.plan_path = plan_path
498
+ if plan_hash is not None:
499
+ state.plan_hash = plan_hash
500
+ if plan_signature is not None:
501
+ state.plan_signature = plan_signature
502
+ if plan_id is not None:
503
+ state.plan_id = plan_id
504
+ if plan_anchors is not None:
505
+ state.plan_anchors = plan_anchors
506
+ if plan_consumed is not None:
507
+ state.plan_consumed = plan_consumed
508
+ if handoff_consumed is not None:
509
+ state.handoff_consumed = handoff_consumed
510
+
511
+ # Clear plan/handoff fields when returning to idle
512
+ if mode == "idle":
513
+ state.plan_path = None
514
+ state.plan_hash = None
515
+ state.plan_signature = None
516
+ state.plan_id = None
517
+ state.plan_anchors = []
518
+ state.plan_consumed = False
519
+ state.handoff_consumed = False
520
+
521
+ save_state(state, project_root)
522
+ return state
523
+
524
+
525
+ def maybe_activate(
526
+ context_id: str,
527
+ permission_mode: str,
528
+ project_root: Path = None,
529
+ caller: str = "",
530
+ ) -> bool:
531
+ """Transition idle/has_plan -> active, unless in plan mode.
532
+
533
+ Centralised mode-activation logic used by context_monitor (PostToolUse)
534
+ and user_prompt_submit (UserPromptSubmit).
535
+
536
+ Returns True if a transition occurred, False otherwise.
537
+ """
538
+ if permission_mode == "plan":
539
+ return False
540
+
541
+ state = get_context(context_id, project_root)
542
+ if not state:
543
+ return False
544
+
545
+ if state.mode in ("idle", "has_plan", "has_handoff"):
546
+ old_mode = state.mode
547
+ kwargs = {}
548
+ if old_mode == "has_plan":
549
+ kwargs["plan_consumed"] = True
550
+ elif old_mode == "has_handoff":
551
+ kwargs["handoff_consumed"] = True
552
+ update_mode(context_id, "active", project_root=project_root, **kwargs)
553
+ log_info("context_store", f"maybe_activate ({caller}): {context_id} {old_mode} -> active")
554
+ return True
555
+
556
+ return False
557
+
558
+
559
+ # ---------------------------------------------------------------------------
560
+ # Auto-creation from prompt
561
+ # ---------------------------------------------------------------------------
562
+
563
+ def create_context_from_prompt(
564
+ user_prompt: str,
565
+ project_root: Path = None,
566
+ ) -> ContextState:
567
+ """Auto-create a context from the user's prompt with an AI-generated slug."""
568
+ summary = user_prompt.strip()[:2000]
569
+ if len(user_prompt.strip()) > 2000:
570
+ summary += "..."
571
+
572
+ return create_context(
573
+ context_id=None,
574
+ summary=summary,
575
+ method="auto-created",
576
+ tags=["auto-created"],
577
+ project_root=project_root,
578
+ )
579
+
580
+
581
+ # ---------------------------------------------------------------------------
582
+ # Archive helpers
583
+ # ---------------------------------------------------------------------------
584
+
585
+ def _update_archive_index(state: ContextState, project_root: Path = None) -> bool:
586
+ """Add context to archive/index.json."""
587
+ archive_dir = get_archive_dir(project_root)
588
+ archive_index_path = get_archive_index_path(project_root)
589
+ archive_dir.mkdir(parents=True, exist_ok=True)
590
+
591
+ archive_index = {"version": INDEX_VERSION, "updated_at": now_iso(), "contexts": {}}
592
+ if archive_index_path.exists():
593
+ try:
594
+ archive_index = json.loads(archive_index_path.read_text(encoding="utf-8"))
595
+ except Exception as e:
596
+ log_warn("context_store", f"Failed to read archive index, recreating: {e}")
597
+
598
+ archive_index["contexts"][state.id] = state.to_index_entry()
599
+ archive_index["updated_at"] = now_iso()
600
+
601
+ content = json.dumps(archive_index, indent=2, ensure_ascii=False)
602
+ success, error = atomic_write(archive_index_path, content)
603
+ if not success:
604
+ log_warn("context_store", f"Failed to write archive index: {error}")
605
+ return success
606
+
607
+
608
+ def _restore_from_archive(context_id: str, project_root: Path = None) -> Optional[ContextState]:
609
+ """Move context from archive back to active location and return its state."""
610
+ archive_dir = get_archive_context_dir(context_id, project_root)
611
+ active_dir = get_context_dir(context_id, project_root)
612
+
613
+ if not archive_dir.exists():
614
+ return None
615
+ if active_dir.exists():
616
+ log_warn("context_store", f"Cannot restore: active folder already exists for '{context_id}'")
617
+ return None
618
+
619
+ try:
620
+ shutil.move(str(archive_dir), str(active_dir))
621
+ except Exception as e:
622
+ log_error("context_store", f"Failed to restore context from archive: {e}")
623
+ return None
624
+
625
+ # Remove from archive index
626
+ _remove_from_archive_index(context_id, project_root)
627
+
628
+ state = load_state(context_id, project_root)
629
+ log_info("context_store", f"Restored context from archive: {context_id}")
630
+ return state
631
+
632
+
633
+ def _remove_from_archive_index(context_id: str, project_root: Path = None) -> bool:
634
+ """Remove context from archive/index.json."""
635
+ archive_index_path = get_archive_index_path(project_root)
636
+ if not archive_index_path.exists():
637
+ return True
638
+
639
+ try:
640
+ archive_index = json.loads(archive_index_path.read_text(encoding="utf-8"))
641
+ except Exception as e:
642
+ log_warn("context_store", f"Failed to read archive index: {e}")
643
+ return False
644
+
645
+ if context_id in archive_index.get("contexts", {}):
646
+ del archive_index["contexts"][context_id]
647
+ archive_index["updated_at"] = now_iso()
648
+ content = json.dumps(archive_index, indent=2, ensure_ascii=False)
649
+ success, error = atomic_write(archive_index_path, content)
650
+ if not success:
651
+ log_warn("context_store", f"Failed to write archive index: {error}")
652
+ return False
653
+ return True