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.
- package/bin/run.js +5 -2
- package/dist/lib/claude-settings-types.d.ts +2 -0
- package/dist/templates/CLAUDE.md +3 -3
- package/dist/templates/_shared/.claude/settings.json +4 -0
- package/dist/templates/_shared/hooks/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/context_enforcer.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/context_monitor.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/file-suggestion.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/pre_compact.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/task_create_capture.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/task_update_capture.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/archive_plan.py +87 -178
- package/dist/templates/_shared/hooks/context_monitor.py +104 -247
- package/dist/templates/_shared/hooks/file-suggestion.py +26 -23
- package/dist/templates/_shared/hooks/pre_compact.py +47 -32
- package/dist/templates/_shared/hooks/session_end.py +114 -60
- package/dist/templates/_shared/hooks/session_start.py +127 -81
- package/dist/templates/_shared/hooks/task_create_capture.py +26 -50
- package/dist/templates/_shared/hooks/task_update_capture.py +42 -115
- package/dist/templates/_shared/hooks/user_prompt_submit.py +47 -81
- package/dist/templates/_shared/lib/base/__init__.py +16 -0
- package/dist/templates/_shared/lib/base/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/hook_utils.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/inference.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/logger.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/base/hook_utils.py +207 -11
- package/dist/templates/_shared/lib/base/inference.py +121 -0
- package/dist/templates/_shared/lib/base/logger.py +291 -0
- package/dist/templates/_shared/lib/base/utils.py +42 -9
- package/dist/templates/_shared/lib/context/__init__.py +72 -80
- package/dist/templates/_shared/lib/context/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_manager.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/discovery.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/plan_manager.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/task_tracker.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/context_formatter.py +317 -0
- package/dist/templates/_shared/lib/context/context_selector.py +508 -0
- package/dist/templates/_shared/lib/context/context_store.py +653 -0
- package/dist/templates/_shared/lib/context/plan_manager.py +204 -0
- package/dist/templates/_shared/lib/context/task_tracker.py +188 -0
- package/dist/templates/_shared/lib/handoff/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/handoff/__pycache__/document_generator.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/handoff/document_generator.py +14 -40
- package/dist/templates/_shared/lib/templates/README.md +5 -13
- package/dist/templates/_shared/lib/templates/__init__.py +2 -6
- package/dist/templates/_shared/lib/templates/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/__pycache__/formatters.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/plan_context.py +22 -37
- package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
- package/dist/templates/_shared/scripts/__pycache__/status_line.cpython-313.pyc +0 -0
- package/dist/templates/_shared/scripts/save_handoff.py +31 -19
- package/dist/templates/_shared/scripts/status_line.py +701 -0
- package/dist/templates/_shared/workflows/handoff.md +9 -3
- package/dist/templates/cc-native/.claude/settings.json +37 -14
- package/dist/templates/cc-native/CC-NATIVE-README.md +25 -28
- package/dist/templates/cc-native/MIGRATION.md +1 -1
- package/dist/templates/cc-native/TEMPLATE-SCHEMA.md +14 -39
- package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +54 -21
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-plan-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/mark_questions_asked.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_accepted.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/plan_questions_early.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/suggest-fresh-perspective.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +76 -89
- package/dist/templates/cc-native/_cc-native/hooks/cc-native-plan-review.py +163 -131
- package/dist/templates/cc-native/_cc-native/hooks/plan_questions_early.py +81 -0
- package/dist/templates/cc-native/_cc-native/hooks/suggest-fresh-perspective.py +26 -25
- package/dist/templates/cc-native/_cc-native/lib/CLAUDE.md +6 -4
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/constants.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/debug.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/orchestrator.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/__pycache__/utils.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/debug.py +37 -22
- package/dist/templates/cc-native/_cc-native/lib/orchestrator.py +34 -29
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/agent.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/base.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/codex.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/__pycache__/gemini.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/reviewers/agent.py +26 -21
- package/dist/templates/cc-native/_cc-native/lib/reviewers/codex.py +12 -7
- package/dist/templates/cc-native/_cc-native/lib/reviewers/gemini.py +12 -7
- package/dist/templates/cc-native/_cc-native/lib/state.py +31 -16
- package/dist/templates/cc-native/_cc-native/lib/utils.py +207 -40
- package/dist/templates/cc-native/_cc-native/plan-review.config.json +1 -2
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
- package/dist/templates/_shared/hooks/context_enforcer.py +0 -625
- package/dist/templates/_shared/hooks/task_create_atomicity.py +0 -177
- package/dist/templates/_shared/lib/context/auto_state.py +0 -167
- package/dist/templates/_shared/lib/context/cache.py +0 -444
- package/dist/templates/_shared/lib/context/context_extractor.py +0 -115
- package/dist/templates/_shared/lib/context/context_manager.py +0 -1057
- package/dist/templates/_shared/lib/context/discovery.py +0 -554
- package/dist/templates/_shared/lib/context/event_log.py +0 -316
- package/dist/templates/_shared/lib/context/plan_archive.py +0 -101
- package/dist/templates/_shared/lib/context/task_sync.py +0 -407
- package/dist/templates/_shared/lib/templates/persona_questions.py +0 -113
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/archive_plan.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/cc-native-agent-review.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/test_permission_request.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/lib/async_archive.py +0 -68
- 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
|