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,370 @@
1
+ """Verification policy logic for the Stop hook (Ralph-Wiggum pattern)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from forge.core.state import now_iso, parse_iso
12
+ from forge.session import SessionStore, set_override
13
+ from forge.session.effective import compute_effective_intent
14
+ from forge.session.models import SessionState, VerificationConfig, VerificationConfirmed
15
+ from forge.session.store import HOOK_LOCK_TIMEOUT_S
16
+
17
+
18
+ def _check_completion_promise(ver: "VerificationConfig", transcript_path: Path) -> tuple[bool | None, str | None]:
19
+ """Check if promise appears on standalone line in last assistant message.
20
+
21
+ Returns:
22
+ (True, None): Verification passed
23
+ (False, error): Verification failed
24
+ (None, None): Skip (misconfiguration - no persistence needed)
25
+ """
26
+ if not ver.promise:
27
+ return (None, None) # No promise configured = skip
28
+
29
+ if "\n" in ver.promise:
30
+ return (None, None) # Multi-line promises not supported = skip
31
+
32
+ last_text = _get_last_assistant_text_for_verification(transcript_path)
33
+ promise_stripped = ver.promise.strip()
34
+
35
+ if last_text is not None:
36
+ for line in last_text.splitlines():
37
+ if line.strip() == promise_stripped:
38
+ return (True, None) # Passed
39
+
40
+ return (False, f"Promise not found: {ver.promise}")
41
+
42
+
43
+ def _check_test_suite(ver: "VerificationConfig") -> tuple[bool | None, str | None]:
44
+ """Run test suite and return (passed, error_message).
45
+
46
+ Command is fixed: ["uv", "run", "pytest"]
47
+ No shell=True, no user-configurable command.
48
+
49
+ Returns:
50
+ (True, None): Tests passed
51
+ (False, error): Tests failed
52
+ (None, None): Skip (infrastructure issue - no persistence needed)
53
+ """
54
+ import subprocess
55
+
56
+ cmd = ["uv", "run", "pytest"]
57
+ try:
58
+ result = subprocess.run(
59
+ cmd,
60
+ capture_output=True,
61
+ timeout=ver.test_timeout_seconds,
62
+ cwd=Path.cwd(),
63
+ )
64
+ if result.returncode == 0:
65
+ return (True, None)
66
+ else:
67
+ # Include stderr snippet for debugging
68
+ stderr_snippet = result.stderr.decode("utf-8", errors="replace")[:200]
69
+ return (False, f"Tests failed (exit {result.returncode}): {stderr_snippet}")
70
+ except subprocess.TimeoutExpired:
71
+ return (False, f"timeout: {ver.test_timeout_seconds} seconds")
72
+ except FileNotFoundError:
73
+ # uv not found = misconfiguration, skip with warning (same as missing promise)
74
+ click.echo("Warning: uv not found - skipping test_suite verification", err=True)
75
+ return (None, None)
76
+ except Exception as e:
77
+ # Other errors = fail-open with warning
78
+ click.echo(f"Warning: test_suite execution error: {e}", err=True)
79
+ return (None, None)
80
+
81
+
82
+ def _run_verification_check(
83
+ *,
84
+ store: SessionStore,
85
+ manifest: SessionState,
86
+ transcript_path: Path,
87
+ ) -> tuple[bool, str | None]:
88
+ """Run verification check on Stop (Ralph-Wiggum pattern).
89
+
90
+ Supports two verification types:
91
+ - completion_promise: Check if last assistant message contains expected promise
92
+ - test_suite: Run `uv run pytest` and check exit code
93
+
94
+ Both types share escape hatch logic (max_iterations, max_minutes, bypass).
95
+
96
+ Args:
97
+ store: SessionStore for persisting verification state.
98
+ manifest: Current session manifest.
99
+ transcript_path: Path to the transcript file (for completion_promise type).
100
+
101
+ Returns:
102
+ Tuple of (should_allow_stop, block_message_or_none).
103
+ If should_allow_stop is False, block_message contains the stderr message.
104
+ """
105
+ from datetime import UTC, datetime
106
+
107
+ try:
108
+ effective = compute_effective_intent(manifest)
109
+ except Exception as e:
110
+ print(
111
+ f"[forge] Verification check: cannot compute effective intent: {e}",
112
+ file=sys.stderr,
113
+ )
114
+ return (True, None)
115
+
116
+ ver = effective.verification
117
+ if ver is None:
118
+ return (True, None)
119
+
120
+ if ver.bypass:
121
+ return (True, None)
122
+
123
+ if ver.on_incomplete == "allow": # applies to both verification types
124
+ return (True, None)
125
+
126
+ if ver.type == "test_suite":
127
+ passed, check_error = _check_test_suite(ver)
128
+ elif ver.type == "completion_promise":
129
+ passed, check_error = _check_completion_promise(ver, transcript_path)
130
+ else:
131
+ # Unknown verification type = skip
132
+ return (True, None)
133
+
134
+ # passed=None means misconfiguration/infra issue; skip without persisting state
135
+ if passed is None:
136
+ return (True, None)
137
+
138
+ # Persist verification state
139
+ def _persist_verification(
140
+ m: object,
141
+ *,
142
+ result: str,
143
+ error: str | None = None,
144
+ increment_iterations: bool = False,
145
+ set_started_at: bool = False,
146
+ auto_bypass: bool = False,
147
+ ) -> None:
148
+ if not isinstance(m, SessionState):
149
+ return
150
+
151
+ if m.confirmed.verification is None:
152
+ m.confirmed.verification = VerificationConfirmed()
153
+
154
+ m.confirmed.verification.last_result = result
155
+ m.confirmed.verification.last_error = error[:200] if error else None
156
+
157
+ if set_started_at and m.confirmed.verification.started_at is None:
158
+ m.confirmed.verification.started_at = now_iso()
159
+
160
+ if increment_iterations:
161
+ m.confirmed.verification.iterations += 1
162
+
163
+ if auto_bypass:
164
+ set_override(m.overrides, "verification.bypass", True)
165
+
166
+ m.confirmed.confirmed_at = now_iso()
167
+ m.confirmed.confirmed_by = "hook:stop:verification"
168
+
169
+ if passed:
170
+ try:
171
+ store.update(
172
+ timeout_s=HOOK_LOCK_TIMEOUT_S,
173
+ mutate=lambda m: _persist_verification(m, result="passed"),
174
+ )
175
+ except Exception as e:
176
+ print(f"[forge] Verification state persistence failed: {e}", file=sys.stderr)
177
+ return (True, None)
178
+
179
+ if ver.on_incomplete == "warn":
180
+ try:
181
+ store.update(
182
+ timeout_s=HOOK_LOCK_TIMEOUT_S,
183
+ mutate=lambda m: _persist_verification(m, result="warned", error=check_error),
184
+ )
185
+ except Exception as e:
186
+ print(f"[forge] Verification state persistence failed: {e}", file=sys.stderr)
187
+ click.echo(
188
+ f"Warning: Verification incomplete - {check_error}",
189
+ err=True,
190
+ )
191
+ return (True, None)
192
+
193
+ # on_incomplete == "block" - check escape hatches before blocking
194
+ current_iterations = 0
195
+ started_at: str | None = None
196
+ if manifest.confirmed.verification:
197
+ current_iterations = manifest.confirmed.verification.iterations
198
+ started_at = manifest.confirmed.verification.started_at
199
+
200
+ # current_iterations + 1 is the count after this block executes
201
+ if current_iterations + 1 > ver.max_iterations:
202
+ try:
203
+ store.update(
204
+ timeout_s=HOOK_LOCK_TIMEOUT_S,
205
+ mutate=lambda m: _persist_verification(
206
+ m,
207
+ result="max_iterations",
208
+ error=f"Exceeded {ver.max_iterations} iterations",
209
+ auto_bypass=True,
210
+ ),
211
+ )
212
+ except Exception as e:
213
+ print(f"[forge] Verification state persistence failed: {e}", file=sys.stderr)
214
+ click.echo(
215
+ f"Verification auto-bypassed: exceeded max_iterations ({ver.max_iterations}).",
216
+ err=True,
217
+ )
218
+ return (True, None)
219
+
220
+ if ver.max_minutes is not None and started_at is not None:
221
+ try:
222
+ start_dt = parse_iso(started_at)
223
+ now_dt = datetime.now(UTC)
224
+ elapsed_minutes = (now_dt - start_dt).total_seconds() / 60
225
+ if elapsed_minutes > ver.max_minutes:
226
+ store.update(
227
+ timeout_s=HOOK_LOCK_TIMEOUT_S,
228
+ mutate=lambda m: _persist_verification(
229
+ m,
230
+ result="max_minutes",
231
+ error=f"Exceeded {ver.max_minutes} minutes",
232
+ auto_bypass=True,
233
+ ),
234
+ )
235
+ click.echo(
236
+ f"Verification auto-bypassed: exceeded max_minutes ({ver.max_minutes}).",
237
+ err=True,
238
+ )
239
+ return (True, None)
240
+ except Exception as e:
241
+ print(f"[forge] Verification time check failed: {e}", file=sys.stderr)
242
+
243
+ try:
244
+ store.update(
245
+ timeout_s=HOOK_LOCK_TIMEOUT_S,
246
+ mutate=lambda m: _persist_verification(
247
+ m,
248
+ result="failed",
249
+ error=check_error,
250
+ increment_iterations=True,
251
+ set_started_at=True,
252
+ ),
253
+ )
254
+ except Exception as e:
255
+ print(f"[forge] Verification state persistence failed: {e}", file=sys.stderr)
256
+
257
+ if ver.re_inject_prompt:
258
+ block_message = ver.re_inject_prompt
259
+ elif ver.type == "test_suite":
260
+ block_message = (
261
+ f"Verification incomplete: tests did not pass.\n"
262
+ f"Error: {check_error}\n\n"
263
+ f"Fix the failing tests and try again.\n"
264
+ f"Escape hatches:\n"
265
+ f" - Type: %cancel-verification\n"
266
+ f" - Or run: forge session set verification.bypass true"
267
+ )
268
+ else:
269
+ block_message = (
270
+ f"Verification incomplete: expected completion promise not found.\n"
271
+ f"Expected: {ver.promise}\n"
272
+ f"(must appear on its own line in the assistant's response)\n\n"
273
+ f"Continue working and output the completion promise when done.\n"
274
+ f"Escape hatches:\n"
275
+ f" - Type: %cancel-verification\n"
276
+ f" - Or run: forge session set verification.bypass true"
277
+ )
278
+
279
+ return (False, block_message)
280
+
281
+
282
+ def _get_last_assistant_text_for_verification(
283
+ transcript_path: str | Path,
284
+ ) -> str | None:
285
+ """Extract text from the most recent assistant message for verification.
286
+
287
+ This is used by the verification policy to check if the completion promise
288
+ is present in the last assistant response.
289
+
290
+ Uses timestamp-based ordering to get the truly last assistant message.
291
+
292
+ Supports two transcript formats:
293
+ 1. requestId/message.role format (newer Claude Code versions)
294
+ 2. entry.type == "assistant" format (older format)
295
+
296
+ Returns:
297
+ The text content of the last assistant message, or None if not found.
298
+ """
299
+ path = Path(transcript_path) if isinstance(transcript_path, str) else transcript_path
300
+
301
+ if not path.is_file():
302
+ return None
303
+
304
+ latest_text: str | None = None
305
+ latest_ts: str = ""
306
+
307
+ try:
308
+ with path.open(encoding="utf-8") as f:
309
+ for line in f:
310
+ line = line.strip()
311
+ if not line:
312
+ continue
313
+ try:
314
+ entry = json.loads(line)
315
+ except json.JSONDecodeError:
316
+ continue
317
+
318
+ # Format 1: requestId/message.role format
319
+ message = entry.get("message")
320
+ if isinstance(message, dict) and message.get("role") == "assistant":
321
+ ts = entry.get("timestamp", "")
322
+ if not isinstance(ts, str):
323
+ ts = ""
324
+
325
+ content = message.get("content")
326
+ if isinstance(content, list):
327
+ texts: list[str] = []
328
+ for block in content:
329
+ if isinstance(block, dict):
330
+ t = block.get("text")
331
+ if isinstance(t, str) and t:
332
+ texts.append(t)
333
+ if texts:
334
+ joined = "".join(texts)
335
+ if ts >= latest_ts:
336
+ latest_ts = ts
337
+ latest_text = joined
338
+ continue
339
+
340
+ # Format 2: entry.type == "assistant" format
341
+ if entry.get("type") == "assistant":
342
+ ts = entry.get("timestamp", "")
343
+ if not isinstance(ts, str):
344
+ ts = ""
345
+
346
+ message = entry.get("message")
347
+ if not isinstance(message, dict):
348
+ continue
349
+
350
+ content = message.get("content")
351
+ if not isinstance(content, list):
352
+ continue
353
+
354
+ texts = []
355
+ for block in content:
356
+ if isinstance(block, dict):
357
+ t = block.get("text")
358
+ if isinstance(t, str) and t:
359
+ texts.append(t)
360
+
361
+ if texts:
362
+ joined = "".join(texts)
363
+ if ts >= latest_ts:
364
+ latest_ts = ts
365
+ latest_text = joined
366
+
367
+ except Exception:
368
+ pass
369
+
370
+ return latest_text