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