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.
Files changed (58) hide show
  1. codevira-1.6.0.dist-info/LICENSE +21 -0
  2. codevira-1.6.0.dist-info/METADATA +477 -0
  3. codevira-1.6.0.dist-info/RECORD +58 -0
  4. codevira-1.6.0.dist-info/WHEEL +5 -0
  5. codevira-1.6.0.dist-info/entry_points.txt +2 -0
  6. codevira-1.6.0.dist-info/top_level.txt +2 -0
  7. indexer/__init__.py +1 -0
  8. indexer/chunker.py +428 -0
  9. indexer/global_db.py +197 -0
  10. indexer/graph_generator.py +380 -0
  11. indexer/index_codebase.py +588 -0
  12. indexer/outcome_tracker.py +172 -0
  13. indexer/rule_learner.py +186 -0
  14. indexer/sqlite_graph.py +640 -0
  15. indexer/treesitter_parser.py +423 -0
  16. mcp_server/__init__.py +1 -0
  17. mcp_server/__main__.py +20 -0
  18. mcp_server/auto_init.py +257 -0
  19. mcp_server/cli.py +622 -0
  20. mcp_server/crash_logger.py +236 -0
  21. mcp_server/data/__init__.py +1 -0
  22. mcp_server/data/agents/builder.md +84 -0
  23. mcp_server/data/agents/developer.md +111 -0
  24. mcp_server/data/agents/documenter.md +138 -0
  25. mcp_server/data/agents/orchestrator.md +96 -0
  26. mcp_server/data/agents/planner.md +106 -0
  27. mcp_server/data/agents/reviewer.md +82 -0
  28. mcp_server/data/agents/tester.md +83 -0
  29. mcp_server/data/config.example.yaml +33 -0
  30. mcp_server/data/rules/coding-standards.md +48 -0
  31. mcp_server/data/rules/engineering-excellence.md +28 -0
  32. mcp_server/data/rules/git-cicd-governance.md +32 -0
  33. mcp_server/data/rules/git_commits.md +130 -0
  34. mcp_server/data/rules/incremental-updates.md +5 -0
  35. mcp_server/data/rules/master_rule.md +187 -0
  36. mcp_server/data/rules/multi-language.md +19 -0
  37. mcp_server/data/rules/persistence.md +21 -0
  38. mcp_server/data/rules/resilience-observability.md +17 -0
  39. mcp_server/data/rules/smoke-testing.md +48 -0
  40. mcp_server/data/rules/testing-standards.md +23 -0
  41. mcp_server/detect.py +284 -0
  42. mcp_server/gitignore.py +284 -0
  43. mcp_server/global_sync.py +187 -0
  44. mcp_server/http_server.py +341 -0
  45. mcp_server/ide_inject.py +444 -0
  46. mcp_server/launchd.py +156 -0
  47. mcp_server/migrate.py +215 -0
  48. mcp_server/paths.py +256 -0
  49. mcp_server/prompts.py +136 -0
  50. mcp_server/server.py +1049 -0
  51. mcp_server/tools/__init__.py +0 -0
  52. mcp_server/tools/changesets.py +223 -0
  53. mcp_server/tools/code_reader.py +335 -0
  54. mcp_server/tools/graph.py +637 -0
  55. mcp_server/tools/learning.py +238 -0
  56. mcp_server/tools/playbook.py +89 -0
  57. mcp_server/tools/roadmap.py +599 -0
  58. 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"]}