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,232 @@
1
+ """Hook enable/disable for Claude Code settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from forge.install.preset import get_builtin_preset
13
+ from forge.session.claude.paths import get_claude_home
14
+
15
+ SETTINGS_FILENAME = "settings.local.json"
16
+
17
+ # Single source of truth: derive from the canonical preset
18
+ FORGE_HOOK_CONFIG: dict[str, Any] = {"hooks": get_builtin_preset()["hooks"]}
19
+
20
+
21
+ def _find_hooks_target(scope: str | None) -> tuple[Path, str]:
22
+ """Find target settings file for hooks based on scope.
23
+
24
+ Args:
25
+ scope: "user", "local", or None (auto-detect)
26
+
27
+ Returns:
28
+ Tuple of (settings_file_path, display_location)
29
+
30
+ Raises:
31
+ click.ClickException if no valid target found
32
+ """
33
+ if scope == "user":
34
+ settings_dir = get_claude_home()
35
+ return settings_dir / SETTINGS_FILENAME, "~/.claude"
36
+
37
+ if scope == "local":
38
+ settings_dir = Path.cwd() / ".claude"
39
+ return settings_dir / SETTINGS_FILENAME, ".claude"
40
+
41
+ current = Path.cwd().resolve()
42
+ home = Path.home().resolve()
43
+
44
+ while True:
45
+ claude_dir = current / ".claude"
46
+ if claude_dir.is_dir():
47
+ if current == home:
48
+ return claude_dir / SETTINGS_FILENAME, "~/.claude"
49
+ # Use relpath to safely compute display path (works when .claude is above cwd)
50
+ display_path = os.path.relpath(claude_dir, Path.cwd())
51
+ return claude_dir / SETTINGS_FILENAME, display_path
52
+
53
+ if current == home:
54
+ # At home without finding .claude = use user scope
55
+ return (get_claude_home() / SETTINGS_FILENAME), "~/.claude"
56
+
57
+ parent = current.parent
58
+ if parent == current:
59
+ raise click.ClickException(
60
+ "No .claude directory found. " "Run from a Claude Code project, or use --user for global install."
61
+ )
62
+ current = parent
63
+
64
+
65
+ @click.command(name="enable")
66
+ @click.option(
67
+ "--user",
68
+ "-U",
69
+ "scope",
70
+ flag_value="user",
71
+ help="Enable for ~/.claude/settings.local.json",
72
+ )
73
+ @click.option(
74
+ "--local",
75
+ "-L",
76
+ "scope",
77
+ flag_value="local",
78
+ help="Enable for .claude/settings.local.json (current directory)",
79
+ )
80
+ @click.option(
81
+ "--force",
82
+ "-f",
83
+ is_flag=True,
84
+ help="Overwrite existing hook configuration",
85
+ )
86
+ def enable(scope: str | None, force: bool) -> None:
87
+ """Enable Forge hooks in Claude Code settings.
88
+
89
+ Adds all Forge hook configurations to settings.local.json.
90
+
91
+ \b
92
+ Scope Detection (when no --user/--local specified):
93
+ Walks up from current directory looking for a .claude/ directory.
94
+ - If found: enables in that project's .claude/settings.local.json
95
+ - If reached ~: enables in ~/.claude/settings.local.json
96
+ """
97
+ from forge.install.version import check_minimum_version
98
+
99
+ version_check = check_minimum_version()
100
+ if not version_check.ok:
101
+ click.echo(f"Error: {version_check.reason}", err=True)
102
+ click.echo("Tip: Run 'claude update' to upgrade.", err=True)
103
+ raise SystemExit(1)
104
+
105
+ settings_file, location = _find_hooks_target(scope)
106
+ if scope is None:
107
+ click.echo(f"Auto-detected: {location}")
108
+
109
+ settings: dict[str, Any] = {}
110
+ if settings_file.exists():
111
+ try:
112
+ settings = json.loads(settings_file.read_text(encoding="utf-8"))
113
+ except json.JSONDecodeError as e:
114
+ click.echo(f"Error: Invalid JSON in {settings_file}: {e}", err=True)
115
+ raise SystemExit(1)
116
+
117
+ existing_hooks = settings.get("hooks", {})
118
+ if any(key in existing_hooks for key in FORGE_HOOK_CONFIG["hooks"].keys()) and not force:
119
+ click.echo(f"Forge hooks already configured in {settings_file}")
120
+ click.echo("Tip: Use --force to overwrite")
121
+ raise SystemExit(1)
122
+
123
+ if "hooks" not in settings:
124
+ settings["hooks"] = {}
125
+
126
+ for key, value in FORGE_HOOK_CONFIG["hooks"].items():
127
+ settings["hooks"][key] = value
128
+
129
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
130
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
131
+
132
+ click.echo(f"Enabled Forge hooks in {location}/{SETTINGS_FILENAME}")
133
+
134
+
135
+ def _is_forge_hook_entry(entry: Any) -> bool:
136
+ """Check if a hook entry is a Forge hook.
137
+
138
+ Matches entries where:
139
+ - type == "command" AND command starts with "forge hook "
140
+ - OR nested hooks contain such an entry
141
+ """
142
+ if not isinstance(entry, dict):
143
+ return False
144
+
145
+ # Direct command format: {type: "command", command: "forge hook ..."}
146
+ if entry.get("type") == "command":
147
+ cmd = entry.get("command", "")
148
+ if isinstance(cmd, str) and cmd.strip().startswith("forge hook "):
149
+ return True
150
+
151
+ # Nested hooks format: {hooks: [{type: "command", command: "forge hook ..."}]}
152
+ hooks_list = entry.get("hooks")
153
+ if isinstance(hooks_list, list):
154
+ for h in hooks_list:
155
+ if isinstance(h, dict) and h.get("type") == "command":
156
+ nested_cmd = h.get("command", "")
157
+ if isinstance(nested_cmd, str) and nested_cmd.strip().startswith("forge hook "):
158
+ return True
159
+
160
+ return False
161
+
162
+
163
+ @click.command(name="disable")
164
+ @click.option(
165
+ "--user",
166
+ "-U",
167
+ "scope",
168
+ flag_value="user",
169
+ help="Disable from ~/.claude/settings.local.json",
170
+ )
171
+ @click.option(
172
+ "--local",
173
+ "-L",
174
+ "scope",
175
+ flag_value="local",
176
+ help="Disable from .claude/settings.local.json (current directory)",
177
+ )
178
+ def disable(scope: str | None) -> None:
179
+ """Disable Forge hooks in Claude Code settings.
180
+
181
+ Removes all Forge hook configuration entries from settings.local.json.
182
+
183
+ \b
184
+ Scope Detection (when no --user/--local specified):
185
+ Walks up from current directory looking for a .claude/ directory.
186
+ - If found: disables in that project's .claude/settings.local.json
187
+ - If reached ~: disables in ~/.claude/settings.local.json
188
+ """
189
+ settings_file, location = _find_hooks_target(scope)
190
+ if scope is None:
191
+ click.echo(f"Auto-detected: {location}")
192
+
193
+ if not settings_file.exists():
194
+ click.echo(f"No settings file found at {settings_file}")
195
+ return
196
+
197
+ try:
198
+ settings = json.loads(settings_file.read_text(encoding="utf-8"))
199
+ except json.JSONDecodeError as e:
200
+ click.echo(f"Error: Invalid JSON in {settings_file}: {e}", err=True)
201
+ raise SystemExit(1)
202
+
203
+ hooks_config = settings.get("hooks", {})
204
+ if not hooks_config:
205
+ click.echo("No hooks configured")
206
+ return
207
+
208
+ removed_any = False
209
+ for hook_name in list(hooks_config.keys()):
210
+ existing = hooks_config.get(hook_name, [])
211
+ if not isinstance(existing, list):
212
+ continue
213
+
214
+ remaining = [e for e in existing if not _is_forge_hook_entry(e)]
215
+ if len(remaining) != len(existing):
216
+ removed_any = True
217
+
218
+ if remaining:
219
+ settings["hooks"][hook_name] = remaining
220
+ else:
221
+ del settings["hooks"][hook_name]
222
+
223
+ if not removed_any:
224
+ click.echo("No Forge hooks found to disable")
225
+ return
226
+
227
+ if not settings.get("hooks"):
228
+ settings.pop("hooks", None)
229
+
230
+ settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
231
+
232
+ click.echo(f"Disabled Forge hooks in {location}/{SETTINGS_FILENAME}")
@@ -0,0 +1,151 @@
1
+ """Policy check helpers for the PreToolUse policy-check hook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from forge.core.state import now_iso
9
+ from forge.session import SessionStore
10
+ from forge.session.models import SessionState
11
+ from forge.session.store import HOOK_LOCK_TIMEOUT_S
12
+
13
+
14
+ def _build_action_context(
15
+ data: dict[str, Any],
16
+ tool_name: str,
17
+ manifest: Any,
18
+ ) -> Any | None:
19
+ """Build ActionContext from hook payload.
20
+
21
+ Args:
22
+ data: Hook JSON payload
23
+ tool_name: "Write" or "Edit"
24
+ manifest: Session manifest
25
+
26
+ Returns:
27
+ ActionContext or None if required fields missing
28
+ """
29
+ from forge.guard.types import ActionContext
30
+
31
+ tool_input = data.get("tool_input", {})
32
+ if not isinstance(tool_input, dict):
33
+ return None
34
+
35
+ target_path = tool_input.get("file_path") or tool_input.get("path")
36
+ if not isinstance(target_path, str):
37
+ target_path = None
38
+
39
+ cwd = Path.cwd().resolve()
40
+ if target_path:
41
+ try:
42
+ p = Path(target_path)
43
+ if p.is_absolute():
44
+ target_path = str(p.relative_to(cwd))
45
+ except (ValueError, RuntimeError):
46
+ # Keep as-is if can't make relative
47
+ pass
48
+
49
+ new_content = None
50
+ if tool_name == "Write":
51
+ new_content = tool_input.get("content")
52
+ elif tool_name == "Edit":
53
+ new_content = tool_input.get("new_string")
54
+
55
+ if new_content and len(new_content) > 5000:
56
+ new_content = new_content[:5000] + "\n... (truncated)"
57
+
58
+ return ActionContext(
59
+ event=f"PreToolUse.{tool_name}",
60
+ tool_name=tool_name,
61
+ tool_args=tool_input,
62
+ repo_root=str(cwd),
63
+ session_name=manifest.name,
64
+ target_path=target_path,
65
+ new_content=new_content,
66
+ )
67
+
68
+
69
+ def _persist_policy_state(
70
+ *,
71
+ store: SessionStore,
72
+ engine: Any,
73
+ result: Any,
74
+ effective: Any,
75
+ context_summary: str,
76
+ ) -> None:
77
+ """Persist policy state updates to session manifest.
78
+
79
+ Updates decision log and generic policy_states from stateful policies.
80
+ """
81
+ from forge.guard.store import build_policy_state_update
82
+ from forge.session.models import PolicyConfirmed
83
+
84
+ collected_state = engine.get_collected_state()
85
+
86
+ def _mutate(m: object) -> None:
87
+ if not isinstance(m, SessionState):
88
+ raise TypeError(f"Expected SessionState, got {type(m)}")
89
+
90
+ existing = None
91
+ if m.confirmed.policy:
92
+ existing = {
93
+ "decisions": m.confirmed.policy.decisions,
94
+ "policy_states": m.confirmed.policy.policy_states,
95
+ "forge_version": m.confirmed.policy.forge_version,
96
+ "bundles": m.confirmed.policy.bundles,
97
+ "rules_active": m.confirmed.policy.rules_active,
98
+ }
99
+
100
+ updated = build_policy_state_update(
101
+ result=result,
102
+ engine_state=collected_state,
103
+ existing_state=existing,
104
+ bundles=effective.policy.bundles if effective.policy else [],
105
+ rules_active=[p.policy_id for p in engine.policies],
106
+ context_summary=context_summary,
107
+ )
108
+
109
+ m.confirmed.policy = PolicyConfirmed(
110
+ forge_version=updated.get("forge_version"),
111
+ bundles=updated.get("bundles", []),
112
+ rules_active=updated.get("rules_active", []),
113
+ decisions=updated.get("decisions", []),
114
+ policy_states=updated.get("policy_states", {}),
115
+ )
116
+
117
+ m.confirmed.confirmed_at = now_iso()
118
+ m.confirmed.confirmed_by = "hook:policy-check"
119
+
120
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
121
+
122
+
123
+ def _derive_policy_source_label(result: Any, effective: Any) -> str:
124
+ """Derive source label from the most relevant policy for the outcome.
125
+
126
+ For deny: the first denying policy (the blocker).
127
+ For non-deny: prefer the supervisor if it participated (the expensive check),
128
+ fall back to the first matching deterministic policy.
129
+ """
130
+ sup = effective.policy.supervisor if effective.policy else None
131
+ sup_resume_id = sup.resume_id if sup else None
132
+
133
+ if result.final_decision == "deny":
134
+ for d in result.decisions:
135
+ if d.decision == "deny":
136
+ if d.policy_id == "semantic.supervisor" and sup_resume_id:
137
+ return f"'{sup_resume_id}'"
138
+ return d.policy_id
139
+ else:
140
+ # Non-deny: prefer supervisor if it evaluated
141
+ for d in result.decisions:
142
+ if d.policy_id == "semantic.supervisor":
143
+ if sup_resume_id:
144
+ return f"'{sup_resume_id}'"
145
+ return d.policy_id
146
+ # No supervisor — use first decision with matching outcome
147
+ for d in result.decisions:
148
+ if d.decision == result.final_decision:
149
+ return d.policy_id
150
+
151
+ return "policy"
@@ -0,0 +1,74 @@
1
+ """Read hygiene hook: strip extra params from skill instruction file reads.
2
+
3
+ Skill instruction files (code.md, docs.md, code-openai.md, docs-gemini.md, etc.)
4
+ have a strict "file_path only" Read contract defined in SKILL.md. Models often
5
+ add offset/limit/pages anyway. This hook silently fixes the call via updatedInput
6
+ rather than blocking it — zero token cost, deterministic correction.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _SKILL_RESOURCE_RE = re.compile(r"/skills/[^/]+/resources/")
19
+ _INSTRUCTION_BASENAME_RE = re.compile(r"^(code|docs)(-[a-z0-9_-]+)?\.md$")
20
+ _EXTRA_READ_PARAMS = {"offset", "limit", "pages"}
21
+
22
+
23
+ def _is_skill_instruction_file(file_path: str) -> bool:
24
+ """Check if a path is a skill instruction file with a strict Read contract.
25
+
26
+ Three checks, all must pass:
27
+ 1. Path contains /skills/<name>/resources/
28
+ 2. File is an immediate child of resources/ (parent dir is "resources")
29
+ 3. Basename matches {mode}.md or {mode}-{family}.md
30
+ """
31
+ if not _SKILL_RESOURCE_RE.search(file_path):
32
+ return False
33
+ p = Path(file_path)
34
+ if p.parent.name != "resources":
35
+ return False
36
+ return bool(_INSTRUCTION_BASENAME_RE.fullmatch(p.name))
37
+
38
+
39
+ def handle_read_hygiene(data: dict[str, Any]) -> dict[str, Any] | None:
40
+ """Process a PreToolUse:Read event and strip extra params if needed.
41
+
42
+ Returns the hookSpecificOutput dict to print, or None if no fix needed.
43
+ """
44
+ if data.get("hook_event_name") != "PreToolUse":
45
+ return None
46
+ if data.get("tool_name") != "Read":
47
+ return None
48
+
49
+ tool_input = data.get("tool_input")
50
+ if not isinstance(tool_input, dict):
51
+ return None
52
+
53
+ file_path = tool_input.get("file_path")
54
+ if not isinstance(file_path, str):
55
+ return None
56
+
57
+ if not _is_skill_instruction_file(file_path):
58
+ return None
59
+
60
+ extra_keys = set(tool_input.keys()) & _EXTRA_READ_PARAMS
61
+ if not extra_keys:
62
+ return None
63
+
64
+ logger.debug("read-hygiene: stripped %s from %s", sorted(extra_keys), Path(file_path).name)
65
+
66
+ return {
67
+ "hookSpecificOutput": {
68
+ "hookEventName": "PreToolUse",
69
+ "permissionDecision": "allow",
70
+ "updatedInput": {
71
+ "file_path": file_path,
72
+ },
73
+ }
74
+ }