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