multi-forge 0.2.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 (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
forge/core/ops/gc.py ADDED
@@ -0,0 +1,802 @@
1
+ """Garbage collection operations (command-core).
2
+
3
+ Detects and removes orphaned Forge state:
4
+ - Session directories not in the global index
5
+ - Handoff files for sessions not in the index
6
+ - Stale active-session entries (dead PIDs)
7
+ - Stale work-queue markers (session gone or worktree gone)
8
+ - Stale proxy entries (dead PIDs, orphaned "starting" state)
9
+ - Orphaned search documents (transcript files deleted)
10
+
11
+ All detect functions are read-only (no mutations). The run_clean()
12
+ function is the only mutator.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import shutil
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+
23
+ from .context import ExecutionContext
24
+
25
+ _log = logging.getLogger(__name__)
26
+
27
+ VALID_SCOPES = {"repo", "project", "all"}
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Result dataclasses
32
+ # ---------------------------------------------------------------------------
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class OrphanCategory:
37
+ """A single category of detected orphans."""
38
+
39
+ category: str
40
+ description: str
41
+ count: int
42
+ items: list[str]
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class CleanReport:
47
+ """Aggregated orphan detection report (read-only)."""
48
+
49
+ categories: list[OrphanCategory]
50
+ scope: str
51
+
52
+ @property
53
+ def total_count(self) -> int:
54
+ return sum(c.count for c in self.categories)
55
+
56
+ @property
57
+ def is_clean(self) -> bool:
58
+ return self.total_count == 0
59
+
60
+
61
+ @dataclass
62
+ class CleanResult:
63
+ """Result of an actual cleanup run."""
64
+
65
+ categories_cleaned: dict[str, int] = field(default_factory=dict)
66
+ failed: list[tuple[str, str]] = field(default_factory=list)
67
+
68
+ @property
69
+ def deleted_count(self) -> int:
70
+ return sum(self.categories_cleaned.values())
71
+
72
+
73
+ class CleanError(RuntimeError):
74
+ """Raised when forge clean cannot proceed."""
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Forge-root discovery
79
+ # ---------------------------------------------------------------------------
80
+
81
+
82
+ def _resolve_tracked_roots(ctx: ExecutionContext, scope: str) -> set[Path]:
83
+ """Build the set of forge_roots to scan from tracked sources.
84
+
85
+ Sources (no filesystem crawl):
86
+ 1. ctx.forge_root (current project)
87
+ 2. Session index entries (filtered by scope)
88
+ 3. Installed manifest entries (project_path)
89
+
90
+ Raises CleanError for --scope project when no forge_root.
91
+ """
92
+ if scope == "project":
93
+ if ctx.forge_root is None:
94
+ raise CleanError("Not inside a Forge project. Run from a directory with .forge/ or use --scope repo.")
95
+ return {ctx.forge_root}
96
+
97
+ from forge.session import SessionManager
98
+
99
+ manager = SessionManager()
100
+
101
+ if scope == "repo":
102
+ entries = manager.list_sessions(
103
+ include_incognito=True,
104
+ project_root_filter=str(ctx.project_root),
105
+ )
106
+ else: # "all"
107
+ entries = manager.list_sessions(include_incognito=True)
108
+
109
+ roots: set[Path] = set()
110
+ for _name, entry in entries:
111
+ fr = entry.forge_root or entry.worktree_path
112
+ if fr:
113
+ roots.add(Path(fr))
114
+
115
+ # Add current forge_root
116
+ if ctx.forge_root is not None:
117
+ roots.add(ctx.forge_root)
118
+
119
+ # Add installed-manifest roots. For repo scope, match by project_root
120
+ # from the index entries rather than path containment, because git
121
+ # worktrees are typically siblings of the main checkout, not children.
122
+ # `roots` at this point contains index-derived roots (already filtered
123
+ # by project_root for repo scope).
124
+ index_roots = set(roots)
125
+ try:
126
+ from forge.install.tracking import TrackingStore
127
+
128
+ manifest = TrackingStore().read()
129
+ for _key, installation in manifest.installations.items():
130
+ pp = installation.project_path
131
+ if pp is None:
132
+ continue
133
+ p = Path(pp)
134
+ if scope == "repo" and not _belongs_to_project(p, ctx.project_root, index_roots):
135
+ continue
136
+ if p.is_dir() and (p / ".forge").is_dir():
137
+ roots.add(p)
138
+ except Exception:
139
+ _log.debug("Could not read installed manifest for root discovery", exc_info=True)
140
+
141
+ return roots
142
+
143
+
144
+ def _belongs_to_project(candidate: Path, project_root: Path, known_roots: set[Path]) -> bool:
145
+ """Check if candidate belongs to the same logical project.
146
+
147
+ Handles sibling worktrees (common git layout) that live beside the
148
+ main checkout rather than under it. Two checks:
149
+ 1. Path containment (regular subdirectories)
150
+ 2. Already in the known roots set (discovered via session index,
151
+ which records project_root per entry)
152
+ """
153
+ resolved = candidate.resolve()
154
+ # Direct containment (subdirectory or equal)
155
+ try:
156
+ resolved.relative_to(project_root.resolve())
157
+ return True
158
+ except ValueError:
159
+ pass
160
+ # Already discovered via index (which filters by project_root)
161
+ return resolved in known_roots
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Reference set
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def _list_reference_entries(
170
+ ctx: ExecutionContext,
171
+ scope: str,
172
+ ) -> list[tuple[str, str, str | None]]:
173
+ """Return scoped session reference tuples from the index.
174
+
175
+ Each tuple contains ``(name, forge_root, worktree_path)`` for categories
176
+ that need different identity axes.
177
+ """
178
+ from forge.session import SessionManager
179
+
180
+ manager = SessionManager()
181
+
182
+ if scope == "project" and ctx.forge_root is not None:
183
+ entries = manager.list_sessions(
184
+ include_incognito=True,
185
+ forge_root_filter=str(ctx.forge_root),
186
+ )
187
+ elif scope == "repo":
188
+ entries = manager.list_sessions(
189
+ include_incognito=True,
190
+ project_root_filter=str(ctx.project_root),
191
+ )
192
+ else: # "all"
193
+ entries = manager.list_sessions(include_incognito=True)
194
+
195
+ return [(name, entry.forge_root or entry.worktree_path, entry.worktree_path) for name, entry in entries]
196
+
197
+
198
+ def _build_reference_set(ctx: ExecutionContext, scope: str, scope_roots: set[Path]) -> set[tuple[str, str]]:
199
+ """Build the set of (session_name, forge_root) tuples from the index.
200
+
201
+ Uses list_sessions() which triggers self-healing. The returned set
202
+ is filtered to only include sessions whose forge_root is in scope_roots.
203
+ """
204
+ result: set[tuple[str, str]] = set()
205
+ for name, forge_root, _worktree_path in _list_reference_entries(ctx, scope):
206
+ if forge_root and Path(forge_root) in scope_roots:
207
+ result.add((name, forge_root))
208
+ return result
209
+
210
+
211
+ def _build_worktree_reference_set(ctx: ExecutionContext, scope: str, scope_roots: set[Path]) -> set[tuple[str, str]]:
212
+ """Build the set of (session_name, worktree_path) tuples for queue markers."""
213
+ result: set[tuple[str, str]] = set()
214
+ for name, _forge_root, worktree_path in _list_reference_entries(ctx, scope):
215
+ if worktree_path and _path_in_roots(Path(worktree_path), scope_roots):
216
+ result.add((name, worktree_path))
217
+ return result
218
+
219
+
220
+ def _build_handoff_context_reference_set(ref_set: set[tuple[str, str]]) -> set[str]:
221
+ """Build absolute paths referenced by session derivation context_file fields."""
222
+ from forge.session import SessionStore
223
+
224
+ result: set[str] = set()
225
+ for name, forge_root in ref_set:
226
+ try:
227
+ state = SessionStore(forge_root, name).read()
228
+ except Exception:
229
+ _log.debug("Could not read session manifest for handoff GC: %s (%s)", name, forge_root, exc_info=True)
230
+ continue
231
+
232
+ derivation = state.confirmed.derivation
233
+ if derivation is None or not derivation.context_file:
234
+ continue
235
+
236
+ context_path = Path(derivation.context_file).expanduser()
237
+ if not context_path.is_absolute():
238
+ context_path = Path(forge_root) / context_path
239
+ result.add(str(context_path.resolve()))
240
+
241
+ return result
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Pure detect functions (read-only)
246
+ # ---------------------------------------------------------------------------
247
+
248
+
249
+ def _detect_orphan_session_dirs(ref_set: set[tuple[str, str]], forge_roots: set[Path]) -> OrphanCategory:
250
+ """Find session directories not in the index for their forge_root."""
251
+ orphans: list[str] = []
252
+ for forge_root in forge_roots:
253
+ sessions_dir = forge_root / ".forge" / "sessions"
254
+ if not sessions_dir.is_dir():
255
+ continue
256
+ for child in sessions_dir.iterdir():
257
+ if not child.is_dir():
258
+ continue
259
+ name = child.name
260
+ if (name, str(forge_root)) not in ref_set:
261
+ # Verify it has content (not just an empty dir)
262
+ manifest = child / "forge.session.json"
263
+ if manifest.is_file() or any(child.iterdir()):
264
+ orphans.append(str(child))
265
+ return OrphanCategory(
266
+ category="session_dirs",
267
+ description="Session directories not in the index",
268
+ count=len(orphans),
269
+ items=sorted(orphans),
270
+ )
271
+
272
+
273
+ def _detect_orphan_handoff_files(ref_set: set[tuple[str, str]], forge_roots: set[Path]) -> OrphanCategory:
274
+ """Find orphaned resume-context artifacts under ``prev_sessions/``.
275
+
276
+ Walks the per-parent layout (``<parent>/generated.md`` +
277
+ ``<parent>/children/<child>.md``) and identifies three kinds of orphans:
278
+
279
+ 1. ``<parent>/`` directories whose parent session is not in the index --
280
+ the whole directory is orphaned (rmtree).
281
+ 2. ``children/<child>.md`` files not referenced by any child session's
282
+ ``Derivation.context_file`` (within a still-referenced parent dir) --
283
+ just the file.
284
+ 3. Top-level ``<parent>.md`` files (legacy pre-0.2.0 flat layout) --
285
+ always orphaned since new code never writes here.
286
+
287
+ Parent liveness checks (session_name, forge_root) against the ref_set to
288
+ handle name reuse across different Forge projects correctly. Child files
289
+ are kept only when an indexed session derivation references that exact path.
290
+ """
291
+ from forge.session import prev_sessions as _ps
292
+
293
+ orphans: list[str] = []
294
+ referenced_context_files = _build_handoff_context_reference_set(ref_set)
295
+
296
+ for forge_root in forge_roots:
297
+ prev_root = _ps.prev_sessions_root(forge_root)
298
+ if not prev_root.is_dir():
299
+ continue
300
+ names_in_root = {name for name, fr in ref_set if fr == str(forge_root)}
301
+
302
+ # 1 + 2: per-parent directories and their child files
303
+ for parent_dir_path in _ps.iter_parents(forge_root):
304
+ parent_name = parent_dir_path.name
305
+ child_files = list(_ps.iter_children(forge_root, parent_name))
306
+ referenced_children = [
307
+ child_file for child_file in child_files if str(child_file.resolve()) in referenced_context_files
308
+ ]
309
+
310
+ if parent_name not in names_in_root and not referenced_children:
311
+ orphans.append(str(parent_dir_path))
312
+ continue
313
+
314
+ # Parent dir is live either because the parent session lives in
315
+ # this Forge root, or because a cross-worktree child references a
316
+ # child context file under it. Remove only unreferenced children.
317
+ for child_file in child_files:
318
+ if str(child_file.resolve()) not in referenced_context_files:
319
+ orphans.append(str(child_file))
320
+
321
+ # 3: legacy flat files at the top of prev_sessions/
322
+ for legacy_file in _ps.iter_legacy_flat_files(forge_root):
323
+ orphans.append(str(legacy_file))
324
+
325
+ return OrphanCategory(
326
+ category="handoff_files",
327
+ description="Orphaned resume-context artifacts (handoff files)",
328
+ count=len(orphans),
329
+ items=sorted(orphans),
330
+ )
331
+
332
+
333
+ def _detect_stale_active_entries(scope_roots: set[Path]) -> OrphanCategory:
334
+ """Find active-session entries with dead PIDs, scoped by worktree_path.
335
+
336
+ Read-only: reads the active index and checks liveness without mutating.
337
+ """
338
+ from forge.session.active import ActiveSessionStore
339
+
340
+ store = ActiveSessionStore()
341
+ try:
342
+ index = store.read()
343
+ except Exception:
344
+ _log.debug("Could not read active session index", exc_info=True)
345
+ return OrphanCategory(
346
+ category="active_entries",
347
+ description="Stale active-session entries (dead PIDs)",
348
+ count=0,
349
+ items=[],
350
+ )
351
+
352
+ from forge.session.identity import session_name_from_key
353
+
354
+ stale: list[str] = []
355
+ for key, entry in index.sessions.items():
356
+ entry_path = Path(entry.worktree_path)
357
+ if not _path_in_roots(entry_path, scope_roots):
358
+ continue
359
+ if not store._entry_is_live(entry):
360
+ # Encode display_name::forge_root so the clean phase can
361
+ # pass forge_root to clear_session for exact scoped deletion.
362
+ display_name = session_name_from_key(key)
363
+ forge_root = entry.forge_root or entry.worktree_path
364
+ stale.append(f"{display_name}::{forge_root}")
365
+
366
+ return OrphanCategory(
367
+ category="active_entries",
368
+ description="Stale active-session entries (dead PIDs)",
369
+ count=len(stale),
370
+ items=sorted(stale),
371
+ )
372
+
373
+
374
+ def _detect_stale_work_queue(ref_set: set[tuple[str, str]], scope_roots: set[Path]) -> OrphanCategory:
375
+ """Find pending work-queue markers for sessions not in the index.
376
+
377
+ Scoped by worktree_path in the marker payload. Checks
378
+ (session_name, worktree_path) against the ref_set to avoid
379
+ cross-root name masking.
380
+ Read-only: reads marker files without mutation.
381
+ """
382
+ from forge.core.paths import get_forge_home
383
+
384
+ queue_dir = get_forge_home() / "pending-work"
385
+ if not queue_dir.is_dir():
386
+ return OrphanCategory(
387
+ category="work_queue",
388
+ description="Stale work-queue markers",
389
+ count=0,
390
+ items=[],
391
+ )
392
+
393
+ stale: list[str] = []
394
+
395
+ for marker_file in queue_dir.iterdir():
396
+ if not marker_file.is_file() or marker_file.suffix != ".json":
397
+ continue
398
+ try:
399
+ data = json.loads(marker_file.read_text(encoding="utf-8"))
400
+ payload = data.get("payload", {})
401
+ wt_path = payload.get("worktree_path", "")
402
+ session_name = payload.get("session_name", "")
403
+
404
+ # Scope filter: skip markers outside scope roots
405
+ if wt_path and not _path_in_roots(Path(wt_path), scope_roots):
406
+ continue
407
+
408
+ # Orphan check: session (name, worktree_path) not in ref_set
409
+ if session_name and (session_name, wt_path) not in ref_set:
410
+ stale.append(str(marker_file))
411
+ except (json.JSONDecodeError, OSError):
412
+ _log.debug("Could not read work-queue marker %s", marker_file, exc_info=True)
413
+ continue
414
+
415
+ return OrphanCategory(
416
+ category="work_queue",
417
+ description="Stale work-queue markers",
418
+ count=len(stale),
419
+ items=sorted(stale),
420
+ )
421
+
422
+
423
+ def _detect_stale_proxies() -> OrphanCategory:
424
+ """Find proxy entries with dead PIDs or orphaned starting state.
425
+
426
+ Read-only: reads the proxy registry without mutation.
427
+ Global scope (proxies have no project affinity).
428
+ """
429
+ from forge.core.process import is_pid_alive
430
+ from forge.proxy.proxies import ProxyRegistryStore, _is_orphaned_starting
431
+
432
+ try:
433
+ store = ProxyRegistryStore()
434
+ registry = store.read()
435
+ except Exception:
436
+ _log.debug("Could not read proxy registry", exc_info=True)
437
+ return OrphanCategory(
438
+ category="proxies",
439
+ description="Stale proxy entries (dead PIDs)",
440
+ count=0,
441
+ items=[],
442
+ )
443
+
444
+ stale: list[str] = []
445
+ for proxy_id, entry in registry.proxies.items():
446
+ if entry.pid is not None:
447
+ if not is_pid_alive(entry.pid):
448
+ stale.append(proxy_id)
449
+ elif entry.status == "starting" and _is_orphaned_starting(entry):
450
+ stale.append(proxy_id)
451
+
452
+ return OrphanCategory(
453
+ category="proxies",
454
+ description="Stale proxy entries (dead PIDs)",
455
+ count=len(stale),
456
+ items=sorted(stale),
457
+ )
458
+
459
+
460
+ def _detect_orphan_search_docs(forge_roots: set[Path]) -> OrphanCategory:
461
+ """Find search-index documents whose transcript files no longer exist.
462
+
463
+ Read-only: reads the document store without calling prune_missing().
464
+ """
465
+ orphans: list[str] = []
466
+
467
+ for forge_root in forge_roots:
468
+ try:
469
+ from forge.search.store import SearchDocumentStore
470
+
471
+ doc_store = SearchDocumentStore(forge_root=forge_root)
472
+ docs = doc_store.read()
473
+ for doc in docs:
474
+ if not Path(doc.transcript_path).is_file():
475
+ orphans.append(doc.transcript_path)
476
+ except Exception:
477
+ _log.debug("Could not read search store for %s", forge_root, exc_info=True)
478
+ continue
479
+
480
+ return OrphanCategory(
481
+ category="search_docs",
482
+ description="Orphaned search documents (transcript deleted)",
483
+ count=len(orphans),
484
+ items=sorted(orphans),
485
+ )
486
+
487
+
488
+ def _detect_dead_installations() -> OrphanCategory:
489
+ """Find installed-manifest entries whose project_path no longer exists.
490
+
491
+ Always global (like proxies): installed.json is global state in
492
+ ~/.forge/. A dead path is dead regardless of which repo you're in,
493
+ and dead paths can't be scoped by containment (they don't exist).
494
+ """
495
+ try:
496
+ from forge.install.models import parse_installation_key
497
+ from forge.install.tracking import TrackingStore
498
+
499
+ manifest = TrackingStore().read()
500
+ except Exception:
501
+ _log.debug("Could not read installed manifest", exc_info=True)
502
+ return OrphanCategory(
503
+ category="dead_installations",
504
+ description="Installed-manifest entries for missing paths",
505
+ count=0,
506
+ items=[],
507
+ )
508
+
509
+ dead: list[str] = []
510
+ for key, installation in manifest.installations.items():
511
+ pp = installation.project_path
512
+ if pp is None:
513
+ continue
514
+ if not Path(pp).is_dir():
515
+ inst_scope, _ = parse_installation_key(key)
516
+ dead.append(f"{inst_scope}:{pp}")
517
+
518
+ return OrphanCategory(
519
+ category="dead_installations",
520
+ description="Installed-manifest entries for missing paths",
521
+ count=len(dead),
522
+ items=sorted(dead),
523
+ )
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # Helpers
528
+ # ---------------------------------------------------------------------------
529
+
530
+
531
+ def _path_in_roots(candidate: Path, roots: set[Path]) -> bool:
532
+ """Check if candidate path is under (or equal to) any root in the set.
533
+
534
+ Returns False for an empty root set — prevents repo-scope from
535
+ silently widening to global scope when no tracked roots exist.
536
+ """
537
+ if not roots:
538
+ return False
539
+ resolved = candidate.resolve()
540
+ for root in roots:
541
+ try:
542
+ resolved.relative_to(root.resolve())
543
+ return True
544
+ except ValueError:
545
+ continue
546
+ return False
547
+
548
+
549
+ # ---------------------------------------------------------------------------
550
+ # Report (read-only)
551
+ # ---------------------------------------------------------------------------
552
+
553
+
554
+ def collect_clean_report(*, ctx: ExecutionContext, scope: str = "repo") -> CleanReport:
555
+ """Scan for orphaned objects and return a report.
556
+
557
+ Pure detection: no mutations. Safe for dry-run.
558
+
559
+ Raises:
560
+ CleanError: If scope=project and no forge_root.
561
+ """
562
+ if scope not in VALID_SCOPES:
563
+ raise CleanError(f"Invalid scope: {scope!r}. Must be one of {VALID_SCOPES}")
564
+
565
+ scope_roots = _resolve_tracked_roots(ctx, scope)
566
+
567
+ ref_set = _build_reference_set(ctx, scope, scope_roots)
568
+ worktree_ref_set = _build_worktree_reference_set(ctx, scope, scope_roots)
569
+
570
+ categories = [
571
+ _detect_orphan_session_dirs(ref_set, scope_roots),
572
+ _detect_orphan_handoff_files(ref_set, scope_roots),
573
+ _detect_stale_active_entries(scope_roots),
574
+ _detect_stale_work_queue(worktree_ref_set, scope_roots),
575
+ _detect_stale_proxies(),
576
+ _detect_orphan_search_docs(scope_roots),
577
+ _detect_dead_installations(),
578
+ ]
579
+
580
+ return CleanReport(categories=categories, scope=scope)
581
+
582
+
583
+ # ---------------------------------------------------------------------------
584
+ # Cleanup (mutating)
585
+ # ---------------------------------------------------------------------------
586
+
587
+
588
+ def run_clean(*, ctx: ExecutionContext, scope: str = "repo") -> CleanResult:
589
+ """Detect orphaned objects and delete them.
590
+
591
+ Calls collect_clean_report() first, then performs deletions.
592
+
593
+ Raises:
594
+ CleanError: If scope=project and no forge_root.
595
+ """
596
+ report = collect_clean_report(ctx=ctx, scope=scope)
597
+ result = CleanResult()
598
+
599
+ for category in report.categories:
600
+ if category.count == 0:
601
+ continue
602
+
603
+ cleaned = 0
604
+ if category.category == "session_dirs":
605
+ cleaned = _clean_session_dirs(category.items, result)
606
+ elif category.category == "handoff_files":
607
+ cleaned = _clean_handoff_files(category.items, result)
608
+ elif category.category == "active_entries":
609
+ cleaned = _clean_active_entries(category.items)
610
+ elif category.category == "work_queue":
611
+ cleaned = _clean_files(category.items, result)
612
+ elif category.category == "proxies":
613
+ cleaned = _clean_proxies()
614
+ elif category.category == "search_docs":
615
+ cleaned = _clean_search_docs(report, result)
616
+ elif category.category == "dead_installations":
617
+ cleaned = _clean_dead_installations(category.items, result)
618
+
619
+ if cleaned > 0:
620
+ result.categories_cleaned[category.category] = cleaned
621
+
622
+ return result
623
+
624
+
625
+ def _clean_session_dirs(items: list[str], result: CleanResult) -> int:
626
+ """Remove orphaned session directories."""
627
+ cleaned = 0
628
+ for path_str in items:
629
+ try:
630
+ shutil.rmtree(path_str)
631
+ cleaned += 1
632
+ except OSError as e:
633
+ result.failed.append((path_str, str(e)))
634
+ return cleaned
635
+
636
+
637
+ def _clean_files(items: list[str], result: CleanResult) -> int:
638
+ """Remove orphaned files (work-queue markers)."""
639
+ cleaned = 0
640
+ for path_str in items:
641
+ try:
642
+ Path(path_str).unlink()
643
+ cleaned += 1
644
+ except OSError as e:
645
+ result.failed.append((path_str, str(e)))
646
+ return cleaned
647
+
648
+
649
+ def _clean_handoff_files(items: list[str], result: CleanResult) -> int:
650
+ """Remove orphaned resume-context artifacts.
651
+
652
+ Items may be:
653
+ - ``<parent>/`` directories (whole orphaned parents) -- rmtree
654
+ - ``<parent>/children/<child>.md`` files -- unlink, then prune empty
655
+ ``children/`` and parent dirs that contain only ``generated.md``
656
+ - Top-level ``<parent>.md`` legacy flat files -- unlink
657
+ """
658
+ cleaned = 0
659
+ dirs_to_check: set[Path] = set()
660
+
661
+ for path_str in items:
662
+ path = Path(path_str)
663
+ try:
664
+ if path.is_dir():
665
+ shutil.rmtree(path)
666
+ cleaned += 1
667
+ elif path.is_file():
668
+ # Track parent for empty-dir cleanup after unlinking
669
+ if path.parent.name == "children":
670
+ dirs_to_check.add(path.parent)
671
+ path.unlink()
672
+ cleaned += 1
673
+ except OSError as e:
674
+ result.failed.append((path_str, str(e)))
675
+
676
+ # Post-cleanup: drop empty children/ and parent dirs left behind.
677
+ # If parent dir contains only generated.md (no children left), the cache
678
+ # is dead weight too -- the whole parent dir goes.
679
+ for children_dir in dirs_to_check:
680
+ try:
681
+ if not children_dir.is_dir() or any(children_dir.iterdir()):
682
+ continue
683
+ children_dir.rmdir()
684
+ parent = children_dir.parent
685
+ if not parent.is_dir():
686
+ continue
687
+ remaining = list(parent.iterdir())
688
+ if not remaining or (len(remaining) == 1 and remaining[0].name == "generated.md"):
689
+ shutil.rmtree(parent)
690
+ except OSError:
691
+ # Best-effort post-cleanup
692
+ pass
693
+
694
+ return cleaned
695
+
696
+
697
+ def _clean_active_entries(items: list[str]) -> int:
698
+ """Clean only the specific stale active-session entries detected.
699
+
700
+ Does NOT call list_sessions() which would self-heal the entire
701
+ registry — that would clean entries outside the requested scope.
702
+ """
703
+ from forge.session.active import ActiveSessionStore
704
+
705
+ store = ActiveSessionStore()
706
+ cleaned = 0
707
+ for item in items:
708
+ # Items encoded as "display_name::forge_root" by detect phase
709
+ if "::" in item:
710
+ name, forge_root = item.split("::", 1)
711
+ else:
712
+ name, forge_root = item, None
713
+ try:
714
+ if store.clear_session(name, forge_root=forge_root):
715
+ cleaned += 1
716
+ except Exception:
717
+ pass
718
+ return cleaned
719
+
720
+
721
+ def _clean_proxies() -> int:
722
+ """Clean stale proxy entries by delegating to existing prune function."""
723
+ from forge.proxy.proxy_orchestrator import prune_stale_proxies
724
+
725
+ result = prune_stale_proxies()
726
+ return len(result.pruned_proxy_ids)
727
+
728
+
729
+ def _clean_dead_installations(items: list[str], result: CleanResult) -> int:
730
+ """Remove installed-manifest entries whose project_path no longer exists."""
731
+ from forge.install.tracking import TrackingStore
732
+
733
+ store = TrackingStore()
734
+ cleaned = 0
735
+ for item in items:
736
+ # Items are "scope:path" strings
737
+ parts = item.split(":", 1)
738
+ if len(parts) != 2:
739
+ continue
740
+ scope, project_path = parts
741
+ try:
742
+ if store.remove_installation(scope, project_path):
743
+ cleaned += 1
744
+ except Exception as e:
745
+ result.failed.append((item, str(e)))
746
+ return cleaned
747
+
748
+
749
+ def _clean_search_docs(report: CleanReport, result: CleanResult) -> int:
750
+ """Clean orphaned search documents per forge_root."""
751
+ from forge.search.bm25_store import BM25IndexStore
752
+ from forge.search.content_store import ContentStore
753
+ from forge.search.index_state import IndexStateStore
754
+ from forge.search.store import SearchDocumentStore
755
+
756
+ # Collect forge_roots from the scope_roots used to generate the report
757
+ # We re-derive from the search_docs category items (transcript paths)
758
+ search_cat = next((c for c in report.categories if c.category == "search_docs"), None)
759
+ if search_cat is None or search_cat.count == 0:
760
+ return 0
761
+
762
+ # Group orphaned transcript paths by forge_root
763
+ # We need to know which forge_root each transcript belongs to.
764
+ # Re-scan the forge_roots and prune per-root.
765
+ cleaned = 0
766
+ scope_roots = _extract_forge_roots_from_report(report)
767
+
768
+ for forge_root in scope_roots:
769
+ try:
770
+ doc_store = SearchDocumentStore(forge_root=forge_root)
771
+ bm25_store = BM25IndexStore(forge_root=forge_root)
772
+ content_store = ContentStore(forge_root=forge_root)
773
+ index_store = IndexStateStore(forge_root=forge_root)
774
+
775
+ removed_docs = doc_store.prune_missing()
776
+ for path in removed_docs:
777
+ bm25_store.remove_document(path)
778
+ content_store.remove(path)
779
+ removed_index = index_store.prune_missing()
780
+ cleaned += len(removed_docs) + len(removed_index)
781
+ except Exception as e:
782
+ result.failed.append((str(forge_root), str(e)))
783
+
784
+ return cleaned
785
+
786
+
787
+ def _extract_forge_roots_from_report(report: CleanReport) -> set[Path]:
788
+ """Extract forge_roots that had search orphans from the report items."""
789
+ search_cat = next((c for c in report.categories if c.category == "search_docs"), None)
790
+ if search_cat is None:
791
+ return set()
792
+
793
+ roots: set[Path] = set()
794
+ for transcript_path in search_cat.items:
795
+ # Transcript paths are under <forge_root>/.forge/artifacts/
796
+ p = Path(transcript_path)
797
+ # Walk up to find .forge/
798
+ for parent in p.parents:
799
+ if parent.name == ".forge" and parent.parent.is_dir():
800
+ roots.add(parent.parent)
801
+ break
802
+ return roots