multi-forge 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
forge/cli/search.py ADDED
@@ -0,0 +1,416 @@
1
+ """Search CLI commands for Forge transcript search.
2
+
3
+ Provides:
4
+ - forge search -q <query>: Search transcripts, output JSON
5
+ - forge search rebuild-index: Full index rebuild (writes three stores)
6
+ - forge search status: Show index statistics
7
+ - forge search clean: Remove orphaned documents
8
+
9
+ Query is passed via -q/--query option to avoid ambiguity with subcommand
10
+ names (Click groups parse positional args before subcommand resolution).
11
+
12
+ Stores are per-project at <forge_root>/.forge/search-index/:
13
+ - documents.json (v2): metadata only
14
+ - bm25_index.json: precomputed BM25 data structures
15
+ - content.json: document content for snippet extraction
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+
23
+ import click
24
+ from rich.console import Console
25
+
26
+ from forge.core.paths import display_path
27
+ from forge.core.state import SchemaVersionError
28
+ from forge.search.bm25_store import BM25IndexData, BM25IndexStore
29
+ from forge.search.content_store import ContentStore
30
+ from forge.search.engine import search_from_index
31
+ from forge.search.exceptions import (
32
+ BM25IndexCorruptedError,
33
+ ContentStoreCorruptedError,
34
+ SearchDocumentStoreCorruptedError,
35
+ )
36
+ from forge.search.store import SearchDocumentStore
37
+
38
+
39
+ def _resolve_forge_root() -> Path:
40
+ """Resolve the current Forge project root, falling back to cwd."""
41
+ from forge.session.artifacts import resolve_forge_root
42
+
43
+ try:
44
+ return resolve_forge_root(Path.cwd())
45
+ except Exception:
46
+ return Path.cwd().resolve()
47
+
48
+
49
+ @click.group(
50
+ invoke_without_command=True,
51
+ context_settings={"help_option_names": ["-h", "--help"]},
52
+ )
53
+ @click.option("-q", "--query", type=str, default=None, help="Search query")
54
+ @click.option("--limit", "-n", type=int, default=10, help="Maximum results")
55
+ @click.option(
56
+ "--scope",
57
+ type=click.Choice(["project", "all"]),
58
+ default="project",
59
+ help="Search scope: current project (default) or all indexed projects",
60
+ )
61
+ @click.pass_context
62
+ def search_cmd(ctx: click.Context, query: str | None, limit: int, scope: str) -> None:
63
+ """Search session transcripts.
64
+
65
+ \b
66
+ Examples:
67
+ forge search -q "timeout config" Search for "timeout config"
68
+ forge search rebuild-index Rebuild the search index
69
+ forge search status Show index statistics
70
+ """
71
+ if ctx.invoked_subcommand is not None:
72
+ return
73
+
74
+ if query is None:
75
+ click.echo(ctx.get_help())
76
+ return
77
+
78
+ _run_search(query, limit=limit, scope=scope)
79
+
80
+
81
+ def _run_search(query: str, *, limit: int, scope: str) -> None:
82
+ """Execute a search and output JSON results."""
83
+ if scope == "all":
84
+ _run_search_all_projects(query, limit=limit)
85
+ return
86
+
87
+ project_root = _resolve_forge_root()
88
+
89
+ try:
90
+ results = _search_project(project_root, query, limit=limit)
91
+ except (
92
+ SearchDocumentStoreCorruptedError,
93
+ BM25IndexCorruptedError,
94
+ ContentStoreCorruptedError,
95
+ SchemaVersionError,
96
+ ) as e:
97
+ output = {
98
+ "query": query,
99
+ "total_results": 0,
100
+ "results": [],
101
+ "error": f"Search index corrupted or outdated: {e}",
102
+ "hint": "Run 'forge search rebuild-index' to rebuild.",
103
+ }
104
+ click.echo(json.dumps(output, indent=2))
105
+ return
106
+
107
+ if results is None:
108
+ hint = "No transcripts indexed for this project. Run 'forge search rebuild-index' or use --scope all."
109
+ try:
110
+ from forge.install.hooks import has_forge_hook
111
+
112
+ if not has_forge_hook(project_root, "Stop"):
113
+ hint += " If hooks are not installed, transcripts are not captured automatically."
114
+ except Exception:
115
+ pass
116
+ output = {
117
+ "query": query,
118
+ "total_results": 0,
119
+ "results": [],
120
+ "hint": hint,
121
+ }
122
+ click.echo(json.dumps(output, indent=2))
123
+ return
124
+
125
+ _output_results(query, results)
126
+
127
+
128
+ def _run_search_all_projects(query: str, *, limit: int) -> None:
129
+ """Search across all known project indices."""
130
+ import logging
131
+
132
+ from forge.session.index import IndexStore
133
+
134
+ logger = logging.getLogger(__name__)
135
+ current_root = _resolve_forge_root()
136
+ project_roots = {str(current_root)}
137
+
138
+ try:
139
+ index = IndexStore()
140
+ sessions = index.list_sessions()
141
+ project_roots.update((entry.forge_root or entry.worktree_path) for _, entry in sessions)
142
+ except Exception:
143
+ pass
144
+
145
+ all_results: list = []
146
+ searched_any_index = False
147
+ for root in project_roots:
148
+ try:
149
+ results = _search_project(Path(root), query, limit=limit)
150
+ if results is not None:
151
+ searched_any_index = True
152
+ all_results.extend(results)
153
+ except (
154
+ SearchDocumentStoreCorruptedError,
155
+ BM25IndexCorruptedError,
156
+ ContentStoreCorruptedError,
157
+ SchemaVersionError,
158
+ ) as e:
159
+ logger.warning("Skipping corrupted search index in %s: %s", root, e)
160
+ continue
161
+ except Exception as e:
162
+ logger.debug("Skipping project %s: %s", root, e)
163
+ continue
164
+
165
+ if not all_results:
166
+ if searched_any_index:
167
+ output = {
168
+ "query": query,
169
+ "total_results": 0,
170
+ "results": [],
171
+ }
172
+ click.echo(json.dumps(output, indent=2))
173
+ return
174
+ output = {
175
+ "query": query,
176
+ "total_results": 0,
177
+ "results": [],
178
+ "hint": "No indexed transcripts. Run 'forge search rebuild-index' first.",
179
+ }
180
+ click.echo(json.dumps(output, indent=2))
181
+ return
182
+
183
+ # Merge and sort by score descending
184
+ all_results.sort(key=lambda r: r.score, reverse=True)
185
+ _output_results(query, all_results[:limit])
186
+
187
+
188
+ def _search_project(project_root: Path, query: str, *, limit: int):
189
+ """Search a single project using its persistent BM25 index.
190
+
191
+ Returns list of SearchResult, or None if no index exists.
192
+ Raises on corruption (caller handles).
193
+ """
194
+ bm25_store = BM25IndexStore(forge_root=project_root)
195
+ bm25_index = bm25_store.read()
196
+ if bm25_index is None:
197
+ return None
198
+
199
+ doc_store = SearchDocumentStore(forge_root=project_root)
200
+ doc_metas = doc_store.read()
201
+ if not doc_metas and not bm25_index.doc_keys:
202
+ return None
203
+
204
+ meta_map = {m.transcript_path: m for m in doc_metas}
205
+
206
+ content_store = ContentStore(forge_root=project_root)
207
+
208
+ return search_from_index(
209
+ query,
210
+ doc_keys=bm25_index.doc_keys,
211
+ term_freqs=bm25_index.term_freqs,
212
+ doc_freqs=bm25_index.doc_freqs,
213
+ doc_lens=bm25_index.doc_lens,
214
+ avgdl=bm25_index.avgdl,
215
+ k1=bm25_index.k1,
216
+ b=bm25_index.b,
217
+ content_loader=content_store.read_keys,
218
+ doc_metadata=meta_map,
219
+ limit=limit,
220
+ )
221
+
222
+
223
+ def _output_results(query: str, results: list) -> None:
224
+ """Format and output search results as JSON."""
225
+ output = {
226
+ "query": query,
227
+ "total_results": len(results),
228
+ "results": [
229
+ {
230
+ "session_name": r.session_name,
231
+ "session_id": r.session_id,
232
+ "score": r.score,
233
+ "snippet": r.snippet,
234
+ "transcript_path": r.transcript_path,
235
+ "metadata": r.metadata,
236
+ }
237
+ for r in results
238
+ ],
239
+ }
240
+ click.echo(json.dumps(output, indent=2))
241
+
242
+
243
+ @search_cmd.command("rebuild-index")
244
+ def rebuild_index_cmd() -> None:
245
+ """Rebuild search index from all transcript artifacts.
246
+
247
+ Scans .forge/artifacts/**/transcripts/*.jsonl in the current project,
248
+ extracts content, and writes to all three per-project stores
249
+ (documents.json, bm25_index.json, content.json).
250
+ This is a full reset — all data for this project is replaced.
251
+ """
252
+ console = Console()
253
+
254
+ from forge.search.engine import BM25
255
+ from forge.search.extractor import decompose_document, extract_document
256
+ from forge.search.index_state import IndexStateStore
257
+
258
+ project_root = _resolve_forge_root()
259
+ artifacts_dir = project_root / ".forge" / "artifacts"
260
+
261
+ if not artifacts_dir.is_dir():
262
+ console.print("[dim]No artifacts directory found.[/dim]")
263
+ return
264
+
265
+ doc_store = SearchDocumentStore(forge_root=project_root)
266
+ bm25_store = BM25IndexStore(forge_root=project_root)
267
+ content_store = ContentStore(forge_root=project_root)
268
+ index_store = IndexStateStore(forge_root=project_root)
269
+
270
+ project_root_str = str(project_root)
271
+
272
+ # Extract all docs
273
+ new_docs = []
274
+ errors = 0
275
+
276
+ for session_dir in sorted(artifacts_dir.iterdir()):
277
+ if not session_dir.is_dir():
278
+ continue
279
+ session_name = session_dir.name
280
+ transcripts_dir = session_dir / "transcripts"
281
+ if not transcripts_dir.is_dir():
282
+ continue
283
+
284
+ for jsonl_file in sorted(transcripts_dir.glob("*.jsonl")):
285
+ session_id = jsonl_file.stem
286
+ try:
287
+ doc = extract_document(
288
+ transcript_path=jsonl_file,
289
+ session_name=session_name,
290
+ session_id=session_id,
291
+ worktree_path=project_root_str,
292
+ )
293
+ new_docs.append(doc)
294
+ except Exception as e:
295
+ console.print(f"[yellow]Warning:[/yellow] Failed to extract {jsonl_file.name}: {e}")
296
+ errors += 1
297
+
298
+ # Decompose into three-store components
299
+ metas = []
300
+ content_map = {}
301
+ all_tokens = []
302
+
303
+ for doc in new_docs:
304
+ meta, _, _, content = decompose_document(doc)
305
+ metas.append(meta)
306
+ content_map[doc.transcript_path] = content
307
+ all_tokens.append(doc.tokens if doc.tokens is not None else [])
308
+
309
+ # Build BM25 from all tokens at once (efficient bulk construction)
310
+ bm25 = BM25(all_tokens)
311
+ precomputed = bm25.to_precomputed()
312
+
313
+ bm25_data = BM25IndexData(
314
+ doc_keys=[doc.transcript_path for doc in new_docs],
315
+ doc_lens=precomputed["doc_lens"],
316
+ term_freqs=precomputed["term_freqs"],
317
+ doc_freqs=precomputed["doc_freqs"],
318
+ avgdl=precomputed["avgdl"],
319
+ k1=precomputed["k1"],
320
+ b=precomputed["b"],
321
+ )
322
+
323
+ # Replace all three stores under locks
324
+ doc_store.replace_all(metas)
325
+ bm25_store.replace_all(bm25_data)
326
+ content_store.replace_all(content_map)
327
+
328
+ # Mark all as indexed
329
+ for doc in new_docs:
330
+ try:
331
+ index_store.mark_indexed(Path(doc.transcript_path))
332
+ except (FileNotFoundError, ValueError):
333
+ pass
334
+
335
+ # Prune stale entries from index state (stores were fully replaced)
336
+ index_store.prune_missing()
337
+
338
+ console.print(f"[green]Indexed {len(new_docs)} transcripts.[/green]")
339
+ if errors:
340
+ console.print(f"[yellow]{errors} files failed extraction.[/yellow]")
341
+
342
+
343
+ @search_cmd.command("clean")
344
+ def clean_cmd() -> None:
345
+ """Remove orphaned documents whose transcript files no longer exist.
346
+
347
+ Scans all three stores and index state, removing entries that point
348
+ to transcript files that have been deleted or moved.
349
+ """
350
+ console = Console()
351
+
352
+ from forge.search.index_state import IndexStateStore
353
+
354
+ project_root = _resolve_forge_root()
355
+ doc_store = SearchDocumentStore(forge_root=project_root)
356
+ bm25_store = BM25IndexStore(forge_root=project_root)
357
+ content_store = ContentStore(forge_root=project_root)
358
+ index_store = IndexStateStore(forge_root=project_root)
359
+
360
+ removed_docs = doc_store.prune_missing()
361
+
362
+ # Also remove from BM25 index and content store
363
+ for path in removed_docs:
364
+ bm25_store.remove_document(path)
365
+ content_store.remove(path)
366
+
367
+ removed_index = index_store.prune_missing()
368
+
369
+ if removed_docs or removed_index:
370
+ console.print(
371
+ f"Pruned [cyan]{len(removed_docs)}[/cyan] orphaned documents"
372
+ f" and [cyan]{len(removed_index)}[/cyan] stale index entries."
373
+ )
374
+ else:
375
+ console.print("[dim]No orphaned entries found.[/dim]")
376
+
377
+
378
+ @search_cmd.command("status")
379
+ def status_cmd() -> None:
380
+ """Show search index statistics."""
381
+ console = Console()
382
+
383
+ from forge.search.index_state import IndexStateStore
384
+
385
+ project_root = _resolve_forge_root()
386
+ doc_store = SearchDocumentStore(forge_root=project_root)
387
+ bm25_store = BM25IndexStore(forge_root=project_root)
388
+ index_store = IndexStateStore(forge_root=project_root)
389
+ index_dir = project_root / ".forge" / "search-index"
390
+
391
+ if not doc_store.exists():
392
+ console.print("Search index: [yellow]not built[/yellow]")
393
+ console.print(f"Index location: [dim]{display_path(index_dir)}[/dim]")
394
+ console.print("\n[dim]Tip: Run 'forge search rebuild-index' to build.[/dim]")
395
+ return
396
+
397
+ documents = doc_store.read()
398
+ state = index_store.read()
399
+
400
+ console.print(f"Index location: [dim]{display_path(index_dir)}[/dim]")
401
+ console.print(f"Documents indexed: [cyan]{len(documents)}[/cyan]")
402
+ console.print(f"Files tracked: [cyan]{len(state.indexed_files)}[/cyan]")
403
+ if state.updated_at:
404
+ console.print(f"Last updated: [dim]{state.updated_at}[/dim]")
405
+
406
+ if documents:
407
+ session_names = {d.session_name for d in documents}
408
+ console.print(f"Sessions: [cyan]{len(session_names)}[/cyan]")
409
+
410
+ # BM25 index stats
411
+ bm25_index = bm25_store.read()
412
+ if bm25_index is not None:
413
+ console.print(
414
+ f"BM25 index: [cyan]{len(bm25_index.doc_keys)}[/cyan] documents, "
415
+ f"[cyan]{len(bm25_index.doc_freqs)}[/cyan] unique terms"
416
+ )