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/session/index.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
"""Session index operations for ~/.forge/sessions/index.json.
|
|
2
|
+
|
|
3
|
+
Session names are project-scoped. The index dict uses compound keys
|
|
4
|
+
(``name|sha256(forge_root)[:12]``) so the same session name can exist
|
|
5
|
+
in different Forge projects. All external APIs accept display names
|
|
6
|
+
(``planner``) and resolve internally via the identity helpers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from dataclasses import asdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import dacite
|
|
17
|
+
|
|
18
|
+
from forge.core.paths import get_forge_home
|
|
19
|
+
from forge.core.state import (
|
|
20
|
+
atomic_write_json,
|
|
21
|
+
file_lock_for_target,
|
|
22
|
+
iso_to_timestamp,
|
|
23
|
+
now_iso,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .exceptions import (
|
|
27
|
+
IndexCorruptedError,
|
|
28
|
+
InvalidSessionNameError,
|
|
29
|
+
SessionExistsError,
|
|
30
|
+
SessionNotFoundError,
|
|
31
|
+
)
|
|
32
|
+
from .identity import (
|
|
33
|
+
make_scoped_key,
|
|
34
|
+
resolve_key_best_effort,
|
|
35
|
+
resolve_key_strict,
|
|
36
|
+
session_name_from_key,
|
|
37
|
+
)
|
|
38
|
+
from .models import (
|
|
39
|
+
INDEX_VERSION,
|
|
40
|
+
SessionIndex,
|
|
41
|
+
SessionIndexEntry,
|
|
42
|
+
SessionState,
|
|
43
|
+
)
|
|
44
|
+
from .store import get_manifest_path
|
|
45
|
+
from .validation import validate_name
|
|
46
|
+
|
|
47
|
+
_log = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# Constants
|
|
50
|
+
INDEX_DIR = "sessions"
|
|
51
|
+
INDEX_FILENAME = "index.json"
|
|
52
|
+
|
|
53
|
+
CLI_LOCK_TIMEOUT_S = 5.0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_index_path() -> Path:
|
|
57
|
+
"""Get the full path to the session index file."""
|
|
58
|
+
return get_forge_home() / INDEX_DIR / INDEX_FILENAME
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IndexStore:
|
|
62
|
+
"""Manage the global session index at ~/.forge/sessions/index.json.
|
|
63
|
+
|
|
64
|
+
The index enables fast session listing without scanning all worktrees.
|
|
65
|
+
It stores minimal metadata for each session, keyed by session name.
|
|
66
|
+
|
|
67
|
+
Error handling:
|
|
68
|
+
- Missing file: returns empty index (self-healing)
|
|
69
|
+
- Corrupted file: raises IndexCorruptedError (don't hide data loss)
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, index_path: Path | None = None) -> None:
|
|
73
|
+
"""Initialize the index store.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
index_path: Override path for testing. Defaults to ~/.forge/sessions/index.json.
|
|
77
|
+
"""
|
|
78
|
+
self._index_path = index_path or get_index_path()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def index_path(self) -> Path:
|
|
82
|
+
"""Return the path to the index file."""
|
|
83
|
+
return self._index_path
|
|
84
|
+
|
|
85
|
+
def exists(self) -> bool:
|
|
86
|
+
"""Check if the index file exists."""
|
|
87
|
+
return self._index_path.is_file()
|
|
88
|
+
|
|
89
|
+
def read(self) -> SessionIndex:
|
|
90
|
+
"""Read the session index.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
SessionIndex: The index, or empty index if file doesn't exist.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
IndexCorruptedError: If file exists but cannot be parsed.
|
|
97
|
+
"""
|
|
98
|
+
if not self.exists():
|
|
99
|
+
return SessionIndex()
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
with open(self._index_path, encoding="utf-8") as f:
|
|
103
|
+
data = json.load(f)
|
|
104
|
+
except json.JSONDecodeError as e:
|
|
105
|
+
raise IndexCorruptedError(str(self._index_path), f"invalid JSON: {e}")
|
|
106
|
+
except OSError as e:
|
|
107
|
+
raise IndexCorruptedError(str(self._index_path), f"read error: {e}")
|
|
108
|
+
|
|
109
|
+
# Validate version
|
|
110
|
+
version = data.get("version")
|
|
111
|
+
if version is None:
|
|
112
|
+
raise IndexCorruptedError(str(self._index_path), "missing version field")
|
|
113
|
+
if version != INDEX_VERSION:
|
|
114
|
+
raise IndexCorruptedError(
|
|
115
|
+
str(self._index_path),
|
|
116
|
+
f"incompatible version {version} (this Forge expects {INDEX_VERSION}). " f"Delete this file and retry.",
|
|
117
|
+
)
|
|
118
|
+
self._validate_key_shape(data)
|
|
119
|
+
|
|
120
|
+
# Deserialize using dacite
|
|
121
|
+
try:
|
|
122
|
+
index = dacite.from_dict(
|
|
123
|
+
data_class=SessionIndex,
|
|
124
|
+
data=data,
|
|
125
|
+
config=dacite.Config(strict=True),
|
|
126
|
+
)
|
|
127
|
+
except (dacite.DaciteError, TypeError, KeyError) as e:
|
|
128
|
+
raise IndexCorruptedError(str(self._index_path), f"deserialization error: {e}")
|
|
129
|
+
|
|
130
|
+
return index
|
|
131
|
+
|
|
132
|
+
def _validate_key_shape(self, data: dict[str, object]) -> None:
|
|
133
|
+
"""Reject pre-OSS v1 indexes that used bare session-name keys."""
|
|
134
|
+
sessions = data.get("sessions")
|
|
135
|
+
if not isinstance(sessions, dict):
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
for key, entry_data in sessions.items():
|
|
139
|
+
if not isinstance(key, str):
|
|
140
|
+
raise IndexCorruptedError(str(self._index_path), "session index keys must be strings")
|
|
141
|
+
if not isinstance(entry_data, dict):
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
root = entry_data.get("forge_root") or entry_data.get("worktree_path")
|
|
145
|
+
if not isinstance(root, str) or not root:
|
|
146
|
+
raise IndexCorruptedError(
|
|
147
|
+
str(self._index_path),
|
|
148
|
+
f"invalid session index entry for '{key}': missing forge_root/worktree_path",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
display_name = session_name_from_key(key)
|
|
152
|
+
expected_key = make_scoped_key(display_name, root)
|
|
153
|
+
if key != expected_key:
|
|
154
|
+
raise IndexCorruptedError(
|
|
155
|
+
str(self._index_path),
|
|
156
|
+
"unsupported pre-OSS session index shape: "
|
|
157
|
+
"expected scoped keys; delete ~/.forge/sessions/index.json and rerun Forge",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def write(self, index: SessionIndex) -> None:
|
|
161
|
+
"""Write the session index atomically.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
index: The index to write.
|
|
165
|
+
"""
|
|
166
|
+
data = asdict(index)
|
|
167
|
+
atomic_write_json(self._index_path, data)
|
|
168
|
+
|
|
169
|
+
def list_sessions(
|
|
170
|
+
self,
|
|
171
|
+
include_incognito: bool = True,
|
|
172
|
+
*,
|
|
173
|
+
project_root_filter: str | None = None,
|
|
174
|
+
forge_root_filter: str | None = None,
|
|
175
|
+
) -> list[tuple[str, SessionIndexEntry]]:
|
|
176
|
+
"""List sessions sorted by last_accessed_at DESC, then name ASC.
|
|
177
|
+
|
|
178
|
+
Also self-heals stale index entries: if an entry points to a missing worktree
|
|
179
|
+
or missing manifest file, it is pruned.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
include_incognito: Whether to include incognito sessions.
|
|
183
|
+
project_root_filter: If set, only return entries matching this project_root.
|
|
184
|
+
forge_root_filter: If set, only return entries matching this forge_root.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
List of (name, entry) tuples sorted deterministically.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
191
|
+
index = self.read()
|
|
192
|
+
|
|
193
|
+
# Filesystem probes run without the lock to avoid timeout on slow I/O.
|
|
194
|
+
# TOCTOU window: a concurrent writer could modify the index between the
|
|
195
|
+
# read above and the prune below. The re-read at the prune step mitigates
|
|
196
|
+
# this (double-check pattern). Worst case is a false-positive prune that
|
|
197
|
+
# gets re-added on the next session start.
|
|
198
|
+
stale: set[str] = set() # scoped keys (dict keys)
|
|
199
|
+
for key, entry in index.sessions.items():
|
|
200
|
+
display_name = session_name_from_key(key)
|
|
201
|
+
worktree = Path(entry.worktree_path)
|
|
202
|
+
store_root = Path(entry.forge_root or entry.worktree_path)
|
|
203
|
+
manifest_path = get_manifest_path(store_root, display_name)
|
|
204
|
+
|
|
205
|
+
if not worktree.exists() or not manifest_path.is_file():
|
|
206
|
+
stale.add(key)
|
|
207
|
+
|
|
208
|
+
if stale:
|
|
209
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
210
|
+
latest = self.read()
|
|
211
|
+
|
|
212
|
+
pruned_any = False
|
|
213
|
+
for key in list(stale):
|
|
214
|
+
latest_entry = latest.sessions.get(key)
|
|
215
|
+
if latest_entry is None:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
display_name = session_name_from_key(key)
|
|
219
|
+
worktree = Path(latest_entry.worktree_path)
|
|
220
|
+
store_root = Path(latest_entry.forge_root or latest_entry.worktree_path)
|
|
221
|
+
manifest_path = get_manifest_path(store_root, display_name)
|
|
222
|
+
if not worktree.exists() or not manifest_path.is_file():
|
|
223
|
+
del latest.sessions[key]
|
|
224
|
+
pruned_any = True
|
|
225
|
+
|
|
226
|
+
if pruned_any:
|
|
227
|
+
self.write(latest)
|
|
228
|
+
|
|
229
|
+
index = latest
|
|
230
|
+
|
|
231
|
+
sessions = [
|
|
232
|
+
(session_name_from_key(key), entry)
|
|
233
|
+
for key, entry in index.sessions.items()
|
|
234
|
+
if include_incognito or not entry.is_incognito
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
# Apply project identity filters (see design.md §3 "session list --scope")
|
|
238
|
+
if project_root_filter is not None:
|
|
239
|
+
sessions = [(n, e) for n, e in sessions if e.project_root == project_root_filter]
|
|
240
|
+
if forge_root_filter is not None:
|
|
241
|
+
sessions = [(n, e) for n, e in sessions if e.forge_root == forge_root_filter]
|
|
242
|
+
|
|
243
|
+
# Sort by last_accessed_at DESC, then name ASC for determinism
|
|
244
|
+
sessions.sort(key=lambda x: (-iso_to_timestamp(x[1].last_accessed_at), x[0]))
|
|
245
|
+
return sessions
|
|
246
|
+
|
|
247
|
+
def get_session(self, name: str, forge_root: str | None = None) -> SessionIndexEntry:
|
|
248
|
+
"""Get a session entry by name, optionally scoped to a forge_root.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
name: Session display name.
|
|
252
|
+
forge_root: If set, scope lookup to this project. If None, uses
|
|
253
|
+
strict resolution (raises AmbiguousSessionError on duplicates).
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
SessionIndexEntry for the session.
|
|
257
|
+
|
|
258
|
+
Raises:
|
|
259
|
+
InvalidSessionNameError: If name is invalid.
|
|
260
|
+
SessionNotFoundError: If session not in index.
|
|
261
|
+
AmbiguousSessionError: If forge_root is None and name exists in multiple projects.
|
|
262
|
+
"""
|
|
263
|
+
validate_name(name)
|
|
264
|
+
|
|
265
|
+
# Phase 1: read entry under lock.
|
|
266
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
267
|
+
index = self.read()
|
|
268
|
+
|
|
269
|
+
key = resolve_key_strict(index.sessions, name, forge_root)
|
|
270
|
+
if key is None:
|
|
271
|
+
raise SessionNotFoundError(name)
|
|
272
|
+
|
|
273
|
+
entry = index.sessions[key]
|
|
274
|
+
|
|
275
|
+
# Phase 2: do filesystem checks without holding the index lock.
|
|
276
|
+
store_root = Path(entry.forge_root or entry.worktree_path)
|
|
277
|
+
manifest_path = get_manifest_path(store_root, name)
|
|
278
|
+
if store_root.exists() and manifest_path.is_file():
|
|
279
|
+
return entry
|
|
280
|
+
|
|
281
|
+
# Phase 3: re-acquire lock and prune only if still stale.
|
|
282
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
283
|
+
latest = self.read()
|
|
284
|
+
latest_key = resolve_key_strict(latest.sessions, name, forge_root)
|
|
285
|
+
if latest_key is None:
|
|
286
|
+
raise SessionNotFoundError(name)
|
|
287
|
+
|
|
288
|
+
latest_entry = latest.sessions[latest_key]
|
|
289
|
+
store_root = Path(latest_entry.forge_root or latest_entry.worktree_path)
|
|
290
|
+
manifest_path = get_manifest_path(store_root, name)
|
|
291
|
+
if not store_root.exists() or not manifest_path.is_file():
|
|
292
|
+
del latest.sessions[latest_key]
|
|
293
|
+
self.write(latest)
|
|
294
|
+
raise SessionNotFoundError(name)
|
|
295
|
+
|
|
296
|
+
return latest_entry
|
|
297
|
+
|
|
298
|
+
def add_session(
|
|
299
|
+
self,
|
|
300
|
+
name: str,
|
|
301
|
+
worktree_path: str,
|
|
302
|
+
project_root: str,
|
|
303
|
+
*,
|
|
304
|
+
is_fork: bool = False,
|
|
305
|
+
is_incognito: bool = False,
|
|
306
|
+
parent_session: str | None = None,
|
|
307
|
+
claude_session_id: str | None = None,
|
|
308
|
+
forge_root: str | None = None,
|
|
309
|
+
checkout_root: str | None = None,
|
|
310
|
+
relative_path: str | None = None,
|
|
311
|
+
) -> SessionIndexEntry:
|
|
312
|
+
"""Add a new session to the index.
|
|
313
|
+
|
|
314
|
+
Session names are project-scoped: the same name can exist in different
|
|
315
|
+
forge_root projects. The dict key is a deterministic compound key.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
name: Session display name (unique within this forge_root).
|
|
319
|
+
worktree_path: Absolute path to worktree.
|
|
320
|
+
project_root: Absolute path to main repository.
|
|
321
|
+
is_fork: Whether this is a forked session.
|
|
322
|
+
is_incognito: Whether this is an incognito session.
|
|
323
|
+
parent_session: Parent session name if this is a fork.
|
|
324
|
+
forge_root: Forge project root (where .forge/ lives).
|
|
325
|
+
checkout_root: Git checkout root (--show-toplevel).
|
|
326
|
+
relative_path: forge_root relative to checkout_root.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
The created SessionIndexEntry.
|
|
330
|
+
|
|
331
|
+
Raises:
|
|
332
|
+
InvalidSessionNameError: If name is invalid.
|
|
333
|
+
SessionExistsError: If session already exists in this project.
|
|
334
|
+
"""
|
|
335
|
+
validate_name(name)
|
|
336
|
+
effective_forge_root = forge_root or worktree_path
|
|
337
|
+
|
|
338
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
339
|
+
index = self.read()
|
|
340
|
+
|
|
341
|
+
scoped_key = make_scoped_key(name, effective_forge_root)
|
|
342
|
+
if scoped_key in index.sessions:
|
|
343
|
+
raise SessionExistsError(name)
|
|
344
|
+
|
|
345
|
+
entry = SessionIndexEntry(
|
|
346
|
+
worktree_path=worktree_path,
|
|
347
|
+
project_root=project_root,
|
|
348
|
+
last_accessed_at=now_iso(),
|
|
349
|
+
is_fork=is_fork,
|
|
350
|
+
is_incognito=is_incognito,
|
|
351
|
+
parent_session=parent_session,
|
|
352
|
+
claude_session_id=claude_session_id,
|
|
353
|
+
forge_root=effective_forge_root,
|
|
354
|
+
checkout_root=checkout_root or worktree_path,
|
|
355
|
+
relative_path=relative_path or ".",
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
index.sessions[scoped_key] = entry
|
|
359
|
+
self.write(index)
|
|
360
|
+
return entry
|
|
361
|
+
|
|
362
|
+
def update_session(
|
|
363
|
+
self, name: str, last_accessed_at: str | None = None, forge_root: str | None = None
|
|
364
|
+
) -> SessionIndexEntry:
|
|
365
|
+
"""Update a session's last_accessed_at timestamp.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
name: Session display name.
|
|
369
|
+
last_accessed_at: New timestamp as ISO8601 string (defaults to now).
|
|
370
|
+
forge_root: Scope to this project. Strict resolution when None.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
The updated SessionIndexEntry.
|
|
374
|
+
|
|
375
|
+
Raises:
|
|
376
|
+
InvalidSessionNameError: If name is invalid.
|
|
377
|
+
SessionNotFoundError: If session not found.
|
|
378
|
+
"""
|
|
379
|
+
validate_name(name)
|
|
380
|
+
|
|
381
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
382
|
+
index = self.read()
|
|
383
|
+
|
|
384
|
+
key = resolve_key_strict(index.sessions, name, forge_root)
|
|
385
|
+
if key is None:
|
|
386
|
+
raise SessionNotFoundError(name)
|
|
387
|
+
|
|
388
|
+
index.sessions[key].last_accessed_at = last_accessed_at or now_iso()
|
|
389
|
+
self.write(index)
|
|
390
|
+
return index.sessions[key]
|
|
391
|
+
|
|
392
|
+
def update_uuid(self, name: str, claude_session_id: str, forge_root: str | None = None) -> None:
|
|
393
|
+
"""Update a session's claude_session_id in the index.
|
|
394
|
+
|
|
395
|
+
Best-effort: silently no-ops if session not found (fail-open for hooks).
|
|
396
|
+
Uses best-effort resolution when forge_root is None.
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
400
|
+
index = self.read()
|
|
401
|
+
key = resolve_key_best_effort(index.sessions, name, forge_root)
|
|
402
|
+
if key is None:
|
|
403
|
+
return
|
|
404
|
+
index.sessions[key].claude_session_id = claude_session_id
|
|
405
|
+
self.write(index)
|
|
406
|
+
except Exception as e:
|
|
407
|
+
_log.debug("Index sync for '%s' failed (non-critical): %s", name, e)
|
|
408
|
+
|
|
409
|
+
def remove_session(self, name: str, forge_root: str | None = None) -> bool:
|
|
410
|
+
"""Remove a session from the index.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
name: Session display name.
|
|
414
|
+
forge_root: Scope to this project. Strict resolution when None.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
True if removed, False if not found.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
InvalidSessionNameError: If name is invalid.
|
|
421
|
+
"""
|
|
422
|
+
validate_name(name)
|
|
423
|
+
|
|
424
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
425
|
+
index = self.read()
|
|
426
|
+
|
|
427
|
+
key = resolve_key_strict(index.sessions, name, forge_root)
|
|
428
|
+
if key is None:
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
del index.sessions[key]
|
|
432
|
+
self.write(index)
|
|
433
|
+
return True
|
|
434
|
+
|
|
435
|
+
def session_exists(self, name: str, forge_root: str | None = None) -> bool:
|
|
436
|
+
"""Check if a session exists in the index.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
name: Session display name.
|
|
440
|
+
forge_root: Scope to this project. Strict resolution when None.
|
|
441
|
+
|
|
442
|
+
Returns:
|
|
443
|
+
True if session exists in index.
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
AmbiguousSessionError: If forge_root is None and name exists in multiple projects.
|
|
447
|
+
"""
|
|
448
|
+
try:
|
|
449
|
+
validate_name(name)
|
|
450
|
+
except InvalidSessionNameError:
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
454
|
+
index = self.read()
|
|
455
|
+
return resolve_key_strict(index.sessions, name, forge_root) is not None
|
|
456
|
+
|
|
457
|
+
def add_from_state(
|
|
458
|
+
self,
|
|
459
|
+
state: SessionState,
|
|
460
|
+
project_root: str,
|
|
461
|
+
*,
|
|
462
|
+
checkout_root: str | None = None,
|
|
463
|
+
forge_root: str | None = None,
|
|
464
|
+
relative_path: str | None = None,
|
|
465
|
+
) -> SessionIndexEntry:
|
|
466
|
+
"""Add session to index from a session state.
|
|
467
|
+
|
|
468
|
+
Convenience method that extracts relevant fields from state.
|
|
469
|
+
Identity fields (forge_root, checkout_root, relative_path) are passed
|
|
470
|
+
explicitly by the caller — they are computed from git and filesystem state
|
|
471
|
+
that this method cannot derive from SessionState alone.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
state: The session state.
|
|
475
|
+
project_root: Absolute path to main repository.
|
|
476
|
+
checkout_root: Git checkout root (--show-toplevel).
|
|
477
|
+
forge_root: Forge project root (where .forge/ lives).
|
|
478
|
+
relative_path: forge_root relative to checkout_root.
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
The created SessionIndexEntry.
|
|
482
|
+
"""
|
|
483
|
+
worktree_path = state.worktree.path if state.worktree else project_root
|
|
484
|
+
# Use state.forge_root as fallback if caller didn't pass it
|
|
485
|
+
effective_forge_root = forge_root or state.forge_root
|
|
486
|
+
|
|
487
|
+
return self.add_session(
|
|
488
|
+
name=state.name,
|
|
489
|
+
worktree_path=worktree_path,
|
|
490
|
+
project_root=project_root,
|
|
491
|
+
is_fork=state.is_fork,
|
|
492
|
+
is_incognito=state.is_incognito,
|
|
493
|
+
parent_session=state.parent_session,
|
|
494
|
+
claude_session_id=state.confirmed.claude_session_id,
|
|
495
|
+
forge_root=effective_forge_root,
|
|
496
|
+
checkout_root=checkout_root,
|
|
497
|
+
relative_path=relative_path,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
def find_session_by_uuid(
|
|
501
|
+
self, session_uuid: str, *, timeout_s: float = CLI_LOCK_TIMEOUT_S
|
|
502
|
+
) -> tuple[str, str] | None:
|
|
503
|
+
"""Find a session by its Claude session UUID.
|
|
504
|
+
|
|
505
|
+
Returns (display_name, forge_root) for exact subsequent lookups,
|
|
506
|
+
or None if not found. Cross-project: scans all entries.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
session_uuid: The Claude session UUID to search for.
|
|
510
|
+
timeout_s: How long to wait for index lock acquisition.
|
|
511
|
+
"""
|
|
512
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=timeout_s):
|
|
513
|
+
index = self.read()
|
|
514
|
+
|
|
515
|
+
for key, entry in index.sessions.items():
|
|
516
|
+
if entry.claude_session_id == session_uuid:
|
|
517
|
+
return session_name_from_key(key), entry.forge_root or entry.worktree_path
|
|
518
|
+
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
def sync_uuid_from_state(self, name: str, state: SessionState) -> SessionIndexEntry:
|
|
522
|
+
"""Sync UUID fields from session state to index entry (lazy reconciliation).
|
|
523
|
+
|
|
524
|
+
Uses best-effort resolution: prefers state.forge_root for scoped lookup,
|
|
525
|
+
falls back to unscoped scan.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
name: Session display name.
|
|
529
|
+
state: The session state with confirmed UUID info.
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
The updated SessionIndexEntry.
|
|
533
|
+
|
|
534
|
+
Raises:
|
|
535
|
+
SessionNotFoundError: If session not found in index.
|
|
536
|
+
"""
|
|
537
|
+
forge_root = state.forge_root
|
|
538
|
+
|
|
539
|
+
with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
|
|
540
|
+
index = self.read()
|
|
541
|
+
|
|
542
|
+
key = resolve_key_best_effort(index.sessions, name, forge_root)
|
|
543
|
+
if key is None:
|
|
544
|
+
raise SessionNotFoundError(name)
|
|
545
|
+
|
|
546
|
+
entry = index.sessions[key]
|
|
547
|
+
confirmed = state.confirmed
|
|
548
|
+
|
|
549
|
+
if confirmed.claude_session_id is not None:
|
|
550
|
+
entry.claude_session_id = confirmed.claude_session_id
|
|
551
|
+
|
|
552
|
+
self.write(index)
|
|
553
|
+
return entry
|