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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- 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
|