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
@@ -0,0 +1,339 @@
1
+ """Runtime active-session registry for live Claude launches.
2
+
3
+ This registry is separate from session manifests and the global session index.
4
+ It stores ephemeral "session is currently launched" state so Forge can warn
5
+ before deleting a live session and self-heal stale entries after crashes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ from contextlib import contextmanager
14
+ from dataclasses import asdict, dataclass, field
15
+ from pathlib import Path
16
+ from typing import Callable, Iterator
17
+
18
+ import dacite
19
+
20
+ from forge.core.paths import get_forge_home
21
+ from forge.core.process import is_pid_alive
22
+ from forge.core.state import atomic_write_json, file_lock_for_target, now_iso
23
+
24
+ from .config import LAUNCH_MODE_HOST, LAUNCH_MODE_SIDECAR
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ ACTIVE_INDEX_VERSION = 1
29
+ ACTIVE_DIR = "sessions"
30
+ ACTIVE_FILENAME = "active.json"
31
+ CLI_LOCK_TIMEOUT_S = 5.0
32
+
33
+
34
+ @dataclass
35
+ class ActiveSessionEntry:
36
+ """Ephemeral runtime state for a currently launched session."""
37
+
38
+ worktree_path: str
39
+ started_at: str
40
+ launch_mode: str = LAUNCH_MODE_HOST
41
+ launcher_pid: int | None = None
42
+ claude_session_id: str | None = None
43
+ container_name: str | None = None
44
+ forge_root: str | None = None # Scope axis (matches durable index)
45
+
46
+
47
+ @dataclass
48
+ class ActiveSessionIndex:
49
+ """All currently active sessions keyed by session name."""
50
+
51
+ version: int = ACTIVE_INDEX_VERSION
52
+ sessions: dict[str, ActiveSessionEntry] = field(default_factory=dict)
53
+
54
+
55
+ def get_active_index_path() -> Path:
56
+ """Return the runtime active-session registry path."""
57
+ return get_forge_home() / ACTIVE_DIR / ACTIVE_FILENAME
58
+
59
+
60
+ class ActiveSessionStore:
61
+ """Manage the runtime active-session registry."""
62
+
63
+ def __init__(self, index_path: Path | None = None) -> None:
64
+ self._index_path = index_path or get_active_index_path()
65
+
66
+ @property
67
+ def index_path(self) -> Path:
68
+ """Return the active-session registry path."""
69
+ return self._index_path
70
+
71
+ def exists(self) -> bool:
72
+ """Return True when the registry file exists."""
73
+ return self._index_path.is_file()
74
+
75
+ def read(self) -> ActiveSessionIndex:
76
+ """Read the registry, returning an empty registry when missing."""
77
+ if not self.exists():
78
+ return ActiveSessionIndex()
79
+
80
+ with open(self._index_path, encoding="utf-8") as f:
81
+ data = json.load(f)
82
+
83
+ version = data.get("version")
84
+ if version != ACTIVE_INDEX_VERSION or not self._has_current_key_shape(data):
85
+ logger.info("Discarding incompatible active-session registry (version=%s)", version)
86
+ empty = ActiveSessionIndex()
87
+ self.write(empty)
88
+ return empty
89
+
90
+ return dacite.from_dict(
91
+ data_class=ActiveSessionIndex,
92
+ data=data,
93
+ config=dacite.Config(strict=True),
94
+ )
95
+
96
+ def _has_current_key_shape(self, data: dict[str, object]) -> bool:
97
+ """Return True when the registry uses scoped session keys."""
98
+ from forge.session.identity import make_scoped_key, session_name_from_key
99
+
100
+ sessions = data.get("sessions")
101
+ if not isinstance(sessions, dict):
102
+ return True
103
+
104
+ for key, entry_data in sessions.items():
105
+ if not isinstance(key, str) or not isinstance(entry_data, dict):
106
+ return False
107
+ root = entry_data.get("forge_root") or entry_data.get("worktree_path")
108
+ if not isinstance(root, str) or not root:
109
+ return False
110
+ display_name = session_name_from_key(key)
111
+ if key != make_scoped_key(display_name, root):
112
+ return False
113
+ return True
114
+
115
+ def write(self, index: ActiveSessionIndex) -> None:
116
+ """Write the registry atomically."""
117
+ atomic_write_json(self._index_path, asdict(index))
118
+
119
+ def upsert_session(
120
+ self,
121
+ session_name: str,
122
+ *,
123
+ worktree_path: str,
124
+ launch_mode: str,
125
+ launcher_pid: int | None = None,
126
+ claude_session_id: str | None = None,
127
+ container_name: str | None = None,
128
+ forge_root: str | None = None,
129
+ ) -> ActiveSessionEntry:
130
+ """Create or replace a live-session entry."""
131
+ from forge.session.identity import make_scoped_key
132
+
133
+ launcher_pid = os.getpid() if launcher_pid is None else launcher_pid
134
+ effective_forge_root = forge_root or worktree_path
135
+
136
+ with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
137
+ index = self.read()
138
+ entry = ActiveSessionEntry(
139
+ worktree_path=worktree_path,
140
+ started_at=now_iso(),
141
+ launch_mode=launch_mode,
142
+ launcher_pid=launcher_pid,
143
+ claude_session_id=claude_session_id,
144
+ container_name=container_name,
145
+ forge_root=effective_forge_root,
146
+ )
147
+ key = make_scoped_key(session_name, effective_forge_root)
148
+ index.sessions[key] = entry
149
+ self.write(index)
150
+ return entry
151
+
152
+ def update_uuid(self, session_name: str, claude_session_id: str, forge_root: str | None = None) -> bool:
153
+ """Update the Claude UUID for an active session if it exists.
154
+
155
+ Best-effort: uses best-effort resolver when forge_root is None.
156
+ """
157
+ from forge.session.identity import resolve_key_best_effort
158
+
159
+ with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
160
+ index = self.read()
161
+ key = resolve_key_best_effort(index.sessions, session_name, forge_root)
162
+ if key is None:
163
+ return False
164
+ index.sessions[key].claude_session_id = claude_session_id
165
+ self.write(index)
166
+ return True
167
+
168
+ def clear_session(self, session_name: str, forge_root: str | None = None) -> bool:
169
+ """Remove an active-session entry by session name.
170
+
171
+ Uses strict resolution when forge_root is None.
172
+ """
173
+ from forge.session.identity import resolve_key_strict
174
+
175
+ with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
176
+ index = self.read()
177
+ key = resolve_key_strict(index.sessions, session_name, forge_root)
178
+ if key is None:
179
+ return False
180
+ del index.sessions[key]
181
+ self.write(index)
182
+ return True
183
+
184
+ def clear_by_claude_session_id(self, claude_session_id: str) -> bool:
185
+ """Remove an active-session entry by Claude UUID (scans all)."""
186
+ with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
187
+ index = self.read()
188
+ removed = False
189
+ for key, entry in list(index.sessions.items()):
190
+ if entry.claude_session_id == claude_session_id:
191
+ del index.sessions[key]
192
+ removed = True
193
+ if removed:
194
+ self.write(index)
195
+ return removed
196
+
197
+ def get_session(self, session_name: str, forge_root: str | None = None) -> ActiveSessionEntry | None:
198
+ """Return the live entry for a session, pruning stale entries.
199
+
200
+ Uses strict resolution when forge_root is None.
201
+ """
202
+ from forge.session.identity import resolve_key_strict
203
+
204
+ with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
205
+ index = self.read()
206
+ key = resolve_key_strict(index.sessions, session_name, forge_root)
207
+
208
+ if key is None:
209
+ return None
210
+ entry = index.sessions.get(key)
211
+ if entry is None:
212
+ return None
213
+ if self._entry_is_live(entry):
214
+ return entry
215
+
216
+ with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
217
+ latest = self.read()
218
+ latest_entry = latest.sessions.get(key)
219
+ if latest_entry is None:
220
+ return None
221
+ if self._entry_is_live(latest_entry):
222
+ return latest_entry
223
+ del latest.sessions[key]
224
+ self.write(latest)
225
+ return None
226
+
227
+ def list_sessions(self) -> list[tuple[str, ActiveSessionEntry]]:
228
+ """List all live sessions, pruning stale entries on read.
229
+
230
+ Returns display names (not compound keys).
231
+ """
232
+ from forge.session.identity import session_name_from_key
233
+
234
+ with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
235
+ index = self.read()
236
+
237
+ stale_keys = [key for key, entry in index.sessions.items() if not self._entry_is_live(entry)]
238
+ if stale_keys:
239
+ with file_lock_for_target(target_path=self._index_path, timeout_s=CLI_LOCK_TIMEOUT_S):
240
+ latest = self.read()
241
+ pruned_any = False
242
+ for key in stale_keys:
243
+ entry = latest.sessions.get(key)
244
+ if entry is None:
245
+ continue
246
+ if not self._entry_is_live(entry):
247
+ del latest.sessions[key]
248
+ pruned_any = True
249
+ if pruned_any:
250
+ self.write(latest)
251
+ index = latest
252
+
253
+ return sorted(
254
+ [(session_name_from_key(k), e) for k, e in index.sessions.items()],
255
+ key=lambda item: item[0],
256
+ )
257
+
258
+ def is_session_active(self, session_name: str, forge_root: str | None = None) -> bool:
259
+ """Return True when the session still appears to be live.
260
+
261
+ Uses strict resolution when forge_root is None.
262
+ """
263
+ return self.get_session(session_name, forge_root=forge_root) is not None
264
+
265
+ def _entry_is_live(self, entry: ActiveSessionEntry) -> bool:
266
+ """Return True when the runtime entry still points at a live launch."""
267
+ if entry.launch_mode == LAUNCH_MODE_SIDECAR and entry.container_name:
268
+ try:
269
+ from forge.sidecar.docker import is_container_running
270
+
271
+ if is_container_running(entry.container_name):
272
+ return True
273
+ except Exception:
274
+ logger.debug("Failed to probe sidecar container liveness", exc_info=True)
275
+
276
+ if entry.launcher_pid is not None and is_pid_alive(entry.launcher_pid):
277
+ return True
278
+
279
+ return False
280
+
281
+
282
+ @contextmanager
283
+ def track_active_session(
284
+ *,
285
+ session_name: str,
286
+ worktree_path: str,
287
+ launch_mode: str,
288
+ forge_root: str | None = None,
289
+ claude_session_id: str | None = None,
290
+ launcher_pid: int | None = None,
291
+ container_name: str | None = None,
292
+ ) -> Iterator[None]:
293
+ """Track a live Claude launch for the duration of a context manager."""
294
+ store = ActiveSessionStore()
295
+ effective_forge_root = forge_root or worktree_path
296
+
297
+ try:
298
+ store.upsert_session(
299
+ session_name,
300
+ worktree_path=worktree_path,
301
+ launch_mode=launch_mode,
302
+ launcher_pid=launcher_pid,
303
+ claude_session_id=claude_session_id,
304
+ container_name=container_name,
305
+ forge_root=effective_forge_root,
306
+ )
307
+ except Exception:
308
+ logger.debug("Failed to register active session '%s'", session_name, exc_info=True)
309
+
310
+ try:
311
+ yield
312
+ finally:
313
+ try:
314
+ store.clear_session(session_name, forge_root=effective_forge_root)
315
+ except Exception:
316
+ logger.debug("Failed to clear active session '%s'", session_name, exc_info=True)
317
+
318
+
319
+ def run_with_active_session(
320
+ *,
321
+ session_name: str,
322
+ worktree_path: Path,
323
+ launch_mode: str,
324
+ forge_root: str | None = None,
325
+ claude_session_id: str | None = None,
326
+ runner: Callable[[], int],
327
+ ) -> int:
328
+ """Track a live session while invoking a Claude launcher callback."""
329
+ container_name = f"forge-{session_name}" if launch_mode == LAUNCH_MODE_SIDECAR else None
330
+
331
+ with track_active_session(
332
+ session_name=session_name,
333
+ worktree_path=str(worktree_path),
334
+ launch_mode=launch_mode,
335
+ forge_root=forge_root,
336
+ claude_session_id=claude_session_id,
337
+ container_name=container_name,
338
+ ):
339
+ return runner()
@@ -0,0 +1,202 @@
1
+ """Session artifact helpers.
2
+
3
+ This module implements Forge-project-local artifact storage for sessions.
4
+
5
+ Artifacts are stored under the **Forge project root** (``forge_root``):
6
+
7
+ - <forge_root>/.forge/artifacts/<session_name>/plans/
8
+ - <forge_root>/.forge/artifacts/<session_name>/transcripts/
9
+
10
+ The session manifest records artifact paths under ``confirmed.artifacts`` as
11
+ **forge-root-relative** paths (e.g., ``.forge/artifacts/...``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import shutil
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from .claude.paths import find_project_root
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ArtifactPaths:
28
+ """Computed artifact roots for a session."""
29
+
30
+ forge_root: Path
31
+ artifacts_root_abs: Path
32
+ artifacts_root_rel: Path
33
+
34
+ plans_abs: Path
35
+ plans_rel: Path
36
+
37
+ transcripts_abs: Path
38
+ transcripts_rel: Path
39
+
40
+
41
+ def resolve_forge_root(cwd: Path) -> Path:
42
+ """Resolve the Forge project root for artifact storage.
43
+
44
+ Preference order:
45
+ 1) Walk up from *cwd* looking for ``.forge/`` (Forge project anchor)
46
+ 2) Fallback to git-aware main-repo detection (worktree safe)
47
+ 3) Fallback to walking upwards for a ``.git`` entry
48
+ 4) Final fallback to cwd
49
+
50
+ In most managed sessions, the caller should prefer the session's
51
+ stored ``forge_root`` over this heuristic.
52
+ """
53
+ # Prefer .forge/ directory as the Forge project anchor
54
+ from forge.core.ops.context import find_forge_root
55
+
56
+ forge_root = find_forge_root(cwd)
57
+ if forge_root is not None:
58
+ return forge_root
59
+
60
+ try:
61
+ from .worktree import get_main_repo_root
62
+
63
+ return get_main_repo_root(cwd)
64
+ except Exception as e:
65
+ logger.debug("get_main_repo_root failed: %s, trying find_project_root", e)
66
+ try:
67
+ return find_project_root(str(cwd))
68
+ except Exception as e2:
69
+ logger.debug("find_project_root failed: %s, falling back to cwd", e2)
70
+ return cwd.resolve()
71
+
72
+
73
+ def get_artifact_paths(forge_root: Path, session_name: str) -> ArtifactPaths:
74
+ """Compute standard artifact directories for a session.
75
+
76
+ Args:
77
+ forge_root: Forge project root (where .forge/ lives).
78
+ session_name: Forge session name.
79
+
80
+ Returns:
81
+ ArtifactPaths with absolute + forge-root-relative paths.
82
+ """
83
+
84
+ forge_root = forge_root.resolve()
85
+
86
+ artifacts_root_rel = Path(".forge") / "artifacts" / session_name
87
+ artifacts_root_abs = forge_root / artifacts_root_rel
88
+
89
+ plans_rel = artifacts_root_rel / "plans"
90
+ plans_abs = forge_root / plans_rel
91
+
92
+ transcripts_rel = artifacts_root_rel / "transcripts"
93
+ transcripts_abs = forge_root / transcripts_rel
94
+
95
+ return ArtifactPaths(
96
+ forge_root=forge_root,
97
+ artifacts_root_abs=artifacts_root_abs,
98
+ artifacts_root_rel=artifacts_root_rel,
99
+ plans_abs=plans_abs,
100
+ plans_rel=plans_rel,
101
+ transcripts_abs=transcripts_abs,
102
+ transcripts_rel=transcripts_rel,
103
+ )
104
+
105
+
106
+ def resolve_artifact_path(forge_root: Path, stored_path: str | Path | None) -> Path | None:
107
+ """Resolve a stored artifact path against the owning Forge project root.
108
+
109
+ Artifact paths recorded in manifests are normally forge-root-relative
110
+ (for example ``.forge/artifacts/...``), but this helper also accepts
111
+ absolute paths as a compatibility fallback.
112
+ """
113
+ if stored_path is None:
114
+ return None
115
+
116
+ candidate = Path(stored_path).expanduser()
117
+ if candidate.is_absolute():
118
+ return candidate
119
+ return forge_root.resolve() / candidate
120
+
121
+
122
+ def ensure_dirs(paths: ArtifactPaths) -> None:
123
+ """Create artifact directories if needed."""
124
+
125
+ paths.plans_abs.mkdir(parents=True, exist_ok=True)
126
+ paths.transcripts_abs.mkdir(parents=True, exist_ok=True)
127
+
128
+
129
+ def safe_copy_file(src: Path, dst: Path, *, overwrite: bool = False) -> bool:
130
+ """Copy a file with idempotent semantics.
131
+
132
+ Args:
133
+ src: Source file.
134
+ dst: Destination file.
135
+ overwrite: Whether to overwrite if dst exists.
136
+
137
+ Returns:
138
+ True if a copy occurred, False if skipped.
139
+
140
+ Raises:
141
+ FileNotFoundError: if src does not exist.
142
+ """
143
+
144
+ if not src.is_file():
145
+ raise FileNotFoundError(str(src))
146
+
147
+ dst.parent.mkdir(parents=True, exist_ok=True)
148
+
149
+ if dst.exists() and not overwrite:
150
+ return False
151
+
152
+ shutil.copy2(src, dst)
153
+ return True
154
+
155
+
156
+ def make_timestamp_suffix() -> str:
157
+ """Return a filesystem-friendly UTC timestamp suffix (``YYYYMMDD_HHMMSS``)."""
158
+ from datetime import UTC, datetime
159
+
160
+ return datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
161
+
162
+
163
+ def make_content_hash(data: bytes, *, length: int = 12) -> str:
164
+ """Return a short hex digest for content-addressable filenames.
165
+
166
+ 12 hex chars = 48 bits of entropy — enough that collisions across a single
167
+ user's plan history are not a practical concern.
168
+ """
169
+ import hashlib
170
+
171
+ return hashlib.sha256(data).hexdigest()[:length]
172
+
173
+
174
+ def snapshot_plan_approved(
175
+ *,
176
+ paths: ArtifactPaths,
177
+ source_plan_path: Path,
178
+ ) -> tuple[Path, Path]:
179
+ """Snapshot an approved plan file into a human-readable destination.
180
+
181
+ Filename format: ``{stem}-{hash}.md`` where ``stem`` is the source plan's
182
+ filename stem and ``hash`` is a 12-char SHA-256 prefix of the file content.
183
+ Same source file with same content always produces the same path (dedup).
184
+ Different source filenames with identical content produce distinct paths —
185
+ accepted tradeoff for human-readable snapshot names.
186
+
187
+ Returns:
188
+ (snapshot_abs_path, snapshot_rel_path)
189
+ """
190
+
191
+ ensure_dirs(paths)
192
+
193
+ content = source_plan_path.read_bytes()
194
+ digest = make_content_hash(content)
195
+ stem = source_plan_path.stem or digest
196
+ dst_name = f"{stem}-{digest}.md"
197
+
198
+ snapshot_abs = paths.plans_abs / dst_name
199
+ snapshot_rel = paths.plans_rel / dst_name
200
+
201
+ safe_copy_file(source_plan_path, snapshot_abs, overwrite=False)
202
+ return snapshot_abs, snapshot_rel
@@ -0,0 +1,50 @@
1
+ """Claude Code integration utilities.
2
+
3
+ This module provides utilities for:
4
+ - Path encoding and transcript path resolution
5
+ - Claude binary invocation
6
+ - Session data cleanup (transcripts, agent logs)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .cleanup import (
12
+ CleanupResult,
13
+ cleanup_session,
14
+ delete_session_data,
15
+ )
16
+ from .invoke import (
17
+ build_claude_args,
18
+ find_claude_binary,
19
+ invoke_claude,
20
+ is_claude_available,
21
+ )
22
+ from .paths import (
23
+ encode_project_path,
24
+ find_agent_logs,
25
+ find_project_root,
26
+ get_claude_home,
27
+ get_claude_projects_dir,
28
+ get_project_encoded_dir,
29
+ get_transcript_path,
30
+ )
31
+
32
+ __all__ = [
33
+ # Cleanup
34
+ "CleanupResult",
35
+ "cleanup_session",
36
+ "delete_session_data",
37
+ # Invoke
38
+ "build_claude_args",
39
+ "invoke_claude",
40
+ "find_claude_binary",
41
+ "is_claude_available",
42
+ # Paths
43
+ "encode_project_path",
44
+ "find_agent_logs",
45
+ "find_project_root",
46
+ "get_claude_home",
47
+ "get_claude_projects_dir",
48
+ "get_project_encoded_dir",
49
+ "get_transcript_path",
50
+ ]
@@ -0,0 +1,105 @@
1
+ """Transcript and agent log cleanup utilities.
2
+
3
+ This module provides utilities for deleting Claude session data:
4
+ - Transcript files (.jsonl)
5
+ - Agent log files (agent-*.jsonl)
6
+
7
+ Under the 1:1 session model, each Forge session has one current claude_session_id.
8
+ If /compact or /clear rolled over to a new UUID, older raw transcript UUIDs may
9
+ also be tracked via transcript artifacts and should be cleaned up too.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+ from .paths import find_agent_logs, get_transcript_path
18
+
19
+
20
+ @dataclass
21
+ class CleanupResult:
22
+ """Result of a cleanup operation.
23
+
24
+ Attributes:
25
+ deleted_transcripts: Paths to successfully deleted transcript files.
26
+ deleted_agent_logs: Paths to successfully deleted agent log files.
27
+ failed: List of (path, error_message) tuples for failed deletions.
28
+ """
29
+
30
+ deleted_transcripts: list[Path] = field(default_factory=list)
31
+ deleted_agent_logs: list[Path] = field(default_factory=list)
32
+ failed: list[tuple[Path, str]] = field(default_factory=list)
33
+
34
+ @property
35
+ def total_deleted(self) -> int:
36
+ """Total number of files successfully deleted."""
37
+ return len(self.deleted_transcripts) + len(self.deleted_agent_logs)
38
+
39
+ @property
40
+ def has_failures(self) -> bool:
41
+ """Whether any deletions failed."""
42
+ return len(self.failed) > 0
43
+
44
+
45
+ def delete_session_data(
46
+ project_root: str,
47
+ session_ids: list[str],
48
+ ) -> CleanupResult:
49
+ """Delete transcript and agent log files for given session IDs.
50
+
51
+ Best-effort: continues even if some deletions fail.
52
+
53
+ Args:
54
+ project_root: Absolute path to project root (for transcript path encoding).
55
+ session_ids: List of Claude session UUIDs to clean up.
56
+
57
+ Returns:
58
+ CleanupResult with lists of deleted files and any failures.
59
+ """
60
+ result = CleanupResult()
61
+
62
+ for session_id in session_ids:
63
+ # Delete transcript
64
+ transcript_path = get_transcript_path(project_root, session_id)
65
+ if transcript_path.exists():
66
+ try:
67
+ transcript_path.unlink()
68
+ result.deleted_transcripts.append(transcript_path)
69
+ except OSError as e:
70
+ result.failed.append((transcript_path, str(e)))
71
+
72
+ # Delete agent logs
73
+ agent_logs = find_agent_logs(project_root, session_id)
74
+ for log_path in agent_logs:
75
+ try:
76
+ log_path.unlink()
77
+ result.deleted_agent_logs.append(log_path)
78
+ except OSError as e:
79
+ result.failed.append((log_path, str(e)))
80
+
81
+ return result
82
+
83
+
84
+ def cleanup_session(
85
+ project_root: str,
86
+ claude_session_id: str | None,
87
+ artifact_session_ids: list[str] | None = None,
88
+ ) -> CleanupResult:
89
+ """Clean up session data for the session's tracked Claude UUIDs.
90
+
91
+ Args:
92
+ project_root: Absolute path to project root.
93
+ claude_session_id: Session UUID (from confirmed.claude_session_id).
94
+ artifact_session_ids: Additional UUIDs referenced by transcript artifacts.
95
+
96
+ Returns:
97
+ CleanupResult with lists of deleted files and any failures.
98
+ """
99
+ session_ids: list[str] = []
100
+
101
+ for session_id in [claude_session_id, *(artifact_session_ids or [])]:
102
+ if session_id and session_id not in session_ids:
103
+ session_ids.append(session_id)
104
+
105
+ return delete_session_data(project_root, session_ids)