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/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