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,1677 @@
1
+ """Hook command entry points invoked by Claude Code.
2
+
3
+ Each function is a Click command registered on the ``hooks`` group.
4
+ Heavy logic is delegated to submodules (verification, direct_commands, policy).
5
+
6
+ CRITICAL: Always exit 0 on errors — don't break Claude.
7
+ Exception: WorktreeCreate exits 1 on failure (replaces Claude's default git behavior).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import logging
14
+ import re
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any, Callable
19
+
20
+ import click
21
+
22
+ from forge.core.state import FileLockTimeoutError, now_iso
23
+ from forge.core.workqueue import (
24
+ enqueue_handoff_marker,
25
+ enqueue_index_marker,
26
+ enqueue_stop_marker,
27
+ )
28
+ from forge.session.artifacts import (
29
+ get_artifact_paths,
30
+ resolve_forge_root,
31
+ safe_copy_file,
32
+ snapshot_plan_approved,
33
+ )
34
+ from forge.session.effective import compute_effective_intent
35
+ from forge.session.hooks import (
36
+ HookResult,
37
+ handle_session_start,
38
+ parse_hook_input,
39
+ resolve_session_store,
40
+ )
41
+ from forge.session.store import HOOK_LOCK_TIMEOUT_S
42
+
43
+ from ._group import hooks
44
+ from ._helpers import (
45
+ _append_artifact_entry,
46
+ _find_latest_plan_from_transcript,
47
+ _output_json,
48
+ _output_result,
49
+ _read_stdin_json,
50
+ )
51
+ from .direct_commands import (
52
+ _handle_cmd_cancel_verification,
53
+ _handle_cmd_clean,
54
+ _handle_cmd_config,
55
+ _handle_cmd_guard,
56
+ _handle_cmd_help,
57
+ _handle_cmd_plan,
58
+ _handle_cmd_proxy,
59
+ _handle_cmd_session,
60
+ _parse_direct_command,
61
+ )
62
+ from .policy import (
63
+ _build_action_context,
64
+ _derive_policy_source_label,
65
+ _persist_policy_state,
66
+ )
67
+ from .verification import _run_verification_check
68
+
69
+ logger = logging.getLogger(__name__)
70
+
71
+
72
+ @hooks.command(name="session-start")
73
+ @click.option(
74
+ "--cwd",
75
+ "-C",
76
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
77
+ default=None,
78
+ help="Working directory (defaults to current directory)",
79
+ )
80
+ def session_start(cwd: Path | None) -> None:
81
+ """Handle SessionStart hook from Claude Code.
82
+
83
+ Reads JSON from stdin with session info, reconciles session state,
84
+ and outputs JSON result to stdout.
85
+
86
+ Expected stdin format:
87
+ {"session_id": "...", "transcript_path": "...", "source": "startup|resume|compact|clear"}
88
+
89
+ Always exits 0 to avoid breaking Claude. Errors are reported in JSON output.
90
+ """
91
+ if cwd is None:
92
+ cwd = Path.cwd()
93
+
94
+ data, err = _read_stdin_json()
95
+ if data is None:
96
+ message = "No input received on stdin" if err == "empty" else "Invalid JSON"
97
+ result = HookResult(
98
+ success=False,
99
+ error="invalid_input",
100
+ message=message,
101
+ )
102
+ _output_result(result)
103
+ return
104
+ logger.debug("session-start: session_id=%s", str(data.get("session_id", "?"))[:12])
105
+
106
+ hook_input = parse_hook_input(data)
107
+ if hook_input is None:
108
+ result = HookResult(
109
+ success=False,
110
+ error="invalid_input",
111
+ message="Missing or invalid required fields: session_id, transcript_path, source",
112
+ )
113
+ _output_result(result)
114
+ return
115
+
116
+ result = handle_session_start(hook_input, cwd)
117
+ _output_result(result)
118
+
119
+
120
+ @hooks.command(name="plan-write")
121
+ def plan_write() -> None:
122
+ """Record latest plan file path on PostToolUse:Write.
123
+
124
+ This hook runs frequently (every Write), so it must be cheap:
125
+ - If the file written is not a plan file, exit successfully with no-op.
126
+ - If it is a plan file, store `confirmed.latest_plan_path` in the manifest.
127
+
128
+ Expected stdin keys (best-effort; we tolerate extra fields):
129
+ - hook_event_name: "PostToolUse"
130
+ - tool_input.file_path: path written (may be absolute or worktree-relative)
131
+
132
+ Always exits 0.
133
+ """
134
+
135
+ data, err = _read_stdin_json()
136
+ if data is None:
137
+ message = "empty stdin" if err == "empty" else "invalid JSON"
138
+ _output_json({"success": False, "error": "invalid_input", "message": message})
139
+ return
140
+ logger.debug(
141
+ "plan-write: event=%s tool=%s",
142
+ data.get("hook_event_name"),
143
+ data.get("tool_name"),
144
+ )
145
+
146
+ if data.get("hook_event_name") != "PostToolUse":
147
+ _output_json({"success": True, "action": "skip", "reason": "wrong_event"})
148
+ return
149
+
150
+ tool_input = data.get("tool_input")
151
+ if not isinstance(tool_input, dict):
152
+ _output_json({"success": True, "action": "skip", "reason": "no_tool_input"})
153
+ return
154
+
155
+ # Some variants use file_path; keep a couple of fallbacks.
156
+ file_path = tool_input.get("file_path") or tool_input.get("path")
157
+ if not isinstance(file_path, str) or not file_path:
158
+ _output_json({"success": True, "action": "skip", "reason": "no_file_path"})
159
+ return
160
+
161
+ # Only record plan files
162
+ if "/.claude/plans/" not in file_path and not file_path.startswith(".claude/plans/"):
163
+ _output_json({"success": True, "action": "skip", "reason": "not_a_plan"})
164
+ return
165
+
166
+ cwd = Path.cwd().resolve()
167
+ store = resolve_session_store(cwd, session_id=data.get("session_id"))
168
+ if store is None:
169
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
170
+ return
171
+ try:
172
+ store.read()
173
+ except Exception as e:
174
+ _output_json({"success": False, "error": "manifest_read_failed", "message": str(e)})
175
+ return
176
+
177
+ # Normalize to worktree-relative if possible
178
+ plan_path = Path(file_path)
179
+ if plan_path.is_absolute():
180
+ try:
181
+ plan_path = plan_path.resolve().relative_to(cwd)
182
+ except Exception:
183
+ # If we can't make it relative, store as-is.
184
+ pass
185
+
186
+ try:
187
+
188
+ def _mutate(m: object) -> None:
189
+ # Type narrow via runtime checks.
190
+ from forge.session.models import SessionState
191
+
192
+ if not isinstance(m, SessionState):
193
+ raise TypeError(f"Expected SessionState, got {type(m)}")
194
+
195
+ m.confirmed.latest_plan_path = str(plan_path)
196
+ m.confirmed.confirmed_at = now_iso()
197
+ m.confirmed.confirmed_by = "hook:plan-write"
198
+
199
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
200
+ except FileLockTimeoutError:
201
+ _output_json({"success": True, "action": "skip_lock_contended"})
202
+ return
203
+ except Exception as e:
204
+ _output_json({"success": False, "error": "manifest_write_failed", "message": str(e)})
205
+ return
206
+
207
+ _output_json({"success": True, "action": "recorded", "latest_plan_path": str(plan_path)})
208
+
209
+
210
+ @hooks.command(name="exit-plan-mode")
211
+ def exit_plan_mode() -> None:
212
+ """Capture an approved plan snapshot on PreToolUse:ExitPlanMode.
213
+
214
+ This is treated as the plan approval boundary.
215
+
216
+ Expected stdin keys (best-effort):
217
+ - hook_event_name: "PreToolUse"
218
+ - transcript_path: used only as fallback to locate the most recent plan file
219
+
220
+ Always exits 0.
221
+ """
222
+
223
+ data, err = _read_stdin_json()
224
+ if data is None:
225
+ message = "empty stdin" if err == "empty" else "invalid JSON"
226
+ _output_json({"success": False, "error": "invalid_input", "message": message})
227
+ return
228
+ logger.debug(
229
+ "exit-plan-mode: event=%s tool=%s",
230
+ data.get("hook_event_name"),
231
+ data.get("tool_name"),
232
+ )
233
+
234
+ if data.get("hook_event_name") != "PreToolUse":
235
+ _output_json({"success": True, "action": "skip", "reason": "wrong_event"})
236
+ return
237
+
238
+ cwd = Path.cwd().resolve()
239
+ store = resolve_session_store(cwd, session_id=data.get("session_id"))
240
+ if store is None:
241
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
242
+ return
243
+ try:
244
+ manifest = store.read()
245
+ except Exception as e:
246
+ _output_json({"success": False, "error": "manifest_read_failed", "message": str(e)})
247
+ return
248
+
249
+ source_plan_path: Path | None = None
250
+ if manifest.confirmed.latest_plan_path:
251
+ source_plan_path = cwd / manifest.confirmed.latest_plan_path
252
+
253
+ # Fallback: scan transcript for last plan write (streaming; no full read).
254
+ if source_plan_path is None or not source_plan_path.is_file():
255
+ transcript_path = data.get("transcript_path")
256
+ if isinstance(transcript_path, str) and transcript_path:
257
+ source_plan_path = _find_latest_plan_from_transcript(transcript_path, cwd)
258
+
259
+ if source_plan_path is None or not source_plan_path.is_file():
260
+ _output_json({"success": False, "error": "plan_not_found"})
261
+ return
262
+
263
+ # Compute artifact roots
264
+ project_root = resolve_forge_root(cwd)
265
+ paths = get_artifact_paths(project_root, manifest.name)
266
+
267
+ try:
268
+ snapshot_abs, snapshot_rel = snapshot_plan_approved(
269
+ paths=paths,
270
+ source_plan_path=source_plan_path,
271
+ )
272
+ except Exception as e:
273
+ _output_json({"success": False, "error": "snapshot_failed", "message": str(e)})
274
+ return
275
+
276
+ source_plan_str = manifest.confirmed.latest_plan_path or str(source_plan_path)
277
+
278
+ try:
279
+
280
+ def _mutate(m: object) -> None:
281
+ from forge.session.models import SessionState
282
+
283
+ if not isinstance(m, SessionState):
284
+ raise TypeError(f"Expected SessionState, got {type(m)}")
285
+
286
+ artifacts = m.confirmed.artifacts
287
+
288
+ # Content-addressable snapshot_path makes re-approval of identical
289
+ # content a no-op on disk. Dedupe the audit entry too, but keep the
290
+ # most recently approved unique plan at the end of the list so
291
+ # readers that use "last approved snapshot wins" still surface the
292
+ # current approval after A->B->A.
293
+ existing = artifacts.get("plans")
294
+ new_snapshot_path = str(snapshot_rel)
295
+ if isinstance(existing, list):
296
+ artifacts["plans"] = [
297
+ entry
298
+ for entry in existing
299
+ if not (isinstance(entry, dict) and entry.get("snapshot_path") == new_snapshot_path)
300
+ ]
301
+
302
+ _append_artifact_entry(
303
+ artifacts,
304
+ kind="plans",
305
+ entry={
306
+ "kind": "approved",
307
+ "captured_at": now_iso(),
308
+ "source_path": source_plan_str,
309
+ "snapshot_path": str(snapshot_rel),
310
+ },
311
+ )
312
+
313
+ m.confirmed.confirmed_at = now_iso()
314
+ m.confirmed.confirmed_by = "hook:exit-plan-mode"
315
+
316
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
317
+
318
+ except FileLockTimeoutError:
319
+ _output_json({"success": True, "action": "skip_lock_contended"})
320
+ return
321
+
322
+ except Exception as e:
323
+ _output_json({"success": False, "error": "manifest_write_failed", "message": str(e)})
324
+ return
325
+
326
+ _output_json({"success": True, "action": "snapshotted", "snapshot_path": str(snapshot_rel)})
327
+
328
+
329
+ @hooks.command(name="stop")
330
+ def stop() -> None:
331
+ """Capture transcript copy on Stop, with optional verification policy.
332
+
333
+ Expected stdin keys (best-effort):
334
+ - hook_event_name: "Stop"
335
+ - transcript_path
336
+ - session_id
337
+
338
+ Exit codes:
339
+ - 0: Allow Stop (normal flow, or verification passed/disabled)
340
+ - 2: Block Stop (verification failed)
341
+ """
342
+
343
+ data, err = _read_stdin_json()
344
+ if data is None:
345
+ message = "empty stdin" if err == "empty" else "invalid JSON"
346
+ _output_json({"success": False, "error": "invalid_input", "message": message})
347
+ return
348
+ logger.debug("stop: session_id=%s", str(data.get("session_id", "?"))[:12])
349
+
350
+ if data.get("hook_event_name") != "Stop":
351
+ _output_json({"success": True, "action": "skip", "reason": "wrong_event"})
352
+ return
353
+
354
+ cwd = Path.cwd().resolve()
355
+ pending_transcript_path: Path | None = None
356
+ raw_transcript_path = data.get("transcript_path")
357
+ if isinstance(raw_transcript_path, str) and raw_transcript_path:
358
+ candidate = Path(raw_transcript_path)
359
+ pending_transcript_path = candidate if candidate.is_absolute() else (cwd / candidate)
360
+ incoming_session_id = data.get("session_id") if isinstance(data.get("session_id"), str) else None
361
+
362
+ store = resolve_session_store(cwd, session_id=data.get("session_id"))
363
+ if store is None:
364
+ if pending_transcript_path is not None:
365
+ _copy_transcript_to_pending_runs(pending_transcript_path, session_id=incoming_session_id)
366
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
367
+ return
368
+ try:
369
+ manifest = store.read()
370
+ except Exception as e:
371
+ _output_json({"success": False, "error": "manifest_read_failed", "message": str(e)})
372
+ return
373
+
374
+ transcript_path = data.get("transcript_path")
375
+ if not isinstance(transcript_path, str) or not transcript_path:
376
+ transcript_path = manifest.confirmed.transcript_path
377
+
378
+ if not transcript_path:
379
+ _output_json({"success": False, "error": "missing_transcript_path"})
380
+ return
381
+
382
+ session_id = data.get("session_id")
383
+ if not isinstance(session_id, str) or not session_id:
384
+ session_id = manifest.confirmed.claude_session_id
385
+
386
+ if not session_id:
387
+ _output_json({"success": False, "error": "missing_session_id"})
388
+ return
389
+
390
+ project_root = resolve_forge_root(cwd)
391
+ paths = get_artifact_paths(project_root, manifest.name)
392
+
393
+ src = Path(transcript_path)
394
+ dst_abs = paths.transcripts_abs / f"{session_id}.jsonl"
395
+ dst_rel = paths.transcripts_rel / f"{session_id}.jsonl"
396
+
397
+ try:
398
+ # Claude can invoke Stop repeatedly for the same session UUID as the
399
+ # transcript grows turn by turn. Refresh the UUID-named artifact so
400
+ # search/index consumers see the latest snapshot instead of the first
401
+ # turn that happened to be copied.
402
+ copied = safe_copy_file(src, dst_abs, overwrite=True)
403
+ except Exception as e:
404
+ _output_json({"success": False, "error": "copy_failed", "message": str(e)})
405
+ return
406
+
407
+ # Copy transcript to pending run directories (QA/walkthrough artifacts)
408
+ _copy_transcript_to_pending_runs(dst_abs, session_id=session_id)
409
+
410
+ # Track manifest update outcome (but don't return early - we still want to enqueue)
411
+ manifest_updated = True
412
+ manifest_error: str | None = None
413
+
414
+ try:
415
+
416
+ def _mutate(m: object) -> None:
417
+ from forge.session.models import SessionState
418
+
419
+ if not isinstance(m, SessionState):
420
+ raise TypeError(f"Expected SessionState, got {type(m)}")
421
+
422
+ artifacts = m.confirmed.artifacts
423
+ _append_artifact_entry(
424
+ artifacts,
425
+ kind="transcripts",
426
+ entry={
427
+ "captured_at": now_iso(),
428
+ "reason": "stop",
429
+ "source_path": transcript_path,
430
+ "session_id": session_id,
431
+ "copied_path": str(dst_rel),
432
+ "copied": copied,
433
+ },
434
+ )
435
+
436
+ # Stop carries the live conversation identity. SessionStart can
437
+ # lag or report an inherited UUID for native fork launches, so
438
+ # reconcile from Stop before later cleanup/resume code reads it.
439
+ m.confirmed.claude_session_id = session_id
440
+ m.confirmed.transcript_path = transcript_path
441
+
442
+ # Record policy provenance (always on Stop, per design §4.1.6)
443
+ from forge import __version__
444
+ from forge.session.models import PolicyConfirmed
445
+
446
+ if m.confirmed.policy:
447
+ m.confirmed.policy.forge_version = __version__
448
+ else:
449
+ m.confirmed.policy = PolicyConfirmed(forge_version=__version__)
450
+
451
+ m.confirmed.confirmed_at = now_iso()
452
+ m.confirmed.confirmed_by = "hook:stop"
453
+
454
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
455
+ try:
456
+ from forge.session.index import IndexStore
457
+
458
+ IndexStore().update_uuid(manifest.name, session_id, forge_root=str(store.forge_root))
459
+ except Exception:
460
+ logger.debug("Stop hook: index UUID sync failed", exc_info=True)
461
+
462
+ except FileLockTimeoutError:
463
+ manifest_updated = False
464
+ manifest_error = "lock_contended"
465
+
466
+ except Exception as e:
467
+ manifest_updated = False
468
+ manifest_error = str(e)
469
+
470
+ # Run verification check (Ralph-Wiggum pattern)
471
+ # This must happen AFTER transcript copy so we have the artifact to check
472
+ # Re-read manifest to get latest state (may have been updated by artifact write)
473
+ try:
474
+ manifest = store.read()
475
+ except Exception:
476
+ pass # Use stale manifest for verification - better than skipping
477
+
478
+ should_allow_stop, block_message = _run_verification_check(
479
+ store=store,
480
+ manifest=manifest,
481
+ transcript_path=dst_abs, # Check the copied artifact, not the original
482
+ )
483
+
484
+ if not should_allow_stop:
485
+ # Verification failed - block Stop
486
+ # Do NOT enqueue pending-work since we're blocking
487
+ click.echo(block_message, err=True)
488
+ sys.exit(2)
489
+
490
+ # Enqueue pending-work markers for deferred processing (best-effort)
491
+ # Important: enqueue even if manifest update failed - the transcript artifact
492
+ # exists on disk and deferred work should still be triggered.
493
+ # Only enqueue if verification passed (we reach here only if should_allow_stop=True)
494
+ effective_forge_root = str(store.forge_root) if store else None
495
+ queued_stop = (
496
+ enqueue_stop_marker(
497
+ session_id=session_id,
498
+ worktree_path=cwd,
499
+ session_name=manifest.name,
500
+ transcript_snapshot_rel=str(dst_rel),
501
+ forge_root=effective_forge_root,
502
+ )
503
+ is not None
504
+ )
505
+ queued_index = (
506
+ enqueue_index_marker(
507
+ session_id=session_id,
508
+ worktree_path=cwd,
509
+ session_name=manifest.name,
510
+ transcript_snapshot_rel=str(dst_rel),
511
+ forge_root=effective_forge_root,
512
+ )
513
+ is not None
514
+ )
515
+
516
+ # Enqueue handoff marker if auto_update is enabled (best-effort)
517
+ queued_handoff = False
518
+ try:
519
+ effective = compute_effective_intent(manifest)
520
+ if effective.memory and effective.memory.auto_update and effective.memory.auto_update.enabled:
521
+ queued_handoff = (
522
+ enqueue_handoff_marker(
523
+ session_id=session_id,
524
+ worktree_path=cwd,
525
+ session_name=manifest.name,
526
+ transcript_snapshot_rel=str(dst_rel),
527
+ subprocess_proxy=effective.subprocess_proxy,
528
+ forge_root=effective_forge_root,
529
+ )
530
+ is not None
531
+ )
532
+ except Exception:
533
+ pass # Best-effort: don't break stop hook on handoff enqueue failure
534
+
535
+ if not manifest_updated:
536
+ # Manifest failed but we still tried to enqueue
537
+ _output_json(
538
+ {
539
+ "success": True,
540
+ "action": "partial",
541
+ "copied_path": str(dst_rel),
542
+ "copied": copied,
543
+ "manifest_updated": False,
544
+ "manifest_error": manifest_error,
545
+ "queued": queued_stop,
546
+ "queued_index": queued_index,
547
+ "queued_handoff": queued_handoff,
548
+ }
549
+ )
550
+ else:
551
+ _output_json(
552
+ {
553
+ "success": True,
554
+ "action": "copied",
555
+ "copied_path": str(dst_rel),
556
+ "copied": copied,
557
+ "queued": queued_stop,
558
+ "queued_index": queued_index,
559
+ "queued_handoff": queued_handoff,
560
+ }
561
+ )
562
+
563
+
564
+ @hooks.command(name="stop-failure")
565
+ def stop_failure() -> None:
566
+ """Best-effort transcript capture on StopFailure.
567
+
568
+ When Claude's stop fails (crash, timeout, etc.), this hook fires as a
569
+ last-chance opportunity to capture the transcript and enqueue work markers.
570
+ No verification is performed (the session is already in a failed state).
571
+
572
+ Expected stdin keys (best-effort):
573
+ - hook_event_name: "StopFailure"
574
+ - transcript_path
575
+ - session_id
576
+
577
+ Always exits 0 (fail-open).
578
+ """
579
+ data, err = _read_stdin_json()
580
+ if data is None:
581
+ message = "empty stdin" if err == "empty" else "invalid JSON"
582
+ _output_json({"success": False, "error": "invalid_input", "message": message})
583
+ return
584
+ logger.debug("stop-failure: session_id=%s", str(data.get("session_id", "?"))[:12])
585
+
586
+ if data.get("hook_event_name") != "StopFailure":
587
+ _output_json({"success": True, "action": "skip", "reason": "wrong_event"})
588
+ return
589
+
590
+ cwd = Path.cwd().resolve()
591
+
592
+ # Best-effort transcript copy to pending runs even without a session
593
+ pending_transcript_path: Path | None = None
594
+ raw_transcript_path = data.get("transcript_path")
595
+ if isinstance(raw_transcript_path, str) and raw_transcript_path:
596
+ candidate = Path(raw_transcript_path)
597
+ pending_transcript_path = candidate if candidate.is_absolute() else (cwd / candidate)
598
+ incoming_session_id = data.get("session_id") if isinstance(data.get("session_id"), str) else None
599
+
600
+ store = resolve_session_store(cwd, session_id=data.get("session_id"))
601
+ if store is None:
602
+ if pending_transcript_path is not None:
603
+ _copy_transcript_to_pending_runs(pending_transcript_path, session_id=incoming_session_id)
604
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
605
+ return
606
+ try:
607
+ manifest = store.read()
608
+ except Exception as e:
609
+ _output_json({"success": False, "error": "manifest_read_failed", "message": str(e)})
610
+ return
611
+
612
+ transcript_path = data.get("transcript_path")
613
+ if not isinstance(transcript_path, str) or not transcript_path:
614
+ transcript_path = manifest.confirmed.transcript_path
615
+ if not transcript_path:
616
+ _output_json({"success": True, "action": "skip", "reason": "no_transcript_path"})
617
+ return
618
+
619
+ session_id = data.get("session_id")
620
+ if not isinstance(session_id, str) or not session_id:
621
+ session_id = manifest.confirmed.claude_session_id
622
+ if not session_id:
623
+ _output_json({"success": True, "action": "skip", "reason": "no_session_id"})
624
+ return
625
+
626
+ project_root = resolve_forge_root(cwd)
627
+ paths = get_artifact_paths(project_root, manifest.name)
628
+
629
+ src = Path(transcript_path)
630
+ dst_abs = paths.transcripts_abs / f"{session_id}.jsonl"
631
+ dst_rel = paths.transcripts_rel / f"{session_id}.jsonl"
632
+
633
+ try:
634
+ # Keep the session artifact aligned with the latest transcript snapshot
635
+ # here too; StopFailure can arrive after earlier Stop captures.
636
+ copied = safe_copy_file(src, dst_abs, overwrite=True)
637
+ except Exception:
638
+ copied = False
639
+
640
+ # Only fan out and enqueue if the artifact actually exists on disk.
641
+ # Otherwise we'd consume pending-transcript markers and create index
642
+ # markers that retry until poison handling kicks in.
643
+ if dst_abs.is_file():
644
+ _copy_transcript_to_pending_runs(dst_abs, session_id=session_id)
645
+
646
+ # Best-effort manifest update
647
+ try:
648
+
649
+ def _mutate(m: object) -> None:
650
+ from forge.session.models import SessionState
651
+
652
+ if not isinstance(m, SessionState):
653
+ raise TypeError(f"Expected SessionState, got {type(m)}")
654
+
655
+ artifacts = m.confirmed.artifacts
656
+ _append_artifact_entry(
657
+ artifacts,
658
+ kind="transcripts",
659
+ entry={
660
+ "captured_at": now_iso(),
661
+ "reason": "stop-failure",
662
+ "source_path": transcript_path,
663
+ "session_id": session_id,
664
+ "copied_path": str(dst_rel),
665
+ "copied": copied,
666
+ },
667
+ )
668
+
669
+ # StopFailure is a last-chance transcript capture. If it carries a
670
+ # newer Claude session ID than SessionStart recorded, reconcile the
671
+ # manifest the same way Stop does.
672
+ m.confirmed.claude_session_id = session_id
673
+ m.confirmed.transcript_path = transcript_path
674
+
675
+ from forge import __version__
676
+ from forge.session.models import PolicyConfirmed
677
+
678
+ if m.confirmed.policy:
679
+ m.confirmed.policy.forge_version = __version__
680
+ else:
681
+ m.confirmed.policy = PolicyConfirmed(forge_version=__version__)
682
+
683
+ m.confirmed.confirmed_at = now_iso()
684
+ m.confirmed.confirmed_by = "hook:stop-failure"
685
+
686
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
687
+ try:
688
+ from forge.session.index import IndexStore
689
+
690
+ IndexStore().update_uuid(manifest.name, session_id, forge_root=str(store.forge_root))
691
+ except Exception:
692
+ logger.debug("StopFailure hook: index UUID sync failed", exc_info=True)
693
+ except Exception:
694
+ pass # Best-effort: never fail on StopFailure
695
+
696
+ # Only enqueue work markers if the artifact exists on disk.
697
+ # Enqueuing for a nonexistent artifact wastes retries until poison handling.
698
+ queued_stop = False
699
+ queued_index = False
700
+ effective_forge_root = str(store.forge_root) if store else None
701
+ if dst_abs.is_file():
702
+ queued_stop = (
703
+ enqueue_stop_marker(
704
+ session_id=session_id,
705
+ worktree_path=cwd,
706
+ session_name=manifest.name,
707
+ transcript_snapshot_rel=str(dst_rel),
708
+ forge_root=effective_forge_root,
709
+ )
710
+ is not None
711
+ )
712
+ queued_index = (
713
+ enqueue_index_marker(
714
+ session_id=session_id,
715
+ worktree_path=cwd,
716
+ session_name=manifest.name,
717
+ transcript_snapshot_rel=str(dst_rel),
718
+ forge_root=effective_forge_root,
719
+ )
720
+ is not None
721
+ )
722
+
723
+ _output_json(
724
+ {
725
+ "success": True,
726
+ "action": "copied" if copied else "attempted",
727
+ "copied_path": str(dst_rel),
728
+ "copied": copied,
729
+ "queued": queued_stop,
730
+ "queued_index": queued_index,
731
+ }
732
+ )
733
+
734
+
735
+ @hooks.command(name="session-end")
736
+ def session_end() -> None:
737
+ """No-op SessionEnd hook (placeholder).
738
+
739
+ Claude Code suppresses SessionEnd hook stderr output
740
+ (anthropics/claude-code#9090). The reconnect tip is printed from
741
+ the parent launcher process instead. This command is kept so the
742
+ hook config doesn't error on missing subcommand.
743
+ """
744
+
745
+
746
+ @hooks.command(name="pre-compact")
747
+ def pre_compact() -> None:
748
+ """Capture full transcript before compaction.
749
+
750
+ Fires BEFORE compaction when the uncompacted transcript is still available.
751
+ This is the canonical compaction snapshot — SessionStart rollover serves as
752
+ fallback for /clear events and defense-in-depth.
753
+
754
+ Always exits 0 (never blocks compaction). CLAUDE_CODE_AUTO_COMPACT_WINDOW
755
+ handles compaction window sizing in proxy mode.
756
+ """
757
+ data, err = _read_stdin_json()
758
+ if data is None:
759
+ sys.exit(0)
760
+ logger.debug("pre-compact: hook_event_name=%s", data.get("hook_event_name"))
761
+
762
+ transcript_path = data.get("transcript_path")
763
+ session_id = data.get("session_id")
764
+ cwd_str = data.get("cwd", "")
765
+ cwd = Path(cwd_str) if cwd_str else Path.cwd()
766
+
767
+ if not transcript_path or not session_id:
768
+ sys.exit(0)
769
+
770
+ try:
771
+ store = resolve_session_store(cwd, session_id=session_id)
772
+ if store is None or not store.exists():
773
+ sys.exit(0)
774
+
775
+ manifest = store.read()
776
+ if manifest is None:
777
+ sys.exit(0)
778
+
779
+ project_root = resolve_forge_root(cwd)
780
+ paths = get_artifact_paths(project_root, manifest.name)
781
+
782
+ src = Path(transcript_path)
783
+ timestamp = now_iso().replace(":", "-")
784
+ snapshot_name = f"{session_id}_pre-compact_{timestamp}.jsonl"
785
+ dst_abs = paths.transcripts_abs / snapshot_name
786
+ dst_rel = paths.transcripts_rel / snapshot_name
787
+
788
+ copied = safe_copy_file(src, dst_abs, overwrite=False)
789
+
790
+ from forge.session.models import CompactionConfirmed, SessionState
791
+
792
+ def _mutate(m: object) -> None:
793
+ if not isinstance(m, SessionState):
794
+ raise TypeError(f"Expected SessionState, got {type(m)}")
795
+
796
+ if m.confirmed.compaction is None:
797
+ m.confirmed.compaction = CompactionConfirmed()
798
+
799
+ m.confirmed.compaction.compact_count += 1
800
+
801
+ _append_artifact_entry(
802
+ m.confirmed.artifacts,
803
+ kind="transcripts",
804
+ entry={
805
+ "captured_at": now_iso(),
806
+ "reason": "pre-compact",
807
+ "source_path": transcript_path,
808
+ "snapshot_path": str(dst_rel),
809
+ "copied": copied,
810
+ },
811
+ )
812
+ m.confirmed.compaction.transcript_snapshots.append(
813
+ {
814
+ "captured_at": now_iso(),
815
+ "reason": "pre-compact",
816
+ "source_path": transcript_path,
817
+ "snapshot_path": str(dst_rel),
818
+ "copied": copied,
819
+ }
820
+ )
821
+ m.confirmed.confirmed_at = now_iso()
822
+ m.confirmed.confirmed_by = "hook:pre-compact"
823
+
824
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
825
+ logger.debug("pre-compact: transcript snapshot captured at %s", dst_rel)
826
+ except Exception as e:
827
+ # Fail-open: never block compaction
828
+ logger.debug("pre-compact: snapshot failed: %s", e)
829
+
830
+ sys.exit(0)
831
+
832
+
833
+ @hooks.command(name="post-compact")
834
+ def post_compact() -> None:
835
+ """Record compaction event in session confirmed state.
836
+
837
+ Fires AFTER compaction. Updates last_compact_at and last_compact_type.
838
+ compact_count is incremented by PreCompact (before compaction) so this
839
+ hook only records the completion timestamp.
840
+
841
+ Side-effect only -- cannot block compaction.
842
+ """
843
+ data, err = _read_stdin_json()
844
+ if data is None:
845
+ sys.exit(0)
846
+ logger.debug("post-compact: hook_event_name=%s", data.get("hook_event_name"))
847
+
848
+ session_id = data.get("session_id")
849
+ cwd_str = data.get("cwd", "")
850
+ cwd = Path(cwd_str) if cwd_str else Path.cwd()
851
+ # PostCompact supports the same matcher values as PreCompact: "auto" | "manual"
852
+ compact_trigger = data.get("trigger", "unknown")
853
+
854
+ if not session_id:
855
+ sys.exit(0)
856
+
857
+ store = resolve_session_store(cwd, session_id=session_id)
858
+ if store is None or not store.exists():
859
+ sys.exit(0)
860
+
861
+ try:
862
+ from forge.session.models import CompactionConfirmed, SessionState
863
+
864
+ def _mutate(m: object) -> None:
865
+ if not isinstance(m, SessionState):
866
+ raise TypeError(f"Expected SessionState, got {type(m)}")
867
+
868
+ if m.confirmed.compaction is None:
869
+ m.confirmed.compaction = CompactionConfirmed()
870
+
871
+ m.confirmed.compaction.last_compact_at = now_iso()
872
+ m.confirmed.compaction.last_compact_type = compact_trigger
873
+ m.confirmed.confirmed_at = now_iso()
874
+ m.confirmed.confirmed_by = "hook:post-compact"
875
+
876
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
877
+ logger.debug("post-compact: compaction metadata recorded (trigger=%s)", compact_trigger)
878
+ except Exception as e:
879
+ logger.debug("post-compact: state update failed: %s", e)
880
+
881
+ sys.exit(0)
882
+
883
+
884
+ @hooks.command(name="worktree-create")
885
+ def worktree_create() -> None:
886
+ """Create worktree with Forge extensions auto-installed.
887
+
888
+ REPLACES Claude Code's default worktree creation and .worktreeinclude
889
+ handling. Prints absolute worktree path to stdout on success.
890
+ Non-zero exit fails worktree creation.
891
+
892
+ stdout contract: ONLY the absolute worktree path. All debug goes to stderr.
893
+ """
894
+ data, err = _read_stdin_json()
895
+ if data is None:
896
+ # Can't parse input — let Claude Code's default fail gracefully
897
+ sys.exit(1)
898
+
899
+ cwd_str = data.get("cwd", "")
900
+ cwd = Path(cwd_str) if cwd_str else Path.cwd()
901
+
902
+ # Claude Code may provide a name slug for the worktree (used by both
903
+ # --worktree and isolation: "worktree" for subagents). Each request
904
+ # must produce a distinct checkout — do NOT collapse onto session_id.
905
+ hook_name = data.get("name", "")
906
+
907
+ try:
908
+ import subprocess
909
+ import uuid as _uuid
910
+
911
+ from forge.session.worktree.create import (
912
+ find_git_binary,
913
+ get_main_repo_root,
914
+ resolve_worktree_path,
915
+ )
916
+
917
+ # Use main-repo root to avoid child-worktree resolution bugs
918
+ repo_root = get_main_repo_root(cwd)
919
+ git = find_git_binary()
920
+
921
+ # Prefer hook-provided name; fall back to unique name per request
922
+ if hook_name:
923
+ wt_name = hook_name
924
+ else:
925
+ short_uuid = _uuid.uuid4().hex[:8]
926
+ wt_name = f"wt-{short_uuid}"
927
+ branch_name = f"forge/{wt_name}"
928
+
929
+ worktree_path = resolve_worktree_path(repo_root, wt_name)
930
+
931
+ result = subprocess.run(
932
+ [git, "worktree", "add", str(worktree_path), "-b", branch_name],
933
+ cwd=str(repo_root),
934
+ capture_output=True,
935
+ text=True,
936
+ )
937
+
938
+ if result.returncode != 0:
939
+ # Fallback: try without -b (use detached HEAD)
940
+ logger.debug("worktree-create: branched creation failed: %s", result.stderr.strip())
941
+ result = subprocess.run(
942
+ [git, "worktree", "add", str(worktree_path)],
943
+ cwd=str(repo_root),
944
+ capture_output=True,
945
+ text=True,
946
+ )
947
+ if result.returncode != 0:
948
+ logger.debug("worktree-create: fallback also failed: %s", result.stderr.strip())
949
+ sys.exit(1)
950
+
951
+ # Best-effort: copy runtime config (.env, .claude/settings.local.json,
952
+ # etc.) before installing extensions so the installer merges on top
953
+ # of existing user settings rather than creating a fresh file.
954
+ # Use the current checkout root (not main repo) so child worktrees
955
+ # inherit config from the checkout the user is actually in.
956
+ try:
957
+ from forge.session.worktree.config_copy import copy_runtime_config
958
+ from forge.session.worktree.create import get_repo_root
959
+
960
+ source_root = get_repo_root(cwd)
961
+ copy_runtime_config(source_root, worktree_path)
962
+ logger.debug("worktree-create: runtime config copied to %s", worktree_path)
963
+ except Exception as cfg_err:
964
+ logger.debug("worktree-create: config copy failed: %s", cfg_err)
965
+
966
+ # Best-effort: install Forge extensions in the new worktree.
967
+ # Suppress stdout to protect the path-only stdout contract.
968
+ try:
969
+ import contextlib
970
+ import os as _os
971
+
972
+ from forge.install.installer import Installer
973
+ from forge.install.models import InstallMode, InstallProfile, InstallScope
974
+
975
+ with open(_os.devnull, "w") as devnull, contextlib.redirect_stdout(devnull):
976
+ installer = Installer(
977
+ scope=InstallScope.LOCAL,
978
+ project_root=worktree_path,
979
+ )
980
+ installer.init(
981
+ profile=InstallProfile.STANDARD,
982
+ mode=InstallMode.COPY,
983
+ )
984
+ logger.debug("worktree-create: extensions installed in %s", worktree_path)
985
+ except Exception as ext_err:
986
+ # Non-fatal: worktree works without Forge extensions
987
+ logger.debug("worktree-create: extension install failed: %s", ext_err)
988
+
989
+ # stdout contract: absolute path only
990
+ print(str(worktree_path.resolve()))
991
+ sys.exit(0)
992
+
993
+ except Exception as e:
994
+ logger.debug("worktree-create: failed: %s", e)
995
+ sys.exit(1)
996
+
997
+
998
+ @hooks.command(name="subagent-stop")
999
+ def subagent_stop() -> None:
1000
+ """Track subagent completion in session confirmed state.
1001
+
1002
+ Records agent type, count, transcript path, and message preview.
1003
+ Observe-only -- always exits 0. Blocking support preserved
1004
+ for future policy enforcement via exit 2.
1005
+
1006
+ Expected stdin keys:
1007
+ - session_id, cwd, agent_id, agent_type
1008
+ - agent_transcript_path, last_assistant_message
1009
+ """
1010
+ data, err = _read_stdin_json()
1011
+ if data is None:
1012
+ sys.exit(0)
1013
+ logger.debug("subagent-stop: agent_type=%s", data.get("agent_type"))
1014
+
1015
+ session_id = data.get("session_id")
1016
+ cwd_str = data.get("cwd", "")
1017
+ cwd = Path(cwd_str) if cwd_str else Path.cwd()
1018
+
1019
+ if not session_id:
1020
+ sys.exit(0)
1021
+
1022
+ store = resolve_session_store(cwd, session_id=session_id)
1023
+ if store is None or not store.exists():
1024
+ sys.exit(0)
1025
+
1026
+ agent_id = data.get("agent_id")
1027
+ agent_type = data.get("agent_type", "unknown")
1028
+ agent_transcript_path = data.get("agent_transcript_path")
1029
+ last_message = data.get("last_assistant_message")
1030
+
1031
+ try:
1032
+ from forge.session.models import SessionState, SubagentConfirmed
1033
+
1034
+ def _mutate(m: object) -> None:
1035
+ if not isinstance(m, SessionState):
1036
+ raise TypeError(f"Expected SessionState, got {type(m)}")
1037
+
1038
+ if m.confirmed.subagents is None:
1039
+ m.confirmed.subagents = SubagentConfirmed()
1040
+
1041
+ sa = m.confirmed.subagents
1042
+ sa.total_count += 1
1043
+ sa.by_type[agent_type] = sa.by_type.get(agent_type, 0) + 1
1044
+ sa.last_agent_id = agent_id
1045
+ sa.last_agent_type = agent_type
1046
+ sa.last_stop_at = now_iso()
1047
+ sa.last_transcript_path = agent_transcript_path
1048
+ sa.last_message_preview = last_message[:200] if last_message else None
1049
+ m.confirmed.confirmed_at = now_iso()
1050
+ m.confirmed.confirmed_by = "hook:subagent-stop"
1051
+
1052
+ store.update(timeout_s=HOOK_LOCK_TIMEOUT_S, mutate=_mutate)
1053
+ logger.debug("subagent-stop: recorded agent_type=%s total=%s", agent_type, "ok")
1054
+ except Exception as e:
1055
+ logger.debug("subagent-stop: state update failed: %s", e)
1056
+
1057
+ sys.exit(0)
1058
+
1059
+
1060
+ @hooks.command(name="policy-check")
1061
+ def policy_check() -> None:
1062
+ """Evaluate policies on PreToolUse:Write/Edit.
1063
+
1064
+ This hook enforces policy rules at tool invocation boundaries.
1065
+ Deterministic policies (TDD, coding standards) run synchronously.
1066
+ Semantic policies (supervisor) may invoke an LLM.
1067
+
1068
+ Exit codes:
1069
+ - 0: Allow (continue with tool use)
1070
+ - 2: Block (display stderr message to user, abort tool use)
1071
+
1072
+ Always defaults to fail-open on internal errors.
1073
+ """
1074
+ data, err = _read_stdin_json()
1075
+ if data is None:
1076
+ # No input or parse error = allow (fail-open)
1077
+ sys.exit(0)
1078
+ logger.debug(
1079
+ "policy-check: event=%s tool=%s session=%s",
1080
+ data.get("hook_event_name"),
1081
+ data.get("tool_name"),
1082
+ str(data.get("session_id", "?"))[:12],
1083
+ )
1084
+
1085
+ if data.get("hook_event_name") != "PreToolUse":
1086
+ sys.exit(0)
1087
+
1088
+ tool_name = data.get("tool_name", "")
1089
+ if tool_name not in ("Write", "Edit"):
1090
+ sys.exit(0)
1091
+
1092
+ cwd = Path.cwd().resolve()
1093
+ store = resolve_session_store(cwd, session_id=data.get("session_id"))
1094
+ if store is None:
1095
+ _output_json({"success": True, "action": "skip", "reason": "no_session"})
1096
+ return
1097
+ try:
1098
+ manifest = store.read()
1099
+ except Exception as e:
1100
+ print(f"[forge] Policy check: cannot read session manifest: {e}", file=sys.stderr)
1101
+ sys.exit(0)
1102
+
1103
+ try:
1104
+ effective = compute_effective_intent(manifest)
1105
+ except Exception as e:
1106
+ print(
1107
+ f"[forge] Policy check: cannot compute effective intent: {e}",
1108
+ file=sys.stderr,
1109
+ )
1110
+ sys.exit(0)
1111
+
1112
+ if not effective.policy or not effective.policy.enabled:
1113
+ sys.exit(0)
1114
+
1115
+ context = _build_action_context(data, tool_name, manifest)
1116
+ if context is None:
1117
+ print("[forge] Policy check: cannot build action context", file=sys.stderr)
1118
+ sys.exit(0)
1119
+
1120
+ from forge.guard.engine import build_engine
1121
+ from forge.guard.types import FailMode
1122
+
1123
+ fail_mode: FailMode = effective.policy.fail_mode or "open"
1124
+ bundles = effective.policy.bundles or []
1125
+ sup = effective.policy.supervisor if effective.policy else None
1126
+ has_supervisor = bool(sup and sup.resume_id and not sup.suspended)
1127
+
1128
+ if not bundles and not has_supervisor:
1129
+ sys.exit(0)
1130
+
1131
+ bundle_config: dict[str, dict[str, Any]] = {}
1132
+ if effective.policy and effective.policy.bundle_config:
1133
+ bundle_config = effective.policy.bundle_config
1134
+
1135
+ try:
1136
+ engine = build_engine(bundles, fail_mode=fail_mode, bundle_config=bundle_config or None)
1137
+ except Exception as e:
1138
+ print(f"[forge] Policy check: cannot build engine: {e}", file=sys.stderr)
1139
+ sys.exit(0)
1140
+
1141
+ # Register semantic supervisor before restore_state so cached state is restored with it.
1142
+ if has_supervisor:
1143
+ from forge.guard.semantic.supervisor import SemanticSupervisorPolicy
1144
+
1145
+ supervisor_policy = SemanticSupervisorPolicy(config=effective.policy.supervisor)
1146
+ engine.register(supervisor_policy)
1147
+
1148
+ existing_policy_state = None
1149
+ if manifest.confirmed.policy:
1150
+ existing_policy_state = manifest.confirmed.policy.policy_states
1151
+ engine.restore_state(existing_policy_state)
1152
+
1153
+ import time
1154
+
1155
+ t0 = time.monotonic()
1156
+ try:
1157
+ result = engine.evaluate(context)
1158
+ except Exception as e:
1159
+ if fail_mode == "closed":
1160
+ print(f"Policy evaluation failed (fail-closed): {e}", file=sys.stderr)
1161
+ sys.exit(2)
1162
+ # fail-open: allow on evaluation error
1163
+ sys.exit(0)
1164
+ elapsed = time.monotonic() - t0
1165
+
1166
+ target_label = f"{tool_name}:{context.target_path}" if context.target_path else tool_name
1167
+
1168
+ try:
1169
+ _persist_policy_state(
1170
+ store=store,
1171
+ engine=engine,
1172
+ result=result,
1173
+ effective=effective,
1174
+ context_summary=target_label,
1175
+ )
1176
+ except Exception as e:
1177
+ print(f"[forge] Policy state persistence failed: {e}", file=sys.stderr)
1178
+
1179
+ from forge.runtime_config import get_runtime_config
1180
+
1181
+ show_summary = get_runtime_config().policy_summary_feedback == "on"
1182
+ is_cached = any(getattr(d, "cached", False) for d in result.decisions)
1183
+ cache_label = ", cached" if is_cached else ""
1184
+ source_label = _derive_policy_source_label(result, effective)
1185
+
1186
+ if result.final_decision == "deny":
1187
+ lines = ["Policy violation(s):"]
1188
+ for d in result.decisions:
1189
+ if d.decision != "deny":
1190
+ continue
1191
+ for i, v in enumerate(d.violations):
1192
+ lines.append(f" [{v.rule_id}] {v.message}")
1193
+ if d.intent and i == 0:
1194
+ lines.append(f" Intent: {d.intent}")
1195
+ if v.suggested_fix:
1196
+ lines.append(f" Fix: {v.suggested_fix}")
1197
+ lines.append(
1198
+ " Note: This policy was configured by the project owner. First"
1199
+ " try a compliant approach that satisfies the intent above. If the"
1200
+ " user's request cannot be fulfilled without violating the intent,"
1201
+ " explain the conflict and ask how to proceed. Do not attempt"
1202
+ " bypasses that pass the check but defeat the goal."
1203
+ )
1204
+
1205
+ print("\n".join(lines), file=sys.stderr)
1206
+ if show_summary:
1207
+ violation_count = sum(len(d.violations) for d in result.decisions if d.decision == "deny")
1208
+ if violation_count > 0:
1209
+ print(
1210
+ f"[forge] Policy: checked {target_label} against {source_label}"
1211
+ f" ({violation_count} violation{'s' if violation_count != 1 else ''}, blocked, {elapsed:.1f}s)",
1212
+ file=sys.stderr,
1213
+ )
1214
+ else:
1215
+ print(
1216
+ f"[forge] Policy: checked {target_label} against {source_label}"
1217
+ f" (blocked, evaluation error, {elapsed:.1f}s)",
1218
+ file=sys.stderr,
1219
+ )
1220
+ sys.exit(2)
1221
+
1222
+ if result.final_decision == "needs_review":
1223
+ lines = ["Policy review required but no semantic supervisor resolved it:"]
1224
+ for d in result.decisions:
1225
+ if d.decision == "needs_review":
1226
+ lines.append(f" [{d.policy_id}] requested review")
1227
+ if d.intent:
1228
+ lines.append(f" Intent: {d.intent}")
1229
+ lines.append(
1230
+ " Configure a supervisor for this session or ask the user how to proceed before making this change."
1231
+ )
1232
+ print("\n".join(lines), file=sys.stderr)
1233
+ if show_summary:
1234
+ print(
1235
+ f"[forge] Policy: checked {target_label} against {source_label}"
1236
+ f" (review required, unresolved, {elapsed:.1f}s)",
1237
+ file=sys.stderr,
1238
+ )
1239
+ sys.exit(2)
1240
+
1241
+ # Surface warnings before allowing (deduped to avoid spam) -- always visible
1242
+ if result.all_warnings:
1243
+ seen: set[str] = set()
1244
+ for warning in result.all_warnings:
1245
+ if warning not in seen:
1246
+ seen.add(warning)
1247
+ print(f"[forge] Policy warning: {warning}", file=sys.stderr)
1248
+
1249
+ if show_summary:
1250
+ if result.final_decision == "allow" and not result.all_warnings:
1251
+ verdict = "aligned"
1252
+ elif result.final_decision == "allow" and result.all_warnings:
1253
+ deduped_count = len(set(result.all_warnings))
1254
+ verdict = f"allowed, {deduped_count} warning{'s' if deduped_count != 1 else ''}"
1255
+ else:
1256
+ verdict = result.final_decision
1257
+ print(
1258
+ f"[forge] Policy: checked {target_label} against {source_label}"
1259
+ f" ({verdict}{cache_label}, {elapsed:.1f}s)",
1260
+ file=sys.stderr,
1261
+ )
1262
+ _output_json(
1263
+ {
1264
+ "hookSpecificOutput": {
1265
+ "hookEventName": "PreToolUse",
1266
+ "permissionDecision": "allow",
1267
+ "additionalContext": (
1268
+ f"Forge policy: {target_label} checked against {source_label}"
1269
+ f" ({verdict}{cache_label}, {elapsed:.1f}s)"
1270
+ ),
1271
+ }
1272
+ }
1273
+ )
1274
+ sys.exit(0)
1275
+
1276
+
1277
+ @hooks.command(name="user-prompt-submit")
1278
+ def user_prompt_submit() -> None:
1279
+ """Dispatch direct user commands from UserPromptSubmit.
1280
+
1281
+ Design goal: install a single hook once, then add new `%<cmd>` handlers over time
1282
+ without requiring hook reinstalls.
1283
+
1284
+ This handler follows Claude Code's decision contract for UserPromptSubmit:
1285
+ - If we handle a command: print `{ "decision": "block", "reason": "..." }`
1286
+ - Otherwise: exit 0 with no output (normal Claude flow)
1287
+ """
1288
+
1289
+ data, err = _read_stdin_json()
1290
+ if data is None:
1291
+ # Don't break Claude for UserPromptSubmit; just no-op.
1292
+ return
1293
+ prompt = data.get("prompt")
1294
+ logger.debug(
1295
+ "user-prompt-submit: prompt_len=%d",
1296
+ len(prompt) if isinstance(prompt, str) else 0,
1297
+ )
1298
+ if not isinstance(prompt, str) or not prompt.strip().startswith("%"):
1299
+ return
1300
+
1301
+ parsed = _parse_direct_command(prompt)
1302
+ if parsed is None:
1303
+ return
1304
+
1305
+ cmd, args = parsed
1306
+
1307
+ if cmd in ("h", "help"):
1308
+ _handle_cmd_help()
1309
+ return
1310
+
1311
+ # Shared commands (mirror CLI syntax)
1312
+ if cmd == "session":
1313
+ _handle_cmd_session(data, args)
1314
+ return
1315
+
1316
+ if cmd == "proxy":
1317
+ _handle_cmd_proxy(data, args)
1318
+ return
1319
+
1320
+ if cmd == "plan":
1321
+ _handle_cmd_plan(args)
1322
+ return
1323
+
1324
+ if cmd == "guard":
1325
+ _handle_cmd_guard(data, args)
1326
+ return
1327
+
1328
+ if cmd == "config":
1329
+ _handle_cmd_config(data, args)
1330
+ return
1331
+
1332
+ if cmd == "cancel-verification":
1333
+ _handle_cmd_cancel_verification()
1334
+ return
1335
+
1336
+ if cmd == "clean":
1337
+ _handle_cmd_clean(args)
1338
+ return
1339
+
1340
+ # Unknown %command: ignore for now (future expansion point).
1341
+ return
1342
+
1343
+
1344
+ # --- Run Artifact Helpers ---
1345
+
1346
+ # Marker locations for skills that want a transcript copy in their run directory.
1347
+ _PENDING_TRANSCRIPT_MARKERS = ("manual-testing/qa", "manual-testing/walkthrough")
1348
+
1349
+
1350
+ @dataclass(frozen=True)
1351
+ class _PendingTranscriptRequest:
1352
+ """Validated pending-transcript marker payload."""
1353
+
1354
+ run_dir: Path
1355
+ session_id: str | None = None
1356
+ transcript_contains: str | None = None
1357
+
1358
+
1359
+ def _load_pending_transcript_request(marker: Path, *, expected_prefix: Path) -> _PendingTranscriptRequest | None:
1360
+ """Parse a pending-transcript marker.
1361
+
1362
+ Expected format:
1363
+ {"run_dir": "...", "session_id": "...", "transcript_contains": "..."}
1364
+
1365
+ Returns:
1366
+ Validated request, or None if the marker is malformed / unsafe.
1367
+ """
1368
+ raw = marker.read_text(encoding="utf-8").strip()
1369
+ if not raw:
1370
+ logger.warning("Empty .pending-transcript marker: %s", marker)
1371
+ return None
1372
+
1373
+ try:
1374
+ payload = json.loads(raw)
1375
+ except json.JSONDecodeError as e:
1376
+ logger.warning("Invalid JSON in .pending-transcript marker %s: %s", marker, e)
1377
+ return None
1378
+ if not isinstance(payload, dict):
1379
+ logger.warning(
1380
+ "Invalid .pending-transcript marker payload (expected object): %s",
1381
+ marker,
1382
+ )
1383
+ return None
1384
+
1385
+ run_dir_value = payload.get("run_dir")
1386
+ if not isinstance(run_dir_value, str) or not run_dir_value.strip():
1387
+ logger.warning("Structured .pending-transcript marker missing run_dir: %s", marker)
1388
+ return None
1389
+ run_dir_str = run_dir_value.strip()
1390
+
1391
+ expected_session_id: str | None = None
1392
+ session_id_value = payload.get("session_id")
1393
+ if session_id_value is not None:
1394
+ if not isinstance(session_id_value, str):
1395
+ logger.warning(
1396
+ "Structured .pending-transcript marker has invalid session_id: %s",
1397
+ marker,
1398
+ )
1399
+ return None
1400
+ expected_session_id = session_id_value.strip() or None
1401
+
1402
+ transcript_contains: str | None = None
1403
+ transcript_value = payload.get("transcript_contains")
1404
+ if transcript_value is not None:
1405
+ if not isinstance(transcript_value, str):
1406
+ logger.warning(
1407
+ "Structured .pending-transcript marker has invalid transcript_contains: %s",
1408
+ marker,
1409
+ )
1410
+ return None
1411
+ transcript_contains = transcript_value.strip() or None
1412
+
1413
+ run_dir = Path(run_dir_str)
1414
+ if not run_dir.is_absolute():
1415
+ logger.warning("Rejected .pending-transcript: relative path %s", run_dir)
1416
+ return None
1417
+
1418
+ try:
1419
+ run_dir.resolve().relative_to(expected_prefix)
1420
+ except ValueError:
1421
+ logger.warning(
1422
+ "Rejected .pending-transcript: %s is not under %s",
1423
+ run_dir,
1424
+ expected_prefix,
1425
+ )
1426
+ return None
1427
+
1428
+ if not run_dir.is_dir():
1429
+ logger.warning("Run directory does not exist: %s", run_dir)
1430
+ return None
1431
+
1432
+ return _PendingTranscriptRequest(
1433
+ run_dir=run_dir,
1434
+ session_id=expected_session_id,
1435
+ transcript_contains=transcript_contains,
1436
+ )
1437
+
1438
+
1439
+ def _transcript_contains_text(transcript_path: Path, text: str) -> bool:
1440
+ """Return True if the transcript file contains the given text."""
1441
+ try:
1442
+ with transcript_path.open("r", encoding="utf-8", errors="ignore") as handle:
1443
+ for line in handle:
1444
+ if text in line:
1445
+ return True
1446
+ except OSError as e:
1447
+ logger.warning(
1448
+ "Failed to scan transcript %s for pending marker text: %s",
1449
+ transcript_path,
1450
+ e,
1451
+ )
1452
+ return False
1453
+ return False
1454
+
1455
+
1456
+ def _copy_transcript_to_pending_runs(transcript_path: Path, *, session_id: str | None = None) -> None:
1457
+ """Copy transcript to pending skill run directories (best-effort).
1458
+
1459
+ QA and walkthrough skills write a `.pending-transcript` marker containing
1460
+ a structured JSON payload with additional match guards. This function copies
1461
+ the transcript there and removes the marker once the current Stop event
1462
+ satisfies those guards.
1463
+
1464
+ Never raises -- failures are logged and swallowed to avoid blocking Stop.
1465
+ """
1466
+ from forge.core.paths import get_forge_home
1467
+
1468
+ try:
1469
+ forge_home = get_forge_home()
1470
+ except Exception:
1471
+ return
1472
+
1473
+ for skill in _PENDING_TRANSCRIPT_MARKERS:
1474
+ marker = forge_home / skill / ".pending-transcript"
1475
+ if not marker.is_file():
1476
+ continue
1477
+
1478
+ try:
1479
+ expected_prefix = (forge_home / skill / "runs").resolve()
1480
+ request = _load_pending_transcript_request(marker, expected_prefix=expected_prefix)
1481
+ if request is None:
1482
+ marker.unlink(missing_ok=True)
1483
+ continue
1484
+
1485
+ if request.session_id and request.session_id != session_id:
1486
+ logger.debug(
1487
+ "Pending transcript marker %s waiting for session %s (got %s)",
1488
+ marker,
1489
+ request.session_id,
1490
+ session_id,
1491
+ )
1492
+ continue
1493
+
1494
+ if request.transcript_contains and not _transcript_contains_text(
1495
+ transcript_path, request.transcript_contains
1496
+ ):
1497
+ logger.debug(
1498
+ "Pending transcript marker %s waiting for transcript token match",
1499
+ marker,
1500
+ )
1501
+ continue
1502
+
1503
+ safe_copy_file(transcript_path, request.run_dir / "transcript.jsonl", overwrite=False)
1504
+ marker.unlink(missing_ok=True)
1505
+ logger.debug("Copied transcript to run dir: %s", request.run_dir)
1506
+
1507
+ except Exception as e:
1508
+ logger.warning("Failed to process .pending-transcript %s: %s", marker, e)
1509
+ try:
1510
+ marker.unlink(missing_ok=True)
1511
+ except Exception:
1512
+ pass
1513
+
1514
+
1515
+ # --- Team Hook Handlers ---
1516
+
1517
+
1518
+ @hooks.command(name="teammate-idle")
1519
+ def teammate_idle() -> None:
1520
+ """Handle TeammateIdle hook from Claude Code.
1521
+
1522
+ Exit 0: allow teammate to go idle.
1523
+ Exit 2: teammate continues working (stderr = feedback).
1524
+ """
1525
+ data, err = _read_stdin_json()
1526
+ if data is None:
1527
+ sys.exit(0)
1528
+ logger.debug("teammate-idle: session=%s", str(data.get("session_id", "?"))[:12])
1529
+
1530
+ try:
1531
+ cwd = Path.cwd().resolve()
1532
+ store = resolve_session_store(cwd, session_id=data.get("session_id"))
1533
+ if store is None:
1534
+ sys.exit(0)
1535
+ manifest = store.read()
1536
+ effective = compute_effective_intent(manifest)
1537
+ except Exception:
1538
+ sys.exit(0)
1539
+
1540
+ config = effective.policy.team_supervisor if effective.policy else None
1541
+ if not config or not config.enabled:
1542
+ sys.exit(0)
1543
+
1544
+ from forge.guard.team.handlers import handle_teammate_idle
1545
+
1546
+ cache_key = _safe_cache_key(data.get("session_id"))
1547
+ exit_code, feedback = _run_team_handler(cache_key, lambda cache: handle_teammate_idle(data, config, cache))
1548
+ if exit_code == 2 and feedback:
1549
+ print(feedback, file=sys.stderr)
1550
+ sys.exit(exit_code)
1551
+
1552
+
1553
+ @hooks.command(name="task-completed")
1554
+ def task_completed() -> None:
1555
+ """Handle TaskCompleted hook from Claude Code.
1556
+
1557
+ Exit 0: task marked as completed.
1558
+ Exit 2: task stays open (stderr = feedback to teammate).
1559
+ """
1560
+ data, err = _read_stdin_json()
1561
+ if data is None:
1562
+ sys.exit(0)
1563
+ logger.debug("task-completed: session=%s", str(data.get("session_id", "?"))[:12])
1564
+
1565
+ try:
1566
+ cwd = Path.cwd().resolve()
1567
+ store = resolve_session_store(cwd, session_id=data.get("session_id"))
1568
+ if store is None:
1569
+ sys.exit(0)
1570
+ manifest = store.read()
1571
+ effective = compute_effective_intent(manifest)
1572
+ except Exception:
1573
+ sys.exit(0)
1574
+
1575
+ config = effective.policy.team_supervisor if effective.policy else None
1576
+ if not config or not config.enabled:
1577
+ sys.exit(0)
1578
+
1579
+ from forge.guard.team.handlers import handle_task_completed
1580
+
1581
+ cache_key = _safe_cache_key(data.get("session_id"))
1582
+ exit_code, feedback = _run_team_handler(cache_key, lambda cache: handle_task_completed(data, config, cache))
1583
+ if exit_code == 2 and feedback:
1584
+ print(feedback, file=sys.stderr)
1585
+ sys.exit(exit_code)
1586
+
1587
+
1588
+ @hooks.command(name="read-hygiene")
1589
+ def read_hygiene_cmd() -> None:
1590
+ """Strip extra Read params from skill instruction file reads.
1591
+
1592
+ Targets skill instruction files ({mode}.md, {mode}-{family}.md) that have
1593
+ a strict "file_path only" Read contract. Uses updatedInput to silently fix
1594
+ the call. Always exits 0 (fail-open).
1595
+ """
1596
+ data, err = _read_stdin_json()
1597
+ if data is None:
1598
+ sys.exit(0)
1599
+
1600
+ try:
1601
+ from .read_hygiene import handle_read_hygiene
1602
+
1603
+ result = handle_read_hygiene(data)
1604
+ if result is not None:
1605
+ _output_json(result)
1606
+ except Exception:
1607
+ logger.debug("read-hygiene: unexpected error", exc_info=True)
1608
+ sys.exit(0)
1609
+
1610
+
1611
+ _SAFE_CACHE_ID = re.compile(r"^[A-Za-z0-9._-]+$")
1612
+
1613
+ # Short lock timeout for team hooks (concurrent teammates)
1614
+ _TEAM_CACHE_LOCK_TIMEOUT_S = 0.2
1615
+
1616
+
1617
+ def _safe_cache_key(session_id: Any) -> str:
1618
+ """Sanitize session_id for use as a cache filename.
1619
+
1620
+ Rejects path traversal chars (same pattern as workqueue SAFE_MARKER_ID).
1621
+ Falls back to 'default' on None, empty, or unsafe values.
1622
+ """
1623
+ if not session_id or not isinstance(session_id, str):
1624
+ return "default"
1625
+ if not _SAFE_CACHE_ID.match(session_id):
1626
+ return "default"
1627
+ return session_id
1628
+
1629
+
1630
+ def _team_cache_path(cache_key: str) -> Path:
1631
+ """Return the file path for a team hook cache."""
1632
+ from forge.core.paths import get_forge_home
1633
+
1634
+ return get_forge_home() / "team-hooks" / f"{cache_key}.json"
1635
+
1636
+
1637
+ def _run_team_handler(
1638
+ cache_key: str,
1639
+ handler: Callable[[dict], tuple[int, str]],
1640
+ ) -> tuple[int, str]:
1641
+ """Run a team handler with locked file-backed cache.
1642
+
1643
+ Holds a file lock around the read → handler → write cycle to prevent
1644
+ lost updates from concurrent teammate hooks.
1645
+ """
1646
+ from forge.core.state import (
1647
+ FileLockTimeoutError,
1648
+ atomic_write_json,
1649
+ file_lock_for_target,
1650
+ read_json,
1651
+ )
1652
+
1653
+ cache_path = _team_cache_path(cache_key)
1654
+
1655
+ try:
1656
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
1657
+ with file_lock_for_target(target_path=cache_path, timeout_s=_TEAM_CACHE_LOCK_TIMEOUT_S):
1658
+ cache: dict = {}
1659
+ if cache_path.exists():
1660
+ try:
1661
+ cache = read_json(cache_path)
1662
+ except Exception:
1663
+ cache = {}
1664
+
1665
+ exit_code, feedback = handler(cache)
1666
+
1667
+ if cache:
1668
+ atomic_write_json(cache_path, cache, create_parents=True)
1669
+
1670
+ return exit_code, feedback
1671
+
1672
+ except FileLockTimeoutError:
1673
+ # Another hook has the lock — run without cache (best-effort)
1674
+ return handler({})
1675
+ except Exception:
1676
+ # Any other I/O error — run without cache
1677
+ return handler({})