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/guards.py ADDED
@@ -0,0 +1,106 @@
1
+ """CWD validation guards for session commands.
2
+
3
+ Enforces two invariants:
4
+ 1. CWD must be a git repo root OR a Forge project root (where .forge/ lives)
5
+ 2. CWD must be the main repo root (not a child worktree) — for --worktree commands
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from rich.console import Console
14
+
15
+ from forge.core.paths import display_path
16
+
17
+ console = Console()
18
+
19
+
20
+ def require_repo_root() -> Path:
21
+ """Verify CWD is a git repository root or a Forge project root.
22
+
23
+ Accepts CWD at a nested Forge project root (where .forge/ lives) for
24
+ monorepo support. Falls back to git root check for non-Forge directories.
25
+
26
+ Returns:
27
+ The validated CWD on success.
28
+ """
29
+ from forge.core.ops.context import find_forge_root
30
+ from forge.session.claude.paths import find_project_root
31
+
32
+ cwd = Path.cwd().resolve()
33
+
34
+ # Accept CWD at a Forge project root (nested or top-level)
35
+ forge_root = find_forge_root(cwd)
36
+ if forge_root is not None and forge_root == cwd:
37
+ return cwd
38
+
39
+ try:
40
+ repo_root = find_project_root().resolve()
41
+ except FileNotFoundError:
42
+ console.print("[red]Error:[/red] Not in a git repository")
43
+ sys.exit(1)
44
+
45
+ if cwd != repo_root:
46
+ hint = str(forge_root) if forge_root else str(repo_root)
47
+ console.print(
48
+ f"[red]Error:[/red] Must run from the repository root ({display_path(repo_root)}), " f"not a subdirectory"
49
+ )
50
+ console.print(f"\n[dim]Tip: cd {display_path(hint)}[/dim]")
51
+ sys.exit(1)
52
+
53
+ return cwd
54
+
55
+
56
+ def require_main_repo_root() -> Path:
57
+ """Verify CWD is the main git repo root (or Forge project root), not a child worktree.
58
+
59
+ Accepts CWD at a nested Forge project root for monorepo support.
60
+ For --worktree commands, also checks that we're not inside a child worktree.
61
+
62
+ Returns:
63
+ The validated CWD on success.
64
+ """
65
+ from forge.core.ops.context import find_forge_root
66
+ from forge.session.claude.paths import find_project_root
67
+ from forge.session.exceptions import GitNotFoundError, GitWorktreeError
68
+ from forge.session.worktree import get_main_repo_root
69
+
70
+ cwd = Path.cwd().resolve()
71
+
72
+ try:
73
+ repo_root = find_project_root().resolve()
74
+ except FileNotFoundError:
75
+ console.print("[red]Error:[/red] Not in a git repository")
76
+ sys.exit(1)
77
+
78
+ # Resolve main repo root before any error so the tip is always correct
79
+ try:
80
+ main_root = get_main_repo_root(repo_root).resolve()
81
+ except (GitWorktreeError, GitNotFoundError):
82
+ main_root = repo_root
83
+
84
+ if repo_root != main_root:
85
+ # Any location inside a child worktree (root or subfolder)
86
+ console.print(
87
+ "[red]Error:[/red] Cannot create worktrees from inside a child worktree. "
88
+ f"Run from the main repository root ({display_path(main_root)})"
89
+ )
90
+ console.print(f"\n[dim]Tip: cd {display_path(main_root)}[/dim]")
91
+ sys.exit(1)
92
+
93
+ # Accept CWD at a Forge project root (nested or top-level)
94
+ forge_root = find_forge_root(cwd)
95
+ if forge_root is not None and forge_root == cwd:
96
+ return cwd
97
+
98
+ if cwd != repo_root:
99
+ # Subfolder of the main repo without .forge/
100
+ console.print(
101
+ f"[red]Error:[/red] Must run from the repository root ({display_path(repo_root)}), " f"not a subdirectory"
102
+ )
103
+ console.print(f"\n[dim]Tip: cd {display_path(repo_root)}[/dim]")
104
+ sys.exit(1)
105
+
106
+ return cwd
forge/cli/handoff.py ADDED
@@ -0,0 +1,110 @@
1
+ """Handoff agent CLI commands.
2
+
3
+ Commands:
4
+ - forge handoff run: Execute the handoff agent for a session (background process)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import click
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @click.group(hidden=True)
19
+ def handoff() -> None:
20
+ """Manage handoff agent operations."""
21
+
22
+
23
+ @handoff.command("run")
24
+ @click.option("--session-name", required=True, help="Forge session name")
25
+ @click.option(
26
+ "--worktree-path",
27
+ required=True,
28
+ type=click.Path(exists=True),
29
+ help="Absolute path to the worktree",
30
+ )
31
+ @click.option(
32
+ "--transcript-rel",
33
+ required=True,
34
+ help="Repo-relative path to transcript artifact",
35
+ )
36
+ @click.option("--timeout", default=None, type=int, help="Max seconds for agent to run")
37
+ @click.option("--subprocess-proxy", default=None, hidden=True, help="Stop-time subprocess proxy snapshot")
38
+ @click.option("--root", "forge_root", default=None, type=click.Path(), hidden=True, help="Explicit Forge project root")
39
+ def run_cmd(
40
+ session_name: str,
41
+ worktree_path: str,
42
+ transcript_rel: str,
43
+ timeout: int | None,
44
+ subprocess_proxy: str | None,
45
+ forge_root: str | None,
46
+ ) -> None:
47
+ """Run the handoff agent for a completed session.
48
+
49
+ This is typically invoked by the work queue handler as a background process,
50
+ not directly by users. It reads the session manifest, checks if handoff is
51
+ enabled, and spawns claude -p to update project memory documents.
52
+ """
53
+ worktree = Path(worktree_path).resolve()
54
+ effective_root = Path(forge_root).resolve() if forge_root else worktree
55
+
56
+ # We use SessionStore directly (not resolve_session_store) because this
57
+ # runs as a detached background process without FORGE_SESSION env var set.
58
+ # The marker payload carries session_name explicitly.
59
+ try:
60
+ from forge.session.effective import compute_effective_intent
61
+ from forge.session.store import SessionStore
62
+
63
+ store = SessionStore(str(effective_root), session_name)
64
+ if not store.exists():
65
+ logger.info("No session manifest for %s in %s", session_name, worktree)
66
+ return
67
+
68
+ manifest = store.read()
69
+ effective = compute_effective_intent(manifest)
70
+ except Exception as e:
71
+ logger.warning("Failed to read session manifest for %s: %s", session_name, e)
72
+ raise SystemExit(1)
73
+
74
+ if not effective.memory or not effective.memory.auto_update:
75
+ logger.info("Handoff not configured for session %s", session_name)
76
+ return
77
+
78
+ config = effective.memory.auto_update
79
+ if not config.enabled:
80
+ logger.info("Handoff disabled for session %s", session_name)
81
+ return
82
+
83
+ from forge.session.handoff_agent import resolve_handoff_base_url, run_handoff_agent
84
+
85
+ confirmed_proxy_url = None
86
+ if manifest.confirmed.started_with_proxy:
87
+ confirmed_proxy_url = manifest.confirmed.started_with_proxy.base_url
88
+
89
+ base_url = resolve_handoff_base_url(
90
+ proxy_id=config.proxy,
91
+ confirmed_proxy_base_url=confirmed_proxy_url,
92
+ env_base_url=os.environ.get("ANTHROPIC_BASE_URL"),
93
+ direct=config.direct,
94
+ subprocess_proxy=subprocess_proxy or effective.subprocess_proxy,
95
+ )
96
+
97
+ designated_docs = effective.memory.designated_docs if effective.memory else []
98
+
99
+ success = run_handoff_agent(
100
+ session_name=session_name,
101
+ forge_root=effective_root,
102
+ transcript_snapshot_rel=transcript_rel,
103
+ config=config,
104
+ base_url=base_url,
105
+ timeout_seconds=timeout,
106
+ designated_docs=designated_docs,
107
+ )
108
+
109
+ if not success:
110
+ raise SystemExit(1)
@@ -0,0 +1,36 @@
1
+ """CLI hook commands for Claude Code integration.
2
+
3
+ This package was decomposed from a single 2,490-line ``hooks.py`` module (L7).
4
+ Each submodule owns a distinct concern:
5
+
6
+ - ``_group``: Click group definition
7
+ - ``commands``: Hook entry points (session-start, plan-write, stop, etc.)
8
+ - ``verification``: Ralph-Wiggum verification logic
9
+ - ``direct_commands``: ``%`` command dispatcher and handlers
10
+ - ``policy``: Policy check helpers
11
+ - ``install``: Hook enable/disable
12
+ - ``_helpers``: Shared I/O helpers
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ # The Click group — imported by cli/main.py
18
+ from ._group import hooks
19
+ from . import commands as _commands # noqa: F401 — registers @hooks.command() decorators
20
+ from .install import FORGE_HOOK_CONFIG, SETTINGS_FILENAME, enable, disable
21
+ from .verification import (
22
+ _get_last_assistant_text_for_verification,
23
+ _run_verification_check,
24
+ )
25
+
26
+ # Register enable/disable as subcommands of the hooks group
27
+ hooks.add_command(enable)
28
+ hooks.add_command(disable)
29
+
30
+ __all__ = [
31
+ "hooks",
32
+ "FORGE_HOOK_CONFIG",
33
+ "SETTINGS_FILENAME",
34
+ "_run_verification_check",
35
+ "_get_last_assistant_text_for_verification",
36
+ ]
@@ -0,0 +1,20 @@
1
+ """Click group for Forge hook commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+
8
+ @click.group(name="hook", hidden=True)
9
+ @click.pass_context
10
+ def hooks(ctx: click.Context) -> None:
11
+ """Hook handlers invoked by Claude Code.
12
+
13
+ Most subcommands are invoked automatically by Claude Code hooks
14
+ configured in .claude/settings.local.json. The 'enable' and
15
+ 'disable' subcommands are user-facing.
16
+ """
17
+ from forge.core.logging import configure_debug_logging
18
+
19
+ hook_name = ctx.invoked_subcommand or "hook"
20
+ configure_debug_logging(component=hook_name, subdirectory="hooks")
@@ -0,0 +1,149 @@
1
+ """Shared I/O helpers used across hook command modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from forge.session.hooks import HookResult
13
+ from forge.session.models import SessionState
14
+
15
+
16
+ def _find_latest_plan_from_transcript(transcript_path: str, cwd: Path) -> Path | None:
17
+ """Streaming scan for last plan file write.
18
+
19
+ This is a fallback only; it avoids loading the entire transcript into memory.
20
+ """
21
+
22
+ path = Path(transcript_path)
23
+ if not path.is_file():
24
+ return None
25
+
26
+ latest: Path | None = None
27
+ try:
28
+ with path.open(encoding="utf-8") as f:
29
+ for line in f:
30
+ line = line.strip()
31
+ if not line:
32
+ continue
33
+ try:
34
+ entry = json.loads(line)
35
+ except json.JSONDecodeError:
36
+ continue
37
+
38
+ if entry.get("type") != "assistant":
39
+ continue
40
+
41
+ message = entry.get("message")
42
+ if not isinstance(message, dict):
43
+ continue
44
+
45
+ content = message.get("content")
46
+ if not isinstance(content, list):
47
+ continue
48
+
49
+ for block in content:
50
+ if not isinstance(block, dict):
51
+ continue
52
+ if block.get("type") != "tool_use":
53
+ continue
54
+ if block.get("name") != "Write":
55
+ continue
56
+
57
+ tool_input = block.get("input")
58
+ if not isinstance(tool_input, dict):
59
+ continue
60
+
61
+ fp = tool_input.get("file_path")
62
+ if not isinstance(fp, str) or not fp:
63
+ continue
64
+
65
+ if "/.claude/plans/" not in fp and not fp.startswith(".claude/plans/"):
66
+ continue
67
+
68
+ candidate = Path(fp)
69
+ if candidate.is_absolute():
70
+ try:
71
+ candidate = candidate.resolve().relative_to(cwd)
72
+ except Exception:
73
+ pass
74
+
75
+ latest = cwd / candidate
76
+ except Exception:
77
+ return latest
78
+
79
+ return latest
80
+
81
+
82
+ def _output_json(data: dict[str, Any]) -> None:
83
+ """Output hook result as JSON to stdout.
84
+
85
+ For non-SessionStart hooks, we return a small JSON payload for debugging,
86
+ but avoid any UI-facing `systemMessage`.
87
+ """
88
+ click.echo(json.dumps(data, indent=2))
89
+
90
+
91
+ def _output_result(result: HookResult) -> None:
92
+ """Output SessionStart hook result as JSON to stdout."""
93
+ _output_json(result.to_dict())
94
+
95
+
96
+ def _read_stdin_json() -> tuple[dict[str, Any] | None, str | None]:
97
+ """Read hook JSON payload from stdin.
98
+
99
+ Returns:
100
+ (parsed_dict, error)
101
+
102
+ - If input is empty/whitespace: (None, "empty")
103
+ - If JSON is invalid or not an object: (None, "invalid_json")
104
+ - If valid: (dict, None)
105
+ """
106
+
107
+ stdin_data = sys.stdin.read()
108
+ if not stdin_data.strip():
109
+ return None, "empty"
110
+
111
+ try:
112
+ parsed = json.loads(stdin_data)
113
+ except Exception:
114
+ return None, "invalid_json"
115
+
116
+ if not isinstance(parsed, dict):
117
+ return None, "invalid_json"
118
+
119
+ return parsed, None
120
+
121
+
122
+ def _print_session_tip(manifest: SessionState) -> None:
123
+ """No-op: SessionEnd hook output is suppressed by Claude Code.
124
+
125
+ See anthropics/claude-code#9090. The reconnect tip is printed from
126
+ the parent launcher process instead (_print_post_exit_tip in session.py).
127
+ Kept as a stub so the session-end hook doesn't break if called.
128
+ """
129
+
130
+
131
+ def _append_artifact_entry(
132
+ confirmed_artifacts: dict[str, Any],
133
+ *,
134
+ kind: str,
135
+ entry: dict[str, Any],
136
+ ) -> None:
137
+ """Append an artifact record under confirmed.artifacts in a stable shape."""
138
+
139
+ items = confirmed_artifacts.get(kind)
140
+ if items is None:
141
+ confirmed_artifacts[kind] = [entry]
142
+ return
143
+
144
+ if not isinstance(items, list):
145
+ # If the field was corrupted or mis-typed, clobber to a list.
146
+ confirmed_artifacts[kind] = [entry]
147
+ return
148
+
149
+ items.append(entry)