codevira 1.6.0__py3-none-any.whl
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.
- codevira-1.6.0.dist-info/LICENSE +21 -0
- codevira-1.6.0.dist-info/METADATA +477 -0
- codevira-1.6.0.dist-info/RECORD +58 -0
- codevira-1.6.0.dist-info/WHEEL +5 -0
- codevira-1.6.0.dist-info/entry_points.txt +2 -0
- codevira-1.6.0.dist-info/top_level.txt +2 -0
- indexer/__init__.py +1 -0
- indexer/chunker.py +428 -0
- indexer/global_db.py +197 -0
- indexer/graph_generator.py +380 -0
- indexer/index_codebase.py +588 -0
- indexer/outcome_tracker.py +172 -0
- indexer/rule_learner.py +186 -0
- indexer/sqlite_graph.py +640 -0
- indexer/treesitter_parser.py +423 -0
- mcp_server/__init__.py +1 -0
- mcp_server/__main__.py +20 -0
- mcp_server/auto_init.py +257 -0
- mcp_server/cli.py +622 -0
- mcp_server/crash_logger.py +236 -0
- mcp_server/data/__init__.py +1 -0
- mcp_server/data/agents/builder.md +84 -0
- mcp_server/data/agents/developer.md +111 -0
- mcp_server/data/agents/documenter.md +138 -0
- mcp_server/data/agents/orchestrator.md +96 -0
- mcp_server/data/agents/planner.md +106 -0
- mcp_server/data/agents/reviewer.md +82 -0
- mcp_server/data/agents/tester.md +83 -0
- mcp_server/data/config.example.yaml +33 -0
- mcp_server/data/rules/coding-standards.md +48 -0
- mcp_server/data/rules/engineering-excellence.md +28 -0
- mcp_server/data/rules/git-cicd-governance.md +32 -0
- mcp_server/data/rules/git_commits.md +130 -0
- mcp_server/data/rules/incremental-updates.md +5 -0
- mcp_server/data/rules/master_rule.md +187 -0
- mcp_server/data/rules/multi-language.md +19 -0
- mcp_server/data/rules/persistence.md +21 -0
- mcp_server/data/rules/resilience-observability.md +17 -0
- mcp_server/data/rules/smoke-testing.md +48 -0
- mcp_server/data/rules/testing-standards.md +23 -0
- mcp_server/detect.py +284 -0
- mcp_server/gitignore.py +284 -0
- mcp_server/global_sync.py +187 -0
- mcp_server/http_server.py +341 -0
- mcp_server/ide_inject.py +444 -0
- mcp_server/launchd.py +156 -0
- mcp_server/migrate.py +215 -0
- mcp_server/paths.py +256 -0
- mcp_server/prompts.py +136 -0
- mcp_server/server.py +1049 -0
- mcp_server/tools/__init__.py +0 -0
- mcp_server/tools/changesets.py +223 -0
- mcp_server/tools/code_reader.py +335 -0
- mcp_server/tools/graph.py +637 -0
- mcp_server/tools/learning.py +238 -0
- mcp_server/tools/playbook.py +89 -0
- mcp_server/tools/roadmap.py +599 -0
- mcp_server/tools/search.py +145 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP tools for reading and managing the project roadmap.
|
|
3
|
+
|
|
4
|
+
Full planning lifecycle:
|
|
5
|
+
get_roadmap() → session start orientation (compact)
|
|
6
|
+
get_full_roadmap() → complete picture for planning sessions
|
|
7
|
+
get_phase(number) → full details of any phase by number
|
|
8
|
+
update_phase_status() → mark current phase in_progress | blocked | pending
|
|
9
|
+
add_phase() → agents plan new upcoming work
|
|
10
|
+
defer_phase() → move an upcoming phase to deferred
|
|
11
|
+
complete_phase() → mark current phase done, advance to next
|
|
12
|
+
update_next_action() → update next_action at session end
|
|
13
|
+
add_open_changeset() → register active changeset in current phase
|
|
14
|
+
remove_open_changeset() → resolve changeset from current phase
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from datetime import date
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
from mcp_server.paths import get_data_dir, get_project_root
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _roadmap_file() -> Path:
|
|
28
|
+
return get_data_dir() / "roadmap.yaml"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _list_or_empty(value: Any) -> list[Any]:
|
|
32
|
+
return value if isinstance(value, list) else []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _phase_number(entry: Any) -> Any:
|
|
36
|
+
if isinstance(entry, dict):
|
|
37
|
+
return entry.get("phase", entry.get("number"))
|
|
38
|
+
return entry
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _normalize_phase_entry(entry: Any, default_status: str | None = None) -> dict[str, Any]:
|
|
42
|
+
normalized = dict(entry) if isinstance(entry, dict) else {}
|
|
43
|
+
phase_number = _phase_number(entry)
|
|
44
|
+
|
|
45
|
+
if phase_number is not None:
|
|
46
|
+
normalized["phase"] = phase_number
|
|
47
|
+
normalized["number"] = phase_number
|
|
48
|
+
|
|
49
|
+
if default_status and not normalized.get("status"):
|
|
50
|
+
normalized["status"] = default_status
|
|
51
|
+
|
|
52
|
+
description = normalized.get("description") or normalized.get("goal")
|
|
53
|
+
if description is not None:
|
|
54
|
+
normalized["description"] = description
|
|
55
|
+
normalized.setdefault("goal", description)
|
|
56
|
+
|
|
57
|
+
return normalized
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _normalize_current_phase(raw_current: Any, data: dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
phases = _list_or_empty(data.get("phases"))
|
|
62
|
+
current = dict(raw_current) if isinstance(raw_current, dict) else {}
|
|
63
|
+
current_number = _phase_number(raw_current)
|
|
64
|
+
|
|
65
|
+
if current_number is None and phases:
|
|
66
|
+
for candidate in phases:
|
|
67
|
+
if isinstance(candidate, dict) and candidate.get("status") in {"in_progress", "blocked", "pending"}:
|
|
68
|
+
current_number = _phase_number(candidate)
|
|
69
|
+
break
|
|
70
|
+
if current_number is None:
|
|
71
|
+
current_number = _phase_number(phases[0])
|
|
72
|
+
|
|
73
|
+
matched_phase = next(
|
|
74
|
+
(
|
|
75
|
+
phase
|
|
76
|
+
for phase in phases
|
|
77
|
+
if str(_phase_number(phase)) == str(current_number)
|
|
78
|
+
),
|
|
79
|
+
{},
|
|
80
|
+
)
|
|
81
|
+
if isinstance(matched_phase, dict):
|
|
82
|
+
for key, value in matched_phase.items():
|
|
83
|
+
current.setdefault(key, value)
|
|
84
|
+
|
|
85
|
+
if current_number is None:
|
|
86
|
+
current_number = current.get("number", current.get("phase"))
|
|
87
|
+
|
|
88
|
+
if current_number is not None:
|
|
89
|
+
current["number"] = current_number
|
|
90
|
+
|
|
91
|
+
normalized = _normalize_phase_entry(current, default_status="pending")
|
|
92
|
+
normalized.pop("phase", None)
|
|
93
|
+
|
|
94
|
+
if current_number is not None:
|
|
95
|
+
normalized["number"] = current_number
|
|
96
|
+
normalized.setdefault("name", f"Phase {current_number}")
|
|
97
|
+
else:
|
|
98
|
+
normalized.setdefault("name", "Getting Started")
|
|
99
|
+
|
|
100
|
+
normalized.setdefault(
|
|
101
|
+
"next_action",
|
|
102
|
+
data.get("next_action")
|
|
103
|
+
or (
|
|
104
|
+
"Define your first phase: use add_phase() to queue upcoming work, "
|
|
105
|
+
"or update_next_action() to describe what needs doing next."
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
normalized["open_changesets"] = _list_or_empty(
|
|
109
|
+
normalized.get("open_changesets", data.get("open_changesets", []))
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return normalized
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _normalize_roadmap(data: Any) -> dict[str, Any]:
|
|
116
|
+
if not isinstance(data, dict):
|
|
117
|
+
return _create_stub_roadmap()
|
|
118
|
+
|
|
119
|
+
current = _normalize_current_phase(data.get("current_phase"), data)
|
|
120
|
+
current_number = current.get("number")
|
|
121
|
+
phases = _list_or_empty(data.get("phases"))
|
|
122
|
+
|
|
123
|
+
upcoming_raw = data.get("upcoming_phases")
|
|
124
|
+
if not isinstance(upcoming_raw, list):
|
|
125
|
+
upcoming_raw = data.get("upcoming")
|
|
126
|
+
if not isinstance(upcoming_raw, list):
|
|
127
|
+
upcoming_raw = [
|
|
128
|
+
phase
|
|
129
|
+
for phase in phases
|
|
130
|
+
if str(_phase_number(phase)) != str(current_number)
|
|
131
|
+
and str(getattr(phase, "get", lambda _k, _d=None: None)("status", "")).lower()
|
|
132
|
+
not in {"done", "complete", "completed"}
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
completed_raw = data.get("completed_phases")
|
|
136
|
+
if not isinstance(completed_raw, list):
|
|
137
|
+
completed_raw = [
|
|
138
|
+
phase
|
|
139
|
+
for phase in phases
|
|
140
|
+
if str(_phase_number(phase)) != str(current_number)
|
|
141
|
+
and str(getattr(phase, "get", lambda _k, _d=None: None)("status", "")).lower()
|
|
142
|
+
in {"done", "complete", "completed"}
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
deferred_raw = data.get("deferred")
|
|
146
|
+
if not isinstance(deferred_raw, list):
|
|
147
|
+
deferred_raw = data.get("deferred_phases", [])
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"project": data.get("project", get_project_root().name),
|
|
151
|
+
"version": str(data.get("version", "1.0")),
|
|
152
|
+
"current_phase": current,
|
|
153
|
+
"upcoming_phases": [
|
|
154
|
+
_normalize_phase_entry(phase, default_status="pending")
|
|
155
|
+
for phase in _list_or_empty(upcoming_raw)
|
|
156
|
+
],
|
|
157
|
+
"deferred": [
|
|
158
|
+
_normalize_phase_entry(phase, default_status="deferred")
|
|
159
|
+
for phase in _list_or_empty(deferred_raw)
|
|
160
|
+
],
|
|
161
|
+
"completed_phases": [
|
|
162
|
+
_normalize_phase_entry(phase, default_status="completed")
|
|
163
|
+
for phase in _list_or_empty(completed_raw)
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _load_roadmap() -> dict:
|
|
169
|
+
roadmap_file = _roadmap_file()
|
|
170
|
+
if not roadmap_file.exists():
|
|
171
|
+
stub = _create_stub_roadmap()
|
|
172
|
+
_save_roadmap(stub)
|
|
173
|
+
return stub
|
|
174
|
+
with open(roadmap_file) as f:
|
|
175
|
+
raw_data = yaml.safe_load(f) or {}
|
|
176
|
+
|
|
177
|
+
normalized = _normalize_roadmap(raw_data)
|
|
178
|
+
if normalized != raw_data:
|
|
179
|
+
_save_roadmap(normalized)
|
|
180
|
+
return normalized
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _create_stub_roadmap() -> dict:
|
|
184
|
+
return {
|
|
185
|
+
"project": get_project_root().name,
|
|
186
|
+
"version": "1.0",
|
|
187
|
+
"current_phase": {
|
|
188
|
+
"number": 1,
|
|
189
|
+
"name": "Getting Started",
|
|
190
|
+
"status": "pending",
|
|
191
|
+
"next_action": (
|
|
192
|
+
"Define your first phase: use add_phase() to queue upcoming work, "
|
|
193
|
+
"or update_next_action() to describe what needs doing next."
|
|
194
|
+
),
|
|
195
|
+
"open_changesets": [],
|
|
196
|
+
"description": "Auto-generated stub — update this to reflect your project.",
|
|
197
|
+
},
|
|
198
|
+
"upcoming_phases": [],
|
|
199
|
+
"deferred": [],
|
|
200
|
+
"completed_phases": [],
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _save_roadmap(data: dict) -> None:
|
|
205
|
+
_roadmap_file().parent.mkdir(parents=True, exist_ok=True)
|
|
206
|
+
with open(_roadmap_file(), "w") as f:
|
|
207
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ─────────────────────────────────────────────
|
|
211
|
+
# READ TOOLS
|
|
212
|
+
# ─────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
def get_roadmap() -> dict[str, Any]:
|
|
215
|
+
"""
|
|
216
|
+
Return current project state: phase, next action, open changesets, upcoming work.
|
|
217
|
+
Call this at the start of every session for quick orientation.
|
|
218
|
+
|
|
219
|
+
Returns a compact summary — use get_full_roadmap() for planning sessions.
|
|
220
|
+
"""
|
|
221
|
+
data = _load_roadmap()
|
|
222
|
+
current = data.get("current_phase", {})
|
|
223
|
+
upcoming = data.get("upcoming_phases", [])[:3] # top 3 only
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
"project": data.get("project", "My Project"),
|
|
227
|
+
"version": data.get("version", "1.0"),
|
|
228
|
+
"current_phase": {
|
|
229
|
+
"number": current.get("number"),
|
|
230
|
+
"name": current.get("name"),
|
|
231
|
+
"status": current.get("status"),
|
|
232
|
+
"next_action": current.get("next_action"),
|
|
233
|
+
"open_changesets": current.get("open_changesets", []),
|
|
234
|
+
"description": current.get("description", ""),
|
|
235
|
+
},
|
|
236
|
+
"upcoming": [
|
|
237
|
+
{
|
|
238
|
+
"phase": p.get("phase"),
|
|
239
|
+
"name": p.get("name"),
|
|
240
|
+
"priority": p.get("priority"),
|
|
241
|
+
"depends_on": p.get("depends_on", []),
|
|
242
|
+
}
|
|
243
|
+
for p in upcoming
|
|
244
|
+
],
|
|
245
|
+
"deferred_count": len(data.get("deferred", [])),
|
|
246
|
+
"completed_phases_count": len(data.get("completed_phases", [])),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_full_roadmap() -> dict[str, Any]:
|
|
251
|
+
"""
|
|
252
|
+
Return the complete roadmap: all completed phases with decisions,
|
|
253
|
+
current phase details, all upcoming phases, and all deferred items.
|
|
254
|
+
|
|
255
|
+
Use this for planning sessions or when you need the full project history.
|
|
256
|
+
More expensive than get_roadmap() — only call when you need the full picture.
|
|
257
|
+
"""
|
|
258
|
+
data = _load_roadmap()
|
|
259
|
+
return {
|
|
260
|
+
"project": data.get("project"),
|
|
261
|
+
"version": data.get("version"),
|
|
262
|
+
"current_phase": data.get("current_phase", {}),
|
|
263
|
+
"upcoming_phases": data.get("upcoming_phases", []),
|
|
264
|
+
"deferred": data.get("deferred", []),
|
|
265
|
+
"deferred_phases": data.get("deferred", []),
|
|
266
|
+
"completed_phases": data.get("completed_phases", []),
|
|
267
|
+
"summary": {
|
|
268
|
+
"completed": len(data.get("completed_phases", [])),
|
|
269
|
+
"upcoming": len(data.get("upcoming_phases", [])),
|
|
270
|
+
"deferred": len(data.get("deferred", [])),
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get_phase(phase_number: int | str) -> dict[str, Any]:
|
|
276
|
+
"""
|
|
277
|
+
Get full details of any phase by number — completed, current, or upcoming.
|
|
278
|
+
|
|
279
|
+
Useful for understanding what was decided in a past phase, or inspecting
|
|
280
|
+
a planned upcoming phase before starting it.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
phase_number: Phase number (e.g. 19, "8R", "12A")
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
Phase details including key_decisions (if completed), description, files, status.
|
|
287
|
+
"""
|
|
288
|
+
data = _load_roadmap()
|
|
289
|
+
pn = str(phase_number)
|
|
290
|
+
|
|
291
|
+
# Check current phase
|
|
292
|
+
current = data.get("current_phase", {})
|
|
293
|
+
if str(current.get("number")) == pn:
|
|
294
|
+
return {"found": True, "location": "current", "phase": current}
|
|
295
|
+
|
|
296
|
+
# Check completed phases
|
|
297
|
+
for p in data.get("completed_phases", []):
|
|
298
|
+
if str(p.get("phase")) == pn:
|
|
299
|
+
return {"found": True, "location": "completed", "phase": p}
|
|
300
|
+
|
|
301
|
+
# Check upcoming phases
|
|
302
|
+
for p in data.get("upcoming_phases", []):
|
|
303
|
+
if str(p.get("phase")) == pn:
|
|
304
|
+
return {"found": True, "location": "upcoming", "phase": p}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
"found": False,
|
|
308
|
+
"message": f"Phase {phase_number} not found in roadmap.",
|
|
309
|
+
"hint": "Use get_full_roadmap() to see all phases.",
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ─────────────────────────────────────────────
|
|
314
|
+
# PLANNING TOOLS
|
|
315
|
+
# ─────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def add_phase(
|
|
318
|
+
phase: int | str,
|
|
319
|
+
name: str,
|
|
320
|
+
description: str,
|
|
321
|
+
priority: str = "medium",
|
|
322
|
+
depends_on: list[int | str] | None = None,
|
|
323
|
+
files: list[str] | None = None,
|
|
324
|
+
effort: str | None = None,
|
|
325
|
+
) -> dict[str, Any]:
|
|
326
|
+
"""
|
|
327
|
+
Add a new upcoming phase to the roadmap.
|
|
328
|
+
|
|
329
|
+
Agents call this when they identify new work during a session —
|
|
330
|
+
e.g., discovering a gap, a refactor need, or a follow-up phase.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
phase: Phase number or label (e.g. 26, "26A")
|
|
334
|
+
name: Short phase name (e.g. "Schema Versioning")
|
|
335
|
+
description: What this phase does and why
|
|
336
|
+
priority: high | medium | low
|
|
337
|
+
depends_on: List of phase numbers that must complete first
|
|
338
|
+
files: Key files that will be touched
|
|
339
|
+
effort: Rough effort estimate (e.g. "~2 hours", "1 day")
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
success, phase added, position in upcoming queue.
|
|
343
|
+
"""
|
|
344
|
+
data = _load_roadmap()
|
|
345
|
+
upcoming = data.get("upcoming_phases", [])
|
|
346
|
+
|
|
347
|
+
# Check if phase number already exists
|
|
348
|
+
existing_phases = {str(p.get("phase")) for p in upcoming}
|
|
349
|
+
existing_phases.add(str(data.get("current_phase", {}).get("number")))
|
|
350
|
+
for p in data.get("completed_phases", []):
|
|
351
|
+
existing_phases.add(str(p.get("phase")))
|
|
352
|
+
|
|
353
|
+
if str(phase) in existing_phases:
|
|
354
|
+
return {
|
|
355
|
+
"success": False,
|
|
356
|
+
"message": f"Phase {phase} already exists in the roadmap.",
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
entry: dict[str, Any] = {
|
|
360
|
+
"phase": phase,
|
|
361
|
+
"number": phase,
|
|
362
|
+
"name": name,
|
|
363
|
+
"priority": priority,
|
|
364
|
+
"depends_on": depends_on or [],
|
|
365
|
+
"description": description,
|
|
366
|
+
"goal": description,
|
|
367
|
+
}
|
|
368
|
+
if files:
|
|
369
|
+
entry["files"] = files
|
|
370
|
+
if effort:
|
|
371
|
+
entry["effort"] = effort
|
|
372
|
+
|
|
373
|
+
# Insert by priority: high → front, medium → after existing highs, low → end
|
|
374
|
+
if priority == "high":
|
|
375
|
+
insert_at = 0
|
|
376
|
+
for i, p in enumerate(upcoming):
|
|
377
|
+
if p.get("priority") == "high":
|
|
378
|
+
insert_at = i + 1
|
|
379
|
+
upcoming.insert(insert_at, entry)
|
|
380
|
+
else:
|
|
381
|
+
upcoming.append(entry)
|
|
382
|
+
|
|
383
|
+
data["upcoming_phases"] = upcoming
|
|
384
|
+
_save_roadmap(data)
|
|
385
|
+
|
|
386
|
+
position = upcoming.index(entry) + 1
|
|
387
|
+
return {
|
|
388
|
+
"success": True,
|
|
389
|
+
"phase": phase,
|
|
390
|
+
"name": name,
|
|
391
|
+
"position_in_queue": position,
|
|
392
|
+
"total_upcoming": len(upcoming),
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def update_phase_status(
|
|
397
|
+
status: str,
|
|
398
|
+
blocker: str | None = None,
|
|
399
|
+
started: str | None = None,
|
|
400
|
+
) -> dict[str, Any]:
|
|
401
|
+
"""
|
|
402
|
+
Update the current phase's status.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
status: pending | in_progress | blocked
|
|
406
|
+
blocker: Required when status=blocked — describe what's blocking
|
|
407
|
+
started: ISO date when work started (auto-fills today if status=in_progress)
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
success, updated phase number, new status.
|
|
411
|
+
"""
|
|
412
|
+
valid = {"pending", "in_progress", "blocked"}
|
|
413
|
+
if status not in valid:
|
|
414
|
+
return {"success": False, "message": f"Invalid status '{status}'. Must be one of: {sorted(valid)}"}
|
|
415
|
+
|
|
416
|
+
if status == "blocked" and not blocker:
|
|
417
|
+
return {"success": False, "message": "blocker description required when status=blocked"}
|
|
418
|
+
|
|
419
|
+
data = _load_roadmap()
|
|
420
|
+
current = data.get("current_phase", {})
|
|
421
|
+
|
|
422
|
+
current["status"] = status
|
|
423
|
+
if status == "blocked":
|
|
424
|
+
current["blocker"] = blocker
|
|
425
|
+
elif "blocker" in current:
|
|
426
|
+
del current["blocker"]
|
|
427
|
+
if status == "in_progress" and "started" not in current:
|
|
428
|
+
current["started"] = started or date.today().isoformat()
|
|
429
|
+
|
|
430
|
+
data["current_phase"] = current
|
|
431
|
+
_save_roadmap(data)
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
"success": True,
|
|
435
|
+
"phase": current.get("number"),
|
|
436
|
+
"name": current.get("name"),
|
|
437
|
+
"status": status,
|
|
438
|
+
"blocker": blocker,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def defer_phase(
|
|
443
|
+
phase_number: int | str,
|
|
444
|
+
reason: str,
|
|
445
|
+
) -> dict[str, Any]:
|
|
446
|
+
"""
|
|
447
|
+
Move an upcoming phase to the deferred list.
|
|
448
|
+
|
|
449
|
+
Use when a phase depends on something not yet available, or when priorities
|
|
450
|
+
shift and the work is genuinely not happening soon.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
phase_number: Phase number to defer
|
|
454
|
+
reason: Why this is being deferred (preserved for future context)
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
success, phase name, reason recorded.
|
|
458
|
+
"""
|
|
459
|
+
data = _load_roadmap()
|
|
460
|
+
upcoming = data.get("upcoming_phases", [])
|
|
461
|
+
|
|
462
|
+
target = None
|
|
463
|
+
for i, p in enumerate(upcoming):
|
|
464
|
+
if str(p.get("phase")) == str(phase_number):
|
|
465
|
+
target = upcoming.pop(i)
|
|
466
|
+
break
|
|
467
|
+
|
|
468
|
+
if target is None:
|
|
469
|
+
return {
|
|
470
|
+
"success": False,
|
|
471
|
+
"message": f"Phase {phase_number} not found in upcoming phases.",
|
|
472
|
+
"hint": "Can only defer upcoming phases, not completed or current.",
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
deferred_entry = {
|
|
476
|
+
"name": target.get("name"),
|
|
477
|
+
"phase": target.get("phase"),
|
|
478
|
+
"number": target.get("number", target.get("phase")),
|
|
479
|
+
"reason": reason,
|
|
480
|
+
"deferred_date": date.today().isoformat(),
|
|
481
|
+
"original_priority": target.get("priority"),
|
|
482
|
+
"goal": target.get("goal", target.get("description")),
|
|
483
|
+
"description": target.get("description", target.get("goal", "")),
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
data["upcoming_phases"] = upcoming
|
|
487
|
+
data.setdefault("deferred", []).append(deferred_entry)
|
|
488
|
+
_save_roadmap(data)
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
"success": True,
|
|
492
|
+
"phase": phase_number,
|
|
493
|
+
"name": target.get("name"),
|
|
494
|
+
"reason": reason,
|
|
495
|
+
"remaining_upcoming": len(upcoming),
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
# ─────────────────────────────────────────────
|
|
500
|
+
# LIFECYCLE TOOLS
|
|
501
|
+
# ─────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
def complete_phase(phase_number: int | str, key_decisions: list[str]) -> dict[str, Any]:
|
|
504
|
+
"""
|
|
505
|
+
Mark the current phase as complete and advance to the next upcoming phase.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
phase_number: Must match the current phase number (safety check)
|
|
509
|
+
key_decisions: List of decisions made — preserved for all future agents
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
success, completed phase, advanced_to phase number.
|
|
513
|
+
"""
|
|
514
|
+
data = _load_roadmap()
|
|
515
|
+
current = data.get("current_phase", {})
|
|
516
|
+
|
|
517
|
+
if str(current.get("number")) != str(phase_number):
|
|
518
|
+
return {
|
|
519
|
+
"success": False,
|
|
520
|
+
"message": f"Current phase is {current.get('number')}, not {phase_number}. Cannot complete.",
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
completed_entry = {
|
|
524
|
+
"phase": current["number"],
|
|
525
|
+
"number": current["number"],
|
|
526
|
+
"name": current["name"],
|
|
527
|
+
"completed": date.today().isoformat(),
|
|
528
|
+
"key_decisions": key_decisions,
|
|
529
|
+
"goal": current.get("goal", current.get("description", "")),
|
|
530
|
+
"description": current.get("description", current.get("goal", "")),
|
|
531
|
+
}
|
|
532
|
+
if current.get("started"):
|
|
533
|
+
completed_entry["started"] = current["started"]
|
|
534
|
+
|
|
535
|
+
data.setdefault("completed_phases", []).append(completed_entry)
|
|
536
|
+
|
|
537
|
+
# Advance to next upcoming phase
|
|
538
|
+
upcoming = data.get("upcoming_phases", [])
|
|
539
|
+
if upcoming:
|
|
540
|
+
next_phase = upcoming.pop(0)
|
|
541
|
+
data["current_phase"] = {
|
|
542
|
+
"number": next_phase["phase"],
|
|
543
|
+
"name": next_phase["name"],
|
|
544
|
+
"status": "pending",
|
|
545
|
+
"next_action": f"Begin {next_phase['name']}: {next_phase.get('description', '')}".strip(": "),
|
|
546
|
+
"open_changesets": [],
|
|
547
|
+
"description": next_phase.get("description", ""),
|
|
548
|
+
"goal": next_phase.get("goal", next_phase.get("description", "")),
|
|
549
|
+
}
|
|
550
|
+
data["upcoming_phases"] = upcoming
|
|
551
|
+
advanced_to = data["current_phase"]["number"]
|
|
552
|
+
else:
|
|
553
|
+
data["current_phase"] = {
|
|
554
|
+
"number": None,
|
|
555
|
+
"name": "No upcoming phases",
|
|
556
|
+
"status": "pending",
|
|
557
|
+
"next_action": "Add new phases with add_phase() or plan the next milestone.",
|
|
558
|
+
"open_changesets": [],
|
|
559
|
+
}
|
|
560
|
+
advanced_to = None
|
|
561
|
+
|
|
562
|
+
_save_roadmap(data)
|
|
563
|
+
return {
|
|
564
|
+
"success": True,
|
|
565
|
+
"completed_phase": phase_number,
|
|
566
|
+
"key_decisions_recorded": len(key_decisions),
|
|
567
|
+
"advanced_to": advanced_to,
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def update_next_action(next_action: str) -> dict[str, Any]:
|
|
572
|
+
"""
|
|
573
|
+
Update the next_action field in the current phase.
|
|
574
|
+
Call at session end — tells the next agent exactly where to pick up.
|
|
575
|
+
"""
|
|
576
|
+
data = _load_roadmap()
|
|
577
|
+
data.setdefault("current_phase", {})["next_action"] = next_action
|
|
578
|
+
_save_roadmap(data)
|
|
579
|
+
return {"success": True, "next_action": next_action}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def add_open_changeset(changeset_id: str) -> dict[str, Any]:
|
|
583
|
+
"""Register a changeset as open in the current phase."""
|
|
584
|
+
data = _load_roadmap()
|
|
585
|
+
open_cs = data.get("current_phase", {}).get("open_changesets", [])
|
|
586
|
+
if changeset_id not in open_cs:
|
|
587
|
+
open_cs.append(changeset_id)
|
|
588
|
+
data["current_phase"]["open_changesets"] = open_cs
|
|
589
|
+
_save_roadmap(data)
|
|
590
|
+
return {"success": True, "open_changesets": open_cs}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def remove_open_changeset(changeset_id: str) -> dict[str, Any]:
|
|
594
|
+
"""Remove a resolved changeset from the current phase open list."""
|
|
595
|
+
data = _load_roadmap()
|
|
596
|
+
open_cs = data.get("current_phase", {}).get("open_changesets", [])
|
|
597
|
+
data["current_phase"]["open_changesets"] = [c for c in open_cs if c != changeset_id]
|
|
598
|
+
_save_roadmap(data)
|
|
599
|
+
return {"success": True, "open_changesets": data["current_phase"]["open_changesets"]}
|