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,295 @@
1
+ """Search index state for incremental transcript indexing.
2
+
3
+ Tracks which transcript files have been indexed via mtime/size fingerprints,
4
+ enabling the search system to skip already-indexed files and detect changes.
5
+
6
+ Two-layer architecture:
7
+ - IndexState (dataclass): pure in-memory operations (needs_reindex, mark_indexed, prune)
8
+ - IndexStateStore: persistence + locking (read, write, update with file_lock_for_target)
9
+
10
+ State file location: <project_root>/.forge/search-index/state.json
11
+
12
+ Follows the BackendRegistry/BackendRegistryStore pattern from forge.backend.registry.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from dataclasses import asdict, dataclass, field
20
+ from pathlib import Path
21
+ from typing import Callable
22
+
23
+ from forge.core.state import (
24
+ SchemaVersionError,
25
+ atomic_write_json,
26
+ file_lock_for_target,
27
+ now_iso,
28
+ )
29
+
30
+ from .exceptions import IndexStateCorruptedError
31
+
32
+ # Directory and file names
33
+ SEARCH_INDEX_DIR = "search-index"
34
+ STATE_FILENAME = "state.json"
35
+
36
+ # Schema version — reject anything else (no migration, per coding-standards.md)
37
+ INDEX_STATE_VERSION = 1
38
+
39
+ # Lock timeouts
40
+ CLI_LOCK_TIMEOUT_S = 5.0
41
+ HANDLER_LOCK_TIMEOUT_S = 1.0
42
+
43
+
44
+ def get_project_index_state_path(forge_root: Path) -> Path:
45
+ """Return the index state path for a Forge project.
46
+
47
+ Path: <forge_root>/.forge/search-index/state.json
48
+ """
49
+ return forge_root / ".forge" / SEARCH_INDEX_DIR / STATE_FILENAME
50
+
51
+
52
+ def _require_absolute(path: Path) -> None:
53
+ """Raise ValueError if path is not absolute."""
54
+ if not path.is_absolute():
55
+ raise ValueError(f"path must be absolute, got: {path}")
56
+
57
+
58
+ # --- Data layer ---
59
+
60
+
61
+ @dataclass
62
+ class IndexedFileEntry:
63
+ """Tracking metadata for a single indexed transcript file."""
64
+
65
+ mtime: float
66
+ size: int
67
+ indexed_at: str
68
+
69
+
70
+ @dataclass
71
+ class IndexState:
72
+ """In-memory representation of the search index state.
73
+
74
+ Pure operations — no disk I/O. Persistence is handled by IndexStateStore.
75
+ """
76
+
77
+ schema_version: int = INDEX_STATE_VERSION
78
+ updated_at: str = ""
79
+ indexed_files: dict[str, IndexedFileEntry] = field(default_factory=dict)
80
+
81
+ def needs_reindex(self, path: Path) -> bool:
82
+ """Check if a file needs (re)indexing based on mtime/size.
83
+
84
+ Returns True if the file is new or has changed since last indexing.
85
+ Returns False if the file is unchanged OR if the file does not exist on disk.
86
+
87
+ Missing-file semantics:
88
+ - No entry + file missing → False (nothing to do)
89
+ - Entry exists + file deleted → False (prune_missing() handles cleanup)
90
+
91
+ Raises:
92
+ ValueError: If path is not absolute.
93
+ """
94
+ _require_absolute(path)
95
+
96
+ try:
97
+ stat = os.stat(path)
98
+ except OSError:
99
+ return False
100
+
101
+ key = str(path)
102
+ entry = self.indexed_files.get(key)
103
+ if entry is None:
104
+ return True
105
+
106
+ return entry.mtime != stat.st_mtime or entry.size != stat.st_size
107
+
108
+ def mark_indexed(self, path: Path) -> None:
109
+ """Record that a file has been indexed with its current mtime/size.
110
+
111
+ Creates or updates the entry for the given path using the file's
112
+ current stat() values and the current timestamp.
113
+
114
+ Raises:
115
+ ValueError: If path is not absolute.
116
+ FileNotFoundError: If path does not exist on disk.
117
+ """
118
+ _require_absolute(path)
119
+
120
+ try:
121
+ stat = os.stat(path)
122
+ except FileNotFoundError:
123
+ raise
124
+ except OSError as e:
125
+ raise FileNotFoundError(str(path)) from e
126
+
127
+ self.indexed_files[str(path)] = IndexedFileEntry(
128
+ mtime=stat.st_mtime,
129
+ size=stat.st_size,
130
+ indexed_at=now_iso(),
131
+ )
132
+
133
+ def prune_missing(self) -> list[str]:
134
+ """Remove entries for files that no longer exist on disk.
135
+
136
+ Returns:
137
+ List of path strings that were removed.
138
+ """
139
+ to_remove = [key for key in self.indexed_files if not Path(key).is_file()]
140
+ for key in to_remove:
141
+ del self.indexed_files[key]
142
+ return to_remove
143
+
144
+
145
+ # --- Persistence layer ---
146
+
147
+
148
+ class IndexStateStore:
149
+ """Manage per-project search index state.
150
+
151
+ Store location: <project_root>/.forge/search-index/state.json
152
+ Tracks which transcript files have been indexed for incremental updates.
153
+ Uses atomic writes and advisory file locking for concurrent safety.
154
+
155
+ Error handling:
156
+ - Missing file: returns empty state (self-healing)
157
+ - Corrupted file: raises IndexStateCorruptedError
158
+ - Wrong schema version: raises SchemaVersionError
159
+ """
160
+
161
+ def __init__(
162
+ self,
163
+ forge_root: Path | None = None,
164
+ *,
165
+ state_path: Path | None = None,
166
+ ) -> None:
167
+ if state_path:
168
+ self._state_path = state_path # Explicit override (tests)
169
+ elif forge_root:
170
+ self._state_path = get_project_index_state_path(forge_root)
171
+ else:
172
+ raise ValueError("Either forge_root or state_path required")
173
+
174
+ @property
175
+ def state_path(self) -> Path:
176
+ return self._state_path
177
+
178
+ def exists(self) -> bool:
179
+ return self._state_path.is_file()
180
+
181
+ def read(self) -> IndexState:
182
+ """Read the index state from disk.
183
+
184
+ Returns empty IndexState if the file does not exist (self-healing).
185
+
186
+ Raises:
187
+ IndexStateCorruptedError: If the file contains invalid JSON or structure.
188
+ SchemaVersionError: If the schema version doesn't match INDEX_STATE_VERSION.
189
+ """
190
+ if not self.exists():
191
+ return IndexState()
192
+
193
+ path_str = str(self._state_path)
194
+
195
+ try:
196
+ with open(self._state_path, encoding="utf-8") as f:
197
+ data = json.load(f)
198
+ except json.JSONDecodeError as e:
199
+ raise IndexStateCorruptedError(path_str, f"invalid JSON: {e}") from e
200
+ except OSError as e:
201
+ raise IndexStateCorruptedError(path_str, f"read error: {e}") from e
202
+
203
+ if not isinstance(data, dict):
204
+ raise IndexStateCorruptedError(path_str, f"expected JSON object, got {type(data).__name__}")
205
+
206
+ version = data.get("schema_version")
207
+ if version is None:
208
+ raise IndexStateCorruptedError(path_str, "missing schema_version")
209
+ if version != INDEX_STATE_VERSION:
210
+ raise SchemaVersionError(path_str, INDEX_STATE_VERSION, version)
211
+
212
+ # Deserialize indexed_files: dict[str, dict] → dict[str, IndexedFileEntry]
213
+ indexed_files: dict[str, IndexedFileEntry] = {}
214
+ raw_files = data.get("indexed_files", {})
215
+ if isinstance(raw_files, dict):
216
+ for key, val in raw_files.items():
217
+ if isinstance(val, dict):
218
+ try:
219
+ indexed_files[key] = IndexedFileEntry(
220
+ mtime=float(val["mtime"]),
221
+ size=int(val["size"]),
222
+ indexed_at=str(val.get("indexed_at", "")),
223
+ )
224
+ except (KeyError, TypeError, ValueError):
225
+ # Skip malformed entries rather than failing the whole read
226
+ continue
227
+
228
+ return IndexState(
229
+ schema_version=version,
230
+ updated_at=data.get("updated_at", ""),
231
+ indexed_files=indexed_files,
232
+ )
233
+
234
+ def write(self, state: IndexState) -> None:
235
+ """Write the index state atomically.
236
+
237
+ Sets state.updated_at to the current timestamp before writing.
238
+ Creates parent directories if needed.
239
+ """
240
+ state.updated_at = now_iso()
241
+ data = asdict(state)
242
+ atomic_write_json(self._state_path, data)
243
+
244
+ def update(self, *, timeout_s: float, mutate: Callable[[IndexState], None]) -> IndexState:
245
+ """Locked read-modify-write cycle.
246
+
247
+ Acquires an advisory file lock, reads the state, calls mutate(state),
248
+ then writes the updated state. Exceptions from mutate propagate
249
+ (not swallowed).
250
+
251
+ Args:
252
+ timeout_s: Maximum time to wait for the lock.
253
+ mutate: Callable that modifies the IndexState in-place.
254
+
255
+ Returns:
256
+ The updated IndexState after mutation and write.
257
+ """
258
+ with file_lock_for_target(target_path=self._state_path, timeout_s=timeout_s):
259
+ state = self.read()
260
+ mutate(state)
261
+ self.write(state)
262
+ return state
263
+
264
+ # -- Convenience wrappers --
265
+
266
+ def mark_indexed(self, path: Path, *, timeout_s: float = HANDLER_LOCK_TIMEOUT_S) -> None:
267
+ """Mark a file as indexed (locked read-modify-write).
268
+
269
+ Convenience wrapper around update() that calls state.mark_indexed(path).
270
+
271
+ Raises:
272
+ ValueError: If path is not absolute.
273
+ FileNotFoundError: If path does not exist on disk.
274
+ """
275
+
276
+ def _mutate(state: IndexState) -> None:
277
+ state.mark_indexed(path)
278
+
279
+ self.update(timeout_s=timeout_s, mutate=_mutate)
280
+
281
+ def prune_missing(self, *, timeout_s: float = CLI_LOCK_TIMEOUT_S) -> list[str]:
282
+ """Remove entries for deleted files (locked read-modify-write).
283
+
284
+ Convenience wrapper around update() that calls state.prune_missing().
285
+
286
+ Returns:
287
+ List of path strings that were removed.
288
+ """
289
+ removed: list[str] = []
290
+
291
+ def _mutate(state: IndexState) -> None:
292
+ removed.extend(state.prune_missing())
293
+
294
+ self.update(timeout_s=timeout_s, mutate=_mutate)
295
+ return removed
forge/search/store.py ADDED
@@ -0,0 +1,215 @@
1
+ """Document metadata store for search-indexed transcripts (v2).
2
+
3
+ Persists SearchDocumentMeta objects (metadata only — no content, no tokens)
4
+ at <project_root>/.forge/search-index/documents.json. Content and BM25 index
5
+ data are stored in separate files (content.json, bm25_index.json).
6
+
7
+ Each project has its own store — no cross-project mixing in a single file.
8
+
9
+ Follows the IndexStateStore pattern: versioned JSON, atomic writes, file locking,
10
+ self-healing on missing file.
11
+
12
+ Uses dacite for deserialization (consistent with BackendRegistryStore).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import dacite
23
+
24
+ from forge.core.state import (
25
+ SchemaVersionError,
26
+ atomic_write_json,
27
+ file_lock_for_target,
28
+ now_iso,
29
+ )
30
+
31
+ from .exceptions import SearchDocumentStoreCorruptedError
32
+ from .extractor import SearchDocumentMeta
33
+ from .index_state import SEARCH_INDEX_DIR
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # File and schema constants
38
+ DOCUMENTS_FILENAME = "documents.json"
39
+ DOCUMENT_STORE_VERSION = 1
40
+
41
+ # Lock timeouts
42
+ STORE_LOCK_TIMEOUT_S = 5.0
43
+ HANDLER_STORE_LOCK_TIMEOUT_S = 1.0
44
+
45
+
46
+ def get_project_documents_store_path(forge_root: Path) -> Path:
47
+ """Return the document store path for a Forge project.
48
+
49
+ Path: <forge_root>/.forge/search-index/documents.json
50
+ """
51
+ return forge_root / ".forge" / SEARCH_INDEX_DIR / DOCUMENTS_FILENAME
52
+
53
+
54
+ class SearchDocumentStore:
55
+ """Manage per-project search document metadata store.
56
+
57
+ Store location: <forge_root>/.forge/search-index/documents.json
58
+ Documents are keyed by transcript_path (absolute path string).
59
+
60
+ V2 schema: metadata only (no content, no tokens). Content and BM25
61
+ index data are stored in separate files.
62
+
63
+ Error handling:
64
+ - Missing file: returns empty list (self-healing)
65
+ - Corrupted file: raises SearchDocumentStoreCorruptedError
66
+ - Wrong schema version: raises SchemaVersionError (v1 triggers rebuild)
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ forge_root: Path | None = None,
72
+ *,
73
+ store_path: Path | None = None,
74
+ ) -> None:
75
+ if store_path:
76
+ self._store_path = store_path # Explicit override (tests)
77
+ elif forge_root:
78
+ self._store_path = get_project_documents_store_path(forge_root)
79
+ else:
80
+ raise ValueError("Either forge_root or store_path required")
81
+
82
+ @property
83
+ def store_path(self) -> Path:
84
+ return self._store_path
85
+
86
+ def exists(self) -> bool:
87
+ return self._store_path.is_file()
88
+
89
+ def read(self) -> list[SearchDocumentMeta]:
90
+ """Read all document metadata from disk.
91
+
92
+ Returns empty list if the file does not exist (self-healing).
93
+
94
+ Raises:
95
+ SearchDocumentStoreCorruptedError: If the file contains invalid JSON.
96
+ SchemaVersionError: If the schema version doesn't match (v1 → rebuild).
97
+ """
98
+ if not self.exists():
99
+ return []
100
+
101
+ path_str = str(self._store_path)
102
+
103
+ try:
104
+ with open(self._store_path, encoding="utf-8") as f:
105
+ data = json.load(f)
106
+ except json.JSONDecodeError as e:
107
+ raise SearchDocumentStoreCorruptedError(path_str, f"invalid JSON: {e}") from e
108
+ except OSError as e:
109
+ raise SearchDocumentStoreCorruptedError(path_str, f"read error: {e}") from e
110
+
111
+ if not isinstance(data, dict):
112
+ raise SearchDocumentStoreCorruptedError(path_str, f"expected JSON object, got {type(data).__name__}")
113
+
114
+ version = data.get("schema_version")
115
+ if version is None:
116
+ raise SearchDocumentStoreCorruptedError(path_str, "missing schema_version")
117
+ if version != DOCUMENT_STORE_VERSION:
118
+ raise SchemaVersionError(path_str, DOCUMENT_STORE_VERSION, version)
119
+
120
+ raw_docs = data.get("documents", [])
121
+ if not isinstance(raw_docs, list):
122
+ logger.warning(
123
+ "Document store %s has non-list 'documents' field (got %s), treating as empty",
124
+ path_str,
125
+ type(raw_docs).__name__,
126
+ )
127
+ return []
128
+
129
+ documents: list[SearchDocumentMeta] = []
130
+ path_str = str(self._store_path)
131
+ for i, raw in enumerate(raw_docs):
132
+ if not isinstance(raw, dict):
133
+ raise SearchDocumentStoreCorruptedError(path_str, f"entry {i} is {type(raw).__name__}, expected dict")
134
+ try:
135
+ doc = dacite.from_dict(
136
+ data_class=SearchDocumentMeta,
137
+ data=raw,
138
+ config=dacite.Config(strict=True),
139
+ )
140
+ documents.append(doc)
141
+ except (dacite.DaciteError, KeyError, TypeError) as e:
142
+ raise SearchDocumentStoreCorruptedError(path_str, f"entry {i} deserialization error: {e}") from e
143
+
144
+ return documents
145
+
146
+ def write(self, documents: list[SearchDocumentMeta]) -> None:
147
+ """Write documents atomically.
148
+
149
+ Creates parent directories if needed.
150
+ """
151
+ data: dict[str, Any] = {
152
+ "schema_version": DOCUMENT_STORE_VERSION,
153
+ "updated_at": now_iso(),
154
+ "documents": [doc.to_dict() for doc in documents],
155
+ }
156
+ atomic_write_json(self._store_path, data)
157
+
158
+ def replace_all(
159
+ self,
160
+ documents: list[SearchDocumentMeta],
161
+ *,
162
+ timeout_s: float = STORE_LOCK_TIMEOUT_S,
163
+ ) -> None:
164
+ """Replace all documents under lock (for rebuild-index)."""
165
+ with file_lock_for_target(target_path=self._store_path, timeout_s=timeout_s):
166
+ self.write(documents)
167
+
168
+ def add(
169
+ self,
170
+ doc: SearchDocumentMeta,
171
+ *,
172
+ timeout_s: float = HANDLER_STORE_LOCK_TIMEOUT_S,
173
+ ) -> None:
174
+ """Add or replace a document (locked read-modify-write).
175
+
176
+ Documents are keyed by transcript_path. Idempotent: if a document
177
+ with the same transcript_path already exists, it is replaced.
178
+ """
179
+ with file_lock_for_target(target_path=self._store_path, timeout_s=timeout_s):
180
+ docs = self.read()
181
+ docs = [d for d in docs if d.transcript_path != doc.transcript_path]
182
+ docs.append(doc)
183
+ self.write(docs)
184
+
185
+ def prune_missing(self, *, timeout_s: float = STORE_LOCK_TIMEOUT_S) -> list[str]:
186
+ """Remove documents whose transcript_path no longer exists on disk.
187
+
188
+ Locked read-modify-write. Returns list of removed transcript_path strings.
189
+ Skips write if nothing was pruned.
190
+ """
191
+ with file_lock_for_target(target_path=self._store_path, timeout_s=timeout_s):
192
+ docs = self.read()
193
+ kept: list[SearchDocumentMeta] = []
194
+ removed: list[str] = []
195
+ for d in docs:
196
+ if Path(d.transcript_path).is_file():
197
+ kept.append(d)
198
+ else:
199
+ removed.append(d.transcript_path)
200
+ if removed:
201
+ self.write(kept)
202
+ return removed
203
+
204
+ def remove(self, transcript_path: str, *, timeout_s: float = STORE_LOCK_TIMEOUT_S) -> bool:
205
+ """Remove a document by transcript_path (locked read-modify-write).
206
+
207
+ Returns True if a document was found and removed, False otherwise.
208
+ """
209
+ with file_lock_for_target(target_path=self._store_path, timeout_s=timeout_s):
210
+ docs = self.read()
211
+ filtered = [d for d in docs if d.transcript_path != transcript_path]
212
+ if len(filtered) == len(docs):
213
+ return False
214
+ self.write(filtered)
215
+ return True
@@ -0,0 +1,24 @@
1
+ """Shared tokenization for BM25 indexing and search.
2
+
3
+ Used by both extractor (token caching at extraction time) and engine
4
+ (query tokenization + snippet anchoring at search time).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ # Matches word-like tokens (letters, digits, underscores).
12
+ # Case-insensitive so _best_snippet() can iterate raw mixed-case content
13
+ # and match against lowercased query tokens.
14
+ TOKEN_RE = re.compile(r"[a-zA-Z0-9_]+")
15
+
16
+ MIN_TOKEN_LENGTH = 2
17
+
18
+
19
+ def tokenize(text: str) -> list[str]:
20
+ """Simple word tokenizer for BM25.
21
+
22
+ Lowercase, extract alphanumeric+underscore tokens, filter short tokens.
23
+ """
24
+ return [m for m in TOKEN_RE.findall(text.lower()) if len(m) >= MIN_TOKEN_LENGTH]
@@ -0,0 +1,130 @@
1
+ """Forge Session - Named session management for Claude Code.
2
+
3
+ This module provides the essential public API for session management:
4
+
5
+ - SessionState: Core data structure for session state (intent + confirmed)
6
+ - SessionStore: Read/write .forge/sessions/<name>/forge.session.json
7
+ - IndexStore: Read/write ~/.forge/sessions/index.json
8
+ - SessionManager: High-level session orchestration
9
+
10
+ For specialized access, import from submodules directly:
11
+
12
+ - forge.session.models: All data models (SessionIntent, SessionConfirmed, Worktree, etc.)
13
+ - forge.session.effective: Effective config computation (apply_overrides, get_effective_value)
14
+ - forge.session.overrides: Override operations (validate_key, parse_value, expand_wildcard)
15
+ - forge.session.hooks: Hook integration (handle_session_start, HookResult, etc.)
16
+ - forge.session.exceptions: Full exception hierarchy
17
+ - forge.session.store: Store constants (MANIFEST_FILENAME, etc.)
18
+ - forge.session.index: Index constants (INDEX_DIR, etc.)
19
+ - forge.session.validation: Name validation constants (MIN/MAX_NAME_LENGTH)
20
+ - forge.session.config: Config constants (VALID_PROXY_TEMPLATES)
21
+
22
+ Quick Start:
23
+ from forge.session import (
24
+ create_session_state,
25
+ SessionStore,
26
+ IndexStore,
27
+ )
28
+
29
+ # Create a new session state
30
+ state = create_session_state(
31
+ "my-session",
32
+ proxy_template="litellm-gemini",
33
+ proxy_base_url="http://localhost:8084",
34
+ )
35
+
36
+ # Write state to worktree (per-session directory)
37
+ store = SessionStore("/path/to/worktree", "my-session")
38
+ store.write(state)
39
+
40
+ # Add to global index
41
+ index = IndexStore()
42
+ index.add_from_state(state, "/path/to/project")
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ # Config
48
+ from .active import (
49
+ ActiveSessionEntry,
50
+ ActiveSessionIndex,
51
+ ActiveSessionStore,
52
+ run_with_active_session,
53
+ track_active_session,
54
+ )
55
+ from .config import (
56
+ DEFAULT_PROXY_BASE_URL,
57
+ DEFAULT_PROXY_TEMPLATE,
58
+ LAUNCH_MODE_HOST,
59
+ LAUNCH_MODE_SIDECAR,
60
+ SIDECAR_RUNTIME_BASE_URL,
61
+ )
62
+
63
+ # Effective config
64
+ from .effective import compute_effective_intent
65
+
66
+ # Exceptions (base + common operational)
67
+ from .exceptions import (
68
+ ForgeSessionError,
69
+ InvalidSessionNameError,
70
+ SessionExistsError,
71
+ SessionNotFoundError,
72
+ )
73
+
74
+ # Index
75
+ from .index import IndexStore
76
+
77
+ # Manager
78
+ from .manager import SessionManager
79
+
80
+ # Models
81
+ from .models import (
82
+ SCHEMA_VERSION,
83
+ SessionIndexEntry,
84
+ SessionState,
85
+ create_session_state,
86
+ )
87
+
88
+ # Overrides
89
+ from .overrides import clear_overrides, delete_override, set_override
90
+
91
+ # Store
92
+ from .store import SessionStore
93
+
94
+ # Validation
95
+ from .validation import validate_name
96
+
97
+ __all__ = [
98
+ # Core types
99
+ "SessionState",
100
+ "SessionIndexEntry",
101
+ "create_session_state",
102
+ "SCHEMA_VERSION",
103
+ "ActiveSessionEntry",
104
+ "ActiveSessionIndex",
105
+ # Stores
106
+ "SessionStore",
107
+ "IndexStore",
108
+ "ActiveSessionStore",
109
+ # Manager
110
+ "SessionManager",
111
+ # Operations
112
+ "compute_effective_intent",
113
+ "set_override",
114
+ "delete_override",
115
+ "clear_overrides",
116
+ "run_with_active_session",
117
+ "track_active_session",
118
+ "validate_name",
119
+ # Config
120
+ "DEFAULT_PROXY_TEMPLATE",
121
+ "DEFAULT_PROXY_BASE_URL",
122
+ "LAUNCH_MODE_HOST",
123
+ "LAUNCH_MODE_SIDECAR",
124
+ "SIDECAR_RUNTIME_BASE_URL",
125
+ # Exceptions (base + common operational)
126
+ "ForgeSessionError",
127
+ "SessionNotFoundError",
128
+ "SessionExistsError",
129
+ "InvalidSessionNameError",
130
+ ]