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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- 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
|