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,128 @@
1
+ """Path layout for resume/fork context files (prev_sessions).
2
+
3
+ Centralizes the on-disk layout so process_handoff, SessionManager, fork paths,
4
+ and GC stay in sync.
5
+
6
+ Layout::
7
+
8
+ <forge_root>/.forge/prev_sessions/
9
+ +-- <parent>/
10
+ +-- generated.md # Strategy output (regeneratable cache)
11
+ +-- children/
12
+ +-- <child>.md # Per-child authoritative context (durable)
13
+
14
+ The split exists so that regenerating the parent cache (re-running resume
15
+ against the same parent) never disturbs an existing child file. Once
16
+ ``children/<child>.md`` exists, it is the authoritative context that gets
17
+ appended to the child session's system prompt.
18
+
19
+ Legacy note: pre-0.2.0, this was a single flat
20
+ ``<forge_root>/.forge/prev_sessions/<parent>.md`` (parent-scoped, overwritten
21
+ by every resume/fork). New code never reads or writes the flat layout. GC
22
+ treats any remaining flat ``*.md`` files at the top level of
23
+ ``prev_sessions/`` as orphans (see ``iter_legacy_flat_files``).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import shutil
29
+ from collections.abc import Iterator
30
+ from pathlib import Path
31
+
32
+ PREV_SESSIONS_DIR = "prev_sessions"
33
+ GENERATED_FILENAME = "generated.md"
34
+ CHILDREN_DIR = "children"
35
+
36
+
37
+ def prev_sessions_root(forge_root: Path) -> Path:
38
+ return forge_root / ".forge" / PREV_SESSIONS_DIR
39
+
40
+
41
+ def parent_dir(forge_root: Path, parent_name: str) -> Path:
42
+ return prev_sessions_root(forge_root) / parent_name
43
+
44
+
45
+ def generated_path(forge_root: Path, parent_name: str) -> Path:
46
+ return parent_dir(forge_root, parent_name) / GENERATED_FILENAME
47
+
48
+
49
+ def children_dir(forge_root: Path, parent_name: str) -> Path:
50
+ return parent_dir(forge_root, parent_name) / CHILDREN_DIR
51
+
52
+
53
+ def child_path(forge_root: Path, parent_name: str, child_name: str) -> Path:
54
+ return children_dir(forge_root, parent_name) / f"{child_name}.md"
55
+
56
+
57
+ def generated_path_rel(parent_name: str) -> str:
58
+ """Return the forge-root-relative path to ``generated.md``."""
59
+ return f".forge/{PREV_SESSIONS_DIR}/{parent_name}/{GENERATED_FILENAME}"
60
+
61
+
62
+ def child_path_rel(parent_name: str, child_name: str) -> str:
63
+ """Return the forge-root-relative path to ``children/<child>.md``."""
64
+ return f".forge/{PREV_SESSIONS_DIR}/{parent_name}/{CHILDREN_DIR}/{child_name}.md"
65
+
66
+
67
+ def ensure_child(forge_root: Path, parent_name: str, child_name: str) -> Path:
68
+ """Create ``children/<child>.md`` as a copy of ``generated.md`` if absent.
69
+
70
+ Idempotent: if the child file already exists (user has curated it, or it
71
+ was created by a previous resume), leave it alone. This is the durability
72
+ guarantee: once a child file exists, regenerating the parent cache does
73
+ not affect it.
74
+
75
+ Raises ``FileNotFoundError`` if neither the child file nor the parent
76
+ cache exists -- the caller is responsible for running ``process_handoff``
77
+ first.
78
+ """
79
+ target = child_path(forge_root, parent_name, child_name)
80
+ if target.exists():
81
+ return target
82
+
83
+ source = generated_path(forge_root, parent_name)
84
+ if not source.is_file():
85
+ raise FileNotFoundError(
86
+ f"Cannot copy parent cache to child: {source} does not exist. " "Run process_handoff() first."
87
+ )
88
+
89
+ target.parent.mkdir(parents=True, exist_ok=True)
90
+ shutil.copyfile(source, target)
91
+ return target
92
+
93
+
94
+ def iter_parents(forge_root: Path) -> Iterator[Path]:
95
+ """Yield each ``<parent>/`` directory under ``prev_sessions/``.
96
+
97
+ Skips legacy flat ``.md`` files at the top level (see
98
+ ``iter_legacy_flat_files``).
99
+ """
100
+ root = prev_sessions_root(forge_root)
101
+ if not root.is_dir():
102
+ return
103
+ for entry in root.iterdir():
104
+ if entry.is_dir():
105
+ yield entry
106
+
107
+
108
+ def iter_children(forge_root: Path, parent_name: str) -> Iterator[Path]:
109
+ """Yield each ``<child>.md`` under ``<parent>/children/``."""
110
+ target = children_dir(forge_root, parent_name)
111
+ if not target.is_dir():
112
+ return
113
+ for entry in target.iterdir():
114
+ if entry.is_file() and entry.suffix == ".md":
115
+ yield entry
116
+
117
+
118
+ def iter_legacy_flat_files(forge_root: Path) -> Iterator[Path]:
119
+ """Yield top-level ``<parent>.md`` files (legacy pre-0.2.0 layout).
120
+
121
+ These are orphan candidates for GC; new code never writes here.
122
+ """
123
+ root = prev_sessions_root(forge_root)
124
+ if not root.is_dir():
125
+ return
126
+ for entry in root.iterdir():
127
+ if entry.is_file() and entry.suffix == ".md":
128
+ yield entry
forge/session/store.py ADDED
@@ -0,0 +1,431 @@
1
+ """Per-session manifest storage.
2
+
3
+ Path: <forge_root>/.forge/sessions/<session_name>/forge.session.json
4
+
5
+ Schema: v1 only (no migration).
6
+
7
+ Session manifests are treated as a strict contract:
8
+ - No schema migration
9
+ - No unknown field preservation
10
+ - Invalid manifests fail fast on read
11
+
12
+ Writes always produce schema v1.
13
+
14
+ Invariant: session names are globally unique across all worktrees (enforced by
15
+ IndexStore.add_session). The directory name IS the session name.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import shutil
22
+ from dataclasses import fields, is_dataclass
23
+ from pathlib import Path
24
+ from typing import Any, Callable, get_origin, get_type_hints
25
+
26
+ import dacite
27
+
28
+ from forge.core.state import atomic_write_json, now_iso
29
+ from forge.core.state.lock import file_lock_for_target
30
+ from forge.core.typing_helpers import unwrap_optional
31
+
32
+ from .exceptions import (
33
+ ManifestCorruptedError,
34
+ ManifestValidationError,
35
+ SessionFileNotFoundError,
36
+ )
37
+ from .models import SCHEMA_VERSION, SessionState, session_state_to_dict
38
+ from .validation import validate_name
39
+
40
+ _SUPPORTED_SCHEMA_VERSIONS = {1}
41
+
42
+ MANIFEST_FILENAME = "forge.session.json"
43
+ MANIFEST_DIR = ".forge"
44
+ SESSIONS_DIR = "sessions"
45
+
46
+ HOOK_LOCK_TIMEOUT_S = 0.2
47
+ CLI_LOCK_TIMEOUT_S = 5.0
48
+
49
+
50
+ # --- Free functions — use these for path construction everywhere (avoid drift) ---
51
+
52
+
53
+ def get_sessions_dir(forge_root: str | Path) -> Path:
54
+ """Return the sessions directory for a Forge project.
55
+
56
+ Returns: <forge_root>/.forge/sessions/
57
+ """
58
+ return Path(forge_root) / MANIFEST_DIR / SESSIONS_DIR
59
+
60
+
61
+ def get_manifest_path(forge_root: str | Path, session_name: str) -> Path:
62
+ """Return the manifest path for a specific session.
63
+
64
+ Returns: <forge_root>/.forge/sessions/<session_name>/forge.session.json
65
+ """
66
+ return Path(forge_root) / MANIFEST_DIR / SESSIONS_DIR / session_name / MANIFEST_FILENAME
67
+
68
+
69
+ class SessionStore:
70
+ """Read/write session state to per-session manifest directory.
71
+
72
+ Each session has its own directory under <forge_root>/.forge/sessions/<name>/.
73
+ Multiple sessions can coexist in the same Forge project.
74
+ """
75
+
76
+ def __init__(self, forge_root: str, session_name: str) -> None:
77
+ """Initialize store for a specific session in a Forge project.
78
+
79
+ Args:
80
+ forge_root: Absolute path to the Forge project root (where .forge/ lives).
81
+ session_name: Session name (must be valid per validate_name).
82
+ """
83
+ self._forge_root = Path(forge_root).resolve()
84
+ self._session_name = session_name
85
+ self._manifest_path = get_manifest_path(self._forge_root, session_name)
86
+
87
+ @property
88
+ def manifest_path(self) -> Path:
89
+ """Return the full path to the manifest file."""
90
+ return self._manifest_path
91
+
92
+ @property
93
+ def forge_root(self) -> Path:
94
+ """Return the Forge project root."""
95
+ return self._forge_root
96
+
97
+ @property
98
+ def worktree_path(self) -> Path:
99
+ """Deprecated alias for forge_root (kept for transition)."""
100
+ return self._forge_root
101
+
102
+ @property
103
+ def session_name(self) -> str:
104
+ """Return the session name."""
105
+ return self._session_name
106
+
107
+ @property
108
+ def session_dir(self) -> Path:
109
+ """Return the session directory (parent of manifest file)."""
110
+ return self._manifest_path.parent
111
+
112
+ def exists(self) -> bool:
113
+ """Check if a manifest exists in this worktree."""
114
+ return self._manifest_path.is_file()
115
+
116
+ def read_raw(self) -> dict[str, Any] | None:
117
+ """Read manifest as raw JSON dict, skipping validation/deserialization.
118
+
119
+ For best-effort field extraction when full parsing fails (e.g. force-delete
120
+ needs confirmed.claude_session_id from a schema-mismatched manifest).
121
+
122
+ Returns None if the file doesn't exist or isn't valid JSON.
123
+ """
124
+ if not self.exists():
125
+ return None
126
+ try:
127
+ with open(self._manifest_path, encoding="utf-8") as f:
128
+ return json.load(f)
129
+ except (json.JSONDecodeError, OSError):
130
+ return None
131
+
132
+ def read(self) -> SessionState:
133
+ """Read and parse the session manifest.
134
+
135
+ Schema v1 only. No migration, no unknown field preservation.
136
+
137
+ Raises:
138
+ SessionFileNotFoundError: If manifest doesn't exist.
139
+ ManifestCorruptedError: If manifest cannot be parsed.
140
+ ManifestValidationError: If manifest is missing required fields.
141
+ """
142
+ if not self.exists():
143
+ raise SessionFileNotFoundError(str(self._manifest_path))
144
+
145
+ try:
146
+ with open(self._manifest_path, encoding="utf-8") as f:
147
+ data = json.load(f)
148
+ except json.JSONDecodeError as e:
149
+ raise ManifestCorruptedError(str(self._manifest_path), f"invalid JSON: {e}")
150
+ except OSError as e:
151
+ raise ManifestCorruptedError(str(self._manifest_path), f"read error: {e}")
152
+
153
+ self._validate_data(data)
154
+
155
+ try:
156
+ manifest = dacite.from_dict(
157
+ data_class=SessionState,
158
+ data=data,
159
+ config=dacite.Config(strict=True),
160
+ )
161
+ except (dacite.DaciteError, TypeError, KeyError, ValueError) as e:
162
+ raise ManifestCorruptedError(str(self._manifest_path), f"deserialization error: {e}")
163
+
164
+ return manifest
165
+
166
+ def write(self, manifest: SessionState) -> None:
167
+ """Write the session manifest atomically under lock.
168
+
169
+ Uses atomic write pattern via core.state.atomic_write_json.
170
+ Creates session directory if it doesn't exist.
171
+ Acquires the same file lock as update() to prevent CLI write + hook
172
+ update lost-update races (D10).
173
+
174
+ Args:
175
+ manifest: The manifest to write.
176
+
177
+ Raises:
178
+ InvalidSessionNameError: If manifest name is invalid.
179
+ """
180
+ self.session_dir.mkdir(parents=True, exist_ok=True)
181
+
182
+ with file_lock_for_target(target_path=self._manifest_path, timeout_s=CLI_LOCK_TIMEOUT_S):
183
+ self._write_unlocked(manifest)
184
+
185
+ def _write_unlocked(self, manifest: SessionState) -> None:
186
+ """Write manifest without acquiring lock (caller must hold it)."""
187
+ validate_name(manifest.name)
188
+
189
+ # Enforce invariant: directory name == manifest name (Issue A).
190
+ if manifest.name != self._session_name:
191
+ raise ValueError(
192
+ f"Manifest name '{manifest.name}' does not match store session "
193
+ f"name '{self._session_name}'. This would create a directory/name mismatch."
194
+ )
195
+
196
+ data = session_state_to_dict(manifest)
197
+ data["schema_version"] = SCHEMA_VERSION
198
+ atomic_write_json(self._manifest_path, data)
199
+
200
+ def delete(self) -> bool:
201
+ """Delete the session directory and its contents.
202
+
203
+ Uses shutil.rmtree since the session directory is entirely session-owned.
204
+ Leaves the parent sessions/ directory in place even if empty (D12).
205
+
206
+ Returns:
207
+ True if directory was removed, False if it didn't exist.
208
+ """
209
+ session_dir = self.session_dir
210
+ if session_dir.is_dir():
211
+ shutil.rmtree(session_dir, ignore_errors=True)
212
+ return True
213
+ return False
214
+
215
+ def update_last_accessed(self) -> SessionState:
216
+ """Update last_accessed_at timestamp and return updated manifest."""
217
+
218
+ return self.update(
219
+ timeout_s=CLI_LOCK_TIMEOUT_S,
220
+ mutate=lambda m: setattr(m, "last_accessed_at", now_iso()),
221
+ )
222
+
223
+ def update(self, *, timeout_s: float, mutate: Callable[[SessionState], None]) -> SessionState:
224
+ """Update a manifest via a locked read-modify-write cycle.
225
+
226
+ This prevents lost updates when multiple processes (CLI + hooks) mutate
227
+ different sections of the manifest concurrently.
228
+
229
+ Args:
230
+ timeout_s: How long to wait for the manifest lock.
231
+ mutate: Callback that mutates the loaded manifest in-place.
232
+
233
+ Returns:
234
+ The updated manifest after persistence.
235
+
236
+ Raises:
237
+ FileLockTimeoutError: If lock cannot be acquired within timeout.
238
+ SessionFileNotFoundError / ManifestCorruptedError / ManifestValidationError: On read failures.
239
+ InvalidSessionNameError: On write failures.
240
+ """
241
+
242
+ with file_lock_for_target(target_path=self._manifest_path, timeout_s=timeout_s):
243
+ manifest = self.read()
244
+ mutate(manifest)
245
+ self._write_unlocked(manifest)
246
+ return manifest
247
+
248
+ def _validate_data(self, data: dict[str, Any]) -> None:
249
+ """Validate required fields for schema v1.
250
+
251
+ The manifest is treated as a strict contract:
252
+ - schema_version must be supported
253
+ - required fields must be present
254
+ - overrides must target valid SessionIntent fields only
255
+
256
+ Raises:
257
+ ManifestCorruptedError: If schema version is unsupported or types are invalid.
258
+ ManifestValidationError: If required fields are missing.
259
+ """
260
+ missing: list[str] = []
261
+
262
+ # Check schema version
263
+ if "schema_version" not in data:
264
+ missing.append("schema_version")
265
+ elif data["schema_version"] not in _SUPPORTED_SCHEMA_VERSIONS:
266
+ raise ManifestCorruptedError(
267
+ str(self._manifest_path),
268
+ f"incompatible schema version {data['schema_version']} "
269
+ f"(this Forge expects {sorted(_SUPPORTED_SCHEMA_VERSIONS)}). "
270
+ f"Delete this session and recreate it.",
271
+ )
272
+
273
+ # Overrides are required (empty dict allowed)
274
+ if "overrides" not in data:
275
+ missing.append("overrides")
276
+ elif not isinstance(data["overrides"], dict):
277
+ raise ManifestCorruptedError(str(self._manifest_path), "overrides must be an object")
278
+
279
+ if "name" not in data:
280
+ missing.append("name")
281
+
282
+ if "created_at" not in data:
283
+ missing.append("created_at")
284
+ if "last_accessed_at" not in data:
285
+ missing.append("last_accessed_at")
286
+
287
+ if "intent" not in data:
288
+ missing.append("intent")
289
+ intent: dict[str, Any] = {}
290
+ else:
291
+ intent_obj = data.get("intent")
292
+ if intent_obj is None or not isinstance(intent_obj, dict):
293
+ raise ManifestCorruptedError(str(self._manifest_path), "intent must be an object")
294
+ intent = intent_obj
295
+ # Check intent.proxy fields (optional; but if present must be complete)
296
+ proxy = intent.get("proxy")
297
+ if proxy is not None:
298
+ if not isinstance(proxy, dict):
299
+ raise ManifestCorruptedError(str(self._manifest_path), "intent.proxy must be an object")
300
+
301
+ if "template" not in proxy:
302
+ missing.append("intent.proxy.template")
303
+ if "base_url" not in proxy:
304
+ missing.append("intent.proxy.base_url")
305
+
306
+ # Strict overrides schema: keys must be valid SessionIntent paths
307
+ overrides = data.get("overrides")
308
+ if isinstance(overrides, dict):
309
+ _validate_overrides_schema(overrides, str(self._manifest_path))
310
+
311
+ # Optional: confirmed.started_with_proxy (B2.1.6)
312
+ confirmed = data.get("confirmed", {})
313
+ started_with_proxy = confirmed.get("started_with_proxy")
314
+ if started_with_proxy is not None:
315
+ if not isinstance(started_with_proxy, dict):
316
+ raise ManifestCorruptedError(
317
+ str(self._manifest_path),
318
+ "confirmed.started_with_proxy must be an object",
319
+ )
320
+
321
+ base_url = started_with_proxy.get("base_url")
322
+ if not isinstance(base_url, str) or not base_url:
323
+ raise ManifestCorruptedError(
324
+ str(self._manifest_path),
325
+ "confirmed.started_with_proxy.base_url is required",
326
+ )
327
+
328
+ proxy_id = started_with_proxy.get("proxy_id")
329
+ if proxy_id is not None and not isinstance(proxy_id, str):
330
+ raise ManifestCorruptedError(
331
+ str(self._manifest_path),
332
+ "confirmed.started_with_proxy.proxy_id must be a string",
333
+ )
334
+
335
+ template = started_with_proxy.get("template")
336
+ if template is not None and not isinstance(template, str):
337
+ raise ManifestCorruptedError(
338
+ str(self._manifest_path),
339
+ "confirmed.started_with_proxy.template must be a string",
340
+ )
341
+
342
+ port = started_with_proxy.get("port")
343
+ if port is not None and not isinstance(port, int):
344
+ raise ManifestCorruptedError(
345
+ str(self._manifest_path),
346
+ "confirmed.started_with_proxy.port must be an integer",
347
+ )
348
+
349
+ if missing:
350
+ raise ManifestValidationError(str(self._manifest_path), missing)
351
+
352
+
353
+ def _is_dict_type(tp: Any) -> bool:
354
+ """Check if a type annotation is a dict type (dict, Dict, dict[...])."""
355
+ return get_origin(tp) is dict or tp is dict
356
+
357
+
358
+ def _collect_dataclass_field_names(cls: type[Any]) -> set[str]:
359
+ return {f.name for f in fields(cls) if not f.name.startswith("_")}
360
+
361
+
362
+ def _collect_dataclass_field_types(cls: type[Any]) -> dict[str, Any]:
363
+ # Use get_type_hints so forward refs and Optional are resolved.
364
+ return get_type_hints(cls)
365
+
366
+
367
+ def _validate_overrides_schema(overrides: dict[str, Any], manifest_path: str) -> None:
368
+ """Validate that override keys target only real SessionIntent fields.
369
+
370
+ SessionState.overrides is a dict, so dacite cannot enforce its schema.
371
+ We validate it manually against the SessionIntent dataclass structure.
372
+
373
+ Rules:
374
+ - No unknown keys at any level
375
+ - No `custom` namespace
376
+ - Only nested dataclass fields may contain nested dict overrides
377
+ """
378
+ from .models import SessionIntent
379
+
380
+ _validate_overrides_dict_against_dataclass(
381
+ overrides=overrides,
382
+ cls=SessionIntent,
383
+ path_prefix="overrides",
384
+ manifest_path=manifest_path,
385
+ )
386
+
387
+
388
+ def _validate_overrides_dict_against_dataclass(
389
+ overrides: dict[str, Any],
390
+ cls: Any,
391
+ path_prefix: str,
392
+ manifest_path: str,
393
+ ) -> None:
394
+ if not is_dataclass(cls):
395
+ raise ManifestCorruptedError(manifest_path, f"internal error: {cls} is not a dataclass")
396
+
397
+ if not isinstance(cls, type):
398
+ raise ManifestCorruptedError(manifest_path, f"internal error: {cls} is not a type")
399
+
400
+ valid_fields = _collect_dataclass_field_names(cls)
401
+ type_hints = _collect_dataclass_field_types(cls)
402
+
403
+ for key, value in overrides.items():
404
+ if key == "custom":
405
+ raise ManifestCorruptedError(manifest_path, "overrides.custom is not supported")
406
+
407
+ if key not in valid_fields:
408
+ raise ManifestCorruptedError(manifest_path, f"unknown override key: {path_prefix}.{key}")
409
+
410
+ field_type = type_hints.get(key)
411
+ if field_type is None:
412
+ # Should not happen for normal dataclasses; treat as schema error.
413
+ raise ManifestCorruptedError(manifest_path, f"missing type hint for {path_prefix}.{key}")
414
+
415
+ actual_type = unwrap_optional(field_type)
416
+
417
+ # Nested dict overrides are only allowed for nested dataclasses or dict-typed fields.
418
+ if isinstance(value, dict):
419
+ if is_dataclass(actual_type):
420
+ _validate_overrides_dict_against_dataclass(
421
+ overrides=value,
422
+ cls=actual_type,
423
+ path_prefix=f"{path_prefix}.{key}",
424
+ manifest_path=manifest_path,
425
+ )
426
+ elif not _is_dict_type(actual_type):
427
+ raise ManifestCorruptedError(
428
+ manifest_path,
429
+ f"{path_prefix}.{key} does not support nested override keys",
430
+ )
431
+ # else: dict-typed field — accept any dict value without schema validation
@@ -0,0 +1,47 @@
1
+ """Session name validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from .exceptions import InvalidSessionNameError
8
+
9
+ # Constants
10
+ MIN_NAME_LENGTH = 2
11
+ MAX_NAME_LENGTH = 64
12
+
13
+ # Regex: lowercase alphanumeric, hyphens allowed in middle, no consecutive hyphens
14
+ # Must start and end with alphanumeric
15
+ _NAME_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
16
+
17
+
18
+ def validate_name(name: str) -> None:
19
+ """Validate a session name.
20
+
21
+ Raises:
22
+ InvalidSessionNameError: If name is invalid, with specific reason.
23
+
24
+ Rules:
25
+ - Length: 2-64 characters
26
+ - Characters: lowercase alphanumeric + hyphens
27
+ - Must start with alphanumeric
28
+ - Must end with alphanumeric
29
+ - No consecutive hyphens
30
+
31
+ Examples:
32
+ Valid: "auth-feature", "bugfix-123", "a1"
33
+ Invalid: "-invalid", "invalid-", "in--valid", "UPPERCASE"
34
+ """
35
+ if len(name) < MIN_NAME_LENGTH:
36
+ raise InvalidSessionNameError(f"name must be at least {MIN_NAME_LENGTH} characters")
37
+
38
+ if len(name) > MAX_NAME_LENGTH:
39
+ raise InvalidSessionNameError(f"name must be at most {MAX_NAME_LENGTH} characters")
40
+
41
+ if not _NAME_PATTERN.match(name):
42
+ raise InvalidSessionNameError(
43
+ "name must be lowercase alphanumeric with hyphens, starting and ending with alphanumeric"
44
+ )
45
+
46
+ if "--" in name:
47
+ raise InvalidSessionNameError("name cannot contain consecutive hyphens")
@@ -0,0 +1,65 @@
1
+ """Git worktree utilities for session isolation.
2
+
3
+ This module provides functions for creating, configuring, and cleaning up
4
+ git worktrees for Forge sessions. Each session can have its own worktree,
5
+ enabling parallel work without manifest conflicts.
6
+
7
+ Key safety features:
8
+ - Never overwrites tracked files during config copy
9
+ - Never deletes tracked files during cleanup
10
+ - Uses refs/heads/ for branch checks (avoids tag false positives)
11
+ - Validates explicit --branch names
12
+ """
13
+
14
+ from .cleanup import (
15
+ CleanupResult,
16
+ cleanup_worktree,
17
+ delete_branch,
18
+ is_worktree_dirty,
19
+ remove_config_files,
20
+ remove_worktree,
21
+ )
22
+ from .config_copy import (
23
+ ConfigCopyResult,
24
+ DEFAULT_CONFIG_ALLOWLIST,
25
+ copy_runtime_config,
26
+ get_copied_config_files,
27
+ is_file_tracked,
28
+ )
29
+ from .create import (
30
+ WorktreeResult,
31
+ branch_exists,
32
+ create_worktree,
33
+ find_git_binary,
34
+ get_main_repo_root,
35
+ get_repo_root,
36
+ resolve_worktree_path,
37
+ sanitize_branch_name,
38
+ validate_branch_name,
39
+ )
40
+
41
+ __all__ = [
42
+ # create.py
43
+ "WorktreeResult",
44
+ "find_git_binary",
45
+ "get_repo_root",
46
+ "get_main_repo_root",
47
+ "branch_exists",
48
+ "validate_branch_name",
49
+ "sanitize_branch_name",
50
+ "resolve_worktree_path",
51
+ "create_worktree",
52
+ # config_copy.py
53
+ "ConfigCopyResult",
54
+ "DEFAULT_CONFIG_ALLOWLIST",
55
+ "is_file_tracked",
56
+ "copy_runtime_config",
57
+ "get_copied_config_files",
58
+ # cleanup.py
59
+ "CleanupResult",
60
+ "is_worktree_dirty",
61
+ "remove_config_files",
62
+ "remove_worktree",
63
+ "delete_branch",
64
+ "cleanup_worktree",
65
+ ]